feat(editor): Add available-types store to the type-availability-policies module (no-changelog) (#38784)

This commit is contained in:
Theodore Nastos
2026-09-16 14:42:18 +00:00
committed by GitHub
parent 721d808eed
commit 5afb7e8102
8 changed files with 374 additions and 1 deletions
@@ -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
```
@@ -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:*",
@@ -1 +1,2 @@
export { TypeAvailabilityPoliciesModule } from './type-availability-policies.module';
export { useTypeAvailabilityPoliciesStore } from './type-availability-policies.store';
@@ -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<AvailableTypesResponse> {
return await makeRestApiRequest(context, 'GET', `/projects/${projectId}/available-types`);
}
@@ -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';
@@ -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<typeof vi.spyOn>;
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<AvailableTypesResponse>((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<AvailableTypesResponse>((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<AvailableTypesResponse>((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);
});
});
});
@@ -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<ReadonlyMap<string, NodeTypeAvailability>>(new Map());
const loadedProjectId = ref<string | null>(null);
const requestedProjectId = ref<string | null>(null);
const isLoading = ref(false);
const isEnabled = computed(
() => settingsStore.isModuleActive(TYPE_AVAILABILITY_POLICIES_MODULE_ID) ?? false,
);
async function fetchForProject(projectId: string): Promise<void> {
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,
};
},
);
+15
View File
@@ -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([email protected])([email protected]([email protected]))
vue:
specifier: catalog:frontend
version: 3.5.26([email protected])
devDependencies:
'@n8n/eslint-config':
specifier: workspace:*