The route table lists the themed names now in use, the repo map gains Jobs, Ids, Moderation, Domain and Web, and the invariants add the job queue, VisibilityPolicy and the rule for which remote posts are stored. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
239 lines
16 KiB
Markdown
239 lines
16 KiB
Markdown
# 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 <root>`)
|
|
RateLimiting.cs accounts (per client address) and inbox (per sending origin) policies
|
|
Federation/
|
|
Controllers/ PeasantsController (actor, outbox, followers, following, posts, inboxes),
|
|
WellKnownController (webfinger, nodeinfo), UsersController (redirect)
|
|
Actors/ LocalActorService (LocalActor, Keys, ReservedName), RemoteActorService
|
|
(authoritative fetch, key verification, WebFinger), ActorDocument (parser)
|
|
Objects/ Origin, ActivityJson, NoteParser, Addressing, ContentSanitizer
|
|
Moderation/ DomainBlocks (suspend / silence / reject media)
|
|
Signing/ HttpSignatures (draft-cavage sign/verify)
|
|
Inbox/ InboxReceiver (verify, queue, 202) → InboxProcessor (job) → Handlers/{Follow,Undo,Create,Update,Delete}
|
|
Outbox/ 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)
|
|
Privacy/ VisibilityPolicy (IsPublic expression, CanSee)
|
|
Web/Pages/ Razor: /@{user}, /@{user}/{id} (public posts only, strict CSP, noindex)
|
|
Services/ RootUsersService, GroupUsersService, PostsService, AppConfigurationService, …
|
|
Models/ Mongo entities: User/, Group/, Post/, Federation/, Jobs/, AppConfiguration
|
|
StaticServices/ DbEntities (Find<T> accessors), AuthTokenManager (JWT), PasswordHasher
|
|
Data/InitDb.cs first-run seeding (languages)
|
|
PrivaPub.ClientModels/ DTOs + validation resources shared with clients
|
|
PrivaPub.Tests/ xUnit v3; Support/ has a fake two-origin peer and a throwaway-database fixture
|
|
deploy/ nginx vhost, systemd units (privapub, privapub-mongod), max/setup.sh
|
|
.gitea/workflows/ build.yml (push) · deploy.yml (tag v*)
|
|
docs/ROADMAP.md decisions + phased plan
|
|
```
|
|
|
|
The roadmap moves code towards `Infrastructure/`, `Federation/`, `Domain/`, `Api/{ClientApi,Mastodon}` and `Web/`,
|
|
one pure-move commit at a time.
|
|
|
|
## Commands
|
|
|
|
```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 <root>
|
|
```
|
|
|
|
## Federation invariants
|
|
|
|
1. **Every outbound request goes through `IFederationHttp`.** Its handler resolves the name itself and connects only
|
|
to public addresses; redirects are followed by hand (three at most, each re-checked); bodies are capped at 1 MB;
|
|
only JSON media types are read; a refused URL is not asked again for five minutes. Never create another
|
|
`HttpClient` for federation.
|
|
2. **Every fetch is signed by the instance actor** (`privapub`), never by a persona; deliveries are signed by the acting
|
|
avatar or group. Both are draft-cavage rsa-sha256 over `(request-target) host date` (+ `digest` on bodies).
|
|
3. **A remote document is believed only from its own address.** `RemoteActorService.FetchObject` requires the
|
|
document's `id` to be the URL it was served from (a same-origin alias is followed once). A key is accepted only if
|
|
the actor lists it, its `owner` is the actor and it shares the actor's origin.
|
|
4. **Inbound inboxes verify everything before acting:**
|
|
- the signature covers `(request-target)`, `host`, `digest` and `date` or `(created)`;
|
|
- the Digest matches the body and the date is at most an hour old and fifteen minutes ahead;
|
|
- the signature verifies against the key owner's key, and the activity's `actor` is the key owner;
|
|
- the activity's `id`, and any object it creates, updates or deletes, is on the actor's origin; a cross-origin
|
|
object is refetched from its own origin.
|
|
5. **Status codes:**
|
|
- bad or missing signature: **401**;
|
|
- malformed or forged body: **400**;
|
|
- accepted: **202**;
|
|
- over the rate limit: **429**;
|
|
- **never 500** from `/peasants` or an inbox. Peers retry or "double-knock" based on these codes.
|
|
6. **Actor documents:**
|
|
- served as `application/activity+json`;
|
|
- `publicKey` is an SPKI PEM at `{actor}#main-key`, with `owner` equal to the actor's id;
|
|
- the shared inbox is advertised in `endpoints.sharedInbox`.
|
|
7. **Remote HTML is sanitized before it is stored** (`ContentSanitizer`); `Post.ContentHtml` is what is shown,
|
|
`ContentFormat` says what `Text` holds. Remote names are plain text.
|
|
8. **Circles 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 local persona addressed or mentioned, a reply to
|
|
a local post, or a community the author follows. Followers-only is detected by the author's stored `followers` URL.
|
|
|
|
## 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.
|
|
- **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.
|
|
- **Startup order:** `EntityMaps.Warm()` (every entity's class map, one at a time; two mapped at once throw "An item
|
|
with the same key has already been added" and stay broken), then `MigrateAsync` (`Infrastructure/Data/Migrations`,
|
|
`_NNN_` order, each runs once), then `Indexes.Create()`. A new entity needs nothing; a new unique index needs a
|
|
dedupe migration before it.
|
|
- Production runs its **own mongod** on 127.0.0.1:27022 (unit `privapub-mongod`, no auth, data in
|
|
`/var/lib/privapub/mongo`). The box's shared mongod needs credentials nobody here has.
|
|
|
|
## Code style
|
|
|
|
- Tabs, block-scoped namespaces, Allman braces.
|
|
- New services use `readonly _camel` fields and constructor injection. Older services use PascalCase fields; leave
|
|
them.
|
|
- Services return `WebResult` (`PrivaPub.ClientModels/WebResult.cs`): `result.Invalidate(localizer[...], status)`.
|
|
Controllers turn it into a status code.
|
|
- Localised strings go through `IStringLocalizer<GenericRes>`.
|
|
- Every `Display`/`ErrorMessage` resource key must exist in `FieldsNameResource`/`ErrorsResource`, including the
|
|
`Designer.cs`, which the CLI build doesn't regenerate. A missing key throws at validation time.
|
|
- 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).
|
|
- **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.
|