Files
SocialPub/CLAUDE.md
T
thepraandClaude Opus 5.5 31f6015591
Build / Build (push) Successful in 25s
CLAUDE.md, a README, and the roadmap to full ActivityPub interop
CLAUDE.md records what the project is (one private login owning unlinkable
personas, each its own actor), the deliberately odd route names (peasants,
mouth, anus, human-centipede, sniff/again, and the agreed ones still to come),
the federation and privacy invariants, the data and style conventions, and
how it deploys. docs/ROADMAP.md holds the intent reconstructed from the 2023
code, the gap audit, the owner's decisions of 2026-10-01 (Mastodon client
API, personas unlinkable to others, local-only range posts, communities and
circles), the libraries chosen, and phases P0-P5.

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

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

  • 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)
Inbox /peasants/{name}/mouth
Outbox /peasants/{name}/anus
Shared inbox /human-centipede (also /peasants/{name}/human-centipede)
Token refresh /clientapi/user/sniff/again

These are agreed for the roadmap and replace the conventional names still in the code today:

Thing Route Replaces
Followers /groupies /followers
Following /stalking /following
Notes /scribbles/{id} /posts/{id}
Activities /grunts/{id} /activities/{id}
Replies, likes, shares /gossip, /drool, /echoes
Featured /trophies
Featured tags /tattoos
Group members /flock
Group moderators /wardens
DM context /whispers/{id}

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
  Controllers/ServerToServer/     PeasantsController (actor, outbox, followers, following, posts, inboxes),
                                  WellKnownController (webfinger, nodeinfo), UsersController (redirect)
  Services/                       RootUsersService, GroupUsersService, PostsService, AppConfigurationService, …
  Services/Federation/            LocalActorService (LocalActor, Keys), HttpSignatures (draft-cavage),
                                  RemoteActorService (signed fetch, WebFinger, ForeignAvatar cache),
                                  ActivityPubRenderer (JsonObject builders), DeliveryService + DeliveryWorker,
                                  InboxService (Follow/Undo/Create/Delete/Update)
  Models/                         Mongo entities: User/, Group/, Post/, Federation/, AppConfiguration
  StaticServices/                 DbEntities (Find<T> accessors), AuthTokenManager (JWT), PasswordHasher
  Data/InitDb.cs                  first-run seeding (languages)
PrivaPub.ClientModels/            DTOs + validation resources shared with clients
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
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 hosts, so local federation needs a real peer (see Testing).

Federation invariants

  1. Every outbound request is signed with draft-cavage rsa-sha256 over (request-target) host date (+ digest on bodies). GoToSocial and authorized-fetch Mastodon require signed GETs.

  2. Inbound inboxes verify everything before acting:

    • the Digest matches the body;
    • the Date is inside the window;
    • the signature verifies against the key owner's key;
    • the activity's actor equals the key owner.
  3. Status codes:

    • bad or missing signature: 401;
    • malformed body: 400;
    • accepted: 202;
    • never 500 from /peasants or an inbox. Peers retry or "double-knock" based on these codes.
  4. 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.
  5. Delete and Update apply only to objects the sender authored. A group re-announces only members' posts.

  6. Durable delivery: activities go out through the delivery queue, never inline in a request, and are signed by the acting avatar or group.

  7. Known weaknesses being fixed in roadmap phase P0:

    • an actor's document isn't yet checked against its fetch URL (key-cache poisoning);
    • the SSRF guard is string-only;
    • remote HTML is stored unsanitised;
    • object origins aren't checked.

    Don't build on these behaviours.

Privacy invariants

  • No root id in federation output, NodeInfo or logs, and no IP next to an identity in logs. Some log lines in RootUserController still do this; P0 removes them.
  • Never derive a public name from the root username. Invitation sign-up still does; P0 fixes it.
  • Per-avatar state stays per avatar: blocks, mutes, notifications, follows. Nothing may relate sibling avatars.
  • 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.
  • 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.
  • 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

There is no test project yet; roadmap P0 adds PrivaPub.Tests (xUnit v3) with fixture JSON captured per peer.

Until then, 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 master runs build.yml on the instance-wide build runner. A v* tag runs deploy.yml: self-contained linux-x64 publish, snapshot to /var/backups/privapub.thepra.dev, stop → rsync → start, a 127.0.0.1:6970/build.json health loop with rollback, then public checks.
  • 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.