Beyond the Statusphere: Part 1, Resolving the User's PDS

Welcome to my Beyond the Statushphere series! In this series, we go beyond the content taught to you in the Statusphere tutorial. If you have not gone through that yet, it is highly recommended to do so first. I am going to explore common gotchas, tips, and tricks that you can use or may have missed while building your ATProto applications.

Not resolving the user's PDS

ATProtocol is special in that the user's posts, likes, etc are all public records in the user's repo kept on a PDS. You can see this easily by looking up your account using something like PDSls. You can also think of your PDS as a bit like a gateway for you to the atmosphere (may want to keep that in mind during this series). But how do we reach that repo and read those records all from just the user's handle? Well, keep reading, and I'll explain how and why it's important to resolve the PDS when building ATProto applications.

bsky.social doesn't cut it

The first bit of code people usually see for anything ATProto is the code below found on the Creating a post page

import { BskyAgent } from '@atproto/api'

const agent = new BskyAgent({
service: 'https://bsky.social'
})
await agent.login({
identifier: 'handle.example.com',
password: 'hunter2'
})

await agent.post({
text: 'Hello world! I posted this via the API.',
createdAt: new Date().toISOString()
})

Which is nice and clean. Reads very much like what you would expect to find for an API client to make a social media post.

Except there is one tiny thing. You set the BskyAgent to have a service at https://bsky.social. You can see a similar thing when creating an AtpAgent on @atproto/api's readme with https://example.com

import { AtpAgent } from '@atproto/api'

const agent = new AtpAgent({
service: 'https://example.com'
})

Well, that service is the URL for the PDS you're logging into or want to read records from! If you're still on Bluesky's PDSs, they make it easy for you with the https://bsky.social url. That's called the entryway and abstracts the calls directly to the PDS for you. But this does not work for accounts not on Bluesky's PDSs. Let's use Jerry as an example. If you click on the URL https://bsky.social/xrpc/com.atproto.repo.describeRepo?repo=jcsalterego.bsky.social You will see the JSON response showing Jerry's ATProto repo. If you use my handle instead, you'll see that my repo is deactivated since I'm no longer there https://bsky.social/xrpc/com.atproto.repo.describeRepo?repo=baileytownsend.dev. For my test account, you get "Could not find user" since it was never on a Bluesky PDS https://bsky.social/xrpc/com.atproto.repo.describeRepo?repo=bailey.skeetcentral.com. Can probably see why it's important for you to call the user's PDS directly to support all users on the atmosphere. This is the same for any login action or getting records from a user's repo. You have to call the user's PDS directly if they are not on a Bluesky hosted PDS.

Here's the same call for Jerry as before, but directly to his PDS

https://shiitake.us-east.host.bsky.network/xrpc/com.atproto.repo.describeRepo?repo=jcsalterego.bsky.social

Then the same for my PDS

https://selfhosted.social/xrpc/com.atproto.repo.describeRepo?repo=baileytownsend.dev

OAuth simplifies this with most ATProto libraries if you are logging in for the user. The OAuth library usually handles all the resolutions and usually gives you a client already with the PDS set automatically, but this only helps if you are signing in for the user. This usually works even for non Bluesky hosted PDSs since part of the login flow is finding the PDS. I will go into more detail about this in the next series entry all about OAuth.

The @atproto/api library also switches to calling the user's PDS automatically once you sign the user in if you have https://bsky.social set, but this only works if the user is on a Bluesky PDS.

Both of those exceptions depend on whether the user is signing in and do not work for applications like wrapped.baileytownsend.dev that shows an overview of the user's teal.fm records without logging in the user. So, it is still important to know this flow when reading other users' repos in your application and for supporting App Password logins for users not on a Bluesky PDS.

Alright, where do I find this PDS?

How do I find the did?

Well, usually you start with the handle, jcsalterego.bsky.social/baileytownsend.dev. Then from there you get the did one of 2 ways.

1. Resolve via HTTP. You make a call to https://jcsalterego.bsky.social/.well-known/atproto-did. This web request then returns the users did

2. Resolve via DNS. You can look up a DNS entry for the handle by appending _atproto. to the handle like _atproto.baileytownsend.dev and look up the TXT DNS record. This will then return the user's did. Example

