BoxLang πŸš€ A New JVM Dynamic Language Learn More...

BxActivityPub

v0.7.0 BoxLang Modules

bx-activitypub

Outbound ActivityPub federation for BoxLang apps. Let people on Mastodon and the rest of the fediverse follow your app's accounts, and send them your posts, edits and deletions.

Your app keeps its data; the module handles the protocol: WebFinger, actor documents, HTTP Signatures, the inbox, a delivery queue that survives restarts, and NodeInfo.

Status: 0.7.0. Author mode (an account publishing Articles) is complete and verified against Mastodon. Posts carry their tags as hashtags. The module counts likes and boosts of your posts, and your app can accept public replies to them, with their edits and deletions, by implementing an optional second contract, reply back to them as the account, and pick up whole threads, including replies that never mention you. Everything else inbound is acknowledged and ignored.

Requirements

  • BoxLang 1.17+
  • MySQL 8.0.13+ (the module's tables use UUID_TO_BIN and expression defaults), through a BoxLang datasource (bx-mysql)
  • A public HTTPS hostname. Other servers must be able to reach you, and your hostname becomes part of every account's identity.
  • boxlang-express for the bundled routes adapter. The core module doesn't depend on it.

Install

install-bx-module bx-activitypub

or with CommandBox: box install bx-activitypub. Then create the tables in your app's database from the module's sql/schema.sql:

mysql your_database < boxlang_modules/bx-activitypub/sql/schema.sql

(That path is for an install into your app's own boxlang_modules/, i.e. with --local.)

The module adds seven Ap* tables and never reads or writes any of yours. Its classes are available as bxModules.bxactivitypub.*.

Upgrading: from 0.1.x run sql/upgrade-0.2.0.sql, then (from anything before 0.4.0) sql/upgrade-0.4.0.sql, then (from anything before 0.5.0) sql/upgrade-0.5.0.sql, then (from anything before 0.6.0) sql/upgrade-0.6.0.sql, once each.

How it fits together

You implement one class, IHostApp, which answers four questions about your data. The module does everything else.

your app ── IHostApp ──> bx-activitypub ──> signed HTTP ──> Mastodon, etc.
   β”‚                          β”‚
   └── express adapter β”€β”€β”€β”€β”€β”€β”€β”˜  (mounts the routes on your BoxExpress app)

The IHostApp contract

Types are "Person" and "Group" for accounts, "post" for posts and, if you reply back, "comment" for comments.

Method Returns
baseUrl() Your canonical origin, e.g. "https://example.com". Account and post ids are built from it.
findActor( type, name ) { id, name, displayName, summary, avatar, header, url } or null. id is your UUID for the account (keypairs and followers are keyed by it); name is the handle, as in @name@host; summary is HTML; avatar, header (the profile banner; Mastodon crops it to about 3:1) and url (the profile page) are optional absolute URLs.
getObject( "post", id ) { author, title, summary, content, url, published, tags } or null. author is the Person's name; content is HTML; url is the post's page; published is a date; tags (optional) is an array of { name, url }, sent as hashtags so posts appear in Mastodon's hashtag timelines on the servers that receive them. Names are reduced to letters, digits and underscores.
getObject( "comment", id ) Optional, for replying back: { author, postId, parentId, content, url, published } or null. author is the Person's name it goes out as; parentId is your id of the comment it answers ("" for top level); content is HTML. Return null for every comment to never reply back.
isPublic( type, id ) Whether this account, post or comment may be federated at all. Checked before every lookup, serving and delivery; false means the module behaves as if it doesn't exist. Return false for drafts, private content and inactive accounts.

Optional: IRemoteReplies

Implement these too (implements="bxModules.bxactivitypub.contracts.IHostApp,bxModules.bxactivitypub.contracts.IRemoteReplies") and replies from the fediverse to your public posts reach your app. Without them, the module stays one-way.

Method
acceptRemoteReply( postId, parentId, reply ) A new reply to postId; parentId is your id of the reply it answers, or "" for the post. Return your id for it, or null to refuse it.
updateRemoteReply( hostId, reply ) Its author edited it.
deleteRemoteReply( hostId ) Its author deleted it, or their account.

reply is { objectUrl, url, author { actorUrl, name, handle, profileUrl, avatarUrl }, contentHtml, sensitive, summary, published, attachmentCount }.

  • contentHtml is untrusted HTML from another server. Sanitize it before you store or render it (for example with bx-esapi's sanitizeHTML() and a policy that allows only what you want to show). The module removes the leading mention of your account but does nothing else to it.
  • Only public replies are passed on. Followers-only replies and direct messages are dropped, and so is anything whose author isn't the account that signed it.
  • Replies to replies you accepted arrive with parentId set, so threads keep their shape.
  • sensitive and summary are the author's content warning; attachmentCount is how many images or files were attached (they aren't passed on).
  • Moderation is yours: store replies as pending, publish them straight away, or anything in between.

Whole threads

A reply to someone else's reply only reaches you if it mentions your account. To fill in the rest of the conversation, fetchThreads() (scheduled every 30 minutes by the express adapter) reads the replies collection of each reply you accepted and fetches the ones you don't have, each from its own server, then passes them to acceptRemoteReply under the same rules as delivered replies. It follows threads for posts up to 30 days old, up to 3 levels below a delivered reply, fetching at most 50 new replies per run; a server that fails is skipped and retried later. See the thread* settings.

Deletions of fetched replies aren't delivered to you, so when a fetched reply disappears from its parent's collection and its server answers 404 or 410, it is removed through deleteRemoteReply. Edits to fetched replies aren't picked up.

Minimal example

A blog with one account, @blog, and one post. Put these three files in one folder, install the modules into it, and create the tables:

install-bx-module bx-activitypub,boxlang-express,bx-mysql --local
mysql -e "CREATE DATABASE mydb"
mysql mydb < boxlang_modules/bx-activitypub/sql/schema.sql

MyHost.bx

class implements="bxModules.bxactivitypub.contracts.IHostApp" {

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

	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>",
				avatar      : "",
				url         : baseUrl() & "/"
			};
		}
		return javacast( "null", "" );
	}

	function getObject( required string type, required string id ) {
		if ( type == "post" && id == "7d5c1f2e-3a4b-4c5d-8e6f-000000000101" ) {
			return {
				author    : "blog",
				title     : "Hello, fediverse",
				summary   : "My first federated post.",
				content   : "<p>Hello from BoxLang.</p>",
				url       : baseUrl() & "/posts/hello-fediverse",
				published : parseDateTime( "2026-10-01T12:00: 00Z" )
			};
		}
		return javacast( "null", "" );
	}

	boolean function isPublic( required string type, required string id ) {
		return true;
	}

}

