2026-10-01 10:38:35 +02:00
# CLAUDE.md
Guidance for working in this repository. `docs/ROADMAP.md` holds the owner's decisions and the phased plan to full
2026-10-01 14:01:57 +02:00
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.
2026-10-01 10:38:35 +02:00
## What this is
**PrivaPub** (repo name SocialPub) is a self-hosted ActivityPub server in C#, live at https://privapub.thepra.dev.
It is meant as a Pleroma-like microblogging server for privacy-minded people and small communities, federating with
Mastodon, GoToSocial, Pleroma/Akkoma, Misskey and Lemmy.
Its defining idea: **one private login owns several public personas.**
- **`RootUser` is the private login.** Username, password, optional email (used only for recovery), policies.
- **`Avatar` is a public persona.** Each one is an ActivityPub actor with its own RSA keys at `/peasants/{username}` .
Root and avatar are linked only through `RootToAvatar` .
- **Personas must stay unlinkable** to other users and servers; only the instance admin can see the link.
- **`Group` is an ActivityPub Group actor.** It has members, an invitation code and an optional password. Groups are
becoming two kinds: a public **community** (FEP-1b12, Lemmy-compatible) and a private, invitation-only **circle** .
- **`DmGroup` is a direct-message conversation.**
- **`privapub` is the instance actor** (type Application). It signs fetches no persona should be tied to.
Privacy features in the model:
- location-ranged posts (`Post.Location` , `RangeKm` ), to become local-only and never federated;
- contacts a persona can share (`Avatar.SharedPersonalContacts` );
- private notes (`RootUserNote` , `PersonalNote` ).
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`
(https://decepub.thepra.dev) is the owner's own Pleroma-FE-like PWA. It still shows mock data and is not wired to the
server.
## Route names are deliberate
The odd names are the owner's and part of the project's character. **Never "fix" them to conventional ones.** A new
actor-scoped route gets a name in the same spirit, agreed with the owner, and a name is frozen once it has federated.
| Thing | Route |
|---|---|
2026-10-01 11:28:37 +02:00
| Actor | `/peasants/{name}` (`/users/{name}` 301-redirects here; browsers are sent to `/@{name}` ) |
2026-10-01 10:38:35 +02:00
| Inbox | `/peasants/{name}/mouth` |
2026-10-01 11:28:37 +02:00
| Outbox | `/peasants/{name}/anus` (`?page=true[&max_id=]` for pages) |
2026-10-01 10:38:35 +02:00
| Shared inbox | `/human-centipede` (also `/peasants/{name}/human-centipede` ) |
2026-10-01 11:28:37 +02:00
| Followers | `/peasants/{name}/groupies` |
| Following | `/peasants/{name}/stalking` |
| Notes | `/peasants/{name}/scribbles/{id}` |
| Activities | `/peasants/{name}/grunts/{id}` (`create-{postId}` resolves) |
| DM context | `/peasants/{name}/whispers/{id}` |
2026-10-01 19:53:13 +02:00
| Quote permission (FEP-044f `QuoteAuthorization` ) | `/peasants/{name}/parrot-licences/{id}` |
2026-10-01 10:38:35 +02:00
| Token refresh | `/clientapi/user/sniff/again` |
2026-10-01 11:28:37 +02:00
Agreed for later phases: `/gossip` , `/drool` , `/echoes` (replies, likes, shares), `/trophies` (featured), `/tattoos`
(featured tags), `/flock` and `/wardens` (group members and moderators).
2026-10-01 10:38:35 +02:00
Routes other software looks up by name stay conventional:
- `/.well-known/*` and `/nodeinfo/*` ;
- `/api/v1/*` and `/oauth/*` ;
- `/@{name}` pages;
- `/media/*` .
## Repo map
```
PrivaPub.sln
PrivaPub/ ASP.NET Core Web API, net10.0
Program.cs host, Mongo init (GUIDs Standard), pipeline, /build.json
Middleware/SocialPubConfigurations.cs every DI registration (auth, federation, services, swagger, CORS)
Controllers/ClientToServer/ /clientapi/*: RootUser (signup/login/invitations/recovery), PrivateAvatar,
Group, Post (posts + DMs), Admin, Data
2026-10-01 11:04:20 +02:00
Infrastructure/
Http/ FederationHttp + SafeHttpHandlerFactory + IpRangeGuard: the only way out
2026-10-01 11:28:37 +02:00
Jobs/ JobQueue (leases), JobWorker, Backoff, HostCircuitBreaker
2026-10-01 13:19:16 +02:00
Ids/ PrivacyIds (day-only ids for personas and groups, arrival-ordered ids for remote posts)
2026-10-01 11:04:20 +02:00
Data/ Indexes (created at start), EntityMaps.Warm, Migrations/_NNN_*.cs
Cli/ AdminCommands (`PrivaPub admin promote|demote <root>`)
RateLimiting.cs accounts (per client address) and inbox (per sending origin) policies
2026-10-01 10:39:41 +02:00
Federation/
Controllers/ PeasantsController (actor, outbox, followers, following, posts, inboxes),
2026-10-01 10:38:35 +02:00
WellKnownController (webfinger, nodeinfo), UsersController (redirect)
2026-10-01 11:04:20 +02:00
Actors/ LocalActorService (LocalActor, Keys, ReservedName), RemoteActorService
(authoritative fetch, key verification, WebFinger), ActorDocument (parser)
2026-10-01 11:28:37 +02:00
Objects/ Origin, ActivityJson, NoteParser, Addressing, ContentSanitizer
Moderation/ DomainBlocks (suspend / silence / reject media)
2026-10-01 10:39:41 +02:00
Signing/ HttpSignatures (draft-cavage sign/verify)
2026-10-01 11:40:02 +02:00
Inbox/ InboxReceiver (verify, queue, 202) → InboxProcessor (job) → Handlers/{Follow,Accept,Reject,
Undo,Create,Update,Delete,Like,Announce}; RemotePosts (build, fetch parents, FetchAncestors)
Outbox/ OutboxPublisher (who a post goes to), DeliveryService (queues jobs) + DeliveryJobHandler
2026-10-01 11:28:37 +02:00
Rendering/ ActivityPubRenderer (Mastodon @context, actors, notes, collections)
Domain/
Content/ ContentRenderer (Markdown or plain text → HTML with h-card mentions and hashtags)
2026-10-01 11:40:02 +02:00
Social/ FollowService (local in-process, remote Follow/Accept), Notifications
Timelines/ Fanout (TimelineEntry rows, Mastodon's home rules), TimelineService
2026-10-01 11:28:37 +02:00
Privacy/ VisibilityPolicy (IsPublic expression, CanSee)
2026-10-01 12:22:08 +02:00
Relationships/ RelationshipService (blocks, mutes, account domain blocks; Hidden), ReportService
Media/ MediaService (libvips, ffmpeg remux, blurhash), MediaProxy, MediaJanitor
2026-10-01 12:09:19 +02:00
Domain/Statuses/ StatusService: publish, edit, remove, favourite, reblog, for a persona (both client APIs use it)
Api/Mastodon/
Auth/ OpenIddict setup (keys in Mongo), MastodonScopes, TokenController, OAuthPruner
Infrastructure/ MastodonController (avatar context, scopes, errors, Link), MastodonParams, MastodonJson, Page
Entities/ Mappers/ Mastodon entities; MastodonMapper (Account, Status), AccountSearch
Controllers/ apps, instance, accounts, statuses, timelines, notifications, search, stubs
Web/Pages/ Razor: /@{user}, /@{user}/{id} (public posts only, strict CSP, noindex);
OAuth/: /oauth/login (root password), /oauth/authorize (choose persona, consent)
2026-10-01 10:38:35 +02:00
Services/ RootUsersService, GroupUsersService, PostsService, AppConfigurationService, …
2026-10-01 11:28:37 +02:00
Models/ Mongo entities: User/, Group/, Post/, Federation/, Jobs/, AppConfiguration
2026-10-01 10:38:35 +02:00
StaticServices/ DbEntities (Find<T> accessors), AuthTokenManager (JWT), PasswordHasher
Data/InitDb.cs first-run seeding (languages)
PrivaPub.ClientModels/ DTOs + validation resources shared with clients
2026-10-01 11:04:20 +02:00
PrivaPub.Tests/ xUnit v3; Support/ has a fake two-origin peer and a throwaway-database fixture
2026-10-01 10:38:35 +02:00
deploy/ nginx vhost, systemd units (privapub, privapub-mongod), max/setup.sh
.gitea/workflows/ build.yml (push) · deploy.yml (tag v*)
docs/ROADMAP.md decisions + phased plan
```
The roadmap moves code towards `Infrastructure/` , `Federation/` , `Domain/` , `Api/{ClientApi,Mastodon}` and `Web/` ,
one pure-move commit at a time.
## Commands
```bash
dotnet build PrivaPub.sln -c Release
2026-10-01 11:04:20 +02:00
dotnet test PrivaPub.sln # unit tests; integration tests skip
PRIVAPUB_TEST_MONGOD = 1 dotnet test PrivaPub.sln # all of them, against mongod on 127.0.0.1:27017
# (PRIVAPUB_TEST_MONGO overrides; a fresh database per run, dropped after)
2026-10-03 10:37:42 +02:00
tools/ci/with-test-mongod.sh dotnet test PrivaPub.sln # all of them, on a throwaway mongod, as CI runs them
2026-10-01 10:38:35 +02:00
cd PrivaPub && ASPNETCORE_ENVIRONMENT = Development \
Kestrel__Endpoints__Http__Url = http://127.0.0.1:6970 Kestrel__Endpoints__Http__Protocols = Http1AndHttp2 \
AppConfiguration__BackendBaseAddress = http://127.0.0.1:6970 MongoSettings__Database = PrivaPubTest \
dotnet run # needs a local mongod on 27017; swagger at /swagger
```
Development config (`appsettings.Development.json` ) binds HTTPS 7195 with HTTP/2 only; the overrides above make it
2026-10-01 11:04:20 +02:00
curl-able. Outbound fetches only go to https DNS names resolving to public addresses; a test network (Pasture) sets
2026-10-01 13:19:16 +02:00
`Federation__AllowPrivateNetworks=true` , `Federation__AllowPlainHttp=true` and `Federation__AcceptAnyCertificate=true` ,
which startup refuses in Production.
2026-10-01 11:04:20 +02:00
2026-10-04 02:37:38 +02:00
The admin CLI runs with every service built but nothing started (no Kestrel, no hosted services), so it can run next to
the live service. Sign-up is closed in production (invitations only), so the first login is made here; promoting is how
an admin is made (signing up as "admin" grants nothing):
2026-10-01 11:04:20 +02:00
```bash
2026-10-04 02:37:38 +02:00
cd /var/www/privapub.thepra.dev
echo '<password>' | ASPNETCORE_ENVIRONMENT = Production ./PrivaPub admin create-root <login> --admin # password on stdin
ASPNETCORE_ENVIRONMENT = Production ./PrivaPub admin promote| demote <root>
ASPNETCORE_ENVIRONMENT = Production ./PrivaPub admin smoke <persona> # the deploy's: prints "deploy-smoke <new password>"
2026-10-01 11:04:20 +02:00
```
2026-10-01 10:38:35 +02:00
2026-10-04 02:37:38 +02:00
The deploy runs them as `build-runner` , which owns the published files, reads `appsettings.Production.json` through
group www-data and reaches the private mongod; `sudo -u www-data` works too.
2026-10-01 10:38:35 +02:00
## Federation invariants
2026-10-01 11:04:20 +02:00
1. **Every outbound request goes through `IFederationHttp`.** Its handler resolves the name itself and connects only
to public addresses; redirects are followed by hand (three at most, each re-checked); bodies are capped at 1 MB;
only JSON media types are read; a refused URL is not asked again for five minutes. Never create another
2026-10-04 02:37:38 +02:00
`HttpClient` for federation. The only other outbound traffic is SMTP and `GeoUpdater` 's monthly DB-IP Lite download
(its own `geo` client, a fixed HTTPS host, size-capped, the file checked before it is swapped in).
2026-10-01 11:04:20 +02:00
2. **Every fetch is signed by the instance actor** (`privapub` ), never by a persona; deliveries are signed by the acting
avatar or group. Both are draft-cavage rsa-sha256 over `(request-target) host date` (+ `digest` on bodies).
3. **A remote document is believed only from its own address.** `RemoteActorService.FetchObject` requires the
document's `id` to be the URL it was served from (a same-origin alias is followed once). A key is accepted only if
the actor lists it, its `owner` is the actor and it shares the actor's origin.
4. **Inbound inboxes verify everything before acting:**
- the signature covers `(request-target)` , `host` , `digest` and `date` or `(created)` ;
- the Digest matches the body and the date is at most an hour old and fifteen minutes ahead;
- the signature verifies against the key owner's key, and the activity's `actor` is the key owner;
- the activity's `id` , and any object it creates, updates or deletes, is on the actor's origin; a cross-origin
object is refetched from its own origin.
5. **Status codes:**
2026-10-01 10:38:35 +02:00
- bad or missing signature: **401** ;
2026-10-01 11:04:20 +02:00
- malformed or forged body: **400** ;
2026-10-01 10:38:35 +02:00
- accepted: **202** ;
2026-10-01 11:04:20 +02:00
- over the rate limit: **429** ;
2026-10-01 10:38:35 +02:00
- **never 500** from `/peasants` or an inbox. Peers retry or "double-knock" based on these codes.
2026-10-01 11:04:20 +02:00
6. **Actor documents:**
2026-10-01 10:38:35 +02:00
- served as `application/activity+json` ;
- `publicKey` is an SPKI PEM at `{actor}#main-key` , with `owner` equal to the actor's id;
- the shared inbox is advertised in `endpoints.sharedInbox` .
2026-10-01 11:04:20 +02:00
7. **Remote HTML is sanitized before it is stored** (`ContentSanitizer` ); `Post.ContentHtml` is what is shown,
`ContentFormat` says what `Text` holds. Remote names are plain text.
2026-10-01 12:30:57 +02:00
8. **Circles federate to members only.** A circle is an undiscoverable Group actor that takes follow requests (the owner
approves); its posts are addressed to the circle and its `/flock` , delivered to members' personal inboxes, never
announced, and served only to a signed request from a member or a member's instance actor
(`SignedFetchAuthorizer` ), 404 otherwise. Circles never appear in search, lookups, mentions or profile pages.
**Communities** are FEP-1b12 groups: `GroupDistributor` announces the whole activity (plus the object for new posts,
for Mastodon), top-level posts are `Page` s with a `name` , posting follows `Group.PostingPolicy` .
Located posts (`LocalGeo` ) are the only local-only posts.
2026-10-01 11:04:20 +02:00
9. **A DM joins a conversation only by `DmGroup.ParticipantsKey`** , the exact set of its participants; a remote
2026-10-01 11:28:37 +02:00
`context` decides nothing. DMs are `Post` s with `Visibility = Direct` and a `ConversationId` (`DmPost` is legacy).
10. **Nothing slow happens inside a request.** Deliveries and inbox processing are `Job` s (`Infrastructure/Jobs` ):
leased, retried on Mastodon's curve, at most two per host, paused per host by `RemoteInstance` . The inbox answers
202 once it has verified and queued; a handler must be idempotent (unique `ObjectURI` , job `DedupeKey` ).
2026-10-03 12:30:05 +02:00
11. **Every "may anyone see this" goes through `VisibilityPolicy.IsPublic`;** a persona-specific read uses `CanSee` , and
any other read of stored posts filters by `IsShown` . All three hide deleted posts and the posts of a remote account
that deleted itself (`Post.AuthorGone` : kept, hidden everywhere, owner decision).
2026-10-01 11:40:02 +02:00
12. **Remote content is stored only when someone here asked for it:** a persona follows the author, is addressed or
mentioned, it replies to a local post, or it is addressed to a community the author follows; a public parent is
fetched as context. Followers-only is detected by the author's stored `followers` URL.
13. **Home timelines are written, not computed:** every stored or created post goes through `Fanout.Distribute` , and
every delete removes its `TimelineEntry` rows. Local deletes are soft (content cleared, 410 Tombstone).
2026-10-01 10:38:35 +02:00
2026-10-01 12:09:19 +02:00
## Mastodon client API invariants
1. **A token is one persona.** Its subject is the avatar id; the root id lives only in the fifteen-minute `/oauth`
cookie used while choosing the persona, and never in a token, an authorization or a response.
2. `/api/*` authenticates with OpenIddict validation, everything else with the old JWT (`PrivaPub` policy scheme).
Every `/api` request re-checks that the persona's root is neither banned nor deleted (`MastodonController` ).
3. Read parameters through `Params` (query, form and JSON merged Rails-style), never MVC binding. A value type read
from a conditional must say `(int?)null` , not `default` : that bug once made every list one item long.
4. Answer with `Json(...)` (snake_case, explicit nulls) or `Error(status, message)` ; page lists with `Page` and `Link` .
2026-10-03 10:46:20 +02:00
5. Unsupported features answer empty lists or 422 with a message, never 404 or 500, so clients degrade. Any `/api`
error that leaves without a body (a challenge, a 404 from routing, a 429) gets Mastodon's `{"error": ...}` from
`UseMastodonErrorBodies` . `NeverFiveHundredTests` walks every route with junk ids, anonymously, as a persona and
with a junk token.
2026-10-01 12:09:19 +02:00
6. Advertise `4.2.0 (compatible; PrivaPub)` until grouped notifications exist.
2026-10-01 17:58:22 +02:00
7. **What a Mastodon `Status` cannot say goes in `Status.privapub`** (`PrivaPubStatus` ): object type, title, excerpt,
cover, the author's source, link, video, audio and event details, and up/down votes. 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.
2026-10-01 12:09:19 +02:00
2026-10-01 12:22:08 +02:00
## Media invariants
1. **No upload keeps its metadata.** Images are re-encoded by libvips with `keep=none` ; audio and video are remuxed with
`-map_metadata -1` . `MediaProcessingTests` checks EXIF and XMP are gone.
2. Files live under `Media:Root` (`/var/lib/privapub/media` ), never in the published directory; the proxy cache is the
sibling `media-proxy` , which `/media/files` does not serve.
3. **A client never contacts a remote server for media:** every remote URL the API returns goes through
`IMediaProxy.Wrap` , an HMAC-signed `/media/proxy/` URL fetched by `IFederationHttp.GetMedia` .
2026-10-01 18:59:14 +02:00
4. **The proxy serves three ways:**
- **Cached:** a file already cached is served from disk, ranges included.
- **Downloaded:** a request without a `Range` is downloaded whole, up to `Media:MaxProxiedBytes` , then cached.
- **Streamed:** a ranged request, or anything too big to cache, is streamed from the origin with the range passed on,
and never cached. That is how remote video plays.
nginx has a `/media/proxy/` location with `proxy_buffering off` and a 600 s read timeout for those streams.
5. **Remote video and audio become one playable attachment** in the Mastodon API (`MastodonMapper.Playable` ): the best MP4
up to 720p that carries both sound and picture, including PeerTube's fragmented files inside an HLS entry. HLS
playlists themselves are not rewritten.
2026-10-01 12:22:08 +02:00
2026-10-01 10:38:35 +02:00
## Privacy invariants
2026-10-01 11:04:20 +02:00
- **No root id in federation output, NodeInfo or logs, no IP next to an identity in logs, and no `ex.Message` to a
client** ("Something went wrong." instead).
- **Never derive a public name from the root username.** Invitation sign-up takes `AvatarUserName` and refuses one
equal to the login.
- **One username space:** personas, groups and the instance reserve their name in `ReservedName` (unique index) before
they are saved; `LocalActorService.TryReserveUserName` is the only way to claim one.
2026-10-01 17:39:58 +02:00
- **Usernames are `Constants.UserNameRegex` ** (`^[a-z0-9_]+$` ): the intersection of what Mastodon and Misskey accept. A
name outside it creates a persona nobody on those servers can reach.
- **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` .
2026-10-01 10:38:35 +02:00
- **Per-avatar state stays per avatar:** blocks, mutes, notifications, follows. Nothing may relate sibling avatars.
2026-10-01 17:32:59 +02:00
- **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 instance actor, never from the reporting avatar.
- **What PrivaPub reveals is the owner's call.** Previews, blocks, website authorship, views, bridging and reactions were
decided in `docs/ROADMAP.md` ("Owner decisions on what PrivaPub reveals"). Anything new that tells another server
something about an avatar gets the same treatment: ask, then record it there.
2026-10-01 10:38:35 +02:00
- **Location-ranged posts never federate.**
2026-10-03 10:56:13 +02:00
- **Statistics name servers, never people** (owner decisions on statistics, 2026-10-03). Every interaction is recorded
through `IInteractionLedger` (`Infrastructure/Statistics` ) as an `InteractionEvent` (90 days), but an event never
holds a root, persona or group id, an activity id, an inbox URL, a remote actor URI or a sender IP:
- Distinct remote accounts are counted with `ActorHash` , an HMAC keyed by that day's `InteractionSalt` , which the
day's rollup deletes.
- `LocalKind` survives `Interactions.Sanitize` only on public and unlisted traffic, so a circle can show neither by
presence nor by absence, and no reason code names one.
- Traffic a reader causes (the media proxy, lookups, the client API, fetches of our own documents) is only counted
per day (`Count` , `CountServer` ), never logged per event.
- A host claimed by an unverified sender is kept only if it is already a `RemoteInstance` .
- `Record` never blocks and never throws: it writes to a bounded channel, and a full channel drops and counts.
2026-10-01 10:38:35 +02:00
## Data
- MongoDB through **MongoDB.Entities 25.1** , instance API: `DB.Default.Find<T>()` , `.SaveAsync(e)` , `.Update<T>()` ,
`.DeleteAsync<T>()` , `.CountAsync<T>()` . `DbEntities` wraps the finds.
- **Pass no cancellation token to `DeleteAsync` outside a transaction;** v25 throws if you do.
- **Collection name = class name, so never rename an entity class.**
- `Entity.ID` is a 24-character lowercase hex string; `GenerateNewID()` returns `object` , so cast it.
- New fields must be additive: a deploy rollback restores the binary, not the database.
2026-10-01 17:49:22 +02:00
- **Every stored remote object has an `ObjectRecord` ** (raw JSON up to 256 KB plus its hash; delivered or fetched; the
activity, inbox, key and signed headers; received time; up to 10 later revisions). Write it right after the post's
save: `CreateHandler` (delivered) and `RemotePosts.StoreContext` (fetched) do, and `UpdateHandler` appends a
revision. Delivery details reach them through `Arrival.Current` , which `InboxProcessor` sets for the handler's
2026-10-03 11:15:21 +02:00
duration. A fetched record has no signature, key or `@context` of the activity that caused the fetch (its activity
fields name that trigger), and every record stores its `Extensions` and `ContextNamespaces` for statistics. The raw form lives outside `Post` so timelines never load it. Read through
2026-10-01 17:49:22 +02:00
`/api/privapub/v1/statuses/:id/provenance` and `/api/privapub/v1/instances/:host` .
2026-10-01 17:53:59 +02:00
- **Remote objects are parsed for every shape in `ObjectShapes` :** `url` /`icon` /`image` as a value, an object or an
array; Markdown `content` ; missing `mediaType` s inferred; thumbnails from any of their five places; and the typed
`Link` , `Video` , `Audio` and `Event` details. A Mastodon API card is built from those, never by fetching the linked page
(that fetch is the owner's decision 1, for P6).
2026-10-03 12:54:01 +02:00
- **An `Update` is an edit only when its `updated` is newer than ours**, or for a first edit no older and with the text
actually changed, since GoToSocial's whole-second timestamps make a quick edit carry `updated == published` (`RemoteEdits.IsEdit` ; plain and community-wrapped
2026-10-03 11:15:21 +02:00
Updates both go through `RemoteEdits.Apply` , and deletes through `RemoteDeletes.Remove` ). Otherwise it refreshes
2026-10-01 18:35:20 +02:00
the poll, video, audio and event details and nothing else, and leaves no revision: Mastodon and Misskey refresh poll
counts with bare Updates.
- **A poll vote is a `Note` with a `name` , an `inReplyTo` that is a poll we hold, and no content.** `CreateHandler`
hands it to `PollService.Receive` before anything else, so it never becomes a reply. Our votes on other servers' polls
2026-10-03 15:24:08 +02:00
go only to the poll's author, without `published` (PieFed counts a vote only then). A `closed` in the future is the
poll's end, not its closing: Akkoma sends an open poll's end only there (`ObjectShapes` ).
2026-10-01 18:43:39 +02:00
- **Link previews follow owner decision 1** (`Domain/Content/LinkPreviews.cs` ): only public posts, queued on arrival with
0– 60 s of jitter, one cached `LinkPreview` per address for the whole server, never fetched when someone reads.
`LinkPreviews.Wanted` is called where posts are saved (`CreateHandler` , `StoreContext` , `StatusService.Publish` ).
2026-10-01 18:53:28 +02:00
- **Quote states come from `QuoteService.Resolve` :**
- with a stamp: accepted only if `Verified` (fetched, on the quoted author's origin, naming both posts exactly);
- a FEP-044f `quote` without a stamp: pending;
- older keys only: accepted when the quoted post is public.
`QuotesCount` moves with the accepted state, never with the raw key. When a persona quotes, `QuotePermission` decides:
asking first for posts that state a policy, quoting at once for posts that state none, refusing otherwise. Only `quote`
of a post that asks for consent puts `quote` in our JSON; older-key quotes leave it out, as Sharkey learned they must.
2026-10-01 19:53:13 +02:00
- **Our quote policy is `ActivityPubRenderer.QuotableBy` :** the post's `LocalQuotePolicy` , falling back to the persona's
`Settings.QuotePolicy` (default `public` , owner decision), and always `nobody` for anything but public and unlisted. The
same rule writes `canQuote` , answers `QuoteRequest` s (`QuoteService.ReceiveRequest` ) and fills `quote_approval` , so they
cannot disagree. A persona quoting another persona gets a parrot-licence too, so other servers see an approved quote.
2026-10-01 17:49:22 +02:00
- **A server is described on arrival, never on read.** The first record from a host enqueues `DescribeInstance` (its
NodeInfo, at most once a week, into `RemoteInstance` ), so opening the details view tells nobody anything.
2026-10-01 13:19:16 +02:00
- **Post ids are the timeline order, so they follow arrival, not `published` .** `PrivacyIds.Arrived` gives a remote post
published within the last hour (or in the future) a fresh `ObjectId` , which sorts after every post already stored,
and only backfill keeps a `published` -derived id. With `published` ids a reply arriving in the same second could sort
under the post it answers, and a late arrival landed behind a client's `since_id` and was never seen. `created_at`
comes from `CreationDate` , never from the id.
- **The same ordering problem exists on the other side, so `published` carries milliseconds**
(`ActivityPubRenderer.Timestamp` ). GoToSocial (ULIDs) and Mastodon (Snowflakes) derive a remote status's id from
`published` at millisecond resolution. With whole seconds, two of our posts from the same second sorted at random
there.
2026-10-01 11:04:20 +02:00
- **Startup order:** `EntityMaps.Warm()` (every entity's class map, one at a time; two mapped at once throw "An item
with the same key has already been added" and stay broken), then `MigrateAsync` (`Infrastructure/Data/Migrations` ,
`_NNN_` order, each runs once), then `Indexes.Create()` . A new entity needs nothing; a new unique index needs a
dedupe migration before it.
2026-10-01 10:38:35 +02:00
- Production runs its **own mongod** on 127.0.0.1:27022 (unit `privapub-mongod` , no auth, data in
`/var/lib/privapub/mongo` ). The box's shared mongod needs credentials nobody here has.
## Code style
- Tabs, block-scoped namespaces, Allman braces.
- New services use `readonly _camel` fields and constructor injection. Older services use PascalCase fields; leave
them.
- Services return `WebResult` (`PrivaPub.ClientModels/WebResult.cs` ): `result.Invalidate(localizer[...], status)` .
Controllers turn it into a status code.
- Localised strings go through `IStringLocalizer<GenericRes>` .
- Every `Display` /`ErrorMessage` resource key must exist in `FieldsNameResource` /`ErrorsResource` , including the
`Designer.cs` , which the CLI build doesn't regenerate. A missing key throws at validation time.
2026-10-01 13:19:16 +02:00
- **`cond ? value : default` with a value-type branch is the type's default, not null:** `false` , `0` , or year one
stored as an edit date on every remote post. Write `(T?)null` . It has shipped four times (`MastodonParams.Bool/Int` ,
`NoteParser.Int` , `NoteParser.Time` ).
2026-10-01 10:38:35 +02:00
- ActivityPub output is built with `System.Text.Json.Nodes` in `ActivityPubRenderer` , not typed models. Inbound
2026-10-03 10:36:15 +02:00
documents are read through `ActivityJson.Id` /`Value` , which handle string, object and array.
2026-10-01 10:38:35 +02:00
- Libraries chosen for the roadmap: HtmlSanitizer, Markdig (`DisableHtml` ), NSign (RFC 9421 inbound), OpenIddict +
OpenIddict.MongoDb, NetVips, Blurhash.Core, FFMpegCore. No ImageSharp (licence key enforced), no MassTransit.
## Testing
2026-10-01 11:04:20 +02:00
`PrivaPub.Tests` (xUnit v3). Unit tests need nothing; tests marked `Category=Integration` need a mongod and skip
2026-10-03 10:37:42 +02:00
without `PRIVAPUB_TEST_MONGOD=1` . CI (`build.yml` and `deploy.yml` ) runs all of them through
`tools/ci/with-test-mongod.sh` , which starts a throwaway mongod on a random localhost port and deletes it afterwards.
The box's own mongods are production, so `MongoFixture` refuses port 27022, a data directory under `/var/lib/privapub` ,
and in CI anything but the wrapper's mongod. With `PRIVAPUB_TEST_REQUIRE_MONGOD=1` , which the wrapper sets, a missing
mongod fails the run instead of silently skipping half the tests.
2026-10-01 11:04:20 +02:00
- `Support/Peer` is an in-process HTTP server answering on two origins (`127.0.0.1` and `localhost` ), so origin rules
can be tested; `Support/RemoteActor` signs real deliveries with its own key.
2026-10-03 10:36:15 +02:00
- Inbox scenarios go through `InboxReceiver.Receive` with a signed request, not through the private handlers.
2026-10-03 10:43:03 +02:00
- `Support/Host/PrivaPubHost` is the whole server under test (`WebApplicationFactory<Program>` , environment `Testing` ,
configured only through `UseSetting` , on the fixture's database). `PrivaPubHost.Shared()` boots it once per run;
`SecureModeHost` is the same with `Federation:SecureMode` . Background workers are removed, so a test runs the jobs it
queued with `host.Run(j => ...)` or `host.RunInbox(activityId)` . `Accounts` signs a root up, adds personas, and gets a
Mastodon token through the real `/oauth` code flow; `RemoteActor.SignedPost` /`SignedGet` sign `HttpRequestMessage` s
for the real `/peasants` routes. Each client gets its own `X-Test-Client` address, so rate limits don't collide.
2026-10-03 10:36:15 +02:00
- All test classes share one database and run in parallel, so a test touches only rows it made:
- random names and GUIDs;
- `Harness.Outgoing` sees only deliveries queued since that harness started, because Peer ports are reused;
- a worker gets a scoped `new JobQueue(j => ...)` so it never leases another test's jobs;
- domain blocks are set with `DomainBlocks.Load` , never written to the database.
A test that must change something database-wide (drop indexes, run a migration over every post, let deliveries to
`localhost` fail and trip its breaker) goes in `[Xunit.Collection(nameof(Exclusive))]` , which runs alone, and
cleans up after itself. Pure logic belongs in an unconditional unit test, not in a Mongo-gated class.
2026-10-01 10:38:35 +02:00
2026-10-01 11:04:20 +02:00
Beyond the tests, verify by building, running locally, and exercising:
2026-10-01 10:38:35 +02:00
- the client API (sign up, create an avatar, a group, a post);
- the ActivityPub endpoints with curl and `Accept: application/activity+json` .
2026-10-01 13:19:16 +02:00
Interop is checked against real servers, starting with the workstation's own pasture:
```bash
2026-10-03 12:30:05 +02:00
DOTNET = ~/.dotnet/dotnet tools/pasture/run.sh up [ gts mastodon ...] # podman: PrivaPub + the named peers (default gts) + Mongo, behind Caddy
tools/pasture/interop.sh [ gts mastodon ...] # each peer's scenario, then what PrivaPub's statistics saw of it
tools/pasture/run.sh down # removes every pasture container and volume
2026-10-01 13:19:16 +02:00
```
2026-10-03 12:30:05 +02:00
- **Layout:** `lib/pasture.sh` (network, Caddy, Mongo, PrivaPub), `peers/<name>.sh` (`<name>_up` , plus `peers/shared.sh`
for the Postgres and Redis several peers share), `lib/interop.sh` (`ok` , `ko` , `xf` for a check expected to fail until
a later phase, `privapub_token <persona>` , `stats_check <host> <software>` ), `scenarios/<name>.sh` . Each peer talks to
its own PrivaPub persona under one root, so OAuth's persona choice is exercised too. Images are pinned.
- Caddy's CA lives in the `pasture-caddy-data` volume and is copied to `tools/pasture/.ca/root.crt` (and `bundle.pem`
with the system roots) for peers that must trust it instead of skipping verification.
- **All sites on one podman network, one Caddy in front.** `privapub.test` , `gts.test` , `mastodon.test` and the other
peers are network aliases of the Caddy container, which serves them all with its internal CA (`tls internal` ).
PrivaPub accepts any certificate (`appsettings.Pasture.json` ) and GoToSocial is told to skip verification
(`GTS_HTTP_CLIENT_TLS_INSECURE_SKIP_VERIFY` ); Mastodon trusts the copied CA through `SSL_CERT_FILE` . Peers fetch over
https only, so plain http between them is not an option.
- From the workstation, PrivaPub's API is `http://127.0.0.1:6971` . A peer is reached as `https://<name>.test:6443` with
`curl -k --resolve <name>.test:6443:127.0.0.1` , because sign-in cookies are bound to the host name.
- **GoToSocial (0.22.1):**
- Its cached home timeline can stop taking new posts after its first read, its owner's own included, while a
`min_id` query shows them all. So a delivery is checked by looking the object up by URI with `resolve=false`
(`on_gts` ), which answers from GoToSocial's database and never fetches from us. A home-timeline check there proves
nothing, in either direction. A deleted status still turns up in that search as a "deleted status" stub, so a
delete is checked as a 404 on `/api/v1/statuses/{id}` .
- It creates its accounts locked, so the scenario approves alice's request through `/api/v1/follow_requests` , which
also checks our pending (`requested` ) state and the manual Accept.
- 37 checks: discovery and follows both ways; posts and CW; a reply and its notification; likes and boosts both
ways; DMs both ways and off public timelines; polls both ways; quote policy; link cards; edits and deletes both
ways; unfollow; block and unblock; statistics.
- **Mastodon (4.7.3):** web and sidekiq on the shared Postgres and Redis, `ALLOWED_PRIVATE_ADDRESSES` for the network.
Its token comes from `rails runner` (no password grant). Without Elasticsearch its status search finds nothing, so
deliveries are checked through `/api/v1/accounts/:id/statuses` of the sender as Mastodon knows them, or
`Status.exists?` through `rails runner` . Its actors are numbered (`/ap/users/<id>` ), so look URIs up rather than
build them. 49 checks; circle posts are an expected failure (see `docs/INTEROP.md` , Mastodon).
2026-10-03 13:29:17 +02:00
- **Misskey (2026.10.0):** one container on the shared Postgres and Redis. A new Misskey federates with nobody
(`federation: none` ) until `admin/update-meta` says `all` , which `misskey_up` does. Its API is `POST /api/<endpoint>`
with the token as `i` (`mk` in the scenario); `users/relation` answers a list, `users/notes` leaves replies out unless
asked, and a user has one reaction per note. Never verify a delivery with `ap/show` : it fetches. 35 checks.
2026-10-03 15:24:08 +02:00
- **Sharkey (2025.4.7):** `peers/sharkey.sh` is the Misskey peer under another name, image, database, Redis db and
home (`/sharkey/.config` ); `scenarios/sharkey.sh` runs Misskey's scenario with `MISSKEY_NAME=sharkey` , then follows
again (Misskey's ends unfollowed and blocked) and checks edits both ways and the FEP-e232 quote tag. 40 checks.
- **Akkoma (3.20.1):** no official image, so `images/akkoma` installs the OTP release (pinned by checksum; the "stable"
zip moves, and a moved one fails the build) and `akkoma_up` builds it once. Its HTTP clients read the CA bundles
shipped in the release (CAStore, certifi), so the entrypoint appends Caddy's CA there. `pleroma_ctl` passes its
arguments on unquoted: no value may contain a space. Its Linkify never takes `@user@host.test` for a mention, so the
scenario addresses alice with Pleroma's `to[]` ; its API never reports a remote blocker as `blocked_by` , so the block is
read from its `user_relationships` . 44 checks.
2026-10-03 14:24:15 +02:00
- **Lemmy (1.0.0-beta.2):** the backend alone on the shared Postgres, its admin made by `setup` in the generated
`config.hjson` . It trusts Caddy's CA through `SSL_CERT_FILE` and reaches the network through
`DANGER_FEDERATION_ALLOW_LOCAL_IP=1` ; 0.19 cannot join (its rustls trusts only its bundled roots). Its API is
`/api/v4/<path>` with a bearer token (`lm` in the scenario), and `sort` values are lowercase. It logs no refused
activity at `warn` (`LEMMY_LOG` sets `RUST_LOG` ); the reason is in the 400's body. It answers our community's echo of
its own activity and every bare `Announce{object}` 400 by design, and the echo is still needed (see
2026-10-03 15:24:08 +02:00
`docs/INTEROP.md` , Lemmy). A new Lemmy never sends what it queued for a server before its send worker for that
server started, so the scenario waits for that worker (`lm_worker` ) before its first follow. 20 checks; relayed votes and a moderator's removal are expected failures (P7).
2026-10-03 12:30:05 +02:00
- **Crawler:** `PRIVAPUB_ENV="Statistics__Crawler__Enabled=true Statistics__Crawler__Seeds__0=mastodon.test"
run.sh up mastodon`, then ` interop.sh crawler`. ` PRIVAPUB_ENV` passes any setting to the PrivaPub container.
2026-10-01 13:19:16 +02:00
- ` run.sh up` replaces every container, Mongo included, so each run starts clean. To keep the data, republish into
` tools/pasture/.publish` and ` podman restart pasture-privapub`; that is how a migration is tried on dirty data.
After the pasture, verify.funfedi.dev and the owner's GoToSocial at social.arasaka.software. **Ask before acting from
the owner's GoToSocial account.**
2026-10-01 10:38:35 +02:00
## Deploy
2026-10-01 11:04:20 +02:00
- **CI/CD:** push to ` master` runs ` build.yml` (build + tests) on the instance-wide ` build` runner. A ` v*` tag runs
` deploy.yml`: tests, self-contained linux-x64 publish, snapshot and ` mongodump` to ` /var/backups/privapub.thepra.dev`,
stop → rsync → start, a ` 127.0.0.1:6970/build.json` health loop with rollback, then public checks (actor, NodeInfo,
2026-10-01 12:09:19 +02:00
Swagger 404, inbox junk 400, unsigned 401) and ` tools/smoke/mastodon-api.sh` (app registration, client credentials,
2026-10-04 02:37:38 +02:00
discovery, instance, public timeline). Then it checks that what production should be running is running:
- NodeInfo and the instance API agree on registrations (closed, invitations only);
- ` /stargazing` says the crawler is on;
- **@thepra signs in**: ` PrivaPub admin smoke thepra` gives the root ` deploy-smoke` a new password, ` tools/smoke/oauth.sh`
runs the real OAuth flow (the pasture's ` privapub_token` uses the same script), the signed-in API is checked, the
persona must be undiscoverable, and the token is revoked;
- the DB-IP Lite databases, which the server fetches itself (` GeoUpdater`), exist and are at most 40 days old.
2026-10-01 10:38:35 +02:00
- **The box:** Max (` nuvola.xyz`). Unit ` privapub` runs as www-data from ` /var/www/privapub.thepra.dev` with
` ASPNETCORE_ENVIRONMENT=Production`.
2026-10-04 02:37:38 +02:00
- **One-time root setup:** ` deploy/max/setup.sh`, run through ` ../arasaka.software/tools/max/run.sh` (directories, the
runner's sudoers line, the units, nginx and the certificate). Nothing that runs later needs it again.
2026-10-01 10:38:35 +02:00
- **Config:** ` appsettings.Production.json` is committed and deployed, **secrets included, by the owner's convention**
(the same as arasaka.software). A hand edit on the box is lost at the next deploy.
- **Secrets drift:** ` AppConfigurationService` copies ` AppConfiguration` into Mongo on first boot and reads the stored
copy afterwards, so changing those keys needs a Mongo update as well.
- **Logs:** ` journalctl -u privapub`, plus the ` logs` database on the private mongod.