CLAUDE.md, a README, and the roadmap to full ActivityPub interop
Build / Build (push) Successful in 25s

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
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-01 10:38:35 +02:00
1 parent 075c22228a
commit 31f6015591
3 files changed
+667 -1

No files matched your search

+194
View File
@@ -0,0 +1,194 @@
# 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
```bash
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.
+12 -1
View File
@@ -1,2 +1,13 @@
# SocialPub # PrivaPub
A self-hosted ActivityPub server in C# (.NET 10, MongoDB) where one private login owns several unlinkable public
personas. It federates with Mastodon, GoToSocial, Pleroma/Akkoma, Misskey and Lemmy.
- Live: https://privapub.thepra.dev
- Client: [decePubClient](https://git.thepra.dev/thepra/decePubClient), https://decepub.thepra.dev
- Architecture, conventions and deploy: [CLAUDE.md](CLAUDE.md)
- Decisions and the plan to full ActivityPub interop: [docs/ROADMAP.md](docs/ROADMAP.md)
```bash
dotnet build PrivaPub.sln -c Release
```
+461
View File
@@ -0,0 +1,461 @@
# PrivaPub roadmap
Written 2026-10-01 from the original 2023 code, the decePubClient UI, a federation gap audit of commit `075c222`, research on .NET ActivityPub libraries, and the owner's decisions. Each phase ends in a tagged deploy plus verification; tick phases off here as they land.
## Status
- [ ] P0 Security baseline
- [ ] P1.1 Infrastructure and content model
- [ ] P1.2 Social graph and timelines
- [ ] P2 Mastodon client API
- [ ] P3 Social features
- [ ] P4 Groups and privacy features
- [ ] P5 FEPs and polish
## Intent
It is a self-hosted, Pleroma-like microblogging server for privacy-minded individuals and small communities. It
federates with Mastodon, Pleroma/Akkoma, Misskey and GoToSocial (`AvatarServer` enum). Its distinguishing idea is
**one private login (RootUser) owning several public personas (Avatars), each a separate actor with its own keys**.
The rename SocialPub → PrivaPub happened in the commit that added the private avatar service.
Its privacy additions on top of the usual fediverse features:
- **Location-ranged posts:** `Post.Location` and `RangeKm` (5 km default), currently unused.
- **Contacts a persona chooses to share:** `Avatar.SharedPersonalContacts`, plus a root contact book (`ContactItem`).
- **Private notes:** about other accounts (`RootUserNote`) and on one's own avatar (`PersonalNote`).
- **Invitation-gated groups and DM groups.**
- **Signup without email:** email is only for password recovery.
The client (decePubClient) is a near copy of **Pleroma-FE plus Pleroma's admin panel**: a visibility picker, a
subject line, Plain/HTML/Markdown content types, media with alt text, boosts and likes, threads, mutes and blocks, data
import/export, and admin sections for users, reports, emoji, MRF policies and uploads. Today it shows mock data and
calls nothing.
Leftovers of the collAnon template, not intent:
- the discussion and confrontation resources;
- collAnon's invitation flow;
- QR scanning;
- the "collAnon support" mail sender.
The finishing pass reinterpreted the owner's never-written `Group` and `DmGroup` as an ActivityPub Group actor with an
invitation code and a direct-message conversation. The owner confirmed it on 2026-10-01, with groups split into communities
and circles (see Owner decisions).
**Status at HEAD:**
| Status | Features |
|---|---|
| Works | Accounts and JWT, several avatars, per-avatar actors and keys, posts, replies, CW, delete, DMs, groups and invitations, WebFinger, NodeInfo, signed inbox for Follow/Undo/Create/Delete/Update, delivery queue |
| Missing | Following remote accounts (no outgoing Follow), home/local/federated timelines, notifications, likes, boosts, visibility other than public, media, polls, edits by the author, profile Update federation, mutes, blocks, reports (Flag), Move, custom emoji, the location and contact features, admin and moderation, and a client that talks to the server |
**Persona separation is breached in three places today:**
- invitation signup names the avatar after the root username;
- logs pair root IDs with IP addresses;
- NodeInfo counts avatars as users.
## Where it stood
**Works:**
- WebFinger, including `acct:` for groups
- NodeInfo 2.0
- Person, Group and Application actors with SPKI keys
- Outbound cavage signatures (every fetch is signed, so authorized-fetch servers work)
- Inbound cavage verification, with key refetch and a key-owner check
- Shared inbox, durable delivery with backoff
- Follow with auto-Accept or manual approval for groups, and Undo
- Inbound public, group and DM Creates
- Delete and Update restricted to the author
- DMs both ways, with Mention tags
**Critical (security):**
- **S1, actor/key cache poisoning:** `RemoteActorService.Upsert` and `GetActorByKeyId` accept any document under its
own `id`, with no origin, `publicKey.id` or `owner` checks. Any remote actor can be impersonated.
- **S2:** no check that an object's `id` host matches its actor's.
- **S3, SSRF:** only string checks. Names resolving to private addresses, redirects and rebinding all get through, and
a request can trigger it before authentication.
- **S4:** unbounded fetch size, and 500s on unexpected content.
- **S5:** remote HTML is stored raw, with no format flag.
- **S6:** loose signature freshness.
- **S8:** DM conversation injection.
- **S9:** "private" groups federate as Public.
- **S14:** the "admin" username grants admin, and Swagger is open in production.
**Blockers:**
- **O1:** no outgoing Follow, so no home timeline.
- **O2, O3:** replies and mentions aren't delivered to their targets.
- **I1:** inbound replies and mentions are stored but invisible, and there are no notifications.
- **G1:** groups Announce the object URI instead of the activity, so Lemmy and other FEP-1b12 software see empty
communities.
- **K7:** no media, timelines, likes or boosts for local users.
**Important:**
- **Data:** no indexes or unique constraints, upsert races (K1-K4).
- **Inbox:** processing runs synchronously with no idempotency store (I9, I10).
- **Delivery:** one serial worker, and a single poisoned row can stall the queue (L1-L3).
- **Objects:**
- Edits and profile updates don't federate (O7).
- No 410 Tombstones (A7).
- Content warning without a title (O5).
- Titles are lost on Mastodon (O6).
- Visibility modes are missing (O4).
- **Interactions:** likes, boosts and reports are dropped (I2, I3).
- **Media:** inbound attachments are dropped, and there's no media proxy (I6, S13).
- **Actor profile:**
- Actor `url` serves JSON to browsers (A1).
- No `attachment` fields, Move or `alsoKnownAs` (A2, A3).
- No locked or undiscoverable accounts (A4).
- **Signatures:** inbound RFC 9421 and Content-Digest (H1, H2).
## Owner decisions (2026-10-01)
| Question | Decision |
|---|---|
| Client interface | **Mastodon client API**, so Tusky, Elk, Phanpy, Ivory and the official apps work. Each avatar is its own Mastodon account: at OAuth authorize, the logged-in RootUser picks the avatar the token is for. PrivaPub-only features (avatars, groups, contacts, range posts) stay on `/clientapi`. Moving decePubClient onto the Mastodon API is out of scope. |
| Personas | **Unlinkable to other users and servers.** The admin can still see the link in the database. No root IDs next to IPs in logs, NodeInfo counts nothing that links personas, blocks, mutes and notifications are per avatar, and invitation signup no longer names the avatar after the root username. |
| Location-ranged posts | **Local only, never federated.** Shown to local users within the radius, with coordinates rounded on storage. |
| Groups | **Per group, two kinds.** A *community* federates per FEP-1b12, Lemmy-compatible: it Announces the activity, uses `audience`, and accepts posts from non-followers. A *circle* is invitation-only: posts are addressed to the members collection, and objects are served only to signed requests from members. |
## Libraries (researched; no maintained .NET ActivityPub library exists, so Letterbook and Iceshrimp.NET both wrote their own)
| Area | Choice |
|---|---|
| AS2 / ActivityPub model | Own thin layer on `System.Text.Json.Nodes`: inbound helpers for single-or-array values, id-or-object, `type` arrays and the three Public forms; outbound builders with Mastodon's `@context`. No JSON-LD processing; fetch from the origin instead of verifying LD signatures. |
| HTTP signatures | Keep own draft-cavage (sign and verify). Add **NSign 1.2.5** (`NSign.AspNetCore`) to verify inbound RFC 9421 and Content-Digest. Always answer a bad signature with 401, never 500. |
| HTML | **HtmlSanitizer 9.x** with Mastodon's allowlist (tags `p br span a del pre code em strong b i u ul ol li blockquote`, attributes `href rel class`, microformat classes, a scheme allowlist, forced `rel=nofollow noopener noreferrer`) |
| Markdown | **Markdig 1.4**: `DisableHtml`, autolinks, custom inline parsers for `@user@domain` and `#tag` emitting Mastodon's h-card and hashtag markup |
| Media | **NetVips** (+NetVips.Native) for images: autorotate, strip EXIF/GPS, thumbnails. **Blurhash.Core**, **FFMpegCore** for video. Own `IMediaStore` (local disk now). Not ImageSharp 4, whose license key is enforced at build. |
| OAuth | **OpenIddict 7.7 + OpenIddict.MongoDb**: authorization code with PKCE, client_credentials, `oob` redirect, dynamic apps from `POST /api/v1/apps`, reference tokens |
| Jobs | Own Mongo job collection for both inbox processing and delivery: `FindOneAndUpdate` leases, a `Channel` wake-up, per-host circuit breaker, Mastodon's backoff (`n⁴+15+jitter`, 16 tries). No MassTransit (commercial from v9, no Mongo transport). |
| Tests | New xUnit project with fixture JSON captured from Mastodon, GoToSocial, Misskey, Lemmy and Akkoma; interop checked with Fediverse Pasture, verify.funfedi.dev and activitypub.academy |
**FEPs, now:** FEP-f1d5 (NodeInfo 2.0 and 2.1), FEP-67ff (FEDERATION.md), FEP-8fcf, FEP-5feb `indexable`,
FEP-7628 Move, FEP-2c59, FEP-044f quotes (parse, plus `interactionPolicy`), FEP-1b12 groups.
**FEPs, later:** FEP-521a, FEP-8b32, FEP-e232, relays (FEP-ae0c).
**FEPs, skipped:** FEP-c390, FEP-ef61, FEP-fb2a, FEP-844e.
## Route names (deliberate; part of the project's character)
The odd names are the owner's and stay. Every new actor-scoped route follows the same theme. The names marked *new*
were agreed on 2026-10-01.
| Thing | Route | Status |
|---|---|---|
| Actor | `/peasants/{name}` | kept (`/users/{name}` 301-redirects here) |
| Inbox | `/peasants/{name}/mouth` | kept |
| Outbox | `/peasants/{name}/anus` | kept |
| Shared inbox | `/human-centipede` (and `/peasants/{name}/human-centipede`) | kept |
| Token refresh (client API) | `/clientapi/user/sniff/again` | kept |
| Followers | `/peasants/{name}/groupies` | *new*, replaces `/followers` in P1 |
| Following | `/peasants/{name}/stalking` | *new*, replaces `/following` in P1 |
| Notes (objects) | `/peasants/{name}/scribbles/{id}` | *new*, replaces `/posts/{id}` in P1 |
| Activities | `/peasants/{name}/grunts/{id}` | *new*, replaces `/activities/{id}` in P1 |
| Replies, likes, shares collections | `/scribbles/{id}/gossip`, `/drool`, `/echoes` | *new* |
| Featured (pins) | `/peasants/{name}/trophies` | *new* |
| Featured tags | `/peasants/{name}/tattoos` | *new* |
| Group members | `/peasants/{group}/flock` | *new* |
| Group moderators | `/peasants/{group}/wardens` | *new* |
| DM conversation context | `/peasants/{name}/whispers/{id}` | *new*, replaces `/conversations/{id}` |
Routes outside the actor namespace stay conventional, because other software looks for them by name:
- `/.well-known/*` and `/nodeinfo/*`;
- the Mastodon API (`/api/v1/*`, `/oauth/*`);
- the human pages `/@{name}` and `/@{name}/{id}`;
- `/media/*`.
Once a P1 deploy has federated a name, it is frozen.
## Target structure (moved incrementally; entity class names and URI shapes never change)
```
PrivaPub/
Infrastructure/ Http/ (IpRangeGuard, SafeHttpHandlerFactory, FederationHttp, BoundedReader), Jobs/ (JobQueue, JobWorker,
Backoff, HostCircuitBreaker), Ids/ (PrivacyIdGenerator, PostIds), Data/ (Indexes, Migrations/_NNN_*),
RateLimiting, ErrorHandling
Federation/ Signing/ (DraftCavage ← Services/Federation/HttpSignatures.cs, SignedFetchAuthorizer, Rfc9421Verifier)
Actors/ (LocalActorService, Keys, RemoteActorService, WebFingerClient, ActorParser)
Objects/ (Origin, ObjectFetcher, NoteParser, Addressing, ContentSanitizer)
Inbox/ (InboxReceiver, InboxProcessor, Handlers/{Follow,Accept,Reject,Undo,Create,Update,Delete,Like,Announce,Flag,Move}Handler)
Outbox/ (AudienceResolver, OutboxPublisher, GroupDistributor) Rendering/ (ApContext, Actor/Note/Activity/CollectionRenderer)
Controllers/ (Peasants, WellKnown, NodeInfo, Users)
Domain/ AvatarContext, Statuses/StatusService (← Services/PostsService.cs), Timelines/FanoutService, Notifications/,
Relationships/, Groups/GroupService (← GroupUsersService.cs), Content/ (MarkdownRenderer, PlainTextRenderer,
MentionTagParsers), Privacy/VisibilityPolicy (single CanSee), Media/ (P3), Geo/ (P4)
Api/ ClientApi/Controllers (← Controllers/ClientToServer, routes unchanged), Mastodon/{Controllers,Entities,Mappers,Auth,Infrastructure}
Web/Pages/ Profile, Status (P1); OAuth/Login, ChooseAvatar, Oob (P2)
```
The first refactor commit is a pure move with namespaces only. Logic changes follow in separate commits.
## Phases (each one ends in a tagged deploy plus verification)
### P0 Security baseline
- **S1, actor verification:** `RemoteActorService`.
- `GetActor` accepts a document only if its `id` equals the final URL, on the same origin.
- `GetActorByKeyId` resolves the owner, then the strict actor, and requires `publicKey.id == keyId` and
`owner == id`.
- Upserts become atomic on a unique `ActorURI`.
- Key refetch is limited to once per 5 minutes.
- **S3 and S4, safe outbound HTTP:**
- A `SocketsHttpHandler.ConnectCallback` resolves DNS and rejects loopback, private, link-local, CGNAT and ULA
addresses, plus NAT64, 6to4, Teredo and IPv4-mapped forms. It then connects to the vetted IP.
- Redirects are manual (at most 3, each re-checked).
- Responses are capped at 1 MB with a content-type check and a 15 s timeout, with a negative cache for failing URLs.
- Exceptions map to ProblemDetails: 400 or 401, never 500.
- **S2, origin checks:** `Origin.Same`. An activity's or object's `id` must share the actor's origin, and
cross-origin embedded objects are refetched.
- **S5, sanitising:** `ContentSanitizer` with HtmlSanitizer. `Post.ContentHtml` and `ContentFormat` are added, and
migration `_002` sanitises stored remote content.
- **S6, signature freshness:** `(request-target)` and `host` are required, plus `date` or `(created)` and `digest`.
The window is −1 h to +15 min. `(expires)` is enforced, and the raw request target is used.
- **S8, DM injection:** a DM joins a conversation by `context` only if the author is already a member; otherwise it
is matched on `DmGroup.ParticipantsKey`.
- **S9, groups:** a `Group.Kind` field is added. Existing groups become Circles (migration `_003`), and Circle posts
stay local-only until P4.
- **S14:**
- Delete the `"admin"` signup branch and add the CLI `admin promote`.
- Serve Swagger only in Development.
- Return generic error messages (no `ex.Message`).
- **Privacy:**
- Drop the IP, User-Agent and root-id log lines in `RootUserController`.
- Invitation signup takes its own `AvatarUserName`.
- All fetches are signed by the instance actor.
- **Indexes and races:** `Infrastructure/Data/Indexes.cs`, with migration `_001` deduplicating first.
- Unique: `ObjectURI`, `ForeignAvatar.ActorURI`, the Follower triple, RootToAvatar, and `ReservedName` (one
username space across avatars and groups).
- Plain: `PublicKeyId`, `(GroupUserId, _id)`, `(GroupId, _id)`.
- **Rate limiting** on login, signup, invitation and inbox (per key host).
- **Cleanup:** delete `Services/ActivityPubClient.cs` and `Models/Post/PostBoost.cs`.
- **New `PrivaPub.Tests`** (xUnit v3): IpRangeGuard table, signature fixtures (a real Mastodon request plus tampering
cases), key-poisoning, origin rules, XSS corpus, DM injection.
- **CI:** `build.yml` runs the tests. `deploy.yml` runs the tests and a `mongodump` before swapping.
### P1 Interop foundations (two deploys)
**P1.1, infrastructure and content model:**
- **Mongo job queue** (`Job`, `RemoteInstance`):
- Leases via `UpdateAndGet`, `Channel` wake-up, a reaper for expired leases.
- Workers: delivery 8 (at most 2 per host), inbox 2.
- Backoff `n⁴+15+jitter`, 16 tries. Non-transient errors go straight to Dead.
- Per-host circuit breaker. Dedupe on `activity.id` and `activityId|inbox`.
- TTL on finished jobs.
- Migration `_004` moves pending `Delivery` rows into jobs.
- **Async inbox:** `InboxReceiver` verifies and enqueues, then answers 202 (a duplicate also gets 202).
`InboxProcessor` dispatches to the handlers split out of `InboxService`.
- **Merge DmPost into Post** (migration `_005`; ids kept; `Visibility=Direct`, `ConversationId`). Post gains:
- `Visibility {Public, Unlisted, FollowersOnly, Direct, Circle, LocalGeo}`
- `AuthorAccountId`, `To`, `Cc`, `ActivityURI`, `Url`, `ContextURI`
- `InReplyToURI`, `InReplyToAccountId`, `ReblogOfPostId`
- `SpoilerText`, `Language`, `Mentions[]`, `Tags[]`, `Media[]`
- counters, `Revisions[]`, `DeletedAt`
`/clientapi/dm/*` moves onto Post, and every read goes through `VisibilityPolicy.CanSee`.
- **Parsing inbound content:**
- `NoteParser` handles Note, Article, Page and Question. It reads `content`, then `contentMap`, then
`_misskey_content`; also `summary`, `name`, `sensitive`, the `tag` array, `attachment` (as remote media),
`inReplyTo`, `context`, `audience`, `updated`, and the `quote` fields (stored as a URI).
- `Addressing` classifies Public, Unlisted, FollowersOnly or Direct. Followers-only is detected by the actor's
stored `followers` URL exactly, not by a path suffix.
- **Rendering local text:**
- `MarkdownRenderer` (Markdig) and `PlainTextRenderer` (for Mastodon API input).
- Mentions resolved through WebFinger become an h-card plus a `Mention` tag; hashtags become a `Hashtag` tag.
- **Renderers:**
- `ApContext`: Mastodon's `@context` (toot, schema, `indexable`, `blurhash`, `focalPoint`, `featured`,
`alsoKnownAs`, `movedTo`, `webfinger`).
- Actor and note `url` point at HTML pages; fields become `attachment` PropertyValue; the `webfinger` property is
added; `published` is truncated to the day.
- A title becomes `name` and is also prepended to the content as bold text.
- A CW without a title gets a localised "Content warning" summary.
- Routes are renamed to the themed set (`/groupies`, `/stalking`, `/scribbles/{id}`, `/grunts/{id}`,
`/whispers/{id}`), activity URIs resolve, and the outbox gets a `first` page.
- **Web:**
- Razor pages `/@{user}` and `/@{user}/{id}`, with content negotiation; `/peasants/{u}` with `text/html`
redirects there.
- WebFinger `profile-page` points at the HTML page.
- NodeInfo 2.1 (avatars counted as users, `openRegistrations` from config) and `FEDERATION.md`.
- **Moderation:** `DomainBlock` (Silence, Suspend, RejectMedia), applied in the receiver, the fetcher and delivery.
- **IDs:** register `PrivacyIdGenerator` for Avatar and Group (day-truncated timestamp plus random bytes). Remote post
ids are ObjectIds generated from their `published` time.
**P1.2, social graph and timelines:**
- **Outgoing Follow** (`Following` entity, plus Accept, Reject and outbound Undo). A local target is handled in-process.
- **`AudienceResolver` and `OutboxPublisher`:**
| Visibility | to | cc |
|---|---|---|
| Public | Public | followers + mentions |
| Unlisted | followers | Public + mentions |
| Followers-only | followers | mentions |
| Direct | mentions | (none) |
Replies are also delivered to the parent's author, and group posts to the group. Mentions are delivered to personal
inboxes.
- **Inbound Create:**
- The interested set is followers of the author, plus addressed or mentioned local avatars, plus the local parent's
author, plus group members. If nobody local is interested, the post is dropped.
- Store the post (idempotent on ObjectURI), then fan out to the **`TimelineEntry`** collection
(`{AvatarId, PostId, AuthorAccountId, ReblogOfPostId}`, unique `(AvatarId, PostId)`, paged by PostId).
- Mastodon's reply, mute and reblog rules apply at write time.
- Mention notifications are written.
- **`Notification`** entity: `{AvatarId, Type, FromAccountId, PostId, DedupeKey}`, unique on `DedupeKey`.
- **Edits:** outbound `Update{Note}` to the same To/Cc, with revisions kept. Profile edits send `Update{Person}`.
- **Deletes:** soft delete, a Delete sent to the stored audience, and the object then answers **410** with a
Tombstone (a deleted actor answers 410 too).
- **Inbound Like and Announce:** a `Favourite` entity, reblog rows with the original refetched, Undo of both,
counters and notifications.
- **Threads:** a `FetchAncestors` backfill job, depth at most 10.
- **Test endpoints:** `/clientapi/timeline/home`, `/clientapi/follow` and `/clientapi/notifications`, usable before P2.
### P2 Mastodon client API
- **OAuth:** OpenIddict 7.7 + OpenIddict.MongoDb.
- Storage is `IMongoDatabase` registered as `DB.Default.Database()`.
- Endpoints: `/oauth/authorize`, `/oauth/token`, `/oauth/revoke` and `/.well-known/oauth-authorization-server`.
- Flows: authorization code (PKCE optional) and client_credentials.
- Scopes: `read`, `write`, `follow`, `push` and the granular ones.
- Tokens are non-expiring reference tokens. The signing and encryption keys are persistent, in `/etc/privapub/oidc-*.pem`.
- The `oob` code page is supported, as is `force_login`.
- **Authorize UX:** log in with the root password (a short-lived cookie limited to `/oauth`), choose an avatar, consent.
- **The token's `sub` is the avatar id, and no root claim is ever in the principal.** A banned or deleted root
invalidates its tokens.
- `POST /api/v1/apps` creates OpenIddict applications dynamically and prunes unused ones.
- **Plumbing:**
- `MastodonJson`: snake_case, explicit nulls and empty arrays (Tusky breaks on missing fields).
- A binder that merges Rails-style query, form and JSON parameters.
- `{"error"}` responses.
- `Link` paging on `max_id`, `since_id` and `min_id`; CORS exposes `Link`.
- `Idempotency-Key` honoured on posting statuses.
- **Endpoints:**
- **Instance:** v1 and v2, advertising version `4.2.0 (compatible; PrivaPub)`.
- **Accounts:** `verify_credentials`, `update_credentials`, `:id`, `:id/statuses`, `lookup`, `relationships`,
`search`, followers and following, follow and unfollow, `follow_requests`.
- **Statuses:** CRUD, edit, `context`, `history`, `source`, favourite and reblog plus their undos (outbound Like and
Announce), `reblogged_by`, `favourited_by`.
- **Timelines and the rest:** home, public, tag; notifications; markers; conversations; `/api/v2/search` with
`resolve`.
- **Stubs:** `custom_emojis`, filters, lists, announcements, trends, suggestions, `followed_tags`, preferences.
- **Mapping:**
- Avatar, ForeignAvatar and Group all map to Account (`group: true` for groups). `acct` uses a WebFinger-verified
handle; `created_at` is truncated to the day; avatar and header always have a placeholder URL.
- Post maps to Status. Reblogs wrap the original. Per-viewer flags are loaded in batches.
- LocalGeo never appears in the Mastodon API.
- **New Avatar settings:** `IsLocked`, `IsDiscoverable`, `IsIndexable`, `IsBot`, `HideCollections`,
`DefaultVisibility`, `DefaultSensitive`, `DefaultLanguage`.
### P3 Social features
- **Media pipeline:** `MediaAttachment` entity plus `/api/v1/media` and `/api/v2/media`.
- NetVips: autorotate, strip all metadata, 4096 px cap, 640 px thumbnail.
- Blurhash.Core.
- FFMpegCore: remux only, with `-map_metadata -1`.
- Stored in `/var/lib/privapub/media`, outside the web root that deploys replace. nginx serves `/media/` with
`nosniff` and a strict CSP, and allows larger uploads only on the media endpoints.
- A job deletes unattached uploads.
- **Remote media proxy:** `/media/proxy/{hmac}/{url}`, going through the safe HTTP handler, with a ~5 GB disk LRU. The
API and avatars use proxied URLs, so clients never contact remote hosts.
- **Outbound attachments:** `mediaType`, alt text, `blurhash`, `focalPoint` and dimensions. Profile avatar and header
images are sent as `Update{Person}`.
- **Bookmarks and pins:** pins are served as the `featured` collection at `/peasants/{name}/trophies`, with featured
tags at `/tattoos`.
- **Blocks and mutes:** per avatar. **Block is not federated**: it sends Reject or Undo Follow instead and drops the
blocked actor's traffic. Domain blocks per account as well.
- **Reports:** inbound Flag becomes a `Report`. Outbound Flag is **sent by the instance actor**, so the reporting
persona isn't revealed. Moderator endpoints on `/clientapi`.
- **Locked accounts:** the follow-request flow.
### P4 Groups and privacy features
- **Group model:** `Group.Kind`, `PostingPolicy`, `Rules`; `GroupMember.State`; new collections for
moderators (`/wardens`), members (`/flock`) and featured (`/trophies`).
- **Communities (FEP-1b12):**
- `GroupDistributor` announces the full activity (Create, Update, Delete, Like or Undo) with `audience` to the
group's followers. New posts additionally get `Announce(object)`, so Mastodon shows them.
- Top-level posts render as `Page` with a title; comments as `Note`.
- Posting from non-followers is allowed according to `PostingPolicy`.
- Inbound Announces from remote groups are refetched from their origin.
- A Mastodon client posts into a group by mentioning `@group@host`.
- **Circles:**
- The actor is undiscoverable, with manual approval and invitation-only joining.
- Posts are addressed `to: [members, group]` and delivered to members' **personal** inboxes, never with Announce.
- `SignedFetchAuthorizer` serves circle objects only to a signed request from a member, or from the instance actor
of a member's server. Anything else gets 404.
- `FEDERATION.md` documents the Mastodon limitation: Mastodon members' replies reach only whoever they mention.
- **Local-only location posts:**
- `Post.Location` becomes a GeoJSON point rounded to 2 decimals (about 1 km), with `RangeKm` clamped to 1–50 and a
`2dsphere` index. Visibility is LocalGeo.
- `/clientapi/post/nearby` runs `$geoNear` filtered by each post's own radius. The viewer's position is never stored.
- The audience is always empty; ActivityPub endpoints and the Mastodon API never expose these posts.
- **Persona hardening:** optionally re-key existing avatar ids; an optional `SecureMode` (signed GETs required); no
suggestions or directories that could relate sibling avatars.
### P5 FEPs and polish (ongoing)
- **FEP-8fcf:** followers synchronisation.
- **FEP-5feb:** honour remote `indexable` in search.
- **FEP-7628:** Move in both directions, plus editing `alsoKnownAs`.
- **FEP-044f:** quotes and `interactionPolicy`.
- **RFC 9421:** inbound verification with NSign, plus Content-Digest.
- **Mastodon API extras:** polls, the streaming WebSocket (nginx Upgrade headers), Web Push (VAPID), and grouped
notifications v2, after which the advertised version moves to 4.3.
- **Backfill:** an author's outbox after following them.
- **Later:** FEP-521a, FEP-8b32, FEP-e232, relays.
### Cut or deferred (deliberately)
- **Cut:**
- Link-preview cards: an SSRF and privacy risk.
- Translation, trends, directory and lists (filters stay as stubs).
- Scheduled posts.
- The Mastodon admin API: moderation stays on `/clientapi`.
- `POST /api/v1/accounts` registration: avatars are created through `/clientapi`.
- Local custom emoji.
- S3 storage.
- JSON-LD and LD-signature processing.
- Outbound RFC 9421.
- **Deferred:** video transcoding (remux only for now).
- **Out of scope:** moving decePubClient onto the Mastodon API.
## Verification (per phase)
- **Unit tests** (`PrivaPub.Tests`) on every phase. Fixture JSON is captured per peer into
`Fixtures/{mastodon,gotosocial,misskey,akkoma,lemmy}/`. Renderers are checked against golden JSON. Inbound
scenarios run as integration tests on a throwaway mongod.
- **Interop:** **Fediverse Pasture** runs on the workstation with podman: Mastodon, GoToSocial, Misskey/Sharkey,
Akkoma, plus Lemmy for P4. `tools/pasture/docker-compose.privapub.yml` runs PrivaPub with `AllowPrivateNetworks`;
startup asserts that flag is false in Production. The matrix, per peer:
- follow both ways;
- public, unlisted, followers-only and direct posts both ways;
- a reply landing in the remote thread;
- a mention producing a notification;
- edit, and delete answering 410;
- like and boost counters;
- CW and title rendering on Mastodon;
- Pasture's odd-payload inputs always answered 202 or 4xx, never 500.
Also run verify.funfedi.dev against `/peasants/privapub` and one avatar.
- **P0:** `curl` the inbox with junk and get 400/401. Resolving `127.0.0.1.nip.io` or a host with an `::1` record is
refused. Swagger answers 404 in production.
- **P1:** 500 deliveries queued for a dead host don't delay deliveries to live hosts.
- **P2:** Tusky, Elk, Phanpy and Ivory each log in with two avatars of one root as separate accounts. Home paginates
both ways; post, reply, DM, follow-by-search, notifications, CW, and edit or delete-and-redraft all work.
`tools/smoke/mastodon-api.sh` runs after every deploy. A test asserts that no response for avatar A contains avatar
B's id.
- **P3:**
- `exiftool` shows no GPS data on an uploaded original.
- Elk renders the blurhash.
- Phanpy's network tab shows no requests to remote hosts.
- Like and boost round-trip with Mastodon and GtS, and a block cuts the follow both ways.
- A Flag reaches the Mastodon admin UI.
- **P4:**
- Lemmy follows a community; posts and comments flow both ways; votes show; moderator deletes propagate.
- A Mastodon member of a circle gets a limited post. An unsigned or non-member GET answers 404.
- Two local avatars, 3 km and 8 km away, see a 5 km post correctly, and the post creates no delivery jobs.
- **Every deploy:** `/build.json` reports the commit. `privapub.thepra.dev/peasants/privapub` and NodeInfo answer.
## Risks and sequencing
- Unique indexes fail on existing duplicates, so the dedupe migration runs first. Run a `mongodump` before every
tagged deploy from P0 on.
- Merge DmPost into Post and register `PrivacyIdGenerator` **before P2** exposes ids to clients.
- The publish grows by about 20 MB (OpenIddict, NetVips.Native, FFMpegCore). Keep `deploy.yml`'s asserted file list
current, and add ffmpeg in `deploy/max/setup.sh`.
- Pasture's private-network and plain-HTTP allowances stay out of `appsettings.Production.json`, and startup asserts
they're off.
- Scale: this is several weeks of work. Each phase is a separate tagged release, and I report after each one.