From 5afb7e8102ed9075d47cdf7facf3f3fe00d4c67f Mon Sep 17 00:00:00 2001 From: Theodore Nastos <15264814+theonastos@users.noreply.github.com> Date: Wed, 16 Sep 2026 14:42:18 +0000 Subject: [PATCH] feat(editor): Add available-types store to the type-availability-policies module (no-changelog) (#38784) --- .../frontend/README.md | 6 + .../frontend/package.json | 7 +- .../frontend/src/index.ts | 1 + .../src/type-availability-policies.api.ts | 10 + .../type-availability-policies.constants.ts | 2 + .../type-availability-policies.store.test.ts | 249 ++++++++++++++++++ .../src/type-availability-policies.store.ts | 85 ++++++ pnpm-lock.yaml | 15 ++ 8 files changed, 374 insertions(+), 1 deletion(-) create mode 100644 packages/modules/type-availability-policies/frontend/src/type-availability-policies.api.ts create mode 100644 packages/modules/type-availability-policies/frontend/src/type-availability-policies.store.test.ts create mode 100644 packages/modules/type-availability-policies/frontend/src/type-availability-policies.store.ts diff --git a/packages/modules/type-availability-policies/frontend/README.md b/packages/modules/type-availability-policies/frontend/README.md index 357c0b94eea..df041f76ce2 100644 --- a/packages/modules/type-availability-policies/frontend/README.md +++ b/packages/modules/type-availability-policies/frontend/README.md @@ -10,6 +10,12 @@ The descriptor `id` must match the backend module id The module is license-gated on `feat:nodeTypePolicies` 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. 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". + ```bash pnpm turbo typecheck lint test --filter=@n8n/frontend-module-type-availability-policies ``` diff --git a/packages/modules/type-availability-policies/frontend/package.json b/packages/modules/type-availability-policies/frontend/package.json index 92a24f342ac..7df64cf48bd 100644 --- a/packages/modules/type-availability-policies/frontend/package.json +++ b/packages/modules/type-availability-policies/frontend/package.json @@ -18,7 +18,12 @@ "format:check": "biome ci . && prettier --check . --ignore-path ../../../../.prettierignore" }, "dependencies": { - "@n8n/frontend-module-sdk": "workspace:*" + "@n8n/api-types": "workspace:*", + "@n8n/frontend-module-sdk": "workspace:*", + "@n8n/rest-api-client": "workspace:*", + "@n8n/stores": "workspace:*", + "pinia": "catalog:frontend", + "vue": "catalog:frontend" }, "devDependencies": { "@n8n/eslint-config": "workspace:*", diff --git a/packages/modules/type-availability-policies/frontend/src/index.ts b/packages/modules/type-availability-policies/frontend/src/index.ts index 4c0507aedce..a1a2fc4ecc4 100644 --- a/packages/modules/type-availability-policies/frontend/src/index.ts +++ b/packages/modules/type-availability-policies/frontend/src/index.ts @@ -1 +1,2 @@ export { TypeAvailabilityPoliciesModule } from './type-availability-policies.module'; +export { useTypeAvailabilityPoliciesStore } from './type-availability-policies.store'; 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 new file mode 100644 index 00000000000..1c035973f33 --- /dev/null +++ b/packages/modules/type-availability-policies/frontend/src/type-availability-policies.api.ts @@ -0,0 +1,10 @@ +import type { AvailableTypesResponse } from '@n8n/api-types'; +import { makeRestApiRequest } from '@n8n/rest-api-client'; +import type { IRestApiContext } from '@n8n/rest-api-client'; + +export async function fetchAvailableTypes( + context: IRestApiContext, + projectId: string, +): Promise { + return await makeRestApiRequest(context, 'GET', `/projects/${projectId}/available-types`); +} diff --git a/packages/modules/type-availability-policies/frontend/src/type-availability-policies.constants.ts b/packages/modules/type-availability-policies/frontend/src/type-availability-policies.constants.ts index d319f5cd5d4..ecc24cbcfdf 100644 --- a/packages/modules/type-availability-policies/frontend/src/type-availability-policies.constants.ts +++ b/packages/modules/type-availability-policies/frontend/src/type-availability-policies.constants.ts @@ -1,2 +1,4 @@ /** Must stay the same as the backend module id: `settings.activeModules` is keyed by it. */ export const TYPE_AVAILABILITY_POLICIES_MODULE_ID = 'type-availability-policies'; + +export const TYPE_AVAILABILITY_POLICIES_STORE_ID = 'typeAvailabilityPolicies'; 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 new file mode 100644 index 00000000000..d9a0ce18f92 --- /dev/null +++ b/packages/modules/type-availability-policies/frontend/src/type-availability-policies.store.test.ts @@ -0,0 +1,249 @@ +import type { AvailableTypesResponse } from '@n8n/api-types'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +import { useTypeAvailabilityPoliciesStore } from './type-availability-policies.store'; + +const mocks = vi.hoisted(() => ({ + fetchAvailableTypes: vi.fn(), + isModuleActive: vi.fn(), +})); + +vi.mock('./type-availability-policies.api', () => ({ + fetchAvailableTypes: mocks.fetchAvailableTypes, +})); + +vi.mock('@n8n/stores/settings.store', () => ({ + useSettingsStore: () => ({ isModuleActive: mocks.isModuleActive }), +})); + +vi.mock('@n8n/stores/useRootStore', () => ({ + useRootStore: () => ({ + restApiContext: { baseUrl: 'http://localhost', pushRef: 'test' }, + }), +})); + +const ALLOWED = 'n8n-nodes-base.slack'; +const RESTRICTED = 'n8n-nodes-base.executeCommand'; +const UNKNOWN = 'n8n-nodes-base.doesNotExist'; + +const PROJECT_A_RESPONSE: AvailableTypesResponse = [ + { name: ALLOWED, available: true }, + { name: RESTRICTED, available: false, scope: 'project', matchedRuleId: 'rule-1' }, +]; + +const PROJECT_B_RESPONSE: AvailableTypesResponse = [ + { name: ALLOWED, available: false, scope: 'instance' }, + { name: RESTRICTED, available: true }, +]; + +describe('useTypeAvailabilityPoliciesStore', () => { + let errorSpy: ReturnType; + + beforeEach(() => { + mocks.fetchAvailableTypes.mockReset(); + mocks.isModuleActive.mockReset().mockReturnValue(true); + errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + errorSpy.mockRestore(); + }); + + describe('when the module is not active', () => { + beforeEach(() => { + mocks.isModuleActive.mockReturnValue(false); + }); + + it('reports every type as available and makes no request', async () => { + const store = useTypeAvailabilityPoliciesStore(); + + await store.fetchForProject('project-a'); + + expect(store.isEnabled).toBe(false); + expect(mocks.fetchAvailableTypes).not.toHaveBeenCalled(); + expect(store.getNodeTypeAvailability(RESTRICTED)).toEqual({ + name: RESTRICTED, + available: true, + }); + }); + }); + + describe('when the instance reports no active modules', () => { + beforeEach(() => { + mocks.isModuleActive.mockReturnValue(undefined); + }); + + it('reports every type as available and makes no request', async () => { + const store = useTypeAvailabilityPoliciesStore(); + + await store.fetchForProject('project-a'); + + expect(store.isEnabled).toBe(false); + expect(mocks.fetchAvailableTypes).not.toHaveBeenCalled(); + expect(store.isNodeTypeAvailable(RESTRICTED)).toBe(true); + }); + }); + + describe('when the module is active', () => { + it('returns the restricted entry with its scope and rule', async () => { + mocks.fetchAvailableTypes.mockResolvedValue(PROJECT_A_RESPONSE); + const store = useTypeAvailabilityPoliciesStore(); + + await store.fetchForProject('project-a'); + + expect(mocks.fetchAvailableTypes).toHaveBeenCalledWith( + expect.objectContaining({ baseUrl: 'http://localhost' }), + 'project-a', + ); + expect(store.loadedProjectId).toBe('project-a'); + expect(store.getNodeTypeAvailability(RESTRICTED)).toEqual({ + name: RESTRICTED, + available: false, + scope: 'project', + matchedRuleId: 'rule-1', + }); + expect(store.isNodeTypeAvailable(RESTRICTED)).toBe(false); + }); + + it('reports allowed and unknown types as available', async () => { + mocks.fetchAvailableTypes.mockResolvedValue(PROJECT_A_RESPONSE); + const store = useTypeAvailabilityPoliciesStore(); + + await store.fetchForProject('project-a'); + + expect(store.getNodeTypeAvailability(ALLOWED)).toEqual({ name: ALLOWED, available: true }); + expect(store.getNodeTypeAvailability(UNKNOWN)).toEqual({ name: UNKNOWN, available: true }); + }); + + it('does not refetch the project that is already loaded', async () => { + mocks.fetchAvailableTypes.mockResolvedValue(PROJECT_A_RESPONSE); + const store = useTypeAvailabilityPoliciesStore(); + + await store.fetchForProject('project-a'); + await store.fetchForProject('project-a'); + + expect(mocks.fetchAvailableTypes).toHaveBeenCalledTimes(1); + }); + + it('reflects the new project after a switch', async () => { + mocks.fetchAvailableTypes + .mockResolvedValueOnce(PROJECT_A_RESPONSE) + .mockResolvedValueOnce(PROJECT_B_RESPONSE); + const store = useTypeAvailabilityPoliciesStore(); + + await store.fetchForProject('project-a'); + await store.fetchForProject('project-b'); + + expect(store.loadedProjectId).toBe('project-b'); + expect(store.isNodeTypeAvailable(ALLOWED)).toBe(false); + expect(store.isNodeTypeAvailable(RESTRICTED)).toBe(true); + }); + + it('reports every type as available while a project switch is in flight', async () => { + let resolveB: (value: AvailableTypesResponse) => void = () => {}; + mocks.fetchAvailableTypes.mockResolvedValueOnce(PROJECT_A_RESPONSE).mockImplementationOnce( + async () => + await new Promise((resolve) => { + resolveB = resolve; + }), + ); + const store = useTypeAvailabilityPoliciesStore(); + + await store.fetchForProject('project-a'); + const pendingB = store.fetchForProject('project-b'); + + expect(store.isNodeTypeAvailable(RESTRICTED)).toBe(true); + expect(store.getNodeTypeAvailability(RESTRICTED)).toEqual({ + name: RESTRICTED, + available: true, + }); + + resolveB(PROJECT_B_RESPONSE); + await pendingB; + + expect(store.isNodeTypeAvailable(ALLOWED)).toBe(false); + expect(store.isNodeTypeAvailable(RESTRICTED)).toBe(true); + }); + + it('discards a response for a project that is no longer requested', async () => { + let resolveA: (value: AvailableTypesResponse) => void = () => {}; + mocks.fetchAvailableTypes + .mockImplementationOnce( + async () => + await new Promise((resolve) => { + resolveA = resolve; + }), + ) + .mockResolvedValueOnce(PROJECT_B_RESPONSE); + const store = useTypeAvailabilityPoliciesStore(); + + const pendingA = store.fetchForProject('project-a'); + await store.fetchForProject('project-b'); + resolveA(PROJECT_A_RESPONSE); + await pendingA; + + expect(store.loadedProjectId).toBe('project-b'); + expect(store.isNodeTypeAvailable(ALLOWED)).toBe(false); + expect(store.isLoading).toBe(false); + }); + + it('discards an in-flight response when the user returns to the loaded project', async () => { + let resolveB: (value: AvailableTypesResponse) => void = () => {}; + mocks.fetchAvailableTypes.mockResolvedValueOnce(PROJECT_A_RESPONSE).mockImplementationOnce( + async () => + await new Promise((resolve) => { + resolveB = resolve; + }), + ); + const store = useTypeAvailabilityPoliciesStore(); + + await store.fetchForProject('project-a'); + const pendingB = store.fetchForProject('project-b'); + await store.fetchForProject('project-a'); + resolveB(PROJECT_B_RESPONSE); + await pendingB; + + expect(store.loadedProjectId).toBe('project-a'); + expect(store.isNodeTypeAvailable(ALLOWED)).toBe(true); + expect(store.isNodeTypeAvailable(RESTRICTED)).toBe(false); + expect(store.isLoading).toBe(false); + }); + + it('degrades to available and logs when the request fails', async () => { + mocks.fetchAvailableTypes.mockRejectedValue(new Error('endpoint failure')); + const store = useTypeAvailabilityPoliciesStore(); + + await store.fetchForProject('project-a'); + + expect(errorSpy).toHaveBeenCalledTimes(1); + expect(store.loadedProjectId).toBeNull(); + expect(store.isLoading).toBe(false); + expect(store.isNodeTypeAvailable(RESTRICTED)).toBe(true); + }); + + it('retries after a failed request', async () => { + mocks.fetchAvailableTypes + .mockRejectedValueOnce(new Error('endpoint failure')) + .mockResolvedValueOnce(PROJECT_A_RESPONSE); + const store = useTypeAvailabilityPoliciesStore(); + + await store.fetchForProject('project-a'); + await store.fetchForProject('project-a'); + + expect(mocks.fetchAvailableTypes).toHaveBeenCalledTimes(2); + expect(store.loadedProjectId).toBe('project-a'); + expect(store.isNodeTypeAvailable(RESTRICTED)).toBe(false); + }); + + it('clears all state on reset', async () => { + mocks.fetchAvailableTypes.mockResolvedValue(PROJECT_A_RESPONSE); + const store = useTypeAvailabilityPoliciesStore(); + + await store.fetchForProject('project-a'); + store.reset(); + + expect(store.loadedProjectId).toBeNull(); + expect(store.isNodeTypeAvailable(RESTRICTED)).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 new file mode 100644 index 00000000000..af5b4d35bca --- /dev/null +++ b/packages/modules/type-availability-policies/frontend/src/type-availability-policies.store.ts @@ -0,0 +1,85 @@ +import type { 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 { + TYPE_AVAILABILITY_POLICIES_MODULE_ID, + TYPE_AVAILABILITY_POLICIES_STORE_ID, +} from './type-availability-policies.constants'; + +export const useTypeAvailabilityPoliciesStore = defineStore( + TYPE_AVAILABILITY_POLICIES_STORE_ID, + () => { + const rootStore = useRootStore(); + const settingsStore = useSettingsStore(); + + const restrictedNodeTypes = shallowRef>(new Map()); + const loadedProjectId = ref(null); + const requestedProjectId = ref(null); + const isLoading = ref(false); + + const isEnabled = computed( + () => settingsStore.isModuleActive(TYPE_AVAILABILITY_POLICIES_MODULE_ID) ?? false, + ); + + async function fetchForProject(projectId: string): Promise { + if (!isEnabled.value) return; + + requestedProjectId.value = projectId; + + if (projectId === loadedProjectId.value) { + isLoading.value = false; + return; + } + + isLoading.value = true; + try { + const entries = await fetchAvailableTypes(rootStore.restApiContext, projectId); + if (requestedProjectId.value !== projectId) return; + + restrictedNodeTypes.value = new Map( + entries.filter((entry) => !entry.available).map((entry) => [entry.name, entry]), + ); + loadedProjectId.value = projectId; + } catch (error) { + if (requestedProjectId.value !== projectId) return; + + console.error('Failed to fetch available types for project', projectId, error); + restrictedNodeTypes.value = new Map(); + loadedProjectId.value = null; + } finally { + if (requestedProjectId.value === projectId) isLoading.value = false; + } + } + + function getNodeTypeAvailability(name: string): NodeTypeAvailability { + if (loadedProjectId.value !== requestedProjectId.value) return { name, available: true }; + + return restrictedNodeTypes.value.get(name) ?? { name, available: true }; + } + + function isNodeTypeAvailable(name: string): boolean { + return getNodeTypeAvailability(name).available; + } + + function reset(): void { + restrictedNodeTypes.value = new Map(); + loadedProjectId.value = null; + requestedProjectId.value = null; + isLoading.value = false; + } + + return { + isEnabled, + isLoading, + loadedProjectId, + fetchForProject, + getNodeTypeAvailability, + isNodeTypeAvailable, + reset, + }; + }, +); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8efbe884bd8..4583219b3ae 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6729,9 +6729,24 @@ importers: packages/modules/type-availability-policies/frontend: dependencies: + '@n8n/api-types': + specifier: workspace:* + version: link:../../../@n8n/api-types '@n8n/frontend-module-sdk': specifier: workspace:* version: link:../../../frontend/@n8n/frontend-module-sdk + '@n8n/rest-api-client': + specifier: workspace:* + version: link:../../../frontend/@n8n/rest-api-client + '@n8n/stores': + specifier: workspace:* + version: link:../../../frontend/@n8n/stores + pinia: + specifier: catalog:frontend + version: 2.2.4(typescript@6.0.2)(vue@3.5.26(typescript@6.0.2)) + vue: + specifier: catalog:frontend + version: 3.5.26(typescript@6.0.2) devDependencies: '@n8n/eslint-config': specifier: workspace:*