// Turning what somebody types into the server this page will talk to. // // Everything here runs in the browser, so the two lookups an atproto client // normally delegates to a backend happen over public HTTPS endpoints instead: // a DNS-over-HTTPS resolver for a handle's TXT record, and the DID directory // for the document. Both are build-time settings, because a local stack runs a // PLC of its own holding DIDs the public directory has never seen. const PLC_URL = String( import.meta.env?.VITE_PLC_URL || 'https://plc.directory', ).replace(/\/$/, ''); const DOH_URL = String( import.meta.env?.VITE_DOH_URL || 'https://cloudflare-dns.com/dns-query', ); /** * @typedef {Object} Identity * @property {string} did * @property {string} handle - the handle the DID document claims, or the DID * @property {string} pds - origin of the account's PDS, no trailing slash * @property {DidDocument} doc */ /** * @typedef {Object} DidDocument * @property {string} id * @property {string[]} [alsoKnownAs] * @property {Array<{id: string, type: string, serviceEndpoint: string}>} [service] */ /** * @param {string} did * @returns {string} */ export function didDocumentUrl(did) { if (did.startsWith('did:plc:')) return `${PLC_URL}/${did}`; if (did.startsWith('did:web:')) { const host = decodeURIComponent(did.slice('did:web:'.length)); return `https://${host}/.well-known/did.json`; } throw new Error(`Unsupported DID method: ${did}`); } /** * @param {string} did * @returns {Promise} */ export async function resolveDid(did) { const response = await fetch(didDocumentUrl(did)); if (!response.ok) throw new Error(`Could not resolve ${did}`); return response.json(); } /** * The PDS a DID document names. * @param {DidDocument} doc * @returns {string} */ export function pdsEndpoint(doc) { const service = (doc.service || []).find((entry) => entry.id.endsWith('#atproto_pds'), ); if (!service) throw new Error('That account names no PDS.'); return String(service.serviceEndpoint).replace(/\/$/, ''); } /** * The handle a DID document claims. A DID with no `at://` alias reads as * itself, which is what an account mid-handle-change looks like. * @param {DidDocument} doc * @returns {string} */ export function documentHandle(doc) { const aka = (doc.alsoKnownAs || []).find((name) => name.startsWith('at://')); return aka ? aka.slice('at://'.length) : doc.id; } /** * Who a DID is, for a page that has only the DID. * * The handle is the document's own claim, and a claim is not a fact: a DID * document may name any handle at all, including one belonging to somebody * else. So the claim is resolved back, and a page shows an unconfirmed handle * as unconfirmed rather than as an identity. * * @param {string} did * @returns {Promise<{did: string, handle: string|null, verified: boolean}>} */ export async function identityOf(did) { const doc = await resolveDid(did); const claimed = documentHandle(doc); // documentHandle answers the DID itself when the document claims no handle, // which is an account mid-handle-change. if (claimed === did) return { did, handle: null, verified: false }; const resolved = await didForHandle(claimed); return { did, handle: claimed, verified: resolved === did }; } /** * The DID a handle resolves to, by whichever of the two routes answers. * @param {string} handle * @returns {Promise} */ async function didForHandle(handle) { return (await didFromDns(handle)) ?? (await didFromWellKnown(handle)); } /** * A handle's DID from its `_atproto` TXT record, or null. * @param {string} handle * @returns {Promise} */ async function didFromDns(handle) { const url = new URL(DOH_URL); url.searchParams.set('name', `_atproto.${handle}`); url.searchParams.set('type', 'TXT'); try { const response = await fetch(url, { headers: { accept: 'application/dns-json' }, }); if (!response.ok) return null; const body = await response.json(); for (const answer of body?.Answer || []) { // A TXT answer arrives quoted, and a long one arrives as several quoted // strings that have to be joined before the prefix is readable. const text = String(answer.data || '').replace(/"\s*"/g, ''); const match = text.match(/^"?did=(did:[^"\s]+)"?$/); if (match) return match[1]; } } catch { // A resolver that is down or blocked leaves the well-known lookup. } return null; } /** * A handle's DID from the well-known document it serves, or null. Reachable * only for a handle whose host allows this origin to read it. * @param {string} handle * @returns {Promise} */ async function didFromWellKnown(handle) { try { const response = await fetch(`https://${handle}/.well-known/atproto-did`); if (!response.ok) return null; const did = (await response.text()).trim(); return did.startsWith('did:') ? did : null; } catch { return null; } } /** * Resolve a handle or DID to the account it names. * * A handle resolved through DNS is checked against the document's own * `alsoKnownAs`, so a TXT record naming somebody else's DID resolves to * nothing rather than to them. * * @param {string} input - a handle, a DID, or either with an `@` or `at://` * @returns {Promise} */ export async function resolveIdentity(input) { const cleaned = input .trim() .replace(/^at:\/\//, '') .replace(/^@/, '') .replace(/\/$/, '') .toLowerCase(); if (!cleaned) throw new Error('Enter a handle or a DID.'); if (cleaned.startsWith('did:')) { const doc = await resolveDid(cleaned); return { did: cleaned, handle: documentHandle(doc), pds: pdsEndpoint(doc), doc, }; } const did = await didForHandle(cleaned); if (!did) throw new Error(`Could not resolve the handle ${cleaned}.`); const doc = await resolveDid(did); const claimed = (doc.alsoKnownAs || []).map((name) => name.replace(/^at:\/\//, '').toLowerCase(), ); if (!claimed.includes(cleaned)) { throw new Error(`${did} does not claim the handle ${cleaned}.`); } return { did, handle: cleaned, pds: pdsEndpoint(doc), doc }; }