DEV Community

Dan Goldstein
Dan Goldstein

Posted on

Getting Started with ActivityPub

ActivityPub is a specification for a decentralized social network. Decentralized here means that anyone can run a node that is a full member of the network. Anyone on any server can follow, like, and communicate with any member on any other server.

The W3C recommended the official ActivityPub spec in 1998, but it's gotten more attention after Elon Musk bought Twitter. When I started looking into the spec, I couldn't find anything that explained how to implement it, so I thought I'd write up some notes.

This article barely touches on how ActivityPub works. I didn't get that far yet. If you want to see the kinds of messages that are sent between ActivityPub servers, take a look at the Activity Streams Vocabulary spec and click on the table of contents for each of the items under Extended Types.

My exploration has used a technique I'll generously call "data-first". Rather than reading the W3C specification and knowing what to expect, I've looked at the data and try to figure out what it means, referring to the spec when necessary.

Full disclosure: I'm nothing near an export on the ActivityPub spec. I'm a veteran software developer who runs a small software agency. Please email me at dan@axelby.com with errors and corrections and I'll do my best to avoid giving out wrong information.

Step 1: Webfinger

ActivityPub is missing an official way to find users and posts, as far as I know.

One popular ActivityPub server is Mastodon. That site's home page looks like Facebook, with a list of posts from users on the site. For this article we're going to look at the profile of Neil Gaiman (wikipedia), a fantastic fiction author. His page on Mastodon is at https://mastodon.social/@neilhimself.

Now that we know Neil Gaiman's ActivityPub server and username, we're going to use Webfinger to look up his profile's information. Webfinger just specifies that you can find information about a resource at the URL https://<server>/.well-known/webfinger/resource=<resource>. In our case, the url is https://mastodon.social/.well-known/webfinger?resource=https://mastodon.social/users/neilhimself. You can open it in a new tab and see what's returned.

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

There are three top level keys: subject, aliases, and links. The subject and aliases are easy to understand, but it's worth looking three links:

  1. a webfinger profile page that's HTML
  2. a self that's application/activity+json
  3. a subscribe that uses the OStatus specification.

I haven't gotten far enough to worry about subscribing. Wikipedia has a good article about OStatus. Just don't actually go to the link in the rel field. It's not related to the OStatus specification. 🤷

Step 2: ActivityPub

Now that we have the href from the self link, we can request some ActivityPub content. If you open the link https://mastodon.social/@neilhimself in a new tab, you'll see an HTML web page. To get ActivityPub content, you'll need to add the header Accept: application/ld+json; profile="https://www.w3.org/ns/activitystreams". This is found in section 3.2 on the ActivityPub spec.

It's a big long, so the response is at the bottom in Appendix A. The format is JSON for Linking Data, or JSON-LD.

Step 2a: JSON-LD

JSON-LD is a way of sending JSON when every piece of data is defined in a spec somewhere. The @context key says what specs are relevant, although I'm not 100% sure how it chooses if a key is in two specs. This JSON-LD document uses https://www.w3.org/ns/activitystreams, https://w3id.org/security/v1, and the third item in @context is an object that refers to http://joinmastodon.org/ns and http://schema.org.

Every piece of data is defined by its spec. I think it would be difficult to get correct information without transforming the document. You'd probably wind up rewriting a JSON-LD transformer in the process. Fortunately, there are libraries to parse this document in many languages. The JSON-LD website keeps a list here.

