# 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; 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 `) 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 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 ```bash 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): ```bash cd /var/www/privapub.thepra.dev && sudo -u www-data ASPNETCORE_ENVIRONMENT=Production ./PrivaPub admin promote ``` ## 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 never federate.** A circle's actor, collections, WebFinger and inbox answer 404, and its posts are `IsLocalOnly`. Only communities are Group actors. 9. **A DM joins a conversation only by `DmGroup.ParticipantsKey`**, the exact set of its participants; a remote `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`). 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. - **Per-avatar state stays per avatar:** blocks, mutes, notifications, follows. Nothing may relate sibling avatars. - **Blocks never federate,** and reports leave as `Flag` from the instance actor. - **Location-ranged posts never federate.** ## Data - MongoDB through **MongoDB.Entities 25.1**, instance API: `DB.Default.Find()`, `.SaveAsync(e)`, `.Update()`, `.DeleteAsync()`, `.CountAsync()`. `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. - **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`. - 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 `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: 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` (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.