this repo has no description
1# Foreign DID Proxying Implementation Plan
2
3> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
4
5**Goal:** Handle foreign DID requests by either (1) respecting `atproto-proxy` header, or (2) detecting foreign `repo` param and proxying to AppView.
6
7**Architecture:** (matches official PDS)
81. Check if `repo` is a local DID → handle locally (ignore atproto-proxy)
92. If foreign DID with `atproto-proxy` header → proxy to specified service
103. If foreign DID without header → proxy to AppView (default)
11
12**Tech Stack:** Cloudflare Workers, Durable Objects, ATProto
13
14---
15
16## Background
17
18When a client needs data from a foreign DID, it may:
191. Send `atproto-proxy: did:web:api.bsky.app#bsky_appview` header (explicit)
202. Just send `repo=did:plc:foreign...` without header (implicit)
21
22Our PDS should handle both cases. Currently it ignores the header and always tries to find records locally.
23
24---
25
26### Task 1: Add parseAtprotoProxyHeader Utility
27
28**Files:**
29- Modify: `src/pds.js` (after errorResponse function, around line 178)
30
31**Step 1: Add the utility function**
32
33```javascript
34/**
35 * Parse atproto-proxy header to get service DID and service ID
36 * Format: "did:web:api.bsky.app#bsky_appview"
37 * @param {string} header
38 * @returns {{ did: string, serviceId: string } | null}
39 */
40function parseAtprotoProxyHeader(header) {
41 if (!header) return null;
42 const hashIndex = header.indexOf('#');
43 if (hashIndex === -1 || hashIndex === 0 || hashIndex === header.length - 1) {
44 return null;
45 }
46 return {
47 did: header.slice(0, hashIndex),
48 serviceId: header.slice(hashIndex + 1),
49 };
50}
51```
52
53**Step 2: Commit**
54
55```bash
56git add src/pds.js
57git commit -m "feat: add parseAtprotoProxyHeader utility"
58```
59
60---
61
62### Task 2: Add getKnownServiceUrl Utility
63
64**Files:**
65- Modify: `src/pds.js` (after parseAtprotoProxyHeader)
66
67**Step 1: Add utility to resolve service URLs**
68
69```javascript
70/**
71 * Get URL for a known service DID
72 * @param {string} did - Service DID (e.g., "did:web:api.bsky.app")
73 * @param {string} serviceId - Service ID (e.g., "bsky_appview")
74 * @returns {string | null}
75 */
76function getKnownServiceUrl(did, serviceId) {
77 // Known Bluesky services
78 if (did === 'did:web:api.bsky.app' && serviceId === 'bsky_appview') {
79 return 'https://api.bsky.app';
80 }
81 // Add more known services as needed
82 return null;
83}
84```
85
86**Step 2: Commit**
87
88```bash
89git add src/pds.js
90git commit -m "feat: add getKnownServiceUrl utility"
91```
92
93---
94
95### Task 3: Add proxyToService Utility
96
97**Files:**
98- Modify: `src/pds.js` (after getKnownServiceUrl)
99
100**Step 1: Add the proxy utility function**
101
102```javascript
103/**
104 * Proxy a request to a service
105 * @param {Request} request - Original request
106 * @param {string} serviceUrl - Target service URL (e.g., "https://api.bsky.app")
107 * @param {string} [authHeader] - Optional Authorization header
108 * @returns {Promise<Response>}
109 */
110async function proxyToService(request, serviceUrl, authHeader) {
111 const url = new URL(request.url);
112 const targetUrl = new URL(url.pathname + url.search, serviceUrl);
113
114 const headers = new Headers();
115 if (authHeader) {
116 headers.set('Authorization', authHeader);
117 }
118 headers.set(
119 'Content-Type',
120 request.headers.get('Content-Type') || 'application/json',
121 );
122 const acceptHeader = request.headers.get('Accept');
123 if (acceptHeader) {
124 headers.set('Accept', acceptHeader);
125 }
126 const acceptLangHeader = request.headers.get('Accept-Language');
127 if (acceptLangHeader) {
128 headers.set('Accept-Language', acceptLangHeader);
129 }
130 // Forward atproto-specific headers
131 const labelersHeader = request.headers.get('atproto-accept-labelers');
132 if (labelersHeader) {
133 headers.set('atproto-accept-labelers', labelersHeader);
134 }
135 const topicsHeader = request.headers.get('x-bsky-topics');
136 if (topicsHeader) {
137 headers.set('x-bsky-topics', topicsHeader);
138 }
139
140 try {
141 const response = await fetch(targetUrl.toString(), {
142 method: request.method,
143 headers,
144 body:
145 request.method !== 'GET' && request.method !== 'HEAD'
146 ? request.body
147 : undefined,
148 });
149 const responseHeaders = new Headers(response.headers);
150 responseHeaders.set('Access-Control-Allow-Origin', '*');
151 return new Response(response.body, {
152 status: response.status,
153 statusText: response.statusText,
154 headers: responseHeaders,
155 });
156 } catch (err) {
157 const message = err instanceof Error ? err.message : String(err);
158 return errorResponse('UpstreamFailure', `Failed to reach service: ${message}`, 502);
159 }
160}
161```
162
163**Step 2: Commit**
164
165```bash
166git add src/pds.js
167git commit -m "feat: add proxyToService utility"
168```
169
170---
171
172### Task 4: Add isLocalDid Helper
173
174**Files:**
175- Modify: `src/pds.js` (after proxyToService)
176
177**Step 1: Add helper to check if DID is registered locally**
178
179```javascript
180/**
181 * Check if a DID is registered on this PDS
182 * @param {Env} env
183 * @param {string} did
184 * @returns {Promise<boolean>}
185 */
186async function isLocalDid(env, did) {
187 const defaultPds = getDefaultPds(env);
188 const res = await defaultPds.fetch(
189 new Request('http://internal/get-registered-dids'),
190 );
191 if (!res.ok) return false;
192 const { dids } = await res.json();
193 return dids.includes(did);
194}
195```
196
197**Step 2: Commit**
198
199```bash
200git add src/pds.js
201git commit -m "feat: add isLocalDid helper"
202```
203
204---
205
206### Task 5: Refactor handleAppViewProxy to Use proxyToService
207
208**Files:**
209- Modify: `src/pds.js:2725-2782` (handleAppViewProxy in PersonalDataServer class)
210
211**Step 1: Refactor the method**
212
213Replace with:
214
215```javascript
216 /**
217 * @param {Request} request
218 * @param {string} userDid
219 */
220 async handleAppViewProxy(request, userDid) {
221 const url = new URL(request.url);
222 const lxm = url.pathname.replace('/xrpc/', '');
223 const serviceJwt = await this.createServiceAuthForAppView(userDid, lxm);
224 return proxyToService(request, 'https://api.bsky.app', `Bearer ${serviceJwt}`);
225 }
226```
227
228**Step 2: Run existing tests**
229
230```bash
231npm test
232```
233
234Expected: All tests pass
235
236**Step 3: Commit**
237
238```bash
239git add src/pds.js
240git commit -m "refactor: simplify handleAppViewProxy using proxyToService"
241```
242
243---
244
245### Task 6: Handle Foreign Repo with atproto-proxy Support in Worker Routing
246
247**Files:**
248- Modify: `src/pds.js` in `handleRequest` function (around line 5199)
249
250**Step 1: Update repo endpoints routing to match official PDS behavior**
251
252Find the repo endpoints routing block and REPLACE the entire block.
253
254Order of operations (matches official PDS):
2551. Check if repo is local → return local data
2562. If foreign → check atproto-proxy header for specific service
2573. If no header → default to AppView
258
259```javascript
260 // Repo endpoints use ?repo= param instead of ?did=
261 if (
262 url.pathname === '/xrpc/com.atproto.repo.describeRepo' ||
263 url.pathname === '/xrpc/com.atproto.repo.listRecords' ||
264 url.pathname === '/xrpc/com.atproto.repo.getRecord'
265 ) {
266 const repo = url.searchParams.get('repo');
267 if (!repo) {
268 return errorResponse('InvalidRequest', 'missing repo param', 400);
269 }
270
271 // Check if this is a local DID - if so, handle locally
272 const isLocal = await isLocalDid(env, repo);
273 if (isLocal) {
274 const id = env.PDS.idFromName(repo);
275 const pds = env.PDS.get(id);
276 return pds.fetch(request);
277 }
278
279 // Foreign DID - check for atproto-proxy header
280 const proxyHeader = request.headers.get('atproto-proxy');
281 if (proxyHeader) {
282 const parsed = parseAtprotoProxyHeader(proxyHeader);
283 if (parsed) {
284 const serviceUrl = getKnownServiceUrl(parsed.did, parsed.serviceId);
285 if (serviceUrl) {
286 return proxyToService(request, serviceUrl);
287 }
288 // Unknown service - could add DID resolution here in the future
289 return errorResponse('InvalidRequest', `Unknown proxy service: ${proxyHeader}`, 400);
290 }
291 }
292
293 // No header - default to AppView
294 return proxyToService(request, 'https://api.bsky.app');
295 }
296```
297
298**Step 2: Run existing tests**
299
300```bash
301npm test
302```
303
304Expected: All tests pass
305
306**Step 3: Commit**
307
308```bash
309git add src/pds.js
310git commit -m "feat: handle atproto-proxy header and foreign repo proxying"
311```
312
313---
314
315### Task 7: Add E2E Tests
316
317**Files:**
318- Modify: `test/e2e.test.js`
319
320**Step 1: Add tests for proxy functionality**
321
322Add a new describe block:
323
324```javascript
325 describe('Foreign DID proxying', () => {
326 it('proxies to AppView when atproto-proxy header present', async () => {
327 // Use a known public post from Bluesky (bsky.app official account)
328 const res = await fetch(
329 `${BASE}/xrpc/com.atproto.repo.getRecord?repo=did:plc:z72i7hdynmk6r22z27h6tvur&collection=app.bsky.feed.post&rkey=3juzlwllznd24`,
330 {
331 headers: {
332 'atproto-proxy': 'did:web:api.bsky.app#bsky_appview',
333 },
334 },
335 );
336 // Should get response from AppView, not local 404
337 assert.ok(
338 res.status === 200 || res.status === 400,
339 `Expected 200 or 400 from AppView, got ${res.status}`,
340 );
341 });
342
343 it('proxies to AppView for foreign repo without header', async () => {
344 // Foreign DID without atproto-proxy header - should still proxy
345 const res = await fetch(
346 `${BASE}/xrpc/com.atproto.repo.getRecord?repo=did:plc:z72i7hdynmk6r22z27h6tvur&collection=app.bsky.feed.post&rkey=3juzlwllznd24`,
347 );
348 // Should get response from AppView, not local 404
349 assert.ok(
350 res.status === 200 || res.status === 400,
351 `Expected 200 or 400 from AppView, got ${res.status}`,
352 );
353 });
354
355 it('returns error for unknown proxy service', async () => {
356 const res = await fetch(
357 `${BASE}/xrpc/com.atproto.repo.getRecord?repo=did:plc:test&collection=test&rkey=test`,
358 {
359 headers: {
360 'atproto-proxy': 'did:web:unknown.service#unknown',
361 },
362 },
363 );
364 assert.strictEqual(res.status, 400);
365 const data = await res.json();
366 assert.ok(data.message.includes('Unknown proxy service'));
367 });
368
369 it('returns local record for local DID without proxy header', async () => {
370 // Create a record first
371 const { data: created } = await jsonPost(
372 '/xrpc/com.atproto.repo.createRecord',
373 {
374 repo: DID,
375 collection: 'app.bsky.feed.post',
376 record: {
377 $type: 'app.bsky.feed.post',
378 text: 'Test post for local DID test',
379 createdAt: new Date().toISOString(),
380 },
381 },
382 { Authorization: `Bearer ${token}` },
383 );
384
385 // Fetch without proxy header - should get local record
386 const rkey = created.uri.split('/').pop();
387 const res = await fetch(
388 `${BASE}/xrpc/com.atproto.repo.getRecord?repo=${DID}&collection=app.bsky.feed.post&rkey=${rkey}`,
389 );
390 assert.strictEqual(res.status, 200);
391 const data = await res.json();
392 assert.ok(data.value.text.includes('Test post for local DID test'));
393 });
394
395 it('describeRepo proxies for foreign DID', async () => {
396 const res = await fetch(
397 `${BASE}/xrpc/com.atproto.repo.describeRepo?repo=did:plc:z72i7hdynmk6r22z27h6tvur`,
398 );
399 // Should get response from AppView
400 assert.ok(res.status === 200 || res.status === 400);
401 });
402
403 it('listRecords proxies for foreign DID', async () => {
404 const res = await fetch(
405 `${BASE}/xrpc/com.atproto.repo.listRecords?repo=did:plc:z72i7hdynmk6r22z27h6tvur&collection=app.bsky.feed.post&limit=1`,
406 );
407 // Should get response from AppView
408 assert.ok(res.status === 200 || res.status === 400);
409 });
410 });
411```
412
413**Step 2: Run the tests**
414
415```bash
416npm test
417```
418
419Expected: All tests pass
420
421**Step 3: Commit**
422
423```bash
424git add test/e2e.test.js
425git commit -m "test: add e2e tests for foreign DID proxying"
426```
427
428---
429
430### Task 8: Manual Verification
431
432**Step 1: Deploy to dev**
433
434```bash
435npx wrangler deploy
436```
437
438**Step 2: Test with the original failing curl (with header)**
439
440```bash
441curl 'https://chad-pds.chad-53c.workers.dev/xrpc/com.atproto.repo.getRecord?collection=app.bsky.feed.post&repo=did%3Aplc%3Abcgltzqazw5tb6k2g3ttenbj&rkey=3mbx6iyfqps2c' \
442 -H 'atproto-proxy: did:web:api.bsky.app#bsky_appview'
443```
444
445Expected: Returns post data from AppView
446
447**Step 3: Test without header (foreign repo detection)**
448
449```bash
450curl 'https://chad-pds.chad-53c.workers.dev/xrpc/com.atproto.repo.getRecord?collection=app.bsky.feed.post&repo=did%3Aplc%3Abcgltzqazw5tb6k2g3ttenbj&rkey=3mbx6iyfqps2c'
451```
452
453Expected: Also returns post data from AppView (detected as foreign DID)
454
455**Step 4: Test replying to a post in Bluesky client**
456
457Verify the original issue is fixed.
458
459---
460
461## Future Enhancements
462
4631. **Service auth for proxied requests** - Add service JWT when proxying authenticated requests
4642. **DID resolution** - Resolve unknown DIDs to find their service endpoints dynamically
4653. **Caching** - Cache registered DIDs list to avoid repeated lookups
466
467---
468
469## Summary
470
471| Task | Description |
472|------|-------------|
473| 1 | Add `parseAtprotoProxyHeader` utility |
474| 2 | Add `getKnownServiceUrl` utility |
475| 3 | Add `proxyToService` utility |
476| 4 | Add `isLocalDid` helper |
477| 5 | Refactor `handleAppViewProxy` to use shared utility |
478| 6 | Handle `atproto-proxy` header AND foreign `repo` param |
479| 7 | Add e2e tests |
480| 8 | Manual verification |