feat(editor): Gate n8n Connect selector on node typeVersion (no-changelog) (#32565)

This commit is contained in:
Michael Kret
2026-06-19 09:26:24 +03:00
committed by GitHub
parent 0e4d2c3bdb
commit e8d548210e
9 changed files with 340 additions and 5 deletions
@@ -19,4 +19,5 @@ export class AiGatewayConfigDto extends Z.class({
credentialTypes: z.array(z.string()),
providerConfig: z.record(z.object(aiGatewayProviderConfigEntryShape)),
supportedActions: z.record(z.record(z.array(z.string()))).optional(),
minNodeTypeVersion: z.record(z.number()).optional(),
}) {}
@@ -30,6 +30,9 @@ export function useAiGateway() {
operation: string,
): boolean => aiGatewayStore.isActionSupported(nodeName, resource, operation);
const isNodeTypeVersionSupported = (nodeName: string, typeVersion: number): boolean =>
aiGatewayStore.isNodeTypeVersionSupported(nodeName, typeVersion);
async function fetchConfig(): Promise<void> {
if (!isEnabled.value) return;
await aiGatewayStore.fetchConfig();
@@ -48,6 +51,7 @@ export function useAiGateway() {
fetchWallet,
isCredentialTypeSupported,
isActionSupported,
isNodeTypeVersionSupported,
saveAfterToggle,
};
}
@@ -371,4 +371,64 @@ describe('aiGateway.store', () => {
});
});
});
describe('isNodeTypeVersionSupported()', () => {
const CONFIG_WITH_VERSION_REQ = {
...MOCK_CONFIG,
nodes: [...MOCK_CONFIG.nodes, 'some-package.SomeNode'],
credentialTypes: [...MOCK_CONFIG.credentialTypes, 'someApi'],
minNodeTypeVersion: { 'some-package.SomeNode': 1.1 },
};
it('should return true when typeVersion meets the minimum', async () => {
mockGetGatewayConfig.mockResolvedValue(CONFIG_WITH_VERSION_REQ);
const store = useAiGatewayStore();
await store.fetchConfig();
expect(store.isNodeTypeVersionSupported('some-package.SomeNode', 1.1)).toBe(true);
});
it('should return true when typeVersion exceeds the minimum', async () => {
mockGetGatewayConfig.mockResolvedValue(CONFIG_WITH_VERSION_REQ);
const store = useAiGatewayStore();
await store.fetchConfig();
expect(store.isNodeTypeVersionSupported('some-package.SomeNode', 2)).toBe(true);
});
it('should return false when typeVersion is below the minimum', async () => {
mockGetGatewayConfig.mockResolvedValue(CONFIG_WITH_VERSION_REQ);
const store = useAiGatewayStore();
await store.fetchConfig();
expect(store.isNodeTypeVersionSupported('some-package.SomeNode', 1.0)).toBe(false);
});
it('should return true when no minNodeTypeVersion entry exists for the node (no version gate)', async () => {
mockGetGatewayConfig.mockResolvedValue(CONFIG_WITH_VERSION_REQ);
const store = useAiGatewayStore();
await store.fetchConfig();
expect(
store.isNodeTypeVersionSupported('@n8n/n8n-nodes-langchain.lmChatGoogleGemini', 1),
).toBe(true);
});
it('should return true for a node with no version requirement (even if unknown)', async () => {
mockGetGatewayConfig.mockResolvedValue(CONFIG_WITH_VERSION_REQ);
const store = useAiGatewayStore();
await store.fetchConfig();
// No minNodeTypeVersion entry = no version gate; node support is a separate concern
expect(store.isNodeTypeVersionSupported('unknown-package.UnknownNode', 2)).toBe(true);
});
it('should return true when config has not been loaded (no version gate defined)', () => {
const store = useAiGatewayStore();
// config not loaded → no minNodeTypeVersion entry → no version gate → pass through
// node support when config is unloaded is handled by isCredentialTypeSupported / isNodeSupported
expect(store.isNodeTypeVersionSupported('some-package.SomeNode', 1.1)).toBe(true);
});
});
});
@@ -104,6 +104,12 @@ export const useAiGatewayStore = defineStore(STORES.AI_GATEWAY, () => {
return ops.includes(operation);
}
function isNodeTypeVersionSupported(nodeName: string, typeVersion: number): boolean {
const minVersion = config.value?.minNodeTypeVersion?.[nodeName];
if (minVersion === undefined) return true;
return typeVersion >= minVersion;
}
return {
config,
balance,
@@ -116,6 +122,7 @@ export const useAiGatewayStore = defineStore(STORES.AI_GATEWAY, () => {
fetchUsage,
fetchMoreUsage,
isNodeSupported,
isNodeTypeVersionSupported,
isCredentialTypeSupported,
isActionSupported,
};
@@ -1110,6 +1110,7 @@ describe('NodeCredentials', () => {
vi.mocked(useAiGateway).mockReturnValue({
isEnabled: computed(() => true),
isCredentialTypeSupported: vi.fn((credType: string) => credType === 'googlePalmApi'),
isNodeTypeVersionSupported: vi.fn(() => true),
isActionSupported: vi.fn(() => true),
balance: computed(() => undefined),
budget: computed(() => undefined),
@@ -1183,6 +1184,7 @@ describe('NodeCredentials', () => {
vi.mocked(useAiGateway).mockReturnValue({
isEnabled: computed(() => true),
isCredentialTypeSupported: vi.fn(() => false),
isNodeTypeVersionSupported: vi.fn(() => true),
isActionSupported: vi.fn(() => true),
balance: computed(() => undefined),
budget: computed(() => undefined),
@@ -1211,6 +1213,7 @@ describe('NodeCredentials', () => {
vi.mocked(useAiGateway).mockReturnValue({
isEnabled: computed(() => false),
isCredentialTypeSupported: vi.fn(() => false),
isNodeTypeVersionSupported: vi.fn(() => true),
isActionSupported: vi.fn(() => true),
balance: computed(() => undefined),
budget: computed(() => undefined),
@@ -1264,6 +1267,170 @@ describe('NodeCredentials', () => {
});
});
describe('minNodeTypeVersion gate', () => {
const versionedNodeType: INodeTypeDescription = {
displayName: 'Some Node',
name: 'some-package.SomeNode',
group: ['transform'],
version: 1,
description: '',
defaults: { name: 'Some Node' },
inputs: [NodeConnectionTypes.Main],
outputs: [NodeConnectionTypes.Main],
credentials: [{ name: 'someApi', required: true }],
properties: [],
};
const someApiCredType: ICredentialType = {
name: 'someApi',
displayName: 'Some API',
properties: [{ displayName: 'API Key', name: 'apiKey', type: 'string', default: '' }],
};
beforeEach(() => {
const nodeTypesStore = mockedStore(useNodeTypesStore);
nodeTypesStore.setNodeTypes([versionedNodeType]);
credentialsStore.state.credentialTypes = { someApi: someApiCredType };
});
it('should hide AiGatewaySelector when typeVersion is below the minimum', () => {
vi.mocked(useAiGateway).mockReturnValue({
isEnabled: computed(() => true),
isCredentialTypeSupported: vi.fn((credType: string) => credType === 'someApi'),
isNodeTypeVersionSupported: vi.fn(() => false),
isActionSupported: vi.fn(() => true),
balance: computed(() => undefined),
budget: computed(() => undefined),
fetchConfig: vi.fn().mockResolvedValue(undefined),
fetchWallet: vi.fn().mockResolvedValue(undefined),
saveAfterToggle: vi.fn().mockResolvedValue(undefined),
fetchError: computed(() => null),
});
const node: INodeUi = {
id: 'node-some',
name: 'Some Node',
type: 'some-package.SomeNode',
typeVersion: 1.0,
position: [0, 0],
parameters: {},
credentials: {},
};
ndvStore.activeNode = node;
renderComponent({
props: { node, overrideCredType: 'someApi' },
global: { stubs: { AiGatewaySelector: aiGatewayToggleStub } },
});
expect(screen.queryByTestId('ai-gateway-toggle')).not.toBeInTheDocument();
});
it('should show AiGatewaySelector when typeVersion meets the minimum', () => {
vi.mocked(useAiGateway).mockReturnValue({
isEnabled: computed(() => true),
isCredentialTypeSupported: vi.fn((credType: string) => credType === 'someApi'),
isNodeTypeVersionSupported: vi.fn(() => true),
isActionSupported: vi.fn(() => true),
balance: computed(() => undefined),
budget: computed(() => undefined),
fetchConfig: vi.fn().mockResolvedValue(undefined),
fetchWallet: vi.fn().mockResolvedValue(undefined),
saveAfterToggle: vi.fn().mockResolvedValue(undefined),
fetchError: computed(() => null),
});
const node: INodeUi = {
id: 'node-some',
name: 'Some Node',
type: 'some-package.SomeNode',
typeVersion: 1.1,
position: [0, 0],
parameters: {},
credentials: {},
};
ndvStore.activeNode = node;
renderComponent({
props: { node, overrideCredType: 'someApi' },
global: { stubs: { AiGatewaySelector: aiGatewayToggleStub } },
});
expect(screen.getByTestId('ai-gateway-toggle')).toBeInTheDocument();
});
it('should emit credentialSelected clearing __aiGatewayManaged when version gate fails on mount', () => {
vi.mocked(useAiGateway).mockReturnValue({
isEnabled: computed(() => true),
isCredentialTypeSupported: vi.fn((credType: string) => credType === 'someApi'),
isNodeTypeVersionSupported: vi.fn(() => false),
isActionSupported: vi.fn(() => true),
balance: computed(() => undefined),
budget: computed(() => undefined),
fetchConfig: vi.fn().mockResolvedValue(undefined),
fetchWallet: vi.fn().mockResolvedValue(undefined),
saveAfterToggle: vi.fn().mockResolvedValue(undefined),
fetchError: computed(() => null),
});
const node: INodeUi = {
id: 'node-some',
name: 'Some Node',
type: 'some-package.SomeNode',
typeVersion: 1.0,
position: [0, 0],
parameters: {},
credentials: { someApi: { id: null, name: '', __aiGatewayManaged: true } },
};
ndvStore.activeNode = node;
const { emitted } = renderComponent({
props: { node, overrideCredType: 'someApi' },
global: { stubs: { AiGatewaySelector: aiGatewayToggleStub } },
});
expect(emitted('credentialSelected')).toBeTruthy();
const payload = ((emitted('credentialSelected')[0] as unknown[]) ?? [])[0] as {
properties: { credentials: Record<string, unknown> };
};
// No available credentials in store → entry is deleted, not restored
expect(payload.properties.credentials['someApi']).toBeUndefined();
});
it('should not emit credentialSelected on mount when version gate fails but no managed credential exists', () => {
vi.mocked(useAiGateway).mockReturnValue({
isEnabled: computed(() => true),
isCredentialTypeSupported: vi.fn((credType: string) => credType === 'someApi'),
isNodeTypeVersionSupported: vi.fn(() => false),
isActionSupported: vi.fn(() => true),
balance: computed(() => undefined),
budget: computed(() => undefined),
fetchConfig: vi.fn().mockResolvedValue(undefined),
fetchWallet: vi.fn().mockResolvedValue(undefined),
saveAfterToggle: vi.fn().mockResolvedValue(undefined),
fetchError: computed(() => null),
});
const node: INodeUi = {
id: 'node-some',
name: 'Some Node',
type: 'some-package.SomeNode',
typeVersion: 1.0,
position: [0, 0],
parameters: {},
credentials: {},
};
ndvStore.activeNode = node;
const { emitted } = renderComponent({
props: { node, overrideCredType: 'someApi' },
global: { stubs: { AiGatewaySelector: aiGatewayToggleStub } },
});
expect(emitted('credentialSelected')).toBeFalsy();
});
});
it('should emit credentialSelected with __aiGatewayManaged:true when toggled ON', async () => {
ndvStore.activeNode = googleAiNode;
@@ -226,6 +226,18 @@ watch(
credentialTypesNodeDescriptionDisplayed,
(types) => {
if (props.skipAutoSelect) return;
if (
aiGateway.isEnabled.value &&
!aiGateway.isNodeTypeVersionSupported(node.value.type, node.value.typeVersion)
) {
for (const { type } of types) {
if (selected.value[type.name]?.__aiGatewayManaged) {
onAiGatewaySelector(type.name, false, false);
}
}
}
if (types.length === 0 || !isEmpty(selected.value)) return;
const allOptions = types.map((type) => type.options).flat();
@@ -234,7 +246,10 @@ watch(
// No credentials configured auto-enable AI Gateway for supported types
if (aiGateway.isEnabled.value) {
for (const { type } of types) {
if (aiGateway.isCredentialTypeSupported(type.name)) {
if (
aiGateway.isCredentialTypeSupported(type.name) &&
aiGateway.isNodeTypeVersionSupported(node.value.type, node.value.typeVersion)
) {
onAiGatewaySelector(type.name, true, false);
}
}
@@ -565,6 +580,7 @@ function isAiGatewayManagedCredentials(credentialType: string): boolean {
function showAiGatewaySelector(credentialType: string): boolean {
if (!aiGateway.isEnabled.value) return false;
if (!aiGateway.isNodeTypeVersionSupported(node.value.type, node.value.typeVersion)) return false;
if (isAiGatewayManagedCredentials(credentialType)) return true;
if (!aiGateway.isCredentialTypeSupported(credentialType)) return false;
return true;
@@ -1746,6 +1746,7 @@ describe('ParameterInputList', () => {
vi.mocked(useAiGateway).mockReturnValue({
isEnabled: { value: true } as never,
isCredentialTypeSupported: vi.fn(() => true),
isNodeTypeVersionSupported: vi.fn(() => true),
isActionSupported: vi.fn(() => false),
balance: { value: undefined } as never,
budget: { value: undefined } as never,
@@ -1779,6 +1780,7 @@ describe('ParameterInputList', () => {
vi.mocked(useAiGateway).mockReturnValue({
isEnabled: { value: true } as never,
isCredentialTypeSupported: vi.fn(() => true),
isNodeTypeVersionSupported: vi.fn(() => true),
isActionSupported: vi.fn(() => true),
balance: { value: undefined } as never,
budget: { value: undefined } as never,
@@ -1857,6 +1859,7 @@ describe('ParameterInputList', () => {
vi.mocked(useAiGateway).mockReturnValue({
isEnabled: { value: true } as never,
isCredentialTypeSupported: vi.fn(() => true),
isNodeTypeVersionSupported: vi.fn(() => true),
isActionSupported: vi.fn(() => false),
balance: { value: undefined } as never,
budget: { value: undefined } as never,
@@ -1889,6 +1892,7 @@ describe('ParameterInputList', () => {
vi.mocked(useAiGateway).mockReturnValue({
isEnabled: { value: true } as never,
isCredentialTypeSupported: vi.fn(() => true),
isNodeTypeVersionSupported: vi.fn(() => true),
isActionSupported: vi.fn(() => true),
balance: { value: undefined } as never,
budget: { value: undefined } as never,
@@ -38,11 +38,28 @@ import {
AI_CATEGORY_ROOT_NODES,
AI_SUBCATEGORY,
} from '@/app/constants';
import { useAiGatewayStore } from '@/app/stores/aiGateway.store';
import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
import { useSettingsStore } from '@/app/stores/settings.store';
vi.mock('@/app/stores/settings.store', () => ({
useSettingsStore: vi.fn(() => ({ settings: {}, isAskAiEnabled: true })),
}));
vi.mock('@/app/stores/aiGateway.store', () => ({
useAiGatewayStore: vi.fn(() => ({
isNodeSupported: vi.fn(() => false),
isNodeTypeVersionSupported: vi.fn(() => true),
})),
}));
vi.mock('@/app/stores/nodeTypes.store', () => ({
useNodeTypesStore: vi.fn(() => ({
getNodeVersions: vi.fn(() => []),
communityNodeType: vi.fn(() => null),
})),
}));
describe('NodeCreator - utils', () => {
describe('groupItemsInSections', () => {
it('should handle multiple sections (with "other" section)', () => {
@@ -738,6 +755,61 @@ describe('NodeCreator - utils', () => {
});
});
describe('finalizeItems - Free credits badge (minNodeTypeVersion gate)', () => {
const makeGatewayNode = (name = 'gatewayNode') => mockNodeCreateElement(undefined, { name });
beforeEach(() => {
vi.mocked(useSettingsStore).mockReturnValue({
isAiGatewayEnabled: true,
} as unknown as ReturnType<typeof useSettingsStore>);
vi.mocked(useAiGatewayStore).mockReturnValue({
isNodeSupported: vi.fn(() => true),
isNodeTypeVersionSupported: vi.fn(() => true),
} as unknown as ReturnType<typeof useAiGatewayStore>);
vi.mocked(useNodeTypesStore).mockReturnValue({
getNodeVersions: vi.fn(() => [1, 1.1]),
} as unknown as ReturnType<typeof useNodeTypesStore>);
});
it('should show Free credits badge when latest version meets the minimum', () => {
const [result] = finalizeItems([makeGatewayNode()]) as NodeCreateElement[];
expect(result.properties.tag).toEqual({ text: expect.any(String), pill: true });
});
it('should suppress Free credits badge when latest version is below the minimum', () => {
vi.mocked(useAiGatewayStore).mockReturnValue({
isNodeSupported: vi.fn(() => true),
isNodeTypeVersionSupported: vi.fn(() => false),
} as unknown as ReturnType<typeof useAiGatewayStore>);
const [result] = finalizeItems([makeGatewayNode()]) as NodeCreateElement[];
expect(result.properties.tag).toBeUndefined();
});
it('should suppress Free credits badge when gateway is disabled', () => {
vi.mocked(useSettingsStore).mockReturnValue({
isAiGatewayEnabled: false,
} as unknown as ReturnType<typeof useSettingsStore>);
const [result] = finalizeItems([makeGatewayNode()]) as NodeCreateElement[];
expect(result.properties.tag).toBeUndefined();
});
it('should pass the latest (max) version to the version check', () => {
const isNodeTypeVersionSupported = vi.fn(() => true);
vi.mocked(useNodeTypesStore).mockReturnValue({
getNodeVersions: vi.fn(() => [1, 1.1, 2]),
} as unknown as ReturnType<typeof useNodeTypesStore>);
vi.mocked(useAiGatewayStore).mockReturnValue({
isNodeSupported: vi.fn(() => true),
isNodeTypeVersionSupported,
} as unknown as ReturnType<typeof useAiGatewayStore>);
finalizeItems([makeGatewayNode('my-node')]);
expect(isNodeTypeVersionSupported).toHaveBeenCalledWith('my-node', 2);
});
});
describe('mapToolSubcategoryIcon', () => {
it('should return "globe" for AI_CATEGORY_OTHER_TOOLS', () => {
expect(mapToolSubcategoryIcon(AI_CATEGORY_OTHER_TOOLS)).toBe('globe');
@@ -315,10 +315,14 @@ function applyNodeTags(element: INodeCreateElement): INodeCreateElement {
useSettingsStore().isAiGatewayEnabled &&
useAiGatewayStore().isNodeSupported(element.properties.name)
) {
element.properties.tag = {
text: i18n.baseText('generic.freeCredits'),
pill: true,
};
const versions = useNodeTypesStore().getNodeVersions(element.properties.name);
const latestVersion = versions.length > 0 ? Math.max(...versions) : 1;
if (useAiGatewayStore().isNodeTypeVersionSupported(element.properties.name, latestVersion)) {
element.properties.tag = {
text: i18n.baseText('generic.freeCredits'),
pill: true,
};
}
}
return element;