Files
SocialPub/CLAUDE.md
T
thepraandClaude Opus 5.5 fc111d8c46
Build / Build (push) Successful in 35s
Deploy / privapub.thepra.dev (push) Successful in 52s
P5: remote objects are read in every shape, and videos, events, audio and links keep their own details
- ObjectShapes reads `url`, `icon` and `image` as a value, a Link or an array; Markdown `content` (PeerTube) is
  rendered; a missing `mediaType` is inferred from the extension or the attachment type (Bridgy); alt text comes from
  `name`, `summary` or their language maps; thumbnails come from attachment `icon`, object `icon[]`, `image` or a Loops
  `preview`; durations are ISO 8601 or seconds; per-attachment `sensitive` is kept; the language falls back to
  `@context` `@language` (Pleroma); titles and excerpts are plain text; hashtags normalise with NFKC like Mastodon.
- Typed details on Post: Link (Lemmy link posts, Mbin `source` URLs, Mastodon 4.7 Link attachments with an FEP-8967
  publisher preview), Video (PeerTube files with their streams, HLS playlist, poster, captions, chapters, licence,
  channel, live state, comment policy), Audio (Funkwhale), Event (Mobilizon, Gancio, Hubzilla: times, zone, floating
  time, places and addresses, online link, capacity, status), the cover image, and the author's own source text.
- Mastodon API: a card built from those details without fetching anything, an event's "when · where" line, attachment
  thumbnails and durations, and no media file offered as a preview image.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
2026-10-01 17:53:59 +02:00

25 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. 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

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
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, arrival-ordered 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, Federation__AllowPlainHttp=true and Federation__AcceptAnyCertificate=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

  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 HttpClient for federation.
  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:
    • bad or missing signature: 401;
    • malformed or forged body: 400;
    • accepted: 202;
    • over the rate limit: 429;
    • never 500 from /peasants or an inbox. Peers retry or "double-knock" based on these codes.
  6. Actor documents:
    • 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.
  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.
  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 Pages with a name, posting follows Group.PostingPolicy. Located posts (LocalGeo) are the only local-only posts.
  9. A DM joins a conversation only by DmGroup.ParticipantsKey, the exact set of its participants; a remote context decides nothing. DMs are Posts with Visibility = Direct and a ConversationId (DmPost is legacy).
  10. 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 by RemoteInstance. The inbox answers 202 once it has verified and queued; a handler must be idempotent (unique ObjectURI, job DedupeKey).
  11. Every "may anyone see this" goes through VisibilityPolicy.IsPublic; a persona-specific read uses CanSee.
  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).

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.
  5. Unsupported features answer empty lists or 422 with a message, never 404 or 500, so clients degrade.
  6. Advertise 4.2.0 (compatible; PrivaPub) until grouped notifications exist.

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.

Privacy invariants

  • 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.
  • 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.
  • Per-avatar state stays per avatar: blocks, mutes, notifications, follows. Nothing may relate sibling avatars.
  • 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.
  • 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>(). 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.
  • 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 duration. The raw form lives outside Post so timelines never load it. Read through /api/privapub/v1/statuses/:id/provenance and /api/privapub/v1/instances/:host.
  • Remote objects are parsed for every shape in ObjectShapes: url/icon/image as a value, an object or an array; Markdown content; missing mediaTypes 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).
  • 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.
  • 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.
  • 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.
  • 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.
  • 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).
  • ActivityPub output is built with System.Text.Json.Nodes in ActivityPubRenderer, not typed models. Inbound documents are read through InboxService.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/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.
  • Inbox scenarios go through InboxService.Receive with 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, starting with the workstation's own pasture:

DOTNET=~/.dotnet/dotnet tools/pasture/run.sh up   # podman: PrivaPub + the latest GoToSocial + Mongo, behind Caddy
tools/pasture/interop.sh                          # 25 checks, each side driven through its own Mastodon API
tools/pasture/run.sh down
  • Two sites on one podman network, one Caddy in front. privapub.test and gts.test are network aliases of the Caddy container, which serves both with its internal CA (tls internal). Both servers are told to accept any certificate: PrivaPub through appsettings.Pasture.json, GoToSocial through GTS_HTTP_CLIENT_TLS_INSECURE_SKIP_VERIFY. GoToSocial WebFingers and fetches 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. GoToSocial is reached as https://gts.test:6443 with curl -k --resolve gts.test:6443:127.0.0.1, because its sign-in cookie is bound to the host name.
  • GoToSocial's 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}.
  • GoToSocial creates its accounts locked, so interop.sh approves alice's request through /api/v1/follow_requests. That also checks our pending (requested) state and the manual Accept.
  • The scenario covers:
    • discovery and follows both ways;
    • posts and CW;
    • a reply and its notification;
    • likes and boosts both ways;
    • DMs both ways, and the DM staying off public timelines;
    • edit, delete both ways, and unfollow.
  • 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.

Deploy

  • 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, Swagger 404, inbox junk 400, unsigned 401) and tools/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). Unit privapub runs as www-data from /var/www/privapub.thepra.dev with ASPNETCORE_ENVIRONMENT=Production.
  • One-time root setup: deploy/max/setup.sh, run through ../arasaka.software/tools/max/run.sh.
  • 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.