/** * Who a `/profile/<...>` address names. * * The address takes either form — a handle, or a fully qualified DID — and * both have to end up at the same place, so this resolves in whichever * direction it is handed and answers with both halves. * * The DID document is the authority in both directions, and nothing here * trusts a resolver: * * - A handle is looked up through `com.atproto.identity.resolveHandle`, * which does live resolution (DNS, then `/.well-known/atproto-did`) * rather than an index lookup. Then the DID it answered with is fetched * and its `alsoKnownAs` has to name the handle that was asked for. A * resolver that lied is caught by that check, which is the reason this * can use somebody else's resolver at all. * - A DID is fetched first, and the handle its `alsoKnownAs` claims is * resolved back. A claim nobody can confirm is dropped rather than * shown: a handle is rented, and the account that used to hold one is * not the account that holds it now. * * A null handle is therefore never an error. It means the account has no * handle we are willing to put in front of a reader, and the DID is what is * shown instead — the same rule profile.ts's `accountName` already follows * for the signed-in account. */ import { didDocument } from "./pds"; /** * The resolver a handle is put to. * * Somebody else's, and it does not have to be honest: every answer is * checked against the DID document before it is used. The public appview is * the one this site already talks to (see avatars.ts), so it is the one with * a cache entry and a warm connection by the time a profile page asks. */ const RESOLVER = "https://public.api.bsky.app"; /** An account, as far as a page is allowed to state it. */ export interface Actor { readonly did: string; /** Null where no handle resolves back to this DID. */ readonly handle: string | null; } /** Whether this segment of the address is a DID rather than a handle. */ export function isDid(actor: string): boolean { return actor.startsWith("did:"); } /** * The account an address names, or null where nothing does. * * Null is the not-found answer, and it covers a handle nobody holds as well * as a DID with no document. It is not an error: a mistyped address is an * ordinary thing for a page to be asked for, and the page says so itself. */ export async function resolveActor(actor: string): Promise { const trimmed = actor.trim().replace(/^@/, ""); if (trimmed === "") return null; try { return isDid(trimmed) ? await fromDid(trimmed) : await fromHandle(trimmed.toLowerCase()); } catch (error: unknown) { console.warn(`identity: ${trimmed} did not resolve`, error); return null; } } /** A DID, and whatever handle it can prove. */ async function fromDid(did: string): Promise { const doc = await didDocument(did); const claimed = claimedHandle(doc); if (!claimed) return { did, handle: null }; const confirmed = await resolveHandle(claimed); return { did, handle: confirmed === did ? claimed : null }; } /** A handle, and the DID it resolves to — if that DID claims it back. */ async function fromHandle(handle: string): Promise { const did = await resolveHandle(handle); if (!did) return null; const doc = await didDocument(did); // The handle the document claims, not the one that was asked for: they are // the same string in the ordinary case, and where they are not, the // document is the one to believe. return { did, handle: claimedHandle(doc) === handle ? handle : null }; } /** The handle a DID document claims, lower-cased, or null where it claims none. */ function claimedHandle(doc: { alsoKnownAs?: unknown }): string | null { const names = Array.isArray(doc.alsoKnownAs) ? doc.alsoKnownAs : []; for (const name of names) { if (typeof name === "string" && name.startsWith("at://")) { const handle = name.slice("at://".length).toLowerCase(); if (handle !== "") return handle; } } return null; } /** The DID a handle resolves to, or null where it resolves to nothing. */ async function resolveHandle(handle: string): Promise { const url = `${RESOLVER}/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}`; const response = await fetch(url); // 400 is what an unresolvable handle answers, and it is an answer rather // than a failure. Anything else is the resolver having a bad day, and is // worth throwing so the caller logs it. if (response.status === 400) return null; if (!response.ok) throw new Error(`resolveHandle answered ${response.status}`); const body = (await response.json()) as { did?: unknown }; return typeof body.did === "string" ? body.did : null; }