feat: enhance global settings update with data loss prevention

- Added safeguards to prevent overwriting non-empty arrays with empty arrays during global settings updates, specifically for the 'projects' field.
- Implemented logging for updates to assist in diagnosing accidental wipes of critical settings.
- Updated tests to verify that projects are preserved during logout transitions and that theme changes are ignored if a project wipe is attempted.
- Enhanced the settings synchronization logic to ensure safe handling during authentication state changes.
This commit is contained in:
webdevcody
2026-01-07 21:38:46 -05:00
parent 8c68c24716
commit d8cdb0bf7a
7 changed files with 190 additions and 13 deletions

View File

@@ -11,7 +11,7 @@
import type { Request, Response } from 'express';
import type { SettingsService } from '../../../services/settings-service.js';
import type { GlobalSettings } from '../../../types/settings.js';
import { getErrorMessage, logError } from '../common.js';
import { getErrorMessage, logError, logger } from '../common.js';
/**
* Create handler factory for PUT /api/settings/global
@@ -32,6 +32,18 @@ export function createUpdateGlobalHandler(settingsService: SettingsService) {
return;
}
// Minimal debug logging to help diagnose accidental wipes.
if ('projects' in updates || 'theme' in updates || 'localStorageMigrated' in updates) {
const projectsLen = Array.isArray((updates as any).projects)
? (updates as any).projects.length
: undefined;
logger.info(
`Update global settings request: projects=${projectsLen ?? 'n/a'}, theme=${
(updates as any).theme ?? 'n/a'
}, localStorageMigrated=${(updates as any).localStorageMigrated ?? 'n/a'}`
);
}
const settings = await settingsService.updateGlobalSettings(updates);
res.json({

View File

@@ -266,25 +266,80 @@ export class SettingsService {
const settingsPath = getGlobalSettingsPath(this.dataDir);
const current = await this.getGlobalSettings();
// Guard against destructive "empty array/object" overwrites.
// During auth transitions, the UI can briefly have default/empty state and accidentally
// sync it, wiping persisted settings (especially `projects`).
const sanitizedUpdates: Partial<GlobalSettings> = { ...updates };
let attemptedProjectWipe = false;
const ignoreEmptyArrayOverwrite = <K extends keyof GlobalSettings>(key: K): void => {
const nextVal = sanitizedUpdates[key] as unknown;
const curVal = current[key] as unknown;
if (
Array.isArray(nextVal) &&
nextVal.length === 0 &&
Array.isArray(curVal) &&
curVal.length > 0
) {
delete sanitizedUpdates[key];
}
};
const currentProjectsLen = Array.isArray(current.projects) ? current.projects.length : 0;
if (
Array.isArray(sanitizedUpdates.projects) &&
sanitizedUpdates.projects.length === 0 &&
currentProjectsLen > 0
) {
attemptedProjectWipe = true;
delete sanitizedUpdates.projects;
}
ignoreEmptyArrayOverwrite('trashedProjects');
ignoreEmptyArrayOverwrite('projectHistory');
ignoreEmptyArrayOverwrite('recentFolders');
ignoreEmptyArrayOverwrite('aiProfiles');
ignoreEmptyArrayOverwrite('mcpServers');
ignoreEmptyArrayOverwrite('enabledCursorModels');
ignoreEmptyArrayOverwrite('enabledCodexModels');
// Empty object overwrite guard
if (
sanitizedUpdates.lastSelectedSessionByProject &&
typeof sanitizedUpdates.lastSelectedSessionByProject === 'object' &&
!Array.isArray(sanitizedUpdates.lastSelectedSessionByProject) &&
Object.keys(sanitizedUpdates.lastSelectedSessionByProject).length === 0 &&
current.lastSelectedSessionByProject &&
Object.keys(current.lastSelectedSessionByProject).length > 0
) {
delete sanitizedUpdates.lastSelectedSessionByProject;
}
// If a request attempted to wipe projects, also ignore theme changes in that same request.
if (attemptedProjectWipe) {
delete sanitizedUpdates.theme;
}
const updated: GlobalSettings = {
...current,
...updates,
...sanitizedUpdates,
version: SETTINGS_VERSION,
};
// Deep merge keyboard shortcuts if provided
if (updates.keyboardShortcuts) {
if (sanitizedUpdates.keyboardShortcuts) {
updated.keyboardShortcuts = {
...current.keyboardShortcuts,
...updates.keyboardShortcuts,
...sanitizedUpdates.keyboardShortcuts,
};
}
// Deep merge phaseModels if provided
if (updates.phaseModels) {
if (sanitizedUpdates.phaseModels) {
updated.phaseModels = {
...current.phaseModels,
...updates.phaseModels,
...sanitizedUpdates.phaseModels,
};
}

View File

@@ -144,6 +144,33 @@ describe('settings-service.ts', () => {
expect(updated.keyboardShortcuts.agent).toBe(DEFAULT_GLOBAL_SETTINGS.keyboardShortcuts.agent);
});
it('should not overwrite non-empty projects with an empty array (data loss guard)', async () => {
const initial: GlobalSettings = {
...DEFAULT_GLOBAL_SETTINGS,
theme: 'solarized' as GlobalSettings['theme'],
projects: [
{
id: 'proj1',
name: 'Project 1',
path: '/tmp/project-1',
lastOpened: new Date().toISOString(),
},
] as any,
};
const settingsPath = path.join(testDataDir, 'settings.json');
await fs.writeFile(settingsPath, JSON.stringify(initial, null, 2));
const updated = await settingsService.updateGlobalSettings({
projects: [],
theme: 'light',
} as any);
expect(updated.projects.length).toBe(1);
expect((updated.projects as any)[0]?.id).toBe('proj1');
// Theme should be preserved in the same request if it attempted to wipe projects
expect(updated.theme).toBe('solarized');
});
it('should create data directory if it does not exist', async () => {
const newDataDir = path.join(os.tmpdir(), `new-data-dir-${Date.now()}`);
const newService = new SettingsService(newDataDir);

View File

@@ -94,6 +94,17 @@ export function waitForMigrationComplete(): Promise<void> {
return migrationCompletePromise;
}
/**
* Reset migration state when auth is lost (logout/session expired).
* This ensures that on re-login, the sync hook properly waits for
* fresh settings hydration before starting to sync.
*/
export function resetMigrationState(): void {
migrationCompleted = false;
migrationCompletePromise = null;
migrationCompleteResolve = null;
}
/**
* Parse localStorage data into settings object
*/

