diff --git a/src/components/clinical-dashboard/recent-query-storage.ts b/src/components/clinical-dashboard/recent-query-storage.ts index 10cf29bcd..f21f099f1 100644 --- a/src/components/clinical-dashboard/recent-query-storage.ts +++ b/src/components/clinical-dashboard/recent-query-storage.ts @@ -27,3 +27,21 @@ export function clearLegacyRecentQueries() { // Recent queries are a convenience only. } } + +// Sign-out / session-expiry boundary: on a shared workstation the next person +// must not inherit anyone's recent clinical question text, so remove every +// owner-scoped key too, not just the legacy unscoped residue. +export function clearRecentQueries() { + if (typeof window === "undefined") return; + try { + clearLegacyRecentQueries(); + for (let index = window.sessionStorage.length - 1; index >= 0; index -= 1) { + const key = window.sessionStorage.key(index); + if (key?.startsWith(`${recentQueryStorageKey}:`)) { + window.sessionStorage.removeItem(key); + } + } + } catch { + // Recent queries are a convenience only. + } +} diff --git a/src/lib/answer-thread-storage.ts b/src/lib/answer-thread-storage.ts index 2a0ac1d0e..aef2334ee 100644 --- a/src/lib/answer-thread-storage.ts +++ b/src/lib/answer-thread-storage.ts @@ -2,6 +2,9 @@ import type { RagAnswer, SearchResult } from "@/lib/types"; export const answerThreadStorageKey = "clinical-kb-answer-thread"; export const maxStoredAnswerTurns = 12; +// sessionStorage already dies with the tab, but long-lived clinical-workstation +// tabs can idle for days; bound how long raw query/answer text stays restorable. +export const answerThreadTtlMs = 12 * 60 * 60 * 1000; export type StoredAnswerTurn = { id: string; @@ -17,6 +20,12 @@ export type PersistedAnswerThread = { collapsedTurnIds: string[]; }; +// Stored envelope = the thread plus a write timestamp. savedAt stays internal to +// this module: loads strip it after the TTL check so callers round-trip the +// plain PersistedAnswerThread shape. Payloads written before the TTL existed +// have no savedAt and are accepted once; the next save stamps them. +type PersistedAnswerThreadEnvelope = PersistedAnswerThread & { savedAt?: number }; + const maxStorageBytes = 4_500_000; function isStoredAnswerTurn(value: unknown): value is StoredAnswerTurn { @@ -35,8 +44,15 @@ function isStoredAnswerTurn(value: unknown): value is StoredAnswerTurn { function normalizePersistedAnswerThread(value: unknown): PersistedAnswerThread | null { if (!value || typeof value !== "object") return null; - const record = value as Partial; + const record = value as Partial; if (record.version !== 1) return null; + if ( + typeof record.savedAt === "number" && + Number.isFinite(record.savedAt) && + Date.now() - record.savedAt > answerThreadTtlMs + ) { + return null; + } const priorTurns = Array.isArray(record.priorTurns) ? record.priorTurns.filter(isStoredAnswerTurn).slice(-maxStoredAnswerTurns) : []; @@ -84,11 +100,12 @@ export function loadPersistedAnswerThread(ownerId: string): PersistedAnswerThrea export function savePersistedAnswerThread(ownerId: string, thread: PersistedAnswerThread): boolean { if (typeof window === "undefined" || !ownerId) return false; try { - const payload: PersistedAnswerThread = { + const payload: PersistedAnswerThreadEnvelope = { version: 1, priorTurns: thread.priorTurns.slice(-maxStoredAnswerTurns), latestTurn: thread.latestTurn, collapsedTurnIds: thread.collapsedTurnIds, + savedAt: Date.now(), }; const serialized = JSON.stringify(payload); if (serialized.length > maxStorageBytes) return false; diff --git a/src/lib/supabase/client.tsx b/src/lib/supabase/client.tsx index 7dd9b67ab..8d8ff644b 100644 --- a/src/lib/supabase/client.tsx +++ b/src/lib/supabase/client.tsx @@ -4,6 +4,7 @@ import { createBrowserClient } from "@supabase/ssr"; import { type Session, type SupabaseClient } from "@supabase/supabase-js"; import { createContext, type ReactNode, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; import { clearPersistedAnswerThread } from "@/lib/answer-thread-storage"; +import { clearRecentQueries } from "@/components/clinical-dashboard/recent-query-storage"; import { authSessionFingerprint, createAuthRequestLifecycle } from "@/lib/auth-request-lifecycle"; import { checkSupabaseProjectConfig, formatSupabaseProjectCheck } from "@/lib/supabase/project"; @@ -137,9 +138,13 @@ export function AuthProvider({ children }: { children: ReactNode }) { if (data.session) { setError(null); setNotice(null); - } else if (callbackError) { - setError(decodeURIComponent(callbackError)); - setNotice(null); + } else { + clearPersistedAnswerThread(); + clearRecentQueries(); + if (callbackError) { + setError(decodeURIComponent(callbackError)); + setNotice(null); + } } } catch { if (!active) return; @@ -158,6 +163,9 @@ export function AuthProvider({ children }: { children: ReactNode }) { if (nextSession) { setError(null); setNotice(null); + } else { + clearPersistedAnswerThread(); + clearRecentQueries(); } }); @@ -265,6 +273,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { invalidateAuthRequests(); await client.auth.signOut(); clearPersistedAnswerThread(); + clearRecentQueries(); setSession(null); setStatus("signed_out"); setError(null); @@ -274,6 +283,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { const markSessionExpired = useCallback(() => { invalidateAuthRequests(); clearPersistedAnswerThread(); + clearRecentQueries(); setSession(null); setStatus("expired"); setNotice(null); diff --git a/tests/answer-thread-storage.test.ts b/tests/answer-thread-storage.test.ts index e9e1fbad3..21d2a3b8f 100644 --- a/tests/answer-thread-storage.test.ts +++ b/tests/answer-thread-storage.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; import type { RagAnswer } from "@/lib/types"; import { answerThreadStorageKey, + answerThreadTtlMs, clearPersistedAnswerThread, loadPersistedAnswerThread, savePersistedAnswerThread, @@ -93,4 +94,25 @@ describe("answer thread storage", () => { storage.set(`${answerThreadStorageKey}:user-a`, JSON.stringify({ version: 2 })); expect(loadPersistedAnswerThread("user-a")).toBeNull(); }); + + it("expires persisted threads once the TTL has elapsed", () => { + storage.set( + `${answerThreadStorageKey}:user-a`, + JSON.stringify({ ...sampleThread, savedAt: Date.now() - answerThreadTtlMs - 1 }), + ); + expect(loadPersistedAnswerThread("user-a")).toBeNull(); + }); + + it("keeps fresh threads and stamps savedAt on save", () => { + savePersistedAnswerThread("user-a", sampleThread); + const raw = JSON.parse(storage.get(`${answerThreadStorageKey}:user-a`) ?? "{}"); + expect(typeof raw.savedAt).toBe("number"); + expect(Date.now() - raw.savedAt).toBeLessThan(answerThreadTtlMs); + expect(loadPersistedAnswerThread("user-a")).toEqual(sampleThread); + }); + + it("accepts legacy payloads without a savedAt stamp", () => { + storage.set(`${answerThreadStorageKey}:user-a`, JSON.stringify(sampleThread)); + expect(loadPersistedAnswerThread("user-a")).toEqual(sampleThread); + }); }); diff --git a/tests/recent-query-storage.test.ts b/tests/recent-query-storage.test.ts index 22d30eabe..571f73f40 100644 --- a/tests/recent-query-storage.test.ts +++ b/tests/recent-query-storage.test.ts @@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { recentQueryStorageKey } from "@/components/clinical-dashboard/dashboard-contracts"; import { clearLegacyRecentQueries, + clearRecentQueries, demoRecentQueryOwnerId, loadRecentQueries, } from "@/components/clinical-dashboard/recent-query-storage"; @@ -15,6 +16,12 @@ describe("recent query storage", () => { localStore = new Map(); sessionStore = new Map(); const storageFor = (store: Map) => ({ + get length() { + return store.size; + }, + key(index: number) { + return [...store.keys()][index] ?? null; + }, getItem(key: string) { return store.get(key) ?? null; }, @@ -75,4 +82,20 @@ describe("recent query storage", () => { expect(sessionStore.has(recentQueryStorageKey)).toBe(false); expect(loadRecentQueries("user-a")).toEqual(["scoped query"]); }); + + it("clearRecentQueries removes the legacy keys and every owner-scoped key", () => { + // Sign-out / expiry boundary: the next person on a shared workstation must + // not inherit anyone's recent clinical question text. + localStore.set(recentQueryStorageKey, JSON.stringify(["legacy"])); + sessionStore.set(recentQueryStorageKey, JSON.stringify(["legacy"])); + sessionStore.set(`${recentQueryStorageKey}:user-a`, JSON.stringify(["a"])); + sessionStore.set(`${recentQueryStorageKey}:user-b`, JSON.stringify(["b"])); + sessionStore.set("unrelated-key", "untouched"); + + clearRecentQueries(); + + expect(localStore.has(recentQueryStorageKey)).toBe(false); + expect([...sessionStore.keys()].some((key) => key.startsWith(recentQueryStorageKey))).toBe(false); + expect(sessionStore.get("unrelated-key")).toBe("untouched"); + }); });