Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,9 @@ export const WEB_SEARCH_PROVIDER_KEY_PLACEHOLDERS: Readonly<
export const WEB_SEARCH_PROVIDER_KEY_STORAGE: Readonly<
Record<string, string>
> = {
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. */
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/domains/chat/chat-layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down
6 changes: 3 additions & 3 deletions apps/web/src/domains/chat/components/chat-route-content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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]);

Expand All @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 —
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
6 changes: 3 additions & 3 deletions apps/web/src/domains/onboarding/onboarding-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 6 additions & 4 deletions apps/web/src/domains/onboarding/prefs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 12 additions & 12 deletions apps/web/src/domains/settings/ai/ai-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/domains/settings/pages/integrations-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/domains/settings/pages/voice-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }> = [
{
Expand Down
28 changes: 22 additions & 6 deletions apps/web/src/lib/auth/gateway-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Comment thread
ashleeradka marked this conversation as resolved.

// 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;
Expand All @@ -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)) {
Expand Down Expand Up @@ -74,7 +84,10 @@ async function acquireGatewayToken(tokenUrl?: string, guardianToken?: string): P

export async function ensureGatewayToken(tokenUrl?: string, guardianToken?: string): Promise<string> {
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();
}
Expand All @@ -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
}
Expand Down
123 changes: 77 additions & 46 deletions apps/web/src/lib/auth/session-cleanup.test.ts
Comment thread
ashleeradka marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -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();
Expand All @@ -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");
});
});
Loading