/** * store/viewer.ts — Viewer singleton state (fstoken + roots) * * fstoken has a 1h TTL on the backend. We cache it in sessionStorage so * F5 and agent→viewer transitions are instant (stale-while-revalidate). * * acquire() is idempotent: no-op if token is fresh, re-acquires if within * REFRESH_BEFORE_EXPIRY of expiry. Safe to call from multiple places. */ import { defineStore } from 'pinia'; import { ref, computed } from 'vue'; import { getApiBase } from '../utils/apiBase'; const STORAGE_KEY = 'viewer_auth'; const REFRESH_BEFORE_EXPIRY_MS = 5 * 60 * 1000; // refresh if <5min left interface ViewerAuth { fstoken: string; roots: string[]; expiresAt: number; // ms epoch, client-side estimate (TTL - 1h) } function loadStored(): ViewerAuth | null { try { const raw = sessionStorage.getItem(STORAGE_KEY); if (!raw) return null; return JSON.parse(raw) as ViewerAuth; } catch { return null; } } function saveStored(auth: ViewerAuth) { try { sessionStorage.setItem(STORAGE_KEY, JSON.stringify(auth)); } catch {} } function clearStored() { sessionStorage.removeItem(STORAGE_KEY); } export const useViewerStore = defineStore('viewer', () => { const stored = loadStored(); const fstoken = ref(stored?.fstoken ?? ''); const roots = ref(stored?.roots ?? []); const expiresAt = ref(stored?.expiresAt ?? 0); // true once we have a valid (non-expired) token const ready = computed(() => !!fstoken.value && Date.now() < expiresAt.value); let acquiring: Promise | null = null; async function acquire(force = false): Promise { // Skip if token is fresh (more than REFRESH_BEFORE_EXPIRY_MS left) if (!force && fstoken.value && Date.now() < expiresAt.value - REFRESH_BEFORE_EXPIRY_MS) return; // Deduplicate concurrent calls if (acquiring) return acquiring; acquiring = _acquire().finally(() => { acquiring = null; }); return acquiring; } async function _acquire(): Promise { const sessionToken = localStorage.getItem('nyx_session') || localStorage.getItem('titan_token') || ''; if (!sessionToken) return; try { const res = await fetch(`${getApiBase()}/api/viewer/token`, { method: 'POST', headers: { Authorization: `Bearer ${sessionToken}` }, }); if (!res.ok) { // token rejected — clear stale state fstoken.value = ''; roots.value = []; expiresAt.value = 0; clearStored(); return; } const data = await res.json(); const newToken: string = data.fstoken; // Fetch roots while we have the token let newRoots: string[] = roots.value.length ? roots.value : ['shared', 'workspace-titan']; try { const tr = await fetch(`${getApiBase()}/api/viewer/tree?root=&token=${encodeURIComponent(newToken)}`); if (tr.ok) { const td = await tr.json(); if (Array.isArray(td.dirs) && td.dirs.length) newRoots = td.dirs; } } catch {} // Client-side expiry estimate: backend TTL is 1h const newExpiresAt = Date.now() + 60 * 60 * 1000; fstoken.value = newToken; roots.value = newRoots; expiresAt.value = newExpiresAt; saveStored({ fstoken: newToken, roots: newRoots, expiresAt: newExpiresAt }); } catch {} } function invalidate() { fstoken.value = ''; roots.value = []; expiresAt.value = 0; clearStored(); } return { fstoken, roots, ready, acquire, invalidate }; });