/** * Resolve an atproto authority (handle or DID) to the DID plus the PDS * service URL that hosts it. ATPROTO_GIT_SERVICE short-circuits discovery, * which is how tests and single-PDS setups avoid network resolution. */ import { XrpcClient } from './xrpc.js'; const PLC_DIRECTORY = 'https://plc.directory'; /** * @typedef {Object} RepoLocation * @property {string} did * @property {string} service - PDS base URL */ /** * @param {string} handle * @returns {Promise} the DID */ async function resolveHandlePublic(handle) { try { const res = await fetch(`https://${handle}/.well-known/atproto-did`); if (res.ok) { const text = (await res.text()).trim(); if (text.startsWith('did:')) return text; } } catch { // Fall through to DNS } const res = await fetch( `https://cloudflare-dns.com/dns-query?name=_atproto.${handle}&type=TXT`, { headers: { accept: 'application/dns-json' } }, ); if (res.ok) { const body = /** @type {{Answer?: Array<{data: string}>}} */ ( await res.json() ); for (const answer of body.Answer ?? []) { const text = answer.data.replace(/^"|"$/g, ''); if (text.startsWith('did=did:')) return text.slice(4); } } throw new Error(`could not resolve handle "${handle}" to a DID`); } /** * @typedef {Object} DidInfo * @property {string} did * @property {string} service - PDS base URL * @property {string|null} handle - from alsoKnownAs, when the document has one * @property {string|null} kemKey - the `#atproto_kem` did:key, which reads * with a space credential ask for a MAC against */ /** * Resolve a DID document to its PDS endpoint and handle. Unlike * resolveRepoLocation this never consults ATPROTO_GIT_SERVICE: that override * names the remote authority's PDS, and the DIDs resolved here are space * members hosted anywhere. * @param {string} did * @param {Record} env * @returns {Promise} */ export async function resolveDidInfo(did, env) { const plcUrl = env.ATPROTO_GIT_PLC_URL ?? PLC_DIRECTORY; /** @type {string} */ let docUrl; if (did.startsWith('did:plc:')) { docUrl = `${plcUrl}/${did}`; } else if (did.startsWith('did:web:')) { const domain = did.slice('did:web:'.length); if (domain.includes(':')) { throw new Error(`unsupported did:web with path segments: ${did}`); } docUrl = `https://${domain}/.well-known/did.json`; } else { throw new Error(`unsupported DID method: ${did}`); } const res = await fetch(docUrl); if (!res.ok) { throw new Error(`could not fetch DID document for ${did}`); } const doc = /** @type {{service?: Array<{id: string, type: string, serviceEndpoint: string}>, alsoKnownAs?: string[], verificationMethod?: Array<{id: string, publicKeyMultibase?: string}>}} */ ( await res.json() ); const service = (doc.service ?? []).find( (s) => s.id === '#atproto_pds' || s.id.endsWith('#atproto_pds'), ); if (!service || typeof service.serviceEndpoint !== 'string') { throw new Error(`no PDS service endpoint in DID document for ${did}`); } const aka = (doc.alsoKnownAs ?? []).find((a) => a.startsWith('at://')); const kem = (doc.verificationMethod ?? []).find( (m) => m.id === '#atproto_kem' || m.id === `${did}#atproto_kem`, ); return { did, service: service.serviceEndpoint, handle: aka ? aka.slice('at://'.length) : null, kemKey: kem?.publicKeyMultibase ? `did:key:${kem.publicKeyMultibase}` : null, }; } /** * @param {string} authority - handle or DID from the remote URL * @param {Record} env * @returns {Promise} */ export async function resolveRepoLocation(authority, env) { const serviceOverride = env.ATPROTO_GIT_SERVICE; if (serviceOverride) { if (authority.startsWith('did:')) { return { did: authority, service: serviceOverride }; } const client = new XrpcClient(serviceOverride); return { did: await client.resolveHandle(authority), service: serviceOverride, }; } const did = authority.startsWith('did:') ? authority : await resolveHandlePublic(authority); const { service } = await resolveDidInfo(did, env); return { did, service }; }