A Small ActivityPub Server For A Static Blog

8 min readThis Blog, Web

Jake Marsh
In this post
  1. Routing Around A Static Site
  2. WebFinger
  3. The Actor Document
  4. Signing Requests
  5. Checking Incoming Signatures
  6. Follow, Undo And Accept
  7. Delivering New Posts
  8. Sending Notes Instead Of Articles
  9. A Queue That Retries
  10. Small Extras

You can follow this blog from Mastodon. Search for @blog@deallocatedobjects.com in Mastodon, Ivory, Ice Cubes or any other fediverse app, tap Follow, and new posts show up in your home timeline next to everything else you read.

The blog itself is a static site. nginx serves plain files, and nothing runs when you load a page. ActivityPub needs a few things a folder of HTML can’t do: it has to answer lookups with JSON built on the fly, accept signed POSTs, remember followers and push new posts out to them. I already had a small Bun service on the server for the newsletter signup form, so the fediverse side lives in that same process, in one file called newsletter/ap.ts. It’s about 200 lines, and it’s a trimmed-down port of the federation code I wrote for abiel.com.

Here’s how each piece works, including the details that took me a while to get right.

Routing Around A Static Site#

nginx sends three kinds of requests to the service on 127.0.0.1:3074 and serves everything else from disk:

