export const DEFAULT_SESSION_VERSION = 2; export const SESSION_STORAGE_KEY = createSessionStorageKey( DEFAULT_SESSION_VERSION, ); const LEGACY_SESSION_STORAGE_KEYS = [ "akintu:conversation:v1:session-id", ] as const; export interface SessionCrypto { getRandomValues?: Crypto["getRandomValues"]; randomUUID?: Crypto["randomUUID"]; } export interface SessionIdOptions { storage?: Pick & Partial>; crypto?: SessionCrypto; sessionVersion?: number | undefined; } /** * Restores conversation continuity using the only value the widget persists. * The storage key is versioned and deliberately independent from the API key * so inspecting storage cannot reveal tenant credentials. */ export function getOrCreateSessionId(options: SessionIdOptions = {}): string { const storage = options.storage ?? getBrowserStorage(); const storageKey = createSessionStorageKey( options.sessionVersion ?? DEFAULT_SESSION_VERSION, ); const storedSessionId = getStoredSessionId(storage, storageKey); if (storedSessionId !== undefined) return storedSessionId; const sessionId = createSessionId(options.crypto ?? globalThis.crypto); try { storage?.setItem(storageKey, sessionId); removeLegacySessions(storage, storageKey); } catch { // Storage can be unavailable in privacy modes; the in-memory session still works. } return sessionId; } /** Starts a clean conversation while retaining previous server-side analytics. */ export function resetConversationSession( options: SessionIdOptions = {}, ): string { const storage = options.storage ?? getBrowserStorage(); const storageKey = createSessionStorageKey( options.sessionVersion ?? DEFAULT_SESSION_VERSION, ); const sessionId = createSessionId(options.crypto ?? globalThis.crypto); try { storage?.setItem(storageKey, sessionId); removeLegacySessions(storage, storageKey); } catch { // Reloading still starts an in-memory session when browser storage is blocked. } return sessionId; } function createSessionId(cryptoSource: SessionCrypto | undefined): string { // randomUUID is unavailable in some older or non-secure browser contexts; // getRandomValues preserves UUID-quality entropy without blocking the widget. if (cryptoSource?.randomUUID !== undefined) { return cryptoSource.randomUUID(); } if (cryptoSource?.getRandomValues !== undefined) { const bytes = cryptoSource.getRandomValues(new Uint8Array(16)); bytes[6] = ((bytes[6] ?? 0) & 0x0f) | 0x40; bytes[8] = ((bytes[8] ?? 0) & 0x3f) | 0x80; const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")); return [ hex.slice(0, 4).join(""), hex.slice(4, 6).join(""), hex.slice(6, 8).join(""), hex.slice(8, 10).join(""), hex.slice(10).join(""), ].join("-"); } // This identifier is not an authentication secret. The last fallback keeps // conversation requests usable when Web Crypto itself is unavailable. return `akintu-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`; } function getStoredSessionId( storage: | (Pick & Partial>) | undefined, storageKey: string, ): string | undefined { try { const value = storage?.getItem(storageKey)?.trim(); if (value !== undefined && value.length >= 1 && value.length <= 128) { return value; } } catch { // A blocked storage backend must not prevent the widget from starting. } return undefined; } function createSessionStorageKey(sessionVersion: number): string { const normalizedVersion = Number.isInteger(sessionVersion) && sessionVersion > 0 ? sessionVersion : DEFAULT_SESSION_VERSION; return `akintu:conversation:v${normalizedVersion}:session-id`; } function removeLegacySessions( storage: Partial> | undefined, activeStorageKey: string, ): void { for (const storageKey of LEGACY_SESSION_STORAGE_KEYS) { if (storageKey !== activeStorageKey) storage?.removeItem?.(storageKey); } } function getBrowserStorage(): Storage | undefined { try { return typeof window === "undefined" ? undefined : window.localStorage; } catch { return undefined; } }