It is recommended that you do both and treat it as a race. Whichever returns first is the one you use. Ideally, if they both are successful, they should be the same did.

Bluesky also has a great web tool to visualize this.

Bluesky Debug

And the PDS?

When you do that look up you'll get 1 of two different dids. You may get a did:plc like this did:plc:hdhoaan3xa3jiuq4fg4mefid. Or a did:web like did:web:lukeacl.com. Each of these has a slightly different way to look the PDS up, but both end in a DID Document. This is a JSON document that holds your identity. It has things like your handle, a public key that signed your records, and the PDS's URL. You'll find the PDS under service and in an object with the id of #atproto_pds. This should be the URL you use when making calls on behalf of the user or when looking up records in their repo.

{
"@context": [
"https://www.w3.org/ns/did/v1",
"https://w3id.org/security/multikey/v1",
"https://w3id.org/security/suites/secp256k1-2019/v1"
],
"id": "did:plc:gotnvwkr56ibs33l4hwgfoet",
"alsoKnownAs": [
"at://zeu.dev"
],
"verificationMethod": [
{
"id": "did:plc:gotnvwkr56ibs33l4hwgfoet#atproto",
"type": "Multikey",
"controller": "did:plc:gotnvwkr56ibs33l4hwgfoet",
"publicKeyMultibase": "zQ3shYNjUmmGouRj2YZ2z6wG7iKpPdNMfKJqvNnJpgAR6PaE3"
}
],
"service": [
{
"id": "#atproto_pds",
"type": "AtprotoPersonalDataServer",
"serviceEndpoint": "https://selfhosted.social"
}
]
}

did:plc to a Did doc

You can find the did doc for did:plc by looking it up on the PLC(Public Ledger of Credentials) by making a web request to it like so https://plc.directory/did:plc:gotnvwkr56ibs33l4hwgfoet

The whole flow for a handle via HTTP to the PLC looks like this

The flow for a handle via DNS to the PLC looks like this

A bit more on the PLC here

What the hell is the atmosphere anyway - HItchhikers Guide to the Atmosphere

did:web to a did doc

Since did:web is actually just a domain, you find the did doc hosted on that domain under /.well-known/did.json. So, for @ducky.ws that would be https://didd.uk/.well-known/did.json. Note that this user's handle is a different domain and is not the same as its did:web. You need to make sure to still do handle resolution, because they do not have to match.

The whole flow for a handle via DNS for a did:web looks like this.

The whole flow for a handle via HTTP for a did:web looks like this.

A note for any HTTP resolutions did or did doc. It's best to disable CORS for any of the HTTP resolutions for dids or did.json, since many ATProto applications are client-side auth. You can do this by setting the header Access-Control-Allow-Origin: * for that endpoint. This will allow the web browser to make a request to a different domain successfully.

There's got to be an easier way

Thankfully, most popular ATProto libraries already got you covered, and this is all just a distant worry. They all usually have a package or section called "identity" that helps you get the did from a handle and the did doc for the PDS. In no particular order, here is a list of libraries, the language and some notes. Then, once you've got the PDS you use it in the agent. Usually named atpagent, or something similar, and will label the PDS URL as "service"

jacquard_identity - Rust

did resolution example

did doc(PDS) resolution example

atproto-identity - Rust

did resolution example

did doc(PDS) resolution example

@atproto/identity - TypeScript

Does not work in browsers because of a node dependency for DNS

@atcute/identity-resolver - TypeScript

It works in browsers since it uses DNS over HTTP.

ATProtoKit/ATIdentityTools - Swift

Should work on anything Swift supports

mackuba/didkit - Ruby

MarshalX/atproto - Python

Code examples

slingshot.microcosm.blue - HTTP

Probably the easiest way if your language of choice does not have support (or even just the easiest overall). With slingshot, it is just a single HTTP request to their servers

did resolution example

get a MiniDoc with all the identifying information you need for ATProto including the PDS. This endpoint can take either the user's did or handle, and makes it extremely easy to use.

I'm sure I've missed several, mostly listed the ones I know of or have used. For a complete list, check out sdk.blue

The End

And that's why you should use the user's PDS in your ATProto app. I'm thinking the next entry in this series will probably be OAuth, so keep your 👀🍌. Till next time, thank's for reading!