diff --git a/CLAUDE.md b/CLAUDE.md index c727e07..14b9802 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,7 +1,9 @@ # CLAUDE.md Guidance for working in this repository. `docs/ROADMAP.md` holds the owner's decisions and the phased plan to full -ActivityPub interop; read it before changing anything federation-, privacy- or API-shaped. +ActivityPub interop; read it before changing anything federation-, privacy- or API-shaped. `docs/INTEROP.md` is the +per-platform evidence behind phases P5 to P8: what each peer sends, what it expects, and what PrivaPub still drops. +Check the platform's section there before writing a parser or a renderer, and add to it whatever you learn. ## What this is diff --git a/FEDERATION.md b/FEDERATION.md index 5cf0da8..c37f8f2 100644 --- a/FEDERATION.md +++ b/FEDERATION.md @@ -29,9 +29,12 @@ PrivaPub is an ActivityPub server written in C#. This document follows - [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) +- [FEP-1b12: Group federation](https://codeberg.org/fediverse/fep/src/branch/main/fep/1b12/fep-1b12.md) (communities; see "Groups") -Planned: FEP-1b12 (communities), FEP-8fcf (followers synchronisation), FEP-5feb (`indexable`), FEP-7628 (Move), -FEP-044f (quotes). +Planned (see `docs/ROADMAP.md`, phases P5 to P8, and the per-platform notes in `docs/INTEROP.md`): FEP-044f (quotes), +FEP-9967 (polls), FEP-c0e0 (emoji reactions), FEP-9098 (custom emoji), FEP-7888 and FEP-f228 (threads), FEP-7628 (Move), +FEP-8fcf (followers synchronisation), FEP-5feb (`indexable`), FEP-8967 (link attachments), FEP-521a and FEP-8b32 (keys +and integrity proofs), FEP-ae0c (relays). ## Actors diff --git a/docs/INTEROP.md b/docs/INTEROP.md new file mode 100644 index 0000000..0a69393 --- /dev/null +++ b/docs/INTEROP.md @@ -0,0 +1,762 @@ +# Interop: what every peer sends, what it expects, and what PrivaPub still drops + +Research as of **2026-10-01**: the platforms' source on their default branches, live ActivityPub fetches from large +instances, release notes and the FEP repository. Version numbers are what was current that day. Claims the +research could not confirm from a primary source are marked *(unconfirmed)*. The sources are at the end. + +This file is for two readers: +- **Whoever changes federation code.** Every gotcha below has broken somebody. +- **Whoever builds the rich client.** That client shows every kind of fediverse content in one place, with a secondary + "details" view of the raw object and how it reached us. Section 4 says what the server must keep for it. + +Priorities, used throughout: + +| Priority | Meaning | +|---|---| +| **P1** | Breaks interop, or loses content the user would see. | +| **P2** | Degrades the experience. | +| **P3** | Nice to have. | + +## 1. Where PrivaPub stands (checked in the code, 2026-10-01) + +**Already right** +- **Public addressing:** all three spellings are recognised (`…#Public`, `as:Public`, `Public`). +- **Undo:** embeds its whole object, which Misskey needs; it fetches the object only when it isn't embedded. +- **Content types:** + - Served documents are `application/activity+json; charset=utf-8`, which is on Lemmy's exact allowlist and is + accepted by GoToSocial and Misskey. + - WebFinger answers `application/jrd+json`, and answers for the actor URL as well as `acct:`. Akkoma and + Iceshrimp.NET require both. +- **SSRF guard:** + - Its IPv6 rule is an allowlist (`2000::/3` only), so IPv4-compatible `::a.b.c.d`, NAT64 and the other tricks behind + Mastodon's GHSA-vwhj (Jul 2026) are already refused. + - It caps fetches at 1 MB. Misskey caps at 256 KiB, so keep our own documents well under that. +- **Attachments:** + - Up to 16 are kept. Mastodon drops anything past 4; Pixelfed albums and Threads carousels go beyond it. + - `{type: Link}` attachments are not mistaken for media. +- **Deleted posts** answer 410 with a `Tombstone`. Actor `published` is truncated to the day. The `webfinger` property + (FEP-2c59) is on every actor. +- **Live check:** the whole follow, post, reply, like, boost, DM, edit and delete set round-trips with GoToSocial + 0.22.1 (`tools/pasture/`). + +**Wrong today (P1, cheap)** +- **`summary` is read as a content warning on every object type.** It is one only on a `Note`, or when + `sensitive: true` is set. Elsewhere it is something else: + + | Sender | What `summary` holds | + |---|---| + | WordPress, WriteFreely, Ghost, NodeBB (`Article`) | a teaser or excerpt | + | Mobilizon, Gancio (`Event`) | the date and address, or the whole description | + | Funkwhale (`Audio`) | a line of hashtags | + | Mbin (`Page`) | a short title plus tags | + + Today all of these arrive hidden behind a warning, and marked sensitive. +- **Persona and group usernames are only lowercased.** Mastodon accepts `[a-z0-9_]` with `.` and `-` only inside the + name; Misskey `^\w([\w-.]*\w)?$`, 128 characters at most. A persona named outside that is unreachable from either. +- **Our JSON-LD context does not define `postingRestrictedToMods`.** Iceshrimp.NET runs full JSON-LD expansion and + silently drops every undefined term. Any term we add later (`quote`, `Emoji`, `EmojiReact`, `interactionPolicy`, + `votersCount`) must be defined in `ActivityPubRenderer.Context()` in the same commit. + +**Smaller** +- No `Vary: Accept` on actor and object URLs, although they answer HTML or JSON depending on `Accept`. +- Only `create-…` and `announce-…` activity ids dereference. `follow-`, `like-`, `accept-` and `undo-…` answer 404. + That is harmless while objects are embedded, but every id should resolve. + +## 2. Rules that hold across platforms + +| # | Rule | What PrivaPub must do | P | +|---|---|---|---| +| W1 | `url`, `icon`, `image`, `attributedTo`, `actor`, `tag`, `attachment` and `alsoKnownAs` can each be a single value, an object or an array. The `url` array matters most: PeerTube video files, Funkwhale streams, and Bridgy posts, whose `rel: canonical` link is `at://…`. | Parse every shape. For "open original", use the `text/html` Link. Keep every other Link as a media variant. | P1 | +| W2 | `summary` is a content warning only on a `Note`, or when `sensitive: true`. | See §1. On other types store it as an excerpt or description. NodeBB 4.16 honours a CW only on a sensitive Note. | P1 | +| W3 | `content` can be Markdown. PeerTube descriptions and comments carry `mediaType: text/markdown`. | Render it, then sanitise. Keep `source{content, mediaType}`: Markdown, BBCode, `text/x.misskeymarkdown` (MFM). | P1 | +| W4 | `mediaType` is missing or wrong: Bridgy media has none, Funkwhale hard-codes `audio/mpeg`, Gancio `image/jpeg`. Mastodon types every attachment `Document`. | Infer it from the object or attachment type, then sniff it in the media proxy. | P1 | +| W5 | Thumbnails live in five places: attachment `icon` (Mastodon), object `icon[]` (PeerTube), object `preview` (Loops), `image` (Bridgy video; Ghost as a bare string; WordPress), and `icon` (Plume). | Keep them all; choose one per kind. | P1 | +| W6 | `Accept`, `Reject` and `TentativeAccept` are not always follow answers. Friendica and Hubzilla use them as event RSVPs; Mobilizon answers a `Join`; GoToSocial answers interaction requests with `result`; Mastodon answers a `QuoteRequest`. | Route on what the object *is*. | P1 | +| W7 | `id` is not the page a person opens. WordPress uses `?p=123`, Ghost `/.ghost/…`, Bridgy `/convert/ap/at://…`. | Link to `url`, never to `id`. | P1 | +| W8 | Rich types are updated in place: PeerTube live state, WordPress (on every save), Mobilizon. A poll's counts are refreshed with an `Update{Question}` that changes nothing else. | Apply Updates to every kind. An Update with no newer `updated` is a refresh, never an edit revision. Mastodon applies the same rule. | P1 | +| W9 | A `Delete` can arrive before its `Create`, and relays and forwarders re-send old Creates. | Keep tombstones so deleted posts stay deleted. When the deleter is not the author, confirm with the origin: 404 or 410 means deleted. | P1 | +| W10 | Time comes in seconds (Mastodon), milliseconds (Misskey, us), or with offsets. `-00:00` means floating local time (Hubzilla events). `duration` is ISO 8601 (`PT6299S`). GoToSocial rejects a status whose `updated` is earlier than `published`. | Parse all of these, and clamp future times. Order by arrival (PrivacyIds.Arrived). Never emit `updated` < `published`. | P1 | +| W11 | Language may be in `contentMap`, in `@context[].@language` (Pleroma 2.9+), or a `language{identifier,name}` object (Lemmy, PeerTube). Akkoma replaces `content` with the *first* `contentMap` entry. | Read all three. When sending, put `content` and the primary `contentMap` entry first and keep them equal. | P2 | +| W12 | Alt text: `name` (Mastodon), `summary` (GoToSocial 0.20.0, Akkoma reads it first), or their `*Map` forms. Avatar and header alt is in `icon.name`/`image.name`, or `summary` on Mastodon. | Read both; send `name`. | P2 | +| W13 | `"id": null` objects (Akkoma); a `Tombstone` served with **200** as a soft delete (FEP-4f05: NodeBB, Discourse); 410 for deleted GoToSocial 0.22 statuses. | Accept a null id inside an activity; never emit one. Any `Tombstone`, whatever the status code, means deleted. | P2 | +| W14 | Size limits on the receiving side: Misskey reads at most 256 KiB, truncates text at 8192 characters, CW 512, poll choice 256, alt 512. GoToSocial takes emoji up to 100 KB. Peers cap fetches at about 1 MB. | Accept long posts from others. Keep our own documents small. | P2 | +| W15 | Hashtag `name` comes with or without `#`. Mastodon normalises with NFKC + lowercase (watch Turkish `İ`). Lemmy adds an automatic `#` tag to every post. | Normalise the same way; ignore Lemmy's automatic tag. | P3 | + +## 3. Per platform + +### Mastodon: 4.7.2 (2026-09-15); mastodon.social runs 4.8 alpha; client `api_versions.mastodon` = 11 + +**Emits** +- **Objects and activities:** + - Objects: `Note` and `Question` only. + - Federated `Block`. + - `Add`/`Remove` for pins, featured hashtags and featured collections (4.6, FEP-7aa9). + - `Move`. + - `QuoteRequest`, its `Accept`/`Reject` (with `result`), and `Delete{QuoteAuthorization}`. + - `FeatureRequest`/`FeatureAuthorization` (4.6). +- **Threads:** `context` is a dereferenceable collection of the thread (FEP-7888, threads started on 4.5+). +- **Interaction counts:** `likes` and `shares` carry `totalItems`. +- **Quotes:** `quote`, plus `quoteUri` and `_misskey_quote`, `quoteAuthorization`, and `interactionPolicy.canQuote`. +- **Link previews:** from 4.7, a `{type: Link, href}` **attachment** names the link a card is made from (FEP-8967). +- **Attachments:** `duration`, and `icon` thumbnails. +- **Actors:** + - `webfinger`, `featuredCollections`, `interactionPolicy.canFeature`, `attributionDomains`; + - `memorial`, `suspended`, `indexable`, `discoverable`; + - avatar and header alt text in `summary`; + - several keys allowed (4.6). +- **Ids:** new accounts get numeric actor ids (4.5). Remote renames are accepted, keyed on the actor `id` (4.7). + +**Expects** +- **Fetched documents:** + - `id` equals the URL requested, exactly. + - The type is `activity+json`, or `ld+json` with the profile. + - `@context` includes the ActivityStreams URL. +- **WebFinger:** it loops back to the same `id`. +- **draft-cavage signatures:** + - `(request-target)` includes the query string (4.3). + - `digest` is signed on POST and `host` on GET. + - The window is 12 h, with 1 h of skew. + - Mastodon no longer signs `Accept` (4.6). Never require it. +- **RFC 9421:** verified by default since 4.5.0. + - A request carries one signature only. + - It must cover `@method`, `@target-uri` and `content-digest`, and include `created` and `keyid`. + - **4.7 double-knocks:** it signs draft-cavage first, and if we answer 400 or 401 it retries with RFC 9421. A 400 for + any other reason therefore triggers a retry we would fail. + - Mastodon answers **503** when it temporarily cannot fetch a key; that is a retry, not a failure. +- **Edits:** an Update counts as an edit only with a newer `updated`. +- **Quotes:** a post with no `canQuote` **cannot be quoted** by Mastodon users at all. A quote without a valid stamp + stays "pending", and only its fallback link shows. +- **Fetch all replies** is always on (4.5). Mastodon crawls `replies` as its instance actor, up to 500 per post, and + **deletes its copy** when a refetch answers 404. +- **Converted types:** `Article`, `Page`, `Event`, `Video`, `Audio` and `Image` become `

