- {ttsProviders.length === 0 ? (
+ {sharedProvidersLoading ? (
+
Loading providers…
+ ) : ttsProviders.length === 0 ? (
User API keys are restricted and no shared provider is configured. Ask an admin to add one.
@@ -815,7 +824,7 @@ export function SettingsModal({
type="button"
variant="primary"
size="md"
- disabled={!canSubmit}
+ disabled={!canSubmit || sharedProvidersLoading}
onClick={async () => {
const defaults = resolveProviderDefaults({
providerRef: selectedProviderRef,
@@ -833,6 +842,16 @@ export function SettingsModal({
: defaults.defaultModel;
await updateConfigKey('ttsModel', finalModel);
await updateConfigKey('ttsInstructions', localTTSInstructions);
+ // Push the change to the server immediately rather than waiting on
+ // the debounce, so a quick reload can't lose the save. Surface
+ // failures instead of silently dropping them.
+ try {
+ await flushUserPreferencesSync();
+ } catch (error) {
+ console.error('Failed to save TTS provider settings:', error);
+ toast.error('Failed to save provider settings. Please try again.');
+ return;
+ }
setIsOpen(false);
}}
>
diff --git a/src/contexts/ConfigContext.tsx b/src/contexts/ConfigContext.tsx
index b6a6c74..cf8ce0a 100644
--- a/src/contexts/ConfigContext.tsx
+++ b/src/contexts/ConfigContext.tsx
@@ -5,11 +5,11 @@ import { useLiveQuery } from 'dexie-react-hooks';
import { db, initDB, migrateLegacyDexieDocumentIdsToSha, updateAppConfig } from '@/lib/client/dexie';
import { APP_CONFIG_DEFAULTS, type ViewType, type SavedVoices, type AppConfigValues, type AppConfigRow } from '@/types/config';
import { isBuiltInTtsProviderId } from '@/lib/shared/tts-provider-catalog';
-import { resolveEffectiveProviderType, resolveProviderDefaults } from '@/lib/shared/tts-provider-policy';
+import { resolveProviderDefaults } from '@/lib/shared/tts-provider-policy';
import { scheduleUserPreferencesSync, cancelPendingPreferenceSync, getUserPreferences, putUserPreferences } from '@/lib/client/api/user-state';
import { SYNCED_PREFERENCE_KEYS, type SyncedPreferenceKey, type SyncedPreferencesPatch } from '@/types/user-state';
import { useAuthSession } from '@/hooks/useAuthSession';
-import { useFeatureFlag } from '@/contexts/RuntimeConfigContext';
+import { useFeatureFlag, useRuntimeConfig } from '@/contexts/RuntimeConfigContext';
import { buildSyncedPreferencePatch } from '@/lib/client/config/preferences';
import { applyConfigUpdate } from '@/lib/client/config/updates';
import { useSharedProviders } from '@/hooks/useSharedProviders';
@@ -69,16 +69,12 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
const didRunStartupMigrations = useRef(false);
const didAttemptInitialPreferenceSeedForSession = useRef
(null);
const syncedPreferenceKeys = useMemo(() => new Set(SYNCED_PREFERENCE_KEYS), []);
- const { providers: sharedProviders } = useSharedProviders();
+ const { providers: sharedProviders, isLoading: sharedProvidersLoading } = useSharedProviders();
const { data: sessionData, isPending: isSessionPending } = useAuthSession();
const sessionKey = sessionData?.user?.id ?? 'no-session';
- const providerResetDefaults = useMemo(() => {
- return resolveProviderDefaults({
- providerRef: APP_CONFIG_DEFAULTS.providerRef,
- providerType: APP_CONFIG_DEFAULTS.providerType,
- sharedProviders,
- });
- }, [sharedProviders]);
+ // The instance/admin default provider. An empty user providerRef "inherits"
+ // this, resolved (admin slug -> concrete provider) where the value is used.
+ const adminDefaultProviderRef = useRuntimeConfig().defaultTtsProvider;
const queueSyncedPreferencePatch = useCallback((patch: Partial) => {
if (sessionKey === 'no-session') return;
@@ -213,28 +209,18 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
}, [appConfig]);
useEffect(() => {
- if (!ttsProvidersTabDisabled || !isDBReady || !appConfig) return;
+ if (!ttsProvidersTabDisabled || !isDBReady || !appConfig || sharedProvidersLoading) return;
const resetPatch: Partial = {};
- if (appConfig.apiKey !== APP_CONFIG_DEFAULTS.apiKey) {
- resetPatch.apiKey = APP_CONFIG_DEFAULTS.apiKey;
- }
- if (appConfig.baseUrl !== APP_CONFIG_DEFAULTS.baseUrl) {
- resetPatch.baseUrl = APP_CONFIG_DEFAULTS.baseUrl;
- }
- if (appConfig.providerRef !== providerResetDefaults.providerRef) {
- resetPatch.providerRef = providerResetDefaults.providerRef;
- }
- if (appConfig.providerType !== providerResetDefaults.providerType) {
- resetPatch.providerType = providerResetDefaults.providerType;
- }
- if (appConfig.ttsModel !== providerResetDefaults.defaultModel) {
- resetPatch.ttsModel = providerResetDefaults.defaultModel;
- }
- if (appConfig.ttsInstructions !== providerResetDefaults.defaultInstructions) {
- resetPatch.ttsInstructions = providerResetDefaults.defaultInstructions;
- }
+ // 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.
@@ -245,32 +231,24 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
console.warn('Failed to clear hidden TTS provider settings:', error);
});
queueSyncedPreferencePatch(resetPatch);
- }, [ttsProvidersTabDisabled, isDBReady, appConfig, queueSyncedPreferencePatch, providerResetDefaults]);
+ }, [ttsProvidersTabDisabled, isDBReady, appConfig, queueSyncedPreferencePatch, sharedProvidersLoading]);
useEffect(() => {
- if (!restrictUserApiKeys || !isDBReady || !appConfig) return;
+ if (!restrictUserApiKeys || !isDBReady || !appConfig || sharedProvidersLoading) return;
const resetPatch: Partial = {};
- if (appConfig.apiKey !== APP_CONFIG_DEFAULTS.apiKey) {
- resetPatch.apiKey = APP_CONFIG_DEFAULTS.apiKey;
- }
- if (appConfig.baseUrl !== APP_CONFIG_DEFAULTS.baseUrl) {
- resetPatch.baseUrl = APP_CONFIG_DEFAULTS.baseUrl;
- }
+ 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)) {
- if (appConfig.providerRef !== providerResetDefaults.providerRef) {
- resetPatch.providerRef = providerResetDefaults.providerRef;
- }
- if (appConfig.providerType !== providerResetDefaults.providerType) {
- resetPatch.providerType = providerResetDefaults.providerType;
- }
- if (appConfig.ttsModel !== providerResetDefaults.defaultModel) {
- resetPatch.ttsModel = providerResetDefaults.defaultModel;
- }
- if (appConfig.ttsInstructions !== providerResetDefaults.defaultInstructions) {
- resetPatch.ttsInstructions = providerResetDefaults.defaultInstructions;
- }
+ resetPatch.providerRef = '';
+ resetPatch.providerType = 'unknown';
+ resetPatch.ttsModel = '';
+ resetPatch.ttsInstructions = '';
}
if (Object.keys(resetPatch).length === 0) return;
@@ -279,15 +257,18 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
console.warn('Failed to enforce restricted user API key mode:', error);
});
queueSyncedPreferencePatch(resetPatch);
- }, [restrictUserApiKeys, isDBReady, appConfig, queueSyncedPreferencePatch, providerResetDefaults]);
+ }, [restrictUserApiKeys, isDBReady, appConfig, queueSyncedPreferencePatch, sharedProvidersLoading]);
useEffect(() => {
- if (showAllProviderModels || !isDBReady || !appConfig) return;
+ if (showAllProviderModels || !isDBReady || !appConfig || sharedProvidersLoading) return;
+ // Inheriting (empty providerRef): the effective model is resolved at read
+ // time, so there is nothing to persist/enforce here.
+ if (!appConfig.providerRef) return;
const providerDefaults = resolveProviderDefaults({
providerRef: appConfig.providerRef,
providerType: appConfig.providerType,
sharedProviders,
- fallbackProviderRef: providerResetDefaults.providerRef,
+ fallbackProviderRef: adminDefaultProviderRef,
});
if (!providerDefaults.defaultModel) return;
if (appConfig.ttsModel === providerDefaults.defaultModel) return;
@@ -296,7 +277,7 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
console.warn('Failed to enforce provider default model restriction:', error);
});
queueSyncedPreferencePatch(patch);
- }, [showAllProviderModels, isDBReady, appConfig, sharedProviders, providerResetDefaults.providerRef, queueSyncedPreferencePatch]);
+ }, [showAllProviderModels, isDBReady, appConfig, sharedProviders, adminDefaultProviderRef, queueSyncedPreferencePatch, sharedProvidersLoading]);
useEffect(() => {
if (!isDBReady || !appConfig || isSessionPending) return;
@@ -359,33 +340,48 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
htmlHighlightEnabled,
htmlWordHighlightEnabled,
} = config || APP_CONFIG_DEFAULTS;
- const providerType = useMemo(
- () => resolveEffectiveProviderType({
+ // Resolve the effective provider for consumers. An empty stored providerRef
+ // means "inherit the admin default", which we resolve here so the reader,
+ // voice pickers, and settings UI all see a concrete, usable provider without
+ // mutating the stored ("inherit") value.
+ const effectiveProvider = useMemo(
+ () => resolveProviderDefaults({
providerRef,
providerType: _persistedProviderType,
sharedProviders,
+ fallbackProviderRef: adminDefaultProviderRef,
}),
- [providerRef, _persistedProviderType, sharedProviders],
+ [providerRef, _persistedProviderType, sharedProviders, adminDefaultProviderRef],
);
+ const effectiveProviderRef = effectiveProvider.providerRef;
+ const providerType = effectiveProvider.providerType;
+ const effectiveTtsModel = ttsModel || effectiveProvider.defaultModel;
+ const effectiveTtsInstructions = ttsInstructions || effectiveProvider.defaultInstructions;
useEffect(() => {
- if (!isDBReady || !appConfig) return;
+ if (!isDBReady || !appConfig || sharedProvidersLoading) return;
+ // Only persist a resolved providerType for an explicitly chosen provider.
+ // While inheriting (empty providerRef) the type stays unset in storage.
+ if (!appConfig.providerRef) return;
if (appConfig.providerType === providerType) return;
const patch: Partial = { providerType };
updateAppConfig(patch).catch((error) => {
console.warn('Failed to persist resolved providerType:', error);
});
queueSyncedPreferencePatch(patch);
- }, [isDBReady, appConfig, providerType, queueSyncedPreferencePatch]);
+ }, [isDBReady, appConfig, providerType, queueSyncedPreferencePatch, sharedProvidersLoading]);
void _persistedProviderType;
useEffect(() => {
- if (!isDBReady || !appConfig) return;
+ if (!isDBReady || !appConfig || sharedProvidersLoading) return;
+ // Inheriting (empty providerRef): the effective model is resolved at read
+ // time; don't write a concrete model into the "inherit" state.
+ if (!appConfig.providerRef) return;
const providerDefaults = resolveProviderDefaults({
providerRef: appConfig.providerRef,
providerType: appConfig.providerType,
sharedProviders,
- fallbackProviderRef: providerResetDefaults.providerRef,
+ fallbackProviderRef: adminDefaultProviderRef,
});
if (!providerDefaults.defaultModel) return;
if (appConfig.ttsModel === providerDefaults.defaultModel) return;
@@ -398,7 +394,7 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
console.warn('Failed to normalize shared-provider default model:', error);
});
queueSyncedPreferencePatch(patch);
- }, [isDBReady, appConfig, sharedProviders, queueSyncedPreferencePatch, providerResetDefaults.providerRef]);
+ }, [isDBReady, appConfig, sharedProviders, queueSyncedPreferencePatch, adminDefaultProviderRef, sharedProvidersLoading]);
/**
* Updates multiple configuration values simultaneously
@@ -436,9 +432,9 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
try {
setIsLoading(true);
const { storagePatch, syncPatch } = applyConfigUpdate({
- providerRef,
+ providerRef: effectiveProviderRef,
providerType,
- ttsModel,
+ ttsModel: effectiveTtsModel,
savedVoices,
}, key, value);
@@ -478,10 +474,10 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
footerMargin,
leftMargin,
rightMargin,
- providerRef,
+ providerRef: effectiveProviderRef,
providerType,
- ttsModel,
- ttsInstructions,
+ ttsModel: effectiveTtsModel,
+ ttsInstructions: effectiveTtsInstructions,
savedVoices,
updateConfig,
updateConfigKey,
diff --git a/src/lib/client/api/user-state.ts b/src/lib/client/api/user-state.ts
index a3d0948..d9e8cf1 100644
--- a/src/lib/client/api/user-state.ts
+++ b/src/lib/client/api/user-state.ts
@@ -148,6 +148,32 @@ export function scheduleUserPreferencesSync(
}, debounceMs);
}
+/**
+ * Immediately send any pending debounced preference patch and await the result.
+ * Use this when the user explicitly saves: we want the server write to complete
+ * (and surface failures) instead of silently relying on the debounce timer, which
+ * can be lost if the page is reloaded inside the debounce window.
+ */
+export async function flushUserPreferencesSync(): Promise {
+ if (pendingPreferenceSync.timer) {
+ clearTimeout(pendingPreferenceSync.timer);
+ pendingPreferenceSync.timer = null;
+ }
+
+ const payload = { ...pendingPreferenceSync.patch };
+ pendingPreferenceSync.patch = {};
+ if (Object.keys(payload).length === 0) return;
+
+ if (activeSyncController) activeSyncController.abort();
+ activeSyncController = new AbortController();
+
+ try {
+ await putUserPreferences(payload, { signal: activeSyncController.signal });
+ } finally {
+ activeSyncController = null;
+ }
+}
+
export async function getDocumentProgress(
documentId: string,
options?: { signal?: AbortSignal },
diff --git a/src/lib/server/admin/settings.ts b/src/lib/server/admin/settings.ts
index 5a87363..9f65e4a 100644
--- a/src/lib/server/admin/settings.ts
+++ b/src/lib/server/admin/settings.ts
@@ -1,4 +1,4 @@
-import { and, eq } from 'drizzle-orm';
+import { asc, desc, eq } from 'drizzle-orm';
import { db } from '@/db';
import { adminProviders, adminSettings } from '@/db/schema';
import { serverLogger } from '@/lib/server/logger';
@@ -116,9 +116,19 @@ async function resolveImplicitDefaultTtsProvider(): Promise
const rows = await db
.select({ slug: adminProviders.slug })
.from(adminProviders)
- .where(and(eq(adminProviders.slug, 'default-openai'), eq(adminProviders.enabled, 1)))
- .limit(1);
- return rows[0]?.slug;
+ .where(eq(adminProviders.enabled, 1))
+ .orderBy(
+ desc(adminProviders.updatedAt),
+ desc(adminProviders.createdAt),
+ asc(adminProviders.slug),
+ );
+ const slugs = (rows as Array<{ slug: string }>).map((row) => row.slug);
+ // Prefer the conventional 'default-openai' slug when present, otherwise fall
+ // back to the first enabled shared provider so a fresh instance with any
+ // configured provider resolves to a real, usable provider rather than the
+ // built-in 'custom-openai' placeholder.
+ if (slugs.includes('default-openai')) return 'default-openai';
+ return slugs[0];
} catch (error) {
logDegraded(serverLogger, {
event: 'admin.runtime_config.default_provider_lookup.failed',
diff --git a/src/lib/server/user/preferences-normalize.ts b/src/lib/server/user/preferences-normalize.ts
new file mode 100644
index 0000000..4555330
--- /dev/null
+++ b/src/lib/server/user/preferences-normalize.ts
@@ -0,0 +1,161 @@
+import { SYNCED_PREFERENCE_KEYS, type SyncedPreferencesPatch } from '@/types/user-state';
+import { isBuiltInTtsProviderId, isTtsProviderType, type TtsProviderId } from '@/lib/shared/tts-provider-catalog';
+import { resolveProviderDefaults } from '@/lib/shared/tts-provider-policy';
+
+export interface PreferenceNormalizationContext {
+ showAllProviderModels: boolean;
+ restrictUserApiKeys: boolean;
+ sharedProviders: Array<{
+ slug: string;
+ providerType: TtsProviderId;
+ defaultModel: string | null;
+ defaultInstructions: string | null;
+ }>;
+}
+
+export function isRecord(value: unknown): value is Record {
+ return typeof value === 'object' && value !== null;
+}
+
+export function sanitizeSavedVoices(value: unknown): Record {
+ if (!isRecord(value)) return {};
+ const out: Record = {};
+ for (const [key, val] of Object.entries(value)) {
+ if (typeof key !== 'string' || key.length === 0) continue;
+ if (typeof val !== 'string') continue;
+ out[key] = val;
+ }
+ return out;
+}
+
+/**
+ * Normalize a stored/incoming preferences blob into a clean synced-preferences
+ * patch.
+ *
+ * Provider handling follows an "inherit the admin default" model: an empty
+ * `providerRef` means the user has made no explicit choice and should follow the
+ * instance default. We deliberately preserve empty rather than collapsing it to
+ * a concrete provider, so inheriting users track whatever the admin configures.
+ * Built-in provider ids under restricted mode (and the legacy `default-openai`
+ * sentinel that isn't a real shared provider) are mapped back to inherit.
+ */
+export function sanitizePreferencesPatch(
+ input: unknown,
+ context: PreferenceNormalizationContext,
+ options: { fillMissingProvider: boolean },
+): { patch: SyncedPreferencesPatch; migrated: boolean } {
+ if (!isRecord(input)) return { patch: {}, migrated: false };
+
+ const rec = input as Record;
+ const out: SyncedPreferencesPatch = {};
+ let migrated = false;
+
+ const legacyProviderRef = typeof rec.ttsProvider === 'string'
+ ? rec.ttsProvider
+ : typeof rec.provider === 'string'
+ ? rec.provider
+ : '';
+ const hasLegacyProviderKey = typeof rec.ttsProvider === 'string' || typeof rec.provider === 'string';
+ const hasProviderRefKey = typeof rec.providerRef === 'string';
+ const rawProviderRef = hasProviderRefKey ? (rec.providerRef as string) : legacyProviderRef;
+
+ const sharedSlugs = new Set(context.sharedProviders.map((entry) => entry.slug));
+ let providerRefIntent = typeof rawProviderRef === 'string' ? rawProviderRef.trim() : '';
+ // Legacy 'default-openai' sentinel: only honored when it's a real configured
+ // shared provider; otherwise it historically meant "use the default" → inherit.
+ if (providerRefIntent === 'default-openai' && !sharedSlugs.has('default-openai')) {
+ providerRefIntent = '';
+ }
+ // Built-in providers aren't selectable under restricted mode → inherit. This
+ // also migrates the old baked-in 'custom-openai' default off existing rows.
+ if (context.restrictUserApiKeys && isBuiltInTtsProviderId(providerRefIntent)) {
+ providerRefIntent = '';
+ }
+ const providerRefIsExplicit = providerRefIntent.length > 0;
+ const providerDefaults = providerRefIsExplicit
+ ? resolveProviderDefaults({
+ providerRef: providerRefIntent,
+ providerType: isTtsProviderType(rec.providerType) ? rec.providerType : 'unknown',
+ sharedProviders: context.sharedProviders,
+ })
+ : null;
+
+ if (hasLegacyProviderKey || (hasProviderRefKey && (rec.providerRef as string) !== providerRefIntent)) {
+ migrated = true;
+ }
+
+ for (const key of SYNCED_PREFERENCE_KEYS) {
+ if (!(key in rec)) continue;
+ const value = rec[key];
+
+ switch (key) {
+ case 'viewType':
+ if (value === 'single' || value === 'dual' || value === 'scroll') out[key] = value;
+ break;
+ case 'voice':
+ case 'ttsInstructions':
+ if (typeof value === 'string') out[key] = value;
+ break;
+ // providerRef / providerType / ttsModel are resolved together below.
+ case 'providerRef':
+ case 'providerType':
+ case 'ttsModel':
+ break;
+ case 'voiceSpeed':
+ case 'audioPlayerSpeed':
+ case 'segmentPreloadDepthPages':
+ case 'segmentPreloadSentenceLookahead':
+ case 'ttsSegmentMaxBlockLength':
+ case 'headerMargin':
+ case 'footerMargin':
+ case 'leftMargin':
+ case 'rightMargin':
+ if (Number.isFinite(value)) out[key] = Number(value);
+ break;
+ case 'skipBlank':
+ case 'epubTheme':
+ case 'pdfHighlightEnabled':
+ case 'pdfWordHighlightEnabled':
+ case 'epubHighlightEnabled':
+ case 'epubWordHighlightEnabled':
+ case 'htmlHighlightEnabled':
+ case 'htmlWordHighlightEnabled':
+ if (typeof value === 'boolean') out[key] = value;
+ break;
+ case 'savedVoices':
+ out[key] = sanitizeSavedVoices(value);
+ break;
+ default:
+ break;
+ }
+ }
+
+ // Persist concrete provider/type/model only for an explicit selection. An
+ // inheriting user keeps these empty so resolution happens against the live
+ // admin default at read/generation time.
+ const shouldWriteProvider = hasProviderRefKey || hasLegacyProviderKey || options.fillMissingProvider;
+ if (shouldWriteProvider) {
+ out.providerRef = providerRefIntent;
+ out.providerType = providerRefIsExplicit ? providerDefaults!.providerType : 'unknown';
+ }
+
+ if (providerRefIsExplicit) {
+ const rawModel = typeof rec.ttsModel === 'string' ? rec.ttsModel.trim() : '';
+ const lockedToDefault = !context.showAllProviderModels && !!providerDefaults!.defaultModel;
+ const model = lockedToDefault
+ ? providerDefaults!.defaultModel
+ : (rawModel || providerDefaults!.defaultModel);
+ if (model) {
+ out.ttsModel = model;
+ if (model !== rawModel) migrated = true;
+ } else if (typeof rec.ttsModel === 'string') {
+ out.ttsModel = rec.ttsModel;
+ }
+ } else if (shouldWriteProvider || 'ttsModel' in rec) {
+ // Inheriting: the model inherits too.
+ if (typeof rec.ttsModel === 'string' && rec.ttsModel !== '') migrated = true;
+ out.ttsModel = '';
+ }
+
+ return { patch: out, migrated };
+}
diff --git a/src/types/config.ts b/src/types/config.ts
index 53db98c..711b634 100644
--- a/src/types/config.ts
+++ b/src/types/config.ts
@@ -1,21 +1,5 @@
import type { DocumentListState } from '@/types/documents';
-import { isBuiltInTtsProviderId, type TtsProviderType } from '@/lib/shared/tts-provider-catalog';
-import { defaultModelForProviderType } from '@/lib/shared/tts-provider-policy';
-
-// Runtime config (admin-controlled) is layered on top of the static defaults
-// below. We resolve it lazily so this module stays importable from non-React
-// contexts (Dexie, server routes). The actual values come from
-// `window.__RUNTIME_CONFIG__` (SSR-injected) on the client, and
-// from the built-in defaults during SSR.
-
-function readRuntimeString(key: string, defaultValue: string): string {
- if (typeof window === 'undefined') return defaultValue;
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- const injected = (window as any).__RUNTIME_CONFIG__;
- if (!injected || typeof injected !== 'object') return defaultValue;
- const value = injected[key];
- return typeof value === 'string' && value ? value : defaultValue;
-}
+import type { TtsProviderType } from '@/lib/shared/tts-provider-catalog';
export type ViewType = 'single' | 'dual' | 'scroll';
@@ -78,20 +62,18 @@ export interface AppConfigValues {
}
/**
- * Build defaults lazily so we can read SSR-injected admin overrides
- * (`window.__RUNTIME_CONFIG__`). Modules that need the defaults
- * statically should call `getAppConfigDefaults()` at use time. The exported
- * `APP_CONFIG_DEFAULTS` is a Proxy that re-resolves on each access so
- * mutations to the runtime config (admin edits) are picked up by anything
- * that reads through it.
+ * Build the static app-config defaults. These no longer read any SSR/admin
+ * runtime config: the user's TTS provider defaults to empty ("inherit the
+ * admin default"), which is resolved to a concrete provider where it is used
+ * (ConfigContext on the client, credential resolution on the server).
*/
export function getAppConfigDefaults(): AppConfigValues {
- const runtimeProviderRef = readRuntimeString('defaultTtsProvider', 'custom-openai');
- const defaultProviderRef = runtimeProviderRef.trim();
- const defaultProviderType = isBuiltInTtsProviderId(defaultProviderRef) ? defaultProviderRef : 'unknown';
- const defaultModel = isBuiltInTtsProviderId(defaultProviderType)
- ? defaultModelForProviderType(defaultProviderType)
- : 'kokoro';
+ // The user's TTS provider is intentionally left empty by default. An empty
+ // providerRef means "inherit the instance/admin default" and is resolved to a
+ // concrete provider at read time (see ConfigContext) and at generation time
+ // (server-side credential resolution). We no longer bake a placeholder
+ // provider id (the old 'custom-openai') into every user's config, since that
+ // value isn't actually selectable in shared-provider mode.
return {
apiKey: '',
baseUrl: '',
@@ -105,9 +87,9 @@ export function getAppConfigDefaults(): AppConfigValues {
footerMargin: 0,
leftMargin: 0,
rightMargin: 0,
- providerRef: defaultProviderRef,
- providerType: defaultProviderType,
- ttsModel: defaultModel,
+ providerRef: '',
+ providerType: 'unknown',
+ ttsModel: '',
ttsInstructions: '',
savedVoices: {},
segmentPreloadDepthPages: 1,
@@ -134,17 +116,13 @@ export function getAppConfigDefaults(): AppConfigValues {
}
/**
- * Static defaults snapshot resolved at first access. For callers that need
- * fresh values after admin edits, prefer `getAppConfigDefaults()` directly.
- * Most consumers just need a stable defaults object for spreads, so this is
- * resolved once per process — admin overrides take effect on next page load
- * (which is the SSR-injected behavior we want anyway).
+ * Static defaults snapshot, resolved once at first access. The values are
+ * constant (no SSR/admin overrides are read here anymore), so the lazy Proxy
+ * just avoids building the object until something first reads it.
*/
let cachedDefaults: AppConfigValues | null = null;
export const APP_CONFIG_DEFAULTS: AppConfigValues = (() => {
- // Return a getter-backed object that resolves on first access. On the
- // server, this resolves to built-in defaults; on the client, to the
- // SSR-injected admin values.
+ // Getter-backed object that builds the defaults lazily on first access.
const handler: ProxyHandler = {
get(_target, prop) {
if (!cachedDefaults) cachedDefaults = getAppConfigDefaults();
diff --git a/tests/unit/tts-provider-catalog.vitest.spec.ts b/tests/unit/tts-provider-catalog.vitest.spec.ts
index 8996617..acd5b94 100644
--- a/tests/unit/tts-provider-catalog.vitest.spec.ts
+++ b/tests/unit/tts-provider-catalog.vitest.spec.ts
@@ -476,9 +476,13 @@ describe('config helpers', () => {
ttsModel: 'kokoro',
});
+ // The user's provider/model now default to empty ("inherit the admin
+ // default"), so empty values are treated as defaults and filtered out.
expect(buildSyncedPreferencePatch({
voiceSpeed: 1,
- ttsModel: 'kokoro',
+ providerRef: '',
+ providerType: 'unknown',
+ ttsModel: '',
}, { nonDefaultOnly: true })).toEqual({});
});
});
diff --git a/tests/unit/user-preferences-normalize.vitest.spec.ts b/tests/unit/user-preferences-normalize.vitest.spec.ts
new file mode 100644
index 0000000..16975ea
--- /dev/null
+++ b/tests/unit/user-preferences-normalize.vitest.spec.ts
@@ -0,0 +1,99 @@
+import { describe, expect, test } from 'vitest';
+
+import {
+ sanitizePreferencesPatch,
+ type PreferenceNormalizationContext,
+} from '../../src/lib/server/user/preferences-normalize';
+
+function makeContext(overrides: Partial = {}): PreferenceNormalizationContext {
+ return {
+ showAllProviderModels: true,
+ restrictUserApiKeys: true,
+ sharedProviders: [
+ { slug: 'shared-a', providerType: 'openai', defaultModel: 'gpt-4o-mini-tts', defaultInstructions: 'hi' },
+ { slug: 'shared-b', providerType: 'custom-openai', defaultModel: 'kokoro', defaultInstructions: null },
+ ],
+ ...overrides,
+ };
+}
+
+describe('sanitizePreferencesPatch — inherit-by-default provider model', () => {
+ test('preserves an empty providerRef as "inherit" rather than collapsing to a concrete provider', () => {
+ const { patch, migrated } = sanitizePreferencesPatch(
+ { providerRef: '', voiceSpeed: 1.5 },
+ makeContext(),
+ { fillMissingProvider: true },
+ );
+ expect(patch.providerRef).toBe('');
+ expect(patch.providerType).toBe('unknown');
+ expect(patch.ttsModel).toBe('');
+ expect(patch.voiceSpeed).toBe(1.5);
+ expect(migrated).toBe(false);
+ });
+
+ test('migrates a stale built-in (custom-openai) selection to inherit under restricted mode', () => {
+ const { patch, migrated } = sanitizePreferencesPatch(
+ { providerRef: 'custom-openai', providerType: 'custom-openai', ttsModel: 'kokoro' },
+ makeContext({ restrictUserApiKeys: true }),
+ { fillMissingProvider: true },
+ );
+ expect(patch.providerRef).toBe('');
+ expect(patch.providerType).toBe('unknown');
+ expect(patch.ttsModel).toBe('');
+ expect(migrated).toBe(true);
+ });
+
+ test('keeps an explicit built-in selection when API keys are NOT restricted', () => {
+ const { patch } = sanitizePreferencesPatch(
+ { providerRef: 'openai', providerType: 'openai', ttsModel: 'tts-1' },
+ makeContext({ restrictUserApiKeys: false }),
+ { fillMissingProvider: false },
+ );
+ expect(patch.providerRef).toBe('openai');
+ expect(patch.providerType).toBe('openai');
+ expect(patch.ttsModel).toBe('tts-1');
+ });
+
+ test('preserves an explicit shared-provider selection and resolves its type', () => {
+ const { patch } = sanitizePreferencesPatch(
+ { providerRef: 'shared-b', ttsModel: 'my-model' },
+ makeContext(),
+ { fillMissingProvider: false },
+ );
+ expect(patch.providerRef).toBe('shared-b');
+ expect(patch.providerType).toBe('custom-openai');
+ expect(patch.ttsModel).toBe('my-model');
+ });
+
+ test('locks the model to the shared provider default when showAllProviderModels is false', () => {
+ const { patch } = sanitizePreferencesPatch(
+ { providerRef: 'shared-a', ttsModel: 'something-else' },
+ makeContext({ showAllProviderModels: false }),
+ { fillMissingProvider: false },
+ );
+ expect(patch.providerRef).toBe('shared-a');
+ expect(patch.ttsModel).toBe('gpt-4o-mini-tts');
+ });
+
+ test('treats the legacy "default-openai" sentinel as inherit when it is not a real shared provider', () => {
+ const { patch, migrated } = sanitizePreferencesPatch(
+ { providerRef: 'default-openai' },
+ makeContext(),
+ { fillMissingProvider: true },
+ );
+ expect(patch.providerRef).toBe('');
+ expect(migrated).toBe(true);
+ });
+
+ test('does not force provider fields on a partial PUT patch that omits them', () => {
+ const { patch } = sanitizePreferencesPatch(
+ { voice: 'af_sarah' },
+ makeContext(),
+ { fillMissingProvider: false },
+ );
+ expect(patch.voice).toBe('af_sarah');
+ expect('providerRef' in patch).toBe(false);
+ expect('providerType' in patch).toBe(false);
+ expect('ttsModel' in patch).toBe(false);
+ });
+});
diff --git a/tests/unit/user-preferences-sync.vitest.spec.ts b/tests/unit/user-preferences-sync.vitest.spec.ts
new file mode 100644
index 0000000..aae3c76
--- /dev/null
+++ b/tests/unit/user-preferences-sync.vitest.spec.ts
@@ -0,0 +1,81 @@
+import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
+
+import {
+ cancelPendingPreferenceSync,
+ flushUserPreferencesSync,
+ scheduleUserPreferencesSync,
+} from '../../src/lib/client/api/user-state';
+
+type FetchMock = ReturnType & { calls: Array<{ url: string; init?: RequestInit }> };
+
+function installFetchMock(responder: () => Response): FetchMock {
+ const mock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
+ mock.calls.push({ url: String(input), init });
+ return responder();
+ }) as unknown as FetchMock;
+ mock.calls = [];
+ global.fetch = mock as unknown as typeof fetch;
+ return mock;
+}
+
+function okResponse(): Response {
+ return new Response(
+ JSON.stringify({ preferences: {}, clientUpdatedAtMs: Date.now(), applied: true }),
+ { status: 200, headers: { 'Content-Type': 'application/json' } },
+ );
+}
+
+describe('flushUserPreferencesSync', () => {
+ const originalFetch = global.fetch;
+
+ beforeEach(() => {
+ vi.useFakeTimers();
+ cancelPendingPreferenceSync();
+ });
+
+ afterEach(() => {
+ cancelPendingPreferenceSync();
+ vi.useRealTimers();
+ global.fetch = originalFetch;
+ });
+
+ test('sends a pending debounced patch immediately instead of waiting for the timer', async () => {
+ const fetchMock = installFetchMock(okResponse);
+
+ scheduleUserPreferencesSync({ providerRef: 'shared-b', providerType: 'openai' }, 'user-1');
+
+ // Nothing should have fired yet — it's still debounced.
+ expect(fetchMock.calls).toHaveLength(0);
+
+ await flushUserPreferencesSync();
+
+ expect(fetchMock.calls).toHaveLength(1);
+ const { url, init } = fetchMock.calls[0];
+ expect(url).toContain('/api/user/state/preferences');
+ expect(init?.method).toBe('PUT');
+ const body = JSON.parse(String(init?.body));
+ expect(body.patch.providerRef).toBe('shared-b');
+ expect(body.patch.providerType).toBe('openai');
+
+ // The debounce timer must not also fire a duplicate request afterwards.
+ await vi.runAllTimersAsync();
+ expect(fetchMock.calls).toHaveLength(1);
+ });
+
+ test('is a no-op when there is no pending patch', async () => {
+ const fetchMock = installFetchMock(okResponse);
+ await flushUserPreferencesSync();
+ expect(fetchMock.calls).toHaveLength(0);
+ });
+
+ test('rejects when the server write fails so callers can surface the error', async () => {
+ installFetchMock(() => new Response(JSON.stringify({ error: 'boom' }), {
+ status: 500,
+ headers: { 'Content-Type': 'application/json' },
+ }));
+
+ scheduleUserPreferencesSync({ providerRef: 'shared-b' }, 'user-1');
+
+ await expect(flushUserPreferencesSync()).rejects.toThrow();
+ });
+});