View File

@@ -18,7 +18,7 @@ import { setItem } from '@/lib/storage';
import { useAppStore, type ThemeMode, THEME_STORAGE_KEY } from '@/store/app-store';
import { useSetupStore } from '@/store/setup-store';
import { useAuthStore } from '@/store/auth-store';
import { waitForMigrationComplete } from './use-settings-migration';
import { waitForMigrationComplete, resetMigrationState } from './use-settings-migration';
import type { GlobalSettings } from '@automaker/types';
const logger = createLogger('SettingsSync');
@@ -98,9 +98,35 @@ export function useSettingsSync(): SettingsSyncState {
const lastSyncedRef = useRef<string>('');
const isInitializedRef = useRef(false);
// If auth is lost (logout / session expired), immediately stop syncing and
// reset initialization so we can safely re-init after the next login.
useEffect(() => {
if (!authChecked) return;
if (!isAuthenticated) {
if (syncTimeoutRef.current) {
clearTimeout(syncTimeoutRef.current);
syncTimeoutRef.current = null;
}
lastSyncedRef.current = '';
isInitializedRef.current = false;
// Reset migration state so next login properly waits for fresh hydration
resetMigrationState();
setState({ loaded: false, error: null, syncing: false });
}
}, [authChecked, isAuthenticated]);
// Debounced sync function
const syncToServer = useCallback(async () => {
try {
// Never sync when not authenticated (prevents overwriting server settings during logout/login transitions)
const auth = useAuthStore.getState();
if (!auth.authChecked || !auth.isAuthenticated) {
return;
}
setState((s) => ({ ...s, syncing: true }));
const api = getHttpApiClient();
const appState = useAppStore.getState();
@@ -215,7 +241,7 @@ export function useSettingsSync(): SettingsSyncState {
// Subscribe to store changes and sync to server
useEffect(() => {
if (!state.loaded) return;
if (!state.loaded || !authChecked || !isAuthenticated) return;
// Subscribe to app store changes
const unsubscribeApp = useAppStore.subscribe((newState, prevState) => {
@@ -272,11 +298,11 @@ export function useSettingsSync(): SettingsSyncState {
clearTimeout(syncTimeoutRef.current);
}
};
}, [state.loaded, scheduleSyncToServer, syncNow]);
}, [state.loaded, authChecked, isAuthenticated, scheduleSyncToServer, syncNow]);
// Best-effort flush on tab close / backgrounding
useEffect(() => {
if (!state.loaded) return;
if (!state.loaded || !authChecked || !isAuthenticated) return;
const handleBeforeUnload = () => {
// Fire-and-forget; may not complete in all browsers, but helps in Electron/webview
@@ -296,7 +322,7 @@ export function useSettingsSync(): SettingsSyncState {
window.removeEventListener('beforeunload', handleBeforeUnload);
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
}, [state.loaded, syncNow]);
}, [state.loaded, authChecked, isAuthenticated, syncNow]);
return state;
}

View File

@@ -81,9 +81,23 @@ export const THEME_STORAGE_KEY = 'automaker:theme';
*/
export function getStoredTheme(): ThemeMode | null {
const stored = getItem(THEME_STORAGE_KEY);
if (stored) {
return stored as ThemeMode;
if (stored) return stored as ThemeMode;
// Backwards compatibility: older versions stored theme inside the Zustand persist blob.
// We intentionally keep reading it as a fallback so users don't get a "default theme flash"
// on login/logged-out pages if THEME_STORAGE_KEY hasn't been written yet.
try {
const legacy = getItem('automaker-storage');
if (!legacy) return null;
const parsed = JSON.parse(legacy) as { state?: { theme?: unknown } } | { theme?: unknown };
const theme = (parsed as any)?.state?.theme ?? (parsed as any)?.theme;
if (typeof theme === 'string' && theme.length > 0) {
return theme as ThemeMode;
}
} catch {
// Ignore legacy parse errors
}
return null;
}

View File

@@ -104,4 +104,36 @@ test.describe('Settings startup sync race', () => {
expect(settingsAfterHydration.projects?.length).toBeGreaterThan(0);
expect(settingsAfterHydration.projects?.[0]?.path).toBe(FIXTURE_PROJECT_PATH);
});
test('does not wipe projects during logout transition', async ({ page }) => {
// Ensure authenticated and app is loaded at least to welcome/board.
await authenticateForTests(page);
await page.goto('/');
await page
.locator('[data-testid="welcome-view"], [data-testid="board-view"]')
.first()
.waitFor({ state: 'visible', timeout: 30000 });
// Confirm settings.json currently has projects (precondition).
const beforeLogout = JSON.parse(fs.readFileSync(SETTINGS_PATH, 'utf-8')) as {
projects?: Array<unknown>;
};
expect(beforeLogout.projects?.length).toBeGreaterThan(0);
// Navigate to settings and click logout.
await page.goto('/settings');
await page.locator('[data-testid="logout-button"]').click();
// Ensure we landed on logged-out or login (either is acceptable).
await page
.locator('text=Youve been logged out, text=Authentication Required')
.first()
.waitFor({ state: 'visible', timeout: 30000 });
// The server settings file should still have projects after logout.
const afterLogout = JSON.parse(fs.readFileSync(SETTINGS_PATH, 'utf-8')) as {
projects?: Array<unknown>;
};
expect(afterLogout.projects?.length).toBeGreaterThan(0);
});
});