app.bxs

app  = boxExpress()
host = new MyHost()
ap   = new bxModules.bxactivitypub.models.ActivityPub( host = host )

// Routes (WebFinger, actors, inboxes, posts, NodeInfo) and the delivery worker.
new bxModules.bxactivitypub.adapters.express().mount( app, ap )

// Your own pages. Mount them after the adapter: it answers ActivityPub requests and
// passes everything else through.
app.get( "/", ( req, res ) => res.send( "My Blog" ) )

// Send new posts, edits and deletions every two minutes.
app.schedule( 2 * 60 * 1000, () => {
	ap.syncPost( "7d5c1f2e-3a4b-4c5d-8e6f-000000000101" )
	ap.syncActor( "Person", "blog" )
}, { name : "activitypub-sync" } )

app.listen( 3000 )

boxlang.json

{
	"datasources": {
		"mydb": {
			"driver": "mysql",
			"host": "127.0.0.1",
			"port": "3306",
			"database": "mydb",
			"username": "root"
		}
	},
	"modules": {
		"bxactivitypub": {
			"settings": { "datasource": "mydb" }
		}
	},
	"logging": {
		"loggers": {
			"activitypub": { "level": "DEBUG", "appender": "file", "encoder": "text", "additive": false }
		}
	}
}

Run it behind your HTTPS hostname:

