diff --git a/CHANGELOG.md b/CHANGELOG.md index cc2a07dda09b1..986b85b0682ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,48 @@ +# [1.53.0](https://github.com/n8n-io/n8n/compare/n8n@1.52.0...n8n@1.53.0) (2024-07-31) + + +### Bug Fixes + +* Better error message when calling data transformation functions on a null value ([#10210](https://github.com/n8n-io/n8n/issues/10210)) ([1718125](https://github.com/n8n-io/n8n/commit/1718125c6d8589cf24dc8d34f6808dd6f1802691)) +* **core:** Fix missing successful items on continueErrorOutput with multiple outputs ([#10218](https://github.com/n8n-io/n8n/issues/10218)) ([1a7713e](https://github.com/n8n-io/n8n/commit/1a7713ef263680da43f08b6c8a15aee7a0341493)) +* **core:** Flush instance stopped event immediately ([#10238](https://github.com/n8n-io/n8n/issues/10238)) ([d6770b5](https://github.com/n8n-io/n8n/commit/d6770b5fcaec6438d677b918aaeb1669ad7424c2)) +* **core:** Restore log event `n8n.workflow.failed` ([#10253](https://github.com/n8n-io/n8n/issues/10253)) ([3e96b29](https://github.com/n8n-io/n8n/commit/3e96b293329525c9d4b2fcef87b3803e458c8e7f)) +* **core:** Upgrade @n8n/vm2 to address CVE‑2023‑37466 ([#10265](https://github.com/n8n-io/n8n/issues/10265)) ([2a09a03](https://github.com/n8n-io/n8n/commit/2a09a036d2e916acff7ee50904f1d011a93758e1)) +* **editor:** Defer `User saved credentials` telemetry event for OAuth credentials ([#10215](https://github.com/n8n-io/n8n/issues/10215)) ([40a5226](https://github.com/n8n-io/n8n/commit/40a5226e24448a4428143e69d80ebc78238365a1)) +* **editor:** Fix custom API call notice ([#10227](https://github.com/n8n-io/n8n/issues/10227)) ([5b47c8b](https://github.com/n8n-io/n8n/commit/5b47c8b57b25528cd2d6f97bc6d98707d47f35bc)) +* **editor:** Fix issue with existing credential not opening in HTTP agent tool ([#10167](https://github.com/n8n-io/n8n/issues/10167)) ([906b4c3](https://github.com/n8n-io/n8n/commit/906b4c3c7b2919111cf23eaa12b3c4d507969179)) +* **editor:** Fix parameter input glitch when there was an error loading remote options ([#10209](https://github.com/n8n-io/n8n/issues/10209)) ([c0e3743](https://github.com/n8n-io/n8n/commit/c0e37439a87105a0e66c8ebced42c06dab30dc5e)) +* **editor:** Fix workflow execution list scrolling after filter change ([#10226](https://github.com/n8n-io/n8n/issues/10226)) ([7e64358](https://github.com/n8n-io/n8n/commit/7e643589c67adc0218216ec4b89a95f0edfedbee)) +* **Google BigQuery Node:** Send timeoutMs in query, pagination support ([#10205](https://github.com/n8n-io/n8n/issues/10205)) ([f5722e8](https://github.com/n8n-io/n8n/commit/f5722e8823ccd2bc2b5f43ba3c849797d5690a93)) +* **Google Sheets Node:** Add column names row if sheet is empty ([#10200](https://github.com/n8n-io/n8n/issues/10200)) ([82eba9f](https://github.com/n8n-io/n8n/commit/82eba9fc5ff49b8e2a9db93c10b253fb67a8c644)) +* **Google Sheets Node:** Do not insert row_number as a new column, do not checkForSchemaChanges in update operation ([#10201](https://github.com/n8n-io/n8n/issues/10201)) ([5136d10](https://github.com/n8n-io/n8n/commit/5136d10ca3492f92af67d4a1d4abc774419580cc)) +* **Google Sheets Node:** Fix Google Sheet URL regex ([#10195](https://github.com/n8n-io/n8n/issues/10195)) ([e6fd996](https://github.com/n8n-io/n8n/commit/e6fd996973d4f40facf0ebf1eea3cc26acd0603d)) +* **HTTP Request Node:** Resolve max pages expression ([#10192](https://github.com/n8n-io/n8n/issues/10192)) ([bfc8e1b](https://github.com/n8n-io/n8n/commit/bfc8e1b56f7714e1f52aae747d58d686b86e60f0)) +* **LinkedIn Node:** Fix issue with some characters cutting off posts early ([#10185](https://github.com/n8n-io/n8n/issues/10185)) ([361b5e7](https://github.com/n8n-io/n8n/commit/361b5e7c37ba49b68dcf5b8122621aad4d8d96e0)) +* **Postgres Node:** Expressions in query parameters for Postgres executeQuery operation ([#10217](https://github.com/n8n-io/n8n/issues/10217)) ([519fc4d](https://github.com/n8n-io/n8n/commit/519fc4d75325a80b84cc4dcacf52d6f4c02e3a44)) +* **Postgres Node:** Option to treat query parameters enclosed in single quotas as text ([#10214](https://github.com/n8n-io/n8n/issues/10214)) ([00ec253](https://github.com/n8n-io/n8n/commit/00ec2533374d3def465efee718592fc4001d5602)) +* **Read/Write Files from Disk Node:** Notice update in file selector, replace backslashes with forward slashes if windows path ([#10186](https://github.com/n8n-io/n8n/issues/10186)) ([3eac673](https://github.com/n8n-io/n8n/commit/3eac673b17986c5c74bd2adb5ad589ba0ca55319)) +* **Text Classifier Node:** Use proper documentation URL and respect continueOnFail ([#10216](https://github.com/n8n-io/n8n/issues/10216)) ([452f52c](https://github.com/n8n-io/n8n/commit/452f52c124017e002e86c547ba42b1633b14beed)) +* **Trello Node:** Use body for POST requests ([#10189](https://github.com/n8n-io/n8n/issues/10189)) ([7775d50](https://github.com/n8n-io/n8n/commit/7775d5059b7f69d9af22e7ad7d12c6cf9092a4e5)) +* **Wait Node:** Authentication fix ([#10236](https://github.com/n8n-io/n8n/issues/10236)) ([f87854f](https://github.com/n8n-io/n8n/commit/f87854f8db360b7b870583753fcfb4af95adab8c)) + + +### Features + +* **Calendly Trigger Node:** Add OAuth Credentials Support ([#10251](https://github.com/n8n-io/n8n/issues/10251)) ([326c983](https://github.com/n8n-io/n8n/commit/326c983915a2c382e32398358e7dcadd022c0b77)) +* **core:** Allow filtering workflows by project and transferring workflows in Public API ([#10231](https://github.com/n8n-io/n8n/issues/10231)) ([d719899](https://github.com/n8n-io/n8n/commit/d719899223907b20a17883a35e4ef637a3453532)) +* **editor:** Show new executions as `Queued` in the UI, until they actually start ([#10204](https://github.com/n8n-io/n8n/issues/10204)) ([44728d7](https://github.com/n8n-io/n8n/commit/44728d72423f5549dda09589f4a618ebd80899cb)) +* **HTTP Request Node:** Add option to disable lowercase headers ([#10154](https://github.com/n8n-io/n8n/issues/10154)) ([5aba69b](https://github.com/n8n-io/n8n/commit/5aba69bcf4d232d9860f3cd9fe57cb8839a2f96f)) +* **Information Extractor Node:** Add new simplified AI-node for information extraction ([#10149](https://github.com/n8n-io/n8n/issues/10149)) ([3d235b0](https://github.com/n8n-io/n8n/commit/3d235b0b2df756df35ac60e3dcd87ad183a07167)) +* Introduce Google Cloud Platform as external secrets provider ([#10146](https://github.com/n8n-io/n8n/issues/10146)) ([3ccb9df](https://github.com/n8n-io/n8n/commit/3ccb9df2f902e46f8cbb9c46c0727f29d752a773)) +* **n8n Form Trigger Node:** Improvements ([#10092](https://github.com/n8n-io/n8n/issues/10092)) ([711b667](https://github.com/n8n-io/n8n/commit/711b667ebefe55740e5eb39f1f0f24ceee10e7b0)) +* Recovery option for jsonParse helper ([#10182](https://github.com/n8n-io/n8n/issues/10182)) ([d165b33](https://github.com/n8n-io/n8n/commit/d165b33ceac4d24d0fc290bffe63b5f551204e38)) +* **Sentiment Analysis Node:** Implement Sentiment Analysis node ([#10184](https://github.com/n8n-io/n8n/issues/10184)) ([8ef0a0c](https://github.com/n8n-io/n8n/commit/8ef0a0c58ac2a84aad649ccbe72aa907d005cc44)) +* **Shopify Node:** Update Shopify API version ([#10155](https://github.com/n8n-io/n8n/issues/10155)) ([e2ee915](https://github.com/n8n-io/n8n/commit/e2ee91569a382bfbf787cf45204c72c821a860a0)) +* Support create, read, delete variables in Public API ([#10241](https://github.com/n8n-io/n8n/issues/10241)) ([af695eb](https://github.com/n8n-io/n8n/commit/af695ebf934526d926ea87fe87df61aa73d70979)) + + + # [1.52.0](https://github.com/n8n-io/n8n/compare/n8n@1.51.0...n8n@1.52.0) (2024-07-24) diff --git a/cypress/e2e/20-workflow-executions.cy.ts b/cypress/e2e/20-workflow-executions.cy.ts index 075923e940b4e..59f08c570b0ff 100644 --- a/cypress/e2e/20-workflow-executions.cy.ts +++ b/cypress/e2e/20-workflow-executions.cy.ts @@ -183,6 +183,50 @@ describe('Current Workflow Executions', () => { .invoke('attr', 'title') .should('eq', newWorkflowName); }); + + it('should load items and auto scroll after filter change', () => { + createMockExecutions(); + createMockExecutions(); + cy.intercept('GET', '/rest/executions?filter=*').as('getExecutions'); + + executionsTab.actions.switchToExecutionsTab(); + + cy.wait(['@getExecutions']); + + executionsTab.getters.executionsList().scrollTo(0, 500).wait(0); + + executionsTab.getters.executionListItems().eq(10).click(); + + cy.getByTestId('executions-filter-button').click(); + cy.getByTestId('executions-filter-status-select').should('be.visible').click(); + getVisibleSelect().find('li:contains("Error")').click(); + + executionsTab.getters.executionListItems().should('have.length', 5); + executionsTab.getters.successfulExecutionListItems().should('have.length', 1); + executionsTab.getters.failedExecutionListItems().should('have.length', 4); + + cy.getByTestId('executions-filter-button').click(); + cy.getByTestId('executions-filter-status-select').should('be.visible').click(); + getVisibleSelect().find('li:contains("Success")').click(); + + // check if the list is scrolled + executionsTab.getters.executionListItems().eq(10).should('be.visible'); + executionsTab.getters.executionsList().then(($el) => { + const { scrollTop, scrollHeight, clientHeight } = $el[0]; + expect(scrollTop).to.be.greaterThan(0); + expect(scrollTop + clientHeight).to.be.lessThan(scrollHeight); + + // scroll to the bottom + $el[0].scrollTo(0, scrollHeight); + executionsTab.getters.executionListItems().should('have.length', 18); + executionsTab.getters.successfulExecutionListItems().should('have.length', 18); + executionsTab.getters.failedExecutionListItems().should('have.length', 0); + }); + + cy.getByTestId('executions-filter-button').click(); + cy.getByTestId('executions-filter-reset-button').should('be.visible').click(); + executionsTab.getters.executionListItems().eq(11).should('be.visible'); + }); }); const createMockExecutions = () => { diff --git a/package.json b/package.json index 3dd939d9d30fa..e05eeb9845f1b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "n8n-monorepo", - "version": "1.52.0", + "version": "1.53.0", "private": true, "engines": { "node": ">=20.15", diff --git a/packages/@n8n/chat/package.json b/packages/@n8n/chat/package.json index 4f425b6fa8237..5ba8a1c39f201 100644 --- a/packages/@n8n/chat/package.json +++ b/packages/@n8n/chat/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/chat", - "version": "0.21.0", + "version": "0.22.0", "scripts": { "dev": "pnpm run storybook", "build": "pnpm build:vite && pnpm build:bundle", diff --git a/packages/@n8n/client-oauth2/package.json b/packages/@n8n/client-oauth2/package.json index 83b858a2e9fa0..7ec91b98390b1 100644 --- a/packages/@n8n/client-oauth2/package.json +++ b/packages/@n8n/client-oauth2/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/client-oauth2", - "version": "0.19.0", + "version": "0.20.0", "scripts": { "clean": "rimraf dist .turbo", "dev": "pnpm watch", diff --git a/packages/@n8n/config/package.json b/packages/@n8n/config/package.json index 66332540fa2e6..a4abfa677adc1 100644 --- a/packages/@n8n/config/package.json +++ b/packages/@n8n/config/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/config", - "version": "1.2.0", + "version": "1.3.0", "scripts": { "clean": "rimraf dist .turbo", "dev": "pnpm watch", diff --git a/packages/@n8n/config/src/configs/endpoints.ts b/packages/@n8n/config/src/configs/endpoints.ts new file mode 100644 index 0000000000000..7a04a8249f692 --- /dev/null +++ b/packages/@n8n/config/src/configs/endpoints.ts @@ -0,0 +1,102 @@ +import { Config, Env, Nested } from '../decorators'; + +@Config +class PrometheusMetricsConfig { + /** Whether to enable the `/metrics` endpoint to expose Prometheus metrics. */ + @Env('N8N_METRICS') + readonly enable: boolean = false; + + /** Prefix for Prometheus metric names. */ + @Env('N8N_METRICS_PREFIX') + readonly prefix: string = 'n8n_'; + + /** Whether to expose system and Node.js metrics. See: https://www.npmjs.com/package/prom-client */ + @Env('N8N_METRICS_INCLUDE_DEFAULT_METRICS') + readonly includeDefaultMetrics = true; + + /** Whether to include a label for workflow ID on workflow metrics. */ + @Env('N8N_METRICS_INCLUDE_WORKFLOW_ID_LABEL') + readonly includeWorkflowIdLabel: boolean = false; + + /** Whether to include a label for node type on node metrics. */ + @Env('N8N_METRICS_INCLUDE_NODE_TYPE_LABEL') + readonly includeNodeTypeLabel: boolean = false; + + /** Whether to include a label for credential type on credential metrics. */ + @Env('N8N_METRICS_INCLUDE_CREDENTIAL_TYPE_LABEL') + readonly includeCredentialTypeLabel: boolean = false; + + /** Whether to expose metrics for API endpoints. See: https://www.npmjs.com/package/express-prom-bundle */ + @Env('N8N_METRICS_INCLUDE_API_ENDPOINTS') + readonly includeApiEndpoints: boolean = false; + + /** Whether to include a label for the path of API endpoint calls. */ + @Env('N8N_METRICS_INCLUDE_API_PATH_LABEL') + readonly includeApiPathLabel: boolean = false; + + /** Whether to include a label for the HTTP method of API endpoint calls. */ + @Env('N8N_METRICS_INCLUDE_API_METHOD_LABEL') + readonly includeApiMethodLabel: boolean = false; + + /** Whether to include a label for the status code of API endpoint calls. */ + @Env('N8N_METRICS_INCLUDE_API_STATUS_CODE_LABEL') + readonly includeApiStatusCodeLabel: boolean = false; + + /** Whether to include metrics for cache hits and misses. */ + @Env('N8N_METRICS_INCLUDE_CACHE_METRICS') + readonly includeCacheMetrics: boolean = false; + + /** Whether to include metrics derived from n8n's internal events */ + @Env('N8N_METRICS_INCLUDE_MESSAGE_EVENT_BUS_METRICS') + readonly includeMessageEventBusMetrics: boolean = false; +} + +@Config +export class EndpointsConfig { + /** Max payload size in MiB */ + @Env('N8N_PAYLOAD_SIZE_MAX') + readonly payloadSizeMax: number = 16; + + @Nested + readonly metrics: PrometheusMetricsConfig; + + /** Path segment for REST API endpoints. */ + @Env('N8N_ENDPOINT_REST') + readonly rest: string = 'rest'; + + /** Path segment for form endpoints. */ + @Env('N8N_ENDPOINT_FORM') + readonly form: string = 'form'; + + /** Path segment for test form endpoints. */ + @Env('N8N_ENDPOINT_FORM_TEST') + readonly formTest: string = 'form-test'; + + /** Path segment for waiting form endpoints. */ + @Env('N8N_ENDPOINT_FORM_WAIT') + readonly formWaiting: string = 'form-waiting'; + + /** Path segment for webhook endpoints. */ + @Env('N8N_ENDPOINT_WEBHOOK') + readonly webhook: string = 'webhook'; + + /** Path segment for test webhook endpoints. */ + @Env('N8N_ENDPOINT_WEBHOOK_TEST') + readonly webhookTest: string = 'webhook-test'; + + /** Path segment for waiting webhook endpoints. */ + @Env('N8N_ENDPOINT_WEBHOOK_WAIT') + readonly webhookWaiting: string = 'webhook-waiting'; + + /** Whether to disable n8n's UI (frontend). */ + @Env('N8N_DISABLE_UI') + readonly disableUi: boolean = false; + + /** Whether to disable production webhooks on the main process, when using webhook-specific processes. */ + @Env('N8N_DISABLE_PRODUCTION_MAIN_PROCESS') + readonly disableProductionWebhooksOnMainProcess: boolean = false; + + /** Colon-delimited list of additional endpoints to not open the UI on. */ + @Env('N8N_ADDITIONAL_NON_UI_ROUTES') + readonly additionalNonUIRoutes: string = ''; +} diff --git a/packages/@n8n/config/src/index.ts b/packages/@n8n/config/src/index.ts index fee6718c6acdb..d7bb09889d5ab 100644 --- a/packages/@n8n/config/src/index.ts +++ b/packages/@n8n/config/src/index.ts @@ -10,6 +10,7 @@ import { EventBusConfig } from './configs/event-bus'; import { NodesConfig } from './configs/nodes'; import { ExternalStorageConfig } from './configs/external-storage'; import { WorkflowsConfig } from './configs/workflows'; +import { EndpointsConfig } from './configs/endpoints'; @Config class UserManagementConfig { @@ -71,4 +72,7 @@ export class GlobalConfig { /** HTTP Protocol via which n8n can be reached */ @Env('N8N_PROTOCOL') readonly protocol: 'http' | 'https' = 'http'; + + @Nested + readonly endpoints: EndpointsConfig; } diff --git a/packages/@n8n/config/test/config.test.ts b/packages/@n8n/config/test/config.test.ts index 97afb0cda405e..a36a74d1e25c7 100644 --- a/packages/@n8n/config/test/config.test.ts +++ b/packages/@n8n/config/test/config.test.ts @@ -145,12 +145,44 @@ describe('GlobalConfig', () => { onboardingFlowDisabled: false, callerPolicyDefaultOption: 'workflowsFromSameOwner', }, + endpoints: { + metrics: { + enable: false, + prefix: 'n8n_', + includeWorkflowIdLabel: false, + includeDefaultMetrics: true, + includeMessageEventBusMetrics: false, + includeNodeTypeLabel: false, + includeCacheMetrics: false, + includeApiEndpoints: false, + includeApiPathLabel: false, + includeApiMethodLabel: false, + includeCredentialTypeLabel: false, + includeApiStatusCodeLabel: false, + }, + additionalNonUIRoutes: '', + disableProductionWebhooksOnMainProcess: false, + disableUi: false, + form: 'form', + formTest: 'form-test', + formWaiting: 'form-waiting', + payloadSizeMax: 16, + rest: 'rest', + webhook: 'webhook', + webhookTest: 'webhook-test', + webhookWaiting: 'webhook-waiting', + }, }; it('should use all default values when no env variables are defined', () => { process.env = {}; const config = Container.get(GlobalConfig); - expect(config).toEqual(defaultConfig); + + // deepCopy for diff to show plain objects + // eslint-disable-next-line n8n-local-rules/no-json-parse-json-stringify + const deepCopy = (obj: T): T => JSON.parse(JSON.stringify(obj)); + + expect(deepCopy(config)).toEqual(defaultConfig); expect(mockFs.readFileSync).not.toHaveBeenCalled(); }); diff --git a/packages/@n8n/nodes-langchain/package.json b/packages/@n8n/nodes-langchain/package.json index a52670e3e3faf..3a328af0ae880 100644 --- a/packages/@n8n/nodes-langchain/package.json +++ b/packages/@n8n/nodes-langchain/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/n8n-nodes-langchain", - "version": "1.52.0", + "version": "1.53.0", "description": "", "main": "index.js", "scripts": { @@ -153,7 +153,7 @@ "@langchain/textsplitters": "0.0.3", "@mozilla/readability": "^0.5.0", "@n8n/typeorm": "0.3.20-10", - "@n8n/vm2": "3.9.20", + "@n8n/vm2": "3.9.24", "@pinecone-database/pinecone": "3.0.0", "@qdrant/js-client-rest": "1.9.0", "@supabase/supabase-js": "2.43.4", diff --git a/packages/cli/package.json b/packages/cli/package.json index 02e6f17f5187b..0da569f1b086b 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "n8n", - "version": "1.52.0", + "version": "1.53.0", "description": "n8n Workflow Automation Tool", "main": "dist/index", "types": "dist/index.d.ts", diff --git a/packages/cli/src/AbstractServer.ts b/packages/cli/src/AbstractServer.ts index 61b3decd20ba1..ebbf2cd6033e2 100644 --- a/packages/cli/src/AbstractServer.ts +++ b/packages/cli/src/AbstractServer.ts @@ -34,7 +34,7 @@ export abstract class AbstractServer { protected externalHooks: ExternalHooks; - protected protocol = Container.get(GlobalConfig).protocol; + protected globalConfig = Container.get(GlobalConfig); protected sslKey: string; @@ -74,15 +74,15 @@ export abstract class AbstractServer { this.sslKey = config.getEnv('ssl_key'); this.sslCert = config.getEnv('ssl_cert'); - this.restEndpoint = config.getEnv('endpoints.rest'); + this.restEndpoint = this.globalConfig.endpoints.rest; - this.endpointForm = config.getEnv('endpoints.form'); - this.endpointFormTest = config.getEnv('endpoints.formTest'); - this.endpointFormWaiting = config.getEnv('endpoints.formWaiting'); + this.endpointForm = this.globalConfig.endpoints.form; + this.endpointFormTest = this.globalConfig.endpoints.formTest; + this.endpointFormWaiting = this.globalConfig.endpoints.formWaiting; - this.endpointWebhook = config.getEnv('endpoints.webhook'); - this.endpointWebhookTest = config.getEnv('endpoints.webhookTest'); - this.endpointWebhookWaiting = config.getEnv('endpoints.webhookWaiting'); + this.endpointWebhook = this.globalConfig.endpoints.webhook; + this.endpointWebhookTest = this.globalConfig.endpoints.webhookTest; + this.endpointWebhookWaiting = this.globalConfig.endpoints.webhookWaiting; this.uniqueInstanceId = generateHostInstanceId(instanceType); @@ -138,7 +138,8 @@ export abstract class AbstractServer { } async init(): Promise { - const { app, protocol, sslKey, sslCert } = this; + const { app, sslKey, sslCert } = this; + const { protocol } = this.globalConfig; if (protocol === 'https' && sslKey && sslCert) { const https = await import('https'); @@ -268,14 +269,16 @@ export abstract class AbstractServer { return; } - this.logger.debug(`Shutting down ${this.protocol} server`); + const { protocol } = this.globalConfig; + + this.logger.debug(`Shutting down ${protocol} server`); this.server.close((error) => { if (error) { - this.logger.error(`Error while shutting down ${this.protocol} server`, { error }); + this.logger.error(`Error while shutting down ${protocol} server`, { error }); } - this.logger.debug(`${this.protocol} server shut down`); + this.logger.debug(`${protocol} server shut down`); }); } } diff --git a/packages/cli/src/InternalHooks.ts b/packages/cli/src/InternalHooks.ts index 7912054085824..fda2c3f21dfc7 100644 --- a/packages/cli/src/InternalHooks.ts +++ b/packages/cli/src/InternalHooks.ts @@ -10,22 +10,15 @@ import type { } from 'n8n-workflow'; import { TelemetryHelpers } from 'n8n-workflow'; -import config from '@/config'; import { N8N_VERSION } from '@/constants'; import type { AuthProviderType } from '@db/entities/AuthIdentity'; import type { User } from '@db/entities/User'; import { SharedWorkflowRepository } from '@db/repositories/sharedWorkflow.repository'; import { determineFinalExecutionStatus } from '@/executionLifecycleHooks/shared/sharedHookFunctions'; -import type { - ITelemetryUserDeletionData, - IWorkflowDb, - IExecutionTrackProperties, -} from '@/Interfaces'; +import type { ITelemetryUserDeletionData, IExecutionTrackProperties } from '@/Interfaces'; import { WorkflowStatisticsService } from '@/services/workflow-statistics.service'; import { NodeTypes } from '@/NodeTypes'; import { Telemetry } from '@/telemetry'; -import type { Project } from '@db/entities/Project'; -import { ProjectRelationRepository } from './databases/repositories/projectRelation.repository'; import { MessageEventBus } from './eventbus/MessageEventBus/MessageEventBus'; /** @@ -40,7 +33,6 @@ export class InternalHooks { private readonly nodeTypes: NodeTypes, private readonly sharedWorkflowRepository: SharedWorkflowRepository, workflowStatisticsService: WorkflowStatisticsService, - private readonly projectRelationRepository: ProjectRelationRepository, // Can't use @ts-expect-error because only dev time tsconfig considers this as an error, but not build time // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore - needed until we decouple telemetry @@ -72,78 +64,6 @@ export class InternalHooks { this.telemetry.track('User responded to personalization questions', personalizationSurveyData); } - onWorkflowCreated( - user: User, - workflow: IWorkflowBase, - project: Project, - publicApi: boolean, - ): void { - const { nodeGraph } = TelemetryHelpers.generateNodesGraph(workflow, this.nodeTypes); - - this.telemetry.track('User created workflow', { - user_id: user.id, - workflow_id: workflow.id, - node_graph_string: JSON.stringify(nodeGraph), - public_api: publicApi, - project_id: project.id, - project_type: project.type, - }); - } - - onWorkflowDeleted(user: User, workflowId: string, publicApi: boolean): void { - this.telemetry.track('User deleted workflow', { - user_id: user.id, - workflow_id: workflowId, - public_api: publicApi, - }); - } - - async onWorkflowSaved(user: User, workflow: IWorkflowDb, publicApi: boolean): Promise { - const isCloudDeployment = config.getEnv('deployment.type') === 'cloud'; - - const { nodeGraph } = TelemetryHelpers.generateNodesGraph(workflow, this.nodeTypes, { - isCloudDeployment, - }); - - let userRole: 'owner' | 'sharee' | 'member' | undefined = undefined; - const role = await this.sharedWorkflowRepository.findSharingRole(user.id, workflow.id); - if (role) { - userRole = role === 'workflow:owner' ? 'owner' : 'sharee'; - } else { - const workflowOwner = await this.sharedWorkflowRepository.getWorkflowOwningProject( - workflow.id, - ); - - if (workflowOwner) { - const projectRole = await this.projectRelationRepository.findProjectRole({ - userId: user.id, - projectId: workflowOwner.id, - }); - - if (projectRole && projectRole !== 'project:personalOwner') { - userRole = 'member'; - } - } - } - - const notesCount = Object.keys(nodeGraph.notes).length; - const overlappingCount = Object.values(nodeGraph.notes).filter( - (note) => note.overlapping, - ).length; - - this.telemetry.track('User saved workflow', { - user_id: user.id, - workflow_id: workflow.id, - node_graph_string: JSON.stringify(nodeGraph), - notes_count_overlapping: overlappingCount, - notes_count_non_overlapping: notesCount - overlappingCount, - version_cli: N8N_VERSION, - num_tags: workflow.tags?.length ?? 0, - public_api: publicApi, - sharing_role: userRole, - }); - } - // eslint-disable-next-line complexity async onWorkflowPostExecute( _executionId: string, 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 ea5eebdfdbe76..4063d0b611d35 100644 --- a/packages/cli/src/PublicApi/v1/handlers/workflows/workflows.handler.ts +++ b/packages/cli/src/PublicApi/v1/handlers/workflows/workflows.handler.ts @@ -60,10 +60,12 @@ export = { ); await Container.get(ExternalHooks).run('workflow.afterCreate', [createdWorkflow]); - Container.get(InternalHooks).onWorkflowCreated(req.user, createdWorkflow, project, true); Container.get(EventService).emit('workflow-created', { workflow: createdWorkflow, user: req.user, + publicApi: true, + projectId: project.id, + projectType: project.type, }); return res.json(createdWorkflow); @@ -259,11 +261,10 @@ export = { } await Container.get(ExternalHooks).run('workflow.afterUpdate', [updateData]); - void Container.get(InternalHooks).onWorkflowSaved(req.user, updateData, true); Container.get(EventService).emit('workflow-saved', { user: req.user, - workflowId: updateData.id, - workflowName: updateData.name, + workflow: updateData, + publicApi: true, }); return res.json(updatedWorkflow); diff --git a/packages/cli/src/Server.ts b/packages/cli/src/Server.ts index 8a58684a28dd1..e745b03df16ce 100644 --- a/packages/cli/src/Server.ts +++ b/packages/cli/src/Server.ts @@ -6,7 +6,6 @@ import { promisify } from 'util'; import cookieParser from 'cookie-parser'; import express from 'express'; import helmet from 'helmet'; -import { GlobalConfig } from '@n8n/config'; import { InstanceSettings } from 'n8n-core'; import type { IN8nUISettings } from 'n8n-workflow'; @@ -84,17 +83,16 @@ export class Server extends AbstractServer { private readonly loadNodesAndCredentials: LoadNodesAndCredentials, private readonly orchestrationService: OrchestrationService, private readonly postHogClient: PostHogClient, - private readonly globalConfig: GlobalConfig, private readonly eventService: EventService, ) { super('main'); this.testWebhooksEnabled = true; - this.webhooksEnabled = !config.getEnv('endpoints.disableProductionWebhooksOnMainProcess'); + this.webhooksEnabled = !this.globalConfig.endpoints.disableProductionWebhooksOnMainProcess; } async start() { - if (!config.getEnv('endpoints.disableUi')) { + if (!this.globalConfig.endpoints.disableUi) { const { FrontendService } = await import('@/services/frontend.service'); this.frontendService = Container.get(FrontendService); } @@ -136,7 +134,7 @@ export class Server extends AbstractServer { await import('@/controllers/mfa.controller'); } - if (!config.getEnv('endpoints.disableUi')) { + if (!this.globalConfig.endpoints.disableUi) { await import('@/controllers/cta.controller'); } @@ -170,7 +168,7 @@ export class Server extends AbstractServer { } async configure(): Promise { - if (config.getEnv('endpoints.metrics.enable')) { + if (this.globalConfig.endpoints.metrics.enable) { const { PrometheusMetricsService } = await import('@/metrics/prometheus-metrics.service'); await Container.get(PrometheusMetricsService).init(this.app); } @@ -310,7 +308,8 @@ export class Server extends AbstractServer { this.app.use('/icons/@:scope/:packageName/*/*.(svg|png)', serveIcons); this.app.use('/icons/:packageName/*/*.(svg|png)', serveIcons); - const isTLSEnabled = this.protocol === 'https' && !!(this.sslKey && this.sslCert); + const isTLSEnabled = + this.globalConfig.protocol === 'https' && !!(this.sslKey && this.sslCert); const isPreviewMode = process.env.N8N_PREVIEW_MODE === 'true'; const securityHeadersMiddleware = helmet({ contentSecurityPolicy: false, @@ -344,7 +343,7 @@ export class Server extends AbstractServer { this.restEndpoint, this.endpointPresetCredentials, isApiEnabled() ? '' : publicApiEndpoint, - ...config.getEnv('endpoints.additionalNonUIRoutes').split(':'), + ...this.globalConfig.endpoints.additionalNonUIRoutes.split(':'), ].filter((u) => !!u); const nonUIRoutesRegex = new RegExp(`^/(${nonUIRoutes.join('|')})/?.*$`); const historyApiHandler: express.RequestHandler = (req, res, next) => { diff --git a/packages/cli/src/WebhookHelpers.ts b/packages/cli/src/WebhookHelpers.ts index 9342a97cda69c..eafec28133326 100644 --- a/packages/cli/src/WebhookHelpers.ts +++ b/packages/cli/src/WebhookHelpers.ts @@ -256,6 +256,10 @@ export async function executeWebhook( // Prepare everything that is needed to run the workflow const additionalData = await WorkflowExecuteAdditionalData.getBase(); + if (executionId) { + additionalData.executionId = executionId; + } + // Get the responseMode const responseMode = workflow.expression.getSimpleParameterValue( workflowStartNode, @@ -359,6 +363,7 @@ export async function executeWebhook( additionalData, NodeExecuteFunctions, executionMode, + runExecutionData ?? null, ); Container.get(WorkflowStatisticsService).emit('nodeFetchedData', { workflowId: workflow.id, diff --git a/packages/cli/src/WorkflowExecuteAdditionalData.ts b/packages/cli/src/WorkflowExecuteAdditionalData.ts index 754cfea693c3d..f4acd61b6aa55 100644 --- a/packages/cli/src/WorkflowExecuteAdditionalData.ts +++ b/packages/cli/src/WorkflowExecuteAdditionalData.ts @@ -651,6 +651,7 @@ function hookFunctionsSaveWorker(): IWorkflowExecuteHooks { executionId, success: runData.status === 'success', isManual: runData.mode === 'manual', + runData, }); }, async function (this: WorkflowHooks, fullRunData: IRun) { @@ -940,6 +941,7 @@ async function executeWorkflow( success: data.status === 'success', isManual: data.mode === 'manual', userId: additionalData.userId, + runData: data, }); // subworkflow either finished, or is in status waiting due to a wait node, both cases are considered successes here @@ -1000,23 +1002,19 @@ export async function getBase( ): Promise { const urlBaseWebhook = Container.get(UrlService).getWebhookBaseUrl(); - const formWaitingBaseUrl = urlBaseWebhook + config.getEnv('endpoints.formWaiting'); - - const webhookBaseUrl = urlBaseWebhook + config.getEnv('endpoints.webhook'); - const webhookTestBaseUrl = urlBaseWebhook + config.getEnv('endpoints.webhookTest'); - const webhookWaitingBaseUrl = urlBaseWebhook + config.getEnv('endpoints.webhookWaiting'); + const globalConfig = Container.get(GlobalConfig); const variables = await WorkflowHelpers.getVariables(); return { credentialsHelper: Container.get(CredentialsHelper), executeWorkflow, - restApiUrl: urlBaseWebhook + config.getEnv('endpoints.rest'), + restApiUrl: urlBaseWebhook + globalConfig.endpoints.rest, instanceBaseUrl: urlBaseWebhook, - formWaitingBaseUrl, - webhookBaseUrl, - webhookWaitingBaseUrl, - webhookTestBaseUrl, + formWaitingBaseUrl: globalConfig.endpoints.formWaiting, + webhookBaseUrl: globalConfig.endpoints.webhook, + webhookWaitingBaseUrl: globalConfig.endpoints.webhookWaiting, + webhookTestBaseUrl: globalConfig.endpoints.webhookTest, currentNodeParameters, executionTimeoutTimestamp, userId, diff --git a/packages/cli/src/WorkflowRunner.ts b/packages/cli/src/WorkflowRunner.ts index f8faf55fbe49d..3318dd283cd60 100644 --- a/packages/cli/src/WorkflowRunner.ts +++ b/packages/cli/src/WorkflowRunner.ts @@ -173,6 +173,7 @@ export class WorkflowRunner { success: executionData?.status === 'success', isManual: data.executionMode === 'manual', userId: data.userId, + runData: executionData, }); if (this.externalHooks.exists('workflow.postExecute')) { try { diff --git a/packages/cli/src/auth/auth.service.ts b/packages/cli/src/auth/auth.service.ts index ccf562e27e050..e2487e6f6f9a6 100644 --- a/packages/cli/src/auth/auth.service.ts +++ b/packages/cli/src/auth/auth.service.ts @@ -1,4 +1,4 @@ -import { Service } from 'typedi'; +import Container, { Service } from 'typedi'; import type { NextFunction, Response } from 'express'; import { createHash } from 'crypto'; import { JsonWebTokenError, TokenExpiredError } from 'jsonwebtoken'; @@ -14,6 +14,7 @@ import { Logger } from '@/Logger'; import type { AuthenticatedRequest } from '@/requests'; import { JwtService } from '@/services/jwt.service'; import { UrlService } from '@/services/url.service'; +import { GlobalConfig } from '@n8n/config'; interface AuthJwtPayload { /** User Id */ @@ -33,7 +34,7 @@ interface PasswordResetToken { hash: string; } -const restEndpoint = config.get('endpoints.rest'); +const restEndpoint = Container.get(GlobalConfig).endpoints.rest; // The browser-id check needs to be skipped on these endpoints const skipBrowserIdCheckEndpoints = [ // we need to exclude push endpoint because we can't send custom header on websocket requests @@ -42,10 +43,6 @@ const skipBrowserIdCheckEndpoints = [ // We need to exclude binary-data downloading endpoint because we can't send custom headers on `` tags `/${restEndpoint}/binary-data/`, - - // oAuth callback urls aren't called by the frontend. therefore we can't send custom header on these requests - `/${restEndpoint}/oauth1-credential/callback`, - `/${restEndpoint}/oauth2-credential/callback`, ]; @Service() diff --git a/packages/cli/src/commands/BaseCommand.ts b/packages/cli/src/commands/BaseCommand.ts index b00d314b91212..4cebc7fbb66f6 100644 --- a/packages/cli/src/commands/BaseCommand.ts +++ b/packages/cli/src/commands/BaseCommand.ts @@ -44,7 +44,7 @@ export abstract class BaseCommand extends Command { protected license: License; - private globalConfig = Container.get(GlobalConfig); + protected globalConfig = Container.get(GlobalConfig); /** * How long to wait for graceful shutdown before force killing the process. diff --git a/packages/cli/src/commands/start.ts b/packages/cli/src/commands/start.ts index c35344326e8e7..4d7cd888b4cc0 100644 --- a/packages/cli/src/commands/start.ts +++ b/packages/cli/src/commands/start.ts @@ -124,8 +124,8 @@ export class Start extends BaseCommand { private async generateStaticAssets() { // Read the index file and replace the path placeholder - const n8nPath = Container.get(GlobalConfig).path; - const restEndpoint = config.getEnv('endpoints.rest'); + const n8nPath = this.globalConfig.path; + const hooksUrls = config.getEnv('externalFrontendHooksUrls'); let scriptsString = ''; @@ -151,7 +151,9 @@ export class Start extends BaseCommand { ]; if (filePath.endsWith('index.html')) { streams.push( - replaceStream('{{REST_ENDPOINT}}', restEndpoint, { ignoreCase: false }), + replaceStream('{{REST_ENDPOINT}}', this.globalConfig.endpoints.rest, { + ignoreCase: false, + }), replaceStream(closingTitleTag, closingTitleTag + scriptsString, { ignoreCase: false, }), @@ -201,7 +203,7 @@ export class Start extends BaseCommand { this.initWorkflowHistory(); this.logger.debug('Workflow history init complete'); - if (!config.getEnv('endpoints.disableUi')) { + if (!this.globalConfig.endpoints.disableUi) { await this.generateStaticAssets(); } } diff --git a/packages/cli/src/config/index.ts b/packages/cli/src/config/index.ts index f743e7961dba5..dedc803839292 100644 --- a/packages/cli/src/config/index.ts +++ b/packages/cli/src/config/index.ts @@ -96,9 +96,10 @@ config.validate({ }); const userManagement = config.get('userManagement'); if (userManagement.jwtRefreshTimeoutHours >= userManagement.jwtSessionDurationHours) { - console.warn( - 'N8N_USER_MANAGEMENT_JWT_REFRESH_TIMEOUT_HOURS needs to smaller than N8N_USER_MANAGEMENT_JWT_DURATION_HOURS. Setting N8N_USER_MANAGEMENT_JWT_REFRESH_TIMEOUT_HOURS to 0 for now.', - ); + if (!inTest) + console.warn( + 'N8N_USER_MANAGEMENT_JWT_REFRESH_TIMEOUT_HOURS needs to smaller than N8N_USER_MANAGEMENT_JWT_DURATION_HOURS. Setting N8N_USER_MANAGEMENT_JWT_REFRESH_TIMEOUT_HOURS to 0 for now.', + ); config.set('userManagement.jwtRefreshTimeoutHours', 0); } diff --git a/packages/cli/src/config/schema.ts b/packages/cli/src/config/schema.ts index 7ba35ca239aa1..b9ef98c26b489 100644 --- a/packages/cli/src/config/schema.ts +++ b/packages/cli/src/config/schema.ts @@ -355,149 +355,6 @@ export const schema = { }, }, - endpoints: { - payloadSizeMax: { - format: Number, - default: 16, - env: 'N8N_PAYLOAD_SIZE_MAX', - doc: 'Maximum payload size in MB.', - }, - metrics: { - enable: { - format: Boolean, - default: false, - env: 'N8N_METRICS', - doc: 'Enable /metrics endpoint. Default: false', - }, - prefix: { - format: String, - default: 'n8n_', - env: 'N8N_METRICS_PREFIX', - doc: 'An optional prefix for metric names. Default: n8n_', - }, - includeDefaultMetrics: { - format: Boolean, - default: true, - env: 'N8N_METRICS_INCLUDE_DEFAULT_METRICS', - doc: 'Whether to expose default system and node.js metrics. Default: true', - }, - includeWorkflowIdLabel: { - format: Boolean, - default: false, - env: 'N8N_METRICS_INCLUDE_WORKFLOW_ID_LABEL', - doc: 'Whether to include a label for the workflow ID on workflow metrics. Default: false', - }, - includeNodeTypeLabel: { - format: Boolean, - default: false, - env: 'N8N_METRICS_INCLUDE_NODE_TYPE_LABEL', - doc: 'Whether to include a label for the node type on node metrics. Default: false', - }, - includeCredentialTypeLabel: { - format: Boolean, - default: false, - env: 'N8N_METRICS_INCLUDE_CREDENTIAL_TYPE_LABEL', - doc: 'Whether to include a label for the credential type on credential metrics. Default: false', - }, - includeApiEndpoints: { - format: Boolean, - default: false, - env: 'N8N_METRICS_INCLUDE_API_ENDPOINTS', - doc: 'Whether to expose metrics for API endpoints. Default: false', - }, - includeApiPathLabel: { - format: Boolean, - default: false, - env: 'N8N_METRICS_INCLUDE_API_PATH_LABEL', - doc: 'Whether to include a label for the path of API invocations. Default: false', - }, - includeApiMethodLabel: { - format: Boolean, - default: false, - env: 'N8N_METRICS_INCLUDE_API_METHOD_LABEL', - doc: 'Whether to include a label for the HTTP method (GET, POST, ...) of API invocations. Default: false', - }, - includeApiStatusCodeLabel: { - format: Boolean, - default: false, - env: 'N8N_METRICS_INCLUDE_API_STATUS_CODE_LABEL', - doc: 'Whether to include a label for the HTTP status code (200, 404, ...) of API invocations. Default: false', - }, - includeCacheMetrics: { - format: Boolean, - default: false, - env: 'N8N_METRICS_INCLUDE_CACHE_METRICS', - doc: 'Whether to include metrics for cache hits and misses. Default: false', - }, - includeMessageEventBusMetrics: { - format: Boolean, - default: true, - env: 'N8N_METRICS_INCLUDE_MESSAGE_EVENT_BUS_METRICS', - doc: 'Whether to include metrics for events. Default: false', - }, - }, - rest: { - format: String, - default: 'rest', - env: 'N8N_ENDPOINT_REST', - doc: 'Path for rest endpoint', - }, - form: { - format: String, - default: 'form', - env: 'N8N_ENDPOINT_FORM', - doc: 'Path for form endpoint', - }, - formTest: { - format: String, - default: 'form-test', - env: 'N8N_ENDPOINT_FORM_TEST', - doc: 'Path for test form endpoint', - }, - formWaiting: { - format: String, - default: 'form-waiting', - env: 'N8N_ENDPOINT_FORM_WAIT', - doc: 'Path for waiting form endpoint', - }, - webhook: { - format: String, - default: 'webhook', - env: 'N8N_ENDPOINT_WEBHOOK', - doc: 'Path for webhook endpoint', - }, - webhookWaiting: { - format: String, - default: 'webhook-waiting', - env: 'N8N_ENDPOINT_WEBHOOK_WAIT', - doc: 'Path for waiting-webhook endpoint', - }, - webhookTest: { - format: String, - default: 'webhook-test', - env: 'N8N_ENDPOINT_WEBHOOK_TEST', - doc: 'Path for test-webhook endpoint', - }, - disableUi: { - format: Boolean, - default: false, - env: 'N8N_DISABLE_UI', - doc: 'Disable N8N UI (Frontend).', - }, - disableProductionWebhooksOnMainProcess: { - format: Boolean, - default: false, - env: 'N8N_DISABLE_PRODUCTION_MAIN_PROCESS', - doc: 'Disable production webhooks from main process. This helps ensures no http traffic load to main process when using webhook-specific processes.', - }, - additionalNonUIRoutes: { - doc: 'Additional endpoints to not open the UI on. Multiple endpoints can be separated by colon (":")', - format: String, - default: '', - env: 'N8N_ADDITIONAL_NON_UI_ROUTES', - }, - }, - workflowTagsDisabled: { format: Boolean, default: false, diff --git a/packages/cli/src/controllers/oauth/abstractOAuth.controller.ts b/packages/cli/src/controllers/oauth/abstractOAuth.controller.ts index 9454b4ee7d1ce..b9f0d64446ad5 100644 --- a/packages/cli/src/controllers/oauth/abstractOAuth.controller.ts +++ b/packages/cli/src/controllers/oauth/abstractOAuth.controller.ts @@ -5,9 +5,7 @@ import { Credentials } from 'n8n-core'; import type { ICredentialDataDecryptedObject, IWorkflowExecuteAdditionalData } from 'n8n-workflow'; import { jsonParse, ApplicationError } from 'n8n-workflow'; -import config from '@/config'; import type { CredentialsEntity } from '@db/entities/CredentialsEntity'; -import type { User } from '@db/entities/User'; import { CredentialsRepository } from '@db/repositories/credentials.repository'; import { SharedCredentialsRepository } from '@db/repositories/sharedCredentials.repository'; import type { ICredentialsDb } from '@/Interfaces'; @@ -20,6 +18,7 @@ import { ExternalHooks } from '@/ExternalHooks'; import { UrlService } from '@/services/url.service'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { GlobalConfig } from '@n8n/config'; export interface CsrfStateParam { cid: string; @@ -37,10 +36,11 @@ export abstract class AbstractOAuthController { private readonly credentialsRepository: CredentialsRepository, private readonly sharedCredentialsRepository: SharedCredentialsRepository, private readonly urlService: UrlService, + private readonly globalConfig: GlobalConfig, ) {} get baseUrl() { - const restUrl = `${this.urlService.getInstanceBaseUrl()}/${config.getEnv('endpoints.rest')}`; + const restUrl = `${this.urlService.getInstanceBaseUrl()}/${this.globalConfig.endpoints.rest}`; return `${restUrl}/oauth${this.oauthVersion}-credential`; } @@ -70,8 +70,8 @@ export abstract class AbstractOAuthController { return credential; } - protected async getAdditionalData(user: User) { - return await WorkflowExecuteAdditionalData.getBase(user.id); + protected async getAdditionalData() { + return await WorkflowExecuteAdditionalData.getBase(); } protected async getDecryptedData( @@ -118,7 +118,7 @@ export abstract class AbstractOAuthController { return await this.credentialsRepository.findOneBy({ id: credentialId }); } - protected createCsrfState(credentialsId: string): [string, string] { + createCsrfState(credentialsId: string): [string, string] { const token = new Csrf(); const csrfSecret = token.secretSync(); const state: CsrfStateParam = { diff --git a/packages/cli/src/controllers/oauth/oAuth1Credential.controller.ts b/packages/cli/src/controllers/oauth/oAuth1Credential.controller.ts index 578a209e3664f..2a50b00bf9d57 100644 --- a/packages/cli/src/controllers/oauth/oAuth1Credential.controller.ts +++ b/packages/cli/src/controllers/oauth/oAuth1Credential.controller.ts @@ -33,7 +33,7 @@ export class OAuth1CredentialController extends AbstractOAuthController { @Get('/auth') async getAuthUri(req: OAuthRequest.OAuth1Credential.Auth): Promise { const credential = await this.getCredential(req); - const additionalData = await this.getAdditionalData(req.user); + const additionalData = await this.getAdditionalData(); const decryptedDataOriginal = await this.getDecryptedData(credential, additionalData); const oauthCredentials = this.applyDefaultsAndOverwrites( credential, @@ -99,9 +99,8 @@ export class OAuth1CredentialController extends AbstractOAuthController { } /** Verify and store app code. Generate access tokens and store for respective credential */ - @Get('/callback', { usesTemplates: true }) + @Get('/callback', { usesTemplates: true, skipAuth: true }) async handleCallback(req: OAuthRequest.OAuth1Credential.Callback, res: Response) { - const userId = req.user?.id; try { const { oauth_verifier, oauth_token, state: encodedState } = req.query; @@ -124,11 +123,11 @@ export class OAuth1CredentialController extends AbstractOAuthController { const credential = await this.getCredentialWithoutUser(credentialId); if (!credential) { const errorMessage = 'OAuth1 callback failed because of insufficient permissions'; - this.logger.error(errorMessage, { userId, credentialId }); + this.logger.error(errorMessage, { credentialId }); return this.renderCallbackError(res, errorMessage); } - const additionalData = await this.getAdditionalData(req.user); + const additionalData = await this.getAdditionalData(); const decryptedDataOriginal = await this.getDecryptedData(credential, additionalData); const oauthCredentials = this.applyDefaultsAndOverwrites( credential, @@ -138,7 +137,7 @@ export class OAuth1CredentialController extends AbstractOAuthController { if (this.verifyCsrfState(decryptedDataOriginal, state)) { const errorMessage = 'The OAuth1 callback state is invalid!'; - this.logger.debug(errorMessage, { userId, credentialId }); + this.logger.debug(errorMessage, { credentialId }); return this.renderCallbackError(res, errorMessage); } @@ -156,7 +155,7 @@ export class OAuth1CredentialController extends AbstractOAuthController { try { oauthToken = await axios.request(options); } catch (error) { - this.logger.error('Unable to fetch tokens for OAuth1 callback', { userId, credentialId }); + this.logger.error('Unable to fetch tokens for OAuth1 callback', { credentialId }); const errorResponse = new NotFoundError('Unable to get access tokens!'); return sendErrorResponse(res, errorResponse); } @@ -172,15 +171,11 @@ export class OAuth1CredentialController extends AbstractOAuthController { await this.encryptAndSaveData(credential, decryptedDataOriginal); this.logger.verbose('OAuth1 callback successful for new credential', { - userId, credentialId, }); return res.render('oauth-callback'); } catch (error) { - this.logger.error('OAuth1 callback failed because of insufficient user permissions', { - userId, - }); - // Error response + this.logger.error('OAuth1 callback failed because of insufficient user permissions'); return sendErrorResponse(res, error as Error); } } diff --git a/packages/cli/src/controllers/oauth/oAuth2Credential.controller.ts b/packages/cli/src/controllers/oauth/oAuth2Credential.controller.ts index 71a0fe140c4c1..5b7929495fd42 100644 --- a/packages/cli/src/controllers/oauth/oAuth2Credential.controller.ts +++ b/packages/cli/src/controllers/oauth/oAuth2Credential.controller.ts @@ -20,7 +20,7 @@ export class OAuth2CredentialController extends AbstractOAuthController { @Get('/auth') async getAuthUri(req: OAuthRequest.OAuth2Credential.Auth): Promise { const credential = await this.getCredential(req); - const additionalData = await this.getAdditionalData(req.user); + const additionalData = await this.getAdditionalData(); const decryptedDataOriginal = await this.getDecryptedData(credential, additionalData); // At some point in the past we saved hidden scopes to credentials (but shouldn't) @@ -80,9 +80,8 @@ export class OAuth2CredentialController extends AbstractOAuthController { } /** Verify and store app code. Generate access tokens and store for respective credential */ - @Get('/callback', { usesTemplates: true }) + @Get('/callback', { usesTemplates: true, skipAuth: true }) async handleCallback(req: OAuthRequest.OAuth2Credential.Callback, res: Response) { - const userId = req.user?.id; try { const { code, state: encodedState } = req.query; if (!code || !encodedState) { @@ -104,11 +103,11 @@ export class OAuth2CredentialController extends AbstractOAuthController { const credential = await this.getCredentialWithoutUser(credentialId); if (!credential) { const errorMessage = 'OAuth2 callback failed because of insufficient permissions'; - this.logger.error(errorMessage, { userId, credentialId }); + this.logger.error(errorMessage, { credentialId }); return this.renderCallbackError(res, errorMessage); } - const additionalData = await this.getAdditionalData(req.user); + const additionalData = await this.getAdditionalData(); const decryptedDataOriginal = await this.getDecryptedData(credential, additionalData); const oauthCredentials = this.applyDefaultsAndOverwrites( credential, @@ -118,7 +117,7 @@ export class OAuth2CredentialController extends AbstractOAuthController { if (this.verifyCsrfState(decryptedDataOriginal, state)) { const errorMessage = 'The OAuth2 callback state is invalid!'; - this.logger.debug(errorMessage, { userId, credentialId }); + this.logger.debug(errorMessage, { credentialId }); return this.renderCallbackError(res, errorMessage); } @@ -157,7 +156,7 @@ export class OAuth2CredentialController extends AbstractOAuthController { if (oauthToken === undefined) { const errorMessage = 'Unable to get OAuth2 access tokens!'; - this.logger.error(errorMessage, { userId, credentialId }); + this.logger.error(errorMessage, { credentialId }); return this.renderCallbackError(res, errorMessage); } @@ -174,7 +173,6 @@ export class OAuth2CredentialController extends AbstractOAuthController { await this.encryptAndSaveData(credential, decryptedDataOriginal); this.logger.verbose('OAuth2 callback successful for credential', { - userId, credentialId, }); diff --git a/packages/cli/src/decorators/controller.registry.ts b/packages/cli/src/decorators/controller.registry.ts index c012922c16728..4173f9309ef3c 100644 --- a/packages/cli/src/decorators/controller.registry.ts +++ b/packages/cli/src/decorators/controller.registry.ts @@ -4,7 +4,6 @@ import type { Application, Request, Response, RequestHandler } from 'express'; import { rateLimit as expressRateLimit } from 'express-rate-limit'; import { AuthService } from '@/auth/auth.service'; -import config from '@/config'; import { UnauthenticatedError } from '@/errors/response-errors/unauthenticated.error'; import { inProduction, RESPONSE_ERROR_MESSAGES } from '@/constants'; import type { BooleanLicenseFeature } from '@/Interfaces'; @@ -12,7 +11,7 @@ import { License } from '@/License'; import type { AuthenticatedRequest } from '@/requests'; import { send } from '@/ResponseHelper'; // TODO: move `ResponseHelper.send` to this file import { userHasScope } from '@/permissions/checkAccess'; - +import { GlobalConfig } from '@n8n/config'; import type { AccessScope, Controller, @@ -52,6 +51,7 @@ export class ControllerRegistry { constructor( private readonly license: License, private readonly authService: AuthService, + private readonly globalConfig: GlobalConfig, ) {} activate(app: Application) { @@ -64,7 +64,7 @@ export class ControllerRegistry { const metadata = registry.get(controllerClass)!; const router = Router({ mergeParams: true }); - const prefix = `/${config.getEnv('endpoints.rest')}/${metadata.basePath}` + const prefix = `/${this.globalConfig.endpoints.rest}/${metadata.basePath}` .replace(/\/+/g, '/') .replace(/\/$/, ''); app.use(prefix, router); diff --git a/packages/cli/src/eventbus/__tests__/audit-event-relay.service.test.ts b/packages/cli/src/eventbus/__tests__/audit-event-relay.service.test.ts index 80392206079b7..52b86b58e1b6b 100644 --- a/packages/cli/src/eventbus/__tests__/audit-event-relay.service.test.ts +++ b/packages/cli/src/eventbus/__tests__/audit-event-relay.service.test.ts @@ -2,82 +2,662 @@ 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 type { EventService } from '../event.service'; +import { EventService } from '../event.service'; +import type { INode, IRun, IWorkflowBase } from 'n8n-workflow'; +import type { IWorkflowDb } from '@/Interfaces'; -describe('AuditorService', () => { +describe('AuditEventRelay', () => { const eventBus = mock(); - const eventService = mock(); + const eventService = new EventService(); const auditor = new AuditEventRelay(eventService, eventBus); + auditor.init(); afterEach(() => { jest.clearAllMocks(); }); - it('should handle `user-deleted` event', () => { - const arg: Event['user-deleted'] = { - user: { - id: '123', - email: 'john@n8n.io', - firstName: 'John', - lastName: 'Doe', - role: 'some-role', - }, - }; - - // @ts-expect-error Private method - auditor.userDeleted(arg); - - expect(eventBus.sendAuditEvent).toHaveBeenCalledWith({ - eventName: 'n8n.audit.user.deleted', - payload: { - userId: '123', - _email: 'john@n8n.io', - _firstName: 'John', - _lastName: 'Doe', - globalRole: 'some-role', - }, - }); - }); + describe('workflow events', () => { + it('should log on `workflow-created` event', () => { + const event: Event['workflow-created'] = { + user: { + id: '123', + email: 'john@n8n.io', + firstName: 'John', + lastName: 'Doe', + role: 'owner', + }, + workflow: mock({ + id: 'wf123', + name: 'Test Workflow', + }), + publicApi: false, + projectId: 'proj123', + projectType: 'personal', + }; - it('should handle `user-invite-email-click` event', () => { - const arg: Event['user-invite-email-click'] = { - inviter: { - id: '123', - email: 'john@n8n.io', - firstName: 'John', - lastName: 'Doe', - role: 'some-role', - }, - invitee: { - id: '456', - email: 'jane@n8n.io', - firstName: 'Jane', - lastName: 'Doe', - role: 'some-other-role', - }, - }; - - // @ts-expect-error Private method - auditor.userInviteEmailClick(arg); - - expect(eventBus.sendAuditEvent).toHaveBeenCalledWith({ - eventName: 'n8n.audit.user.invitation.accepted', - payload: { - inviter: { + eventService.emit('workflow-created', event); + + expect(eventBus.sendAuditEvent).toHaveBeenCalledWith({ + eventName: 'n8n.audit.workflow.created', + payload: { userId: '123', _email: 'john@n8n.io', _firstName: 'John', _lastName: 'Doe', - globalRole: 'some-role', + globalRole: 'owner', + workflowId: 'wf123', + workflowName: 'Test Workflow', }, - invitee: { + }); + }); + + it('should log on `workflow-deleted` event', () => { + const event: Event['workflow-deleted'] = { + user: { + id: '456', + email: 'jane@n8n.io', + firstName: 'Jane', + lastName: 'Smith', + role: 'user', + }, + workflowId: 'wf789', + publicApi: false, + }; + + eventService.emit('workflow-deleted', event); + + expect(eventBus.sendAuditEvent).toHaveBeenCalledWith({ + eventName: 'n8n.audit.workflow.deleted', + payload: { userId: '456', _email: 'jane@n8n.io', _firstName: 'Jane', + _lastName: 'Smith', + globalRole: 'user', + workflowId: 'wf789', + }, + }); + }); + + it('should log on `workflow-saved` event', () => { + const event: Event['workflow-saved'] = { + user: { + id: '789', + email: 'alex@n8n.io', + firstName: 'Alex', + lastName: 'Johnson', + role: 'editor', + }, + workflow: mock({ id: 'wf101', name: 'Updated Workflow' }), + publicApi: false, + }; + + eventService.emit('workflow-saved', event); + + expect(eventBus.sendAuditEvent).toHaveBeenCalledWith({ + eventName: 'n8n.audit.workflow.updated', + payload: { + userId: '789', + _email: 'alex@n8n.io', + _firstName: 'Alex', + _lastName: 'Johnson', + globalRole: 'editor', + workflowId: 'wf101', + workflowName: 'Updated Workflow', + }, + }); + }); + + it('should log on `workflow-pre-execute` event', () => { + const workflow = mock({ + id: 'wf202', + name: 'Test Workflow', + active: true, + nodes: [], + connections: {}, + staticData: undefined, + settings: {}, + }); + + const event: Event['workflow-pre-execute'] = { + executionId: 'exec123', + data: workflow, + }; + + eventService.emit('workflow-pre-execute', event); + + expect(eventBus.sendWorkflowEvent).toHaveBeenCalledWith({ + eventName: 'n8n.workflow.started', + payload: { + executionId: 'exec123', + userId: undefined, + workflowId: 'wf202', + isManual: false, + workflowName: 'Test Workflow', + }, + }); + }); + + it('should log on `workflow-post-execute` for successful execution', () => { + const payload = mock({ + executionId: 'some-id', + success: true, + userId: 'some-id', + workflowId: 'some-id', + isManual: true, + workflowName: 'some-name', + metadata: {}, + runData: mock({ data: { resultData: {} } }), + }); + + eventService.emit('workflow-post-execute', payload); + + const { runData: _, ...rest } = payload; + + expect(eventBus.sendWorkflowEvent).toHaveBeenCalledWith({ + eventName: 'n8n.workflow.success', + payload: rest, + }); + }); + + it('should handle `workflow-post-execute` event for unsuccessful execution', () => { + const runData = mock({ + data: { + resultData: { + lastNodeExecuted: 'some-node', + // @ts-expect-error Partial mock + error: { + node: mock({ type: 'some-type' }), + message: 'some-message', + }, + errorMessage: 'some-message', + }, + }, + }) as unknown as IRun; + + const event = { + executionId: 'some-id', + success: false, + userId: 'some-id', + workflowId: 'some-id', + isManual: true, + workflowName: 'some-name', + metadata: {}, + runData, + }; + + eventService.emit('workflow-post-execute', event); + + const { runData: _, ...rest } = event; + + expect(eventBus.sendWorkflowEvent).toHaveBeenCalledWith({ + eventName: 'n8n.workflow.failed', + payload: { + ...rest, + lastNodeExecuted: 'some-node', + errorNodeType: 'some-type', + errorMessage: 'some-message', + }, + }); + }); + }); + + describe('user events', () => { + it('should log on `user-updated` event', () => { + const event: Event['user-updated'] = { + user: { + id: 'user456', + email: 'updated@example.com', + firstName: 'Updated', + lastName: 'User', + role: 'global:member', + }, + fieldsChanged: ['firstName', 'lastName', 'password'], + }; + + eventService.emit('user-updated', event); + + expect(eventBus.sendAuditEvent).toHaveBeenCalledWith({ + eventName: 'n8n.audit.user.updated', + payload: { + userId: 'user456', + _email: 'updated@example.com', + _firstName: 'Updated', + _lastName: 'User', + globalRole: 'global:member', + fieldsChanged: ['firstName', 'lastName', 'password'], + }, + }); + }); + + it('should log on `user-deleted` event', () => { + const event: Event['user-deleted'] = { + user: { + id: '123', + email: 'john@n8n.io', + firstName: 'John', + lastName: 'Doe', + role: 'some-role', + }, + }; + + eventService.emit('user-deleted', event); + + expect(eventBus.sendAuditEvent).toHaveBeenCalledWith({ + eventName: 'n8n.audit.user.deleted', + payload: { + userId: '123', + _email: 'john@n8n.io', + _firstName: 'John', _lastName: 'Doe', - globalRole: 'some-other-role', + globalRole: 'some-role', + }, + }); + }); + }); + + describe('click events', () => { + it('should log on `user-password-reset-request-click` event', () => { + const event: Event['user-password-reset-request-click'] = { + user: { + id: 'user101', + email: 'user101@example.com', + firstName: 'John', + lastName: 'Doe', + role: 'global:member', + }, + }; + + eventService.emit('user-password-reset-request-click', event); + + expect(eventBus.sendAuditEvent).toHaveBeenCalledWith({ + eventName: 'n8n.audit.user.reset.requested', + payload: { + userId: 'user101', + _email: 'user101@example.com', + _firstName: 'John', + _lastName: 'Doe', + globalRole: 'global:member', + }, + }); + }); + + it('should log on `user-invite-email-click` event', () => { + const event: Event['user-invite-email-click'] = { + inviter: { + id: '123', + email: 'john@n8n.io', + firstName: 'John', + lastName: 'Doe', + role: 'some-role', + }, + invitee: { + id: '456', + email: 'jane@n8n.io', + firstName: 'Jane', + lastName: 'Doe', + role: 'some-other-role', + }, + }; + + eventService.emit('user-invite-email-click', event); + + expect(eventBus.sendAuditEvent).toHaveBeenCalledWith({ + eventName: 'n8n.audit.user.invitation.accepted', + payload: { + inviter: { + userId: '123', + _email: 'john@n8n.io', + _firstName: 'John', + _lastName: 'Doe', + globalRole: 'some-role', + }, + invitee: { + userId: '456', + _email: 'jane@n8n.io', + _firstName: 'Jane', + _lastName: 'Doe', + globalRole: 'some-other-role', + }, + }, + }); + }); + }); + + describe('node events', () => { + it('should log on `node-pre-execute` event', () => { + const workflow = mock({ + id: 'wf303', + name: 'Test Workflow with Nodes', + active: true, + nodes: [ + { + id: 'node1', + name: 'Start Node', + type: 'n8n-nodes-base.start', + typeVersion: 1, + position: [100, 200], + }, + { + id: 'node2', + name: 'HTTP Request', + type: 'n8n-nodes-base.httpRequest', + typeVersion: 1, + position: [300, 200], + }, + ], + connections: {}, + settings: {}, + }); + + const event: Event['node-pre-execute'] = { + executionId: 'exec456', + nodeName: 'HTTP Request', + workflow, + }; + + eventService.emit('node-pre-execute', event); + + expect(eventBus.sendNodeEvent).toHaveBeenCalledWith({ + eventName: 'n8n.node.started', + payload: { + executionId: 'exec456', + nodeName: 'HTTP Request', + workflowId: 'wf303', + workflowName: 'Test Workflow with Nodes', + nodeType: 'n8n-nodes-base.httpRequest', + }, + }); + }); + + it('should log on `node-post-execute` event', () => { + const workflow = mock({ + id: 'wf404', + name: 'Test Workflow with Completed Node', + active: true, + nodes: [ + { + id: 'node1', + name: 'Start Node', + type: 'n8n-nodes-base.start', + typeVersion: 1, + position: [100, 200], + }, + { + id: 'node2', + name: 'HTTP Response', + type: 'n8n-nodes-base.httpResponse', + typeVersion: 1, + position: [300, 200], + }, + ], + connections: {}, + settings: {}, + }); + + const event: Event['node-post-execute'] = { + executionId: 'exec789', + nodeName: 'HTTP Response', + workflow, + }; + + eventService.emit('node-post-execute', event); + + expect(eventBus.sendNodeEvent).toHaveBeenCalledWith({ + eventName: 'n8n.node.finished', + payload: { + executionId: 'exec789', + nodeName: 'HTTP Response', + workflowId: 'wf404', + workflowName: 'Test Workflow with Completed Node', + nodeType: 'n8n-nodes-base.httpResponse', + }, + }); + }); + }); + + describe('credentials events', () => { + it('should log on `credentials-shared` event', () => { + const event: Event['credentials-shared'] = { + user: { + id: 'user123', + email: 'sharer@example.com', + firstName: 'Alice', + lastName: 'Sharer', + role: 'global:owner', + }, + credentialId: 'cred789', + credentialType: 'githubApi', + userIdSharer: 'user123', + userIdsShareesAdded: ['user456', 'user789'], + shareesRemoved: null, + }; + + eventService.emit('credentials-shared', event); + + expect(eventBus.sendAuditEvent).toHaveBeenCalledWith({ + eventName: 'n8n.audit.user.credentials.shared', + payload: { + userId: 'user123', + _email: 'sharer@example.com', + _firstName: 'Alice', + _lastName: 'Sharer', + globalRole: 'global:owner', + credentialId: 'cred789', + credentialType: 'githubApi', + userIdSharer: 'user123', + userIdsShareesAdded: ['user456', 'user789'], + shareesRemoved: null, + }, + }); + }); + + it('should log on `credentials-created` event', () => { + const event: Event['credentials-created'] = { + user: { + id: 'user123', + email: 'user@example.com', + firstName: 'Test', + lastName: 'User', + role: 'global:owner', + }, + credentialType: 'githubApi', + credentialId: 'cred456', + publicApi: false, + projectId: 'proj789', + projectType: 'Personal', + }; + + eventService.emit('credentials-created', event); + + expect(eventBus.sendAuditEvent).toHaveBeenCalledWith({ + eventName: 'n8n.audit.user.credentials.created', + payload: { + userId: 'user123', + _email: 'user@example.com', + _firstName: 'Test', + _lastName: 'User', + globalRole: 'global:owner', + credentialType: 'githubApi', + credentialId: 'cred456', + publicApi: false, + projectId: 'proj789', + projectType: 'Personal', + }, + }); + }); + }); + + describe('auth events', () => { + it('should log on `user-login-failed` event', () => { + const event: Event['user-login-failed'] = { + userEmail: 'user@example.com', + authenticationMethod: 'email', + reason: 'Invalid password', + }; + + eventService.emit('user-login-failed', event); + + expect(eventBus.sendAuditEvent).toHaveBeenCalledWith({ + eventName: 'n8n.audit.user.login.failed', + payload: { + userEmail: 'user@example.com', + authenticationMethod: 'email', + reason: 'Invalid password', + }, + }); + }); + }); + + describe('community package events', () => { + it('should log on `community-package-updated` event', () => { + const event: Event['community-package-updated'] = { + user: { + id: 'user202', + email: 'packageupdater@example.com', + firstName: 'Package', + lastName: 'Updater', + role: 'global:admin', + }, + packageName: 'n8n-nodes-awesome-package', + packageVersionCurrent: '1.0.0', + packageVersionNew: '1.1.0', + packageNodeNames: ['AwesomeNode1', 'AwesomeNode2'], + packageAuthor: 'Jane Doe', + packageAuthorEmail: 'jane@example.com', + }; + + eventService.emit('community-package-updated', event); + + expect(eventBus.sendAuditEvent).toHaveBeenCalledWith({ + eventName: 'n8n.audit.package.updated', + payload: { + userId: 'user202', + _email: 'packageupdater@example.com', + _firstName: 'Package', + _lastName: 'Updater', + globalRole: 'global:admin', + packageName: 'n8n-nodes-awesome-package', + packageVersionCurrent: '1.0.0', + packageVersionNew: '1.1.0', + packageNodeNames: ['AwesomeNode1', 'AwesomeNode2'], + packageAuthor: 'Jane Doe', + packageAuthorEmail: 'jane@example.com', + }, + }); + }); + + it('should log on `community-package-installed` event', () => { + const event: Event['community-package-installed'] = { + user: { + id: 'user789', + email: 'admin@example.com', + firstName: 'Admin', + lastName: 'User', + role: 'global:admin', + }, + inputString: 'n8n-nodes-custom-package', + packageName: 'n8n-nodes-custom-package', + success: true, + packageVersion: '1.0.0', + packageNodeNames: ['CustomNode1', 'CustomNode2'], + packageAuthor: 'John Doe', + packageAuthorEmail: 'john@example.com', + }; + + eventService.emit('community-package-installed', event); + + expect(eventBus.sendAuditEvent).toHaveBeenCalledWith({ + eventName: 'n8n.audit.package.installed', + payload: { + userId: 'user789', + _email: 'admin@example.com', + _firstName: 'Admin', + _lastName: 'User', + globalRole: 'global:admin', + inputString: 'n8n-nodes-custom-package', + packageName: 'n8n-nodes-custom-package', + success: true, + packageVersion: '1.0.0', + packageNodeNames: ['CustomNode1', 'CustomNode2'], + packageAuthor: 'John Doe', + packageAuthorEmail: 'john@example.com', + }, + }); + }); + }); + + describe('email events', () => { + it('should log on `email-failed` event', () => { + const event: Event['email-failed'] = { + user: { + id: 'user789', + email: 'recipient@example.com', + firstName: 'Failed', + lastName: 'Recipient', + role: 'global:member', + }, + messageType: 'New user invite', + }; + + eventService.emit('email-failed', event); + + expect(eventBus.sendAuditEvent).toHaveBeenCalledWith({ + eventName: 'n8n.audit.user.email.failed', + payload: { + userId: 'user789', + _email: 'recipient@example.com', + _firstName: 'Failed', + _lastName: 'Recipient', + globalRole: 'global:member', + messageType: 'New user invite', + }, + }); + }); + }); + + describe('public API events', () => { + it('should log on `public-api-key-created` event', () => { + const event: Event['public-api-key-created'] = { + user: { + id: 'user101', + email: 'apiuser@example.com', + firstName: 'API', + lastName: 'User', + role: 'global:owner', + }, + publicApi: true, + }; + + eventService.emit('public-api-key-created', event); + + expect(eventBus.sendAuditEvent).toHaveBeenCalledWith({ + eventName: 'n8n.audit.user.api.created', + payload: { + userId: 'user101', + _email: 'apiuser@example.com', + _firstName: 'API', + _lastName: 'User', + globalRole: 'global:owner', + }, + }); + }); + }); + + describe('execution events', () => { + it('should log on `execution-throttled` event', () => { + const event: Event['execution-throttled'] = { + executionId: 'exec123456', + }; + + eventService.emit('execution-throttled', event); + + expect(eventBus.sendExecutionEvent).toHaveBeenCalledWith({ + eventName: 'n8n.execution.throttled', + payload: { + executionId: 'exec123456', }, - }, + }); }); }); }); diff --git a/packages/cli/src/eventbus/audit-event-relay.service.ts b/packages/cli/src/eventbus/audit-event-relay.service.ts index 9c73494520dbe..f8a95a3ebf359 100644 --- a/packages/cli/src/eventbus/audit-event-relay.service.ts +++ b/packages/cli/src/eventbus/audit-event-relay.service.ts @@ -86,13 +86,13 @@ export class AuditEventRelay { } @Redactable() - private workflowSaved({ user, workflowId, workflowName }: Event['workflow-saved']) { + private workflowSaved({ user, workflow }: Event['workflow-saved']) { void this.eventBus.sendAuditEvent({ eventName: 'n8n.audit.workflow.updated', payload: { ...user, - workflowId, - workflowName, + workflowId: workflow.id, + workflowName: workflow.name, }, }); } @@ -122,9 +122,28 @@ export class AuditEventRelay { } 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.success', - payload: event, + 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(), + }, }); } @@ -253,7 +272,7 @@ export class AuditEventRelay { } /** - * API key + * Public API */ @Redactable() diff --git a/packages/cli/src/eventbus/event.types.ts b/packages/cli/src/eventbus/event.types.ts index 225f9aca8c873..b62d3bc141031 100644 --- a/packages/cli/src/eventbus/event.types.ts +++ b/packages/cli/src/eventbus/event.types.ts @@ -1,5 +1,5 @@ -import type { AuthenticationMethod, IWorkflowBase } from 'n8n-workflow'; -import type { IWorkflowExecutionDataProcess } from '@/Interfaces'; +import type { AuthenticationMethod, IRun, IWorkflowBase } from 'n8n-workflow'; +import type { IWorkflowDb, IWorkflowExecutionDataProcess } from '@/Interfaces'; import type { ProjectRole } from '@/databases/entities/ProjectRelation'; import type { GlobalRole } from '@/databases/entities/User'; @@ -20,17 +20,21 @@ export type Event = { 'workflow-created': { user: UserLike; workflow: IWorkflowBase; + publicApi: boolean; + projectId: string; + projectType: string; }; 'workflow-deleted': { user: UserLike; workflowId: string; + publicApi: boolean; }; 'workflow-saved': { user: UserLike; - workflowId: string; - workflowName: string; + workflow: IWorkflowDb; + publicApi: boolean; }; 'workflow-pre-execute': { @@ -46,6 +50,7 @@ export type Event = { isManual: boolean; workflowName: string; metadata?: Record; + runData?: IRun; }; 'node-pre-execute': { diff --git a/packages/cli/src/executions/execution-recovery.service.ts b/packages/cli/src/executions/execution-recovery.service.ts index 05431a6d9c9d5..b72fc490dddbb 100644 --- a/packages/cli/src/executions/execution-recovery.service.ts +++ b/packages/cli/src/executions/execution-recovery.service.ts @@ -296,6 +296,7 @@ export class ExecutionRecoveryService { executionId: execution.id, success: execution.status === 'success', isManual: execution.mode === 'manual', + runData: execution, }); const externalHooks = getWorkflowHooksMain( diff --git a/packages/cli/src/metrics/__tests__/prometheus-metrics.service.test.ts b/packages/cli/src/metrics/__tests__/prometheus-metrics.service.test.ts index 8b89878203ed5..219170ac085a8 100644 --- a/packages/cli/src/metrics/__tests__/prometheus-metrics.service.test.ts +++ b/packages/cli/src/metrics/__tests__/prometheus-metrics.service.test.ts @@ -5,6 +5,8 @@ import { mock } from 'jest-mock-extended'; import { PrometheusMetricsService } from '../prometheus-metrics.service'; import type express from 'express'; import type { MessageEventBus } from '@/eventbus/MessageEventBus/MessageEventBus'; +import { mockInstance } from '@test/mocking'; +import { GlobalConfig } from '@n8n/config'; const mockMiddleware = ( _req: express.Request, @@ -16,13 +18,27 @@ jest.mock('prom-client'); jest.mock('express-prom-bundle', () => jest.fn(() => mockMiddleware)); describe('PrometheusMetricsService', () => { - beforeEach(() => { - config.load(config.default); + const globalConfig = mockInstance(GlobalConfig, { + endpoints: { + metrics: { + prefix: 'n8n_', + includeDefaultMetrics: true, + includeApiEndpoints: true, + includeCacheMetrics: true, + includeMessageEventBusMetrics: true, + includeCredentialTypeLabel: false, + includeNodeTypeLabel: false, + includeWorkflowIdLabel: false, + includeApiPathLabel: true, + includeApiMethodLabel: true, + includeApiStatusCodeLabel: true, + }, + }, }); describe('init', () => { it('should set up `n8n_version_info`', async () => { - const service = new PrometheusMetricsService(mock(), mock()); + const service = new PrometheusMetricsService(mock(), mock(), globalConfig); await service.init(mock()); @@ -34,7 +50,7 @@ describe('PrometheusMetricsService', () => { }); it('should set up default metrics collection with `prom-client`', async () => { - const service = new PrometheusMetricsService(mock(), mock()); + const service = new PrometheusMetricsService(mock(), mock(), globalConfig); await service.init(mock()); @@ -43,7 +59,7 @@ describe('PrometheusMetricsService', () => { it('should set up `n8n_cache_hits_total`', async () => { config.set('endpoints.metrics.includeCacheMetrics', true); - const service = new PrometheusMetricsService(mock(), mock()); + const service = new PrometheusMetricsService(mock(), mock(), globalConfig); await service.init(mock()); @@ -58,7 +74,7 @@ describe('PrometheusMetricsService', () => { it('should set up `n8n_cache_misses_total`', async () => { config.set('endpoints.metrics.includeCacheMetrics', true); - const service = new PrometheusMetricsService(mock(), mock()); + const service = new PrometheusMetricsService(mock(), mock(), globalConfig); await service.init(mock()); @@ -73,7 +89,7 @@ describe('PrometheusMetricsService', () => { it('should set up `n8n_cache_updates_total`', async () => { config.set('endpoints.metrics.includeCacheMetrics', true); - const service = new PrometheusMetricsService(mock(), mock()); + const service = new PrometheusMetricsService(mock(), mock(), globalConfig); await service.init(mock()); @@ -91,7 +107,7 @@ describe('PrometheusMetricsService', () => { config.set('endpoints.metrics.includeApiPathLabel', true); config.set('endpoints.metrics.includeApiMethodLabel', true); config.set('endpoints.metrics.includeApiStatusCodeLabel', true); - const service = new PrometheusMetricsService(mock(), mock()); + const service = new PrometheusMetricsService(mock(), mock(), globalConfig); const app = mock(); @@ -122,7 +138,7 @@ describe('PrometheusMetricsService', () => { it('should set up event bus metrics', async () => { const eventBus = mock(); - const service = new PrometheusMetricsService(mock(), eventBus); + const service = new PrometheusMetricsService(mock(), eventBus, globalConfig); await service.init(mock()); diff --git a/packages/cli/src/metrics/prometheus-metrics.service.ts b/packages/cli/src/metrics/prometheus-metrics.service.ts index b2d38424bccf7..1444f6f694bbd 100644 --- a/packages/cli/src/metrics/prometheus-metrics.service.ts +++ b/packages/cli/src/metrics/prometheus-metrics.service.ts @@ -1,4 +1,3 @@ -import config from '@/config'; import { N8N_VERSION } from '@/constants'; import type express from 'express'; import promBundle from 'express-prom-bundle'; @@ -11,32 +10,34 @@ import { MessageEventBus } from '@/eventbus/MessageEventBus/MessageEventBus'; import { EventMessageTypeNames } from 'n8n-workflow'; import type { EventMessageTypes } from '@/eventbus'; import type { Includes, MetricCategory, MetricLabel } from './types'; +import { GlobalConfig } from '@n8n/config'; @Service() export class PrometheusMetricsService { constructor( private readonly cacheService: CacheService, private readonly eventBus: MessageEventBus, + private readonly globalConfig: GlobalConfig, ) {} private readonly counters: { [key: string]: Counter | null } = {}; - private readonly prefix = config.getEnv('endpoints.metrics.prefix'); + private readonly prefix = this.globalConfig.endpoints.metrics.prefix; private readonly includes: Includes = { metrics: { - default: config.getEnv('endpoints.metrics.includeDefaultMetrics'), - routes: config.getEnv('endpoints.metrics.includeApiEndpoints'), - cache: config.getEnv('endpoints.metrics.includeCacheMetrics'), - logs: config.getEnv('endpoints.metrics.includeMessageEventBusMetrics'), + default: this.globalConfig.endpoints.metrics.includeDefaultMetrics, + routes: this.globalConfig.endpoints.metrics.includeApiEndpoints, + cache: this.globalConfig.endpoints.metrics.includeCacheMetrics, + logs: this.globalConfig.endpoints.metrics.includeMessageEventBusMetrics, }, labels: { - credentialsType: config.getEnv('endpoints.metrics.includeCredentialTypeLabel'), - nodeType: config.getEnv('endpoints.metrics.includeNodeTypeLabel'), - workflowId: config.getEnv('endpoints.metrics.includeWorkflowIdLabel'), - apiPath: config.getEnv('endpoints.metrics.includeApiPathLabel'), - apiMethod: config.getEnv('endpoints.metrics.includeApiMethodLabel'), - apiStatusCode: config.getEnv('endpoints.metrics.includeApiStatusCodeLabel'), + credentialsType: this.globalConfig.endpoints.metrics.includeCredentialTypeLabel, + nodeType: this.globalConfig.endpoints.metrics.includeNodeTypeLabel, + workflowId: this.globalConfig.endpoints.metrics.includeWorkflowIdLabel, + apiPath: this.globalConfig.endpoints.metrics.includeApiPathLabel, + apiMethod: this.globalConfig.endpoints.metrics.includeApiMethodLabel, + apiStatusCode: this.globalConfig.endpoints.metrics.includeApiStatusCodeLabel, }, }; diff --git a/packages/cli/src/middlewares/bodyParser.ts b/packages/cli/src/middlewares/bodyParser.ts index d48bf593cceee..5efe388e6f1e1 100644 --- a/packages/cli/src/middlewares/bodyParser.ts +++ b/packages/cli/src/middlewares/bodyParser.ts @@ -6,8 +6,9 @@ import { parse as parseQueryString } from 'querystring'; import { Parser as XmlParser } from 'xml2js'; import { parseIncomingMessage } from 'n8n-core'; import { jsonParse } from 'n8n-workflow'; -import config from '@/config'; import { UnprocessableRequestError } from '@/errors/response-errors/unprocessable.error'; +import { GlobalConfig } from '@n8n/config'; +import Container from 'typedi'; const xmlParser = new XmlParser({ async: true, @@ -16,7 +17,7 @@ const xmlParser = new XmlParser({ explicitArray: false, // Only put properties in array if length > 1 }); -const payloadSizeMax = config.getEnv('endpoints.payloadSizeMax'); +const payloadSizeMax = Container.get(GlobalConfig).endpoints.payloadSizeMax; export const rawBodyReader: RequestHandler = async (req, _res, next) => { parseIncomingMessage(req); diff --git a/packages/cli/src/requests.ts b/packages/cli/src/requests.ts index 1ff6537257529..1ea265f2394e7 100644 --- a/packages/cli/src/requests.ts +++ b/packages/cli/src/requests.ts @@ -76,9 +76,7 @@ export type AuthlessRequest< ResponseBody = {}, RequestBody = {}, RequestQuery = {}, -> = APIRequest & { - user: never; -}; +> = APIRequest; export type AuthenticatedRequest< RouteParams = {}, @@ -371,7 +369,7 @@ export declare namespace MFA { export declare namespace OAuthRequest { namespace OAuth1Credential { type Auth = AuthenticatedRequest<{}, {}, {}, { id: string }>; - type Callback = AuthenticatedRequest< + type Callback = AuthlessRequest< {}, {}, {}, @@ -383,7 +381,7 @@ export declare namespace OAuthRequest { namespace OAuth2Credential { type Auth = AuthenticatedRequest<{}, {}, {}, { id: string }>; - type Callback = AuthenticatedRequest<{}, {}, {}, { code: string; state: string }>; + type Callback = AuthlessRequest<{}, {}, {}, { code: string; state: string }>; } } diff --git a/packages/cli/src/services/frontend.service.ts b/packages/cli/src/services/frontend.service.ts index f9285d1cc8e58..13ce4239054ec 100644 --- a/packages/cli/src/services/frontend.service.ts +++ b/packages/cli/src/services/frontend.service.ts @@ -66,7 +66,7 @@ export class FrontendService { private initSettings() { const instanceBaseUrl = this.urlService.getInstanceBaseUrl(); - const restEndpoint = config.getEnv('endpoints.rest'); + const restEndpoint = this.globalConfig.endpoints.rest; const telemetrySettings: ITelemetrySettings = { enabled: config.getEnv('diagnostics.enabled'), @@ -88,11 +88,11 @@ export class FrontendService { isDocker: this.isDocker(), databaseType: this.globalConfig.database.type, previewMode: process.env.N8N_PREVIEW_MODE === 'true', - endpointForm: config.getEnv('endpoints.form'), - endpointFormTest: config.getEnv('endpoints.formTest'), - endpointFormWaiting: config.getEnv('endpoints.formWaiting'), - endpointWebhook: config.getEnv('endpoints.webhook'), - endpointWebhookTest: config.getEnv('endpoints.webhookTest'), + endpointForm: this.globalConfig.endpoints.form, + endpointFormTest: this.globalConfig.endpoints.formTest, + endpointFormWaiting: this.globalConfig.endpoints.formWaiting, + endpointWebhook: this.globalConfig.endpoints.webhook, + endpointWebhookTest: this.globalConfig.endpoints.webhookTest, saveDataErrorExecution: config.getEnv('executions.saveDataOnError'), saveDataSuccessExecution: config.getEnv('executions.saveDataOnSuccess'), saveManualExecutions: config.getEnv('executions.saveDataManualExecutions'), @@ -249,7 +249,7 @@ export class FrontendService { getSettings(pushRef?: string): IN8nUISettings { this.internalHooks.onFrontendSettingsAPI(pushRef); - const restEndpoint = config.getEnv('endpoints.rest'); + const restEndpoint = this.globalConfig.endpoints.rest; // Update all urls, in case `WEBHOOK_URL` was updated by `--tunnel` const instanceBaseUrl = this.urlService.getInstanceBaseUrl(); diff --git a/packages/cli/src/telemetry/telemetry-event-relay.service.ts b/packages/cli/src/telemetry/telemetry-event-relay.service.ts index bb1d4b8d560a2..9077abdf9ce23 100644 --- a/packages/cli/src/telemetry/telemetry-event-relay.service.ts +++ b/packages/cli/src/telemetry/telemetry-event-relay.service.ts @@ -8,6 +8,10 @@ import { License } from '@/License'; import { GlobalConfig } from '@n8n/config'; import { N8N_VERSION } from '@/constants'; import { WorkflowRepository } from '@/databases/repositories/workflow.repository'; +import { TelemetryHelpers } from 'n8n-workflow'; +import { NodeTypes } from '@/NodeTypes'; +import { SharedWorkflowRepository } from '@/databases/repositories/sharedWorkflow.repository'; +import { ProjectRelationRepository } from '@/databases/repositories/projectRelation.repository'; @Service() export class TelemetryEventRelay { @@ -17,6 +21,9 @@ export class TelemetryEventRelay { private readonly license: License, private readonly globalConfig: GlobalConfig, private readonly workflowRepository: WorkflowRepository, + private readonly nodeTypes: NodeTypes, + private readonly sharedWorkflowRepository: SharedWorkflowRepository, + private readonly projectRelationRepository: ProjectRelationRepository, ) {} async init() { @@ -101,6 +108,16 @@ export class TelemetryEventRelay { 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']) { @@ -431,6 +448,79 @@ export class TelemetryEventRelay { this.telemetry.track('User login failed since ldap disabled', { user_ud: userId }); } + private workflowCreated({ + user, + workflow, + publicApi, + projectId, + projectType, + }: Event['workflow-created']) { + const { nodeGraph } = TelemetryHelpers.generateNodesGraph(workflow, this.nodeTypes); + + this.telemetry.track('User created workflow', { + user_id: user.id, + workflow_id: workflow.id, + node_graph_string: JSON.stringify(nodeGraph), + public_api: publicApi, + project_id: projectId, + project_type: projectType, + }); + } + + private workflowDeleted({ user, workflowId, publicApi }: Event['workflow-deleted']) { + this.telemetry.track('User deleted workflow', { + user_id: user.id, + workflow_id: workflowId, + public_api: publicApi, + }); + } + + private async workflowSaved({ user, workflow, publicApi }: Event['workflow-saved']) { + const isCloudDeployment = config.getEnv('deployment.type') === 'cloud'; + + const { nodeGraph } = TelemetryHelpers.generateNodesGraph(workflow, this.nodeTypes, { + isCloudDeployment, + }); + + let userRole: 'owner' | 'sharee' | 'member' | undefined = undefined; + const role = await this.sharedWorkflowRepository.findSharingRole(user.id, workflow.id); + if (role) { + userRole = role === 'workflow:owner' ? 'owner' : 'sharee'; + } else { + const workflowOwner = await this.sharedWorkflowRepository.getWorkflowOwningProject( + workflow.id, + ); + + if (workflowOwner) { + const projectRole = await this.projectRelationRepository.findProjectRole({ + userId: user.id, + projectId: workflowOwner.id, + }); + + if (projectRole && projectRole !== 'project:personalOwner') { + userRole = 'member'; + } + } + } + + const notesCount = Object.keys(nodeGraph.notes).length; + const overlappingCount = Object.values(nodeGraph.notes).filter( + (note) => note.overlapping, + ).length; + + this.telemetry.track('User saved workflow', { + user_id: user.id, + workflow_id: workflow.id, + node_graph_string: JSON.stringify(nodeGraph), + notes_count_overlapping: overlappingCount, + notes_count_non_overlapping: notesCount - overlappingCount, + version_cli: N8N_VERSION, + num_tags: workflow.tags?.length ?? 0, + public_api: publicApi, + sharing_role: userRole, + }); + } + private async serverStarted() { const cpus = os.cpus(); const binaryDataConfig = config.getEnv('binaryDataManager'); @@ -444,9 +534,8 @@ export class TelemetryEventRelay { version_cli: N8N_VERSION, db_type: this.globalConfig.database.type, n8n_version_notifications_enabled: this.globalConfig.versionNotifications.enabled, - n8n_disable_production_main_process: config.getEnv( - 'endpoints.disableProductionWebhooksOnMainProcess', - ), + n8n_disable_production_main_process: + this.globalConfig.endpoints.disableProductionWebhooksOnMainProcess, system_info: { os: { type: os.type(), diff --git a/packages/cli/src/workflows/workflow.service.ts b/packages/cli/src/workflows/workflow.service.ts index 7c0cbfc242e1b..4f59f8238fe68 100644 --- a/packages/cli/src/workflows/workflow.service.ts +++ b/packages/cli/src/workflows/workflow.service.ts @@ -1,4 +1,4 @@ -import Container, { Service } from 'typedi'; +import { Service } from 'typedi'; import { NodeApiError } from 'n8n-workflow'; import pick from 'lodash/pick'; import omit from 'lodash/omit'; @@ -17,7 +17,6 @@ import { validateEntity } from '@/GenericHelpers'; import { ExternalHooks } from '@/ExternalHooks'; import { hasSharing, type ListQuery } from '@/requests'; import { TagService } from '@/services/tag.service'; -import { InternalHooks } from '@/InternalHooks'; import { OwnershipService } from '@/services/ownership.service'; import { WorkflowHistoryService } from './workflowHistory/workflowHistory.service.ee'; import { Logger } from '@/Logger'; @@ -219,11 +218,10 @@ export class WorkflowService { } await this.externalHooks.run('workflow.afterUpdate', [updatedWorkflow]); - void Container.get(InternalHooks).onWorkflowSaved(user, updatedWorkflow, false); this.eventService.emit('workflow-saved', { user, - workflowId: updatedWorkflow.id, - workflowName: updatedWorkflow.name, + workflow: updatedWorkflow, + publicApi: false, }); if (updatedWorkflow.active) { @@ -282,8 +280,7 @@ export class WorkflowService { await this.workflowRepository.delete(workflowId); await this.binaryDataService.deleteMany(idsForDeletion); - Container.get(InternalHooks).onWorkflowDeleted(user, workflowId, false); - this.eventService.emit('workflow-deleted', { user, workflowId }); + this.eventService.emit('workflow-deleted', { user, workflowId, publicApi: false }); await this.externalHooks.run('workflow.afterDelete', [workflowId]); return workflow; diff --git a/packages/cli/src/workflows/workflows.controller.ts b/packages/cli/src/workflows/workflows.controller.ts index 05863edb84c04..c774b21917c98 100644 --- a/packages/cli/src/workflows/workflows.controller.ts +++ b/packages/cli/src/workflows/workflows.controller.ts @@ -179,8 +179,13 @@ export class WorkflowsController { delete savedWorkflowWithMetaData.shared; await this.externalHooks.run('workflow.afterCreate', [savedWorkflow]); - this.internalHooks.onWorkflowCreated(req.user, newWorkflow, project!, false); - this.eventService.emit('workflow-created', { user: req.user, workflow: newWorkflow }); + this.eventService.emit('workflow-created', { + user: req.user, + workflow: newWorkflow, + publicApi: false, + projectId: project!.id, + projectType: project!.type, + }); const scopes = await this.workflowService.getWorkflowScopes(req.user, savedWorkflow.id); diff --git a/packages/cli/templates/form-trigger.handlebars b/packages/cli/templates/form-trigger.handlebars index a1abca87b81a4..67818629f57db 100644 --- a/packages/cli/templates/form-trigger.handlebars +++ b/packages/cli/templates/form-trigger.handlebars @@ -282,7 +282,10 @@ display: inline-block; } - @media only screen and (max-width: 400px) { + @media only screen and (max-width: 500px) { + body { + background-color: white; + } hr { display: block; } @@ -291,16 +294,16 @@ min-height: 100vh; padding: 24px; background-color: white; - border: 1px solid #dbdfe7; - border-radius: 8px; - box-shadow: 0px 4px 16px 0px #634dff0f; + border: 0px solid #dbdfe7; + border-radius: 0px; + box-shadow: 0px 0px 0px 0px white; } .card { padding: 0px; background-color: white; border: 0px solid #dbdfe7; border-radius: 0px; - box-shadow: 0px 0px 10px 0px #634dff0f; + box-shadow: 0px 0px 0px 0px white; margin-bottom: 0px; } } diff --git a/packages/cli/test/integration/controllers/oauth/oauth2.api.test.ts b/packages/cli/test/integration/controllers/oauth/oauth2.api.test.ts new file mode 100644 index 0000000000000..970727c2c8c64 --- /dev/null +++ b/packages/cli/test/integration/controllers/oauth/oauth2.api.test.ts @@ -0,0 +1,107 @@ +import { Container } from 'typedi'; +import { response as Response } from 'express'; +import nock from 'nock'; +import { parse as parseQs } from 'querystring'; + +import type { CredentialsEntity } from '@db/entities/CredentialsEntity'; +import type { User } from '@db/entities/User'; +import { CredentialsHelper } from '@/CredentialsHelper'; +import { OAuth2CredentialController } from '@/controllers/oauth/oAuth2Credential.controller'; + +import { createOwner } from '@test-integration/db/users'; +import { saveCredential } from '@test-integration/db/credentials'; +import * as testDb from '@test-integration/testDb'; +import { setupTestServer } from '@test-integration/utils'; +import type { SuperAgentTest } from '@test-integration/types'; + +describe('OAuth2 API', () => { + const testServer = setupTestServer({ endpointGroups: ['oauth2'] }); + + let owner: User; + let ownerAgent: SuperAgentTest; + let credential: CredentialsEntity; + const credentialData = { + clientId: 'client_id', + clientSecret: 'client_secret', + authUrl: 'https://test.domain/oauth2/auth', + accessTokenUrl: 'https://test.domain/oauth2/token', + authQueryParameters: 'access_type=offline', + }; + + CredentialsHelper.prototype.applyDefaultsAndOverwrites = (_, decryptedDataOriginal) => + decryptedDataOriginal; + + beforeAll(async () => { + owner = await createOwner(); + ownerAgent = testServer.authAgentFor(owner); + }); + + beforeEach(async () => { + await testDb.truncate(['SharedCredentials', 'Credentials']); + credential = await saveCredential( + { + name: 'Test', + type: 'testOAuth2Api', + data: credentialData, + }, + { + user: owner, + role: 'credential:owner', + }, + ); + }); + + it('should return a valid auth URL when the auth flow is initiated', async () => { + const controller = Container.get(OAuth2CredentialController); + const csrfSpy = jest.spyOn(controller, 'createCsrfState').mockClear(); + + const response = await ownerAgent + .get('/oauth2-credential/auth') + .query({ id: credential.id }) + .expect(200); + const authUrl = new URL(response.body.data); + expect(authUrl.hostname).toBe('test.domain'); + expect(authUrl.pathname).toBe('/oauth2/auth'); + + expect(csrfSpy).toHaveBeenCalled(); + const [_, state] = csrfSpy.mock.results[0].value; + expect(parseQs(authUrl.search.slice(1))).toEqual({ + access_type: 'offline', + client_id: 'client_id', + redirect_uri: 'http://localhost:5678/rest/oauth2-credential/callback', + response_type: 'code', + state, + scope: 'openid', + }); + }); + + it('should handle a valid callback without auth', async () => { + const controller = Container.get(OAuth2CredentialController); + const csrfSpy = jest.spyOn(controller, 'createCsrfState').mockClear(); + const renderSpy = (Response.render = jest.fn(function () { + this.end(); + })); + + await ownerAgent.get('/oauth2-credential/auth').query({ id: credential.id }).expect(200); + + const [_, state] = csrfSpy.mock.results[0].value; + + nock('https://test.domain').post('/oauth2/token').reply(200, { access_token: 'updated_token' }); + + await testServer.authlessAgent + .get('/oauth2-credential/callback') + .query({ code: 'auth_code', state }) + .expect(200); + + expect(renderSpy).toHaveBeenCalledWith('oauth-callback'); + + const updatedCredential = await Container.get(CredentialsHelper).getCredentials( + credential, + credential.type, + ); + expect(updatedCredential.getData()).toEqual({ + ...credentialData, + oauthTokenData: { access_token: 'updated_token' }, + }); + }); +}); diff --git a/packages/cli/test/integration/prometheus-metrics.test.ts b/packages/cli/test/integration/prometheus-metrics.test.ts index 68c8756a86ec0..4c00a98f74a49 100644 --- a/packages/cli/test/integration/prometheus-metrics.test.ts +++ b/packages/cli/test/integration/prometheus-metrics.test.ts @@ -2,17 +2,48 @@ import { Container } from 'typedi'; import { parse as semverParse } from 'semver'; import request, { type Response } from 'supertest'; -import config from '@/config'; 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'); -config.set('endpoints.metrics.enable', true); -config.set('endpoints.metrics.prefix', 'n8n_test_'); +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 server = setupTestServer({ endpointGroups: ['metrics'] }); const agent = request.agent(server.app); diff --git a/packages/cli/test/integration/shared/constants.ts b/packages/cli/test/integration/shared/constants.ts index caa3667c23012..5fffacbd11788 100644 --- a/packages/cli/test/integration/shared/constants.ts +++ b/packages/cli/test/integration/shared/constants.ts @@ -1,8 +1,7 @@ -import config from '@/config'; import { GlobalConfig } from '@n8n/config'; import Container from 'typedi'; -export const REST_PATH_SEGMENT = config.getEnv('endpoints.rest'); +export const REST_PATH_SEGMENT = Container.get(GlobalConfig).endpoints.rest; export const PUBLIC_API_REST_PATH_SEGMENT = Container.get(GlobalConfig).publicApi.path; diff --git a/packages/cli/test/integration/shared/types.ts b/packages/cli/test/integration/shared/types.ts index 0352386590a3d..cb794d0f95b92 100644 --- a/packages/cli/test/integration/shared/types.ts +++ b/packages/cli/test/integration/shared/types.ts @@ -13,6 +13,7 @@ type EndpointGroup = | 'me' | 'users' | 'auth' + | 'oauth2' | 'owner' | 'passwordReset' | 'credentials' diff --git a/packages/cli/test/integration/shared/utils/testServer.ts b/packages/cli/test/integration/shared/utils/testServer.ts index 3fc4cb5642b49..7776b7e669415 100644 --- a/packages/cli/test/integration/shared/utils/testServer.ts +++ b/packages/cli/test/integration/shared/utils/testServer.ts @@ -159,6 +159,10 @@ export const setupTestServer = ({ await import('@/controllers/auth.controller'); break; + case 'oauth2': + await import('@/controllers/oauth/oAuth2Credential.controller'); + break; + case 'mfa': await import('@/controllers/mfa.controller'); break; diff --git a/packages/cli/test/unit/decorators/controller.registry.test.ts b/packages/cli/test/unit/decorators/controller.registry.test.ts index 04b4884dcc331..05a97aab5378e 100644 --- a/packages/cli/test/unit/decorators/controller.registry.test.ts +++ b/packages/cli/test/unit/decorators/controller.registry.test.ts @@ -10,16 +10,18 @@ import { ControllerRegistry, Get, Licensed, RestController } from '@/decorators' import type { AuthService } from '@/auth/auth.service'; import type { License } from '@/License'; import type { SuperAgentTest } from '@test-integration/types'; +import type { GlobalConfig } from '@n8n/config'; describe('ControllerRegistry', () => { const license = mock(); const authService = mock(); + const globalConfig = mock({ endpoints: { rest: 'rest' } }); let agent: SuperAgentTest; beforeEach(() => { jest.resetAllMocks(); const app = express(); - new ControllerRegistry(license, authService).activate(app); + new ControllerRegistry(license, authService, globalConfig).activate(app); agent = testAgent(app); }); diff --git a/packages/cli/test/unit/webhooks.test.ts b/packages/cli/test/unit/webhooks.test.ts index 5fe8f937de660..c891588597ac5 100644 --- a/packages/cli/test/unit/webhooks.test.ts +++ b/packages/cli/test/unit/webhooks.test.ts @@ -2,7 +2,6 @@ import type SuperAgentTest from 'supertest/lib/agent'; import { agent as testAgent } from 'supertest'; import { mock } from 'jest-mock-extended'; -import config from '@/config'; import { AbstractServer } from '@/AbstractServer'; import { ActiveWebhooks } from '@/ActiveWebhooks'; import { ExternalHooks } from '@/ExternalHooks'; @@ -13,6 +12,8 @@ import { WaitingForms } from '@/WaitingForms'; import type { IResponseCallbackData } from '@/Interfaces'; import { mockInstance } from '../shared/mocking'; +import { GlobalConfig } from '@n8n/config'; +import Container from 'typedi'; let agent: SuperAgentTest; @@ -46,7 +47,7 @@ describe('WebhookServer', () => { for (const [key, manager] of tests) { describe(`for ${key}`, () => { it('should handle preflight requests', async () => { - const pathPrefix = config.getEnv(`endpoints.${key}`); + const pathPrefix = Container.get(GlobalConfig).endpoints[key]; manager.getWebhookMethods.mockResolvedValueOnce(['GET']); const response = await agent @@ -60,7 +61,7 @@ describe('WebhookServer', () => { }); it('should handle regular requests', async () => { - const pathPrefix = config.getEnv(`endpoints.${key}`); + const pathPrefix = Container.get(GlobalConfig).endpoints[key]; manager.getWebhookMethods.mockResolvedValueOnce(['GET']); manager.executeWebhook.mockResolvedValueOnce( mockResponse({ test: true }, { key: 'value ' }), diff --git a/packages/core/package.json b/packages/core/package.json index 17f57cc99bad1..dfcc23db99d92 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "n8n-core", - "version": "1.52.0", + "version": "1.53.0", "description": "Core functionality of n8n", "main": "dist/index", "types": "dist/index.d.ts", diff --git a/packages/core/src/NodeExecuteFunctions.ts b/packages/core/src/NodeExecuteFunctions.ts index f9e27cc15c0ac..cf677ba5bd036 100644 --- a/packages/core/src/NodeExecuteFunctions.ts +++ b/packages/core/src/NodeExecuteFunctions.ts @@ -4212,8 +4212,9 @@ export function getExecuteWebhookFunctions( mode: WorkflowExecuteMode, webhookData: IWebhookData, closeFunctions: CloseFunction[], + runExecutionData: IRunExecutionData | null, ): IWebhookFunctions { - return ((workflow: Workflow, node: INode) => { + return ((workflow: Workflow, node: INode, runExecutionData: IRunExecutionData | null) => { return { ...getCommonWorkflowFunctions(workflow, node, additionalData), getBodyData(): IDataObject { @@ -4274,10 +4275,21 @@ export function getExecuteWebhookFunctions( fallbackValue?: any, options?: IGetNodeParameterOptions, ): NodeParameterValueType | object => { - const runExecutionData: IRunExecutionData | null = null; const itemIndex = 0; const runIndex = 0; - const connectionInputData: INodeExecutionData[] = []; + + let connectionInputData: INodeExecutionData[] = []; + let executionData: IExecuteData | undefined; + + if (runExecutionData?.executionData !== undefined) { + executionData = runExecutionData.executionData.nodeExecutionStack[0]; + + if (executionData !== undefined) { + connectionInputData = executionData.data.main[0]!; + } + } + + const additionalKeys = getAdditionalKeys(additionalData, mode, runExecutionData); return getNodeParameter( workflow, @@ -4288,8 +4300,8 @@ export function getExecuteWebhookFunctions( parameterName, itemIndex, mode, - getAdditionalKeys(additionalData, mode, null), - undefined, + additionalKeys, + executionData, fallbackValue, options, ); @@ -4336,5 +4348,5 @@ export function getExecuteWebhookFunctions( }, nodeHelpers: getNodeHelperFunctions(additionalData, workflow.id), }; - })(workflow, node); + })(workflow, node, runExecutionData); } diff --git a/packages/design-system/package.json b/packages/design-system/package.json index d0a5382f60898..30db649157559 100644 --- a/packages/design-system/package.json +++ b/packages/design-system/package.json @@ -1,6 +1,6 @@ { "name": "n8n-design-system", - "version": "1.42.0", + "version": "1.43.0", "main": "src/main.ts", "import": "src/main.ts", "scripts": { diff --git a/packages/editor-ui/package.json b/packages/editor-ui/package.json index d4acc7843d1b2..caf156fa83c9e 100644 --- a/packages/editor-ui/package.json +++ b/packages/editor-ui/package.json @@ -1,6 +1,6 @@ { "name": "n8n-editor-ui", - "version": "1.52.0", + "version": "1.53.0", "description": "Workflow Editor UI for n8n", "main": "index.js", "scripts": { diff --git a/packages/editor-ui/src/components/executions/workflow/WorkflowExecutionsList.vue b/packages/editor-ui/src/components/executions/workflow/WorkflowExecutionsList.vue index 22dd7c00e0fad..9e66df8117baa 100644 --- a/packages/editor-ui/src/components/executions/workflow/WorkflowExecutionsList.vue +++ b/packages/editor-ui/src/components/executions/workflow/WorkflowExecutionsList.vue @@ -5,10 +5,10 @@ :loading="loading && !executions.length" :loading-more="loadingMore" :temporary-execution="temporaryExecution" - @update:auto-refresh="$emit('update:auto-refresh', $event)" - @reload-executions="$emit('reload')" - @filter-updated="$emit('update:filters', $event)" - @load-more="$emit('load-more')" + @update:auto-refresh="emit('update:auto-refresh', $event)" + @reload-executions="emit('reload')" + @filter-updated="emit('update:filters', $event)" + @load-more="emit('load-more')" @retry-execution="onRetryExecution" />
@@ -23,177 +23,98 @@
- diff --git a/packages/editor-ui/src/components/executions/workflow/WorkflowExecutionsSidebar.vue b/packages/editor-ui/src/components/executions/workflow/WorkflowExecutionsSidebar.vue index df30fd0eb0438..0186ef4830a1a 100644 --- a/packages/editor-ui/src/components/executions/workflow/WorkflowExecutionsSidebar.vue +++ b/packages/editor-ui/src/components/executions/workflow/WorkflowExecutionsSidebar.vue @@ -186,6 +186,9 @@ export default defineComponent({ this.$emit('refresh'); }, onFilterChanged(filter: ExecutionFilterType) { + this.autoScrollDeps.activeExecutionSet = false; + this.autoScrollDeps.scroll = true; + this.mountedItems = []; this.$emit('filterUpdated', filter); }, reloadExecutions(): void { diff --git a/packages/editor-ui/src/composables/useNodeHelpers.ts b/packages/editor-ui/src/composables/useNodeHelpers.ts index 19e46339bcdd2..a8c763b11f5a2 100644 --- a/packages/editor-ui/src/composables/useNodeHelpers.ts +++ b/packages/editor-ui/src/composables/useNodeHelpers.ts @@ -8,6 +8,7 @@ import { FORM_TRIGGER_NODE_TYPE, NODE_OUTPUT_DEFAULT_KEY, PLACEHOLDER_FILLED_AT_EXECUTION_TIME, + SPLIT_IN_BATCHES_NODE_TYPE, WEBHOOK_NODE_TYPE, } from '@/constants'; @@ -571,6 +572,16 @@ export function useNodeHelpers() { paneType: NodePanelType = 'output', connectionType: ConnectionTypes = NodeConnectionType.Main, ): INodeExecutionData[] { + //TODO: check if this needs to be fixed in different place + if ( + node?.type === SPLIT_IN_BATCHES_NODE_TYPE && + paneType === 'input' && + runIndex !== 0 && + outputIndex !== 0 + ) { + runIndex = runIndex - 1; + } + if (node === null) { return []; } diff --git a/packages/editor-ui/src/views/WorkflowExecutionsView.vue b/packages/editor-ui/src/views/WorkflowExecutionsView.vue index 3cca54466e02c..86a19f603ba20 100644 --- a/packages/editor-ui/src/views/WorkflowExecutionsView.vue +++ b/packages/editor-ui/src/views/WorkflowExecutionsView.vue @@ -12,7 +12,6 @@ import { PLACEHOLDER_EMPTY_WORKFLOW_ID, VIEWS } from '@/constants'; import { useRoute, useRouter } from 'vue-router'; import type { ExecutionSummary } from 'n8n-workflow'; import { useDebounce } from '@/composables/useDebounce'; -import { storeToRefs } from 'pinia'; import { useTelemetry } from '@/composables/useTelemetry'; import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers'; import { useNodeHelpers } from '@/composables/useNodeHelpers'; @@ -29,8 +28,6 @@ const { callDebounced } = useDebounce(); const workflowHelpers = useWorkflowHelpers({ router }); const nodeHelpers = useNodeHelpers(); -const { filters } = storeToRefs(executionsStore); - const loading = ref(false); const loadingMore = ref(false); @@ -311,7 +308,6 @@ async function loadMore(): Promise { v-if="workflow" :executions="executions" :execution="execution" - :filters="filters" :workflow="workflow" :loading="loading" :loading-more="loadingMore" diff --git a/packages/node-dev/package.json b/packages/node-dev/package.json index 92db12b997737..d7b16155a1aa5 100644 --- a/packages/node-dev/package.json +++ b/packages/node-dev/package.json @@ -1,6 +1,6 @@ { "name": "n8n-node-dev", - "version": "1.52.0", + "version": "1.53.0", "description": "CLI to simplify n8n credentials/node development", "main": "dist/src/index", "types": "dist/src/index.d.ts", diff --git a/packages/nodes-base/credentials/CalendlyOAuth2Api.credentials.ts b/packages/nodes-base/credentials/CalendlyOAuth2Api.credentials.ts new file mode 100644 index 0000000000000..200d238c6d73f --- /dev/null +++ b/packages/nodes-base/credentials/CalendlyOAuth2Api.credentials.ts @@ -0,0 +1,52 @@ +import type { ICredentialType, INodeProperties, Icon } from 'n8n-workflow'; + +export class CalendlyOAuth2Api implements ICredentialType { + name = 'calendlyOAuth2Api'; + + extends = ['oAuth2Api']; + + displayName = 'Calendly OAuth2 API'; + + documentationUrl = 'calendly'; + + icon: Icon = 'file:icons/Calendly.svg'; + + properties: INodeProperties[] = [ + { + displayName: 'Grant Type', + name: 'grantType', + type: 'hidden', + default: 'authorizationCode', + }, + { + displayName: 'Authorization URL', + name: 'authUrl', + type: 'hidden', + default: 'https://auth.calendly.com/oauth/authorize', + }, + { + displayName: 'Access Token URL', + name: 'accessTokenUrl', + type: 'hidden', + default: 'https://auth.calendly.com/oauth/token', + }, + { + displayName: 'Authentication', + name: 'authentication', + type: 'hidden', + default: 'header', + }, + { + displayName: 'Auth URI Query Parameters', + name: 'authQueryParameters', + type: 'hidden', + default: '', + }, + { + displayName: 'Scope', + name: 'scope', + type: 'hidden', + default: '', + }, + ]; +} diff --git a/packages/nodes-base/credentials/icons/Calendly.svg b/packages/nodes-base/credentials/icons/Calendly.svg new file mode 100644 index 0000000000000..195a7461e33b6 --- /dev/null +++ b/packages/nodes-base/credentials/icons/Calendly.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/nodes-base/nodes/Aws/AwsLambda.node.ts b/packages/nodes-base/nodes/Aws/AwsLambda.node.ts index e1fd51f13086c..ed69ab35bd1b8 100644 --- a/packages/nodes-base/nodes/Aws/AwsLambda.node.ts +++ b/packages/nodes-base/nodes/Aws/AwsLambda.node.ts @@ -195,13 +195,21 @@ export class AwsLambda implements INodeType { throw new NodeApiError(this.getNode(), responseData as JsonObject); } else { - returnData.push({ - result: responseData, - } as IDataObject); + const executionData = this.helpers.constructExecutionMetaData( + this.helpers.returnJsonArray({ + result: responseData, + }), + { itemData: { item: i } }, + ); + returnData.push(...executionData); } } catch (error) { if (this.continueOnFail(error)) { - returnData.push({ error: (error as JsonObject).message }); + const executionData = this.helpers.constructExecutionMetaData( + this.helpers.returnJsonArray({ error: (error as JsonObject).message }), + { itemData: { item: i } }, + ); + returnData.push(...executionData); continue; } throw error; diff --git a/packages/nodes-base/nodes/Calendly/CalendlyTrigger.node.ts b/packages/nodes-base/nodes/Calendly/CalendlyTrigger.node.ts index d2e7b127237d2..40a10cab92cfa 100644 --- a/packages/nodes-base/nodes/Calendly/CalendlyTrigger.node.ts +++ b/packages/nodes-base/nodes/Calendly/CalendlyTrigger.node.ts @@ -26,6 +26,20 @@ export class CalendlyTrigger implements INodeType { { name: 'calendlyApi', required: true, + displayOptions: { + show: { + authentication: ['apiKey'], + }, + }, + }, + { + name: 'calendlyOAuth2Api', + required: true, + displayOptions: { + show: { + authentication: ['oAuth2'], + }, + }, }, ], webhooks: [ @@ -37,6 +51,23 @@ export class CalendlyTrigger implements INodeType { }, ], properties: [ + { + displayName: 'Authentication', + name: 'authentication', + type: 'options', + options: [ + { + // eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased + name: 'OAuth2 (recommended)', + value: 'oAuth2', + }, + { + name: 'API Key or Personal Access Token', + value: 'apiKey', + }, + ], + default: 'apiKey', + }, { displayName: 'Scope', name: 'scope', @@ -86,9 +117,8 @@ export class CalendlyTrigger implements INodeType { const webhookUrl = this.getNodeWebhookUrl('default'); const webhookData = this.getWorkflowStaticData('node'); const events = this.getNodeParameter('events') as string; - const { apiKey } = (await this.getCredentials('calendlyApi')) as { apiKey: string }; - const authenticationType = getAuthenticationType(apiKey); + const authenticationType = await getAuthenticationType.call(this); // remove condition once API Keys are deprecated if (authenticationType === 'apiKey') { @@ -149,9 +179,8 @@ export class CalendlyTrigger implements INodeType { const webhookData = this.getWorkflowStaticData('node'); const webhookUrl = this.getNodeWebhookUrl('default'); const events = this.getNodeParameter('events') as string; - const { apiKey } = (await this.getCredentials('calendlyApi')) as { apiKey: string }; - const authenticationType = getAuthenticationType(apiKey); + const authenticationType = await getAuthenticationType.call(this); // remove condition once API Keys are deprecated if (authenticationType === 'apiKey') { @@ -201,8 +230,7 @@ export class CalendlyTrigger implements INodeType { }, async delete(this: IHookFunctions): Promise { const webhookData = this.getWorkflowStaticData('node'); - const { apiKey } = (await this.getCredentials('calendlyApi')) as { apiKey: string }; - const authenticationType = getAuthenticationType(apiKey); + const authenticationType = await getAuthenticationType.call(this); // remove condition once API Keys are deprecated if (authenticationType === 'apiKey') { diff --git a/packages/nodes-base/nodes/Calendly/GenericFunctions.ts b/packages/nodes-base/nodes/Calendly/GenericFunctions.ts index 4befbb5ceed21..f39aa877618b2 100644 --- a/packages/nodes-base/nodes/Calendly/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Calendly/GenericFunctions.ts @@ -1,6 +1,4 @@ import type { - ICredentialDataDecryptedObject, - ICredentialTestFunctions, IDataObject, IExecuteFunctions, ILoadOptionsFunctions, @@ -10,12 +8,24 @@ import type { IRequestOptions, } from 'n8n-workflow'; -export function getAuthenticationType(data: string): 'accessToken' | 'apiKey' { +function getAuthenticationTypeFromApiKey(data: string): 'accessToken' | 'apiKey' { // The access token is a JWT, so it will always include dots to separate // header, payoload and signature. return data.includes('.') ? 'accessToken' : 'apiKey'; } +export async function getAuthenticationType( + this: IExecuteFunctions | IWebhookFunctions | IHookFunctions | ILoadOptionsFunctions, +): Promise<'accessToken' | 'apiKey'> { + const authentication = this.getNodeParameter('authentication', 0) as string; + if (authentication === 'apiKey') { + const { apiKey } = (await this.getCredentials('calendlyApi')) as { apiKey: string }; + return getAuthenticationTypeFromApiKey(apiKey); + } else { + return 'accessToken'; + } +} + export async function calendlyApiRequest( this: IExecuteFunctions | IWebhookFunctions | IHookFunctions | ILoadOptionsFunctions, method: IHttpRequestMethods, @@ -26,9 +36,7 @@ export async function calendlyApiRequest( uri?: string, option: IDataObject = {}, ): Promise { - const { apiKey } = (await this.getCredentials('calendlyApi')) as { apiKey: string }; - - const authenticationType = getAuthenticationType(apiKey); + const authenticationType = await getAuthenticationType.call(this); const headers: IDataObject = { 'Content-Type': 'application/json', @@ -57,37 +65,10 @@ export async function calendlyApiRequest( delete options.qs; } options = Object.assign({}, options, option); - return await this.helpers.requestWithAuthentication.call(this, 'calendlyApi', options); -} - -export async function validateCredentials( - this: ICredentialTestFunctions, - decryptedCredentials: ICredentialDataDecryptedObject, -): Promise { - const credentials = decryptedCredentials; - const { apiKey } = credentials as { - apiKey: string; - }; - - const authenticationType = getAuthenticationType(apiKey); - - const options: IRequestOptions = { - method: 'GET', - uri: '', - json: true, - }; - - if (authenticationType === 'accessToken') { - Object.assign(options, { - headers: { Authorization: `Bearer ${apiKey}` }, - uri: 'https://api.calendly.com/users/me', - }); - } else { - Object.assign(options, { - headers: { 'X-TOKEN': apiKey }, - uri: 'https://calendly.com/api/v1/users/me', - }); - } - return await this.helpers.request(options); + const credentialsType = + (this.getNodeParameter('authentication', 0) as string) === 'apiKey' + ? 'calendlyApi' + : 'calendlyOAuth2Api'; + return await this.helpers.requestWithAuthentication.call(this, credentialsType, options); } diff --git a/packages/nodes-base/nodes/Form/common.descriptions.ts b/packages/nodes-base/nodes/Form/common.descriptions.ts index 8ab9cc0927a53..c3505e9509f86 100644 --- a/packages/nodes-base/nodes/Form/common.descriptions.ts +++ b/packages/nodes-base/nodes/Form/common.descriptions.ts @@ -54,7 +54,7 @@ export const formFields: INodeProperties = { type: 'string', default: '', placeholder: 'e.g. What is your name?', - description: 'Label appears above the input field', + description: 'Label that appears above the input field', required: true, }, { @@ -102,6 +102,7 @@ export const formFields: INodeProperties = { { displayName: 'Placeholder', name: 'placeholder', + description: 'Sample text to display inside the field', type: 'string', default: '', displayOptions: { @@ -169,11 +170,11 @@ export const formFields: INodeProperties = { }, }, { - displayName: 'Accept File Types', + displayName: 'Accepted File Types', name: 'acceptFileTypes', type: 'string', default: '', - description: 'List of file types that can be uploaded, separated by commas', + description: 'Comma-separated list of allowed file extensions', hint: 'Leave empty to allow all file types', placeholder: 'e.g. .jpg, .png', displayOptions: { @@ -188,7 +189,7 @@ export const formFields: INodeProperties = { type: 'string', default: '', description: - 'Returns a string representation of this field formatted according to the specified format string. For a table of tokens and their interpretations, see here.', + 'How to format the date in the output data. For a table of tokens and their interpretations, see here.', placeholder: 'e.g. dd/mm/yyyy', hint: 'Leave empty to use the default format', displayOptions: { diff --git a/packages/nodes-base/nodes/Postgres/Postgres.node.ts b/packages/nodes-base/nodes/Postgres/Postgres.node.ts index 05e09dff4dfcb..0e1760faeac94 100644 --- a/packages/nodes-base/nodes/Postgres/Postgres.node.ts +++ b/packages/nodes-base/nodes/Postgres/Postgres.node.ts @@ -11,7 +11,7 @@ export class Postgres extends VersionedNodeType { name: 'postgres', icon: 'file:postgres.svg', group: ['input'], - defaultVersion: 2.4, + defaultVersion: 2.5, description: 'Get, add and update data in Postgres', parameterPane: 'wide', }; @@ -23,6 +23,7 @@ export class Postgres extends VersionedNodeType { 2.2: new PostgresV2(baseDescription), 2.3: new PostgresV2(baseDescription), 2.4: new PostgresV2(baseDescription), + 2.5: new PostgresV2(baseDescription), }; super(nodeVersions, baseDescription); diff --git a/packages/nodes-base/nodes/Postgres/v2/actions/database/executeQuery.operation.ts b/packages/nodes-base/nodes/Postgres/v2/actions/database/executeQuery.operation.ts index 2b8449e983ccb..4e6100e5755a2 100644 --- a/packages/nodes-base/nodes/Postgres/v2/actions/database/executeQuery.operation.ts +++ b/packages/nodes-base/nodes/Postgres/v2/actions/database/executeQuery.operation.ts @@ -3,6 +3,7 @@ import type { IExecuteFunctions, INodeExecutionData, INodeProperties, + NodeParameterValueType, } from 'n8n-workflow'; import { NodeOperationError } from 'n8n-workflow'; @@ -78,22 +79,45 @@ export async function execute( const rawReplacements = (node.parameters.options as IDataObject)?.queryReplacement as string; - if (rawReplacements) { - const rawValues = rawReplacements - .replace(/^=+/, '') + const stringToArray = (str: NodeParameterValueType | undefined) => { + if (!str) return []; + return String(str) .split(',') .filter((entry) => entry) .map((entry) => entry.trim()); + }; - for (const rawValue of rawValues) { - const resolvables = getResolvables(rawValue); + if (rawReplacements) { + const nodeVersion = nodeOptions.nodeVersion as number; + if (nodeVersion >= 2.5) { + const rawValues = rawReplacements.replace(/^=+/, ''); + const resolvables = getResolvables(rawValues); if (resolvables.length) { for (const resolvable of resolvables) { - values.push(this.evaluateExpression(`${resolvable}`, i) as IDataObject); + const evaluatedValues = stringToArray(this.evaluateExpression(`${resolvable}`, i)); + if (evaluatedValues.length) values.push(...evaluatedValues); } } else { - values.push(rawValue); + values.push(...stringToArray(rawValues)); + } + } else { + const rawValues = rawReplacements + .replace(/^=+/, '') + .split(',') + .filter((entry) => entry) + .map((entry) => entry.trim()); + + for (const rawValue of rawValues) { + const resolvables = getResolvables(rawValue); + + if (resolvables.length) { + for (const resolvable of resolvables) { + values.push(this.evaluateExpression(`${resolvable}`, i) as IDataObject); + } + } else { + values.push(rawValue); + } } } } diff --git a/packages/nodes-base/nodes/Postgres/v2/actions/versionDescription.ts b/packages/nodes-base/nodes/Postgres/v2/actions/versionDescription.ts index 1687accf1cde4..de6047003de9e 100644 --- a/packages/nodes-base/nodes/Postgres/v2/actions/versionDescription.ts +++ b/packages/nodes-base/nodes/Postgres/v2/actions/versionDescription.ts @@ -8,7 +8,7 @@ export const versionDescription: INodeTypeDescription = { name: 'postgres', icon: 'file:postgres.svg', group: ['input'], - version: [2, 2.1, 2.2, 2.3, 2.4], + version: [2, 2.1, 2.2, 2.3, 2.4, 2.5], subtitle: '={{ $parameter["operation"] }}', description: 'Get, add and update data in Postgres', defaults: { diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index e512fb4ef89bc..e9da85e9faf47 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -1,6 +1,6 @@ { "name": "n8n-nodes-base", - "version": "1.52.0", + "version": "1.53.0", "description": "Base nodes of n8n", "main": "index.js", "scripts": { @@ -52,6 +52,7 @@ "dist/credentials/BubbleApi.credentials.js", "dist/credentials/CalApi.credentials.js", "dist/credentials/CalendlyApi.credentials.js", + "dist/credentials/CalendlyOAuth2Api.credentials.js", "dist/credentials/CarbonBlackApi.credentials.js", "dist/credentials/ChargebeeApi.credentials.js", "dist/credentials/CircleCiApi.credentials.js", @@ -829,7 +830,7 @@ "dependencies": { "@kafkajs/confluent-schema-registry": "1.0.6", "@n8n/imap": "workspace:*", - "@n8n/vm2": "3.9.20", + "@n8n/vm2": "3.9.24", "amqplib": "0.10.3", "alasql": "^4.4.0", "aws4": "1.11.0", diff --git a/packages/workflow/package.json b/packages/workflow/package.json index 83de78636bf8c..179ae29311c11 100644 --- a/packages/workflow/package.json +++ b/packages/workflow/package.json @@ -1,6 +1,6 @@ { "name": "n8n-workflow", - "version": "1.51.0", + "version": "1.52.0", "description": "Workflow base code of n8n", "main": "dist/index.js", "module": "src/index.ts", diff --git a/packages/workflow/src/Interfaces.ts b/packages/workflow/src/Interfaces.ts index 05ca9d89eb840..c984abb9c1f1e 100644 --- a/packages/workflow/src/Interfaces.ts +++ b/packages/workflow/src/Interfaces.ts @@ -466,6 +466,7 @@ export interface IGetExecuteWebhookFunctions { mode: WorkflowExecuteMode, webhookData: IWebhookData, closeFunctions: CloseFunction[], + runExecutionData: IRunExecutionData | null, ): IWebhookFunctions; } diff --git a/packages/workflow/src/Workflow.ts b/packages/workflow/src/Workflow.ts index 3c74480b3864f..ef0539db28a97 100644 --- a/packages/workflow/src/Workflow.ts +++ b/packages/workflow/src/Workflow.ts @@ -1237,6 +1237,7 @@ export class Workflow { additionalData: IWorkflowExecuteAdditionalData, nodeExecuteFunctions: INodeExecuteFunctions, mode: WorkflowExecuteMode, + runExecutionData: IRunExecutionData | null, ): Promise { const nodeType = this.nodeTypes.getByNameAndVersion(node.type, node.typeVersion); if (nodeType === undefined) { @@ -1258,6 +1259,7 @@ export class Workflow { mode, webhookData, closeFunctions, + runExecutionData, ); return nodeType instanceof Node ? await nodeType.webhook(context) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ccb60099264ae..ee38596ce3b2f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -390,8 +390,8 @@ importers: specifier: 0.3.20-10 version: 0.3.20-10(@sentry/node@7.87.0)(ioredis@5.3.2)(mssql@10.0.2)(mysql2@3.10.0)(pg@8.11.3)(redis@4.6.12)(sqlite3@5.1.7) '@n8n/vm2': - specifier: 3.9.20 - version: 3.9.20 + specifier: 3.9.24 + version: 3.9.24 '@pinecone-database/pinecone': specifier: 3.0.0 version: 3.0.0 @@ -1454,8 +1454,8 @@ importers: specifier: workspace:* version: link:../@n8n/imap '@n8n/vm2': - specifier: 3.9.20 - version: 3.9.20 + specifier: 3.9.24 + version: 3.9.24 alasql: specifier: ^4.4.0 version: 4.4.0(encoding@0.1.13) @@ -4217,9 +4217,9 @@ packages: typeorm-aurora-data-api-driver: optional: true - '@n8n/vm2@3.9.20': - resolution: {integrity: sha512-qk2oJYkuFRVSTxoro4obX/sv/wT1pViZjHh/isjOvFB93D52QIg3TCjMPsHOfHTmkxCKJffjLrUvjIwvWzSMCQ==} - engines: {node: '>=18.10', pnpm: '>=8.6.12'} + '@n8n/vm2@3.9.24': + resolution: {integrity: sha512-O4z67yVgUs2FHkcw3vbGnxdC1EglpzOj966kPkK4gtW+ZmTTFRfEB+2Ehq6PMthgg/Ou5JCLSR3wvQIZFFt4Pg==} + engines: {node: '>=18.10', pnpm: '>=9.6'} hasBin: true '@n8n_io/ai-assistant-sdk@1.8.2': @@ -6009,10 +6009,6 @@ packages: peerDependencies: acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 - acorn-walk@8.2.0: - resolution: {integrity: sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA==} - engines: {node: '>=0.4.0'} - acorn-walk@8.3.2: resolution: {integrity: sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A==} engines: {node: '>=0.4.0'} @@ -16740,10 +16736,10 @@ snapshots: transitivePeerDependencies: - supports-color - '@n8n/vm2@3.9.20': + '@n8n/vm2@3.9.24': dependencies: - acorn: 8.11.2 - acorn-walk: 8.2.0 + acorn: 8.12.1 + acorn-walk: 8.3.2 '@n8n_io/ai-assistant-sdk@1.8.2': dependencies: @@ -19394,15 +19390,13 @@ snapshots: acorn-globals@7.0.1: dependencies: - acorn: 8.11.2 + acorn: 8.12.1 acorn-walk: 8.3.2 acorn-jsx@5.3.2(acorn@8.11.2): dependencies: acorn: 8.11.2 - acorn-walk@8.2.0: {} - acorn-walk@8.3.2: {} acorn@7.4.1: {} @@ -23390,7 +23384,7 @@ snapshots: jsdom@20.0.2: dependencies: abab: 2.0.6 - acorn: 8.11.2 + acorn: 8.12.1 acorn-globals: 7.0.1 cssom: 0.5.0 cssstyle: 2.3.0 @@ -24246,7 +24240,7 @@ snapshots: mlly@1.4.2: dependencies: - acorn: 8.11.2 + acorn: 8.12.1 pathe: 1.1.2 pkg-types: 1.0.3 ufo: 1.3.2 @@ -27229,7 +27223,7 @@ snapshots: unplugin@1.0.1: dependencies: - acorn: 8.11.2 + acorn: 8.12.1 chokidar: 3.5.2 webpack-sources: 3.2.3 webpack-virtual-modules: 0.5.0 @@ -27243,7 +27237,7 @@ snapshots: unplugin@1.5.1: dependencies: - acorn: 8.11.2 + acorn: 8.12.1 chokidar: 3.5.2 webpack-sources: 3.2.3 webpack-virtual-modules: 0.6.1