Files
SocialPub/FEDERATION.md
T
thepraandClaude Opus 5.5 0646de22bd Replies to a persona's posts reach its followers; personas join remote events
Two owner decisions of 2026-10-05, recorded in the roadmap.

Replies passed on ("the fediverse is broken without"): a public or unlisted
reply from another server to a persona's public, unlisted or followers-only
post goes on to the persona's followers as its author's server sent it, as
Mastodon forwards it, never to the replier's own server, never for a
local-only or group post; its edit and deletion follow. Only an activity its
own actor delivered is passed on (Arrival.Raw), so nothing forwarded is
forwarded again. The town checks it as relay.reply cells (specs/relay-five:
882 checks pass); Mastodon takes a passed-on activity only with an LD
signature, which GoToSocial and Akkoma don't add, and the checker knows it.

Events: a persona joins another server's event with a Join and leaves it with
a Leave, both to the organiser only, through
POST /api/privapub/v1/statuses/:id/join|leave; the organiser's Accept or
Reject is routed by our join id and shows as privapub.event.participation.
Events by invitation or taken on another site are refused before anything
is sent. Mobilizon's scenario joins and leaves an event (28 checks) and keeps
one for decePubClient's e2e.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-05 14:55:20 +02:00

23 KiB

Federation

PrivaPub is an ActivityPub server written in C#. This document follows FEP-67ff and describes how it federates.

Supported federation protocols and standards

Tested against

End to end, in a private network on the workstation (tools/pasture/), each peer driven through its own client API and every run starting clean, with signed fetches required (as privapub.thepra.dev runs):

  • GoToSocial 0.22.1
  • Mastodon 4.7.3
  • Misskey 2026.10
  • Sharkey 2025.4.7
  • Akkoma 3.20.1
  • Lemmy 1.0.0-beta.2
  • PeerTube 8.3.1
  • Pixelfed 0.14.4
  • WordPress 6 with ActivityPub 9.3.1
  • Friendica 2026.05
  • Mobilizon 5.2.4
  • Gancio 1.28.2
  • Funkwhale 2.0.11
  • in the town only (a seeded community checked server by server): Hollo 0.9.19, Iceshrimp.NET 2026.1.2-beta, Pleroma 2.10.2

Checked both ways, where the peer has the feature:

  • follows, locked accounts included;
  • every visibility, content warnings, replies and their notifications;
  • likes, boosts and emoji reactions with their undos;
  • direct messages;
  • polls;
  • quotes;
  • images with alt text;
  • edits with history and deletes;
  • communities and circles;
  • blocks and unfollows.

docs/INTEROP.md has each peer's evidence and what is still expected to fail. PeerTube, PieFed, Mbin and the others are covered by unit tests written in their documents' shape.

Supported FEPs

Planned (see docs/ROADMAP.md, phases P7 and P8, and the per-platform notes in docs/INTEROP.md): FEP-9098 (custom emoji), FEP-7888 and FEP-f228 (threads), FEP-7628 (Move), FEP-8fcf (followers synchronisation), FEP-8967 (link attachments), FEP-521a and FEP-8b32 (keys and integrity proofs), FEP-ae0c (relays).

Actors

Every account is an avatar: one private login can own several, and they are deliberately unlinkable. Nothing in an actor document, a collection, NodeInfo or a delivery relates two avatars of the same login.

Thing Address
Actor (Person, Group, Application) /peasants/{name} (/users/{name} redirects)
Inbox /peasants/{name}/mouth
Outbox /peasants/{name}/anus
Shared inbox /human-centipede
Followers / following /peasants/{name}/groupies, /peasants/{name}/stalking
Objects /peasants/{name}/scribbles/{id}
Activities /peasants/{name}/grunts/{id}
Quote permissions (QuoteAuthorization) /peasants/{name}/parrot-licences/{id}
Direct-message context /peasants/{name}/whispers/{id}
Profile and post pages /@{name}, /@{name}/{id}

