chat / server /sessionStore.js
incognitolm
Update
0f84d64
// 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;
},
};