~james/core

core/web/src/lib/auth/accounts.ts -rw-r--r-- 5.6 KiB
452f17b7 — james chore(import): snapshot james.dad.town/core at f70c71c7696a 16 hours ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
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;
};