# 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 signs in once on `/clientapi` and exchanges that JWT for each persona's Mastodon token (`PersonaExchange`), then uses both APIs. ## 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}` | | Quote permission (FEP-044f `QuoteAuthorization`) | `/peasants/{name}/parrot-licences/{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 `) 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) tools/ci/with-test-mongod.sh dotnet test PrivaPub.sln # all of them, on a throwaway mongod, as CI runs them 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. 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): ```bash cd /var/www/privapub.thepra.dev echo '' | ASPNETCORE_ENVIRONMENT=Production ./PrivaPub admin create-root --admin # password on stdin ASPNETCORE_ENVIRONMENT=Production ./PrivaPub admin promote|demote ASPNETCORE_ENVIRONMENT=Production ./PrivaPub admin smoke # the deploy's: prints "deploy-smoke " ``` 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. ## 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. 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). 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. Each member's copy also names that member in `cc` (`OutboxPublisher.Naming`, owner decision 2026-10-04), because Mastodon and GoToSocial keep a post only when it names one of their accounts; Create, every Update and the Delete all go through `OutboxPublisher.Publish` for that. A reply to a circle post stays in the circle, and a circle post never asks a non-member for a quote. Circles never appear in search, lookups, mentions or profile pages. **A post that is not public is served only to a signed request from someone it was for** (`SignedFetchAuthorizer.MayRead`): a follower or an addressed account for followers-only, an addressed account for a DM, a member for a circle, or the instance actor of a server where one of them lives; 404 to anyone else, 410 to them once it is deleted. A circle refetch names the requesting member, or the members on the requesting server. A DM's `context` (`/peasants/{name}/whispers/{id}`) lists the conversation's posts for its participants. Mastodon deletes its copy when a refetch answers 404, which is why this matters. **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. 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 number about posts and users goes through `Domain/Privacy/Counted`** (owner decision 2026-10-04: one answer everywhere): a persona's `statuses_count`, its outbox `totalItems`, NodeInfo `localPosts` and the instance `status_count` all count Mastodon's way (not deleted, not a DM, boosts included, circle and located posts too); users exclude personas of banned or deleted roots; `replies_count` counts only public and unlisted replies; a remote account that deletes itself takes its likes, votes, reactions, boosts and replies out of every count (`GoneActors`). Follower and following counts are public, their members never are (`hide_collections` is always true). 12. **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). 13. **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. 14. **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. - **The first-party client exchanges the root JWT for one persona's token** (RFC 8693 on `/oauth/token`, `subject_token_type` `urn:ietf:params:oauth:token-type:jwt`, `avatar_id`; `Api/Mastodon/Auth/PersonaExchange.cs`). Only the seeded public application (`ClientApi:FirstPartyClientId`, default `decepub`) holds the grant. `RootJwtSubjectToken` validates the JWT through `RootJwt`, the same rules as JwtBearer (signature, lifetime, ban, deletion, session stamp). The issued token is the same kind as the authorization code flow's, so revocation and `RootSessions` cover it. 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. 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. 6. Advertise `4.2.0 (compatible; PrivaPub)` until streaming and Web Push exist too; `api_versions.mastodon = 7` stays, because clients show quotes from it, so everything a 4.3+ client calls because of it must answer: grouped notifications (`/api/v2/notifications`, its unread count, groups, accounts and dismiss) and the notification policy. 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, a remote post's own place (Pixelfed's `location`, never federated again), 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. ## 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`. 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. ## 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). - **Sign-in and recovery never tell which logins or addresses exist** (owner decision 2026-10-04): every failed sign-in gets "That username and password do not match." after the same hashing (`RootUsersService.Decoy`, a constant-time comparison), and only a correct password learns of a ban. Every recovery request answers the same sentence and queues a `SendRecovery` job, found or not; `RecoveryJob` sends the email and keeps only a SHA-256 of the code, for an hour. A recovered password ends every session of the root (`RootSessions`: a new `SessionStamp`, which every `/clientapi` JWT carries, and OpenIddict revocation for each persona). - **Deleting a root deletes everything public it had** (`RootRemoval`, from the admin route or `/clientapi/user/delete` with the password): its sessions end, each persona and each group it owns sends `Delete{Actor}` to followers, members and the accounts it follows, the personas' posts are emptied, and `/peasants/{name}`, its inbox and WebFinger answer 410 (`LocalActorService.Gone`) while the names stay reserved. The root keeps only a `deleted-{id}` name. - **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.** - **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. ## 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. - **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. 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 `/api/privapub/v1/statuses/:id/provenance` and `/api/privapub/v1/instances/:host` (or `?host[]=`, up to 40; this server answers too). - **A server's place leaves only through `PublicGeo.Project`:** the city, coordinates (0.1°) and network it was located to, whatever its size (there is no user threshold), and only the CDN's name for a CDN-fronted one. This server's own place is its host's address located once a day (`SelfLocation`), or `Statistics:Geo:Self` when the owner sets it. - **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). - **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 Updates both go through `RemoteEdits.Apply`, and deletes through `RemoteDeletes.Remove`). Otherwise it refreshes 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 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`). - **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`). - **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. - **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. - **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`. - 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 `ActivityJson.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 (`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. - `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 `InboxReceiver.Receive` with a signed request, not through the private handlers. - `Support/Host/PrivaPubHost` is the whole server under test (`WebApplicationFactory`, 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. - 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. 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: ```bash 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 ``` - **Layout:** `lib/pasture.sh` (network, Caddy, Mongo, PrivaPub), `peers/.sh` (`_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 `, `stats_check `), `scenarios/.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://.test:6443` with `curl -k --resolve .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. - 55 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; communities and circles; locked personas; unfollow; block and unblock; statistics. - It files a circle post as a direct message and shows it only to the accounts it mentions, so like a DM it is checked in the member's `/api/v1/conversations`, never by URI. - **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/`), so look URIs up rather than build them. Circle posts and a followers-only post survive its signed refetch (`ActivityPub::FetchRemoteStatusService` through `rails runner`). Inbound Block is the one expected failure until P7. - **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/` 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. - **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. - **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/` 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 `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). - **SecureMode:** `PRIVAPUB_ENV="Federation__SecureMode=true" run.sh up …`, as production runs. A check of what an unsigned reader sees uses `unserved` and `gone_unsigned` (`lib/interop.sh`), which expect 401 when SecureMode is on and 404 or 410 when it is off. - **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. - `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). 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. - **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` (directories, the runner's sudoers line, the units, nginx and the certificate). Nothing that runs later needs it again. - **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.