The names are the project's own and are stable; resolve actors through WebFinger, not by guessing a path.

  • The key is {actor}#main-key, RSA 2048, served as SPKI PEM with owner set to the actor.
  • published on an actor is a whole day, chosen at random up to two weeks before the account was made, so two personas made on the same day do not share a date. indexable is false unless the persona turns it on, and discoverable is true unless it turns that off (the deploy's own persona, @thepra, is undiscoverable).
  • The instance actor is /peasants/privapub (type Application). It signs every fetch PrivaPub makes, so no avatar's key is used to read another server's content.

Groups

A group is either a community or a circle.

  • A community follows FEP-1b12. Posts addressed to it (in to, cc or audience) are accepted according to its posting policy (followers, anyone, or moderators only) and the group Announces the whole activity, with audience set, to its followers. A new post is also announced as an object so Mastodon shows it. Updates and deletes of community content are announced too. A top-level post is a Page with a name. Members are counted at /flock; moderators are listed at /wardens, which the actor's attributedTo points to, with postingRestrictedToMods as Lemmy expects. A mention of a community posts into it.
  • A circle is private. Its actor is not discoverable and every follow is a request. Its posts are addressed to the circle and its members collection, delivered to each member's own inbox and never announced. Each member's copy also names that member in cc, so servers that keep only posts naming one of their accounts (Mastodon, GoToSocial) keep it; it names no other member. The posts are served only to a signed request from a member, or from the instance actor of a member's server, and that copy names the member (or the members on that server); anyone else gets 404. A Mastodon member's replies reach only the people they mention.
  • Announces from remote groups (Lemmy communities) are followed through to the activity: the object is fetched from its own origin, never taken from the announce. They are taken from a group someone here follows, and also when they relay a vote on one of our posts in a thread of that group (Lemmy sends a vote to the community alone). A post keeps the group its audience names, however it arrived.
  • A remote community's moderation reaches the posts it holds. A removal (Announce{Delete}) is believed once the post's origin answers it gone. A lock (Announce{Lock}, or commentsEnabled: false on the post) refuses replies, ours included, until Undo{Lock}. A ban of a persona (Announce{Block} with the community as target) shows as blocked_by on the community and refuses the persona's posts and replies there until the Undo.

Activities

Received:

Activity Effect
Follow follows an avatar or community; Accept is sent unless the community approves members by hand
Accept{Follow}, Reject{Follow} completes or ends a follow an avatar requested
Accept{Join}, Reject{Join} from the event's server: a persona's participation in the event is accepted or refused
Undo{Follow, Like, Announce} reverses it
Create{Note, Article, Page, Question, Video, Audio, Event, ChatMessage, …} stored when a local avatar follows the author, is addressed or mentioned, when it replies to a local post, or when it is addressed to a community the author follows; a public parent is fetched to complete the thread
Update{Note} replaces the content; the previous version is kept
Update{Person} refetches the actor
Like counted and notified, on posts the liker could see
Dislike counted as a downvote (Lemmy, PieFed, Mbin, Friendica); Undo takes it back
EmojiReact, Like with an emoji content an emoji reaction (FEP-c0e0; Pleroma, Akkoma, Iceshrimp.NET, Misskey, Sharkey), Unicode or a custom emoji from its tag; a Like whose content is ❤ stays a favourite; Undo takes it back
Join answered with Ignore: PrivaPub hosts no events of its own yet (FEP-8a8e)
Announce counted and notified for local posts; shown to followers of the announcer, with the original refetched from its origin
Delete deletes the object, or the actor and its follows; a deleted object id is remembered for 90 days, so a late Create cannot bring it back
Flag becomes a report for this server's moderators
Block of a persona the follows between them end, the blocker's posts and notifications are hidden from the persona, nothing of the persona's is addressed to the blocker, and the relationship says blocked_by; Undo{Block} lifts it

Sent: Follow, Undo{Follow}, Create{Note}, Create{Question} and poll votes, EmojiReact and its Undo, Update{Note}, Update{Person}, Delete{Tombstone}, Accept{Follow}, Reject{Follow}, Like, Announce and their Undo, Flag, Join and Leave of a remote event, and the replies to a persona's posts passed on to its followers. A deleted post answers 410 with a Tombstone.

  • Attachments are Documents with mediaType, name (alt text), blurhash, focalPoint, width and height. Uploaded files have all metadata removed.
  • Pinned posts are the actor's featured collection (/trophies); featuredTags is /tattoos.
  • Blocks are sent. A blocked remote account receives Block from the blocking account (and Reject{Follow} if it followed); an unblock sends Undo{Block}.
  • Reports are sent as Flag by the instance actor, never by the reporting account.
  • Replies are passed on (inbox forwarding; owner decision 2026-10-05). A public or unlisted reply from another server to a persona's public, unlisted or followers-only post goes on to the persona's followers as its author's server sent and signed it, the way Mastodon forwards it: to every follower's server but the replier's own. Its Update and Delete follow the same way. Nothing is passed on for a local-only post, a group's post or a reply that is not public or unlisted. A server that receives it checks it as it checks any forwarded activity (Mastodon by the LD signature or by reading the reply from its origin).
  • Events (owner decision 2026-10-05). A persona joins another server's event with a Join and leaves it with a Leave, both sent to the event's organiser only (…/grunts/join-<id>, …/grunts/leave-<id>). The organiser's server answers Accept (Mobilizon at once for an open event) or Reject, which the persona sees as privapub.event.participation: pending, accepted or rejected. An event that takes participants by invitation or on another site (Mobilizon's joinMode invite or external) is refused before anything is sent (422). Who takes part is public on the event's server.