deploy/nginx.confnginx
location = /.well-known/webfinger { proxy_pass http://127.0.0.1:3074; }
location = /.well-known/nodeinfo  { proxy_pass http://127.0.0.1:3074; }
location ^~ /ap/ {
    client_max_body_size 1m;
    proxy_pass http://127.0.0.1:3074;
}

(The real blocks also set the usual Host and X-Forwarded-* headers and a 30 second read timeout.)

The fourth route is the one I like best. When someone pastes a post’s URL into Mastodon’s search box, their server fetches that URL asking for ActivityStreams JSON instead of HTML. A map on the Accept header catches that and rewrites the request into the service:

deploy/nginx-markdown-map.confnginx
map $http_accept $deallocated_ap {
    default "";
    "~*application/activity\+json" "1";
    "~*application/ld\+json" "1";
}
deploy/nginx.confnginx
location ~ ^/posts/[^/.]+$ {
    if ($deallocated_ap) { rewrite ^/posts/(.+)$ /ap/note/$1 last; }
    if ($deallocated_md) { rewrite ^ $uri.md last; }
    try_files $uri.html =404;
}

So the same URL gives a browser HTML, an agent asking for text/markdown the markdown source, and a fediverse server a Note. Every response carries Vary: Accept so caches keep the three apart. You can see it yourself:

Shell
curl -s -H 'Accept: application/activity+json' \
  https://deallocatedobjects.com/posts/scheduled-posts-on-a-static-site

WebFinger#

Everything starts with WebFinger. When you type @blog@deallocatedobjects.com into Mastodon, your server asks https://deallocatedobjects.com/.well-known/webfinger?resource=acct:blog@deallocatedobjects.com where the account lives. The answer is small:

newsletter/ap.tsTypeScript
if (r !== `acct:${USERNAME}@${HOST}` && r !== actorId) return new Response("not found", { status: 404 });
return new Response(JSON.stringify({
  subject: `acct:${USERNAME}@${HOST}`, aliases: [actorId, `${SITE}/`],
  links: [
    { rel: "self", type: AP_TYPE, href: actorId },
    { rel: "http://webfinger.net/rel/profile-page", type: "text/html", href: `${SITE}/` },
  ],
}), { headers: {
  "Content-Type": "application/jrd+json; charset=utf-8",
  "Cache-Control": "public, max-age=86400",
  "Access-Control-Allow-Origin": "*",
} });

Three details matter here. It accepts the actor URL as a resource too, since servers sometimes look an actor up in reverse. The content type is application/jrd+json, WebFinger’s own type. And the CORS header lets browser-based clients do the lookup directly. The self link with type application/activity+json is the line Mastodon is looking for: it says where the actor document is.

The Actor Document#

The actor at /ap/actor is the account’s profile: name, bio, avatar, header image, and the URLs for everything else. A few fields are worth calling out:

newsletter/ap.tsTypeScript
"@context": ["https://www.w3.org/ns/activitystreams", "https://w3id.org/security/v1", { toot: "http://joinmastodon.org/ns#", discoverable: "toot:discoverable", indexable: "toot:indexable", /* ... */ }],
id: actorId, type: "Service", preferredUsername: USERNAME, name: "Deallocated Objects",
inbox: `${SITE}/ap/inbox`, outbox: `${SITE}/ap/outbox`, followers: `${SITE}/ap/followers`,
endpoints: { sharedInbox: `${SITE}/ap/inbox` },
manuallyApprovesFollowers: false, discoverable: true, indexable: true, attributionDomains: [HOST],
publicKey: { id: keyId, owner: actorId, publicKeyPem: keys.pub },

type: "Service" marks it as an automated account, so Mastodon shows it with a bot badge. That fits: it’s a feed of posts, and replies to it go nowhere. discoverable and indexable are Mastodon extensions, which is why the toot namespace has to be in the context for them to mean anything. attributionDomains pairs with a <meta name="fediverse:creator" content="@blog@deallocatedobjects.com"> tag in every page’s head, so when someone shares a post link, Mastodon can show the blog’s account on the link preview.

The publicKey block is how other servers check my signatures. The service generates a 2048-bit RSA key pair the first time it starts and stores both halves in a settings table in its SQLite database. The rule I wrote down next to it is to never regenerate them. Followers’ servers cache that public key, and a new one would make every future delivery look forged until they refetched it.

Signing Requests#

Mastodon expects every server-to-server request to carry an HTTP Signature in the older draft-cavage format, using rsa-sha256. You build a small string out of chosen headers, sign it with your private key, and put the result in a Signature header that names the key:

newsletter/ap.tsTypeScript
const names = ["(request-target)", "host", "date"];
const lines = [`(request-target): ${method.toLowerCase()} ${u.pathname}${u.search}`, `host: ${u.host}`, `date: ${date}`];
const headers: Record<string, string> = { Host: u.host, Date: date, "User-Agent": UA };
if (body != null) {
  const d = digest(body); // "SHA-256=" + base64 of the body's sha256
  Object.assign(headers, { Digest: d, "Content-Type": AP_TYPE });
  names.push("digest", "content-type"); lines.push(`digest: ${d}`, `content-type: ${AP_TYPE}`);
} else headers.Accept = AP_ACCEPT;
const signature = createSign("sha256").update(lines.join("\n")).sign(keys.priv, "base64");
headers.Signature = `keyId="${keyId}",algorithm="rsa-sha256",headers="${names.join(" ")}",signature="${signature}"`;

(request-target) is a pseudo-header: the lowercase method, a space, and the path with its query string. Signing it ties the signature to this exact request, so it can’t be replayed against another URL. For POSTs I also sign a Digest header, which is the SHA-256 of the body, so the body can’t be swapped out underneath the signature. Mastodon rejects a POST whose signature doesn’t cover the digest.

I sign GETs too. Some servers only hand out actor documents to signed requests, so when the service fetches a follower’s profile it signs that fetch and asks for application/activity+json, application/ld+json; profile="https://www.w3.org/ns/activitystreams", which covers both ways servers like to be asked.

The keyId is https://deallocatedobjects.com/ap/actor#main-key. A receiving server fetches that, finds the actor document, pulls out publicKeyPem and checks the signature.

Checking Incoming Signatures#

The inbox does the same thing in reverse. A POST to /ap/inbox has to pass four checks before the service does anything with it:

newsletter/ap.tsTypeScript
if (!p.signature || !["rsa-sha256", "hs2019", undefined].includes(p.algorithm)) return false;
const theirs = (req.headers.get("digest") ?? "").split(",").map((d) => d.trim()).find((d) => d.toLowerCase().startsWith("sha-256="));
if (!theirs || theirs.slice(8) !== digest(raw).slice(8)) return false;
const date = req.headers.get("date");
if (!date || Math.abs(Date.now() - +new Date(date)) > 3600e3) return false;
const lines = (p.headers ?? "date").split(/\s+/).map((h) => (h === "(request-target)" ? `(request-target): ${req.method.toLowerCase()} ${path}` : `${h}: ${req.headers.get(h) ?? ""}`));
return createVerify("sha256").update(lines.join("\n")).verify(pem, p.signature, "base64");

The algorithm has to be one I can check (hs2019 shows up in the wild even when the key is plain RSA). The Digest has to match the raw bytes I received, so I hash the body before parsing it as JSON. The Date has to be within an hour of now. And the signature has to verify against the sender’s public key, rebuilding the signed string from whatever headers they say they signed, in their order.

That public key comes from fetching the sender’s actor, and I cache it in an ap_actors table. If verification fails with the cached key, the service fetches the actor once more and tries again before giving up with a 401. That second try handles people who moved servers or whose server rotated keys, without fetching a profile on every single request.

Two smaller things keep the inbox calm. Every activity id gets written to an ap_seen table (kept for 14 days), and a repeat gets a 202 and nothing else, since servers do retry. And a Delete where the actor is deleting itself, from an actor I’ve never stored, gets a 202 right away. Account deletions go out widely across the fediverse, the actor is already gone so there’s no key to fetch, and there’s nothing of theirs here to remove.

Follow, Undo And Accept#

Once a request is verified, the blog only cares about three kinds of activity:

newsletter/ap.tsTypeScript
if (a.type === "Follow" && object === actorId && remote.inbox) {
  db.run(`insert into ap_followers (actor, inbox, shared_inbox, created_at) values (?, ?, ?, ?)
    on conflict (actor) do update set inbox = excluded.inbox, shared_inbox = excluded.shared_inbox`,
    [remote.actor, remote.inbox, remote.shared_inbox, now()]);
  enqueue([remote.inbox], { "@context": "https://www.w3.org/ns/activitystreams",
    id: `${actorId}#accepts/${crypto.randomUUID()}`, type: "Accept", actor: actorId,
    object: { ...(a.id ? { id: a.id } : {}), type: "Follow", actor: remote.actor, object: actorId } });
} else if (a.type === "Undo" && a.object?.type === "Follow") {
  db.run("delete from ap_followers where actor = ?", [actor]);
} else if (a.type === "Delete" && actor === object) {
  db.run("delete from ap_followers where actor = ?", [actor]); db.run("delete from ap_actors where actor = ?", [actor]);
}
return new Response(null, { status: 202 });

A Follow gets stored with both the follower’s personal inbox and their server’s shared inbox, and then the blog sends back an Accept. The Accept wraps the original Follow, including its id. Mastodon matches the Accept to the pending follow request by that id, and until it arrives the follow button stays on “Requested”. Since manuallyApprovesFollowers is false, the Accept goes out right away.

Unfollowing arrives as an Undo wrapping the old Follow, and it removes the row. Everything else (replies, likes, boosts) gets a 202 and is ignored. A blog that answers replies would need moderation, and this account is a feed.

Delivering New Posts#

Here’s where the static site and the service meet. The site build writes feed.json, and the service reads that same file from the site’s folder on disk. Every five minutes it looks for feed items newer than the moment federation was turned on, and hands each new one to publish:

newsletter/server.tsTypeScript
const apTick = async () => {
  try { for (const item of (await readFeed()).items) if (item.date_published > apSince! && ap.publish(item)) console.log(`ap: published ${item.url}`); }
  catch (e) { console.error(`ap tick: ${(e as Error).message}`); }
};
void apTick(); setInterval(apTick, 5 * 60e3);

publish records the slug in an ap_published table first, so a post goes out once even across restarts. Then it builds a Create activity around a Note and queues one copy per inbox, using each follower’s shared inbox when they have one. That’s what keeps it cheap: a hundred followers on one Mastodon server is one delivery, and that server fans the post out to all of them.

Nothing on my Mac has to be awake for this. A scheduled post goes live when a timer on the server swaps in a pre-built copy of the site, which brings a new feed.json with it, and within five minutes the next tick sees the new item.

Sending Notes Instead Of Articles#

ActivityStreams has an Article type that sounds like the right fit for a blog post. In Mastodon 4.3 and later, an Article shows up in the timeline as only its title and a link. A Note shows its full HTML content, so I send a Note that reads like a small card: the title in bold, a summary paragraph, then the link.

newsletter/ap.tsTypeScript
id: item.url, type: "Note", attributedTo: actorId, url: item.url, published: item.date_published,
to: [AS_PUBLIC], cc: [`${SITE}/ap/followers`],
content: `<p><strong>${esc(item.title)}</strong></p><p>${esc(item.summary ?? "")}</p><p><a href="${esc(link)}">${esc(shown)}</a></p>`,
tag: (item.tags ?? []).map((t) => ({ type: "Hashtag", name: `#${t.replace(/[^A-Za-z0-9]/g, "")}`, href: `${SITE}/topics/...` })),

The Note’s id is the post’s own URL. That’s the same URL the nginx rewrite answers with this Note, so whether someone finds a post in their timeline or by pasting the link into search, their server ends up with the same single object. The post’s topics become hashtags that link to the matching topic pages, and the post’s link-preview card rides along as an image attachment.

For posts older than the feed, the build also writes ap-posts.json with every listed post, and /ap/note/<slug> falls back to it. That’s how a link to a 2012 post still resolves in Mastodon search. The outbox at /ap/outbox returns the 20 newest posts as Creates, so a new follower who opens the profile sees recent posts right away.

A Queue That Retries#

Deliveries go through an ap_deliveries table. The payload is stored unsigned and gets signed at the moment it’s sent, which matters because the Date header is part of the signature: a retry signed hours ago would fail that one-hour freshness check on the other end.

newsletter/ap.tsTypeScript
const BACKOFF = [1, 8, 40, 120, 360, 720, 1440, 2880]; // minutes
// ...
if (res.status === 501 || (res.status >= 400 && res.status < 500 && ![401, 408, 429].includes(res.status))) {
  db.run("update ap_deliveries set status = 'failed', attempts = attempts + 1, last_error = ? where id = ?", [err, d.id]); return;
}

The queue drains eight deliveries at a time, and it runs both right after something is queued and once a minute on a timer. A 2xx marks a delivery done. Most 4xx responses mean the other server will never take it (the account is gone, or the server doesn’t want it), so those fail right away. The exceptions are 401, 408 and 429, which are often temporary: a key the other side hasn’t refreshed yet, a timeout, or rate limiting. Those, along with 5xx errors and network failures, wait for the next step of the backoff. Eight retries spread from one minute to two days add up to just under four days before a delivery is given up on, which is plenty of room for a server to come back from maintenance.

Small Extras#

NodeInfo at /.well-known/nodeinfo points to a short document that says this is a single-user server running software called deallocatedobjects with closed registrations. Fediverse crawlers and some instance software read it to learn what they’re talking to. The followers collection at /ap/followers reports a total and no list, and the following collection reports zero.

If you’re building this for your own static site, start from the inbox and the signing code: once those work, the rest is a handful of JSON documents. And to watch it run, follow @blog@deallocatedobjects.com and keep an eye on your timeline when the next post goes out.