import { getArchiveScopeId } from './storage.js';
export const SESSION_CONFIG = {
DEFAULT_DURATION_MS: 4 * 60 * 60 * 1000,
WARNING_BEFORE_MS: 5 * 60 * 1000,
IDLE_TIMEOUT_MS: 15 * 60 * 1000,
STORAGE_MEMORY: 'memory', STORAGE_SESSION: 'session', STORAGE_LOCAL: 'local',
KEY_SESSION_TOKEN: 'cass_session',
KEY_EXPIRY: 'cass_expiry',
KEY_STORAGE_PREF: 'cass_storage_pref',
};
function getScopedSessionKeys() {
const scopeId = getArchiveScopeId();
return {
TOKEN: `${SESSION_CONFIG.KEY_SESSION_TOKEN}_${scopeId}`,
EXPIRY: `${SESSION_CONFIG.KEY_EXPIRY}_${scopeId}`,
STORAGE_PREF: `${SESSION_CONFIG.KEY_STORAGE_PREF}_${scopeId}`,
};
}
function encodeBytes(bytes) {
return btoa(String.fromCharCode(...bytes));
}
function decodeBytes(base64) {
return Uint8Array.from(atob(base64), (char) => char.charCodeAt(0));
}
function getPersistentStorages() {
const storages = [];
try {
if (typeof sessionStorage !== 'undefined') {
storages.push(sessionStorage);
}
} catch (error) {
}
try {
if (typeof localStorage !== 'undefined') {
storages.push(localStorage);
}
} catch (error) {
}
return storages;
}
class MemoryStorage {
constructor() {
this.data = new Map();
}
getItem(key) {
return this.data.get(key) || null;
}
setItem(key, value) {
this.data.set(key, value);
}
removeItem(key) {
this.data.delete(key);
}
clear() {
this.data.clear();
}
}
export class SessionManager {
constructor(options = {}) {
this.duration = options.duration || SESSION_CONFIG.DEFAULT_DURATION_MS;
this.storage = options.storage || SESSION_CONFIG.STORAGE_SESSION;
this.onExpired = options.onExpired || (() => {});
this.onWarning = options.onWarning || (() => {});
this.dek = null; this.expiryTs = 0; this.persistent = false; this.expiryTimeout = null; this.warningTimeout = null; this.memoryStorage = new MemoryStorage();
this.cleanupHandlersInstalled = false;
this.handleVisibilityChange = this.handleVisibilityChange.bind(this);
this.handleBeforeUnload = this.handleBeforeUnload.bind(this);
}
async startSession(dek, rememberMe = false) {
this.clearStorage();
this.dek = dek;
const expiry = Date.now() + this.duration;
this.expiryTs = expiry;
this.persistent = rememberMe && this.storage !== SESSION_CONFIG.STORAGE_MEMORY;
if (this.persistent) {
const storage = this.getStorage();
const sessionKeys = getScopedSessionKeys();
storage.setItem(sessionKeys.TOKEN, encodeBytes(dek));
storage.setItem(sessionKeys.EXPIRY, expiry.toString());
}
this.setTimers(expiry);
this.setupCleanupHandlers();
console.log(`[Session] Started, expires at ${new Date(expiry).toISOString()}`);
}
async restoreSession() {
const storage = this.getStorage();
const sessionKeys = getScopedSessionKeys();
const token = storage.getItem(sessionKeys.TOKEN);
const expiry = parseInt(
storage.getItem(sessionKeys.EXPIRY) || '0',
10
);
if (!token || Date.now() > expiry) {
console.log('[Session] No valid session to restore');
this.clearStorage();
return null;
}
try {
const dek = decodeBytes(token);
this.dek = dek;
this.expiryTs = expiry;
this.persistent = true;
this.setTimers(expiry);
this.setupCleanupHandlers();
console.log(`[Session] Restored, expires at ${new Date(expiry).toISOString()}`);
return dek;
} catch (error) {
console.error('[Session] Failed to restore:', error);
this.clearStorage();
return null;
}
}
endSession() {
console.log('[Session] Ending session');
if (this.dek) {
this.dek.fill(0);
this.dek = null;
}
this.clearTimers();
this.expiryTs = 0;
this.persistent = false;
this.clearStorage();
this.removeCleanupHandlers();
}
extendSession(additionalMs = null) {
if (!this.dek) {
console.warn('[Session] No active session to extend');
return false;
}
const extension = additionalMs || this.duration;
const storage = this.getStorage();
const sessionKeys = getScopedSessionKeys();
const currentExpiry = this.expiryTs || parseInt(storage.getItem(sessionKeys.EXPIRY) || '0', 10);
const newExpiry = Math.max(Date.now(), currentExpiry) + extension;
this.expiryTs = newExpiry;
if (this.persistent) {
storage.setItem(sessionKeys.EXPIRY, newExpiry.toString());
}
this.setTimers(newExpiry);
console.log(`[Session] Extended to ${new Date(newExpiry).toISOString()}`);
return true;
}
getDek() {
return this.dek;
}
isActive() {
return this.dek !== null;
}
getRemainingTime() {
return Math.max(0, this.expiryTs - Date.now());
}
setTimers(expiry) {
this.clearTimers();
const remaining = expiry - Date.now();
if (remaining > 0) {
this.expiryTimeout = setTimeout(() => {
this.endSession();
this.onExpired();
}, remaining);
const warningTime = remaining - SESSION_CONFIG.WARNING_BEFORE_MS;
if (warningTime > 0) {
this.warningTimeout = setTimeout(() => {
this.onWarning(SESSION_CONFIG.WARNING_BEFORE_MS);
}, warningTime);
}
}
}
clearTimers() {
if (this.expiryTimeout) {
clearTimeout(this.expiryTimeout);
this.expiryTimeout = null;
}
if (this.warningTimeout) {
clearTimeout(this.warningTimeout);
this.warningTimeout = null;
}
}
getStorage() {
switch (this.storage) {
case SESSION_CONFIG.STORAGE_LOCAL:
try {
if (typeof localStorage !== 'undefined') {
return localStorage;
}
} catch (error) {
}
return this.memoryStorage;
case SESSION_CONFIG.STORAGE_SESSION:
try {
if (typeof sessionStorage !== 'undefined') {
return sessionStorage;
}
} catch (error) {
}
return this.memoryStorage;
case SESSION_CONFIG.STORAGE_MEMORY:
default:
return this.memoryStorage;
}
}
clearStorage() {
const sessionKeys = getScopedSessionKeys();
this.memoryStorage.removeItem(sessionKeys.TOKEN);
this.memoryStorage.removeItem(sessionKeys.EXPIRY);
for (const storage of getPersistentStorages()) {
storage.removeItem(sessionKeys.TOKEN);
storage.removeItem(sessionKeys.EXPIRY);
}
}
setupCleanupHandlers() {
if (this.cleanupHandlersInstalled) {
return;
}
document.addEventListener('visibilitychange', this.handleVisibilityChange);
window.addEventListener('beforeunload', this.handleBeforeUnload);
this.cleanupHandlersInstalled = true;
}
removeCleanupHandlers() {
if (!this.cleanupHandlersInstalled) {
return;
}
document.removeEventListener('visibilitychange', this.handleVisibilityChange);
window.removeEventListener('beforeunload', this.handleBeforeUnload);
this.cleanupHandlersInstalled = false;
}
handleVisibilityChange() {
if (document.hidden) {
console.log('[Session] Page hidden');
} else {
console.log('[Session] Page visible');
const remaining = this.getRemainingTime();
if (remaining <= 0 && this.dek) {
this.endSession();
this.onExpired();
}
}
}
handleBeforeUnload() {
if (this.storage === SESSION_CONFIG.STORAGE_MEMORY && this.dek) {
this.dek.fill(0);
}
}
}
export class ActivityMonitor {
constructor(sessionManager, options = {}) {
this.session = sessionManager;
this.idleTimeout = options.idleTimeout || SESSION_CONFIG.IDLE_TIMEOUT_MS;
this.lastActivity = Date.now();
this.enabled = false;
this.onActivity = this.onActivity.bind(this);
}
start() {
if (this.enabled) return;
const events = ['mousedown', 'keydown', 'scroll', 'touchstart', 'mousemove'];
events.forEach(event => {
document.addEventListener(event, this.onActivity, { passive: true });
});
this.enabled = true;
console.log('[Activity] Monitoring started');
}
stop() {
if (!this.enabled) return;
const events = ['mousedown', 'keydown', 'scroll', 'touchstart', 'mousemove'];
events.forEach(event => {
document.removeEventListener(event, this.onActivity);
});
this.enabled = false;
console.log('[Activity] Monitoring stopped');
}
onActivity() {
const now = Date.now();
if (now - this.lastActivity > this.idleTimeout) {
console.log('[Activity] User returned from idle, extending session');
this.session.extendSession();
}
this.lastActivity = now;
}
getIdleTime() {
return Date.now() - this.lastActivity;
}
}
export function createSessionManager(options = {}) {
const session = new SessionManager({
duration: options.duration || SESSION_CONFIG.DEFAULT_DURATION_MS,
storage: options.storage || SESSION_CONFIG.STORAGE_SESSION,
onExpired: options.onExpired,
onWarning: options.onWarning,
});
const activity = new ActivityMonitor(session, {
idleTimeout: options.idleTimeout || SESSION_CONFIG.IDLE_TIMEOUT_MS,
});
return { session, activity };
}
export default {
SESSION_CONFIG,
SessionManager,
ActivityMonitor,
createSessionManager,
};