Federation Module — ActivityPub
Location: apps/api/src/modules/federation/
Aggregate roots: RemoteFollower, RemoteFollowing, RemotePost (+ apPublicKey/apPrivateKey on User)
ActivityPub bridge to the Fediverse (ADR-008), shipped in three increments:
- v1 — read side: WebFinger, Actor, Outbox, NodeInfo. Regulus users discoverable + readable from Mastodon et al.
- v2 — write side: signed Inbox; inbound
Follow/Undo(Follow); outboundCreate(Note)delivery to remote followers on every public post. - v3 — inbound follow + read: local users follow remote actors, cache their
Create(Note)posts, and read a Fediverse feed in-app.
Identity Mapping
Public actor identifiers are acct:username@host (WebFinger style; host from FEDERATION_HOST, default api.rgls.uk). Actor URL: https://<host>/users/<username>. Internal UUIDs are never exposed — all federation routes key on username, and the acct mapping layer translates to internal ids. Each user gets a lazily minted, persisted RSA-2048 keypair (#main-key) for HTTP signatures.
Public Routes (domain root)
These live at the domain root, not under /api/v1 (excluded in main.ts), because the Fediverse expects well-known paths. No auth.
| Method | Path | Description |
|---|---|---|
| GET | /.well-known/webfinger?resource=acct:user@host | JRD discovery document |
| GET | /.well-known/nodeinfo | NodeInfo index |
| GET | /nodeinfo/2.1 | Instance metadata (user/post counts) |
| GET | /users/:username | ActivityStreams Actor (Person + publicKey) |
| GET | /users/:username/outbox | Last 20 public posts as Create(Note) |
| GET | /users/:username/followers | OrderedCollection (count only) |
| POST | /users/:username/inbox | Signed inbound activities (202) |
Inbox handling
Every inbound POST is verified with HTTP-Signature over the raw body (verifyInbound in signing.ts) before parsing. Then:
Follow→ upsertRemoteFollower+ send signedAcceptback.Undo(Follow)→ delete the follower row.Accept→ mark our outboundRemoteFollowingasaccepted.Create(Note)from an actor we follow → cache asRemotePost(content capped at 5000 chars).Delete→ drop the cachedRemotePost.
App-Facing Routes (/api/v1/federation, JWT)
| Method | Path | Description |
|---|---|---|
| POST | /federation/follow | Follow a remote handle (user@host) — sends signed Follow |
| POST | /federation/unfollow | Send Undo(Follow) + delete the row |
| GET | /federation/following | My remote followings (+ accepted flag) |
| GET | /federation/feed?limit= | Cached posts from followed remote actors |
Events Consumed
| Event | Handler |
|---|---|
posts.created | If the post is public (and author not banned), deliver Create(Note) to every remote follower's inbox (deduped by sharedInbox), each request HTTP-signed. Best-effort: failures never break local posting. |
Invariants
- Banned users 404 on all federation routes; outbound delivery skips banned authors.
- Only
visibility: 'public', non-hidden, non-deleted, non-scheduled posts appear in the Outbox or get delivered. - Post HTML content is escaped before embedding in Notes.
- Inbox returns
202and never trusts an unverified signature.
See also: /modules/posts, /modules/identity, ADR-008 in docs/architecture/.