Exosphere is a set of small, modular, self-hostable community tools built on the AT Protocol. app.exosphere.site
7
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: invite with handle

Hugo b5b3f77e 0abac61f

+43 -18
+6 -2
packages/app/src/api/spheres.ts
··· 102 102 return apiFetch<{ members: MemberData[] }>(`/api/spheres/${encodeURIComponent(handle)}/members`); 103 103 } 104 104 105 - export function inviteMember(handle: string, did: string, role: "admin" | "member" = "member") { 105 + export function inviteMember( 106 + handle: string, 107 + identifier: string, 108 + role: "admin" | "member" = "member", 109 + ) { 106 110 return apiFetch(`/api/spheres/${encodeURIComponent(handle)}/members`, { 107 111 method: "POST", 108 112 headers: { "Content-Type": "application/json" }, 109 - body: JSON.stringify({ did, role }), 113 + body: JSON.stringify({ identifier, role }), 110 114 }); 111 115 } 112 116
+8 -8
packages/app/src/pages/sphere-members.tsx
··· 41 41 42 42 function MembersContent({ handle, sphereName }: { handle: string; sphereName: string }) { 43 43 const members = useQuery(() => getSphereMembers(handle), [handle]); 44 - const inviteDid = useSignal(""); 44 + const inviteIdentifier = useSignal(""); 45 45 const inviteRole = useSignal<"admin" | "member">("member"); 46 46 const inviteError = useSignal(""); 47 47 const memberError = useSignal(""); ··· 51 51 52 52 const handleInvite = async (e: Event) => { 53 53 e.preventDefault(); 54 - const did = inviteDid.value.trim(); 55 - if (!did) return; 54 + const identifier = inviteIdentifier.value.trim(); 55 + if (!identifier) return; 56 56 inviting.value = true; 57 57 inviteError.value = ""; 58 58 try { 59 - await inviteMember(handle, did, inviteRole.value); 60 - inviteDid.value = ""; 59 + await inviteMember(handle, identifier, inviteRole.value); 60 + inviteIdentifier.value = ""; 61 61 members.refetch(); 62 62 } catch (err) { 63 63 inviteError.value = err instanceof Error ? err.message : "Failed to invite member."; ··· 179 179 <input 180 180 class={`${ui.input} ${ui.flexGrow}`} 181 181 type="text" 182 - placeholder="did:plc:..." 183 - value={inviteDid.value} 184 - onInput={(e) => (inviteDid.value = (e.target as HTMLInputElement).value)} 182 + placeholder="handle or did:plc:..." 183 + value={inviteIdentifier.value} 184 + onInput={(e) => (inviteIdentifier.value = (e.target as HTMLInputElement).value)} 185 185 /> 186 186 <select 187 187 class={ui.selectCompact}
+16 -6
packages/core/src/auth/client.ts
··· 59 59 * the input unchanged (the OAuth client will resolve it via DNS). 60 60 */ 61 61 export async function resolveHandle(handle: string): Promise<string> { 62 - if (handle.startsWith("did:") || !pdsUrl) return handle; 62 + if (handle.startsWith("did:")) return handle; 63 + if (pdsUrl) { 64 + try { 65 + const res = await fetch( 66 + `${pdsUrl}/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}`, 67 + ); 68 + if (res.ok) { 69 + const data = (await res.json()) as { did: string }; 70 + return data.did; 71 + } 72 + } catch {} 73 + } 74 + // Production: resolve via well-known endpoint 63 75 try { 64 - const res = await fetch( 65 - `${pdsUrl}/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}`, 66 - ); 76 + const res = await fetch(`https://${handle}/.well-known/atproto-did`); 67 77 if (res.ok) { 68 - const data = (await res.json()) as { did: string }; 69 - return data.did; 78 + const did = (await res.text()).trim(); 79 + if (did.startsWith("did:")) return did; 70 80 } 71 81 } catch {} 72 82 return handle;
+12 -1
packages/core/src/sphere/api/members.ts
··· 11 11 import { checkPermission } from "../../permissions/check.ts"; 12 12 import { findSphere } from "./helpers.ts"; 13 13 import { resolveDidHandles } from "../../identity/index.ts"; 14 + import { resolveHandle } from "../../auth/client.ts"; 14 15 15 16 const SPHERE_COLLECTION = "site.exosphere.sphere.profile"; 16 17 const MEMBER_COLLECTION = "site.exosphere.sphere.member"; ··· 78 79 return c.json({ error: z.flattenError(parsed.error) }, 400); 79 80 } 80 81 81 - const { did: memberDid, role } = parsed.data; 82 + const { identifier, role } = parsed.data; 83 + 84 + // Resolve handle → DID if the identifier isn't already a DID 85 + let memberDid = identifier; 86 + if (!identifier.startsWith("did:")) { 87 + const resolved = await resolveHandle(identifier.replace(/^@/, "")); 88 + if (!resolved.startsWith("did:")) { 89 + return c.json({ error: "Could not resolve handle" }, 400); 90 + } 91 + memberDid = resolved; 92 + } 82 93 83 94 const db = getDb(); 84 95
+1 -1
packages/core/src/sphere/schemas.ts
··· 17 17 }); 18 18 19 19 export const inviteMemberSchema = z.object({ 20 - did: z.string().startsWith("did:"), 20 + identifier: z.string().min(1), 21 21 role: z.enum(["admin", "member"]).default("member"), 22 22 });