/** * Auth token rotator — manage a pool of authenticated sessions for multi-account crawls. * * Use case: crawl the same app as admin+user+guest in one crawl job to discover * role-specific screens that only appear when authenticated with different privileges. * Each session is a Playwright storageState JSON string. * * Sessions are checked out (locked) before use and returned after the page is done. * Round-robin assignment with stale-session eviction after maxAgeMs. */ export interface SessionEntry { id: string; role: string; // 'admin' | 'user' | 'guest' | custom label storageState: string; // Playwright StorageState JSON createdAt: number; // Date.now() checkedOutAt: number | null; requestCount: number; } export interface SessionPoolOpts { sessions: Array<{ role: string; storageState: string }>; maxAgeMs?: number; // evict sessions older than this (default: 4h) maxRequestsPerSession?: number; // rotate after N requests (default: 100) } export class AuthTokenRotator { private pool: SessionEntry[]; private readonly maxAgeMs: number; private readonly maxRequests: number; constructor(opts: SessionPoolOpts) { this.maxAgeMs = opts.maxAgeMs ?? 4 * 60 * 60 * 1000; this.maxRequests = opts.maxRequestsPerSession ?? 100; this.pool = opts.sessions.map((s, i) => ({ id: `session-${i}-${s.role}`, role: s.role, storageState: s.storageState, createdAt: Date.now(), checkedOutAt: null, requestCount: 0, })); } /** Get the next available session. Returns null if all are checked out. */ checkout(): SessionEntry | null { const now = Date.now(); const available = this.pool.filter(s => !s.checkedOutAt && now - s.createdAt < this.maxAgeMs && s.requestCount < this.maxRequests, ); if (available.length === 0) return null; // Round-robin: pick lowest requestCount available.sort((a, b) => a.requestCount - b.requestCount); const session = available[0]; session.checkedOutAt = now; return session; } /** Return a session to the pool after use. */ checkin(sessionId: string, updatedStorageState?: string): void { const s = this.pool.find(e => e.id === sessionId); if (!s) return; s.checkedOutAt = null; s.requestCount++; if (updatedStorageState) s.storageState = updatedStorageState; } /** Get all available sessions (for parallel crawl setup). */ getAll(): ReadonlyArray { return this.pool; } /** Stats for monitoring. */ stats(): { total: number; available: number; checkedOut: number; stale: number } { const now = Date.now(); const stale = this.pool.filter(s => now - s.createdAt >= this.maxAgeMs || s.requestCount >= this.maxRequests).length; const checkedOut = this.pool.filter(s => !!s.checkedOutAt).length; return { total: this.pool.length, available: this.pool.length - checkedOut - stale, checkedOut, stale }; } /** Add a new session to the pool at runtime. */ addSession(role: string, storageState: string): string { const id = `session-${this.pool.length}-${role}`; this.pool.push({ id, role, storageState, createdAt: Date.now(), checkedOutAt: null, requestCount: 0 }); return id; } }