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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
284 lines · 9.2 KB Raw History
// @pdsjs/core/proxy - Forwarding a request to another atproto service.
//
// Two ways in. An `atproto-proxy` header names the service, which covers feed
// generators, labelers and the chat service. Absent that header, an app.bsky.*
// path goes to the configured AppView.
//
// A proxied call carries a service-auth token whose audience is the named
// service's DID and whose `lxm` is the single method being called. That is what
// makes resolving an arbitrary service safe: the token the client gets is usable
// nowhere else.

import { createServiceJwt } from './auth.js';

export const BSKY_APPVIEW_URL = 'https://api.bsky.app';
const BSKY_CHAT_URL = 'https://api.bsky.chat';

/**
 * Parse atproto-proxy header to get service DID and service ID
 * Format: "did:web:api.bsky.app#bsky_appview"
 * @param {string} header
 * @returns {{ did: string, serviceId: string } | null}
 */
export function parseAtprotoProxyHeader(header) {
  if (!header) return null;
  const hashIndex = header.indexOf('#');
  if (hashIndex === -1 || hashIndex === 0 || hashIndex === header.length - 1) {
    return null;
  }
  return {
    did: header.slice(0, hashIndex),
    serviceId: header.slice(hashIndex + 1),
  };
}

/**
 * Fast path for the first-party services, mapping their DID + service id to a
 * configured base URL without a DID-document round trip — the AppView (feeds,
 * profiles) and the chat service (DMs). Any other service is resolved from its
 * DID document instead (see resolveServiceEndpoint); this returns null for
 * those so the caller falls through to resolution.
 * @param {string} did - Service DID (e.g., "did:web:api.bsky.chat")
 * @param {string} serviceId - Service ID (e.g., "bsky_chat")
 * @returns {string | null}
 */
export function getKnownServiceUrl(did, serviceId) {
  if (did === 'did:web:api.bsky.app' && serviceId === 'bsky_appview') {
    return BSKY_APPVIEW_URL;
  }
  if (did === 'did:web:api.bsky.chat' && serviceId === 'bsky_chat') {
    return BSKY_CHAT_URL;
  }
  return null;
}

/**
 * Proxy a request to a service
 * @param {Request} request - Original request
 * @param {string} serviceUrl - Target service URL
 * @param {string} [authHeader] - Optional Authorization header
 * @returns {Promise<Response>}
 */
export async function proxyToService(request, serviceUrl, authHeader) {
  const url = new URL(request.url);
  const targetUrl = new URL(url.pathname + url.search, serviceUrl);

  const headers = new Headers();
  if (authHeader) {
    headers.set('Authorization', authHeader);
  }
  headers.set(
    'Content-Type',
    request.headers.get('Content-Type') || 'application/json',
  );
  const acceptHeader = request.headers.get('Accept');
  if (acceptHeader) {
    headers.set('Accept', acceptHeader);
  }
  const acceptLangHeader = request.headers.get('Accept-Language');
  if (acceptLangHeader) {
    headers.set('Accept-Language', acceptLangHeader);
  }
  // Forward atproto-specific headers
  const labelersHeader = request.headers.get('atproto-accept-labelers');
  if (labelersHeader) {
    headers.set('atproto-accept-labelers', labelersHeader);
  }
  // The Bluesky client's own headers, whose names grow with the client. The
  // prefix matches them all, so a new one needs no change here. atproto-proxy
  // stays behind: this server read it to choose the target.
  for (const [name, value] of request.headers) {
    if (name.startsWith('x-bsky-')) headers.set(name, value);
  }

  try {
    const response = await fetch(targetUrl.toString(), {
      method: request.method,
      headers,
      body:
        request.method !== 'GET' && request.method !== 'HEAD'
          ? request.body
          : undefined,
    });
    const responseHeaders = new Headers(response.headers);
    responseHeaders.set('Access-Control-Allow-Origin', '*');
    return new Response(response.body, {
      status: response.status,
      statusText: response.statusText,
      headers: responseHeaders,
    });
  } catch (err) {
    const message = err instanceof Error ? err.message : String(err);
    return Response.json(
      {
        error: 'UpstreamFailure',
        message: `Failed to reach service: ${message}`,
      },
      { status: 502 },
    );
  }
}

/**
 * @typedef {Object} ProxyContext
 * @property {string} appviewUrl
 * @property {string} [appviewDid]
 * @property {import('./ports.js').DidResolverPort|null} didResolver - Absent, only the first-party services resolve
 * @property {() => Promise<import('./crypto.js').SigningKey|null>} getSigningKey
 * @property {(request: Request) => Promise<{did: string, scope: string}|null>} authenticate
 * @property {(auth: {did: string, scope: string}, lxm: string, aud: string) => Response|null} checkRpcScope
 */

