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
|
||||
├─ build-and-format (Blacksmith: build, then format check; populate master cache)
|
||||
├─ unit-test (matrix: Node 24.18.1, 26.5.1)
|
||||
│ └─ Coverage only on 24.18.1
|
||||
├─ unit-test (matrix: Node 24.22.1, 26.12.1)
|
||||
│ └─ Coverage only on 24.22.1
|
||||
├─ lint
|
||||
├─ (performance: CodSpeed benchmarks, paused until the plugin supports Vitest 5)
|
||||
├─ verify-single-instance-npm (advisory; packages changed by this push)
|
||||
|
|
@ -650,7 +650,7 @@ Composite actions in `.github/actions/`:
|
|||
|
||||
```yaml
|
||||
inputs:
|
||||
node-version: # default: '26.5.1'
|
||||
node-version: # default: '26.12.1'
|
||||
enable-docker-cache: # default: 'false' (Blacksmith Buildx)
|
||||
docker-cache-key: # required when enable-docker-cache is true
|
||||
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:
|
||||
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
|
||||
default: '26.5.1'
|
||||
default: '26.12.1'
|
||||
enable-docker-cache:
|
||||
description: 'Whether to set up Blacksmith Buildx for Docker layer caching (Blacksmith runners only).'
|
||||
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.
|
||||
include:
|
||||
- node_version: '22'
|
||||
dhi_ref: dhi.io/node:22.23.3-alpine3.24-dev@sha256:23ee07ace431ea7d14e22c0e52fb52b0bd1853dabdacd89ba75f937f6dff8a8d
|
||||
- node_version: '24.21.0'
|
||||
dhi_ref: dhi.io/node:24.21.0-alpine3.24-dev@sha256:d22ba0e9ab2a54609ae533df4ebfd6f36c1f233b662a4c547f5a3195e8c21937
|
||||
- node_version: '26.10.0'
|
||||
dhi_ref: dhi.io/node:26.10.0-alpine3.24-dev@sha256:8da859df5dc853c923ace53c4cfe994a909a28917f7fcfc8508869beab8cf132
|
||||
dhi_ref: dhi.io/node:22.23.4-alpine3.24-dev@sha256:a2c4778492ad46fd6f362d8d93e21c0543d0009db7f934d5eb9f9ccb77eafdcb
|
||||
- node_version: '24.22.1'
|
||||
dhi_ref: dhi.io/node:24.22.1-alpine3.24-dev@sha256:7b851ff65d774343caf89450555663caef54dbd8f673658a615d256883973337
|
||||
- node_version: '26.12.1'
|
||||
dhi_ref: dhi.io/node:26.12.1-alpine3.24-dev@sha256:4b1591bfce570a17f9562e206b62e76be84d6df32c3a86bfc677fc87959402cc
|
||||
steps:
|
||||
- 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
|
||||
|
||||
env:
|
||||
NODE_VERSION: '26.10.0'
|
||||
NODE_VERSION: '26.12.1'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
|
|
|||
4
.github/workflows/ci-master.yml
vendored
4
.github/workflows/ci-master.yml
vendored
|
|
@ -40,11 +40,11 @@ jobs:
|
|||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
node-version: [24.18.1, 26.5.1]
|
||||
node-version: [24.22.1, 26.12.1]
|
||||
with:
|
||||
ref: ${{ github.sha }}
|
||||
nodeVersion: ${{ matrix.node-version }}
|
||||
collectCoverage: ${{ matrix.node-version == '24.18.1' }}
|
||||
collectCoverage: ${{ matrix.node-version == '24.22.1' }}
|
||||
secrets: inherit
|
||||
|
||||
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:
|
||||
NODE_OPTIONS: '--max-old-space-size=7168'
|
||||
NODE_VERSION: '26.10.0'
|
||||
NODE_VERSION: '26.12.1'
|
||||
|
||||
on:
|
||||
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
|
||||
# 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: '26.10.0'
|
||||
NODE_VERSION: '26.12.1'
|
||||
# Also build the distroless runners image and exec-check
|
||||
# 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
|
||||
|
|
|
|||
2
.github/workflows/docker-sbom-probe.yml
vendored
2
.github/workflows/docker-sbom-probe.yml
vendored
|
|
@ -19,7 +19,7 @@ on:
|
|||
default: false
|
||||
|
||||
env:
|
||||
NODE_VERSION: '26.10.0'
|
||||
NODE_VERSION: '26.12.1'
|
||||
SCRATCH_TAG: sbom-probe-${{ github.run_id }}
|
||||
# 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
|
||||
|
|
|
|||
|
|
@ -76,7 +76,7 @@ jobs:
|
|||
steps:
|
||||
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
node-version: 24.18.1
|
||||
node-version: 24.22.1
|
||||
|
||||
# Remove after https://github.com/npm/cli/issues/8547 gets resolved
|
||||
- run: echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > ~/.npmrc
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@ jobs:
|
|||
- name: Setup Node.js
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
node-version: '24.18.1'
|
||||
node-version: '24.22.1'
|
||||
|
||||
# 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.
|
||||
|
|
|
|||
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
|
||||
with:
|
||||
# @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
|
||||
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.
|
||||
required: false
|
||||
type: string
|
||||
default: 26.5.1
|
||||
default: 26.12.1
|
||||
|
||||
env:
|
||||
# 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.
|
||||
required: false
|
||||
type: string
|
||||
default: 26.5.1
|
||||
default: 26.12.1
|
||||
collectCoverage:
|
||||
required: false
|
||||
default: false
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ on:
|
|||
description: Version of node to use.
|
||||
required: false
|
||||
type: string
|
||||
default: 26.5.1
|
||||
default: 26.12.1
|
||||
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=7168
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ jobs:
|
|||
if: matrix.safe_chain == false
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
node-version: '26.5.1'
|
||||
node-version: '26.12.1'
|
||||
|
||||
- name: Resolve 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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
# Every n8n image build uses this file. `pnpm build:docker` and CI both drive
|
||||
# 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_RELEASE_TYPE" { default = "dev" }
|
||||
|
||||
|
|
@ -15,14 +15,14 @@ variable "BUILDER_IMAGE" { default = "" }
|
|||
variable "RUNTIME_IMAGE" { default = "" }
|
||||
|
||||
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" {
|
||||
default = "n8nio/node-pc:26.10.0@sha256:386bae24d41c0091d21b4eee124a86464710fff24d5cb7f37386c4c5649b0c89"
|
||||
default = "n8nio/node-pc:26.12.1@sha256:15afd63862b7cbc828537b7481f0c58a6e504765b62e7f55a226ad052068f77c"
|
||||
}
|
||||
|
||||
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 = "" }
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
ARG NODE_VERSION=24.18.1
|
||||
ARG NODE_VERSION=24.22.1
|
||||
|
||||
FROM node:${NODE_VERSION}-alpine3.24
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# CI passes one DHI reference per published Node version (see the matrix in
|
||||
# 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}
|
||||
|
||||
# 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
|
||||
|
||||
# The digest pins node:26.10.0-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 RUNTIME_IMAGE=n8nio/base:26.10.0@sha256:9205c76a7745bfe71de35c2a0b7420238ccb0bdf69fc5273ab2e617fe918cf5f
|
||||
# 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.12.1-alpine3.24@sha256:40cd011b55415bc894e45b02fdd5a5dfde056921b7d23a64c74f80497af48797
|
||||
ARG RUNTIME_IMAGE=n8nio/base:26.12.1@sha256:435e5310b29cfa5880b5512703f7d10bafcf0f5dd700c7adac6ee8cfbe77306a
|
||||
|
||||
# The runtime base has no compiler, so this stage supplies one.
|
||||
FROM ${BUILDER_IMAGE} AS toolchain
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@
|
|||
# dev - plain Alpine with apk usable, for the n8n builder
|
||||
# stage, which must compile native addons against this
|
||||
# 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
|
||||
# builders whose memory-to-core ratio cannot cover all cores.
|
||||
ARG MAKE_JOBS=
|
||||
|
|
@ -90,7 +90,7 @@ ENV npm_config_nodedir=/usr/local
|
|||
# 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.
|
||||
# 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
|
||||
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 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
|
||||
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
|
||||
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 && \
|
||||
pip install --no-cache-dir "pip>=26.0" && \
|
||||
apk del apk-tools
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@
|
|||
# - 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 PYTHON_VERSION=3.13
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "n8n-monorepo",
|
||||
"version": "2.44.0",
|
||||
"version": "2.44.6",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": ">=24.0.0",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/agents",
|
||||
"version": "0.29.0",
|
||||
"version": "0.29.5",
|
||||
"description": "AI agent SDK for n8n's code-first execution engine",
|
||||
"engines": {
|
||||
"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']);
|
||||
});
|
||||
|
||||
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', () => {
|
||||
|
|
@ -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 () => {
|
||||
const store = new InMemoryMemory();
|
||||
const parentText = 'User needs the current request remembered.';
|
||||
|
|
@ -610,6 +709,12 @@ describe('runObservationLogObserver', () => {
|
|||
'```\nNO_OBSERVATIONS\n```',
|
||||
'NO_OBSERVATIONS\n* CRITICAL Keep this fact.',
|
||||
'* 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 ',
|
||||
'```\n* CRITICAL Keep this fact.\n```',
|
||||
])('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.
|
||||
|
||||
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:
|
||||
|
||||
NO_OBSERVATIONS
|
||||
|
|
@ -90,6 +92,16 @@ GOOD:
|
|||
* 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.
|
||||
|
||||
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
|
||||
|
||||
Transcript:
|
||||
|
|
|
|||
|
|
@ -29,6 +29,12 @@ const MARKER_BY_TOKEN: Record<string, ObservationLogMarker> = {
|
|||
|
||||
const BULLET_PATTERN =
|
||||
/^(\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_STRING_CHARS = 500;
|
||||
const DEFAULT_MAX_ARRAY_ITEMS = 20;
|
||||
|
|
@ -119,6 +125,13 @@ export function parseObservationLogMarkdown(markdown: string): ParseObservationL
|
|||
|
||||
const match = BULLET_PATTERN.exec(rawLine);
|
||||
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);
|
||||
continue;
|
||||
}
|
||||
|
|
@ -141,6 +154,12 @@ export function parseObservationLogMarkdown(markdown: string): ParseObservationL
|
|||
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(
|
||||
messages: AgentDbMessage[],
|
||||
options: RenderObserverTranscriptOptions = {},
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/ai-node-sdk",
|
||||
"version": "0.34.0",
|
||||
"version": "0.34.4",
|
||||
"description": "SDK for building AI nodes in n8n",
|
||||
"types": "dist/esm/index.d.ts",
|
||||
"module": "dist/esm/index.js",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/ai-utilities",
|
||||
"version": "0.37.0",
|
||||
"version": "0.37.4",
|
||||
"description": "Utilities for building AI nodes in n8n",
|
||||
"types": "dist/esm/index.d.ts",
|
||||
"module": "dist/esm/index.js",
|
||||
|
|
|
|||
|
|
@ -153,6 +153,88 @@ describe('model-discovery', () => {
|
|||
{ 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', () => {
|
||||
|
|
|
|||
|
|
@ -70,7 +70,14 @@ export {
|
|||
} from './providers/azure';
|
||||
export { listCohereModels } from './providers/cohere';
|
||||
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 { listMiniMaxModels } from './providers/minimax';
|
||||
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 type { ListModelsFn } from '../types';
|
||||
|
||||
|
|
@ -33,22 +36,48 @@ export function shouldIncludeGoogleModel(model: {
|
|||
*/
|
||||
export const listGoogleModels: ListModelsFn = async (options) => {
|
||||
const base = baseUrl(options, 'https://generativelanguage.googleapis.com');
|
||||
const data = (await getJson(
|
||||
const data = await getJson(
|
||||
`${base}/v1beta/models`,
|
||||
{ 'x-goog-api-key': options.apiKey },
|
||||
options,
|
||||
'google',
|
||||
)) as { models?: Array<{ name?: unknown; supportedGenerationMethods?: unknown }> };
|
||||
);
|
||||
|
||||
return (data.models ?? [])
|
||||
.filter(
|
||||
(model): model is { name: string; supportedGenerationMethods?: unknown } =>
|
||||
typeof model.name === 'string' &&
|
||||
shouldIncludeGoogleModel({
|
||||
name: model.name,
|
||||
supportedGenerationMethods: model.supportedGenerationMethods,
|
||||
}),
|
||||
)
|
||||
.map((model) => ({ id: model.name, name: model.name }))
|
||||
const models = parseGoogleModels(data)
|
||||
.filter(shouldIncludeGoogleModel)
|
||||
.map((model) => ({
|
||||
id: model.name,
|
||||
name: getGoogleModelLabel(model.name, model.displayName),
|
||||
...(model.priority !== undefined ? { priority: model.priority } : {}),
|
||||
}))
|
||||
.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;
|
||||
/** Human-readable name when the provider supplies one, otherwise the id. */
|
||||
name: string;
|
||||
/** Pickers list models with a priority first, highest first. Only the AI Gateway reports one. */
|
||||
priority?: number;
|
||||
}
|
||||
|
||||
export interface ListModelsOptions {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/ai-workflow-builder",
|
||||
"version": "1.44.0",
|
||||
"version": "1.44.4",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"typecheck": "tsc --noEmit",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/api-types",
|
||||
"version": "1.44.0",
|
||||
"version": "1.44.3",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"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;
|
||||
cost?: { input: number; output: number; cacheRead?: number; cacheWrite?: 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`. */
|
||||
|
|
|
|||
|
|
@ -420,7 +420,7 @@ export interface AgentChatMessagesResponse {
|
|||
/**
|
||||
* 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
|
||||
* 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
|
||||
|
|
@ -436,6 +436,11 @@ export interface AgentChatListItem {
|
|||
project: { id: string; name: string };
|
||||
/** Media types this agent's model accepts as attachments, derived from its published config. */
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,10 @@ import type { Scope } from '@n8n/permissions';
|
|||
import {
|
||||
CHAT_TOOL_NODE_TYPE,
|
||||
type ChunkType,
|
||||
CODE_NODE_TYPE,
|
||||
CODE_TOOL_LANGCHAIN_NODE_TYPE,
|
||||
DATA_TABLE_TOOL_NODE_TYPE,
|
||||
EXECUTE_WORKFLOW_NODE_TYPE,
|
||||
type INode,
|
||||
INodeSchema,
|
||||
WORKFLOW_TOOL_LANGCHAIN_NODE_TYPE,
|
||||
|
|
@ -709,12 +712,52 @@ export interface ChatHubToolDto {
|
|||
/** Tool types blocked for ALL users in the Chat Hub. */
|
||||
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[] = [
|
||||
WORKFLOW_TOOL_LANGCHAIN_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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -245,6 +245,11 @@ export { GetWorkflowQueryDto } from './workflows/get-workflow-query.dto';
|
|||
export { UpdateWorkflowPublicDto } from './workflows/update-workflow-public.dto';
|
||||
export { UpdateWorkflowQueryDto } from './workflows/update-workflow-query.dto';
|
||||
export { ListWorkflowsQueryDto } from './workflows/list-workflows-query.dto';
|
||||
export {
|
||||
ListCallableSubworkflowsQueryDto,
|
||||
type CallableSubworkflow,
|
||||
type CallableSubworkflowsResponse,
|
||||
} from './workflows/list-callable-subworkflows-query.dto';
|
||||
export {
|
||||
WorkflowPublicDto,
|
||||
CreatedWorkflowPublicDto,
|
||||
|
|
|
|||
|
|
@ -212,6 +212,11 @@ describe('CreateWorkflowDto', () => {
|
|||
request: { name: 'a'.repeat(129), nodes: [], connections: {} },
|
||||
expectedErrorPath: ['name'],
|
||||
},
|
||||
...['__proto__', 'constructor', 'prototype'].map((id) => ({
|
||||
name: `reserved id ${id}`,
|
||||
request: { id, name: 'My Workflow', nodes: [], connections: {} },
|
||||
expectedErrorPath: ['id'],
|
||||
})),
|
||||
{
|
||||
name: 'missing nodes',
|
||||
request: { name: 'Test', connections: {} },
|
||||
|
|
|
|||
|
|
@ -1,9 +1,13 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
import { baseWorkflowShape } from './base-workflow.dto';
|
||||
import { isSafeObjectKey } from '../../schemas/instance-ai.schema';
|
||||
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({
|
||||
// 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,
|
||||
ALWAYS_BLOCKED_CHAT_HUB_TOOL_TYPES,
|
||||
CHAT_USER_BLOCKED_CHAT_HUB_TOOL_TYPES,
|
||||
isChatHubToolTypeAllowed,
|
||||
chatHubVectorStoreProviderSchema,
|
||||
type ChatHubVectorStoreProvider,
|
||||
VECTOR_STORE_PROVIDER_CREDENTIAL_TYPE_MAP,
|
||||
|
|
@ -966,3 +967,4 @@ export { compareExecutionListItems } from './dto/executions/compare-execution-li
|
|||
|
||||
export type * from './workflow-suggestions';
|
||||
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",
|
||||
"version": "1.44.0",
|
||||
"version": "1.44.4",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"dev": "pnpm watch",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/backend-network",
|
||||
"version": "1.18.0",
|
||||
"version": "1.18.4",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"dev": "pnpm watch",
|
||||
|
|
|
|||
|
|
@ -2,11 +2,13 @@ import { IncomingMessage } from 'http';
|
|||
import { Readable } from 'stream';
|
||||
|
||||
import { binaryToString } from '../binary-string';
|
||||
import { Socket } from 'net';
|
||||
|
||||
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}` };
|
||||
Object.setPrototypeOf(incomingMessage, IncomingMessage.prototype);
|
||||
incomingMessage.push(buffer);
|
||||
incomingMessage.push(null);
|
||||
return incomingMessage;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
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 { installProxyConnectionGuard } from '../connection-guards';
|
||||
import { installConnectionGuard, installProxyConnectionGuard } from '../connection-guards';
|
||||
|
||||
type ConnectOpts = Parameters<HttpsProxyAgent<string>['connect']>[1];
|
||||
|
||||
|
|
@ -76,3 +76,78 @@ describe('installProxyConnectionGuard', () => {
|
|||
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 }));
|
||||
|
||||
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', () => {
|
||||
beforeEach(() => getProxyForUrl.mockReset());
|
||||
|
|
@ -110,4 +110,17 @@ describe('EnvProxyRouter', () => {
|
|||
expect(createProxyAgent).toHaveBeenCalledTimes(2);
|
||||
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 { EnvProxyHttpsAgent } from '../env-proxy-https-agent';
|
||||
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`
|
||||
// (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 ?? {};
|
||||
}
|
||||
|
||||
function withSocketPath(socketPath: string): NodeAgentOptions {
|
||||
return { socketPath } as unknown as NodeAgentOptions;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// buildNodeAgents — shared builder (single source of truth for the undici
|
||||
// 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', () => {
|
||||
const modes: Array<[string, false | 'env' | ProxyUrl]> = [
|
||||
['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(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 () => {
|
||||
|
|
@ -46,7 +46,7 @@ describe('OutboundHttp.requests', () => {
|
|||
|
||||
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 () => {
|
||||
|
|
@ -54,7 +54,7 @@ describe('OutboundHttp.requests', () => {
|
|||
|
||||
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 () => {
|
||||
|
|
@ -62,7 +62,7 @@ describe('OutboundHttp.requests', () => {
|
|||
|
||||
await client.request(REQUEST);
|
||||
|
||||
expect(mockedHttpRequest).toHaveBeenCalledWith(REQUEST, undefined);
|
||||
expect(mockedHttpRequest).toHaveBeenCalledWith(REQUEST, undefined, undefined);
|
||||
});
|
||||
|
||||
describe('bound baseURL', () => {
|
||||
|
|
@ -77,6 +77,7 @@ describe('OutboundHttp.requests', () => {
|
|||
baseURL: 'https://vault.test/v1/',
|
||||
}),
|
||||
expect.anything(),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -88,6 +89,7 @@ describe('OutboundHttp.requests', () => {
|
|||
expect(mockedHttpRequest).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ baseURL: 'https://other.test/' }),
|
||||
expect.anything(),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -101,6 +103,7 @@ describe('OutboundHttp.requests', () => {
|
|||
expect(mockedHttpRequest).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ timeout: 5000 }),
|
||||
expect.anything(),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -112,6 +115,7 @@ describe('OutboundHttp.requests', () => {
|
|||
expect(mockedHttpRequest).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ timeout: 1000 }),
|
||||
expect.anything(),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -129,6 +133,7 @@ describe('OutboundHttp.requests', () => {
|
|||
headers: { 'X-Vault-Namespace': 'admin', Accept: 'text/plain' },
|
||||
}),
|
||||
expect.anything(),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -142,6 +147,7 @@ describe('OutboundHttp.requests', () => {
|
|||
expect(mockedHttpRequest).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ headers: { 'content-type': 'text/plain' } }),
|
||||
expect.anything(),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -159,11 +165,13 @@ describe('OutboundHttp.requests', () => {
|
|||
1,
|
||||
expect.objectContaining({ headers: { Authorization: 'Bearer first' } }),
|
||||
expect.anything(),
|
||||
undefined,
|
||||
);
|
||||
expect(mockedHttpRequest).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({ headers: { Authorization: 'Bearer second' } }),
|
||||
expect.anything(),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -175,6 +183,7 @@ describe('OutboundHttp.requests', () => {
|
|||
expect(mockedHttpRequest).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ headers: {} }),
|
||||
expect.anything(),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -24,10 +24,12 @@ import {
|
|||
validateUrlSsrf,
|
||||
} from './utils';
|
||||
import type { SsrfBridge } from '../../ssrf';
|
||||
import type { NodeAgents } from '../node-agents';
|
||||
|
||||
export function convertN8nRequestToAxios(
|
||||
n8nRequest: IHttpRequestOptions,
|
||||
ssrfBridge?: SsrfBridge,
|
||||
agents?: NodeAgents,
|
||||
): AxiosRequestConfig {
|
||||
// Destructure properties with the same name first.
|
||||
const { headers, method, timeout, auth, proxy, url } = n8nRequest;
|
||||
|
|
@ -62,6 +64,11 @@ export function convertN8nRequestToAxios(
|
|||
axiosRequest.responseType = n8nRequest.encoding;
|
||||
}
|
||||
|
||||
if (agents) {
|
||||
axiosRequest.httpAgent = agents.httpAgent;
|
||||
axiosRequest.httpsAgent = agents.httpsAgent;
|
||||
}
|
||||
|
||||
const agentOptions = buildAgentOptions(n8nRequest);
|
||||
setAxiosAgents(axiosRequest, agentOptions, proxy, ssrfBridge ?? 'disabled');
|
||||
|
||||
|
|
@ -172,18 +179,22 @@ export function removeEmptyBody(requestOptions: IHttpRequestOptions | IRequestOp
|
|||
export async function httpRequest(
|
||||
requestOptions: IHttpRequestOptions & { returnFullResponse: true },
|
||||
ssrfBridge?: SsrfBridge,
|
||||
agents?: NodeAgents,
|
||||
): Promise<IN8nHttpFullResponse>;
|
||||
export async function httpRequest(
|
||||
requestOptions: IHttpRequestOptions & { returnFullResponse?: false },
|
||||
ssrfBridge?: SsrfBridge,
|
||||
agents?: NodeAgents,
|
||||
): Promise<IN8nHttpResponse>;
|
||||
export async function httpRequest(
|
||||
requestOptions: IHttpRequestOptions,
|
||||
ssrfBridge?: SsrfBridge,
|
||||
agents?: NodeAgents,
|
||||
): Promise<IN8nHttpFullResponse | IN8nHttpResponse>;
|
||||
export async function httpRequest(
|
||||
requestOptions: IHttpRequestOptions,
|
||||
ssrfBridge?: SsrfBridge,
|
||||
agents?: NodeAgents,
|
||||
): Promise<IN8nHttpFullResponse | IN8nHttpResponse> {
|
||||
removeEmptyBody(requestOptions);
|
||||
|
||||
|
|
@ -191,7 +202,7 @@ export async function httpRequest(
|
|||
await validateUrlSsrf(url, 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') {
|
||||
delete axiosRequest.data;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import type { IHttpRequestOptions } from 'n8n-workflow';
|
||||
|
||||
import type { HttpRequestDefaultHeaders } from './client-default-headers';
|
||||
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.
|
||||
*/
|
||||
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. */
|
||||
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.
|
||||
|
|
@ -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.
|
||||
*
|
||||
|
|
@ -68,11 +88,11 @@ export function installProxyConnectionGuard(agent: ProxyConnectionAgent, ssrf: S
|
|||
*/
|
||||
export function installConnectionGuard(
|
||||
target: { createConnection: CreateConnection },
|
||||
ssrf: SsrfBridge,
|
||||
ssrf: SsrfBridge | 'disabled',
|
||||
): void {
|
||||
const createConnection = target.createConnection.bind(target);
|
||||
target.createConnection = (options, onConnect) => {
|
||||
const error = connectionHostError(ssrf, options);
|
||||
const error = connectionError(ssrf, options);
|
||||
if (error) {
|
||||
if (onConnect) {
|
||||
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.
|
||||
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.
|
||||
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`).
|
||||
*/
|
||||
export class EnvProxyRouter<TProxyAgent> {
|
||||
export class EnvProxyRouter<TProxyAgent extends { destroy(): void }> {
|
||||
private readonly proxyCache = new Map<string, TProxyAgent>();
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
if (this.proxyCache.size >= MAX_CACHED_AGENTS) {
|
||||
if (!this.cacheLimitWarned) {
|
||||
|
|
|
|||
|
|
@ -132,6 +132,7 @@ export function createFakeOutboundHttp(routes: Route[], fn: MockFnFactory) {
|
|||
requestLegacy: () => {
|
||||
throw new Error('requestLegacy is not supported by the fake OutboundHttp');
|
||||
},
|
||||
close: () => {},
|
||||
}),
|
||||
);
|
||||
const outboundHttp = {
|
||||
|
|
|
|||
|
|
@ -29,4 +29,4 @@ export {
|
|||
isTransportFailure,
|
||||
markHttpRequestError,
|
||||
} 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 https from 'node:https';
|
||||
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 type { SsrfBridge } from '../ssrf';
|
||||
|
||||
export interface NodeAgents {
|
||||
httpAgent: http.Agent;
|
||||
httpsAgent: https.Agent;
|
||||
}
|
||||
|
||||
/**
|
||||
* An explicit proxy URL for routing all requests from this client.
|
||||
* 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
|
||||
* 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
|
||||
* options travel with the tunnelled session rather than the proxy handshake
|
||||
* (see `proxy/proxied-agents.ts`).
|
||||
|
|
@ -89,13 +98,19 @@ export function buildNodeAgents(
|
|||
proxy: ProxyOption,
|
||||
ssrf: SsrfOption,
|
||||
agentOptions?: NodeAgentOptions,
|
||||
): { httpAgent: http.Agent; httpsAgent: https.Agent } {
|
||||
): NodeAgents {
|
||||
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`.',
|
||||
);
|
||||
}
|
||||
|
||||
if (agentOptions !== undefined && 'socketPath' in agentOptions && agentOptions.socketPath) {
|
||||
throw new UserError(
|
||||
'`agentOptions.socketPath` is not supported. Remove it from `agentOptions`.',
|
||||
);
|
||||
}
|
||||
|
||||
const lookup: LookupFunction | 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(
|
||||
agents: { httpAgent: http.Agent; httpsAgent: https.Agent },
|
||||
ssrf: SsrfOption,
|
||||
): { httpAgent: http.Agent; httpsAgent: https.Agent } {
|
||||
if (ssrf !== 'disabled') {
|
||||
installConnectionGuard(agents.httpAgent, ssrf);
|
||||
installConnectionGuard(agents.httpsAgent, ssrf);
|
||||
}
|
||||
installConnectionGuard(agents.httpAgent, ssrf);
|
||||
installConnectionGuard(agents.httpsAgent, ssrf);
|
||||
return agents;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { Logger } from '@n8n/backend-common';
|
||||
import { SsrfProtectionConfig } from '@n8n/config';
|
||||
import { Service } from '@n8n/di';
|
||||
import { UnexpectedError } from 'n8n-workflow';
|
||||
import type {
|
||||
IHttpRequestOptions,
|
||||
IN8nHttpFullResponse,
|
||||
|
|
@ -15,11 +16,12 @@ import type { Dispatcher } from 'undici';
|
|||
|
||||
import { passthroughEgressFilter, SsrfProtectionService } from '../ssrf';
|
||||
import { httpRequest } from './axios/request';
|
||||
import { getUrlFromProxyConfig, resolveProxyOption } from './axios/utils';
|
||||
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 { 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 {
|
||||
createDispatcherTransport,
|
||||
|
|
@ -114,13 +116,20 @@ export interface HttpRequestClient {
|
|||
* `callbacks.onFetched` runs once after data is successfully fetched (used by
|
||||
* 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
|
||||
* to back the deprecated `request` helpers.
|
||||
*/
|
||||
// oxlint-disable-next-line typescript/no-deprecated
|
||||
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 =>
|
||||
withClientDefaults(requestOptions, options?.baseURL, options?.headers, options?.timeout);
|
||||
|
||||
const pool = options?.connectionPool;
|
||||
const poolAgents = pool && buildPoolAgents(pool, ssrf);
|
||||
|
||||
function request<T = IN8nHttpResponse | Readable>(
|
||||
requestOptions: IHttpRequestOptions & { returnFullResponse: true },
|
||||
): Promise<TypedHttpFullResponse<T>>;
|
||||
|
|
@ -199,8 +211,9 @@ export class OutboundHttp {
|
|||
requestOptions: IHttpRequestOptions,
|
||||
): Promise<T | TypedHttpFullResponse<T>>;
|
||||
async function request(requestOptions: IHttpRequestOptions): Promise<unknown> {
|
||||
const withPool = pool ? withConnectionPool(requestOptions, pool) : requestOptions;
|
||||
try {
|
||||
return await httpRequest(applyDefaults(requestOptions), ssrfBridge);
|
||||
return await httpRequest(applyDefaults(withPool), ssrfBridge, poolAgents);
|
||||
} catch (error) {
|
||||
// Tag so callers can recognize a client-rejected error transport-agnostically.
|
||||
throw markHttpRequestError(error);
|
||||
|
|
@ -217,6 +230,10 @@ export class OutboundHttp {
|
|||
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;
|
||||
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",
|
||||
"version": "1.4.0",
|
||||
"version": "1.4.4",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"dev": "pnpm watch",
|
||||
|
|
|
|||
|
|
@ -71,4 +71,21 @@ describe('ProjectScopeService', () => {
|
|||
]);
|
||||
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 () => {
|
||||
const project = { id: 'project-1' } as Project;
|
||||
cacheService.getHashValue.mockResolvedValue('invalid' as never);
|
||||
sharedWorkflowRepository.findOneOrFail.mockResolvedValue({ project } as never);
|
||||
it.each(['invalid', {}])(
|
||||
'loads the owner project when the cached value is malformed',
|
||||
async (cached) => {
|
||||
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);
|
||||
expect(sharedWorkflowRepository.findOneOrFail).toHaveBeenCalledOnce();
|
||||
});
|
||||
await expect(service.getWorkflowProjectCached('workflow-1')).resolves.toBe(project);
|
||||
expect(sharedWorkflowRepository.findOneOrFail).toHaveBeenCalledOnce();
|
||||
},
|
||||
);
|
||||
|
||||
it('sets the cached owner project', async () => {
|
||||
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');
|
||||
});
|
||||
|
||||
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)) ?? {};
|
||||
|
||||
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);
|
||||
}
|
||||
|
|
@ -309,7 +317,10 @@ export class CacheService extends TypedEmitter<CacheEvents> {
|
|||
} else {
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -22,9 +22,17 @@ export class ProjectScopeService {
|
|||
}
|
||||
|
||||
async getProjectIds(user: User, scopes: Scope[]): Promise<string[] | null> {
|
||||
const roles = await this.getProjectRoleSlugs(user, scopes);
|
||||
if (roles === null) return null;
|
||||
if (hasGlobalScope(user, scopes, { mode: 'allOf' })) 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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,9 @@ export class WorkflowProjectService {
|
|||
) {}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/backend-test-utils",
|
||||
"version": "1.44.0",
|
||||
"version": "1.44.4",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"dev": "pnpm watch",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"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",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/chat-hub",
|
||||
"version": "1.37.0",
|
||||
"version": "1.37.3",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"dev": "pnpm watch",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/client-oauth2",
|
||||
"version": "1.26.0",
|
||||
"version": "1.26.4",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"dev": "pnpm watch",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/config",
|
||||
"version": "2.42.0",
|
||||
"version": "2.42.1",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"dev": "pnpm watch",
|
||||
|
|
|
|||
|
|
@ -156,8 +156,8 @@ describe('ExpressionEngineConfig', () => {
|
|||
expect(Container.get(ExpressionEngineConfig).observabilityEnabled).toBe(true);
|
||||
});
|
||||
|
||||
test('tracesEnabled defaults to true', () => {
|
||||
expect(Container.get(ExpressionEngineConfig).tracesEnabled).toBe(true);
|
||||
test('tracesEnabled defaults to false', () => {
|
||||
expect(Container.get(ExpressionEngineConfig).tracesEnabled).toBe(false);
|
||||
});
|
||||
|
||||
test('slowEvaluationThresholdMs defaults to 50', () => {
|
||||
|
|
|
|||
|
|
@ -65,12 +65,13 @@ export class ExpressionEngineConfig {
|
|||
observabilityEnabled: boolean = true;
|
||||
|
||||
/**
|
||||
* Whether to emit OpenTelemetry spans for expression evaluation.
|
||||
* Slow evaluations (>slowEvaluationThresholdMs) and errors always emit a span.
|
||||
* Healthy-path evaluations are sampled at tracesSampleRate.
|
||||
* Whether n8n sends OpenTelemetry spans for expression evaluations when `N8N_OTEL_ENABLED`
|
||||
* is true. n8n creates a span for each evaluation that fails or takes longer than
|
||||
* `N8N_EXPRESSION_ENGINE_SLOW_EVAL_THRESHOLD_MS`, and samples the rest at
|
||||
* `N8N_EXPRESSION_ENGINE_TRACES_SAMPLE_RATE`.
|
||||
*/
|
||||
@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. */
|
||||
@Env('N8N_EXPRESSION_ENGINE_SLOW_EVAL_THRESHOLD_MS', z.number({ coerce: true }).positive())
|
||||
|
|
|
|||
|
|
@ -691,7 +691,7 @@ describe('GlobalConfig', () => {
|
|||
bridgeTimeout: 5000,
|
||||
bridgeMemoryLimit: 128,
|
||||
observabilityEnabled: true,
|
||||
tracesEnabled: true,
|
||||
tracesEnabled: false,
|
||||
slowEvaluationThresholdMs: 50,
|
||||
tracesSampleRate: 0.0,
|
||||
allowWebhookIsolateSkip: true,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/create-node",
|
||||
"version": "0.52.0",
|
||||
"version": "0.52.4",
|
||||
"description": "Official CLI to create new community nodes for n8n",
|
||||
"bin": {
|
||||
"create-node": "bin/create-node.cjs"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/db",
|
||||
"version": "1.44.0",
|
||||
"version": "1.44.4",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo .tmp-schema-docs",
|
||||
"gen:migration-index": "node scripts/generate-migration-index.mjs",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { GlobalConfig } from '@n8n/config';
|
||||
import { In, IsNull, MoreThan, Not, type SelectQueryBuilder } from '@n8n/typeorm';
|
||||
import type { IWorkflowSettings } from 'n8n-workflow';
|
||||
import type { Mock, Mocked } from 'vitest';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
|
||||
|
|
@ -840,4 +841,270 @@ describe('WorkflowRepository', () => {
|
|||
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) {
|
||||
return await this.findOne({
|
||||
where: { id: workflowId },
|
||||
|
|
@ -864,7 +994,6 @@ export class WorkflowRepository extends BaseRepository<WorkflowEntity> {
|
|||
onlySharedWithMe?: boolean;
|
||||
},
|
||||
options: WorkflowListQueryOptions = {},
|
||||
callableForParentWorkflowId?: string,
|
||||
) {
|
||||
if (
|
||||
options.filter?.parentFolderId &&
|
||||
|
|
@ -884,18 +1013,8 @@ export class WorkflowRepository extends BaseRepository<WorkflowEntity> {
|
|||
}
|
||||
|
||||
const [workflowsAndFolders, count] = await Promise.all([
|
||||
this.getWorkflowsAndFoldersUnionWithSharingSubquery(
|
||||
user,
|
||||
sharingOptions,
|
||||
options,
|
||||
callableForParentWorkflowId,
|
||||
),
|
||||
this.getWorkflowsAndFoldersCountWithSharingSubquery(
|
||||
user,
|
||||
sharingOptions,
|
||||
options,
|
||||
callableForParentWorkflowId,
|
||||
),
|
||||
this.getWorkflowsAndFoldersUnionWithSharingSubquery(user, sharingOptions, options),
|
||||
this.getWorkflowsAndFoldersCountWithSharingSubquery(user, sharingOptions, options),
|
||||
]);
|
||||
|
||||
const isArchived =
|
||||
|
|
@ -922,15 +1041,9 @@ export class WorkflowRepository extends BaseRepository<WorkflowEntity> {
|
|||
onlySharedWithMe?: boolean;
|
||||
},
|
||||
options: WorkflowListQueryOptions = {},
|
||||
callableForParentWorkflowId?: string,
|
||||
) {
|
||||
const { baseQuery, sortByColumn, sortByDirection } =
|
||||
this.buildBaseUnionQueryWithSharingSubquery(
|
||||
user,
|
||||
sharingOptions,
|
||||
options,
|
||||
callableForParentWorkflowId,
|
||||
);
|
||||
this.buildBaseUnionQueryWithSharingSubquery(user, sharingOptions, options);
|
||||
|
||||
const query = this.buildUnionQuery(baseQuery, {
|
||||
sortByColumn,
|
||||
|
|
@ -956,7 +1069,6 @@ export class WorkflowRepository extends BaseRepository<WorkflowEntity> {
|
|||
onlySharedWithMe?: boolean;
|
||||
},
|
||||
options: WorkflowListQueryOptions = {},
|
||||
callableForParentWorkflowId?: string,
|
||||
) {
|
||||
const { skip, take, ...baseQueryParameters } = options;
|
||||
|
||||
|
|
@ -964,7 +1076,6 @@ export class WorkflowRepository extends BaseRepository<WorkflowEntity> {
|
|||
user,
|
||||
sharingOptions,
|
||||
baseQueryParameters,
|
||||
callableForParentWorkflowId,
|
||||
);
|
||||
|
||||
const response = await baseQuery
|
||||
|
|
@ -986,7 +1097,6 @@ export class WorkflowRepository extends BaseRepository<WorkflowEntity> {
|
|||
onlySharedWithMe?: boolean;
|
||||
},
|
||||
options: WorkflowListQueryOptions = {},
|
||||
callableForParentWorkflowId?: string,
|
||||
) {
|
||||
// Common fields for both folders and workflows
|
||||
const commonFields = {
|
||||
|
|
@ -1036,7 +1146,6 @@ export class WorkflowRepository extends BaseRepository<WorkflowEntity> {
|
|||
user,
|
||||
sharingOptions,
|
||||
workflowQueryParameters,
|
||||
callableForParentWorkflowId,
|
||||
).addSelect("'workflow'", 'resource');
|
||||
|
||||
const qb = this.manager.createQueryBuilder();
|
||||
|
|
@ -1155,14 +1264,8 @@ export class WorkflowRepository extends BaseRepository<WorkflowEntity> {
|
|||
onlySharedWithMe?: boolean;
|
||||
},
|
||||
options: WorkflowListQueryOptions = {},
|
||||
callableForParentWorkflowId?: string,
|
||||
): Promise<WorkflowListResult> {
|
||||
const query = this.getManyQueryWithSharingSubquery(
|
||||
user,
|
||||
sharingOptions,
|
||||
options,
|
||||
callableForParentWorkflowId,
|
||||
);
|
||||
const query = this.getManyQueryWithSharingSubquery(user, sharingOptions, options);
|
||||
|
||||
const [workflows, count] = await query.getManyAndCount();
|
||||
return { workflows, count };
|
||||
|
|
@ -1182,7 +1285,6 @@ export class WorkflowRepository extends BaseRepository<WorkflowEntity> {
|
|||
onlySharedWithMe?: boolean;
|
||||
},
|
||||
options: WorkflowListQueryOptions = {},
|
||||
callableForParentWorkflowId?: string,
|
||||
): SelectQueryBuilder<WorkflowEntity> {
|
||||
const qb = this.createQueryBuilder('workflow');
|
||||
|
||||
|
|
@ -1200,21 +1302,9 @@ export class WorkflowRepository extends BaseRepository<WorkflowEntity> {
|
|||
sharingOptionsWithProjectId,
|
||||
);
|
||||
|
||||
if (callableForParentWorkflowId) {
|
||||
// Union: workflows readable by the user OR callable by the parent workflow.
|
||||
const callableSubquery = this.buildCallablePolicySubquery(callableForParentWorkflowId);
|
||||
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 the sharing filter using the subquery
|
||||
qb.andWhere(`workflow.id IN (${sharedWorkflowSubquery.getQuery()})`);
|
||||
qb.setParameters(sharedWorkflowSubquery.getParameters());
|
||||
|
||||
// Apply other filters
|
||||
// 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
|
||||
* given `parentWorkflowId` to call them.
|
||||
* List the workflows that `parentWorkflowId` can call as sub-workflows. The
|
||||
* 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
|
||||
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 callerPolicy = this.settingsTextValue('cpw.settings', 'callerPolicy');
|
||||
const callerIds = this.settingsTextValue('cpw.settings', 'callerIds');
|
||||
|
||||
const conditions: string[] = [];
|
||||
const params: Record<string, string> = {
|
||||
cpParentWorkflowId: parentWorkflowId,
|
||||
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(
|
||||
const conditions = [
|
||||
`${callerPolicy} = 'any'`,
|
||||
// The allowlist must contain parentWorkflowId as a whole ID.
|
||||
`(${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') {
|
||||
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 {
|
||||
|
|
@ -1467,14 +1585,15 @@ export class WorkflowRepository extends BaseRepository<WorkflowEntity> {
|
|||
/**
|
||||
* 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[];
|
||||
parameters: Record<string, string>;
|
||||
} {
|
||||
const concatExpression = this.getFieldConcatExpression();
|
||||
|
||||
const conditions = searchWords.map((_, index) => {
|
||||
return `${concatExpression} LIKE :searchWord${index}`;
|
||||
return `${fieldExpression} LIKE :searchWord${index}`;
|
||||
});
|
||||
|
||||
const parameters: Record<string, string> = {};
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/decorators",
|
||||
"version": "1.44.0",
|
||||
"version": "1.44.3",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"dev": "pnpm watch",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/engine",
|
||||
"version": "0.24.0",
|
||||
"version": "0.24.1",
|
||||
"description": "n8n workflow execution engine (v2)",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo compiled",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/inbound-auth",
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.3",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"dev": "pnpm watch",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/instance-ai",
|
||||
"version": "1.29.0",
|
||||
"version": "1.29.5",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"typecheck": "tsc --noEmit && tsc -p evaluations/tsconfig.json",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/mcp-apps",
|
||||
"version": "0.21.0",
|
||||
"version": "0.21.3",
|
||||
"description": "MCP Apps UI resources and server helpers for n8n",
|
||||
"main": "dist/server/index.js",
|
||||
"types": "dist/server/index.d.ts",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/node-cli",
|
||||
"version": "0.53.0",
|
||||
"version": "0.53.4",
|
||||
"description": "Official CLI for developing community nodes for n8n",
|
||||
"bin": {
|
||||
"n8n-node": "bin/n8n-node.mjs"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"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",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
|
|
|
|||
|
|
@ -18,7 +18,11 @@ import {
|
|||
N8nLlmTracing,
|
||||
getConnectionHintNoticeField,
|
||||
} 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';
|
||||
|
||||
|
|
@ -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) {
|
||||
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';
|
||||
|
|
@ -78,14 +106,11 @@ const modelRLC: INodeProperties = {
|
|||
name: '={{$responseItem.name}}',
|
||||
value: '={{$responseItem.name}}',
|
||||
description: '={{$responseItem.description}}',
|
||||
displayName: '={{$responseItem.displayName}}',
|
||||
priority: '={{$responseItem.priority}}',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'sort',
|
||||
properties: {
|
||||
key: 'name',
|
||||
},
|
||||
},
|
||||
labelModels,
|
||||
],
|
||||
},
|
||||
},
|
||||
|
|
@ -133,14 +158,11 @@ const modelRLCV2: INodeProperties = {
|
|||
name: '={{$responseItem.name}}',
|
||||
value: '={{$responseItem.name}}',
|
||||
description: '={{$responseItem.description}}',
|
||||
displayName: '={{$responseItem.displayName}}',
|
||||
priority: '={{$responseItem.priority}}',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'sort',
|
||||
properties: {
|
||||
key: 'name',
|
||||
},
|
||||
},
|
||||
labelModels,
|
||||
],
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import type { IExecuteSingleFunctions, INodeExecutionData, PostReceiveAction } from 'n8n-workflow';
|
||||
|
||||
import { LmChatGoogleGemini } from '../LmChatGoogleGemini.node';
|
||||
|
||||
describe('LmChatGoogleGemini', () => {
|
||||
|
|
@ -23,4 +25,72 @@ describe('LmChatGoogleGemini', () => {
|
|||
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
|
||||
// origin even if the attribute is ever stripped.
|
||||
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({
|
||||
|
|
|
|||
|
|
@ -984,6 +984,17 @@ describe('ChatTrigger Node', () => {
|
|||
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 () => {
|
||||
mockRequest.query = { n8nShellInner: '1' };
|
||||
mockRequest.headers = {
|
||||
|
|
@ -998,6 +1009,7 @@ describe('ChatTrigger Node', () => {
|
|||
'Content-Security-Policy',
|
||||
'sandbox allow-scripts allow-forms allow-modals allow-popups',
|
||||
);
|
||||
expect(mockResponse.removeHeader).not.toHaveBeenCalled();
|
||||
expect(renderedPage()).toContain('createChat');
|
||||
// Identity injected server-side, and a header token for the messages that follow.
|
||||
expect(renderedPage()).toContain('"email":"visitor@example.com"');
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { MongoClient } from 'mongodb';
|
||||
import type { ILoadOptionsFunctions, ISupplyDataFunctions } from 'n8n-workflow';
|
||||
import { NodeOperationError } from 'n8n-workflow';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
|
||||
import {
|
||||
|
|
@ -144,6 +145,30 @@ describe('VectorStoreMongoDBAtlas', () => {
|
|||
expect(mockClient.connect).toHaveBeenCalledTimes(1);
|
||||
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', () => {
|
||||
|
|
|
|||
|
|
@ -11,7 +11,10 @@ import {
|
|||
} from 'n8n-workflow';
|
||||
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.
|
||||
|
|
@ -169,15 +172,22 @@ export async function createMongoClient(
|
|||
const node = context.getNode();
|
||||
const { connectionString } = validateAndResolveMongoCredentials(node, credentials);
|
||||
|
||||
const client = new MongoClient(connectionString, {
|
||||
appName: 'devrel.integration.n8n_vector_integ',
|
||||
driverInfo: {
|
||||
name: 'n8n_vector',
|
||||
version: version.toString(),
|
||||
},
|
||||
});
|
||||
await client.connect();
|
||||
return client;
|
||||
try {
|
||||
const client = new MongoClient(connectionString, {
|
||||
appName: 'devrel.integration.n8n_vector_integ',
|
||||
driverInfo: {
|
||||
name: 'n8n_vector',
|
||||
version: version.toString(),
|
||||
},
|
||||
});
|
||||
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