PUBLIC_BASE_URL=https://your.host boxlang --bx-config ./boxlang.json app.bxs

Then search Mastodon for @[email protected], follow it, and the post arrives within two minutes. Check it without Mastodon:

curl "https://your.host/.well-known/webfinger?resource=acct:[email protected]"
curl -H "Accept: application/activity+json" https://your.host/u/blog

Publishing

Call syncPost( id ) for any post that might have changed, as often as you like. It compares the post with what was last sent and does whatever is needed:

Post is… Sends
public and never sentCreate
public, sent, and its title, summary, content, url or tags changedUpdate
sent before, and now not public or gone (getObject returns null)Delete; its id then answers 410 Gone
anything elsenothing

syncActor( type, name ) does the same for the account's profile (Update{Person}): name, bio, avatar, header or profile URL. federatedPostIds() lists every post currently on the fediverse, so a sweep can revisit posts that have since been unpublished or deleted. publishPost( id ) sends a post's first Create explicitly.

A typical app runs a scheduled sweep: syncPost for its recent posts plus federatedPostIds(), then syncActor. Choose a cutoff for "recent". Mastodon files a post under its published date, so an old post federated today lands deep in followers' timelines, and backfilling your whole archive sends every new follower a flood.

Some things are permanent. Choose your hostname and handles before you federate for real: changing either orphans every follower. A post's id belongs forever to the account that first published it. A deleted post's id stays deleted: Mastodon won't accept it again, even if you republish the post.

Replying back

syncComment( id ) does for a comment what syncPost does for a post: Create, Update (content changed) or Delete, as a Note from its author's account. Only replies in fediverse threads go out: a comment answering a remote reply you accepted, or a comment answering one of your own that went out. Top-level comments and threads with no one from the fediverse stay home. The remote author being answered is mentioned, so their server notifies them, and gets it in their own inbox as well as the account's followers. federatedCommentIds() lists the comments currently on the fediverse, for a sweep.

Replies to your comments from the fediverse arrive through acceptRemoteReply with parentId set to your comment's id.

Whatever goes out goes out under the account's name. If other people can comment on your site, anything they write in reply to a fediverse commenter can go out as the account; hold those for approval (answer isPublic( "comment", id ) with false until approved) if you don't want that.

Likes and boosts

reactionCounts( "post", id ) returns { likes, boosts } for a post: likes and boosts from the fediverse are verified, stored once per account, removed when someone un-likes or un-boosts, and removed when their account is deleted.

Routes

The express adapter mounts these. Account and post routes answer ActivityPub requests (Accept: application/activity+json) and redirect browsers to the url from your host, or pass them through.

Method Path
GET/.well-known/webfinger acct:name@host or an account URL
GET/.well-known/nodeinfo, /nodeinfo/2.1 NodeInfo
GET/actor Instance account; signs outgoing requests
GET/u/{name}, /c/{name} Person and Group accounts
POST/u/{name}/inbox, /c/{name}/inbox, /inbox Follow, Like, Announce (boost) and their Undo; account deletions; with IRemoteReplies, replies (Create/Update/Delete of a Note)
GET/u/{name}/outbox, /c/{name}/outbox Empty collection
GET/u/{name}/followers, /c/{name}/followers Follower count only
GET/post/{id} Posts
GET/comment/{id} Comments that went out
GET/activities/{type}/{uuid} Every activity that was sent

The adapter's own settings (maxInboxBytes, inboxRateLimit, deliveryIntervalMs, threadIntervalMs) are listed under Settings; mount( app, ap, options ) can override them for one mount.

Settings

Every setting has a default in the module's ModuleConfig.bx. Override any of them in your app's boxlang.json, where ${env.NAME:default} placeholders work too:

