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 0f32e0ea56dde..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", @@ -13,7 +13,7 @@ "build:backend": "turbo run build:backend", "build:frontend": "turbo run build:frontend", "build:nodes": "turbo run build:nodes", - "typecheck": "turbo --filter=!n8n typecheck", + "typecheck": "turbo typecheck", "dev": "turbo run dev --parallel --env-mode=loose --filter=!n8n-design-system --filter=!@n8n/chat", "dev:ai": "turbo run dev --parallel --env-mode=loose --filter=@n8n/nodes-langchain --filter=n8n --filter=n8n-core", "clean": "turbo run clean --parallel", 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/nodes/chains/TextClassifier/TextClassifier.node.ts b/packages/@n8n/nodes-langchain/nodes/chains/TextClassifier/TextClassifier.node.ts index 6a433eaac31a0..0033361a07396 100644 --- a/packages/@n8n/nodes-langchain/nodes/chains/TextClassifier/TextClassifier.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/chains/TextClassifier/TextClassifier.node.ts @@ -46,7 +46,7 @@ export class TextClassifier implements INodeType { resources: { primaryDocumentation: [ { - url: 'https://docs.n8n.io/integrations/builtin/cluster-nodes/root-nodes/n8n-nodes-langchain.chainllm/', + url: 'https://docs.n8n.io/integrations/builtin/cluster-nodes/root-nodes/n8n-nodes-langchain.text-classifier/', }, ], }, @@ -203,20 +203,27 @@ export class TextClassifier implements INodeType { discard: 'If there is not a very fitting category, select none of the categories.', }[fallback]; - const systemPromptTemplate = SystemMessagePromptTemplate.fromTemplate( - `${options.systemPromptTemplate ?? SYSTEM_PROMPT_TEMPLATE} -{format_instructions} -${multiClassPrompt} -${fallbackPrompt}`, - ); - const returnData: INodeExecutionData[][] = Array.from( { length: categories.length + (fallback === 'other' ? 1 : 0) }, (_) => [], ); for (let itemIdx = 0; itemIdx < items.length; itemIdx++) { + const item = items[itemIdx]; + item.pairedItem = { item: itemIdx }; const input = this.getNodeParameter('inputText', itemIdx) as string; const inputPrompt = new HumanMessage(input); + + const systemPromptTemplateOpt = this.getNodeParameter( + 'options.systemPromptTemplate', + itemIdx, + ) as string; + const systemPromptTemplate = SystemMessagePromptTemplate.fromTemplate( + `${systemPromptTemplateOpt ?? SYSTEM_PROMPT_TEMPLATE} +{format_instructions} +${multiClassPrompt} +${fallbackPrompt}`, + ); + const messages = [ await systemPromptTemplate.format({ categories: categories.map((cat) => cat.category).join(', '), @@ -227,13 +234,27 @@ ${fallbackPrompt}`, const prompt = ChatPromptTemplate.fromMessages(messages); const chain = prompt.pipe(llm).pipe(parser).withConfig(getTracingConfig(this)); - const output = await chain.invoke(messages); - categories.forEach((cat, idx) => { - if (output[cat.category]) returnData[idx].push(items[itemIdx]); - }); - if (fallback === 'other' && output.fallback) - returnData[returnData.length - 1].push(items[itemIdx]); + try { + const output = await chain.invoke(messages); + + categories.forEach((cat, idx) => { + if (output[cat.category]) returnData[idx].push(item); + }); + if (fallback === 'other' && output.fallback) returnData[returnData.length - 1].push(item); + } catch (error) { + if (this.continueOnFail(error)) { + returnData[0].push({ + json: { error: error.message }, + pairedItem: { item: itemIdx }, + }); + + continue; + } + + throw error; + } } + return returnData; } } 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/@n8n_io/eslint-config/local-rules.js b/packages/@n8n_io/eslint-config/local-rules.js index c9b533a032e1e..152415e14c8d0 100644 --- a/packages/@n8n_io/eslint-config/local-rules.js +++ b/packages/@n8n_io/eslint-config/local-rules.js @@ -448,6 +448,36 @@ module.exports = { }; }, }, + + 'no-type-unsafe-event-emitter': { + meta: { + type: 'problem', + docs: { + description: 'Disallow extending from `EventEmitter`, which is not type-safe.', + recommended: 'error', + }, + messages: { + noExtendsEventEmitter: 'Extend from the type-safe `TypedEmitter` class instead.', + }, + }, + create(context) { + return { + ClassDeclaration(node) { + if ( + node.superClass && + node.superClass.type === 'Identifier' && + node.superClass.name === 'EventEmitter' && + node.id.name !== 'TypedEmitter' + ) { + context.report({ + node: node.superClass, + messageId: 'noExtendsEventEmitter', + }); + } + }, + }; + }, + }, }; const isJsonParseCall = (node) => diff --git a/packages/cli/.eslintrc.js b/packages/cli/.eslintrc.js index ac66574887551..bb1041607ddcd 100644 --- a/packages/cli/.eslintrc.js +++ b/packages/cli/.eslintrc.js @@ -21,6 +21,7 @@ module.exports = { rules: { 'n8n-local-rules/no-dynamic-import-template': 'error', 'n8n-local-rules/misplaced-n8n-typeorm-import': 'error', + 'n8n-local-rules/no-type-unsafe-event-emitter': 'error', complexity: 'error', // TODO: Remove this @@ -44,6 +45,12 @@ module.exports = { 'n8n-local-rules/misplaced-n8n-typeorm-import': 'off', }, }, + { + files: ['./test/**/*.ts'], + rules: { + 'n8n-local-rules/no-type-unsafe-event-emitter': 'off', + }, + }, { files: ['./src/decorators/**/*.ts'], rules: { diff --git a/packages/cli/package.json b/packages/cli/package.json index 60ace14d9bce8..ca8f192e40152 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 bef45bb1433d2..ab150e2947328 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); @@ -134,7 +134,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'); @@ -261,14 +262,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/ActiveExecutions.ts b/packages/cli/src/ActiveExecutions.ts index 97313d5cb2f07..c1a6e8ffd65a9 100644 --- a/packages/cli/src/ActiveExecutions.ts +++ b/packages/cli/src/ActiveExecutions.ts @@ -12,6 +12,7 @@ import { ExecutionCancelledError, sleep, } from 'n8n-workflow'; +import { strict as assert } from 'node:assert'; import type { ExecutionPayload, @@ -74,9 +75,7 @@ export class ActiveExecutions { } executionId = await this.executionRepository.createNewExecution(fullExecutionData); - if (executionId === undefined) { - throw new ApplicationError('There was an issue assigning an execution id to the execution'); - } + assert(executionId); await this.concurrencyControl.throttle({ mode, executionId }); executionStatus = 'running'; diff --git a/packages/cli/src/InternalHooks.ts b/packages/cli/src/InternalHooks.ts index dedfb46b0cb3f..46637106b7a47 100644 --- a/packages/cli/src/InternalHooks.ts +++ b/packages/cli/src/InternalHooks.ts @@ -1,31 +1,11 @@ import { Service } from 'typedi'; import { snakeCase } from 'change-case'; -import { get as pslGet } from 'psl'; -import type { - ExecutionStatus, - INodesGraphResult, - IRun, - ITelemetryTrackProperties, - IWorkflowBase, -} from 'n8n-workflow'; -import { TelemetryHelpers } from 'n8n-workflow'; - -import config from '@/config'; -import { N8N_VERSION } from '@/constants'; +import type { ITelemetryTrackProperties } from 'n8n-workflow'; import type { AuthProviderType } from '@db/entities/AuthIdentity'; import type { User } from '@db/entities/User'; -import { SharedWorkflowRepository } from '@db/repositories/sharedWorkflow.repository'; -import { determineFinalExecutionStatus } from '@/executionLifecycleHooks/shared/sharedHookFunctions'; -import type { - ITelemetryUserDeletionData, - IWorkflowDb, - IExecutionTrackProperties, -} from '@/Interfaces'; +import type { ITelemetryUserDeletionData } 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'; /** @@ -37,10 +17,10 @@ import { MessageEventBus } from './eventbus/MessageEventBus/MessageEventBus'; export class InternalHooks { constructor( private readonly telemetry: Telemetry, - 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 private readonly _eventBus: MessageEventBus, // needed until we decouple telemetry ) { workflowStatisticsService.on('telemetry.onFirstProductionWorkflowSuccess', (metrics) => @@ -69,217 +49,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, - workflow: IWorkflowBase, - runData?: IRun, - userId?: string, - ) { - if (!workflow.id) { - return; - } - - if (runData?.status === 'waiting') { - // No need to send telemetry or logs when the workflow hasn't finished yet. - return; - } - - const telemetryProperties: IExecutionTrackProperties = { - workflow_id: workflow.id, - is_manual: false, - version_cli: N8N_VERSION, - success: false, - }; - - if (userId) { - telemetryProperties.user_id = userId; - } - - if (runData?.data.resultData.error?.message?.includes('canceled')) { - runData.status = 'canceled'; - } - - telemetryProperties.success = !!runData?.finished; - - // const executionStatus: ExecutionStatus = runData?.status ?? 'unknown'; - const executionStatus: ExecutionStatus = runData - ? determineFinalExecutionStatus(runData) - : 'unknown'; - - if (runData !== undefined) { - telemetryProperties.execution_mode = runData.mode; - telemetryProperties.is_manual = runData.mode === 'manual'; - - let nodeGraphResult: INodesGraphResult | null = null; - - if (!telemetryProperties.success && runData?.data.resultData.error) { - telemetryProperties.error_message = runData?.data.resultData.error.message; - let errorNodeName = - 'node' in runData?.data.resultData.error - ? runData?.data.resultData.error.node?.name - : undefined; - telemetryProperties.error_node_type = - 'node' in runData?.data.resultData.error - ? runData?.data.resultData.error.node?.type - : undefined; - - if (runData.data.resultData.lastNodeExecuted) { - const lastNode = TelemetryHelpers.getNodeTypeForName( - workflow, - runData.data.resultData.lastNodeExecuted, - ); - - if (lastNode !== undefined) { - telemetryProperties.error_node_type = lastNode.type; - errorNodeName = lastNode.name; - } - } - - if (telemetryProperties.is_manual) { - nodeGraphResult = TelemetryHelpers.generateNodesGraph(workflow, this.nodeTypes); - telemetryProperties.node_graph = nodeGraphResult.nodeGraph; - telemetryProperties.node_graph_string = JSON.stringify(nodeGraphResult.nodeGraph); - - if (errorNodeName) { - telemetryProperties.error_node_id = nodeGraphResult.nameIndices[errorNodeName]; - } - } - } - - if (telemetryProperties.is_manual) { - if (!nodeGraphResult) { - nodeGraphResult = TelemetryHelpers.generateNodesGraph(workflow, this.nodeTypes); - } - - let userRole: 'owner' | 'sharee' | undefined = undefined; - if (userId) { - const role = await this.sharedWorkflowRepository.findSharingRole(userId, workflow.id); - if (role) { - userRole = role === 'workflow:owner' ? 'owner' : 'sharee'; - } - } - - const manualExecEventProperties: ITelemetryTrackProperties = { - user_id: userId, - workflow_id: workflow.id, - status: executionStatus, - executionStatus: runData?.status ?? 'unknown', - error_message: telemetryProperties.error_message as string, - error_node_type: telemetryProperties.error_node_type, - node_graph_string: telemetryProperties.node_graph_string as string, - error_node_id: telemetryProperties.error_node_id as string, - webhook_domain: null, - sharing_role: userRole, - }; - - if (!manualExecEventProperties.node_graph_string) { - nodeGraphResult = TelemetryHelpers.generateNodesGraph(workflow, this.nodeTypes); - manualExecEventProperties.node_graph_string = JSON.stringify(nodeGraphResult.nodeGraph); - } - - if (runData.data.startData?.destinationNode) { - const telemetryPayload = { - ...manualExecEventProperties, - node_type: TelemetryHelpers.getNodeTypeForName( - workflow, - runData.data.startData?.destinationNode, - )?.type, - node_id: nodeGraphResult.nameIndices[runData.data.startData?.destinationNode], - }; - - this.telemetry.track('Manual node exec finished', telemetryPayload); - } else { - nodeGraphResult.webhookNodeNames.forEach((name: string) => { - const execJson = runData.data.resultData.runData[name]?.[0]?.data?.main?.[0]?.[0] - ?.json as { headers?: { origin?: string } }; - if (execJson?.headers?.origin && execJson.headers.origin !== '') { - manualExecEventProperties.webhook_domain = pslGet( - execJson.headers.origin.replace(/^https?:\/\//, ''), - ); - } - }); - - this.telemetry.track('Manual workflow exec finished', manualExecEventProperties); - } - } - } - - this.telemetry.trackWorkflowExecution(telemetryProperties); - } - onWorkflowSharingUpdate(workflowId: string, userId: string, userList: string[]) { const properties: ITelemetryTrackProperties = { workflow_id: workflowId, diff --git a/packages/cli/src/PublicApi/types.ts b/packages/cli/src/PublicApi/types.ts index 5ac8bb3ed7685..631a8c09d7236 100644 --- a/packages/cli/src/PublicApi/types.ts +++ b/packages/cli/src/PublicApi/types.ts @@ -73,6 +73,7 @@ export declare namespace WorkflowRequest { workflowId?: number; active: boolean; name?: string; + projectId?: string; } >; @@ -83,6 +84,11 @@ export declare namespace WorkflowRequest { type Activate = Get; type GetTags = Get; type UpdateTags = AuthenticatedRequest<{ id: string }, {}, TagEntity[]>; + type Transfer = AuthenticatedRequest< + { workflowId: string }, + {}, + { destinationProjectId: string } + >; } export declare namespace UserRequest { @@ -137,6 +143,12 @@ export declare namespace CredentialRequest { >; type Delete = AuthenticatedRequest<{ id: string }, {}, {}, Record>; + + type Transfer = AuthenticatedRequest< + { workflowId: string }, + {}, + { destinationProjectId: string } + >; } export type OperationID = 'getUsers' | 'getUser'; diff --git a/packages/cli/src/PublicApi/v1/handlers/credentials/credentials.handler.ts b/packages/cli/src/PublicApi/v1/handlers/credentials/credentials.handler.ts index 4da7635831340..57b6bd66f6841 100644 --- a/packages/cli/src/PublicApi/v1/handlers/credentials/credentials.handler.ts +++ b/packages/cli/src/PublicApi/v1/handlers/credentials/credentials.handler.ts @@ -19,6 +19,8 @@ import { toJsonSchema, } from './credentials.service'; import { Container } from 'typedi'; +import { z } from 'zod'; +import { EnterpriseCredentialsService } from '@/credentials/credentials.service.ee'; export = { createCredential: [ @@ -44,6 +46,20 @@ export = { } }, ], + transferCredential: [ + projectScope('credential:move', 'credential'), + async (req: CredentialRequest.Transfer, res: express.Response) => { + const body = z.object({ destinationProjectId: z.string() }).parse(req.body); + + await Container.get(EnterpriseCredentialsService).transferOne( + req.user, + req.params.workflowId, + body.destinationProjectId, + ); + + res.status(204).send(); + }, + ], deleteCredential: [ projectScope('credential:delete', 'credential'), async ( diff --git a/packages/cli/src/PublicApi/v1/handlers/credentials/spec/paths/credentials.id.transfer.yml b/packages/cli/src/PublicApi/v1/handlers/credentials/spec/paths/credentials.id.transfer.yml new file mode 100644 index 0000000000000..a9e9c5cf7c229 --- /dev/null +++ b/packages/cli/src/PublicApi/v1/handlers/credentials/spec/paths/credentials.id.transfer.yml @@ -0,0 +1,31 @@ +put: + x-eov-operation-id: transferCredential + x-eov-operation-handler: v1/handlers/credentials/credentials.handler + tags: + - Workflow + summary: Transfer a credential to another project. + description: Transfer a credential to another project. + parameters: + - $ref: '../schemas/parameters/credentialId.yml' + requestBody: + description: Destination project for the credential transfer. + content: + application/json: + schema: + type: object + properties: + destinationProjectId: + type: string + description: The ID of the project to transfer the credential to. + required: + - destinationProjectId + required: true + responses: + '200': + description: Operation successful. + '400': + $ref: '../../../../shared/spec/responses/badRequest.yml' + '401': + $ref: '../../../../shared/spec/responses/unauthorized.yml' + '404': + $ref: '../../../../shared/spec/responses/notFound.yml' diff --git a/packages/cli/src/PublicApi/v1/handlers/credentials/spec/schemas/parameters/credentialId.yml b/packages/cli/src/PublicApi/v1/handlers/credentials/spec/schemas/parameters/credentialId.yml new file mode 100644 index 0000000000000..f16676ce0b96c --- /dev/null +++ b/packages/cli/src/PublicApi/v1/handlers/credentials/spec/schemas/parameters/credentialId.yml @@ -0,0 +1,6 @@ +name: id +in: path +description: The ID of the credential. +required: true +schema: + type: string diff --git a/packages/cli/src/PublicApi/v1/handlers/projects/projects.handler.ts b/packages/cli/src/PublicApi/v1/handlers/projects/projects.handler.ts new file mode 100644 index 0000000000000..e61e808daf301 --- /dev/null +++ b/packages/cli/src/PublicApi/v1/handlers/projects/projects.handler.ts @@ -0,0 +1,65 @@ +import { globalScope, isLicensed, validCursor } from '../../shared/middlewares/global.middleware'; +import type { Response } from 'express'; +import type { ProjectRequest } from '@/requests'; +import type { PaginatedRequest } from '@/PublicApi/types'; +import Container from 'typedi'; +import { ProjectController } from '@/controllers/project.controller'; +import { ProjectRepository } from '@/databases/repositories/project.repository'; +import { encodeNextCursor } from '../../shared/services/pagination.service'; + +type Create = ProjectRequest.Create; +type Update = ProjectRequest.Update; +type Delete = ProjectRequest.Delete; +type GetAll = PaginatedRequest; + +export = { + createProject: [ + isLicensed('feat:projectRole:admin'), + globalScope('project:create'), + async (req: Create, res: Response) => { + const project = await Container.get(ProjectController).createProject(req); + + return res.status(201).json(project); + }, + ], + updateProject: [ + isLicensed('feat:projectRole:admin'), + globalScope('project:update'), + async (req: Update, res: Response) => { + await Container.get(ProjectController).updateProject(req); + + return res.status(204).send(); + }, + ], + deleteProject: [ + isLicensed('feat:projectRole:admin'), + globalScope('project:delete'), + async (req: Delete, res: Response) => { + await Container.get(ProjectController).deleteProject(req); + + return res.status(204).send(); + }, + ], + getProjects: [ + isLicensed('feat:projectRole:admin'), + globalScope('project:list'), + validCursor, + async (req: GetAll, res: Response) => { + const { offset = 0, limit = 100 } = req.query; + + const [projects, count] = await Container.get(ProjectRepository).findAndCount({ + skip: offset, + take: limit, + }); + + return res.json({ + data: projects, + nextCursor: encodeNextCursor({ + offset, + limit, + numberOfTotalRecords: count, + }), + }); + }, + ], +}; diff --git a/packages/cli/src/PublicApi/v1/handlers/projects/spec/paths/projects.projectId.yml b/packages/cli/src/PublicApi/v1/handlers/projects/spec/paths/projects.projectId.yml new file mode 100644 index 0000000000000..a5aab19b3d6ed --- /dev/null +++ b/packages/cli/src/PublicApi/v1/handlers/projects/spec/paths/projects.projectId.yml @@ -0,0 +1,43 @@ +delete: + x-eov-operation-id: deleteProject + x-eov-operation-handler: v1/handlers/projects/projects.handler + tags: + - Projects + summary: Delete a project + description: Delete a project from your instance. + parameters: + - $ref: '../schemas/parameters/projectId.yml' + responses: + '204': + description: Operation successful. + '401': + $ref: '../../../../shared/spec/responses/unauthorized.yml' + '403': + $ref: '../../../../shared/spec/responses/forbidden.yml' + '404': + $ref: '../../../../shared/spec/responses/notFound.yml' +put: + x-eov-operation-id: updateProject + x-eov-operation-handler: v1/handlers/projects/projects.handler + tags: + - Project + summary: Update a project + description: Update a project. + requestBody: + description: Updated project object. + content: + application/json: + schema: + $ref: '../schemas/project.yml' + required: true + responses: + '204': + description: Operation successful. + '400': + $ref: '../../../../shared/spec/responses/badRequest.yml' + '401': + $ref: '../../../../shared/spec/responses/unauthorized.yml' + '403': + $ref: '../../../../shared/spec/responses/forbidden.yml' + '404': + $ref: '../../../../shared/spec/responses/notFound.yml' diff --git a/packages/cli/src/PublicApi/v1/handlers/projects/spec/paths/projects.yml b/packages/cli/src/PublicApi/v1/handlers/projects/spec/paths/projects.yml new file mode 100644 index 0000000000000..1babd3dd6ea03 --- /dev/null +++ b/packages/cli/src/PublicApi/v1/handlers/projects/spec/paths/projects.yml @@ -0,0 +1,40 @@ +post: + x-eov-operation-id: createProject + x-eov-operation-handler: v1/handlers/projects/projects.handler + tags: + - Projects + summary: Create a project + description: Create a project in your instance. + requestBody: + description: Payload for project to create. + content: + application/json: + schema: + $ref: '../schemas/project.yml' + required: true + responses: + '201': + description: Operation successful. + '400': + $ref: '../../../../shared/spec/responses/badRequest.yml' + '401': + $ref: '../../../../shared/spec/responses/unauthorized.yml' +get: + x-eov-operation-id: getProjects + x-eov-operation-handler: v1/handlers/projects/projects.handler + tags: + - Projects + summary: Retrieve projects + description: Retrieve projects from your instance. + parameters: + - $ref: '../../../../shared/spec/parameters/limit.yml' + - $ref: '../../../../shared/spec/parameters/cursor.yml' + responses: + '200': + description: Operation successful. + content: + application/json: + schema: + $ref: '../schemas/projectList.yml' + '401': + $ref: '../../../../shared/spec/responses/unauthorized.yml' diff --git a/packages/cli/src/PublicApi/v1/handlers/projects/spec/schemas/parameters/projectId.yml b/packages/cli/src/PublicApi/v1/handlers/projects/spec/schemas/parameters/projectId.yml new file mode 100644 index 0000000000000..32961f46016f9 --- /dev/null +++ b/packages/cli/src/PublicApi/v1/handlers/projects/spec/schemas/parameters/projectId.yml @@ -0,0 +1,6 @@ +name: projectId +in: path +description: The ID of the project. +required: true +schema: + type: string diff --git a/packages/cli/src/PublicApi/v1/handlers/projects/spec/schemas/project.yml b/packages/cli/src/PublicApi/v1/handlers/projects/spec/schemas/project.yml new file mode 100644 index 0000000000000..7a4d2ec432bdd --- /dev/null +++ b/packages/cli/src/PublicApi/v1/handlers/projects/spec/schemas/project.yml @@ -0,0 +1,13 @@ +type: object +additionalProperties: false +required: + - name +properties: + id: + type: string + readOnly: true + name: + type: string + type: + type: string + readOnly: true diff --git a/packages/cli/src/PublicApi/v1/handlers/projects/spec/schemas/projectList.yml b/packages/cli/src/PublicApi/v1/handlers/projects/spec/schemas/projectList.yml new file mode 100644 index 0000000000000..7d88be72fb008 --- /dev/null +++ b/packages/cli/src/PublicApi/v1/handlers/projects/spec/schemas/projectList.yml @@ -0,0 +1,11 @@ +type: object +properties: + data: + type: array + items: + $ref: './project.yml' + nextCursor: + type: string + description: Paginate through projects by setting the cursor parameter to a nextCursor attribute returned by a previous request. Default value fetches the first "page" of the collection. + nullable: true + example: MTIzZTQ1NjctZTg5Yi0xMmQzLWE0NTYtNDI2NjE0MTc0MDA diff --git a/packages/cli/src/PublicApi/v1/handlers/users/spec/paths/users.id.role.yml b/packages/cli/src/PublicApi/v1/handlers/users/spec/paths/users.id.role.yml new file mode 100644 index 0000000000000..92993adf7f9d4 --- /dev/null +++ b/packages/cli/src/PublicApi/v1/handlers/users/spec/paths/users.id.role.yml @@ -0,0 +1,31 @@ +patch: + x-eov-operation-id: changeRole + x-eov-operation-handler: v1/handlers/users/users.handler.ee + tags: + - User + summary: Change a user's global role + description: Change a user's global role + parameters: + - $ref: '../schemas/parameters/userIdentifier.yml' + requestBody: + description: New role for the user + required: true + content: + application/json: + schema: + type: object + properties: + newRoleName: + type: string + enum: [global:admin, global:member] + required: + - newRoleName + responses: + '200': + description: Operation successful. + '401': + $ref: '../../../../shared/spec/responses/unauthorized.yml' + '403': + $ref: '../../../../shared/spec/responses/forbidden.yml' + '404': + $ref: '../../../../shared/spec/responses/notFound.yml' diff --git a/packages/cli/src/PublicApi/v1/handlers/users/spec/paths/users.id.yml b/packages/cli/src/PublicApi/v1/handlers/users/spec/paths/users.id.yml index 0d3c86c4cef97..f3dcae00534c4 100644 --- a/packages/cli/src/PublicApi/v1/handlers/users/spec/paths/users.id.yml +++ b/packages/cli/src/PublicApi/v1/handlers/users/spec/paths/users.id.yml @@ -17,3 +17,21 @@ get: $ref: '../schemas/user.yml' '401': $ref: '../../../../shared/spec/responses/unauthorized.yml' +delete: + x-eov-operation-id: deleteUser + x-eov-operation-handler: v1/handlers/users/users.handler.ee + tags: + - User + summary: Delete a user + description: Delete a user from your instance. + parameters: + - $ref: '../schemas/parameters/userIdentifier.yml' + responses: + '204': + description: Operation successful. + '401': + $ref: '../../../../shared/spec/responses/unauthorized.yml' + '403': + $ref: '../../../../shared/spec/responses/forbidden.yml' + '404': + $ref: '../../../../shared/spec/responses/notFound.yml' diff --git a/packages/cli/src/PublicApi/v1/handlers/users/spec/paths/users.yml b/packages/cli/src/PublicApi/v1/handlers/users/spec/paths/users.yml index 6a18e8fcc5adc..e767ab33cc16b 100644 --- a/packages/cli/src/PublicApi/v1/handlers/users/spec/paths/users.yml +++ b/packages/cli/src/PublicApi/v1/handlers/users/spec/paths/users.yml @@ -26,3 +26,53 @@ get: $ref: '../schemas/userList.yml' '401': $ref: '../../../../shared/spec/responses/unauthorized.yml' +post: + x-eov-operation-id: createUser + x-eov-operation-handler: v1/handlers/users/users.handler.ee + tags: + - User + summary: Create multiple users + description: Create one or more users. + requestBody: + description: Array of users to be created. + required: true + content: + application/json: + schema: + type: array + items: + type: object + properties: + email: + type: string + format: email + role: + type: string + enum: [global:admin, global:member] + required: + - email + responses: + '200': + description: Operation successful. + content: + application/json: + schema: + type: object + properties: + user: + type: object + properties: + id: + type: string + email: + type: string + inviteAcceptUrl: + type: string + emailSent: + type: boolean + error: + type: string + '401': + $ref: '../../../../shared/spec/responses/unauthorized.yml' + '403': + $ref: '../../../../shared/spec/responses/forbidden.yml' diff --git a/packages/cli/src/PublicApi/v1/handlers/users/users.handler.ee.ts b/packages/cli/src/PublicApi/v1/handlers/users/users.handler.ee.ts index b9f88bd52d0f0..53c568ee69acd 100644 --- a/packages/cli/src/PublicApi/v1/handlers/users/users.handler.ee.ts +++ b/packages/cli/src/PublicApi/v1/handlers/users/users.handler.ee.ts @@ -6,12 +6,20 @@ import { clean, getAllUsersAndCount, getUser } from './users.service.ee'; import { encodeNextCursor } from '../../shared/services/pagination.service'; import { globalScope, + isLicensed, validCursor, validLicenseWithUserQuota, } from '../../shared/middlewares/global.middleware'; import type { UserRequest } from '@/requests'; import { InternalHooks } from '@/InternalHooks'; import { ProjectRelationRepository } from '@/databases/repositories/projectRelation.repository'; +import type { Response } from 'express'; +import { InvitationController } from '@/controllers/invitation.controller'; +import { UsersController } from '@/controllers/users.controller'; + +type Create = UserRequest.Invite; +type Delete = UserRequest.Delete; +type ChangeRole = UserRequest.ChangeRole; export = { getUser: [ @@ -74,4 +82,29 @@ export = { }); }, ], + createUser: [ + globalScope('user:create'), + async (req: Create, res: Response) => { + const usersInvited = await Container.get(InvitationController).inviteUser(req); + + return res.status(201).json(usersInvited); + }, + ], + deleteUser: [ + globalScope('user:delete'), + async (req: Delete, res: Response) => { + await Container.get(UsersController).deleteUser(req); + + return res.status(204).send(); + }, + ], + changeRole: [ + isLicensed('feat:advancedPermissions'), + globalScope('user:changeRole'), + async (req: ChangeRole, res: Response) => { + await Container.get(UsersController).changeGlobalRole(req); + + return res.status(204).send(); + }, + ], }; diff --git a/packages/cli/src/PublicApi/v1/handlers/workflows/spec/paths/workflows.id.transfer.yml b/packages/cli/src/PublicApi/v1/handlers/workflows/spec/paths/workflows.id.transfer.yml new file mode 100644 index 0000000000000..3997647e1dafa --- /dev/null +++ b/packages/cli/src/PublicApi/v1/handlers/workflows/spec/paths/workflows.id.transfer.yml @@ -0,0 +1,31 @@ +put: + x-eov-operation-id: transferWorkflow + x-eov-operation-handler: v1/handlers/workflows/workflows.handler + tags: + - Workflow + summary: Transfer a workflow to another project. + description: Transfer a workflow to another project. + parameters: + - $ref: '../schemas/parameters/workflowId.yml' + requestBody: + description: Destination project information for the workflow transfer. + content: + application/json: + schema: + type: object + properties: + destinationProjectId: + type: string + description: The ID of the project to transfer the workflow to. + required: + - destinationProjectId + required: true + responses: + '200': + description: Operation successful. + '400': + $ref: '../../../../shared/spec/responses/badRequest.yml' + '401': + $ref: '../../../../shared/spec/responses/unauthorized.yml' + '404': + $ref: '../../../../shared/spec/responses/notFound.yml' diff --git a/packages/cli/src/PublicApi/v1/handlers/workflows/spec/paths/workflows.yml b/packages/cli/src/PublicApi/v1/handlers/workflows/spec/paths/workflows.yml index 6db149195df26..1024e36cb5948 100644 --- a/packages/cli/src/PublicApi/v1/handlers/workflows/spec/paths/workflows.yml +++ b/packages/cli/src/PublicApi/v1/handlers/workflows/spec/paths/workflows.yml @@ -52,6 +52,14 @@ get: schema: type: string example: My Workflow + - name: projectId + in: query + required: false + explode: false + allowReserved: true + schema: + type: string + example: VmwOO9HeTEj20kxM - $ref: '../../../../shared/spec/parameters/limit.yml' - $ref: '../../../../shared/spec/parameters/cursor.yml' responses: diff --git a/packages/cli/src/PublicApi/v1/handlers/workflows/workflows.handler.ts b/packages/cli/src/PublicApi/v1/handlers/workflows/workflows.handler.ts index 5139cb686fd60..4063d0b611d35 100644 --- a/packages/cli/src/PublicApi/v1/handlers/workflows/workflows.handler.ts +++ b/packages/cli/src/PublicApi/v1/handlers/workflows/workflows.handler.ts @@ -33,6 +33,8 @@ import { TagRepository } from '@/databases/repositories/tag.repository'; import { WorkflowRepository } from '@/databases/repositories/workflow.repository'; import { ProjectRepository } from '@/databases/repositories/project.repository'; import { EventService } from '@/eventbus/event.service'; +import { z } from 'zod'; +import { EnterpriseWorkflowService } from '@/workflows/workflow.service.ee'; export = { createWorkflow: [ @@ -58,15 +60,31 @@ 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); }, ], + transferWorkflow: [ + projectScope('workflow:move', 'workflow'), + async (req: WorkflowRequest.Transfer, res: express.Response) => { + const body = z.object({ destinationProjectId: z.string() }).parse(req.body); + + await Container.get(EnterpriseWorkflowService).transferOne( + req.user, + req.params.workflowId, + body.destinationProjectId, + ); + + res.status(204).send(); + }, + ], deleteWorkflow: [ projectScope('workflow:delete', 'workflow'), async (req: WorkflowRequest.Get, res: express.Response): Promise => { @@ -112,7 +130,7 @@ export = { getWorkflows: [ validCursor, async (req: WorkflowRequest.GetAll, res: express.Response): Promise => { - const { offset = 0, limit = 100, active, tags, name } = req.query; + const { offset = 0, limit = 100, active, tags, name, projectId } = req.query; const where: FindOptionsWhere = { ...(active !== undefined && { active }), @@ -145,6 +163,10 @@ export = { workflows = workflows.filter((wf) => workflowIds.includes(wf.id)); } + if (projectId) { + workflows = workflows.filter((w) => w.projectId === projectId); + } + if (!workflows.length) { return res.status(200).json({ data: [], @@ -239,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/PublicApi/v1/openapi.yml b/packages/cli/src/PublicApi/v1/openapi.yml index 91eacd53767ad..30c3a73bde0c7 100644 --- a/packages/cli/src/PublicApi/v1/openapi.yml +++ b/packages/cli/src/PublicApi/v1/openapi.yml @@ -32,6 +32,8 @@ tags: description: Operations about source control - name: Variables description: Operations about variables + - name: Projects + description: Operations about projects paths: /audit: @@ -58,18 +60,28 @@ paths: $ref: './handlers/workflows/spec/paths/workflows.id.activate.yml' /workflows/{id}/deactivate: $ref: './handlers/workflows/spec/paths/workflows.id.deactivate.yml' + /workflows/{id}/transfer: + $ref: './handlers/workflows/spec/paths/workflows.id.transfer.yml' + /credentials/{id}/transfer: + $ref: './handlers/credentials/spec/paths/credentials.id.transfer.yml' /workflows/{id}/tags: $ref: './handlers/workflows/spec/paths/workflows.id.tags.yml' /users: $ref: './handlers/users/spec/paths/users.yml' /users/{id}: $ref: './handlers/users/spec/paths/users.id.yml' + /users/{id}/role: + $ref: './handlers/users/spec/paths/users.id.role.yml' /source-control/pull: $ref: './handlers/sourceControl/spec/paths/sourceControl.yml' /variables: $ref: './handlers/variables/spec/paths/variables.yml' /variables/{id}: $ref: './handlers/variables/spec/paths/variables.id.yml' + /projects: + $ref: './handlers/projects/spec/paths/projects.yml' + /projects/{projectId}: + $ref: './handlers/projects/spec/paths/projects.projectId.yml' components: schemas: $ref: './shared/spec/schemas/_index.yml' diff --git a/packages/cli/src/Server.ts b/packages/cli/src/Server.ts index dc63d697e77b8..4c9628bc1bf1d 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'; @@ -81,17 +80,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); } @@ -133,7 +131,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'); } @@ -167,7 +165,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); } @@ -307,7 +305,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, @@ -341,7 +340,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..9538ec91988fd 100644 --- a/packages/cli/src/WorkflowExecuteAdditionalData.ts +++ b/packages/cli/src/WorkflowExecuteAdditionalData.ts @@ -52,7 +52,6 @@ import { Push } from '@/push'; import * as WorkflowHelpers from '@/WorkflowHelpers'; import { findSubworkflowStart, isWorkflowIdValid } from '@/utils'; import { PermissionChecker } from './UserManagement/PermissionChecker'; -import { InternalHooks } from '@/InternalHooks'; import { ExecutionRepository } from '@db/repositories/execution.repository'; import { WorkflowStatisticsService } from '@/services/workflow-statistics.service'; import { SecretsHelper } from './SecretsHelpers'; @@ -548,7 +547,6 @@ function hookFunctionsSave(): IWorkflowExecuteHooks { */ function hookFunctionsSaveWorker(): IWorkflowExecuteHooks { const logger = Container.get(Logger); - const internalHooks = Container.get(InternalHooks); const workflowStatisticsService = Container.get(WorkflowStatisticsService); const eventService = Container.get(EventService); return { @@ -644,13 +642,10 @@ function hookFunctionsSaveWorker(): IWorkflowExecuteHooks { async function (this: WorkflowHooks, runData: IRun): Promise { const { executionId, workflowData: workflow } = this; - void internalHooks.onWorkflowPostExecute(executionId, workflow, runData); eventService.emit('workflow-post-execute', { - workflowId: workflow.id, - workflowName: workflow.name, + workflow, executionId, - success: runData.status === 'success', - isManual: runData.mode === 'manual', + runData, }); }, async function (this: WorkflowHooks, fullRunData: IRun) { @@ -786,7 +781,6 @@ async function executeWorkflow( parentCallbackManager?: CallbackManager; }, ): Promise | IWorkflowExecuteProcess> { - const internalHooks = Container.get(InternalHooks); const externalHooks = Container.get(ExternalHooks); await externalHooks.init(); @@ -932,14 +926,11 @@ async function executeWorkflow( await externalHooks.run('workflow.postExecute', [data, workflowData, executionId]); - void internalHooks.onWorkflowPostExecute(executionId, workflowData, data, additionalData.userId); eventService.emit('workflow-post-execute', { - workflowId: workflowData.id, - workflowName: workflowData.name, + workflow: workflowData, executionId, - success: data.status === 'success', - isManual: data.mode === 'manual', userId: additionalData.userId, + runData: data, }); // subworkflow either finished, or is in status waiting due to a wait node, both cases are considered successes here @@ -1000,23 +991,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..f51a44cc4d2a3 100644 --- a/packages/cli/src/WorkflowRunner.ts +++ b/packages/cli/src/WorkflowRunner.ts @@ -34,7 +34,6 @@ import * as WorkflowHelpers from '@/WorkflowHelpers'; import * as WorkflowExecuteAdditionalData from '@/WorkflowExecuteAdditionalData'; import { generateFailedExecutionFromError } from '@/WorkflowHelpers'; import { PermissionChecker } from '@/UserManagement/PermissionChecker'; -import { InternalHooks } from '@/InternalHooks'; import { Logger } from '@/Logger'; import { WorkflowStaticDataService } from '@/workflows/workflowStaticData.service'; import { EventService } from './eventbus/event.service'; @@ -160,19 +159,11 @@ export class WorkflowRunner { const postExecutePromise = this.activeExecutions.getPostExecutePromise(executionId); postExecutePromise .then(async (executionData) => { - void Container.get(InternalHooks).onWorkflowPostExecute( - executionId, - data.workflowData, - executionData, - data.userId, - ); this.eventService.emit('workflow-post-execute', { - workflowId: data.workflowData.id, - workflowName: data.workflowData.name, + workflow: data.workflowData, executionId, - success: executionData?.status === 'success', - isManual: data.executionMode === 'manual', userId: data.userId, + runData: executionData, }); 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 0b689b8f4cfbe..dc63f53099659 100644 --- a/packages/cli/src/config/schema.ts +++ b/packages/cli/src/config/schema.ts @@ -4,6 +4,7 @@ import { Container } from 'typedi'; import { InstanceSettings } from 'n8n-core'; import { LOG_LEVELS } from 'n8n-workflow'; import { ensureStringArray } from './utils'; +import { GlobalConfig } from '@n8n/config'; convict.addFormat({ name: 'comma-separated-list', @@ -355,149 +356,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, @@ -524,12 +382,17 @@ export const schema = { default: 0, env: 'N8N_USER_MANAGEMENT_JWT_REFRESH_TIMEOUT_HOURS', }, + + /** + * @important Do not remove until after cloud hooks are updated to stop using convict config. + */ isInstanceOwnerSetUp: { // n8n loads this setting from DB on startup doc: "Whether the instance owner's account has been set up", format: Boolean, default: false, }, + authenticationMethod: { doc: 'How to authenticate users (e.g. "email", "ldap", "saml")', format: ['email', 'ldap', 'saml'] as const, @@ -834,6 +697,19 @@ export const schema = { }, }, + /** + * @important Do not remove until after cloud hooks are updated to stop using convict config. + */ + endpoints: { + rest: { + format: String, + default: Container.get(GlobalConfig).endpoints.rest, + }, + }, + + /** + * @important Do not remove until after cloud hooks are updated to stop using convict config. + */ ai: { enabled: { doc: 'Whether AI features are enabled', 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/databases/repositories/execution.repository.ts b/packages/cli/src/databases/repositories/execution.repository.ts index 1ebb22d8eb4bc..a8605147aa751 100644 --- a/packages/cli/src/databases/repositories/execution.repository.ts +++ b/packages/cli/src/databases/repositories/execution.repository.ts @@ -270,17 +270,27 @@ export class ExecutionRepository extends Repository { return rest; } + /** + * Insert a new execution and its execution data using a transaction. + */ async createNewExecution(execution: ExecutionPayload): Promise { - const { data, workflowData, ...rest } = execution; - const { identifiers: inserted } = await this.insert(rest); - const { id: executionId } = inserted[0] as { id: string }; - const { connections, nodes, name, settings } = workflowData ?? {}; - await this.executionDataRepository.insert({ - executionId, - workflowData: { connections, nodes, name, settings, id: workflowData.id }, - data: stringify(data), + return await this.manager.transaction(async (transactionManager) => { + const { data, workflowData, ...rest } = execution; + const insertResult = await transactionManager.insert(ExecutionEntity, rest); + const { id: executionId } = insertResult.identifiers[0] as { id: string }; + + const { connections, nodes, name, settings } = workflowData ?? {}; + await this.executionDataRepository.createExecutionDataForExecution( + { + executionId, + workflowData: { connections, nodes, name, settings, id: workflowData.id }, + data: stringify(data), + }, + transactionManager, + ); + + return String(executionId); }); - return String(executionId); } async markAsCrashed(executionIds: string | string[]) { diff --git a/packages/cli/src/databases/repositories/executionData.repository.ts b/packages/cli/src/databases/repositories/executionData.repository.ts index 5872f9888cd66..013453d998e42 100644 --- a/packages/cli/src/databases/repositories/executionData.repository.ts +++ b/packages/cli/src/databases/repositories/executionData.repository.ts @@ -1,13 +1,32 @@ import { Service } from 'typedi'; +import type { EntityManager } from '@n8n/typeorm'; +import type { IWorkflowBase } from 'n8n-workflow'; import { DataSource, In, Repository } from '@n8n/typeorm'; import { ExecutionData } from '../entities/ExecutionData'; +export interface CreateExecutionDataOpts extends Pick { + workflowData: Pick; +} + @Service() export class ExecutionDataRepository extends Repository { constructor(dataSource: DataSource) { super(ExecutionData, dataSource.manager); } + async createExecutionDataForExecution( + executionData: CreateExecutionDataOpts, + transactionManager: EntityManager, + ) { + const { data, executionId, workflowData } = executionData; + + return await transactionManager.insert(ExecutionData, { + executionId, + data, + workflowData, + }); + } + async findByExecutionIds(executionIds: string[]) { return await this.find({ select: ['workflowData'], diff --git a/packages/cli/src/databases/repositories/sharedWorkflow.repository.ts b/packages/cli/src/databases/repositories/sharedWorkflow.repository.ts index f8ff3523b2eab..4dc54935cb193 100644 --- a/packages/cli/src/databases/repositories/sharedWorkflow.repository.ts +++ b/packages/cli/src/databases/repositories/sharedWorkflow.repository.ts @@ -175,7 +175,7 @@ export class SharedWorkflowRepository extends Repository { }, }); - return sharedWorkflows.map((sw) => sw.workflow); + return sharedWorkflows.map((sw) => ({ ...sw.workflow, projectId: sw.projectId })); } /** 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/MessageEventBus/MessageEventBus.ts b/packages/cli/src/eventbus/MessageEventBus/MessageEventBus.ts index 758eeb5ae590f..eeb868798bf6b 100644 --- a/packages/cli/src/eventbus/MessageEventBus/MessageEventBus.ts +++ b/packages/cli/src/eventbus/MessageEventBus/MessageEventBus.ts @@ -52,6 +52,8 @@ export interface MessageEventBusInitializeOptions { } @Service() +// TODO: Convert to TypedEventEmitter +// eslint-disable-next-line n8n-local-rules/no-type-unsafe-event-emitter export class MessageEventBus extends EventEmitter { private isInitialized = false; diff --git a/packages/cli/src/eventbus/MessageEventBusWriter/MessageEventBusLogWriterWorker.ts b/packages/cli/src/eventbus/MessageEventBusWriter/MessageEventBusLogWriterWorker.ts index 4686a1cf3c860..69d2e8ce26f73 100644 --- a/packages/cli/src/eventbus/MessageEventBusWriter/MessageEventBusLogWriterWorker.ts +++ b/packages/cli/src/eventbus/MessageEventBusWriter/MessageEventBusLogWriterWorker.ts @@ -6,7 +6,6 @@ import type { MessageEventBusLogWriterOptions } from './MessageEventBusLogWriter let logFileBasePath = ''; let loggingPaused = true; let keepFiles = 10; -let fileStatTimer: NodeJS.Timer; let maxLogFileSizeInKB = 102400; function setLogFileBasePath(basePath: string) { @@ -117,7 +116,7 @@ if (!isMainThread) { if (logFileBasePath) { renameAndCreateLogs(); loggingPaused = false; - fileStatTimer = setInterval(async () => { + setInterval(async () => { await checkFileSize(buildLogFileNameWithCounter()); }, 5000); } 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..44277f1de4d0d 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,666 @@ 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', + userId: 'some-id', + workflow: mock({ id: 'some-id', name: 'some-name' }), + runData: mock({ status: 'success', mode: 'manual', data: { resultData: {} } }), + }); + + eventService.emit('workflow-post-execute', payload); + + const { runData: _, workflow: __, ...rest } = payload; + + expect(eventBus.sendWorkflowEvent).toHaveBeenCalledWith({ + eventName: 'n8n.workflow.success', + payload: { + ...rest, + success: true, + isManual: true, + workflowName: 'some-name', + workflowId: 'some-id', + }, + }); + }); + + it('should log on `workflow-post-execute` event for unsuccessful execution', () => { + const runData = mock({ + status: 'error', + mode: 'manual', + 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', + userId: 'some-id', + workflow: mock({ id: 'some-id', name: 'some-name' }), + runData, + }; + + eventService.emit('workflow-post-execute', event); + + const { runData: _, workflow: __, ...rest } = event; + + expect(eventBus.sendWorkflowEvent).toHaveBeenCalledWith({ + eventName: 'n8n.workflow.failed', + payload: { + ...rest, + success: false, + isManual: true, + workflowName: 'some-name', + workflowId: 'some-id', + lastNodeExecuted: 'some-node', + errorNodeType: 'some-type', + errorMessage: 'some-message', + }, + }); + }); + }); + + 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..dcefeac0bd317 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,36 @@ export class AuditEventRelay { } private workflowPostExecute(event: Event['workflow-post-execute']) { + const { runData, workflow, ...rest } = event; + + const payload = { + ...rest, + success: runData?.status === 'success', + isManual: runData?.mode === 'manual', + workflowId: workflow.id, + workflowName: workflow.name, + }; + + if (payload.success) { + void this.eventBus.sendWorkflowEvent({ + eventName: 'n8n.workflow.success', + payload, + }); + + return; + } + void this.eventBus.sendWorkflowEvent({ - eventName: 'n8n.workflow.success', - payload: event, + eventName: 'n8n.workflow.failed', + payload: { + ...payload, + lastNodeExecuted: runData?.data.resultData.lastNodeExecuted, + errorNodeType: + runData?.data.resultData.error && 'node' in runData?.data.resultData.error + ? runData?.data.resultData.error.node?.type + : undefined, + errorMessage: runData?.data.resultData.error?.message.toString(), + }, }); } @@ -253,7 +280,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..bcca98b919637 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': { @@ -40,12 +44,9 @@ export type Event = { 'workflow-post-execute': { executionId: string; - success: boolean; userId?: string; - workflowId: string; - isManual: boolean; - workflowName: string; - metadata?: Record; + workflow: IWorkflowBase; + 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..615c65ea6ef5f 100644 --- a/packages/cli/src/executions/execution-recovery.service.ts +++ b/packages/cli/src/executions/execution-recovery.service.ts @@ -3,7 +3,6 @@ import { Push } from '@/push'; import { jsonStringify, sleep } from 'n8n-workflow'; import { ExecutionRepository } from '@db/repositories/execution.repository'; import { getWorkflowHooksMain } from '@/WorkflowExecuteAdditionalData'; // @TODO: Dependency cycle -import { InternalHooks } from '@/InternalHooks'; // @TODO: Dependency cycle if injected import type { DateTime } from 'luxon'; import type { IRun, ITaskData } from 'n8n-workflow'; import type { EventMessageTypes } from '../eventbus/EventMessageClasses'; @@ -280,22 +279,10 @@ export class ExecutionRecoveryService { private async runHooks(execution: IExecutionResponse) { execution.data ??= { resultData: { runData: {} } }; - await Container.get(InternalHooks).onWorkflowPostExecute(execution.id, execution.workflowData, { - data: execution.data, - finished: false, - mode: execution.mode, - waitTill: execution.waitTill, - startedAt: execution.startedAt, - stoppedAt: execution.stoppedAt, - status: execution.status, - }); - this.eventService.emit('workflow-post-execute', { - workflowId: execution.workflowData.id, - workflowName: execution.workflowData.name, + workflow: execution.workflowData, executionId: execution.id, - success: execution.status === 'success', - isManual: execution.mode === 'manual', + runData: execution, }); const externalHooks = getWorkflowHooksMain( diff --git a/packages/cli/src/license/license.controller.ts b/packages/cli/src/license/license.controller.ts index 1c0ca8c0d68ed..945f3650ea340 100644 --- a/packages/cli/src/license/license.controller.ts +++ b/packages/cli/src/license/license.controller.ts @@ -1,6 +1,8 @@ import { Get, Post, RestController, GlobalScope } from '@/decorators'; import { AuthenticatedRequest, LicenseRequest } from '@/requests'; import { LicenseService } from './license.service'; +import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import type { AxiosError } from 'axios'; @RestController('/license') export class LicenseController { @@ -14,7 +16,18 @@ export class LicenseController { @Post('/enterprise/request_trial') @GlobalScope('license:manage') async requestEnterpriseTrial(req: AuthenticatedRequest) { - await this.licenseService.requestEnterpriseTrial(req.user); + try { + await this.licenseService.requestEnterpriseTrial(req.user); + } catch (error: unknown) { + if (error instanceof Error) { + const errorMsg = + (error as AxiosError<{ message: string }>).response?.data?.message ?? error.message; + + throw new BadRequestError(errorMsg); + } else { + throw new BadRequestError('Failed to request trial'); + } + } } @Post('/activate') diff --git a/packages/cli/src/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/middlewares/listQuery/index.ts b/packages/cli/src/middlewares/listQuery/index.ts index 0c8e2c7427c54..524dcb268e96d 100644 --- a/packages/cli/src/middlewares/listQuery/index.ts +++ b/packages/cli/src/middlewares/listQuery/index.ts @@ -1,8 +1,16 @@ import { filterListQueryMiddleware } from './filter'; import { selectListQueryMiddleware } from './select'; import { paginationListQueryMiddleware } from './pagination'; +import type { ListQuery } from '@/requests'; +import type { NextFunction, Response } from 'express'; -export const listQueryMiddleware = [ +export type ListQueryMiddleware = ( + req: ListQuery.Request, + res: Response, + next: NextFunction, +) => void; + +export const listQueryMiddleware: ListQueryMiddleware[] = [ filterListQueryMiddleware, selectListQueryMiddleware, paginationListQueryMiddleware, diff --git a/packages/cli/src/requests.ts b/packages/cli/src/requests.ts index f6ffa11a75cd5..4fe369857eb7a 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 4ada98a9cf7d7..7cd9843c1d97f 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'), @@ -246,7 +246,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..f92b38987b1e3 100644 --- a/packages/cli/src/telemetry/telemetry-event-relay.service.ts +++ b/packages/cli/src/telemetry/telemetry-event-relay.service.ts @@ -8,6 +8,14 @@ import { License } from '@/License'; import { GlobalConfig } from '@n8n/config'; import { N8N_VERSION } from '@/constants'; import { WorkflowRepository } from '@/databases/repositories/workflow.repository'; +import type { ExecutionStatus, INodesGraphResult, ITelemetryTrackProperties } from 'n8n-workflow'; +import { get as pslGet } from 'psl'; +import { TelemetryHelpers } from 'n8n-workflow'; +import { NodeTypes } from '@/NodeTypes'; +import { SharedWorkflowRepository } from '@/databases/repositories/sharedWorkflow.repository'; +import { ProjectRelationRepository } from '@/databases/repositories/projectRelation.repository'; +import type { IExecutionTrackProperties } from '@/Interfaces'; +import { determineFinalExecutionStatus } from '@/executionLifecycleHooks/shared/sharedHookFunctions'; @Service() export class TelemetryEventRelay { @@ -17,6 +25,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 +112,19 @@ 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); + }); + this.eventService.on('workflow-post-execute', async (event) => { + await this.workflowPostExecute(event); + }); } private teamProjectUpdated({ userId, role, members, projectId }: Event['team-project-updated']) { @@ -431,6 +455,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 +541,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(), @@ -495,4 +591,138 @@ export class TelemetryEventRelay { earliest_workflow_created: firstWorkflow?.createdAt, }); } + + // eslint-disable-next-line complexity + private async workflowPostExecute({ workflow, runData, userId }: Event['workflow-post-execute']) { + if (!workflow.id) { + return; + } + + if (runData?.status === 'waiting') { + // No need to send telemetry or logs when the workflow hasn't finished yet. + return; + } + + const telemetryProperties: IExecutionTrackProperties = { + workflow_id: workflow.id, + is_manual: false, + version_cli: N8N_VERSION, + success: false, + }; + + if (userId) { + telemetryProperties.user_id = userId; + } + + if (runData?.data.resultData.error?.message?.includes('canceled')) { + runData.status = 'canceled'; + } + + telemetryProperties.success = !!runData?.finished; + + // const executionStatus: ExecutionStatus = runData?.status ?? 'unknown'; + const executionStatus: ExecutionStatus = runData + ? determineFinalExecutionStatus(runData) + : 'unknown'; + + if (runData !== undefined) { + telemetryProperties.execution_mode = runData.mode; + telemetryProperties.is_manual = runData.mode === 'manual'; + + let nodeGraphResult: INodesGraphResult | null = null; + + if (!telemetryProperties.success && runData?.data.resultData.error) { + telemetryProperties.error_message = runData?.data.resultData.error.message; + let errorNodeName = + 'node' in runData?.data.resultData.error + ? runData?.data.resultData.error.node?.name + : undefined; + telemetryProperties.error_node_type = + 'node' in runData?.data.resultData.error + ? runData?.data.resultData.error.node?.type + : undefined; + + if (runData.data.resultData.lastNodeExecuted) { + const lastNode = TelemetryHelpers.getNodeTypeForName( + workflow, + runData.data.resultData.lastNodeExecuted, + ); + + if (lastNode !== undefined) { + telemetryProperties.error_node_type = lastNode.type; + errorNodeName = lastNode.name; + } + } + + if (telemetryProperties.is_manual) { + nodeGraphResult = TelemetryHelpers.generateNodesGraph(workflow, this.nodeTypes); + telemetryProperties.node_graph = nodeGraphResult.nodeGraph; + telemetryProperties.node_graph_string = JSON.stringify(nodeGraphResult.nodeGraph); + + if (errorNodeName) { + telemetryProperties.error_node_id = nodeGraphResult.nameIndices[errorNodeName]; + } + } + } + + if (telemetryProperties.is_manual) { + if (!nodeGraphResult) { + nodeGraphResult = TelemetryHelpers.generateNodesGraph(workflow, this.nodeTypes); + } + + let userRole: 'owner' | 'sharee' | undefined = undefined; + if (userId) { + const role = await this.sharedWorkflowRepository.findSharingRole(userId, workflow.id); + if (role) { + userRole = role === 'workflow:owner' ? 'owner' : 'sharee'; + } + } + + const manualExecEventProperties: ITelemetryTrackProperties = { + user_id: userId, + workflow_id: workflow.id, + status: executionStatus, + executionStatus: runData?.status ?? 'unknown', + error_message: telemetryProperties.error_message as string, + error_node_type: telemetryProperties.error_node_type, + node_graph_string: telemetryProperties.node_graph_string as string, + error_node_id: telemetryProperties.error_node_id as string, + webhook_domain: null, + sharing_role: userRole, + }; + + if (!manualExecEventProperties.node_graph_string) { + nodeGraphResult = TelemetryHelpers.generateNodesGraph(workflow, this.nodeTypes); + manualExecEventProperties.node_graph_string = JSON.stringify(nodeGraphResult.nodeGraph); + } + + if (runData.data.startData?.destinationNode) { + const telemetryPayload = { + ...manualExecEventProperties, + node_type: TelemetryHelpers.getNodeTypeForName( + workflow, + runData.data.startData?.destinationNode, + )?.type, + node_id: nodeGraphResult.nameIndices[runData.data.startData?.destinationNode], + }; + + this.telemetry.track('Manual node exec finished', telemetryPayload); + } else { + nodeGraphResult.webhookNodeNames.forEach((name: string) => { + const execJson = runData.data.resultData.runData[name]?.[0]?.data?.main?.[0]?.[0] + ?.json as { headers?: { origin?: string } }; + if (execJson?.headers?.origin && execJson.headers.origin !== '') { + manualExecEventProperties.webhook_domain = pslGet( + execJson.headers.origin.replace(/^https?:\/\//, ''), + ); + } + }); + + this.telemetry.track('Manual workflow exec finished', manualExecEventProperties); + } + } + } + + this.telemetry.trackWorkflowExecution(telemetryProperties); + } } 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/database/repositories/execution.repository.test.ts b/packages/cli/test/integration/database/repositories/execution.repository.test.ts index cfb897d627a56..e777f624299aa 100644 --- a/packages/cli/test/integration/database/repositories/execution.repository.test.ts +++ b/packages/cli/test/integration/database/repositories/execution.repository.test.ts @@ -52,5 +52,34 @@ describe('ExecutionRepository', () => { }); expect(executionData?.data).toEqual('[{"resultData":"1"},{}]'); }); + + it('should not create execution if execution data insert fails', async () => { + const executionRepo = Container.get(ExecutionRepository); + const executionDataRepo = Container.get(ExecutionDataRepository); + + const workflow = await createWorkflow({ settings: { executionOrder: 'v1' } }); + jest + .spyOn(executionDataRepo, 'createExecutionDataForExecution') + .mockRejectedValueOnce(new Error()); + + await expect( + async () => + await executionRepo.createNewExecution({ + workflowId: workflow.id, + data: { + //@ts-expect-error This is not needed for tests + resultData: {}, + }, + workflowData: workflow, + mode: 'manual', + startedAt: new Date(), + status: 'new', + finished: false, + }), + ).rejects.toThrow(); + + const executionEntities = await executionRepo.find(); + expect(executionEntities).toBeEmptyArray(); + }); }); }); diff --git a/packages/cli/test/integration/eventbus.ee.test.ts b/packages/cli/test/integration/eventbus.ee.test.ts index a79c1388b39ee..f21822683b854 100644 --- a/packages/cli/test/integration/eventbus.ee.test.ts +++ b/packages/cli/test/integration/eventbus.ee.test.ts @@ -200,7 +200,7 @@ test('should anonymize audit message to syslog ', async () => { 'message', async function handler005(msg: { command: string; data: any }) { if (msg.command === 'appendMessageToLog') { - const sent = await eventBus.getEventsAll(); + await eventBus.getEventsAll(); await confirmIdInAll(testAuditMessage.id); expect(mockedSyslogClientLog).toHaveBeenCalled(); eventBus.logWriter.worker?.removeListener('message', handler005); @@ -217,7 +217,7 @@ test('should anonymize audit message to syslog ', async () => { 'message', async function handler006(msg: { command: string; data: any }) { if (msg.command === 'appendMessageToLog') { - const sent = await eventBus.getEventsAll(); + await eventBus.getEventsAll(); await confirmIdInAll(testAuditMessage.id); expect(mockedSyslogClientLog).toHaveBeenCalled(); syslogDestination.disable(); diff --git a/packages/cli/test/integration/executions.controller.test.ts b/packages/cli/test/integration/executions.controller.test.ts index ef04dde99bd81..1e6d65a4f8368 100644 --- a/packages/cli/test/integration/executions.controller.test.ts +++ b/packages/cli/test/integration/executions.controller.test.ts @@ -12,7 +12,10 @@ import { WaitTracker } from '@/WaitTracker'; import { createTeamProject, linkUserToProject } from './shared/db/projects'; mockInstance(WaitTracker); -mockInstance(ConcurrencyControlService, { isEnabled: false }); +mockInstance(ConcurrencyControlService, { + // @ts-expect-error Private property + isEnabled: false, +}); const testServer = setupTestServer({ endpointGroups: ['executions'] }); diff --git a/packages/cli/test/integration/project.service.integration.test.ts b/packages/cli/test/integration/project.service.integration.test.ts index 77d388c1617d6..7fe55ade06747 100644 --- a/packages/cli/test/integration/project.service.integration.test.ts +++ b/packages/cli/test/integration/project.service.integration.test.ts @@ -31,7 +31,6 @@ describe('ProjectService', () => { describe('when user has roles in projects where workflow is accessible', () => { it('should return roles and project IDs', async () => { const user = await createUser(); - const secondUser = await createUser(); // @TODO: Needed only to satisfy index in legacy column const firstProject = await createTeamProject('Project 1'); const secondProject = await createTeamProject('Project 2'); @@ -42,17 +41,15 @@ describe('ProjectService', () => { const workflow = await createWorkflow(); await sharedWorkflowRepository.insert({ - userId: user.id, // @TODO: Legacy column projectId: firstProject.id, workflowId: workflow.id, role: 'workflow:owner', }); await sharedWorkflowRepository.insert({ - userId: secondUser.id, // @TODO: Legacy column projectId: secondProject.id, workflowId: workflow.id, - role: 'workflow:user', + role: 'workflow:owner', }); const projectIds = await projectService.findProjectsWorkflowIsIn(workflow.id); @@ -63,9 +60,6 @@ describe('ProjectService', () => { describe('when user has no roles in projects where workflow is accessible', () => { it('should return project IDs but no roles', async () => { - const user = await createUser(); - const secondUser = await createUser(); // @TODO: Needed only to satisfy index in legacy column - const firstProject = await createTeamProject('Project 1'); const secondProject = await createTeamProject('Project 2'); @@ -74,17 +68,15 @@ describe('ProjectService', () => { const workflow = await createWorkflow(); await sharedWorkflowRepository.insert({ - userId: user.id, // @TODO: Legacy column projectId: firstProject.id, workflowId: workflow.id, role: 'workflow:owner', }); await sharedWorkflowRepository.insert({ - userId: secondUser.id, // @TODO: Legacy column projectId: secondProject.id, workflowId: workflow.id, - role: 'workflow:user', + role: 'workflow:owner', }); const projectIds = await projectService.findProjectsWorkflowIsIn(workflow.id); diff --git a/packages/cli/test/integration/prometheus-metrics.test.ts b/packages/cli/test/integration/prometheus-metrics.test.ts index 68c8756a86ec0..9f6a0fad6e89d 100644 --- a/packages/cli/test/integration/prometheus-metrics.test.ts +++ b/packages/cli/test/integration/prometheus-metrics.test.ts @@ -2,17 +2,30 @@ 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 { 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_'); +const globalConfig = Container.get(GlobalConfig); +// @ts-expect-error `metrics` is a readonly property +globalConfig.endpoints.metrics = { + prefix: 'n8n_test_', + includeDefaultMetrics: true, + includeApiEndpoints: true, + includeCacheMetrics: true, + includeMessageEventBusMetrics: true, + includeCredentialTypeLabel: false, + includeNodeTypeLabel: false, + includeWorkflowIdLabel: false, + includeApiPathLabel: true, + includeApiMethodLabel: true, + includeApiStatusCodeLabel: true, +}; const server = setupTestServer({ endpointGroups: ['metrics'] }); const agent = request.agent(server.app); diff --git a/packages/cli/test/integration/pruning.service.test.ts b/packages/cli/test/integration/pruning.service.test.ts index a600b4aabdcab..09a43a5b5b123 100644 --- a/packages/cli/test/integration/pruning.service.test.ts +++ b/packages/cli/test/integration/pruning.service.test.ts @@ -98,7 +98,6 @@ describe('softDeleteOnPruningCycle()', () => { }); test.each<[ExecutionStatus, Partial]>([ - ['warning', { startedAt: now, stoppedAt: now }], ['unknown', { startedAt: now, stoppedAt: now }], ['canceled', { startedAt: now, stoppedAt: now }], ['crashed', { startedAt: now, stoppedAt: now }], @@ -191,7 +190,6 @@ describe('softDeleteOnPruningCycle()', () => { }); test.each<[ExecutionStatus, Partial]>([ - ['warning', { startedAt: yesterday, stoppedAt: yesterday }], ['unknown', { startedAt: yesterday, stoppedAt: yesterday }], ['canceled', { startedAt: yesterday, stoppedAt: yesterday }], ['crashed', { startedAt: yesterday, stoppedAt: yesterday }], diff --git a/packages/cli/test/integration/publicApi/credentials.test.ts b/packages/cli/test/integration/publicApi/credentials.test.ts index dfa6c44dd4037..b5ed2bfb31d82 100644 --- a/packages/cli/test/integration/publicApi/credentials.test.ts +++ b/packages/cli/test/integration/publicApi/credentials.test.ts @@ -9,9 +9,10 @@ import { randomApiKey, randomName } from '../shared/random'; import * as utils from '../shared/utils/'; import type { CredentialPayload, SaveCredentialFunction } from '../shared/types'; import * as testDb from '../shared/testDb'; -import { affixRoleToSaveCredential } from '../shared/db/credentials'; +import { affixRoleToSaveCredential, createCredentials } from '../shared/db/credentials'; import { addApiKey, createUser, createUserShell } from '../shared/db/users'; import type { SuperAgentTest } from '../shared/types'; +import { createTeamProject } from '@test-integration/db/projects'; let owner: User; let member: User; @@ -256,6 +257,53 @@ describe('GET /credentials/schema/:credentialType', () => { }); }); +describe('PUT /credentials/:id/transfer', () => { + test('should transfer credential to project', async () => { + /** + * Arrange + */ + const [firstProject, secondProject] = await Promise.all([ + createTeamProject('first-project', owner), + createTeamProject('second-project', owner), + ]); + + const credentials = await createCredentials( + { name: 'Test', type: 'test', data: '' }, + firstProject, + ); + + /** + * Act + */ + const response = await authOwnerAgent.put(`/credentials/${credentials.id}/transfer`).send({ + destinationProjectId: secondProject.id, + }); + + /** + * Assert + */ + expect(response.statusCode).toBe(204); + }); + + test('if no destination project, should reject', async () => { + /** + * Arrange + */ + const project = await createTeamProject('first-project', member); + const credentials = await createCredentials({ name: 'Test', type: 'test', data: '' }, project); + + /** + * Act + */ + const response = await authOwnerAgent.put(`/credentials/${credentials.id}/transfer`).send({}); + + /** + * Assert + */ + expect(response.statusCode).toBe(400); + }); +}); + const credentialPayload = (): CredentialPayload => ({ name: randomName(), type: 'githubApi', diff --git a/packages/cli/test/integration/publicApi/projects.test.ts b/packages/cli/test/integration/publicApi/projects.test.ts new file mode 100644 index 0000000000000..0554fd6f4116f --- /dev/null +++ b/packages/cli/test/integration/publicApi/projects.test.ts @@ -0,0 +1,401 @@ +import { setupTestServer } from '@test-integration/utils'; +import { createMember, createOwner } from '@test-integration/db/users'; +import * as testDb from '../shared/testDb'; +import { FeatureNotLicensedError } from '@/errors/feature-not-licensed.error'; +import { createTeamProject, getProjectByNameOrFail } from '@test-integration/db/projects'; +import { mockInstance } from '@test/mocking'; +import { Telemetry } from '@/telemetry'; + +describe('Projects in Public API', () => { + const testServer = setupTestServer({ endpointGroups: ['publicApi'] }); + mockInstance(Telemetry); + + beforeAll(async () => { + await testDb.init(); + }); + + beforeEach(async () => { + await testDb.truncate(['Project', 'User']); + }); + + describe('GET /projects', () => { + it('if licensed, should return all projects with pagination', async () => { + /** + * Arrange + */ + testServer.license.setQuota('quota:maxTeamProjects', -1); + testServer.license.enable('feat:projectRole:admin'); + const owner = await createOwner({ withApiKey: true }); + const projects = await Promise.all([ + createTeamProject(), + createTeamProject(), + createTeamProject(), + ]); + + /** + * Act + */ + const response = await testServer.publicApiAgentFor(owner).get('/projects'); + + /** + * Assert + */ + expect(response.status).toBe(200); + expect(response.body).toHaveProperty('data'); + expect(response.body).toHaveProperty('nextCursor'); + expect(Array.isArray(response.body.data)).toBe(true); + expect(response.body.data.length).toBe(projects.length + 1); // +1 for the owner's personal project + + projects.forEach(({ id, name }) => { + expect(response.body.data).toContainEqual(expect.objectContaining({ id, name })); + }); + }); + + it('if not authenticated, should reject', async () => { + /** + * Arrange + */ + const owner = await createOwner({ withApiKey: false }); + + /** + * Act + */ + const response = await testServer.publicApiAgentFor(owner).get('/projects'); + + /** + * Assert + */ + expect(response.status).toBe(401); + expect(response.body).toHaveProperty('message', "'X-N8N-API-KEY' header required"); + }); + + it('if not licensed, should reject', async () => { + /** + * Arrange + */ + const owner = await createOwner({ withApiKey: true }); + + /** + * Act + */ + const response = await testServer.publicApiAgentFor(owner).get('/projects'); + + /** + * Assert + */ + expect(response.status).toBe(403); + expect(response.body).toHaveProperty( + 'message', + new FeatureNotLicensedError('feat:projectRole:admin').message, + ); + }); + + it('if missing scope, should reject', async () => { + /** + * Arrange + */ + testServer.license.setQuota('quota:maxTeamProjects', -1); + testServer.license.enable('feat:projectRole:admin'); + const owner = await createMember({ withApiKey: true }); + + /** + * Act + */ + const response = await testServer.publicApiAgentFor(owner).get('/projects'); + + /** + * Assert + */ + expect(response.status).toBe(403); + expect(response.body).toHaveProperty('message', 'Forbidden'); + }); + }); + + describe('POST /projects', () => { + it('if licensed, should create a new project', async () => { + /** + * Arrange + */ + testServer.license.setQuota('quota:maxTeamProjects', -1); + testServer.license.enable('feat:projectRole:admin'); + const owner = await createOwner({ withApiKey: true }); + const projectPayload = { name: 'some-project' }; + + /** + * Act + */ + const response = await testServer + .publicApiAgentFor(owner) + .post('/projects') + .send(projectPayload); + + /** + * Assert + */ + expect(response.status).toBe(201); + expect(response.body).toEqual({ + name: 'some-project', + type: 'team', + id: expect.any(String), + createdAt: expect.any(String), + updatedAt: expect.any(String), + role: 'project:admin', + scopes: expect.any(Array), + }); + await expect(getProjectByNameOrFail(projectPayload.name)).resolves.not.toThrow(); + }); + + it('if not authenticated, should reject', async () => { + /** + * Arrange + */ + const owner = await createOwner({ withApiKey: false }); + const projectPayload = { name: 'some-project' }; + + /** + * Act + */ + const response = await testServer + .publicApiAgentFor(owner) + .post('/projects') + .send(projectPayload); + + /** + * Assert + */ + expect(response.status).toBe(401); + expect(response.body).toHaveProperty('message', "'X-N8N-API-KEY' header required"); + }); + + it('if not licensed, should reject', async () => { + /** + * Arrange + */ + const owner = await createOwner({ withApiKey: true }); + const projectPayload = { name: 'some-project' }; + + /** + * Act + */ + const response = await testServer + .publicApiAgentFor(owner) + .post('/projects') + .send(projectPayload); + + /** + * Assert + */ + expect(response.status).toBe(403); + expect(response.body).toHaveProperty( + 'message', + new FeatureNotLicensedError('feat:projectRole:admin').message, + ); + }); + + it('if missing scope, should reject', async () => { + /** + * Arrange + */ + testServer.license.setQuota('quota:maxTeamProjects', -1); + testServer.license.enable('feat:projectRole:admin'); + const member = await createMember({ withApiKey: true }); + const projectPayload = { name: 'some-project' }; + + /** + * Act + */ + const response = await testServer + .publicApiAgentFor(member) + .post('/projects') + .send(projectPayload); + + /** + * Assert + */ + expect(response.status).toBe(403); + expect(response.body).toHaveProperty('message', 'Forbidden'); + }); + }); + + describe('DELETE /projects/:id', () => { + it('if licensed, should delete a project', async () => { + /** + * Arrange + */ + testServer.license.setQuota('quota:maxTeamProjects', -1); + testServer.license.enable('feat:projectRole:admin'); + const owner = await createOwner({ withApiKey: true }); + const project = await createTeamProject(); + + /** + * Act + */ + const response = await testServer.publicApiAgentFor(owner).delete(`/projects/${project.id}`); + + /** + * Assert + */ + expect(response.status).toBe(204); + await expect(getProjectByNameOrFail(project.id)).rejects.toThrow(); + }); + + it('if not authenticated, should reject', async () => { + /** + * Arrange + */ + const owner = await createOwner({ withApiKey: false }); + const project = await createTeamProject(); + + /** + * Act + */ + const response = await testServer.publicApiAgentFor(owner).delete(`/projects/${project.id}`); + + /** + * Assert + */ + expect(response.status).toBe(401); + expect(response.body).toHaveProperty('message', "'X-N8N-API-KEY' header required"); + }); + + it('if not licensed, should reject', async () => { + /** + * Arrange + */ + const owner = await createOwner({ withApiKey: true }); + const project = await createTeamProject(); + + /** + * Act + */ + const response = await testServer.publicApiAgentFor(owner).delete(`/projects/${project.id}`); + + /** + * Assert + */ + expect(response.status).toBe(403); + expect(response.body).toHaveProperty( + 'message', + new FeatureNotLicensedError('feat:projectRole:admin').message, + ); + }); + + it('if missing scope, should reject', async () => { + /** + * Arrange + */ + testServer.license.setQuota('quota:maxTeamProjects', -1); + testServer.license.enable('feat:projectRole:admin'); + const member = await createMember({ withApiKey: true }); + const project = await createTeamProject(); + + /** + * Act + */ + const response = await testServer.publicApiAgentFor(member).delete(`/projects/${project.id}`); + + /** + * Assert + */ + expect(response.status).toBe(403); + expect(response.body).toHaveProperty('message', 'Forbidden'); + }); + }); + + describe('PUT /projects/:id', () => { + it('if licensed, should update a project', async () => { + /** + * Arrange + */ + testServer.license.setQuota('quota:maxTeamProjects', -1); + testServer.license.enable('feat:projectRole:admin'); + const owner = await createOwner({ withApiKey: true }); + const project = await createTeamProject('old-name'); + + /** + * Act + */ + const response = await testServer + .publicApiAgentFor(owner) + .put(`/projects/${project.id}`) + .send({ name: 'new-name' }); + + /** + * Assert + */ + expect(response.status).toBe(204); + await expect(getProjectByNameOrFail('new-name')).resolves.not.toThrow(); + }); + + it('if not authenticated, should reject', async () => { + /** + * Arrange + */ + const owner = await createOwner({ withApiKey: false }); + const project = await createTeamProject(); + + /** + * Act + */ + const response = await testServer + .publicApiAgentFor(owner) + .put(`/projects/${project.id}`) + .send({ name: 'new-name' }); + + /** + * Assert + */ + expect(response.status).toBe(401); + expect(response.body).toHaveProperty('message', "'X-N8N-API-KEY' header required"); + }); + + it('if not licensed, should reject', async () => { + /** + * Arrange + */ + const owner = await createOwner({ withApiKey: true }); + const project = await createTeamProject(); + + /** + * Act + */ + const response = await testServer + .publicApiAgentFor(owner) + .put(`/projects/${project.id}`) + .send({ name: 'new-name' }); + + /** + * Assert + */ + expect(response.status).toBe(403); + expect(response.body).toHaveProperty( + 'message', + new FeatureNotLicensedError('feat:projectRole:admin').message, + ); + }); + + it('if missing scope, should reject', async () => { + /** + * Arrange + */ + testServer.license.setQuota('quota:maxTeamProjects', -1); + testServer.license.enable('feat:projectRole:admin'); + const member = await createMember({ withApiKey: true }); + const project = await createTeamProject(); + + /** + * Act + */ + const response = await testServer + .publicApiAgentFor(member) + .put(`/projects/${project.id}`) + .send({ name: 'new-name' }); + + /** + * Assert + */ + expect(response.status).toBe(403); + expect(response.body).toHaveProperty('message', 'Forbidden'); + }); + }); +}); diff --git a/packages/cli/test/integration/publicApi/users.test.ts b/packages/cli/test/integration/publicApi/users.test.ts new file mode 100644 index 0000000000000..6021ae01a35f5 --- /dev/null +++ b/packages/cli/test/integration/publicApi/users.test.ts @@ -0,0 +1,252 @@ +import { setupTestServer } from '@test-integration/utils'; +import * as testDb from '../shared/testDb'; +import { createMember, createOwner, getUserById } from '@test-integration/db/users'; +import { mockInstance } from '@test/mocking'; +import { Telemetry } from '@/telemetry'; +import { FeatureNotLicensedError } from '@/errors/feature-not-licensed.error'; + +describe('Users in Public API', () => { + const testServer = setupTestServer({ endpointGroups: ['publicApi'] }); + mockInstance(Telemetry); + + beforeAll(async () => { + await testDb.init(); + }); + + beforeEach(async () => { + await testDb.truncate(['User']); + }); + + describe('POST /users', () => { + it('if not authenticated, should reject', async () => { + /** + * Arrange + */ + const owner = await createOwner({ withApiKey: false }); + const payload = { email: 'test@test.com', role: 'global:admin' }; + + /** + * Act + */ + const response = await testServer.publicApiAgentFor(owner).post('/users').send(payload); + + /** + * Assert + */ + expect(response.status).toBe(401); + }); + + it('if missing scope, should reject', async () => { + /** + * Arrange + */ + testServer.license.enable('feat:advancedPermissions'); + const member = await createMember({ withApiKey: true }); + const payload = [{ email: 'test@test.com', role: 'global:admin' }]; + + /** + * Act + */ + const response = await testServer.publicApiAgentFor(member).post('/users').send(payload); + + /** + * Assert + */ + expect(response.status).toBe(403); + expect(response.body).toHaveProperty('message', 'Forbidden'); + }); + + it('should create a user', async () => { + /** + * Arrange + */ + testServer.license.enable('feat:advancedPermissions'); + const owner = await createOwner({ withApiKey: true }); + const payload = [{ email: 'test@test.com', role: 'global:admin' }]; + + /** + * Act + */ + const response = await testServer.publicApiAgentFor(owner).post('/users').send(payload); + + /** + * Assert + */ + expect(response.status).toBe(201); + + expect(response.body).toHaveLength(1); + + const [result] = response.body; + const { user: returnedUser, error } = result; + const payloadUser = payload[0]; + + expect(returnedUser).toHaveProperty('email', payload[0].email); + expect(typeof returnedUser.inviteAcceptUrl).toBe('string'); + expect(typeof returnedUser.emailSent).toBe('boolean'); + expect(error).toBe(''); + + const storedUser = await getUserById(returnedUser.id); + expect(returnedUser.id).toBe(storedUser.id); + expect(returnedUser.email).toBe(storedUser.email); + expect(returnedUser.email).toBe(payloadUser.email); + expect(storedUser.role).toBe(payloadUser.role); + }); + }); + + describe('DELETE /users/:id', () => { + it('if not authenticated, should reject', async () => { + /** + * Arrange + */ + const owner = await createOwner({ withApiKey: false }); + const member = await createMember(); + + /** + * Act + */ + const response = await testServer.publicApiAgentFor(owner).delete(`/users/${member.id}`); + + /** + * Assert + */ + expect(response.status).toBe(401); + }); + + it('if missing scope, should reject', async () => { + /** + * Arrange + */ + testServer.license.enable('feat:advancedPermissions'); + const firstMember = await createMember({ withApiKey: true }); + const secondMember = await createMember(); + + /** + * Act + */ + const response = await testServer + .publicApiAgentFor(firstMember) + .delete(`/users/${secondMember.id}`); + + /** + * Assert + */ + expect(response.status).toBe(403); + expect(response.body).toHaveProperty('message', 'Forbidden'); + }); + + it('should delete a user', async () => { + /** + * Arrange + */ + testServer.license.enable('feat:advancedPermissions'); + const owner = await createOwner({ withApiKey: true }); + const member = await createMember(); + + /** + * Act + */ + const response = await testServer.publicApiAgentFor(owner).delete(`/users/${member.id}`); + + /** + * Assert + */ + expect(response.status).toBe(204); + await expect(getUserById(member.id)).rejects.toThrow(); + }); + }); + + describe('PATCH /users/:id/role', () => { + it('if not authenticated, should reject', async () => { + /** + * Arrange + */ + const owner = await createOwner({ withApiKey: false }); + const member = await createMember(); + + /** + * Act + */ + const response = await testServer.publicApiAgentFor(owner).patch(`/users/${member.id}/role`); + + /** + * Assert + */ + expect(response.status).toBe(401); + }); + + it('if not licensed, should reject', async () => { + /** + * Arrange + */ + const owner = await createOwner({ withApiKey: true }); + const member = await createMember(); + const payload = { newRoleName: 'global:admin' }; + + /** + * Act + */ + const response = await testServer + .publicApiAgentFor(owner) + .patch(`/users/${member.id}/role`) + .send(payload); + + /** + * Assert + */ + expect(response.status).toBe(403); + expect(response.body).toHaveProperty( + 'message', + new FeatureNotLicensedError('feat:advancedPermissions').message, + ); + }); + + it('if missing scope, should reject', async () => { + /** + * Arrange + */ + testServer.license.enable('feat:advancedPermissions'); + const firstMember = await createMember({ withApiKey: true }); + const secondMember = await createMember(); + const payload = { newRoleName: 'global:admin' }; + + /** + * Act + */ + const response = await testServer + .publicApiAgentFor(firstMember) + .patch(`/users/${secondMember.id}/role`) + .send(payload); + + /** + * Assert + */ + expect(response.status).toBe(403); + expect(response.body).toHaveProperty('message', 'Forbidden'); + }); + + it("should change a user's role", async () => { + /** + * Arrange + */ + testServer.license.enable('feat:advancedPermissions'); + const owner = await createOwner({ withApiKey: true }); + const member = await createMember(); + const payload = { newRoleName: 'global:admin' }; + + /** + * Act + */ + const response = await testServer + .publicApiAgentFor(owner) + .patch(`/users/${member.id}/role`) + .send(payload); + + /** + * Assert + */ + expect(response.status).toBe(204); + const storedUser = await getUserById(member.id); + expect(storedUser.role).toBe(payload.newRoleName); + }); + }); +}); diff --git a/packages/cli/test/integration/publicApi/workflows.test.ts b/packages/cli/test/integration/publicApi/workflows.test.ts index 10737a30f0b12..1e292af64d92f 100644 --- a/packages/cli/test/integration/publicApi/workflows.test.ts +++ b/packages/cli/test/integration/publicApi/workflows.test.ts @@ -21,6 +21,8 @@ import { createTag } from '../shared/db/tags'; import { mockInstance } from '../../shared/mocking'; import type { SuperAgentTest } from '../shared/types'; import { Telemetry } from '@/telemetry'; +import { ProjectService } from '@/services/project.service'; +import { createTeamProject } from '@test-integration/db/projects'; mockInstance(Telemetry); @@ -265,6 +267,25 @@ describe('GET /workflows', () => { } }); + test('should return all user-accessible workflows filtered by `projectId`', async () => { + license.setQuota('quota:maxTeamProjects', 2); + const otherProject = await Container.get(ProjectService).createTeamProject( + 'Other project', + member, + ); + + await Promise.all([ + createWorkflow({}, member), + createWorkflow({ name: 'Other workflow' }, otherProject), + ]); + + const response = await authMemberAgent.get(`/workflows?projectId=${otherProject.id}`); + + expect(response.statusCode).toBe(200); + expect(response.body.data.length).toBe(1); + expect(response.body.data[0].name).toBe('Other workflow'); + }); + test('should return all owned workflows filtered by name', async () => { const workflowName = 'Workflow 1'; @@ -1465,3 +1486,44 @@ describe('PUT /workflows/:id/tags', () => { } }); }); + +describe('PUT /workflows/:id/transfer', () => { + test('should transfer workflow to project', async () => { + /** + * Arrange + */ + const firstProject = await createTeamProject('first-project', member); + const secondProject = await createTeamProject('second-project', member); + const workflow = await createWorkflow({}, firstProject); + + /** + * Act + */ + const response = await authMemberAgent.put(`/workflows/${workflow.id}/transfer`).send({ + destinationProjectId: secondProject.id, + }); + + /** + * Assert + */ + expect(response.statusCode).toBe(204); + }); + + test('if no destination project, should reject', async () => { + /** + * Arrange + */ + const firstProject = await createTeamProject('first-project', member); + const workflow = await createWorkflow({}, firstProject); + + /** + * Act + */ + const response = await authMemberAgent.put(`/workflows/${workflow.id}/transfer`).send({}); + + /** + * Assert + */ + expect(response.statusCode).toBe(400); + }); +}); diff --git a/packages/cli/test/integration/security-audit/InstanceRiskReporter.test.ts b/packages/cli/test/integration/security-audit/InstanceRiskReporter.test.ts index 5d359e0dba52b..5f59206426a05 100644 --- a/packages/cli/test/integration/security-audit/InstanceRiskReporter.test.ts +++ b/packages/cli/test/integration/security-audit/InstanceRiskReporter.test.ts @@ -14,6 +14,7 @@ import config from '@/config'; import { generateNanoId } from '@db/utils/generators'; import { WorkflowRepository } from '@db/repositories/workflow.repository'; import Container from 'typedi'; +import { NodeConnectionType } from 'n8n-workflow'; let securityAuditService: SecurityAuditService; @@ -156,7 +157,7 @@ test('should not report webhooks validated by direct children', async () => { [ { node: 'My Node', - type: 'main', + type: NodeConnectionType.Main, index: 0, }, ], 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/db/credentials.ts b/packages/cli/test/integration/shared/db/credentials.ts index 046d27db261a0..588fee6b51196 100644 --- a/packages/cli/test/integration/shared/db/credentials.ts +++ b/packages/cli/test/integration/shared/db/credentials.ts @@ -38,11 +38,24 @@ export async function createManyCredentials( ); } -export async function createCredentials(attributes: Partial = emptyAttributes) { +export async function createCredentials( + attributes: Partial = emptyAttributes, + project?: Project, +) { const credentialsRepository = Container.get(CredentialsRepository); - const entity = credentialsRepository.create(attributes); + const credentials = await credentialsRepository.save(credentialsRepository.create(attributes)); + + if (project) { + await Container.get(SharedCredentialsRepository).save( + Container.get(SharedCredentialsRepository).create({ + project, + credentials, + role: 'credential:owner', + }), + ); + } - return await credentialsRepository.save(entity); + return credentials; } /** diff --git a/packages/cli/test/integration/shared/db/projects.ts b/packages/cli/test/integration/shared/db/projects.ts index 60548575b362b..3de7de5bb95f9 100644 --- a/packages/cli/test/integration/shared/db/projects.ts +++ b/packages/cli/test/integration/shared/db/projects.ts @@ -34,6 +34,10 @@ export const linkUserToProject = async (user: User, project: Project, role: Proj ); }; +export async function getProjectByNameOrFail(name: string) { + return await Container.get(ProjectRepository).findOneOrFail({ where: { name } }); +} + export const getPersonalProject = async (user: User): Promise => { return await Container.get(ProjectRepository).findOneOrFail({ where: { diff --git a/packages/cli/test/integration/shared/db/users.ts b/packages/cli/test/integration/shared/db/users.ts index f125f5ccded4f..98626bc549d9e 100644 --- a/packages/cli/test/integration/shared/db/users.ts +++ b/packages/cli/test/integration/shared/db/users.ts @@ -86,7 +86,11 @@ export async function createOwner({ withApiKey } = { withApiKey: false }) { return await createUser({ role: 'global:owner' }); } -export async function createMember() { +export async function createMember({ withApiKey } = { withApiKey: false }) { + if (withApiKey) { + return await addApiKey(await createUser({ role: 'global:member' })); + } + return await createUser({ role: 'global:member' }); } diff --git a/packages/cli/test/integration/shared/db/workflows.ts b/packages/cli/test/integration/shared/db/workflows.ts index f81ac044c3c1d..dc5490f9f3c61 100644 --- a/packages/cli/test/integration/shared/db/workflows.ts +++ b/packages/cli/test/integration/shared/db/workflows.ts @@ -9,6 +9,7 @@ import { WorkflowRepository } from '@db/repositories/workflow.repository'; import type { SharedWorkflow, WorkflowSharingRole } from '@db/entities/SharedWorkflow'; import { ProjectRepository } from '@/databases/repositories/project.repository'; import { Project } from '@/databases/entities/Project'; +import { NodeConnectionType } from 'n8n-workflow'; export async function createManyWorkflows( amount: number, @@ -157,7 +158,7 @@ export async function createWorkflowWithTrigger( position: [780, 300], }, ], - connections: { Cron: { main: [[{ node: 'Set', type: 'main', index: 0 }]] } }, + connections: { Cron: { main: [[{ node: 'Set', type: NodeConnectionType.Main, index: 0 }]] } }, ...attributes, }, user, 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/index.ts b/packages/cli/test/integration/shared/utils/index.ts index 96efb06039681..7abdb1a8e834d 100644 --- a/packages/cli/test/integration/shared/utils/index.ts +++ b/packages/cli/test/integration/shared/utils/index.ts @@ -96,9 +96,10 @@ export async function initBinaryDataService(mode: 'default' | 'filesystem' = 'de * Extract the value (token) of the auth cookie in a response. */ export function getAuthToken(response: request.Response, authCookieName = AUTH_COOKIE_NAME) { - const cookies: string[] = response.headers['set-cookie']; + const cookiesHeader = response.headers['set-cookie']; + if (!cookiesHeader) return undefined; - if (!cookies) return undefined; + const cookies = Array.isArray(cookiesHeader) ? cookiesHeader : [cookiesHeader]; const authCookie = cookies.find((c) => c.startsWith(`${authCookieName}=`)); 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/integration/workflows/workflow.service.ee.test.ts b/packages/cli/test/integration/workflows/workflow.service.ee.test.ts index 55287c5f2210c..6e3b00bd58fcc 100644 --- a/packages/cli/test/integration/workflows/workflow.service.ee.test.ts +++ b/packages/cli/test/integration/workflows/workflow.service.ee.test.ts @@ -30,6 +30,8 @@ describe('EnterpriseWorkflowService', () => { Container.get(CredentialsRepository), mock(), mock(), + mock(), + mock(), ); }); diff --git a/packages/cli/test/integration/workflows/workflows.controller-with-active-workflow-manager.ee.test.ts b/packages/cli/test/integration/workflows/workflows.controller-with-active-workflow-manager.ee.test.ts index 607639b091a12..d32722c23dd86 100644 --- a/packages/cli/test/integration/workflows/workflows.controller-with-active-workflow-manager.ee.test.ts +++ b/packages/cli/test/integration/workflows/workflows.controller-with-active-workflow-manager.ee.test.ts @@ -11,7 +11,6 @@ import { Telemetry } from '@/telemetry'; mockInstance(Telemetry); let member: User; -let anotherMember: User; const testServer = utils.setupTestServer({ endpointGroups: ['workflows'], @@ -20,7 +19,6 @@ const testServer = utils.setupTestServer({ beforeAll(async () => { member = await createUser({ role: 'global:member' }); - anotherMember = await createUser({ role: 'global:member' }); await utils.initNodeTypes(); }); diff --git a/packages/cli/test/unit/ActiveExecutions.test.ts b/packages/cli/test/unit/ActiveExecutions.test.ts index b2454de87c5e1..0cc70a10a4e61 100644 --- a/packages/cli/test/unit/ActiveExecutions.test.ts +++ b/packages/cli/test/unit/ActiveExecutions.test.ts @@ -20,7 +20,10 @@ const executionRepository = mock({ createNewExecution, }); -const concurrencyControl = mockInstance(ConcurrencyControlService, { isEnabled: false }); +const concurrencyControl = mockInstance(ConcurrencyControlService, { + // @ts-expect-error Private property + isEnabled: false, +}); describe('ActiveExecutions', () => { let activeExecutions: ActiveExecutions; diff --git a/packages/cli/test/unit/Telemetry.test.ts b/packages/cli/test/unit/Telemetry.test.ts index e055abb1fdb74..af8445c814663 100644 --- a/packages/cli/test/unit/Telemetry.test.ts +++ b/packages/cli/test/unit/Telemetry.test.ts @@ -15,11 +15,6 @@ describe('Telemetry', () => { const spyTrack = jest.spyOn(Telemetry.prototype, 'track').mockName('track'); const mockRudderStack = mock(); - mockRudderStack.track.mockImplementation(function (_, cb) { - cb?.(); - - return this; - }); let telemetry: Telemetry; const instanceId = 'Telemetry unit test'; 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/services/orchestration.service.test.ts b/packages/cli/test/unit/services/orchestration.service.test.ts index 0cdd73f605963..75c7c06e40bb6 100644 --- a/packages/cli/test/unit/services/orchestration.service.test.ts +++ b/packages/cli/test/unit/services/orchestration.service.test.ts @@ -33,7 +33,7 @@ function setDefaultConfig() { config.set('generic.instanceType', 'main'); } -const workerRestartEventbusResponse: RedisServiceWorkerResponseObject = { +const workerRestartEventBusResponse: RedisServiceWorkerResponseObject = { senderId: 'test', workerId: 'test', command: 'restartEventBus', @@ -88,7 +88,7 @@ describe('Orchestration Service', () => { test('should handle worker responses', async () => { const response = await handleWorkerResponseMessageMain( - JSON.stringify(workerRestartEventbusResponse), + JSON.stringify(workerRestartEventBusResponse), ); expect(response.command).toEqual('restartEventBus'); }); @@ -108,7 +108,7 @@ describe('Orchestration Service', () => { test('should reject command messages from itself', async () => { const response = await handleCommandMessageMain( - JSON.stringify({ ...workerRestartEventbusResponse, senderId: queueModeId }), + JSON.stringify({ ...workerRestartEventBusResponse, senderId: queueModeId }), ); expect(response).toBeDefined(); expect(response!.command).toEqual('restartEventBus'); @@ -141,7 +141,7 @@ describe('Orchestration Service', () => { ); expect(helpers.debounceMessageReceiver).toHaveBeenCalledTimes(2); expect(res1!.payload).toBeUndefined(); - expect(res2!.payload!.result).toEqual('debounced'); + expect((res2!.payload as { result: string }).result).toEqual('debounced'); }); describe('shouldAddWebhooks', () => { diff --git a/packages/cli/test/unit/services/workflow-statistics.service.test.ts b/packages/cli/test/unit/services/workflow-statistics.service.test.ts index 9bd9864bb0446..6d9baf49ea939 100644 --- a/packages/cli/test/unit/services/workflow-statistics.service.test.ts +++ b/packages/cli/test/unit/services/workflow-statistics.service.test.ts @@ -118,7 +118,7 @@ describe('WorkflowStatisticsService', () => { }; const runData: IRun = { finished: false, - status: 'failed', + status: 'error', data: { resultData: { runData: {} } }, mode: 'internal' as WorkflowExecuteMode, startedAt: new Date(), @@ -206,7 +206,7 @@ describe('WorkflowStatisticsService', () => { test('should not send metrics for entries that already have the flag set', async () => { // Fetch data for workflow 2 which is set up to not be altered in the mocks - entityManager.insert.mockRejectedValueOnce(new QueryFailedError('', undefined, '')); + entityManager.insert.mockRejectedValueOnce(new QueryFailedError('', undefined, new Error())); const workflowId = '1'; const node = { id: 'abcde', 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/cli/tsconfig.json b/packages/cli/tsconfig.json index 008f1cb70d742..0efc9328610d8 100644 --- a/packages/cli/tsconfig.json +++ b/packages/cli/tsconfig.json @@ -2,7 +2,6 @@ "extends": ["../../tsconfig.json", "../../tsconfig.backend.json"], "compilerOptions": { "rootDir": ".", - "preserveSymlinks": true, "emitDecoratorMetadata": true, "experimentalDecorators": true, "baseUrl": "src", 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 150636a64c3f0..386a7e1e862c8 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/CredentialCard.test.ts b/packages/editor-ui/src/components/CredentialCard.test.ts index 06760c75da7c5..9ca589923d59e 100644 --- a/packages/editor-ui/src/components/CredentialCard.test.ts +++ b/packages/editor-ui/src/components/CredentialCard.test.ts @@ -6,7 +6,7 @@ import { createComponentRenderer } from '@/__tests__/render'; import CredentialCard from '@/components/CredentialCard.vue'; import type { ICredentialsResponse } from '@/Interface'; import type { ProjectSharingData } from '@/types/projects.types'; -import { useSettingsStore } from '@/stores/settings.store'; +import { useProjectsStore } from '@/stores/projects.store'; const renderComponent = createComponentRenderer(CredentialCard); @@ -22,12 +22,12 @@ const createCredential = (overrides = {}): ICredentialsResponse => ({ }); describe('CredentialCard', () => { - let settingsStore: ReturnType; + let projectsStore: ReturnType; beforeEach(() => { const pinia = createTestingPinia(); setActivePinia(pinia); - settingsStore = useSettingsStore(); + projectsStore = useProjectsStore(); }); it('should render name and home project name', () => { @@ -63,7 +63,7 @@ describe('CredentialCard', () => { }); it('should show Move action only if there is resource permission and not on community plan', async () => { - vi.spyOn(settingsStore, 'isCommunityPlan', 'get').mockReturnValue(false); + vi.spyOn(projectsStore, 'isTeamProjectFeatureEnabled', 'get').mockReturnValue(true); const data = createCredential({ scopes: ['credential:move'], diff --git a/packages/editor-ui/src/components/CredentialCard.vue b/packages/editor-ui/src/components/CredentialCard.vue index f36926a19b09d..d9ac12ffd9a54 100644 --- a/packages/editor-ui/src/components/CredentialCard.vue +++ b/packages/editor-ui/src/components/CredentialCard.vue @@ -14,7 +14,6 @@ import { useProjectsStore } from '@/stores/projects.store'; import ProjectCardBadge from '@/components/Projects/ProjectCardBadge.vue'; import { useI18n } from '@/composables/useI18n'; import { ResourceType } from '@/utils/projects.utils'; -import { useSettingsStore } from '@/stores/settings.store'; const CREDENTIAL_LIST_ITEM_ACTIONS = { OPEN: 'open', @@ -46,7 +45,6 @@ const message = useMessage(); const uiStore = useUIStore(); const credentialsStore = useCredentialsStore(); const projectsStore = useProjectsStore(); -const settingsStore = useSettingsStore(); const resourceTypeLabel = computed(() => locale.baseText('generic.credential').toLowerCase()); const credentialType = computed(() => credentialsStore.getCredentialTypeByName(props.data.type)); @@ -66,7 +64,7 @@ const actions = computed(() => { }); } - if (credentialPermissions.value.move && !settingsStore.isCommunityPlan) { + if (credentialPermissions.value.move && projectsStore.isTeamProjectFeatureEnabled) { items.push({ label: locale.baseText('credentials.item.move'), value: CREDENTIAL_LIST_ITEM_ACTIONS.MOVE, diff --git a/packages/editor-ui/src/components/Projects/ProjectNavigation.vue b/packages/editor-ui/src/components/Projects/ProjectNavigation.vue index d1f5bac9ce60a..2b4909e0f2f95 100644 --- a/packages/editor-ui/src/components/Projects/ProjectNavigation.vue +++ b/packages/editor-ui/src/components/Projects/ProjectNavigation.vue @@ -116,7 +116,7 @@ onMounted(async () => {
@@ -137,7 +137,9 @@ onMounted(async () => { @@ -171,7 +173,7 @@ onMounted(async () => {
diff --git a/packages/editor-ui/src/components/WorkflowCard.test.ts b/packages/editor-ui/src/components/WorkflowCard.test.ts index 97be0e7fd4536..735b20ada5f0b 100644 --- a/packages/editor-ui/src/components/WorkflowCard.test.ts +++ b/packages/editor-ui/src/components/WorkflowCard.test.ts @@ -7,7 +7,7 @@ import { VIEWS } from '@/constants'; import WorkflowCard from '@/components/WorkflowCard.vue'; import type { IWorkflowDb } from '@/Interface'; import { useRouter } from 'vue-router'; -import { useSettingsStore } from '@/stores/settings.store'; +import { useProjectsStore } from '@/stores/projects.store'; vi.mock('vue-router', () => { const push = vi.fn(); @@ -40,13 +40,13 @@ describe('WorkflowCard', () => { let pinia: ReturnType; let windowOpenSpy: MockInstance; let router: ReturnType; - let settingsStore: ReturnType; + let projectsStore: ReturnType; beforeEach(async () => { pinia = createPinia(); setActivePinia(pinia); router = useRouter(); - settingsStore = useSettingsStore(); + projectsStore = useProjectsStore(); windowOpenSpy = vi.spyOn(window, 'open').mockImplementation(() => null); }); @@ -143,8 +143,8 @@ describe('WorkflowCard', () => { expect(badge).toHaveTextContent('John Doe'); }); - it('should show Move action only if there is resource permission and not on community plan', async () => { - vi.spyOn(settingsStore, 'isCommunityPlan', 'get').mockReturnValue(false); + it('should show Move action only if there is resource permission and team projects available', async () => { + vi.spyOn(projectsStore, 'isTeamProjectFeatureEnabled', 'get').mockReturnValue(true); const data = createWorkflow({ scopes: ['workflow:move'], diff --git a/packages/editor-ui/src/components/WorkflowCard.vue b/packages/editor-ui/src/components/WorkflowCard.vue index 21220e94924dc..6cc73db627817 100644 --- a/packages/editor-ui/src/components/WorkflowCard.vue +++ b/packages/editor-ui/src/components/WorkflowCard.vue @@ -95,7 +95,7 @@ const actions = computed(() => { }); } - if (workflowPermissions.value.move && !settingsStore.isCommunityPlan) { + if (workflowPermissions.value.move && projectsStore.isTeamProjectFeatureEnabled) { items.push({ label: locale.baseText('workflows.item.move'), value: WORKFLOW_LIST_ITEM_ACTIONS.MOVE, 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/__tests__/useNodeHelpers.test.ts b/packages/editor-ui/src/composables/__tests__/useNodeHelpers.test.ts index 1969b3d5b8192..732bf51f5e677 100644 --- a/packages/editor-ui/src/composables/__tests__/useNodeHelpers.test.ts +++ b/packages/editor-ui/src/composables/__tests__/useNodeHelpers.test.ts @@ -3,6 +3,7 @@ import { createTestingPinia } from '@pinia/testing'; import { useNodeHelpers } from '@/composables/useNodeHelpers'; import { createTestNode } from '@/__tests__/mocks'; import { useWorkflowsStore } from '@/stores/workflows.store'; +import { CUSTOM_API_CALL_KEY } from '@/constants'; vi.mock('@/stores/workflows.store', () => ({ useWorkflowsStore: vi.fn(), @@ -17,6 +18,34 @@ describe('useNodeHelpers()', () => { vi.clearAllMocks(); }); + describe('isCustomApiCallSelected', () => { + test('should return `true` when resource includes `CUSTOM_API_CALL_KEY`', () => { + const nodeValues = { + parameters: { resource: CUSTOM_API_CALL_KEY }, + }; + expect(useNodeHelpers().isCustomApiCallSelected(nodeValues)).toBe(true); + }); + + test('should return `true` when operation includes `CUSTOM_API_CALL_KEY`', () => { + const nodeValues = { + parameters: { + operation: CUSTOM_API_CALL_KEY, + }, + }; + expect(useNodeHelpers().isCustomApiCallSelected(nodeValues)).toBe(true); + }); + + test('should return `false` when neither resource nor operation includes `CUSTOM_API_CALL_KEY`', () => { + const nodeValues = { + parameters: { + resource: 'users', + operation: 'get', + }, + }; + expect(useNodeHelpers().isCustomApiCallSelected(nodeValues)).toBe(false); + }); + }); + describe('getNodeInputData()', () => { it('should return an empty array when node is null', () => { const { getNodeInputData } = useNodeHelpers(); diff --git a/packages/editor-ui/src/composables/useNodeHelpers.ts b/packages/editor-ui/src/composables/useNodeHelpers.ts index 00f18041b0225..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'; @@ -97,11 +98,13 @@ export function useNodeHelpers() { if (!isObject(parameters)) return false; - if ('resource' in parameters && 'operation' in parameters) { + if ('resource' in parameters || 'operation' in parameters) { const { resource, operation } = parameters; - if (!isString(resource) || !isString(operation)) return false; - return resource.includes(CUSTOM_API_CALL_KEY) || operation.includes(CUSTOM_API_CALL_KEY); + return ( + (isString(resource) && resource.includes(CUSTOM_API_CALL_KEY)) || + (isString(operation) && operation.includes(CUSTOM_API_CALL_KEY)) + ); } return false; @@ -569,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/stores/projects.store.ts b/packages/editor-ui/src/stores/projects.store.ts index f5fbaf6200762..6bc61d246c7c9 100644 --- a/packages/editor-ui/src/stores/projects.store.ts +++ b/packages/editor-ui/src/stores/projects.store.ts @@ -49,19 +49,19 @@ export const useProjectsStore = defineStore('projects', () => { ); const teamProjects = computed(() => projects.value.filter((p) => p.type === ProjectTypes.Team)); const teamProjectsLimit = computed(() => settingsStore.settings.enterprise.projects.team.limit); - const teamProjectsAvailable = computed( + const isTeamProjectFeatureEnabled = computed( () => settingsStore.settings.enterprise.projects.team.limit !== 0, ); const hasUnlimitedProjects = computed( () => settingsStore.settings.enterprise.projects.team.limit === -1, ); - const teamProjectLimitExceeded = computed( + const isTeamProjectLimitExceeded = computed( () => projectsCount.value.team >= teamProjectsLimit.value, ); const canCreateProjects = computed( () => hasUnlimitedProjects.value || - (teamProjectsAvailable.value && !teamProjectLimitExceeded.value), + (isTeamProjectFeatureEnabled.value && !isTeamProjectLimitExceeded.value), ); const hasPermissionToCreateProjects = computed(() => hasPermission(['rbac'], { rbac: { scope: 'project:create' } }), @@ -199,7 +199,7 @@ export const useProjectsStore = defineStore('projects', () => { hasUnlimitedProjects, canCreateProjects, hasPermissionToCreateProjects, - teamProjectsAvailable, + isTeamProjectFeatureEnabled, projectNavActiveId, setCurrentProject, getAllProjects, 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..77348f2a945e1 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", @@ -39,7 +39,7 @@ "@types/xml2js": "catalog:" }, "dependencies": { - "@n8n/tournament": "1.0.2", + "@n8n/tournament": "1.0.3", "@n8n_io/riot-tmpl": "4.0.0", "ast-types": "0.15.2", "axios": "catalog:", diff --git a/packages/workflow/src/Interfaces.ts b/packages/workflow/src/Interfaces.ts index ea31b06fef34b..39b4d06b47d98 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 005cdc310122d..8287287e22db9 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 @@ -1448,8 +1448,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) @@ -1710,8 +1710,8 @@ importers: packages/workflow: dependencies: '@n8n/tournament': - specifier: 1.0.2 - version: 1.0.2 + specifier: 1.0.3 + version: 1.0.3 '@n8n_io/riot-tmpl': specifier: 4.0.0 version: 4.0.0 @@ -4143,8 +4143,8 @@ packages: resolution: {integrity: sha512-rbnMnSdEwq2yuYMgzOQ4jTXm+oH7yjN/0ISfB/7O6pUcEPsZt9UW60BYfQ1WWHkKa/evI8vgER2zV5/RC1BupQ==} engines: {node: '>=18.10'} - '@n8n/tournament@1.0.2': - resolution: {integrity: sha512-fTpi7F8ra5flGSVfRzohPyG7czAAKCZPlLjdKdwbLJivLoI/Ekhgodov1jfVSCVFVbwQ06gRQRxLEDzl2jl8ig==} + '@n8n/tournament@1.0.3': + resolution: {integrity: sha512-GnmDD5wKAxKfxnSzhENHPn5n91/1c3/psnuT7D+jHHVQdMe8qaCcSq15rcGRfDfTf2v+BZBT0yeyK8Cfexr9yw==} engines: {node: '>=18.10', pnpm: '>=8.6'} '@n8n/typeorm@0.3.20-10': @@ -4211,9 +4211,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/license-sdk@2.13.0': @@ -5999,10 +5999,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'} @@ -6235,9 +6231,6 @@ packages: resolution: {integrity: sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==} engines: {node: '>=0.8'} - assert@2.0.0: - resolution: {integrity: sha512-se5Cd+js9dXJnu6Ag2JFc00t+HmHOen+8Q+L7O9zI0PqQXr20uk2J0XQqMxZEeo5U50o8Nvmmx7dZrl+Ufr35A==} - assert@2.1.0: resolution: {integrity: sha512-eLHpSK/Y4nhMJ07gDaAzoX/XAKS8PSaojml3M0DM4JpV1LAi5JOJ/p6H/XWrl8L+DzVEvVCW1z3vWAaB9oTsQw==} @@ -7561,9 +7554,6 @@ packages: resolution: {integrity: sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==} engines: {node: '>= 0.4'} - es6-object-assign@1.1.0: - resolution: {integrity: sha512-MEl9uirslVwqQU369iHNWZXsI8yaZYGg/D65aOgZkeyFJwHYSxilf7rQzXKI7DdDuBPrBXbfk3sl9hJhmd5AUw==} - es6-promise@3.3.1: resolution: {integrity: sha512-SOp9Phqvqn7jtEUxPWdWfWoLmyt2VaJ6MpvP9Comy1MceMXqE6bxvaTu4iaxpYYPzhny28Lc+M87/c2cPK6lDg==} @@ -16653,7 +16643,7 @@ snapshots: '@types/retry': 0.12.5 retry: 0.13.1 - '@n8n/tournament@1.0.2': + '@n8n/tournament@1.0.3': dependencies: '@n8n_io/riot-tmpl': 4.0.1 ast-types: 0.16.1 @@ -16720,10 +16710,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/license-sdk@2.13.0': dependencies: @@ -19367,15 +19357,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: {} @@ -19618,13 +19606,6 @@ snapshots: assert-plus@1.0.0: {} - assert@2.0.0: - dependencies: - es6-object-assign: 1.1.0 - is-nan: 1.3.2 - object-is: 1.1.5 - util: 0.12.5 - assert@2.1.0: dependencies: call-bind: 1.0.7 @@ -21214,8 +21195,6 @@ snapshots: is-date-object: 1.0.5 is-symbol: 1.0.4 - es6-object-assign@1.1.0: {} - es6-promise@3.3.1: {} esbuild-plugin-alias@0.2.1: {} @@ -21307,7 +21286,7 @@ snapshots: eslint-import-resolver-node@0.3.9: dependencies: - debug: 3.2.7(supports-color@8.1.1) + debug: 3.2.7(supports-color@5.5.0) is-core-module: 2.13.1 resolve: 1.22.8 transitivePeerDependencies: @@ -21332,7 +21311,7 @@ snapshots: eslint-module-utils@2.8.0(@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@5.5.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@5.5.2))(eslint-plugin-import@2.29.1)(eslint@8.57.0))(eslint@8.57.0): dependencies: - debug: 3.2.7(supports-color@8.1.1) + debug: 3.2.7(supports-color@5.5.0) optionalDependencies: '@typescript-eslint/parser': 7.2.0(eslint@8.57.0)(typescript@5.5.2) eslint: 8.57.0 @@ -21352,7 +21331,7 @@ snapshots: array.prototype.findlastindex: 1.2.3 array.prototype.flat: 1.3.2 array.prototype.flatmap: 1.3.2 - debug: 3.2.7(supports-color@8.1.1) + debug: 3.2.7(supports-color@5.5.0) doctrine: 2.1.0 eslint: 8.57.0 eslint-import-resolver-node: 0.3.9 @@ -21882,7 +21861,7 @@ snapshots: follow-redirects@1.15.6(debug@3.2.7): optionalDependencies: - debug: 3.2.7(supports-color@8.1.1) + debug: 3.2.7(supports-color@5.5.0) follow-redirects@1.15.6(debug@4.3.4): optionalDependencies: @@ -22223,7 +22202,7 @@ snapshots: array-parallel: 0.1.3 array-series: 0.1.5 cross-spawn: 4.0.2 - debug: 3.2.7(supports-color@8.1.1) + debug: 3.2.7(supports-color@5.5.0) transitivePeerDependencies: - supports-color @@ -23353,7 +23332,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 @@ -24209,7 +24188,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 @@ -24916,7 +24895,7 @@ snapshots: pdf-parse@1.1.1: dependencies: - debug: 3.2.7(supports-color@8.1.1) + debug: 3.2.7(supports-color@5.5.0) node-ensure: 0.0.0 transitivePeerDependencies: - supports-color @@ -25585,7 +25564,7 @@ snapshots: recast@0.22.0: dependencies: - assert: 2.0.0 + assert: 2.1.0 ast-types: 0.15.2 esprima: 4.0.1 source-map: 0.6.1 @@ -25828,7 +25807,7 @@ snapshots: rhea@1.0.24: dependencies: - debug: 3.2.7(supports-color@8.1.1) + debug: 3.2.7(supports-color@5.5.0) transitivePeerDependencies: - supports-color @@ -26202,7 +26181,7 @@ snapshots: binascii: 0.0.2 bn.js: 5.2.1 browser-request: 0.3.3 - debug: 3.2.7(supports-color@8.1.1) + debug: 3.2.7(supports-color@5.5.0) expand-tilde: 2.0.2 extend: 3.0.2 fast-xml-parser: 4.2.7 @@ -27190,7 +27169,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 @@ -27204,7 +27183,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