diff --git a/.github/workflows/docker-images.yml b/.github/workflows/docker-images.yml index 5c91f3832d134..fe5770252b214 100644 --- a/.github/workflows/docker-images.yml +++ b/.github/workflows/docker-images.yml @@ -1,8 +1,9 @@ name: Docker Image CI on: - release: - types: [published] + push: + tags: + - 'n8n@*' jobs: build: diff --git a/CHANGELOG.md b/CHANGELOG.md index 99120642fe416..06ded0c1cad1d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,41 @@ +# [1.55.0](https://github.com/n8n-io/n8n/compare/n8n@1.54.0...n8n@1.55.0) (2024-08-14) + + +### Bug Fixes + +* Add better error handling for chat errors ([#10408](https://github.com/n8n-io/n8n/issues/10408)) ([f82b6e4](https://github.com/n8n-io/n8n/commit/f82b6e4ba9bf527b3a4c17872162d9ae124ead0d)) +* **AI Agent Node:** Fix issues with some tools not populating ([#10406](https://github.com/n8n-io/n8n/issues/10406)) ([51a1edd](https://github.com/n8n-io/n8n/commit/51a1eddbf00393f3881c340cf37cfcca59566c99)) +* **core:** Account for cancelling an execution with no workers available ([#10343](https://github.com/n8n-io/n8n/issues/10343)) ([b044e78](https://github.com/n8n-io/n8n/commit/b044e783e73a499dbd7532a5d489a782d3d021da)) +* **core:** Account for owner when filtering by project ID in `GET /workflows` in Public API ([#10379](https://github.com/n8n-io/n8n/issues/10379)) ([5ac65b3](https://github.com/n8n-io/n8n/commit/5ac65b36bcb1351c6233b951f064f60862f790a5)) +* **core:** Enforce shutdown timer and sequence on `SIGINT` for main ([#10346](https://github.com/n8n-io/n8n/issues/10346)) ([5255793](https://github.com/n8n-io/n8n/commit/5255793afee5653d8356b8e4d2e1009d5cf36164)) +* **core:** Filter out prototype and constructor lookups in expressions ([#10382](https://github.com/n8n-io/n8n/issues/10382)) ([8e7d29a](https://github.com/n8n-io/n8n/commit/8e7d29ad3c4872b1cc147dfcfe9a864ba916692f)) +* **core:** Fix duplicate Redis publisher ([#10392](https://github.com/n8n-io/n8n/issues/10392)) ([45813de](https://github.com/n8n-io/n8n/commit/45813debc963096f63cc0aabe82d9d9f853a39d7)) +* **core:** Fix worker shutdown errors when active executions ([#10353](https://github.com/n8n-io/n8n/issues/10353)) ([e071b73](https://github.com/n8n-io/n8n/commit/e071b73bab34edd4b3e6aef6497514acc504cdc6)) +* **core:** Prevent XSS in user update endpoints ([#10338](https://github.com/n8n-io/n8n/issues/10338)) ([7898498](https://github.com/n8n-io/n8n/commit/78984986a6b4add89df9743b94c113046f1d5ee8)) +* **core:** Prevent XSS via static cache dir ([#10339](https://github.com/n8n-io/n8n/issues/10339)) ([4f392b5](https://github.com/n8n-io/n8n/commit/4f392b5e3e0ee166e85a2e060b3ec7fcf145229b)) +* **core:** Rate limit MFA activation and verification endpoints ([#10330](https://github.com/n8n-io/n8n/issues/10330)) ([b6c47c0](https://github.com/n8n-io/n8n/commit/b6c47c0e3214878d42980d5c9535df52b3984b3c)) +* **editor:** Connect up new project viewer role to the FE ([#9913](https://github.com/n8n-io/n8n/issues/9913)) ([117e2d9](https://github.com/n8n-io/n8n/commit/117e2d968fcc535f32c583ac8f2dc8a84e8cd6bd)) +* **editor:** Enable credential sharing between all types of projects ([#10233](https://github.com/n8n-io/n8n/issues/10233)) ([1cf48cc](https://github.com/n8n-io/n8n/commit/1cf48cc3019c1cf27e2f3c9affd18426237e9064)) +* **editor:** Fix rendering of SVG icons in public chat on iOS ([#10381](https://github.com/n8n-io/n8n/issues/10381)) ([7ab3811](https://github.com/n8n-io/n8n/commit/7ab38114dbf3881afba39287a061446ec4bf0431)) +* **editor:** Fixing XSS vulnerability in toast messages ([#10329](https://github.com/n8n-io/n8n/issues/10329)) ([38bdd9f](https://github.com/n8n-io/n8n/commit/38bdd9f5d0d9ca06fab1a7e1a3e7a4a648a6a89a)) +* **editor:** Revert change that hid swagger docs in the ui ([#10350](https://github.com/n8n-io/n8n/issues/10350)) ([bae49d3](https://github.com/n8n-io/n8n/commit/bae49d3198d4bcc27e7996cd4f7be3132becc98e)) +* **n8n Form Trigger Node:** Fix issue preventing v1 node from working ([#10364](https://github.com/n8n-io/n8n/issues/10364)) ([9b647a9](https://github.com/n8n-io/n8n/commit/9b647a9837434e8b75e3ad754ff5136bb5ac760d)) +* Require mfa code for password change if its enabled ([#10341](https://github.com/n8n-io/n8n/issues/10341)) ([9d7caac](https://github.com/n8n-io/n8n/commit/9d7caacc699f10962783393925a980ec6f1ca975)) +* Require mfa code to disable mfa ([#10345](https://github.com/n8n-io/n8n/issues/10345)) ([3384f52](https://github.com/n8n-io/n8n/commit/3384f52a35b835ba1d8633dc94bab0ad6e7023b3)) + + +### Features + +* Add Ask assistant behind feature flag ([#9995](https://github.com/n8n-io/n8n/issues/9995)) ([5ed2a77](https://github.com/n8n-io/n8n/commit/5ed2a77740db1f02b27c571f4dfdfa206ebdb19c)) +* **AI Transform Node:** New node ([#10405](https://github.com/n8n-io/n8n/issues/10405)) ([4d222ac](https://github.com/n8n-io/n8n/commit/4d222ac19d943b69fd9f87abe5e5c5f5141eed8d)) +* **AI Transform Node:** New node ([#9990](https://github.com/n8n-io/n8n/issues/9990)) ([0de9d56](https://github.com/n8n-io/n8n/commit/0de9d56619ed1c055407353046b8a9ebe78da527)) +* **core:** Allow overriding npm registry for community packages ([#10325](https://github.com/n8n-io/n8n/issues/10325)) ([33a2703](https://github.com/n8n-io/n8n/commit/33a2703429d9eaa41f72d2e7d2da5be60b6c620f)) +* **editor:** Add schema view to expression modal ([#9976](https://github.com/n8n-io/n8n/issues/9976)) ([71b6c67](https://github.com/n8n-io/n8n/commit/71b6c671797024d7b516352fa9b7ecda101ce3b2)) +* **MySQL Node:** Return decimal types as numbers ([#10313](https://github.com/n8n-io/n8n/issues/10313)) ([f744d7c](https://github.com/n8n-io/n8n/commit/f744d7c100be68669d9a3efd0033dd371a3cfaf7)) +* **Okta Node:** Add Okta Node ([#10278](https://github.com/n8n-io/n8n/issues/10278)) ([5cac0f3](https://github.com/n8n-io/n8n/commit/5cac0f339d649cfe5857d33738210cbc1599370b)) + + + # [1.54.0](https://github.com/n8n-io/n8n/compare/n8n@1.53.0...n8n@1.54.0) (2024-08-07) diff --git a/cypress/e2e/19-execution.cy.ts b/cypress/e2e/19-execution.cy.ts index 804e81d4e605b..1f7d5c332eb50 100644 --- a/cypress/e2e/19-execution.cy.ts +++ b/cypress/e2e/19-execution.cy.ts @@ -275,7 +275,6 @@ describe('Execution', () => { .within(() => cy.get('.fa-check').should('not.exist')); successToast().should('be.visible'); - clearNotifications(); // Clear execution data workflowPage.getters.clearExecutionDataButton().should('be.visible'); diff --git a/package.json b/package.json index 5744497d771cd..e7d55c64455ad 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "n8n-monorepo", - "version": "1.54.0", + "version": "1.55.0", "private": true, "engines": { "node": ">=20.15", diff --git a/packages/@n8n/chat/package.json b/packages/@n8n/chat/package.json index ea2cd454388b0..6508081f45553 100644 --- a/packages/@n8n/chat/package.json +++ b/packages/@n8n/chat/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/chat", - "version": "0.23.0", + "version": "0.24.0", "scripts": { "dev": "pnpm run storybook", "build": "pnpm build:vite && pnpm build:bundle", diff --git a/packages/@n8n/config/package.json b/packages/@n8n/config/package.json index e28090e49b9d2..1ee3fb9160b9a 100644 --- a/packages/@n8n/config/package.json +++ b/packages/@n8n/config/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/config", - "version": "1.4.0", + "version": "1.5.0", "scripts": { "clean": "rimraf dist .turbo", "dev": "pnpm watch", diff --git a/packages/@n8n/config/src/configs/cache.ts b/packages/@n8n/config/src/configs/cache.config.ts similarity index 100% rename from packages/@n8n/config/src/configs/cache.ts rename to packages/@n8n/config/src/configs/cache.config.ts diff --git a/packages/@n8n/config/src/configs/credentials.ts b/packages/@n8n/config/src/configs/credentials.config.ts similarity index 100% rename from packages/@n8n/config/src/configs/credentials.ts rename to packages/@n8n/config/src/configs/credentials.config.ts diff --git a/packages/@n8n/config/src/configs/database.ts b/packages/@n8n/config/src/configs/database.config.ts similarity index 100% rename from packages/@n8n/config/src/configs/database.ts rename to packages/@n8n/config/src/configs/database.config.ts diff --git a/packages/@n8n/config/src/configs/endpoints.ts b/packages/@n8n/config/src/configs/endpoints.config.ts similarity index 100% rename from packages/@n8n/config/src/configs/endpoints.ts rename to packages/@n8n/config/src/configs/endpoints.config.ts diff --git a/packages/@n8n/config/src/configs/event-bus.ts b/packages/@n8n/config/src/configs/event-bus.config.ts similarity index 100% rename from packages/@n8n/config/src/configs/event-bus.ts rename to packages/@n8n/config/src/configs/event-bus.config.ts diff --git a/packages/@n8n/config/src/configs/external-secrets.ts b/packages/@n8n/config/src/configs/external-secrets.config.ts similarity index 100% rename from packages/@n8n/config/src/configs/external-secrets.ts rename to packages/@n8n/config/src/configs/external-secrets.config.ts diff --git a/packages/@n8n/config/src/configs/external-storage.ts b/packages/@n8n/config/src/configs/external-storage.config.ts similarity index 100% rename from packages/@n8n/config/src/configs/external-storage.ts rename to packages/@n8n/config/src/configs/external-storage.config.ts diff --git a/packages/@n8n/config/src/configs/nodes.ts b/packages/@n8n/config/src/configs/nodes.config.ts similarity index 100% rename from packages/@n8n/config/src/configs/nodes.ts rename to packages/@n8n/config/src/configs/nodes.config.ts diff --git a/packages/@n8n/config/src/configs/public-api.ts b/packages/@n8n/config/src/configs/public-api.config.ts similarity index 100% rename from packages/@n8n/config/src/configs/public-api.ts rename to packages/@n8n/config/src/configs/public-api.config.ts diff --git a/packages/@n8n/config/src/configs/templates.ts b/packages/@n8n/config/src/configs/templates.config.ts similarity index 100% rename from packages/@n8n/config/src/configs/templates.ts rename to packages/@n8n/config/src/configs/templates.config.ts diff --git a/packages/@n8n/config/src/configs/email.ts b/packages/@n8n/config/src/configs/user-management.config.ts similarity index 92% rename from packages/@n8n/config/src/configs/email.ts rename to packages/@n8n/config/src/configs/user-management.config.ts index f0e130c3b48be..2c603a7148002 100644 --- a/packages/@n8n/config/src/configs/email.ts +++ b/packages/@n8n/config/src/configs/user-management.config.ts @@ -1,7 +1,7 @@ import { Config, Env, Nested } from '../decorators'; @Config -export class SmtpAuth { +class SmtpAuth { /** SMTP login username */ @Env('N8N_SMTP_USER') user = ''; @@ -20,7 +20,7 @@ export class SmtpAuth { } @Config -export class SmtpConfig { +class SmtpConfig { /** SMTP server host */ @Env('N8N_SMTP_HOST') host = ''; @@ -65,7 +65,7 @@ export class TemplateConfig { } @Config -export class EmailConfig { +class EmailConfig { /** How to send emails */ @Env('N8N_EMAIL_MODE') mode: '' | 'smtp' = 'smtp'; @@ -76,3 +76,9 @@ export class EmailConfig { @Nested template: TemplateConfig; } + +@Config +export class UserManagementConfig { + @Nested + emails: EmailConfig; +} diff --git a/packages/@n8n/config/src/configs/version-notifications.ts b/packages/@n8n/config/src/configs/version-notifications.config.ts similarity index 100% rename from packages/@n8n/config/src/configs/version-notifications.ts rename to packages/@n8n/config/src/configs/version-notifications.config.ts diff --git a/packages/@n8n/config/src/configs/workflows.ts b/packages/@n8n/config/src/configs/workflows.config.ts similarity index 100% rename from packages/@n8n/config/src/configs/workflows.ts rename to packages/@n8n/config/src/configs/workflows.config.ts diff --git a/packages/@n8n/config/src/index.ts b/packages/@n8n/config/src/index.ts index 88e6fb01170d8..a5b970eab4730 100644 --- a/packages/@n8n/config/src/index.ts +++ b/packages/@n8n/config/src/index.ts @@ -1,85 +1,79 @@ import { Config, Env, Nested } from './decorators'; -import { CredentialsConfig } from './configs/credentials'; -import { DatabaseConfig } from './configs/database'; -import { EmailConfig } from './configs/email'; -import { VersionNotificationsConfig } from './configs/version-notifications'; -import { PublicApiConfig } from './configs/public-api'; -import { ExternalSecretsConfig } from './configs/external-secrets'; -import { TemplatesConfig } from './configs/templates'; -import { EventBusConfig } from './configs/event-bus'; -import { NodesConfig } from './configs/nodes'; -import { ExternalStorageConfig } from './configs/external-storage'; -import { WorkflowsConfig } from './configs/workflows'; -import { EndpointsConfig } from './configs/endpoints'; -import { CacheConfig } from './configs/cache'; +import { CredentialsConfig } from './configs/credentials.config'; +import { DatabaseConfig } from './configs/database.config'; +import { VersionNotificationsConfig } from './configs/version-notifications.config'; +import { PublicApiConfig } from './configs/public-api.config'; +import { ExternalSecretsConfig } from './configs/external-secrets.config'; +import { TemplatesConfig } from './configs/templates.config'; +import { EventBusConfig } from './configs/event-bus.config'; +import { NodesConfig } from './configs/nodes.config'; +import { ExternalStorageConfig } from './configs/external-storage.config'; +import { WorkflowsConfig } from './configs/workflows.config'; +import { EndpointsConfig } from './configs/endpoints.config'; +import { CacheConfig } from './configs/cache.config'; import { ScalingModeConfig } from './configs/scaling-mode.config'; - -@Config -class UserManagementConfig { - @Nested - emails: EmailConfig; -} +import { UserManagementConfig } from './configs/user-management.config'; @Config export class GlobalConfig { @Nested - readonly database: DatabaseConfig; + database: DatabaseConfig; @Nested - readonly credentials: CredentialsConfig; + credentials: CredentialsConfig; @Nested - readonly userManagement: UserManagementConfig; + userManagement: UserManagementConfig; @Nested - readonly versionNotifications: VersionNotificationsConfig; + versionNotifications: VersionNotificationsConfig; @Nested - readonly publicApi: PublicApiConfig; + publicApi: PublicApiConfig; @Nested - readonly externalSecrets: ExternalSecretsConfig; + externalSecrets: ExternalSecretsConfig; @Nested - readonly templates: TemplatesConfig; + templates: TemplatesConfig; @Nested - readonly eventBus: EventBusConfig; + eventBus: EventBusConfig; @Nested - readonly nodes: NodesConfig; + nodes: NodesConfig; @Nested - readonly externalStorage: ExternalStorageConfig; + externalStorage: ExternalStorageConfig; @Nested - readonly workflows: WorkflowsConfig; + workflows: WorkflowsConfig; /** Path n8n is deployed to */ @Env('N8N_PATH') - readonly path: string = '/'; + path = '/'; /** Host name n8n can be reached */ @Env('N8N_HOST') - readonly host: string = 'localhost'; + host = 'localhost'; /** HTTP port n8n can be reached */ @Env('N8N_PORT') - readonly port: number = 5678; + port = 5678; /** IP address n8n should listen on */ @Env('N8N_LISTEN_ADDRESS') - readonly listen_address: string = '0.0.0.0'; + listen_address = '0.0.0.0'; /** HTTP Protocol via which n8n can be reached */ @Env('N8N_PROTOCOL') - readonly protocol: 'http' | 'https' = 'http'; + protocol: 'http' | 'https' = 'http'; @Nested - readonly endpoints: EndpointsConfig; + endpoints: EndpointsConfig; @Nested - readonly cache: CacheConfig; + cache: CacheConfig; @Nested queue: ScalingModeConfig; diff --git a/packages/@n8n/nodes-langchain/package.json b/packages/@n8n/nodes-langchain/package.json index d1478e1d22347..acec252253c6c 100644 --- a/packages/@n8n/nodes-langchain/package.json +++ b/packages/@n8n/nodes-langchain/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/n8n-nodes-langchain", - "version": "1.54.0", + "version": "1.55.0", "description": "", "main": "index.js", "scripts": { diff --git a/packages/@n8n/nodes-langchain/utils/helpers.ts b/packages/@n8n/nodes-langchain/utils/helpers.ts index 673fa0402c483..05b4a5cadc6f7 100644 --- a/packages/@n8n/nodes-langchain/utils/helpers.ts +++ b/packages/@n8n/nodes-langchain/utils/helpers.ts @@ -13,7 +13,6 @@ import type { BaseLLM } from '@langchain/core/language_models/llms'; import type { BaseChatMemory } from 'langchain/memory'; import type { BaseChatMessageHistory } from '@langchain/core/chat_history'; import { N8nTool } from './N8nTool'; -import { DynamicTool } from '@langchain/core/tools'; function hasMethods(obj: unknown, ...methodNames: Array): obj is T { return methodNames.every( @@ -195,8 +194,6 @@ export const getConnectedTools = async ( const finalTools = []; for (const tool of connectedTools) { - if (!(tool instanceof DynamicTool) && !(tool instanceof N8nTool)) continue; - const { name } = tool; if (seenNames.has(name)) { throw new NodeOperationError( diff --git a/packages/@n8n/permissions/package.json b/packages/@n8n/permissions/package.json index e3b1c7b27697e..7b02f5ef028c5 100644 --- a/packages/@n8n/permissions/package.json +++ b/packages/@n8n/permissions/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/permissions", - "version": "0.11.0", + "version": "0.12.0", "scripts": { "clean": "rimraf dist .turbo", "dev": "pnpm watch", diff --git a/packages/cli/package.json b/packages/cli/package.json index d0f1fc34c3f83..54ca084465300 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "n8n", - "version": "1.54.0", + "version": "1.55.0", "description": "n8n Workflow Automation Tool", "main": "dist/index", "types": "dist/index.d.ts", @@ -90,7 +90,8 @@ "@n8n/n8n-nodes-langchain": "workspace:*", "@n8n/permissions": "workspace:*", "@n8n/typeorm": "0.3.20-10", - "@n8n_io/license-sdk": "2.13.0", + "@n8n_io/ai-assistant-sdk": "1.9.4", + "@n8n_io/license-sdk": "2.13.1", "@oclif/core": "4.0.7", "@rudderstack/rudder-sdk-node": "2.0.7", "@sentry/integrations": "7.87.0", diff --git a/packages/cli/src/License.ts b/packages/cli/src/License.ts index fc0d2cd45a235..8a5ed18e5808e 100644 --- a/packages/cli/src/License.ts +++ b/packages/cli/src/License.ts @@ -249,6 +249,10 @@ export class License { return this.isFeatureEnabled(LICENSE_FEATURES.SAML); } + isAiAssistantEnabled() { + return this.isFeatureEnabled(LICENSE_FEATURES.AI_ASSISTANT); + } + isAdvancedExecutionFiltersEnabled() { return this.isFeatureEnabled(LICENSE_FEATURES.ADVANCED_EXECUTION_FILTERS); } diff --git a/packages/cli/src/Server.ts b/packages/cli/src/Server.ts index 777472b99fa42..49b358f0f254c 100644 --- a/packages/cli/src/Server.ts +++ b/packages/cli/src/Server.ts @@ -41,6 +41,7 @@ import '@/controllers/activeWorkflows.controller'; import '@/controllers/auth.controller'; import '@/controllers/binaryData.controller'; import '@/controllers/curl.controller'; +import '@/controllers/aiAssistant.controller'; import '@/controllers/dynamicNodeParameters.controller'; import '@/controllers/invitation.controller'; import '@/controllers/me.controller'; diff --git a/packages/cli/src/config/schema.ts b/packages/cli/src/config/schema.ts index a0db9a4c36d16..d3334d4ab1c57 100644 --- a/packages/cli/src/config/schema.ts +++ b/packages/cli/src/config/schema.ts @@ -569,6 +569,15 @@ export const schema = { }, }, + aiAssistant: { + baseUrl: { + doc: 'Base URL of the AI assistant service', + format: String, + default: '', + env: 'N8N_AI_ASSISTANT_BASE_URL', + }, + }, + expression: { evaluator: { doc: 'Expression evaluator to use', diff --git a/packages/cli/src/constants.ts b/packages/cli/src/constants.ts index dfb072576aa9f..045d5a30e1cba 100644 --- a/packages/cli/src/constants.ts +++ b/packages/cli/src/constants.ts @@ -90,6 +90,7 @@ export const LICENSE_FEATURES = { PROJECT_ROLE_ADMIN: 'feat:projectRole:admin', PROJECT_ROLE_EDITOR: 'feat:projectRole:editor', PROJECT_ROLE_VIEWER: 'feat:projectRole:viewer', + AI_ASSISTANT: 'feat:aiAssistant', COMMUNITY_NODES_CUSTOM_REGISTRY: 'feat:communityNodes:customRegistry', } as const; diff --git a/packages/cli/src/controllers/__tests__/me.controller.test.ts b/packages/cli/src/controllers/__tests__/me.controller.test.ts index 3ff4c5bbe0860..71a6d693842cf 100644 --- a/packages/cli/src/controllers/__tests__/me.controller.test.ts +++ b/packages/cli/src/controllers/__tests__/me.controller.test.ts @@ -53,9 +53,14 @@ describe('MeController', () => { password: 'password', authIdentities: [], role: 'global:owner', + mfaEnabled: false, }); - const reqBody = { email: 'valid@email.com', firstName: 'John', lastName: 'Potato' }; - const req = mock({ user, body: reqBody, browserId }); + const req = mock({ user, browserId }); + req.body = { + email: 'valid@email.com', + firstName: 'John', + lastName: 'Potato', + }; const res = mock(); userRepository.findOneByOrFail.mockResolvedValue(user); userRepository.findOneOrFail.mockResolvedValue(user); @@ -67,7 +72,7 @@ describe('MeController', () => { expect(externalHooks.run).toHaveBeenCalledWith('user.profile.beforeUpdate', [ user.id, user.email, - reqBody, + req.body, ]); expect(userService.update).toHaveBeenCalled(); @@ -98,25 +103,25 @@ describe('MeController', () => { password: 'password', authIdentities: [], role: 'global:member', + mfaEnabled: false, }); - const reqBody = { email: 'valid@email.com', firstName: 'John', lastName: 'Potato' }; const req = mock({ user, browserId }); - req.body = reqBody; + req.body = { email: 'valid@email.com', firstName: 'John', lastName: 'Potato' }; const res = mock(); userRepository.findOneOrFail.mockResolvedValue(user); jest.spyOn(jwt, 'sign').mockImplementation(() => 'signed-token'); // Add invalid data to the request payload - Object.assign(reqBody, { id: '0', role: 'global:owner' }); + Object.assign(req.body, { id: '0', role: 'global:owner' }); await controller.updateCurrentUser(req, res); expect(userService.update).toHaveBeenCalled(); const updatePayload = userService.update.mock.calls[0][1]; - expect(updatePayload.email).toBe(reqBody.email); - expect(updatePayload.firstName).toBe(reqBody.firstName); - expect(updatePayload.lastName).toBe(reqBody.lastName); + expect(updatePayload.email).toBe(req.body.email); + expect(updatePayload.firstName).toBe(req.body.firstName); + expect(updatePayload.lastName).toBe(req.body.lastName); expect(updatePayload.id).toBeUndefined(); expect(updatePayload.role).toBeUndefined(); }); @@ -127,10 +132,11 @@ describe('MeController', () => { password: 'password', authIdentities: [], role: 'global:owner', + mfaEnabled: false, }); const reqBody = { email: 'valid@email.com', firstName: 'John', lastName: 'Potato' }; const req = mock({ user, body: reqBody }); - // userService.findOneOrFail.mockResolvedValue(user); + req.body = reqBody; // We don't want the body to be a mock object externalHooks.run.mockImplementationOnce(async (hookName) => { if (hookName === 'user.profile.beforeUpdate') { @@ -142,6 +148,76 @@ describe('MeController', () => { new BadRequestError('Invalid email address'), ); }); + + describe('when mfa is enabled', () => { + it('should throw BadRequestError if mfa code is missing', async () => { + const user = mock({ + id: '123', + email: 'valid@email.com', + password: 'password', + authIdentities: [], + role: 'global:owner', + mfaEnabled: true, + }); + const req = mock({ user, browserId }); + req.body = { email: 'new@email.com', firstName: 'John', lastName: 'Potato' }; + + await expect(controller.updateCurrentUser(req, mock())).rejects.toThrowError( + new BadRequestError('Two-factor code is required to change email'), + ); + }); + + it('should throw InvalidMfaCodeError if mfa code is invalid', async () => { + const user = mock({ + id: '123', + email: 'valid@email.com', + password: 'password', + authIdentities: [], + role: 'global:owner', + mfaEnabled: true, + }); + const req = mock({ user, browserId }); + req.body = { + email: 'new@email.com', + firstName: 'John', + lastName: 'Potato', + mfaCode: 'invalid', + }; + mockMfaService.validateMfa.mockResolvedValue(false); + + await expect(controller.updateCurrentUser(req, mock())).rejects.toThrow( + InvalidMfaCodeError, + ); + }); + + it("should update the user's email if mfa code is valid", async () => { + const user = mock({ + id: '123', + email: 'valid@email.com', + password: 'password', + authIdentities: [], + role: 'global:owner', + mfaEnabled: true, + }); + const req = mock({ user, browserId }); + req.body = { + email: 'new@email.com', + firstName: 'John', + lastName: 'Potato', + mfaCode: '123456', + }; + const res = mock(); + userRepository.findOneByOrFail.mockResolvedValue(user); + userRepository.findOneOrFail.mockResolvedValue(user); + jest.spyOn(jwt, 'sign').mockImplementation(() => 'signed-token'); + userService.toPublic.mockResolvedValue({} as unknown as PublicUser); + mockMfaService.validateMfa.mockResolvedValue(true); + + const result = await controller.updateCurrentUser(req, res); + + expect(result).toEqual({}); + }); + }); }); describe('updatePassword', () => { diff --git a/packages/cli/src/controllers/aiAssistant.controller.ts b/packages/cli/src/controllers/aiAssistant.controller.ts new file mode 100644 index 0000000000000..473c20a79dcad --- /dev/null +++ b/packages/cli/src/controllers/aiAssistant.controller.ts @@ -0,0 +1,44 @@ +import { Post, RestController } from '@/decorators'; +import { AiAssistantService } from '@/services/aiAsisstant.service'; +import { AiAssistantRequest } from '@/requests'; +import { Response } from 'express'; +import type { AiAssistantSDK } from '@n8n_io/ai-assistant-sdk'; +import { Readable, promises } from 'node:stream'; +import { InternalServerError } from 'express-openapi-validator/dist/openapi.validator'; +import { strict as assert } from 'node:assert'; +import { ErrorReporterProxy } from 'n8n-workflow'; + +@RestController('/ai-assistant') +export class AiAssistantController { + constructor(private readonly aiAssistantService: AiAssistantService) {} + + @Post('/chat', { rateLimit: { limit: 100 } }) + async chat(req: AiAssistantRequest.Chat, res: Response) { + try { + const stream = await this.aiAssistantService.chat(req.body, req.user); + + if (stream.body) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + await promises.pipeline(Readable.fromWeb(stream.body), res); + } + } catch (e) { + // todo add sentry reporting + assert(e instanceof Error); + ErrorReporterProxy.error(e); + throw new InternalServerError({ message: `Something went wrong: ${e.message}` }); + } + } + + @Post('/chat/apply-suggestion') + async applySuggestion( + req: AiAssistantRequest.ApplySuggestion, + ): Promise { + try { + return await this.aiAssistantService.applySuggestion(req.body, req.user); + } catch (e) { + assert(e instanceof Error); + ErrorReporterProxy.error(e); + throw new InternalServerError({ message: `Something went wrong: ${e.message}` }); + } + } +} diff --git a/packages/cli/src/controllers/e2e.controller.ts b/packages/cli/src/controllers/e2e.controller.ts index 20224795052e8..2a93fe8edac16 100644 --- a/packages/cli/src/controllers/e2e.controller.ts +++ b/packages/cli/src/controllers/e2e.controller.ts @@ -87,6 +87,7 @@ export class E2EController { [LICENSE_FEATURES.PROJECT_ROLE_ADMIN]: false, [LICENSE_FEATURES.PROJECT_ROLE_EDITOR]: false, [LICENSE_FEATURES.PROJECT_ROLE_VIEWER]: false, + [LICENSE_FEATURES.AI_ASSISTANT]: false, [LICENSE_FEATURES.COMMUNITY_NODES_CUSTOM_REGISTRY]: false, }; diff --git a/packages/cli/src/controllers/me.controller.ts b/packages/cli/src/controllers/me.controller.ts index 179076d25d224..19429228edead 100644 --- a/packages/cli/src/controllers/me.controller.ts +++ b/packages/cli/src/controllers/me.controller.ts @@ -54,7 +54,8 @@ export class MeController { */ @Patch('/') async updateCurrentUser(req: MeRequest.UserUpdate, res: Response): Promise { - const { id: userId, email: currentEmail } = req.user; + const { id: userId, email: currentEmail, mfaEnabled } = req.user; + const payload = plainToInstance(UserUpdatePayload, req.body, { excludeExtraneousValues: true }); const { email } = payload; @@ -76,17 +77,28 @@ export class MeController { await validateEntity(payload); + const isEmailBeingChanged = email !== currentEmail; + // If SAML is enabled, we don't allow the user to change their email address - if (isSamlLicensedAndEnabled()) { - if (email !== currentEmail) { - this.logger.debug( - 'Request to update user failed because SAML user may not change their email', - { - userId, - payload, - }, - ); - throw new BadRequestError('SAML user may not change their email'); + if (isSamlLicensedAndEnabled() && isEmailBeingChanged) { + this.logger.debug( + 'Request to update user failed because SAML user may not change their email', + { + userId, + payload, + }, + ); + throw new BadRequestError('SAML user may not change their email'); + } + + if (mfaEnabled && isEmailBeingChanged) { + if (!payload.mfaCode) { + throw new BadRequestError('Two-factor code is required to change email'); + } + + const isMfaTokenValid = await this.mfaService.validateMfa(userId, payload.mfaCode, undefined); + if (!isMfaTokenValid) { + throw new InvalidMfaCodeError(); } } @@ -102,8 +114,9 @@ export class MeController { this.authService.issueCookie(res, user, req.browserId); - const fieldsChanged = (Object.keys(payload) as Array).filter( - (key) => payload[key] !== preUpdateUser[key], + const changeableFields = ['email', 'firstName', 'lastName'] as const; + const fieldsChanged = changeableFields.filter( + (key) => key in payload && payload[key] !== preUpdateUser[key], ); this.eventService.emit('user-updated', { user, fieldsChanged }); diff --git a/packages/cli/src/credentials/credentials.controller.ts b/packages/cli/src/credentials/credentials.controller.ts index 40f2aa8d43219..ba965a1adde64 100644 --- a/packages/cli/src/credentials/credentials.controller.ts +++ b/packages/cli/src/credentials/credentials.controller.ts @@ -127,11 +127,11 @@ export class CredentialsController { const mergedCredentials = deepCopy(credentials); const decryptedData = this.credentialsService.decrypt(storedCredential); - // When a sharee opens a credential, the fields and the credential data are missing - // so the payload will be empty + // When a sharee (or project viewer) opens a credential, the fields and the + // credential data are missing so the payload will be empty // We need to replace the credential contents with the db version if that's the case // So the credential can be tested properly - this.credentialsService.replaceCredentialContentsForSharee( + await this.credentialsService.replaceCredentialContentsForSharee( req.user, storedCredential, decryptedData, diff --git a/packages/cli/src/credentials/credentials.service.ts b/packages/cli/src/credentials/credentials.service.ts index 01517c960adad..03ea13efe1a3e 100644 --- a/packages/cli/src/credentials/credentials.service.ts +++ b/packages/cli/src/credentials/credentials.service.ts @@ -38,6 +38,7 @@ import { NotFoundError } from '@/errors/response-errors/not-found.error'; import type { ProjectRelation } from '@/databases/entities/ProjectRelation'; import { RoleService } from '@/services/role.service'; import { UserRepository } from '@/databases/repositories/user.repository'; +import { userHasScope } from '@/permissions/checkAccess'; export type CredentialsGetSharedOptions = | { allowGlobalScope: true; globalScope: Scope } @@ -599,28 +600,20 @@ export class CredentialsService { ); } - replaceCredentialContentsForSharee( + async replaceCredentialContentsForSharee( user: User, credential: CredentialsEntity, decryptedData: ICredentialDataDecryptedObject, mergedCredentials: ICredentialsDecrypted, ) { - credential.shared.forEach((sharedCredentials) => { - if (sharedCredentials.role === 'credential:owner') { - if (sharedCredentials.project.type === 'personal') { - // Find the owner of this personal project - sharedCredentials.project.projectRelations.forEach((projectRelation) => { - if ( - projectRelation.role === 'project:personalOwner' && - projectRelation.user.id !== user.id - ) { - // If we realize that the current user does not own this credential - // We replace the payload with the stored decrypted data - mergedCredentials.data = decryptedData; - } - }); - } - } - }); + // We may want to change this to 'credential:decrypt' if that gets added, but this + // works for now. The only time we wouldn't want to do this is if the user + // could actually be testing the credential before saving it, so this should cover + // the cases we need it for. + if ( + !(await userHasScope(user, ['credential:update'], false, { credentialId: credential.id })) + ) { + mergedCredentials.data = decryptedData; + } } } diff --git a/packages/cli/src/eventbus/EventMessageClasses/index.ts b/packages/cli/src/eventbus/EventMessageClasses/index.ts index 461394d378d94..2e4d78560d9da 100644 --- a/packages/cli/src/eventbus/EventMessageClasses/index.ts +++ b/packages/cli/src/eventbus/EventMessageClasses/index.ts @@ -10,7 +10,6 @@ export const eventNamesWorkflow = [ 'n8n.workflow.started', 'n8n.workflow.success', 'n8n.workflow.failed', - 'n8n.workflow.crashed', ] as const; export const eventNamesGeneric = ['n8n.worker.started', 'n8n.worker.stopped'] as const; export const eventNamesNode = ['n8n.node.started', 'n8n.node.finished'] as const; diff --git a/packages/cli/src/eventbus/MessageEventBusWriter/MessageEventBusLogWriter.ts b/packages/cli/src/eventbus/MessageEventBusWriter/MessageEventBusLogWriter.ts index 08899c0e0902d..0e49ff3fb7613 100644 --- a/packages/cli/src/eventbus/MessageEventBusWriter/MessageEventBusLogWriter.ts +++ b/packages/cli/src/eventbus/MessageEventBusWriter/MessageEventBusLogWriter.ts @@ -225,7 +225,6 @@ export class MessageEventBusLogWriter { break; case 'n8n.workflow.success': case 'n8n.workflow.failed': - case 'n8n.workflow.crashed': case 'n8n.execution.throttled': case 'n8n.execution.started-during-bootup': delete results.unfinishedExecutions[executionId]; diff --git a/packages/cli/src/events/__tests__/log-streaming-event-relay.test.ts b/packages/cli/src/events/__tests__/log-streaming-event-relay.test.ts index 7254f5a09bfc6..720e2d494c332 100644 --- a/packages/cli/src/events/__tests__/log-streaming-event-relay.test.ts +++ b/packages/cli/src/events/__tests__/log-streaming-event-relay.test.ts @@ -142,7 +142,12 @@ describe('LogStreamingEventRelay', () => { executionId: 'some-id', userId: 'some-id', workflow: mock({ id: 'some-id', name: 'some-name' }), - runData: mock({ status: 'success', mode: 'manual', data: { resultData: {} } }), + runData: mock({ + finished: true, + status: 'success', + mode: 'manual', + data: { resultData: {} }, + }), }); eventService.emit('workflow-post-execute', payload); @@ -153,7 +158,7 @@ describe('LogStreamingEventRelay', () => { eventName: 'n8n.workflow.success', payload: { ...rest, - success: true, + success: true, // same as finished isManual: true, workflowName: 'some-name', workflowId: 'some-id', @@ -161,10 +166,11 @@ describe('LogStreamingEventRelay', () => { }); }); - it('should log on `workflow-post-execute` event for unsuccessful execution', () => { + it('should log on `workflow-post-execute` event for failed execution', () => { const runData = mock({ status: 'error', mode: 'manual', + finished: false, data: { resultData: { lastNodeExecuted: 'some-node', @@ -193,7 +199,7 @@ describe('LogStreamingEventRelay', () => { eventName: 'n8n.workflow.failed', payload: { ...rest, - success: false, + success: false, // same as finished isManual: true, workflowName: 'some-name', workflowId: 'some-id', diff --git a/packages/cli/src/events/__tests__/telemetry-event-relay.test.ts b/packages/cli/src/events/__tests__/telemetry-event-relay.test.ts new file mode 100644 index 0000000000000..387a60e6256db --- /dev/null +++ b/packages/cli/src/events/__tests__/telemetry-event-relay.test.ts @@ -0,0 +1,1031 @@ +import { mock } from 'jest-mock-extended'; +import { TelemetryEventRelay } from '@/events/telemetry-event-relay'; +import { EventService } from '@/events/event.service'; +import config from '@/config'; +import type { IWorkflowBase } from 'n8n-workflow'; +import type { IWorkflowDb } from '@/Interfaces'; +import type { Telemetry } from '@/telemetry'; +import type { License } from '@/License'; +import type { GlobalConfig } from '@n8n/config'; +import type { WorkflowRepository } from '@/databases/repositories/workflow.repository'; +import type { NodeTypes } from '@/NodeTypes'; +import type { SharedWorkflowRepository } from '@/databases/repositories/sharedWorkflow.repository'; +import type { ProjectRelationRepository } from '@/databases/repositories/projectRelation.repository'; +import type { RelayEventMap } from '@/events/relay-event-map'; +import type { WorkflowEntity } from '@/databases/entities/WorkflowEntity'; +import { N8N_VERSION } from '@/constants'; + +const flushPromises = async () => await new Promise((resolve) => setImmediate(resolve)); + +describe('TelemetryEventRelay', () => { + const telemetry = mock(); + const license = mock(); + const globalConfig = mock({ userManagement: { emails: { mode: 'smtp' } } }); + const workflowRepository = mock(); + const nodeTypes = mock(); + const sharedWorkflowRepository = mock(); + const projectRelationRepository = mock(); + const eventService = new EventService(); + + let telemetryEventRelay: TelemetryEventRelay; + + beforeAll(async () => { + telemetryEventRelay = new TelemetryEventRelay( + eventService, + telemetry, + license, + globalConfig, + workflowRepository, + nodeTypes, + sharedWorkflowRepository, + projectRelationRepository, + ); + + await telemetryEventRelay.init(); + }); + + beforeEach(() => { + config.set('diagnostics.enabled', true); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('init', () => { + it('with diagnostics enabled, should init telemetry and register listeners', async () => { + config.set('diagnostics.enabled', true); + const telemetryEventRelay = new TelemetryEventRelay( + eventService, + telemetry, + license, + globalConfig, + workflowRepository, + nodeTypes, + sharedWorkflowRepository, + projectRelationRepository, + ); + // @ts-expect-error Private method + const setupListenersSpy = jest.spyOn(telemetryEventRelay, 'setupListeners'); + + await telemetryEventRelay.init(); + + expect(telemetry.init).toHaveBeenCalled(); + expect(setupListenersSpy).toHaveBeenCalled(); + }); + + it('with diagnostics disabled, should neither init telemetry nor register listeners', async () => { + config.set('diagnostics.enabled', false); + const telemetryEventRelay = new TelemetryEventRelay( + eventService, + telemetry, + license, + globalConfig, + workflowRepository, + nodeTypes, + sharedWorkflowRepository, + projectRelationRepository, + ); + // @ts-expect-error Private method + const setupListenersSpy = jest.spyOn(telemetryEventRelay, 'setupListeners'); + + await telemetryEventRelay.init(); + + expect(telemetry.init).not.toHaveBeenCalled(); + expect(setupListenersSpy).not.toHaveBeenCalled(); + }); + }); + + describe('project events', () => { + it('should track on `team-project-updated` event', () => { + const event: RelayEventMap['team-project-updated'] = { + userId: 'user123', + role: 'global:owner', + members: [ + { userId: 'user456', role: 'project:admin' }, + { userId: 'user789', role: 'project:editor' }, + ], + projectId: 'project123', + }; + + eventService.emit('team-project-updated', event); + + expect(telemetry.track).toHaveBeenCalledWith('Project settings updated', { + user_id: 'user123', + role: 'global:owner', + members: [ + { user_id: 'user456', role: 'project:admin' }, + { user_id: 'user789', role: 'project:editor' }, + ], + project_id: 'project123', + }); + }); + + it('should track on `team-project-deleted` event', () => { + const event: RelayEventMap['team-project-deleted'] = { + userId: 'user123', + role: 'global:owner', + projectId: 'project123', + removalType: 'delete', + }; + + eventService.emit('team-project-deleted', event); + + expect(telemetry.track).toHaveBeenCalledWith('User deleted project', { + user_id: 'user123', + role: 'global:owner', + project_id: 'project123', + removal_type: 'delete', + target_project_id: undefined, + }); + }); + + it('should track on `team-project-created` event', () => { + const event: RelayEventMap['team-project-created'] = { + userId: 'user123', + role: 'global:owner', + }; + + eventService.emit('team-project-created', event); + + expect(telemetry.track).toHaveBeenCalledWith('User created project', { + user_id: 'user123', + role: 'global:owner', + }); + }); + }); + + describe('source control events', () => { + it('should track on `source-control-settings-updated` event', () => { + const event: RelayEventMap['source-control-settings-updated'] = { + branchName: 'main', + readOnlyInstance: false, + repoType: 'github', + connected: true, + }; + + eventService.emit('source-control-settings-updated', event); + + expect(telemetry.track).toHaveBeenCalledWith('User updated source control settings', { + branch_name: 'main', + read_only_instance: false, + repo_type: 'github', + connected: true, + }); + }); + + it('should track on `source-control-user-started-pull-ui` event', () => { + const event: RelayEventMap['source-control-user-started-pull-ui'] = { + workflowUpdates: 5, + workflowConflicts: 2, + credConflicts: 1, + }; + + eventService.emit('source-control-user-started-pull-ui', event); + + expect(telemetry.track).toHaveBeenCalledWith('User started pull via UI', { + workflow_updates: 5, + workflow_conflicts: 2, + cred_conflicts: 1, + }); + }); + + it('should track on `source-control-user-finished-pull-ui` event', () => { + const event: RelayEventMap['source-control-user-finished-pull-ui'] = { + workflowUpdates: 3, + }; + + eventService.emit('source-control-user-finished-pull-ui', event); + + expect(telemetry.track).toHaveBeenCalledWith('User finished pull via UI', { + workflow_updates: 3, + }); + }); + + it('should track on `source-control-user-pulled-api` event', () => { + const event: RelayEventMap['source-control-user-pulled-api'] = { + workflowUpdates: 2, + forced: false, + }; + + eventService.emit('source-control-user-pulled-api', event); + + expect(telemetry.track).toHaveBeenCalledWith('User pulled via API', { + workflow_updates: 2, + forced: false, + }); + }); + + it('should track on `source-control-user-started-push-ui` event', () => { + const event: RelayEventMap['source-control-user-started-push-ui'] = { + workflowsEligible: 10, + workflowsEligibleWithConflicts: 2, + credsEligible: 5, + credsEligibleWithConflicts: 1, + variablesEligible: 3, + }; + + eventService.emit('source-control-user-started-push-ui', event); + + expect(telemetry.track).toHaveBeenCalledWith('User started push via UI', { + workflows_eligible: 10, + workflows_eligible_with_conflicts: 2, + creds_eligible: 5, + creds_eligible_with_conflicts: 1, + variables_eligible: 3, + }); + }); + + it('should track on `source-control-user-finished-push-ui` event', () => { + const event: RelayEventMap['source-control-user-finished-push-ui'] = { + workflowsEligible: 10, + workflowsPushed: 8, + credsPushed: 5, + variablesPushed: 3, + }; + + eventService.emit('source-control-user-finished-push-ui', event); + + expect(telemetry.track).toHaveBeenCalledWith('User finished push via UI', { + workflows_eligible: 10, + workflows_pushed: 8, + creds_pushed: 5, + variables_pushed: 3, + }); + }); + }); + + describe('license events', () => { + it('should track on `license-renewal-attempted` event', () => { + const event: RelayEventMap['license-renewal-attempted'] = { + success: true, + }; + + eventService.emit('license-renewal-attempted', event); + + expect(telemetry.track).toHaveBeenCalledWith('Instance attempted to refresh license', { + success: true, + }); + }); + }); + + describe('variable events', () => { + it('should track on `variable-created` event', () => { + eventService.emit('variable-created', {}); + + expect(telemetry.track).toHaveBeenCalledWith('User created variable'); + }); + }); + + describe('external secrets events', () => { + it('should track on `external-secrets-provider-settings-saved` event', () => { + const event: RelayEventMap['external-secrets-provider-settings-saved'] = { + userId: 'user123', + vaultType: 'aws', + isValid: true, + isNew: false, + }; + + eventService.emit('external-secrets-provider-settings-saved', event); + + expect(telemetry.track).toHaveBeenCalledWith('User updated external secrets settings', { + user_id: 'user123', + vault_type: 'aws', + is_valid: true, + is_new: false, + error_message: undefined, + }); + }); + }); + + describe('public API events', () => { + it('should track on `public-api-invoked` event', () => { + const event: RelayEventMap['public-api-invoked'] = { + userId: 'user123', + path: '/api/v1/workflows', + method: 'GET', + apiVersion: 'v1', + }; + + eventService.emit('public-api-invoked', event); + + expect(telemetry.track).toHaveBeenCalledWith('User invoked API', { + user_id: 'user123', + path: '/api/v1/workflows', + method: 'GET', + api_version: 'v1', + }); + }); + + it('should track on `public-api-key-created` event', () => { + const event: RelayEventMap['public-api-key-created'] = { + user: { + id: 'user123', + email: 'user@example.com', + firstName: 'John', + lastName: 'Doe', + role: 'global:owner', + }, + publicApi: true, + }; + + eventService.emit('public-api-key-created', event); + + expect(telemetry.track).toHaveBeenCalledWith('API key created', { + user_id: 'user123', + public_api: true, + }); + }); + + it('should track on `public-api-key-deleted` event', () => { + const event: RelayEventMap['public-api-key-deleted'] = { + user: { + id: 'user123', + email: 'user@example.com', + firstName: 'John', + lastName: 'Doe', + role: 'global:owner', + }, + publicApi: true, + }; + + eventService.emit('public-api-key-deleted', event); + + expect(telemetry.track).toHaveBeenCalledWith('API key deleted', { + user_id: 'user123', + public_api: true, + }); + }); + }); + + describe('community package events', () => { + it('should track on `community-package-installed` event', () => { + const event: RelayEventMap['community-package-installed'] = { + user: { + id: 'user123', + email: 'user@example.com', + firstName: 'John', + lastName: 'Doe', + role: 'global:owner', + }, + inputString: 'n8n-nodes-package', + packageName: 'n8n-nodes-package', + success: true, + packageVersion: '1.0.0', + packageNodeNames: ['CustomNode1', 'CustomNode2'], + packageAuthor: 'John Smith', + packageAuthorEmail: 'john@example.com', + }; + + eventService.emit('community-package-installed', event); + + expect(telemetry.track).toHaveBeenCalledWith('cnr package install finished', { + user_id: 'user123', + input_string: 'n8n-nodes-package', + package_name: 'n8n-nodes-package', + success: true, + package_version: '1.0.0', + package_node_names: ['CustomNode1', 'CustomNode2'], + package_author: 'John Smith', + package_author_email: 'john@example.com', + failure_reason: undefined, + }); + }); + + it('should track on `community-package-updated` event', () => { + const event: RelayEventMap['community-package-updated'] = { + user: { + id: 'user123', + email: 'user@example.com', + firstName: 'John', + lastName: 'Doe', + role: 'global:owner', + }, + packageName: 'n8n-nodes-package', + packageVersionCurrent: '1.0.0', + packageVersionNew: '1.1.0', + packageNodeNames: ['CustomNode1', 'CustomNode2'], + packageAuthor: 'John Smith', + packageAuthorEmail: 'john@example.com', + }; + + eventService.emit('community-package-updated', event); + + expect(telemetry.track).toHaveBeenCalledWith('cnr package updated', { + user_id: 'user123', + package_name: 'n8n-nodes-package', + package_version_current: '1.0.0', + package_version_new: '1.1.0', + package_node_names: ['CustomNode1', 'CustomNode2'], + package_author: 'John Smith', + package_author_email: 'john@example.com', + }); + }); + + it('should track on `community-package-deleted` event', () => { + const event: RelayEventMap['community-package-deleted'] = { + user: { + id: 'user123', + email: 'user@example.com', + firstName: 'John', + lastName: 'Doe', + role: 'global:owner', + }, + packageName: 'n8n-nodes-package', + packageVersion: '1.0.0', + packageNodeNames: ['CustomNode1', 'CustomNode2'], + packageAuthor: 'John Smith', + packageAuthorEmail: 'john@example.com', + }; + + eventService.emit('community-package-deleted', event); + + expect(telemetry.track).toHaveBeenCalledWith('cnr package deleted', { + user_id: 'user123', + package_name: 'n8n-nodes-package', + package_version: '1.0.0', + package_node_names: ['CustomNode1', 'CustomNode2'], + package_author: 'John Smith', + package_author_email: 'john@example.com', + }); + }); + }); + + describe('credentials events', () => { + it('should track on `credentials-created` event', () => { + const event: RelayEventMap['credentials-created'] = { + user: { + id: 'user123', + email: 'user@example.com', + firstName: 'John', + lastName: 'Doe', + role: 'global:owner', + }, + credentialType: 'github', + credentialId: 'cred123', + publicApi: false, + projectId: 'project123', + projectType: 'personal', + }; + + eventService.emit('credentials-created', event); + + expect(telemetry.track).toHaveBeenCalledWith('User created credentials', { + user_id: 'user123', + credential_type: 'github', + credential_id: 'cred123', + project_id: 'project123', + project_type: 'personal', + }); + }); + + it('should track on `credentials-shared` event', () => { + const event: RelayEventMap['credentials-shared'] = { + user: { + id: 'user123', + email: 'user@example.com', + firstName: 'John', + lastName: 'Doe', + role: 'global:owner', + }, + credentialType: 'github', + credentialId: 'cred123', + userIdSharer: 'user123', + userIdsShareesAdded: ['user456', 'user789'], + shareesRemoved: 1, + }; + + eventService.emit('credentials-shared', event); + + expect(telemetry.track).toHaveBeenCalledWith('User updated cred sharing', { + user_id: 'user123', + credential_type: 'github', + credential_id: 'cred123', + user_id_sharer: 'user123', + user_ids_sharees_added: ['user456', 'user789'], + sharees_removed: 1, + }); + }); + + it('should track on `credentials-updated` event', () => { + const event: RelayEventMap['credentials-updated'] = { + user: { + id: 'user123', + email: 'user@example.com', + firstName: 'John', + lastName: 'Doe', + role: 'global:owner', + }, + credentialId: 'cred123', + credentialType: 'github', + }; + + eventService.emit('credentials-updated', event); + + expect(telemetry.track).toHaveBeenCalledWith('User updated credentials', { + user_id: 'user123', + credential_type: 'github', + credential_id: 'cred123', + }); + }); + + it('should track on `credentials-deleted` event', () => { + const event: RelayEventMap['credentials-deleted'] = { + user: { + id: 'user123', + email: 'user@example.com', + firstName: 'John', + lastName: 'Doe', + role: 'global:owner', + }, + credentialId: 'cred123', + credentialType: 'github', + }; + + eventService.emit('credentials-deleted', event); + + expect(telemetry.track).toHaveBeenCalledWith('User deleted credentials', { + user_id: 'user123', + credential_type: 'github', + credential_id: 'cred123', + }); + }); + }); + + describe('LDAP events', () => { + it('should track on `ldap-general-sync-finished` event', () => { + const event: RelayEventMap['ldap-general-sync-finished'] = { + type: 'full', + succeeded: true, + usersSynced: 10, + error: '', + }; + + eventService.emit('ldap-general-sync-finished', event); + + expect(telemetry.track).toHaveBeenCalledWith('Ldap general sync finished', { + type: 'full', + succeeded: true, + users_synced: 10, + error: '', + }); + }); + + it('should track on `ldap-settings-updated` event', () => { + const event: RelayEventMap['ldap-settings-updated'] = { + userId: 'user123', + loginIdAttribute: 'uid', + firstNameAttribute: 'givenName', + lastNameAttribute: 'sn', + emailAttribute: 'mail', + ldapIdAttribute: 'entryUUID', + searchPageSize: 100, + searchTimeout: 60, + synchronizationEnabled: true, + synchronizationInterval: 60, + loginLabel: 'LDAP Login', + loginEnabled: true, + }; + + eventService.emit('ldap-settings-updated', { + ...event, + }); + + const { userId: _, ...rest } = event; + + expect(telemetry.track).toHaveBeenCalledWith('User updated Ldap settings', { + user_id: 'user123', + ...rest, + }); + }); + + it('should track on `ldap-login-sync-failed` event', () => { + const event: RelayEventMap['ldap-login-sync-failed'] = { + error: 'Connection failed', + }; + + eventService.emit('ldap-login-sync-failed', event); + + expect(telemetry.track).toHaveBeenCalledWith('Ldap login sync failed', { + error: 'Connection failed', + }); + }); + + it('should track on `login-failed-due-to-ldap-disabled` event', () => { + const event: RelayEventMap['login-failed-due-to-ldap-disabled'] = { + userId: 'user123', + }; + + eventService.emit('login-failed-due-to-ldap-disabled', event); + + expect(telemetry.track).toHaveBeenCalledWith('User login failed since ldap disabled', { + user_ud: 'user123', + }); + }); + }); + + describe('workflow events', () => { + it('should track on `workflow-created` event', async () => { + const event: RelayEventMap['workflow-created'] = { + user: { + id: 'user123', + email: 'user@example.com', + firstName: 'John', + lastName: 'Doe', + role: 'global:owner', + }, + workflow: mock({ id: 'workflow123', name: 'Test Workflow', nodes: [] }), + publicApi: false, + projectId: 'project123', + projectType: 'personal', + }; + + eventService.emit('workflow-created', event); + + await flushPromises(); + + expect(telemetry.track).toHaveBeenCalledWith('User created workflow', { + user_id: 'user123', + workflow_id: 'workflow123', + node_graph_string: expect.any(String), + public_api: false, + project_id: 'project123', + project_type: 'personal', + }); + }); + + it('should track on `workflow-deleted` event', () => { + const event: RelayEventMap['workflow-deleted'] = { + user: { + id: 'user123', + email: 'user@example.com', + firstName: 'John', + lastName: 'Doe', + role: 'global:owner', + }, + workflowId: 'workflow123', + publicApi: false, + }; + + eventService.emit('workflow-deleted', event); + + expect(telemetry.track).toHaveBeenCalledWith('User deleted workflow', { + user_id: 'user123', + workflow_id: 'workflow123', + public_api: false, + }); + }); + + it('should track on `workflow-post-execute` event', async () => { + const event: RelayEventMap['workflow-post-execute'] = { + workflow: mock({ + id: 'workflow123', + name: 'Test Workflow', + nodes: [], + }), + userId: 'user123', + executionId: 'execution123', + }; + + eventService.emit('workflow-post-execute', event); + + await flushPromises(); + + expect(telemetry.trackWorkflowExecution).toHaveBeenCalledWith({ + is_manual: false, + success: false, + user_id: 'user123', + version_cli: N8N_VERSION, + workflow_id: 'workflow123', + }); + }); + + it('should track on `workflow-saved` event', async () => { + const event: RelayEventMap['workflow-saved'] = { + user: { + id: 'user123', + email: 'user@example.com', + firstName: 'John', + lastName: 'Doe', + role: 'global:owner', + }, + workflow: mock({ id: 'workflow123', name: 'Test Workflow', nodes: [] }), + publicApi: false, + }; + + eventService.emit('workflow-saved', event); + + await flushPromises(); + + expect(telemetry.track).toHaveBeenCalledWith('User saved workflow', { + user_id: 'user123', + workflow_id: 'workflow123', + node_graph_string: expect.any(String), + notes_count_overlapping: 0, + notes_count_non_overlapping: 0, + version_cli: expect.any(String), + num_tags: 0, + public_api: false, + sharing_role: undefined, + }); + }); + + it('should track on `workflow-sharing-updated` event', () => { + const event: RelayEventMap['workflow-sharing-updated'] = { + workflowId: 'workflow123', + userIdSharer: 'user123', + userIdList: ['user456', 'user789'], + }; + + eventService.emit('workflow-sharing-updated', event); + + expect(telemetry.track).toHaveBeenCalledWith('User updated workflow sharing', { + workflow_id: 'workflow123', + user_id_sharer: 'user123', + user_id_list: ['user456', 'user789'], + }); + }); + }); + + describe('user events', () => { + it('should track on `user-updated` event', () => { + const event: RelayEventMap['user-updated'] = { + user: { + id: 'user123', + email: 'user@example.com', + firstName: 'John', + lastName: 'Doe', + role: 'global:owner', + }, + fieldsChanged: ['firstName', 'lastName'], + }; + + eventService.emit('user-updated', event); + + expect(telemetry.track).toHaveBeenCalledWith('User changed personal settings', { + user_id: 'user123', + fields_changed: ['firstName', 'lastName'], + }); + }); + + it('should track on `user-deleted` event', () => { + const event: RelayEventMap['user-deleted'] = { + user: { + id: 'user123', + email: 'user@example.com', + firstName: 'John', + lastName: 'Doe', + role: 'global:owner', + }, + publicApi: false, + targetUserOldStatus: 'active', + migrationStrategy: 'transfer_data', + targetUserId: 'user456', + migrationUserId: 'user789', + }; + + eventService.emit('user-deleted', event); + + expect(telemetry.track).toHaveBeenCalledWith('User deleted user', { + user_id: 'user123', + public_api: false, + target_user_old_status: 'active', + migration_strategy: 'transfer_data', + target_user_id: 'user456', + migration_user_id: 'user789', + }); + }); + + it('should track on `user-invited` event', () => { + const event: RelayEventMap['user-invited'] = { + user: { + id: 'user123', + email: 'user@example.com', + firstName: 'John', + lastName: 'Doe', + role: 'global:owner', + }, + targetUserId: ['user456'], + publicApi: false, + emailSent: true, + inviteeRole: 'global:member', + }; + + eventService.emit('user-invited', event); + + expect(telemetry.track).toHaveBeenCalledWith('User invited new user', { + user_id: 'user123', + target_user_id: ['user456'], + public_api: false, + email_sent: true, + invitee_role: 'global:member', + }); + }); + + it('should track on `user-signed-up` event', () => { + const event: RelayEventMap['user-signed-up'] = { + user: { + id: 'user123', + email: 'user@example.com', + firstName: 'John', + lastName: 'Doe', + role: 'global:owner', + }, + userType: 'email', + wasDisabledLdapUser: false, + }; + + eventService.emit('user-signed-up', event); + + expect(telemetry.track).toHaveBeenCalledWith('User signed up', { + user_id: 'user123', + user_type: 'email', + was_disabled_ldap_user: false, + }); + }); + + it('should track on `user-submitted-personalization-survey` event', () => { + const event: RelayEventMap['user-submitted-personalization-survey'] = { + userId: 'user123', + answers: { + companySize: '1-10', + workArea: 'IT', + automationGoal: 'Improve efficiency', + valueExpectation: 'Time savings', + }, + }; + + eventService.emit('user-submitted-personalization-survey', event); + + expect(telemetry.track).toHaveBeenCalledWith('User responded to personalization questions', { + user_id: 'user123', + company_size: '1-10', + work_area: 'IT', + automation_goal: 'Improve efficiency', + value_expectation: 'Time savings', + }); + }); + + it('should track on `user-changed-role` event', () => { + const event: RelayEventMap['user-changed-role'] = { + userId: 'user123', + targetUserId: 'user456', + targetUserNewRole: 'global:member', + publicApi: false, + }; + + eventService.emit('user-changed-role', event); + + expect(telemetry.track).toHaveBeenCalledWith('User changed role', { + user_id: 'user123', + target_user_id: 'user456', + target_user_new_role: 'global:member', + public_api: false, + }); + }); + + it('should track on `user-retrieved-user` event', () => { + const event: RelayEventMap['user-retrieved-user'] = { + userId: 'user123', + publicApi: false, + }; + + eventService.emit('user-retrieved-user', event); + + expect(telemetry.track).toHaveBeenCalledWith('User retrieved user', { + user_id: 'user123', + public_api: false, + }); + }); + + it('should track on `user-retrieved-all-users` event', () => { + const event: RelayEventMap['user-retrieved-all-users'] = { + userId: 'user123', + publicApi: false, + }; + + eventService.emit('user-retrieved-all-users', event); + + expect(telemetry.track).toHaveBeenCalledWith('User retrieved all users', { + user_id: 'user123', + public_api: false, + }); + }); + }); + + describe('lifecycle events', () => { + it('should track on `server-started` event', async () => { + const firstWorkflow = mock({ createdAt: new Date() }); + workflowRepository.findOne.mockResolvedValue(firstWorkflow); + + eventService.emit('server-started'); + + await flushPromises(); + + // expect(telemetry.identify).toHaveBeenCalled(); + expect(telemetry.track).toHaveBeenCalledWith( + 'Instance started', + expect.objectContaining({ + earliest_workflow_created: firstWorkflow.createdAt, + }), + ); + }); + + it('should track on `session-started` event', () => { + const event: RelayEventMap['session-started'] = { + pushRef: 'ref123', + }; + + eventService.emit('session-started', event); + + expect(telemetry.track).toHaveBeenCalledWith('Session started', { + session_id: 'ref123', + }); + }); + + it('should track on `instance-stopped` event', () => { + eventService.emit('instance-stopped', {}); + + expect(telemetry.track).toHaveBeenCalledWith('User instance stopped'); + }); + + it('should track on `instance-owner-setup` event', () => { + const event: RelayEventMap['instance-owner-setup'] = { + userId: 'user123', + }; + + eventService.emit('instance-owner-setup', event); + + expect(telemetry.track).toHaveBeenCalledWith('Owner finished instance setup', { + user_id: 'user123', + }); + }); + }); + + describe('workflow execution events', () => { + it('should track on `first-production-workflow-succeeded` event', () => { + const event: RelayEventMap['first-production-workflow-succeeded'] = { + projectId: 'project123', + workflowId: 'workflow123', + userId: 'user123', + }; + + eventService.emit('first-production-workflow-succeeded', event); + + expect(telemetry.track).toHaveBeenCalledWith('Workflow first prod success', { + project_id: 'project123', + workflow_id: 'workflow123', + user_id: 'user123', + }); + }); + + it('should track on `first-workflow-data-loaded` event', () => { + const event: RelayEventMap['first-workflow-data-loaded'] = { + userId: 'user123', + workflowId: 'workflow123', + nodeType: 'http', + nodeId: 'node123', + credentialType: 'oAuth2', + credentialId: 'cred123', + }; + + eventService.emit('first-workflow-data-loaded', event); + + expect(telemetry.track).toHaveBeenCalledWith('Workflow first data fetched', { + user_id: 'user123', + workflow_id: 'workflow123', + node_type: 'http', + node_id: 'node123', + credential_type: 'oAuth2', + credential_id: 'cred123', + }); + }); + }); + + describe('email events', () => { + it('should track on `email-failed` event', () => { + const event: RelayEventMap['email-failed'] = { + user: { + id: 'user123', + email: 'user@example.com', + firstName: 'John', + lastName: 'Doe', + role: 'global:owner', + }, + messageType: 'New user invite', + publicApi: false, + }; + + eventService.emit('email-failed', event); + + expect(telemetry.track).toHaveBeenCalledWith( + 'Instance failed to send transactional email to user', + { + user_id: 'user123', + message_type: 'New user invite', + public_api: false, + }, + ); + }); + }); +}); diff --git a/packages/cli/src/events/log-streaming-event-relay.ts b/packages/cli/src/events/log-streaming-event-relay.ts index 85d5a8cb8fb3c..2d79408d4a8c4 100644 --- a/packages/cli/src/events/log-streaming-event-relay.ts +++ b/packages/cli/src/events/log-streaming-event-relay.ts @@ -112,7 +112,7 @@ export class LogStreamingEventRelay extends EventRelay { const payload = { ...rest, - success: runData?.status === 'success', + success: !!runData?.finished, // despite the `success` name, this reports `finished` state isManual: runData?.mode === 'manual', workflowId: workflow.id, workflowName: workflow.name, diff --git a/packages/cli/src/requests.ts b/packages/cli/src/requests.ts index 565020447c91d..581ad920ea82d 100644 --- a/packages/cli/src/requests.ts +++ b/packages/cli/src/requests.ts @@ -25,6 +25,7 @@ import type { Project, ProjectType } from '@db/entities/Project'; import type { ProjectRole } from './databases/entities/ProjectRelation'; import type { Scope } from '@n8n/permissions'; import type { ScopesField } from './services/role.service'; +import type { AiAssistantSDK } from '@n8n_io/ai-assistant-sdk'; export class UserUpdatePayload implements Pick { @Expose() @@ -42,6 +43,11 @@ export class UserUpdatePayload implements Pick; } + +// ---------------------------------- +// /ai-assistant +// ---------------------------------- + +export declare namespace AiAssistantRequest { + type Chat = AuthenticatedRequest<{}, {}, AiAssistantSDK.ChatRequestPayload>; + + type SuggestionPayload = { sessionId: string; suggestionId: string }; + type ApplySuggestion = AuthenticatedRequest<{}, {}, SuggestionPayload>; +} diff --git a/packages/cli/src/services/aiAsisstant.service.ts b/packages/cli/src/services/aiAsisstant.service.ts new file mode 100644 index 0000000000000..39a2d950ef1b6 --- /dev/null +++ b/packages/cli/src/services/aiAsisstant.service.ts @@ -0,0 +1,54 @@ +import { Service } from 'typedi'; +import type { AiAssistantSDK } from '@n8n_io/ai-assistant-sdk'; +import { AiAssistantClient } from '@n8n_io/ai-assistant-sdk'; +import { assert, type IUser } from 'n8n-workflow'; +import { License } from '../License'; +import { N8N_VERSION } from '../constants'; +import config from '@/config'; +import type { AiAssistantRequest } from '@/requests'; +import type { Response } from 'undici'; + +@Service() +export class AiAssistantService { + private client: AiAssistantClient | undefined; + + constructor(private readonly licenseService: License) {} + + async init() { + const aiAssistantEnabled = this.licenseService.isAiAssistantEnabled(); + if (!aiAssistantEnabled) { + return; + } + + const licenseCert = await this.licenseService.loadCertStr(); + const consumerId = this.licenseService.getConsumerId(); + const baseUrl = config.get('aiAssistant.baseUrl'); + const logLevel = config.getEnv('logs.level'); + + this.client = new AiAssistantClient({ + licenseCert, + consumerId, + n8nVersion: N8N_VERSION, + baseUrl, + logLevel, + }); + } + + async chat(payload: AiAssistantSDK.ChatRequestPayload, user: IUser): Promise { + if (!this.client) { + await this.init(); + } + assert(this.client, 'Assistant client not setup'); + + return await this.client.chat(payload, { id: user.id }); + } + + async applySuggestion(payload: AiAssistantRequest.SuggestionPayload, user: IUser) { + if (!this.client) { + await this.init(); + } + assert(this.client, 'Assistant client not setup'); + + return await this.client.applySuggestion(payload, { id: user.id }); + } +} diff --git a/packages/cli/src/services/cache/cache.service.ts b/packages/cli/src/services/cache/cache.service.ts index daf51911ff362..eaa58c32f0887 100644 --- a/packages/cli/src/services/cache/cache.service.ts +++ b/packages/cli/src/services/cache/cache.service.ts @@ -45,7 +45,7 @@ export class CacheService extends TypedEmitter { ); const redisClient = redisClientService.createClient({ - type: 'client(cache)', + type: 'cache(n8n)', extraOptions: { keyPrefix: prefix }, }); diff --git a/packages/cli/src/services/frontend.service.ts b/packages/cli/src/services/frontend.service.ts index fc38eb992e154..de0abdbfbbe37 100644 --- a/packages/cli/src/services/frontend.service.ts +++ b/packages/cli/src/services/frontend.service.ts @@ -160,6 +160,9 @@ export class FrontendService { workflowTagsDisabled: config.getEnv('workflowTagsDisabled'), logLevel: config.getEnv('logs.level'), hiringBannerEnabled: config.getEnv('hiringBanner.enabled'), + aiAssistant: { + enabled: false, + }, templates: { enabled: this.globalConfig.templates.enabled, host: this.globalConfig.templates.host, @@ -279,6 +282,7 @@ export class FrontendService { const isS3Selected = config.getEnv('binaryDataManager.mode') === 's3'; const isS3Available = config.getEnv('binaryDataManager.availableModes').includes('s3'); const isS3Licensed = this.license.isBinaryDataS3Licensed(); + const isAiAssistantEnabled = this.license.isAiAssistantEnabled(); this.settings.license.planName = this.license.getPlanName(); this.settings.license.consumerId = this.license.getConsumerId(); @@ -331,6 +335,10 @@ export class FrontendService { this.settings.missingPackages = this.communityPackagesService.hasMissingPackages; } + if (isAiAssistantEnabled) { + this.settings.aiAssistant.enabled = isAiAssistantEnabled; + } + this.settings.mfa.enabled = config.get('mfa.enabled'); this.settings.executionMode = config.getEnv('executions.mode'); diff --git a/packages/cli/src/services/redis/RedisServiceBaseClasses.ts b/packages/cli/src/services/redis/RedisServiceBaseClasses.ts index ba2cc41a91dce..b9db6125b043c 100644 --- a/packages/cli/src/services/redis/RedisServiceBaseClasses.ts +++ b/packages/cli/src/services/redis/RedisServiceBaseClasses.ts @@ -4,20 +4,7 @@ import { Service } from 'typedi'; import config from '@/config'; import { Logger } from '@/Logger'; import { RedisClientService } from './redis-client.service'; - -export type RedisClientType = - | 'subscriber' - | 'client' - | 'bclient' - | 'subscriber(bull)' - | 'client(bull)' - | 'bclient(bull)' - | 'client(cache)' - | 'publisher' - | 'consumer' - | 'producer' - | 'list-sender' - | 'list-receiver'; +import type { RedisClientType } from './redis.types'; export type RedisServiceMessageHandler = | ((channel: string, message: string) => void) @@ -34,7 +21,7 @@ class RedisServiceBase { private readonly redisClientService: RedisClientService, ) {} - async init(type: RedisClientType = 'client'): Promise { + async init(type: RedisClientType): Promise { if (this.redisClient && this.isInitialized) { return; } @@ -45,6 +32,8 @@ class RedisServiceBase { this.logger.warn('Error with Redis: ', error); } }); + + this.isInitialized = true; } async destroy(): Promise { @@ -60,7 +49,7 @@ class RedisServiceBase { export abstract class RedisServiceBaseSender extends RedisServiceBase { senderId: string; - async init(type: RedisClientType = 'client'): Promise { + async init(type: RedisClientType): Promise { await super.init(type); this.senderId = config.get('redis.queueModeId'); } diff --git a/packages/cli/src/services/redis/RedisServicePubSubPublisher.ts b/packages/cli/src/services/redis/RedisServicePubSubPublisher.ts index ff810ba7c6523..23ca9a5b6935e 100644 --- a/packages/cli/src/services/redis/RedisServicePubSubPublisher.ts +++ b/packages/cli/src/services/redis/RedisServicePubSubPublisher.ts @@ -9,7 +9,7 @@ import { RedisServiceBaseSender } from './RedisServiceBaseClasses'; @Service() export class RedisServicePubSubPublisher extends RedisServiceBaseSender { async init(): Promise { - await super.init('publisher'); + await super.init('publisher(n8n)'); } async publish(channel: string, message: string): Promise { diff --git a/packages/cli/src/services/redis/RedisServicePubSubSubscriber.ts b/packages/cli/src/services/redis/RedisServicePubSubSubscriber.ts index 87518264283b2..144647009f16b 100644 --- a/packages/cli/src/services/redis/RedisServicePubSubSubscriber.ts +++ b/packages/cli/src/services/redis/RedisServicePubSubSubscriber.ts @@ -5,7 +5,7 @@ import { RedisServiceBaseReceiver } from './RedisServiceBaseClasses'; @Service() export class RedisServicePubSubSubscriber extends RedisServiceBaseReceiver { async init(): Promise { - await super.init('subscriber'); + await super.init('subscriber(n8n)'); this.redisClient?.on('message', (channel: string, message: string) => { this.messageHandlers.forEach((handler: (channel: string, message: string) => void) => diff --git a/packages/cli/src/services/redis/redis-client.service.ts b/packages/cli/src/services/redis/redis-client.service.ts index b5c86523e006a..21f74bf074d33 100644 --- a/packages/cli/src/services/redis/redis-client.service.ts +++ b/packages/cli/src/services/redis/redis-client.service.ts @@ -2,7 +2,8 @@ import { Service } from 'typedi'; import { Logger } from '@/Logger'; import ioRedis from 'ioredis'; import type { Cluster, RedisOptions } from 'ioredis'; -import type { RedisClientType } from './RedisServiceBaseClasses'; +import type { RedisClientType } from './redis.types'; + import { OnShutdown } from '@/decorators/OnShutdown'; import { LOWEST_SHUTDOWN_PRIORITY } from '@/constants'; import { GlobalConfig } from '@n8n/config'; diff --git a/packages/cli/src/services/redis/redis.types.ts b/packages/cli/src/services/redis/redis.types.ts new file mode 100644 index 0000000000000..ed694904d7b11 --- /dev/null +++ b/packages/cli/src/services/redis/redis.types.ts @@ -0,0 +1,19 @@ +export type RedisClientType = N8nRedisClientType | BullRedisClientType; + +/** + * Redis client used by n8n. + * + * - `subscriber(n8n)` to listen for messages from scaling mode communication channels + * - `publisher(n8n)` to send messages into scaling mode communication channels + * - `cache(n8n)` for caching operations (variables, resource ownership, etc.) + */ +type N8nRedisClientType = 'subscriber(n8n)' | 'publisher(n8n)' | 'cache(n8n)'; + +/** + * Redis client used internally by Bull. Suffixed with `(bull)` at `ScalingService.setupQueue`. + * + * - `subscriber(bull)` for event listening + * - `client(bull)` for general queue operations + * - `bclient(bull)` for blocking operations when processing jobs + */ +type BullRedisClientType = 'subscriber(bull)' | 'client(bull)' | 'bclient(bull)'; diff --git a/packages/cli/test/integration/credentials/credentials.service.test.ts b/packages/cli/test/integration/credentials/credentials.service.test.ts index 95aea5cbf8b7c..827ec5fdba4dd 100644 --- a/packages/cli/test/integration/credentials/credentials.service.test.ts +++ b/packages/cli/test/integration/credentials/credentials.service.test.ts @@ -7,6 +7,7 @@ import { SharedCredentialsRepository } from '@/databases/repositories/sharedCred import Container from 'typedi'; import { CredentialsService } from '@/credentials/credentials.service'; import * as testDb from '../shared/testDb'; +import { createTeamProject, linkUserToProject } from '@test-integration/db/projects'; const credentialPayload = randomCredentialPayload(); let memberWhoOwnsCredential: User; @@ -42,7 +43,7 @@ describe('credentials service', () => { data: { accessToken: '' }, }; - Container.get(CredentialsService).replaceCredentialContentsForSharee( + await Container.get(CredentialsService).replaceCredentialContentsForSharee( memberWhoDoesNotOwnCredential, storedCredential!, decryptedData, @@ -51,5 +52,68 @@ describe('credentials service', () => { expect(mergedCredentials.data).toEqual({ accessToken: credentialPayload.data.accessToken }); }); + + it('should replace the contents of the credential for project viewer', async () => { + const [project, viewerMember] = await Promise.all([createTeamProject(), createMember()]); + await linkUserToProject(viewerMember, project, 'project:viewer'); + const projectCredential = await saveCredential(credentialPayload, { + project, + role: 'credential:owner', + }); + + const storedProjectCredential = await Container.get( + SharedCredentialsRepository, + ).findCredentialForUser(projectCredential.id, viewerMember, ['credential:read']); + + const decryptedData = Container.get(CredentialsService).decrypt(storedProjectCredential!); + + const mergedCredentials = { + id: projectCredential.id, + name: projectCredential.name, + type: projectCredential.type, + data: { accessToken: '' }, + }; + + await Container.get(CredentialsService).replaceCredentialContentsForSharee( + viewerMember, + storedProjectCredential!, + decryptedData, + mergedCredentials, + ); + + expect(mergedCredentials.data).toEqual({ accessToken: credentialPayload.data.accessToken }); + }); + + it('should not replace the contents of the credential for project editor', async () => { + const [project, editorMember] = await Promise.all([createTeamProject(), createMember()]); + await linkUserToProject(editorMember, project, 'project:editor'); + const projectCredential = await saveCredential(credentialPayload, { + project, + role: 'credential:owner', + }); + + const storedProjectCredential = await Container.get( + SharedCredentialsRepository, + ).findCredentialForUser(projectCredential.id, editorMember, ['credential:read']); + + const decryptedData = Container.get(CredentialsService).decrypt(storedProjectCredential!); + + const originalData = { accessToken: '' }; + const mergedCredentials = { + id: projectCredential.id, + name: projectCredential.name, + type: projectCredential.type, + data: originalData, + }; + + await Container.get(CredentialsService).replaceCredentialContentsForSharee( + editorMember, + storedProjectCredential!, + decryptedData, + mergedCredentials, + ); + + expect(mergedCredentials.data).toBe(originalData); + }); }); }); diff --git a/packages/core/package.json b/packages/core/package.json index 1f857b47e5ea5..24da0bf137b64 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "n8n-core", - "version": "1.54.0", + "version": "1.55.0", "description": "Core functionality of n8n", "main": "dist/index", "types": "dist/index.d.ts", diff --git a/packages/design-system/package.json b/packages/design-system/package.json index 9c04bd801b3af..0de4c11ce9217 100644 --- a/packages/design-system/package.json +++ b/packages/design-system/package.json @@ -1,6 +1,6 @@ { "name": "n8n-design-system", - "version": "1.44.0", + "version": "1.45.0", "main": "src/main.ts", "import": "src/main.ts", "scripts": { @@ -49,6 +49,7 @@ "markdown-it-emoji": "^2.0.2", "markdown-it-link-attributes": "^4.0.1", "markdown-it-task-lists": "^2.1.1", + "parse-diff": "^0.11.1", "sanitize-html": "2.12.1", "vue": "catalog:frontend", "vue-boring-avatars": "^1.3.0", diff --git a/packages/design-system/src/components/AskAssistantAvatar/AssistantAvatar.stories.ts b/packages/design-system/src/components/AskAssistantAvatar/AssistantAvatar.stories.ts new file mode 100644 index 0000000000000..8bf85cca2c50e --- /dev/null +++ b/packages/design-system/src/components/AskAssistantAvatar/AssistantAvatar.stories.ts @@ -0,0 +1,25 @@ +import AssistantAvatar from './AssistantAvatar.vue'; +import type { StoryFn } from '@storybook/vue3'; + +export default { + title: 'Assistant/AssistantAvatar', + component: AssistantAvatar, + argTypes: {}, +}; + +const Template: StoryFn = (args, { argTypes }) => ({ + setup: () => ({ args }), + props: Object.keys(argTypes), + components: { + AssistantAvatar, + }, + template: '', +}); + +export const Default = Template.bind({}); +Default.args = {}; + +export const Mini = Template.bind({}); +Mini.args = { + size: 'mini', +}; diff --git a/packages/design-system/src/components/AskAssistantAvatar/AssistantAvatar.vue b/packages/design-system/src/components/AskAssistantAvatar/AssistantAvatar.vue new file mode 100644 index 0000000000000..9df650ce504f7 --- /dev/null +++ b/packages/design-system/src/components/AskAssistantAvatar/AssistantAvatar.vue @@ -0,0 +1,33 @@ + + + + + diff --git a/packages/design-system/src/components/AskAssistantButton/AskAssistantButton.stories.ts b/packages/design-system/src/components/AskAssistantButton/AskAssistantButton.stories.ts new file mode 100644 index 0000000000000..6d41905aed209 --- /dev/null +++ b/packages/design-system/src/components/AskAssistantButton/AskAssistantButton.stories.ts @@ -0,0 +1,31 @@ +import AskAssistantButton from './AskAssistantButton.vue'; +import { action } from '@storybook/addon-actions'; +import type { StoryFn } from '@storybook/vue3'; + +export default { + title: 'Assistant/AskAssistantButton', + component: AskAssistantButton, + argTypes: {}, +}; + +const methods = { + onClick: action('click'), +}; + +const Template: StoryFn = (args, { argTypes }) => ({ + setup: () => ({ args }), + props: Object.keys(argTypes), + components: { + AskAssistantButton, + }, + template: + '
', + methods, +}); + +export const Button = Template.bind({}); + +export const Notifications = Template.bind({}); +Notifications.args = { + unreadCount: 1, +}; diff --git a/packages/design-system/src/components/AskAssistantButton/AskAssistantButton.vue b/packages/design-system/src/components/AskAssistantButton/AskAssistantButton.vue new file mode 100644 index 0000000000000..5cb1824699db4 --- /dev/null +++ b/packages/design-system/src/components/AskAssistantButton/AskAssistantButton.vue @@ -0,0 +1,95 @@ + + + + + diff --git a/packages/design-system/src/components/AskAssistantChat/AskAssistantChat.stories.ts b/packages/design-system/src/components/AskAssistantChat/AskAssistantChat.stories.ts new file mode 100644 index 0000000000000..ec44622844afb --- /dev/null +++ b/packages/design-system/src/components/AskAssistantChat/AskAssistantChat.stories.ts @@ -0,0 +1,240 @@ +import AskAssistantChat from './AskAssistantChat.vue'; +import type { StoryFn } from '@storybook/vue3'; +import type { ChatUI } from '../../types/assistant'; + +export default { + title: 'Assistant/AskAssistantChat', + component: AskAssistantChat, + argTypes: {}, +}; + +function getMessages(messages: ChatUI.AssistantMessage[]): ChatUI.AssistantMessage[] { + return messages; +} + +const methods = {}; + +const Template: StoryFn = (args, { argTypes }) => ({ + setup: () => ({ args }), + props: Object.keys(argTypes), + components: { + AskAssistantChat, + }, + template: '
', + methods, +}); + +export const DefaultPlaceholderChat = Template.bind({}); +DefaultPlaceholderChat.args = { + user: { + firstName: 'Max', + lastName: 'Test', + }, +}; + +export const Chat = Template.bind({}); +Chat.args = { + user: { + firstName: 'Max', + lastName: 'Test', + }, + messages: getMessages([ + { + id: '1', + type: 'text', + role: 'assistant', + content: 'Hi Max! Here is my top solution to fix the error in your **Transform data** node👇', + read: false, + }, + { + id: '1', + type: 'code-diff', + role: 'assistant', + description: 'Short solution description here that can spill over to two lines', + codeDiff: + '@@ -1,7 +1,6 @@\n-The Way that can be told of is not the eternal Way;\n-The name that can be named is not the eternal name.\nThe Nameless is the origin of Heaven and Earth;\n-The Named is the mother of all things.\n+The named is the mother of all things.\n+\nTherefore let there always be non-being,\nso we may see their subtlety,\nAnd let there always be being,\n@@ -9,3 +8,6 @@\n The two are the same,\n But after they are produced,\n they have different names.\n+They both may be called deep and profound.\n+Deeper and more profound,\n+The door of all subtleties!', + suggestionId: 'test', + quickReplies: [ + { + type: 'new-suggestion', + text: 'Give me another solution', + }, + { + type: 'resolved', + text: 'All good', + }, + ], + read: false, + }, + { + id: '2', + type: 'text', + role: 'user', + content: 'Give it to me **ignore this markdown**', + read: false, + }, + { + id: '2', + type: 'block', + role: 'assistant', + title: 'Credential doesn’t have correct permissions to send a message', + content: + 'Solution steps:\n1. Lorem ipsum dolor sit amet, consectetur **adipiscing** elit. Proin id nulla placerat, tristique ex at, euismod dui.\n2. Copy this into somewhere\n3. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Proin id nulla placerat, tristique ex at, euismod dui.\n4. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Proin id nulla placerat, tristique ex at, euismod dui. \n Testing more code \n - Unordered item 1 \n - Unordered item 2', + read: false, + }, + { + id: '2', + type: 'code-diff', + role: 'assistant', + description: 'Short solution with min height', + codeDiff: + '@@ -1,7 +1,6 @@\n-The Way that can be told of is not the eternal Way;\n-The name that can be named is not the eternal name.\n+The door of all subtleties!', + quickReplies: [ + { + type: 'new-suggestion', + text: 'Give me another solution', + }, + { + type: 'resolved', + text: 'All good', + }, + ], + suggestionId: 'test', + read: false, + }, + ]), +}; + +export const JustSummary = Template.bind({}); +JustSummary.args = { + user: { + firstName: 'Max', + lastName: 'Test', + }, + messages: getMessages([ + { + id: '123', + role: 'assistant', + type: 'block', + title: 'Credential doesn’t have correct permissions to send a message', + content: + 'Solution steps:\n1. Lorem ipsum dolor sit amet, consectetur **adipiscing** elit. Proin id nulla placerat, tristique ex at, euismod dui.\n2. Copy this into somewhere\n3. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Proin id nulla placerat, tristique ex at, euismod dui.\n4. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Proin id nulla placerat, tristique ex at, euismod dui. \n Testing more code \n - Unordered item 1 \n - Unordered item 2', + read: false, + }, + ]), +}; + +export const SummaryTitleStreaming = Template.bind({}); +SummaryTitleStreaming.args = { + user: { + firstName: 'Max', + lastName: 'Test', + }, + messages: getMessages([ + { + id: '123', + role: 'assistant', + type: 'block', + title: 'Credential doesn’t have', + content: '', + read: false, + }, + ]), + streaming: true, +}; + +export const SummaryContentStreaming = Template.bind({}); +SummaryContentStreaming.args = { + user: { + firstName: 'Max', + lastName: 'Test', + }, + messages: getMessages([ + { + id: '123', + role: 'assistant', + type: 'block', + title: 'Credential doesn’t have correct permissions to send a message', + content: 'Solution steps:\n1. Lorem ipsum dolor sit amet, consectetur', + read: false, + }, + ]), + streaming: true, +}; + +export const ErrorChat = Template.bind({}); +ErrorChat.args = { + user: { + firstName: 'Max', + lastName: 'Test', + }, + messages: getMessages([ + { + id: '123', + role: 'assistant', + type: 'error', + content: 'There was an error reaching the service', + read: false, + }, + ]), +}; + +export const EmptyStreamingChat = Template.bind({}); +EmptyStreamingChat.args = { + user: { + firstName: 'Max', + lastName: 'Test', + }, + messages: getMessages([ + { + id: '123', + type: 'text', + role: 'assistant', + content: '', + read: false, + }, + ]), + streaming: true, +}; + +export const StreamingChat = Template.bind({}); +StreamingChat.args = { + user: { + firstName: 'Max', + lastName: 'Test', + }, + messages: getMessages([ + { + id: '123', + type: 'text', + role: 'assistant', + content: 'I am thinking through this problem', + read: false, + }, + ]), + streaming: true, +}; + +export const EndOfSessionChat = Template.bind({}); +EndOfSessionChat.args = { + user: { + firstName: 'Max', + lastName: 'Test', + }, + messages: getMessages([ + { + id: '123', + type: 'text', + role: 'assistant', + content: "Great, glad I could help! I'm here whenever you need more help.", + read: false, + }, + { + id: '123', + role: 'assistant', + type: 'event', + eventName: 'end-session', + read: false, + }, + ]), +}; diff --git a/packages/design-system/src/components/AskAssistantChat/AskAssistantChat.vue b/packages/design-system/src/components/AskAssistantChat/AskAssistantChat.vue new file mode 100644 index 0000000000000..30bdf81351334 --- /dev/null +++ b/packages/design-system/src/components/AskAssistantChat/AskAssistantChat.vue @@ -0,0 +1,471 @@ + + +