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)
- Check if
repois a local DID → handle locally (ignore atproto-proxy) - If foreign DID with
atproto-proxyheader → proxy to specified service - 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:
- Send
atproto-proxy: did:web:api.bsky.app#bsky_appviewheader (explicit) - 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.jsinhandleRequestfunction (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):
- Check if repo is local → return local data
- If foreign → check atproto-proxy header for specific service
- 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#
- Service auth for proxied requests - Add service JWT when proxying authenticated requests
- DID resolution - Resolve unknown DIDs to find their service endpoints dynamically
- 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 |