Something went wrong. Try again.
Client side atproto account migrator in your web browser, along with services for backups and adversarial migrations. pdsmoover.com
pds atproto migrations moo cow
Something went wrong. Try again.
3.8 kB · 106 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107import type {SessionData} from '@pds-moover/moover';
/** * Generic localStorage-backed cache for login sessions, so a page refresh can offer to resume a * multi-step flow instead of forcing the user to re-enter credentials and re-trigger 2FA. All * persistence policy lives here; the `@pds-moover/moover` package only exposes generic hooks * (onSessionUpdate / restoreSessions). * * A flow names its own set of `Side` values (e.g. 'old'|'new' for migration, 'current'|'old' for * missing blobs) and which of those are required before a resume is offered. * * Security note: this stores access + refresh JWTs in localStorage. Acceptable for this SPA, * mitigated by clearing on completion / start-over and by PasswordSession.resume() invalidating * dead sessions. */
export type SessionCache<Side extends string> = Partial<Record<Side, SessionData>> & { savedAt: number;};
export interface SessionCacheApi<Side extends string> { /** * Persist (data) or drop (null) one side of the flow. When every side ends up gone the whole * key is removed so we never leave an orphaned entry behind. */ saveSide(side: Side, data: SessionData | null): void; /** * Read the cached sessions. Returns null (after clearing the key) when any required side is * missing — a half-populated set of JWTs is never worth resuming. */ load(): SessionCache<Side> | null; /** Remove the whole cache entry (completion / start-over / expired sessions). */ clear(): void;}
function hasStorage(): boolean { return typeof localStorage !== 'undefined';}
/** * Builds a session cache bound to a localStorage key and the set of sides required before a * resume is offered. */export function createSessionCache<Side extends string>( storageKey: string, requiredSides: readonly Side[],): SessionCacheApi<Side> { function readRaw(): SessionCache<Side> | null { if (!hasStorage()) return null; const raw = localStorage.getItem(storageKey); if (!raw) return null; try { return JSON.parse(raw) as SessionCache<Side>; } catch { // Corrupt entry — drop it so we don't keep tripping over it. localStorage.removeItem(storageKey); return null; } }
function writeRaw(cache: SessionCache<Side>): void { if (!hasStorage()) return; localStorage.setItem(storageKey, JSON.stringify(cache)); }
function hasAnySide(cache: SessionCache<Side>): boolean { return Object.entries(cache).some(([key, value]) => key !== 'savedAt' && value); }
function clear(): void { if (!hasStorage()) return; localStorage.removeItem(storageKey); }
function saveSide(side: Side, data: SessionData | null): void { if (!hasStorage()) return; const cache = readRaw() ?? ({savedAt: Date.now()} as SessionCache<Side>); // Narrow to the record view so indexing by the open `Side` type param stays well-typed. const sides = cache as Partial<Record<Side, SessionData>>; if (data) { sides[side] = data; } else { delete sides[side]; } cache.savedAt = Date.now(); if (!hasAnySide(cache)) { localStorage.removeItem(storageKey); return; } writeRaw(cache); }
function load(): SessionCache<Side> | null { const cache = readRaw(); if (!cache) return null; const sides = cache as Partial<Record<Side, SessionData>>; if (requiredSides.some((side) => !sides[side])) { // Orphan hygiene: don't leave a half-populated set of JWTs lying around. clear(); return null; } return cache; }
return {saveSide, load, clear};}