Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions src/components/clinical-dashboard/recent-query-storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
}
}
21 changes: 19 additions & 2 deletions src/lib/answer-thread-storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 {
Expand All @@ -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<PersistedAnswerThread>;
const record = value as Partial<PersistedAnswerThreadEnvelope>;
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)
: [];
Expand Down Expand Up @@ -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;
Expand Down
16 changes: 13 additions & 3 deletions src/lib/supabase/client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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;
Expand All @@ -158,6 +163,9 @@ export function AuthProvider({ children }: { children: ReactNode }) {
if (nextSession) {
setError(null);
setNotice(null);
} else {
clearPersistedAnswerThread();
clearRecentQueries();
}
});

Expand Down Expand Up @@ -265,6 +273,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
invalidateAuthRequests();
await client.auth.signOut();
clearPersistedAnswerThread();
clearRecentQueries();
Comment thread
BigSimmo marked this conversation as resolved.
setSession(null);
setStatus("signed_out");
setError(null);
Expand All @@ -274,6 +283,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const markSessionExpired = useCallback(() => {
invalidateAuthRequests();
clearPersistedAnswerThread();
clearRecentQueries();
setSession(null);
setStatus("expired");
setNotice(null);
Expand Down
22 changes: 22 additions & 0 deletions tests/answer-thread-storage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
});
});
23 changes: 23 additions & 0 deletions tests/recent-query-storage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -15,6 +16,12 @@ describe("recent query storage", () => {
localStore = new Map<string, string>();
sessionStore = new Map<string, string>();
const storageFor = (store: Map<string, string>) => ({
get length() {
return store.size;
},
key(index: number) {
return [...store.keys()][index] ?? null;
},
getItem(key: string) {
return store.get(key) ?? null;
},
Expand Down Expand Up @@ -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");
});
});