- JobQueue takes an optional scope, so a test's worker leases and reaps only its own jobs. - The dead-host delivery test runs alone (Exclusive), on its own jobs, and cleans up the breaker rows it trips; the breaker has tests of its own on unique hosts. - Index and migration tests run alone: they drop indexes and rewrite every post. - DomainBlocks.Load replaces reflection and a database-wide block in tests. - Harness.Outgoing sees only deliveries queued since the harness started: Peer ports are reused within a run, which made the circle test flaky. - Two pure-logic tests leave Mongo-gated classes, so CI runs them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ELjqpznMFMNrJoJUj6K5p2
394 lines
29 KiB
Markdown
394 lines
29 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. `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 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}` |
|
||
| 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 <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,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<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`, `Federation__AllowPlainHttp=true` and `Federation__AcceptAnyCertificate=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 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, and served only to a signed request from a member or a member's instance actor
|
||
(`SignedFetchAuthorizer`), 404 otherwise. Circles never appear in search, lookups, mentions or profile pages.
|
||
**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 "may anyone see this" goes through `VisibilityPolicy.IsPublic`;** a persona-specific read uses `CanSee`.
|
||
12. **Remote content is stored only when someone here asked for it:** a persona follows the author, is addressed or
|
||
mentioned, it replies to a local post, or it is addressed to a community the author follows; a public parent is
|
||
fetched as context. Followers-only is detected by the author's stored `followers` URL.
|
||
13. **Home timelines are written, not computed:** every stored or created post goes through `Fanout.Distribute`, and
|
||
every delete removes its `TimelineEntry` rows. Local deletes are soft (content cleared, 410 Tombstone).
|
||
|
||
## Mastodon client API invariants
|
||
|
||
1. **A token is one persona.** Its subject is the avatar id; the root id lives only in the fifteen-minute `/oauth`
|
||
cookie used while choosing the persona, and never in a token, an authorization or a response.
|
||
2. `/api/*` authenticates with OpenIddict validation, everything else with the old JWT (`PrivaPub` policy scheme).
|
||
Every `/api` request re-checks that the persona's root is neither banned nor deleted (`MastodonController`).
|
||
3. Read parameters through `Params` (query, form and JSON merged Rails-style), never MVC binding. A value type read
|
||
from a conditional must say `(int?)null`, not `default`: that bug once made every list one item long.
|
||
4. Answer with `Json(...)` (snake_case, explicit nulls) or `Error(status, message)`; page lists with `Page` and `Link`.
|
||
5. Unsupported features answer empty lists or 422 with a message, never 404 or 500, so clients degrade.
|
||
6. Advertise `4.2.0 (compatible; PrivaPub)` until grouped notifications exist.
|
||
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, 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).
|
||
- **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.**
|
||
|
||
## 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.
|
||
- **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. 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`.
|
||
- **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** (`UpdateHandler.IsEdit`). 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).
|
||
- **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<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.
|
||
- **`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 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 `InboxReceiver.Receive` with a signed request, not through the private handlers.
|
||
- 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 # podman: PrivaPub + the latest GoToSocial + Mongo, behind Caddy
|
||
tools/pasture/interop.sh # 33 checks, each side driven through its own Mastodon API
|
||
tools/pasture/run.sh down
|
||
```
|
||
|
||
- **Two sites on one podman network, one Caddy in front.** `privapub.test` and `gts.test` are network aliases of the
|
||
Caddy container, which serves both with its internal CA (`tls internal`). Both servers are told to accept any
|
||
certificate: PrivaPub through `appsettings.Pasture.json`, GoToSocial through `GTS_HTTP_CLIENT_TLS_INSECURE_SKIP_VERIFY`.
|
||
GoToSocial WebFingers and fetches 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`. GoToSocial is reached as `https://gts.test:6443`
|
||
with `curl -k --resolve gts.test:6443:127.0.0.1`, because its sign-in cookie is bound to the host name.
|
||
- **GoToSocial's 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}`.
|
||
- **GoToSocial creates its accounts locked**, so `interop.sh` approves alice's request through
|
||
`/api/v1/follow_requests`. That also checks our pending (`requested`) state and the manual Accept.
|
||
- The scenario covers:
|
||
- discovery and follows both ways;
|
||
- posts and CW;
|
||
- a reply and its notification;
|
||
- likes and boosts both ways;
|
||
- DMs both ways, and the DM staying off public timelines;
|
||
- edit, delete both ways, and unfollow.
|
||
- `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; pass a persona token as a second argument to check the signed-in side).
|
||
- **The box:** Max (`nuvola.xyz`). Unit `privapub` runs as www-data from `/var/www/privapub.thepra.dev` with
|
||
`ASPNETCORE_ENVIRONMENT=Production`.
|
||
- **One-time root setup:** `deploy/max/setup.sh`, run through `../arasaka.software/tools/max/run.sh`.
|
||
- **Config:** `appsettings.Production.json` is committed and deployed, **secrets included, by the owner's convention**
|
||
(the same as arasaka.software). A hand edit on the box is lost at the next deploy.
|
||
- **Secrets drift:** `AppConfigurationService` copies `AppConfiguration` into Mongo on first boot and reads the stored
|
||
copy afterwards, so changing those keys needs a Mongo update as well.
|
||
- **Logs:** `journalctl -u privapub`, plus the `logs` database on the private mongod.
|