{
	"modules": {
		"bxactivitypub": {
			"settings": {
				"datasource": "${env.AP_DATASOURCE:mydb}",
				"inboxRateLimit": 300
			}
		}
	}
}

Settings passed in code, new ActivityPub( host, settings ) or mount( app, ap, options ), win over both. ap.getSettings() returns the settings in effect.

Setting Default
datasource (required)Where the Ap* tables live
maxAgeSeconds 3600 Oldest signed Date accepted on incoming requests
maxFutureSeconds 300 Furthest-ahead signed Date accepted
httpTimeoutSeconds 10 Outgoing connect and request timeout
maxResponseBytes 1048576 Largest response read from another server
userAgent bx-activitypub/{version} (+{baseUrl}) Outgoing User-Agent
remoteActorTtlSeconds 86400 How long a fetched remote account (inbox, key) is used before refetching
remoteActorFailureSeconds 300 How long a remote account that couldn't be fetched isn't tried again
softwareName, softwareVersion bx-activitypub, module versionReported in NodeInfo
threadMaxDepth 3 Levels below a delivered reply that fetchThreads() follows
threadMaxPostAgeDays 30 Posts older than this aren't followed
threadMaxNewPerRun 50 New replies fetched per fetchThreads() run
threadWalkMinutes 30 How often each reply's replies are re-read
maxInboxBytes 262144 Largest inbox POST body accepted (express adapter)
inboxRateLimit 120 Inbox POSTs per client IP per minute; 0 for none (express adapter)
deliveryIntervalMs 5000 Delivery worker tick; 0 to run ap.processDeliveries() yourself (express adapter)
threadIntervalMs 1800000 Thread fetcher tick; 0 to run ap.fetchThreads() yourself (express adapter)

Security

  • Every incoming activity the module acts on must carry a valid HTTP Signature from the activity's own actor, with a matching Digest and a recent Date. Anything it doesn't act on is acknowledged without fetching anything. The Host is checked against your configured base URL, so a tunnel or proxy that rewrites it can't break verification.
  • Outgoing requests go only to HTTPS URLs on public addresses, and a remote account's inboxes must be on its own host, so a hostile account can't aim your server at internal or third-party URLs.
  • The inbox allows 120 POSTs per client IP per minute (inboxRateLimit), and an actor whose key couldn't be fetched isn't fetched again for 5 minutes, so junk requests can't make your server fetch URLs on demand. The limit keys on req.ip: behind a proxy or CDN, set BoxExpress's trust proxy or trust proxy header first, or every request shares the proxy's address.
  • Rejections tell the sender why in general terms; details such as the signature's signing string go only to your activitypub log. Inbox bodies are never logged, even at debug level: they can be followers-only replies or direct messages.
  • Private keys are stored in the ApActorKey table. Encrypt the database at rest if it's shared with anything else.

Delivery

Deliveries are queued in ApDelivery, so a restart loses nothing, and several app instances can run the worker at once. Failed deliveries retry after 1m, 5m, 30m, 2h and 12h, then give up. Each run sends to up to 10 inboxes in parallel, so a slow or dead server can't hold up the rest. Each inbox receives its activities in the order they were created. A 410 Gone removes the followers behind that inbox.

Logging

Everything goes to the activitypub logger. At DEBUG, as in the example, it records every inbound and outbound request with headers and body, which is how you debug a signature mismatch. Leave it at the default level in production.

Development

box install
mysql -e "CREATE DATABASE bxactivitypub_test"
box run-script test

Tests use the bxactivitypub_test database and load the module from tests/modules/ (see tests/boxlang.json). examples/dev-host/ is a small app for trying the module over a tunnel.

License

MIT

$ box install bx-activitypub

No collaborators yet.
   
  • {{ getFullDate("2026-09-28T17:06:11Z") }}
  • {{ getFullDate("2026-09-29T13:17:57Z") }}
  • 146
  • 37