diff --git a/.github/scripts/package.json b/.github/scripts/package.json index cd67711fa4c29..80bf0baf11043 100644 --- a/.github/scripts/package.json +++ b/.github/scripts/package.json @@ -5,7 +5,7 @@ "debug": "4.3.4", "glob": "10.3.10", "p-limit": "3.1.0", - "picocolors": "1.0.0", + "picocolors": "1.0.1", "semver": "7.5.4", "tempfile": "5.0.0", "typescript": "*" diff --git a/.github/workflows/test-workflows.yml b/.github/workflows/test-workflows.yml index 8ba22b590cb62..2bb91dd065eb7 100644 --- a/.github/workflows/test-workflows.yml +++ b/.github/workflows/test-workflows.yml @@ -73,6 +73,7 @@ jobs: env: N8N_ENCRYPTION_KEY: ${{secrets.ENCRYPTION_KEY}} SKIP_STATISTICS_EVENTS: true + DB_SQLITE_POOL_SIZE: 4 # - # name: Export credentials # if: always() diff --git a/.github/workflows/units-tests-reusable.yml b/.github/workflows/units-tests-reusable.yml index fe270b1de004c..1f64b2e27c6c3 100644 --- a/.github/workflows/units-tests-reusable.yml +++ b/.github/workflows/units-tests-reusable.yml @@ -50,6 +50,7 @@ jobs: run: pnpm install --frozen-lockfile - name: Setup build cache + if: inputs.collectCoverage != true uses: rharkor/caching-for-turbo@v1.5 - name: Build diff --git a/CHANGELOG.md b/CHANGELOG.md index 986b85b0682ea..99120642fe416 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,44 @@ +# [1.54.0](https://github.com/n8n-io/n8n/compare/n8n@1.53.0...n8n@1.54.0) (2024-08-07) + + +### Bug Fixes + +* **core:** Ensure OAuth token data is not stubbed in source control ([#10302](https://github.com/n8n-io/n8n/issues/10302)) ([98115e9](https://github.com/n8n-io/n8n/commit/98115e95df8289a8ec400a570a7f256382f8e286)) +* **core:** Fix expressions in webhook nodes(Form, Webhook) to access previous node's data ([#10247](https://github.com/n8n-io/n8n/issues/10247)) ([88a1701](https://github.com/n8n-io/n8n/commit/88a170176a3447e7f847e9cf145aeb867b1c5fcf)) +* **core:** Fix user telemetry bugs ([#10293](https://github.com/n8n-io/n8n/issues/10293)) ([42a0b59](https://github.com/n8n-io/n8n/commit/42a0b594d6ea2527c55a2aa9976c904cf70ecf92)) +* **core:** Make execution and its data creation atomic ([#10276](https://github.com/n8n-io/n8n/issues/10276)) ([ae50bb9](https://github.com/n8n-io/n8n/commit/ae50bb95a8e5bf1cdbf9483da54b84094b82e260)) +* **core:** Make OAuth1/OAuth2 callback not require auth ([#10263](https://github.com/n8n-io/n8n/issues/10263)) ([a8e2774](https://github.com/n8n-io/n8n/commit/a8e2774f5382e202556b5506c7788265786aa973)) +* **core:** Revert transactions until we remove the legacy sqlite driver ([#10299](https://github.com/n8n-io/n8n/issues/10299)) ([1eba7c3](https://github.com/n8n-io/n8n/commit/1eba7c3c763ac5b6b28c1c6fc43fc8c215249292)) +* **core:** Surface enterprise trial error message ([#10267](https://github.com/n8n-io/n8n/issues/10267)) ([432ac1d](https://github.com/n8n-io/n8n/commit/432ac1da59e173ce4c0f2abbc416743d9953ba70)) +* **core:** Upgrade tournament to address some XSS vulnerabilities ([#10277](https://github.com/n8n-io/n8n/issues/10277)) ([43ae159](https://github.com/n8n-io/n8n/commit/43ae159ea40c574f8e41bdfd221ab2bf3268eee7)) +* **core:** VM2 sandbox should not throw on `new Promise` ([#10298](https://github.com/n8n-io/n8n/issues/10298)) ([7e95f9e](https://github.com/n8n-io/n8n/commit/7e95f9e2e40a99871f1b6abcdacb39ac5f857332)) +* **core:** Webhook and form baseUrl missing ([#10290](https://github.com/n8n-io/n8n/issues/10290)) ([8131d66](https://github.com/n8n-io/n8n/commit/8131d66f8ca1b1da00597a12859ee4372148a0c9)) +* **editor:** Enable moving resources only if team projects are available by the license ([#10271](https://github.com/n8n-io/n8n/issues/10271)) ([42ba884](https://github.com/n8n-io/n8n/commit/42ba8841c401126c77158a53dc8fcbb45dfce8fd)) +* **editor:** Fix execution retry button ([#10275](https://github.com/n8n-io/n8n/issues/10275)) ([55f2ffe](https://github.com/n8n-io/n8n/commit/55f2ffe256c91a028cee95c3bbb37a093a1c0f81)) +* **editor:** Update design system Avatar component to show initials also when only firstName or lastName is given ([#10308](https://github.com/n8n-io/n8n/issues/10308)) ([46bbf09](https://github.com/n8n-io/n8n/commit/46bbf09beacad12472d91786b91d845fe2afb26d)) +* **editor:** Update tags filter/editor to not show non existing tag as a selectable option ([#10297](https://github.com/n8n-io/n8n/issues/10297)) ([557a76e](https://github.com/n8n-io/n8n/commit/557a76ec2326de72fb7a8b46fc4353f8fd9b591d)) +* **Invoice Ninja Node:** Fix payment types ([#10196](https://github.com/n8n-io/n8n/issues/10196)) ([c5acbb7](https://github.com/n8n-io/n8n/commit/c5acbb7ec0d24ec9b30c221fa3b2fb615fb9ec7f)) +* Loop node no input data shown ([#10224](https://github.com/n8n-io/n8n/issues/10224)) ([c8ee852](https://github.com/n8n-io/n8n/commit/c8ee852159207be0cfe2c3e0ee8e7b29d838aa35)) + + +### Features + +* **core:** Allow filtering executions and users by project in Public API ([#10250](https://github.com/n8n-io/n8n/issues/10250)) ([7056e50](https://github.com/n8n-io/n8n/commit/7056e50b006bda665f64ce6234c5c1967891c415)) +* **core:** Allow transferring credentials in Public API ([#10259](https://github.com/n8n-io/n8n/issues/10259)) ([07d7b24](https://github.com/n8n-io/n8n/commit/07d7b247f02a9d7185beca7817deb779a3d665dd)) +* **core:** Show sub-node error on the logs pane. Open logs pane on sub-node error ([#10248](https://github.com/n8n-io/n8n/issues/10248)) ([57d1c9a](https://github.com/n8n-io/n8n/commit/57d1c9a99e97308f2f1b8ae05ac3861a835e8e5a)) +* **core:** Support community packages in scaling-mode ([#10228](https://github.com/n8n-io/n8n/issues/10228)) ([88086a4](https://github.com/n8n-io/n8n/commit/88086a41ff5b804b35aa9d9503dc2d48836fe4ec)) +* **core:** Support create, delete, edit role for users in Public API ([#10279](https://github.com/n8n-io/n8n/issues/10279)) ([84efbd9](https://github.com/n8n-io/n8n/commit/84efbd9b9c51f536b21a4f969ab607d277bef692)) +* **core:** Support create, read, update, delete projects in Public API ([#10269](https://github.com/n8n-io/n8n/issues/10269)) ([489ce10](https://github.com/n8n-io/n8n/commit/489ce100634c3af678fb300e9a39d273042542e6)) +* **editor:** Auto-add LLM chain for new LLM nodes on empty canvas ([#10245](https://github.com/n8n-io/n8n/issues/10245)) ([06419d9](https://github.com/n8n-io/n8n/commit/06419d9483ae916e79aace6d8c17e265b419b15d)) +* **Elasticsearch Node:** Add bulk operations for Elasticsearch ([#9940](https://github.com/n8n-io/n8n/issues/9940)) ([bf8f848](https://github.com/n8n-io/n8n/commit/bf8f848645dfd31527713a55bd1fc93865327017)) +* **Lemlist Trigger Node:** Update Trigger events ([#10311](https://github.com/n8n-io/n8n/issues/10311)) ([15f10ec](https://github.com/n8n-io/n8n/commit/15f10ec325cb5eda0f952bed3a5f171dd91bc639)) +* **MongoDB Node:** Add projection to query options on Find ([#9972](https://github.com/n8n-io/n8n/issues/9972)) ([0a84e0d](https://github.com/n8n-io/n8n/commit/0a84e0d8b047669f5cf023c21383d01c929c5b4f)) +* **Postgres Chat Memory, Redis Chat Memory, Xata:** Add support for context window length ([#10203](https://github.com/n8n-io/n8n/issues/10203)) ([e3edeaa](https://github.com/n8n-io/n8n/commit/e3edeaa03526f041d15d1099ea91869e38a0decc)) +* **Stripe Trigger Node:** Add Stripe webhook descriptions based on the workflow ID and name ([#9956](https://github.com/n8n-io/n8n/issues/9956)) ([3433465](https://github.com/n8n-io/n8n/commit/34334651e0e6874736a437a894176bed4590e5a7)) +* **Webflow Node:** Update to use the v2 API ([#9996](https://github.com/n8n-io/n8n/issues/9996)) ([6d8323f](https://github.com/n8n-io/n8n/commit/6d8323fadea8af04483eb1a873df0cf3ccc2a891)) + + + # [1.53.0](https://github.com/n8n-io/n8n/compare/n8n@1.52.0...n8n@1.53.0) (2024-07-31) diff --git a/README.md b/README.md index 145ecab8c62d6..d51ac596cad65 100644 --- a/README.md +++ b/README.md @@ -95,8 +95,8 @@ development environment ready in minutes. ## License n8n is [fair-code](https://faircode.io) distributed under the -[**Sustainable Use License**](https://github.com/n8n-io/n8n/blob/master/packages/cli/LICENSE.md) and the -[**n8n Enterprise License**](https://github.com/n8n-io/n8n/blob/master/packages/cli/LICENSE_EE.md). +[**Sustainable Use License**](https://github.com/n8n-io/n8n/blob/master/LICENSE.md) and the +[**n8n Enterprise License**](https://github.com/n8n-io/n8n/blob/master/LICENSE_EE.md). Proprietary licenses are available for enterprise customers. [Get in touch](mailto:license@n8n.io) diff --git a/cypress/composables/projects.ts b/cypress/composables/projects.ts index 6fa2c6f502a3e..84379088d1a39 100644 --- a/cypress/composables/projects.ts +++ b/cypress/composables/projects.ts @@ -1,41 +1,45 @@ import { CredentialsModal, WorkflowPage } from '../pages'; +import { getVisibleSelect } from '../utils'; const workflowPage = new WorkflowPage(); const credentialsModal = new CredentialsModal(); export const getHomeButton = () => cy.getByTestId('project-home-menu-item'); export const getMenuItems = () => cy.getByTestId('project-menu-item'); -export const getAddProjectButton = () => cy.getByTestId('add-project-menu-item'); +export const getAddProjectButton = () => + cy.getByTestId('add-project-menu-item').should('contain', 'Add project').should('be.visible'); export const getProjectTabs = () => cy.getByTestId('project-tabs').find('a'); export const getProjectTabWorkflows = () => getProjectTabs().filter('a[href$="/workflows"]'); export const getProjectTabCredentials = () => getProjectTabs().filter('a[href$="/credentials"]'); export const getProjectTabSettings = () => getProjectTabs().filter('a[href$="/settings"]'); -export const getProjectSettingsNameInput = () => cy.getByTestId('project-settings-name-input'); +export const getProjectSettingsNameInput = () => + cy.getByTestId('project-settings-name-input').find('input'); export const getProjectSettingsSaveButton = () => cy.getByTestId('project-settings-save-button'); export const getProjectSettingsCancelButton = () => cy.getByTestId('project-settings-cancel-button'); export const getProjectSettingsDeleteButton = () => cy.getByTestId('project-settings-delete-button'); export const getProjectMembersSelect = () => cy.getByTestId('project-members-select'); -export const addProjectMember = (email: string) => { +export const addProjectMember = (email: string, role?: string) => { getProjectMembersSelect().click(); getProjectMembersSelect().get('.el-select-dropdown__item').contains(email.toLowerCase()).click(); + + if (role) { + cy.getByTestId(`user-list-item-${email}`) + .find('[data-test-id="projects-settings-user-role-select"]') + .click(); + getVisibleSelect().find('li').contains(role).click(); + } }; -export const getProjectNameInput = () => cy.get('#projectName').find('input'); export const getResourceMoveModal = () => cy.getByTestId('project-move-resource-modal'); export const getResourceMoveConfirmModal = () => cy.getByTestId('project-move-resource-confirm-modal'); export const getProjectMoveSelect = () => cy.getByTestId('project-move-resource-modal-select'); export function createProject(name: string) { - getAddProjectButton().should('be.visible').click(); + getAddProjectButton().click(); - getProjectNameInput() - .should('be.visible') - .should('be.focused') - .should('have.value', 'My project') - .clear() - .type(name); + getProjectSettingsNameInput().should('be.visible').clear().type(name); getProjectSettingsSaveButton().click(); } @@ -46,7 +50,7 @@ export function createWorkflow(fixtureKey: string, name: string) { workflowPage.actions.zoomToFit(); } -export function createCredential(name: string) { +export function createCredential(name: string, closeModal = true) { credentialsModal.getters.newCredentialModal().should('be.visible'); credentialsModal.getters.newCredentialTypeSelect().should('be.visible'); credentialsModal.getters.newCredentialTypeOption('Notion API').click(); @@ -54,13 +58,8 @@ export function createCredential(name: string) { credentialsModal.getters.connectionParameter('Internal Integration Secret').type('1234567890'); credentialsModal.actions.setName(name); credentialsModal.actions.save(); - credentialsModal.actions.close(); -} -export const actions = { - createProject: (name: string) => { - getAddProjectButton().click(); - getProjectSettingsNameInput().type(name); - getProjectSettingsSaveButton().click(); - }, -}; + if (closeModal) { + credentialsModal.actions.close(); + } +} diff --git a/cypress/constants.ts b/cypress/constants.ts index 8439952ac7b81..6f7e7b978d317 100644 --- a/cypress/constants.ts +++ b/cypress/constants.ts @@ -37,6 +37,7 @@ export const INSTANCE_MEMBERS = [ export const MANUAL_TRIGGER_NODE_NAME = 'Manual Trigger'; export const MANUAL_TRIGGER_NODE_DISPLAY_NAME = 'When clicking ‘Test workflow’'; export const MANUAL_CHAT_TRIGGER_NODE_NAME = 'Chat Trigger'; +export const CHAT_TRIGGER_NODE_DISPLAY_NAME = 'When chat message received'; export const SCHEDULE_TRIGGER_NODE_NAME = 'Schedule Trigger'; export const CODE_NODE_NAME = 'Code'; export const SET_NODE_NAME = 'Set'; @@ -57,6 +58,7 @@ export const AI_TOOL_CODE_NODE_NAME = 'Code Tool'; export const AI_TOOL_WIKIPEDIA_NODE_NAME = 'Wikipedia'; export const AI_TOOL_HTTP_NODE_NAME = 'HTTP Request Tool'; export const AI_LANGUAGE_MODEL_OPENAI_CHAT_MODEL_NODE_NAME = 'OpenAI Chat Model'; +export const AI_MEMORY_POSTGRES_NODE_NAME = 'Postgres Chat Memory'; export const AI_OUTPUT_PARSER_AUTO_FIXING_NODE_NAME = 'Auto-fixing Output Parser'; export const WEBHOOK_NODE_NAME = 'Webhook'; diff --git a/cypress/e2e/12-canvas-actions.cy.ts b/cypress/e2e/12-canvas-actions.cy.ts index 9b05cb84d4130..53dad1cc89669 100644 --- a/cypress/e2e/12-canvas-actions.cy.ts +++ b/cypress/e2e/12-canvas-actions.cy.ts @@ -145,7 +145,16 @@ describe('Canvas Actions', () => { }); }); - it('should delete connections by pressing the delete button', () => { + it('should delete node by pressing keyboard backspace', () => { + WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME); + WorkflowPage.getters.canvasNodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click(); + WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME); + WorkflowPage.getters.canvasNodeByName(CODE_NODE_NAME).click(); + cy.get('body').type('{backspace}'); + WorkflowPage.getters.nodeConnections().should('have.length', 0); + }); + + it('should delete connections by clicking on the delete button', () => { WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME); WorkflowPage.getters.canvasNodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click(); WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME); diff --git a/cypress/e2e/14-mapping.cy.ts b/cypress/e2e/14-mapping.cy.ts index 2a33aee5c0ee1..2e405e69e88c4 100644 --- a/cypress/e2e/14-mapping.cy.ts +++ b/cypress/e2e/14-mapping.cy.ts @@ -44,7 +44,7 @@ describe('Data mapping', () => { ndv.actions.mapDataFromHeader(2, 'value'); ndv.getters .inlineExpressionEditorInput() - .should('have.text', "{{ $json.timestamp }} {{ $json['Readable date'] }}"); + .should('have.text', "{{ $json['Readable date'] }}{{ $json.timestamp }}"); }); it('maps expressions from table json, and resolves value based on hover', () => { @@ -145,8 +145,8 @@ describe('Data mapping', () => { ndv.actions.mapToParameter('value'); ndv.getters .inlineExpressionEditorInput() - .should('have.text', '{{ $json.input[0].count }} {{ $json.input }}'); - ndv.actions.validateExpressionPreview('value', '0 [object Object]'); + .should('have.text', '{{ $json.input }}{{ $json.input[0].count }}'); + ndv.actions.validateExpressionPreview('value', '[object Object]0'); }); it('maps expressions from schema view', () => { @@ -170,8 +170,8 @@ describe('Data mapping', () => { ndv.actions.mapToParameter('value'); ndv.getters .inlineExpressionEditorInput() - .should('have.text', '{{ $json.input[0].count }} {{ $json.input }}'); - ndv.actions.validateExpressionPreview('value', '0 [object Object]'); + .should('have.text', '{{ $json.input }}{{ $json.input[0].count }}'); + ndv.actions.validateExpressionPreview('value', '[object Object]0'); }); it('maps expressions from previous nodes', () => { @@ -200,17 +200,17 @@ describe('Data mapping', () => { .inlineExpressionEditorInput() .should( 'have.text', - `{{ $('${SCHEDULE_TRIGGER_NODE_NAME}').item.json.input[0].count }} {{ $('${SCHEDULE_TRIGGER_NODE_NAME}').item.json.input }}`, + `{{ $('${SCHEDULE_TRIGGER_NODE_NAME}').item.json.input }}{{ $('${SCHEDULE_TRIGGER_NODE_NAME}').item.json.input[0].count }}`, ); ndv.actions.selectInputNode('Set'); ndv.getters.executingLoader().should('not.exist'); ndv.getters.inputDataContainer().should('exist'); - ndv.actions.validateExpressionPreview('value', '0 [object Object]'); + ndv.actions.validateExpressionPreview('value', '[object Object]0'); ndv.getters.inputTbodyCell(2, 0).realHover(); - ndv.actions.validateExpressionPreview('value', '1 [object Object]'); + ndv.actions.validateExpressionPreview('value', '[object Object]1'); }); it('maps keys to path', () => { @@ -284,8 +284,8 @@ describe('Data mapping', () => { ndv.actions.mapToParameter('value'); ndv.getters .inlineExpressionEditorInput() - .should('have.text', '{{ $json.input[0].count }} {{ $json.input }}'); - ndv.actions.validateExpressionPreview('value', '0 [object Object]'); + .should('have.text', '{{ $json.input }}{{ $json.input[0].count }}'); + ndv.actions.validateExpressionPreview('value', '[object Object]0'); }); it('renders expression preview when a previous node is selected', () => { @@ -342,4 +342,27 @@ describe('Data mapping', () => { .invoke('css', 'border') .should('include', 'dashed rgb(90, 76, 194)'); }); + + it('maps expressions to a specific location in the editor', () => { + cy.fixture('Test_workflow_3.json').then((data) => { + cy.get('body').paste(JSON.stringify(data)); + }); + workflowPage.actions.zoomToFit(); + + workflowPage.actions.openNode('Set'); + ndv.actions.clearParameterInput('value'); + ndv.actions.typeIntoParameterInput('value', '='); + ndv.actions.typeIntoParameterInput('value', 'hello world{enter}{enter}newline'); + + ndv.getters.inputDataContainer().should('exist').find('span').contains('count').realMouseDown(); + + ndv.actions.mapToParameter('value'); + + ndv.getters.inputDataContainer().find('span').contains('input').realMouseDown(); + ndv.actions.mapToParameter('value', 'bottom'); + + ndv.getters + .inlineExpressionEditorInput() + .should('have.text', '{{ $json.input[0].count }}hello worldnewline{{ $json.input }}'); + }); }); diff --git a/cypress/e2e/17-sharing.cy.ts b/cypress/e2e/17-sharing.cy.ts index 54c5e6efe202c..64769ae1935b8 100644 --- a/cypress/e2e/17-sharing.cy.ts +++ b/cypress/e2e/17-sharing.cy.ts @@ -7,7 +7,7 @@ import { WorkflowSharingModal, WorkflowsPage, } from '../pages'; -import { getVisibleSelect } from '../utils'; +import { getVisibleDropdown, getVisibleSelect } from '../utils'; import * as projects from '../composables/projects'; /** @@ -192,11 +192,79 @@ describe('Sharing', { disableAutoLogin: true }, () => { credentialsModal.actions.saveSharing(); credentialsModal.actions.close(); }); + + it('credentials should work between team and personal projects', () => { + cy.resetDatabase(); + cy.enableFeature('sharing'); + cy.enableFeature('advancedPermissions'); + cy.enableFeature('projectRole:admin'); + cy.enableFeature('projectRole:editor'); + cy.changeQuota('maxTeamProjects', -1); + + cy.signinAsOwner(); + cy.visit('/'); + + projects.createProject('Development'); + + projects.getHomeButton().click(); + workflowsPage.getters.newWorkflowButtonCard().click(); + projects.createWorkflow('Test_workflow_1.json', 'Test workflow'); + + projects.getHomeButton().click(); + projects.getProjectTabCredentials().click(); + credentialsPage.getters.emptyListCreateCredentialButton().click(); + projects.createCredential('Notion API'); + + credentialsPage.getters.credentialCard('Notion API').click(); + credentialsModal.actions.changeTab('Sharing'); + credentialsModal.getters.usersSelect().click(); + getVisibleSelect() + .find('li') + .should('have.length', 4) + .filter(':contains("Development")') + .should('have.length', 1) + .click(); + credentialsModal.getters.saveButton().click(); + credentialsModal.actions.close(); + + projects.getProjectTabWorkflows().click(); + workflowsPage.getters.workflowCardActions('Test workflow').click(); + getVisibleDropdown().find('li').contains('Share').click(); + + workflowSharingModal.getters.usersSelect().filter(':visible').click(); + getVisibleSelect().find('li').should('have.length', 3).first().click(); + workflowSharingModal.getters.saveButton().click(); + + projects.getMenuItems().first().click(); + workflowsPage.getters.newWorkflowButtonCard().click(); + projects.createWorkflow('Test_workflow_1.json', 'Test workflow 2'); + workflowPage.actions.openShareModal(); + workflowSharingModal.getters.usersSelect().should('not.exist'); + + cy.get('body').type('{esc}'); + + projects.getMenuItems().first().click(); + projects.getProjectTabCredentials().click(); + credentialsPage.getters.createCredentialButton().click(); + projects.createCredential('Notion API 2', false); + credentialsModal.actions.changeTab('Sharing'); + credentialsModal.getters.usersSelect().click(); + getVisibleSelect().find('li').should('have.length', 4).first().click(); + credentialsModal.getters.saveButton().click(); + credentialsModal.actions.close(); + + credentialsPage.getters + .credentialCards() + .should('have.length', 2) + .filter(':contains("Owned by me")') + .should('have.length', 1); + }); }); describe('Credential Usage in Cross Shared Workflows', () => { beforeEach(() => { cy.resetDatabase(); + cy.enableFeature('sharing'); cy.enableFeature('advancedPermissions'); cy.enableFeature('projectRole:admin'); cy.enableFeature('projectRole:editor'); @@ -207,23 +275,18 @@ describe('Credential Usage in Cross Shared Workflows', () => { }); it('should only show credentials from the same team project', () => { - cy.enableFeature('advancedPermissions'); - cy.enableFeature('projectRole:admin'); - cy.enableFeature('projectRole:editor'); - cy.changeQuota('maxTeamProjects', -1); - // Create a notion credential in the home project credentialsPage.getters.emptyListCreateCredentialButton().click(); credentialsModal.actions.createNewCredential('Notion API'); // Create a notion credential in one project - projects.actions.createProject('Development'); + projects.createProject('Development'); projects.getProjectTabCredentials().click(); credentialsPage.getters.emptyListCreateCredentialButton().click(); credentialsModal.actions.createNewCredential('Notion API'); // Create a notion credential in another project - projects.actions.createProject('Test'); + projects.createProject('Test'); projects.getProjectTabCredentials().click(); credentialsPage.getters.emptyListCreateCredentialButton().click(); credentialsModal.actions.createNewCredential('Notion API'); @@ -238,10 +301,36 @@ describe('Credential Usage in Cross Shared Workflows', () => { getVisibleSelect().find('li').should('have.length', 2); }); + it('should only show credentials in their personal project for members', () => { + // Create a notion credential as the owner + credentialsPage.getters.emptyListCreateCredentialButton().click(); + credentialsModal.actions.createNewCredential('Notion API'); + + // Create another notion credential as the owner, but share it with member + // 0 + credentialsPage.getters.createCredentialButton().click(); + credentialsModal.actions.createNewCredential('Notion API', false); + credentialsModal.actions.changeTab('Sharing'); + credentialsModal.actions.addUser(INSTANCE_MEMBERS[0].email); + credentialsModal.actions.saveSharing(); + + // As the member, create a new notion credential and a workflow + cy.signinAsMember(); + cy.visit(credentialsPage.url); + credentialsPage.getters.createCredentialButton().click(); + credentialsModal.actions.createNewCredential('Notion API'); + cy.visit(workflowsPage.url); + workflowsPage.actions.createWorkflowFromCard(); + workflowPage.actions.addNodeToCanvas(NOTION_NODE_NAME, true, true); + + // Only the own credential the shared one (+ the 'Create new' option) + // should be in the dropdown + workflowPage.getters.nodeCredentialsSelect().click(); + getVisibleSelect().find('li').should('have.length', 3); + }); + it('should only show credentials in their personal project for members if the workflow was shared with them', () => { const workflowName = 'Test workflow'; - cy.enableFeature('sharing'); - cy.reload(); // Create a notion credential as the owner and a workflow that is shared // with member 0 @@ -272,7 +361,6 @@ describe('Credential Usage in Cross Shared Workflows', () => { it("should show all credentials from all personal projects the workflow's been shared into for the global owner", () => { const workflowName = 'Test workflow'; - cy.enableFeature('sharing'); // As member 1, create a new notion credential. This should not show up. cy.signinAsMember(1); @@ -317,8 +405,6 @@ describe('Credential Usage in Cross Shared Workflows', () => { }); it('should show all personal credentials if the global owner owns the workflow', () => { - cy.enableFeature('sharing'); - // As member 0, create a new notion credential. cy.signinAsMember(); cy.visit(credentialsPage.url); diff --git a/cypress/e2e/17-workflow-tags.cy.ts b/cypress/e2e/17-workflow-tags.cy.ts index 86b18560eb2f5..b9b8137d9eb79 100644 --- a/cypress/e2e/17-workflow-tags.cy.ts +++ b/cypress/e2e/17-workflow-tags.cy.ts @@ -1,4 +1,5 @@ import { WorkflowPage } from '../pages'; +import { getVisibleSelect } from '../utils'; const wf = new WorkflowPage(); @@ -68,4 +69,20 @@ describe('Workflow tags', () => { cy.get('body').click(0, 0); wf.getters.tagPills().should('have.length', TEST_TAGS.length - 1); }); + + it('should not show non existing tag as a selectable option', () => { + const NON_EXISTING_TAG = 'My Test Tag'; + + wf.getters.createTagButton().click(); + wf.actions.addTags(TEST_TAGS); + cy.get('body').click(0, 0); + wf.getters.workflowTags().click(); + wf.getters.tagsDropdown().find('input:focus').type(NON_EXISTING_TAG); + + getVisibleSelect() + .find('li') + .should('have.length', 2) + .filter(`:contains("${NON_EXISTING_TAG}")`) + .should('not.have.length'); + }); }); diff --git a/cypress/e2e/233-AI-switch-to-logs-on-error.cy.ts b/cypress/e2e/233-AI-switch-to-logs-on-error.cy.ts new file mode 100644 index 0000000000000..4c733df90dc48 --- /dev/null +++ b/cypress/e2e/233-AI-switch-to-logs-on-error.cy.ts @@ -0,0 +1,279 @@ +import type { ExecutionError } from 'n8n-workflow/src'; +import { NDV, WorkflowPage as WorkflowPageClass } from '../pages'; +import { + addLanguageModelNodeToParent, + addMemoryNodeToParent, + addNodeToCanvas, + addToolNodeToParent, + navigateToNewWorkflowPage, + openNode, +} from '../composables/workflow'; +import { + AGENT_NODE_NAME, + AI_LANGUAGE_MODEL_OPENAI_CHAT_MODEL_NODE_NAME, + AI_MEMORY_POSTGRES_NODE_NAME, + AI_TOOL_CALCULATOR_NODE_NAME, + CHAT_TRIGGER_NODE_DISPLAY_NAME, + MANUAL_CHAT_TRIGGER_NODE_NAME, + MANUAL_TRIGGER_NODE_DISPLAY_NAME, + MANUAL_TRIGGER_NODE_NAME, +} from '../constants'; +import { + clickCreateNewCredential, + clickExecuteNode, + clickGetBackToCanvas, +} from '../composables/ndv'; +import { setCredentialValues } from '../composables/modals/credential-modal'; +import { + closeManualChatModal, + getManualChatMessages, + getManualChatModalLogs, + getManualChatModalLogsEntries, + sendManualChatMessage, +} from '../composables/modals/chat-modal'; +import { createMockNodeExecutionData, getVisibleSelect, runMockWorkflowExecution } from '../utils'; + +const ndv = new NDV(); +const WorkflowPage = new WorkflowPageClass(); + +function createRunDataWithError(inputMessage: string) { + return [ + createMockNodeExecutionData(MANUAL_CHAT_TRIGGER_NODE_NAME, { + jsonData: { + main: { input: inputMessage }, + }, + }), + createMockNodeExecutionData(AI_MEMORY_POSTGRES_NODE_NAME, { + jsonData: { + ai_memory: { + json: { + action: 'loadMemoryVariables', + values: { + input: inputMessage, + system_message: 'You are a helpful assistant', + formatting_instructions: + 'IMPORTANT: Always call `format_final_response` to format your final response!', + }, + }, + }, + }, + inputOverride: { + ai_memory: [ + [ + { + json: { + action: 'loadMemoryVariables', + values: { + input: inputMessage, + system_message: 'You are a helpful assistant', + formatting_instructions: + 'IMPORTANT: Always call `format_final_response` to format your final response!', + }, + }, + }, + ], + ], + }, + error: { + message: 'Internal error', + timestamp: 1722591723244, + name: 'NodeOperationError', + description: 'Internal error', + context: {}, + cause: { + name: 'error', + severity: 'FATAL', + code: '3D000', + file: 'postinit.c', + line: '885', + routine: 'InitPostgres', + } as unknown as Error, + } as ExecutionError, + }), + createMockNodeExecutionData(AGENT_NODE_NAME, { + executionStatus: 'error', + error: { + level: 'error', + tags: { + packageName: 'workflow', + }, + context: {}, + functionality: 'configuration-node', + name: 'NodeOperationError', + timestamp: 1722591723244, + node: { + parameters: { + notice: '', + sessionIdType: 'fromInput', + tableName: 'n8n_chat_histories', + }, + id: '6b9141da-0135-4e9d-94d1-2d658cbf48b5', + name: 'Postgres Chat Memory', + type: '@n8n/n8n-nodes-langchain.memoryPostgresChat', + typeVersion: 1, + position: [1140, 500], + credentials: { + postgres: { + id: 'RkyZetVpGsSfEAhQ', + name: 'Postgres account', + }, + }, + }, + messages: ['database "chat11" does not exist'], + description: 'Internal error', + message: 'Internal error', + } as unknown as ExecutionError, + metadata: { + subRun: [ + { + node: 'Postgres Chat Memory', + runIndex: 0, + }, + ], + }, + }), + ]; +} + +function setupTestWorkflow(chatTrigger: boolean = false) { + // Setup test workflow with AI Agent, Postgres Memory Node (source of error), Calculator Tool, and OpenAI Chat Model + if (chatTrigger) { + addNodeToCanvas(MANUAL_CHAT_TRIGGER_NODE_NAME, true); + } else { + addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME, true); + } + + addNodeToCanvas(AGENT_NODE_NAME, true); + + if (!chatTrigger) { + // Remove chat trigger + WorkflowPage.getters + .canvasNodeByName(CHAT_TRIGGER_NODE_DISPLAY_NAME) + .find('[data-test-id="delete-node-button"]') + .click({ force: true }); + + // Set manual trigger to output standard pinned data + openNode(MANUAL_TRIGGER_NODE_DISPLAY_NAME); + ndv.actions.editPinnedData(); + ndv.actions.savePinnedData(); + ndv.actions.close(); + } + + // Calculator is added just to make OpenAI Chat Model work (tools can not be empty with OpenAI model) + addToolNodeToParent(AI_TOOL_CALCULATOR_NODE_NAME, AGENT_NODE_NAME); + clickGetBackToCanvas(); + + addMemoryNodeToParent(AI_MEMORY_POSTGRES_NODE_NAME, AGENT_NODE_NAME); + + clickCreateNewCredential(); + setCredentialValues({ + password: 'testtesttest', + }); + + ndv.getters.parameterInput('sessionIdType').click(); + getVisibleSelect().contains('Define below').click(); + ndv.getters.parameterInput('sessionKey').type('asdasd'); + + clickGetBackToCanvas(); + + addLanguageModelNodeToParent( + AI_LANGUAGE_MODEL_OPENAI_CHAT_MODEL_NODE_NAME, + AGENT_NODE_NAME, + true, + ); + + clickCreateNewCredential(); + setCredentialValues({ + apiKey: 'sk_test_123', + }); + clickGetBackToCanvas(); + + WorkflowPage.actions.zoomToFit(); +} + +function checkMessages(inputMessage: string, outputMessage: string) { + const messages = getManualChatMessages(); + messages.should('have.length', 2); + messages.should('contain', inputMessage); + messages.should('contain', outputMessage); + + getManualChatModalLogs().should('exist'); + getManualChatModalLogsEntries() + .should('have.length', 1) + .should('contain', AI_MEMORY_POSTGRES_NODE_NAME); +} + +describe("AI-233 Make root node's logs pane active in case of an error in sub-nodes", () => { + beforeEach(() => { + navigateToNewWorkflowPage(); + }); + + it('should open logs tab by default when there was an error', () => { + setupTestWorkflow(true); + + openNode(AGENT_NODE_NAME); + + const inputMessage = 'Test the code tool'; + + clickExecuteNode(); + runMockWorkflowExecution({ + trigger: () => sendManualChatMessage(inputMessage), + runData: createRunDataWithError(inputMessage), + lastNodeExecuted: AGENT_NODE_NAME, + }); + + checkMessages(inputMessage, '[ERROR: Internal error]'); + closeManualChatModal(); + + // Open the AI Agent node to see the logs + openNode(AGENT_NODE_NAME); + + // Finally check that logs pane is opened by default + ndv.getters.outputDataContainer().should('be.visible'); + + ndv.getters.aiOutputModeToggle().should('be.visible'); + ndv.getters + .aiOutputModeToggle() + .find('[role="radio"]') + .should('have.length', 2) + .eq(1) + .should('have.attr', 'aria-checked', 'true'); + + ndv.getters + .outputPanel() + .findChildByTestId('node-error-message') + .should('be.visible') + .should('contain', 'Error in sub-node'); + }); + + it('should switch to logs tab on error, when NDV is already opened', () => { + setupTestWorkflow(false); + + openNode(AGENT_NODE_NAME); + + const inputMessage = 'Test the code tool'; + + runMockWorkflowExecution({ + trigger: () => clickExecuteNode(), + runData: createRunDataWithError(inputMessage), + lastNodeExecuted: AGENT_NODE_NAME, + }); + + // Check that logs pane is opened by default + ndv.getters.outputDataContainer().should('be.visible'); + + ndv.getters.aiOutputModeToggle().should('be.visible'); + ndv.getters + .aiOutputModeToggle() + .find('[role="radio"]') + .should('have.length', 2) + .eq(1) + .should('have.attr', 'aria-checked', 'true'); + + ndv.getters + .outputPanel() + .findChildByTestId('node-error-message') + .should('be.visible') + .should('contain', 'Error in sub-node'); + }); +}); diff --git a/cypress/e2e/30-langchain.cy.ts b/cypress/e2e/30-langchain.cy.ts index c1409a34f379b..c6d0f4ab4d3e7 100644 --- a/cypress/e2e/30-langchain.cy.ts +++ b/cypress/e2e/30-langchain.cy.ts @@ -10,7 +10,9 @@ import { disableNode, getExecuteWorkflowButton, navigateToNewWorkflowPage, + getNodes, openNode, + getConnectionBySourceAndTarget, } from '../composables/workflow'; import { clickCreateNewCredential, @@ -41,6 +43,7 @@ import { AI_TOOL_WIKIPEDIA_NODE_NAME, BASIC_LLM_CHAIN_NODE_NAME, EDIT_FIELDS_SET_NODE_NAME, + CHAT_TRIGGER_NODE_DISPLAY_NAME, } from './../constants'; describe('Langchain Integration', () => { @@ -331,4 +334,27 @@ describe('Langchain Integration', () => { closeManualChatModal(); }); + + it('should auto-add chat trigger and basic LLM chain when adding LLM node', () => { + addNodeToCanvas(AI_LANGUAGE_MODEL_OPENAI_CHAT_MODEL_NODE_NAME, true); + + getConnectionBySourceAndTarget( + CHAT_TRIGGER_NODE_DISPLAY_NAME, + BASIC_LLM_CHAIN_NODE_NAME, + ).should('exist'); + + getConnectionBySourceAndTarget( + AI_LANGUAGE_MODEL_OPENAI_CHAT_MODEL_NODE_NAME, + BASIC_LLM_CHAIN_NODE_NAME, + ).should('exist'); + getNodes().should('have.length', 3); + }); + + it('should not auto-add nodes if AI nodes are already present', () => { + addNodeToCanvas(AGENT_NODE_NAME, true); + + addNodeToCanvas(AI_LANGUAGE_MODEL_OPENAI_CHAT_MODEL_NODE_NAME, true); + getConnectionBySourceAndTarget(CHAT_TRIGGER_NODE_DISPLAY_NAME, AGENT_NODE_NAME).should('exist'); + getNodes().should('have.length', 3); + }); }); diff --git a/cypress/e2e/39-projects.cy.ts b/cypress/e2e/39-projects.cy.ts index 94a6384233c2c..e2bf63df7dc46 100644 --- a/cypress/e2e/39-projects.cy.ts +++ b/cypress/e2e/39-projects.cy.ts @@ -1,9 +1,4 @@ -import { - INSTANCE_MEMBERS, - INSTANCE_OWNER, - MANUAL_TRIGGER_NODE_NAME, - NOTION_NODE_NAME, -} from '../constants'; +import { INSTANCE_MEMBERS, MANUAL_TRIGGER_NODE_NAME, NOTION_NODE_NAME } from '../constants'; import { WorkflowsPage, WorkflowPage, @@ -11,9 +6,10 @@ import { CredentialsPage, WorkflowExecutionsTab, NDV, + MainSidebar, } from '../pages'; import * as projects from '../composables/projects'; -import { getVisibleSelect } from '../utils'; +import { getVisibleDropdown, getVisibleModalOverlay, getVisibleSelect } from '../utils'; const workflowsPage = new WorkflowsPage(); const workflowPage = new WorkflowPage(); @@ -21,6 +17,7 @@ const credentialsPage = new CredentialsPage(); const credentialsModal = new CredentialsModal(); const executionsTab = new WorkflowExecutionsTab(); const ndv = new NDV(); +const mainSidebar = new MainSidebar(); describe('Projects', { disableAutoLogin: true }, () => { before(() => { @@ -237,10 +234,30 @@ describe('Projects', { disableAutoLogin: true }, () => { cy.signinAsMember(1); cy.visit(workflowsPage.url); - projects.getAddProjectButton().should('not.exist'); + cy.getByTestId('add-project-menu-item').should('not.exist'); projects.getMenuItems().should('not.exist'); }); + it('should not show viewer role if not licensed', () => { + cy.signinAsOwner(); + cy.visit(workflowsPage.url); + + projects.getMenuItems().first().click(); + projects.getProjectTabSettings().click(); + + cy.get( + `[data-test-id="user-list-item-${INSTANCE_MEMBERS[0].email}"] [data-test-id="projects-settings-user-role-select"]`, + ).click(); + + cy.get('.el-select-dropdown__item.is-disabled') + .should('contain.text', 'Viewer') + .get('span:contains("Upgrade")') + .filter(':visible') + .click(); + + getVisibleModalOverlay().should('contain.text', 'Upgrade to unlock additional roles'); + }); + describe('when starting from scratch', () => { beforeEach(() => { cy.resetDatabase(); @@ -257,7 +274,7 @@ describe('Projects', { disableAutoLogin: true }, () => { // Create a project and add a credential to it cy.intercept('POST', '/rest/projects').as('projectCreate'); - projects.getAddProjectButton().should('contain', 'Add project').should('be.visible').click(); + projects.getAddProjectButton().click(); cy.wait('@projectCreate'); projects.getMenuItems().should('have.length', 1); projects.getMenuItems().first().click(); @@ -418,7 +435,7 @@ describe('Projects', { disableAutoLogin: true }, () => { }); it('should move resources between projects', () => { - cy.signin(INSTANCE_OWNER); + cy.signinAsOwner(); cy.visit(workflowsPage.url); // Create a workflow and a credential in the Home project @@ -563,5 +580,80 @@ describe('Projects', { disableAutoLogin: true }, () => { projects.getProjectTabCredentials().click(); credentialsPage.getters.credentialCards().should('have.length', 2); }); + + it('should handle viewer role', () => { + cy.enableFeature('projectRole:viewer'); + cy.signinAsOwner(); + cy.visit(workflowsPage.url); + + projects.createProject('Development'); + projects.addProjectMember(INSTANCE_MEMBERS[0].email, 'Viewer'); + projects.getProjectSettingsSaveButton().click(); + + projects.getProjectTabWorkflows().click(); + workflowsPage.getters.newWorkflowButtonCard().click(); + projects.createWorkflow('Test_workflow_4_executions_view.json', 'WF with random error'); + executionsTab.actions.createManualExecutions(2); + executionsTab.actions.toggleNodeEnabled('Error'); + executionsTab.actions.createManualExecutions(2); + workflowPage.actions.saveWorkflowUsingKeyboardShortcut(); + + projects.getMenuItems().first().click(); + projects.getProjectTabCredentials().click(); + credentialsPage.getters.emptyListCreateCredentialButton().click(); + projects.createCredential('Notion API'); + + mainSidebar.actions.openUserMenu(); + cy.getByTestId('user-menu-item-logout').click(); + + cy.get('input[name="email"]').type(INSTANCE_MEMBERS[0].email); + cy.get('input[name="password"]').type(INSTANCE_MEMBERS[0].password); + cy.getByTestId('form-submit-button').click(); + + mainSidebar.getters.executions().click(); + cy.getByTestId('global-execution-list-item').first().find('td:last button').click(); + getVisibleDropdown() + .find('li') + .filter(':contains("Retry")') + .should('have.class', 'is-disabled'); + getVisibleDropdown() + .find('li') + .filter(':contains("Delete")') + .should('have.class', 'is-disabled'); + + projects.getMenuItems().first().click(); + cy.getByTestId('workflow-card-name').should('be.visible').first().click(); + workflowPage.getters.nodeViewRoot().should('be.visible'); + workflowPage.getters.executeWorkflowButton().should('not.exist'); + workflowPage.getters.nodeCreatorPlusButton().should('not.exist'); + workflowPage.getters.canvasNodes().should('have.length', 3).last().click(); + cy.get('body').type('{backspace}'); + workflowPage.getters.canvasNodes().should('have.length', 3).last().rightclick(); + getVisibleDropdown() + .find('li') + .should('be.visible') + .filter( + ':contains("Open"), :contains("Copy"), :contains("Select all"), :contains("Clear selection")', + ) + .should('not.have.class', 'is-disabled'); + cy.get('body').type('{esc}'); + + executionsTab.actions.switchToExecutionsTab(); + cy.getByTestId('retry-execution-button') + .should('be.visible') + .find('.is-disabled') + .should('exist'); + cy.get('button:contains("Debug")').should('be.disabled'); + cy.get('button[title="Retry execution"]').should('be.disabled'); + cy.get('button[title="Delete this execution"]').should('be.disabled'); + + projects.getMenuItems().first().click(); + projects.getProjectTabCredentials().click(); + credentialsPage.getters.credentialCards().filter(':contains("Notion")').click(); + cy.getByTestId('node-credentials-config-container') + .should('be.visible') + .find('input') + .should('not.have.length'); + }); }); }); diff --git a/cypress/pages/ndv.ts b/cypress/pages/ndv.ts index 018ec43a5de9c..1a80d79707c6d 100644 --- a/cypress/pages/ndv.ts +++ b/cypress/pages/ndv.ts @@ -24,6 +24,7 @@ export class NDV extends BasePage { editPinnedDataButton: () => cy.getByTestId('ndv-edit-pinned-data'), pinnedDataEditor: () => this.getters.outputPanel().find('.cm-editor .cm-scroller .cm-content'), runDataPaneHeader: () => cy.getByTestId('run-data-pane-header'), + aiOutputModeToggle: () => cy.getByTestId('ai-output-mode-select'), nodeOutputHint: () => cy.getByTestId('ndv-output-run-node-hint'), savePinnedDataButton: () => this.getters.runDataPaneHeader().find('button').filter(':visible').contains('Save'), @@ -204,9 +205,9 @@ export class NDV extends BasePage { const droppable = `[data-test-id="parameter-input-${parameterName}"]`; cy.draganddrop(draggable, droppable); }, - mapToParameter: (parameterName: string) => { + mapToParameter: (parameterName: string, position?: 'top' | 'center' | 'bottom') => { const droppable = `[data-test-id="parameter-input-${parameterName}"]`; - cy.draganddrop('', droppable); + cy.draganddrop('', droppable, { position }); }, switchInputMode: (type: 'Schema' | 'Table' | 'JSON' | 'Binary') => { this.getters.inputDisplayMode().find('label').contains(type).click({ force: true }); diff --git a/cypress/support/commands.ts b/cypress/support/commands.ts index a7fa994289029..1f353ab7c51ce 100644 --- a/cypress/support/commands.ts +++ b/cypress/support/commands.ts @@ -175,7 +175,7 @@ Cypress.Commands.add('drag', (selector, pos, options) => { }); }); -Cypress.Commands.add('draganddrop', (draggableSelector, droppableSelector) => { +Cypress.Commands.add('draganddrop', (draggableSelector, droppableSelector, options) => { if (draggableSelector) { cy.get(draggableSelector).should('exist'); } @@ -197,7 +197,7 @@ Cypress.Commands.add('draganddrop', (draggableSelector, droppableSelector) => { cy.get(droppableSelector).realMouseMove(0, 0); cy.get(droppableSelector).realMouseMove(pageX, pageY); cy.get(droppableSelector).realHover(); - cy.get(droppableSelector).realMouseUp(); + cy.get(droppableSelector).realMouseUp({ position: options?.position ?? 'top' }); if (draggableSelector) { cy.get(draggableSelector).realMouseUp(); } diff --git a/cypress/support/index.ts b/cypress/support/index.ts index 9819e7c3a1d6b..7c1897b11f27e 100644 --- a/cypress/support/index.ts +++ b/cypress/support/index.ts @@ -12,6 +12,10 @@ interface SigninPayload { password: string; } +interface DragAndDropOptions { + position: 'top' | 'center' | 'bottom'; +} + declare global { namespace Cypress { interface SuiteConfigOverrides { @@ -56,7 +60,11 @@ declare global { target: [number, number], options?: { abs?: boolean; index?: number; realMouse?: boolean; clickToFinish?: boolean }, ): void; - draganddrop(draggableSelector: string, droppableSelector: string): void; + draganddrop( + draggableSelector: string, + droppableSelector: string, + options?: Partial, + ): void; push(type: string, data: unknown): void; shouldNotHaveConsoleErrors(): void; window(): Chainable< diff --git a/docker/images/n8n/README.md b/docker/images/n8n/README.md index d65459615086e..f7b45f9467502 100644 --- a/docker/images/n8n/README.md +++ b/docker/images/n8n/README.md @@ -230,6 +230,4 @@ Before you upgrade to the latest version make sure to check here if there are an ## License -n8n is [fair-code](https://faircode.io) distributed under the [**Sustainable Use License**](https://github.com/n8n-io/n8n/blob/master/packages/cli/LICENSE.md). - -Additional information about the license can be found in the [docs](https://docs.n8n.io/reference/license/). +You can find the license information [here](https://github.com/n8n-io/n8n/blob/master/README.md#license) diff --git a/package.json b/package.json index e05eeb9845f1b..5744497d771cd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "n8n-monorepo", - "version": "1.53.0", + "version": "1.54.0", "private": true, "engines": { "node": ">=20.15", @@ -63,7 +63,7 @@ ], "overrides": { "@types/node": "^18.16.16", - "axios": "1.6.7", + "axios": "1.7.3", "chokidar": "3.5.2", "esbuild": "^0.20.2", "formidable": "3.5.1", diff --git a/packages/@n8n/chat/README.md b/packages/@n8n/chat/README.md index 538d72ce0af91..0ed53a5774082 100644 --- a/packages/@n8n/chat/README.md +++ b/packages/@n8n/chat/README.md @@ -260,10 +260,5 @@ body, ``` ## License -n8n Chat is [fair-code](https://faircode.io) distributed under the -[**Sustainable Use License**](https://github.com/n8n-io/n8n/blob/master/packages/cli/LICENSE.md). -Proprietary licenses are available for enterprise customers. [Get in touch](mailto:license@n8n.io) - -Additional information about the license model can be found in the -[docs](https://docs.n8n.io/reference/license/). +You can find the license information [here](https://github.com/n8n-io/n8n/blob/master/README.md#license) diff --git a/packages/@n8n/chat/package.json b/packages/@n8n/chat/package.json index 5ba8a1c39f201..ea2cd454388b0 100644 --- a/packages/@n8n/chat/package.json +++ b/packages/@n8n/chat/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/chat", - "version": "0.22.0", + "version": "0.23.0", "scripts": { "dev": "pnpm run storybook", "build": "pnpm build:vite && pnpm build:bundle", @@ -40,12 +40,11 @@ "markdown-it-link-attributes": "^4.0.1", "uuid": "catalog:", "vue": "catalog:frontend", - "vue-markdown-render": "^2.1.1" + "vue-markdown-render": "catalog:frontend" }, "devDependencies": { "@iconify-json/mdi": "^1.1.54", "@n8n/storybook": "workspace:*", - "@types/markdown-it": "^12.2.3", "@vitest/coverage-v8": "catalog:frontend", "unplugin-icons": "^0.19.0", "vite": "catalog:frontend", diff --git a/packages/@n8n/chat/src/components/Input.vue b/packages/@n8n/chat/src/components/Input.vue index 04153960825b6..af4b3343b7a38 100644 --- a/packages/@n8n/chat/src/components/Input.vue +++ b/packages/@n8n/chat/src/components/Input.vue @@ -247,6 +247,10 @@ function onOpenFileDialog() { justify-content: center; transition: color var(--chat--transition-duration) ease; + svg { + min-width: fit-content; + } + &:hover, &:focus { background: var( diff --git a/packages/@n8n/config/package.json b/packages/@n8n/config/package.json index a4abfa677adc1..e28090e49b9d2 100644 --- a/packages/@n8n/config/package.json +++ b/packages/@n8n/config/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/config", - "version": "1.3.0", + "version": "1.4.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.ts new file mode 100644 index 0000000000000..8a24bdc18beff --- /dev/null +++ b/packages/@n8n/config/src/configs/cache.ts @@ -0,0 +1,36 @@ +import { Config, Env, Nested } from '../decorators'; + +@Config +class MemoryConfig { + /** Max size of memory cache in bytes */ + @Env('N8N_CACHE_MEMORY_MAX_SIZE') + maxSize = 3 * 1024 * 1024; // 3 MiB + + /** Time to live (in milliseconds) for data cached in memory. */ + @Env('N8N_CACHE_MEMORY_TTL') + ttl = 3600 * 1000; // 1 hour +} + +@Config +class RedisConfig { + /** Prefix for cache keys in Redis. */ + @Env('N8N_CACHE_REDIS_KEY_PREFIX') + prefix = 'redis'; + + /** Time to live (in milliseconds) for data cached in Redis. 0 for no TTL. */ + @Env('N8N_CACHE_REDIS_TTL') + ttl = 3600 * 1000; // 1 hour +} + +@Config +export class CacheConfig { + /** Backend to use for caching. */ + @Env('N8N_CACHE_BACKEND') + backend: 'memory' | 'redis' | 'auto' = 'auto'; + + @Nested + memory: MemoryConfig; + + @Nested + redis: RedisConfig; +} diff --git a/packages/@n8n/config/src/configs/credentials.ts b/packages/@n8n/config/src/configs/credentials.ts index 9659061c05e20..ee5f78a681ae6 100644 --- a/packages/@n8n/config/src/configs/credentials.ts +++ b/packages/@n8n/config/src/configs/credentials.ts @@ -7,19 +7,19 @@ class CredentialsOverwrite { * Format: { CREDENTIAL_NAME: { PARAMETER: VALUE }} */ @Env('CREDENTIALS_OVERWRITE_DATA') - readonly data: string = '{}'; + data = '{}'; /** Internal API endpoint to fetch overwritten credential types from. */ @Env('CREDENTIALS_OVERWRITE_ENDPOINT') - readonly endpoint: string = ''; + endpoint = ''; } @Config export class CredentialsConfig { /** Default name for credentials */ @Env('CREDENTIALS_DEFAULT_NAME') - readonly defaultName: string = 'My credentials'; + defaultName = 'My credentials'; @Nested - readonly overwrite: CredentialsOverwrite; + overwrite: CredentialsOverwrite; } diff --git a/packages/@n8n/config/src/configs/database.ts b/packages/@n8n/config/src/configs/database.ts index 384ecb1fb065c..06a3f85465929 100644 --- a/packages/@n8n/config/src/configs/database.ts +++ b/packages/@n8n/config/src/configs/database.ts @@ -4,19 +4,19 @@ import { Config, Env, Nested } from '../decorators'; class LoggingConfig { /** Whether database logging is enabled. */ @Env('DB_LOGGING_ENABLED') - readonly enabled: boolean = false; + enabled = false; /** * Database logging level. Requires `DB_LOGGING_MAX_EXECUTION_TIME` to be higher than `0`. */ @Env('DB_LOGGING_OPTIONS') - readonly options: 'query' | 'error' | 'schema' | 'warn' | 'info' | 'log' | 'all' = 'error'; + options: 'query' | 'error' | 'schema' | 'warn' | 'info' | 'log' | 'all' = 'error'; /** * Only queries that exceed this time (ms) will be logged. Set `0` to disable. */ @Env('DB_LOGGING_MAX_EXECUTION_TIME') - readonly maxQueryExecutionTime: number = 0; + maxQueryExecutionTime = 0; } @Config @@ -26,97 +26,97 @@ class PostgresSSLConfig { * If `DB_POSTGRESDB_SSL_CA`, `DB_POSTGRESDB_SSL_CERT`, or `DB_POSTGRESDB_SSL_KEY` are defined, `DB_POSTGRESDB_SSL_ENABLED` defaults to `true`. */ @Env('DB_POSTGRESDB_SSL_ENABLED') - readonly enabled: boolean = false; + enabled = false; /** SSL certificate authority */ @Env('DB_POSTGRESDB_SSL_CA') - readonly ca: string = ''; + ca = ''; /** SSL certificate */ @Env('DB_POSTGRESDB_SSL_CERT') - readonly cert: string = ''; + cert = ''; /** SSL key */ @Env('DB_POSTGRESDB_SSL_KEY') - readonly key: string = ''; + key = ''; /** If unauthorized SSL connections should be rejected */ @Env('DB_POSTGRESDB_SSL_REJECT_UNAUTHORIZED') - readonly rejectUnauthorized: boolean = true; + rejectUnauthorized = true; } @Config class PostgresConfig { /** Postgres database name */ @Env('DB_POSTGRESDB_DATABASE') - database: string = 'n8n'; + database = 'n8n'; /** Postgres database host */ @Env('DB_POSTGRESDB_HOST') - readonly host: string = 'localhost'; + host = 'localhost'; /** Postgres database password */ @Env('DB_POSTGRESDB_PASSWORD') - readonly password: string = ''; + password = ''; /** Postgres database port */ @Env('DB_POSTGRESDB_PORT') - readonly port: number = 5432; + port: number = 5432; /** Postgres database user */ @Env('DB_POSTGRESDB_USER') - readonly user: string = 'postgres'; + user = 'postgres'; /** Postgres database schema */ @Env('DB_POSTGRESDB_SCHEMA') - readonly schema: string = 'public'; + schema = 'public'; /** Postgres database pool size */ @Env('DB_POSTGRESDB_POOL_SIZE') - readonly poolSize = 2; + poolSize = 2; @Nested - readonly ssl: PostgresSSLConfig; + ssl: PostgresSSLConfig; } @Config class MysqlConfig { /** @deprecated MySQL database name */ @Env('DB_MYSQLDB_DATABASE') - database: string = 'n8n'; + database = 'n8n'; /** MySQL database host */ @Env('DB_MYSQLDB_HOST') - readonly host: string = 'localhost'; + host = 'localhost'; /** MySQL database password */ @Env('DB_MYSQLDB_PASSWORD') - readonly password: string = ''; + password = ''; /** MySQL database port */ @Env('DB_MYSQLDB_PORT') - readonly port: number = 3306; + port: number = 3306; /** MySQL database user */ @Env('DB_MYSQLDB_USER') - readonly user: string = 'root'; + user = 'root'; } @Config class SqliteConfig { /** SQLite database file name */ @Env('DB_SQLITE_DATABASE') - readonly database: string = 'database.sqlite'; + database = 'database.sqlite'; /** SQLite database pool size. Set to `0` to disable pooling. */ @Env('DB_SQLITE_POOL_SIZE') - readonly poolSize: number = 0; + poolSize: number = 0; /** * Enable SQLite WAL mode. */ @Env('DB_SQLITE_ENABLE_WAL') - readonly enableWAL: boolean = this.poolSize > 1; + enableWAL = this.poolSize > 1; /** * Run `VACUUM` on startup to rebuild the database, reducing file size and optimizing indexes. @@ -124,7 +124,7 @@ class SqliteConfig { * @warning Long-running blocking operation that will increase startup time. */ @Env('DB_SQLITE_VACUUM_ON_STARTUP') - readonly executeVacuumOnStartup: boolean = false; + executeVacuumOnStartup = false; } @Config @@ -135,17 +135,17 @@ export class DatabaseConfig { /** Prefix for table names */ @Env('DB_TABLE_PREFIX') - readonly tablePrefix: string = ''; + tablePrefix = ''; @Nested - readonly logging: LoggingConfig; + logging: LoggingConfig; @Nested - readonly postgresdb: PostgresConfig; + postgresdb: PostgresConfig; @Nested - readonly mysqldb: MysqlConfig; + mysqldb: MysqlConfig; @Nested - readonly sqlite: SqliteConfig; + sqlite: SqliteConfig; } diff --git a/packages/@n8n/config/src/configs/email.ts b/packages/@n8n/config/src/configs/email.ts index 318c35238070f..f0e130c3b48be 100644 --- a/packages/@n8n/config/src/configs/email.ts +++ b/packages/@n8n/config/src/configs/email.ts @@ -4,75 +4,75 @@ import { Config, Env, Nested } from '../decorators'; export class SmtpAuth { /** SMTP login username */ @Env('N8N_SMTP_USER') - readonly user: string = ''; + user = ''; /** SMTP login password */ @Env('N8N_SMTP_PASS') - readonly pass: string = ''; + pass = ''; /** SMTP OAuth Service Client */ @Env('N8N_SMTP_OAUTH_SERVICE_CLIENT') - readonly serviceClient: string = ''; + serviceClient = ''; /** SMTP OAuth Private Key */ @Env('N8N_SMTP_OAUTH_PRIVATE_KEY') - readonly privateKey: string = ''; + privateKey = ''; } @Config export class SmtpConfig { /** SMTP server host */ @Env('N8N_SMTP_HOST') - readonly host: string = ''; + host = ''; /** SMTP server port */ @Env('N8N_SMTP_PORT') - readonly port: number = 465; + port: number = 465; /** Whether to use SSL for SMTP */ @Env('N8N_SMTP_SSL') - readonly secure: boolean = true; + secure: boolean = true; /** Whether to use STARTTLS for SMTP when SSL is disabled */ @Env('N8N_SMTP_STARTTLS') - readonly startTLS: boolean = true; + startTLS: boolean = true; /** How to display sender name */ @Env('N8N_SMTP_SENDER') - readonly sender: string = ''; + sender = ''; @Nested - readonly auth: SmtpAuth; + auth: SmtpAuth; } @Config export class TemplateConfig { /** Overrides default HTML template for inviting new people (use full path) */ @Env('N8N_UM_EMAIL_TEMPLATES_INVITE') - readonly invite: string = ''; + invite = ''; /** Overrides default HTML template for resetting password (use full path) */ @Env('N8N_UM_EMAIL_TEMPLATES_PWRESET') - readonly passwordReset: string = ''; + passwordReset = ''; /** Overrides default HTML template for notifying that a workflow was shared (use full path) */ @Env('N8N_UM_EMAIL_TEMPLATES_WORKFLOW_SHARED') - readonly workflowShared: string = ''; + workflowShared = ''; /** Overrides default HTML template for notifying that credentials were shared (use full path) */ @Env('N8N_UM_EMAIL_TEMPLATES_CREDENTIALS_SHARED') - readonly credentialsShared: string = ''; + credentialsShared = ''; } @Config export class EmailConfig { /** How to send emails */ @Env('N8N_EMAIL_MODE') - readonly mode: '' | 'smtp' = 'smtp'; + mode: '' | 'smtp' = 'smtp'; @Nested - readonly smtp: SmtpConfig; + smtp: SmtpConfig; @Nested - readonly template: TemplateConfig; + template: TemplateConfig; } diff --git a/packages/@n8n/config/src/configs/endpoints.ts b/packages/@n8n/config/src/configs/endpoints.ts index 7a04a8249f692..4957c5afa58d6 100644 --- a/packages/@n8n/config/src/configs/endpoints.ts +++ b/packages/@n8n/config/src/configs/endpoints.ts @@ -4,99 +4,99 @@ import { Config, Env, Nested } from '../decorators'; class PrometheusMetricsConfig { /** Whether to enable the `/metrics` endpoint to expose Prometheus metrics. */ @Env('N8N_METRICS') - readonly enable: boolean = false; + enable = false; /** Prefix for Prometheus metric names. */ @Env('N8N_METRICS_PREFIX') - readonly prefix: string = 'n8n_'; + prefix = 'n8n_'; /** Whether to expose system and Node.js metrics. See: https://www.npmjs.com/package/prom-client */ @Env('N8N_METRICS_INCLUDE_DEFAULT_METRICS') - readonly includeDefaultMetrics = true; + includeDefaultMetrics = true; /** Whether to include a label for workflow ID on workflow metrics. */ @Env('N8N_METRICS_INCLUDE_WORKFLOW_ID_LABEL') - readonly includeWorkflowIdLabel: boolean = false; + includeWorkflowIdLabel = false; /** Whether to include a label for node type on node metrics. */ @Env('N8N_METRICS_INCLUDE_NODE_TYPE_LABEL') - readonly includeNodeTypeLabel: boolean = false; + includeNodeTypeLabel = false; /** Whether to include a label for credential type on credential metrics. */ @Env('N8N_METRICS_INCLUDE_CREDENTIAL_TYPE_LABEL') - readonly includeCredentialTypeLabel: boolean = false; + includeCredentialTypeLabel = false; /** Whether to expose metrics for API endpoints. See: https://www.npmjs.com/package/express-prom-bundle */ @Env('N8N_METRICS_INCLUDE_API_ENDPOINTS') - readonly includeApiEndpoints: boolean = false; + includeApiEndpoints = false; /** Whether to include a label for the path of API endpoint calls. */ @Env('N8N_METRICS_INCLUDE_API_PATH_LABEL') - readonly includeApiPathLabel: boolean = false; + includeApiPathLabel = false; /** Whether to include a label for the HTTP method of API endpoint calls. */ @Env('N8N_METRICS_INCLUDE_API_METHOD_LABEL') - readonly includeApiMethodLabel: boolean = false; + includeApiMethodLabel = false; /** Whether to include a label for the status code of API endpoint calls. */ @Env('N8N_METRICS_INCLUDE_API_STATUS_CODE_LABEL') - readonly includeApiStatusCodeLabel: boolean = false; + includeApiStatusCodeLabel = false; /** Whether to include metrics for cache hits and misses. */ @Env('N8N_METRICS_INCLUDE_CACHE_METRICS') - readonly includeCacheMetrics: boolean = false; + includeCacheMetrics = false; /** Whether to include metrics derived from n8n's internal events */ @Env('N8N_METRICS_INCLUDE_MESSAGE_EVENT_BUS_METRICS') - readonly includeMessageEventBusMetrics: boolean = false; + includeMessageEventBusMetrics = false; } @Config export class EndpointsConfig { /** Max payload size in MiB */ @Env('N8N_PAYLOAD_SIZE_MAX') - readonly payloadSizeMax: number = 16; + payloadSizeMax: number = 16; @Nested - readonly metrics: PrometheusMetricsConfig; + metrics: PrometheusMetricsConfig; /** Path segment for REST API endpoints. */ @Env('N8N_ENDPOINT_REST') - readonly rest: string = 'rest'; + rest = 'rest'; /** Path segment for form endpoints. */ @Env('N8N_ENDPOINT_FORM') - readonly form: string = 'form'; + form = 'form'; /** Path segment for test form endpoints. */ @Env('N8N_ENDPOINT_FORM_TEST') - readonly formTest: string = 'form-test'; + formTest = 'form-test'; /** Path segment for waiting form endpoints. */ @Env('N8N_ENDPOINT_FORM_WAIT') - readonly formWaiting: string = 'form-waiting'; + formWaiting = 'form-waiting'; /** Path segment for webhook endpoints. */ @Env('N8N_ENDPOINT_WEBHOOK') - readonly webhook: string = 'webhook'; + webhook = 'webhook'; /** Path segment for test webhook endpoints. */ @Env('N8N_ENDPOINT_WEBHOOK_TEST') - readonly webhookTest: string = 'webhook-test'; + webhookTest = 'webhook-test'; /** Path segment for waiting webhook endpoints. */ @Env('N8N_ENDPOINT_WEBHOOK_WAIT') - readonly webhookWaiting: string = 'webhook-waiting'; + webhookWaiting = 'webhook-waiting'; /** Whether to disable n8n's UI (frontend). */ @Env('N8N_DISABLE_UI') - readonly disableUi: boolean = false; + disableUi = false; /** Whether to disable production webhooks on the main process, when using webhook-specific processes. */ @Env('N8N_DISABLE_PRODUCTION_MAIN_PROCESS') - readonly disableProductionWebhooksOnMainProcess: boolean = false; + disableProductionWebhooksOnMainProcess = false; /** Colon-delimited list of additional endpoints to not open the UI on. */ @Env('N8N_ADDITIONAL_NON_UI_ROUTES') - readonly additionalNonUIRoutes: string = ''; + additionalNonUIRoutes = ''; } diff --git a/packages/@n8n/config/src/configs/event-bus.ts b/packages/@n8n/config/src/configs/event-bus.ts index ed1226fa923ac..87db613e63968 100644 --- a/packages/@n8n/config/src/configs/event-bus.ts +++ b/packages/@n8n/config/src/configs/event-bus.ts @@ -2,30 +2,30 @@ import { Config, Env, Nested } from '../decorators'; @Config class LogWriterConfig { - /** Number of event log files to keep */ + /* of event log files to keep */ @Env('N8N_EVENTBUS_LOGWRITER_KEEPLOGCOUNT') - readonly keepLogCount: number = 3; + keepLogCount = 3; /** Max size (in KB) of an event log file before a new one is started */ @Env('N8N_EVENTBUS_LOGWRITER_MAXFILESIZEINKB') - readonly maxFileSizeInKB: number = 10240; // 10 MB + maxFileSizeInKB = 10240; // 10 MB /** Basename of event log file */ @Env('N8N_EVENTBUS_LOGWRITER_LOGBASENAME') - readonly logBaseName: string = 'n8nEventLog'; + logBaseName = 'n8nEventLog'; } @Config export class EventBusConfig { /** How often (in ms) to check for unsent event messages. Can in rare cases cause a message to be sent twice. `0` to disable */ @Env('N8N_EVENTBUS_CHECKUNSENTINTERVAL') - readonly checkUnsentInterval: number = 0; + checkUnsentInterval = 0; /** Endpoint to retrieve n8n version information from */ @Nested - readonly logWriter: LogWriterConfig; + logWriter: LogWriterConfig; /** Whether to recover execution details after a crash or only mark status executions as crashed. */ @Env('N8N_EVENTBUS_RECOVERY_MODE') - readonly crashRecoveryMode: 'simple' | 'extensive' = 'extensive'; + crashRecoveryMode: 'simple' | 'extensive' = 'extensive'; } diff --git a/packages/@n8n/config/src/configs/external-secrets.ts b/packages/@n8n/config/src/configs/external-secrets.ts index a5310d675e373..2e51be87bc6c0 100644 --- a/packages/@n8n/config/src/configs/external-secrets.ts +++ b/packages/@n8n/config/src/configs/external-secrets.ts @@ -4,9 +4,9 @@ import { Config, Env } from '../decorators'; export class ExternalSecretsConfig { /** How often (in seconds) to check for secret updates */ @Env('N8N_EXTERNAL_SECRETS_UPDATE_INTERVAL') - readonly updateInterval: number = 300; + updateInterval = 300; /** Whether to prefer GET over LIST when fetching secrets from Hashicorp Vault */ @Env('N8N_EXTERNAL_SECRETS_PREFER_GET') - readonly preferGet: boolean = false; + preferGet = false; } diff --git a/packages/@n8n/config/src/configs/external-storage.ts b/packages/@n8n/config/src/configs/external-storage.ts index c876e0ee34465..3dd1448b44e38 100644 --- a/packages/@n8n/config/src/configs/external-storage.ts +++ b/packages/@n8n/config/src/configs/external-storage.ts @@ -4,39 +4,39 @@ import { Config, Env, Nested } from '../decorators'; class S3BucketConfig { /** Name of the n8n bucket in S3-compatible external storage */ @Env('N8N_EXTERNAL_STORAGE_S3_BUCKET_NAME') - readonly name: string = ''; + name = ''; /** Region of the n8n bucket in S3-compatible external storage @example "us-east-1" */ @Env('N8N_EXTERNAL_STORAGE_S3_BUCKET_REGION') - readonly region: string = ''; + region = ''; } @Config class S3CredentialsConfig { /** Access key in S3-compatible external storage */ @Env('N8N_EXTERNAL_STORAGE_S3_ACCESS_KEY') - readonly accessKey: string = ''; + accessKey = ''; /** Access secret in S3-compatible external storage */ @Env('N8N_EXTERNAL_STORAGE_S3_ACCESS_SECRET') - readonly accessSecret: string = ''; + accessSecret = ''; } @Config class S3Config { /** Host of the n8n bucket in S3-compatible external storage @example "s3.us-east-1.amazonaws.com" */ @Env('N8N_EXTERNAL_STORAGE_S3_HOST') - readonly host: string = ''; + host = ''; @Nested - readonly bucket: S3BucketConfig; + bucket: S3BucketConfig; @Nested - readonly credentials: S3CredentialsConfig; + credentials: S3CredentialsConfig; } @Config export class ExternalStorageConfig { @Nested - readonly s3: S3Config; + s3: S3Config; } diff --git a/packages/@n8n/config/src/configs/nodes.ts b/packages/@n8n/config/src/configs/nodes.ts index f845607a8c0f0..cf8407762cb17 100644 --- a/packages/@n8n/config/src/configs/nodes.ts +++ b/packages/@n8n/config/src/configs/nodes.ts @@ -25,22 +25,30 @@ class CommunityPackagesConfig { /** Whether to enable community packages */ @Env('N8N_COMMUNITY_PACKAGES_ENABLED') enabled: boolean = true; + + /** NPM registry URL to pull community packages from */ + @Env('N8N_COMMUNITY_PACKAGES_REGISTRY') + registry: string = 'https://registry.npmjs.org'; + + /** Whether to reinstall any missing community packages */ + @Env('N8N_REINSTALL_MISSING_PACKAGES') + reinstallMissing: boolean = false; } @Config export class NodesConfig { /** Node types to load. Includes all if unspecified. @example '["n8n-nodes-base.hackerNews"]' */ @Env('NODES_INCLUDE') - readonly include: JsonStringArray = []; + include: JsonStringArray = []; /** Node types not to load. Excludes none if unspecified. @example '["n8n-nodes-base.hackerNews"]' */ @Env('NODES_EXCLUDE') - readonly exclude: JsonStringArray = []; + exclude: JsonStringArray = []; /** Node type to use as error trigger */ @Env('NODES_ERROR_TRIGGER_TYPE') - readonly errorTriggerType: string = 'n8n-nodes-base.errorTrigger'; + errorTriggerType = 'n8n-nodes-base.errorTrigger'; @Nested - readonly communityPackages: CommunityPackagesConfig; + communityPackages: CommunityPackagesConfig; } diff --git a/packages/@n8n/config/src/configs/public-api.ts b/packages/@n8n/config/src/configs/public-api.ts index 33e3bf3fc38bc..b62cac68c7834 100644 --- a/packages/@n8n/config/src/configs/public-api.ts +++ b/packages/@n8n/config/src/configs/public-api.ts @@ -4,13 +4,13 @@ import { Config, Env } from '../decorators'; export class PublicApiConfig { /** Whether to disable the Public API */ @Env('N8N_PUBLIC_API_DISABLED') - readonly disabled: boolean = false; + disabled = false; /** Path segment for the Public API */ @Env('N8N_PUBLIC_API_ENDPOINT') - readonly path: string = 'api'; + path = 'api'; /** Whether to disable the Swagger UI for the Public API */ @Env('N8N_PUBLIC_API_SWAGGERUI_DISABLED') - readonly swaggerUiDisabled: boolean = false; + swaggerUiDisabled = false; } diff --git a/packages/@n8n/config/src/configs/scaling-mode.config.ts b/packages/@n8n/config/src/configs/scaling-mode.config.ts new file mode 100644 index 0000000000000..6ff331eedd8c2 --- /dev/null +++ b/packages/@n8n/config/src/configs/scaling-mode.config.ts @@ -0,0 +1,96 @@ +import { Config, Env, Nested } from '../decorators'; + +@Config +class HealthConfig { + /** Whether to enable the worker health check endpoint `/healthz`. */ + @Env('QUEUE_HEALTH_CHECK_ACTIVE') + active = false; + + /** Port for worker to respond to health checks requests on, if enabled. */ + @Env('QUEUE_HEALTH_CHECK_PORT') + port = 5678; +} + +@Config +class RedisConfig { + /** Redis database for Bull queue. */ + @Env('QUEUE_BULL_REDIS_DB') + db = 0; + + /** Redis host for Bull queue. */ + @Env('QUEUE_BULL_REDIS_HOST') + host = 'localhost'; + + /** Password to authenticate with Redis. */ + @Env('QUEUE_BULL_REDIS_PASSWORD') + password = ''; + + /** Port for Redis to listen on. */ + @Env('QUEUE_BULL_REDIS_PORT') + port = 6379; + + /** Max cumulative timeout (in milliseconds) of connection retries before process exit. */ + @Env('QUEUE_BULL_REDIS_TIMEOUT_THRESHOLD') + timeoutThreshold = 10_000; + + /** Redis username. Redis 6.0 or higher required. */ + @Env('QUEUE_BULL_REDIS_USERNAME') + username = ''; + + /** Redis cluster startup nodes, as comma-separated list of `{host}:{port}` pairs. @example 'redis-1:6379,redis-2:6379' */ + @Env('QUEUE_BULL_REDIS_CLUSTER_NODES') + clusterNodes = ''; + + /** Whether to enable TLS on Redis connections. */ + @Env('QUEUE_BULL_REDIS_TLS') + tls = false; +} + +@Config +class SettingsConfig { + /** How long (in milliseconds) is the lease period for a worker processing a job. */ + @Env('QUEUE_WORKER_LOCK_DURATION') + lockDuration = 30_000; + + /** How often (in milliseconds) a worker must renew the lease. */ + @Env('QUEUE_WORKER_LOCK_RENEW_TIME') + lockRenewTime = 15_000; + + /** How often (in milliseconds) Bull must check for stalled jobs. `0` to disable. */ + @Env('QUEUE_WORKER_STALLED_INTERVAL') + stalledInterval = 30_000; + + /** Max number of times a stalled job will be re-processed. See Bull's [documentation](https://docs.bullmq.io/guide/workers/stalled-jobs). */ + @Env('QUEUE_WORKER_MAX_STALLED_COUNT') + maxStalledCount = 1; +} + +@Config +class BullConfig { + /** Prefix for Bull keys on Redis. @example 'bull:jobs:23' */ + @Env('QUEUE_BULL_PREFIX') + prefix = 'bull'; + + @Nested + redis: RedisConfig; + + /** How often (in seconds) to poll the Bull queue to identify executions finished during a Redis crash. `0` to disable. May increase Redis traffic significantly. */ + @Env('QUEUE_RECOVERY_INTERVAL') + queueRecoveryInterval = 60; // watchdog interval + + /** @deprecated How long (in seconds) a worker must wait for active executions to finish before exiting. Use `N8N_GRACEFUL_SHUTDOWN_TIMEOUT` instead */ + @Env('QUEUE_WORKER_TIMEOUT') + gracefulShutdownTimeout = 30; + + @Nested + settings: SettingsConfig; +} + +@Config +export class ScalingModeConfig { + @Nested + health: HealthConfig; + + @Nested + bull: BullConfig; +} diff --git a/packages/@n8n/config/src/configs/templates.ts b/packages/@n8n/config/src/configs/templates.ts index 3e10c892b3469..3b05048b36301 100644 --- a/packages/@n8n/config/src/configs/templates.ts +++ b/packages/@n8n/config/src/configs/templates.ts @@ -4,9 +4,9 @@ import { Config, Env } from '../decorators'; export class TemplatesConfig { /** Whether to load workflow templates. */ @Env('N8N_TEMPLATES_ENABLED') - readonly enabled: boolean = true; + enabled = true; /** Host to retrieve workflow templates from endpoints. */ @Env('N8N_TEMPLATES_HOST') - readonly host: string = 'https://api.n8n.io/api/'; + host = 'https://api.n8n.io/api/'; } diff --git a/packages/@n8n/config/src/configs/version-notifications.ts b/packages/@n8n/config/src/configs/version-notifications.ts index 1aa693228d494..5fe495ed6c02e 100644 --- a/packages/@n8n/config/src/configs/version-notifications.ts +++ b/packages/@n8n/config/src/configs/version-notifications.ts @@ -4,13 +4,13 @@ import { Config, Env } from '../decorators'; export class VersionNotificationsConfig { /** Whether to request notifications about new n8n versions */ @Env('N8N_VERSION_NOTIFICATIONS_ENABLED') - readonly enabled: boolean = true; + enabled = true; /** Endpoint to retrieve n8n version information from */ @Env('N8N_VERSION_NOTIFICATIONS_ENDPOINT') - readonly endpoint: string = 'https://api.n8n.io/api/versions/'; + endpoint = 'https://api.n8n.io/api/versions/'; /** URL for versions panel to page instructing user on how to update n8n instance */ @Env('N8N_VERSION_NOTIFICATIONS_INFO_URL') - readonly infoUrl: string = 'https://docs.n8n.io/hosting/installation/updating/'; + infoUrl = 'https://docs.n8n.io/hosting/installation/updating/'; } diff --git a/packages/@n8n/config/src/configs/workflows.ts b/packages/@n8n/config/src/configs/workflows.ts index b19f4bc95d7e0..9ca004c886b58 100644 --- a/packages/@n8n/config/src/configs/workflows.ts +++ b/packages/@n8n/config/src/configs/workflows.ts @@ -4,17 +4,14 @@ import { Config, Env } from '../decorators'; export class WorkflowsConfig { /** Default name for workflow */ @Env('WORKFLOWS_DEFAULT_NAME') - readonly defaultName: string = 'My workflow'; + defaultName = 'My workflow'; /** Show onboarding flow in new workflow */ @Env('N8N_ONBOARDING_FLOW_DISABLED') - readonly onboardingFlowDisabled: boolean = false; + onboardingFlowDisabled = false; /** Default option for which workflows may call the current workflow */ @Env('N8N_WORKFLOW_CALLER_POLICY_DEFAULT_OPTION') - readonly callerPolicyDefaultOption: - | 'any' - | 'none' - | 'workflowsFromAList' - | 'workflowsFromSameOwner' = 'workflowsFromSameOwner'; + callerPolicyDefaultOption: 'any' | 'none' | 'workflowsFromAList' | 'workflowsFromSameOwner' = + 'workflowsFromSameOwner'; } diff --git a/packages/@n8n/config/src/index.ts b/packages/@n8n/config/src/index.ts index d7bb09889d5ab..88e6fb01170d8 100644 --- a/packages/@n8n/config/src/index.ts +++ b/packages/@n8n/config/src/index.ts @@ -11,6 +11,8 @@ 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 { ScalingModeConfig } from './configs/scaling-mode.config'; @Config class UserManagementConfig { @@ -75,4 +77,10 @@ export class GlobalConfig { @Nested readonly endpoints: EndpointsConfig; + + @Nested + readonly cache: CacheConfig; + + @Nested + queue: ScalingModeConfig; } diff --git a/packages/@n8n/config/test/config.test.ts b/packages/@n8n/config/test/config.test.ts index a36a74d1e25c7..b8e89d0ab7f0d 100644 --- a/packages/@n8n/config/test/config.test.ts +++ b/packages/@n8n/config/test/config.test.ts @@ -108,6 +108,8 @@ describe('GlobalConfig', () => { nodes: { communityPackages: { enabled: true, + registry: 'https://registry.npmjs.org', + reinstallMissing: false, }, errorTriggerType: 'n8n-nodes-base.errorTrigger', include: [], @@ -172,6 +174,44 @@ describe('GlobalConfig', () => { webhookTest: 'webhook-test', webhookWaiting: 'webhook-waiting', }, + cache: { + backend: 'auto', + memory: { + maxSize: 3145728, + ttl: 3600000, + }, + redis: { + prefix: 'redis', + ttl: 3600000, + }, + }, + queue: { + health: { + active: false, + port: 5678, + }, + bull: { + redis: { + db: 0, + host: 'localhost', + password: '', + port: 6379, + timeoutThreshold: 10_000, + username: '', + clusterNodes: '', + tls: false, + }, + queueRecoveryInterval: 60, + gracefulShutdownTimeout: 30, + prefix: 'bull', + settings: { + lockDuration: 30_000, + lockRenewTime: 15_000, + stalledInterval: 30_000, + maxStalledCount: 1, + }, + }, + }, }; it('should use all default values when no env variables are defined', () => { diff --git a/packages/@n8n/nodes-langchain/README.md b/packages/@n8n/nodes-langchain/README.md index e5761628cf67b..03a23d21865d8 100644 --- a/packages/@n8n/nodes-langchain/README.md +++ b/packages/@n8n/nodes-langchain/README.md @@ -8,6 +8,4 @@ These nodes are still in Beta state and are only compatible with the Docker imag ## License -n8n is [fair-code](https://faircode.io) distributed under the [**Sustainable Use License**](https://github.com/n8n-io/n8n/blob/master/packages/cli/LICENSE.md). - -Additional information about the license can be found in the [docs](https://docs.n8n.io/reference/license/). +You can find the license information [here](https://github.com/n8n-io/n8n/blob/master/README.md#license) diff --git a/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/OpenAiFunctionsAgent/execute.ts b/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/OpenAiFunctionsAgent/execute.ts index a027829e3a7d8..3c4ff28f06a10 100644 --- a/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/OpenAiFunctionsAgent/execute.ts +++ b/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/OpenAiFunctionsAgent/execute.ts @@ -38,7 +38,7 @@ export async function openAiFunctionsAgentExecute( const memory = (await this.getInputConnectionData(NodeConnectionType.AiMemory, 0)) as | BaseChatMemory | undefined; - const tools = await getConnectedTools(this, nodeVersion >= 1.5); + const tools = await getConnectedTools(this, nodeVersion >= 1.5, false); const outputParsers = await getOptionalOutputParsers(this); const options = this.getNodeParameter('options', 0, {}) as { systemMessage?: string; diff --git a/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ToolsAgent/execute.ts b/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ToolsAgent/execute.ts index 81ac6bad5db43..e0da7f1e315f9 100644 --- a/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ToolsAgent/execute.ts +++ b/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ToolsAgent/execute.ts @@ -90,7 +90,7 @@ export async function toolsAgentExecute(this: IExecuteFunctions): Promise; + const tools = (await getConnectedTools(this, true, false)) as Array; const outputParser = (await getOptionalOutputParsers(this))?.[0]; let structuredOutputParserTool: DynamicStructuredTool | undefined; diff --git a/packages/@n8n/nodes-langchain/nodes/agents/OpenAiAssistant/OpenAiAssistant.node.ts b/packages/@n8n/nodes-langchain/nodes/agents/OpenAiAssistant/OpenAiAssistant.node.ts index f56ce7c5c4484..8f05faccb0274 100644 --- a/packages/@n8n/nodes-langchain/nodes/agents/OpenAiAssistant/OpenAiAssistant.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/agents/OpenAiAssistant/OpenAiAssistant.node.ts @@ -313,7 +313,7 @@ export class OpenAiAssistant implements INodeType { async execute(this: IExecuteFunctions): Promise { const nodeVersion = this.getNode().typeVersion; - const tools = await getConnectedTools(this, nodeVersion > 1); + const tools = await getConnectedTools(this, nodeVersion > 1, false); const credentials = await this.getCredentials('openAiApi'); const items = this.getInputData(); diff --git a/packages/@n8n/nodes-langchain/nodes/llms/LMChatAnthropic/LmChatAnthropic.node.ts b/packages/@n8n/nodes-langchain/nodes/llms/LMChatAnthropic/LmChatAnthropic.node.ts index 241efadb6d392..ecc14e1344341 100644 --- a/packages/@n8n/nodes-langchain/nodes/llms/LMChatAnthropic/LmChatAnthropic.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/llms/LMChatAnthropic/LmChatAnthropic.node.ts @@ -74,7 +74,7 @@ export class LmChatAnthropic implements INodeType { codex: { categories: ['AI'], subcategories: { - AI: ['Language Models'], + AI: ['Language Models', 'Root Nodes'], 'Language Models': ['Chat Models (Recommended)'], }, resources: { diff --git a/packages/@n8n/nodes-langchain/nodes/llms/LMChatOllama/LmChatOllama.node.ts b/packages/@n8n/nodes-langchain/nodes/llms/LMChatOllama/LmChatOllama.node.ts index f0f9e5f630e29..b4fc474dd2ecf 100644 --- a/packages/@n8n/nodes-langchain/nodes/llms/LMChatOllama/LmChatOllama.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/llms/LMChatOllama/LmChatOllama.node.ts @@ -28,7 +28,7 @@ export class LmChatOllama implements INodeType { codex: { categories: ['AI'], subcategories: { - AI: ['Language Models'], + AI: ['Language Models', 'Root Nodes'], 'Language Models': ['Chat Models (Recommended)'], }, resources: { diff --git a/packages/@n8n/nodes-langchain/nodes/llms/LMChatOpenAi/LmChatOpenAi.node.ts b/packages/@n8n/nodes-langchain/nodes/llms/LMChatOpenAi/LmChatOpenAi.node.ts index 264c594d1bd87..1f39c082290e4 100644 --- a/packages/@n8n/nodes-langchain/nodes/llms/LMChatOpenAi/LmChatOpenAi.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/llms/LMChatOpenAi/LmChatOpenAi.node.ts @@ -26,7 +26,7 @@ export class LmChatOpenAi implements INodeType { codex: { categories: ['AI'], subcategories: { - AI: ['Language Models'], + AI: ['Language Models', 'Root Nodes'], 'Language Models': ['Chat Models (Recommended)'], }, resources: { diff --git a/packages/@n8n/nodes-langchain/nodes/llms/LMCohere/LmCohere.node.ts b/packages/@n8n/nodes-langchain/nodes/llms/LMCohere/LmCohere.node.ts index ee5163a78f1a3..191209bb3363b 100644 --- a/packages/@n8n/nodes-langchain/nodes/llms/LMCohere/LmCohere.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/llms/LMCohere/LmCohere.node.ts @@ -26,7 +26,7 @@ export class LmCohere implements INodeType { codex: { categories: ['AI'], subcategories: { - AI: ['Language Models'], + AI: ['Language Models', 'Root Nodes'], 'Language Models': ['Text Completion Models'], }, resources: { diff --git a/packages/@n8n/nodes-langchain/nodes/llms/LMOllama/LmOllama.node.ts b/packages/@n8n/nodes-langchain/nodes/llms/LMOllama/LmOllama.node.ts index 95024ad4b2f39..5492a51a97914 100644 --- a/packages/@n8n/nodes-langchain/nodes/llms/LMOllama/LmOllama.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/llms/LMOllama/LmOllama.node.ts @@ -27,7 +27,7 @@ export class LmOllama implements INodeType { codex: { categories: ['AI'], subcategories: { - AI: ['Language Models'], + AI: ['Language Models', 'Root Nodes'], 'Language Models': ['Text Completion Models'], }, resources: { diff --git a/packages/@n8n/nodes-langchain/nodes/llms/LMOpenAi/LmOpenAi.node.ts b/packages/@n8n/nodes-langchain/nodes/llms/LMOpenAi/LmOpenAi.node.ts index 2f0e3480d8701..a46ad429a2963 100644 --- a/packages/@n8n/nodes-langchain/nodes/llms/LMOpenAi/LmOpenAi.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/llms/LMOpenAi/LmOpenAi.node.ts @@ -38,7 +38,7 @@ export class LmOpenAi implements INodeType { codex: { categories: ['AI'], subcategories: { - AI: ['Language Models'], + AI: ['Language Models', 'Root Nodes'], 'Language Models': ['Text Completion Models'], }, resources: { diff --git a/packages/@n8n/nodes-langchain/nodes/llms/LMOpenHuggingFaceInference/LmOpenHuggingFaceInference.node.ts b/packages/@n8n/nodes-langchain/nodes/llms/LMOpenHuggingFaceInference/LmOpenHuggingFaceInference.node.ts index 9a30ef74d73ae..7b2c821f9c34c 100644 --- a/packages/@n8n/nodes-langchain/nodes/llms/LMOpenHuggingFaceInference/LmOpenHuggingFaceInference.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/llms/LMOpenHuggingFaceInference/LmOpenHuggingFaceInference.node.ts @@ -26,7 +26,7 @@ export class LmOpenHuggingFaceInference implements INodeType { codex: { categories: ['AI'], subcategories: { - AI: ['Language Models'], + AI: ['Language Models', 'Root Nodes'], 'Language Models': ['Text Completion Models'], }, resources: { diff --git a/packages/@n8n/nodes-langchain/nodes/llms/LmChatAwsBedrock/LmChatAwsBedrock.node.ts b/packages/@n8n/nodes-langchain/nodes/llms/LmChatAwsBedrock/LmChatAwsBedrock.node.ts index d313ba53b54e4..4e1c27bfde8c2 100644 --- a/packages/@n8n/nodes-langchain/nodes/llms/LmChatAwsBedrock/LmChatAwsBedrock.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/llms/LmChatAwsBedrock/LmChatAwsBedrock.node.ts @@ -29,7 +29,7 @@ export class LmChatAwsBedrock implements INodeType { codex: { categories: ['AI'], subcategories: { - AI: ['Language Models'], + AI: ['Language Models', 'Root Nodes'], 'Language Models': ['Chat Models (Recommended)'], }, resources: { diff --git a/packages/@n8n/nodes-langchain/nodes/llms/LmChatAzureOpenAi/LmChatAzureOpenAi.node.ts b/packages/@n8n/nodes-langchain/nodes/llms/LmChatAzureOpenAi/LmChatAzureOpenAi.node.ts index 2e05b4770d99d..5770387158b1e 100644 --- a/packages/@n8n/nodes-langchain/nodes/llms/LmChatAzureOpenAi/LmChatAzureOpenAi.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/llms/LmChatAzureOpenAi/LmChatAzureOpenAi.node.ts @@ -27,7 +27,7 @@ export class LmChatAzureOpenAi implements INodeType { codex: { categories: ['AI'], subcategories: { - AI: ['Language Models'], + AI: ['Language Models', 'Root Nodes'], 'Language Models': ['Chat Models (Recommended)'], }, resources: { diff --git a/packages/@n8n/nodes-langchain/nodes/llms/LmChatGoogleGemini/LmChatGoogleGemini.node.ts b/packages/@n8n/nodes-langchain/nodes/llms/LmChatGoogleGemini/LmChatGoogleGemini.node.ts index d3d3d1ea2c218..ce08a650f203f 100644 --- a/packages/@n8n/nodes-langchain/nodes/llms/LmChatGoogleGemini/LmChatGoogleGemini.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/llms/LmChatGoogleGemini/LmChatGoogleGemini.node.ts @@ -27,7 +27,7 @@ export class LmChatGoogleGemini implements INodeType { codex: { categories: ['AI'], subcategories: { - AI: ['Language Models'], + AI: ['Language Models', 'Root Nodes'], 'Language Models': ['Chat Models (Recommended)'], }, resources: { diff --git a/packages/@n8n/nodes-langchain/nodes/llms/LmChatGooglePalm/LmChatGooglePalm.node.ts b/packages/@n8n/nodes-langchain/nodes/llms/LmChatGooglePalm/LmChatGooglePalm.node.ts index a32a5e959cdb5..6195d4d98763b 100644 --- a/packages/@n8n/nodes-langchain/nodes/llms/LmChatGooglePalm/LmChatGooglePalm.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/llms/LmChatGooglePalm/LmChatGooglePalm.node.ts @@ -25,7 +25,7 @@ export class LmChatGooglePalm implements INodeType { codex: { categories: ['AI'], subcategories: { - AI: ['Language Models'], + AI: ['Language Models', 'Root Nodes'], 'Language Models': ['Chat Models (Recommended)'], }, resources: { diff --git a/packages/@n8n/nodes-langchain/nodes/llms/LmChatGoogleVertex/LmChatGoogleVertex.node.ts b/packages/@n8n/nodes-langchain/nodes/llms/LmChatGoogleVertex/LmChatGoogleVertex.node.ts index 1e3837818fb5b..044428c01adc3 100644 --- a/packages/@n8n/nodes-langchain/nodes/llms/LmChatGoogleVertex/LmChatGoogleVertex.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/llms/LmChatGoogleVertex/LmChatGoogleVertex.node.ts @@ -32,7 +32,7 @@ export class LmChatGoogleVertex implements INodeType { codex: { categories: ['AI'], subcategories: { - AI: ['Language Models'], + AI: ['Language Models', 'Root Nodes'], 'Language Models': ['Chat Models (Recommended)'], }, resources: { diff --git a/packages/@n8n/nodes-langchain/nodes/llms/LmChatGroq/LmChatGroq.node.ts b/packages/@n8n/nodes-langchain/nodes/llms/LmChatGroq/LmChatGroq.node.ts index 3354ac030f404..d0a28715e1010 100644 --- a/packages/@n8n/nodes-langchain/nodes/llms/LmChatGroq/LmChatGroq.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/llms/LmChatGroq/LmChatGroq.node.ts @@ -26,7 +26,7 @@ export class LmChatGroq implements INodeType { codex: { categories: ['AI'], subcategories: { - AI: ['Language Models'], + AI: ['Language Models', 'Root Nodes'], 'Language Models': ['Chat Models (Recommended)'], }, resources: { diff --git a/packages/@n8n/nodes-langchain/nodes/llms/LmChatMistralCloud/LmChatMistralCloud.node.ts b/packages/@n8n/nodes-langchain/nodes/llms/LmChatMistralCloud/LmChatMistralCloud.node.ts index 32364545d2073..129beeadfe5eb 100644 --- a/packages/@n8n/nodes-langchain/nodes/llms/LmChatMistralCloud/LmChatMistralCloud.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/llms/LmChatMistralCloud/LmChatMistralCloud.node.ts @@ -27,7 +27,7 @@ export class LmChatMistralCloud implements INodeType { codex: { categories: ['AI'], subcategories: { - AI: ['Language Models'], + AI: ['Language Models', 'Root Nodes'], 'Language Models': ['Chat Models (Recommended)'], }, resources: { diff --git a/packages/@n8n/nodes-langchain/nodes/llms/LmGooglePalm/LmGooglePalm.node.ts b/packages/@n8n/nodes-langchain/nodes/llms/LmGooglePalm/LmGooglePalm.node.ts index d79f74be02fe6..e4681803fe59a 100644 --- a/packages/@n8n/nodes-langchain/nodes/llms/LmGooglePalm/LmGooglePalm.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/llms/LmGooglePalm/LmGooglePalm.node.ts @@ -25,7 +25,7 @@ export class LmGooglePalm implements INodeType { codex: { categories: ['AI'], subcategories: { - AI: ['Language Models'], + AI: ['Language Models', 'Root Nodes'], 'Language Models': ['Text Completion Models'], }, resources: { diff --git a/packages/@n8n/nodes-langchain/nodes/memory/MemoryBufferWindow/MemoryBufferWindow.node.ts b/packages/@n8n/nodes-langchain/nodes/memory/MemoryBufferWindow/MemoryBufferWindow.node.ts index 2b7e205de63fb..b8eea7a5e2c82 100644 --- a/packages/@n8n/nodes-langchain/nodes/memory/MemoryBufferWindow/MemoryBufferWindow.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/memory/MemoryBufferWindow/MemoryBufferWindow.node.ts @@ -10,7 +10,7 @@ import type { BufferWindowMemoryInput } from 'langchain/memory'; import { BufferWindowMemory } from 'langchain/memory'; import { logWrapper } from '../../../utils/logWrapper'; import { getConnectionHintNoticeField } from '../../../utils/sharedFields'; -import { sessionIdOption, sessionKeyProperty } from '../descriptions'; +import { sessionIdOption, sessionKeyProperty, contextWindowLengthProperty } from '../descriptions'; import { getSessionId } from '../../../utils/helpers'; class MemoryChatBufferSingleton { @@ -130,13 +130,7 @@ export class MemoryBufferWindow implements INodeType { }, }, sessionKeyProperty, - { - displayName: 'Context Window Length', - name: 'contextWindowLength', - type: 'number', - default: 5, - description: 'The number of previous messages to consider for context', - }, + contextWindowLengthProperty, ], }; diff --git a/packages/@n8n/nodes-langchain/nodes/memory/MemoryPostgresChat/MemoryPostgresChat.node.ts b/packages/@n8n/nodes-langchain/nodes/memory/MemoryPostgresChat/MemoryPostgresChat.node.ts index ea3ed3c33ea6b..b1a9cd7aeaec5 100644 --- a/packages/@n8n/nodes-langchain/nodes/memory/MemoryPostgresChat/MemoryPostgresChat.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/memory/MemoryPostgresChat/MemoryPostgresChat.node.ts @@ -1,7 +1,7 @@ /* eslint-disable n8n-nodes-base/node-dirname-against-convention */ import type { IExecuteFunctions, INodeType, INodeTypeDescription, SupplyData } from 'n8n-workflow'; import { NodeConnectionType } from 'n8n-workflow'; -import { BufferMemory } from 'langchain/memory'; +import { BufferMemory, BufferWindowMemory } from 'langchain/memory'; import { PostgresChatMessageHistory } from '@langchain/community/stores/message/postgres'; import type pg from 'pg'; import { configurePostgres } from 'n8n-nodes-base/dist/nodes/Postgres/v2/transport'; @@ -9,7 +9,7 @@ import type { PostgresNodeCredentials } from 'n8n-nodes-base/dist/nodes/Postgres import { postgresConnectionTest } from 'n8n-nodes-base/dist/nodes/Postgres/v2/methods/credentialTest'; import { logWrapper } from '../../../utils/logWrapper'; import { getConnectionHintNoticeField } from '../../../utils/sharedFields'; -import { sessionIdOption, sessionKeyProperty } from '../descriptions'; +import { sessionIdOption, sessionKeyProperty, contextWindowLengthProperty } from '../descriptions'; import { getSessionId } from '../../../utils/helpers'; export class MemoryPostgresChat implements INodeType { @@ -18,7 +18,7 @@ export class MemoryPostgresChat implements INodeType { name: 'memoryPostgresChat', icon: 'file:postgres.svg', group: ['transform'], - version: [1], + version: [1, 1.1], description: 'Stores the chat history in Postgres table.', defaults: { name: 'Postgres Chat Memory', @@ -60,6 +60,10 @@ export class MemoryPostgresChat implements INodeType { description: 'The table name to store the chat history in. If table does not exist, it will be created.', }, + { + ...contextWindowLengthProperty, + displayOptions: { hide: { '@version': [{ _cnd: { lt: 1.1 } }] } }, + }, ], }; @@ -83,12 +87,19 @@ export class MemoryPostgresChat implements INodeType { tableName, }); - const memory = new BufferMemory({ + const memClass = this.getNode().typeVersion < 1.1 ? BufferMemory : BufferWindowMemory; + const kOptions = + this.getNode().typeVersion < 1.1 + ? {} + : { k: this.getNodeParameter('contextWindowLength', itemIndex) }; + + const memory = new memClass({ memoryKey: 'chat_history', chatHistory: pgChatHistory, returnMessages: true, inputKey: 'input', outputKey: 'output', + ...kOptions, }); async function closeFunction() { diff --git a/packages/@n8n/nodes-langchain/nodes/memory/MemoryRedisChat/MemoryRedisChat.node.ts b/packages/@n8n/nodes-langchain/nodes/memory/MemoryRedisChat/MemoryRedisChat.node.ts index d139bd31e365a..da57ede1d2315 100644 --- a/packages/@n8n/nodes-langchain/nodes/memory/MemoryRedisChat/MemoryRedisChat.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/memory/MemoryRedisChat/MemoryRedisChat.node.ts @@ -7,14 +7,14 @@ import { type SupplyData, NodeConnectionType, } from 'n8n-workflow'; -import { BufferMemory } from 'langchain/memory'; +import { BufferMemory, BufferWindowMemory } from 'langchain/memory'; import type { RedisChatMessageHistoryInput } from '@langchain/redis'; import { RedisChatMessageHistory } from '@langchain/redis'; import type { RedisClientOptions } from 'redis'; import { createClient } from 'redis'; import { logWrapper } from '../../../utils/logWrapper'; import { getConnectionHintNoticeField } from '../../../utils/sharedFields'; -import { sessionIdOption, sessionKeyProperty } from '../descriptions'; +import { sessionIdOption, sessionKeyProperty, contextWindowLengthProperty } from '../descriptions'; import { getSessionId } from '../../../utils/helpers'; export class MemoryRedisChat implements INodeType { @@ -23,7 +23,7 @@ export class MemoryRedisChat implements INodeType { name: 'memoryRedisChat', icon: 'file:redis.svg', group: ['transform'], - version: [1, 1.1, 1.2], + version: [1, 1.1, 1.2, 1.3], description: 'Stores the chat history in Redis.', defaults: { name: 'Redis Chat Memory', @@ -95,6 +95,10 @@ export class MemoryRedisChat implements INodeType { description: 'For how long the session should be stored in seconds. If set to 0 it will not expire.', }, + { + ...contextWindowLengthProperty, + displayOptions: { hide: { '@version': [{ _cnd: { lt: 1.3 } }] } }, + }, ], }; @@ -143,12 +147,19 @@ export class MemoryRedisChat implements INodeType { } const redisChatHistory = new RedisChatMessageHistory(redisChatConfig); - const memory = new BufferMemory({ + const memClass = this.getNode().typeVersion < 1.3 ? BufferMemory : BufferWindowMemory; + const kOptions = + this.getNode().typeVersion < 1.3 + ? {} + : { k: this.getNodeParameter('contextWindowLength', itemIndex) }; + + const memory = new memClass({ memoryKey: 'chat_history', chatHistory: redisChatHistory, returnMessages: true, inputKey: 'input', outputKey: 'output', + ...kOptions, }); async function closeFunction() { diff --git a/packages/@n8n/nodes-langchain/nodes/memory/MemoryXata/MemoryXata.node.ts b/packages/@n8n/nodes-langchain/nodes/memory/MemoryXata/MemoryXata.node.ts index e5c9dc4c35611..f0177d9e75e8b 100644 --- a/packages/@n8n/nodes-langchain/nodes/memory/MemoryXata/MemoryXata.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/memory/MemoryXata/MemoryXata.node.ts @@ -2,11 +2,11 @@ import { NodeConnectionType, NodeOperationError } from 'n8n-workflow'; import type { IExecuteFunctions, INodeType, INodeTypeDescription, SupplyData } from 'n8n-workflow'; import { XataChatMessageHistory } from '@langchain/community/stores/message/xata'; -import { BufferMemory } from 'langchain/memory'; +import { BufferMemory, BufferWindowMemory } from 'langchain/memory'; import { BaseClient } from '@xata.io/client'; import { logWrapper } from '../../../utils/logWrapper'; import { getConnectionHintNoticeField } from '../../../utils/sharedFields'; -import { sessionIdOption, sessionKeyProperty } from '../descriptions'; +import { sessionIdOption, sessionKeyProperty, contextWindowLengthProperty } from '../descriptions'; import { getSessionId } from '../../../utils/helpers'; export class MemoryXata implements INodeType { @@ -15,7 +15,7 @@ export class MemoryXata implements INodeType { name: 'memoryXata', icon: 'file:xata.svg', group: ['transform'], - version: [1, 1.1, 1.2], + version: [1, 1.1, 1.2, 1.3], description: 'Use Xata Memory', defaults: { name: 'Xata', @@ -81,6 +81,10 @@ export class MemoryXata implements INodeType { }, }, sessionKeyProperty, + { + ...contextWindowLengthProperty, + displayOptions: { hide: { '@version': [{ _cnd: { lt: 1.3 } }] } }, + }, ], }; @@ -120,12 +124,19 @@ export class MemoryXata implements INodeType { apiKey: credentials.apiKey as string, }); - const memory = new BufferMemory({ + const memClass = this.getNode().typeVersion < 1.3 ? BufferMemory : BufferWindowMemory; + const kOptions = + this.getNode().typeVersion < 1.3 + ? {} + : { k: this.getNodeParameter('contextWindowLength', itemIndex) }; + + const memory = new memClass({ chatHistory, memoryKey: 'chat_history', returnMessages: true, inputKey: 'input', outputKey: 'output', + ...kOptions, }); return { diff --git a/packages/@n8n/nodes-langchain/nodes/memory/descriptions.ts b/packages/@n8n/nodes-langchain/nodes/memory/descriptions.ts index 5f722c4647828..354d134fb7c42 100644 --- a/packages/@n8n/nodes-langchain/nodes/memory/descriptions.ts +++ b/packages/@n8n/nodes-langchain/nodes/memory/descriptions.ts @@ -33,3 +33,11 @@ export const sessionKeyProperty: INodeProperties = { }, }, }; + +export const contextWindowLengthProperty: INodeProperties = { + displayName: 'Context Window Length', + name: 'contextWindowLength', + type: 'number', + default: 5, + hint: 'How many past interactions the model receives as context', +}; diff --git a/packages/@n8n/nodes-langchain/nodes/tools/ToolCode/ToolCode.node.ts b/packages/@n8n/nodes-langchain/nodes/tools/ToolCode/ToolCode.node.ts index 492284b190c78..6ae5a8807539b 100644 --- a/packages/@n8n/nodes-langchain/nodes/tools/ToolCode/ToolCode.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/tools/ToolCode/ToolCode.node.ts @@ -6,6 +6,7 @@ import type { SupplyData, ExecutionError, } from 'n8n-workflow'; + import { NodeConnectionType, NodeOperationError } from 'n8n-workflow'; import type { Sandbox } from 'n8n-nodes-base/dist/nodes/Code/Sandbox'; import { getSandboxContext } from 'n8n-nodes-base/dist/nodes/Code/Sandbox'; @@ -208,7 +209,7 @@ export class ToolCode implements INodeType { try { response = await runFunction(query); } catch (error: unknown) { - executionError = error as ExecutionError; + executionError = new NodeOperationError(this.getNode(), error as ExecutionError); response = `There was an error: "${executionError.message}"`; } @@ -229,6 +230,7 @@ export class ToolCode implements INodeType { } else { void this.addOutputData(NodeConnectionType.AiTool, index, [[{ json: { response } }]]); } + return response; }, }), diff --git a/packages/@n8n/nodes-langchain/nodes/tools/ToolHttpRequest/ToolHttpRequest.node.ts b/packages/@n8n/nodes-langchain/nodes/tools/ToolHttpRequest/ToolHttpRequest.node.ts index 1d391f313ef36..421e85e1b5b8c 100644 --- a/packages/@n8n/nodes-langchain/nodes/tools/ToolHttpRequest/ToolHttpRequest.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/tools/ToolHttpRequest/ToolHttpRequest.node.ts @@ -12,6 +12,7 @@ import { NodeConnectionType, NodeOperationError, tryToParseAlphanumericString } import { DynamicTool } from '@langchain/core/tools'; import { getConnectionHintNoticeField } from '../../../utils/sharedFields'; +import { N8nTool } from '../../../utils/N8nTool'; import { configureHttpRequestFunction, configureResponseOptimizer, @@ -19,6 +20,7 @@ import { prepareToolDescription, configureToolFunction, updateParametersAndOptions, + makeToolInputSchema, } from './utils'; import { @@ -38,7 +40,7 @@ export class ToolHttpRequest implements INodeType { name: 'toolHttpRequest', icon: { light: 'file:httprequest.svg', dark: 'file:httprequest.dark.svg' }, group: ['output'], - version: 1, + version: [1, 1.1], description: 'Makes an HTTP request and returns the response data', subtitle: '={{ $parameter.toolDescription }}', defaults: { @@ -394,9 +396,24 @@ export class ToolHttpRequest implements INodeType { optimizeResponse, ); - const description = prepareToolDescription(toolDescription, toolParameters); + let tool: DynamicTool | N8nTool; - const tool = new DynamicTool({ name, description, func }); + // If the node version is 1.1 or higher, we use the N8nTool wrapper: + // it allows to use tool as a DynamicStructuredTool and have a fallback to DynamicTool + if (this.getNode().typeVersion >= 1.1) { + const schema = makeToolInputSchema(toolParameters); + + tool = new N8nTool(this, { + name, + description: toolDescription, + func, + schema, + }); + } else { + // Keep the old behavior for nodes with version 1.0 + const description = prepareToolDescription(toolDescription, toolParameters); + tool = new DynamicTool({ name, description, func }); + } return { response: tool, diff --git a/packages/@n8n/nodes-langchain/nodes/tools/ToolHttpRequest/utils.ts b/packages/@n8n/nodes-langchain/nodes/tools/ToolHttpRequest/utils.ts index 28015b588a101..96ae0d8492a33 100644 --- a/packages/@n8n/nodes-langchain/nodes/tools/ToolHttpRequest/utils.ts +++ b/packages/@n8n/nodes-langchain/nodes/tools/ToolHttpRequest/utils.ts @@ -27,6 +27,8 @@ import type { SendIn, ToolParameter, } from './interfaces'; +import type { DynamicZodObject } from '../../../types/zod.types'; +import { z } from 'zod'; const genericCredentialRequest = async (ctx: IExecuteFunctions, itemIndex: number) => { const genericType = ctx.getNodeParameter('genericAuthType', itemIndex) as string; @@ -566,7 +568,7 @@ export const configureToolFunction = ( httpRequest: (options: IHttpRequestOptions) => Promise, optimizeResponse: (response: string) => string, ) => { - return async (query: string): Promise => { + return async (query: string | IDataObject): Promise => { const { index } = ctx.addInputData(NodeConnectionType.AiTool, [[{ json: { query } }]]); let response: string = ''; @@ -581,18 +583,22 @@ export const configureToolFunction = ( if (query) { let dataFromModel; - try { - dataFromModel = jsonParse(query); - } catch (error) { - if (toolParameters.length === 1) { - dataFromModel = { [toolParameters[0].name]: query }; - } else { - throw new NodeOperationError( - ctx.getNode(), - `Input is not a valid JSON: ${error.message}`, - { itemIndex }, - ); + if (typeof query === 'string') { + try { + dataFromModel = jsonParse(query); + } catch (error) { + if (toolParameters.length === 1) { + dataFromModel = { [toolParameters[0].name]: query }; + } else { + throw new NodeOperationError( + ctx.getNode(), + `Input is not a valid JSON: ${error.message}`, + { itemIndex }, + ); + } } + } else { + dataFromModel = query; } for (const parameter of toolParameters) { @@ -727,6 +733,8 @@ export const configureToolFunction = ( } } } catch (error) { + console.error(error); + const errorMessage = 'Input provided by model is not valid'; if (error instanceof NodeOperationError) { @@ -765,3 +773,38 @@ export const configureToolFunction = ( return response; }; }; + +function makeParameterZodSchema(parameter: ToolParameter) { + let schema: z.ZodTypeAny; + + if (parameter.type === 'string') { + schema = z.string(); + } else if (parameter.type === 'number') { + schema = z.number(); + } else if (parameter.type === 'boolean') { + schema = z.boolean(); + } else if (parameter.type === 'json') { + schema = z.record(z.any()); + } else { + schema = z.string(); + } + + if (!parameter.required) { + schema = schema.optional(); + } + + if (parameter.description) { + schema = schema.describe(parameter.description); + } + + return schema; +} + +export function makeToolInputSchema(parameters: ToolParameter[]): DynamicZodObject { + const schemaEntries = parameters.map((parameter) => [ + parameter.name, + makeParameterZodSchema(parameter), + ]); + + return z.object(Object.fromEntries(schemaEntries)); +} diff --git a/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/ToolWorkflow.node.ts b/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/ToolWorkflow.node.ts index 54b8318f5b8b8..5ed96cbd60123 100644 --- a/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/ToolWorkflow.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/ToolWorkflow.node.ts @@ -493,7 +493,7 @@ export class ToolWorkflow implements INodeType { if (useSchema) { try { // We initialize these even though one of them will always be empty - // it makes it easer to navigate the ternary operator + // it makes it easier to navigate the ternary operator const jsonExample = this.getNodeParameter('jsonSchemaExample', itemIndex, '') as string; const inputSchema = this.getNodeParameter('inputSchema', itemIndex, '') as string; diff --git a/packages/@n8n/nodes-langchain/nodes/trigger/ManualChatTrigger/ManualChatTrigger.node.ts b/packages/@n8n/nodes-langchain/nodes/trigger/ManualChatTrigger/ManualChatTrigger.node.ts index 272213d5390c2..816b56b59ad40 100644 --- a/packages/@n8n/nodes-langchain/nodes/trigger/ManualChatTrigger/ManualChatTrigger.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/trigger/ManualChatTrigger/ManualChatTrigger.node.ts @@ -50,7 +50,9 @@ export class ManualChatTrigger implements INodeType { name: 'openChat', type: 'button', typeOptions: { - action: 'openChat', + buttonConfig: { + action: 'openChat', + }, }, default: '', }, diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/actions/assistant/message.operation.ts b/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/actions/assistant/message.operation.ts index da2770d05f2eb..134e8a1167924 100644 --- a/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/actions/assistant/message.operation.ts +++ b/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/actions/assistant/message.operation.ts @@ -163,7 +163,7 @@ export async function execute(this: IExecuteFunctions, i: number): Promise 1); + const tools = await getConnectedTools(this, nodeVersion > 1, false); let assistantTools; if (tools.length) { diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/actions/text/message.operation.ts b/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/actions/text/message.operation.ts index 4cf72e9f5f48c..d37be5a065e6d 100644 --- a/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/actions/text/message.operation.ts +++ b/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/actions/text/message.operation.ts @@ -219,7 +219,7 @@ export async function execute(this: IExecuteFunctions, i: number): Promise 1; - externalTools = await getConnectedTools(this, enforceUniqueNames); + externalTools = await getConnectedTools(this, enforceUniqueNames, false); } if (externalTools.length) { diff --git a/packages/@n8n/nodes-langchain/package.json b/packages/@n8n/nodes-langchain/package.json index 3a328af0ae880..d1478e1d22347 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.53.0", + "version": "1.54.0", "description": "", "main": "index.js", "scripts": { @@ -153,11 +153,11 @@ "@langchain/textsplitters": "0.0.3", "@mozilla/readability": "^0.5.0", "@n8n/typeorm": "0.3.20-10", - "@n8n/vm2": "3.9.24", + "@n8n/vm2": "3.9.25", "@pinecone-database/pinecone": "3.0.0", "@qdrant/js-client-rest": "1.9.0", "@supabase/supabase-js": "2.43.4", - "@types/pg": "^8.11.3", + "@types/pg": "^8.11.6", "@xata.io/client": "0.28.4", "basic-auth": "catalog:", "cheerio": "1.0.0-rc.12", @@ -176,7 +176,7 @@ "n8n-workflow": "workspace:*", "openai": "4.53.0", "pdf-parse": "1.1.1", - "pg": "8.11.3", + "pg": "8.12.0", "redis": "4.6.12", "sqlite3": "5.1.7", "temp": "0.9.4", diff --git a/packages/@n8n/nodes-langchain/utils/N8nTool.test.ts b/packages/@n8n/nodes-langchain/utils/N8nTool.test.ts new file mode 100644 index 0000000000000..6f12b18079551 --- /dev/null +++ b/packages/@n8n/nodes-langchain/utils/N8nTool.test.ts @@ -0,0 +1,169 @@ +import { N8nTool } from './N8nTool'; +import { createMockExecuteFunction } from 'n8n-nodes-base/test/nodes/Helpers'; +import { z } from 'zod'; +import type { INode } from 'n8n-workflow'; +import { DynamicStructuredTool, DynamicTool } from '@langchain/core/tools'; + +const mockNode: INode = { + id: '1', + name: 'Mock node', + typeVersion: 2, + type: 'n8n-nodes-base.mock', + position: [60, 760], + parameters: { + operation: 'test', + }, +}; + +describe('Test N8nTool wrapper as DynamicStructuredTool', () => { + it('should wrap a tool', () => { + const func = jest.fn(); + + const ctx = createMockExecuteFunction({}, mockNode); + + const tool = new N8nTool(ctx, { + name: 'Dummy Tool', + description: 'A dummy tool for testing', + func, + schema: z.object({ + foo: z.string(), + }), + }); + + expect(tool).toBeInstanceOf(DynamicStructuredTool); + }); +}); + +describe('Test N8nTool wrapper - DynamicTool fallback', () => { + it('should convert the tool to a dynamic tool', () => { + const func = jest.fn(); + + const ctx = createMockExecuteFunction({}, mockNode); + + const tool = new N8nTool(ctx, { + name: 'Dummy Tool', + description: 'A dummy tool for testing', + func, + schema: z.object({ + foo: z.string(), + }), + }); + + const dynamicTool = tool.asDynamicTool(); + + expect(dynamicTool).toBeInstanceOf(DynamicTool); + }); + + it('should format fallback description correctly', () => { + const func = jest.fn(); + + const ctx = createMockExecuteFunction({}, mockNode); + + const tool = new N8nTool(ctx, { + name: 'Dummy Tool', + description: 'A dummy tool for testing', + func, + schema: z.object({ + foo: z.string(), + bar: z.number().optional(), + qwe: z.boolean().describe('Boolean description'), + }), + }); + + const dynamicTool = tool.asDynamicTool(); + + expect(dynamicTool.description).toContain('foo: (description: , type: string, required: true)'); + expect(dynamicTool.description).toContain( + 'bar: (description: , type: number, required: false)', + ); + + expect(dynamicTool.description).toContain( + 'qwe: (description: Boolean description, type: boolean, required: true)', + ); + }); + + it('should handle empty parameter list correctly', () => { + const func = jest.fn(); + + const ctx = createMockExecuteFunction({}, mockNode); + + const tool = new N8nTool(ctx, { + name: 'Dummy Tool', + description: 'A dummy tool for testing', + func, + schema: z.object({}), + }); + + const dynamicTool = tool.asDynamicTool(); + + expect(dynamicTool.description).toEqual('A dummy tool for testing'); + }); + + it('should parse correct parameters', async () => { + const func = jest.fn(); + + const ctx = createMockExecuteFunction({}, mockNode); + + const tool = new N8nTool(ctx, { + name: 'Dummy Tool', + description: 'A dummy tool for testing', + func, + schema: z.object({ + foo: z.string().describe('Foo description'), + bar: z.number().optional(), + }), + }); + + const dynamicTool = tool.asDynamicTool(); + + const testParameters = { foo: 'some value' }; + + await dynamicTool.func(JSON.stringify(testParameters)); + + expect(func).toHaveBeenCalledWith(testParameters); + }); + + it('should recover when 1 parameter is passed directly', async () => { + const func = jest.fn(); + + const ctx = createMockExecuteFunction({}, mockNode); + + const tool = new N8nTool(ctx, { + name: 'Dummy Tool', + description: 'A dummy tool for testing', + func, + schema: z.object({ + foo: z.string().describe('Foo description'), + }), + }); + + const dynamicTool = tool.asDynamicTool(); + + const testParameter = 'some value'; + + await dynamicTool.func(testParameter); + + expect(func).toHaveBeenCalledWith({ foo: testParameter }); + }); + + it('should recover when JS object is passed instead of JSON', async () => { + const func = jest.fn(); + + const ctx = createMockExecuteFunction({}, mockNode); + + const tool = new N8nTool(ctx, { + name: 'Dummy Tool', + description: 'A dummy tool for testing', + func, + schema: z.object({ + foo: z.string().describe('Foo description'), + }), + }); + + const dynamicTool = tool.asDynamicTool(); + + await dynamicTool.func('{ foo: "some value" }'); + + expect(func).toHaveBeenCalledWith({ foo: 'some value' }); + }); +}); diff --git a/packages/@n8n/nodes-langchain/utils/N8nTool.ts b/packages/@n8n/nodes-langchain/utils/N8nTool.ts new file mode 100644 index 0000000000000..bb8bab08bd4fb --- /dev/null +++ b/packages/@n8n/nodes-langchain/utils/N8nTool.ts @@ -0,0 +1,113 @@ +import type { DynamicStructuredToolInput } from '@langchain/core/tools'; +import { DynamicStructuredTool, DynamicTool } from '@langchain/core/tools'; +import type { IExecuteFunctions, IDataObject } from 'n8n-workflow'; +import { NodeConnectionType, jsonParse, NodeOperationError } from 'n8n-workflow'; +import { StructuredOutputParser } from 'langchain/output_parsers'; +import type { ZodTypeAny } from 'zod'; +import { ZodBoolean, ZodNullable, ZodNumber, ZodObject, ZodOptional } from 'zod'; + +const getSimplifiedType = (schema: ZodTypeAny) => { + if (schema instanceof ZodObject) { + return 'object'; + } else if (schema instanceof ZodNumber) { + return 'number'; + } else if (schema instanceof ZodBoolean) { + return 'boolean'; + } else if (schema instanceof ZodNullable || schema instanceof ZodOptional) { + return getSimplifiedType(schema.unwrap()); + } + + return 'string'; +}; + +const getParametersDescription = (parameters: Array<[string, ZodTypeAny]>) => + parameters + .map( + ([name, schema]) => + `${name}: (description: ${schema.description ?? ''}, type: ${getSimplifiedType(schema)}, required: ${!schema.isOptional()})`, + ) + .join(',\n '); + +export const prepareFallbackToolDescription = (toolDescription: string, schema: ZodObject) => { + let description = `${toolDescription}`; + + const toolParameters = Object.entries(schema.shape); + + if (toolParameters.length) { + description += ` +Tool expects valid stringified JSON object with ${toolParameters.length} properties. +Property names with description, type and required status: +${getParametersDescription(toolParameters)} +ALL parameters marked as required must be provided`; + } + + return description; +}; + +export class N8nTool extends DynamicStructuredTool { + private context: IExecuteFunctions; + + constructor(context: IExecuteFunctions, fields: DynamicStructuredToolInput) { + super(fields); + + this.context = context; + } + + asDynamicTool(): DynamicTool { + const { name, func, schema, context, description } = this; + + const parser = new StructuredOutputParser(schema); + + const wrappedFunc = async function (query: string) { + let parsedQuery: object; + + // First we try to parse the query using the structured parser (Zod schema) + try { + parsedQuery = await parser.parse(query); + } catch (e) { + // If we were unable to parse the query using the schema, we try to gracefully handle it + let dataFromModel; + + try { + // First we try to parse a JSON with more relaxed rules + dataFromModel = jsonParse(query, { acceptJSObject: true }); + } catch (error) { + // In case of error, + // If model supplied a simple string instead of an object AND only one parameter expected, we try to recover the object structure + if (Object.keys(schema.shape).length === 1) { + const parameterName = Object.keys(schema.shape)[0]; + dataFromModel = { [parameterName]: query }; + } else { + // Finally throw an error if we were unable to parse the query + throw new NodeOperationError( + context.getNode(), + `Input is not a valid JSON: ${error.message}`, + ); + } + } + + // If we were able to parse the query with a fallback, we try to validate it using the schema + // Here we will throw an error if the data still does not match the schema + parsedQuery = schema.parse(dataFromModel); + } + + try { + // Call tool function with parsed query + const result = await func(parsedQuery); + + return result; + } catch (e) { + const { index } = context.addInputData(NodeConnectionType.AiTool, [[{ json: { query } }]]); + void context.addOutputData(NodeConnectionType.AiTool, index, e); + + return e.toString(); + } + }; + + return new DynamicTool({ + name, + description: prepareFallbackToolDescription(description, schema), + func: wrappedFunc, + }); + } +} diff --git a/packages/@n8n/nodes-langchain/utils/helpers.ts b/packages/@n8n/nodes-langchain/utils/helpers.ts index c6d27ee2f69f3..673fa0402c483 100644 --- a/packages/@n8n/nodes-langchain/utils/helpers.ts +++ b/packages/@n8n/nodes-langchain/utils/helpers.ts @@ -1,17 +1,19 @@ -import { NodeConnectionType, NodeOperationError, jsonStringify } from 'n8n-workflow'; import type { EventNamesAiNodesType, IDataObject, IExecuteFunctions, IWebhookFunctions, } from 'n8n-workflow'; +import { NodeConnectionType, NodeOperationError, jsonStringify } from 'n8n-workflow'; import type { BaseChatModel } from '@langchain/core/language_models/chat_models'; import type { BaseOutputParser } from '@langchain/core/output_parsers'; import type { BaseMessage } from '@langchain/core/messages'; -import { DynamicTool, type Tool } from '@langchain/core/tools'; +import type { Tool } from '@langchain/core/tools'; 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( @@ -178,7 +180,11 @@ export function serializeChatHistory(chatHistory: BaseMessage[]): string { .join('\n'); } -export const getConnectedTools = async (ctx: IExecuteFunctions, enforceUniqueNames: boolean) => { +export const getConnectedTools = async ( + ctx: IExecuteFunctions, + enforceUniqueNames: boolean, + convertStructuredTool: boolean = true, +) => { const connectedTools = ((await ctx.getInputConnectionData(NodeConnectionType.AiTool, 0)) as Tool[]) || []; @@ -186,8 +192,10 @@ export const getConnectedTools = async (ctx: IExecuteFunctions, enforceUniqueNam const seenNames = new Set(); + const finalTools = []; + for (const tool of connectedTools) { - if (!(tool instanceof DynamicTool)) continue; + if (!(tool instanceof DynamicTool) && !(tool instanceof N8nTool)) continue; const { name } = tool; if (seenNames.has(name)) { @@ -197,7 +205,13 @@ export const getConnectedTools = async (ctx: IExecuteFunctions, enforceUniqueNam ); } seenNames.add(name); + + if (convertStructuredTool && tool instanceof N8nTool) { + finalTools.push(tool.asDynamicTool()); + } else { + finalTools.push(tool); + } } - return connectedTools; + return finalTools; }; diff --git a/packages/@n8n/permissions/src/constants.ts b/packages/@n8n/permissions/src/constants.ts new file mode 100644 index 0000000000000..de027891c59e9 --- /dev/null +++ b/packages/@n8n/permissions/src/constants.ts @@ -0,0 +1,23 @@ +export const DEFAULT_OPERATIONS = ['create', 'read', 'update', 'delete', 'list'] as const; +export const RESOURCES = { + auditLogs: ['manage'] as const, + banner: ['dismiss'] as const, + communityPackage: ['install', 'uninstall', 'update', 'list', 'manage'] as const, + credential: ['share', 'move', ...DEFAULT_OPERATIONS] as const, + externalSecretsProvider: ['sync', ...DEFAULT_OPERATIONS] as const, + externalSecret: ['list', 'use'] as const, + eventBusDestination: ['test', ...DEFAULT_OPERATIONS] as const, + ldap: ['sync', 'manage'] as const, + license: ['manage'] as const, + logStreaming: ['manage'] as const, + orchestration: ['read', 'list'] as const, + project: [...DEFAULT_OPERATIONS] as const, + saml: ['manage'] as const, + securityAudit: ['generate'] as const, + sourceControl: ['pull', 'push', 'manage'] as const, + tag: [...DEFAULT_OPERATIONS] as const, + user: ['resetPassword', 'changeRole', ...DEFAULT_OPERATIONS] as const, + variable: [...DEFAULT_OPERATIONS] as const, + workersView: ['manage'] as const, + workflow: ['share', 'execute', 'move', ...DEFAULT_OPERATIONS] as const, +} as const; diff --git a/packages/@n8n/permissions/src/index.ts b/packages/@n8n/permissions/src/index.ts index 0d3e510abe67a..f04f2e4ef68ac 100644 --- a/packages/@n8n/permissions/src/index.ts +++ b/packages/@n8n/permissions/src/index.ts @@ -1,3 +1,4 @@ export type * from './types'; +export * from './constants'; export * from './hasScope'; export * from './combineScopes'; diff --git a/packages/@n8n/permissions/src/types.ts b/packages/@n8n/permissions/src/types.ts index 2720272e6fd75..bc714734d35aa 100644 --- a/packages/@n8n/permissions/src/types.ts +++ b/packages/@n8n/permissions/src/types.ts @@ -1,25 +1,7 @@ -export type DefaultOperations = 'create' | 'read' | 'update' | 'delete' | 'list'; -export type Resource = - | 'auditLogs' - | 'banner' - | 'communityPackage' - | 'credential' - | 'externalSecretsProvider' - | 'externalSecret' - | 'eventBusDestination' - | 'ldap' - | 'license' - | 'logStreaming' - | 'orchestration' - | 'project' - | 'saml' - | 'securityAudit' - | 'sourceControl' - | 'tag' - | 'user' - | 'variable' - | 'workersView' - | 'workflow'; +import type { DEFAULT_OPERATIONS, RESOURCES } from './constants'; + +export type DefaultOperations = (typeof DEFAULT_OPERATIONS)[number]; +export type Resource = keyof typeof RESOURCES; export type ResourceScope< R extends Resource, diff --git a/packages/@n8n_io/eslint-config/local-rules.js b/packages/@n8n_io/eslint-config/local-rules.js index c9b533a032e1e..09a9c00f0f852 100644 --- a/packages/@n8n_io/eslint-config/local-rules.js +++ b/packages/@n8n_io/eslint-config/local-rules.js @@ -182,12 +182,14 @@ module.exports = { messages: { removeSkip: 'Remove `.skip()` call', removeOnly: 'Remove `.only()` call', + removeXPrefix: 'Remove `x` prefix', }, fixable: 'code', }, create(context) { const TESTING_FUNCTIONS = new Set(['test', 'it', 'describe']); const SKIPPING_METHODS = new Set(['skip', 'only']); + const PREFIXED_TESTING_FUNCTIONS = new Set(['xtest', 'xit', 'xdescribe']); const toMessageId = (s) => 'remove' + s.charAt(0).toUpperCase() + s.slice(1); return { @@ -208,6 +210,18 @@ module.exports = { }); } }, + CallExpression(node) { + if ( + node.callee.type === 'Identifier' && + PREFIXED_TESTING_FUNCTIONS.has(node.callee.name) + ) { + context.report({ + messageId: 'removeXPrefix', + node, + fix: (fixer) => fixer.replaceText(node.callee, 'test'), + }); + } + }, }; }, }, @@ -448,6 +462,36 @@ module.exports = { }; }, }, + + 'no-type-unsafe-event-emitter': { + meta: { + type: 'problem', + docs: { + description: 'Disallow extending from `EventEmitter`, which is not type-safe.', + recommended: 'error', + }, + messages: { + noExtendsEventEmitter: 'Extend from the type-safe `TypedEmitter` class instead.', + }, + }, + create(context) { + return { + ClassDeclaration(node) { + if ( + node.superClass && + node.superClass.type === 'Identifier' && + node.superClass.name === 'EventEmitter' && + node.id.name !== 'TypedEmitter' + ) { + context.report({ + node: node.superClass, + messageId: 'noExtendsEventEmitter', + }); + } + }, + }; + }, + }, }; const isJsonParseCall = (node) => diff --git a/packages/cli/.eslintrc.js b/packages/cli/.eslintrc.js index ac66574887551..17ecfee499ae9 100644 --- a/packages/cli/.eslintrc.js +++ b/packages/cli/.eslintrc.js @@ -21,6 +21,7 @@ module.exports = { rules: { 'n8n-local-rules/no-dynamic-import-template': 'error', 'n8n-local-rules/misplaced-n8n-typeorm-import': 'error', + 'n8n-local-rules/no-type-unsafe-event-emitter': 'error', complexity: 'error', // TODO: Remove this @@ -39,11 +40,17 @@ module.exports = { overrides: [ { - files: ['./src/databases/**/*.ts', './test/**/*.ts'], + files: ['./src/databases/**/*.ts', './test/**/*.ts', './src/**/__tests__/**/*.ts'], rules: { 'n8n-local-rules/misplaced-n8n-typeorm-import': 'off', }, }, + { + files: ['./test/**/*.ts', './src/**/__tests__/**/*.ts'], + rules: { + 'n8n-local-rules/no-type-unsafe-event-emitter': 'off', + }, + }, { files: ['./src/decorators/**/*.ts'], rules: { diff --git a/packages/cli/BREAKING-CHANGES.md b/packages/cli/BREAKING-CHANGES.md index 4797def42288b..869ace642e35b 100644 --- a/packages/cli/BREAKING-CHANGES.md +++ b/packages/cli/BREAKING-CHANGES.md @@ -2,6 +2,16 @@ This list shows all the versions which include breaking changes and how to upgrade. +## 1.55.0 + +### What changed? + +The `N8N_BLOCK_FILE_ACCESS_TO_N8N_FILES` environment variable now also blocks access to n8n's static cache directory at `~/.cache/n8n/public`. + +### When is action necessary? + +If you are writing to or reading from a file at n8n's static cache directory via a node, e.g. `Read/Write Files from Disk`, please update your node to use a different path. + ## 1.52.0 ### What changed? diff --git a/packages/cli/README.md b/packages/cli/README.md index beeac69369b2f..526f4631676de 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -147,8 +147,4 @@ You can also find breaking changes here: [Breaking Changes](./BREAKING-CHANGES.m ## License -n8n is [fair-code](https://faircode.io) distributed under the [**Sustainable Use License**](https://github.com/n8n-io/n8n/blob/master/packages/cli/LICENSE.md). - -Proprietary licenses are available for enterprise customers. [Get in touch](mailto:license@n8n.io) - -Additional information about the license can be found in the [docs](https://docs.n8n.io/reference/license/). +You can find the license information [here](https://github.com/n8n-io/n8n/blob/master/README.md#license) diff --git a/packages/cli/package.json b/packages/cli/package.json index ca8f192e40152..d0f1fc34c3f83 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "n8n", - "version": "1.53.0", + "version": "1.54.0", "description": "n8n Workflow Automation Tool", "main": "dist/index", "types": "dist/index.d.ts", @@ -86,7 +86,7 @@ "@google-cloud/secret-manager": "^5.6.0", "@n8n/client-oauth2": "workspace:*", "@n8n/config": "workspace:*", - "@n8n/localtunnel": "2.1.0", + "@n8n/localtunnel": "3.0.0", "@n8n/n8n-nodes-langchain": "workspace:*", "@n8n/permissions": "workspace:*", "@n8n/typeorm": "0.3.20-10", @@ -113,7 +113,7 @@ "express": "4.19.2", "express-async-errors": "3.1.1", "express-handlebars": "7.1.2", - "express-openapi-validator": "5.1.6", + "express-openapi-validator": "5.3.1", "express-prom-bundle": "6.6.0", "express-rate-limit": "7.2.0", "fast-glob": "catalog:", @@ -131,7 +131,7 @@ "ldapts": "4.2.6", "lodash": "catalog:", "luxon": "catalog:", - "mysql2": "3.10.0", + "mysql2": "3.11.0", "n8n-core": "workspace:*", "n8n-editor-ui": "workspace:*", "n8n-nodes-base": "workspace:*", @@ -144,8 +144,8 @@ "otpauth": "9.1.1", "p-cancelable": "2.1.1", "p-lazy": "3.1.0", - "pg": "8.11.3", - "picocolors": "1.0.0", + "pg": "8.12.0", + "picocolors": "1.0.1", "pkce-challenge": "3.0.0", "posthog-node": "3.2.1", "prom-client": "13.2.0", diff --git a/packages/cli/src/AbstractServer.ts b/packages/cli/src/AbstractServer.ts index ab150e2947328..93ecb500c698c 100644 --- a/packages/cli/src/AbstractServer.ts +++ b/packages/cli/src/AbstractServer.ts @@ -13,15 +13,15 @@ import { N8nInstanceType } from '@/Interfaces'; import { ExternalHooks } from '@/ExternalHooks'; import { send, sendErrorResponse } from '@/ResponseHelper'; import { rawBodyReader, bodyParser, corsMiddleware } from '@/middlewares'; -import { TestWebhooks } from '@/TestWebhooks'; import { WaitingForms } from '@/WaitingForms'; -import { WaitingWebhooks } from '@/WaitingWebhooks'; -import { webhookRequestHandler } from '@/WebhookHelpers'; +import { TestWebhooks } from '@/webhooks/TestWebhooks'; +import { WaitingWebhooks } from '@/webhooks/WaitingWebhooks'; +import { createWebhookHandlerFor } from '@/webhooks/WebhookRequestHandler'; +import { LiveWebhooks } from '@/webhooks/LiveWebhooks'; import { generateHostInstanceId } from './databases/utils/generators'; import { Logger } from '@/Logger'; import { ServiceUnavailableError } from './errors/response-errors/service-unavailable.error'; import { OnShutdown } from '@/decorators/OnShutdown'; -import { ActiveWebhooks } from '@/ActiveWebhooks'; import { GlobalConfig } from '@n8n/config'; @Service() @@ -181,33 +181,32 @@ export abstract class AbstractServer { // Setup webhook handlers before bodyParser, to let the Webhook node handle binary data in requests if (this.webhooksEnabled) { - const activeWebhooks = Container.get(ActiveWebhooks); + const liveWebhooksRequestHandler = createWebhookHandlerFor(Container.get(LiveWebhooks)); + // Register a handler for live forms + this.app.all(`/${this.endpointForm}/:path(*)`, liveWebhooksRequestHandler); - // Register a handler for active forms - this.app.all(`/${this.endpointForm}/:path(*)`, webhookRequestHandler(activeWebhooks)); - - // Register a handler for active webhooks - this.app.all(`/${this.endpointWebhook}/:path(*)`, webhookRequestHandler(activeWebhooks)); + // Register a handler for live webhooks + this.app.all(`/${this.endpointWebhook}/:path(*)`, liveWebhooksRequestHandler); // Register a handler for waiting forms this.app.all( `/${this.endpointFormWaiting}/:path/:suffix?`, - webhookRequestHandler(Container.get(WaitingForms)), + createWebhookHandlerFor(Container.get(WaitingForms)), ); // Register a handler for waiting webhooks this.app.all( `/${this.endpointWebhookWaiting}/:path/:suffix?`, - webhookRequestHandler(Container.get(WaitingWebhooks)), + createWebhookHandlerFor(Container.get(WaitingWebhooks)), ); } if (this.testWebhooksEnabled) { - const testWebhooks = Container.get(TestWebhooks); + const testWebhooksRequestHandler = createWebhookHandlerFor(Container.get(TestWebhooks)); // Register a handler - this.app.all(`/${this.endpointFormTest}/:path(*)`, webhookRequestHandler(testWebhooks)); - this.app.all(`/${this.endpointWebhookTest}/:path(*)`, webhookRequestHandler(testWebhooks)); + this.app.all(`/${this.endpointFormTest}/:path(*)`, testWebhooksRequestHandler); + this.app.all(`/${this.endpointWebhookTest}/:path(*)`, testWebhooksRequestHandler); } // Block bots from scanning the application diff --git a/packages/cli/src/ActiveExecutions.ts b/packages/cli/src/ActiveExecutions.ts index 97313d5cb2f07..c1a6e8ffd65a9 100644 --- a/packages/cli/src/ActiveExecutions.ts +++ b/packages/cli/src/ActiveExecutions.ts @@ -12,6 +12,7 @@ import { ExecutionCancelledError, sleep, } from 'n8n-workflow'; +import { strict as assert } from 'node:assert'; import type { ExecutionPayload, @@ -74,9 +75,7 @@ export class ActiveExecutions { } executionId = await this.executionRepository.createNewExecution(fullExecutionData); - if (executionId === undefined) { - throw new ApplicationError('There was an issue assigning an execution id to the execution'); - } + assert(executionId); await this.concurrencyControl.throttle({ mode, executionId }); executionStatus = 'running'; diff --git a/packages/cli/src/ActiveWorkflowManager.ts b/packages/cli/src/ActiveWorkflowManager.ts index 1d5050e4d1ca5..fac6e9d9faa2e 100644 --- a/packages/cli/src/ActiveWorkflowManager.ts +++ b/packages/cli/src/ActiveWorkflowManager.ts @@ -27,7 +27,7 @@ import { } from 'n8n-workflow'; import type { IWorkflowDb } from '@/Interfaces'; -import * as WebhookHelpers from '@/WebhookHelpers'; +import * as WebhookHelpers from '@/webhooks/WebhookHelpers'; import * as WorkflowExecuteAdditionalData from '@/WorkflowExecuteAdditionalData'; import type { WorkflowEntity } from '@db/entities/WorkflowEntity'; @@ -40,7 +40,7 @@ import { } from '@/constants'; import { NodeTypes } from '@/NodeTypes'; import { ExternalHooks } from '@/ExternalHooks'; -import { WebhookService } from './services/webhook.service'; +import { WebhookService } from '@/webhooks/webhook.service'; import { Logger } from './Logger'; import { WorkflowRepository } from '@db/repositories/workflow.repository'; import { OrchestrationService } from '@/services/orchestration.service'; diff --git a/packages/cli/src/ErrorReporting.ts b/packages/cli/src/ErrorReporting.ts index ceb67fe7fb7cc..a8b25c891dbe2 100644 --- a/packages/cli/src/ErrorReporting.ts +++ b/packages/cli/src/ErrorReporting.ts @@ -69,7 +69,7 @@ export const initErrorHandling = async () => { if ( originalException instanceof QueryFailedError && - originalException.message.includes('SQLITE_FULL') + ['SQLITE_FULL', 'SQLITE_IOERR'].some((errMsg) => originalException.message.includes(errMsg)) ) { return null; } diff --git a/packages/cli/src/ExternalSecrets/ExternalSecretsManager.ee.ts b/packages/cli/src/ExternalSecrets/ExternalSecretsManager.ee.ts index 03436f3d7acaf..2ae33be62aa9e 100644 --- a/packages/cli/src/ExternalSecrets/ExternalSecretsManager.ee.ts +++ b/packages/cli/src/ExternalSecrets/ExternalSecretsManager.ee.ts @@ -13,7 +13,7 @@ import { Logger } from '@/Logger'; import { jsonParse, type IDataObject, ApplicationError } from 'n8n-workflow'; import { EXTERNAL_SECRETS_INITIAL_BACKOFF, EXTERNAL_SECRETS_MAX_BACKOFF } from './constants'; import { License } from '@/License'; -import { EventService } from '@/eventbus/event.service'; +import { EventService } from '@/events/event.service'; import { updateIntervalTime } from './externalSecretsHelper.ee'; import { ExternalSecretsProviders } from './ExternalSecretsProviders.ee'; import { OrchestrationService } from '@/services/orchestration.service'; diff --git a/packages/cli/test/unit/ExternalSecrets/ExternalSecretsManager.test.ts b/packages/cli/src/ExternalSecrets/__tests__/ExternalSecretsManager.ee.test.ts similarity index 95% rename from packages/cli/test/unit/ExternalSecrets/ExternalSecretsManager.test.ts rename to packages/cli/src/ExternalSecrets/__tests__/ExternalSecretsManager.ee.test.ts index cf72688d24397..7df8d6d323558 100644 --- a/packages/cli/test/unit/ExternalSecrets/ExternalSecretsManager.test.ts +++ b/packages/cli/src/ExternalSecrets/__tests__/ExternalSecretsManager.ee.test.ts @@ -5,14 +5,13 @@ import type { ExternalSecretsSettings } from '@/Interfaces'; import { License } from '@/License'; import { ExternalSecretsManager } from '@/ExternalSecrets/ExternalSecretsManager.ee'; import { ExternalSecretsProviders } from '@/ExternalSecrets/ExternalSecretsProviders.ee'; -import { InternalHooks } from '@/InternalHooks'; -import { mockInstance } from '../../shared/mocking'; +import { mockInstance } from '@test/mocking'; import { DummyProvider, ErrorProvider, FailedProvider, MockProviders, -} from '../../shared/ExternalSecrets/utils'; +} from '@test/ExternalSecrets/utils'; import { mock } from 'jest-mock-extended'; describe('External Secrets Manager', () => { @@ -22,7 +21,6 @@ describe('External Secrets Manager', () => { const mockProvidersInstance = new MockProviders(); const license = mockInstance(License); const settingsRepo = mockInstance(SettingsRepository); - mockInstance(InternalHooks); const cipher = Container.get(Cipher); let providersMock: ExternalSecretsProviders; diff --git a/packages/cli/src/GenericHelpers.ts b/packages/cli/src/GenericHelpers.ts index 13762e5dfd672..300f24f9f9355 100644 --- a/packages/cli/src/GenericHelpers.ts +++ b/packages/cli/src/GenericHelpers.ts @@ -1,10 +1,15 @@ -import { validate } from 'class-validator'; +import { ValidationError, validate } from 'class-validator'; import type { WorkflowEntity } from '@db/entities/WorkflowEntity'; import type { CredentialsEntity } from '@db/entities/CredentialsEntity'; import type { TagEntity } from '@db/entities/TagEntity'; import type { User } from '@db/entities/User'; -import type { UserRoleChangePayload, UserUpdatePayload } from '@/requests'; +import type { + UserRoleChangePayload, + UserSettingsUpdatePayload, + UserUpdatePayload, +} from '@/requests'; import { BadRequestError } from './errors/response-errors/bad-request.error'; +import { NoXss } from './databases/utils/customValidators'; export async function validateEntity( entity: @@ -13,7 +18,8 @@ export async function validateEntity( | TagEntity | User | UserUpdatePayload - | UserRoleChangePayload, + | UserRoleChangePayload + | UserSettingsUpdatePayload, ): Promise { const errors = await validate(entity); @@ -31,3 +37,37 @@ export async function validateEntity( } export const DEFAULT_EXECUTIONS_GET_ALL_LIMIT = 20; + +class StringWithNoXss { + @NoXss() + value: string; + + constructor(value: string) { + this.value = value; + } +} + +// Temporary solution until we implement payload validation middleware +export async function validateRecordNoXss(record: Record) { + const errors: ValidationError[] = []; + + for (const [key, value] of Object.entries(record)) { + const stringWithNoXss = new StringWithNoXss(value); + const validationErrors = await validate(stringWithNoXss); + + if (validationErrors.length > 0) { + const error = new ValidationError(); + error.property = key; + error.constraints = validationErrors[0].constraints; + errors.push(error); + } + } + + if (errors.length > 0) { + const errorMessages = errors + .map((error) => `${error.property}: ${Object.values(error.constraints ?? {}).join(', ')}`) + .join(' | '); + + throw new BadRequestError(errorMessages); + } +} diff --git a/packages/cli/src/Interfaces.ts b/packages/cli/src/Interfaces.ts index 25f758185aea9..73b1e99ec6559 100644 --- a/packages/cli/src/Interfaces.ts +++ b/packages/cli/src/Interfaces.ts @@ -1,4 +1,4 @@ -import type { Application, Request, Response } from 'express'; +import type { Application } from 'express'; import type { ExecutionError, ICredentialDataDecryptedObject, @@ -22,7 +22,6 @@ import type { FeatureFlags, INodeProperties, IUserSettings, - IHttpRequestMethods, StartNodeData, } from 'n8n-workflow'; @@ -43,7 +42,7 @@ import type { WorkflowRepository } from '@db/repositories/workflow.repository'; import type { ExternalHooks } from './ExternalHooks'; import type { LICENSE_FEATURES, LICENSE_QUOTAS } from './constants'; import type { WorkflowWithSharingsAndCredentials } from './workflows/workflows.types'; -import type { WorkerJobStatusSummary } from './services/orchestration/worker/types'; +import type { RunningJobSummary } from './scaling/types'; import type { Scope } from '@n8n/permissions'; export interface ICredentialsTypeData { @@ -239,42 +238,6 @@ export interface IExternalHooksFunctions { }; } -export type WebhookCORSRequest = Request & { method: 'OPTIONS' }; - -export type WebhookRequest = Request<{ path: string }> & { - method: IHttpRequestMethods; - params: Record; -}; - -export type WaitingWebhookRequest = WebhookRequest & { - params: WebhookRequest['path'] & { suffix?: string }; -}; - -export interface WebhookAccessControlOptions { - allowedOrigins?: string; -} - -export interface IWebhookManager { - /** Gets all request methods associated with a webhook path*/ - getWebhookMethods?: (path: string) => Promise; - - /** Find the CORS options matching a path and method */ - findAccessControlOptions?: ( - path: string, - httpMethod: IHttpRequestMethods, - ) => Promise; - - executeWebhook(req: WebhookRequest, res: Response): Promise; -} - -export interface ITelemetryUserDeletionData { - user_id: string; - target_user_old_status: 'active' | 'invited'; - migration_strategy?: 'transfer_data' | 'delete_data'; - target_user_id?: string; - migration_user_id?: string; -} - export interface IVersionNotificationSettings { enabled: boolean; endpoint: string; @@ -457,7 +420,7 @@ export interface IPushDataWorkerStatusMessage { export interface IPushDataWorkerStatusPayload { workerId: string; - runningJobsSummary: WorkerJobStatusSummary[]; + runningJobsSummary: RunningJobSummary[]; freeMem: number; totalMem: number; uptime: number; @@ -474,13 +437,6 @@ export interface IPushDataWorkerStatusPayload { version: string; } -export interface IResponseCallbackData { - data?: IDataObject | IDataObject[]; - headers?: object; - noWebhookResponse?: boolean; - responseCode?: number; -} - export interface INodesTypeData { [key: string]: { className: string; diff --git a/packages/cli/src/InternalHooks.ts b/packages/cli/src/InternalHooks.ts index fda2c3f21dfc7..b3d45b67c0acd 100644 --- a/packages/cli/src/InternalHooks.ts +++ b/packages/cli/src/InternalHooks.ts @@ -1,374 +1,24 @@ import { Service } from 'typedi'; -import { snakeCase } from 'change-case'; -import { get as pslGet } from 'psl'; -import type { - ExecutionStatus, - INodesGraphResult, - IRun, - ITelemetryTrackProperties, - IWorkflowBase, -} from 'n8n-workflow'; -import { TelemetryHelpers } from 'n8n-workflow'; - -import { N8N_VERSION } from '@/constants'; -import type { AuthProviderType } from '@db/entities/AuthIdentity'; -import type { User } from '@db/entities/User'; -import { SharedWorkflowRepository } from '@db/repositories/sharedWorkflow.repository'; -import { determineFinalExecutionStatus } from '@/executionLifecycleHooks/shared/sharedHookFunctions'; -import type { ITelemetryUserDeletionData, IExecutionTrackProperties } from '@/Interfaces'; -import { WorkflowStatisticsService } from '@/services/workflow-statistics.service'; -import { NodeTypes } from '@/NodeTypes'; import { Telemetry } from '@/telemetry'; import { MessageEventBus } from './eventbus/MessageEventBus/MessageEventBus'; /** - * @deprecated Do not add to this class. To add audit or telemetry events, use - * `EventService` to emit the event and then use the `AuditEventRelay` or + * @deprecated Do not add to this class. It will be removed once we remove + * further dep cycles. To add log streaming or telemetry events, use + * `EventService` to emit the event and then use the `LogStreamingEventRelay` or * `TelemetryEventRelay` to forward them to the event bus or telemetry. */ @Service() export class InternalHooks { constructor( private readonly telemetry: Telemetry, - private readonly nodeTypes: NodeTypes, - private readonly sharedWorkflowRepository: SharedWorkflowRepository, - workflowStatisticsService: WorkflowStatisticsService, // Can't use @ts-expect-error because only dev time tsconfig considers this as an error, but not build time // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore - needed until we decouple telemetry private readonly _eventBus: MessageEventBus, // needed until we decouple telemetry - ) { - workflowStatisticsService.on('telemetry.onFirstProductionWorkflowSuccess', (metrics) => - this.onFirstProductionWorkflowSuccess(metrics), - ); - workflowStatisticsService.on('telemetry.onFirstWorkflowDataLoad', (metrics) => - this.onFirstWorkflowDataLoad(metrics), - ); - } + ) {} async init() { await this.telemetry.init(); } - - onFrontendSettingsAPI(pushRef?: string): void { - this.telemetry.track('Session started', { session_id: pushRef }); - } - - onPersonalizationSurveySubmitted(userId: string, answers: Record): void { - const camelCaseKeys = Object.keys(answers); - const personalizationSurveyData = { user_id: userId } as Record; - camelCaseKeys.forEach((camelCaseKey) => { - personalizationSurveyData[snakeCase(camelCaseKey)] = answers[camelCaseKey]; - }); - - this.telemetry.track('User responded to personalization questions', personalizationSurveyData); - } - - // eslint-disable-next-line complexity - async onWorkflowPostExecute( - _executionId: string, - workflow: IWorkflowBase, - runData?: IRun, - userId?: string, - ) { - if (!workflow.id) { - return; - } - - if (runData?.status === 'waiting') { - // No need to send telemetry or logs when the workflow hasn't finished yet. - return; - } - - const telemetryProperties: IExecutionTrackProperties = { - workflow_id: workflow.id, - is_manual: false, - version_cli: N8N_VERSION, - success: false, - }; - - if (userId) { - telemetryProperties.user_id = userId; - } - - if (runData?.data.resultData.error?.message?.includes('canceled')) { - runData.status = 'canceled'; - } - - telemetryProperties.success = !!runData?.finished; - - // const executionStatus: ExecutionStatus = runData?.status ?? 'unknown'; - const executionStatus: ExecutionStatus = runData - ? determineFinalExecutionStatus(runData) - : 'unknown'; - - if (runData !== undefined) { - telemetryProperties.execution_mode = runData.mode; - telemetryProperties.is_manual = runData.mode === 'manual'; - - let nodeGraphResult: INodesGraphResult | null = null; - - if (!telemetryProperties.success && runData?.data.resultData.error) { - telemetryProperties.error_message = runData?.data.resultData.error.message; - let errorNodeName = - 'node' in runData?.data.resultData.error - ? runData?.data.resultData.error.node?.name - : undefined; - telemetryProperties.error_node_type = - 'node' in runData?.data.resultData.error - ? runData?.data.resultData.error.node?.type - : undefined; - - if (runData.data.resultData.lastNodeExecuted) { - const lastNode = TelemetryHelpers.getNodeTypeForName( - workflow, - runData.data.resultData.lastNodeExecuted, - ); - - if (lastNode !== undefined) { - telemetryProperties.error_node_type = lastNode.type; - errorNodeName = lastNode.name; - } - } - - if (telemetryProperties.is_manual) { - nodeGraphResult = TelemetryHelpers.generateNodesGraph(workflow, this.nodeTypes); - telemetryProperties.node_graph = nodeGraphResult.nodeGraph; - telemetryProperties.node_graph_string = JSON.stringify(nodeGraphResult.nodeGraph); - - if (errorNodeName) { - telemetryProperties.error_node_id = nodeGraphResult.nameIndices[errorNodeName]; - } - } - } - - if (telemetryProperties.is_manual) { - if (!nodeGraphResult) { - nodeGraphResult = TelemetryHelpers.generateNodesGraph(workflow, this.nodeTypes); - } - - let userRole: 'owner' | 'sharee' | undefined = undefined; - if (userId) { - const role = await this.sharedWorkflowRepository.findSharingRole(userId, workflow.id); - if (role) { - userRole = role === 'workflow:owner' ? 'owner' : 'sharee'; - } - } - - const manualExecEventProperties: ITelemetryTrackProperties = { - user_id: userId, - workflow_id: workflow.id, - status: executionStatus, - executionStatus: runData?.status ?? 'unknown', - error_message: telemetryProperties.error_message as string, - error_node_type: telemetryProperties.error_node_type, - node_graph_string: telemetryProperties.node_graph_string as string, - error_node_id: telemetryProperties.error_node_id as string, - webhook_domain: null, - sharing_role: userRole, - }; - - if (!manualExecEventProperties.node_graph_string) { - nodeGraphResult = TelemetryHelpers.generateNodesGraph(workflow, this.nodeTypes); - manualExecEventProperties.node_graph_string = JSON.stringify(nodeGraphResult.nodeGraph); - } - - if (runData.data.startData?.destinationNode) { - const telemetryPayload = { - ...manualExecEventProperties, - node_type: TelemetryHelpers.getNodeTypeForName( - workflow, - runData.data.startData?.destinationNode, - )?.type, - node_id: nodeGraphResult.nameIndices[runData.data.startData?.destinationNode], - }; - - this.telemetry.track('Manual node exec finished', telemetryPayload); - } else { - nodeGraphResult.webhookNodeNames.forEach((name: string) => { - const execJson = runData.data.resultData.runData[name]?.[0]?.data?.main?.[0]?.[0] - ?.json as { headers?: { origin?: string } }; - if (execJson?.headers?.origin && execJson.headers.origin !== '') { - manualExecEventProperties.webhook_domain = pslGet( - execJson.headers.origin.replace(/^https?:\/\//, ''), - ); - } - }); - - this.telemetry.track('Manual workflow exec finished', manualExecEventProperties); - } - } - } - - this.telemetry.trackWorkflowExecution(telemetryProperties); - } - - onWorkflowSharingUpdate(workflowId: string, userId: string, userList: string[]) { - const properties: ITelemetryTrackProperties = { - workflow_id: workflowId, - user_id_sharer: userId, - user_id_list: userList, - }; - - this.telemetry.track('User updated workflow sharing', properties); - } - - async onN8nStop(): Promise { - const timeoutPromise = new Promise((resolve) => { - setTimeout(resolve, 3000); - }); - - return await Promise.race([timeoutPromise, this.telemetry.trackN8nStop()]); - } - - onUserDeletion(userDeletionData: { - user: User; - telemetryData: ITelemetryUserDeletionData; - publicApi: boolean; - }) { - this.telemetry.track('User deleted user', { - ...userDeletionData.telemetryData, - user_id: userDeletionData.user.id, - public_api: userDeletionData.publicApi, - }); - } - - onUserInvite(userInviteData: { - user: User; - target_user_id: string[]; - public_api: boolean; - email_sent: boolean; - invitee_role: string; - }) { - this.telemetry.track('User invited new user', { - user_id: userInviteData.user.id, - target_user_id: userInviteData.target_user_id, - public_api: userInviteData.public_api, - email_sent: userInviteData.email_sent, - invitee_role: userInviteData.invitee_role, - }); - } - - onUserRoleChange(userRoleChangeData: { - user: User; - target_user_id: string; - public_api: boolean; - target_user_new_role: string; - }) { - const { user, ...rest } = userRoleChangeData; - - this.telemetry.track('User changed role', { user_id: user.id, ...rest }); - } - - onUserRetrievedUser(userRetrievedData: { user_id: string; public_api: boolean }) { - this.telemetry.track('User retrieved user', userRetrievedData); - } - - onUserRetrievedAllUsers(userRetrievedData: { user_id: string; public_api: boolean }) { - this.telemetry.track('User retrieved all users', userRetrievedData); - } - - onUserRetrievedExecution(userRetrievedData: { user_id: string; public_api: boolean }) { - this.telemetry.track('User retrieved execution', userRetrievedData); - } - - onUserRetrievedAllExecutions(userRetrievedData: { user_id: string; public_api: boolean }) { - this.telemetry.track('User retrieved all executions', userRetrievedData); - } - - onUserRetrievedWorkflow(userRetrievedData: { user_id: string; public_api: boolean }) { - this.telemetry.track('User retrieved workflow', userRetrievedData); - } - - onUserRetrievedAllWorkflows(userRetrievedData: { user_id: string; public_api: boolean }) { - this.telemetry.track('User retrieved all workflows', userRetrievedData); - } - - onUserUpdate(userUpdateData: { user: User; fields_changed: string[] }) { - this.telemetry.track('User changed personal settings', { - user_id: userUpdateData.user.id, - fields_changed: userUpdateData.fields_changed, - }); - } - - onUserInviteEmailClick(userInviteClickData: { inviter: User; invitee: User }) { - this.telemetry.track('User clicked invite link from email', { - user_id: userInviteClickData.invitee.id, - }); - } - - onUserPasswordResetEmailClick(userPasswordResetData: { user: User }) { - this.telemetry.track('User clicked password reset link from email', { - user_id: userPasswordResetData.user.id, - }); - } - - onUserTransactionalEmail(userTransactionalEmailData: { - user_id: string; - message_type: - | 'Reset password' - | 'New user invite' - | 'Resend invite' - | 'Workflow shared' - | 'Credentials shared'; - public_api: boolean; - }) { - this.telemetry.track('Instance sent transactional email to user', userTransactionalEmailData); - } - - onUserPasswordResetRequestClick(userPasswordResetData: { user: User }) { - this.telemetry.track('User requested password reset while logged out', { - user_id: userPasswordResetData.user.id, - }); - } - - onInstanceOwnerSetup(instanceOwnerSetupData: { user_id: string }) { - this.telemetry.track('Owner finished instance setup', instanceOwnerSetupData); - } - - onUserSignup( - user: User, - userSignupData: { - user_type: AuthProviderType; - was_disabled_ldap_user: boolean; - }, - ) { - this.telemetry.track('User signed up', { - user_id: user.id, - ...userSignupData, - }); - } - - onEmailFailed(failedEmailData: { - user: User; - message_type: - | 'Reset password' - | 'New user invite' - | 'Resend invite' - | 'Workflow shared' - | 'Credentials shared'; - public_api: boolean; - }) { - this.telemetry.track('Instance failed to send transactional email to user', { - user_id: failedEmailData.user.id, - }); - } - - /* - * Execution Statistics - */ - onFirstProductionWorkflowSuccess(data: { user_id: string; workflow_id: string }) { - this.telemetry.track('Workflow first prod success', data); - } - - onFirstWorkflowDataLoad(data: { - user_id: string; - workflow_id: string; - node_type: string; - node_id: string; - credential_type?: string; - credential_id?: string; - }) { - this.telemetry.track('Workflow first data fetched', data); - } } diff --git a/packages/cli/test/unit/Ldap/helpers.test.ts b/packages/cli/src/Ldap/__tests__/helpers.test.ts similarity index 96% rename from packages/cli/test/unit/Ldap/helpers.test.ts rename to packages/cli/src/Ldap/__tests__/helpers.test.ts index debb96da382d1..719adea76c5a7 100644 --- a/packages/cli/test/unit/Ldap/helpers.test.ts +++ b/packages/cli/src/Ldap/__tests__/helpers.test.ts @@ -1,5 +1,5 @@ import { UserRepository } from '@/databases/repositories/user.repository'; -import { mockInstance } from '../../shared/mocking'; +import { mockInstance } from '@test/mocking'; import * as helpers from '@/Ldap/helpers.ee'; import { AuthIdentity } from '@/databases/entities/AuthIdentity'; import { User } from '@/databases/entities/User'; diff --git a/packages/cli/src/Ldap/ldap.controller.ee.ts b/packages/cli/src/Ldap/ldap.controller.ee.ts index 9bdbea39b2d99..7a56d8049d9ca 100644 --- a/packages/cli/src/Ldap/ldap.controller.ee.ts +++ b/packages/cli/src/Ldap/ldap.controller.ee.ts @@ -6,7 +6,7 @@ import { NON_SENSIBLE_LDAP_CONFIG_PROPERTIES } from './constants'; import { getLdapSynchronizations } from './helpers.ee'; import { LdapConfiguration } from './types'; import { LdapService } from './ldap.service.ee'; -import { EventService } from '@/eventbus/event.service'; +import { EventService } from '@/events/event.service'; @RestController('/ldap') export class LdapController { diff --git a/packages/cli/src/Ldap/ldap.service.ee.ts b/packages/cli/src/Ldap/ldap.service.ee.ts index 85c8c6c636ea6..32c3152fb5f47 100644 --- a/packages/cli/src/Ldap/ldap.service.ee.ts +++ b/packages/cli/src/Ldap/ldap.service.ee.ts @@ -44,7 +44,7 @@ import { LDAP_LOGIN_ENABLED, LDAP_LOGIN_LABEL, } from './constants'; -import { EventService } from '@/eventbus/event.service'; +import { EventService } from '@/events/event.service'; @Service() export class LdapService { diff --git a/packages/cli/src/License.ts b/packages/cli/src/License.ts index 9a2740ce7cbb7..fc0d2cd45a235 100644 --- a/packages/cli/src/License.ts +++ b/packages/cli/src/License.ts @@ -55,7 +55,7 @@ export class License { * This ensures the mains do not cause a 429 (too many requests) on license init. */ if (config.getEnv('multiMainSetup.enabled')) { - return autoRenewEnabled && config.getEnv('instanceRole') === 'leader'; + return autoRenewEnabled && this.instanceSettings.isLeader; } return autoRenewEnabled; @@ -305,6 +305,10 @@ export class License { return this.isFeatureEnabled(LICENSE_FEATURES.PROJECT_ROLE_VIEWER); } + isCustomNpmRegistryEnabled() { + return this.isFeatureEnabled(LICENSE_FEATURES.COMMUNITY_NODES_CUSTOM_REGISTRY); + } + getCurrentEntitlements() { return this.manager?.getCurrentEntitlements() ?? []; } diff --git a/packages/cli/src/Mfa/mfa.service.ts b/packages/cli/src/Mfa/mfa.service.ts index 91270cf4ab27d..aa6fddb5a93ce 100644 --- a/packages/cli/src/Mfa/mfa.service.ts +++ b/packages/cli/src/Mfa/mfa.service.ts @@ -3,6 +3,7 @@ import { Service } from 'typedi'; import { Cipher } from 'n8n-core'; import { AuthUserRepository } from '@db/repositories/authUser.repository'; import { TOTPService } from './totp.service'; +import { InvalidMfaCodeError } from '@/errors/response-errors/invalid-mfa-code.error'; @Service() export class MfaService { @@ -60,7 +61,9 @@ export class MfaService { if (mfaToken) { const decryptedSecret = this.cipher.decrypt(user.mfaSecret!); return this.totp.verifySecret({ secret: decryptedSecret, token: mfaToken }); - } else if (mfaRecoveryCode) { + } + + if (mfaRecoveryCode) { const validCodes = user.mfaRecoveryCodes.map((code) => this.cipher.decrypt(code)); const index = validCodes.indexOf(mfaRecoveryCode); if (index === -1) return false; @@ -70,6 +73,7 @@ export class MfaService { await this.authUserRepository.save(user); return true; } + return false; } @@ -79,11 +83,16 @@ export class MfaService { return await this.authUserRepository.save(user); } - async disableMfa(userId: string) { - const user = await this.authUserRepository.findOneByOrFail({ id: userId }); - user.mfaEnabled = false; - user.mfaSecret = null; - user.mfaRecoveryCodes = []; - return await this.authUserRepository.save(user); + async disableMfa(userId: string, mfaToken: string) { + const isValidToken = await this.validateMfa(userId, mfaToken, undefined); + if (!isValidToken) { + throw new InvalidMfaCodeError(); + } + + await this.authUserRepository.update(userId, { + mfaEnabled: false, + mfaSecret: null, + mfaRecoveryCodes: [], + }); } } diff --git a/packages/cli/src/PublicApi/index.ts b/packages/cli/src/PublicApi/index.ts index d484a34e36941..af4fc97fc7907 100644 --- a/packages/cli/src/PublicApi/index.ts +++ b/packages/cli/src/PublicApi/index.ts @@ -15,7 +15,7 @@ import { UserRepository } from '@db/repositories/user.repository'; import { UrlService } from '@/services/url.service'; import type { AuthenticatedRequest } from '@/requests'; import { GlobalConfig } from '@n8n/config'; -import { EventService } from '@/eventbus/event.service'; +import { EventService } from '@/events/event.service'; async function createApiRouter( version: string, diff --git a/packages/cli/src/PublicApi/types.ts b/packages/cli/src/PublicApi/types.ts index f58b521e04a88..631a8c09d7236 100644 --- a/packages/cli/src/PublicApi/types.ts +++ b/packages/cli/src/PublicApi/types.ts @@ -29,6 +29,7 @@ export declare namespace ExecutionRequest { includeData?: boolean; workflowId?: string; lastId?: string; + projectId?: string; } >; @@ -142,6 +143,12 @@ export declare namespace CredentialRequest { >; type Delete = AuthenticatedRequest<{ id: string }, {}, {}, Record>; + + type Transfer = AuthenticatedRequest< + { workflowId: string }, + {}, + { destinationProjectId: string } + >; } export type OperationID = 'getUsers' | 'getUser'; diff --git a/packages/cli/src/PublicApi/v1/handlers/credentials/credentials.handler.ts b/packages/cli/src/PublicApi/v1/handlers/credentials/credentials.handler.ts index 4da7635831340..57b6bd66f6841 100644 --- a/packages/cli/src/PublicApi/v1/handlers/credentials/credentials.handler.ts +++ b/packages/cli/src/PublicApi/v1/handlers/credentials/credentials.handler.ts @@ -19,6 +19,8 @@ import { toJsonSchema, } from './credentials.service'; import { Container } from 'typedi'; +import { z } from 'zod'; +import { EnterpriseCredentialsService } from '@/credentials/credentials.service.ee'; export = { createCredential: [ @@ -44,6 +46,20 @@ export = { } }, ], + transferCredential: [ + projectScope('credential:move', 'credential'), + async (req: CredentialRequest.Transfer, res: express.Response) => { + const body = z.object({ destinationProjectId: z.string() }).parse(req.body); + + await Container.get(EnterpriseCredentialsService).transferOne( + req.user, + req.params.workflowId, + body.destinationProjectId, + ); + + res.status(204).send(); + }, + ], deleteCredential: [ projectScope('credential:delete', 'credential'), async ( diff --git a/packages/cli/src/PublicApi/v1/handlers/credentials/credentials.service.ts b/packages/cli/src/PublicApi/v1/handlers/credentials/credentials.service.ts index 668424530a516..2c4a35a6aa177 100644 --- a/packages/cli/src/PublicApi/v1/handlers/credentials/credentials.service.ts +++ b/packages/cli/src/PublicApi/v1/handlers/credentials/credentials.service.ts @@ -17,7 +17,7 @@ import { Container } from 'typedi'; import { CredentialsRepository } from '@db/repositories/credentials.repository'; import { SharedCredentialsRepository } from '@db/repositories/sharedCredentials.repository'; import { ProjectRepository } from '@/databases/repositories/project.repository'; -import { EventService } from '@/eventbus/event.service'; +import { EventService } from '@/events/event.service'; export async function getCredentials(credentialId: string): Promise { return await Container.get(CredentialsRepository).findOneBy({ id: credentialId }); diff --git a/packages/cli/src/PublicApi/v1/handlers/credentials/spec/paths/credentials.id.transfer.yml b/packages/cli/src/PublicApi/v1/handlers/credentials/spec/paths/credentials.id.transfer.yml new file mode 100644 index 0000000000000..a9e9c5cf7c229 --- /dev/null +++ b/packages/cli/src/PublicApi/v1/handlers/credentials/spec/paths/credentials.id.transfer.yml @@ -0,0 +1,31 @@ +put: + x-eov-operation-id: transferCredential + x-eov-operation-handler: v1/handlers/credentials/credentials.handler + tags: + - Workflow + summary: Transfer a credential to another project. + description: Transfer a credential to another project. + parameters: + - $ref: '../schemas/parameters/credentialId.yml' + requestBody: + description: Destination project for the credential transfer. + content: + application/json: + schema: + type: object + properties: + destinationProjectId: + type: string + description: The ID of the project to transfer the credential to. + required: + - destinationProjectId + required: true + responses: + '200': + description: Operation successful. + '400': + $ref: '../../../../shared/spec/responses/badRequest.yml' + '401': + $ref: '../../../../shared/spec/responses/unauthorized.yml' + '404': + $ref: '../../../../shared/spec/responses/notFound.yml' diff --git a/packages/cli/src/PublicApi/v1/handlers/credentials/spec/schemas/parameters/credentialId.yml b/packages/cli/src/PublicApi/v1/handlers/credentials/spec/schemas/parameters/credentialId.yml new file mode 100644 index 0000000000000..f16676ce0b96c --- /dev/null +++ b/packages/cli/src/PublicApi/v1/handlers/credentials/spec/schemas/parameters/credentialId.yml @@ -0,0 +1,6 @@ +name: id +in: path +description: The ID of the credential. +required: true +schema: + type: string diff --git a/packages/cli/src/PublicApi/v1/handlers/executions/executions.handler.ts b/packages/cli/src/PublicApi/v1/handlers/executions/executions.handler.ts index 16ca8093cb325..ab6927724c1aa 100644 --- a/packages/cli/src/PublicApi/v1/handlers/executions/executions.handler.ts +++ b/packages/cli/src/PublicApi/v1/handlers/executions/executions.handler.ts @@ -7,7 +7,7 @@ import { validCursor } from '../../shared/middlewares/global.middleware'; import type { ExecutionRequest } from '../../../types'; import { getSharedWorkflowIds } from '../workflows/workflows.service'; import { encodeNextCursor } from '../../shared/services/pagination.service'; -import { InternalHooks } from '@/InternalHooks'; +import { EventService } from '@/events/event.service'; import { ExecutionRepository } from '@db/repositories/execution.repository'; import { ConcurrencyControlService } from '@/concurrency/concurrency-control.service'; @@ -78,9 +78,9 @@ export = { return res.status(404).json({ message: 'Not Found' }); } - Container.get(InternalHooks).onUserRetrievedExecution({ - user_id: req.user.id, - public_api: true, + Container.get(EventService).emit('user-retrieved-execution', { + userId: req.user.id, + publicApi: true, }); return res.json(replaceCircularReferences(execution)); @@ -95,9 +95,10 @@ export = { status = undefined, includeData = false, workflowId = undefined, + projectId, } = req.query; - const sharedWorkflowsIds = await getSharedWorkflowIds(req.user, ['workflow:read']); + const sharedWorkflowsIds = await getSharedWorkflowIds(req.user, ['workflow:read'], projectId); // user does not have workflows hence no executions // or the execution they are trying to access belongs to a workflow they do not own @@ -129,9 +130,9 @@ export = { const count = await Container.get(ExecutionRepository).getExecutionsCountForPublicApi(filters); - Container.get(InternalHooks).onUserRetrievedAllExecutions({ - user_id: req.user.id, - public_api: true, + Container.get(EventService).emit('user-retrieved-all-executions', { + userId: req.user.id, + publicApi: true, }); return res.json({ diff --git a/packages/cli/src/PublicApi/v1/handlers/executions/spec/paths/executions.yml b/packages/cli/src/PublicApi/v1/handlers/executions/spec/paths/executions.yml index 8d26492ec30bf..6fcdaf356e4bb 100644 --- a/packages/cli/src/PublicApi/v1/handlers/executions/spec/paths/executions.yml +++ b/packages/cli/src/PublicApi/v1/handlers/executions/spec/paths/executions.yml @@ -21,6 +21,14 @@ get: schema: type: string example: '1000' + - name: projectId + in: query + required: false + explode: false + allowReserved: true + schema: + type: string + example: VmwOO9HeTEj20kxM - $ref: '../../../../shared/spec/parameters/limit.yml' - $ref: '../../../../shared/spec/parameters/cursor.yml' responses: diff --git a/packages/cli/src/PublicApi/v1/handlers/projects/projects.handler.ts b/packages/cli/src/PublicApi/v1/handlers/projects/projects.handler.ts new file mode 100644 index 0000000000000..e61e808daf301 --- /dev/null +++ b/packages/cli/src/PublicApi/v1/handlers/projects/projects.handler.ts @@ -0,0 +1,65 @@ +import { globalScope, isLicensed, validCursor } from '../../shared/middlewares/global.middleware'; +import type { Response } from 'express'; +import type { ProjectRequest } from '@/requests'; +import type { PaginatedRequest } from '@/PublicApi/types'; +import Container from 'typedi'; +import { ProjectController } from '@/controllers/project.controller'; +import { ProjectRepository } from '@/databases/repositories/project.repository'; +import { encodeNextCursor } from '../../shared/services/pagination.service'; + +type Create = ProjectRequest.Create; +type Update = ProjectRequest.Update; +type Delete = ProjectRequest.Delete; +type GetAll = PaginatedRequest; + +export = { + createProject: [ + isLicensed('feat:projectRole:admin'), + globalScope('project:create'), + async (req: Create, res: Response) => { + const project = await Container.get(ProjectController).createProject(req); + + return res.status(201).json(project); + }, + ], + updateProject: [ + isLicensed('feat:projectRole:admin'), + globalScope('project:update'), + async (req: Update, res: Response) => { + await Container.get(ProjectController).updateProject(req); + + return res.status(204).send(); + }, + ], + deleteProject: [ + isLicensed('feat:projectRole:admin'), + globalScope('project:delete'), + async (req: Delete, res: Response) => { + await Container.get(ProjectController).deleteProject(req); + + return res.status(204).send(); + }, + ], + getProjects: [ + isLicensed('feat:projectRole:admin'), + globalScope('project:list'), + validCursor, + async (req: GetAll, res: Response) => { + const { offset = 0, limit = 100 } = req.query; + + const [projects, count] = await Container.get(ProjectRepository).findAndCount({ + skip: offset, + take: limit, + }); + + return res.json({ + data: projects, + nextCursor: encodeNextCursor({ + offset, + limit, + numberOfTotalRecords: count, + }), + }); + }, + ], +}; diff --git a/packages/cli/src/PublicApi/v1/handlers/projects/spec/paths/projects.projectId.yml b/packages/cli/src/PublicApi/v1/handlers/projects/spec/paths/projects.projectId.yml new file mode 100644 index 0000000000000..a5aab19b3d6ed --- /dev/null +++ b/packages/cli/src/PublicApi/v1/handlers/projects/spec/paths/projects.projectId.yml @@ -0,0 +1,43 @@ +delete: + x-eov-operation-id: deleteProject + x-eov-operation-handler: v1/handlers/projects/projects.handler + tags: + - Projects + summary: Delete a project + description: Delete a project from your instance. + parameters: + - $ref: '../schemas/parameters/projectId.yml' + responses: + '204': + description: Operation successful. + '401': + $ref: '../../../../shared/spec/responses/unauthorized.yml' + '403': + $ref: '../../../../shared/spec/responses/forbidden.yml' + '404': + $ref: '../../../../shared/spec/responses/notFound.yml' +put: + x-eov-operation-id: updateProject + x-eov-operation-handler: v1/handlers/projects/projects.handler + tags: + - Project + summary: Update a project + description: Update a project. + requestBody: + description: Updated project object. + content: + application/json: + schema: + $ref: '../schemas/project.yml' + required: true + responses: + '204': + description: Operation successful. + '400': + $ref: '../../../../shared/spec/responses/badRequest.yml' + '401': + $ref: '../../../../shared/spec/responses/unauthorized.yml' + '403': + $ref: '../../../../shared/spec/responses/forbidden.yml' + '404': + $ref: '../../../../shared/spec/responses/notFound.yml' diff --git a/packages/cli/src/PublicApi/v1/handlers/projects/spec/paths/projects.yml b/packages/cli/src/PublicApi/v1/handlers/projects/spec/paths/projects.yml new file mode 100644 index 0000000000000..1babd3dd6ea03 --- /dev/null +++ b/packages/cli/src/PublicApi/v1/handlers/projects/spec/paths/projects.yml @@ -0,0 +1,40 @@ +post: + x-eov-operation-id: createProject + x-eov-operation-handler: v1/handlers/projects/projects.handler + tags: + - Projects + summary: Create a project + description: Create a project in your instance. + requestBody: + description: Payload for project to create. + content: + application/json: + schema: + $ref: '../schemas/project.yml' + required: true + responses: + '201': + description: Operation successful. + '400': + $ref: '../../../../shared/spec/responses/badRequest.yml' + '401': + $ref: '../../../../shared/spec/responses/unauthorized.yml' +get: + x-eov-operation-id: getProjects + x-eov-operation-handler: v1/handlers/projects/projects.handler + tags: + - Projects + summary: Retrieve projects + description: Retrieve projects from your instance. + parameters: + - $ref: '../../../../shared/spec/parameters/limit.yml' + - $ref: '../../../../shared/spec/parameters/cursor.yml' + responses: + '200': + description: Operation successful. + content: + application/json: + schema: + $ref: '../schemas/projectList.yml' + '401': + $ref: '../../../../shared/spec/responses/unauthorized.yml' diff --git a/packages/cli/src/PublicApi/v1/handlers/projects/spec/schemas/parameters/projectId.yml b/packages/cli/src/PublicApi/v1/handlers/projects/spec/schemas/parameters/projectId.yml new file mode 100644 index 0000000000000..32961f46016f9 --- /dev/null +++ b/packages/cli/src/PublicApi/v1/handlers/projects/spec/schemas/parameters/projectId.yml @@ -0,0 +1,6 @@ +name: projectId +in: path +description: The ID of the project. +required: true +schema: + type: string diff --git a/packages/cli/src/PublicApi/v1/handlers/projects/spec/schemas/project.yml b/packages/cli/src/PublicApi/v1/handlers/projects/spec/schemas/project.yml new file mode 100644 index 0000000000000..7a4d2ec432bdd --- /dev/null +++ b/packages/cli/src/PublicApi/v1/handlers/projects/spec/schemas/project.yml @@ -0,0 +1,13 @@ +type: object +additionalProperties: false +required: + - name +properties: + id: + type: string + readOnly: true + name: + type: string + type: + type: string + readOnly: true diff --git a/packages/cli/src/PublicApi/v1/handlers/projects/spec/schemas/projectList.yml b/packages/cli/src/PublicApi/v1/handlers/projects/spec/schemas/projectList.yml new file mode 100644 index 0000000000000..7d88be72fb008 --- /dev/null +++ b/packages/cli/src/PublicApi/v1/handlers/projects/spec/schemas/projectList.yml @@ -0,0 +1,11 @@ +type: object +properties: + data: + type: array + items: + $ref: './project.yml' + nextCursor: + type: string + description: Paginate through projects by setting the cursor parameter to a nextCursor attribute returned by a previous request. Default value fetches the first "page" of the collection. + nullable: true + example: MTIzZTQ1NjctZTg5Yi0xMmQzLWE0NTYtNDI2NjE0MTc0MDA diff --git a/packages/cli/src/PublicApi/v1/handlers/sourceControl/sourceControl.handler.ts b/packages/cli/src/PublicApi/v1/handlers/sourceControl/sourceControl.handler.ts index f54e8bd95d8e2..7a3cf08ec0eb5 100644 --- a/packages/cli/src/PublicApi/v1/handlers/sourceControl/sourceControl.handler.ts +++ b/packages/cli/src/PublicApi/v1/handlers/sourceControl/sourceControl.handler.ts @@ -10,7 +10,7 @@ import { getTrackingInformationFromPullResult, isSourceControlLicensed, } from '@/environments/sourceControl/sourceControlHelper.ee'; -import { EventService } from '@/eventbus/event.service'; +import { EventService } from '@/events/event.service'; export = { pull: [ diff --git a/packages/cli/src/PublicApi/v1/handlers/users/spec/paths/users.id.role.yml b/packages/cli/src/PublicApi/v1/handlers/users/spec/paths/users.id.role.yml new file mode 100644 index 0000000000000..92993adf7f9d4 --- /dev/null +++ b/packages/cli/src/PublicApi/v1/handlers/users/spec/paths/users.id.role.yml @@ -0,0 +1,31 @@ +patch: + x-eov-operation-id: changeRole + x-eov-operation-handler: v1/handlers/users/users.handler.ee + tags: + - User + summary: Change a user's global role + description: Change a user's global role + parameters: + - $ref: '../schemas/parameters/userIdentifier.yml' + requestBody: + description: New role for the user + required: true + content: + application/json: + schema: + type: object + properties: + newRoleName: + type: string + enum: [global:admin, global:member] + required: + - newRoleName + responses: + '200': + description: Operation successful. + '401': + $ref: '../../../../shared/spec/responses/unauthorized.yml' + '403': + $ref: '../../../../shared/spec/responses/forbidden.yml' + '404': + $ref: '../../../../shared/spec/responses/notFound.yml' diff --git a/packages/cli/src/PublicApi/v1/handlers/users/spec/paths/users.id.yml b/packages/cli/src/PublicApi/v1/handlers/users/spec/paths/users.id.yml index 0d3c86c4cef97..f3dcae00534c4 100644 --- a/packages/cli/src/PublicApi/v1/handlers/users/spec/paths/users.id.yml +++ b/packages/cli/src/PublicApi/v1/handlers/users/spec/paths/users.id.yml @@ -17,3 +17,21 @@ get: $ref: '../schemas/user.yml' '401': $ref: '../../../../shared/spec/responses/unauthorized.yml' +delete: + x-eov-operation-id: deleteUser + x-eov-operation-handler: v1/handlers/users/users.handler.ee + tags: + - User + summary: Delete a user + description: Delete a user from your instance. + parameters: + - $ref: '../schemas/parameters/userIdentifier.yml' + responses: + '204': + description: Operation successful. + '401': + $ref: '../../../../shared/spec/responses/unauthorized.yml' + '403': + $ref: '../../../../shared/spec/responses/forbidden.yml' + '404': + $ref: '../../../../shared/spec/responses/notFound.yml' diff --git a/packages/cli/src/PublicApi/v1/handlers/users/spec/paths/users.yml b/packages/cli/src/PublicApi/v1/handlers/users/spec/paths/users.yml index 1d618435531b5..e767ab33cc16b 100644 --- a/packages/cli/src/PublicApi/v1/handlers/users/spec/paths/users.yml +++ b/packages/cli/src/PublicApi/v1/handlers/users/spec/paths/users.yml @@ -9,6 +9,14 @@ get: - $ref: '../../../../shared/spec/parameters/limit.yml' - $ref: '../../../../shared/spec/parameters/cursor.yml' - $ref: '../schemas/parameters/includeRole.yml' + - name: projectId + in: query + required: false + explode: false + allowReserved: true + schema: + type: string + example: VmwOO9HeTEj20kxM responses: '200': description: Operation successful. @@ -18,3 +26,53 @@ get: $ref: '../schemas/userList.yml' '401': $ref: '../../../../shared/spec/responses/unauthorized.yml' +post: + x-eov-operation-id: createUser + x-eov-operation-handler: v1/handlers/users/users.handler.ee + tags: + - User + summary: Create multiple users + description: Create one or more users. + requestBody: + description: Array of users to be created. + required: true + content: + application/json: + schema: + type: array + items: + type: object + properties: + email: + type: string + format: email + role: + type: string + enum: [global:admin, global:member] + required: + - email + responses: + '200': + description: Operation successful. + content: + application/json: + schema: + type: object + properties: + user: + type: object + properties: + id: + type: string + email: + type: string + inviteAcceptUrl: + type: string + emailSent: + type: boolean + error: + type: string + '401': + $ref: '../../../../shared/spec/responses/unauthorized.yml' + '403': + $ref: '../../../../shared/spec/responses/forbidden.yml' diff --git a/packages/cli/src/PublicApi/v1/handlers/users/users.handler.ee.ts b/packages/cli/src/PublicApi/v1/handlers/users/users.handler.ee.ts index 0df22ec4aa9ef..59a7a4c3e3f42 100644 --- a/packages/cli/src/PublicApi/v1/handlers/users/users.handler.ee.ts +++ b/packages/cli/src/PublicApi/v1/handlers/users/users.handler.ee.ts @@ -6,11 +6,20 @@ import { clean, getAllUsersAndCount, getUser } from './users.service.ee'; import { encodeNextCursor } from '../../shared/services/pagination.service'; import { globalScope, + isLicensed, validCursor, validLicenseWithUserQuota, } from '../../shared/middlewares/global.middleware'; import type { UserRequest } from '@/requests'; -import { InternalHooks } from '@/InternalHooks'; +import { EventService } from '@/events/event.service'; +import { ProjectRelationRepository } from '@/databases/repositories/projectRelation.repository'; +import type { Response } from 'express'; +import { InvitationController } from '@/controllers/invitation.controller'; +import { UsersController } from '@/controllers/users.controller'; + +type Create = UserRequest.Invite; +type Delete = UserRequest.Delete; +type ChangeRole = UserRequest.ChangeRole; export = { getUser: [ @@ -28,12 +37,10 @@ export = { }); } - const telemetryData = { - user_id: req.user.id, - public_api: true, - }; - - Container.get(InternalHooks).onUserRetrievedUser(telemetryData); + Container.get(EventService).emit('user-retrieved-user', { + userId: req.user.id, + publicApi: true, + }); return res.json(clean(user, { includeRole })); }, @@ -43,20 +50,23 @@ export = { validCursor, globalScope(['user:list', 'user:read']), async (req: UserRequest.Get, res: express.Response) => { - const { offset = 0, limit = 100, includeRole = false } = req.query; + const { offset = 0, limit = 100, includeRole = false, projectId } = req.query; + + const _in = projectId + ? await Container.get(ProjectRelationRepository).findUserIdsByProjectId(projectId) + : undefined; const [users, count] = await getAllUsersAndCount({ includeRole, limit, offset, + in: _in, }); - const telemetryData = { - user_id: req.user.id, - public_api: true, - }; - - Container.get(InternalHooks).onUserRetrievedAllUsers(telemetryData); + Container.get(EventService).emit('user-retrieved-all-users', { + userId: req.user.id, + publicApi: true, + }); return res.json({ data: clean(users, { includeRole }), @@ -68,4 +78,29 @@ export = { }); }, ], + createUser: [ + globalScope('user:create'), + async (req: Create, res: Response) => { + const usersInvited = await Container.get(InvitationController).inviteUser(req); + + return res.status(201).json(usersInvited); + }, + ], + deleteUser: [ + globalScope('user:delete'), + async (req: Delete, res: Response) => { + await Container.get(UsersController).deleteUser(req); + + return res.status(204).send(); + }, + ], + changeRole: [ + isLicensed('feat:advancedPermissions'), + globalScope('user:changeRole'), + async (req: ChangeRole, res: Response) => { + await Container.get(UsersController).changeGlobalRole(req); + + return res.status(204).send(); + }, + ], }; diff --git a/packages/cli/src/PublicApi/v1/handlers/users/users.service.ee.ts b/packages/cli/src/PublicApi/v1/handlers/users/users.service.ee.ts index f7bf6618168f0..e62c9465747ed 100644 --- a/packages/cli/src/PublicApi/v1/handlers/users/users.service.ee.ts +++ b/packages/cli/src/PublicApi/v1/handlers/users/users.service.ee.ts @@ -3,6 +3,8 @@ import { UserRepository } from '@db/repositories/user.repository'; import type { User } from '@db/entities/User'; import pick from 'lodash/pick'; import { validate as uuidValidate } from 'uuid'; +// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import +import { In } from '@n8n/typeorm'; export async function getUser(data: { withIdentifier: string; @@ -25,9 +27,12 @@ export async function getAllUsersAndCount(data: { includeRole?: boolean; limit?: number; offset?: number; + in?: string[]; }): Promise<[User[], number]> { + const { in: _in } = data; + const users = await Container.get(UserRepository).find({ - where: {}, + where: { ...(_in && { id: In(_in) }) }, skip: data.offset, take: data.limit, }); diff --git a/packages/cli/src/PublicApi/v1/handlers/workflows/workflows.handler.ts b/packages/cli/src/PublicApi/v1/handlers/workflows/workflows.handler.ts index 4063d0b611d35..8515548dea16b 100644 --- a/packages/cli/src/PublicApi/v1/handlers/workflows/workflows.handler.ts +++ b/packages/cli/src/PublicApi/v1/handlers/workflows/workflows.handler.ts @@ -26,13 +26,12 @@ import { updateTags, } from './workflows.service'; import { WorkflowService } from '@/workflows/workflow.service'; -import { InternalHooks } from '@/InternalHooks'; import { WorkflowHistoryService } from '@/workflows/workflowHistory/workflowHistory.service.ee'; import { SharedWorkflowRepository } from '@/databases/repositories/sharedWorkflow.repository'; import { TagRepository } from '@/databases/repositories/tag.repository'; import { WorkflowRepository } from '@/databases/repositories/workflow.repository'; import { ProjectRepository } from '@/databases/repositories/project.repository'; -import { EventService } from '@/eventbus/event.service'; +import { EventService } from '@/events/event.service'; import { z } from 'zod'; import { EnterpriseWorkflowService } from '@/workflows/workflow.service.ee'; @@ -119,9 +118,9 @@ export = { return res.status(404).json({ message: 'Not Found' }); } - Container.get(InternalHooks).onUserRetrievedWorkflow({ - user_id: req.user.id, - public_api: true, + Container.get(EventService).emit('user-retrieved-workflow', { + userId: req.user.id, + publicApi: true, }); return res.json(workflow); @@ -144,6 +143,19 @@ export = { ); where.id = In(workflowIds); } + + if (projectId) { + const workflows = await Container.get(SharedWorkflowRepository).findAllWorkflowsForUser( + req.user, + ['workflow:read'], + ); + + const workflowIds = workflows + .filter((workflow) => workflow.projectId === projectId) + .map((workflow) => workflow.id); + + where.id = In(workflowIds); + } } else { const options: { workflowIds?: string[] } = {}; @@ -185,9 +197,9 @@ export = { ...(!config.getEnv('workflowTagsDisabled') && { relations: ['tags'] }), }); - Container.get(InternalHooks).onUserRetrievedAllWorkflows({ - user_id: req.user.id, - public_api: true, + Container.get(EventService).emit('user-retrieved-all-workflows', { + userId: req.user.id, + publicApi: true, }); return res.json({ diff --git a/packages/cli/src/PublicApi/v1/handlers/workflows/workflows.service.ts b/packages/cli/src/PublicApi/v1/handlers/workflows/workflows.service.ts index d301e61c93966..3e3afc078ed92 100644 --- a/packages/cli/src/PublicApi/v1/handlers/workflows/workflows.service.ts +++ b/packages/cli/src/PublicApi/v1/handlers/workflows/workflows.service.ts @@ -17,15 +17,21 @@ function insertIf(condition: boolean, elements: string[]): string[] { return condition ? elements : []; } -export async function getSharedWorkflowIds(user: User, scopes: Scope[]): Promise { +export async function getSharedWorkflowIds( + user: User, + scopes: Scope[], + projectId?: string, +): Promise { if (Container.get(License).isSharingEnabled()) { return await Container.get(WorkflowSharingService).getSharedWorkflowIds(user, { scopes, + projectId, }); } else { return await Container.get(WorkflowSharingService).getSharedWorkflowIds(user, { workflowRoles: ['workflow:owner'], projectRoles: ['project:personalOwner'], + projectId, }); } } diff --git a/packages/cli/src/PublicApi/v1/openapi.yml b/packages/cli/src/PublicApi/v1/openapi.yml index fd3b286a17fb5..0a84925734c7b 100644 --- a/packages/cli/src/PublicApi/v1/openapi.yml +++ b/packages/cli/src/PublicApi/v1/openapi.yml @@ -8,7 +8,7 @@ info: email: hello@n8n.io license: name: Sustainable Use License - url: https://github.com/n8n-io/n8n/blob/master/packages/cli/LICENSE.md + url: https://github.com/n8n-io/n8n/blob/master/LICENSE.md version: 1.1.1 externalDocs: description: n8n API documentation @@ -32,6 +32,8 @@ tags: description: Operations about source control - name: Variables description: Operations about variables + - name: Projects + description: Operations about projects paths: /audit: @@ -60,18 +62,26 @@ paths: $ref: './handlers/workflows/spec/paths/workflows.id.deactivate.yml' /workflows/{id}/transfer: $ref: './handlers/workflows/spec/paths/workflows.id.transfer.yml' + /credentials/{id}/transfer: + $ref: './handlers/credentials/spec/paths/credentials.id.transfer.yml' /workflows/{id}/tags: $ref: './handlers/workflows/spec/paths/workflows.id.tags.yml' /users: $ref: './handlers/users/spec/paths/users.yml' /users/{id}: $ref: './handlers/users/spec/paths/users.id.yml' + /users/{id}/role: + $ref: './handlers/users/spec/paths/users.id.role.yml' /source-control/pull: $ref: './handlers/sourceControl/spec/paths/sourceControl.yml' /variables: $ref: './handlers/variables/spec/paths/variables.yml' /variables/{id}: $ref: './handlers/variables/spec/paths/variables.id.yml' + /projects: + $ref: './handlers/projects/spec/paths/projects.yml' + /projects/{projectId}: + $ref: './handlers/projects/spec/paths/projects.projectId.yml' components: schemas: $ref: './shared/spec/schemas/_index.yml' diff --git a/packages/cli/src/Queue.ts b/packages/cli/src/Queue.ts deleted file mode 100644 index 11cfed839bee7..0000000000000 --- a/packages/cli/src/Queue.ts +++ /dev/null @@ -1,166 +0,0 @@ -import type Bull from 'bull'; -import Container, { Service } from 'typedi'; -import { - ApplicationError, - BINARY_ENCODING, - type IDataObject, - type ExecutionError, - type IExecuteResponsePromiseData, -} from 'n8n-workflow'; -import { ActiveExecutions } from '@/ActiveExecutions'; -import config from '@/config'; -import { OnShutdown } from './decorators/OnShutdown'; -import { HIGHEST_SHUTDOWN_PRIORITY } from './constants'; - -export type JobId = Bull.JobId; -export type Job = Bull.Job; -export type JobQueue = Bull.Queue; - -export interface JobData { - executionId: string; - loadStaticData: boolean; -} - -export interface JobResponse { - success: boolean; - error?: ExecutionError; -} - -export interface WebhookResponse { - executionId: string; - response: IExecuteResponsePromiseData; -} - -@Service() -export class Queue { - private jobQueue: JobQueue; - - /** - * The number of jobs a single server can process concurrently - * Any worker that wants to process executions must first set this to a non-zero value - */ - private concurrency = 0; - - setConcurrency(concurrency: number) { - this.concurrency = concurrency; - // This sets the max event listeners on the jobQueue EventEmitter to prevent the logs getting flooded with MaxListenersExceededWarning - // see: https://github.com/OptimalBits/bull/blob/develop/lib/job.js#L497-L521 - this.jobQueue.setMaxListeners( - 4 + // `close` - 2 + // `error` - 2 + // `global:progress` - concurrency * 2, // 2 global events for every call to `job.finished()` - ); - } - - constructor(private activeExecutions: ActiveExecutions) {} - - async init() { - const { default: Bull } = await import('bull'); - const { RedisClientService } = await import('@/services/redis/redis-client.service'); - - const redisClientService = Container.get(RedisClientService); - - const bullPrefix = config.getEnv('queue.bull.prefix'); - const prefix = redisClientService.toValidPrefix(bullPrefix); - - this.jobQueue = new Bull('jobs', { - prefix, - settings: config.get('queue.bull.settings'), - createClient: (type) => redisClientService.createClient({ type: `${type}(bull)` }), - }); - - this.jobQueue.on('global:progress', (_jobId, progress: WebhookResponse) => { - this.activeExecutions.resolveResponsePromise( - progress.executionId, - this.decodeWebhookResponse(progress.response), - ); - }); - } - - async findRunningJobBy({ executionId }: { executionId: string }) { - const activeOrWaitingJobs = await this.getJobs(['active', 'waiting']); - - return activeOrWaitingJobs.find(({ data }) => data.executionId === executionId) ?? null; - } - - decodeWebhookResponse(response: IExecuteResponsePromiseData): IExecuteResponsePromiseData { - if ( - typeof response === 'object' && - typeof response.body === 'object' && - (response.body as IDataObject)['__@N8nEncodedBuffer@__'] - ) { - response.body = Buffer.from( - (response.body as IDataObject)['__@N8nEncodedBuffer@__'] as string, - BINARY_ENCODING, - ); - } - - return response; - } - - async add(jobData: JobData, jobOptions: object): Promise { - return await this.jobQueue.add(jobData, jobOptions); - } - - async getJob(jobId: JobId): Promise { - return await this.jobQueue.getJob(jobId); - } - - async getJobs(jobTypes: Bull.JobStatus[]): Promise { - return await this.jobQueue.getJobs(jobTypes); - } - - /** - * Get IDs of executions that are currently in progress in the queue. - */ - async getInProgressExecutionIds() { - const inProgressJobs = await this.getJobs(['active', 'waiting']); - - return new Set(inProgressJobs.map((job) => job.data.executionId)); - } - - async process(fn: Bull.ProcessCallbackFunction): Promise { - return await this.jobQueue.process(this.concurrency, fn); - } - - async ping(): Promise { - return await this.jobQueue.client.ping(); - } - - @OnShutdown(HIGHEST_SHUTDOWN_PRIORITY) - // Stop accepting new jobs, `doNotWaitActive` allows reporting progress - async pause(): Promise { - return await this.jobQueue?.pause(true, true); - } - - getBullObjectInstance(): JobQueue { - if (this.jobQueue === undefined) { - // if queue is not initialized yet throw an error, since we do not want to hand around an undefined queue - throw new ApplicationError('Queue is not initialized yet!'); - } - return this.jobQueue; - } - - /** - * - * @param job A Job instance - * @returns boolean true if we were able to securely stop the job - */ - async stopJob(job: Job): Promise { - if (await job.isActive()) { - // Job is already running so tell it to stop - await job.progress(-1); - return true; - } - // Job did not get started yet so remove from queue - try { - await job.remove(); - return true; - } catch (e) { - await job.progress(-1); - } - - return false; - } -} diff --git a/packages/cli/src/Server.ts b/packages/cli/src/Server.ts index 4c9628bc1bf1d..777472b99fa42 100644 --- a/packages/cli/src/Server.ts +++ b/packages/cli/src/Server.ts @@ -35,7 +35,7 @@ import { MessageEventBus } from '@/eventbus/MessageEventBus/MessageEventBus'; import { handleMfaDisable, isMfaFeatureEnabled } from '@/Mfa/helpers'; import type { FrontendService } from '@/services/frontend.service'; import { OrchestrationService } from '@/services/orchestration.service'; -import { AuditEventRelay } from './eventbus/audit-event-relay.service'; +import { LogStreamingEventRelay } from '@/events/log-streaming-event-relay'; import '@/controllers/activeWorkflows.controller'; import '@/controllers/auth.controller'; @@ -64,7 +64,7 @@ import '@/ExternalSecrets/ExternalSecrets.controller.ee'; import '@/license/license.controller'; import '@/workflows/workflowHistory/workflowHistory.controller.ee'; import '@/workflows/workflows.controller'; -import { EventService } from './eventbus/event.service'; +import { EventService } from './events/event.service'; const exec = promisify(callbackExec); @@ -211,8 +211,8 @@ export class Server extends AbstractServer { setupPushHandler(restEndpoint, app); if (config.getEnv('executions.mode') === 'queue') { - const { Queue } = await import('@/Queue'); - await Container.get(Queue).init(); + const { ScalingService } = await import('@/scaling/scaling.service'); + await Container.get(ScalingService).setupQueue(); } await handleMfaDisable(); @@ -250,7 +250,7 @@ export class Server extends AbstractServer { // ---------------------------------------- const eventBus = Container.get(MessageEventBus); await eventBus.initialize(); - Container.get(AuditEventRelay).init(); + Container.get(LogStreamingEventRelay).init(); if (this.endpointPresetCredentials !== '') { // POST endpoint to set preset credentials diff --git a/packages/cli/src/UserManagement/email/UserManagementMailer.ts b/packages/cli/src/UserManagement/email/UserManagementMailer.ts index 6d60c3b4907e1..7d5e8d254865e 100644 --- a/packages/cli/src/UserManagement/email/UserManagementMailer.ts +++ b/packages/cli/src/UserManagement/email/UserManagementMailer.ts @@ -8,7 +8,6 @@ import { GlobalConfig } from '@n8n/config'; import type { User } from '@db/entities/User'; import type { WorkflowEntity } from '@db/entities/WorkflowEntity'; import { UserRepository } from '@db/repositories/user.repository'; -import { InternalHooks } from '@/InternalHooks'; import { Logger } from '@/Logger'; import { UrlService } from '@/services/url.service'; import { InternalServerError } from '@/errors/response-errors/internal-server.error'; @@ -16,7 +15,7 @@ import { toError } from '@/utils'; import type { InviteEmailData, PasswordResetData, SendEmailResult } from './Interfaces'; import { NodeMailer } from './NodeMailer'; -import { EventService } from '@/eventbus/event.service'; +import { EventService } from '@/events/event.service'; type Template = HandlebarsTemplateDelegate; type TemplateName = 'invite' | 'passwordReset' | 'workflowShared' | 'credentialsShared'; @@ -112,22 +111,18 @@ export class UserManagementMailer { this.logger.info('Sent workflow shared email successfully', { sharerId: sharer.id }); - Container.get(InternalHooks).onUserTransactionalEmail({ - user_id: sharer.id, - message_type: 'Workflow shared', - public_api: false, + Container.get(EventService).emit('user-transactional-email-sent', { + userId: sharer.id, + messageType: 'Workflow shared', + publicApi: false, }); return result; } catch (e) { - Container.get(InternalHooks).onEmailFailed({ - user: sharer, - message_type: 'Workflow shared', - public_api: false, - }); Container.get(EventService).emit('email-failed', { user: sharer, messageType: 'Workflow shared', + publicApi: false, }); const error = toError(e); @@ -171,22 +166,18 @@ export class UserManagementMailer { this.logger.info('Sent credentials shared email successfully', { sharerId: sharer.id }); - Container.get(InternalHooks).onUserTransactionalEmail({ - user_id: sharer.id, - message_type: 'Credentials shared', - public_api: false, + Container.get(EventService).emit('user-transactional-email-sent', { + userId: sharer.id, + messageType: 'Credentials shared', + publicApi: false, }); return result; } catch (e) { - Container.get(InternalHooks).onEmailFailed({ - user: sharer, - message_type: 'Credentials shared', - public_api: false, - }); Container.get(EventService).emit('email-failed', { user: sharer, messageType: 'Credentials shared', + publicApi: false, }); const error = toError(e); diff --git a/packages/cli/test/unit/UserManagementMailer.test.ts b/packages/cli/src/UserManagement/email/__tests__/UserManagementMailer.test.ts similarity index 100% rename from packages/cli/test/unit/UserManagementMailer.test.ts rename to packages/cli/src/UserManagement/email/__tests__/UserManagementMailer.test.ts diff --git a/packages/cli/src/WaitingForms.ts b/packages/cli/src/WaitingForms.ts index 0625acd7e40c4..bf0ab7dedb161 100644 --- a/packages/cli/src/WaitingForms.ts +++ b/packages/cli/src/WaitingForms.ts @@ -1,7 +1,7 @@ import { Service } from 'typedi'; import type { IExecutionResponse } from '@/Interfaces'; -import { WaitingWebhooks } from '@/WaitingWebhooks'; +import { WaitingWebhooks } from '@/webhooks/WaitingWebhooks'; @Service() export class WaitingForms extends WaitingWebhooks { diff --git a/packages/cli/src/WorkflowExecuteAdditionalData.ts b/packages/cli/src/WorkflowExecuteAdditionalData.ts index f4acd61b6aa55..127e0cc817393 100644 --- a/packages/cli/src/WorkflowExecuteAdditionalData.ts +++ b/packages/cli/src/WorkflowExecuteAdditionalData.ts @@ -52,7 +52,6 @@ import { Push } from '@/push'; import * as WorkflowHelpers from '@/WorkflowHelpers'; import { findSubworkflowStart, isWorkflowIdValid } from '@/utils'; import { PermissionChecker } from './UserManagement/PermissionChecker'; -import { InternalHooks } from '@/InternalHooks'; import { ExecutionRepository } from '@db/repositories/execution.repository'; import { WorkflowStatisticsService } from '@/services/workflow-statistics.service'; import { SecretsHelper } from './SecretsHelpers'; @@ -71,7 +70,7 @@ import { WorkflowRepository } from './databases/repositories/workflow.repository import { UrlService } from './services/url.service'; import { WorkflowExecutionService } from './workflows/workflowExecution.service'; import { MessageEventBus } from '@/eventbus/MessageEventBus/MessageEventBus'; -import { EventService } from './eventbus/event.service'; +import { EventService } from './events/event.service'; import { GlobalConfig } from '@n8n/config'; import { SubworkflowPolicyChecker } from './subworkflows/subworkflow-policy-checker.service'; @@ -548,7 +547,6 @@ function hookFunctionsSave(): IWorkflowExecuteHooks { */ function hookFunctionsSaveWorker(): IWorkflowExecuteHooks { const logger = Container.get(Logger); - const internalHooks = Container.get(InternalHooks); const workflowStatisticsService = Container.get(WorkflowStatisticsService); const eventService = Container.get(EventService); return { @@ -644,13 +642,9 @@ function hookFunctionsSaveWorker(): IWorkflowExecuteHooks { async function (this: WorkflowHooks, runData: IRun): Promise { const { executionId, workflowData: workflow } = this; - void internalHooks.onWorkflowPostExecute(executionId, workflow, runData); eventService.emit('workflow-post-execute', { - workflowId: workflow.id, - workflowName: workflow.name, + workflow, executionId, - success: runData.status === 'success', - isManual: runData.mode === 'manual', runData, }); }, @@ -723,7 +717,6 @@ export async function getRunData( const runData: IWorkflowExecutionDataProcess = { executionMode: mode, executionData: runExecutionData, - // @ts-ignore workflowData, }; @@ -787,7 +780,6 @@ async function executeWorkflow( parentCallbackManager?: CallbackManager; }, ): Promise | IWorkflowExecuteProcess> { - const internalHooks = Container.get(InternalHooks); const externalHooks = Container.get(ExternalHooks); await externalHooks.init(); @@ -933,13 +925,9 @@ async function executeWorkflow( await externalHooks.run('workflow.postExecute', [data, workflowData, executionId]); - void internalHooks.onWorkflowPostExecute(executionId, workflowData, data, additionalData.userId); eventService.emit('workflow-post-execute', { - workflowId: workflowData.id, - workflowName: workflowData.name, + workflow: workflowData, executionId, - success: data.status === 'success', - isManual: data.mode === 'manual', userId: additionalData.userId, runData: data, }); @@ -1011,10 +999,10 @@ export async function getBase( executeWorkflow, restApiUrl: urlBaseWebhook + globalConfig.endpoints.rest, instanceBaseUrl: urlBaseWebhook, - formWaitingBaseUrl: globalConfig.endpoints.formWaiting, - webhookBaseUrl: globalConfig.endpoints.webhook, - webhookWaitingBaseUrl: globalConfig.endpoints.webhookWaiting, - webhookTestBaseUrl: globalConfig.endpoints.webhookTest, + formWaitingBaseUrl: urlBaseWebhook + globalConfig.endpoints.formWaiting, + webhookBaseUrl: urlBaseWebhook + globalConfig.endpoints.webhook, + webhookWaitingBaseUrl: urlBaseWebhook + globalConfig.endpoints.webhookWaiting, + webhookTestBaseUrl: urlBaseWebhook + globalConfig.endpoints.webhookTest, currentNodeParameters, executionTimeoutTimestamp, userId, diff --git a/packages/cli/src/WorkflowRunner.ts b/packages/cli/src/WorkflowRunner.ts index 3318dd283cd60..9ff1de8f35242 100644 --- a/packages/cli/src/WorkflowRunner.ts +++ b/packages/cli/src/WorkflowRunner.ts @@ -28,20 +28,20 @@ import { ExecutionRepository } from '@db/repositories/execution.repository'; import { ExternalHooks } from '@/ExternalHooks'; import type { IExecutionResponse, IWorkflowExecutionDataProcess } from '@/Interfaces'; import { NodeTypes } from '@/NodeTypes'; -import type { Job, JobData, JobResponse } from '@/Queue'; -import { Queue } from '@/Queue'; +import type { Job, JobData, JobResult } from '@/scaling/types'; +import type { ScalingService } from '@/scaling/scaling.service'; import * as WorkflowHelpers from '@/WorkflowHelpers'; import * as WorkflowExecuteAdditionalData from '@/WorkflowExecuteAdditionalData'; import { generateFailedExecutionFromError } from '@/WorkflowHelpers'; import { PermissionChecker } from '@/UserManagement/PermissionChecker'; -import { InternalHooks } from '@/InternalHooks'; import { Logger } from '@/Logger'; import { WorkflowStaticDataService } from '@/workflows/workflowStaticData.service'; -import { EventService } from './eventbus/event.service'; +import { EventService } from './events/event.service'; +import { GlobalConfig } from '@n8n/config'; @Service() export class WorkflowRunner { - private jobQueue: Queue; + private scalingService: ScalingService; private executionsMode = config.getEnv('executions.mode'); @@ -54,11 +54,7 @@ export class WorkflowRunner { private readonly nodeTypes: NodeTypes, private readonly permissionChecker: PermissionChecker, private readonly eventService: EventService, - ) { - if (this.executionsMode === 'queue') { - this.jobQueue = Container.get(Queue); - } - } + ) {} /** The process did error */ async processError( @@ -160,18 +156,9 @@ export class WorkflowRunner { const postExecutePromise = this.activeExecutions.getPostExecutePromise(executionId); postExecutePromise .then(async (executionData) => { - void Container.get(InternalHooks).onWorkflowPostExecute( - executionId, - data.workflowData, - executionData, - data.userId, - ); this.eventService.emit('workflow-post-execute', { - workflowId: data.workflowData.id, - workflowName: data.workflowData.name, + workflow: data.workflowData, executionId, - success: executionData?.status === 'success', - isManual: data.executionMode === 'manual', userId: data.userId, runData: executionData, }); @@ -370,6 +357,11 @@ export class WorkflowRunner { loadStaticData: !!loadStaticData, }; + if (!this.scalingService) { + const { ScalingService } = await import('@/scaling/scaling.service'); + this.scalingService = Container.get(ScalingService); + } + let priority = 100; if (realtime === true) { // Jobs which require a direct response get a higher priority @@ -385,9 +377,7 @@ export class WorkflowRunner { let job: Job; let hooks: WorkflowHooks; try { - job = await this.jobQueue.add(jobData, jobOptions); - - this.logger.info(`Started with job ID: ${job.id.toString()} (Execution ID: ${executionId})`); + job = await this.scalingService.addJob(jobData, jobOptions); hooks = WorkflowExecuteAdditionalData.getWorkflowHooksWorkerMain( data.executionMode, @@ -416,8 +406,7 @@ export class WorkflowRunner { async (resolve, reject, onCancel) => { onCancel.shouldReject = false; onCancel(async () => { - const queue = Container.get(Queue); - await queue.stopJob(job); + await this.scalingService.stopJob(job); // We use "getWorkflowHooksWorkerExecuter" as "getWorkflowHooksWorkerMain" does not contain the // "workflowExecuteAfter" which we require. @@ -434,11 +423,11 @@ export class WorkflowRunner { reject(error); }); - const jobData: Promise = job.finished(); + const jobData: Promise = job.finished(); - const queueRecoveryInterval = config.getEnv('queue.bull.queueRecoveryInterval'); + const { queueRecoveryInterval } = Container.get(GlobalConfig).queue.bull; - const racingPromises: Array> = [jobData]; + const racingPromises: Array> = [jobData]; let clearWatchdogInterval; if (queueRecoveryInterval > 0) { @@ -456,9 +445,9 @@ export class WorkflowRunner { ************************************************ */ let watchDogInterval: NodeJS.Timeout | undefined; - const watchDog: Promise = new Promise((res) => { + const watchDog: Promise = new Promise((res) => { watchDogInterval = setInterval(async () => { - const currentJob = await this.jobQueue.getJob(job.id); + const currentJob = await this.scalingService.getJob(job.id); // When null means job is finished (not found in queue) if (currentJob === null) { // Mimic worker's success message diff --git a/packages/cli/test/unit/ActiveExecutions.test.ts b/packages/cli/src/__tests__/ActiveExecutions.test.ts similarity index 100% rename from packages/cli/test/unit/ActiveExecutions.test.ts rename to packages/cli/src/__tests__/ActiveExecutions.test.ts diff --git a/packages/cli/test/unit/CredentialTypes.test.ts b/packages/cli/src/__tests__/CredentialTypes.test.ts similarity index 95% rename from packages/cli/test/unit/CredentialTypes.test.ts rename to packages/cli/src/__tests__/CredentialTypes.test.ts index 0ee99c9f8ae6e..2a8e6a939a045 100644 --- a/packages/cli/test/unit/CredentialTypes.test.ts +++ b/packages/cli/src/__tests__/CredentialTypes.test.ts @@ -1,7 +1,7 @@ import { CredentialTypes } from '@/CredentialTypes'; import { Container } from 'typedi'; import { LoadNodesAndCredentials } from '@/LoadNodesAndCredentials'; -import { mockInstance } from '../shared/mocking'; +import { mockInstance } from '@test/mocking'; describe('CredentialTypes', () => { const mockNodesAndCredentials = mockInstance(LoadNodesAndCredentials, { diff --git a/packages/cli/test/unit/CredentialsHelper.test.ts b/packages/cli/src/__tests__/CredentialsHelper.test.ts similarity index 99% rename from packages/cli/test/unit/CredentialsHelper.test.ts rename to packages/cli/src/__tests__/CredentialsHelper.test.ts index 19be100b02b84..88cd19ad3dc5d 100644 --- a/packages/cli/test/unit/CredentialsHelper.test.ts +++ b/packages/cli/src/__tests__/CredentialsHelper.test.ts @@ -14,7 +14,7 @@ import { NodeTypes } from '@/NodeTypes'; import { LoadNodesAndCredentials } from '@/LoadNodesAndCredentials'; import { CredentialsRepository } from '@db/repositories/credentials.repository'; import { SharedCredentialsRepository } from '@db/repositories/sharedCredentials.repository'; -import { mockInstance } from '../shared/mocking'; +import { mockInstance } from '@test/mocking'; describe('CredentialsHelper', () => { mockInstance(CredentialsRepository); diff --git a/packages/cli/test/unit/License.test.ts b/packages/cli/src/__tests__/License.test.ts similarity index 99% rename from packages/cli/test/unit/License.test.ts rename to packages/cli/src/__tests__/License.test.ts index 6d7311656c961..31effecc0eba4 100644 --- a/packages/cli/test/unit/License.test.ts +++ b/packages/cli/src/__tests__/License.test.ts @@ -5,7 +5,7 @@ import config from '@/config'; import { License } from '@/License'; import { Logger } from '@/Logger'; import { N8N_VERSION } from '@/constants'; -import { mockInstance } from '../shared/mocking'; +import { mockInstance } from '@test/mocking'; import { OrchestrationService } from '@/services/orchestration.service'; jest.mock('@n8n_io/license-sdk'); diff --git a/packages/cli/test/unit/WaitTracker.test.ts b/packages/cli/src/__tests__/WaitTracker.test.ts similarity index 99% rename from packages/cli/test/unit/WaitTracker.test.ts rename to packages/cli/src/__tests__/WaitTracker.test.ts index 0f8464e18d14d..fb51d2e25bd30 100644 --- a/packages/cli/test/unit/WaitTracker.test.ts +++ b/packages/cli/src/__tests__/WaitTracker.test.ts @@ -10,7 +10,7 @@ jest.useFakeTimers(); describe('WaitTracker', () => { const executionRepository = mock(); const multiMainSetup = mock(); - const orchestrationService = new OrchestrationService(mock(), mock(), multiMainSetup); + const orchestrationService = new OrchestrationService(mock(), mock(), mock(), multiMainSetup); const execution = mock({ id: '123', diff --git a/packages/cli/test/unit/WorkflowExecuteAdditionalData.test.ts b/packages/cli/src/__tests__/WorkflowExecuteAdditionalData.test.ts similarity index 96% rename from packages/cli/test/unit/WorkflowExecuteAdditionalData.test.ts rename to packages/cli/src/__tests__/WorkflowExecuteAdditionalData.test.ts index 2984220637aae..eca60e56c5216 100644 --- a/packages/cli/test/unit/WorkflowExecuteAdditionalData.test.ts +++ b/packages/cli/src/__tests__/WorkflowExecuteAdditionalData.test.ts @@ -1,5 +1,5 @@ import { VariablesService } from '@/environments/variables/variables.service.ee'; -import { mockInstance } from '../shared/mocking'; +import { mockInstance } from '@test/mocking'; import { MessageEventBus } from '@/eventbus/MessageEventBus/MessageEventBus'; import { getBase } from '@/WorkflowExecuteAdditionalData'; import Container from 'typedi'; diff --git a/packages/cli/test/unit/WorkflowHelpers.test.ts b/packages/cli/src/__tests__/WorkflowHelpers.test.ts similarity index 100% rename from packages/cli/test/unit/WorkflowHelpers.test.ts rename to packages/cli/src/__tests__/WorkflowHelpers.test.ts diff --git a/packages/cli/test/unit/WorkflowRunner.test.ts b/packages/cli/src/__tests__/WorkflowRunner.test.ts similarity index 86% rename from packages/cli/test/unit/WorkflowRunner.test.ts rename to packages/cli/src/__tests__/WorkflowRunner.test.ts index c972d6bb73933..668150092f60a 100644 --- a/packages/cli/test/unit/WorkflowRunner.test.ts +++ b/packages/cli/src/__tests__/WorkflowRunner.test.ts @@ -4,11 +4,11 @@ import type { User } from '@db/entities/User'; import { WorkflowRunner } from '@/WorkflowRunner'; import config from '@/config'; -import * as testDb from '../integration/shared/testDb'; -import { setupTestServer } from '../integration/shared/utils'; -import { createUser } from '../integration/shared/db/users'; -import { createWorkflow } from '../integration/shared/db/workflows'; -import { createExecution } from '../integration/shared/db/executions'; +import * as testDb from '@test-integration/testDb'; +import { setupTestServer } from '@test-integration/utils'; +import { createUser } from '@test-integration/db/users'; +import { createWorkflow } from '@test-integration/db/workflows'; +import { createExecution } from '@test-integration/db/executions'; import { mockInstance } from '@test/mocking'; import { Telemetry } from '@/telemetry'; diff --git a/packages/cli/test/unit/auth/auth.service.test.ts b/packages/cli/src/auth/__tests__/auth.service.test.ts similarity index 100% rename from packages/cli/test/unit/auth/auth.service.test.ts rename to packages/cli/src/auth/__tests__/auth.service.test.ts diff --git a/packages/cli/src/auth/methods/email.ts b/packages/cli/src/auth/methods/email.ts index a88d00186b06a..f954991974570 100644 --- a/packages/cli/src/auth/methods/email.ts +++ b/packages/cli/src/auth/methods/email.ts @@ -4,7 +4,7 @@ import { Container } from 'typedi'; import { isLdapLoginEnabled } from '@/Ldap/helpers.ee'; import { UserRepository } from '@db/repositories/user.repository'; import { AuthError } from '@/errors/response-errors/auth.error'; -import { EventService } from '@/eventbus/event.service'; +import { EventService } from '@/events/event.service'; export const handleEmailLogin = async ( email: string, diff --git a/packages/cli/src/auth/methods/ldap.ts b/packages/cli/src/auth/methods/ldap.ts index c8946aec9321e..66f6f6dcd9ffe 100644 --- a/packages/cli/src/auth/methods/ldap.ts +++ b/packages/cli/src/auth/methods/ldap.ts @@ -1,6 +1,5 @@ import { Container } from 'typedi'; -import { InternalHooks } from '@/InternalHooks'; import { LdapService } from '@/Ldap/ldap.service.ee'; import { createLdapUserOnLocalDb, @@ -12,7 +11,7 @@ import { updateLdapUserOnLocalDb, } from '@/Ldap/helpers.ee'; import type { User } from '@db/entities/User'; -import { EventService } from '@/eventbus/event.service'; +import { EventService } from '@/events/event.service'; export const handleLdapLogin = async ( loginId: string, @@ -51,11 +50,11 @@ export const handleLdapLogin = async ( await updateLdapUserOnLocalDb(identity, ldapAttributesValues); } else { const user = await createLdapUserOnLocalDb(ldapAttributesValues, ldapId); - Container.get(InternalHooks).onUserSignup(user, { - user_type: 'ldap', - was_disabled_ldap_user: false, + Container.get(EventService).emit('user-signed-up', { + user, + userType: 'ldap', + wasDisabledLdapUser: false, }); - Container.get(EventService).emit('user-signed-up', { user }); return user; } } else { diff --git a/packages/cli/src/commands/BaseCommand.ts b/packages/cli/src/commands/BaseCommand.ts index 4cebc7fbb66f6..af3958c25a4a0 100644 --- a/packages/cli/src/commands/BaseCommand.ts +++ b/packages/cli/src/commands/BaseCommand.ts @@ -15,15 +15,15 @@ import { ExternalHooks } from '@/ExternalHooks'; import { NodeTypes } from '@/NodeTypes'; import { LoadNodesAndCredentials } from '@/LoadNodesAndCredentials'; import type { N8nInstanceType } from '@/Interfaces'; -import { InternalHooks } from '@/InternalHooks'; import { PostHogClient } from '@/posthog'; +import { InternalHooks } from '@/InternalHooks'; import { License } from '@/License'; import { ExternalSecretsManager } from '@/ExternalSecrets/ExternalSecretsManager.ee'; import { initExpressionEvaluator } from '@/ExpressionEvaluator'; import { generateHostInstanceId } from '@db/utils/generators'; import { WorkflowHistoryManager } from '@/workflows/workflowHistory/workflowHistoryManager.ee'; import { ShutdownService } from '@/shutdown/Shutdown.service'; -import { TelemetryEventRelay } from '@/telemetry/telemetry-event-relay.service'; +import { TelemetryEventRelay } from '@/events/telemetry-event-relay'; export abstract class BaseCommand extends Command { protected logger = Container.get(Logger); @@ -44,13 +44,16 @@ export abstract class BaseCommand extends Command { protected license: License; - protected globalConfig = Container.get(GlobalConfig); + protected readonly globalConfig = Container.get(GlobalConfig); /** * How long to wait for graceful shutdown before force killing the process. */ protected gracefulShutdownTimeoutInS = config.getEnv('generic.gracefulShutdownTimeout'); + /** Whether to init community packages (if enabled) */ + protected needsCommunityPackages = false; + async init(): Promise { await initErrorHandling(); initExpressionEvaluator(); @@ -111,6 +114,12 @@ export abstract class BaseCommand extends Command { ); } + const { communityPackages } = this.globalConfig.nodes; + if (communityPackages.enabled && this.needsCommunityPackages) { + const { CommunityPackagesService } = await import('@/services/communityPackages.service'); + await Container.get(CommunityPackagesService).checkForMissingPackages(); + } + await Container.get(PostHogClient).init(); await Container.get(InternalHooks).init(); await Container.get(TelemetryEventRelay).init(); @@ -306,7 +315,7 @@ export abstract class BaseCommand extends Command { this.exit(exitCode); } - private onTerminationSignal(signal: string) { + protected onTerminationSignal(signal: string) { return async () => { if (this.shutdownService.isShuttingDown()) { this.logger.info(`Received ${signal}. Already shutting down...`); @@ -324,7 +333,9 @@ export abstract class BaseCommand extends Command { this.logger.info(`Received ${signal}. Shutting down...`); this.shutdownService.shutdown(); - await Promise.all([this.stopProcess(), this.shutdownService.waitForShutdown()]); + await this.shutdownService.waitForShutdown(); + + await this.stopProcess(); clearTimeout(forceShutdownTimer); }; diff --git a/packages/cli/test/unit/commands/db/revert.test.ts b/packages/cli/src/commands/db/__tests__/revert.test.ts similarity index 98% rename from packages/cli/test/unit/commands/db/revert.test.ts rename to packages/cli/src/commands/db/__tests__/revert.test.ts index cea3f89253383..13c554a786ad3 100644 --- a/packages/cli/test/unit/commands/db/revert.test.ts +++ b/packages/cli/src/commands/db/__tests__/revert.test.ts @@ -1,5 +1,5 @@ import { main } from '@/commands/db/revert'; -import { mockInstance } from '../../../shared/mocking'; +import { mockInstance } from '@test/mocking'; import { Logger } from '@/Logger'; import type { IrreversibleMigration, ReversibleMigration } from '@/databases/types'; import type { Migration, MigrationExecutor } from '@n8n/typeorm'; diff --git a/packages/cli/src/commands/execute.ts b/packages/cli/src/commands/execute.ts index a375d19c31034..cdf949e87ceda 100644 --- a/packages/cli/src/commands/execute.ts +++ b/packages/cli/src/commands/execute.ts @@ -27,6 +27,8 @@ export class Execute extends BaseCommand { }), }; + override needsCommunityPackages = true; + async init() { await super.init(); await this.initBinaryDataService(); diff --git a/packages/cli/src/commands/executeBatch.ts b/packages/cli/src/commands/executeBatch.ts index d8fdbeb8a201c..227dd962efb1b 100644 --- a/packages/cli/src/commands/executeBatch.ts +++ b/packages/cli/src/commands/executeBatch.ts @@ -108,6 +108,8 @@ export class ExecuteBatch extends BaseCommand { }), }; + override needsCommunityPackages = true; + /** * Gracefully handles exit. * @param {boolean} skipExit Whether to skip exit or number according to received signal diff --git a/packages/cli/src/commands/start.ts b/packages/cli/src/commands/start.ts index 4d7cd888b4cc0..cc4555dc5e65b 100644 --- a/packages/cli/src/commands/start.ts +++ b/packages/cli/src/commands/start.ts @@ -8,7 +8,6 @@ import { createReadStream, createWriteStream, existsSync } from 'fs'; import { pipeline } from 'stream/promises'; import replaceStream from 'replacestream'; import glob from 'fast-glob'; -import { GlobalConfig } from '@n8n/config'; import { jsonParse, randomString } from 'n8n-workflow'; import config from '@/config'; @@ -17,7 +16,6 @@ import { ActiveWorkflowManager } from '@/ActiveWorkflowManager'; import { Server } from '@/Server'; import { EDITOR_UI_DIST_DIR, LICENSE_FEATURES } from '@/constants'; import { MessageEventBus } from '@/eventbus/MessageEventBus/MessageEventBus'; -import { InternalHooks } from '@/InternalHooks'; import { License } from '@/License'; import { OrchestrationService } from '@/services/orchestration.service'; import { OrchestrationHandlerMainService } from '@/services/orchestration/main/orchestration.handler.main.service'; @@ -32,8 +30,7 @@ import type { IWorkflowExecutionDataProcess } from '@/Interfaces'; import { ExecutionService } from '@/executions/execution.service'; import { OwnershipService } from '@/services/ownership.service'; import { WorkflowRunner } from '@/WorkflowRunner'; -import { ExecutionRecoveryService } from '@/executions/execution-recovery.service'; -import { EventService } from '@/eventbus/event.service'; +import { EventService } from '@/events/event.service'; // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-var-requires const open = require('open'); @@ -68,6 +65,8 @@ export class Start extends BaseCommand { protected server = Container.get(Server); + override needsCommunityPackages = true; + constructor(argv: string[], cmdConfig: Config) { super(argv, cmdConfig); this.setInstanceType('main'); @@ -109,7 +108,7 @@ export class Start extends BaseCommand { await Container.get(OrchestrationService).shutdown(); } - await Container.get(InternalHooks).onN8nStop(); + Container.get(EventService).emit('instance-stopped'); await Container.get(ActiveExecutions).shutdown(); @@ -125,7 +124,6 @@ export class Start extends BaseCommand { private async generateStaticAssets() { // Read the index file and replace the path placeholder const n8nPath = this.globalConfig.path; - const hooksUrls = config.getEnv('externalFrontendHooksUrls'); let scriptsString = ''; @@ -178,6 +176,22 @@ export class Start extends BaseCommand { this.logger.debug(`Queue mode id: ${this.queueModeId}`); } + const { flags } = await this.parse(Start); + const { communityPackages } = this.globalConfig.nodes; + // cli flag overrides the config env variable + if (flags.reinstallMissingPackages) { + if (communityPackages.enabled) { + this.logger.warn( + '`--reinstallMissingPackages` is deprecated: Please use the env variable `N8N_REINSTALL_MISSING_PACKAGES` instead', + ); + communityPackages.reinstallMissing = true; + } else { + this.logger.warn( + '`--reinstallMissingPackages` was passed, but community packages are disabled', + ); + } + } + await super.init(); this.activeWorkflowManager = Container.get(ActiveWorkflowManager); @@ -186,7 +200,7 @@ export class Start extends BaseCommand { await this.initOrchestration(); this.logger.debug('Orchestration init complete'); - if (!config.getEnv('license.autoRenewEnabled') && config.getEnv('instanceRole') === 'leader') { + if (!config.getEnv('license.autoRenewEnabled') && this.instanceSettings.isLeader) { this.logger.warn( 'Automatic license renewal is disabled. The license will not renew automatically, and access to licensed features may be lost!', ); @@ -210,7 +224,7 @@ export class Start extends BaseCommand { async initOrchestration() { if (config.getEnv('executions.mode') === 'regular') { - config.set('instanceRole', 'leader'); + this.instanceSettings.markAsLeader(); return; } @@ -251,18 +265,9 @@ export class Start extends BaseCommand { config.set(setting.key, jsonParse(setting.value, { fallbackValue: setting.value })); }); - const globalConfig = Container.get(GlobalConfig); - - if (globalConfig.nodes.communityPackages.enabled) { - const { CommunityPackagesService } = await import('@/services/communityPackages.service'); - await Container.get(CommunityPackagesService).setMissingPackages({ - reinstallMissingPackages: flags.reinstallMissingPackages, - }); - } - - const { type: dbType } = globalConfig.database; + const { type: dbType } = this.globalConfig.database; if (dbType === 'sqlite') { - const shouldRunVacuum = globalConfig.database.sqlite.executeVacuumOnStartup; + const shouldRunVacuum = this.globalConfig.database.sqlite.executeVacuumOnStartup; if (shouldRunVacuum) { await Container.get(ExecutionRepository).query('VACUUM;'); } @@ -282,7 +287,7 @@ export class Start extends BaseCommand { } const { default: localtunnel } = await import('@n8n/localtunnel'); - const { port } = Container.get(GlobalConfig); + const { port } = this.globalConfig; const webhookTunnel = await localtunnel(port, { host: 'https://hooks.n8n.cloud', @@ -299,7 +304,6 @@ export class Start extends BaseCommand { await this.server.start(); Container.get(PruningService).init(); - Container.get(ExecutionRecoveryService).init(); if (config.getEnv('executions.mode') === 'regular') { await this.runEnqueuedExecutions(); @@ -326,7 +330,7 @@ export class Start extends BaseCommand { this.openBrowser(); } else if (key.charCodeAt(0) === 3) { // Ctrl + c got pressed - void this.stopProcess(); + void this.onTerminationSignal('SIGINT')(); } else { // When anything else got pressed, record it and send it on enter into the child process diff --git a/packages/cli/src/commands/webhook.ts b/packages/cli/src/commands/webhook.ts index 5b72c1eb86a9b..d9c197c850385 100644 --- a/packages/cli/src/commands/webhook.ts +++ b/packages/cli/src/commands/webhook.ts @@ -4,8 +4,7 @@ import { ApplicationError } from 'n8n-workflow'; import config from '@/config'; import { ActiveExecutions } from '@/ActiveExecutions'; -import { WebhookServer } from '@/WebhookServer'; -import { Queue } from '@/Queue'; +import { WebhookServer } from '@/webhooks/WebhookServer'; import { BaseCommand } from './BaseCommand'; import { OrchestrationWebhookService } from '@/services/orchestration/webhook/orchestration.webhook.service'; @@ -22,6 +21,8 @@ export class Webhook extends BaseCommand { protected server = Container.get(WebhookServer); + override needsCommunityPackages = true; + constructor(argv: string[], cmdConfig: Config) { super(argv, cmdConfig); this.setInstanceType('webhook'); @@ -84,7 +85,7 @@ export class Webhook extends BaseCommand { await this.initExternalHooks(); this.logger.debug('External hooks init complete'); await this.initExternalSecrets(); - this.logger.debug('External seecrets init complete'); + this.logger.debug('External secrets init complete'); } async run() { @@ -94,7 +95,8 @@ export class Webhook extends BaseCommand { ); } - await Container.get(Queue).init(); + const { ScalingService } = await import('@/scaling/scaling.service'); + await Container.get(ScalingService).setupQueue(); await this.server.start(); this.logger.debug(`Webhook listener ID: ${this.server.uniqueInstanceId}`); this.logger.info('Webhook listener waiting for requests.'); diff --git a/packages/cli/src/commands/worker.ts b/packages/cli/src/commands/worker.ts index 4f582904cddbb..5e75e4792d30d 100644 --- a/packages/cli/src/commands/worker.ts +++ b/packages/cli/src/commands/worker.ts @@ -2,22 +2,13 @@ import { Container } from 'typedi'; import { Flags, type Config } from '@oclif/core'; import express from 'express'; import http from 'http'; -import type PCancelable from 'p-cancelable'; -import { GlobalConfig } from '@n8n/config'; -import { WorkflowExecute } from 'n8n-core'; -import type { ExecutionStatus, IExecuteResponsePromiseData, INodeTypes, IRun } from 'n8n-workflow'; -import { Workflow, sleep, ApplicationError } from 'n8n-workflow'; +import { ApplicationError } from 'n8n-workflow'; import * as Db from '@/Db'; import * as ResponseHelper from '@/ResponseHelper'; -import * as WebhookHelpers from '@/WebhookHelpers'; -import * as WorkflowExecuteAdditionalData from '@/WorkflowExecuteAdditionalData'; import config from '@/config'; -import type { Job, JobId, JobResponse, WebhookResponse } from '@/Queue'; -import { Queue } from '@/Queue'; +import type { ScalingService } from '@/scaling/scaling.service'; import { N8N_VERSION, inTest } from '@/constants'; -import { ExecutionRepository } from '@db/repositories/execution.repository'; -import { WorkflowRepository } from '@db/repositories/workflow.repository'; import type { ICredentialsOverwrite } from '@/Interfaces'; import { CredentialsOverwrites } from '@/CredentialsOverwrites'; import { rawBodyReader, bodyParser } from '@/middlewares'; @@ -26,11 +17,10 @@ import type { RedisServicePubSubSubscriber } from '@/services/redis/RedisService import { EventMessageGeneric } from '@/eventbus/EventMessageClasses/EventMessageGeneric'; import { OrchestrationHandlerWorkerService } from '@/services/orchestration/worker/orchestration.handler.worker.service'; import { OrchestrationWorkerService } from '@/services/orchestration/worker/orchestration.worker.service'; -import type { WorkerJobStatusSummary } from '@/services/orchestration/worker/types'; import { ServiceUnavailableError } from '@/errors/response-errors/service-unavailable.error'; import { BaseCommand } from './BaseCommand'; -import { MaxStalledCountError } from '@/errors/max-stalled-count.error'; -import { AuditEventRelay } from '@/eventbus/audit-event-relay.service'; +import { JobProcessor } from '@/scaling/job-processor'; +import { LogStreamingEventRelay } from '@/events/log-streaming-event-relay'; export class Worker extends BaseCommand { static description = '\nStarts a n8n worker'; @@ -45,18 +35,22 @@ export class Worker extends BaseCommand { }), }; - static runningJobs: { - [key: string]: PCancelable; - } = {}; + /** + * How many jobs this worker may run concurrently. + * + * Taken from env var `N8N_CONCURRENCY_PRODUCTION_LIMIT` if set to a value + * other than -1, else taken from `--concurrency` flag. + */ + concurrency: number; - static runningJobsSummary: { - [jobId: string]: WorkerJobStatusSummary; - } = {}; + scalingService: ScalingService; - static jobQueue: Queue; + jobProcessor: JobProcessor; redisSubscriber: RedisServicePubSubSubscriber; + override needsCommunityPackages = true; + /** * Stop n8n in a graceful way. * Make for example sure that all the webhooks from third party services @@ -67,23 +61,6 @@ export class Worker extends BaseCommand { try { await this.externalHooks?.run('n8n.stop', []); - - const hardStopTimeMs = Date.now() + this.gracefulShutdownTimeoutInS * 1000; - - // Wait for active workflow executions to finish - let count = 0; - while (Object.keys(Worker.runningJobs).length !== 0) { - if (count++ % 4 === 0) { - const waitLeft = Math.ceil((hardStopTimeMs - Date.now()) / 1000); - this.logger.info( - `Waiting for ${ - Object.keys(Worker.runningJobs).length - } active executions to finish... (max wait ${waitLeft} more seconds)`, - ); - } - - await sleep(500); - } } catch (error) { await this.exitWithCrash('There was an error shutting down n8n.', error); } @@ -91,143 +68,6 @@ export class Worker extends BaseCommand { await this.exitSuccessFully(); } - async runJob(job: Job, nodeTypes: INodeTypes): Promise { - const { executionId, loadStaticData } = job.data; - const executionRepository = Container.get(ExecutionRepository); - const fullExecutionData = await executionRepository.findSingleExecution(executionId, { - includeData: true, - unflattenData: true, - }); - - if (!fullExecutionData) { - this.logger.error( - `Worker failed to find data of execution "${executionId}" in database. Cannot continue.`, - { executionId }, - ); - throw new ApplicationError( - 'Unable to find data of execution in database. Aborting execution.', - { extra: { executionId } }, - ); - } - const workflowId = fullExecutionData.workflowData.id; - - this.logger.info( - `Start job: ${job.id} (Workflow ID: ${workflowId} | Execution: ${executionId})`, - ); - await executionRepository.updateStatus(executionId, 'running'); - - let { staticData } = fullExecutionData.workflowData; - if (loadStaticData) { - const workflowData = await Container.get(WorkflowRepository).findOne({ - select: ['id', 'staticData'], - where: { - id: workflowId, - }, - }); - if (workflowData === null) { - this.logger.error( - 'Worker execution failed because workflow could not be found in database.', - { workflowId, executionId }, - ); - throw new ApplicationError('Workflow could not be found', { extra: { workflowId } }); - } - staticData = workflowData.staticData; - } - - const workflowSettings = fullExecutionData.workflowData.settings ?? {}; - - let workflowTimeout = workflowSettings.executionTimeout ?? config.getEnv('executions.timeout'); // initialize with default - - let executionTimeoutTimestamp: number | undefined; - if (workflowTimeout > 0) { - workflowTimeout = Math.min(workflowTimeout, config.getEnv('executions.maxTimeout')); - executionTimeoutTimestamp = Date.now() + workflowTimeout * 1000; - } - - const workflow = new Workflow({ - id: workflowId, - name: fullExecutionData.workflowData.name, - nodes: fullExecutionData.workflowData.nodes, - connections: fullExecutionData.workflowData.connections, - active: fullExecutionData.workflowData.active, - nodeTypes, - staticData, - settings: fullExecutionData.workflowData.settings, - }); - - const additionalData = await WorkflowExecuteAdditionalData.getBase( - undefined, - undefined, - executionTimeoutTimestamp, - ); - additionalData.hooks = WorkflowExecuteAdditionalData.getWorkflowHooksWorkerExecuter( - fullExecutionData.mode, - job.data.executionId, - fullExecutionData.workflowData, - { - retryOf: fullExecutionData.retryOf as string, - }, - ); - - additionalData.hooks.hookFunctions.sendResponse = [ - async (response: IExecuteResponsePromiseData): Promise => { - const progress: WebhookResponse = { - executionId, - response: WebhookHelpers.encodeWebhookResponse(response), - }; - await job.progress(progress); - }, - ]; - - additionalData.executionId = executionId; - - additionalData.setExecutionStatus = (status: ExecutionStatus) => { - // Can't set the status directly in the queued worker, but it will happen in InternalHook.onWorkflowPostExecute - this.logger.debug(`Queued worker execution status for ${executionId} is "${status}"`); - }; - - let workflowExecute: WorkflowExecute; - let workflowRun: PCancelable; - if (fullExecutionData.data !== undefined) { - workflowExecute = new WorkflowExecute( - additionalData, - fullExecutionData.mode, - fullExecutionData.data, - ); - workflowRun = workflowExecute.processRunExecutionData(workflow); - } else { - // Execute all nodes - // Can execute without webhook so go on - workflowExecute = new WorkflowExecute(additionalData, fullExecutionData.mode); - workflowRun = workflowExecute.run(workflow); - } - - Worker.runningJobs[job.id] = workflowRun; - Worker.runningJobsSummary[job.id] = { - jobId: job.id.toString(), - executionId, - workflowId: fullExecutionData.workflowId ?? '', - workflowName: fullExecutionData.workflowData.name, - mode: fullExecutionData.mode, - startedAt: fullExecutionData.startedAt, - retryOf: fullExecutionData.retryOf ?? '', - status: fullExecutionData.status, - }; - - // Wait till the execution is finished - await workflowRun; - - delete Worker.runningJobs[job.id]; - delete Worker.runningJobsSummary[job.id]; - - // do NOT call workflowExecuteAfter hook here, since it is being called from processSuccessExecution() - // already! - - return { - success: true, - }; - } - constructor(argv: string[], cmdConfig: Config) { super(argv, cmdConfig); @@ -245,7 +85,7 @@ export class Worker extends BaseCommand { const { QUEUE_WORKER_TIMEOUT } = process.env; if (QUEUE_WORKER_TIMEOUT) { this.gracefulShutdownTimeoutInS = - parseInt(QUEUE_WORKER_TIMEOUT, 10) || config.default('queue.bull.gracefulShutdownTimeout'); + parseInt(QUEUE_WORKER_TIMEOUT, 10) || this.globalConfig.queue.bull.gracefulShutdownTimeout; this.logger.warn( 'QUEUE_WORKER_TIMEOUT has been deprecated. Rename it to N8N_GRACEFUL_SHUTDOWN_TIMEOUT.', ); @@ -255,6 +95,7 @@ export class Worker extends BaseCommand { this.logger.debug('Starting n8n worker...'); this.logger.debug(`Queue mode id: ${this.queueModeId}`); + await this.setConcurrency(); await super.init(); await this.initLicense(); @@ -267,8 +108,7 @@ export class Worker extends BaseCommand { this.logger.debug('External secrets init complete'); await this.initEventBus(); this.logger.debug('Event bus init complete'); - await this.initQueue(); - this.logger.debug('Queue init complete'); + await this.initScalingService(); await this.initOrchestration(); this.logger.debug('Orchestration init complete'); @@ -286,7 +126,7 @@ export class Worker extends BaseCommand { await Container.get(MessageEventBus).initialize({ workerId: this.queueModeId, }); - Container.get(AuditEventRelay).init(); + Container.get(LogStreamingEventRelay).init(); } /** @@ -300,84 +140,32 @@ export class Worker extends BaseCommand { await Container.get(OrchestrationHandlerWorkerService).initWithOptions({ queueModeId: this.queueModeId, redisPublisher: Container.get(OrchestrationWorkerService).redisPublisher, - getRunningJobIds: () => Object.keys(Worker.runningJobs), - getRunningJobsSummary: () => Object.values(Worker.runningJobsSummary), + getRunningJobIds: () => this.jobProcessor.getRunningJobIds(), + getRunningJobsSummary: () => this.jobProcessor.getRunningJobsSummary(), }); } - async initQueue() { + async setConcurrency() { const { flags } = await this.parse(Worker); - const redisConnectionTimeoutLimit = config.getEnv('queue.bull.redis.timeoutThreshold'); - - this.logger.debug( - `Opening Redis connection to listen to messages with timeout ${redisConnectionTimeoutLimit}`, - ); - - Worker.jobQueue = Container.get(Queue); - await Worker.jobQueue.init(); - this.logger.debug('Queue singleton ready'); - const envConcurrency = config.getEnv('executions.concurrency.productionLimit'); - const concurrency = envConcurrency !== -1 ? envConcurrency : flags.concurrency; - Worker.jobQueue.setConcurrency(concurrency); - void Worker.jobQueue.process(async (job) => await this.runJob(job, this.nodeTypes)); + this.concurrency = envConcurrency !== -1 ? envConcurrency : flags.concurrency; + } - Worker.jobQueue.getBullObjectInstance().on('global:progress', (jobId: JobId, progress) => { - // Progress of a job got updated which does get used - // to communicate that a job got canceled. + async initScalingService() { + const { ScalingService } = await import('@/scaling/scaling.service'); + this.scalingService = Container.get(ScalingService); - if (progress === -1) { - // Job has to get canceled - if (Worker.runningJobs[jobId] !== undefined) { - // Job is processed by current worker so cancel - Worker.runningJobs[jobId].cancel(); - delete Worker.runningJobs[jobId]; - } - } - }); + await this.scalingService.setupQueue(); - let lastTimer = 0; - let cumulativeTimeout = 0; - Worker.jobQueue.getBullObjectInstance().on('error', (error: Error) => { - if (error.toString().includes('ECONNREFUSED')) { - const now = Date.now(); - if (now - lastTimer > 30000) { - // Means we had no timeout at all or last timeout was temporary and we recovered - lastTimer = now; - cumulativeTimeout = 0; - } else { - cumulativeTimeout += now - lastTimer; - lastTimer = now; - if (cumulativeTimeout > redisConnectionTimeoutLimit) { - this.logger.error( - `Unable to connect to Redis after ${redisConnectionTimeoutLimit}. Exiting process.`, - ); - process.exit(1); - } - } - this.logger.warn('Redis unavailable - trying to reconnect...'); - } else if (error.toString().includes('Error initializing Lua scripts')) { - // This is a non-recoverable error - // Happens when worker starts and Redis is unavailable - // Even if Redis comes back online, worker will be zombie - this.logger.error('Error initializing worker.'); - process.exit(2); - } else { - this.logger.error('Error from queue: ', error); - - if (error.message.includes('job stalled more than maxStalledCount')) { - throw new MaxStalledCountError(error); - } + this.scalingService.setupWorker(this.concurrency); - throw error; - } - }); + this.jobProcessor = Container.get(JobProcessor); } async setupHealthMonitor() { - const port = config.getEnv('queue.health.port'); + const { port } = this.globalConfig.queue.health; const app = express(); app.disable('x-powered-by'); @@ -409,7 +197,7 @@ export class Worker extends BaseCommand { // if it loses the connection to redis try { // Redis ping - await Worker.jobQueue.ping(); + await this.scalingService.pingQueue(); } catch (e) { this.logger.error('No Redis connection!', e as Error); const error = new ServiceUnavailableError('No Redis connection!'); @@ -429,8 +217,7 @@ export class Worker extends BaseCommand { let presetCredentialsLoaded = false; - const globalConfig = Container.get(GlobalConfig); - const endpointPresetCredentials = globalConfig.credentials.overwrite.endpoint; + const endpointPresetCredentials = this.globalConfig.credentials.overwrite.endpoint; if (endpointPresetCredentials !== '') { // POST endpoint to set preset credentials app.post( @@ -476,18 +263,16 @@ export class Worker extends BaseCommand { } async run() { - const { flags } = await this.parse(Worker); - this.logger.info('\nn8n worker is now ready'); this.logger.info(` * Version: ${N8N_VERSION}`); - this.logger.info(` * Concurrency: ${flags.concurrency}`); + this.logger.info(` * Concurrency: ${this.concurrency}`); this.logger.info(''); - if (config.getEnv('queue.health.active')) { + if (this.globalConfig.queue.health.active) { await this.setupHealthMonitor(); } - if (process.stdout.isTTY) { + if (!inTest && process.stdout.isTTY) { process.stdin.setRawMode(true); process.stdin.resume(); process.stdin.setEncoding('utf8'); diff --git a/packages/cli/src/concurrency/__tests__/concurrency-control.service.test.ts b/packages/cli/src/concurrency/__tests__/concurrency-control.service.test.ts index c694ab29406a3..08f58ac600324 100644 --- a/packages/cli/src/concurrency/__tests__/concurrency-control.service.test.ts +++ b/packages/cli/src/concurrency/__tests__/concurrency-control.service.test.ts @@ -12,7 +12,7 @@ import type { WorkflowExecuteMode as ExecutionMode } from 'n8n-workflow'; import type { ExecutionRepository } from '@/databases/repositories/execution.repository'; import type { IExecutingWorkflowData } from '@/Interfaces'; import type { Telemetry } from '@/telemetry'; -import type { EventService } from '@/eventbus/event.service'; +import type { EventService } from '@/events/event.service'; describe('ConcurrencyControlService', () => { const logger = mock(); diff --git a/packages/cli/src/concurrency/concurrency-control.service.ts b/packages/cli/src/concurrency/concurrency-control.service.ts index 50c73fa668b4e..6e62e9b78df54 100644 --- a/packages/cli/src/concurrency/concurrency-control.service.ts +++ b/packages/cli/src/concurrency/concurrency-control.service.ts @@ -8,7 +8,7 @@ import { ExecutionRepository } from '@/databases/repositories/execution.reposito import type { WorkflowExecuteMode as ExecutionMode } from 'n8n-workflow'; import type { IExecutingWorkflowData } from '@/Interfaces'; import { Telemetry } from '@/telemetry'; -import { EventService } from '@/eventbus/event.service'; +import { EventService } from '@/events/event.service'; export const CLOUD_TEMP_PRODUCTION_LIMIT = 999; export const CLOUD_TEMP_REPORTABLE_THRESHOLDS = [5, 10, 20, 50, 100, 200]; diff --git a/packages/cli/test/unit/config/index.test.ts b/packages/cli/src/config/__tests__/index.test.ts similarity index 100% rename from packages/cli/test/unit/config/index.test.ts rename to packages/cli/src/config/__tests__/index.test.ts diff --git a/packages/cli/src/config/schema.ts b/packages/cli/src/config/schema.ts index 068317e4918f6..a0db9a4c36d16 100644 --- a/packages/cli/src/config/schema.ts +++ b/packages/cli/src/config/schema.ts @@ -4,6 +4,7 @@ import { Container } from 'typedi'; import { InstanceSettings } from 'n8n-core'; import { LOG_LEVELS } from 'n8n-workflow'; import { ensureStringArray } from './utils'; +import { GlobalConfig } from '@n8n/config'; convict.addFormat({ name: 'comma-separated-list', @@ -161,119 +162,6 @@ export const schema = { }, }, - queue: { - health: { - active: { - doc: 'If health checks should be enabled', - format: Boolean, - default: false, - env: 'QUEUE_HEALTH_CHECK_ACTIVE', - }, - port: { - doc: 'Port to serve health check on if activated', - format: Number, - default: 5678, - env: 'QUEUE_HEALTH_CHECK_PORT', - }, - }, - bull: { - prefix: { - doc: 'Prefix for all bull queue keys', - format: String, - default: 'bull', - env: 'QUEUE_BULL_PREFIX', - }, - redis: { - db: { - doc: 'Redis DB', - format: Number, - default: 0, - env: 'QUEUE_BULL_REDIS_DB', - }, - host: { - doc: 'Redis Host', - format: String, - default: 'localhost', - env: 'QUEUE_BULL_REDIS_HOST', - }, - password: { - doc: 'Redis Password', - format: String, - default: '', - env: 'QUEUE_BULL_REDIS_PASSWORD', - }, - port: { - doc: 'Redis Port', - format: Number, - default: 6379, - env: 'QUEUE_BULL_REDIS_PORT', - }, - timeoutThreshold: { - doc: 'Max cumulative timeout (in milliseconds) of connection retries before process exit', - format: Number, - default: 10000, - env: 'QUEUE_BULL_REDIS_TIMEOUT_THRESHOLD', - }, - username: { - doc: 'Redis Username (needs Redis >= 6)', - format: String, - default: '', - env: 'QUEUE_BULL_REDIS_USERNAME', - }, - clusterNodes: { - doc: 'Redis Cluster startup nodes (comma separated list of host:port pairs)', - format: String, - default: '', - env: 'QUEUE_BULL_REDIS_CLUSTER_NODES', - }, - tls: { - format: Boolean, - default: false, - env: 'QUEUE_BULL_REDIS_TLS', - doc: 'Enable TLS on Redis connections. Default: false', - }, - }, - queueRecoveryInterval: { - doc: 'If > 0 enables an active polling to the queue that can recover for Redis crashes. Given in seconds; 0 is disabled. May increase Redis traffic significantly.', - format: Number, - default: 60, - env: 'QUEUE_RECOVERY_INTERVAL', - }, - gracefulShutdownTimeout: { - doc: '[DEPRECATED] (Use N8N_GRACEFUL_SHUTDOWN_TIMEOUT instead) How long should n8n wait for running executions before exiting worker process (seconds)', - format: Number, - default: 30, - env: 'QUEUE_WORKER_TIMEOUT', - }, - settings: { - lockDuration: { - doc: 'How long (ms) is the lease period for a worker to work on a message', - format: Number, - default: 30000, - env: 'QUEUE_WORKER_LOCK_DURATION', - }, - lockRenewTime: { - doc: 'How frequently (ms) should a worker renew the lease time', - format: Number, - default: 15000, - env: 'QUEUE_WORKER_LOCK_RENEW_TIME', - }, - stalledInterval: { - doc: 'How often check for stalled jobs (use 0 for never checking)', - format: Number, - default: 30000, - env: 'QUEUE_WORKER_STALLED_INTERVAL', - }, - maxStalledCount: { - doc: 'Max amount of times a stalled job will be re-processed', - format: Number, - default: 1, - env: 'QUEUE_WORKER_MAX_STALLED_COUNT', - }, - }, - }, - }, - generic: { // The timezone to use. Is important for nodes like "Cron" which start the // workflow automatically at a specified time. This setting can also be @@ -340,7 +228,7 @@ export const schema = { env: 'N8N_RESTRICT_FILE_ACCESS_TO', }, blockFileAccessToN8nFiles: { - doc: 'If set to true it will block access to all files in the ".n8n" directory and user defined config files.', + doc: 'If set to true it will block access to all files in the ".n8n" directory, the static cache dir at ~/.cache/n8n/public, and user defined config files.', format: Boolean, default: true, env: 'N8N_BLOCK_FILE_ACCESS_TO_N8N_FILES', @@ -381,12 +269,17 @@ export const schema = { default: 0, env: 'N8N_USER_MANAGEMENT_JWT_REFRESH_TIMEOUT_HOURS', }, + + /** + * @important Do not remove until after cloud hooks are updated to stop using convict config. + */ isInstanceOwnerSetUp: { // n8n loads this setting from DB on startup doc: "Whether the instance owner's account has been set up", format: Boolean, default: false, }, + authenticationMethod: { doc: 'How to authenticate users (e.g. "email", "ldap", "saml")', format: ['email', 'ldap', 'saml'] as const, @@ -654,43 +547,19 @@ export const schema = { }, }, - cache: { - backend: { - doc: 'Backend to use for caching', - format: ['memory', 'redis', 'auto'] as const, - default: 'auto', - env: 'N8N_CACHE_BACKEND', - }, - memory: { - maxSize: { - doc: 'Maximum size of memory cache in bytes', - format: Number, - default: 3 * 1024 * 1024, // 3 MB - env: 'N8N_CACHE_MEMORY_MAX_SIZE', - }, - ttl: { - doc: 'Time to live for cached items in memory (in ms)', - format: Number, - default: 3600 * 1000, // 1 hour - env: 'N8N_CACHE_MEMORY_TTL', - }, - }, - redis: { - prefix: { - doc: 'Prefix for all cache keys', - format: String, - default: 'cache', - env: 'N8N_CACHE_REDIS_KEY_PREFIX', - }, - ttl: { - doc: 'Time to live for cached items in redis (in ms), 0 for no TTL', - format: Number, - default: 3600 * 1000, // 1 hour - env: 'N8N_CACHE_REDIS_TTL', - }, + /** + * @important Do not remove until after cloud hooks are updated to stop using convict config. + */ + endpoints: { + rest: { + format: String, + default: Container.get(GlobalConfig).endpoints.rest, }, }, + /** + * @important Do not remove until after cloud hooks are updated to stop using convict config. + */ ai: { enabled: { doc: 'Whether AI features are enabled', @@ -740,12 +609,6 @@ export const schema = { }, }, - instanceRole: { - doc: 'Always `leader` in single-main setup. `leader` or `follower` in multi-main setup.', - format: ['unset', 'leader', 'follower'] as const, - default: 'unset', // only until Start.initOrchestration - }, - multiMainSetup: { enabled: { doc: 'Whether to enable multi-main setup for queue mode (license required)', diff --git a/packages/cli/src/constants.ts b/packages/cli/src/constants.ts index 41c95cb01ace6..dfb072576aa9f 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', + COMMUNITY_NODES_CUSTOM_REGISTRY: 'feat:communityNodes:customRegistry', } as const; export const LICENSE_QUOTAS = { diff --git a/packages/cli/test/unit/controllers/curl.controller.test.ts b/packages/cli/src/controllers/__tests__/curl.controller.test.ts similarity index 100% rename from packages/cli/test/unit/controllers/curl.controller.test.ts rename to packages/cli/src/controllers/__tests__/curl.controller.test.ts diff --git a/packages/cli/test/unit/controllers/dynamic-node-parameters.controller.test.ts b/packages/cli/src/controllers/__tests__/dynamic-node-parameters.controller.test.ts similarity index 100% rename from packages/cli/test/unit/controllers/dynamic-node-parameters.controller.test.ts rename to packages/cli/src/controllers/__tests__/dynamic-node-parameters.controller.test.ts diff --git a/packages/cli/test/unit/controllers/me.controller.test.ts b/packages/cli/src/controllers/__tests__/me.controller.test.ts similarity index 72% rename from packages/cli/test/unit/controllers/me.controller.test.ts rename to packages/cli/src/controllers/__tests__/me.controller.test.ts index 2416ecafbddb4..3ff4c5bbe0860 100644 --- a/packages/cli/test/unit/controllers/me.controller.test.ts +++ b/packages/cli/src/controllers/__tests__/me.controller.test.ts @@ -9,20 +9,25 @@ import { AUTH_COOKIE_NAME } from '@/constants'; import type { AuthenticatedRequest, MeRequest } from '@/requests'; import { UserService } from '@/services/user.service'; import { ExternalHooks } from '@/ExternalHooks'; -import { InternalHooks } from '@/InternalHooks'; import { License } from '@/License'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { UserRepository } from '@/databases/repositories/user.repository'; -import { badPasswords } from '../shared/testData'; -import { mockInstance } from '../../shared/mocking'; +import { EventService } from '@/events/event.service'; +import { badPasswords } from '@test/testData'; +import { mockInstance } from '@test/mocking'; +import { AuthUserRepository } from '@/databases/repositories/authUser.repository'; +import { MfaService } from '@/Mfa/mfa.service'; +import { InvalidMfaCodeError } from '@/errors/response-errors/invalid-mfa-code.error'; const browserId = 'test-browser-id'; describe('MeController', () => { const externalHooks = mockInstance(ExternalHooks); - const internalHooks = mockInstance(InternalHooks); + const eventService = mockInstance(EventService); const userService = mockInstance(UserService); const userRepository = mockInstance(UserRepository); + const mockMfaService = mockInstance(MfaService); + mockInstance(AuthUserRepository); mockInstance(License).isWithinUsersLimit.mockReturnValue(true); const controller = Container.get(MeController); @@ -44,6 +49,7 @@ describe('MeController', () => { it('should update the user in the DB, and issue a new cookie', async () => { const user = mock({ id: '123', + email: 'valid@email.com', password: 'password', authIdentities: [], role: 'global:owner', @@ -51,6 +57,7 @@ describe('MeController', () => { const reqBody = { email: 'valid@email.com', firstName: 'John', lastName: 'Potato' }; const req = mock({ user, body: reqBody, browserId }); 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); @@ -64,7 +71,10 @@ describe('MeController', () => { ]); expect(userService.update).toHaveBeenCalled(); - + expect(eventService.emit).toHaveBeenCalledWith('user-updated', { + user, + fieldsChanged: ['firstName', 'lastName'], // email did not change + }); expect(res.cookie).toHaveBeenCalledWith( AUTH_COOKIE_NAME, 'signed-token', @@ -174,7 +184,7 @@ describe('MeController', () => { it('should update the password in the DB, and issue a new cookie', async () => { const req = mock({ - user: mock({ password: passwordHash }), + user: mock({ password: passwordHash, mfaEnabled: false }), body: { currentPassword: 'old_password', newPassword: 'NewPassword123' }, browserId, }); @@ -202,9 +212,53 @@ describe('MeController', () => { req.user.password, ]); - expect(internalHooks.onUserUpdate).toHaveBeenCalledWith({ + expect(eventService.emit).toHaveBeenCalledWith('user-updated', { user: req.user, - fields_changed: ['password'], + fieldsChanged: ['password'], + }); + }); + + describe('mfa enabled', () => { + it('should throw BadRequestError if mfa code is missing', async () => { + const req = mock({ + user: mock({ password: passwordHash, mfaEnabled: true }), + body: { currentPassword: 'old_password', newPassword: 'NewPassword123' }, + }); + + await expect(controller.updatePassword(req, mock())).rejects.toThrowError( + new BadRequestError('Two-factor code is required to change password.'), + ); + }); + + it('should throw InvalidMfaCodeError if invalid mfa code is given', async () => { + const req = mock({ + user: mock({ password: passwordHash, mfaEnabled: true }), + body: { currentPassword: 'old_password', newPassword: 'NewPassword123', mfaCode: '123' }, + }); + mockMfaService.validateMfa.mockResolvedValue(false); + + await expect(controller.updatePassword(req, mock())).rejects.toThrow(InvalidMfaCodeError); + }); + + it('should succeed when mfa code is correct', async () => { + const req = mock({ + user: mock({ password: passwordHash, mfaEnabled: true }), + body: { + currentPassword: 'old_password', + newPassword: 'NewPassword123', + mfaCode: 'valid', + }, + browserId, + }); + const res = mock(); + userRepository.save.calledWith(req.user).mockResolvedValue(req.user); + jest.spyOn(jwt, 'sign').mockImplementation(() => 'new-signed-token'); + mockMfaService.validateMfa.mockResolvedValue(true); + + const result = await controller.updatePassword(req, res); + + expect(result).toEqual({ success: true }); + expect(req.user.password).not.toBe(passwordHash); }); }); }); @@ -218,6 +272,26 @@ describe('MeController', () => { new BadRequestError('Personalization answers are mandatory'), ); }); + + it('should throw BadRequestError on XSS attempt', async () => { + const req = mock({ + body: { 'test-answer': '' }, + }); + + await expect(controller.storeSurveyAnswers(req)).rejects.toThrowError(BadRequestError); + }); + }); + + describe('updateCurrentUserSettings', () => { + it('should throw BadRequestError on XSS attempt', async () => { + const req = mock({ + body: { + userActivated: '', + }, + }); + + await expect(controller.updateCurrentUserSettings(req)).rejects.toThrowError(BadRequestError); + }); }); describe('API Key methods', () => { diff --git a/packages/cli/test/unit/controllers/owner.controller.test.ts b/packages/cli/src/controllers/__tests__/owner.controller.test.ts similarity index 94% rename from packages/cli/test/unit/controllers/owner.controller.test.ts rename to packages/cli/src/controllers/__tests__/owner.controller.test.ts index 0057c2370896b..8c4f00abeeab0 100644 --- a/packages/cli/test/unit/controllers/owner.controller.test.ts +++ b/packages/cli/src/controllers/__tests__/owner.controller.test.ts @@ -10,18 +10,16 @@ import type { User } from '@db/entities/User'; import type { SettingsRepository } from '@db/repositories/settings.repository'; import type { UserRepository } from '@db/repositories/user.repository'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import type { InternalHooks } from '@/InternalHooks'; import { License } from '@/License'; import type { OwnerRequest } from '@/requests'; import type { UserService } from '@/services/user.service'; import { PasswordUtility } from '@/services/password.utility'; -import { mockInstance } from '../../shared/mocking'; -import { badPasswords } from '../shared/testData'; +import { mockInstance } from '@test/mocking'; +import { badPasswords } from '@test/testData'; describe('OwnerController', () => { const configGetSpy = jest.spyOn(config, 'getEnv'); - const internalHooks = mock(); const authService = mock(); const userService = mock(); const userRepository = mock(); @@ -29,7 +27,7 @@ describe('OwnerController', () => { mockInstance(License).isWithinUsersLimit.mockReturnValue(true); const controller = new OwnerController( mock(), - internalHooks, + mock(), settingsRepository, authService, userService, diff --git a/packages/cli/test/unit/controllers/translation.controller.test.ts b/packages/cli/src/controllers/__tests__/translation.controller.test.ts similarity index 100% rename from packages/cli/test/unit/controllers/translation.controller.test.ts rename to packages/cli/src/controllers/__tests__/translation.controller.test.ts diff --git a/packages/cli/test/unit/controllers/userSettings.controller.test.ts b/packages/cli/src/controllers/__tests__/userSettings.controller.test.ts similarity index 96% rename from packages/cli/test/unit/controllers/userSettings.controller.test.ts rename to packages/cli/src/controllers/__tests__/userSettings.controller.test.ts index e29afb74ccc29..3794008641fa5 100644 --- a/packages/cli/test/unit/controllers/userSettings.controller.test.ts +++ b/packages/cli/src/controllers/__tests__/userSettings.controller.test.ts @@ -60,7 +60,7 @@ describe('UserSettingsController', () => { [], ], [ - 'updates user settings, reseting to waiting state', + 'updates user settings, resetting to waiting state', { waitingForResponse: true, ignoredCount: 0, @@ -137,7 +137,7 @@ describe('UserSettingsController', () => { 'is waitingForResponse but missing ignoredCount', { lastShownAt: 123, waitingForResponse: true }, ], - ])('thows error when request payload is %s', async (_, payload) => { + ])('throws error when request payload is %s', async (_, payload) => { const req = mock(); req.user.id = '1'; req.body = payload; diff --git a/packages/cli/src/controllers/__tests__/users.controller.test.ts b/packages/cli/src/controllers/__tests__/users.controller.test.ts new file mode 100644 index 0000000000000..38a4399cab2ba --- /dev/null +++ b/packages/cli/src/controllers/__tests__/users.controller.test.ts @@ -0,0 +1,52 @@ +import { mock } from 'jest-mock-extended'; +import { UsersController } from '../users.controller'; +import type { UserRequest } from '@/requests'; +import type { EventService } from '@/events/event.service'; +import type { User } from '@/databases/entities/User'; +import type { UserRepository } from '@/databases/repositories/user.repository'; +import type { ProjectService } from '@/services/project.service'; + +describe('UsersController', () => { + const eventService = mock(); + const userRepository = mock(); + const projectService = mock(); + const controller = new UsersController( + mock(), + mock(), + mock(), + mock(), + userRepository, + mock(), + mock(), + mock(), + mock(), + mock(), + projectService, + eventService, + ); + + beforeEach(() => { + jest.restoreAllMocks(); + }); + + describe('changeGlobalRole', () => { + it('should emit event user-changed-role', async () => { + const request = mock({ + user: { id: '123' }, + params: { id: '456' }, + body: { newRoleName: 'global:member' }, + }); + userRepository.findOne.mockResolvedValue(mock({ id: '456' })); + projectService.getUserOwnedOrAdminProjects.mockResolvedValue([]); + + await controller.changeGlobalRole(request); + + expect(eventService.emit).toHaveBeenCalledWith('user-changed-role', { + userId: '123', + targetUserId: '456', + targetUserNewRole: 'global:member', + publicApi: false, + }); + }); + }); +}); diff --git a/packages/cli/src/controllers/auth.controller.ts b/packages/cli/src/controllers/auth.controller.ts index 7711d95177e0f..99b5c523207a1 100644 --- a/packages/cli/src/controllers/auth.controller.ts +++ b/packages/cli/src/controllers/auth.controller.ts @@ -14,7 +14,6 @@ import { isLdapCurrentAuthenticationMethod, isSamlCurrentAuthenticationMethod, } from '@/sso/ssoHelpers'; -import { InternalHooks } from '../InternalHooks'; import { License } from '@/License'; import { UserService } from '@/services/user.service'; import { MfaService } from '@/Mfa/mfa.service'; @@ -24,13 +23,12 @@ import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; import { ApplicationError } from 'n8n-workflow'; import { UserRepository } from '@/databases/repositories/user.repository'; -import { EventService } from '@/eventbus/event.service'; +import { EventService } from '@/events/event.service'; @RestController() export class AuthController { constructor( private readonly logger: Logger, - private readonly internalHooks: InternalHooks, private readonly authService: AuthService, private readonly mfaService: MfaService, private readonly userService: UserService, @@ -179,7 +177,6 @@ export class AuthController { throw new BadRequestError('Invalid request'); } - this.internalHooks.onUserInviteEmailClick({ inviter, invitee }); this.eventService.emit('user-invite-email-click', { inviter, invitee }); const { firstName, lastName } = inviter; diff --git a/packages/cli/src/controllers/communityPackages.controller.ts b/packages/cli/src/controllers/communityPackages.controller.ts index 1860e1df86652..d9e7f49719679 100644 --- a/packages/cli/src/controllers/communityPackages.controller.ts +++ b/packages/cli/src/controllers/communityPackages.controller.ts @@ -1,11 +1,9 @@ -import { Request, Response, NextFunction } from 'express'; -import config from '@/config'; import { RESPONSE_ERROR_MESSAGES, STARTER_TEMPLATE_NAME, UNKNOWN_FAILURE_REASON, } from '@/constants'; -import { Delete, Get, Middleware, Patch, Post, RestController, GlobalScope } from '@/decorators'; +import { Delete, Get, Patch, Post, RestController, GlobalScope } from '@/decorators'; import { NodeRequest } from '@/requests'; import type { InstalledPackages } from '@db/entities/InstalledPackages'; import type { CommunityPackages } from '@/Interfaces'; @@ -13,7 +11,7 @@ import { Push } from '@/push'; import { CommunityPackagesService } from '@/services/communityPackages.service'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { InternalServerError } from '@/errors/response-errors/internal-server.error'; -import { EventService } from '@/eventbus/event.service'; +import { EventService } from '@/events/event.service'; const { PACKAGE_NOT_INSTALLED, @@ -40,17 +38,6 @@ export class CommunityPackagesController { private readonly eventService: EventService, ) {} - // TODO: move this into a new decorator `@IfConfig('executions.mode', 'queue')` - @Middleware() - checkIfCommunityNodesEnabled(req: Request, res: Response, next: NextFunction) { - if (config.getEnv('executions.mode') === 'queue' && req.method !== 'GET') - res.status(400).json({ - status: 'error', - message: 'Package management is disabled when running in "queue" mode', - }); - else next(); - } - @Post('/') @GlobalScope('communityPackage:install') async installPackage(req: NodeRequest.Post) { @@ -99,7 +86,7 @@ export class CommunityPackagesController { let installedPackage: InstalledPackages; try { - installedPackage = await this.communityPackagesService.installNpmModule( + installedPackage = await this.communityPackagesService.installPackage( parsed.packageName, parsed.version, ); @@ -207,7 +194,7 @@ export class CommunityPackagesController { } try { - await this.communityPackagesService.removeNpmModule(name, installedPackage); + await this.communityPackagesService.removePackage(name, installedPackage); } catch (error) { const message = [ `Error removing package "${name}"`, @@ -252,7 +239,7 @@ export class CommunityPackagesController { } try { - const newInstalledPackage = await this.communityPackagesService.updateNpmModule( + const newInstalledPackage = await this.communityPackagesService.updatePackage( this.communityPackagesService.parseNpmPackageName(name).packageName, previouslyInstalledPackage, ); diff --git a/packages/cli/src/controllers/dynamicNodeParameters.controller.ts b/packages/cli/src/controllers/dynamicNodeParameters.controller.ts index c6a46aa3d4eed..bfd1cf651b875 100644 --- a/packages/cli/src/controllers/dynamicNodeParameters.controller.ts +++ b/packages/cli/src/controllers/dynamicNodeParameters.controller.ts @@ -1,4 +1,4 @@ -import type { INodePropertyOptions } from 'n8n-workflow'; +import type { INodePropertyOptions, NodeParameterValueType } from 'n8n-workflow'; import { Post, RestController } from '@/decorators'; import { getBase } from '@/WorkflowExecuteAdditionalData'; @@ -92,4 +92,28 @@ export class DynamicNodeParametersController { credentials, ); } + + @Post('/action-result') + async getActionResult( + req: DynamicNodeParametersRequest.ActionResult, + ): Promise { + const { currentNodeParameters, nodeTypeAndVersion, path, credentials, handler, payload } = + req.body; + + const additionalData = await getBase(req.user.id, currentNodeParameters); + + if (handler) { + return await this.service.getActionResult( + handler, + path, + additionalData, + nodeTypeAndVersion, + currentNodeParameters, + payload, + credentials, + ); + } + + return; + } } diff --git a/packages/cli/src/controllers/e2e.controller.ts b/packages/cli/src/controllers/e2e.controller.ts index 3954bb1caf891..20224795052e8 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.COMMUNITY_NODES_CUSTOM_REGISTRY]: false, }; private numericFeatures: Record = { diff --git a/packages/cli/src/controllers/invitation.controller.ts b/packages/cli/src/controllers/invitation.controller.ts index c32bf543002fd..19bd803c5e6c0 100644 --- a/packages/cli/src/controllers/invitation.controller.ts +++ b/packages/cli/src/controllers/invitation.controller.ts @@ -16,15 +16,13 @@ import type { User } from '@/databases/entities/User'; import { UserRepository } from '@db/repositories/user.repository'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; -import { InternalHooks } from '@/InternalHooks'; import { ExternalHooks } from '@/ExternalHooks'; -import { EventService } from '@/eventbus/event.service'; +import { EventService } from '@/events/event.service'; @RestController('/invitations') export class InvitationController { constructor( private readonly logger: Logger, - private readonly internalHooks: InternalHooks, private readonly externalHooks: ExternalHooks, private readonly authService: AuthService, private readonly userService: UserService, @@ -168,11 +166,11 @@ export class InvitationController { this.authService.issueCookie(res, updatedUser, req.browserId); - this.internalHooks.onUserSignup(updatedUser, { - user_type: 'email', - was_disabled_ldap_user: false, + this.eventService.emit('user-signed-up', { + user: updatedUser, + userType: 'email', + wasDisabledLdapUser: false, }); - this.eventService.emit('user-signed-up', { user: updatedUser }); const publicInvitee = await this.userService.toPublic(invitee); diff --git a/packages/cli/src/controllers/me.controller.ts b/packages/cli/src/controllers/me.controller.ts index 3f9366b441260..179076d25d224 100644 --- a/packages/cli/src/controllers/me.controller.ts +++ b/packages/cli/src/controllers/me.controller.ts @@ -6,7 +6,7 @@ import { randomBytes } from 'crypto'; import { AuthService } from '@/auth/auth.service'; import { Delete, Get, Patch, Post, RestController } from '@/decorators'; import { PasswordUtility } from '@/services/password.utility'; -import { validateEntity } from '@/GenericHelpers'; +import { validateEntity, validateRecordNoXss } from '@/GenericHelpers'; import type { User } from '@db/entities/User'; import { AuthenticatedRequest, @@ -19,11 +19,12 @@ import { isSamlLicensedAndEnabled } from '@/sso/saml/samlHelpers'; import { UserService } from '@/services/user.service'; import { Logger } from '@/Logger'; import { ExternalHooks } from '@/ExternalHooks'; -import { InternalHooks } from '@/InternalHooks'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { UserRepository } from '@/databases/repositories/user.repository'; import { isApiEnabled } from '@/PublicApi'; -import { EventService } from '@/eventbus/event.service'; +import { EventService } from '@/events/event.service'; +import { MfaService } from '@/Mfa/mfa.service'; +import { InvalidMfaCodeError } from '@/errors/response-errors/invalid-mfa-code.error'; export const API_KEY_PREFIX = 'n8n_api_'; @@ -40,12 +41,12 @@ export class MeController { constructor( private readonly logger: Logger, private readonly externalHooks: ExternalHooks, - private readonly internalHooks: InternalHooks, private readonly authService: AuthService, private readonly userService: UserService, private readonly passwordUtility: PasswordUtility, private readonly userRepository: UserRepository, private readonly eventService: EventService, + private readonly mfaService: MfaService, ) {} /** @@ -91,6 +92,7 @@ export class MeController { await this.externalHooks.run('user.profile.beforeUpdate', [userId, currentEmail, payload]); + const preUpdateUser = await this.userRepository.findOneByOrFail({ id: userId }); await this.userService.update(userId, payload); const user = await this.userRepository.findOneOrFail({ where: { id: userId }, @@ -100,8 +102,10 @@ export class MeController { this.authService.issueCookie(res, user, req.browserId); - const fieldsChanged = Object.keys(payload); - this.internalHooks.onUserUpdate({ user, fields_changed: fieldsChanged }); + const fieldsChanged = (Object.keys(payload) as Array).filter( + (key) => payload[key] !== preUpdateUser[key], + ); + this.eventService.emit('user-updated', { user, fieldsChanged }); const publicUser = await this.userService.toPublic(user); @@ -114,12 +118,12 @@ export class MeController { /** * Update the logged-in user's password. */ - @Patch('/password') + @Patch('/password', { rateLimit: true }) async updatePassword(req: MeRequest.Password, res: Response) { const { user } = req; - const { currentPassword, newPassword } = req.body; + const { currentPassword, newPassword, mfaCode } = req.body; - // If SAML is enabled, we don't allow the user to change their email address + // If SAML is enabled, we don't allow the user to change their password if (isSamlLicensedAndEnabled()) { this.logger.debug('Attempted to change password for user, while SAML is enabled', { userId: user.id, @@ -144,6 +148,17 @@ export class MeController { const validPassword = this.passwordUtility.validate(newPassword); + if (user.mfaEnabled) { + if (typeof mfaCode !== 'string') { + throw new BadRequestError('Two-factor code is required to change password.'); + } + + const isMfaTokenValid = await this.mfaService.validateMfa(user.id, mfaCode, undefined); + if (!isMfaTokenValid) { + throw new InvalidMfaCodeError(); + } + } + user.password = await this.passwordUtility.hash(validPassword); const updatedUser = await this.userRepository.save(user, { transaction: false }); @@ -151,7 +166,6 @@ export class MeController { this.authService.issueCookie(res, updatedUser, req.browserId); - this.internalHooks.onUserUpdate({ user: updatedUser, fields_changed: ['password'] }); this.eventService.emit('user-updated', { user: updatedUser, fieldsChanged: ['password'] }); await this.externalHooks.run('user.password.update', [updatedUser.email, updatedUser.password]); @@ -176,6 +190,8 @@ export class MeController { throw new BadRequestError('Personalization answers are mandatory'); } + await validateRecordNoXss(personalizationAnswers); + await this.userRepository.save( { id: req.user.id, @@ -186,7 +202,10 @@ export class MeController { this.logger.info('User survey updated successfully', { userId: req.user.id }); - this.internalHooks.onPersonalizationSurveySubmitted(req.user.id, personalizationAnswers); + this.eventService.emit('user-submitted-personalization-survey', { + userId: req.user.id, + answers: personalizationAnswers, + }); return { success: true }; } @@ -234,6 +253,9 @@ export class MeController { const payload = plainToInstance(UserSettingsUpdatePayload, req.body, { excludeExtraneousValues: true, }); + + await validateEntity(payload); + const { id } = req.user; await this.userService.updateSettings(id, payload); diff --git a/packages/cli/src/controllers/mfa.controller.ts b/packages/cli/src/controllers/mfa.controller.ts index 3c58b944d57d0..6c228df47e90e 100644 --- a/packages/cli/src/controllers/mfa.controller.ts +++ b/packages/cli/src/controllers/mfa.controller.ts @@ -1,4 +1,4 @@ -import { Delete, Get, Post, RestController } from '@/decorators'; +import { Get, Post, RestController } from '@/decorators'; import { AuthenticatedRequest, MFA } from '@/requests'; import { MfaService } from '@/Mfa/mfa.service'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; @@ -47,7 +47,7 @@ export class MFAController { }; } - @Post('/enable') + @Post('/enable', { rateLimit: true }) async activateMFA(req: MFA.Activate) { const { token = null } = req.body; const { id, mfaEnabled } = req.user; @@ -71,14 +71,19 @@ export class MFAController { await this.mfaService.enableMfa(id); } - @Delete('/disable') - async disableMFA(req: AuthenticatedRequest) { - const { id } = req.user; + @Post('/disable', { rateLimit: true }) + async disableMFA(req: MFA.Disable) { + const { id: userId } = req.user; + const { token = null } = req.body; + + if (typeof token !== 'string' || !token) { + throw new BadRequestError('Token is required to disable MFA feature'); + } - await this.mfaService.disableMfa(id); + await this.mfaService.disableMfa(userId, token); } - @Post('/verify') + @Post('/verify', { rateLimit: true }) async verifyMFA(req: MFA.Verify) { const { id } = req.user; const { token } = req.body; diff --git a/packages/cli/test/unit/controllers/oauth/oAuth1Credential.controller.test.ts b/packages/cli/src/controllers/oauth/__tests__/oAuth1Credential.controller.test.ts similarity index 99% rename from packages/cli/test/unit/controllers/oauth/oAuth1Credential.controller.test.ts rename to packages/cli/src/controllers/oauth/__tests__/oAuth1Credential.controller.test.ts index cbadc1cf87716..fae443b4ce9a2 100644 --- a/packages/cli/test/unit/controllers/oauth/oAuth1Credential.controller.test.ts +++ b/packages/cli/src/controllers/oauth/__tests__/oAuth1Credential.controller.test.ts @@ -19,7 +19,7 @@ import { CredentialsHelper } from '@/CredentialsHelper'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { NotFoundError } from '@/errors/response-errors/not-found.error'; -import { mockInstance } from '../../../shared/mocking'; +import { mockInstance } from '@test/mocking'; describe('OAuth1CredentialController', () => { mockInstance(Logger); diff --git a/packages/cli/test/unit/controllers/oauth/oAuth2Credential.controller.test.ts b/packages/cli/src/controllers/oauth/__tests__/oAuth2Credential.controller.test.ts similarity index 99% rename from packages/cli/test/unit/controllers/oauth/oAuth2Credential.controller.test.ts rename to packages/cli/src/controllers/oauth/__tests__/oAuth2Credential.controller.test.ts index f2b718fda0090..f354a1ec024e7 100644 --- a/packages/cli/test/unit/controllers/oauth/oAuth2Credential.controller.test.ts +++ b/packages/cli/src/controllers/oauth/__tests__/oAuth2Credential.controller.test.ts @@ -19,7 +19,7 @@ import { CredentialsHelper } from '@/CredentialsHelper'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { NotFoundError } from '@/errors/response-errors/not-found.error'; -import { mockInstance } from '../../../shared/mocking'; +import { mockInstance } from '@test/mocking'; describe('OAuth2CredentialController', () => { mockInstance(Logger); diff --git a/packages/cli/src/controllers/owner.controller.ts b/packages/cli/src/controllers/owner.controller.ts index 9b86ac41ab313..c0c2e7308d311 100644 --- a/packages/cli/src/controllers/owner.controller.ts +++ b/packages/cli/src/controllers/owner.controller.ts @@ -13,13 +13,13 @@ import { PostHogClient } from '@/posthog'; import { UserService } from '@/services/user.service'; import { Logger } from '@/Logger'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { InternalHooks } from '@/InternalHooks'; +import { EventService } from '@/events/event.service'; @RestController('/owner') export class OwnerController { constructor( private readonly logger: Logger, - private readonly internalHooks: InternalHooks, + private readonly eventService: EventService, private readonly settingsRepository: SettingsRepository, private readonly authService: AuthService, private readonly userService: UserService, @@ -85,7 +85,7 @@ export class OwnerController { this.authService.issueCookie(res, owner, req.browserId); - this.internalHooks.onInstanceOwnerSetup({ user_id: owner.id }); + this.eventService.emit('instance-owner-setup', { userId: owner.id }); return await this.userService.toPublic(owner, { posthog: this.postHog, withScopes: true }); } diff --git a/packages/cli/src/controllers/passwordReset.controller.ts b/packages/cli/src/controllers/passwordReset.controller.ts index e17a0f15efe63..0cfe7cac19e12 100644 --- a/packages/cli/src/controllers/passwordReset.controller.ts +++ b/packages/cli/src/controllers/passwordReset.controller.ts @@ -13,7 +13,6 @@ import { RESPONSE_ERROR_MESSAGES } from '@/constants'; import { MfaService } from '@/Mfa/mfa.service'; import { Logger } from '@/Logger'; import { ExternalHooks } from '@/ExternalHooks'; -import { InternalHooks } from '@/InternalHooks'; import { UrlService } from '@/services/url.service'; import { InternalServerError } from '@/errors/response-errors/internal-server.error'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; @@ -21,14 +20,13 @@ import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; import { NotFoundError } from '@/errors/response-errors/not-found.error'; import { UnprocessableRequestError } from '@/errors/response-errors/unprocessable.error'; import { UserRepository } from '@/databases/repositories/user.repository'; -import { EventService } from '@/eventbus/event.service'; +import { EventService } from '@/events/event.service'; @RestController() export class PasswordResetController { constructor( private readonly logger: Logger, private readonly externalHooks: ExternalHooks, - private readonly internalHooks: InternalHooks, private readonly mailer: UserManagementMailer, private readonly authService: AuthService, private readonly userService: UserService, @@ -120,25 +118,23 @@ export class PasswordResetController { domain: this.urlService.getInstanceBaseUrl(), }); } catch (error) { - this.internalHooks.onEmailFailed({ + this.eventService.emit('email-failed', { user, - message_type: 'Reset password', - public_api: false, + messageType: 'Reset password', + publicApi: false, }); - this.eventService.emit('email-failed', { user, messageType: 'Reset password' }); if (error instanceof Error) { throw new InternalServerError(`Please contact your administrator: ${error.message}`); } } this.logger.info('Sent password reset email successfully', { userId: user.id, email }); - this.internalHooks.onUserTransactionalEmail({ - user_id: id, - message_type: 'Reset password', - public_api: false, + this.eventService.emit('user-transactional-email-sent', { + userId: id, + messageType: 'Reset password', + publicApi: false, }); - this.internalHooks.onUserPasswordResetRequestClick({ user }); this.eventService.emit('user-password-reset-request-click', { user }); } @@ -171,7 +167,6 @@ export class PasswordResetController { } this.logger.info('Reset-password token resolved successfully', { userId: user.id }); - this.internalHooks.onUserPasswordResetEmailClick({ user }); this.eventService.emit('user-password-reset-email-click', { user }); } @@ -215,17 +210,16 @@ export class PasswordResetController { this.authService.issueCookie(res, user, req.browserId); - this.internalHooks.onUserUpdate({ user, fields_changed: ['password'] }); this.eventService.emit('user-updated', { user, fieldsChanged: ['password'] }); - // if this user used to be an LDAP users + // if this user used to be an LDAP user const ldapIdentity = user?.authIdentities?.find((i) => i.providerType === 'ldap'); if (ldapIdentity) { - this.internalHooks.onUserSignup(user, { - user_type: 'email', - was_disabled_ldap_user: true, + this.eventService.emit('user-signed-up', { + user, + userType: 'email', + wasDisabledLdapUser: true, }); - this.eventService.emit('user-signed-up', { user }); } await this.externalHooks.run('user.password.update', [user.email, passwordHash]); diff --git a/packages/cli/src/controllers/project.controller.ts b/packages/cli/src/controllers/project.controller.ts index 848ba4b84c6eb..e93b919ecb8e3 100644 --- a/packages/cli/src/controllers/project.controller.ts +++ b/packages/cli/src/controllers/project.controller.ts @@ -23,7 +23,7 @@ import { ProjectRepository } from '@/databases/repositories/project.repository'; // eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import import { In, Not } from '@n8n/typeorm'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { EventService } from '@/eventbus/event.service'; +import { EventService } from '@/events/event.service'; @RestController('/projects') export class ProjectController { diff --git a/packages/cli/src/controllers/users.controller.ts b/packages/cli/src/controllers/users.controller.ts index f0815aa54d405..f3055ee4dc179 100644 --- a/packages/cli/src/controllers/users.controller.ts +++ b/packages/cli/src/controllers/users.controller.ts @@ -9,7 +9,7 @@ import { UserRoleChangePayload, UserSettingsUpdatePayload, } from '@/requests'; -import type { PublicUser, ITelemetryUserDeletionData } from '@/Interfaces'; +import type { PublicUser } from '@/Interfaces'; import { AuthIdentity } from '@db/entities/AuthIdentity'; import { SharedCredentialsRepository } from '@db/repositories/sharedCredentials.repository'; import { SharedWorkflowRepository } from '@db/repositories/sharedWorkflow.repository'; @@ -21,21 +21,19 @@ import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; import { NotFoundError } from '@/errors/response-errors/not-found.error'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { ExternalHooks } from '@/ExternalHooks'; -import { InternalHooks } from '@/InternalHooks'; import { validateEntity } from '@/GenericHelpers'; import { ProjectRepository } from '@/databases/repositories/project.repository'; import { Project } from '@/databases/entities/Project'; import { WorkflowService } from '@/workflows/workflow.service'; import { CredentialsService } from '@/credentials/credentials.service'; import { ProjectService } from '@/services/project.service'; -import { EventService } from '@/eventbus/event.service'; +import { EventService } from '@/events/event.service'; @RestController('/users') export class UsersController { constructor( private readonly logger: Logger, private readonly externalHooks: ExternalHooks, - private readonly internalHooks: InternalHooks, private readonly sharedCredentialsRepository: SharedCredentialsRepository, private readonly sharedWorkflowRepository: SharedWorkflowRepository, private readonly userRepository: UserRepository, @@ -183,12 +181,7 @@ export class UsersController { ); } - const telemetryData: ITelemetryUserDeletionData = { - user_id: req.user.id, - target_user_old_status: userToDelete.isPending ? 'invited' : 'active', - target_user_id: idToDelete, - migration_strategy: transferId ? 'transfer_data' : 'delete_data', - }; + let transfereeId; if (transferId) { const transfereePersonalProject = await this.projectRepository.findOneBy({ id: transferId }); @@ -206,7 +199,7 @@ export class UsersController { }, }); - telemetryData.migration_user_id = transferee.id; + transfereeId = transferee.id; await this.userService.getManager().transaction(async (trx) => { await this.workflowService.transferAll( @@ -253,12 +246,14 @@ export class UsersController { await trx.delete(User, { id: userToDelete.id }); }); - this.internalHooks.onUserDeletion({ + this.eventService.emit('user-deleted', { user: req.user, - telemetryData, publicApi: false, + targetUserOldStatus: userToDelete.isPending ? 'invited' : 'active', + targetUserId: idToDelete, + migrationStrategy: transferId ? 'transfer_data' : 'delete_data', + migrationUserId: transfereeId, }); - this.eventService.emit('user-deleted', { user: req.user }); await this.externalHooks.run('user.deleted', [await this.userService.toPublic(userToDelete)]); @@ -294,11 +289,11 @@ export class UsersController { await this.userService.update(targetUser.id, { role: payload.newRoleName }); - this.internalHooks.onUserRoleChange({ - user: req.user, - target_user_id: targetUser.id, - target_user_new_role: ['global', payload.newRoleName].join(' '), - public_api: false, + this.eventService.emit('user-changed-role', { + userId: req.user.id, + targetUserId: targetUser.id, + targetUserNewRole: payload.newRoleName, + publicApi: false, }); const projects = await this.projectService.getUserOwnedOrAdminProjects(targetUser.id); diff --git a/packages/cli/src/credentials/__tests__/credentials.service.test.ts b/packages/cli/src/credentials/__tests__/credentials.service.test.ts index 18216df8674ce..a5fa3ca183cb7 100644 --- a/packages/cli/src/credentials/__tests__/credentials.service.test.ts +++ b/packages/cli/src/credentials/__tests__/credentials.service.test.ts @@ -3,7 +3,7 @@ import { mock } from 'jest-mock-extended'; import { CREDENTIAL_BLANKING_VALUE } from '@/constants'; import type { CredentialsEntity } from '@db/entities/CredentialsEntity'; import type { CredentialTypes } from '@/CredentialTypes'; -import { CredentialsService } from '../credentials.service'; +import { CredentialsService } from '@/credentials/credentials.service'; describe('CredentialsService', () => { const credType = mock({ diff --git a/packages/cli/src/credentials/credentials.controller.ts b/packages/cli/src/credentials/credentials.controller.ts index 1587fbd1ca426..40f2aa8d43219 100644 --- a/packages/cli/src/credentials/credentials.controller.ts +++ b/packages/cli/src/credentials/credentials.controller.ts @@ -30,7 +30,7 @@ import { SharedCredentialsRepository } from '@/databases/repositories/sharedCred import { SharedCredentials } from '@/databases/entities/SharedCredentials'; import { ProjectRelationRepository } from '@/databases/repositories/projectRelation.repository'; import { z } from 'zod'; -import { EventService } from '@/eventbus/event.service'; +import { EventService } from '@/events/event.service'; @RestController('/credentials') export class CredentialsController { @@ -291,25 +291,22 @@ export class CredentialsController { let newShareeIds: string[] = []; await Db.transaction(async (trx) => { - const currentPersonalProjectIDs = credential.shared + const currentProjectIds = credential.shared .filter((sc) => sc.role === 'credential:user') .map((sc) => sc.projectId); - const newPersonalProjectIds = shareWithIds; + const newProjectIds = shareWithIds; - const toShare = utils.rightDiff( - [currentPersonalProjectIDs, (id) => id], - [newPersonalProjectIds, (id) => id], - ); + const toShare = utils.rightDiff([currentProjectIds, (id) => id], [newProjectIds, (id) => id]); const toUnshare = utils.rightDiff( - [newPersonalProjectIds, (id) => id], - [currentPersonalProjectIDs, (id) => id], + [newProjectIds, (id) => id], + [currentProjectIds, (id) => id], ); const deleteResult = await trx.delete(SharedCredentials, { credentialsId: credentialId, projectId: In(toUnshare), }); - await this.enterpriseCredentialsService.shareWithProjects(credential, toShare, trx); + await this.enterpriseCredentialsService.shareWithProjects(req.user, credential, toShare, trx); if (deleteResult.affected) { amountRemoved = deleteResult.affected; diff --git a/packages/cli/src/credentials/credentials.service.ee.ts b/packages/cli/src/credentials/credentials.service.ee.ts index 981ecc5d59fc9..44fc614cbd63d 100644 --- a/packages/cli/src/credentials/credentials.service.ee.ts +++ b/packages/cli/src/credentials/credentials.service.ee.ts @@ -12,6 +12,7 @@ import { Project } from '@/databases/entities/Project'; import { ProjectService } from '@/services/project.service'; import { TransferCredentialError } from '@/errors/response-errors/transfer-credential.error'; import { SharedCredentials } from '@/databases/entities/SharedCredentials'; +import { RoleService } from '@/services/role.service'; @Service() export class EnterpriseCredentialsService { @@ -20,9 +21,11 @@ export class EnterpriseCredentialsService { private readonly ownershipService: OwnershipService, private readonly credentialsService: CredentialsService, private readonly projectService: ProjectService, + private readonly roleService: RoleService, ) {} async shareWithProjects( + user: User, credential: CredentialsEntity, shareWithIds: string[], entityManager?: EntityManager, @@ -30,19 +33,35 @@ export class EnterpriseCredentialsService { const em = entityManager ?? this.sharedCredentialsRepository.manager; const projects = await em.find(Project, { - where: { id: In(shareWithIds), type: 'personal' }, + where: [ + { + id: In(shareWithIds), + type: 'team', + // if user can see all projects, don't check project access + // if they can't, find projects they can list + ...(user.hasGlobalScope('project:list') + ? {} + : { + projectRelations: { + userId: user.id, + role: In(this.roleService.rolesWithScope('project', 'project:list')), + }, + }), + }, + { + id: In(shareWithIds), + type: 'personal', + }, + ], }); - const newSharedCredentials = projects - // We filter by role === 'project:personalOwner' above and there should - // always only be one owner. - .map((project) => - this.sharedCredentialsRepository.create({ - credentialsId: credential.id, - role: 'credential:user', - projectId: project.id, - }), - ); + const newSharedCredentials = projects.map((project) => + this.sharedCredentialsRepository.create({ + credentialsId: credential.id, + role: 'credential:user', + projectId: project.id, + }), + ); return await em.save(newSharedCredentials); } diff --git a/packages/cli/src/credentials/credentials.service.ts b/packages/cli/src/credentials/credentials.service.ts index 0ecfd31877bf2..01517c960adad 100644 --- a/packages/cli/src/credentials/credentials.service.ts +++ b/packages/cli/src/credentials/credentials.service.ts @@ -90,6 +90,19 @@ export class CredentialsService { let credentials = await this.credentialsRepository.findMany(options.listQueryOptions); if (isDefaultSelect) { + // Since we're filtering using project ID as part of the relation, + // we end up filtering out all the other relations, meaning that if + // it's shared to a project, it won't be able to find the home project. + // To solve this, we have to get all the relation now, even though + // we're deleting them later. + if ((options.listQueryOptions?.filter?.shared as { projectId?: string })?.projectId) { + const relations = await this.sharedCredentialsRepository.getAllRelationsForCredentials( + credentials.map((c) => c.id), + ); + credentials.forEach((c) => { + c.shared = relations.filter((r) => r.credentialsId === c.id); + }); + } credentials = credentials.map((c) => this.ownershipService.addOwnedByAndSharedWith(c)); } @@ -130,6 +143,20 @@ export class CredentialsService { ); if (isDefaultSelect) { + // Since we're filtering using project ID as part of the relation, + // we end up filtering out all the other relations, meaning that if + // it's shared to a project, it won't be able to find the home project. + // To solve this, we have to get all the relation now, even though + // we're deleting them later. + if ((options.listQueryOptions?.filter?.shared as { projectId?: string })?.projectId) { + const relations = await this.sharedCredentialsRepository.getAllRelationsForCredentials( + credentials.map((c) => c.id), + ); + credentials.forEach((c) => { + c.shared = relations.filter((r) => r.credentialsId === c.id); + }); + } + credentials = credentials.map((c) => this.ownershipService.addOwnedByAndSharedWith(c)); } diff --git a/packages/cli/test/unit/databases/entities/user.entity.test.ts b/packages/cli/src/databases/entities/__tests__/user.entity.test.ts similarity index 100% rename from packages/cli/test/unit/databases/entities/user.entity.test.ts rename to packages/cli/src/databases/entities/__tests__/user.entity.test.ts diff --git a/packages/cli/test/unit/repositories/execution.repository.test.ts b/packages/cli/src/databases/repositories/__tests__/execution.repository.test.ts similarity index 95% rename from packages/cli/test/unit/repositories/execution.repository.test.ts rename to packages/cli/src/databases/repositories/__tests__/execution.repository.test.ts index f8aba2e8a1228..1a4929f5cdf6e 100644 --- a/packages/cli/test/unit/repositories/execution.repository.test.ts +++ b/packages/cli/src/databases/repositories/__tests__/execution.repository.test.ts @@ -8,8 +8,7 @@ import { mock } from 'jest-mock-extended'; import { ExecutionEntity } from '@db/entities/ExecutionEntity'; import { ExecutionRepository } from '@db/repositories/execution.repository'; -import { mockEntityManager } from '../../shared/mocking'; -import { mockInstance } from '../../shared/mocking'; +import { mockInstance, mockEntityManager } from '@test/mocking'; describe('ExecutionRepository', () => { const entityManager = mockEntityManager(ExecutionEntity); diff --git a/packages/cli/test/unit/repositories/sharedCredentials.repository.test.ts b/packages/cli/src/databases/repositories/__tests__/sharedCredentials.repository.test.ts similarity index 98% rename from packages/cli/test/unit/repositories/sharedCredentials.repository.test.ts rename to packages/cli/src/databases/repositories/__tests__/sharedCredentials.repository.test.ts index 8eb8b498145ef..6fb4bad4eacf3 100644 --- a/packages/cli/test/unit/repositories/sharedCredentials.repository.test.ts +++ b/packages/cli/src/databases/repositories/__tests__/sharedCredentials.repository.test.ts @@ -8,7 +8,7 @@ import type { CredentialsEntity } from '@db/entities/CredentialsEntity'; import { SharedCredentials } from '@db/entities/SharedCredentials'; import { SharedCredentialsRepository } from '@db/repositories/sharedCredentials.repository'; import { GLOBAL_MEMBER_SCOPES, GLOBAL_OWNER_SCOPES } from '@/permissions/global-roles'; -import { mockEntityManager } from '../../shared/mocking'; +import { mockEntityManager } from '@test/mocking'; describe('SharedCredentialsRepository', () => { const entityManager = mockEntityManager(SharedCredentials); diff --git a/packages/cli/test/unit/repositories/workflowStatistics.test.ts b/packages/cli/src/databases/repositories/__tests__/workflowStatistics.test.ts similarity index 96% rename from packages/cli/test/unit/repositories/workflowStatistics.test.ts rename to packages/cli/src/databases/repositories/__tests__/workflowStatistics.test.ts index 86e0ee1c92bd1..7bed056549751 100644 --- a/packages/cli/test/unit/repositories/workflowStatistics.test.ts +++ b/packages/cli/src/databases/repositories/__tests__/workflowStatistics.test.ts @@ -5,7 +5,7 @@ import { mock, mockClear } from 'jest-mock-extended'; import { StatisticsNames, WorkflowStatistics } from '@db/entities/WorkflowStatistics'; import { WorkflowStatisticsRepository } from '@db/repositories/workflowStatistics.repository'; -import { mockEntityManager } from '../../shared/mocking'; +import { mockEntityManager } from '@test/mocking'; describe('insertWorkflowStatistics', () => { const entityManager = mockEntityManager(WorkflowStatistics); diff --git a/packages/cli/src/databases/repositories/execution.repository.ts b/packages/cli/src/databases/repositories/execution.repository.ts index 1ebb22d8eb4bc..5b4e515af6da3 100644 --- a/packages/cli/src/databases/repositories/execution.repository.ts +++ b/packages/cli/src/databases/repositories/execution.repository.ts @@ -270,6 +270,9 @@ export class ExecutionRepository extends Repository { return rest; } + /** + * Insert a new execution and its execution data using a transaction. + */ async createNewExecution(execution: ExecutionPayload): Promise { const { data, workflowData, ...rest } = execution; const { identifiers: inserted } = await this.insert(rest); diff --git a/packages/cli/src/databases/repositories/projectRelation.repository.ts b/packages/cli/src/databases/repositories/projectRelation.repository.ts index bddfd6e38d66f..1f875d011fe06 100644 --- a/packages/cli/src/databases/repositories/projectRelation.repository.ts +++ b/packages/cli/src/databases/repositories/projectRelation.repository.ts @@ -52,4 +52,21 @@ export class ProjectRelationRepository extends Repository { {} as Record, ); } + + async findUserIdsByProjectId(projectId: string): Promise { + const rows = await this.find({ + select: ['userId'], + where: { projectId }, + }); + + return [...new Set(rows.map((r) => r.userId))]; + } + + async findAllByUser(userId: string) { + return await this.find({ + where: { + userId, + }, + }); + } } diff --git a/packages/cli/src/databases/repositories/sharedCredentials.repository.ts b/packages/cli/src/databases/repositories/sharedCredentials.repository.ts index 8d2d1fa7af586..03acc24823ac5 100644 --- a/packages/cli/src/databases/repositories/sharedCredentials.repository.ts +++ b/packages/cli/src/databases/repositories/sharedCredentials.repository.ts @@ -151,4 +151,13 @@ export class SharedCredentialsRepository extends Repository { }) )?.project; } + + async getAllRelationsForCredentials(credentialIds: string[]) { + return await this.find({ + where: { + credentialsId: In(credentialIds), + }, + relations: ['project'], + }); + } } diff --git a/packages/cli/src/databases/repositories/sharedWorkflow.repository.ts b/packages/cli/src/databases/repositories/sharedWorkflow.repository.ts index 4dc54935cb193..d8e224fee2280 100644 --- a/packages/cli/src/databases/repositories/sharedWorkflow.repository.ts +++ b/packages/cli/src/databases/repositories/sharedWorkflow.repository.ts @@ -200,4 +200,13 @@ export class SharedWorkflowRepository extends Repository { }) )?.project; } + + async getRelationsByWorkflowIdsAndProjectIds(workflowIds: string[], projectIds: string[]) { + return await this.find({ + where: { + workflowId: In(workflowIds), + projectId: In(projectIds), + }, + }); + } } diff --git a/packages/cli/test/unit/databases/utils/customValidators.test.ts b/packages/cli/src/databases/utils/__tests__/customValidators.test.ts similarity index 100% rename from packages/cli/test/unit/databases/utils/customValidators.test.ts rename to packages/cli/src/databases/utils/__tests__/customValidators.test.ts diff --git a/packages/cli/test/unit/databases/utils/migrationHelpers.test.ts b/packages/cli/src/databases/utils/__tests__/migrationHelpers.test.ts similarity index 100% rename from packages/cli/test/unit/databases/utils/migrationHelpers.test.ts rename to packages/cli/src/databases/utils/__tests__/migrationHelpers.test.ts diff --git a/packages/cli/src/decorators/Redactable.ts b/packages/cli/src/decorators/Redactable.ts index e5debeb7a1507..51d02c5c3d6fc 100644 --- a/packages/cli/src/decorators/Redactable.ts +++ b/packages/cli/src/decorators/Redactable.ts @@ -1,5 +1,5 @@ import { RedactableError } from '@/errors/redactable.error'; -import type { UserLike } from '@/eventbus/event.types'; +import type { UserLike } from '@/events/relay-event-map'; function toRedactable(userLike: UserLike) { return { @@ -14,7 +14,7 @@ function toRedactable(userLike: UserLike) { type FieldName = 'user' | 'inviter' | 'invitee'; /** - * Mark redactable properties in a `{ user: UserLike }` field in an `AuditEventRelay` + * Mark redactable properties in a `{ user: UserLike }` field in an `LogStreamingEventRelay` * method arg. These properties will be later redacted by the log streaming * destination based on user prefs. Only for `n8n.audit.*` logs. * diff --git a/packages/cli/test/unit/decorators/OnShutdown.test.ts b/packages/cli/src/decorators/__tests__/OnShutdown.test.ts similarity index 100% rename from packages/cli/test/unit/decorators/OnShutdown.test.ts rename to packages/cli/src/decorators/__tests__/OnShutdown.test.ts diff --git a/packages/cli/test/unit/decorators/controller.registry.test.ts b/packages/cli/src/decorators/__tests__/controller.registry.test.ts similarity index 100% rename from packages/cli/test/unit/decorators/controller.registry.test.ts rename to packages/cli/src/decorators/__tests__/controller.registry.test.ts diff --git a/packages/cli/src/environments/sourceControl/__tests__/source-control-export.service.test.ts b/packages/cli/src/environments/sourceControl/__tests__/source-control-export.service.test.ts new file mode 100644 index 0000000000000..7746875f6386e --- /dev/null +++ b/packages/cli/src/environments/sourceControl/__tests__/source-control-export.service.test.ts @@ -0,0 +1,85 @@ +import mock from 'jest-mock-extended/lib/Mock'; +import { SourceControlExportService } from '../sourceControlExport.service.ee'; +import type { SourceControlledFile } from '../types/sourceControlledFile'; +import { Cipher, type InstanceSettings } from 'n8n-core'; +import { SharedCredentialsRepository } from '@/databases/repositories/sharedCredentials.repository'; +import { mockInstance } from '@test/mocking'; + +import type { SharedCredentials } from '@/databases/entities/SharedCredentials'; +import type { CredentialsEntity } from '@/databases/entities/CredentialsEntity'; +import Container from 'typedi'; +import { ApplicationError, deepCopy } from 'n8n-workflow'; + +// https://github.com/jestjs/jest/issues/4715 +function deepSpyOn(object: O, methodName: M) { + const spy = jest.fn(); + // eslint-disable-next-line @typescript-eslint/ban-types + const originalMethod = object[methodName]; + + if (typeof originalMethod !== 'function') { + throw new ApplicationError(`${methodName.toString()} is not a function`, { level: 'warning' }); + } + + object[methodName] = function (...args: unknown[]) { + const clonedArgs = deepCopy(args); + spy(...clonedArgs); + return originalMethod.apply(this, args); + } as O[M]; + + return spy; +} + +describe('SourceControlExportService', () => { + const service = new SourceControlExportService( + mock(), + mock(), + mock(), + mock({ n8nFolder: '' }), + ); + + describe('exportCredentialsToWorkFolder', () => { + it('should export credentials to work folder', async () => { + /** + * Arrange + */ + // @ts-expect-error Private method + const replaceSpy = deepSpyOn(service, 'replaceCredentialData'); + + mockInstance(SharedCredentialsRepository).findByCredentialIds.mockResolvedValue([ + mock({ + credentials: mock({ + data: Container.get(Cipher).encrypt( + JSON.stringify({ + authUrl: 'test', + accessTokenUrl: 'test', + clientId: 'test', + clientSecret: 'test', + oauthTokenData: { + access_token: 'test', + token_type: 'test', + expires_in: 123, + refresh_token: 'test', + }, + }), + ), + }), + }), + ]); + + /** + * Act + */ + await service.exportCredentialsToWorkFolder([mock()]); + + /** + * Assert + */ + expect(replaceSpy).toHaveBeenCalledWith({ + authUrl: 'test', + accessTokenUrl: 'test', + clientId: 'test', + clientSecret: 'test', + }); + }); + }); +}); diff --git a/packages/cli/test/unit/GitService.test.ts b/packages/cli/src/environments/sourceControl/__tests__/sourceControlGit.service.test.ts similarity index 100% rename from packages/cli/test/unit/GitService.test.ts rename to packages/cli/src/environments/sourceControl/__tests__/sourceControlGit.service.test.ts diff --git a/packages/cli/test/unit/SourceControl.test.ts b/packages/cli/src/environments/sourceControl/__tests__/sourceControlHelper.ee.test.ts similarity index 99% rename from packages/cli/test/unit/SourceControl.test.ts rename to packages/cli/src/environments/sourceControl/__tests__/sourceControlHelper.ee.test.ts index 5eeccbb1b97f4..5141d36f2f7dd 100644 --- a/packages/cli/test/unit/SourceControl.test.ts +++ b/packages/cli/src/environments/sourceControl/__tests__/sourceControlHelper.ee.test.ts @@ -18,7 +18,7 @@ import { import { constants as fsConstants, accessSync } from 'fs'; import type { SourceControlledFile } from '@/environments/sourceControl/types/sourceControlledFile'; import type { SourceControlPreferences } from '@/environments/sourceControl/types/sourceControlPreferences'; -import { mockInstance } from '../shared/mocking'; +import { mockInstance } from '@test/mocking'; const pushResult: SourceControlledFile[] = [ { diff --git a/packages/cli/src/environments/sourceControl/sourceControl.controller.ee.ts b/packages/cli/src/environments/sourceControl/sourceControl.controller.ee.ts index 0a1db892f60c9..f92b0bfb1f02f 100644 --- a/packages/cli/src/environments/sourceControl/sourceControl.controller.ee.ts +++ b/packages/cli/src/environments/sourceControl/sourceControl.controller.ee.ts @@ -12,7 +12,7 @@ import type { SourceControlPreferences } from './types/sourceControlPreferences' import type { SourceControlledFile } from './types/sourceControlledFile'; import { SOURCE_CONTROL_DEFAULT_BRANCH } from './constants'; import type { ImportResult } from './types/importResult'; -import { EventService } from '@/eventbus/event.service'; +import { EventService } from '@/events/event.service'; import { getRepoType } from './sourceControlHelper.ee'; import { SourceControlGetStatus } from './types/sourceControlGetStatus'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; diff --git a/packages/cli/src/environments/sourceControl/sourceControl.service.ee.ts b/packages/cli/src/environments/sourceControl/sourceControl.service.ee.ts index ac226a1b2eacc..0c9279ffbe7a3 100644 --- a/packages/cli/src/environments/sourceControl/sourceControl.service.ee.ts +++ b/packages/cli/src/environments/sourceControl/sourceControl.service.ee.ts @@ -30,7 +30,7 @@ import type { TagEntity } from '@db/entities/TagEntity'; import type { Variables } from '@db/entities/Variables'; import type { SourceControlWorkflowVersionId } from './types/sourceControlWorkflowVersionId'; import type { ExportableCredential } from './types/exportableCredential'; -import { EventService } from '@/eventbus/event.service'; +import { EventService } from '@/events/event.service'; import { TagRepository } from '@db/repositories/tag.repository'; import { Logger } from '@/Logger'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; diff --git a/packages/cli/src/environments/sourceControl/sourceControlExport.service.ee.ts b/packages/cli/src/environments/sourceControl/sourceControlExport.service.ee.ts index e586357093a41..65886a6d6d0d2 100644 --- a/packages/cli/src/environments/sourceControl/sourceControlExport.service.ee.ts +++ b/packages/cli/src/environments/sourceControl/sourceControlExport.service.ee.ts @@ -293,11 +293,18 @@ export class SourceControlExportService { }; } + /** + * Edge case: Do not export `oauthTokenData`, so that that the + * pulling instance reconnects instead of trying to use stubbed values. + */ + const credentialData = credentials.getData(); + const { oauthTokenData, ...rest } = credentialData; + const stub: ExportableCredential = { id, name, type, - data: this.replaceCredentialData(credentials.getData()), + data: this.replaceCredentialData(rest), ownedBy: owner, }; diff --git a/packages/cli/src/environments/sourceControl/sourceControlImport.service.ee.ts b/packages/cli/src/environments/sourceControl/sourceControlImport.service.ee.ts index d9a366b6f053e..7ff77ca7f8b44 100644 --- a/packages/cli/src/environments/sourceControl/sourceControlImport.service.ee.ts +++ b/packages/cli/src/environments/sourceControl/sourceControlImport.service.ee.ts @@ -326,7 +326,12 @@ export class SourceControlImportService { if (existingCredential?.data) { newCredentialObject.data = existingCredential.data; } else { - newCredentialObject.setData(data); + /** + * Edge case: Do not import `oauthTokenData`, so that that the + * pulling instance reconnects instead of trying to use stubbed values. + */ + const { oauthTokenData, ...rest } = data; + newCredentialObject.setData(rest); } this.logger.debug(`Updating credential id ${newCredentialObject.id as string}`); diff --git a/packages/cli/src/environments/variables/variables.service.ee.ts b/packages/cli/src/environments/variables/variables.service.ee.ts index 94233da065839..78a3d23fbe55d 100644 --- a/packages/cli/src/environments/variables/variables.service.ee.ts +++ b/packages/cli/src/environments/variables/variables.service.ee.ts @@ -6,7 +6,7 @@ import { CacheService } from '@/services/cache/cache.service'; import { VariablesRepository } from '@db/repositories/variables.repository'; import { VariableCountLimitReachedError } from '@/errors/variable-count-limit-reached.error'; import { VariableValidationError } from '@/errors/variable-validation.error'; -import { EventService } from '@/eventbus/event.service'; +import { EventService } from '@/events/event.service'; @Service() export class VariablesService { diff --git a/packages/cli/src/errors/max-stalled-count.error.ts b/packages/cli/src/errors/max-stalled-count.error.ts index 6715de0ade837..653ca18eacac7 100644 --- a/packages/cli/src/errors/max-stalled-count.error.ts +++ b/packages/cli/src/errors/max-stalled-count.error.ts @@ -1,7 +1,7 @@ import { ApplicationError } from 'n8n-workflow'; /** - * See https://github.com/OptimalBits/bull/blob/60fa88f08637f0325639988a3f054880a04ce402/docs/README.md?plain=1#L133 + * @docs https://docs.bullmq.io/guide/workers/stalled-jobs */ export class MaxStalledCountError extends ApplicationError { constructor(cause: Error) { diff --git a/packages/cli/src/errors/redactable.error.ts b/packages/cli/src/errors/redactable.error.ts index 0f6697a0652d6..0d5b07ac504fb 100644 --- a/packages/cli/src/errors/redactable.error.ts +++ b/packages/cli/src/errors/redactable.error.ts @@ -3,7 +3,7 @@ import { ApplicationError } from 'n8n-workflow'; export class RedactableError extends ApplicationError { constructor(fieldName: string, args: string) { super( - `Failed to find "${fieldName}" property in argument "${args.toString()}". Please set the decorator \`@Redactable()\` only on \`AuditEventRelay\` methods where the argument contains a "${fieldName}" property.`, + `Failed to find "${fieldName}" property in argument "${args.toString()}". Please set the decorator \`@Redactable()\` only on \`LogStreamingEventRelay\` methods where the argument contains a "${fieldName}" property.`, ); } } diff --git a/packages/cli/test/unit/utils.test.ts b/packages/cli/src/errors/response-errors/__tests__/webhook-not-found.error.test.ts similarity index 100% rename from packages/cli/test/unit/utils.test.ts rename to packages/cli/src/errors/response-errors/__tests__/webhook-not-found.error.test.ts diff --git a/packages/cli/src/errors/response-errors/invalid-mfa-code.error.ts b/packages/cli/src/errors/response-errors/invalid-mfa-code.error.ts new file mode 100644 index 0000000000000..cc00976a84ef5 --- /dev/null +++ b/packages/cli/src/errors/response-errors/invalid-mfa-code.error.ts @@ -0,0 +1,7 @@ +import { ForbiddenError } from './forbidden.error'; + +export class InvalidMfaCodeError extends ForbiddenError { + constructor(hint?: string) { + super('Invalid two-factor code.', hint); + } +} diff --git a/packages/cli/src/eventbus/MessageEventBus/MessageEventBus.ts b/packages/cli/src/eventbus/MessageEventBus/MessageEventBus.ts index 758eeb5ae590f..eeb868798bf6b 100644 --- a/packages/cli/src/eventbus/MessageEventBus/MessageEventBus.ts +++ b/packages/cli/src/eventbus/MessageEventBus/MessageEventBus.ts @@ -52,6 +52,8 @@ export interface MessageEventBusInitializeOptions { } @Service() +// TODO: Convert to TypedEventEmitter +// eslint-disable-next-line n8n-local-rules/no-type-unsafe-event-emitter export class MessageEventBus extends EventEmitter { private isInitialized = false; diff --git a/packages/cli/src/eventbus/audit-event-relay.service.ts b/packages/cli/src/eventbus/audit-event-relay.service.ts deleted file mode 100644 index f8a95a3ebf359..0000000000000 --- a/packages/cli/src/eventbus/audit-event-relay.service.ts +++ /dev/null @@ -1,387 +0,0 @@ -import { Service } from 'typedi'; -import { MessageEventBus } from './MessageEventBus/MessageEventBus'; -import { Redactable } from '@/decorators/Redactable'; -import { EventService } from './event.service'; -import type { Event } from './event.types'; -import type { IWorkflowBase } from 'n8n-workflow'; - -@Service() -export class AuditEventRelay { - constructor( - private readonly eventService: EventService, - private readonly eventBus: MessageEventBus, - ) {} - - init() { - this.setupHandlers(); - } - - private setupHandlers() { - this.eventService.on('workflow-created', (event) => this.workflowCreated(event)); - this.eventService.on('workflow-deleted', (event) => this.workflowDeleted(event)); - this.eventService.on('workflow-saved', (event) => this.workflowSaved(event)); - this.eventService.on('workflow-pre-execute', (event) => this.workflowPreExecute(event)); - this.eventService.on('workflow-post-execute', (event) => this.workflowPostExecute(event)); - this.eventService.on('node-pre-execute', (event) => this.nodePreExecute(event)); - this.eventService.on('node-post-execute', (event) => this.nodePostExecute(event)); - this.eventService.on('user-deleted', (event) => this.userDeleted(event)); - this.eventService.on('user-invited', (event) => this.userInvited(event)); - this.eventService.on('user-reinvited', (event) => this.userReinvited(event)); - this.eventService.on('user-updated', (event) => this.userUpdated(event)); - this.eventService.on('user-signed-up', (event) => this.userSignedUp(event)); - this.eventService.on('user-logged-in', (event) => this.userLoggedIn(event)); - this.eventService.on('user-login-failed', (event) => this.userLoginFailed(event)); - this.eventService.on('user-invite-email-click', (event) => this.userInviteEmailClick(event)); - this.eventService.on('user-password-reset-email-click', (event) => - this.userPasswordResetEmailClick(event), - ); - this.eventService.on('user-password-reset-request-click', (event) => - this.userPasswordResetRequestClick(event), - ); - this.eventService.on('public-api-key-created', (event) => this.publicApiKeyCreated(event)); - this.eventService.on('public-api-key-deleted', (event) => this.publicApiKeyDeleted(event)); - this.eventService.on('email-failed', (event) => this.emailFailed(event)); - this.eventService.on('credentials-created', (event) => this.credentialsCreated(event)); - this.eventService.on('credentials-deleted', (event) => this.credentialsDeleted(event)); - this.eventService.on('credentials-shared', (event) => this.credentialsShared(event)); - this.eventService.on('credentials-updated', (event) => this.credentialsUpdated(event)); - this.eventService.on('credentials-deleted', (event) => this.credentialsDeleted(event)); - this.eventService.on('community-package-installed', (event) => - this.communityPackageInstalled(event), - ); - this.eventService.on('community-package-updated', (event) => - this.communityPackageUpdated(event), - ); - this.eventService.on('community-package-deleted', (event) => - this.communityPackageDeleted(event), - ); - this.eventService.on('execution-throttled', (event) => this.executionThrottled(event)); - this.eventService.on('execution-started-during-bootup', (event) => - this.executionStartedDuringBootup(event), - ); - } - - /** - * Workflow - */ - - @Redactable() - private workflowCreated({ user, workflow }: Event['workflow-created']) { - void this.eventBus.sendAuditEvent({ - eventName: 'n8n.audit.workflow.created', - payload: { - ...user, - workflowId: workflow.id, - workflowName: workflow.name, - }, - }); - } - - @Redactable() - private workflowDeleted({ user, workflowId }: Event['workflow-deleted']) { - void this.eventBus.sendAuditEvent({ - eventName: 'n8n.audit.workflow.deleted', - payload: { ...user, workflowId }, - }); - } - - @Redactable() - private workflowSaved({ user, workflow }: Event['workflow-saved']) { - void this.eventBus.sendAuditEvent({ - eventName: 'n8n.audit.workflow.updated', - payload: { - ...user, - workflowId: workflow.id, - workflowName: workflow.name, - }, - }); - } - - private workflowPreExecute({ data, executionId }: Event['workflow-pre-execute']) { - const payload = - 'executionData' in data - ? { - executionId, - userId: data.userId, - workflowId: data.workflowData.id, - isManual: data.executionMode === 'manual', - workflowName: data.workflowData.name, - } - : { - executionId, - userId: undefined, - workflowId: (data as IWorkflowBase).id, - isManual: false, - workflowName: (data as IWorkflowBase).name, - }; - - void this.eventBus.sendWorkflowEvent({ - eventName: 'n8n.workflow.started', - payload, - }); - } - - private workflowPostExecute(event: Event['workflow-post-execute']) { - const { runData, ...rest } = event; - - if (event.success) { - void this.eventBus.sendWorkflowEvent({ - eventName: 'n8n.workflow.success', - payload: rest, - }); - - return; - } - - void this.eventBus.sendWorkflowEvent({ - eventName: 'n8n.workflow.failed', - payload: { - ...rest, - lastNodeExecuted: runData?.data.resultData.lastNodeExecuted, - errorNodeType: - runData?.data.resultData.error && 'node' in runData?.data.resultData.error - ? runData?.data.resultData.error.node?.type - : undefined, - errorMessage: runData?.data.resultData.error?.message.toString(), - }, - }); - } - - /** - * Node - */ - - private nodePreExecute({ workflow, executionId, nodeName }: Event['node-pre-execute']) { - void this.eventBus.sendNodeEvent({ - eventName: 'n8n.node.started', - payload: { - workflowId: workflow.id, - workflowName: workflow.name, - executionId, - nodeType: workflow.nodes.find((n) => n.name === nodeName)?.type, - nodeName, - }, - }); - } - - private nodePostExecute({ workflow, executionId, nodeName }: Event['node-post-execute']) { - void this.eventBus.sendNodeEvent({ - eventName: 'n8n.node.finished', - payload: { - workflowId: workflow.id, - workflowName: workflow.name, - executionId, - nodeType: workflow.nodes.find((n) => n.name === nodeName)?.type, - nodeName, - }, - }); - } - - /** - * User - */ - - @Redactable() - private userDeleted({ user }: Event['user-deleted']) { - void this.eventBus.sendAuditEvent({ - eventName: 'n8n.audit.user.deleted', - payload: user, - }); - } - - @Redactable() - private userInvited({ user, targetUserId }: Event['user-invited']) { - void this.eventBus.sendAuditEvent({ - eventName: 'n8n.audit.user.invited', - payload: { ...user, targetUserId }, - }); - } - - @Redactable() - private userReinvited({ user, targetUserId }: Event['user-reinvited']) { - void this.eventBus.sendAuditEvent({ - eventName: 'n8n.audit.user.reinvited', - payload: { ...user, targetUserId }, - }); - } - - @Redactable() - private userUpdated({ user, fieldsChanged }: Event['user-updated']) { - void this.eventBus.sendAuditEvent({ - eventName: 'n8n.audit.user.updated', - payload: { ...user, fieldsChanged }, - }); - } - - /** - * Auth - */ - - @Redactable() - private userSignedUp({ user }: Event['user-signed-up']) { - void this.eventBus.sendAuditEvent({ - eventName: 'n8n.audit.user.signedup', - payload: user, - }); - } - - @Redactable() - private userLoggedIn({ user, authenticationMethod }: Event['user-logged-in']) { - void this.eventBus.sendAuditEvent({ - eventName: 'n8n.audit.user.login.success', - payload: { ...user, authenticationMethod }, - }); - } - - private userLoginFailed( - event: Event['user-login-failed'] /* exception: no `UserLike` to redact */, - ) { - void this.eventBus.sendAuditEvent({ - eventName: 'n8n.audit.user.login.failed', - payload: event, - }); - } - - /** - * Click - */ - - @Redactable('inviter') - @Redactable('invitee') - private userInviteEmailClick(event: Event['user-invite-email-click']) { - void this.eventBus.sendAuditEvent({ - eventName: 'n8n.audit.user.invitation.accepted', - payload: event, - }); - } - - @Redactable() - private userPasswordResetEmailClick({ user }: Event['user-password-reset-email-click']) { - void this.eventBus.sendAuditEvent({ - eventName: 'n8n.audit.user.reset', - payload: user, - }); - } - - @Redactable() - private userPasswordResetRequestClick({ user }: Event['user-password-reset-request-click']) { - void this.eventBus.sendAuditEvent({ - eventName: 'n8n.audit.user.reset.requested', - payload: user, - }); - } - - /** - * Public API - */ - - @Redactable() - private publicApiKeyCreated({ user }: Event['public-api-key-created']) { - void this.eventBus.sendAuditEvent({ - eventName: 'n8n.audit.user.api.created', - payload: user, - }); - } - - @Redactable() - private publicApiKeyDeleted({ user }: Event['public-api-key-deleted']) { - void this.eventBus.sendAuditEvent({ - eventName: 'n8n.audit.user.api.deleted', - payload: user, - }); - } - - /** - * Emailing - */ - - @Redactable() - private emailFailed({ user, messageType }: Event['email-failed']) { - void this.eventBus.sendAuditEvent({ - eventName: 'n8n.audit.user.email.failed', - payload: { ...user, messageType }, - }); - } - - /** - * Credentials - */ - - @Redactable() - private credentialsCreated({ user, ...rest }: Event['credentials-created']) { - void this.eventBus.sendAuditEvent({ - eventName: 'n8n.audit.user.credentials.created', - payload: { ...user, ...rest }, - }); - } - - @Redactable() - private credentialsDeleted({ user, ...rest }: Event['credentials-deleted']) { - void this.eventBus.sendAuditEvent({ - eventName: 'n8n.audit.user.credentials.deleted', - payload: { ...user, ...rest }, - }); - } - - @Redactable() - private credentialsShared({ user, ...rest }: Event['credentials-shared']) { - void this.eventBus.sendAuditEvent({ - eventName: 'n8n.audit.user.credentials.shared', - payload: { ...user, ...rest }, - }); - } - - @Redactable() - private credentialsUpdated({ user, ...rest }: Event['credentials-updated']) { - void this.eventBus.sendAuditEvent({ - eventName: 'n8n.audit.user.credentials.updated', - payload: { ...user, ...rest }, - }); - } - - /** - * Community package - */ - - @Redactable() - private communityPackageInstalled({ user, ...rest }: Event['community-package-installed']) { - void this.eventBus.sendAuditEvent({ - eventName: 'n8n.audit.package.installed', - payload: { ...user, ...rest }, - }); - } - - @Redactable() - private communityPackageUpdated({ user, ...rest }: Event['community-package-updated']) { - void this.eventBus.sendAuditEvent({ - eventName: 'n8n.audit.package.updated', - payload: { ...user, ...rest }, - }); - } - - @Redactable() - private communityPackageDeleted({ user, ...rest }: Event['community-package-deleted']) { - void this.eventBus.sendAuditEvent({ - eventName: 'n8n.audit.package.deleted', - payload: { ...user, ...rest }, - }); - } - - /** - * Execution - */ - - private executionThrottled({ executionId }: Event['execution-throttled']) { - void this.eventBus.sendExecutionEvent({ - eventName: 'n8n.execution.throttled', - payload: { executionId }, - }); - } - - private executionStartedDuringBootup({ executionId }: Event['execution-started-during-bootup']) { - void this.eventBus.sendExecutionEvent({ - eventName: 'n8n.execution.started-during-bootup', - payload: { executionId }, - }); - } -} diff --git a/packages/cli/src/eventbus/event.service.ts b/packages/cli/src/eventbus/event.service.ts deleted file mode 100644 index 2b16ff06ab40c..0000000000000 --- a/packages/cli/src/eventbus/event.service.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { Service } from 'typedi'; -import { TypedEmitter } from '@/TypedEmitter'; -import type { Event } from './event.types'; - -@Service() -export class EventService extends TypedEmitter {} diff --git a/packages/cli/src/eventbus/__tests__/audit-event-relay.service.test.ts b/packages/cli/src/events/__tests__/log-streaming-event-relay.test.ts similarity index 61% rename from packages/cli/src/eventbus/__tests__/audit-event-relay.service.test.ts rename to packages/cli/src/events/__tests__/log-streaming-event-relay.test.ts index 52b86b58e1b6b..7254f5a09bfc6 100644 --- a/packages/cli/src/eventbus/__tests__/audit-event-relay.service.test.ts +++ b/packages/cli/src/events/__tests__/log-streaming-event-relay.test.ts @@ -1,16 +1,15 @@ import { mock } from 'jest-mock-extended'; -import { AuditEventRelay } from '../audit-event-relay.service'; -import type { MessageEventBus } from '../MessageEventBus/MessageEventBus'; -import type { Event } from '../event.types'; -import { EventService } from '../event.service'; +import { LogStreamingEventRelay } from '@/events/log-streaming-event-relay'; +import { EventService } from '@/events/event.service'; import type { INode, IRun, IWorkflowBase } from 'n8n-workflow'; import type { IWorkflowDb } from '@/Interfaces'; +import type { MessageEventBus } from '@/eventbus/MessageEventBus/MessageEventBus'; +import type { RelayEventMap } from '@/events/relay-event-map'; -describe('AuditEventRelay', () => { +describe('LogStreamingEventRelay', () => { const eventBus = mock(); const eventService = new EventService(); - const auditor = new AuditEventRelay(eventService, eventBus); - auditor.init(); + new LogStreamingEventRelay(eventService, eventBus).init(); afterEach(() => { jest.clearAllMocks(); @@ -18,7 +17,7 @@ describe('AuditEventRelay', () => { describe('workflow events', () => { it('should log on `workflow-created` event', () => { - const event: Event['workflow-created'] = { + const event: RelayEventMap['workflow-created'] = { user: { id: '123', email: 'john@n8n.io', @@ -52,7 +51,7 @@ describe('AuditEventRelay', () => { }); it('should log on `workflow-deleted` event', () => { - const event: Event['workflow-deleted'] = { + const event: RelayEventMap['workflow-deleted'] = { user: { id: '456', email: 'jane@n8n.io', @@ -80,7 +79,7 @@ describe('AuditEventRelay', () => { }); it('should log on `workflow-saved` event', () => { - const event: Event['workflow-saved'] = { + const event: RelayEventMap['workflow-saved'] = { user: { id: '789', email: 'alex@n8n.io', @@ -119,7 +118,7 @@ describe('AuditEventRelay', () => { settings: {}, }); - const event: Event['workflow-pre-execute'] = { + const event: RelayEventMap['workflow-pre-execute'] = { executionId: 'exec123', data: workflow, }; @@ -139,29 +138,33 @@ describe('AuditEventRelay', () => { }); it('should log on `workflow-post-execute` for successful execution', () => { - const payload = mock({ + const payload = mock({ executionId: 'some-id', - success: true, userId: 'some-id', - workflowId: 'some-id', - isManual: true, - workflowName: 'some-name', - metadata: {}, - runData: mock({ data: { resultData: {} } }), + workflow: mock({ id: 'some-id', name: 'some-name' }), + runData: mock({ status: 'success', mode: 'manual', data: { resultData: {} } }), }); eventService.emit('workflow-post-execute', payload); - const { runData: _, ...rest } = payload; + const { runData: _, workflow: __, ...rest } = payload; expect(eventBus.sendWorkflowEvent).toHaveBeenCalledWith({ eventName: 'n8n.workflow.success', - payload: rest, + payload: { + ...rest, + success: true, + isManual: true, + workflowName: 'some-name', + workflowId: 'some-id', + }, }); }); - it('should handle `workflow-post-execute` event for unsuccessful execution', () => { + it('should log on `workflow-post-execute` event for unsuccessful execution', () => { const runData = mock({ + status: 'error', + mode: 'manual', data: { resultData: { lastNodeExecuted: 'some-node', @@ -177,23 +180,23 @@ describe('AuditEventRelay', () => { const event = { executionId: 'some-id', - success: false, userId: 'some-id', - workflowId: 'some-id', - isManual: true, - workflowName: 'some-name', - metadata: {}, + workflow: mock({ id: 'some-id', name: 'some-name' }), runData, }; eventService.emit('workflow-post-execute', event); - const { runData: _, ...rest } = event; + const { runData: _, workflow: __, ...rest } = event; expect(eventBus.sendWorkflowEvent).toHaveBeenCalledWith({ eventName: 'n8n.workflow.failed', payload: { ...rest, + success: false, + isManual: true, + workflowName: 'some-name', + workflowId: 'some-id', lastNodeExecuted: 'some-node', errorNodeType: 'some-type', errorMessage: 'some-message', @@ -204,7 +207,7 @@ describe('AuditEventRelay', () => { describe('user events', () => { it('should log on `user-updated` event', () => { - const event: Event['user-updated'] = { + const event: RelayEventMap['user-updated'] = { user: { id: 'user456', email: 'updated@example.com', @@ -231,7 +234,7 @@ describe('AuditEventRelay', () => { }); it('should log on `user-deleted` event', () => { - const event: Event['user-deleted'] = { + const event: RelayEventMap['user-deleted'] = { user: { id: '123', email: 'john@n8n.io', @@ -239,6 +242,11 @@ describe('AuditEventRelay', () => { lastName: 'Doe', role: 'some-role', }, + targetUserOldStatus: 'active', + publicApi: false, + migrationStrategy: 'transfer_data', + targetUserId: '456', + migrationUserId: '789', }; eventService.emit('user-deleted', event); @@ -254,11 +262,122 @@ describe('AuditEventRelay', () => { }, }); }); + + it('should log on `user-invited` event', () => { + const event: RelayEventMap['user-invited'] = { + user: { + id: 'user101', + email: 'inviter@example.com', + firstName: 'Inviter', + lastName: 'User', + role: 'global:owner', + }, + targetUserId: ['newUser123'], + publicApi: false, + emailSent: true, + inviteeRole: 'global:member', + }; + + eventService.emit('user-invited', event); + + expect(eventBus.sendAuditEvent).toHaveBeenCalledWith({ + eventName: 'n8n.audit.user.invited', + payload: { + userId: 'user101', + _email: 'inviter@example.com', + _firstName: 'Inviter', + _lastName: 'User', + globalRole: 'global:owner', + targetUserId: ['newUser123'], + }, + }); + }); + + it('should log on `user-reinvited` event', () => { + const event: RelayEventMap['user-reinvited'] = { + user: { + id: 'user202', + email: 'reinviter@example.com', + firstName: 'Reinviter', + lastName: 'User', + role: 'global:admin', + }, + targetUserId: ['existingUser456'], + }; + + eventService.emit('user-reinvited', event); + + expect(eventBus.sendAuditEvent).toHaveBeenCalledWith({ + eventName: 'n8n.audit.user.reinvited', + payload: { + userId: 'user202', + _email: 'reinviter@example.com', + _firstName: 'Reinviter', + _lastName: 'User', + globalRole: 'global:admin', + targetUserId: ['existingUser456'], + }, + }); + }); + + it('should log on `user-signed-up` event', () => { + const event: RelayEventMap['user-signed-up'] = { + user: { + id: 'user303', + email: 'newuser@example.com', + firstName: 'New', + lastName: 'User', + role: 'global:member', + }, + userType: 'email', + wasDisabledLdapUser: false, + }; + + eventService.emit('user-signed-up', event); + + expect(eventBus.sendAuditEvent).toHaveBeenCalledWith({ + eventName: 'n8n.audit.user.signedup', + payload: { + userId: 'user303', + _email: 'newuser@example.com', + _firstName: 'New', + _lastName: 'User', + globalRole: 'global:member', + }, + }); + }); + + it('should log on `user-logged-in` event', () => { + const event: RelayEventMap['user-logged-in'] = { + user: { + id: 'user404', + email: 'loggedin@example.com', + firstName: 'Logged', + lastName: 'In', + role: 'global:owner', + }, + authenticationMethod: 'email', + }; + + eventService.emit('user-logged-in', event); + + expect(eventBus.sendAuditEvent).toHaveBeenCalledWith({ + eventName: 'n8n.audit.user.login.success', + payload: { + userId: 'user404', + _email: 'loggedin@example.com', + _firstName: 'Logged', + _lastName: 'In', + globalRole: 'global:owner', + authenticationMethod: 'email', + }, + }); + }); }); describe('click events', () => { it('should log on `user-password-reset-request-click` event', () => { - const event: Event['user-password-reset-request-click'] = { + const event: RelayEventMap['user-password-reset-request-click'] = { user: { id: 'user101', email: 'user101@example.com', @@ -283,7 +402,7 @@ describe('AuditEventRelay', () => { }); it('should log on `user-invite-email-click` event', () => { - const event: Event['user-invite-email-click'] = { + const event: RelayEventMap['user-invite-email-click'] = { inviter: { id: '123', email: 'john@n8n.io', @@ -322,6 +441,31 @@ describe('AuditEventRelay', () => { }, }); }); + + it('should log on `user-password-reset-email-click` event', () => { + const event: RelayEventMap['user-password-reset-email-click'] = { + user: { + id: 'user505', + email: 'resetuser@example.com', + firstName: 'Reset', + lastName: 'User', + role: 'global:member', + }, + }; + + eventService.emit('user-password-reset-email-click', event); + + expect(eventBus.sendAuditEvent).toHaveBeenCalledWith({ + eventName: 'n8n.audit.user.reset', + payload: { + userId: 'user505', + _email: 'resetuser@example.com', + _firstName: 'Reset', + _lastName: 'User', + globalRole: 'global:member', + }, + }); + }); }); describe('node events', () => { @@ -350,7 +494,7 @@ describe('AuditEventRelay', () => { settings: {}, }); - const event: Event['node-pre-execute'] = { + const event: RelayEventMap['node-pre-execute'] = { executionId: 'exec456', nodeName: 'HTTP Request', workflow, @@ -395,7 +539,7 @@ describe('AuditEventRelay', () => { settings: {}, }); - const event: Event['node-post-execute'] = { + const event: RelayEventMap['node-post-execute'] = { executionId: 'exec789', nodeName: 'HTTP Response', workflow, @@ -418,7 +562,7 @@ describe('AuditEventRelay', () => { describe('credentials events', () => { it('should log on `credentials-shared` event', () => { - const event: Event['credentials-shared'] = { + const event: RelayEventMap['credentials-shared'] = { user: { id: 'user123', email: 'sharer@example.com', @@ -453,7 +597,7 @@ describe('AuditEventRelay', () => { }); it('should log on `credentials-created` event', () => { - const event: Event['credentials-created'] = { + const event: RelayEventMap['credentials-created'] = { user: { id: 'user123', email: 'user@example.com', @@ -486,11 +630,69 @@ describe('AuditEventRelay', () => { }, }); }); + + it('should log on `credentials-deleted` event', () => { + const event: RelayEventMap['credentials-deleted'] = { + user: { + id: 'user707', + email: 'creduser@example.com', + firstName: 'Cred', + lastName: 'User', + role: 'global:owner', + }, + credentialId: 'cred789', + credentialType: 'githubApi', + }; + + eventService.emit('credentials-deleted', event); + + expect(eventBus.sendAuditEvent).toHaveBeenCalledWith({ + eventName: 'n8n.audit.user.credentials.deleted', + payload: { + userId: 'user707', + _email: 'creduser@example.com', + _firstName: 'Cred', + _lastName: 'User', + globalRole: 'global:owner', + credentialId: 'cred789', + credentialType: 'githubApi', + }, + }); + }); + + it('should log on `credentials-updated` event', () => { + const event: RelayEventMap['credentials-updated'] = { + user: { + id: 'user808', + email: 'updatecred@example.com', + firstName: 'Update', + lastName: 'Cred', + role: 'global:owner', + }, + credentialId: 'cred101', + credentialType: 'slackApi', + }; + + eventService.emit('credentials-updated', event); + + expect(eventBus.sendAuditEvent).toHaveBeenCalledWith({ + eventName: 'n8n.audit.user.credentials.updated', + payload: { + userId: 'user808', + _email: 'updatecred@example.com', + _firstName: 'Update', + _lastName: 'Cred', + globalRole: 'global:owner', + credentialId: 'cred101', + credentialType: 'slackApi', + }, + }); + }); }); describe('auth events', () => { it('should log on `user-login-failed` event', () => { - const event: Event['user-login-failed'] = { + const event: RelayEventMap['user-login-failed'] = { userEmail: 'user@example.com', authenticationMethod: 'email', reason: 'Invalid password', @@ -511,7 +713,7 @@ describe('AuditEventRelay', () => { describe('community package events', () => { it('should log on `community-package-updated` event', () => { - const event: Event['community-package-updated'] = { + const event: RelayEventMap['community-package-updated'] = { user: { id: 'user202', email: 'packageupdater@example.com', @@ -548,7 +750,7 @@ describe('AuditEventRelay', () => { }); it('should log on `community-package-installed` event', () => { - const event: Event['community-package-installed'] = { + const event: RelayEventMap['community-package-installed'] = { user: { id: 'user789', email: 'admin@example.com', @@ -585,11 +787,46 @@ describe('AuditEventRelay', () => { }, }); }); + + it('should log on `community-package-deleted` event', () => { + const event: RelayEventMap['community-package-deleted'] = { + user: { + id: 'user909', + email: 'packagedeleter@example.com', + firstName: 'Package', + lastName: 'Deleter', + role: 'global:admin', + }, + packageName: 'n8n-nodes-awesome-package', + packageVersion: '1.0.0', + packageNodeNames: ['AwesomeNode1', 'AwesomeNode2'], + packageAuthor: 'John Doe', + packageAuthorEmail: 'john@example.com', + }; + + eventService.emit('community-package-deleted', event); + + expect(eventBus.sendAuditEvent).toHaveBeenCalledWith({ + eventName: 'n8n.audit.package.deleted', + payload: { + userId: 'user909', + _email: 'packagedeleter@example.com', + _firstName: 'Package', + _lastName: 'Deleter', + globalRole: 'global:admin', + packageName: 'n8n-nodes-awesome-package', + packageVersion: '1.0.0', + packageNodeNames: ['AwesomeNode1', 'AwesomeNode2'], + packageAuthor: 'John Doe', + packageAuthorEmail: 'john@example.com', + }, + }); + }); }); describe('email events', () => { it('should log on `email-failed` event', () => { - const event: Event['email-failed'] = { + const event: RelayEventMap['email-failed'] = { user: { id: 'user789', email: 'recipient@example.com', @@ -598,6 +835,7 @@ describe('AuditEventRelay', () => { role: 'global:member', }, messageType: 'New user invite', + publicApi: false, }; eventService.emit('email-failed', event); @@ -618,7 +856,7 @@ describe('AuditEventRelay', () => { describe('public API events', () => { it('should log on `public-api-key-created` event', () => { - const event: Event['public-api-key-created'] = { + const event: RelayEventMap['public-api-key-created'] = { user: { id: 'user101', email: 'apiuser@example.com', @@ -642,11 +880,52 @@ describe('AuditEventRelay', () => { }, }); }); + + it('should log on `public-api-key-deleted` event', () => { + const event: RelayEventMap['public-api-key-deleted'] = { + user: { + id: 'user606', + email: 'apiuser@example.com', + firstName: 'API', + lastName: 'User', + role: 'global:owner', + }, + publicApi: true, + }; + + eventService.emit('public-api-key-deleted', event); + + expect(eventBus.sendAuditEvent).toHaveBeenCalledWith({ + eventName: 'n8n.audit.user.api.deleted', + payload: { + userId: 'user606', + _email: 'apiuser@example.com', + _firstName: 'API', + _lastName: 'User', + globalRole: 'global:owner', + }, + }); + }); }); describe('execution events', () => { + it('should log on `execution-started-during-bootup` event', () => { + const event: RelayEventMap['execution-started-during-bootup'] = { + executionId: 'exec101010', + }; + + eventService.emit('execution-started-during-bootup', event); + + expect(eventBus.sendExecutionEvent).toHaveBeenCalledWith({ + eventName: 'n8n.execution.started-during-bootup', + payload: { + executionId: 'exec101010', + }, + }); + }); + it('should log on `execution-throttled` event', () => { - const event: Event['execution-throttled'] = { + const event: RelayEventMap['execution-throttled'] = { executionId: 'exec123456', }; diff --git a/packages/cli/src/events/event-relay.ts b/packages/cli/src/events/event-relay.ts new file mode 100644 index 0000000000000..1a8a17b8930f2 --- /dev/null +++ b/packages/cli/src/events/event-relay.ts @@ -0,0 +1,20 @@ +import { EventService } from './event.service'; +import { Service } from 'typedi'; +import type { RelayEventMap } from '@/events/relay-event-map'; + +@Service() +export class EventRelay { + constructor(readonly eventService: EventService) {} + + protected setupListeners(map: { + [EventName in EventNames]?: (event: RelayEventMap[EventName]) => void | Promise; + }) { + for (const [eventName, handler] of Object.entries(map) as Array< + [EventNames, (event: RelayEventMap[EventNames]) => void | Promise] + >) { + this.eventService.on(eventName, async (event) => { + await handler(event); + }); + } + } +} diff --git a/packages/cli/src/events/event.service.ts b/packages/cli/src/events/event.service.ts new file mode 100644 index 0000000000000..6744103a07799 --- /dev/null +++ b/packages/cli/src/events/event.service.ts @@ -0,0 +1,6 @@ +import { Service } from 'typedi'; +import { TypedEmitter } from '@/TypedEmitter'; +import type { RelayEventMap } from './relay-event-map'; + +@Service() +export class EventService extends TypedEmitter {} diff --git a/packages/cli/src/events/log-streaming-event-relay.ts b/packages/cli/src/events/log-streaming-event-relay.ts new file mode 100644 index 0000000000000..85d5a8cb8fb3c --- /dev/null +++ b/packages/cli/src/events/log-streaming-event-relay.ts @@ -0,0 +1,390 @@ +import { Service } from 'typedi'; +import { MessageEventBus } from '@/eventbus/MessageEventBus/MessageEventBus'; +import { Redactable } from '@/decorators/Redactable'; +import { EventRelay } from '@/events/event-relay'; +import type { RelayEventMap } from '@/events/relay-event-map'; +import type { IWorkflowBase } from 'n8n-workflow'; +import { EventService } from './event.service'; + +@Service() +export class LogStreamingEventRelay extends EventRelay { + constructor( + readonly eventService: EventService, + private readonly eventBus: MessageEventBus, + ) { + super(eventService); + } + + init() { + this.setupListeners({ + 'workflow-created': (event) => this.workflowCreated(event), + 'workflow-deleted': (event) => this.workflowDeleted(event), + 'workflow-saved': (event) => this.workflowSaved(event), + 'workflow-pre-execute': (event) => this.workflowPreExecute(event), + 'workflow-post-execute': (event) => this.workflowPostExecute(event), + 'node-pre-execute': (event) => this.nodePreExecute(event), + 'node-post-execute': (event) => this.nodePostExecute(event), + 'user-deleted': (event) => this.userDeleted(event), + 'user-invited': (event) => this.userInvited(event), + 'user-reinvited': (event) => this.userReinvited(event), + 'user-updated': (event) => this.userUpdated(event), + 'user-signed-up': (event) => this.userSignedUp(event), + 'user-logged-in': (event) => this.userLoggedIn(event), + 'user-login-failed': (event) => this.userLoginFailed(event), + 'user-invite-email-click': (event) => this.userInviteEmailClick(event), + 'user-password-reset-email-click': (event) => this.userPasswordResetEmailClick(event), + 'user-password-reset-request-click': (event) => this.userPasswordResetRequestClick(event), + 'public-api-key-created': (event) => this.publicApiKeyCreated(event), + 'public-api-key-deleted': (event) => this.publicApiKeyDeleted(event), + 'email-failed': (event) => this.emailFailed(event), + 'credentials-created': (event) => this.credentialsCreated(event), + 'credentials-deleted': (event) => this.credentialsDeleted(event), + 'credentials-shared': (event) => this.credentialsShared(event), + 'credentials-updated': (event) => this.credentialsUpdated(event), + 'community-package-installed': (event) => this.communityPackageInstalled(event), + 'community-package-updated': (event) => this.communityPackageUpdated(event), + 'community-package-deleted': (event) => this.communityPackageDeleted(event), + 'execution-throttled': (event) => this.executionThrottled(event), + 'execution-started-during-bootup': (event) => this.executionStartedDuringBootup(event), + }); + } + + // #region Workflow + + @Redactable() + private workflowCreated({ user, workflow }: RelayEventMap['workflow-created']) { + void this.eventBus.sendAuditEvent({ + eventName: 'n8n.audit.workflow.created', + payload: { + ...user, + workflowId: workflow.id, + workflowName: workflow.name, + }, + }); + } + + @Redactable() + private workflowDeleted({ user, workflowId }: RelayEventMap['workflow-deleted']) { + void this.eventBus.sendAuditEvent({ + eventName: 'n8n.audit.workflow.deleted', + payload: { ...user, workflowId }, + }); + } + + @Redactable() + private workflowSaved({ user, workflow }: RelayEventMap['workflow-saved']) { + void this.eventBus.sendAuditEvent({ + eventName: 'n8n.audit.workflow.updated', + payload: { + ...user, + workflowId: workflow.id, + workflowName: workflow.name, + }, + }); + } + + private workflowPreExecute({ data, executionId }: RelayEventMap['workflow-pre-execute']) { + const payload = + 'executionData' in data + ? { + executionId, + userId: data.userId, + workflowId: data.workflowData.id, + isManual: data.executionMode === 'manual', + workflowName: data.workflowData.name, + } + : { + executionId, + userId: undefined, + workflowId: (data as IWorkflowBase).id, + isManual: false, + workflowName: (data as IWorkflowBase).name, + }; + + void this.eventBus.sendWorkflowEvent({ + eventName: 'n8n.workflow.started', + payload, + }); + } + + private workflowPostExecute(event: RelayEventMap['workflow-post-execute']) { + const { runData, workflow, ...rest } = event; + + const payload = { + ...rest, + success: runData?.status === 'success', + isManual: runData?.mode === 'manual', + workflowId: workflow.id, + workflowName: workflow.name, + }; + + if (payload.success) { + void this.eventBus.sendWorkflowEvent({ + eventName: 'n8n.workflow.success', + payload, + }); + + return; + } + + void this.eventBus.sendWorkflowEvent({ + eventName: 'n8n.workflow.failed', + payload: { + ...payload, + lastNodeExecuted: runData?.data.resultData.lastNodeExecuted, + errorNodeType: + runData?.data.resultData.error && 'node' in runData?.data.resultData.error + ? runData?.data.resultData.error.node?.type + : undefined, + errorMessage: runData?.data.resultData.error?.message.toString(), + }, + }); + } + + // #endregion + + // #region Node + + private nodePreExecute({ workflow, executionId, nodeName }: RelayEventMap['node-pre-execute']) { + void this.eventBus.sendNodeEvent({ + eventName: 'n8n.node.started', + payload: { + workflowId: workflow.id, + workflowName: workflow.name, + executionId, + nodeType: workflow.nodes.find((n) => n.name === nodeName)?.type, + nodeName, + }, + }); + } + + private nodePostExecute({ workflow, executionId, nodeName }: RelayEventMap['node-post-execute']) { + void this.eventBus.sendNodeEvent({ + eventName: 'n8n.node.finished', + payload: { + workflowId: workflow.id, + workflowName: workflow.name, + executionId, + nodeType: workflow.nodes.find((n) => n.name === nodeName)?.type, + nodeName, + }, + }); + } + + // #endregion + + // #region User + + @Redactable() + private userDeleted({ user }: RelayEventMap['user-deleted']) { + void this.eventBus.sendAuditEvent({ + eventName: 'n8n.audit.user.deleted', + payload: user, + }); + } + + @Redactable() + private userInvited({ user, targetUserId }: RelayEventMap['user-invited']) { + void this.eventBus.sendAuditEvent({ + eventName: 'n8n.audit.user.invited', + payload: { ...user, targetUserId }, + }); + } + + @Redactable() + private userReinvited({ user, targetUserId }: RelayEventMap['user-reinvited']) { + void this.eventBus.sendAuditEvent({ + eventName: 'n8n.audit.user.reinvited', + payload: { ...user, targetUserId }, + }); + } + + @Redactable() + private userUpdated({ user, fieldsChanged }: RelayEventMap['user-updated']) { + void this.eventBus.sendAuditEvent({ + eventName: 'n8n.audit.user.updated', + payload: { ...user, fieldsChanged }, + }); + } + + // #endregion + + // #region Auth + + @Redactable() + private userSignedUp({ user }: RelayEventMap['user-signed-up']) { + void this.eventBus.sendAuditEvent({ + eventName: 'n8n.audit.user.signedup', + payload: user, + }); + } + + @Redactable() + private userLoggedIn({ user, authenticationMethod }: RelayEventMap['user-logged-in']) { + void this.eventBus.sendAuditEvent({ + eventName: 'n8n.audit.user.login.success', + payload: { ...user, authenticationMethod }, + }); + } + + private userLoginFailed( + event: RelayEventMap['user-login-failed'] /* exception: no `UserLike` to redact */, + ) { + void this.eventBus.sendAuditEvent({ + eventName: 'n8n.audit.user.login.failed', + payload: event, + }); + } + + // #endregion + + // #region Click + + @Redactable('inviter') + @Redactable('invitee') + private userInviteEmailClick(event: RelayEventMap['user-invite-email-click']) { + void this.eventBus.sendAuditEvent({ + eventName: 'n8n.audit.user.invitation.accepted', + payload: event, + }); + } + + @Redactable() + private userPasswordResetEmailClick({ user }: RelayEventMap['user-password-reset-email-click']) { + void this.eventBus.sendAuditEvent({ + eventName: 'n8n.audit.user.reset', + payload: user, + }); + } + + @Redactable() + private userPasswordResetRequestClick({ + user, + }: RelayEventMap['user-password-reset-request-click']) { + void this.eventBus.sendAuditEvent({ + eventName: 'n8n.audit.user.reset.requested', + payload: user, + }); + } + + // #endregion + + // #region Public API + + @Redactable() + private publicApiKeyCreated({ user }: RelayEventMap['public-api-key-created']) { + void this.eventBus.sendAuditEvent({ + eventName: 'n8n.audit.user.api.created', + payload: user, + }); + } + + @Redactable() + private publicApiKeyDeleted({ user }: RelayEventMap['public-api-key-deleted']) { + void this.eventBus.sendAuditEvent({ + eventName: 'n8n.audit.user.api.deleted', + payload: user, + }); + } + + // #endregion + + // #region Email + + @Redactable() + private emailFailed({ user, messageType }: RelayEventMap['email-failed']) { + void this.eventBus.sendAuditEvent({ + eventName: 'n8n.audit.user.email.failed', + payload: { ...user, messageType }, + }); + } + + // #endregion + + // #region Credentials + + @Redactable() + private credentialsCreated({ user, ...rest }: RelayEventMap['credentials-created']) { + void this.eventBus.sendAuditEvent({ + eventName: 'n8n.audit.user.credentials.created', + payload: { ...user, ...rest }, + }); + } + + @Redactable() + private credentialsDeleted({ user, ...rest }: RelayEventMap['credentials-deleted']) { + void this.eventBus.sendAuditEvent({ + eventName: 'n8n.audit.user.credentials.deleted', + payload: { ...user, ...rest }, + }); + } + + @Redactable() + private credentialsShared({ user, ...rest }: RelayEventMap['credentials-shared']) { + void this.eventBus.sendAuditEvent({ + eventName: 'n8n.audit.user.credentials.shared', + payload: { ...user, ...rest }, + }); + } + + @Redactable() + private credentialsUpdated({ user, ...rest }: RelayEventMap['credentials-updated']) { + void this.eventBus.sendAuditEvent({ + eventName: 'n8n.audit.user.credentials.updated', + payload: { ...user, ...rest }, + }); + } + + // #endregion + + // #region Community package + + @Redactable() + private communityPackageInstalled({ + user, + ...rest + }: RelayEventMap['community-package-installed']) { + void this.eventBus.sendAuditEvent({ + eventName: 'n8n.audit.package.installed', + payload: { ...user, ...rest }, + }); + } + + @Redactable() + private communityPackageUpdated({ user, ...rest }: RelayEventMap['community-package-updated']) { + void this.eventBus.sendAuditEvent({ + eventName: 'n8n.audit.package.updated', + payload: { ...user, ...rest }, + }); + } + + @Redactable() + private communityPackageDeleted({ user, ...rest }: RelayEventMap['community-package-deleted']) { + void this.eventBus.sendAuditEvent({ + eventName: 'n8n.audit.package.deleted', + payload: { ...user, ...rest }, + }); + } + + // #endregion + + // #region Execution + + private executionThrottled({ executionId }: RelayEventMap['execution-throttled']) { + void this.eventBus.sendExecutionEvent({ + eventName: 'n8n.execution.throttled', + payload: { executionId }, + }); + } + + private executionStartedDuringBootup({ + executionId, + }: RelayEventMap['execution-started-during-bootup']) { + void this.eventBus.sendExecutionEvent({ + eventName: 'n8n.execution.started-during-bootup', + payload: { executionId }, + }); + } + + // #endregion +} diff --git a/packages/cli/src/eventbus/event.types.ts b/packages/cli/src/events/relay-event-map.ts similarity index 68% rename from packages/cli/src/eventbus/event.types.ts rename to packages/cli/src/events/relay-event-map.ts index b62d3bc141031..8f0efc2fedf8c 100644 --- a/packages/cli/src/eventbus/event.types.ts +++ b/packages/cli/src/events/relay-event-map.ts @@ -2,6 +2,7 @@ import type { AuthenticationMethod, IRun, IWorkflowBase } from 'n8n-workflow'; import type { IWorkflowDb, IWorkflowExecutionDataProcess } from '@/Interfaces'; import type { ProjectRole } from '@/databases/entities/ProjectRelation'; import type { GlobalRole } from '@/databases/entities/User'; +import type { AuthProviderType } from '@/databases/entities/AuthIdentity'; export type UserLike = { id: string; @@ -11,12 +12,40 @@ export type UserLike = { role: string; }; -/** - * Events sent by `EventService` and forwarded by relays, e.g. `AuditEventRelay` and `TelemetryEventRelay`. - */ -export type Event = { +export type RelayEventMap = { + // #region Lifecycle + 'server-started': {}; + 'session-started': { + pushRef?: string; + }; + + 'instance-stopped': {}; + + 'instance-owner-setup': { + userId: string; + }; + + 'first-production-workflow-succeeded': { + projectId: string; + workflowId: string; + userId: string; + }; + + 'first-workflow-data-loaded': { + userId: string; + workflowId: string; + nodeType: string; + nodeId: string; + credentialType?: string; + credentialId?: string; + }; + + // #endregion + + // #region Workflow + 'workflow-created': { user: UserLike; workflow: IWorkflowBase; @@ -44,15 +73,21 @@ export type Event = { 'workflow-post-execute': { executionId: string; - success: boolean; userId?: string; - workflowId: string; - isManual: boolean; - workflowName: string; - metadata?: Record; + workflow: IWorkflowBase; runData?: IRun; }; + 'workflow-sharing-updated': { + workflowId: string; + userIdSharer: string; + userIdList: string[]; + }; + + // #endregion + + // #region Node + 'node-pre-execute': { executionId: string; workflow: IWorkflowBase; @@ -65,13 +100,30 @@ export type Event = { nodeName: string; }; + // #endregion + + // #region User + + 'user-submitted-personalization-survey': { + userId: string; + answers: Record; + }; + 'user-deleted': { user: UserLike; + publicApi: boolean; + targetUserOldStatus: 'active' | 'invited'; + migrationStrategy?: 'transfer_data' | 'delete_data'; + targetUserId?: string; + migrationUserId?: string; }; 'user-invited': { user: UserLike; targetUserId: string[]; + publicApi: boolean; + emailSent: boolean; + inviteeRole: string; }; 'user-reinvited': { @@ -86,6 +138,8 @@ export type Event = { 'user-signed-up': { user: UserLike; + userType: AuthProviderType; + wasDisabledLdapUser: boolean; }; 'user-logged-in': { @@ -99,6 +153,47 @@ export type Event = { reason?: string; }; + 'user-changed-role': { + userId: string; + targetUserId: string; + publicApi: boolean; + targetUserNewRole: string; + }; + + 'user-retrieved-user': { + userId: string; + publicApi: boolean; + }; + + 'user-retrieved-all-users': { + userId: string; + publicApi: boolean; + }; + + 'user-retrieved-execution': { + userId: string; + publicApi: boolean; + }; + + 'user-retrieved-all-executions': { + userId: string; + publicApi: boolean; + }; + + 'user-retrieved-workflow': { + userId: string; + publicApi: boolean; + }; + + 'user-retrieved-all-workflows': { + userId: string; + publicApi: boolean; + }; + + // #endregion + + // #region Click + 'user-invite-email-click': { inviter: UserLike; invitee: UserLike; @@ -112,6 +207,31 @@ export type Event = { user: UserLike; }; + 'user-transactional-email-sent': { + userId: string; + messageType: + | 'Reset password' + | 'New user invite' + | 'Resend invite' + | 'Workflow shared' + | 'Credentials shared'; + publicApi: boolean; + }; + + // #endregion + + // #region Public API + + 'public-api-key-created': { + user: UserLike; + publicApi: boolean; + }; + + 'public-api-key-deleted': { + user: UserLike; + publicApi: boolean; + }; + 'public-api-invoked': { userId: string; path: string; @@ -119,6 +239,10 @@ export type Event = { apiVersion: string; }; + // #endregion + + // #region Email + 'email-failed': { user: UserLike; messageType: @@ -127,8 +251,13 @@ export type Event = { | 'Resend invite' | 'Workflow shared' | 'Credentials shared'; + publicApi: boolean; }; + // #endregion + + // #region Credentials + 'credentials-created': { user: UserLike; credentialType: string; @@ -159,6 +288,10 @@ export type Event = { credentialId: string; }; + // #endregion + + // #region Community package + 'community-package-installed': { user: UserLike; inputString: string; @@ -190,6 +323,10 @@ export type Event = { packageAuthorEmail?: string; }; + // #endregion + + // #region Execution + 'execution-throttled': { executionId: string; }; @@ -198,6 +335,10 @@ export type Event = { executionId: string; }; + // #endregion + + // #region Project + 'team-project-updated': { userId: string; role: GlobalRole; @@ -221,6 +362,10 @@ export type Event = { role: GlobalRole; }; + // #endregion + + // #region Source control + 'source-control-settings-updated': { branchName: string; readOnlyInstance: boolean; @@ -258,12 +403,24 @@ export type Event = { variablesPushed: number; }; + // #endregion + + // #region License + 'license-renewal-attempted': { success: boolean; }; + // #endregion + + // #region Variable + 'variable-created': {}; + // #endregion + + // #region External secrets + 'external-secrets-provider-settings-saved': { userId?: string; vaultType: string; @@ -272,6 +429,10 @@ export type Event = { errorMessage?: string; }; + // #endregion + + // #region LDAP + 'ldap-general-sync-finished': { type: string; succeeded: boolean; @@ -302,17 +463,5 @@ export type Event = { userId: string; }; - /** - * Events listened to by more than one relay - */ - - 'public-api-key-created': { - user: UserLike; // audit and telemetry - publicApi: boolean; // telemetry only - }; - - 'public-api-key-deleted': { - user: UserLike; // audit and telemetry - publicApi: boolean; // telemetry only - }; + // #endregion }; diff --git a/packages/cli/src/events/telemetry-event-relay.ts b/packages/cli/src/events/telemetry-event-relay.ts new file mode 100644 index 0000000000000..6b0ae37a44307 --- /dev/null +++ b/packages/cli/src/events/telemetry-event-relay.ts @@ -0,0 +1,999 @@ +import { Service } from 'typedi'; +import { EventService } from '@/events/event.service'; +import type { RelayEventMap } from '@/events/relay-event-map'; +import { Telemetry } from '../telemetry'; +import config from '@/config'; +import os from 'node:os'; +import { License } from '@/License'; +import { GlobalConfig } from '@n8n/config'; +import { N8N_VERSION } from '@/constants'; +import { WorkflowRepository } from '@/databases/repositories/workflow.repository'; +import type { ExecutionStatus, INodesGraphResult, ITelemetryTrackProperties } from 'n8n-workflow'; +import { get as pslGet } from 'psl'; +import { TelemetryHelpers } from 'n8n-workflow'; +import { NodeTypes } from '@/NodeTypes'; +import { SharedWorkflowRepository } from '@/databases/repositories/sharedWorkflow.repository'; +import { ProjectRelationRepository } from '@/databases/repositories/projectRelation.repository'; +import type { IExecutionTrackProperties } from '@/Interfaces'; +import { determineFinalExecutionStatus } from '@/executionLifecycleHooks/shared/sharedHookFunctions'; +import { EventRelay } from './event-relay'; +import { snakeCase } from 'change-case'; + +@Service() +export class TelemetryEventRelay extends EventRelay { + constructor( + readonly eventService: EventService, + private readonly telemetry: Telemetry, + private readonly license: License, + private readonly globalConfig: GlobalConfig, + private readonly workflowRepository: WorkflowRepository, + private readonly nodeTypes: NodeTypes, + private readonly sharedWorkflowRepository: SharedWorkflowRepository, + private readonly projectRelationRepository: ProjectRelationRepository, + ) { + super(eventService); + } + + async init() { + if (!config.getEnv('diagnostics.enabled')) return; + + await this.telemetry.init(); + + this.setupListeners({ + 'team-project-updated': (event) => this.teamProjectUpdated(event), + 'team-project-deleted': (event) => this.teamProjectDeleted(event), + 'team-project-created': (event) => this.teamProjectCreated(event), + 'source-control-settings-updated': (event) => this.sourceControlSettingsUpdated(event), + 'source-control-user-started-pull-ui': (event) => this.sourceControlUserStartedPullUi(event), + 'source-control-user-finished-pull-ui': (event) => + this.sourceControlUserFinishedPullUi(event), + 'source-control-user-pulled-api': (event) => this.sourceControlUserPulledApi(event), + 'source-control-user-started-push-ui': (event) => this.sourceControlUserStartedPushUi(event), + 'source-control-user-finished-push-ui': (event) => + this.sourceControlUserFinishedPushUi(event), + 'license-renewal-attempted': (event) => this.licenseRenewalAttempted(event), + 'variable-created': () => this.variableCreated(), + 'external-secrets-provider-settings-saved': (event) => + this.externalSecretsProviderSettingsSaved(event), + 'public-api-invoked': (event) => this.publicApiInvoked(event), + 'public-api-key-created': (event) => this.publicApiKeyCreated(event), + 'public-api-key-deleted': (event) => this.publicApiKeyDeleted(event), + 'community-package-installed': (event) => this.communityPackageInstalled(event), + 'community-package-updated': (event) => this.communityPackageUpdated(event), + 'community-package-deleted': (event) => this.communityPackageDeleted(event), + 'credentials-created': (event) => this.credentialsCreated(event), + 'credentials-shared': (event) => this.credentialsShared(event), + 'credentials-updated': (event) => this.credentialsUpdated(event), + 'credentials-deleted': (event) => this.credentialsDeleted(event), + 'ldap-general-sync-finished': (event) => this.ldapGeneralSyncFinished(event), + 'ldap-settings-updated': (event) => this.ldapSettingsUpdated(event), + 'ldap-login-sync-failed': (event) => this.ldapLoginSyncFailed(event), + 'login-failed-due-to-ldap-disabled': (event) => this.loginFailedDueToLdapDisabled(event), + 'workflow-created': (event) => this.workflowCreated(event), + 'workflow-deleted': (event) => this.workflowDeleted(event), + 'workflow-sharing-updated': (event) => this.workflowSharingUpdated(event), + 'workflow-saved': async (event) => await this.workflowSaved(event), + 'server-started': async () => await this.serverStarted(), + 'session-started': (event) => this.sessionStarted(event), + 'instance-stopped': () => this.instanceStopped(), + 'instance-owner-setup': async (event) => await this.instanceOwnerSetup(event), + 'first-production-workflow-succeeded': (event) => + this.firstProductionWorkflowSucceeded(event), + 'first-workflow-data-loaded': (event) => this.firstWorkflowDataLoaded(event), + 'workflow-post-execute': async (event) => await this.workflowPostExecute(event), + 'user-changed-role': (event) => this.userChangedRole(event), + 'user-retrieved-user': (event) => this.userRetrievedUser(event), + 'user-retrieved-all-users': (event) => this.userRetrievedAllUsers(event), + 'user-retrieved-execution': (event) => this.userRetrievedExecution(event), + 'user-retrieved-all-executions': (event) => this.userRetrievedAllExecutions(event), + 'user-retrieved-workflow': (event) => this.userRetrievedWorkflow(event), + 'user-retrieved-all-workflows': (event) => this.userRetrievedAllWorkflows(event), + 'user-updated': (event) => this.userUpdated(event), + 'user-deleted': (event) => this.userDeleted(event), + 'user-invited': (event) => this.userInvited(event), + 'user-signed-up': (event) => this.userSignedUp(event), + 'user-submitted-personalization-survey': (event) => + this.userSubmittedPersonalizationSurvey(event), + 'email-failed': (event) => this.emailFailed(event), + 'user-transactional-email-sent': (event) => this.userTransactionalEmailSent(event), + 'user-invite-email-click': (event) => this.userInviteEmailClick(event), + 'user-password-reset-email-click': (event) => this.userPasswordResetEmailClick(event), + 'user-password-reset-request-click': (event) => this.userPasswordResetRequestClick(event), + }); + } + + // #endregion + + // #region Team + + private teamProjectUpdated({ + userId, + role, + members, + projectId, + }: RelayEventMap['team-project-updated']) { + this.telemetry.track('Project settings updated', { + user_id: userId, + role, + // eslint-disable-next-line @typescript-eslint/no-shadow + members: members.map(({ userId: user_id, role }) => ({ user_id, role })), + project_id: projectId, + }); + } + + private teamProjectDeleted({ + userId, + role, + projectId, + removalType, + targetProjectId, + }: RelayEventMap['team-project-deleted']) { + this.telemetry.track('User deleted project', { + user_id: userId, + role, + project_id: projectId, + removal_type: removalType, + target_project_id: targetProjectId, + }); + } + + private teamProjectCreated({ userId, role }: RelayEventMap['team-project-created']) { + this.telemetry.track('User created project', { + user_id: userId, + role, + }); + } + + // #endregion + + // #region Source control + + private sourceControlSettingsUpdated({ + branchName, + readOnlyInstance, + repoType, + connected, + }: RelayEventMap['source-control-settings-updated']) { + this.telemetry.track('User updated source control settings', { + branch_name: branchName, + read_only_instance: readOnlyInstance, + repo_type: repoType, + connected, + }); + } + + private sourceControlUserStartedPullUi({ + workflowUpdates, + workflowConflicts, + credConflicts, + }: RelayEventMap['source-control-user-started-pull-ui']) { + this.telemetry.track('User started pull via UI', { + workflow_updates: workflowUpdates, + workflow_conflicts: workflowConflicts, + cred_conflicts: credConflicts, + }); + } + + private sourceControlUserFinishedPullUi({ + workflowUpdates, + }: RelayEventMap['source-control-user-finished-pull-ui']) { + this.telemetry.track('User finished pull via UI', { + workflow_updates: workflowUpdates, + }); + } + + private sourceControlUserPulledApi({ + workflowUpdates, + forced, + }: RelayEventMap['source-control-user-pulled-api']) { + this.telemetry.track('User pulled via API', { + workflow_updates: workflowUpdates, + forced, + }); + } + + private sourceControlUserStartedPushUi({ + workflowsEligible, + workflowsEligibleWithConflicts, + credsEligible, + credsEligibleWithConflicts, + variablesEligible, + }: RelayEventMap['source-control-user-started-push-ui']) { + this.telemetry.track('User started push via UI', { + workflows_eligible: workflowsEligible, + workflows_eligible_with_conflicts: workflowsEligibleWithConflicts, + creds_eligible: credsEligible, + creds_eligible_with_conflicts: credsEligibleWithConflicts, + variables_eligible: variablesEligible, + }); + } + + private sourceControlUserFinishedPushUi({ + workflowsEligible, + workflowsPushed, + credsPushed, + variablesPushed, + }: RelayEventMap['source-control-user-finished-push-ui']) { + this.telemetry.track('User finished push via UI', { + workflows_eligible: workflowsEligible, + workflows_pushed: workflowsPushed, + creds_pushed: credsPushed, + variables_pushed: variablesPushed, + }); + } + + // #endregion + + // #region License + + private licenseRenewalAttempted({ success }: RelayEventMap['license-renewal-attempted']) { + this.telemetry.track('Instance attempted to refresh license', { + success, + }); + } + + // #endregion + + // #region Variable + + private variableCreated() { + this.telemetry.track('User created variable'); + } + + // #endregion + + // #region External secrets + + private externalSecretsProviderSettingsSaved({ + userId, + vaultType, + isValid, + isNew, + errorMessage, + }: RelayEventMap['external-secrets-provider-settings-saved']) { + this.telemetry.track('User updated external secrets settings', { + user_id: userId, + vault_type: vaultType, + is_valid: isValid, + is_new: isNew, + error_message: errorMessage, + }); + } + + // #endregion + + // #region Public API + + private publicApiInvoked({ + userId, + path, + method, + apiVersion, + }: RelayEventMap['public-api-invoked']) { + this.telemetry.track('User invoked API', { + user_id: userId, + path, + method, + api_version: apiVersion, + }); + } + + private publicApiKeyCreated(event: RelayEventMap['public-api-key-created']) { + const { user, publicApi } = event; + + this.telemetry.track('API key created', { + user_id: user.id, + public_api: publicApi, + }); + } + + private publicApiKeyDeleted(event: RelayEventMap['public-api-key-deleted']) { + const { user, publicApi } = event; + + this.telemetry.track('API key deleted', { + user_id: user.id, + public_api: publicApi, + }); + } + + // #endregion + + // #region Community package + + private communityPackageInstalled({ + user, + inputString, + packageName, + success, + packageVersion, + packageNodeNames, + packageAuthor, + packageAuthorEmail, + failureReason, + }: RelayEventMap['community-package-installed']) { + this.telemetry.track('cnr package install finished', { + user_id: user.id, + input_string: inputString, + package_name: packageName, + success, + package_version: packageVersion, + package_node_names: packageNodeNames, + package_author: packageAuthor, + package_author_email: packageAuthorEmail, + failure_reason: failureReason, + }); + } + + private communityPackageUpdated({ + user, + packageName, + packageVersionCurrent, + packageVersionNew, + packageNodeNames, + packageAuthor, + packageAuthorEmail, + }: RelayEventMap['community-package-updated']) { + this.telemetry.track('cnr package updated', { + user_id: user.id, + package_name: packageName, + package_version_current: packageVersionCurrent, + package_version_new: packageVersionNew, + package_node_names: packageNodeNames, + package_author: packageAuthor, + package_author_email: packageAuthorEmail, + }); + } + + private communityPackageDeleted({ + user, + packageName, + packageVersion, + packageNodeNames, + packageAuthor, + packageAuthorEmail, + }: RelayEventMap['community-package-deleted']) { + this.telemetry.track('cnr package deleted', { + user_id: user.id, + package_name: packageName, + package_version: packageVersion, + package_node_names: packageNodeNames, + package_author: packageAuthor, + package_author_email: packageAuthorEmail, + }); + } + + // #endregion + + // #region Credentials + + private credentialsCreated({ + user, + credentialType, + credentialId, + projectId, + projectType, + }: RelayEventMap['credentials-created']) { + this.telemetry.track('User created credentials', { + user_id: user.id, + credential_type: credentialType, + credential_id: credentialId, + project_id: projectId, + project_type: projectType, + }); + } + + private credentialsShared({ + user, + credentialType, + credentialId, + userIdSharer, + userIdsShareesAdded, + shareesRemoved, + }: RelayEventMap['credentials-shared']) { + this.telemetry.track('User updated cred sharing', { + user_id: user.id, + credential_type: credentialType, + credential_id: credentialId, + user_id_sharer: userIdSharer, + user_ids_sharees_added: userIdsShareesAdded, + sharees_removed: shareesRemoved, + }); + } + + private credentialsUpdated({ + user, + credentialId, + credentialType, + }: RelayEventMap['credentials-updated']) { + this.telemetry.track('User updated credentials', { + user_id: user.id, + credential_type: credentialType, + credential_id: credentialId, + }); + } + + private credentialsDeleted({ + user, + credentialId, + credentialType, + }: RelayEventMap['credentials-deleted']) { + this.telemetry.track('User deleted credentials', { + user_id: user.id, + credential_type: credentialType, + credential_id: credentialId, + }); + } + + // #endregion + + // #region LDAP + + private ldapGeneralSyncFinished({ + type, + succeeded, + usersSynced, + error, + }: RelayEventMap['ldap-general-sync-finished']) { + this.telemetry.track('Ldap general sync finished', { + type, + succeeded, + users_synced: usersSynced, + error, + }); + } + + private ldapSettingsUpdated({ + userId, + loginIdAttribute, + firstNameAttribute, + lastNameAttribute, + emailAttribute, + ldapIdAttribute, + searchPageSize, + searchTimeout, + synchronizationEnabled, + synchronizationInterval, + loginLabel, + loginEnabled, + }: RelayEventMap['ldap-settings-updated']) { + this.telemetry.track('User updated Ldap settings', { + user_id: userId, + loginIdAttribute, + firstNameAttribute, + lastNameAttribute, + emailAttribute, + ldapIdAttribute, + searchPageSize, + searchTimeout, + synchronizationEnabled, + synchronizationInterval, + loginLabel, + loginEnabled, + }); + } + + private ldapLoginSyncFailed({ error }: RelayEventMap['ldap-login-sync-failed']) { + this.telemetry.track('Ldap login sync failed', { error }); + } + + private loginFailedDueToLdapDisabled({ + userId, + }: RelayEventMap['login-failed-due-to-ldap-disabled']) { + this.telemetry.track('User login failed since ldap disabled', { user_ud: userId }); + } + + // #endregion + + // #region Workflow + + private workflowCreated({ + user, + workflow, + publicApi, + projectId, + projectType, + }: RelayEventMap['workflow-created']) { + const { nodeGraph } = TelemetryHelpers.generateNodesGraph(workflow, this.nodeTypes); + + this.telemetry.track('User created workflow', { + user_id: user.id, + workflow_id: workflow.id, + node_graph_string: JSON.stringify(nodeGraph), + public_api: publicApi, + project_id: projectId, + project_type: projectType, + }); + } + + private workflowDeleted({ user, workflowId, publicApi }: RelayEventMap['workflow-deleted']) { + this.telemetry.track('User deleted workflow', { + user_id: user.id, + workflow_id: workflowId, + public_api: publicApi, + }); + } + + private workflowSharingUpdated({ + workflowId, + userIdSharer, + userIdList, + }: RelayEventMap['workflow-sharing-updated']) { + this.telemetry.track('User updated workflow sharing', { + workflow_id: workflowId, + user_id_sharer: userIdSharer, + user_id_list: userIdList, + }); + } + + private async workflowSaved({ user, workflow, publicApi }: RelayEventMap['workflow-saved']) { + const isCloudDeployment = config.getEnv('deployment.type') === 'cloud'; + + const { nodeGraph } = TelemetryHelpers.generateNodesGraph(workflow, this.nodeTypes, { + isCloudDeployment, + }); + + let userRole: 'owner' | 'sharee' | 'member' | undefined = undefined; + const role = await this.sharedWorkflowRepository.findSharingRole(user.id, workflow.id); + if (role) { + userRole = role === 'workflow:owner' ? 'owner' : 'sharee'; + } else { + const workflowOwner = await this.sharedWorkflowRepository.getWorkflowOwningProject( + workflow.id, + ); + + if (workflowOwner) { + const projectRole = await this.projectRelationRepository.findProjectRole({ + userId: user.id, + projectId: workflowOwner.id, + }); + + if (projectRole && projectRole !== 'project:personalOwner') { + userRole = 'member'; + } + } + } + + const notesCount = Object.keys(nodeGraph.notes).length; + const overlappingCount = Object.values(nodeGraph.notes).filter( + (note) => note.overlapping, + ).length; + + this.telemetry.track('User saved workflow', { + user_id: user.id, + workflow_id: workflow.id, + node_graph_string: JSON.stringify(nodeGraph), + notes_count_overlapping: overlappingCount, + notes_count_non_overlapping: notesCount - overlappingCount, + version_cli: N8N_VERSION, + num_tags: workflow.tags?.length ?? 0, + public_api: publicApi, + sharing_role: userRole, + }); + } + + // eslint-disable-next-line complexity + private async workflowPostExecute({ + workflow, + runData, + userId, + }: RelayEventMap['workflow-post-execute']) { + if (!workflow.id) { + return; + } + + if (runData?.status === 'waiting') { + // No need to send telemetry or logs when the workflow hasn't finished yet. + return; + } + + const telemetryProperties: IExecutionTrackProperties = { + workflow_id: workflow.id, + is_manual: false, + version_cli: N8N_VERSION, + success: false, + }; + + if (userId) { + telemetryProperties.user_id = userId; + } + + if (runData?.data.resultData.error?.message?.includes('canceled')) { + runData.status = 'canceled'; + } + + telemetryProperties.success = !!runData?.finished; + + // const executionStatus: ExecutionStatus = runData?.status ?? 'unknown'; + const executionStatus: ExecutionStatus = runData + ? determineFinalExecutionStatus(runData) + : 'unknown'; + + if (runData !== undefined) { + telemetryProperties.execution_mode = runData.mode; + telemetryProperties.is_manual = runData.mode === 'manual'; + + let nodeGraphResult: INodesGraphResult | null = null; + + if (!telemetryProperties.success && runData?.data.resultData.error) { + telemetryProperties.error_message = runData?.data.resultData.error.message; + let errorNodeName = + 'node' in runData?.data.resultData.error + ? runData?.data.resultData.error.node?.name + : undefined; + telemetryProperties.error_node_type = + 'node' in runData?.data.resultData.error + ? runData?.data.resultData.error.node?.type + : undefined; + + if (runData.data.resultData.lastNodeExecuted) { + const lastNode = TelemetryHelpers.getNodeTypeForName( + workflow, + runData.data.resultData.lastNodeExecuted, + ); + + if (lastNode !== undefined) { + telemetryProperties.error_node_type = lastNode.type; + errorNodeName = lastNode.name; + } + } + + if (telemetryProperties.is_manual) { + nodeGraphResult = TelemetryHelpers.generateNodesGraph(workflow, this.nodeTypes); + telemetryProperties.node_graph = nodeGraphResult.nodeGraph; + telemetryProperties.node_graph_string = JSON.stringify(nodeGraphResult.nodeGraph); + + if (errorNodeName) { + telemetryProperties.error_node_id = nodeGraphResult.nameIndices[errorNodeName]; + } + } + } + + if (telemetryProperties.is_manual) { + if (!nodeGraphResult) { + nodeGraphResult = TelemetryHelpers.generateNodesGraph(workflow, this.nodeTypes); + } + + let userRole: 'owner' | 'sharee' | undefined = undefined; + if (userId) { + const role = await this.sharedWorkflowRepository.findSharingRole(userId, workflow.id); + if (role) { + userRole = role === 'workflow:owner' ? 'owner' : 'sharee'; + } + } + + const manualExecEventProperties: ITelemetryTrackProperties = { + user_id: userId, + workflow_id: workflow.id, + status: executionStatus, + executionStatus: runData?.status ?? 'unknown', + error_message: telemetryProperties.error_message as string, + error_node_type: telemetryProperties.error_node_type, + node_graph_string: telemetryProperties.node_graph_string as string, + error_node_id: telemetryProperties.error_node_id as string, + webhook_domain: null, + sharing_role: userRole, + }; + + if (!manualExecEventProperties.node_graph_string) { + nodeGraphResult = TelemetryHelpers.generateNodesGraph(workflow, this.nodeTypes); + manualExecEventProperties.node_graph_string = JSON.stringify(nodeGraphResult.nodeGraph); + } + + if (runData.data.startData?.destinationNode) { + const telemetryPayload = { + ...manualExecEventProperties, + node_type: TelemetryHelpers.getNodeTypeForName( + workflow, + runData.data.startData?.destinationNode, + )?.type, + node_id: nodeGraphResult.nameIndices[runData.data.startData?.destinationNode], + }; + + this.telemetry.track('Manual node exec finished', telemetryPayload); + } else { + nodeGraphResult.webhookNodeNames.forEach((name: string) => { + const execJson = runData.data.resultData.runData[name]?.[0]?.data?.main?.[0]?.[0] + ?.json as { headers?: { origin?: string } }; + if (execJson?.headers?.origin && execJson.headers.origin !== '') { + manualExecEventProperties.webhook_domain = pslGet( + execJson.headers.origin.replace(/^https?:\/\//, ''), + ); + } + }); + + this.telemetry.track('Manual workflow exec finished', manualExecEventProperties); + } + } + } + + this.telemetry.trackWorkflowExecution(telemetryProperties); + } + + // #endregion + + // #region Lifecycle + + private async serverStarted() { + const cpus = os.cpus(); + const binaryDataConfig = config.getEnv('binaryDataManager'); + + const isS3Selected = config.getEnv('binaryDataManager.mode') === 's3'; + const isS3Available = config.getEnv('binaryDataManager.availableModes').includes('s3'); + const isS3Licensed = this.license.isBinaryDataS3Licensed(); + const authenticationMethod = config.getEnv('userManagement.authenticationMethod'); + + const info = { + version_cli: N8N_VERSION, + db_type: this.globalConfig.database.type, + n8n_version_notifications_enabled: this.globalConfig.versionNotifications.enabled, + n8n_disable_production_main_process: + this.globalConfig.endpoints.disableProductionWebhooksOnMainProcess, + system_info: { + os: { + type: os.type(), + version: os.version(), + }, + memory: os.totalmem() / 1024, + cpus: { + count: cpus.length, + model: cpus[0].model, + speed: cpus[0].speed, + }, + }, + execution_variables: { + executions_mode: config.getEnv('executions.mode'), + executions_timeout: config.getEnv('executions.timeout'), + executions_timeout_max: config.getEnv('executions.maxTimeout'), + executions_data_save_on_error: config.getEnv('executions.saveDataOnError'), + executions_data_save_on_success: config.getEnv('executions.saveDataOnSuccess'), + executions_data_save_on_progress: config.getEnv('executions.saveExecutionProgress'), + executions_data_save_manual_executions: config.getEnv( + 'executions.saveDataManualExecutions', + ), + executions_data_prune: config.getEnv('executions.pruneData'), + executions_data_max_age: config.getEnv('executions.pruneDataMaxAge'), + }, + n8n_deployment_type: config.getEnv('deployment.type'), + n8n_binary_data_mode: binaryDataConfig.mode, + smtp_set_up: this.globalConfig.userManagement.emails.mode === 'smtp', + ldap_allowed: authenticationMethod === 'ldap', + saml_enabled: authenticationMethod === 'saml', + license_plan_name: this.license.getPlanName(), + license_tenant_id: config.getEnv('license.tenantId'), + binary_data_s3: isS3Available && isS3Selected && isS3Licensed, + multi_main_setup_enabled: config.getEnv('multiMainSetup.enabled'), + }; + + const firstWorkflow = await this.workflowRepository.findOne({ + select: ['createdAt'], + order: { createdAt: 'ASC' }, + where: {}, + }); + + this.telemetry.identify(info); + this.telemetry.track('Instance started', { + ...info, + earliest_workflow_created: firstWorkflow?.createdAt, + }); + } + + private sessionStarted({ pushRef }: RelayEventMap['session-started']) { + this.telemetry.track('Session started', { session_id: pushRef }); + } + + private instanceStopped() { + this.telemetry.track('User instance stopped'); + } + + private async instanceOwnerSetup({ userId }: RelayEventMap['instance-owner-setup']) { + this.telemetry.track('Owner finished instance setup', { user_id: userId }); + } + + private firstProductionWorkflowSucceeded({ + projectId, + workflowId, + userId, + }: RelayEventMap['first-production-workflow-succeeded']) { + this.telemetry.track('Workflow first prod success', { + project_id: projectId, + workflow_id: workflowId, + user_id: userId, + }); + } + + private firstWorkflowDataLoaded({ + userId, + workflowId, + nodeType, + nodeId, + credentialType, + credentialId, + }: RelayEventMap['first-workflow-data-loaded']) { + this.telemetry.track('Workflow first data fetched', { + user_id: userId, + workflow_id: workflowId, + node_type: nodeType, + node_id: nodeId, + credential_type: credentialType, + credential_id: credentialId, + }); + } + + // #endregion + + // #region User + + private userChangedRole({ + userId, + targetUserId, + targetUserNewRole, + publicApi, + }: RelayEventMap['user-changed-role']) { + this.telemetry.track('User changed role', { + user_id: userId, + target_user_id: targetUserId, + target_user_new_role: targetUserNewRole, + public_api: publicApi, + }); + } + + private userRetrievedUser({ userId, publicApi }: RelayEventMap['user-retrieved-user']) { + this.telemetry.track('User retrieved user', { + user_id: userId, + public_api: publicApi, + }); + } + + private userRetrievedAllUsers({ userId, publicApi }: RelayEventMap['user-retrieved-all-users']) { + this.telemetry.track('User retrieved all users', { + user_id: userId, + public_api: publicApi, + }); + } + + private userRetrievedExecution({ userId, publicApi }: RelayEventMap['user-retrieved-execution']) { + this.telemetry.track('User retrieved execution', { + user_id: userId, + public_api: publicApi, + }); + } + + private userRetrievedAllExecutions({ + userId, + publicApi, + }: RelayEventMap['user-retrieved-all-executions']) { + this.telemetry.track('User retrieved all executions', { + user_id: userId, + public_api: publicApi, + }); + } + + private userRetrievedWorkflow({ userId, publicApi }: RelayEventMap['user-retrieved-workflow']) { + this.telemetry.track('User retrieved workflow', { + user_id: userId, + public_api: publicApi, + }); + } + + private userRetrievedAllWorkflows({ + userId, + publicApi, + }: RelayEventMap['user-retrieved-all-workflows']) { + this.telemetry.track('User retrieved all workflows', { + user_id: userId, + public_api: publicApi, + }); + } + + private userUpdated({ user, fieldsChanged }: RelayEventMap['user-updated']) { + this.telemetry.track('User changed personal settings', { + user_id: user.id, + fields_changed: fieldsChanged, + }); + } + + private userDeleted({ + user, + publicApi, + targetUserOldStatus, + migrationStrategy, + targetUserId, + migrationUserId, + }: RelayEventMap['user-deleted']) { + this.telemetry.track('User deleted user', { + user_id: user.id, + public_api: publicApi, + target_user_old_status: targetUserOldStatus, + migration_strategy: migrationStrategy, + target_user_id: targetUserId, + migration_user_id: migrationUserId, + }); + } + + private userInvited({ + user, + targetUserId, + publicApi, + emailSent, + inviteeRole, + }: RelayEventMap['user-invited']) { + this.telemetry.track('User invited new user', { + user_id: user.id, + target_user_id: targetUserId, + public_api: publicApi, + email_sent: emailSent, + invitee_role: inviteeRole, + }); + } + + private userSignedUp({ user, userType, wasDisabledLdapUser }: RelayEventMap['user-signed-up']) { + this.telemetry.track('User signed up', { + user_id: user.id, + user_type: userType, + was_disabled_ldap_user: wasDisabledLdapUser, + }); + } + + private userSubmittedPersonalizationSurvey({ + userId, + answers, + }: RelayEventMap['user-submitted-personalization-survey']) { + const camelCaseKeys = Object.keys(answers); + const personalizationSurveyData = { user_id: userId } as Record; + camelCaseKeys.forEach((camelCaseKey) => { + personalizationSurveyData[snakeCase(camelCaseKey)] = answers[camelCaseKey]; + }); + + this.telemetry.track('User responded to personalization questions', personalizationSurveyData); + } + + // #endregion + + // #region Email + + private emailFailed({ user, messageType, publicApi }: RelayEventMap['email-failed']) { + this.telemetry.track('Instance failed to send transactional email to user', { + user_id: user.id, + message_type: messageType, + public_api: publicApi, + }); + } + + private userTransactionalEmailSent({ + userId, + messageType, + publicApi, + }: RelayEventMap['user-transactional-email-sent']) { + this.telemetry.track('User sent transactional email', { + user_id: userId, + message_type: messageType, + public_api: publicApi, + }); + } + + // #endregion + + // #region Click + + private userInviteEmailClick({ invitee }: RelayEventMap['user-invite-email-click']) { + this.telemetry.track('User clicked invite link from email', { + user_id: invitee.id, + }); + } + + private userPasswordResetEmailClick({ user }: RelayEventMap['user-password-reset-email-click']) { + this.telemetry.track('User clicked password reset link from email', { + user_id: user.id, + }); + } + + private userPasswordResetRequestClick({ + user, + }: RelayEventMap['user-password-reset-request-click']) { + this.telemetry.track('User requested password reset while logged out', { + user_id: user.id, + }); + } + + // #endregion +} diff --git a/packages/cli/test/unit/execution-lifecyle/restoreBinaryDataId.test.ts b/packages/cli/src/executionLifecycleHooks/__tests__/restoreBinaryDataId.test.ts similarity index 97% rename from packages/cli/test/unit/execution-lifecyle/restoreBinaryDataId.test.ts rename to packages/cli/src/executionLifecycleHooks/__tests__/restoreBinaryDataId.test.ts index 3fcdb79c72c7e..ea962882bd782 100644 --- a/packages/cli/test/unit/execution-lifecyle/restoreBinaryDataId.test.ts +++ b/packages/cli/src/executionLifecycleHooks/__tests__/restoreBinaryDataId.test.ts @@ -1,6 +1,6 @@ import { restoreBinaryDataId } from '@/executionLifecycleHooks/restoreBinaryDataId'; import { BinaryDataService } from 'n8n-core'; -import { mockInstance } from '../../shared/mocking'; +import { mockInstance } from '@test/mocking'; import type { IRun } from 'n8n-workflow'; import config from '@/config'; @@ -24,7 +24,7 @@ function toIRun(item?: object) { } function getDataId(run: IRun, kind: 'binary' | 'json') { - // @ts-ignore + // @ts-expect-error The type doesn't have the correct structure return run.data.resultData.runData.myNode[0].data.main[0][0][kind].data.id; } diff --git a/packages/cli/test/unit/execution-lifecyle/saveExecutionProgress.test.ts b/packages/cli/src/executionLifecycleHooks/__tests__/saveExecutionProgress.test.ts similarity index 98% rename from packages/cli/test/unit/execution-lifecyle/saveExecutionProgress.test.ts rename to packages/cli/src/executionLifecycleHooks/__tests__/saveExecutionProgress.test.ts index 4235e56ecbad3..9b1faa7f60540 100644 --- a/packages/cli/test/unit/execution-lifecyle/saveExecutionProgress.test.ts +++ b/packages/cli/src/executionLifecycleHooks/__tests__/saveExecutionProgress.test.ts @@ -1,5 +1,5 @@ import { ExecutionRepository } from '@/databases/repositories/execution.repository'; -import { mockInstance } from '../../shared/mocking'; +import { mockInstance } from '@test/mocking'; import { Logger } from '@/Logger'; import { saveExecutionProgress } from '@/executionLifecycleHooks/saveExecutionProgress'; import * as fnModule from '@/executionLifecycleHooks/toSaveSettings'; diff --git a/packages/cli/test/unit/execution-lifecyle/toSaveSettings.test.ts b/packages/cli/src/executionLifecycleHooks/__tests__/toSaveSettings.test.ts similarity index 98% rename from packages/cli/test/unit/execution-lifecyle/toSaveSettings.test.ts rename to packages/cli/src/executionLifecycleHooks/__tests__/toSaveSettings.test.ts index 6fc516a0ea214..57379e0e73055 100644 --- a/packages/cli/test/unit/execution-lifecyle/toSaveSettings.test.ts +++ b/packages/cli/src/executionLifecycleHooks/__tests__/toSaveSettings.test.ts @@ -35,7 +35,7 @@ describe('failed production executions', () => { }); }); -describe('sucessful production executions', () => { +describe('successful production executions', () => { it('should favor workflow settings over defaults', () => { config.set('executions.saveDataOnSuccess', 'none'); diff --git a/packages/cli/src/executions/__tests__/execution-recovery.service.test.ts b/packages/cli/src/executions/__tests__/execution-recovery.service.test.ts index 7e33c6ac74a8a..f72c81a3cad53 100644 --- a/packages/cli/src/executions/__tests__/execution-recovery.service.test.ts +++ b/packages/cli/src/executions/__tests__/execution-recovery.service.test.ts @@ -1,6 +1,7 @@ import Container from 'typedi'; import { stringify } from 'flatted'; import { randomInt } from 'n8n-workflow'; +import { InstanceSettings } from 'n8n-core'; import { mockInstance } from '@test/mocking'; import { createWorkflow } from '@test-integration/db/workflows'; @@ -8,11 +9,8 @@ import { createExecution } from '@test-integration/db/executions'; import * as testDb from '@test-integration/testDb'; import { mock } from 'jest-mock-extended'; -import { OrchestrationService } from '@/services/orchestration.service'; -import config from '@/config'; import { ExecutionRecoveryService } from '@/executions/execution-recovery.service'; import { ExecutionRepository } from '@/databases/repositories/execution.repository'; -import { InternalHooks } from '@/InternalHooks'; import { Push } from '@/push'; import { ARTIFICIAL_TASK_DATA } from '@/constants'; import { NodeCrashedError } from '@/errors/node-crashed.error'; @@ -21,116 +19,48 @@ import { EventMessageNode } from '@/eventbus/EventMessageClasses/EventMessageNod import { IN_PROGRESS_EXECUTION_DATA, OOM_WORKFLOW } from './constants'; import { setupMessages } from './utils'; -import type { EventService } from '@/eventbus/event.service'; import type { EventMessageTypes as EventMessage } from '@/eventbus/EventMessageClasses'; -import type { Logger } from '@/Logger'; describe('ExecutionRecoveryService', () => { - let push: Push; + const push = mockInstance(Push); + const instanceSettings = new InstanceSettings(); + let executionRecoveryService: ExecutionRecoveryService; - let orchestrationService: OrchestrationService; let executionRepository: ExecutionRepository; beforeAll(async () => { await testDb.init(); - push = mockInstance(Push); executionRepository = Container.get(ExecutionRepository); - orchestrationService = Container.get(OrchestrationService); - mockInstance(InternalHooks); executionRecoveryService = new ExecutionRecoveryService( - mock(), + mock(), + instanceSettings, push, executionRepository, - orchestrationService, - mock(), + mock(), ); }); beforeEach(() => { - config.set('instanceRole', 'leader'); + instanceSettings.markAsLeader(); }); afterEach(async () => { - config.load(config.default); jest.restoreAllMocks(); await testDb.truncate(['Execution', 'ExecutionData', 'Workflow']); - executionRecoveryService.shutdown(); }); afterAll(async () => { await testDb.terminate(); }); - describe('scheduleQueueRecovery', () => { - describe('queue mode', () => { - it('if leader, should schedule queue recovery', () => { - /** - * Arrange - */ - config.set('executions.mode', 'queue'); - jest.spyOn(orchestrationService, 'isLeader', 'get').mockReturnValue(true); - const scheduleSpy = jest.spyOn(executionRecoveryService, 'scheduleQueueRecovery'); - - /** - * Act - */ - executionRecoveryService.init(); - - /** - * Assert - */ - expect(scheduleSpy).toHaveBeenCalled(); - }); - - it('if follower, should do nothing', () => { - /** - * Arrange - */ - config.set('executions.mode', 'queue'); - jest.spyOn(orchestrationService, 'isLeader', 'get').mockReturnValue(false); - const scheduleSpy = jest.spyOn(executionRecoveryService, 'scheduleQueueRecovery'); - - /** - * Act - */ - executionRecoveryService.init(); - - /** - * Assert - */ - expect(scheduleSpy).not.toHaveBeenCalled(); - }); - }); - - describe('regular mode', () => { - it('should do nothing', () => { - /** - * Arrange - */ - config.set('executions.mode', 'regular'); - const scheduleSpy = jest.spyOn(executionRecoveryService, 'scheduleQueueRecovery'); - - /** - * Act - */ - executionRecoveryService.init(); - - /** - * Assert - */ - expect(scheduleSpy).not.toHaveBeenCalled(); - }); - }); - }); - describe('recoverFromLogs', () => { describe('if follower', () => { test('should do nothing', async () => { /** * Arrange */ - config.set('instanceRole', 'follower'); + instanceSettings.markAsFollower(); // @ts-expect-error Private method const amendSpy = jest.spyOn(executionRecoveryService, 'amend'); const messages = setupMessages('123', 'Some workflow'); diff --git a/packages/cli/src/executions/__tests__/execution.service.test.ts b/packages/cli/src/executions/__tests__/execution.service.test.ts index 567e0c9758a6f..28c76e1c58faf 100644 --- a/packages/cli/src/executions/__tests__/execution.service.test.ts +++ b/packages/cli/src/executions/__tests__/execution.service.test.ts @@ -6,14 +6,16 @@ import { AbortedExecutionRetryError } from '@/errors/aborted-execution-retry.err import { MissingExecutionStopError } from '@/errors/missing-execution-stop.error'; import type { ActiveExecutions } from '@/ActiveExecutions'; import type { IExecutionResponse } from '@/Interfaces'; -import type { Job, Queue } from '@/Queue'; +import { ScalingService } from '@/scaling/scaling.service'; import type { WaitTracker } from '@/WaitTracker'; import type { ExecutionRepository } from '@/databases/repositories/execution.repository'; import type { ExecutionRequest } from '@/executions/execution.types'; import type { ConcurrencyControlService } from '@/concurrency/concurrency-control.service'; +import type { Job } from '@/scaling/types'; +import { mockInstance } from '@test/mocking'; describe('ExecutionService', () => { - const queue = mock(); + const scalingService = mockInstance(ScalingService); const activeExecutions = mock(); const executionRepository = mock(); const waitTracker = mock(); @@ -22,7 +24,6 @@ describe('ExecutionService', () => { const executionService = new ExecutionService( mock(), mock(), - queue, activeExecutions, executionRepository, mock(), @@ -31,6 +32,7 @@ describe('ExecutionService', () => { mock(), concurrencyControl, mock(), + mock(), ); beforeEach(() => { @@ -210,7 +212,7 @@ describe('ExecutionService', () => { expect(concurrencyControl.remove).not.toHaveBeenCalled(); expect(waitTracker.stopExecution).not.toHaveBeenCalled(); - expect(queue.stopJob).not.toHaveBeenCalled(); + expect(scalingService.stopJob).not.toHaveBeenCalled(); }); }); @@ -223,7 +225,8 @@ describe('ExecutionService', () => { const execution = mock({ id: '123', status: 'running' }); executionRepository.findSingleExecution.mockResolvedValue(execution); waitTracker.has.mockReturnValue(false); - queue.findRunningJobBy.mockResolvedValue(mock()); + const job = mock({ data: { executionId: '123' } }); + scalingService.findJobsByStatus.mockResolvedValue([job]); executionRepository.stopDuringRun.mockResolvedValue(mock()); /** @@ -236,8 +239,8 @@ describe('ExecutionService', () => { */ expect(waitTracker.stopExecution).not.toHaveBeenCalled(); expect(activeExecutions.stopExecution).toHaveBeenCalled(); - expect(queue.findRunningJobBy).toBeCalledWith({ executionId: execution.id }); - expect(queue.stopJob).toHaveBeenCalled(); + expect(scalingService.findJobsByStatus).toHaveBeenCalled(); + expect(scalingService.stopJob).toHaveBeenCalled(); expect(executionRepository.stopDuringRun).toHaveBeenCalled(); }); @@ -249,7 +252,8 @@ describe('ExecutionService', () => { const execution = mock({ id: '123', status: 'waiting' }); executionRepository.findSingleExecution.mockResolvedValue(execution); waitTracker.has.mockReturnValue(true); - queue.findRunningJobBy.mockResolvedValue(mock()); + const job = mock({ data: { executionId: '123' } }); + scalingService.findJobsByStatus.mockResolvedValue([job]); executionRepository.stopDuringRun.mockResolvedValue(mock()); /** @@ -261,9 +265,8 @@ describe('ExecutionService', () => { * Assert */ expect(waitTracker.stopExecution).toHaveBeenCalledWith(execution.id); - expect(activeExecutions.stopExecution).toHaveBeenCalled(); - expect(queue.findRunningJobBy).toBeCalledWith({ executionId: execution.id }); - expect(queue.stopJob).toHaveBeenCalled(); + expect(scalingService.findJobsByStatus).toHaveBeenCalled(); + expect(scalingService.stopJob).toHaveBeenCalled(); expect(executionRepository.stopDuringRun).toHaveBeenCalled(); }); }); diff --git a/packages/cli/test/unit/controllers/executions.controller.test.ts b/packages/cli/src/executions/__tests__/executions.controller.test.ts similarity index 98% rename from packages/cli/test/unit/controllers/executions.controller.test.ts rename to packages/cli/src/executions/__tests__/executions.controller.test.ts index 2a4c733c5ab3c..decb88d59854e 100644 --- a/packages/cli/test/unit/controllers/executions.controller.test.ts +++ b/packages/cli/src/executions/__tests__/executions.controller.test.ts @@ -74,6 +74,8 @@ describe('ExecutionsController', () => { }, ]; + executionService.findRangeWithCount.mockResolvedValue(NO_EXECUTIONS); + describe('if either status or range provided', () => { test.each(QUERIES_WITH_EITHER_STATUS_OR_RANGE)( 'should fetch executions per query', diff --git a/packages/cli/test/unit/middleware/executions/parse-range-query.middleware.test.ts b/packages/cli/src/executions/__tests__/parse-range-query.middleware.test.ts similarity index 100% rename from packages/cli/test/unit/middleware/executions/parse-range-query.middleware.test.ts rename to packages/cli/src/executions/__tests__/parse-range-query.middleware.test.ts diff --git a/packages/cli/src/executions/execution-recovery.service.ts b/packages/cli/src/executions/execution-recovery.service.ts index b72fc490dddbb..8e29127737792 100644 --- a/packages/cli/src/executions/execution-recovery.service.ts +++ b/packages/cli/src/executions/execution-recovery.service.ts @@ -1,22 +1,18 @@ -import Container, { Service } from 'typedi'; +import { Service } from 'typedi'; import { Push } from '@/push'; -import { jsonStringify, sleep } from 'n8n-workflow'; +import { sleep } from 'n8n-workflow'; import { ExecutionRepository } from '@db/repositories/execution.repository'; import { getWorkflowHooksMain } from '@/WorkflowExecuteAdditionalData'; // @TODO: Dependency cycle -import { InternalHooks } from '@/InternalHooks'; // @TODO: Dependency cycle if injected import type { DateTime } from 'luxon'; import type { IRun, ITaskData } from 'n8n-workflow'; +import { InstanceSettings } from 'n8n-core'; import type { EventMessageTypes } from '../eventbus/EventMessageClasses'; import type { IExecutionResponse } from '@/Interfaces'; import { NodeCrashedError } from '@/errors/node-crashed.error'; import { WorkflowCrashedError } from '@/errors/workflow-crashed.error'; import { ARTIFICIAL_TASK_DATA } from '@/constants'; import { Logger } from '@/Logger'; -import config from '@/config'; -import { OnShutdown } from '@/decorators/OnShutdown'; -import type { QueueRecoverySettings } from './execution.types'; -import { OrchestrationService } from '@/services/orchestration.service'; -import { EventService } from '@/eventbus/event.service'; +import { EventService } from '@/events/event.service'; /** * Service for recovering key properties in executions. @@ -25,41 +21,17 @@ import { EventService } from '@/eventbus/event.service'; export class ExecutionRecoveryService { constructor( private readonly logger: Logger, + private readonly instanceSettings: InstanceSettings, private readonly push: Push, private readonly executionRepository: ExecutionRepository, - private readonly orchestrationService: OrchestrationService, private readonly eventService: EventService, ) {} - /** - * @important Requires `OrchestrationService` to be initialized on queue mode. - */ - init() { - if (config.getEnv('executions.mode') === 'regular') return; - - const { isLeader, isMultiMainSetupEnabled } = this.orchestrationService; - - if (isLeader) this.scheduleQueueRecovery(); - - if (isMultiMainSetupEnabled) { - this.orchestrationService.multiMainSetup - .on('leader-takeover', () => this.scheduleQueueRecovery()) - .on('leader-stepdown', () => this.stopQueueRecovery()); - } - } - - private readonly queueRecoverySettings: QueueRecoverySettings = { - batchSize: config.getEnv('executions.queueRecovery.batchSize'), - waitMs: config.getEnv('executions.queueRecovery.interval') * 60 * 1000, - }; - - private isShuttingDown = false; - /** * Recover key properties of a truncated execution using event logs. */ async recoverFromLogs(executionId: string, messages: EventMessageTypes[]) { - if (this.orchestrationService.isFollower) return; + if (this.instanceSettings.isFollower) return; const amendedExecution = await this.amend(executionId, messages); @@ -81,87 +53,10 @@ export class ExecutionRecoveryService { return amendedExecution; } - /** - * Schedule a cycle to mark dangling executions as crashed in queue mode. - */ - scheduleQueueRecovery(waitMs = this.queueRecoverySettings.waitMs) { - if (!this.shouldScheduleQueueRecovery()) return; - - this.queueRecoverySettings.timeout = setTimeout(async () => { - try { - const nextWaitMs = await this.recoverFromQueue(); - this.scheduleQueueRecovery(nextWaitMs); - } catch (error) { - const msg = this.toErrorMsg(error); - - this.logger.error('[Recovery] Failed to recover dangling executions from queue', { msg }); - this.logger.error('[Recovery] Retrying...'); - - this.scheduleQueueRecovery(); - } - }, waitMs); - - const wait = [this.queueRecoverySettings.waitMs / (60 * 1000), 'min'].join(' '); - - this.logger.debug(`[Recovery] Scheduled queue recovery check for next ${wait}`); - } - - stopQueueRecovery() { - clearTimeout(this.queueRecoverySettings.timeout); - } - - @OnShutdown() - shutdown() { - this.isShuttingDown = true; - this.stopQueueRecovery(); - } - // ---------------------------------- // private // ---------------------------------- - /** - * Mark in-progress executions as `crashed` if stored in DB as `new` or `running` - * but absent from the queue. Return time until next recovery cycle. - */ - private async recoverFromQueue() { - const { waitMs, batchSize } = this.queueRecoverySettings; - - const storedIds = await this.executionRepository.getInProgressExecutionIds(batchSize); - - if (storedIds.length === 0) { - this.logger.debug('[Recovery] Completed queue recovery check, no dangling executions'); - return waitMs; - } - - const { Queue } = await import('@/Queue'); - - const queuedIds = await Container.get(Queue).getInProgressExecutionIds(); - - if (queuedIds.size === 0) { - this.logger.debug('[Recovery] Completed queue recovery check, no dangling executions'); - return waitMs; - } - - const danglingIds = storedIds.filter((id) => !queuedIds.has(id)); - - if (danglingIds.length === 0) { - this.logger.debug('[Recovery] Completed queue recovery check, no dangling executions'); - return waitMs; - } - - await this.executionRepository.markAsCrashed(danglingIds); - - this.logger.info('[Recovery] Completed queue recovery check, recovered dangling executions', { - danglingIds, - }); - - // if this cycle used up the whole batch size, it is possible for there to be - // dangling executions outside this check, so speed up next cycle - - return storedIds.length >= this.queueRecoverySettings.batchSize ? waitMs / 2 : waitMs; - } - /** * Amend `status`, `stoppedAt`, and (if possible) `data` of an execution using event logs. */ @@ -280,22 +175,9 @@ export class ExecutionRecoveryService { private async runHooks(execution: IExecutionResponse) { execution.data ??= { resultData: { runData: {} } }; - await Container.get(InternalHooks).onWorkflowPostExecute(execution.id, execution.workflowData, { - data: execution.data, - finished: false, - mode: execution.mode, - waitTill: execution.waitTill, - startedAt: execution.startedAt, - stoppedAt: execution.stoppedAt, - status: execution.status, - }); - this.eventService.emit('workflow-post-execute', { - workflowId: execution.workflowData.id, - workflowName: execution.workflowData.name, + workflow: execution.workflowData, executionId: execution.id, - success: execution.status === 'success', - isManual: execution.mode === 'manual', runData: execution, }); @@ -323,18 +205,4 @@ export class ExecutionRecoveryService { await externalHooks.executeHookFunctions('workflowExecuteAfter', [run]); } - - private toErrorMsg(error: unknown) { - return error instanceof Error - ? error.message - : jsonStringify(error, { replaceCircularRefs: true }); - } - - private shouldScheduleQueueRecovery() { - return ( - config.getEnv('executions.mode') === 'queue' && - config.getEnv('instanceRole') === 'leader' && - !this.isShuttingDown - ); - } } diff --git a/packages/cli/src/executions/execution.service.ts b/packages/cli/src/executions/execution.service.ts index bb8650e99f1d6..b4e8b335739be 100644 --- a/packages/cli/src/executions/execution.service.ts +++ b/packages/cli/src/executions/execution.service.ts @@ -1,4 +1,4 @@ -import { Service } from 'typedi'; +import { Container, Service } from 'typedi'; import { GlobalConfig } from '@n8n/config'; import { validate as jsonSchemaValidate } from 'jsonschema'; import type { @@ -24,7 +24,6 @@ import type { IWorkflowExecutionDataProcess, } from '@/Interfaces'; import { NodeTypes } from '@/NodeTypes'; -import { Queue } from '@/Queue'; import type { ExecutionRequest, ExecutionSummaries, StopResult } from './execution.types'; import { WorkflowRunner } from '@/WorkflowRunner'; import type { IGetExecutionsQueryFilter } from '@db/repositories/execution.repository'; @@ -40,6 +39,8 @@ import { QueuedExecutionRetryError } from '@/errors/queued-execution-retry.error import { ConcurrencyControlService } from '@/concurrency/concurrency-control.service'; import { AbortedExecutionRetryError } from '@/errors/aborted-execution-retry.error'; import { License } from '@/License'; +import type { User } from '@/databases/entities/User'; +import { WorkflowSharingService } from '@/workflows/workflowSharing.service'; export const schemaGetExecutionsQueryFilter = { $id: '/IGetExecutionsQueryFilter', @@ -83,7 +84,6 @@ export class ExecutionService { constructor( private readonly globalConfig: GlobalConfig, private readonly logger: Logger, - private readonly queue: Queue, private readonly activeExecutions: ActiveExecutions, private readonly executionRepository: ExecutionRepository, private readonly workflowRepository: WorkflowRepository, @@ -92,6 +92,7 @@ export class ExecutionService { private readonly workflowRunner: WorkflowRunner, private readonly concurrencyControl: ConcurrencyControlService, private readonly license: License, + private readonly workflowSharingService: WorkflowSharingService, ) {} async findOne( @@ -468,14 +469,30 @@ export class ExecutionService { this.waitTracker.stopExecution(execution.id); } - const job = await this.queue.findRunningJobBy({ executionId: execution.id }); + const { ScalingService } = await import('@/scaling/scaling.service'); + const scalingService = Container.get(ScalingService); + const jobs = await scalingService.findJobsByStatus(['active', 'waiting']); + + const job = jobs.find(({ data }) => data.executionId === execution.id); if (job) { - await this.queue.stopJob(job); + await scalingService.stopJob(job); } else { this.logger.debug('Job to stop not in queue', { executionId: execution.id }); } return await this.executionRepository.stopDuringRun(execution); } + + async addScopes(user: User, summaries: ExecutionSummaries.ExecutionSummaryWithScopes[]) { + const workflowIds = [...new Set(summaries.map((s) => s.workflowId))]; + + const scopes = Object.fromEntries( + await this.workflowSharingService.getSharedWorkflowScopes(workflowIds, user), + ); + + for (const s of summaries) { + s.scopes = scopes[s.workflowId] ?? []; + } + } } diff --git a/packages/cli/src/executions/execution.types.ts b/packages/cli/src/executions/execution.types.ts index 7e8872bf1b305..15c27261fc1a3 100644 --- a/packages/cli/src/executions/execution.types.ts +++ b/packages/cli/src/executions/execution.types.ts @@ -1,6 +1,12 @@ import type { ExecutionEntity } from '@/databases/entities/ExecutionEntity'; import type { AuthenticatedRequest } from '@/requests'; -import type { ExecutionStatus, IDataObject, WorkflowExecuteMode } from 'n8n-workflow'; +import type { Scope } from '@n8n/permissions'; +import type { + ExecutionStatus, + ExecutionSummary, + IDataObject, + WorkflowExecuteMode, +} from 'n8n-workflow'; export declare namespace ExecutionRequest { namespace QueryParams { @@ -83,24 +89,9 @@ export namespace ExecutionSummaries { stoppedAt?: 'DESC'; }; }; -} -export type QueueRecoverySettings = { - /** - * ID of timeout for next scheduled recovery cycle. - */ - timeout?: NodeJS.Timeout; - - /** - * Number of in-progress executions to check per cycle. - */ - batchSize: number; - - /** - * Time (in milliseconds) to wait before the next cycle. - */ - waitMs: number; -}; + export type ExecutionSummaryWithScopes = ExecutionSummary & { scopes: Scope[] }; +} export type StopResult = { mode: WorkflowExecuteMode; diff --git a/packages/cli/src/executions/executions.controller.ts b/packages/cli/src/executions/executions.controller.ts index 64b6a5427429f..c68c8cb7d5c48 100644 --- a/packages/cli/src/executions/executions.controller.ts +++ b/packages/cli/src/executions/executions.controller.ts @@ -1,4 +1,4 @@ -import { ExecutionRequest } from './execution.types'; +import { ExecutionRequest, type ExecutionSummaries } from './execution.types'; import { ExecutionService } from './execution.service'; import { Get, Post, RestController } from '@/decorators'; import { EnterpriseExecutionsService } from './execution.service.ee'; @@ -53,10 +53,20 @@ export class ExecutionsController { const noRange = !query.range.lastId || !query.range.firstId; if (noStatus && noRange) { - return await this.executionService.findLatestCurrentAndCompleted(query); + const executions = await this.executionService.findLatestCurrentAndCompleted(query); + await this.executionService.addScopes( + req.user, + executions.results as ExecutionSummaries.ExecutionSummaryWithScopes[], + ); + return executions; } - return await this.executionService.findRangeWithCount(query); + const executions = await this.executionService.findRangeWithCount(query); + await this.executionService.addScopes( + req.user, + executions.results as ExecutionSummaries.ExecutionSummaryWithScopes[], + ); + return executions; } @Get('/:id') diff --git a/packages/cli/test/unit/license/license.service.test.ts b/packages/cli/src/license/__tests__/license.service.test.ts similarity index 97% rename from packages/cli/test/unit/license/license.service.test.ts rename to packages/cli/src/license/__tests__/license.service.test.ts index e28895025ffbe..fb75c6a27d692 100644 --- a/packages/cli/test/unit/license/license.service.test.ts +++ b/packages/cli/src/license/__tests__/license.service.test.ts @@ -1,6 +1,6 @@ import { LicenseErrors, LicenseService } from '@/license/license.service'; import type { License } from '@/License'; -import type { EventService } from '@/eventbus/event.service'; +import type { EventService } from '@/events/event.service'; import type { WorkflowRepository } from '@db/repositories/workflow.repository'; import type { TEntitlement } from '@n8n_io/license-sdk'; import { mock } from 'jest-mock-extended'; diff --git a/packages/cli/src/license/license.controller.ts b/packages/cli/src/license/license.controller.ts index 1c0ca8c0d68ed..945f3650ea340 100644 --- a/packages/cli/src/license/license.controller.ts +++ b/packages/cli/src/license/license.controller.ts @@ -1,6 +1,8 @@ import { Get, Post, RestController, GlobalScope } from '@/decorators'; import { AuthenticatedRequest, LicenseRequest } from '@/requests'; import { LicenseService } from './license.service'; +import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import type { AxiosError } from 'axios'; @RestController('/license') export class LicenseController { @@ -14,7 +16,18 @@ export class LicenseController { @Post('/enterprise/request_trial') @GlobalScope('license:manage') async requestEnterpriseTrial(req: AuthenticatedRequest) { - await this.licenseService.requestEnterpriseTrial(req.user); + try { + await this.licenseService.requestEnterpriseTrial(req.user); + } catch (error: unknown) { + if (error instanceof Error) { + const errorMsg = + (error as AxiosError<{ message: string }>).response?.data?.message ?? error.message; + + throw new BadRequestError(errorMsg); + } else { + throw new BadRequestError('Failed to request trial'); + } + } } @Post('/activate') diff --git a/packages/cli/src/license/license.service.ts b/packages/cli/src/license/license.service.ts index 01d2a73c48941..0555597a9d3b3 100644 --- a/packages/cli/src/license/license.service.ts +++ b/packages/cli/src/license/license.service.ts @@ -3,7 +3,7 @@ import axios from 'axios'; import { Logger } from '@/Logger'; import { License } from '@/License'; -import { EventService } from '@/eventbus/event.service'; +import { EventService } from '@/events/event.service'; import type { User } from '@db/entities/User'; import { WorkflowRepository } from '@db/repositories/workflow.repository'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; diff --git a/packages/cli/test/unit/middleware/listQuery.test.ts b/packages/cli/src/middlewares/listQuery/__tests__/listQuery.test.ts similarity index 100% rename from packages/cli/test/unit/middleware/listQuery.test.ts rename to packages/cli/src/middlewares/listQuery/__tests__/listQuery.test.ts diff --git a/packages/cli/src/permissions/project-roles.ts b/packages/cli/src/permissions/project-roles.ts index 96e4dfff8a475..d05507c47c46c 100644 --- a/packages/cli/src/permissions/project-roles.ts +++ b/packages/cli/src/permissions/project-roles.ts @@ -20,6 +20,7 @@ export const REGULAR_PROJECT_ADMIN_SCOPES: Scope[] = [ 'credential:delete', 'credential:list', 'credential:move', + 'credential:share', 'project:list', 'project:read', 'project:update', diff --git a/packages/cli/test/unit/PostHog.test.ts b/packages/cli/src/posthog/__tests__/PostHog.test.ts similarity index 97% rename from packages/cli/test/unit/PostHog.test.ts rename to packages/cli/src/posthog/__tests__/PostHog.test.ts index 5798c0cce2217..f1604f7253df1 100644 --- a/packages/cli/test/unit/PostHog.test.ts +++ b/packages/cli/src/posthog/__tests__/PostHog.test.ts @@ -2,7 +2,7 @@ import { PostHog } from 'posthog-node'; import { InstanceSettings } from 'n8n-core'; import { PostHogClient } from '@/posthog'; import config from '@/config'; -import { mockInstance } from '../shared/mocking'; +import { mockInstance } from '@test/mocking'; jest.mock('posthog-node'); diff --git a/packages/cli/test/unit/push/index.test.ts b/packages/cli/src/push/__tests__/index.test.ts similarity index 96% rename from packages/cli/test/unit/push/index.test.ts rename to packages/cli/src/push/__tests__/index.test.ts index 1736693509374..a61496b0c9248 100644 --- a/packages/cli/test/unit/push/index.test.ts +++ b/packages/cli/src/push/__tests__/index.test.ts @@ -9,7 +9,7 @@ import { WebSocketPush } from '@/push/websocket.push'; import type { WebSocketPushRequest, SSEPushRequest } from '@/push/types'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { mockInstance } from '../../shared/mocking'; +import { mockInstance } from '@test/mocking'; jest.unmock('@/push'); diff --git a/packages/cli/test/unit/push/websocket.push.test.ts b/packages/cli/src/push/__tests__/websocket.push.test.ts similarity index 98% rename from packages/cli/test/unit/push/websocket.push.test.ts rename to packages/cli/src/push/__tests__/websocket.push.test.ts index 7531e43776c2c..f1a0e577f9ad8 100644 --- a/packages/cli/test/unit/push/websocket.push.test.ts +++ b/packages/cli/src/push/__tests__/websocket.push.test.ts @@ -6,7 +6,7 @@ import { WebSocketPush } from '@/push/websocket.push'; import { Logger } from '@/Logger'; import type { PushDataExecutionRecovered } from '@/Interfaces'; -import { mockInstance } from '../../shared/mocking'; +import { mockInstance } from '@test/mocking'; jest.useFakeTimers(); diff --git a/packages/cli/src/requests.ts b/packages/cli/src/requests.ts index 1ea265f2394e7..565020447c91d 100644 --- a/packages/cli/src/requests.ts +++ b/packages/cli/src/requests.ts @@ -224,7 +224,7 @@ export declare namespace MeRequest { export type Password = AuthenticatedRequest< {}, {}, - { currentPassword: string; newPassword: string; token?: string } + { currentPassword: string; newPassword: string; mfaCode?: string } >; export type SurveyAnswers = AuthenticatedRequest<{}, {}, Record | {}>; } @@ -306,7 +306,7 @@ export declare namespace UserRequest { { id: string; email: string; identifier: string }, {}, {}, - { limit?: number; offset?: number; cursor?: string; includeRole?: boolean } + { limit?: number; offset?: number; cursor?: string; includeRole?: boolean; projectId?: string } >; export type PasswordResetLink = AuthenticatedRequest<{ id: string }, {}, {}, {}>; @@ -353,6 +353,7 @@ export type LoginRequest = AuthlessRequest< export declare namespace MFA { type Verify = AuthenticatedRequest<{}, {}, { token: string }, {}>; type Activate = AuthenticatedRequest<{}, {}, { token: string }, {}>; + type Disable = AuthenticatedRequest<{}, {}, { token: string }, {}>; type Config = AuthenticatedRequest<{}, {}, { login: { enabled: boolean } }, {}>; type ValidateRecoveryCode = AuthenticatedRequest< {}, @@ -418,6 +419,12 @@ export declare namespace DynamicNodeParametersRequest { type ResourceMapperFields = BaseRequest<{ methodName: string; }>; + + /** POST /dynamic-node-parameters/action-result */ + type ActionResult = BaseRequest<{ + handler: string; + payload: IDataObject | string | undefined; + }>; } // ---------------------------------- diff --git a/packages/cli/src/scaling/__tests__/scaling.service.test.ts b/packages/cli/src/scaling/__tests__/scaling.service.test.ts new file mode 100644 index 0000000000000..adbf5ebde2f0e --- /dev/null +++ b/packages/cli/src/scaling/__tests__/scaling.service.test.ts @@ -0,0 +1,358 @@ +import { mock } from 'jest-mock-extended'; +import { ScalingService } from '../scaling.service'; +import { JOB_TYPE_NAME, QUEUE_NAME } from '../constants'; +import config from '@/config'; +import * as BullModule from 'bull'; +import type { Job, JobData, JobOptions, JobQueue } from '../types'; +import { ApplicationError } from 'n8n-workflow'; +import { mockInstance } from '@test/mocking'; +import { GlobalConfig } from '@n8n/config'; +import { InstanceSettings } from 'n8n-core'; +import type { OrchestrationService } from '@/services/orchestration.service'; +import Container from 'typedi'; +import type { JobProcessor } from '../job-processor'; + +const queue = mock({ + client: { ping: jest.fn() }, +}); + +jest.mock('bull', () => ({ + __esModule: true, + default: jest.fn(() => queue), +})); + +describe('ScalingService', () => { + const globalConfig = mockInstance(GlobalConfig, { + queue: { + bull: { + prefix: 'bull', + redis: { + clusterNodes: '', + host: 'localhost', + password: '', + port: 6379, + tls: false, + }, + }, + }, + }); + + const instanceSettings = Container.get(InstanceSettings); + const orchestrationService = mock({ isMultiMainSetupEnabled: false }); + const jobProcessor = mock(); + let scalingService: ScalingService; + + beforeEach(() => { + jest.clearAllMocks(); + config.set('generic.instanceType', 'main'); + scalingService = new ScalingService( + mock(), + mock(), + jobProcessor, + globalConfig, + mock(), + instanceSettings, + orchestrationService, + ); + }); + + afterEach(() => { + scalingService.stopQueueRecovery(); + }); + + describe('setupQueue', () => { + it('should set up the queue', async () => { + /** + * Arrange + */ + const { prefix, settings } = globalConfig.queue.bull; + const Bull = jest.mocked(BullModule.default); + + /** + * Act + */ + await scalingService.setupQueue(); + + /** + * Assert + */ + expect(Bull).toHaveBeenCalledWith(QUEUE_NAME, { + prefix, + settings, + createClient: expect.any(Function), + }); + expect(queue.on).toHaveBeenCalledWith('global:progress', expect.any(Function)); + expect(queue.on).toHaveBeenCalledWith('error', expect.any(Function)); + }); + }); + + describe('setupWorker', () => { + it('should set up a worker with concurrency', async () => { + /** + * Arrange + */ + config.set('generic.instanceType', 'worker'); + const scalingService = new ScalingService( + mock(), + mock(), + mock(), + globalConfig, + mock(), + instanceSettings, + orchestrationService, + ); + await scalingService.setupQueue(); + const concurrency = 5; + + /** + * Act + */ + scalingService.setupWorker(concurrency); + + /** + * Assert + */ + expect(queue.process).toHaveBeenCalledWith(JOB_TYPE_NAME, concurrency, expect.any(Function)); + }); + + it('should throw if called on a non-worker instance', async () => { + /** + * Arrange + */ + await scalingService.setupQueue(); + + /** + * Act and Assert + */ + expect(() => scalingService.setupWorker(5)).toThrow(); + }); + }); + + describe('stop', () => { + it('should pause the queue, check for running jobs, and stop queue recovery', async () => { + /** + * Arrange + */ + await scalingService.setupQueue(); + jobProcessor.getRunningJobIds.mockReturnValue([]); + const stopQueueRecoverySpy = jest.spyOn(scalingService, 'stopQueueRecovery'); + const getRunningJobsCountSpy = jest.spyOn(scalingService, 'getRunningJobsCount'); + + /** + * Act + */ + await scalingService.stop(); + + /** + * Assert + */ + expect(queue.pause).toHaveBeenCalledWith(true, true); + expect(stopQueueRecoverySpy).toHaveBeenCalled(); + expect(getRunningJobsCountSpy).toHaveBeenCalled(); + }); + }); + + describe('pingQueue', () => { + it('should ping the queue', async () => { + /** + * Arrange + */ + await scalingService.setupQueue(); + + /** + * Act + */ + await scalingService.pingQueue(); + + /** + * Assert + */ + expect(queue.client.ping).toHaveBeenCalled(); + }); + }); + + describe('addJob', () => { + it('should add a job', async () => { + /** + * Arrange + */ + await scalingService.setupQueue(); + queue.add.mockResolvedValue(mock({ id: '456' })); + + /** + * Act + */ + const jobData = mock({ executionId: '123' }); + const jobOptions = mock(); + await scalingService.addJob(jobData, jobOptions); + + /** + * Assert + */ + expect(queue.add).toHaveBeenCalledWith(JOB_TYPE_NAME, jobData, jobOptions); + }); + }); + + describe('getJob', () => { + it('should get a job', async () => { + /** + * Arrange + */ + await scalingService.setupQueue(); + const jobId = '123'; + queue.getJob.mockResolvedValue(mock({ id: jobId })); + + /** + * Act + */ + const job = await scalingService.getJob(jobId); + + /** + * Assert + */ + expect(queue.getJob).toHaveBeenCalledWith(jobId); + expect(job?.id).toBe(jobId); + }); + }); + + describe('findJobsByStatus', () => { + it('should find jobs by status', async () => { + /** + * Arrange + */ + await scalingService.setupQueue(); + queue.getJobs.mockResolvedValue([mock({ id: '123' })]); + + /** + * Act + */ + const jobs = await scalingService.findJobsByStatus(['active']); + + /** + * Assert + */ + expect(queue.getJobs).toHaveBeenCalledWith(['active']); + expect(jobs).toHaveLength(1); + expect(jobs.at(0)?.id).toBe('123'); + }); + + it('should filter out `null` in Redis response', async () => { + /** + * Arrange + */ + await scalingService.setupQueue(); + // @ts-expect-error - Untyped but possible Redis response + queue.getJobs.mockResolvedValue([mock(), null]); + + /** + * Act + */ + const jobs = await scalingService.findJobsByStatus(['waiting']); + + /** + * Assert + */ + expect(jobs).toHaveLength(1); + }); + }); + + describe('stopJob', () => { + it('should stop an active job', async () => { + /** + * Arrange + */ + await scalingService.setupQueue(); + const job = mock({ isActive: jest.fn().mockResolvedValue(true) }); + + /** + * Act + */ + const result = await scalingService.stopJob(job); + + /** + * Assert + */ + expect(job.progress).toHaveBeenCalledWith({ kind: 'abort-job' }); + expect(result).toBe(true); + }); + + it('should stop an inactive job', async () => { + /** + * Arrange + */ + await scalingService.setupQueue(); + const job = mock({ isActive: jest.fn().mockResolvedValue(false) }); + + /** + * Act + */ + const result = await scalingService.stopJob(job); + + /** + * Assert + */ + expect(job.remove).toHaveBeenCalled(); + expect(result).toBe(true); + }); + + it('should report failure to stop a job', async () => { + /** + * Arrange + */ + await scalingService.setupQueue(); + const job = mock({ + isActive: jest.fn().mockImplementation(() => { + throw new ApplicationError('Something went wrong'); + }), + }); + + /** + * Act + */ + const result = await scalingService.stopJob(job); + + /** + * Assert + */ + expect(result).toBe(false); + }); + }); + + describe('scheduleQueueRecovery', () => { + it('if leader, should schedule queue recovery', async () => { + /** + * Arrange + */ + const scheduleSpy = jest.spyOn(scalingService, 'scheduleQueueRecovery'); + instanceSettings.markAsLeader(); + + /** + * Act + */ + await scalingService.setupQueue(); + + /** + * Assert + */ + expect(scheduleSpy).toHaveBeenCalled(); + }); + + it('if follower, should not schedule queue recovery', async () => { + /** + * Arrange + */ + const scheduleSpy = jest.spyOn(scalingService, 'scheduleQueueRecovery'); + instanceSettings.markAsFollower(); + + /** + * Act + */ + await scalingService.setupQueue(); + + /** + * Assert + */ + expect(scheduleSpy).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/cli/src/scaling/constants.ts b/packages/cli/src/scaling/constants.ts new file mode 100644 index 0000000000000..8ef5f716b17aa --- /dev/null +++ b/packages/cli/src/scaling/constants.ts @@ -0,0 +1,3 @@ +export const QUEUE_NAME = 'jobs'; + +export const JOB_TYPE_NAME = 'job'; diff --git a/packages/cli/src/scaling/job-processor.ts b/packages/cli/src/scaling/job-processor.ts new file mode 100644 index 0000000000000..693c9e3a74fb7 --- /dev/null +++ b/packages/cli/src/scaling/job-processor.ts @@ -0,0 +1,182 @@ +import { Service } from 'typedi'; +import { BINARY_ENCODING, ApplicationError, Workflow } from 'n8n-workflow'; +import { WorkflowExecute } from 'n8n-core'; +import { Logger } from '@/Logger'; +import config from '@/config'; +import { ExecutionRepository } from '@/databases/repositories/execution.repository'; +import { WorkflowRepository } from '@/databases/repositories/workflow.repository'; +import * as WorkflowExecuteAdditionalData from '@/WorkflowExecuteAdditionalData'; +import { NodeTypes } from '@/NodeTypes'; +import type { ExecutionStatus, IExecuteResponsePromiseData, IRun } from 'n8n-workflow'; +import type { Job, JobId, JobResult, RunningJob, RunningJobSummary } from './types'; +import type PCancelable from 'p-cancelable'; + +/** + * Responsible for processing jobs from the queue, i.e. running enqueued executions. + */ +@Service() +export class JobProcessor { + private readonly runningJobs: Record = {}; + + constructor( + private readonly logger: Logger, + private readonly executionRepository: ExecutionRepository, + private readonly workflowRepository: WorkflowRepository, + private readonly nodeTypes: NodeTypes, + ) {} + + async processJob(job: Job): Promise { + const { executionId, loadStaticData } = job.data; + + const execution = await this.executionRepository.findSingleExecution(executionId, { + includeData: true, + unflattenData: true, + }); + + if (!execution) { + this.logger.error('[JobProcessor] Failed to find execution data', { executionId }); + throw new ApplicationError('Failed to find execution data. Aborting execution.', { + extra: { executionId }, + }); + } + + const workflowId = execution.workflowData.id; + + this.logger.info(`[JobProcessor] Starting job ${job.id} (execution ${executionId})`); + + await this.executionRepository.updateStatus(executionId, 'running'); + + let { staticData } = execution.workflowData; + + if (loadStaticData) { + const workflowData = await this.workflowRepository.findOne({ + select: ['id', 'staticData'], + where: { id: workflowId }, + }); + + if (workflowData === null) { + this.logger.error('[JobProcessor] Failed to find workflow', { workflowId, executionId }); + throw new ApplicationError('Failed to find workflow', { extra: { workflowId } }); + } + + staticData = workflowData.staticData; + } + + const workflowSettings = execution.workflowData.settings ?? {}; + + let workflowTimeout = workflowSettings.executionTimeout ?? config.getEnv('executions.timeout'); + + let executionTimeoutTimestamp: number | undefined; + + if (workflowTimeout > 0) { + workflowTimeout = Math.min(workflowTimeout, config.getEnv('executions.maxTimeout')); + executionTimeoutTimestamp = Date.now() + workflowTimeout * 1000; + } + + const workflow = new Workflow({ + id: workflowId, + name: execution.workflowData.name, + nodes: execution.workflowData.nodes, + connections: execution.workflowData.connections, + active: execution.workflowData.active, + nodeTypes: this.nodeTypes, + staticData, + settings: execution.workflowData.settings, + }); + + const additionalData = await WorkflowExecuteAdditionalData.getBase( + undefined, + undefined, + executionTimeoutTimestamp, + ); + + additionalData.hooks = WorkflowExecuteAdditionalData.getWorkflowHooksWorkerExecuter( + execution.mode, + job.data.executionId, + execution.workflowData, + { retryOf: execution.retryOf as string }, + ); + + additionalData.hooks.hookFunctions.sendResponse = [ + async (response: IExecuteResponsePromiseData): Promise => { + await job.progress({ + kind: 'respond-to-webhook', + executionId, + response: this.encodeWebhookResponse(response), + }); + }, + ]; + + additionalData.executionId = executionId; + + additionalData.setExecutionStatus = (status: ExecutionStatus) => { + // Can't set the status directly in the queued worker, but it will happen in InternalHook.onWorkflowPostExecute + this.logger.debug( + `[JobProcessor] Queued worker execution status for ${executionId} is "${status}"`, + ); + }; + + let workflowExecute: WorkflowExecute; + let workflowRun: PCancelable; + if (execution.data !== undefined) { + workflowExecute = new WorkflowExecute(additionalData, execution.mode, execution.data); + workflowRun = workflowExecute.processRunExecutionData(workflow); + } else { + // Execute all nodes + // Can execute without webhook so go on + workflowExecute = new WorkflowExecute(additionalData, execution.mode); + workflowRun = workflowExecute.run(workflow); + } + + const runningJob: RunningJob = { + run: workflowRun, + executionId, + workflowId: execution.workflowId, + workflowName: execution.workflowData.name, + mode: execution.mode, + startedAt: execution.startedAt, + retryOf: execution.retryOf ?? '', + status: execution.status, + }; + + this.runningJobs[job.id] = runningJob; + + await workflowRun; + + delete this.runningJobs[job.id]; + + this.logger.debug('[JobProcessor] Job finished running', { jobId: job.id, executionId }); + + /** + * @important Do NOT call `workflowExecuteAfter` hook here. + * It is being called from processSuccessExecution() already. + */ + + return { success: true }; + } + + stopJob(jobId: JobId) { + this.runningJobs[jobId]?.run.cancel(); + delete this.runningJobs[jobId]; + } + + getRunningJobIds(): JobId[] { + return Object.keys(this.runningJobs); + } + + getRunningJobsSummary(): RunningJobSummary[] { + return Object.values(this.runningJobs).map(({ run, ...summary }) => summary); + } + + private encodeWebhookResponse( + response: IExecuteResponsePromiseData, + ): IExecuteResponsePromiseData { + if (typeof response === 'object' && Buffer.isBuffer(response.body)) { + response.body = { + '__@N8nEncodedBuffer@__': response.body.toString(BINARY_ENCODING), + }; + } + + return response; + } +} diff --git a/packages/cli/src/scaling/scaling.service.ts b/packages/cli/src/scaling/scaling.service.ts new file mode 100644 index 0000000000000..1a8bc9e173798 --- /dev/null +++ b/packages/cli/src/scaling/scaling.service.ts @@ -0,0 +1,342 @@ +import Container, { Service } from 'typedi'; +import { ApplicationError, BINARY_ENCODING, sleep, jsonStringify } from 'n8n-workflow'; +import { ActiveExecutions } from '@/ActiveExecutions'; +import config from '@/config'; +import { Logger } from '@/Logger'; +import { MaxStalledCountError } from '@/errors/max-stalled-count.error'; +import { HIGHEST_SHUTDOWN_PRIORITY, Time } from '@/constants'; +import { OnShutdown } from '@/decorators/OnShutdown'; +import { JOB_TYPE_NAME, QUEUE_NAME } from './constants'; +import { JobProcessor } from './job-processor'; +import type { + JobQueue, + Job, + JobData, + JobOptions, + JobMessage, + JobStatus, + JobId, + QueueRecoveryContext, +} from './types'; +import type { IExecuteResponsePromiseData } from 'n8n-workflow'; +import { GlobalConfig } from '@n8n/config'; +import { ExecutionRepository } from '@/databases/repositories/execution.repository'; +import { InstanceSettings } from 'n8n-core'; +import { OrchestrationService } from '@/services/orchestration.service'; + +@Service() +export class ScalingService { + private queue: JobQueue; + + private readonly instanceType = config.getEnv('generic.instanceType'); + + constructor( + private readonly logger: Logger, + private readonly activeExecutions: ActiveExecutions, + private readonly jobProcessor: JobProcessor, + private readonly globalConfig: GlobalConfig, + private readonly executionRepository: ExecutionRepository, + private readonly instanceSettings: InstanceSettings, + private readonly orchestrationService: OrchestrationService, + ) {} + + // #region Lifecycle + + async setupQueue() { + const { default: BullQueue } = await import('bull'); + const { RedisClientService } = await import('@/services/redis/redis-client.service'); + const service = Container.get(RedisClientService); + + const bullPrefix = this.globalConfig.queue.bull.prefix; + const prefix = service.toValidPrefix(bullPrefix); + + this.queue = new BullQueue(QUEUE_NAME, { + prefix, + settings: this.globalConfig.queue.bull.settings, + createClient: (type) => service.createClient({ type: `${type}(bull)` }), + }); + + this.registerListeners(); + + if (this.instanceSettings.isLeader) this.scheduleQueueRecovery(); + + if (this.orchestrationService.isMultiMainSetupEnabled) { + this.orchestrationService.multiMainSetup + .on('leader-takeover', () => this.scheduleQueueRecovery()) + .on('leader-stepdown', () => this.stopQueueRecovery()); + } + + this.logger.debug('[ScalingService] Queue setup completed'); + } + + setupWorker(concurrency: number) { + this.assertWorker(); + + void this.queue.process( + JOB_TYPE_NAME, + concurrency, + async (job: Job) => await this.jobProcessor.processJob(job), + ); + + this.logger.debug('[ScalingService] Worker setup completed'); + } + + @OnShutdown(HIGHEST_SHUTDOWN_PRIORITY) + async stop() { + await this.queue.pause(true, true); + + this.logger.debug('[ScalingService] Queue paused'); + + this.stopQueueRecovery(); + + this.logger.debug('[ScalingService] Queue recovery stopped'); + + let count = 0; + + while (this.getRunningJobsCount() !== 0) { + if (count++ % 4 === 0) { + this.logger.info( + `Waiting for ${this.getRunningJobsCount()} active executions to finish...`, + ); + } + + await sleep(500); + } + } + + async pingQueue() { + await this.queue.client.ping(); + } + + // #endregion + + // #region Jobs + + async addJob(jobData: JobData, jobOptions: JobOptions) { + const { executionId } = jobData; + + const job = await this.queue.add(JOB_TYPE_NAME, jobData, jobOptions); + + this.logger.info(`[ScalingService] Added job ${job.id} (execution ${executionId})`); + + return job; + } + + async getJob(jobId: JobId) { + return await this.queue.getJob(jobId); + } + + async findJobsByStatus(statuses: JobStatus[]) { + const jobs = await this.queue.getJobs(statuses); + + return jobs.filter((job) => job !== null); + } + + async stopJob(job: Job) { + const props = { jobId: job.id, executionId: job.data.executionId }; + + try { + if (await job.isActive()) { + await job.progress({ kind: 'abort-job' }); // being processed by worker + this.logger.debug('[ScalingService] Stopped active job', props); + return true; + } + + await job.remove(); // not yet picked up, or waiting for next pickup (stalled) + this.logger.debug('[ScalingService] Stopped inactive job', props); + return true; + } catch (error: unknown) { + await job.progress({ kind: 'abort-job' }); + this.logger.error('[ScalingService] Failed to stop job', { ...props, error }); + return false; + } + } + + getRunningJobsCount() { + return this.jobProcessor.getRunningJobIds().length; + } + + // #endregion + + // #region Listeners + + private registerListeners() { + this.queue.on('global:progress', (_jobId: JobId, msg: JobMessage) => { + if (msg.kind === 'respond-to-webhook') { + const { executionId, response } = msg; + this.activeExecutions.resolveResponsePromise( + executionId, + this.decodeWebhookResponse(response), + ); + } + }); + + this.queue.on('global:progress', (jobId: JobId, msg: JobMessage) => { + if (msg.kind === 'abort-job') { + this.jobProcessor.stopJob(jobId); + } + }); + + let latestAttemptTs = 0; + let cumulativeTimeoutMs = 0; + + const MAX_TIMEOUT_MS = this.globalConfig.queue.bull.redis.timeoutThreshold; + const RESET_LENGTH_MS = 30_000; + + this.queue.on('error', (error: Error) => { + this.logger.error('[ScalingService] Queue errored', { error }); + + /** + * On Redis connection failure, try to reconnect. On every failed attempt, + * increment a cumulative timeout - if this exceeds a limit, exit the + * process. Reset the cumulative timeout if >30s between retries. + */ + if (error.message.includes('ECONNREFUSED')) { + const nowTs = Date.now(); + if (nowTs - latestAttemptTs > RESET_LENGTH_MS) { + latestAttemptTs = nowTs; + cumulativeTimeoutMs = 0; + } else { + cumulativeTimeoutMs += nowTs - latestAttemptTs; + latestAttemptTs = nowTs; + if (cumulativeTimeoutMs > MAX_TIMEOUT_MS) { + this.logger.error('[ScalingService] Redis unavailable after max timeout'); + this.logger.error('[ScalingService] Exiting process...'); + process.exit(1); + } + } + + this.logger.warn('[ScalingService] Redis unavailable - retrying to connect...'); + return; + } + + if ( + this.instanceType === 'worker' && + error.message.includes('job stalled more than maxStalledCount') + ) { + throw new MaxStalledCountError(error); + } + + /** + * Non-recoverable error on worker start with Redis unavailable. + * Even if Redis recovers, worker will remain unable to process jobs. + */ + if ( + this.instanceType === 'worker' && + error.message.includes('Error initializing Lua scripts') + ) { + this.logger.error('[ScalingService] Fatal error initializing worker', { error }); + this.logger.error('[ScalingService] Exiting process...'); + process.exit(1); + } + + throw error; + }); + } + + // #endregion + + private decodeWebhookResponse( + response: IExecuteResponsePromiseData, + ): IExecuteResponsePromiseData { + if ( + typeof response === 'object' && + typeof response.body === 'object' && + response.body !== null && + '__@N8nEncodedBuffer@__' in response.body && + typeof response.body['__@N8nEncodedBuffer@__'] === 'string' + ) { + response.body = Buffer.from(response.body['__@N8nEncodedBuffer@__'], BINARY_ENCODING); + } + + return response; + } + + private assertWorker() { + if (this.instanceType === 'worker') return; + + throw new ApplicationError('This method must be called on a `worker` instance'); + } + + // #region Queue recovery + + private readonly queueRecoveryContext: QueueRecoveryContext = { + batchSize: config.getEnv('executions.queueRecovery.batchSize'), + waitMs: config.getEnv('executions.queueRecovery.interval') * 60 * 1000, + }; + + scheduleQueueRecovery(waitMs = this.queueRecoveryContext.waitMs) { + this.queueRecoveryContext.timeout = setTimeout(async () => { + try { + const nextWaitMs = await this.recoverFromQueue(); + this.scheduleQueueRecovery(nextWaitMs); + } catch (error) { + this.logger.error('[ScalingService] Failed to recover dangling executions from queue', { + msg: this.toErrorMsg(error), + }); + this.logger.error('[ScalingService] Retrying...'); + + this.scheduleQueueRecovery(); + } + }, waitMs); + + const wait = [this.queueRecoveryContext.waitMs / Time.minutes.toMilliseconds, 'min'].join(' '); + + this.logger.debug(`[ScalingService] Scheduled queue recovery check for next ${wait}`); + } + + stopQueueRecovery() { + clearTimeout(this.queueRecoveryContext.timeout); + } + + /** + * Mark in-progress executions as `crashed` if stored in DB as `new` or `running` + * but absent from the queue. Return time until next recovery cycle. + */ + private async recoverFromQueue() { + const { waitMs, batchSize } = this.queueRecoveryContext; + + const storedIds = await this.executionRepository.getInProgressExecutionIds(batchSize); + + if (storedIds.length === 0) { + this.logger.debug('[ScalingService] Completed queue recovery check, no dangling executions'); + return waitMs; + } + + const runningJobs = await this.findJobsByStatus(['active', 'waiting']); + + const queuedIds = new Set(runningJobs.map((job) => job.data.executionId)); + + if (queuedIds.size === 0) { + this.logger.debug('[ScalingService] Completed queue recovery check, no dangling executions'); + return waitMs; + } + + const danglingIds = storedIds.filter((id) => !queuedIds.has(id)); + + if (danglingIds.length === 0) { + this.logger.debug('[ScalingService] Completed queue recovery check, no dangling executions'); + return waitMs; + } + + await this.executionRepository.markAsCrashed(danglingIds); + + this.logger.info( + '[ScalingService] Completed queue recovery check, recovered dangling executions', + { danglingIds }, + ); + + // if this cycle used up the whole batch size, it is possible for there to be + // dangling executions outside this check, so speed up next cycle + + return storedIds.length >= this.queueRecoveryContext.batchSize ? waitMs / 2 : waitMs; + } + + private toErrorMsg(error: unknown) { + return error instanceof Error + ? error.message + : jsonStringify(error, { replaceCircularRefs: true }); + } + + // #endregion +} diff --git a/packages/cli/src/scaling/types.ts b/packages/cli/src/scaling/types.ts new file mode 100644 index 0000000000000..b35d1d109d7c4 --- /dev/null +++ b/packages/cli/src/scaling/types.ts @@ -0,0 +1,66 @@ +import type { + ExecutionError, + ExecutionStatus, + IExecuteResponsePromiseData, + IRun, + WorkflowExecuteMode as WorkflowExecutionMode, +} from 'n8n-workflow'; +import type Bull from 'bull'; +import type PCancelable from 'p-cancelable'; + +export type JobQueue = Bull.Queue; + +export type Job = Bull.Job; + +export type JobId = Job['id']; + +export type JobData = { + executionId: string; + loadStaticData: boolean; +}; + +export type JobResult = { + success: boolean; + error?: ExecutionError; +}; + +export type JobStatus = Bull.JobStatus; + +export type JobOptions = Bull.JobOptions; + +/** Message sent by worker to queue or by queue to worker. */ +export type JobMessage = RepondToWebhookMessage | AbortJobMessage; + +export type RepondToWebhookMessage = { + kind: 'respond-to-webhook'; + executionId: string; + response: IExecuteResponsePromiseData; +}; + +export type AbortJobMessage = { + kind: 'abort-job'; +}; + +export type RunningJob = { + executionId: string; + workflowId: string; + workflowName: string; + mode: WorkflowExecutionMode; + startedAt: Date; + retryOf: string; + status: ExecutionStatus; + run: PCancelable; +}; + +export type RunningJobSummary = Omit; + +export type QueueRecoveryContext = { + /** ID of timeout for next scheduled recovery cycle. */ + timeout?: NodeJS.Timeout; + + /** Number of in-progress executions to check per cycle. */ + batchSize: number; + + /** Time (in milliseconds) to wait until the next cycle. */ + waitMs: number; +}; diff --git a/packages/cli/test/unit/ExecutionMetadataService.test.ts b/packages/cli/src/services/__tests__/ExecutionMetadataService.test.ts similarity index 94% rename from packages/cli/test/unit/ExecutionMetadataService.test.ts rename to packages/cli/src/services/__tests__/ExecutionMetadataService.test.ts index 826aae5e25324..8b77b8b1681d4 100644 --- a/packages/cli/test/unit/ExecutionMetadataService.test.ts +++ b/packages/cli/src/services/__tests__/ExecutionMetadataService.test.ts @@ -1,7 +1,7 @@ import { Container } from 'typedi'; import { ExecutionMetadataRepository } from '@db/repositories/executionMetadata.repository'; import { ExecutionMetadataService } from '@/services/executionMetadata.service'; -import { mockInstance } from '../shared/mocking'; +import { mockInstance } from '@test/mocking'; describe('ExecutionMetadataService', () => { const repository = mockInstance(ExecutionMetadataRepository); diff --git a/packages/cli/test/unit/services/activeWorkflows.service.test.ts b/packages/cli/src/services/__tests__/activeWorkflows.service.test.ts similarity index 100% rename from packages/cli/test/unit/services/activeWorkflows.service.test.ts rename to packages/cli/src/services/__tests__/activeWorkflows.service.test.ts diff --git a/packages/cli/test/unit/services/communityPackages.service.test.ts b/packages/cli/src/services/__tests__/communityPackages.service.test.ts similarity index 86% rename from packages/cli/test/unit/services/communityPackages.service.test.ts rename to packages/cli/src/services/__tests__/communityPackages.service.test.ts index 2b0d8bf0d452a..ec7ce61ba9c1c 100644 --- a/packages/cli/test/unit/services/communityPackages.service.test.ts +++ b/packages/cli/src/services/__tests__/communityPackages.service.test.ts @@ -2,8 +2,10 @@ import { exec } from 'child_process'; import { access as fsAccess, mkdir as fsMkdir } from 'fs/promises'; import axios from 'axios'; import { mocked } from 'jest-mock'; -import Container from 'typedi'; +import { mock } from 'jest-mock-extended'; +import type { GlobalConfig } from '@n8n/config'; import type { PublicInstalledPackage } from 'n8n-workflow'; +import type { PackageDirectoryLoader } from 'n8n-core'; import { NODE_PACKAGE_PREFIX, @@ -11,24 +13,19 @@ import { NPM_PACKAGE_STATUS_GOOD, RESPONSE_ERROR_MESSAGES, } from '@/constants'; -import config from '@/config'; import { InstalledPackages } from '@db/entities/InstalledPackages'; import type { CommunityPackages } from '@/Interfaces'; import { CommunityPackagesService } from '@/services/communityPackages.service'; import { InstalledNodesRepository } from '@db/repositories/installedNodes.repository'; import { InstalledPackagesRepository } from '@db/repositories/installedPackages.repository'; import { InstalledNodes } from '@db/entities/InstalledNodes'; -import { LoadNodesAndCredentials } from '@/LoadNodesAndCredentials'; +import type { LoadNodesAndCredentials } from '@/LoadNodesAndCredentials'; +import type { License } from '@/License'; -import { mockInstance } from '../../shared/mocking'; -import { - COMMUNITY_NODE_VERSION, - COMMUNITY_PACKAGE_VERSION, -} from '../../integration/shared/constants'; -import { randomName } from '../../integration/shared/random'; -import { mockPackageName, mockPackagePair } from '../../integration/shared/utils'; -import { InstanceSettings, PackageDirectoryLoader } from 'n8n-core'; -import { Logger } from '@/Logger'; +import { mockInstance } from '@test/mocking'; +import { COMMUNITY_NODE_VERSION, COMMUNITY_PACKAGE_VERSION } from '@test-integration/constants'; +import { randomName } from '@test-integration/random'; +import { mockPackageName, mockPackagePair } from '@test-integration/utils'; jest.mock('fs/promises'); jest.mock('child_process'); @@ -43,10 +40,20 @@ const execMock = ((...args) => { }) as typeof exec; describe('CommunityPackagesService', () => { + const license = mock(); + const globalConfig = mock({ + nodes: { + communityPackages: { + reinstallMissing: false, + registry: 'some.random.host', + }, + }, + }); + const loadNodesAndCredentials = mock(); + + const nodeName = randomName(); const installedNodesRepository = mockInstance(InstalledNodesRepository); installedNodesRepository.create.mockImplementation(() => { - const nodeName = randomName(); - return Object.assign(new InstalledNodes(), { name: nodeName, type: nodeName, @@ -63,13 +70,15 @@ describe('CommunityPackagesService', () => { }); }); - mockInstance(LoadNodesAndCredentials); - - const communityPackagesService = Container.get(CommunityPackagesService); - - beforeEach(() => { - config.load(config.default); - }); + const communityPackagesService = new CommunityPackagesService( + mock(), + mock(), + mock(), + loadNodesAndCredentials, + mock(), + license, + globalConfig, + ); describe('parseNpmPackageName()', () => { test('should fail with empty package name', () => { @@ -368,50 +377,28 @@ describe('CommunityPackagesService', () => { }; describe('updateNpmModule', () => { - let packageDirectoryLoader: PackageDirectoryLoader; - let communityPackagesService: CommunityPackagesService; + const installedPackage = mock({ packageName: mockPackageName() }); + const packageDirectoryLoader = mock({ + loadedNodes: [{ name: nodeName, version: 1 }], + }); beforeEach(async () => { - jest.restoreAllMocks(); + jest.clearAllMocks(); - packageDirectoryLoader = mockInstance(PackageDirectoryLoader); - const loadNodesAndCredentials = mockInstance(LoadNodesAndCredentials); loadNodesAndCredentials.loadPackage.mockResolvedValue(packageDirectoryLoader); - const instanceSettings = mockInstance(InstanceSettings); - const logger = mockInstance(Logger); - const installedPackagesRepository = mockInstance(InstalledPackagesRepository); - - communityPackagesService = new CommunityPackagesService( - instanceSettings, - logger, - installedPackagesRepository, - loadNodesAndCredentials, - ); - }); - - afterEach(async () => { - jest.restoreAllMocks(); + mocked(exec).mockImplementation(execMock); }); - test('should call `exec` with the correct command ', async () => { + test('should call `exec` with the correct command and registry', async () => { // // ARRANGE // - const nodeName = randomName(); - packageDirectoryLoader.loadedNodes = [{ name: nodeName, version: 1 }]; - - const installedPackage = new InstalledPackages(); - installedPackage.packageName = mockPackageName(); - - mocked(exec).mockImplementation(execMock); + license.isCustomNpmRegistryEnabled.mockReturnValue(true); // // ACT // - await communityPackagesService.updateNpmModule( - installedPackage.packageName, - installedPackage, - ); + await communityPackagesService.updatePackage(installedPackage.packageName, installedPackage); // // ASSERT @@ -420,10 +407,32 @@ describe('CommunityPackagesService', () => { expect(exec).toHaveBeenCalledTimes(1); expect(exec).toHaveBeenNthCalledWith( 1, - `npm install ${installedPackage.packageName}@latest`, + `npm install ${installedPackage.packageName}@latest --registry=some.random.host`, expect.any(Object), expect.any(Function), ); }); + + test('should throw when not licensed', async () => { + // + // ARRANGE + // + license.isCustomNpmRegistryEnabled.mockReturnValue(false); + + // + // ACT + // + const promise = communityPackagesService.updatePackage( + installedPackage.packageName, + installedPackage, + ); + + // + // ASSERT + // + await expect(promise).rejects.toThrow( + 'Your license does not allow for feat:communityNodes:customRegistry.', + ); + }); }); }); diff --git a/packages/cli/test/unit/credentials-tester.unit.test.ts b/packages/cli/src/services/__tests__/credentials-tester.service.test.ts similarity index 100% rename from packages/cli/test/unit/credentials-tester.unit.test.ts rename to packages/cli/src/services/__tests__/credentials-tester.service.test.ts diff --git a/packages/cli/test/unit/services/curl.service.test.ts b/packages/cli/src/services/__tests__/curl.service.test.ts similarity index 100% rename from packages/cli/test/unit/services/curl.service.test.ts rename to packages/cli/src/services/__tests__/curl.service.test.ts diff --git a/packages/cli/test/unit/services/hooks.service.test.ts b/packages/cli/src/services/__tests__/hooks.service.test.ts similarity index 100% rename from packages/cli/test/unit/services/hooks.service.test.ts rename to packages/cli/src/services/__tests__/hooks.service.test.ts diff --git a/packages/cli/test/unit/services/jwt.service.test.ts b/packages/cli/src/services/__tests__/jwt.service.test.ts similarity index 100% rename from packages/cli/test/unit/services/jwt.service.test.ts rename to packages/cli/src/services/__tests__/jwt.service.test.ts diff --git a/packages/cli/test/unit/services/naming.service.test.ts b/packages/cli/src/services/__tests__/naming.service.test.ts similarity index 98% rename from packages/cli/test/unit/services/naming.service.test.ts rename to packages/cli/src/services/__tests__/naming.service.test.ts index ea2c34fb8c1ee..1ca216734630e 100644 --- a/packages/cli/test/unit/services/naming.service.test.ts +++ b/packages/cli/src/services/__tests__/naming.service.test.ts @@ -1,6 +1,6 @@ import { WorkflowRepository } from '@/databases/repositories/workflow.repository'; import { CredentialsRepository } from '@/databases/repositories/credentials.repository'; -import { mockInstance } from '../../shared/mocking'; +import { mockInstance } from '@test/mocking'; import { NamingService } from '@/services/naming.service'; import type { WorkflowEntity } from '@/databases/entities/WorkflowEntity'; import type { CredentialsEntity } from '@/databases/entities/CredentialsEntity'; diff --git a/packages/cli/test/unit/services/orchestration.service.test.ts b/packages/cli/src/services/__tests__/orchestration.service.test.ts similarity index 95% rename from packages/cli/test/unit/services/orchestration.service.test.ts rename to packages/cli/src/services/__tests__/orchestration.service.test.ts index 75c7c06e40bb6..c69e674613e25 100644 --- a/packages/cli/test/unit/services/orchestration.service.test.ts +++ b/packages/cli/src/services/__tests__/orchestration.service.test.ts @@ -1,4 +1,9 @@ import Container from 'typedi'; +import type Redis from 'ioredis'; +import { mock } from 'jest-mock-extended'; +import { InstanceSettings } from 'n8n-core'; +import type { WorkflowActivateMode } from 'n8n-workflow'; + import config from '@/config'; import { OrchestrationService } from '@/services/orchestration.service'; import type { RedisServiceWorkerResponseObject } from '@/services/redis/RedisServiceCommands'; @@ -12,12 +17,10 @@ import { ExternalSecretsManager } from '@/ExternalSecrets/ExternalSecretsManager import { Logger } from '@/Logger'; import { Push } from '@/push'; import { ActiveWorkflowManager } from '@/ActiveWorkflowManager'; -import { mockInstance } from '../../shared/mocking'; -import type { WorkflowActivateMode } from 'n8n-workflow'; +import { mockInstance } from '@test/mocking'; import { RedisClientService } from '@/services/redis/redis-client.service'; -import type Redis from 'ioredis'; -import { mock } from 'jest-mock-extended'; +const instanceSettings = Container.get(InstanceSettings); const redisClientService = mockInstance(RedisClientService); const mockRedisClient = mock(); redisClientService.createClient.mockReturnValue(mockRedisClient); @@ -72,6 +75,10 @@ describe('Orchestration Service', () => { queueModeId = config.get('redis.queueModeId'); }); + beforeEach(() => { + instanceSettings.markAsLeader(); + }); + afterAll(async () => { jest.mock('@/services/redis/RedisServicePubSubPublisher').restoreAllMocks(); jest.mock('@/services/redis/RedisServicePubSubSubscriber').restoreAllMocks(); @@ -141,13 +148,10 @@ describe('Orchestration Service', () => { ); expect(helpers.debounceMessageReceiver).toHaveBeenCalledTimes(2); expect(res1!.payload).toBeUndefined(); - expect((res2!.payload as { result: string }).result).toEqual('debounced'); + expect(res2!.payload).toEqual({ result: 'debounced' }); }); describe('shouldAddWebhooks', () => { - beforeEach(() => { - config.set('instanceRole', 'leader'); - }); test('should return true for init', () => { // We want to ensure that webhooks are populated on init // more https://github.com/n8n-io/n8n/pull/8830 @@ -169,7 +173,7 @@ describe('Orchestration Service', () => { }); test('should return false for update or activate when not leader', () => { - config.set('instanceRole', 'follower'); + instanceSettings.markAsFollower(); const modes = ['update', 'activate'] as WorkflowActivateMode[]; for (const mode of modes) { const result = os.shouldAddWebhooks(mode); diff --git a/packages/cli/test/unit/services/ownership.service.test.ts b/packages/cli/src/services/__tests__/ownership.service.test.ts similarity index 98% rename from packages/cli/test/unit/services/ownership.service.test.ts rename to packages/cli/src/services/__tests__/ownership.service.test.ts index d1a722da19625..8a3d40eb60f8d 100644 --- a/packages/cli/test/unit/services/ownership.service.test.ts +++ b/packages/cli/src/services/__tests__/ownership.service.test.ts @@ -3,14 +3,14 @@ import { SharedWorkflowRepository } from '@db/repositories/sharedWorkflow.reposi import { SharedWorkflow } from '@db/entities/SharedWorkflow'; import { User } from '@db/entities/User'; import type { SharedCredentials } from '@db/entities/SharedCredentials'; -import { mockInstance } from '../../shared/mocking'; +import { mockInstance } from '@test/mocking'; import { WorkflowEntity } from '@/databases/entities/WorkflowEntity'; import { UserRepository } from '@/databases/repositories/user.repository'; import { mock } from 'jest-mock-extended'; import { Project } from '@/databases/entities/Project'; import { ProjectRelationRepository } from '@/databases/repositories/projectRelation.repository'; import { ProjectRelation } from '@/databases/entities/ProjectRelation'; -import { mockCredential, mockProject } from '../shared/mockObjects'; +import { mockCredential, mockProject } from '@test/mockObjects'; describe('OwnershipService', () => { const userRepository = mockInstance(UserRepository); diff --git a/packages/cli/test/unit/utilities/password.utility.test.ts b/packages/cli/src/services/__tests__/password.utility.test.ts similarity index 100% rename from packages/cli/test/unit/utilities/password.utility.test.ts rename to packages/cli/src/services/__tests__/password.utility.test.ts diff --git a/packages/cli/test/unit/services/redis.service.test.ts b/packages/cli/src/services/__tests__/redis.service.test.ts similarity index 94% rename from packages/cli/test/unit/services/redis.service.test.ts rename to packages/cli/src/services/__tests__/redis.service.test.ts index cb963ad535567..1d9652983735c 100644 --- a/packages/cli/test/unit/services/redis.service.test.ts +++ b/packages/cli/src/services/__tests__/redis.service.test.ts @@ -2,7 +2,7 @@ import Container from 'typedi'; import { Logger } from '@/Logger'; import config from '@/config'; import { RedisService } from '@/services/redis.service'; -import { mockInstance } from '../../shared/mocking'; +import { mockInstance } from '@test/mocking'; jest.mock('ioredis', () => { const Redis = require('ioredis-mock'); @@ -14,7 +14,7 @@ jest.mock('ioredis', () => { }; } // second mock for our code - return function (...args: any) { + return function (...args: unknown[]) { return new Redis(args); }; }); diff --git a/packages/cli/test/unit/services/user.service.test.ts b/packages/cli/src/services/__tests__/user.service.test.ts similarity index 98% rename from packages/cli/test/unit/services/user.service.test.ts rename to packages/cli/src/services/__tests__/user.service.test.ts index 89929f57b0b89..5aeb919220c23 100644 --- a/packages/cli/test/unit/services/user.service.test.ts +++ b/packages/cli/src/services/__tests__/user.service.test.ts @@ -4,7 +4,7 @@ import { v4 as uuid } from 'uuid'; import { User } from '@db/entities/User'; import { UserService } from '@/services/user.service'; import { UrlService } from '@/services/url.service'; -import { mockInstance } from '../../shared/mocking'; +import { mockInstance } from '@test/mocking'; import { UserRepository } from '@/databases/repositories/user.repository'; import { GlobalConfig } from '@n8n/config'; diff --git a/packages/cli/test/unit/services/workflow-statistics.service.test.ts b/packages/cli/src/services/__tests__/workflow-statistics.service.test.ts similarity index 81% rename from packages/cli/test/unit/services/workflow-statistics.service.test.ts rename to packages/cli/src/services/__tests__/workflow-statistics.service.test.ts index 6d9baf49ea939..b3cc6b7998db8 100644 --- a/packages/cli/test/unit/services/workflow-statistics.service.test.ts +++ b/packages/cli/src/services/__tests__/workflow-statistics.service.test.ts @@ -17,8 +17,9 @@ import { WorkflowStatisticsRepository } from '@db/repositories/workflowStatistic import { WorkflowStatisticsService } from '@/services/workflow-statistics.service'; import { UserService } from '@/services/user.service'; import { OwnershipService } from '@/services/ownership.service'; -import { mockInstance } from '../../shared/mocking'; +import { mockInstance } from '@test/mocking'; import type { Project } from '@/databases/entities/Project'; +import type { EventService } from '@/events/event.service'; describe('WorkflowStatisticsService', () => { const fakeUser = mock({ id: 'abcde-fghij' }); @@ -44,21 +45,15 @@ describe('WorkflowStatisticsService', () => { mocked(ownershipService.getProjectOwnerCached).mockResolvedValue(fakeUser); const updateSettingsMock = jest.spyOn(userService, 'updateSettings').mockImplementation(); + const eventService = mock(); const workflowStatisticsService = new WorkflowStatisticsService( mock(), new WorkflowStatisticsRepository(dataSource, globalConfig), ownershipService, userService, + eventService, ); - const onFirstProductionWorkflowSuccess = jest.fn(); - const onFirstWorkflowDataLoad = jest.fn(); - workflowStatisticsService.on( - 'telemetry.onFirstProductionWorkflowSuccess', - onFirstProductionWorkflowSuccess, - ); - workflowStatisticsService.on('telemetry.onFirstWorkflowDataLoad', onFirstWorkflowDataLoad); - beforeEach(() => { jest.clearAllMocks(); }); @@ -97,11 +92,10 @@ describe('WorkflowStatisticsService', () => { await workflowStatisticsService.workflowExecutionCompleted(workflow, runData); expect(updateSettingsMock).toHaveBeenCalledTimes(1); - expect(onFirstProductionWorkflowSuccess).toBeCalledTimes(1); - expect(onFirstProductionWorkflowSuccess).toHaveBeenNthCalledWith(1, { - project_id: fakeProject.id, - user_id: fakeUser.id, - workflow_id: workflow.id, + expect(eventService.emit).toHaveBeenCalledWith('first-production-workflow-succeeded', { + projectId: fakeProject.id, + workflowId: workflow.id, + userId: fakeUser.id, }); }); @@ -124,7 +118,7 @@ describe('WorkflowStatisticsService', () => { startedAt: new Date(), }; await workflowStatisticsService.workflowExecutionCompleted(workflow, runData); - expect(onFirstProductionWorkflowSuccess).toBeCalledTimes(0); + expect(eventService.emit).not.toHaveBeenCalled(); }); test('should not send metrics for updated entries', async () => { @@ -147,7 +141,7 @@ describe('WorkflowStatisticsService', () => { }; mockDBCall(2); await workflowStatisticsService.workflowExecutionCompleted(workflow, runData); - expect(onFirstProductionWorkflowSuccess).toBeCalledTimes(0); + expect(eventService.emit).not.toHaveBeenCalled(); }); }); @@ -164,13 +158,12 @@ describe('WorkflowStatisticsService', () => { parameters: {}, }; await workflowStatisticsService.nodeFetchedData(workflowId, node); - expect(onFirstWorkflowDataLoad).toBeCalledTimes(1); - expect(onFirstWorkflowDataLoad).toHaveBeenNthCalledWith(1, { - user_id: fakeUser.id, - project_id: fakeProject.id, - workflow_id: workflowId, - node_type: node.type, - node_id: node.id, + expect(eventService.emit).toHaveBeenCalledWith('first-workflow-data-loaded', { + userId: fakeUser.id, + project: fakeProject.id, + workflowId, + nodeType: node.type, + nodeId: node.id, }); }); @@ -192,15 +185,14 @@ describe('WorkflowStatisticsService', () => { }, }; await workflowStatisticsService.nodeFetchedData(workflowId, node); - expect(onFirstWorkflowDataLoad).toBeCalledTimes(1); - expect(onFirstWorkflowDataLoad).toHaveBeenNthCalledWith(1, { - user_id: fakeUser.id, - project_id: fakeProject.id, - workflow_id: workflowId, - node_type: node.type, - node_id: node.id, - credential_type: 'testCredentials', - credential_id: node.credentials.testCredentials.id, + expect(eventService.emit).toHaveBeenCalledWith('first-workflow-data-loaded', { + userId: fakeUser.id, + project: fakeProject.id, + workflowId, + nodeType: node.type, + nodeId: node.id, + credentialType: 'testCredentials', + credentialId: node.credentials.testCredentials.id, }); }); @@ -217,7 +209,7 @@ describe('WorkflowStatisticsService', () => { parameters: {}, }; await workflowStatisticsService.nodeFetchedData(workflowId, node); - expect(onFirstWorkflowDataLoad).toBeCalledTimes(0); + expect(eventService.emit).not.toHaveBeenCalled(); }); }); }); diff --git a/packages/cli/test/unit/services/cache-mock.service.test.ts b/packages/cli/src/services/cache/__tests__/cache-mock.service.test.ts similarity index 100% rename from packages/cli/test/unit/services/cache-mock.service.test.ts rename to packages/cli/src/services/cache/__tests__/cache-mock.service.test.ts diff --git a/packages/cli/test/unit/services/cache.service.test.ts b/packages/cli/src/services/cache/__tests__/cache.service.test.ts similarity index 92% rename from packages/cli/test/unit/services/cache.service.test.ts rename to packages/cli/src/services/cache/__tests__/cache.service.test.ts index a742b20698d46..5c024b2903cc7 100644 --- a/packages/cli/test/unit/services/cache.service.test.ts +++ b/packages/cli/src/services/cache/__tests__/cache.service.test.ts @@ -1,6 +1,8 @@ import { CacheService } from '@/services/cache/cache.service'; import config from '@/config'; import { sleep } from 'n8n-workflow'; +import { GlobalConfig } from '@n8n/config'; +import Container from 'typedi'; jest.mock('ioredis', () => { const Redis = require('ioredis-mock'); @@ -13,10 +15,12 @@ jest.mock('ioredis', () => { for (const backend of ['memory', 'redis'] as const) { describe(backend, () => { let cacheService: CacheService; + let globalConfig: GlobalConfig; beforeAll(async () => { - config.set('cache.backend', backend); - cacheService = new CacheService(); + globalConfig = Container.get(GlobalConfig); + globalConfig.cache.backend = backend; + cacheService = new CacheService(globalConfig); await cacheService.init(); }); @@ -43,7 +47,7 @@ for (const backend of ['memory', 'redis'] as const) { if (backend === 'memory') { test('should honor max size when enough', async () => { - config.set('cache.memory.maxSize', 16); // enough bytes for "withoutUnicode" + globalConfig.cache.memory.maxSize = 16; // enough bytes for "withoutUnicode" await cacheService.init(); await cacheService.set('key', 'withoutUnicode'); @@ -51,12 +55,12 @@ for (const backend of ['memory', 'redis'] as const) { await expect(cacheService.get('key')).resolves.toBe('withoutUnicode'); // restore - config.set('cache.memory.maxSize', 3 * 1024 * 1024); + globalConfig.cache.memory.maxSize = 3 * 1024 * 1024; await cacheService.init(); }); test('should honor max size when not enough', async () => { - config.set('cache.memory.maxSize', 16); // not enough bytes for "withUnicodeԱԲԳ" + globalConfig.cache.memory.maxSize = 16; // not enough bytes for "withUnicodeԱԲԳ" await cacheService.init(); await cacheService.set('key', 'withUnicodeԱԲԳ'); @@ -64,7 +68,8 @@ for (const backend of ['memory', 'redis'] as const) { await expect(cacheService.get('key')).resolves.toBeUndefined(); // restore - config.set('cache.memory.maxSize', 3 * 1024 * 1024); + globalConfig.cache.memory.maxSize = 3 * 1024 * 1024; + // restore await cacheService.init(); }); } diff --git a/packages/cli/src/services/cache/cache.service.ts b/packages/cli/src/services/cache/cache.service.ts index 75dad03b49d26..daf51911ff362 100644 --- a/packages/cli/src/services/cache/cache.service.ts +++ b/packages/cli/src/services/cache/cache.service.ts @@ -13,6 +13,7 @@ import type { } from '@/services/cache/cache.types'; import { TIME } from '@/constants'; import { TypedEmitter } from '@/TypedEmitter'; +import { GlobalConfig } from '@n8n/config'; type CacheEvents = { 'metrics.cache.hit': never; @@ -22,12 +23,15 @@ type CacheEvents = { @Service() export class CacheService extends TypedEmitter { + constructor(private readonly globalConfig: GlobalConfig) { + super(); + } + private cache: TaggedRedisCache | TaggedMemoryCache; async init() { - const backend = config.getEnv('cache.backend'); + const { backend } = this.globalConfig.cache; const mode = config.getEnv('executions.mode'); - const ttl = config.getEnv('cache.redis.ttl'); const useRedis = backend === 'redis' || (backend === 'auto' && mode === 'queue'); @@ -36,8 +40,9 @@ export class CacheService extends TypedEmitter { const redisClientService = Container.get(RedisClientService); const prefixBase = config.getEnv('redis.prefix'); - const cachePrefix = config.getEnv('cache.redis.prefix'); - const prefix = redisClientService.toValidPrefix(`${prefixBase}:${cachePrefix}:`); + const prefix = redisClientService.toValidPrefix( + `${prefixBase}:${this.globalConfig.cache.redis.prefix}:`, + ); const redisClient = redisClientService.createClient({ type: 'client(cache)', @@ -45,7 +50,9 @@ export class CacheService extends TypedEmitter { }); const { redisStoreUsingClient } = await import('@/services/cache/redis.cache-manager'); - const redisStore = redisStoreUsingClient(redisClient, { ttl }); + const redisStore = redisStoreUsingClient(redisClient, { + ttl: this.globalConfig.cache.redis.ttl, + }); const redisCache = await caching(redisStore); @@ -54,7 +61,7 @@ export class CacheService extends TypedEmitter { return; } - const maxSize = config.getEnv('cache.memory.maxSize'); + const { maxSize, ttl } = this.globalConfig.cache.memory; const sizeCalculation = (item: unknown) => { const str = jsonStringify(item, { replaceCircularRefs: true }); diff --git a/packages/cli/src/services/communityPackages.service.ts b/packages/cli/src/services/communityPackages.service.ts index 23bf9461d697d..985fb44fabdc7 100644 --- a/packages/cli/src/services/communityPackages.service.ts +++ b/packages/cli/src/services/communityPackages.service.ts @@ -5,6 +5,7 @@ import { Service } from 'typedi'; import { promisify } from 'util'; import axios from 'axios'; +import { GlobalConfig } from '@n8n/config'; import { ApplicationError, type PublicInstalledPackage } from 'n8n-workflow'; import { InstanceSettings } from 'n8n-core'; import type { PackageDirectoryLoader } from 'n8n-core'; @@ -13,15 +14,21 @@ import { toError } from '@/utils'; import { InstalledPackagesRepository } from '@db/repositories/installedPackages.repository'; import type { InstalledPackages } from '@db/entities/InstalledPackages'; import { + LICENSE_FEATURES, NODE_PACKAGE_PREFIX, NPM_COMMAND_TOKENS, NPM_PACKAGE_STATUS_GOOD, RESPONSE_ERROR_MESSAGES, UNKNOWN_FAILURE_REASON, } from '@/constants'; +import { FeatureNotLicensedError } from '@/errors/feature-not-licensed.error'; import type { CommunityPackages } from '@/Interfaces'; import { LoadNodesAndCredentials } from '@/LoadNodesAndCredentials'; import { Logger } from '@/Logger'; +import { OrchestrationService } from './orchestration.service'; +import { License } from '@/License'; + +const DEFAULT_REGISTRY = 'https://registry.npmjs.org'; const { PACKAGE_NAME_NOT_PROVIDED, @@ -45,6 +52,8 @@ const INVALID_OR_SUSPICIOUS_PACKAGE_NAME = /[^0-9a-z@\-./]/; @Service() export class CommunityPackagesService { + reinstallMissingPackages = false; + missingPackages: string[] = []; constructor( @@ -52,6 +61,9 @@ export class CommunityPackagesService { private readonly logger: Logger, private readonly installedPackageRepository: InstalledPackagesRepository, private readonly loadNodesAndCredentials: LoadNodesAndCredentials, + private readonly orchestrationService: OrchestrationService, + private readonly license: License, + private readonly globalConfig: GlobalConfig, ) {} get hasMissingPackages() { @@ -73,11 +85,11 @@ export class CommunityPackagesService { return await this.installedPackageRepository.find({ relations: ['installedNodes'] }); } - async removePackageFromDatabase(packageName: InstalledPackages) { + private async removePackageFromDatabase(packageName: InstalledPackages) { return await this.installedPackageRepository.remove(packageName); } - async persistInstalledPackage(packageLoader: PackageDirectoryLoader) { + private async persistInstalledPackage(packageLoader: PackageDirectoryLoader) { try { return await this.installedPackageRepository.saveInstalledPackageWithNodes(packageLoader); } catch (maybeError) { @@ -251,7 +263,7 @@ export class CommunityPackagesService { } } - async setMissingPackages({ reinstallMissingPackages }: { reinstallMissingPackages: boolean }) { + async checkForMissingPackages() { const installedPackages = await this.getAllInstalledPackages(); const missingPackages = new Set<{ packageName: string; version: string }>(); @@ -271,24 +283,25 @@ export class CommunityPackagesService { if (missingPackages.size === 0) return; - this.logger.error( - 'n8n detected that some packages are missing. For more information, visit https://docs.n8n.io/integrations/community-nodes/troubleshooting/', - ); - - if (reinstallMissingPackages || process.env.N8N_REINSTALL_MISSING_PACKAGES) { + const { reinstallMissing } = this.globalConfig.nodes.communityPackages; + if (reinstallMissing) { this.logger.info('Attempting to reinstall missing packages', { missingPackages }); try { // Optimistic approach - stop if any installation fails - for (const missingPackage of missingPackages) { - await this.installNpmModule(missingPackage.packageName, missingPackage.version); + await this.installPackage(missingPackage.packageName, missingPackage.version); missingPackages.delete(missingPackage); } this.logger.info('Packages reinstalled successfully. Resuming regular initialization.'); + await this.loadNodesAndCredentials.postProcessLoaders(); } catch (error) { this.logger.error('n8n was unable to install the missing packages.'); } + } else { + this.logger.warn( + 'n8n detected that some packages are missing. For more information, visit https://docs.n8n.io/integrations/community-nodes/troubleshooting/', + ); } this.missingPackages = [...missingPackages].map( @@ -296,32 +309,38 @@ export class CommunityPackagesService { ); } - async installNpmModule(packageName: string, version?: string): Promise { - return await this.installOrUpdateNpmModule(packageName, { version }); + async installPackage(packageName: string, version?: string): Promise { + return await this.installOrUpdatePackage(packageName, { version }); } - async updateNpmModule( + async updatePackage( packageName: string, installedPackage: InstalledPackages, ): Promise { - return await this.installOrUpdateNpmModule(packageName, { installedPackage }); + return await this.installOrUpdatePackage(packageName, { installedPackage }); } - async removeNpmModule(packageName: string, installedPackage: InstalledPackages): Promise { - await this.executeNpmCommand(`npm remove ${packageName}`); + async removePackage(packageName: string, installedPackage: InstalledPackages): Promise { + await this.removeNpmPackage(packageName); await this.removePackageFromDatabase(installedPackage); - await this.loadNodesAndCredentials.unloadPackage(packageName); - await this.loadNodesAndCredentials.postProcessLoaders(); + await this.orchestrationService.publish('community-package-uninstall', { packageName }); + } + + private getNpmRegistry() { + const { registry } = this.globalConfig.nodes.communityPackages; + if (registry !== DEFAULT_REGISTRY && !this.license.isCustomNpmRegistryEnabled()) { + throw new FeatureNotLicensedError(LICENSE_FEATURES.COMMUNITY_NODES_CUSTOM_REGISTRY); + } + return registry; } - private async installOrUpdateNpmModule( + private async installOrUpdatePackage( packageName: string, options: { version?: string } | { installedPackage: InstalledPackages }, ) { const isUpdate = 'installedPackage' in options; - const command = isUpdate - ? `npm install ${packageName}@latest` - : `npm install ${packageName}${options.version ? `@${options.version}` : ''}`; + const packageVersion = isUpdate || !options.version ? 'latest' : options.version; + const command = `npm install ${packageName}@${packageVersion} --registry=${this.getNpmRegistry()}`; try { await this.executeNpmCommand(command); @@ -337,9 +356,8 @@ export class CommunityPackagesService { loader = await this.loadNodesAndCredentials.loadPackage(packageName); } catch (error) { // Remove this package since loading it failed - const removeCommand = `npm remove ${packageName}`; try { - await this.executeNpmCommand(removeCommand); + await this.executeNpmCommand(`npm remove ${packageName}`); } catch {} throw new ApplicationError(RESPONSE_ERROR_MESSAGES.PACKAGE_LOADING_FAILED, { cause: error }); } @@ -351,7 +369,12 @@ export class CommunityPackagesService { await this.removePackageFromDatabase(options.installedPackage); } const installedPackage = await this.persistInstalledPackage(loader); + await this.orchestrationService.publish( + isUpdate ? 'community-package-update' : 'community-package-install', + { packageName, packageVersion }, + ); await this.loadNodesAndCredentials.postProcessLoaders(); + this.logger.info(`Community package installed: ${packageName}`); return installedPackage; } catch (error) { throw new ApplicationError('Failed to save installed package', { @@ -361,12 +384,26 @@ export class CommunityPackagesService { } } else { // Remove this package since it contains no loadable nodes - const removeCommand = `npm remove ${packageName}`; try { - await this.executeNpmCommand(removeCommand); + await this.executeNpmCommand(`npm remove ${packageName}`); } catch {} - throw new ApplicationError(RESPONSE_ERROR_MESSAGES.PACKAGE_DOES_NOT_CONTAIN_NODES); } } + + async installOrUpdateNpmPackage(packageName: string, packageVersion: string) { + await this.executeNpmCommand( + `npm install ${packageName}@${packageVersion} --registry=${this.getNpmRegistry()}`, + ); + await this.loadNodesAndCredentials.loadPackage(packageName); + await this.loadNodesAndCredentials.postProcessLoaders(); + this.logger.info(`Community package installed: ${packageName}`); + } + + async removeNpmPackage(packageName: string) { + await this.executeNpmCommand(`npm remove ${packageName}`); + await this.loadNodesAndCredentials.unloadPackage(packageName); + await this.loadNodesAndCredentials.postProcessLoaders(); + this.logger.info(`Community package uninstalled: ${packageName}`); + } } diff --git a/packages/cli/src/services/dynamicNodeParameters.service.ts b/packages/cli/src/services/dynamicNodeParameters.service.ts index 1788bac6e1877..f3b0f7e192ba3 100644 --- a/packages/cli/src/services/dynamicNodeParameters.service.ts +++ b/packages/cli/src/services/dynamicNodeParameters.service.ts @@ -15,6 +15,8 @@ import type { INodeCredentials, INodeParameters, INodeTypeNameVersion, + NodeParameterValueType, + IDataObject, } from 'n8n-workflow'; import { Workflow, RoutingNode, ApplicationError } from 'n8n-workflow'; import { NodeExecuteFunctions } from 'n8n-core'; @@ -156,6 +158,24 @@ export class DynamicNodeParametersService { return method.call(thisArgs); } + /** Returns the result of the action handler */ + async getActionResult( + handler: string, + path: string, + additionalData: IWorkflowExecuteAdditionalData, + nodeTypeAndVersion: INodeTypeNameVersion, + currentNodeParameters: INodeParameters, + payload: IDataObject | string | undefined, + credentials?: INodeCredentials, + ): Promise { + const nodeType = this.getNodeType(nodeTypeAndVersion); + const method = this.getMethod('actionHandler', handler, nodeType); + const workflow = this.getWorkflow(nodeTypeAndVersion, currentNodeParameters, credentials); + const thisArgs = this.getThisArg(path, additionalData, workflow); + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return method.call(thisArgs, payload); + } + private getMethod( type: 'resourceMapping', methodName: string, @@ -175,9 +195,14 @@ export class DynamicNodeParametersService { methodName: string, nodeType: INodeType, ): (this: ILoadOptionsFunctions) => Promise; + private getMethod( + type: 'actionHandler', + methodName: string, + nodeType: INodeType, + ): (this: ILoadOptionsFunctions, payload?: string) => Promise; private getMethod( - type: 'resourceMapping' | 'listSearch' | 'loadOptions', + type: 'resourceMapping' | 'listSearch' | 'loadOptions' | 'actionHandler', methodName: string, nodeType: INodeType, ) { diff --git a/packages/cli/src/services/frontend.service.ts b/packages/cli/src/services/frontend.service.ts index 7cd9843c1d97f..fc38eb992e154 100644 --- a/packages/cli/src/services/frontend.service.ts +++ b/packages/cli/src/services/frontend.service.ts @@ -31,7 +31,7 @@ import { UserManagementMailer } from '@/UserManagement/email'; import type { CommunityPackagesService } from '@/services/communityPackages.service'; import { Logger } from '@/Logger'; import { UrlService } from './url.service'; -import { InternalHooks } from '@/InternalHooks'; +import { EventService } from '@/events/event.service'; import { isApiEnabled } from '@/PublicApi'; @Service() @@ -50,7 +50,7 @@ export class FrontendService { private readonly mailer: UserManagementMailer, private readonly instanceSettings: InstanceSettings, private readonly urlService: UrlService, - private readonly internalHooks: InternalHooks, + private readonly eventService: EventService, ) { loadNodesAndCredentials.addPostProcessor(async () => await this.generateTypes()); void this.generateTypes(); @@ -244,7 +244,7 @@ export class FrontendService { } getSettings(pushRef?: string): IN8nUISettings { - this.internalHooks.onFrontendSettingsAPI(pushRef); + this.eventService.emit('session-started', { pushRef }); const restEndpoint = this.globalConfig.endpoints.rest; diff --git a/packages/cli/src/services/orchestration.service.ts b/packages/cli/src/services/orchestration.service.ts index bfc1d140fc6fa..283470f4d2114 100644 --- a/packages/cli/src/services/orchestration.service.ts +++ b/packages/cli/src/services/orchestration.service.ts @@ -7,11 +7,13 @@ import type { RedisServiceBaseCommand, RedisServiceCommand } from './redis/Redis import { RedisService } from './redis.service'; import { MultiMainSetup } from './orchestration/main/MultiMainSetup.ee'; import type { WorkflowActivateMode } from 'n8n-workflow'; +import { InstanceSettings } from 'n8n-core'; @Service() export class OrchestrationService { constructor( private readonly logger: Logger, + private readonly instanceSettings: InstanceSettings, private readonly redisService: RedisService, readonly multiMainSetup: MultiMainSetup, ) {} @@ -43,12 +45,14 @@ export class OrchestrationService { return config.getEnv('redis.queueModeId'); } + /** @deprecated use InstanceSettings.isLeader */ get isLeader() { - return config.getEnv('instanceRole') === 'leader'; + return this.instanceSettings.isLeader; } + /** @deprecated use InstanceSettings.isFollower */ get isFollower() { - return config.getEnv('instanceRole') !== 'leader'; + return this.instanceSettings.isFollower; } sanityCheck() { @@ -63,7 +67,7 @@ export class OrchestrationService { if (this.isMultiMainSetupEnabled) { await this.multiMainSetup.init(); } else { - config.set('instanceRole', 'leader'); + this.instanceSettings.markAsLeader(); } this.isInitialized = true; diff --git a/packages/cli/src/services/orchestration/main/MultiMainSetup.ee.ts b/packages/cli/src/services/orchestration/main/MultiMainSetup.ee.ts index cabcf5996bc08..89c6ef725ab45 100644 --- a/packages/cli/src/services/orchestration/main/MultiMainSetup.ee.ts +++ b/packages/cli/src/services/orchestration/main/MultiMainSetup.ee.ts @@ -1,6 +1,7 @@ import config from '@/config'; import { Service } from 'typedi'; import { TIME } from '@/constants'; +import { InstanceSettings } from 'n8n-core'; import { ErrorReporterProxy as EventReporter } from 'n8n-workflow'; import { Logger } from '@/Logger'; import { RedisServicePubSubPublisher } from '@/services/redis/RedisServicePubSubPublisher'; @@ -16,6 +17,7 @@ type MultiMainEvents = { export class MultiMainSetup extends TypedEmitter { constructor( private readonly logger: Logger, + private readonly instanceSettings: InstanceSettings, private readonly redisPublisher: RedisServicePubSubPublisher, private readonly redisClientService: RedisClientService, ) { @@ -50,7 +52,7 @@ export class MultiMainSetup extends TypedEmitter { async shutdown() { clearInterval(this.leaderCheckInterval); - const isLeader = config.getEnv('instanceRole') === 'leader'; + const { isLeader } = this.instanceSettings; if (isLeader) await this.redisPublisher.clear(this.leaderKey); } @@ -69,8 +71,8 @@ export class MultiMainSetup extends TypedEmitter { if (leaderId && leaderId !== this.instanceId) { this.logger.debug(`[Instance ID ${this.instanceId}] Leader is other instance "${leaderId}"`); - if (config.getEnv('instanceRole') === 'leader') { - config.set('instanceRole', 'follower'); + if (this.instanceSettings.isLeader) { + this.instanceSettings.markAsFollower(); this.emit('leader-stepdown'); // lost leadership - stop triggers, pollers, pruning, wait-tracking, queue recovery @@ -85,7 +87,7 @@ export class MultiMainSetup extends TypedEmitter { `[Instance ID ${this.instanceId}] Leadership vacant, attempting to become leader...`, ); - config.set('instanceRole', 'follower'); + this.instanceSettings.markAsFollower(); /** * Lost leadership - stop triggers, pollers, pruning, wait tracking, license renewal, queue recovery @@ -106,7 +108,7 @@ export class MultiMainSetup extends TypedEmitter { if (keySetSuccessfully) { this.logger.debug(`[Instance ID ${this.instanceId}] Leader is now this instance`); - config.set('instanceRole', 'leader'); + this.instanceSettings.markAsLeader(); await this.redisPublisher.setExpiration(this.leaderKey, this.leaderKeyTtl); @@ -115,7 +117,7 @@ export class MultiMainSetup extends TypedEmitter { */ this.emit('leader-takeover'); } else { - config.set('instanceRole', 'follower'); + this.instanceSettings.markAsFollower(); } } diff --git a/packages/cli/src/services/orchestration/main/handleCommandMessageMain.ts b/packages/cli/src/services/orchestration/main/handleCommandMessageMain.ts index 8abcbe78b2c47..9a7b6b5640dd0 100644 --- a/packages/cli/src/services/orchestration/main/handleCommandMessageMain.ts +++ b/packages/cli/src/services/orchestration/main/handleCommandMessageMain.ts @@ -7,9 +7,10 @@ import { License } from '@/License'; import { Logger } from '@/Logger'; import { ActiveWorkflowManager } from '@/ActiveWorkflowManager'; import { Push } from '@/push'; -import { TestWebhooks } from '@/TestWebhooks'; +import { TestWebhooks } from '@/webhooks/TestWebhooks'; import { OrchestrationService } from '@/services/orchestration.service'; import { WorkflowRepository } from '@/databases/repositories/workflow.repository'; +import { CommunityPackagesService } from '@/services/communityPackages.service'; // eslint-disable-next-line complexity export async function handleCommandMessageMain(messageString: string) { @@ -77,6 +78,20 @@ export async function handleCommandMessageMain(messageString: string) { } await Container.get(ExternalSecretsManager).reloadAllProviders(); break; + case 'community-package-install': + case 'community-package-update': + case 'community-package-uninstall': + if (!debounceMessageReceiver(message, 200)) { + return message; + } + const { packageName, packageVersion } = message.payload; + const communityPackagesService = Container.get(CommunityPackagesService); + if (message.command === 'community-package-uninstall') { + await communityPackagesService.removeNpmPackage(packageName); + } else { + await communityPackagesService.installOrUpdateNpmPackage(packageName, packageVersion); + } + break; case 'add-webhooks-triggers-and-pollers': { if (!debounceMessageReceiver(message, 100)) { diff --git a/packages/cli/src/services/orchestration/webhook/handleCommandMessageWebhook.ts b/packages/cli/src/services/orchestration/webhook/handleCommandMessageWebhook.ts index 9dc326978d803..e6f6e656280a8 100644 --- a/packages/cli/src/services/orchestration/webhook/handleCommandMessageWebhook.ts +++ b/packages/cli/src/services/orchestration/webhook/handleCommandMessageWebhook.ts @@ -5,6 +5,7 @@ import Container from 'typedi'; import { Logger } from 'winston'; import { messageToRedisServiceCommandObject, debounceMessageReceiver } from '../helpers'; import config from '@/config'; +import { CommunityPackagesService } from '@/services/communityPackages.service'; export async function handleCommandMessageWebhook(messageString: string) { const queueModeId = config.getEnv('redis.queueModeId'); @@ -63,6 +64,20 @@ export async function handleCommandMessageWebhook(messageString: string) { } await Container.get(ExternalSecretsManager).reloadAllProviders(); break; + case 'community-package-install': + case 'community-package-update': + case 'community-package-uninstall': + if (!debounceMessageReceiver(message, 200)) { + return message; + } + const { packageName, packageVersion } = message.payload; + const communityPackagesService = Container.get(CommunityPackagesService); + if (message.command === 'community-package-uninstall') { + await communityPackagesService.removeNpmPackage(packageName); + } else { + await communityPackagesService.installOrUpdateNpmPackage(packageName, packageVersion); + } + break; default: break; diff --git a/packages/cli/src/services/orchestration/worker/handleCommandMessageWorker.ts b/packages/cli/src/services/orchestration/worker/handleCommandMessageWorker.ts index fa9ee67675e83..23c96e1a41cd9 100644 --- a/packages/cli/src/services/orchestration/worker/handleCommandMessageWorker.ts +++ b/packages/cli/src/services/orchestration/worker/handleCommandMessageWorker.ts @@ -10,6 +10,7 @@ import { debounceMessageReceiver, getOsCpuString } from '../helpers'; import type { WorkerCommandReceivedHandlerOptions } from './types'; import { Logger } from '@/Logger'; import { N8N_VERSION } from '@/constants'; +import { CommunityPackagesService } from '@/services/communityPackages.service'; export function getWorkerCommandReceivedHandler(options: WorkerCommandReceivedHandlerOptions) { // eslint-disable-next-line complexity @@ -112,6 +113,18 @@ export function getWorkerCommandReceivedHandler(options: WorkerCommandReceivedHa }); } break; + case 'community-package-install': + case 'community-package-update': + case 'community-package-uninstall': + if (!debounceMessageReceiver(message, 500)) return; + const { packageName, packageVersion } = message.payload; + const communityPackagesService = Container.get(CommunityPackagesService); + if (message.command === 'community-package-uninstall') { + await communityPackagesService.removeNpmPackage(packageName); + } else { + await communityPackagesService.installOrUpdateNpmPackage(packageName, packageVersion); + } + break; case 'reloadLicense': if (!debounceMessageReceiver(message, 500)) return; await Container.get(License).reload(); diff --git a/packages/cli/src/services/orchestration/worker/types.ts b/packages/cli/src/services/orchestration/worker/types.ts index 351c56394a31c..84c515466e2c2 100644 --- a/packages/cli/src/services/orchestration/worker/types.ts +++ b/packages/cli/src/services/orchestration/worker/types.ts @@ -1,11 +1,12 @@ import type { ExecutionStatus, WorkflowExecuteMode } from 'n8n-workflow'; import type { RedisServicePubSubPublisher } from '../../redis/RedisServicePubSubPublisher'; +import type { RunningJobSummary } from '@/scaling/types'; export interface WorkerCommandReceivedHandlerOptions { queueModeId: string; redisPublisher: RedisServicePubSubPublisher; - getRunningJobIds: () => string[]; - getRunningJobsSummary: () => WorkerJobStatusSummary[]; + getRunningJobIds: () => Array; + getRunningJobsSummary: () => RunningJobSummary[]; } export interface WorkerJobStatusSummary { diff --git a/packages/cli/src/services/pruning.service.ts b/packages/cli/src/services/pruning.service.ts index 78c0aad03750c..7f824836ae4ee 100644 --- a/packages/cli/src/services/pruning.service.ts +++ b/packages/cli/src/services/pruning.service.ts @@ -1,5 +1,5 @@ import { Service } from 'typedi'; -import { BinaryDataService } from 'n8n-core'; +import { BinaryDataService, InstanceSettings } from 'n8n-core'; import { inTest, TIME } from '@/constants'; import config from '@/config'; import { ExecutionRepository } from '@db/repositories/execution.repository'; @@ -25,6 +25,7 @@ export class PruningService { constructor( private readonly logger: Logger, + private readonly instanceSettings: InstanceSettings, private readonly executionRepository: ExecutionRepository, private readonly binaryDataService: BinaryDataService, private readonly orchestrationService: OrchestrationService, @@ -56,7 +57,7 @@ export class PruningService { if ( config.getEnv('multiMainSetup.enabled') && config.getEnv('generic.instanceType') === 'main' && - config.getEnv('instanceRole') === 'follower' + this.instanceSettings.isFollower ) { return false; } diff --git a/packages/cli/src/services/redis/RedisConstants.ts b/packages/cli/src/services/redis/RedisConstants.ts index 281817b9c719f..038e94e9ce615 100644 --- a/packages/cli/src/services/redis/RedisConstants.ts +++ b/packages/cli/src/services/redis/RedisConstants.ts @@ -1,6 +1,2 @@ -export const EVENT_BUS_REDIS_STREAM = 'n8n:eventstream'; -export const COMMAND_REDIS_STREAM = 'n8n:commandstream'; -export const WORKER_RESPONSE_REDIS_STREAM = 'n8n:workerstream'; export const COMMAND_REDIS_CHANNEL = 'n8n.commands'; export const WORKER_RESPONSE_REDIS_CHANNEL = 'n8n.worker-response'; -export const WORKER_RESPONSE_REDIS_LIST = 'n8n:list:worker-response'; diff --git a/packages/cli/src/services/redis/RedisServiceCommands.ts b/packages/cli/src/services/redis/RedisServiceCommands.ts index 009f39ef650eb..a8ae41c11390b 100644 --- a/packages/cli/src/services/redis/RedisServiceCommands.ts +++ b/packages/cli/src/services/redis/RedisServiceCommands.ts @@ -7,6 +7,9 @@ export type RedisServiceCommand = | 'stopWorker' | 'reloadLicense' | 'reloadExternalSecretsProviders' + | 'community-package-install' + | 'community-package-update' + | 'community-package-uninstall' | 'display-workflow-activation' // multi-main only | 'display-workflow-deactivation' // multi-main only | 'add-webhooks-triggers-and-pollers' // multi-main only @@ -26,7 +29,11 @@ export type RedisServiceBaseCommand = senderId: string; command: Exclude< RedisServiceCommand, - 'relay-execution-lifecycle-event' | 'clear-test-webhooks' + | 'relay-execution-lifecycle-event' + | 'clear-test-webhooks' + | 'community-package-install' + | 'community-package-update' + | 'community-package-uninstall' >; payload?: { [key: string]: string | number | boolean | string[] | number[] | boolean[]; @@ -41,6 +48,14 @@ export type RedisServiceBaseCommand = senderId: string; command: 'clear-test-webhooks'; payload: { webhookKey: string; workflowEntity: IWorkflowDb; pushRef: string }; + } + | { + senderId: string; + command: + | 'community-package-install' + | 'community-package-update' + | 'community-package-uninstall'; + payload: { packageName: string; packageVersion: string }; }; export type RedisServiceWorkerResponseObject = { diff --git a/packages/cli/src/services/redis/redis-client.service.ts b/packages/cli/src/services/redis/redis-client.service.ts index 7363a9c9b7ec8..b5c86523e006a 100644 --- a/packages/cli/src/services/redis/redis-client.service.ts +++ b/packages/cli/src/services/redis/redis-client.service.ts @@ -1,17 +1,20 @@ import { Service } from 'typedi'; -import config from '@/config'; import { Logger } from '@/Logger'; import ioRedis from 'ioredis'; import type { Cluster, RedisOptions } from 'ioredis'; import type { RedisClientType } from './RedisServiceBaseClasses'; import { OnShutdown } from '@/decorators/OnShutdown'; import { LOWEST_SHUTDOWN_PRIORITY } from '@/constants'; +import { GlobalConfig } from '@n8n/config'; @Service() export class RedisClientService { private readonly clients = new Set(); - constructor(private readonly logger: Logger) {} + constructor( + private readonly logger: Logger, + private readonly globalConfig: GlobalConfig, + ) {} createClient(arg: { type: RedisClientType; extraOptions?: RedisOptions }) { const client = @@ -57,7 +60,7 @@ export class RedisClientService { }) { const options = this.getOptions({ extraOptions }); - const { host, port } = config.getEnv('queue.bull.redis'); + const { host, port } = this.globalConfig.queue.bull.redis; options.host = host; options.port = port; @@ -87,7 +90,7 @@ export class RedisClientService { } private getOptions({ extraOptions }: { extraOptions?: RedisOptions }) { - const { username, password, db, tls } = config.getEnv('queue.bull.redis'); + const { username, password, db, tls } = this.globalConfig.queue.bull.redis; /** * Disabling ready check allows quick reconnection to Redis if Redis becomes @@ -124,7 +127,7 @@ export class RedisClientService { private retryStrategy() { const RETRY_INTERVAL = 500; // ms const RESET_LENGTH = 30_000; // ms - const MAX_TIMEOUT = config.getEnv('queue.bull.redis.timeoutThreshold'); + const MAX_TIMEOUT = this.globalConfig.queue.bull.redis.timeoutThreshold; let lastAttemptTs = 0; let cumulativeTimeout = 0; @@ -152,8 +155,7 @@ export class RedisClientService { } private clusterNodes() { - return config - .getEnv('queue.bull.redis.clusterNodes') + return this.globalConfig.queue.bull.redis.clusterNodes .split(',') .filter((pair) => pair.trim().length > 0) .map((pair) => { diff --git a/packages/cli/src/services/user.service.ts b/packages/cli/src/services/user.service.ts index 007054c6aa980..19b49fdb1927e 100644 --- a/packages/cli/src/services/user.service.ts +++ b/packages/cli/src/services/user.service.ts @@ -1,4 +1,4 @@ -import { Container, Service } from 'typedi'; +import { Service } from 'typedi'; import type { IUserSettings } from 'n8n-workflow'; import { ApplicationError, ErrorReporterProxy as ErrorReporter } from 'n8n-workflow'; @@ -8,11 +8,10 @@ import type { Invitation, PublicUser } from '@/Interfaces'; import type { PostHogClient } from '@/posthog'; import { Logger } from '@/Logger'; import { UserManagementMailer } from '@/UserManagement/email'; -import { InternalHooks } from '@/InternalHooks'; import { UrlService } from '@/services/url.service'; import type { UserRequest } from '@/requests'; import { InternalServerError } from '@/errors/response-errors/internal-server.error'; -import { EventService } from '@/eventbus/event.service'; +import { EventService } from '@/events/event.service'; @Service() export class UserService { @@ -144,32 +143,28 @@ export class UserService { if (result.emailSent) { invitedUser.user.emailSent = true; delete invitedUser.user?.inviteAcceptUrl; - Container.get(InternalHooks).onUserTransactionalEmail({ - user_id: id, - message_type: 'New user invite', - public_api: false, + + this.eventService.emit('user-transactional-email-sent', { + userId: id, + messageType: 'New user invite', + publicApi: false, }); } - Container.get(InternalHooks).onUserInvite({ - user: owner, - target_user_id: Object.values(toInviteUsers), - public_api: false, - email_sent: result.emailSent, - invitee_role: role, // same role for all invited users - }); this.eventService.emit('user-invited', { user: owner, targetUserId: Object.values(toInviteUsers), + publicApi: false, + emailSent: result.emailSent, + inviteeRole: role, // same role for all invited users }); } catch (e) { if (e instanceof Error) { - Container.get(InternalHooks).onEmailFailed({ + this.eventService.emit('email-failed', { user: owner, - message_type: 'New user invite', - public_api: false, + messageType: 'New user invite', + publicApi: false, }); - this.eventService.emit('email-failed', { user: owner, messageType: 'New user invite' }); this.logger.error('Failed to send email', { userId: owner.id, inviteAcceptUrl, diff --git a/packages/cli/src/services/workflow-statistics.service.ts b/packages/cli/src/services/workflow-statistics.service.ts index 9311ef885dfbd..262dae4f1c545 100644 --- a/packages/cli/src/services/workflow-statistics.service.ts +++ b/packages/cli/src/services/workflow-statistics.service.ts @@ -6,6 +6,7 @@ import { UserService } from '@/services/user.service'; import { Logger } from '@/Logger'; import { OwnershipService } from './ownership.service'; import { TypedEmitter } from '@/TypedEmitter'; +import { EventService } from '@/events/event.service'; type WorkflowStatisticsEvents = { nodeFetchedData: { workflowId: string; node: INode }; @@ -31,6 +32,7 @@ export class WorkflowStatisticsService extends TypedEmitter { metrics = Object.assign(metrics, { - credential_type: credName, - credential_id: credDetails.id, + credentialType: credName, + credentialId: credDetails.id, }); }); } - // Send metrics to posthog - this.emit('telemetry.onFirstWorkflowDataLoad', metrics); + this.eventService.emit('first-workflow-data-loaded', metrics); } } diff --git a/packages/cli/test/unit/shutdown/Shutdown.service.test.ts b/packages/cli/src/shutdown/__tests__/Shutdown.service.test.ts similarity index 100% rename from packages/cli/test/unit/shutdown/Shutdown.service.test.ts rename to packages/cli/src/shutdown/__tests__/Shutdown.service.test.ts diff --git a/packages/cli/test/unit/sso/saml/saml.service.ee.test.ts b/packages/cli/src/sso/saml/__tests__/saml.service.ee.test.ts similarity index 97% rename from packages/cli/test/unit/sso/saml/saml.service.ee.test.ts rename to packages/cli/src/sso/saml/__tests__/saml.service.ee.test.ts index 9ba6ddaf2a6bd..89d693fc98037 100644 --- a/packages/cli/test/unit/sso/saml/saml.service.ee.test.ts +++ b/packages/cli/src/sso/saml/__tests__/saml.service.ee.test.ts @@ -1,7 +1,7 @@ import { mock } from 'jest-mock-extended'; import type express from 'express'; import { SamlService } from '@/sso/saml/saml.service.ee'; -import { mockInstance } from '../../../shared/mocking'; +import { mockInstance } from '@test/mocking'; import { UrlService } from '@/services/url.service'; import { Logger } from '@/Logger'; import type { IdentityProviderInstance, ServiceProviderInstance } from 'samlify'; diff --git a/packages/cli/test/unit/sso/saml/samlHelpers.test.ts b/packages/cli/src/sso/saml/__tests__/samlHelpers.test.ts similarity index 96% rename from packages/cli/test/unit/sso/saml/samlHelpers.test.ts rename to packages/cli/src/sso/saml/__tests__/samlHelpers.test.ts index f6c35ff67e514..778b1a0857156 100644 --- a/packages/cli/test/unit/sso/saml/samlHelpers.test.ts +++ b/packages/cli/src/sso/saml/__tests__/samlHelpers.test.ts @@ -2,7 +2,7 @@ import { User } from '@/databases/entities/User'; import { generateNanoId } from '@/databases/utils/generators'; import * as helpers from '@/sso/saml/samlHelpers'; import type { SamlUserAttributes } from '@/sso/saml/types/samlUserAttributes'; -import { mockInstance } from '../../../shared/mocking'; +import { mockInstance } from '@test/mocking'; import { UserRepository } from '@/databases/repositories/user.repository'; import type { AuthIdentity } from '@/databases/entities/AuthIdentity'; import { AuthIdentityRepository } from '@/databases/repositories/authIdentity.repository'; diff --git a/packages/cli/src/sso/saml/routes/saml.controller.ee.ts b/packages/cli/src/sso/saml/routes/saml.controller.ee.ts index 344bd34e92b72..8169ee317bfee 100644 --- a/packages/cli/src/sso/saml/routes/saml.controller.ee.ts +++ b/packages/cli/src/sso/saml/routes/saml.controller.ee.ts @@ -27,7 +27,7 @@ import { import { SamlService } from '../saml.service.ee'; import { SamlConfiguration } from '../types/requests'; import { getInitSSOFormView } from '../views/initSsoPost'; -import { EventService } from '@/eventbus/event.service'; +import { EventService } from '@/events/event.service'; @RestController('/sso/saml') export class SamlController { diff --git a/packages/cli/test/unit/Telemetry.test.ts b/packages/cli/src/telemetry/__tests__/telemetry.test.ts similarity index 68% rename from packages/cli/test/unit/Telemetry.test.ts rename to packages/cli/src/telemetry/__tests__/telemetry.test.ts index af8445c814663..ca30c86ec4ade 100644 --- a/packages/cli/test/unit/Telemetry.test.ts +++ b/packages/cli/src/telemetry/__tests__/telemetry.test.ts @@ -1,11 +1,10 @@ import type RudderStack from '@rudderstack/rudder-sdk-node'; import { Telemetry } from '@/telemetry'; import config from '@/config'; -import { flushPromises } from './Helpers'; import { PostHogClient } from '@/posthog'; import { mock } from 'jest-mock-extended'; import { InstanceSettings } from 'n8n-core'; -import { mockInstance } from '../shared/mocking'; +import { mockInstance } from '@test/mocking'; jest.unmock('@/telemetry'); jest.mock('@/posthog'); @@ -35,7 +34,7 @@ describe('Telemetry', () => { jest.clearAllTimers(); jest.useRealTimers(); startPulseSpy.mockRestore(); - await telemetry.trackN8nStop(); + await telemetry.stopTracking(); }); beforeEach(async () => { @@ -50,14 +49,7 @@ describe('Telemetry', () => { }); afterEach(async () => { - await telemetry.trackN8nStop(); - }); - - describe('trackN8nStop', () => { - test('should call track method', async () => { - await telemetry.trackN8nStop(); - expect(spyTrack).toHaveBeenCalledTimes(1); - }); + await telemetry.stopTracking(); }); describe('trackWorkflowExecution', () => { @@ -266,162 +258,6 @@ describe('Telemetry', () => { expect(execBuffer['2'].prod_success?.first).toEqual(execTime1); }); }); - - describe('pulse', () => { - let pulseSpy: jest.SpyInstance; - beforeAll(() => { - startPulseSpy.mockRestore(); - }); - - beforeEach(() => { - fakeJestSystemTime(testDateTime); - pulseSpy = jest.spyOn(Telemetry.prototype as any, 'pulse').mockName('pulseSpy'); - }); - - afterEach(() => { - pulseSpy.mockClear(); - }); - - xtest('should trigger pulse in intervals', async () => { - expect(pulseSpy).toBeCalledTimes(0); - - jest.advanceTimersToNextTimer(); - await flushPromises(); - - expect(pulseSpy).toBeCalledTimes(1); - expect(spyTrack).toHaveBeenCalledTimes(1); - expect(spyTrack).toHaveBeenCalledWith('pulse', { - plan_name_current: 'Community', - quota: -1, - usage: 0, - }); - - jest.advanceTimersToNextTimer(); - - await flushPromises(); - - expect(pulseSpy).toBeCalledTimes(2); - expect(spyTrack).toHaveBeenCalledTimes(2); - expect(spyTrack).toHaveBeenCalledWith('pulse', { - plan_name_current: 'Community', - quota: -1, - usage: 0, - }); - }); - - xtest('should track workflow counts correctly', async () => { - expect(pulseSpy).toBeCalledTimes(0); - - let execBuffer = telemetry.getCountsBuffer(); - - // expect clear counters on start - expect(Object.keys(execBuffer).length).toBe(0); - - const payload = { - workflow_id: '1', - is_manual: true, - success: true, - error_node_type: 'custom-nodes-base.node-type', - }; - - telemetry.trackWorkflowExecution(payload); - telemetry.trackWorkflowExecution(payload); - - payload.is_manual = false; - payload.success = true; - telemetry.trackWorkflowExecution(payload); - telemetry.trackWorkflowExecution(payload); - - payload.is_manual = true; - payload.success = false; - telemetry.trackWorkflowExecution(payload); - telemetry.trackWorkflowExecution(payload); - - payload.is_manual = false; - payload.success = false; - telemetry.trackWorkflowExecution(payload); - telemetry.trackWorkflowExecution(payload); - - payload.workflow_id = '2'; - telemetry.trackWorkflowExecution(payload); - telemetry.trackWorkflowExecution(payload); - - expect(spyTrack).toHaveBeenCalledTimes(0); - expect(pulseSpy).toBeCalledTimes(0); - - jest.advanceTimersToNextTimer(); - - execBuffer = telemetry.getCountsBuffer(); - - await flushPromises(); - - expect(pulseSpy).toBeCalledTimes(1); - expect(spyTrack).toHaveBeenCalledTimes(3); - expect(spyTrack).toHaveBeenNthCalledWith( - 1, - 'Workflow execution count', - { - event_version: '2', - workflow_id: '1', - user_id: undefined, - manual_error: { - count: 2, - first: testDateTime, - }, - manual_success: { - count: 2, - first: testDateTime, - }, - prod_error: { - count: 2, - first: testDateTime, - }, - prod_success: { - count: 2, - first: testDateTime, - }, - }, - { withPostHog: true }, - ); - expect(spyTrack).toHaveBeenNthCalledWith( - 2, - 'Workflow execution count', - { - event_version: '2', - workflow_id: '2', - user_id: undefined, - prod_error: { - count: 2, - first: testDateTime, - }, - }, - { withPostHog: true }, - ); - expect(spyTrack).toHaveBeenNthCalledWith(3, 'pulse', { - plan_name_current: 'Community', - quota: -1, - usage: 0, - }); - expect(Object.keys(execBuffer).length).toBe(0); - - // Adding a second step here because we believe PostHog may use timers for sending data - // and adding posthog to the above metric was causing the pulseSpy timer to not be ran - jest.advanceTimersToNextTimer(); - - execBuffer = telemetry.getCountsBuffer(); - expect(Object.keys(execBuffer).length).toBe(0); - - // @TODO: Flushing promises here is not working - - // expect(pulseSpy).toBeCalledTimes(2); - // expect(spyTrack).toHaveBeenCalledTimes(4); - // expect(spyTrack).toHaveBeenNthCalledWith(4, 'pulse', { - // plan_name_current: 'Community', - // quota: -1, - // usage: 0, - // }); - }); - }); }); const fakeJestSystemTime = (dateTime: string | Date): Date => { diff --git a/packages/cli/src/telemetry/index.ts b/packages/cli/src/telemetry/index.ts index 2d762297304cc..e2b93cdd0d7a9 100644 --- a/packages/cli/src/telemetry/index.ts +++ b/packages/cli/src/telemetry/index.ts @@ -9,12 +9,13 @@ import config from '@/config'; import type { IExecutionTrackProperties } from '@/Interfaces'; import { Logger } from '@/Logger'; import { License } from '@/License'; -import { N8N_VERSION } from '@/constants'; +import { LOWEST_SHUTDOWN_PRIORITY, N8N_VERSION } from '@/constants'; import { WorkflowRepository } from '@db/repositories/workflow.repository'; import { SourceControlPreferencesService } from '../environments/sourceControl/sourceControlPreferences.service.ee'; import { UserRepository } from '@db/repositories/user.repository'; import { ProjectRepository } from '@/databases/repositories/project.repository'; import { ProjectRelationRepository } from '@/databases/repositories/projectRelation.repository'; +import { OnShutdown } from '@/decorators/OnShutdown'; type ExecutionTrackDataKey = 'manual_error' | 'manual_success' | 'prod_error' | 'prod_success'; @@ -167,11 +168,10 @@ export class Telemetry { } } - async trackN8nStop(): Promise { + @OnShutdown(LOWEST_SHUTDOWN_PRIORITY) + async stopTracking(): Promise { clearInterval(this.pulseIntervalReference); - this.track('User instance stopped'); - await Promise.all([this.postHog.stop(), this.rudderStack?.flush()]); } diff --git a/packages/cli/src/telemetry/telemetry-event-relay.service.ts b/packages/cli/src/telemetry/telemetry-event-relay.service.ts deleted file mode 100644 index 9077abdf9ce23..0000000000000 --- a/packages/cli/src/telemetry/telemetry-event-relay.service.ts +++ /dev/null @@ -1,587 +0,0 @@ -import { Service } from 'typedi'; -import { EventService } from '@/eventbus/event.service'; -import type { Event } from '@/eventbus/event.types'; -import { Telemetry } from '.'; -import config from '@/config'; -import os from 'node:os'; -import { License } from '@/License'; -import { GlobalConfig } from '@n8n/config'; -import { N8N_VERSION } from '@/constants'; -import { WorkflowRepository } from '@/databases/repositories/workflow.repository'; -import { TelemetryHelpers } from 'n8n-workflow'; -import { NodeTypes } from '@/NodeTypes'; -import { SharedWorkflowRepository } from '@/databases/repositories/sharedWorkflow.repository'; -import { ProjectRelationRepository } from '@/databases/repositories/projectRelation.repository'; - -@Service() -export class TelemetryEventRelay { - constructor( - private readonly eventService: EventService, - private readonly telemetry: Telemetry, - private readonly license: License, - private readonly globalConfig: GlobalConfig, - private readonly workflowRepository: WorkflowRepository, - private readonly nodeTypes: NodeTypes, - private readonly sharedWorkflowRepository: SharedWorkflowRepository, - private readonly projectRelationRepository: ProjectRelationRepository, - ) {} - - async init() { - if (!config.getEnv('diagnostics.enabled')) return; - - await this.telemetry.init(); - - this.setupHandlers(); - } - - private setupHandlers() { - this.eventService.on('server-started', async () => await this.serverStarted()); - - this.eventService.on('team-project-updated', (event) => this.teamProjectUpdated(event)); - this.eventService.on('team-project-deleted', (event) => this.teamProjectDeleted(event)); - this.eventService.on('team-project-created', (event) => this.teamProjectCreated(event)); - this.eventService.on('source-control-settings-updated', (event) => - this.sourceControlSettingsUpdated(event), - ); - this.eventService.on('source-control-user-started-pull-ui', (event) => - this.sourceControlUserStartedPullUi(event), - ); - this.eventService.on('source-control-user-finished-pull-ui', (event) => - this.sourceControlUserFinishedPullUi(event), - ); - this.eventService.on('source-control-user-pulled-api', (event) => - this.sourceControlUserPulledApi(event), - ); - this.eventService.on('source-control-user-started-push-ui', (event) => - this.sourceControlUserStartedPushUi(event), - ); - this.eventService.on('source-control-user-finished-push-ui', (event) => - this.sourceControlUserFinishedPushUi(event), - ); - this.eventService.on('license-renewal-attempted', (event) => { - this.licenseRenewalAttempted(event); - }); - this.eventService.on('variable-created', () => this.variableCreated()); - this.eventService.on('external-secrets-provider-settings-saved', (event) => { - this.externalSecretsProviderSettingsSaved(event); - }); - this.eventService.on('public-api-invoked', (event) => { - this.publicApiInvoked(event); - }); - this.eventService.on('public-api-key-created', (event) => { - this.publicApiKeyCreated(event); - }); - this.eventService.on('public-api-key-deleted', (event) => { - this.publicApiKeyDeleted(event); - }); - this.eventService.on('community-package-installed', (event) => { - this.communityPackageInstalled(event); - }); - this.eventService.on('community-package-updated', (event) => { - this.communityPackageUpdated(event); - }); - this.eventService.on('community-package-deleted', (event) => { - this.communityPackageDeleted(event); - }); - - this.eventService.on('credentials-created', (event) => { - this.credentialsCreated(event); - }); - this.eventService.on('credentials-shared', (event) => { - this.credentialsShared(event); - }); - this.eventService.on('credentials-updated', (event) => { - this.credentialsUpdated(event); - }); - this.eventService.on('credentials-deleted', (event) => { - this.credentialsDeleted(event); - }); - this.eventService.on('ldap-general-sync-finished', (event) => { - this.ldapGeneralSyncFinished(event); - }); - this.eventService.on('ldap-settings-updated', (event) => { - this.ldapSettingsUpdated(event); - }); - this.eventService.on('ldap-login-sync-failed', (event) => { - this.ldapLoginSyncFailed(event); - }); - this.eventService.on('login-failed-due-to-ldap-disabled', (event) => { - this.loginFailedDueToLdapDisabled(event); - }); - - this.eventService.on('workflow-created', (event) => { - this.workflowCreated(event); - }); - this.eventService.on('workflow-deleted', (event) => { - this.workflowDeleted(event); - }); - this.eventService.on('workflow-saved', async (event) => { - await this.workflowSaved(event); - }); - } - - private teamProjectUpdated({ userId, role, members, projectId }: Event['team-project-updated']) { - this.telemetry.track('Project settings updated', { - user_id: userId, - role, - // eslint-disable-next-line @typescript-eslint/no-shadow - members: members.map(({ userId: user_id, role }) => ({ user_id, role })), - project_id: projectId, - }); - } - - private teamProjectDeleted({ - userId, - role, - projectId, - removalType, - targetProjectId, - }: Event['team-project-deleted']) { - this.telemetry.track('User deleted project', { - user_id: userId, - role, - project_id: projectId, - removal_type: removalType, - target_project_id: targetProjectId, - }); - } - - private teamProjectCreated({ userId, role }: Event['team-project-created']) { - this.telemetry.track('User created project', { - user_id: userId, - role, - }); - } - - private sourceControlSettingsUpdated({ - branchName, - readOnlyInstance, - repoType, - connected, - }: Event['source-control-settings-updated']) { - this.telemetry.track('User updated source control settings', { - branch_name: branchName, - read_only_instance: readOnlyInstance, - repo_type: repoType, - connected, - }); - } - - private sourceControlUserStartedPullUi({ - workflowUpdates, - workflowConflicts, - credConflicts, - }: Event['source-control-user-started-pull-ui']) { - this.telemetry.track('User started pull via UI', { - workflow_updates: workflowUpdates, - workflow_conflicts: workflowConflicts, - cred_conflicts: credConflicts, - }); - } - - private sourceControlUserFinishedPullUi({ - workflowUpdates, - }: Event['source-control-user-finished-pull-ui']) { - this.telemetry.track('User finished pull via UI', { - workflow_updates: workflowUpdates, - }); - } - - private sourceControlUserPulledApi({ - workflowUpdates, - forced, - }: Event['source-control-user-pulled-api']) { - console.log('source-control-user-pulled-api', { - workflow_updates: workflowUpdates, - forced, - }); - this.telemetry.track('User pulled via API', { - workflow_updates: workflowUpdates, - forced, - }); - } - - private sourceControlUserStartedPushUi({ - workflowsEligible, - workflowsEligibleWithConflicts, - credsEligible, - credsEligibleWithConflicts, - variablesEligible, - }: Event['source-control-user-started-push-ui']) { - this.telemetry.track('User started push via UI', { - workflows_eligible: workflowsEligible, - workflows_eligible_with_conflicts: workflowsEligibleWithConflicts, - creds_eligible: credsEligible, - creds_eligible_with_conflicts: credsEligibleWithConflicts, - variables_eligible: variablesEligible, - }); - } - - private sourceControlUserFinishedPushUi({ - workflowsEligible, - workflowsPushed, - credsPushed, - variablesPushed, - }: Event['source-control-user-finished-push-ui']) { - this.telemetry.track('User finished push via UI', { - workflows_eligible: workflowsEligible, - workflows_pushed: workflowsPushed, - creds_pushed: credsPushed, - variables_pushed: variablesPushed, - }); - } - - private licenseRenewalAttempted({ success }: Event['license-renewal-attempted']) { - this.telemetry.track('Instance attempted to refresh license', { - success, - }); - } - - private variableCreated() { - this.telemetry.track('User created variable'); - } - - private externalSecretsProviderSettingsSaved({ - userId, - vaultType, - isValid, - isNew, - errorMessage, - }: Event['external-secrets-provider-settings-saved']) { - this.telemetry.track('User updated external secrets settings', { - user_id: userId, - vault_type: vaultType, - is_valid: isValid, - is_new: isNew, - error_message: errorMessage, - }); - } - - private publicApiInvoked({ userId, path, method, apiVersion }: Event['public-api-invoked']) { - this.telemetry.track('User invoked API', { - user_id: userId, - path, - method, - api_version: apiVersion, - }); - } - - private publicApiKeyCreated(event: Event['public-api-key-created']) { - const { user, publicApi } = event; - - this.telemetry.track('API key created', { - user_id: user.id, - public_api: publicApi, - }); - } - - private publicApiKeyDeleted(event: Event['public-api-key-deleted']) { - const { user, publicApi } = event; - - this.telemetry.track('API key deleted', { - user_id: user.id, - public_api: publicApi, - }); - } - - private communityPackageInstalled({ - user, - inputString, - packageName, - success, - packageVersion, - packageNodeNames, - packageAuthor, - packageAuthorEmail, - failureReason, - }: Event['community-package-installed']) { - this.telemetry.track('cnr package install finished', { - user_id: user.id, - input_string: inputString, - package_name: packageName, - success, - package_version: packageVersion, - package_node_names: packageNodeNames, - package_author: packageAuthor, - package_author_email: packageAuthorEmail, - failure_reason: failureReason, - }); - } - - private communityPackageUpdated({ - user, - packageName, - packageVersionCurrent, - packageVersionNew, - packageNodeNames, - packageAuthor, - packageAuthorEmail, - }: Event['community-package-updated']) { - this.telemetry.track('cnr package updated', { - user_id: user.id, - package_name: packageName, - package_version_current: packageVersionCurrent, - package_version_new: packageVersionNew, - package_node_names: packageNodeNames, - package_author: packageAuthor, - package_author_email: packageAuthorEmail, - }); - } - - private communityPackageDeleted({ - user, - packageName, - packageVersion, - packageNodeNames, - packageAuthor, - packageAuthorEmail, - }: Event['community-package-deleted']) { - this.telemetry.track('cnr package deleted', { - user_id: user.id, - package_name: packageName, - package_version: packageVersion, - package_node_names: packageNodeNames, - package_author: packageAuthor, - package_author_email: packageAuthorEmail, - }); - } - - private credentialsCreated({ - user, - credentialType, - credentialId, - projectId, - projectType, - }: Event['credentials-created']) { - this.telemetry.track('User created credentials', { - user_id: user.id, - credential_type: credentialType, - credential_id: credentialId, - project_id: projectId, - project_type: projectType, - }); - } - - private credentialsShared({ - user, - credentialType, - credentialId, - userIdSharer, - userIdsShareesAdded, - shareesRemoved, - }: Event['credentials-shared']) { - this.telemetry.track('User updated cred sharing', { - user_id: user.id, - credential_type: credentialType, - credential_id: credentialId, - user_id_sharer: userIdSharer, - user_ids_sharees_added: userIdsShareesAdded, - sharees_removed: shareesRemoved, - }); - } - - private credentialsUpdated({ user, credentialId, credentialType }: Event['credentials-updated']) { - this.telemetry.track('User updated credentials', { - user_id: user.id, - credential_type: credentialType, - credential_id: credentialId, - }); - } - - private credentialsDeleted({ user, credentialId, credentialType }: Event['credentials-deleted']) { - this.telemetry.track('User deleted credentials', { - user_id: user.id, - credential_type: credentialType, - credential_id: credentialId, - }); - } - - private ldapGeneralSyncFinished({ - type, - succeeded, - usersSynced, - error, - }: Event['ldap-general-sync-finished']) { - this.telemetry.track('Ldap general sync finished', { - type, - succeeded, - users_synced: usersSynced, - error, - }); - } - - private ldapSettingsUpdated({ - userId, - loginIdAttribute, - firstNameAttribute, - lastNameAttribute, - emailAttribute, - ldapIdAttribute, - searchPageSize, - searchTimeout, - synchronizationEnabled, - synchronizationInterval, - loginLabel, - loginEnabled, - }: Event['ldap-settings-updated']) { - this.telemetry.track('User updated Ldap settings', { - user_id: userId, - loginIdAttribute, - firstNameAttribute, - lastNameAttribute, - emailAttribute, - ldapIdAttribute, - searchPageSize, - searchTimeout, - synchronizationEnabled, - synchronizationInterval, - loginLabel, - loginEnabled, - }); - } - - private ldapLoginSyncFailed({ error }: Event['ldap-login-sync-failed']) { - this.telemetry.track('Ldap login sync failed', { error }); - } - - private loginFailedDueToLdapDisabled({ userId }: Event['login-failed-due-to-ldap-disabled']) { - this.telemetry.track('User login failed since ldap disabled', { user_ud: userId }); - } - - private workflowCreated({ - user, - workflow, - publicApi, - projectId, - projectType, - }: Event['workflow-created']) { - const { nodeGraph } = TelemetryHelpers.generateNodesGraph(workflow, this.nodeTypes); - - this.telemetry.track('User created workflow', { - user_id: user.id, - workflow_id: workflow.id, - node_graph_string: JSON.stringify(nodeGraph), - public_api: publicApi, - project_id: projectId, - project_type: projectType, - }); - } - - private workflowDeleted({ user, workflowId, publicApi }: Event['workflow-deleted']) { - this.telemetry.track('User deleted workflow', { - user_id: user.id, - workflow_id: workflowId, - public_api: publicApi, - }); - } - - private async workflowSaved({ user, workflow, publicApi }: Event['workflow-saved']) { - const isCloudDeployment = config.getEnv('deployment.type') === 'cloud'; - - const { nodeGraph } = TelemetryHelpers.generateNodesGraph(workflow, this.nodeTypes, { - isCloudDeployment, - }); - - let userRole: 'owner' | 'sharee' | 'member' | undefined = undefined; - const role = await this.sharedWorkflowRepository.findSharingRole(user.id, workflow.id); - if (role) { - userRole = role === 'workflow:owner' ? 'owner' : 'sharee'; - } else { - const workflowOwner = await this.sharedWorkflowRepository.getWorkflowOwningProject( - workflow.id, - ); - - if (workflowOwner) { - const projectRole = await this.projectRelationRepository.findProjectRole({ - userId: user.id, - projectId: workflowOwner.id, - }); - - if (projectRole && projectRole !== 'project:personalOwner') { - userRole = 'member'; - } - } - } - - const notesCount = Object.keys(nodeGraph.notes).length; - const overlappingCount = Object.values(nodeGraph.notes).filter( - (note) => note.overlapping, - ).length; - - this.telemetry.track('User saved workflow', { - user_id: user.id, - workflow_id: workflow.id, - node_graph_string: JSON.stringify(nodeGraph), - notes_count_overlapping: overlappingCount, - notes_count_non_overlapping: notesCount - overlappingCount, - version_cli: N8N_VERSION, - num_tags: workflow.tags?.length ?? 0, - public_api: publicApi, - sharing_role: userRole, - }); - } - - private async serverStarted() { - const cpus = os.cpus(); - const binaryDataConfig = config.getEnv('binaryDataManager'); - - const isS3Selected = config.getEnv('binaryDataManager.mode') === 's3'; - const isS3Available = config.getEnv('binaryDataManager.availableModes').includes('s3'); - const isS3Licensed = this.license.isBinaryDataS3Licensed(); - const authenticationMethod = config.getEnv('userManagement.authenticationMethod'); - - const info = { - version_cli: N8N_VERSION, - db_type: this.globalConfig.database.type, - n8n_version_notifications_enabled: this.globalConfig.versionNotifications.enabled, - n8n_disable_production_main_process: - this.globalConfig.endpoints.disableProductionWebhooksOnMainProcess, - system_info: { - os: { - type: os.type(), - version: os.version(), - }, - memory: os.totalmem() / 1024, - cpus: { - count: cpus.length, - model: cpus[0].model, - speed: cpus[0].speed, - }, - }, - execution_variables: { - executions_mode: config.getEnv('executions.mode'), - executions_timeout: config.getEnv('executions.timeout'), - executions_timeout_max: config.getEnv('executions.maxTimeout'), - executions_data_save_on_error: config.getEnv('executions.saveDataOnError'), - executions_data_save_on_success: config.getEnv('executions.saveDataOnSuccess'), - executions_data_save_on_progress: config.getEnv('executions.saveExecutionProgress'), - executions_data_save_manual_executions: config.getEnv( - 'executions.saveDataManualExecutions', - ), - executions_data_prune: config.getEnv('executions.pruneData'), - executions_data_max_age: config.getEnv('executions.pruneDataMaxAge'), - }, - n8n_deployment_type: config.getEnv('deployment.type'), - n8n_binary_data_mode: binaryDataConfig.mode, - smtp_set_up: this.globalConfig.userManagement.emails.mode === 'smtp', - ldap_allowed: authenticationMethod === 'ldap', - saml_enabled: authenticationMethod === 'saml', - license_plan_name: this.license.getPlanName(), - license_tenant_id: config.getEnv('license.tenantId'), - binary_data_s3: isS3Available && isS3Selected && isS3Licensed, - multi_main_setup_enabled: config.getEnv('multiMainSetup.enabled'), - }; - - const firstWorkflow = await this.workflowRepository.findOne({ - select: ['createdAt'], - order: { createdAt: 'ASC' }, - where: {}, - }); - - this.telemetry.identify(info); - this.telemetry.track('Instance started', { - ...info, - earliest_workflow_created: firstWorkflow?.createdAt, - }); - } -} diff --git a/packages/cli/src/ActiveWebhooks.ts b/packages/cli/src/webhooks/LiveWebhooks.ts similarity index 90% rename from packages/cli/src/ActiveWebhooks.ts rename to packages/cli/src/webhooks/LiveWebhooks.ts index 79626df025f75..3d66b7ee7f796 100644 --- a/packages/cli/src/ActiveWebhooks.ts +++ b/packages/cli/src/webhooks/LiveWebhooks.ts @@ -5,22 +5,27 @@ import type { INode, IWebhookData, IHttpRequestMethods } from 'n8n-workflow'; import { WorkflowRepository } from '@db/repositories/workflow.repository'; import type { - IResponseCallbackData, + IWebhookResponseCallbackData, IWebhookManager, WebhookAccessControlOptions, WebhookRequest, -} from '@/Interfaces'; +} from './webhook.types'; import { Logger } from '@/Logger'; import { NodeTypes } from '@/NodeTypes'; -import { WebhookService } from '@/services/webhook.service'; +import { WebhookService } from '@/webhooks/webhook.service'; import { WebhookNotFoundError } from '@/errors/response-errors/webhook-not-found.error'; import { NotFoundError } from '@/errors/response-errors/not-found.error'; import * as WorkflowExecuteAdditionalData from '@/WorkflowExecuteAdditionalData'; -import * as WebhookHelpers from '@/WebhookHelpers'; +import * as WebhookHelpers from '@/webhooks/WebhookHelpers'; import { WorkflowStaticDataService } from '@/workflows/workflowStaticData.service'; +/** + * Service for handling the execution of live webhooks, i.e. webhooks + * that belong to activated workflows and use the production URL + * (https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.webhook/#webhook-urls) + */ @Service() -export class ActiveWebhooks implements IWebhookManager { +export class LiveWebhooks implements IWebhookManager { constructor( private readonly logger: Logger, private readonly nodeTypes: NodeTypes, @@ -57,7 +62,7 @@ export class ActiveWebhooks implements IWebhookManager { async executeWebhook( request: WebhookRequest, response: Response, - ): Promise { + ): Promise { const httpMethod = request.method; const path = request.params.path; @@ -72,11 +77,9 @@ export class ActiveWebhooks implements IWebhookManager { const pathElements = path.split('/').slice(1); // extracting params from path - // @ts-ignore webhook.webhookPath.split('/').forEach((ele, index) => { if (ele.startsWith(':')) { // write params to req.params - // @ts-ignore request.params[ele.slice(1)] = pathElements[index]; } }); diff --git a/packages/cli/src/TestWebhooks.ts b/packages/cli/src/webhooks/TestWebhooks.ts similarity index 94% rename from packages/cli/src/TestWebhooks.ts rename to packages/cli/src/webhooks/TestWebhooks.ts index 827226ee546be..8cf1b4292989b 100644 --- a/packages/cli/src/TestWebhooks.ts +++ b/packages/cli/src/webhooks/TestWebhooks.ts @@ -8,26 +8,30 @@ import type { IRunData, } from 'n8n-workflow'; import type { - IResponseCallbackData, + IWebhookResponseCallbackData, IWebhookManager, - IWorkflowDb, WebhookAccessControlOptions, WebhookRequest, -} from '@/Interfaces'; +} from './webhook.types'; import { Push } from '@/push'; import { NodeTypes } from '@/NodeTypes'; -import * as WebhookHelpers from '@/WebhookHelpers'; +import * as WebhookHelpers from '@/webhooks/WebhookHelpers'; import { TEST_WEBHOOK_TIMEOUT } from '@/constants'; import { NotFoundError } from '@/errors/response-errors/not-found.error'; import { WorkflowMissingIdError } from '@/errors/workflow-missing-id.error'; import { WebhookNotFoundError } from '@/errors/response-errors/webhook-not-found.error'; import * as NodeExecuteFunctions from 'n8n-core'; -import { removeTrailingSlash } from './utils'; -import type { TestWebhookRegistration } from '@/services/test-webhook-registrations.service'; -import { TestWebhookRegistrationsService } from '@/services/test-webhook-registrations.service'; +import { removeTrailingSlash } from '@/utils'; +import type { TestWebhookRegistration } from '@/webhooks/test-webhook-registrations.service'; +import { TestWebhookRegistrationsService } from '@/webhooks/test-webhook-registrations.service'; import { OrchestrationService } from '@/services/orchestration.service'; import * as WorkflowExecuteAdditionalData from '@/WorkflowExecuteAdditionalData'; +import type { IWorkflowDb } from '@/Interfaces'; +/** + * Service for handling the execution of webhooks of manual executions + * that use the [Test URL](https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.webhook/#webhook-urls). + */ @Service() export class TestWebhooks implements IWebhookManager { constructor( @@ -46,7 +50,7 @@ export class TestWebhooks implements IWebhookManager { async executeWebhook( request: WebhookRequest, response: express.Response, - ): Promise { + ): Promise { const httpMethod = request.method; let path = removeTrailingSlash(request.params.path); @@ -117,7 +121,7 @@ export class TestWebhooks implements IWebhookManager { undefined, // executionId request, response, - (error: Error | null, data: IResponseCallbackData) => { + (error: Error | null, data: IWebhookResponseCallbackData) => { if (error !== null) reject(error); else resolve(data); }, diff --git a/packages/cli/src/WaitingWebhooks.ts b/packages/cli/src/webhooks/WaitingWebhooks.ts similarity index 88% rename from packages/cli/src/WaitingWebhooks.ts rename to packages/cli/src/webhooks/WaitingWebhooks.ts index d795c948f142f..367635c45ec64 100644 --- a/packages/cli/src/WaitingWebhooks.ts +++ b/packages/cli/src/webhooks/WaitingWebhooks.ts @@ -2,21 +2,25 @@ import { NodeHelpers, Workflow } from 'n8n-workflow'; import { Service } from 'typedi'; import type express from 'express'; -import * as WebhookHelpers from '@/WebhookHelpers'; +import * as WebhookHelpers from '@/webhooks/WebhookHelpers'; import { NodeTypes } from '@/NodeTypes'; import type { - IExecutionResponse, - IResponseCallbackData, + IWebhookResponseCallbackData, IWebhookManager, - IWorkflowDb, WaitingWebhookRequest, -} from '@/Interfaces'; +} from './webhook.types'; import * as WorkflowExecuteAdditionalData from '@/WorkflowExecuteAdditionalData'; import { ExecutionRepository } from '@db/repositories/execution.repository'; import { Logger } from '@/Logger'; -import { ConflictError } from './errors/response-errors/conflict.error'; -import { NotFoundError } from './errors/response-errors/not-found.error'; - +import { ConflictError } from '@/errors/response-errors/conflict.error'; +import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import type { IExecutionResponse, IWorkflowDb } from '@/Interfaces'; + +/** + * Service for handling the execution of webhooks of Wait nodes that use the + * [Resume On Webhook Call](https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.wait/#on-webhook-call) + * feature. + */ @Service() export class WaitingWebhooks implements IWebhookManager { protected includeForms = false; @@ -40,7 +44,7 @@ export class WaitingWebhooks implements IWebhookManager { async executeWebhook( req: WaitingWebhookRequest, res: express.Response, - ): Promise { + ): Promise { const { path: executionId, suffix } = req.params; this.logReceivedWebhook(req.method, executionId); diff --git a/packages/cli/src/WebhookHelpers.ts b/packages/cli/src/webhooks/WebhookHelpers.ts similarity index 84% rename from packages/cli/src/WebhookHelpers.ts rename to packages/cli/src/webhooks/WebhookHelpers.ts index eafec28133326..1b8bc2b7e986d 100644 --- a/packages/cli/src/WebhookHelpers.ts +++ b/packages/cli/src/webhooks/WebhookHelpers.ts @@ -20,8 +20,6 @@ import type { IDataObject, IDeferredPromise, IExecuteData, - IExecuteResponsePromiseData, - IHttpRequestMethods, IN8nHttpFullResponse, INode, IPinData, @@ -42,119 +40,20 @@ import { NodeHelpers, } from 'n8n-workflow'; -import type { - IExecutionDb, - IResponseCallbackData, - IWebhookManager, - IWorkflowDb, - IWorkflowExecutionDataProcess, - WebhookCORSRequest, - WebhookRequest, -} from '@/Interfaces'; -import * as ResponseHelper from '@/ResponseHelper'; +import type { IWebhookResponseCallbackData, WebhookRequest } from './webhook.types'; import * as WorkflowHelpers from '@/WorkflowHelpers'; import { WorkflowRunner } from '@/WorkflowRunner'; import * as WorkflowExecuteAdditionalData from '@/WorkflowExecuteAdditionalData'; import { ActiveExecutions } from '@/ActiveExecutions'; import { WorkflowStatisticsService } from '@/services/workflow-statistics.service'; -import { OwnershipService } from './services/ownership.service'; -import { parseBody } from './middlewares'; -import { Logger } from './Logger'; -import { NotFoundError } from './errors/response-errors/not-found.error'; -import { InternalServerError } from './errors/response-errors/internal-server.error'; -import { UnprocessableRequestError } from './errors/response-errors/unprocessable.error'; -import type { Project } from './databases/entities/Project'; - -export const WEBHOOK_METHODS: IHttpRequestMethods[] = [ - 'DELETE', - 'GET', - 'HEAD', - 'PATCH', - 'POST', - 'PUT', -]; - -export const webhookRequestHandler = - (webhookManager: IWebhookManager) => - async (req: WebhookRequest | WebhookCORSRequest, res: express.Response) => { - const { path } = req.params; - const method = req.method; - - if (method !== 'OPTIONS' && !WEBHOOK_METHODS.includes(method)) { - return ResponseHelper.sendErrorResponse( - res, - new Error(`The method ${method} is not supported.`), - ); - } - - // Setup CORS headers only if the incoming request has an `origin` header - if ('origin' in req.headers) { - if (webhookManager.getWebhookMethods) { - try { - const allowedMethods = await webhookManager.getWebhookMethods(path); - res.header('Access-Control-Allow-Methods', ['OPTIONS', ...allowedMethods].join(', ')); - } catch (error) { - return ResponseHelper.sendErrorResponse(res, error as Error); - } - } - - const requestedMethod = - method === 'OPTIONS' - ? (req.headers['access-control-request-method'] as IHttpRequestMethods) - : method; - if (webhookManager.findAccessControlOptions && requestedMethod) { - const options = await webhookManager.findAccessControlOptions(path, requestedMethod); - const { allowedOrigins } = options ?? {}; - - if (allowedOrigins && allowedOrigins !== '*' && allowedOrigins !== req.headers.origin) { - const originsList = allowedOrigins.split(','); - const defaultOrigin = originsList[0]; - - if (originsList.length === 1) { - res.header('Access-Control-Allow-Origin', defaultOrigin); - } - - if (originsList.includes(req.headers.origin as string)) { - res.header('Access-Control-Allow-Origin', req.headers.origin); - } else { - res.header('Access-Control-Allow-Origin', defaultOrigin); - } - } else { - res.header('Access-Control-Allow-Origin', req.headers.origin); - } - - if (method === 'OPTIONS') { - res.header('Access-Control-Max-Age', '300'); - const requestedHeaders = req.headers['access-control-request-headers']; - if (requestedHeaders?.length) { - res.header('Access-Control-Allow-Headers', requestedHeaders); - } - } - } - } - - if (method === 'OPTIONS') { - return ResponseHelper.sendSuccessResponse(res, {}, true, 204); - } - - let response; - try { - response = await webhookManager.executeWebhook(req, res); - } catch (error) { - return ResponseHelper.sendErrorResponse(res, error as Error); - } - - // Don't respond, if already responded - if (response.noWebhookResponse !== true) { - ResponseHelper.sendSuccessResponse( - res, - response.data, - true, - response.responseCode, - response.headers, - ); - } - }; +import { OwnershipService } from '@/services/ownership.service'; +import { parseBody } from '@/middlewares'; +import { Logger } from '@/Logger'; +import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { InternalServerError } from '@/errors/response-errors/internal-server.error'; +import { UnprocessableRequestError } from '@/errors/response-errors/unprocessable.error'; +import type { Project } from '@/databases/entities/Project'; +import type { IExecutionDb, IWorkflowDb, IWorkflowExecutionDataProcess } from '@/Interfaces'; /** * Returns all the webhooks which should be created for the given workflow @@ -192,18 +91,6 @@ export function getWorkflowWebhooks( return returnData; } -export function encodeWebhookResponse( - response: IExecuteResponsePromiseData, -): IExecuteResponsePromiseData { - if (typeof response === 'object' && Buffer.isBuffer(response.body)) { - response.body = { - '__@N8nEncodedBuffer@__': response.body.toString(BINARY_ENCODING), - }; - } - - return response; -} - const normalizeFormData = (values: Record) => { for (const key in values) { const value = values[key]; @@ -228,7 +115,7 @@ export async function executeWebhook( executionId: string | undefined, req: WebhookRequest, res: express.Response, - responseCallback: (error: Error | null, data: IResponseCallbackData) => void, + responseCallback: (error: Error | null, data: IWebhookResponseCallbackData) => void, destinationNode?: string, ): Promise { // Get the nodeType to know which responseMode is set diff --git a/packages/cli/src/webhooks/WebhookRequestHandler.ts b/packages/cli/src/webhooks/WebhookRequestHandler.ts new file mode 100644 index 0000000000000..289175ac0541c --- /dev/null +++ b/packages/cli/src/webhooks/WebhookRequestHandler.ts @@ -0,0 +1,119 @@ +import type express from 'express'; +import type { IHttpRequestMethods } from 'n8n-workflow'; +import type { + IWebhookManager, + WebhookOptionsRequest, + WebhookRequest, +} from '@/webhooks/webhook.types'; +import * as ResponseHelper from '@/ResponseHelper'; + +const WEBHOOK_METHODS: IHttpRequestMethods[] = ['DELETE', 'GET', 'HEAD', 'PATCH', 'POST', 'PUT']; + +class WebhookRequestHandler { + constructor(private readonly webhookManager: IWebhookManager) {} + + /** + * Handles an incoming webhook request. Handles CORS and delegates the + * request to the webhook manager to execute the webhook. + */ + async handleRequest(req: WebhookRequest | WebhookOptionsRequest, res: express.Response) { + const method = req.method; + + if (method !== 'OPTIONS' && !WEBHOOK_METHODS.includes(method)) { + return ResponseHelper.sendErrorResponse( + res, + new Error(`The method ${method} is not supported.`), + ); + } + + // Setup CORS headers only if the incoming request has an `origin` header + if ('origin' in req.headers) { + const corsSetupError = await this.setupCorsHeaders(req, res); + if (corsSetupError) { + return ResponseHelper.sendErrorResponse(res, corsSetupError); + } + } + + if (method === 'OPTIONS') { + return ResponseHelper.sendSuccessResponse(res, {}, true, 204); + } + + try { + const response = await this.webhookManager.executeWebhook(req, res); + + // Don't respond, if already responded + if (response.noWebhookResponse !== true) { + ResponseHelper.sendSuccessResponse( + res, + response.data, + true, + response.responseCode, + response.headers, + ); + } + } catch (error) { + return ResponseHelper.sendErrorResponse(res, error as Error); + } + } + + private async setupCorsHeaders( + req: WebhookRequest | WebhookOptionsRequest, + res: express.Response, + ): Promise { + const method = req.method; + const { path } = req.params; + + if (this.webhookManager.getWebhookMethods) { + try { + const allowedMethods = await this.webhookManager.getWebhookMethods(path); + res.header('Access-Control-Allow-Methods', ['OPTIONS', ...allowedMethods].join(', ')); + } catch (error) { + return error as Error; + } + } + + const requestedMethod = + method === 'OPTIONS' + ? (req.headers['access-control-request-method'] as IHttpRequestMethods) + : method; + if (this.webhookManager.findAccessControlOptions && requestedMethod) { + const options = await this.webhookManager.findAccessControlOptions(path, requestedMethod); + const { allowedOrigins } = options ?? {}; + + if (allowedOrigins && allowedOrigins !== '*' && allowedOrigins !== req.headers.origin) { + const originsList = allowedOrigins.split(','); + const defaultOrigin = originsList[0]; + + if (originsList.length === 1) { + res.header('Access-Control-Allow-Origin', defaultOrigin); + } + + if (originsList.includes(req.headers.origin as string)) { + res.header('Access-Control-Allow-Origin', req.headers.origin); + } else { + res.header('Access-Control-Allow-Origin', defaultOrigin); + } + } else { + res.header('Access-Control-Allow-Origin', req.headers.origin); + } + + if (method === 'OPTIONS') { + res.header('Access-Control-Max-Age', '300'); + const requestedHeaders = req.headers['access-control-request-headers']; + if (requestedHeaders?.length) { + res.header('Access-Control-Allow-Headers', requestedHeaders); + } + } + } + + return null; + } +} + +export function createWebhookHandlerFor(webhookManager: IWebhookManager) { + const handler = new WebhookRequestHandler(webhookManager); + + return async (req: WebhookRequest | WebhookOptionsRequest, res: express.Response) => { + await handler.handleRequest(req, res); + }; +} diff --git a/packages/cli/src/WebhookServer.ts b/packages/cli/src/webhooks/WebhookServer.ts similarity index 100% rename from packages/cli/src/WebhookServer.ts rename to packages/cli/src/webhooks/WebhookServer.ts diff --git a/packages/cli/test/unit/TestWebhooks.test.ts b/packages/cli/src/webhooks/__tests__/TestWebhooks.test.ts similarity index 93% rename from packages/cli/test/unit/TestWebhooks.test.ts rename to packages/cli/src/webhooks/__tests__/TestWebhooks.test.ts index 6c7ae555b3b52..deb6930b96e14 100644 --- a/packages/cli/test/unit/TestWebhooks.test.ts +++ b/packages/cli/src/webhooks/__tests__/TestWebhooks.test.ts @@ -1,20 +1,21 @@ import { mock } from 'jest-mock-extended'; -import { TestWebhooks } from '@/TestWebhooks'; +import { TestWebhooks } from '@/webhooks/TestWebhooks'; import { WebhookNotFoundError } from '@/errors/response-errors/webhook-not-found.error'; import { v4 as uuid } from 'uuid'; import { generateNanoId } from '@/databases/utils/generators'; import { NotFoundError } from '@/errors/response-errors/not-found.error'; -import * as WebhookHelpers from '@/WebhookHelpers'; +import * as WebhookHelpers from '@/webhooks/WebhookHelpers'; import type * as express from 'express'; -import type { IWorkflowDb, WebhookRequest } from '@/Interfaces'; +import type { IWorkflowDb } from '@/Interfaces'; import type { IWebhookData, IWorkflowExecuteAdditionalData, Workflow } from 'n8n-workflow'; import type { TestWebhookRegistrationsService, TestWebhookRegistration, -} from '@/services/test-webhook-registrations.service'; +} from '@/webhooks/test-webhook-registrations.service'; import * as AdditionalData from '@/WorkflowExecuteAdditionalData'; +import type { WebhookRequest } from '@/webhooks/webhook.types'; jest.mock('@/WorkflowExecuteAdditionalData'); diff --git a/packages/cli/src/webhooks/__tests__/WebhookRequestHandler.test.ts b/packages/cli/src/webhooks/__tests__/WebhookRequestHandler.test.ts new file mode 100644 index 0000000000000..aff310241958f --- /dev/null +++ b/packages/cli/src/webhooks/__tests__/WebhookRequestHandler.test.ts @@ -0,0 +1,220 @@ +import { type Response } from 'express'; +import { mock } from 'jest-mock-extended'; +import { randomString } from 'n8n-workflow'; +import type { IHttpRequestMethods } from 'n8n-workflow'; + +import type { + IWebhookManager, + IWebhookResponseCallbackData, + WebhookOptionsRequest, + WebhookRequest, +} from '@/webhooks/webhook.types'; +import { createWebhookHandlerFor } from '@/webhooks/WebhookRequestHandler'; +import { ResponseError } from '@/errors/response-errors/abstract/response.error'; + +describe('WebhookRequestHandler', () => { + const webhookManager = mock>(); + const handler = createWebhookHandlerFor(webhookManager); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('should throw for unsupported methods', async () => { + const req = mock({ + method: 'CONNECT' as IHttpRequestMethods, + }); + const res = mock(); + res.status.mockReturnValue(res); + + await handler(req, res); + + expect(res.status).toHaveBeenCalledWith(500); + expect(res.json).toHaveBeenCalledWith({ + code: 0, + message: 'The method CONNECT is not supported.', + }); + }); + + describe('preflight requests', () => { + it('should handle missing header for requested method', async () => { + const req = mock({ + method: 'OPTIONS', + headers: { + origin: 'https://example.com', + 'access-control-request-method': undefined, + }, + params: { path: 'test' }, + }); + const res = mock(); + res.status.mockReturnValue(res); + + webhookManager.getWebhookMethods.mockResolvedValue(['GET', 'PATCH']); + + await handler(req, res); + + expect(res.status).toHaveBeenCalledWith(204); + expect(res.header).toHaveBeenCalledWith( + 'Access-Control-Allow-Methods', + 'OPTIONS, GET, PATCH', + ); + }); + + it('should handle default origin and max-age', async () => { + const req = mock({ + method: 'OPTIONS', + headers: { + origin: 'https://example.com', + 'access-control-request-method': 'GET', + }, + params: { path: 'test' }, + }); + const res = mock(); + res.status.mockReturnValue(res); + + webhookManager.getWebhookMethods.mockResolvedValue(['GET', 'PATCH']); + + await handler(req, res); + + expect(res.status).toHaveBeenCalledWith(204); + expect(res.header).toHaveBeenCalledWith( + 'Access-Control-Allow-Methods', + 'OPTIONS, GET, PATCH', + ); + expect(res.header).toHaveBeenCalledWith('Access-Control-Allow-Origin', 'https://example.com'); + expect(res.header).toHaveBeenCalledWith('Access-Control-Max-Age', '300'); + }); + + it('should handle wildcard origin', async () => { + const randomOrigin = randomString(10); + const req = mock({ + method: 'OPTIONS', + headers: { + origin: randomOrigin, + 'access-control-request-method': 'GET', + }, + params: { path: 'test' }, + }); + const res = mock(); + res.status.mockReturnValue(res); + + webhookManager.getWebhookMethods.mockResolvedValue(['GET', 'PATCH']); + webhookManager.findAccessControlOptions.mockResolvedValue({ + allowedOrigins: '*', + }); + + await handler(req, res); + + expect(res.status).toHaveBeenCalledWith(204); + expect(res.header).toHaveBeenCalledWith( + 'Access-Control-Allow-Methods', + 'OPTIONS, GET, PATCH', + ); + expect(res.header).toHaveBeenCalledWith('Access-Control-Allow-Origin', randomOrigin); + }); + + it('should handle custom origin', async () => { + const req = mock({ + method: 'OPTIONS', + headers: { + origin: 'https://example.com', + 'access-control-request-method': 'GET', + }, + params: { path: 'test' }, + }); + const res = mock(); + res.status.mockReturnValue(res); + + webhookManager.getWebhookMethods.mockResolvedValue(['GET', 'PATCH']); + webhookManager.findAccessControlOptions.mockResolvedValue({ + allowedOrigins: 'https://test.com', + }); + + await handler(req, res); + + expect(res.status).toHaveBeenCalledWith(204); + expect(res.header).toHaveBeenCalledWith( + 'Access-Control-Allow-Methods', + 'OPTIONS, GET, PATCH', + ); + expect(res.header).toHaveBeenCalledWith('Access-Control-Allow-Origin', 'https://test.com'); + }); + }); + + describe('webhook requests', () => { + it('should delegate the request to the webhook manager and send the response', async () => { + const req = mock({ + method: 'GET', + params: { path: 'test' }, + }); + + const res = mock(); + + const executeWebhookResponse: IWebhookResponseCallbackData = { + responseCode: 200, + data: {}, + headers: { + 'x-custom-header': 'test', + }, + }; + webhookManager.executeWebhook.mockResolvedValueOnce(executeWebhookResponse); + + await handler(req, res); + + expect(webhookManager.executeWebhook).toHaveBeenCalledWith(req, res); + expect(res.status).toHaveBeenCalledWith(200); + expect(res.header).toHaveBeenCalledWith({ + 'x-custom-header': 'test', + }); + expect(res.json).toHaveBeenCalledWith(executeWebhookResponse.data); + }); + + it('should send an error response if webhook execution throws', async () => { + class TestError extends ResponseError {} + const req = mock({ + method: 'GET', + params: { path: 'test' }, + }); + + const res = mock(); + res.status.mockReturnValue(res); + + webhookManager.executeWebhook.mockRejectedValueOnce( + new TestError('Test error', 500, 100, 'Test hint'), + ); + + await handler(req, res); + + expect(webhookManager.executeWebhook).toHaveBeenCalledWith(req, res); + expect(res.status).toHaveBeenCalledWith(500); + expect(res.json).toHaveBeenCalledWith({ + code: 100, + message: 'Test error', + hint: 'Test hint', + }); + }); + + test.each(['DELETE', 'GET', 'HEAD', 'PATCH', 'POST', 'PUT'])( + "should handle '%s' method", + async (method) => { + const req = mock({ + method, + params: { path: 'test' }, + }); + + const res = mock(); + + const executeWebhookResponse: IWebhookResponseCallbackData = { + responseCode: 200, + }; + webhookManager.executeWebhook.mockResolvedValueOnce(executeWebhookResponse); + + await handler(req, res); + + expect(webhookManager.executeWebhook).toHaveBeenCalledWith(req, res); + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith(executeWebhookResponse.data); + }, + ); + }); +}); diff --git a/packages/cli/test/unit/services/test-webhook-registrations.service.test.ts b/packages/cli/src/webhooks/__tests__/test-webhook-registrations.service.test.ts similarity index 95% rename from packages/cli/test/unit/services/test-webhook-registrations.service.test.ts rename to packages/cli/src/webhooks/__tests__/test-webhook-registrations.service.test.ts index c93540938c1bd..95502e3611e7c 100644 --- a/packages/cli/test/unit/services/test-webhook-registrations.service.test.ts +++ b/packages/cli/src/webhooks/__tests__/test-webhook-registrations.service.test.ts @@ -1,7 +1,7 @@ import type { CacheService } from '@/services/cache/cache.service'; import type { OrchestrationService } from '@/services/orchestration.service'; -import type { TestWebhookRegistration } from '@/services/test-webhook-registrations.service'; -import { TestWebhookRegistrationsService } from '@/services/test-webhook-registrations.service'; +import type { TestWebhookRegistration } from '@/webhooks/test-webhook-registrations.service'; +import { TestWebhookRegistrationsService } from '@/webhooks/test-webhook-registrations.service'; import { mock } from 'jest-mock-extended'; describe('TestWebhookRegistrationsService', () => { diff --git a/packages/cli/src/__tests__/waiting-webhooks.test.ts b/packages/cli/src/webhooks/__tests__/waiting-webhooks.test.ts similarity index 90% rename from packages/cli/src/__tests__/waiting-webhooks.test.ts rename to packages/cli/src/webhooks/__tests__/waiting-webhooks.test.ts index 6748c7233566f..31f64eb198432 100644 --- a/packages/cli/src/__tests__/waiting-webhooks.test.ts +++ b/packages/cli/src/webhooks/__tests__/waiting-webhooks.test.ts @@ -1,10 +1,11 @@ import { mock } from 'jest-mock-extended'; -import { WaitingWebhooks } from '@/WaitingWebhooks'; +import { WaitingWebhooks } from '@/webhooks/WaitingWebhooks'; import { ConflictError } from '@/errors/response-errors/conflict.error'; import { NotFoundError } from '@/errors/response-errors/not-found.error'; -import type { IExecutionResponse, WaitingWebhookRequest } from '@/Interfaces'; +import type { IExecutionResponse } from '@/Interfaces'; import type express from 'express'; import type { ExecutionRepository } from '@/databases/repositories/execution.repository'; +import type { WaitingWebhookRequest } from '@/webhooks/webhook.types'; describe('WaitingWebhooks', () => { const executionRepository = mock(); diff --git a/packages/cli/test/unit/services/webhook.service.test.ts b/packages/cli/src/webhooks/__tests__/webhook.service.test.ts similarity index 98% rename from packages/cli/test/unit/services/webhook.service.test.ts rename to packages/cli/src/webhooks/__tests__/webhook.service.test.ts index adb18de4b309b..5a8e19e84c809 100644 --- a/packages/cli/test/unit/services/webhook.service.test.ts +++ b/packages/cli/src/webhooks/__tests__/webhook.service.test.ts @@ -2,9 +2,9 @@ import { v4 as uuid } from 'uuid'; import config from '@/config'; import { WebhookRepository } from '@db/repositories/webhook.repository'; import { CacheService } from '@/services/cache/cache.service'; -import { WebhookService } from '@/services/webhook.service'; +import { WebhookService } from '@/webhooks/webhook.service'; import { WebhookEntity } from '@db/entities/WebhookEntity'; -import { mockInstance } from '../../shared/mocking'; +import { mockInstance } from '@test/mocking'; const createWebhook = (method: string, path: string, webhookId?: string, pathSegments?: number) => Object.assign(new WebhookEntity(), { diff --git a/packages/cli/src/services/test-webhook-registrations.service.ts b/packages/cli/src/webhooks/test-webhook-registrations.service.ts similarity index 97% rename from packages/cli/src/services/test-webhook-registrations.service.ts rename to packages/cli/src/webhooks/test-webhook-registrations.service.ts index e2abae5605f98..94e7e7d826dbf 100644 --- a/packages/cli/src/services/test-webhook-registrations.service.ts +++ b/packages/cli/src/webhooks/test-webhook-registrations.service.ts @@ -3,7 +3,7 @@ import { CacheService } from '@/services/cache/cache.service'; import type { IWebhookData } from 'n8n-workflow'; import type { IWorkflowDb } from '@/Interfaces'; import { TEST_WEBHOOK_TIMEOUT, TEST_WEBHOOK_TIMEOUT_BUFFER } from '@/constants'; -import { OrchestrationService } from './orchestration.service'; +import { OrchestrationService } from '@/services/orchestration.service'; export type TestWebhookRegistration = { pushRef?: string; diff --git a/packages/cli/src/services/webhook.service.ts b/packages/cli/src/webhooks/webhook.service.ts similarity index 100% rename from packages/cli/src/services/webhook.service.ts rename to packages/cli/src/webhooks/webhook.service.ts diff --git a/packages/cli/src/webhooks/webhook.types.ts b/packages/cli/src/webhooks/webhook.types.ts new file mode 100644 index 0000000000000..4d34b00d04c2c --- /dev/null +++ b/packages/cli/src/webhooks/webhook.types.ts @@ -0,0 +1,37 @@ +import type { Request, Response } from 'express'; +import type { IDataObject, IHttpRequestMethods } from 'n8n-workflow'; + +export type WebhookOptionsRequest = Request & { method: 'OPTIONS' }; + +export type WebhookRequest = Request<{ path: string }> & { + method: IHttpRequestMethods; + params: Record; +}; + +export type WaitingWebhookRequest = WebhookRequest & { + params: WebhookRequest['path'] & { suffix?: string }; +}; + +export interface WebhookAccessControlOptions { + allowedOrigins?: string; +} + +export interface IWebhookManager { + /** Gets all request methods associated with a webhook path*/ + getWebhookMethods?: (path: string) => Promise; + + /** Find the CORS options matching a path and method */ + findAccessControlOptions?: ( + path: string, + httpMethod: IHttpRequestMethods, + ) => Promise; + + executeWebhook(req: WebhookRequest, res: Response): Promise; +} + +export interface IWebhookResponseCallbackData { + data?: IDataObject | IDataObject[]; + headers?: object; + noWebhookResponse?: boolean; + responseCode?: number; +} diff --git a/packages/cli/test/unit/workflow-execution.service.test.ts b/packages/cli/src/workflows/__tests__/workflow-execution.service.test.ts similarity index 100% rename from packages/cli/test/unit/workflow-execution.service.test.ts rename to packages/cli/src/workflows/__tests__/workflow-execution.service.test.ts diff --git a/packages/cli/src/workflows/workflow.service.ts b/packages/cli/src/workflows/workflow.service.ts index 4f59f8238fe68..20917e60cbc16 100644 --- a/packages/cli/src/workflows/workflow.service.ts +++ b/packages/cli/src/workflows/workflow.service.ts @@ -33,7 +33,7 @@ import type { EntityManager } from '@n8n/typeorm'; // eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import import { In } from '@n8n/typeorm'; import { SharedWorkflow } from '@/databases/entities/SharedWorkflow'; -import { EventService } from '@/eventbus/event.service'; +import { EventService } from '@/events/event.service'; @Service() export class WorkflowService { diff --git a/packages/cli/src/workflows/workflowExecution.service.ts b/packages/cli/src/workflows/workflowExecution.service.ts index 8ebe7aed0c142..9ddce37d2f207 100644 --- a/packages/cli/src/workflows/workflowExecution.service.ts +++ b/packages/cli/src/workflows/workflowExecution.service.ts @@ -30,7 +30,7 @@ import type { import { NodeTypes } from '@/NodeTypes'; import { WorkflowRunner } from '@/WorkflowRunner'; import * as WorkflowExecuteAdditionalData from '@/WorkflowExecuteAdditionalData'; -import { TestWebhooks } from '@/TestWebhooks'; +import { TestWebhooks } from '@/webhooks/TestWebhooks'; import { Logger } from '@/Logger'; import type { Project } from '@/databases/entities/Project'; import { GlobalConfig } from '@n8n/config'; diff --git a/packages/cli/test/unit/services/workflowHistory.service.ee.test.ts b/packages/cli/src/workflows/workflowHistory/__tests__/workflowHistory.service.ee.test.ts similarity index 96% rename from packages/cli/test/unit/services/workflowHistory.service.ee.test.ts rename to packages/cli/src/workflows/workflowHistory/__tests__/workflowHistory.service.ee.test.ts index 05ccae70051a2..5b28b8d171468 100644 --- a/packages/cli/test/unit/services/workflowHistory.service.ee.test.ts +++ b/packages/cli/src/workflows/workflowHistory/__tests__/workflowHistory.service.ee.test.ts @@ -4,8 +4,8 @@ import { WorkflowHistoryRepository } from '@db/repositories/workflowHistory.repo import { SharedWorkflowRepository } from '@db/repositories/sharedWorkflow.repository'; import { WorkflowHistoryService } from '@/workflows/workflowHistory/workflowHistory.service.ee'; import { Logger } from '@/Logger'; -import { mockInstance } from '../../shared/mocking'; -import { getWorkflow } from '../../integration/shared/workflow'; +import { mockInstance } from '@test/mocking'; +import { getWorkflow } from '@test-integration/workflow'; const workflowHistoryRepository = mockInstance(WorkflowHistoryRepository); const logger = mockInstance(Logger); diff --git a/packages/cli/test/unit/workflowHistoryHelper.test.ts b/packages/cli/src/workflows/workflowHistory/__tests__/workflowHistoryHelper.ee.test.ts similarity index 96% rename from packages/cli/test/unit/workflowHistoryHelper.test.ts rename to packages/cli/src/workflows/workflowHistory/__tests__/workflowHistoryHelper.ee.test.ts index 32a6cdd6f0d52..427f98188495b 100644 --- a/packages/cli/test/unit/workflowHistoryHelper.test.ts +++ b/packages/cli/src/workflows/workflowHistory/__tests__/workflowHistoryHelper.ee.test.ts @@ -1,7 +1,7 @@ import { License } from '@/License'; import config from '@/config'; import { getWorkflowHistoryPruneTime } from '@/workflows/workflowHistory/workflowHistoryHelper.ee'; -import { mockInstance } from '../shared/mocking'; +import { mockInstance } from '@test/mocking'; let licensePruneTime = -1; diff --git a/packages/cli/src/workflows/workflowSharing.service.ts b/packages/cli/src/workflows/workflowSharing.service.ts index 93ef76438f7b0..111e50b58103f 100644 --- a/packages/cli/src/workflows/workflowSharing.service.ts +++ b/packages/cli/src/workflows/workflowSharing.service.ts @@ -8,12 +8,14 @@ import { RoleService } from '@/services/role.service'; import type { Scope } from '@n8n/permissions'; import type { ProjectRole } from '@/databases/entities/ProjectRelation'; import type { WorkflowSharingRole } from '@/databases/entities/SharedWorkflow'; +import { ProjectRelationRepository } from '@/databases/repositories/projectRelation.repository'; @Service() export class WorkflowSharingService { constructor( private readonly sharedWorkflowRepository: SharedWorkflowRepository, private readonly roleService: RoleService, + private readonly projectRelationRepository: ProjectRelationRepository, ) {} /** @@ -27,11 +29,16 @@ export class WorkflowSharingService { async getSharedWorkflowIds( user: User, options: - | { scopes: Scope[] } - | { projectRoles: ProjectRole[]; workflowRoles: WorkflowSharingRole[] }, + | { scopes: Scope[]; projectId?: string } + | { projectRoles: ProjectRole[]; workflowRoles: WorkflowSharingRole[]; projectId?: string }, ): Promise { + const { projectId } = options; + if (user.hasGlobalScope('workflow:read')) { - const sharedWorkflows = await this.sharedWorkflowRepository.find({ select: ['workflowId'] }); + const sharedWorkflows = await this.sharedWorkflowRepository.find({ + select: ['workflowId'], + ...(projectId && { where: { projectId } }), + }); return sharedWorkflows.map(({ workflowId }) => workflowId); } @@ -59,4 +66,28 @@ export class WorkflowSharingService { return sharedWorkflows.map(({ workflowId }) => workflowId); } + + async getSharedWorkflowScopes( + workflowIds: string[], + user: User, + ): Promise> { + const projectRelations = await this.projectRelationRepository.findAllByUser(user.id); + const sharedWorkflows = + await this.sharedWorkflowRepository.getRelationsByWorkflowIdsAndProjectIds( + workflowIds, + projectRelations.map((p) => p.projectId), + ); + + return workflowIds.map((workflowId) => { + return [ + workflowId, + this.roleService.combineResourceScopes( + 'workflow', + user, + sharedWorkflows.filter((s) => s.workflowId === workflowId), + projectRelations, + ), + ]; + }); + } } diff --git a/packages/cli/src/workflows/workflows.controller.ts b/packages/cli/src/workflows/workflows.controller.ts index c774b21917c98..4c03dd9e246c4 100644 --- a/packages/cli/src/workflows/workflows.controller.ts +++ b/packages/cli/src/workflows/workflows.controller.ts @@ -17,7 +17,6 @@ import { validateEntity } from '@/GenericHelpers'; import { ExternalHooks } from '@/ExternalHooks'; import { WorkflowService } from './workflow.service'; import { License } from '@/License'; -import { InternalHooks } from '@/InternalHooks'; import * as utils from '@/utils'; import { listQueryMiddleware } from '@/middlewares'; import { TagService } from '@/services/tag.service'; @@ -42,14 +41,13 @@ import { In, type FindOptionsRelations } from '@n8n/typeorm'; import type { Project } from '@/databases/entities/Project'; import { ProjectRelationRepository } from '@/databases/repositories/projectRelation.repository'; import { z } from 'zod'; -import { EventService } from '@/eventbus/event.service'; +import { EventService } from '@/events/event.service'; import { GlobalConfig } from '@n8n/config'; @RestController('/workflows') export class WorkflowsController { constructor( private readonly logger: Logger, - private readonly internalHooks: InternalHooks, private readonly externalHooks: ExternalHooks, private readonly tagRepository: TagRepository, private readonly enterpriseWorkflowService: EnterpriseWorkflowService, @@ -459,7 +457,11 @@ export class WorkflowsController { newShareeIds = toShare; }); - this.internalHooks.onWorkflowSharingUpdate(workflowId, req.user.id, shareWithIds); + this.eventService.emit('workflow-sharing-updated', { + workflowId, + userIdSharer: req.user.id, + userIdList: shareWithIds, + }); const projectsRelations = await this.projectRelationRepository.findBy({ projectId: In(newShareeIds), diff --git a/packages/cli/test/integration/ExternalSecrets/externalSecrets.api.test.ts b/packages/cli/test/integration/ExternalSecrets/externalSecrets.api.test.ts index 7a49e61fa1b01..190ab437fa5f6 100644 --- a/packages/cli/test/integration/ExternalSecrets/externalSecrets.api.test.ts +++ b/packages/cli/test/integration/ExternalSecrets/externalSecrets.api.test.ts @@ -21,7 +21,7 @@ import { TestFailProvider, } from '../../shared/ExternalSecrets/utils'; import type { SuperAgentTest } from '../shared/types'; -import type { EventService } from '@/eventbus/event.service'; +import type { EventService } from '@/events/event.service'; let authOwnerAgent: SuperAgentTest; let authMemberAgent: SuperAgentTest; diff --git a/packages/cli/test/integration/PermissionChecker.test.ts b/packages/cli/test/integration/PermissionChecker.test.ts index d5262a50d0672..ff10d0497439a 100644 --- a/packages/cli/test/integration/PermissionChecker.test.ts +++ b/packages/cli/test/integration/PermissionChecker.test.ts @@ -1,6 +1,6 @@ import { v4 as uuid } from 'uuid'; import { Container } from 'typedi'; -import type { INode } from 'n8n-workflow'; +import type { INode, INodeTypeData } from 'n8n-workflow'; import { randomInt } from 'n8n-workflow'; import type { User } from '@db/entities/User'; import { WorkflowRepository } from '@db/repositories/workflow.repository'; @@ -14,7 +14,6 @@ import { mockInstance } from '../shared/mocking'; import { randomCredentialPayload as randomCred } from '../integration/shared/random'; import * as testDb from '../integration/shared/testDb'; import type { SaveCredentialFunction } from '../integration/shared/types'; -import { mockNodeTypesData } from '../unit/Helpers'; import { affixRoleToSaveCredential } from '../integration/shared/db/credentials'; import { createOwner, createUser } from '../integration/shared/db/users'; import { SharedCredentialsRepository } from '@/databases/repositories/sharedCredentials.repository'; @@ -25,6 +24,36 @@ import { ProjectRepository } from '@/databases/repositories/project.repository'; const ownershipService = mockInstance(OwnershipService); +function mockNodeTypesData( + nodeNames: string[], + options?: { + addTrigger?: boolean; + }, +) { + return nodeNames.reduce((acc, nodeName) => { + return ( + (acc[`n8n-nodes-base.${nodeName}`] = { + sourcePath: '', + type: { + description: { + displayName: nodeName, + name: nodeName, + group: [], + description: '', + version: 1, + defaults: {}, + inputs: [], + outputs: [], + properties: [], + }, + trigger: options?.addTrigger ? async () => undefined : undefined, + }, + }), + acc + ); + }, {}); +} + const createWorkflow = async (nodes: INode[], workflowOwner?: User): Promise => { const workflowDetails = { id: randomInt(1, 10).toString(), diff --git a/packages/cli/test/integration/activation-errors.service.test.ts b/packages/cli/test/integration/activation-errors.service.test.ts index 7635660db3aca..5d56f0dc90d6a 100644 --- a/packages/cli/test/integration/activation-errors.service.test.ts +++ b/packages/cli/test/integration/activation-errors.service.test.ts @@ -1,8 +1,13 @@ import { ActivationErrorsService } from '@/ActivationErrors.service'; import { CacheService } from '@/services/cache/cache.service'; +import { GlobalConfig } from '@n8n/config'; +import { mockInstance } from '@test/mocking'; describe('ActivationErrorsService', () => { - const cacheService = new CacheService(); + const globalConfig = mockInstance(GlobalConfig, { + cache: { backend: 'memory', memory: { maxSize: 3 * 1024 * 1024, ttl: 3600 * 1000 } }, + }); + const cacheService = new CacheService(globalConfig); const activationErrorsService = new ActivationErrorsService(cacheService); const firstWorkflowId = 'GSG0etbfTA2CNPDX'; diff --git a/packages/cli/test/integration/active-workflow-manager.test.ts b/packages/cli/test/integration/active-workflow-manager.test.ts index 03ce58e7ef6bc..de586b643a468 100644 --- a/packages/cli/test/integration/active-workflow-manager.test.ts +++ b/packages/cli/test/integration/active-workflow-manager.test.ts @@ -8,8 +8,8 @@ import { ActiveWorkflowManager } from '@/ActiveWorkflowManager'; import { ExternalHooks } from '@/ExternalHooks'; import { Push } from '@/push'; import { SecretsHelper } from '@/SecretsHelpers'; -import { WebhookService } from '@/services/webhook.service'; -import * as WebhookHelpers from '@/WebhookHelpers'; +import { WebhookService } from '@/webhooks/webhook.service'; +import * as WebhookHelpers from '@/webhooks/WebhookHelpers'; import * as AdditionalData from '@/WorkflowExecuteAdditionalData'; import type { WebhookEntity } from '@db/entities/WebhookEntity'; import { NodeTypes } from '@/NodeTypes'; diff --git a/packages/cli/test/integration/commands/credentials.cmd.test.ts b/packages/cli/test/integration/commands/credentials.cmd.test.ts index 386f3a41b20de..bcb6f0173b262 100644 --- a/packages/cli/test/integration/commands/credentials.cmd.test.ts +++ b/packages/cli/test/integration/commands/credentials.cmd.test.ts @@ -1,6 +1,5 @@ import { nanoid } from 'nanoid'; -import { InternalHooks } from '@/InternalHooks'; import { ImportCredentialsCommand } from '@/commands/import/credentials'; import { LoadNodesAndCredentials } from '@/LoadNodesAndCredentials'; @@ -11,7 +10,6 @@ import { getAllCredentials, getAllSharedCredentials } from '../shared/db/credent import { createMember, createOwner } from '../shared/db/users'; import { getPersonalProject } from '../shared/db/projects'; -mockInstance(InternalHooks); mockInstance(LoadNodesAndCredentials); const command = setupTestCommand(ImportCredentialsCommand); diff --git a/packages/cli/test/integration/commands/import.cmd.test.ts b/packages/cli/test/integration/commands/import.cmd.test.ts index 3295f0de27b1c..ba72d64d775fe 100644 --- a/packages/cli/test/integration/commands/import.cmd.test.ts +++ b/packages/cli/test/integration/commands/import.cmd.test.ts @@ -1,6 +1,5 @@ import { nanoid } from 'nanoid'; -import { InternalHooks } from '@/InternalHooks'; import { ImportWorkflowsCommand } from '@/commands/import/workflow'; import { LoadNodesAndCredentials } from '@/LoadNodesAndCredentials'; @@ -11,7 +10,6 @@ import { getAllSharedWorkflows, getAllWorkflows } from '../shared/db/workflows'; import { createMember, createOwner } from '../shared/db/users'; import { getPersonalProject } from '../shared/db/projects'; -mockInstance(InternalHooks); mockInstance(LoadNodesAndCredentials); const command = setupTestCommand(ImportWorkflowsCommand); diff --git a/packages/cli/test/integration/commands/ldap/reset.test.ts b/packages/cli/test/integration/commands/ldap/reset.test.ts index 12833b633f018..720c98c401063 100644 --- a/packages/cli/test/integration/commands/ldap/reset.test.ts +++ b/packages/cli/test/integration/commands/ldap/reset.test.ts @@ -4,7 +4,6 @@ import { EntityNotFoundError } from '@n8n/typeorm'; import { Reset } from '@/commands/ldap/reset'; import { LoadNodesAndCredentials } from '@/LoadNodesAndCredentials'; -import { InternalHooks } from '@/InternalHooks'; import { WorkflowRepository } from '@db/repositories/workflow.repository'; import { CredentialsRepository } from '@db/repositories/credentials.repository'; import { SharedWorkflowRepository } from '@db/repositories/sharedWorkflow.repository'; @@ -26,7 +25,6 @@ import { createTeamProject, findProject, getPersonalProject } from '../../shared mockInstance(Telemetry); mockInstance(Push); -mockInstance(InternalHooks); mockInstance(LoadNodesAndCredentials); const command = setupTestCommand(Reset); diff --git a/packages/cli/test/integration/commands/license.cmd.test.ts b/packages/cli/test/integration/commands/license.cmd.test.ts index 8637d2d02017d..1717e1a5ff185 100644 --- a/packages/cli/test/integration/commands/license.cmd.test.ts +++ b/packages/cli/test/integration/commands/license.cmd.test.ts @@ -1,4 +1,3 @@ -import { InternalHooks } from '@/InternalHooks'; import { License } from '@/License'; import { LoadNodesAndCredentials } from '@/LoadNodesAndCredentials'; import { ClearLicenseCommand } from '@/commands/license/clear'; @@ -6,7 +5,6 @@ import { ClearLicenseCommand } from '@/commands/license/clear'; import { setupTestCommand } from '@test-integration/utils/testCommand'; import { mockInstance } from '../../shared/mocking'; -mockInstance(InternalHooks); mockInstance(LoadNodesAndCredentials); const license = mockInstance(License); const command = setupTestCommand(ClearLicenseCommand); diff --git a/packages/cli/test/integration/commands/reset.cmd.test.ts b/packages/cli/test/integration/commands/reset.cmd.test.ts index f38ea789d71b7..573e85043ea3c 100644 --- a/packages/cli/test/integration/commands/reset.cmd.test.ts +++ b/packages/cli/test/integration/commands/reset.cmd.test.ts @@ -1,7 +1,6 @@ import { Container } from 'typedi'; import { Reset } from '@/commands/user-management/reset'; -import { InternalHooks } from '@/InternalHooks'; import { LoadNodesAndCredentials } from '@/LoadNodesAndCredentials'; import { NodeTypes } from '@/NodeTypes'; import { SharedWorkflowRepository } from '@db/repositories/sharedWorkflow.repository'; @@ -20,7 +19,6 @@ import { getPersonalProject } from '../shared/db/projects'; import { encryptCredentialData, saveCredential } from '../shared/db/credentials'; import { randomCredentialPayload } from '../shared/random'; -mockInstance(InternalHooks); mockInstance(LoadNodesAndCredentials); mockInstance(NodeTypes); const command = setupTestCommand(Reset); diff --git a/packages/cli/test/integration/commands/update/workflow.test.ts b/packages/cli/test/integration/commands/update/workflow.test.ts index 315e64c5263af..95a25bc4f647e 100644 --- a/packages/cli/test/integration/commands/update/workflow.test.ts +++ b/packages/cli/test/integration/commands/update/workflow.test.ts @@ -1,4 +1,3 @@ -import { InternalHooks } from '@/InternalHooks'; import { LoadNodesAndCredentials } from '@/LoadNodesAndCredentials'; import { UpdateWorkflowCommand } from '@/commands/update/workflow'; @@ -7,7 +6,6 @@ import * as testDb from '../../shared/testDb'; import { createWorkflowWithTrigger, getAllWorkflows } from '../../shared/db/workflows'; import { mockInstance } from '../../../shared/mocking'; -mockInstance(InternalHooks); mockInstance(LoadNodesAndCredentials); const command = setupTestCommand(UpdateWorkflowCommand); diff --git a/packages/cli/test/integration/commands/worker.cmd.test.ts b/packages/cli/test/integration/commands/worker.cmd.test.ts index 0ffad7bc056ec..e7f783bf94126 100644 --- a/packages/cli/test/integration/commands/worker.cmd.test.ts +++ b/packages/cli/test/integration/commands/worker.cmd.test.ts @@ -1,39 +1,34 @@ import { BinaryDataService } from 'n8n-core'; -import { mock } from 'jest-mock-extended'; import { Worker } from '@/commands/worker'; import config from '@/config'; import { ExternalSecretsManager } from '@/ExternalSecrets/ExternalSecretsManager.ee'; import { MessageEventBus } from '@/eventbus/MessageEventBus/MessageEventBus'; import { LoadNodesAndCredentials } from '@/LoadNodesAndCredentials'; -import { InternalHooks } from '@/InternalHooks'; import { OrchestrationHandlerWorkerService } from '@/services/orchestration/worker/orchestration.handler.worker.service'; import { OrchestrationWorkerService } from '@/services/orchestration/worker/orchestration.worker.service'; import { License } from '@/License'; import { ExternalHooks } from '@/ExternalHooks'; -import { type JobQueue, Queue } from '@/Queue'; +import { ScalingService } from '@/scaling/scaling.service'; import { setupTestCommand } from '@test-integration/utils/testCommand'; import { mockInstance } from '../../shared/mocking'; -import { AuditEventRelay } from '@/eventbus/audit-event-relay.service'; +import { LogStreamingEventRelay } from '@/events/log-streaming-event-relay'; config.set('executions.mode', 'queue'); config.set('binaryDataManager.availableModes', 'filesystem'); -mockInstance(InternalHooks); mockInstance(LoadNodesAndCredentials); const binaryDataService = mockInstance(BinaryDataService); const externalHooks = mockInstance(ExternalHooks); const externalSecretsManager = mockInstance(ExternalSecretsManager); const license = mockInstance(License); const messageEventBus = mockInstance(MessageEventBus); -const auditEventRelay = mockInstance(AuditEventRelay); +const logStreamingEventRelay = mockInstance(LogStreamingEventRelay); const orchestrationHandlerWorkerService = mockInstance(OrchestrationHandlerWorkerService); -const queue = mockInstance(Queue); +const scalingService = mockInstance(ScalingService); const orchestrationWorkerService = mockInstance(OrchestrationWorkerService); const command = setupTestCommand(Worker); -queue.getBullObjectInstance.mockReturnValue(mock({ on: jest.fn() })); - test('worker initializes all its components', async () => { const worker = await command.run(); @@ -45,9 +40,9 @@ test('worker initializes all its components', async () => { expect(externalHooks.init).toHaveBeenCalledTimes(1); expect(externalSecretsManager.init).toHaveBeenCalledTimes(1); expect(messageEventBus.initialize).toHaveBeenCalledTimes(1); - expect(auditEventRelay.init).toHaveBeenCalledTimes(1); - expect(queue.init).toHaveBeenCalledTimes(1); - expect(queue.process).toHaveBeenCalledTimes(1); + expect(scalingService.setupQueue).toHaveBeenCalledTimes(1); + expect(scalingService.setupWorker).toHaveBeenCalledTimes(1); + expect(logStreamingEventRelay.init).toHaveBeenCalledTimes(1); expect(orchestrationWorkerService.init).toHaveBeenCalledTimes(1); expect(orchestrationHandlerWorkerService.initWithOptions).toHaveBeenCalledTimes(1); expect(messageEventBus.send).toHaveBeenCalledTimes(1); diff --git a/packages/cli/test/integration/community-packages.api.test.ts b/packages/cli/test/integration/community-packages.api.test.ts index 661dbcd0fe4e9..46c7efad03434 100644 --- a/packages/cli/test/integration/community-packages.api.test.ts +++ b/packages/cli/test/integration/community-packages.api.test.ts @@ -179,7 +179,7 @@ describe('POST /community-packages', () => { communityPackagesService.hasPackageLoaded.mockReturnValue(false); communityPackagesService.checkNpmPackageStatus.mockResolvedValue({ status: 'OK' }); communityPackagesService.parseNpmPackageName.mockReturnValue(parsedNpmPackageName); - communityPackagesService.installNpmModule.mockResolvedValue(mockPackage()); + communityPackagesService.installPackage.mockResolvedValue(mockPackage()); await authAgent.post('/community-packages').send({ name: mockPackageName() }).expect(200); @@ -219,7 +219,7 @@ describe('DELETE /community-packages', () => { await authAgent.delete('/community-packages').query({ name: mockPackageName() }).expect(200); - expect(communityPackagesService.removeNpmModule).toHaveBeenCalledTimes(1); + expect(communityPackagesService.removePackage).toHaveBeenCalledTimes(1); }); }); @@ -242,6 +242,6 @@ describe('PATCH /community-packages', () => { await authAgent.patch('/community-packages').send({ name: mockPackageName() }); - expect(communityPackagesService.updateNpmModule).toHaveBeenCalledTimes(1); + expect(communityPackagesService.updatePackage).toHaveBeenCalledTimes(1); }); }); diff --git a/packages/cli/test/integration/controllers/invitation/invitation.controller.integration.test.ts b/packages/cli/test/integration/controllers/invitation/invitation.controller.integration.test.ts index 00e480f86f6f6..8b1048267da20 100644 --- a/packages/cli/test/integration/controllers/invitation/invitation.controller.integration.test.ts +++ b/packages/cli/test/integration/controllers/invitation/invitation.controller.integration.test.ts @@ -1,8 +1,6 @@ -import { mocked } from 'jest-mock'; import Container from 'typedi'; import { Not } from '@n8n/typeorm'; - -import { InternalHooks } from '@/InternalHooks'; +import { EventService } from '@/events/event.service'; import { ExternalHooks } from '@/ExternalHooks'; import { UserManagementMailer } from '@/UserManagement/email'; import { UserRepository } from '@/databases/repositories/user.repository'; @@ -31,7 +29,7 @@ import { ProjectRelationRepository } from '@/databases/repositories/projectRelat describe('InvitationController', () => { const mailer = mockInstance(UserManagementMailer); const externalHooks = mockInstance(ExternalHooks); - const internalHooks = mockInstance(InternalHooks); + const eventService = mockInstance(EventService); const testServer = utils.setupTestServer({ endpointGroups: ['invitations'] }); @@ -413,14 +411,24 @@ describe('InvitationController', () => { expect(externalHookName).toBe('user.invited'); expect(externalHookArg?.[0]).toStrictEqual([newUserEmail]); - // internal hooks - - const calls = mocked(internalHooks).onUserTransactionalEmail.mock.calls; - - for (const [onUserTransactionalEmailArg] of calls) { - expect(onUserTransactionalEmailArg.user_id).toBeDefined(); - expect(onUserTransactionalEmailArg.message_type).toBe('New user invite'); - expect(onUserTransactionalEmailArg.public_api).toBe(false); + for (const [eventName, payload] of eventService.emit.mock.calls) { + if (eventName === 'user-invited') { + expect(payload).toEqual({ + user: expect.objectContaining({ id: expect.any(String) }), + targetUserId: expect.arrayContaining([expect.any(String), expect.any(String)]), + publicApi: false, + emailSent: true, + inviteeRole: 'global:member', + }); + } else if (eventName === 'user-transactional-email-sent') { + expect(payload).toEqual({ + userId: expect.any(String), + messageType: 'New user invite', + publicApi: false, + }); + } else { + fail(`Unexpected event name: ${eventName}`); + } } }); diff --git a/packages/cli/test/integration/credentials/credentials.api.ee.test.ts b/packages/cli/test/integration/credentials/credentials.api.ee.test.ts index df8a562a76768..6b74dd843c303 100644 --- a/packages/cli/test/integration/credentials/credentials.api.ee.test.ts +++ b/packages/cli/test/integration/credentials/credentials.api.ee.test.ts @@ -48,6 +48,7 @@ let memberPersonalProject: Project; let anotherMember: User; let anotherMemberPersonalProject: Project; let authOwnerAgent: SuperAgentTest; +let authMemberAgent: SuperAgentTest; let authAnotherMemberAgent: SuperAgentTest; let saveCredential: SaveCredentialFunction; const mailer = mockInstance(UserManagementMailer); @@ -73,6 +74,7 @@ beforeEach(async () => { ); authOwnerAgent = testServer.authAgentFor(owner); + authMemberAgent = testServer.authAgentFor(member); authAnotherMemberAgent = testServer.authAgentFor(anotherMember); saveCredential = affixRoleToSaveCredential('credential:owner'); @@ -978,6 +980,128 @@ describe('PUT /credentials/:id/share', () => { config.set('userManagement.emails.mode', 'smtp'); }); + + test('member should be able to share from personal project to team project that member has access to', async () => { + const savedCredential = await saveCredential(randomCredentialPayload(), { user: member }); + + const testProject = await createTeamProject(); + await linkUserToProject(member, testProject, 'project:editor'); + + const response = await authMemberAgent + .put(`/credentials/${savedCredential.id}/share`) + .send({ shareWithIds: [testProject.id] }); + + expect(response.statusCode).toBe(200); + expect(response.body.data).toBeUndefined(); + + const shares = await getCredentialSharings(savedCredential); + const testShare = shares.find((s) => s.projectId === testProject.id); + expect(testShare).not.toBeUndefined(); + expect(testShare?.role).toBe('credential:user'); + }); + + test('member should be able to share from team project to personal project', async () => { + const testProject = await createTeamProject(undefined, member); + + const savedCredential = await saveCredential(randomCredentialPayload(), { + project: testProject, + }); + + const response = await authMemberAgent + .put(`/credentials/${savedCredential.id}/share`) + .send({ shareWithIds: [anotherMemberPersonalProject.id] }); + + expect(response.statusCode).toBe(200); + expect(response.body.data).toBeUndefined(); + + const shares = await getCredentialSharings(savedCredential); + const testShare = shares.find((s) => s.projectId === anotherMemberPersonalProject.id); + expect(testShare).not.toBeUndefined(); + expect(testShare?.role).toBe('credential:user'); + }); + + test('member should be able to share from team project to team project that member has access to', async () => { + const testProject = await createTeamProject(undefined, member); + const testProject2 = await createTeamProject(); + await linkUserToProject(member, testProject2, 'project:editor'); + + const savedCredential = await saveCredential(randomCredentialPayload(), { + project: testProject, + }); + + const response = await authMemberAgent + .put(`/credentials/${savedCredential.id}/share`) + .send({ shareWithIds: [testProject2.id] }); + + expect(response.statusCode).toBe(200); + expect(response.body.data).toBeUndefined(); + + const shares = await getCredentialSharings(savedCredential); + const testShare = shares.find((s) => s.projectId === testProject2.id); + expect(testShare).not.toBeUndefined(); + expect(testShare?.role).toBe('credential:user'); + }); + + test('admins should be able to share from any team project to any team project ', async () => { + const testProject = await createTeamProject(); + const testProject2 = await createTeamProject(); + + const savedCredential = await saveCredential(randomCredentialPayload(), { + project: testProject, + }); + + const response = await authOwnerAgent + .put(`/credentials/${savedCredential.id}/share`) + .send({ shareWithIds: [testProject2.id] }); + + expect(response.statusCode).toBe(200); + expect(response.body.data).toBeUndefined(); + + const shares = await getCredentialSharings(savedCredential); + const testShare = shares.find((s) => s.projectId === testProject2.id); + expect(testShare).not.toBeUndefined(); + expect(testShare?.role).toBe('credential:user'); + }); + + test("admins should be able to share from any team project to any user's personal project ", async () => { + const testProject = await createTeamProject(); + + const savedCredential = await saveCredential(randomCredentialPayload(), { + project: testProject, + }); + + const response = await authOwnerAgent + .put(`/credentials/${savedCredential.id}/share`) + .send({ shareWithIds: [memberPersonalProject.id] }); + + expect(response.statusCode).toBe(200); + expect(response.body.data).toBeUndefined(); + + const shares = await getCredentialSharings(savedCredential); + const testShare = shares.find((s) => s.projectId === memberPersonalProject.id); + expect(testShare).not.toBeUndefined(); + expect(testShare?.role).toBe('credential:user'); + }); + + test('admins should be able to share from any personal project to any team project ', async () => { + const testProject = await createTeamProject(); + + const savedCredential = await saveCredential(randomCredentialPayload(), { + user: member, + }); + + const response = await authOwnerAgent + .put(`/credentials/${savedCredential.id}/share`) + .send({ shareWithIds: [testProject.id] }); + + expect(response.statusCode).toBe(200); + expect(response.body.data).toBeUndefined(); + + const shares = await getCredentialSharings(savedCredential); + const testShare = shares.find((s) => s.projectId === testProject.id); + expect(testShare).not.toBeUndefined(); + expect(testShare?.role).toBe('credential:user'); + }); }); describe('PUT /:credentialId/transfer', () => { diff --git a/packages/cli/test/integration/credentials/credentials.api.test.ts b/packages/cli/test/integration/credentials/credentials.api.test.ts index 54de47b16e948..cd461b3dafe66 100644 --- a/packages/cli/test/integration/credentials/credentials.api.test.ts +++ b/packages/cli/test/integration/credentials/credentials.api.test.ts @@ -142,7 +142,13 @@ describe('GET /credentials', () => { // Team cred expect(cred1.id).toBe(savedCredential1.id); expect(cred1.scopes).toEqual( - ['credential:move', 'credential:read', 'credential:update', 'credential:delete'].sort(), + [ + 'credential:move', + 'credential:read', + 'credential:update', + 'credential:share', + 'credential:delete', + ].sort(), ); // Shared cred @@ -389,6 +395,21 @@ describe('GET /credentials', () => { expect(response2.body.data).toHaveLength(0); }); + test('should return homeProject when filtering credentials by projectId', async () => { + const project = await createTeamProject(undefined, member); + const credential = await saveCredential(payload(), { user: owner, role: 'credential:owner' }); + await shareCredentialWithProjects(credential, [project]); + + const response: GetAllResponse = await testServer + .authAgentFor(member) + .get('/credentials') + .query(`filter={ "projectId": "${project.id}" }`) + .expect(200); + + expect(response.body.data).toHaveLength(1); + expect(response.body.data[0].homeProject).not.toBeNull(); + }); + test('should return all credentials in a team project that member is part of', async () => { const teamProjectWithMember = await createTeamProject('Team Project With member', owner); void (await linkUserToProject(member, teamProjectWithMember, 'project:editor')); diff --git a/packages/cli/test/integration/execution.service.integration.test.ts b/packages/cli/test/integration/execution.service.integration.test.ts index e30d55602af95..41dcec3ce60e6 100644 --- a/packages/cli/test/integration/execution.service.integration.test.ts +++ b/packages/cli/test/integration/execution.service.integration.test.ts @@ -22,7 +22,6 @@ describe('ExecutionService', () => { mock(), mock(), mock(), - mock(), executionRepository, Container.get(WorkflowRepository), mock(), @@ -30,6 +29,7 @@ describe('ExecutionService', () => { mock(), mock(), mock(), + mock(), ); }); diff --git a/packages/cli/test/integration/executions.controller.test.ts b/packages/cli/test/integration/executions.controller.test.ts index 1e6d65a4f8368..94faa32351764 100644 --- a/packages/cli/test/integration/executions.controller.test.ts +++ b/packages/cli/test/integration/executions.controller.test.ts @@ -48,6 +48,16 @@ describe('GET /executions', () => { const response2 = await testServer.authAgentFor(member).get('/executions').expect(200); expect(response2.body.data.count).toBe(1); }); + + test('should return a scopes array for each execution', async () => { + testServer.license.enable('feat:sharing'); + const workflow = await createWorkflow({}, owner); + await shareWorkflowWithUsers(workflow, [member]); + await createSuccessfulExecution(workflow); + + const response = await testServer.authAgentFor(member).get('/executions').expect(200); + expect(response.body.data.results[0].scopes).toContain('workflow:execute'); + }); }); describe('GET /executions/:id', () => { diff --git a/packages/cli/test/integration/mfa/mfa.api.test.ts b/packages/cli/test/integration/mfa/mfa.api.test.ts index 7755ad4e3ceab..792ad1cb8876c 100644 --- a/packages/cli/test/integration/mfa/mfa.api.test.ts +++ b/packages/cli/test/integration/mfa/mfa.api.test.ts @@ -48,7 +48,8 @@ describe('Enable MFA setup', () => { secondCall.body.data.recoveryCodes.join(''), ); - await testServer.authAgentFor(owner).delete('/mfa/disable').expect(200); + const token = new TOTPService().generateTOTP(firstCall.body.data.secret); + await testServer.authAgentFor(owner).post('/mfa/disable').send({ token }).expect(200); const thirdCall = await testServer.authAgentFor(owner).get('/mfa/qr').expect(200); @@ -135,9 +136,16 @@ describe('Enable MFA setup', () => { describe('Disable MFA setup', () => { test('POST /disable should disable login with MFA', async () => { - const { user } = await createUserWithMfaEnabled(); + const { user, rawSecret } = await createUserWithMfaEnabled(); + const token = new TOTPService().generateTOTP(rawSecret); - await testServer.authAgentFor(user).delete('/mfa/disable').expect(200); + await testServer + .authAgentFor(user) + .post('/mfa/disable') + .send({ + token, + }) + .expect(200); const dbUser = await Container.get(AuthUserRepository).findOneOrFail({ where: { id: user.id }, @@ -147,6 +155,18 @@ describe('Disable MFA setup', () => { expect(dbUser.mfaSecret).toBe(null); expect(dbUser.mfaRecoveryCodes.length).toBe(0); }); + + test('POST /disable should fail if invalid token is given', async () => { + const { user } = await createUserWithMfaEnabled(); + + await testServer + .authAgentFor(user) + .post('/mfa/disable') + .send({ + token: 'invalid token', + }) + .expect(403); + }); }); describe('Change password with MFA enabled', () => { diff --git a/packages/cli/test/integration/prometheus-metrics.test.ts b/packages/cli/test/integration/prometheus-metrics.test.ts index 4c00a98f74a49..1eccb9b7d0ce2 100644 --- a/packages/cli/test/integration/prometheus-metrics.test.ts +++ b/packages/cli/test/integration/prometheus-metrics.test.ts @@ -5,45 +5,27 @@ import request, { type Response } from 'supertest'; import { N8N_VERSION } from '@/constants'; import { PrometheusMetricsService } from '@/metrics/prometheus-metrics.service'; import { setupTestServer } from './shared/utils'; -import { mockInstance } from '@test/mocking'; import { GlobalConfig } from '@n8n/config'; jest.unmock('@/eventbus/MessageEventBus/MessageEventBus'); const toLines = (response: Response) => response.text.trim().split('\n'); -mockInstance(GlobalConfig, { - database: { - type: 'sqlite', - sqlite: { - database: 'database.sqlite', - enableWAL: false, - executeVacuumOnStartup: false, - poolSize: 0, - }, - logging: { - enabled: false, - maxQueryExecutionTime: 0, - options: 'error', - }, - tablePrefix: '', - }, - endpoints: { - metrics: { - prefix: 'n8n_test_', - includeDefaultMetrics: true, - includeApiEndpoints: true, - includeCacheMetrics: true, - includeMessageEventBusMetrics: true, - includeCredentialTypeLabel: false, - includeNodeTypeLabel: false, - includeWorkflowIdLabel: false, - includeApiPathLabel: true, - includeApiMethodLabel: true, - includeApiStatusCodeLabel: true, - }, - }, -}); +const globalConfig = Container.get(GlobalConfig); +globalConfig.endpoints.metrics = { + enable: true, + prefix: 'n8n_test_', + includeDefaultMetrics: true, + includeApiEndpoints: true, + includeCacheMetrics: true, + includeMessageEventBusMetrics: true, + includeCredentialTypeLabel: false, + includeNodeTypeLabel: false, + includeWorkflowIdLabel: false, + includeApiPathLabel: true, + includeApiMethodLabel: true, + includeApiStatusCodeLabel: true, +}; const server = setupTestServer({ endpointGroups: ['metrics'] }); const agent = request.agent(server.app); diff --git a/packages/cli/test/integration/pruning.service.test.ts b/packages/cli/test/integration/pruning.service.test.ts index 09a43a5b5b123..37d218c09d438 100644 --- a/packages/cli/test/integration/pruning.service.test.ts +++ b/packages/cli/test/integration/pruning.service.test.ts @@ -1,5 +1,5 @@ import config from '@/config'; -import { BinaryDataService } from 'n8n-core'; +import { BinaryDataService, InstanceSettings } from 'n8n-core'; import type { ExecutionStatus } from 'n8n-workflow'; import Container from 'typedi'; @@ -15,10 +15,11 @@ import { mockInstance } from '../shared/mocking'; import { createWorkflow } from './shared/db/workflows'; import { createExecution, createSuccessfulExecution } from './shared/db/executions'; import { mock } from 'jest-mock-extended'; -import type { OrchestrationService } from '@/services/orchestration.service'; describe('softDeleteOnPruningCycle()', () => { let pruningService: PruningService; + const instanceSettings = new InstanceSettings(); + instanceSettings.markAsLeader(); const now = new Date(); const yesterday = new Date(Date.now() - TIME.DAY); @@ -29,9 +30,10 @@ describe('softDeleteOnPruningCycle()', () => { pruningService = new PruningService( mockInstance(Logger), + instanceSettings, Container.get(ExecutionRepository), mockInstance(BinaryDataService), - mock(), + mock(), ); workflow = await createWorkflow(); diff --git a/packages/cli/test/integration/publicApi/credentials.test.ts b/packages/cli/test/integration/publicApi/credentials.test.ts index dfa6c44dd4037..b5ed2bfb31d82 100644 --- a/packages/cli/test/integration/publicApi/credentials.test.ts +++ b/packages/cli/test/integration/publicApi/credentials.test.ts @@ -9,9 +9,10 @@ import { randomApiKey, randomName } from '../shared/random'; import * as utils from '../shared/utils/'; import type { CredentialPayload, SaveCredentialFunction } from '../shared/types'; import * as testDb from '../shared/testDb'; -import { affixRoleToSaveCredential } from '../shared/db/credentials'; +import { affixRoleToSaveCredential, createCredentials } from '../shared/db/credentials'; import { addApiKey, createUser, createUserShell } from '../shared/db/users'; import type { SuperAgentTest } from '../shared/types'; +import { createTeamProject } from '@test-integration/db/projects'; let owner: User; let member: User; @@ -256,6 +257,53 @@ describe('GET /credentials/schema/:credentialType', () => { }); }); +describe('PUT /credentials/:id/transfer', () => { + test('should transfer credential to project', async () => { + /** + * Arrange + */ + const [firstProject, secondProject] = await Promise.all([ + createTeamProject('first-project', owner), + createTeamProject('second-project', owner), + ]); + + const credentials = await createCredentials( + { name: 'Test', type: 'test', data: '' }, + firstProject, + ); + + /** + * Act + */ + const response = await authOwnerAgent.put(`/credentials/${credentials.id}/transfer`).send({ + destinationProjectId: secondProject.id, + }); + + /** + * Assert + */ + expect(response.statusCode).toBe(204); + }); + + test('if no destination project, should reject', async () => { + /** + * Arrange + */ + const project = await createTeamProject('first-project', member); + const credentials = await createCredentials({ name: 'Test', type: 'test', data: '' }, project); + + /** + * Act + */ + const response = await authOwnerAgent.put(`/credentials/${credentials.id}/transfer`).send({}); + + /** + * Assert + */ + expect(response.statusCode).toBe(400); + }); +}); + const credentialPayload = (): CredentialPayload => ({ name: randomName(), type: 'githubApi', diff --git a/packages/cli/test/integration/publicApi/executions.test.ts b/packages/cli/test/integration/publicApi/executions.test.ts index 9961dac545036..8dac97fc22381 100644 --- a/packages/cli/test/integration/publicApi/executions.test.ts +++ b/packages/cli/test/integration/publicApi/executions.test.ts @@ -20,6 +20,8 @@ import { import type { SuperAgentTest } from '../shared/types'; import { mockInstance } from '@test/mocking'; import { Telemetry } from '@/telemetry'; +import { createTeamProject } from '@test-integration/db/projects'; +import type { ExecutionEntity } from '@/databases/entities/ExecutionEntity'; let owner: User; let user1: User; @@ -447,6 +449,42 @@ describe('GET /executions', () => { } }); + test('should return executions filtered by project ID', async () => { + /** + * Arrange + */ + const [firstProject, secondProject] = await Promise.all([ + createTeamProject(), + createTeamProject(), + ]); + const [firstWorkflow, secondWorkflow] = await Promise.all([ + createWorkflow({}, firstProject), + createWorkflow({}, secondProject), + ]); + const [firstExecution, secondExecution, _] = await Promise.all([ + createExecution({}, firstWorkflow), + createExecution({}, firstWorkflow), + createExecution({}, secondWorkflow), + ]); + + /** + * Act + */ + const response = await authOwnerAgent.get('/executions').query({ + projectId: firstProject.id, + }); + + /** + * Assert + */ + expect(response.statusCode).toBe(200); + expect(response.body.data.length).toBe(2); + expect(response.body.nextCursor).toBeNull(); + expect(response.body.data.map((execution: ExecutionEntity) => execution.id)).toEqual( + expect.arrayContaining([firstExecution.id, secondExecution.id]), + ); + }); + test('owner should retrieve all executions regardless of ownership', async () => { const [firstWorkflowForUser1, secondWorkflowForUser1] = await createManyWorkflows(2, {}, user1); await createManyExecutions(2, firstWorkflowForUser1, createSuccessfulExecution); diff --git a/packages/cli/test/integration/publicApi/projects.test.ts b/packages/cli/test/integration/publicApi/projects.test.ts new file mode 100644 index 0000000000000..0554fd6f4116f --- /dev/null +++ b/packages/cli/test/integration/publicApi/projects.test.ts @@ -0,0 +1,401 @@ +import { setupTestServer } from '@test-integration/utils'; +import { createMember, createOwner } from '@test-integration/db/users'; +import * as testDb from '../shared/testDb'; +import { FeatureNotLicensedError } from '@/errors/feature-not-licensed.error'; +import { createTeamProject, getProjectByNameOrFail } from '@test-integration/db/projects'; +import { mockInstance } from '@test/mocking'; +import { Telemetry } from '@/telemetry'; + +describe('Projects in Public API', () => { + const testServer = setupTestServer({ endpointGroups: ['publicApi'] }); + mockInstance(Telemetry); + + beforeAll(async () => { + await testDb.init(); + }); + + beforeEach(async () => { + await testDb.truncate(['Project', 'User']); + }); + + describe('GET /projects', () => { + it('if licensed, should return all projects with pagination', async () => { + /** + * Arrange + */ + testServer.license.setQuota('quota:maxTeamProjects', -1); + testServer.license.enable('feat:projectRole:admin'); + const owner = await createOwner({ withApiKey: true }); + const projects = await Promise.all([ + createTeamProject(), + createTeamProject(), + createTeamProject(), + ]); + + /** + * Act + */ + const response = await testServer.publicApiAgentFor(owner).get('/projects'); + + /** + * Assert + */ + expect(response.status).toBe(200); + expect(response.body).toHaveProperty('data'); + expect(response.body).toHaveProperty('nextCursor'); + expect(Array.isArray(response.body.data)).toBe(true); + expect(response.body.data.length).toBe(projects.length + 1); // +1 for the owner's personal project + + projects.forEach(({ id, name }) => { + expect(response.body.data).toContainEqual(expect.objectContaining({ id, name })); + }); + }); + + it('if not authenticated, should reject', async () => { + /** + * Arrange + */ + const owner = await createOwner({ withApiKey: false }); + + /** + * Act + */ + const response = await testServer.publicApiAgentFor(owner).get('/projects'); + + /** + * Assert + */ + expect(response.status).toBe(401); + expect(response.body).toHaveProperty('message', "'X-N8N-API-KEY' header required"); + }); + + it('if not licensed, should reject', async () => { + /** + * Arrange + */ + const owner = await createOwner({ withApiKey: true }); + + /** + * Act + */ + const response = await testServer.publicApiAgentFor(owner).get('/projects'); + + /** + * Assert + */ + expect(response.status).toBe(403); + expect(response.body).toHaveProperty( + 'message', + new FeatureNotLicensedError('feat:projectRole:admin').message, + ); + }); + + it('if missing scope, should reject', async () => { + /** + * Arrange + */ + testServer.license.setQuota('quota:maxTeamProjects', -1); + testServer.license.enable('feat:projectRole:admin'); + const owner = await createMember({ withApiKey: true }); + + /** + * Act + */ + const response = await testServer.publicApiAgentFor(owner).get('/projects'); + + /** + * Assert + */ + expect(response.status).toBe(403); + expect(response.body).toHaveProperty('message', 'Forbidden'); + }); + }); + + describe('POST /projects', () => { + it('if licensed, should create a new project', async () => { + /** + * Arrange + */ + testServer.license.setQuota('quota:maxTeamProjects', -1); + testServer.license.enable('feat:projectRole:admin'); + const owner = await createOwner({ withApiKey: true }); + const projectPayload = { name: 'some-project' }; + + /** + * Act + */ + const response = await testServer + .publicApiAgentFor(owner) + .post('/projects') + .send(projectPayload); + + /** + * Assert + */ + expect(response.status).toBe(201); + expect(response.body).toEqual({ + name: 'some-project', + type: 'team', + id: expect.any(String), + createdAt: expect.any(String), + updatedAt: expect.any(String), + role: 'project:admin', + scopes: expect.any(Array), + }); + await expect(getProjectByNameOrFail(projectPayload.name)).resolves.not.toThrow(); + }); + + it('if not authenticated, should reject', async () => { + /** + * Arrange + */ + const owner = await createOwner({ withApiKey: false }); + const projectPayload = { name: 'some-project' }; + + /** + * Act + */ + const response = await testServer + .publicApiAgentFor(owner) + .post('/projects') + .send(projectPayload); + + /** + * Assert + */ + expect(response.status).toBe(401); + expect(response.body).toHaveProperty('message', "'X-N8N-API-KEY' header required"); + }); + + it('if not licensed, should reject', async () => { + /** + * Arrange + */ + const owner = await createOwner({ withApiKey: true }); + const projectPayload = { name: 'some-project' }; + + /** + * Act + */ + const response = await testServer + .publicApiAgentFor(owner) + .post('/projects') + .send(projectPayload); + + /** + * Assert + */ + expect(response.status).toBe(403); + expect(response.body).toHaveProperty( + 'message', + new FeatureNotLicensedError('feat:projectRole:admin').message, + ); + }); + + it('if missing scope, should reject', async () => { + /** + * Arrange + */ + testServer.license.setQuota('quota:maxTeamProjects', -1); + testServer.license.enable('feat:projectRole:admin'); + const member = await createMember({ withApiKey: true }); + const projectPayload = { name: 'some-project' }; + + /** + * Act + */ + const response = await testServer + .publicApiAgentFor(member) + .post('/projects') + .send(projectPayload); + + /** + * Assert + */ + expect(response.status).toBe(403); + expect(response.body).toHaveProperty('message', 'Forbidden'); + }); + }); + + describe('DELETE /projects/:id', () => { + it('if licensed, should delete a project', async () => { + /** + * Arrange + */ + testServer.license.setQuota('quota:maxTeamProjects', -1); + testServer.license.enable('feat:projectRole:admin'); + const owner = await createOwner({ withApiKey: true }); + const project = await createTeamProject(); + + /** + * Act + */ + const response = await testServer.publicApiAgentFor(owner).delete(`/projects/${project.id}`); + + /** + * Assert + */ + expect(response.status).toBe(204); + await expect(getProjectByNameOrFail(project.id)).rejects.toThrow(); + }); + + it('if not authenticated, should reject', async () => { + /** + * Arrange + */ + const owner = await createOwner({ withApiKey: false }); + const project = await createTeamProject(); + + /** + * Act + */ + const response = await testServer.publicApiAgentFor(owner).delete(`/projects/${project.id}`); + + /** + * Assert + */ + expect(response.status).toBe(401); + expect(response.body).toHaveProperty('message', "'X-N8N-API-KEY' header required"); + }); + + it('if not licensed, should reject', async () => { + /** + * Arrange + */ + const owner = await createOwner({ withApiKey: true }); + const project = await createTeamProject(); + + /** + * Act + */ + const response = await testServer.publicApiAgentFor(owner).delete(`/projects/${project.id}`); + + /** + * Assert + */ + expect(response.status).toBe(403); + expect(response.body).toHaveProperty( + 'message', + new FeatureNotLicensedError('feat:projectRole:admin').message, + ); + }); + + it('if missing scope, should reject', async () => { + /** + * Arrange + */ + testServer.license.setQuota('quota:maxTeamProjects', -1); + testServer.license.enable('feat:projectRole:admin'); + const member = await createMember({ withApiKey: true }); + const project = await createTeamProject(); + + /** + * Act + */ + const response = await testServer.publicApiAgentFor(member).delete(`/projects/${project.id}`); + + /** + * Assert + */ + expect(response.status).toBe(403); + expect(response.body).toHaveProperty('message', 'Forbidden'); + }); + }); + + describe('PUT /projects/:id', () => { + it('if licensed, should update a project', async () => { + /** + * Arrange + */ + testServer.license.setQuota('quota:maxTeamProjects', -1); + testServer.license.enable('feat:projectRole:admin'); + const owner = await createOwner({ withApiKey: true }); + const project = await createTeamProject('old-name'); + + /** + * Act + */ + const response = await testServer + .publicApiAgentFor(owner) + .put(`/projects/${project.id}`) + .send({ name: 'new-name' }); + + /** + * Assert + */ + expect(response.status).toBe(204); + await expect(getProjectByNameOrFail('new-name')).resolves.not.toThrow(); + }); + + it('if not authenticated, should reject', async () => { + /** + * Arrange + */ + const owner = await createOwner({ withApiKey: false }); + const project = await createTeamProject(); + + /** + * Act + */ + const response = await testServer + .publicApiAgentFor(owner) + .put(`/projects/${project.id}`) + .send({ name: 'new-name' }); + + /** + * Assert + */ + expect(response.status).toBe(401); + expect(response.body).toHaveProperty('message', "'X-N8N-API-KEY' header required"); + }); + + it('if not licensed, should reject', async () => { + /** + * Arrange + */ + const owner = await createOwner({ withApiKey: true }); + const project = await createTeamProject(); + + /** + * Act + */ + const response = await testServer + .publicApiAgentFor(owner) + .put(`/projects/${project.id}`) + .send({ name: 'new-name' }); + + /** + * Assert + */ + expect(response.status).toBe(403); + expect(response.body).toHaveProperty( + 'message', + new FeatureNotLicensedError('feat:projectRole:admin').message, + ); + }); + + it('if missing scope, should reject', async () => { + /** + * Arrange + */ + testServer.license.setQuota('quota:maxTeamProjects', -1); + testServer.license.enable('feat:projectRole:admin'); + const member = await createMember({ withApiKey: true }); + const project = await createTeamProject(); + + /** + * Act + */ + const response = await testServer + .publicApiAgentFor(member) + .put(`/projects/${project.id}`) + .send({ name: 'new-name' }); + + /** + * Assert + */ + expect(response.status).toBe(403); + expect(response.body).toHaveProperty('message', 'Forbidden'); + }); + }); +}); diff --git a/packages/cli/test/integration/publicApi/users.ee.test.ts b/packages/cli/test/integration/publicApi/users.ee.test.ts index 6e57a629d7dc7..be23d8f45a519 100644 --- a/packages/cli/test/integration/publicApi/users.ee.test.ts +++ b/packages/cli/test/integration/publicApi/users.ee.test.ts @@ -7,8 +7,10 @@ import { mockInstance } from '../../shared/mocking'; import { randomApiKey } from '../shared/random'; import * as utils from '../shared/utils/'; import * as testDb from '../shared/testDb'; -import { createUser, createUserShell } from '../shared/db/users'; +import { createOwner, createUser, createUserShell } from '../shared/db/users'; import type { SuperAgentTest } from '../shared/types'; +import { createTeamProject, linkUserToProject } from '@test-integration/db/projects'; +import type { User } from '@/databases/entities/User'; mockInstance(License, { getUsersLimit: jest.fn().mockReturnValue(-1), @@ -84,6 +86,46 @@ describe('With license unlimited quota:users', () => { expect(updatedAt).toBeDefined(); } }); + + it('should return users filtered by project ID', async () => { + /** + * Arrange + */ + const [owner, firstMember, secondMember, thirdMember] = await Promise.all([ + createOwner({ withApiKey: true }), + createUser({ role: 'global:member' }), + createUser({ role: 'global:member' }), + createUser({ role: 'global:member' }), + ]); + + const [firstProject, secondProject] = await Promise.all([ + createTeamProject(), + createTeamProject(), + ]); + + await Promise.all([ + linkUserToProject(firstMember, firstProject, 'project:admin'), + linkUserToProject(secondMember, firstProject, 'project:viewer'), + linkUserToProject(thirdMember, secondProject, 'project:admin'), + ]); + + /** + * Act + */ + const response = await testServer.publicApiAgentFor(owner).get('/users').query({ + projectId: firstProject.id, + }); + + /** + * Assert + */ + expect(response.status).toBe(200); + expect(response.body.data.length).toBe(2); + expect(response.body.nextCursor).toBeNull(); + expect(response.body.data.map((user: User) => user.id)).toEqual( + expect.arrayContaining([firstMember.id, secondMember.id]), + ); + }); }); describe('GET /users/:id', () => { diff --git a/packages/cli/test/integration/publicApi/users.test.ts b/packages/cli/test/integration/publicApi/users.test.ts new file mode 100644 index 0000000000000..6021ae01a35f5 --- /dev/null +++ b/packages/cli/test/integration/publicApi/users.test.ts @@ -0,0 +1,252 @@ +import { setupTestServer } from '@test-integration/utils'; +import * as testDb from '../shared/testDb'; +import { createMember, createOwner, getUserById } from '@test-integration/db/users'; +import { mockInstance } from '@test/mocking'; +import { Telemetry } from '@/telemetry'; +import { FeatureNotLicensedError } from '@/errors/feature-not-licensed.error'; + +describe('Users in Public API', () => { + const testServer = setupTestServer({ endpointGroups: ['publicApi'] }); + mockInstance(Telemetry); + + beforeAll(async () => { + await testDb.init(); + }); + + beforeEach(async () => { + await testDb.truncate(['User']); + }); + + describe('POST /users', () => { + it('if not authenticated, should reject', async () => { + /** + * Arrange + */ + const owner = await createOwner({ withApiKey: false }); + const payload = { email: 'test@test.com', role: 'global:admin' }; + + /** + * Act + */ + const response = await testServer.publicApiAgentFor(owner).post('/users').send(payload); + + /** + * Assert + */ + expect(response.status).toBe(401); + }); + + it('if missing scope, should reject', async () => { + /** + * Arrange + */ + testServer.license.enable('feat:advancedPermissions'); + const member = await createMember({ withApiKey: true }); + const payload = [{ email: 'test@test.com', role: 'global:admin' }]; + + /** + * Act + */ + const response = await testServer.publicApiAgentFor(member).post('/users').send(payload); + + /** + * Assert + */ + expect(response.status).toBe(403); + expect(response.body).toHaveProperty('message', 'Forbidden'); + }); + + it('should create a user', async () => { + /** + * Arrange + */ + testServer.license.enable('feat:advancedPermissions'); + const owner = await createOwner({ withApiKey: true }); + const payload = [{ email: 'test@test.com', role: 'global:admin' }]; + + /** + * Act + */ + const response = await testServer.publicApiAgentFor(owner).post('/users').send(payload); + + /** + * Assert + */ + expect(response.status).toBe(201); + + expect(response.body).toHaveLength(1); + + const [result] = response.body; + const { user: returnedUser, error } = result; + const payloadUser = payload[0]; + + expect(returnedUser).toHaveProperty('email', payload[0].email); + expect(typeof returnedUser.inviteAcceptUrl).toBe('string'); + expect(typeof returnedUser.emailSent).toBe('boolean'); + expect(error).toBe(''); + + const storedUser = await getUserById(returnedUser.id); + expect(returnedUser.id).toBe(storedUser.id); + expect(returnedUser.email).toBe(storedUser.email); + expect(returnedUser.email).toBe(payloadUser.email); + expect(storedUser.role).toBe(payloadUser.role); + }); + }); + + describe('DELETE /users/:id', () => { + it('if not authenticated, should reject', async () => { + /** + * Arrange + */ + const owner = await createOwner({ withApiKey: false }); + const member = await createMember(); + + /** + * Act + */ + const response = await testServer.publicApiAgentFor(owner).delete(`/users/${member.id}`); + + /** + * Assert + */ + expect(response.status).toBe(401); + }); + + it('if missing scope, should reject', async () => { + /** + * Arrange + */ + testServer.license.enable('feat:advancedPermissions'); + const firstMember = await createMember({ withApiKey: true }); + const secondMember = await createMember(); + + /** + * Act + */ + const response = await testServer + .publicApiAgentFor(firstMember) + .delete(`/users/${secondMember.id}`); + + /** + * Assert + */ + expect(response.status).toBe(403); + expect(response.body).toHaveProperty('message', 'Forbidden'); + }); + + it('should delete a user', async () => { + /** + * Arrange + */ + testServer.license.enable('feat:advancedPermissions'); + const owner = await createOwner({ withApiKey: true }); + const member = await createMember(); + + /** + * Act + */ + const response = await testServer.publicApiAgentFor(owner).delete(`/users/${member.id}`); + + /** + * Assert + */ + expect(response.status).toBe(204); + await expect(getUserById(member.id)).rejects.toThrow(); + }); + }); + + describe('PATCH /users/:id/role', () => { + it('if not authenticated, should reject', async () => { + /** + * Arrange + */ + const owner = await createOwner({ withApiKey: false }); + const member = await createMember(); + + /** + * Act + */ + const response = await testServer.publicApiAgentFor(owner).patch(`/users/${member.id}/role`); + + /** + * Assert + */ + expect(response.status).toBe(401); + }); + + it('if not licensed, should reject', async () => { + /** + * Arrange + */ + const owner = await createOwner({ withApiKey: true }); + const member = await createMember(); + const payload = { newRoleName: 'global:admin' }; + + /** + * Act + */ + const response = await testServer + .publicApiAgentFor(owner) + .patch(`/users/${member.id}/role`) + .send(payload); + + /** + * Assert + */ + expect(response.status).toBe(403); + expect(response.body).toHaveProperty( + 'message', + new FeatureNotLicensedError('feat:advancedPermissions').message, + ); + }); + + it('if missing scope, should reject', async () => { + /** + * Arrange + */ + testServer.license.enable('feat:advancedPermissions'); + const firstMember = await createMember({ withApiKey: true }); + const secondMember = await createMember(); + const payload = { newRoleName: 'global:admin' }; + + /** + * Act + */ + const response = await testServer + .publicApiAgentFor(firstMember) + .patch(`/users/${secondMember.id}/role`) + .send(payload); + + /** + * Assert + */ + expect(response.status).toBe(403); + expect(response.body).toHaveProperty('message', 'Forbidden'); + }); + + it("should change a user's role", async () => { + /** + * Arrange + */ + testServer.license.enable('feat:advancedPermissions'); + const owner = await createOwner({ withApiKey: true }); + const member = await createMember(); + const payload = { newRoleName: 'global:admin' }; + + /** + * Act + */ + const response = await testServer + .publicApiAgentFor(owner) + .patch(`/users/${member.id}/role`) + .send(payload); + + /** + * Assert + */ + expect(response.status).toBe(204); + const storedUser = await getUserById(member.id); + expect(storedUser.role).toBe(payload.newRoleName); + }); + }); +}); diff --git a/packages/cli/test/integration/publicApi/workflows.test.ts b/packages/cli/test/integration/publicApi/workflows.test.ts index 855e69e3df12a..5185f3862de89 100644 --- a/packages/cli/test/integration/publicApi/workflows.test.ts +++ b/packages/cli/test/integration/publicApi/workflows.test.ts @@ -267,8 +267,30 @@ describe('GET /workflows', () => { } }); - test('should return all user-accessible workflows filtered by `projectId`', async () => { - license.setQuota('quota:maxTeamProjects', 2); + test('for owner, should return all workflows filtered by `projectId`', async () => { + license.setQuota('quota:maxTeamProjects', -1); + const firstProject = await Container.get(ProjectService).createTeamProject('First', owner); + const secondProject = await Container.get(ProjectService).createTeamProject('Second', member); + + await Promise.all([ + createWorkflow({ name: 'First workflow' }, firstProject), + createWorkflow({ name: 'Second workflow' }, secondProject), + ]); + + const firstResponse = await authOwnerAgent.get(`/workflows?projectId=${firstProject.id}`); + const secondResponse = await authOwnerAgent.get(`/workflows?projectId=${secondProject.id}`); + + expect(firstResponse.statusCode).toBe(200); + expect(firstResponse.body.data.length).toBe(1); + expect(firstResponse.body.data[0].name).toBe('First workflow'); + + expect(secondResponse.statusCode).toBe(200); + expect(secondResponse.body.data.length).toBe(1); + expect(secondResponse.body.data[0].name).toBe('Second workflow'); + }); + + test('for member, should return all member-accessible workflows filtered by `projectId`', async () => { + license.setQuota('quota:maxTeamProjects', -1); const otherProject = await Container.get(ProjectService).createTeamProject( 'Other project', member, @@ -1493,7 +1515,7 @@ describe('PUT /workflows/:id/transfer', () => { * Arrange */ const firstProject = await createTeamProject('first-project', member); - const secondProject = await createTeamProject('secon-project', member); + const secondProject = await createTeamProject('second-project', member); const workflow = await createWorkflow({}, firstProject); /** diff --git a/packages/cli/test/integration/shared/db/credentials.ts b/packages/cli/test/integration/shared/db/credentials.ts index 046d27db261a0..588fee6b51196 100644 --- a/packages/cli/test/integration/shared/db/credentials.ts +++ b/packages/cli/test/integration/shared/db/credentials.ts @@ -38,11 +38,24 @@ export async function createManyCredentials( ); } -export async function createCredentials(attributes: Partial = emptyAttributes) { +export async function createCredentials( + attributes: Partial = emptyAttributes, + project?: Project, +) { const credentialsRepository = Container.get(CredentialsRepository); - const entity = credentialsRepository.create(attributes); + const credentials = await credentialsRepository.save(credentialsRepository.create(attributes)); + + if (project) { + await Container.get(SharedCredentialsRepository).save( + Container.get(SharedCredentialsRepository).create({ + project, + credentials, + role: 'credential:owner', + }), + ); + } - return await credentialsRepository.save(entity); + return credentials; } /** diff --git a/packages/cli/test/integration/shared/db/projects.ts b/packages/cli/test/integration/shared/db/projects.ts index 60548575b362b..3de7de5bb95f9 100644 --- a/packages/cli/test/integration/shared/db/projects.ts +++ b/packages/cli/test/integration/shared/db/projects.ts @@ -34,6 +34,10 @@ export const linkUserToProject = async (user: User, project: Project, role: Proj ); }; +export async function getProjectByNameOrFail(name: string) { + return await Container.get(ProjectRepository).findOneOrFail({ where: { name } }); +} + export const getPersonalProject = async (user: User): Promise => { return await Container.get(ProjectRepository).findOneOrFail({ where: { diff --git a/packages/cli/test/integration/shared/db/users.ts b/packages/cli/test/integration/shared/db/users.ts index f125f5ccded4f..98626bc549d9e 100644 --- a/packages/cli/test/integration/shared/db/users.ts +++ b/packages/cli/test/integration/shared/db/users.ts @@ -86,7 +86,11 @@ export async function createOwner({ withApiKey } = { withApiKey: false }) { return await createUser({ role: 'global:owner' }); } -export async function createMember() { +export async function createMember({ withApiKey } = { withApiKey: false }) { + if (withApiKey) { + return await addApiKey(await createUser({ role: 'global:member' })); + } + return await createUser({ role: 'global:member' }); } diff --git a/packages/cli/test/integration/shared/utils/testCommand.ts b/packages/cli/test/integration/shared/utils/testCommand.ts index 7a8477c4dd1b8..9592b806c3c75 100644 --- a/packages/cli/test/integration/shared/utils/testCommand.ts +++ b/packages/cli/test/integration/shared/utils/testCommand.ts @@ -4,8 +4,11 @@ import { mock } from 'jest-mock-extended'; import type { BaseCommand } from '@/commands/BaseCommand'; import * as testDb from '../testDb'; -import { TelemetryEventRelay } from '@/telemetry/telemetry-event-relay.service'; +import { TelemetryEventRelay } from '@/events/telemetry-event-relay'; import { mockInstance } from '@test/mocking'; +import { InternalHooks } from '@/InternalHooks'; + +mockInstance(InternalHooks); export const setupTestCommand = (Command: Class) => { const config = mock(); diff --git a/packages/cli/test/integration/shared/utils/testServer.ts b/packages/cli/test/integration/shared/utils/testServer.ts index 7776b7e669415..319c4e2b588db 100644 --- a/packages/cli/test/integration/shared/utils/testServer.ts +++ b/packages/cli/test/integration/shared/utils/testServer.ts @@ -14,7 +14,6 @@ import { PostHogClient } from '@/posthog'; import { Push } from '@/push'; import { License } from '@/License'; import { Logger } from '@/Logger'; -import { InternalHooks } from '@/InternalHooks'; import { AuthService } from '@/auth/auth.service'; import type { APIRequest } from '@/requests'; @@ -82,7 +81,6 @@ export const setupTestServer = ({ // Mock all telemetry and logging mockInstance(Logger); - mockInstance(InternalHooks); mockInstance(PostHogClient); mockInstance(Push); diff --git a/packages/cli/test/integration/webhooks.api.test.ts b/packages/cli/test/integration/webhooks.api.test.ts index 64cb760c8ec69..5b61dd98611ac 100644 --- a/packages/cli/test/integration/webhooks.api.test.ts +++ b/packages/cli/test/integration/webhooks.api.test.ts @@ -4,7 +4,6 @@ import type { INodeType, INodeTypeDescription, IWebhookFunctions } from 'n8n-wor import { AbstractServer } from '@/AbstractServer'; import { ExternalHooks } from '@/ExternalHooks'; -import { InternalHooks } from '@/InternalHooks'; import { NodeTypes } from '@/NodeTypes'; import { Push } from '@/push'; import type { WorkflowEntity } from '@db/entities/WorkflowEntity'; @@ -21,7 +20,6 @@ mockInstance(Telemetry); describe('Webhook API', () => { mockInstance(ExternalHooks); - mockInstance(InternalHooks); mockInstance(Push); let agent: SuperAgentTest; diff --git a/packages/cli/test/unit/webhooks.test.ts b/packages/cli/test/integration/webhooks.test.ts similarity index 84% rename from packages/cli/test/unit/webhooks.test.ts rename to packages/cli/test/integration/webhooks.test.ts index c891588597ac5..a729f9515818a 100644 --- a/packages/cli/test/unit/webhooks.test.ts +++ b/packages/cli/test/integration/webhooks.test.ts @@ -3,15 +3,14 @@ import { agent as testAgent } from 'supertest'; import { mock } from 'jest-mock-extended'; import { AbstractServer } from '@/AbstractServer'; -import { ActiveWebhooks } from '@/ActiveWebhooks'; +import { LiveWebhooks } from '@/webhooks/LiveWebhooks'; import { ExternalHooks } from '@/ExternalHooks'; -import { InternalHooks } from '@/InternalHooks'; -import { TestWebhooks } from '@/TestWebhooks'; -import { WaitingWebhooks } from '@/WaitingWebhooks'; +import { TestWebhooks } from '@/webhooks/TestWebhooks'; +import { WaitingWebhooks } from '@/webhooks/WaitingWebhooks'; import { WaitingForms } from '@/WaitingForms'; -import type { IResponseCallbackData } from '@/Interfaces'; +import type { IWebhookResponseCallbackData } from '@/webhooks/webhook.types'; -import { mockInstance } from '../shared/mocking'; +import { mockInstance } from '@test/mocking'; import { GlobalConfig } from '@n8n/config'; import Container from 'typedi'; @@ -19,11 +18,10 @@ let agent: SuperAgentTest; describe('WebhookServer', () => { mockInstance(ExternalHooks); - mockInstance(InternalHooks); describe('CORS', () => { const corsOrigin = 'https://example.com'; - const activeWebhooks = mockInstance(ActiveWebhooks); + const liveWebhooks = mockInstance(LiveWebhooks); const testWebhooks = mockInstance(TestWebhooks); mockInstance(WaitingWebhooks); mockInstance(WaitingForms); @@ -37,7 +35,7 @@ describe('WebhookServer', () => { }); const tests = [ - ['webhook', activeWebhooks], + ['webhook', liveWebhooks], ['webhookTest', testWebhooks], // TODO: enable webhookWaiting & waitingForms after CORS support is added // ['webhookWaiting', waitingWebhooks], @@ -80,7 +78,7 @@ describe('WebhookServer', () => { } const mockResponse = (data = {}, headers = {}, status = 200) => { - const response = mock(); + const response = mock(); response.responseCode = status; response.data = data; response.headers = headers; diff --git a/packages/cli/test/unit/shared/mockObjects.ts b/packages/cli/test/shared/mockObjects.ts similarity index 94% rename from packages/cli/test/unit/shared/mockObjects.ts rename to packages/cli/test/shared/mockObjects.ts index e7a165977301f..a8795e8e1013f 100644 --- a/packages/cli/test/unit/shared/mockObjects.ts +++ b/packages/cli/test/shared/mockObjects.ts @@ -8,7 +8,7 @@ import { randomEmail, randomName, uniqueId, -} from '../../integration/shared/random'; +} from '../integration/shared/random'; export const mockCredential = (): CredentialsEntity => Object.assign(new CredentialsEntity(), randomCredentialPayload()); diff --git a/packages/cli/test/unit/shared/testData.ts b/packages/cli/test/shared/testData.ts similarity index 100% rename from packages/cli/test/unit/shared/testData.ts rename to packages/cli/test/shared/testData.ts diff --git a/packages/cli/test/unit/Helpers.ts b/packages/cli/test/unit/Helpers.ts deleted file mode 100644 index 50b9f43489076..0000000000000 --- a/packages/cli/test/unit/Helpers.ts +++ /dev/null @@ -1,38 +0,0 @@ -import type { INodeTypeData } from 'n8n-workflow'; - -/** - * Ensure all pending promises settle. The promise's `resolve` is placed in - * the macrotask queue and so called at the next iteration of the event loop - * after all promises in the microtask queue have settled first. - */ -export const flushPromises = async () => await new Promise(setImmediate); - -export function mockNodeTypesData( - nodeNames: string[], - options?: { - addTrigger?: boolean; - }, -) { - return nodeNames.reduce((acc, nodeName) => { - return ( - (acc[`n8n-nodes-base.${nodeName}`] = { - sourcePath: '', - type: { - description: { - displayName: nodeName, - name: nodeName, - group: [], - description: '', - version: 1, - defaults: {}, - inputs: [], - outputs: [], - properties: [], - }, - trigger: options?.addTrigger ? async () => undefined : undefined, - }, - }), - acc - ); - }, {}); -} diff --git a/packages/cli/test/unit/WebhookHelpers.test.ts b/packages/cli/test/unit/WebhookHelpers.test.ts deleted file mode 100644 index 391d01b6fcfe4..0000000000000 --- a/packages/cli/test/unit/WebhookHelpers.test.ts +++ /dev/null @@ -1,142 +0,0 @@ -import { type Response } from 'express'; -import { mock } from 'jest-mock-extended'; -import { randomString } from 'n8n-workflow'; -import type { IHttpRequestMethods } from 'n8n-workflow'; - -import type { IWebhookManager, WebhookCORSRequest, WebhookRequest } from '@/Interfaces'; -import { webhookRequestHandler } from '@/WebhookHelpers'; - -describe('WebhookHelpers', () => { - describe('webhookRequestHandler', () => { - const webhookManager = mock>(); - const handler = webhookRequestHandler(webhookManager); - - beforeEach(() => { - jest.resetAllMocks(); - }); - - it('should throw for unsupported methods', async () => { - const req = mock({ - method: 'CONNECT' as IHttpRequestMethods, - }); - const res = mock(); - res.status.mockReturnValue(res); - - await handler(req, res); - - expect(res.status).toHaveBeenCalledWith(500); - expect(res.json).toHaveBeenCalledWith({ - code: 0, - message: 'The method CONNECT is not supported.', - }); - }); - - describe('preflight requests', () => { - it('should handle missing header for requested method', async () => { - const req = mock({ - method: 'OPTIONS', - headers: { - origin: 'https://example.com', - 'access-control-request-method': undefined, - }, - params: { path: 'test' }, - }); - const res = mock(); - res.status.mockReturnValue(res); - - webhookManager.getWebhookMethods.mockResolvedValue(['GET', 'PATCH']); - - await handler(req, res); - - expect(res.status).toHaveBeenCalledWith(204); - expect(res.header).toHaveBeenCalledWith( - 'Access-Control-Allow-Methods', - 'OPTIONS, GET, PATCH', - ); - }); - - it('should handle default origin and max-age', async () => { - const req = mock({ - method: 'OPTIONS', - headers: { - origin: 'https://example.com', - 'access-control-request-method': 'GET', - }, - params: { path: 'test' }, - }); - const res = mock(); - res.status.mockReturnValue(res); - - webhookManager.getWebhookMethods.mockResolvedValue(['GET', 'PATCH']); - - await handler(req, res); - - expect(res.status).toHaveBeenCalledWith(204); - expect(res.header).toHaveBeenCalledWith( - 'Access-Control-Allow-Methods', - 'OPTIONS, GET, PATCH', - ); - expect(res.header).toHaveBeenCalledWith( - 'Access-Control-Allow-Origin', - 'https://example.com', - ); - expect(res.header).toHaveBeenCalledWith('Access-Control-Max-Age', '300'); - }); - - it('should handle wildcard origin', async () => { - const randomOrigin = randomString(10); - const req = mock({ - method: 'OPTIONS', - headers: { - origin: randomOrigin, - 'access-control-request-method': 'GET', - }, - params: { path: 'test' }, - }); - const res = mock(); - res.status.mockReturnValue(res); - - webhookManager.getWebhookMethods.mockResolvedValue(['GET', 'PATCH']); - webhookManager.findAccessControlOptions.mockResolvedValue({ - allowedOrigins: '*', - }); - - await handler(req, res); - - expect(res.status).toHaveBeenCalledWith(204); - expect(res.header).toHaveBeenCalledWith( - 'Access-Control-Allow-Methods', - 'OPTIONS, GET, PATCH', - ); - expect(res.header).toHaveBeenCalledWith('Access-Control-Allow-Origin', randomOrigin); - }); - - it('should handle custom origin', async () => { - const req = mock({ - method: 'OPTIONS', - headers: { - origin: 'https://example.com', - 'access-control-request-method': 'GET', - }, - params: { path: 'test' }, - }); - const res = mock(); - res.status.mockReturnValue(res); - - webhookManager.getWebhookMethods.mockResolvedValue(['GET', 'PATCH']); - webhookManager.findAccessControlOptions.mockResolvedValue({ - allowedOrigins: 'https://test.com', - }); - - await handler(req, res); - - expect(res.status).toHaveBeenCalledWith(204); - expect(res.header).toHaveBeenCalledWith( - 'Access-Control-Allow-Methods', - 'OPTIONS, GET, PATCH', - ); - expect(res.header).toHaveBeenCalledWith('Access-Control-Allow-Origin', 'https://test.com'); - }); - }); - }); -}); diff --git a/packages/core/README.md b/packages/core/README.md index 0518567fc9bce..ad67d1a3798d6 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -10,8 +10,4 @@ npm install n8n-core ## License -n8n is [fair-code](https://faircode.io) distributed under the [**Sustainable Use License**](https://github.com/n8n-io/n8n/blob/master/packages/cli/LICENSE.md). - -Proprietary licenses are available for enterprise customers. [Get in touch](mailto:license@n8n.io) - -Additional information about the license can be found in the [docs](https://docs.n8n.io/reference/license/). +You can find the license information [here](https://github.com/n8n-io/n8n/blob/master/README.md#license) diff --git a/packages/core/bin/generate-ui-types b/packages/core/bin/generate-ui-types index 988462d604e08..8cecb6b054d74 100755 --- a/packages/core/bin/generate-ui-types +++ b/packages/core/bin/generate-ui-types @@ -33,8 +33,11 @@ function findReferencedMethods(obj, refs = {}, latestName = '') { const knownCredentials = loader.known.credentials; const credentialTypes = Object.values(loader.credentialTypes).map((data) => { const credentialType = data.type; - if (knownCredentials[credentialType.name].supportedNodes?.length > 0) { - delete credentialType.httpRequestNode; + if ( + knownCredentials[credentialType.name].supportedNodes?.length > 0 && + credentialType.httpRequestNode + ) { + credentialType.httpRequestNode.hidden = true; } return credentialType; }); diff --git a/packages/core/package.json b/packages/core/package.json index dfcc23db99d92..1f857b47e5ea5 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "n8n-core", - "version": "1.53.0", + "version": "1.54.0", "description": "Core functionality of n8n", "main": "dist/index", "types": "dist/index.d.ts", diff --git a/packages/core/src/InstanceSettings.ts b/packages/core/src/InstanceSettings.ts index e8ab9aa553223..f75e1df712108 100644 --- a/packages/core/src/InstanceSettings.ts +++ b/packages/core/src/InstanceSettings.ts @@ -14,6 +14,8 @@ interface WritableSettings { type Settings = ReadOnlySettings & WritableSettings; +type InstanceRole = 'unset' | 'leader' | 'follower'; + const inTest = process.env.NODE_ENV === 'test'; @Service() @@ -38,6 +40,25 @@ export class InstanceSettings { readonly instanceId = this.generateInstanceId(); + /** Always `leader` in single-main setup. `leader` or `follower` in multi-main setup. */ + private instanceRole: InstanceRole = 'unset'; + + get isLeader() { + return this.instanceRole === 'leader'; + } + + markAsLeader() { + this.instanceRole = 'leader'; + } + + get isFollower() { + return this.instanceRole === 'follower'; + } + + markAsFollower() { + this.instanceRole = 'follower'; + } + get encryptionKey() { return this.settings.encryptionKey; } diff --git a/packages/core/src/NodeExecuteFunctions.ts b/packages/core/src/NodeExecuteFunctions.ts index cf677ba5bd036..f97d86805911f 100644 --- a/packages/core/src/NodeExecuteFunctions.ts +++ b/packages/core/src/NodeExecuteFunctions.ts @@ -1342,7 +1342,9 @@ export async function requestOAuth2( // if it's the first time using the credentials, get the access token and save it into the DB. if ( credentials.grantType === 'clientCredentials' && - (oauthTokenData === undefined || Object.keys(oauthTokenData).length === 0) + (oauthTokenData === undefined || + Object.keys(oauthTokenData).length === 0 || + oauthTokenData.access_token === '') // stub ) { const { data } = await oAuthClient.credentials.getToken(); // Find the credentials @@ -3324,7 +3326,7 @@ const getAllowedPaths = () => { return allowedPaths; }; -function isFilePathBlocked(filePath: string): boolean { +export function isFilePathBlocked(filePath: string): boolean { const allowedPaths = getAllowedPaths(); const resolvedFilePath = path.resolve(filePath); const blockFileAccessToN8nFiles = process.env[BLOCK_FILE_ACCESS_TO_N8N_FILES] !== 'false'; @@ -3340,10 +3342,10 @@ function isFilePathBlocked(filePath: string): boolean { return true; } - //restrict access to .n8n folder and other .env config related paths + //restrict access to .n8n folder, ~/.cache/n8n/public, and other .env config related paths if (blockFileAccessToN8nFiles) { - const { n8nFolder } = Container.get(InstanceSettings); - const restrictedPaths = [n8nFolder]; + const { n8nFolder, staticCacheDir } = Container.get(InstanceSettings); + const restrictedPaths = [n8nFolder, staticCacheDir]; if (process.env[CONFIG_FILES]) { restrictedPaths.push(...process.env[CONFIG_FILES].split(',')); diff --git a/packages/core/src/WorkflowExecute.ts b/packages/core/src/WorkflowExecute.ts index ce17eafa9bb0d..303b46b20aa2f 100644 --- a/packages/core/src/WorkflowExecute.ts +++ b/packages/core/src/WorkflowExecute.ts @@ -44,7 +44,7 @@ import { ApplicationError, NodeExecutionOutput, sleep, - OBFUSCATED_ERROR_MESSAGE, + ErrorReporterProxy, } from 'n8n-workflow'; import get from 'lodash/get'; import * as NodeExecuteFunctions from './NodeExecuteFunctions'; @@ -1318,12 +1318,30 @@ export class WorkflowExecute { } catch (error) { this.runExecutionData.resultData.lastNodeExecuted = executionData.node.name; - const message = - error instanceof ApplicationError ? error.message : OBFUSCATED_ERROR_MESSAGE; + let toReport: Error | undefined; + if (error instanceof ApplicationError) { + // Report any unhandled errors that were wrapped in by one of our error classes + if (error.cause instanceof Error) toReport = error.cause; + } else { + // Report any unhandled and non-wrapped errors to Sentry + toReport = error; + // Set obfuscate to true so that the error would be obfuscated in th UI + error.obfuscate = true; + } + if (toReport) { + ErrorReporterProxy.error(toReport, { + extra: { + nodeName: executionNode.name, + nodeType: executionNode.type, + nodeVersion: executionNode.typeVersion, + workflowId: workflow.id, + }, + }); + } const e = error as unknown as ExecutionBaseError; - executionError = { ...e, message, stack: e.stack }; + executionError = { ...e, message: e.message, stack: e.stack }; Logger.debug(`Running node "${executionNode.name}" finished with error`, { node: executionNode.name, diff --git a/packages/core/test/NodeExecuteFunctions.test.ts b/packages/core/test/NodeExecuteFunctions.test.ts index 8ff4ca22e59a8..3af9c752f67d1 100644 --- a/packages/core/test/NodeExecuteFunctions.test.ts +++ b/packages/core/test/NodeExecuteFunctions.test.ts @@ -4,6 +4,7 @@ import { copyInputItems, ensureType, getBinaryDataBuffer, + isFilePathBlocked, parseIncomingMessage, parseRequestObject, proxyRequestToAxios, @@ -34,6 +35,7 @@ import { join } from 'path'; import Container from 'typedi'; import type { Agent } from 'https'; import toPlainObject from 'lodash/toPlainObject'; +import { InstanceSettings } from '@/InstanceSettings'; const temporaryDir = mkdtempSync(join(tmpdir(), 'n8n')); @@ -663,3 +665,11 @@ describe('NodeExecuteFunctions', () => { }); }); }); + +describe('isFilePathBlocked', () => { + test('should return true for static cache dir', () => { + const filePath = Container.get(InstanceSettings).staticCacheDir; + + expect(isFilePathBlocked(filePath)).toBe(true); + }); +}); diff --git a/packages/design-system/.storybook/storybook.scss b/packages/design-system/.storybook/storybook.scss index 86a675f90cff2..16aaf700f403e 100644 --- a/packages/design-system/.storybook/storybook.scss +++ b/packages/design-system/.storybook/storybook.scss @@ -15,3 +15,7 @@ #storybook-root > * { margin: var(--spacing-5xs); } + +body { + padding: 0 !important; +} diff --git a/packages/design-system/README.md b/packages/design-system/README.md index ea69f24524a5b..c43317d077334 100644 --- a/packages/design-system/README.md +++ b/packages/design-system/README.md @@ -48,8 +48,4 @@ pnpm watch:theme ## License -n8n is [fair-code](https://faircode.io) distributed under the [**Sustainable Use License**](https://github.com/n8n-io/n8n/blob/master/packages/cli/LICENSE.md). - -Proprietary licenses are available for enterprise customers. [Get in touch](mailto:license@n8n.io) - -Additional information about the license can be found in the [docs](https://docs.n8n.io/reference/license/). +You can find the license information [here](https://github.com/n8n-io/n8n/blob/master/README.md#license) diff --git a/packages/design-system/package.json b/packages/design-system/package.json index 9885f9872f2f3..9c04bd801b3af 100644 --- a/packages/design-system/package.json +++ b/packages/design-system/package.json @@ -1,6 +1,6 @@ { "name": "n8n-design-system", - "version": "1.43.0", + "version": "1.44.0", "main": "src/main.ts", "import": "src/main.ts", "scripts": { @@ -22,9 +22,9 @@ "@testing-library/jest-dom": "^6.1.5", "@testing-library/user-event": "^14.5.1", "@testing-library/vue": "^8.0.1", - "@types/markdown-it": "^12.2.3", + "@types/markdown-it": "^13.0.9", "@types/markdown-it-emoji": "^2.0.2", - "@types/markdown-it-link-attributes": "^3.0.1", + "@types/markdown-it-link-attributes": "^3.0.5", "@types/sanitize-html": "^2.11.0", "@vitejs/plugin-vue": "^5.0.4", "@vitest/coverage-v8": "catalog:frontend", @@ -45,7 +45,7 @@ "@fortawesome/free-solid-svg-icons": "^5.15.4", "@fortawesome/vue-fontawesome": "^3.0.3", "element-plus": "2.4.3", - "markdown-it": "^13.0.1", + "markdown-it": "^13.0.2", "markdown-it-emoji": "^2.0.2", "markdown-it-link-attributes": "^4.0.1", "markdown-it-task-lists": "^2.1.1", diff --git a/packages/design-system/src/components/N8nActionBox/ActionBox.vue b/packages/design-system/src/components/N8nActionBox/ActionBox.vue index 94f2e1376e702..575895ab1585b 100644 --- a/packages/design-system/src/components/N8nActionBox/ActionBox.vue +++ b/packages/design-system/src/components/N8nActionBox/ActionBox.vue @@ -15,13 +15,19 @@ - + + + + should render correctly 1`] = `
- + + + " `; diff --git a/packages/design-system/src/components/N8nActionDropdown/ActionDropdown.vue b/packages/design-system/src/components/N8nActionDropdown/ActionDropdown.vue index 79a8e883f9fb3..649d4bc570491 100644 --- a/packages/design-system/src/components/N8nActionDropdown/ActionDropdown.vue +++ b/packages/design-system/src/components/N8nActionDropdown/ActionDropdown.vue @@ -6,6 +6,7 @@ :trigger="trigger" :popper-class="popperClass" :teleported="teleported" + :disabled="disabled" @command="onSelect" @visible-change="onVisibleChange" > @@ -76,6 +77,7 @@ interface ActionDropdownProps { trigger?: (typeof TRIGGER)[number]; hideArrow?: boolean; teleported?: boolean; + disabled?: boolean; } const props = withDefaults(defineProps(), { @@ -86,6 +88,7 @@ const props = withDefaults(defineProps(), { trigger: 'click', hideArrow: false, teleported: true, + disabled: false, }); const attrs = useAttrs(); diff --git a/packages/design-system/src/components/N8nAvatar/Avatar.vue b/packages/design-system/src/components/N8nAvatar/Avatar.vue index f7993a84a0038..348af6ed4784a 100644 --- a/packages/design-system/src/components/N8nAvatar/Avatar.vue +++ b/packages/design-system/src/components/N8nAvatar/Avatar.vue @@ -1,14 +1,14 @@ @@ -38,7 +38,8 @@ const props = withDefaults(defineProps(), { ], }); -const initials = computed(() => getInitials(`${props.firstName} ${props.lastName}`)); +const name = computed(() => `${props.firstName} ${props.lastName}`.trim()); +const initials = computed(() => getInitials(name.value)); const getColors = (colors: string[]): string[] => { const style = getComputedStyle(document.body); @@ -62,6 +63,7 @@ const getSize = (size: string): number => sizes[size]; } .empty { + display: block; border-radius: 50%; background-color: var(--color-foreground-dark); opacity: 0.3; @@ -72,7 +74,8 @@ const getSize = (size: string): number => sizes[size]; font-size: var(--font-size-2xs); font-weight: var(--font-weight-bold); color: var(--color-avatar-font); - text-shadow: 0px 1px 6px rgba(25, 11, 9, 0.3); + text-shadow: 0 1px 6px rgba(25, 11, 9, 0.3); + text-transform: uppercase; } .small { diff --git a/packages/design-system/src/components/N8nAvatar/__tests__/Avatar.test.ts b/packages/design-system/src/components/N8nAvatar/__tests__/Avatar.test.ts new file mode 100644 index 0000000000000..4fdeef6229795 --- /dev/null +++ b/packages/design-system/src/components/N8nAvatar/__tests__/Avatar.test.ts @@ -0,0 +1,25 @@ +import { render } from '@testing-library/vue'; +import N8nAvatar from '../Avatar.vue'; + +describe('components', () => { + describe('N8nAlert', () => { + test.each([ + ['Firstname', 'Lastname', 'FL'], + ['Firstname', undefined, 'Fi'], + [undefined, 'Lastname', 'La'], + [undefined, undefined, ''], + ['', '', ''], + ])('should render initials for name "%s %s" as %s', (firstName, lastName, initials) => { + const { container, getByText } = render(N8nAvatar, { + props: { firstName, lastName }, + }); + + if (firstName || lastName) { + expect(container.querySelector('svg')).toBeVisible(); + expect(getByText(initials)).toBeVisible(); + } else { + expect(container.querySelector('svg')).not.toBeInTheDocument(); + } + }); + }); +}); diff --git a/packages/design-system/src/components/N8nFormBox/FormBox.vue b/packages/design-system/src/components/N8nFormBox/FormBox.vue index ed01219eb914e..7881c4b5c9ba4 100644 --- a/packages/design-system/src/components/N8nFormBox/FormBox.vue +++ b/packages/design-system/src/components/N8nFormBox/FormBox.vue @@ -44,7 +44,7 @@ import N8nHeading from '../N8nHeading'; import N8nLink from '../N8nLink'; import N8nButton from '../N8nButton'; import type { IFormInput } from 'n8n-design-system/types'; -import { createEventBus } from '../../utils'; +import { createFormEventBus } from '../../utils'; interface FormBoxProps { title?: string; @@ -67,7 +67,7 @@ withDefaults(defineProps(), { redirectLink: '', }); -const formBus = createEventBus(); +const formBus = createFormEventBus(); const emit = defineEmits<{ submit: [value: { [key: string]: Value }]; update: [value: { name: string; value: Value }]; diff --git a/packages/design-system/src/components/N8nFormInputs/FormInputs.vue b/packages/design-system/src/components/N8nFormInputs/FormInputs.vue index 620778cd2cb9d..977a10a88fe16 100644 --- a/packages/design-system/src/components/N8nFormInputs/FormInputs.vue +++ b/packages/design-system/src/components/N8nFormInputs/FormInputs.vue @@ -3,12 +3,12 @@ import { computed, onMounted, reactive, ref, watch } from 'vue'; import N8nFormInput from '../N8nFormInput'; import type { IFormInput } from '../../types'; import ResizeObserver from '../ResizeObserver'; -import type { EventBus } from '../../utils'; -import { createEventBus } from '../../utils'; +import type { FormEventBus } from '../../utils'; +import { createFormEventBus } from '../../utils'; export type FormInputsProps = { inputs?: IFormInput[]; - eventBus?: EventBus; + eventBus?: FormEventBus; columnView?: boolean; verticalSpacing?: '' | 'xs' | 's' | 'm' | 'l' | 'xl'; teleported?: boolean; @@ -19,7 +19,7 @@ type Value = string | number | boolean | null | undefined; const props = withDefaults(defineProps(), { inputs: () => [], - eventBus: createEventBus, + eventBus: createFormEventBus, columnView: false, verticalSpacing: '', teleported: true, diff --git a/packages/design-system/src/components/N8nTabs/Tabs.vue b/packages/design-system/src/components/N8nTabs/Tabs.vue index 1cd2792c9957f..967f08a430701 100644 --- a/packages/design-system/src/components/N8nTabs/Tabs.vue +++ b/packages/design-system/src/components/N8nTabs/Tabs.vue @@ -13,7 +13,7 @@ :key="option.value" :class="{ [$style.alignRight]: option.align === 'right' }" > - + @@ -31,14 +31,14 @@ - {{ option.label }} - +
{{ option.label }}
-
+ diff --git a/packages/design-system/src/css/_tokens.dark.scss b/packages/design-system/src/css/_tokens.dark.scss index 4c3c078384bd3..15930499e4097 100644 --- a/packages/design-system/src/css/_tokens.dark.scss +++ b/packages/design-system/src/css/_tokens.dark.scss @@ -101,6 +101,7 @@ --color-pending-resolvable-foreground: var(--color-text-base); --color-pending-resolvable-background: var(--prim-gray-70-alpha-01); --color-expression-editor-background: var(--prim-gray-800); + --color-expression-editor-modal-background: var(--prim-gray-800); --color-expression-syntax-example: var(--prim-gray-670); --color-autocomplete-item-selected: var(--prim-color-secondary-tint-200); --color-autocomplete-section-header-border: var(--prim-gray-540); diff --git a/packages/design-system/src/css/_tokens.scss b/packages/design-system/src/css/_tokens.scss index 40bf465cfa786..c63c5eb64baf5 100644 --- a/packages/design-system/src/css/_tokens.scss +++ b/packages/design-system/src/css/_tokens.scss @@ -135,6 +135,7 @@ --color-pending-resolvable-foreground: var(--color-text-base); --color-pending-resolvable-background: var(--prim-gray-40); --color-expression-editor-background: var(--prim-gray-0); + --color-expression-editor-modal-background: var(--color-background-base); --color-expression-syntax-example: var(--prim-gray-40); --color-autocomplete-item-selected: var(--color-secondary); --color-autocomplete-section-header-border: var(--color-foreground-light); diff --git a/packages/design-system/src/css/dropdown.scss b/packages/design-system/src/css/dropdown.scss index a15d17f0268a4..e3ff966a4fcdf 100644 --- a/packages/design-system/src/css/dropdown.scss +++ b/packages/design-system/src/css/dropdown.scss @@ -19,6 +19,14 @@ } } + @include mixins.when(disabled) { + .el-tooltip__trigger { + opacity: 0.25; + cursor: not-allowed; + background: unset; + } + } + & .el-dropdown__caret-button { padding-left: 5px; padding-right: 5px; diff --git a/packages/design-system/src/utils/__tests__/event-bus.spec.ts b/packages/design-system/src/utils/__tests__/event-bus.spec.ts index e403b61008f4e..2fb0735404ee0 100644 --- a/packages/design-system/src/utils/__tests__/event-bus.spec.ts +++ b/packages/design-system/src/utils/__tests__/event-bus.spec.ts @@ -14,18 +14,30 @@ describe('createEventBus()', () => { expect(handler).toHaveBeenCalled(); }); + }); - it('should return unregister fn', () => { + describe('once()', () => { + it('should register event handler', () => { const handler = vi.fn(); const eventName = 'test'; - const unregister = eventBus.on(eventName, handler); + eventBus.once(eventName, handler); + + eventBus.emit(eventName, {}); + + expect(handler).toHaveBeenCalled(); + }); + + it('should unregister event handler after first call', () => { + const handler = vi.fn(); + const eventName = 'test'; - unregister(); + eventBus.once(eventName, handler); + eventBus.emit(eventName, {}); eventBus.emit(eventName, {}); - expect(handler).not.toHaveBeenCalled(); + expect(handler).toHaveBeenCalledTimes(1); }); }); diff --git a/packages/design-system/src/utils/event-bus.ts b/packages/design-system/src/utils/event-bus.ts index f6ffc597f5fd6..eb08228b47afe 100644 --- a/packages/design-system/src/utils/event-bus.ts +++ b/packages/design-system/src/utils/event-bus.ts @@ -1,51 +1,84 @@ // eslint-disable-next-line @typescript-eslint/ban-types export type CallbackFn = Function; -export type UnregisterFn = () => void; -export interface EventBus { - on: (eventName: string, fn: CallbackFn) => UnregisterFn; - off: (eventName: string, fn: CallbackFn) => void; - emit: (eventName: string, event?: T) => void; -} +type Payloads = { + [E in keyof ListenerMap]: unknown; +}; -export function createEventBus(): EventBus { - const handlers = new Map(); +type Listener = (payload: Payload) => void; - function off(eventName: string, fn: CallbackFn) { - const eventFns = handlers.get(eventName); +// TODO: Fix all usages of `createEventBus` and convert `any` to `unknown` +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export interface EventBus = Record> { + on( + eventName: EventName, + fn: Listener, + ): void; - if (eventFns) { - eventFns.splice(eventFns.indexOf(fn) >>> 0, 1); - } - } + once( + eventName: EventName, + fn: Listener, + ): void; - function on(eventName: string, fn: CallbackFn): UnregisterFn { - let eventFns = handlers.get(eventName); + off( + eventName: EventName, + fn: Listener, + ): void; - if (!eventFns) { - eventFns = [fn]; - } else { - eventFns.push(fn); - } + emit( + eventName: EventName, + event?: ListenerMap[EventName], + ): void; +} - handlers.set(eventName, eventFns); +/** + * Creates an event bus with the given listener map. + * + * @example + * ```ts + * const eventBus = createEventBus<{ + * 'user-logged-in': { username: string }; + * 'user-logged-out': never; + * }>(); + */ +export function createEventBus< + // TODO: Fix all usages of `createEventBus` and convert `any` to `unknown` + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ListenerMap extends Payloads = Record, +>(): EventBus { + const handlers = new Map(); - return () => off(eventName, fn); - } + return { + on(eventName, fn) { + let eventFns = handlers.get(eventName); + if (!eventFns) { + eventFns = [fn]; + } else { + eventFns.push(fn); + } + handlers.set(eventName, eventFns); + }, - function emit(eventName: string, event?: T) { - const eventFns = handlers.get(eventName); + once(eventName, fn) { + const handler: typeof fn = (payload) => { + this.off(eventName, handler); + fn(payload); + }; + this.on(eventName, handler); + }, - if (eventFns) { - eventFns.slice().forEach(async (handler) => { - await handler(event); - }); - } - } + off(eventName, fn) { + const eventFns = handlers.get(eventName); + if (eventFns) { + eventFns.splice(eventFns.indexOf(fn) >>> 0, 1); + } + }, - return { - on, - off, - emit, + emit(eventName, event) { + const eventFns = handlers.get(eventName); + if (eventFns) { + eventFns.slice().forEach((handler) => handler(event)); + } + }, }; } diff --git a/packages/design-system/src/utils/form-event-bus.ts b/packages/design-system/src/utils/form-event-bus.ts new file mode 100644 index 0000000000000..5b518c8a4233d --- /dev/null +++ b/packages/design-system/src/utils/form-event-bus.ts @@ -0,0 +1,12 @@ +import { createEventBus } from './event-bus'; + +export interface FormEventBusEvents { + submit: never; +} + +export type FormEventBus = ReturnType; + +/** + * Creates a new event bus to be used with the `FormInputs` component. + */ +export const createFormEventBus = createEventBus; diff --git a/packages/design-system/src/utils/index.ts b/packages/design-system/src/utils/index.ts index a6948fc0063cc..be6ddc63754b0 100644 --- a/packages/design-system/src/utils/index.ts +++ b/packages/design-system/src/utils/index.ts @@ -1,4 +1,5 @@ export * from './event-bus'; +export * from './form-event-bus'; export * from './markdown'; export * from './typeguards'; export * from './uid'; diff --git a/packages/editor-ui/README.md b/packages/editor-ui/README.md index c8583ffbbdd31..75b8026fb8bae 100644 --- a/packages/editor-ui/README.md +++ b/packages/editor-ui/README.md @@ -56,8 +56,4 @@ See [Configuration Reference](https://cli.vuejs.org/config/). ## License -n8n is [fair-code](https://faircode.io) distributed under the [**Sustainable Use License**](https://github.com/n8n-io/n8n/blob/master/packages/cli/LICENSE.md). - -Proprietary licenses are available for enterprise customers. [Get in touch](mailto:license@n8n.io) - -Additional information about the license can be found in the [docs](https://docs.n8n.io/reference/license/). +You can find the license information [here](https://github.com/n8n-io/n8n/blob/master/README.md#license) diff --git a/packages/editor-ui/package.json b/packages/editor-ui/package.json index caf156fa83c9e..956f5b361a4de 100644 --- a/packages/editor-ui/package.json +++ b/packages/editor-ui/package.json @@ -1,6 +1,6 @@ { "name": "n8n-editor-ui", - "version": "1.53.0", + "version": "1.54.0", "description": "Workflow Editor UI for n8n", "main": "index.js", "scripts": { @@ -70,7 +70,7 @@ "vue-chartjs": "^5.2.0", "vue-i18n": "^9.2.2", "vue-json-pretty": "2.2.4", - "vue-markdown-render": "^2.0.1", + "vue-markdown-render": "catalog:frontend", "vue-router": "^4.2.2", "vue3-touch-events": "^4.1.3", "xss": "^1.0.14" diff --git a/packages/editor-ui/src/Interface.ts b/packages/editor-ui/src/Interface.ts index 770cfe2298238..986336a396d64 100644 --- a/packages/editor-ui/src/Interface.ts +++ b/packages/editor-ui/src/Interface.ts @@ -207,19 +207,6 @@ export interface ITableData { hasJson: { [key: string]: boolean }; } -export interface IVariableItemSelected { - variable: string; -} - -export interface IVariableSelectorOption { - name: string; - key?: string; - value?: string; - options?: IVariableSelectorOption[] | null; - allowParentSelect?: boolean; - dataType?: string; -} - // Simple version of n8n-workflow.Workflow export interface IWorkflowData { id?: string; @@ -1564,6 +1551,11 @@ export declare namespace DynamicNodeParameters { interface ResourceMapperFieldsRequest extends BaseRequest { methodName: string; } + + interface ActionResultRequest extends BaseRequest { + handler: string; + payload: IDataObject | string | undefined; + } } export interface EnvironmentVariable { @@ -1808,8 +1800,8 @@ export type AddedNode = { } & Partial; export type AddedNodeConnection = { - from: { nodeIndex: number; outputIndex?: number }; - to: { nodeIndex: number; inputIndex?: number }; + from: { nodeIndex: number; outputIndex?: number; type?: NodeConnectionType }; + to: { nodeIndex: number; inputIndex?: number; type?: NodeConnectionType }; }; export type AddedNodesAndConnections = { diff --git a/packages/editor-ui/src/api/mfa.ts b/packages/editor-ui/src/api/mfa.ts index dcc5d2131399e..09cfb84df40bf 100644 --- a/packages/editor-ui/src/api/mfa.ts +++ b/packages/editor-ui/src/api/mfa.ts @@ -18,6 +18,10 @@ export async function verifyMfaToken( return await makeRestApiRequest(context, 'POST', '/mfa/verify', data); } -export async function disableMfa(context: IRestApiContext): Promise { - return await makeRestApiRequest(context, 'DELETE', '/mfa/disable'); +export type DisableMfaParams = { + token: string; +}; + +export async function disableMfa(context: IRestApiContext, data: DisableMfaParams): Promise { + return await makeRestApiRequest(context, 'POST', '/mfa/disable', data); } diff --git a/packages/editor-ui/src/api/nodeTypes.ts b/packages/editor-ui/src/api/nodeTypes.ts index 300b25390d121..f4d516aaeff82 100644 --- a/packages/editor-ui/src/api/nodeTypes.ts +++ b/packages/editor-ui/src/api/nodeTypes.ts @@ -5,6 +5,7 @@ import type { INodePropertyOptions, INodeTypeDescription, INodeTypeNameVersion, + NodeParameterValueType, ResourceMapperFields, } from 'n8n-workflow'; import axios from 'axios'; @@ -57,3 +58,15 @@ export async function getResourceMapperFields( sendData, ); } + +export async function getNodeParameterActionResult( + context: IRestApiContext, + sendData: DynamicNodeParameters.ActionResultRequest, +): Promise { + return await makeRestApiRequest( + context, + 'POST', + '/dynamic-node-parameters/action-result', + sendData, + ); +} diff --git a/packages/editor-ui/src/api/users.ts b/packages/editor-ui/src/api/users.ts index 0085baadc1a99..bb730c671c70a 100644 --- a/packages/editor-ui/src/api/users.ts +++ b/packages/editor-ui/src/api/users.ts @@ -116,9 +116,15 @@ export async function updateOtherUserSettings( return await makeRestApiRequest(context, 'PATCH', `/users/${userId}/settings`, settings); } +export type UpdateUserPasswordParams = { + newPassword: string; + currentPassword: string; + mfaCode?: string; +}; + export async function updateCurrentUserPassword( context: IRestApiContext, - params: { newPassword: string; currentPassword: string }, + params: UpdateUserPasswordParams, ): Promise { return await makeRestApiRequest(context, 'PATCH', '/me/password', params); } diff --git a/packages/editor-ui/src/components/AboutModal.vue b/packages/editor-ui/src/components/AboutModal.vue index 1aeeed0fb5715..8552044e4b30b 100644 --- a/packages/editor-ui/src/components/AboutModal.vue +++ b/packages/editor-ui/src/components/AboutModal.vue @@ -29,7 +29,7 @@ {{ $locale.baseText('about.license') }} - + {{ $locale.baseText('about.n8nLicense') }} diff --git a/packages/editor-ui/src/components/ButtonParameter/ButtonParameter.vue b/packages/editor-ui/src/components/ButtonParameter/ButtonParameter.vue new file mode 100644 index 0000000000000..d7e05b0f5bf85 --- /dev/null +++ b/packages/editor-ui/src/components/ButtonParameter/ButtonParameter.vue @@ -0,0 +1,281 @@ + + + + + diff --git a/packages/editor-ui/src/components/ButtonParameter/utils.ts b/packages/editor-ui/src/components/ButtonParameter/utils.ts new file mode 100644 index 0000000000000..14d3ca4d78334 --- /dev/null +++ b/packages/editor-ui/src/components/ButtonParameter/utils.ts @@ -0,0 +1,46 @@ +import type { Schema } from '@/Interface'; +import type { INodeExecutionData } from 'n8n-workflow'; +import { useWorkflowsStore } from '@/stores/workflows.store'; +import { useNDVStore } from '@/stores/ndv.store'; +import { useDataSchema } from '@/composables/useDataSchema'; +import { executionDataToJson } from '@/utils/nodeTypesUtils'; + +export function getParentNodes() { + const activeNode = useNDVStore().activeNode; + const { getCurrentWorkflow, getNodeByName } = useWorkflowsStore(); + const workflow = getCurrentWorkflow(); + + if (!activeNode || !workflow) return []; + + return workflow + .getParentNodesByDepth(activeNode?.name) + .filter(({ name }, i, nodes) => { + return name !== activeNode.name && nodes.findIndex((node) => node.name === name) === i; + }) + .map((n) => getNodeByName(n.name)) + .filter((n) => n !== null); +} + +export function getSchemas() { + const parentNodes = getParentNodes(); + const parentNodesNames = parentNodes.map((node) => node?.name); + const { getSchemaForExecutionData, getInputDataWithPinned } = useDataSchema(); + const parentNodesSchemas: Array<{ nodeName: string; schema: Schema }> = parentNodes + .map((node) => { + const inputData: INodeExecutionData[] = getInputDataWithPinned(node); + + return { + nodeName: node?.name || '', + schema: getSchemaForExecutionData(executionDataToJson(inputData), true), + }; + }) + .filter((node) => node.schema?.value.length > 0); + + const inputSchema = parentNodesSchemas.shift(); + + return { + parentNodesNames, + inputSchema, + parentNodesSchemas, + }; +} diff --git a/packages/editor-ui/src/components/ChangePasswordModal.vue b/packages/editor-ui/src/components/ChangePasswordModal.vue index 978ec9cedb830..24de3b5dfb18c 100644 --- a/packages/editor-ui/src/components/ChangePasswordModal.vue +++ b/packages/editor-ui/src/components/ChangePasswordModal.vue @@ -1,7 +1,7 @@ - diff --git a/packages/editor-ui/src/components/CodeNodeEditor/CodeNodeEditor.vue b/packages/editor-ui/src/components/CodeNodeEditor/CodeNodeEditor.vue index d59a13e98ee60..f4ba1febf2471 100644 --- a/packages/editor-ui/src/components/CodeNodeEditor/CodeNodeEditor.vue +++ b/packages/editor-ui/src/components/CodeNodeEditor/CodeNodeEditor.vue @@ -60,13 +60,12 @@ import jsParser from 'prettier/plugins/babel'; import * as estree from 'prettier/plugins/estree'; import { type Ref, computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'; -import { ASK_AI_EXPERIMENT, CODE_NODE_TYPE } from '@/constants'; +import { CODE_NODE_TYPE } from '@/constants'; import { codeNodeEditorEventBus } from '@/event-bus'; import { useRootStore } from '@/stores/root.store'; import { usePostHog } from '@/stores/posthog.store'; import { useMessage } from '@/composables/useMessage'; -import { useSettingsStore } from '@/stores/settings.store'; import AskAI from './AskAI/AskAI.vue'; import { readOnlyEditorExtensions, writableEditorExtensions } from './baseExtensions'; import { useCompleter } from './completer'; @@ -114,7 +113,6 @@ const { autocompletionExtension } = useCompleter(() => props.mode, editor); const { createLinter } = useLinter(() => props.mode, editor); const rootStore = useRootStore(); -const settingsStore = useSettingsStore(); const posthog = usePostHog(); const i18n = useI18n(); const telemetry = useTelemetry(); @@ -191,13 +189,7 @@ onBeforeUnmount(() => { }); const aiEnabled = computed(() => { - const isAiExperimentEnabled = [ASK_AI_EXPERIMENT.gpt3, ASK_AI_EXPERIMENT.gpt4].includes( - (posthog.getVariant(ASK_AI_EXPERIMENT.name) ?? '') as string, - ); - - return ( - isAiExperimentEnabled && settingsStore.settings.ai.enabled && props.language === 'javaScript' - ); + return posthog.isAiEnabled() && props.language === 'javaScript'; }); const placeholder = computed(() => { diff --git a/packages/editor-ui/src/components/CredentialCard.vue b/packages/editor-ui/src/components/CredentialCard.vue index d9ac12ffd9a54..33ffefa37811f 100644 --- a/packages/editor-ui/src/components/CredentialCard.vue +++ b/packages/editor-ui/src/components/CredentialCard.vue @@ -5,7 +5,7 @@ import type { ICredentialsResponse } from '@/Interface'; import { MODAL_CONFIRM, PROJECT_MOVE_RESOURCE_MODAL } from '@/constants'; import { useMessage } from '@/composables/useMessage'; import CredentialIcon from '@/components/CredentialIcon.vue'; -import { getCredentialPermissions } from '@/permissions'; +import { getResourcePermissions } from '@/permissions'; import { useUIStore } from '@/stores/ui.store'; import { useCredentialsStore } from '@/stores/credentials.store'; import TimeAgo from '@/components/TimeAgo.vue'; @@ -48,7 +48,7 @@ const projectsStore = useProjectsStore(); const resourceTypeLabel = computed(() => locale.baseText('generic.credential').toLowerCase()); const credentialType = computed(() => credentialsStore.getCredentialTypeByName(props.data.type)); -const credentialPermissions = computed(() => getCredentialPermissions(props.data)); +const credentialPermissions = computed(() => getResourcePermissions(props.data.scopes).credential); const actions = computed(() => { const items = [ { diff --git a/packages/editor-ui/src/components/CredentialEdit/CredentialConfig.vue b/packages/editor-ui/src/components/CredentialEdit/CredentialConfig.vue index 1bfda209de447..ca79b07f836dd 100644 --- a/packages/editor-ui/src/components/CredentialEdit/CredentialConfig.vue +++ b/packages/editor-ui/src/components/CredentialEdit/CredentialConfig.vue @@ -172,14 +172,13 @@ import EnterpriseEdition from '@/components/EnterpriseEdition.ee.vue'; import { useI18n } from '@/composables/useI18n'; import { useTelemetry } from '@/composables/useTelemetry'; import { BUILTIN_CREDENTIALS_DOCS_URL, DOCS_DOMAIN, EnterpriseEditionFeature } from '@/constants'; -import type { PermissionsMap } from '@/permissions'; +import type { PermissionsRecord } from '@/permissions'; import { addCredentialTranslation } from '@/plugins/i18n'; import { useCredentialsStore } from '@/stores/credentials.store'; import { useNDVStore } from '@/stores/ndv.store'; import { useRootStore } from '@/stores/root.store'; import { useUIStore } from '@/stores/ui.store'; import { useWorkflowsStore } from '@/stores/workflows.store'; -import type { CredentialScope } from '@n8n/permissions'; import Banner from '../Banner.vue'; import CopyInput from '../CopyInput.vue'; import CredentialInputs from './CredentialInputs.vue'; @@ -194,7 +193,7 @@ type Props = { credentialProperties: INodeProperties[]; credentialData: ICredentialDataDecryptedObject; credentialId?: string; - credentialPermissions?: PermissionsMap; + credentialPermissions: PermissionsRecord['credential']; parentTypes?: string[]; showValidationWarning?: boolean; authError?: string; @@ -212,7 +211,7 @@ const props = withDefaults(defineProps(), { credentialId: '', authError: '', showValidationWarning: false, - credentialPermissions: () => ({}) as PermissionsMap, + credentialPermissions: () => ({}) as PermissionsRecord['credential'], }); const emit = defineEmits<{ update: [value: IUpdateInformation]; diff --git a/packages/editor-ui/src/components/CredentialEdit/CredentialEdit.vue b/packages/editor-ui/src/components/CredentialEdit/CredentialEdit.vue index fd539fed7554f..e2c7c428e5a36 100644 --- a/packages/editor-ui/src/components/CredentialEdit/CredentialEdit.vue +++ b/packages/editor-ui/src/components/CredentialEdit/CredentialEdit.vue @@ -145,8 +145,7 @@ import { useMessage } from '@/composables/useMessage'; import { useNodeHelpers } from '@/composables/useNodeHelpers'; import { useToast } from '@/composables/useToast'; import { CREDENTIAL_EDIT_MODAL_KEY, EnterpriseEditionFeature, MODAL_CONFIRM } from '@/constants'; -import type { PermissionsMap } from '@/permissions'; -import { getCredentialPermissions } from '@/permissions'; +import { getResourcePermissions } from '@/permissions'; import { useCredentialsStore } from '@/stores/credentials.store'; import { useNDVStore } from '@/stores/ndv.store'; import { useNodeTypesStore } from '@/stores/nodeTypes.store'; @@ -169,7 +168,6 @@ import { updateNodeAuthType, } from '@/utils/nodeTypesUtils'; import { isCredentialModalState, isValidCredentialResponse } from '@/utils/typeGuards'; -import type { CredentialScope } from '@n8n/permissions'; type Props = { modalName: string; @@ -395,14 +393,11 @@ const requiredPropertiesFilled = computed(() => { return true; }); -const credentialPermissions = computed>(() => { - if (loading.value) { - return {} as PermissionsMap; - } - - return getCredentialPermissions( - (credentialId.value ? currentCredential.value : credentialData.value) as ICredentialsResponse, - ); +const credentialPermissions = computed(() => { + return getResourcePermissions( + ((credentialId.value ? currentCredential.value : credentialData.value) as ICredentialsResponse) + ?.scopes, + ).credential; }); const sidebarItems = computed(() => { @@ -446,6 +441,11 @@ const showSaveButton = computed(() => { const showSharingContent = computed(() => activeTab.value === 'sharing' && !!credentialType.value); +const homeProject = computed(() => { + const { currentProject, personalProject } = projectsStore; + return currentProject ?? personalProject; +}); + onMounted(async () => { requiredCredentials.value = isCredentialModalState(uiStore.modalsById[CREDENTIAL_EDIT_MODAL_KEY]) && @@ -456,14 +456,12 @@ onMounted(async () => { credentialTypeName: defaultCredentialTypeName.value, }); - const { currentProject, personalProject } = projectsStore; - const scopes = currentProject?.scopes ?? personalProject?.scopes ?? []; - const homeProject = currentProject ?? personalProject ?? {}; + const scopes = homeProject.value?.scopes ?? []; credentialData.value = { ...credentialData.value, scopes, - homeProject, + ...(homeProject.value ? { homeProject: homeProject.value } : {}), }; } else { await loadCurrentCredential(); @@ -793,6 +791,10 @@ async function saveCredential(): Promise { .sharedWithProjects as ProjectSharingData[]; } + if (credentialData.value.homeProject) { + credentialDetails.homeProject = credentialData.value.homeProject as ProjectSharingData; + } + let credential: ICredentialsResponse | null = null; const isNewCredential = props.mode === 'new' && !credentialId.value; diff --git a/packages/editor-ui/src/components/CredentialEdit/CredentialSharing.ee.vue b/packages/editor-ui/src/components/CredentialEdit/CredentialSharing.ee.vue index 133b00ca4d46e..f86e48a0c5a2c 100644 --- a/packages/editor-ui/src/components/CredentialEdit/CredentialSharing.ee.vue +++ b/packages/editor-ui/src/components/CredentialEdit/CredentialSharing.ee.vue @@ -1,7 +1,7 @@ @@ -89,14 +65,14 @@ import { useUsageStore } from '@/stores/usage.store'; import { EnterpriseEditionFeature } from '@/constants'; import ProjectSharing from '@/components/Projects/ProjectSharing.vue'; import { useProjectsStore } from '@/stores/projects.store'; -import type { ProjectListItem, ProjectSharingData, Project } from '@/types/projects.types'; +import type { ProjectListItem, ProjectSharingData } from '@/types/projects.types'; import { ProjectTypes } from '@/types/projects.types'; import type { ICredentialDataDecryptedObject } from 'n8n-workflow'; -import type { PermissionsMap } from '@/permissions'; -import type { CredentialScope } from '@n8n/permissions'; +import type { PermissionsRecord } from '@/permissions'; import type { EventBus } from 'n8n-design-system/utils'; import { useRolesStore } from '@/stores/roles.store'; import type { RoleMap } from '@/types/roles.types'; +import { splitName } from '@/utils/projects.utils'; export default defineComponent({ name: 'CredentialSharing', @@ -117,7 +93,7 @@ export default defineComponent({ required: true, }, credentialPermissions: { - type: Object as PropType>, + type: Object as PropType, required: true, }, modalBus: { @@ -134,7 +110,6 @@ export default defineComponent({ data() { return { sharedWithProjects: [...(this.credential?.sharedWithProjects ?? [])] as ProjectSharingData[], - teamProject: null as Project | null, }; }, computed: { @@ -159,7 +134,8 @@ export default defineComponent({ return this.settingsStore.isEnterpriseFeatureEnabled[EnterpriseEditionFeature.Sharing]; }, credentialOwnerName(): string { - return this.credentialsStore.getCredentialOwnerNameById(`${this.credentialId}`); + const { firstName, lastName, email } = splitName(this.credential?.homeProject?.name ?? ''); + return firstName || lastName ? `${firstName}${lastName ? ' ' + lastName : ''}` : email ?? ''; }, credentialDataHomeProject(): ProjectSharingData | undefined { const credentialContainsProjectSharingData = ( @@ -182,7 +158,7 @@ export default defineComponent({ }); }, projects(): ProjectListItem[] { - return this.projectsStore.personalProjects.filter( + return this.projectsStore.projects.filter( (project) => project.id !== this.credential?.homeProject?.id && project.id !== this.credentialDataHomeProject?.id, @@ -194,9 +170,6 @@ export default defineComponent({ isHomeTeamProject(): boolean { return this.homeProject?.type === ProjectTypes.Team; }, - numberOfMembersInHomeTeamProject(): number { - return this.teamProject?.relations.length ?? 0; - }, credentialRoleTranslations(): Record { return { 'credential:user': this.$locale.baseText('credentialEdit.credentialSharing.role.user'), @@ -210,6 +183,11 @@ export default defineComponent({ licensed, })); }, + sharingSelectPlaceholder() { + return this.projectsStore.teamProjects.length + ? this.$locale.baseText('projects.sharing.select.placeholder.project') + : this.$locale.baseText('projects.sharing.select.placeholder.user'); + }, }, watch: { sharedWithProjects: { @@ -221,10 +199,6 @@ export default defineComponent({ }, async mounted() { await Promise.all([this.usersStore.fetchUsers(), this.projectsStore.getAllProjects()]); - - if (this.homeProject && this.isHomeTeamProject) { - this.teamProject = await this.projectsStore.fetchProject(this.homeProject.id); - } }, methods: { goToUpgrade() { diff --git a/packages/editor-ui/src/components/DeleteUserModal.vue b/packages/editor-ui/src/components/DeleteUserModal.vue index 7516466a340b6..2fdb0ad73468f 100644 --- a/packages/editor-ui/src/components/DeleteUserModal.vue +++ b/packages/editor-ui/src/components/DeleteUserModal.vue @@ -3,7 +3,7 @@ :name="modalName" :title="title" :center="true" - width="460px" + width="520" :event-bus="modalBus" @enter="onSubmit" > @@ -147,7 +147,7 @@ export default defineComponent({ return false; }, projects(): ProjectListItem[] { - return this.projectsStore.personalProjects.filter( + return this.projectsStore.projects.filter( (project) => project.name !== `${this.userToDelete?.firstName} ${this.userToDelete?.lastName} <${this.userToDelete?.email}>`, diff --git a/packages/editor-ui/src/components/DraggableTarget.vue b/packages/editor-ui/src/components/DraggableTarget.vue index 0dcadd856ef0c..6197d3c347837 100644 --- a/packages/editor-ui/src/components/DraggableTarget.vue +++ b/packages/editor-ui/src/components/DraggableTarget.vue @@ -26,7 +26,7 @@ const props = withDefaults(defineProps(), { }); const emit = defineEmits<{ - drop: [value: string]; + drop: [value: string, event: MouseEvent]; }>(); const hovering = ref(false); @@ -60,10 +60,10 @@ function onMouseLeave() { hovering.value = false; } -function onMouseUp() { +function onMouseUp(event: MouseEvent) { if (activeDrop.value) { const data = ndvStore.draggableData; - emit('drop', data); + emit('drop', data, event); } } diff --git a/packages/editor-ui/src/components/Error/NodeErrorView.vue b/packages/editor-ui/src/components/Error/NodeErrorView.vue index 5244371d0fc47..6b6238338d25e 100644 --- a/packages/editor-ui/src/components/Error/NodeErrorView.vue +++ b/packages/editor-ui/src/components/Error/NodeErrorView.vue @@ -21,6 +21,7 @@ import type { BaseTextKey } from '@/plugins/i18n'; type Props = { error: NodeError | NodeApiError | NodeOperationError; + compact?: boolean; }; const props = defineProps(); @@ -178,6 +179,10 @@ function addItemIndexSuffix(message: string): string { } function getErrorMessage(): string { + if ('obfuscate' in props.error && props.error.obfuscate === true) { + return i18n.baseText('nodeErrorView.showMessage.obfuscate'); + } + let message = ''; const isSubNodeError = @@ -377,7 +382,7 @@ function copySuccess() { > -
+

{{ i18n.baseText('nodeErrorView.details.title') }} diff --git a/packages/editor-ui/src/components/ExpressionEdit.vue b/packages/editor-ui/src/components/ExpressionEdit.vue deleted file mode 100644 index e61b4dbe052c8..0000000000000 --- a/packages/editor-ui/src/components/ExpressionEdit.vue +++ /dev/null @@ -1,388 +0,0 @@ - - - - - diff --git a/packages/editor-ui/src/components/ExpressionEditModal.vue b/packages/editor-ui/src/components/ExpressionEditModal.vue new file mode 100644 index 0000000000000..cb58f67a58217 --- /dev/null +++ b/packages/editor-ui/src/components/ExpressionEditModal.vue @@ -0,0 +1,336 @@ + + + + + diff --git a/packages/editor-ui/src/components/ExpressionEditorModal/ExpressionEditorModalInput.vue b/packages/editor-ui/src/components/ExpressionEditorModal/ExpressionEditorModalInput.vue index 78c4400030df3..68a65ada6e84f 100644 --- a/packages/editor-ui/src/components/ExpressionEditorModal/ExpressionEditorModalInput.vue +++ b/packages/editor-ui/src/components/ExpressionEditorModal/ExpressionEditorModalInput.vue @@ -5,8 +5,8 @@ - diff --git a/packages/editor-ui/src/components/ExpressionEditorModal/theme.ts b/packages/editor-ui/src/components/ExpressionEditorModal/theme.ts index f47f7c1535ec3..a35a18643f9be 100644 --- a/packages/editor-ui/src/components/ExpressionEditorModal/theme.ts +++ b/packages/editor-ui/src/components/ExpressionEditorModal/theme.ts @@ -1,7 +1,7 @@ import { EditorView } from '@codemirror/view'; import { highlighter } from '@/plugins/codemirror/resolvableHighlighter'; -const commonThemeProps = { +const commonThemeProps = (isReadOnly = false) => ({ '&': { borderWidth: 'var(--border-width-base)', borderStyle: 'var(--input-border-style, var(--border-style-base))', @@ -9,31 +9,33 @@ const commonThemeProps = { borderRadius: 'var(--input-border-radius, var(--border-radius-base))', backgroundColor: 'var(--color-expression-editor-background)', }, + '.cm-cursor, .cm-dropCursor': { + borderLeftColor: 'var(--color-code-caret)', + }, '&.cm-focused': { borderColor: 'var(--color-secondary)', outline: '0 !important', }, '.cm-content': { fontFamily: 'var(--font-family-monospace)', - height: '220px', padding: 'var(--spacing-xs)', color: 'var(--input-font-color, var(--color-text-dark))', - caretColor: 'var(--color-code-caret)', + caretColor: isReadOnly ? 'transparent' : 'var(--color-code-caret)', }, '.cm-line': { padding: '0', }, -}; +}); -export const inputTheme = () => { - const theme = EditorView.theme(commonThemeProps); +export const inputTheme = (isReadOnly = false) => { + const theme = EditorView.theme(commonThemeProps(isReadOnly)); return [theme, highlighter.resolvableStyle]; }; export const outputTheme = () => { const theme = EditorView.theme({ - ...commonThemeProps, + ...commonThemeProps(true), '.cm-valid-resolvable': { padding: '0 2px', borderRadius: '2px', diff --git a/packages/editor-ui/src/components/ExpressionParameterInput.vue b/packages/editor-ui/src/components/ExpressionParameterInput.vue index 5618c3ae269d1..86e4c540a2ed9 100644 --- a/packages/editor-ui/src/components/ExpressionParameterInput.vue +++ b/packages/editor-ui/src/components/ExpressionParameterInput.vue @@ -1,5 +1,5 @@ diff --git a/packages/editor-ui/src/components/InlineExpressionEditor/InlineExpressionEditorOutput.vue b/packages/editor-ui/src/components/InlineExpressionEditor/InlineExpressionEditorOutput.vue index ff0eaa673237f..634c8d340f201 100644 --- a/packages/editor-ui/src/components/InlineExpressionEditor/InlineExpressionEditorOutput.vue +++ b/packages/editor-ui/src/components/InlineExpressionEditor/InlineExpressionEditorOutput.vue @@ -2,13 +2,13 @@ import type { EditorState, SelectionRange } from '@codemirror/state'; import { useI18n } from '@/composables/useI18n'; +import { useNDVStore } from '@/stores/ndv.store'; import type { Segment } from '@/types/expressions'; +import { onBeforeUnmount } from 'vue'; import ExpressionOutput from './ExpressionOutput.vue'; +import OutputItemSelect from './OutputItemSelect.vue'; import InlineExpressionTip from './InlineExpressionTip.vue'; import { outputTheme } from './theme'; -import { computed, onBeforeUnmount } from 'vue'; -import { useNDVStore } from '@/stores/ndv.store'; -import { N8nTooltip } from 'n8n-design-system/components'; interface InlineExpressionEditorOutputProps { segments: Segment[]; @@ -29,31 +29,6 @@ const i18n = useI18n(); const theme = outputTheme(); const ndvStore = useNDVStore(); -const hideTableHoverHint = computed(() => ndvStore.isTableHoverOnboarded); -const hoveringItem = computed(() => ndvStore.getHoveringItem); -const hoveringItemIndex = computed(() => hoveringItem.value?.itemIndex); -const isHoveringItem = computed(() => Boolean(hoveringItem.value)); -const itemsLength = computed(() => ndvStore.ndvInputDataWithPinnedData.length); -const itemIndex = computed(() => hoveringItemIndex.value ?? ndvStore.expressionOutputItemIndex); -const max = computed(() => Math.max(itemsLength.value - 1, 0)); -const isItemIndexEditable = computed(() => !isHoveringItem.value && itemsLength.value > 0); -const canSelectPrevItem = computed(() => isItemIndexEditable.value && itemIndex.value !== 0); -const canSelectNextItem = computed( - () => isItemIndexEditable.value && itemIndex.value < itemsLength.value - 1, -); - -function updateItemIndex(index: number) { - ndvStore.expressionOutputItemIndex = index; -} - -function nextItem() { - ndvStore.expressionOutputItemIndex = ndvStore.expressionOutputItemIndex + 1; -} - -function prevItem() { - ndvStore.expressionOutputItemIndex = ndvStore.expressionOutputItemIndex - 1; -} - onBeforeUnmount(() => { ndvStore.expressionOutputItemIndex = 0; }); @@ -66,48 +41,7 @@ onBeforeUnmount(() => { {{ i18n.baseText('parameterInput.result') }} -

- - {{ i18n.baseText('parameterInput.item') }} - - -
- - - - - - - -
-
+
{ padding-top: var(--spacing-2xs); } - .item { - display: flex; - align-items: center; - gap: var(--spacing-4xs); - } - .body { padding-top: 0; padding-left: var(--spacing-2xs); @@ -179,33 +107,5 @@ onBeforeUnmount(() => { padding-top: var(--spacing-2xs); } } - - .controls { - display: flex; - align-items: center; - } - - .input { - --input-height: 22px; - --input-width: 26px; - --input-border-top-left-radius: var(--border-radius-base); - --input-border-bottom-left-radius: var(--border-radius-base); - --input-border-top-right-radius: var(--border-radius-base); - --input-border-bottom-right-radius: var(--border-radius-base); - max-width: var(--input-width); - line-height: calc(var(--input-height) - var(--spacing-4xs)); - - &.hovering { - --input-font-color: var(--color-secondary); - } - - :global(.el-input__inner) { - height: var(--input-height); - min-height: var(--input-height); - line-height: var(--input-height); - text-align: center; - padding: 0 var(--spacing-4xs); - } - } } diff --git a/packages/editor-ui/src/components/InlineExpressionEditor/OutputItemSelect.vue b/packages/editor-ui/src/components/InlineExpressionEditor/OutputItemSelect.vue new file mode 100644 index 0000000000000..6845b77bf5c74 --- /dev/null +++ b/packages/editor-ui/src/components/InlineExpressionEditor/OutputItemSelect.vue @@ -0,0 +1,114 @@ + + + + + diff --git a/packages/editor-ui/src/components/InlineExpressionEditor/theme.ts b/packages/editor-ui/src/components/InlineExpressionEditor/theme.ts index 384cf4a01f1a6..732996f6b7c74 100644 --- a/packages/editor-ui/src/components/InlineExpressionEditor/theme.ts +++ b/packages/editor-ui/src/components/InlineExpressionEditor/theme.ts @@ -41,6 +41,9 @@ export const inputTheme = ({ rows, isReadOnly } = { rows: 5, isReadOnly: false } 'var(--input-border-bottom-right-radius, var(--input-border-radius, var(--border-radius-base)))', backgroundColor: 'white', }, + '.cm-cursor, .cm-dropCursor': { + borderLeftColor: 'var(--color-code-caret)', + }, '.cm-scroller': { lineHeight: '1.68', }, diff --git a/packages/editor-ui/src/components/JsEditor/JsEditor.vue b/packages/editor-ui/src/components/JsEditor/JsEditor.vue index 40692ab32392c..5a5f2cb411878 100644 --- a/packages/editor-ui/src/components/JsEditor/JsEditor.vue +++ b/packages/editor-ui/src/components/JsEditor/JsEditor.vue @@ -1,5 +1,5 @@