From 370b30f9195ba8d5ceaf75fb4cdfcf127105a3c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=BAben=20Castro?= Date: Thu, 8 Oct 2026 10:13:44 +0000 Subject: [PATCH] feat(core): Hide credential-only nodes under a credential type policy (#39652) Co-authored-by: Claude Fable 5.1 --- .../type-availability-policies/README.md | 21 +++- .../credential-type-policy.check.test.ts | 110 ++++++++++++++++++ .../type-availability-policy.service.test.ts | 71 +++++++++++ .../credential-type-policy.check.ts | 10 +- .../type-availability-policy.service.ts | 30 ++++- .../editor-ui/src/app/constants/nodeTypes.ts | 2 +- .../src/app/stores/nodeTypes.store.ts | 2 +- .../src/app/utils/credentialOnlyNodes.ts | 30 ++--- .../src/app/utils/nodes/nodeTransforms.ts | 7 +- .../components/ItemTypes/NodeItem.test.ts | 23 +++- .../nodeCreator/nodeCreator.utils.test.ts | 23 +++- .../shared/nodeCreator/nodeCreator.utils.ts | 16 ++- .../frontend/README.md | 23 ++-- .../frontend/package.json | 1 + .../frontend/src/__tests__/mocks.ts | 14 ++- .../useNodeTypeRestriction.test.ts | 89 ++++++++++++-- .../src/composables/useNodeTypeRestriction.ts | 42 ++++++- .../frontend/src/index.ts | 1 + .../src/type-availability-policies.api.ts | 13 ++- .../type-availability-policies.store.test.ts | 95 ++++++++++++++- .../src/type-availability-policies.store.ts | 40 ++++++- packages/workflow/src/constants.ts | 5 + .../workflow/src/credential-only-nodes.ts | 25 ++++ packages/workflow/src/index.ts | 1 + .../test/credential-only-nodes.test.ts | 31 +++++ pnpm-lock.yaml | 3 + 26 files changed, 669 insertions(+), 59 deletions(-) create mode 100644 packages/workflow/src/credential-only-nodes.ts create mode 100644 packages/workflow/test/credential-only-nodes.test.ts diff --git a/packages/cli/src/modules/type-availability-policies/README.md b/packages/cli/src/modules/type-availability-policies/README.md index 24928b4933a..516d76630b4 100644 --- a/packages/cli/src/modules/type-availability-policies/README.md +++ b/packages/cli/src/modules/type-availability-policies/README.md @@ -105,7 +105,7 @@ It implements all eight points: | Point | What it reads | | ------------------------ | ----------------------------------------------------- | -| the five workflow points | the keys of every node's `credentials` map | +| the five workflow points | the keys of every node's `credentials` map, the type HTTP Request names by parameter, and `extendsCredential` | | `credentialSave` | the type of the credential being written | | `credentialTransfer` | the credential's type, against the **target** project | | `credentialDecrypt` | `credentialType` — the credential's own type | @@ -128,6 +128,25 @@ Workflow-point grandfathering works the same way, one level down: the save diff credential **types**, so swapping which `slackApi` credential a node uses, or copying the node, adds nothing to police. +### Credential-only nodes + +The VirusTotal, Sysdig and Zabbix nodes, and every other node the editor generates from a +credential type with `httpRequestNode`, are HTTP Request with one credential type attached. The +editor names them `n8n-creds-base.` and stores them as +`n8n-nodes-base.httpRequest` with `extendsCredential: ''`. No package registers +the generated name, so the backend never sees it. + +Both kinds agree on such a node: it is available when `n8n-nodes-base.httpRequest` is available +under the node kind **and** its credential type is available under the credential kind. The node +check judges the stored `httpRequest` node; this check judges `extendsCredential` (and the +parameter and `credentials` sources, which name the same type once configured). The frontend +module composes the same two verdicts for the nodes panel, so a credential type rule on +`virusTotalApi` hides the VirusTotal node and leaves HTTP Request and Sysdig in place. + +A node type rule on `n8n-creds-base.*` would match nothing, so the service refuses it at every +write and names the credential type rule to write instead. Blocking HTTP Request itself still +hides every credential-only node, as before. + ### What the second kind costs The cache is keyed per kind, so the two kinds share nothing. A cold decision on a project with diff --git a/packages/cli/src/modules/type-availability-policies/__tests__/credential-type-policy.check.test.ts b/packages/cli/src/modules/type-availability-policies/__tests__/credential-type-policy.check.test.ts index 317ce090452..267b3bb7eb7 100644 --- a/packages/cli/src/modules/type-availability-policies/__tests__/credential-type-policy.check.test.ts +++ b/packages/cli/src/modules/type-availability-policies/__tests__/credential-type-policy.check.test.ts @@ -346,6 +346,116 @@ describe('CredentialTypePolicyCheck', () => { }); }); + describe('a credential-only node', () => { + const VIRUS_TOTAL_API = 'virusTotalApi'; + const SYSDIG_API = 'sysdigApi'; + + /** How the editor stores the VirusTotal node: HTTP Request extending the credential type. */ + const credentialOnly = (credentialType: string, name = `${credentialType} node`): INode => ({ + ...node(HTTP_REQUEST, {}, name), + extendsCredential: credentialType, + }); + + const saveNew = async (nodes: INode[]) => + await check.onWorkflowSave({ + workflow: workflow(nodes, null), + storedWorkflow: null, + projectId: 'project-1', + }); + + beforeEach(() => { + denying([VIRUS_TOTAL_API]); + }); + + it('reports the extended type when no credential is selected and HTTP Request is not installed', async () => { + nodeTypes.getByNameAndVersion.mockImplementation(() => { + throw new Error('not installed'); + }); + + // The parameter names the type too, but resolving it needs the missing description. + const result = await saveNew([ + { + ...credentialOnly(VIRUS_TOTAL_API), + parameters: { + authentication: 'predefinedCredentialType', + nodeCredentialType: VIRUS_TOTAL_API, + }, + }, + ]); + + expect(nodeTypes.getByNameAndVersion).toHaveBeenCalledWith(HTTP_REQUEST, 1); + expect(result.violations).toHaveLength(1); + expect(result.violations[0]).toMatchObject({ + subject: VIRUS_TOTAL_API, + subjectType: 'credentialType', + }); + expect(service.evaluateComposedTypesFor).toHaveBeenCalledWith( + 'credential-types', + 'project-1', + [VIRUS_TOTAL_API], + ); + }); + + it('reports one violation when the extended type is also named by parameter and selected', async () => { + nodeTypes.getByNameAndVersion.mockReturnValue({ + description: { + credentials: [], + properties: [{ name: 'nodeCredentialType', type: 'credentialsSelect' }], + }, + } as never); + + const result = await saveNew([ + { + ...credentialOnly(VIRUS_TOTAL_API), + parameters: { + authentication: 'predefinedCredentialType', + nodeCredentialType: VIRUS_TOTAL_API, + }, + credentials: { [VIRUS_TOTAL_API]: { id: 'cred-1', name: 'VirusTotal account' } }, + }, + ]); + + expect(result.violations).toHaveLength(1); + expect(service.evaluateComposedTypesFor).toHaveBeenCalledWith( + 'credential-types', + 'project-1', + [VIRUS_TOTAL_API], + ); + }); + + it('grandfathers an extended type the stored workflow already carried', async () => { + const nodes = [credentialOnly(VIRUS_TOTAL_API)]; + + const result = await check.onWorkflowSave({ + workflow: workflow(nodes), + storedWorkflow: workflow(nodes), + projectId: 'project-1', + }); + + expect(result.violations).toEqual([]); + expect(service.evaluateComposedTypesFor).not.toHaveBeenCalled(); + }); + + it('leaves plain HTTP Request alone', async () => { + const result = await saveNew([node(HTTP_REQUEST)]); + + expect(result.violations).toEqual([]); + expect(service.evaluateComposedTypesFor).not.toHaveBeenCalled(); + }); + + it('reports only the blocked type when two credential-only nodes share the HTTP Request base', async () => { + const result = await saveNew([credentialOnly(VIRUS_TOTAL_API), credentialOnly(SYSDIG_API)]); + + expect(result.violations).toHaveLength(1); + expect(result.violations[0].subject).toBe(VIRUS_TOTAL_API); + expect(service.evaluateComposedTypesFor).toHaveBeenCalledWith( + 'credential-types', + 'project-1', + [VIRUS_TOTAL_API, SYSDIG_API], + ); + }); + }); + describe('the points that report the full list', () => { const nodes = [ node(SET), diff --git a/packages/cli/src/modules/type-availability-policies/__tests__/type-availability-policy.service.test.ts b/packages/cli/src/modules/type-availability-policies/__tests__/type-availability-policy.service.test.ts index 6e4e4d18089..23995ed0e8e 100644 --- a/packages/cli/src/modules/type-availability-policies/__tests__/type-availability-policy.service.test.ts +++ b/packages/cli/src/modules/type-availability-policies/__tests__/type-availability-policy.service.test.ts @@ -342,6 +342,45 @@ describe('TypeAvailabilityPolicyService', () => { await expect(service.createPolicyDocument(KIND, [rule], 'user-1')).resolves.toBeDefined(); }); + + it('rejects a node type rule naming a credential-only node, and names the rule to write instead', async () => { + const rule: PolicyRule = { + id: 'r1', + action: 'deny', + selector: { kind: 'name', value: 'n8n-creds-base.virusTotalApi' }, + }; + + await expect(service.createPolicyDocument(KIND, [rule], 'user-1')).rejects.toThrow( + 'Node type rule names the credential-only node "n8n-creds-base.virusTotalApi", which is HTTP Request with a credential attached. Write a credential type rule on "virusTotalApi" instead.', + ); + expect(policyRepository.createPolicy).not.toHaveBeenCalled(); + }); + + it('accepts a credential type rule on the type a credential-only node wraps', async () => { + policyRepository.createPolicy.mockResolvedValue(makePolicy({ kind: CREDENTIAL_TYPES_KIND })); + const rule: PolicyRule = { + id: 'r1', + action: 'deny', + selector: { kind: 'name', value: 'virusTotalApi' }, + }; + + await expect( + service.createPolicyDocument(CREDENTIAL_TYPES_KIND, [rule], 'user-1'), + ).resolves.toBeDefined(); + }); + + it('rejects a package rule on the credential-only prefix, which no loader provides', async () => { + const rule: PolicyRule = { + id: 'r1', + action: 'deny', + selector: { kind: 'package', value: 'n8n-creds-base' }, + }; + + await expect(service.createPolicyDocument(KIND, [rule], 'user-1')).rejects.toThrow( + 'Package rule names a package that is not installed: n8n-creds-base', + ); + expect(policyRepository.createPolicy).not.toHaveBeenCalled(); + }); }); describe('updatePolicyDocument', () => { @@ -366,6 +405,19 @@ describe('TypeAvailabilityPolicyService', () => { expect(transactionRunner.run).not.toHaveBeenCalled(); }); + it('rejects a node type rule naming a credential-only node, before opening a transaction', async () => { + const rule: PolicyRule = { + id: 'r1', + action: 'deny', + selector: { kind: 'name', value: 'n8n-creds-base.virusTotalApi' }, + }; + + await expect( + service.updatePolicyDocument(KIND, 'policy-1', [rule], 1, 'user-1'), + ).rejects.toThrow('Node type rule names the credential-only node'); + expect(transactionRunner.run).not.toHaveBeenCalled(); + }); + it('rejects a delegate rule when the document is attached to a project scope, before reading it', async () => { attachmentRepository.listScopeIdsAttachedToPolicy.mockResolvedValue(['scope-1']); scopeRepository.lockScopesByIds.mockResolvedValue(['scope-1']); @@ -752,6 +804,25 @@ describe('TypeAvailabilityPolicyService', () => { expect(transactionRunner.run).not.toHaveBeenCalled(); }); + it('rejects a node type rule naming a credential-only node, before opening a transaction', async () => { + const rule: PolicyRule = { + id: 'r1', + action: 'deny', + selector: { kind: 'name', value: 'n8n-creds-base.virusTotalApi' }, + }; + + await expect( + service.setEffectivePolicy( + KIND, + null, + { rules: [rule], defaultAction: 'allow' }, + 0, + 'user-1', + ), + ).rejects.toThrow('Node type rule names the credential-only node'); + expect(transactionRunner.run).not.toHaveBeenCalled(); + }); + it('throws ConflictError when the scope has several attached documents, and writes nothing', async () => { const scope = makeScope({ projectId: 'project-1', defaultAction: 'allow', version: 1 }); scopeRepository.findScopeByKindAndProject.mockResolvedValue(scope); diff --git a/packages/cli/src/modules/type-availability-policies/credential-type-policy.check.ts b/packages/cli/src/modules/type-availability-policies/credential-type-policy.check.ts index 743d54b619c..58904f01559 100644 --- a/packages/cli/src/modules/type-availability-policies/credential-type-policy.check.ts +++ b/packages/cli/src/modules/type-availability-policies/credential-type-policy.check.ts @@ -163,8 +163,13 @@ export class CredentialTypePolicyCheck implements RegisteredPolicyCheck { /** * The credential types a workflow's nodes ask for: the keys of each node's `credentials` map, - * plus a type a node like HTTP Request names by parameter. A node that names a type without - * selecting a credential still counts: the workflow is built around a type the policy refuses. + * a type a node like HTTP Request names by parameter, and the type a credential-only node + * extends. A node that names a type without selecting a credential still counts: the workflow + * is built around a type the policy refuses. + * + * `extendsCredential` is read on its own because it needs no node description. It is how a + * credential-only node (the VirusTotal node, say) is stored, so a rule on `virusTotalApi` + * reaches that node even when HTTP Request's description is not installed here. */ private distinctCredentialTypes(nodes: PolicedWorkflow['nodes']): string[] { // A Set keeps first-encounter order, so violations and audit lines stay deterministic. @@ -173,6 +178,7 @@ export class CredentialTypePolicyCheck implements RegisteredPolicyCheck { for (const node of nodes) { for (const type of Object.keys(node.credentials ?? {})) types.add(type); for (const type of this.parameterCredentialTypes(node)) types.add(type); + if (node.extendsCredential) types.add(node.extendsCredential); } return [...types]; diff --git a/packages/cli/src/modules/type-availability-policies/type-availability-policy.service.ts b/packages/cli/src/modules/type-availability-policies/type-availability-policy.service.ts index 92c9458d451..746537e2725 100644 --- a/packages/cli/src/modules/type-availability-policies/type-availability-policy.service.ts +++ b/packages/cli/src/modules/type-availability-policies/type-availability-policy.service.ts @@ -5,7 +5,12 @@ import { Time } from '@n8n/constants'; import { TransactionRunner, type OperationContext } from '@n8n/db'; import { Service } from '@n8n/di'; import { LRUCache } from 'lru-cache'; -import { OperationalError, UserError } from 'n8n-workflow'; +import { + getCredentialOnlyNodeCredentialType, + isCredentialOnlyNodeType, + OperationalError, + UserError, +} from 'n8n-workflow'; import { ConflictError, NotFoundError } from '@n8n/errors'; import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials'; @@ -16,6 +21,7 @@ import { TypeAvailabilityPolicyScopeRepository } from './database/repositories/t import { TypeAvailabilityPolicyRepository } from './database/repositories/type-availability-policy.repository'; import type { TypeAvailabilityPolicy } from './database/entities/type-availability-policy.entity'; import type { TypeAvailabilityPolicyScope } from './database/entities/type-availability-policy-scope.entity'; +import { NODE_TYPES_KIND } from './constants'; import { isPackageInstalled, packageResolverFor, policedTypeFor } from './package-resolver'; import { evaluateComposedType, orderedAttachments, type ComposedVerdict } from './policy-evaluator'; import type { @@ -242,6 +248,25 @@ function assertPackagesInstalled( } } +/** + * A credential-only node (`n8n-creds-base.`) exists only in the editor. It is stored and + * executed as `n8n-nodes-base.httpRequest`, so a node type rule on the generated name would + * never match anything and never reach the builder. The credential type rule on `` is + * what hides that node and blocks it, so the write points there. + */ +function assertNoCredentialOnlyNodeRules(kind: string, rules: readonly PolicyRule[]): void { + if (kind !== NODE_TYPES_KIND) return; + + for (const rule of rules) { + if (rule.selector.kind === 'name' && isCredentialOnlyNodeType(rule.selector.value)) { + const credentialType = getCredentialOnlyNodeCredentialType(rule.selector.value); + throw new UserError( + `Node type rule names the credential-only node "${rule.selector.value}", which is HTTP Request with a credential attached. Write a credential type rule on "${credentialType}" instead.`, + ); + } + } +} + /** Mirrors the DTO-level check in `ReplaceAttachmentsDto`, as a defensive service-level guard. */ function assertNoDuplicateAttachmentSlots(attachments: readonly AttachmentInput[]): void { const seenPolicyIds = new Set(); @@ -438,6 +463,7 @@ export class TypeAvailabilityPolicyService { updatedBy: string, ): Promise { assertPackagesInstalled(rules, this.loadNodesAndCredentials); + assertNoCredentialOnlyNodeRules(kind, rules); const warnings = lintRulesForShadowing( rules, packageResolverFor(kind, this.loadNodesAndCredentials), @@ -476,6 +502,7 @@ export class TypeAvailabilityPolicyService { updatedBy: string, ): Promise { assertPackagesInstalled(rules, this.loadNodesAndCredentials); + assertNoCredentialOnlyNodeRules(kind, rules); const warnings = lintRulesForShadowing( rules, packageResolverFor(kind, this.loadNodesAndCredentials), @@ -730,6 +757,7 @@ export class TypeAvailabilityPolicyService { }> { assertNoDelegateAtProjectScope(projectId, input.defaultAction, input.rules); assertPackagesInstalled(input.rules, this.loadNodesAndCredentials); + assertNoCredentialOnlyNodeRules(kind, input.rules); const warnings = lintRulesForShadowing( input.rules, diff --git a/packages/frontend/editor-ui/src/app/constants/nodeTypes.ts b/packages/frontend/editor-ui/src/app/constants/nodeTypes.ts index 73d53bee572..ecaa759d92a 100644 --- a/packages/frontend/editor-ui/src/app/constants/nodeTypes.ts +++ b/packages/frontend/editor-ui/src/app/constants/nodeTypes.ts @@ -116,7 +116,7 @@ export const DATA_TABLE_TOOL_NODE_TYPE = 'n8n-nodes-base.dataTableTool'; export const MESSAGE_AN_AGENT_NODE_TYPE = 'n8n-nodes-base.messageAnAgent'; export const TIME_SAVED_NODE_TYPE = 'n8n-nodes-base.timeSaved'; -export const CREDENTIAL_ONLY_NODE_PREFIX = 'n8n-creds-base'; +export { CREDENTIAL_ONLY_NODE_PREFIX } from 'n8n-workflow'; export const CREDENTIAL_ONLY_HTTP_NODE_VERSION = 4.1; diff --git a/packages/frontend/editor-ui/src/app/stores/nodeTypes.store.ts b/packages/frontend/editor-ui/src/app/stores/nodeTypes.store.ts index dbd40495475..9e539f3af92 100644 --- a/packages/frontend/editor-ui/src/app/stores/nodeTypes.store.ts +++ b/packages/frontend/editor-ui/src/app/stores/nodeTypes.store.ts @@ -137,7 +137,7 @@ export const useNodeTypesStore = defineStore(STORES.NODE_TYPES, () => { const getCredentialOnlyNodeType = computed(() => { return (nodeTypeName: string, version?: number): INodeTypeDescription | null => { - const credentialName = utils.getCredentialTypeName(nodeTypeName); + const credentialName = utils.getCredentialOnlyNodeCredentialType(nodeTypeName); const httpNode = getNodeType.value( HTTP_REQUEST_NODE_TYPE, version ?? CREDENTIAL_ONLY_HTTP_NODE_VERSION, diff --git a/packages/frontend/editor-ui/src/app/utils/credentialOnlyNodes.ts b/packages/frontend/editor-ui/src/app/utils/credentialOnlyNodes.ts index 0422c141901..deb360f84b6 100644 --- a/packages/frontend/editor-ui/src/app/utils/credentialOnlyNodes.ts +++ b/packages/frontend/editor-ui/src/app/utils/credentialOnlyNodes.ts @@ -1,23 +1,17 @@ -import { deepCopy, type ICredentialType, type INodeTypeDescription } from 'n8n-workflow'; -import { CREDENTIAL_ONLY_NODE_PREFIX, HTTP_REQUEST_NODE_TYPE } from '../constants'; +import { + deepCopy, + getCredentialOnlyNodeTypeName, + type ICredentialType, + type INodeTypeDescription, +} from 'n8n-workflow'; import { i18n } from '@n8n/i18n'; -export function isCredentialOnlyNodeType(nodeTypeName: string): boolean { - return nodeTypeName?.startsWith(CREDENTIAL_ONLY_NODE_PREFIX) ?? false; -} - -/** A credential-only node is HTTP Request in disguise, so policy applies to the node it wraps. */ -export function toPolicyNodeType(nodeTypeName: string): string { - return isCredentialOnlyNodeType(nodeTypeName) ? HTTP_REQUEST_NODE_TYPE : nodeTypeName; -} - -export function getCredentialTypeName(nodeTypeName: string): string { - return nodeTypeName.split('.')[1]; -} - -export function getCredentialOnlyNodeTypeName(credentialTypeName: string): string { - return `${CREDENTIAL_ONLY_NODE_PREFIX}.${credentialTypeName}`; -} +// The name helpers are shared with the backend and the policy module through `n8n-workflow`. +export { + getCredentialOnlyNodeCredentialType, + getCredentialOnlyNodeTypeName, + isCredentialOnlyNodeType, +} from 'n8n-workflow'; export function getCredentialOnlyNodeType( httpNode?: INodeTypeDescription | null, diff --git a/packages/frontend/editor-ui/src/app/utils/nodes/nodeTransforms.ts b/packages/frontend/editor-ui/src/app/utils/nodes/nodeTransforms.ts index 07b19009f25..2161e9e5355 100644 --- a/packages/frontend/editor-ui/src/app/utils/nodes/nodeTransforms.ts +++ b/packages/frontend/editor-ui/src/app/utils/nodes/nodeTransforms.ts @@ -19,7 +19,10 @@ import { traverseNodeParameters, } from 'n8n-workflow'; import { useNodeTypesStore } from '@/app/stores/nodeTypes.store'; -import { getCredentialTypeName, isCredentialOnlyNodeType } from '@/app/utils/credentialOnlyNodes'; +import { + getCredentialOnlyNodeCredentialType, + isCredentialOnlyNodeType, +} from '@/app/utils/credentialOnlyNodes'; import { getInactiveCredentials, usesParameterSelectedCredentials, @@ -220,7 +223,7 @@ export function serializeNode(nodeTypeProvider: NodeTypeProvider, node: INodeUi) if (isCredentialOnly) { nodeData.type = HTTP_REQUEST_NODE_TYPE; - nodeData.extendsCredential = getCredentialTypeName(nodeType.name); + nodeData.extendsCredential = getCredentialOnlyNodeCredentialType(nodeType.name); } // Node-Type is known so we can save the parameters correctly diff --git a/packages/frontend/editor-ui/src/features/shared/nodeCreator/components/ItemTypes/NodeItem.test.ts b/packages/frontend/editor-ui/src/features/shared/nodeCreator/components/ItemTypes/NodeItem.test.ts index d7b09561da3..d79224ac691 100644 --- a/packages/frontend/editor-ui/src/features/shared/nodeCreator/components/ItemTypes/NodeItem.test.ts +++ b/packages/frontend/editor-ui/src/features/shared/nodeCreator/components/ItemTypes/NodeItem.test.ts @@ -10,7 +10,10 @@ import { MESSAGE_AN_AGENT_NODE_TYPE, } from '@/app/constants'; import { createComponentRenderer } from '@/__tests__/render'; -import { mockRestrictedNodeTypes } from '@n8n/frontend-module-type-availability-policies/__tests__/mocks'; +import { + mockRestrictedCredentialTypes, + mockRestrictedNodeTypes, +} from '@n8n/frontend-module-type-availability-policies/__tests__/mocks'; import { mockSimplifiedNodeType } from '../../__tests__/utils'; import { useViewStacks } from '../../composables/useViewStacks'; import NodeItem from './NodeItem.vue'; @@ -273,6 +276,24 @@ describe('NodeItem', () => { expect(queryByTestId('node-creator-restricted-item')).toBeInTheDocument(); }); + it('treats a credential-only node as restricted when its credential type is restricted', () => { + mockRestrictedNodeTypes(); + mockRestrictedCredentialTypes({ sysdigApi: 'project' }); + + const { queryByTestId } = render({ + pinia, + props: { + nodeType: mockSimplifiedNodeType({ + name: 'n8n-creds-base.sysdigApi', + displayName: 'Sysdig', + group: ['output'], + }), + }, + }); + + expect(queryByTestId('node-creator-restricted-item')).toBeInTheDocument(); + }); + it('renders a normal row when the type is not restricted', () => { mockRestrictedNodeTypes(); diff --git a/packages/frontend/editor-ui/src/features/shared/nodeCreator/nodeCreator.utils.test.ts b/packages/frontend/editor-ui/src/features/shared/nodeCreator/nodeCreator.utils.test.ts index c8cee85e88d..76b007d07ca 100644 --- a/packages/frontend/editor-ui/src/features/shared/nodeCreator/nodeCreator.utils.test.ts +++ b/packages/frontend/editor-ui/src/features/shared/nodeCreator/nodeCreator.utils.test.ts @@ -37,7 +37,10 @@ import { mockSimplifiedNodeType, mockViewCreateElement, } from './__tests__/utils'; -import { mockRestrictedNodeTypes } from '@n8n/frontend-module-type-availability-policies/__tests__/mocks'; +import { + mockRestrictedCredentialTypes, + mockRestrictedNodeTypes, +} from '@n8n/frontend-module-type-availability-policies/__tests__/mocks'; import { setActivePinia } from 'pinia'; import { createTestingPinia } from '@pinia/testing'; @@ -1462,6 +1465,24 @@ describe('node item restriction lookups', () => { expect(isNodeItemRestricted('n8n-creds-base.sysdigApi')).toBe(true); }); + + it('hides a credential-only node whose credential type is restricted and keeps HTTP Request and its siblings', () => { + mockRestrictedNodeTypes(); + mockRestrictedCredentialTypes({ virusTotalApi: 'instance' }); + const items = [ + 'n8n-nodes-base.httpRequest', + 'n8n-creds-base.virusTotalApi', + 'n8n-creds-base.sysdigApi', + ].map((key) => mockNodeCreateElement({ key })); + + expect(withoutRestrictedNodes(items, isNodeItemRestricted).map((item) => item.key)).toEqual([ + 'n8n-nodes-base.httpRequest', + 'n8n-creds-base.sysdigApi', + ]); + expect(getNodeItemRestriction('n8n-creds-base.virusTotalApi')).toMatchObject({ + scope: 'instance', + }); + }); }); describe('restricted node helpers', () => { diff --git a/packages/frontend/editor-ui/src/features/shared/nodeCreator/nodeCreator.utils.ts b/packages/frontend/editor-ui/src/features/shared/nodeCreator/nodeCreator.utils.ts index b17356a574c..e2b53ad5232 100644 --- a/packages/frontend/editor-ui/src/features/shared/nodeCreator/nodeCreator.utils.ts +++ b/packages/frontend/editor-ui/src/features/shared/nodeCreator/nodeCreator.utils.ts @@ -43,13 +43,14 @@ import type { NodeViewItemSection } from './views/viewsData'; import { stripToolSuffix, useAiGatewayStore } from '@/app/stores/aiGateway.store'; import { useNodeTypesStore } from '@/app/stores/nodeTypes.store'; -import { toPolicyNodeType } from '@/app/utils/credentialOnlyNodes'; -import { getNodeTypeRestriction } from '@n8n/frontend-module-type-availability-policies'; +import { + getNodeTypeRestriction, + type TypeRestriction, +} from '@n8n/frontend-module-type-availability-policies'; import { useSettingsStore } from '@n8n/stores/settings.store'; import type { NodeIconSource } from '@/app/utils/nodeIcon'; import { getN8nAgentsNodeName } from '@/experiments/inlineAgents/useInlineAgentsExperiment'; import { SampleTemplates } from '@/features/workflows/templates/utils/workflowSamples'; -import type { NodeTypeAvailability } from '@n8n/api-types'; import type { IconName } from '@n8n/design-system'; import type { INodeOutputConfiguration, NodeConnectionType } from 'n8n-workflow'; import { NodeConnectionTypes, SEND_AND_WAIT_OPERATION } from 'n8n-workflow'; @@ -296,9 +297,12 @@ export function searchNodes( export function flattenCreateElements(items: INodeCreateElement[]): INodeCreateElement[] { return items.map((item) => (item.type === 'section' ? item.children : item)).flat(); } -/** Restriction lookups for node creator items, with credential-only nodes following HTTP Request. */ -export function getNodeItemRestriction(nodeTypeName: string): NodeTypeAvailability | undefined { - return getNodeTypeRestriction(toPolicyNodeType(nodeTypeName)) ?? undefined; +/** + * Restriction lookups for node creator items. The policy module composes the answer for a + * credential-only node from HTTP Request and the credential type it wraps. + */ +export function getNodeItemRestriction(nodeTypeName: string): TypeRestriction | undefined { + return getNodeTypeRestriction(nodeTypeName) ?? undefined; } export function isNodeItemRestricted(nodeTypeName: string): boolean { diff --git a/packages/modules/type-availability-policies/frontend/README.md b/packages/modules/type-availability-policies/frontend/README.md index 3a4d329d039..d9a043f2409 100644 --- a/packages/modules/type-availability-policies/frontend/README.md +++ b/packages/modules/type-availability-policies/frontend/README.md @@ -10,12 +10,21 @@ The descriptor `id` must match the backend module id The module is license-gated on `feat:typeAvailabilityPolicies` and is not a default module. Enable it in a dev instance with `N8N_ENABLED_MODULES=type-availability-policies`. -`useTypeAvailabilityPoliciesStore` answers whether a project may use a node type. The store is -passive: it does not watch the route or the active project, because the projects store lives in the -shell. The shell calls `fetchForProject(projectId)` when a workflow opens or the active project -changes, and `reload()` after a community node install, because the server answers only for the -node types it has loaded. When the module is off, the request fails, a request is in flight for a -different project, or nothing has loaded yet, every lookup reports "available". +`useTypeAvailabilityPoliciesStore` answers whether a project may use a node type or a credential +type. The store is passive: it does not watch the route or the active project, because the +projects store lives in the shell. The shell calls `fetchForProject(projectId)` when a workflow +opens or the active project changes, and `reload()` after a community node install, because the +server answers only for the node types it has loaded. The store loads both kinds together from +`/projects/:projectId/available-types` and `/projects/:projectId/available-credential-types`. +When the module is off, either request fails, a request is in flight for a different project, or +nothing has loaded yet, every lookup reports "available". + +A credential-only node (`n8n-creds-base.`, generated by the editor from a +credential type with `httpRequestNode`) is restricted when HTTP Request is restricted under the +node kind, or when the credential type it wraps is restricted under the credential kind. +`getNodeTypeRestriction` composes that, so blocking `virusTotalApi` hides the VirusTotal node +and leaves HTTP Request and the other credential-only nodes alone. The backend never sees the +generated name and refuses a node type rule on it; the credential type rule is the one to write. ```bash pnpm turbo typecheck lint test --filter=@n8n/frontend-module-type-availability-policies @@ -28,7 +37,7 @@ and on a cold tree turbo builds them first. for one node type. `isNodeTypeRestricted(type)` is the plain predicate for a list of nodes. The shell reads restriction through these and never through the store directly. -Tests stub the store through `mockRestrictedNodeTypes` from the module's `__tests__/mocks`. +Tests stub the store through `mockRestrictedNodeTypes` and `mockRestrictedCredentialTypes` from the module's `__tests__/mocks`. `ContactInstanceAdminModal` lists the instance owners with a mail link. The surfaces below render it as a sibling of their own content, so a popover that closes never unmounts the open dialog. diff --git a/packages/modules/type-availability-policies/frontend/package.json b/packages/modules/type-availability-policies/frontend/package.json index 64ca210a404..c7e2efb1034 100644 --- a/packages/modules/type-availability-policies/frontend/package.json +++ b/packages/modules/type-availability-policies/frontend/package.json @@ -27,6 +27,7 @@ "@n8n/stores": "workspace:*", "@n8n/utils": "workspace:*", "@vueuse/core": "catalog:frontend", + "n8n-workflow": "workspace:*", "pinia": "catalog:frontend", "vue": "catalog:frontend" }, diff --git a/packages/modules/type-availability-policies/frontend/src/__tests__/mocks.ts b/packages/modules/type-availability-policies/frontend/src/__tests__/mocks.ts index 291a564be3d..e8e3b61fb55 100644 --- a/packages/modules/type-availability-policies/frontend/src/__tests__/mocks.ts +++ b/packages/modules/type-availability-policies/frontend/src/__tests__/mocks.ts @@ -1,4 +1,4 @@ -import type { NodeTypeAvailabilityScope } from '@n8n/api-types'; +import type { CredentialTypeAvailabilityScope, NodeTypeAvailabilityScope } from '@n8n/api-types'; import { vi } from 'vitest'; import { useTypeAvailabilityPoliciesStore } from '../type-availability-policies.store'; @@ -17,3 +17,15 @@ export function mockRestrictedNodeTypes( }, ); } + +/** The credential-type counterpart of `mockRestrictedNodeTypes`. */ +export function mockRestrictedCredentialTypes( + restricted: Record = {}, +): void { + vi.spyOn(useTypeAvailabilityPoliciesStore(), 'getCredentialTypeAvailability').mockImplementation( + (name) => { + const scope = restricted[name]; + return scope ? { name, available: false, scope } : { name, available: true }; + }, + ); +} diff --git a/packages/modules/type-availability-policies/frontend/src/composables/useNodeTypeRestriction.test.ts b/packages/modules/type-availability-policies/frontend/src/composables/useNodeTypeRestriction.test.ts index e34827526c7..3a228a25030 100644 --- a/packages/modules/type-availability-policies/frontend/src/composables/useNodeTypeRestriction.test.ts +++ b/packages/modules/type-availability-policies/frontend/src/composables/useNodeTypeRestriction.test.ts @@ -2,18 +2,39 @@ import { createPinia, setActivePinia } from 'pinia'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import { ref } from 'vue'; -import { isNodeTypeRestricted, useNodeTypeRestriction } from './useNodeTypeRestriction'; +import { + getCredentialTypeRestriction, + getNodeTypeRestriction, + isNodeTypeRestricted, + useNodeTypeRestriction, +} from './useNodeTypeRestriction'; import { useTypeAvailabilityPoliciesStore } from '../type-availability-policies.store'; +type Scope = 'instance' | 'project'; + +const HTTP_REQUEST = 'n8n-nodes-base.httpRequest'; +const VIRUS_TOTAL_NODE = 'n8n-creds-base.virusTotalApi'; +const SYSDIG_NODE = 'n8n-creds-base.sysdigApi'; + +function mockRestrictions( + nodeTypes: Record, + credentialTypes: Record = {}, +): void { + const store = useTypeAvailabilityPoliciesStore(); + vi.spyOn(store, 'getNodeTypeAvailability').mockImplementation((name) => { + const scope = nodeTypes[name]; + return scope ? { name, available: false, scope } : { name, available: true }; + }); + vi.spyOn(store, 'getCredentialTypeAvailability').mockImplementation((name) => { + const scope = credentialTypes[name]; + return scope ? { name, available: false, scope } : { name, available: true }; + }); +} + describe('useNodeTypeRestriction', () => { beforeEach(() => { setActivePinia(createPinia()); - vi.spyOn(useTypeAvailabilityPoliciesStore(), 'getNodeTypeAvailability').mockImplementation( - (name) => - name === 'n8n-nodes-base.slack' - ? { name, available: false, scope: 'project' } - : { name, available: true }, - ); + mockRestrictions({ 'n8n-nodes-base.slack': 'project' }); }); it('reports a blocked type with the scope that blocked it', () => { @@ -34,4 +55,58 @@ describe('useNodeTypeRestriction', () => { expect(isNodeTypeRestricted('n8n-nodes-base.slack')).toBe(true); expect(isNodeTypeRestricted('n8n-nodes-base.set')).toBe(false); }); + + it('answers the credential type verdict on its own', () => { + mockRestrictions({}, { virusTotalApi: 'instance' }); + + expect(getCredentialTypeRestriction('virusTotalApi')).toMatchObject({ scope: 'instance' }); + expect(getCredentialTypeRestriction('sysdigApi')).toBeNull(); + }); + + describe('a credential-only node', () => { + it('is restricted when HTTP Request is restricted', () => { + mockRestrictions({ [HTTP_REQUEST]: 'instance' }); + + expect(getNodeTypeRestriction(VIRUS_TOTAL_NODE)).toMatchObject({ + name: HTTP_REQUEST, + scope: 'instance', + }); + expect(isNodeTypeRestricted(SYSDIG_NODE)).toBe(true); + }); + + it('is restricted when the credential type it wraps is restricted', () => { + mockRestrictions({}, { virusTotalApi: 'project' }); + + expect(getNodeTypeRestriction(VIRUS_TOTAL_NODE)).toMatchObject({ + name: 'virusTotalApi', + scope: 'project', + }); + }); + + it('leaves HTTP Request and its siblings available when one credential type is restricted', () => { + mockRestrictions({}, { virusTotalApi: 'instance' }); + + expect(isNodeTypeRestricted(VIRUS_TOTAL_NODE)).toBe(true); + expect(isNodeTypeRestricted(HTTP_REQUEST)).toBe(false); + expect(isNodeTypeRestricted(SYSDIG_NODE)).toBe(false); + }); + + it('reports the HTTP Request restriction first when both apply', () => { + mockRestrictions({ [HTTP_REQUEST]: 'instance' }, { virusTotalApi: 'project' }); + + expect(getNodeTypeRestriction(VIRUS_TOTAL_NODE)).toMatchObject({ + name: HTTP_REQUEST, + scope: 'instance', + }); + }); + + it('follows the composed answer in the reactive helper', () => { + mockRestrictions({}, { virusTotalApi: 'project' }); + + const { isRestricted, restrictionScope } = useNodeTypeRestriction(ref(VIRUS_TOTAL_NODE)); + + expect(isRestricted.value).toBe(true); + expect(restrictionScope.value).toBe('project'); + }); + }); }); diff --git a/packages/modules/type-availability-policies/frontend/src/composables/useNodeTypeRestriction.ts b/packages/modules/type-availability-policies/frontend/src/composables/useNodeTypeRestriction.ts index 9fbf8a251d8..4994027557e 100644 --- a/packages/modules/type-availability-policies/frontend/src/composables/useNodeTypeRestriction.ts +++ b/packages/modules/type-availability-policies/frontend/src/composables/useNodeTypeRestriction.ts @@ -1,14 +1,52 @@ -import type { NodeTypeAvailability, NodeTypeAvailabilityScope } from '@n8n/api-types'; +import type { + CredentialTypeAvailability, + NodeTypeAvailability, + NodeTypeAvailabilityScope, +} from '@n8n/api-types'; import { useI18n, type BaseTextKey } from '@n8n/i18n'; +import { + getCredentialOnlyNodeCredentialType, + HTTP_REQUEST_NODE_TYPE, + isCredentialOnlyNodeType, +} from 'n8n-workflow'; import { computed, toValue, type MaybeRefOrGetter } from 'vue'; import { useTypeAvailabilityPoliciesStore } from '../type-availability-policies.store'; -export function getNodeTypeRestriction(nodeType: string): NodeTypeAvailability | null { +/** The verdict that restricts a node type: from the node kind, or the credential kind for a credential-only node. */ +export type TypeRestriction = NodeTypeAvailability | CredentialTypeAvailability; + +function nodeRestriction(nodeType: string): NodeTypeAvailability | null { const availability = useTypeAvailabilityPoliciesStore().getNodeTypeAvailability(nodeType); return availability && !availability.available ? availability : null; } +export function getCredentialTypeRestriction( + credentialType: string, +): CredentialTypeAvailability | null { + const availability = + useTypeAvailabilityPoliciesStore().getCredentialTypeAvailability(credentialType); + return availability && !availability.available ? availability : null; +} + +/** + * A credential-only node (`n8n-creds-base.`) is HTTP Request with one credential type + * attached, so it is restricted when either is: the node kind decides for HTTP Request, the + * credential kind for the type it wraps. HTTP Request is read first because it is the wider + * reason and already covers every credential-only node. This mirrors the backend, where the + * node check judges the stored `httpRequest` node and the credential check judges its type. + */ +export function getNodeTypeRestriction(nodeType: string): TypeRestriction | null { + if (isCredentialOnlyNodeType(nodeType)) { + return ( + nodeRestriction(HTTP_REQUEST_NODE_TYPE) ?? + getCredentialTypeRestriction(getCredentialOnlyNodeCredentialType(nodeType)) + ); + } + + return nodeRestriction(nodeType); +} + export function isNodeTypeRestricted(nodeType: string): boolean { return getNodeTypeRestriction(nodeType) !== null; } diff --git a/packages/modules/type-availability-policies/frontend/src/index.ts b/packages/modules/type-availability-policies/frontend/src/index.ts index d1cb62b723b..4fe91a2987b 100644 --- a/packages/modules/type-availability-policies/frontend/src/index.ts +++ b/packages/modules/type-availability-policies/frontend/src/index.ts @@ -5,6 +5,7 @@ export { getNodeTypeRestriction, isNodeTypeRestricted, useNodeTypeRestriction, + type TypeRestriction, } from './composables/useNodeTypeRestriction'; export { default as RestrictedNodePopover } from './components/RestrictedNodePopover.vue'; export { default as RestrictedNodePanel } from './components/RestrictedNodePanel.vue'; diff --git a/packages/modules/type-availability-policies/frontend/src/type-availability-policies.api.ts b/packages/modules/type-availability-policies/frontend/src/type-availability-policies.api.ts index 1c035973f33..9542a3e7c11 100644 --- a/packages/modules/type-availability-policies/frontend/src/type-availability-policies.api.ts +++ b/packages/modules/type-availability-policies/frontend/src/type-availability-policies.api.ts @@ -1,4 +1,4 @@ -import type { AvailableTypesResponse } from '@n8n/api-types'; +import type { AvailableCredentialTypesResponse, AvailableTypesResponse } from '@n8n/api-types'; import { makeRestApiRequest } from '@n8n/rest-api-client'; import type { IRestApiContext } from '@n8n/rest-api-client'; @@ -8,3 +8,14 @@ export async function fetchAvailableTypes( ): Promise { return await makeRestApiRequest(context, 'GET', `/projects/${projectId}/available-types`); } + +export async function fetchAvailableCredentialTypes( + context: IRestApiContext, + projectId: string, +): Promise { + return await makeRestApiRequest( + context, + 'GET', + `/projects/${projectId}/available-credential-types`, + ); +} diff --git a/packages/modules/type-availability-policies/frontend/src/type-availability-policies.store.test.ts b/packages/modules/type-availability-policies/frontend/src/type-availability-policies.store.test.ts index 741e9a6070a..2b89580bd85 100644 --- a/packages/modules/type-availability-policies/frontend/src/type-availability-policies.store.test.ts +++ b/packages/modules/type-availability-policies/frontend/src/type-availability-policies.store.test.ts @@ -1,4 +1,4 @@ -import type { AvailableTypesResponse } from '@n8n/api-types'; +import type { AvailableCredentialTypesResponse, AvailableTypesResponse } from '@n8n/api-types'; import { createDeferredPromise } from '@n8n/utils/promise/deferred-promise'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; @@ -6,11 +6,13 @@ import { useTypeAvailabilityPoliciesStore } from './type-availability-policies.s const mocks = vi.hoisted(() => ({ fetchAvailableTypes: vi.fn(), + fetchAvailableCredentialTypes: vi.fn(), isModuleActive: vi.fn(), })); vi.mock('./type-availability-policies.api', () => ({ fetchAvailableTypes: mocks.fetchAvailableTypes, + fetchAvailableCredentialTypes: mocks.fetchAvailableCredentialTypes, })); vi.mock('@n8n/stores/settings.store', () => ({ @@ -37,11 +39,20 @@ const PROJECT_B_RESPONSE: AvailableTypesResponse = [ { name: RESTRICTED, available: true }, ]; +const ALLOWED_CREDENTIAL = 'sysdigApi'; +const RESTRICTED_CREDENTIAL = 'virusTotalApi'; + +const PROJECT_A_CREDENTIALS: AvailableCredentialTypesResponse = [ + { name: ALLOWED_CREDENTIAL, available: true }, + { name: RESTRICTED_CREDENTIAL, available: false, scope: 'instance', matchedRuleId: 'rule-9' }, +]; + describe('useTypeAvailabilityPoliciesStore', () => { let errorSpy: ReturnType; beforeEach(() => { mocks.fetchAvailableTypes.mockReset(); + mocks.fetchAvailableCredentialTypes.mockReset().mockResolvedValue([]); mocks.isModuleActive.mockReset().mockReturnValue(true); errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); }); @@ -292,4 +303,86 @@ describe('useTypeAvailabilityPoliciesStore', () => { expect(store.isNodeTypeAvailable(RESTRICTED)).toBe(true); }); }); + + describe('credential types', () => { + beforeEach(() => { + mocks.fetchAvailableTypes.mockResolvedValue(PROJECT_A_RESPONSE); + mocks.fetchAvailableCredentialTypes.mockResolvedValue(PROJECT_A_CREDENTIALS); + }); + + it('loads credential type availability together with the node types of the project', async () => { + const store = useTypeAvailabilityPoliciesStore(); + + await store.fetchForProject('project-a'); + + expect(mocks.fetchAvailableCredentialTypes).toHaveBeenCalledWith( + expect.objectContaining({ baseUrl: 'http://localhost' }), + 'project-a', + ); + expect(store.getCredentialTypeAvailability(RESTRICTED_CREDENTIAL)).toEqual({ + name: RESTRICTED_CREDENTIAL, + available: false, + scope: 'instance', + matchedRuleId: 'rule-9', + }); + expect(store.getCredentialTypeAvailability(RESTRICTED_CREDENTIAL).available).toBe(false); + expect(store.getCredentialTypeAvailability(ALLOWED_CREDENTIAL).available).toBe(true); + expect(store.getCredentialTypeAvailability('doesNotExistApi').available).toBe(true); + }); + + it('reports every credential type as available while a project switch is in flight', async () => { + const deferredB = createDeferredPromise(); + mocks.fetchAvailableCredentialTypes + .mockResolvedValueOnce(PROJECT_A_CREDENTIALS) + .mockReturnValueOnce(deferredB.promise); + const store = useTypeAvailabilityPoliciesStore(); + + await store.fetchForProject('project-a'); + const pendingB = store.fetchForProject('project-b'); + + expect(store.getCredentialTypeAvailability(RESTRICTED_CREDENTIAL).available).toBe(true); + + deferredB.resolve([]); + await pendingB; + + expect(store.loadedProjectId).toBe('project-b'); + expect(store.getCredentialTypeAvailability(RESTRICTED_CREDENTIAL).available).toBe(true); + }); + + it('reloads both kinds for the requested project', async () => { + mocks.fetchAvailableCredentialTypes + .mockResolvedValueOnce(PROJECT_A_CREDENTIALS) + .mockResolvedValueOnce([]); + const store = useTypeAvailabilityPoliciesStore(); + + await store.fetchForProject('project-a'); + expect(store.getCredentialTypeAvailability(RESTRICTED_CREDENTIAL).available).toBe(false); + + await store.reload(); + + expect(mocks.fetchAvailableCredentialTypes).toHaveBeenCalledTimes(2); + expect(store.getCredentialTypeAvailability(RESTRICTED_CREDENTIAL).available).toBe(true); + }); + + it('degrades both kinds to available when only the credential request fails', async () => { + mocks.fetchAvailableCredentialTypes.mockRejectedValue(new Error('endpoint failure')); + const store = useTypeAvailabilityPoliciesStore(); + + await store.fetchForProject('project-a'); + + expect(errorSpy).toHaveBeenCalledTimes(1); + expect(store.loadedProjectId).toBeNull(); + expect(store.isNodeTypeAvailable(RESTRICTED)).toBe(true); + expect(store.getCredentialTypeAvailability(RESTRICTED_CREDENTIAL).available).toBe(true); + }); + + it('clears credential types on reset', async () => { + const store = useTypeAvailabilityPoliciesStore(); + + await store.fetchForProject('project-a'); + store.reset(); + + expect(store.getCredentialTypeAvailability(RESTRICTED_CREDENTIAL).available).toBe(true); + }); + }); }); diff --git a/packages/modules/type-availability-policies/frontend/src/type-availability-policies.store.ts b/packages/modules/type-availability-policies/frontend/src/type-availability-policies.store.ts index ea8132c1faa..699f65e8ded 100644 --- a/packages/modules/type-availability-policies/frontend/src/type-availability-policies.store.ts +++ b/packages/modules/type-availability-policies/frontend/src/type-availability-policies.store.ts @@ -1,15 +1,24 @@ -import type { NodeTypeAvailability } from '@n8n/api-types'; +import type { CredentialTypeAvailability, NodeTypeAvailability } from '@n8n/api-types'; import { useSettingsStore } from '@n8n/stores/settings.store'; import { useRootStore } from '@n8n/stores/useRootStore'; import { defineStore } from 'pinia'; import { computed, ref, shallowRef } from 'vue'; -import { fetchAvailableTypes } from './type-availability-policies.api'; +import { + fetchAvailableCredentialTypes, + fetchAvailableTypes, +} from './type-availability-policies.api'; import { TYPE_AVAILABILITY_POLICIES_MODULE_ID, TYPE_AVAILABILITY_POLICIES_STORE_ID, } from './type-availability-policies.constants'; +function unavailableByName( + entries: T[], +): ReadonlyMap { + return new Map(entries.filter((entry) => !entry.available).map((entry) => [entry.name, entry])); +} + export const useTypeAvailabilityPoliciesStore = defineStore( TYPE_AVAILABILITY_POLICIES_STORE_ID, () => { @@ -17,6 +26,9 @@ export const useTypeAvailabilityPoliciesStore = defineStore( const settingsStore = useSettingsStore(); const restrictedNodeTypes = shallowRef>(new Map()); + const restrictedCredentialTypes = shallowRef>( + new Map(), + ); const loadedProjectId = ref(null); const requestedProjectId = ref(null); const isLoading = ref(false); @@ -47,22 +59,30 @@ export const useTypeAvailabilityPoliciesStore = defineStore( await load(requestedProjectId.value); } + /** + * Loads both kinds together. A credential-only node's answer composes the two, so they + * must describe the same project at the same time: either both maps are the project's or + * neither is. + */ async function load(projectId: string): Promise { const request = ++latestRequest; isLoading.value = true; try { - const entries = await fetchAvailableTypes(rootStore.restApiContext, projectId); + const [nodeTypes, credentialTypes] = await Promise.all([ + fetchAvailableTypes(rootStore.restApiContext, projectId), + fetchAvailableCredentialTypes(rootStore.restApiContext, projectId), + ]); if (request !== latestRequest) return; - restrictedNodeTypes.value = new Map( - entries.filter((entry) => !entry.available).map((entry) => [entry.name, entry]), - ); + restrictedNodeTypes.value = unavailableByName(nodeTypes); + restrictedCredentialTypes.value = unavailableByName(credentialTypes); loadedProjectId.value = projectId; } catch (error) { if (request !== latestRequest) return; console.error('Failed to fetch available types for project', projectId, error); restrictedNodeTypes.value = new Map(); + restrictedCredentialTypes.value = new Map(); loadedProjectId.value = null; } finally { if (request === latestRequest) isLoading.value = false; @@ -79,9 +99,16 @@ export const useTypeAvailabilityPoliciesStore = defineStore( return getNodeTypeAvailability(name).available; } + function getCredentialTypeAvailability(name: string): CredentialTypeAvailability { + if (loadedProjectId.value !== requestedProjectId.value) return { name, available: true }; + + return restrictedCredentialTypes.value.get(name) ?? { name, available: true }; + } + function reset(): void { latestRequest++; restrictedNodeTypes.value = new Map(); + restrictedCredentialTypes.value = new Map(); loadedProjectId.value = null; requestedProjectId.value = null; isLoading.value = false; @@ -95,6 +122,7 @@ export const useTypeAvailabilityPoliciesStore = defineStore( reload, getNodeTypeAvailability, isNodeTypeAvailable, + getCredentialTypeAvailability, reset, }; }, diff --git a/packages/workflow/src/constants.ts b/packages/workflow/src/constants.ts index 5f4e8591866..3018011290a 100644 --- a/packages/workflow/src/constants.ts +++ b/packages/workflow/src/constants.ts @@ -45,6 +45,11 @@ export const UNKNOWN_ERROR_MESSAGE_CRED = 'UNKNOWN ERROR'; export const STICKY_NODE_TYPE = 'n8n-nodes-base.stickyNote'; export const NO_OP_NODE_TYPE = 'n8n-nodes-base.noOp'; export const HTTP_REQUEST_NODE_TYPE = 'n8n-nodes-base.httpRequest'; +/** + * Package prefix of the node types the editor generates from credential types that declare + * `httpRequestNode`. No package registers them; see `credential-only-nodes.ts`. + */ +export const CREDENTIAL_ONLY_NODE_PREFIX = 'n8n-creds-base'; export const WEBHOOK_NODE_TYPE = 'n8n-nodes-base.webhook'; export const MANUAL_TRIGGER_NODE_TYPE = 'n8n-nodes-base.manualTrigger'; export const EVALUATION_TRIGGER_NODE_TYPE = 'n8n-nodes-base.evaluationTrigger'; diff --git a/packages/workflow/src/credential-only-nodes.ts b/packages/workflow/src/credential-only-nodes.ts new file mode 100644 index 00000000000..8ee722406c5 --- /dev/null +++ b/packages/workflow/src/credential-only-nodes.ts @@ -0,0 +1,25 @@ +import { CREDENTIAL_ONLY_NODE_PREFIX } from './constants'; + +/** + * A credential-only node is HTTP Request with one credential type attached, such as the + * VirusTotal or Sysdig node. The editor generates it from a credential type that declares + * `httpRequestNode`, so no package registers it and the backend never sees its name: it is + * stored as `n8n-nodes-base.httpRequest` with `extendsCredential` set to the credential type. + * + * Its generated name is `n8n-creds-base.`. + * + * Accepts a missing name because the editor asks about nodes that have no type yet, for + * example while a node is being placed. Such a node is not credential-only. + */ +export function isCredentialOnlyNodeType(nodeTypeName: string | null | undefined): boolean { + return nodeTypeName?.startsWith(`${CREDENTIAL_ONLY_NODE_PREFIX}.`) ?? false; +} + +export function getCredentialOnlyNodeTypeName(credentialTypeName: string): string { + return `${CREDENTIAL_ONLY_NODE_PREFIX}.${credentialTypeName}`; +} + +/** The credential type a credential-only node wraps, e.g. `virusTotalApi` for `n8n-creds-base.virusTotalApi`. */ +export function getCredentialOnlyNodeCredentialType(nodeTypeName: string): string { + return nodeTypeName.slice(CREDENTIAL_ONLY_NODE_PREFIX.length + 1); +} diff --git a/packages/workflow/src/index.ts b/packages/workflow/src/index.ts index de7b47f597f..6e5ee5fbccd 100644 --- a/packages/workflow/src/index.ts +++ b/packages/workflow/src/index.ts @@ -60,6 +60,7 @@ export * from './workflow-structure-validation'; export * from './versioned-node-type'; export * from './type-validation'; export * from './credential-domain-restrictions'; +export * from './credential-only-nodes'; export * from './schemas'; export * from './run-execution-data/run-execution-data'; export { WorkflowExpression } from './workflow-expression'; diff --git a/packages/workflow/test/credential-only-nodes.test.ts b/packages/workflow/test/credential-only-nodes.test.ts new file mode 100644 index 00000000000..5ae6d3a9001 --- /dev/null +++ b/packages/workflow/test/credential-only-nodes.test.ts @@ -0,0 +1,31 @@ +import { CREDENTIAL_ONLY_NODE_PREFIX } from '../src/constants'; +import { + getCredentialOnlyNodeCredentialType, + getCredentialOnlyNodeTypeName, + isCredentialOnlyNodeType, +} from '../src/credential-only-nodes'; + +describe('credential-only nodes', () => { + it('recognises a generated node type name', () => { + expect(isCredentialOnlyNodeType('n8n-creds-base.virusTotalApi')).toBe(true); + }); + + it('does not recognise a registered node type or the bare prefix', () => { + expect(isCredentialOnlyNodeType('n8n-nodes-base.httpRequest')).toBe(false); + expect(isCredentialOnlyNodeType(CREDENTIAL_ONLY_NODE_PREFIX)).toBe(false); + expect(isCredentialOnlyNodeType('n8n-creds-baseX.virusTotalApi')).toBe(false); + }); + + it('answers false for a node that has no type yet', () => { + expect(isCredentialOnlyNodeType(undefined)).toBe(false); + expect(isCredentialOnlyNodeType(null)).toBe(false); + expect(isCredentialOnlyNodeType('')).toBe(false); + }); + + it('round-trips a credential type through the generated name', () => { + const nodeTypeName = getCredentialOnlyNodeTypeName('virusTotalApi'); + + expect(nodeTypeName).toBe('n8n-creds-base.virusTotalApi'); + expect(getCredentialOnlyNodeCredentialType(nodeTypeName)).toBe('virusTotalApi'); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7e42de9b023..9584d51df8a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7554,6 +7554,9 @@ importers: '@vueuse/core': specifier: catalog:frontend version: 14.3.0(vue@3.5.26(typescript@6.0.2)) + n8n-workflow: + specifier: workspace:* + version: link:../../../workflow pinia: specifier: catalog:frontend version: 2.2.4(typescript@6.0.2)(vue@3.5.26(typescript@6.0.2))