/**
 * @param {ProxyContext} ctx
 * @returns {{
 *   createServiceAuthForAppView: (did: string, lxm: string|null) => Promise<string>,
 *   handleAppViewProxy: (request: Request, userDid: string) => Promise<Response>,
 *   resolveServiceEndpoint: (did: string, serviceId: string) => Promise<string|null>,
 *   handleProxyHeader: (request: Request) => Promise<Response|null>,
 * }}
 */
export function createProxy(ctx) {
  const {
    appviewUrl,
    appviewDid,
    didResolver,
    getSigningKey,
    authenticate,
    checkRpcScope,
  } = ctx;

  /**
   * Create service auth JWT for AppView requests
   * @param {string} did - User DID
   * @param {string|null} lxm - Lexicon method being called
   * @returns {Promise<string>}
   */
  async function createServiceAuthForAppView(did, lxm) {
    const signingKey = await getSigningKey();
    if (!signingKey) throw new Error('No signing key available');
    return createServiceJwt({
      iss: did,
      aud: appviewDid || 'did:web:api.bsky.app',
      lxm,
      signingKey,
    });
  }

  /**
   * Proxy request to AppView with service auth
   * @param {Request} request
   * @param {string} userDid
   * @returns {Promise<Response>}
   */
  async function handleAppViewProxy(request, userDid) {
    const url = new URL(request.url);
    const lxm = url.pathname.replace('/xrpc/', '');
    const serviceJwt = await createServiceAuthForAppView(userDid, lxm);
    return proxyToService(request, appviewUrl, `Bearer ${serviceJwt}`);
  }

  /**
   * Resolve the base URL of a service named in an atproto-proxy header. The
   * first-party AppView and chat services use their configured URLs; anything
   * else — feed generators, labelers, custom services — is resolved from the
   * matching entry in its DID document.
   * @param {string} did
   * @param {string} serviceId
   * @returns {Promise<string|null>}
   */
  async function resolveServiceEndpoint(did, serviceId) {
    const known = getKnownServiceUrl(did, serviceId);
    if (known) return known;
    if (!didResolver) return null;

    let didDoc;
    try {
      didDoc = /** @type {any} */ (await didResolver(did));
    } catch {
      return null;
    }
    const services = Array.isArray(didDoc?.service) ? didDoc.service : [];
    const service = services.find(
      (/** @type {{id?: string}} */ s) =>
        typeof s?.id === 'string' &&
        (s.id === `#${serviceId}` || s.id === `${did}#${serviceId}`),
    );
    const endpoint =
      service && typeof service.serviceEndpoint === 'string'
        ? service.serviceEndpoint
        : null;
    // Only proxy to an https endpoint; drop any trailing slash for joining.
    if (!endpoint?.startsWith('https://')) return null;
    return endpoint.replace(/\/+$/, '');
  }

  /**
   * Handle atproto-proxy header for proxying to external services
   * @param {Request} request
   * @returns {Promise<Response|null>} Response if handled, null if not
   */
  async function handleProxyHeader(request) {
    const proxyHeader = request.headers.get('atproto-proxy');
    if (!proxyHeader) return null;

    const parsed = parseAtprotoProxyHeader(proxyHeader);
    if (!parsed) {
      return Response.json(
        {
          error: 'InvalidRequest',
          message: `Malformed atproto-proxy header: ${proxyHeader}`,
        },
        { status: 400 },
      );
    }

    const serviceUrl = await resolveServiceEndpoint(
      parsed.did,
      parsed.serviceId,
    );
    if (serviceUrl) {
      const url = new URL(request.url);
      const lxm = url.pathname.replace('/xrpc/', '');
      const auth = await authenticate(request);

      // An authenticated call is signed as the account, addressed to the service
      // the header named. An unauthenticated call is a public read, forwarded
      // unsigned.
      if (auth) {
        const scopeError = checkRpcScope(
          auth,
          lxm,
          `${parsed.did}#${parsed.serviceId}`,
        );
        if (scopeError) return scopeError;

        const signingKey = await getSigningKey();
        if (!signingKey) throw new Error('No signing key available');
        const serviceJwt = await createServiceJwt({
          iss: auth.did,
          aud: parsed.did,
          lxm,
          signingKey,
        });
        return proxyToService(request, serviceUrl, `Bearer ${serviceJwt}`);
      }
      return proxyToService(request, serviceUrl);
    }

    return Response.json(
      {
        error: 'InvalidRequest',
        message: `Unknown proxy service: ${proxyHeader}`,
      },
      { status: 400 },
    );
  }

  return {
    createServiceAuthForAppView,
    handleAppViewProxy,
    resolveServiceEndpoint,
    handleProxyHeader,
  };
}