R6 moderation: roles, silence and suspension, bans that write nothing on the personas, deletions, report actions

Roles (user, moderator, administrator) from the CLI and /clientapi/admin/roots/{id}/role, never one's own nor the last
administrator. Silence keeps an account's posts out of public timelines, hashtags, trends, search and relays while its
followers still get them; suspension refuses and revokes a local persona's tokens, answers 404 for its posts and
collections, holds back every delivery and tells other servers nothing new, its actor unchanged; a suspended remote
account's activities are dropped and nothing goes to its own inbox (a shared one still serves its server). A banned
login's personas are held back in memory alone (Restrictions), so nothing pairs them in stored data. Personas and groups
are deleted one at a time (PersonaRemoval, GroupRemoval), by their login or an administrator with the password. Reports
resolve with an action and a note; posts are taken down, a remote copy hidden. Warnings reach the persona as Mastodon's
moderation_warning. Every staff act goes to the staff's log. Migration _020 limits posts from servers already silenced;
a restore keeps the moderation made since the backup.

Verified: interop.sh moderation gts mastodon with SecureMode, 194 passed; restore.sh, 25 passed, now with a suspension,
a silence and a takedown made after the backup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WpVCoDeM1qUTRp4fXQdfof
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-17 05:23:45 +02:00
1 parent 70d1ffe9a2
commit 9f68ca3ed0
71 files changed
+2229 -219

No files matched your search

