A minimal AT Protocol Personal Data Server written in JavaScript.
atproto pds
46
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 480 lines 13 kB view raw view rendered
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 |