~ knotless Search/
↑↓ move↵ open⇧↵ all results

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
153 lines · 5.0 KB Raw History
// Turning what a visitor types at sign-in into the server this page will
// talk to. The repository browser reads one account's repositories; the
// visitor signing in to review them is anybody, on any PDS, so their handle
// or DID resolves here rather than against this site's own account.
//
// Everything runs in the browser: 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
 */

/**
 * @param {string} did
 * @returns {string}
 */
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<{id: string, alsoKnownAs?: string[], service?: Array<{id: string, type: string, serviceEndpoint: string}>}>}
 */
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 {Awaited<ReturnType<typeof resolveDid>>} doc
 * @returns {string}
 */
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 {Awaited<ReturnType<typeof resolveDid>>} doc
 * @returns {string}
 */
function documentHandle(doc) {
  const aka = (doc.alsoKnownAs || []).find((name) => name.startsWith('at://'));
  return aka ? aka.slice('at://'.length) : doc.id;
}

/**
 * A handle's DID from its `_atproto` TXT record, or null.
 * @param {string} handle
 * @returns {Promise<string|null>}
 */
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<string|null>}
 */
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<Identity>}
 */
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) };
  }

  const did = (await didFromDns(cleaned)) ?? (await didFromWellKnown(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) };
}