name

` + summary + + link. **The body is dropped.** Only `Mention` tags notify. Previews come from fetching the page; FEP-8967's + `preview` is ignored. + +**Gaps** + +| Gap | P | Client surface | +|---|---|---| +| Quotes: read all keys; verify the `QuoteAuthorization` (type, host, `attributedTo`, `interactingObject`, `interactionTarget`; GHSA-vg36 compared domains only); revocation; strip the `.quote-inline` fallback | P1 | `Status.quote{state, quoted_status}`, `quotes_count`, `/statuses/:id/quotes`, `quote`/`quoted_update` notifications, `api_versions.mastodon ≥ 7` | +| Being quotable: emit `canQuote`; answer `QuoteRequest` with `Accept{object: request id, result: stamp}`; serve and revoke stamps | P2 | `Status.quote_approval`, `PUT /statuses/:id/interaction_policy` | +| Polls (see §3 Misskey for vote shapes) | P1 | `Status.poll`, `/polls/:id`, `/polls/:id/votes`, `poll` notification | +| Custom emoji on posts, names, fields and poll options, proxied, refreshed by `updated` | P1 | `Status.emojis`, `Account.emojis` | +| Link attachments as the card source | P1 | `Status.card` | +| Inbound `Move` with Mastodon's checks (`target` re-fetched, its `alsoKnownAs` lists the old account, 7-day lock); move each persona's follow | P1 | `Account.moved` | +| Re-run WebFinger when `preferredUsername` changes; key accounts on the actor id | P2 | `Account.acct` | +| Inbound `Block`: stop delivering, hide | P2 | `relationship.blocked_by` | +| `Add`/`Remove` featured (pins, tags) | P2 | `GET /accounts/:id/statuses?pinned=true` | +| Remote like and boost totals | P2 | counts | +| Publish `context` and a paged `replies` | P2 | — | +| `FeatureRequest`: send `Reject` (or implement FEP-7aa9) | P3 | — | + +### GoToSocial: 0.22.1 (2026-07-20) + +**Emits** +- **Objects:** `Note` and `Question` only. +- **Interaction policies** on every status: `canLike`, `canReply`, `canAnnounce` and `canQuote`, each split into + `automaticApproval` and `manualApproval`. + - Defaults: open on public and unlisted posts. + - On followers-only posts, liking and replying are limited to the author, followers and mentioned accounts, and + boosting to the author. + - `canQuote` is author-only on every post (0.21). +- **Since 0.21 it asks politely:** `LikeRequest`, `ReplyRequest` and `AnnounceRequest`, with the interaction in + `instrument`. The answer is `Accept`/`Reject` whose `result` is a `*Authorization`. The interaction then carries + `replyAuthorization` (with `approvedBy` as the legacy fallback). +- **Keys and actors:** + - The key id is `…/main-key` (no `#`), and its document is a stub actor. + - There is **no `sharedInbox`**. + - `hidesToPublicFromUnauthedWeb` / `hidesCcPublicFromUnauthedWeb`, `indexable`. + - `featured` holds URIs only, and changes to it are never announced, so read it instead. +- **What it leaves out:** + - `context`; + - `likes`/`shares`; + - attachment `width`/`height`. +- **Deleted statuses:** 0.22 keeps a stub and answers 410. + +**Expects** +- **Signed requests:** every GET and POST is signed, draft-cavage only, with RSA keys. **No RFC 9421 in either + direction.** +- **Key handshake:** the instance actor and key documents must be served **unsigned**, or both sides deadlock fetching + each other's keys. Ours are: SecureMode exempts the instance actor. +- **Content-Type:** an inbox POST must be `activity+json`, or `ld+json` with the profile. Anything else gets 406. +- **Activities:** one without an `id` is dropped. A 400 is never retried. +- **Keys:** a changed public key on refresh is refused. **Never rotate keys silently.** +- **Interaction policies:** + - Third-party GoToSocial servers drop replies that have no valid `replyAuthorization`. + - On followers-only GoToSocial posts, send `ReplyRequest` / `LikeRequest` instead of a bare Create or Like. +- **Rate limit:** 300 requests per 5 minutes per IP, answered with 503 and `Retry-After`. +- **Not accepted:** top-level `Audio`. +- **Timelines:** its cached home timeline can miss new posts. Check a delivery by URI, not through its timelines; see + CLAUDE.md, "Testing". + +**Gaps** + +| Gap | P | Client surface | +|---|---|---| +| Store remote `interactionPolicy` (with GoToSocial's defaults); disable or mark actions | P1 | GoToSocial-style `Status.interaction_policy` | +| Send `ReplyRequest`/`LikeRequest` where approval is needed; handle `Accept{result}`/`Reject`; attach the authorization | P1 | own: pending/approved/rejected on our own reply | +| Honour 503 with `Retry-After` in delivery and in the proxy | P1 | — | +| Respect `hides*FromUnauthedWeb` on our public pages; emit it for personas (it suits the privacy design) | P2 | — | +| Measure media size when proxying | P2 | `MediaAttachment.meta` | +| Only advertise the policies we enforce | P2 | — | + +### Misskey family: Misskey 2026.10.0, Sharkey 2025.4.7, Iceshrimp.NET 2026.1.2-beta, CherryPick 4.17 + +Firefish is dead (its site has answered 410 since February 2025). + +**Emits** +- **Text and quotes:** + - `_misskey_content` plus `source{content, mediaType: text/x.misskeymarkdown}` (MFM). + - The quote is `_misskey_quote`/`quoteUrl`, plus a `RE:` fallback inside the content. + - An empty CW is a zero-width space. +- **Attachments:** each carries its own `sensitive`. +- **Emoji tags** carry `_misskey_license`. +- **Polls:** `Question` with `oneOf`/`anyOf`. Counts are in `replies.totalItems` per option. `endTime` and `closed` are + set; there is **no `votersCount`**. + - A vote is `Create{Note{name, inReplyTo, to:[owner]}}`, one per choice. + - Every vote triggers an `Update{Question}`. +- **Reactions** are `Like{content: "👍"|":name:", _misskey_reaction, tag:[Emoji]}`. + - One reaction per user per note; a plain like is `❤`. + - They are sent to the author **and to the reactor's followers**. +- **Actors:** + - `isCat`, `_misskey_summary`, `_misskey_followedMessage`; + - `_misskey_requireSigninToViewContents`; + - `_misskey_makeNotesFollowersOnlyBefore`/`HiddenBefore` (seconds; a negative value is relative to now); + - `vcard:bday`, `vcard:Address`, `backgroundUrl`. +- **Vanilla Misskey has no edits.** +- **Sharkey adds:** + - edits; + - a FEP-e232 quote `Link` tag (it deliberately leaves out `quote`); + - a `replies` collection; + - `hideOnlineStatus`, `noindex`, `enableRss`, `speakAsCat`. + - It sends contentless Likes only to Mastodon-like peers, so **we always receive Like+content**. +- **Iceshrimp.NET adds:** + - `EmojiReact`, several per user, and `:name@host:` for remote emoji; + - a FEP-7888 `context`; + - `htmlMfm: true` (FEP-c16b); + - every `QuoteRequest` is auto-accepted; + - `Bite`, `pronouns`. +- **CherryPick adds:** events on a plain Note (`startTime`/`endTime`), `deleteAt`, and federated chat + (`_misskey_talk: true`). + +**Expects** +- **Inbound signatures:** + - draft-cavage over `(request-target) host date digest`; + - at most 300 s of skew; + - since 2026.10.0, the query string is included in `(request-target)`. + - **No RFC 9421 anywhere in the family.** +- **Activities:** + - An activity's `id` must be on the signer's host. + - Activities forwarded on behalf of someone else are refused. A group must `Announce`. + - Misskey answers 202 even when it drops something, so errors stay invisible. +- **Fetched documents:** request URL = final URL = `id`; ≤256 KiB; `activity+json` or `ld+json`. +- **Actor collections** must be on the actor's host. +- **Visibility:** Misskey recognises followers-only by the author's own `followers` URL, matched exactly. Otherwise: + - **A "specified" (direct) note with no resolvable recipients that Misskey fetches by URL is stored as public.** + Circle objects must therefore never be served to an unauthorised fetcher. They aren't: 404. +- **Groups:** vanilla Misskey drops `Announce{Create}`, so groups should `Announce` the Note itself. We send both. +- **Reactions:** must be `:name:` with no host, plus an Emoji tag, or they fall back to ❤. +- **Article/Page titles** are never shown in Misskey's web UI. +- **Iceshrimp.NET:** + - full JSON-LD expansion (see §1); + - `@graph`, `@reverse` and `@included` are refused; + - every actor must resolve through WebFinger; + - `preferredUsername` must be unique per domain. PrivaPub shares one name space across personas and groups, so this + holds. + +**Gaps** + +| Gap | P | Client surface | +|---|---|---| +| Emoji reactions in all three inbound forms: Like with `content`/`_misskey_reaction`, `EmojiReact`, and `Dislike`-as-un-like (Sharkey). Normalise `:n:`, `:n@host:` and `n@host`. Store per actor, emoji and activity, including reactions to remote posts. Undo by id. | P1 | `emoji_reactions` and `pleroma.emoji_reactions` `[{name,count,me,url,static_url}]` (Phanpy reads the first); `PUT/DELETE /api/v1/pleroma/statuses/:id/reactions/:emoji` | +| Outbound reaction as `EmojiReact{content: ":name:", tag:[Emoji]}`. A plain Like stays a favourite. | P2 | same | +| Polls: per-option counts (fall back to `_misskey_votes`); `votersCount` null when absent; `Update{Question}` is a refresh; an inbound `Note{name, inReplyTo: question}` with no content is a vote, never a reply | P1 | `Status.poll` | +| Keep MFM source; let the sanitiser keep `` and `` (FEP-c16b) | P2 | `text`, `content_type`; the rich client renders MFM from the source | +| Per-attachment `sensitive`; `isCat` and other actor extras; enforce `requireSignin…` and `makeNotes…Before` on our public pages | P2 | own `privapub.*` | +| Quotes: when we send one, send every key (`quote`, `_misskey_quote`, `quoteUrl`, `quoteUri`, a FEP-e232 tag, the `RE:` fallback) | P2 | — | + +### Pleroma 2.10.2 and Akkoma 3.20.1 + +**Emits** +- **Notes:** + - `@context` with the instance's own `litepub-0.1.jsonld` URL (never fetch it) and `@language`; + - `source{content, mediaType: text/markdown}`; + - `context` and `conversation` holding the same value; + - `quoteUrl`/`quoteUri`. +- **Edit history:** `formerRepresentations`, an OrderedCollection of earlier versions. +- **Reactions:** `EmojiReact{content: ":name:", tag:[Emoji]}`, several per user; separate from Like. +- **Polls:** `votersCount` (Pleroma 2.10.1, Akkoma 3.20). A vote is a `Note{name, inReplyTo, to:[], cc:[owner]}`. +- **Pleroma only:** + - `ChatMessage`, sent only to actors with `capabilities.acceptsChatMessages`; + - `Listen{Audio}`; + - outgoing `Block` is on by default. +- **Akkoma only:** + - local-only posts are addressed to `/#Public`, which is **not** public; + - FEP-2c59. + +**Expects** +- **Pleroma's inbox guard answers 400 for unknown activity types:** `Move`, `QuoteRequest` and `Bite` are not on its + list (develop, 2026-09-30). Treat that 4xx as final. +- **Signatures (Akkoma):** `host` must be signed and match; signatures up to 2 h old and up to 40 min in the future. +- **Activity ids** must be at least 8 bytes. +- **ObjectAgePolicy** (default in both) delists anything older than 7 days, so `published` must be accurate. +- **Quotes:** Pleroma's InlineQuotePolicy rewrites incoming quotes into "RT: url" text. **Neither reads FEP-044f + `quote`**, so send `quoteUri` and `_misskey_quote` as well. +- **Edits:** a changed `name` is ignored on update. + +**Gaps** + +| Gap | P | Client surface | +|---|---|---| +| Language from `@context[].@language` | P1 | `Status.language` | +| `formerRepresentations` → our revisions | P2 | `/statuses/:id/history` | +| `context`/`conversation` threading; parents and quotes we could not fetch, kept as URIs | P2 | `pleroma.context`; `akkoma.in_reply_to_apid`, `akkoma.quote_apid` precedent | +| `ChatMessage` in as a direct message (also needed for Lemmy, Mbin and PieFed); advertise `acceptsChatMessages` only once it is answered | P1 (in), P3 (out) | `visibility: direct`, Conversations | +| `Listen`, `vcard:bday`, `backgroundUrl` | P3 | own | + +### Lemmy: 0.19.20 live (lemmy.ml); 1.0.0-beta.2 (2026-09-25) in beta since May + +join-lemmy.org's federation page is out of date. Current Lemmy neither sends nor reads `stickied` or `commentsEnabled` +on a Page: pins live in `featured`, locks in `Lock`. + +**Emits** +- **Group:** + - `summary` (sidebar HTML) and `source` (sidebar Markdown); a plain `description` in 1.0; + - `sensitive`; `attributedTo` → the moderators collection; `featured`; `postingRestrictedToMods`; `language[]`; + - 1.0 adds: `manuallyApprovesFollowers` for private communities, `discoverable: false` for unlisted ones, and the + community's post tags in `tag[]` as `CommunityPostTag` with colour slots `color01`–`color10`. +- **Page (post):** + - `name`, `content` and `source`; + - a link post is `attachment[0] = Link{href, mediaType}`, with `image` as the thumbnail; + - 1.0 image posts are `Image{url, name}`, where `name` is the alt text; + - `language{identifier, name}`, `audience`, `to: [community, Public]`; + - an automatic `#` hashtag; + - 1.0 adds a Mention of the community and `context`. +- **Note (comment):** `distinguished`. +- **Votes:** `Like`/`Dislike` as `{actor, object, audience}` with no `to`/`cc`, and their `Undo`. +- **Moderation:** + - a `Delete` with a `summary` is a mod removal (1.0 adds `withReplies`); its `Undo` restores; + - `Lock`/`Undo{Lock}` (1.0 also on comments); + - a ban is `Block{target, removeData, summary, endTime}` (0.19 also sends `expires`); + - `Add`/`Remove` of featured posts and moderators; + - `Update{Group}` **by the moderator Person**; + - 1.0 adds `Warn` and `Resolve{Flag}`. +- **Delivery:** everything travels inside the community's `Announce`. For a new post Lemmy also sends a compatibility + `Announce(Page)` with a synthetic id; deduplicate it. +- **Private messages:** `ChatMessage` on 0.19; a single-recipient `Note` on 1.0. +- **Context:** 1.0's `context` collection is unpaged and **every comment's own context URL returns the whole post + thread**. Group threads by the root post, never by comparing `context` strings. +- **Feeds:** multi-communities are 1.0's `type: Feed` actors. + +**Expects** +- **Fetched documents:** Content-Type exactly one of `activity+json`, `activity+json; charset=utf-8`, or `ld+json` with + the profile; and `id` equals the URL fetched. +- **Addressing:** + - Create, Update, Lock, Delete and Block **must carry both `to` and `cc` arrays**. + - A post's community is the first Group found in `to` ∪ `cc`. + - In a public community, the object, the activity **and the Announce** all include Public. +- **Accepted types:** Page, Article, Note, Video and Event become posts. **`Question` is dropped.** +- **Anti-spam:** activities for a community are accepted only if a local user follows it. +- **Actors:** a Create, vote, moderation action or Flag must come from a Person, Service or Organization. **A Group or + Application actor fails to parse**, so moderation comes from the moderator Person. Our Flags, sent by an + Application instance actor, probably fail *(unconfirmed)*. +- **Flags:** exactly one `to` (community or site); `object` a URL or an array; the reason in `summary` or `content`. +- **Moderation trust:** an action is trusted when it is on the community's or the object's host (FEP-fe34), or when its + actor is in the moderators list Lemmy fetched. +- **1.0 hides a local user's post or comment in a remote community until that community Announces it back.** A + community we host must therefore announce to the author's own instance too. +- **Refused:** Lemmy does not accept an incoming `Announce(Page)`. + +**Gaps** + +| Gap | P | Client surface | +|---|---|---| +| `Dislike` and its `Undo`: a vote ledger per object (actor, ±1, activity, relaying group, time) | P1 | `favourites_count` = upvotes; own `privapub.vote{score, up, down, mine}`, `POST …/vote` | +| Announces of activities other than Create (votes, moderation): trust the inner activity when the object's own group signed the Announce; refetching every vote does not scale. Keep the origin refetch for Create and Update. | P1 | — | +| Moderation state: removals (reason, by, at, cascade), locks, bans with `endTime`/`removeData`, featured, moderators, `Update{Group}` by a moderator | P1 | removed posts hidden plus `privapub.removed`; `privapub.locked` (replying answers 422); pins as `pinned=true` | +| Link posts: keep `Link.href`, the thumbnail `image` and alt text; build the card (Lemmy sends no title or description for the link) | P1 | `Status.card` | +| `ChatMessage` in and out (out only to Lemmy < 1.0 and Mbin; `Note` to everyone else) | P1 | `visibility: direct` | +| Outbound shape for Lemmy: both `to` and `cc`; the community in `to`; Public in the object, Create and Announce; votes and comments sent to the community inbox | P1 | — | +| Communities we host: announce to every follower instance including the author's; pick `Announce(object)` per peer by NodeInfo (as PieFed does) | P1 | — | +| Flags from a `Service`-typed reporter actor with `to: [community]`; the reporter stays anonymous | P2 | — | +| `Warn` → `moderation_warning` notification; `Resolve{Flag}` | P2 | AccountWarning | +| Remote communities: `description`, `language[]`, private (`locked`), `discoverable`; post tags | P2 | `Account.locked`, own `privapub.flairs[]` | +| Serve our communities' collections the way Lemmy reads them: inline outbox of `Announce{Create{Page}}`, inline featured Pages, inline moderators. Lemmy does not page. | P2 | — | +| `Feed` actors | P2 | group-like account | +| Read 1.0 `context`, grouped by root post; cross-post detection by URL | P3 | — | + +### PieFed 1.7.17 and Mbin 1.10.1 + +- **PieFed sends Lemmy's set plus:** + - posts: `commentsEnabled`, `stickied`, `nsfl`, `genAI`, `searchableBy`, `canQuote`; + - galleries of several Documents; + - **polls in communities** (`Question` with `votersCount`), and Mobilizon-style `Event`s; + - flairs in two dialects; + - comments: `repliesEnabled` (comment lock), `answer`, plus custom `ChooseAnswer` and `PollVote` activities; + - `Move{object: post}` between communities; + - `Feed` actors; + - emoji Likes and `EmojiReact` count as upvotes. +- **How PieFed formats what it sends us:** + - It **chooses the format by our NodeInfo software name**, so keep that accurate. + - Software it lists as microblogs gets a bare `Announce(URL)`. + - A poll vote is recognised only when it is a `Note{name, inReplyTo}` **without `published`**. + - Batched Announces (FEP-1a11) go only to PieFed and Pylova. +- **Mbin:** + - Thread `summary` is "short title + tags", so it is **not a CW**. + - On link threads `source` is a **plain URL string**. + - `commentsEnabled` and `stickied` are sent. + - A Person's `Announce` counts as an upvote; Mbin reads the `likes`/`dislikes`/`shares` counts we publish. + - Private messages are `ChatMessage`. + - The magazine outbox is empty. + - Mbin also auto-ingests Mastodon posts by hashtag and Announces them. +- **Gaps:** + - **P1:** Lemmy's P1 set, W2, and tolerating `source` as a string. + - **P2:** galleries; community polls (vote without `published`); post `Move`; `repliesEnabled`; `nsfl`; flairs; + publish `likes`/`shares` totals. + - **P3:** `genAI`; accepted answers; batched Announces. + +### NodeBB 4.16, Discourse, Friendica + +- **NodeBB:** + - A topic's first post is an `Article` with `name`, **`summary` = an excerpt** and `preview`; replies are Notes. + - Categories are `Group`s **without `followers`**. + - `context` is a paged collection with an **ETag digest**; NodeBB refetches with `If-None-Match`. + - Since 4.15, an Announce of anything but a Create or a plain object is accepted only from Group actors. + - It sends `Move`/`Remove` of a whole context (FEP-f15d) and `Add{post → context}` (FEP-11dd). +- **Discourse** (plugin, semi-dormant): categories and tags are Groups. "Full Topic" mode makes the topic an + OrderedCollection used as `context`. +- **Friendica** (2026.05-1): + - Group accounts relay with `Announce(object)`. + - Titled posts are `Page`/`Article`; it sends `Dislike`. + - `instrument{Service}` names the software. + - It sends **`Follow` with a post as the object**, meaning "include me in this thread". Answer that with `Reject` or + ignore it, without an error. +- **Gaps:** + - **P1:** W2 for NodeBB Articles (`privapub.excerpt`). + - **P2:** Groups without `followers`; Announces from an Application; context Move/Remove; paged `context` with ETag; + Friendica's thread-Follow. + +### PeerTube 8.3.1 (2026-09-28) + +**Emits** + +The account sends `Create{Video}`; the channel (a Group) sends `Announce{Video}`, so deduplicate. + +| Part of the Video | What it holds | +|---|---| +| Attribution | `attributedTo: [Person, Group]` (both, possibly bare URLs); `audience` = the channel | +| Description | Markdown in `content` with `mediaType: text/markdown`; `summary` is the CW (since 7.2) | +| `url[]` | A `text/html` watch page; per-resolution mp4 Links (`height`, `width`, `fps`, `size`, ffprobe codec types); HLS `application/x-mpegURL` (since 6.3 audio and video can be separate, with "0" as the audio-only resolution); torrent and magnet; a metadata JSON | +| Images | `icon[]`: thumbnails up to 1920 px; `preview`: storyboards | +| Captions | `subtitleLanguage[]` with VTT and HLS URLs | +| Chapters | `hasParts` | +| Playback and metadata | `duration` (ISO 8601), `views`, `state`, `isLiveBroadcast`, `permanentLive`, `latencyMode`, `commentsPolicy`, `downloadEnabled`, `category`, `licence`, `language`, `support`, `uuid`, `embedUrl`, `originallyPublishedAt`, `schedules`, `aspectRatio` | +| Sensitivity | `SensitiveTag` | + +- **Value meanings:** + - **Live now** means `isLiveBroadcast && state == 1`. + - `commentsPolicy`: 1 open, 2 closed, 3 needs approval. +- **Other activities:** + - Comments are Markdown Notes. + - `View` comes from the server's Application actor. + - `Dislike`; `ApproveReply` (FEP-5624, since 6.2); `CacheFile` (mirrors); playlists. + +**Expects** +- **Replies** must: + - be Public; + - have non-empty `content`, a valid `url` and `published`; + - have an `id` on the actor's host; + - have an `inReplyTo` that resolves to the video or one of its comments. +- `commentsPolicy` 2 rejects replies; 3 holds them until approved. +- PeerTube signs its fetches. +- It drops followers that have been unreachable for about 7 days (8.2). + +**What Mastodon does with it:** `

