diff --git a/cypress/constants.ts b/cypress/constants.ts index 8439952ac7b81..8fdb03ef9cfba 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 MANUAL_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/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..78d4b61449c23 --- /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, + MANUAL_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(MANUAL_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/pages/ndv.ts b/cypress/pages/ndv.ts index 018ec43a5de9c..24e1922663371 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'), 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_io/eslint-config/local-rules.js b/packages/@n8n_io/eslint-config/local-rules.js index c9b533a032e1e..152415e14c8d0 100644 --- a/packages/@n8n_io/eslint-config/local-rules.js +++ b/packages/@n8n_io/eslint-config/local-rules.js @@ -448,6 +448,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..bb1041607ddcd 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 @@ -44,6 +45,12 @@ module.exports = { 'n8n-local-rules/misplaced-n8n-typeorm-import': 'off', }, }, + { + files: ['./test/**/*.ts'], + rules: { + 'n8n-local-rules/no-type-unsafe-event-emitter': 'off', + }, + }, { files: ['./src/decorators/**/*.ts'], rules: { 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/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/src/InternalHooks.ts b/packages/cli/src/InternalHooks.ts index fda2c3f21dfc7..af72d6bfece78 100644 --- a/packages/cli/src/InternalHooks.ts +++ b/packages/cli/src/InternalHooks.ts @@ -1,37 +1,22 @@ 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 { ITelemetryTrackProperties } from 'n8n-workflow'; 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 type { ITelemetryUserDeletionData } 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. 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 @@ -64,145 +49,6 @@ export class InternalHooks { 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, 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..643da18fd26e9 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; 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..ed078b52d5249 100644 --- a/packages/cli/src/PublicApi/v1/handlers/executions/executions.handler.ts +++ b/packages/cli/src/PublicApi/v1/handlers/executions/executions.handler.ts @@ -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 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..53c568ee69acd 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 { 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: [ @@ -43,12 +52,17 @@ 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 = { @@ -68,4 +82,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..16434ef4e9a1e 100644 --- a/packages/cli/src/PublicApi/v1/handlers/workflows/workflows.handler.ts +++ b/packages/cli/src/PublicApi/v1/handlers/workflows/workflows.handler.ts @@ -32,7 +32,7 @@ import { SharedWorkflowRepository } from '@/databases/repositories/sharedWorkflo 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'; 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..30c3a73bde0c7 100644 --- a/packages/cli/src/PublicApi/v1/openapi.yml +++ b/packages/cli/src/PublicApi/v1/openapi.yml @@ -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/Server.ts b/packages/cli/src/Server.ts index a78d6b23725c7..745c26859de57 100644 --- a/packages/cli/src/Server.ts +++ b/packages/cli/src/Server.ts @@ -35,11 +35,11 @@ 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 { License } from './License'; import { AuthService } from './auth/auth.service'; import { getAiServiceProxyMiddleware } from '@n8n_io/ai-assistant-sdk'; import { GlobalConfig } from '@n8n/config'; +import { LogStreamingEventRelay } from '@/events/log-streaming-event-relay'; import '@/controllers/activeWorkflows.controller'; import '@/controllers/auth.controller'; @@ -68,7 +68,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); @@ -254,7 +254,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..1e092077b10de 100644 --- a/packages/cli/src/UserManagement/email/UserManagementMailer.ts +++ b/packages/cli/src/UserManagement/email/UserManagementMailer.ts @@ -16,7 +16,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'; diff --git a/packages/cli/src/WorkflowExecuteAdditionalData.ts b/packages/cli/src/WorkflowExecuteAdditionalData.ts index f4acd61b6aa55..74c469284a7eb 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, }); }, @@ -787,7 +781,6 @@ async function executeWorkflow( parentCallbackManager?: CallbackManager; }, ): Promise | IWorkflowExecuteProcess> { - const internalHooks = Container.get(InternalHooks); const externalHooks = Container.get(ExternalHooks); await externalHooks.init(); @@ -933,13 +926,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, }); diff --git a/packages/cli/src/WorkflowRunner.ts b/packages/cli/src/WorkflowRunner.ts index 3318dd283cd60..2f7976a073b30 100644 --- a/packages/cli/src/WorkflowRunner.ts +++ b/packages/cli/src/WorkflowRunner.ts @@ -34,10 +34,9 @@ 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'; @Service() export class WorkflowRunner { @@ -160,18 +159,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, }); 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..c632557c95c85 100644 --- a/packages/cli/src/auth/methods/ldap.ts +++ b/packages/cli/src/auth/methods/ldap.ts @@ -12,7 +12,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, diff --git a/packages/cli/src/commands/BaseCommand.ts b/packages/cli/src/commands/BaseCommand.ts index 4cebc7fbb66f6..9828e6207b525 100644 --- a/packages/cli/src/commands/BaseCommand.ts +++ b/packages/cli/src/commands/BaseCommand.ts @@ -23,7 +23,7 @@ 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); diff --git a/packages/cli/src/commands/start.ts b/packages/cli/src/commands/start.ts index 4d7cd888b4cc0..76d8b4c7e9273 100644 --- a/packages/cli/src/commands/start.ts +++ b/packages/cli/src/commands/start.ts @@ -33,7 +33,7 @@ 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'); @@ -186,7 +186,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 +210,7 @@ export class Start extends BaseCommand { async initOrchestration() { if (config.getEnv('executions.mode') === 'regular') { - config.set('instanceRole', 'leader'); + this.instanceSettings.markAsLeader(); return; } diff --git a/packages/cli/src/commands/worker.ts b/packages/cli/src/commands/worker.ts index 4f582904cddbb..cf4c23a0859a7 100644 --- a/packages/cli/src/commands/worker.ts +++ b/packages/cli/src/commands/worker.ts @@ -30,7 +30,7 @@ import type { WorkerJobStatusSummary } from '@/services/orchestration/worker/typ 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 { LogStreamingEventRelay } from '@/events/log-streaming-event-relay'; export class Worker extends BaseCommand { static description = '\nStarts a n8n worker'; @@ -286,7 +286,7 @@ export class Worker extends BaseCommand { await Container.get(MessageEventBus).initialize({ workerId: this.queueModeId, }); - Container.get(AuditEventRelay).init(); + Container.get(LogStreamingEventRelay).init(); } /** 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/src/config/schema.ts b/packages/cli/src/config/schema.ts index b9ef98c26b489..f681fe32eaf15 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', @@ -381,12 +382,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, @@ -691,6 +697,19 @@ export const schema = { }, }, + /** + * @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', @@ -755,12 +774,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/controllers/auth.controller.ts b/packages/cli/src/controllers/auth.controller.ts index 7711d95177e0f..e2a481fae9fe0 100644 --- a/packages/cli/src/controllers/auth.controller.ts +++ b/packages/cli/src/controllers/auth.controller.ts @@ -24,7 +24,7 @@ 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 { diff --git a/packages/cli/src/controllers/communityPackages.controller.ts b/packages/cli/src/controllers/communityPackages.controller.ts index 1860e1df86652..e6323c728cb40 100644 --- a/packages/cli/src/controllers/communityPackages.controller.ts +++ b/packages/cli/src/controllers/communityPackages.controller.ts @@ -13,7 +13,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, diff --git a/packages/cli/src/controllers/invitation.controller.ts b/packages/cli/src/controllers/invitation.controller.ts index c32bf543002fd..edf3b5c151fcc 100644 --- a/packages/cli/src/controllers/invitation.controller.ts +++ b/packages/cli/src/controllers/invitation.controller.ts @@ -18,7 +18,7 @@ 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 { diff --git a/packages/cli/src/controllers/me.controller.ts b/packages/cli/src/controllers/me.controller.ts index 3f9366b441260..39dfc93ab7406 100644 --- a/packages/cli/src/controllers/me.controller.ts +++ b/packages/cli/src/controllers/me.controller.ts @@ -23,7 +23,7 @@ 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'; export const API_KEY_PREFIX = 'n8n_api_'; diff --git a/packages/cli/src/controllers/passwordReset.controller.ts b/packages/cli/src/controllers/passwordReset.controller.ts index e17a0f15efe63..84d3b40124ad8 100644 --- a/packages/cli/src/controllers/passwordReset.controller.ts +++ b/packages/cli/src/controllers/passwordReset.controller.ts @@ -21,7 +21,7 @@ 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 { 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..aee2fd28c2a27 100644 --- a/packages/cli/src/controllers/users.controller.ts +++ b/packages/cli/src/controllers/users.controller.ts @@ -28,7 +28,7 @@ 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 { diff --git a/packages/cli/src/credentials/credentials.controller.ts b/packages/cli/src/credentials/credentials.controller.ts index 1587fbd1ca426..9ac57bb2e3af3 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 { diff --git a/packages/cli/src/databases/repositories/execution.repository.ts b/packages/cli/src/databases/repositories/execution.repository.ts index 1ebb22d8eb4bc..a8605147aa751 100644 --- a/packages/cli/src/databases/repositories/execution.repository.ts +++ b/packages/cli/src/databases/repositories/execution.repository.ts @@ -270,17 +270,27 @@ 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); - const { id: executionId } = inserted[0] as { id: string }; - const { connections, nodes, name, settings } = workflowData ?? {}; - await this.executionDataRepository.insert({ - executionId, - workflowData: { connections, nodes, name, settings, id: workflowData.id }, - data: stringify(data), + return await this.manager.transaction(async (transactionManager) => { + const { data, workflowData, ...rest } = execution; + const insertResult = await transactionManager.insert(ExecutionEntity, rest); + const { id: executionId } = insertResult.identifiers[0] as { id: string }; + + const { connections, nodes, name, settings } = workflowData ?? {}; + await this.executionDataRepository.createExecutionDataForExecution( + { + executionId, + workflowData: { connections, nodes, name, settings, id: workflowData.id }, + data: stringify(data), + }, + transactionManager, + ); + + return String(executionId); }); - return String(executionId); } async markAsCrashed(executionIds: string | string[]) { diff --git a/packages/cli/src/databases/repositories/executionData.repository.ts b/packages/cli/src/databases/repositories/executionData.repository.ts index 5872f9888cd66..013453d998e42 100644 --- a/packages/cli/src/databases/repositories/executionData.repository.ts +++ b/packages/cli/src/databases/repositories/executionData.repository.ts @@ -1,13 +1,32 @@ import { Service } from 'typedi'; +import type { EntityManager } from '@n8n/typeorm'; +import type { IWorkflowBase } from 'n8n-workflow'; import { DataSource, In, Repository } from '@n8n/typeorm'; import { ExecutionData } from '../entities/ExecutionData'; +export interface CreateExecutionDataOpts extends Pick { + workflowData: Pick; +} + @Service() export class ExecutionDataRepository extends Repository { constructor(dataSource: DataSource) { super(ExecutionData, dataSource.manager); } + async createExecutionDataForExecution( + executionData: CreateExecutionDataOpts, + transactionManager: EntityManager, + ) { + const { data, executionId, workflowData } = executionData; + + return await transactionManager.insert(ExecutionData, { + executionId, + data, + workflowData, + }); + } + async findByExecutionIds(executionIds: string[]) { return await this.find({ select: ['workflowData'], diff --git a/packages/cli/src/databases/repositories/projectRelation.repository.ts b/packages/cli/src/databases/repositories/projectRelation.repository.ts index bddfd6e38d66f..00fc4de34a195 100644 --- a/packages/cli/src/databases/repositories/projectRelation.repository.ts +++ b/packages/cli/src/databases/repositories/projectRelation.repository.ts @@ -52,4 +52,13 @@ 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))]; + } } 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/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/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/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/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 87% 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..4084bebeb228f 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', @@ -258,7 +261,7 @@ describe('AuditEventRelay', () => { 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 +286,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', @@ -350,7 +353,7 @@ describe('AuditEventRelay', () => { settings: {}, }); - const event: Event['node-pre-execute'] = { + const event: RelayEventMap['node-pre-execute'] = { executionId: 'exec456', nodeName: 'HTTP Request', workflow, @@ -395,7 +398,7 @@ describe('AuditEventRelay', () => { settings: {}, }); - const event: Event['node-post-execute'] = { + const event: RelayEventMap['node-post-execute'] = { executionId: 'exec789', nodeName: 'HTTP Response', workflow, @@ -418,7 +421,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 +456,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', @@ -490,7 +493,7 @@ describe('AuditEventRelay', () => { 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 +514,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 +551,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', @@ -589,7 +592,7 @@ describe('AuditEventRelay', () => { 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', @@ -618,7 +621,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', @@ -646,7 +649,7 @@ describe('AuditEventRelay', () => { describe('execution events', () => { 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 88% rename from packages/cli/src/eventbus/event.types.ts rename to packages/cli/src/events/relay-event-map.ts index b62d3bc141031..193e85c9a4ed8 100644 --- a/packages/cli/src/eventbus/event.types.ts +++ b/packages/cli/src/events/relay-event-map.ts @@ -11,12 +11,15 @@ 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 Server + 'server-started': {}; + // #endregion + + // #region Workflow + 'workflow-created': { user: UserLike; workflow: IWorkflowBase; @@ -44,15 +47,15 @@ export type Event = { 'workflow-post-execute': { executionId: string; - success: boolean; userId?: string; - workflowId: string; - isManual: boolean; - workflowName: string; - metadata?: Record; + workflow: IWorkflowBase; runData?: IRun; }; + // #endregion + + // #region Node + 'node-pre-execute': { executionId: string; workflow: IWorkflowBase; @@ -65,6 +68,10 @@ export type Event = { nodeName: string; }; + // #endregion + + // #region User + 'user-deleted': { user: UserLike; }; @@ -99,6 +106,10 @@ export type Event = { reason?: string; }; + // #endregion + + // #region Click + 'user-invite-email-click': { inviter: UserLike; invitee: UserLike; @@ -112,6 +123,20 @@ export type Event = { user: UserLike; }; + // #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 +144,10 @@ export type Event = { apiVersion: string; }; + // #endregion + + // #region Email + 'email-failed': { user: UserLike; messageType: @@ -129,6 +158,10 @@ export type Event = { | 'Credentials shared'; }; + // #endregion + + // #region Credentials + 'credentials-created': { user: UserLike; credentialType: string; @@ -159,6 +192,10 @@ export type Event = { credentialId: string; }; + // #endregion + + // #region Community package + 'community-package-installed': { user: UserLike; inputString: string; @@ -190,6 +227,10 @@ export type Event = { packageAuthorEmail?: string; }; + // #endregion + + // #region Execution + 'execution-throttled': { executionId: string; }; @@ -198,6 +239,10 @@ export type Event = { executionId: string; }; + // #endregion + + // #region Project + 'team-project-updated': { userId: string; role: GlobalRole; @@ -221,6 +266,10 @@ export type Event = { role: GlobalRole; }; + // #endregion + + // #region Source control + 'source-control-settings-updated': { branchName: string; readOnlyInstance: boolean; @@ -258,12 +307,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 +333,10 @@ export type Event = { errorMessage?: string; }; + // #endregion + + // #region LDAP + 'ldap-general-sync-finished': { type: string; succeeded: boolean; @@ -302,17 +367,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/telemetry/telemetry-event-relay.service.ts b/packages/cli/src/events/telemetry-event-relay.ts similarity index 54% rename from packages/cli/src/telemetry/telemetry-event-relay.service.ts rename to packages/cli/src/events/telemetry-event-relay.ts index 9077abdf9ce23..91f82a0c127a5 100644 --- a/packages/cli/src/telemetry/telemetry-event-relay.service.ts +++ b/packages/cli/src/events/telemetry-event-relay.ts @@ -1,22 +1,27 @@ import { Service } from 'typedi'; -import { EventService } from '@/eventbus/event.service'; -import type { Event } from '@/eventbus/event.types'; -import { Telemetry } from '.'; +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'; @Service() -export class TelemetryEventRelay { +export class TelemetryEventRelay extends EventRelay { constructor( - private readonly eventService: EventService, + readonly eventService: EventService, private readonly telemetry: Telemetry, private readonly license: License, private readonly globalConfig: GlobalConfig, @@ -24,103 +29,63 @@ export class TelemetryEventRelay { 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.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.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-saved': async (event) => await this.workflowSaved(event), + 'server-started': async () => await this.serverStarted(), + 'workflow-post-execute': async (event) => await this.workflowPostExecute(event), + }); + } + + // #endregion + + // #region Team + + private teamProjectUpdated({ + userId, + role, + members, + projectId, + }: RelayEventMap['team-project-updated']) { this.telemetry.track('Project settings updated', { user_id: userId, role, @@ -136,7 +101,7 @@ export class TelemetryEventRelay { projectId, removalType, targetProjectId, - }: Event['team-project-deleted']) { + }: RelayEventMap['team-project-deleted']) { this.telemetry.track('User deleted project', { user_id: userId, role, @@ -146,19 +111,23 @@ export class TelemetryEventRelay { }); } - private teamProjectCreated({ userId, role }: Event['team-project-created']) { + 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, - }: Event['source-control-settings-updated']) { + }: RelayEventMap['source-control-settings-updated']) { this.telemetry.track('User updated source control settings', { branch_name: branchName, read_only_instance: readOnlyInstance, @@ -171,7 +140,7 @@ export class TelemetryEventRelay { workflowUpdates, workflowConflicts, credConflicts, - }: Event['source-control-user-started-pull-ui']) { + }: RelayEventMap['source-control-user-started-pull-ui']) { this.telemetry.track('User started pull via UI', { workflow_updates: workflowUpdates, workflow_conflicts: workflowConflicts, @@ -181,7 +150,7 @@ export class TelemetryEventRelay { private sourceControlUserFinishedPullUi({ workflowUpdates, - }: Event['source-control-user-finished-pull-ui']) { + }: RelayEventMap['source-control-user-finished-pull-ui']) { this.telemetry.track('User finished pull via UI', { workflow_updates: workflowUpdates, }); @@ -190,7 +159,7 @@ export class TelemetryEventRelay { private sourceControlUserPulledApi({ workflowUpdates, forced, - }: Event['source-control-user-pulled-api']) { + }: RelayEventMap['source-control-user-pulled-api']) { console.log('source-control-user-pulled-api', { workflow_updates: workflowUpdates, forced, @@ -207,7 +176,7 @@ export class TelemetryEventRelay { credsEligible, credsEligibleWithConflicts, variablesEligible, - }: Event['source-control-user-started-push-ui']) { + }: RelayEventMap['source-control-user-started-push-ui']) { this.telemetry.track('User started push via UI', { workflows_eligible: workflowsEligible, workflows_eligible_with_conflicts: workflowsEligibleWithConflicts, @@ -222,7 +191,7 @@ export class TelemetryEventRelay { workflowsPushed, credsPushed, variablesPushed, - }: Event['source-control-user-finished-push-ui']) { + }: RelayEventMap['source-control-user-finished-push-ui']) { this.telemetry.track('User finished push via UI', { workflows_eligible: workflowsEligible, workflows_pushed: workflowsPushed, @@ -231,23 +200,35 @@ export class TelemetryEventRelay { }); } - private licenseRenewalAttempted({ success }: Event['license-renewal-attempted']) { + // #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, - }: Event['external-secrets-provider-settings-saved']) { + }: RelayEventMap['external-secrets-provider-settings-saved']) { this.telemetry.track('User updated external secrets settings', { user_id: userId, vault_type: vaultType, @@ -257,7 +238,16 @@ export class TelemetryEventRelay { }); } - private publicApiInvoked({ userId, path, method, apiVersion }: Event['public-api-invoked']) { + // #endregion + + // #region Public API + + private publicApiInvoked({ + userId, + path, + method, + apiVersion, + }: RelayEventMap['public-api-invoked']) { this.telemetry.track('User invoked API', { user_id: userId, path, @@ -266,7 +256,7 @@ export class TelemetryEventRelay { }); } - private publicApiKeyCreated(event: Event['public-api-key-created']) { + private publicApiKeyCreated(event: RelayEventMap['public-api-key-created']) { const { user, publicApi } = event; this.telemetry.track('API key created', { @@ -275,7 +265,7 @@ export class TelemetryEventRelay { }); } - private publicApiKeyDeleted(event: Event['public-api-key-deleted']) { + private publicApiKeyDeleted(event: RelayEventMap['public-api-key-deleted']) { const { user, publicApi } = event; this.telemetry.track('API key deleted', { @@ -284,6 +274,10 @@ export class TelemetryEventRelay { }); } + // #endregion + + // #region Community package + private communityPackageInstalled({ user, inputString, @@ -294,7 +288,7 @@ export class TelemetryEventRelay { packageAuthor, packageAuthorEmail, failureReason, - }: Event['community-package-installed']) { + }: RelayEventMap['community-package-installed']) { this.telemetry.track('cnr package install finished', { user_id: user.id, input_string: inputString, @@ -316,7 +310,7 @@ export class TelemetryEventRelay { packageNodeNames, packageAuthor, packageAuthorEmail, - }: Event['community-package-updated']) { + }: RelayEventMap['community-package-updated']) { this.telemetry.track('cnr package updated', { user_id: user.id, package_name: packageName, @@ -335,7 +329,7 @@ export class TelemetryEventRelay { packageNodeNames, packageAuthor, packageAuthorEmail, - }: Event['community-package-deleted']) { + }: RelayEventMap['community-package-deleted']) { this.telemetry.track('cnr package deleted', { user_id: user.id, package_name: packageName, @@ -346,13 +340,17 @@ export class TelemetryEventRelay { }); } + // #endregion + + // #region Credentials + private credentialsCreated({ user, credentialType, credentialId, projectId, projectType, - }: Event['credentials-created']) { + }: RelayEventMap['credentials-created']) { this.telemetry.track('User created credentials', { user_id: user.id, credential_type: credentialType, @@ -369,7 +367,7 @@ export class TelemetryEventRelay { userIdSharer, userIdsShareesAdded, shareesRemoved, - }: Event['credentials-shared']) { + }: RelayEventMap['credentials-shared']) { this.telemetry.track('User updated cred sharing', { user_id: user.id, credential_type: credentialType, @@ -380,7 +378,11 @@ export class TelemetryEventRelay { }); } - private credentialsUpdated({ user, credentialId, credentialType }: Event['credentials-updated']) { + private credentialsUpdated({ + user, + credentialId, + credentialType, + }: RelayEventMap['credentials-updated']) { this.telemetry.track('User updated credentials', { user_id: user.id, credential_type: credentialType, @@ -388,7 +390,11 @@ export class TelemetryEventRelay { }); } - private credentialsDeleted({ user, credentialId, credentialType }: Event['credentials-deleted']) { + private credentialsDeleted({ + user, + credentialId, + credentialType, + }: RelayEventMap['credentials-deleted']) { this.telemetry.track('User deleted credentials', { user_id: user.id, credential_type: credentialType, @@ -396,12 +402,16 @@ export class TelemetryEventRelay { }); } + // #endregion + + // #region LDAP + private ldapGeneralSyncFinished({ type, succeeded, usersSynced, error, - }: Event['ldap-general-sync-finished']) { + }: RelayEventMap['ldap-general-sync-finished']) { this.telemetry.track('Ldap general sync finished', { type, succeeded, @@ -423,7 +433,7 @@ export class TelemetryEventRelay { synchronizationInterval, loginLabel, loginEnabled, - }: Event['ldap-settings-updated']) { + }: RelayEventMap['ldap-settings-updated']) { this.telemetry.track('User updated Ldap settings', { user_id: userId, loginIdAttribute, @@ -440,21 +450,27 @@ export class TelemetryEventRelay { }); } - private ldapLoginSyncFailed({ error }: Event['ldap-login-sync-failed']) { + private ldapLoginSyncFailed({ error }: RelayEventMap['ldap-login-sync-failed']) { this.telemetry.track('Ldap login sync failed', { error }); } - private loginFailedDueToLdapDisabled({ userId }: Event['login-failed-due-to-ldap-disabled']) { + 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, - }: Event['workflow-created']) { + }: RelayEventMap['workflow-created']) { const { nodeGraph } = TelemetryHelpers.generateNodesGraph(workflow, this.nodeTypes); this.telemetry.track('User created workflow', { @@ -467,7 +483,7 @@ export class TelemetryEventRelay { }); } - private workflowDeleted({ user, workflowId, publicApi }: Event['workflow-deleted']) { + private workflowDeleted({ user, workflowId, publicApi }: RelayEventMap['workflow-deleted']) { this.telemetry.track('User deleted workflow', { user_id: user.id, workflow_id: workflowId, @@ -475,7 +491,7 @@ export class TelemetryEventRelay { }); } - private async workflowSaved({ user, workflow, publicApi }: Event['workflow-saved']) { + private async workflowSaved({ user, workflow, publicApi }: RelayEventMap['workflow-saved']) { const isCloudDeployment = config.getEnv('deployment.type') === 'cloud'; const { nodeGraph } = TelemetryHelpers.generateNodesGraph(workflow, this.nodeTypes, { @@ -521,6 +537,148 @@ export class TelemetryEventRelay { }); } + // 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 Server + private async serverStarted() { const cpus = os.cpus(); const binaryDataConfig = config.getEnv('binaryDataManager'); @@ -584,4 +742,6 @@ export class TelemetryEventRelay { earliest_workflow_created: firstWorkflow?.createdAt, }); } + + // #endregion } 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..39a84d27a78c1 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'; @@ -21,38 +22,37 @@ 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); + mockInstance(InternalHooks); + 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(); @@ -69,7 +69,6 @@ describe('ExecutionRecoveryService', () => { * Arrange */ config.set('executions.mode', 'queue'); - jest.spyOn(orchestrationService, 'isLeader', 'get').mockReturnValue(true); const scheduleSpy = jest.spyOn(executionRecoveryService, 'scheduleQueueRecovery'); /** @@ -88,7 +87,7 @@ describe('ExecutionRecoveryService', () => { * Arrange */ config.set('executions.mode', 'queue'); - jest.spyOn(orchestrationService, 'isLeader', 'get').mockReturnValue(false); + instanceSettings.markAsFollower(); const scheduleSpy = jest.spyOn(executionRecoveryService, 'scheduleQueueRecovery'); /** @@ -130,7 +129,7 @@ describe('ExecutionRecoveryService', () => { /** * 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/execution-recovery.service.ts b/packages/cli/src/executions/execution-recovery.service.ts index b72fc490dddbb..435145545ab16 100644 --- a/packages/cli/src/executions/execution-recovery.service.ts +++ b/packages/cli/src/executions/execution-recovery.service.ts @@ -3,9 +3,9 @@ import { Push } from '@/push'; import { jsonStringify, 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'; @@ -16,7 +16,7 @@ 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,6 +25,7 @@ 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, @@ -37,10 +38,10 @@ export class ExecutionRecoveryService { init() { if (config.getEnv('executions.mode') === 'regular') return; - const { isLeader, isMultiMainSetupEnabled } = this.orchestrationService; - + const { isLeader } = this.instanceSettings; if (isLeader) this.scheduleQueueRecovery(); + const { isMultiMainSetupEnabled } = this.orchestrationService; if (isMultiMainSetupEnabled) { this.orchestrationService.multiMainSetup .on('leader-takeover', () => this.scheduleQueueRecovery()) @@ -59,7 +60,7 @@ export class ExecutionRecoveryService { * 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); @@ -280,22 +281,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, }); @@ -333,7 +321,7 @@ export class ExecutionRecoveryService { private shouldScheduleQueueRecovery() { return ( config.getEnv('executions.mode') === 'queue' && - config.getEnv('instanceRole') === 'leader' && + this.instanceSettings.isLeader && !this.isShuttingDown ); } 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/src/requests.ts b/packages/cli/src/requests.ts index 1ea265f2394e7..4fe369857eb7a 100644 --- a/packages/cli/src/requests.ts +++ b/packages/cli/src/requests.ts @@ -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 }, {}, {}, {}>; 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/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/user.service.ts b/packages/cli/src/services/user.service.ts index 007054c6aa980..25be080ca05bf 100644 --- a/packages/cli/src/services/user.service.ts +++ b/packages/cli/src/services/user.service.ts @@ -12,7 +12,7 @@ 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 { 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/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/workflowSharing.service.ts b/packages/cli/src/workflows/workflowSharing.service.ts index 93ef76438f7b0..add5bb31b8fc9 100644 --- a/packages/cli/src/workflows/workflowSharing.service.ts +++ b/packages/cli/src/workflows/workflowSharing.service.ts @@ -27,11 +27,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); } diff --git a/packages/cli/src/workflows/workflows.controller.ts b/packages/cli/src/workflows/workflows.controller.ts index c774b21917c98..60dbbf8191c4f 100644 --- a/packages/cli/src/workflows/workflows.controller.ts +++ b/packages/cli/src/workflows/workflows.controller.ts @@ -42,7 +42,7 @@ 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') 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/commands/worker.cmd.test.ts b/packages/cli/test/integration/commands/worker.cmd.test.ts index 0ffad7bc056ec..54c15d381de95 100644 --- a/packages/cli/test/integration/commands/worker.cmd.test.ts +++ b/packages/cli/test/integration/commands/worker.cmd.test.ts @@ -15,7 +15,7 @@ import { type JobQueue, Queue } from '@/Queue'; 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'); @@ -26,7 +26,7 @@ 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 orchestrationWorkerService = mockInstance(OrchestrationWorkerService); @@ -45,7 +45,7 @@ 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(logStreamingEventRelay.init).toHaveBeenCalledTimes(1); expect(queue.init).toHaveBeenCalledTimes(1); expect(queue.process).toHaveBeenCalledTimes(1); expect(orchestrationWorkerService.init).toHaveBeenCalledTimes(1); diff --git a/packages/cli/test/integration/database/repositories/execution.repository.test.ts b/packages/cli/test/integration/database/repositories/execution.repository.test.ts index cfb897d627a56..e777f624299aa 100644 --- a/packages/cli/test/integration/database/repositories/execution.repository.test.ts +++ b/packages/cli/test/integration/database/repositories/execution.repository.test.ts @@ -52,5 +52,34 @@ describe('ExecutionRepository', () => { }); expect(executionData?.data).toEqual('[{"resultData":"1"},{}]'); }); + + it('should not create execution if execution data insert fails', async () => { + const executionRepo = Container.get(ExecutionRepository); + const executionDataRepo = Container.get(ExecutionDataRepository); + + const workflow = await createWorkflow({ settings: { executionOrder: 'v1' } }); + jest + .spyOn(executionDataRepo, 'createExecutionDataForExecution') + .mockRejectedValueOnce(new Error()); + + await expect( + async () => + await executionRepo.createNewExecution({ + workflowId: workflow.id, + data: { + //@ts-expect-error This is not needed for tests + resultData: {}, + }, + workflowData: workflow, + mode: 'manual', + startedAt: new Date(), + status: 'new', + finished: false, + }), + ).rejects.toThrow(); + + const executionEntities = await executionRepo.find(); + expect(executionEntities).toBeEmptyArray(); + }); }); }); diff --git a/packages/cli/test/integration/prometheus-metrics.test.ts b/packages/cli/test/integration/prometheus-metrics.test.ts index 4c00a98f74a49..9f6a0fad6e89d 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); +// @ts-expect-error `metrics` is a readonly property +globalConfig.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 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..1e292af64d92f 100644 --- a/packages/cli/test/integration/publicApi/workflows.test.ts +++ b/packages/cli/test/integration/publicApi/workflows.test.ts @@ -1493,7 +1493,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..2d25d837cc928 100644 --- a/packages/cli/test/integration/shared/utils/testCommand.ts +++ b/packages/cli/test/integration/shared/utils/testCommand.ts @@ -4,7 +4,7 @@ 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'; export const setupTestCommand = (Command: Class) => { diff --git a/packages/cli/test/unit/WaitTracker.test.ts b/packages/cli/test/unit/WaitTracker.test.ts index 0f8464e18d14d..fb51d2e25bd30 100644 --- a/packages/cli/test/unit/WaitTracker.test.ts +++ b/packages/cli/test/unit/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/license/license.service.test.ts b/packages/cli/test/unit/license/license.service.test.ts index e28895025ffbe..fb75c6a27d692 100644 --- a/packages/cli/test/unit/license/license.service.test.ts +++ b/packages/cli/test/unit/license/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/test/unit/services/orchestration.service.test.ts b/packages/cli/test/unit/services/orchestration.service.test.ts index 75c7c06e40bb6..0b63f74344b57 100644 --- a/packages/cli/test/unit/services/orchestration.service.test.ts +++ b/packages/cli/test/unit/services/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'; @@ -13,11 +18,9 @@ import { Logger } from '@/Logger'; import { Push } from '@/push'; import { ActiveWorkflowManager } from '@/ActiveWorkflowManager'; import { mockInstance } from '../../shared/mocking'; -import type { WorkflowActivateMode } from 'n8n-workflow'; 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/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/editor-ui/src/components/CredentialCard.test.ts b/packages/editor-ui/src/components/CredentialCard.test.ts index 06760c75da7c5..9ca589923d59e 100644 --- a/packages/editor-ui/src/components/CredentialCard.test.ts +++ b/packages/editor-ui/src/components/CredentialCard.test.ts @@ -6,7 +6,7 @@ import { createComponentRenderer } from '@/__tests__/render'; import CredentialCard from '@/components/CredentialCard.vue'; import type { ICredentialsResponse } from '@/Interface'; import type { ProjectSharingData } from '@/types/projects.types'; -import { useSettingsStore } from '@/stores/settings.store'; +import { useProjectsStore } from '@/stores/projects.store'; const renderComponent = createComponentRenderer(CredentialCard); @@ -22,12 +22,12 @@ const createCredential = (overrides = {}): ICredentialsResponse => ({ }); describe('CredentialCard', () => { - let settingsStore: ReturnType; + let projectsStore: ReturnType; beforeEach(() => { const pinia = createTestingPinia(); setActivePinia(pinia); - settingsStore = useSettingsStore(); + projectsStore = useProjectsStore(); }); it('should render name and home project name', () => { @@ -63,7 +63,7 @@ describe('CredentialCard', () => { }); it('should show Move action only if there is resource permission and not on community plan', async () => { - vi.spyOn(settingsStore, 'isCommunityPlan', 'get').mockReturnValue(false); + vi.spyOn(projectsStore, 'isTeamProjectFeatureEnabled', 'get').mockReturnValue(true); const data = createCredential({ scopes: ['credential:move'], diff --git a/packages/editor-ui/src/components/CredentialCard.vue b/packages/editor-ui/src/components/CredentialCard.vue index f36926a19b09d..d9ac12ffd9a54 100644 --- a/packages/editor-ui/src/components/CredentialCard.vue +++ b/packages/editor-ui/src/components/CredentialCard.vue @@ -14,7 +14,6 @@ import { useProjectsStore } from '@/stores/projects.store'; import ProjectCardBadge from '@/components/Projects/ProjectCardBadge.vue'; import { useI18n } from '@/composables/useI18n'; import { ResourceType } from '@/utils/projects.utils'; -import { useSettingsStore } from '@/stores/settings.store'; const CREDENTIAL_LIST_ITEM_ACTIONS = { OPEN: 'open', @@ -46,7 +45,6 @@ const message = useMessage(); const uiStore = useUIStore(); const credentialsStore = useCredentialsStore(); const projectsStore = useProjectsStore(); -const settingsStore = useSettingsStore(); const resourceTypeLabel = computed(() => locale.baseText('generic.credential').toLowerCase()); const credentialType = computed(() => credentialsStore.getCredentialTypeByName(props.data.type)); @@ -66,7 +64,7 @@ const actions = computed(() => { }); } - if (credentialPermissions.value.move && !settingsStore.isCommunityPlan) { + if (credentialPermissions.value.move && projectsStore.isTeamProjectFeatureEnabled) { items.push({ label: locale.baseText('credentials.item.move'), value: CREDENTIAL_LIST_ITEM_ACTIONS.MOVE, diff --git a/packages/editor-ui/src/components/Error/NodeErrorView.vue b/packages/editor-ui/src/components/Error/NodeErrorView.vue index 3ef1c568ec8aa..f5b35979aabbe 100644 --- a/packages/editor-ui/src/components/Error/NodeErrorView.vue +++ b/packages/editor-ui/src/components/Error/NodeErrorView.vue @@ -29,6 +29,7 @@ import { useWorkflowsStore } from '@/stores/workflows.store'; type Props = { // TODO: .node can be undefined error: NodeError | NodeApiError | NodeOperationError; + compact?: boolean; }; const props = defineProps(); @@ -459,7 +460,7 @@ async function onAskAssistantClick() { -
+

{{ i18n.baseText('nodeErrorView.details.title') }} diff --git a/packages/editor-ui/src/components/OutputPanel.vue b/packages/editor-ui/src/components/OutputPanel.vue index 6d76c34295978..af1a7801f6652 100644 --- a/packages/editor-ui/src/components/OutputPanel.vue +++ b/packages/editor-ui/src/components/OutputPanel.vue @@ -28,6 +28,7 @@

+