Skip to content
File

Blob: src/client/lib/api/session-storage.ts

typescript165 lines
1import {
2 InboxSession,
3 InboxSessionSummary,
4 InboxSessionSummaryList,
5 type InboxSession as InboxSessionType,
6 type InboxSessionSummary as InboxSessionSummaryType,
7} from "@/shared/contracts";
8import { ADMIN_BOOKMARK_SCOPE, INBOX_SESSIONS_KEY, INBOX_TOKEN_KEY_PREFIX } from "./shared";
9import { clearStoredBookmark, getInboxBookmarkScope } from "./bookmarks";
10 
11function getInboxTokenStorageKey(address: string) {
12 return `${INBOX_TOKEN_KEY_PREFIX}:${address}`;
13}
14 
15function clearStoredInboxToken(address: string) {
16 localStorage.removeItem(getInboxTokenStorageKey(address));
17 sessionStorage.removeItem(getInboxTokenStorageKey(address));
18}
19 
20function clearStoredInboxBookmark(address: string) {
21 clearStoredBookmark(getInboxBookmarkScope(address));
22}
23 
24function isExpiredSession(expiresAt: string) {
25 const expiresAtMs = Date.parse(expiresAt);
26 return !Number.isFinite(expiresAtMs) || expiresAtMs <= Date.now();
27}
28 
29function readStoredInboxToken(address: string) {
30 const storageKey = getInboxTokenStorageKey(address);
31 const persistentToken = localStorage.getItem(storageKey);
32 if (persistentToken) {
33 return persistentToken;
34 }
35 
36 const legacySessionToken = sessionStorage.getItem(storageKey);
37 if (legacySessionToken) {
38 localStorage.setItem(storageKey, legacySessionToken);
39 sessionStorage.removeItem(storageKey);
40 return legacySessionToken;
41 }
42 
43 return null;
44}
45 
46function writeStoredSessions(sessions: InboxSessionSummaryType[]) {
47 const validated = InboxSessionSummaryList.assertDecode(sessions);
48 localStorage.setItem(INBOX_SESSIONS_KEY, JSON.stringify(validated));
49 return validated;
50}
51 
52function parseStoredSessions() {
53 try {
54 const raw = localStorage.getItem(INBOX_SESSIONS_KEY);
55 if (!raw) {
56 return [] as InboxSessionSummaryType[];
57 }
58 
59 return InboxSessionSummaryList.assertDecode(JSON.parse(raw));
60 } catch {
61 return [] as InboxSessionSummaryType[];
62 }
63}
64 
65function pruneExpiredSessions() {
66 const sessions = parseStoredSessions();
67 const activeSessions = sessions.filter((session) => {
68 if (!isExpiredSession(session.expiresAt)) {
69 return true;
70 }
71 
72 clearStoredInboxToken(session.address);
73 clearStoredInboxBookmark(session.address);
74 return false;
75 });
76 
77 if (activeSessions.length === sessions.length) {
78 return sessions;
79 }
80 
81 return writeStoredSessions(activeSessions);
82}
83 
84export function loadInboxSessions() {
85 return pruneExpiredSessions();
86}
87 
88export function storeInboxSession(session: InboxSessionType) {
89 const summary = InboxSessionSummary.assertDecode({
90 address: session.address,
91 ttlHours: session.ttlHours,
92 expiresAt: session.expiresAt,
93 });
94 const next = writeStoredSessions(
95 [summary, ...pruneExpiredSessions().filter((item) => item.address !== session.address)].slice(0, 8),
96 );
97 
98 localStorage.setItem(getInboxTokenStorageKey(session.address), session.token);
99 sessionStorage.removeItem(getInboxTokenStorageKey(session.address));
100 return next;
101}
102 
103export function removeInboxSession(address: string) {
104 const next = writeStoredSessions(parseStoredSessions().filter((item) => item.address !== address));
105 clearStoredInboxToken(address);
106 clearStoredInboxBookmark(address);
107 return next;
108}
109 
110export function getInboxSession(address: string) {
111 const summary = pruneExpiredSessions().find((item) => item.address === address);
112 
113 if (!summary) {
114 return null;
115 }
116 
117 const token = readStoredInboxToken(address);
118 
119 if (!token) {
120 return null;
121 }
122 
123 try {
124 return InboxSession.assertDecode({
125 ...summary,
126 token,
127 });
128 } catch {
129 return null;
130 }
131}
132 
133export function updateInboxSession(address: string, updates: Partial<InboxSessionType>) {
134 const next = writeStoredSessions(
135 pruneExpiredSessions().map((item) =>
136 item.address === address
137 ? InboxSessionSummary.assertDecode({
138 ...item,
139 ...(typeof updates.ttlHours === "number" ? { ttlHours: updates.ttlHours } : {}),
140 ...(typeof updates.expiresAt === "string" ? { expiresAt: updates.expiresAt } : {}),
141 })
142 : item,
143 ),
144 );
145 
146 if (typeof updates.token === "string") {
147 localStorage.setItem(getInboxTokenStorageKey(address), updates.token);
148 sessionStorage.removeItem(getInboxTokenStorageKey(address));
149 }
150 
151 const updatedSession = next.find((item) => item.address === address);
152 if (updatedSession && isExpiredSession(updatedSession.expiresAt)) {
153 return removeInboxSession(address);
154 }
155 
156 return next;
157}
158 
159// Admin sessions are now backed by an httpOnly cookie minted at the
160// tessera OIDC callback. Bookmark cleanup runs from the logout handler
161// because the cookie itself is not visible to JS.
162export function clearAdminBookmark() {
163 clearStoredBookmark(ADMIN_BOOKMARK_SCOPE);
164}