Compare commits

...

26 Commits

Author SHA1 Message Date
n8n-assistant[bot]
4e52729330
🚀 Release 2.19.5 (#30011)
Co-authored-by: konstantintieber <46342664+konstantintieber@users.noreply.github.com>
2026-05-07 13:31:21 +00:00
n8n-assistant[bot]
7e94cf8081
chore: Bump Axios, hono, vm2 and fast-xml-parser (backport to release-candidate/2.19.x) (#30001)
Co-authored-by: aikido-autofix[bot] <119856028+aikido-autofix[bot]@users.noreply.github.com>
Co-authored-by: Matsuuu <huhta.matias@gmail.com>
2026-05-07 15:55:42 +03:00
n8n-assistant[bot]
17fa22b416
fix(core): Simple-git update broke https connection (backport to release-candidate/2.19.x) (#30004)
Co-authored-by: Konstantin Tieber <46342664+konstantintieber@users.noreply.github.com>
2026-05-07 12:52:53 +00:00
n8n-assistant[bot]
103b7c8787
🚀 Release 2.19.4 (#29925)
Co-authored-by: konstantintieber <46342664+konstantintieber@users.noreply.github.com>
2026-05-06 17:54:44 +00:00
n8n-assistant[bot]
4b73a25077
fix(core): Allow GIT_SSH_COMMAND in simple-git after 3.36.0 upgrade (backport to release-candidate/2.19.x) (#29921)
Co-authored-by: Daria <daria.staferova@n8n.io>
2026-05-06 17:10:53 +00:00
n8n-assistant[bot]
c967cb23d4
fix(core): Add support for context establishment hooks in webhook mode (backport to release-candidate/2.19.x) (#29901)
Co-authored-by: Andreas Fitzek <andreas.fitzek@n8n.io>
2026-05-06 14:02:46 +00:00
n8n-assistant[bot]
492a0b7c01
fix: Add fully dynamic disclaimer to Quick Connect offer (backport to release-candidate/2.19.x) (#29865)
Co-authored-by: Bernhard Wittmann <bernhard.wittmann@n8n.io>
2026-05-06 12:44:37 +00:00
n8n-release-tag-merge[bot]
b6eb793a54 Merge tag 'n8n@2.19.3' into release-candidate/2.19.x 2026-05-06 11:00:23 +00:00
n8n-assistant[bot]
f6561da6fe
🚀 Release 2.19.3 (#29868)
Co-authored-by: Matsuuu <16068444+Matsuuu@users.noreply.github.com>
2026-05-06 10:31:13 +00:00
n8n-assistant[bot]
b1f220013f
fix(Snowflake Node): Fix issue with Insert and Update operations not working (backport to release-candidate/2.19.x) (#29811)
Co-authored-by: Jon <jonathan.bennetts@gmail.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 11:21:04 +01:00
n8n-assistant[bot]
8a9f427e22
refactor(core): Extract leader election client and improve robustness (no-changelog) (backport to release-candidate/2.19.x) (#29805)
Co-authored-by: Tomi Turtiainen <10324676+tomi@users.noreply.github.com>
2026-05-06 11:33:27 +03:00
n8n-assistant[bot]
d874ec26eb
chore: Bump simple-git to 3.36.0 (backport to release-candidate/2.19.x) (#29836)
Co-authored-by: Matsu <huhta.matias@gmail.com>
2026-05-06 06:39:16 +00:00
n8n-assistant[bot]
616f255e2d
fix: Restore broken stdlib calls in Python Code node (backport to release-candidate/2.19.x) (#29782)
Some checks failed
CI: Python / Checks (push) Has been cancelled
Co-authored-by: Iván Ovejero <ivov.src@gmail.com>
2026-05-05 15:28:07 +02:00
n8n-assistant[bot]
5352606c74
fix(core): Add file path validation to localFile source (backport to release-candidate/2.19.x) (#29789)
Co-authored-by: Mike Repeć <mikerepec@pm.me>
2026-05-05 15:19:34 +02:00
Sandra Zollner
ddad28ac95
Backport 29481 to release candidate/2.19.x (#29763) 2026-05-05 11:34:36 +02:00
n8n-assistant[bot]
cd4a3f5795
fix(core): Acquire expression isolate for dynamic node parameter requests (backport to release-candidate/2.19.x) (#29711)
Co-authored-by: Alexander Gekov <40495748+alexander-gekov@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-04 19:02:22 +03:00
n8n-assistant[bot]
fa798fb379
fix(core): Show AI Builder draft workflows in workflow list (backport to release-candidate/2.19.x) (#29678)
Co-authored-by: Albert Alises <albert.alises@gmail.com>
2026-05-04 12:54:16 +02:00
n8n-assistant[bot]
8630712455
🚀 Release 2.19.2 (#29612)
Co-authored-by: Matsuuu <16068444+Matsuuu@users.noreply.github.com>
2026-05-01 09:07:36 +00:00
n8n-assistant[bot]
3907b0e081
fix(core): Restore peer project discovery in share dropdowns (backport to release-candidate/2.19.x) (#29564)
Co-authored-by: Andreas Fitzek <andreas.fitzek@n8n.io>
2026-04-30 10:34:12 +00:00
n8n-assistant[bot]
f443a58fde
fix(core): Persist execution context before writing to db (backport to release-candidate/2.19.x) (#29529)
Co-authored-by: phyllis-noester <102315132+phyllis-noester@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 11:02:43 +02:00
n8n-release-tag-merge[bot]
d6e4645ab3 Merge tag 'n8n@2.19.1' into release-candidate/2.19.x 2026-04-29 16:14:51 +00:00
n8n-assistant[bot]
2aedc53d8f
🚀 Release 2.19.1 (#29457)
Co-authored-by: mfsiega <93014743+mfsiega@users.noreply.github.com>
2026-04-29 14:30:42 +00:00
n8n-assistant[bot]
e8db9111ce
fix(core): Respect global admin scope when listing favorites (backport to release-candidate/2.19.x) (#29517)
Co-authored-by: Charlie Kolb <charlie@n8n.io>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 16:22:31 +02:00
n8n-assistant[bot]
020293d8fc
fix(editor): Remove clipping for focus panel textarea (backport to release-candidate/2.19.x) (#29510)
Co-authored-by: Rob Hough <robhough180@gmail.com>
2026-04-29 13:54:50 +00:00
n8n-assistant[bot]
8333c0343b
feat(core): Warn and skip on duplicate scheduled executions (backport to release-candidate/2.19.x) (#29503)
Co-authored-by: mfsiega <93014743+mfsiega@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 15:36:57 +02:00
n8n-assistant[bot]
41ade461ac
fix(editor): Keep publish actions menu enabled for published workflows (backport to release-candidate/2.19.x) (#29411)
Co-authored-by: Rob Hough <robhough180@gmail.com>
2026-04-28 18:34:04 +01:00
109 changed files with 3477 additions and 690 deletions

View File

@ -1,3 +1,57 @@
## [2.19.5](https://github.com/n8n-io/n8n/compare/n8n@2.19.4...n8n@2.19.5) (2026-05-07)
### Bug Fixes
* **core:** Simple-git update broke https connection ([#30004](https://github.com/n8n-io/n8n/issues/30004)) ([17fa22b](https://github.com/n8n-io/n8n/commit/17fa22b4169e74bded8bedafe19bce11944bd17b))
## [2.19.4](https://github.com/n8n-io/n8n/compare/n8n@2.19.3...n8n@2.19.4) (2026-05-06)
### Bug Fixes
* Add fully dynamic disclaimer to Quick Connect offer ([#29865](https://github.com/n8n-io/n8n/issues/29865)) ([492a0b7](https://github.com/n8n-io/n8n/commit/492a0b7c01c5552685e859c432fa92b82d3b300c))
* **core:** Add support for context establishment hooks in webhook mode ([#29901](https://github.com/n8n-io/n8n/issues/29901)) ([c967cb2](https://github.com/n8n-io/n8n/commit/c967cb23d4e6bb349d13f639869e81c46da9dc93))
* **core:** Allow GIT_SSH_COMMAND in simple-git after 3.36.0 upgrade ([#29921](https://github.com/n8n-io/n8n/issues/29921)) ([4b73a25](https://github.com/n8n-io/n8n/commit/4b73a25077af5dec798175ac1998c4cccb106434))
* **Snowflake Node:** Fix issue with Insert and Update operations not working ([#29811](https://github.com/n8n-io/n8n/issues/29811)) ([b1f2200](https://github.com/n8n-io/n8n/commit/b1f220013fdaadf477d31c0bf521aa7b72784768))
## [2.19.3](https://github.com/n8n-io/n8n/compare/n8n@2.19.2...n8n@2.19.3) (2026-05-06)
### Bug Fixes
* **core:** Acquire expression isolate for dynamic node parameter requests ([#29711](https://github.com/n8n-io/n8n/issues/29711)) ([cd4a3f5](https://github.com/n8n-io/n8n/commit/cd4a3f579545736be33921c6f7dd9337165e37dc))
* **core:** Add file path validation to localFile source ([#29789](https://github.com/n8n-io/n8n/issues/29789)) ([5352606](https://github.com/n8n-io/n8n/commit/5352606c74989cfdee36f8890b889a0478c19516))
* **core:** Show AI Builder draft workflows in workflow list ([#29678](https://github.com/n8n-io/n8n/issues/29678)) ([fa798fb](https://github.com/n8n-io/n8n/commit/fa798fb379eabdb24274324e782dad62b01a9514))
* Restore broken stdlib calls in Python Code node ([#29782](https://github.com/n8n-io/n8n/issues/29782)) ([616f255](https://github.com/n8n-io/n8n/commit/616f255e2d4b590ae5ac455612d6e927324c0812))
## [2.19.2](https://github.com/n8n-io/n8n/compare/n8n@2.19.1...n8n@2.19.2) (2026-05-01)
### Bug Fixes
* **core:** Persist execution context before writing to db ([#29529](https://github.com/n8n-io/n8n/issues/29529)) ([f443a58](https://github.com/n8n-io/n8n/commit/f443a58fdec51f02d61d747578dae5624771ee20))
* **core:** Respect global admin scope when listing favorites ([#29517](https://github.com/n8n-io/n8n/issues/29517)) ([e8db911](https://github.com/n8n-io/n8n/commit/e8db9111ce34478576d89407095c0b03f9185159))
* **core:** Restore peer project discovery in share dropdowns ([#29564](https://github.com/n8n-io/n8n/issues/29564)) ([3907b0e](https://github.com/n8n-io/n8n/commit/3907b0e0813d3273cf3632217c0325d86f33ed78))
* **editor:** Remove clipping for focus panel textarea ([#29510](https://github.com/n8n-io/n8n/issues/29510)) ([020293d](https://github.com/n8n-io/n8n/commit/020293d8fca7519fc91750a6a847564e9b109721))
## [2.19.1](https://github.com/n8n-io/n8n/compare/n8n@2.19.0...n8n@2.19.1) (2026-04-29)
### Bug Fixes
* **editor:** Keep publish actions menu enabled for published workflows ([#29411](https://github.com/n8n-io/n8n/issues/29411)) ([41ade46](https://github.com/n8n-io/n8n/commit/41ade461ac78c6304e91e4a90666248727bb5e2f))
### Features
* **core:** Warn and skip on duplicate scheduled executions ([#29503](https://github.com/n8n-io/n8n/issues/29503)) ([8333c03](https://github.com/n8n-io/n8n/commit/8333c0343be04ef3b30dc780612797abdbb2ddfe))
# [2.19.0](https://github.com/n8n-io/n8n/compare/n8n@2.18.0...n8n@2.19.0) (2026-04-28)

View File

@ -1,6 +1,6 @@
{
"name": "n8n-monorepo",
"version": "2.19.0",
"version": "2.19.5",
"private": true,
"engines": {
"node": ">=22.16",
@ -73,7 +73,7 @@
"jest-mock-extended": "^3.0.4",
"lefthook": "^1.7.15",
"license-checker": "^25.0.1",
"nock": "^14.0.1",
"nock": "^14.0.14",
"nodemon": "^3.0.1",
"npm-run-all2": "^7.0.2",
"p-limit": "^3.1.0",
@ -103,7 +103,6 @@
"@mistralai/mistralai": "^1.10.0",
"@n8n/typeorm>@sentry/node": "catalog:sentry",
"@types/node": "^20.17.50",
"axios": "1.15.0",
"chokidar": "4.0.3",
"esbuild": "^0.25.0",
"expr-eval@2.0.2": "npm:expr-eval-fork@3.0.0",
@ -140,7 +139,6 @@
"undici@6": "^6.24.0",
"undici@7": "^7.24.0",
"tar": "^7.5.11",
"hono": "4.12.14",
"ajv@6": "6.14.0",
"ajv@7": "8.18.0",
"ajv@8": "8.18.0",
@ -167,7 +165,9 @@
"@xmldom/xmldom": "0.8.13",
"langsmith": "0.5.19",
"yaml@<=2.8.3": "2.8.3",
"fast-xml-parser": "5.7.0"
"hono": "4.12.16",
"axios": "1.16.0",
"fast-xml-parser": "5.7.2"
},
"patchedDependencies": {
"bull@4.16.4": "patches/bull@4.16.4.patch",

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/ai-node-sdk",
"version": "0.10.0",
"version": "0.10.1",
"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.13.0",
"version": "0.13.1",
"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.19.0",
"version": "1.19.2",
"scripts": {
"clean": "rimraf dist .turbo",
"typecheck": "tsc --noEmit",

View File

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

View File

@ -1,3 +1,9 @@
export type QuickConnectDisclaimer = {
text: string;
linkUrl: string;
linkLabel?: string;
};
type QuickConnectGenericOption = {
packageName: string;
credentialType: string;
@ -5,6 +11,7 @@ type QuickConnectGenericOption = {
quickConnectType: string;
consentText?: string;
consentCheckbox?: string;
disclaimer?: QuickConnectDisclaimer;
config?: never;
};

View File

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

View File

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

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/n8n-benchmark",
"version": "2.7.0",
"version": "2.7.1",
"description": "Cli for running benchmark tests for n8n",
"main": "dist/index",
"scripts": {

View File

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

View File

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

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/config",
"version": "2.18.0",
"version": "2.18.1",
"scripts": {
"clean": "rimraf dist .turbo",
"dev": "pnpm watch",

View File

@ -13,4 +13,8 @@ export class MultiMainSetupConfig {
/** Interval in seconds between leader eligibility checks in multi-main setup. */
@Env('N8N_MULTI_MAIN_SETUP_CHECK_INTERVAL')
interval: number = 3;
/** Whether to use the new leader election implementation (Lua-script based). */
@Env('N8N_NEW_LEADER_ELECTION_IMPLEMENTATION')
newLeaderElection: boolean = false;
}

View File

@ -77,6 +77,7 @@ export { ChatTriggerConfig } from './configs/chat-trigger.config';
export { InstanceAiConfig } from './configs/instance-ai.config';
export { ExpressionEngineConfig } from './configs/expression-engine.config';
export { PasswordConfig } from './configs/password.config';
export { RedisConfig } from './configs/redis.config';
const protocolSchema = z.enum(['http', 'https']);

View File

@ -367,6 +367,7 @@ describe('GlobalConfig', () => {
enabled: false,
ttl: 10,
interval: 3,
newLeaderElection: false,
},
generic: {
timezone: 'America/New_York',

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/create-node",
"version": "0.28.0",
"version": "0.28.1",
"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.19.0",
"version": "1.19.4",
"scripts": {
"clean": "rimraf dist .turbo",
"dev": "pnpm watch",

View File

@ -2,7 +2,7 @@ import { GlobalConfig } from '@n8n/config';
import { In, type SelectQueryBuilder } from '@n8n/typeorm';
import { mock } from 'jest-mock-extended';
import { AiBuilderTemporaryWorkflow, WorkflowEntity } from '../../entities';
import { WorkflowEntity } from '../../entities';
import { mockEntityManager } from '../../utils/test-utils/mock-entity-manager';
import { mockInstance } from '../../utils/test-utils/mock-instance';
import { FolderRepository } from '../folder.repository';
@ -32,17 +32,9 @@ describe('WorkflowRepository', () => {
jest.resetAllMocks();
queryBuilder = mock<SelectQueryBuilder<WorkflowEntity>>();
const subQueryBuilder = mock<SelectQueryBuilder<AiBuilderTemporaryWorkflow>>();
subQueryBuilder.select.mockReturnThis();
subQueryBuilder.from.mockReturnThis();
subQueryBuilder.where.mockReturnThis();
subQueryBuilder.getQuery.mockReturnValue(
'(SELECT 1 FROM "ai_builder_temporary_workflow" "aitw" WHERE aitw."workflowId" = workflow.id)',
);
queryBuilder.where.mockReturnThis();
queryBuilder.andWhere.mockReturnThis();
queryBuilder.subQuery.mockReturnValue(subQueryBuilder);
queryBuilder.orWhere.mockReturnThis();
queryBuilder.select.mockReturnThis();
queryBuilder.addSelect.mockReturnThis();
@ -65,18 +57,6 @@ describe('WorkflowRepository', () => {
jest.spyOn(workflowRepository, 'createQueryBuilder').mockReturnValue(queryBuilder);
});
describe('applyAiBuilderTemporaryFilter', () => {
it('hides marker-table rows through a prefix-safe entity subquery', async () => {
await workflowRepository.getMany(['workflow1']);
expect(queryBuilder.subQuery).toHaveBeenCalled();
expect(queryBuilder.subQuery().from).toHaveBeenCalledWith(AiBuilderTemporaryWorkflow, 'aitw');
expect(queryBuilder.andWhere).toHaveBeenCalledWith(
expect.stringContaining('NOT EXISTS (SELECT 1 FROM "ai_builder_temporary_workflow"'),
);
});
});
describe('applyNameFilter', () => {
it('should search for workflows containing all words from the query', async () => {
const workflowIds = ['workflow1'];

View File

@ -53,6 +53,11 @@ export class ProjectRepository extends Repository<Project> {
return await query.getManyAndCount();
}
// Strict semantics: returns only projects the user has a relation to
// (their personal project + projects they are explicitly a member of).
// Do not broaden — peer-personal-project discovery for the share modal lives
// in `getShareableProjectsAndCount` below; conflating the two has regressed
// the share dropdown before (see IAM-591).
async getAccessibleProjectsAndCount(
userId: string,
options: ProjectListOptions,
@ -62,6 +67,40 @@ export class ProjectRepository extends Repository<Project> {
.innerJoin('p.projectRelations', 'pr')
.where('pr.userId = :userId', { userId });
this.applyIdsQueryFilters(idsQuery, options);
return await this.runProjectListByIdsQuery(idsQuery, options);
}
// Wide semantics: returns peer personal projects in addition to projects
// the user has a relation to. Used only by the sharing-discovery endpoint
// (`GET /rest/projects/sharing-candidates`) so the workflow / credential
// share dropdowns can list other users as share targets.
async getShareableProjectsAndCount(
userId: string,
options: ProjectListOptions,
): Promise<[Project[], number]> {
// DISTINCT + LEFT JOIN avoids duplicate rows from the relation join
// while still allowing personal projects with no caller relation to match.
const idsQuery = this.createQueryBuilder('p')
.select('DISTINCT p.id', 'id')
.leftJoin('p.projectRelations', 'pr')
.where(
new Brackets((qb) => {
qb.where('p.type = :personalType', { personalType: 'personal' }).orWhere(
'pr.userId = :userId',
{ userId },
);
}),
);
this.applyIdsQueryFilters(idsQuery, options);
return await this.runProjectListByIdsQuery(idsQuery, options);
}
private applyIdsQueryFilters(
idsQuery: SelectQueryBuilder<Project>,
options: ProjectListOptions,
): void {
if (options.search) {
idsQuery.andWhere('LOWER(p.name) LIKE LOWER(:search)', {
search: `%${options.search}%`,
@ -81,7 +120,12 @@ export class ProjectRepository extends Repository<Project> {
}),
);
}
}
private async runProjectListByIdsQuery(
idsQuery: SelectQueryBuilder<Project>,
options: ProjectListOptions,
): Promise<[Project[], number]> {
const query = this.createQueryBuilder('project')
.leftJoin('project.creator', 'creator')
.where(`project.id IN (${idsQuery.getQuery()})`);

View File

@ -18,7 +18,6 @@ import { SharedWorkflowRepository } from './shared-workflow.repository';
import { WorkflowHistoryRepository } from './workflow-history.repository';
import {
WebhookEntity,
AiBuilderTemporaryWorkflow,
TagEntity,
WorkflowEntity,
WorkflowTagMapping,
@ -883,23 +882,6 @@ export class WorkflowRepository extends Repository<WorkflowEntity> {
this.applyParentFolderFilter(qb, filter);
this.applyNodeTypesFilter(qb, filter);
this.applyAvailableInMCPFilter(qb, filter);
this.applyAiBuilderTemporaryFilter(qb);
}
/**
* Hide workflows the AI builder created and has not yet promoted to the
* main deliverable. The orchestrator clears the marker on the main at
* build-time and reaps the rest at run-finish, but in the window between
* create and reap, marked rows must not surface in the workflows list.
*/
private applyAiBuilderTemporaryFilter(qb: SelectQueryBuilder<WorkflowEntity>): void {
const markerSubquery = qb
.subQuery()
.select('1')
.from(AiBuilderTemporaryWorkflow, 'aitw')
.where('aitw."workflowId" = workflow.id')
.getQuery();
qb.andWhere(`NOT EXISTS ${markerSubquery}`);
}
private applyAvailableInMCPFilter(

View File

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

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/instance-ai",
"version": "1.4.0",
"version": "1.4.1",
"scripts": {
"clean": "rimraf dist .turbo",
"typecheck": "tsc --noEmit",

View File

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

View File

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

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/scan-community-package",
"version": "0.16.0",
"version": "0.16.1",
"description": "Static code analyser for n8n community packages",
"license": "none",
"bin": "scanner/cli.mjs",

View File

@ -1,7 +1,6 @@
import multiprocessing
import traceback
import textwrap
import types
import json
import io
import os
@ -436,7 +435,49 @@ class TaskExecutor:
filtered["__import__"] = TaskExecutor._create_safe_import(security_config)
return types.MappingProxyType(filtered)
class _ImmutableBuiltins:
__slots__ = ()
def __getitem__(self, key):
return filtered[key]
def __contains__(self, key):
return key in filtered
def __iter__(self):
return iter(filtered)
def __len__(self):
return len(filtered)
def keys(self):
return filtered.keys()
def values(self):
return filtered.values()
def items(self):
return filtered.items()
def get(self, key, default=None):
return filtered.get(key, default)
def __getattr__(self, name):
try:
return filtered[name]
except KeyError:
raise AttributeError(name) from None
def __setattr__(self, name, value):
raise AttributeError("read-only")
def __delattr__(self, name):
raise AttributeError("read-only")
def __repr__(self):
return f"ImmutableBuiltins({len(filtered)} keys)"
return _ImmutableBuiltins()
@staticmethod
def _sanitize_sys_modules(security_config: SecurityConfig):

View File

@ -185,6 +185,99 @@ async def test_per_item_with_continue_on_fail(broker, manager):
assert "division by zero" in done_msg["data"]["result"][0]["json"]["error"]
@pytest.mark.asyncio
async def test_per_item_datetime_strptime_works(broker, manager_with_stdlib_wildcard):
task_id = nanoid()
items = [{"json": {"value": "2026-04-23T16:04:28+0000"}}]
code = textwrap.dedent("""
from datetime import datetime
parsed = datetime.strptime(_item['json']['value'], "%Y-%m-%dT%H:%M:%S%z")
return {'parsed': parsed.isoformat()}
""")
task_settings = create_task_settings(code=code, node_mode="per_item", items=items)
await broker.send_task(task_id=task_id, task_settings=task_settings)
done_msg = await wait_for_task_done(broker, task_id)
assert done_msg["data"]["result"] == [
{"json": {"parsed": "2026-04-23T16:04:28+00:00"}, "pairedItem": {"item": 0}}
]
@pytest.mark.asyncio
async def test_per_item_datetime_strftime_works(broker, manager_with_stdlib_wildcard):
task_id = nanoid()
items = [{"json": {}}]
code = textwrap.dedent("""
from datetime import datetime
return {'formatted': datetime(2026, 4, 23).strftime('%Y-%m-%d')}
""")
task_settings = create_task_settings(code=code, node_mode="per_item", items=items)
await broker.send_task(task_id=task_id, task_settings=task_settings)
done_msg = await wait_for_task_done(broker, task_id)
assert done_msg["data"]["result"] == [
{"json": {"formatted": "2026-04-23"}, "pairedItem": {"item": 0}}
]
@pytest.mark.asyncio
async def test_per_item_time_strptime_works(broker, manager_with_stdlib_wildcard):
task_id = nanoid()
items = [{"json": {"value": "2026-04-23"}}]
code = textwrap.dedent("""
import time
parsed = time.strptime(_item['json']['value'], "%Y-%m-%d")
return {'year': parsed.tm_year, 'month': parsed.tm_mon, 'day': parsed.tm_mday}
""")
task_settings = create_task_settings(code=code, node_mode="per_item", items=items)
await broker.send_task(task_id=task_id, task_settings=task_settings)
done_msg = await wait_for_task_done(broker, task_id)
assert done_msg["data"]["result"] == [
{"json": {"year": 2026, "month": 4, "day": 23}, "pairedItem": {"item": 0}}
]
@pytest.mark.asyncio
async def test_per_item_warnings_warn_works(broker, manager_with_stdlib_wildcard):
task_id = nanoid()
items = [{"json": {}}]
code = textwrap.dedent("""
import warnings
warnings.warn('sample', stacklevel=1)
return {'ok': True}
""")
task_settings = create_task_settings(code=code, node_mode="per_item", items=items)
await broker.send_task(task_id=task_id, task_settings=task_settings)
done_msg = await wait_for_task_done(broker, task_id)
assert done_msg["data"]["result"] == [
{"json": {"ok": True}, "pairedItem": {"item": 0}}
]
@pytest.mark.asyncio
async def test_all_items_datetime_strptime_works(broker, manager_with_stdlib_wildcard):
task_id = nanoid()
code = textwrap.dedent("""
from datetime import datetime
parsed = datetime.strptime("2026-04-23T16:04:28+0000", "%Y-%m-%dT%H:%M:%S%z")
return [{'json': {'parsed': parsed.isoformat()}}]
""")
task_settings = create_task_settings(code=code, node_mode="all_items")
await broker.send_task(task_id=task_id, task_settings=task_settings)
done_msg = await wait_for_task_done(broker, task_id)
assert done_msg["data"]["result"] == [
{"json": {"parsed": "2026-04-23T16:04:28+00:00"}}
]
# ========== Security ===========

View File

@ -1,6 +1,5 @@
import pytest
import json
import types
from unittest.mock import MagicMock, patch
from src.task_executor import TaskExecutor
@ -209,7 +208,70 @@ class TestFilterBuiltins:
runner_env_deny=False,
)
def test_returns_mapping_proxy(self):
def test_supports_item_access(self):
result = TaskExecutor._filter_builtins(self._make_security_config())
assert isinstance(result, types.MappingProxyType)
assert result["__import__"] is not None
assert result["len"] is len
assert "len" in result
def test_supports_attribute_access(self):
result = TaskExecutor._filter_builtins(self._make_security_config())
assert result.__import__ is not None
assert result.len is len
def test_is_not_a_dict(self):
result = TaskExecutor._filter_builtins(self._make_security_config())
assert not isinstance(result, dict)
@pytest.mark.parametrize(
"name,mutate,expected",
[
(
"item_assignment",
lambda r: r.__setitem__("len", None),
(TypeError, AttributeError),
),
(
"attribute_assignment",
lambda r: setattr(r, "__import__", None),
AttributeError,
),
(
"dict_class_setitem",
lambda r: dict.__setitem__(r, "len", None),
TypeError,
),
("dict_class_init", lambda r: dict.__init__(r, {"pwned": True}), TypeError),
("dict_class_update", lambda r: dict.update(r, {"len": None}), TypeError),
("dict_class_clear", lambda r: dict.clear(r), TypeError),
("class_swap", lambda r: setattr(r, "__class__", dict), AttributeError),
("vars_injection", lambda r: vars(r), TypeError),
(
"object_setattr",
lambda r: object.__setattr__(r, "_x", {"pwned": True}),
AttributeError,
),
],
)
def test_rejects_mutation(self, name, mutate, expected):
result = TaskExecutor._filter_builtins(self._make_security_config())
with pytest.raises(expected):
mutate(result)
def test_applies_builtins_deny(self):
config = SecurityConfig(
stdlib_allow=set(),
external_allow=set(),
builtins_deny={"open", "eval"},
runner_env_deny=False,
)
result = TaskExecutor._filter_builtins(config)
assert "open" not in result
assert "eval" not in result
assert "len" in result
assert result["__import__"] is not None

View File

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

View File

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

View File

@ -119,6 +119,21 @@ describe('ActiveExecutions', () => {
expect(executionRepository.updateExistingExecution).toHaveBeenCalledTimes(1);
});
test('Should forward deduplicationKey to executionPersistence.create', async () => {
const executionDataWithKey: IWorkflowExecutionDataProcess = {
...executionData,
deduplicationKey: 'wf-1:node-1:1700000000000',
};
await activeExecutions.add(executionDataWithKey);
expect(executionPersistence.create).toHaveBeenCalledWith(
expect.objectContaining({
deduplicationKey: 'wf-1:node-1:1700000000000',
}),
);
});
test('Should throw ExecutionAlreadyResumingError when another process is resuming execution', async () => {
// Mock updateExistingExecution to return false (status check failed)
executionRepository.updateExistingExecution.mockResolvedValue(false);

View File

@ -1,4 +1,5 @@
/* eslint-disable @typescript-eslint/unbound-method */
import type { Logger } from '@n8n/backend-common';
import { mockLogger } from '@n8n/backend-test-utils';
import type { WorkflowEntity, WorkflowHistory, WorkflowRepository } from '@n8n/db';
import { mock } from 'jest-mock-extended';
@ -7,16 +8,18 @@ import type {
ExecutionError,
INodeExecutionData,
INode,
IRun,
IWorkflowExecuteAdditionalData,
Workflow,
WorkflowActivateMode,
WorkflowExecuteMode,
} from 'n8n-workflow';
import { WorkflowActivationError } from 'n8n-workflow';
import { createDeferredPromise, WorkflowActivationError } from 'n8n-workflow';
import type { ActivationErrorsService } from '@/activation-errors.service';
import { ActiveWorkflowManager } from '@/active-workflow-manager';
import { DuplicateExecutionError } from '@/errors/duplicate-execution.error';
import type { EventService } from '@/events/event.service';
import type { ExecutionService } from '@/executions/execution.service';
import type { NodeTypes } from '@/node-types';
@ -294,6 +297,7 @@ describe('ActiveWorkflowManager', () => {
const activeWorkflows = mock<ActiveWorkflows>();
const activationErrorsService = mock<ActivationErrorsService>();
const executionService = mock<ExecutionService>();
let scopedLogger: Logger;
beforeEach(() => {
jest.clearAllMocks();
@ -303,8 +307,11 @@ describe('ActiveWorkflowManager', () => {
activationErrorsService.register.mockResolvedValue(undefined);
executionService.createErrorExecution.mockResolvedValue(undefined);
scopedLogger = mock<Logger>();
const rootLogger = mock<Logger>({ scoped: jest.fn().mockReturnValue(scopedLogger) });
activeWorkflowManager = new ActiveWorkflowManager(
mockLogger(),
rootLogger,
mock(),
activeWorkflows,
mock(),
@ -358,6 +365,7 @@ describe('ActiveWorkflowManager', () => {
additionalData,
mode,
undefined,
undefined,
);
await new Promise((resolve) => setTimeout(resolve, 0));
@ -369,6 +377,91 @@ describe('ActiveWorkflowManager', () => {
source: 'trigger',
});
});
test('forwards deduplicationKey to workflowExecutionService.runWorkflow', async () => {
const workflowData = mock<WorkflowEntity>({ id: 'wf-1', name: 'Test Workflow' });
const additionalData = mock<IWorkflowExecuteAdditionalData>();
const mode: WorkflowExecuteMode = 'trigger';
const activation: WorkflowActivateMode = 'activate';
const workflow = mock<Workflow>({ name: 'Test Workflow' });
const node = mock<INode>({ name: 'Trigger Node', id: 'node-1' });
const triggerData: INodeExecutionData[][] = [[]];
const getTriggerFunctions = activeWorkflowManager.getExecuteTriggerFunctions(
workflowData,
additionalData,
mode,
activation,
);
const context = getTriggerFunctions(workflow, node, additionalData, mode, activation);
context.emit(triggerData, undefined, undefined, 'wf-1:node-1:1700000000000');
expect(workflowExecutionService.runWorkflow).toHaveBeenCalledWith(
workflowData,
node,
triggerData,
additionalData,
mode,
undefined,
'wf-1:node-1:1700000000000',
);
});
test('skips event emission when runWorkflow rejects with DuplicateExecutionError', async () => {
const workflowData = mock<WorkflowEntity>({ id: 'wf-1', name: 'Test Workflow' });
const additionalData = mock<IWorkflowExecuteAdditionalData>();
const mode: WorkflowExecuteMode = 'trigger';
const activation: WorkflowActivateMode = 'activate';
const workflow = mock<Workflow>({ name: 'Test Workflow' });
const node = mock<INode>({ name: 'Trigger Node', id: 'node-1' });
const triggerData: INodeExecutionData[][] = [[]];
workflowExecutionService.runWorkflow.mockRejectedValueOnce(
new DuplicateExecutionError('wf-1:node-1:1700000000000'),
);
const getTriggerFunctions = activeWorkflowManager.getExecuteTriggerFunctions(
workflowData,
additionalData,
mode,
activation,
);
const context = getTriggerFunctions(workflow, node, additionalData, mode, activation);
context.emit(triggerData, undefined, undefined, 'wf-1:node-1:1700000000000');
await new Promise((resolve) => setTimeout(resolve, 0));
expect(eventService.emit).not.toHaveBeenCalled();
});
test('resolves donePromise with undefined when runWorkflow rejects with DuplicateExecutionError', async () => {
const workflowData = mock<WorkflowEntity>({ id: 'wf-1', name: 'Test Workflow' });
const additionalData = mock<IWorkflowExecuteAdditionalData>();
const mode: WorkflowExecuteMode = 'trigger';
const activation: WorkflowActivateMode = 'activate';
const workflow = mock<Workflow>({ name: 'Test Workflow' });
const node = mock<INode>({ name: 'Trigger Node', id: 'node-1' });
const triggerData: INodeExecutionData[][] = [[]];
workflowExecutionService.runWorkflow.mockRejectedValueOnce(
new DuplicateExecutionError('wf-1:node-1:1700000000000'),
);
const getTriggerFunctions = activeWorkflowManager.getExecuteTriggerFunctions(
workflowData,
additionalData,
mode,
activation,
);
const context = getTriggerFunctions(workflow, node, additionalData, mode, activation);
const donePromise = createDeferredPromise<IRun>();
context.emit(triggerData, undefined, donePromise, 'wf-1:node-1:1700000000000');
await expect(donePromise.promise).resolves.toBeUndefined();
});
});
describe('emitError', () => {

View File

@ -16,6 +16,7 @@ import {
type IExecuteData,
type INode,
type IRun,
type IRunExecutionData,
type ITaskData,
type IWaitingForExecution,
type IWaitingForExecutionSource,
@ -23,9 +24,11 @@ import {
type IWorkflowExecutionDataProcess,
type StartNodeData,
type IWorkflowExecuteAdditionalData,
type WorkflowExecuteMode,
Workflow,
ExecutionError,
TimeoutExecutionCancelledError,
createRunExecutionData,
} from 'n8n-workflow';
import PCancelable from 'p-cancelable';
@ -630,6 +633,202 @@ describe('needsFullExecutionData', () => {
});
});
describe('pre-persist context establishment', () => {
const callOrder: string[] = [];
let establishSpy: jest.SpyInstance;
let addSpy: jest.SpyInstance;
let capturedAddData: IWorkflowExecutionDataProcess | undefined;
const buildRunData = (
executionData: IRunExecutionData | undefined,
executionMode: WorkflowExecuteMode = 'webhook',
): IWorkflowExecutionDataProcess =>
mock<IWorkflowExecutionDataProcess>({
executionMode,
workflowData: {
id: 'wf-1',
name: 'Test',
nodes: [],
connections: {},
settings: undefined,
},
executionData,
userId: 'u1',
});
const buildExecutionDataWithHeader = (): IRunExecutionData =>
createRunExecutionData({
executionData: {
contextData: {},
nodeExecutionStack: [
{
node: {
id: 'n1',
name: 'Webhook',
type: 'n8n-nodes-base.webhook',
typeVersion: 2,
position: [0, 0],
parameters: {},
},
data: {
main: [[{ json: { headers: { authorization: 'Bearer eyJtest' } } }]],
},
source: null,
},
],
metadata: {},
waitingExecution: {},
waitingExecutionSource: {},
},
});
beforeEach(() => {
callOrder.length = 0;
capturedAddData = undefined;
const permissionChecker = Container.get(CredentialsPermissionChecker);
jest.spyOn(permissionChecker, 'check').mockResolvedValue();
establishSpy = jest
.spyOn(core, 'establishExecutionContext')
.mockImplementation(async (_workflow, runExecutionData) => {
callOrder.push('establishExecutionContext');
// Simulate real behaviour: mask header, set runtimeData.
const stack = runExecutionData.executionData?.nodeExecutionStack;
const item = stack?.[0]?.data?.main?.[0]?.[0];
const headers = item && (item.json as { headers?: Record<string, string> }).headers;
if (headers && typeof headers.authorization === 'string') {
headers.authorization = '**********';
}
if (runExecutionData.executionData) {
(runExecutionData.executionData as { runtimeData?: unknown }).runtimeData = {
version: 1,
establishedAt: Date.now(),
source: 'webhook',
};
}
});
const activeExecutions = Container.get(ActiveExecutions);
addSpy = jest
.spyOn(activeExecutions, 'add')
.mockImplementation(async (data: IWorkflowExecutionDataProcess) => {
capturedAddData = data;
callOrder.push('activeExecutions.add');
throw new Error('short-circuit for test');
});
});
afterEach(() => {
establishSpy.mockRestore();
addSpy.mockRestore();
});
it('calls establishExecutionContext before activeExecutions.add', async () => {
const data = buildRunData(buildExecutionDataWithHeader());
await expect(runner.run(data)).rejects.toThrow('short-circuit for test');
expect(callOrder).toEqual(['establishExecutionContext', 'activeExecutions.add']);
});
it('passes masked executionData to activeExecutions.add', async () => {
const data = buildRunData(buildExecutionDataWithHeader());
await expect(runner.run(data)).rejects.toThrow('short-circuit for test');
expect(capturedAddData).toBeDefined();
const stack = capturedAddData!.executionData!.executionData!.nodeExecutionStack;
expect(stack[0].data.main[0]![0].json).toMatchObject({
headers: { authorization: '**********' },
});
expect(capturedAddData!.executionData!.executionData!.runtimeData).toBeDefined();
});
it('skips establishExecutionContext when data.executionData is undefined', async () => {
const data = buildRunData(undefined);
await expect(runner.run(data)).rejects.toThrow('short-circuit for test');
expect(establishSpy).not.toHaveBeenCalled();
expect(callOrder).toEqual(['activeExecutions.add']);
});
it('skips establishExecutionContext when nodeExecutionStack has not been populated yet', async () => {
// Queue mode with OFFLOAD_MANUAL_EXECUTIONS_TO_WORKERS=true creates
// the outer IRunExecutionData with `executionData: null`, which
// `createRunExecutionData` normalises to an object whose inner
// `.executionData` is undefined. The worker establishes context
// later, once it populates the trigger-item stack.
const data = buildRunData(createRunExecutionData({ executionData: null }), 'manual');
await expect(runner.run(data)).rejects.toThrow('short-circuit for test');
expect(establishSpy).not.toHaveBeenCalled();
expect(callOrder).toEqual(['activeExecutions.add']);
});
describe('when establishExecutionContext throws', () => {
const lifecycleRunHook = jest.fn().mockResolvedValue(undefined);
const responseReject = jest.fn();
beforeEach(() => {
establishSpy.mockReset();
establishSpy.mockImplementation(async () => {
callOrder.push('establishExecutionContext');
throw new Error('hook augmentation failed');
});
addSpy.mockReset();
addSpy.mockImplementation(async (data: IWorkflowExecutionDataProcess) => {
capturedAddData = data;
callOrder.push('activeExecutions.add');
return 'exec-1';
});
lifecycleRunHook.mockClear();
responseReject.mockClear();
jest
.spyOn(ExecutionLifecycleHooks, 'getLifecycleHooksForRegularMain')
.mockReturnValue(mock<core.ExecutionLifecycleHooks>({ runHook: lifecycleRunHook }));
jest.spyOn(Container.get(ActiveExecutions), 'finalizeExecution').mockReturnValue();
});
it('clears the trigger-item stack so raw headers do not get persisted', async () => {
const data = buildRunData(buildExecutionDataWithHeader());
await runner.run(data, undefined, undefined, undefined, {
reject: responseReject,
resolve: jest.fn(),
promise: Promise.resolve() as never,
} as never);
expect(capturedAddData).toBeDefined();
expect(capturedAddData!.executionData!.executionData!.nodeExecutionStack).toEqual([]);
});
it('creates a failed-execution record and rejects the responsePromise', async () => {
const data = buildRunData(buildExecutionDataWithHeader());
const executionId = await runner.run(data, undefined, undefined, undefined, {
reject: responseReject,
resolve: jest.fn(),
promise: Promise.resolve() as never,
} as never);
expect(executionId).toBe('exec-1');
expect(lifecycleRunHook).toHaveBeenCalledWith('workflowExecuteBefore', [
undefined,
data.executionData,
]);
expect(lifecycleRunHook).toHaveBeenCalledWith('workflowExecuteAfter', [expect.any(Object)]);
expect(responseReject).toHaveBeenCalledWith(
expect.objectContaining({ message: 'hook augmentation failed' }),
);
});
});
});
describe('streaming functionality', () => {
it('should setup heartbeat interval and sendChunk handler when streaming is enabled', async () => {
// ARRANGE

View File

@ -78,6 +78,7 @@ export class ActiveExecutions {
workflowId: executionData.workflowData.id,
retryOf: executionData.retryOf ?? undefined,
tracingContext: executionData.tracingContext ?? null,
deduplicationKey: executionData.deduplicationKey,
};
const workflowId = executionData.workflowData.id;

View File

@ -49,6 +49,7 @@ import {
import { strict } from 'node:assert';
import { ActivationErrorsService } from '@/activation-errors.service';
import { DuplicateExecutionError } from '@/errors/duplicate-execution.error';
import { MessageEventBus } from '@/eventbus/message-event-bus/message-event-bus';
import { ActiveExecutions } from '@/active-executions';
import { EventService } from '@/events/event.service';
@ -364,20 +365,39 @@ export class ActiveWorkflowManager {
data: INodeExecutionData[][],
responsePromise?: IDeferredPromise<IExecuteResponsePromiseData>,
donePromise?: IDeferredPromise<IRun | undefined>,
deduplicationKey?: string,
) => {
this.logger.debug(`Received trigger for workflow "${workflow.name}"`);
void this.workflowStaticDataService.saveStaticData(workflow);
const executePromise = this.workflowExecutionService.runWorkflow(
workflowData,
node,
data,
additionalData,
mode,
responsePromise,
);
const executePromise = this.workflowExecutionService
.runWorkflow(
workflowData,
node,
data,
additionalData,
mode,
responsePromise,
deduplicationKey,
)
.catch((error: unknown) => {
if (error instanceof DuplicateExecutionError) {
const context = {
workflowId: workflowData.id,
nodeId: node.id,
deduplicationKey: error.deduplicationKey,
};
this.logger.warn('Scheduled execution skipped: duplicate deduplication key', context);
this.errorReporter.warn(error, { extra: context, shouldBeLogged: false });
return undefined;
}
throw error;
});
void executePromise.then((executionId) => {
// `executionId` is undefined when the catch above swallowed a
// duplicate scheduled execution; nothing ran, so nothing to emit.
if (executionId === undefined) return;
this.eventService.emit('workflow-executed', {
workflowId: workflowData.id,
workflowName: workflowData.name,
@ -388,6 +408,12 @@ export class ActiveWorkflowManager {
if (donePromise) {
void executePromise.then((executionId) => {
// Same as above: a duplicate scheduled execution was skipped,
// so resolve with undefined and don't wait on a non-existent run.
if (executionId === undefined) {
donePromise.resolve(undefined);
return;
}
this.activeExecutions
.getPostExecutePromise(executionId)
.then(donePromise.resolve)

View File

@ -1,13 +1,19 @@
import { mockInstance } from '@n8n/backend-test-utils';
import { DbConnection, DeploymentKeyRepository } from '@n8n/db';
import { Container } from '@n8n/di';
import { BinaryDataConfig } from 'n8n-core';
import { DeprecationService } from '@/deprecation/deprecation.service';
import { MessageEventBus } from '@/eventbus/message-event-bus/message-event-bus';
import { LogStreamingEventRelay } from '@/events/relays/log-streaming.event-relay';
import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials';
import { PubSubRegistry } from '@/scaling/pubsub/pubsub.registry';
import { Subscriber } from '@/scaling/pubsub/subscriber.service';
import { JwtService } from '@/services/jwt.service';
import { RedisClientService } from '@/services/redis-client.service';
import { WebhookServer } from '@/webhooks/webhook-server';
import { BaseCommand } from '../base-command';
import { Webhook } from '../webhook';
jest.mock('@/scaling/scaling.service', () => ({
@ -26,7 +32,14 @@ mockInstance(RedisClientService);
mockInstance(PubSubRegistry);
const mockSubscriber = mockInstance(Subscriber);
const mockWebhookServer = mockInstance(WebhookServer);
mockInstance(LoadNodesAndCredentials);
const mockLoadNodesAndCredentials = mockInstance(LoadNodesAndCredentials);
mockLoadNodesAndCredentials.postProcessLoaders.mockResolvedValue(undefined);
mockInstance(DeprecationService);
mockInstance(JwtService, { initialize: jest.fn().mockResolvedValue(undefined) });
mockInstance(BinaryDataConfig, { initialize: jest.fn().mockResolvedValue(undefined) });
mockInstance(MessageEventBus, { initialize: jest.fn().mockResolvedValue(undefined) });
mockInstance(LogStreamingEventRelay);
describe('Webhook', () => {
beforeEach(() => {
@ -74,4 +87,57 @@ describe('Webhook', () => {
expect(mockWebhookServer.markAsReady).toHaveBeenCalled();
});
});
describe('init', () => {
let baseInitSpy: jest.SpyInstance;
beforeEach(() => {
baseInitSpy = jest.spyOn(BaseCommand.prototype, 'init').mockResolvedValue(undefined);
});
afterEach(() => {
baseInitSpy.mockRestore();
});
it('should call executionContextHookRegistry.init before LoadNodesAndCredentials.postProcessLoaders', async () => {
const webhook = new Webhook();
// @ts-expect-error - Accessing protected property for testing
webhook.globalConfig = { executions: { mode: 'queue' } };
// @ts-expect-error - Accessing protected method for testing
webhook.initCrashJournal = jest.fn().mockResolvedValue(undefined);
webhook.initLicense = jest.fn().mockResolvedValue(undefined);
// @ts-expect-error - Accessing protected method for testing
webhook.initCommunityPackages = jest.fn().mockResolvedValue(undefined);
webhook.initOrchestration = jest.fn().mockResolvedValue(undefined);
webhook.initBinaryDataService = jest.fn().mockResolvedValue(undefined);
// @ts-expect-error - Accessing protected method for testing
webhook.initDataDeduplicationService = jest.fn().mockResolvedValue(undefined);
webhook.initExternalHooks = jest.fn().mockResolvedValue(undefined);
// @ts-expect-error - Accessing protected property for testing
webhook.moduleRegistry = { initModules: jest.fn().mockResolvedValue(undefined) };
// @ts-expect-error - Accessing protected property for testing
webhook.instanceSettings = {
hostId: 'test',
instanceType: 'webhook',
initialize: jest.fn().mockResolvedValue(undefined),
};
// @ts-expect-error - Accessing protected property for testing
webhook.executionContextHookRegistry = {
init: jest.fn().mockResolvedValue(undefined),
};
await webhook.init();
// @ts-expect-error - Accessing protected property for testing
const hookInitMock = webhook.executionContextHookRegistry.init as jest.Mock;
const postProcessMock = mockLoadNodesAndCredentials.postProcessLoaders as jest.Mock;
expect(hookInitMock).toHaveBeenCalled();
expect(postProcessMock).toHaveBeenCalled();
expect(hookInitMock.mock.invocationCallOrder[0]).toBeLessThan(
postProcessMock.mock.invocationCallOrder[0],
);
});
});
});

View File

@ -92,6 +92,9 @@ export class Webhook extends BaseCommand {
Container.get(LogStreamingEventRelay).init();
await this.moduleRegistry.initModules(this.instanceSettings.instanceType);
await this.executionContextHookRegistry.init();
await Container.get(LoadNodesAndCredentials).postProcessLoaders();
}
async run() {

View File

@ -78,6 +78,45 @@ describe('ProjectController', () => {
});
});
describe('getSharingCandidates', () => {
it('calls service with query options and returns enriched { count, data }', async () => {
const projects = [
{ id: 'p1', name: 'Project 1' },
{ id: 'p2', name: 'Peer personal project' },
];
const enriched = projects.map((p) => ({
...p,
role: 'global:member',
scopes: ['user:list'],
}));
(projectsService.getShareableProjectsAndCount as jest.Mock).mockResolvedValue([projects, 2]);
(projectsService.addUserScopes as jest.Mock).mockResolvedValue(enriched);
const res = makeRes();
const query = { skip: 0, take: 50, search: '' };
await controller.getSharingCandidates(req, res, query as any);
expect(projectsService.getShareableProjectsAndCount).toHaveBeenCalledWith(req.user, query);
expect(projectsService.addUserScopes).toHaveBeenCalledWith(req.user, projects);
expect(res.json).toHaveBeenCalledWith({ count: 2, data: enriched });
});
it('always returns the { count, data } envelope (no bare-array path)', async () => {
(projectsService.getShareableProjectsAndCount as jest.Mock).mockResolvedValue([[], 0]);
(projectsService.addUserScopes as jest.Mock).mockResolvedValue([]);
const res = makeRes();
const parsed = ListProjectsQueryDto.safeParse({});
expect(parsed.success).toBe(true);
const query = parsed.data!;
await controller.getSharingCandidates(req, res, query);
expect(res.json).toHaveBeenCalledWith({ count: 0, data: [] });
});
});
it('emits team-project-updated with full members list on addProjectUsers', async () => {
// Arrange
const projectId = 'p1';

View File

@ -75,6 +75,26 @@ export class ProjectController {
return await this.projectsService.getProjectCounts();
}
// Lists projects a caller can pick as share targets, including peer
// personal projects so the workflow / credential share dropdowns can
// surface other users. Gated on `user:list` (the same boundary that
// `GET /rest/users` enforces) — restricted roles without that scope
// (e.g. chat-only users) cannot enumerate peer personal projects here.
@Get('/sharing-candidates')
@GlobalScope('user:list')
async getSharingCandidates(
req: AuthenticatedRequest,
res: Response,
@Query payload: ListProjectsQueryDto,
) {
const [data, count] = await this.projectsService.getShareableProjectsAndCount(
req.user,
payload,
);
const enriched = await this.projectsService.addUserScopes(req.user, data);
return res.json({ count, data: enriched });
}
@Post('/')
@GlobalScope('project:create')
// Using admin as all plans that contain projects should allow admins at the very least

View File

@ -0,0 +1,12 @@
import { OperationalError } from 'n8n-workflow';
export class DuplicateExecutionError extends OperationalError {
constructor(
readonly deduplicationKey: string,
cause?: Error,
) {
super(`Execution with deduplication key "${deduplicationKey}" already exists`, {
cause,
});
}
}

View File

@ -1,7 +1,7 @@
/* eslint-disable id-denylist */
/* eslint-disable @typescript-eslint/unbound-method */
import type { ExecutionsConfig } from '@n8n/config';
import type { DatabaseConfig, ExecutionsConfig } from '@n8n/config';
import {
ExecutionData,
ExecutionEntity,
@ -9,11 +9,13 @@ import {
type EntityManager,
type ExecutionRepository,
} from '@n8n/db';
import { QueryFailedError } from '@n8n/typeorm';
import { mock } from 'jest-mock-extended';
import type { BinaryDataService, StorageConfig } from 'n8n-core';
import type { IWorkflowBase } from 'n8n-workflow';
import { createEmptyRunExecutionData } from 'n8n-workflow';
import { DuplicateExecutionError } from '@/errors/duplicate-execution.error';
import type { FsStore } from '@/executions/execution-data/fs-store';
import { ExecutionPersistence } from '@/executions/execution-persistence';
@ -54,13 +56,17 @@ describe('ExecutionPersistence', () => {
const createMockTx = (tx: EntityManager) =>
jest.fn().mockImplementation(async <T>(cb: (em: EntityManager) => Promise<T>) => await cb(tx));
const createPersistenceService = (modeTag: 'db' | 'fs') =>
const createPersistenceService = (
modeTag: 'db' | 'fs',
dbType: DatabaseConfig['type'] = 'postgresdb',
) =>
new ExecutionPersistence(
executionRepository,
binaryDataService,
fsStore,
mock<StorageConfig>({ modeTag }),
executionsConfig,
mock<DatabaseConfig>({ type: dbType }),
);
describe('create', () => {
@ -160,6 +166,127 @@ describe('ExecutionPersistence', () => {
await expect(executionPersistence.create(createPayload)).rejects.toThrow(fsWriteError);
});
});
describe('deduplication key handling', () => {
const executionPersistence = createPersistenceService('db');
const makeUniqueViolationError = (
message = 'duplicate key value violates unique constraint on deduplicationKey',
) => {
const driverError = Object.assign(new Error(message), { code: '23505' });
return new QueryFailedError('Query', [], driverError);
};
it('converts unique-violation into DuplicateExecutionError when payload has a deduplicationKey', async () => {
const uniqueViolation = makeUniqueViolationError();
executionRepository.manager.transaction = jest.fn().mockRejectedValue(uniqueViolation);
const payloadWithKey: CreateExecutionPayload = {
...createPayload,
deduplicationKey: 'wf-1:node-1:1700000000000',
};
await expect(executionPersistence.create(payloadWithKey)).rejects.toBeInstanceOf(
DuplicateExecutionError,
);
await expect(executionPersistence.create(payloadWithKey)).rejects.toMatchObject({
deduplicationKey: 'wf-1:node-1:1700000000000',
cause: uniqueViolation,
});
});
it('rethrows original unique-violation when payload has no deduplicationKey', async () => {
const uniqueViolation = makeUniqueViolationError();
executionRepository.manager.transaction = jest.fn().mockRejectedValue(uniqueViolation);
await expect(executionPersistence.create(createPayload)).rejects.toBe(uniqueViolation);
});
it('rethrows non-unique-violation QueryFailedError unchanged', async () => {
const otherError = new QueryFailedError(
'Query',
[],
Object.assign(new Error('not null'), { code: '23502' }),
);
executionRepository.manager.transaction = jest.fn().mockRejectedValue(otherError);
const payloadWithKey: CreateExecutionPayload = {
...createPayload,
deduplicationKey: 'wf-1:node-1:1700000000000',
};
await expect(executionPersistence.create(payloadWithKey)).rejects.toBe(otherError);
});
it('rethrows unique-violation on a different column unchanged', async () => {
const otherUniqueViolation = makeUniqueViolationError(
'duplicate key value violates unique constraint on someOtherColumn',
);
executionRepository.manager.transaction = jest.fn().mockRejectedValue(otherUniqueViolation);
const payloadWithKey: CreateExecutionPayload = {
...createPayload,
deduplicationKey: 'wf-1:node-1:1700000000000',
};
await expect(executionPersistence.create(payloadWithKey)).rejects.toBe(
otherUniqueViolation,
);
});
it.each([
{
name: 'extended code',
code: 'SQLITE_CONSTRAINT_UNIQUE',
message: 'UNIQUE constraint failed: execution_entity.deduplicationKey',
},
{
name: 'base code',
code: 'SQLITE_CONSTRAINT',
message: 'SQLITE_CONSTRAINT: UNIQUE constraint failed: execution_entity.deduplicationKey',
},
])(
'converts SQLite unique-violation ($name) into DuplicateExecutionError',
async ({ code, message }) => {
const sqlitePersistence = createPersistenceService('db', 'sqlite');
const sqliteError = new QueryFailedError(
'Query',
[],
Object.assign(new Error(message), { code }),
);
executionRepository.manager.transaction = jest.fn().mockRejectedValue(sqliteError);
const payloadWithKey: CreateExecutionPayload = {
...createPayload,
deduplicationKey: 'wf-1:node-1:1700000000000',
};
await expect(sqlitePersistence.create(payloadWithKey)).rejects.toBeInstanceOf(
DuplicateExecutionError,
);
},
);
it('returns executionId on happy path when deduplicationKey is provided', async () => {
const mockTx = createMockTransaction();
executionRepository.manager.transaction = createMockTx(mockTx);
const payloadWithKey: CreateExecutionPayload = {
...createPayload,
deduplicationKey: 'wf-1:node-1:1700000000000',
};
const executionId = await executionPersistence.create(payloadWithKey);
expect(executionId).toBe('exec-1');
expect(mockTx.insert).toHaveBeenCalledWith(
ExecutionEntity,
expect.objectContaining({
deduplicationKey: 'wf-1:node-1:1700000000000',
}),
);
});
});
});
describe('hardDelete', () => {

View File

@ -1,4 +1,4 @@
import { ExecutionsConfig } from '@n8n/config';
import { DatabaseConfig, ExecutionsConfig } from '@n8n/config';
import { Time } from '@n8n/constants';
import type {
CreateExecutionPayload,
@ -12,6 +12,7 @@ import { BinaryDataService, StorageConfig } from 'n8n-core';
import { FsStore } from './execution-data/fs-store';
import type { ExecutionRef, WorkflowSnapshot } from './execution-data/types';
import { DuplicateExecutionError } from '../errors/duplicate-execution.error';
type DeletionTarget = ExecutionRef & { storedAt: ExecutionDataStorageLocation };
@ -27,6 +28,7 @@ export class ExecutionPersistence {
private readonly fsStore: FsStore,
private readonly storageConfig: StorageConfig,
private readonly executionsConfig: ExecutionsConfig,
private readonly databaseConfig: DatabaseConfig,
) {}
/**
@ -43,26 +45,60 @@ export class ExecutionPersistence {
const data = stringify(rawData);
const workflowVersionId = workflowData.versionId ?? null;
return await this.executionRepository.manager.transaction(async (tx) => {
const { identifiers } = await tx.insert(ExecutionEntity, executionEntity);
const executionId = String(identifiers[0].id);
try {
return await this.executionRepository.manager.transaction(async (tx) => {
const { identifiers } = await tx.insert(ExecutionEntity, executionEntity);
const executionId = String(identifiers[0].id);
if (storedAt === 'db') {
await tx.insert(ExecutionData, {
executionId,
workflowData: workflowSnapshot,
data,
workflowVersionId,
});
if (storedAt === 'db') {
await tx.insert(ExecutionData, {
executionId,
workflowData: workflowSnapshot,
data,
workflowVersionId,
});
return executionId;
}
await this.fsStore.write(
{ workflowId: id, executionId },
{ data, workflowData: workflowSnapshot, workflowVersionId },
);
return executionId;
});
} catch (error) {
if (executionEntity.deduplicationKey && this.isDuplicateExecutionError(error)) {
throw new DuplicateExecutionError(executionEntity.deduplicationKey, error);
}
throw error;
}
}
await this.fsStore.write(
{ workflowId: id, executionId },
{ data, workflowData: workflowSnapshot, workflowVersionId },
);
return executionId;
});
/**
* Detect whether the DB rejected the insert because of the unique index on
* `execution_entity.deduplicationKey`. We expect TypeORM to surface the
* driver's error code at `error.driverError.code` as a string, with the
* code's exact value depending on the configured DB.
*/
private isDuplicateExecutionError(error: unknown): error is Error {
if (!(error instanceof Error) || !('driverError' in error)) return false;
const { driverError } = error;
if (typeof driverError !== 'object' || driverError === null || !('code' in driverError)) {
return false;
}
const { code } = driverError;
if (typeof code !== 'string') return false;
if (!error.message.includes('deduplicationKey')) return false;
if (this.databaseConfig.type === 'postgresdb') {
return code === '23505';
}
// SQLite reports `SQLITE_CONSTRAINT_UNIQUE` when extended result codes are
// enabled, and falls back to the base `SQLITE_CONSTRAINT` otherwise.
return (
code === 'SQLITE_CONSTRAINT_UNIQUE' ||
(code === 'SQLITE_CONSTRAINT' && error.message.includes('UNIQUE constraint failed'))
);
}
/**

View File

@ -21,7 +21,7 @@ describe('FavoritesController', () => {
const result = await controller.getFavorites(req);
expect(favoritesService.getEnrichedFavorites).toHaveBeenCalledWith('user1');
expect(favoritesService.getEnrichedFavorites).toHaveBeenCalledWith(req.user);
expect(result).toBe(favorites);
});
});

View File

@ -1,9 +1,12 @@
import { mock } from 'jest-mock-extended';
import type {
FolderRepository,
ProjectRepository,
SharedWorkflowRepository,
WorkflowRepository,
import {
GLOBAL_ADMIN_ROLE,
GLOBAL_MEMBER_ROLE,
type FolderRepository,
type ProjectRepository,
type SharedWorkflowRepository,
type User,
type WorkflowRepository,
} from '@n8n/db';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
@ -23,6 +26,20 @@ const makeUserFavorite = (overrides: Partial<UserFavorite> = {}): UserFavorite =
...overrides,
}) as UserFavorite;
const makeUser = (overrides: Partial<User> = {}): User =>
({
id: 'user1',
role: GLOBAL_MEMBER_ROLE,
...overrides,
}) as unknown as User;
const makeAdmin = (overrides: Partial<User> = {}): User =>
makeUser({
id: 'admin1',
role: GLOBAL_ADMIN_ROLE,
...overrides,
});
describe('FavoritesService', () => {
const repo = mock<UserFavoriteRepository>();
const workflowRepository = mock<WorkflowRepository>();
@ -45,7 +62,7 @@ describe('FavoritesService', () => {
it('should return empty array when user has no favorites', async () => {
repo.findByUser.mockResolvedValue([]);
const result = await service.getEnrichedFavorites('user1');
const result = await service.getEnrichedFavorites(makeUser());
expect(repo.findByUser).toHaveBeenCalledWith('user1');
expect(result).toEqual([]);
@ -60,7 +77,7 @@ describe('FavoritesService', () => {
workflowRepository.findByIds.mockResolvedValue([{ id: 'wf1', name: 'My Workflow' } as never]);
sharedWorkflowRepository.find.mockResolvedValue([{ workflowId: 'wf1' } as never]);
const result = await service.getEnrichedFavorites('user1');
const result = await service.getEnrichedFavorites(makeUser());
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({ resourceId: 'wf1', resourceName: 'My Workflow' });
@ -76,7 +93,7 @@ describe('FavoritesService', () => {
// Not in accessible workflows via SharedWorkflow
sharedWorkflowRepository.find.mockResolvedValue([]);
const result = await service.getEnrichedFavorites('user1');
const result = await service.getEnrichedFavorites(makeUser());
expect(result).toHaveLength(0);
});
@ -86,7 +103,7 @@ describe('FavoritesService', () => {
repo.findByUser.mockResolvedValue([favorite]);
projectRepository.getAccessibleProjects.mockResolvedValue([]);
const result = await service.getEnrichedFavorites('user1');
const result = await service.getEnrichedFavorites(makeUser());
expect(workflowRepository.findByIds).not.toHaveBeenCalled();
expect(result).toHaveLength(0);
@ -99,7 +116,7 @@ describe('FavoritesService', () => {
{ id: 'proj1', name: 'My Project' } as never,
]);
const result = await service.getEnrichedFavorites('user1');
const result = await service.getEnrichedFavorites(makeUser());
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({ resourceId: 'proj1', resourceName: 'My Project' });
@ -112,7 +129,7 @@ describe('FavoritesService', () => {
{ id: 'proj1', name: 'My Project' } as never,
]);
const result = await service.getEnrichedFavorites('user1');
const result = await service.getEnrichedFavorites(makeUser());
expect(result).toHaveLength(0);
});
@ -127,7 +144,7 @@ describe('FavoritesService', () => {
{ id: 'dt1', name: 'My Table', projectId: 'proj1' } as never,
]);
const result = await service.getEnrichedFavorites('user1');
const result = await service.getEnrichedFavorites(makeUser());
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
@ -147,7 +164,7 @@ describe('FavoritesService', () => {
{ id: 'dt1', name: 'My Table', projectId: 'proj-other' } as never,
]);
const result = await service.getEnrichedFavorites('user1');
const result = await service.getEnrichedFavorites(makeUser());
expect(result).toHaveLength(0);
});
@ -166,7 +183,7 @@ describe('FavoritesService', () => {
} as never,
]);
const result = await service.getEnrichedFavorites('user1');
const result = await service.getEnrichedFavorites(makeUser());
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
@ -190,7 +207,7 @@ describe('FavoritesService', () => {
} as never,
]);
const result = await service.getEnrichedFavorites('user1');
const result = await service.getEnrichedFavorites(makeUser());
expect(result).toHaveLength(0);
});
@ -209,7 +226,7 @@ describe('FavoritesService', () => {
} as never,
]);
const result = await service.getEnrichedFavorites('user1');
const result = await service.getEnrichedFavorites(makeUser());
expect(result).toHaveLength(0);
});
@ -233,10 +250,98 @@ describe('FavoritesService', () => {
{ id: 'folder1', name: 'My Folder', homeProject: { id: 'proj1' } } as never,
]);
const result = await service.getEnrichedFavorites('user1');
const result = await service.getEnrichedFavorites(makeUser());
expect(result).toHaveLength(4);
});
describe('global admin access', () => {
it('should enrich workflow favorites without explicit project membership', async () => {
const favorite = makeUserFavorite({ resourceId: 'wf1', resourceType: 'workflow' });
repo.findByUser.mockResolvedValue([favorite]);
projectRepository.getAccessibleProjects.mockResolvedValue([]);
workflowRepository.findByIds.mockResolvedValue([
{ id: 'wf1', name: 'My Workflow' } as never,
]);
const result = await service.getEnrichedFavorites(makeAdmin());
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({ resourceId: 'wf1', resourceName: 'My Workflow' });
expect(sharedWorkflowRepository.find).not.toHaveBeenCalled();
});
it('should enrich project favorites without explicit project membership', async () => {
const favorite = makeUserFavorite({ resourceId: 'proj-other', resourceType: 'project' });
repo.findByUser.mockResolvedValue([favorite]);
projectRepository.getAccessibleProjects.mockResolvedValue([]);
projectRepository.find.mockResolvedValue([
{ id: 'proj-other', name: 'Other Project' } as never,
]);
const result = await service.getEnrichedFavorites(makeAdmin());
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
resourceId: 'proj-other',
resourceName: 'Other Project',
});
});
it('should enrich dataTable favorites without explicit project membership', async () => {
const favorite = makeUserFavorite({ resourceId: 'dt1', resourceType: 'dataTable' });
repo.findByUser.mockResolvedValue([favorite]);
projectRepository.getAccessibleProjects.mockResolvedValue([]);
dataTableRepository.find.mockResolvedValue([
{ id: 'dt1', name: 'My Table', projectId: 'proj-other' } as never,
]);
const result = await service.getEnrichedFavorites(makeAdmin());
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
resourceId: 'dt1',
resourceName: 'My Table',
resourceProjectId: 'proj-other',
});
});
it('should enrich folder favorites without explicit project membership', async () => {
const favorite = makeUserFavorite({ resourceId: 'folder1', resourceType: 'folder' });
repo.findByUser.mockResolvedValue([favorite]);
projectRepository.getAccessibleProjects.mockResolvedValue([]);
folderRepository.find.mockResolvedValue([
{
id: 'folder1',
name: 'My Folder',
homeProject: { id: 'proj-other' },
} as never,
]);
const result = await service.getEnrichedFavorites(makeAdmin());
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
resourceId: 'folder1',
resourceName: 'My Folder',
resourceProjectId: 'proj-other',
});
});
it('should not query for additional projects when admin already has membership', async () => {
const favorite = makeUserFavorite({ resourceId: 'proj1', resourceType: 'project' });
repo.findByUser.mockResolvedValue([favorite]);
projectRepository.getAccessibleProjects.mockResolvedValue([
{ id: 'proj1', name: 'My Project' } as never,
]);
const result = await service.getEnrichedFavorites(makeAdmin());
expect(projectRepository.find).not.toHaveBeenCalled();
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({ resourceId: 'proj1', resourceName: 'My Project' });
});
});
});
describe('addFavorite', () => {

View File

@ -17,7 +17,7 @@ export class FavoritesController {
@Get('/')
async getFavorites(req: AuthenticatedRequest) {
return await this.favoritesService.getEnrichedFavorites(req.user.id);
return await this.favoritesService.getEnrichedFavorites(req.user);
}
@Post('/')

View File

@ -4,7 +4,10 @@ import {
ProjectRepository,
SharedWorkflowRepository,
WorkflowRepository,
type Project,
type User,
} from '@n8n/db';
import { hasGlobalScope } from '@n8n/permissions';
import { In } from '@n8n/typeorm';
import { UserFavoriteRepository } from './database/repositories/user-favorite.repository';
@ -15,6 +18,27 @@ import { DataTableRepository } from '@/modules/data-table/data-table.repository'
import type { FavoriteResourceType } from '@n8n/api-types';
type ResourceMeta = { name: string; projectId: string };
type Favorite = { resourceId: string; resourceType: FavoriteResourceType };
const idsOfType = (favorites: Favorite[], type: FavoriteResourceType): string[] =>
favorites.filter((f) => f.resourceType === type).map((f) => f.resourceId);
const enrichWithName = <F>(
fav: F,
name: string | undefined,
): Array<F & { resourceName: string }> =>
name === undefined ? [] : [{ ...fav, resourceName: name }];
const enrichWithMeta = <F>(
fav: F,
meta: ResourceMeta | undefined,
): Array<F & { resourceName: string; resourceProjectId: string }> =>
meta === undefined
? []
: [{ ...fav, resourceName: meta.name, resourceProjectId: meta.projectId }];
@Service()
export class FavoritesService {
private readonly MAX_FAVORITES = 200;
@ -27,103 +51,137 @@ export class FavoritesService {
private readonly folderRepository: FolderRepository,
) {}
async getEnrichedFavorites(userId: string) {
const favorites = await this.userFavoriteRepository.findByUser(userId);
async getEnrichedFavorites(user: User) {
const favorites = await this.userFavoriteRepository.findByUser(user.id);
if (favorites.length === 0) return [];
const workflowIds = favorites
.filter((f) => f.resourceType === 'workflow')
.map((f) => f.resourceId);
const dataTableIds = favorites
.filter((f) => f.resourceType === 'dataTable')
.map((f) => f.resourceId);
const folderIds = favorites.filter((f) => f.resourceType === 'folder').map((f) => f.resourceId);
const ids = {
workflow: idsOfType(favorites, 'workflow'),
project: idsOfType(favorites, 'project'),
dataTable: idsOfType(favorites, 'dataTable'),
folder: idsOfType(favorites, 'folder'),
};
// Get accessible projects for user
const accessibleProjects = await this.projectRepository.getAccessibleProjects(userId);
const accessibleProjects = await this.projectRepository.getAccessibleProjects(user.id);
const accessibleProjectIds = new Set(accessibleProjects.map((p) => p.id));
const projectNameMap = new Map(accessibleProjects.map((p) => [p.id, p.name ?? '']));
// Workflow, data table, and folder enrichment run in parallel
const [workflows, sharedWorkflows, dataTables, folders] = await Promise.all([
workflowIds.length > 0 && accessibleProjectIds.size > 0
? this.workflowRepository.findByIds(workflowIds, { fields: ['id', 'name'] })
: [],
workflowIds.length > 0 && accessibleProjectIds.size > 0
? this.sharedWorkflowRepository.find({
select: { workflowId: true },
where: { workflowId: In(workflowIds), projectId: In([...accessibleProjectIds]) },
})
: [],
dataTableIds.length > 0
? this.dataTableRepository.find({ where: { id: In(dataTableIds) } })
: [],
folderIds.length > 0
? this.folderRepository.find({
where: { id: In(folderIds) },
relations: { homeProject: true },
})
: [],
const [workflowNames, projectNames, dataTableMeta, folderMeta] = await Promise.all([
this.enrichWorkflowFavorites(user, ids.workflow, accessibleProjectIds),
this.enrichProjectFavorites(user, ids.project, accessibleProjects),
this.enrichDataTableFavorites(user, ids.dataTable, accessibleProjectIds),
this.enrichFolderFavorites(user, ids.folder, accessibleProjectIds),
]);
// Workflow enrichment with access control
const workflowNameMap = new Map<string, string>();
if (workflowIds.length > 0 && accessibleProjectIds.size > 0) {
const wfMap = new Map(workflows.map((wf) => [wf.id, wf.name]));
const accessibleWfIds = new Set(sharedWorkflows.map((sw) => sw.workflowId));
for (const id of workflowIds) {
const name = wfMap.get(id);
if (accessibleWfIds.has(id) && name !== undefined) {
workflowNameMap.set(id, name);
}
return favorites.flatMap((fav) => {
switch (fav.resourceType) {
case 'workflow':
return enrichWithName(fav, workflowNames.get(fav.resourceId));
case 'project':
return enrichWithName(fav, projectNames.get(fav.resourceId));
case 'dataTable':
return enrichWithMeta(fav, dataTableMeta.get(fav.resourceId));
case 'folder':
return enrichWithMeta(fav, folderMeta.get(fav.resourceId));
default:
return [];
}
}
});
}
private async enrichWorkflowFavorites(
user: User,
workflowIds: string[],
accessibleProjectIds: Set<string>,
): Promise<Map<string, string>> {
const result = new Map<string, string>();
if (workflowIds.length === 0) return result;
const hasGlobalAccess = hasGlobalScope(user, 'workflow:read');
if (!hasGlobalAccess && accessibleProjectIds.size === 0) return result;
const [workflows, sharedWorkflows] = await Promise.all([
this.workflowRepository.findByIds(workflowIds, { fields: ['id', 'name'] }),
hasGlobalAccess
? Promise.resolve([])
: this.sharedWorkflowRepository.find({
select: { workflowId: true },
where: { workflowId: In(workflowIds), projectId: In([...accessibleProjectIds]) },
}),
]);
const accessibleIds = hasGlobalAccess
? new Set(workflows.map((wf) => wf.id))
: new Set(sharedWorkflows.map((sw) => sw.workflowId));
for (const wf of workflows) {
if (accessibleIds.has(wf.id)) result.set(wf.id, wf.name);
}
return result;
}
private async enrichProjectFavorites(
user: User,
projectFavoriteIds: string[],
accessibleProjects: Project[],
): Promise<Map<string, string>> {
const result = new Map(accessibleProjects.map((p) => [p.id, p.name ?? '']));
if (projectFavoriteIds.length === 0) return result;
if (!hasGlobalScope(user, 'project:read')) return result;
const missingIds = projectFavoriteIds.filter((id) => !result.has(id));
if (missingIds.length === 0) return result;
const additional = await this.projectRepository.find({
where: { id: In(missingIds) },
select: { id: true, name: true },
});
for (const p of additional) {
result.set(p.id, p.name ?? '');
}
return result;
}
private async enrichDataTableFavorites(
user: User,
dataTableIds: string[],
accessibleProjectIds: Set<string>,
): Promise<Map<string, ResourceMeta>> {
const result = new Map<string, ResourceMeta>();
if (dataTableIds.length === 0) return result;
const hasGlobalAccess = hasGlobalScope(user, 'dataTable:read');
const dataTables = await this.dataTableRepository.find({ where: { id: In(dataTableIds) } });
// Data table enrichment with access control
const dataTableMetaMap = new Map<string, { name: string; projectId: string }>();
for (const dt of dataTables) {
if (accessibleProjectIds.has(dt.projectId)) {
dataTableMetaMap.set(dt.id, { name: dt.name, projectId: dt.projectId });
if (hasGlobalAccess || accessibleProjectIds.has(dt.projectId)) {
result.set(dt.id, { name: dt.name, projectId: dt.projectId });
}
}
return result;
}
private async enrichFolderFavorites(
user: User,
folderIds: string[],
accessibleProjectIds: Set<string>,
): Promise<Map<string, ResourceMeta>> {
const result = new Map<string, ResourceMeta>();
if (folderIds.length === 0) return result;
const hasGlobalAccess = hasGlobalScope(user, 'folder:read');
const folders = await this.folderRepository.find({
where: { id: In(folderIds) },
relations: { homeProject: true },
});
// Folder enrichment with access control
const folderMetaMap = new Map<string, { name: string; projectId: string }>();
for (const folder of folders) {
const projectId = folder.homeProject?.id;
if (projectId && accessibleProjectIds.has(projectId)) {
folderMetaMap.set(folder.id, { name: folder.name, projectId });
if (projectId && (hasGlobalAccess || accessibleProjectIds.has(projectId))) {
result.set(folder.id, { name: folder.name, projectId });
}
}
// Build enriched result, filtering out inaccessible resources
const enriched: Array<
(typeof favorites)[0] & { resourceName: string; resourceProjectId?: string }
> = [];
for (const fav of favorites) {
if (fav.resourceType === 'workflow') {
const name = workflowNameMap.get(fav.resourceId);
if (name !== undefined) enriched.push({ ...fav, resourceName: name });
} else if (fav.resourceType === 'project') {
const name = projectNameMap.get(fav.resourceId);
if (name !== undefined) {
enriched.push({ ...fav, resourceName: name });
}
} else if (fav.resourceType === 'dataTable') {
const meta = dataTableMetaMap.get(fav.resourceId);
if (meta !== undefined) {
enriched.push({ ...fav, resourceName: meta.name, resourceProjectId: meta.projectId });
}
} else if (fav.resourceType === 'folder') {
const meta = folderMetaMap.get(fav.resourceId);
if (meta !== undefined) {
enriched.push({ ...fav, resourceName: meta.name, resourceProjectId: meta.projectId });
}
}
}
return enriched;
return result;
}
async addFavorite(userId: string, resourceId: string, resourceType: FavoriteResourceType) {

View File

@ -55,6 +55,48 @@ describe('QuickConnectConfig', () => {
expect(options[0].consentText).toBe('Allow access to your account?');
});
it('parses valid config with disclaimer', () => {
const testConfig = [
{
packageName: '@n8n/superagent',
credentialType: 'agentApi',
text: 'Superagent for everyone',
quickConnectType: 'oauth',
disclaimer: {
text: 'Offer subject to terms (available {link}).',
linkUrl: 'https://example.com/terms',
linkLabel: 'here',
},
},
];
process.env.N8N_QUICK_CONNECT_OPTIONS = JSON.stringify(testConfig);
const { options } = Container.get(QuickConnectConfig);
expect(options).toEqual(testConfig);
expect(options[0].disclaimer?.linkUrl).toBe('https://example.com/terms');
});
it('parses disclaimer without optional linkLabel', () => {
const testConfig = [
{
packageName: '@n8n/superagent',
credentialType: 'agentApi',
text: 'Superagent for everyone',
quickConnectType: 'oauth',
disclaimer: {
text: 'Offer subject to terms (available {link}).',
linkUrl: 'https://example.com/terms',
},
},
];
process.env.N8N_QUICK_CONNECT_OPTIONS = JSON.stringify(testConfig);
const { options } = Container.get(QuickConnectConfig);
expect(options).toEqual(testConfig);
});
it('handles empty JSON array', () => {
process.env.N8N_QUICK_CONNECT_OPTIONS = '[]';
@ -159,6 +201,50 @@ describe('QuickConnectConfig', () => {
},
]),
],
[
'disclaimer text missing {link} placeholder',
JSON.stringify([
{
packageName: '@n8n/superagent',
credentialType: 'agentApi',
text: 'Superagent for everyone',
quickConnectType: 'oauth',
disclaimer: {
text: 'No placeholder here.',
linkUrl: 'https://example.com/terms',
},
},
]),
],
[
'disclaimer linkUrl is not a valid URL',
JSON.stringify([
{
packageName: '@n8n/superagent',
credentialType: 'agentApi',
text: 'Superagent for everyone',
quickConnectType: 'oauth',
disclaimer: {
text: 'Subject to terms (available {link}).',
linkUrl: 'not-a-url',
},
},
]),
],
[
'disclaimer missing linkUrl',
JSON.stringify([
{
packageName: '@n8n/superagent',
credentialType: 'agentApi',
text: 'Superagent for everyone',
quickConnectType: 'oauth',
disclaimer: {
text: 'Subject to terms (available {link}).',
},
},
]),
],
])('uses default if configuration is invalid: %s', (_description, config) => {
process.env.N8N_QUICK_CONNECT_OPTIONS = config;

View File

@ -1,6 +1,14 @@
import { Config, Env } from '@n8n/config';
import { z } from 'zod';
const disclaimerSchema = z.object({
text: z.string().refine((s) => s.includes('{link}'), {
message: '`disclaimer.text` must contain the {link} placeholder',
}),
linkUrl: z.string().url(),
linkLabel: z.string().optional(),
});
const baseQuickConnectOptionSchema = z.object({
packageName: z.string(),
credentialType: z.string(),
@ -8,6 +16,7 @@ const baseQuickConnectOptionSchema = z.object({
quickConnectType: z.string(),
consentText: z.string().optional(),
consentCheckbox: z.string().optional(),
disclaimer: disclaimerSchema.optional(),
config: z.never().optional(),
backendFlowConfig: z.never().optional(),
});

View File

@ -329,6 +329,7 @@ describe('SourceControlGitService', () => {
maxConcurrentProcesses: 6,
trimmed: false,
config: [`credential.helper=${expectedCredentialScript}`, 'credential.useHttpPath=true'],
unsafe: { allowUnsafeCredentialHelper: true },
}),
);
});
@ -366,9 +367,15 @@ describe('SourceControlGitService', () => {
mockSourceControlPreferencesService.getPreferences.mockReturnValue({
connectionType: 'ssh',
} as never);
(simpleGit as jest.Mock).mockClear();
await sourceControlGitService.setGitCommand();
expect(simpleGit).toHaveBeenCalledWith(
expect.objectContaining({
unsafe: { allowUnsafeSshCommand: true },
}),
);
expect(mockGitInstance.env).toHaveBeenCalledWith(
'GIT_SSH_COMMAND',
'ssh -o UserKnownHostsFile=".ssh/known_hosts" -o StrictHostKeyChecking=accept-new -i "private-key"',

View File

@ -140,6 +140,7 @@ export class SourceControlGitService {
// ensures that the credentials are only used for the configured repositoryUrl of the environment
'credential.useHttpPath=true',
],
unsafe: { allowUnsafeCredentialHelper: true },
};
// Add proxy configuration if proxy environment variables are set
@ -171,7 +172,12 @@ export class SourceControlGitService {
// - Subsequent connections: verifies against saved key
const sshCommand = `ssh -o UserKnownHostsFile="${escapedKnownHostsPath}" -o StrictHostKeyChecking=accept-new -i "${escapedPrivateKeyPath}"`;
this.git = simpleGit(this.gitOptions)
// Allow GIT_SSH_COMMAND so we can point SSH at n8n's own private key and known_hosts.
// This is safe because the command is constructed internally above, not from user input.
this.git = simpleGit({
...this.gitOptions,
unsafe: { allowUnsafeSshCommand: true },
})
.env('GIT_SSH_COMMAND', sshCommand)
.env('GIT_TERMINAL_PROMPT', '0');
}

View File

@ -1,110 +1,382 @@
import type { LeaderElectionClient } from '@/scaling/leader-election-client';
import type { Publisher } from '@/scaling/pubsub/publisher.service';
import type { RedisClientService } from '@/services/redis-client.service';
import { mockLogger } from '@n8n/backend-test-utils';
import type { GlobalConfig } from '@n8n/config';
import { MultiMainMetadata } from '@n8n/decorators';
import { mock } from 'jest-mock-extended';
import type { ErrorReporter, InstanceSettings } from 'n8n-core';
import type { Publisher } from '@/scaling/pubsub/publisher.service';
import type { RedisClientService } from '@/services/redis-client.service';
import { createResultOk, createResultError } from 'n8n-workflow';
import { MultiMainSetup } from '../multi-main-setup.ee';
function createInstanceSettings(hostId: string) {
let isLeader = false;
const settings = mock<InstanceSettings>({ hostId });
Object.defineProperty(settings, 'isLeader', {
get: () => isLeader,
configurable: true,
});
Object.defineProperty(settings, 'markAsLeader', {
value: jest.fn(() => {
isLeader = true;
}),
configurable: true,
writable: true,
});
Object.defineProperty(settings, 'markAsFollower', {
value: jest.fn(() => {
isLeader = false;
}),
configurable: true,
writable: true,
});
return settings;
}
describe('MultiMainSetup', () => {
const hostId = 'main-n8n-main-0';
const logger = mockLogger();
const publisher = mock<Publisher>();
const redisClientService = mock<RedisClientService>();
const errorReporter = mock<ErrorReporter>();
const metadata = new MultiMainMetadata();
const globalConfig = mock<GlobalConfig>({
redis: { prefix: 'n8n' },
multiMainSetup: { ttl: 10, interval: 3, enabled: true },
describe('with legacy implementation (flag off)', () => {
const logger = mockLogger();
const publisher = mock<Publisher>();
const redisClientService = mock<RedisClientService>();
const errorReporter = mock<ErrorReporter>();
const globalConfig = mock<GlobalConfig>({
redis: { prefix: 'n8n' },
multiMainSetup: { ttl: 10, interval: 3, enabled: true, newLeaderElection: false },
});
let instanceSettings: InstanceSettings;
let multiMainSetup: MultiMainSetup;
beforeEach(() => {
jest.clearAllMocks();
instanceSettings = createInstanceSettings(hostId);
redisClientService.toValidPrefix.mockReturnValue('n8n');
multiMainSetup = new MultiMainSetup(
logger,
instanceSettings,
globalConfig,
metadata,
errorReporter,
publisher,
redisClientService,
);
});
describe('init', () => {
it('should become leader if setIfNotExists succeeds', async () => {
publisher.setIfNotExists.mockResolvedValue(true);
const emit = jest.spyOn(multiMainSetup, 'emit');
await multiMainSetup.init();
expect(publisher.setIfNotExists).toHaveBeenCalled();
expect(instanceSettings.markAsLeader).toHaveBeenCalled();
expect(emit).toHaveBeenCalledWith('leader-takeover');
});
it('should remain follower if setIfNotExists returns false', async () => {
publisher.setIfNotExists.mockResolvedValue(false);
const emit = jest.spyOn(multiMainSetup, 'emit');
await multiMainSetup.init();
expect(instanceSettings.markAsLeader).not.toHaveBeenCalled();
expect(emit).not.toHaveBeenCalledWith('leader-takeover');
});
});
describe('checkLeader', () => {
it('should renew TTL when this instance is the leader', async () => {
publisher.setIfNotExists.mockResolvedValue(true);
await multiMainSetup.init();
jest.clearAllMocks();
publisher.get.mockResolvedValue(hostId);
await multiMainSetup['strategy'].checkLeader();
expect(publisher.setExpiration).toHaveBeenCalled();
});
it('should emit leader-takeover on mismatch recovery', async () => {
publisher.setIfNotExists.mockResolvedValue(false);
await multiMainSetup.init();
jest.clearAllMocks();
publisher.get.mockResolvedValue(hostId);
const emit = jest.spyOn(multiMainSetup, 'emit');
await multiMainSetup['strategy'].checkLeader();
expect(instanceSettings.markAsLeader).toHaveBeenCalled();
expect(emit).toHaveBeenCalledWith('leader-takeover');
});
it('should step down when another instance is leader', async () => {
publisher.setIfNotExists.mockResolvedValue(true);
await multiMainSetup.init();
jest.clearAllMocks();
publisher.get.mockResolvedValue('main-n8n-main-1');
const emit = jest.spyOn(multiMainSetup, 'emit');
await multiMainSetup['strategy'].checkLeader();
expect(instanceSettings.markAsFollower).toHaveBeenCalled();
expect(emit).toHaveBeenCalledWith('leader-stepdown');
});
it('should attempt to become leader when leadership is vacant', async () => {
publisher.setIfNotExists.mockResolvedValue(true);
await multiMainSetup.init();
jest.clearAllMocks();
publisher.get.mockResolvedValue(null);
publisher.setIfNotExists.mockResolvedValue(true);
const emit = jest.spyOn(multiMainSetup, 'emit');
await multiMainSetup['strategy'].checkLeader();
expect(emit).toHaveBeenCalledWith('leader-stepdown');
expect(publisher.setIfNotExists).toHaveBeenCalled();
expect(emit).toHaveBeenCalledWith('leader-takeover');
});
});
});
redisClientService.toValidPrefix.mockReturnValue('n8n');
describe('with new implementation (flag on)', () => {
const logger = mockLogger();
const publisher = mock<Publisher>();
const redisClientService = mock<RedisClientService>();
const errorReporter = mock<ErrorReporter>();
const client = mock<LeaderElectionClient>();
let instanceSettings: InstanceSettings;
let multiMainSetup: MultiMainSetup;
beforeEach(() => {
instanceSettings = mock<InstanceSettings>({ hostId, isLeader: false });
multiMainSetup = new MultiMainSetup(
logger,
instanceSettings,
publisher,
redisClientService,
globalConfig,
metadata,
errorReporter,
);
jest.clearAllMocks();
});
describe('checkLeader', () => {
beforeEach(async () => {
await multiMainSetup.init();
const globalConfig = mock<GlobalConfig>({
redis: { prefix: 'n8n' },
multiMainSetup: { ttl: 10, interval: 3, enabled: true, newLeaderElection: true },
});
it('should emit `leader-takeover` when Redis has own `hostId` but instance thinks it is follower', async () => {
publisher.get.mockResolvedValue(hostId);
const emit = jest.spyOn(multiMainSetup, 'emit');
let instanceSettings: InstanceSettings;
let multiMainSetup: MultiMainSetup;
// @ts-expect-error - private method
await multiMainSetup.checkLeader();
beforeEach(() => {
jest.clearAllMocks();
instanceSettings = createInstanceSettings(hostId);
expect(instanceSettings.markAsLeader).toHaveBeenCalled();
expect(emit).toHaveBeenCalledWith('leader-takeover');
jest.mock('@/scaling/leader-election-client', () => ({
LeaderElectionClient: jest.fn(),
}));
const { Container } = jest.requireActual('@n8n/di');
Container.set(
jest.requireMock('@/scaling/leader-election-client').LeaderElectionClient,
client,
);
multiMainSetup = new MultiMainSetup(
logger,
instanceSettings,
globalConfig,
metadata,
errorReporter,
publisher,
redisClientService,
);
});
it('should not emit `leader-takeover` when already leader', async () => {
publisher.get.mockResolvedValue(hostId);
Object.defineProperty(instanceSettings, 'isLeader', { get: () => true });
const emit = jest.spyOn(multiMainSetup, 'emit');
describe('init', () => {
it('should become leader if setLeaderIfNotExists succeeds', async () => {
client.setLeaderIfNotExists.mockResolvedValue(createResultOk(true));
const emit = jest.spyOn(multiMainSetup, 'emit');
// @ts-expect-error - private method
await multiMainSetup.checkLeader();
await multiMainSetup.init();
expect(instanceSettings.markAsLeader).not.toHaveBeenCalled();
expect(emit).not.toHaveBeenCalledWith('leader-takeover');
expect(publisher.setExpiration).toHaveBeenCalled();
expect(client.setLeaderIfNotExists).toHaveBeenCalled();
expect(instanceSettings.markAsLeader).toHaveBeenCalled();
expect(emit).toHaveBeenCalledWith('leader-takeover');
});
it('should remain follower if setLeaderIfNotExists returns false', async () => {
client.setLeaderIfNotExists.mockResolvedValue(createResultOk(false));
const emit = jest.spyOn(multiMainSetup, 'emit');
await multiMainSetup.init();
expect(instanceSettings.markAsLeader).not.toHaveBeenCalled();
expect(emit).not.toHaveBeenCalledWith('leader-takeover');
});
it('should remain follower if setLeaderIfNotExists fails', async () => {
client.setLeaderIfNotExists.mockResolvedValue(
createResultError(new Error('Command timed out')),
);
const emit = jest.spyOn(multiMainSetup, 'emit');
await multiMainSetup.init();
expect(instanceSettings.markAsLeader).not.toHaveBeenCalled();
expect(emit).not.toHaveBeenCalledWith('leader-takeover');
});
});
it('should emit `leader-stepdown` when another instance is leader', async () => {
publisher.get.mockResolvedValue('main-n8n-main-1');
Object.defineProperty(instanceSettings, 'isLeader', { get: () => true });
const emit = jest.spyOn(multiMainSetup, 'emit');
describe('checkLeader (leader path)', () => {
beforeEach(async () => {
client.setLeaderIfNotExists.mockResolvedValue(createResultOk(true));
await multiMainSetup.init();
jest.clearAllMocks();
});
// @ts-expect-error - private method
await multiMainSetup.checkLeader();
it('should stay leader when TTL renewal succeeds', async () => {
client.tryRenewLeaderTtl.mockResolvedValue(createResultOk({ id: 'success' }));
const emit = jest.spyOn(multiMainSetup, 'emit');
expect(instanceSettings.markAsFollower).toHaveBeenCalled();
expect(emit).toHaveBeenCalledWith('leader-stepdown');
await multiMainSetup['strategy'].checkLeader();
expect(client.tryRenewLeaderTtl).toHaveBeenCalled();
expect(instanceSettings.markAsFollower).not.toHaveBeenCalled();
expect(emit).not.toHaveBeenCalledWith('leader-stepdown');
});
it('should step down when another host is leader', async () => {
client.tryRenewLeaderTtl.mockResolvedValue(
createResultOk({ id: 'other-host-is-leader', currentLeaderId: 'main-n8n-main-1' }),
);
const emit = jest.spyOn(multiMainSetup, 'emit');
await multiMainSetup['strategy'].checkLeader();
expect(instanceSettings.markAsFollower).toHaveBeenCalled();
expect(emit).toHaveBeenCalledWith('leader-stepdown');
});
it('should try to re-acquire leader key when key is missing', async () => {
client.tryRenewLeaderTtl.mockResolvedValue(createResultOk({ id: 'key-missing' }));
client.setLeaderIfNotExists.mockResolvedValue(createResultOk(true));
await multiMainSetup['strategy'].checkLeader();
expect(client.setLeaderIfNotExists).toHaveBeenCalled();
expect(instanceSettings.markAsFollower).not.toHaveBeenCalled();
});
it('should step down when key is missing and re-acquire fails', async () => {
client.tryRenewLeaderTtl.mockResolvedValue(createResultOk({ id: 'key-missing' }));
client.setLeaderIfNotExists.mockResolvedValue(createResultOk(false));
const emit = jest.spyOn(multiMainSetup, 'emit');
await multiMainSetup['strategy'].checkLeader();
expect(instanceSettings.markAsFollower).toHaveBeenCalled();
expect(emit).toHaveBeenCalledWith('leader-stepdown');
});
it('should step down when key is missing and Redis command fails', async () => {
client.tryRenewLeaderTtl.mockResolvedValue(createResultOk({ id: 'key-missing' }));
client.setLeaderIfNotExists.mockResolvedValue(
createResultError(new Error('Command timed out')),
);
const emit = jest.spyOn(multiMainSetup, 'emit');
await multiMainSetup['strategy'].checkLeader();
expect(instanceSettings.markAsFollower).toHaveBeenCalled();
expect(emit).toHaveBeenCalledWith('leader-stepdown');
});
it('should stay leader when TTL renewal Redis command fails', async () => {
client.tryRenewLeaderTtl.mockResolvedValue(
createResultError(new Error('Command timed out')),
);
const emit = jest.spyOn(multiMainSetup, 'emit');
await multiMainSetup['strategy'].checkLeader();
expect(instanceSettings.markAsFollower).not.toHaveBeenCalled();
expect(emit).not.toHaveBeenCalledWith('leader-stepdown');
});
});
it('should not emit `leader-stepdown` when already a follower', async () => {
publisher.get.mockResolvedValue('main-n8n-main-1');
const emit = jest.spyOn(multiMainSetup, 'emit');
describe('checkLeader (follower path)', () => {
beforeEach(async () => {
client.setLeaderIfNotExists.mockResolvedValue(createResultOk(false));
await multiMainSetup.init();
jest.clearAllMocks();
});
// @ts-expect-error - private method
await multiMainSetup.checkLeader();
it('should become leader when Redis shows own hostId as leader (mismatch recovery)', async () => {
client.getLeader.mockResolvedValue(createResultOk(hostId));
const emit = jest.spyOn(multiMainSetup, 'emit');
expect(emit).not.toHaveBeenCalledWith('leader-stepdown');
});
await multiMainSetup['strategy'].checkLeader();
it('should attempt to become leader when leadership is vacant', async () => {
publisher.get.mockResolvedValue(null);
publisher.setIfNotExists.mockResolvedValue(true);
const emit = jest.spyOn(multiMainSetup, 'emit');
expect(errorReporter.info).toHaveBeenCalled();
expect(instanceSettings.markAsLeader).toHaveBeenCalled();
expect(emit).toHaveBeenCalledWith('leader-takeover');
});
// @ts-expect-error - private method
await multiMainSetup.checkLeader();
it('should stay follower when another instance is leader', async () => {
client.getLeader.mockResolvedValue(createResultOk('main-n8n-main-1'));
const emit = jest.spyOn(multiMainSetup, 'emit');
expect(instanceSettings.markAsFollower).toHaveBeenCalled();
expect(emit).toHaveBeenCalledWith('leader-stepdown');
expect(emit).toHaveBeenCalledWith('leader-takeover');
expect(instanceSettings.markAsLeader).toHaveBeenCalled();
await multiMainSetup['strategy'].checkLeader();
expect(instanceSettings.markAsLeader).not.toHaveBeenCalled();
expect(emit).not.toHaveBeenCalledWith('leader-takeover');
expect(emit).not.toHaveBeenCalledWith('leader-stepdown');
});
it('should attempt to become leader when leadership is vacant', async () => {
client.getLeader.mockResolvedValue(createResultOk(null));
client.setLeaderIfNotExists.mockResolvedValue(createResultOk(true));
const emit = jest.spyOn(multiMainSetup, 'emit');
await multiMainSetup['strategy'].checkLeader();
expect(client.setLeaderIfNotExists).toHaveBeenCalled();
expect(instanceSettings.markAsLeader).toHaveBeenCalled();
expect(emit).toHaveBeenCalledWith('leader-takeover');
});
it('should stay follower when leadership is vacant but setLeaderIfNotExists fails', async () => {
client.getLeader.mockResolvedValue(createResultOk(null));
client.setLeaderIfNotExists.mockResolvedValue(createResultOk(false));
const emit = jest.spyOn(multiMainSetup, 'emit');
await multiMainSetup['strategy'].checkLeader();
expect(instanceSettings.markAsLeader).not.toHaveBeenCalled();
expect(emit).not.toHaveBeenCalledWith('leader-takeover');
});
it('should stay follower when Redis is unreachable', async () => {
client.getLeader.mockResolvedValue(createResultError(new Error('Command timed out')));
const emit = jest.spyOn(multiMainSetup, 'emit');
await multiMainSetup['strategy'].checkLeader();
expect(instanceSettings.markAsLeader).not.toHaveBeenCalled();
expect(instanceSettings.markAsFollower).not.toHaveBeenCalled();
expect(emit).not.toHaveBeenCalledWith('leader-takeover');
expect(emit).not.toHaveBeenCalledWith('leader-stepdown');
});
it('should stay follower when leadership is vacant and Redis command fails', async () => {
client.getLeader.mockResolvedValue(createResultOk(null));
client.setLeaderIfNotExists.mockResolvedValue(
createResultError(new Error('Command timed out')),
);
const emit = jest.spyOn(multiMainSetup, 'emit');
await multiMainSetup['strategy'].checkLeader();
expect(instanceSettings.markAsLeader).not.toHaveBeenCalled();
expect(emit).not.toHaveBeenCalledWith('leader-takeover');
});
});
});
});

View File

@ -0,0 +1,151 @@
import { RedisClientService } from '@/services/redis-client.service';
import { GlobalConfig } from '@n8n/config';
import { Service } from '@n8n/di';
import type { Cluster, Redis } from 'ioredis';
import { InstanceSettings } from 'n8n-core';
import { ensureError, type Result, createResultOk, createResultError } from 'n8n-workflow';
const COMMAND_TIMEOUT_MS = 5_000;
/**
* Atomically extends the leader key TTL only if the current value matches the
* provided hostId
*
* KEYS[1] - leader key
* ARGV[1] - hostId
* ARGV[2] - TTL in seconds
*
* Returns:
* -1 key does not exist
* "actual-value" key exists but value is not the expected value
* 1 value matched and TTL was extended
* 0 value matched, but TTL was not extended
*/
const INCREASE_TTL_IF_LEADER = `
-- Renew only if we still hold the lock
local currentValue = redis.call("GET", KEYS[1])
if not currentValue then
return -1
end
if currentValue ~= ARGV[1] then
return currentValue
end
return redis.call("EXPIRE", KEYS[1], tonumber(ARGV[2]))
`;
export type TtlRenewalResultKeyMissing = { id: 'key-missing' };
export type TtlRenewalResultOtherHostIsLeader = {
id: 'other-host-is-leader';
currentLeaderId: string;
};
export type TtlRenewalResultSuccess = { id: 'success' };
export type TtlRenewalResult =
| TtlRenewalResultKeyMissing
| TtlRenewalResultOtherHostIsLeader
| TtlRenewalResultSuccess;
/**
* Redis-backed client for leader election in multi-main setups. Uses a TTL-based key to
* track which instance is the current leader.
*/
@Service()
export class LeaderElectionClient {
private readonly redisClient: Redis | Cluster;
private readonly leaderKey: string;
private readonly leaderKeyTtlInS: number;
private get hostId() {
return this.instanceSettings.hostId;
}
constructor(
private readonly instanceSettings: InstanceSettings,
globalConfig: GlobalConfig,
redisClientService: RedisClientService,
) {
const prefix = redisClientService.toValidPrefix(globalConfig.redis.prefix);
this.leaderKey = prefix + ':main_instance_leader';
this.leaderKeyTtlInS = globalConfig.multiMainSetup.ttl;
this.redisClient = redisClientService.createClient({
type: 'leader(n8n)',
extraOptions: { commandTimeout: COMMAND_TIMEOUT_MS },
});
}
/** Return the current leader's hostId, or `null` if the key is absent. */
async getLeader(): Promise<Result<string | null, Error>> {
try {
return createResultOk(await this.redisClient.get(this.leaderKey));
} catch (e) {
return createResultError(ensureError(e));
}
}
/** Claim leadership with a TTL. Returns `true` if the key was set (i.e. no leader yet). */
async setLeaderIfNotExists(): Promise<Result<boolean, Error>> {
try {
const result = await this.redisClient.set(
this.leaderKey,
this.hostId,
'EX',
this.leaderKeyTtlInS,
'NX',
);
return createResultOk(result === 'OK');
} catch (e) {
return createResultError(ensureError(e));
}
}
/** Atomically extend the leader key TTL only if this host still holds it. */
async tryRenewLeaderTtl(): Promise<Result<TtlRenewalResult, Error>> {
try {
const result = await this.redisClient.eval(
INCREASE_TTL_IF_LEADER,
1,
this.leaderKey,
this.hostId,
this.leaderKeyTtlInS,
);
if (result === -1 || result === 0) {
return createResultOk({ id: 'key-missing' });
}
if (result === 1) {
return createResultOk({ id: 'success' });
}
if (typeof result === 'string') {
return createResultOk({ id: 'other-host-is-leader', currentLeaderId: result });
}
return createResultError(
new Error(`Unexpected result from Redis script: ${JSON.stringify(result)}`),
);
} catch (e) {
return createResultError(ensureError(e));
}
}
/** Delete the leader key so another instance can claim leadership. */
async clearLeader(): Promise<Result<void, Error>> {
try {
await this.redisClient.del(this.leaderKey);
return createResultOk(undefined);
} catch (e) {
return createResultError(ensureError(e));
}
}
/** Disconnect the underlying Redis client. */
destroy() {
this.redisClient.disconnect();
}
}

View File

@ -0,0 +1,120 @@
import type { Logger } from '@n8n/backend-common';
import type { GlobalConfig } from '@n8n/config';
import type { ErrorReporter, InstanceSettings } from 'n8n-core';
import type { Publisher } from '@/scaling/pubsub/publisher.service';
import type { RedisClientService } from '@/services/redis-client.service';
import type { MultiMainStrategy } from './multi-main-setup.types';
type EmitFn = (event: 'leader-takeover' | 'leader-stepdown') => void;
export class MultiMainSetupLegacy implements MultiMainStrategy {
private leaderKey: string;
private readonly leaderKeyTtl: number;
constructor(
private readonly logger: Logger,
private readonly instanceSettings: InstanceSettings,
private readonly publisher: Publisher,
private readonly redisClientService: RedisClientService,
private readonly globalConfig: GlobalConfig,
private readonly errorReporter: ErrorReporter,
private readonly emit: EmitFn,
) {
this.leaderKeyTtl = this.globalConfig.multiMainSetup.ttl;
}
async init() {
const prefix = this.globalConfig.redis.prefix;
const validPrefix = this.redisClientService.toValidPrefix(prefix);
this.leaderKey = validPrefix + ':main_instance_leader';
await this.tryBecomeLeader();
}
async shutdown() {
const { isLeader } = this.instanceSettings;
if (isLeader) await this.publisher.clear(this.leaderKey);
}
async checkLeader() {
const leaderId = await this.publisher.get(this.leaderKey);
const { hostId } = this.instanceSettings;
if (leaderId === hostId) {
if (!this.instanceSettings.isLeader) {
this.errorReporter.info(
`[Instance ID ${hostId}] Remote/Local leadership mismatch, marking self as leader`,
{
shouldBeLogged: true,
shouldReport: true,
},
);
this.instanceSettings.markAsLeader();
this.emit('leader-takeover');
}
this.logger.debug(`[Instance ID ${hostId}] Leader is this instance`);
await this.publisher.setExpiration(this.leaderKey, this.leaderKeyTtl);
return;
}
if (leaderId && leaderId !== hostId) {
this.logger.debug(`[Instance ID ${hostId}] Leader is other instance "${leaderId}"`);
if (this.instanceSettings.isLeader) {
this.instanceSettings.markAsFollower();
this.emit('leader-stepdown');
this.logger.warn('[Multi-main setup] Leader failed to renew leader key');
}
return;
}
if (!leaderId) {
this.logger.debug(
`[Instance ID ${hostId}] Leadership vacant, attempting to become leader...`,
);
this.instanceSettings.markAsFollower();
this.emit('leader-stepdown');
await this.tryBecomeLeader();
}
}
private async tryBecomeLeader() {
const { hostId } = this.instanceSettings;
const keySetSuccessfully = await this.publisher.setIfNotExists(
this.leaderKey,
hostId,
this.leaderKeyTtl,
);
if (keySetSuccessfully) {
this.logger.info(`[Instance ID ${hostId}] Leader is now this instance`);
this.instanceSettings.markAsLeader();
this.emit('leader-takeover');
} else {
this.instanceSettings.markAsFollower();
}
}
async fetchLeaderKey() {
return await this.publisher.get(this.leaderKey);
}
}

View File

@ -0,0 +1,191 @@
import type { LeaderElectionClient } from '@/scaling/leader-election-client';
import type { Logger } from '@n8n/backend-common';
import type { ErrorReporter, InstanceSettings } from 'n8n-core';
import assert from 'node:assert';
import type { MultiMainStrategy } from './multi-main-setup.types';
type EmitFn = (event: 'leader-takeover' | 'leader-stepdown') => void;
export class MultiMainSetupV2 implements MultiMainStrategy {
private leaderCheckInProgress = false;
private get hostId() {
return this.instanceSettings.hostId;
}
constructor(
private readonly logger: Logger,
private readonly instanceSettings: InstanceSettings,
private readonly errorReporter: ErrorReporter,
private readonly client: LeaderElectionClient,
private readonly emit: EmitFn,
) {}
async init() {
const result = await this.client.setLeaderIfNotExists();
if (!result.ok) {
this.logRedisCommandFailure('Failed to set leader key in Redis during init', result.error);
this.instanceSettings.markAsFollower();
} else if (result.result) {
this.takeOverAsLeader();
} else {
this.instanceSettings.markAsFollower();
}
}
async shutdown() {
const { isLeader } = this.instanceSettings;
if (isLeader) {
// TODO: We should guard here that we only remove the key the key in Redis matches
// our host ID.
const result = await this.client.clearLeader();
if (!result.ok) {
this.logger.warn('Failed to clear leader key from Redis', { error: result.error });
}
}
this.client.destroy();
}
async checkLeader() {
if (this.leaderCheckInProgress) {
this.logger.warn('Previous leader check is still in progress, skipping this check');
return;
}
this.leaderCheckInProgress = true;
try {
if (this.instanceSettings.isLeader) {
await this.checkAreWeStillLeader();
} else {
await this.checkCanBecomeLeader();
}
} finally {
this.leaderCheckInProgress = false;
}
}
/** Renew our leadership lease. If we've lost the lease, step down to follower. */
private async checkAreWeStillLeader() {
assert(this.instanceSettings.isLeader);
const renewTtlResult = await this.client.tryRenewLeaderTtl();
if (!renewTtlResult.ok) {
this.logRedisCommandFailure('Failed to renew leader TTL', renewTtlResult.error);
// There's a decision to be made here: Do we step down or not? Redis might
// be unavailable for all clients or only for us. We could also track the TTL
// locally, but this would make the implementation more complex and error-prone.
// For now we accept that this might cause some inconsistencies in a network
// partition scenario, but eventually the system will recover once Redis is available again.
return;
}
const renewalResult = renewTtlResult.result;
if (renewalResult.id === 'success') {
this.logger.debug(`[Instance ID ${this.hostId}] Leader is this instance`);
return;
}
this.logger.warn('[Multi-main setup] Leader failed to renew leader key');
if (renewalResult.id === 'other-host-is-leader') {
this.logger.debug(
`[Instance ID ${this.hostId}] Leader is other instance "${renewalResult.currentLeaderId}"`,
);
this.stepDownToFollower();
return;
}
// The only remaining case is 'key-missing', which means we lost leadership
// (e.g. due to Redis unavailability or a network partition). In this case
// we try to become leader and step down if that fails.
assert(renewalResult.id === 'key-missing');
const result = await this.client.setLeaderIfNotExists();
if (!result.ok) {
this.logRedisCommandFailure('Failed to set leader key in Redis', result.error);
this.stepDownToFollower();
return;
}
const wasSet = result.result;
if (!wasSet) {
this.stepDownToFollower();
}
}
private async checkCanBecomeLeader() {
assert(!this.instanceSettings.isLeader);
const getResult = await this.client.getLeader();
if (!getResult.ok) {
this.logRedisCommandFailure('Failed to get leader key from Redis', getResult.error);
return;
}
const leaderId = getResult.result;
if (leaderId && leaderId === this.hostId) {
this.errorReporter.info(
`[Instance ID ${this.hostId}] Remote/Local leadership mismatch, marking self as leader`,
{
shouldBeLogged: true,
shouldReport: true,
},
);
this.takeOverAsLeader();
return;
}
if (leaderId) {
this.logger.debug(`[Instance ID ${this.hostId}] Leader is other instance "${leaderId}"`);
return;
}
this.logger.debug(
`[Instance ID ${this.hostId}] Leadership vacant, attempting to become leader...`,
);
const result = await this.client.setLeaderIfNotExists();
if (!result.ok) {
this.logger.warn('Failed to try leader key set in Redis', { error: result.error });
return;
}
const wasSet = result.result;
if (wasSet) {
this.takeOverAsLeader();
}
}
private takeOverAsLeader() {
assert(!this.instanceSettings.isLeader);
this.logger.info(`[Instance ID ${this.hostId}] Leader is now this instance`);
this.instanceSettings.markAsLeader();
this.emit('leader-takeover');
}
private stepDownToFollower() {
assert(this.instanceSettings.isLeader);
this.logger.info(`[Instance ID ${this.hostId}] This is now a follower instance`);
this.instanceSettings.markAsFollower();
this.emit('leader-stepdown');
}
async fetchLeaderKey() {
const result = await this.client.getLeader();
return result.ok ? result.result : null;
}
private logRedisCommandFailure(message: string, error: Error) {
this.logger.warn(`${message}: ${error.message}`, { error });
}
}

View File

@ -1,3 +1,4 @@
import { TypedEmitter } from '@/typed-emitter';
import { Logger } from '@n8n/backend-common';
import { GlobalConfig } from '@n8n/config';
import { Time } from '@n8n/constants';
@ -5,9 +6,13 @@ import { MultiMainMetadata } from '@n8n/decorators';
import { Container, Service } from '@n8n/di';
import { ErrorReporter, InstanceSettings } from 'n8n-core';
import type * as LeaderElectionClientModule from '@/scaling/leader-election-client';
import { Publisher } from '@/scaling/pubsub/publisher.service';
import { RedisClientService } from '@/services/redis-client.service';
import { TypedEmitter } from '@/typed-emitter';
import { MultiMainSetupLegacy } from './multi-main-setup-legacy';
import type { MultiMainStrategy } from './multi-main-setup.types';
import { MultiMainSetupV2 } from './multi-main-setup-v2';
type MultiMainEvents = {
/**
@ -28,34 +33,53 @@ type MultiMainEvents = {
/** Designates leader and followers when running multiple main processes. */
@Service()
export class MultiMainSetup extends TypedEmitter<MultiMainEvents> {
constructor(
private readonly logger: Logger,
private readonly instanceSettings: InstanceSettings,
private readonly publisher: Publisher,
private readonly redisClientService: RedisClientService,
private readonly globalConfig: GlobalConfig,
private readonly metadata: MultiMainMetadata,
private readonly errorReporter: ErrorReporter,
) {
super();
this.logger = this.logger.scoped(['scaling', 'multi-main-setup']);
}
private leaderKey: string;
private readonly leaderKeyTtl = this.globalConfig.multiMainSetup.ttl;
private readonly strategy: MultiMainStrategy;
private leaderCheckInterval: NodeJS.Timeout | undefined;
async init() {
const prefix = this.globalConfig.redis.prefix;
const validPrefix = this.redisClientService.toValidPrefix(prefix);
this.leaderKey = validPrefix + ':main_instance_leader';
constructor(
private readonly logger: Logger,
private readonly instanceSettings: InstanceSettings,
private readonly globalConfig: GlobalConfig,
private readonly metadata: MultiMainMetadata,
private readonly errorReporter: ErrorReporter,
private readonly publisher: Publisher,
private readonly redisClientService: RedisClientService,
) {
super();
this.logger = this.logger.scoped(['scaling', 'multi-main-setup']);
await this.tryBecomeLeader(); // prevent initial wait
const emitFn = (event: 'leader-takeover' | 'leader-stepdown') => this.emit(event);
if (this.globalConfig.multiMainSetup.newLeaderElection) {
const { LeaderElectionClient } =
require('@/scaling/leader-election-client') as typeof LeaderElectionClientModule;
const client = Container.get(LeaderElectionClient);
this.strategy = new MultiMainSetupV2(
this.logger,
this.instanceSettings,
this.errorReporter,
client,
emitFn,
);
} else {
this.strategy = new MultiMainSetupLegacy(
this.logger,
this.instanceSettings,
this.publisher,
this.redisClientService,
this.globalConfig,
this.errorReporter,
emitFn,
);
}
}
async init() {
await this.strategy.init();
this.leaderCheckInterval = setInterval(async () => {
await this.checkLeader();
await this.strategy.checkLeader();
}, this.globalConfig.multiMainSetup.interval * Time.seconds.toMilliseconds);
}
@ -63,90 +87,11 @@ export class MultiMainSetup extends TypedEmitter<MultiMainEvents> {
async shutdown() {
clearInterval(this.leaderCheckInterval);
const { isLeader } = this.instanceSettings;
if (isLeader) await this.publisher.clear(this.leaderKey);
await this.strategy.shutdown();
}
private async checkLeader() {
const leaderId = await this.publisher.get(this.leaderKey);
const { hostId } = this.instanceSettings;
if (leaderId === hostId) {
if (!this.instanceSettings.isLeader) {
// This indicates that the remote state indicated that this host is the leader, but this
// host believed it was a follower. See CAT-2200 for more context.
this.errorReporter.info(
`[Instance ID ${hostId}] Remote/Local leadership mismatch, marking self as leader`,
{
shouldBeLogged: true,
shouldReport: true,
},
);
this.instanceSettings.markAsLeader();
this.emit('leader-takeover');
}
this.logger.debug(`[Instance ID ${hostId}] Leader is this instance`);
await this.publisher.setExpiration(this.leaderKey, this.leaderKeyTtl);
return;
}
if (leaderId && leaderId !== hostId) {
this.logger.debug(`[Instance ID ${hostId}] Leader is other instance "${leaderId}"`);
if (this.instanceSettings.isLeader) {
this.instanceSettings.markAsFollower();
this.emit('leader-stepdown');
this.logger.warn('[Multi-main setup] Leader failed to renew leader key');
}
return;
}
if (!leaderId) {
this.logger.debug(
`[Instance ID ${hostId}] Leadership vacant, attempting to become leader...`,
);
this.instanceSettings.markAsFollower();
this.emit('leader-stepdown');
await this.tryBecomeLeader();
}
}
private async tryBecomeLeader() {
const { hostId } = this.instanceSettings;
// this can only succeed if leadership is currently vacant
const keySetSuccessfully = await this.publisher.setIfNotExists(
this.leaderKey,
hostId,
this.leaderKeyTtl,
);
if (keySetSuccessfully) {
this.logger.info(`[Instance ID ${hostId}] Leader is now this instance`);
this.instanceSettings.markAsLeader();
this.emit('leader-takeover');
} else {
this.instanceSettings.markAsFollower();
}
}
async fetchLeaderKey() {
return await this.publisher.get(this.leaderKey);
async fetchLeaderKey(): Promise<string | null> {
return await this.strategy.fetchLeaderKey();
}
registerEventHandlers() {
@ -155,7 +100,6 @@ export class MultiMainSetup extends TypedEmitter<MultiMainEvents> {
for (const { eventHandlerClass, methodName, eventName } of handlers) {
const instance = Container.get(eventHandlerClass);
this.on(eventName, async () => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return await instance[methodName].call(instance);
});
}

View File

@ -0,0 +1,6 @@
export interface MultiMainStrategy {
init(): Promise<void>;
shutdown(): Promise<void>;
checkLeader(): Promise<void>;
fetchLeaderKey(): Promise<string | null>;
}

View File

@ -118,9 +118,7 @@ export class Publisher {
// #endregion
// #region Utils for multi-main setup
// @TODO: The following methods are not pubsub-specific. Consider a dedicated client for multi-main setup.
// #region Key-value utils (used by MCP session store and legacy leader election)
async setIfNotExists(key: string, value: string, ttl: number) {
const result = await this.client.set(key, value, 'EX', ttl, 'NX');

View File

@ -7,7 +7,12 @@ export type RedisClientType = N8nRedisClientType | BullRedisClientType;
* - `publisher(n8n)` to send messages into scaling mode pubsub channels
* - `cache(n8n)` for caching operations (variables, resource ownership, etc.)
*/
type N8nRedisClientType = 'subscriber(n8n)' | 'publisher(n8n)' | 'cache(n8n)' | 'registry(n8n)';
type N8nRedisClientType =
| 'subscriber(n8n)'
| 'publisher(n8n)'
| 'cache(n8n)'
| 'registry(n8n)'
| 'leader(n8n)';
/**
* Redis client used internally by Bull. Suffixed with `(bull)` at `ScalingService.setupQueue`.

View File

@ -6,6 +6,7 @@ import {
type INodeType,
type IWorkflowExecuteAdditionalData,
type ResourceMapperFields,
Expression,
} from 'n8n-workflow';
import { DynamicNodeParametersService } from '../dynamic-node-parameters.service';
@ -84,6 +85,128 @@ describe('DynamicNodeParametersService', () => {
});
});
describe('expression isolate lifecycle', () => {
let acquireSpy: jest.SpyInstance;
let releaseSpy: jest.SpyInstance;
beforeEach(() => {
acquireSpy = jest.spyOn(Expression.prototype, 'acquireIsolate').mockResolvedValue(undefined);
releaseSpy = jest.spyOn(Expression.prototype, 'releaseIsolate').mockResolvedValue(undefined);
});
it('should acquire and release isolate around getOptionsViaMethodName', async () => {
const loadOptionsMethod = jest.fn().mockResolvedValue([{ name: 'opt', value: 'v' }]);
nodeTypes.getByNameAndVersion.mockReturnValue(
mock<INodeType>({
description: { properties: [] },
methods: { loadOptions: { getOptions: loadOptionsMethod } },
}),
);
await service.getOptionsViaMethodName(
'getOptions',
'',
mock<IWorkflowExecuteAdditionalData>(),
{ name: 'TestNode', version: 1 },
mock<INodeParameters>(),
);
expect(acquireSpy).toHaveBeenCalledTimes(1);
expect(releaseSpy).toHaveBeenCalledTimes(1);
});
it('should release isolate even when the inner method throws', async () => {
const loadOptionsMethod = jest.fn().mockRejectedValue(new Error('boom'));
nodeTypes.getByNameAndVersion.mockReturnValue(
mock<INodeType>({
description: { properties: [] },
methods: { loadOptions: { getOptions: loadOptionsMethod } },
}),
);
await expect(
service.getOptionsViaMethodName(
'getOptions',
'',
mock<IWorkflowExecuteAdditionalData>(),
{ name: 'TestNode', version: 1 },
mock<INodeParameters>(),
),
).rejects.toThrow('boom');
expect(acquireSpy).toHaveBeenCalledTimes(1);
expect(releaseSpy).toHaveBeenCalledTimes(1);
});
it('should acquire and release isolate around getResourceLocatorResults', async () => {
const listSearchMethod = jest
.fn()
.mockResolvedValue({ results: [{ name: 'r', value: 'v' }] });
nodeTypes.getByNameAndVersion.mockReturnValue(
mock<INodeType>({
description: { properties: [] },
methods: { listSearch: { searchModels: listSearchMethod } },
}),
);
await service.getResourceLocatorResults(
'searchModels',
'',
mock<IWorkflowExecuteAdditionalData>(),
{ name: 'TestNode', version: 1 },
mock<INodeParameters>(),
);
expect(acquireSpy).toHaveBeenCalledTimes(1);
expect(releaseSpy).toHaveBeenCalledTimes(1);
});
it('should acquire and release isolate around getResourceMappingFields', async () => {
const resourceMappingMethod = jest.fn().mockResolvedValue({
fields: [{ id: '1', displayName: 'F', defaultMatch: false, required: true, display: true }],
});
nodeTypes.getByNameAndVersion.mockReturnValue(
mock<INodeType>({
description: { properties: [] },
methods: { resourceMapping: { getFields: resourceMappingMethod } },
}),
);
await service.getResourceMappingFields(
'getFields',
'',
mock<IWorkflowExecuteAdditionalData>(),
{ name: 'TestNode', version: 1 },
mock<INodeParameters>(),
);
expect(acquireSpy).toHaveBeenCalledTimes(1);
expect(releaseSpy).toHaveBeenCalledTimes(1);
});
it('should acquire and release isolate around getActionResult', async () => {
const actionHandler = jest.fn().mockResolvedValue({ key: 'value' });
nodeTypes.getByNameAndVersion.mockReturnValue(
mock<INodeType>({
description: { properties: [] },
methods: { actionHandler: { handle: actionHandler } },
}),
);
await service.getActionResult(
'handle',
'',
mock<IWorkflowExecuteAdditionalData>(),
{ name: 'TestNode', version: 1 },
mock<INodeParameters>(),
undefined,
);
expect(acquireSpy).toHaveBeenCalledTimes(1);
expect(releaseSpy).toHaveBeenCalledTimes(1);
});
});
describe('getLocalResourceMappingFields', () => {
it('should remove duplicate resource mapping fields', async () => {
const resourceMappingMethod = jest.fn();

View File

@ -17,7 +17,6 @@ import { mock } from 'jest-mock-extended';
import { v4 as uuid } from 'uuid';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import { UrlService } from '@/services/url.service';
import { UserService } from '@/services/user.service';
@ -656,15 +655,12 @@ describe('UserService', () => {
});
describe('assertGetUsersAccess', () => {
it('should allow project admin to list all users', async () => {
it('should allow global member to list all users without project filter', async () => {
const member = Object.assign(new User(), { role: GLOBAL_MEMBER_ROLE });
projectService.getProjectIdsWithScope.mockResolvedValueOnce(['project-1']);
await expect(userService.assertGetUsersAccess(member)).resolves.toBeUndefined();
expect(projectService.getProjectIdsWithScope).toHaveBeenCalledWith(member, [
'project:update',
]);
expect(projectService.getProjectIdsWithScope).not.toHaveBeenCalled();
});
it('should allow non-admin members to list users by projectId', async () => {
@ -678,13 +674,6 @@ describe('UserService', () => {
]);
});
it('should throw ForbiddenError for member without project admin scope', async () => {
const member = Object.assign(new User(), { role: GLOBAL_MEMBER_ROLE });
projectService.getProjectIdsWithScope.mockResolvedValueOnce([]);
await expect(userService.assertGetUsersAccess(member)).rejects.toThrow(ForbiddenError);
});
it('should throw NotFoundError when filtering by unknown projectId', async () => {
const member = Object.assign(new User(), { role: GLOBAL_MEMBER_ROLE });
projectService.getProjectWithScope.mockResolvedValueOnce(null);

View File

@ -134,10 +134,11 @@ export class DynamicNodeParametersService {
const method = this.getMethod('loadOptions', methodName, nodeType);
const workflow = this.getWorkflow(nodeTypeAndVersion, currentNodeParameters, credentials);
const thisArgs = this.getThisArg(path, additionalData, workflow);
// Need to use untyped call since `this` usage is widespread and we don't have `strictBindCallApply`
// enabled in `tsconfig.json`
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return await method.call(thisArgs);
return await this.withExpressionIsolate(workflow, async () => {
// Need to use untyped call since `this` usage is widespread and we don't have `strictBindCallApply`
// enabled in `tsconfig.json`
return await method.call(thisArgs);
});
}
/** Returns the available options via a loadOptions param */
@ -210,17 +211,19 @@ export class DynamicNodeParametersService {
[],
);
const routingNode = new RoutingNode(executeFunctions, tempNodeType);
const optionsData = await routingNode.runNode();
return await this.withExpressionIsolate(workflow, async () => {
const optionsData = await routingNode.runNode();
if (optionsData?.length === 0) {
return [];
}
if (optionsData?.length === 0) {
return [];
}
if (!Array.isArray(optionsData)) {
throw new UnexpectedError('The returned data is not an array');
}
if (!Array.isArray(optionsData)) {
throw new UnexpectedError('The returned data is not an array');
}
return optionsData[0].map((item) => item.json) as unknown as INodePropertyOptions[];
return optionsData[0].map((item) => item.json) as unknown as INodePropertyOptions[];
});
}
async getResourceLocatorResults(
@ -237,8 +240,9 @@ export class DynamicNodeParametersService {
const method = this.getMethod('listSearch', methodName, nodeType);
const workflow = this.getWorkflow(nodeTypeAndVersion, currentNodeParameters, credentials);
const thisArgs = this.getThisArg(path, additionalData, workflow);
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return await method.call(thisArgs, filter, paginationToken);
return await this.withExpressionIsolate(workflow, async () => {
return await method.call(thisArgs, filter, paginationToken);
});
}
/** Returns the available mapping fields for the ResourceMapper component */
@ -254,7 +258,9 @@ export class DynamicNodeParametersService {
const method = this.getMethod('resourceMapping', methodName, nodeType);
const workflow = this.getWorkflow(nodeTypeAndVersion, currentNodeParameters, credentials);
const thisArgs = this.getThisArg(path, additionalData, workflow);
return this.removeDuplicateResourceMappingFields(await method.call(thisArgs));
return await this.withExpressionIsolate(workflow, async () =>
this.removeDuplicateResourceMappingFields(await method.call(thisArgs)),
);
}
/** Returns the available workflow input mapping fields for the ResourceMapper component */
@ -284,8 +290,22 @@ export class DynamicNodeParametersService {
const method = this.getMethod('actionHandler', handler, nodeType);
const workflow = this.getWorkflow(nodeTypeAndVersion, currentNodeParameters, credentials);
const thisArgs = this.getThisArg(path, additionalData, workflow);
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return await method.call(thisArgs, payload);
return await this.withExpressionIsolate(workflow, async () => {
return await method.call(thisArgs, payload);
});
}
/**
* When N8N_EXPRESSION_ENGINE=vm, expressions run in an isolate that must be acquired
* for this workflow before any code resolves {{ }} in parameters or credentials.
*/
private async withExpressionIsolate<T>(workflow: Workflow, fn: () => Promise<T>): Promise<T> {
await workflow.expression.acquireIsolate();
try {
return await fn();
} finally {
await workflow.expression.releaseIsolate();
}
}
private getMethod(

View File

@ -278,6 +278,20 @@ export class ProjectService {
return await this.projectRepository.getAccessibleProjectsAndCount(user.id, options);
}
// Returns the projects a caller can pick as share targets, including peer
// personal projects. Admins (project:read) still see everything; non-admin
// callers also see all personal projects so the share dropdown can surface
// other users. See `ProjectRepository.getShareableProjectsAndCount`.
async getShareableProjectsAndCount(
user: User,
options: ProjectListOptions,
): Promise<[Project[], number]> {
if (hasGlobalScope(user, 'project:read')) {
return await this.projectRepository.findAllProjectsAndCount(options);
}
return await this.projectRepository.getShareableProjectsAndCount(user.id, options);
}
async getPersonalProjectOwners(projectIds: string[]): Promise<ProjectRelation[]> {
return await this.projectRelationRepository.getPersonalProjectOwners(projectIds);
}

View File

@ -23,7 +23,6 @@ import type { IUserSettings } from 'n8n-workflow';
import { UserError } from 'n8n-workflow';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
import { InternalServerError } from '@/errors/response-errors/internal-server.error';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import { EventService } from '@/events/event.service';
@ -80,17 +79,6 @@ export class UserService {
}
return;
}
const isInstanceAdmin = ['global:owner', 'global:admin'].includes(user.role.slug);
if (isInstanceAdmin) {
return;
}
const hasProjectUpdateScope =
(await this.projectService.getProjectIdsWithScope(user, ['project:update'])).length > 0;
if (!hasProjectUpdateScope) {
throw new ForbiddenError(
'Listing all users is limited to instance administrators and project admins. Filter by project to list project members.',
);
}
}
async updateSettings(userId: string, newSettings: Partial<IUserSettings>) {

View File

@ -7,11 +7,18 @@ import { ExecutionsConfig } from '@n8n/config';
import { ExecutionRepository } from '@n8n/db';
import { Container, Service } from '@n8n/di';
import type { ExecutionLifecycleHooks } from 'n8n-core';
import { ErrorReporter, InstanceSettings, StorageConfig, WorkflowExecute } from 'n8n-core';
import {
ErrorReporter,
establishExecutionContext,
InstanceSettings,
StorageConfig,
WorkflowExecute,
} from 'n8n-core';
import type {
ExecutionError,
IDeferredPromise,
IExecuteResponsePromiseData,
INode,
IPinData,
IRun,
WorkflowExecuteMode,
@ -149,6 +156,28 @@ export class WorkflowRunner {
await hooks?.runHook('workflowExecuteAfter', [fullRunData]);
}
/**
* Persist a failed execution record for an already-registered execution and
* finalize it, so a pre-flight failure surfaces as a normal failed run.
*/
private async failExecution(
data: IWorkflowExecutionDataProcess,
executionId: string,
error: ExecutionError & { node?: INode },
responsePromise?: IDeferredPromise<IExecuteResponsePromiseData>,
): Promise<void> {
const runData = this.failedRunFactory.generateFailedExecutionFromError(
data.executionMode,
error,
error.node,
);
const lifecycleHooks = getLifecycleHooksForRegularMain(data, executionId);
await lifecycleHooks.runHook('workflowExecuteBefore', [undefined, data.executionData]);
await lifecycleHooks.runHook('workflowExecuteAfter', [runData]);
responsePromise?.reject(error);
this.activeExecutions.finalizeExecution(executionId);
}
/** Run the workflow
* @param realtime This is used in queue mode to change the priority of an execution, making sure they are picked up quicker.
*/
@ -159,25 +188,64 @@ export class WorkflowRunner {
restartExecutionId?: string,
responsePromise?: IDeferredPromise<IExecuteResponsePromiseData>,
): Promise<string> {
// Establish the execution context before persisting to the DB.
// activeExecutions.add() -> executionPersistence.create() writes
// data.executionData to the DB; any header masking or runtimeData
// population must happen before that write so the persisted record
// does not contain raw trigger-item data (e.g. Authorization headers).
// The runtimeData early-exit guard in establishExecutionContext keeps
// the subsequent worker-side call at workflow-execute.ts idempotent.
// Guard on the inner executionData: in queue mode with manual offload
// the outer IRunExecutionData is created with `executionData: null`
// so the trigger-item stack is undefined here; nothing to mask yet,
// the worker will establish context once it populates the stack.
let establishContextError: (ExecutionError & { node?: INode }) | undefined;
if (data.executionData?.executionData) {
// Deliberately lightweight: no pinData, no staticData loading,
// no additionalData. establishExecutionContext only needs the
// workflow's settings (for redactionPolicy) and node lookups.
// runMainProcess() builds its own fully-configured Workflow for
// actual execution.
const contextWorkflow = new Workflow({
id: data.workflowData.id,
name: data.workflowData.name,
nodes: data.workflowData.nodes,
connections: data.workflowData.connections,
active: data.workflowData.activeVersionId !== null,
nodeTypes: this.nodeTypes,
staticData: data.workflowData.staticData,
settings: data.workflowData.settings ?? {},
});
try {
await establishExecutionContext(
contextWorkflow,
data.executionData,
undefined,
data.executionMode,
);
} catch (error) {
// Masking may have failed partway through, so the trigger-item
// stack can still contain raw header data. Drop it before
// activeExecutions.add() persists the execution row.
data.executionData.executionData.nodeExecutionStack = [];
establishContextError = error as ExecutionError & { node?: INode };
}
}
// Register a new execution
const executionId = await this.activeExecutions.add(data, restartExecutionId);
if (establishContextError) {
await this.failExecution(data, executionId, establishContextError, responsePromise);
return executionId;
}
const { id: workflowId, nodes } = data.workflowData;
try {
await this.credentialsPermissionChecker.check(workflowId, nodes);
} catch (error) {
// Create a failed execution with the data for the node, save it and abort execution
const runData = this.failedRunFactory.generateFailedExecutionFromError(
data.executionMode,
error,
error.node,
);
const lifecycleHooks = getLifecycleHooksForRegularMain(data, executionId);
await lifecycleHooks.runHook('workflowExecuteBefore', [undefined, data.executionData]);
await lifecycleHooks.runHook('workflowExecuteAfter', [runData]);
responsePromise?.reject(error);
this.activeExecutions.finalizeExecution(executionId);
await this.failExecution(data, executionId, error, responsePromise);
return executionId;
}

View File

@ -120,6 +120,35 @@ describe('WorkflowExecutionService', () => {
expect(workflowRunner.run).toHaveBeenCalledTimes(1);
});
test('should forward deduplicationKey to `WorkflowRunner.run()`', async () => {
const node = mock<INode>();
const workflow = mock<IWorkflowBase>({
active: true,
activeVersionId: 'some-version-id',
nodes: [node],
});
workflowRunner.run.mockResolvedValue('fake-execution-id');
await workflowExecutionService.runWorkflow(
workflow,
node,
[[]],
mock(),
'trigger',
undefined,
'wf-1:node-1:1700000000000',
);
expect(workflowRunner.run).toHaveBeenCalledWith(
expect.objectContaining({ deduplicationKey: 'wf-1:node-1:1700000000000' }),
true,
undefined,
undefined,
undefined,
);
});
});
describe('executeManually()', () => {

View File

@ -61,6 +61,7 @@ export class WorkflowExecutionService {
additionalData: IWorkflowExecuteAdditionalData,
mode: WorkflowExecuteMode,
responsePromise?: IDeferredPromise<IExecuteResponsePromiseData>,
deduplicationKey?: string,
) {
const nodeExecutionStack: IExecuteData[] = [
{
@ -84,6 +85,7 @@ export class WorkflowExecutionService {
executionMode: mode,
executionData,
workflowData,
deduplicationKey,
};
return await this.workflowRunner.run(runData, true, undefined, undefined, responsePromise);

View File

@ -3,7 +3,6 @@ import type { WorkflowEntity } from '@n8n/db';
import { generateNanoId, WorkflowRepository } from '@n8n/db';
import { Container } from '@n8n/di';
import { InstanceSettings } from 'n8n-core';
import { ActiveWorkflowManager } from '@/active-workflow-manager';
import { MultiMainSetup } from '@/scaling/multi-main-setup.ee';

View File

@ -0,0 +1,72 @@
import { createWorkflow, testDb } from '@n8n/backend-test-utils';
import type { CreateExecutionPayload, WorkflowEntity } from '@n8n/db';
import { Container } from '@n8n/di';
import { createEmptyRunExecutionData } from 'n8n-workflow';
import { DuplicateExecutionError } from '@/errors/duplicate-execution.error';
import { ExecutionPersistence } from '@/executions/execution-persistence';
describe('ExecutionPersistence', () => {
let executionPersistence: ExecutionPersistence;
beforeAll(async () => {
await testDb.init();
executionPersistence = Container.get(ExecutionPersistence);
});
beforeEach(async () => {
await testDb.truncate(['ExecutionEntity']);
});
afterAll(async () => {
await testDb.terminate();
});
describe('create with deduplicationKey', () => {
const buildPayload = (
workflow: WorkflowEntity,
deduplicationKey?: string,
): CreateExecutionPayload => ({
data: createEmptyRunExecutionData(),
workflowData: workflow,
mode: 'trigger',
finished: false,
status: 'new',
workflowId: workflow.id,
deduplicationKey,
});
it('creates multiple executions when deduplicationKey is null', async () => {
const workflow = await createWorkflow();
const id1 = await executionPersistence.create(buildPayload(workflow));
const id2 = await executionPersistence.create(buildPayload(workflow));
const id3 = await executionPersistence.create(buildPayload(workflow));
expect(new Set([id1, id2, id3]).size).toBe(3);
});
it('creates executions with distinct deduplicationKeys', async () => {
const workflow = await createWorkflow();
const id1 = await executionPersistence.create(buildPayload(workflow, 'wf:node:t1'));
const id2 = await executionPersistence.create(buildPayload(workflow, 'wf:node:t2'));
expect(id1).not.toBe(id2);
});
it('throws DuplicateExecutionError when deduplicationKey is reused', async () => {
const workflow = await createWorkflow();
const key = 'wf:node:t1';
await executionPersistence.create(buildPayload(workflow, key));
await expect(executionPersistence.create(buildPayload(workflow, key))).rejects.toBeInstanceOf(
DuplicateExecutionError,
);
await expect(executionPersistence.create(buildPayload(workflow, key))).rejects.toMatchObject({
deduplicationKey: key,
});
});
});
});

View File

@ -34,7 +34,7 @@ import {
saveCredential,
shareCredentialWithProjects,
} from './shared/db/credentials';
import { createMember, createOwner, createUser } from './shared/db/users';
import { createChatUser, createMember, createOwner, createUser } from './shared/db/users';
import * as utils from './shared/utils/';
import { ActiveWorkflowManager } from '@/active-workflow-manager';
@ -143,6 +143,133 @@ describe('GET /projects/', () => {
});
});
describe('GET /projects/sharing-candidates', () => {
test('member sees own personal project plus all peer personal projects', async () => {
const [member1, member2, member3] = await Promise.all([
createMember(),
createMember(),
createMember(),
]);
const [teamProject1, teamProject2] = await Promise.all([
createTeamProject(undefined, member1),
createTeamProject(),
]);
const [personal1, personal2, personal3] = await Promise.all([
getPersonalProject(member1),
getPersonalProject(member2),
getPersonalProject(member3),
]);
const resp = await testServer
.authAgentFor(member1)
.get('/projects/sharing-candidates')
.query({ take: 50, skip: 0 });
expect(resp.status).toBe(200);
const respProjects = resp.body.data as Project[];
// Own + peer personal projects appear (3 personal projects total)
expect(respProjects.find((p) => p.id === personal1.id)).not.toBeUndefined();
expect(respProjects.find((p) => p.id === personal2.id)).not.toBeUndefined();
expect(respProjects.find((p) => p.id === personal3.id)).not.toBeUndefined();
// Team project the caller is a member of appears
expect(respProjects.find((p) => p.id === teamProject1.id)).not.toBeUndefined();
// Team project the caller is NOT a member of does not appear
expect(respProjects.find((p) => p.id === teamProject2.id)).toBeUndefined();
});
test('member does not see peer team projects they are not a member of', async () => {
const [member1, member2] = await Promise.all([createMember(), createMember()]);
const peerOnlyTeam = await createTeamProject(undefined, member2);
const resp = await testServer
.authAgentFor(member1)
.get('/projects/sharing-candidates')
.query({ take: 50, skip: 0 });
expect(resp.status).toBe(200);
const respProjects = resp.body.data as Project[];
expect(respProjects.find((p) => p.id === peerOnlyTeam.id)).toBeUndefined();
});
test('search filter narrows results across personal and relation branches', async () => {
const [member1, peer] = await Promise.all([
createUser({ firstName: 'Alice', lastName: 'Anderson' }),
createUser({ firstName: 'Bob', lastName: 'Banana' }),
]);
const matchingTeam = await createTeamProject('Banana Republic', member1);
const nonMatchingTeam = await createTeamProject('Other Project', member1);
const resp = await testServer
.authAgentFor(member1)
.get('/projects/sharing-candidates')
.query({ take: 50, skip: 0, search: 'banana' });
expect(resp.status).toBe(200);
const respProjects = resp.body.data as Project[];
const peerPersonal = await getPersonalProject(peer);
// Matches by team name
expect(respProjects.find((p) => p.id === matchingTeam.id)).not.toBeUndefined();
// Matches peer personal project (name contains "Banana")
expect(respProjects.find((p) => p.id === peerPersonal.id)).not.toBeUndefined();
// Non-matching team does not appear
expect(respProjects.find((p) => p.id === nonMatchingTeam.id)).toBeUndefined();
});
test('type=team filter excludes peer personal projects', async () => {
const [member1, member2] = await Promise.all([createMember(), createMember()]);
const team = await createTeamProject(undefined, member1);
const peerPersonal = await getPersonalProject(member2);
const resp = await testServer
.authAgentFor(member1)
.get('/projects/sharing-candidates')
.query({ take: 50, skip: 0, type: 'team' });
expect(resp.status).toBe(200);
const respProjects = resp.body.data as Project[];
expect(respProjects.find((p) => p.id === team.id)).not.toBeUndefined();
expect(respProjects.find((p) => p.id === peerPersonal.id)).toBeUndefined();
});
test('owner sees all projects via the admin path', async () => {
const [owner, peer1, peer2] = await Promise.all([
createOwner(),
createMember(),
createMember(),
]);
const [team1, team2] = await Promise.all([createTeamProject(), createTeamProject()]);
const [ownerPersonal, peer1Personal, peer2Personal] = await Promise.all([
getPersonalProject(owner),
getPersonalProject(peer1),
getPersonalProject(peer2),
]);
const resp = await testServer
.authAgentFor(owner)
.get('/projects/sharing-candidates')
.query({ take: 50, skip: 0 });
expect(resp.status).toBe(200);
const respProjects = resp.body.data as Project[];
// All five projects accessible to the admin
for (const expected of [ownerPersonal, peer1Personal, peer2Personal, team1, team2]) {
expect(respProjects.find((p) => p.id === expected.id)).not.toBeUndefined();
}
});
test('caller without user:list global scope receives 403', async () => {
const chatUser = await createChatUser();
const resp = await testServer
.authAgentFor(chatUser)
.get('/projects/sharing-candidates')
.query({ take: 50, skip: 0 });
expect(resp.status).toBe(403);
});
});
describe('Project members endpoints', () => {
test('POST /projects/:projectId/users adds a member and emits telemetry', async () => {
const owner = await createOwner();

View File

@ -48,11 +48,13 @@ describe('With license unlimited quota:users', () => {
await authOwnerAgent.get('/users').expect(401);
});
test('should forbid global user list for a member API key', async () => {
test('should allow global user list for a member API key with user:list scope', async () => {
const member = await createMemberWithApiKey();
await createUser();
await testServer.publicApiAgentFor(member).get('/users').expect(403);
const response = await testServer.publicApiAgentFor(member).get('/users').expect(200);
expect(response.body.data.length).toBe(2);
});
test('should allow member to list users of a project they belong to', async () => {

View File

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

View File

@ -456,6 +456,73 @@ describe('establishExecutionContext', () => {
'parent-exec-123',
);
});
it('should skip hook augmentation when runtimeData is already set (queue-mode worker resume)', async () => {
// When the main process established the context before persisting,
// the worker fetches the execution with runtimeData already populated.
// The function must return immediately without re-running hook augmentation.
const existingContext: IExecutionContext = {
version: 1,
establishedAt: 1234567890,
source: 'webhook',
credentials: 'encrypted-credentials-blob',
};
const webhookNode = mock<INode>({
name: 'Webhook',
type: 'n8n-nodes-base.webhook',
parameters: {},
});
const runExecutionData = createRunExecutionData({
startData: {},
resultData: { runData: {} },
executionData: {
contextData: {},
nodeExecutionStack: [
{
node: webhookNode,
data: {
main: [
[
{
json: {
headers: { authorization: 'original-header-value' },
},
},
],
],
},
source: null,
},
],
metadata: {},
waitingExecution: {},
waitingExecutionSource: {},
runtimeData: existingContext,
},
});
await establishExecutionContext(
mockWorkflow,
runExecutionData,
mockAdditionalData,
'webhook',
);
// runtimeData reference is preserved — same object, same values
expect(runExecutionData.executionData!.runtimeData).toBe(existingContext);
expect(runExecutionData.executionData!.runtimeData!.credentials).toBe(
'encrypted-credentials-blob',
);
// Trigger items are left untouched (hook augmentation was skipped).
// The main process already applied any transformations before persisting;
// this assertion just verifies the worker-side call is a no-op.
const headers = runExecutionData.executionData!.nodeExecutionStack[0].data.main[0]![0].json
.headers as Record<string, string>;
expect(headers.authorization).toBe('original-header-value');
});
});
describe('sub-workflow context inheritance', () => {

View File

@ -109,7 +109,7 @@ import { ExecutionContextService } from './execution-context.service';
export const establishExecutionContext = async (
workflow: Workflow,
runExecutionData: IRunExecutionData,
additionalData: IWorkflowExecuteAdditionalData,
additionalData: IWorkflowExecuteAdditionalData | undefined,
mode: WorkflowExecuteMode,
): Promise<void> => {
assertExecutionDataExists(runExecutionData.executionData, workflow, additionalData, mode);

View File

@ -91,4 +91,5 @@ export * from './execution-context-hook-registry.service';
export { ExecutionLifecycleHooks } from './execution-lifecycle-hooks';
export { ExternalSecretsProxy, type IExternalSecretsManager } from './external-secrets-proxy';
export { ExecutionContextService } from './execution-context.service';
export { establishExecutionContext } from './execution-context';
export { isEngineRequest } from './requests-response';

View File

@ -500,7 +500,7 @@ describe('Request Helper Functions', () => {
hostname: 'example.de',
href: requestObject.uri,
};
axiosOptions.beforeRedirect!(redirectOptions, mock());
axiosOptions.beforeRedirect!(redirectOptions, mock(), mock());
expect(redirectOptions.agent).toEqual(redirectOptions.agents.https);
expect((redirectOptions.agent as HttpsAgent).options).toMatchObject({
servername: 'example.de',
@ -1917,7 +1917,7 @@ describe('Request Helper Functions', () => {
};
expect(axiosOptions.beforeRedirect).toBeDefined();
expect(() => axiosOptions.beforeRedirect!(redirectOptions, mock())).toThrow(
expect(() => axiosOptions.beforeRedirect!(redirectOptions, mock(), mock())).toThrow(
'Domain not allowed',
);
});
@ -1936,7 +1936,9 @@ describe('Request Helper Functions', () => {
};
expect(axiosOptions.beforeRedirect).toBeDefined();
expect(() => axiosOptions.beforeRedirect!(redirectOptions, mock())).not.toThrow();
expect(() =>
axiosOptions.beforeRedirect!(redirectOptions, mock(), mock()),
).not.toThrow();
},
);
});
@ -1955,7 +1957,7 @@ describe('Request Helper Functions', () => {
};
expect(axiosConfig.beforeRedirect).toBeDefined();
expect(() => axiosConfig.beforeRedirect!(redirectOptions, mock())).toThrow(
expect(() => axiosConfig.beforeRedirect!(redirectOptions, mock(), mock())).toThrow(
'Domain not allowed',
);
});
@ -1975,7 +1977,9 @@ describe('Request Helper Functions', () => {
};
expect(axiosConfig.beforeRedirect).toBeDefined();
expect(() => axiosConfig.beforeRedirect!(redirectOptions, mock())).not.toThrow();
expect(() =>
axiosConfig.beforeRedirect!(redirectOptions, mock(), mock()),
).not.toThrow();
},
);
});

View File

@ -424,7 +424,11 @@ export async function proxyRequestToAxios(
): Promise<any> {
let axiosConfig: AxiosRequestConfig = {
maxBodyLength: Infinity,
maxContentLength: Infinity,
// -1 is the Axios sentinel for "no limit". Infinity also means no limit but
// Axios 1.15.1+ treats any value > -1 as a finite cap, wrapping stream responses
// in Readable.from() even when the limit is Infinity. That breaks the downstream
// `instanceof IncomingMessage` checks in parseIncomingMessage / prepareBinaryData.
maxContentLength: -1,
};
let configObject: IRequestOptions;
if (typeof uriOrObject === 'string') {

View File

@ -9,14 +9,14 @@ import {
export function assertExecutionDataExists(
executionData: IRunExecutionData['executionData'],
workflow: Workflow,
additionalData: IWorkflowExecuteAdditionalData,
additionalData: IWorkflowExecuteAdditionalData | undefined,
mode: WorkflowExecuteMode,
): asserts executionData is NonNullable<IRunExecutionData['executionData']> {
if (!executionData) {
throw new UnexpectedError('Failed to run workflow due to missing execution data', {
extra: {
workflowId: workflow.id,
executionId: additionalData.executionId,
executionId: additionalData?.executionId,
mode,
},
});

View File

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

View File

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

View File

@ -702,6 +702,11 @@ function onRenameNode(value: string) {
align-items: normal;
font-size: var(--font-size--2xs);
textarea {
height: 100%;
resize: none;
}
:global(.cm-editor) {
background-color: var(--code--color--background);
width: 100%;

View File

@ -509,6 +509,29 @@ describe('WorkflowHeaderDraftPublishActions', () => {
expect(getByTestId('version-menu-button')).not.toBeDisabled();
});
it('should keep the version menu enabled when workflow is published with no changes and unpublish is unavailable', () => {
workflowsStore.workflowTriggerNodes = [triggerNode];
workflowsStore.workflow.versionId = 'version-1';
workflowDocumentStore.setActiveState({
activeVersionId: 'version-1',
activeVersion: createMockActiveVersion('version-1'),
});
uiStore.markStateClean();
const { getByTestId } = renderComponent({
props: {
...defaultWorkflowProps,
workflowPermissions: {
...defaultWorkflowProps.workflowPermissions,
unpublish: false,
},
},
});
expect(getByTestId('workflow-open-publish-modal-button')).toBeDisabled();
expect(getByTestId('version-menu-button')).not.toBeDisabled();
});
it('should show publish button enabled when workflow has never been published (no active version)', () => {
workflowsStore.workflowTriggerNodes = [triggerNode];
workflowsStore.workflow.versionId = 'version-1';

View File

@ -353,6 +353,10 @@ const versionMenuActions = computed<Array<ActionDropdownItem<VERSION_ACTIONS>>>(
});
const shouldDisableActionDropdown = computed(() => {
if (activeVersion.value) {
return false;
}
return versionMenuActions.value.every((action) => action.disabled);
});

View File

@ -89,7 +89,7 @@ describe('WorkflowShareModal.ee.vue', () => {
settingsStore.settings.enterprise = { sharing: true } as FrontendSettings['enterprise'];
workflowsEEStore.getWorkflowOwnerName = vi.fn(() => 'Owner Name');
projectsStore.personalProjects = [createProjectListItem()];
projectsStore.searchProjects.mockResolvedValue({
projectsStore.searchShareableProjects.mockResolvedValue({
count: projectsStore.personalProjects.length,
data: projectsStore.personalProjects,
});

View File

@ -21,6 +21,27 @@ export const searchProjects = async (
return await getFullApiResponse<ProjectListItem[]>(context, 'GET', '/projects', params);
};
// Returns projects the caller can pick as share targets, including peer
// personal projects so the workflow / credential share dropdowns can list
// other users. Backed by `GET /rest/projects/sharing-candidates`.
export const searchShareableProjects = async (
context: IRestApiContext,
params: {
search?: string;
take?: number;
skip?: number;
type?: 'personal' | 'team';
activated?: boolean;
},
): Promise<{ count: number; data: ProjectListItem[] }> => {
return await getFullApiResponse<ProjectListItem[]>(
context,
'GET',
'/projects/sharing-candidates',
params,
);
};
export const getMyProjects = async (context: IRestApiContext): Promise<ProjectListItem[]> => {
return await makeRestApiRequest(context, 'GET', '/projects/my-projects');
};

View File

@ -126,6 +126,16 @@ export const useProjectsStore = defineStore(STORES.PROJECTS, () => {
return await projectsApi.searchProjects(rootStore.restApiContext, params);
};
const searchShareableProjects = async (params: {
search?: string;
take?: number;
skip?: number;
type?: 'personal' | 'team';
activated?: boolean;
}) => {
return await projectsApi.searchShareableProjects(rootStore.restApiContext, params);
};
const fetchProject = async (id: string) =>
await projectsApi.getProject(rootStore.restApiContext, id);
@ -345,6 +355,7 @@ export const useProjectsStore = defineStore(STORES.PROJECTS, () => {
projectNavActiveId,
setCurrentProject,
searchProjects,
searchShareableProjects,
getAllProjects,
getMyProjects,
getPersonalProject,

View File

@ -1,4 +1,10 @@
import { splitName } from './projects.utils';
import { createPinia, setActivePinia } from 'pinia';
import {
splitName,
useRemoteProjectSearch,
DEFAULT_PROJECT_SEARCH_PAGE_SIZE,
} from './projects.utils';
import { useProjectsStore } from './projects.store';
describe('splitName', () => {
test.each([
@ -76,3 +82,24 @@ describe('splitName', () => {
expect(splitName(input)).toEqual(result);
});
});
describe('useRemoteProjectSearch', () => {
beforeEach(() => {
setActivePinia(createPinia());
});
it('routes to the sharing-candidates endpoint via store.searchShareableProjects', async () => {
const store = useProjectsStore();
const spy = vi
.spyOn(store, 'searchShareableProjects')
.mockResolvedValue({ count: 0, data: [] });
const search = useRemoteProjectSearch();
await search('alice');
expect(spy).toHaveBeenCalledWith({
search: 'alice',
take: DEFAULT_PROJECT_SEARCH_PAGE_SIZE,
});
});
});

View File

@ -8,13 +8,15 @@ export type ProjectSearchResult = { count: number; data: ProjectListItem[] };
export type ProjectSearchFn = (query: string) => Promise<ProjectSearchResult>;
/**
* Remote search for Group 1 consumers (sharing/transfer modals).
* Always searches via GET /projects?search=&take= for ALL roles.
* Remote search for Group 1 consumers (sharing / transfer / user-deletion modals).
* Hits `GET /projects/sharing-candidates` so non-admin callers receive peer
* personal projects in addition to projects they have a relation to without
* that, the share dropdown would be empty for `global:member` users.
*/
export function useRemoteProjectSearch(): ProjectSearchFn {
const store = useProjectsStore();
return async (query: string) => {
return await store.searchProjects({
return await store.searchShareableProjects({
search: query,
take: DEFAULT_PROJECT_SEARCH_PAGE_SIZE,
});

View File

@ -293,7 +293,11 @@ watch(showOAuthSuccessBanner, (newValue, oldValue) => {
/>
<template v-if="isQuickConnectMode">
<QuickConnectBanner v-if="quickConnectBannerText" :text="quickConnectBannerText" />
<QuickConnectBanner
v-if="quickConnectBannerText || quickConnectOption?.disclaimer"
:text="quickConnectBannerText"
:disclaimer="quickConnectOption?.disclaimer"
/>
<QuickConnectButton
:service-name="serviceName"
:credential-type-name="credentialType.name"

View File

@ -100,7 +100,7 @@ describe('CredentialSharing.ee', () => {
// Mock store methods
vi.spyOn(usersStore, 'fetchUsers').mockResolvedValue();
vi.spyOn(projectsStore, 'getAllProjects').mockResolvedValue();
vi.spyOn(projectsStore, 'searchProjects').mockResolvedValue({
vi.spyOn(projectsStore, 'searchShareableProjects').mockResolvedValue({
count: testProjects.length,
data: testProjects,
});

View File

@ -23,9 +23,105 @@ describe('QuickConnectBanner', () => {
expect(wrapper.getByText(text)).toBeInTheDocument();
});
it('should not render callout when text is empty', () => {
it('should not render when text is empty and no disclaimer is provided', () => {
const wrapper = renderComponent({ pinia, props: { text: '' } });
expect(wrapper.queryByTestId('quick-connect-banner')).not.toBeInTheDocument();
});
it('should render the disclaimer text and link with provided linkUrl and linkLabel', () => {
const wrapper = renderComponent({
pinia,
props: {
disclaimer: {
text: 'Subject to terms (available {link}).',
linkUrl: 'https://example.com/terms',
linkLabel: 'over here',
},
},
});
const disclaimer = wrapper.getByTestId('quick-connect-banner-disclaimer');
expect(disclaimer).toBeInTheDocument();
expect(disclaimer).toHaveTextContent('Subject to terms (available over here).');
const link = disclaimer.querySelector('a');
expect(link).not.toBeNull();
expect(link).toHaveAttribute('href', 'https://example.com/terms');
expect(link).toHaveAttribute('target', '_blank');
expect(link).toHaveTextContent('over here');
});
it('should default linkLabel to "here" when omitted', () => {
const wrapper = renderComponent({
pinia,
props: {
disclaimer: {
text: 'Subject to terms (available {link}).',
linkUrl: 'https://example.com/terms',
},
},
});
const disclaimer = wrapper.getByTestId('quick-connect-banner-disclaimer');
expect(disclaimer.querySelector('a')).toHaveTextContent('here');
});
it('should escape HTML in disclaimer text, linkLabel, and linkUrl', () => {
const wrapper = renderComponent({
pinia,
props: {
disclaimer: {
text: 'Read <terms> (available {link}).',
linkUrl: 'https://example.com/terms?a=1&b=2',
linkLabel: '<b>evil</b>',
},
},
});
const disclaimer = wrapper.getByTestId('quick-connect-banner-disclaimer');
expect(disclaimer.querySelector('b')).toBeNull();
expect(disclaimer).toHaveTextContent('Read <terms> (available <b>evil</b>).');
const link = disclaimer.querySelector('a');
expect(link).not.toBeNull();
expect(link).toHaveTextContent('<b>evil</b>');
expect(link).toHaveAttribute('href', 'https://example.com/terms?a=1&b=2');
});
it('should render disclaimer alongside callout text', () => {
const wrapper = renderComponent({
pinia,
props: {
text: 'Offer text',
disclaimer: {
text: 'Subject to terms (available {link}).',
linkUrl: 'https://example.com/terms',
},
},
});
expect(wrapper.getByText('Offer text')).toBeInTheDocument();
expect(wrapper.getByTestId('quick-connect-banner-disclaimer')).toBeInTheDocument();
});
it('should not render the disclaimer paragraph when disclaimer prop is omitted', () => {
const wrapper = renderComponent({ pinia, props: { text: 'Offer text' } });
expect(wrapper.queryByTestId('quick-connect-banner-disclaimer')).not.toBeInTheDocument();
});
it('should render only the disclaimer when text is empty but disclaimer is provided', () => {
const wrapper = renderComponent({
pinia,
props: {
disclaimer: {
text: 'Subject to terms (available {link}).',
linkUrl: 'https://example.com/terms',
},
},
});
expect(wrapper.getByTestId('quick-connect-banner')).toBeInTheDocument();
expect(wrapper.getByTestId('quick-connect-banner-disclaimer')).toBeInTheDocument();
});
});

View File

@ -1,13 +1,61 @@
<script setup lang="ts">
import type { QuickConnectDisclaimer } from '@n8n/api-types';
import { N8nCallout } from '@n8n/design-system';
import { computed } from 'vue';
const { text } = defineProps<{
text: string;
const { text, disclaimer } = defineProps<{
text?: string;
disclaimer?: QuickConnectDisclaimer;
}>();
const escapeHtml = (value: string) =>
value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
const disclaimerHtml = computed(() => {
if (!disclaimer) return '';
const label = escapeHtml(disclaimer.linkLabel ?? 'here');
const url = escapeHtml(disclaimer.linkUrl);
const link = `<a href="${url}" target="_blank" rel="noopener noreferrer">${label}</a>`;
const parts = disclaimer.text.split('{link}').map(escapeHtml);
return parts.join(link);
});
</script>
<template>
<N8nCallout v-if="text" theme="secondary" iconless data-test-id="quick-connect-banner">
<div v-n8n-html="text"></div>
</N8nCallout>
<div v-if="text || disclaimer" :class="$style.wrapper" data-test-id="quick-connect-banner">
<N8nCallout v-if="text" theme="secondary" iconless>
<div v-n8n-html="text"></div>
</N8nCallout>
<div
v-if="disclaimer"
:class="$style.disclaimer"
data-test-id="quick-connect-banner-disclaimer"
v-n8n-html="disclaimerHtml"
></div>
</div>
</template>
<style lang="scss" module>
.wrapper {
display: flex;
flex-direction: column;
gap: var(--spacing--2xs);
}
.disclaimer {
margin: 0;
font-size: var(--font-size--2xs);
font-style: italic;
color: var(--color--text--tint-1);
a {
color: inherit;
text-decoration: underline;
}
}
</style>

View File

@ -764,6 +764,7 @@ function handleSelectAction(params: INodeParameters) {
<QuickConnectBanner
v-if="showQuickConnectBanner"
:text="quickConnect?.text ?? ''"
:disclaimer="quickConnect?.disclaimer"
:class="$style.quickConnectBanner"
/>
<NodeCredentials

View File

@ -175,7 +175,11 @@ onMounted(async () => {
</N8nTooltip>
</div>
<QuickConnectBanner v-if="quickConnect" :text="quickConnect?.text" />
<QuickConnectBanner
v-if="quickConnect"
:text="quickConnect?.text"
:disclaimer="quickConnect?.disclaimer"
/>
<ContactAdministratorToInstall v-if="!isAdminOrOwner && !communityNodeDetails?.installed" />
</div>
</template>

View File

@ -95,7 +95,7 @@ describe('DeleteUserModal', () => {
usersStore = mockedStore(useUsersStore);
const projectsStore = mockedStore(useProjectsStore);
projectsStore.searchProjects.mockResolvedValue({
projectsStore.searchShareableProjects.mockResolvedValue({
count: initialState[STORES.PROJECTS].projects.length,
data: initialState[STORES.PROJECTS].projects,
});

View File

@ -273,7 +273,7 @@ const callouts = computed<INodeCreateElement[]>(() => []);
<CommunityNodeInfo v-if="communityNodeDetails" />
<div :class="$style.banner" v-if="quickConnect">
<QuickConnectBanner :text="quickConnect.text" />
<QuickConnectBanner :text="quickConnect.text" :disclaimer="quickConnect.disclaimer" />
</div>
<OrderSwitcher v-if="rootView" :root-view="rootView">
<template v-if="shouldShowTriggers" #triggers>

View File

@ -1,30 +1,64 @@
import { readFile as fsReadFile } from 'fs/promises';
import { mockDeep, type DeepMockProxy } from 'jest-mock-extended';
import type { IExecuteFunctions, ILoadOptionsFunctions, INode } from 'n8n-workflow';
import type { IExecuteFunctions, INode } from 'n8n-workflow';
import { getWorkflowInfo } from './GenericFunctions';
jest.mock('fs/promises', () => ({
readFile: jest.fn().mockResolvedValue('sensitive data'),
}));
jest.mock('fs/promises', () => ({ readFile: jest.fn() }));
const mockReadFile = jest.mocked(fsReadFile);
const enoentError = () => Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
describe('ExecuteWorkflow node - GenericFunctions', () => {
let executeFunctionsMock: DeepMockProxy<ILoadOptionsFunctions | IExecuteFunctions>;
let executeFunctionsMock: DeepMockProxy<IExecuteFunctions>;
beforeEach(() => {
jest.clearAllMocks();
executeFunctionsMock = mockDeep<ILoadOptionsFunctions | IExecuteFunctions>();
executeFunctionsMock = mockDeep<IExecuteFunctions>();
executeFunctionsMock.helpers.resolvePath.mockResolvedValue('path/to/file' as never);
});
describe('getWorkflowInfo', () => {
it('should throw an error without the file content when source is localFile and the file is not json', async () => {
executeFunctionsMock.getNode.mockReturnValue({
typeVersion: 1,
} as INode);
executeFunctionsMock.getNodeParameter.mockReturnValue('path/to/file');
describe('when source is localFile', () => {
it('should throw an error without the file content when the file is not json', async () => {
executeFunctionsMock.getNode.mockReturnValue({ typeVersion: 1 } as INode);
executeFunctionsMock.getNodeParameter.mockReturnValue('path/to/file');
mockReadFile.mockResolvedValueOnce('non-json data');
await expect(getWorkflowInfo.call(executeFunctionsMock, 'localFile', 0)).rejects.toThrow(
'The file content is not valid JSON',
);
await expect(getWorkflowInfo.call(executeFunctionsMock, 'localFile', 0)).rejects.toThrow(
'The file content is not valid JSON',
);
});
it('should throw an error when the file is in a blocked path', async () => {
executeFunctionsMock.getNode.mockReturnValue({ typeVersion: 1 } as INode);
executeFunctionsMock.getNodeParameter.mockReturnValue('path/to/file');
executeFunctionsMock.helpers.isFilePathBlocked.mockReturnValue(true);
await expect(getWorkflowInfo.call(executeFunctionsMock, 'localFile', 0)).rejects.toThrow(
'Access to the workflow file path is not allowed',
);
});
it('should throw a friendly error when the parent directory does not exist', async () => {
executeFunctionsMock.getNode.mockReturnValue({ typeVersion: 1 } as INode);
executeFunctionsMock.getNodeParameter.mockReturnValue('/nonexistent/dir/file.json');
executeFunctionsMock.helpers.resolvePath.mockRejectedValue(enoentError());
await expect(getWorkflowInfo.call(executeFunctionsMock, 'localFile', 0)).rejects.toThrow(
'The file "/nonexistent/dir/file.json" could not be found, [item 0]',
);
});
it('should throw a friendly error when the file does not exist', async () => {
executeFunctionsMock.getNode.mockReturnValue({ typeVersion: 1 } as INode);
executeFunctionsMock.getNodeParameter.mockReturnValue('/existing/dir/missing.json');
mockReadFile.mockRejectedValueOnce(enoentError());
await expect(getWorkflowInfo.call(executeFunctionsMock, 'localFile', 0)).rejects.toThrow(
'The file "/existing/dir/missing.json" could not be found, [item 0]',
);
});
});
});
});

View File

@ -3,16 +3,11 @@ import { NodeOperationError, jsonParse } from 'n8n-workflow';
import type {
IExecuteFunctions,
IExecuteWorkflowInfo,
ILoadOptionsFunctions,
INodeParameterResourceLocator,
IRequestOptions,
} from 'n8n-workflow';
export async function getWorkflowInfo(
this: ILoadOptionsFunctions | IExecuteFunctions,
source: string,
itemIndex = 0,
) {
export async function getWorkflowInfo(this: IExecuteFunctions, source: string, itemIndex = 0) {
const workflowInfo: IExecuteWorkflowInfo = {};
const nodeVersion = this.getNode().typeVersion;
if (source === 'database') {
@ -31,20 +26,29 @@ export async function getWorkflowInfo(
// Read workflow from filesystem
const workflowPath = this.getNodeParameter('workflowPath', itemIndex) as string;
let workflowJson;
try {
workflowJson = await fsReadFile(workflowPath, { encoding: 'utf8' });
} catch (error) {
if (error.code === 'ENOENT') {
const handleFileError = (error: unknown): never => {
if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') {
throw new NodeOperationError(
this.getNode(),
`The file "${workflowPath}" could not be found, [item ${itemIndex}]`,
);
}
throw error;
};
const resolvedPath = await this.helpers.resolvePath(workflowPath).catch(handleFileError);
if (this.helpers.isFilePathBlocked(resolvedPath)) {
throw new NodeOperationError(
this.getNode(),
'Access to the workflow file path is not allowed',
);
}
const workflowJson = await fsReadFile(resolvedPath, { encoding: 'utf8' }).catch(
handleFileError,
);
workflowInfo.code = jsonParse(workflowJson, {
errorMessage: 'The file content is not valid JSON', // pass a custom error message to not expose the file contents
});

View File

@ -4,7 +4,7 @@ import nock from 'nock';
describe('Test Binary Data Download', () => {
const baseUrl = 'https://dummy.domain';
beforeAll(async () => {
beforeAll(() => {
nock(baseUrl)
.persist()
.get('/path/to/image.png')

View File

@ -73,6 +73,44 @@ export async function destroy(conn: snowflake.Connection) {
});
}
export function escapeSnowflakeIdentifier(identifier: string): string {
if (identifier.startsWith('"') && identifier.endsWith('"') && identifier.length > 2) {
// Already quoted — preserve case (Snowflake quoted identifiers are case-sensitive)
const bare = identifier.slice(1, -1).replace(/""/g, '"');
return `"${bare.replace(/"/g, '""')}"`;
}
// Snowflake stores unquoted identifiers as UPPERCASE by default; uppercase for compatibility
return `"${identifier.toUpperCase().replace(/"/g, '""')}"`;
}
export function escapeSnowflakeObjectIdentifier(identifier: string): string {
const parts: string[] = [];
let current = '';
let inQuotes = false;
for (let i = 0; i < identifier.length; i++) {
const char = identifier[i];
if (char === '"') {
if (inQuotes && identifier[i + 1] === '"') {
// Escaped double-quote inside a quoted identifier
current += '""';
i++;
} else {
inQuotes = !inQuotes;
current += char;
}
} else if (char === '.' && !inQuotes) {
parts.push(current);
current = '';
} else {
current += char;
}
}
parts.push(current);
return parts.map(escapeSnowflakeIdentifier).join('.');
}
export async function execute(
conn: snowflake.Connection,
sqlText: string,

Some files were not shown because too many files have changed in this diff Show More