/** * Session management for authenticated crawl sessions. * Detects auth expiry via HTTP status codes and login-page redirects, * then attempts recovery via a token refresh endpoint or re-login flow. * Designed to keep long-running crawls alive without manual intervention. */ import type { Page } from 'playwright'; export interface SessionConfig { loginUrl?: string; email?: string; password?: string; healthCheckUrl?: string; refreshEndpoint?: string; tokenLocalStorageKey?: string; maxRefreshAttempts?: number; } export type SessionEventType = | 'HEALTHY' | 'EXPIRY_DETECTED' | 'REFRESH_ATTEMPT' | 'REFRESH_SUCCESS' | 'REFRESH_FAILED' | 'AUTH_FAILURE'; export interface SessionEvent { type: SessionEventType; isoTimestamp: string; url?: string; strategy?: string; attemptCount?: number; } export interface SessionManager { onResponse(url: string, status: number): void; onNavigation(url: string): void; isHealthy(): boolean; needsRefresh(): boolean; markHealthy(isoTimestamp: string): void; refresh(page: Page, isoTimestamp: string): Promise; getEvents(): SessionEvent[]; getStats(): { healthy: boolean; refreshAttempts: number; authFailure: boolean }; } const LOGIN_PATH_PATTERNS = [ '/login', '/sign-in', '/signin', '/auth/login', '/auth/sign-in', ]; export function createSessionManager(config: SessionConfig): SessionManager { let healthy = true; let refreshAttempts = 0; let authFailure = false; const events: SessionEvent[] = []; const maxAttempts = config.maxRefreshAttempts ?? 3; function onResponse(url: string, status: number): void { if ((status === 401 || status === 403) && healthy) { healthy = false; events.push({ type: 'EXPIRY_DETECTED', isoTimestamp: new Date().toISOString(), url }); } } function onNavigation(url: string): void { const isLoginPage = LOGIN_PATH_PATTERNS.some((pattern) => url.includes(pattern)); if (isLoginPage && healthy) { healthy = false; events.push({ type: 'EXPIRY_DETECTED', isoTimestamp: new Date().toISOString(), url }); } } function isHealthy(): boolean { return healthy; } function needsRefresh(): boolean { return !healthy && !authFailure && refreshAttempts < maxAttempts; } function markHealthy(isoTimestamp: string): void { healthy = true; events.push({ type: 'HEALTHY', isoTimestamp }); } async function tryRefreshEndpoint(page: Page, isoTimestamp: string): Promise { if (!config.refreshEndpoint) return false; events.push({ type: 'REFRESH_ATTEMPT', isoTimestamp, strategy: 'refreshEndpoint', attemptCount: refreshAttempts + 1, }); try { const result = await page.evaluate(async (endpoint) => { try { const res = await fetch(endpoint, { method: 'POST', credentials: 'include' }); if (res.ok) { const data = await res.json().catch(() => ({})); return { ok: true, token: (data as any).access_token || (data as any).token || null }; } return { ok: false, token: null }; } catch { return { ok: false, token: null }; } }, config.refreshEndpoint); if (result.ok) { if (result.token && config.tokenLocalStorageKey) { await page.evaluate( ([key, val]) => localStorage.setItem(key, val), [config.tokenLocalStorageKey, result.token] as [string, string], ); } refreshAttempts++; events.push({ type: 'REFRESH_SUCCESS', isoTimestamp, strategy: 'refreshEndpoint' }); return true; } } catch { // page.evaluate or localStorage.setItem failed — fall through } return false; } async function tryReLogin(page: Page, isoTimestamp: string): Promise { if (!config.loginUrl || !config.email || !config.password) return false; events.push({ type: 'REFRESH_ATTEMPT', isoTimestamp, strategy: 'reLogin', attemptCount: refreshAttempts + 1, }); try { await page.goto(config.loginUrl, { waitUntil: 'domcontentloaded' }); await page .locator('input[type="email"], input[name="email"], input[name="username"]') .first() .fill(config.email); await page .locator('input[type="password"]') .first() .fill(config.password); await page .locator( 'button[type="submit"], button:has-text("Sign In"), button:has-text("Log In"), button:has-text("Login")', ) .first() .click(); await page.waitForFunction( (loginUrl) => !location.href.includes(loginUrl), config.loginUrl, { timeout: 10000 }, ); events.push({ type: 'REFRESH_SUCCESS', isoTimestamp, strategy: 'reLogin' }); return true; } catch { events.push({ type: 'REFRESH_FAILED', isoTimestamp, strategy: 'reLogin' }); return false; } } async function refresh(page: Page, isoTimestamp: string): Promise { // Strategy 1: token refresh endpoint if (config.refreshEndpoint) { const ok = await tryRefreshEndpoint(page, isoTimestamp); if (ok) return true; } // Strategy 2: re-login if (config.loginUrl && config.email && config.password) { const ok = await tryReLogin(page, isoTimestamp); if (ok) return true; } // All strategies exhausted refreshAttempts++; events.push({ type: 'REFRESH_FAILED', isoTimestamp, attemptCount: refreshAttempts }); if (refreshAttempts >= maxAttempts) { authFailure = true; events.push({ type: 'AUTH_FAILURE', isoTimestamp, attemptCount: refreshAttempts }); } return false; } function getEvents(): SessionEvent[] { return [...events]; } function getStats(): { healthy: boolean; refreshAttempts: number; authFailure: boolean } { return { healthy, refreshAttempts, authFailure }; } return { onResponse, onNavigation, isHealthy, needsRefresh, markHealthy, refresh, getEvents, getStats, }; } /** * Attaches a Playwright response listener to feed HTTP status codes into the * session manager. Returns a cleanup function to remove the listener. */ export function attachSessionWatcher(page: Page, manager: SessionManager): () => void { const onResponse = (res: import('playwright').Response) => { manager.onResponse(res.url(), res.status()); }; page.on('response', onResponse); return () => page.off('response', onResponse); }