A Create's Note carries Mastodon's content, contentMap, summary and sensitive, plus Mention and Hashtag tags.

Polls (FEP-9967) go out as a Question with oneOf or anyOf, each option's count in replies.totalItems, endTime, votersCount, and closed once it has ended. Incoming votes are Create{Note{name, inReplyTo}} with no content, one per choice, counted once per voter. Counts are refreshed with an Update{Question} at most every three minutes. Our own votes on other servers' polls are sent the same way to the poll's author only, without published. An incoming Update without a newer updated only refreshes counts and details; it is never recorded as an edit.

Quotes (FEP-044f, with the older keys):

  • Received. A quote is read from quote, quoteUrl, quoteUri, _misskey_quote or a FEP-e232 Link tag.
    • With a quoteAuthorization, the stamp is fetched and must be a QuoteAuthorization on the quoted author's origin, attributed to them, naming exactly the two posts.
    • A FEP-044f quote without a stamp is pending until an Update brings one.
    • A quote made with the older keys only, by software that asks nobody, is shown when the quoted post is public.
    • A Delete of a stamp revokes the quote.
  • Sent. Our accounts quote with every key, plus the RE: fallback.
    • When the quoted post states an interactionPolicy.canQuote that allows us, quote is included and a QuoteRequest (with the quoting post as instrument) goes to the quoted author. The quote stays pending until their Accept brings a stamp we can verify; the post is then updated with quoteAuthorization.
    • Posts that state no policy are quoted the older way, without quote.
    • Quoting posts are also delivered to the quoted author.
  • Quoting our accounts. Our public and unlisted posts state interactionPolicy.canQuote. By default anyone may quote, automatically (as on Mastodon); an account can choose followers only or nobody, and so can each post.
    • A QuoteRequest is answered with Accept{result} naming a stamp at /peasants/{name}/parrot-licences/{id}, or with Reject.
    • A stamp is a QuoteAuthorization naming both posts. A revoked stamp answers 410 and is announced with Delete{stamp}.
    • Followers-only posts and DMs can never be quoted.

