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);
};