diff --git a/src/composables/useFeatureFlags.test.ts b/src/composables/useFeatureFlags.test.ts index 6efa298f54..07b547ef7d 100644 --- a/src/composables/useFeatureFlags.test.ts +++ b/src/composables/useFeatureFlags.test.ts @@ -182,6 +182,20 @@ describe('useFeatureFlags', () => { }) }) + describe('partnerNodeGovernanceEnabled', () => { + afterEach(() => { + remoteConfig.value = {} + }) + + it('uses the workspace eligibility flag', () => { + remoteConfig.value = { partner_node_governance_enabled: true } + + const { flags } = useFeatureFlags() + + expect(flags.partnerNodeGovernanceEnabled).toBe(true) + }) + }) + describe('dev override via localStorage', () => { afterEach(() => { localStorage.clear() diff --git a/src/composables/useFeatureFlags.ts b/src/composables/useFeatureFlags.ts index 04792e966b..0e45fa0e02 100644 --- a/src/composables/useFeatureFlags.ts +++ b/src/composables/useFeatureFlags.ts @@ -24,6 +24,7 @@ export enum ServerFeatureFlag { ONBOARDING_SURVEY_ENABLED = 'onboarding_survey_enabled', LINEAR_TOGGLE_ENABLED = 'linear_toggle_enabled', TEAM_WORKSPACES_ENABLED = 'team_workspaces_enabled', + PARTNER_NODE_GOVERNANCE_ENABLED = 'partner_node_governance_enabled', USER_SECRETS_ENABLED = 'user_secrets_enabled', NODE_REPLACEMENTS = 'node_replacements', NODE_LIBRARY_ESSENTIALS_ENABLED = 'node_library_essentials_enabled', @@ -134,6 +135,13 @@ export function useFeatureFlags() { cachedTeamWorkspacesEnabled ) }, + get partnerNodeGovernanceEnabled() { + return resolveFlag( + ServerFeatureFlag.PARTNER_NODE_GOVERNANCE_ENABLED, + remoteConfig.value.partner_node_governance_enabled, + false + ) + }, get userSecretsEnabled() { return resolveFlag( ServerFeatureFlag.USER_SECRETS_ENABLED, diff --git a/src/platform/remoteConfig/types.ts b/src/platform/remoteConfig/types.ts index 4a34358bef..ebc526dd12 100644 --- a/src/platform/remoteConfig/types.ts +++ b/src/platform/remoteConfig/types.ts @@ -107,6 +107,7 @@ export type RemoteConfig = { manager_survey_url?: string linear_toggle_enabled?: boolean team_workspaces_enabled?: boolean + partner_node_governance_enabled?: boolean user_secrets_enabled?: boolean node_library_essentials_enabled?: boolean free_tier_credits?: number diff --git a/src/platform/workspace/api/partnerNodePolicyApi.test.ts b/src/platform/workspace/api/partnerNodePolicyApi.test.ts new file mode 100644 index 0000000000..21fc5a3d3c --- /dev/null +++ b/src/platform/workspace/api/partnerNodePolicyApi.test.ts @@ -0,0 +1,79 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { + getPartnerNodePolicy, + PartnerNodePolicyApiError +} from '@/platform/workspace/api/partnerNodePolicyApi' + +const mockFetchApi = vi.fn() + +vi.mock('@/scripts/api', () => ({ + api: { + fetchApi: (...args: unknown[]) => mockFetchApi(...args) + } +})) + +function jsonResponse(body: unknown, init: Partial = {}): Response { + return { + ok: true, + status: 200, + statusText: 'OK', + json: () => Promise.resolve(body), + ...init + } as Response +} + +describe('partnerNodePolicyApi', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('normalizes the configured policy response', async () => { + mockFetchApi.mockResolvedValue( + jsonResponse({ + enforcement_enabled: true, + nodes: { AllowedNode: true, DisabledNode: false } + }) + ) + + await expect(getPartnerNodePolicy()).resolves.toEqual({ + enforcementEnabled: true, + nodes: { AllowedNode: true, DisabledNode: false } + }) + expect(mockFetchApi).toHaveBeenCalledWith( + '/workspace/partner-node-policy', + { cache: 'no-store' } + ) + }) + + it('maps 404 to an unconfigured policy', async () => { + mockFetchApi.mockResolvedValue( + jsonResponse({}, { ok: false, status: 404, statusText: 'Not Found' }) + ) + + await expect(getPartnerNodePolicy()).resolves.toBeNull() + }) + + it('preserves non-404 status codes for policy decisions', async () => { + mockFetchApi.mockResolvedValue( + jsonResponse( + {}, + { ok: false, status: 503, statusText: 'Service Unavailable' } + ) + ) + + await expect(getPartnerNodePolicy()).rejects.toEqual( + new PartnerNodePolicyApiError(503, 'Service Unavailable') + ) + }) + + it('rejects malformed policy responses', async () => { + mockFetchApi.mockResolvedValue( + jsonResponse({ enforcement_enabled: 'yes', nodes: [] }) + ) + + await expect(getPartnerNodePolicy()).rejects.toMatchObject({ + name: 'ZodError' + }) + }) +}) diff --git a/src/platform/workspace/api/partnerNodePolicyApi.ts b/src/platform/workspace/api/partnerNodePolicyApi.ts new file mode 100644 index 0000000000..92c97875b0 --- /dev/null +++ b/src/platform/workspace/api/partnerNodePolicyApi.ts @@ -0,0 +1,39 @@ +import { z } from 'zod' + +import { api } from '@/scripts/api' + +const partnerNodePolicyResponseSchema = z.object({ + enforcement_enabled: z.boolean(), + nodes: z.record(z.string(), z.boolean()) +}) + +export interface PartnerNodePolicy { + enforcementEnabled: boolean + nodes: Readonly> +} + +export class PartnerNodePolicyApiError extends Error { + constructor( + public readonly status: number, + message: string + ) { + super(message) + this.name = 'PartnerNodePolicyApiError' + } +} + +export async function getPartnerNodePolicy(): Promise { + const response = await api.fetchApi('/workspace/partner-node-policy', { + cache: 'no-store' + }) + if (response.status === 404) return null + if (!response.ok) { + throw new PartnerNodePolicyApiError(response.status, response.statusText) + } + + const data = partnerNodePolicyResponseSchema.parse(await response.json()) + return { + enforcementEnabled: data.enforcement_enabled, + nodes: data.nodes + } +} diff --git a/src/platform/workspace/stores/partnerNodeGovernanceStore.test.ts b/src/platform/workspace/stores/partnerNodeGovernanceStore.test.ts new file mode 100644 index 0000000000..d8ea32a14a --- /dev/null +++ b/src/platform/workspace/stores/partnerNodeGovernanceStore.test.ts @@ -0,0 +1,247 @@ +import { createTestingPinia } from '@pinia/testing' +import { setActivePinia } from 'pinia' +import { nextTick } from 'vue' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import type * as PartnerNodePolicyApi from '@/platform/workspace/api/partnerNodePolicyApi' +import type { PartnerNodePolicy } from '@/platform/workspace/api/partnerNodePolicyApi' +import { PartnerNodePolicyApiError } from '@/platform/workspace/api/partnerNodePolicyApi' +import { usePartnerNodeGovernanceStore } from '@/platform/workspace/stores/partnerNodeGovernanceStore' +import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore' +import type { ComfyNodeDef } from '@/schemas/nodeDefSchema' +import { useNodeDefStore } from '@/stores/nodeDefStore' + +const mockGetPartnerNodePolicy = vi.hoisted(() => vi.fn()) +const mockFlags = vi.hoisted(() => ({ + teamWorkspacesEnabled: true, + partnerNodeGovernanceEnabled: true +})) + +vi.mock('@/composables/useFeatureFlags', () => ({ + useFeatureFlags: () => ({ flags: mockFlags }) +})) + +vi.mock( + '@/platform/workspace/api/partnerNodePolicyApi', + async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + getPartnerNodePolicy: mockGetPartnerNodePolicy + } + } +) + +function nodeDef( + name: string, + overrides: Partial = {} +): ComfyNodeDef { + return { + name, + display_name: `Display ${name}`, + category: 'partner/image/Provider', + python_module: 'comfy_api_nodes.provider', + description: '', + input: {}, + output: [], + output_is_list: [], + output_name: [], + output_node: false, + api_node: true, + ...overrides + } +} + +function activateWorkspace(id: string, type: 'personal' | 'team' = 'team') { + const store = useTeamWorkspaceStore() + store.workspaces = [ + { + id, + name: id, + type, + role: 'owner', + created_at: '2026-01-01T00:00:00Z', + joined_at: '2026-01-01T00:00:00Z', + isSubscribed: false, + subscriptionPlan: null, + subscriptionTier: null, + members: [], + pendingInvites: [] + } + ] + store.activeWorkspaceId = id +} + +async function createLoadedStore() { + const store = usePartnerNodeGovernanceStore() + await vi.waitFor(() => expect(store.status).not.toBe('loading')) + return store +} + +describe('partnerNodeGovernanceStore', () => { + let store: ReturnType | undefined + + beforeEach(() => { + setActivePinia(createTestingPinia({ stubActions: false })) + vi.clearAllMocks() + mockFlags.teamWorkspacesEnabled = true + mockFlags.partnerNodeGovernanceEnabled = true + mockGetPartnerNodePolicy.mockResolvedValue(null) + activateWorkspace('workspace-one') + useNodeDefStore().updateNodeDefs([ + nodeDef('AllowedNode'), + nodeDef('DisabledNode'), + nodeDef('UnreviewedNode'), + nodeDef('CoreNode', { + api_node: false, + category: 'sampling', + python_module: 'nodes' + }) + ]) + }) + + afterEach(() => { + store?.$dispose() + store = undefined + }) + + it('composes partner-node catalog metadata from object info', async () => { + useNodeDefStore().updateNodeDefs([ + nodeDef('PartnerNode', { + display_name: 'Partner Node', + category: 'partner/video/Acme' + }), + nodeDef('CoreNode', { api_node: false }) + ]) + + store = await createLoadedStore() + + expect(store.partnerNodes).toEqual([ + { id: 'PartnerNode', name: 'Partner Node', provider: 'Acme' } + ]) + }) + + it('treats 404 as unconfigured and allows every node', async () => { + store = await createLoadedStore() + + expect(store.status).toBe('unconfigured') + expect(store.isNodeDisabled('AllowedNode')).toBe(false) + expect(store.isNodeDisabled('DisabledNode')).toBe(false) + }) + + it('does not block nodes while enforcement is off', async () => { + mockGetPartnerNodePolicy.mockResolvedValue({ + enforcementEnabled: false, + nodes: { AllowedNode: true, DisabledNode: false } + } satisfies PartnerNodePolicy) + + store = await createLoadedStore() + + expect(store.isNodeDisabled('AllowedNode')).toBe(false) + expect(store.isNodeDisabled('DisabledNode')).toBe(false) + }) + + it('allows only explicit true rules while enforcement is on', async () => { + mockGetPartnerNodePolicy.mockResolvedValue({ + enforcementEnabled: true, + nodes: { AllowedNode: true, DisabledNode: false } + } satisfies PartnerNodePolicy) + + store = await createLoadedStore() + + expect(store.isNodeDisabled('AllowedNode')).toBe(false) + expect(store.isNodeDisabled('DisabledNode')).toBe(true) + expect(store.isNodeDisabled('UnreviewedNode')).toBe(true) + expect(store.isNodeDisabled('CoreNode')).toBe(false) + }) + + it('fails closed for a 503 from an enforcing workspace', async () => { + mockGetPartnerNodePolicy.mockRejectedValue( + new PartnerNodePolicyApiError(503, 'Service Unavailable') + ) + + store = await createLoadedStore() + + expect(store.status).toBe('unavailable') + expect(store.isNodeDisabled('AllowedNode')).toBe(true) + expect(store.isNodeDisabled('CoreNode')).toBe(false) + }) + + it('stays fail-closed while retrying an unavailable policy', async () => { + mockGetPartnerNodePolicy.mockRejectedValueOnce( + new PartnerNodePolicyApiError(503, 'Service Unavailable') + ) + store = await createLoadedStore() + let rejectRetry!: (error: Error) => void + mockGetPartnerNodePolicy.mockReturnValueOnce( + new Promise((_, reject) => { + rejectRetry = reject + }) + ) + + const retry = store.loadPolicy() + + expect(store.status).toBe('unavailable') + expect(store.isNodeDisabled('AllowedNode')).toBe(true) + rejectRetry(new Error('Network error')) + await retry + expect(store.status).toBe('unavailable') + expect(store.isNodeDisabled('AllowedNode')).toBe(true) + }) + + it('preserves the last enforcing policy after a generic refresh error', async () => { + mockGetPartnerNodePolicy.mockResolvedValue({ + enforcementEnabled: true, + nodes: { AllowedNode: true, DisabledNode: false } + } satisfies PartnerNodePolicy) + store = await createLoadedStore() + mockGetPartnerNodePolicy.mockRejectedValue(new Error('Network error')) + + await store.loadPolicy() + + expect(store.status).toBe('error') + expect(store.isNodeDisabled('AllowedNode')).toBe(false) + expect(store.isNodeDisabled('DisabledNode')).toBe(true) + }) + + it('stays inactive outside an eligible team workspace', async () => { + mockFlags.partnerNodeGovernanceEnabled = false + activateWorkspace('personal-workspace', 'personal') + + store = usePartnerNodeGovernanceStore() + await nextTick() + + expect(mockGetPartnerNodePolicy).not.toHaveBeenCalled() + expect(store.status).toBe('inactive') + expect(store.isNodeDisabled('DisabledNode')).toBe(false) + }) + + it('ignores a stale response after switching workspaces', async () => { + let resolveFirst!: (policy: PartnerNodePolicy) => void + mockGetPartnerNodePolicy + .mockReturnValueOnce( + new Promise((resolve) => { + resolveFirst = resolve + }) + ) + .mockResolvedValueOnce({ + enforcementEnabled: true, + nodes: { DisabledNode: true } + } satisfies PartnerNodePolicy) + store = usePartnerNodeGovernanceStore() + await vi.waitFor(() => + expect(mockGetPartnerNodePolicy).toHaveBeenCalledTimes(1) + ) + + activateWorkspace('workspace-two') + await vi.waitFor(() => expect(store?.status).toBe('configured')) + resolveFirst({ + enforcementEnabled: true, + nodes: { DisabledNode: false } + }) + await nextTick() + + expect(store.governedWorkspaceId).toBe('workspace-two') + expect(store.isNodeDisabled('DisabledNode')).toBe(false) + }) +}) diff --git a/src/platform/workspace/stores/partnerNodeGovernanceStore.ts b/src/platform/workspace/stores/partnerNodeGovernanceStore.ts new file mode 100644 index 0000000000..bc6c4299db --- /dev/null +++ b/src/platform/workspace/stores/partnerNodeGovernanceStore.ts @@ -0,0 +1,139 @@ +import { defineStore } from 'pinia' +import { computed, ref, shallowRef, watch } from 'vue' + +import { useFeatureFlags } from '@/composables/useFeatureFlags' +import { + getPartnerNodePolicy, + PartnerNodePolicyApiError +} from '@/platform/workspace/api/partnerNodePolicyApi' +import type { PartnerNodePolicy } from '@/platform/workspace/api/partnerNodePolicyApi' +import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore' +import { useNodeDefStore } from '@/stores/nodeDefStore' + +export interface PartnerNodeCatalogItem { + id: string + name: string + provider: string +} + +export type PartnerNodePolicyStatus = + | 'inactive' + | 'loading' + | 'unconfigured' + | 'configured' + | 'unavailable' + | 'error' + +export const usePartnerNodeGovernanceStore = defineStore( + 'partnerNodeGovernance', + () => { + const { flags } = useFeatureFlags() + const workspaceStore = useTeamWorkspaceStore() + const nodeDefStore = useNodeDefStore() + + const policy = shallowRef(null) + const policyWorkspaceId = ref(null) + const status = ref('inactive') + const error = shallowRef(null) + let loadVersion = 0 + + const governedWorkspaceId = computed(() => { + const workspace = workspaceStore.activeWorkspace + return flags.teamWorkspacesEnabled && + flags.partnerNodeGovernanceEnabled && + workspace?.type === 'team' + ? workspace.id + : null + }) + + const partnerNodes = computed(() => + Object.values(nodeDefStore.nodeDefsByName) + .filter((nodeDef) => nodeDef.api_node) + .map((nodeDef) => ({ + id: nodeDef.name, + name: nodeDef.display_name || nodeDef.name, + provider: + nodeDef.category.split('/')[2] || + nodeDef.category || + nodeDef.python_module + })) + ) + + function isNodeDisabled(nodeType: string): boolean { + if (!nodeDefStore.nodeDefsByName[nodeType]?.api_node) return false + const workspaceId = governedWorkspaceId.value + if (!workspaceId || policyWorkspaceId.value !== workspaceId) return false + if (status.value === 'unavailable') return true + return ( + policy.value?.enforcementEnabled === true && + policy.value.nodes[nodeType] !== true + ) + } + + async function loadPolicy(): Promise { + const workspaceId = governedWorkspaceId.value + const version = ++loadVersion + if (!workspaceId) { + policy.value = null + policyWorkspaceId.value = null + status.value = 'inactive' + error.value = null + return + } + + const workspaceChanged = policyWorkspaceId.value !== workspaceId + if (workspaceChanged) { + policy.value = null + policyWorkspaceId.value = workspaceId + status.value = 'loading' + } else if (status.value !== 'unavailable') { + status.value = 'loading' + } + error.value = null + + try { + const nextPolicy = await getPartnerNodePolicy() + if ( + version !== loadVersion || + governedWorkspaceId.value !== workspaceId + ) { + return + } + policy.value = nextPolicy + status.value = nextPolicy ? 'configured' : 'unconfigured' + } catch (loadError) { + if ( + version !== loadVersion || + governedWorkspaceId.value !== workspaceId + ) { + return + } + error.value = + loadError instanceof Error + ? loadError + : new Error('Failed to load partner node policy') + if ( + loadError instanceof PartnerNodePolicyApiError && + loadError.status === 503 + ) { + policy.value = null + status.value = 'unavailable' + return + } + if (status.value !== 'unavailable') status.value = 'error' + } + } + + watch(governedWorkspaceId, () => void loadPolicy(), { immediate: true }) + + return { + policy, + status, + error, + governedWorkspaceId, + partnerNodes, + isNodeDisabled, + loadPolicy + } + } +)