import { browser } from "$app/env";
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<string, Did> => {
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<string, Did> = {};
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<Record<string, Did>>,
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;
};