R8 the Mastodon API made real: featured tags, endorsements, notes, notify and languages, suggestions, the direct stream

Featured tags per persona (/api/v1/featured_tags, suggestions, tags/:name/feature, accounts' featured tags, max 10),
listed at /tattoos as Hashtags and sent as Add/Remove{Hashtag} to followers on the featured collection, as Mastodon
sends them; remote accounts' tags received and read from featuredTags. Endorsements (pin/endorse) and private notes
(PersonaNote) kept per persona and never federated; the relationship's notifying, languages, endorsed and note, with
follow's notify (status notifications) and languages (home filter). Reading preferences set on /clientapi. Suggestions
v1/v2 with dismissals and staff picks, never the persona or a sibling. conversation events on the direct stream.
translate, annual reports and the admin API answer. New collections backed up and removed with their persona.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WpVCoDeM1qUTRp4fXQdfof
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-18 08:38:11 +02:00
1 parent d08fbabaa5
commit 87529bca49
42 files changed
+1505 -77

No files matched your search

+99 -6
View File
@@ -31,8 +31,10 @@ Its defining idea: **one private login owns several public personas.**
Privacy features in the model:
- location-ranged posts (`Post.Location`, `RangeKm`), local-only and never federated;
- contacts a persona can share (`Avatar.SharedPersonalContacts`) and private notes (`RootUserNote`, `PersonalNote`)
exist only as model classes, with no endpoint.
- contacts a persona can share (`Avatar.SharedPersonalContacts`) and the root's notes (`RootUserNote`, `PersonalNote`)
exist only as model classes, with no endpoint. A persona's private note on an account is `PersonaNote` (R8), its own:
`RootUserNote` is keyed by the root and would show a note made as one persona to its siblings, so it is never used for
it.
The intended client is the Mastodon client API (Tusky, Elk, Phanpy, Ivory), where each avatar logs in as its own
account. The private `/clientapi` covers what Mastodon can't express. `thepra/decePubClient`
@@ -57,7 +59,7 @@ actor-scoped route gets a name in the same spirit, agreed with the owner, and a
| DM context | `/peasants/{name}/whispers/{id}` |
| Quote permission (FEP-044f `QuoteAuthorization`) | `/peasants/{name}/parrot-licences/{id}` |
| Featured (pinned posts) | `/peasants/{name}/trophies` |
| Featured tags | `/peasants/{name}/tattoos` |
| Featured tags (an `OrderedCollection` of `Hashtag`s, R8) | `/peasants/{name}/tattoos` |
| Wall (FEP-400e) | `/peasants/{name}/graffiti` |
| Followers synchronisation (FEP-8fcf) | `/peasants/{name}/groupies/roll-call` |
| A group's members and moderators | `/peasants/{name}/flock`, `/peasants/{name}/wardens` |
@@ -127,7 +129,8 @@ PrivaPub/ ASP.NET Core Web API, net10.0
Instance/ InstanceSettings (the server's own settings in memory), MediaLimitsFromSettings
Moderation/ ModerationLog (the staff's log, AdminAction), AccountModeration (silence, suspend, posts
removed), Restrictions (who is held back, in memory), ModerationFlags (posts' flags again)
Social/ FollowService (local in-process, remote Follow/Accept), Notifications, Announcements
Social/ FollowService (local in-process, remote Follow/Accept), Notifications, Announcements,
FeaturedTags (a persona's featured hashtags, sent and received), Suggestions (who to follow)
Timelines/ Fanout (TimelineEntry rows, Mastodon's home rules), TimelineService
Privacy/ VisibilityPolicy (IsPublic expression, CanSee)
Relationships/ RelationshipService (blocks, mutes, account domain blocks; Hidden), ReportService
@@ -143,7 +146,8 @@ PrivaPub/ ASP.NET Core Web API, net10.0
Entities/ Mappers/ Mastodon entities; MastodonMapper (Account, Status), AccountSearch
Controllers/ apps, instance, accounts, statuses, timelines, notifications, search, lists, filters,
media, polls, reactions, reports, scheduled statuses, streaming, tags, trends, cdns,
provenance, custom emojis; the stubs are `StubsController` in `SearchController.cs`
provenance, custom emojis, featured tags, suggestions; the stubs are `StubsController`
in `SearchController.cs`
Web/Pages/ Razor: /@{user}, /@{user}/{id} (public posts only, strict CSP, noindex);
OAuth/: /oauth/login (root password), /oauth/authorize (choose persona, consent)
Services/ RootUsersService, GroupUsersService, PostsService, AppConfigurationService, RootRemoval,
@@ -350,6 +354,81 @@ client's role route never does.
here and elsewhere, and the posts taken down, before it does; indexes on `SilencedAt` and `SuspendedAt` of both account
collections and a partial one on `Post.ModeratedAt`. No new collection, so backups hold it all as they did.
## The Mastodon API made real (completion program R8)
- **Every row is one persona's** (`Models/Social/Featuring.cs`, new collections): `FeaturedTag`, `Endorsement`,
`PersonaNote` and `SuggestionDismissal` are keyed by the persona (`AvatarId`), never by its root, with unique indexes on
(persona, name or account); `RemoteFeaturedTag` is an account elsewhere's (by `ActorURI`). `Following` gained `Notify`
and `Languages` (null: every language), `ForeignAvatar` `FeaturedTagsURL`, `AvatarSettings` `ExpandMedia`
(`default`, `show_all`, `hide_all`) and `ExpandSpoilers`, `NotificationType` `Status` (last, as enum members are stored
as ints).
- **Featured tags** (`Domain/Social/FeaturedTags.cs`, `FeaturedTagsController`, `TagsController`): `GET`/`POST
/api/v1/featured_tags` (`name`; 422 for a name that is no hashtag or past `FeaturedTags.MaxFeatured`, 10, which both
instance APIs advertise as `max_featured_tags`; one featured already is answered as it is), `DELETE
/api/v1/featured_tags/{id}` (`{}`, 404), `GET /api/v1/featured_tags/suggestions` (Mastodon's `Tag`s: the tags of the
persona's last 200 posts it does not feature, the most used first, ten), `POST /api/v1/tags/{name}/feature|unfeature`
(the `Tag`, with Mastodon 4.4's `featuring`, which every `Tag` now carries for a signed-in persona), `GET
/api/v1/accounts/{id}/featured_tags` (anonymous too: a persona's own, an account elsewhere's from `RemoteFeaturedTag`, a
community none, a suspended account 404). Mastodon's `FeaturedTag`: `statuses_count` a string and `last_status_at` a
day, counted from the account's public and unlisted posts held here with the tag. They are public, so they federate:
`/tattoos` lists them as `Hashtag`s, and featuring one sends `Add{Hashtag}`, unfeaturing `Remove{Hashtag}`, to the
persona's followers, **with its `featured` collection (`/trophies`) as the target**, as Mastodon sends them
(`AddHashtagSerializer`) and the only target Mastodon takes a Hashtag from. Received: `FeaturedHandler` takes a `Hashtag`
added to or removed from an account's `featured` or `featuredTags` (an object with no id, read before anything takes its
`href` for a post), and `AccountCountsJob` reads the `featuredTags` collection (its own server only, three pages, 20 tags
at most) with the account's counts.
- **Endorsements** (`AccountsController`): `POST /api/v1/accounts/{id}/pin|unpin` (Mastodon's old names) and
`/endorse|/unendorse` (4.4) answer the relationship; only an account the persona follows (422 otherwise, as Mastodon
says it), 500 at most. `GET /api/v1/endorsements` lists the persona's own, newest first, those it still follows (paged
by `Link`); `GET /api/v1/accounts/{id}/endorsements` answers them to the persona itself and `[]` for every other account:
**endorsements stay private** (the owner's default), never federated as featured accounts. Unfollowing drops the
endorsement, as on Mastodon; a `Move` carries the endorsement and the note over to the new account.
- **Private notes:** `POST /api/v1/accounts/{id}/note` (`comment`, 2000 characters at most, 422 beyond; empty takes it
away) answers the relationship, whose `note` is the persona's own `PersonaNote`. Never federated.
- **The relationship** fills `notifying` and `languages` from the follow, `endorsed` (only while it follows) and `note`.
`POST /api/v1/accounts/{id}/follow` takes `notify` and `languages[]` (each changed only when given, as on Mastodon; an
empty list is every language). `Fanout` keeps a post in another language out of that follower's home (a post whose
language is unknown comes in; languages compare by their primary subtag) and gives a follower with `notify` a `status`
notification for the account's own posts and its replies to itself, never its boosts, once the post reached that home
(Mastodon's `FeedInsertWorker`). Archives carry both in `following_accounts.csv`'s own columns.
- **Preferences:** `GET /api/v1/preferences` answers `reading:expand:media` and `reading:expand:spoilers` from the
persona's settings, which Mastodon's API sets nowhere, so `GET`/`POST /clientapi/avatar/private/preferences`
(`ViewPersonaPreferences`, `PersonaPreferencesForm`, the login's own persona; 404 for any other, 422 for another mode)
sets them.
- **Suggestions** (`Domain/Social/Suggestions.cs`, `SuggestionsController`): `GET /api/v1/suggestions` (accounts) and
`/api/v2/suggestions` (`source`, `sources`, `account`), `limit` 40 (80 at most); `DELETE /api/v1/suggestions/{account_id}`
(`{}`) dismisses one for that persona for good. In order: the staff's picks (`ServerSettings.StaffPicks`, `staff` /
`featured`), past interactions (accounts the persona favourited, boosted or answered lately, `past_interactions`, which
4.3's `sources` has no word for), then global: accounts followed by at least three personas here (`most_followed`) and
personas here whose public posts of the last 30 days were favourited and boosted most (`most_interactions`),
discoverable ones only, worked out at most every ten minutes. **Never the persona itself and never a sibling persona of
its login** (`RootToAvatar` read server-side only: suggesting a sibling would pair them the moment the persona followed
it), nor what it follows, asked to follow, blocked, muted, was blocked by (here or elsewhere), dismissed or hides by
server, nor an account held back (`Counted.HeldBackPersonas`), suspended, silenced or shown `limited`; a community only
as a staff pick.
- **Staff picks** (`InstanceAdminController`, administrators, checked against the database): `GET
/clientapi/admin/staff-picks`, `POST /staff-picks/insert` (`StaffPickForm`: a persona here or a person elsewhere by its
Mastodon id; 404 for no such account, 409 for one picked already, 422 past 40), `POST /staff-picks/delete?accountId=`;
each answers `List<ViewStaffPick>`, which `ViewServerSettings.StaffPicks` shows too (`IsShown` false for one deleted,
suspended or silenced since). They are kept in the settings but never changed by the settings form, so an older page
saving the form keeps them. Logged as `staff_pick.insert` and `staff_pick.delete` (target `persona` or `account`, so an
entry about a persona of the acting login keeps no actor).
- **familiar_followers stays empty by design:** which accounts the persona follows also follow another would tell who
follows whom among the personas here; every account answers `accounts: []`.
- **The direct stream:** `StreamingController` takes `stream=direct` (and the WebSocket's `subscribe` to `direct`); a
direct message saved here or arriving (`StatusService`, `CreateHandler`, after `ConversationStates.Posted`) tells the
`direct` streams of the conversation's personas a `conversation` event (`Fanout.Conversation`), mapped per persona as
`/api/v1/conversations` maps it.
- **Stubs that answer:** `POST /api/v1/statuses/{id}/translate` 422 (translation is cut), `GET /api/v1/annual_reports`
`{"annual_reports":[],"accounts":[],"statuses":[]}`, every verb under `/api/v1/admin/**` (and `GET`/`POST` under
`/api/v2/admin/**`) 403 `{"error":"This action is not allowed"}`, signed in or not (moderation stays on `/clientapi`).
- **Removal, backups, archives:** `PersonaRemoval` deletes the persona's featured tags, endorsements, notes and
dismissals, and what other personas kept about it (their endorsements of it, notes on it, dismissals of it); the Delete
of the actor tells other servers. Every new collection is in every backup (none is in `ServerBackup.Excluded`: they are
personas' own data, the remote featured tags a cache like `RemoteFeatured`); a restore keeps them as the backup has them,
and `ProtectiveMerge` deletes those of a persona deleted since. Mastodon's account archive has no featured tags, notes or
endorsements, so the persona archive leaves them out.
## Custom emoji (completion program R7)
- **The server's own emoji** (`Models/Post/LocalEmoji.cs`, a collection of its own): a shortcode (`[a-zA-Z0-9_]{2,64}`,
@@ -541,6 +620,10 @@ client's role route never does.
(`ObjectShapes.Flairs`, both dialects). Every media URL in it goes
through the proxy; only page links (`link.url`, an event's online link) point at the remote site, because following
one is the reader's choice.
8. **What a persona keeps through the API is that persona's** (R8): featured tags (public, federated), endorsements and
private notes (shown to it alone, never federated), dismissed suggestions. Suggestions never offer the persona itself
or a sibling persona of its login, and `familiar_followers` answers no one for every account, since it would tell who
follows whom here.
## Media invariants
@@ -673,7 +756,9 @@ client's role route never does.
- **An actor's `published` is `PublishedOn`**, a random whole day up to two weeks before creation, so personas made the
same day do not share a date. New persona and group ids carry that same day (`GenerateNewID`), because local clients
see ids. Never emit `CreatedAt`.
- **Per-avatar state stays per avatar:** blocks, mutes, notifications, follows. Nothing may relate sibling avatars.
- **Per-avatar state stays per avatar:** blocks, mutes, notifications, follows, and since R8 featured tags,
endorsements, private notes (`PersonaNote`, never `RootUserNote`) and dismissed suggestions. Nothing may relate sibling
avatars: suggestions never offer a sibling, and familiar followers stay empty.
- **Blocks federate** (owner decision, 2026-10-01): a block is sent as `Block` from the blocking avatar, an unblock as
`Undo{Block}`. Reports still leave as `Flag` from the server's own actors (the instance actor, or the reporter for
communities on Lemmy, PieFed and Mbin), never from the reporting avatar.
@@ -1061,6 +1146,14 @@ tools/pasture/run.sh down # removes e
in the status's `emojis` on Mastodon, GoToSocial (by URI), Misskey (the note's `emojis`) and Akkoma; her display name
shows it on Mastodon; her `EmojiReact` with it reaches Misskey and Akkoma as that emoji; deleted, it leaves the list,
its id answers 410 and posts here carry it no more; the staff's log keeps the insert and the delete.
- **Featured tags and the API made real (`scenarios/featured.sh`, needs mastodon; after emoji in batch A):** mastouser
follows feat_alice, who features a tag: Mastodon's `featured_tags` of her account shows it (her `Add{Hashtag}`, the
`/tattoos` collection serving it), unfeaturing takes it away there; mastouser features a tag, which feat_alice reads
(following him) through `/api/v1/accounts/:id/featured_tags`; following mastouser with `notify=true` gives feat_alice a
`status` notification for his next post; her SSE and WebSocket `direct` streams tell a `conversation` event for his
DM; her endorsement of him and her note on him are hers alone (feat_bob, a sibling persona of the same login, sees
neither, and `GET /api/v1/accounts/:id/endorsements` answers nobody else), and feat_bob is never suggested to her; the
stubs (translate 422, annual reports, the admin API 403) answer.
- **Mailpit (1.31.4, `peers/mailpit.sh`, as `mailpit.test`):** the pasture's mail server. `appsettings.Pasture.json`
sends PrivaPub's mail to `pasture-mailpit:1025` (plain SMTP, any login accepted) and its recovery links to
`https://decepub.test`. `scenarios/accounts.sh` reads what arrived through Mailpit's API: an address set with the