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> => { 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 => { 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() ); 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 => { const accounts = await controlledAccounts(agent, serviceDid); return accounts.some((account) => account.did === orgDid); }; export const viewerOwnsOrganization = async ( agent: OAuthUserAgent, serviceDid: string, orgDid: string ): Promise => { 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 => { 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); };