The JSON-LD spec defines how to manipulate JSON-LD documents. It can be expanded, compacted, flattened, and framed. You can test all of these out by going to the JSON-LD playground. Load the document entering the URL (https://mastodon.social/users/neilhimself) in the box at the top right and clicking the cloud button to the left of the text input. Then use the tabs at the bottom to see the different representations.

So far I've found compacted to be the most useful. Expanded puts everything in arrays which means you can potentially have multiple values, even when there's only one. I haven't gotten anything useful out of flattened and framed, so please let me know if these are helpful.

The compacted ActivityPub document is in Appendix B.

Most of the values in the compacted document have this structure:

{
  "http://www.w3.org/ns/ldp#outbox": [
    {
      "@id": "https://mastodon.social/users/neilhimself/outbox"
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

This is pretty clear that Neil Gaiman's Mastodon account has one outbox at that URL. According to the ActivityPub spec for Actor objects, this is the URL where you can retrieve what he's published. Take a look here.

Some values have more complex structures:

{
  "https://www.w3.org/ns/activitystreams#icon": [
    {
      "https://www.w3.org/ns/activitystreams#mediaType": [
        {
          "@value": "image/jpeg"
        }
      ],
      "@type": [
        "https://www.w3.org/ns/activitystreams#Image"
      ],
      "https://www.w3.org/ns/activitystreams#url": [
        {
          "@id": "https://files.mastodon.social/accounts/avatars/109/292/929/514/346/332/original/de4a4f94f765e7e8.jpeg"
        }
      ]
    }
  ]    
}
Enter fullscreen mode Exit fullscreen mode

This value's @type is https://www.w3.org/ns/activitystreams#Image. You can click that link to be taken to the ActivityPub Vocabulary spec and find the meaning for each field in the Image

Step 2b: Back to ActivityPub

We just looked at the outbox and icon fields in the ActivityPub spec. Going back to the profile document in Appendix B, we can see it's of the type https://www.w3.org/ns/activitystreams#Person.

This is where I started to have trouble getting meaning from the spec. The vocabulary spec says that Person is an Object. That doesn't say much. Everything is an Object, even Collections. After perusing the spec for a while, you'll find out that Person is an Actor type. More useful information is in the regular spec, which defines the additional fields that Actors have.

If anyone has a better way to read these specs, please let me know and I'll spread the information.

Step 3: Inspecting the Person Document

By looking at the keys of the compacted Person document, we can see there are 4 namespaces:

  1. https://www.w3.org/ns/activitystreams
  2. http://joinmastodon.org/ns
  3. http://www.w3.org/ns/ldp
  4. https://w3id.org/security

This document contains data from the ActivityPub spec, Mastodon's ActivityPub extensions (which might be de facto part of the spec), W3C's linked data spec, and W3ID's security spec.

Personally, this seems a bit overwhelming to implement. We'll just finish looking at this document.

I've pulled out the data in the ActivityPub namespace. This seems like it would be most relevant to interacting with the ActivityPub network.

{
  "@id": "https://mastodon.social/users/neilhimself",
  "@type": "https://www.w3.org/ns/activitystreams#Person",
  "attachment": {
    "type": "http://schema.org#PropertyValue",
    "value": '<a href="https://www.neilgaiman.com" target="_blank" rel="nofollow noopener noreferrer me"><span cla...',
    "name": "site"
  },
  "endpoints": {
    "https://www.w3.org/ns/activitystreams#sharedInbox": { "@id": "https://mastodon.social/inbox" }
  },
  "followers": "https://mastodon.social/users/neilhimself/followers",
  "following": "https://mastodon.social/users/neilhimself/following",
  "icon": {
    "type": "https://www.w3.org/ns/activitystreams#Image",
    "mediaType": "image/jpeg",
    "url": "https://files.mastodon.social/accounts/avatars/109/292/929/514/346/332/original/de4a4f94f765e7e8.jpe..."
  },
  "manuallyApprovesFollowers": false,
  "name": "Neil Gaiman",
  "outbox": "https://mastodon.social/users/neilhimself/outbox",
  "preferredUsername": "neilhimself",
  "published": 2022-11-05T00:00:00.000Z,
  "summary": "<p>It&#39;s too late now. I&#39;ll probably never grow up and get a real job. I suppose I will just ...",
  "tag": [],
  "url": "https://mastodon.social/@neilhimself"
}
Enter fullscreen mode Exit fullscreen mode

This looks like it would be a good place to get started. The meanings are in the Actor section of the spec linked above.

Unfortunately, it looks like some Mastodon data is classified as ActivityPub data. The manuallyApprovesFollowers field cannot be found in the ActivityPub spec. Instead, it's found in the ActivityPub 2.0 spec found on Mastodon's website which refers to their open-source Ruby implementation.

I'm not sure what's happening here. Did Mastodon take over the W3C spec? Is this ActivityPub 2.0 spec by Mastodon the de facto ActivityPub spec? I thought Mastodon was just one implementation.

This is when I realize that I don't even really participate on social media and go play Fortnite with my kids.


Appendix A - ActivityPub Content

{
  "@context": [
    "https://www.w3.org/ns/activitystreams",
    "https://w3id.org/security/v1",
    {
      "manuallyApprovesFollowers": "as:manuallyApprovesFollowers",
      "toot": "http://joinmastodon.org/ns#",
      "featured": {
        "@id": "toot:featured",
        "@type": "@id"
      },
      "featuredTags": {
        "@id": "toot:featuredTags",
        "@type": "@id"
      },
      "alsoKnownAs": {
        "@id": "as:alsoKnownAs",
        "@type": "@id"
      },
      "movedTo": {
        "@id": "as:movedTo",
        "@type": "@id"
      },
      "schema": "http://schema.org#",
      "PropertyValue": "schema:PropertyValue",
      "value": "schema:value",
      "discoverable": "toot:discoverable",
      "Device": "toot:Device",
      "Ed25519Signature": "toot:Ed25519Signature",
      "Ed25519Key": "toot:Ed25519Key",
      "Curve25519Key": "toot:Curve25519Key",
      "EncryptedMessage": "toot:EncryptedMessage",
      "publicKeyBase64": "toot:publicKeyBase64",
      "deviceId": "toot:deviceId",
      "claim": {
        "@type": "@id",
        "@id": "toot:claim"
      },
      "fingerprintKey": {
        "@type": "@id",
        "@id": "toot:fingerprintKey"
      },
      "identityKey": {
        "@type": "@id",
        "@id": "toot:identityKey"
      },
      "devices": {
        "@type": "@id",
        "@id": "toot:devices"
      },
      "messageFranking": "toot:messageFranking",
      "messageType": "toot:messageType",
      "cipherText": "toot:cipherText",
      "suspended": "toot:suspended",
      "focalPoint": {
        "@container": "@list",
        "@id": "toot:focalPoint"
      }
    }
  ],
  "id": "https://mastodon.social/users/neilhimself",
  "type": "Person",
  "following": "https://mastodon.social/users/neilhimself/following",
  "followers": "https://mastodon.social/users/neilhimself/followers",
  "inbox": "https://mastodon.social/users/neilhimself/inbox",
  "outbox": "https://mastodon.social/users/neilhimself/outbox",
  "featured": "https://mastodon.social/users/neilhimself/collections/featured",
  "featuredTags": "https://mastodon.social/users/neilhimself/collections/tags",
  "preferredUsername": "neilhimself",
  "name": "Neil Gaiman",
  "summary": "<p>It&#39;s too late now. I&#39;ll probably never grow up and get a real job. I suppose I will just have to keep making things up and writing them down.</p>",
  "url": "https://mastodon.social/@neilhimself",
  "manuallyApprovesFollowers": false,
  "discoverable": true,
  "published": "2022-11-05T00:00:00Z",
  "devices": "https://mastodon.social/users/neilhimself/collections/devices",
  "publicKey": {
    "id": "https://mastodon.social/users/neilhimself#main-key",
    "owner": "https://mastodon.social/users/neilhimself",
    "publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAmO9jYR31rOtqrKUA+oLP\nPm3VU/ynL+gsxXKFCp4c5HQYlffbpkx1pIQXiQU0CgiwSxR1/rZuk6w8AGQEiyhB\n9W70Vnj8wHMeIx82bPrEf87GWv1kIf81DLxYuiRRjfMtYM9IR6QhXWaxNAacv1Bx\noWmVcTE2bZ80BiWioRC8381eQ9E2C1UkuWlIBgaI4/B7o35IosdE3UMjY77zdM39\nGqmZG4cjmNiCkxN9VVQrsH7WT2ZArtl1FF3r+SGP8bwY2hdDIXzP2xNmvvJBc4bM\nYKmF/WIfKi3WgUuEFBiVnXWiPNLE1en09IXz1ntGDUcpkAGiCTqu/v/+kKTE+CVC\ncwIDAQAB\n-----END PUBLIC KEY-----\n"
  },
  "tag": [],
  "attachment": [
    {
      "type": "PropertyValue",
      "name": "site",
      "value": "<a href=\"https://www.neilgaiman.com\" target=\"_blank\" rel=\"nofollow noopener noreferrer me\"><span class=\"invisible\">https://www.</span><span class=\"\">neilgaiman.com</span><span class=\"invisible\"></span></a>"
    }
  ],
  "endpoints": {
    "sharedInbox": "https://mastodon.social/inbox"
  },
  "icon": {
    "type": "Image",
    "mediaType": "image/jpeg",
    "url": "https://files.mastodon.social/accounts/avatars/109/292/929/514/346/332/original/de4a4f94f765e7e8.jpeg"
  }
}
Enter fullscreen mode Exit fullscreen mode

Appendix B - ActivityPub Document in Compact Form

[
  {
    "https://www.w3.org/ns/activitystreams#attachment": [
      {
        "https://www.w3.org/ns/activitystreams#name": [
          {
            "@value": "site"
          }
        ],
        "@type": [
          "http://schema.org#PropertyValue"
        ],
        "http://schema.org#value": [
          {
            "@value": "<a href=\"https://www.neilgaiman.com\" target=\"_blank\" rel=\"nofollow noopener noreferrer me\"><span class=\"invisible\">https://www.</span><span class=\"\">neilgaiman.com</span><span class=\"invisible\"></span></a>"
          }
        ]
      }
    ],
    "http://joinmastodon.org/ns#devices": [
      {
        "@id": "https://mastodon.social/users/neilhimself/collections/devices"
      }
    ],
    "http://joinmastodon.org/ns#discoverable": [
      {
        "@value": true
      }
    ],
    "https://www.w3.org/ns/activitystreams#endpoints": [
      {
        "https://www.w3.org/ns/activitystreams#sharedInbox": [
          {
            "@id": "https://mastodon.social/inbox"
          }
        ]
      }
    ],
    "http://joinmastodon.org/ns#featured": [
      {
        "@id": "https://mastodon.social/users/neilhimself/collections/featured"
      }
    ],
    "http://joinmastodon.org/ns#featuredTags": [
      {
        "@id": "https://mastodon.social/users/neilhimself/collections/tags"
      }
    ],
    "https://www.w3.org/ns/activitystreams#followers": [
      {
        "@id": "https://mastodon.social/users/neilhimself/followers"
      }
    ],
    "https://www.w3.org/ns/activitystreams#following": [
      {
        "@id": "https://mastodon.social/users/neilhimself/following"
      }
    ],
    "https://www.w3.org/ns/activitystreams#icon": [
      {
        "https://www.w3.org/ns/activitystreams#mediaType": [
          {
            "@value": "image/jpeg"
          }
        ],
        "@type": [
          "https://www.w3.org/ns/activitystreams#Image"
        ],
        "https://www.w3.org/ns/activitystreams#url": [
          {
            "@id": "https://files.mastodon.social/accounts/avatars/109/292/929/514/346/332/original/de4a4f94f765e7e8.jpeg"
          }
        ]
      }
    ],
    "@id": "https://mastodon.social/users/neilhimself",
    "http://www.w3.org/ns/ldp#inbox": [
      {
        "@id": "https://mastodon.social/users/neilhimself/inbox"
      }
    ],
    "https://www.w3.org/ns/activitystreams#manuallyApprovesFollowers": [
      {
        "@value": false
      }
    ],
    "https://www.w3.org/ns/activitystreams#name": [
      {
        "@value": "Neil Gaiman"
      }
    ],
    "https://www.w3.org/ns/activitystreams#outbox": [
      {
        "@id": "https://mastodon.social/users/neilhimself/outbox"
      }
    ],
    "https://www.w3.org/ns/activitystreams#preferredUsername": [
      {
        "@value": "neilhimself"
      }
    ],
    "https://w3id.org/security#publicKey": [
      {
        "@id": "https://mastodon.social/users/neilhimself#main-key",
        "https://w3id.org/security#owner": [
          {
            "@id": "https://mastodon.social/users/neilhimself"
          }
        ],
        "https://w3id.org/security#publicKeyPem": [
          {
            "@value": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAmO9jYR31rOtqrKUA+oLP\nPm3VU/ynL+gsxXKFCp4c5HQYlffbpkx1pIQXiQU0CgiwSxR1/rZuk6w8AGQEiyhB\n9W70Vnj8wHMeIx82bPrEf87GWv1kIf81DLxYuiRRjfMtYM9IR6QhXWaxNAacv1Bx\noWmVcTE2bZ80BiWioRC8381eQ9E2C1UkuWlIBgaI4/B7o35IosdE3UMjY77zdM39\nGqmZG4cjmNiCkxN9VVQrsH7WT2ZArtl1FF3r+SGP8bwY2hdDIXzP2xNmvvJBc4bM\nYKmF/WIfKi3WgUuEFBiVnXWiPNLE1en09IXz1ntGDUcpkAGiCTqu/v/+kKTE+CVC\ncwIDAQAB\n-----END PUBLIC KEY-----\n"
          }
        ]
      }
    ],
    "https://www.w3.org/ns/activitystreams#published": [
      {
        "@type": "http://www.w3.org/2001/XMLSchema#dateTime",
        "@value": "2022-11-05T00:00:00Z"
      }
    ],
    "https://www.w3.org/ns/activitystreams#summary": [
      {
        "@value": "<p>It&#39;s too late now. I&#39;ll probably never grow up and get a real job. I suppose I will just have to keep making things up and writing them down.</p>"
      }
    ],
    "https://www.w3.org/ns/activitystreams#tag": [],
    "@type": [
      "https://www.w3.org/ns/activitystreams#Person"
    ],
    "https://www.w3.org/ns/activitystreams#url": [
      {
        "@id": "https://mastodon.social/@neilhimself"
      }
    ]
  }
]
Enter fullscreen mode Exit fullscreen mode

Top comments (1)

Collapse
 
mikaelgramont profile image
Mikael Gramont

Thank you, I enjoyed the no-nonsense approach here!