// sessionStore.js — access_token + Supabase RLS, no service role key needed. // Device sessions live in memory only (restart clears them). import { createClient } from '@supabase/supabase-js'; import crypto from 'crypto'; import { saveEncryptedJson, loadEncryptedJson } from './cryptoUtils.js'; import path from 'path'; let _SUPABASE_URL, _SUPABASE_ANON_KEY; export function initStoreConfig(url, key) { _SUPABASE_URL = url; _SUPABASE_ANON_KEY = key; } const TEMP_TTL_MS = 24 * 60 * 60 * 1000; const TEMP_INACTIVITY = 12 * 60 * 60 * 1000; const TEMP_MSG_LIMIT = 10; const userCache = new Map(); // userId -> { sessions: Map, online: Set } const tempStore = new Map(); // tempId -> TempData const devSessions = new Map(); // token -> DeviceSession const TEMP_STORE_FILE = '/data/temp_sessions.json'; async function loadTempStore() { const data = await loadEncryptedJson(TEMP_STORE_FILE); if (data) { for (const [id, d] of Object.entries(data)) { tempStore.set(id, { sessions: new Map(Object.entries(d.sessions || {})), msgCount: d.msgCount || 0, created: d.created || Date.now(), lastActive: d.lastActive || Date.now(), }); } } } async function saveTempStore() { const data = {}; for (const [id, d] of tempStore) { data[id] = { sessions: Object.fromEntries(d.sessions), msgCount: d.msgCount, created: d.created, lastActive: d.lastActive, }; } await saveEncryptedJson(TEMP_STORE_FILE, data); } // Load temp store on init loadTempStore().catch(err => console.error('Failed to load temp store:', err)); setInterval(async () => { const now = Date.now(); for (const [id, d] of tempStore) if (now - d.created > TEMP_TTL_MS || now - d.lastActive > TEMP_INACTIVITY) tempStore.delete(id); // Save after cleanup await saveTempStore().catch(err => console.error('Failed to save temp store:', err)); }, 30 * 60 * 1000); function userClient(accessToken) { return createClient(_SUPABASE_URL, _SUPABASE_ANON_KEY, { global: { headers: { Authorization: `Bearer ${accessToken}` } }, auth: { persistSession: false }, }); } export const sessionStore = { // ── TEMP ──────────────────────────────────────────────────────────────── initTemp(t) { if (!tempStore.has(t)) tempStore.set(t, { sessions: new Map(), msgCount: 0, created: Date.now(), lastActive: Date.now() }); return tempStore.get(t); }, tempCanSend(t) { const d = tempStore.get(t); return d ? d.msgCount < TEMP_MSG_LIMIT : false; }, tempBump(t) { const d = tempStore.get(t); if (d) { d.msgCount++; d.lastActive = Date.now(); } }, getTempSessions(t) { return [...(tempStore.get(t)?.sessions.values() || [])]; }, getTempSession(t, id) { return tempStore.get(t)?.sessions.get(id) || null; }, createTempSession(t) { const d = this.initTemp(t); const s = { id: crypto.randomUUID(), name: 'New Chat', created: Date.now(), history: [] }; d.sessions.set(s.id, s); d.lastActive = Date.now(); saveTempStore().catch(err => console.error('Failed to save temp store:', err)); return s; }, updateTempSession(t, id, patch) { const d = tempStore.get(t); if (!d) return null; const s = d.sessions.get(id); if (!s) return null; Object.assign(s, patch); d.lastActive = Date.now(); saveTempStore().catch(err => console.error('Failed to save temp store:', err)); return s; }, restoreTempSession(t, session) { const d = this.initTemp(t); const restored = JSON.parse(JSON.stringify(session)); d.sessions.set(restored.id, restored); d.lastActive = Date.now(); saveTempStore().catch(err => console.error('Failed to save temp store:', err)); return restored; }, deleteTempSession(t, id) { tempStore.get(t)?.sessions.delete(id); saveTempStore().catch(err => console.error('Failed to save temp store:', err)); }, deleteTempAll(t) { tempStore.get(t)?.sessions.clear(); saveTempStore().catch(err => console.error('Failed to save temp store:', err)); }, deleteTempSessionEverywhere(id) { let changed = false; for (const temp of tempStore.values()) { if (temp.sessions.delete(id)) changed = true; } if (changed) saveTempStore().catch(err => console.error('Failed to save temp store:', err)); return changed; }, /** * Copy temp sessions into the user's account on login. * We intentionally do NOT delete from tempStore so the guest session * remains usable if the user logs out again (and so the WS client's * tempId still resolves while the tab is open). * Sessions that already exist in the user account (same id) are skipped * to avoid overwriting newer server data. */ async transferTempToUser(tempId, userId, accessToken) { const d = tempStore.get(tempId); if (!d || !d.sessions.size) return; const uc = userClient(accessToken); const user = this._ensureUser(userId); for (const s of d.sessions.values()) { // Skip sessions that are empty (never actually used) if (!s.history || s.history.length === 0) continue; // Skip if the user already has a session with the same id if (user.sessions.has(s.id)) continue; // Deep-clone so mutations to the user copy don't affect temp copy const copy = JSON.parse(JSON.stringify(s)); user.sessions.set(copy.id, copy); await this._persist(uc, userId, copy).catch(err => console.error('transferTempToUser persist error:', err.message)); } }, // ── USERS ──────────────────────────────────────────────────────────────── _ensureUser(uid) { if (!userCache.has(uid)) userCache.set(uid, { sessions: new Map(), online: new Set() }); return userCache.get(uid); }, async loadUserSessions(userId, accessToken) { const uc = userClient(accessToken); const { data, error } = await uc.from('web_sessions').select('*') .eq('user_id', userId).order('updated_at', { ascending: false }); if (error) { console.error('loadUserSessions', error.message); return []; } const user = this._ensureUser(userId); for (const row of data || []) user.sessions.set(row.id, { id: row.id, name: row.name, created: new Date(row.created_at).getTime(), history: row.history || [], model: row.model }); return [...user.sessions.values()]; }, getUserSessions(uid) { return [...(userCache.get(uid)?.sessions.values() || [])]; }, getUserSession(uid, id) { return userCache.get(uid)?.sessions.get(id) || null; }, async createUserSession(userId, accessToken) { const s = { id: crypto.randomUUID(), name: 'New Chat', created: Date.now(), history: [] }; this._ensureUser(userId).sessions.set(s.id, s); await this._persist(userClient(accessToken), userId, s).catch(() => {}); return s; }, async restoreUserSession(userId, accessToken, session) { const restored = JSON.parse(JSON.stringify(session)); this._ensureUser(userId).sessions.set(restored.id, restored); await this._persist(userClient(accessToken), userId, restored).catch(() => {}); return restored; }, async updateUserSession(userId, accessToken, sessionId, patch) { const user = userCache.get(userId); if (!user) { console.error("No user for " + userId); return null; } const s = user.sessions.get(sessionId); if (!s) { console.error ("No session found for " + sessionId); return null; } Object.assign(s, patch); await this._persist(userClient(accessToken), userId, s).catch(() => {}); return s; }, async deleteUserSession(userId, accessToken, id) { try { userCache.get(userId)?.sessions.delete(id); const { error } = await userClient(accessToken) .from('web_sessions') .delete() .eq('id', id) .eq('user_id', userId); if (error) console.error('Supabase delete error:', error.message); } catch (ex) { console.error('Unexpected deleteUserSession error:', ex); } }, async deleteAllUserSessions(userId, accessToken) { const u = userCache.get(userId); if (u) { u.sessions.clear(); } else { console.error('No user for ' + userId); return null; } try { const { error } = await userClient(accessToken) .from('web_sessions') .delete() .eq('user_id', userId); if (error) console.error('Supabase bulk delete error:', error.message); } catch (ex) { console.error('Unexpected deleteAllUserSessions error:', ex); } }, async _persist(uc, userId, s) { await uc.from('web_sessions').upsert({ id: s.id, user_id: userId, name: s.name, history: s.history || [], model: s.model || null, updated_at: new Date().toISOString(), created_at: new Date(s.created).toISOString(), }); }, markOnline(uid, ws) { this._ensureUser(uid).online.add(ws); }, markOffline(uid, ws) { userCache.get(uid)?.online.delete(ws); }, // ── SHARE ──────────────────────────────────────────────────────────────── async createShareToken(userId, accessToken, sessionId) { const s = this.getUserSession(userId, sessionId); if (!s) return null; const token = crypto.randomBytes(24).toString('base64url'); const uc = userClient(accessToken); const { error } = await uc.from('shared_sessions').insert({ token, owner_id: userId, session_snapshot: s, created_at: new Date().toISOString(), }); return error ? null : token; }, async resolveShareToken(token) { // shared_sessions SELECT is public via RLS const uc = createClient(_SUPABASE_URL, _SUPABASE_ANON_KEY, { auth: { persistSession: false } }); const { data } = await uc.from('shared_sessions').select('*').eq('token', token).single(); return data || null; }, async importSharedSession(userId, accessToken, token) { const shared = await this.resolveShareToken(token); if (!shared) return null; const snap = shared.session_snapshot; const newSession = { ...snap, id: crypto.randomUUID(), name: `${snap.name} (shared)`, created: Date.now() }; const uc = userClient(accessToken); this._ensureUser(userId).sessions.set(newSession.id, newSession); await this._persist(uc, userId, newSession).catch(() => {}); return newSession; }, }; export const deviceSessionStore = { create(userId, ip, userAgent) { const token = crypto.randomBytes(32).toString('hex'); devSessions.set(token, { token, userId, ip, userAgent, createdAt: new Date().toISOString(), lastSeen: new Date().toISOString(), active: true }); return token; }, getForUser(uid) { return [...devSessions.values()].filter(s => s.userId === uid && s.active); }, revoke(token) { const s = devSessions.get(token); if (s) { s.active = false; return s; } return null; }, revokeAllExcept(uid, except) { for (const [t, s] of devSessions) if (s.userId === uid && t !== except) s.active = false; }, validate(token) { const s = devSessions.get(token); if (!s || !s.active) return null; s.lastSeen = new Date().toISOString(); return s; }, };