/** * Identity service for managing persistent visitor and session identifiers using cookies. * Handles consent-based activation, automatic session renewal via user activity, and provides * IDs to analytics tracking modules when user consent is granted. */ export interface IdentityState { visitorId: string; sessionId: string; } const VISITOR_COOKIE = 'adalong_vid'; const SESSION_COOKIE = 'adalong_sid'; const VISITOR_MAX_AGE = 60 * 60 * 24 * 365; // 365 days const SESSION_MAX_AGE = 60 * 30; // 30 minutes // Reference count so multiple consumers (analytics modules) can enable/disable without racing. let usageCount = 0; let visitorId: string | null = null; let sessionId: string | null = null; let activityAttached = false; const activityEvents: Array = ['click', 'touchstart', 'keydown']; const readCookie = (name: string): string | null => { if (typeof document === 'undefined') { return null; } const cookies = document.cookie ? document.cookie.split('; ') : []; for (let i = 0; i < cookies.length; i += 1) { const [cookieName, ...valueParts] = cookies[i].split('='); if (cookieName === name) { return decodeURIComponent(valueParts.join('=')); } } return null; }; const writeCookie = (name: string, value: string, maxAge: number): void => { if (typeof document === 'undefined') { return; } const encodedValue = encodeURIComponent(value); const expires = new Date(Date.now() + maxAge * 1000).toUTCString(); const secure = typeof window !== 'undefined' && window.location.protocol === 'https:' ? '; Secure' : ''; document.cookie = `${name}=${encodedValue}; Max-Age=${maxAge}; Expires=${expires}; Path=/; SameSite=Lax${secure}`; }; const deleteCookie = (name: string): void => { if (typeof document === 'undefined') { return; } const secure = typeof window !== 'undefined' && window.location.protocol === 'https:' ? '; Secure' : ''; document.cookie = `${name}=; Expires=Thu, 01 Jan 1970 00:00:00 GMT; Max-Age=0; Path=/; SameSite=Lax${secure}`; }; const randomId = (prefix: 'vid' | 'sid'): string => { if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { return `${prefix}_${crypto.randomUUID().replace(/-/g, '')}`; } const random = Math.random().toString(36).slice(2, 12); const timestamp = Date.now().toString(36); return `${prefix}_${timestamp}${random}`; }; const ensureVisitor = (): void => { if (visitorId) { writeCookie(VISITOR_COOKIE, visitorId, VISITOR_MAX_AGE); return; } const existing = readCookie(VISITOR_COOKIE); visitorId = existing && existing.startsWith('vid_') ? existing : randomId('vid'); writeCookie(VISITOR_COOKIE, visitorId, VISITOR_MAX_AGE); }; const ensureSession = (): void => { if (sessionId) { writeCookie(SESSION_COOKIE, sessionId, SESSION_MAX_AGE); return; } const existing = readCookie(SESSION_COOKIE); sessionId = existing && existing.startsWith('sid_') ? existing : randomId('sid'); writeCookie(SESSION_COOKIE, sessionId, SESSION_MAX_AGE); }; const handleActivity = (): void => { if (!isActive()) { return; } ensureSession(); }; /** * Attaches activity listeners so user interactions automatically renew the session. * These listeners keep the short-lived session alive as long as the user remains * active (clicks, touches, key presses) and avoids forcing re-auth when analytics * tracking is still in use. */ const attachActivityListeners = (): void => { if (activityAttached || typeof window === 'undefined') { return; } activityEvents.forEach((eventName) => { window.addEventListener(eventName, handleActivity, { passive: true }); }); activityAttached = true; }; const detachActivityListeners = (): void => { if (!activityAttached || typeof window === 'undefined') { return; } activityEvents.forEach((eventName) => { window.removeEventListener(eventName, handleActivity); }); activityAttached = false; }; const isActive = (): boolean => usageCount > 0; /** * Called when analytics consent is granted. Tracks how many consumers are active * so shared resources (cookies/listeners) are only created once. */ const enable = (): void => { usageCount += 1; if (usageCount > 1) { return; } ensureVisitor(); ensureSession(); attachActivityListeners(); }; /** * Called when consumers drop consent/stop tracking. Releases listeners once nobody * is using the service by waiting for usageCount to reach zero. */ const disable = (): void => { usageCount = Math.max(usageCount - 1, 0); if (usageCount === 0) { detachActivityListeners(); } }; const clear = (): void => { usageCount = 0; detachActivityListeners(); deleteCookie(VISITOR_COOKIE); deleteCookie(SESSION_COOKIE); visitorId = null; sessionId = null; }; /** * Returns the current visitor/session IDs. Analytics pipelines call this when * consent is present to include the IDs in server calls, and also `touchSession` * is invoked to keep the session alive while users are active. */ const getIdentity = (): IdentityState | null => { if (isActive()) { ensureVisitor(); ensureSession(); } else { const vid = readCookie(VISITOR_COOKIE); const sid = readCookie(SESSION_COOKIE); visitorId = vid && vid.startsWith('vid_') ? vid : visitorId; sessionId = sid && sid.startsWith('sid_') ? sid : sessionId; } if (!visitorId || !sessionId) { return null; } return { visitorId, sessionId }; }; const touchSession = (): void => { if (!isActive()) { return; } ensureSession(); }; export const Identity = { enable, disable, getIdentity, touchSession, clear, };