Interaction policies (GoToSocial's canReply, canLike, canAnnounce, with the older always and approvalRequired):

  • Read on every remote post; one left out means anyone, automatically. A persona is let in at once when the rule names the public, the persona itself, the author's followers while it follows the author, or the accounts the author follows while the author follows it.
  • Shut out: the client API answers 422 to a reply, favourite or boost the rule does not allow at all.
  • Asked first: a ReplyRequest, LikeRequest or AnnounceRequest (the interaction as its instrument) goes to the author alone, and the interaction waits (privapub.approval: pending). The author's Accept brings an authorization (result), which must be on the author's origin, attributed to them and naming the interaction and the post; only then does the reply go out with replyAuthorization, the boost with announceAuthorization and the like with likeAuthorization. A Reject leaves it ours alone (rejected), and takes a like or a boost back.
  • As a third party, a reply that a post's policy does not let in at once is kept only with an authorization that verifies the same way. A rule naming a collection we cannot list (followers, following) lets the reply in.
  • Shown to clients as GoToSocial's interaction_policy (can_favourite, can_reply, can_reblog).
  • Our own posts state only canQuote: anyone may reply to, like and boost them.

Custom emoji (Emoji tags) are read on posts, display names, bios and profile fields, at most 64 per object. Profiles keep their header, profile fields, manuallyApprovesFollowers, published, movedTo, indexable, memorial and avatar and header descriptions. A post's title becomes name and is also the first, bold line of content, because Mastodon does not show name. A content warning without its own text uses the title, or "Content warning".

Received summary is a content warning only on a Note or Question, or when sensitive is true. On an Article, Page, Event, Video or Audio it is an excerpt or description, kept as such and never hidden.

Visibility is expressed in to/cc the way Mastodon does it: public, unlisted, followers-only and direct. Inbound followers-only posts are recognised by the author's own followers collection.

For a public post that links to a page, PrivaPub builds a preview card. It uses, in order:

  1. a Link attachment's own preview (FEP-8967) or the post's own image, title and summary;
  2. otherwise, the linked page, read once by the server between 0 and 60 seconds after the post arrives (never when someone reads it), with an Accept: text/html request that reads at most 512 KB, through the same address checks as every other fetch.

The page's OpenGraph and Twitter tags give the title, description and image; the result is cached per address for 7 days and shared by every account on the server. Images are served to clients only through PrivaPub's media proxy. Federation:FetchLinkPreviews=false turns page fetching off.

Server descriptions and the crawler

PrivaPub keeps statistics about servers, never about accounts (see /stargazing on the server).

  • Describing a server: a server it exchanges activities with is described at most once a week, from:

    • /.well-known/nodeinfo and the NodeInfo document it links;
    • /api/v2/instance, falling back to /api/v1/instance.

    These requests are unsigned, because they are not ActivityPub documents.

  • The crawler is on at privapub.thepra.dev. It identifies as PrivaPub-Stargazer/<version> (+https://privapub.thepra.dev/stargazing).

    • What it reads: /robots.txt, then the documents above and /api/v1/instance/peers, and nothing else: no accounts, posts or directories.
    • How often: one server a minute, each at most weekly.
    • Opting out: in robots.txt, the crawler obeys the group PrivaPub-Stargazer, then PrivaPub, then *. A robots.txt that answers with a server error or times out also keeps it out, as do this server's domain blocks.

Local-only posts

Posts with a location (shown to nearby users of this server) never leave the server, in any form.

Security rules a peer will notice

  • Signatures. Inbox POSTs must be signed over (request-target), host, digest and date (or (created)). The date may be at most one hour old and fifteen minutes ahead. A bad signature gets 401, malformed input 400, an accepted activity 202, too many requests 429. Activities are processed after the 202.
  • Origins. An actor document is accepted only from the address it names as its id. A key only if its actor lists it with owner set to the actor and on the actor's origin. An activity's id, and any object it creates, updates or deletes, must be on its actor's origin. An embedded object from another origin is fetched from that origin.
  • Another account of the same server. An object attributed to another account of the actor's own server (Mobilizon's organiser creates, edits and deletes the group's event) is that server's to vouch for: a creation or an edit is taken as the server has the object, a deletion once it answers 404 or 410. Attributed to an account elsewhere, it is refused (400).
  • Forwarded activities (ActivityPub's inbox forwarding: a thread's server passing on the replies in it, signed with its own key) are answered 202 and believed only as far as their origin vouches for them: a created or edited post is read again from its author's server, a deletion of a public or unlisted post is applied once that server answers 404 or 410, and anything else is dropped. LD signatures are not verified. A reply forwarded this way is kept when someone here follows the author of the post it answers, as Mastodon does.
  • Follow requests still unanswered are sent again after 15 minutes, an hour, 6 hours, a day, two and four days: a server can take a Follow and lose its answer (Lemmy 1.0 sends nothing it queued for a server before it started sending there), and one that holds the follow answers it again. Each is the same follow under a new id (…/follow-<id>-again-<n>), since Lemmy ignores an id it has seen; an answer naming any of them counts.
  • Fetching. All fetches are signed by the instance actor. They go only to public addresses, follow at most three redirects and read at most 1 MB.
  • Threads. A reply's missing parents are fetched, up to 10 levels. When someone here opens a public remote thread, its replies are read from their servers, at most once an hour: the thread's context collection (FEP-7888) if it has one, else its replies (PeerTube's comments) and theirs, two levels down; 5 pages and 100 posts at most. Only public and unlisted replies are kept, each fetched from its own origin.
  • Reading our documents (SecureMode). privapub.thepra.dev answers ActivityPub GETs only when they are signed, like Mastodon's authorized fetch; the instance actor /peasants/privapub is the exception, since its key is needed first. A browser asking for HTML is redirected to the public page instead.
  • Posts that are not public (followers-only, direct, circle) are served to a signed request from someone they were for, or from the instance actor of a server where someone they were for lives, and to nobody else (404). Once deleted they answer those same readers 410. A direct message's context, /peasants/{name}/whispers/{id}, is an OrderedCollection of the conversation's posts for its participants.
  • HTML. Received HTML is sanitised to Mastodon's allowlist.
  • Keys we cannot fetch for now. When a sender's key cannot be fetched because its server timed out or answered 5xx, the inbox answers 503 with Retry-After: 300 rather than 401.
  • Activity ids. Create and Announce ids dereference, a community's announces included, until the post they carry is deleted (then 410); a persona's boost id sends a browser to the boosted post. Follow, Like, Block and the Accept, Reject and Undo that answer them do not, because serving them would reveal who follows, likes and blocks whom. Neither do Update, Delete, EmojiReact, QuoteRequest and its answers, Flag, Ignore or poll votes. All of them are always sent with their object embedded. Every boost and every favourite is an activity of its own (announce-{boost}, like-{favourite}), so one given again after its Undo is new to the server it reaches.
  • Hashtags. A post's Hashtag links go to /tags/{tag}, a public page of this server's public posts with that tag.
  • Delivery. Failed deliveries are retried with Mastodon's backoff (16 attempts). A host that keeps failing is paused, starting at an hour and growing to a week. A server can also take a Follow (202) and drop it afterwards, as Pleroma does when it cannot yet fetch our actor, so a follow request still unanswered is sent again, same activity, when the persona follows once more, at most once an hour.

Known limitations

  • Our own posts carry no custom emoji: emoji are received and shown, not offered.
  • Remote media is fetched through this server's proxy when a local client displays it. Video and audio are streamed through it with byte ranges passed on, so a PeerTube file is never fetched whole for one viewer.
  • Collections expose counts, not members: /groupies and /stalking give the same totals as the Mastodon API's follower and following counts, and the outbox's totalItems is the persona's post count, though only public posts are listed. /api/v1/instance/peers is empty: which servers this one talks to is not published.
  • Only RSA keys are verified, under draft-cavage or RFC 9421; Ed25519 (FEP-521a) is planned. What PrivaPub sends is signed with draft-cavage only, which every server reads.