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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
130 lines · 4.2 KB Raw History
/**
 * 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<string>} 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<string, string|undefined>} env
 * @returns {Promise<DidInfo>}
 */
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<string, string|undefined>} env
 * @returns {Promise<RepoLocation>}
 */
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 };
}