A zero-dependency AT Protocol Personal Data Server written in JavaScript
atproto pds

Foreign DID Proxying Implementation Plan#

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Handle foreign DID requests by either (1) respecting atproto-proxy header, or (2) detecting foreign repo param and proxying to AppView.

Architecture: (matches official PDS)

  1. Check if repo is a local DID → handle locally (ignore atproto-proxy)
  2. If foreign DID with atproto-proxy header → proxy to specified service
  3. If foreign DID without header → proxy to AppView (default)

Tech Stack: Cloudflare Workers, Durable Objects, ATProto


Background#

When a client needs data from a foreign DID, it may:

  1. Send atproto-proxy: did:web:api.bsky.app#bsky_appview header (explicit)
  2. Just send repo=did:plc:foreign... without header (implicit)

Our PDS should handle both cases. Currently it ignores the header and always tries to find records locally.


Task 1: Add parseAtprotoProxyHeader Utility#

Files:

  • Modify: src/pds.js (after errorResponse function, around line 178)

Step 1: Add the utility function

/**
 * 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}
 */
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),
  };
}

Step 2: Commit

git add src/pds.js
git commit -m "feat: add parseAtprotoProxyHeader utility"

Task 2: Add getKnownServiceUrl Utility#

Files:

  • Modify: src/pds.js (after parseAtprotoProxyHeader)

Step 1: Add utility to resolve service URLs

/**
 * Get URL for a known service DID
 * @param {string} did - Service DID (e.g., "did:web:api.bsky.app")
 * @param {string} serviceId - Service ID (e.g., "bsky_appview")
 * @returns {string | null}
 */
function getKnownServiceUrl(did, serviceId) {
  // Known Bluesky services
  if (did === 'did:web:api.bsky.app' && serviceId === 'bsky_appview') {
    return 'https://api.bsky.app';
  }
  // Add more known services as needed
  return null;
}

Step 2: Commit

git add src/pds.js
git commit -m "feat: add getKnownServiceUrl utility"

Task 3: Add proxyToService Utility#

Files:

  • Modify: src/pds.js (after getKnownServiceUrl)

Step 1: Add the proxy utility function

/**
 * Proxy a request to a service
 * @param {Request} request - Original request
 * @param {string} serviceUrl - Target service URL (e.g., "https://api.bsky.app")
 * @param {string} [authHeader] - Optional Authorization header
 * @returns {Promise<Response>}
 */
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);
  }
  const topicsHeader = request.headers.get('x-bsky-topics');
  if (topicsHeader) {
    headers.set('x-bsky-topics', topicsHeader);
  }

  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 errorResponse('UpstreamFailure', `Failed to reach service: ${message}`, 502);
  }
}

Step 2: Commit

git add src/pds.js
git commit -m "feat: add proxyToService utility"

Task 4: Add isLocalDid Helper#

Files:

  • Modify: src/pds.js (after proxyToService)

Step 1: Add helper to check if DID is registered locally

/**
 * Check if a DID is registered on this PDS
 * @param {Env} env
 * @param {string} did
 * @returns {Promise<boolean>}
 */
async function isLocalDid(env, did) {
  const defaultPds = getDefaultPds(env);
  const res = await defaultPds.fetch(
    new Request('http://internal/get-registered-dids'),
  );
  if (!res.ok) return false;
  const { dids } = await res.json();
  return dids.includes(did);
}

Step 2: Commit

git add src/pds.js
git commit -m "feat: add isLocalDid helper"

Task 5: Refactor handleAppViewProxy to Use proxyToService#

Files:

  • Modify: src/pds.js:2725-2782 (handleAppViewProxy in PersonalDataServer class)

Step 1: Refactor the method

Replace with:

  /**
   * @param {Request} request
   * @param {string} userDid
   */
  async handleAppViewProxy(request, userDid) {
    const url = new URL(request.url);
    const lxm = url.pathname.replace('/xrpc/', '');
    const serviceJwt = await this.createServiceAuthForAppView(userDid, lxm);
    return proxyToService(request, 'https://api.bsky.app', `Bearer ${serviceJwt}`);
  }

Step 2: Run existing tests

npm test

Expected: All tests pass

Step 3: Commit

git add src/pds.js
git commit -m "refactor: simplify handleAppViewProxy using proxyToService"

Task 6: Handle Foreign Repo with atproto-proxy Support in Worker Routing#

Files:

  • Modify: src/pds.js in handleRequest function (around line 5199)

Step 1: Update repo endpoints routing to match official PDS behavior

Find the repo endpoints routing block and REPLACE the entire block.

Order of operations (matches official PDS):

  1. Check if repo is local → return local data
  2. If foreign → check atproto-proxy header for specific service
  3. If no header → default to AppView
  // Repo endpoints use ?repo= param instead of ?did=
  if (
    url.pathname === '/xrpc/com.atproto.repo.describeRepo' ||
    url.pathname === '/xrpc/com.atproto.repo.listRecords' ||
    url.pathname === '/xrpc/com.atproto.repo.getRecord'
  ) {
    const repo = url.searchParams.get('repo');
    if (!repo) {
      return errorResponse('InvalidRequest', 'missing repo param', 400);
    }

    // Check if this is a local DID - if so, handle locally
    const isLocal = await isLocalDid(env, repo);
    if (isLocal) {
      const id = env.PDS.idFromName(repo);
      const pds = env.PDS.get(id);
      return pds.fetch(request);
    }

    // Foreign DID - check for atproto-proxy header
    const proxyHeader = request.headers.get('atproto-proxy');
    if (proxyHeader) {
      const parsed = parseAtprotoProxyHeader(proxyHeader);
      if (parsed) {
        const serviceUrl = getKnownServiceUrl(parsed.did, parsed.serviceId);
        if (serviceUrl) {
          return proxyToService(request, serviceUrl);
        }
        // Unknown service - could add DID resolution here in the future
        return errorResponse('InvalidRequest', `Unknown proxy service: ${proxyHeader}`, 400);
      }
    }

    // No header - default to AppView
    return proxyToService(request, 'https://api.bsky.app');
  }

