Skip to content

Federation (ActivityPub) ​

For what a person sees, read Following from the fediverse.

An instance of Spelling Creator can take part in the fediverse: every author is an ActivityPub actor, every published lesson is an Article, and somebody on Mastodon, or on another instance of this app, can follow an author and see their lessons as they are published. The protocol work is done by Fedify; this page is about how it is wired in, what crosses the wire, and what deliberately does not.

Federation is off until an instance opts in by setting FEDERATION_ORIGIN (see Turning it on). With it unset, nothing on this page runs and nothing is written to the federation tables.

What federates ​

On this instanceTo the fediverse
An authorA Person actor at /users/<id>, handle @<id>@<host>
A published lessonAn Article at /hub/<id>, by that actor, addressed to Public
Publishing a lessonCreate(Article) delivered to the author's followers
Saving a published oneUpdate(Article)
Deleting, unpublishing, or a moderator hiding oneDelete
A moderator showing it againCreate(Article) again

The actor's handle is the account id (@6f1c2d3e-...@spellingcreator.org) rather than the display name, because a handle has to be unique and must never change, and display names are neither: two people may both be "Miss Kelly", and anyone can rename themselves. The display name is the actor's name, which is what a fediverse client shows most prominently; the id only appears in the small print. A chosen handle is a possible later addition and would need a new uniqueness rule of its own.

An Article carries the title, a line saying who published it and how many sections it has, the first few lines of the passage, and a link to the lesson page. It does not carry the lesson: the lesson is read on its page, and copied through two attachments another instance of this app understands, links to the lesson's JSON document (/lessons/<id>) and its packed git history (/git/<id>/pack). Those are the two things a fork needs, so an instance that can read an Article can clone the lesson behind it with the full history a local fork gets. See Version history.

A draft and a shadowbanned lesson do not exist to another server: the Article dispatcher answers as if there were no such lesson, the outbox leaves them out, and hiding a lesson sends a Delete. The rule is the same as canReadLesson for the public, with no exceptions for the author, since a remote server cannot be the author.

What does not ​

This instance accepts three things from other servers: a Follow of one of its actors (answered with an Accept and remembered), the Undo of that follow, and a remote account's deletion (its follows are forgotten). Nothing a remote user writes ever becomes content here. A reply from Mastodon to a lesson is not a comment on it; a change offered from another instance is not a proposal. Every piece of content on the hub goes through the same bans, profanity check and moderation, and all of that is built on local accounts (auth.users). Accepting remote content means giving remote users a place in that model and giving moderators the tools to judge them, including blocking whole servers, and for an audience of nonspeaking spellers and their families that is not work to do by halves. It is the next step, not this one.

Likewise, nobody here follows anyone elsewhere, so there is no following collection and no home timeline of remote posts; the following that exists is the hub's own.

How it is built ​

apps/api/src/lib/federation/ holds all of it:

FileWhat it is
builder.jsThe Fedify FederationBuilder: actor, key and object dispatchers, the outbox and followers collections, the inbox listeners, NodeInfo.
article.jsA lesson row as an Article, and the Create, Update and Delete activities around it.
store.jsThe PostgREST reads and writes: keys, followers, and the lesson queries the collections page through.
index.jsThe switch (FEDERATION_ORIGIN), building the Federation once per env, and announceLesson, which the routes call.

It is a builder rather than a Federation because the stores Fedify needs are the host's bindings, which only exist once a request is in hand. federationFor(env) builds it on first use and memoizes it per env, the way the platform seam memoizes its adapters.

Fedify itself is loaded on first use (import() inside index.js) rather than at the top of the module. The lesson routes import index.js for announceLesson, and Fedify brings a large dependency tree with it that an instance with federation off never needs.

Requests ​

@fedify/hono provides a middleware, registered in app.js before every route. Fedify owns /.well-known/webfinger, /.well-known/nodeinfo, /nodeinfo/2.1, the inboxes, the collections, and, for ActivityPub requests only, the actor at /users/<id> and the Article at /hub/<id>. Both of those are also pages in the app; which one a request gets is decided by its Accept header, and a browser asking for HTML is handed on to the next handler as if the middleware were not there. With federation off the middleware is a pass-through.

Deliveries ​

