ui: read persisted settings before the API key probe (#27365)

The route loads run ahead of the root layout script, so validateApiKey
read the settings store while it still held factory defaults and probed
/props without the stored key. initStores() now hands the same startup
promise to every caller and the chat loads await it before probing.

The one-time admin baseline no longer overwrites a key the user has
already set: on a first visit the config carries factory values only, so
a diverging key comes from the user and wins.
This commit is contained in:
Pascal 2026-08-19 14:02:08 +02:00 committed by GitHub
parent 95c409c136
commit 77acca437f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 52 additions and 18 deletions

View file

@ -1,10 +1,15 @@
/**
* Explicit store initialization, called once from the root layout.
* Explicit store initialization, run once and shared by every caller.
*
* Order matters: migrations run first because they rename and rewrite
* localStorage keys, so every store that reads localStorage initializes
* only after they complete. Constructors and module-level side effects
* stay empty so import order can no longer change startup behavior.
*
* The returned promise resolves once the persisted state is in memory, which
* route loads await before reading settings: they run ahead of the root layout
* script. The conversation list loads in the background, awaited by the chat
* page that renders it.
*/
// direct imports, not via the barrel, to avoid circular deps
@ -16,19 +21,20 @@ import { versionStore } from './version.svelte';
import { browser } from '$app/environment';
import { MigrationService } from '$lib/services/migration.service';
let started = false;
let startup: Promise<void> | null = null;
export async function initStores(): Promise<void> {
if (!browser || started) return;
export function initStores(): Promise<void> {
if (!browser) return Promise.resolve();
started = true;
startup ??= (async () => {
await MigrationService.runAllMigrations();
await MigrationService.runAllMigrations();
settingsStore.initialize();
permissionsStore.initialize();
toolsStore.initialize();
void versionStore.initialize();
void conversationsStore.init();
})();
settingsStore.initialize();
permissionsStore.initialize();
toolsStore.initialize();
void versionStore.initialize();
await conversationsStore.init();
return startup;
}

View file

@ -358,17 +358,24 @@ class SettingsStore {
// UI settings are the admin's defaults for new users: applied once on
// the first visit, never on later loads, so the user's config can
// diverge. "Reset to Default" is the explicit way back to the baseline.
// A first visit config carries factory values only, so a key that
// already diverges here was set by the user before the baseline could
// be reached, through the API key splash, and stays theirs.
if (uiSettings && this.isFirstVisit) {
this.isFirstVisit = false;
for (const [key, value] of Object.entries(uiSettings)) {
if (!this.userOverrides.has(key) && value !== undefined) {
setConfigValue(this.config, key, value);
if (value === undefined || this.userOverrides.has(key)) continue;
// theme lives in mode-watcher, not just in config -> propagate
if (key === SETTINGS_KEYS.THEME) {
setMode(value as ColorMode);
}
if (getConfigValue(this.config, key) !== getConfigValue(SETTING_CONFIG_DEFAULT, key)) {
continue;
}
setConfigValue(this.config, key, value);
// theme lives in mode-watcher, not just in config -> propagate
if (key === SETTINGS_KEYS.THEME) {
setMode(value as ColorMode);
}
}
}

View file

@ -1,6 +1,10 @@
import type { PageLoad } from './$types';
import { initStores } from '$lib/stores/init';
import { validateApiKey } from '$lib/utils';
export const load: PageLoad = async ({ fetch }) => {
// loads run before the root layout script, so the stored API key reaches
// the probe only once the settings store has read localStorage
await initStores();
await validateApiKey(fetch);
};

View file

@ -1,6 +1,10 @@
import type { PageLoad } from './$types';
import { initStores } from '$lib/stores/init';
import { validateApiKey } from '$lib/utils';
export const load: PageLoad = async ({ fetch }) => {
// loads run before the root layout script, so the stored API key reaches
// the probe only once the settings store has read localStorage
await initStores();
await validateApiKey(fetch);
};

View file

@ -47,6 +47,19 @@ describe('server ui_settings application semantics', () => {
expect(stored.apiKey).toBe('sk-user-key');
});
it('keeps a value the user sets before the baseline is reachable', () => {
settingsStore.initialize();
// the splash is the only way in when the server runs with --api-key,
// so the first user write lands before the first successful /props
settingsStore.updateConfig('apiKey', 'sk-user-key');
mockProps({ apiKey: 'admin-placeholder', theme: 'dark' });
settingsStore.syncWithServerDefaults();
expect(settingsStore.config.apiKey).toBe('sk-user-key');
expect(settingsStore.config.theme).toBe('dark');
});
it('Reset to Default reapplies the full baseline, api key included', () => {
settingsStore.initialize();
settingsStore.updateConfig('theme', 'light');