Step 2: Run existing tests

npm test

Expected: All tests pass

Step 3: Commit

git add src/pds.js
git commit -m "feat: handle atproto-proxy header and foreign repo proxying"

Task 7: Add E2E Tests#

Files:

  • Modify: test/e2e.test.js

Step 1: Add tests for proxy functionality

Add a new describe block:

  describe('Foreign DID proxying', () => {
    it('proxies to AppView when atproto-proxy header present', async () => {
      // Use a known public post from Bluesky (bsky.app official account)
      const res = await fetch(
        `${BASE}/xrpc/com.atproto.repo.getRecord?repo=did:plc:z72i7hdynmk6r22z27h6tvur&collection=app.bsky.feed.post&rkey=3juzlwllznd24`,
        {
          headers: {
            'atproto-proxy': 'did:web:api.bsky.app#bsky_appview',
          },
        },
      );
      // Should get response from AppView, not local 404
      assert.ok(
        res.status === 200 || res.status === 400,
        `Expected 200 or 400 from AppView, got ${res.status}`,
      );
    });

    it('proxies to AppView for foreign repo without header', async () => {
      // Foreign DID without atproto-proxy header - should still proxy
      const res = await fetch(
        `${BASE}/xrpc/com.atproto.repo.getRecord?repo=did:plc:z72i7hdynmk6r22z27h6tvur&collection=app.bsky.feed.post&rkey=3juzlwllznd24`,
      );
      // Should get response from AppView, not local 404
      assert.ok(
        res.status === 200 || res.status === 400,
        `Expected 200 or 400 from AppView, got ${res.status}`,
      );
    });

    it('returns error for unknown proxy service', async () => {
      const res = await fetch(
        `${BASE}/xrpc/com.atproto.repo.getRecord?repo=did:plc:test&collection=test&rkey=test`,
        {
          headers: {
            'atproto-proxy': 'did:web:unknown.service#unknown',
          },
        },
      );
      assert.strictEqual(res.status, 400);
      const data = await res.json();
      assert.ok(data.message.includes('Unknown proxy service'));
    });

    it('returns local record for local DID without proxy header', async () => {
      // Create a record first
      const { data: created } = await jsonPost(
        '/xrpc/com.atproto.repo.createRecord',
        {
          repo: DID,
          collection: 'app.bsky.feed.post',
          record: {
            $type: 'app.bsky.feed.post',
            text: 'Test post for local DID test',
            createdAt: new Date().toISOString(),
          },
        },
        { Authorization: `Bearer ${token}` },
      );

      // Fetch without proxy header - should get local record
      const rkey = created.uri.split('/').pop();
      const res = await fetch(
        `${BASE}/xrpc/com.atproto.repo.getRecord?repo=${DID}&collection=app.bsky.feed.post&rkey=${rkey}`,
      );
      assert.strictEqual(res.status, 200);
      const data = await res.json();
      assert.ok(data.value.text.includes('Test post for local DID test'));
    });

    it('describeRepo proxies for foreign DID', async () => {
      const res = await fetch(
        `${BASE}/xrpc/com.atproto.repo.describeRepo?repo=did:plc:z72i7hdynmk6r22z27h6tvur`,
      );
      // Should get response from AppView
      assert.ok(res.status === 200 || res.status === 400);
    });

    it('listRecords proxies for foreign DID', async () => {
      const res = await fetch(
        `${BASE}/xrpc/com.atproto.repo.listRecords?repo=did:plc:z72i7hdynmk6r22z27h6tvur&collection=app.bsky.feed.post&limit=1`,
      );
      // Should get response from AppView
      assert.ok(res.status === 200 || res.status === 400);
    });
  });

Step 2: Run the tests

npm test

Expected: All tests pass

Step 3: Commit

git add test/e2e.test.js
git commit -m "test: add e2e tests for foreign DID proxying"

Task 8: Manual Verification#

Step 1: Deploy to dev

npx wrangler deploy

Step 2: Test with the original failing curl (with header)

curl 'https://chad-pds.chad-53c.workers.dev/xrpc/com.atproto.repo.getRecord?collection=app.bsky.feed.post&repo=did%3Aplc%3Abcgltzqazw5tb6k2g3ttenbj&rkey=3mbx6iyfqps2c' \
  -H 'atproto-proxy: did:web:api.bsky.app#bsky_appview'

Expected: Returns post data from AppView

Step 3: Test without header (foreign repo detection)

curl 'https://chad-pds.chad-53c.workers.dev/xrpc/com.atproto.repo.getRecord?collection=app.bsky.feed.post&repo=did%3Aplc%3Abcgltzqazw5tb6k2g3ttenbj&rkey=3mbx6iyfqps2c'

Expected: Also returns post data from AppView (detected as foreign DID)

Step 4: Test replying to a post in Bluesky client

Verify the original issue is fixed.


Future Enhancements#

  1. Service auth for proxied requests - Add service JWT when proxying authenticated requests
  2. DID resolution - Resolve unknown DIDs to find their service endpoints dynamically
  3. Caching - Cache registered DIDs list to avoid repeated lookups

Summary#

Task Description
1 Add parseAtprotoProxyHeader utility
2 Add getKnownServiceUrl utility
3 Add proxyToService utility
4 Add isLocalDid helper
5 Refactor handleAppViewProxy to use shared utility
6 Handle atproto-proxy header AND foreign repo param
7 Add e2e tests
8 Manual verification