import { browser } from "$app/environment"; import { isDid, type Did } from "@atcute/lexicons/syntax"; // atcute owns oauth sessions; this stores metadata/order and active-account cookies. export const CURRENT_DID_KEY = "tangled.currentDid"; export const CURRENT_HANDLE_KEY = "tangled.currentHandle"; const ACCOUNTS_KEY = "tangled.accounts"; const ORG_DIDS_KEY = "tangled.orgDids"; const ORG_CONTROLLERS_KEY = "tangled.orgControllers"; // appview account cap parity export const MAX_ACCOUNTS = 20; export interface AuthAccount { did: Did; handle: string; // unix seconds; appview parity addedAt: number; } const isAccount = (value: unknown): value is AuthAccount => !!value && typeof value === "object" && isDid((value as AuthAccount).did) && typeof (value as AuthAccount).handle === "string"; export const loadAccounts = (): AuthAccount[] => { if (!browser) return []; try { const raw = localStorage.getItem(ACCOUNTS_KEY); if (!raw) return []; const parsed = JSON.parse(raw) as unknown; if (!Array.isArray(parsed)) return []; return parsed.filter(isAccount).map((account) => ({ did: account.did, handle: account.handle, addedAt: typeof account.addedAt === "number" ? account.addedAt : 0 })); } catch { return []; } }; export const saveAccounts = (accounts: readonly AuthAccount[]): void => { if (!browser) return; localStorage.setItem(ACCOUNTS_KEY, JSON.stringify(accounts)); }; // stored sessions are authoritative, metadata only supplies order and handle. // // org dids are passed in so that they're excluded here // rather than showing up in the account switcher export const reconcileAccounts = ( stored: readonly Did[], meta: readonly AuthAccount[], orgDids: readonly Did[] = [] ): AuthAccount[] => { const orgSet = new Set(orgDids); const storedSet = new Set(stored); const known = new Set(meta.map((account) => account.did)); const ordered = meta.filter( (account) => storedSet.has(account.did) && !orgSet.has(account.did) ); for (const did of stored) { if (!known.has(did) && !orgSet.has(did)) { ordered.push({ did, handle: did, addedAt: Math.floor(Date.now() / 1000) }); } } return ordered; }; const isDidArray = (value: unknown): value is Did[] => Array.isArray(value) && value.every(isDid); export const loadOrgDids = (): Did[] => { if (!browser) return []; try { const raw = localStorage.getItem(ORG_DIDS_KEY); if (!raw) return []; const parsed = JSON.parse(raw) as unknown; return isDidArray(parsed) ? parsed : []; } catch { return []; } }; export const loadOrgControllers = (): Record => { if (!browser) return {}; try { const raw = localStorage.getItem(ORG_CONTROLLERS_KEY); if (!raw) return {}; const parsed = JSON.parse(raw) as unknown; if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {}; const controllers: Record = {}; for (const [delegatedDid, controllerDid] of Object.entries(parsed)) { if (isDid(delegatedDid) && isDid(controllerDid)) { controllers[delegatedDid] = controllerDid; } } return controllers; } catch { return {}; } }; export const orgControllerFor = (did: Did): Did | undefined => loadOrgControllers()[did]; export const orgDidsControlledBy = ( controllers: Readonly>, controllerDid: Did ): Did[] => Object.entries(controllers) .filter(([, controller]) => controller === controllerDid) .map(([orgDid]) => orgDid as Did); export const forgetOrgDid = (did: Did): void => { if (!browser) return; localStorage.setItem( ORG_DIDS_KEY, JSON.stringify(loadOrgDids().filter((orgDid) => orgDid !== did)) ); const { [did]: _dropped, ...controllers } = loadOrgControllers(); localStorage.setItem(ORG_CONTROLLERS_KEY, JSON.stringify(controllers)); }; export const markOrgDid = (did: Did, controllerDid?: Did): void => { if (!browser) return; const current = loadOrgDids(); if (!current.includes(did)) { localStorage.setItem(ORG_DIDS_KEY, JSON.stringify([...current, did])); } if (controllerDid) { localStorage.setItem( ORG_CONTROLLERS_KEY, JSON.stringify({ ...loadOrgControllers(), [did]: controllerDid }) ); } }; // dedupe by did, preserve insertion order, and keep the original addedAt. export const upsertAccount = ( accounts: readonly AuthAccount[], account: AuthAccount ): AuthAccount[] => { const index = accounts.findIndex((existing) => existing.did === account.did); if (index >= 0) { const next = accounts.slice(); next[index] = { ...account, addedAt: accounts[index].addedAt }; return next; } if (accounts.length >= MAX_ACCOUNTS) return accounts.slice(); return [...accounts, account]; }; export const dropAccount = (accounts: readonly AuthAccount[], did: Did): AuthAccount[] => accounts.filter((account) => account.did !== did); export const persistActive = (did: string, handle: string): void => { if (!browser) return; localStorage.setItem(CURRENT_DID_KEY, did); localStorage.setItem(CURRENT_HANDLE_KEY, handle); const secure = location.protocol === "https:" ? "; secure" : ""; const attrs = `; path=/; max-age=31536000; samesite=lax${secure}`; document.cookie = `${CURRENT_DID_KEY}=${encodeURIComponent(did)}${attrs}`; document.cookie = `${CURRENT_HANDLE_KEY}=${encodeURIComponent(handle)}${attrs}`; }; export const clearActive = (): void => { if (!browser) return; localStorage.removeItem(CURRENT_DID_KEY); localStorage.removeItem(CURRENT_HANDLE_KEY); document.cookie = `${CURRENT_DID_KEY}=; path=/; max-age=0; samesite=lax`; document.cookie = `${CURRENT_HANDLE_KEY}=; path=/; max-age=0; samesite=lax`; }; export const readActiveDid = (): Did | null => { if (!browser) return null; const value = localStorage.getItem(CURRENT_DID_KEY); return isDid(value) ? value : null; };