From 23b9417567d2c89573ffbbd749d5beedaede49df Mon Sep 17 00:00:00 2001 From: Richard R Date: Sat, 6 Jun 2026 15:55:54 -0600 Subject: [PATCH] fix(config): prevent arrays from being treated as records in preference normalization Update preference normalization logic to explicitly reject arrays when validating record types, ensuring only plain objects are accepted. Adjust ConfigProvider effect conditions for clarity and correctness. Add unit tests verifying that arrays are not accepted as valid preference records or saved voices. --- src/contexts/ConfigContext.tsx | 78 +++++++++---------- src/lib/server/user/preferences-normalize.ts | 2 +- .../user-preferences-normalize.vitest.spec.ts | 9 +++ 3 files changed, 49 insertions(+), 40 deletions(-) diff --git a/src/contexts/ConfigContext.tsx b/src/contexts/ConfigContext.tsx index cf8ce0a..a397c0f 100644 --- a/src/contexts/ConfigContext.tsx +++ b/src/contexts/ConfigContext.tsx @@ -209,54 +209,54 @@ export function ConfigProvider({ children }: { children: ReactNode }) { }, [appConfig]); useEffect(() => { - if (!ttsProvidersTabDisabled || !isDBReady || !appConfig || sharedProvidersLoading) return; + if (ttsProvidersTabDisabled && isDBReady && appConfig && !sharedProvidersLoading) { + const resetPatch: Partial = {}; - const resetPatch: Partial = {}; + // When the provider tab is hidden, clear any user-set provider config back to + // "inherit the admin default" (empty) rather than baking in a concrete value. + if (appConfig.apiKey !== '') resetPatch.apiKey = ''; + if (appConfig.baseUrl !== '') resetPatch.baseUrl = ''; + if (appConfig.providerRef !== '') resetPatch.providerRef = ''; + if (appConfig.providerType !== 'unknown') resetPatch.providerType = 'unknown'; + if (appConfig.ttsModel !== '') resetPatch.ttsModel = ''; + if (appConfig.ttsInstructions !== '') resetPatch.ttsInstructions = ''; + // Keep voice selection state intact so player/Audiobook voice pickers still + // work when the TTS providers tab is hidden. This reset is only for provider + // configuration fields. - // When the provider tab is hidden, clear any user-set provider config back to - // "inherit the admin default" (empty) rather than baking in a concrete value. - if (appConfig.apiKey !== '') resetPatch.apiKey = ''; - if (appConfig.baseUrl !== '') resetPatch.baseUrl = ''; - if (appConfig.providerRef !== '') resetPatch.providerRef = ''; - if (appConfig.providerType !== 'unknown') resetPatch.providerType = 'unknown'; - if (appConfig.ttsModel !== '') resetPatch.ttsModel = ''; - if (appConfig.ttsInstructions !== '') resetPatch.ttsInstructions = ''; - // Keep voice selection state intact so player/Audiobook voice pickers still - // work when the TTS providers tab is hidden. This reset is only for provider - // configuration fields. + if (Object.keys(resetPatch).length === 0) return; - if (Object.keys(resetPatch).length === 0) return; - - updateAppConfig(resetPatch).catch((error) => { - console.warn('Failed to clear hidden TTS provider settings:', error); - }); - queueSyncedPreferencePatch(resetPatch); + updateAppConfig(resetPatch).catch((error) => { + console.warn('Failed to clear hidden TTS provider settings:', error); + }); + queueSyncedPreferencePatch(resetPatch); + } }, [ttsProvidersTabDisabled, isDBReady, appConfig, queueSyncedPreferencePatch, sharedProvidersLoading]); useEffect(() => { - if (!restrictUserApiKeys || !isDBReady || !appConfig || sharedProvidersLoading) return; + if (restrictUserApiKeys && isDBReady && appConfig && !sharedProvidersLoading) { + const resetPatch: Partial = {}; - const resetPatch: Partial = {}; + if (appConfig.apiKey !== '') resetPatch.apiKey = ''; + if (appConfig.baseUrl !== '') resetPatch.baseUrl = ''; + // Built-in providers aren't selectable in restricted mode. Clear any stale + // built-in selection (including the old 'custom-openai' default that used to + // be baked into every config) back to "inherit the admin default" so the + // user follows whatever shared provider the admin has configured. + if (isBuiltInTtsProviderId(appConfig.providerRef)) { + resetPatch.providerRef = ''; + resetPatch.providerType = 'unknown'; + resetPatch.ttsModel = ''; + resetPatch.ttsInstructions = ''; + } - if (appConfig.apiKey !== '') resetPatch.apiKey = ''; - if (appConfig.baseUrl !== '') resetPatch.baseUrl = ''; - // Built-in providers aren't selectable in restricted mode. Clear any stale - // built-in selection (including the old 'custom-openai' default that used to - // be baked into every config) back to "inherit the admin default" so the - // user follows whatever shared provider the admin has configured. - if (isBuiltInTtsProviderId(appConfig.providerRef)) { - resetPatch.providerRef = ''; - resetPatch.providerType = 'unknown'; - resetPatch.ttsModel = ''; - resetPatch.ttsInstructions = ''; + if (Object.keys(resetPatch).length === 0) return; + + updateAppConfig(resetPatch).catch((error) => { + console.warn('Failed to enforce restricted user API key mode:', error); + }); + queueSyncedPreferencePatch(resetPatch); } - - if (Object.keys(resetPatch).length === 0) return; - - updateAppConfig(resetPatch).catch((error) => { - console.warn('Failed to enforce restricted user API key mode:', error); - }); - queueSyncedPreferencePatch(resetPatch); }, [restrictUserApiKeys, isDBReady, appConfig, queueSyncedPreferencePatch, sharedProvidersLoading]); useEffect(() => { diff --git a/src/lib/server/user/preferences-normalize.ts b/src/lib/server/user/preferences-normalize.ts index 4555330..51d0a4e 100644 --- a/src/lib/server/user/preferences-normalize.ts +++ b/src/lib/server/user/preferences-normalize.ts @@ -14,7 +14,7 @@ export interface PreferenceNormalizationContext { } export function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null; + return typeof value === 'object' && value !== null && !Array.isArray(value); } export function sanitizeSavedVoices(value: unknown): Record { diff --git a/tests/unit/user-preferences-normalize.vitest.spec.ts b/tests/unit/user-preferences-normalize.vitest.spec.ts index 16975ea..13f5478 100644 --- a/tests/unit/user-preferences-normalize.vitest.spec.ts +++ b/tests/unit/user-preferences-normalize.vitest.spec.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from 'vitest'; import { sanitizePreferencesPatch, + sanitizeSavedVoices, type PreferenceNormalizationContext, } from '../../src/lib/server/user/preferences-normalize'; @@ -96,4 +97,12 @@ describe('sanitizePreferencesPatch — inherit-by-default provider model', () => expect('providerType' in patch).toBe(false); expect('ttsModel' in patch).toBe(false); }); + + test('rejects arrays where preference records are expected', () => { + expect(sanitizePreferencesPatch(['voice'], makeContext(), { fillMissingProvider: false })).toEqual({ + patch: {}, + migrated: false, + }); + expect(sanitizeSavedVoices(['af_sarah'])).toEqual({}); + }); });