Communities: - a post addressed to a community (to, cc or audience) is accepted according to its posting policy - followers, anyone, or moderators - and GroupDistributor announces the whole activity with `audience` to the community's followers, plus the object for new posts so Mastodon shows them; updates and deletes of community content are announced too; - top-level posts are Pages with a name (the title, or a headline from the text); /flock counts members, /wardens lists moderators; - a Mastodon client posts into a community by mentioning it, or into a remote group, which sets `audience`; - an Announce of an activity from a remote group a persona follows (Lemmy) is followed through: the object is fetched from its own origin, kept with its AudienceURI, and fanned out to the group's local followers; updates are applied in place and deletes checked against the origin. Circles stop being local-only: an undiscoverable Group actor whose follows are all requests the owner approves; posts addressed to the circle and its /flock and delivered to members' own inboxes, never announced, never public; a remote member's post into the circle is accepted from members only. SignedFetchAuthorizer serves circle posts and collections only to a signed request from a member or a member server's instance actor - 404 for anyone else. Circles never surface in search, lookups, mentions, account ids or profile pages. Federation:SecureMode requires a valid signature on every GET under /peasants except the instance actor. Group forms take a posting policy. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
20 KiB
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.
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.
RootUseris the private login. Username, password, optional email (used only for recovery), policies.Avataris a public persona. Each one is an ActivityPub actor with its own RSA keys at/peasants/{username}. Root and avatar are linked only throughRootToAvatar.- Personas must stay unlinkable to other users and servers; only the instance admin can see the link.
Groupis 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.DmGroupis a direct-message conversation.privapubis 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 |
|---|---|
| Actor | /peasants/{name} (/users/{name} 301-redirects here; browsers are sent to /@{name}) |
| Inbox | /peasants/{name}/mouth |
| Outbox | /peasants/{name}/anus (?page=true[&max_id=] for pages) |
| Shared inbox | /human-centipede (also /peasants/{name}/human-centipede) |
| 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} |
| Token refresh | /clientapi/user/sniff/again |
Agreed for later phases: /gossip, /drool, /echoes (replies, likes, shares), /trophies (featured), /tattoos
(featured tags), /flock and /wardens (group members and moderators).
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
Infrastructure/
Http/ FederationHttp + SafeHttpHandlerFactory + IpRangeGuard: the only way out
Jobs/ JobQueue (leases), JobWorker, Backoff, HostCircuitBreaker
Ids/ PrivacyIds (day-only ids for personas and groups, published-time ids for remote posts)
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
Federation/
Controllers/ PeasantsController (actor, outbox, followers, following, posts, inboxes),
WellKnownController (webfinger, nodeinfo), UsersController (redirect)
Actors/ LocalActorService (LocalActor, Keys, ReservedName), RemoteActorService
(authoritative fetch, key verification, WebFinger), ActorDocument (parser)
Objects/ Origin, ActivityJson, NoteParser, Addressing, ContentSanitizer
Moderation/ DomainBlocks (suspend / silence / reject media)
Signing/ HttpSignatures (draft-cavage sign/verify)
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
Rendering/ ActivityPubRenderer (Mastodon @context, actors, notes, collections)
Domain/
Content/ ContentRenderer (Markdown or plain text → HTML with h-card mentions and hashtags)
Social/ FollowService (local in-process, remote Follow/Accept), Notifications
Timelines/ Fanout (TimelineEntry rows, Mastodon's home rules), TimelineService
Privacy/ VisibilityPolicy (IsPublic expression, CanSee)
Relationships/ RelationshipService (blocks, mutes, account domain blocks; Hidden), ReportService
Media/ MediaService (libvips, ffmpeg remux, blurhash), MediaProxy, MediaJanitor
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)
Services/ RootUsersService, GroupUsersService, PostsService, AppConfigurationService, …
Models/ Mongo entities: User/, Group/, Post/, Federation/, Jobs/, AppConfiguration
StaticServices/ DbEntities (Find<T> accessors), AuthTokenManager (JWT), PasswordHasher
Data/InitDb.cs first-run seeding (languages)
PrivaPub.ClientModels/ DTOs + validation resources shared with clients
PrivaPub.Tests/ xUnit v3; Support/ has a fake two-origin peer and a throwaway-database fixture
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
dotnet build PrivaPub.sln -c Release
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)
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
curl-able. Outbound fetches only go to https DNS names resolving to public addresses; a test network (Pasture) sets
Federation__AllowPrivateNetworks=true and Federation__AllowPlainHttp=true, which startup refuses in Production.
Promoting an admin on the box (signing up as "admin" grants nothing):
cd /var/www/privapub.thepra.dev && sudo -u www-data ASPNETCORE_ENVIRONMENT=Production ./PrivaPub admin promote <root>
Federation invariants
- 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 anotherHttpClientfor federation. - 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(+digeston bodies). - A remote document is believed only from its own address.
RemoteActorService.FetchObjectrequires the document'sidto be the URL it was served from (a same-origin alias is followed once). A key is accepted only if the actor lists it, itsowneris the actor and it shares the actor's origin. - Inbound inboxes verify everything before acting:
- the signature covers
(request-target),host,digestanddateor(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
actoris 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.
- the signature covers
- Status codes:
- bad or missing signature: 401;
- malformed or forged body: 400;
- accepted: 202;
- over the rate limit: 429;
- never 500 from
/peasantsor an inbox. Peers retry or "double-knock" based on these codes.
- Actor documents:
- served as
application/activity+json; publicKeyis an SPKI PEM at{actor}#main-key, withownerequal to the actor's id;- the shared inbox is advertised in
endpoints.sharedInbox.
- served as
- Remote HTML is sanitized before it is stored (
ContentSanitizer);Post.ContentHtmlis what is shown,ContentFormatsays whatTextholds. Remote names are plain text. - 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:GroupDistributorannounces the whole activity (plus the object for new posts, for Mastodon), top-level posts arePages with aname, posting followsGroup.PostingPolicy. Located posts (LocalGeo) are the only local-only posts. - A DM joins a conversation only by
DmGroup.ParticipantsKey, the exact set of its participants; a remotecontextdecides nothing. DMs arePosts withVisibility = Directand aConversationId(DmPostis legacy). - Nothing slow happens inside a request. Deliveries and inbox processing are
Jobs (Infrastructure/Jobs): leased, retried on Mastodon's curve, at most two per host, paused per host byRemoteInstance. The inbox answers 202 once it has verified and queued; a handler must be idempotent (uniqueObjectURI, jobDedupeKey). - Every "may anyone see this" goes through
VisibilityPolicy.IsPublic; a persona-specific read usesCanSee. - 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
followersURL. - Home timelines are written, not computed: every stored or created post goes through
Fanout.Distribute, and every delete removes itsTimelineEntryrows. Local deletes are soft (content cleared, 410 Tombstone).
Mastodon client API invariants
- A token is one persona. Its subject is the avatar id; the root id lives only in the fifteen-minute
/oauthcookie used while choosing the persona, and never in a token, an authorization or a response. /api/*authenticates with OpenIddict validation, everything else with the old JWT (PrivaPubpolicy scheme). Every/apirequest re-checks that the persona's root is neither banned nor deleted (MastodonController).- 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, notdefault: that bug once made every list one item long. - Answer with
Json(...)(snake_case, explicit nulls) orError(status, message); page lists withPageandLink. - Unsupported features answer empty lists or 422 with a message, never 404 or 500, so clients degrade.
- Advertise
4.2.0 (compatible; PrivaPub)until grouped notifications exist.
Media invariants
- No upload keeps its metadata. Images are re-encoded by libvips with
keep=none; audio and video are remuxed with-map_metadata -1.MediaProcessingTestschecks EXIF and XMP are gone. - Files live under
Media:Root(/var/lib/privapub/media), never in the published directory; the proxy cache is the siblingmedia-proxy, which/media/filesdoes not serve. - 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 byIFederationHttp.GetMedia.
Privacy invariants
- No root id in federation output, NodeInfo or logs, no IP next to an identity in logs, and no
ex.Messageto a client ("Something went wrong." instead). - Never derive a public name from the root username. Invitation sign-up takes
AvatarUserNameand 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.TryReserveUserNameis the only way to claim one. - Per-avatar state stays per avatar: blocks, mutes, notifications, follows. Nothing may relate sibling avatars.
- Blocks never federate, and reports leave as
Flagfrom the instance actor. - Location-ranged posts never federate.
Data
- MongoDB through MongoDB.Entities 25.1, instance API:
DB.Default.Find<T>(),.SaveAsync(e),.Update<T>(),.DeleteAsync<T>(),.CountAsync<T>().DbEntitieswraps the finds. - Pass no cancellation token to
DeleteAsyncoutside a transaction; v25 throws if you do. - Collection name = class name, so never rename an entity class.
Entity.IDis a 24-character lowercase hex string;GenerateNewID()returnsobject, so cast it.- New fields must be additive: a deploy rollback restores the binary, not the database.
- 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), thenMigrateAsync(Infrastructure/Data/Migrations,_NNN_order, each runs once), thenIndexes.Create(). A new entity needs nothing; a new unique index needs a dedupe migration before it. - 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 _camelfields 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/ErrorMessageresource key must exist inFieldsNameResource/ErrorsResource, including theDesigner.cs, which the CLI build doesn't regenerate. A missing key throws at validation time. - ActivityPub output is built with
System.Text.Json.NodesinActivityPubRenderer, not typed models. Inbound documents are read throughInboxService.Id/Value, which handle string, object and array. - 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
PrivaPub.Tests (xUnit v3). Unit tests need nothing; tests marked Category=Integration need a mongod and skip
without PRIVAPUB_TEST_MONGOD=1. CI runs the unit tests only (the box's mongods are production).
Support/Peeris an in-process HTTP server answering on two origins (127.0.0.1andlocalhost), so origin rules can be tested;Support/RemoteActorsigns real deliveries with its own key.- Inbox scenarios go through
InboxService.Receivewith a signed request, not through the private handlers.
Beyond the tests, verify by building, running locally, and exercising:
- the client API (sign up, create an avatar, a group, a post);
- the ActivityPub endpoints with curl and
Accept: application/activity+json.
Interop is checked against real servers: Fediverse Pasture with podman on the workstation, verify.funfedi.dev, and the owner's GoToSocial at social.arasaka.software. Ask before acting from the owner's GoToSocial account.
Deploy
- CI/CD: push to
masterrunsbuild.yml(build + tests) on the instance-widebuildrunner. Av*tag runsdeploy.yml: tests, self-contained linux-x64 publish, snapshot andmongodumpto/var/backups/privapub.thepra.dev, stop → rsync → start, a127.0.0.1:6970/build.jsonhealth loop with rollback, then public checks (actor, NodeInfo, Swagger 404, inbox junk 400, unsigned 401) andtools/smoke/mastodon-api.sh(app registration, client credentials, discovery, instance, public timeline; pass a persona token as a second argument to check the signed-in side). - The box: Max (
nuvola.xyz). Unitprivapubruns as www-data from/var/www/privapub.thepra.devwithASPNETCORE_ENVIRONMENT=Production. - One-time root setup:
deploy/max/setup.sh, run through../arasaka.software/tools/max/run.sh. - Config:
appsettings.Production.jsonis 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:
AppConfigurationServicecopiesAppConfigurationinto 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 thelogsdatabase on the private mongod.