Something went wrong. Try again.
A fully featured CLI used as an alternative to AppView applications (Bsky.app/mu.social)
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182import * as DNS from 'node:dns';import { NotFoundError, UsageError } from './errors.ts';import { fetchJson } from './xrpc.ts';import { setting } from './config.ts';
// Who someone is: a handle resolves to a DID, a DID to its document, and the// document names the PDS holding the account's repository and the handle it// claims. Lookups are kept for the life of the process, since one command// often asks about the same few identities several times.
export interface DidDocument { id: string; alsoKnownAs?: string[]; verificationMethod?: { id: string; type: string; controller: string; publicKeyMultibase?: string }[]; service?: { id: string; type: string; serviceEndpoint: string | Record<string, unknown> }[];}
export interface Identity { did: string; /** The handle the DID document claims, when it resolves back to the DID. */ handle: string | undefined; pds: string; doc: DidDocument;}
const DID_PATTERN = /^did:(plc:[a-z2-7]{24}|web:[A-Za-z0-9.%-]+)$/;const HANDLE_PATTERN = /^([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]([a-z0-9-]{0,61}[a-z0-9])?$/;
export function plcDirectory(): string { return setting('plc-directory').replace(/\/+$/, '');}
export function isDid(value: string): boolean { return DID_PATTERN.test(value);}
export function isHandle(value: string): boolean { return HANDLE_PATTERN.test(value.replace(/^@/, '').toLowerCase());}
const documents = new Map<string, Promise<DidDocument>>();const handles = new Map<string, Promise<string>>();
function cached<T>(map: Map<string, Promise<T>>, key: string, make: () => Promise<T>): Promise<T> { let pending = map.get(key); if (!pending) { pending = make(); pending.catch(() => map.delete(key)); map.set(key, pending); } return pending;}
export function resolveDid(did: string): Promise<DidDocument> { return cached(documents, did, async () => { let url: string; if (did.startsWith('did:plc:')) { url = `${plcDirectory()}/${did}`; } else if (did.startsWith('did:web:')) { // atproto's did:web is a bare hostname, a port percent-encoded. const host = decodeURIComponent(did.slice('did:web:'.length)); if (!/^[A-Za-z0-9.-]+(:\d+)?$/.test(host)) throw new UsageError(`${did} is not a hostname did:web`); url = `${/^(localhost|127\.)/.test(host) ? 'http' : 'https'}://${host}/.well-known/did.json`; } else { throw new UsageError(`${did}: only did:plc and did:web are supported`); } let doc: DidDocument; try { doc = await fetchJson<DidDocument>(url); } catch (err) { if ((err as { status?: number }).status === 404) throw new NotFoundError(`${did} does not exist`, 'DidNotFound'); throw err; } if (doc?.id !== did) throw new Error(`${url} returned the document of ${String(doc?.id)}, not ${did}`); return doc; });}
/** * Resolve a handle to a DID the way atproto specifies: the `_atproto.<handle>` * TXT record first, then `https://<handle>/.well-known/atproto-did`. */export function resolveHandle(handle: string): Promise<string> { const name = handle.replace(/^@/, '').toLowerCase(); return cached(handles, name, async () => { if (!HANDLE_PATTERN.test(name)) throw new UsageError(`'${handle}' is neither a handle nor a DID`); let did: string | undefined; try { const records = await DNS.promises.resolveTxt(`_atproto.${name}`); const found = [...new Set(records.map((chunks) => chunks.join('')).filter((text) => text.startsWith('did=')))]; if (found.length === 1) did = found[0]!.slice('did='.length); } catch { // No TXT record: try the well-known file. } if (!did) { try { const response = await fetch(`https://${name}/.well-known/atproto-did`, { signal: AbortSignal.timeout(10_000) }); if (response.ok) did = (await response.text()).trim(); } catch { // Neither worked; said below. } } if (!did) { // Where DNS is filtered or the handle's host is unreachable, the // app view resolves it for us. The answer is checked against the // DID document like any other. try { const appview = setting('public-appview').replace(/\/+$/, ''); did = (await fetchJson<{ did: string }>(`${appview}/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(name)}`)).did; } catch { // Said below. } } if (!did || !isDid(did)) throw new NotFoundError(`could not resolve the handle ${name}`, 'HandleNotFound'); return did; });}
/** The origin of the PDS a DID document names. */export function pdsEndpoint(doc: DidDocument): string { const endpoint = serviceEndpoint(doc, 'atproto_pds'); if (!endpoint) throw new Error(`${doc.id} names no PDS (#atproto_pds)`); return endpoint;}
export function serviceEndpoint(doc: DidDocument, fragment: string): string | undefined { const service = (doc.service ?? []).find((entry) => entry.id === `#${fragment}` || entry.id === `${doc.id}#${fragment}`); if (typeof service?.serviceEndpoint !== 'string') return undefined; const url = new URL(service.serviceEndpoint); if (url.protocol !== 'https:' && url.protocol !== 'http:') throw new Error(`${doc.id}: #${fragment} is not http(s)`); return url.origin;}
export function claimedHandle(doc: DidDocument): string | undefined { return (doc.alsoKnownAs ?? []).find((entry) => entry.startsWith('at://'))?.slice('at://'.length);}
/** * A DID or handle as a full identity. A handle is checked both ways — it * points at the DID and the DID claims it back — and a DID's claimed handle * is only reported when it resolves back to the DID. */export async function resolveIdentity(subject: string): Promise<Identity> { const input = subject.trim().replace(/^@/, ''); if (input.startsWith('did:')) { if (!isDid(input)) throw new UsageError(`'${input}' is not a did:plc or did:web`); const doc = await resolveDid(input); const claimed = claimedHandle(doc); let handle: string | undefined; if (claimed) { try { if (await resolveHandle(claimed) === input) handle = claimed; } catch { // An unverifiable handle is reported as none. } } return { did: input, handle, pds: pdsEndpoint(doc), doc }; } const name = input.toLowerCase(); const did = await resolveHandle(name); const doc = await resolveDid(did); if (claimedHandle(doc)?.toLowerCase() !== name) { throw new Error(`${name} points at ${did}, but that DID does not claim ${name} back`); } return { did, handle: name, pds: pdsEndpoint(doc), doc };}
/** A handle or DID as a DID. */export async function toDid(subject: string): Promise<string> { const input = subject.trim().replace(/^@/, ''); if (input.startsWith('did:')) { if (!isDid(input)) throw new UsageError(`'${input}' is not a did:plc or did:web`); return input; } return await resolveHandle(input);}
/** The `did:web` a service host is known by. */export function didWeb(host: string): string { return `did:web:${host.toLowerCase().replace(/:/g, '%3A')}`;}