diff --git a/packages/@n8n/config/src/configs/nodes.config.ts b/packages/@n8n/config/src/configs/nodes.config.ts index e4a9905a73a..76a265e713f 100644 --- a/packages/@n8n/config/src/configs/nodes.config.ts +++ b/packages/@n8n/config/src/configs/nodes.config.ts @@ -49,4 +49,14 @@ export class NodesConfig { /** Memory limit in MB for the Merge node's SQL sandbox. */ @Env('NODES_MERGE_SQL_SANDBOX_MEMORY_LIMIT_MB', z.coerce.number().int().positive()) mergeSqlSandboxMemoryLimitMb: number = 64; + + /** + * Refuse deprecated nodes. Saving or importing a workflow fails if it adds or + * changes one. A sub-workflow from inline JSON, a URL or a file, or a single + * node run by n8n Assistant, fails if it contains one. Saved workflows that + * already contain deprecated nodes keep running. Set to `false` to turn this + * off, for example to import older backups. + */ + @Env('N8N_DEPRECATED_NODES_BLOCK') + blockDeprecated: boolean = true; } diff --git a/packages/@n8n/config/test/config.test.ts b/packages/@n8n/config/test/config.test.ts index ebe82cbb2cb..08e6944f28a 100644 --- a/packages/@n8n/config/test/config.test.ts +++ b/packages/@n8n/config/test/config.test.ts @@ -214,6 +214,7 @@ describe('GlobalConfig', () => { exclude: ['n8n-nodes-base.executeCommand', 'n8n-nodes-base.localFileTrigger'], pythonEnabled: true, mergeSqlSandboxMemoryLimitMb: 64, + blockDeprecated: true, }, publicApi: { disabled: false, diff --git a/packages/@n8n/nodes-langchain/nodes/code/Code.node.test.ts b/packages/@n8n/nodes-langchain/nodes/code/Code.node.test.ts index 8206ad4c9eb..ef1ae1b9d03 100644 --- a/packages/@n8n/nodes-langchain/nodes/code/Code.node.test.ts +++ b/packages/@n8n/nodes-langchain/nodes/code/Code.node.test.ts @@ -1,12 +1,18 @@ import { LOG_LEVELS, CONSOLE_OUTPUT_REDACTED_MESSAGE } from 'n8n-workflow'; import { + Code, transformLegacyLangchainImport, createSandboxLogger, createProductionConsoleLog, } from './Code.node'; describe('Code.node', () => { + it('is marked deprecated so the backend refuses new instances', () => { + const node = new Code(); + expect(node.description.deprecated).toBe(true); + }); + describe('createSandboxLogger', () => { const logLevelKeys = LOG_LEVELS.filter((level) => level !== 'silent'); diff --git a/packages/@n8n/nodes-langchain/nodes/code/Code.node.ts b/packages/@n8n/nodes-langchain/nodes/code/Code.node.ts index 9d353fc1e0f..554572f84d2 100644 --- a/packages/@n8n/nodes-langchain/nodes/code/Code.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/code/Code.node.ts @@ -238,6 +238,7 @@ export class Code implements INodeType { description: INodeTypeDescription = { displayName: 'LangChain Code', name: 'code', + deprecated: true, icon: 'fa:code', iconColor: 'black', group: ['transform'], diff --git a/packages/cli/src/__tests__/workflow-execute-additional-data.test.ts b/packages/cli/src/__tests__/workflow-execute-additional-data.test.ts index ed61afe15a0..ae1b233e233 100644 --- a/packages/cli/src/__tests__/workflow-execute-additional-data.test.ts +++ b/packages/cli/src/__tests__/workflow-execute-additional-data.test.ts @@ -1,7 +1,7 @@ -import { ModuleRegistry } from '@n8n/backend-common'; +import { Logger, ModuleRegistry } from '@n8n/backend-common'; import { EventService, UrlService } from '@n8n/backend-services'; import { mockInstance } from '@n8n/backend-test-utils'; -import { ExecutionsConfig, GlobalConfig, WorkflowsConfig } from '@n8n/config'; +import { ExecutionsConfig, GlobalConfig, type NodesConfig, WorkflowsConfig } from '@n8n/config'; import type { WorkflowEntity, Project, WorkflowHistory } from '@n8n/db'; import { ExecutionRepository, @@ -62,6 +62,8 @@ import { } from '@/workflow-execute-additional-data'; import * as WorkflowHelpers from '@/workflow-helpers'; import { WorkflowHookContextService } from '@/workflow-hook-context.service'; +import { DeprecatedNodesError } from '@/errors/response-errors/deprecated-nodes.error'; +import { DeprecatedNodesValidationService } from '@/workflows/deprecated-nodes-validation.service'; import { WorkflowPublishedDataService } from '@/workflows/workflow-published-data.service'; import { WorkflowStaticDataService } from '@/workflows/workflow-static-data.service'; @@ -141,6 +143,20 @@ describe('WorkflowExecuteAdditionalData', () => { mockInstance(DataTableProxyService); mockInstance(WorkflowHookContextService); const workflowPublishedDataService = mockInstance(WorkflowPublishedDataService); + const deprecatedNodeTypes = mock(); + deprecatedNodeTypes.getByNameAndVersion.mockImplementation((type) => + type === 'n8n-nodes-base.function' + ? mock({ description: { deprecated: true } }) + : mock(), + ); + Container.set( + DeprecatedNodesValidationService, + new DeprecatedNodesValidationService( + mock(), + mock({ blockDeprecated: true }), + deprecatedNodeTypes, + ), + ); const workflowsConfig = Container.get(WorkflowsConfig); afterEach(() => { // Keep the default (flag off) for every test; flag-on tests opt in. @@ -1196,6 +1212,15 @@ describe('WorkflowExecuteAdditionalData', () => { expect(result.id).toBe('parent-workflow-id'); }, ); + + it('refuses inline nodes of a deprecated type', async () => { + const nodes = [mock({ type: 'n8n-nodes-base.function', typeVersion: 1 })]; + const workflowCode = mock({ nodes, connections: {} }); + + await expect(loadWorkflow({ code: workflowCode }, 'parent-workflow-id')).rejects.toThrow( + DeprecatedNodesError, + ); + }); }); describe('getPublishedWorkflowData', () => { @@ -1425,6 +1450,17 @@ describe('WorkflowExecuteAdditionalData', () => { workflowRepository.get.mockClear(); }); + it('loads a stored workflow that contains a deprecated node', async () => { + const nodes = [mock({ type: 'n8n-nodes-base.function', typeVersion: 1 })]; + workflowRepository.get.mockResolvedValue( + mock({ id: 'workflow-123', nodes, connections: {} }), + ); + + const result = await getDraftWorkflowData({ id: 'workflow-123' }, 'parent-workflow-id'); + + expect(result.nodes).toEqual(nodes); + }); + it('should use draft version', async () => { const activeVersionNodes: INode[] = [ mock({ diff --git a/packages/cli/src/errors/response-errors/deprecated-nodes.error.ts b/packages/cli/src/errors/response-errors/deprecated-nodes.error.ts new file mode 100644 index 00000000000..b6c2b42fc3f --- /dev/null +++ b/packages/cli/src/errors/response-errors/deprecated-nodes.error.ts @@ -0,0 +1,23 @@ +import { ResponseError } from '@n8n/errors'; + +export type DeprecatedNodeViolation = { + kind: 'added' | 'edited'; + nodeName: string; + nodeType: string; +}; + +/** + * Thrown when a workflow adds or edits a node whose type is marked + * `deprecated: true`, on save or before an unsaved workflow or node runs. + * Lists the offending nodes in `meta.violations` so callers can act on them + * without parsing the error message. + */ +export class DeprecatedNodesError extends ResponseError { + constructor( + message: string, + readonly meta: { violations: DeprecatedNodeViolation[] }, + ) { + super(message, 400); + this.name = 'DeprecatedNodesError'; + } +} diff --git a/packages/cli/src/modules/chat-hub/__tests__/chat-hub-tool.service.test.ts b/packages/cli/src/modules/chat-hub/__tests__/chat-hub-tool.service.test.ts index 23e0b88b24a..ed464b82a84 100644 --- a/packages/cli/src/modules/chat-hub/__tests__/chat-hub-tool.service.test.ts +++ b/packages/cli/src/modules/chat-hub/__tests__/chat-hub-tool.service.test.ts @@ -1,4 +1,5 @@ import type { Logger } from '@n8n/backend-common'; +import type { NodesConfig } from '@n8n/config'; import type { User } from '@n8n/db'; import type { EntityManager } from '@n8n/typeorm'; import type { INode, INodeType } from 'n8n-workflow'; @@ -6,7 +7,9 @@ import { v4 as uuid } from 'uuid'; import { mock } from 'vitest-mock-extended'; import { BadRequestError, NotFoundError } from '@n8n/errors'; +import { DeprecatedNodesError } from '@/errors/response-errors/deprecated-nodes.error'; import type { NodeTypes } from '@/node-types'; +import { DeprecatedNodesValidationService } from '@/workflows/deprecated-nodes-validation.service'; import type { ChatHubTool } from '../chat-hub-tool.entity'; import type { ChatHubToolRepository } from '../chat-hub-tool.repository'; @@ -73,7 +76,16 @@ describe('ChatHubToolService', () => { description: { properties: [] }, } as unknown as INodeType); - service = new ChatHubToolService(logger, chatToolRepository, nodeTypes); + service = new ChatHubToolService( + logger, + chatToolRepository, + nodeTypes, + new DeprecatedNodesValidationService( + logger, + mock({ blockDeprecated: true }), + nodeTypes, + ), + ); }); describe('getToolsByUserId', () => { @@ -124,7 +136,34 @@ describe('ChatHubToolService', () => { }); }); + const deprecatedCodeTool: INode = { + ...mockDefinition, + type: '@n8n/n8n-nodes-langchain.code', + parameters: { code: { supplyData: { code: 'return 1;' } } }, + }; + + const markCodeNodeDeprecated = () => { + nodeTypes.getByNameAndVersion.mockImplementation( + (type) => + ({ + description: { + properties: [], + deprecated: type === deprecatedCodeTool.type ? true : undefined, + }, + }) as unknown as INodeType, + ); + }; + describe('createTool', () => { + it('rejects a tool of a deprecated node type', async () => { + markCodeNodeDeprecated(); + + await expect( + service.createTool(mockUser, { definition: deprecatedCodeTool }), + ).rejects.toThrow(DeprecatedNodesError); + expect(chatToolRepository.createTool).not.toHaveBeenCalled(); + }); + it('should create a tool from the definition', async () => { const created = makeTool(); chatToolRepository.createTool.mockResolvedValue(created); @@ -288,6 +327,40 @@ describe('ChatHubToolService', () => { }); describe('updateTool', () => { + it('rejects changing a tool of a deprecated node type', async () => { + markCodeNodeDeprecated(); + chatToolRepository.getOneById.mockResolvedValue(makeTool({ definition: deprecatedCodeTool })); + + await expect( + service.updateTool('tool-1', mockUser, { + definition: { ...deprecatedCodeTool, name: 'Renamed tool' }, + }), + ).rejects.toThrow(DeprecatedNodesError); + expect(chatToolRepository.updateTool).not.toHaveBeenCalled(); + }); + + it('rejects enabling a stored tool of a deprecated node type', async () => { + markCodeNodeDeprecated(); + chatToolRepository.getOneById.mockResolvedValue( + makeTool({ definition: deprecatedCodeTool, enabled: false }), + ); + + await expect(service.updateTool('tool-1', mockUser, { enabled: true })).rejects.toThrow( + DeprecatedNodesError, + ); + expect(chatToolRepository.updateTool).not.toHaveBeenCalled(); + }); + + it('allows disabling a stored tool of a deprecated node type', async () => { + markCodeNodeDeprecated(); + chatToolRepository.getOneById.mockResolvedValue(makeTool({ definition: deprecatedCodeTool })); + chatToolRepository.updateTool.mockResolvedValue(makeTool({ enabled: false })); + + await service.updateTool('tool-1', mockUser, { enabled: false }); + + expect(chatToolRepository.updateTool).toHaveBeenCalled(); + }); + it('should update tool definition and denormalized fields', async () => { const existingTool = makeTool(); const updatedDef: INode = { ...mockDefinition, name: 'Updated Tool' }; diff --git a/packages/cli/src/modules/chat-hub/__tests__/chat-hub-workflow.service.test.ts b/packages/cli/src/modules/chat-hub/__tests__/chat-hub-workflow.service.test.ts index 1adef174d59..86d98215bf4 100644 --- a/packages/cli/src/modules/chat-hub/__tests__/chat-hub-workflow.service.test.ts +++ b/packages/cli/src/modules/chat-hub/__tests__/chat-hub-workflow.service.test.ts @@ -7,7 +7,9 @@ import { type IBinaryData, type INode, CHAT_TRIGGER_NODE_TYPE } from 'n8n-workfl import type { Mock } from 'vitest'; import { mock } from 'vitest-mock-extended'; +import { DeprecatedNodesError } from '@/errors/response-errors/deprecated-nodes.error'; import type { PolicyEnforcementService } from '@/policy/policy-enforcement.service'; +import type { DeprecatedNodesValidationService } from '@/workflows/deprecated-nodes-validation.service'; import type { WorkflowFinderService } from '@/workflows/workflow-finder.service'; import type { ChatHubAgent } from '../chat-hub-agent.entity'; @@ -38,6 +40,7 @@ describe('ChatHubWorkflowService', () => { const mockCipher = mock(); const policyEnforcementService = mock(); + const deprecatedNodesValidationService = mock(); let chatHubAttachmentService: ChatHubAttachmentService; let service: ChatHubWorkflowService; @@ -72,6 +75,7 @@ describe('ChatHubWorkflowService', () => { workflowFinderService, mockCipher, policyEnforcementService, + deprecatedNodesValidationService, ); // Mock repository methods @@ -162,6 +166,32 @@ describe('ChatHubWorkflowService', () => { expect(workflowRepository.createContent).not.toHaveBeenCalled(); }); + it('writes nothing when the workflow contains a deprecated node', async () => { + deprecatedNodesValidationService.validateOnCreate.mockImplementationOnce(() => { + throw new DeprecatedNodesError('deprecated', { violations: [] }); + }); + + await expect( + service.createChatWorkflow( + 'user-123', + 'session-456', + 'project-789', + [], + 'Hello', + [], + {}, + { provider: 'openai', model: 'gpt-4' }, + undefined, + [], + 'UTC', + null, + defaultExecutionMetadata, + ), + ).rejects.toThrow(DeprecatedNodesError); + + expect(workflowRepository.createContent).not.toHaveBeenCalled(); + }); + it('enforces the save for the title-generation workflow', async () => { await service.createTitleGenerationWorkflow( 'user-123', @@ -1363,6 +1393,7 @@ describe('ChatHubWorkflowService', () => { workflowFinderService, mockCipher, policyEnforcementService, + deprecatedNodesValidationService, ); const mockTrx = mock(); diff --git a/packages/cli/src/modules/chat-hub/chat-hub-tool.service.ts b/packages/cli/src/modules/chat-hub/chat-hub-tool.service.ts index c8eeeebbb7f..4291a553a88 100644 --- a/packages/cli/src/modules/chat-hub/chat-hub-tool.service.ts +++ b/packages/cli/src/modules/chat-hub/chat-hub-tool.service.ts @@ -11,6 +11,7 @@ import { collectExpressionDefaults, findDisallowedChatToolExpressions } from 'n8 import { BadRequestError, NotFoundError } from '@n8n/errors'; import { NodeTypes } from '@/node-types'; +import { DeprecatedNodesValidationService } from '@/workflows/deprecated-nodes-validation.service'; import type { ChatHubTool } from './chat-hub-tool.entity'; import { ChatHubToolRepository } from './chat-hub-tool.repository'; @@ -21,6 +22,7 @@ export class ChatHubToolService { private readonly logger: Logger, private readonly chatToolRepository: ChatHubToolRepository, private readonly nodeTypes: NodeTypes, + private readonly deprecatedNodesValidationService: DeprecatedNodesValidationService, ) { this.logger = this.logger.scoped('chat-hub'); } @@ -85,6 +87,7 @@ export class ChatHubToolService { async createTool(user: User, data: ChatHubCreateToolRequest): Promise { const definition = data.definition; this.validateToolExpressions(definition); + this.deprecatedNodesValidationService.validateOnCreate([definition]); const tool = await this.chatToolRepository.createTool({ id: definition.id, @@ -117,12 +120,21 @@ export class ChatHubToolService { if (updates.definition !== undefined) { this.validateToolExpressions(updates.definition); + this.deprecatedNodesValidationService.validateOnUpdate( + [updates.definition], + [existingTool.definition], + ); updateData.definition = updates.definition; updateData.name = updates.definition.name; updateData.type = updates.definition.type; updateData.typeVersion = updates.definition.typeVersion ?? 1; } if (updates.enabled !== undefined) { + if (updates.enabled) { + this.deprecatedNodesValidationService.validateOnCreate([ + updates.definition ?? existingTool.definition, + ]); + } updateData.enabled = updates.enabled; } diff --git a/packages/cli/src/modules/chat-hub/chat-hub-workflow.service.ts b/packages/cli/src/modules/chat-hub/chat-hub-workflow.service.ts index ec35d2f5ad6..bc804c08f61 100644 --- a/packages/cli/src/modules/chat-hub/chat-hub-workflow.service.ts +++ b/packages/cli/src/modules/chat-hub/chat-hub-workflow.service.ts @@ -52,6 +52,7 @@ import { v4 as uuidv4 } from 'uuid'; import { BadRequestError } from '@n8n/errors'; import type { UserLike } from '@/types/user-like.types'; import { PolicyEnforcementService } from '@/policy/policy-enforcement.service'; +import { DeprecatedNodesValidationService } from '@/workflows/deprecated-nodes-validation.service'; import { WorkflowFinderService } from '@/workflows/workflow-finder.service'; import { ChatHubAgentRepository } from './chat-hub-agent.repository'; @@ -96,6 +97,7 @@ export class ChatHubWorkflowService { private readonly workflowFinderService: WorkflowFinderService, private readonly cipher: Cipher, private readonly policyEnforcementService: PolicyEnforcementService, + private readonly deprecatedNodesValidationService: DeprecatedNodesValidationService, ) { this.logger = this.logger.scoped('chat-hub'); } @@ -196,6 +198,8 @@ export class ChatHubWorkflowService { projectId: string, user: UserLike, ) { + this.deprecatedNodesValidationService.validateOnCreate(workflow.nodes, workflow.id); + return await this.policyEnforcementService.enforceWorkflowSave( { workflow: { id: workflow.id ?? null, name: workflow.name, nodes: workflow.nodes }, diff --git a/packages/cli/src/modules/source-control.ee/__tests__/source-control-import.service.ee.test.ts b/packages/cli/src/modules/source-control.ee/__tests__/source-control-import.service.ee.test.ts index d4002e60229..e08c27ce196 100644 --- a/packages/cli/src/modules/source-control.ee/__tests__/source-control-import.service.ee.test.ts +++ b/packages/cli/src/modules/source-control.ee/__tests__/source-control-import.service.ee.test.ts @@ -36,6 +36,7 @@ import { mock } from 'vitest-mock-extended'; import type { CredentialsService } from '@/credentials/credentials.service'; import type { VariablesService } from '@/environments.ee/variables/variables.service.ee'; import type { ExecutionPersistence } from '@/executions/execution-persistence'; +import { DeprecatedNodesError } from '@/errors/response-errors/deprecated-nodes.error'; import { WorkflowPublishBlockedError } from '@/errors/response-errors/workflow-publish-blocked.error'; import type { DataTableColumnRepository } from '@/modules/data-table/data-table-column.repository'; import type { DataTableDDLService } from '@/modules/data-table/data-table-ddl.service'; @@ -44,6 +45,7 @@ import type { DataTableRepository } from '@/modules/data-table/data-table.reposi import type { RedactionEnforcementService } from '@/modules/redaction/redaction-enforcement.service'; import type { PolicyEnforcementService } from '@/policy/policy-enforcement.service'; import { PolicyViolationError } from '@/policy/policy-violation.error'; +import type { DeprecatedNodesValidationService } from '@/workflows/deprecated-nodes-validation.service'; import type { WorkflowHistoryService } from '@/workflows/workflow-history/workflow-history.service'; import type { WorkflowMutationHooksProxy } from '@/workflows/workflow-mutation-hooks-proxy.service'; import type { WorkflowPublishGuardProxy } from '@/workflows/workflow-publish-guard-proxy.service'; @@ -86,6 +88,7 @@ describe('SourceControlImportService', () => { const dataTableColumnRepository = mock(); const dataTableDDLService = mock(); const redactionEnforcementService = mock(); + const deprecatedNodesValidationService = mock(); const policyEnforcementService = mock(); policyEnforcementService.hasChecksFor.mockReturnValue(true); policyEnforcementService.enforceContentImport.mockResolvedValue(mock()); @@ -140,6 +143,7 @@ describe('SourceControlImportService', () => { workflowPublishGuard, workflowMutationHooks, workflowFinderService, + deprecatedNodesValidationService, ); const globMock = fastGlob.default as unknown as Mock<(...args: string[]) => Promise>; @@ -1618,6 +1622,82 @@ describe('SourceControlImportService', () => { }); }); + describe('deprecated node enforcement', () => { + const mockUserId = 'user-id-123'; + const deprecatedNode = { + id: 'node-1', + name: 'Function', + type: 'n8n-nodes-base.function', + typeVersion: 1, + position: [0, 0], + parameters: { functionCode: 'return items;' }, + }; + const workflowFile = (id: string) => + JSON.stringify({ + id, + name: `Workflow ${id}`, + active: false, + nodes: [deprecatedNode], + connections: {}, + versionId: 'v1', + parentFolderId: null, + }); + + beforeEach(() => { + projectRepository.getPersonalProjectForUserOrFail.mockResolvedValue( + Object.assign(new Project(), { + id: 'personal-project-id-123', + name: 'Personal Project', + type: 'personal', + createdAt: new Date(), + updatedAt: new Date(), + }), + ); + folderRepository.find.mockResolvedValue([]); + sharedWorkflowRepository.findWithFields.mockResolvedValue([]); + workflowRepository.upsertImportedContent.mockResolvedValue('1'); + }); + + it('rejects a new workflow that fails the deprecated-node check', async () => { + workflowRepository.findByIds.mockResolvedValue([]); + fsReadFile.mockResolvedValueOnce(workflowFile('2')); + deprecatedNodesValidationService.validateOnCreate.mockImplementationOnce(() => { + throw new DeprecatedNodesError('blocked', { violations: [] }); + }); + + const candidates = [mock({ file: '/mock/workflow2.json', id: '2' })]; + + await expect(service.importWorkflowFromWorkFolder(candidates, mockUserId)).rejects.toThrow( + DeprecatedNodesError, + ); + + expect(deprecatedNodesValidationService.validateOnCreate).toHaveBeenCalledWith( + [deprecatedNode], + '2', + ); + expect(workflowRepository.upsertImportedContent).not.toHaveBeenCalled(); + }); + + it('checks an existing workflow against its stored nodes', async () => { + workflowRepository.findByIds.mockResolvedValue([ + Object.assign(new WorkflowEntity(), { id: '1', nodes: [deprecatedNode] }), + ]); + fsReadFile.mockResolvedValueOnce(workflowFile('1')); + + const candidates = [mock({ file: '/mock/workflow1.json', id: '1' })]; + + await service.importWorkflowFromWorkFolder(candidates, mockUserId); + + expect(deprecatedNodesValidationService.validateOnUpdate).toHaveBeenCalledWith( + [deprecatedNode], + [deprecatedNode], + '1', + ); + expect(deprecatedNodesValidationService.validateOnCreate).not.toHaveBeenCalled(); + expect(workflowRepository.upsertImportedContent).toHaveBeenCalled(); + }); + }); + describe('content-import policy enforcement', () => { const mockUserId = 'user-id-123'; const mockWorkflowFile = '/mock/workflow1.json'; diff --git a/packages/cli/src/modules/source-control.ee/source-control-import.service.ee.ts b/packages/cli/src/modules/source-control.ee/source-control-import.service.ee.ts index 3d562943b26..25414fab59d 100644 --- a/packages/cli/src/modules/source-control.ee/source-control-import.service.ee.ts +++ b/packages/cli/src/modules/source-control.ee/source-control-import.service.ee.ts @@ -61,6 +61,7 @@ import { isUniqueConstraintError } from '@/response-helper'; import { TagService } from '@/services/tag.service'; import { assertNever } from '@/utils'; import { validateWorkflowNodeGroups, sanitizeNodeGroupDescriptions } from '@/workflow-helpers'; +import { DeprecatedNodesValidationService } from '@/workflows/deprecated-nodes-validation.service'; import { WorkflowFinderService } from '@/workflows/workflow-finder.service'; import { WorkflowHistoryService } from '@/workflows/workflow-history/workflow-history.service'; import { WorkflowMutationHooksProxy } from '@/workflows/workflow-mutation-hooks-proxy.service'; @@ -173,6 +174,7 @@ export class SourceControlImportService { private readonly workflowPublishGuard: WorkflowPublishGuardProxy, private readonly workflowMutationHooks: WorkflowMutationHooksProxy, private readonly workflowFinderService: WorkflowFinderService, + private readonly deprecatedNodesValidationService: DeprecatedNodesValidationService, ) { this.gitFolder = path.join(instanceSettings.n8nFolder, SOURCE_CONTROL_GIT_FOLDER); this.workflowExportFolder = path.join(this.gitFolder, SOURCE_CONTROL_WORKFLOW_EXPORT_FOLDER); @@ -783,7 +785,7 @@ export class SourceControlImportService { const personalProject = await this.projectRepository.getPersonalProjectForUserOrFail(userId); const candidateIds = candidates.map((c) => c.id); const existingWorkflows = await this.workflowRepository.findByIds(candidateIds, { - fields: ['id', 'name', 'versionId', 'active', 'activeVersionId', 'isArchived'], + fields: ['id', 'name', 'versionId', 'active', 'activeVersionId', 'isArchived', 'nodes'], }); const folders = await this.folderRepository.find({ select: ['id'] }); @@ -857,6 +859,19 @@ export class SourceControlImportService { } const existingWorkflow = existingWorkflows.find((e) => e.id === id); + // Apply the same deprecated-node enforcement as the REST/public API + // endpoints. A re-sync that leaves the deprecated nodes untouched is + // allowed; introducing or in-place editing them through git is not. + if (existingWorkflow) { + this.deprecatedNodesValidationService.validateOnUpdate( + nodes, + existingWorkflow.nodes ?? [], + id, + ); + } else { + this.deprecatedNodesValidationService.validateOnCreate(nodes, id); + } + await this.redactionEnforcementService.assertPolicyChangeAllowed( existingWorkflow?.settings?.redactionPolicy, importedWorkflow.settings?.redactionPolicy, diff --git a/packages/cli/src/modules/workflow-reviews.ee/__tests__/workflow-review-lifecycle.integration.test.ts b/packages/cli/src/modules/workflow-reviews.ee/__tests__/workflow-review-lifecycle.integration.test.ts index e40bafebe27..1bef79d7202 100644 --- a/packages/cli/src/modules/workflow-reviews.ee/__tests__/workflow-review-lifecycle.integration.test.ts +++ b/packages/cli/src/modules/workflow-reviews.ee/__tests__/workflow-review-lifecycle.integration.test.ts @@ -641,6 +641,7 @@ describe('auto-close on source-control pull', () => { mock(), // workflowPublishGuard Container.get(WorkflowMutationHooksProxy), Container.get(WorkflowFinderService), + mock(), ); }); diff --git a/packages/cli/src/node-execution/__tests__/execute-node.service.test.ts b/packages/cli/src/node-execution/__tests__/execute-node.service.test.ts index 7d7066008dd..d3b2e8c26af 100644 --- a/packages/cli/src/node-execution/__tests__/execute-node.service.test.ts +++ b/packages/cli/src/node-execution/__tests__/execute-node.service.test.ts @@ -1,5 +1,6 @@ import { Logger } from '@n8n/backend-common'; import { mockInstance } from '@n8n/backend-test-utils'; +import type { NodesConfig } from '@n8n/config'; import { WorkflowRepository } from '@n8n/db'; import type { User } from '@n8n/db'; import { InstanceSettings } from 'n8n-core'; @@ -11,7 +12,9 @@ import { CredentialsFinderService } from '@n8n/backend-services'; import { BadRequestError, ForbiddenError } from '@n8n/errors'; import { ExecutionPersistence } from '@/executions/execution-persistence'; import { NodeTypes } from '@/node-types'; +import { DeprecatedNodesError } from '@/errors/response-errors/deprecated-nodes.error'; import { WorkflowRunner } from '@/workflow-runner'; +import { DeprecatedNodesValidationService } from '@/workflows/deprecated-nodes-validation.service'; import { ExecuteNodeService } from '../execute-node.service'; import type { ExecuteNodeRequest } from '../execute-node.service'; @@ -54,6 +57,11 @@ describe('ExecuteNodeService', () => { const activeExecutions = mockInstance(ActiveExecutions); const executionPersistence = mockInstance(ExecutionPersistence); const instanceSettings = mockInstance(InstanceSettings); + const deprecatedNodesValidationService = new DeprecatedNodesValidationService( + logger, + mock({ blockDeprecated: true }), + nodeTypes, + ); const service = new ExecuteNodeService( nodeTypes, @@ -64,6 +72,7 @@ describe('ExecuteNodeService', () => { activeExecutions, executionPersistence, instanceSettings, + deprecatedNodesValidationService, ); const user = mock({ id: 'user-1' }); @@ -86,6 +95,17 @@ describe('ExecuteNodeService', () => { }); describe('pre-flight validation', () => { + it('rejects a deprecated node type before creating any workflow row', async () => { + nodeTypes.getByNameAndVersion.mockReturnValue( + mockNodeType({ description: { ...testNodeDescription, deprecated: true } }), + ); + + await expect( + service.run(user, baseRequest({ type: 'n8n-nodes-base.function', version: 1 })), + ).rejects.toThrow(DeprecatedNodesError); + expect(workflowRepository.createWorkflowWithOwner).not.toHaveBeenCalled(); + }); + it('rejects an unknown node type before creating any workflow row', async () => { nodeTypes.getByNameAndVersion.mockImplementation(() => { throw new Error('unknown'); diff --git a/packages/cli/src/node-execution/execute-node.service.ts b/packages/cli/src/node-execution/execute-node.service.ts index 4abfda08a31..30a58e11fd1 100644 --- a/packages/cli/src/node-execution/execute-node.service.ts +++ b/packages/cli/src/node-execution/execute-node.service.ts @@ -22,6 +22,7 @@ import { BadRequestError, ForbiddenError } from '@n8n/errors'; import { ExecutionPersistence } from '@/executions/execution-persistence'; import { NodeTypes } from '@/node-types'; import { WorkflowRunner } from '@/workflow-runner'; +import { DeprecatedNodesValidationService } from '@/workflows/deprecated-nodes-validation.service'; export const DEFAULT_EXECUTE_NODE_TIMEOUT_MS = 30_000; export const MAX_EXECUTE_NODE_TIMEOUT_MS = 60_000; @@ -86,6 +87,7 @@ export class ExecuteNodeService { private readonly activeExecutions: ActiveExecutions, private readonly executionPersistence: ExecutionPersistence, private readonly instanceSettings: InstanceSettings, + private readonly deprecatedNodesValidationService: DeprecatedNodesValidationService, ) {} async run(user: User, request: ExecuteNodeRequest): Promise { @@ -98,6 +100,7 @@ export class ExecuteNodeService { await this.checkCredentialAccess(user, credentials); const node = this.buildNode(request, credentials); + this.deprecatedNodesValidationService.validateOnCreate([node]); const timeoutMs = Math.min( request.timeoutMs ?? DEFAULT_EXECUTE_NODE_TIMEOUT_MS, MAX_EXECUTE_NODE_TIMEOUT_MS, diff --git a/packages/cli/src/services/__tests__/import.service.test.ts b/packages/cli/src/services/__tests__/import.service.test.ts index b081e0ed3ea..90769381f90 100644 --- a/packages/cli/src/services/__tests__/import.service.test.ts +++ b/packages/cli/src/services/__tests__/import.service.test.ts @@ -135,6 +135,7 @@ describe('ImportService', () => { mockPolicyEnforcementService, mockSharedWorkflowRepository, mockWorkflowRepository, + mock(), ); }); diff --git a/packages/cli/src/services/import.service.ts b/packages/cli/src/services/import.service.ts index 25df754f004..0a19984172f 100644 --- a/packages/cli/src/services/import.service.ts +++ b/packages/cli/src/services/import.service.ts @@ -40,6 +40,7 @@ import { validateWorkflowStructure, sanitizeNodeGroupDescriptions, } from '@/workflow-helpers'; +import { DeprecatedNodesValidationService } from '@/workflows/deprecated-nodes-validation.service'; import { WorkflowService } from '@/workflows/workflow.service'; import { DATA_TABLE_ROWS_FILE_PREFIX } from './data-table-export.constants'; @@ -90,6 +91,7 @@ export class ImportService { private readonly policyEnforcementService: PolicyEnforcementService, private readonly sharedWorkflowRepository: SharedWorkflowRepository, private readonly workflowRepository: WorkflowRepository, + private readonly deprecatedNodesValidationService: DeprecatedNodesValidationService, ) {} async initRecords() { @@ -118,20 +120,22 @@ export class ImportService { const workflowIds = workflows.map((w) => w.id).filter((id) => !!id); const existingWorkflowIds = new Set(); const activeVersionIdByWorkflow = new Map(); + const existingNodesByWorkflow = new Map(); let existingOwnerProjects = new Map(); if (workflowIds.length > 0) { const existingWorkflows = await dbManager.find(WorkflowEntity, { where: { id: In(workflowIds) }, - select: ['id', 'activeVersionId'], + select: ['id', 'activeVersionId', 'nodes'], }); - for (const { id, activeVersionId } of existingWorkflows) { + for (const { id, activeVersionId, nodes } of existingWorkflows) { existingWorkflowIds.add(id); if (activeVersionId !== null) { activeVersionIdByWorkflow.set(id, activeVersionId); } + existingNodesByWorkflow.set(id, nodes ?? []); } // An existing workflow's policy scope is its own project, not `projectId` — that @@ -162,6 +166,21 @@ export class ImportService { if (hasInvalidCreds) await this.replaceInvalidCreds(workflow, projectId); validateWorkflowStructure(workflow); + // Apply the same deprecated-node enforcement that the REST/public API + // endpoints use: treat unknown workflow IDs as creates, known IDs as + // updates (so a re-import of an existing workflow with the same + // deprecated nodes is allowed, but introducing new ones is not). + const existingNodes = workflow.id ? existingNodesByWorkflow.get(workflow.id) : undefined; + if (existingNodes) { + this.deprecatedNodesValidationService.validateOnUpdate( + workflow.nodes, + existingNodes, + workflow.id, + ); + } else { + this.deprecatedNodesValidationService.validateOnCreate(workflow.nodes, workflow.id); + } + for (const warning of sanitizeNodeGroupDescriptions(workflow)) { this.logger.warn(`Workflow "${workflow.name}": ${warning}`); } diff --git a/packages/cli/src/workflow-execute-additional-data.ts b/packages/cli/src/workflow-execute-additional-data.ts index 0135c2c491f..7055378f0ba 100644 --- a/packages/cli/src/workflow-execute-additional-data.ts +++ b/packages/cli/src/workflow-execute-additional-data.ts @@ -77,6 +77,7 @@ import { TaskRequester } from '@/task-runners/task-managers/task-requester'; import { findSubworkflowStart } from '@/utils'; import { objectToError } from '@/utils/object-to-error'; import * as WorkflowHelpers from '@/workflow-helpers'; +import { DeprecatedNodesValidationService } from '@/workflows/deprecated-nodes-validation.service'; import { getWorkflowProjectDetailsSafe } from '@/workflows/utils'; import { WorkflowPublishedDataService } from '@/workflows/workflow-published-data.service'; @@ -157,6 +158,11 @@ async function fetchWorkflowData( } else { const workflowData = workflowInfo.code; if (workflowData) { + // The save-time check does not see inline JSON, so it runs here. + Container.get(DeprecatedNodesValidationService).validateOnCreate( + workflowData.nodes ?? [], + parentWorkflowId, + ); // An inline sub-workflow is part of the parent that embeds it, not a // workflow of its own, so it runs under the parent workflow's id. workflowData.id = parentWorkflowId; diff --git a/packages/cli/src/workflows/__tests__/deprecated-nodes-validation.service.test.ts b/packages/cli/src/workflows/__tests__/deprecated-nodes-validation.service.test.ts new file mode 100644 index 00000000000..0ec7eebc28c --- /dev/null +++ b/packages/cli/src/workflows/__tests__/deprecated-nodes-validation.service.test.ts @@ -0,0 +1,322 @@ +import type { Logger } from '@n8n/backend-common'; +import type { NodesConfig } from '@n8n/config'; +import type { INode, INodeType } from 'n8n-workflow'; +import { fail } from 'node:assert'; +import { mock } from 'vitest-mock-extended'; + +import { + DeprecatedNodesError, + type DeprecatedNodeViolation, +} from '@/errors/response-errors/deprecated-nodes.error'; +import type { NodeTypes } from '@/node-types'; +import { DeprecatedNodesValidationService } from '@/workflows/deprecated-nodes-validation.service'; + +describe('DeprecatedNodesValidationService', () => { + let validator: DeprecatedNodesValidationService; + let nodesConfig: NodesConfig; + let nodeTypes: ReturnType>; + let logger: ReturnType>; + + const nodeTypeFor = (type: string, deprecated?: boolean): INodeType => + Object.assign(mock(), { + description: { + name: type, + deprecated: deprecated ? true : undefined, + properties: [ + { displayName: 'Code', name: 'functionCode', type: 'string', default: '// default' }, + ], + }, + }); + + beforeEach(() => { + nodesConfig = { blockDeprecated: true } as NodesConfig; + nodeTypes = mock(); + logger = mock(); + + nodeTypes.getByNameAndVersion.mockImplementation((type) => { + if (type === 'n8n-nodes-base.function' || type === 'n8n-nodes-base.functionItem') { + return nodeTypeFor(type, true); + } + return nodeTypeFor(type, false); + }); + + validator = new DeprecatedNodesValidationService(logger, nodesConfig, nodeTypes); + }); + + const expectViolations = (run: () => void, violations: DeprecatedNodeViolation[]) => { + try { + run(); + fail('expected to throw'); + } catch (error) { + expect(error).toBeInstanceOf(DeprecatedNodesError); + expect((error as DeprecatedNodesError).meta.violations).toEqual(violations); + } + }; + + const makeNode = (overrides: Partial & Pick): INode => ({ + name: overrides.id, + typeVersion: 1, + position: [0, 0], + parameters: {}, + ...overrides, + }); + + describe('validateOnCreate', () => { + it('passes when no deprecated nodes are present', () => { + const nodes = [makeNode({ id: 'a', type: 'n8n-nodes-base.set' })]; + expect(() => validator.validateOnCreate(nodes)).not.toThrow(); + }); + + it('throws DeprecatedNodesError when any node type is deprecated', () => { + const nodes = [ + makeNode({ id: 'a', type: 'n8n-nodes-base.set' }), + makeNode({ id: 'b', type: 'n8n-nodes-base.function' }), + ]; + expect(() => validator.validateOnCreate(nodes)).toThrow(DeprecatedNodesError); + expect(() => validator.validateOnCreate(nodes)).toThrow(/deprecated/); + }); + + it('exposes every deprecated node in error meta and message', () => { + const nodes = [ + makeNode({ id: 'a', type: 'n8n-nodes-base.function', name: 'Func A' }), + makeNode({ id: 'b', type: 'n8n-nodes-base.functionItem', name: 'Func B' }), + ]; + try { + validator.validateOnCreate(nodes); + fail('expected to throw'); + } catch (error) { + expect(error).toBeInstanceOf(DeprecatedNodesError); + const typed = error as DeprecatedNodesError; + expect(typed.meta.violations).toEqual([ + { kind: 'added', nodeName: 'Func A', nodeType: 'n8n-nodes-base.function' }, + { kind: 'added', nodeName: 'Func B', nodeType: 'n8n-nodes-base.functionItem' }, + ]); + expect(typed.message).toContain('Func A'); + expect(typed.message).toContain('Func B'); + } + }); + + it('does not treat an unknown node type as deprecated', () => { + nodeTypes.getByNameAndVersion.mockImplementation(() => { + throw new Error('Unrecognized node type'); + }); + const nodes = [makeNode({ id: 'a', type: 'community.unknown' })]; + expect(() => validator.validateOnCreate(nodes)).not.toThrow(); + }); + + it('is a no-op when the config flag is off', () => { + nodesConfig.blockDeprecated = false; + const nodes = [makeNode({ id: 'a', type: 'n8n-nodes-base.function' })]; + expect(() => validator.validateOnCreate(nodes)).not.toThrow(); + }); + }); + + describe('validateOnUpdate', () => { + it('allows an unchanged deprecated node to pass through', () => { + const node = makeNode({ + id: 'a', + type: 'n8n-nodes-base.function', + parameters: { functionCode: 'return items;' }, + }); + expect(() => validator.validateOnUpdate([node], [node])).not.toThrow(); + }); + + it('allows position-only changes to a deprecated node', () => { + const before = makeNode({ + id: 'a', + type: 'n8n-nodes-base.function', + name: 'Func', + position: [0, 0], + }); + const after = { ...before, position: [200, 100] as [number, number] }; + expect(() => validator.validateOnUpdate([after], [before])).not.toThrow(); + }); + + it('allows a structurally equal copy of a deprecated node', () => { + const before = makeNode({ + id: 'a', + type: 'n8n-nodes-base.function', + parameters: { functionCode: 'return items;' }, + credentials: { someApi: { id: '1', name: 'Some API' } }, + }); + expect(() => validator.validateOnUpdate([structuredClone(before)], [before])).not.toThrow(); + }); + + it('treats absent and default disabled and credentials as unchanged', () => { + const before = makeNode({ id: 'a', type: 'n8n-nodes-base.function' }); + const after = { ...before, disabled: false, credentials: {} }; + expect(() => validator.validateOnUpdate([after], [before])).not.toThrow(); + }); + + it('allows the editor-saved shape of a deprecated node stored with explicit defaults', () => { + const before: INode = { + ...makeNode({ id: 'a', type: 'n8n-nodes-base.function', name: 'Func' }), + parameters: { functionCode: '// default' }, + notes: '', + onError: 'stopWorkflow', + continueOnFail: false, + }; + const after = makeNode({ id: 'a', type: 'n8n-nodes-base.function', name: 'Func' }); + expect(() => validator.validateOnUpdate([after], [before])).not.toThrow(); + }); + + it('blocks adding notes to an existing deprecated node', () => { + const before = makeNode({ id: 'a', type: 'n8n-nodes-base.function', name: 'Func' }); + const after = { ...before, notes: 'changed' }; + expect(() => validator.validateOnUpdate([after], [before])).toThrow(/Cannot modify.*Func/); + }); + + it('blocks disabling an existing deprecated node', () => { + const before = makeNode({ id: 'a', type: 'n8n-nodes-base.function', name: 'Func' }); + const after = { ...before, disabled: true }; + expect(() => validator.validateOnUpdate([after], [before])).toThrow(/Cannot modify.*Func/); + }); + + it('blocks changing the credentials of an existing deprecated node', () => { + const before = makeNode({ id: 'a', type: 'n8n-nodes-base.function', name: 'Func' }); + const after = { ...before, credentials: { someApi: { id: '2', name: 'Other API' } } }; + expect(() => validator.validateOnUpdate([after], [before])).toThrow(/Cannot modify.*Func/); + }); + + it('blocks changing the error handling of an existing deprecated node', () => { + const before = makeNode({ id: 'a', type: 'n8n-nodes-base.function', name: 'Func' }); + const after: INode = { ...before, onError: 'continueRegularOutput' }; + expect(() => validator.validateOnUpdate([after], [before])).toThrow(/Cannot modify.*Func/); + }); + + it('treats a deprecated node sent with a new id as added', () => { + const before = makeNode({ id: 'a', type: 'n8n-nodes-base.function', name: 'Func' }); + const after = { ...before, id: 'b' }; + expectViolations( + () => validator.validateOnUpdate([after], [before]), + [{ kind: 'added', nodeName: 'Func', nodeType: 'n8n-nodes-base.function' }], + ); + }); + + it('treats a deprecated node sent without an id as added', () => { + const before = makeNode({ id: 'a', type: 'n8n-nodes-base.function', name: 'Func' }); + const after = { ...before, id: undefined as unknown as string }; + expectViolations( + () => validator.validateOnUpdate([after], [before]), + [{ kind: 'added', nodeName: 'Func', nodeType: 'n8n-nodes-base.function' }], + ); + }); + + it('treats a deprecated node reusing the id of a non-deprecated node as added', () => { + const before = makeNode({ id: 'a', type: 'n8n-nodes-base.set', name: 'Set' }); + const after = makeNode({ id: 'a', type: 'n8n-nodes-base.function', name: 'Set' }); + expectViolations( + () => validator.validateOnUpdate([after], [before]), + [{ kind: 'added', nodeName: 'Set', nodeType: 'n8n-nodes-base.function' }], + ); + }); + + it('logs a warning with the workflow id when it rejects a save', () => { + const before = makeNode({ id: 'a', type: 'n8n-nodes-base.function', name: 'Func' }); + const after = { ...before, parameters: { functionCode: 'return [];' } }; + expect(() => validator.validateOnUpdate([after], [before], 'wf-1')).toThrow( + DeprecatedNodesError, + ); + expect(logger.warn).toHaveBeenCalledWith(expect.any(String), { + workflowId: 'wf-1', + violations: [{ kind: 'edited', nodeType: 'n8n-nodes-base.function' }], + }); + }); + + it('blocks adding a deprecated node that did not exist before', () => { + const before = [makeNode({ id: 'a', type: 'n8n-nodes-base.set' })]; + const after = [ + ...before, + makeNode({ id: 'b', type: 'n8n-nodes-base.function', name: 'New Func' }), + ]; + expect(() => validator.validateOnUpdate(after, before)).toThrow(/Cannot use.*New Func/); + }); + + it('blocks editing the parameters of an existing deprecated node', () => { + const before = makeNode({ + id: 'a', + type: 'n8n-nodes-base.function', + name: 'Func', + parameters: { functionCode: 'return items;' }, + }); + const after = { ...before, parameters: { functionCode: 'return [];' } }; + expect(() => validator.validateOnUpdate([after], [before])).toThrow(/Cannot modify.*Func/); + }); + + it('blocks editing the name of an existing deprecated node', () => { + const before = makeNode({ + id: 'a', + type: 'n8n-nodes-base.function', + name: 'Func', + }); + const after = { ...before, name: 'Renamed' }; + expect(() => validator.validateOnUpdate([after], [before])).toThrow(/Cannot modify/); + }); + + it('blocks bumping the typeVersion of a non-deprecated node down to a deprecated version', () => { + nodeTypes.getByNameAndVersion.mockImplementation((type, version) => { + if (type === 'n8n-nodes-base.myNode' && version === 1) { + return nodeTypeFor(type, true); + } + return nodeTypeFor(type, false); + }); + + const before = makeNode({ id: 'a', type: 'n8n-nodes-base.myNode', typeVersion: 2 }); + const after = { ...before, typeVersion: 1 }; + expect(() => validator.validateOnUpdate([after], [before])).toThrow(/Cannot use/); + }); + + it('allows migrating a deprecated typeVersion forward, including parameter changes', () => { + nodeTypes.getByNameAndVersion.mockImplementation((type, version) => { + if (type === 'n8n-nodes-base.myNode' && version === 1) { + return nodeTypeFor(type, true); + } + return nodeTypeFor(type, false); + }); + + const before = makeNode({ + id: 'a', + type: 'n8n-nodes-base.myNode', + typeVersion: 1, + parameters: { mode: 'legacy' }, + }); + const after = { + ...before, + typeVersion: 2, + parameters: { mode: 'safe', extra: 'new-field' }, + }; + expect(() => validator.validateOnUpdate([after], [before])).not.toThrow(); + }); + + it('allows replacing a deprecated node with a different node type at the same id', () => { + const before = makeNode({ id: 'a', type: 'n8n-nodes-base.function', name: 'Func' }); + const after = makeNode({ id: 'a', type: 'n8n-nodes-base.code', name: 'Func' }); + expect(() => validator.validateOnUpdate([after], [before])).not.toThrow(); + }); + + it('allows deleting a deprecated node', () => { + const before = [ + makeNode({ id: 'a', type: 'n8n-nodes-base.set' }), + makeNode({ id: 'b', type: 'n8n-nodes-base.function' }), + ]; + const after = [before[0]]; + expect(() => validator.validateOnUpdate(after, before)).not.toThrow(); + }); + + it('allows edits to non-deprecated nodes alongside a frozen deprecated one', () => { + const deprecated = makeNode({ id: 'a', type: 'n8n-nodes-base.function' }); + const otherBefore = makeNode({ id: 'b', type: 'n8n-nodes-base.set', parameters: { x: 1 } }); + const otherAfter = { ...otherBefore, parameters: { x: 2 } }; + expect(() => + validator.validateOnUpdate([deprecated, otherAfter], [deprecated, otherBefore]), + ).not.toThrow(); + }); + + it('is a no-op when the config flag is off', () => { + nodesConfig.blockDeprecated = false; + const before = [makeNode({ id: 'a', type: 'n8n-nodes-base.set' })]; + const after = [...before, makeNode({ id: 'b', type: 'n8n-nodes-base.function' })]; + expect(() => validator.validateOnUpdate(after, before)).not.toThrow(); + }); + }); +}); diff --git a/packages/cli/src/workflows/__tests__/workflow-creation.service.test.ts b/packages/cli/src/workflows/__tests__/workflow-creation.service.test.ts index 47277e688e1..e63439fb5db 100644 --- a/packages/cli/src/workflows/__tests__/workflow-creation.service.test.ts +++ b/packages/cli/src/workflows/__tests__/workflow-creation.service.test.ts @@ -123,6 +123,7 @@ describe('WorkflowCreationService', () => { policyEnforcementServiceMock, workflowRepositoryMock, nodeGroupRulesFlagGateMock, + mock(), ); }); diff --git a/packages/cli/src/workflows/__tests__/workflow.service.test.ts b/packages/cli/src/workflows/__tests__/workflow.service.test.ts index 512b36dd012..fc60722f7bf 100644 --- a/packages/cli/src/workflows/__tests__/workflow.service.test.ts +++ b/packages/cli/src/workflows/__tests__/workflow.service.test.ts @@ -144,6 +144,7 @@ describe('WorkflowService', () => { mock(), // nodeGroupRulesFlagGate mock(), // errorWorkflowValidationService restrictedNodeTypesProviderMock, // restrictedNodeTypesProvider + mock(), ); }); @@ -562,6 +563,7 @@ describe('WorkflowService', () => { nodeGroupRulesFlagGateMock, // nodeGroupRulesFlagGate mock(), // errorWorkflowValidationService mock(), // restrictedNodeTypesProvider + mock(), ); vi.clearAllMocks(); @@ -1599,6 +1601,7 @@ describe('WorkflowService', () => { mock(), // nodeGroupRulesFlagGate mock(), // errorWorkflowValidationService mock(), // restrictedNodeTypesProvider + mock(), ); // Bypass validation internals @@ -2361,6 +2364,7 @@ describe('WorkflowService', () => { mock(), // nodeGroupRulesFlagGate mock(), // errorWorkflowValidationService mock(), // restrictedNodeTypesProvider + mock(), ); }); @@ -2504,6 +2508,7 @@ describe('WorkflowService', () => { mock(), // nodeGroupRulesFlagGate mock(), // errorWorkflowValidationService mock(), // restrictedNodeTypesProvider + mock(), ); }); @@ -2816,6 +2821,7 @@ describe('WorkflowService', () => { mock(), // nodeGroupRulesFlagGate mock(), // errorWorkflowValidationService mock(), // restrictedNodeTypesProvider + mock(), ); }); @@ -2992,6 +2998,7 @@ describe('WorkflowService', () => { mock(), // nodeGroupRulesFlagGate mock(), // errorWorkflowValidationService mock(), // restrictedNodeTypesProvider + mock(), ); }); @@ -3181,6 +3188,7 @@ describe('WorkflowService', () => { mock(), // nodeGroupRulesFlagGate mock(), // errorWorkflowValidationService mock(), // restrictedNodeTypesProvider + mock(), ); }); @@ -3286,6 +3294,7 @@ describe('WorkflowService', () => { mock(), // nodeGroupRulesFlagGate mock(), // errorWorkflowValidationService mock(), // restrictedNodeTypesProvider + mock(), ); }); @@ -3394,6 +3403,7 @@ describe('WorkflowService', () => { mock(), // nodeGroupRulesFlagGate errorWorkflowValidationServiceMock, // errorWorkflowValidationService mock(), // restrictedNodeTypesProvider + mock(), ); vi.mocked(WorkflowHelpers.removeDefaultValues).mockImplementation((settings) => settings); diff --git a/packages/cli/src/workflows/deprecated-nodes-validation.service.ts b/packages/cli/src/workflows/deprecated-nodes-validation.service.ts new file mode 100644 index 00000000000..f344b8d87c7 --- /dev/null +++ b/packages/cli/src/workflows/deprecated-nodes-validation.service.ts @@ -0,0 +1,135 @@ +import { Logger } from '@n8n/backend-common'; +import { NodesConfig } from '@n8n/config'; +import { Service } from '@n8n/di'; +import isEqual from 'lodash/isEqual'; +import type { INode, INodeTypeDescription } from 'n8n-workflow'; +import { NodeHelpers, normalizeNodeShape } from 'n8n-workflow'; + +import { + DeprecatedNodesError, + type DeprecatedNodeViolation, +} from '@/errors/response-errors/deprecated-nodes.error'; +import { NodeTypes } from '@/node-types'; + +/** + * Refuses adding a deprecated node or changing one in place. Removing a + * deprecated node, or moving it to a non-deprecated type or version, is allowed. + * Does nothing when `N8N_DEPRECATED_NODES_BLOCK` is off. + */ +@Service() +export class DeprecatedNodesValidationService { + constructor( + private readonly logger: Logger, + private readonly nodesConfig: NodesConfig, + private readonly nodeTypes: NodeTypes, + ) {} + + /** Throws if any node is deprecated. Use where there is no stored version to compare with. */ + validateOnCreate(nodes: INode[], workflowId?: string): void { + if (!this.nodesConfig.blockDeprecated) return; + + const violations: DeprecatedNodeViolation[] = []; + for (const node of nodes) { + if (this.deprecatedDescription(node)) { + violations.push({ kind: 'added', nodeName: node.name, nodeType: node.type }); + } + } + + this.throwIfViolations(violations, workflowId); + } + + /** + * Throws if a deprecated node is new, or differs from the stored node with the + * same id other than in position. + */ + validateOnUpdate(incomingNodes: INode[], existingNodes: INode[], workflowId?: string): void { + if (!this.nodesConfig.blockDeprecated) return; + + const existingById = new Map(existingNodes.map((n) => [n.id, n])); + const violations: DeprecatedNodeViolation[] = []; + + for (const incoming of incomingNodes) { + const description = this.deprecatedDescription(incoming); + if (!description) continue; + + const before = existingById.get(incoming.id); + // A stored node that was not deprecated counts as adding one, so a node cannot be moved back onto a deprecated version. + if (!before || !this.deprecatedDescription(before)) { + violations.push({ kind: 'added', nodeName: incoming.name, nodeType: incoming.type }); + continue; + } + + if ( + !isEqual(this.frozenFields(before, description), this.frozenFields(incoming, description)) + ) { + violations.push({ kind: 'edited', nodeName: incoming.name, nodeType: incoming.type }); + } + } + + this.throwIfViolations(violations, workflowId); + } + + /** Puts a node in the shape the editor saves, so a re-save of an untouched node compares equal. */ + private frozenFields(node: INode, description: INodeTypeDescription) { + const { + position: _position, + notes, + onError, + continueOnFail, + disabled, + credentials, + parameters, + ...rest + } = normalizeNodeShape(node); + + return { + ...rest, + parameters: + NodeHelpers.getNodeParameters( + description.properties, + parameters, + false, + false, + node, + description, + ) ?? {}, + credentials: credentials ?? {}, + disabled: disabled === true, + continueOnFail: continueOnFail === true, + onError: onError ?? 'stopWorkflow', + notes: notes ?? '', + }; + } + + private throwIfViolations(violations: DeprecatedNodeViolation[], workflowId?: string) { + if (violations.length === 0) return; + + this.logger.warn('Rejected deprecated nodes', { + workflowId, + violations: violations.map(({ kind, nodeType }) => ({ kind, nodeType })), + }); + + throw new DeprecatedNodesError(this.formatMessage(violations), { violations }); + } + + private deprecatedDescription(node: INode): INodeTypeDescription | undefined { + try { + const { description } = this.nodeTypes.getByNameAndVersion(node.type, node.typeVersion); + return description.deprecated === true ? description : undefined; + } catch { + // Unknown node types can't be deprecated by us; they're handled elsewhere. + return undefined; + } + } + + private formatMessage(violations: DeprecatedNodeViolation[]): string { + const lines = violations.map((v) => { + const verb = v.kind === 'added' ? 'use a' : 'modify a'; + return `Cannot ${verb} "${v.nodeType}" node ("${v.nodeName}"): this node type is deprecated.`; + }); + + const suffix = ' Replace the node with a supported alternative or remove it from the workflow.'; + + return lines.join(' ') + suffix; + } +} diff --git a/packages/cli/src/workflows/workflow-creation.service.ts b/packages/cli/src/workflows/workflow-creation.service.ts index 3724670cd98..825ca3e30f3 100644 --- a/packages/cli/src/workflows/workflow-creation.service.ts +++ b/packages/cli/src/workflows/workflow-creation.service.ts @@ -32,6 +32,7 @@ import { TagService } from '@/services/tag.service'; import * as WorkflowHelpers from '@/workflow-helpers'; import { WorkflowHookContextService } from '@/workflow-hook-context.service'; +import { DeprecatedNodesValidationService } from './deprecated-nodes-validation.service'; import { NodeGroupRulesFlagGate } from './node-group-rules-flag-gate'; import { dropRedactionPolicy } from './utils'; import { WorkflowFinderService } from './workflow-finder.service'; @@ -74,6 +75,7 @@ export class WorkflowCreationService { private readonly policyEnforcementService: PolicyEnforcementService, private readonly workflowRepository: WorkflowRepository, private readonly nodeGroupRulesFlagGate: NodeGroupRulesFlagGate, + private readonly deprecatedNodesValidationService: DeprecatedNodesValidationService, ) {} async prepareBatchContext( @@ -222,6 +224,7 @@ export class WorkflowCreationService { WorkflowHelpers.makeGetNodeTypeForGrouping(this.nodeTypes), rules, ); + this.deprecatedNodesValidationService.validateOnCreate(newWorkflow.nodes, newWorkflow.id); if (parentFolderId && parentFolderId !== PROJECT_ROOT) { if (!batchContext) { diff --git a/packages/cli/src/workflows/workflow.service.ts b/packages/cli/src/workflows/workflow.service.ts index a13ceaafa0a..439a695ad01 100644 --- a/packages/cli/src/workflows/workflow.service.ts +++ b/packages/cli/src/workflows/workflow.service.ts @@ -46,6 +46,7 @@ import { staticErrorWorkflowId, type ErrorWorkflowProblem, } from './error-workflow-validation.service'; +import { DeprecatedNodesValidationService } from './deprecated-nodes-validation.service'; import { WorkflowPublicationNotifier } from './publication/workflow-publication-notifier'; import { WorkflowPublicationStatusService } from './publication/workflow-publication-status.service'; import { NodeGroupRulesFlagGate } from './node-group-rules-flag-gate'; @@ -185,6 +186,7 @@ export class WorkflowService { private readonly nodeGroupRulesFlagGate: NodeGroupRulesFlagGate, private readonly errorWorkflowValidationService: ErrorWorkflowValidationService, private readonly restrictedNodeTypesProvider: RestrictedNodeTypesProviderProxy, + private readonly deprecatedNodesValidationService: DeprecatedNodesValidationService, ) {} /** @@ -668,6 +670,14 @@ export class WorkflowService { ); } + if (hasNodesKey && nodesChanged) { + this.deprecatedNodesValidationService.validateOnUpdate( + workflowUpdateData.nodes, + workflow.nodes, + workflow.id, + ); + } + // Strip redactionPolicy if instance lacks data-redaction license if ( workflowUpdateData.settings?.redactionPolicy !== undefined && diff --git a/packages/cli/test/integration/deprecated-nodes-inline-subworkflow.test.ts b/packages/cli/test/integration/deprecated-nodes-inline-subworkflow.test.ts new file mode 100644 index 00000000000..fe438a175bc --- /dev/null +++ b/packages/cli/test/integration/deprecated-nodes-inline-subworkflow.test.ts @@ -0,0 +1,161 @@ +import { testDb, createWorkflow } from '@n8n/backend-test-utils'; +import { NodesConfig } from '@n8n/config'; +import { ExecutionRepository, type IWorkflowDb } from '@n8n/db'; +import { Container } from '@n8n/di'; +import { NodeConnectionTypes } from 'n8n-workflow'; +import { v4 as uuid } from 'uuid'; + +import { WorkflowExecutionService } from '@/workflows/workflow-execution.service'; + +import { createOwner } from './shared/db/users'; +import * as utils from './shared/utils'; +import { loadNodesFromDist } from './shared/utils/node-types-data'; + +describe('inline sub-workflow with a deprecated node', () => { + let owner: Awaited>; + let workflowExecutionService: WorkflowExecutionService; + let executionRepository: ExecutionRepository; + let nodesConfig: NodesConfig; + let previousBlockDeprecated: boolean; + + beforeAll(async () => { + await testDb.init(); + + owner = await createOwner(); + + const nodeTypes = loadNodesFromDist([ + 'n8n-nodes-base.manualTrigger', + 'n8n-nodes-base.executeWorkflow', + 'n8n-nodes-base.executeWorkflowTrigger', + 'n8n-nodes-base.function', + ]); + + await utils.initNodeTypes(nodeTypes); + await utils.initBinaryDataService(); + + workflowExecutionService = Container.get(WorkflowExecutionService); + executionRepository = Container.get(ExecutionRepository); + nodesConfig = Container.get(NodesConfig); + previousBlockDeprecated = nodesConfig.blockDeprecated; + }); + + afterEach(async () => { + nodesConfig.blockDeprecated = previousBlockDeprecated; + await testDb.truncate(['ExecutionEntity', 'WorkflowEntity']); + }); + + afterAll(async () => { + await testDb.terminate(); + }); + + function inlineSubWorkflowWithFunctionNode() { + return { + nodes: [ + { + parameters: { inputSource: 'passthrough' }, + type: 'n8n-nodes-base.executeWorkflowTrigger', + typeVersion: 1.1, + id: uuid(), + name: 'Sub Trigger', + position: [0, 0], + }, + { + parameters: { functionCode: "return [{ json: { ran: 'function' } }];" }, + type: 'n8n-nodes-base.function', + typeVersion: 1, + id: uuid(), + name: 'Function', + position: [200, 0], + }, + ], + connections: { + 'Sub Trigger': { + main: [[{ node: 'Function', type: NodeConnectionTypes.Main, index: 0 }]], + }, + }, + }; + } + + function parentWithInlineSubWorkflow() { + return { + nodes: [ + { + parameters: {}, + type: 'n8n-nodes-base.manualTrigger', + typeVersion: 1, + id: uuid(), + name: 'Trigger', + position: [0, 0], + }, + { + parameters: { + source: 'parameter', + workflowJson: JSON.stringify(inlineSubWorkflowWithFunctionNode()), + options: { waitForSubWorkflow: true }, + }, + type: 'n8n-nodes-base.executeWorkflow', + typeVersion: 1.2, + id: uuid(), + name: 'Execute Sub-workflow', + position: [200, 0], + }, + ], + connections: { + Trigger: { + main: [[{ node: 'Execute Sub-workflow', type: NodeConnectionTypes.Main, index: 0 }]], + }, + }, + }; + } + + async function runParent() { + const parent = await createWorkflow( + { name: 'Parent', ...parentWithInlineSubWorkflow() } as unknown as IWorkflowDb, + owner, + ); + + const result = await workflowExecutionService.executeManually( + parent, + { triggerToStartFrom: { name: 'Trigger' } }, + owner, + ); + if (!('executionId' in result)) { + throw new Error(`Expected an executionId, got ${JSON.stringify(result)}`); + } + + const start = Date.now(); + while (Date.now() - start < 10000) { + const execution = await executionRepository.findSingleExecution(result.executionId, { + includeData: true, + unflattenData: true, + }); + if (execution && ['error', 'success'].includes(execution.status)) return execution; + await new Promise((resolve) => setTimeout(resolve, 100)); + } + throw new Error(`Execution ${result.executionId} did not finish within 10000ms`); + } + + it('fails the parent node before the deprecated node runs when the block is on', async () => { + nodesConfig.blockDeprecated = true; + + const execution = await runParent(); + + expect(execution.status).toBe('error'); + const nodeRun = execution.data.resultData.runData['Execute Sub-workflow']?.at(-1); + expect(nodeRun?.executionStatus).toBe('error'); + expect(JSON.stringify(nodeRun?.error)).toContain('n8n-nodes-base.function'); + expect(await executionRepository.count({ where: { workflowId: execution.workflowId } })).toBe( + 1, + ); + }); + + it('runs the deprecated node when the block is off', async () => { + nodesConfig.blockDeprecated = false; + + const execution = await runParent(); + + expect(execution.status).toBe('success'); + const output = execution.data.resultData.runData['Execute Sub-workflow']?.at(-1)?.data?.main; + expect(output?.[0]?.[0]?.json).toEqual({ ran: 'function' }); + }); +}); diff --git a/packages/cli/test/integration/environments/source-control-import.service.test.ts b/packages/cli/test/integration/environments/source-control-import.service.test.ts index e205514566e..28564fbe843 100644 --- a/packages/cli/test/integration/environments/source-control-import.service.test.ts +++ b/packages/cli/test/integration/environments/source-control-import.service.test.ts @@ -32,23 +32,28 @@ import { UserRepository, WorkflowPublishedVersionRepository, } from '@n8n/db'; +import type { NodesConfig } from '@n8n/config'; import { Container } from '@n8n/di'; import * as fastGlob from 'fast-glob'; import { Cipher } from 'n8n-core'; import type { InstanceSettings } from 'n8n-core'; import * as utils from 'n8n-workflow'; +import type { INodeType } from 'n8n-workflow'; import { nanoid } from 'nanoid'; import { readFile } from 'node:fs/promises'; import type { Mock, Mocked } from 'vitest'; import { mock } from 'vitest-mock-extended'; +import { DeprecatedNodesError } from '@/errors/response-errors/deprecated-nodes.error'; import type { IWorkflowToImport } from '@/interfaces'; import { SourceControlContextFactory } from '@/modules/source-control.ee/source-control-context.factory'; import { SourceControlImportService } from '@/modules/source-control.ee/source-control-import.service.ee'; import { SourceControlScopedService } from '@/modules/source-control.ee/source-control-scoped.service'; import type { ExportableCredential } from '@/modules/source-control.ee/types/exportable-credential'; +import type { NodeTypes } from '@/node-types'; import { PolicyEnforcementService } from '@/policy/policy-enforcement.service'; import { PolicyViolationError } from '@/policy/policy-violation.error'; +import { DeprecatedNodesValidationService } from '@/workflows/deprecated-nodes-validation.service'; import { WorkflowFinderService } from '@/workflows/workflow-finder.service'; import { WorkflowHistoryService } from '@/workflows/workflow-history/workflow-history.service'; import { createFolder } from '@test-integration/db/folders'; @@ -115,6 +120,15 @@ describe('SourceControlImportService', () => { async (context, actor) => await Container.get(PolicyEnforcementService).enforceContentImport(context, actor), ); + const nodeTypes = mock(); + nodeTypes.getByNameAndVersion.mockImplementation((type) => + Object.assign(mock(), { + description: { + deprecated: type === 'n8n-nodes-base.function' ? true : undefined, + properties: [{ displayName: 'Code', name: 'functionCode', type: 'string', default: '' }], + }, + }), + ); service = new SourceControlImportService( mock(), mock(), @@ -148,6 +162,11 @@ describe('SourceControlImportService', () => { mock(), // workflowPublishGuard mock(), // workflowMutationHooks Container.get(WorkflowFinderService), + new DeprecatedNodesValidationService( + mock(), + mock({ blockDeprecated: true }), + nodeTypes, + ), ); }); @@ -1853,6 +1872,89 @@ describe('SourceControlImportService', () => { }); }); + describe('deprecated nodes', () => { + it('rejects pulling a new workflow that contains a deprecated node', async () => { + const importingUser = await getGlobalOwner(); + const workflow = makeWorkflowImport({ + nodes: [ + { + id: 'node-1', + name: 'Function', + type: 'n8n-nodes-base.function', + typeVersion: 1, + position: [250, 300], + parameters: { functionCode: 'return items;' }, + }, + ] as IWorkflowToImport['nodes'], + }); + const file = putWorkflowFile(workflow.id, workflow); + + await expect( + service.importWorkflowFromWorkFolder( + [mock({ id: workflow.id, file })], + importingUser.id, + ), + ).rejects.toThrow(DeprecatedNodesError); + + await expect(workflowRepository.findOneBy({ id: workflow.id })).resolves.toBeNull(); + }); + + const functionNode = { + id: 'node-1', + name: 'Function', + type: 'n8n-nodes-base.function', + typeVersion: 1, + position: [250, 300] as [number, number], + parameters: { functionCode: 'return items;' }, + }; + + it('allows pulling an existing workflow whose deprecated node is unchanged', async () => { + const importingUser = await getGlobalOwner(); + const workflowId = nanoid(); + await createWorkflowWithHistory( + { id: workflowId, name: 'Old name', nodes: [functionNode], connections: {} }, + importingUser, + ); + const workflow = makeWorkflowImport({ + id: workflowId, + name: 'New name', + nodes: [functionNode] as IWorkflowToImport['nodes'], + }); + const file = putWorkflowFile(workflowId, workflow); + + await service.importWorkflowFromWorkFolder( + [mock({ id: workflowId, file })], + importingUser.id, + ); + + const stored = await workflowRepository.findOneBy({ id: workflowId }); + expect(stored?.name).toBe('New name'); + }); + + it('rejects pulling an existing workflow whose deprecated node was edited', async () => { + const importingUser = await getGlobalOwner(); + const workflowId = nanoid(); + await createWorkflowWithHistory( + { id: workflowId, name: 'Test Workflow', nodes: [functionNode], connections: {} }, + importingUser, + ); + const workflow = makeWorkflowImport({ + id: workflowId, + nodes: [ + { ...functionNode, parameters: { functionCode: 'return [];' } }, + ] as IWorkflowToImport['nodes'], + }); + const file = putWorkflowFile(workflowId, workflow); + + await expect( + service.importWorkflowFromWorkFolder( + [mock({ id: workflowId, file })], + importingUser.id, + ), + ).rejects.toThrow(DeprecatedNodesError); + }); + }); + describe('workflow history', () => { it('should create workflow history for new workflow on import', async () => { const importingUser = await getGlobalOwner(); diff --git a/packages/cli/test/integration/import.service.test.ts b/packages/cli/test/integration/import.service.test.ts index f488aa3f057..3925e5cdfb5 100644 --- a/packages/cli/test/integration/import.service.test.ts +++ b/packages/cli/test/integration/import.service.test.ts @@ -19,16 +19,20 @@ import { UserRepository, } from '@n8n/db'; import { Container } from '@n8n/di'; +import type { NodesConfig } from '@n8n/config'; import type { ContentImportContext, PolicyViolation } from '@n8n/decorators'; -import type { INode } from 'n8n-workflow'; +import type { INode, INodeType } from 'n8n-workflow'; import { v4 as uuid } from 'uuid'; import type { Mocked } from 'vitest'; import { mock } from 'vitest-mock-extended'; +import { DeprecatedNodesError } from '@/errors/response-errors/deprecated-nodes.error'; import type { WorkflowIndexService } from '@/modules/workflow-index/workflow-index.service'; +import type { NodeTypes } from '@/node-types'; import { PolicyEnforcementService } from '@/policy/policy-enforcement.service'; import { PolicyViolationError } from '@/policy/policy-violation.error'; import { ImportService } from '@/services/import.service'; +import { DeprecatedNodesValidationService } from '@/workflows/deprecated-nodes-validation.service'; import type { WorkflowService } from '@/workflows/workflow.service'; import { createMember, createOwner } from './shared/db/users'; @@ -85,6 +89,7 @@ describe('ImportService', () => { mockPolicyEnforcementService, sharedWorkflowRepository, Container.get(WorkflowRepository), + mock(), ); }); @@ -653,4 +658,98 @@ describe('ImportService', () => { expect(mockWorkflowService.deactivateWorkflow).not.toHaveBeenCalled(); }); }); + + describe('deprecated nodes', () => { + let blockingImportService: ImportService; + + const deprecatedNode: INode = { + id: uuid(), + name: 'Function', + type: 'n8n-nodes-base.function', + typeVersion: 1, + position: [0, 0], + parameters: { functionCode: 'return items;' }, + }; + + beforeAll(() => { + const nodeTypes = mock(); + nodeTypes.getByNameAndVersion.mockImplementation((type) => + Object.assign(mock(), { + description: { + deprecated: type === deprecatedNode.type ? true : undefined, + properties: [ + { displayName: 'Code', name: 'functionCode', type: 'string', default: '' }, + ], + }, + }), + ); + + blockingImportService = new ImportService( + mock(), + Container.get(CredentialsRepository), + tagRepository, + mock(), + mock(), + mockWorkflowIndexService, + mock(), + Container.get(UserRepository), + mockWorkflowService, + mockPolicyEnforcementService, + sharedWorkflowRepository, + workflowRepository, + new DeprecatedNodesValidationService( + mock(), + mock({ blockDeprecated: true }), + nodeTypes, + ), + ); + }); + + test('rejects a new workflow with a deprecated node and leaves active workflows running', async () => { + const active = await createActiveWorkflow({ name: 'Active' }); + const withDeprecated = newWorkflow({ id: uuid(), nodes: [deprecatedNode] }); + + await expect( + blockingImportService.importWorkflows( + [active, withDeprecated], + ownerPersonalProject.id, + owner.id, + {}, + ), + ).rejects.toThrow(DeprecatedNodesError); + + await expect(getWorkflowById(withDeprecated.id)).resolves.toBeNull(); + expect(mockWorkflowService.deactivateWorkflow).not.toHaveBeenCalled(); + }); + + test('rejects re-importing a workflow whose deprecated node was edited', async () => { + const existing = await createWorkflow({ nodes: [deprecatedNode] }); + const edited = { + ...existing, + nodes: [{ ...deprecatedNode, parameters: { functionCode: 'return [];' } }], + }; + + await expect( + blockingImportService.importWorkflows([edited], ownerPersonalProject.id, owner.id, {}), + ).rejects.toThrow(DeprecatedNodesError); + + const dbWorkflow = await getWorkflowById(existing.id); + expect(dbWorkflow?.nodes).toEqual([deprecatedNode]); + }); + + test('allows re-importing a workflow whose deprecated node is unchanged', async () => { + const existing = await createWorkflow({ nodes: [deprecatedNode] }); + + await blockingImportService.importWorkflows( + [{ ...existing, name: 'Re-imported' }], + ownerPersonalProject.id, + owner.id, + {}, + ); + + const dbWorkflow = await getWorkflowById(existing.id); + expect(dbWorkflow?.name).toBe('Re-imported'); + expect(dbWorkflow?.nodes).toEqual([deprecatedNode]); + }); + }); }); diff --git a/packages/cli/test/integration/public-api/workflows.test.ts b/packages/cli/test/integration/public-api/workflows.test.ts index 0d5ad18cc77..716c17ac905 100644 --- a/packages/cli/test/integration/public-api/workflows.test.ts +++ b/packages/cli/test/integration/public-api/workflows.test.ts @@ -10,7 +10,7 @@ import { linkUserToProject, shareWorkflowWithUsers, } from '@n8n/backend-test-utils'; -import { GlobalConfig } from '@n8n/config'; +import { GlobalConfig, NodesConfig } from '@n8n/config'; import type { Project, TagEntity, User, WorkflowHistory } from '@n8n/db'; import { WorkflowRepository, @@ -4394,3 +4394,92 @@ return [{ json: result }]; expect(retrievedUpdatedNode.parameters.language).toBe('javaScript'); }); }); + +describe('deprecated nodes', () => { + const nodesConfig = Container.get(NodesConfig); + let previousBlockDeprecated: boolean; + beforeAll(() => { + previousBlockDeprecated = nodesConfig.blockDeprecated; + nodesConfig.blockDeprecated = true; + }); + afterAll(() => { + nodesConfig.blockDeprecated = previousBlockDeprecated; + }); + + const deprecatedNode: INode = { + id: uuid(), + name: 'Function', + type: 'n8n-nodes-base.function', + typeVersion: 1, + position: [0, 0], + parameters: { functionCode: 'return items;' }, + }; + + test('POST /workflows rejects a workflow with a deprecated node', async () => { + const response = await authOwnerAgent.post('/workflows').send({ + name: 'has deprecated', + nodes: [deprecatedNode], + connections: {}, + settings: {}, + }); + + expect(response.statusCode).toBe(400); + expect(response.body.message).toMatch(/deprecated/i); + }); + + test('PUT /workflows/:id rejects editing a deprecated node', async () => { + const workflow = await createWorkflowWithHistory({ nodes: [deprecatedNode] }, owner); + + const response = await authOwnerAgent.put(`/workflows/${workflow.id}`).send({ + name: workflow.name, + nodes: [{ ...deprecatedNode, parameters: { functionCode: 'return [];' } }], + connections: workflow.connections, + settings: workflow.settings, + }); + + expect(response.statusCode).toBe(400); + expect(response.body.message).toMatch(/deprecated/i); + }); + + test('PUT /workflows/:id allows editing other nodes while a deprecated node stays unchanged', async () => { + const otherNode: INode = { + id: uuid(), + name: 'Start', + type: 'n8n-nodes-base.manualTrigger', + typeVersion: 1, + position: [200, 0], + parameters: {}, + }; + const workflow = await createWorkflowWithHistory({ nodes: [deprecatedNode, otherNode] }, owner); + + const response = await authOwnerAgent.put(`/workflows/${workflow.id}`).send({ + name: workflow.name, + nodes: [deprecatedNode, { ...otherNode, notes: 'edited' }], + connections: workflow.connections, + settings: workflow.settings, + }); + + expect(response.statusCode).toBe(200); + }); + + test('PUT /workflows/:id allows removing a deprecated node', async () => { + const otherNode: INode = { + id: uuid(), + name: 'Start', + type: 'n8n-nodes-base.manualTrigger', + typeVersion: 1, + position: [200, 0], + parameters: {}, + }; + const workflow = await createWorkflowWithHistory({ nodes: [deprecatedNode, otherNode] }, owner); + + const response = await authOwnerAgent.put(`/workflows/${workflow.id}`).send({ + name: workflow.name, + nodes: [otherNode], + connections: {}, + settings: workflow.settings, + }); + + expect(response.statusCode).toBe(200); + }); +}); diff --git a/packages/cli/test/integration/shared/utils/index.ts b/packages/cli/test/integration/shared/utils/index.ts index 4cafdc985e7..b236195ec45 100644 --- a/packages/cli/test/integration/shared/utils/index.ts +++ b/packages/cli/test/integration/shared/utils/index.ts @@ -17,6 +17,8 @@ import { HttpHeaderAuth } from 'n8n-nodes-base/credentials/HttpHeaderAuth.creden import { OpenAiApi } from 'n8n-nodes-base/credentials/OpenAiApi.credentials'; import { Cron } from 'n8n-nodes-base/nodes/Cron/Cron.node'; import { FormTrigger } from 'n8n-nodes-base/nodes/Form/FormTrigger.node'; +import { Function as FunctionNode } from 'n8n-nodes-base/nodes/Function/Function.node'; +import { FunctionItem } from 'n8n-nodes-base/nodes/FunctionItem/FunctionItem.node'; import { ManualTrigger } from 'n8n-nodes-base/nodes/ManualTrigger/ManualTrigger.node'; import { ScheduleTrigger } from 'n8n-nodes-base/nodes/Schedule/ScheduleTrigger.node'; import { Set } from 'n8n-nodes-base/nodes/Set/Set.node'; @@ -139,6 +141,14 @@ function buildDefaultNodes(): INodeTypeData { type: new WebhookNode() as unknown as INodeType, sourcePath: '', }, + 'n8n-nodes-base.function': { + type: new FunctionNode(), + sourcePath: '', + }, + 'n8n-nodes-base.functionItem': { + type: new FunctionItem(), + sourcePath: '', + }, // Minimal mocks for node types the package-import fixtures reference at typeVersion 1. 'n8n-nodes-base.httpRequest': minimalNodeType('n8n-nodes-base.httpRequest'), 'n8n-nodes-base.dataTable': minimalNodeType('n8n-nodes-base.dataTable'), diff --git a/packages/cli/test/integration/workflows/workflow.service.test.ts b/packages/cli/test/integration/workflows/workflow.service.test.ts index 44e9168de65..99b92824676 100644 --- a/packages/cli/test/integration/workflows/workflow.service.test.ts +++ b/packages/cli/test/integration/workflows/workflow.service.test.ts @@ -137,6 +137,7 @@ beforeAll(async () => { Container.get(NodeGroupRulesFlagGate), // nodeGroupRulesFlagGate Container.get(ErrorWorkflowValidationService), // errorWorkflowValidationService Container.get(RestrictedNodeTypesProviderProxy), // restrictedNodeTypesProvider + mock(), ); }); diff --git a/packages/cli/test/integration/workflows/workflows.controller.test.ts b/packages/cli/test/integration/workflows/workflows.controller.test.ts index 1575a77a2d5..27192c0e080 100644 --- a/packages/cli/test/integration/workflows/workflows.controller.test.ts +++ b/packages/cli/test/integration/workflows/workflows.controller.test.ts @@ -31,6 +31,7 @@ import { WorkflowRepository, WorkflowPublishHistoryRepository, } from '@n8n/db'; +import { NodesConfig } from '@n8n/config'; import { Container } from '@n8n/di'; import type { Scope } from '@n8n/permissions'; import { DateTime } from 'luxon'; @@ -880,6 +881,43 @@ describe('POST /workflows', () => { expect(workflowsInDb).toHaveLength(0); }); + describe('deprecated nodes', () => { + const nodesConfig = Container.get(NodesConfig); + let previousBlockDeprecated: boolean; + beforeAll(() => { + previousBlockDeprecated = nodesConfig.blockDeprecated; + nodesConfig.blockDeprecated = true; + }); + afterAll(() => { + nodesConfig.blockDeprecated = previousBlockDeprecated; + }); + + const deprecatedNode: INode = { + id: uuid(), + name: 'Function', + type: 'n8n-nodes-base.function', + typeVersion: 1, + position: [0, 0], + parameters: { functionCode: 'return items;' }, + }; + + test('blocks creating a workflow that contains a deprecated node', async () => { + const response = await authOwnerAgent.post('/workflows').send({ + name: 'has deprecated', + nodes: [deprecatedNode], + connections: {}, + settings: {}, + active: false, + }); + + expect(response.statusCode).toBe(400); + expect(response.body.message).toMatch(/deprecated/i); + expect(response.body.meta?.violations).toEqual([ + { kind: 'added', nodeName: 'Function', nodeType: 'n8n-nodes-base.function' }, + ]); + }); + }); + describe('Security: Mass Assignment Protection', () => { test.each([ { @@ -4009,6 +4047,132 @@ describe('PATCH /workflows/:workflowId', () => { expect(response.body.data.name).toBe('Updated name'); }); + describe('deprecated nodes', () => { + const nodesConfig = Container.get(NodesConfig); + let previousBlockDeprecated: boolean; + beforeAll(() => { + previousBlockDeprecated = nodesConfig.blockDeprecated; + nodesConfig.blockDeprecated = true; + }); + afterAll(() => { + nodesConfig.blockDeprecated = previousBlockDeprecated; + }); + + const buildDeprecatedNode = (overrides: Partial = {}): INode => ({ + id: 'deprecated-node-id', + name: 'Function', + type: 'n8n-nodes-base.function', + typeVersion: 1, + position: [0, 0], + parameters: { functionCode: 'return items;' }, + ...overrides, + }); + + const buildCleanNode = (overrides: Partial = {}): INode => ({ + id: 'clean-node-id', + name: 'Manual Trigger', + type: 'n8n-nodes-base.manualTrigger', + typeVersion: 1, + position: [200, 0], + parameters: {}, + ...overrides, + }); + + test('blocks adding a deprecated node to an existing workflow', async () => { + const workflow = await createWorkflow({ nodes: [buildCleanNode()] }, owner); + + const response = await authOwnerAgent.patch(`/workflows/${workflow.id}`).send({ + versionId: workflow.versionId, + nodes: [buildCleanNode(), buildDeprecatedNode()], + connections: {}, + }); + + expect(response.statusCode).toBe(400); + expect(response.body.meta?.violations).toEqual([ + { kind: 'added', nodeName: 'Function', nodeType: 'n8n-nodes-base.function' }, + ]); + }); + + test('blocks editing a deprecated node that already exists in the workflow', async () => { + const deprecated = buildDeprecatedNode(); + const workflow = await createWorkflow({ nodes: [deprecated] }, owner); + + const response = await authOwnerAgent.patch(`/workflows/${workflow.id}`).send({ + versionId: workflow.versionId, + nodes: [{ ...deprecated, parameters: { functionCode: 'return [];' } }], + connections: {}, + }); + + expect(response.statusCode).toBe(400); + expect(response.body.meta?.violations).toEqual([ + { kind: 'edited', nodeName: 'Function', nodeType: 'n8n-nodes-base.function' }, + ]); + }); + + test('allows editing other nodes while a deprecated node stays unchanged', async () => { + const deprecated = buildDeprecatedNode(); + const cleanNode = buildCleanNode(); + const workflow = await createWorkflow({ nodes: [deprecated, cleanNode] }, owner); + + const response = await authOwnerAgent.patch(`/workflows/${workflow.id}`).send({ + versionId: workflow.versionId, + nodes: [deprecated, { ...cleanNode, notes: 'edited' }], + connections: workflow.connections, + }); + + expect(response.statusCode).toBe(200); + }); + + test('allows the editor-saved shape of a deprecated node stored with explicit defaults', async () => { + const deprecated = buildDeprecatedNode(); + const cleanNode = buildCleanNode(); + const workflow = await createWorkflow( + { + nodes: [ + { ...deprecated, notes: '', onError: 'stopWorkflow', continueOnFail: false }, + cleanNode, + ], + }, + owner, + ); + + const response = await authOwnerAgent.patch(`/workflows/${workflow.id}`).send({ + versionId: workflow.versionId, + nodes: [deprecated, { ...cleanNode, notes: 'edited' }], + connections: workflow.connections, + }); + + expect(response.statusCode).toBe(200); + }); + + test('allows moving a deprecated node on the canvas', async () => { + const deprecated = buildDeprecatedNode(); + const workflow = await createWorkflow({ nodes: [deprecated] }, owner); + + const response = await authOwnerAgent.patch(`/workflows/${workflow.id}`).send({ + versionId: workflow.versionId, + nodes: [{ ...deprecated, position: [400, 200] }], + connections: workflow.connections, + }); + + expect(response.statusCode).toBe(200); + }); + + test('allows removing a deprecated node from an existing workflow', async () => { + const deprecated = buildDeprecatedNode(); + const cleanNode = buildCleanNode(); + const workflow = await createWorkflow({ nodes: [deprecated, cleanNode] }, owner); + + const response = await authOwnerAgent.patch(`/workflows/${workflow.id}`).send({ + versionId: workflow.versionId, + nodes: [cleanNode], + connections: {}, + }); + + expect(response.statusCode).toBe(200); + }); + }); + describe('Security: Mass Assignment Protection on Update', () => { test.each([ { diff --git a/packages/nodes-base/nodes/Function/Function.node.test.ts b/packages/nodes-base/nodes/Function/Function.node.test.ts new file mode 100644 index 00000000000..d701d5954fc --- /dev/null +++ b/packages/nodes-base/nodes/Function/Function.node.test.ts @@ -0,0 +1,8 @@ +import { Function } from './Function.node'; + +describe('Function Node', () => { + it('is marked deprecated so the backend refuses new instances', () => { + const node = new Function(); + expect(node.description.deprecated).toBe(true); + }); +}); diff --git a/packages/nodes-base/nodes/Function/Function.node.ts b/packages/nodes-base/nodes/Function/Function.node.ts index dca8fdca7db..e585148c821 100644 --- a/packages/nodes-base/nodes/Function/Function.node.ts +++ b/packages/nodes-base/nodes/Function/Function.node.ts @@ -22,6 +22,7 @@ export class Function implements INodeType { displayName: 'Function', name: 'function', hidden: true, + deprecated: true, icon: 'fa:code', group: ['transform'], version: 1, diff --git a/packages/nodes-base/nodes/FunctionItem/FunctionItem.node.test.ts b/packages/nodes-base/nodes/FunctionItem/FunctionItem.node.test.ts new file mode 100644 index 00000000000..659e6631950 --- /dev/null +++ b/packages/nodes-base/nodes/FunctionItem/FunctionItem.node.test.ts @@ -0,0 +1,8 @@ +import { FunctionItem } from './FunctionItem.node'; + +describe('FunctionItem Node', () => { + it('is marked deprecated so the backend refuses new instances', () => { + const node = new FunctionItem(); + expect(node.description.deprecated).toBe(true); + }); +}); diff --git a/packages/nodes-base/nodes/FunctionItem/FunctionItem.node.ts b/packages/nodes-base/nodes/FunctionItem/FunctionItem.node.ts index 999d5bc48af..d3e5ef5cfea 100644 --- a/packages/nodes-base/nodes/FunctionItem/FunctionItem.node.ts +++ b/packages/nodes-base/nodes/FunctionItem/FunctionItem.node.ts @@ -22,6 +22,7 @@ export class FunctionItem implements INodeType { displayName: 'Function Item', name: 'functionItem', hidden: true, + deprecated: true, icon: 'fa:code', group: ['transform'], version: 1, diff --git a/packages/quality/testing/playwright/tests/cli-workflows/setup-workflow-tests.ts b/packages/quality/testing/playwright/tests/cli-workflows/setup-workflow-tests.ts index ff5c1926b0f..8618934e9a6 100644 --- a/packages/quality/testing/playwright/tests/cli-workflows/setup-workflow-tests.ts +++ b/packages/quality/testing/playwright/tests/cli-workflows/setup-workflow-tests.ts @@ -24,7 +24,11 @@ const TMP_PDF_DEST_DIR = path.join(BASE_TMP_DIR, 'testData', 'pdfs'); * @param options Options for `child_process.execFile`. `cwd` is typically required. * @returns A promise that resolves with the stdout of the command, or rejects on error. */ -async function runN8nCliCommand(command: string, args: string[], options: { cwd: string }) { +async function runN8nCliCommand( + command: string, + args: string[], + options: { cwd: string; env?: NodeJS.ProcessEnv }, +) { const packagesRoot = findPackagesRoot('cli'); const n8nExecutablePath = path.join(packagesRoot, 'cli/bin/n8n'); console.log(`Executing n8n command: n8n ${command} ${args.join(' ')}`); @@ -85,6 +89,7 @@ export async function globalWorkflowSetup(): Promise { console.log('📥 Importing test workflows...'); await runN8nCliCommand('import:workflow', ['--separate', '--input', WORKFLOWS_DIR_NAME], { cwd: __dirname, + env: { ...process.env, N8N_DEPRECATED_NODES_BLOCK: 'false' }, }); console.log('📁 Copying test assets...'); diff --git a/packages/quality/testing/playwright/tests/e2e/ai/ai-2505-pdf-embed.spec.ts b/packages/quality/testing/playwright/tests/e2e/ai/ai-2505-pdf-embed.spec.ts index e32a75b1044..c698016eca8 100644 --- a/packages/quality/testing/playwright/tests/e2e/ai/ai-2505-pdf-embed.spec.ts +++ b/packages/quality/testing/playwright/tests/e2e/ai/ai-2505-pdf-embed.spec.ts @@ -1,5 +1,7 @@ import { test } from '../../../fixtures/base'; +test.use({ capability: { env: { N8N_DEPRECATED_NODES_BLOCK: 'false' } } }); + /** * Regression guard for AI-2505 — PDF embedding via Default Data Loader → * In-Memory Vector Store insert was throwing diff --git a/packages/quality/testing/playwright/tests/e2e/ai/langchain-vectorstores.spec.ts b/packages/quality/testing/playwright/tests/e2e/ai/langchain-vectorstores.spec.ts index 0b06cd78295..2345a75627d 100644 --- a/packages/quality/testing/playwright/tests/e2e/ai/langchain-vectorstores.spec.ts +++ b/packages/quality/testing/playwright/tests/e2e/ai/langchain-vectorstores.spec.ts @@ -1,6 +1,8 @@ import { test, expect } from '../../../fixtures/base'; -test.use({ capability: 'proxy' }); +test.use({ + capability: { services: ['proxy'], env: { N8N_DEPRECATED_NODES_BLOCK: 'false' } }, +}); test.describe( 'Langchain Integration', { diff --git a/packages/quality/testing/playwright/tests/e2e/api/publish-unreachable-node.spec.ts b/packages/quality/testing/playwright/tests/e2e/api/publish-unreachable-node.spec.ts index ef6d3f38217..3d095177e8f 100644 --- a/packages/quality/testing/playwright/tests/e2e/api/publish-unreachable-node.spec.ts +++ b/packages/quality/testing/playwright/tests/e2e/api/publish-unreachable-node.spec.ts @@ -3,6 +3,8 @@ import { nanoid } from 'nanoid'; import { test, expect } from '../../../fixtures/base'; +test.use({ capability: { env: { N8N_DEPRECATED_NODES_BLOCK: 'false' } } }); + const AGENT = '@n8n/n8n-nodes-langchain.agent'; const PARSER = '@n8n/n8n-nodes-langchain.outputParserAutofixing'; const LANGCHAIN_CODE = '@n8n/n8n-nodes-langchain.code'; diff --git a/packages/quality/testing/playwright/tests/e2e/workflows/editor/ndv/ndv-core.spec.ts b/packages/quality/testing/playwright/tests/e2e/workflows/editor/ndv/ndv-core.spec.ts index 552e4c07194..703de0bbeae 100644 --- a/packages/quality/testing/playwright/tests/e2e/workflows/editor/ndv/ndv-core.spec.ts +++ b/packages/quality/testing/playwright/tests/e2e/workflows/editor/ndv/ndv-core.spec.ts @@ -164,9 +164,9 @@ test.describe( await expect(n8n.ndv.getNodeVersion()).toContainText('Latest'); await n8n.ndv.close(); - await n8n.canvas.openNode('Function'); + await n8n.canvas.openNode('Cron'); await n8n.ndv.openSettings(); - await expect(n8n.ndv.getNodeVersion()).toContainText('Function node version 1'); + await expect(n8n.ndv.getNodeVersion()).toContainText('Cron node version 1'); await expect(n8n.ndv.getNodeVersion()).toContainText('Deprecated'); await n8n.ndv.close(); }); diff --git a/packages/quality/testing/playwright/workflows/Test_Template_2.json b/packages/quality/testing/playwright/workflows/Test_Template_2.json index 1ef82fd7dc8..1e78b263f50 100644 --- a/packages/quality/testing/playwright/workflows/Test_Template_2.json +++ b/packages/quality/testing/playwright/workflows/Test_Template_2.json @@ -35,10 +35,10 @@ }, { "name": "Map each attachment", - "type": "n8n-nodes-base.function", + "type": "n8n-nodes-base.code", "position": [620, 420], "parameters": { - "functionCode": "const _ = require('lodash')\n\nconst sanitize = str => _.chain(str)\n .replace(/[^A-Za-z0-9&.-]/g, '-') // sanitise via whitelist of characters\n .replace(/-(?=-)/g, '') // remove repeated dashes - https://regexr.com/6ag8h\n .trim('-') // trim any leading/trailing dashes\n .truncate({\n length: 60,\n omission: '-' // when the string ends with '-', you'll know it was truncated\n })\n .value()\n\nconst result = _.flatMap(items.map(item => {\n //console.log({item})\n\n // Maps each attachment to a separate item\n return _.values(item.binary).map(file => {\n console.log(\"Saving attachement:\", file.fileName, 'from:', ...item.json.from.value)\n \n // sanitize filename but exclude extension\n const filename_parts = file.fileName.split('.')\n const ext = _.slice(filename_parts, filename_parts.length-1)\n const filename_main = _.join(_.dropRight(filename_parts), '.')\n file.fileName = sanitize(filename_main) + '.' + ext\n \n return {\n json: {\n from: sanitize(item.json.from.value[0].name),\n date: sanitize(new Date(item.json.date).toISOString().split(\"T\")[0]) // get date part \"2020-01-01\"\n }, \n binary: { file }\n }\n })\n}))\n\n//console.log(result)\nreturn result" + "jsCode": "const _ = require('lodash')\n\nconst sanitize = str => _.chain(str)\n .replace(/[^A-Za-z0-9&.-]/g, '-') // sanitise via whitelist of characters\n .replace(/-(?=-)/g, '') // remove repeated dashes - https://regexr.com/6ag8h\n .trim('-') // trim any leading/trailing dashes\n .truncate({\n length: 60,\n omission: '-' // when the string ends with '-', you'll know it was truncated\n })\n .value()\n\nconst result = _.flatMap(items.map(item => {\n //console.log({item})\n\n // Maps each attachment to a separate item\n return _.values(item.binary).map(file => {\n console.log(\"Saving attachement:\", file.fileName, 'from:', ...item.json.from.value)\n \n // sanitize filename but exclude extension\n const filename_parts = file.fileName.split('.')\n const ext = _.slice(filename_parts, filename_parts.length-1)\n const filename_main = _.join(_.dropRight(filename_parts), '.')\n file.fileName = sanitize(filename_main) + '.' + ext\n \n return {\n json: {\n from: sanitize(item.json.from.value[0].name),\n date: sanitize(new Date(item.json.date).toISOString().split(\"T\")[0]) // get date part \"2020-01-01\"\n }, \n binary: { file }\n }\n })\n}))\n\n//console.log(result)\nreturn result" }, "typeVersion": 1 } @@ -72,7 +72,7 @@ "workflowInfo": { "nodeCount": 3, "nodeTypes": { - "n8n-nodes-base.function": { + "n8n-nodes-base.code": { "count": 1 }, "n8n-nodes-base.nextCloud": { @@ -115,7 +115,7 @@ { "id": 14, "icon": "fa:code", - "name": "n8n-nodes-base.function", + "name": "n8n-nodes-base.code", "defaults": { "name": "Function", "color": "#FF9922" diff --git a/packages/quality/testing/playwright/workflows/Test_workflow_ndv_version.json b/packages/quality/testing/playwright/workflows/Test_workflow_ndv_version.json index 9f1a6e69503..ee2f9de6d1d 100644 --- a/packages/quality/testing/playwright/workflows/Test_workflow_ndv_version.json +++ b/packages/quality/testing/playwright/workflows/Test_workflow_ndv_version.json @@ -10,8 +10,8 @@ }, { "id": "1ea0a87c-3395-4bd9-84fb-cf8b0f769cc4", - "name": "Function", - "type": "n8n-nodes-base.function", + "name": "Cron", + "type": "n8n-nodes-base.cron", "typeVersion": 1, "position": [960, 460] }, diff --git a/packages/workflow/src/interfaces.ts b/packages/workflow/src/interfaces.ts index 190c38d8bd7..8c6c3314139 100644 --- a/packages/workflow/src/interfaces.ts +++ b/packages/workflow/src/interfaces.ts @@ -2849,6 +2849,19 @@ export interface INodeTypeBaseDescription { */ hidden?: true; + /** + * Marks the node, or one version of it, as deprecated. When `N8N_DEPRECATED_NODES_BLOCK` is enabled, workflows cannot add or + * change a deprecated node, and workflow definitions that run without being + * saved, such as inline sub-workflows, cannot contain one. Saved workflows that + * already contain it keep running. Set this only for nodes that pose an active + * risk, such as legacy nodes with insecure sandboxing; use `hidden` for nodes + * that are only superseded. + * + * For versioned nodes, set this on the per-version description to mark only + * that version as deprecated. + */ + deprecated?: true; + /** * Whether the node will be wrapped for tool-use by AI Agents, * optionally replacing provided parts of the description