A route that changes a lesson's public state calls announceLesson(env, ctx, row, kind) after the row is written. It builds the activity and asks Fedify to send it to the author's followers, preferring each remote server's shared inbox so one delivery serves every follower there, and excluding this instance's own inbox, since local followers read the hub directly. It runs after the response, through the execution context's waitUntil, and a failure is logged and never fails the save.

Whether a failed delivery is retried depends on the host's queue (see Per host). Fedify remembers which signature spec each remote server accepted (it tries RFC 9421 first and falls back to the older draft signatures Mastodon still uses) so later deliveries do not pay for the discovery again.

A remote inbox that answers 404 or 410 is gone for good, and the followers behind it are forgotten (setOutboxPermanentFailureHandler), so no later lesson is retried against it.

Keys ​

Every actor signs its requests, and a remote server verifies them against the public key in the actor document it fetched. So a key pair, once given out, has to stay the same: a key that changed under a remote server would make every later delivery fail verification there.

Keys are made the first time an actor is asked for, and only for an account that exists, so a request for a random id cannot fill the table with keys for nobody. Each actor gets an RSA pair (HTTP Signatures, which is what the fediverse verifies today) and an Ed25519 pair (Object Integrity Proofs, which is where it is heading), stored as JWKs in federation_keys. The insert is ON CONFLICT DO NOTHING and re-reads the winner, so two first requests racing for the same actor end up with one key pair, not two.

Followers ​

