mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-19 08:46:56 +02:00
Compare commits
8 Commits
master
...
n8n@2.44.3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bfbeea9c35 | ||
|
|
c09e0a94f5 | ||
|
|
6525584709 | ||
|
|
c52f1817b3 | ||
|
|
16b49eb412 | ||
|
|
89f18b23b8 | ||
|
|
321b0203b7 | ||
|
|
c25417c88e |
24
CHANGELOG.md
24
CHANGELOG.md
|
|
@ -1,3 +1,27 @@
|
||||||
|
## [2.44.3](https://github.com/n8n-io/n8n/compare/n8n@2.44.2...n8n@2.44.3) (2026-10-16)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **core:** Keep Observer detail lines instead of discarding the response ([#41157](https://github.com/n8n-io/n8n/issues/41157)) ([c09e0a9](https://github.com/n8n-io/n8n/commit/c09e0a94f57d7b046232b78f6cf2c9066784f87e))
|
||||||
|
|
||||||
|
|
||||||
|
## [2.44.2](https://github.com/n8n-io/n8n/compare/n8n@2.44.1...n8n@2.44.2) (2026-10-15)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **core:** Create an XML parser for each webhook request body ([#41112](https://github.com/n8n-io/n8n/issues/41112)) ([c52f181](https://github.com/n8n-io/n8n/commit/c52f1817b38cb03758fee45b1109caae12411ffa))
|
||||||
|
|
||||||
|
|
||||||
|
## [2.44.1](https://github.com/n8n-io/n8n/compare/n8n@2.44.0...n8n@2.44.1) (2026-10-14)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* Show Gemini models by name and list prioritized models first in model pickers ([#41015](https://github.com/n8n-io/n8n/issues/41015)) ([321b020](https://github.com/n8n-io/n8n/commit/321b0203b73e3351c766cbece6a8f79c800d453f))
|
||||||
|
|
||||||
|
|
||||||
# [2.44.0](https://github.com/n8n-io/n8n/compare/n8n@2.43.0...n8n@2.44.0) (2026-10-13)
|
# [2.44.0](https://github.com/n8n-io/n8n/compare/n8n@2.43.0...n8n@2.44.0) (2026-10-13)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "n8n-monorepo",
|
"name": "n8n-monorepo",
|
||||||
"version": "2.44.0",
|
"version": "2.44.3",
|
||||||
"private": true,
|
"private": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=24.0.0",
|
"node": ">=24.0.0",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/agents",
|
"name": "@n8n/agents",
|
||||||
"version": "0.29.0",
|
"version": "0.29.2",
|
||||||
"description": "AI agent SDK for n8n's code-first execution engine",
|
"description": "AI agent SDK for n8n's code-first execution engine",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=24.0.0"
|
"node": ">=24.0.0"
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -237,6 +237,68 @@ describe('parseObservationLogMarkdown', () => {
|
||||||
]);
|
]);
|
||||||
expect(parsed.skippedLines).toEqual(['not a bullet']);
|
expect(parsed.skippedLines).toEqual(['not a bullet']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('appends indented unmarked detail lines to the entry above', () => {
|
||||||
|
const parsed = parseObservationLogMarkdown(
|
||||||
|
[
|
||||||
|
"* CRITICAL (15:09) User's invoice rules:",
|
||||||
|
' * Count only invoices with status paid or pending.',
|
||||||
|
' - Status void means cancelled.',
|
||||||
|
'* IMPORTANT (15:10) Open questions for the user',
|
||||||
|
' * INFO (15:10) Setup is blocked on two answers.',
|
||||||
|
' 1. Which bridge do they use?',
|
||||||
|
' 2. Which webhook URL?',
|
||||||
|
].join('\n'),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(parsed.entries).toEqual([
|
||||||
|
{
|
||||||
|
marker: 'critical',
|
||||||
|
text: "User's invoice rules: Count only invoices with status paid or pending. Status void means cancelled.",
|
||||||
|
parentIndex: null,
|
||||||
|
},
|
||||||
|
{ marker: 'important', text: 'Open questions for the user', parentIndex: null },
|
||||||
|
{
|
||||||
|
marker: 'info',
|
||||||
|
text: 'Setup is blocked on two answers. 1. Which bridge do they use? 2. Which webhook URL?',
|
||||||
|
parentIndex: 1,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(parsed.skippedLines).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('joins a detail line with a semicolon when the entry has no closing punctuation', () => {
|
||||||
|
const parsed = parseObservationLogMarkdown(
|
||||||
|
['* INFO (10:02) Published version 90f4e3d6', ' * Current draft fbda5a15'].join('\n'),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(parsed.entries).toEqual([
|
||||||
|
{
|
||||||
|
marker: 'info',
|
||||||
|
text: 'Published version 90f4e3d6; Current draft fbda5a15',
|
||||||
|
parentIndex: null,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps detail lines that start with an uppercase word', () => {
|
||||||
|
const parsed = parseObservationLogMarkdown(
|
||||||
|
[
|
||||||
|
'* INFO (10:02) Endpoints checked:',
|
||||||
|
' * GET /orders returns 200.',
|
||||||
|
' * API key rotated.',
|
||||||
|
].join('\n'),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(parsed.entries).toEqual([
|
||||||
|
{
|
||||||
|
marker: 'info',
|
||||||
|
text: 'Endpoints checked: GET /orders returns 200. API key rotated.',
|
||||||
|
parentIndex: null,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(parsed.skippedLines).toEqual([]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('renderObserverTranscript', () => {
|
describe('renderObserverTranscript', () => {
|
||||||
|
|
@ -520,6 +582,43 @@ describe('runObservationLogObserver', () => {
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
it('writes observations with indented detail lists and advances the cursor', async () => {
|
||||||
|
const store = new InMemoryMemory();
|
||||||
|
await store.saveThread({ id: 'thread-1', resourceId: 'user-1' });
|
||||||
|
await store.saveMessages({
|
||||||
|
threadId: 'thread-1',
|
||||||
|
resourceId: 'user-1',
|
||||||
|
messages: [message('m1', 'user', 'Remember my rules.', new Date(2026, 4, 12, 14, 30))],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await runObservationLogObserver({
|
||||||
|
memory: store,
|
||||||
|
observationScopeId: 'thread-1',
|
||||||
|
observationLogTailLimit: 20,
|
||||||
|
tokenCounter: () => 10,
|
||||||
|
now: new Date(2026, 4, 12, 14, 31),
|
||||||
|
observe: async () =>
|
||||||
|
await Promise.resolve(
|
||||||
|
[
|
||||||
|
'* CRITICAL (14:31) User rules:',
|
||||||
|
' * Count paid invoices.',
|
||||||
|
' * Skip void ones.',
|
||||||
|
].join('\n'),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
status: 'ran',
|
||||||
|
observationsWritten: 1,
|
||||||
|
cursorAdvanced: true,
|
||||||
|
skippedLines: [],
|
||||||
|
});
|
||||||
|
expect(await store.getActiveObservationLog({ observationScopeId: 'thread-1' })).toMatchObject([
|
||||||
|
{ marker: 'critical', text: 'User rules: Count paid invoices. Skip void ones.' },
|
||||||
|
]);
|
||||||
|
expect(await store.getCursor('thread-1')).toMatchObject({ lastObservedMessageId: 'm1' });
|
||||||
|
});
|
||||||
|
|
||||||
it('writes parsed observations and advances the cursor after observing', async () => {
|
it('writes parsed observations and advances the cursor after observing', async () => {
|
||||||
const store = new InMemoryMemory();
|
const store = new InMemoryMemory();
|
||||||
const parentText = 'User needs the current request remembered.';
|
const parentText = 'User needs the current request remembered.';
|
||||||
|
|
@ -610,6 +709,12 @@ describe('runObservationLogObserver', () => {
|
||||||
'```\nNO_OBSERVATIONS\n```',
|
'```\nNO_OBSERVATIONS\n```',
|
||||||
'NO_OBSERVATIONS\n* CRITICAL Keep this fact.',
|
'NO_OBSERVATIONS\n* CRITICAL Keep this fact.',
|
||||||
'* CRITICAL Keep this fact.\nUnformatted detail.',
|
'* CRITICAL Keep this fact.\nUnformatted detail.',
|
||||||
|
' Indented detail before any entry.\n* CRITICAL Keep this fact.',
|
||||||
|
'* CRITICAL Keep this fact.\n * COMPLETED (14:31) Unknown marker.',
|
||||||
|
'* CRITICAL Keep this fact.\n * COMPLETED Deploy is set for noon.',
|
||||||
|
'* CRITICAL Keep this fact.\n * INFO: Marker with a colon.',
|
||||||
|
'* CRITICAL Keep this fact.\n ```\n code\n ```',
|
||||||
|
'* CRITICAL Keep this fact.\n NO_OBSERVATIONS',
|
||||||
'* INFO ',
|
'* INFO ',
|
||||||
'```\n* CRITICAL Keep this fact.\n```',
|
'```\n* CRITICAL Keep this fact.\n```',
|
||||||
])('rejects an invalid response without changing memory: %j', async (output) => {
|
])('rejects an invalid response without changing memory: %j', async (output) => {
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,8 @@ A valid response has exactly one of these forms:
|
||||||
|
|
||||||
Use only one indentation level. A child attaches to the preceding top-level bullet in this response. Never omit the \`*\` prefix.
|
Use only one indentation level. A child attaches to the preceding top-level bullet in this response. Never omit the \`*\` prefix.
|
||||||
|
|
||||||
|
Every line is one observation with its own marker. To add details to an observation, write each detail as a marked child, or put the details on the same line separated by semicolons. Never write a plain sub-list, a numbered list, or any line without a marker.
|
||||||
|
|
||||||
2. Exactly this marker when there is nothing durable to record:
|
2. Exactly this marker when there is nothing durable to record:
|
||||||
|
|
||||||
NO_OBSERVATIONS
|
NO_OBSERVATIONS
|
||||||
|
|
@ -90,6 +92,16 @@ GOOD:
|
||||||
* CRITICAL (14:30) User approved the data migration. Deployment requires separate approval.
|
* CRITICAL (14:30) User approved the data migration. Deployment requires separate approval.
|
||||||
* IMPORTANT (14:31) Agent plans to start the migration; completion is not verified.
|
* IMPORTANT (14:31) Agent plans to start the migration; completion is not verified.
|
||||||
|
|
||||||
|
Details go on marked lines
|
||||||
|
|
||||||
|
BAD:
|
||||||
|
* CRITICAL (15:09) User's invoice rules:
|
||||||
|
* Count only invoices with status paid or pending.
|
||||||
|
- Status void means cancelled.
|
||||||
|
|
||||||
|
GOOD:
|
||||||
|
* CRITICAL (15:09) User's invoice rules: count only invoices with status paid or pending; status void means cancelled.
|
||||||
|
|
||||||
Keep verification evidence together
|
Keep verification evidence together
|
||||||
|
|
||||||
Transcript:
|
Transcript:
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,12 @@ const MARKER_BY_TOKEN: Record<string, ObservationLogMarker> = {
|
||||||
|
|
||||||
const BULLET_PATTERN =
|
const BULLET_PATTERN =
|
||||||
/^(\s*)[*-]\s+(CRITICAL|IMPORTANT|INFO|COMPLETION)(?:\s+\(\d{2}:\d{2}\))?\s+(.+)$/iu;
|
/^(\s*)[*-]\s+(CRITICAL|IMPORTANT|INFO|COMPLETION)(?:\s+\(\d{2}:\d{2}\))?\s+(.+)$/iu;
|
||||||
|
/**
|
||||||
|
* Indented lines that are malformed structure, not detail: a fence, the sentinel, a timed
|
||||||
|
* unknown marker, or a misspelled marker. Other uppercase words (API, GET) stay detail.
|
||||||
|
*/
|
||||||
|
const NON_DETAIL_PATTERN =
|
||||||
|
/^(?:```|NO_OBSERVATIONS$|(?:[*-]\s+)?(?:[A-Z_]+\s+\(\d{2}:\d{2}\)|(?:COMPLET|CRITIC|IMPORTAN|INFO)[A-Z]*\b))/u;
|
||||||
const DEFAULT_MAX_SERIALIZED_CHARS = 2_000;
|
const DEFAULT_MAX_SERIALIZED_CHARS = 2_000;
|
||||||
const DEFAULT_MAX_STRING_CHARS = 500;
|
const DEFAULT_MAX_STRING_CHARS = 500;
|
||||||
const DEFAULT_MAX_ARRAY_ITEMS = 20;
|
const DEFAULT_MAX_ARRAY_ITEMS = 20;
|
||||||
|
|
@ -119,6 +125,13 @@ export function parseObservationLogMarkdown(markdown: string): ParseObservationL
|
||||||
|
|
||||||
const match = BULLET_PATTERN.exec(rawLine);
|
const match = BULLET_PATTERN.exec(rawLine);
|
||||||
if (!match) {
|
if (!match) {
|
||||||
|
// Models often add an indented detail list under an observation. Keep
|
||||||
|
// that detail on the entry above instead of rejecting the response.
|
||||||
|
const previous = entries.at(-1);
|
||||||
|
if (previous && /^\s/.test(rawLine) && !NON_DETAIL_PATTERN.test(trimmed)) {
|
||||||
|
previous.text = appendObservationDetail(previous.text, trimmed);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
skippedLines.push(rawLine);
|
skippedLines.push(rawLine);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -141,6 +154,12 @@ export function parseObservationLogMarkdown(markdown: string): ParseObservationL
|
||||||
return { entries, skippedLines };
|
return { entries, skippedLines };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Joins a detail line onto one line, so the rendered log keeps one entry per line. */
|
||||||
|
function appendObservationDetail(text: string, detailLine: string): string {
|
||||||
|
const detail = detailLine.replace(/^[*-]\s+/, '');
|
||||||
|
return /[:;.!?]$/.test(text) ? `${text} ${detail}` : `${text}; ${detail}`;
|
||||||
|
}
|
||||||
|
|
||||||
export function renderObserverTranscript(
|
export function renderObserverTranscript(
|
||||||
messages: AgentDbMessage[],
|
messages: AgentDbMessage[],
|
||||||
options: RenderObserverTranscriptOptions = {},
|
options: RenderObserverTranscriptOptions = {},
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/ai-node-sdk",
|
"name": "@n8n/ai-node-sdk",
|
||||||
"version": "0.34.0",
|
"version": "0.34.1",
|
||||||
"description": "SDK for building AI nodes in n8n",
|
"description": "SDK for building AI nodes in n8n",
|
||||||
"types": "dist/esm/index.d.ts",
|
"types": "dist/esm/index.d.ts",
|
||||||
"module": "dist/esm/index.js",
|
"module": "dist/esm/index.js",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/ai-utilities",
|
"name": "@n8n/ai-utilities",
|
||||||
"version": "0.37.0",
|
"version": "0.37.1",
|
||||||
"description": "Utilities for building AI nodes in n8n",
|
"description": "Utilities for building AI nodes in n8n",
|
||||||
"types": "dist/esm/index.d.ts",
|
"types": "dist/esm/index.d.ts",
|
||||||
"module": "dist/esm/index.js",
|
"module": "dist/esm/index.js",
|
||||||
|
|
|
||||||
|
|
@ -153,6 +153,88 @@ describe('model-discovery', () => {
|
||||||
{ id: 'models/gemini-2.5-pro', name: 'models/gemini-2.5-pro' },
|
{ id: 'models/gemini-2.5-pro', name: 'models/gemini-2.5-pro' },
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("labels models with the API's displayName, else the id", async () => {
|
||||||
|
const fetch = mockFetch({
|
||||||
|
models: [
|
||||||
|
{
|
||||||
|
name: 'models/gemini-2.5-pro',
|
||||||
|
displayName: 'Gemini 2.5 Pro',
|
||||||
|
supportedGenerationMethods: ['generateContent'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'models/gemini-2.5-flash',
|
||||||
|
displayName: ' ',
|
||||||
|
supportedGenerationMethods: ['generateContent'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const models = await listModelsForProvider('google', { apiKey: 'g-key', fetch });
|
||||||
|
|
||||||
|
expect(models).toEqual([
|
||||||
|
{ id: 'models/gemini-2.5-pro', name: 'Gemini 2.5 Pro' },
|
||||||
|
{ id: 'models/gemini-2.5-flash', name: 'models/gemini-2.5-flash' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lists models with a priority first and passes the priority on', async () => {
|
||||||
|
const fetch = mockFetch({
|
||||||
|
models: [
|
||||||
|
{ name: 'models/gemini-2.5-pro', supportedGenerationMethods: ['generateContent'] },
|
||||||
|
{
|
||||||
|
name: 'models/gemini-featured',
|
||||||
|
priority: 10,
|
||||||
|
supportedGenerationMethods: ['generateContent'],
|
||||||
|
},
|
||||||
|
{ name: 'models/gemini-2.5-flash', supportedGenerationMethods: ['generateContent'] },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const models = await listModelsForProvider('google', { apiKey: 'g-key', fetch });
|
||||||
|
|
||||||
|
expect(models).toEqual([
|
||||||
|
{ id: 'models/gemini-featured', name: 'models/gemini-featured', priority: 10 },
|
||||||
|
{ id: 'models/gemini-2.5-flash', name: 'models/gemini-2.5-flash' },
|
||||||
|
{ id: 'models/gemini-2.5-pro', name: 'models/gemini-2.5-pro' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('google empty response', () => {
|
||||||
|
it('returns no models when the response has no models list', async () => {
|
||||||
|
const fetch = mockFetch({});
|
||||||
|
|
||||||
|
await expect(listModelsForProvider('google', { apiKey: 'g-key', fetch })).resolves.toEqual(
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('google priority', () => {
|
||||||
|
it('drops a priority that is not a finite number', async () => {
|
||||||
|
const fetch = mockFetch({
|
||||||
|
models: [
|
||||||
|
{
|
||||||
|
name: 'models/gemini-2.5-pro',
|
||||||
|
priority: Number.POSITIVE_INFINITY,
|
||||||
|
supportedGenerationMethods: ['generateContent'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'models/gemini-2.5-flash',
|
||||||
|
priority: '5',
|
||||||
|
supportedGenerationMethods: ['generateContent'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const models = await listModelsForProvider('google', { apiKey: 'g-key', fetch });
|
||||||
|
|
||||||
|
expect(models).toEqual([
|
||||||
|
{ id: 'models/gemini-2.5-flash', name: 'models/gemini-2.5-flash' },
|
||||||
|
{ id: 'models/gemini-2.5-pro', name: 'models/gemini-2.5-pro' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('groq', () => {
|
describe('groq', () => {
|
||||||
|
|
|
||||||
|
|
@ -70,7 +70,14 @@ export {
|
||||||
} from './providers/azure';
|
} from './providers/azure';
|
||||||
export { listCohereModels } from './providers/cohere';
|
export { listCohereModels } from './providers/cohere';
|
||||||
export { listDeepSeekModels } from './providers/deepseek';
|
export { listDeepSeekModels } from './providers/deepseek';
|
||||||
export { listGoogleModels, shouldIncludeGoogleModel } from './providers/google';
|
export {
|
||||||
|
getGoogleModelLabel,
|
||||||
|
listGoogleModels,
|
||||||
|
parseGoogleModels,
|
||||||
|
shouldIncludeGoogleModel,
|
||||||
|
type GoogleModel,
|
||||||
|
} from './providers/google';
|
||||||
|
export { pinPrioritizedModels } from '@n8n/api-types';
|
||||||
export { listGroqModels } from './providers/groq';
|
export { listGroqModels } from './providers/groq';
|
||||||
export { listMiniMaxModels } from './providers/minimax';
|
export { listMiniMaxModels } from './providers/minimax';
|
||||||
export { listMistralModels } from './providers/mistral';
|
export { listMistralModels } from './providers/mistral';
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,6 @@
|
||||||
|
import { pinPrioritizedModels } from '@n8n/api-types';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { baseUrl, byName, getJson } from '../request';
|
import { baseUrl, byName, getJson } from '../request';
|
||||||
import type { ListModelsFn } from '../types';
|
import type { ListModelsFn } from '../types';
|
||||||
|
|
||||||
|
|
@ -33,22 +36,48 @@ export function shouldIncludeGoogleModel(model: {
|
||||||
*/
|
*/
|
||||||
export const listGoogleModels: ListModelsFn = async (options) => {
|
export const listGoogleModels: ListModelsFn = async (options) => {
|
||||||
const base = baseUrl(options, 'https://generativelanguage.googleapis.com');
|
const base = baseUrl(options, 'https://generativelanguage.googleapis.com');
|
||||||
const data = (await getJson(
|
const data = await getJson(
|
||||||
`${base}/v1beta/models`,
|
`${base}/v1beta/models`,
|
||||||
{ 'x-goog-api-key': options.apiKey },
|
{ 'x-goog-api-key': options.apiKey },
|
||||||
options,
|
options,
|
||||||
'google',
|
'google',
|
||||||
)) as { models?: Array<{ name?: unknown; supportedGenerationMethods?: unknown }> };
|
);
|
||||||
|
|
||||||
return (data.models ?? [])
|
const models = parseGoogleModels(data)
|
||||||
.filter(
|
.filter(shouldIncludeGoogleModel)
|
||||||
(model): model is { name: string; supportedGenerationMethods?: unknown } =>
|
.map((model) => ({
|
||||||
typeof model.name === 'string' &&
|
id: model.name,
|
||||||
shouldIncludeGoogleModel({
|
name: getGoogleModelLabel(model.name, model.displayName),
|
||||||
name: model.name,
|
...(model.priority !== undefined ? { priority: model.priority } : {}),
|
||||||
supportedGenerationMethods: model.supportedGenerationMethods,
|
}))
|
||||||
}),
|
|
||||||
)
|
|
||||||
.map((model) => ({ id: model.name, name: model.name }))
|
|
||||||
.sort(byName);
|
.sort(byName);
|
||||||
|
return pinPrioritizedModels(models, (model) => model.priority);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** A malformed optional field is dropped; a model without a name is skipped. */
|
||||||
|
const googleModelSchema = z.object({
|
||||||
|
name: z.string(),
|
||||||
|
displayName: z.string().optional().catch(undefined),
|
||||||
|
priority: z.number().finite().optional().catch(undefined),
|
||||||
|
supportedGenerationMethods: z.array(z.string()).optional().catch(undefined),
|
||||||
|
});
|
||||||
|
|
||||||
|
const googleModelsResponseSchema = z.object({
|
||||||
|
models: z.array(z.unknown()).optional().catch(undefined),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type GoogleModel = z.infer<typeof googleModelSchema>;
|
||||||
|
|
||||||
|
/** Models of a Google `GET /v1beta/models` response. */
|
||||||
|
export function parseGoogleModels(data: unknown): GoogleModel[] {
|
||||||
|
const response = googleModelsResponseSchema.safeParse(data);
|
||||||
|
return (response.success ? (response.data.models ?? []) : []).flatMap((item) => {
|
||||||
|
const model = googleModelSchema.safeParse(item);
|
||||||
|
return model.success ? [model.data] : [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dropdown label of a Google model: the API's `displayName`, else the id. */
|
||||||
|
export function getGoogleModelLabel(modelId: string, displayName?: string): string {
|
||||||
|
return displayName?.trim() || modelId;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ export interface ProviderModel {
|
||||||
id: string;
|
id: string;
|
||||||
/** Human-readable name when the provider supplies one, otherwise the id. */
|
/** Human-readable name when the provider supplies one, otherwise the id. */
|
||||||
name: string;
|
name: string;
|
||||||
|
/** Pickers list models with a priority first, highest first. Only the AI Gateway reports one. */
|
||||||
|
priority?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ListModelsOptions {
|
export interface ListModelsOptions {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/ai-workflow-builder",
|
"name": "@n8n/ai-workflow-builder",
|
||||||
"version": "1.44.0",
|
"version": "1.44.1",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"clean": "rimraf dist .turbo",
|
"clean": "rimraf dist .turbo",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/api-types",
|
"name": "@n8n/api-types",
|
||||||
"version": "1.44.0",
|
"version": "1.44.1",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"clean": "rimraf dist .turbo",
|
"clean": "rimraf dist .turbo",
|
||||||
"dev": "pnpm watch",
|
"dev": "pnpm watch",
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,68 @@
|
||||||
|
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('blocks the Data Table HITL tool for chat users', () => {
|
||||||
|
expect(isChatHubToolTypeAllowed('n8n-nodes-base.dataTableHitlTool', { isChatUser: true })).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks the Chat tool for all users', () => {
|
||||||
|
expect(
|
||||||
|
isChatHubToolTypeAllowed('@n8n/n8n-nodes-langchain.chatTool', { isChatUser: false }),
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
isChatHubToolTypeAllowed('@n8n/n8n-nodes-langchain.chatTool', { isChatUser: true }),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
'@n8n/n8n-nodes-langchain.toolWorkflow',
|
||||||
|
'n8n-nodes-base.dataTableTool',
|
||||||
|
'n8n-nodes-base.executeCommand',
|
||||||
|
'n8n-nodes-base.executeCommandTool',
|
||||||
|
'n8n-nodes-base.code',
|
||||||
|
'@n8n/n8n-nodes-langchain.toolCode',
|
||||||
|
'n8n-nodes-base.git',
|
||||||
|
'n8n-nodes-base.gitTool',
|
||||||
|
'n8n-nodes-base.messageAnAgentTool',
|
||||||
|
'n8n-nodes-base.executeCommandHitlTool',
|
||||||
|
'n8n-nodes-base.codeHitlTool',
|
||||||
|
'n8n-nodes-base.readWriteFileHitlTool',
|
||||||
|
'n8n-nodes-base.executeWorkflowHitlTool',
|
||||||
|
'n8n-nodes-base.n8nHitlTool',
|
||||||
|
])('blocks %s for chat users', (type) => {
|
||||||
|
expect(isChatHubToolTypeAllowed(type, { isChatUser: true })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(['n8n-nodes-base.executeCommandTool', '@n8n/n8n-nodes-langchain.toolCode'])(
|
||||||
|
'allows %s for users who can build workflows',
|
||||||
|
(type) => {
|
||||||
|
expect(isChatHubToolTypeAllowed(type, { isChatUser: false })).toBe(true);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
'n8n-nodes-base.slackTool',
|
||||||
|
'n8n-nodes-base.gmailTool',
|
||||||
|
'n8n-nodes-base.httpRequestTool',
|
||||||
|
'@n8n/n8n-nodes-langchain.toolHttpRequest',
|
||||||
|
'n8n-nodes-base.graphqlTool',
|
||||||
|
'n8n-nodes-base.rssFeedReadTool',
|
||||||
|
'@n8n/n8n-nodes-langchain.mcpClientTool',
|
||||||
|
'n8n-nodes-base.sshTool',
|
||||||
|
'n8n-nodes-base.ftpHitlTool',
|
||||||
|
'n8n-nodes-base.jenkinsTool',
|
||||||
|
'n8n-nodes-base.rundeckTool',
|
||||||
|
'n8n-nodes-base.awsLambdaTool',
|
||||||
|
])('allows %s for chat users', (type) => {
|
||||||
|
expect(isChatHubToolTypeAllowed(type, { isChatUser: true })).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
37
packages/@n8n/api-types/src/__tests__/model-priority.test.ts
Normal file
37
packages/@n8n/api-types/src/__tests__/model-priority.test.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
import { pinPrioritizedModels } from '../model-priority';
|
||||||
|
|
||||||
|
describe('pinPrioritizedModels', () => {
|
||||||
|
it('moves models with a priority to the front, highest first, and keeps the order of the rest', () => {
|
||||||
|
const models = [
|
||||||
|
{ id: 'b' },
|
||||||
|
{ id: 'low', priority: 1 },
|
||||||
|
{ id: 'a' },
|
||||||
|
{ id: 'high', priority: 10 },
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(pinPrioritizedModels(models, (model) => model.priority).map((m) => m.id)).toEqual([
|
||||||
|
'high',
|
||||||
|
'low',
|
||||||
|
'b',
|
||||||
|
'a',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores priorities that are not finite numbers', () => {
|
||||||
|
const models = [
|
||||||
|
{ id: 'a', priority: '5' },
|
||||||
|
{ id: 'b', priority: Number.NaN },
|
||||||
|
{ id: 'c' },
|
||||||
|
{ id: 'd', priority: Number.POSITIVE_INFINITY },
|
||||||
|
{ id: 'valid', priority: 1 },
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(pinPrioritizedModels(models, (model) => model.priority).map((m) => m.id)).toEqual([
|
||||||
|
'valid',
|
||||||
|
'a',
|
||||||
|
'b',
|
||||||
|
'c',
|
||||||
|
'd',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -70,6 +70,8 @@ export interface AgentCatalogModel {
|
||||||
toolCall: boolean;
|
toolCall: boolean;
|
||||||
cost?: { input: number; output: number; cacheRead?: number; cacheWrite?: number };
|
cost?: { input: number; output: number; cacheRead?: number; cacheWrite?: number };
|
||||||
limits?: { context?: number; output?: number };
|
limits?: { context?: number; output?: number };
|
||||||
|
/** Pickers list models with a priority first, highest first. Reported by the models API. */
|
||||||
|
priority?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Response of `GET /agents/v2/catalog/models/:provider`. */
|
/** Response of `GET /agents/v2/catalog/models/:provider`. */
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,10 @@ import type { Scope } from '@n8n/permissions';
|
||||||
import {
|
import {
|
||||||
CHAT_TOOL_NODE_TYPE,
|
CHAT_TOOL_NODE_TYPE,
|
||||||
type ChunkType,
|
type ChunkType,
|
||||||
|
CODE_NODE_TYPE,
|
||||||
|
CODE_TOOL_LANGCHAIN_NODE_TYPE,
|
||||||
DATA_TABLE_TOOL_NODE_TYPE,
|
DATA_TABLE_TOOL_NODE_TYPE,
|
||||||
|
EXECUTE_WORKFLOW_NODE_TYPE,
|
||||||
type INode,
|
type INode,
|
||||||
INodeSchema,
|
INodeSchema,
|
||||||
WORKFLOW_TOOL_LANGCHAIN_NODE_TYPE,
|
WORKFLOW_TOOL_LANGCHAIN_NODE_TYPE,
|
||||||
|
|
@ -709,12 +712,46 @@ export interface ChatHubToolDto {
|
||||||
/** Tool types blocked for ALL users in the Chat Hub. */
|
/** Tool types blocked for ALL users in the Chat Hub. */
|
||||||
export const ALWAYS_BLOCKED_CHAT_HUB_TOOL_TYPES: string[] = [CHAT_TOOL_NODE_TYPE];
|
export const ALWAYS_BLOCKED_CHAT_HUB_TOOL_TYPES: string[] = [CHAT_TOOL_NODE_TYPE];
|
||||||
|
|
||||||
/** Additional tool types blocked for chat-only users (global:chatUser). */
|
/**
|
||||||
|
* Additional tool types blocked for chat-only users (global:chatUser).
|
||||||
|
* These tools run code or commands on the n8n instance, or access its files,
|
||||||
|
* workflows, data tables or agents. Tools that act on a remote system through
|
||||||
|
* a credential stay allowed: the credential limits what they can do.
|
||||||
|
* Chat users cannot build workflows, so they cannot add these tools either.
|
||||||
|
* A `*Tool` or `*HitlTool` variant of a listed node type is blocked too.
|
||||||
|
* Any node can have a `*HitlTool` variant, so the list also holds node types
|
||||||
|
* that are not usable as tools.
|
||||||
|
*/
|
||||||
export const CHAT_USER_BLOCKED_CHAT_HUB_TOOL_TYPES: string[] = [
|
export const CHAT_USER_BLOCKED_CHAT_HUB_TOOL_TYPES: string[] = [
|
||||||
WORKFLOW_TOOL_LANGCHAIN_NODE_TYPE,
|
WORKFLOW_TOOL_LANGCHAIN_NODE_TYPE,
|
||||||
DATA_TABLE_TOOL_NODE_TYPE,
|
DATA_TABLE_TOOL_NODE_TYPE,
|
||||||
|
CODE_NODE_TYPE,
|
||||||
|
CODE_TOOL_LANGCHAIN_NODE_TYPE,
|
||||||
|
EXECUTE_WORKFLOW_NODE_TYPE,
|
||||||
|
'n8n-nodes-base.executeCommand',
|
||||||
|
'n8n-nodes-base.messageAnAgent',
|
||||||
|
'n8n-nodes-base.git',
|
||||||
|
'n8n-nodes-base.readWriteFile',
|
||||||
|
'n8n-nodes-base.n8n',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/** Matches the node type registry, which resolves both suffixes to the base node type. */
|
||||||
|
function withoutToolSuffix(type: string): string {
|
||||||
|
return type.replace(/HitlTool$/, '').replace(/Tool$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Compares base node types, so an entry also covers the base, `*Tool` and `*HitlTool` forms. */
|
||||||
|
function isListed(list: string[], type: string): boolean {
|
||||||
|
const baseType = withoutToolSuffix(type);
|
||||||
|
return list.some((entry) => withoutToolSuffix(entry) === baseType);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 (isListed(ALWAYS_BLOCKED_CHAT_HUB_TOOL_TYPES, type)) return false;
|
||||||
|
return !(user.isChatUser && isListed(CHAT_USER_BLOCKED_CHAT_HUB_TOOL_TYPES, type));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Request schema for creating a chat hub tool
|
* Request schema for creating a chat hub tool
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -245,6 +245,11 @@ export { GetWorkflowQueryDto } from './workflows/get-workflow-query.dto';
|
||||||
export { UpdateWorkflowPublicDto } from './workflows/update-workflow-public.dto';
|
export { UpdateWorkflowPublicDto } from './workflows/update-workflow-public.dto';
|
||||||
export { UpdateWorkflowQueryDto } from './workflows/update-workflow-query.dto';
|
export { UpdateWorkflowQueryDto } from './workflows/update-workflow-query.dto';
|
||||||
export { ListWorkflowsQueryDto } from './workflows/list-workflows-query.dto';
|
export { ListWorkflowsQueryDto } from './workflows/list-workflows-query.dto';
|
||||||
|
export {
|
||||||
|
ListCallableSubworkflowsQueryDto,
|
||||||
|
type CallableSubworkflow,
|
||||||
|
type CallableSubworkflowsResponse,
|
||||||
|
} from './workflows/list-callable-subworkflows-query.dto';
|
||||||
export {
|
export {
|
||||||
WorkflowPublicDto,
|
WorkflowPublicDto,
|
||||||
CreatedWorkflowPublicDto,
|
CreatedWorkflowPublicDto,
|
||||||
|
|
|
||||||
|
|
@ -212,6 +212,11 @@ describe('CreateWorkflowDto', () => {
|
||||||
request: { name: 'a'.repeat(129), nodes: [], connections: {} },
|
request: { name: 'a'.repeat(129), nodes: [], connections: {} },
|
||||||
expectedErrorPath: ['name'],
|
expectedErrorPath: ['name'],
|
||||||
},
|
},
|
||||||
|
...['__proto__', 'constructor', 'prototype'].map((id) => ({
|
||||||
|
name: `reserved id ${id}`,
|
||||||
|
request: { id, name: 'My Workflow', nodes: [], connections: {} },
|
||||||
|
expectedErrorPath: ['id'],
|
||||||
|
})),
|
||||||
{
|
{
|
||||||
name: 'missing nodes',
|
name: 'missing nodes',
|
||||||
request: { name: 'Test', connections: {} },
|
request: { name: 'Test', connections: {} },
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,13 @@
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { baseWorkflowShape } from './base-workflow.dto';
|
import { baseWorkflowShape } from './base-workflow.dto';
|
||||||
|
import { isSafeObjectKey } from '../../schemas/instance-ai.schema';
|
||||||
import { Z } from '../../zod-class';
|
import { Z } from '../../zod-class';
|
||||||
|
|
||||||
export const workflowIdSchema = z.string();
|
// Workflow ids are used as object keys, so `__proto__`, `constructor` and `prototype` are not allowed
|
||||||
|
export const workflowIdSchema = z
|
||||||
|
.string()
|
||||||
|
.refine(isSafeObjectKey, { message: 'Invalid workflow id' });
|
||||||
|
|
||||||
export class CreateWorkflowDto extends Z.class({
|
export class CreateWorkflowDto extends Z.class({
|
||||||
// Spread base fields (name, nodes, connections, settings, etc.)
|
// Spread base fields (name, nodes, connections, settings, etc.)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { Z } from '../../zod-class';
|
||||||
|
import { paginationSchema } from '../pagination/pagination.dto';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query for the list of workflows that a parent workflow can call as
|
||||||
|
* sub-workflows. Only name search and pagination are accepted, so callers
|
||||||
|
* cannot probe workflow contents.
|
||||||
|
*/
|
||||||
|
export class ListCallableSubworkflowsQueryDto extends Z.class({
|
||||||
|
...paginationSchema,
|
||||||
|
query: z.string().trim().max(256).optional(),
|
||||||
|
}) {}
|
||||||
|
|
||||||
|
export type CallableSubworkflow = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
active: boolean;
|
||||||
|
isArchived: boolean;
|
||||||
|
/** Set only when the workflow belongs to a team project. */
|
||||||
|
projectName?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CallableSubworkflowsResponse = {
|
||||||
|
count: number;
|
||||||
|
data: CallableSubworkflow[];
|
||||||
|
};
|
||||||
|
|
@ -96,6 +96,7 @@ export {
|
||||||
ChatHubUpdateToolRequest,
|
ChatHubUpdateToolRequest,
|
||||||
ALWAYS_BLOCKED_CHAT_HUB_TOOL_TYPES,
|
ALWAYS_BLOCKED_CHAT_HUB_TOOL_TYPES,
|
||||||
CHAT_USER_BLOCKED_CHAT_HUB_TOOL_TYPES,
|
CHAT_USER_BLOCKED_CHAT_HUB_TOOL_TYPES,
|
||||||
|
isChatHubToolTypeAllowed,
|
||||||
chatHubVectorStoreProviderSchema,
|
chatHubVectorStoreProviderSchema,
|
||||||
type ChatHubVectorStoreProvider,
|
type ChatHubVectorStoreProvider,
|
||||||
VECTOR_STORE_PROVIDER_CREDENTIAL_TYPE_MAP,
|
VECTOR_STORE_PROVIDER_CREDENTIAL_TYPE_MAP,
|
||||||
|
|
@ -966,3 +967,4 @@ export { compareExecutionListItems } from './dto/executions/compare-execution-li
|
||||||
|
|
||||||
export type * from './workflow-suggestions';
|
export type * from './workflow-suggestions';
|
||||||
export * from './self-healing-results';
|
export * from './self-healing-results';
|
||||||
|
export * from './model-priority';
|
||||||
|
|
|
||||||
23
packages/@n8n/api-types/src/model-priority.ts
Normal file
23
packages/@n8n/api-types/src/model-priority.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
/**
|
||||||
|
* Moves models that have a priority to the front, highest first, and keeps the
|
||||||
|
* order of the rest. A models API reports a priority to promote a model, for
|
||||||
|
* example at its launch.
|
||||||
|
*/
|
||||||
|
export function pinPrioritizedModels<T>(models: T[], getPriority: (model: T) => unknown): T[] {
|
||||||
|
const entries = models.map((model) => {
|
||||||
|
const priority = getPriority(model);
|
||||||
|
return {
|
||||||
|
model,
|
||||||
|
priority: typeof priority === 'number' && Number.isFinite(priority) ? priority : null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const prioritized = entries
|
||||||
|
.flatMap((entry) =>
|
||||||
|
entry.priority === null ? [] : [{ model: entry.model, priority: entry.priority }],
|
||||||
|
)
|
||||||
|
.sort((a, b) => b.priority - a.priority);
|
||||||
|
return [
|
||||||
|
...prioritized.map((entry) => entry.model),
|
||||||
|
...entries.filter((entry) => entry.priority === null).map((entry) => entry.model),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/backend-common",
|
"name": "@n8n/backend-common",
|
||||||
"version": "1.44.0",
|
"version": "1.44.1",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"clean": "rimraf dist .turbo",
|
"clean": "rimraf dist .turbo",
|
||||||
"dev": "pnpm watch",
|
"dev": "pnpm watch",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/backend-network",
|
"name": "@n8n/backend-network",
|
||||||
"version": "1.18.0",
|
"version": "1.18.1",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"clean": "rimraf dist .turbo",
|
"clean": "rimraf dist .turbo",
|
||||||
"dev": "pnpm watch",
|
"dev": "pnpm watch",
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import type { HttpsProxyAgent } from 'https-proxy-agent';
|
import type { HttpsProxyAgent } from 'https-proxy-agent';
|
||||||
import type http from 'node:http';
|
import http from 'node:http';
|
||||||
|
|
||||||
import { makeSsrfBridge } from '../../ssrf/__tests__/mock-ssrf-bridge';
|
import { makeSsrfBridge } from '../../ssrf/__tests__/mock-ssrf-bridge';
|
||||||
import { installProxyConnectionGuard } from '../connection-guards';
|
import { installConnectionGuard, installProxyConnectionGuard } from '../connection-guards';
|
||||||
|
|
||||||
type ConnectOpts = Parameters<HttpsProxyAgent<string>['connect']>[1];
|
type ConnectOpts = Parameters<HttpsProxyAgent<string>['connect']>[1];
|
||||||
|
|
||||||
|
|
@ -76,3 +76,78 @@ describe('installProxyConnectionGuard', () => {
|
||||||
expect(bridge.validateConnectionHost).not.toHaveBeenCalled();
|
expect(bridge.validateConnectionHost).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('installConnectionGuard', () => {
|
||||||
|
function guarded() {
|
||||||
|
const createConnection = vi.fn().mockReturnValue('SOCKET');
|
||||||
|
const target = { createConnection };
|
||||||
|
return { target, createConnection };
|
||||||
|
}
|
||||||
|
|
||||||
|
it('validates the target host and delegates when the policy allows it', () => {
|
||||||
|
const bridge = makeSsrfBridge();
|
||||||
|
const { target, createConnection } = guarded();
|
||||||
|
installConnectionGuard(target, bridge);
|
||||||
|
|
||||||
|
expect(target.createConnection({ host: 'target.example.com' })).toBe('SOCKET');
|
||||||
|
|
||||||
|
expect(bridge.validateConnectionHost).toHaveBeenCalledWith('target.example.com');
|
||||||
|
expect(createConnection).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a connection to a socket path without validating the unused host', () => {
|
||||||
|
const bridge = makeSsrfBridge();
|
||||||
|
const { target, createConnection } = guarded();
|
||||||
|
installConnectionGuard(target, bridge);
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
target.createConnection({ host: 'target.example.com', socketPath: '/var/run/docker.sock' }),
|
||||||
|
).toThrow('Cannot open a connection to a socket path');
|
||||||
|
|
||||||
|
expect(createConnection).not.toHaveBeenCalled();
|
||||||
|
expect(bridge.validateConnectionHost).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports a socket path through the callback when the caller passes one', () => {
|
||||||
|
const bridge = makeSsrfBridge();
|
||||||
|
const { target, createConnection } = guarded();
|
||||||
|
installConnectionGuard(target, bridge);
|
||||||
|
const onConnect = vi.fn();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
target.createConnection({ socketPath: '/var/run/docker.sock' }, onConnect),
|
||||||
|
).toBeUndefined();
|
||||||
|
|
||||||
|
expect(onConnect).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ message: 'Cannot open a connection to a socket path' }),
|
||||||
|
);
|
||||||
|
expect(createConnection).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('installConnectionGuard on a real http.Agent', () => {
|
||||||
|
async function get(agent: http.Agent): Promise<string> {
|
||||||
|
return await new Promise((resolve, reject) => {
|
||||||
|
const req = http.get('http://target.example.com/x', { agent, timeout: 3000 }, (res) => {
|
||||||
|
let data = '';
|
||||||
|
res.on('data', (chunk) => (data += String(chunk)));
|
||||||
|
res.on('end', () => resolve(data));
|
||||||
|
});
|
||||||
|
req.on('error', reject);
|
||||||
|
req.on('timeout', () => {
|
||||||
|
req.destroy();
|
||||||
|
reject(new Error('timeout'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['ssrf active', () => makeSsrfBridge()],
|
||||||
|
['ssrf disabled', () => 'disabled' as const],
|
||||||
|
])('refuses a socket path Node puts in the connect options (%s)', async (_label, ssrf) => {
|
||||||
|
const agent = new http.Agent({ socketPath: '/tmp/backend-network.sock' } as http.AgentOptions);
|
||||||
|
installConnectionGuard(agent as unknown as { createConnection: never }, ssrf());
|
||||||
|
|
||||||
|
await expect(get(agent)).rejects.toThrow('Cannot open a connection to a socket path');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ import { installConnectionGuard, UnknownConnectionHostError } from '../connectio
|
||||||
import { EnvProxyHttpAgent } from '../env-proxy-http-agent';
|
import { EnvProxyHttpAgent } from '../env-proxy-http-agent';
|
||||||
import { EnvProxyHttpsAgent } from '../env-proxy-https-agent';
|
import { EnvProxyHttpsAgent } from '../env-proxy-https-agent';
|
||||||
import { type LocalServer, startServer } from '../local-server';
|
import { type LocalServer, startServer } from '../local-server';
|
||||||
import { buildNodeAgents, type ProxyUrl } from '../node-agents';
|
import { buildNodeAgents, type NodeAgentOptions, type ProxyUrl } from '../node-agents';
|
||||||
|
|
||||||
// HttpsProxyAgent stores `lookup` in `connectOpts` rather than `options`
|
// HttpsProxyAgent stores `lookup` in `connectOpts` rather than `options`
|
||||||
// (unlike http.Agent and HttpProxyAgent which use `options`).
|
// (unlike http.Agent and HttpProxyAgent which use `options`).
|
||||||
|
|
@ -32,6 +32,10 @@ function getAgentOptions(agent: http.Agent | https.Agent): { keepAlive?: boolean
|
||||||
return (agent as unknown as { options?: { keepAlive?: boolean } }).options ?? {};
|
return (agent as unknown as { options?: { keepAlive?: boolean } }).options ?? {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function withSocketPath(socketPath: string): NodeAgentOptions {
|
||||||
|
return { socketPath } as unknown as NodeAgentOptions;
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// buildNodeAgents — shared builder (single source of truth for the undici
|
// buildNodeAgents — shared builder (single source of truth for the undici
|
||||||
// factory, the axios transport layer, and the global proxy agents)
|
// factory, the axios transport layer, and the global proxy agents)
|
||||||
|
|
@ -111,6 +115,29 @@ describe('buildNodeAgents', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('rejects a caller-provided socketPath (not a validated connection target)', () => {
|
||||||
|
const socketPath = '/tmp/backend-network.sock';
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['ssrf disabled', 'disabled' as const],
|
||||||
|
['ssrf active', makeSsrfBridge()],
|
||||||
|
])('throws when agentOptions.socketPath is set (%s)', (_label, ssrf) => {
|
||||||
|
expect(() => buildNodeAgents(false, ssrf, withSocketPath(socketPath))).toThrow(
|
||||||
|
'`agentOptions.socketPath` is not supported',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['direct', false as const],
|
||||||
|
['env proxy', 'env' as const],
|
||||||
|
['explicit proxy', 'http://proxy.internal:3128' as ProxyUrl],
|
||||||
|
])('throws on the %s path too', (_label, proxy) => {
|
||||||
|
expect(() => buildNodeAgents(proxy, makeSsrfBridge(), withSocketPath(socketPath))).toThrow(
|
||||||
|
'`agentOptions.socketPath` is not supported',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('SSRF lookup placement', () => {
|
describe('SSRF lookup placement', () => {
|
||||||
const modes: Array<[string, false | 'env' | ProxyUrl]> = [
|
const modes: Array<[string, false | 'env' | ProxyUrl]> = [
|
||||||
['proxy: false', false],
|
['proxy: false', false],
|
||||||
|
|
|
||||||
|
|
@ -14,8 +14,18 @@ export class UnknownConnectionHostError extends UnexpectedError {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class SocketPathConnectionError extends UnexpectedError {
|
||||||
|
constructor() {
|
||||||
|
super('Cannot open a connection to a socket path');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Subset of an agent's connection options we read to find the target host. */
|
/** Subset of an agent's connection options we read to find the target host. */
|
||||||
type ConnectionOptions = { host?: string | null; hostname?: string | null };
|
type ConnectionOptions = {
|
||||||
|
host?: string | null;
|
||||||
|
hostname?: string | null;
|
||||||
|
socketPath?: string;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Runtime-only `createConnection` method of Node's http(s) agents.
|
* Runtime-only `createConnection` method of Node's http(s) agents.
|
||||||
|
|
@ -59,6 +69,16 @@ export function installProxyConnectionGuard(agent: ProxyConnectionAgent, ssrf: S
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function connectionError(
|
||||||
|
ssrf: SsrfBridge | 'disabled',
|
||||||
|
options: ConnectionOptions,
|
||||||
|
): Error | undefined {
|
||||||
|
if (options.socketPath) {
|
||||||
|
return new SocketPathConnectionError();
|
||||||
|
}
|
||||||
|
return ssrf === 'disabled' ? undefined : connectionHostError(ssrf, options);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wraps an agent's `createConnection` to validate the connection target before the socket opens.
|
* Wraps an agent's `createConnection` to validate the connection target before the socket opens.
|
||||||
*
|
*
|
||||||
|
|
@ -68,11 +88,11 @@ export function installProxyConnectionGuard(agent: ProxyConnectionAgent, ssrf: S
|
||||||
*/
|
*/
|
||||||
export function installConnectionGuard(
|
export function installConnectionGuard(
|
||||||
target: { createConnection: CreateConnection },
|
target: { createConnection: CreateConnection },
|
||||||
ssrf: SsrfBridge,
|
ssrf: SsrfBridge | 'disabled',
|
||||||
): void {
|
): void {
|
||||||
const createConnection = target.createConnection.bind(target);
|
const createConnection = target.createConnection.bind(target);
|
||||||
target.createConnection = (options, onConnect) => {
|
target.createConnection = (options, onConnect) => {
|
||||||
const error = connectionHostError(ssrf, options);
|
const error = connectionError(ssrf, options);
|
||||||
if (error) {
|
if (error) {
|
||||||
if (onConnect) {
|
if (onConnect) {
|
||||||
onConnect(error);
|
onConnect(error);
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { UnexpectedError } from 'n8n-workflow';
|
import { UserError } from 'n8n-workflow';
|
||||||
import http from 'node:http';
|
import http from 'node:http';
|
||||||
import https from 'node:https';
|
import https from 'node:https';
|
||||||
import type { LookupFunction } from 'node:net';
|
import type { LookupFunction } from 'node:net';
|
||||||
|
|
@ -81,6 +81,10 @@ export type NodeAgentOptions = https.AgentOptions;
|
||||||
* therefore has no effect and is rejected to avoid a false sense of control over
|
* therefore has no effect and is rejected to avoid a false sense of control over
|
||||||
* DNS resolution.
|
* DNS resolution.
|
||||||
*
|
*
|
||||||
|
* `agentOptions.socketPath` is rejected for a related reason: it replaces the
|
||||||
|
* connection target, leaving the request's host and port unused, so nothing the
|
||||||
|
* policy decides applies to the socket that opens.
|
||||||
|
*
|
||||||
* `agentOptions` describes the connection to the **target**. Behind a proxy its TLS
|
* `agentOptions` describes the connection to the **target**. Behind a proxy its TLS
|
||||||
* options travel with the tunnelled session rather than the proxy handshake
|
* options travel with the tunnelled session rather than the proxy handshake
|
||||||
* (see `proxy/proxied-agents.ts`).
|
* (see `proxy/proxied-agents.ts`).
|
||||||
|
|
@ -91,11 +95,17 @@ export function buildNodeAgents(
|
||||||
agentOptions?: NodeAgentOptions,
|
agentOptions?: NodeAgentOptions,
|
||||||
): { httpAgent: http.Agent; httpsAgent: https.Agent } {
|
): { httpAgent: http.Agent; httpsAgent: https.Agent } {
|
||||||
if (agentOptions?.lookup) {
|
if (agentOptions?.lookup) {
|
||||||
throw new UnexpectedError(
|
throw new UserError(
|
||||||
'`agentOptions.lookup` is not supported: DNS resolution is managed by the SSRF policy. Remove it from `agentOptions`.',
|
'`agentOptions.lookup` is not supported: DNS resolution is managed by the SSRF policy. Remove it from `agentOptions`.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (agentOptions !== undefined && 'socketPath' in agentOptions && agentOptions.socketPath) {
|
||||||
|
throw new UserError(
|
||||||
|
'`agentOptions.socketPath` is not supported. Remove it from `agentOptions`.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const lookup: LookupFunction | undefined =
|
const lookup: LookupFunction | undefined =
|
||||||
ssrf !== 'disabled' ? ssrf.createSecureLookup() : undefined;
|
ssrf !== 'disabled' ? ssrf.createSecureLookup() : undefined;
|
||||||
|
|
||||||
|
|
@ -132,15 +142,16 @@ export function buildNodeAgents(
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Installs {@link installConnectionGuard} on a direct-path agent pair when SSRF protection is active.
|
* Installs {@link installConnectionGuard} on a direct-path agent pair.
|
||||||
|
*
|
||||||
|
* It goes on whatever the SSRF setting, because it also refuses a socket path, which
|
||||||
|
* is not an SSRF decision.
|
||||||
*/
|
*/
|
||||||
function applyConnectionGuard(
|
function applyConnectionGuard(
|
||||||
agents: { httpAgent: http.Agent; httpsAgent: https.Agent },
|
agents: { httpAgent: http.Agent; httpsAgent: https.Agent },
|
||||||
ssrf: SsrfOption,
|
ssrf: SsrfOption,
|
||||||
): { httpAgent: http.Agent; httpsAgent: https.Agent } {
|
): { httpAgent: http.Agent; httpsAgent: https.Agent } {
|
||||||
if (ssrf !== 'disabled') {
|
installConnectionGuard(agents.httpAgent, ssrf);
|
||||||
installConnectionGuard(agents.httpAgent, ssrf);
|
installConnectionGuard(agents.httpsAgent, ssrf);
|
||||||
installConnectionGuard(agents.httpsAgent, ssrf);
|
|
||||||
}
|
|
||||||
return agents;
|
return agents;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/backend-services",
|
"name": "@n8n/backend-services",
|
||||||
"version": "1.4.0",
|
"version": "1.4.1",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"clean": "rimraf dist .turbo",
|
"clean": "rimraf dist .turbo",
|
||||||
"dev": "pnpm watch",
|
"dev": "pnpm watch",
|
||||||
|
|
|
||||||
|
|
@ -35,14 +35,17 @@ describe('WorkflowProjectService', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('loads the owner project when the cached value is malformed', async () => {
|
it.each(['invalid', {}])(
|
||||||
const project = { id: 'project-1' } as Project;
|
'loads the owner project when the cached value is malformed',
|
||||||
cacheService.getHashValue.mockResolvedValue('invalid' as never);
|
async (cached) => {
|
||||||
sharedWorkflowRepository.findOneOrFail.mockResolvedValue({ project } as never);
|
const project = { id: 'project-1' } as Project;
|
||||||
|
cacheService.getHashValue.mockResolvedValue(cached as never);
|
||||||
|
sharedWorkflowRepository.findOneOrFail.mockResolvedValue({ project } as never);
|
||||||
|
|
||||||
await expect(service.getWorkflowProjectCached('workflow-1')).resolves.toBe(project);
|
await expect(service.getWorkflowProjectCached('workflow-1')).resolves.toBe(project);
|
||||||
expect(sharedWorkflowRepository.findOneOrFail).toHaveBeenCalledOnce();
|
expect(sharedWorkflowRepository.findOneOrFail).toHaveBeenCalledOnce();
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
it('sets the cached owner project', async () => {
|
it('sets the cached owner project', async () => {
|
||||||
const project = { id: 'project-1' } as Project;
|
const project = { id: 'project-1' } as Project;
|
||||||
|
|
|
||||||
|
|
@ -338,6 +338,27 @@ for (const backend of ['memory', 'redis'] as const) {
|
||||||
|
|
||||||
await expect(cacheService.getHashValue('key', 'field1')).resolves.toBe('value1');
|
await expect(cacheService.getHashValue('key', 'field1')).resolves.toBe('value1');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (backend === 'memory') {
|
||||||
|
test('should not return inherited properties as hash field values', async () => {
|
||||||
|
await cacheService.setHash('key', { field1: { id: 'value1' } });
|
||||||
|
|
||||||
|
await expect(cacheService.getHashValue('key', '__proto__')).resolves.toBeUndefined();
|
||||||
|
await expect(cacheService.getHashValue('key', 'constructor')).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should not store a `__proto__` hash field', async () => {
|
||||||
|
await cacheService.setHash('key', { field1: { id: 'value1' } });
|
||||||
|
// A computed key creates an own `__proto__` field, as a hash keyed by a workflow id does
|
||||||
|
await cacheService.setHash('key', { ['__proto__']: { id: 'value2' } });
|
||||||
|
|
||||||
|
expect(Object.getPrototypeOf(await cacheService.get('key'))).toBe(Object.prototype);
|
||||||
|
await expect(cacheService.getHashValue('key', '__proto__')).resolves.toBeUndefined();
|
||||||
|
await expect(cacheService.getHashValue('key', 'field1')).resolves.toEqual({
|
||||||
|
id: 'value1',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -151,7 +151,15 @@ export class CacheService extends TypedEmitter<CacheEvents> {
|
||||||
|
|
||||||
const hashObject: Hash = (await this.get(key)) ?? {};
|
const hashObject: Hash = (await this.get(key)) ?? {};
|
||||||
|
|
||||||
Object.assign(hashObject, hash);
|
for (const [field, value] of Object.entries(hash)) {
|
||||||
|
// Skip `__proto__`. On a plain object, `hash['__proto__'] = value` does not add a field.
|
||||||
|
// It changes the object's prototype.
|
||||||
|
if (field === '__proto__') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
hashObject[field] = value;
|
||||||
|
}
|
||||||
|
|
||||||
await this.set(key, hashObject);
|
await this.set(key, hashObject);
|
||||||
}
|
}
|
||||||
|
|
@ -309,7 +317,10 @@ export class CacheService extends TypedEmitter<CacheEvents> {
|
||||||
} else {
|
} else {
|
||||||
const hashObject = await this.cache.store.get<Hash<T>>(cacheKey);
|
const hashObject = await this.cache.store.get<Hash<T>>(cacheKey);
|
||||||
|
|
||||||
hashValue = hashObject?.[hashKey] as MaybeHash<T>;
|
hashValue =
|
||||||
|
hashObject && Object.hasOwn(hashObject, hashKey)
|
||||||
|
? (hashObject[hashKey] as MaybeHash<T>)
|
||||||
|
: undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hashValue !== undefined) {
|
if (hashValue !== undefined) {
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,9 @@ export class WorkflowProjectService {
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private reconstructProject(project: Partial<Project>): Project | undefined {
|
private reconstructProject(project: Partial<Project>): Project | undefined {
|
||||||
if (typeof project !== 'object' || project === null) return undefined;
|
if (typeof project !== 'object' || project === null || typeof project.id !== 'string') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
return Object.assign(new Project(), project);
|
return Object.assign(new Project(), project);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/backend-test-utils",
|
"name": "@n8n/backend-test-utils",
|
||||||
"version": "1.44.0",
|
"version": "1.44.1",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"clean": "rimraf dist .turbo",
|
"clean": "rimraf dist .turbo",
|
||||||
"dev": "pnpm watch",
|
"dev": "pnpm watch",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/blob-storage",
|
"name": "@n8n/blob-storage",
|
||||||
"version": "1.12.0",
|
"version": "1.12.1",
|
||||||
"description": "Blob storage layer for n8n, with filesystem, S3, and Azure Blob backends",
|
"description": "Blob storage layer for n8n, with filesystem, S3, and Azure Blob backends",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"clean": "rimraf dist .turbo",
|
"clean": "rimraf dist .turbo",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/chat-hub",
|
"name": "@n8n/chat-hub",
|
||||||
"version": "1.37.0",
|
"version": "1.37.1",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"clean": "rimraf dist .turbo",
|
"clean": "rimraf dist .turbo",
|
||||||
"dev": "pnpm watch",
|
"dev": "pnpm watch",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/client-oauth2",
|
"name": "@n8n/client-oauth2",
|
||||||
"version": "1.26.0",
|
"version": "1.26.1",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"clean": "rimraf dist .turbo",
|
"clean": "rimraf dist .turbo",
|
||||||
"dev": "pnpm watch",
|
"dev": "pnpm watch",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/create-node",
|
"name": "@n8n/create-node",
|
||||||
"version": "0.52.0",
|
"version": "0.52.1",
|
||||||
"description": "Official CLI to create new community nodes for n8n",
|
"description": "Official CLI to create new community nodes for n8n",
|
||||||
"bin": {
|
"bin": {
|
||||||
"create-node": "bin/create-node.cjs"
|
"create-node": "bin/create-node.cjs"
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/db",
|
"name": "@n8n/db",
|
||||||
"version": "1.44.0",
|
"version": "1.44.1",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"clean": "rimraf dist .turbo .tmp-schema-docs",
|
"clean": "rimraf dist .turbo .tmp-schema-docs",
|
||||||
"gen:migration-index": "node scripts/generate-migration-index.mjs",
|
"gen:migration-index": "node scripts/generate-migration-index.mjs",
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { GlobalConfig } from '@n8n/config';
|
import { GlobalConfig } from '@n8n/config';
|
||||||
import { In, IsNull, MoreThan, Not, type SelectQueryBuilder } from '@n8n/typeorm';
|
import { In, IsNull, MoreThan, Not, type SelectQueryBuilder } from '@n8n/typeorm';
|
||||||
|
import type { IWorkflowSettings } from 'n8n-workflow';
|
||||||
import type { Mock, Mocked } from 'vitest';
|
import type { Mock, Mocked } from 'vitest';
|
||||||
import { mock } from 'vitest-mock-extended';
|
import { mock } from 'vitest-mock-extended';
|
||||||
|
|
||||||
|
|
@ -840,4 +841,270 @@ describe('WorkflowRepository', () => {
|
||||||
expect(mockExecute).toHaveBeenCalled();
|
expect(mockExecute).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('caller allowlist purge', () => {
|
||||||
|
let writes: Array<{ id: string; callerIds: string }>;
|
||||||
|
let writtenSettings: Array<IWorkflowSettings | undefined>;
|
||||||
|
let selectQb: { where: Mock; andWhere: Mock; setLock: Mock };
|
||||||
|
|
||||||
|
const stubCandidates = (candidates: Array<Partial<WorkflowEntity>>) => {
|
||||||
|
writes = [];
|
||||||
|
writtenSettings = [];
|
||||||
|
|
||||||
|
const select = {
|
||||||
|
select: vi.fn(),
|
||||||
|
where: vi.fn(),
|
||||||
|
andWhere: vi.fn(),
|
||||||
|
setLock: vi.fn(),
|
||||||
|
getMany: vi.fn().mockResolvedValue(candidates),
|
||||||
|
};
|
||||||
|
select.select.mockReturnValue(select);
|
||||||
|
select.where.mockReturnValue(select);
|
||||||
|
select.andWhere.mockReturnValue(select);
|
||||||
|
select.setLock.mockReturnValue(select);
|
||||||
|
|
||||||
|
let pendingId = '';
|
||||||
|
let pendingCallerIds = '';
|
||||||
|
let pendingSettings: IWorkflowSettings | undefined;
|
||||||
|
const update = {
|
||||||
|
update: vi.fn(),
|
||||||
|
set: vi.fn(),
|
||||||
|
where: vi.fn(),
|
||||||
|
setParameter: vi.fn(),
|
||||||
|
execute: vi.fn(async () => {
|
||||||
|
writes.push({ id: pendingId, callerIds: pendingCallerIds });
|
||||||
|
writtenSettings.push(pendingSettings);
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
update.update.mockReturnValue(update);
|
||||||
|
// Postgres writes the whole document, SQLite the one key through a parameter.
|
||||||
|
update.set.mockImplementation((values: { settings: unknown }) => {
|
||||||
|
if (typeof values.settings === 'object' && values.settings !== null) {
|
||||||
|
pendingSettings = values.settings as IWorkflowSettings;
|
||||||
|
pendingCallerIds = pendingSettings.callerIds ?? '';
|
||||||
|
}
|
||||||
|
return update;
|
||||||
|
});
|
||||||
|
update.where.mockImplementation((_sql: string, parameters: { workflowId: string }) => {
|
||||||
|
pendingId = parameters.workflowId;
|
||||||
|
return update;
|
||||||
|
});
|
||||||
|
update.setParameter.mockImplementation((_name: string, value: string) => {
|
||||||
|
pendingCallerIds = value;
|
||||||
|
return update;
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mocked(entityManager.createQueryBuilder).mockImplementation(((...args: unknown[]) =>
|
||||||
|
args.length > 0 ? select : update) as never);
|
||||||
|
|
||||||
|
transactionRunner.run.mockImplementation(
|
||||||
|
async (ctx: unknown, fn: (c: unknown) => Promise<unknown>) => await fn(ctx),
|
||||||
|
);
|
||||||
|
|
||||||
|
selectQb = select;
|
||||||
|
return select;
|
||||||
|
};
|
||||||
|
|
||||||
|
it('removes the entry and keeps the rest in order, whatever whitespace separates them', async () => {
|
||||||
|
stubCandidates([
|
||||||
|
{
|
||||||
|
id: 'subworkflow-1',
|
||||||
|
settings: { callerPolicy: 'workflowsFromAList', callerIds: 'first,\n second , ,\tthird' },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
await workflowRepository.removeFromCallerAllowlists(['second'], {});
|
||||||
|
|
||||||
|
expect(writes).toEqual([{ id: 'subworkflow-1', callerIds: 'first,third' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rewrites only the allowlists that hold the id as an entry of its own', async () => {
|
||||||
|
stubCandidates([
|
||||||
|
{
|
||||||
|
id: 'subworkflow-1',
|
||||||
|
settings: { callerPolicy: 'workflowsFromAList', callerIds: 'abc,abcdef' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'subworkflow-2',
|
||||||
|
settings: { callerPolicy: 'workflowsFromAList', callerIds: 'abcdef' },
|
||||||
|
},
|
||||||
|
{ id: 'subworkflow-3', settings: { callerPolicy: 'workflowsFromAList' } },
|
||||||
|
]);
|
||||||
|
|
||||||
|
await workflowRepository.removeFromCallerAllowlists(['abc'], {});
|
||||||
|
|
||||||
|
expect(writes).toEqual([{ id: 'subworkflow-1', callerIds: 'abcdef' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves an allowlist alone when the id only appears inside another entry', async () => {
|
||||||
|
stubCandidates([
|
||||||
|
{
|
||||||
|
id: 'subworkflow-1',
|
||||||
|
settings: { callerPolicy: 'workflowsFromAList', callerIds: 'abc-1, abc-2' },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
await workflowRepository.removeFromCallerAllowlists(['abc'], {});
|
||||||
|
|
||||||
|
expect(writes).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rewrites every workflow that names the id', async () => {
|
||||||
|
stubCandidates([
|
||||||
|
{
|
||||||
|
id: 'subworkflow-1',
|
||||||
|
settings: { callerPolicy: 'workflowsFromAList', callerIds: 'first,second' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'subworkflow-2',
|
||||||
|
settings: { callerPolicy: 'workflowsFromAList', callerIds: 'second,third' },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
await workflowRepository.removeFromCallerAllowlists(['second'], {});
|
||||||
|
|
||||||
|
expect(writes).toEqual([
|
||||||
|
{ id: 'subworkflow-1', callerIds: 'first' },
|
||||||
|
{ id: 'subworkflow-2', callerIds: 'third' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('takes every id out of one allowlist in a single write', async () => {
|
||||||
|
stubCandidates([
|
||||||
|
{
|
||||||
|
id: 'subworkflow-1',
|
||||||
|
settings: { callerPolicy: 'workflowsFromAList', callerIds: 'first,second,third' },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
await workflowRepository.removeFromCallerAllowlists(['first', 'third'], {});
|
||||||
|
|
||||||
|
expect(writes).toEqual([{ id: 'subworkflow-1', callerIds: 'second' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads the candidates once, whatever the batch size, and locks only the ones it rewrites', async () => {
|
||||||
|
const select = stubCandidates([
|
||||||
|
{
|
||||||
|
id: 'subworkflow-1',
|
||||||
|
settings: { callerPolicy: 'workflowsFromAList', callerIds: 'one,first' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'subworkflow-2',
|
||||||
|
settings: { callerPolicy: 'workflowsFromAList', callerIds: 'first' },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
await workflowRepository.removeFromCallerAllowlists(['one', 'two'], {});
|
||||||
|
|
||||||
|
expect(select.where).toHaveBeenCalledTimes(2);
|
||||||
|
expect(select.where).toHaveBeenNthCalledWith(1, expect.stringContaining('callerIds'));
|
||||||
|
expect(select.where).toHaveBeenNthCalledWith(2, 'workflow.id IN (:...ids)', {
|
||||||
|
ids: ['subworkflow-1'],
|
||||||
|
});
|
||||||
|
expect(select.setLock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(select.setLock).toHaveBeenCalledWith('pessimistic_write');
|
||||||
|
expect(writes).toEqual([{ id: 'subworkflow-1', callerIds: 'first' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('locks nothing when no allowlist names a removed id', async () => {
|
||||||
|
const select = stubCandidates([
|
||||||
|
{
|
||||||
|
id: 'subworkflow-1',
|
||||||
|
settings: { callerPolicy: 'workflowsFromAList', callerIds: 'first' },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
await workflowRepository.removeFromCallerAllowlists(['one'], {});
|
||||||
|
|
||||||
|
expect(select.setLock).not.toHaveBeenCalled();
|
||||||
|
expect(writes).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches candidates on the stored document, never on a value read out of it', async () => {
|
||||||
|
const select = stubCandidates([]);
|
||||||
|
|
||||||
|
await workflowRepository.removeFromCallerAllowlists(['one'], {});
|
||||||
|
|
||||||
|
expect(select.where).toHaveBeenCalledWith('workflow.settings::text LIKE \'%"callerIds"%\'');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a value the binary JSON form has no room for', async () => {
|
||||||
|
const noBinaryJsonForm = String.fromCharCode(0);
|
||||||
|
stubCandidates([
|
||||||
|
{
|
||||||
|
id: 'subworkflow-1',
|
||||||
|
settings: {
|
||||||
|
callerPolicy: 'workflowsFromAList',
|
||||||
|
callerIds: 'first,second',
|
||||||
|
errorWorkflow: `error-workflow${noBinaryJsonForm}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
await workflowRepository.removeFromCallerAllowlists(['second'], {});
|
||||||
|
|
||||||
|
expect(writtenSettings).toEqual([
|
||||||
|
{
|
||||||
|
callerPolicy: 'workflowsFromAList',
|
||||||
|
callerIds: 'first',
|
||||||
|
errorWorkflow: `error-workflow${noBinaryJsonForm}`,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('purges a row that exists at an id being removed', async () => {
|
||||||
|
stubCandidates([
|
||||||
|
{
|
||||||
|
id: 'recreated-1',
|
||||||
|
settings: { callerPolicy: 'workflowsFromAList', callerIds: 'first,second' },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
await workflowRepository.removeFromCallerAllowlists(['recreated-1', 'second'], {});
|
||||||
|
|
||||||
|
expect(writes).toEqual([{ id: 'recreated-1', callerIds: 'first' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('writes nothing when given no ids', async () => {
|
||||||
|
stubCandidates([]);
|
||||||
|
|
||||||
|
await workflowRepository.removeFromCallerAllowlists([], {});
|
||||||
|
|
||||||
|
expect(selectQb.where).not.toHaveBeenCalled();
|
||||||
|
expect(writes).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes only the entries naming an id without a row', async () => {
|
||||||
|
stubCandidates([
|
||||||
|
{
|
||||||
|
id: 'subworkflow-1',
|
||||||
|
settings: { callerPolicy: 'workflowsFromAList', callerIds: 'deleted,survivor,other' },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
vi.mocked(entityManager.find).mockResolvedValue([{ id: 'survivor' }, { id: 'other' }]);
|
||||||
|
|
||||||
|
await workflowRepository.removeMissingFromCallerAllowlists({});
|
||||||
|
|
||||||
|
expect(entityManager.find).toHaveBeenCalledWith(WorkflowEntity, {
|
||||||
|
select: ['id'],
|
||||||
|
where: { id: In(['deleted', 'survivor', 'other']) },
|
||||||
|
});
|
||||||
|
expect(writes).toEqual([{ id: 'subworkflow-1', callerIds: 'survivor,other' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('locks and writes nothing when every named id has a row', async () => {
|
||||||
|
const select = stubCandidates([
|
||||||
|
{
|
||||||
|
id: 'subworkflow-1',
|
||||||
|
settings: { callerPolicy: 'workflowsFromAList', callerIds: 'first,second' },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
vi.mocked(entityManager.find).mockResolvedValue([{ id: 'first' }, { id: 'second' }]);
|
||||||
|
|
||||||
|
await workflowRepository.removeMissingFromCallerAllowlists({});
|
||||||
|
|
||||||
|
expect(select.setLock).not.toHaveBeenCalled();
|
||||||
|
expect(writes).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -454,6 +454,136 @@ export class WorkflowRepository extends BaseRepository<WorkflowEntity> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Remove every id in `workflowIds` from the `callerIds` allowlist of every other workflow. */
|
||||||
|
async removeFromCallerAllowlists(workflowIds: string[], ctx: OperationContext): Promise<void> {
|
||||||
|
if (workflowIds.length === 0) return;
|
||||||
|
|
||||||
|
await this.runInTransaction(ctx, async (tx) => {
|
||||||
|
const candidates = await this.findCallerAllowlistCandidates(tx);
|
||||||
|
await this.purgeCallerAllowlists(candidates, new Set(workflowIds), tx);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove every id that has no workflow row from every `callerIds` allowlist. */
|
||||||
|
async removeMissingFromCallerAllowlists(ctx: OperationContext): Promise<void> {
|
||||||
|
await this.runInTransaction(ctx, async (tx) => {
|
||||||
|
const candidates = await this.findCallerAllowlistCandidates(tx);
|
||||||
|
const namedIds = [
|
||||||
|
...new Set(candidates.flatMap((workflow) => this.callerIdEntries(workflow))),
|
||||||
|
];
|
||||||
|
|
||||||
|
const existingIds = new Set<string>();
|
||||||
|
for (const chunk of chunkIds(namedIds)) {
|
||||||
|
const existing = await tx.find(WorkflowEntity, {
|
||||||
|
select: ['id'],
|
||||||
|
where: { id: In(chunk) },
|
||||||
|
});
|
||||||
|
for (const { id } of existing) existingIds.add(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const missingIds = new Set(namedIds.filter((id) => !existingIds.has(id)));
|
||||||
|
if (missingIds.size > 0) await this.purgeCallerAllowlists(candidates, missingIds, tx);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rewrite the allowlists among `candidates` that name any of `removedIds`, under a row lock. */
|
||||||
|
private async purgeCallerAllowlists(
|
||||||
|
candidates: WorkflowEntity[],
|
||||||
|
removedIds: Set<string>,
|
||||||
|
tx: EntityManager,
|
||||||
|
): Promise<void> {
|
||||||
|
const affected = candidates.filter(
|
||||||
|
(workflow) => this.callerIdsWithout(workflow, removedIds) !== undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const workflow of await this.lockCallerAllowlists(affected, tx)) {
|
||||||
|
const remaining = this.callerIdsWithout(workflow, removedIds);
|
||||||
|
if (remaining !== undefined) await this.writeCallerIds(workflow, remaining, tx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The non-empty, trimmed entries of the `callerIds` allowlist of `workflow`. */
|
||||||
|
private callerIdEntries(workflow: WorkflowEntity): string[] {
|
||||||
|
const callerIds = workflow.settings?.callerIds;
|
||||||
|
if (typeof callerIds !== 'string') return [];
|
||||||
|
|
||||||
|
return callerIds
|
||||||
|
.split(',')
|
||||||
|
.map((callerId) => callerId.trim())
|
||||||
|
.filter((callerId) => callerId !== '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The allowlist of `workflow` without `removedIds`, or `undefined` when it names none of them. */
|
||||||
|
private callerIdsWithout(workflow: WorkflowEntity, removedIds: Set<string>): string | undefined {
|
||||||
|
const entries = this.callerIdEntries(workflow);
|
||||||
|
if (!entries.some((callerId) => removedIds.has(callerId))) return undefined;
|
||||||
|
|
||||||
|
return entries.filter((callerId) => !removedIds.has(callerId)).join(',');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Find every workflow that has a `callerIds` allowlist. */
|
||||||
|
private async findCallerAllowlistCandidates(tx: EntityManager): Promise<WorkflowEntity[]> {
|
||||||
|
// On Postgres, match the raw text rather than extract: the `json` column accepts characters,
|
||||||
|
// such as an escaped NUL, that extracting `callerIds` rejects with `unsupported Unicode
|
||||||
|
// escape sequence`, which fails the query for every row.
|
||||||
|
const hasAllowlist =
|
||||||
|
this.globalConfig.database.type === 'postgresdb'
|
||||||
|
? 'workflow.settings::text LIKE \'%"callerIds"%\''
|
||||||
|
: `${this.settingsTextValue('workflow.settings', 'callerIds')} IS NOT NULL`;
|
||||||
|
|
||||||
|
return await tx
|
||||||
|
.createQueryBuilder(WorkflowEntity, 'workflow')
|
||||||
|
.select(['workflow.id', 'workflow.settings'])
|
||||||
|
.where(hasAllowlist)
|
||||||
|
.getMany();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lock `workflows` until the transaction ends and re-read their settings under the lock. */
|
||||||
|
private async lockCallerAllowlists(
|
||||||
|
workflows: WorkflowEntity[],
|
||||||
|
tx: EntityManager,
|
||||||
|
): Promise<WorkflowEntity[]> {
|
||||||
|
// SQLite serialises writes, so what it read is already current.
|
||||||
|
if (this.globalConfig.database.type !== 'postgresdb') return workflows;
|
||||||
|
|
||||||
|
const locked: WorkflowEntity[] = [];
|
||||||
|
for (const chunk of chunkIds(workflows.map(({ id }) => id))) {
|
||||||
|
locked.push(
|
||||||
|
...(await tx
|
||||||
|
.createQueryBuilder(WorkflowEntity, 'workflow')
|
||||||
|
.select(['workflow.id', 'workflow.settings'])
|
||||||
|
.where('workflow.id IN (:...ids)', { ids: chunk })
|
||||||
|
.setLock('pessimistic_write')
|
||||||
|
.getMany()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return locked;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Store `callerIds` back on one workflow, keeping its other settings. */
|
||||||
|
private async writeCallerIds(
|
||||||
|
workflow: WorkflowEntity,
|
||||||
|
callerIds: string,
|
||||||
|
tx: EntityManager,
|
||||||
|
): Promise<void> {
|
||||||
|
const update = tx
|
||||||
|
.createQueryBuilder()
|
||||||
|
.update(WorkflowEntity)
|
||||||
|
.where('id = :workflowId', { workflowId: workflow.id });
|
||||||
|
|
||||||
|
if (this.globalConfig.database.type === 'postgresdb') {
|
||||||
|
// `jsonb_set` rejects characters the `json` column accepts, so rewrite the whole document
|
||||||
|
// read under the row lock.
|
||||||
|
await update.set({ settings: { ...workflow.settings, callerIds } }).execute();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await update
|
||||||
|
.set({ settings: () => "JSON_SET(COALESCE(settings, '{}'), '$.callerIds', :callerIds)" })
|
||||||
|
.setParameter('callerIds', callerIds)
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
|
||||||
async findById(workflowId: string) {
|
async findById(workflowId: string) {
|
||||||
return await this.findOne({
|
return await this.findOne({
|
||||||
where: { id: workflowId },
|
where: { id: workflowId },
|
||||||
|
|
@ -864,7 +994,6 @@ export class WorkflowRepository extends BaseRepository<WorkflowEntity> {
|
||||||
onlySharedWithMe?: boolean;
|
onlySharedWithMe?: boolean;
|
||||||
},
|
},
|
||||||
options: WorkflowListQueryOptions = {},
|
options: WorkflowListQueryOptions = {},
|
||||||
callableForParentWorkflowId?: string,
|
|
||||||
) {
|
) {
|
||||||
if (
|
if (
|
||||||
options.filter?.parentFolderId &&
|
options.filter?.parentFolderId &&
|
||||||
|
|
@ -884,18 +1013,8 @@ export class WorkflowRepository extends BaseRepository<WorkflowEntity> {
|
||||||
}
|
}
|
||||||
|
|
||||||
const [workflowsAndFolders, count] = await Promise.all([
|
const [workflowsAndFolders, count] = await Promise.all([
|
||||||
this.getWorkflowsAndFoldersUnionWithSharingSubquery(
|
this.getWorkflowsAndFoldersUnionWithSharingSubquery(user, sharingOptions, options),
|
||||||
user,
|
this.getWorkflowsAndFoldersCountWithSharingSubquery(user, sharingOptions, options),
|
||||||
sharingOptions,
|
|
||||||
options,
|
|
||||||
callableForParentWorkflowId,
|
|
||||||
),
|
|
||||||
this.getWorkflowsAndFoldersCountWithSharingSubquery(
|
|
||||||
user,
|
|
||||||
sharingOptions,
|
|
||||||
options,
|
|
||||||
callableForParentWorkflowId,
|
|
||||||
),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const isArchived =
|
const isArchived =
|
||||||
|
|
@ -922,15 +1041,9 @@ export class WorkflowRepository extends BaseRepository<WorkflowEntity> {
|
||||||
onlySharedWithMe?: boolean;
|
onlySharedWithMe?: boolean;
|
||||||
},
|
},
|
||||||
options: WorkflowListQueryOptions = {},
|
options: WorkflowListQueryOptions = {},
|
||||||
callableForParentWorkflowId?: string,
|
|
||||||
) {
|
) {
|
||||||
const { baseQuery, sortByColumn, sortByDirection } =
|
const { baseQuery, sortByColumn, sortByDirection } =
|
||||||
this.buildBaseUnionQueryWithSharingSubquery(
|
this.buildBaseUnionQueryWithSharingSubquery(user, sharingOptions, options);
|
||||||
user,
|
|
||||||
sharingOptions,
|
|
||||||
options,
|
|
||||||
callableForParentWorkflowId,
|
|
||||||
);
|
|
||||||
|
|
||||||
const query = this.buildUnionQuery(baseQuery, {
|
const query = this.buildUnionQuery(baseQuery, {
|
||||||
sortByColumn,
|
sortByColumn,
|
||||||
|
|
@ -956,7 +1069,6 @@ export class WorkflowRepository extends BaseRepository<WorkflowEntity> {
|
||||||
onlySharedWithMe?: boolean;
|
onlySharedWithMe?: boolean;
|
||||||
},
|
},
|
||||||
options: WorkflowListQueryOptions = {},
|
options: WorkflowListQueryOptions = {},
|
||||||
callableForParentWorkflowId?: string,
|
|
||||||
) {
|
) {
|
||||||
const { skip, take, ...baseQueryParameters } = options;
|
const { skip, take, ...baseQueryParameters } = options;
|
||||||
|
|
||||||
|
|
@ -964,7 +1076,6 @@ export class WorkflowRepository extends BaseRepository<WorkflowEntity> {
|
||||||
user,
|
user,
|
||||||
sharingOptions,
|
sharingOptions,
|
||||||
baseQueryParameters,
|
baseQueryParameters,
|
||||||
callableForParentWorkflowId,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const response = await baseQuery
|
const response = await baseQuery
|
||||||
|
|
@ -986,7 +1097,6 @@ export class WorkflowRepository extends BaseRepository<WorkflowEntity> {
|
||||||
onlySharedWithMe?: boolean;
|
onlySharedWithMe?: boolean;
|
||||||
},
|
},
|
||||||
options: WorkflowListQueryOptions = {},
|
options: WorkflowListQueryOptions = {},
|
||||||
callableForParentWorkflowId?: string,
|
|
||||||
) {
|
) {
|
||||||
// Common fields for both folders and workflows
|
// Common fields for both folders and workflows
|
||||||
const commonFields = {
|
const commonFields = {
|
||||||
|
|
@ -1036,7 +1146,6 @@ export class WorkflowRepository extends BaseRepository<WorkflowEntity> {
|
||||||
user,
|
user,
|
||||||
sharingOptions,
|
sharingOptions,
|
||||||
workflowQueryParameters,
|
workflowQueryParameters,
|
||||||
callableForParentWorkflowId,
|
|
||||||
).addSelect("'workflow'", 'resource');
|
).addSelect("'workflow'", 'resource');
|
||||||
|
|
||||||
const qb = this.manager.createQueryBuilder();
|
const qb = this.manager.createQueryBuilder();
|
||||||
|
|
@ -1155,14 +1264,8 @@ export class WorkflowRepository extends BaseRepository<WorkflowEntity> {
|
||||||
onlySharedWithMe?: boolean;
|
onlySharedWithMe?: boolean;
|
||||||
},
|
},
|
||||||
options: WorkflowListQueryOptions = {},
|
options: WorkflowListQueryOptions = {},
|
||||||
callableForParentWorkflowId?: string,
|
|
||||||
): Promise<WorkflowListResult> {
|
): Promise<WorkflowListResult> {
|
||||||
const query = this.getManyQueryWithSharingSubquery(
|
const query = this.getManyQueryWithSharingSubquery(user, sharingOptions, options);
|
||||||
user,
|
|
||||||
sharingOptions,
|
|
||||||
options,
|
|
||||||
callableForParentWorkflowId,
|
|
||||||
);
|
|
||||||
|
|
||||||
const [workflows, count] = await query.getManyAndCount();
|
const [workflows, count] = await query.getManyAndCount();
|
||||||
return { workflows, count };
|
return { workflows, count };
|
||||||
|
|
@ -1182,7 +1285,6 @@ export class WorkflowRepository extends BaseRepository<WorkflowEntity> {
|
||||||
onlySharedWithMe?: boolean;
|
onlySharedWithMe?: boolean;
|
||||||
},
|
},
|
||||||
options: WorkflowListQueryOptions = {},
|
options: WorkflowListQueryOptions = {},
|
||||||
callableForParentWorkflowId?: string,
|
|
||||||
): SelectQueryBuilder<WorkflowEntity> {
|
): SelectQueryBuilder<WorkflowEntity> {
|
||||||
const qb = this.createQueryBuilder('workflow');
|
const qb = this.createQueryBuilder('workflow');
|
||||||
|
|
||||||
|
|
@ -1200,21 +1302,9 @@ export class WorkflowRepository extends BaseRepository<WorkflowEntity> {
|
||||||
sharingOptionsWithProjectId,
|
sharingOptionsWithProjectId,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (callableForParentWorkflowId) {
|
// Apply the sharing filter using the subquery
|
||||||
// Union: workflows readable by the user OR callable by the parent workflow.
|
qb.andWhere(`workflow.id IN (${sharedWorkflowSubquery.getQuery()})`);
|
||||||
const callableSubquery = this.buildCallablePolicySubquery(callableForParentWorkflowId);
|
qb.setParameters(sharedWorkflowSubquery.getParameters());
|
||||||
qb.andWhere(
|
|
||||||
`(workflow.id IN (${sharedWorkflowSubquery.getQuery()}) OR workflow.id IN (${callableSubquery.getQuery()}))`,
|
|
||||||
);
|
|
||||||
qb.setParameters({
|
|
||||||
...sharedWorkflowSubquery.getParameters(),
|
|
||||||
...callableSubquery.getParameters(),
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// Apply the sharing filter using the subquery
|
|
||||||
qb.andWhere(`workflow.id IN (${sharedWorkflowSubquery.getQuery()})`);
|
|
||||||
qb.setParameters(sharedWorkflowSubquery.getParameters());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply other filters
|
// Apply other filters
|
||||||
// For personal project and shared-with-me cases, projectId is already handled in the subquery
|
// For personal project and shared-with-me cases, projectId is already handled in the subquery
|
||||||
|
|
@ -1238,70 +1328,98 @@ export class WorkflowRepository extends BaseRepository<WorkflowEntity> {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build a subquery returning IDs of workflows whose callerPolicy permits the
|
* List the workflows that `parentWorkflowId` can call as sub-workflows. The
|
||||||
* given `parentWorkflowId` to call them.
|
* list holds the workflows the user can read, plus the workflows whose
|
||||||
|
* caller policy names the parent explicitly (`any`, or an allowlist that
|
||||||
|
* contains the parent id).
|
||||||
|
*
|
||||||
|
* Only a minimal set of columns is loaded, because the callable rows can
|
||||||
|
* belong to projects that the user has no access to.
|
||||||
|
*/
|
||||||
|
async findCallableSubworkflows(
|
||||||
|
user: User,
|
||||||
|
sharingOptions: { scopes: Scope[]; projectRoles: string[]; workflowRoles: string[] },
|
||||||
|
parentWorkflowId: string,
|
||||||
|
options: { query?: string; triggerNodeTypes: string[]; skip: number; take: number },
|
||||||
|
): Promise<{ workflows: WorkflowEntity[]; count: number }> {
|
||||||
|
const sharedWorkflowSubquery = this.buildSharedWorkflowIdsSubquery(user, sharingOptions);
|
||||||
|
const callableSubquery = this.buildCallablePolicySubquery(parentWorkflowId);
|
||||||
|
|
||||||
|
const qb = this.createQueryBuilder('workflow')
|
||||||
|
.select([
|
||||||
|
'workflow.id',
|
||||||
|
'workflow.name',
|
||||||
|
'workflow.activeVersionId',
|
||||||
|
'workflow.isArchived',
|
||||||
|
'workflow.updatedAt',
|
||||||
|
])
|
||||||
|
.leftJoin('workflow.shared', 'owner', 'owner.role = :callableOwnerRole', {
|
||||||
|
callableOwnerRole: 'workflow:owner',
|
||||||
|
})
|
||||||
|
.leftJoin('owner.project', 'ownerProject')
|
||||||
|
.addSelect([
|
||||||
|
'owner.workflowId',
|
||||||
|
'owner.projectId',
|
||||||
|
'owner.role',
|
||||||
|
'ownerProject.id',
|
||||||
|
'ownerProject.type',
|
||||||
|
'ownerProject.name',
|
||||||
|
])
|
||||||
|
.where(
|
||||||
|
`(workflow.id IN (${sharedWorkflowSubquery.getQuery()}) OR workflow.id IN (${callableSubquery.getQuery()}))`,
|
||||||
|
)
|
||||||
|
.setParameters({
|
||||||
|
...sharedWorkflowSubquery.getParameters(),
|
||||||
|
...callableSubquery.getParameters(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Match names only: the list can hold workflows the user cannot read, so
|
||||||
|
// their descriptions must not affect the result.
|
||||||
|
const searchWords = this.parseSearchWords(options.query);
|
||||||
|
if (searchWords.length > 0) {
|
||||||
|
const { conditions, parameters } = this.buildSearchConditions(
|
||||||
|
searchWords,
|
||||||
|
'LOWER(workflow.name)',
|
||||||
|
);
|
||||||
|
qb.andWhere(`(${conditions.join(' AND ')})`, parameters);
|
||||||
|
}
|
||||||
|
this.applyTriggerNodeTypesFilter(qb, options.triggerNodeTypes);
|
||||||
|
qb.orderBy('workflow.updatedAt', 'DESC').skip(options.skip).take(options.take);
|
||||||
|
|
||||||
|
const [workflows, count] = await qb.getManyAndCount();
|
||||||
|
return { workflows, count };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a subquery returning IDs of workflows whose callerPolicy names the
|
||||||
|
* given `parentWorkflowId` explicitly.
|
||||||
|
*
|
||||||
|
* The `workflowsFromSameOwner` policy is left out on purpose: for a user who
|
||||||
|
* is not the owner, it would list workflows that the owner did not share.
|
||||||
|
* The owner can already read those workflows through the sharing subquery.
|
||||||
*/
|
*/
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
private buildCallablePolicySubquery(parentWorkflowId: string): SelectQueryBuilder<any> {
|
private buildCallablePolicySubquery(parentWorkflowId: string): SelectQueryBuilder<any> {
|
||||||
const subquery = this.manager
|
|
||||||
.createQueryBuilder()
|
|
||||||
.select('cpw.id')
|
|
||||||
.from(WorkflowEntity, 'cpw')
|
|
||||||
.leftJoin(
|
|
||||||
SharedWorkflow,
|
|
||||||
'sw_sub',
|
|
||||||
"sw_sub.workflowId = cpw.id AND sw_sub.role = 'workflow:owner'",
|
|
||||||
)
|
|
||||||
.leftJoin(
|
|
||||||
SharedWorkflow,
|
|
||||||
'sw_par',
|
|
||||||
"sw_par.workflowId = :cpParentWorkflowId AND sw_par.role = 'workflow:owner'",
|
|
||||||
);
|
|
||||||
|
|
||||||
const { conditions, params } = this.buildCallablePolicyConditions(parentWorkflowId);
|
|
||||||
|
|
||||||
subquery.where('(' + conditions.join(' OR ') + ')', params);
|
|
||||||
|
|
||||||
return subquery;
|
|
||||||
}
|
|
||||||
|
|
||||||
private buildCallablePolicyConditions(parentWorkflowId: string): {
|
|
||||||
conditions: string[];
|
|
||||||
params: Record<string, string>;
|
|
||||||
} {
|
|
||||||
const defaultPolicy = this.globalConfig.workflows.callerPolicyDefaultOption;
|
const defaultPolicy = this.globalConfig.workflows.callerPolicyDefaultOption;
|
||||||
const callerPolicy = this.settingsTextValue('cpw.settings', 'callerPolicy');
|
const callerPolicy = this.settingsTextValue('cpw.settings', 'callerPolicy');
|
||||||
const callerIds = this.settingsTextValue('cpw.settings', 'callerIds');
|
const callerIds = this.settingsTextValue('cpw.settings', 'callerIds');
|
||||||
|
|
||||||
const conditions: string[] = [];
|
const conditions = [
|
||||||
const params: Record<string, string> = {
|
`${callerPolicy} = 'any'`,
|
||||||
cpParentWorkflowId: parentWorkflowId,
|
// The allowlist must contain parentWorkflowId as a whole ID.
|
||||||
cpCallerIdMembership: `%,${escapeLike(parentWorkflowId)},%`,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Branch 1: callerPolicy = 'any'
|
|
||||||
conditions.push(`${callerPolicy} = 'any'`);
|
|
||||||
|
|
||||||
// Branch 2: callerPolicy = 'workflowsFromAList' and the allowlist contains parentWorkflowId as a whole ID.
|
|
||||||
conditions.push(
|
|
||||||
`(${callerPolicy} = 'workflowsFromAList' AND (',' || REPLACE(${callerIds}, ' ', '') || ',') LIKE :cpCallerIdMembership ${LIKE_ESCAPE_CLAUSE})`,
|
`(${callerPolicy} = 'workflowsFromAList' AND (',' || REPLACE(${callerIds}, ' ', '') || ',') LIKE :cpCallerIdMembership ${LIKE_ESCAPE_CLAUSE})`,
|
||||||
);
|
];
|
||||||
|
|
||||||
// Branch 3: callerPolicy = 'workflowsFromSameOwner' (or NULL when default is 'workflowsFromSameOwner').
|
|
||||||
const sameOwnerPolicyClauses = [`${callerPolicy} = 'workflowsFromSameOwner'`];
|
|
||||||
if (defaultPolicy === 'workflowsFromSameOwner') {
|
|
||||||
sameOwnerPolicyClauses.push(`${callerPolicy} IS NULL`);
|
|
||||||
}
|
|
||||||
conditions.push(
|
|
||||||
`((${sameOwnerPolicyClauses.join(' OR ')}) AND sw_sub.projectId = sw_par.projectId AND sw_par.projectId IS NOT NULL)`,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Handle NULL callerPolicy when default is 'any'
|
|
||||||
if (defaultPolicy === 'any') {
|
if (defaultPolicy === 'any') {
|
||||||
conditions.push(`${callerPolicy} IS NULL`);
|
conditions.push(`${callerPolicy} IS NULL`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { conditions, params };
|
return this.manager
|
||||||
|
.createQueryBuilder()
|
||||||
|
.select('cpw.id')
|
||||||
|
.from(WorkflowEntity, 'cpw')
|
||||||
|
.where(`(${conditions.join(' OR ')})`, {
|
||||||
|
cpCallerIdMembership: `%,${escapeLike(parentWorkflowId)},%`,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private settingsTextValue(field: string, key: string): string {
|
private settingsTextValue(field: string, key: string): string {
|
||||||
|
|
@ -1467,14 +1585,15 @@ export class WorkflowRepository extends BaseRepository<WorkflowEntity> {
|
||||||
/**
|
/**
|
||||||
* Builds search conditions and parameters for matching all of the search words
|
* Builds search conditions and parameters for matching all of the search words
|
||||||
*/
|
*/
|
||||||
private buildSearchConditions(searchWords: string[]): {
|
private buildSearchConditions(
|
||||||
|
searchWords: string[],
|
||||||
|
fieldExpression = this.getFieldConcatExpression(),
|
||||||
|
): {
|
||||||
conditions: string[];
|
conditions: string[];
|
||||||
parameters: Record<string, string>;
|
parameters: Record<string, string>;
|
||||||
} {
|
} {
|
||||||
const concatExpression = this.getFieldConcatExpression();
|
|
||||||
|
|
||||||
const conditions = searchWords.map((_, index) => {
|
const conditions = searchWords.map((_, index) => {
|
||||||
return `${concatExpression} LIKE :searchWord${index}`;
|
return `${fieldExpression} LIKE :searchWord${index}`;
|
||||||
});
|
});
|
||||||
|
|
||||||
const parameters: Record<string, string> = {};
|
const parameters: Record<string, string> = {};
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/decorators",
|
"name": "@n8n/decorators",
|
||||||
"version": "1.44.0",
|
"version": "1.44.1",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"clean": "rimraf dist .turbo",
|
"clean": "rimraf dist .turbo",
|
||||||
"dev": "pnpm watch",
|
"dev": "pnpm watch",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/inbound-auth",
|
"name": "@n8n/inbound-auth",
|
||||||
"version": "0.3.0",
|
"version": "0.3.1",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"clean": "rimraf dist .turbo",
|
"clean": "rimraf dist .turbo",
|
||||||
"dev": "pnpm watch",
|
"dev": "pnpm watch",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/instance-ai",
|
"name": "@n8n/instance-ai",
|
||||||
"version": "1.29.0",
|
"version": "1.29.2",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"clean": "rimraf dist .turbo",
|
"clean": "rimraf dist .turbo",
|
||||||
"typecheck": "tsc --noEmit && tsc -p evaluations/tsconfig.json",
|
"typecheck": "tsc --noEmit && tsc -p evaluations/tsconfig.json",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/mcp-apps",
|
"name": "@n8n/mcp-apps",
|
||||||
"version": "0.21.0",
|
"version": "0.21.1",
|
||||||
"description": "MCP Apps UI resources and server helpers for n8n",
|
"description": "MCP Apps UI resources and server helpers for n8n",
|
||||||
"main": "dist/server/index.js",
|
"main": "dist/server/index.js",
|
||||||
"types": "dist/server/index.d.ts",
|
"types": "dist/server/index.d.ts",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/node-cli",
|
"name": "@n8n/node-cli",
|
||||||
"version": "0.53.0",
|
"version": "0.53.1",
|
||||||
"description": "Official CLI for developing community nodes for n8n",
|
"description": "Official CLI for developing community nodes for n8n",
|
||||||
"bin": {
|
"bin": {
|
||||||
"n8n-node": "bin/n8n-node.mjs"
|
"n8n-node": "bin/n8n-node.mjs"
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/node-engine-compatibility",
|
"name": "@n8n/node-engine-compatibility",
|
||||||
"version": "0.12.0",
|
"version": "0.12.1",
|
||||||
"description": "Adapts v1 nodes and workflows to the Engine v2 execution model",
|
"description": "Adapts v1 nodes and workflows to the Engine v2 execution model",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"clean": "rimraf dist .turbo",
|
"clean": "rimraf dist .turbo",
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,11 @@ import {
|
||||||
N8nLlmTracing,
|
N8nLlmTracing,
|
||||||
getConnectionHintNoticeField,
|
getConnectionHintNoticeField,
|
||||||
} from '@n8n/ai-utilities';
|
} from '@n8n/ai-utilities';
|
||||||
import { shouldIncludeGoogleModel } from '@n8n/ai-utilities/model-discovery';
|
import {
|
||||||
|
getGoogleModelLabel,
|
||||||
|
pinPrioritizedModels,
|
||||||
|
shouldIncludeGoogleModel,
|
||||||
|
} from '@n8n/ai-utilities/model-discovery';
|
||||||
|
|
||||||
import { MODEL_SELECTION_HINT } from '@utils/model-builder-hints';
|
import { MODEL_SELECTION_HINT } from '@utils/model-builder-hints';
|
||||||
|
|
||||||
|
|
@ -37,6 +41,30 @@ async function filterChatModels(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Show models under the name the API reports, sorted by it, with prioritized models first. */
|
||||||
|
async function labelModels(
|
||||||
|
this: IExecuteSingleFunctions,
|
||||||
|
items: INodeExecutionData[],
|
||||||
|
): Promise<INodeExecutionData[]> {
|
||||||
|
const labelled = items.map((item) => {
|
||||||
|
// `displayName` and `priority` only carry the API's values to here; options do not keep them.
|
||||||
|
const { displayName, priority, ...option } = item.json;
|
||||||
|
const json =
|
||||||
|
typeof option.value === 'string'
|
||||||
|
? {
|
||||||
|
...option,
|
||||||
|
name: getGoogleModelLabel(
|
||||||
|
option.value,
|
||||||
|
typeof displayName === 'string' ? displayName : undefined,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: option;
|
||||||
|
return { item: { ...item, json }, priority };
|
||||||
|
});
|
||||||
|
labelled.sort((a, b) => String(a.item.json.name).localeCompare(String(b.item.json.name)));
|
||||||
|
return pinPrioritizedModels(labelled, (entry) => entry.priority).map((entry) => entry.item);
|
||||||
|
}
|
||||||
|
|
||||||
function errorDescriptionMapper(error: NodeError) {
|
function errorDescriptionMapper(error: NodeError) {
|
||||||
if (error.description?.includes('properties: should be non-empty for OBJECT type')) {
|
if (error.description?.includes('properties: should be non-empty for OBJECT type')) {
|
||||||
return 'Google Gemini requires at least one <a href="https://docs.n8n.io/advanced-ai/examples/using-the-fromai-function/" target="_blank">dynamic parameter</a> when using tools';
|
return 'Google Gemini requires at least one <a href="https://docs.n8n.io/advanced-ai/examples/using-the-fromai-function/" target="_blank">dynamic parameter</a> when using tools';
|
||||||
|
|
@ -78,14 +106,11 @@ const modelRLC: INodeProperties = {
|
||||||
name: '={{$responseItem.name}}',
|
name: '={{$responseItem.name}}',
|
||||||
value: '={{$responseItem.name}}',
|
value: '={{$responseItem.name}}',
|
||||||
description: '={{$responseItem.description}}',
|
description: '={{$responseItem.description}}',
|
||||||
|
displayName: '={{$responseItem.displayName}}',
|
||||||
|
priority: '={{$responseItem.priority}}',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
labelModels,
|
||||||
type: 'sort',
|
|
||||||
properties: {
|
|
||||||
key: 'name',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -133,14 +158,11 @@ const modelRLCV2: INodeProperties = {
|
||||||
name: '={{$responseItem.name}}',
|
name: '={{$responseItem.name}}',
|
||||||
value: '={{$responseItem.name}}',
|
value: '={{$responseItem.name}}',
|
||||||
description: '={{$responseItem.description}}',
|
description: '={{$responseItem.description}}',
|
||||||
|
displayName: '={{$responseItem.displayName}}',
|
||||||
|
priority: '={{$responseItem.priority}}',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
labelModels,
|
||||||
type: 'sort',
|
|
||||||
properties: {
|
|
||||||
key: 'name',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import type { IExecuteSingleFunctions, INodeExecutionData, PostReceiveAction } from 'n8n-workflow';
|
||||||
|
|
||||||
import { LmChatGoogleGemini } from '../LmChatGoogleGemini.node';
|
import { LmChatGoogleGemini } from '../LmChatGoogleGemini.node';
|
||||||
|
|
||||||
describe('LmChatGoogleGemini', () => {
|
describe('LmChatGoogleGemini', () => {
|
||||||
|
|
@ -23,4 +25,72 @@ describe('LmChatGoogleGemini', () => {
|
||||||
expect(hint).not.toMatch(/searchModels|explore-resources|nodes\(/);
|
expect(hint).not.toMatch(/searchModels|explore-resources|nodes\(/);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should label, sort and pin models in every version', async () => {
|
||||||
|
const modelProperties = new LmChatGoogleGemini().description.properties.filter(
|
||||||
|
(p) => p.name === 'modelName',
|
||||||
|
);
|
||||||
|
const options: INodeExecutionData[] = [
|
||||||
|
{
|
||||||
|
json: {
|
||||||
|
name: 'models/gemini-zeta',
|
||||||
|
value: 'models/gemini-zeta',
|
||||||
|
displayName: '',
|
||||||
|
priority: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
json: {
|
||||||
|
name: 'models/gemini-featured',
|
||||||
|
value: 'models/gemini-featured',
|
||||||
|
displayName: 'Gemini Featured',
|
||||||
|
priority: 10,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ json: { name: 'models/gemini-2.5-pro', value: 'models/gemini-2.5-pro' } },
|
||||||
|
{
|
||||||
|
json: {
|
||||||
|
name: 'models/gemini-api-named',
|
||||||
|
value: 'models/gemini-api-named',
|
||||||
|
displayName: 'Gemini Api Named',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(modelProperties).toHaveLength(3);
|
||||||
|
for (const property of modelProperties) {
|
||||||
|
const postReceive = property.typeOptions?.loadOptions?.routing?.output
|
||||||
|
?.postReceive as PostReceiveAction[];
|
||||||
|
const labelModels = postReceive.at(-1);
|
||||||
|
if (typeof labelModels !== 'function') throw new Error('Expected a postReceive function');
|
||||||
|
|
||||||
|
const result = await labelModels.call({} as IExecuteSingleFunctions, options, {} as never);
|
||||||
|
|
||||||
|
// The API's displayName labels a model, else its id. A priority pins a model.
|
||||||
|
expect(result.map((option) => option.json)).toEqual([
|
||||||
|
{ name: 'Gemini Featured', value: 'models/gemini-featured' },
|
||||||
|
{ name: 'Gemini Api Named', value: 'models/gemini-api-named' },
|
||||||
|
{ name: 'models/gemini-2.5-pro', value: 'models/gemini-2.5-pro' },
|
||||||
|
{ name: 'models/gemini-zeta', value: 'models/gemini-zeta' },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should leave the label of an option without a string value alone', async () => {
|
||||||
|
const property = new LmChatGoogleGemini().description.properties.find(
|
||||||
|
(p) => p.name === 'modelName',
|
||||||
|
);
|
||||||
|
const postReceive = property?.typeOptions?.loadOptions?.routing?.output
|
||||||
|
?.postReceive as PostReceiveAction[];
|
||||||
|
const labelModels = postReceive.at(-1);
|
||||||
|
if (typeof labelModels !== 'function') throw new Error('Expected a postReceive function');
|
||||||
|
|
||||||
|
const result = await labelModels.call(
|
||||||
|
{} as IExecuteSingleFunctions,
|
||||||
|
[{ json: { name: 'Unknown', value: 42, displayName: 'Ignored' } }],
|
||||||
|
{} as never,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.map((option) => option.json)).toEqual([{ name: 'Unknown', value: 42 }]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1098,6 +1098,10 @@ export class ChatTrigger extends Node {
|
||||||
// By header as well as by the iframe's attribute, so the document has no
|
// By header as well as by the iframe's attribute, so the document has no
|
||||||
// origin even if the attribute is ever stripped.
|
// origin even if the attribute is ever stripped.
|
||||||
res.setHeader('Content-Security-Policy', `sandbox ${CHAT_FRAME_SANDBOX}`);
|
res.setHeader('Content-Security-Policy', `sandbox ${CHAT_FRAME_SANDBOX}`);
|
||||||
|
} else {
|
||||||
|
// The webhook route sandboxes every response. This page keeps its origin,
|
||||||
|
// because the widget keeps the session in `localStorage`.
|
||||||
|
res.removeHeader('Content-Security-Policy');
|
||||||
}
|
}
|
||||||
|
|
||||||
const page = createPage({
|
const page = createPage({
|
||||||
|
|
|
||||||
|
|
@ -984,6 +984,17 @@ describe('ChatTrigger Node', () => {
|
||||||
expect(renderedView().sandbox).not.toContain('allow-popups-to-escape-sandbox');
|
expect(renderedView().sandbox).not.toContain('allow-popups-to-escape-sandbox');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('removes the route sandbox from the public chat page', async () => {
|
||||||
|
await renderSetupPage('none');
|
||||||
|
|
||||||
|
expect(mockResponse.removeHeader).toHaveBeenCalledWith('Content-Security-Policy');
|
||||||
|
expect(mockResponse.setHeader).not.toHaveBeenCalledWith(
|
||||||
|
'Content-Security-Policy',
|
||||||
|
expect.anything(),
|
||||||
|
);
|
||||||
|
expect(renderedPage()).toContain('createChat');
|
||||||
|
});
|
||||||
|
|
||||||
it('renders the author chat for the frame own request', async () => {
|
it('renders the author chat for the frame own request', async () => {
|
||||||
mockRequest.query = { n8nShellInner: '1' };
|
mockRequest.query = { n8nShellInner: '1' };
|
||||||
mockRequest.headers = {
|
mockRequest.headers = {
|
||||||
|
|
@ -998,6 +1009,7 @@ describe('ChatTrigger Node', () => {
|
||||||
'Content-Security-Policy',
|
'Content-Security-Policy',
|
||||||
'sandbox allow-scripts allow-forms allow-modals allow-popups',
|
'sandbox allow-scripts allow-forms allow-modals allow-popups',
|
||||||
);
|
);
|
||||||
|
expect(mockResponse.removeHeader).not.toHaveBeenCalled();
|
||||||
expect(renderedPage()).toContain('createChat');
|
expect(renderedPage()).toContain('createChat');
|
||||||
// Identity injected server-side, and a header token for the messages that follow.
|
// Identity injected server-side, and a header token for the messages that follow.
|
||||||
expect(renderedPage()).toContain('"email":"visitor@example.com"');
|
expect(renderedPage()).toContain('"email":"visitor@example.com"');
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { MongoClient } from 'mongodb';
|
import { MongoClient } from 'mongodb';
|
||||||
import type { ILoadOptionsFunctions, ISupplyDataFunctions } from 'n8n-workflow';
|
import type { ILoadOptionsFunctions, ISupplyDataFunctions } from 'n8n-workflow';
|
||||||
|
import { NodeOperationError } from 'n8n-workflow';
|
||||||
import { mock } from 'vitest-mock-extended';
|
import { mock } from 'vitest-mock-extended';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
|
@ -144,6 +145,30 @@ describe('VectorStoreMongoDBAtlas', () => {
|
||||||
expect(mockClient.connect).toHaveBeenCalledTimes(1);
|
expect(mockClient.connect).toHaveBeenCalledTimes(1);
|
||||||
expect(client).toBe(mockClient);
|
expect(client).toBe(mockClient);
|
||||||
});
|
});
|
||||||
|
it('should not include the connection string in the error when the client cannot be created', async () => {
|
||||||
|
MockMongoClient.mockImplementation(function () {
|
||||||
|
throw new Error(
|
||||||
|
'Protocol and host list are required in "mongodb://testuser:secret-pass@?x:27017"',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
mockContext.getCredentials.mockResolvedValue({
|
||||||
|
configurationType: 'values',
|
||||||
|
host: '?x',
|
||||||
|
user: 'testuser',
|
||||||
|
password: 'secret-pass',
|
||||||
|
port: 27017,
|
||||||
|
database: 'testdb',
|
||||||
|
});
|
||||||
|
|
||||||
|
const error = await createMongoClient(mockContext, 1.1).catch((e: unknown) => e);
|
||||||
|
|
||||||
|
expect(error).toBeInstanceOf(NodeOperationError);
|
||||||
|
expect((error as Error).message).toBe(
|
||||||
|
'MongoDB connection error: Protocol and host list are required in "mongodb://[REDACTED]"',
|
||||||
|
);
|
||||||
|
expect(JSON.stringify(error)).not.toContain('secret-pass');
|
||||||
|
expect((error as Error).stack).not.toContain('secret-pass');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('.getCollections', () => {
|
describe('.getCollections', () => {
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,10 @@ import {
|
||||||
} from 'n8n-workflow';
|
} from 'n8n-workflow';
|
||||||
import { metadataFilterField, createVectorStoreNode } from '@n8n/ai-utilities';
|
import { metadataFilterField, createVectorStoreNode } from '@n8n/ai-utilities';
|
||||||
|
|
||||||
import { validateAndResolveMongoCredentials } from 'n8n-nodes-base/dist/nodes/MongoDb/GenericFunctions';
|
import {
|
||||||
|
sanitizeMongoUriInMessage,
|
||||||
|
validateAndResolveMongoCredentials,
|
||||||
|
} from 'n8n-nodes-base/dist/nodes/MongoDb/GenericFunctions';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Constants for the name of the credentials and Node parameters.
|
* Constants for the name of the credentials and Node parameters.
|
||||||
|
|
@ -169,15 +172,22 @@ export async function createMongoClient(
|
||||||
const node = context.getNode();
|
const node = context.getNode();
|
||||||
const { connectionString } = validateAndResolveMongoCredentials(node, credentials);
|
const { connectionString } = validateAndResolveMongoCredentials(node, credentials);
|
||||||
|
|
||||||
const client = new MongoClient(connectionString, {
|
try {
|
||||||
appName: 'devrel.integration.n8n_vector_integ',
|
const client = new MongoClient(connectionString, {
|
||||||
driverInfo: {
|
appName: 'devrel.integration.n8n_vector_integ',
|
||||||
name: 'n8n_vector',
|
driverInfo: {
|
||||||
version: version.toString(),
|
name: 'n8n_vector',
|
||||||
},
|
version: version.toString(),
|
||||||
});
|
},
|
||||||
await client.connect();
|
});
|
||||||
return client;
|
await client.connect();
|
||||||
|
return client;
|
||||||
|
} catch (error) {
|
||||||
|
throw new NodeOperationError(
|
||||||
|
node,
|
||||||
|
`MongoDB connection error: ${sanitizeMongoUriInMessage(error, connectionString)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import type { Embeddings } from '@langchain/core/embeddings';
|
||||||
import type { VectorStore } from '@langchain/core/vectorstores';
|
import type { VectorStore } from '@langchain/core/vectorstores';
|
||||||
import type { VectorStoreNodeConstructorArgs } from '@n8n/ai-utilities';
|
import type { VectorStoreNodeConstructorArgs } from '@n8n/ai-utilities';
|
||||||
import { ConnectionPoolManager } from 'n8n-nodes-base/dist/utils/connection-pool-manager';
|
import { ConnectionPoolManager } from 'n8n-nodes-base/dist/utils/connection-pool-manager';
|
||||||
import type { IExecuteFunctions } from 'n8n-workflow';
|
import type { IExecuteFunctions, INode } from 'n8n-workflow';
|
||||||
import type { Pool } from 'oracledb';
|
import type { Pool } from 'oracledb';
|
||||||
import type { Mocked } from 'vitest';
|
import type { Mocked } from 'vitest';
|
||||||
|
|
||||||
|
|
@ -323,9 +323,12 @@ vi.mock('@n8n/ai-utilities', () => {
|
||||||
import { VectorStoreOracleDB } from './VectorStoreOracleDB.node';
|
import { VectorStoreOracleDB } from './VectorStoreOracleDB.node';
|
||||||
|
|
||||||
describe('VectorStoreOracleDB.node', () => {
|
describe('VectorStoreOracleDB.node', () => {
|
||||||
|
const node = { name: 'Oracle Database Vector Store', type: 'vectorStoreOracleDBVector' } as INode;
|
||||||
|
|
||||||
const context = {
|
const context = {
|
||||||
getNodeParameter: vi.fn(),
|
getNodeParameter: vi.fn(),
|
||||||
getCredentials: vi.fn(),
|
getCredentials: vi.fn(),
|
||||||
|
getNode: vi.fn(() => node),
|
||||||
logger: {
|
logger: {
|
||||||
debug: vi.fn(),
|
debug: vi.fn(),
|
||||||
error: vi.fn(),
|
error: vi.fn(),
|
||||||
|
|
@ -594,4 +597,104 @@ describe('VectorStoreOracleDB.node', () => {
|
||||||
expect(connectionCloseMocks[1]).toHaveBeenCalledTimes(1);
|
expect(connectionCloseMocks[1]).toHaveBeenCalledTimes(1);
|
||||||
expect(poolCloseMocks[0]).not.toHaveBeenCalled();
|
expect(poolCloseMocks[0]).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('parameter validation', () => {
|
||||||
|
const withTableName = (tableName: string) => (name: string) => {
|
||||||
|
if (name === 'tableName') return tableName;
|
||||||
|
if (name === 'options.distanceStrategy') return DistanceStrategyMock.DOT_PRODUCT;
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
it('rejects a metadata filter name that is not a plain identifier', async () => {
|
||||||
|
const node = createNode();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
node.getVectorStoreClient(context, { "userId'": 'alice' } as OracleFilter, embeddings, 0),
|
||||||
|
).rejects.toThrow('Invalid metadata filter key');
|
||||||
|
expect(initializeSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a metadata filter name supplied at search time', async () => {
|
||||||
|
const node = createNode();
|
||||||
|
const vectorStore = await node.getVectorStoreClient(context, undefined, embeddings, 0);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
vectorStore.similaritySearchVectorWithScore([0.1], 3, {
|
||||||
|
'userId?(@ == "bob")': 'alice',
|
||||||
|
} as OracleFilter),
|
||||||
|
).rejects.toThrow('Invalid metadata filter key');
|
||||||
|
expect(similaritySearchSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a metadata filter name nested under $and at search time', async () => {
|
||||||
|
const node = createNode();
|
||||||
|
const vectorStore = await node.getVectorStoreClient(context, undefined, embeddings, 0);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
vectorStore.similaritySearchVectorWithScore([0.1], 3, {
|
||||||
|
$and: [{ project: 'n8n' }, { 'a.b': 'x' }],
|
||||||
|
} as OracleFilter),
|
||||||
|
).rejects.toThrow('Invalid metadata filter key');
|
||||||
|
expect(similaritySearchSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts a filter whose names are plain identifiers', async () => {
|
||||||
|
const node = createNode();
|
||||||
|
const filter = { userId: 'alice', user_id: 'alice' } as OracleFilter;
|
||||||
|
|
||||||
|
const vectorStore = await node.getVectorStoreClient(context, filter, embeddings, 0);
|
||||||
|
await vectorStore.similaritySearchVectorWithScore([0.1], 3, undefined);
|
||||||
|
|
||||||
|
expect(similaritySearchSpy).toHaveBeenCalledWith([0.1], 3, filter);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a table name that is not a plain identifier', async () => {
|
||||||
|
const node = createNode();
|
||||||
|
context.getNodeParameter.mockImplementation(withTableName('n8n_vectors; SELECT 1'));
|
||||||
|
|
||||||
|
await expect(node.getVectorStoreClient(context, undefined, embeddings, 0)).rejects.toThrow(
|
||||||
|
'Invalid table name',
|
||||||
|
);
|
||||||
|
|
||||||
|
context.getNodeParameter.mockImplementation(defaultGetNodeParameter);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a table name that is not a plain identifier when inserting', async () => {
|
||||||
|
const node = createNode();
|
||||||
|
context.getNodeParameter.mockImplementation(withTableName('n8n_vectors; SELECT 1'));
|
||||||
|
|
||||||
|
await expect(node.populateVectorStore(context, embeddings, documents, 0)).rejects.toThrow(
|
||||||
|
'Invalid table name',
|
||||||
|
);
|
||||||
|
expect(fromDocumentsSpy).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
context.getNodeParameter.mockImplementation(defaultGetNodeParameter);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts a schema-qualified table name', async () => {
|
||||||
|
const node = createNode();
|
||||||
|
context.getNodeParameter.mockImplementation(withTableName('app.n8n_vectors'));
|
||||||
|
|
||||||
|
await node.getVectorStoreClient(context, undefined, embeddings, 0);
|
||||||
|
|
||||||
|
const lastCall = initializeSpy.mock.calls.at(-1);
|
||||||
|
if (!lastCall) throw new Error('initializeSpy should have been called');
|
||||||
|
expect(lastCall[1]?.tableName).toBe('app.n8n_vectors');
|
||||||
|
|
||||||
|
context.getNodeParameter.mockImplementation(defaultGetNodeParameter);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to the default table name when the parameter resolves to empty', async () => {
|
||||||
|
const node = createNode();
|
||||||
|
context.getNodeParameter.mockImplementation(withTableName(''));
|
||||||
|
|
||||||
|
await node.getVectorStoreClient(context, undefined, embeddings, 0);
|
||||||
|
|
||||||
|
const lastCall = initializeSpy.mock.calls.at(-1);
|
||||||
|
if (!lastCall) throw new Error('initializeSpy should have been called');
|
||||||
|
expect(lastCall[1]?.tableName).toBe('n8n_vectors');
|
||||||
|
|
||||||
|
context.getNodeParameter.mockImplementation(defaultGetNodeParameter);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,15 @@ import { createVectorStoreNode, metadataFilterField } from '@n8n/ai-utilities';
|
||||||
import { DistanceStrategy, OracleVS, type OracleDBVSArgs } from '@oracle/langchain-oracledb';
|
import { DistanceStrategy, OracleVS, type OracleDBVSArgs } from '@oracle/langchain-oracledb';
|
||||||
import type { OracleDBNodeCredentials } from 'n8n-nodes-base/dist/nodes/Oracle/Sql/helpers/interfaces';
|
import type { OracleDBNodeCredentials } from 'n8n-nodes-base/dist/nodes/Oracle/Sql/helpers/interfaces';
|
||||||
import { configureOracleDB } from 'n8n-nodes-base/dist/nodes/Oracle/Sql/transport';
|
import { configureOracleDB } from 'n8n-nodes-base/dist/nodes/Oracle/Sql/transport';
|
||||||
import type { IExecuteFunctions, INodeProperties, ISupplyDataFunctions } from 'n8n-workflow';
|
import type { INode, IExecuteFunctions, INodeProperties, ISupplyDataFunctions } from 'n8n-workflow';
|
||||||
|
import { NodeOperationError } from 'n8n-workflow';
|
||||||
import type oracledb from 'oracledb';
|
import type oracledb from 'oracledb';
|
||||||
|
|
||||||
|
import {
|
||||||
|
findUnsupportedJsonPathFilterKey,
|
||||||
|
isSafeQualifiedSqlIdentifier,
|
||||||
|
} from '@utils/sqlIdentifier';
|
||||||
|
|
||||||
const sharedFields: INodeProperties[] = [
|
const sharedFields: INodeProperties[] = [
|
||||||
{
|
{
|
||||||
displayName: 'Table Name',
|
displayName: 'Table Name',
|
||||||
|
|
@ -20,6 +26,40 @@ const sharedFields: INodeProperties[] = [
|
||||||
|
|
||||||
const DEFAULT_INITIALIZATION_TEXT = 'n8n vector store initialization text';
|
const DEFAULT_INITIALIZATION_TEXT = 'n8n vector store initialization text';
|
||||||
|
|
||||||
|
const DEFAULT_TABLE_NAME = 'n8n_vectors';
|
||||||
|
|
||||||
|
/** Reads the Table Name, falling back to the default when an expression resolves to empty. */
|
||||||
|
function getTableName(
|
||||||
|
context: IExecuteFunctions | ISupplyDataFunctions,
|
||||||
|
itemIndex: number,
|
||||||
|
): string {
|
||||||
|
const tableName =
|
||||||
|
(context.getNodeParameter('tableName', itemIndex, '', { extractValue: true }) as string) ||
|
||||||
|
DEFAULT_TABLE_NAME;
|
||||||
|
|
||||||
|
if (!isSafeQualifiedSqlIdentifier(tableName)) {
|
||||||
|
throw new NodeOperationError(context.getNode(), `Invalid table name \"${tableName}\"`, {
|
||||||
|
itemIndex,
|
||||||
|
description:
|
||||||
|
'The table name must be a plain name, or a schema and name separated by a dot. Letters, digits and underscores only.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return tableName;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rejects a metadata filter the Oracle store cannot resolve into a SQL/JSON path. */
|
||||||
|
function assertSupportedFilter(node: INode, filter: unknown, itemIndex?: number): void {
|
||||||
|
const unsupported = findUnsupportedJsonPathFilterKey(filter);
|
||||||
|
if (unsupported === undefined) return;
|
||||||
|
|
||||||
|
throw new NodeOperationError(node, `Invalid metadata filter key \"${unsupported}\"`, {
|
||||||
|
itemIndex,
|
||||||
|
description:
|
||||||
|
'A metadata filter name must start with a letter or underscore and contain only letters, digits and underscores.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const NO_ROWS_FOUND_ERROR_MESSAGE = 'No rows found.';
|
const NO_ROWS_FOUND_ERROR_MESSAGE = 'No rows found.';
|
||||||
|
|
||||||
const isNoRowsFoundError = (error: unknown): error is Error =>
|
const isNoRowsFoundError = (error: unknown): error is Error =>
|
||||||
|
|
@ -146,11 +186,15 @@ const createLazyOraclePool = (
|
||||||
* with any ad-hoc filter provided at runtime.
|
* with any ad-hoc filter provided at runtime.
|
||||||
*/
|
*/
|
||||||
class ExtendedOracleDBVectorStore extends OracleVS {
|
class ExtendedOracleDBVectorStore extends OracleVS {
|
||||||
|
n8nNode!: INode;
|
||||||
|
|
||||||
static async initialize(
|
static async initialize(
|
||||||
embeddings: EmbeddingsInterface,
|
embeddings: EmbeddingsInterface,
|
||||||
args: OracleDBVSArgs,
|
args: OracleDBVSArgs & { n8nNode: INode },
|
||||||
): Promise<ExtendedOracleDBVectorStore> {
|
): Promise<ExtendedOracleDBVectorStore> {
|
||||||
const oracleDBVectorStore = new this(embeddings, args);
|
const { n8nNode, ...rest } = args;
|
||||||
|
const oracleDBVectorStore = new this(embeddings, rest);
|
||||||
|
oracleDBVectorStore.n8nNode = n8nNode;
|
||||||
|
|
||||||
await oracleDBVectorStore.initialize();
|
await oracleDBVectorStore.initialize();
|
||||||
return oracleDBVectorStore;
|
return oracleDBVectorStore;
|
||||||
|
|
@ -162,6 +206,9 @@ class ExtendedOracleDBVectorStore extends OracleVS {
|
||||||
filter?: OracleVS['FilterType'],
|
filter?: OracleVS['FilterType'],
|
||||||
) {
|
) {
|
||||||
const mergedFilter = mergeFilters(this.filter, filter);
|
const mergedFilter = mergeFilters(this.filter, filter);
|
||||||
|
// Checked here as well as at construction, because load and both
|
||||||
|
// retrieve-as-tool modes supply the filter at search time.
|
||||||
|
assertSupportedFilter(this.n8nNode, mergedFilter);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return await super.similaritySearchVectorWithScore(query, k, mergedFilter);
|
return await super.similaritySearchVectorWithScore(query, k, mergedFilter);
|
||||||
|
|
@ -199,17 +246,17 @@ export class VectorStoreOracleDB extends createVectorStoreNode<ExtendedOracleDBV
|
||||||
embeddings: EmbeddingsInterface,
|
embeddings: EmbeddingsInterface,
|
||||||
itemIndex: number,
|
itemIndex: number,
|
||||||
): Promise<ExtendedOracleDBVectorStore> {
|
): Promise<ExtendedOracleDBVectorStore> {
|
||||||
const tableName = context.getNodeParameter('tableName', itemIndex, '', {
|
const tableName = getTableName(context, itemIndex);
|
||||||
extractValue: true,
|
assertSupportedFilter(context.getNode(), filter, itemIndex);
|
||||||
}) as string;
|
|
||||||
const credentials = (await context.getCredentials('oracleDBApi')) as OracleDBNodeCredentials;
|
const credentials = (await context.getCredentials('oracleDBApi')) as OracleDBNodeCredentials;
|
||||||
const client = createLazyOraclePool(context, credentials);
|
const client = createLazyOraclePool(context, credentials);
|
||||||
const query = DEFAULT_INITIALIZATION_TEXT;
|
const query = DEFAULT_INITIALIZATION_TEXT;
|
||||||
const config: OracleDBVSArgs = {
|
const config: OracleDBVSArgs & { n8nNode: INode } = {
|
||||||
client,
|
client,
|
||||||
tableName,
|
tableName,
|
||||||
query,
|
query,
|
||||||
filter: filter as ExtendedOracleDBVectorStore['FilterType'] | undefined,
|
filter: filter as ExtendedOracleDBVectorStore['FilterType'] | undefined,
|
||||||
|
n8nNode: context.getNode(),
|
||||||
};
|
};
|
||||||
|
|
||||||
config.distanceStrategy = context.getNodeParameter(
|
config.distanceStrategy = context.getNodeParameter(
|
||||||
|
|
@ -227,9 +274,7 @@ export class VectorStoreOracleDB extends createVectorStoreNode<ExtendedOracleDBV
|
||||||
documents: Array<Document<Record<string, unknown>>>,
|
documents: Array<Document<Record<string, unknown>>>,
|
||||||
itemIndex: number,
|
itemIndex: number,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const tableName = context.getNodeParameter('tableName', itemIndex, '', {
|
const tableName = getTableName(context, itemIndex);
|
||||||
extractValue: true,
|
|
||||||
}) as string;
|
|
||||||
const credentials = (await context.getCredentials('oracleDBApi')) as OracleDBNodeCredentials;
|
const credentials = (await context.getCredentials('oracleDBApi')) as OracleDBNodeCredentials;
|
||||||
const client = createLazyOraclePool(context, credentials);
|
const client = createLazyOraclePool(context, credentials);
|
||||||
const query = DEFAULT_INITIALIZATION_TEXT;
|
const query = DEFAULT_INITIALIZATION_TEXT;
|
||||||
|
|
|
||||||
|
|
@ -101,6 +101,44 @@ describe('GoogleGemini -> listSearch', () => {
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should label, sort and pin models', async () => {
|
||||||
|
apiRequestMock.mockResolvedValue({
|
||||||
|
models: [
|
||||||
|
{ name: 'models/gemini-zeta' },
|
||||||
|
{ name: 'models/gemini-featured', displayName: 'Gemini Featured', priority: 10 },
|
||||||
|
{ name: 'models/gemini-2.5-pro' },
|
||||||
|
{ name: 'models/gemini-api-named', displayName: 'Gemini Api Named' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await modelSearch.call(mockExecuteFunctions);
|
||||||
|
|
||||||
|
// The API's displayName labels a model, else its id. A priority pins a model.
|
||||||
|
expect(result).toEqual({
|
||||||
|
results: [
|
||||||
|
{ 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 match the filter against the label and the id', async () => {
|
||||||
|
apiRequestMock.mockResolvedValue({
|
||||||
|
models: [
|
||||||
|
{ name: 'models/gemini-zeta' },
|
||||||
|
{ name: 'models/gemini-2.5-pro', displayName: 'Gemini 2.5 Pro' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const byName = await modelSearch.call(mockExecuteFunctions, '2.5 Pro');
|
||||||
|
const byId = await modelSearch.call(mockExecuteFunctions, 'gemini-zeta');
|
||||||
|
|
||||||
|
expect(byName.results).toEqual([{ name: 'Gemini 2.5 Pro', value: 'models/gemini-2.5-pro' }]);
|
||||||
|
expect(byId.results).toEqual([{ name: 'models/gemini-zeta', value: 'models/gemini-zeta' }]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('audioModelSearch', () => {
|
describe('audioModelSearch', () => {
|
||||||
|
|
@ -115,14 +153,14 @@ describe('GoogleGemini -> listSearch', () => {
|
||||||
name: 'models/gemini-2.5-flash',
|
name: 'models/gemini-2.5-flash',
|
||||||
value: 'models/gemini-2.5-flash',
|
value: 'models/gemini-2.5-flash',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: 'models/gemma-3-1b-it',
|
|
||||||
value: 'models/gemma-3-1b-it',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: 'models/gemini-2.5-flash-preview-native-audio-dialog',
|
name: 'models/gemini-2.5-flash-preview-native-audio-dialog',
|
||||||
value: 'models/gemini-2.5-flash-preview-native-audio-dialog',
|
value: 'models/gemini-2.5-flash-preview-native-audio-dialog',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'models/gemma-3-1b-it',
|
||||||
|
value: 'models/gemma-3-1b-it',
|
||||||
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -140,10 +178,6 @@ describe('GoogleGemini -> listSearch', () => {
|
||||||
name: 'models/gemini-2.0-flash-exp-image-generation',
|
name: 'models/gemini-2.0-flash-exp-image-generation',
|
||||||
value: 'models/gemini-2.0-flash-exp-image-generation',
|
value: 'models/gemini-2.0-flash-exp-image-generation',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: 'models/imagen-3.0-generate-002',
|
|
||||||
value: 'models/imagen-3.0-generate-002',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased
|
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased
|
||||||
name: 'models/gemini-2.5-flash-image (Nano Banana)',
|
name: 'models/gemini-2.5-flash-image (Nano Banana)',
|
||||||
|
|
@ -159,15 +193,19 @@ describe('GoogleGemini -> listSearch', () => {
|
||||||
name: 'models/gemini-3.1-flash-image-preview (Nano Banana 2)',
|
name: 'models/gemini-3.1-flash-image-preview (Nano Banana 2)',
|
||||||
value: 'models/gemini-3.1-flash-image-preview',
|
value: 'models/gemini-3.1-flash-image-preview',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased
|
||||||
|
name: 'models/gemini-3.1-flash-lite-image (Nano Banana 2 Lite)',
|
||||||
|
value: 'models/gemini-3.1-flash-lite-image',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased
|
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased
|
||||||
name: 'models/gemini-nano-banana-2.1 (Nano Banana 2.1)',
|
name: 'models/gemini-nano-banana-2.1 (Nano Banana 2.1)',
|
||||||
value: 'models/gemini-nano-banana-2.1',
|
value: 'models/gemini-nano-banana-2.1',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased
|
name: 'models/imagen-3.0-generate-002',
|
||||||
name: 'models/gemini-3.1-flash-lite-image (Nano Banana 2 Lite)',
|
value: 'models/imagen-3.0-generate-002',
|
||||||
value: 'models/gemini-3.1-flash-lite-image',
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
@ -189,6 +227,26 @@ describe('GoogleGemini -> listSearch', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('imageModelSearch with names from the API', () => {
|
||||||
|
it('should match the filter against the id when the API names the model', async () => {
|
||||||
|
apiRequestMock.mockResolvedValue({
|
||||||
|
models: [{ name: 'models/gemini-2.5-flash-image', displayName: 'Gemini 2.5 Flash Image' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await imageGenerationModelSearch.call(
|
||||||
|
mockExecuteFunctions,
|
||||||
|
'gemini-2.5-flash-image',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.results).toEqual([
|
||||||
|
{
|
||||||
|
name: 'Gemini 2.5 Flash Image (Nano Banana)',
|
||||||
|
value: 'models/gemini-2.5-flash-image',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('imageEditModelSearch', () => {
|
describe('imageEditModelSearch', () => {
|
||||||
it('should return image edit models', async () => {
|
it('should return image edit models', async () => {
|
||||||
apiRequestMock.mockResolvedValue(mockResponse);
|
apiRequestMock.mockResolvedValue(mockResponse);
|
||||||
|
|
@ -214,13 +272,13 @@ describe('GoogleGemini -> listSearch', () => {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased
|
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased
|
||||||
name: 'models/gemini-nano-banana-2.1 (Nano Banana 2.1)',
|
name: 'models/gemini-3.1-flash-lite-image (Nano Banana 2 Lite)',
|
||||||
value: 'models/gemini-nano-banana-2.1',
|
value: 'models/gemini-3.1-flash-lite-image',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased
|
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased
|
||||||
name: 'models/gemini-3.1-flash-lite-image (Nano Banana 2 Lite)',
|
name: 'models/gemini-nano-banana-2.1 (Nano Banana 2.1)',
|
||||||
value: 'models/gemini-3.1-flash-lite-image',
|
value: 'models/gemini-nano-banana-2.1',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,8 @@
|
||||||
|
import {
|
||||||
|
getGoogleModelLabel,
|
||||||
|
parseGoogleModels,
|
||||||
|
pinPrioritizedModels,
|
||||||
|
} from '@n8n/ai-utilities/model-discovery';
|
||||||
import type { ILoadOptionsFunctions, INodeListSearchResult } from 'n8n-workflow';
|
import type { ILoadOptionsFunctions, INodeListSearchResult } from 'n8n-workflow';
|
||||||
|
|
||||||
import { apiRequest } from '../transport';
|
import { apiRequest } from '../transport';
|
||||||
|
|
@ -12,21 +17,29 @@ async function baseModelSearch(
|
||||||
modelFilter: (model: string) => boolean,
|
modelFilter: (model: string) => boolean,
|
||||||
filter?: string,
|
filter?: string,
|
||||||
): Promise<INodeListSearchResult> {
|
): Promise<INodeListSearchResult> {
|
||||||
const response = (await apiRequest.call(this, 'GET', '/v1beta/models', {
|
const response: unknown = await apiRequest.call(this, 'GET', '/v1beta/models', {
|
||||||
qs: {
|
qs: {
|
||||||
pageSize: 1000,
|
pageSize: 1000,
|
||||||
},
|
},
|
||||||
})) as {
|
});
|
||||||
models: Array<{ name: string }>;
|
|
||||||
};
|
|
||||||
|
|
||||||
let models = response.models.filter((model) => modelFilter(model.name));
|
let entries = parseGoogleModels(response)
|
||||||
|
.filter((model) => modelFilter(model.name))
|
||||||
|
.map((model) => ({
|
||||||
|
result: { name: getGoogleModelLabel(model.name, model.displayName), value: model.name },
|
||||||
|
priority: model.priority,
|
||||||
|
}));
|
||||||
if (filter) {
|
if (filter) {
|
||||||
models = models.filter((model) => model.name.toLowerCase().includes(filter.toLowerCase()));
|
const query = filter.toLowerCase();
|
||||||
|
entries = entries.filter(
|
||||||
|
({ result }) =>
|
||||||
|
result.name.toLowerCase().includes(query) || result.value.toLowerCase().includes(query),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
entries.sort((a, b) => a.result.name.localeCompare(b.result.name));
|
||||||
return {
|
return {
|
||||||
results: models.map((model) => ({ name: model.name, value: model.name })),
|
results: pinPrioritizedModels(entries, (entry) => entry.priority).map((entry) => entry.result),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -71,24 +84,25 @@ export async function imageGenerationModelSearch(
|
||||||
): Promise<INodeListSearchResult> {
|
): Promise<INodeListSearchResult> {
|
||||||
const rawResult = await baseModelSearch.call(this, isImageModel);
|
const rawResult = await baseModelSearch.call(this, isImageModel);
|
||||||
let results = rawResult.results.map((r) => {
|
let results = rawResult.results.map((r) => {
|
||||||
if (r.name.includes('gemini-nano-banana-2.1')) {
|
const modelId = String(r.value);
|
||||||
|
if (modelId.includes('gemini-nano-banana-2.1')) {
|
||||||
return { name: `${r.name} (Nano Banana 2.1)`, value: r.value };
|
return { name: `${r.name} (Nano Banana 2.1)`, value: r.value };
|
||||||
}
|
}
|
||||||
|
|
||||||
// The Lite id also contains "gemini-3.1-flash-image". Match it first.
|
// The Lite id also contains "gemini-3.1-flash-image". Match it first.
|
||||||
if (r.name.includes('gemini-3.1-flash-lite-image')) {
|
if (modelId.includes('gemini-3.1-flash-lite-image')) {
|
||||||
return { name: `${r.name} (Nano Banana 2 Lite)`, value: r.value };
|
return { name: `${r.name} (Nano Banana 2 Lite)`, value: r.value };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (r.name.includes('gemini-3.1-flash-image')) {
|
if (modelId.includes('gemini-3.1-flash-image')) {
|
||||||
return { name: `${r.name} (Nano Banana 2)`, value: r.value };
|
return { name: `${r.name} (Nano Banana 2)`, value: r.value };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (r.name.includes('gemini-2.5-flash-image')) {
|
if (modelId.includes('gemini-2.5-flash-image')) {
|
||||||
return { name: `${r.name} (Nano Banana)`, value: r.value };
|
return { name: `${r.name} (Nano Banana)`, value: r.value };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (r.name.includes('gemini-3-pro-image')) {
|
if (modelId.includes('gemini-3-pro-image')) {
|
||||||
return { name: `${r.name} (Nano Banana Pro)`, value: r.value };
|
return { name: `${r.name} (Nano Banana Pro)`, value: r.value };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -97,7 +111,11 @@ export async function imageGenerationModelSearch(
|
||||||
|
|
||||||
if (filter) {
|
if (filter) {
|
||||||
const filterLowerCase = filter.toLowerCase();
|
const filterLowerCase = filter.toLowerCase();
|
||||||
results = results.filter((r) => r.name.toLowerCase().includes(filterLowerCase));
|
results = results.filter(
|
||||||
|
(r) =>
|
||||||
|
r.name.toLowerCase().includes(filterLowerCase) ||
|
||||||
|
String(r.value).toLowerCase().includes(filterLowerCase),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/n8n-nodes-langchain",
|
"name": "@n8n/n8n-nodes-langchain",
|
||||||
"version": "2.44.0",
|
"version": "2.44.1",
|
||||||
"description": "",
|
"description": "",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"exports": {
|
"exports": {
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import {
|
||||||
escapeQualifiedSqlIdentifier,
|
escapeQualifiedSqlIdentifier,
|
||||||
escapeSqlIdentifier,
|
escapeSqlIdentifier,
|
||||||
findUnsafeMetadataFilterKey,
|
findUnsafeMetadataFilterKey,
|
||||||
|
findUnsupportedJsonPathFilterKey,
|
||||||
isSafeQualifiedSqlIdentifier,
|
isSafeQualifiedSqlIdentifier,
|
||||||
isSafeSqlIdentifier,
|
isSafeSqlIdentifier,
|
||||||
} from './sqlIdentifier';
|
} from './sqlIdentifier';
|
||||||
|
|
@ -97,4 +98,56 @@ describe('sqlIdentifier', () => {
|
||||||
expect(findUnsafeMetadataFilterKey({ ok: '1', 'a\\b': '2' })).toBe('a\\b');
|
expect(findUnsafeMetadataFilterKey({ ok: '1', 'a\\b': '2' })).toBe('a\\b');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('findUnsupportedJsonPathFilterKey', () => {
|
||||||
|
it.each([
|
||||||
|
['a plain name', { userId: 'alice' }],
|
||||||
|
['an underscored name', { user_id: 'alice' }],
|
||||||
|
['a leading underscore', { _internal: true }],
|
||||||
|
['mixed case', { UserID: 'alice' }],
|
||||||
|
['trailing digits', { field2: 'x' }],
|
||||||
|
['several names at once', { project: 'n8n', category: 'movies', author: 'a' }],
|
||||||
|
['an array value', { author: ['a', 'b'] }],
|
||||||
|
['an operator in value position', { rating: { $gte: 4.5 } }],
|
||||||
|
['an operator the store does not know', { nested: { flag: true } }],
|
||||||
|
['names nested under $and', { $and: [{ project: 'n8n' }, { language: 'en' }] }],
|
||||||
|
['names nested under $or', { $or: [{ project: 'n8n' }] }],
|
||||||
|
['no filter at all', undefined],
|
||||||
|
])('accepts %s', (_name, filter) => {
|
||||||
|
expect(findUnsupportedJsonPathFilterKey(filter)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['a quote', "x'"],
|
||||||
|
['a backslash', 'a\\b'],
|
||||||
|
['a double quote', 'a"b'],
|
||||||
|
['a path predicate', 'userId?(@ == "bob")'],
|
||||||
|
['a dot', 'user.id'],
|
||||||
|
['a hyphen', 'file-name'],
|
||||||
|
['a space', 'my key'],
|
||||||
|
['a dollar sign', 'a$b'],
|
||||||
|
['a leading digit', '1field'],
|
||||||
|
['an empty name', ''],
|
||||||
|
])('rejects %s', (_name, key) => {
|
||||||
|
expect(findUnsupportedJsonPathFilterKey({ [key]: 'value' })).toBe(key);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports a name nested under $and', () => {
|
||||||
|
const filter = { $and: [{ project: 'n8n' }, { "x'": 'alice' }] };
|
||||||
|
|
||||||
|
expect(findUnsupportedJsonPathFilterKey(filter)).toBe("x'");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports a name nested under $or inside $and', () => {
|
||||||
|
const filter = { $and: [{ $or: [{ ok: 1 }, { 'a.b': 2 }] }] };
|
||||||
|
|
||||||
|
expect(findUnsupportedJsonPathFilterKey(filter)).toBe('a.b');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports the first unsupported name when several are present', () => {
|
||||||
|
const filter = { good: 1, "bad'one": 2, 'bad.two': 3 };
|
||||||
|
|
||||||
|
expect(findUnsupportedJsonPathFilterKey(filter)).toBe("bad'one");
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { isRecord } from '@n8n/utils/is-record';
|
||||||
import pg from 'pg';
|
import pg from 'pg';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -64,3 +65,39 @@ export function findUnsafeMetadataFilterKey(
|
||||||
if (!filter) return undefined;
|
if (!filter) return undefined;
|
||||||
return Object.keys(filter).find((key) => key.includes("'") || key.includes('\\'));
|
return Object.keys(filter).find((key) => key.includes("'") || key.includes('\\'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Matches a field name that Oracle accepts as an unquoted SQL/JSON path step.
|
||||||
|
* Oracle documents the unquoted form as a leading letter followed by letters and
|
||||||
|
* digits; underscore is allowed here because it is inert in both the path
|
||||||
|
* grammar and the surrounding string literal, and is common in existing keys.
|
||||||
|
*/
|
||||||
|
const SAFE_JSON_PATH_FIELD = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the first metadata-filter key that the Oracle store cannot resolve as
|
||||||
|
* an unquoted SQL/JSON path step, or undefined when every key is usable.
|
||||||
|
*
|
||||||
|
* Walks `$and` and `$or` because the store expands them before building the
|
||||||
|
* path, so a nested key reaches the same place a top-level one does.
|
||||||
|
*/
|
||||||
|
export function findUnsupportedJsonPathFilterKey(filter: unknown): string | undefined {
|
||||||
|
if (!isRecord(filter)) return undefined;
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(filter)) {
|
||||||
|
if (key === '$and' || key === '$or') {
|
||||||
|
if (!Array.isArray(value)) continue;
|
||||||
|
for (const condition of value) {
|
||||||
|
const unsupported = findUnsupportedJsonPathFilterKey(condition);
|
||||||
|
if (unsupported !== undefined) return unsupported;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only the field name reaches the path. Operator names in value position
|
||||||
|
// are matched against a fixed set by the store, and values are bound.
|
||||||
|
if (!SAFE_JSON_PATH_FIELD.test(key)) return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/scheduler",
|
"name": "@n8n/scheduler",
|
||||||
"version": "1.13.0",
|
"version": "1.13.1",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"clean": "rimraf dist .turbo",
|
"clean": "rimraf dist .turbo",
|
||||||
"dev": "pnpm watch",
|
"dev": "pnpm watch",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/task-runner",
|
"name": "@n8n/task-runner",
|
||||||
"version": "2.44.0",
|
"version": "2.44.1",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"clean": "rimraf dist .turbo",
|
"clean": "rimraf dist .turbo",
|
||||||
"start": "node dist/start.js",
|
"start": "node dist/start.js",
|
||||||
|
|
|
||||||
|
|
@ -193,6 +193,34 @@ describe('JsTaskRunner', () => {
|
||||||
expect(outcome.result).toEqual([wrapIntoJson({ allZeros: true })]);
|
expect(outcome.result).toEqual([wrapIntoJson({ allZeros: true })]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should give small buffers their own backing memory', async () => {
|
||||||
|
const outcome = await executeForAllItems({
|
||||||
|
code: `
|
||||||
|
const fromString = Buffer.from('.');
|
||||||
|
const fromArray = Buffer.from([1, 2]);
|
||||||
|
return [{ json: {
|
||||||
|
fromString: fromString.buffer.byteLength,
|
||||||
|
fromArray: fromArray.buffer.byteLength,
|
||||||
|
} }]
|
||||||
|
`,
|
||||||
|
inputItems: [{ a: 1 }],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(outcome.result).toEqual([wrapIntoJson({ fromString: 1, fromArray: 2 })]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should prevent guest code from re-enabling the buffer pool', async () => {
|
||||||
|
const outcome = await executeForAllItems({
|
||||||
|
code: `
|
||||||
|
Buffer.poolSize = 8192;
|
||||||
|
return [{ json: { poolSize: Buffer.poolSize } }]
|
||||||
|
`,
|
||||||
|
inputItems: [{ a: 1 }],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(outcome.result).toEqual([wrapIntoJson({ poolSize: 0 })]);
|
||||||
|
});
|
||||||
|
|
||||||
it('should support Buffer.from and Buffer.concat', async () => {
|
it('should support Buffer.from and Buffer.concat', async () => {
|
||||||
const outcome = await executeForAllItems({
|
const outcome = await executeForAllItems({
|
||||||
code: `
|
code: `
|
||||||
|
|
|
||||||
|
|
@ -186,6 +186,9 @@ export class JsTaskRunner extends TaskRunner {
|
||||||
const safeAlloc = Buffer.alloc.bind(Buffer);
|
const safeAlloc = Buffer.alloc.bind(Buffer);
|
||||||
Buffer.allocUnsafe = safeAlloc;
|
Buffer.allocUnsafe = safeAlloc;
|
||||||
Buffer.allocUnsafeSlow = safeAlloc;
|
Buffer.allocUnsafeSlow = safeAlloc;
|
||||||
|
// Disable the shared buffer pool so small buffers get their own backing memory.
|
||||||
|
// Use defineProperty so the value is read-only: sandboxed code must not re-enable the pool.
|
||||||
|
Object.defineProperty(Buffer, 'poolSize', { value: 0, writable: false, configurable: false });
|
||||||
|
|
||||||
if (process.env.NODE_ENV !== 'test') {
|
if (process.env.NODE_ENV !== 'test') {
|
||||||
freezeGlobals();
|
freezeGlobals();
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/telemetry",
|
"name": "@n8n/telemetry",
|
||||||
"version": "1.12.0",
|
"version": "1.12.1",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"clean": "rimraf dist .turbo",
|
"clean": "rimraf dist .turbo",
|
||||||
"dev": "pnpm watch",
|
"dev": "pnpm watch",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/workflow-sdk",
|
"name": "@n8n/workflow-sdk",
|
||||||
"version": "0.37.0",
|
"version": "0.37.1",
|
||||||
"description": "TypeScript SDK for programmatically creating n8n workflows",
|
"description": "TypeScript SDK for programmatically creating n8n workflows",
|
||||||
"exports": {
|
"exports": {
|
||||||
".": {
|
".": {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "n8n",
|
"name": "n8n",
|
||||||
"version": "2.44.0",
|
"version": "2.44.3",
|
||||||
"description": "n8n Workflow Automation Tool",
|
"description": "n8n Workflow Automation Tool",
|
||||||
"main": "dist/index",
|
"main": "dist/index",
|
||||||
"types": "dist/index.d.ts",
|
"types": "dist/index.d.ts",
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,16 @@ import { mock } from 'vitest-mock-extended';
|
||||||
|
|
||||||
import { AbstractServer } from '@/abstract-server';
|
import { AbstractServer } from '@/abstract-server';
|
||||||
import { ExternalHooks } from '@/external-hooks';
|
import { ExternalHooks } from '@/external-hooks';
|
||||||
|
import { LiveWebhooks } from '@/webhooks/live-webhooks';
|
||||||
|
import { SlackInteractionWebhooks } from '@/webhooks/slack-interaction-webhooks';
|
||||||
|
import { TelegramInteractionWebhooks } from '@/webhooks/telegram-interaction-webhooks';
|
||||||
|
import { WaitingForms } from '@/webhooks/waiting-forms';
|
||||||
|
import { WaitingWebhooks } from '@/webhooks/waiting-webhooks';
|
||||||
|
import { createWebhookHandlerFor } from '@/webhooks/webhook-request-handler';
|
||||||
|
|
||||||
|
vi.mock('@/webhooks/webhook-request-handler', () => ({
|
||||||
|
createWebhookHandlerFor: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
const mockServer = mock<http.Server>();
|
const mockServer = mock<http.Server>();
|
||||||
|
|
||||||
|
|
@ -87,6 +97,21 @@ describe('AbstractServer health endpoints', () => {
|
||||||
await testServer.init();
|
await testServer.init();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('webhook route registration', () => {
|
||||||
|
it('should register the waiting-forms route with the form sandbox family', async () => {
|
||||||
|
const waitingForms = mock<WaitingForms>();
|
||||||
|
Container.set(LiveWebhooks, mock<LiveWebhooks>());
|
||||||
|
Container.set(WaitingForms, waitingForms);
|
||||||
|
Container.set(WaitingWebhooks, mock<WaitingWebhooks>());
|
||||||
|
Container.set(SlackInteractionWebhooks, mock<SlackInteractionWebhooks>());
|
||||||
|
Container.set(TelegramInteractionWebhooks, mock<TelegramInteractionWebhooks>());
|
||||||
|
|
||||||
|
await testServer.start();
|
||||||
|
|
||||||
|
expect(createWebhookHandlerFor).toHaveBeenCalledWith(waitingForms, undefined, 'form');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('/healthz (liveness)', () => {
|
describe('/healthz (liveness)', () => {
|
||||||
it('should return 200 regardless of fullyReady flag', () => {
|
it('should return 200 regardless of fullyReady flag', () => {
|
||||||
const res = mock<express.Response>();
|
const res = mock<express.Response>();
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
import { mockInstance } from '@n8n/backend-test-utils';
|
import { mockInstance } from '@n8n/backend-test-utils';
|
||||||
|
import { SecurityConfig } from '@n8n/config';
|
||||||
|
import { Container } from '@n8n/di';
|
||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
import { ErrorReporter } from 'n8n-core';
|
import { ErrorReporter } from 'n8n-core';
|
||||||
import { UserError } from 'n8n-workflow';
|
import { UserError } from 'n8n-workflow';
|
||||||
|
|
@ -8,6 +10,7 @@ import { ConflictError, ForbiddenError, InternalServerError, NotFoundError } fro
|
||||||
import { LicenseEulaRequiredError } from '@/errors/response-errors/license-eula-required.error';
|
import { LicenseEulaRequiredError } from '@/errors/response-errors/license-eula-required.error';
|
||||||
import { PolicyViolationError } from '@/policy/policy-violation.error';
|
import { PolicyViolationError } from '@/policy/policy-violation.error';
|
||||||
import { reportError, sendErrorResponse } from '@/response-helper';
|
import { reportError, sendErrorResponse } from '@/response-helper';
|
||||||
|
import { setRouteSandboxSetting } from '@/webhooks/webhook-response-headers';
|
||||||
|
|
||||||
describe('sendErrorResponse', () => {
|
describe('sendErrorResponse', () => {
|
||||||
let mockResponse: Response;
|
let mockResponse: Response;
|
||||||
|
|
@ -56,10 +59,34 @@ describe('sendErrorResponse', () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should declare a JSON content type on error responses', () => {
|
||||||
|
// Simulate an earlier handler having declared an HTML content type.
|
||||||
|
const res = mock<Response>({
|
||||||
|
status: vi.fn().mockReturnThis(),
|
||||||
|
json: vi.fn().mockReturnThis(),
|
||||||
|
get: vi.fn().mockReturnValue('text/html; charset=utf-8'),
|
||||||
|
});
|
||||||
|
|
||||||
|
sendErrorResponse(res, new Error('Regular error'));
|
||||||
|
|
||||||
|
expect(res.setHeader).toHaveBeenCalledWith(
|
||||||
|
'Content-Type',
|
||||||
|
expect.stringContaining('application/json'),
|
||||||
|
);
|
||||||
|
expect(res.status).toHaveBeenCalledWith(500);
|
||||||
|
expect(res.json).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
code: 0,
|
||||||
|
message: 'Regular error',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
describe('form pages', () => {
|
describe('form pages', () => {
|
||||||
const responseFor = (originalUrl: string) =>
|
const responseFor = (originalUrl: string) =>
|
||||||
mock<Response>({
|
mock<Response>({
|
||||||
req: mock<Request>({ originalUrl }),
|
req: mock<Request>({ originalUrl }),
|
||||||
|
locals: {},
|
||||||
status: vi.fn().mockReturnThis(),
|
status: vi.fn().mockReturnThis(),
|
||||||
json: vi.fn().mockReturnThis(),
|
json: vi.fn().mockReturnThis(),
|
||||||
render: vi.fn().mockReturnThis(),
|
render: vi.fn().mockReturnThis(),
|
||||||
|
|
@ -88,6 +115,45 @@ describe('sendErrorResponse', () => {
|
||||||
expect.stringContaining('sandbox'),
|
expect.stringContaining('sandbox'),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('when only form sandboxing is disabled', () => {
|
||||||
|
const securityConfig = Container.get(SecurityConfig);
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
securityConfig.disableFormHtmlSandboxing = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
securityConfig.disableFormHtmlSandboxing = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should keep the webhook sandbox on a webhook path that looks like a form path', () => {
|
||||||
|
const res = responseFor('/webhook/n8n-form-demo');
|
||||||
|
setRouteSandboxSetting(res, 'webhook');
|
||||||
|
|
||||||
|
sendErrorResponse(res, new NotFoundError('not found'));
|
||||||
|
|
||||||
|
expect(res.render).toHaveBeenCalledWith('form-trigger-404', { isTestWebhook: false });
|
||||||
|
expect(res.setHeader).toHaveBeenCalledWith(
|
||||||
|
'Content-Security-Policy',
|
||||||
|
expect.stringContaining('sandbox'),
|
||||||
|
);
|
||||||
|
expect(res.removeHeader).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not sandbox the form 404 page on a form route', () => {
|
||||||
|
const res = responseFor('/form/does-not-exist');
|
||||||
|
setRouteSandboxSetting(res, 'form');
|
||||||
|
|
||||||
|
sendErrorResponse(res, new NotFoundError('not found'));
|
||||||
|
|
||||||
|
expect(res.render).toHaveBeenCalledWith('form-trigger-404', { isTestWebhook: false });
|
||||||
|
expect(res.setHeader).not.toHaveBeenCalledWith(
|
||||||
|
'Content-Security-Policy',
|
||||||
|
expect.stringContaining('sandbox'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -379,15 +379,16 @@ describe('WorkflowExecuteAdditionalData', () => {
|
||||||
parameters: {},
|
parameters: {},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
const subWorkflowData = () =>
|
// Plain object: inline code is structure-validated, which a deep mock fails.
|
||||||
mock<IWorkflowBase>({
|
const subWorkflowData = (): IWorkflowBase =>
|
||||||
|
({
|
||||||
id: 'sub-id',
|
id: 'sub-id',
|
||||||
name: 'Sub Workflow',
|
name: 'Sub Workflow',
|
||||||
nodes: structuredClone(nodes),
|
nodes: structuredClone(nodes),
|
||||||
connections: {},
|
connections: {},
|
||||||
staticData: {},
|
staticData: {},
|
||||||
settings: {},
|
settings: {},
|
||||||
});
|
}) as IWorkflowBase;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
nodeTypes.getByNameAndVersion.mockReturnValue(nodeType);
|
nodeTypes.getByNameAndVersion.mockReturnValue(nodeType);
|
||||||
|
|
@ -398,7 +399,7 @@ describe('WorkflowExecuteAdditionalData', () => {
|
||||||
|
|
||||||
it('checks credentials against the triggering user for an inline sub-workflow', async () => {
|
it('checks credentials against the triggering user for an inline sub-workflow', async () => {
|
||||||
await executeWorkflow(
|
await executeWorkflow(
|
||||||
mock<IExecuteWorkflowInfo>({ id: undefined, code: subWorkflowData() }),
|
{ code: subWorkflowData() },
|
||||||
// `rootExecutionMode` explicit: a deep mock auto-stubs every
|
// `rootExecutionMode` explicit: a deep mock auto-stubs every
|
||||||
// property, so leaving it out would hand `??` a truthy proxy.
|
// property, so leaving it out would hand `??` a truthy proxy.
|
||||||
mock<IWorkflowExecuteAdditionalData>({
|
mock<IWorkflowExecuteAdditionalData>({
|
||||||
|
|
@ -415,11 +416,24 @@ describe('WorkflowExecuteAdditionalData', () => {
|
||||||
expect(credentialsPermissionChecker.check).not.toHaveBeenCalled();
|
expect(credentialsPermissionChecker.check).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('rejects an inline sub-workflow with a non-boolean disabled flag', async () => {
|
||||||
|
const code = subWorkflowData();
|
||||||
|
code.nodes = code.nodes.map((n) => ({ ...n, disabled: 1 as unknown as boolean }));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
executeWorkflow({ code }, mock<IWorkflowExecuteAdditionalData>({ userId: 'user-1' }), {
|
||||||
|
parentWorkflowId: 'parent-1',
|
||||||
|
executionMode: 'manual',
|
||||||
|
}),
|
||||||
|
).rejects.toThrow('Workflow structure is invalid');
|
||||||
|
expect(credentialsPermissionChecker.checkForUser).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
// A sub-workflow's own WorkflowExecute runs as 'integrated', so the
|
// A sub-workflow's own WorkflowExecute runs as 'integrated', so the
|
||||||
// original mode has to come from the root.
|
// original mode has to come from the root.
|
||||||
it('checks against the triggering user through a nested inline sub-workflow', async () => {
|
it('checks against the triggering user through a nested inline sub-workflow', async () => {
|
||||||
await executeWorkflow(
|
await executeWorkflow(
|
||||||
mock<IExecuteWorkflowInfo>({ id: undefined, code: subWorkflowData() }),
|
{ code: subWorkflowData() },
|
||||||
mock<IWorkflowExecuteAdditionalData>({
|
mock<IWorkflowExecuteAdditionalData>({
|
||||||
userId: 'user-1',
|
userId: 'user-1',
|
||||||
rootExecutionMode: 'manual',
|
rootExecutionMode: 'manual',
|
||||||
|
|
@ -442,7 +456,7 @@ describe('WorkflowExecuteAdditionalData', () => {
|
||||||
// access to it has since changed.
|
// access to it has since changed.
|
||||||
it('keeps the project check for an inline sub-workflow in a triggered run', async () => {
|
it('keeps the project check for an inline sub-workflow in a triggered run', async () => {
|
||||||
await executeWorkflow(
|
await executeWorkflow(
|
||||||
mock<IExecuteWorkflowInfo>({ id: undefined, code: subWorkflowData() }),
|
{ code: subWorkflowData() },
|
||||||
mock<IWorkflowExecuteAdditionalData>({
|
mock<IWorkflowExecuteAdditionalData>({
|
||||||
userId: 'publisher-1',
|
userId: 'publisher-1',
|
||||||
rootExecutionMode: undefined,
|
rootExecutionMode: undefined,
|
||||||
|
|
@ -467,7 +481,7 @@ describe('WorkflowExecuteAdditionalData', () => {
|
||||||
'checks against the acting user for an inline sub-workflow in a %s run',
|
'checks against the acting user for an inline sub-workflow in a %s run',
|
||||||
async (executionMode) => {
|
async (executionMode) => {
|
||||||
await executeWorkflow(
|
await executeWorkflow(
|
||||||
mock<IExecuteWorkflowInfo>({ id: undefined, code: subWorkflowData() }),
|
{ code: subWorkflowData() },
|
||||||
mock<IWorkflowExecuteAdditionalData>({
|
mock<IWorkflowExecuteAdditionalData>({
|
||||||
userId: 'user-1',
|
userId: 'user-1',
|
||||||
rootExecutionMode: undefined,
|
rootExecutionMode: undefined,
|
||||||
|
|
@ -502,7 +516,7 @@ describe('WorkflowExecuteAdditionalData', () => {
|
||||||
it('falls back to the project check for an inline sub-workflow without a triggering user', async () => {
|
it('falls back to the project check for an inline sub-workflow without a triggering user', async () => {
|
||||||
const workflowData = subWorkflowData();
|
const workflowData = subWorkflowData();
|
||||||
await executeWorkflow(
|
await executeWorkflow(
|
||||||
mock<IExecuteWorkflowInfo>({ id: undefined, code: workflowData }),
|
{ code: workflowData },
|
||||||
mock<IWorkflowExecuteAdditionalData>({ userId: undefined }),
|
mock<IWorkflowExecuteAdditionalData>({ userId: undefined }),
|
||||||
{ parentWorkflowId: 'parent-1', executionMode: 'webhook' },
|
{ parentWorkflowId: 'parent-1', executionMode: 'webhook' },
|
||||||
);
|
);
|
||||||
|
|
@ -555,7 +569,7 @@ describe('WorkflowExecuteAdditionalData', () => {
|
||||||
|
|
||||||
it('preserves parent static-data persistence for inline definitions without an id', async () => {
|
it('preserves parent static-data persistence for inline definitions without an id', async () => {
|
||||||
await executeWorkflow(
|
await executeWorkflow(
|
||||||
{ code: mock<IWorkflowBase>({ ...subWorkflowData(), id: undefined }) },
|
{ code: { ...subWorkflowData(), id: undefined } as unknown as IWorkflowBase },
|
||||||
mock<IWorkflowExecuteAdditionalData>({ userId: undefined }),
|
mock<IWorkflowExecuteAdditionalData>({ userId: undefined }),
|
||||||
{ parentWorkflowId: 'parent-1', executionMode: 'webhook' },
|
{ parentWorkflowId: 'parent-1', executionMode: 'webhook' },
|
||||||
);
|
);
|
||||||
|
|
@ -579,7 +593,7 @@ describe('WorkflowExecuteAdditionalData', () => {
|
||||||
|
|
||||||
it('preserves the triggering user in the sub-workflow additional data for inline sub-workflows so nested inline calls stay scoped to that user', async () => {
|
it('preserves the triggering user in the sub-workflow additional data for inline sub-workflows so nested inline calls stay scoped to that user', async () => {
|
||||||
await executeWorkflow(
|
await executeWorkflow(
|
||||||
mock<IExecuteWorkflowInfo>({ id: undefined, code: subWorkflowData() }),
|
{ code: subWorkflowData() },
|
||||||
mock<IWorkflowExecuteAdditionalData>({ userId: 'user-1' }),
|
mock<IWorkflowExecuteAdditionalData>({ userId: 'user-1' }),
|
||||||
mock<ExecuteWorkflowOptions>({
|
mock<ExecuteWorkflowOptions>({
|
||||||
loadedWorkflowData: subWorkflowData(),
|
loadedWorkflowData: subWorkflowData(),
|
||||||
|
|
@ -1352,22 +1366,22 @@ describe('WorkflowExecuteAdditionalData', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should use provided workflow code when id is not provided', async () => {
|
it('should use provided workflow code when id is not provided', async () => {
|
||||||
const workflowCode = mock<IWorkflowBase>({
|
const workflowCode = {
|
||||||
id: 'code-workflow',
|
id: 'code-workflow',
|
||||||
name: 'Code Workflow',
|
name: 'Code Workflow',
|
||||||
active: false,
|
active: false,
|
||||||
nodes: [
|
nodes: [
|
||||||
mock<INode>({
|
{
|
||||||
id: 'node1',
|
id: 'node1',
|
||||||
type: 'n8n-nodes-base.set',
|
type: 'n8n-nodes-base.set',
|
||||||
name: 'Node 1',
|
name: 'Node 1',
|
||||||
typeVersion: 1,
|
typeVersion: 1,
|
||||||
parameters: {},
|
parameters: {},
|
||||||
position: [250, 300],
|
position: [250, 300],
|
||||||
}),
|
} as INode,
|
||||||
],
|
],
|
||||||
connections: {},
|
connections: {},
|
||||||
});
|
} as IWorkflowBase;
|
||||||
|
|
||||||
const result = await getPublishedWorkflowData({ code: workflowCode }, 'parent-workflow-id');
|
const result = await getPublishedWorkflowData({ code: workflowCode }, 'parent-workflow-id');
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -269,7 +269,7 @@ export abstract class AbstractServer {
|
||||||
// Register a handler for waiting forms (excluded from metrics to avoid double-counting)
|
// Register a handler for waiting forms (excluded from metrics to avoid double-counting)
|
||||||
this.app.all(
|
this.app.all(
|
||||||
`/${this.endpointFormWaiting}/:path{/:suffix}`,
|
`/${this.endpointFormWaiting}/:path{/:suffix}`,
|
||||||
createWebhookHandlerFor(Container.get(WaitingForms)),
|
createWebhookHandlerFor(Container.get(WaitingForms), undefined, 'form'),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Register a handler for waiting webhooks (excluded from metrics to avoid double-counting)
|
// Register a handler for waiting webhooks (excluded from metrics to avoid double-counting)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
import type { INode } from 'n8n-workflow';
|
||||||
|
import { NodeOperationError } from 'n8n-workflow';
|
||||||
|
|
||||||
|
export class UninspectableSubworkflowSourceError extends NodeOperationError {
|
||||||
|
constructor(node: INode) {
|
||||||
|
super(
|
||||||
|
node,
|
||||||
|
`The '${node.name}' node loads its sub-workflow from a source that is resolved only when the workflow runs, so its credential access cannot be checked on save. Use the "Database" or "Define Below" source instead.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -810,6 +810,77 @@ describe('CredentialsPermissionChecker', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('with a default-valued authentication parameter', () => {
|
||||||
|
const credentialId = 'password-cred';
|
||||||
|
const description = {
|
||||||
|
credentials: [
|
||||||
|
{
|
||||||
|
name: 'passwordApi',
|
||||||
|
required: true,
|
||||||
|
displayOptions: { show: { authentication: ['password'] } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'tokenApi',
|
||||||
|
required: true,
|
||||||
|
displayOptions: { show: { authentication: ['token'] } },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
properties: [
|
||||||
|
{
|
||||||
|
displayName: 'Authentication',
|
||||||
|
name: 'authentication',
|
||||||
|
type: 'options',
|
||||||
|
options: [
|
||||||
|
{ name: 'Password', value: 'password' },
|
||||||
|
{ name: 'Token', value: 'token' },
|
||||||
|
],
|
||||||
|
default: 'password',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Saved nodes omit parameters left at their default value
|
||||||
|
const node: INode = {
|
||||||
|
id: 'node-6',
|
||||||
|
name: 'Service',
|
||||||
|
type: 'n8n-nodes-base.service',
|
||||||
|
typeVersion: 1,
|
||||||
|
position: [0, 0],
|
||||||
|
parameters: { resource: 'entry' },
|
||||||
|
credentials: { passwordApi: { id: credentialId, name: 'Password' } },
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
nodeTypes.getByNameAndVersion.mockReturnValue({ description } as never);
|
||||||
|
credentialsRepository.findGlobalProjectCredentialIds.mockResolvedValue([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should check the credential selected by the default value', async () => {
|
||||||
|
sharedCredentialsRepository.getFilteredAccessibleCredentials.mockResolvedValue([]);
|
||||||
|
|
||||||
|
await expect(permissionChecker.check(workflowId, [node])).rejects.toThrow(
|
||||||
|
'Node "Service" does not have access to the credential',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(sharedCredentialsRepository.getFilteredAccessibleCredentials).toHaveBeenCalledWith(
|
||||||
|
[teamProject.id],
|
||||||
|
[credentialId],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should let the run through when the project can use the credential', async () => {
|
||||||
|
sharedCredentialsRepository.getFilteredAccessibleCredentials.mockResolvedValue([
|
||||||
|
credentialId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
await expect(permissionChecker.check(workflowId, [node])).resolves.not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return the credential id for the node', () => {
|
||||||
|
expect(permissionChecker.getCredentialIdsForNodes([node])).toEqual([credentialId]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('should fall back to checking all credentials if node type cannot be resolved', async () => {
|
it('should fall back to checking all credentials if node type cannot be resolved', async () => {
|
||||||
nodeTypes.getByNameAndVersion.mockImplementation(() => {
|
nodeTypes.getByNameAndVersion.mockImplementation(() => {
|
||||||
throw new Error('Unknown node type');
|
throw new Error('Unknown node type');
|
||||||
|
|
@ -874,6 +945,12 @@ describe('CredentialsPermissionChecker', () => {
|
||||||
expect(permissionChecker.getCredentialIdsForNodes([disabledNode])).toEqual([]);
|
expect(permissionChecker.getCredentialIdsForNodes([disabledNode])).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each([1, 'true', {}])('does not skip a node whose disabled flag is %p', (disabled) => {
|
||||||
|
const flaggedNode = { ...node, disabled } as unknown as INode;
|
||||||
|
|
||||||
|
expect(permissionChecker.getCredentialIdsForNodes([flaggedNode])).toEqual([credentialId]);
|
||||||
|
});
|
||||||
|
|
||||||
it('skips a credential reference with no id instead of throwing', () => {
|
it('skips a credential reference with no id instead of throwing', () => {
|
||||||
const nodeWithNoId: INode = {
|
const nodeWithNoId: INode = {
|
||||||
...node,
|
...node,
|
||||||
|
|
|
||||||
|
|
@ -480,7 +480,7 @@ export class CredentialsPermissionChecker {
|
||||||
getCredentialIdsForNodes(nodes: INode[]): string[] {
|
getCredentialIdsForNodes(nodes: INode[]): string[] {
|
||||||
const ids = new Set<string>();
|
const ids = new Set<string>();
|
||||||
for (const node of nodes) {
|
for (const node of nodes) {
|
||||||
if (node.disabled || !node.credentials) continue;
|
if (node.disabled === true || !node.credentials) continue;
|
||||||
|
|
||||||
const activeCredTypes = this.getActiveCredentialTypes(node);
|
const activeCredTypes = this.getActiveCredentialTypes(node);
|
||||||
|
|
||||||
|
|
@ -495,7 +495,7 @@ export class CredentialsPermissionChecker {
|
||||||
|
|
||||||
private mapCredIdsToNodes(nodes: INode[]) {
|
private mapCredIdsToNodes(nodes: INode[]) {
|
||||||
return nodes.reduce<{ [credentialId: string]: INode[] }>((map, node) => {
|
return nodes.reduce<{ [credentialId: string]: INode[] }>((map, node) => {
|
||||||
if (node.disabled || !node.credentials) return map;
|
if (node.disabled === true || !node.credentials) return map;
|
||||||
|
|
||||||
const activeCredTypes = this.getActiveCredentialTypes(node);
|
const activeCredTypes = this.getActiveCredentialTypes(node);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,9 @@ import type { Request, Response } from 'express';
|
||||||
import { mock } from 'vitest-mock-extended';
|
import { mock } from 'vitest-mock-extended';
|
||||||
import { Readable } from 'stream';
|
import { Readable } from 'stream';
|
||||||
|
|
||||||
import { BadRequestError } from '@n8n/errors';
|
import { BadRequestError, UnprocessableRequestError } from '@n8n/errors';
|
||||||
|
|
||||||
import { rawBodyReader } from '../body-parser';
|
import { parseBody, rawBodyReader } from '../body-parser';
|
||||||
|
|
||||||
/** Build a request backed by a real readable stream carrying `payload`. */
|
/** Build a request backed by a real readable stream carrying `payload`. */
|
||||||
const mockRequest = (payload = '', headers: Record<string, string> = {}) => {
|
const mockRequest = (payload = '', headers: Record<string, string> = {}) => {
|
||||||
|
|
@ -57,3 +57,36 @@ describe('rawBodyReader', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('parseBody', () => {
|
||||||
|
const xmlRequest = (body: string) =>
|
||||||
|
({
|
||||||
|
contentType: 'text/xml',
|
||||||
|
encoding: 'utf-8',
|
||||||
|
rawBody: Buffer.from(body),
|
||||||
|
readRawBody: async () => {},
|
||||||
|
}) as unknown as Request;
|
||||||
|
|
||||||
|
// Bodies over 10,000 chars make xml2js parse in chunks across ticks.
|
||||||
|
const largeXml = (id: string) =>
|
||||||
|
`<?xml version="1.0"?><Envelope><Body>${'<Item><Name>x</Name></Item>'.repeat(1000)}<Id>${id}</Id></Body></Envelope>`;
|
||||||
|
|
||||||
|
it('parses concurrent large XML bodies independently', async () => {
|
||||||
|
const requests = ['a', 'b', 'c'].map((id) => xmlRequest(largeXml(id)));
|
||||||
|
|
||||||
|
await Promise.all(requests.map(parseBody));
|
||||||
|
|
||||||
|
expect(requests.map((req) => req.body.envelope.body.id)).toEqual(['a', 'b', 'c']);
|
||||||
|
|
||||||
|
const after = xmlRequest('<root><id>after</id></root>');
|
||||||
|
await parseBody(after);
|
||||||
|
expect(after.body).toEqual({ root: { id: 'after' } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an XML body that fails inside an element handler', async () => {
|
||||||
|
// An element named `_` collides with the text key and throws while closing the tag.
|
||||||
|
await expect(parseBody(xmlRequest('<a><_>x</_></a>'))).rejects.toThrow(
|
||||||
|
UnprocessableRequestError,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -11,14 +11,17 @@ import { createGunzip, createInflate } from 'zlib';
|
||||||
|
|
||||||
import { BadRequestError, UnprocessableRequestError } from '@n8n/errors';
|
import { BadRequestError, UnprocessableRequestError } from '@n8n/errors';
|
||||||
|
|
||||||
const xmlParser = new XmlParser({
|
// One parser per request: xml2js keeps parse state on the instance, and async
|
||||||
async: true,
|
// mode spreads large bodies over several ticks.
|
||||||
normalize: true, // Trim whitespace inside text nodes
|
const createXmlParser = () =>
|
||||||
normalizeTags: true, // Transform tags to lowercase
|
new XmlParser({
|
||||||
explicitArray: false, // Only put properties in array if length > 1
|
async: true,
|
||||||
tagNameProcessors: [sanitizeXmlName],
|
normalize: true, // Trim whitespace inside text nodes
|
||||||
attrNameProcessors: [sanitizeXmlName],
|
normalizeTags: true, // Transform tags to lowercase
|
||||||
});
|
explicitArray: false, // Only put properties in array if length > 1
|
||||||
|
tagNameProcessors: [sanitizeXmlName],
|
||||||
|
attrNameProcessors: [sanitizeXmlName],
|
||||||
|
});
|
||||||
|
|
||||||
const payloadSizeMax = Container.get(GlobalConfig).endpoints.payloadSizeMax;
|
const payloadSizeMax = Container.get(GlobalConfig).endpoints.payloadSizeMax;
|
||||||
|
|
||||||
|
|
@ -97,7 +100,7 @@ export const parseBody = async (req: Request) => {
|
||||||
req.body = jsonParse(stripBom(rawBody.toString(encoding)));
|
req.body = jsonParse(stripBom(rawBody.toString(encoding)));
|
||||||
} else if (contentType?.endsWith('/xml') || contentType?.endsWith('+xml')) {
|
} else if (contentType?.endsWith('/xml') || contentType?.endsWith('+xml')) {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||||
req.body = await xmlParser.parseStringPromise(rawBody.toString(encoding));
|
req.body = await createXmlParser().parseStringPromise(rawBody.toString(encoding));
|
||||||
} else if (contentType === 'application/x-www-form-urlencoded') {
|
} else if (contentType === 'application/x-www-form-urlencoded') {
|
||||||
req.body = parseQueryString(stripBom(rawBody.toString(encoding)), undefined, undefined, {
|
req.body = parseQueryString(stripBom(rawBody.toString(encoding)), undefined, undefined, {
|
||||||
maxKeys: 1000,
|
maxKeys: 1000,
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ describe('List query middleware', () => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
|
||||||
mockReq = { baseUrl: '/rest/workflows' } as ListQuery.Request;
|
mockReq = { baseUrl: '/rest/workflows' } as ListQuery.Request;
|
||||||
mockRes = { status: () => ({ json: vi.fn() }) } as unknown as Response;
|
mockRes = { status: () => ({ json: vi.fn() }), setHeader: vi.fn() } as unknown as Response;
|
||||||
args = [mockReq, mockRes, nextFn];
|
args = [mockReq, mockRes, nextFn];
|
||||||
|
|
||||||
sendErrorResponse = vi.spyOn(ResponseHelper, 'sendErrorResponse');
|
sendErrorResponse = vi.spyOn(ResponseHelper, 'sendErrorResponse');
|
||||||
|
|
|
||||||
|
|
@ -76,25 +76,6 @@ export class WorkflowFilter extends BaseFilter {
|
||||||
@Expose()
|
@Expose()
|
||||||
executionBlockedBy?: WorkflowExecutionBlockCause[];
|
executionBlockedBy?: WorkflowExecutionBlockCause[];
|
||||||
|
|
||||||
/**
|
|
||||||
* When `true`, the list is widened to include workflows that the workflow
|
|
||||||
* identified by `parentWorkflowId` is permitted to call, even if those
|
|
||||||
* workflows are not directly shared with the requesting user.
|
|
||||||
*/
|
|
||||||
@IsBoolean()
|
|
||||||
@IsOptional()
|
|
||||||
@Expose()
|
|
||||||
includeCallableSubworkflows?: boolean;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* ID of the workflow that will act as the caller.
|
|
||||||
* Used together with `includeCallableSubworkflows` to evaluate `callerPolicy`.
|
|
||||||
*/
|
|
||||||
@IsString()
|
|
||||||
@IsOptional()
|
|
||||||
@Expose()
|
|
||||||
parentWorkflowId?: string;
|
|
||||||
|
|
||||||
static async fromString(rawFilter: string) {
|
static async fromString(rawFilter: string) {
|
||||||
return await this.toFilter(rawFilter, WorkflowFilter);
|
return await this.toFilter(rawFilter, WorkflowFilter);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,9 @@ vi.mock('../agent-eval-instructions-rewrite', () => ({ rewriteAgentInstructions:
|
||||||
vi.mock('../agent-eval-case-generation.service', () => ({
|
vi.mock('../agent-eval-case-generation.service', () => ({
|
||||||
AgentEvalCaseGenerationService: class AgentEvalCaseGenerationService {},
|
AgentEvalCaseGenerationService: class AgentEvalCaseGenerationService {},
|
||||||
}));
|
}));
|
||||||
|
vi.mock('@/permissions.ee/check-access', () => ({
|
||||||
|
userHasScopes: vi.fn().mockResolvedValue(true),
|
||||||
|
}));
|
||||||
|
|
||||||
const AGENT_ID = 'agent-1';
|
const AGENT_ID = 'agent-1';
|
||||||
const PROJECT_ID = 'proj-1';
|
const PROJECT_ID = 'proj-1';
|
||||||
|
|
@ -132,6 +135,7 @@ describe('AgentEvalService', () => {
|
||||||
runRepository.findByIdAndAgentId.mockResolvedValue(makeRun());
|
runRepository.findByIdAndAgentId.mockResolvedValue(makeRun());
|
||||||
runRepository.findAndCountByDatasetIdAndAgentId.mockResolvedValue([[], 0]);
|
runRepository.findAndCountByDatasetIdAndAgentId.mockResolvedValue([[], 0]);
|
||||||
resultRepository.findAndCountByRunId.mockResolvedValue([[], 0]);
|
resultRepository.findAndCountByRunId.mockResolvedValue([[], 0]);
|
||||||
|
vi.mocked(userHasScopes).mockReset().mockResolvedValue(true);
|
||||||
resultRepository.findById.mockResolvedValue(makeResult());
|
resultRepository.findById.mockResolvedValue(makeResult());
|
||||||
|
|
||||||
service = new AgentEvalService(
|
service = new AgentEvalService(
|
||||||
|
|
@ -181,7 +185,10 @@ describe('AgentEvalService', () => {
|
||||||
['previewRun', async () => await service.previewRun(user, AGENT_ID, PROJECT_ID, {})],
|
['previewRun', async () => await service.previewRun(user, AGENT_ID, PROJECT_ID, {})],
|
||||||
['startRun', async () => await service.startRun(user, AGENT_ID, PROJECT_ID, 'ds-1', {})],
|
['startRun', async () => await service.startRun(user, AGENT_ID, PROJECT_ID, 'ds-1', {})],
|
||||||
['listRuns', async () => await service.listRuns(AGENT_ID, PROJECT_ID, 'ds-1', PAGE)],
|
['listRuns', async () => await service.listRuns(AGENT_ID, PROJECT_ID, 'ds-1', PAGE)],
|
||||||
['getRunDetail', async () => await service.getRunDetail(AGENT_ID, PROJECT_ID, 'run-1', PAGE)],
|
[
|
||||||
|
'getRunDetail',
|
||||||
|
async () => await service.getRunDetail(user, AGENT_ID, PROJECT_ID, 'run-1', PAGE),
|
||||||
|
],
|
||||||
['getRunSummary', async () => await service.getRunSummary(AGENT_ID, PROJECT_ID, 'run-1')],
|
['getRunSummary', async () => await service.getRunSummary(AGENT_ID, PROJECT_ID, 'run-1')],
|
||||||
['cancelRun', async () => await service.cancelRun(AGENT_ID, PROJECT_ID, 'run-1')],
|
['cancelRun', async () => await service.cancelRun(AGENT_ID, PROJECT_ID, 'run-1')],
|
||||||
[
|
[
|
||||||
|
|
@ -255,9 +262,9 @@ describe('AgentEvalService', () => {
|
||||||
it('404s a run belonging to another agent, and reads no results for it', async () => {
|
it('404s a run belonging to another agent, and reads no results for it', async () => {
|
||||||
runRepository.findByIdAndAgentId.mockResolvedValue(null);
|
runRepository.findByIdAndAgentId.mockResolvedValue(null);
|
||||||
|
|
||||||
await expect(service.getRunDetail(AGENT_ID, PROJECT_ID, 'run-other', PAGE)).rejects.toThrow(
|
await expect(
|
||||||
NotFoundError,
|
service.getRunDetail(user, AGENT_ID, PROJECT_ID, 'run-other', PAGE),
|
||||||
);
|
).rejects.toThrow(NotFoundError);
|
||||||
expect(resultRepository.findAndCountByRunId).not.toHaveBeenCalled();
|
expect(resultRepository.findAndCountByRunId).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -956,6 +963,77 @@ describe('AgentEvalService', () => {
|
||||||
createdById: 'user-1',
|
createdById: 'user-1',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('checks the creating user can read the referenced data table', async () => {
|
||||||
|
datasetRepository.createDataset.mockResolvedValue(makeDataset());
|
||||||
|
|
||||||
|
await service.createDataset(user, AGENT_ID, PROJECT_ID, {
|
||||||
|
name: 'cases',
|
||||||
|
agentId: AGENT_ID,
|
||||||
|
datasetSource: 'data_table',
|
||||||
|
datasetRef: { dataTableId: 'dt-1' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(userHasScopes).toHaveBeenCalledWith(user, ['dataTable:readRow'], false, {
|
||||||
|
dataTableId: 'dt-1',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a data table the creating user cannot read', async () => {
|
||||||
|
vi.mocked(userHasScopes).mockResolvedValue(false);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.createDataset(user, AGENT_ID, PROJECT_ID, {
|
||||||
|
name: 'cases',
|
||||||
|
agentId: AGENT_ID,
|
||||||
|
datasetSource: 'data_table',
|
||||||
|
datasetRef: { dataTableId: 'dt-other' },
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(ForbiddenError);
|
||||||
|
expect(datasetRepository.createDataset).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Results hold copies of the source rows, and the table can live in another
|
||||||
|
// project than the agent, so `agent:read` alone must not open them.
|
||||||
|
describe('getRunDetail data table access', () => {
|
||||||
|
it('checks the reader can read the run dataset’s data table', async () => {
|
||||||
|
await service.getRunDetail(user, AGENT_ID, PROJECT_ID, 'run-1', PAGE);
|
||||||
|
|
||||||
|
expect(datasetRepository.findByIdAndAgentId).toHaveBeenCalledWith('ds-1', AGENT_ID);
|
||||||
|
expect(userHasScopes).toHaveBeenCalledWith(user, ['dataTable:readRow'], false, {
|
||||||
|
dataTableId: 'dt-1',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a reader without access to the data table, and reads no results', async () => {
|
||||||
|
vi.mocked(userHasScopes).mockResolvedValue(false);
|
||||||
|
|
||||||
|
await expect(service.getRunDetail(user, AGENT_ID, PROJECT_ID, 'run-1', PAGE)).rejects.toThrow(
|
||||||
|
ForbiddenError,
|
||||||
|
);
|
||||||
|
expect(resultRepository.findAndCountByRunId).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads no results when the run’s dataset does not resolve', async () => {
|
||||||
|
datasetRepository.findByIdAndAgentId.mockResolvedValue(null);
|
||||||
|
|
||||||
|
await expect(service.getRunDetail(user, AGENT_ID, PROJECT_ID, 'run-1', PAGE)).rejects.toThrow(
|
||||||
|
NotFoundError,
|
||||||
|
);
|
||||||
|
expect(resultRepository.findAndCountByRunId).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a data-table dataset whose ref carries no table id', async () => {
|
||||||
|
datasetRepository.findByIdAndAgentId.mockResolvedValue(
|
||||||
|
makeDataset({ datasetRef: { credentialId: 'c-1', spreadsheetId: 's-1', sheetName: 'S' } }),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(service.getRunDetail(user, AGENT_ID, PROJECT_ID, 'run-1', PAGE)).rejects.toThrow(
|
||||||
|
/without a table id/,
|
||||||
|
);
|
||||||
|
expect(resultRepository.findAndCountByRunId).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('deleteDataset', () => {
|
describe('deleteDataset', () => {
|
||||||
|
|
@ -1039,7 +1117,7 @@ describe('AgentEvalService', () => {
|
||||||
|
|
||||||
describe('response mapping', () => {
|
describe('response mapping', () => {
|
||||||
it('serializes dates as ISO strings and keeps run coordination columns off the wire', async () => {
|
it('serializes dates as ISO strings and keeps run coordination columns off the wire', async () => {
|
||||||
const detail = await service.getRunDetail(AGENT_ID, PROJECT_ID, 'run-1', PAGE);
|
const detail = await service.getRunDetail(user, AGENT_ID, PROJECT_ID, 'run-1', PAGE);
|
||||||
|
|
||||||
expect(detail.runAt).toBe('2026-01-03T00:00:00.000Z');
|
expect(detail.runAt).toBe('2026-01-03T00:00:00.000Z');
|
||||||
expect(detail.createdAt).toBe('2026-01-03T00:00:00.000Z');
|
expect(detail.createdAt).toBe('2026-01-03T00:00:00.000Z');
|
||||||
|
|
@ -1106,7 +1184,7 @@ describe('AgentEvalService', () => {
|
||||||
1,
|
1,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const detail = await service.getRunDetail(AGENT_ID, PROJECT_ID, 'run-1', PAGE);
|
const detail = await service.getRunDetail(user, AGENT_ID, PROJECT_ID, 'run-1', PAGE);
|
||||||
|
|
||||||
expect(detail.results.data).toHaveLength(1);
|
expect(detail.results.data).toHaveLength(1);
|
||||||
expect(detail.results.data[0]).toMatchObject({
|
expect(detail.results.data[0]).toMatchObject({
|
||||||
|
|
@ -1138,7 +1216,7 @@ describe('AgentEvalService', () => {
|
||||||
it('passes the run-detail window to the repository and reports the case total', async () => {
|
it('passes the run-detail window to the repository and reports the case total', async () => {
|
||||||
resultRepository.findAndCountByRunId.mockResolvedValue([[], 500]);
|
resultRepository.findAndCountByRunId.mockResolvedValue([[], 500]);
|
||||||
|
|
||||||
const detail = await service.getRunDetail(AGENT_ID, PROJECT_ID, 'run-1', {
|
const detail = await service.getRunDetail(user, AGENT_ID, PROJECT_ID, 'run-1', {
|
||||||
take: 25,
|
take: 25,
|
||||||
skip: 50,
|
skip: 50,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -485,7 +485,7 @@ describe('AgentEvalsController', () => {
|
||||||
|
|
||||||
await controller.getRun(runReq(), undefined, query);
|
await controller.getRun(runReq(), undefined, query);
|
||||||
|
|
||||||
expect(service.getRunDetail).toHaveBeenCalledWith(AGENT_ID, PROJECT_ID, 'run-1', {
|
expect(service.getRunDetail).toHaveBeenCalledWith(user, AGENT_ID, PROJECT_ID, 'run-1', {
|
||||||
take: 25,
|
take: 25,
|
||||||
skip: 50,
|
skip: 50,
|
||||||
});
|
});
|
||||||
|
|
@ -507,7 +507,7 @@ describe('AgentEvalsController', () => {
|
||||||
it('defaults the run-detail window wider than the shared list default', async () => {
|
it('defaults the run-detail window wider than the shared list default', async () => {
|
||||||
await controller.getRun(runReq(), undefined, AgentEvalRunDetailQueryDto.parse({}));
|
await controller.getRun(runReq(), undefined, AgentEvalRunDetailQueryDto.parse({}));
|
||||||
|
|
||||||
expect(service.getRunDetail).toHaveBeenCalledWith(AGENT_ID, PROJECT_ID, 'run-1', {
|
expect(service.getRunDetail).toHaveBeenCalledWith(user, AGENT_ID, PROJECT_ID, 'run-1', {
|
||||||
take: AGENT_EVAL_RESULTS_DEFAULT_TAKE,
|
take: AGENT_EVAL_RESULTS_DEFAULT_TAKE,
|
||||||
skip: 0,
|
skip: 0,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ import {
|
||||||
AgentEvalRunRepository,
|
AgentEvalRunRepository,
|
||||||
} from '@n8n/db';
|
} from '@n8n/db';
|
||||||
import { Service } from '@n8n/di';
|
import { Service } from '@n8n/di';
|
||||||
|
import { UnexpectedError } from 'n8n-workflow';
|
||||||
import pLimit from 'p-limit';
|
import pLimit from 'p-limit';
|
||||||
|
|
||||||
import { BadRequestError, ForbiddenError, NotFoundError, OperationalError } from '@n8n/errors';
|
import { BadRequestError, ForbiddenError, NotFoundError, OperationalError } from '@n8n/errors';
|
||||||
|
|
@ -110,6 +111,10 @@ export class AgentEvalService {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (payload.datasetSource === 'data_table') {
|
||||||
|
await this.assertDataTableReadable(user, payload.datasetRef.dataTableId);
|
||||||
|
}
|
||||||
|
|
||||||
const dataset = await this.datasetRepository.createDataset({
|
const dataset = await this.datasetRepository.createDataset({
|
||||||
name: payload.name,
|
name: payload.name,
|
||||||
description: payload.description ?? null,
|
description: payload.description ?? null,
|
||||||
|
|
@ -323,8 +328,13 @@ export class AgentEvalService {
|
||||||
return { count, data: runs.map(toRunRecord) };
|
return { count, data: runs.map(toRunRecord) };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A run with a page of its per-case results — the "open a run" view. */
|
/**
|
||||||
|
* A run with a page of its per-case results — the "open a run" view. Results
|
||||||
|
* hold copies of the source rows, so the reader needs the same table access
|
||||||
|
* the runner required of whoever started the run.
|
||||||
|
*/
|
||||||
async getRunDetail(
|
async getRunDetail(
|
||||||
|
user: User,
|
||||||
agentId: string,
|
agentId: string,
|
||||||
projectId: string,
|
projectId: string,
|
||||||
runId: string,
|
runId: string,
|
||||||
|
|
@ -332,6 +342,7 @@ export class AgentEvalService {
|
||||||
): Promise<AgentEvalRunDetail> {
|
): Promise<AgentEvalRunDetail> {
|
||||||
await this.assertAgentInProject(agentId, projectId);
|
await this.assertAgentInProject(agentId, projectId);
|
||||||
const run = await this.resolveRun(agentId, runId);
|
const run = await this.resolveRun(agentId, runId);
|
||||||
|
await this.assertDatasetSourceReadable(user, await this.resolveDataset(agentId, run.datasetId));
|
||||||
const [results, count] = await this.resultRepository.findAndCountByRunId(runId, page);
|
const [results, count] = await this.resultRepository.findAndCountByRunId(runId, page);
|
||||||
return { ...toRunRecord(run), results: { count, data: results.map(toResultRecord) } };
|
return { ...toRunRecord(run), results: { count, data: results.map(toResultRecord) } };
|
||||||
}
|
}
|
||||||
|
|
@ -541,6 +552,24 @@ export class AgentEvalService {
|
||||||
return dataset;
|
return dataset;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The table may live in another project than the agent, so `@ProjectScope` on
|
||||||
|
// the agent's project says nothing about it. Only data-table datasets are
|
||||||
|
// runnable today, so no other source has stored rows to protect.
|
||||||
|
private async assertDatasetSourceReadable(user: User, dataset: AgentEvalDataset): Promise<void> {
|
||||||
|
if (dataset.datasetSource !== 'data_table') return;
|
||||||
|
if (!('dataTableId' in dataset.datasetRef)) {
|
||||||
|
throw new UnexpectedError(
|
||||||
|
`Agent eval dataset ${dataset.id} has a 'data_table' source without a table id.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await this.assertDataTableReadable(user, dataset.datasetRef.dataTableId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertDataTableReadable(user: User, dataTableId: string): Promise<void> {
|
||||||
|
const allowed = await userHasScopes(user, ['dataTable:readRow'], false, { dataTableId });
|
||||||
|
if (!allowed) throw new ForbiddenError('You do not have access to this dataset.');
|
||||||
|
}
|
||||||
|
|
||||||
private async resolveRun(agentId: string, runId: string): Promise<AgentEvalRun> {
|
private async resolveRun(agentId: string, runId: string): Promise<AgentEvalRun> {
|
||||||
const run = await this.runRepository.findByIdAndAgentId(runId, agentId);
|
const run = await this.runRepository.findByIdAndAgentId(runId, agentId);
|
||||||
if (!run) throw new NotFoundError(`Agent eval run ${runId} not found.`);
|
if (!run) throw new NotFoundError(`Agent eval run ${runId} not found.`);
|
||||||
|
|
|
||||||
|
|
@ -255,7 +255,7 @@ export class AgentEvalsController {
|
||||||
): Promise<AgentEvalRunDetail> {
|
): Promise<AgentEvalRunDetail> {
|
||||||
await this.flagGate.assertEnabled(req.user);
|
await this.flagGate.assertEnabled(req.user);
|
||||||
const { agentId, projectId, runId } = req.params;
|
const { agentId, projectId, runId } = req.params;
|
||||||
return await this.service.getRunDetail(agentId, projectId, runId, query);
|
return await this.service.getRunDetail(req.user, agentId, projectId, runId, query);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Counts only — cheap enough for the UI to poll while a run is in flight. */
|
/** Counts only — cheap enough for the UI to poll while a run is in flight. */
|
||||||
|
|
|
||||||
|
|
@ -300,6 +300,79 @@ describe('AgentModelCatalogService', () => {
|
||||||
expect(result.models.map((m) => m.id)).toEqual(['claude-sonnet-4-6']);
|
expect(result.models.map((m) => m.id)).toEqual(['claude-sonnet-4-6']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps the gateway label of a google model missing from the catalog', async () => {
|
||||||
|
const { service, lookupService } = makeService();
|
||||||
|
lookupService.lookup.mockResolvedValue({
|
||||||
|
status: 'success',
|
||||||
|
policy: 'managed',
|
||||||
|
models: [{ name: 'Gemini Brand New', value: 'models/gemini-brand-new' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.getProviderModels(
|
||||||
|
user,
|
||||||
|
'project-1',
|
||||||
|
'google',
|
||||||
|
AI_GATEWAY_MANAGED_TAG,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.models).toEqual([
|
||||||
|
expect.objectContaining({ id: 'gemini-brand-new', name: 'Gemini Brand New' }),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prefers the gateway name over the catalog name and keeps its priority for a managed model', async () => {
|
||||||
|
const { service, lookupService } = makeService();
|
||||||
|
lookupService.lookup.mockResolvedValue({
|
||||||
|
status: 'success',
|
||||||
|
policy: 'managed',
|
||||||
|
models: [
|
||||||
|
{ name: 'Gemini 2.5 Flash (Gateway)', value: 'models/gemini-2.5-flash', priority: 10 },
|
||||||
|
{ name: 'models/gemini-2.5-flash-lite', value: 'models/gemini-2.5-flash-lite' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.getProviderModels(
|
||||||
|
user,
|
||||||
|
'project-1',
|
||||||
|
'google',
|
||||||
|
AI_GATEWAY_MANAGED_TAG,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.models).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
id: 'gemini-2.5-flash',
|
||||||
|
name: 'Gemini 2.5 Flash (Gateway)',
|
||||||
|
reasoning: true,
|
||||||
|
priority: 10,
|
||||||
|
}),
|
||||||
|
expect.objectContaining({ id: 'gemini-2.5-flash-lite', name: 'gemini-2.5-flash-lite' }),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to the catalog name or the id when the gateway reports no name', async () => {
|
||||||
|
const { service, lookupService } = makeService();
|
||||||
|
lookupService.lookup.mockResolvedValue({
|
||||||
|
status: 'success',
|
||||||
|
policy: 'managed',
|
||||||
|
models: [
|
||||||
|
{ name: '', value: 'models/gemini-2.5-flash' },
|
||||||
|
{ name: '', value: 'models/gemini-brand-new', priority: 5 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.getProviderModels(
|
||||||
|
user,
|
||||||
|
'project-1',
|
||||||
|
'google',
|
||||||
|
AI_GATEWAY_MANAGED_TAG,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.models).toEqual([
|
||||||
|
expect.objectContaining({ id: 'gemini-2.5-flash', name: 'Gemini 2.5 Flash' }),
|
||||||
|
{ id: 'gemini-brand-new', name: 'gemini-brand-new', toolCall: true, priority: 5 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it('strips the "models/" prefix from google model ids before matching', async () => {
|
it('strips the "models/" prefix from google model ids before matching', async () => {
|
||||||
const { service, lookupService } = makeService();
|
const { service, lookupService } = makeService();
|
||||||
lookupService.lookup.mockResolvedValue({
|
lookupService.lookup.mockResolvedValue({
|
||||||
|
|
|
||||||
|
|
@ -21,11 +21,11 @@ describe('AgentsListController', () => {
|
||||||
const res = mock<Response>();
|
const res = mock<Response>();
|
||||||
const query = { skip: 0, take: 50, sortBy: 'updatedAt:desc' } as never;
|
const query = { skip: 0, take: 50, sortBy: 'updatedAt:desc' } as never;
|
||||||
|
|
||||||
agentsService.findByUserMembershipPaginated.mockResolvedValue(response);
|
agentsService.findListableByUserPaginated.mockResolvedValue(response);
|
||||||
|
|
||||||
await controller.list(req, res, query);
|
await controller.list(req, res, query);
|
||||||
|
|
||||||
expect(agentsService.findByUserMembershipPaginated).toHaveBeenCalledWith(user, query);
|
expect(agentsService.findListableByUserPaginated).toHaveBeenCalledWith(user, query);
|
||||||
expect(agentsService.findChatReachableByUserPaginated).not.toHaveBeenCalled();
|
expect(agentsService.findChatReachableByUserPaginated).not.toHaveBeenCalled();
|
||||||
expect(res.json).toHaveBeenCalledWith(response);
|
expect(res.json).toHaveBeenCalledWith(response);
|
||||||
});
|
});
|
||||||
|
|
@ -35,11 +35,11 @@ describe('AgentsListController', () => {
|
||||||
const res = mock<Response>();
|
const res = mock<Response>();
|
||||||
const query = { skip: 0, take: 10, filter: { query: 'support' } } as never;
|
const query = { skip: 0, take: 10, filter: { query: 'support' } } as never;
|
||||||
|
|
||||||
agentsService.findByUserMembershipPaginated.mockResolvedValue({ count: 0, data: [] } as never);
|
agentsService.findListableByUserPaginated.mockResolvedValue({ count: 0, data: [] } as never);
|
||||||
|
|
||||||
await controller.list(req, res, query);
|
await controller.list(req, res, query);
|
||||||
|
|
||||||
expect(agentsService.findByUserMembershipPaginated).toHaveBeenCalledWith(user, query);
|
expect(agentsService.findListableByUserPaginated).toHaveBeenCalledWith(user, query);
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each([true, false])('serves the chat list when availableInChat is %s', async (value) => {
|
it.each([true, false])('serves the chat list when availableInChat is %s', async (value) => {
|
||||||
|
|
@ -53,7 +53,7 @@ describe('AgentsListController', () => {
|
||||||
await controller.list(req, res, query);
|
await controller.list(req, res, query);
|
||||||
|
|
||||||
expect(agentsService.findChatReachableByUserPaginated).toHaveBeenCalledWith(user, query);
|
expect(agentsService.findChatReachableByUserPaginated).toHaveBeenCalledWith(user, query);
|
||||||
expect(agentsService.findByUserMembershipPaginated).not.toHaveBeenCalled();
|
expect(agentsService.findListableByUserPaginated).not.toHaveBeenCalled();
|
||||||
expect(res.json).toHaveBeenCalledWith(response);
|
expect(res.json).toHaveBeenCalledWith(response);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -63,7 +63,7 @@ describe('AgentsListController', () => {
|
||||||
const query = { skip: 0, take: 50 } as never;
|
const query = { skip: 0, take: 50 } as never;
|
||||||
const emptyResponse = { count: 0, data: [] } as never;
|
const emptyResponse = { count: 0, data: [] } as never;
|
||||||
|
|
||||||
agentsService.findByUserMembershipPaginated.mockResolvedValue(emptyResponse);
|
agentsService.findListableByUserPaginated.mockResolvedValue(emptyResponse);
|
||||||
|
|
||||||
await controller.list(req, res, query);
|
await controller.list(req, res, query);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1134,28 +1134,36 @@ describe('AgentsService', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('findByUserMembershipPaginated', () => {
|
describe('findListableByUserPaginated', () => {
|
||||||
const user = { id: 'user-1' } as unknown as User;
|
const user = { id: 'user-1' } as unknown as User;
|
||||||
|
|
||||||
it('uses bare project membership and does not consult the scope service', async () => {
|
it('scopes to agent:list, the scope the project list route requires', async () => {
|
||||||
const { service, agentRepository, projectRelationRepository, projectScopeService } =
|
const { service, agentRepository, projectRelationRepository, projectScopeService } =
|
||||||
makeService();
|
makeService();
|
||||||
projectRelationRepository.findAllByUser.mockResolvedValue([
|
projectScopeService.getProjectIds.mockResolvedValue(['project-1', 'project-2']);
|
||||||
{ projectId: 'project-1' },
|
|
||||||
{ projectId: 'project-2' },
|
|
||||||
] as never);
|
|
||||||
agentRepository.findByProjectIdsPaginated.mockResolvedValue({ count: 0, data: [] });
|
agentRepository.findByProjectIdsPaginated.mockResolvedValue({ count: 0, data: [] });
|
||||||
const options = { skip: 0, take: 10 } as never;
|
const options = { skip: 0, take: 10 } as never;
|
||||||
|
|
||||||
await service.findByUserMembershipPaginated(user, options);
|
await service.findListableByUserPaginated(user, options);
|
||||||
|
|
||||||
expect(projectRelationRepository.findAllByUser).toHaveBeenCalledWith('user-1');
|
expect(projectScopeService.getProjectIds).toHaveBeenCalledWith(user, ['agent:list']);
|
||||||
expect(projectScopeService.getProjectIds).not.toHaveBeenCalled();
|
expect(projectRelationRepository.findAllByUser).not.toHaveBeenCalled();
|
||||||
expect(agentRepository.findByProjectIdsPaginated).toHaveBeenCalledWith(
|
expect(agentRepository.findByProjectIdsPaginated).toHaveBeenCalledWith(
|
||||||
['project-1', 'project-2'],
|
['project-1', 'project-2'],
|
||||||
options,
|
options,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('passes a null project scope through, so a global role sees every project', async () => {
|
||||||
|
const { service, agentRepository, projectScopeService } = makeService();
|
||||||
|
projectScopeService.getProjectIds.mockResolvedValue(null);
|
||||||
|
agentRepository.findByProjectIdsPaginated.mockResolvedValue({ count: 0, data: [] });
|
||||||
|
const options = { skip: 0, take: 10 } as never;
|
||||||
|
|
||||||
|
await service.findListableByUserPaginated(user, options);
|
||||||
|
|
||||||
|
expect(agentRepository.findByProjectIdsPaginated).toHaveBeenCalledWith(null, options);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('getCapabilitySummary', () => {
|
describe('getCapabilitySummary', () => {
|
||||||
|
|
|
||||||
|
|
@ -138,13 +138,13 @@ export class AgentModelCatalogService {
|
||||||
return liveModels.map((live) => {
|
return liveModels.map((live) => {
|
||||||
const id = normalizeProviderModelId(provider, live.value);
|
const id = normalizeProviderModelId(provider, live.value);
|
||||||
const catalogMatch = catalogModels[id] ?? catalogModels[stripSnapshotSuffix(id)];
|
const catalogMatch = catalogModels[id] ?? catalogModels[stripSnapshotSuffix(id)];
|
||||||
|
// The gateway names the models it serves; a name that only repeats the id does not count.
|
||||||
|
const liveName = normalizeProviderModelId(provider, live.name);
|
||||||
|
const gatewayName = liveName && liveName !== id ? liveName : undefined;
|
||||||
|
const priority = live.priority !== undefined ? { priority: live.priority } : {};
|
||||||
return catalogMatch
|
return catalogMatch
|
||||||
? { ...catalogMatch, id }
|
? { ...catalogMatch, id, ...(gatewayName ? { name: gatewayName } : {}), ...priority }
|
||||||
: {
|
: { id, name: gatewayName ?? id, toolCall: true, ...priority };
|
||||||
id,
|
|
||||||
name: normalizeProviderModelId(provider, live.name) || id,
|
|
||||||
toolCall: true,
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,8 @@ import { parseThreadListLimit } from './utils/parse-thread-list-limit';
|
||||||
* Global (cross-project) agents list endpoint.
|
* Global (cross-project) agents list endpoint.
|
||||||
*
|
*
|
||||||
* It serves two audiences, and `availableInChat` picks between them:
|
* It serves two audiences, and `availableInChat` picks between them:
|
||||||
* - the overview page, which wants every agent in the user's projects and the
|
* - the overview page, which wants every agent in the projects where the user
|
||||||
* full entity to render its cards;
|
* holds `agent:list` and the full entity to render its cards;
|
||||||
* - the n8n chat page, which wants the agents whose published config carries
|
* - the n8n chat page, which wants the agents whose published config carries
|
||||||
* the n8n Chat channel, scoped to `agent:execute` and answered with the
|
* the n8n Chat channel, scoped to `agent:execute` and answered with the
|
||||||
* narrow `AgentChatListItem` so a chat-only member gets no config.
|
* narrow `AgentChatListItem` so a chat-only member gets no config.
|
||||||
|
|
@ -30,7 +30,7 @@ export class AgentsListController {
|
||||||
async list(req: AuthenticatedRequest, res: Response, @Query query: ListAgentsQueryDto) {
|
async list(req: AuthenticatedRequest, res: Response, @Query query: ListAgentsQueryDto) {
|
||||||
res.json(
|
res.json(
|
||||||
query.filter?.availableInChat === undefined
|
query.filter?.availableInChat === undefined
|
||||||
? await this.agentsService.findByUserMembershipPaginated(req.user, query)
|
? await this.agentsService.findListableByUserPaginated(req.user, query)
|
||||||
: await this.agentsService.findChatReachableByUserPaginated(req.user, query),
|
: await this.agentsService.findChatReachableByUserPaginated(req.user, query),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -459,13 +459,12 @@ export class AgentsService {
|
||||||
return await this.agentExecutionService.findN8nChatThreadForAgents(user.id, agentIds, threadId);
|
return await this.agentExecutionService.findN8nChatThreadForAgents(user.id, agentIds, threadId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The agents overview list: every project the user belongs to, no scope check. */
|
/** The agents overview list: every project where the user holds `agent:list`. */
|
||||||
async findByUserMembershipPaginated(
|
async findListableByUserPaginated(
|
||||||
user: User,
|
user: User,
|
||||||
options: ListAgentsQueryDto,
|
options: ListAgentsQueryDto,
|
||||||
): Promise<AgentListResult> {
|
): Promise<AgentListResult> {
|
||||||
const projectRelations = await this.projectRelationRepository.findAllByUser(user.id);
|
const projectIds = await this.projectScopeService.getProjectIds(user, ['agent:list']);
|
||||||
const projectIds = projectRelations.map((pr) => pr.projectId);
|
|
||||||
return await this.agentRepository.findByProjectIdsPaginated(projectIds, options);
|
return await this.agentRepository.findByProjectIdsPaginated(projectIds, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -262,6 +262,36 @@ describe('BuilderModelLiveLookupService', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('passes a priority from Gateway credits discovery on to the model choice', async () => {
|
||||||
|
const { service, aiGatewayService } = makeService();
|
||||||
|
aiGatewayService.getCredentialTypeForProvider.mockResolvedValue('googlePalmApi');
|
||||||
|
aiGatewayService.getSyntheticCredential.mockResolvedValue({
|
||||||
|
apiKey: 'gateway-jwt',
|
||||||
|
host: 'https://gw.example/v1/gateway/google',
|
||||||
|
});
|
||||||
|
listModelsForProvider.mockResolvedValue([
|
||||||
|
{ id: 'models/gemini-featured', name: 'Gemini Featured', priority: 10 },
|
||||||
|
{ id: 'models/gemini-2.5-pro', name: 'Gemini 2.5 Pro' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await service.lookup(
|
||||||
|
user,
|
||||||
|
projectId,
|
||||||
|
AI_GATEWAY_MANAGED_TAG,
|
||||||
|
'googlePalmApi',
|
||||||
|
'google',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
status: 'success',
|
||||||
|
policy: 'managed',
|
||||||
|
models: [
|
||||||
|
{ name: 'Gemini Featured', value: 'models/gemini-featured', priority: 10 },
|
||||||
|
{ name: 'Gemini 2.5 Pro', value: 'models/gemini-2.5-pro' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('keeps list compatible with successful and unavailable lookups', async () => {
|
it('keeps list compatible with successful and unavailable lookups', async () => {
|
||||||
const { service } = makeService();
|
const { service } = makeService();
|
||||||
const models = [{ name: 'GPT-5', value: 'gpt-5' }];
|
const models = [{ name: 'GPT-5', value: 'gpt-5' }];
|
||||||
|
|
|
||||||
|
|
@ -190,7 +190,11 @@ export class BuilderModelLiveLookupService {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
status: 'success',
|
status: 'success',
|
||||||
models: models.map((model) => ({ name: model.name, value: model.id })),
|
models: models.map((model) => ({
|
||||||
|
name: model.name,
|
||||||
|
value: model.id,
|
||||||
|
...(model.priority !== undefined ? { priority: model.priority } : {}),
|
||||||
|
})),
|
||||||
policy,
|
policy,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
export interface ModelChoice {
|
export interface ModelChoice {
|
||||||
name: string;
|
name: string;
|
||||||
value: string;
|
value: string;
|
||||||
|
/** Pickers list models with a priority first, highest first. Only the AI Gateway reports one. */
|
||||||
|
priority?: number;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import type { ChatHubAgentKnowledgeItem } from '@n8n/api-types';
|
import type { ChatHubAgentKnowledgeItem } from '@n8n/api-types';
|
||||||
import type { Logger } from '@n8n/backend-common';
|
import type { Logger } from '@n8n/backend-common';
|
||||||
import type { User } from '@n8n/db';
|
import type { User } from '@n8n/db';
|
||||||
|
import type { EntityManager } from '@n8n/typeorm';
|
||||||
import type { IRunExecutionData, IWorkflowBase } from 'n8n-workflow';
|
import type { IRunExecutionData, IWorkflowBase } from 'n8n-workflow';
|
||||||
import { v4 as uuid } from 'uuid';
|
import { v4 as uuid } from 'uuid';
|
||||||
import { mock } from 'vitest-mock-extended';
|
import { mock } from 'vitest-mock-extended';
|
||||||
|
|
@ -80,6 +81,7 @@ describe('ChatHubAgentService', () => {
|
||||||
const dynamicNodeParametersService = mock<DynamicNodeParametersService>();
|
const dynamicNodeParametersService = mock<DynamicNodeParametersService>();
|
||||||
const toolService = mock<ChatHubToolService>();
|
const toolService = mock<ChatHubToolService>();
|
||||||
const mockUser = mock<User>({ id: mockUserId });
|
const mockUser = mock<User>({ id: mockUserId });
|
||||||
|
const mockTrx = mock<EntityManager>();
|
||||||
|
|
||||||
let service: ChatHubAgentService;
|
let service: ChatHubAgentService;
|
||||||
|
|
||||||
|
|
@ -99,6 +101,11 @@ describe('ChatHubAgentService', () => {
|
||||||
dynamicNodeParametersService,
|
dynamicNodeParametersService,
|
||||||
toolService,
|
toolService,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
agentRepository.runInTransaction.mockImplementation(
|
||||||
|
async (ctx: unknown, fn: (em: never, ctx: unknown) => Promise<unknown>) =>
|
||||||
|
await fn(mockTrx as never, ctx),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('createAgent', () => {
|
describe('createAgent', () => {
|
||||||
|
|
@ -129,6 +136,7 @@ describe('ChatHubAgentService', () => {
|
||||||
model: 'gpt-4',
|
model: 'gpt-4',
|
||||||
ownerId: mockUserId,
|
ownerId: mockUserId,
|
||||||
}),
|
}),
|
||||||
|
mockTrx,
|
||||||
);
|
);
|
||||||
expect(dto.id).toBe(agent.id);
|
expect(dto.id).toBe(agent.id);
|
||||||
expect(dto.name).toBe('Test Agent');
|
expect(dto.name).toBe('Test Agent');
|
||||||
|
|
@ -153,6 +161,7 @@ describe('ChatHubAgentService', () => {
|
||||||
|
|
||||||
expect(agentRepository.createAgent).toHaveBeenCalledWith(
|
expect(agentRepository.createAgent).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ description: null }),
|
expect.objectContaining({ description: null }),
|
||||||
|
mockTrx,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -171,10 +180,34 @@ describe('ChatHubAgentService', () => {
|
||||||
toolIds,
|
toolIds,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(toolService.setAgentTools).toHaveBeenCalledWith(expect.any(String), toolIds);
|
expect(toolService.setAgentTools).toHaveBeenCalledWith(
|
||||||
|
expect.any(String),
|
||||||
|
mockUserId,
|
||||||
|
toolIds,
|
||||||
|
mockTrx,
|
||||||
|
);
|
||||||
expect(dto.toolIds).toEqual(toolIds);
|
expect(dto.toolIds).toEqual(toolIds);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should create the agent and set its tools in one transaction', async () => {
|
||||||
|
agentRepository.createAgent.mockResolvedValue(makeAgent());
|
||||||
|
toolService.setAgentTools.mockRejectedValue(new NotFoundError('Chat hub tool not found'));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.createAgent(mockUser, {
|
||||||
|
name: 'Test Agent',
|
||||||
|
systemPrompt: 'You are helpful',
|
||||||
|
icon: { type: 'emoji', value: '🤖' },
|
||||||
|
credentialId: 'cred-1',
|
||||||
|
provider: 'openai',
|
||||||
|
model: 'gpt-4',
|
||||||
|
toolIds: [uuid()],
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(NotFoundError);
|
||||||
|
|
||||||
|
expect(agentRepository.createAgent).toHaveBeenCalledWith(expect.anything(), mockTrx);
|
||||||
|
});
|
||||||
|
|
||||||
it('should not call setAgentTools when toolIds is empty', async () => {
|
it('should not call setAgentTools when toolIds is empty', async () => {
|
||||||
const agent = makeAgent();
|
const agent = makeAgent();
|
||||||
agentRepository.createAgent.mockResolvedValue(agent);
|
agentRepository.createAgent.mockResolvedValue(agent);
|
||||||
|
|
@ -210,6 +243,7 @@ describe('ChatHubAgentService', () => {
|
||||||
|
|
||||||
expect(agentRepository.createAgent).toHaveBeenCalledWith(
|
expect(agentRepository.createAgent).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ suggestedPrompts: prompts }),
|
expect.objectContaining({ suggestedPrompts: prompts }),
|
||||||
|
mockTrx,
|
||||||
);
|
);
|
||||||
expect(dto.suggestedPrompts).toEqual(prompts);
|
expect(dto.suggestedPrompts).toEqual(prompts);
|
||||||
});
|
});
|
||||||
|
|
@ -230,6 +264,7 @@ describe('ChatHubAgentService', () => {
|
||||||
|
|
||||||
expect(agentRepository.createAgent).toHaveBeenCalledWith(
|
expect(agentRepository.createAgent).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ suggestedPrompts: [] }),
|
expect.objectContaining({ suggestedPrompts: [] }),
|
||||||
|
mockTrx,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -273,7 +308,11 @@ describe('ChatHubAgentService', () => {
|
||||||
|
|
||||||
await service.updateAgent(agent.id, mockUser, { name: 'New Name' });
|
await service.updateAgent(agent.id, mockUser, { name: 'New Name' });
|
||||||
|
|
||||||
expect(agentRepository.updateAgent).toHaveBeenCalledWith(agent.id, { name: 'New Name' });
|
expect(agentRepository.updateAgent).toHaveBeenCalledWith(
|
||||||
|
agent.id,
|
||||||
|
{ name: 'New Name' },
|
||||||
|
mockTrx,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should update multiple fields at once', async () => {
|
it('should update multiple fields at once', async () => {
|
||||||
|
|
@ -294,11 +333,15 @@ describe('ChatHubAgentService', () => {
|
||||||
systemPrompt: 'New prompt',
|
systemPrompt: 'New prompt',
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(agentRepository.updateAgent).toHaveBeenCalledWith(agent.id, {
|
expect(agentRepository.updateAgent).toHaveBeenCalledWith(
|
||||||
name: 'Updated',
|
agent.id,
|
||||||
description: 'New desc',
|
{
|
||||||
systemPrompt: 'New prompt',
|
name: 'Updated',
|
||||||
});
|
description: 'New desc',
|
||||||
|
systemPrompt: 'New prompt',
|
||||||
|
},
|
||||||
|
mockTrx,
|
||||||
|
);
|
||||||
expect(dto.name).toBe('Updated');
|
expect(dto.name).toBe('Updated');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -311,7 +354,24 @@ describe('ChatHubAgentService', () => {
|
||||||
|
|
||||||
await service.updateAgent(agent.id, mockUser, { toolIds });
|
await service.updateAgent(agent.id, mockUser, { toolIds });
|
||||||
|
|
||||||
expect(toolService.setAgentTools).toHaveBeenCalledWith(agent.id, toolIds);
|
expect(toolService.setAgentTools).toHaveBeenCalledWith(
|
||||||
|
agent.id,
|
||||||
|
mockUserId,
|
||||||
|
toolIds,
|
||||||
|
mockTrx,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not update the agent fields when the tools are rejected', async () => {
|
||||||
|
const agent = makeAgent();
|
||||||
|
agentRepository.getOneById.mockResolvedValue(agent);
|
||||||
|
toolService.setAgentTools.mockRejectedValue(new NotFoundError('Chat hub tool not found'));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.updateAgent(agent.id, mockUser, { name: 'New Name', toolIds: [uuid()] }),
|
||||||
|
).rejects.toThrow(NotFoundError);
|
||||||
|
|
||||||
|
expect(agentRepository.updateAgent).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not set tools when toolIds is not provided', async () => {
|
it('should not set tools when toolIds is not provided', async () => {
|
||||||
|
|
@ -339,6 +399,7 @@ describe('ChatHubAgentService', () => {
|
||||||
expect(agentRepository.updateAgent).toHaveBeenCalledWith(
|
expect(agentRepository.updateAgent).toHaveBeenCalledWith(
|
||||||
agent.id,
|
agent.id,
|
||||||
expect.objectContaining({ suggestedPrompts: prompts }),
|
expect.objectContaining({ suggestedPrompts: prompts }),
|
||||||
|
mockTrx,
|
||||||
);
|
);
|
||||||
expect(dto.suggestedPrompts).toEqual(prompts);
|
expect(dto.suggestedPrompts).toEqual(prompts);
|
||||||
});
|
});
|
||||||
|
|
@ -354,6 +415,7 @@ describe('ChatHubAgentService', () => {
|
||||||
expect(agentRepository.updateAgent).toHaveBeenCalledWith(
|
expect(agentRepository.updateAgent).toHaveBeenCalledWith(
|
||||||
agent.id,
|
agent.id,
|
||||||
expect.not.objectContaining({ suggestedPrompts: expect.anything() }),
|
expect.not.objectContaining({ suggestedPrompts: expect.anything() }),
|
||||||
|
mockTrx,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,307 @@
|
||||||
|
import { mockInstance, testDb, testModules } from '@n8n/backend-test-utils';
|
||||||
|
import type { User } from '@n8n/db';
|
||||||
|
import { Container } from '@n8n/di';
|
||||||
|
import { NotFoundError } from '@n8n/errors';
|
||||||
|
import { BinaryDataService } from 'n8n-core';
|
||||||
|
import type { INode } from 'n8n-workflow';
|
||||||
|
|
||||||
|
import { saveCredential } from '@test-integration/db/credentials';
|
||||||
|
import { createMember } from '@test-integration/db/users';
|
||||||
|
|
||||||
|
import { WorkflowExecutionService } from '../../../workflows/workflow-execution.service';
|
||||||
|
import { ChatHubAgent } from '../chat-hub-agent.entity';
|
||||||
|
import { ChatHubAgentRepository } from '../chat-hub-agent.repository';
|
||||||
|
import { ChatHubAgentService } from '../chat-hub-agent.service';
|
||||||
|
import { ChatHubCredentialsService } from '../chat-hub-credentials.service';
|
||||||
|
import { ChatHubSession } from '../chat-hub-session.entity';
|
||||||
|
import type { ChatHubTool } from '../chat-hub-tool.entity';
|
||||||
|
import { ChatHubToolRepository } from '../chat-hub-tool.repository';
|
||||||
|
import { ChatHubToolService } from '../chat-hub-tool.service';
|
||||||
|
import { ChatHubService } from '../chat-hub.service';
|
||||||
|
import { ChatHubSessionRepository } from '../chat-session.repository';
|
||||||
|
|
||||||
|
mockInstance(BinaryDataService);
|
||||||
|
mockInstance(WorkflowExecutionService);
|
||||||
|
mockInstance(ChatHubCredentialsService);
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await testModules.loadModules(['chat-hub']);
|
||||||
|
await testDb.init();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await testDb.truncate([
|
||||||
|
'ChatHubSession',
|
||||||
|
'ChatHubAgent',
|
||||||
|
'ChatHubTool',
|
||||||
|
'SharedCredentials',
|
||||||
|
'CredentialsEntity',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await testDb.terminate();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('chat hub tool references', () => {
|
||||||
|
let chatHubService: ChatHubService;
|
||||||
|
let agentService: ChatHubAgentService;
|
||||||
|
let toolService: ChatHubToolService;
|
||||||
|
let toolRepository: ChatHubToolRepository;
|
||||||
|
let sessionRepository: ChatHubSessionRepository;
|
||||||
|
let agentRepository: ChatHubAgentRepository;
|
||||||
|
|
||||||
|
let owner: User;
|
||||||
|
let otherUser: User;
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
chatHubService = Container.get(ChatHubService);
|
||||||
|
agentService = Container.get(ChatHubAgentService);
|
||||||
|
toolService = Container.get(ChatHubToolService);
|
||||||
|
toolRepository = Container.get(ChatHubToolRepository);
|
||||||
|
sessionRepository = Container.get(ChatHubSessionRepository);
|
||||||
|
agentRepository = Container.get(ChatHubAgentRepository);
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
owner = await createMember();
|
||||||
|
otherUser = await createMember();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function createTool(ownerId: string) {
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
const definition: INode = {
|
||||||
|
id,
|
||||||
|
name: `Tool ${id}`,
|
||||||
|
type: 'n8n-nodes-base.httpRequestTool',
|
||||||
|
typeVersion: 4.4,
|
||||||
|
position: [0, 0],
|
||||||
|
parameters: {},
|
||||||
|
};
|
||||||
|
return await toolRepository.createTool({
|
||||||
|
id,
|
||||||
|
name: definition.name,
|
||||||
|
type: definition.type,
|
||||||
|
typeVersion: definition.typeVersion,
|
||||||
|
ownerId,
|
||||||
|
definition,
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createSession(ownerId: string) {
|
||||||
|
return await sessionRepository.createChatSession({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
ownerId,
|
||||||
|
title: 'session',
|
||||||
|
lastMessageAt: new Date(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createAgent(ownerId: string) {
|
||||||
|
return await agentRepository.createAgent({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
name: 'agent',
|
||||||
|
systemPrompt: 'prompt',
|
||||||
|
ownerId,
|
||||||
|
credentialId: null,
|
||||||
|
provider: 'openai',
|
||||||
|
model: 'gpt-4',
|
||||||
|
files: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reads every stored link row, without the owner filter that the services apply */
|
||||||
|
async function storedLinkIds(entity: typeof ChatHubSession | typeof ChatHubAgent, id: string) {
|
||||||
|
const tools = await toolRepository.manager
|
||||||
|
.createQueryBuilder()
|
||||||
|
.relation(entity, 'tools')
|
||||||
|
.of(id)
|
||||||
|
.loadMany<ChatHubTool>();
|
||||||
|
return tools.map((t) => t.id).sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('updateSession', () => {
|
||||||
|
it('should set tools that the user owns', async () => {
|
||||||
|
const session = await createSession(owner.id);
|
||||||
|
const tool = await createTool(owner.id);
|
||||||
|
|
||||||
|
await chatHubService.updateSession(owner, session.id, { toolIds: [tool.id] });
|
||||||
|
|
||||||
|
expect(await toolService.getToolIdsForSession(session.id)).toEqual([tool.id]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject tools that another user owns and keep the session unchanged', async () => {
|
||||||
|
const session = await createSession(owner.id);
|
||||||
|
const ownTool = await createTool(owner.id);
|
||||||
|
const foreignTool = await createTool(otherUser.id);
|
||||||
|
await chatHubService.updateSession(owner, session.id, { toolIds: [ownTool.id] });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
chatHubService.updateSession(owner, session.id, {
|
||||||
|
title: 'renamed',
|
||||||
|
toolIds: [ownTool.id, foreignTool.id],
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(NotFoundError);
|
||||||
|
|
||||||
|
expect(await toolService.getToolIdsForSession(session.id)).toEqual([ownTool.id]);
|
||||||
|
const stored = await sessionRepository.findOneByOrFail({ id: session.id });
|
||||||
|
expect(stored.title).toBe('session');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should accept duplicate IDs of an owned tool', async () => {
|
||||||
|
const session = await createSession(owner.id);
|
||||||
|
const tool = await createTool(owner.id);
|
||||||
|
|
||||||
|
await chatHubService.updateSession(owner, session.id, { toolIds: [tool.id, tool.id] });
|
||||||
|
|
||||||
|
expect(await toolService.getToolIdsForSession(session.id)).toEqual([tool.id]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should keep the tool links unchanged when the session update fails', async () => {
|
||||||
|
const session = await createSession(owner.id);
|
||||||
|
const tool = await createTool(owner.id);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
chatHubService.updateSession(owner, session.id, {
|
||||||
|
credentialId: crypto.randomUUID(),
|
||||||
|
toolIds: [tool.id],
|
||||||
|
}),
|
||||||
|
).rejects.toThrow();
|
||||||
|
|
||||||
|
expect(await storedLinkIds(ChatHubSession, session.id)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should only return owned tool IDs and remove other links on the next save', async () => {
|
||||||
|
const session = await createSession(owner.id);
|
||||||
|
const ownTool = await createTool(owner.id);
|
||||||
|
const foreignTool = await createTool(otherUser.id);
|
||||||
|
// Write the link rows directly to simulate rows stored before validation existed
|
||||||
|
await toolRepository.setSessionTools(session.id, [ownTool.id, foreignTool.id]);
|
||||||
|
|
||||||
|
const { session: dto } = await chatHubService.getConversation(owner.id, session.id);
|
||||||
|
expect(dto.toolIds).toEqual([ownTool.id]);
|
||||||
|
|
||||||
|
await chatHubService.updateSession(owner, session.id, { toolIds: dto.toolIds });
|
||||||
|
|
||||||
|
expect(await storedLinkIds(ChatHubSession, session.id)).toEqual([ownTool.id]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createAgent', () => {
|
||||||
|
async function agentRequest() {
|
||||||
|
const credential = await saveCredential(
|
||||||
|
{ name: 'OpenAI', type: 'openAiApi', data: { apiKey: 'test-api-key' } },
|
||||||
|
{ user: owner, role: 'credential:owner' },
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
name: 'agent',
|
||||||
|
systemPrompt: 'prompt',
|
||||||
|
icon: { type: 'emoji' as const, value: '🤖' },
|
||||||
|
credentialId: credential.id,
|
||||||
|
provider: 'openai' as const,
|
||||||
|
model: 'gpt-4',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
it('should set tools that the user owns', async () => {
|
||||||
|
const tool = await createTool(owner.id);
|
||||||
|
|
||||||
|
const agent = await agentService.createAgent(owner, {
|
||||||
|
...(await agentRequest()),
|
||||||
|
toolIds: [tool.id],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await toolService.getToolIdsForAgent(agent.id)).toEqual([tool.id]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject tools that another user owns and not create the agent', async () => {
|
||||||
|
const foreignTool = await createTool(otherUser.id);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
agentService.createAgent(owner, { ...(await agentRequest()), toolIds: [foreignTool.id] }),
|
||||||
|
).rejects.toThrow(NotFoundError);
|
||||||
|
|
||||||
|
expect(await agentRepository.getManyByUserId(owner.id)).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return each tool ID once when the request repeats an ID', async () => {
|
||||||
|
const tool = await createTool(owner.id);
|
||||||
|
|
||||||
|
const agent = await agentService.createAgent(owner, {
|
||||||
|
...(await agentRequest()),
|
||||||
|
toolIds: [tool.id, tool.id],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(agent.toolIds).toEqual([tool.id]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('updateAgent', () => {
|
||||||
|
it('should reject tools that another user owns and keep the agent unchanged', async () => {
|
||||||
|
const agent = await createAgent(owner.id);
|
||||||
|
const foreignTool = await createTool(otherUser.id);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
agentService.updateAgent(agent.id, owner, { name: 'renamed', toolIds: [foreignTool.id] }),
|
||||||
|
).rejects.toThrow(NotFoundError);
|
||||||
|
|
||||||
|
expect(await toolService.getToolIdsForAgent(agent.id)).toEqual([]);
|
||||||
|
const stored = await agentRepository.findOneByOrFail({ id: agent.id });
|
||||||
|
expect(stored.name).toBe('agent');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should keep the tool links unchanged when the agent update fails', async () => {
|
||||||
|
const agent = await createAgent(owner.id);
|
||||||
|
const tool = await createTool(owner.id);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
agentService.updateAgent(agent.id, owner, {
|
||||||
|
credentialId: crypto.randomUUID(),
|
||||||
|
toolIds: [tool.id],
|
||||||
|
}),
|
||||||
|
).rejects.toThrow();
|
||||||
|
|
||||||
|
expect(await storedLinkIds(ChatHubAgent, agent.id)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should only return owned tool IDs and remove other links on the next save', async () => {
|
||||||
|
const agent = await createAgent(owner.id);
|
||||||
|
const ownTool = await createTool(owner.id);
|
||||||
|
const foreignTool = await createTool(otherUser.id);
|
||||||
|
await toolRepository.setAgentTools(agent.id, [ownTool.id, foreignTool.id]);
|
||||||
|
|
||||||
|
const dto = await agentService.getAgentByIdAsDto(agent.id, owner.id);
|
||||||
|
expect(dto.toolIds).toEqual([ownTool.id]);
|
||||||
|
|
||||||
|
await agentService.updateAgent(agent.id, owner, { toolIds: dto.toolIds });
|
||||||
|
|
||||||
|
expect(await storedLinkIds(ChatHubAgent, agent.id)).toEqual([ownTool.id]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('tool definitions at run time', () => {
|
||||||
|
it('should only return session tools that the session owner owns', async () => {
|
||||||
|
const session = await createSession(owner.id);
|
||||||
|
const ownTool = await createTool(owner.id);
|
||||||
|
const foreignTool = await createTool(otherUser.id);
|
||||||
|
// Write the link rows directly to simulate rows stored before validation existed
|
||||||
|
await toolRepository.setSessionTools(session.id, [ownTool.id, foreignTool.id]);
|
||||||
|
|
||||||
|
const definitions = await toolService.getToolDefinitionsForSession(session.id);
|
||||||
|
|
||||||
|
expect(definitions.map((d) => d.id)).toEqual([ownTool.id]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should only return agent tools that the agent owner owns', async () => {
|
||||||
|
const agent = await createAgent(owner.id);
|
||||||
|
const ownTool = await createTool(owner.id);
|
||||||
|
const foreignTool = await createTool(otherUser.id);
|
||||||
|
await toolRepository.setAgentTools(agent.id, [ownTool.id, foreignTool.id]);
|
||||||
|
|
||||||
|
const definitions = await toolService.getToolDefinitionsForAgent(agent.id);
|
||||||
|
|
||||||
|
expect(definitions.map((d) => d.id)).toEqual([ownTool.id]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -136,6 +136,85 @@ describe('ChatHubToolService', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('setSessionTools', () => {
|
||||||
|
it('should set tools that the user owns', async () => {
|
||||||
|
const tool = makeTool();
|
||||||
|
chatToolRepository.getByIds.mockResolvedValue([tool]);
|
||||||
|
|
||||||
|
await service.setSessionTools(mockSessionId, mockUserId, [tool.id]);
|
||||||
|
|
||||||
|
expect(chatToolRepository.setSessionTools).toHaveBeenCalledWith(
|
||||||
|
mockSessionId,
|
||||||
|
[tool.id],
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not set tools when the user does not own one of them', async () => {
|
||||||
|
chatToolRepository.getByIds.mockResolvedValue([]);
|
||||||
|
|
||||||
|
await expect(service.setSessionTools(mockSessionId, mockUserId, [uuid()])).rejects.toThrow(
|
||||||
|
NotFoundError,
|
||||||
|
);
|
||||||
|
expect(chatToolRepository.setSessionTools).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not set tools when the user owns only some of them', async () => {
|
||||||
|
const ownTool = makeTool();
|
||||||
|
chatToolRepository.getByIds.mockResolvedValue([ownTool]);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.setSessionTools(mockSessionId, mockUserId, [ownTool.id, uuid()]),
|
||||||
|
).rejects.toThrow(NotFoundError);
|
||||||
|
expect(chatToolRepository.setSessionTools).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should check and store each tool once when IDs repeat', async () => {
|
||||||
|
const tool = makeTool();
|
||||||
|
chatToolRepository.getByIds.mockResolvedValue([tool]);
|
||||||
|
|
||||||
|
await service.setSessionTools(mockSessionId, mockUserId, [tool.id, tool.id]);
|
||||||
|
|
||||||
|
expect(chatToolRepository.getByIds).toHaveBeenCalledWith([tool.id], mockUserId, undefined);
|
||||||
|
expect(chatToolRepository.setSessionTools).toHaveBeenCalledWith(
|
||||||
|
mockSessionId,
|
||||||
|
[tool.id],
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should clear the tools without a lookup when the list is empty', async () => {
|
||||||
|
await service.setSessionTools(mockSessionId, mockUserId, []);
|
||||||
|
|
||||||
|
expect(chatToolRepository.getByIds).not.toHaveBeenCalled();
|
||||||
|
expect(chatToolRepository.setSessionTools).toHaveBeenCalledWith(mockSessionId, [], undefined);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('setAgentTools', () => {
|
||||||
|
it('should set tools that the user owns', async () => {
|
||||||
|
const tool = makeTool();
|
||||||
|
chatToolRepository.getByIds.mockResolvedValue([tool]);
|
||||||
|
|
||||||
|
await service.setAgentTools(mockAgentId, mockUserId, [tool.id]);
|
||||||
|
|
||||||
|
expect(chatToolRepository.setAgentTools).toHaveBeenCalledWith(
|
||||||
|
mockAgentId,
|
||||||
|
[tool.id],
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not set tools when the user does not own one of them', async () => {
|
||||||
|
chatToolRepository.getByIds.mockResolvedValue([]);
|
||||||
|
|
||||||
|
await expect(service.setAgentTools(mockAgentId, mockUserId, [uuid()])).rejects.toThrow(
|
||||||
|
NotFoundError,
|
||||||
|
);
|
||||||
|
expect(chatToolRepository.setAgentTools).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
const deprecatedCodeTool: INode = {
|
const deprecatedCodeTool: INode = {
|
||||||
...mockDefinition,
|
...mockDefinition,
|
||||||
type: '@n8n/n8n-nodes-langchain.code',
|
type: '@n8n/n8n-nodes-langchain.code',
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
|
import type { ChatHubConversationModel, ChatProviderSettingsDto } from '@n8n/api-types';
|
||||||
import type { PolicyCleared } from '@n8n/decorators';
|
import type { PolicyCleared } from '@n8n/decorators';
|
||||||
import type { Logger } from '@n8n/backend-common';
|
import type { Logger } from '@n8n/backend-common';
|
||||||
import type { WorkflowRepository, SharedWorkflowRepository, User } from '@n8n/db';
|
import type { Project, WorkflowRepository, SharedWorkflowRepository, User } from '@n8n/db';
|
||||||
import type { EntityManager } from '@n8n/typeorm';
|
import type { EntityManager } from '@n8n/typeorm';
|
||||||
import type { Cipher, BinaryDataService } from 'n8n-core';
|
import type { Cipher, BinaryDataService } from 'n8n-core';
|
||||||
import { type IBinaryData, type INode, CHAT_TRIGGER_NODE_TYPE } from 'n8n-workflow';
|
import { type IBinaryData, type INode, CHAT_TRIGGER_NODE_TYPE } from 'n8n-workflow';
|
||||||
|
|
@ -1409,7 +1410,7 @@ describe('ChatHubWorkflowService', () => {
|
||||||
vi.spyOn(serviceWithRepo, 'getSystemMessageMetadata').mockReturnValue('__metadata__');
|
vi.spyOn(serviceWithRepo, 'getSystemMessageMetadata').mockReturnValue('__metadata__');
|
||||||
|
|
||||||
const result = await serviceWithRepo.prepareReplyWorkflow(
|
const result = await serviceWithRepo.prepareReplyWorkflow(
|
||||||
{ id: 'user-123' } as User,
|
{ id: 'user-123', role: { slug: 'global:member' } } as User,
|
||||||
'session-456' as any,
|
'session-456' as any,
|
||||||
{},
|
{},
|
||||||
{ provider: 'custom-agent', agentId: 'agent-1' } as any,
|
{ provider: 'custom-agent', agentId: 'agent-1' } as any,
|
||||||
|
|
@ -1508,4 +1509,78 @@ __metadata__"
|
||||||
`);
|
`);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('tool type authorization', () => {
|
||||||
|
const toolNode = (name: string, type: string): INode => ({
|
||||||
|
id: `id-${name}`,
|
||||||
|
name,
|
||||||
|
type,
|
||||||
|
typeVersion: 1,
|
||||||
|
position: [0, 0],
|
||||||
|
parameters: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const storedTools = [
|
||||||
|
toolNode('Shell', 'n8n-nodes-base.executeCommandTool'),
|
||||||
|
toolNode('Code', '@n8n/n8n-nodes-langchain.toolCode'),
|
||||||
|
toolNode('Slack', 'n8n-nodes-base.slackTool'),
|
||||||
|
];
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
chatHubSettingsService.ensureModelIsAllowed.mockResolvedValue(undefined);
|
||||||
|
chatHubSettingsService.getProviderSettings.mockResolvedValue(mock<ChatProviderSettingsDto>());
|
||||||
|
chatHubSettingsService.getSemanticSearchOptions.mockResolvedValue(null);
|
||||||
|
chatHubCredentialsService.findPersonalProject.mockResolvedValue(
|
||||||
|
mock<Project>({ id: 'project-789' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function getToolNodeNames(user: User, model: ChatHubConversationModel) {
|
||||||
|
const result = await service.prepareReplyWorkflow(
|
||||||
|
user,
|
||||||
|
'session-456',
|
||||||
|
{ openAiApi: { id: 'cred-1', name: '' } },
|
||||||
|
model,
|
||||||
|
[],
|
||||||
|
'Hello',
|
||||||
|
storedTools,
|
||||||
|
[],
|
||||||
|
'UTC',
|
||||||
|
{},
|
||||||
|
defaultExecutionMetadata,
|
||||||
|
);
|
||||||
|
const toolNames = new Set(storedTools.map((t) => t.name));
|
||||||
|
return result.workflowData.nodes.map((n) => n.name).filter((name) => toolNames.has(name));
|
||||||
|
}
|
||||||
|
|
||||||
|
const chatUser = { id: 'user-123', role: { slug: 'global:chatUser' } } as User;
|
||||||
|
const member = { id: 'user-123', role: { slug: 'global:member' } } as User;
|
||||||
|
const baseModel: ChatHubConversationModel = { provider: 'openai', model: 'gpt-4' };
|
||||||
|
|
||||||
|
it('should drop stored tools that a chat user is not allowed to run', async () => {
|
||||||
|
expect(await getToolNodeNames(chatUser, baseModel)).toEqual(['Slack']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should keep all stored tools for a member', async () => {
|
||||||
|
expect(await getToolNodeNames(member, baseModel)).toEqual(['Shell', 'Code', 'Slack']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should drop blocked tools from a chat user agent', async () => {
|
||||||
|
chatHubAgentRepository.getOneById.mockResolvedValue(
|
||||||
|
mock<ChatHubAgent>({
|
||||||
|
id: 'agent-1',
|
||||||
|
provider: 'openai',
|
||||||
|
model: 'gpt-4',
|
||||||
|
credentialId: 'cred-1',
|
||||||
|
systemPrompt: '',
|
||||||
|
files: [],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
chatHubToolService.getToolDefinitionsForAgent.mockResolvedValue(storedTools);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await getToolNodeNames(chatUser, { provider: 'custom-agent', agentId: 'agent-1' }),
|
||||||
|
).toEqual(['Slack']);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,15 @@
|
||||||
|
import type { ChatHubCreateToolRequest, ChatHubUpdateToolRequest } from '@n8n/api-types';
|
||||||
import type { ModuleRegistry } from '@n8n/backend-common';
|
import type { ModuleRegistry } from '@n8n/backend-common';
|
||||||
|
import type { AuthenticatedRequest } from '@n8n/db';
|
||||||
import type { ModuleSettings } from '@n8n/decorators';
|
import type { ModuleSettings } from '@n8n/decorators';
|
||||||
import type { NextFunction, Request, Response } from 'express';
|
import type { NextFunction, Request, Response } from 'express';
|
||||||
|
import type { INode } from 'n8n-workflow';
|
||||||
import { mock } from 'vitest-mock-extended';
|
import { mock } from 'vitest-mock-extended';
|
||||||
|
|
||||||
import { ForbiddenError } from '@n8n/errors';
|
import { BadRequestError, ForbiddenError } from '@n8n/errors';
|
||||||
import { sendErrorResponse } from '@/response-helper';
|
import { sendErrorResponse } from '@/response-helper';
|
||||||
|
|
||||||
|
import type { ChatHubToolService } from '../chat-hub-tool.service';
|
||||||
import { ChatHubController } from '../chat-hub.controller';
|
import { ChatHubController } from '../chat-hub.controller';
|
||||||
|
|
||||||
vi.mock('@/response-helper');
|
vi.mock('@/response-helper');
|
||||||
|
|
@ -13,7 +17,15 @@ vi.mock('@/response-helper');
|
||||||
describe('ChatHubController', () => {
|
describe('ChatHubController', () => {
|
||||||
const settingsMap = new Map<string, ModuleSettings>();
|
const settingsMap = new Map<string, ModuleSettings>();
|
||||||
const moduleRegistry = { settings: settingsMap } as unknown as ModuleRegistry;
|
const moduleRegistry = { settings: settingsMap } as unknown as ModuleRegistry;
|
||||||
const controller = new ChatHubController(mock(), mock(), mock(), mock(), mock(), moduleRegistry);
|
const chatToolService = mock<ChatHubToolService>();
|
||||||
|
const controller = new ChatHubController(
|
||||||
|
mock(),
|
||||||
|
mock(),
|
||||||
|
mock(),
|
||||||
|
chatToolService,
|
||||||
|
mock(),
|
||||||
|
moduleRegistry,
|
||||||
|
);
|
||||||
|
|
||||||
const req = mock<Request>();
|
const req = mock<Request>();
|
||||||
const res = mock<Response>();
|
const res = mock<Response>();
|
||||||
|
|
@ -51,4 +63,81 @@ describe('ChatHubController', () => {
|
||||||
expect(sendErrorResponse).toHaveBeenCalledWith(res, expect.any(ForbiddenError));
|
expect(sendErrorResponse).toHaveBeenCalledWith(res, expect.any(ForbiddenError));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('tool type authorization', () => {
|
||||||
|
const chatUserReq = mock<AuthenticatedRequest>({
|
||||||
|
user: { id: 'chat-user', role: { slug: 'global:chatUser' } },
|
||||||
|
});
|
||||||
|
const memberReq = mock<AuthenticatedRequest>({
|
||||||
|
user: { id: 'member', role: { slug: 'global:member' } },
|
||||||
|
});
|
||||||
|
|
||||||
|
const toolDefinition = (type: string): INode => ({
|
||||||
|
id: '00000000-0000-4000-8000-000000000001',
|
||||||
|
name: 'Tool',
|
||||||
|
type,
|
||||||
|
typeVersion: 1,
|
||||||
|
position: [0, 0],
|
||||||
|
parameters: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const createPayload = (type: string) =>
|
||||||
|
({ definition: toolDefinition(type) }) as ChatHubCreateToolRequest;
|
||||||
|
const updatePayload = (type: string) =>
|
||||||
|
({ definition: toolDefinition(type) }) as ChatHubUpdateToolRequest;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
chatToolService.createTool.mockResolvedValue(mock({ definition: toolDefinition('x') }));
|
||||||
|
chatToolService.updateTool.mockResolvedValue(mock({ definition: toolDefinition('x') }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
'n8n-nodes-base.executeCommandTool',
|
||||||
|
'n8n-nodes-base.gitTool',
|
||||||
|
'@n8n/n8n-nodes-langchain.toolCode',
|
||||||
|
'n8n-nodes-base.messageAnAgentTool',
|
||||||
|
])('should reject creating a %s tool for a chat user', async (type) => {
|
||||||
|
await expect(controller.createTool(chatUserReq, res, createPayload(type))).rejects.toThrow(
|
||||||
|
BadRequestError,
|
||||||
|
);
|
||||||
|
expect(chatToolService.createTool).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject updating a tool to a blocked type for a chat user', async () => {
|
||||||
|
await expect(
|
||||||
|
controller.updateTool(
|
||||||
|
chatUserReq,
|
||||||
|
res,
|
||||||
|
'tool-1',
|
||||||
|
updatePayload('n8n-nodes-base.executeCommandTool'),
|
||||||
|
),
|
||||||
|
).rejects.toThrow(BadRequestError);
|
||||||
|
expect(chatToolService.updateTool).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(['n8n-nodes-base.slackTool', 'n8n-nodes-base.httpRequestTool'])(
|
||||||
|
'should allow creating a %s tool for a chat user',
|
||||||
|
async (type) => {
|
||||||
|
await controller.createTool(chatUserReq, res, createPayload(type));
|
||||||
|
|
||||||
|
expect(chatToolService.createTool).toHaveBeenCalled();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it('should allow creating a Code tool for a member', async () => {
|
||||||
|
await controller.createTool(
|
||||||
|
memberReq,
|
||||||
|
res,
|
||||||
|
createPayload('@n8n/n8n-nodes-langchain.toolCode'),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(chatToolService.createTool).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject creating a Chat tool for a member', async () => {
|
||||||
|
await expect(
|
||||||
|
controller.createTool(memberReq, res, createPayload('@n8n/n8n-nodes-langchain.chatTool')),
|
||||||
|
).rejects.toThrow(BadRequestError);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -98,26 +98,34 @@ export class ChatHubAgentService {
|
||||||
|
|
||||||
const id = uuidv4();
|
const id = uuidv4();
|
||||||
|
|
||||||
const agent = await this.chatAgentRepository.createAgent({
|
// One transaction: a rejected tool removes the new agent again
|
||||||
id,
|
const agent = await this.chatAgentRepository.runInTransaction({}, async (trx) => {
|
||||||
name: data.name,
|
const created = await this.chatAgentRepository.createAgent(
|
||||||
description: data.description ?? null,
|
{
|
||||||
icon: data.icon,
|
id,
|
||||||
suggestedPrompts: data.suggestedPrompts ?? [],
|
name: data.name,
|
||||||
systemPrompt: data.systemPrompt,
|
description: data.description ?? null,
|
||||||
ownerId: user.id,
|
icon: data.icon,
|
||||||
credentialId: data.credentialId,
|
suggestedPrompts: data.suggestedPrompts ?? [],
|
||||||
provider: data.provider,
|
systemPrompt: data.systemPrompt,
|
||||||
model: data.model,
|
ownerId: user.id,
|
||||||
files: [],
|
credentialId: data.credentialId,
|
||||||
|
provider: data.provider,
|
||||||
|
model: data.model,
|
||||||
|
files: [],
|
||||||
|
},
|
||||||
|
trx,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (data.toolIds.length > 0) {
|
||||||
|
await this.chatHubToolService.setAgentTools(id, user.id, data.toolIds, trx);
|
||||||
|
}
|
||||||
|
|
||||||
|
return created;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (data.toolIds.length > 0) {
|
|
||||||
await this.chatHubToolService.setAgentTools(id, data.toolIds);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.logger.debug(`Chat agent created: ${id} by user ${user.id}`);
|
this.logger.debug(`Chat agent created: ${id} by user ${user.id}`);
|
||||||
return this.toDto(agent, data.toolIds);
|
return this.toDto(agent, [...new Set(data.toolIds)]);
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateAgent(
|
async updateAgent(
|
||||||
|
|
@ -148,11 +156,14 @@ export class ChatHubAgentService {
|
||||||
if (updates.provider !== undefined) updateData.provider = updates.provider;
|
if (updates.provider !== undefined) updateData.provider = updates.provider;
|
||||||
if (updates.model !== undefined) updateData.model = updates.model ?? null;
|
if (updates.model !== undefined) updateData.model = updates.model ?? null;
|
||||||
|
|
||||||
const agent = await this.chatAgentRepository.updateAgent(id, updateData);
|
// One transaction: a rejected tool or a failed field update leaves the agent unchanged
|
||||||
|
const { toolIds: newToolIds } = updates;
|
||||||
if (updates.toolIds !== undefined) {
|
const agent = await this.chatAgentRepository.runInTransaction({}, async (trx) => {
|
||||||
await this.chatHubToolService.setAgentTools(id, updates.toolIds);
|
if (newToolIds !== undefined) {
|
||||||
}
|
await this.chatHubToolService.setAgentTools(id, user.id, newToolIds, trx);
|
||||||
|
}
|
||||||
|
return await this.chatAgentRepository.updateAgent(id, updateData, trx);
|
||||||
|
});
|
||||||
|
|
||||||
this.logger.debug(`Chat agent updated: ${id} by user ${user.id}`);
|
this.logger.debug(`Chat agent updated: ${id} by user ${user.id}`);
|
||||||
const toolIds = await this.chatHubToolService.getToolIdsForAgent(agent.id);
|
const toolIds = await this.chatHubToolService.getToolIdsForAgent(agent.id);
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user