import type { Did } from "@atcute/lexicons/syntax"; import { deleteStoredSession, type OAuthUserAgent } from "@atcute/oauth-browser-client"; import { getOrCreateDelegatedSession } from "$lib/api/actAs"; import type { BobbinContext, XrpcRequestInit } from "$lib/api/client"; import { REPO_COUNT } from "$lib/api/descriptors"; import { countOf, enrich, target } from "$lib/api/enrich"; import type { ProfileRecord, RecordList } from "$lib/api/records"; import { listControlledAccounts, removeController } from "$lib/api/tranquil"; import { didFromUri } from "$lib/api/uri"; import { forgetOrgDid } from "$lib/auth/accounts"; export type OrganizationRole = "owner" | "member"; export interface UserOrganization { did: Did; handle: string; description?: string; repos: number; role: OrganizationRole; joinedAt: string; } export const roleForScopes = (grantedScopes: string): OrganizationRole => grantedScopes .split(/\s+/) .some((scope) => scope.startsWith("account:") && scope.includes("action=manage")) ? "owner" : "member"; const profileUriFor = (did: Did): string => `at://${did}/sh.tangled.actor.profile/self`; interface OrganizationProfiles { profiles: Map; repos: Map; } const emptyProfiles: OrganizationProfiles = { profiles: new Map(), repos: new Map() }; const fetchOrganizationProfiles = async ( ctx: BobbinContext, dids: readonly Did[], init?: XrpcRequestInit ): Promise => { const enriched = await enrich>( ctx, { xrpc: "sh.tangled.actor.getProfiles", params: { actors: dids.map(profileUriFor) }, enrich: [target(REPO_COUNT, ["items[].uri"])] }, init ); const profiles = new Map(); for (const item of enriched.output.items) { profiles.set(didFromUri(item.uri), item.value); } const repos = new Map(dids.map((did) => [did, countOf(enriched.data, did, REPO_COUNT)])); return { profiles, repos }; }; export const listUserOrganizations = async ( agent: OAuthUserAgent, ctx: BobbinContext, init?: XrpcRequestInit ): Promise => { const accounts = await listControlledAccounts(agent, init); if (accounts.length === 0) return []; const dids = accounts.map((account) => account.did); // the profile record carries the description, the appview carries the repo // count; neither is essential, so the list still renders if bobbin is down const { profiles, repos } = await fetchOrganizationProfiles(ctx, dids, init).catch( () => emptyProfiles ); return accounts .filter((account) => profiles.get(account.did)?.isOrganization !== false) .map((account) => { const profile = profiles.get(account.did); return { did: account.did, handle: account.handle ?? account.did, description: profile?.description, repos: repos.get(account.did) ?? 0, role: roleForScopes(account.grantedScopes), joinedAt: account.grantedAt }; }); }; export const leaveOrganization = async ( controllerAgent: OAuthUserAgent, orgDid: Did ): Promise => { const orgAgent = await getOrCreateDelegatedSession(controllerAgent, orgDid); await removeController(orgAgent, controllerAgent.sub as Did); // 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); };