mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-22 20:41:50 +02:00
Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b58d9e89d6 | ||
|
|
9395e2a436 | ||
|
|
664275d0cb | ||
|
|
c30d4d909a | ||
|
|
12fe535b4b | ||
|
|
0cbb74c675 | ||
|
|
654315ee2b | ||
|
|
e993c789c8 | ||
|
|
fa60c78685 | ||
|
|
5c0f50cc5c | ||
|
|
db15336502 | ||
|
|
370a687d6e | ||
|
|
67db65c88f | ||
|
|
69fd1cb70a | ||
|
|
f00e4f4c4d | ||
|
|
615a518eaa | ||
|
|
6ff319863b | ||
|
|
1f074d4ea7 | ||
|
|
50688e31ed | ||
|
|
69651932d6 | ||
|
|
b79afe2390 | ||
|
|
bfbeea9c35 | ||
|
|
c09e0a94f5 | ||
|
|
6525584709 | ||
|
|
c52f1817b3 | ||
|
|
16b49eb412 | ||
|
|
89f18b23b8 | ||
|
|
321b0203b7 | ||
|
|
c25417c88e |
6
.github/WORKFLOWS.md
vendored
6
.github/WORKFLOWS.md
vendored
|
|
@ -568,8 +568,8 @@ Runs on push to `master`:
|
||||||
```
|
```
|
||||||
Push to master
|
Push to master
|
||||||
├─ build-and-format (Blacksmith: build, then format check; populate master cache)
|
├─ build-and-format (Blacksmith: build, then format check; populate master cache)
|
||||||
├─ unit-test (matrix: Node 24.18.1, 26.5.1)
|
├─ unit-test (matrix: Node 24.22.1, 26.12.1)
|
||||||
│ └─ Coverage only on 24.18.1
|
│ └─ Coverage only on 24.22.1
|
||||||
├─ lint
|
├─ lint
|
||||||
├─ (performance: CodSpeed benchmarks, paused until the plugin supports Vitest 5)
|
├─ (performance: CodSpeed benchmarks, paused until the plugin supports Vitest 5)
|
||||||
├─ verify-single-instance-npm (advisory; packages changed by this push)
|
├─ verify-single-instance-npm (advisory; packages changed by this push)
|
||||||
|
|
@ -650,7 +650,7 @@ Composite actions in `.github/actions/`:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
inputs:
|
inputs:
|
||||||
node-version: # default: '26.5.1'
|
node-version: # default: '26.12.1'
|
||||||
enable-docker-cache: # default: 'false' (Blacksmith Buildx)
|
enable-docker-cache: # default: 'false' (Blacksmith Buildx)
|
||||||
docker-cache-key: # required when enable-docker-cache is true
|
docker-cache-key: # required when enable-docker-cache is true
|
||||||
build-command: # default: 'pnpm build'
|
build-command: # default: 'pnpm build'
|
||||||
|
|
|
||||||
4
.github/actions/setup-nodejs/action.yml
vendored
4
.github/actions/setup-nodejs/action.yml
vendored
|
|
@ -7,9 +7,9 @@ description: 'Configures Node.js with pnpm, installs Aikido SafeChain for supply
|
||||||
|
|
||||||
inputs:
|
inputs:
|
||||||
node-version:
|
node-version:
|
||||||
description: 'Node.js version to use. Pinned to 26.5.1 by default for reproducible builds.'
|
description: 'Node.js version to use. Pinned to 26.12.1 by default for reproducible builds.'
|
||||||
required: false
|
required: false
|
||||||
default: '26.5.1'
|
default: '26.12.1'
|
||||||
enable-docker-cache:
|
enable-docker-cache:
|
||||||
description: 'Whether to set up Blacksmith Buildx for Docker layer caching (Blacksmith runners only).'
|
description: 'Whether to set up Blacksmith Buildx for Docker layer caching (Blacksmith runners only).'
|
||||||
required: false
|
required: false
|
||||||
|
|
|
||||||
10
.github/workflows/build-base-image.yml
vendored
10
.github/workflows/build-base-image.yml
vendored
|
|
@ -32,11 +32,11 @@ jobs:
|
||||||
# when updating.
|
# when updating.
|
||||||
include:
|
include:
|
||||||
- node_version: '22'
|
- node_version: '22'
|
||||||
dhi_ref: dhi.io/node:22.23.3-alpine3.24-dev@sha256:23ee07ace431ea7d14e22c0e52fb52b0bd1853dabdacd89ba75f937f6dff8a8d
|
dhi_ref: dhi.io/node:22.23.4-alpine3.24-dev@sha256:a2c4778492ad46fd6f362d8d93e21c0543d0009db7f934d5eb9f9ccb77eafdcb
|
||||||
- node_version: '24.21.0'
|
- node_version: '24.22.1'
|
||||||
dhi_ref: dhi.io/node:24.21.0-alpine3.24-dev@sha256:d22ba0e9ab2a54609ae533df4ebfd6f36c1f233b662a4c547f5a3195e8c21937
|
dhi_ref: dhi.io/node:24.22.1-alpine3.24-dev@sha256:7b851ff65d774343caf89450555663caef54dbd8f673658a615d256883973337
|
||||||
- node_version: '26.10.0'
|
- node_version: '26.12.1'
|
||||||
dhi_ref: dhi.io/node:26.10.0-alpine3.24-dev@sha256:8da859df5dc853c923ace53c4cfe994a909a28917f7fcfc8508869beab8cf132
|
dhi_ref: dhi.io/node:26.12.1-alpine3.24-dev@sha256:4b1591bfce570a17f9562e206b62e76be84d6df32c3a86bfc677fc87959402cc
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
|
||||||
|
|
|
||||||
2
.github/workflows/build-node-pc-image.yml
vendored
2
.github/workflows/build-node-pc-image.yml
vendored
|
|
@ -25,7 +25,7 @@ on:
|
||||||
type: boolean
|
type: boolean
|
||||||
|
|
||||||
env:
|
env:
|
||||||
NODE_VERSION: '26.10.0'
|
NODE_VERSION: '26.12.1'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
|
|
|
||||||
4
.github/workflows/ci-master.yml
vendored
4
.github/workflows/ci-master.yml
vendored
|
|
@ -40,11 +40,11 @@ jobs:
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
node-version: [24.18.1, 26.5.1]
|
node-version: [24.22.1, 26.12.1]
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.sha }}
|
ref: ${{ github.sha }}
|
||||||
nodeVersion: ${{ matrix.node-version }}
|
nodeVersion: ${{ matrix.node-version }}
|
||||||
collectCoverage: ${{ matrix.node-version == '24.18.1' }}
|
collectCoverage: ${{ matrix.node-version == '24.22.1' }}
|
||||||
secrets: inherit
|
secrets: inherit
|
||||||
|
|
||||||
lint:
|
lint:
|
||||||
|
|
|
||||||
2
.github/workflows/docker-build-push.yml
vendored
2
.github/workflows/docker-build-push.yml
vendored
|
|
@ -8,7 +8,7 @@ run-name: "${{ github.event_name == 'workflow_dispatch' && format('Docker: Build
|
||||||
|
|
||||||
env:
|
env:
|
||||||
NODE_OPTIONS: '--max-old-space-size=7168'
|
NODE_OPTIONS: '--max-old-space-size=7168'
|
||||||
NODE_VERSION: '26.10.0'
|
NODE_VERSION: '26.12.1'
|
||||||
|
|
||||||
on:
|
on:
|
||||||
schedule:
|
schedule:
|
||||||
|
|
|
||||||
2
.github/workflows/docker-build-smoke.yml
vendored
2
.github/workflows/docker-build-smoke.yml
vendored
|
|
@ -12,7 +12,7 @@ env:
|
||||||
# The images copy native bindings from the host build, so both have to use the same
|
# The images copy native bindings from the host build, so both have to use the same
|
||||||
# Node. dockerize-n8n.mjs reads this env for the image builds. Keep in sync with
|
# Node. dockerize-n8n.mjs reads this env for the image builds. Keep in sync with
|
||||||
# NODE_VERSION in docker-build-push.yml, which is what the published images use.
|
# NODE_VERSION in docker-build-push.yml, which is what the published images use.
|
||||||
NODE_VERSION: '26.10.0'
|
NODE_VERSION: '26.12.1'
|
||||||
# Also build the distroless runners image and exec-check
|
# Also build the distroless runners image and exec-check
|
||||||
# both runners images' interpreters, so a broken runtime assembly (e.g. a
|
# both runners images' interpreters, so a broken runtime assembly (e.g. a
|
||||||
# missing shared library after a Node bump) fails at PR time. The smoke
|
# missing shared library after a Node bump) fails at PR time. The smoke
|
||||||
|
|
|
||||||
2
.github/workflows/docker-sbom-probe.yml
vendored
2
.github/workflows/docker-sbom-probe.yml
vendored
|
|
@ -19,7 +19,7 @@ on:
|
||||||
default: false
|
default: false
|
||||||
|
|
||||||
env:
|
env:
|
||||||
NODE_VERSION: '26.10.0'
|
NODE_VERSION: '26.12.1'
|
||||||
SCRATCH_TAG: sbom-probe-${{ github.run_id }}
|
SCRATCH_TAG: sbom-probe-${{ github.run_id }}
|
||||||
# A separate package. These are throwaway fixtures (one is a two-layer
|
# A separate package. These are throwaway fixtures (one is a two-layer
|
||||||
# alpine), and ghcr.io/<repo> is where the real release tags live - they
|
# alpine), and ghcr.io/<repo> is where the real release tags live - they
|
||||||
|
|
|
||||||
|
|
@ -76,7 +76,7 @@ jobs:
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||||
with:
|
with:
|
||||||
node-version: 24.18.1
|
node-version: 24.22.1
|
||||||
|
|
||||||
# Remove after https://github.com/npm/cli/issues/8547 gets resolved
|
# Remove after https://github.com/npm/cli/issues/8547 gets resolved
|
||||||
- run: echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > ~/.npmrc
|
- run: echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > ~/.npmrc
|
||||||
|
|
|
||||||
|
|
@ -67,7 +67,7 @@ jobs:
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||||
with:
|
with:
|
||||||
node-version: '24.18.1'
|
node-version: '24.22.1'
|
||||||
|
|
||||||
# A version bump only edits one package.json, so no workspace install/build
|
# A version bump only edits one package.json, so no workspace install/build
|
||||||
# is needed. `pnpm ls -r --only-projects` reads the workspace config directly.
|
# is needed. `pnpm ls -r --only-projects` reads the workspace config directly.
|
||||||
|
|
|
||||||
2
.github/workflows/test-bench-reusable.yml
vendored
2
.github/workflows/test-bench-reusable.yml
vendored
|
|
@ -34,7 +34,7 @@ jobs:
|
||||||
uses: ./.github/actions/setup-nodejs
|
uses: ./.github/actions/setup-nodejs
|
||||||
with:
|
with:
|
||||||
# @codspeed/core prebuilds do not load on Node 26 (removed V8 symbol).
|
# @codspeed/core prebuilds do not load on Node 26 (removed V8 symbol).
|
||||||
node-version: '24.18.1'
|
node-version: '24.22.1'
|
||||||
|
|
||||||
- name: Run benchmarks
|
- name: Run benchmarks
|
||||||
uses: CodSpeedHQ/action@281164b0f014a4e7badd2c02cecad9b595b70537 # v4.11.1
|
uses: CodSpeedHQ/action@281164b0f014a4e7badd2c02cecad9b595b70537 # v4.11.1
|
||||||
|
|
|
||||||
2
.github/workflows/test-linting-reusable.yml
vendored
2
.github/workflows/test-linting-reusable.yml
vendored
|
|
@ -12,7 +12,7 @@ on:
|
||||||
description: Version of node to use.
|
description: Version of node to use.
|
||||||
required: false
|
required: false
|
||||||
type: string
|
type: string
|
||||||
default: 26.5.1
|
default: 26.12.1
|
||||||
|
|
||||||
env:
|
env:
|
||||||
# Each shard runs two workers with a 6 GB cap. This fits the runner's 15.2 GB.
|
# Each shard runs two workers with a 6 GB cap. This fits the runner's 15.2 GB.
|
||||||
|
|
|
||||||
2
.github/workflows/test-unit-reusable.yml
vendored
2
.github/workflows/test-unit-reusable.yml
vendored
|
|
@ -12,7 +12,7 @@ on:
|
||||||
description: Version of node to use.
|
description: Version of node to use.
|
||||||
required: false
|
required: false
|
||||||
type: string
|
type: string
|
||||||
default: 26.5.1
|
default: 26.12.1
|
||||||
collectCoverage:
|
collectCoverage:
|
||||||
required: false
|
required: false
|
||||||
default: false
|
default: false
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ on:
|
||||||
description: Version of node to use.
|
description: Version of node to use.
|
||||||
required: false
|
required: false
|
||||||
type: string
|
type: string
|
||||||
default: 26.5.1
|
default: 26.12.1
|
||||||
|
|
||||||
env:
|
env:
|
||||||
NODE_OPTIONS: --max-old-space-size=7168
|
NODE_OPTIONS: --max-old-space-size=7168
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,7 @@ jobs:
|
||||||
if: matrix.safe_chain == false
|
if: matrix.safe_chain == false
|
||||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||||
with:
|
with:
|
||||||
node-version: '26.5.1'
|
node-version: '26.12.1'
|
||||||
|
|
||||||
- name: Resolve pnpm version
|
- name: Resolve pnpm version
|
||||||
id: pnpm-version
|
id: pnpm-version
|
||||||
|
|
|
||||||
50
CHANGELOG.md
50
CHANGELOG.md
|
|
@ -1,3 +1,53 @@
|
||||||
|
## [2.44.6](https://github.com/n8n-io/n8n/compare/n8n@2.44.5...n8n@2.44.6) (2026-10-22)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **Postgres Node:** Prevent global pgtypes overwrite ([#41667](https://github.com/n8n-io/n8n/issues/41667)) ([654315e](https://github.com/n8n-io/n8n/commit/654315ee2b751212f09026d112d86b262687b181))
|
||||||
|
|
||||||
|
|
||||||
|
## [2.44.5](https://github.com/n8n-io/n8n/compare/n8n@2.44.4...n8n@2.44.5) (2026-10-21)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **core:** Key queue job outcomes by job on shutdown resume ([#41404](https://github.com/n8n-io/n8n/issues/41404)) ([1f074d4](https://github.com/n8n-io/n8n/commit/1f074d4ea734cbe07b4e49e8b7db4d1cccc74a89))
|
||||||
|
* **editor:** Load credential types on startup ([#41405](https://github.com/n8n-io/n8n/issues/41405)) ([6ff3198](https://github.com/n8n-io/n8n/commit/6ff319863b85557eab4db82c20da693763dade0b))
|
||||||
|
|
||||||
|
|
||||||
|
## [2.44.4](https://github.com/n8n-io/n8n/compare/n8n@2.44.3...n8n@2.44.4) (2026-10-19)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **core:** Reuse connections in the log streaming webhook destination ([#41281](https://github.com/n8n-io/n8n/issues/41281)) ([b79afe2](https://github.com/n8n-io/n8n/commit/b79afe23905e6b7212f4da6ccb65935c1bb29377))
|
||||||
|
* **core:** Turn off expression spans by default ([#41309](https://github.com/n8n-io/n8n/issues/41309)) ([6965193](https://github.com/n8n-io/n8n/commit/69651932d6311454f62f8d7f4ea45d7879ab9e7e))
|
||||||
|
|
||||||
|
|
||||||
|
## [2.44.3](https://github.com/n8n-io/n8n/compare/n8n@2.44.2...n8n@2.44.3) (2026-10-16)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **core:** Keep Observer detail lines instead of discarding the response ([#41157](https://github.com/n8n-io/n8n/issues/41157)) ([c09e0a9](https://github.com/n8n-io/n8n/commit/c09e0a94f57d7b046232b78f6cf2c9066784f87e))
|
||||||
|
|
||||||
|
|
||||||
|
## [2.44.2](https://github.com/n8n-io/n8n/compare/n8n@2.44.1...n8n@2.44.2) (2026-10-15)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **core:** Create an XML parser for each webhook request body ([#41112](https://github.com/n8n-io/n8n/issues/41112)) ([c52f181](https://github.com/n8n-io/n8n/commit/c52f1817b38cb03758fee45b1109caae12411ffa))
|
||||||
|
|
||||||
|
|
||||||
|
## [2.44.1](https://github.com/n8n-io/n8n/compare/n8n@2.44.0...n8n@2.44.1) (2026-10-14)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* Show Gemini models by name and list prioritized models first in model pickers ([#41015](https://github.com/n8n-io/n8n/issues/41015)) ([321b020](https://github.com/n8n-io/n8n/commit/321b0203b73e3351c766cbece6a8f79c800d453f))
|
||||||
|
|
||||||
|
|
||||||
# [2.44.0](https://github.com/n8n-io/n8n/compare/n8n@2.43.0...n8n@2.44.0) (2026-10-13)
|
# [2.44.0](https://github.com/n8n-io/n8n/compare/n8n@2.43.0...n8n@2.44.0) (2026-10-13)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
# Every n8n image build uses this file. `pnpm build:docker` and CI both drive
|
# Every n8n image build uses this file. `pnpm build:docker` and CI both drive
|
||||||
# these targets, so a pin changed here changes both.
|
# these targets, so a pin changed here changes both.
|
||||||
|
|
||||||
variable "NODE_VERSION" { default = "26.10.0" }
|
variable "NODE_VERSION" { default = "26.12.1" }
|
||||||
variable "N8N_VERSION" { default = "snapshot" }
|
variable "N8N_VERSION" { default = "snapshot" }
|
||||||
variable "N8N_RELEASE_TYPE" { default = "dev" }
|
variable "N8N_RELEASE_TYPE" { default = "dev" }
|
||||||
|
|
||||||
|
|
@ -15,14 +15,14 @@ variable "BUILDER_IMAGE" { default = "" }
|
||||||
variable "RUNTIME_IMAGE" { default = "" }
|
variable "RUNTIME_IMAGE" { default = "" }
|
||||||
|
|
||||||
variable "PC_BUILDER_IMAGE" {
|
variable "PC_BUILDER_IMAGE" {
|
||||||
default = "n8nio/node-pc:26.10.0-dev@sha256:61e4b1f3b3824ffb908812e01bcbddabb64a01ef23d408d561696c344c6679e3"
|
default = "n8nio/node-pc:26.12.1-dev@sha256:8993967928b6167703f05f28a95f82543974357dcfd4160281cf352490d208ca"
|
||||||
}
|
}
|
||||||
variable "PC_RUNTIME_IMAGE" {
|
variable "PC_RUNTIME_IMAGE" {
|
||||||
default = "n8nio/node-pc:26.10.0@sha256:386bae24d41c0091d21b4eee124a86464710fff24d5cb7f37386c4c5649b0c89"
|
default = "n8nio/node-pc:26.12.1@sha256:15afd63862b7cbc828537b7481f0c58a6e504765b62e7f55a226ad052068f77c"
|
||||||
}
|
}
|
||||||
|
|
||||||
variable "DHI_REF" {
|
variable "DHI_REF" {
|
||||||
default = "dhi.io/node:26.10.0-alpine3.24-dev@sha256:8da859df5dc853c923ace53c4cfe994a909a28917f7fcfc8508869beab8cf132"
|
default = "dhi.io/node:26.12.1-alpine3.24-dev@sha256:4b1591bfce570a17f9562e206b62e76be84d6df32c3a86bfc677fc87959402cc"
|
||||||
}
|
}
|
||||||
|
|
||||||
variable "N8N_TAGS" { default = "" }
|
variable "N8N_TAGS" { default = "" }
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
ARG NODE_VERSION=24.18.1
|
ARG NODE_VERSION=24.22.1
|
||||||
|
|
||||||
FROM node:${NODE_VERSION}-alpine3.24
|
FROM node:${NODE_VERSION}-alpine3.24
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# CI passes one DHI reference per published Node version (see the matrix in
|
# CI passes one DHI reference per published Node version (see the matrix in
|
||||||
# build-base-image.yml). The default keeps plain `docker build` working.
|
# build-base-image.yml). The default keeps plain `docker build` working.
|
||||||
ARG DHI_REF=dhi.io/node:24.21.0-alpine3.24-dev@sha256:d22ba0e9ab2a54609ae533df4ebfd6f36c1f233b662a4c547f5a3195e8c21937
|
ARG DHI_REF=dhi.io/node:24.22.1-alpine3.24-dev@sha256:7b851ff65d774343caf89450555663caef54dbd8f673658a615d256883973337
|
||||||
FROM ${DHI_REF}
|
FROM ${DHI_REF}
|
||||||
|
|
||||||
# Install all dependencies in a single layer to minimize image size
|
# Install all dependencies in a single layer to minimize image size
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
ARG NODE_VERSION=26.10.0
|
ARG NODE_VERSION=26.12.1
|
||||||
ARG N8N_VERSION=snapshot
|
ARG N8N_VERSION=snapshot
|
||||||
|
|
||||||
# The digest pins node:26.10.0-alpine3.24 for amd64 and arm64. Change the tag and the digest together.
|
# The digest pins node:26.12.1-alpine3.24 for amd64 and arm64. Change the tag and the digest together.
|
||||||
ARG BUILDER_IMAGE=node:26.10.0-alpine3.24@sha256:0b36e8c136b94cd4fcf02188228e76c31ad5872eef3fec8cbd2eee500cfd9e80
|
ARG BUILDER_IMAGE=node:26.12.1-alpine3.24@sha256:40cd011b55415bc894e45b02fdd5a5dfde056921b7d23a64c74f80497af48797
|
||||||
ARG RUNTIME_IMAGE=n8nio/base:26.10.0@sha256:9205c76a7745bfe71de35c2a0b7420238ccb0bdf69fc5273ab2e617fe918cf5f
|
ARG RUNTIME_IMAGE=n8nio/base:26.12.1@sha256:435e5310b29cfa5880b5512703f7d10bafcf0f5dd700c7adac6ee8cfbe77306a
|
||||||
|
|
||||||
# The runtime base has no compiler, so this stage supplies one.
|
# The runtime base has no compiler, so this stage supplies one.
|
||||||
FROM ${BUILDER_IMAGE} AS toolchain
|
FROM ${BUILDER_IMAGE} AS toolchain
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@
|
||||||
# dev - plain Alpine with apk usable, for the n8n builder
|
# dev - plain Alpine with apk usable, for the n8n builder
|
||||||
# stage, which must compile native addons against this
|
# stage, which must compile native addons against this
|
||||||
# Node's headers
|
# Node's headers
|
||||||
ARG NODE_VERSION=26.10.0
|
ARG NODE_VERSION=26.12.1
|
||||||
# Compiling V8 needs 1.5-2 GB of memory per parallel job. Lower this on
|
# Compiling V8 needs 1.5-2 GB of memory per parallel job. Lower this on
|
||||||
# builders whose memory-to-core ratio cannot cover all cores.
|
# builders whose memory-to-core ratio cannot cover all cores.
|
||||||
ARG MAKE_JOBS=
|
ARG MAKE_JOBS=
|
||||||
|
|
@ -90,7 +90,7 @@ ENV npm_config_nodedir=/usr/local
|
||||||
# npx, corepack) are swapped for the pointer-compressed build's files.
|
# npx, corepack) are swapped for the pointer-compressed build's files.
|
||||||
# Pinned to a multi-arch index digest (linux/amd64 + linux/arm64) for reproducible builds.
|
# Pinned to a multi-arch index digest (linux/amd64 + linux/arm64) for reproducible builds.
|
||||||
# Bump the tag and digest together when updating.
|
# Bump the tag and digest together when updating.
|
||||||
FROM n8nio/base:26.10.0@sha256:9205c76a7745bfe71de35c2a0b7420238ccb0bdf69fc5273ab2e617fe918cf5f AS runtime
|
FROM n8nio/base:26.12.1@sha256:435e5310b29cfa5880b5512703f7d10bafcf0f5dd700c7adac6ee8cfbe77306a AS runtime
|
||||||
|
|
||||||
RUN rm -f /usr/bin/node /usr/bin/nodejs /usr/bin/npm /usr/bin/npx /usr/bin/corepack /usr/local/bin/node
|
RUN rm -f /usr/bin/node /usr/bin/nodejs /usr/bin/npm /usr/bin/npx /usr/bin/corepack /usr/local/bin/node
|
||||||
COPY --from=builder /node-install/usr/local /usr/local
|
COPY --from=builder /node-install/usr/local /usr/local
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
ARG NODE_VERSION=26.10.0
|
ARG NODE_VERSION=26.12.1
|
||||||
ARG PNPM_VERSION=12.4.2
|
ARG PNPM_VERSION=12.4.2
|
||||||
ARG PYTHON_VERSION=3.13
|
ARG PYTHON_VERSION=3.13
|
||||||
|
|
||||||
|
|
@ -132,9 +132,9 @@ COPY --from=python-runner-builder /usr/local/bin/uv /usr/local/bin/uv
|
||||||
# Bring node over from node-alpine
|
# Bring node over from node-alpine
|
||||||
COPY --from=node-alpine /usr/local/bin/node /usr/local/bin/node
|
COPY --from=node-alpine /usr/local/bin/node /usr/local/bin/node
|
||||||
|
|
||||||
# libstdc++ is required by Node
|
# libstdc++ and libatomic are required by Node
|
||||||
# libc6-compat is required by task-runner-launcher
|
# libc6-compat is required by task-runner-launcher
|
||||||
RUN apk add --no-cache ca-certificates tini libstdc++ libc6-compat && \
|
RUN apk add --no-cache ca-certificates tini libstdc++ libatomic libc6-compat && \
|
||||||
apk upgrade --no-cache && \
|
apk upgrade --no-cache && \
|
||||||
pip install --no-cache-dir "pip>=26.0" && \
|
pip install --no-cache-dir "pip>=26.0" && \
|
||||||
apk del apk-tools
|
apk del apk-tools
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@
|
||||||
# - Uses distroless nonroot user (UID 65532)
|
# - Uses distroless nonroot user (UID 65532)
|
||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
|
|
||||||
ARG NODE_VERSION=26.10.0
|
ARG NODE_VERSION=26.12.1
|
||||||
ARG PNPM_VERSION=12.4.2
|
ARG PNPM_VERSION=12.4.2
|
||||||
ARG PYTHON_VERSION=3.13
|
ARG PYTHON_VERSION=3.13
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "n8n-monorepo",
|
"name": "n8n-monorepo",
|
||||||
"version": "2.44.0",
|
"version": "2.44.6",
|
||||||
"private": true,
|
"private": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=24.0.0",
|
"node": ">=24.0.0",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/agents",
|
"name": "@n8n/agents",
|
||||||
"version": "0.29.0",
|
"version": "0.29.5",
|
||||||
"description": "AI agent SDK for n8n's code-first execution engine",
|
"description": "AI agent SDK for n8n's code-first execution engine",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=24.0.0"
|
"node": ">=24.0.0"
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -237,6 +237,68 @@ describe('parseObservationLogMarkdown', () => {
|
||||||
]);
|
]);
|
||||||
expect(parsed.skippedLines).toEqual(['not a bullet']);
|
expect(parsed.skippedLines).toEqual(['not a bullet']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('appends indented unmarked detail lines to the entry above', () => {
|
||||||
|
const parsed = parseObservationLogMarkdown(
|
||||||
|
[
|
||||||
|
"* CRITICAL (15:09) User's invoice rules:",
|
||||||
|
' * Count only invoices with status paid or pending.',
|
||||||
|
' - Status void means cancelled.',
|
||||||
|
'* IMPORTANT (15:10) Open questions for the user',
|
||||||
|
' * INFO (15:10) Setup is blocked on two answers.',
|
||||||
|
' 1. Which bridge do they use?',
|
||||||
|
' 2. Which webhook URL?',
|
||||||
|
].join('\n'),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(parsed.entries).toEqual([
|
||||||
|
{
|
||||||
|
marker: 'critical',
|
||||||
|
text: "User's invoice rules: Count only invoices with status paid or pending. Status void means cancelled.",
|
||||||
|
parentIndex: null,
|
||||||
|
},
|
||||||
|
{ marker: 'important', text: 'Open questions for the user', parentIndex: null },
|
||||||
|
{
|
||||||
|
marker: 'info',
|
||||||
|
text: 'Setup is blocked on two answers. 1. Which bridge do they use? 2. Which webhook URL?',
|
||||||
|
parentIndex: 1,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(parsed.skippedLines).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('joins a detail line with a semicolon when the entry has no closing punctuation', () => {
|
||||||
|
const parsed = parseObservationLogMarkdown(
|
||||||
|
['* INFO (10:02) Published version 90f4e3d6', ' * Current draft fbda5a15'].join('\n'),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(parsed.entries).toEqual([
|
||||||
|
{
|
||||||
|
marker: 'info',
|
||||||
|
text: 'Published version 90f4e3d6; Current draft fbda5a15',
|
||||||
|
parentIndex: null,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps detail lines that start with an uppercase word', () => {
|
||||||
|
const parsed = parseObservationLogMarkdown(
|
||||||
|
[
|
||||||
|
'* INFO (10:02) Endpoints checked:',
|
||||||
|
' * GET /orders returns 200.',
|
||||||
|
' * API key rotated.',
|
||||||
|
].join('\n'),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(parsed.entries).toEqual([
|
||||||
|
{
|
||||||
|
marker: 'info',
|
||||||
|
text: 'Endpoints checked: GET /orders returns 200. API key rotated.',
|
||||||
|
parentIndex: null,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(parsed.skippedLines).toEqual([]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('renderObserverTranscript', () => {
|
describe('renderObserverTranscript', () => {
|
||||||
|
|
@ -520,6 +582,43 @@ describe('runObservationLogObserver', () => {
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
it('writes observations with indented detail lists and advances the cursor', async () => {
|
||||||
|
const store = new InMemoryMemory();
|
||||||
|
await store.saveThread({ id: 'thread-1', resourceId: 'user-1' });
|
||||||
|
await store.saveMessages({
|
||||||
|
threadId: 'thread-1',
|
||||||
|
resourceId: 'user-1',
|
||||||
|
messages: [message('m1', 'user', 'Remember my rules.', new Date(2026, 4, 12, 14, 30))],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await runObservationLogObserver({
|
||||||
|
memory: store,
|
||||||
|
observationScopeId: 'thread-1',
|
||||||
|
observationLogTailLimit: 20,
|
||||||
|
tokenCounter: () => 10,
|
||||||
|
now: new Date(2026, 4, 12, 14, 31),
|
||||||
|
observe: async () =>
|
||||||
|
await Promise.resolve(
|
||||||
|
[
|
||||||
|
'* CRITICAL (14:31) User rules:',
|
||||||
|
' * Count paid invoices.',
|
||||||
|
' * Skip void ones.',
|
||||||
|
].join('\n'),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
status: 'ran',
|
||||||
|
observationsWritten: 1,
|
||||||
|
cursorAdvanced: true,
|
||||||
|
skippedLines: [],
|
||||||
|
});
|
||||||
|
expect(await store.getActiveObservationLog({ observationScopeId: 'thread-1' })).toMatchObject([
|
||||||
|
{ marker: 'critical', text: 'User rules: Count paid invoices. Skip void ones.' },
|
||||||
|
]);
|
||||||
|
expect(await store.getCursor('thread-1')).toMatchObject({ lastObservedMessageId: 'm1' });
|
||||||
|
});
|
||||||
|
|
||||||
it('writes parsed observations and advances the cursor after observing', async () => {
|
it('writes parsed observations and advances the cursor after observing', async () => {
|
||||||
const store = new InMemoryMemory();
|
const store = new InMemoryMemory();
|
||||||
const parentText = 'User needs the current request remembered.';
|
const parentText = 'User needs the current request remembered.';
|
||||||
|
|
@ -610,6 +709,12 @@ describe('runObservationLogObserver', () => {
|
||||||
'```\nNO_OBSERVATIONS\n```',
|
'```\nNO_OBSERVATIONS\n```',
|
||||||
'NO_OBSERVATIONS\n* CRITICAL Keep this fact.',
|
'NO_OBSERVATIONS\n* CRITICAL Keep this fact.',
|
||||||
'* CRITICAL Keep this fact.\nUnformatted detail.',
|
'* CRITICAL Keep this fact.\nUnformatted detail.',
|
||||||
|
' Indented detail before any entry.\n* CRITICAL Keep this fact.',
|
||||||
|
'* CRITICAL Keep this fact.\n * COMPLETED (14:31) Unknown marker.',
|
||||||
|
'* CRITICAL Keep this fact.\n * COMPLETED Deploy is set for noon.',
|
||||||
|
'* CRITICAL Keep this fact.\n * INFO: Marker with a colon.',
|
||||||
|
'* CRITICAL Keep this fact.\n ```\n code\n ```',
|
||||||
|
'* CRITICAL Keep this fact.\n NO_OBSERVATIONS',
|
||||||
'* INFO ',
|
'* INFO ',
|
||||||
'```\n* CRITICAL Keep this fact.\n```',
|
'```\n* CRITICAL Keep this fact.\n```',
|
||||||
])('rejects an invalid response without changing memory: %j', async (output) => {
|
])('rejects an invalid response without changing memory: %j', async (output) => {
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,8 @@ A valid response has exactly one of these forms:
|
||||||
|
|
||||||
Use only one indentation level. A child attaches to the preceding top-level bullet in this response. Never omit the \`*\` prefix.
|
Use only one indentation level. A child attaches to the preceding top-level bullet in this response. Never omit the \`*\` prefix.
|
||||||
|
|
||||||
|
Every line is one observation with its own marker. To add details to an observation, write each detail as a marked child, or put the details on the same line separated by semicolons. Never write a plain sub-list, a numbered list, or any line without a marker.
|
||||||
|
|
||||||
2. Exactly this marker when there is nothing durable to record:
|
2. Exactly this marker when there is nothing durable to record:
|
||||||
|
|
||||||
NO_OBSERVATIONS
|
NO_OBSERVATIONS
|
||||||
|
|
@ -90,6 +92,16 @@ GOOD:
|
||||||
* CRITICAL (14:30) User approved the data migration. Deployment requires separate approval.
|
* CRITICAL (14:30) User approved the data migration. Deployment requires separate approval.
|
||||||
* IMPORTANT (14:31) Agent plans to start the migration; completion is not verified.
|
* IMPORTANT (14:31) Agent plans to start the migration; completion is not verified.
|
||||||
|
|
||||||
|
Details go on marked lines
|
||||||
|
|
||||||
|
BAD:
|
||||||
|
* CRITICAL (15:09) User's invoice rules:
|
||||||
|
* Count only invoices with status paid or pending.
|
||||||
|
- Status void means cancelled.
|
||||||
|
|
||||||
|
GOOD:
|
||||||
|
* CRITICAL (15:09) User's invoice rules: count only invoices with status paid or pending; status void means cancelled.
|
||||||
|
|
||||||
Keep verification evidence together
|
Keep verification evidence together
|
||||||
|
|
||||||
Transcript:
|
Transcript:
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,12 @@ const MARKER_BY_TOKEN: Record<string, ObservationLogMarker> = {
|
||||||
|
|
||||||
const BULLET_PATTERN =
|
const BULLET_PATTERN =
|
||||||
/^(\s*)[*-]\s+(CRITICAL|IMPORTANT|INFO|COMPLETION)(?:\s+\(\d{2}:\d{2}\))?\s+(.+)$/iu;
|
/^(\s*)[*-]\s+(CRITICAL|IMPORTANT|INFO|COMPLETION)(?:\s+\(\d{2}:\d{2}\))?\s+(.+)$/iu;
|
||||||
|
/**
|
||||||
|
* Indented lines that are malformed structure, not detail: a fence, the sentinel, a timed
|
||||||
|
* unknown marker, or a misspelled marker. Other uppercase words (API, GET) stay detail.
|
||||||
|
*/
|
||||||
|
const NON_DETAIL_PATTERN =
|
||||||
|
/^(?:```|NO_OBSERVATIONS$|(?:[*-]\s+)?(?:[A-Z_]+\s+\(\d{2}:\d{2}\)|(?:COMPLET|CRITIC|IMPORTAN|INFO)[A-Z]*\b))/u;
|
||||||
const DEFAULT_MAX_SERIALIZED_CHARS = 2_000;
|
const DEFAULT_MAX_SERIALIZED_CHARS = 2_000;
|
||||||
const DEFAULT_MAX_STRING_CHARS = 500;
|
const DEFAULT_MAX_STRING_CHARS = 500;
|
||||||
const DEFAULT_MAX_ARRAY_ITEMS = 20;
|
const DEFAULT_MAX_ARRAY_ITEMS = 20;
|
||||||
|
|
@ -119,6 +125,13 @@ export function parseObservationLogMarkdown(markdown: string): ParseObservationL
|
||||||
|
|
||||||
const match = BULLET_PATTERN.exec(rawLine);
|
const match = BULLET_PATTERN.exec(rawLine);
|
||||||
if (!match) {
|
if (!match) {
|
||||||
|
// Models often add an indented detail list under an observation. Keep
|
||||||
|
// that detail on the entry above instead of rejecting the response.
|
||||||
|
const previous = entries.at(-1);
|
||||||
|
if (previous && /^\s/.test(rawLine) && !NON_DETAIL_PATTERN.test(trimmed)) {
|
||||||
|
previous.text = appendObservationDetail(previous.text, trimmed);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
skippedLines.push(rawLine);
|
skippedLines.push(rawLine);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -141,6 +154,12 @@ export function parseObservationLogMarkdown(markdown: string): ParseObservationL
|
||||||
return { entries, skippedLines };
|
return { entries, skippedLines };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Joins a detail line onto one line, so the rendered log keeps one entry per line. */
|
||||||
|
function appendObservationDetail(text: string, detailLine: string): string {
|
||||||
|
const detail = detailLine.replace(/^[*-]\s+/, '');
|
||||||
|
return /[:;.!?]$/.test(text) ? `${text} ${detail}` : `${text}; ${detail}`;
|
||||||
|
}
|
||||||
|
|
||||||
export function renderObserverTranscript(
|
export function renderObserverTranscript(
|
||||||
messages: AgentDbMessage[],
|
messages: AgentDbMessage[],
|
||||||
options: RenderObserverTranscriptOptions = {},
|
options: RenderObserverTranscriptOptions = {},
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/ai-node-sdk",
|
"name": "@n8n/ai-node-sdk",
|
||||||
"version": "0.34.0",
|
"version": "0.34.4",
|
||||||
"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.37.0",
|
"version": "0.37.4",
|
||||||
"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",
|
||||||
|
|
|
||||||
|
|
@ -153,6 +153,88 @@ describe('model-discovery', () => {
|
||||||
{ id: 'models/gemini-2.5-pro', name: 'models/gemini-2.5-pro' },
|
{ id: 'models/gemini-2.5-pro', name: 'models/gemini-2.5-pro' },
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("labels models with the API's displayName, else the id", async () => {
|
||||||
|
const fetch = mockFetch({
|
||||||
|
models: [
|
||||||
|
{
|
||||||
|
name: 'models/gemini-2.5-pro',
|
||||||
|
displayName: 'Gemini 2.5 Pro',
|
||||||
|
supportedGenerationMethods: ['generateContent'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'models/gemini-2.5-flash',
|
||||||
|
displayName: ' ',
|
||||||
|
supportedGenerationMethods: ['generateContent'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const models = await listModelsForProvider('google', { apiKey: 'g-key', fetch });
|
||||||
|
|
||||||
|
expect(models).toEqual([
|
||||||
|
{ id: 'models/gemini-2.5-pro', name: 'Gemini 2.5 Pro' },
|
||||||
|
{ id: 'models/gemini-2.5-flash', name: 'models/gemini-2.5-flash' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lists models with a priority first and passes the priority on', async () => {
|
||||||
|
const fetch = mockFetch({
|
||||||
|
models: [
|
||||||
|
{ name: 'models/gemini-2.5-pro', supportedGenerationMethods: ['generateContent'] },
|
||||||
|
{
|
||||||
|
name: 'models/gemini-featured',
|
||||||
|
priority: 10,
|
||||||
|
supportedGenerationMethods: ['generateContent'],
|
||||||
|
},
|
||||||
|
{ name: 'models/gemini-2.5-flash', supportedGenerationMethods: ['generateContent'] },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const models = await listModelsForProvider('google', { apiKey: 'g-key', fetch });
|
||||||
|
|
||||||
|
expect(models).toEqual([
|
||||||
|
{ id: 'models/gemini-featured', name: 'models/gemini-featured', priority: 10 },
|
||||||
|
{ id: 'models/gemini-2.5-flash', name: 'models/gemini-2.5-flash' },
|
||||||
|
{ id: 'models/gemini-2.5-pro', name: 'models/gemini-2.5-pro' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('google empty response', () => {
|
||||||
|
it('returns no models when the response has no models list', async () => {
|
||||||
|
const fetch = mockFetch({});
|
||||||
|
|
||||||
|
await expect(listModelsForProvider('google', { apiKey: 'g-key', fetch })).resolves.toEqual(
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('google priority', () => {
|
||||||
|
it('drops a priority that is not a finite number', async () => {
|
||||||
|
const fetch = mockFetch({
|
||||||
|
models: [
|
||||||
|
{
|
||||||
|
name: 'models/gemini-2.5-pro',
|
||||||
|
priority: Number.POSITIVE_INFINITY,
|
||||||
|
supportedGenerationMethods: ['generateContent'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'models/gemini-2.5-flash',
|
||||||
|
priority: '5',
|
||||||
|
supportedGenerationMethods: ['generateContent'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const models = await listModelsForProvider('google', { apiKey: 'g-key', fetch });
|
||||||
|
|
||||||
|
expect(models).toEqual([
|
||||||
|
{ id: 'models/gemini-2.5-flash', name: 'models/gemini-2.5-flash' },
|
||||||
|
{ id: 'models/gemini-2.5-pro', name: 'models/gemini-2.5-pro' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('groq', () => {
|
describe('groq', () => {
|
||||||
|
|
|
||||||
|
|
@ -70,7 +70,14 @@ export {
|
||||||
} from './providers/azure';
|
} from './providers/azure';
|
||||||
export { listCohereModels } from './providers/cohere';
|
export { listCohereModels } from './providers/cohere';
|
||||||
export { listDeepSeekModels } from './providers/deepseek';
|
export { listDeepSeekModels } from './providers/deepseek';
|
||||||
export { listGoogleModels, shouldIncludeGoogleModel } from './providers/google';
|
export {
|
||||||
|
getGoogleModelLabel,
|
||||||
|
listGoogleModels,
|
||||||
|
parseGoogleModels,
|
||||||
|
shouldIncludeGoogleModel,
|
||||||
|
type GoogleModel,
|
||||||
|
} from './providers/google';
|
||||||
|
export { pinPrioritizedModels } from '@n8n/api-types';
|
||||||
export { listGroqModels } from './providers/groq';
|
export { listGroqModels } from './providers/groq';
|
||||||
export { listMiniMaxModels } from './providers/minimax';
|
export { listMiniMaxModels } from './providers/minimax';
|
||||||
export { listMistralModels } from './providers/mistral';
|
export { listMistralModels } from './providers/mistral';
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,6 @@
|
||||||
|
import { pinPrioritizedModels } from '@n8n/api-types';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { baseUrl, byName, getJson } from '../request';
|
import { baseUrl, byName, getJson } from '../request';
|
||||||
import type { ListModelsFn } from '../types';
|
import type { ListModelsFn } from '../types';
|
||||||
|
|
||||||
|
|
@ -33,22 +36,48 @@ export function shouldIncludeGoogleModel(model: {
|
||||||
*/
|
*/
|
||||||
export const listGoogleModels: ListModelsFn = async (options) => {
|
export const listGoogleModels: ListModelsFn = async (options) => {
|
||||||
const base = baseUrl(options, 'https://generativelanguage.googleapis.com');
|
const base = baseUrl(options, 'https://generativelanguage.googleapis.com');
|
||||||
const data = (await getJson(
|
const data = await getJson(
|
||||||
`${base}/v1beta/models`,
|
`${base}/v1beta/models`,
|
||||||
{ 'x-goog-api-key': options.apiKey },
|
{ 'x-goog-api-key': options.apiKey },
|
||||||
options,
|
options,
|
||||||
'google',
|
'google',
|
||||||
)) as { models?: Array<{ name?: unknown; supportedGenerationMethods?: unknown }> };
|
);
|
||||||
|
|
||||||
return (data.models ?? [])
|
const models = parseGoogleModels(data)
|
||||||
.filter(
|
.filter(shouldIncludeGoogleModel)
|
||||||
(model): model is { name: string; supportedGenerationMethods?: unknown } =>
|
.map((model) => ({
|
||||||
typeof model.name === 'string' &&
|
id: model.name,
|
||||||
shouldIncludeGoogleModel({
|
name: getGoogleModelLabel(model.name, model.displayName),
|
||||||
name: model.name,
|
...(model.priority !== undefined ? { priority: model.priority } : {}),
|
||||||
supportedGenerationMethods: model.supportedGenerationMethods,
|
}))
|
||||||
}),
|
|
||||||
)
|
|
||||||
.map((model) => ({ id: model.name, name: model.name }))
|
|
||||||
.sort(byName);
|
.sort(byName);
|
||||||
|
return pinPrioritizedModels(models, (model) => model.priority);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** A malformed optional field is dropped; a model without a name is skipped. */
|
||||||
|
const googleModelSchema = z.object({
|
||||||
|
name: z.string(),
|
||||||
|
displayName: z.string().optional().catch(undefined),
|
||||||
|
priority: z.number().finite().optional().catch(undefined),
|
||||||
|
supportedGenerationMethods: z.array(z.string()).optional().catch(undefined),
|
||||||
|
});
|
||||||
|
|
||||||
|
const googleModelsResponseSchema = z.object({
|
||||||
|
models: z.array(z.unknown()).optional().catch(undefined),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type GoogleModel = z.infer<typeof googleModelSchema>;
|
||||||
|
|
||||||
|
/** Models of a Google `GET /v1beta/models` response. */
|
||||||
|
export function parseGoogleModels(data: unknown): GoogleModel[] {
|
||||||
|
const response = googleModelsResponseSchema.safeParse(data);
|
||||||
|
return (response.success ? (response.data.models ?? []) : []).flatMap((item) => {
|
||||||
|
const model = googleModelSchema.safeParse(item);
|
||||||
|
return model.success ? [model.data] : [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dropdown label of a Google model: the API's `displayName`, else the id. */
|
||||||
|
export function getGoogleModelLabel(modelId: string, displayName?: string): string {
|
||||||
|
return displayName?.trim() || modelId;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ export interface ProviderModel {
|
||||||
id: string;
|
id: string;
|
||||||
/** Human-readable name when the provider supplies one, otherwise the id. */
|
/** Human-readable name when the provider supplies one, otherwise the id. */
|
||||||
name: string;
|
name: string;
|
||||||
|
/** Pickers list models with a priority first, highest first. Only the AI Gateway reports one. */
|
||||||
|
priority?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ListModelsOptions {
|
export interface ListModelsOptions {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/ai-workflow-builder",
|
"name": "@n8n/ai-workflow-builder",
|
||||||
"version": "1.44.0",
|
"version": "1.44.4",
|
||||||
"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.44.0",
|
"version": "1.44.3",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"clean": "rimraf dist .turbo",
|
"clean": "rimraf dist .turbo",
|
||||||
"dev": "pnpm watch",
|
"dev": "pnpm watch",
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,81 @@
|
||||||
|
import { isChatHubToolTypeAllowed } from '../chat-hub';
|
||||||
|
|
||||||
|
describe('isChatHubToolTypeAllowed', () => {
|
||||||
|
it.each(['@n8n/n8n-nodes-langchain.chat', '@n8n/n8n-nodes-langchain.chatHitlTool'])(
|
||||||
|
'blocks the %s variant of the Chat tool for all users',
|
||||||
|
(type) => {
|
||||||
|
expect(isChatHubToolTypeAllowed(type, { isChatUser: false })).toBe(false);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
'n8nNodesBaseSlackTool',
|
||||||
|
'n8n-nodes-base.',
|
||||||
|
'.slackTool',
|
||||||
|
'n8n-nodes-base.slackTool.v2',
|
||||||
|
'n8n-nodes-base.slack.Tool',
|
||||||
|
'@n8n/n8n-nodes-langchain.toolCode.x',
|
||||||
|
'@n8n/n8n-nodes-langchain.toolWorkflow.Tool',
|
||||||
|
])('rejects the malformed node type %s for all users', (type) => {
|
||||||
|
expect(isChatHubToolTypeAllowed(type, { isChatUser: false })).toBe(false);
|
||||||
|
expect(isChatHubToolTypeAllowed(type, { isChatUser: true })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks the Data Table HITL tool for chat users', () => {
|
||||||
|
expect(isChatHubToolTypeAllowed('n8n-nodes-base.dataTableHitlTool', { isChatUser: true })).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks the Chat tool for all users', () => {
|
||||||
|
expect(
|
||||||
|
isChatHubToolTypeAllowed('@n8n/n8n-nodes-langchain.chatTool', { isChatUser: false }),
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
isChatHubToolTypeAllowed('@n8n/n8n-nodes-langchain.chatTool', { isChatUser: true }),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
'@n8n/n8n-nodes-langchain.toolWorkflow',
|
||||||
|
'n8n-nodes-base.dataTableTool',
|
||||||
|
'n8n-nodes-base.executeCommand',
|
||||||
|
'n8n-nodes-base.executeCommandTool',
|
||||||
|
'n8n-nodes-base.code',
|
||||||
|
'@n8n/n8n-nodes-langchain.toolCode',
|
||||||
|
'n8n-nodes-base.git',
|
||||||
|
'n8n-nodes-base.gitTool',
|
||||||
|
'n8n-nodes-base.messageAnAgentTool',
|
||||||
|
'n8n-nodes-base.executeCommandHitlTool',
|
||||||
|
'n8n-nodes-base.codeHitlTool',
|
||||||
|
'n8n-nodes-base.readWriteFileHitlTool',
|
||||||
|
'n8n-nodes-base.executeWorkflowHitlTool',
|
||||||
|
'n8n-nodes-base.n8nHitlTool',
|
||||||
|
])('blocks %s for chat users', (type) => {
|
||||||
|
expect(isChatHubToolTypeAllowed(type, { isChatUser: true })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(['n8n-nodes-base.executeCommandTool', '@n8n/n8n-nodes-langchain.toolCode'])(
|
||||||
|
'allows %s for users who can build workflows',
|
||||||
|
(type) => {
|
||||||
|
expect(isChatHubToolTypeAllowed(type, { isChatUser: false })).toBe(true);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
'n8n-nodes-base.slackTool',
|
||||||
|
'n8n-nodes-base.gmailTool',
|
||||||
|
'n8n-nodes-base.httpRequestTool',
|
||||||
|
'@n8n/n8n-nodes-langchain.toolHttpRequest',
|
||||||
|
'n8n-nodes-base.graphqlTool',
|
||||||
|
'n8n-nodes-base.rssFeedReadTool',
|
||||||
|
'@n8n/n8n-nodes-langchain.mcpClientTool',
|
||||||
|
'n8n-nodes-base.sshTool',
|
||||||
|
'n8n-nodes-base.ftpHitlTool',
|
||||||
|
'n8n-nodes-base.jenkinsTool',
|
||||||
|
'n8n-nodes-base.rundeckTool',
|
||||||
|
'n8n-nodes-base.awsLambdaTool',
|
||||||
|
])('allows %s for chat users', (type) => {
|
||||||
|
expect(isChatHubToolTypeAllowed(type, { isChatUser: true })).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
37
packages/@n8n/api-types/src/__tests__/model-priority.test.ts
Normal file
37
packages/@n8n/api-types/src/__tests__/model-priority.test.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
import { pinPrioritizedModels } from '../model-priority';
|
||||||
|
|
||||||
|
describe('pinPrioritizedModels', () => {
|
||||||
|
it('moves models with a priority to the front, highest first, and keeps the order of the rest', () => {
|
||||||
|
const models = [
|
||||||
|
{ id: 'b' },
|
||||||
|
{ id: 'low', priority: 1 },
|
||||||
|
{ id: 'a' },
|
||||||
|
{ id: 'high', priority: 10 },
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(pinPrioritizedModels(models, (model) => model.priority).map((m) => m.id)).toEqual([
|
||||||
|
'high',
|
||||||
|
'low',
|
||||||
|
'b',
|
||||||
|
'a',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores priorities that are not finite numbers', () => {
|
||||||
|
const models = [
|
||||||
|
{ id: 'a', priority: '5' },
|
||||||
|
{ id: 'b', priority: Number.NaN },
|
||||||
|
{ id: 'c' },
|
||||||
|
{ id: 'd', priority: Number.POSITIVE_INFINITY },
|
||||||
|
{ id: 'valid', priority: 1 },
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(pinPrioritizedModels(models, (model) => model.priority).map((m) => m.id)).toEqual([
|
||||||
|
'valid',
|
||||||
|
'a',
|
||||||
|
'b',
|
||||||
|
'c',
|
||||||
|
'd',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -70,6 +70,8 @@ export interface AgentCatalogModel {
|
||||||
toolCall: boolean;
|
toolCall: boolean;
|
||||||
cost?: { input: number; output: number; cacheRead?: number; cacheWrite?: number };
|
cost?: { input: number; output: number; cacheRead?: number; cacheWrite?: number };
|
||||||
limits?: { context?: number; output?: number };
|
limits?: { context?: number; output?: number };
|
||||||
|
/** Pickers list models with a priority first, highest first. Reported by the models API. */
|
||||||
|
priority?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Response of `GET /agents/v2/catalog/models/:provider`. */
|
/** Response of `GET /agents/v2/catalog/models/:provider`. */
|
||||||
|
|
|
||||||
|
|
@ -420,7 +420,7 @@ export interface AgentChatMessagesResponse {
|
||||||
/**
|
/**
|
||||||
* One agent as the n8n chat page lists it.
|
* One agent as the n8n chat page lists it.
|
||||||
*
|
*
|
||||||
* Deliberately narrow. Its audience holds `agent:execute` alone — a
|
* Deliberately narrow. Its audience can hold `agent:execute` alone — a
|
||||||
* `project:chatUser` can start a production chat but has no `agent:read` — so
|
* `project:chatUser` can start a production chat but has no `agent:read` — so
|
||||||
* the published instructions, tools and skills must not travel with the list.
|
* the published instructions, tools and skills must not travel with the list.
|
||||||
* The page needs a label, an avatar, the owning project, and a user-facing
|
* The page needs a label, an avatar, the owning project, and a user-facing
|
||||||
|
|
@ -436,6 +436,11 @@ export interface AgentChatListItem {
|
||||||
project: { id: string; name: string };
|
project: { id: string; name: string };
|
||||||
/** Media types this agent's model accepts as attachments, derived from its published config. */
|
/** Media types this agent's model accepts as attachments, derived from its published config. */
|
||||||
attachments: ProviderAttachmentCapabilities;
|
attachments: ProviderAttachmentCapabilities;
|
||||||
|
/**
|
||||||
|
* `false` for an agent the user owns that is not published to n8n Chat yet.
|
||||||
|
* Its icon, blurb and attachments then come from the draft config.
|
||||||
|
*/
|
||||||
|
availableInChat: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AgentChatListResponse {
|
export interface AgentChatListResponse {
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,10 @@ import type { Scope } from '@n8n/permissions';
|
||||||
import {
|
import {
|
||||||
CHAT_TOOL_NODE_TYPE,
|
CHAT_TOOL_NODE_TYPE,
|
||||||
type ChunkType,
|
type ChunkType,
|
||||||
|
CODE_NODE_TYPE,
|
||||||
|
CODE_TOOL_LANGCHAIN_NODE_TYPE,
|
||||||
DATA_TABLE_TOOL_NODE_TYPE,
|
DATA_TABLE_TOOL_NODE_TYPE,
|
||||||
|
EXECUTE_WORKFLOW_NODE_TYPE,
|
||||||
type INode,
|
type INode,
|
||||||
INodeSchema,
|
INodeSchema,
|
||||||
WORKFLOW_TOOL_LANGCHAIN_NODE_TYPE,
|
WORKFLOW_TOOL_LANGCHAIN_NODE_TYPE,
|
||||||
|
|
@ -709,12 +712,52 @@ export interface ChatHubToolDto {
|
||||||
/** Tool types blocked for ALL users in the Chat Hub. */
|
/** Tool types blocked for ALL users in the Chat Hub. */
|
||||||
export const ALWAYS_BLOCKED_CHAT_HUB_TOOL_TYPES: string[] = [CHAT_TOOL_NODE_TYPE];
|
export const ALWAYS_BLOCKED_CHAT_HUB_TOOL_TYPES: string[] = [CHAT_TOOL_NODE_TYPE];
|
||||||
|
|
||||||
/** Additional tool types blocked for chat-only users (global:chatUser). */
|
/**
|
||||||
|
* Additional tool types blocked for chat-only users (global:chatUser).
|
||||||
|
* These tools run code or commands on the n8n instance, or access its files,
|
||||||
|
* workflows, data tables or agents. Tools that act on a remote system through
|
||||||
|
* a credential stay allowed: the credential limits what they can do.
|
||||||
|
* Chat users cannot build workflows, so they cannot add these tools either.
|
||||||
|
* A `*Tool` or `*HitlTool` variant of a listed node type is blocked too.
|
||||||
|
* Any node can have a `*HitlTool` variant, so the list also holds node types
|
||||||
|
* that are not usable as tools.
|
||||||
|
*/
|
||||||
export const CHAT_USER_BLOCKED_CHAT_HUB_TOOL_TYPES: string[] = [
|
export const CHAT_USER_BLOCKED_CHAT_HUB_TOOL_TYPES: string[] = [
|
||||||
WORKFLOW_TOOL_LANGCHAIN_NODE_TYPE,
|
WORKFLOW_TOOL_LANGCHAIN_NODE_TYPE,
|
||||||
DATA_TABLE_TOOL_NODE_TYPE,
|
DATA_TABLE_TOOL_NODE_TYPE,
|
||||||
|
CODE_NODE_TYPE,
|
||||||
|
CODE_TOOL_LANGCHAIN_NODE_TYPE,
|
||||||
|
EXECUTE_WORKFLOW_NODE_TYPE,
|
||||||
|
'n8n-nodes-base.executeCommand',
|
||||||
|
'n8n-nodes-base.messageAnAgent',
|
||||||
|
'n8n-nodes-base.git',
|
||||||
|
'n8n-nodes-base.readWriteFile',
|
||||||
|
'n8n-nodes-base.n8n',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/** Matches the node type registry, which resolves both suffixes to the base node type. */
|
||||||
|
function withoutToolSuffix(type: string): string {
|
||||||
|
return type.replace(/HitlTool$/, '').replace(/Tool$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Compares base node types, so an entry also covers the base, `*Tool` and `*HitlTool` forms. */
|
||||||
|
function isListed(list: string[], type: string): boolean {
|
||||||
|
const baseType = withoutToolSuffix(type);
|
||||||
|
return list.some((entry) => withoutToolSuffix(entry) === baseType);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A node type is a package name and a node name, joined by a single dot. */
|
||||||
|
function isWellFormedNodeType(type: string): boolean {
|
||||||
|
return /^[^.]+\.[^.]+$/.test(type);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns whether a user can add or run a tool of this type in the Chat Hub. */
|
||||||
|
export function isChatHubToolTypeAllowed(type: string, user: { isChatUser: boolean }): boolean {
|
||||||
|
if (!isWellFormedNodeType(type)) return false;
|
||||||
|
if (isListed(ALWAYS_BLOCKED_CHAT_HUB_TOOL_TYPES, type)) return false;
|
||||||
|
return !(user.isChatUser && isListed(CHAT_USER_BLOCKED_CHAT_HUB_TOOL_TYPES, type));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Request schema for creating a chat hub tool
|
* Request schema for creating a chat hub tool
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -245,6 +245,11 @@ export { GetWorkflowQueryDto } from './workflows/get-workflow-query.dto';
|
||||||
export { UpdateWorkflowPublicDto } from './workflows/update-workflow-public.dto';
|
export { UpdateWorkflowPublicDto } from './workflows/update-workflow-public.dto';
|
||||||
export { UpdateWorkflowQueryDto } from './workflows/update-workflow-query.dto';
|
export { UpdateWorkflowQueryDto } from './workflows/update-workflow-query.dto';
|
||||||
export { ListWorkflowsQueryDto } from './workflows/list-workflows-query.dto';
|
export { ListWorkflowsQueryDto } from './workflows/list-workflows-query.dto';
|
||||||
|
export {
|
||||||
|
ListCallableSubworkflowsQueryDto,
|
||||||
|
type CallableSubworkflow,
|
||||||
|
type CallableSubworkflowsResponse,
|
||||||
|
} from './workflows/list-callable-subworkflows-query.dto';
|
||||||
export {
|
export {
|
||||||
WorkflowPublicDto,
|
WorkflowPublicDto,
|
||||||
CreatedWorkflowPublicDto,
|
CreatedWorkflowPublicDto,
|
||||||
|
|
|
||||||
|
|
@ -212,6 +212,11 @@ describe('CreateWorkflowDto', () => {
|
||||||
request: { name: 'a'.repeat(129), nodes: [], connections: {} },
|
request: { name: 'a'.repeat(129), nodes: [], connections: {} },
|
||||||
expectedErrorPath: ['name'],
|
expectedErrorPath: ['name'],
|
||||||
},
|
},
|
||||||
|
...['__proto__', 'constructor', 'prototype'].map((id) => ({
|
||||||
|
name: `reserved id ${id}`,
|
||||||
|
request: { id, name: 'My Workflow', nodes: [], connections: {} },
|
||||||
|
expectedErrorPath: ['id'],
|
||||||
|
})),
|
||||||
{
|
{
|
||||||
name: 'missing nodes',
|
name: 'missing nodes',
|
||||||
request: { name: 'Test', connections: {} },
|
request: { name: 'Test', connections: {} },
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,13 @@
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { baseWorkflowShape } from './base-workflow.dto';
|
import { baseWorkflowShape } from './base-workflow.dto';
|
||||||
|
import { isSafeObjectKey } from '../../schemas/instance-ai.schema';
|
||||||
import { Z } from '../../zod-class';
|
import { Z } from '../../zod-class';
|
||||||
|
|
||||||
export const workflowIdSchema = z.string();
|
// Workflow ids are used as object keys, so `__proto__`, `constructor` and `prototype` are not allowed
|
||||||
|
export const workflowIdSchema = z
|
||||||
|
.string()
|
||||||
|
.refine(isSafeObjectKey, { message: 'Invalid workflow id' });
|
||||||
|
|
||||||
export class CreateWorkflowDto extends Z.class({
|
export class CreateWorkflowDto extends Z.class({
|
||||||
// Spread base fields (name, nodes, connections, settings, etc.)
|
// Spread base fields (name, nodes, connections, settings, etc.)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { Z } from '../../zod-class';
|
||||||
|
import { paginationSchema } from '../pagination/pagination.dto';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query for the list of workflows that a parent workflow can call as
|
||||||
|
* sub-workflows. Only name search and pagination are accepted, so callers
|
||||||
|
* cannot probe workflow contents.
|
||||||
|
*/
|
||||||
|
export class ListCallableSubworkflowsQueryDto extends Z.class({
|
||||||
|
...paginationSchema,
|
||||||
|
query: z.string().trim().max(256).optional(),
|
||||||
|
}) {}
|
||||||
|
|
||||||
|
export type CallableSubworkflow = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
active: boolean;
|
||||||
|
isArchived: boolean;
|
||||||
|
/** Set only when the workflow belongs to a team project. */
|
||||||
|
projectName?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CallableSubworkflowsResponse = {
|
||||||
|
count: number;
|
||||||
|
data: CallableSubworkflow[];
|
||||||
|
};
|
||||||
|
|
@ -96,6 +96,7 @@ export {
|
||||||
ChatHubUpdateToolRequest,
|
ChatHubUpdateToolRequest,
|
||||||
ALWAYS_BLOCKED_CHAT_HUB_TOOL_TYPES,
|
ALWAYS_BLOCKED_CHAT_HUB_TOOL_TYPES,
|
||||||
CHAT_USER_BLOCKED_CHAT_HUB_TOOL_TYPES,
|
CHAT_USER_BLOCKED_CHAT_HUB_TOOL_TYPES,
|
||||||
|
isChatHubToolTypeAllowed,
|
||||||
chatHubVectorStoreProviderSchema,
|
chatHubVectorStoreProviderSchema,
|
||||||
type ChatHubVectorStoreProvider,
|
type ChatHubVectorStoreProvider,
|
||||||
VECTOR_STORE_PROVIDER_CREDENTIAL_TYPE_MAP,
|
VECTOR_STORE_PROVIDER_CREDENTIAL_TYPE_MAP,
|
||||||
|
|
@ -966,3 +967,4 @@ export { compareExecutionListItems } from './dto/executions/compare-execution-li
|
||||||
|
|
||||||
export type * from './workflow-suggestions';
|
export type * from './workflow-suggestions';
|
||||||
export * from './self-healing-results';
|
export * from './self-healing-results';
|
||||||
|
export * from './model-priority';
|
||||||
|
|
|
||||||
23
packages/@n8n/api-types/src/model-priority.ts
Normal file
23
packages/@n8n/api-types/src/model-priority.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
/**
|
||||||
|
* Moves models that have a priority to the front, highest first, and keeps the
|
||||||
|
* order of the rest. A models API reports a priority to promote a model, for
|
||||||
|
* example at its launch.
|
||||||
|
*/
|
||||||
|
export function pinPrioritizedModels<T>(models: T[], getPriority: (model: T) => unknown): T[] {
|
||||||
|
const entries = models.map((model) => {
|
||||||
|
const priority = getPriority(model);
|
||||||
|
return {
|
||||||
|
model,
|
||||||
|
priority: typeof priority === 'number' && Number.isFinite(priority) ? priority : null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const prioritized = entries
|
||||||
|
.flatMap((entry) =>
|
||||||
|
entry.priority === null ? [] : [{ model: entry.model, priority: entry.priority }],
|
||||||
|
)
|
||||||
|
.sort((a, b) => b.priority - a.priority);
|
||||||
|
return [
|
||||||
|
...prioritized.map((entry) => entry.model),
|
||||||
|
...entries.filter((entry) => entry.priority === null).map((entry) => entry.model),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/backend-common",
|
"name": "@n8n/backend-common",
|
||||||
"version": "1.44.0",
|
"version": "1.44.4",
|
||||||
"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.18.0",
|
"version": "1.18.4",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"clean": "rimraf dist .turbo",
|
"clean": "rimraf dist .turbo",
|
||||||
"dev": "pnpm watch",
|
"dev": "pnpm watch",
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,13 @@ import { IncomingMessage } from 'http';
|
||||||
import { Readable } from 'stream';
|
import { Readable } from 'stream';
|
||||||
|
|
||||||
import { binaryToString } from '../binary-string';
|
import { binaryToString } from '../binary-string';
|
||||||
|
import { Socket } from 'net';
|
||||||
|
|
||||||
const bufferToIncomingMessage = (buffer: Buffer, encoding = 'utf-8') => {
|
const bufferToIncomingMessage = (buffer: Buffer, encoding = 'utf-8') => {
|
||||||
const incomingMessage = Readable.from(buffer) as IncomingMessage;
|
const incomingMessage = new IncomingMessage(new Socket());
|
||||||
incomingMessage.headers = { 'content-type': `application/json;charset=${encoding}` };
|
incomingMessage.headers = { 'content-type': `application/json;charset=${encoding}` };
|
||||||
Object.setPrototypeOf(incomingMessage, IncomingMessage.prototype);
|
incomingMessage.push(buffer);
|
||||||
|
incomingMessage.push(null);
|
||||||
return incomingMessage;
|
return incomingMessage;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import type { HttpsProxyAgent } from 'https-proxy-agent';
|
import type { HttpsProxyAgent } from 'https-proxy-agent';
|
||||||
import type http from 'node:http';
|
import http from 'node:http';
|
||||||
|
|
||||||
import { makeSsrfBridge } from '../../ssrf/__tests__/mock-ssrf-bridge';
|
import { makeSsrfBridge } from '../../ssrf/__tests__/mock-ssrf-bridge';
|
||||||
import { installProxyConnectionGuard } from '../connection-guards';
|
import { installConnectionGuard, installProxyConnectionGuard } from '../connection-guards';
|
||||||
|
|
||||||
type ConnectOpts = Parameters<HttpsProxyAgent<string>['connect']>[1];
|
type ConnectOpts = Parameters<HttpsProxyAgent<string>['connect']>[1];
|
||||||
|
|
||||||
|
|
@ -76,3 +76,78 @@ describe('installProxyConnectionGuard', () => {
|
||||||
expect(bridge.validateConnectionHost).not.toHaveBeenCalled();
|
expect(bridge.validateConnectionHost).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('installConnectionGuard', () => {
|
||||||
|
function guarded() {
|
||||||
|
const createConnection = vi.fn().mockReturnValue('SOCKET');
|
||||||
|
const target = { createConnection };
|
||||||
|
return { target, createConnection };
|
||||||
|
}
|
||||||
|
|
||||||
|
it('validates the target host and delegates when the policy allows it', () => {
|
||||||
|
const bridge = makeSsrfBridge();
|
||||||
|
const { target, createConnection } = guarded();
|
||||||
|
installConnectionGuard(target, bridge);
|
||||||
|
|
||||||
|
expect(target.createConnection({ host: 'target.example.com' })).toBe('SOCKET');
|
||||||
|
|
||||||
|
expect(bridge.validateConnectionHost).toHaveBeenCalledWith('target.example.com');
|
||||||
|
expect(createConnection).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a connection to a socket path without validating the unused host', () => {
|
||||||
|
const bridge = makeSsrfBridge();
|
||||||
|
const { target, createConnection } = guarded();
|
||||||
|
installConnectionGuard(target, bridge);
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
target.createConnection({ host: 'target.example.com', socketPath: '/var/run/docker.sock' }),
|
||||||
|
).toThrow('Cannot open a connection to a socket path');
|
||||||
|
|
||||||
|
expect(createConnection).not.toHaveBeenCalled();
|
||||||
|
expect(bridge.validateConnectionHost).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports a socket path through the callback when the caller passes one', () => {
|
||||||
|
const bridge = makeSsrfBridge();
|
||||||
|
const { target, createConnection } = guarded();
|
||||||
|
installConnectionGuard(target, bridge);
|
||||||
|
const onConnect = vi.fn();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
target.createConnection({ socketPath: '/var/run/docker.sock' }, onConnect),
|
||||||
|
).toBeUndefined();
|
||||||
|
|
||||||
|
expect(onConnect).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ message: 'Cannot open a connection to a socket path' }),
|
||||||
|
);
|
||||||
|
expect(createConnection).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('installConnectionGuard on a real http.Agent', () => {
|
||||||
|
async function get(agent: http.Agent): Promise<string> {
|
||||||
|
return await new Promise((resolve, reject) => {
|
||||||
|
const req = http.get('http://target.example.com/x', { agent, timeout: 3000 }, (res) => {
|
||||||
|
let data = '';
|
||||||
|
res.on('data', (chunk) => (data += String(chunk)));
|
||||||
|
res.on('end', () => resolve(data));
|
||||||
|
});
|
||||||
|
req.on('error', reject);
|
||||||
|
req.on('timeout', () => {
|
||||||
|
req.destroy();
|
||||||
|
reject(new Error('timeout'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['ssrf active', () => makeSsrfBridge()],
|
||||||
|
['ssrf disabled', () => 'disabled' as const],
|
||||||
|
])('refuses a socket path Node puts in the connect options (%s)', async (_label, ssrf) => {
|
||||||
|
const agent = new http.Agent({ socketPath: '/tmp/backend-network.sock' } as http.AgentOptions);
|
||||||
|
installConnectionGuard(agent as unknown as { createConnection: never }, ssrf());
|
||||||
|
|
||||||
|
await expect(get(agent)).rejects.toThrow('Cannot open a connection to a socket path');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ const { getProxyForUrl } = vi.hoisted(() => ({
|
||||||
vi.mock('proxy-from-env', () => ({ getProxyForUrl }));
|
vi.mock('proxy-from-env', () => ({ getProxyForUrl }));
|
||||||
|
|
||||||
const options = (o: Partial<http.RequestOptions>): http.RequestOptions => o as http.RequestOptions;
|
const options = (o: Partial<http.RequestOptions>): http.RequestOptions => o as http.RequestOptions;
|
||||||
const createAgent = () => vi.fn((proxyUrl: string) => ({ proxyUrl }));
|
const createAgent = () => vi.fn((proxyUrl: string) => ({ proxyUrl, destroy: vi.fn() }));
|
||||||
|
|
||||||
describe('EnvProxyRouter', () => {
|
describe('EnvProxyRouter', () => {
|
||||||
beforeEach(() => getProxyForUrl.mockReset());
|
beforeEach(() => getProxyForUrl.mockReset());
|
||||||
|
|
@ -110,4 +110,17 @@ describe('EnvProxyRouter', () => {
|
||||||
expect(createProxyAgent).toHaveBeenCalledTimes(2);
|
expect(createProxyAgent).toHaveBeenCalledTimes(2);
|
||||||
expect(a).not.toBe(b);
|
expect(a).not.toBe(b);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('destroys cached proxy agents and creates a new one after destroy', () => {
|
||||||
|
getProxyForUrl.mockReturnValue('http://proxy.internal:3128');
|
||||||
|
const createProxyAgent = createAgent();
|
||||||
|
const router = new EnvProxyRouter('http', 80, createProxyAgent);
|
||||||
|
|
||||||
|
const first = router.resolve(options({ host: 'a.example' }));
|
||||||
|
router.destroy();
|
||||||
|
const second = router.resolve(options({ host: 'a.example' }));
|
||||||
|
|
||||||
|
expect(first?.destroy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(second).not.toBe(first);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
-----BEGIN CERTIFICATE-----
|
||||||
|
MIICwDCCAaigAwIBAgIJAIe0kAmSG0RGMA0GCSqGSIb3DQEBCwUAMBQxEjAQBgNV
|
||||||
|
BAMMCTEyNy4wLjAuMTAgFw0yNjEwMTYxMDQzMjRaGA8yMTI2MDkyMjEwNDMyNFow
|
||||||
|
FDESMBAGA1UEAwwJMTI3LjAuMC4xMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB
|
||||||
|
CgKCAQEAray8R7pr1LGPofrgxuawPyJ/kvJrBY452nQxVXP/0e69hmnJskO2Xab+
|
||||||
|
cADx1+Xv9pRmSPLL5E4ZmwebHPCDE1PHd5tQo8u+I1h/mLsrxc3aeehnQGxpDi9p
|
||||||
|
RjOdM00j1gBuZIJoC/scJEkOdFz+7aHmNFIZzf9OgqJA9CyFzUrFB/i7PyyILkUs
|
||||||
|
pIZta0iMZa2hytpFeHGB9FAZypU/XaBZQHbRc8IU3R9CL8ppGBayHMvdGNiEFK1U
|
||||||
|
5oPEk5de7S9UN/trK7nypqaZynP+hkcr6PywTswPnAJ5xBX1XG/IQplZEBrNcE1B
|
||||||
|
aHbKeC187qEJEf4xaUHkHdFGvokF3QIDAQABoxMwETAPBgNVHREECDAGhwR/AAAB
|
||||||
|
MA0GCSqGSIb3DQEBCwUAA4IBAQBKRUtiIhIaIxaOFUrCZLCM6XVypKwEb0IzU3rC
|
||||||
|
fspk9L7sFH+U1rv1lOy0aGDr7CDf+NJhY2s7p7vbyWP4xo4mDI99R/W9VF1SXOUs
|
||||||
|
nVao0Ial+N+/7BCwCZRcdPAnv7roUr6Dt7RYRTG5zdUfu079dWkm7RcicmerU4uD
|
||||||
|
2asNeHPuunkGFu8FyrT67S03bYjRUppsqxDnKjtFTavA578Ky2Gk2jzpCKL8wmM9
|
||||||
|
hcVGA90vnGPXekmMWvE0okJlEA5z+nLDPXqeT11ycwdcIsS/d7ll0bZSVZrmRLa0
|
||||||
|
TYR9TXIlbzgyTe0oSRSHruTyn5rPEero7lAWi32SLkk8KSX+
|
||||||
|
-----END CERTIFICATE-----
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
-----BEGIN PRIVATE KEY-----
|
||||||
|
MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCtrLxHumvUsY+h
|
||||||
|
+uDG5rA/In+S8msFjjnadDFVc//R7r2GacmyQ7Zdpv5wAPHX5e/2lGZI8svkThmb
|
||||||
|
B5sc8IMTU8d3m1Cjy74jWH+YuyvFzdp56GdAbGkOL2lGM50zTSPWAG5kgmgL+xwk
|
||||||
|
SQ50XP7toeY0UhnN/06CokD0LIXNSsUH+Ls/LIguRSykhm1rSIxlraHK2kV4cYH0
|
||||||
|
UBnKlT9doFlAdtFzwhTdH0IvymkYFrIcy90Y2IQUrVTmg8STl17tL1Q3+2srufKm
|
||||||
|
ppnKc/6GRyvo/LBOzA+cAnnEFfVcb8hCmVkQGs1wTUFodsp4LXzuoQkR/jFpQeQd
|
||||||
|
0Ua+iQXdAgMBAAECggEAbFHfAHGyDW9NYT6i8Qsyg74ViWYu/8EMLkbn+h8mQB5C
|
||||||
|
ENJsHQYEePbNnLfKHtjnyIgYH4lnR5a6e6nLwcn0RpqrPtsc+Xv6s/rfYjfi/jT8
|
||||||
|
/anItDKpd1J0u3c3paaIIlIUiBWZpVdR3wO9wy+5RCcE+D74nzWMFwfxsRkPvtDL
|
||||||
|
eJPCjWwzqTV/YI0pmlUqwrJiezEs6B96bFIv1r0CC2/4XY5OCQ3QuYc9TFth+7mq
|
||||||
|
jtJw5XKRH7Zh3COdM6BnsCtLrtFVqIC8dFIghcNd36ypPIQf9WAKnCSZjEGSfn8w
|
||||||
|
yzKKJj6q966iTwja0ckZGoX6UP790gzB1hAYBgRIFQKBgQDUdTpzioC7MSNKthJL
|
||||||
|
/Cr2dCXvIzvKo4QHQYEIJNc4Kop3e4mj6VqPlOHJd31Fdec3Sfl6vVLwLTpe1GD6
|
||||||
|
HhNnzjFJ3WvoIshH7XzVmo+YVCTMPi5W8qHIHbQJyxZZJJyCiAZh9ZVq+PQEODFj
|
||||||
|
gyGZyAwuUUF04N8uhxsxS0LlwwKBgQDRRLeox/M8Z6o5bNs38+LjaU1+pkS2Ly5p
|
||||||
|
HpdaMTOPlgc4skPKm6wiFjFXWmvGkJHh2MWL/QQ7EwLk0bye1fSg3qcV/vbSwfTs
|
||||||
|
haCoZhr6K+FlSk9OtbUMcD0HN/p54+DHo8S3YQMgIFh0yYnDgf1Lk3aRUcR9vvbH
|
||||||
|
oi99z1aL3wKBgAP0erYT6fmSHJoTcZvAgfw8hUhCW6aWDKsl6QUByo/dGt7GifVn
|
||||||
|
LqxllR5u/wIAA+HfmqlSROz7qo6BXZzKWoogKIK7gHq9ywSrwxsm2W14g4bZ0XsE
|
||||||
|
4UYcZ3GXOj9GkzNK3PON4s4u/Dvn3+N+WW3Hb5KdKJ7IYN843KS6RfCJAoGBAKO0
|
||||||
|
oggDAyDJ57wskAFhZKiKA/mDkFUE68WIDNadjrxQnS2ZJrckuK+htM+lCJK5HaYT
|
||||||
|
sdYlBF5CLQ1blbaboVcHVqY6YlPVLwioMFoMJiZ2lUhrrUbhltQ4KUWwYN1hisIi
|
||||||
|
Nw1Ncuh65TfXQVIUKlYo8aQU1yWXce4Rzh0ZSZb5AoGBAIHC3nYPZAJXl7SxH4k0
|
||||||
|
P13Y10j3K71kjuXsvqRmg9WhYV+Y9sSGLvcM5tIkBexz1CCqeJ/9x7B8UR0zNTaO
|
||||||
|
PumXYr0XW5Dnoesr8oDTch3pRoCqLvt0GWrLzJChpVVWY3qeyxIOjA1O0cLCFMBB
|
||||||
|
/vW69JqXYMyIHnLMLoyDp8fy
|
||||||
|
-----END PRIVATE KEY-----
|
||||||
|
|
@ -15,7 +15,7 @@ import { installConnectionGuard, UnknownConnectionHostError } from '../connectio
|
||||||
import { EnvProxyHttpAgent } from '../env-proxy-http-agent';
|
import { EnvProxyHttpAgent } from '../env-proxy-http-agent';
|
||||||
import { EnvProxyHttpsAgent } from '../env-proxy-https-agent';
|
import { EnvProxyHttpsAgent } from '../env-proxy-https-agent';
|
||||||
import { type LocalServer, startServer } from '../local-server';
|
import { type LocalServer, startServer } from '../local-server';
|
||||||
import { buildNodeAgents, type ProxyUrl } from '../node-agents';
|
import { buildNodeAgents, type NodeAgentOptions, type ProxyUrl } from '../node-agents';
|
||||||
|
|
||||||
// HttpsProxyAgent stores `lookup` in `connectOpts` rather than `options`
|
// HttpsProxyAgent stores `lookup` in `connectOpts` rather than `options`
|
||||||
// (unlike http.Agent and HttpProxyAgent which use `options`).
|
// (unlike http.Agent and HttpProxyAgent which use `options`).
|
||||||
|
|
@ -32,6 +32,10 @@ function getAgentOptions(agent: http.Agent | https.Agent): { keepAlive?: boolean
|
||||||
return (agent as unknown as { options?: { keepAlive?: boolean } }).options ?? {};
|
return (agent as unknown as { options?: { keepAlive?: boolean } }).options ?? {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function withSocketPath(socketPath: string): NodeAgentOptions {
|
||||||
|
return { socketPath } as unknown as NodeAgentOptions;
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// buildNodeAgents — shared builder (single source of truth for the undici
|
// buildNodeAgents — shared builder (single source of truth for the undici
|
||||||
// factory, the axios transport layer, and the global proxy agents)
|
// factory, the axios transport layer, and the global proxy agents)
|
||||||
|
|
@ -111,6 +115,29 @@ describe('buildNodeAgents', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('rejects a caller-provided socketPath (not a validated connection target)', () => {
|
||||||
|
const socketPath = '/tmp/backend-network.sock';
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['ssrf disabled', 'disabled' as const],
|
||||||
|
['ssrf active', makeSsrfBridge()],
|
||||||
|
])('throws when agentOptions.socketPath is set (%s)', (_label, ssrf) => {
|
||||||
|
expect(() => buildNodeAgents(false, ssrf, withSocketPath(socketPath))).toThrow(
|
||||||
|
'`agentOptions.socketPath` is not supported',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['direct', false as const],
|
||||||
|
['env proxy', 'env' as const],
|
||||||
|
['explicit proxy', 'http://proxy.internal:3128' as ProxyUrl],
|
||||||
|
])('throws on the %s path too', (_label, proxy) => {
|
||||||
|
expect(() => buildNodeAgents(proxy, makeSsrfBridge(), withSocketPath(socketPath))).toThrow(
|
||||||
|
'`agentOptions.socketPath` is not supported',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('SSRF lookup placement', () => {
|
describe('SSRF lookup placement', () => {
|
||||||
const modes: Array<[string, false | 'env' | ProxyUrl]> = [
|
const modes: Array<[string, false | 'env' | ProxyUrl]> = [
|
||||||
['proxy: false', false],
|
['proxy: false', false],
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,239 @@
|
||||||
|
import type { Logger } from '@n8n/backend-common';
|
||||||
|
import type { SsrfProtectionConfig } from '@n8n/config';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import http from 'node:http';
|
||||||
|
import https from 'node:https';
|
||||||
|
import type { AddressInfo, Socket } from 'node:net';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import type { IHttpRequestOptions } from 'n8n-workflow';
|
||||||
|
import { mock } from 'vitest-mock-extended';
|
||||||
|
|
||||||
|
import type { SsrfProtectionService } from '../../ssrf';
|
||||||
|
import { useCleanProxyEnv } from '../../ssrf/__tests__/mock-ssrf-bridge';
|
||||||
|
import { OutboundHttp } from '../outbound-http';
|
||||||
|
|
||||||
|
interface CountingServer {
|
||||||
|
url: string;
|
||||||
|
connections: Socket[];
|
||||||
|
openConnections: () => number;
|
||||||
|
maxConcurrentRequests: () => number;
|
||||||
|
close: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startCountingServer(
|
||||||
|
server: http.Server | https.Server,
|
||||||
|
scheme: 'http' | 'https',
|
||||||
|
responseDelayMs = 0,
|
||||||
|
): Promise<CountingServer> {
|
||||||
|
const connections: Socket[] = [];
|
||||||
|
let active = 0;
|
||||||
|
let maxActive = 0;
|
||||||
|
|
||||||
|
server.on('connection', (socket: Socket) => connections.push(socket));
|
||||||
|
server.on('request', (_req: http.IncomingMessage, res: http.ServerResponse) => {
|
||||||
|
active++;
|
||||||
|
maxActive = Math.max(maxActive, active);
|
||||||
|
setTimeout(() => {
|
||||||
|
active--;
|
||||||
|
res.setHeader('Content-Type', 'application/json');
|
||||||
|
res.end(JSON.stringify({ ok: true }));
|
||||||
|
}, responseDelayMs);
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||||
|
const { port } = server.address() as AddressInfo;
|
||||||
|
|
||||||
|
return {
|
||||||
|
url: `${scheme}://127.0.0.1:${port}`,
|
||||||
|
connections,
|
||||||
|
openConnections: () => connections.filter((socket) => !socket.destroyed).length,
|
||||||
|
maxConcurrentRequests: () => maxActive,
|
||||||
|
close: async () => {
|
||||||
|
server.closeAllConnections();
|
||||||
|
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const fixture = (name: string) => readFileSync(join(__dirname, 'fixtures', name));
|
||||||
|
|
||||||
|
function makeOutboundHttp(): OutboundHttp {
|
||||||
|
return new OutboundHttp(
|
||||||
|
mock<SsrfProtectionService>(),
|
||||||
|
mock<SsrfProtectionConfig>({ enabled: false }),
|
||||||
|
mock<Logger>(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendSequentially(request: () => Promise<unknown>, count: number) {
|
||||||
|
for (let i = 0; i < count; i++) await request();
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('OutboundHttp.requests with a connection pool', () => {
|
||||||
|
useCleanProxyEnv();
|
||||||
|
|
||||||
|
const servers: CountingServer[] = [];
|
||||||
|
const start = async (...args: Parameters<typeof startCountingServer>) => {
|
||||||
|
const server = await startCountingServer(...args);
|
||||||
|
servers.push(server);
|
||||||
|
return server;
|
||||||
|
};
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await Promise.all(servers.splice(0).map(async (server) => await server.close()));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens a new connection for each request without a pool', async () => {
|
||||||
|
const target = await start(http.createServer(), 'http');
|
||||||
|
const client = makeOutboundHttp().requests({ useDefaultSsrfPolicy: 'unsafe' });
|
||||||
|
|
||||||
|
await sendSequentially(async () => await client.request({ url: target.url }), 3);
|
||||||
|
|
||||||
|
expect(target.connections).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reuses one connection for sequential HTTP requests', async () => {
|
||||||
|
const target = await start(http.createServer(), 'http');
|
||||||
|
const client = makeOutboundHttp().requests({
|
||||||
|
useDefaultSsrfPolicy: 'unsafe',
|
||||||
|
connectionPool: { agentOptions: { keepAlive: true } },
|
||||||
|
});
|
||||||
|
|
||||||
|
await sendSequentially(async () => await client.request({ url: target.url }), 3);
|
||||||
|
|
||||||
|
expect(target.connections).toHaveLength(1);
|
||||||
|
client.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps connections alive by default', async () => {
|
||||||
|
const target = await start(http.createServer(), 'http');
|
||||||
|
const client = makeOutboundHttp().requests({
|
||||||
|
useDefaultSsrfPolicy: 'unsafe',
|
||||||
|
connectionPool: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
await sendSequentially(async () => await client.request({ url: target.url }), 3);
|
||||||
|
|
||||||
|
expect(target.connections).toHaveLength(1);
|
||||||
|
client.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a pool with a fixed servername', () => {
|
||||||
|
expect(() =>
|
||||||
|
makeOutboundHttp().requests({
|
||||||
|
useDefaultSsrfPolicy: 'unsafe',
|
||||||
|
connectionPool: { agentOptions: { servername: 'example.com' } },
|
||||||
|
}),
|
||||||
|
).toThrow('servername');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reuses one TLS connection for sequential HTTPS requests', async () => {
|
||||||
|
const target = await start(
|
||||||
|
https.createServer({
|
||||||
|
key: fixture('loopback-test-key.pem'),
|
||||||
|
cert: fixture('loopback-test-cert.pem'),
|
||||||
|
}),
|
||||||
|
'https',
|
||||||
|
);
|
||||||
|
const client = makeOutboundHttp().requests({
|
||||||
|
useDefaultSsrfPolicy: 'unsafe',
|
||||||
|
connectionPool: { agentOptions: { keepAlive: true }, skipSslCertificateValidation: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
await sendSequentially(async () => await client.request({ url: target.url }), 3);
|
||||||
|
|
||||||
|
expect(target.connections).toHaveLength(1);
|
||||||
|
client.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('limits concurrent connections to maxSockets', async () => {
|
||||||
|
const target = await start(http.createServer(), 'http', 20);
|
||||||
|
const client = makeOutboundHttp().requests({
|
||||||
|
useDefaultSsrfPolicy: 'unsafe',
|
||||||
|
connectionPool: { agentOptions: { keepAlive: true, maxSockets: 1 } },
|
||||||
|
});
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
Array.from({ length: 5 }, async () => await client.request({ url: target.url })),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(target.connections).toHaveLength(1);
|
||||||
|
expect(target.maxConcurrentRequests()).toBe(1);
|
||||||
|
client.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('closes idle connections after the agent timeout', async () => {
|
||||||
|
const target = await start(http.createServer(), 'http');
|
||||||
|
const client = makeOutboundHttp().requests({
|
||||||
|
useDefaultSsrfPolicy: 'unsafe',
|
||||||
|
connectionPool: { agentOptions: { keepAlive: true, timeout: 100 } },
|
||||||
|
});
|
||||||
|
|
||||||
|
await client.request({ url: target.url });
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(target.openConnections()).toBe(0), { timeout: 2000 });
|
||||||
|
client.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('closes pooled connections on close()', async () => {
|
||||||
|
const target = await start(http.createServer(), 'http');
|
||||||
|
const client = makeOutboundHttp().requests({
|
||||||
|
useDefaultSsrfPolicy: 'unsafe',
|
||||||
|
connectionPool: { agentOptions: { keepAlive: true } },
|
||||||
|
});
|
||||||
|
await client.request({ url: target.url });
|
||||||
|
expect(target.openConnections()).toBe(1);
|
||||||
|
|
||||||
|
client.close();
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(target.openConnections()).toBe(0), { timeout: 2000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reuses one connection to an explicit proxy', async () => {
|
||||||
|
const proxy = await start(http.createServer(), 'http');
|
||||||
|
const client = makeOutboundHttp().requests({
|
||||||
|
useDefaultSsrfPolicy: 'unsafe',
|
||||||
|
connectionPool: {
|
||||||
|
proxy: { protocol: 'http', host: '127.0.0.1', port: Number(new URL(proxy.url).port) },
|
||||||
|
agentOptions: { keepAlive: true },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await sendSequentially(async () => await client.request({ url: 'http://target.invalid/x' }), 3);
|
||||||
|
|
||||||
|
expect(proxy.connections).toHaveLength(1);
|
||||||
|
client.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reuses one connection to an environment proxy and closes it on close()', async () => {
|
||||||
|
const proxy = await start(http.createServer(), 'http');
|
||||||
|
process.env.HTTP_PROXY = proxy.url;
|
||||||
|
const client = makeOutboundHttp().requests({
|
||||||
|
useDefaultSsrfPolicy: 'unsafe',
|
||||||
|
connectionPool: { agentOptions: { keepAlive: true } },
|
||||||
|
});
|
||||||
|
await sendSequentially(async () => await client.request({ url: 'http://target.invalid/x' }), 3);
|
||||||
|
expect(proxy.connections).toHaveLength(1);
|
||||||
|
expect(proxy.openConnections()).toBe(1);
|
||||||
|
|
||||||
|
client.close();
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(proxy.openConnections()).toBe(0), { timeout: 2000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each<Partial<IHttpRequestOptions>>([
|
||||||
|
{ proxy: { host: '127.0.0.1', port: 1 } },
|
||||||
|
{ agentOptions: { keepAlive: false } },
|
||||||
|
{ skipSslCertificateValidation: true },
|
||||||
|
])('rejects a request that sets a pool setting itself: %o', async (requestSettings) => {
|
||||||
|
const client = makeOutboundHttp().requests({
|
||||||
|
useDefaultSsrfPolicy: 'unsafe',
|
||||||
|
connectionPool: { agentOptions: { keepAlive: true } },
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(client.request({ url: 'http://127.0.0.1:1', ...requestSettings })).rejects.toThrow(
|
||||||
|
'does not accept',
|
||||||
|
);
|
||||||
|
client.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -37,7 +37,7 @@ describe('OutboundHttp.requests', () => {
|
||||||
|
|
||||||
expect(res).toEqual({ statusCode: 200, body: 'ok' });
|
expect(res).toEqual({ statusCode: 200, body: 'ok' });
|
||||||
expect(mockedHttpRequest).toHaveBeenCalledTimes(1);
|
expect(mockedHttpRequest).toHaveBeenCalledTimes(1);
|
||||||
expect(mockedHttpRequest).toHaveBeenCalledWith(REQUEST, expect.anything());
|
expect(mockedHttpRequest).toHaveBeenCalledWith(REQUEST, expect.anything(), undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('uses the container SSRF service as the bridge by default', async () => {
|
it('uses the container SSRF service as the bridge by default', async () => {
|
||||||
|
|
@ -46,7 +46,7 @@ describe('OutboundHttp.requests', () => {
|
||||||
|
|
||||||
await client.request(REQUEST);
|
await client.request(REQUEST);
|
||||||
|
|
||||||
expect(mockedHttpRequest).toHaveBeenCalledWith(REQUEST, service);
|
expect(mockedHttpRequest).toHaveBeenCalledWith(REQUEST, service, undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('omits the bridge when the instance disables SSRF protection', async () => {
|
it('omits the bridge when the instance disables SSRF protection', async () => {
|
||||||
|
|
@ -54,7 +54,7 @@ describe('OutboundHttp.requests', () => {
|
||||||
|
|
||||||
await client.request(REQUEST);
|
await client.request(REQUEST);
|
||||||
|
|
||||||
expect(mockedHttpRequest).toHaveBeenCalledWith(REQUEST, undefined);
|
expect(mockedHttpRequest).toHaveBeenCalledWith(REQUEST, undefined, undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('omits the bridge when SSRF protection is disabled', async () => {
|
it('omits the bridge when SSRF protection is disabled', async () => {
|
||||||
|
|
@ -62,7 +62,7 @@ describe('OutboundHttp.requests', () => {
|
||||||
|
|
||||||
await client.request(REQUEST);
|
await client.request(REQUEST);
|
||||||
|
|
||||||
expect(mockedHttpRequest).toHaveBeenCalledWith(REQUEST, undefined);
|
expect(mockedHttpRequest).toHaveBeenCalledWith(REQUEST, undefined, undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('bound baseURL', () => {
|
describe('bound baseURL', () => {
|
||||||
|
|
@ -77,6 +77,7 @@ describe('OutboundHttp.requests', () => {
|
||||||
baseURL: 'https://vault.test/v1/',
|
baseURL: 'https://vault.test/v1/',
|
||||||
}),
|
}),
|
||||||
expect.anything(),
|
expect.anything(),
|
||||||
|
undefined,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -88,6 +89,7 @@ describe('OutboundHttp.requests', () => {
|
||||||
expect(mockedHttpRequest).toHaveBeenCalledWith(
|
expect(mockedHttpRequest).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ baseURL: 'https://other.test/' }),
|
expect.objectContaining({ baseURL: 'https://other.test/' }),
|
||||||
expect.anything(),
|
expect.anything(),
|
||||||
|
undefined,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -101,6 +103,7 @@ describe('OutboundHttp.requests', () => {
|
||||||
expect(mockedHttpRequest).toHaveBeenCalledWith(
|
expect(mockedHttpRequest).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ timeout: 5000 }),
|
expect.objectContaining({ timeout: 5000 }),
|
||||||
expect.anything(),
|
expect.anything(),
|
||||||
|
undefined,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -112,6 +115,7 @@ describe('OutboundHttp.requests', () => {
|
||||||
expect(mockedHttpRequest).toHaveBeenCalledWith(
|
expect(mockedHttpRequest).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ timeout: 1000 }),
|
expect.objectContaining({ timeout: 1000 }),
|
||||||
expect.anything(),
|
expect.anything(),
|
||||||
|
undefined,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -129,6 +133,7 @@ describe('OutboundHttp.requests', () => {
|
||||||
headers: { 'X-Vault-Namespace': 'admin', Accept: 'text/plain' },
|
headers: { 'X-Vault-Namespace': 'admin', Accept: 'text/plain' },
|
||||||
}),
|
}),
|
||||||
expect.anything(),
|
expect.anything(),
|
||||||
|
undefined,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -142,6 +147,7 @@ describe('OutboundHttp.requests', () => {
|
||||||
expect(mockedHttpRequest).toHaveBeenCalledWith(
|
expect(mockedHttpRequest).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ headers: { 'content-type': 'text/plain' } }),
|
expect.objectContaining({ headers: { 'content-type': 'text/plain' } }),
|
||||||
expect.anything(),
|
expect.anything(),
|
||||||
|
undefined,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -159,11 +165,13 @@ describe('OutboundHttp.requests', () => {
|
||||||
1,
|
1,
|
||||||
expect.objectContaining({ headers: { Authorization: 'Bearer first' } }),
|
expect.objectContaining({ headers: { Authorization: 'Bearer first' } }),
|
||||||
expect.anything(),
|
expect.anything(),
|
||||||
|
undefined,
|
||||||
);
|
);
|
||||||
expect(mockedHttpRequest).toHaveBeenNthCalledWith(
|
expect(mockedHttpRequest).toHaveBeenNthCalledWith(
|
||||||
2,
|
2,
|
||||||
expect.objectContaining({ headers: { Authorization: 'Bearer second' } }),
|
expect.objectContaining({ headers: { Authorization: 'Bearer second' } }),
|
||||||
expect.anything(),
|
expect.anything(),
|
||||||
|
undefined,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -175,6 +183,7 @@ describe('OutboundHttp.requests', () => {
|
||||||
expect(mockedHttpRequest).toHaveBeenCalledWith(
|
expect(mockedHttpRequest).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ headers: {} }),
|
expect.objectContaining({ headers: {} }),
|
||||||
expect.anything(),
|
expect.anything(),
|
||||||
|
undefined,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -24,10 +24,12 @@ import {
|
||||||
validateUrlSsrf,
|
validateUrlSsrf,
|
||||||
} from './utils';
|
} from './utils';
|
||||||
import type { SsrfBridge } from '../../ssrf';
|
import type { SsrfBridge } from '../../ssrf';
|
||||||
|
import type { NodeAgents } from '../node-agents';
|
||||||
|
|
||||||
export function convertN8nRequestToAxios(
|
export function convertN8nRequestToAxios(
|
||||||
n8nRequest: IHttpRequestOptions,
|
n8nRequest: IHttpRequestOptions,
|
||||||
ssrfBridge?: SsrfBridge,
|
ssrfBridge?: SsrfBridge,
|
||||||
|
agents?: NodeAgents,
|
||||||
): AxiosRequestConfig {
|
): AxiosRequestConfig {
|
||||||
// Destructure properties with the same name first.
|
// Destructure properties with the same name first.
|
||||||
const { headers, method, timeout, auth, proxy, url } = n8nRequest;
|
const { headers, method, timeout, auth, proxy, url } = n8nRequest;
|
||||||
|
|
@ -62,6 +64,11 @@ export function convertN8nRequestToAxios(
|
||||||
axiosRequest.responseType = n8nRequest.encoding;
|
axiosRequest.responseType = n8nRequest.encoding;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (agents) {
|
||||||
|
axiosRequest.httpAgent = agents.httpAgent;
|
||||||
|
axiosRequest.httpsAgent = agents.httpsAgent;
|
||||||
|
}
|
||||||
|
|
||||||
const agentOptions = buildAgentOptions(n8nRequest);
|
const agentOptions = buildAgentOptions(n8nRequest);
|
||||||
setAxiosAgents(axiosRequest, agentOptions, proxy, ssrfBridge ?? 'disabled');
|
setAxiosAgents(axiosRequest, agentOptions, proxy, ssrfBridge ?? 'disabled');
|
||||||
|
|
||||||
|
|
@ -172,18 +179,22 @@ export function removeEmptyBody(requestOptions: IHttpRequestOptions | IRequestOp
|
||||||
export async function httpRequest(
|
export async function httpRequest(
|
||||||
requestOptions: IHttpRequestOptions & { returnFullResponse: true },
|
requestOptions: IHttpRequestOptions & { returnFullResponse: true },
|
||||||
ssrfBridge?: SsrfBridge,
|
ssrfBridge?: SsrfBridge,
|
||||||
|
agents?: NodeAgents,
|
||||||
): Promise<IN8nHttpFullResponse>;
|
): Promise<IN8nHttpFullResponse>;
|
||||||
export async function httpRequest(
|
export async function httpRequest(
|
||||||
requestOptions: IHttpRequestOptions & { returnFullResponse?: false },
|
requestOptions: IHttpRequestOptions & { returnFullResponse?: false },
|
||||||
ssrfBridge?: SsrfBridge,
|
ssrfBridge?: SsrfBridge,
|
||||||
|
agents?: NodeAgents,
|
||||||
): Promise<IN8nHttpResponse>;
|
): Promise<IN8nHttpResponse>;
|
||||||
export async function httpRequest(
|
export async function httpRequest(
|
||||||
requestOptions: IHttpRequestOptions,
|
requestOptions: IHttpRequestOptions,
|
||||||
ssrfBridge?: SsrfBridge,
|
ssrfBridge?: SsrfBridge,
|
||||||
|
agents?: NodeAgents,
|
||||||
): Promise<IN8nHttpFullResponse | IN8nHttpResponse>;
|
): Promise<IN8nHttpFullResponse | IN8nHttpResponse>;
|
||||||
export async function httpRequest(
|
export async function httpRequest(
|
||||||
requestOptions: IHttpRequestOptions,
|
requestOptions: IHttpRequestOptions,
|
||||||
ssrfBridge?: SsrfBridge,
|
ssrfBridge?: SsrfBridge,
|
||||||
|
agents?: NodeAgents,
|
||||||
): Promise<IN8nHttpFullResponse | IN8nHttpResponse> {
|
): Promise<IN8nHttpFullResponse | IN8nHttpResponse> {
|
||||||
removeEmptyBody(requestOptions);
|
removeEmptyBody(requestOptions);
|
||||||
|
|
||||||
|
|
@ -191,7 +202,7 @@ export async function httpRequest(
|
||||||
await validateUrlSsrf(url, ssrfBridge);
|
await validateUrlSsrf(url, ssrfBridge);
|
||||||
await validateProxySsrf(requestOptions.proxy, ssrfBridge);
|
await validateProxySsrf(requestOptions.proxy, ssrfBridge);
|
||||||
|
|
||||||
const axiosRequest = convertN8nRequestToAxios(requestOptions, ssrfBridge);
|
const axiosRequest = convertN8nRequestToAxios(requestOptions, ssrfBridge, agents);
|
||||||
if (axiosRequest.data === undefined || axiosRequest.method?.toUpperCase() === 'GET') {
|
if (axiosRequest.data === undefined || axiosRequest.method?.toUpperCase() === 'GET') {
|
||||||
delete axiosRequest.data;
|
delete axiosRequest.data;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import type { IHttpRequestOptions } from 'n8n-workflow';
|
||||||
|
|
||||||
import type { HttpRequestDefaultHeaders } from './client-default-headers';
|
import type { HttpRequestDefaultHeaders } from './client-default-headers';
|
||||||
import type { UseDefaultSsrfPolicy } from './use-default-ssrf-policy';
|
import type { UseDefaultSsrfPolicy } from './use-default-ssrf-policy';
|
||||||
|
|
||||||
|
|
@ -27,4 +29,24 @@ export interface HttpRequestClientOptions {
|
||||||
* Set it once here instead of repeating it on each request.
|
* Set it once here instead of repeating it on each request.
|
||||||
*/
|
*/
|
||||||
timeout?: number;
|
timeout?: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reuse connections across requests on this client.
|
||||||
|
* The client creates its agents once, with these settings, and `request()` sends through them.
|
||||||
|
* `keepAlive` defaults to `true`.
|
||||||
|
* Call `close()` on the client to release them.
|
||||||
|
*
|
||||||
|
* Redirect hops create their own agents, and `requestLegacy()` does not use the pool.
|
||||||
|
* `maxSockets` and `close()` do not apply to them.
|
||||||
|
*
|
||||||
|
* A request on this client must not set `proxy`, `agentOptions` or `skipSslCertificateValidation`.
|
||||||
|
* The client throws if it does, because the shared agents cannot apply them.
|
||||||
|
*/
|
||||||
|
connectionPool?: HttpConnectionPoolOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HttpConnectionPoolOptions {
|
||||||
|
proxy?: IHttpRequestOptions['proxy'];
|
||||||
|
agentOptions?: IHttpRequestOptions['agentOptions'];
|
||||||
|
skipSslCertificateValidation?: boolean;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,8 +14,18 @@ export class UnknownConnectionHostError extends UnexpectedError {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class SocketPathConnectionError extends UnexpectedError {
|
||||||
|
constructor() {
|
||||||
|
super('Cannot open a connection to a socket path');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Subset of an agent's connection options we read to find the target host. */
|
/** Subset of an agent's connection options we read to find the target host. */
|
||||||
type ConnectionOptions = { host?: string | null; hostname?: string | null };
|
type ConnectionOptions = {
|
||||||
|
host?: string | null;
|
||||||
|
hostname?: string | null;
|
||||||
|
socketPath?: string;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Runtime-only `createConnection` method of Node's http(s) agents.
|
* Runtime-only `createConnection` method of Node's http(s) agents.
|
||||||
|
|
@ -59,6 +69,16 @@ export function installProxyConnectionGuard(agent: ProxyConnectionAgent, ssrf: S
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function connectionError(
|
||||||
|
ssrf: SsrfBridge | 'disabled',
|
||||||
|
options: ConnectionOptions,
|
||||||
|
): Error | undefined {
|
||||||
|
if (options.socketPath) {
|
||||||
|
return new SocketPathConnectionError();
|
||||||
|
}
|
||||||
|
return ssrf === 'disabled' ? undefined : connectionHostError(ssrf, options);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wraps an agent's `createConnection` to validate the connection target before the socket opens.
|
* Wraps an agent's `createConnection` to validate the connection target before the socket opens.
|
||||||
*
|
*
|
||||||
|
|
@ -68,11 +88,11 @@ export function installProxyConnectionGuard(agent: ProxyConnectionAgent, ssrf: S
|
||||||
*/
|
*/
|
||||||
export function installConnectionGuard(
|
export function installConnectionGuard(
|
||||||
target: { createConnection: CreateConnection },
|
target: { createConnection: CreateConnection },
|
||||||
ssrf: SsrfBridge,
|
ssrf: SsrfBridge | 'disabled',
|
||||||
): void {
|
): void {
|
||||||
const createConnection = target.createConnection.bind(target);
|
const createConnection = target.createConnection.bind(target);
|
||||||
target.createConnection = (options, onConnect) => {
|
target.createConnection = (options, onConnect) => {
|
||||||
const error = connectionHostError(ssrf, options);
|
const error = connectionError(ssrf, options);
|
||||||
if (error) {
|
if (error) {
|
||||||
if (onConnect) {
|
if (onConnect) {
|
||||||
onConnect(error);
|
onConnect(error);
|
||||||
|
|
|
||||||
|
|
@ -40,4 +40,9 @@ export class EnvProxyHttpAgent extends http.Agent {
|
||||||
// No proxy for this target: serve it directly from this agent's own pool.
|
// No proxy for this target: serve it directly from this agent's own pool.
|
||||||
super.addRequest(req, options);
|
super.addRequest(req, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
destroy(): void {
|
||||||
|
this.router.destroy();
|
||||||
|
super.destroy();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -32,4 +32,9 @@ export class EnvProxyHttpsAgent extends https.Agent {
|
||||||
// No proxy for this target: serve it directly from this agent's own pool.
|
// No proxy for this target: serve it directly from this agent's own pool.
|
||||||
super.addRequest(req, options);
|
super.addRequest(req, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
destroy(): void {
|
||||||
|
this.router.destroy();
|
||||||
|
super.destroy();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ const MAX_CACHED_AGENTS = 64;
|
||||||
*
|
*
|
||||||
* @typeParam TProxyAgent - the proxy-agent type for this scheme (`HttpProxyAgent` or `HttpsProxyAgent`).
|
* @typeParam TProxyAgent - the proxy-agent type for this scheme (`HttpProxyAgent` or `HttpsProxyAgent`).
|
||||||
*/
|
*/
|
||||||
export class EnvProxyRouter<TProxyAgent> {
|
export class EnvProxyRouter<TProxyAgent extends { destroy(): void }> {
|
||||||
private readonly proxyCache = new Map<string, TProxyAgent>();
|
private readonly proxyCache = new Map<string, TProxyAgent>();
|
||||||
|
|
||||||
// Defensive upper bound on cached agents. Normally unreachable: the cache is keyed by the *proxy* URL.
|
// Defensive upper bound on cached agents. Normally unreachable: the cache is keyed by the *proxy* URL.
|
||||||
|
|
@ -48,6 +48,16 @@ export class EnvProxyRouter<TProxyAgent> {
|
||||||
return agent;
|
return agent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Destroys the cached proxy agents and empties the cache.
|
||||||
|
* Agents created past `MAX_CACHED_AGENTS` are not tracked, so this does not destroy them.
|
||||||
|
* Reaching the limit needs that many distinct environment proxies, which is already a misconfiguration.
|
||||||
|
*/
|
||||||
|
destroy(): void {
|
||||||
|
for (const agent of this.proxyCache.values()) agent.destroy();
|
||||||
|
this.proxyCache.clear();
|
||||||
|
}
|
||||||
|
|
||||||
private cacheAgent(proxyUrl: string, agent: TProxyAgent): void {
|
private cacheAgent(proxyUrl: string, agent: TProxyAgent): void {
|
||||||
if (this.proxyCache.size >= MAX_CACHED_AGENTS) {
|
if (this.proxyCache.size >= MAX_CACHED_AGENTS) {
|
||||||
if (!this.cacheLimitWarned) {
|
if (!this.cacheLimitWarned) {
|
||||||
|
|
|
||||||
|
|
@ -132,6 +132,7 @@ export function createFakeOutboundHttp(routes: Route[], fn: MockFnFactory) {
|
||||||
requestLegacy: () => {
|
requestLegacy: () => {
|
||||||
throw new Error('requestLegacy is not supported by the fake OutboundHttp');
|
throw new Error('requestLegacy is not supported by the fake OutboundHttp');
|
||||||
},
|
},
|
||||||
|
close: () => {},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
const outboundHttp = {
|
const outboundHttp = {
|
||||||
|
|
|
||||||
|
|
@ -29,4 +29,4 @@ export {
|
||||||
isTransportFailure,
|
isTransportFailure,
|
||||||
markHttpRequestError,
|
markHttpRequestError,
|
||||||
} from './client-request-error';
|
} from './client-request-error';
|
||||||
export type { HttpRequestClientOptions } from './client-options';
|
export type { HttpConnectionPoolOptions, HttpRequestClientOptions } from './client-options';
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { UnexpectedError } from 'n8n-workflow';
|
import { UserError } from 'n8n-workflow';
|
||||||
import http from 'node:http';
|
import http from 'node:http';
|
||||||
import https from 'node:https';
|
import https from 'node:https';
|
||||||
import type { LookupFunction } from 'node:net';
|
import type { LookupFunction } from 'node:net';
|
||||||
|
|
@ -9,6 +9,11 @@ import { EnvProxyHttpsAgent } from './env-proxy-https-agent';
|
||||||
import { createProxiedHttpAgent, createProxiedHttpsAgent } from '../proxy/proxied-agents';
|
import { createProxiedHttpAgent, createProxiedHttpsAgent } from '../proxy/proxied-agents';
|
||||||
import type { SsrfBridge } from '../ssrf';
|
import type { SsrfBridge } from '../ssrf';
|
||||||
|
|
||||||
|
export interface NodeAgents {
|
||||||
|
httpAgent: http.Agent;
|
||||||
|
httpsAgent: https.Agent;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* An explicit proxy URL for routing all requests from this client.
|
* An explicit proxy URL for routing all requests from this client.
|
||||||
* Only HTTP(S) forward proxies are supported: both the Node.js agents
|
* Only HTTP(S) forward proxies are supported: both the Node.js agents
|
||||||
|
|
@ -81,6 +86,10 @@ export type NodeAgentOptions = https.AgentOptions;
|
||||||
* therefore has no effect and is rejected to avoid a false sense of control over
|
* therefore has no effect and is rejected to avoid a false sense of control over
|
||||||
* DNS resolution.
|
* DNS resolution.
|
||||||
*
|
*
|
||||||
|
* `agentOptions.socketPath` is rejected for a related reason: it replaces the
|
||||||
|
* connection target, leaving the request's host and port unused, so nothing the
|
||||||
|
* policy decides applies to the socket that opens.
|
||||||
|
*
|
||||||
* `agentOptions` describes the connection to the **target**. Behind a proxy its TLS
|
* `agentOptions` describes the connection to the **target**. Behind a proxy its TLS
|
||||||
* options travel with the tunnelled session rather than the proxy handshake
|
* options travel with the tunnelled session rather than the proxy handshake
|
||||||
* (see `proxy/proxied-agents.ts`).
|
* (see `proxy/proxied-agents.ts`).
|
||||||
|
|
@ -89,13 +98,19 @@ export function buildNodeAgents(
|
||||||
proxy: ProxyOption,
|
proxy: ProxyOption,
|
||||||
ssrf: SsrfOption,
|
ssrf: SsrfOption,
|
||||||
agentOptions?: NodeAgentOptions,
|
agentOptions?: NodeAgentOptions,
|
||||||
): { httpAgent: http.Agent; httpsAgent: https.Agent } {
|
): NodeAgents {
|
||||||
if (agentOptions?.lookup) {
|
if (agentOptions?.lookup) {
|
||||||
throw new UnexpectedError(
|
throw new UserError(
|
||||||
'`agentOptions.lookup` is not supported: DNS resolution is managed by the SSRF policy. Remove it from `agentOptions`.',
|
'`agentOptions.lookup` is not supported: DNS resolution is managed by the SSRF policy. Remove it from `agentOptions`.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (agentOptions !== undefined && 'socketPath' in agentOptions && agentOptions.socketPath) {
|
||||||
|
throw new UserError(
|
||||||
|
'`agentOptions.socketPath` is not supported. Remove it from `agentOptions`.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const lookup: LookupFunction | undefined =
|
const lookup: LookupFunction | undefined =
|
||||||
ssrf !== 'disabled' ? ssrf.createSecureLookup() : undefined;
|
ssrf !== 'disabled' ? ssrf.createSecureLookup() : undefined;
|
||||||
|
|
||||||
|
|
@ -132,15 +147,16 @@ export function buildNodeAgents(
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Installs {@link installConnectionGuard} on a direct-path agent pair when SSRF protection is active.
|
* Installs {@link installConnectionGuard} on a direct-path agent pair.
|
||||||
|
*
|
||||||
|
* It goes on whatever the SSRF setting, because it also refuses a socket path, which
|
||||||
|
* is not an SSRF decision.
|
||||||
*/
|
*/
|
||||||
function applyConnectionGuard(
|
function applyConnectionGuard(
|
||||||
agents: { httpAgent: http.Agent; httpsAgent: https.Agent },
|
agents: { httpAgent: http.Agent; httpsAgent: https.Agent },
|
||||||
ssrf: SsrfOption,
|
ssrf: SsrfOption,
|
||||||
): { httpAgent: http.Agent; httpsAgent: https.Agent } {
|
): { httpAgent: http.Agent; httpsAgent: https.Agent } {
|
||||||
if (ssrf !== 'disabled') {
|
installConnectionGuard(agents.httpAgent, ssrf);
|
||||||
installConnectionGuard(agents.httpAgent, ssrf);
|
installConnectionGuard(agents.httpsAgent, ssrf);
|
||||||
installConnectionGuard(agents.httpsAgent, ssrf);
|
|
||||||
}
|
|
||||||
return agents;
|
return agents;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { Logger } from '@n8n/backend-common';
|
import { Logger } from '@n8n/backend-common';
|
||||||
import { SsrfProtectionConfig } from '@n8n/config';
|
import { SsrfProtectionConfig } from '@n8n/config';
|
||||||
import { Service } from '@n8n/di';
|
import { Service } from '@n8n/di';
|
||||||
|
import { UnexpectedError } from 'n8n-workflow';
|
||||||
import type {
|
import type {
|
||||||
IHttpRequestOptions,
|
IHttpRequestOptions,
|
||||||
IN8nHttpFullResponse,
|
IN8nHttpFullResponse,
|
||||||
|
|
@ -15,11 +16,12 @@ import type { Dispatcher } from 'undici';
|
||||||
|
|
||||||
import { passthroughEgressFilter, SsrfProtectionService } from '../ssrf';
|
import { passthroughEgressFilter, SsrfProtectionService } from '../ssrf';
|
||||||
import { httpRequest } from './axios/request';
|
import { httpRequest } from './axios/request';
|
||||||
|
import { getUrlFromProxyConfig, resolveProxyOption } from './axios/utils';
|
||||||
import { withClientDefaults } from './client-default-headers';
|
import { withClientDefaults } from './client-default-headers';
|
||||||
import { HttpRequestClientOptions } from './client-options';
|
import type { HttpConnectionPoolOptions, HttpRequestClientOptions } from './client-options';
|
||||||
import { markHttpRequestError } from './client-request-error';
|
import { markHttpRequestError } from './client-request-error';
|
||||||
import { executeLegacyRequest, type LegacyRequestCallbacks } from './legacy-request';
|
import { executeLegacyRequest, type LegacyRequestCallbacks } from './legacy-request';
|
||||||
import type { NodeAgentOptions, ProxyOption, SsrfOption } from './node-agents';
|
import type { NodeAgentOptions, NodeAgents, ProxyOption, SsrfOption } from './node-agents';
|
||||||
import { buildNodeAgents } from './node-agents';
|
import { buildNodeAgents } from './node-agents';
|
||||||
import {
|
import {
|
||||||
createDispatcherTransport,
|
createDispatcherTransport,
|
||||||
|
|
@ -114,13 +116,20 @@ export interface HttpRequestClient {
|
||||||
* `callbacks.onFetched` runs once after data is successfully fetched (used by
|
* `callbacks.onFetched` runs once after data is successfully fetched (used by
|
||||||
* the execution engine to fire its `nodeFetchedData` hook).
|
* the execution engine to fire its `nodeFetchedData` hook).
|
||||||
*
|
*
|
||||||
* Ignores the default `baseUrl` and `headers` defined on `HttpRequestClientOptions`.
|
* Ignores the default `baseUrl` and `headers` defined on `HttpRequestClientOptions`,
|
||||||
|
* and does not use the `connectionPool`.
|
||||||
*
|
*
|
||||||
* @deprecated Use {@link request} with `IHttpRequestOptions`. This exists only
|
* @deprecated Use {@link request} with `IHttpRequestOptions`. This exists only
|
||||||
* to back the deprecated `request` helpers.
|
* to back the deprecated `request` helpers.
|
||||||
*/
|
*/
|
||||||
// oxlint-disable-next-line typescript/no-deprecated
|
// oxlint-disable-next-line typescript/no-deprecated
|
||||||
requestLegacy(options: IRequestOptions, callbacks?: LegacyRequestCallbacks): Promise<unknown>;
|
requestLegacy(options: IRequestOptions, callbacks?: LegacyRequestCallbacks): Promise<unknown>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Closes the connections of the client's `connectionPool`, including requests in flight.
|
||||||
|
* Does nothing for a client without a pool.
|
||||||
|
*/
|
||||||
|
close(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -189,6 +198,9 @@ export class OutboundHttp {
|
||||||
const applyDefaults = (requestOptions: IHttpRequestOptions): IHttpRequestOptions =>
|
const applyDefaults = (requestOptions: IHttpRequestOptions): IHttpRequestOptions =>
|
||||||
withClientDefaults(requestOptions, options?.baseURL, options?.headers, options?.timeout);
|
withClientDefaults(requestOptions, options?.baseURL, options?.headers, options?.timeout);
|
||||||
|
|
||||||
|
const pool = options?.connectionPool;
|
||||||
|
const poolAgents = pool && buildPoolAgents(pool, ssrf);
|
||||||
|
|
||||||
function request<T = IN8nHttpResponse | Readable>(
|
function request<T = IN8nHttpResponse | Readable>(
|
||||||
requestOptions: IHttpRequestOptions & { returnFullResponse: true },
|
requestOptions: IHttpRequestOptions & { returnFullResponse: true },
|
||||||
): Promise<TypedHttpFullResponse<T>>;
|
): Promise<TypedHttpFullResponse<T>>;
|
||||||
|
|
@ -199,8 +211,9 @@ export class OutboundHttp {
|
||||||
requestOptions: IHttpRequestOptions,
|
requestOptions: IHttpRequestOptions,
|
||||||
): Promise<T | TypedHttpFullResponse<T>>;
|
): Promise<T | TypedHttpFullResponse<T>>;
|
||||||
async function request(requestOptions: IHttpRequestOptions): Promise<unknown> {
|
async function request(requestOptions: IHttpRequestOptions): Promise<unknown> {
|
||||||
|
const withPool = pool ? withConnectionPool(requestOptions, pool) : requestOptions;
|
||||||
try {
|
try {
|
||||||
return await httpRequest(applyDefaults(requestOptions), ssrfBridge);
|
return await httpRequest(applyDefaults(withPool), ssrfBridge, poolAgents);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Tag so callers can recognize a client-rejected error transport-agnostically.
|
// Tag so callers can recognize a client-rejected error transport-agnostically.
|
||||||
throw markHttpRequestError(error);
|
throw markHttpRequestError(error);
|
||||||
|
|
@ -217,6 +230,10 @@ export class OutboundHttp {
|
||||||
throw markHttpRequestError(error);
|
throw markHttpRequestError(error);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
close: () => {
|
||||||
|
poolAgents?.httpAgent.destroy();
|
||||||
|
poolAgents?.httpsAgent.destroy();
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -275,3 +292,38 @@ function lazy<T>(factory: () => T): () => T {
|
||||||
let cached: { value: T } | undefined;
|
let cached: { value: T } | undefined;
|
||||||
return () => (cached ??= { value: factory() }).value;
|
return () => (cached ??= { value: factory() }).value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const POOL_SETTINGS = ['proxy', 'agentOptions', 'skipSslCertificateValidation'] as const;
|
||||||
|
|
||||||
|
function buildPoolAgents(pool: HttpConnectionPoolOptions, ssrf: SsrfOption): NodeAgents {
|
||||||
|
// Node derives SNI from each request's host, so one pool serves any host.
|
||||||
|
if (pool.agentOptions?.servername) {
|
||||||
|
throw new UnexpectedError(
|
||||||
|
'`connectionPool.agentOptions.servername` is not supported: the pool serves every host of the client. Remove it.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return buildNodeAgents(resolveProxyOption(getUrlFromProxyConfig(pool.proxy)), ssrf, {
|
||||||
|
keepAlive: true,
|
||||||
|
...pool.agentOptions,
|
||||||
|
...(pool.skipSslCertificateValidation === true && { rejectUnauthorized: false }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Copies the pool's settings onto the request so the SSRF proxy check and redirects see them. */
|
||||||
|
function withConnectionPool(
|
||||||
|
requestOptions: IHttpRequestOptions,
|
||||||
|
pool: HttpConnectionPoolOptions,
|
||||||
|
): IHttpRequestOptions {
|
||||||
|
const conflicting = POOL_SETTINGS.filter((key) => requestOptions[key] !== undefined);
|
||||||
|
if (conflicting.length > 0) {
|
||||||
|
throw new UnexpectedError(
|
||||||
|
`A client with a \`connectionPool\` does not accept \`${conflicting.join('`, `')}\` on a request. Set them on \`connectionPool\` instead.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...requestOptions,
|
||||||
|
proxy: pool.proxy,
|
||||||
|
agentOptions: pool.agentOptions,
|
||||||
|
skipSslCertificateValidation: pool.skipSslCertificateValidation,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/backend-services",
|
"name": "@n8n/backend-services",
|
||||||
"version": "1.4.0",
|
"version": "1.4.4",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"clean": "rimraf dist .turbo",
|
"clean": "rimraf dist .turbo",
|
||||||
"dev": "pnpm watch",
|
"dev": "pnpm watch",
|
||||||
|
|
|
||||||
|
|
@ -71,4 +71,21 @@ describe('ProjectScopeService', () => {
|
||||||
]);
|
]);
|
||||||
expect(result).toEqual(['project-1', 'project-2']);
|
expect(result).toEqual(['project-1', 'project-2']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('getMemberProjectIds', () => {
|
||||||
|
it('returns only member projects for a user with the global scope', async () => {
|
||||||
|
roleService.rolesWithScope.mockResolvedValue(['project:admin']);
|
||||||
|
projectRelationRepository.getAccessibleProjectsByRoles.mockResolvedValue(['project-1']);
|
||||||
|
|
||||||
|
const result = await service.getMemberProjectIds(makeUser(['agent:update']), [
|
||||||
|
'agent:update',
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(projectRelationRepository.getAccessibleProjectsByRoles).toHaveBeenCalledWith(
|
||||||
|
'user-1',
|
||||||
|
['project:admin'],
|
||||||
|
);
|
||||||
|
expect(result).toEqual(['project-1']);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -35,14 +35,17 @@ describe('WorkflowProjectService', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('loads the owner project when the cached value is malformed', async () => {
|
it.each(['invalid', {}])(
|
||||||
const project = { id: 'project-1' } as Project;
|
'loads the owner project when the cached value is malformed',
|
||||||
cacheService.getHashValue.mockResolvedValue('invalid' as never);
|
async (cached) => {
|
||||||
sharedWorkflowRepository.findOneOrFail.mockResolvedValue({ project } as never);
|
const project = { id: 'project-1' } as Project;
|
||||||
|
cacheService.getHashValue.mockResolvedValue(cached as never);
|
||||||
|
sharedWorkflowRepository.findOneOrFail.mockResolvedValue({ project } as never);
|
||||||
|
|
||||||
await expect(service.getWorkflowProjectCached('workflow-1')).resolves.toBe(project);
|
await expect(service.getWorkflowProjectCached('workflow-1')).resolves.toBe(project);
|
||||||
expect(sharedWorkflowRepository.findOneOrFail).toHaveBeenCalledOnce();
|
expect(sharedWorkflowRepository.findOneOrFail).toHaveBeenCalledOnce();
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
it('sets the cached owner project', async () => {
|
it('sets the cached owner project', async () => {
|
||||||
const project = { id: 'project-1' } as Project;
|
const project = { id: 'project-1' } as Project;
|
||||||
|
|
|
||||||
|
|
@ -338,6 +338,27 @@ for (const backend of ['memory', 'redis'] as const) {
|
||||||
|
|
||||||
await expect(cacheService.getHashValue('key', 'field1')).resolves.toBe('value1');
|
await expect(cacheService.getHashValue('key', 'field1')).resolves.toBe('value1');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (backend === 'memory') {
|
||||||
|
test('should not return inherited properties as hash field values', async () => {
|
||||||
|
await cacheService.setHash('key', { field1: { id: 'value1' } });
|
||||||
|
|
||||||
|
await expect(cacheService.getHashValue('key', '__proto__')).resolves.toBeUndefined();
|
||||||
|
await expect(cacheService.getHashValue('key', 'constructor')).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should not store a `__proto__` hash field', async () => {
|
||||||
|
await cacheService.setHash('key', { field1: { id: 'value1' } });
|
||||||
|
// A computed key creates an own `__proto__` field, as a hash keyed by a workflow id does
|
||||||
|
await cacheService.setHash('key', { ['__proto__']: { id: 'value2' } });
|
||||||
|
|
||||||
|
expect(Object.getPrototypeOf(await cacheService.get('key'))).toBe(Object.prototype);
|
||||||
|
await expect(cacheService.getHashValue('key', '__proto__')).resolves.toBeUndefined();
|
||||||
|
await expect(cacheService.getHashValue('key', 'field1')).resolves.toEqual({
|
||||||
|
id: 'value1',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -151,7 +151,15 @@ export class CacheService extends TypedEmitter<CacheEvents> {
|
||||||
|
|
||||||
const hashObject: Hash = (await this.get(key)) ?? {};
|
const hashObject: Hash = (await this.get(key)) ?? {};
|
||||||
|
|
||||||
Object.assign(hashObject, hash);
|
for (const [field, value] of Object.entries(hash)) {
|
||||||
|
// Skip `__proto__`. On a plain object, `hash['__proto__'] = value` does not add a field.
|
||||||
|
// It changes the object's prototype.
|
||||||
|
if (field === '__proto__') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
hashObject[field] = value;
|
||||||
|
}
|
||||||
|
|
||||||
await this.set(key, hashObject);
|
await this.set(key, hashObject);
|
||||||
}
|
}
|
||||||
|
|
@ -309,7 +317,10 @@ export class CacheService extends TypedEmitter<CacheEvents> {
|
||||||
} else {
|
} else {
|
||||||
const hashObject = await this.cache.store.get<Hash<T>>(cacheKey);
|
const hashObject = await this.cache.store.get<Hash<T>>(cacheKey);
|
||||||
|
|
||||||
hashValue = hashObject?.[hashKey] as MaybeHash<T>;
|
hashValue =
|
||||||
|
hashObject && Object.hasOwn(hashObject, hashKey)
|
||||||
|
? (hashObject[hashKey] as MaybeHash<T>)
|
||||||
|
: undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hashValue !== undefined) {
|
if (hashValue !== undefined) {
|
||||||
|
|
|
||||||
|
|
@ -22,9 +22,17 @@ export class ProjectScopeService {
|
||||||
}
|
}
|
||||||
|
|
||||||
async getProjectIds(user: User, scopes: Scope[]): Promise<string[] | null> {
|
async getProjectIds(user: User, scopes: Scope[]): Promise<string[] | null> {
|
||||||
const roles = await this.getProjectRoleSlugs(user, scopes);
|
if (hasGlobalScope(user, scopes, { mode: 'allOf' })) return null;
|
||||||
if (roles === null) return null;
|
|
||||||
|
|
||||||
|
return await this.getMemberProjectIds(user, scopes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Like {@link getProjectIds}, but ignores the global role: only projects the
|
||||||
|
* user is a member of with a role that grants all `scopes`.
|
||||||
|
*/
|
||||||
|
async getMemberProjectIds(user: User, scopes: Scope[]): Promise<string[]> {
|
||||||
|
const roles = await this.roleService.rolesWithScope('project', scopes);
|
||||||
return await this.projectRelationRepository.getAccessibleProjectsByRoles(user.id, roles);
|
return await this.projectRelationRepository.getAccessibleProjectsByRoles(user.id, roles);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,9 @@ export class WorkflowProjectService {
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private reconstructProject(project: Partial<Project>): Project | undefined {
|
private reconstructProject(project: Partial<Project>): Project | undefined {
|
||||||
if (typeof project !== 'object' || project === null) return undefined;
|
if (typeof project !== 'object' || project === null || typeof project.id !== 'string') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
return Object.assign(new Project(), project);
|
return Object.assign(new Project(), project);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/backend-test-utils",
|
"name": "@n8n/backend-test-utils",
|
||||||
"version": "1.44.0",
|
"version": "1.44.4",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"clean": "rimraf dist .turbo",
|
"clean": "rimraf dist .turbo",
|
||||||
"dev": "pnpm watch",
|
"dev": "pnpm watch",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/blob-storage",
|
"name": "@n8n/blob-storage",
|
||||||
"version": "1.12.0",
|
"version": "1.12.4",
|
||||||
"description": "Blob storage layer for n8n, with filesystem, S3, and Azure Blob backends",
|
"description": "Blob storage layer for n8n, with filesystem, S3, and Azure Blob backends",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"clean": "rimraf dist .turbo",
|
"clean": "rimraf dist .turbo",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/chat-hub",
|
"name": "@n8n/chat-hub",
|
||||||
"version": "1.37.0",
|
"version": "1.37.3",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"clean": "rimraf dist .turbo",
|
"clean": "rimraf dist .turbo",
|
||||||
"dev": "pnpm watch",
|
"dev": "pnpm watch",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/client-oauth2",
|
"name": "@n8n/client-oauth2",
|
||||||
"version": "1.26.0",
|
"version": "1.26.4",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"clean": "rimraf dist .turbo",
|
"clean": "rimraf dist .turbo",
|
||||||
"dev": "pnpm watch",
|
"dev": "pnpm watch",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/config",
|
"name": "@n8n/config",
|
||||||
"version": "2.42.0",
|
"version": "2.42.1",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"clean": "rimraf dist .turbo",
|
"clean": "rimraf dist .turbo",
|
||||||
"dev": "pnpm watch",
|
"dev": "pnpm watch",
|
||||||
|
|
|
||||||
|
|
@ -156,8 +156,8 @@ describe('ExpressionEngineConfig', () => {
|
||||||
expect(Container.get(ExpressionEngineConfig).observabilityEnabled).toBe(true);
|
expect(Container.get(ExpressionEngineConfig).observabilityEnabled).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('tracesEnabled defaults to true', () => {
|
test('tracesEnabled defaults to false', () => {
|
||||||
expect(Container.get(ExpressionEngineConfig).tracesEnabled).toBe(true);
|
expect(Container.get(ExpressionEngineConfig).tracesEnabled).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('slowEvaluationThresholdMs defaults to 50', () => {
|
test('slowEvaluationThresholdMs defaults to 50', () => {
|
||||||
|
|
|
||||||
|
|
@ -65,12 +65,13 @@ export class ExpressionEngineConfig {
|
||||||
observabilityEnabled: boolean = true;
|
observabilityEnabled: boolean = true;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether to emit OpenTelemetry spans for expression evaluation.
|
* Whether n8n sends OpenTelemetry spans for expression evaluations when `N8N_OTEL_ENABLED`
|
||||||
* Slow evaluations (>slowEvaluationThresholdMs) and errors always emit a span.
|
* is true. n8n creates a span for each evaluation that fails or takes longer than
|
||||||
* Healthy-path evaluations are sampled at tracesSampleRate.
|
* `N8N_EXPRESSION_ENGINE_SLOW_EVAL_THRESHOLD_MS`, and samples the rest at
|
||||||
|
* `N8N_EXPRESSION_ENGINE_TRACES_SAMPLE_RATE`.
|
||||||
*/
|
*/
|
||||||
@Env('N8N_EXPRESSION_ENGINE_TRACES_ENABLED')
|
@Env('N8N_EXPRESSION_ENGINE_TRACES_ENABLED')
|
||||||
tracesEnabled: boolean = true;
|
tracesEnabled: boolean = false;
|
||||||
|
|
||||||
/** Threshold in ms above which an evaluation is considered "slow" and gets a span. */
|
/** Threshold in ms above which an evaluation is considered "slow" and gets a span. */
|
||||||
@Env('N8N_EXPRESSION_ENGINE_SLOW_EVAL_THRESHOLD_MS', z.number({ coerce: true }).positive())
|
@Env('N8N_EXPRESSION_ENGINE_SLOW_EVAL_THRESHOLD_MS', z.number({ coerce: true }).positive())
|
||||||
|
|
|
||||||
|
|
@ -691,7 +691,7 @@ describe('GlobalConfig', () => {
|
||||||
bridgeTimeout: 5000,
|
bridgeTimeout: 5000,
|
||||||
bridgeMemoryLimit: 128,
|
bridgeMemoryLimit: 128,
|
||||||
observabilityEnabled: true,
|
observabilityEnabled: true,
|
||||||
tracesEnabled: true,
|
tracesEnabled: false,
|
||||||
slowEvaluationThresholdMs: 50,
|
slowEvaluationThresholdMs: 50,
|
||||||
tracesSampleRate: 0.0,
|
tracesSampleRate: 0.0,
|
||||||
allowWebhookIsolateSkip: true,
|
allowWebhookIsolateSkip: true,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/create-node",
|
"name": "@n8n/create-node",
|
||||||
"version": "0.52.0",
|
"version": "0.52.4",
|
||||||
"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.44.0",
|
"version": "1.44.4",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"clean": "rimraf dist .turbo .tmp-schema-docs",
|
"clean": "rimraf dist .turbo .tmp-schema-docs",
|
||||||
"gen:migration-index": "node scripts/generate-migration-index.mjs",
|
"gen:migration-index": "node scripts/generate-migration-index.mjs",
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { GlobalConfig } from '@n8n/config';
|
import { GlobalConfig } from '@n8n/config';
|
||||||
import { In, IsNull, MoreThan, Not, type SelectQueryBuilder } from '@n8n/typeorm';
|
import { In, IsNull, MoreThan, Not, type SelectQueryBuilder } from '@n8n/typeorm';
|
||||||
|
import type { IWorkflowSettings } from 'n8n-workflow';
|
||||||
import type { Mock, Mocked } from 'vitest';
|
import type { Mock, Mocked } from 'vitest';
|
||||||
import { mock } from 'vitest-mock-extended';
|
import { mock } from 'vitest-mock-extended';
|
||||||
|
|
||||||
|
|
@ -840,4 +841,270 @@ describe('WorkflowRepository', () => {
|
||||||
expect(mockExecute).toHaveBeenCalled();
|
expect(mockExecute).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('caller allowlist purge', () => {
|
||||||
|
let writes: Array<{ id: string; callerIds: string }>;
|
||||||
|
let writtenSettings: Array<IWorkflowSettings | undefined>;
|
||||||
|
let selectQb: { where: Mock; andWhere: Mock; setLock: Mock };
|
||||||
|
|
||||||
|
const stubCandidates = (candidates: Array<Partial<WorkflowEntity>>) => {
|
||||||
|
writes = [];
|
||||||
|
writtenSettings = [];
|
||||||
|
|
||||||
|
const select = {
|
||||||
|
select: vi.fn(),
|
||||||
|
where: vi.fn(),
|
||||||
|
andWhere: vi.fn(),
|
||||||
|
setLock: vi.fn(),
|
||||||
|
getMany: vi.fn().mockResolvedValue(candidates),
|
||||||
|
};
|
||||||
|
select.select.mockReturnValue(select);
|
||||||
|
select.where.mockReturnValue(select);
|
||||||
|
select.andWhere.mockReturnValue(select);
|
||||||
|
select.setLock.mockReturnValue(select);
|
||||||
|
|
||||||
|
let pendingId = '';
|
||||||
|
let pendingCallerIds = '';
|
||||||
|
let pendingSettings: IWorkflowSettings | undefined;
|
||||||
|
const update = {
|
||||||
|
update: vi.fn(),
|
||||||
|
set: vi.fn(),
|
||||||
|
where: vi.fn(),
|
||||||
|
setParameter: vi.fn(),
|
||||||
|
execute: vi.fn(async () => {
|
||||||
|
writes.push({ id: pendingId, callerIds: pendingCallerIds });
|
||||||
|
writtenSettings.push(pendingSettings);
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
update.update.mockReturnValue(update);
|
||||||
|
// Postgres writes the whole document, SQLite the one key through a parameter.
|
||||||
|
update.set.mockImplementation((values: { settings: unknown }) => {
|
||||||
|
if (typeof values.settings === 'object' && values.settings !== null) {
|
||||||
|
pendingSettings = values.settings as IWorkflowSettings;
|
||||||
|
pendingCallerIds = pendingSettings.callerIds ?? '';
|
||||||
|
}
|
||||||
|
return update;
|
||||||
|
});
|
||||||
|
update.where.mockImplementation((_sql: string, parameters: { workflowId: string }) => {
|
||||||
|
pendingId = parameters.workflowId;
|
||||||
|
return update;
|
||||||
|
});
|
||||||
|
update.setParameter.mockImplementation((_name: string, value: string) => {
|
||||||
|
pendingCallerIds = value;
|
||||||
|
return update;
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mocked(entityManager.createQueryBuilder).mockImplementation(((...args: unknown[]) =>
|
||||||
|
args.length > 0 ? select : update) as never);
|
||||||
|
|
||||||
|
transactionRunner.run.mockImplementation(
|
||||||
|
async (ctx: unknown, fn: (c: unknown) => Promise<unknown>) => await fn(ctx),
|
||||||
|
);
|
||||||
|
|
||||||
|
selectQb = select;
|
||||||
|
return select;
|
||||||
|
};
|
||||||
|
|
||||||
|
it('removes the entry and keeps the rest in order, whatever whitespace separates them', async () => {
|
||||||
|
stubCandidates([
|
||||||
|
{
|
||||||
|
id: 'subworkflow-1',
|
||||||
|
settings: { callerPolicy: 'workflowsFromAList', callerIds: 'first,\n second , ,\tthird' },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
await workflowRepository.removeFromCallerAllowlists(['second'], {});
|
||||||
|
|
||||||
|
expect(writes).toEqual([{ id: 'subworkflow-1', callerIds: 'first,third' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rewrites only the allowlists that hold the id as an entry of its own', async () => {
|
||||||
|
stubCandidates([
|
||||||
|
{
|
||||||
|
id: 'subworkflow-1',
|
||||||
|
settings: { callerPolicy: 'workflowsFromAList', callerIds: 'abc,abcdef' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'subworkflow-2',
|
||||||
|
settings: { callerPolicy: 'workflowsFromAList', callerIds: 'abcdef' },
|
||||||
|
},
|
||||||
|
{ id: 'subworkflow-3', settings: { callerPolicy: 'workflowsFromAList' } },
|
||||||
|
]);
|
||||||
|
|
||||||
|
await workflowRepository.removeFromCallerAllowlists(['abc'], {});
|
||||||
|
|
||||||
|
expect(writes).toEqual([{ id: 'subworkflow-1', callerIds: 'abcdef' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves an allowlist alone when the id only appears inside another entry', async () => {
|
||||||
|
stubCandidates([
|
||||||
|
{
|
||||||
|
id: 'subworkflow-1',
|
||||||
|
settings: { callerPolicy: 'workflowsFromAList', callerIds: 'abc-1, abc-2' },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
await workflowRepository.removeFromCallerAllowlists(['abc'], {});
|
||||||
|
|
||||||
|
expect(writes).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rewrites every workflow that names the id', async () => {
|
||||||
|
stubCandidates([
|
||||||
|
{
|
||||||
|
id: 'subworkflow-1',
|
||||||
|
settings: { callerPolicy: 'workflowsFromAList', callerIds: 'first,second' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'subworkflow-2',
|
||||||
|
settings: { callerPolicy: 'workflowsFromAList', callerIds: 'second,third' },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
await workflowRepository.removeFromCallerAllowlists(['second'], {});
|
||||||
|
|
||||||
|
expect(writes).toEqual([
|
||||||
|
{ id: 'subworkflow-1', callerIds: 'first' },
|
||||||
|
{ id: 'subworkflow-2', callerIds: 'third' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('takes every id out of one allowlist in a single write', async () => {
|
||||||
|
stubCandidates([
|
||||||
|
{
|
||||||
|
id: 'subworkflow-1',
|
||||||
|
settings: { callerPolicy: 'workflowsFromAList', callerIds: 'first,second,third' },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
await workflowRepository.removeFromCallerAllowlists(['first', 'third'], {});
|
||||||
|
|
||||||
|
expect(writes).toEqual([{ id: 'subworkflow-1', callerIds: 'second' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads the candidates once, whatever the batch size, and locks only the ones it rewrites', async () => {
|
||||||
|
const select = stubCandidates([
|
||||||
|
{
|
||||||
|
id: 'subworkflow-1',
|
||||||
|
settings: { callerPolicy: 'workflowsFromAList', callerIds: 'one,first' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'subworkflow-2',
|
||||||
|
settings: { callerPolicy: 'workflowsFromAList', callerIds: 'first' },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
await workflowRepository.removeFromCallerAllowlists(['one', 'two'], {});
|
||||||
|
|
||||||
|
expect(select.where).toHaveBeenCalledTimes(2);
|
||||||
|
expect(select.where).toHaveBeenNthCalledWith(1, expect.stringContaining('callerIds'));
|
||||||
|
expect(select.where).toHaveBeenNthCalledWith(2, 'workflow.id IN (:...ids)', {
|
||||||
|
ids: ['subworkflow-1'],
|
||||||
|
});
|
||||||
|
expect(select.setLock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(select.setLock).toHaveBeenCalledWith('pessimistic_write');
|
||||||
|
expect(writes).toEqual([{ id: 'subworkflow-1', callerIds: 'first' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('locks nothing when no allowlist names a removed id', async () => {
|
||||||
|
const select = stubCandidates([
|
||||||
|
{
|
||||||
|
id: 'subworkflow-1',
|
||||||
|
settings: { callerPolicy: 'workflowsFromAList', callerIds: 'first' },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
await workflowRepository.removeFromCallerAllowlists(['one'], {});
|
||||||
|
|
||||||
|
expect(select.setLock).not.toHaveBeenCalled();
|
||||||
|
expect(writes).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches candidates on the stored document, never on a value read out of it', async () => {
|
||||||
|
const select = stubCandidates([]);
|
||||||
|
|
||||||
|
await workflowRepository.removeFromCallerAllowlists(['one'], {});
|
||||||
|
|
||||||
|
expect(select.where).toHaveBeenCalledWith('workflow.settings::text LIKE \'%"callerIds"%\'');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a value the binary JSON form has no room for', async () => {
|
||||||
|
const noBinaryJsonForm = String.fromCharCode(0);
|
||||||
|
stubCandidates([
|
||||||
|
{
|
||||||
|
id: 'subworkflow-1',
|
||||||
|
settings: {
|
||||||
|
callerPolicy: 'workflowsFromAList',
|
||||||
|
callerIds: 'first,second',
|
||||||
|
errorWorkflow: `error-workflow${noBinaryJsonForm}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
await workflowRepository.removeFromCallerAllowlists(['second'], {});
|
||||||
|
|
||||||
|
expect(writtenSettings).toEqual([
|
||||||
|
{
|
||||||
|
callerPolicy: 'workflowsFromAList',
|
||||||
|
callerIds: 'first',
|
||||||
|
errorWorkflow: `error-workflow${noBinaryJsonForm}`,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('purges a row that exists at an id being removed', async () => {
|
||||||
|
stubCandidates([
|
||||||
|
{
|
||||||
|
id: 'recreated-1',
|
||||||
|
settings: { callerPolicy: 'workflowsFromAList', callerIds: 'first,second' },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
await workflowRepository.removeFromCallerAllowlists(['recreated-1', 'second'], {});
|
||||||
|
|
||||||
|
expect(writes).toEqual([{ id: 'recreated-1', callerIds: 'first' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('writes nothing when given no ids', async () => {
|
||||||
|
stubCandidates([]);
|
||||||
|
|
||||||
|
await workflowRepository.removeFromCallerAllowlists([], {});
|
||||||
|
|
||||||
|
expect(selectQb.where).not.toHaveBeenCalled();
|
||||||
|
expect(writes).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes only the entries naming an id without a row', async () => {
|
||||||
|
stubCandidates([
|
||||||
|
{
|
||||||
|
id: 'subworkflow-1',
|
||||||
|
settings: { callerPolicy: 'workflowsFromAList', callerIds: 'deleted,survivor,other' },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
vi.mocked(entityManager.find).mockResolvedValue([{ id: 'survivor' }, { id: 'other' }]);
|
||||||
|
|
||||||
|
await workflowRepository.removeMissingFromCallerAllowlists({});
|
||||||
|
|
||||||
|
expect(entityManager.find).toHaveBeenCalledWith(WorkflowEntity, {
|
||||||
|
select: ['id'],
|
||||||
|
where: { id: In(['deleted', 'survivor', 'other']) },
|
||||||
|
});
|
||||||
|
expect(writes).toEqual([{ id: 'subworkflow-1', callerIds: 'survivor,other' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('locks and writes nothing when every named id has a row', async () => {
|
||||||
|
const select = stubCandidates([
|
||||||
|
{
|
||||||
|
id: 'subworkflow-1',
|
||||||
|
settings: { callerPolicy: 'workflowsFromAList', callerIds: 'first,second' },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
vi.mocked(entityManager.find).mockResolvedValue([{ id: 'first' }, { id: 'second' }]);
|
||||||
|
|
||||||
|
await workflowRepository.removeMissingFromCallerAllowlists({});
|
||||||
|
|
||||||
|
expect(select.setLock).not.toHaveBeenCalled();
|
||||||
|
expect(writes).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -454,6 +454,136 @@ export class WorkflowRepository extends BaseRepository<WorkflowEntity> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Remove every id in `workflowIds` from the `callerIds` allowlist of every other workflow. */
|
||||||
|
async removeFromCallerAllowlists(workflowIds: string[], ctx: OperationContext): Promise<void> {
|
||||||
|
if (workflowIds.length === 0) return;
|
||||||
|
|
||||||
|
await this.runInTransaction(ctx, async (tx) => {
|
||||||
|
const candidates = await this.findCallerAllowlistCandidates(tx);
|
||||||
|
await this.purgeCallerAllowlists(candidates, new Set(workflowIds), tx);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove every id that has no workflow row from every `callerIds` allowlist. */
|
||||||
|
async removeMissingFromCallerAllowlists(ctx: OperationContext): Promise<void> {
|
||||||
|
await this.runInTransaction(ctx, async (tx) => {
|
||||||
|
const candidates = await this.findCallerAllowlistCandidates(tx);
|
||||||
|
const namedIds = [
|
||||||
|
...new Set(candidates.flatMap((workflow) => this.callerIdEntries(workflow))),
|
||||||
|
];
|
||||||
|
|
||||||
|
const existingIds = new Set<string>();
|
||||||
|
for (const chunk of chunkIds(namedIds)) {
|
||||||
|
const existing = await tx.find(WorkflowEntity, {
|
||||||
|
select: ['id'],
|
||||||
|
where: { id: In(chunk) },
|
||||||
|
});
|
||||||
|
for (const { id } of existing) existingIds.add(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const missingIds = new Set(namedIds.filter((id) => !existingIds.has(id)));
|
||||||
|
if (missingIds.size > 0) await this.purgeCallerAllowlists(candidates, missingIds, tx);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rewrite the allowlists among `candidates` that name any of `removedIds`, under a row lock. */
|
||||||
|
private async purgeCallerAllowlists(
|
||||||
|
candidates: WorkflowEntity[],
|
||||||
|
removedIds: Set<string>,
|
||||||
|
tx: EntityManager,
|
||||||
|
): Promise<void> {
|
||||||
|
const affected = candidates.filter(
|
||||||
|
(workflow) => this.callerIdsWithout(workflow, removedIds) !== undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const workflow of await this.lockCallerAllowlists(affected, tx)) {
|
||||||
|
const remaining = this.callerIdsWithout(workflow, removedIds);
|
||||||
|
if (remaining !== undefined) await this.writeCallerIds(workflow, remaining, tx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The non-empty, trimmed entries of the `callerIds` allowlist of `workflow`. */
|
||||||
|
private callerIdEntries(workflow: WorkflowEntity): string[] {
|
||||||
|
const callerIds = workflow.settings?.callerIds;
|
||||||
|
if (typeof callerIds !== 'string') return [];
|
||||||
|
|
||||||
|
return callerIds
|
||||||
|
.split(',')
|
||||||
|
.map((callerId) => callerId.trim())
|
||||||
|
.filter((callerId) => callerId !== '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The allowlist of `workflow` without `removedIds`, or `undefined` when it names none of them. */
|
||||||
|
private callerIdsWithout(workflow: WorkflowEntity, removedIds: Set<string>): string | undefined {
|
||||||
|
const entries = this.callerIdEntries(workflow);
|
||||||
|
if (!entries.some((callerId) => removedIds.has(callerId))) return undefined;
|
||||||
|
|
||||||
|
return entries.filter((callerId) => !removedIds.has(callerId)).join(',');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Find every workflow that has a `callerIds` allowlist. */
|
||||||
|
private async findCallerAllowlistCandidates(tx: EntityManager): Promise<WorkflowEntity[]> {
|
||||||
|
// On Postgres, match the raw text rather than extract: the `json` column accepts characters,
|
||||||
|
// such as an escaped NUL, that extracting `callerIds` rejects with `unsupported Unicode
|
||||||
|
// escape sequence`, which fails the query for every row.
|
||||||
|
const hasAllowlist =
|
||||||
|
this.globalConfig.database.type === 'postgresdb'
|
||||||
|
? 'workflow.settings::text LIKE \'%"callerIds"%\''
|
||||||
|
: `${this.settingsTextValue('workflow.settings', 'callerIds')} IS NOT NULL`;
|
||||||
|
|
||||||
|
return await tx
|
||||||
|
.createQueryBuilder(WorkflowEntity, 'workflow')
|
||||||
|
.select(['workflow.id', 'workflow.settings'])
|
||||||
|
.where(hasAllowlist)
|
||||||
|
.getMany();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lock `workflows` until the transaction ends and re-read their settings under the lock. */
|
||||||
|
private async lockCallerAllowlists(
|
||||||
|
workflows: WorkflowEntity[],
|
||||||
|
tx: EntityManager,
|
||||||
|
): Promise<WorkflowEntity[]> {
|
||||||
|
// SQLite serialises writes, so what it read is already current.
|
||||||
|
if (this.globalConfig.database.type !== 'postgresdb') return workflows;
|
||||||
|
|
||||||
|
const locked: WorkflowEntity[] = [];
|
||||||
|
for (const chunk of chunkIds(workflows.map(({ id }) => id))) {
|
||||||
|
locked.push(
|
||||||
|
...(await tx
|
||||||
|
.createQueryBuilder(WorkflowEntity, 'workflow')
|
||||||
|
.select(['workflow.id', 'workflow.settings'])
|
||||||
|
.where('workflow.id IN (:...ids)', { ids: chunk })
|
||||||
|
.setLock('pessimistic_write')
|
||||||
|
.getMany()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return locked;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Store `callerIds` back on one workflow, keeping its other settings. */
|
||||||
|
private async writeCallerIds(
|
||||||
|
workflow: WorkflowEntity,
|
||||||
|
callerIds: string,
|
||||||
|
tx: EntityManager,
|
||||||
|
): Promise<void> {
|
||||||
|
const update = tx
|
||||||
|
.createQueryBuilder()
|
||||||
|
.update(WorkflowEntity)
|
||||||
|
.where('id = :workflowId', { workflowId: workflow.id });
|
||||||
|
|
||||||
|
if (this.globalConfig.database.type === 'postgresdb') {
|
||||||
|
// `jsonb_set` rejects characters the `json` column accepts, so rewrite the whole document
|
||||||
|
// read under the row lock.
|
||||||
|
await update.set({ settings: { ...workflow.settings, callerIds } }).execute();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await update
|
||||||
|
.set({ settings: () => "JSON_SET(COALESCE(settings, '{}'), '$.callerIds', :callerIds)" })
|
||||||
|
.setParameter('callerIds', callerIds)
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
|
||||||
async findById(workflowId: string) {
|
async findById(workflowId: string) {
|
||||||
return await this.findOne({
|
return await this.findOne({
|
||||||
where: { id: workflowId },
|
where: { id: workflowId },
|
||||||
|
|
@ -864,7 +994,6 @@ export class WorkflowRepository extends BaseRepository<WorkflowEntity> {
|
||||||
onlySharedWithMe?: boolean;
|
onlySharedWithMe?: boolean;
|
||||||
},
|
},
|
||||||
options: WorkflowListQueryOptions = {},
|
options: WorkflowListQueryOptions = {},
|
||||||
callableForParentWorkflowId?: string,
|
|
||||||
) {
|
) {
|
||||||
if (
|
if (
|
||||||
options.filter?.parentFolderId &&
|
options.filter?.parentFolderId &&
|
||||||
|
|
@ -884,18 +1013,8 @@ export class WorkflowRepository extends BaseRepository<WorkflowEntity> {
|
||||||
}
|
}
|
||||||
|
|
||||||
const [workflowsAndFolders, count] = await Promise.all([
|
const [workflowsAndFolders, count] = await Promise.all([
|
||||||
this.getWorkflowsAndFoldersUnionWithSharingSubquery(
|
this.getWorkflowsAndFoldersUnionWithSharingSubquery(user, sharingOptions, options),
|
||||||
user,
|
this.getWorkflowsAndFoldersCountWithSharingSubquery(user, sharingOptions, options),
|
||||||
sharingOptions,
|
|
||||||
options,
|
|
||||||
callableForParentWorkflowId,
|
|
||||||
),
|
|
||||||
this.getWorkflowsAndFoldersCountWithSharingSubquery(
|
|
||||||
user,
|
|
||||||
sharingOptions,
|
|
||||||
options,
|
|
||||||
callableForParentWorkflowId,
|
|
||||||
),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const isArchived =
|
const isArchived =
|
||||||
|
|
@ -922,15 +1041,9 @@ export class WorkflowRepository extends BaseRepository<WorkflowEntity> {
|
||||||
onlySharedWithMe?: boolean;
|
onlySharedWithMe?: boolean;
|
||||||
},
|
},
|
||||||
options: WorkflowListQueryOptions = {},
|
options: WorkflowListQueryOptions = {},
|
||||||
callableForParentWorkflowId?: string,
|
|
||||||
) {
|
) {
|
||||||
const { baseQuery, sortByColumn, sortByDirection } =
|
const { baseQuery, sortByColumn, sortByDirection } =
|
||||||
this.buildBaseUnionQueryWithSharingSubquery(
|
this.buildBaseUnionQueryWithSharingSubquery(user, sharingOptions, options);
|
||||||
user,
|
|
||||||
sharingOptions,
|
|
||||||
options,
|
|
||||||
callableForParentWorkflowId,
|
|
||||||
);
|
|
||||||
|
|
||||||
const query = this.buildUnionQuery(baseQuery, {
|
const query = this.buildUnionQuery(baseQuery, {
|
||||||
sortByColumn,
|
sortByColumn,
|
||||||
|
|
@ -956,7 +1069,6 @@ export class WorkflowRepository extends BaseRepository<WorkflowEntity> {
|
||||||
onlySharedWithMe?: boolean;
|
onlySharedWithMe?: boolean;
|
||||||
},
|
},
|
||||||
options: WorkflowListQueryOptions = {},
|
options: WorkflowListQueryOptions = {},
|
||||||
callableForParentWorkflowId?: string,
|
|
||||||
) {
|
) {
|
||||||
const { skip, take, ...baseQueryParameters } = options;
|
const { skip, take, ...baseQueryParameters } = options;
|
||||||
|
|
||||||
|
|
@ -964,7 +1076,6 @@ export class WorkflowRepository extends BaseRepository<WorkflowEntity> {
|
||||||
user,
|
user,
|
||||||
sharingOptions,
|
sharingOptions,
|
||||||
baseQueryParameters,
|
baseQueryParameters,
|
||||||
callableForParentWorkflowId,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const response = await baseQuery
|
const response = await baseQuery
|
||||||
|
|
@ -986,7 +1097,6 @@ export class WorkflowRepository extends BaseRepository<WorkflowEntity> {
|
||||||
onlySharedWithMe?: boolean;
|
onlySharedWithMe?: boolean;
|
||||||
},
|
},
|
||||||
options: WorkflowListQueryOptions = {},
|
options: WorkflowListQueryOptions = {},
|
||||||
callableForParentWorkflowId?: string,
|
|
||||||
) {
|
) {
|
||||||
// Common fields for both folders and workflows
|
// Common fields for both folders and workflows
|
||||||
const commonFields = {
|
const commonFields = {
|
||||||
|
|
@ -1036,7 +1146,6 @@ export class WorkflowRepository extends BaseRepository<WorkflowEntity> {
|
||||||
user,
|
user,
|
||||||
sharingOptions,
|
sharingOptions,
|
||||||
workflowQueryParameters,
|
workflowQueryParameters,
|
||||||
callableForParentWorkflowId,
|
|
||||||
).addSelect("'workflow'", 'resource');
|
).addSelect("'workflow'", 'resource');
|
||||||
|
|
||||||
const qb = this.manager.createQueryBuilder();
|
const qb = this.manager.createQueryBuilder();
|
||||||
|
|
@ -1155,14 +1264,8 @@ export class WorkflowRepository extends BaseRepository<WorkflowEntity> {
|
||||||
onlySharedWithMe?: boolean;
|
onlySharedWithMe?: boolean;
|
||||||
},
|
},
|
||||||
options: WorkflowListQueryOptions = {},
|
options: WorkflowListQueryOptions = {},
|
||||||
callableForParentWorkflowId?: string,
|
|
||||||
): Promise<WorkflowListResult> {
|
): Promise<WorkflowListResult> {
|
||||||
const query = this.getManyQueryWithSharingSubquery(
|
const query = this.getManyQueryWithSharingSubquery(user, sharingOptions, options);
|
||||||
user,
|
|
||||||
sharingOptions,
|
|
||||||
options,
|
|
||||||
callableForParentWorkflowId,
|
|
||||||
);
|
|
||||||
|
|
||||||
const [workflows, count] = await query.getManyAndCount();
|
const [workflows, count] = await query.getManyAndCount();
|
||||||
return { workflows, count };
|
return { workflows, count };
|
||||||
|
|
@ -1182,7 +1285,6 @@ export class WorkflowRepository extends BaseRepository<WorkflowEntity> {
|
||||||
onlySharedWithMe?: boolean;
|
onlySharedWithMe?: boolean;
|
||||||
},
|
},
|
||||||
options: WorkflowListQueryOptions = {},
|
options: WorkflowListQueryOptions = {},
|
||||||
callableForParentWorkflowId?: string,
|
|
||||||
): SelectQueryBuilder<WorkflowEntity> {
|
): SelectQueryBuilder<WorkflowEntity> {
|
||||||
const qb = this.createQueryBuilder('workflow');
|
const qb = this.createQueryBuilder('workflow');
|
||||||
|
|
||||||
|
|
@ -1200,21 +1302,9 @@ export class WorkflowRepository extends BaseRepository<WorkflowEntity> {
|
||||||
sharingOptionsWithProjectId,
|
sharingOptionsWithProjectId,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (callableForParentWorkflowId) {
|
// Apply the sharing filter using the subquery
|
||||||
// Union: workflows readable by the user OR callable by the parent workflow.
|
qb.andWhere(`workflow.id IN (${sharedWorkflowSubquery.getQuery()})`);
|
||||||
const callableSubquery = this.buildCallablePolicySubquery(callableForParentWorkflowId);
|
qb.setParameters(sharedWorkflowSubquery.getParameters());
|
||||||
qb.andWhere(
|
|
||||||
`(workflow.id IN (${sharedWorkflowSubquery.getQuery()}) OR workflow.id IN (${callableSubquery.getQuery()}))`,
|
|
||||||
);
|
|
||||||
qb.setParameters({
|
|
||||||
...sharedWorkflowSubquery.getParameters(),
|
|
||||||
...callableSubquery.getParameters(),
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// Apply the sharing filter using the subquery
|
|
||||||
qb.andWhere(`workflow.id IN (${sharedWorkflowSubquery.getQuery()})`);
|
|
||||||
qb.setParameters(sharedWorkflowSubquery.getParameters());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply other filters
|
// Apply other filters
|
||||||
// For personal project and shared-with-me cases, projectId is already handled in the subquery
|
// For personal project and shared-with-me cases, projectId is already handled in the subquery
|
||||||
|
|
@ -1238,70 +1328,98 @@ export class WorkflowRepository extends BaseRepository<WorkflowEntity> {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build a subquery returning IDs of workflows whose callerPolicy permits the
|
* List the workflows that `parentWorkflowId` can call as sub-workflows. The
|
||||||
* given `parentWorkflowId` to call them.
|
* list holds the workflows the user can read, plus the workflows whose
|
||||||
|
* caller policy names the parent explicitly (`any`, or an allowlist that
|
||||||
|
* contains the parent id).
|
||||||
|
*
|
||||||
|
* Only a minimal set of columns is loaded, because the callable rows can
|
||||||
|
* belong to projects that the user has no access to.
|
||||||
|
*/
|
||||||
|
async findCallableSubworkflows(
|
||||||
|
user: User,
|
||||||
|
sharingOptions: { scopes: Scope[]; projectRoles: string[]; workflowRoles: string[] },
|
||||||
|
parentWorkflowId: string,
|
||||||
|
options: { query?: string; triggerNodeTypes: string[]; skip: number; take: number },
|
||||||
|
): Promise<{ workflows: WorkflowEntity[]; count: number }> {
|
||||||
|
const sharedWorkflowSubquery = this.buildSharedWorkflowIdsSubquery(user, sharingOptions);
|
||||||
|
const callableSubquery = this.buildCallablePolicySubquery(parentWorkflowId);
|
||||||
|
|
||||||
|
const qb = this.createQueryBuilder('workflow')
|
||||||
|
.select([
|
||||||
|
'workflow.id',
|
||||||
|
'workflow.name',
|
||||||
|
'workflow.activeVersionId',
|
||||||
|
'workflow.isArchived',
|
||||||
|
'workflow.updatedAt',
|
||||||
|
])
|
||||||
|
.leftJoin('workflow.shared', 'owner', 'owner.role = :callableOwnerRole', {
|
||||||
|
callableOwnerRole: 'workflow:owner',
|
||||||
|
})
|
||||||
|
.leftJoin('owner.project', 'ownerProject')
|
||||||
|
.addSelect([
|
||||||
|
'owner.workflowId',
|
||||||
|
'owner.projectId',
|
||||||
|
'owner.role',
|
||||||
|
'ownerProject.id',
|
||||||
|
'ownerProject.type',
|
||||||
|
'ownerProject.name',
|
||||||
|
])
|
||||||
|
.where(
|
||||||
|
`(workflow.id IN (${sharedWorkflowSubquery.getQuery()}) OR workflow.id IN (${callableSubquery.getQuery()}))`,
|
||||||
|
)
|
||||||
|
.setParameters({
|
||||||
|
...sharedWorkflowSubquery.getParameters(),
|
||||||
|
...callableSubquery.getParameters(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Match names only: the list can hold workflows the user cannot read, so
|
||||||
|
// their descriptions must not affect the result.
|
||||||
|
const searchWords = this.parseSearchWords(options.query);
|
||||||
|
if (searchWords.length > 0) {
|
||||||
|
const { conditions, parameters } = this.buildSearchConditions(
|
||||||
|
searchWords,
|
||||||
|
'LOWER(workflow.name)',
|
||||||
|
);
|
||||||
|
qb.andWhere(`(${conditions.join(' AND ')})`, parameters);
|
||||||
|
}
|
||||||
|
this.applyTriggerNodeTypesFilter(qb, options.triggerNodeTypes);
|
||||||
|
qb.orderBy('workflow.updatedAt', 'DESC').skip(options.skip).take(options.take);
|
||||||
|
|
||||||
|
const [workflows, count] = await qb.getManyAndCount();
|
||||||
|
return { workflows, count };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a subquery returning IDs of workflows whose callerPolicy names the
|
||||||
|
* given `parentWorkflowId` explicitly.
|
||||||
|
*
|
||||||
|
* The `workflowsFromSameOwner` policy is left out on purpose: for a user who
|
||||||
|
* is not the owner, it would list workflows that the owner did not share.
|
||||||
|
* The owner can already read those workflows through the sharing subquery.
|
||||||
*/
|
*/
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
private buildCallablePolicySubquery(parentWorkflowId: string): SelectQueryBuilder<any> {
|
private buildCallablePolicySubquery(parentWorkflowId: string): SelectQueryBuilder<any> {
|
||||||
const subquery = this.manager
|
|
||||||
.createQueryBuilder()
|
|
||||||
.select('cpw.id')
|
|
||||||
.from(WorkflowEntity, 'cpw')
|
|
||||||
.leftJoin(
|
|
||||||
SharedWorkflow,
|
|
||||||
'sw_sub',
|
|
||||||
"sw_sub.workflowId = cpw.id AND sw_sub.role = 'workflow:owner'",
|
|
||||||
)
|
|
||||||
.leftJoin(
|
|
||||||
SharedWorkflow,
|
|
||||||
'sw_par',
|
|
||||||
"sw_par.workflowId = :cpParentWorkflowId AND sw_par.role = 'workflow:owner'",
|
|
||||||
);
|
|
||||||
|
|
||||||
const { conditions, params } = this.buildCallablePolicyConditions(parentWorkflowId);
|
|
||||||
|
|
||||||
subquery.where('(' + conditions.join(' OR ') + ')', params);
|
|
||||||
|
|
||||||
return subquery;
|
|
||||||
}
|
|
||||||
|
|
||||||
private buildCallablePolicyConditions(parentWorkflowId: string): {
|
|
||||||
conditions: string[];
|
|
||||||
params: Record<string, string>;
|
|
||||||
} {
|
|
||||||
const defaultPolicy = this.globalConfig.workflows.callerPolicyDefaultOption;
|
const defaultPolicy = this.globalConfig.workflows.callerPolicyDefaultOption;
|
||||||
const callerPolicy = this.settingsTextValue('cpw.settings', 'callerPolicy');
|
const callerPolicy = this.settingsTextValue('cpw.settings', 'callerPolicy');
|
||||||
const callerIds = this.settingsTextValue('cpw.settings', 'callerIds');
|
const callerIds = this.settingsTextValue('cpw.settings', 'callerIds');
|
||||||
|
|
||||||
const conditions: string[] = [];
|
const conditions = [
|
||||||
const params: Record<string, string> = {
|
`${callerPolicy} = 'any'`,
|
||||||
cpParentWorkflowId: parentWorkflowId,
|
// The allowlist must contain parentWorkflowId as a whole ID.
|
||||||
cpCallerIdMembership: `%,${escapeLike(parentWorkflowId)},%`,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Branch 1: callerPolicy = 'any'
|
|
||||||
conditions.push(`${callerPolicy} = 'any'`);
|
|
||||||
|
|
||||||
// Branch 2: callerPolicy = 'workflowsFromAList' and the allowlist contains parentWorkflowId as a whole ID.
|
|
||||||
conditions.push(
|
|
||||||
`(${callerPolicy} = 'workflowsFromAList' AND (',' || REPLACE(${callerIds}, ' ', '') || ',') LIKE :cpCallerIdMembership ${LIKE_ESCAPE_CLAUSE})`,
|
`(${callerPolicy} = 'workflowsFromAList' AND (',' || REPLACE(${callerIds}, ' ', '') || ',') LIKE :cpCallerIdMembership ${LIKE_ESCAPE_CLAUSE})`,
|
||||||
);
|
];
|
||||||
|
|
||||||
// Branch 3: callerPolicy = 'workflowsFromSameOwner' (or NULL when default is 'workflowsFromSameOwner').
|
|
||||||
const sameOwnerPolicyClauses = [`${callerPolicy} = 'workflowsFromSameOwner'`];
|
|
||||||
if (defaultPolicy === 'workflowsFromSameOwner') {
|
|
||||||
sameOwnerPolicyClauses.push(`${callerPolicy} IS NULL`);
|
|
||||||
}
|
|
||||||
conditions.push(
|
|
||||||
`((${sameOwnerPolicyClauses.join(' OR ')}) AND sw_sub.projectId = sw_par.projectId AND sw_par.projectId IS NOT NULL)`,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Handle NULL callerPolicy when default is 'any'
|
|
||||||
if (defaultPolicy === 'any') {
|
if (defaultPolicy === 'any') {
|
||||||
conditions.push(`${callerPolicy} IS NULL`);
|
conditions.push(`${callerPolicy} IS NULL`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { conditions, params };
|
return this.manager
|
||||||
|
.createQueryBuilder()
|
||||||
|
.select('cpw.id')
|
||||||
|
.from(WorkflowEntity, 'cpw')
|
||||||
|
.where(`(${conditions.join(' OR ')})`, {
|
||||||
|
cpCallerIdMembership: `%,${escapeLike(parentWorkflowId)},%`,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private settingsTextValue(field: string, key: string): string {
|
private settingsTextValue(field: string, key: string): string {
|
||||||
|
|
@ -1467,14 +1585,15 @@ export class WorkflowRepository extends BaseRepository<WorkflowEntity> {
|
||||||
/**
|
/**
|
||||||
* Builds search conditions and parameters for matching all of the search words
|
* Builds search conditions and parameters for matching all of the search words
|
||||||
*/
|
*/
|
||||||
private buildSearchConditions(searchWords: string[]): {
|
private buildSearchConditions(
|
||||||
|
searchWords: string[],
|
||||||
|
fieldExpression = this.getFieldConcatExpression(),
|
||||||
|
): {
|
||||||
conditions: string[];
|
conditions: string[];
|
||||||
parameters: Record<string, string>;
|
parameters: Record<string, string>;
|
||||||
} {
|
} {
|
||||||
const concatExpression = this.getFieldConcatExpression();
|
|
||||||
|
|
||||||
const conditions = searchWords.map((_, index) => {
|
const conditions = searchWords.map((_, index) => {
|
||||||
return `${concatExpression} LIKE :searchWord${index}`;
|
return `${fieldExpression} LIKE :searchWord${index}`;
|
||||||
});
|
});
|
||||||
|
|
||||||
const parameters: Record<string, string> = {};
|
const parameters: Record<string, string> = {};
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/decorators",
|
"name": "@n8n/decorators",
|
||||||
"version": "1.44.0",
|
"version": "1.44.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.24.0",
|
"version": "0.24.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/inbound-auth",
|
"name": "@n8n/inbound-auth",
|
||||||
"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/instance-ai",
|
"name": "@n8n/instance-ai",
|
||||||
"version": "1.29.0",
|
"version": "1.29.5",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"clean": "rimraf dist .turbo",
|
"clean": "rimraf dist .turbo",
|
||||||
"typecheck": "tsc --noEmit && tsc -p evaluations/tsconfig.json",
|
"typecheck": "tsc --noEmit && tsc -p evaluations/tsconfig.json",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/mcp-apps",
|
"name": "@n8n/mcp-apps",
|
||||||
"version": "0.21.0",
|
"version": "0.21.3",
|
||||||
"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.53.0",
|
"version": "0.53.4",
|
||||||
"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"
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/node-engine-compatibility",
|
"name": "@n8n/node-engine-compatibility",
|
||||||
"version": "0.12.0",
|
"version": "0.12.4",
|
||||||
"description": "Adapts v1 nodes and workflows to the Engine v2 execution model",
|
"description": "Adapts v1 nodes and workflows to the Engine v2 execution model",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"clean": "rimraf dist .turbo",
|
"clean": "rimraf dist .turbo",
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,11 @@ import {
|
||||||
N8nLlmTracing,
|
N8nLlmTracing,
|
||||||
getConnectionHintNoticeField,
|
getConnectionHintNoticeField,
|
||||||
} from '@n8n/ai-utilities';
|
} from '@n8n/ai-utilities';
|
||||||
import { shouldIncludeGoogleModel } from '@n8n/ai-utilities/model-discovery';
|
import {
|
||||||
|
getGoogleModelLabel,
|
||||||
|
pinPrioritizedModels,
|
||||||
|
shouldIncludeGoogleModel,
|
||||||
|
} from '@n8n/ai-utilities/model-discovery';
|
||||||
|
|
||||||
import { MODEL_SELECTION_HINT } from '@utils/model-builder-hints';
|
import { MODEL_SELECTION_HINT } from '@utils/model-builder-hints';
|
||||||
|
|
||||||
|
|
@ -37,6 +41,30 @@ async function filterChatModels(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Show models under the name the API reports, sorted by it, with prioritized models first. */
|
||||||
|
async function labelModels(
|
||||||
|
this: IExecuteSingleFunctions,
|
||||||
|
items: INodeExecutionData[],
|
||||||
|
): Promise<INodeExecutionData[]> {
|
||||||
|
const labelled = items.map((item) => {
|
||||||
|
// `displayName` and `priority` only carry the API's values to here; options do not keep them.
|
||||||
|
const { displayName, priority, ...option } = item.json;
|
||||||
|
const json =
|
||||||
|
typeof option.value === 'string'
|
||||||
|
? {
|
||||||
|
...option,
|
||||||
|
name: getGoogleModelLabel(
|
||||||
|
option.value,
|
||||||
|
typeof displayName === 'string' ? displayName : undefined,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: option;
|
||||||
|
return { item: { ...item, json }, priority };
|
||||||
|
});
|
||||||
|
labelled.sort((a, b) => String(a.item.json.name).localeCompare(String(b.item.json.name)));
|
||||||
|
return pinPrioritizedModels(labelled, (entry) => entry.priority).map((entry) => entry.item);
|
||||||
|
}
|
||||||
|
|
||||||
function errorDescriptionMapper(error: NodeError) {
|
function errorDescriptionMapper(error: NodeError) {
|
||||||
if (error.description?.includes('properties: should be non-empty for OBJECT type')) {
|
if (error.description?.includes('properties: should be non-empty for OBJECT type')) {
|
||||||
return 'Google Gemini requires at least one <a href="https://docs.n8n.io/advanced-ai/examples/using-the-fromai-function/" target="_blank">dynamic parameter</a> when using tools';
|
return 'Google Gemini requires at least one <a href="https://docs.n8n.io/advanced-ai/examples/using-the-fromai-function/" target="_blank">dynamic parameter</a> when using tools';
|
||||||
|
|
@ -78,14 +106,11 @@ const modelRLC: INodeProperties = {
|
||||||
name: '={{$responseItem.name}}',
|
name: '={{$responseItem.name}}',
|
||||||
value: '={{$responseItem.name}}',
|
value: '={{$responseItem.name}}',
|
||||||
description: '={{$responseItem.description}}',
|
description: '={{$responseItem.description}}',
|
||||||
|
displayName: '={{$responseItem.displayName}}',
|
||||||
|
priority: '={{$responseItem.priority}}',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
labelModels,
|
||||||
type: 'sort',
|
|
||||||
properties: {
|
|
||||||
key: 'name',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -133,14 +158,11 @@ const modelRLCV2: INodeProperties = {
|
||||||
name: '={{$responseItem.name}}',
|
name: '={{$responseItem.name}}',
|
||||||
value: '={{$responseItem.name}}',
|
value: '={{$responseItem.name}}',
|
||||||
description: '={{$responseItem.description}}',
|
description: '={{$responseItem.description}}',
|
||||||
|
displayName: '={{$responseItem.displayName}}',
|
||||||
|
priority: '={{$responseItem.priority}}',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
labelModels,
|
||||||
type: 'sort',
|
|
||||||
properties: {
|
|
||||||
key: 'name',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import type { IExecuteSingleFunctions, INodeExecutionData, PostReceiveAction } from 'n8n-workflow';
|
||||||
|
|
||||||
import { LmChatGoogleGemini } from '../LmChatGoogleGemini.node';
|
import { LmChatGoogleGemini } from '../LmChatGoogleGemini.node';
|
||||||
|
|
||||||
describe('LmChatGoogleGemini', () => {
|
describe('LmChatGoogleGemini', () => {
|
||||||
|
|
@ -23,4 +25,72 @@ describe('LmChatGoogleGemini', () => {
|
||||||
expect(hint).not.toMatch(/searchModels|explore-resources|nodes\(/);
|
expect(hint).not.toMatch(/searchModels|explore-resources|nodes\(/);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should label, sort and pin models in every version', async () => {
|
||||||
|
const modelProperties = new LmChatGoogleGemini().description.properties.filter(
|
||||||
|
(p) => p.name === 'modelName',
|
||||||
|
);
|
||||||
|
const options: INodeExecutionData[] = [
|
||||||
|
{
|
||||||
|
json: {
|
||||||
|
name: 'models/gemini-zeta',
|
||||||
|
value: 'models/gemini-zeta',
|
||||||
|
displayName: '',
|
||||||
|
priority: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
json: {
|
||||||
|
name: 'models/gemini-featured',
|
||||||
|
value: 'models/gemini-featured',
|
||||||
|
displayName: 'Gemini Featured',
|
||||||
|
priority: 10,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ json: { name: 'models/gemini-2.5-pro', value: 'models/gemini-2.5-pro' } },
|
||||||
|
{
|
||||||
|
json: {
|
||||||
|
name: 'models/gemini-api-named',
|
||||||
|
value: 'models/gemini-api-named',
|
||||||
|
displayName: 'Gemini Api Named',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(modelProperties).toHaveLength(3);
|
||||||
|
for (const property of modelProperties) {
|
||||||
|
const postReceive = property.typeOptions?.loadOptions?.routing?.output
|
||||||
|
?.postReceive as PostReceiveAction[];
|
||||||
|
const labelModels = postReceive.at(-1);
|
||||||
|
if (typeof labelModels !== 'function') throw new Error('Expected a postReceive function');
|
||||||
|
|
||||||
|
const result = await labelModels.call({} as IExecuteSingleFunctions, options, {} as never);
|
||||||
|
|
||||||
|
// The API's displayName labels a model, else its id. A priority pins a model.
|
||||||
|
expect(result.map((option) => option.json)).toEqual([
|
||||||
|
{ name: 'Gemini Featured', value: 'models/gemini-featured' },
|
||||||
|
{ name: 'Gemini Api Named', value: 'models/gemini-api-named' },
|
||||||
|
{ name: 'models/gemini-2.5-pro', value: 'models/gemini-2.5-pro' },
|
||||||
|
{ name: 'models/gemini-zeta', value: 'models/gemini-zeta' },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should leave the label of an option without a string value alone', async () => {
|
||||||
|
const property = new LmChatGoogleGemini().description.properties.find(
|
||||||
|
(p) => p.name === 'modelName',
|
||||||
|
);
|
||||||
|
const postReceive = property?.typeOptions?.loadOptions?.routing?.output
|
||||||
|
?.postReceive as PostReceiveAction[];
|
||||||
|
const labelModels = postReceive.at(-1);
|
||||||
|
if (typeof labelModels !== 'function') throw new Error('Expected a postReceive function');
|
||||||
|
|
||||||
|
const result = await labelModels.call(
|
||||||
|
{} as IExecuteSingleFunctions,
|
||||||
|
[{ json: { name: 'Unknown', value: 42, displayName: 'Ignored' } }],
|
||||||
|
{} as never,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.map((option) => option.json)).toEqual([{ name: 'Unknown', value: 42 }]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1098,6 +1098,10 @@ export class ChatTrigger extends Node {
|
||||||
// By header as well as by the iframe's attribute, so the document has no
|
// By header as well as by the iframe's attribute, so the document has no
|
||||||
// origin even if the attribute is ever stripped.
|
// origin even if the attribute is ever stripped.
|
||||||
res.setHeader('Content-Security-Policy', `sandbox ${CHAT_FRAME_SANDBOX}`);
|
res.setHeader('Content-Security-Policy', `sandbox ${CHAT_FRAME_SANDBOX}`);
|
||||||
|
} else {
|
||||||
|
// The webhook route sandboxes every response. This page keeps its origin,
|
||||||
|
// because the widget keeps the session in `localStorage`.
|
||||||
|
res.removeHeader('Content-Security-Policy');
|
||||||
}
|
}
|
||||||
|
|
||||||
const page = createPage({
|
const page = createPage({
|
||||||
|
|
|
||||||
|
|
@ -984,6 +984,17 @@ describe('ChatTrigger Node', () => {
|
||||||
expect(renderedView().sandbox).not.toContain('allow-popups-to-escape-sandbox');
|
expect(renderedView().sandbox).not.toContain('allow-popups-to-escape-sandbox');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('removes the route sandbox from the public chat page', async () => {
|
||||||
|
await renderSetupPage('none');
|
||||||
|
|
||||||
|
expect(mockResponse.removeHeader).toHaveBeenCalledWith('Content-Security-Policy');
|
||||||
|
expect(mockResponse.setHeader).not.toHaveBeenCalledWith(
|
||||||
|
'Content-Security-Policy',
|
||||||
|
expect.anything(),
|
||||||
|
);
|
||||||
|
expect(renderedPage()).toContain('createChat');
|
||||||
|
});
|
||||||
|
|
||||||
it('renders the author chat for the frame own request', async () => {
|
it('renders the author chat for the frame own request', async () => {
|
||||||
mockRequest.query = { n8nShellInner: '1' };
|
mockRequest.query = { n8nShellInner: '1' };
|
||||||
mockRequest.headers = {
|
mockRequest.headers = {
|
||||||
|
|
@ -998,6 +1009,7 @@ describe('ChatTrigger Node', () => {
|
||||||
'Content-Security-Policy',
|
'Content-Security-Policy',
|
||||||
'sandbox allow-scripts allow-forms allow-modals allow-popups',
|
'sandbox allow-scripts allow-forms allow-modals allow-popups',
|
||||||
);
|
);
|
||||||
|
expect(mockResponse.removeHeader).not.toHaveBeenCalled();
|
||||||
expect(renderedPage()).toContain('createChat');
|
expect(renderedPage()).toContain('createChat');
|
||||||
// Identity injected server-side, and a header token for the messages that follow.
|
// Identity injected server-side, and a header token for the messages that follow.
|
||||||
expect(renderedPage()).toContain('"email":"visitor@example.com"');
|
expect(renderedPage()).toContain('"email":"visitor@example.com"');
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { MongoClient } from 'mongodb';
|
import { MongoClient } from 'mongodb';
|
||||||
import type { ILoadOptionsFunctions, ISupplyDataFunctions } from 'n8n-workflow';
|
import type { ILoadOptionsFunctions, ISupplyDataFunctions } from 'n8n-workflow';
|
||||||
|
import { NodeOperationError } from 'n8n-workflow';
|
||||||
import { mock } from 'vitest-mock-extended';
|
import { mock } from 'vitest-mock-extended';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
|
@ -144,6 +145,30 @@ describe('VectorStoreMongoDBAtlas', () => {
|
||||||
expect(mockClient.connect).toHaveBeenCalledTimes(1);
|
expect(mockClient.connect).toHaveBeenCalledTimes(1);
|
||||||
expect(client).toBe(mockClient);
|
expect(client).toBe(mockClient);
|
||||||
});
|
});
|
||||||
|
it('should not include the connection string in the error when the client cannot be created', async () => {
|
||||||
|
MockMongoClient.mockImplementation(function () {
|
||||||
|
throw new Error(
|
||||||
|
'Protocol and host list are required in "mongodb://testuser:secret-pass@?x:27017"',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
mockContext.getCredentials.mockResolvedValue({
|
||||||
|
configurationType: 'values',
|
||||||
|
host: '?x',
|
||||||
|
user: 'testuser',
|
||||||
|
password: 'secret-pass',
|
||||||
|
port: 27017,
|
||||||
|
database: 'testdb',
|
||||||
|
});
|
||||||
|
|
||||||
|
const error = await createMongoClient(mockContext, 1.1).catch((e: unknown) => e);
|
||||||
|
|
||||||
|
expect(error).toBeInstanceOf(NodeOperationError);
|
||||||
|
expect((error as Error).message).toBe(
|
||||||
|
'MongoDB connection error: Protocol and host list are required in "mongodb://[REDACTED]"',
|
||||||
|
);
|
||||||
|
expect(JSON.stringify(error)).not.toContain('secret-pass');
|
||||||
|
expect((error as Error).stack).not.toContain('secret-pass');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('.getCollections', () => {
|
describe('.getCollections', () => {
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,10 @@ import {
|
||||||
} from 'n8n-workflow';
|
} from 'n8n-workflow';
|
||||||
import { metadataFilterField, createVectorStoreNode } from '@n8n/ai-utilities';
|
import { metadataFilterField, createVectorStoreNode } from '@n8n/ai-utilities';
|
||||||
|
|
||||||
import { validateAndResolveMongoCredentials } from 'n8n-nodes-base/dist/nodes/MongoDb/GenericFunctions';
|
import {
|
||||||
|
sanitizeMongoUriInMessage,
|
||||||
|
validateAndResolveMongoCredentials,
|
||||||
|
} from 'n8n-nodes-base/dist/nodes/MongoDb/GenericFunctions';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Constants for the name of the credentials and Node parameters.
|
* Constants for the name of the credentials and Node parameters.
|
||||||
|
|
@ -169,15 +172,22 @@ export async function createMongoClient(
|
||||||
const node = context.getNode();
|
const node = context.getNode();
|
||||||
const { connectionString } = validateAndResolveMongoCredentials(node, credentials);
|
const { connectionString } = validateAndResolveMongoCredentials(node, credentials);
|
||||||
|
|
||||||
const client = new MongoClient(connectionString, {
|
try {
|
||||||
appName: 'devrel.integration.n8n_vector_integ',
|
const client = new MongoClient(connectionString, {
|
||||||
driverInfo: {
|
appName: 'devrel.integration.n8n_vector_integ',
|
||||||
name: 'n8n_vector',
|
driverInfo: {
|
||||||
version: version.toString(),
|
name: 'n8n_vector',
|
||||||
},
|
version: version.toString(),
|
||||||
});
|
},
|
||||||
await client.connect();
|
});
|
||||||
return client;
|
await client.connect();
|
||||||
|
return client;
|
||||||
|
} catch (error) {
|
||||||
|
throw new NodeOperationError(
|
||||||
|
node,
|
||||||
|
`MongoDB connection error: ${sanitizeMongoUriInMessage(error, connectionString)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user