A PDS that runs in your browser?
README.md

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 Request frame carries a unique id; the client replies with a Response frame carrying the same id, 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-DID header (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 ./...