A Follow arrives at the author's inbox, signed by the follower, which Fedify verifies before the listener runs. The listener checks the followed actor is a real account here, fetches the follower's actor document (through the recipient's authenticated loader, so a server in authorized-fetch mode answers it), records the follower's id, inbox and shared inbox in federation_followers, and sends an Accept. Re-following is an upsert rather than a duplicate.

The followers collection is served to anyone, paged, as the protocol expects. Asked for the whole collection at once, which is what Fedify does to gather a delivery's recipients, it answers with every follower in one go rather than paging through its own table.

Forking a lesson from another instance ​

The attachments on an Article are what makes this possible: a lesson on any federating instance can be forked here with the full history a local fork gets, and the fork records where it came from.

The hub's Copy a lesson from another instance button takes the address of a lesson page elsewhere and hands it to the editor (through REMOTE_FORK_REQUEST_KEY in sessionStorage, the way a local fork request travels). The button only appears once GET /remote has said this instance can copy lessons, which it can when it federates. The editor cannot read the other instance itself: that instance's API only answers cross-origin reads from its own app, which is right. So this instance's API reads for it, on the signed-in caller's behalf:

RouteDoes
GET /remotePublic. { available }: whether this instance federates, and so whether it can copy lessons at all.
GET /remote/lesson?url=<page>Finds the lesson through ActivityPub and answers { lesson: { url, origin, title, author, sectionCount, createdAt, doc, packUrl } }.
GET /remote/pack?url=<pack url>Fetches the lesson's packed history and passes it through, tip and branch map in X-Git-Head and X-Git-Refs as from /git/:id/pack.

The last two need a Bearer token and answer 501 on an instance that does not federate. Both are shaped around the fact that the server is fetching an address a visitor typed, and every fetch either makes goes through the same guards (lib/federation/remote.js):

  • No private addresses. An address written as a private IP is refused up front. On the Node host the connection itself is made by a fetch whose socket lookup refuses any name with a private address among its answers (node/remoteFetch.js), so a domain that answers a public address when checked and 127.0.0.1 when fetched (DNS rebinding) gains nothing: the check happens when the socket opens. A Worker cannot reach a private network at all. A test pair (FEDERATION_ALLOW_PRIVATE_ADDRESSES) switches this off.
  • Redirects one by one. Up to three are followed by hand, each new address checked the same way; the pack proxy follows none.
  • Bounded reads. Bodies are read chunk by chunk and dropped the moment they pass the cap (20 MB for a lesson, the pack limit for a history), whatever Content-Length says or leaves out, and every fetch has a deadline.
  • Fedify parses, it does not fetch. The Article is loaded with those guards and handed to Fedify only to parse. Fedify's own lookupObject would fall back to a WebFinger request with its own fetch, outside them. The id of what comes back has to be on the origin that served it, as lookupObject requires.
  • One origin. It has to be an Article, and its two links, and the page the fork will be credited to, all have to be on its own origin, so an Article cannot send the server elsewhere or have a fork claim another hub's lesson. An Article on this very hub is refused with a pointer to the lesson's own Fork action.
  • Checked headers. The pack proxy passes a tip only when it is a commit id, and a branch map only when it reads cleanly and agrees with the tip, since both go on to the browser. The browser checks the tip again, and a clone that fails leaves no half-made repository behind.

With the document and the pack in hand the editor does what a local fork does (forkRemoteLessonRepo in core/browser/git/sync): clones the pack into the new lesson's repository, so the fork shares the original's commits and oids, and records the origin, which the lesson page shows as "Forked from the original on <host>". It is kept in lessons.forked_from_url, sent as forkedFromUrl on POST /lessons, since nothing in this database can be a foreign key to a row elsewhere; a lesson has one pointer home or the other, never both.

Pictures are the one part that is copied rather than cloned. The document refers to them by content hash, and the hashes belong to the other instance's store. The browser fetches each one from there, a few at a time (an instance's /images/:hash answers any origin, as this one's does), keeps it like a picture the user added, and rewrites the reference to the hash of the bytes it stored. Those are usually the compressed form the other instance serves, so the hash changes and the fork's first commit touches that picture block, an honest record of what happened to it; when the other instance kept the original, the hash comes out the same and nothing changes. Either way the picture is now in this browser's store, which is what lets the normal save-to-cloud upload it here under a hash the server can verify.

Only a picture this hub's own upload would take is kept: an image/ type, under the 8 MB limit (MAX_IMAGE_BYTES in core/image, which the upload route uses too). Anything else, such as an object served as application/octet-stream or an error page, would be uploaded on every save and refused every time, so it is treated as a picture that could not be copied: it keeps its old reference, and the editor says how many could not be copied, and also whether the history failed to come along. See copyRemoteImages in core/imagesClient.

Storing a picture hashes it with crypto.subtle, which browsers only offer on a secure origin: https, or localhost. An instance served over plain http at a network address is neither, so no picture can be copied there, and none can be added in its editor either. Two test instances reached over an SSH tunnel are on localhost and work.

Not done yet: pulling the original's later changes into a remote fork. The clone makes that merge possible (the shared ancestry is real), but the sync action is keyed on a local forkedFrom id today and stays hidden for a remote one.

Per host ​

Fedify needs two stores from the host, and the platform seam provides both:

Seam memberHosted instance (Cloudflare)Self-hosted (Node)
federationKvWorkersKvStore over the rate-limit KV namespace; every key starts _fedifypostgrestFedifyKv, the same kv_store table the rate limiters use, under a fedify/ prefix
federationQueueWorkersMessageQueue over a Cloudflare Queue, when the binding exists, else noneInProcessMessageQueue: retried while the process lives, lost when it stops

The key-value store holds Fedify's caches: remote actor documents and their public keys, which activities have already been processed, and which signature spec each remote server accepts. Nothing in it is authoritative, which is what lets it share a table with the rate limiters. postgrestFedifyKv is Fedify's own wider interface (keys are arrays of strings, values are JSON, prefixes can be listed) over PostgREST. A listing matches whole key segments, as Fedify expects (publicKey does not list publicKeyX), with a key encoding that keeps a _ or % in a key from widening it, and it reads in pages, since PostgREST cuts a single response at its max-rows without saying so. See apps/api/src/platform/postgrestFedifyKv.js.

Without a queue (federationQueue null), deliveries are made from the request that caused them and are not retried. For an instance with a handful of followers that is an honest trade against running a broker; for one with many, it is not, and the seam is where a Redis-backed queue would go. On the hosted instance the queue is a Cloudflare Queue bound in wrangler.jsonc, which the first deploy creates: wrangler provisions a named queue that does not exist yet, since its experimental-provision and experimental-auto-create flags are on by default. The Worker's queue() export works through its batches with Federation.processQueuedTask, acknowledging each message on success and retrying it alone on failure.

The dependency check (docker compose logs app, or GET /_diagnostics) reports federation on its own line: off, on at which origin, and whether deliveries will be retried.

Turning it on ​

Set FEDERATION_ORIGIN to the origin other servers reach the instance at, scheme included: https://spelling.example.org. It is written into every actor and object id, which is why it is explicit rather than read off the request: an id built from a Host header a client chose is an id another server will store. For the same reason it must not change once anyone follows someone here; followers hold the old ids and the keys that went with them. And it has to be https: an origin on plain http leaves federation off (the dependency check says so), since those stored ids would point other servers at an address anyone on the path can read and rewrite. The one exception is a test pair, below.

On the hosted instance it is a vars entry in wrangler.jsonc, and apps/api/scripts/dev.sh overrides it with the local address so a development Worker never claims to live on the real site. In the compose stack it is the FEDERATION_ORIGIN line of .env, normally the same value as PUBLIC_URL, and empty keeps federation off. See Self-hosting.

FEDERATION_ALLOW_PRIVATE_ADDRESSES=true switches off Fedify's refusal to fetch from or deliver to private network addresses, and lets the origin be plain http. That refusal is what stops a remote actor document from pointing the server at its own network, so the switch is for one thing only: two instances on one machine or private network, in development. The development Worker under portless has an https origin and federates; run with PORTLESS=0, its origin is plain http and federation stays off.

Trying it with two instances ​

Federation is server to server, so a test needs two instances that can each reach the other's FEDERATION_ORIGIN, not only a browser that can reach both. A development Worker on a laptop and a compose stack on another machine do not qualify unless the laptop is reachable from that machine under the very name its origin uses. Two compose stacks on one Docker host do, with the host's address on its network as both origins, and a script sets that up. On the Docker host, from a checkout with Git LFS:

bash
./scripts/federation-pair.sh            # or --address <ip> if it can't guess

It builds the image from the checkout under its own tag (so neither stack pulls the published one), writes ~/spelling-creator-federation/hub-a and hub-b with a .env each, starts both on ports 8080 and 8081, waits for them, and checks that each app container can reach the other instance at its origin, which a host firewall can quietly prevent. --help lists the options. Running it again keeps each instance's credentials, which its database volume was created with, and only rewrites the settings it owns.

What it sets in each .env, for doing the same by hand: its own COMPOSE_PROJECT_NAME (so the two stacks share no containers or volumes), a different PUBLIC_PORT and PUBLIC_HTTPS_PORT, PUBLIC_URL and FEDERATION_ORIGIN both http://<host address>:<port>, PUBLIC_HOSTNAME the address plus localhost, the same IMAGE_TAG, and FEDERATION_ALLOW_PRIVATE_ADDRESSES=true.

Then, in a browser that can reach the host (over SSH, ssh -L for each port works, since the pages only ever talk to their own origin): sign in on each, publish a lesson on one, and on the other paste that lesson's page address into Copy a lesson from another instance on the hub, using the host's address rather than localhost, since the server is what fetches it. For the follow side, curl one instance's WebFinger from the other host, or follow an author from any fediverse account that can reach the address.

Schema ​

apps/api/schema.sql adds two tables, both with row level security on and no policies, since one of them holds private keys:

sql
create table public.federation_keys (
  user_id    uuid primary key references auth.users (id) on delete cascade,
  rsa        jsonb not null,             -- { privateKey, publicKey } as JWKs
  ed25519    jsonb not null,
  created_at timestamptz not null default now()
);

create table public.federation_followers (
  user_id      uuid not null references auth.users (id) on delete cascade,
  actor_id     text not null,            -- the follower's actor URI
  inbox        text not null,
  shared_inbox text,
  created_at   timestamptz not null default now(),
  primary key (user_id, actor_id)
);

Creating them on an instance that never federates is harmless; nothing writes to them until FEDERATION_ORIGIN is set.

Testing it ​

apps/api/src/lib/federation/federation.test.js drives the real builder over an in-memory key-value store and a fake of the slice of PostgREST and GoTrue the dispatchers read, as another server would: WebFinger, the actor document, the Article, the collections, a draft from outside, and a lesson publication delivered as a signed request to a follower's shared inbox. It runs in the Node project only; Fedify's OpenTelemetry dependency imports node:process when it loads, which the Workers test runner cannot resolve, where the real Worker bundle can (pnpm --filter @spelling-creator/api bundle checks that).

Copyright © 2026 Spelling Creator.