All Articles

My blog joined the fediverse

This blog is now on the fediverse as @blog@kisdigital.com. Follow it from Mastodon and new posts, edits and deletions reach your timeline within minutes; replies come back here as comments, and likes and boosts are counted. It's all powered by bx-activitypub, a new BoxLang module on ForgeBox and GitHub: your app answers four questions about its data and the module handles WebFinger, HTTP Signatures and delivery. Here's how it works, what I learned, and what's next.

If you're reading this on Mastodon, it worked.

This blog now has a fediverse account: @[email protected]. Follow it from Mastodon (or any server that speaks ActivityPub) and new posts land in your timeline a couple of minutes after I publish them. Edit a post and your copy updates; unpublish one and it disappears. Reply to a post and your reply shows up here, under the post, as a comment.

All of that comes from a new BoxLang module, bx-activitypub, which is on ForgeBox and GitHub.

People have been saying blogging is dead for years, and it's hard to argue with them. So instead of waiting for readers to find the site, I wanted to take the site to where the readers already are. I also needed this for other projects, so now was as good a time as any to write it.

What ActivityPub asks of you

ActivityPub is the protocol behind Mastodon and the rest of the fediverse. For a site that just wants to publish, the moving parts are:

  • WebFinger, so @[email protected] can be looked up and resolved to an account.
  • An actor document: the account's profile, including a public key.
  • An inbox, where other servers send follows, replies, likes and boosts.
  • HTTP Signatures on every request in both directions. This is the part that bites. If a signature is off by one header, the other side answers with a polite 401 and nothing else.
  • Delivery: pushing each new post, edit and deletion to every follower's server, and retrying when a server is down.

bx-activitypub handles all of that. Your app answers four questions about its own data, and the module does the protocol.

The contract

Your app implements one class, IHostApp:

// MyHost.bx
class implements="bxModules.bxactivitypub.contracts.IHostApp" {

	string function baseUrl() {
		return "https://example.com";
	}

	// The account: name, bio, avatar, header image...
	function findActor( required string type, required string name ) {
		if ( type == "Person" && name == "blog" ) {
			return {
				id          : "7d5c1f2e-3a4b-4c5d-8e6f-000000000001",
				name        : "blog",
				displayName : "My Blog",
				summary     : "<p>New posts from my blog.</p>"
			};
		}
		return javacast( "null", "" );
	}

	// A post: title, summary, HTML content, its URL, publish date and tags
	function getObject( required string type, required string id ) {
		// load it from your own tables
	}

	// The gate: drafts and private content never leave the building
	boolean function isPublic( required string type, required string id ) {
		// return false for drafts, scheduled posts, anything private
	}

}

Then mount it on a BoxExpress app and tell it when posts might have changed:

// app.bxs
ap = new bxModules.bxactivitypub.models.ActivityPub( host = new MyHost(), settings = { datasource : "mydb" } )
new bxModules.bxactivitypub.adapters.express().mount( app, ap )

app.schedule( 2 * 60 * 1000, () => {
	ap.syncPost( postId )            // Create, Update or Delete, whichever is needed
	ap.syncActor( "Person", "blog" ) // profile changes
} )

syncPost() compares a post with what it last sent and does whatever is needed: a Create the first time a post goes public, an Update when you edit it, and a Delete when you unpublish it. Call it as often as you like; an unchanged post sends nothing.

The core module doesn't depend on BoxExpress. The routes live in an adapter, and a test fails the build if anything else so much as mentions BoxExpress, so a ColdBox adapter can come later without touching the protocol code.

How this blog uses it

  • One account for the blog, not for me. The fediverse account is @blog, presented as the site: its name, description, logo, and a header image.
  • A two-minute sweep. A scheduled job syncs every post published since federation went live, plus every post already on the fediverse. New posts, scheduled posts going live, edits and unpublishing all flow out on their own. There's no "federate" button.
  • No backfill. Older posts stay home. Mastodon files a post under its original publish date, so federating a two-year-old post today would bury it, and a backfill would flood every new follower.
  • Tags become hashtags. A post's tags go out as hashtags, so it shows up in Mastodon's hashtag timelines on the servers that receive it.
  • Replies become comments. Only public replies are accepted: followers-only replies and direct messages are dropped, never stored. A first reply from someone waits for my approval; once I've approved one, their later replies publish immediately. Remote replies arrive as HTML, so they're sanitized before they're stored, and remote images aren't loaded at all.
  • Likes and boosts are counted. They show up next to a post's view count. Un-like or un-boost it and the count goes back down.

Things I learned along the way

A few of these cost me real time, so maybe they save you some:

  • mastodon.social won't hand you an actor document unless you sign the request. Plain GETs get a 401. The module signs every outgoing fetch with an instance-level account.
  • Mastodon doesn't show an Article's body. It shows the title, the summary and a link. Your post's description is doing all the work in someone's timeline.
  • Some things are permanent. Your handle and your domain are part of your identity: change either and every follower is orphaned. A post's ID belongs forever to the account that first published it, and once a post is deleted, its ID can't be reused, even if you republish.
  • Replies arrive at the shared inbox, not the account's own inbox, and followers-only replies arrive too. If you import replies, check who they were addressed to.

What isn't there yet

bx-activitypub 0.4 does one job well: publishing a site's own posts and hearing back from the people who read them. Plenty is left for later, roughly in the order I'd expect to want it:

Conversation (next up)

  • Replying back. My replies to fediverse comments stay on the blog; they aren't sent back to the person's server yet.
  • Whole threads. Only replies that mention the account arrive; deeper conversation branches on Mastodon aren't fetched.

Publishing

  • Images. Cover images and inline images aren't attached to federated posts yet.
  • A real outbox and pinned posts. A new follower currently sees an empty profile until the next post.
  • Community mode. Groups that re-share posts (Announce), Lemmy-style. That's the model for a forum, where a category can be followed; it's designed in but not built or tested against Lemmy.

Identity and moderation

  • Moving accounts (Move and alsoKnownAs), so a handle or domain change doesn't orphan followers.
  • Verified profile links and profile fields.
  • Blocking: per-domain and per-account blocks, plus rate limits on the inbox. Today, moderation is the approval queue.

Protocol

  • Newer HTTP signatures (RFC 9421) and Ed25519 keys, which newer fediverse servers are starting to support.
  • Authorized fetch on our side, for requiring signatures on requests to this server too.
  • Encrypting private keys at rest.

Frameworks

  • A ColdBox adapter. Only the BoxExpress adapter exists today, but nothing in the core depends on it.

If any of these would be useful to you, or you run into something I've missed, open an issue. Or reply to this post from Mastodon. It'll show up right here.

Try it

install-bx-module bx-activitypub

The README has the full IHostApp contract, a minimal example that runs as written, and the database schema. You'll need BoxLang 1.17+, MySQL 8 and a public HTTPS hostname.

And if you're on the fediverse, search for @[email protected] and follow along. Say hi.

No comments yet — be the first.