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

feat: add relay notification and comprehensive E2E tests

- Add notifyCrawlers() to notify relays after writes via requestCrawl
- Add worker-level routing for sync endpoints (getLatestCommit, getRepoStatus, getRepo, getRecord)
- Fix /init to register handles in default DO's handleMap
- Add self-contained E2E test suite (23 tests) covering:
- Server basics, identity, authentication
- Repo CRUD, applyWrites batch operations
- Sync endpoints (CAR file retrieval)
- Error handling (401, 403, 404)
- Move test credentials to .dev.vars (gitignored)
- Update .env.example with all config options

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

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

Changed files
+251 -1
src
test
+8
.env.example
··· 1 + # Cloudflare 1 2 CLOUDFLARE_API_TOKEN=your_api_token_here 3 + 4 + # PDS Authentication 5 + PDS_PASSWORD=your_pds_password_here 6 + JWT_SECRET=your_jwt_secret_here 7 + 8 + # Relay (optional - enables network sync) 9 + RELAY_HOST=https://bsky.network
+1
.gitignore
··· 2 2 node_modules/ 3 3 credentials-*.json 4 4 .env 5 + .dev.vars
+1
package.json
··· 7 7 "dev": "wrangler dev", 8 8 "deploy": "wrangler deploy", 9 9 "test": "node --test test/*.test.js", 10 + "test:e2e": "./test/e2e.sh", 10 11 "setup": "node scripts/setup.js" 11 12 }, 12 13 "devDependencies": {
+59 -1
src/pds.js
··· 28 28 return Response.json({ error, message }, { status }) 29 29 } 30 30 31 + // === CRAWLER NOTIFICATION === 32 + // Notify relays to come crawl us after writes (like official PDS) 33 + let lastCrawlNotify = 0 34 + const CRAWL_NOTIFY_THRESHOLD = 20 * 60 * 1000 // 20 minutes (matches official PDS) 35 + 36 + async function notifyCrawlers(env, hostname) { 37 + const now = Date.now() 38 + if (now - lastCrawlNotify < CRAWL_NOTIFY_THRESHOLD) { 39 + return // Throttle notifications 40 + } 41 + 42 + const relayHost = env.RELAY_HOST 43 + if (!relayHost) return 44 + 45 + lastCrawlNotify = now 46 + 47 + // Fire and forget - don't block writes on relay notification 48 + fetch(`${relayHost}/xrpc/com.atproto.sync.requestCrawl`, { 49 + method: 'POST', 50 + headers: { 'Content-Type': 'application/json' }, 51 + body: JSON.stringify({ hostname }) 52 + }).catch(err => { 53 + console.log('Failed to notify relay:', err.message) 54 + }) 55 + } 56 + 31 57 // === CID WRAPPER === 32 58 // Explicit CID type for DAG-CBOR encoding (avoids fragile heuristic detection) 33 59 ··· 2166 2192 2167 2193 const id = env.PDS.idFromName(repo) 2168 2194 const pds = env.PDS.get(id) 2169 - return pds.fetch(new Request(request.url, { 2195 + const response = await pds.fetch(new Request(request.url, { 2170 2196 method: 'POST', 2171 2197 headers: request.headers, 2172 2198 body: JSON.stringify(body) 2173 2199 })) 2200 + 2201 + // Notify relay of updates on successful writes 2202 + if (response.ok) { 2203 + const url = new URL(request.url) 2204 + notifyCrawlers(env, url.hostname) 2205 + } 2206 + 2207 + return response 2174 2208 } 2175 2209 2176 2210 async function handleRequest(request, env) { ··· 2303 2337 return pds.fetch(request) 2304 2338 } 2305 2339 2340 + // Sync endpoints use ?did= param 2341 + if (url.pathname === '/xrpc/com.atproto.sync.getLatestCommit' || 2342 + url.pathname === '/xrpc/com.atproto.sync.getRepoStatus' || 2343 + url.pathname === '/xrpc/com.atproto.sync.getRepo' || 2344 + url.pathname === '/xrpc/com.atproto.sync.getRecord') { 2345 + const did = url.searchParams.get('did') 2346 + if (!did) { 2347 + return errorResponse('InvalidRequest', 'missing did param', 400) 2348 + } 2349 + const id = env.PDS.idFromName(did) 2350 + const pds = env.PDS.get(id) 2351 + return pds.fetch(request) 2352 + } 2353 + 2306 2354 // Authenticated repo write endpoints 2307 2355 const repoWriteEndpoints = [ 2308 2356 '/xrpc/com.atproto.repo.createRecord', ··· 2323 2371 ██╔═══╝ ██║ ██║ ╚════██║ ██ ██║ ╚════██║ 2324 2372 ██║ ██████╔╝ ███████║ ██╗ ╚█████╔╝ ███████║ 2325 2373 ╚═╝ ╚═════╝ ╚══════╝ ╚═╝ ╚════╝ ╚══════╝ 2374 + 2375 + ATProto PDS on Cloudflare Workers 2326 2376 ` 2327 2377 return new Response(ascii, { headers: { 'Content-Type': 'text/plain; charset=utf-8' } }) 2328 2378 } ··· 2342 2392 method: 'POST', 2343 2393 body: JSON.stringify({ did }) 2344 2394 })) 2395 + 2396 + // Register handle if provided 2397 + if (body.handle) { 2398 + await defaultPds.fetch(new Request('http://internal/register-handle', { 2399 + method: 'POST', 2400 + body: JSON.stringify({ did, handle: body.handle }) 2401 + })) 2402 + } 2345 2403 2346 2404 // Forward to the actual PDS instance 2347 2405 const id = env.PDS.idFromName(did)
+182
test/e2e.sh
··· 1 + #!/bin/bash 2 + # E2E tests for PDS - runs against local wrangler dev 3 + set -e 4 + 5 + BASE="http://localhost:8787" 6 + DID="did:plc:c6vxslynzebnlk5kw2orx37o" 7 + 8 + # Helper for colored output 9 + pass() { echo "✓ $1"; } 10 + fail() { echo "✗ $1" >&2; cleanup; exit 1; } 11 + 12 + # Cleanup function 13 + cleanup() { 14 + if [ -n "$WRANGLER_PID" ]; then 15 + echo "Shutting down wrangler..." 16 + kill $WRANGLER_PID 2>/dev/null || true 17 + wait $WRANGLER_PID 2>/dev/null || true 18 + fi 19 + } 20 + trap cleanup EXIT 21 + 22 + # Start wrangler dev 23 + echo "Starting wrangler dev..." 24 + npx wrangler dev --port 8787 > /dev/null 2>&1 & 25 + WRANGLER_PID=$! 26 + 27 + # Wait for server to be ready 28 + for i in {1..30}; do 29 + if curl -sf "$BASE/" > /dev/null 2>&1; then 30 + break 31 + fi 32 + sleep 0.5 33 + done 34 + 35 + # Verify server is up 36 + curl -sf "$BASE/" > /dev/null || fail "Server failed to start" 37 + pass "Server started" 38 + 39 + # Initialize PDS 40 + PRIVKEY=$(openssl rand -hex 32) 41 + curl -sf -X POST "$BASE/init?did=$DID" \ 42 + -H "Content-Type: application/json" \ 43 + -d "{\"did\":\"$DID\",\"privateKey\":\"$PRIVKEY\",\"handle\":\"test.local\"}" > /dev/null \ 44 + && pass "PDS initialized" || fail "PDS init" 45 + 46 + echo 47 + echo "Running tests..." 48 + echo 49 + 50 + # 1. Root returns ASCII art 51 + curl -sf "$BASE/" | grep -q "PDS" && pass "Root returns ASCII art" || fail "Root" 52 + 53 + # 2. describeServer works 54 + curl -sf "$BASE/xrpc/com.atproto.server.describeServer" | jq -e '.did' > /dev/null \ 55 + && pass "describeServer" || fail "describeServer" 56 + 57 + # 3. resolveHandle works 58 + curl -sf "$BASE/xrpc/com.atproto.identity.resolveHandle?handle=test.local" \ 59 + | jq -e '.did' > /dev/null && pass "resolveHandle" || fail "resolveHandle" 60 + 61 + # 4. createSession returns tokens 62 + SESSION=$(curl -sf -X POST "$BASE/xrpc/com.atproto.server.createSession" \ 63 + -H "Content-Type: application/json" \ 64 + -d "{\"identifier\":\"$DID\",\"password\":\"test-password\"}") 65 + TOKEN=$(echo "$SESSION" | jq -r '.accessJwt') 66 + [ "$TOKEN" != "null" ] && [ -n "$TOKEN" ] && pass "createSession returns token" || fail "createSession" 67 + 68 + # 5. getSession works with token 69 + curl -sf "$BASE/xrpc/com.atproto.server.getSession" \ 70 + -H "Authorization: Bearer $TOKEN" | jq -e '.did' > /dev/null \ 71 + && pass "getSession with valid token" || fail "getSession" 72 + 73 + # 6. Protected endpoint rejects without auth 74 + STATUS=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BASE/xrpc/com.atproto.repo.createRecord" \ 75 + -H "Content-Type: application/json" \ 76 + -d '{"repo":"x","collection":"x","record":{}}') 77 + [ "$STATUS" = "401" ] && pass "createRecord rejects without auth" || fail "createRecord should reject" 78 + 79 + # 7. getPreferences works (returns empty array initially) 80 + curl -sf "$BASE/xrpc/app.bsky.actor.getPreferences" \ 81 + -H "Authorization: Bearer $TOKEN" | jq -e '.preferences' > /dev/null \ 82 + && pass "getPreferences" || fail "getPreferences" 83 + 84 + # 8. putPreferences works 85 + curl -sf -X POST "$BASE/xrpc/app.bsky.actor.putPreferences" \ 86 + -H "Authorization: Bearer $TOKEN" \ 87 + -H "Content-Type: application/json" \ 88 + -d '{"preferences":[{"$type":"app.bsky.actor.defs#savedFeedsPrefV2"}]}' > /dev/null \ 89 + && pass "putPreferences" || fail "putPreferences" 90 + 91 + # 9. createRecord works with auth 92 + RECORD=$(curl -sf -X POST "$BASE/xrpc/com.atproto.repo.createRecord" \ 93 + -H "Authorization: Bearer $TOKEN" \ 94 + -H "Content-Type: application/json" \ 95 + -d "{\"repo\":\"$DID\",\"collection\":\"app.bsky.feed.post\",\"record\":{\"text\":\"test\",\"createdAt\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"}}") 96 + URI=$(echo "$RECORD" | jq -r '.uri') 97 + [ "$URI" != "null" ] && [ -n "$URI" ] && pass "createRecord with auth" || fail "createRecord" 98 + 99 + # 10. getRecord retrieves it 100 + RKEY=$(echo "$URI" | sed 's|.*/||') 101 + curl -sf "$BASE/xrpc/com.atproto.repo.getRecord?repo=$DID&collection=app.bsky.feed.post&rkey=$RKEY" \ 102 + | jq -e '.value.text' > /dev/null && pass "getRecord" || fail "getRecord" 103 + 104 + # 11. putRecord updates the record 105 + curl -sf -X POST "$BASE/xrpc/com.atproto.repo.putRecord" \ 106 + -H "Authorization: Bearer $TOKEN" \ 107 + -H "Content-Type: application/json" \ 108 + -d "{\"repo\":\"$DID\",\"collection\":\"app.bsky.feed.post\",\"rkey\":\"$RKEY\",\"record\":{\"text\":\"updated\",\"createdAt\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"}}" \ 109 + | jq -e '.uri' > /dev/null && pass "putRecord" || fail "putRecord" 110 + 111 + # 12. listRecords shows the record 112 + curl -sf "$BASE/xrpc/com.atproto.repo.listRecords?repo=$DID&collection=app.bsky.feed.post" \ 113 + | jq -e '.records | length > 0' > /dev/null && pass "listRecords" || fail "listRecords" 114 + 115 + # 13. describeRepo returns repo info 116 + curl -sf "$BASE/xrpc/com.atproto.repo.describeRepo?repo=$DID" \ 117 + | jq -e '.did' > /dev/null && pass "describeRepo" || fail "describeRepo" 118 + 119 + # 14. applyWrites batch operation (create then delete a record) 120 + APPLY_RESULT=$(curl -sf -X POST "$BASE/xrpc/com.atproto.repo.applyWrites" \ 121 + -H "Authorization: Bearer $TOKEN" \ 122 + -H "Content-Type: application/json" \ 123 + -d "{\"repo\":\"$DID\",\"writes\":[{\"\$type\":\"com.atproto.repo.applyWrites#create\",\"collection\":\"app.bsky.feed.post\",\"rkey\":\"applytest\",\"value\":{\"text\":\"batch\",\"createdAt\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"}}]}") 124 + echo "$APPLY_RESULT" | jq -e '.results' > /dev/null && pass "applyWrites create" || fail "applyWrites create" 125 + 126 + # 15. applyWrites delete 127 + curl -sf -X POST "$BASE/xrpc/com.atproto.repo.applyWrites" \ 128 + -H "Authorization: Bearer $TOKEN" \ 129 + -H "Content-Type: application/json" \ 130 + -d "{\"repo\":\"$DID\",\"writes\":[{\"\$type\":\"com.atproto.repo.applyWrites#delete\",\"collection\":\"app.bsky.feed.post\",\"rkey\":\"applytest\"}]}" \ 131 + | jq -e '.results' > /dev/null && pass "applyWrites delete" || fail "applyWrites delete" 132 + 133 + # 16. sync.getLatestCommit returns head 134 + curl -sf "$BASE/xrpc/com.atproto.sync.getLatestCommit?did=$DID" \ 135 + | jq -e '.cid' > /dev/null && pass "sync.getLatestCommit" || fail "sync.getLatestCommit" 136 + 137 + # 17. sync.getRepoStatus returns status 138 + curl -sf "$BASE/xrpc/com.atproto.sync.getRepoStatus?did=$DID" \ 139 + | jq -e '.did' > /dev/null && pass "sync.getRepoStatus" || fail "sync.getRepoStatus" 140 + 141 + # 18. sync.getRepo returns CAR file 142 + REPO_SIZE=$(curl -sf "$BASE/xrpc/com.atproto.sync.getRepo?did=$DID" | wc -c) 143 + [ "$REPO_SIZE" -gt 100 ] && pass "sync.getRepo returns CAR" || fail "sync.getRepo" 144 + 145 + # 19. sync.getRecord returns record with proof (binary CAR data) 146 + RECORD_SIZE=$(curl -sf "$BASE/xrpc/com.atproto.sync.getRecord?did=$DID&collection=app.bsky.feed.post&rkey=$RKEY" | wc -c) 147 + [ "$RECORD_SIZE" -gt 50 ] && pass "sync.getRecord" || fail "sync.getRecord" 148 + 149 + # 20. sync.listRepos lists repos 150 + curl -sf "$BASE/xrpc/com.atproto.sync.listRepos" \ 151 + | jq -e '.repos | length > 0' > /dev/null && pass "sync.listRepos" || fail "sync.listRepos" 152 + 153 + # Error handling tests 154 + echo 155 + echo "Testing error handling..." 156 + 157 + # 21. Invalid password rejected 158 + STATUS=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BASE/xrpc/com.atproto.server.createSession" \ 159 + -H "Content-Type: application/json" \ 160 + -d "{\"identifier\":\"$DID\",\"password\":\"wrong-password\"}") 161 + [ "$STATUS" = "401" ] && pass "Invalid password rejected (401)" || fail "Invalid password should return 401" 162 + 163 + # 22. Wrong repo rejected (can't modify another user's repo) 164 + STATUS=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BASE/xrpc/com.atproto.repo.createRecord" \ 165 + -H "Authorization: Bearer $TOKEN" \ 166 + -H "Content-Type: application/json" \ 167 + -d '{"repo":"did:plc:z72i7hdynmk6r22z27h6tvur","collection":"app.bsky.feed.post","record":{"text":"x","createdAt":"2024-01-01T00:00:00Z"}}') 168 + [ "$STATUS" = "403" ] && pass "Wrong repo rejected (403)" || fail "Wrong repo should return 403" 169 + 170 + # 23. Non-existent record returns 404 171 + STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$BASE/xrpc/com.atproto.repo.getRecord?repo=$DID&collection=app.bsky.feed.post&rkey=nonexistent") 172 + [ "$STATUS" = "400" ] || [ "$STATUS" = "404" ] && pass "Non-existent record error" || fail "Non-existent record should error" 173 + 174 + # Cleanup: delete the test record 175 + curl -sf -X POST "$BASE/xrpc/com.atproto.repo.deleteRecord" \ 176 + -H "Authorization: Bearer $TOKEN" \ 177 + -H "Content-Type: application/json" \ 178 + -d "{\"repo\":\"$DID\",\"collection\":\"app.bsky.feed.post\",\"rkey\":\"$RKEY\"}" > /dev/null \ 179 + && pass "deleteRecord (cleanup)" || fail "deleteRecord" 180 + 181 + echo 182 + echo "All tests passed!"