name

` + summary + link. The description is dropped and there is no +attachment. The player is a card whose iframe loads from the remote host, which our proxy rule forbids. + +**Gaps** + +| Gap | P | Client surface | +|---|---|---| +| Store the whole Video: variants, thumbnails, storyboards, captions, chapters, duration, live state, views, `commentsPolicy`, licence, category, language, support, channel | P1 | own `privapub.video` | +| Play through the proxy: a `MediaAttachment{type: video}` pointing at a proxied muxed mp4 (a `web-video` file, or a fragmented file whose codec types include both audio and video); `preview_url` = a ~560 px `icon`; `meta.original` with width, height, `frame_rate`, `duration` | P1 | `media_attachments` | +| The proxy answers **Range** requests and rewrites HLS playlists and caption URLs to proxied ones. A 720p file is about 0.9 GB, so stream it; never buffer. | P1 | — | +| A video card made from the object, **without** a remote iframe | P1 | `Status.card{type: video}` | +| Reply rules: closed when `commentsPolicy` is 2; replies sent Public with a `url`; `ApproveReply` shows our reply as pending | P1 / P2 | own | +| Dislike counts; live state through `Update`; chapters and captions | P2 | own | +| Optionally, `View` sent from the instance actor (so it never names a persona) | P3 | — | + +### Loops (1.0.0-beta.14) and Pixelfed (0.14.4) + +- **Loops:** + - A video is a Note with one mp4 `Document` whose `url` is a string. **The poster is in the object's `preview`.** + Videos are vertical. + - Its interactionPolicy follows GoToSocial's model. + - It sends `QuoteRequest` and `FeatureRequest`. + - A top-level post it accepts must be a Note with an mp4 attachment, from an instance its admin allowlisted, **≤ 100 MB, + checked with a HEAD request**. Our media must answer HEAD. +- **Pixelfed:** + - Posts are a Note with attachments. + - It also sends `location: Place{name, latitude, longitude, country}`, `commentsEnabled`, `capabilities`, and + `canQuote` (0.14). + - Stories are `Add{Story}` with a bearcap only Pixelfed understands. + - Pixelfed 0.14 does FEP-044f and FEP-8fcf. +- **What Pixelfed accepts:** + - Only `Note`s, and **a top-level post must have media**. + - **Every** attachment must be a Document or Image with a string `url` and a `mediaType` in the instance's list. The + default list is **jpeg, png and gif only**, and a single webp or avif attachment rejects the whole post. +- **Gaps:** + - **P1 outbound:** keep JPEG/PNG renditions with an explicit `mediaType`. + - **P1 inbound:** Loops' `preview` poster. + - **P2:** Pixelfed `location` → own `privapub.place`, display only and never re-federated; `commentsEnabled: false` + disables replies. + - **P3:** ignore `Add{Story}` without an error; answer `FeatureRequest` with `Reject`. + +### Long-form: WordPress plugin 9.3.1, Ghost 6, WriteFreely 0.17.2 + +FEP-b2b8 (draft) describes the shape: plain-text `name`, a `summary` teaser (≤500), full HTML `content`, `image`, and +a `preview` Note fallback. + +- **WordPress:** + - Object type: an Article for a titled post, a Page for a page, otherwise a Note. + - Fields: `image` (the featured image), `preview`, `interactionPolicy.canQuote`. + - A CW is `sensitive` + `summary` + `dcterms:subject`. + - `id` is `?p=123`, different from `url`. + - The blog actor is a Group with `attributionDomains`. + - It sends an `Update` on every save, and **signs with RFC 9421 first** (9.3.0), falling back to draft-cavage after + any 4xx. + - It drops followers-only replies. +- **Ghost 6** (its ActivityPub service is separate, built on Fedify): + - Article with `image` as a bare string and `preview`; members-only parts removed. + - It refetches every object signed, **never applies remote Updates**, and accepts only Note and Article. + - Public is addressed as `as:Public`. +- **WriteFreely:** Article when the body has a paragraph break. **`preview` reuses the Article's id**, so never store + it as its own post. It has no comments. +- **Gaps:** + +| Gap | P | Client surface | +|---|---|---| +| An Article shown in the Mastodon API: `content` = name + teaser (`summary`, else `preview.content`) + a link to `url`; a card made from the object (title, description, `image` then `icon`, author, provider, date) | P1 | `Status.content`, `Status.card` | +| The full sanitised HTML kept for a reader view | P1 | own `privapub.article{title, html, cover, excerpt}` | +| Body images duplicated in `attachment` removed; `attributedTo` arrays resolved to the Person | P2 | — | + +### Events: Mobilizon 5.2.4, Gancio 1.28, Friendica/Hubzilla/Forte + +FEP-8a8e (draft) is the common reference. + +- **Mobilizon:** + - Times and status: `startTime`, `endTime`, `timezone`, `status`/`ical:status`, `isOnline`, `draft`. + - Place: `location: Place{address: PostalAddress, latitude, longitude}`. + - Participation: `joinMode`, `participantCount`, `maximumAttendeeCapacity`, `remainingAttendeeCapacity`, + `anonymousParticipationEnabled`. + - Comments: `repliesModerationOption`, `commentsEnabled`. + - Other: `category`, `contacts`. + - Attachments: the online link `Link{name: Website}`; `PropertyValue`s under `mz:` keys; a banner `Document`. + - The event is attributed to the Group. + - **RSVP:** `Join{object: event}` with a stable id that can be fetched; Mobilizon answers `Accept` or `Reject`; + `Leave`. +- **Gancio:** a single Application actor; `location` is an **array** of `VirtualLocation` and `Place`; no RSVP. +- **Friendica, Hubzilla:** RSVP with `Accept`/`Reject`/`TentativeAccept`. Hubzilla creates events as `Invite{Event}`, + with HTML in `location.content`, and `-00:00` for floating times. +- **FEP-8a8e:** a server that does not handle joins answers `Join` with `Ignore`. +- **Gaps:** + +| Gap | P | Client surface | +|---|---|---| +| Store times, time zone and place in every one of those shapes; RSVP routing (W6); `Invite{Event}`; answer `Join` with `Ignore` until RSVP exists | P1 | `content` = title + "date (zone) · place" + link; card with the banner | +| Capacity, join mode, online link, status, category | P2 | own `privapub.event` | +| RSVP out (`Join`/`Leave`, stable ids), from the persona the user chose | P2 | own `…/rsvp` | + +### Audio: Funkwhale 2.0.11, Castopod 1.15.5, Owncast 0.3.0 + +- **Funkwhale:** + - `Create{Audio}` whose `url` is an array: a page link plus the stream, with `bitrate` and `size`. + - Also `duration`, `position`, `disc`, `album`, `license` and `image`; `summary` is a hashtag line (W2). + - `Listen{Track}`. +- **Castopod:** an episode arrives as a link-only Note; the player comes from OpenGraph/oEmbed. Fetching the episode + with the podcast type gives a `PodcastEpisode` with transcript and chapters. +- **Owncast:** a `Service` actor; "go live" is a Note with a thumbnail. +- **Gaps:** + - **P1:** pick the audio Link out of `url`, sniff its type, apply W2. Surfaces as `MediaAttachment{type: audio}` + with `meta.original.duration` and the cover as `preview_url`. + - **P2:** duration, cover, album, position and licence (own `privapub.audio`); the Castopod transcript and chapters. + - **P3:** `Listen` history; Owncast live state. + +### Books: BookWyrm 0.9.3, NeoDB 0.19.4 + +- **BookWyrm:** + - **To non-BookWyrm servers** it sends a review as an `Article` named `Review of "Title" (★★★★): …`, a comment or + quotation as a Note with the citation appended, and the cover as a `Document`. + - The fields `inReplyToBook`, `rating` and `quote` go only to BookWyrm servers. +- **NeoDB:** `relatedWith[]` carries the item, the rating (out of 10), the review and the shelf; `tag[]` holds catalogue + items (`{type: "Movie", href, name, image}`). +- **Gaps:** + - **P1:** keep the Article `name`, `tag` entries that are not Mention or Hashtag, and covers that have no blurhash. + - **P2:** keep `relatedWith`, `rating` and `inReplyToBook` raw; a card for the item; own + `privapub.review{item, rating, scale}`. + +### Threads, Flipboard, Bluesky via Bridgy Fed + +- **Threads:** + - **Unsigned GETs answer 404**, so fetch it signed, as our instance actor. + - Quotes arrive as `_misskey_quote` + a FEP-e232 tag + an `RE:` fallback. + - Polls are not federated. + - Threads users must be 18 or over, opt in, and live outside the EU. + - Threads blocks servers that ignore deletes or have no privacy policy. + - **P1:** process `Delete` promptly; publish a privacy policy and a minimum age. +- **Flipboard:** + - Each flip is a link-only Note: headline, a link with `utm_*` parameters, and a Mention of the magazine. + - Magazines are Groups that `Announce` bare URIs. + - **P1:** fetch objects that arrive as bare-URI Announces (we do). + - **P2:** a card, or the post reads as a bare headline; deduplicate by canonical URL with `utm_*` stripped. +- **Bridgy Fed:** + - Actors are `https://bsky.brid.gy/ap/did:plc:…`, with handles like `@x.bsky.social@bsky.brid.gy` (**dots in the + user part**). `alsoKnownAs` holds `did:` values; there is no `published` and the outbox is empty. + - Posts: a `url` array with an `at://` canonical link (W1); **media without `mediaType`** (W4); link embeds flattened + into `content`; video thumbnails in `image`; long-form as Article. + - **Bridging a persona:** + - opt-in, by following the bot; + - the persona needs an `icon` and must be at least 7 days old; + - only public posts are bridged; + - **opting out takes a `Block` sent to the bot**, which we never send today. + - **Privacy note:** two personas created on the same day share the same day-truncated `published`. That is weak, + but a link. +- **P2:** `Account.created_at` fallback; the Bridgy opt-out Block (see §6). + +## 4. What the rich client needs from the server + +The rule: **never drop what arrived; keep it typed where we understand it and raw where we don't.** The Mastodon API +keeps working for existing apps. Everything it cannot express goes under a `privapub` object on the same entities, +following the precedent of `pleroma.*` and `akkoma.*`, plus a few endpoints of our own. + +### 4.1 One post, many kinds + +`Post` gains a `Kind` and one typed payload per kind. + +| Kind | From | Typed payload | Mastodon API view | `privapub.*` | +|---|---|---|---|---| +| note | everyone | — | as today | `source` (Markdown/MFM/BBCode), per-attachment `sensitive` | +| article | WordPress, Ghost, WriteFreely, NodeBB, BookWyrm, Bridgy | title, excerpt, cover, full HTML, `preview` | content = title + excerpt + link; card from the object | `article` (reader view) | +| video | PeerTube, Loops, Bridgy, Owncast | variants, HLS, poster, storyboards, captions, chapters, duration, live state, licence, channel | proxied `video` attachment + card | `video` | +| audio | Funkwhale, Castopod | stream variants, cover, duration, album, position, licence, transcript, chapters | `audio` attachment | `audio` | +| event | Mobilizon, Gancio, PieFed, Friendica, Hubzilla, CherryPick | start, end, zone, place, online link, join mode, capacity, status | text + card | `event` (+ RSVP) | +| poll | Mastodon, Misskey, Pleroma, GoToSocial, PieFed | options, counts, multiple, end, closed, voters | `poll` | — | +| link | Lemmy, PieFed, Mbin, Flipboard, Mastodon 4.7 | href, thumbnail, alt, author | `card` | `link` | +| review | BookWyrm, NeoDB | item, rating, scale, shelf | text + card | `review` | +| page / thread | Lemmy, PieFed, Mbin, NodeBB | title, community, flair, lock, removal, score | content + `card` for links | `thread{community, flairs, locked, removed, score, distinguished}` | + +These cut across every kind: + +| Feature | Mastodon API | `privapub.*` | +|---|---|---| +| Quote | `quote{state, quoted_status}` | the URI when it could not be fetched | +| Emoji | `emojis` | — | +| Reactions | `emoji_reactions` (and `pleroma.emoji_reactions`) | — | +| Votes | — | `vote{score, up, down, mine}` | +| Interaction policy | GoToSocial-style `interaction_policy` | — | +| Edit history | `edited_at`, `/history` | — | + +### 4.2 Linking things together + +Every reference is kept as a URI even when the target could not be fetched: the parent, the quoted post, the +community, the channel, the book, the original `url`. The client can then link out where it cannot embed. This +follows the `akkoma.in_reply_to_apid` / `akkoma.quote_apid` precedent. + +A link opens `url` (W7), never `id`. Media and thumbnails only ever go through the proxy, so the client never contacts +a remote host. + +### 4.3 Details view ("nerd stats") + +`GET /api/privapub/v1/statuses/:id/provenance` (and the same for accounts): + +| Group | Fields | +|---|---| +| Raw | The object exactly as received, with its hash. Every later refetch and `Update`, with timestamps. The `@context` as sent (the namespaces show `toot`, `misskey`, `litepub`, `lemmy`, `pt`, `mz`, `gts`, `fedibird`, …). | +| Path in | How it arrived: direct Create, inbox forward, Announce (and by whom: community, channel, magazine, relay), backfill, or fetch on demand. Delivered to the personal or the shared inbox. | +| Trust | Signature scheme: draft-cavage (algorithm string, signed headers, query signed or not), RFC 9421, FEP-8b32 proof, or verified by refetch from origin. Key id and key type. LD signature present but ignored. | +| Time | `published`, `updated`, received, and their gaps (backdated posts, clock skew). | +| Extensions | Detected from properties: 044f `quote`, `interactionPolicy`, 7888 `context`, 8b32 `proof`, 521a `assertionMethod`, 8967 Link attachment, c16b `htmlMfm`, `_misskey_*`, `searchableBy`, `formerRepresentations`, FEP-1b12 `audience`. | +| Origin | Software and version from NodeInfo, with a family. This is for display only: FEP-0151 says the names are opaque, so never branch on them except where a peer does the same to us (PieFed). | +| Media | Variants with codec, fps, size and bitrate; infohashes, magnets and mirrors; licence. | +| Counts | Remote likes, boosts, views, downloads and dislikes as last seen, with the time. | +| Interactions | Policy as received, defaults applied, approval state, authorization URIs. | +| Moderation | Removals, locks and bans as received from a community, with reasons. | + +`GET /api/privapub/v1/instances/:host` returns cached NodeInfo (`metadata` extras: Misskey `themeColor`/`maxNoteTextLength`, +Pleroma `features[]`/`federation.mrf_policies`), the instance API's icon and description, the delivery health our +circuit breaker sees, and which signature scheme worked. + +`software.name` → family, for display: + +| Family | `software.name` values | +|---|---| +| Mastodon | mastodon (glitch-soc shows `+glitch` in the version), hometown, fedibird, kmyblue | +| Misskey | misskey, sharkey, cherrypick, iceshrimp, firefish, foundkey, catodon | +| Pleroma | pleroma, akkoma | +| Microblog | gotosocial, takahe, hollo, mitra, snac, smithereen, ktistec, wafrn, bonfire, friendica, hubzilla, streams/forte | +| Threadiverse | lemmy, piefed, mbin, kbin, lotide, nodebb, discourse | +| Media | peertube, pixelfed, loops, funkwhale, owncast, vernissage, castopod | +| Publishing | wordpress, ghost, writefreely, plume | +| Events | mobilizon, gancio | +| Other | bookwyrm, neodb, forgejo | +| Bridges and relays | bridgy-fed, activityrelay | + +## 5. Signatures, identity and transport + +| Topic | State on 2026-10-01 | PrivaPub | P | +|---|---|---|---| +| RFC 9421 inbound | Mastodon accepts since 4.5. WordPress and Fedify sign with it first. GoToSocial, the Misskey family, Akkoma, Pleroma and Bridgy do not. | Verify RSA and Ed25519; `content-digest` (RFC 9530); one signature; `created` and `keyid` | P2 | +| RFC 9421 outbound | Mastodon 4.7 double-knocks | draft-cavage first; RFC 9421 after a 401; remember per host | P2 | +| 400 vs 401 | A 400 or 401 makes Mastodon 4.7 and WordPress retry with the other scheme | 401 only for signature failures (we do this); 400 only for bodies that are really malformed | P1 (keep) | +| Temporary key failure | Mastodon answers 503 | We should answer 503 too, and treat a 503 as a retry in delivery | P2 | +| Keys | `publicKey` can be an array (Mastodon 4.6). FEP-521a `assertionMethod` Multikey is FINAL (Ed25519 `z6Mk…`). GoToSocial key ids have no `#` and point at a stub. | Read all of these | P2 | +| Integrity proofs | FEP-8b32 `eddsa-jcs-2022`: JCS, no JSON-LD. Sent by Mitra, Streams, Hubzilla, Fedify and others; Mastodon verifies them from 4.7 | Verify, so relayed or forwarded objects need no refetch | P2 | +| LD signatures | Mastodon still sends `RsaSignature2017` | Ignore, and refetch from origin (we do) | — | +| Query string | GoToSocial, Akkoma 3.20 and Misskey 2026.10 sign it; GoToSocial retries without it | Verify both ways | P1 | +| `hs2019` | The algorithm comes from the key; some senders hash with SHA-512 | Try rsa-sha256, then sha512 | P2 | +| Move (FEP-7628, FINAL 2026-08-26) | See Mastodon | Inbound P1; outbound per persona P3 (never link personas) | P1 / P3 | +| Followers sync (FEP-8fcf) | Mastodon, Pixelfed, Fedify and WordPress | Send and honour `Collection-Synchronization`. It protects followers-only posts. | P2 | +| Instance actor discovery | FEP-d556 (FINAL), FEP-2677 | Publish both | P3 | +| Relays (FEP-ae0c, FINAL) | Mastodon-style relays forward LD-signed Creates; LitePub-style relays Announce. GoToSocial 0.22 subscribes to relays. | Client for both styles; refetch or check an integrity proof. This is how a small server sees beyond its follows. | P2 | +| FASP | Mastodon 4.4+, behind a flag. Its data sharing pushes content to a third party. | Do not join the data sharing; maybe consume search and trends | P3 | +| Search consent | `indexable` (missing = false), `discoverable`, `searchableBy` (FEP-268d, which takes precedence) | Honour all three; emit explicit `false` per persona | P2 | + +## 6. Choices the privacy design has to make + +These change what PrivaPub reveals, so they are the owner's calls, not implementation details: + +1. **Link previews.** + - Building a card from the object, or from FEP-8967 `preview` data, fetches nothing; we do that. + - Fetching the linked page tells that site our server read the link. Cache per URL across personas, so no fetch + ties a page to one persona; add jitter. + - Proposed admin switch: *off / from the object only / fetch*. +2. **Outbound Block.** + - Blocks are never sent today. + - Bridgy Fed's opt-out *requires* one, and Mastodon, GoToSocial and Misskey federate blocks as a matter of course. + - Proposal: never by default; an explicit per-persona "tell their server" option. +3. **`attributionDomains` / `fediverse:creator`.** It ties a persona to a website. Opt-in per persona only. +4. **PeerTube `View`.** Counting a view tells the origin a video was watched. If ever, send it from the instance actor. +5. **Bridging to Bluesky.** It is per persona; each persona needs its own avatar and 7 days of age. Same-day personas + share a `published` day. +6. **Emoji reactions and votes** are public by nature on every platform that has them. The client should say so + before the first one. + +## Sources + +The full source lists, with versions and dates, were gathered by five research passes on 2026-10-01. The primary +ones: + +| Area | Sources | +|---|---| +| Mastodon | `CHANGELOG.md` (4.3.0–4.7.2); docs.joinmastodon.org (`spec/activitypub`, `spec/security`, `spec/webfinger`, `client/quotes`, `client/collections`, entities); the `main` source (`note_serializer.rb`, `activity/{create,update,accept,delete,quote_request,move,like,flag}.rb`, `status_parser.rb`, `media_attachment_parser.rb`, `fetch_link_card_service.rb`, `verify_quote_service.rb`, `fetch_all_replies_service.rb`, `signed_request.rb`); developer posts for 4.5, 4.6 and 4.7; GHSA-vwhj, GHSA-rwcw, GHSA-vg36; issue #39997 (spam, July 2026) | +| GoToSocial | Codeberg releases 0.18.0–0.22.1; `docs/federation/*` (`interaction_controls`, `posts`, `actors`, `http_signatures`, `access_control`); `internal/typeutils`, `internal/ap`, `internal/federation`, `internal/transport`; issues #4939, #1894, #4994, #4849, #4677 | +| Misskey family | misskey-dev/misskey develop `ed9654b` (`ApRendererService`, `ApInboxService`, `ApNoteService`, `ApAudienceService`, `ReactionService`, `check-against-url`); PR #16250; Sharkey develop `effcecb`; Iceshrimp.NET dev `926fcde` (`FEDERATION.md`, `LdHelpers.cs`, `HttpSignature.cs`, `NoteRenderer.cs`); CherryPick develop | +| Pleroma, Akkoma | Pleroma develop `cfeca7f` (`transmogrifier.ex`, `inbox_guard_plug.ex`, `ap_extensions.md`); Akkoma develop (`FEDERATION.md`, `CHANGELOG.md`, `nodeinfo_extensions.md`) | +| Lemmy | LemmyNet/lemmy `main` `f1476db` and tag 0.19.20 (`crates/apub/apub/assets/*` fixtures, `objects/src/protocol/*`, `activities/src/protocol/*`, the pending-post migration); PRs #5856, #6152, #6401, #6409, #6466; issues #6281, #6343, #6346; activitypub-federation-rust `fetch/mod.rs` | +| Other threadiverse | PieFed `41cccb7` (`FEDERATION.md`, `docs/activitypub_examples`, `docs/fep-1a11.md`); Mbin `cf95b04` (`docs/05-fediverse_developers`); NodeBB `260a0cc` (`src/activitypub/*`); discourse-activity-pub; friendica `FEDERATION.md` | +| PeerTube | docs.joinpeertube.org/api/activitypub; Chocobozzz/PeerTube develop (`object-to-model-attributes.ts`, `custom-validators/activitypub/*`, `process-*.ts`); live fetches from peertube2.cpy.re, garr.tv (8.2.4), tube.tchncs.de | +| Pixelfed, Loops | pixelfed dev (`Transformer/ActivityPub/Verb/*`, `Inbox/HandlesCreates.php`, `Helpers::verifyAttachments`); loops-server main (`FEDERATION.md`, `NoteWithVideoAttachmentValidator.php`) | +| Long-form, events, audio, books | wordpress-activitypub trunk (`class-post.php`, `class-signature.php`); TryGhost/ActivityPub v1.2.13; writefreely `posts.go`; Mobilizon 5.2.4 converters; Gancio 1.28.2/2.0-beta; Funkwhale 2.0.x; Castopod 1.15.5; Owncast 0.3.0; BookWyrm 0.9.3; NeoDB `docs/internals/activitypub.md` | +| New entrants | fed.brid.gy/docs and bridgy-fed `activitypub.py`; live probes of threads.net, flipboard.com and bsky.brid.gy; engineering.fb.com (2024-03-21) | +| FEPs | codeberg.org/fediverse/fep at 2026-09-30: 044f, 1311, 1a11, 1b12, 2345, 268d, 2c59, 4f05, 521a, 5624, 5feb, 7458, 7628, 7888, 7aa9, 844e, 8967, 8a8e, 8b32, 8fcf, 9098, 9967, ae0c, b2b8, c0e0, c16b, d556, e232, ef61, f15d, f228, fb2a, fe34 | +| Signatures | SWICG "ActivityPub and HTTP Signatures" report and its RFC 9421 adoption tracker; SocialHub "RFC 9421 HTTP signatures in 2026" (Jan 2026) | diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 8e0e4da..22bc529 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -10,7 +10,10 @@ Written 2026-10-01 from the original 2023 code, the decePubClient UI, a federati - [x] P2 Mastodon client API: v1.4.0, deployed 2026-10-01; OAuth and the anonymous API verified on production, the signed-in API verified locally (no real-client login on production yet) - [x] P3 Social features: v1.5.0, deployed 2026-10-01; media, proxy, blocks, mutes, bookmarks, pins and reports verified by tests and locally (no upload on production yet) - [x] P4 Groups and privacy features: v1.6.0, deployed 2026-10-01; communities, circles and local-only located posts verified by tests. v1.6.1 adds the pasture (`tools/pasture/`): live interop with GoToSocial 0.22.1 passes all 25 checks, three runs in a row. Lemmy and a live Mastodon circle member are not run yet; the pasture has GoToSocial only -- [ ] P5 FEPs and polish +- [ ] P5 Lose nothing: wire tolerance, full objects, raw capture (see `docs/INTEROP.md`) +- [ ] P6 Emoji, polls, quotes, reactions, cards, players +- [ ] P7 Threads, communities, moderation, the social graph +- [ ] P8 Signatures, discovery, the long tail ## Intent @@ -386,28 +389,112 @@ The first refactor commit is a pure move with namespaces only. Logic changes fol - **Persona hardening:** optionally re-key existing avatar ids; an optional `SecureMode` (signed GETs required); no suggestions or directories that could relate sibling avatars. -### P5 FEPs and polish (ongoing) -- **FEP-8fcf:** followers synchronisation. -- **FEP-5feb:** honour remote `indexable` in search. -- **FEP-7628:** Move in both directions, plus editing `alsoKnownAs`. -- **FEP-044f:** quotes and `interactionPolicy`. -- **RFC 9421:** inbound verification with NSign, plus Content-Digest. -- **Mastodon API extras:** polls, the streaming WebSocket (nginx Upgrade headers), Web Push (VAPID), and grouped - notifications v2, after which the advertised version moves to 4.3. +### P5 and beyond: rich content and the rest of the fediverse + +Rewritten on 2026-10-01 from the research in `docs/INTEROP.md`, which holds the per-platform evidence and the +priorities. The goal is a future client that shows and links every kind of fediverse content in one place, with a +details view of where each object came from. So the server keeps everything it receives: typed where it understands +it, raw where it doesn't. + +#### P5 Lose nothing (wire tolerance and full objects) +- **Cheap fixes that are wrong today:** + - `summary` is a content warning only on a `Note`, or when `sensitive` is set; elsewhere it is an excerpt or + description (INTEROP W2). + - Persona and group usernames must match Mastodon's and Misskey's pattern. + - Define every term we emit in our JSON-LD context. + - `Vary: Accept`; every activity id dereferences. +- **Parsing every shape:** + - `url`, `icon`, `image`, `attachment` and `attributedTo` as a value, an object or an array (W1). + - A Markdown `content` and `source` (W3). + - Inferring a missing `mediaType` (W4). + - Every thumbnail location (W5). + - Language from `@context` (W11); alt text from `summary` (W12). + - A null `id`, a 200 `Tombstone`, a `Delete` before its `Create` (W9, W13). +- **`Post.Kind`** plus typed payloads for article, video, audio, event, link, review and thread (INTEROP §4.1). The + Mastodon API view of each kind: content, a card made from the object without fetching, attachments. +- **Raw capture for the details view:** the object as received, how it arrived, the signature scheme and key, received + versus `published`, the extensions detected, and the origin's software (§4.3). Exposed at + `/api/privapub/v1/statuses/:id/provenance` and `/api/privapub/v1/instances/:host`. +- **Routing by object type:** + - `Accept`/`Reject`/`TentativeAccept` routed by what their object is (W6). + - `ChatMessage` in as a direct message. + - `Dislike` and votes recorded in a ledger. + - A `Join` answered with `Ignore` until RSVP exists. + - Friendica's thread-`Follow` refused without an error. +- **Delivery:** honour 503 with `Retry-After`; answer 503 ourselves when a key fetch fails temporarily. + +#### P6 What people see: emoji, polls, quotes, reactions, cards, players +- **Custom emoji** on posts, names, fields and poll options, proxied. +- **Polls** in and out, with the Misskey, Pleroma and PieFed vote shapes. A count refresh is never an edit. +- **Quotes (FEP-044f):** + - read every key; verify `QuoteAuthorization` on all of its fields; handle revocation; + - be quotable: `canQuote`, answer `QuoteRequest`, serve and revoke stamps; + - then advertise `api_versions.mastodon ≥ 7` (4.5.0). +- **Emoji reactions** in all three inbound forms, plus outbound `EmojiReact`, exposed as `emoji_reactions`. +- **Link cards:** + - from the object, or from FEP-8967 `preview`, without fetching; + - fetching the page itself is the owner's decision (INTEROP §6.1). +- **Media:** + - video playback through the proxy (Range requests, HLS playlist rewriting, the poster), with a `video` card; + - audio attachments; + - an article reader view; + - JPEG/PNG renditions kept for Pixelfed. + +#### P7 Threads, communities and the social graph +- **Thread backfill:** + - read in order: `contextHistory`, then `context` (paged, with ETag), then `replies`; + - group by the root post; + - publish our own `context` and a paged `replies`. +- **Lemmy, PieFed and Mbin:** + - the moderation set: removals, locks, bans, featured, moderators, `Warn`, `Resolve`; + - votes in and out; link posts; flairs; `Feed` actors; community polls; post `Move`; + - the outbound shape Lemmy requires; + - communities we host announce to the author's own instance too; + - Flags from a `Service`-typed reporter actor. +- **GoToSocial interaction policies:** stored and shown; send `ReplyRequest`/`LikeRequest` where approval is + needed; handle `Accept{result}`. +- **Accounts and follows:** + - inbound `Move` with Mastodon's checks; + - re-run WebFinger on a rename; + - inbound `Block`, plus `Add`/`Remove` of pins; + - FEP-8fcf followers sync; + - `indexable`/`discoverable`/`searchableBy`; + - edit history from `formerRepresentations`; + - PeerTube reply rules and `ApproveReply`. +- **Events:** structured RSVP (`Join`/`Leave` with stable ids). + +#### P8 Signatures, discovery and the long tail +- **Signatures:** + - RFC 9421 inbound (RSA and Ed25519, Content-Digest); + - outbound double-knock, remembered per host; + - `publicKey` arrays and FEP-521a Multikey; + - FEP-8b32 proof verification; + - `hs2019` with SHA-512. +- **Discovery:** + - a relay client for both relay styles; + - instance actor discovery (FEP-d556, FEP-2677); + - `implements` (FEP-844e). +- **Mastodon API:** streaming WebSocket, Web Push, grouped notifications. Then advertise an honest version. - **Backfill:** an author's outbox after following them. -- **Later:** FEP-521a, FEP-8b32, FEP-e232, relays. +- **Long tail:** + - MFM rendering data; + - Misskey actor extras; + - book reviews (`relatedWith`, `rating`); + - Funkwhale and Castopod metadata; + - Pixelfed `place` (display only, never re-federated); + - Bluesky bridging per persona. ### Cut or deferred (deliberately) - **Cut:** - - Link-preview cards: an SSRF and privacy risk. + - Link-preview cards *fetched from the linked page* by default. Cards built from the object, or from FEP-8967 + `preview` data, are in P6; fetching the page is an owner decision (INTEROP §6.1). - Translation, trends, directory and lists (filters stay as stubs). - Scheduled posts. - The Mastodon admin API: moderation stays on `/clientapi`. - `POST /api/v1/accounts` registration: avatars are created through `/clientapi`. - Local custom emoji. - S3 storage. - - JSON-LD and LD-signature processing. - - Outbound RFC 9421. + - JSON-LD and LD-signature processing. FEP-8b32 proofs need neither (JCS), so they are in P8. - **Deferred:** video transcoding (remux only for now). - **Out of scope:** moving decePubClient onto the Mastodon API.