+120 -15
View File
@@ -83,14 +83,15 @@ PrivaPub/ ASP.NET Core Web API, net10.0
Middleware/SocialPubConfigurations.cs every DI registration (auth, federation, services, swagger, CORS)
Controllers/ClientToServer/ /clientapi/*: RootUser (signup, setup, login, invitations, recovery), PrivateAvatar,
Group, Post (posts + DMs), Social, Timeline, Data, PersonaArchive, Moderator (reports,
the staff's log), Admin (logins), DomainBlock, Statistics, Backup, InstanceAdmin (the
server's settings, thumbnail, announcements, relays)
accounts and posts moderated, the staff's log), Admin (logins: roles, bans, deletions,
two-factor resets), DomainBlock, Statistics, Backup, InstanceAdmin (the server's
settings, thumbnail, announcements, relays)
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|create-root|move-persona|delete-root|reset-totp`,
Cli/ AdminCommands (`PrivaPub admin promote|demote|role|create-root|move-persona|delete-root|reset-totp`,
`media audit`, `backup`, `backups`, `restore`)
Backup/ ServerBackup, ServerRestore, ProtectiveMerge, MaintenanceLock, TransferStore
Geo/ GeoLocator, GeoUpdater (DB-IP Lite), CdnCatalog, CdnUpdater
@@ -120,7 +121,8 @@ PrivaPub/ ASP.NET Core Web API, net10.0
Content/ ContentRenderer (Markdown or plain text → HTML with h-card mentions and hashtags;
Document: the server's own texts)
Instance/ InstanceSettings (the server's own settings in memory), MediaLimitsFromSettings
Moderation/ ModerationLog (the staff's log, AdminAction)
Moderation/ ModerationLog (the staff's log, AdminAction), AccountModeration (silence, suspend, posts
removed), Restrictions (who is held back, in memory), ModerationFlags (posts' flags again)
Social/ FollowService (local in-process, remote Follow/Accept), Notifications, Announcements
Timelines/ Fanout (TimelineEntry rows, Mastodon's home rules), TimelineService
Privacy/ VisibilityPolicy (IsPublic expression, CanSee)
@@ -140,7 +142,8 @@ PrivaPub/ ASP.NET Core Web API, net10.0
provenance; the stubs are `StubsController` in `SearchController.cs`
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, …
Services/ RootUsersService, GroupUsersService, PostsService, AppConfigurationService, RootRemoval,
PersonaRemoval and GroupRemoval (deleting a login, a persona, a group), …
Models/ Mongo entities: User/, Group/, Post/, Federation/, Jobs/, AppConfiguration, ServerSetup,
ServerSettings, AdminAction
StaticServices/ DbEntities (Find<T> accessors), AuthTokenManager (JWT), PasswordHasher
@@ -188,6 +191,7 @@ made (signing up as "admin" grants nothing):
cd /var/www/privapub.thepra.dev
echo '<password>' | ASPNETCORE_ENVIRONMENT=Production ./PrivaPub admin create-root <login> --admin # password on stdin
ASPNETCORE_ENVIRONMENT=Production ./PrivaPub admin promote|demote <root>
ASPNETCORE_ENVIRONMENT=Production ./PrivaPub admin role <root> <user|moderator|admin> # promote/demote are admin/user
ASPNETCORE_ENVIRONMENT=Production ./PrivaPub admin move-persona <persona> <root> # its link moves, its Mastodon tokens end
ASPNETCORE_ENVIRONMENT=Production ./PrivaPub admin delete-root <root> # as the client deletes a login: its personas go too
ASPNETCORE_ENVIRONMENT=Production ./PrivaPub admin reset-totp <root> # a login that lost its authenticator and codes
@@ -195,8 +199,9 @@ ASPNETCORE_ENVIRONMENT=Production ./PrivaPub admin reset-totp <root> # a logi
The deploy runs them as `build-runner`, which owns the published files, reads `appsettings.Production.json` through
group www-data and reaches the private mongod; `sudo -u www-data` works too. Every one of them that changes a login or a
persona (promote, demote, create-root, move-persona, delete-root, reset-totp) and `restore` is kept in the staff's log as
the command line's (`cli`).
persona (promote, demote, role, create-root, move-persona, delete-root, reset-totp) and `restore` is kept in the staff's
log as the command line's (`cli`). The command line may leave the server without an administrator (it says so); the
client's role route never does.
## The server's own settings (completion program R5)
@@ -262,13 +267,85 @@ the command line's (`cli`).
by their creation (their dates are spread on purpose).
- **The staff's log** (`Models/AdminAction.cs`, `Domain/Moderation/ModerationLog.cs`): who (a root id, or `cli`), the action,
the target's kind, id and label, the report, a note; never an IP address, and never a root next to its own personas
(an act on a persona the acting root owns is kept without who acted; a root's entry names none of its personas). It
(an act on a persona the acting root owns, or since R6 on a post of it, a group it owns or a report about it, is kept
without who acted; a root's entry names none of its personas). It
keeps domain blocks, backups (create, delete, restore), settings saves (the fields changed, never their values),
rules, the thumbnail, announcements, relays, report resolutions and the CLI's acts. `GET
rules, the thumbnail, announcements, relays, report resolutions, the CLI's acts and, since R6, every act of moderation
(below). `GET
/clientapi/moderator/log?before=&limit=` (`ViewAdminActionsPage`, newest first, by id): administrators read all of it
with login names; a moderator who isn't one reads what isn't about a login, named only when it acted itself or it was
the command line. Writing never fails the act it records.
## Moderation (completion program R6)
- **Roles.** A login is a user, a moderator (also a user) or an administrator (all three): `RootUser.SetRole` writes its
`Policies` (`SetAdmin` is a wrapper), and JwtEvents reads them from the database on every request. Moderators handle
reports, silence and suspend personas and accounts elsewhere, and remove posts; never logins (roles, bans, deletions,
two-factor), settings, emoji, relays, backups or statistics, which are the administrators'.
- **What an act does** (`Domain/Moderation/AccountModeration.cs`, following the `AuthorGone` pattern): the account keeps
when it was silenced or suspended (`Avatar`/`ForeignAvatar.SilencedAt`, `SuspendedAt`, `AccountState`, the staff's
`ModerationNote`), and its posts carry `Post.AuthorLimited` or `Post.AuthorSuspended`, set on what it already wrote
and on what arrives or is written later (`RemotePosts.Build`, `AnnounceHandler`, `StatusService`, archive imports), so
a query needs no join. `Restrictions` (static, in memory, reloaded after every act and when five minutes old) holds
the suspended personas, the silenced ones, and the suspended accounts elsewhere with their own inboxes.
- **Silenced** (a persona, an account elsewhere, or a server: a domain block of `Silence` limits every post of its
accounts and subdomains, on arrival and when the block is made or lifted, `AccountModeration.DomainChanged`):
`VisibilityPolicy.IsListed` keeps its posts out of the public and hashtag timelines and streams, trends, followed
hashtags, status search (but what the reader met itself), the `/tags` page and the relays; its account out of the
directory, trends and search by a part of its name (its exact name still finds it). Its followers still get it, and
anyone may still open it; the Mastodon account says `limited`.
- **Suspended, a persona** (owner decision 2026-10-17, **tells other servers nothing new**): its Mastodon tokens are
revoked and refused (`MastodonController`, 401; `TokenController` and `/oauth/authorize` give it none), its streams
close, `/clientapi` acts as it nowhere (posts, follows, groups, profile edits, archive imports; its archive may still
be exported), it delivers nothing (`DeliveryService.HeldBack`, at enqueueing and again at sending; the one exception
is the Delete of itself when it is deleted), it takes no follower (`FollowHandler` leaves the request unanswered),
its scheduled posts wait (up to a month), `IsShown` hides its posts from everyone, the Mastodon mapper shows its
account to nobody (404 for the account, its statuses, lookup), and `PeasantsController` answers 404 for everything
of it but its actor and its inbox, signed or not; the actor, WebFinger and the server's counts stay exactly as they
were. Unsuspending gives everything back but the revoked tokens. **It is warned** with Mastodon's
`moderation_warning` notification (`Notifications.Warn`: from itself, as Mastodon's are, `Notification.WarningAction`
and the moderator's `Warning` text), which it reads once it can sign in again; meanwhile its login sees it
`Suspended` in `/clientapi/avatar/private/list`. Silencing warns it too, and a removed post warns its author
(`delete_statuses`).
- **Suspended, an account elsewhere:** its posts are hidden as above, `InboxProcessor` drops what it sends (but a
`Delete` or an `Undo`, which only take away what we hold), and nothing is addressed to it or delivered to its own
inbox (its server's shared inbox still serves the others there). It is told nothing.
- **A banned login** (`/clientapi/admin/ban/users`): its sessions end and `Restrictions` holds its personas back as
suspended ones (`Counted.HeldBackPersonas`, which is `BarredPersonas` plus the suspended personas), **without writing
anything on any of them**: flagging every persona of a login at once would pair them in stored data a moderator or a
backup reads. Where posts are read one at a time `VisibilityPolicy.Shown` asks `Restrictions` (`Barred`); lists leave
them to the Mastodon mapper, which shows no account of theirs and so none of their posts. Unbanning gives them back.
- **A post removed:** a post here is deleted as its author deletes it (`StatusService.Remove`: emptied, a Delete to its
audience, unless its author is suspended); a copy of a post elsewhere gets `Post.ModeratedAt`, hidden everywhere by
`IsShown` and never taken in again (its address stays known).
- **Deleting** a persona (`Services/PersonaRemoval.cs`) or a group (`Services/GroupRemoval.cs`) deletes everything
public it had, as `RootRemoval` always did for a login's personas (and now does through PersonaRemoval): its
tokens end, a persona's groups go with it, a Delete of the actor goes to its followers, the accounts it follows and
a group's members elsewhere, its posts are emptied, its name stays reserved and its documents answer 410.
- **Routes**, every one kept in the staff's log (`persona.silence|unsilence|suspend|unsuspend|delete`,
`account.silence|unsilence|suspend|unsuspend`, `post.remove`, `report.resolve` with its action in the note,
`group.delete`, `root.role|ban|unban|delete|reset_totp`); an entry about a persona of the acting login, a post of it, a
group it owns or a report about it is kept without who acted:
- moderators (`ModeratorController`): `GET /clientapi/moderator/accounts?q=` (a name, `user@host` or an actor's address:
personas here, never their logins, and accounts elsewhere), `GET /accounts/moderated?offset=&limit=`,
`GET /accounts/{id}` (`ViewModeratedAccount`, `ViewModeratedAccountsPage`), `POST /accounts/moderate`
(`ModerateAccountForm`: `silence`, `unsilence`, `suspend`, `unsuspend`, a `Note` for the staff, a `Warning` for the
persona), `POST /posts/remove` (`RemovePostForm`), `POST /reports/{id}/resolve` (an optional `ResolveReportForm`:
`none`, `silence`, `suspend` or `remove_posts`, the act first, so one that fails leaves the report open; kept as
`Report.Resolution` and `ResolutionNote`, which `ViewReport` shows);
- administrators (`AdminController`, `Authorize(IsAdmin)`): `GET /clientapi/admin/roots?q=&before=&limit=` and
`/roots/{id}` (`ViewAdminRoot`: the one place a login is shown with its personas and the groups they own; `q` matches
a login's name or a persona's), `POST /roots/{id}/role` (`RootRoleForm`; never one's own, 409, and never the last
administrator: a demotion that leaves none is undone, 409), `POST /roots/{id}/totp/reset` (as `admin reset-totp`;
never one's own), `POST /personas/{id}/delete` and `/groups/{id}/delete` (`AdminPasswordForm`: the administrator's own
password, 403 when wrong), and the older `/ban/users`, `/unban/users`, `DELETE /remove/users`, now logged;
- the login itself: `POST /clientapi/avatar/private/delete` (`DeletePersonaForm`, its password) and the group's owner
`POST /clientapi/group/delete` (`DeleteGroupForm`, the login's password). Neither is a staff act, so neither is logged.
- **Data:** migration `_020` and every restore run `ModerationFlags.Recompute` (the posts' flags and the accounts'
`AccountState` from their moderation and the silenced servers); `ProtectiveMerge` keeps P's silences and suspensions,
here and elsewhere, and the posts taken down, before it does; indexes on `SilencedAt` and `SuspendedAt` of both account
collections and a partial one on `Post.ModeratedAt`. No new collection, so backups hold it all as they did.
## Federation invariants
1. **Every outbound request goes through `IFederationHttp`.** Its handler resolves the name itself and connects only
@@ -341,13 +418,17 @@ the command line's (`cli`).
11. **Every number about posts and users goes through `Domain/Privacy/Counted`** (owner decision 2026-10-04: one
answer everywhere): a persona's `statuses_count`, its outbox `totalItems`, NodeInfo `localPosts` and the instance
`status_count` all count Mastodon's way (not deleted, not a DM, boosts included, circle and located posts too);
users exclude personas of banned or deleted roots; `replies_count` counts only public and unlisted replies; a
users exclude personas of banned or deleted roots (a suspended persona still counts, and its posts: a count tells
nothing new of it); `replies_count` counts only public and unlisted replies; a
remote account that deletes itself takes its likes, votes, reactions, boosts and replies out of every count
(`GoneActors`). Follower and following counts are public, their members never are (`hide_collections` is always
true).
12. **Every "may anyone see this" goes through `VisibilityPolicy.IsPublic`;** a persona-specific read uses `CanSee`, and
any other read of stored posts filters by `IsShown`. All three hide deleted posts and the posts of a remote account
that deleted itself (`Post.AuthorGone`: kept, hidden everywhere, owner decision).
any other read of stored posts filters by `IsShown`. All three hide deleted posts, the posts of a remote account
that deleted itself (`Post.AuthorGone`: kept, hidden everywhere, owner decision), of a suspended author
(`AuthorSuspended`) and those the staff took down (`ModeratedAt`); a post read one at a time (`Shown`) is also hidden
when its persona's login is banned (`Restrictions`). Where strangers browse (public and hashtag timelines, trends,
search, relays) `IsListed` leaves out a silenced author's posts (`AuthorLimited`, R6).
13. **Remote content is stored only when someone here asked for it:** a persona follows the author, is addressed or
mentioned, it replies to a local post, or it is addressed to a community the author follows; a public parent is
fetched as context. Followers-only is detected by the author's stored `followers` URL.
@@ -379,7 +460,9 @@ the command line's (`cli`).
deletion, session stamp). The issued token is the same kind as the authorization code flow's, so revocation and
`RootSessions` cover it.
2. `/api/*` authenticates with OpenIddict validation, everything else with the old JWT (`PrivaPub` policy scheme).
Every `/api` request re-checks that the persona's root is neither banned nor deleted (`MastodonController`).
Every `/api` request re-checks that the persona's root is neither banned nor deleted and the persona neither deleted
nor suspended (`MastodonController`); a persona's token that can no longer act answers 401 everywhere, as a revoked
one does on Mastodon (R6).
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`.
@@ -511,7 +594,12 @@ the command line's (`cli`).
- **Deleting a root deletes everything public it had** (`RootRemoval`, from the admin route or `/clientapi/user/delete`
with the password): its sessions end, each persona and each group it owns sends `Delete{Actor}` to followers, members
and the accounts it follows, the personas' posts are emptied, and `/peasants/{name}`, its inbox and WebFinger answer
410 (`LocalActorService.Gone`) while the names stay reserved. The root keeps only a `deleted-{id}` name.
410 (`LocalActorService.Gone`) while the names stay reserved. The root keeps only a `deleted-{id}` name. One persona
or one group is deleted the same way (`PersonaRemoval`, `GroupRemoval`, R6), by its login or owner with the password,
or by an administrator with theirs.
- **Moderation never pairs a login with its personas** (R6): a ban is held in memory (`Restrictions`) and never written
on the personas or their posts; moderators see personas and accounts, never logins (`ViewModeratedAccount`), and only
administrators read a login next to its personas (`ViewAdminRoot`); the staff's log follows R5's rule.
- **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
@@ -650,7 +738,9 @@ the command line's (`cli`).
4. **the protective merge from P: a restore never undoes a protective act.** Followers and follows are P's; blocks,
mutes, domain blocks, being blocked, reserved names, `DeletedObject`, reports, filters and OAuth applications are
the union, P's row winning; roots, personas, groups, posts and remote accounts deleted since are deleted again, with
what their deletion takes away; P's moderation of a persona and a root's password, e-mail, ban and policies stay;
what their deletion takes away; P's moderation of a persona or an account elsewhere (silenced, suspended), the posts
the staff took down, and a root's password, e-mail, ban and policies stay, and every post's moderation flags are
worked out again (`ModerationFlags`);
roots, personas and groups made since become tombstones (deleted, names kept), local posts made since answer 410,
media made or trashed since go to the trash (but the server's own pictures, `ServerUse`); the staff's log
(`AdminAction`) is the union, so a restore never loses an entry, its own request included, and the server's settings
@@ -885,6 +975,21 @@ tools/pasture/run.sh down # removes e
beside the configured actor), accepted and removed without ending the configured subscription; the activity once
published; the staff's log listing it all without an address. It saves the settings it found at the start back at the
end (the description's and the policy's dates then say today).
- **Moderation (`scenarios/moderation.sh`, needs gts and mastodon; after instance in batch A, whose log check expects
the administrator's acts alone):** a second login made a moderator by `create-root` and the role route (its own role
refused, 409; a moderator refused the logins, 403; two-factor reset); a silenced persona's post out of the public,
local and hashtag timelines and search, still in a follower's home here and on Mastodon, its account `limited`, the
persona warned; a suspended persona's token 401, its account, statuses, post and lookup 404, its actor 200 and its
outbox and post 404 to a signed GET (Mastodon's `Request` through `rails runner`, since SecureMode answers unsigned
ones 401), OAuth no longer offering it, Mastodon's copy of the account untouched, then back whole (a new token) and
warned; gtsuser suspended: its post and account gone here, what it sends meanwhile dropped (Mongo), a DM to it never
sent, then its earlier post back and new ones arriving; gts.test silenced through a domain block and back; a banned
login's personas 401 and 404, a Mastodon follow left waiting (`follow_requests`), nothing written on either persona,
back once unbanned; a persona deleted by the administrator (password checked, moderators refused) gone on Mastodon and
GoToSocial, one deleted by its own login, groups deleted by their owner and by the administrator (410); posts removed
directly and through a report gone on Mastodon, the author warned, a GoToSocial copy taken down here; the staff's log
listing every act, the report's action in its note, no login entry naming a persona, none shown to the moderator, and
no address.
- **Mailpit (1.31.4, `peers/mailpit.sh`, as `mailpit.test`):** the pasture's mail server. `appsettings.Pasture.json`
sends PrivaPub's mail to `pasture-mailpit:1025` (plain SMTP, any login accepted) and its recovery links to
`https://decepub.test`. `scenarios/accounts.sh` reads what arrived through Mailpit's API: an address set with the