A Plan for Social Media - Understanding ActivityPub

This is part of a series of posts about the current state of Social Media and the Fediverse. In the first post, I described some of the issues with the shot that Mozilla took with their mozilla.social initiative. By the end of this series, I plan to have laid out a strategy that can be more successful. But before doing all that, it will be good to have a very fundamental understanding of ActivityPub, the protocol that enables interoperability between all the different services. Let’s get to it.

ActivityPub, actors and message boxes

If you want a proper introduction to the protocol, a good resource to start is activitypub.rocks. There we can find this:

Sequence Diagram for ActivityPub

The diagram introduces us to the three main concepts in ActivityPub:

  • Actors as the entity being addressed by messages
  • The inbox is the URL where the actor can be reached
  • The outbox is the URL used by the actor to post new messages

This looks simple, but how do we get from these simple concepts into an useable application? Let’s say that we want to find out what was the last message that I posted on my @raphael@communick.com account.

The first thing we will figure out is that my handle is @communick.com, but the server is mastodon.communick.com. How do we get from one to the other? The answer is Webfinger, a protocol to help us with service discovery via the web.

Let’s query communick.com and find out where to reach my account:

$ curl "https://communick.com/.well-known/webfinger?resource=acct:raphael@communick.com" -I

I am cheating a bit here. I’m using the -I flag is to show us the response headers, because I know already that’s the relevant part of the response:

HTTP/2 302
content-type: text/html; charset=utf-8
cross-origin-opener-policy: same-origin
date: Fri, 17 May 2024 01:43:01 GMT
location: https://mastodon.communick.com/.well-known/webfinger?resource=acct%3Araphael%40communick.com
referrer-policy: same-origin
server: uvicorn
x-content-type-options: nosniff
x-frame-options: DENY
content-length: 0

Ha! communick.com is telling us to go to mastodon.communick.com, and make the same query there. Let’s try it, this time we expect the actual data:

$ curl "https://mastodon.communick.com/.well-known/webfinger?resource=acct:raphael@communick.com" | jq .

And the response is:

{
  "subject": "acct:raphael@communick.com",
  "aliases": [
    "https://mastodon.communick.com/@raphael",
    "https://mastodon.communick.com/users/raphael"
  ],
  "links": [
    {
      "rel": "http://webfinger.net/rel/profile-page",
      "type": "text/html",
      "href": "https://mastodon.communick.com/@raphael"
    },
    {
      "rel": "self",
      "type": "application/activity+json",
      "href": "https://mastodon.communick.com/users/raphael"
    },
    {
      "rel": "http://ostatus.org/schema/1.0/subscribe",
      "template": "https://mastodon.communick.com/authorize_interaction?uri={uri}"
    },
  ]
}

