CLAUDE.md, a README, and the roadmap to full ActivityPub interop
Build / Build (push) Successful in 25s
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:
1 parent
075c22228a
commit
31f6015591
3 files changed
+667
-1
No files matched your search
@@ -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.
|
||||
Reference in new issue
Block a user