Votes out. A persona's downvote is a Dislike (POST /api/v1/statuses/:id/downvote and /undownvote, the viewer's own as privapub.votes.downvoted). One vote a post: a changed vote is sent as the new vote alone, as Lemmy sends one, since an Undo of the old one beside it could arrive after it and take the new one away; Undo goes only when a vote is taken back. A vote on a post in a remote community goes to the community, which counts it, and to the author only when on another server: one copy a server, since PieFed drops a second copy of an activity it has just seen. Mbin keeps a favourite apart from a vote, so there a favourite stays after a switch to a downvote. Feeds followed (owner decision 2026-10-07: through the server). Following a feed (Lemmy's multi-community, PieFed's feed) keeps a FeedSubscription for the persona and nothing else; the new Service privapub_feeds (LocalActorKind.Reader, reserved by migration _017) follows every community of the feeds read here, reconciled when a persona follows or leaves one, when a feed's list is read again (kept as it was when it cannot be read) and every six hours. Its Following rows carry FollowerKind, so nobody's home gets what it brings in and its unanswered follows are sent again as its own. The persona reads GET /api/v1/timelines/feed/:id (the feed's threads, ours included) and lists its feeds at GET /api/v1/feeds. Checked in the pasture, every scenario: 873 pass. The 14 failures are Hubzilla's (identical on the previous commit: Hubzilla no longer answers a follow in this pasture since its restore) and two activities Smithereen never sent; followsync passes once the pasture's restore is older than the 14-day pause. Live: alice's downvotes count as downvotes on Lemmy 1.0 and PieFed, replacing her upvote; Lemmy 1.0 and PieFed take privapub_feeds' follows and their threads reach the feed timelines. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
465 lines
39 KiB
Markdown
465 lines
39 KiB
Markdown
# Federation
|
|
|
|
PrivaPub is an ActivityPub server written in C#. This document follows
|
|
[FEP-67ff](https://codeberg.org/fediverse/fep/src/branch/main/fep/67ff/fep-67ff.md) and describes how it federates.
|
|
|
|
## Supported federation protocols and standards
|
|
|
|
- [ActivityPub](https://www.w3.org/TR/activitypub/) (server-to-server)
|
|
- [WebFinger](https://webfinger.net/)
|
|
- [HTTP Signatures](https://datatracker.ietf.org/doc/html/draft-cavage-http-signatures), `rsa-sha256` / `hs2019` with RSA keys
|
|
(`hs2019` hashed with SHA-256, or SHA-512 as some sign it; a `SHA-256=` or `SHA-512=` digest)
|
|
- [HTTP Message Signatures](https://www.rfc-editor.org/rfc/rfc9421) (RFC 9421) and Content-Digest (RFC 9530), verified on
|
|
deliveries and signed fetches: `rsa-v1_5-sha256` and `rsa-pss-sha512` with RSA keys
|
|
- [NodeInfo](https://nodeinfo.diaspora.software/) 2.0 and 2.1
|
|
- Object integrity proofs ([FEP-8b32](https://codeberg.org/fediverse/fep/src/branch/main/fep/8b32/fep-8b32.md),
|
|
`eddsa-jcs-2022`, JSON canonicalised by [RFC 8785](https://www.rfc-editor.org/rfc/rfc8785)) by Ed25519 keys published
|
|
as Multikeys ([FEP-521a](https://codeberg.org/fediverse/fep/src/branch/main/fep/521a/fep-521a.md)), on what personas
|
|
send to relays and verified on what is forwarded
|
|
|
|
## 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**, and **Lemmy 0.19.20** (built with the platform's roots, so it trusts the pasture's CA)
|
|
- **PeerTube 8.3.1**
|
|
- **Pixelfed 0.14.4**
|
|
- **WordPress 7.1.2 with ActivityPub 9.3.1**
|
|
- **Friendica 2026.05**
|
|
- **Mobilizon 5.2.4**
|
|
- **Gancio 1.28.2**
|
|
- **Funkwhale 2.0.11**
|
|
- **PieFed 1.7.17**
|
|
- **Mbin 1.10.1**
|
|
- **NodeBB 4.16.1**
|
|
- **Smithereen 1.0.3**
|
|
- **Mitra 5.9.1**
|
|
- **snac2 2.95**
|
|
- **WriteFreely 0.17.2**
|
|
- **Owncast 0.3.0**
|
|
- **Ghost 6.67** with its ActivityPub service 1.2.14
|
|
- **BookWyrm 0.9.3**
|
|
- **Vernissage 1.43.0**
|
|
- **Hubzilla 11.4.1** with its pubcrawl addon
|
|
- **Forte 26.9.10** (portable identities: actors served through `/.well-known/apgateway/did:key:…`)
|
|
- **Castopod 1.15.5** (podcasts as actors, their episodes with the sound)
|
|
- **Ktistec 3.13.0**
|
|
- **Activity-Relay 2.0.9** and **aode-relay 0.3.129**, as relays PrivaPub reads from
|
|
- 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. The platforms not yet in the pasture are
|
|
covered by unit tests written in their documents' shape.
|
|
|
|
## Supported FEPs
|
|
|
|
- [FEP-67ff: FEDERATION.md](https://codeberg.org/fediverse/fep/src/branch/main/fep/67ff/fep-67ff.md)
|
|
- [FEP-f1d5: NodeInfo in Fediverse Software](https://codeberg.org/fediverse/fep/src/branch/main/fep/f1d5/fep-f1d5.md)
|
|
- [FEP-2c59: Discovery of a WebFinger address from an ActivityPub actor](https://codeberg.org/fediverse/fep/src/branch/main/fep/2c59/fep-2c59.md):
|
|
every actor names its handle, and a peer's `webfinger` on another domain than its actor's host (Mastodon's
|
|
`LOCAL_DOMAIN`) is the handle shown once that domain's WebFinger points back to the actor; asked again on a rename
|
|
- [FEP-d556: Server-Level Actor Discovery Using WebFinger](https://codeberg.org/fediverse/fep/src/branch/main/fep/d556/fep-d556.md)
|
|
(WebFinger for the server's origin links its instance actor as `…#Service`) and
|
|
[FEP-2677: Identifying the Application Actor](https://codeberg.org/fediverse/fep/src/branch/main/fep/2677/fep-2677.md)
|
|
(`/.well-known/nodeinfo` links it as `…#Application`)
|
|
- [FEP-844e: Capability discovery](https://codeberg.org/fediverse/fep/src/branch/main/fep/844e/fep-844e.md): the instance
|
|
actor's `implements`, and every other actor's `generator`, name RFC 9421 (verified with RSA keys)
|
|
- [FEP-1b12: Group federation](https://codeberg.org/fediverse/fep/src/branch/main/fep/1b12/fep-1b12.md) (communities; see "Groups")
|
|
- [FEP-044f: Consent-respecting quote posts](https://codeberg.org/fediverse/fep/src/branch/main/fep/044f/fep-044f.md)
|
|
(`QuoteAuthorization` at `/parrot-licences/{id}`; checked with Mastodon both ways)
|
|
- [FEP-9967: Polls](https://codeberg.org/fediverse/fep/src/branch/main/fep/9967/fep-9967.md)
|
|
- [FEP-c0e0: Emoji reactions](https://codeberg.org/fediverse/fep/src/branch/main/fep/c0e0/fep-c0e0.md) (`EmojiReact`, and Misskey's
|
|
`Like` with content)
|
|
- [FEP-5feb: Search indexing consent](https://codeberg.org/fediverse/fep/src/branch/main/fep/5feb/fep-5feb.md) (`indexable`)
|
|
- [FEP-521a: Representing actor's public keys](https://codeberg.org/fediverse/fep/src/branch/main/fep/521a/fep-521a.md) and
|
|
[FEP-8b32: Object Integrity Proofs](https://codeberg.org/fediverse/fep/src/branch/main/fep/8b32/fep-8b32.md) (see
|
|
"Integrity proofs")
|
|
- [FEP-171b: Conversation Containers](https://codeberg.org/fediverse/fep/src/branch/main/fep/171b/fep-171b.md), received:
|
|
a thread owner's `Add` of someone's `Create`, `Update` or `Delete` to the thread's context is taken as that activity
|
|
passed on by the owner (on its FEP-8b32 proof, or as its origin has it)
|
|
- [FEP-8fcf: Followers collection synchronization across servers](https://codeberg.org/fediverse/fep/src/branch/main/fep/8fcf/fep-8fcf.md)
|
|
(sent and honoured; see "Followers synchronisation")
|
|
- [FEP-5624: Per-object reply control policies](https://codeberg.org/fediverse/fep/src/branch/main/fep/5624/fep-5624.md):
|
|
read (see "Replies its author approves")
|
|
|
|
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-8967 (link
|
|
attachments), 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}` |
|
|
| A persona's wall (FEP-400e) | `/peasants/{name}/graffiti` |
|
|
| Followers on the asking server (FEP-8fcf) | `/peasants/{name}/groupies/roll-call` |
|
|
| 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. It also answers at the server's root (`/`) for a request that asks for
|
|
ActivityPub, as Lemmy's site actor does: PieFed sends a community's announces to the inbox of the Application at a
|
|
peer's root, and to `/inbox` when there is none.
|
|
- The reporter is `/peasants/privapub_reports` (type `Service`, "Reports from <host>"), one for the whole server with
|
|
its own key. It sends the reports Lemmy, PieFed and Mbin take in Lemmy's shape (see **Reports** below) and does
|
|
nothing else: nobody follows or mentions it, the Mastodon API has no account for it, and it has no page.
|
|
- The reader of feeds is `/peasants/privapub_feeds` (type `Service`, "Feeds read on <host>"), one for the whole server
|
|
with its own key. When a persona here follows a feed, the reader follows each community in it, and stops following one
|
|
no feed read here holds any more; no community and no feed learns which persona reads it. It does nothing else.
|
|
|
|
## Groups
|
|
|
|
A group is either a **community** or a **circle**.
|
|
|
|
- A **community** follows [FEP-1b12](https://codeberg.org/fediverse/fep/src/branch/main/fep/1b12/fep-1b12.md). Posts
|
|
addressed to it (in `to`, `cc` or `audience`) are accepted according to its posting policy (followers, anyone, or
|
|
moderators only) and the group `Announce`s the whole activity, with `audience` set, to its followers and to the
|
|
author's own server, whether anyone there follows it or not (Lemmy keeps its user's post pending until then). 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. Its owner lets a request in with an `Accept` of the
|
|
Follow, and declines one or takes a member from elsewhere out with a `Reject` of it, as Mastodon removes a follower.
|
|
- 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 post in a remote group followed here that the group's own server delivers itself, as its author's `Create` with the
|
|
group as its `audience` (Mbin sends a magazine's threads that way, with no announce), is kept as if announced: the
|
|
group's server speaks for posts in it. The same from another server is not.
|
|
- A remote community's **moderation** reaches the posts it holds. A removal (`Announce{Delete}`) is believed at once
|
|
from a community on the post's own server, which speaks for it (PieFed keeps serving a thread its moderator removed),
|
|
and from a community elsewhere once the post's origin answers it gone. A lock sent as it is (`Lock`, Mbin) is taken
|
|
from the post's own server only. 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`. A post moved to
|
|
another community (PieFed's `Announce{Move{object: post, origin, target}}`, relayed by the community it leaves) goes
|
|
there with its thread; a move into a community this server hosts is not taken.
|
|
- **Feeds** (Lemmy 1.0's multi-communities and PieFed's feeds, `type: Feed`) are read as accounts whose following is
|
|
their communities: the feed's `following` collection, read once a day with its counts, and each community in it read in
|
|
turn. A persona following a feed is followed through the server (owner decision 2026-10-07): the reader of feeds
|
|
follows its communities, the feed itself is sent nothing, and the persona reads the feed's threads in a timeline of
|
|
its own (`GET /api/v1/timelines/feed/:id`; the feeds it reads are `GET /api/v1/feeds`), not in its home.
|
|
|
|
## 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; once a follow holds, the account's latest public posts are read from its outbox's first page (twenty at most, once a day), so its profile is not empty; homes get only what arrives from then on |
|
|
| `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 persona is addressed by its actor id or by its profile page, as Mbin addresses its private messages); 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 |
|
|
| `Warn` (Lemmy 1.0) | a community moderator's warning about a persona's post there becomes the persona's `moderation_warning` notification, believed from the community's own server or an account its moderators collection lists |
|
|
| `Resolve{Flag}` (Lemmy 1.0) | a report we sent is marked resolved by the server holding what was reported (kept beside our own moderators' resolution) |
|
|
| `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, though a new post its server publishes under that id after the deletion (Gancio numbers events from the last one kept) is kept |
|
|
| `Flag` | becomes a report for this server's moderators |
|
|
| `Move` | an account moving: believed as Mastodon believes it, when the account sends it about itself and the new account, read again from its server, names it in `alsoKnownAs`. The old account then shows where it went (`moved`), and, as Mastodon does it (owner decision 2026-10-05), the personas following it follow the new one instead (a Follow to its server, an Undo to the old), in the same lists; a mute or a block of the old account carries over |
|
|
| `Add`/`Remove` on the actor's `featured` | the account pins or unpins one of its own posts (fetched from its server when not held); inside a community's announce, the community features a post made in it. Its profile shows them first (`pinned=true`). The collection itself is read with the account's counts, at most once a day. On the account's wall (`sm:wall`), see "Walls". Any other target (a community's moderators) is dropped; one on the account's own server that PrivaPub does not know has its document read again first, at most hourly |
|
|
| `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`, `Dislike`, `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`.
|
|
|
|
- **Votes.** A favourite is a `Like`, a downvote a `Dislike`; a persona has one vote a post, and a changed vote is sent
|
|
as the new vote alone, as Lemmy sends one (an `Undo` of the old one beside it could arrive after it and take the new
|
|
one away). `Undo` is sent only when the vote is taken back. A vote on a post in a remote community goes to the
|
|
community, which counts it and passes it on, as Lemmy sends one, and to the post's author only when it lives on
|
|
another server: one copy a server, since PieFed drops a second copy of an activity it has just seen.
|
|
|
|
- **Attachments** are `Document`s with `mediaType`, `name` (alt text), `blurhash`, `focalPoint`, `width` and `height`.
|
|
Uploaded files have all metadata removed.
|
|
- **Threads** (owner decision 2026-10-06). A persona's public or unlisted post outside any group names its `replies`
|
|
(`…/scribbles/{id}/replies`) and its conversation, `context` (FEP-7888): the root post's `…/scribbles/{id}/context`,
|
|
which a reply inherits from its parent, ours or another server's. Both list only the public and unlisted posts
|
|
PrivaPub holds, the context every one under the root (at most 500); followers-only, circle, direct and local-only posts
|
|
name neither, and their collections answer 404. Mastodon completes a thread from them.
|
|
- **Followers synchronisation** (FEP-8fcf, owner decision 2026-10-06). A persona's delivery addressed to its followers
|
|
carries a signed `Collection-Synchronization` header: its `followers`, its roll-call (`…/groupies/roll-call`) and the
|
|
digest (XOR of each id's SHA-256) of its accepted followers on the receiving server only. The roll-call answers a
|
|
signed request with the persona's followers on the signer's server and nobody else's; unsigned, 401. A server whose
|
|
view differs mends itself from it, Mastodon both ways (checked live). Mastodon's `Undo{Follow}` for a follow it never
|
|
knew of has one id per account (`…#follows//undo`); when it comes again after a new follow, it ends that follow too.
|
|
Honoured the other way: when a delivery's header digests the sender's followers here otherwise than the personas
|
|
following it, PrivaPub reads the list it names (same origin, signed by the instance actor). A follow the list leaves out
|
|
ends, but only when the list is the one the digest describes; a request it lists is taken as accepted; a persona it
|
|
lists that follows nothing there sends `Undo{Follow}`, as Mastodon does.
|
|
- **Walls** (FEP-400e, Smithereen's; owner decision 2026-10-06). A persona's actor names its `wall`
|
|
(`…/graffiti`, `sm:wall`) and Smithereen's `privacySettings`: its followers may write on it (`wallPosting`), everyone
|
|
may read it (`wallPostVisibility`). A public post that is not a reply, sent with the wall as its `target` by an account
|
|
following the persona, is hosted: the persona is notified (as a mention), it reaches the persona's home and its
|
|
followers' here, and the persona's followers' servers and the author's are told with `Add{Note}` (the wall as the
|
|
target). Anything else written there is dropped. The persona may delete it (`DELETE /api/v1/statuses/:id`), which
|
|
sends `Remove{Note}` with the wall as a plain id; Smithereen deletes the post then, as it does when an owner deletes a
|
|
post on its wall. The wall lists the persona's public posts that start a thread and what was written on it, newest
|
|
first. An account elsewhere that tells its followers here, with `Add{Note}` on its own wall, of a post written there
|
|
has it shown to them as on its wall (`privapub.wall` on the status); a `Remove` there takes it away.
|
|
- **Pinned posts** are the actor's `featured` collection (`/trophies`); `featuredTags` is `/tattoos`. A pin or an unpin
|
|
is told to the post's audience as `Add` or `Remove` on `featured`, as Mastodon tells it.
|
|
- **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`, never by the reporting account and never naming it.
|
|
- To the reported account's server: from the instance actor, with the account and the posts as `object` and the
|
|
persona's words in `content`.
|
|
- To a community on a server whose NodeInfo names Lemmy, PieFed or Mbin (owner decision 2026-10-06, the second place
|
|
PrivaPub decides by a server's software; PieFed and Mbin once the pasture showed each keeps such a report with its
|
|
reason): one `Flag` per reported post or comment that was made in that community (its own
|
|
`audience`, else its thread's), from the reporter, `to` the community, the post alone as `object`, the words (or,
|
|
with none, the category) in `summary` and `content`, sent to the community's inbox. Lemmy refuses a Flag from an
|
|
`Application`, with no `to` or with no reason; PieFed drops one from an `Application`; Mbin reads the reason from
|
|
`summary` alone. Such a server gets no instance actor's Flag: an account alone, or a post outside its communities,
|
|
is not reported there, since Lemmy takes neither (PieFed would take an account alone from the reporter; that is not
|
|
sent yet).
|
|
- **Direct messages** go out as a `Note` addressed to their recipients, except a message to one account elsewhere that
|
|
writes to us as `ChatMessage`s (Pleroma's type), or whose server takes nothing else: Lemmy before 1.0 and Mbin, as
|
|
their NodeInfo names them (owner decision 2026-10-05, the first place PrivaPub decides by a server's software). That
|
|
message goes out as a `ChatMessage`, to the account alone, without a mention in its text.
|
|
- **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, Misskey and Sharkey
|
|
keep it only when its author LD-signed it (Mastodon does; Misskey 2026.10 and Akkoma do not), others read 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.
|
|
A post's earlier versions in `formerRepresentations` (Pleroma, Akkoma) are its edit history, read as the post itself is:
|
|
a post first met after its edits has them, and an edit that carries them brings the versions missed in between.
|
|
|
|
**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.
|
|
|
|
**Replies its author approves** (FEP-5624's `canReply`, as PeerTube sets it on a video whose comments are moderated; a
|
|
`null` one says nothing): a persona it names, or that the post mentions, may reply, and the reply waits
|
|
(`privapub.approval: pending`), its `Create` going to the author alone. The author's `ApproveReply`, signed by the author
|
|
and naming the post answered, lets it out to its audience with `replyApproval`; a `RejectReply` leaves it ours alone
|
|
(`rejected`). An empty `canReply` refuses every reply (422).
|
|
|
|
**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.
|
|
|
|
## Link previews
|
|
|
|
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.
|
|
|
|
## Relays
|
|
|
|
PrivaPub reads from the relays `Federation:Relays` names (by their actor's address, or their inbox's), none by default.
|
|
Its instance actor follows `Public` at each, as Mastodon subscribes (`Federation/Relays/Relays.cs`, a minute after start
|
|
and every six hours: an unanswered or refused subscription is asked again a day later, and a relay no longer named gets
|
|
the `Undo`). What an accepted relay passes on comes to the federated timeline, never to anyone's home: a public post it
|
|
forwards as its author sent it (Activity-Relay), read again from its origin like any forwarded post, and a post it
|
|
announces (aode-relay), kept as its author's and never as the relay's boost. Nothing else is taken from a relay. A
|
|
persona's public post outside any group, its edit and its deletion also go to the relays that accepted us (owner decision
|
|
2026-10-06), as Mastodon sends them; nothing less public, and no boost. Mastodon takes a post Activity-Relay forwards
|
|
only with an LD signature or an FEP-8b32 proof: PrivaPub's carry a proof, so they reach Mastodon through both kinds of
|
|
relay (checked live).
|
|
|
|
## 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 conversation container (FEP-171b's
|
|
`contextHistory`: the posts its owner's `Add`s brought in) or its `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. A thread collection read whole, which counts its posts or
|
|
holds them all, is asked again with `If-None-Match` and its last ETag; a 304 ends the read.
|
|
- **Reading our documents (SecureMode).** privapub.thepra.dev answers ActivityPub GETs only when they are signed, like
|
|
Mastodon's authorized fetch; the server's own actors (the instance actor `/peasants/privapub`, the reporter
|
|
`/peasants/privapub_reports` and the reader of feeds `/peasants/privapub_feeds`) are the exception, since their keys
|
|
are 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.** An activity goes once to each server, to its shared inbox when the server has one, whoever there it
|
|
follows, names or answers (a circle post excepted: each member's copy names that member). 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: unreachable, timing out, or a gateway answering 502, 503 or 504. A 500 is
|
|
the server failing on that one activity, retried on its own while its other deliveries go on. 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.
|
|
- HTTP signatures are verified with RSA keys only, under draft-cavage or RFC 9421. What PrivaPub sends is signed with
|
|
draft-cavage only, which every server reads.
|
|
- **Integrity proofs** (FEP-8b32, FEP-521a). Every persona has an Ed25519 key of its own (never shared between personas),
|
|
named in its actor's `assertionMethod` as a `Multikey` (`…#ed25519-key`, `publicKeyMultibase`); the terms are defined
|
|
in the actor's own context, so no context document has to be fetched. A persona's activity going to a relay carries a
|
|
`DataIntegrityProof` (`eddsa-jcs-2022`, `proofPurpose` `assertionMethod`) over the activity as delivered; Mastodon
|
|
4.7 verifies it (checked against its own verifier), so what the relay forwards reaches it. Nothing else carries one:
|
|
Mitra takes a proof over the HTTP signature, and refuses one by a key it has not read yet without reading the actor
|
|
again, which every server that knew a persona before it had its key would do. What a persona passes on of others'
|
|
carries theirs or none. Received: an actor's own Multikeys (at most five, under its id, controlled by it) are kept;
|
|
a forwarded activity whose proof one of them verifies is taken as it came, instead of being read again from its origin
|
|
(an unknown key of the actor's has the actor read again first).
|