~james/core

core/web/src/lib/api/organizations.ts -rw-r--r-- 3.5 KiB
452f17b7 — james chore(import): snapshot james.dad.town/core at f70c71c7696a 15 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
import { isHandle, type ActorIdentifier, type Did } from "@atcute/lexicons/syntax";
import { deleteStoredSession, type OAuthUserAgent } from "@atcute/oauth-browser-client";
import { getOrCreateDelegatedSession } from "#lib/api/actAs.ts";
import { cached, invalidate } from "#lib/api/cache.ts";
import type { BobbinContext, XrpcRequestInit } from "#lib/api/client.ts";
import { listDelegatedAccounts } from "#lib/api/delegation.ts";
import { PROFILE_COLLECTION } from "#lib/api/profile.ts";
import { getProfiles, type ProfileRecord } from "#lib/api/records.ts";
import { removeController } from "#lib/api/tranquil.ts";
import { didFromUri } from "#lib/api/uri.ts";
import { forgetOrgDid } from "#lib/auth/accounts.ts";

export type OrganizationRole = "owner" | "member";

export interface UserOrganization {
	did: Did;
	handle: ActorIdentifier;
	role: OrganizationRole;
	joinedAt: string;
}

const MANAGE_ACCOUNT_SCOPE = "account:*?action=manage";

export const organizationRole = (grantedScopes: string): OrganizationRole =>
	grantedScopes.split(" ").includes(MANAGE_ACCOUNT_SCOPE) ? "owner" : "member";

const controlledKey = (agent: OAuthUserAgent, serviceDid: string) =>
	`controlled:${agent.sub}:${serviceDid}`;

const controlledAccounts = (agent: OAuthUserAgent, serviceDid: string) =>
	cached(controlledKey(agent, serviceDid), 30_000, () =>
		listDelegatedAccounts(agent, serviceDid)
	);

const fetchOrganizationProfiles = async (
	ctx: BobbinContext,
	dids: readonly Did[],
	init?: XrpcRequestInit
): Promise<Map<string, ProfileRecord>> => {
	const list = await getProfiles(
		ctx,
		dids.map((did) => `at://${did}/${PROFILE_COLLECTION}/self`),
		init
	);
	return new Map(list.items.map((item) => [didFromUri(item.uri), item.value]));
};

export const listUserOrganizations = async (
	agent: OAuthUserAgent,
	ctx: BobbinContext,
	serviceDid: string,
	init?: XrpcRequestInit
): Promise<UserOrganization[]> => {
	const accounts = await controlledAccounts(agent, serviceDid);
	if (accounts.length === 0) return [];
	const dids = accounts.map((account) => account.did);
	const profiles = await fetchOrganizationProfiles(ctx, dids, init).catch(
		() => new Map<string, ProfileRecord>()
	);
	return accounts
		.filter((account) => profiles.get(account.did)?.isOrganization !== false)
		.map((account) => ({
			did: account.did,
			handle: isHandle(account.handle) ? account.handle : account.did,
			role: organizationRole(account.grantedScopes),
			joinedAt: account.grantedAt
		}));
};

export const viewerControlsOrganization = async (
	agent: OAuthUserAgent,
	serviceDid: string,
	orgDid: string
): Promise<boolean> => {
	const accounts = await controlledAccounts(agent, serviceDid);
	return accounts.some((account) => account.did === orgDid);
};

export const viewerOwnsOrganization = async (
	agent: OAuthUserAgent,
	serviceDid: string,
	orgDid: string
): Promise<boolean> => {
	const accounts = await controlledAccounts(agent, serviceDid);
	return accounts.some(
		(account) => account.did === orgDid && organizationRole(account.grantedScopes) === "owner"
	);
};

export const leaveOrganization = async (
	controllerAgent: OAuthUserAgent,
	orgDid: Did,
	serviceDid: string
): Promise<void> => {
	const orgAgent = await getOrCreateDelegatedSession(controllerAgent, orgDid);
	await removeController(orgAgent, controllerAgent.sub as Did);
	invalidate(controlledKey(controllerAgent, serviceDid));
	// the grant is gone, so the org session is dead whether or not the pds
	// accepts the revocation
	await orgAgent.signOut().catch(() => deleteStoredSession(orgDid));
	forgetOrgDid(orgDid);
};