So, now we know how my account actually lives. The response is describing different links that would be relevant to someone who want to interact with this account. From those, the most interesting one is rel=self`, which tells us which url is used to represent my profile as an actor. It is also indicating what mime-type should be used to make the request. Let’s try it:

$ curl https://mastodon.communick.com/users/raphael -H 'accept: application/activity+json' | jq .

And the response - with most attributes ommited for brevity:

{
  "@context": [
    "https://www.w3.org/ns/activitystreams",
    "https://w3id.org/security/v1",
    ],
  "id": "https://mastodon.communick.com/users/raphael",
  "type": "Person",
  "following": "https://mastodon.communick.com/users/raphael/following",
  "followers": "https://mastodon.communick.com/users/raphael/followers",
  "inbox": "https://mastodon.communick.com/users/raphael/inbox",
  "outbox": "https://mastodon.communick.com/users/raphael/outbox",
  "featured": "https://mastodon.communick.com/users/raphael/collections/featured",
  "featuredTags": "https://mastodon.communick.com/users/raphael/collections/tags",
  "preferredUsername": "raphael",
  "name": "Raphael Lullis",
  "devices": "https://mastodon.communick.com/users/raphael/collections/devices",
  "publicKey": {
    "id": "https://mastodon.communick.com/users/raphael#main-key",
    "owner": "https://mastodon.communick.com/users/raphael",
    "publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA2Qsjv7JwN0vnEk6Vtqiv\n/S6qk7yGmynpGZW1F/qiuzpnoLfF18czx0UFHU4imU8xv8eYDhvRUSj1nVwRmT/S\noIA0c/zlesBxkxMxtiml/dbaTUXxfgsa5SKtw6l8CaFv1IfpXAmc9T24RFiUVFGU\nWUDtKqWwYwT+QZiQG7YAF89ehEcXvbGajnQOnORgT3O+M4VOxMgNMzZsvtCTa9kg\nY1Ll56lk5XkOy/VauC5YmawAUDcfinpZVHjkKmVE+qaOg0ELduL6Vw42CADCN3TV\nHz0Ap6wJPeTj0ma+a2l8eE9MyqX7M7Y115kV2eyB0/BKMKdhMyLuNaocCFNjhm4y\ncQIDAQAB\n-----END PUBLIC KEY-----\n"
  },

}

Alright, now we are getting closer. Not only this response already provided the cryptographic public key used by my account (an important aspect for those that want to make sure that the messages I’m sending are actually from me), we have also have the urls that tells us what are the inbox and the outbox.

Let’s find out what type of nonsense I’ve been posting?

$ curl https://mastodon.communick.com/users/raphael/outbox -H 'accept: application/activity+json' | jq .
{
  "@context": "https://www.w3.org/ns/activitystreams",
  "id": "https://mastodon.communick.com/users/raphael/outbox",
  "type": "OrderedCollection",
  "totalItems": 1474,
  "first": "https://mastodon.communick.com/users/raphael/outbox?page=true",
  "last": "https://mastodon.communick.com/users/raphael/outbox?min_id=0&page=true"
}

So, the people working on Mastodon are smart enough to know that they shouldn’t respond with all 1474 posts every time someone request the content of my outbox, so it tells us to look at the paginated results.

curl "https://mastodon.communick.com/users/raphael/outbox?page=true" -H 'Accept: application/ld+json' | jq .orderedItems.[].id

Again for brevity, I’m going to list only the ids of the posts.

"https://mastodon.communick.com/users/raphael/statuses/112453788068689232/activity"
"https://mastodon.communick.com/users/raphael/statuses/112451233768213990/activity"
"https://mastodon.communick.com/users/raphael/statuses/112450952761586193/activity"
"https://mastodon.communick.com/users/raphael/statuses/112450838604583727/activity"
"https://mastodon.communick.com/users/raphael/statuses/112450692646931646/activity"
"https://mastodon.communick.com/users/raphael/statuses/112450481357958805/activity"
"https://mastodon.communick.com/users/raphael/statuses/112450434346644443/activity"
"https://mastodon.communick.com/users/raphael/statuses/112450380678942390/activity"
"https://mastodon.communick.com/users/raphael/statuses/112450366779981942/activity"
"https://mastodon.communick.com/users/raphael/statuses/112450254434441343/activity"
"https://mastodon.communick.com/users/raphael/statuses/112445793173734858/activity"
"https://mastodon.communick.com/users/raphael/statuses/112445792468741280/activity"
"https://mastodon.communick.com/users/raphael/statuses/112445753422312223/activity"
"https://mastodon.communick.com/users/raphael/statuses/112445505614367554/activity"
"https://mastodon.communick.com/users/raphael/statuses/112427864602893406/activity"
"https://mastodon.communick.com/users/raphael/statuses/112412635359916476/activity"
"https://mastodon.communick.com/users/raphael/statuses/112411290416210974/activity"
"https://mastodon.communick.com/users/raphael/statuses/112410255917704070/activity"
"https://mastodon.communick.com/users/raphael/statuses/112409574645967938/activity"
"https://mastodon.communick.com/users/raphael/statuses/112409529723480739/activity"

And let’s take a look at the response for the first result (i.e, my most recent post):

{
  "@context": [
    "https://www.w3.org/ns/activitystreams",
    {
      "ostatus": "http://ostatus.org#",
      "atomUri": "ostatus:atomUri",
      "inReplyToAtomUri": "ostatus:inReplyToAtomUri",
      "conversation": "ostatus:conversation",
      "sensitive": "as:sensitive",
      "toot": "http://joinmastodon.org/ns#",
      "votersCount": "toot:votersCount",
      "Hashtag": "as:Hashtag"
    }
  ],
  "id": "https://mastodon.communick.com/users/raphael/statuses/112453788068689232/activity",
  "type": "Create",
  "actor": "https://mastodon.communick.com/users/raphael",
  "published": "2024-05-17T01:17:11Z",
  "to": [
    "https://www.w3.org/ns/activitystreams#Public"
  ],
  "cc": [
    "https://mastodon.communick.com/users/raphael/followers"
  ],
  "object": {
    "id": "https://mastodon.communick.com/users/raphael/statuses/112453788068689232",
    "type": "Note",
    "summary": null,
    "inReplyTo": null,
    "published": "2024-05-17T01:17:11Z",
    "url": "https://mastodon.communick.com/@raphael/112453788068689232",
    "attributedTo": "https://mastodon.communick.com/users/raphael",
    "to": [
      "https://www.w3.org/ns/activitystreams#Public"
    ],
    "cc": [
      "https://mastodon.communick.com/users/raphael/followers"
    ],
    "sensitive": false,
    "atomUri": "https://mastodon.communick.com/users/raphael/statuses/112453788068689232",
    "inReplyToAtomUri": null,
    "conversation": "tag:communick.com,2024-05-17:objectId=1006185:objectType=Conversation",
    "content": "<p>I&#39;m working on a blog post where I argue that we should stop writing web applications that emulate &quot;traditional&quot; social networks and propose a different approach for <a href=\"https://mastodon.communick.com/tags/ActivityPub\" class=\"mention hashtag\" rel=\"tag\">#<span>ActivityPub</span></a> software, putting the client at the center of everything and making the servers just specialized relays of ActivityStream messages.</p>",
    "contentMap": {
      "en": "<p>I&#39;m working on a blog post where I argue that we should stop writing web applications that emulate &quot;traditional&quot; social networks and propose a different approach for <a href=\"https://mastodon.communick.com/tags/ActivityPub\" class=\"mention hashtag\" rel=\"tag\">#<span>ActivityPub</span></a> software, putting the client at the center of everything and making the servers just specialized relays of ActivityStream messages.</p>"
    },
    "attachment": [],
    "tag": [
      {
        "type": "Hashtag",
        "href": "https://mastodon.communick.com/tags/activitypub",
        "name": "#activitypub"
      }
    ],
    "replies": {
      "id": "https://mastodon.communick.com/users/raphael/statuses/112453788068689232/replies",
      "type": "Collection",
      "first": {
        "type": "CollectionPage",
        "next": "https://mastodon.communick.com/users/raphael/statuses/112453788068689232/replies?only_other_accounts=true&page=true",
        "partOf": "https://mastodon.communick.com/users/raphael/statuses/112453788068689232/replies",
        "items": []
      }
    }
  }
}

Et VoilĂ ! We figured out how to go from my account handle, to the actual Actor URL, to the outbox, and finally to read a message that I have posted. An even more interesting exercise would be to make a similar journey from the other end and send a message to me, but to do that we need to get a bit deeper into how messages are structured, the cryptography involved and some other implementation details which are not at all related to the main point here. The important thing to take away here is the concept of actors and their inbox/outbox, which can be represented by URIs and discovered through established protocols.

Now that we know the basics, we are ready to take the next step. We will challenge one of the basic assumptions common to all current software using ActivityPub and flip the whole architecture on its head.