diff --git a/apps/web/src/assistant/generated/web-search-provider-catalog.gen.ts b/apps/web/src/assistant/generated/web-search-provider-catalog.gen.ts index e020678b511..5707942099d 100644 --- a/apps/web/src/assistant/generated/web-search-provider-catalog.gen.ts +++ b/apps/web/src/assistant/generated/web-search-provider-catalog.gen.ts @@ -32,9 +32,9 @@ export const WEB_SEARCH_PROVIDER_KEY_PLACEHOLDERS: Readonly< export const WEB_SEARCH_PROVIDER_KEY_STORAGE: Readonly< Record > = { - perplexity: "vellum_perplexity_key", - brave: "vellum_brave_key", - tavily: "vellum_tavily_key", + perplexity: "vellum:ai:perplexityKey", + brave: "vellum:ai:braveKey", + tavily: "vellum:ai:tavilyKey", }; /** Provider ids that require a user-supplied API key. */ diff --git a/apps/web/src/domains/chat/chat-layout.tsx b/apps/web/src/domains/chat/chat-layout.tsx index 8f73e989fb5..1af7ed05b3d 100644 --- a/apps/web/src/domains/chat/chat-layout.tsx +++ b/apps/web/src/domains/chat/chat-layout.tsx @@ -52,8 +52,8 @@ import { ChatLayoutHeader } from "./chat-layout-header"; * LocalStorage key used to persist the collapsed state of the sidebar rail * across reloads. */ -export const SIDEBAR_COLLAPSED_STORAGE_KEY = "assistantSidebarCollapsed"; -export const SIDEBAR_WIDTH_STORAGE_KEY = "assistantSidebarWidth"; +export const SIDEBAR_COLLAPSED_STORAGE_KEY = "vellum:sidebar:collapsed"; +export const SIDEBAR_WIDTH_STORAGE_KEY = "vellum:sidebar:width"; const DEFAULT_SIDEBAR_WIDTH = 230; const FOCUSABLE_SELECTOR = [ diff --git a/apps/web/src/domains/chat/components/chat-route-content.tsx b/apps/web/src/domains/chat/components/chat-route-content.tsx index 1cade3919b8..64a92b12ae4 100644 --- a/apps/web/src/domains/chat/components/chat-route-content.tsx +++ b/apps/web/src/domains/chat/components/chat-route-content.tsx @@ -1012,12 +1012,12 @@ export function ChatRouteContent({ const [warningDismissed, setWarningDismissed] = useState(() => { if (!assistantId) return false; - return localStorage.getItem(`disk-pressure-warning-dismissed-${assistantId}`) === "true"; + return localStorage.getItem(`vellum:diskPressureDismissed:${assistantId}`) === "true"; }); const dismissWarning = useCallback(() => { if (!assistantId) return; - localStorage.setItem(`disk-pressure-warning-dismissed-${assistantId}`, "true"); + localStorage.setItem(`vellum:diskPressureDismissed:${assistantId}`, "true"); setWarningDismissed(true); }, [assistantId]); @@ -1026,7 +1026,7 @@ export function ChatRouteContent({ const st = diskPressure.status?.state; if (st && st !== "warning" && warningDismissed) { if (assistantId) { - localStorage.removeItem(`disk-pressure-warning-dismissed-${assistantId}`); + localStorage.removeItem(`vellum:diskPressureDismissed:${assistantId}`); } setWarningDismissed(false); } diff --git a/apps/web/src/domains/chat/components/mic-permission-primer.tsx b/apps/web/src/domains/chat/components/mic-permission-primer.tsx index 261203f5ba0..c63fefad5c8 100644 --- a/apps/web/src/domains/chat/components/mic-permission-primer.tsx +++ b/apps/web/src/domains/chat/components/mic-permission-primer.tsx @@ -5,7 +5,7 @@ import { Button } from "@vellum/design-library"; import { Modal } from "@vellum/design-library"; import { isBatchSttSupported } from "@/domains/chat/components/voice-input-button"; -const MIC_PRIMER_STORAGE_KEY = "voice:permissionPrimerSeen"; +const MIC_PRIMER_STORAGE_KEY = "vellum:voice:permissionPrimerSeen"; /** * Returns `true` when the microphone permission primer should be shown — diff --git a/apps/web/src/domains/intelligence/components/skills/skills-tab.tsx b/apps/web/src/domains/intelligence/components/skills/skills-tab.tsx index f70dbb733c0..24f3d8a9cae 100644 --- a/apps/web/src/domains/intelligence/components/skills/skills-tab.tsx +++ b/apps/web/src/domains/intelligence/components/skills/skills-tab.tsx @@ -81,7 +81,7 @@ const ORIGIN_FILTERS: FilterOption[] = [ const FILTERS: FilterOption[] = [...STATUS_FILTERS, ...ORIGIN_FILTERS]; const SEARCH_DEBOUNCE_MS = 300; -const TIP_STORAGE_KEY = "vellum:skillsTabTipDismissed"; +const TIP_STORAGE_KEY = "vellum:skills:tipDismissed"; export function SkillsTab({ assistantId, initialSkillId }: SkillsTabProps) { const queryClient = useQueryClient(); diff --git a/apps/web/src/domains/onboarding/onboarding-store.ts b/apps/web/src/domains/onboarding/onboarding-store.ts index aa82e3dc551..d38d7173254 100644 --- a/apps/web/src/domains/onboarding/onboarding-store.ts +++ b/apps/web/src/domains/onboarding/onboarding-store.ts @@ -15,9 +15,9 @@ * |-------------------|-------------------------------|------------------------| * | `shareAnalytics` | `device:share_analytics` | privacy page (direct) | * | `shareDiagnostics`| `device:share_diagnostics` | privacy page + Sentry | - * | `tosAccepted` | `onboarding.tosAccepted` | onboarding pages | - * | `aiDataConsent` | `onboarding.aiDataConsent` | onboarding pages | - * | `completed` | `onboarding.completed` | onboarding + chat gate | + * | `tosAccepted` | `vellum:onboarding:tosAccepted` | onboarding pages | + * | `aiDataConsent` | `vellum:onboarding:aiDataConsent`| onboarding pages | + * | `completed` | `vellum:onboarding:completed` | onboarding + chat gate | * * We deliberately do **not** use Zustand's `persist` middleware here. * `persist` writes the full state envelope on every update, which would diff --git a/apps/web/src/domains/onboarding/prefs.ts b/apps/web/src/domains/onboarding/prefs.ts index 622df9ef559..04dfffe4607 100644 --- a/apps/web/src/domains/onboarding/prefs.ts +++ b/apps/web/src/domains/onboarding/prefs.ts @@ -21,22 +21,24 @@ import { setLocalSetting, } from "@/utils/local-settings"; import { getDeviceSetting } from "@/utils/device-settings"; +import { + KEY_TOS_ACCEPTED, + KEY_AI_DATA_CONSENT, + KEY_COMPLETED, +} from "@/utils/onboarding-cleanup"; import { useOnboardingStore } from "@/domains/onboarding/onboarding-store"; // --------------------------------------------------------------------------- // Storage keys (non-boolean — boolean keys live in onboarding-store.ts) // --------------------------------------------------------------------------- -const KEY_TOS_ACCEPTED = "onboarding.tosAccepted"; -const KEY_AI_DATA_CONSENT = "onboarding.aiDataConsent"; -const KEY_COMPLETED = "onboarding.completed"; /** * Onboarding-only, nonprod-only: pinned release version for the hatch. * Written by the privacy screen's dev-tools version picker, read by the * hatching screen and forwarded to `hatchAssistant({ version })`. Empty * string / absent means "latest" (the normal managed default). */ -const KEY_SELECTED_VERSION = "onboarding.selectedVersion"; +const KEY_SELECTED_VERSION = "vellum:onboarding:selectedVersion"; // --------------------------------------------------------------------------- // Public hooks — thin wrappers around the Zustand store diff --git a/apps/web/src/domains/settings/ai/ai-page.tsx b/apps/web/src/domains/settings/ai/ai-page.tsx index 2f6a0698810..c6c581cd86f 100644 --- a/apps/web/src/domains/settings/ai/ai-page.tsx +++ b/apps/web/src/domains/settings/ai/ai-page.tsx @@ -325,22 +325,22 @@ export function reconcileFromDaemonConfig(config: DaemonConfig): DaemonConfigRec // Local-storage keys // --------------------------------------------------------------------------- -const LS_IMAGE_GEN_MODE = "vellum_image_gen_mode"; -const LS_IMAGE_GEN_MODEL = "vellum_image_gen_model"; -const LS_WEB_SEARCH_MODE = "vellum_web_search_mode"; -const LS_WEB_SEARCH_PROVIDER = "vellum_web_search_provider"; -const LS_EMAIL_MODE = "vellum_email_mode"; -const LS_EMAIL_BYO_PROVIDER = "vellum_email_byo_provider"; +const LS_IMAGE_GEN_MODE = "vellum:ai:imageGenMode"; +const LS_IMAGE_GEN_MODEL = "vellum:ai:imageGenModel"; +const LS_WEB_SEARCH_MODE = "vellum:ai:webSearchMode"; +const LS_WEB_SEARCH_PROVIDER = "vellum:ai:webSearchProvider"; +const LS_EMAIL_MODE = "vellum:ai:emailMode"; +const LS_EMAIL_BYO_PROVIDER = "vellum:ai:emailByoProvider"; // TTS / STT localStorage keys (shared with the Voice settings tab) -const LS_TTS_PROVIDER = "voice:ttsProvider"; -const LS_TTS_API_KEY_PREFIX = "voice:ttsApiKey:"; -const LS_TTS_VOICE_ID_PREFIX = "voice:ttsVoiceId:"; -const LS_STT_PROVIDER = "voice:sttProvider"; -const LS_STT_API_KEY_PREFIX = "voice:sttApiKey:"; +const LS_TTS_PROVIDER = "vellum:voice:ttsProvider"; +const LS_TTS_API_KEY_PREFIX = "vellum:voice:ttsApiKey:"; +const LS_TTS_VOICE_ID_PREFIX = "vellum:voice:ttsVoiceId:"; +const LS_STT_PROVIDER = "vellum:voice:sttProvider"; +const LS_STT_API_KEY_PREFIX = "vellum:voice:sttApiKey:"; // localStorage key for the image generation credential (matching service-keys page) -const LS_IMAGE_GEN_CREDENTIAL = "vellum_gemini_key"; +const LS_IMAGE_GEN_CREDENTIAL = "vellum:ai:geminiKey"; // Per-web-search-provider localStorage keys live in the generated catalog // (`WEB_SEARCH_PROVIDER_KEY_STORAGE`). Returns "" for managed providers diff --git a/apps/web/src/domains/settings/pages/integrations-page.tsx b/apps/web/src/domains/settings/pages/integrations-page.tsx index 81be41a90fb..bd71babc996 100644 --- a/apps/web/src/domains/settings/pages/integrations-page.tsx +++ b/apps/web/src/domains/settings/pages/integrations-page.tsx @@ -30,7 +30,7 @@ import { setLocalSetting, } from "@/utils/local-settings"; -const BANNER_STORAGE_KEY = "integrations.bannerDismissed"; +const BANNER_STORAGE_KEY = "vellum:integrations:bannerDismissed"; type IntegrationFilter = "all" | "enabled" | "not-enabled"; diff --git a/apps/web/src/domains/settings/pages/voice-page.tsx b/apps/web/src/domains/settings/pages/voice-page.tsx index 101606d8377..93716a61a45 100644 --- a/apps/web/src/domains/settings/pages/voice-page.tsx +++ b/apps/web/src/domains/settings/pages/voice-page.tsx @@ -29,7 +29,7 @@ import { } from "@/utils/ptt-activator"; import { routes } from "@/utils/routes"; -const LS_CONVERSATION_TIMEOUT = "voice:conversationTimeoutSeconds"; +const LS_CONVERSATION_TIMEOUT = "vellum:voice:conversationTimeoutSeconds"; const PTT_PRESETS: ReadonlyArray<{ label: string; activator: PTTActivator }> = [ { diff --git a/apps/web/src/lib/auth/gateway-session.ts b/apps/web/src/lib/auth/gateway-session.ts index d2323cc2e5c..8c8ab1150b5 100644 --- a/apps/web/src/lib/auth/gateway-session.ts +++ b/apps/web/src/lib/auth/gateway-session.ts @@ -3,9 +3,15 @@ import { getLocalGatewayUrl, } from "@/lib/local-mode"; -const LS_TOKEN_KEY = "gw:token"; -const LS_EXPIRES_KEY = "gw:expiresAt"; -const LS_TOKEN_SOURCE_KEY = "gw:tokenSource"; +const LS_TOKEN_KEY = "vellum:gw:token"; +const LS_EXPIRES_KEY = "vellum:gw:expiresAt"; +const LS_TOKEN_SOURCE_KEY = "vellum:gw:tokenSource"; + +// Legacy key names kept as read fallbacks in case the startup migration +// in storage-migration.ts failed (e.g. QuotaExceededError on setItem). +const LEGACY_TOKEN_KEY = "gw:token"; +const LEGACY_EXPIRES_KEY = "gw:expiresAt"; +const LEGACY_TOKEN_SOURCE_KEY = "gw:tokenSource"; let cachedToken: string | null = null; let cachedExpiresAt: number = 0; @@ -29,8 +35,12 @@ export function getGatewayToken(): string | null { } cachedToken = null; try { - const token = localStorage.getItem(LS_TOKEN_KEY); - const expiresAtRaw = localStorage.getItem(LS_EXPIRES_KEY); + const token = + localStorage.getItem(LS_TOKEN_KEY) ?? + localStorage.getItem(LEGACY_TOKEN_KEY); + const expiresAtRaw = + localStorage.getItem(LS_EXPIRES_KEY) ?? + localStorage.getItem(LEGACY_EXPIRES_KEY); if (token && expiresAtRaw) { const expiresAt = Number(expiresAtRaw); if (!isTokenExpired(expiresAt)) { @@ -74,7 +84,10 @@ async function acquireGatewayToken(tokenUrl?: string, guardianToken?: string): P export async function ensureGatewayToken(tokenUrl?: string, guardianToken?: string): Promise { const source = tokenUrl ?? "/auth/token"; - const storedSource = cachedTokenSource ?? localStorage.getItem(LS_TOKEN_SOURCE_KEY); + const storedSource = + cachedTokenSource ?? + localStorage.getItem(LS_TOKEN_SOURCE_KEY) ?? + localStorage.getItem(LEGACY_TOKEN_SOURCE_KEY); if (storedSource && storedSource !== source) { clearGatewayToken(); } @@ -97,6 +110,9 @@ export function clearGatewayToken(): void { localStorage.removeItem(LS_TOKEN_KEY); localStorage.removeItem(LS_EXPIRES_KEY); localStorage.removeItem(LS_TOKEN_SOURCE_KEY); + localStorage.removeItem(LEGACY_TOKEN_KEY); + localStorage.removeItem(LEGACY_EXPIRES_KEY); + localStorage.removeItem(LEGACY_TOKEN_SOURCE_KEY); } catch { // localStorage unavailable } diff --git a/apps/web/src/lib/auth/session-cleanup.test.ts b/apps/web/src/lib/auth/session-cleanup.test.ts index 455a9bdc829..675d75b8fc3 100644 --- a/apps/web/src/lib/auth/session-cleanup.test.ts +++ b/apps/web/src/lib/auth/session-cleanup.test.ts @@ -14,35 +14,44 @@ afterEach(() => { describe("clearUserScopedStorage", () => { test("clears sessionStorage entirely", () => { - sessionStorage.setItem("vellum_active_organization_id", "org-123"); sessionStorage.setItem("vellum:edit-chat:asst-1:app-1", "conv-xyz"); + sessionStorage.setItem("arbitrary-session-key", "data"); clearUserScopedStorage(); expect(sessionStorage.length).toBe(0); }); - test("removes all user-scoped app keys from localStorage", () => { + test("removes all vellum: prefixed keys from localStorage", () => { localStorage.setItem("vellum:pinnedApps", "[]"); localStorage.setItem("vellum:lastViewedConversation:asst-1", "conv-1"); localStorage.setItem("vellum:sidebar-open-categories:asst-1", "{}"); localStorage.setItem("vellum:sidebar-open-custom-groups:asst-1", "{}"); - localStorage.setItem("vellum_current_assistant_id__org-1", "asst-1"); + localStorage.setItem("vellum:currentAssistantId:org-1", "asst-1"); localStorage.setItem("vellum:nudge-prefs", "{}"); localStorage.setItem("vellum:chatDrafts:asst-1", '{"text":"hi"}'); localStorage.setItem("vellum:ctxwindow:asst-1", "4096"); localStorage.setItem("vellum:dismissed-surfaces:asst-1", "[]"); - localStorage.setItem("ff:client:some-flag", "true"); - localStorage.setItem("onboarding.tosAccepted", "true"); - localStorage.setItem("onboarding.aiDataConsent", "true"); - localStorage.setItem("onboarding.completed", "true"); - localStorage.setItem("onboarding.selectedVersion", "v1.0"); - localStorage.setItem("integrations.bannerDismissed", "true"); - localStorage.setItem("voice:activationKey", "Space"); + localStorage.setItem("vellum:ff:some-flag", "true"); + localStorage.setItem("vellum:onboarding:tosAccepted", "true"); + localStorage.setItem("vellum:onboarding:aiDataConsent", "true"); + localStorage.setItem("vellum:onboarding:completed", "true"); + localStorage.setItem("vellum:onboarding:selectedVersion", "v1.0"); + localStorage.setItem("vellum:integrations:bannerDismissed", "true"); + localStorage.setItem("vellum:voice:activationKey", "Space"); // eslint-disable-next-line no-restricted-syntax -- test: verifying cleanup of user-scoped storage keys - localStorage.setItem("voice:ttsApiKey:openai", "test-value"); + localStorage.setItem("vellum:voice:ttsApiKey:openai", "test-value"); // eslint-disable-next-line no-restricted-syntax -- test: verifying cleanup of user-scoped storage keys - localStorage.setItem("voice:sttApiKey:openai", "test-value"); + localStorage.setItem("vellum:voice:sttApiKey:openai", "test-value"); + // eslint-disable-next-line no-restricted-syntax -- test: verifying cleanup of user-scoped storage keys + localStorage.setItem("vellum:gw:token", "jwt-token"); + localStorage.setItem("vellum:local:lockfile", "{}"); + localStorage.setItem("vellum:ai:imageGenMode", "enabled"); + localStorage.setItem("vellum:debug:impersonateAssistantVersion", "0.8.6"); + localStorage.setItem("vellum:sidebar:collapsed", "true"); + localStorage.setItem("vellum:sidebar:width", "300"); + localStorage.setItem("vellum:diskPressureDismissed:asst-1", "true"); + localStorage.setItem("vellum:skills:tipDismissed", "true"); clearUserScopedStorage(); @@ -73,37 +82,9 @@ describe("clearUserScopedStorage", () => { expect(localStorage.getItem("device:last_user_id")).toBe("user-123"); }); - test("preserves legacy device-level keys (transitional safety net)", () => { - localStorage.setItem("vellum_theme", "dark"); - localStorage.setItem("vellum_share_analytics", "true"); - localStorage.setItem("vellum_share_diagnostics", "false"); - localStorage.setItem("vellum_biometric_enabled", "true"); - localStorage.setItem("vellum_llm_log_retention", "dontRetain"); - localStorage.setItem("vellum_timezone", "America/New_York"); - localStorage.setItem("vellum_media_embeds_enabled", "false"); - localStorage.setItem("vellum_media_embed_domains", '["youtube.com"]'); - localStorage.setItem("onboarding.lastUserId", "user-123"); - - clearUserScopedStorage(); - - expect(localStorage.getItem("vellum_theme")).toBe("dark"); - expect(localStorage.getItem("vellum_share_analytics")).toBe("true"); - expect(localStorage.getItem("vellum_share_diagnostics")).toBe("false"); - expect(localStorage.getItem("vellum_biometric_enabled")).toBe("true"); - expect(localStorage.getItem("vellum_llm_log_retention")).toBe("dontRetain"); - expect(localStorage.getItem("vellum_timezone")).toBe("America/New_York"); - expect(localStorage.getItem("vellum_media_embeds_enabled")).toBe("false"); - expect(localStorage.getItem("vellum_media_embed_domains")).toBe('["youtube.com"]'); - expect(localStorage.getItem("onboarding.lastUserId")).toBe("user-123"); - }); - - test("automatically clears future app keys without needing explicit registration", () => { + test("automatically clears future vellum: keys without needing explicit registration", () => { localStorage.setItem("vellum:some-future-feature:asst-1", "data"); - localStorage.setItem("vellum_new_preference", "value"); - localStorage.setItem("onboarding.newFlag", "true"); - localStorage.setItem("ff:client:new-experiment", "variant-b"); - localStorage.setItem("voice:newSetting", "on"); - localStorage.setItem("integrations.newBanner", "dismissed"); + localStorage.setItem("vellum:another-feature", "value"); clearUserScopedStorage(); @@ -132,12 +113,12 @@ describe("clearUserScopedStorage", () => { expect(localStorage.getItem("some-other-sdk")).toBe("data"); }); - test("removes user-scoped keys while preserving device and third-party keys", () => { + test("removes vellum: keys while preserving device: and third-party keys", () => { localStorage.setItem("device:theme", "dark"); localStorage.setItem("device:share_analytics", "true"); localStorage.setItem("vellum:pinnedApps", "[]"); - localStorage.setItem("ff:client:my-flag", "true"); - localStorage.setItem("onboarding.completed", "true"); + localStorage.setItem("vellum:ff:my-flag", "true"); + localStorage.setItem("vellum:onboarding:completed", "true"); localStorage.setItem("_ga", "GA1.2.123456"); clearUserScopedStorage(); @@ -146,7 +127,57 @@ describe("clearUserScopedStorage", () => { expect(localStorage.getItem("device:share_analytics")).toBe("true"); expect(localStorage.getItem("_ga")).toBe("GA1.2.123456"); expect(localStorage.getItem("vellum:pinnedApps")).toBeNull(); - expect(localStorage.getItem("ff:client:my-flag")).toBeNull(); + expect(localStorage.getItem("vellum:ff:my-flag")).toBeNull(); + expect(localStorage.getItem("vellum:onboarding:completed")).toBeNull(); + }); + + test("clears legacy prefixed keys if startup migration failed", () => { + // eslint-disable-next-line no-restricted-syntax -- test: verifying cleanup of legacy auth token + localStorage.setItem("gw:token", "legacy-jwt-token"); + // generic-examples:ignore-next-line — reason: epoch timestamp for token expiry, not a phone number + localStorage.setItem("gw:expiresAt", "9999999999"); + localStorage.setItem("voice:ttsProvider", "elevenlabs"); + localStorage.setItem("onboarding.completed", "true"); + localStorage.setItem("ff:client:darkMode", "true"); + localStorage.setItem("local:lockfile", "{}"); + localStorage.setItem("integrations.bannerDismissed", "true"); + localStorage.setItem("vellumDebug.flags.impersonateAssistantVersion", "0.8.6"); + localStorage.setItem("vellum_image_gen_mode", "enabled"); + + clearUserScopedStorage(); + + expect(localStorage.getItem("gw:token")).toBeNull(); + expect(localStorage.getItem("gw:expiresAt")).toBeNull(); + expect(localStorage.getItem("voice:ttsProvider")).toBeNull(); expect(localStorage.getItem("onboarding.completed")).toBeNull(); + expect(localStorage.getItem("ff:client:darkMode")).toBeNull(); + expect(localStorage.getItem("local:lockfile")).toBeNull(); + expect(localStorage.getItem("integrations.bannerDismissed")).toBeNull(); + expect(localStorage.getItem("vellumDebug.flags.impersonateAssistantVersion")).toBeNull(); + expect(localStorage.getItem("vellum_image_gen_mode")).toBeNull(); + }); + + test("preserves legacy device-level keys from cleanup", () => { + localStorage.setItem("vellum_theme", "dark"); + localStorage.setItem("vellum_share_analytics", "true"); + localStorage.setItem("vellum_share_diagnostics", "false"); + localStorage.setItem("vellum_biometric_enabled", "false"); + localStorage.setItem("vellum_llm_log_retention", "dontRetain"); + localStorage.setItem("vellum_timezone", "America/New_York"); + localStorage.setItem("vellum_media_embeds_enabled", "false"); + localStorage.setItem("vellum_media_embed_domains", '["youtube.com"]'); + localStorage.setItem("onboarding.lastUserId", "user-123"); + + clearUserScopedStorage(); + + expect(localStorage.getItem("vellum_theme")).toBe("dark"); + expect(localStorage.getItem("vellum_share_analytics")).toBe("true"); + expect(localStorage.getItem("vellum_share_diagnostics")).toBe("false"); + expect(localStorage.getItem("vellum_biometric_enabled")).toBe("false"); + expect(localStorage.getItem("vellum_llm_log_retention")).toBe("dontRetain"); + expect(localStorage.getItem("vellum_timezone")).toBe("America/New_York"); + expect(localStorage.getItem("vellum_media_embeds_enabled")).toBe("false"); + expect(localStorage.getItem("vellum_media_embed_domains")).toBe('["youtube.com"]'); + expect(localStorage.getItem("onboarding.lastUserId")).toBe("user-123"); }); }); diff --git a/apps/web/src/lib/auth/session-cleanup.ts b/apps/web/src/lib/auth/session-cleanup.ts index b1d3f95d0a9..76b193c1d22 100644 --- a/apps/web/src/lib/auth/session-cleanup.ts +++ b/apps/web/src/lib/auth/session-cleanup.ts @@ -1,18 +1,19 @@ /** * Clear user-scoped browser storage on logout. * - * Any localStorage key starting with `device:` is automatically - * preserved — these are device-scoped settings managed by - * `utils/device-settings.ts`. All other keys matching app prefixes are - * removed. Third-party keys (analytics SDKs, Sentry, etc.) are - * untouched because they don't match app prefixes. + * All app-owned localStorage keys use one of two prefixes: + * - `vellum:` — user-scoped, cleared on logout + * - `device:` — device-scoped, preserved across sessions * - * The `DEVICE_LEVEL_KEYS` set below is a transitional safety net for - * legacy (non-prefixed) device keys that haven't been migrated yet. - * It will be removed once the `device:` namespace migration is - * complete (see LUM-1933). + * This function removes every `vellum:` key while leaving `device:` + * keys and third-party keys (analytics SDKs, Sentry, etc.) untouched. + * sessionStorage is cleared entirely — all keys are session-scoped. * - * sessionStorage is cleared entirely — all keys are user-session-scoped. + * Legacy prefixes are also swept as a safety net: if the startup + * migration in `storage-migration.ts` failed (e.g. QuotaExceededError), + * old key names would survive without this fallback. Particularly + * important for auth tokens (`gw:*`). This sweep can be removed + * once we're confident all users have been migrated. * * Called from the auth store's `logout()` action and from the * cross-tab BroadcastChannel handler before a hard page reload. @@ -21,25 +22,31 @@ * - https://web.dev/articles/sign-out-best-practices */ -import { DEVICE_PREFIX } from "@/utils/device-settings"; +const USER_PREFIX = "vellum:"; -/** Prefixes that identify keys owned by this app. */ -const APP_KEY_PREFIXES = [ - "vellum", +/** + * Legacy key prefixes that were user-scoped before the `vellum:` + * standardization. Swept as a fallback in case startup migration failed. + */ +const LEGACY_USER_PREFIXES = [ "onboarding.", - "ff:client:", "voice:", - "integrations.", "gw:", + "ff:client:", "local:", + "integrations.", + "vellumDebug.", + "vellum_", ]; /** - * Legacy device-level keys preserved as a transitional safety net. - * After the `device:` namespace migration completes, this set is - * removed — the prefix check handles everything. See LUM-1933. + * Legacy device-level keys that used the `vellum_` prefix. + * Must NOT be cleaned on logout — they are device-scoped settings. + * Normally these have been migrated to `device:*` by + * `migrateDeviceSettings()`, but if that migration also failed, + * this set prevents accidental deletion. */ -const DEVICE_LEVEL_KEYS = new Set([ +const LEGACY_DEVICE_KEYS = new Set([ "vellum_theme", "vellum_share_analytics", "vellum_share_diagnostics", @@ -48,15 +55,13 @@ const DEVICE_LEVEL_KEYS = new Set([ "vellum_timezone", "vellum_media_embeds_enabled", "vellum_media_embed_domains", - "onboarding.lastUserId", + "onboarding.lastUserId", // matches "onboarding." prefix but is device-scoped ]); -function isAppKey(key: string): boolean { - return APP_KEY_PREFIXES.some((p) => key.startsWith(p)); -} - -function isDeviceKey(key: string): boolean { - return key.startsWith(DEVICE_PREFIX) || DEVICE_LEVEL_KEYS.has(key); +function isUserScopedKey(key: string): boolean { + if (key.startsWith(USER_PREFIX)) return true; + if (LEGACY_DEVICE_KEYS.has(key)) return false; + return LEGACY_USER_PREFIXES.some((prefix) => key.startsWith(prefix)); } export function clearUserScopedStorage(): void { @@ -70,7 +75,7 @@ export function clearUserScopedStorage(): void { const keysToRemove: string[] = []; for (let i = 0; i < localStorage.length; i++) { const key = localStorage.key(i); - if (key && isAppKey(key) && !isDeviceKey(key)) { + if (key && isUserScopedKey(key)) { keysToRemove.push(key); } } diff --git a/apps/web/src/lib/backwards-compat/impersonate-version-flag.test.ts b/apps/web/src/lib/backwards-compat/impersonate-version-flag.test.ts index 2df08623454..a96adc11326 100644 --- a/apps/web/src/lib/backwards-compat/impersonate-version-flag.test.ts +++ b/apps/web/src/lib/backwards-compat/impersonate-version-flag.test.ts @@ -18,7 +18,7 @@ import { } from "@/lib/backwards-compat/impersonate-version-flag"; import { useAssistantIdentityStore } from "@/stores/assistant-identity-store"; -const STORAGE_KEY = "vellumDebug.flags.impersonateAssistantVersion"; +const STORAGE_KEY = "vellum:debug:impersonateAssistantVersion"; describe("impersonate-version-flag", () => { let originalReload: typeof window.location.reload; diff --git a/apps/web/src/lib/backwards-compat/impersonate-version-flag.ts b/apps/web/src/lib/backwards-compat/impersonate-version-flag.ts index 69a6722f059..083126d7f32 100644 --- a/apps/web/src/lib/backwards-compat/impersonate-version-flag.ts +++ b/apps/web/src/lib/backwards-compat/impersonate-version-flag.ts @@ -28,7 +28,7 @@ // impersonateVersion(null) — clear + reload // impersonateVersion() — log + return current value, no reload -const STORAGE_KEY = "vellumDebug.flags.impersonateAssistantVersion"; +const STORAGE_KEY = "vellum:debug:impersonateAssistantVersion"; /** * Read the impersonated version synchronously. Safe to call at any diff --git a/apps/web/src/lib/local-mode.ts b/apps/web/src/lib/local-mode.ts index db0b0f3c1d2..f18a7cafe75 100644 --- a/apps/web/src/lib/local-mode.ts +++ b/apps/web/src/lib/local-mode.ts @@ -45,8 +45,8 @@ let lockfile: Lockfile | null = null; const EMPTY_LOCKFILE: Lockfile = { assistants: [], activeAssistant: null }; -const LOCKFILE_STORAGE_KEY = "local:lockfile"; -const SELECTED_ASSISTANT_STORAGE_KEY = "local:selectedAssistantId"; +const LOCKFILE_STORAGE_KEY = "vellum:local:lockfile"; +const SELECTED_ASSISTANT_STORAGE_KEY = "vellum:local:selectedAssistantId"; // --------------------------------------------------------------------------- // Core helpers diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 48e605c2a05..0b0c548cac9 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -1,9 +1,13 @@ +// Run localStorage migrations before any other app import. +// MUST stay above the routes import — routes → onboarding-store and +// client-feature-flag-store read localStorage at module level. +import "@/utils/run-storage-migrations"; + import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import { RouterProvider } from "react-router"; import * as Sentry from "@sentry/react"; -import { migrateDeviceSettings } from "@/utils/device-settings"; import { initSentry } from "@/lib/sentry/sentry-init"; import { isLocalMode, loadLockfile } from "@/lib/local-mode"; import { useAuthStore, setupAuthListeners } from "@/stores/auth-store"; @@ -19,7 +23,6 @@ import { initSafeAreaBridge } from "@/runtime/native-safe-area"; async function boot() { await initSafeAreaBridge(); - migrateDeviceSettings(); initSentry(); setupOrganizationStore(); diff --git a/apps/web/src/stores/client-feature-flag-store.ts b/apps/web/src/stores/client-feature-flag-store.ts index 2725b21b3ff..c1e1d33ea8c 100644 --- a/apps/web/src/stores/client-feature-flag-store.ts +++ b/apps/web/src/stores/client-feature-flag-store.ts @@ -3,7 +3,7 @@ import { create } from "zustand"; import { createSelectors } from "@/utils/create-selectors"; import { CLIENT_FLAG_DEFAULTS } from "@/lib/feature-flags/feature-flag-catalog"; -const LS_PREFIX = "ff:client:"; +const LS_PREFIX = "vellum:ff:"; function readOverrides(): Record { if (typeof window === "undefined") return {}; diff --git a/apps/web/src/stores/current-platform-assistant-store.ts b/apps/web/src/stores/current-platform-assistant-store.ts index c571670fdc6..a188dd57e86 100644 --- a/apps/web/src/stores/current-platform-assistant-store.ts +++ b/apps/web/src/stores/current-platform-assistant-store.ts @@ -6,7 +6,7 @@ * **Storage model — one localStorage key per org:** * * Each org's selection is stored under - * `vellum_current_assistant_id__{orgId}`. A custom `StateStorage` + * `vellum:currentAssistantId:{orgId}`. A custom `StateStorage` * adapter wired into the `persist` middleware reads/writes those keys * directly, so the on-disk format stays compatible with the prior * hand-rolled implementation. @@ -44,7 +44,7 @@ import { import { createSelectors } from "@/utils/create-selectors"; export const PLATFORM_ASSISTANT_STORAGE_PREFIX = - "vellum_current_assistant_id__"; + "vellum:currentAssistantId:"; export interface CurrentPlatformAssistantState { /** orgId → selected assistant ID. Absent entries mean "no selection yet". */ @@ -92,7 +92,7 @@ function removeStoredAssistantId(orgId: string): void { /** * Translates the single-name view that `persist` expects into per-org * reads and writes against the existing - * `vellum_current_assistant_id__{orgId}` localStorage keys. Writes are + * `vellum:currentAssistantId:{orgId}` localStorage keys. Writes are * additive — see the file header for why deletions are not handled * here. */ diff --git a/apps/web/src/utils/onboarding-cleanup.ts b/apps/web/src/utils/onboarding-cleanup.ts index 1f66d5054eb..77ff0b9c8c1 100644 --- a/apps/web/src/utils/onboarding-cleanup.ts +++ b/apps/web/src/utils/onboarding-cleanup.ts @@ -13,10 +13,12 @@ import { removeLocalSetting } from "@/utils/local-settings"; import { getDeviceSetting, setDeviceSetting } from "@/utils/device-settings"; -export const KEY_TOS_ACCEPTED = "onboarding.tosAccepted"; -export const KEY_AI_DATA_CONSENT = "onboarding.aiDataConsent"; -export const KEY_COMPLETED = "onboarding.completed"; -const KEY_SELECTED_VERSION = "onboarding.selectedVersion"; +/** Source of truth for onboarding key constants. Also imported by + * `onboarding-store.ts`, `prefs.ts`, and `storage-migration.ts`. */ +export const KEY_TOS_ACCEPTED = "vellum:onboarding:tosAccepted"; +export const KEY_AI_DATA_CONSENT = "vellum:onboarding:aiDataConsent"; +export const KEY_COMPLETED = "vellum:onboarding:completed"; +const KEY_SELECTED_VERSION = "vellum:onboarding:selectedVersion"; /** * Remove per-user onboarding flags so a different account signing in on the diff --git a/apps/web/src/utils/ptt-activator.ts b/apps/web/src/utils/ptt-activator.ts index f5e9527887e..f17f5f8e055 100644 --- a/apps/web/src/utils/ptt-activator.ts +++ b/apps/web/src/utils/ptt-activator.ts @@ -33,7 +33,7 @@ export interface PTTKey { export type PTTActivator = PTTOff | PTTModifierOnly | PTTKey; -export const LS_PTT_ACTIVATION_KEY = "voice:activationKey"; +export const LS_PTT_ACTIVATION_KEY = "vellum:voice:activationKey"; const MODIFIER_ORDER: PTTModifier[] = [ "function", diff --git a/apps/web/src/utils/run-storage-migrations.ts b/apps/web/src/utils/run-storage-migrations.ts new file mode 100644 index 00000000000..cd7c5aa22b6 --- /dev/null +++ b/apps/web/src/utils/run-storage-migrations.ts @@ -0,0 +1,17 @@ +/** + * Side-effect module: runs all localStorage migrations synchronously at + * import time. + * + * **IMPORTANT** — In `main.tsx`, this import MUST appear before any import + * that transitively evaluates a Zustand store reading from localStorage + * (e.g. `routes.tsx` → `onboarding-store`, `client-feature-flag-store`). + * ES modules evaluate in import-declaration order within a file, so + * placing this import first guarantees migrations write the new key names + * before any store's module-level initializer reads them. + */ + +import { migrateDeviceSettings } from "./device-settings"; +import { runStorageMigrations } from "./storage-migration"; + +migrateDeviceSettings(); +runStorageMigrations(); diff --git a/apps/web/src/utils/storage-migration.test.ts b/apps/web/src/utils/storage-migration.test.ts new file mode 100644 index 00000000000..c1a01e68ef9 --- /dev/null +++ b/apps/web/src/utils/storage-migration.test.ts @@ -0,0 +1,267 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; + +import { migrateKey, migratePrefix, runStorageMigrations } from "./storage-migration"; + +beforeEach(() => { + localStorage.clear(); +}); + +afterEach(() => { + localStorage.clear(); +}); + +describe("migrateKey", () => { + test("renames old key to new key", () => { + localStorage.setItem("old:key", "value"); + + migrateKey("old:key", "new:key"); + + expect(localStorage.getItem("new:key")).toBe("value"); + expect(localStorage.getItem("old:key")).toBeNull(); + }); + + test("no-op when old key is absent", () => { + migrateKey("missing", "new:key"); + + expect(localStorage.getItem("new:key")).toBeNull(); + }); + + test("preserves existing new key (idempotent)", () => { + localStorage.setItem("old:key", "stale"); + localStorage.setItem("new:key", "fresh"); + + migrateKey("old:key", "new:key"); + + expect(localStorage.getItem("new:key")).toBe("fresh"); + expect(localStorage.getItem("old:key")).toBeNull(); + }); + + test("idempotent when called twice", () => { + localStorage.setItem("old:key", "value"); + + migrateKey("old:key", "new:key"); + migrateKey("old:key", "new:key"); + + expect(localStorage.getItem("new:key")).toBe("value"); + expect(localStorage.getItem("old:key")).toBeNull(); + }); +}); + +describe("migratePrefix", () => { + test("renames all keys matching the old prefix", () => { + // eslint-disable-next-line no-restricted-syntax -- test: verifying migration of storage keys + localStorage.setItem("voice:ttsApiKey:openai", "sk-123"); + // eslint-disable-next-line no-restricted-syntax -- test: verifying migration of storage keys + localStorage.setItem("voice:ttsApiKey:elevenlabs", "el-456"); + localStorage.setItem("voice:sttProvider", "whisper"); + + migratePrefix("voice:ttsApiKey:", "vellum:voice:ttsApiKey:"); + + expect(localStorage.getItem("vellum:voice:ttsApiKey:openai")).toBe("sk-123"); + expect(localStorage.getItem("vellum:voice:ttsApiKey:elevenlabs")).toBe("el-456"); + expect(localStorage.getItem("voice:ttsApiKey:openai")).toBeNull(); + expect(localStorage.getItem("voice:ttsApiKey:elevenlabs")).toBeNull(); + // Unrelated key untouched + expect(localStorage.getItem("voice:sttProvider")).toBe("whisper"); + }); + + test("no-op when no keys match", () => { + localStorage.setItem("other:key", "value"); + + migratePrefix("voice:", "vellum:voice:"); + + expect(localStorage.getItem("other:key")).toBe("value"); + expect(localStorage.length).toBe(1); + }); + + test("preserves existing new keys (idempotent)", () => { + localStorage.setItem("ff:client:flag-a", "old"); + localStorage.setItem("vellum:ff:flag-a", "already-migrated"); + + migratePrefix("ff:client:", "vellum:ff:"); + + expect(localStorage.getItem("vellum:ff:flag-a")).toBe("already-migrated"); + expect(localStorage.getItem("ff:client:flag-a")).toBeNull(); + }); +}); + +describe("runStorageMigrations", () => { + test("migrates sidebar keys", () => { + localStorage.setItem("assistantSidebarCollapsed", "true"); + localStorage.setItem("assistantSidebarWidth", "300"); + + runStorageMigrations(); + + expect(localStorage.getItem("vellum:sidebar:collapsed")).toBe("true"); + expect(localStorage.getItem("vellum:sidebar:width")).toBe("300"); + expect(localStorage.getItem("assistantSidebarCollapsed")).toBeNull(); + expect(localStorage.getItem("assistantSidebarWidth")).toBeNull(); + }); + + test("migrates voice: keys", () => { + localStorage.setItem("voice:permissionPrimerSeen", "true"); + localStorage.setItem("voice:ttsProvider", "openai"); + localStorage.setItem("voice:sttProvider", "whisper"); + localStorage.setItem("voice:activationKey", "Space"); + // eslint-disable-next-line no-restricted-syntax -- test: verifying migration of API key storage keys + localStorage.setItem("voice:ttsApiKey:openai", "sk-123"); + localStorage.setItem("voice:ttsVoiceId:openai", "alloy"); + // eslint-disable-next-line no-restricted-syntax -- test: verifying migration of API key storage keys + localStorage.setItem("voice:sttApiKey:deepgram", "dg-456"); + + runStorageMigrations(); + + expect(localStorage.getItem("vellum:voice:permissionPrimerSeen")).toBe("true"); + expect(localStorage.getItem("vellum:voice:ttsProvider")).toBe("openai"); + expect(localStorage.getItem("vellum:voice:sttProvider")).toBe("whisper"); + expect(localStorage.getItem("vellum:voice:activationKey")).toBe("Space"); + expect(localStorage.getItem("vellum:voice:ttsApiKey:openai")).toBe("sk-123"); + expect(localStorage.getItem("vellum:voice:ttsVoiceId:openai")).toBe("alloy"); + expect(localStorage.getItem("vellum:voice:sttApiKey:deepgram")).toBe("dg-456"); + // Old keys removed + expect(localStorage.getItem("voice:permissionPrimerSeen")).toBeNull(); + expect(localStorage.getItem("voice:ttsApiKey:openai")).toBeNull(); + }); + + test("migrates onboarding. keys", () => { + localStorage.setItem("onboarding.tosAccepted", "true"); + localStorage.setItem("onboarding.aiDataConsent", "true"); + localStorage.setItem("onboarding.completed", "true"); + localStorage.setItem("onboarding.selectedVersion", "v1.0"); + + runStorageMigrations(); + + expect(localStorage.getItem("vellum:onboarding:tosAccepted")).toBe("true"); + expect(localStorage.getItem("vellum:onboarding:aiDataConsent")).toBe("true"); + expect(localStorage.getItem("vellum:onboarding:completed")).toBe("true"); + expect(localStorage.getItem("vellum:onboarding:selectedVersion")).toBe("v1.0"); + expect(localStorage.getItem("onboarding.tosAccepted")).toBeNull(); + }); + + test("migrates vellum_ AI settings keys", () => { + localStorage.setItem("vellum_image_gen_mode", "enabled"); + localStorage.setItem("vellum_web_search_provider", "perplexity"); + localStorage.setItem("vellum_gemini_key", "gk-789"); + localStorage.setItem("vellum_perplexity_key", "pplx-abc"); + localStorage.setItem("vellum_brave_key", "BSA-def"); + localStorage.setItem("vellum_tavily_key", "tvly-ghi"); + + runStorageMigrations(); + + expect(localStorage.getItem("vellum:ai:imageGenMode")).toBe("enabled"); + expect(localStorage.getItem("vellum:ai:webSearchProvider")).toBe("perplexity"); + expect(localStorage.getItem("vellum:ai:geminiKey")).toBe("gk-789"); + expect(localStorage.getItem("vellum:ai:perplexityKey")).toBe("pplx-abc"); + expect(localStorage.getItem("vellum:ai:braveKey")).toBe("BSA-def"); + expect(localStorage.getItem("vellum:ai:tavilyKey")).toBe("tvly-ghi"); + expect(localStorage.getItem("vellum_image_gen_mode")).toBeNull(); + }); + + test("migrates ff:client: prefix", () => { + localStorage.setItem("ff:client:my-flag", "true"); + localStorage.setItem("ff:client:another-flag", "false"); + + runStorageMigrations(); + + expect(localStorage.getItem("vellum:ff:my-flag")).toBe("true"); + expect(localStorage.getItem("vellum:ff:another-flag")).toBe("false"); + expect(localStorage.getItem("ff:client:my-flag")).toBeNull(); + }); + + test("migrates gw: and local: keys", () => { + // eslint-disable-next-line no-restricted-syntax -- test: verifying migration of gateway token keys + localStorage.setItem("gw:token", "jwt-abc"); + // generic-examples:ignore-next-line — reason: epoch timestamp, not a phone number + localStorage.setItem("gw:expiresAt", "1700000000"); + // eslint-disable-next-line no-restricted-syntax -- test: verifying migration of gateway token keys + localStorage.setItem("gw:tokenSource", "/auth/token"); + localStorage.setItem("local:lockfile", "{}"); + localStorage.setItem("local:selectedAssistantId", "asst-1"); + + runStorageMigrations(); + + expect(localStorage.getItem("vellum:gw:token")).toBe("jwt-abc"); + // generic-examples:ignore-next-line — reason: epoch timestamp, not a phone number + expect(localStorage.getItem("vellum:gw:expiresAt")).toBe("1700000000"); + expect(localStorage.getItem("vellum:gw:tokenSource")).toBe("/auth/token"); + expect(localStorage.getItem("vellum:local:lockfile")).toBe("{}"); + expect(localStorage.getItem("vellum:local:selectedAssistantId")).toBe("asst-1"); + expect(localStorage.getItem("gw:token")).toBeNull(); + expect(localStorage.getItem("local:lockfile")).toBeNull(); + }); + + test("migrates disk-pressure-warning prefix", () => { + localStorage.setItem("disk-pressure-warning-dismissed-asst-1", "true"); + localStorage.setItem("disk-pressure-warning-dismissed-asst-2", "true"); + + runStorageMigrations(); + + expect(localStorage.getItem("vellum:diskPressureDismissed:asst-1")).toBe("true"); + expect(localStorage.getItem("vellum:diskPressureDismissed:asst-2")).toBe("true"); + expect(localStorage.getItem("disk-pressure-warning-dismissed-asst-1")).toBeNull(); + }); + + test("migrates vellum_current_assistant_id__ prefix", () => { + localStorage.setItem("vellum_current_assistant_id__org-1", "asst-a"); + localStorage.setItem("vellum_current_assistant_id__org-2", "asst-b"); + + runStorageMigrations(); + + expect(localStorage.getItem("vellum:currentAssistantId:org-1")).toBe("asst-a"); + expect(localStorage.getItem("vellum:currentAssistantId:org-2")).toBe("asst-b"); + expect(localStorage.getItem("vellum_current_assistant_id__org-1")).toBeNull(); + }); + + test("migrates vellumDebug key", () => { + localStorage.setItem("vellumDebug.flags.impersonateAssistantVersion", "0.8.6"); + + runStorageMigrations(); + + expect(localStorage.getItem("vellum:debug:impersonateAssistantVersion")).toBe("0.8.6"); + expect(localStorage.getItem("vellumDebug.flags.impersonateAssistantVersion")).toBeNull(); + }); + + test("migrates skillsTabTipDismissed to new name", () => { + localStorage.setItem("vellum:skillsTabTipDismissed", "true"); + + runStorageMigrations(); + + expect(localStorage.getItem("vellum:skills:tipDismissed")).toBe("true"); + expect(localStorage.getItem("vellum:skillsTabTipDismissed")).toBeNull(); + }); + + test("does not touch device: keys", () => { + localStorage.setItem("device:theme", "dark"); + localStorage.setItem("device:timezone", "UTC"); + + runStorageMigrations(); + + expect(localStorage.getItem("device:theme")).toBe("dark"); + expect(localStorage.getItem("device:timezone")).toBe("UTC"); + }); + + test("does not touch third-party keys", () => { + localStorage.setItem("_ga", "GA1.2.123456"); + localStorage.setItem("intercom-session", "abc"); + + runStorageMigrations(); + + expect(localStorage.getItem("_ga")).toBe("GA1.2.123456"); + expect(localStorage.getItem("intercom-session")).toBe("abc"); + }); + + test("full migration is idempotent", () => { + localStorage.setItem("assistantSidebarCollapsed", "true"); + localStorage.setItem("voice:ttsProvider", "openai"); + localStorage.setItem("onboarding.completed", "true"); + localStorage.setItem("ff:client:flag", "true"); + + runStorageMigrations(); + const snapshot1 = { ...localStorage }; + + runStorageMigrations(); + const snapshot2 = { ...localStorage }; + + expect(snapshot1).toEqual(snapshot2); + }); +}); diff --git a/apps/web/src/utils/storage-migration.ts b/apps/web/src/utils/storage-migration.ts new file mode 100644 index 00000000000..37895f4bacc --- /dev/null +++ b/apps/web/src/utils/storage-migration.ts @@ -0,0 +1,145 @@ +/** + * One-time localStorage key migrations. + * + * All app-owned localStorage keys must start with either: + * - `vellum:` — user-scoped, cleared on logout + * - `device:` — device-scoped, preserved across sessions + * + * This module renames legacy keys (unprefixed, `onboarding.`, `voice:`, + * `ff:client:`, `gw:`, `local:`, `integrations.`, `vellum_`) to the + * canonical `vellum:` namespace so that session-cleanup.ts can use a + * single prefix check instead of a brittle allowlist. + * + * Migrations are idempotent — safe to re-run on every app startup. + * Executed synchronously at import time via `run-storage-migrations.ts`, + * which must be imported before any Zustand store that reads localStorage + * at module level (see the import order comment in `main.tsx`). + */ + +/** + * Migrate a single static key. Idempotent: writes the new key only + * when it doesn't already exist, removes the old key only after the + * new key is confirmed persisted (guards against QuotaExceededError + * silently losing the value). + */ +export function migrateKey(oldKey: string, newKey: string): void { + if (typeof window === "undefined") return; + try { + const value = localStorage.getItem(oldKey); + if (value === null) return; + if (localStorage.getItem(newKey) === null) { + localStorage.setItem(newKey, value); + } + if (localStorage.getItem(newKey) !== null) { + localStorage.removeItem(oldKey); + } + } catch { + // Storage unavailable — migration retries on next load. + } +} + +/** + * Migrate all keys matching `oldPrefix` to `newPrefix`, preserving + * the suffix. Uses a snapshot of keys to avoid mutating during + * iteration. + */ +export function migratePrefix(oldPrefix: string, newPrefix: string): void { + if (typeof window === "undefined") return; + try { + const pairs: [string, string][] = []; + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (key && key.startsWith(oldPrefix)) { + const suffix = key.slice(oldPrefix.length); + pairs.push([key, newPrefix + suffix]); + } + } + for (const [oldKey, newKey] of pairs) { + migrateKey(oldKey, newKey); + } + } catch { + // Storage unavailable. + } +} + +/** + * Run all pending storage key migrations. Called from + * `run-storage-migrations.ts` (side-effect import at the top of + * `main.tsx`), after `migrateDeviceSettings()` — device keys must + * already be in the `device:` namespace before we migrate user keys. + * + * Each migration is a one-time rename: read old → write new → remove old. + * The order within each group doesn't matter since there are no + * inter-key dependencies. + */ +export function runStorageMigrations(): void { + if (typeof window === "undefined") return; + + // -- Static key renames ------------------------------------------------ + + // Unprefixed → vellum: (these escaped cleanup entirely before) + migrateKey("assistantSidebarCollapsed", "vellum:sidebar:collapsed"); + migrateKey("assistantSidebarWidth", "vellum:sidebar:width"); + + // voice: → vellum:voice: + migrateKey("voice:permissionPrimerSeen", "vellum:voice:permissionPrimerSeen"); + migrateKey("voice:conversationTimeoutSeconds", "vellum:voice:conversationTimeoutSeconds"); + migrateKey("voice:ttsProvider", "vellum:voice:ttsProvider"); + migrateKey("voice:sttProvider", "vellum:voice:sttProvider"); + migrateKey("voice:activationKey", "vellum:voice:activationKey"); + + // integrations. → vellum:integrations: + migrateKey("integrations.bannerDismissed", "vellum:integrations:bannerDismissed"); + + // onboarding. → vellum:onboarding: + migrateKey("onboarding.tosAccepted", "vellum:onboarding:tosAccepted"); + migrateKey("onboarding.aiDataConsent", "vellum:onboarding:aiDataConsent"); + migrateKey("onboarding.completed", "vellum:onboarding:completed"); + migrateKey("onboarding.selectedVersion", "vellum:onboarding:selectedVersion"); + + // vellum:skillsTabTipDismissed → vellum:skills:tipDismissed (consistent naming) + migrateKey("vellum:skillsTabTipDismissed", "vellum:skills:tipDismissed"); + + // vellum_ → vellum:ai: (AI settings page) + migrateKey("vellum_image_gen_mode", "vellum:ai:imageGenMode"); + migrateKey("vellum_image_gen_model", "vellum:ai:imageGenModel"); + migrateKey("vellum_web_search_mode", "vellum:ai:webSearchMode"); + migrateKey("vellum_web_search_provider", "vellum:ai:webSearchProvider"); + migrateKey("vellum_email_mode", "vellum:ai:emailMode"); + migrateKey("vellum_email_byo_provider", "vellum:ai:emailByoProvider"); + migrateKey("vellum_gemini_key", "vellum:ai:geminiKey"); + migrateKey("vellum_perplexity_key", "vellum:ai:perplexityKey"); + migrateKey("vellum_brave_key", "vellum:ai:braveKey"); + migrateKey("vellum_tavily_key", "vellum:ai:tavilyKey"); + + // vellumDebug. → vellum:debug: + migrateKey( + "vellumDebug.flags.impersonateAssistantVersion", + "vellum:debug:impersonateAssistantVersion", + ); + + // gw: → vellum:gw: + migrateKey("gw:token", "vellum:gw:token"); + migrateKey("gw:expiresAt", "vellum:gw:expiresAt"); + migrateKey("gw:tokenSource", "vellum:gw:tokenSource"); + + // local: → vellum:local: + migrateKey("local:lockfile", "vellum:local:lockfile"); + migrateKey("local:selectedAssistantId", "vellum:local:selectedAssistantId"); + + // -- Prefix renames (dynamic/per-entity keys) -------------------------- + + // voice: per-provider keys → vellum:voice: + migratePrefix("voice:ttsApiKey:", "vellum:voice:ttsApiKey:"); + migratePrefix("voice:ttsVoiceId:", "vellum:voice:ttsVoiceId:"); + migratePrefix("voice:sttApiKey:", "vellum:voice:sttApiKey:"); + + // ff:client: → vellum:ff: + migratePrefix("ff:client:", "vellum:ff:"); + + // Unprefixed per-entity → vellum: + migratePrefix("disk-pressure-warning-dismissed-", "vellum:diskPressureDismissed:"); + + // vellum_ per-org → vellum: + migratePrefix("vellum_current_assistant_id__", "vellum:currentAssistantId:"); +}