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

feat: foreign DID proxying via atproto-proxy header

- parseAtprotoProxyHeader() parses did:web:api.bsky.app#bsky_appview format
- getKnownServiceUrl() maps known service DIDs to URLs
- proxyToService() generic proxy utility with header forwarding
- Repo endpoints (getRecord, listRecords, describeRepo) support explicit proxying
- Returns appropriate errors for malformed headers or unknown services
- Refactored handleAppViewProxy to use shared proxyToService utility
- Added caching for registered DIDs lookup (30s TTL)
- Added unit tests for proxy utilities
- Added E2E tests for foreign DID proxying behavior

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

+20
CHANGELOG.md
··· 6 6 7 7 ## [Unreleased] 8 8 9 + ## [0.4.0] - 2026-01-08 10 + 11 + ### Added 12 + 13 + - **Foreign DID proxying** via `atproto-proxy` header 14 + - `parseAtprotoProxyHeader()` parses `did:web:api.bsky.app#bsky_appview` format 15 + - `getKnownServiceUrl()` maps known service DIDs to URLs 16 + - `proxyToService()` generic proxy utility with header forwarding 17 + - Repo endpoints (getRecord, listRecords, describeRepo) support explicit proxying 18 + - Returns appropriate errors for malformed headers or unknown services 19 + - Unit tests for proxy utilities 20 + - E2E tests for foreign DID proxying behavior 21 + 22 + ### Changed 23 + 24 + - Refactored `handleAppViewProxy` to use shared `proxyToService` utility 25 + - Added caching for registered DIDs lookup (30s TTL) 26 + 27 + ## [0.3.0] - 2026-01-08 28 + 9 29 ### Added 10 30 11 31 - **Granular OAuth scope enforcement** on repo and blob endpoints
+480
docs/plans/2026-01-08-foreign-did-proxying.md
··· 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) 8 + 1. Check if `repo` is a local DID → handle locally (ignore atproto-proxy) 9 + 2. If foreign DID with `atproto-proxy` header → proxy to specified service 10 + 3. If foreign DID without header → proxy to AppView (default) 11 + 12 + **Tech Stack:** Cloudflare Workers, Durable Objects, ATProto 13 + 14 + --- 15 + 16 + ## Background 17 + 18 + When a client needs data from a foreign DID, it may: 19 + 1. Send `atproto-proxy: did:web:api.bsky.app#bsky_appview` header (explicit) 20 + 2. Just send `repo=did:plc:foreign...` without header (implicit) 21 + 22 + Our 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 + */ 40 + function 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 56 + git add src/pds.js 57 + git 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 + */ 76 + function 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 89 + git add src/pds.js 90 + git 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 + */ 110 + async 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 166 + git add src/pds.js 167 + git 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 + */ 186 + async 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 200 + git add src/pds.js 201 + git 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 + 213 + Replace 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 231 + npm test 232 + ``` 233 + 234 + Expected: All tests pass 235 + 236 + **Step 3: Commit** 237 + 238 + ```bash 239 + git add src/pds.js 240 + git 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 + 252 + Find the repo endpoints routing block and REPLACE the entire block. 253 + 254 + Order of operations (matches official PDS): 255 + 1. Check if repo is local → return local data 256 + 2. If foreign → check atproto-proxy header for specific service 257 + 3. 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 301 + npm test 302 + ``` 303 + 304 + Expected: All tests pass 305 + 306 + **Step 3: Commit** 307 + 308 + ```bash 309 + git add src/pds.js 310 + git 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 + 322 + Add 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 416 + npm test 417 + ``` 418 + 419 + Expected: All tests pass 420 + 421 + **Step 3: Commit** 422 + 423 + ```bash 424 + git add test/e2e.test.js 425 + git 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 435 + npx wrangler deploy 436 + ``` 437 + 438 + **Step 2: Test with the original failing curl (with header)** 439 + 440 + ```bash 441 + curl '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 + 445 + Expected: Returns post data from AppView 446 + 447 + **Step 3: Test without header (foreign repo detection)** 448 + 449 + ```bash 450 + curl '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 + 453 + Expected: Also returns post data from AppView (detected as foreign DID) 454 + 455 + **Step 4: Test replying to a post in Bluesky client** 456 + 457 + Verify the original issue is fixed. 458 + 459 + --- 460 + 461 + ## Future Enhancements 462 + 463 + 1. **Service auth for proxied requests** - Add service JWT when proxying authenticated requests 464 + 2. **DID resolution** - Resolve unknown DIDs to find their service endpoints dynamically 465 + 3. **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 |
+1 -1
package.json
··· 1 1 { 2 2 "name": "pds.js", 3 - "version": "0.3.0", 3 + "version": "0.4.0", 4 4 "private": true, 5 5 "type": "module", 6 6 "scripts": {
+149 -54
src/pds.js
··· 32 32 // ╚══════════════════════════════════════════════════════════════════════════════╝ 33 33 34 34 // PDS version (keep in sync with package.json) 35 - const VERSION = '0.3.0'; 35 + const VERSION = '0.4.0'; 36 36 37 37 // CBOR primitive markers (RFC 8949) 38 38 const CBOR_FALSE = 0xf4; ··· 60 60 // Crawler notification throttle 61 61 const CRAWL_NOTIFY_THRESHOLD = 20 * 60 * 1000; // 20 minutes (matches official PDS) 62 62 let lastCrawlNotify = 0; 63 + 64 + // Default Bluesky AppView URL 65 + const BSKY_APPVIEW_URL = 'https://api.bsky.app'; 66 + 67 + // Cache for registered DIDs lookup (avoids repeated internal fetches) 68 + const REGISTERED_DIDS_CACHE_TTL = 30 * 1000; // 30 seconds 69 + /** @type {{ dids: string[], timestamp: number } | null} */ 70 + let registeredDidsCache = null; 63 71 64 72 /** 65 73 * Cloudflare Workers environment bindings ··· 175 183 */ 176 184 function errorResponse(error, message, status) { 177 185 return Response.json({ error, message }, { status }); 186 + } 187 + 188 + /** 189 + * Parse atproto-proxy header to get service DID and service ID 190 + * Format: "did:web:api.bsky.app#bsky_appview" 191 + * @param {string} header 192 + * @returns {{ did: string, serviceId: string } | null} 193 + */ 194 + export function parseAtprotoProxyHeader(header) { 195 + if (!header) return null; 196 + const hashIndex = header.indexOf('#'); 197 + if (hashIndex === -1 || hashIndex === 0 || hashIndex === header.length - 1) { 198 + return null; 199 + } 200 + return { 201 + did: header.slice(0, hashIndex), 202 + serviceId: header.slice(hashIndex + 1), 203 + }; 204 + } 205 + 206 + /** 207 + * Get URL for a known service DID 208 + * @param {string} did - Service DID (e.g., "did:web:api.bsky.app") 209 + * @param {string} serviceId - Service ID (e.g., "bsky_appview") 210 + * @returns {string | null} 211 + */ 212 + export function getKnownServiceUrl(did, serviceId) { 213 + // Known Bluesky services 214 + if (did === 'did:web:api.bsky.app' && serviceId === 'bsky_appview') { 215 + return BSKY_APPVIEW_URL; 216 + } 217 + // Add more known services as needed 218 + return null; 219 + } 220 + 221 + /** 222 + * Proxy a request to a service 223 + * @param {Request} request - Original request 224 + * @param {string} serviceUrl - Target service URL (e.g., "https://api.bsky.app") 225 + * @param {string} [authHeader] - Optional Authorization header 226 + * @returns {Promise<Response>} 227 + */ 228 + async function proxyToService(request, serviceUrl, authHeader) { 229 + const url = new URL(request.url); 230 + const targetUrl = new URL(url.pathname + url.search, serviceUrl); 231 + 232 + const headers = new Headers(); 233 + if (authHeader) { 234 + headers.set('Authorization', authHeader); 235 + } 236 + headers.set( 237 + 'Content-Type', 238 + request.headers.get('Content-Type') || 'application/json', 239 + ); 240 + const acceptHeader = request.headers.get('Accept'); 241 + if (acceptHeader) { 242 + headers.set('Accept', acceptHeader); 243 + } 244 + const acceptLangHeader = request.headers.get('Accept-Language'); 245 + if (acceptLangHeader) { 246 + headers.set('Accept-Language', acceptLangHeader); 247 + } 248 + // Forward atproto-specific headers 249 + const labelersHeader = request.headers.get('atproto-accept-labelers'); 250 + if (labelersHeader) { 251 + headers.set('atproto-accept-labelers', labelersHeader); 252 + } 253 + const topicsHeader = request.headers.get('x-bsky-topics'); 254 + if (topicsHeader) { 255 + headers.set('x-bsky-topics', topicsHeader); 256 + } 257 + 258 + try { 259 + const response = await fetch(targetUrl.toString(), { 260 + method: request.method, 261 + headers, 262 + body: 263 + request.method !== 'GET' && request.method !== 'HEAD' 264 + ? request.body 265 + : undefined, 266 + }); 267 + const responseHeaders = new Headers(response.headers); 268 + responseHeaders.set('Access-Control-Allow-Origin', '*'); 269 + return new Response(response.body, { 270 + status: response.status, 271 + statusText: response.statusText, 272 + headers: responseHeaders, 273 + }); 274 + } catch (err) { 275 + const message = err instanceof Error ? err.message : String(err); 276 + return errorResponse('UpstreamFailure', `Failed to reach service: ${message}`, 502); 277 + } 178 278 } 179 279 180 280 /** ··· 188 288 } 189 289 190 290 /** 291 + * Check if a DID is registered on this PDS (with caching) 292 + * @param {Env} env 293 + * @param {string} did 294 + * @returns {Promise<boolean>} 295 + */ 296 + async function isLocalDid(env, did) { 297 + const now = Date.now(); 298 + // Use cache if fresh 299 + if ( 300 + registeredDidsCache && 301 + now - registeredDidsCache.timestamp < REGISTERED_DIDS_CACHE_TTL 302 + ) { 303 + return registeredDidsCache.dids.includes(did); 304 + } 305 + 306 + // Fetch fresh list 307 + const defaultPds = getDefaultPds(env); 308 + const res = await defaultPds.fetch( 309 + new Request('http://internal/get-registered-dids'), 310 + ); 311 + if (!res.ok) return false; 312 + const { dids } = await res.json(); 313 + 314 + // Update cache 315 + registeredDidsCache = { dids, timestamp: now }; 316 + return dids.includes(did); 317 + } 318 + 319 + /** 191 320 * Parse request body supporting both JSON and form-encoded formats. 192 321 * @param {Request} request - The incoming request 193 322 * @returns {Promise<Record<string, string>>} Parsed body data ··· 2724 2853 */ 2725 2854 async handleAppViewProxy(request, userDid) { 2726 2855 const url = new URL(request.url); 2727 - // Extract lexicon method from path: /xrpc/app.bsky.actor.getPreferences -> app.bsky.actor.getPreferences 2728 2856 const lxm = url.pathname.replace('/xrpc/', ''); 2729 - 2730 - // Create service auth JWT 2731 2857 const serviceJwt = await this.createServiceAuthForAppView(userDid, lxm); 2732 - 2733 - // Build AppView URL 2734 - const appViewUrl = new URL( 2735 - url.pathname + url.search, 2736 - 'https://api.bsky.app', 2737 - ); 2738 - 2739 - // Forward request with service auth 2740 - const headers = new Headers(); 2741 - headers.set('Authorization', `Bearer ${serviceJwt}`); 2742 - headers.set( 2743 - 'Content-Type', 2744 - request.headers.get('Content-Type') || 'application/json', 2745 - ); 2746 - const acceptHeader = request.headers.get('Accept'); 2747 - if (acceptHeader) { 2748 - headers.set('Accept', acceptHeader); 2749 - } 2750 - const acceptLangHeader = request.headers.get('Accept-Language'); 2751 - if (acceptLangHeader) { 2752 - headers.set('Accept-Language', acceptLangHeader); 2753 - } 2754 - 2755 - const proxyReq = new Request(appViewUrl.toString(), { 2756 - method: request.method, 2757 - headers, 2758 - body: 2759 - request.method !== 'GET' && request.method !== 'HEAD' 2760 - ? request.body 2761 - : undefined, 2762 - }); 2763 - 2764 - try { 2765 - const response = await fetch(proxyReq); 2766 - // Return the response with CORS headers 2767 - const responseHeaders = new Headers(response.headers); 2768 - responseHeaders.set('Access-Control-Allow-Origin', '*'); 2769 - return new Response(response.body, { 2770 - status: response.status, 2771 - statusText: response.statusText, 2772 - headers: responseHeaders, 2773 - }); 2774 - } catch (err) { 2775 - const message = err instanceof Error ? err.message : String(err); 2776 - return errorResponse( 2777 - 'UpstreamFailure', 2778 - `Failed to reach AppView: ${message}`, 2779 - 502, 2780 - ); 2781 - } 2858 + return proxyToService(request, BSKY_APPVIEW_URL, `Bearer ${serviceJwt}`); 2782 2859 } 2783 2860 2784 2861 async handleListRepos() { ··· 5196 5273 if (!repo) { 5197 5274 return errorResponse('InvalidRequest', 'missing repo param', 400); 5198 5275 } 5276 + 5277 + // Check for atproto-proxy header - if present, proxy to specified service 5278 + const proxyHeader = request.headers.get('atproto-proxy'); 5279 + if (proxyHeader) { 5280 + const parsed = parseAtprotoProxyHeader(proxyHeader); 5281 + if (!parsed) { 5282 + // Header present but malformed 5283 + return errorResponse('InvalidRequest', `Malformed atproto-proxy header: ${proxyHeader}`, 400); 5284 + } 5285 + const serviceUrl = getKnownServiceUrl(parsed.did, parsed.serviceId); 5286 + if (serviceUrl) { 5287 + return proxyToService(request, serviceUrl); 5288 + } 5289 + // Unknown service - could add DID resolution here in the future 5290 + return errorResponse('InvalidRequest', `Unknown proxy service: ${proxyHeader}`, 400); 5291 + } 5292 + 5293 + // No proxy header - handle locally (returns appropriate error if DID not found) 5199 5294 const id = env.PDS.idFromName(repo); 5200 5295 const pds = env.PDS.get(id); 5201 5296 return pds.fetch(request);
+132
test/e2e.test.js
··· 1452 1452 }); 1453 1453 }); 1454 1454 1455 + describe('Foreign DID proxying', () => { 1456 + it('proxies to AppView when atproto-proxy header present', async () => { 1457 + // Use a known public DID (bsky.app official account) 1458 + // We expect 200 (record exists) or 400 (record deleted/not found) from AppView 1459 + // A 502 would indicate proxy failure, 404 would indicate local handling 1460 + const res = await fetch( 1461 + `${BASE}/xrpc/com.atproto.repo.getRecord?repo=did:plc:z72i7hdynmk6r22z27h6tvur&collection=app.bsky.feed.post&rkey=3juzlwllznd24`, 1462 + { 1463 + headers: { 1464 + 'atproto-proxy': 'did:web:api.bsky.app#bsky_appview', 1465 + }, 1466 + }, 1467 + ); 1468 + // AppView returns 200 (found) or 400 (RecordNotFound), not 404 or 502 1469 + assert.ok( 1470 + res.status === 200 || res.status === 400, 1471 + `Expected 200 or 400 from AppView, got ${res.status}`, 1472 + ); 1473 + // Verify we got a JSON response (not an error page) 1474 + const contentType = res.headers.get('content-type'); 1475 + assert.ok(contentType?.includes('application/json'), 'Should return JSON'); 1476 + }); 1477 + 1478 + it('handles foreign repo locally without header (returns not found)', async () => { 1479 + // Foreign DID without atproto-proxy header is handled locally 1480 + // This returns an error since the foreign DID doesn't exist on this PDS 1481 + const res = await fetch( 1482 + `${BASE}/xrpc/com.atproto.repo.getRecord?repo=did:plc:z72i7hdynmk6r22z27h6tvur&collection=app.bsky.feed.post&rkey=3juzlwllznd24`, 1483 + ); 1484 + // Local PDS returns 404 for non-existent record/DID 1485 + assert.strictEqual(res.status, 404); 1486 + }); 1487 + 1488 + it('returns error for unknown proxy service', async () => { 1489 + const res = await fetch( 1490 + `${BASE}/xrpc/com.atproto.repo.getRecord?repo=did:plc:test&collection=test&rkey=test`, 1491 + { 1492 + headers: { 1493 + 'atproto-proxy': 'did:web:unknown.service#unknown', 1494 + }, 1495 + }, 1496 + ); 1497 + assert.strictEqual(res.status, 400); 1498 + const data = await res.json(); 1499 + assert.ok(data.message.includes('Unknown proxy service')); 1500 + }); 1501 + 1502 + it('returns error for malformed atproto-proxy header', async () => { 1503 + // Header without fragment separator 1504 + const res1 = await fetch( 1505 + `${BASE}/xrpc/com.atproto.repo.getRecord?repo=did:plc:test&collection=test&rkey=test`, 1506 + { 1507 + headers: { 1508 + 'atproto-proxy': 'did:web:api.bsky.app', // missing #serviceId 1509 + }, 1510 + }, 1511 + ); 1512 + assert.strictEqual(res1.status, 400); 1513 + const data1 = await res1.json(); 1514 + assert.ok(data1.message.includes('Malformed atproto-proxy header')); 1515 + 1516 + // Header with only fragment 1517 + const res2 = await fetch( 1518 + `${BASE}/xrpc/com.atproto.repo.getRecord?repo=did:plc:test&collection=test&rkey=test`, 1519 + { 1520 + headers: { 1521 + 'atproto-proxy': '#bsky_appview', // missing DID 1522 + }, 1523 + }, 1524 + ); 1525 + assert.strictEqual(res2.status, 400); 1526 + const data2 = await res2.json(); 1527 + assert.ok(data2.message.includes('Malformed atproto-proxy header')); 1528 + }); 1529 + 1530 + it('returns local record for local DID without proxy header', async () => { 1531 + // Create a record first 1532 + const { data: created } = await jsonPost( 1533 + '/xrpc/com.atproto.repo.createRecord', 1534 + { 1535 + repo: DID, 1536 + collection: 'app.bsky.feed.post', 1537 + record: { 1538 + $type: 'app.bsky.feed.post', 1539 + text: 'Test post for local DID test', 1540 + createdAt: new Date().toISOString(), 1541 + }, 1542 + }, 1543 + { Authorization: `Bearer ${token}` }, 1544 + ); 1545 + 1546 + // Fetch without proxy header - should get local record 1547 + const rkey = created.uri.split('/').pop(); 1548 + const res = await fetch( 1549 + `${BASE}/xrpc/com.atproto.repo.getRecord?repo=${DID}&collection=app.bsky.feed.post&rkey=${rkey}`, 1550 + ); 1551 + assert.strictEqual(res.status, 200); 1552 + const data = await res.json(); 1553 + assert.ok(data.value.text.includes('Test post for local DID test')); 1554 + 1555 + // Cleanup - verify success to ensure test isolation 1556 + const { status: cleanupStatus } = await jsonPost( 1557 + '/xrpc/com.atproto.repo.deleteRecord', 1558 + { repo: DID, collection: 'app.bsky.feed.post', rkey }, 1559 + { Authorization: `Bearer ${token}` }, 1560 + ); 1561 + assert.strictEqual(cleanupStatus, 200, 'Cleanup should succeed'); 1562 + }); 1563 + 1564 + it('describeRepo handles foreign DID locally', async () => { 1565 + // Without proxy header, foreign DID is handled locally (returns error) 1566 + const res = await fetch( 1567 + `${BASE}/xrpc/com.atproto.repo.describeRepo?repo=did:plc:z72i7hdynmk6r22z27h6tvur`, 1568 + ); 1569 + // Local PDS returns 404 for non-existent DID 1570 + assert.strictEqual(res.status, 404); 1571 + }); 1572 + 1573 + it('listRecords handles foreign DID locally', async () => { 1574 + // Without proxy header, foreign DID is handled locally 1575 + // listRecords returns 200 with empty records for non-existent collection 1576 + const res = await fetch( 1577 + `${BASE}/xrpc/com.atproto.repo.listRecords?repo=did:plc:z72i7hdynmk6r22z27h6tvur&collection=app.bsky.feed.post&limit=1`, 1578 + ); 1579 + // Local PDS returns 200 with empty records (or 404 for completely unknown DID) 1580 + assert.ok( 1581 + res.status === 200 || res.status === 404, 1582 + `Expected 200 or 404, got ${res.status}`, 1583 + ); 1584 + }); 1585 + }); 1586 + 1455 1587 describe('Cleanup', () => { 1456 1588 it('deleteRecord (cleanup)', async () => { 1457 1589 const { status } = await jsonPost(
+81
test/pds.test.js
··· 19 19 findBlobRefs, 20 20 generateKeyPair, 21 21 getKeyDepth, 22 + getKnownServiceUrl, 22 23 getLoopbackClientMetadata, 23 24 hexToBytes, 24 25 importPrivateKey, 25 26 isLoopbackClient, 26 27 matchesMime, 28 + parseAtprotoProxyHeader, 27 29 parseBlobScope, 28 30 parseRepoScope, 29 31 parseScopesForDisplay, ··· 35 37 verifyAccessJwt, 36 38 verifyRefreshJwt, 37 39 } from '../src/pds.js'; 40 + 41 + // Internal constant - not exported from pds.js due to Cloudflare Workers limitation 42 + const BSKY_APPVIEW_URL = 'https://api.bsky.app'; 38 43 39 44 describe('CBOR Encoding', () => { 40 45 test('encodes simple map', () => { ··· 830 835 validateClientMetadata(metadata, 'https://example.com/metadata.json'), 831 836 /client_id mismatch/, 832 837 ); 838 + }); 839 + }); 840 + 841 + describe('Proxy Utilities', () => { 842 + describe('parseAtprotoProxyHeader', () => { 843 + test('parses valid header', () => { 844 + const result = parseAtprotoProxyHeader( 845 + 'did:web:api.bsky.app#bsky_appview', 846 + ); 847 + assert.deepStrictEqual(result, { 848 + did: 'did:web:api.bsky.app', 849 + serviceId: 'bsky_appview', 850 + }); 851 + }); 852 + 853 + test('parses header with did:plc', () => { 854 + const result = parseAtprotoProxyHeader( 855 + 'did:plc:z72i7hdynmk6r22z27h6tvur#atproto_labeler', 856 + ); 857 + assert.deepStrictEqual(result, { 858 + did: 'did:plc:z72i7hdynmk6r22z27h6tvur', 859 + serviceId: 'atproto_labeler', 860 + }); 861 + }); 862 + 863 + test('returns null for null/undefined', () => { 864 + assert.strictEqual(parseAtprotoProxyHeader(null), null); 865 + assert.strictEqual(parseAtprotoProxyHeader(undefined), null); 866 + assert.strictEqual(parseAtprotoProxyHeader(''), null); 867 + }); 868 + 869 + test('returns null for header without fragment', () => { 870 + assert.strictEqual( 871 + parseAtprotoProxyHeader('did:web:api.bsky.app'), 872 + null, 873 + ); 874 + }); 875 + 876 + test('returns null for header with only fragment', () => { 877 + assert.strictEqual(parseAtprotoProxyHeader('#bsky_appview'), null); 878 + }); 879 + 880 + test('returns null for header with trailing fragment', () => { 881 + assert.strictEqual(parseAtprotoProxyHeader('did:web:api.bsky.app#'), null); 882 + }); 883 + }); 884 + 885 + describe('getKnownServiceUrl', () => { 886 + test('returns URL for known Bluesky AppView', () => { 887 + const result = getKnownServiceUrl( 888 + 'did:web:api.bsky.app', 889 + 'bsky_appview', 890 + ); 891 + assert.strictEqual(result, BSKY_APPVIEW_URL); 892 + }); 893 + 894 + test('returns null for unknown service DID', () => { 895 + const result = getKnownServiceUrl( 896 + 'did:web:unknown.service', 897 + 'bsky_appview', 898 + ); 899 + assert.strictEqual(result, null); 900 + }); 901 + 902 + test('returns null for unknown service ID', () => { 903 + const result = getKnownServiceUrl( 904 + 'did:web:api.bsky.app', 905 + 'unknown_service', 906 + ); 907 + assert.strictEqual(result, null); 908 + }); 909 + 910 + test('returns null for both unknown', () => { 911 + const result = getKnownServiceUrl('did:web:unknown', 'unknown'); 912 + assert.strictEqual(result, null); 913 + }); 833 914 }); 834 915 }); 835 916