From 2d0980cb1c251e4b51c392c61713807d753f2102 Mon Sep 17 00:00:00 2001 From: Christian Byrne Date: Fri, 23 Jan 2026 15:41:21 -0800 Subject: [PATCH] fix: use authenticated API for remote config polling (#8266) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Fixes remote config polling to use authenticated API - Consolidates `loadRemoteConfig` into `refreshRemoteConfig` with auth control - Adds unit tests for both auth modes ## Problem The cloud extension's polling interval was using unauthenticated `fetch`, causing it to receive only default feature flags instead of user-specific configurations. **Root cause:** 1. Bootstrap called `loadRemoteConfig()` (raw `fetch`, no auth) - correct, auth not initialized yet 2. Extension watch called `refreshRemoteConfig()` (`api.fetchApi`, with auth) - correct 3. Extension interval called `loadRemoteConfig()` (raw `fetch`, no auth) - **bug** ## Solution - Consolidate into single `refreshRemoteConfig()` with optional `useAuth` parameter (defaults to `true`) - Bootstrap: `refreshRemoteConfig({ useAuth: false })` - Polling: `refreshRemoteConfig()` (authenticated by default) ## Test Plan - Unit tests verify both auth modes - `pnpm typecheck`, `pnpm lint`, `pnpm test:unit` all pass ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-8266-fix-use-authenticated-API-for-remote-config-polling-2f16d73d3650817ea7b0e3a7e3ccf12a) by [Unito](https://www.unito.io) --- src/extensions/core/cloudRemoteConfig.ts | 5 +- src/main.ts | 6 +- .../remoteConfig/refreshRemoteConfig.test.ts | 109 ++++++++++++++++++ .../remoteConfig/refreshRemoteConfig.ts | 25 +++- src/platform/remoteConfig/remoteConfig.ts | 24 ---- 5 files changed, 137 insertions(+), 32 deletions(-) create mode 100644 src/platform/remoteConfig/refreshRemoteConfig.test.ts diff --git a/src/extensions/core/cloudRemoteConfig.ts b/src/extensions/core/cloudRemoteConfig.ts index 0628800d8..6d775389e 100644 --- a/src/extensions/core/cloudRemoteConfig.ts +++ b/src/extensions/core/cloudRemoteConfig.ts @@ -2,7 +2,6 @@ import { watchDebounced } from '@vueuse/core' import { useCurrentUser } from '@/composables/auth/useCurrentUser' import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription' -import { loadRemoteConfig } from '@/platform/remoteConfig/remoteConfig' import { refreshRemoteConfig } from '@/platform/remoteConfig/refreshRemoteConfig' import { useExtensionService } from '@/services/extensionService' @@ -26,7 +25,7 @@ useExtensionService().registerExtension({ { debounce: 256, immediate: true } ) - // Poll for config updates every 10 minutes - setInterval(() => void loadRemoteConfig(), 600_000) + // Poll for config updates every 10 minutes (with auth) + setInterval(() => void refreshRemoteConfig(), 600_000) } }) diff --git a/src/main.ts b/src/main.ts index 94af1fcd9..4d395dbb6 100644 --- a/src/main.ts +++ b/src/main.ts @@ -27,9 +27,9 @@ import { i18n } from './i18n' import { isCloud } from '@/platform/distribution/types' if (isCloud) { - const { loadRemoteConfig } = - await import('@/platform/remoteConfig/remoteConfig') - await loadRemoteConfig() + const { refreshRemoteConfig } = + await import('@/platform/remoteConfig/refreshRemoteConfig') + await refreshRemoteConfig({ useAuth: false }) } const ComfyUIPreset = definePreset(Aura, { diff --git a/src/platform/remoteConfig/refreshRemoteConfig.test.ts b/src/platform/remoteConfig/refreshRemoteConfig.test.ts new file mode 100644 index 000000000..34b75d123 --- /dev/null +++ b/src/platform/remoteConfig/refreshRemoteConfig.test.ts @@ -0,0 +1,109 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { api } from '@/scripts/api' + +import { refreshRemoteConfig } from './refreshRemoteConfig' +import { remoteConfig } from './remoteConfig' + +vi.mock('@/scripts/api', () => ({ + api: { + fetchApi: vi.fn() + } +})) + +global.fetch = vi.fn() + +describe('refreshRemoteConfig', () => { + const mockConfig = { feature1: true, feature2: 'value' } + + beforeEach(() => { + vi.clearAllMocks() + remoteConfig.value = {} + window.__CONFIG__ = {} + }) + + describe('with auth (default)', () => { + it('uses api.fetchApi when useAuth is true', async () => { + vi.mocked(api.fetchApi).mockResolvedValue({ + ok: true, + json: async () => mockConfig + } as Response) + + await refreshRemoteConfig({ useAuth: true }) + + expect(api.fetchApi).toHaveBeenCalledWith('/features', { + cache: 'no-store' + }) + expect(global.fetch).not.toHaveBeenCalled() + expect(remoteConfig.value).toEqual(mockConfig) + expect(window.__CONFIG__).toEqual(mockConfig) + }) + + it('uses api.fetchApi by default', async () => { + vi.mocked(api.fetchApi).mockResolvedValue({ + ok: true, + json: async () => mockConfig + } as Response) + + await refreshRemoteConfig() + + expect(api.fetchApi).toHaveBeenCalled() + expect(global.fetch).not.toHaveBeenCalled() + }) + }) + + describe('without auth', () => { + it('uses raw fetch when useAuth is false', async () => { + vi.mocked(global.fetch).mockResolvedValue({ + ok: true, + json: async () => mockConfig + } as Response) + + await refreshRemoteConfig({ useAuth: false }) + + expect(global.fetch).toHaveBeenCalledWith('/api/features', { + cache: 'no-store' + }) + expect(api.fetchApi).not.toHaveBeenCalled() + expect(remoteConfig.value).toEqual(mockConfig) + expect(window.__CONFIG__).toEqual(mockConfig) + }) + }) + + describe('error handling', () => { + it('clears config on 401 response', async () => { + vi.mocked(api.fetchApi).mockResolvedValue({ + ok: false, + status: 401, + statusText: 'Unauthorized' + } as Response) + + await refreshRemoteConfig() + + expect(remoteConfig.value).toEqual({}) + expect(window.__CONFIG__).toEqual({}) + }) + + it('clears config on 403 response', async () => { + vi.mocked(api.fetchApi).mockResolvedValue({ + ok: false, + status: 403, + statusText: 'Forbidden' + } as Response) + + await refreshRemoteConfig() + + expect(remoteConfig.value).toEqual({}) + expect(window.__CONFIG__).toEqual({}) + }) + + it('clears config on fetch error', async () => { + vi.mocked(api.fetchApi).mockRejectedValue(new Error('Network error')) + + await refreshRemoteConfig() + + expect(remoteConfig.value).toEqual({}) + expect(window.__CONFIG__).toEqual({}) + }) + }) +}) diff --git a/src/platform/remoteConfig/refreshRemoteConfig.ts b/src/platform/remoteConfig/refreshRemoteConfig.ts index 5000f2014..8c87bfcdc 100644 --- a/src/platform/remoteConfig/refreshRemoteConfig.ts +++ b/src/platform/remoteConfig/refreshRemoteConfig.ts @@ -2,9 +2,28 @@ import { api } from '@/scripts/api' import { remoteConfig } from './remoteConfig' -export async function refreshRemoteConfig(): Promise { +interface RefreshRemoteConfigOptions { + /** + * Whether to use authenticated API (default: true). + * Set to false during bootstrap before auth is initialized. + */ + useAuth?: boolean +} + +/** + * Loads remote configuration from the backend /features endpoint + * and updates the reactive remoteConfig ref + */ +export async function refreshRemoteConfig( + options: RefreshRemoteConfigOptions = {} +): Promise { + const { useAuth = true } = options + try { - const response = await api.fetchApi('/features', { cache: 'no-store' }) + const response = useAuth + ? await api.fetchApi('/features', { cache: 'no-store' }) + : await fetch('/api/features', { cache: 'no-store' }) + if (response.ok) { const config = await response.json() window.__CONFIG__ = config @@ -19,5 +38,7 @@ export async function refreshRemoteConfig(): Promise { } } catch (error) { console.error('Failed to fetch remote config:', error) + window.__CONFIG__ = {} + remoteConfig.value = {} } } diff --git a/src/platform/remoteConfig/remoteConfig.ts b/src/platform/remoteConfig/remoteConfig.ts index 7d8f28c5f..40f36522f 100644 --- a/src/platform/remoteConfig/remoteConfig.ts +++ b/src/platform/remoteConfig/remoteConfig.ts @@ -8,7 +8,6 @@ * - Avoiding vendor lock-in for native apps * * This module is tree-shaken in OSS builds. - * Used for initial config load in main.ts and polling in the extension. */ import { ref } from 'vue' @@ -29,26 +28,3 @@ export function configValueOrDefault( const configValue = remoteConfig[key] return configValue || defaultValue } - -/** - * Loads remote configuration from the backend /api/features endpoint - * and updates the reactive remoteConfig ref - */ -export async function loadRemoteConfig(): Promise { - try { - const response = await fetch('/api/features', { cache: 'no-store' }) - if (response.ok) { - const config = await response.json() - window.__CONFIG__ = config - remoteConfig.value = config - } else { - console.warn('Failed to load remote config:', response.statusText) - window.__CONFIG__ = {} - remoteConfig.value = {} - } - } catch (error) { - console.error('Failed to fetch remote config:', error) - window.__CONFIG__ = {} - remoteConfig.value = {} - } -}