- Go 81%
- HTML 19%
- Docker <1%
my-pds #
A "TURN server" for the Bluesky PDS, plus a small binary that runs a PDS on your own computer. This is a Go monorepo with two entry points that share a common HTTP surface driven by the atproto lexicons.
Why "TURN"? #
A TURN server relays media between peers when they can't connect directly. The goal here is the analogue for atproto: a relay that fronts PDS read traffic so a PDS running on a personal machine (behind NAT, intermittently online, etc.) can still be reached.
Status #
A DID-scoped read now flows all the way through: HTTP → per-DID queue → WebSocket
back channel → the connected PDS client → response written back to the HTTP
caller. When no client is connected for a DID, the read falls back to a 503
stub. Auth on the back channel is a stub (trusts a header/param) until DPoP
lands.
Request flow (DID-scoped reads) #
GET /xrpc/com.atproto.repo.getRecord?repo=<did>
├─ missing repo/did param ............ 400 InvalidRequest
├─ DID not in known allow-list ....... 404 RepoNotFound
├─ per-DID queue full (evict oldest).. 503 Overloaded
├─ waited longer than queue-timeout .. 503 Timeout
├─ no client connected for the DID ... 503 UpstreamUnavailable
├─ relay/transport error ............. 502 UpstreamError
└─ otherwise → relayed to client ..... client's status/headers/body
Each known DID has its own queue served by a pool of workers (-queue-concurrency,
default 8), so multiple requests for one DID can be in flight at once. Two
backpressure rules keep it bounded: a depth cap (oldest waiter evicted on
overflow) and a max wait (jobs that sit too long are evicted); both surface as
503. Server-/identity-level reads (describeServer, listRepos,
subscribeRepos, resolveHandle, ...) are not DID-scoped and bypass the queues.
Back channel #
A PDS client connects with GET /backchannel (WebSocket). It authenticates to a
DID, and the relay registers the connection. Queue workers then forward each
request as a DAG-CBOR frame and await the matching response:
- Multiplexed: every
Requestframe carries a uniqueid; the client replies with aResponseframe carrying the sameid, so many requests can be outstanding and responses may arrive in any order. - One connection per DID: a new connection for a DID displaces (closes) the previous one.
- Auth is a stub:
X-PDS-DIDheader (or?did=query). DPoP signed by the DID document is the next step. Do not expose this publicly yet.
Subscriptions (subscribeRepos) are not handled over the back channel yet.
Identity & auth (DPoP over did:plc) #
Back-channel connections authenticate with a DPoP proof signed by the DID's
#atproto key. The relay reads the proof (from the DPoP header, or a dpop
query param for browsers), takes the DID from the proof's iss, resolves its
document via the PLC directory, and verifies the signature against the published
key. A verified DID gets a queue created on demand — it need not be a built-in,
so real auth overrides the hardcoded seed list.
go run ./cmd/pdsturn -auth=dpop -plc-url=https://plc.directory # real (default)
go run ./cmd/pdsturn -auth=stub # dev: trust ?did= (browser demo)
Create a did:plc (keys saved locally, 0600, gitignored; rotation key persisted before anything is published):
go run ./cmd/mkdid -submit=false # mint + save locally only
go run ./cmd/mkdid -handle you.example.com \ # mint + publish to plc.directory
-pds-endpoint https://your-relay.example.com # (permanent, irreversible)
cmd/pdsclient is a headless PDS: it loads identity.json, connects to the
relay with a DPoP proof, and serves records from memory. With -serve-doc it
also serves its own DID document, standing in for a PLC directory so you can test
the whole path locally without publishing:
go run ./cmd/pdsclient -serve-doc 127.0.0.1:5000 # client + local PLC stand-in
go run ./cmd/pdsturn -auth=dpop -plc-url=http://127.0.0.1:5000
curl "localhost:3478/xrpc/com.atproto.repo.getRecord?repo=<did>&collection=app.bsky.feed.post&rkey=3kdummyrecord01"
Crypto is secp256k1 only for now (atproto's default); P-256 DIDs are rejected
with a clear error. The browser PDS still uses -auth=stub until JS-side DPoP
signing is added.
Browser PDS (web/pds.html) #
A Stage-1 PDS in a single HTML page: it stores records in IndexedDB and answers
describeRepo / listRecords / getRecord frames over the back channel (with a
self-contained CBOR codec — no build step, no external JS). Enough to drive
PDSls's core repo → collection → record navigation.
go run ./cmd/pdsturn # relay on :3478
python3 -m http.server 8080 --directory web # serve the page
# open http://localhost:8080/pds.html, add a record, click "connect"
Then reads against the relay relay through to the tab:
DID=did:plc:z72i7hdynmk6r22z27h6tvur
curl "localhost:3478/xrpc/com.atproto.repo.getRecord?repo=$DID&collection=app.bsky.feed.post&rkey=3kdummyrecord01"
Not yet implemented in the page: content-addressing (real CIDs/MST), the
sync.* CAR surfaces, blobs, and writes — these degrade gracefully in PDSls
(it checks ok / hides the feature). CIDs returned today are structurally valid
but not canonical atproto content CIDs.
Layout #
lexicons/ vendored atproto read-surface lexicons (source of truth)
lexicons.go embeds lexicons/ via go:embed
internal/lexicon/ parses lexicons -> []Route (query + subscription only)
internal/queue/ dynamic per-DID queues, worker pool (evict + timeout -> 503)
internal/known/ seed/built-in DIDs (overridden by verified connects)
internal/wire/ DAG-CBOR frame protocol (Request/Response, id multiplexing)
internal/backchannel/ multiplexed WS connections + per-DID registry
internal/atcrypto/ secp256k1 keys, low-S signatures, did:key multibase
internal/did/ DID Document + Resolver (PLC directory / static)
internal/plc/ did:plc create/sign/derive, submit, key persistence
internal/auth/ Authenticator: DPoP verifier + dev stub
internal/server/ HTTP handler: DID dispatch, queueing, back-channel relay
cmd/pdsturn/ the relay ("TURN") server (default :3478)
cmd/pds/ runs a PDS locally (default :3000)
cmd/pdsclient/ headless PDS: loads identity, connects with DPoP
cmd/mkdid/ mint a did:plc, save keys, publish to PLC
Configurable per binary: -queue-depth, -queue-timeout, -queue-concurrency.
The route table is derived from the lexicon files at startup, so the set of
advertised methods can never drift from the vendored lexicons. As real handlers
are written they replace the 503 stubs in internal/server.
Read surfaces #
Vendored from bluesky-social/atproto,
covering the query and subscription definitions of a PDS across the
com.atproto.{repo,sync,identity,server} namespaces (e.g. getRecord,
listRecords, getRepo, subscribeRepos, resolveHandle, describeServer).
Run #
go run ./cmd/pdsturn # relay on :3478
go run ./cmd/pds # local PDS on :3000
# override the listen address
go run ./cmd/pdsturn -addr :8080
PDS_ADDR=:4000 go run ./cmd/pds
curl -i localhost:3478/xrpc/com.atproto.repo.getRecord # -> 503
curl localhost:3478/health # -> {"status":"ok"}
Test #
go test ./...