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 |