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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
146 lines · 4.9 KB Raw History
const DEFAULT_PLC_URL = 'https://plc.directory';

/**
 * @typedef {{id: string, type: string, serviceEndpoint: string}} DidService
 * @typedef {{service?: DidService[]}} DidDocument
 */

/**
 * Extract PDS service endpoint from DID document.
 *
 * @param {DidDocument} didDoc - DID document
 * @returns {string|null} - PDS endpoint URL or null
 */
export function extractPdsEndpoint(didDoc) {
  if (!didDoc?.service || !Array.isArray(didDoc.service)) {
    return null;
  }

  const pdsService = didDoc.service.find(
    (/** @type {DidService} */ s) =>
      s.id === '#atproto_pds' || s.type === 'AtprotoPersonalDataServer',
  );

  return pdsService?.serviceEndpoint ?? null;
}

/**
 * Resolve a DID to its PDS service endpoint.
 *
 * @param {string} did - DID string (did:plc:... or did:web:...)
 * @param {Object} [opts]
 * @param {string} [opts.plcUrl] - PLC directory URL
 * @param {typeof fetch} [opts.fetch] - Fetch implementation
 * @returns {Promise<string|null>} - PDS endpoint URL or null
 */
export async function resolveDid(did, opts = {}) {
  const didDoc = await resolveDidDocument(did, opts);
  return didDoc ? extractPdsEndpoint(didDoc) : null;
}

/**
 * Resolve a DID to its full DID document.
 *
 * Distinct from {@link resolveDid}, which reduces the document to its PDS
 * endpoint: consumers verifying signatures (service auth, space credentials)
 * need the verification methods, not just the service entry.
 *
 * @param {string} did - The DID to resolve (did:plc or did:web)
 * @param {{plcUrl?: string, fetch?: typeof globalThis.fetch}} [opts]
 * @returns {Promise<Object|null>} The DID document, or null if unresolvable
 */
export async function resolveDidDocument(did, opts = {}) {
  const plcUrl = opts.plcUrl ?? DEFAULT_PLC_URL;
  const fetchFn = opts.fetch ?? globalThis.fetch;

  try {
    let didDocUrl;

    if (did.startsWith('did:plc:')) {
      didDocUrl = `${plcUrl}/${did}`;
    } else if (did.startsWith('did:web:')) {
      const domain = did.slice('did:web:'.length);
      didDocUrl = `https://${domain}/.well-known/did.json`;
    } else {
      return null;
    }

    const response = await fetchFn(didDocUrl);
    if (!response.ok) return null;

    return await response.json();
  } catch {
    return null;
  }
}

/**
 * A DID document resolver that holds its answers.
 *
 * Every space credential names its issuer, and verifying one reads that
 * issuer's key, so an uncached resolver reads the directory on every space
 * request. plc.directory sends no `Cache-Control`, so no layer below this one
 * holds the answer either.
 *
 * A held document can name a key the issuer has rotated away from. Callers
 * that verify a signature must pass `forceRefresh` and try again when the
 * signature fails, which is what tells this resolver the entry is wrong.
 *
 * @param {Object} [opts]
 * @param {number} [opts.ttl=3600000] - How long an answer serves, in ms
 *   (default 1 hour, the interval the reference implementation holds one for)
 * @param {number} [opts.maxTtl=86400000] - How long a held answer may serve
 *   when the directory stops answering (default 24 hours)
 * @param {number} [opts.negativeTtl=60000] - How long an unresolvable DID
 *   stays unresolvable (default 1 minute), so a DID registered moments ago
 *   is not pinned for the full TTL
 * @param {string} [opts.plcUrl]
 * @param {typeof fetch} [opts.fetch]
 * @returns {(did: string, opts?: {forceRefresh?: boolean}) => Promise<Object|null>}
 */
export function createCachedDidResolver(opts = {}) {
  const ttl = opts.ttl ?? 3600000;
  const maxTtl = opts.maxTtl ?? 86400000;
  const negativeTtl = opts.negativeTtl ?? 60000;
  /** @type {Map<string, {doc: Object|null, at: number}>} */
  const cache = new Map();
  /** @type {Map<string, Promise<Object|null>>} */
  const reading = new Map();

  /** @param {string} did */
  async function read(did) {
    const held = cache.get(did);
    const doc = await resolveDidDocument(did, {
      plcUrl: opts.plcUrl,
      fetch: opts.fetch,
    });

    // resolveDidDocument answers null for an unreachable directory and for a
    // DID that does not exist alike. Serving the held document covers the
    // first case; maxTtl bounds how long it covers the second.
    if (!doc && held?.doc && Date.now() - held.at < maxTtl) {
      return held.doc;
    }

    cache.set(did, { doc, at: Date.now() });
    return doc;
  }

  return async function resolve(did, { forceRefresh = false } = {}) {
    const held = cache.get(did);
    if (held && !forceRefresh) {
      const age = Date.now() - held.at;
      if (age < (held.doc ? ttl : negativeTtl)) return held.doc;
    }

    // Requests arrive together and name the same authority, so a cold entry
    // would otherwise start one read each. A read already running is a live
    // one, which answers a forced caller as well as an ordinary one.
    const running = reading.get(did);
    if (running) return running;

    const pending = read(did).finally(() => reading.delete(did));
    reading.set(did, pending);
    return pending;
  };
}