Moderation¶
The Moderation module is a fully standalone punishment system - kick, ban,
temp-ban, IP ban, mute, temp-mute, and warn, plus unban/unmute/remove-warn -
built entirely on the existing Module/GUI/Action/Audit/Settings
infrastructure, the same way Players is. It has no dependency on any
external moderation plugin (Essentials, LiteBans, AdvancedBan, or
otherwise); every punishment is its own row in UniversalAdmin's own
database, every mutation is an Action run through ActionExecutor (so
it's permission-checked and audited the same way regardless of frontend),
and enforcement (join-time ban check, chat-time mute check) is UniversalAdmin's
own Bukkit/Paper listeners.
Open via /admin → Moderation (needs universaladmin.moderation.use),
or the Moderate button on a player's profile page (Players module, if
both modules are enabled - see "Cross-module link" below).
This module also owns Vanish, Freeze, Godmode, No-Collision, and Staff Mode - see docs/user/modules/staff-tools.md for those. Freeze is a punishment type here (see below); the rest are self-directed staff toggles documented separately since they aren't "punish another player" actions.
Punishment model¶
Every punishment type is one row in the punishments table (see
"Database" below), including kicks and warns - "Recent Punishments" is a
single query over one table, not a union of several. A punishment record
carries: id, type, target UUID, target's last known name, target IP
(IP bans only), acting UUID/name (null actor UUID for console/system),
reason, created-at, expires-at (null = permanent), active, revoked-at,
revoked-by, and a small metadata map.
"Currently in force" is never decided by a stored flag alone - every
check (join, chat, the "Active Punishments" list) computes it live as
active AND (expires_at IS NULL OR expires_at > now). The active column
itself only changes on an explicit revoke (Unban/Unmute/Remove Warning) or
the optional hourly housekeeping sweep that flips overdue rows for cleaner
history display - never on a per-punishment scheduled timer. This is
deliberate: a fleet of Bukkit tasks, one per active temp-ban, doesn't scale
and doesn't survive a restart cleanly; a query-time expiry check does both.
Punishment types¶
| Type | Duration | Notes |
|---|---|---|
| Kick | instant | Also recorded for history, immediately inactive |
| Ban | permanent | |
| Temp Ban | temporary | Requires a duration |
| IP Ban | permanent or temporary | Captures the target's current IP - requires them to be online |
| Mute | permanent | Blocks chat, not commands |
| Temp Mute | temporary | Requires a duration |
| Warn | permanent | Removed one at a time via Remove Warning, never bulk-cleared |
| Freeze | permanent | Blocks movement/teleport/interaction/inventory/commands (configurable) - see staff-tools.md |
Issuing a Ban/IP Ban immediately kicks the target if they're online; a Mute sends them a notice instead (they stay connected, just can't chat).
Enforcement¶
- Join -
ModerationJoinListenerhandlesAsyncPlayerPreLoginEvent(before aPlayerobject even exists, off the main thread): checks for an active ban by UUID, then by IP if none was found by UUID, and denies the login with a localized message (reason + expiry, if temporary) if either matches. - Chat -
ModerationChatListenerhandles the modern Paperio.papermc.paper.event.player.AsyncChatEvent(not the deprecated BukkitAsyncPlayerChatEvent): cancels the message and notifies the player if they have an active mute.
Both listeners are pure event-to-service-call translation - the actual
"is this in force" decision lives in PunishmentService, not the listener.
GUI flow¶
Player → Moderate → Type → one form with reason, duration (only for Temp Ban/Temp Mute/IP Ban) and mute kind → Confirmation → the action runs.
- Type - one button per punishment type plus Unban/Unmute/Remove Warning, each hidden unless the viewer holds the matching permission. Unban/Unmute run immediately (no reason/duration needed to revoke); Remove Warning opens the target's warnings list so a specific one can be picked.
- The form (a Paper dialog, see ADR 0023):
- Reason - a choice of the presets (
moderation.reasons.presets, see "Settings") and a text field below it; a typed reason wins over the chosen preset. - Duration - only for types that need one; the presets
(
moderation.durations.presets) and a text field the same way, parsed byDurationParser(10m,1h,3d, compound forms like1d12h, orpermanent/perm). - Mute in - only for mutes, and only while a voice chat is connected (UniversalAdmin Voice Chat): chat and voice chat, chat only, or voice chat only - see Chat, voice and full mutes.
- Confirmation - a summary of target/reason/expiry, skipped if
gui.confirmationsis off (same setting every other confirmation in the plugin respects).
The Moderation home page (/admin → Moderation) lists Active
Punishments, Recent Punishments, Warnings, Bans (Ban/Temp
Ban/IP Ban), and Mutes (Mute/Temp Mute) - each an async, paginated,
newest-first list capped at 200 rows per query, loaded once and paginated
client-side (the same "load a bounded batch, slice in memory" shape every
other list page in this plugin uses). Clicking a row opens its detail page,
which shows the full record and, if it's still active, an Unban/Unmute/
Remove Warning button gated on the matching permission.
Commands¶
Every punishment is also a command - /admin ban Steve 7d griefing or just
/ban Steve 7d griefing (a duration right after the name turns ban/mute/
ipban temporary). /history <player> lists everything with ids,
/checkban <player> shows what is active right now, /unwarn <id> removes
a warning. Without a reason, the localized "No reason given" is used.
/chatmute and /voicemute (each with an optional duration) mute in the
chat or the voice chat only. Full list: commands.md.
Chat, voice and full mutes¶
A mute has a kind (ADR 0022):
| Kind | Command | Text chat | Voice chat |
|---|---|---|---|
| Full | /mute, /tempmute |
blocked | blocked |
| Chat only | /chatmute |
blocked | free |
| Voice chat only | /voicemute |
free | blocked |
- Without a voice chat nothing changes: a mute blocks the chat, as before.
The voice part needs UniversalAdmin Voice Chat
on every server where players talk;
/voicemuteand the "Mute in" choice only appear while it runs. - Every mute from before 1.5.0 is a full mute.
/unmutelifts every kind at once. The notice the player gets, the staff notification, the history and/checkbansay which kind it is.- Templates take
chatmute [duration]andvoicemute [duration]steps. - In a network, a mute reaches the voice chat on every server right away - the network message that announces it also refreshes the player's status there.
Punishment templates¶
Templates make punishments consistent across a team and escalate on their
own: /punish Steve spam warns Steve the first time, mutes him for an hour
the second time, and so on. They live in
plugins/UniversalAdmin/punishment-templates.yml, written on the first
start in the server's language with five examples (spam, insults,
advertising, griefing, cheating):
default-window: 90d
templates:
spam:
name: "Spam"
reason: "Spam"
steps:
- warn
- mute 1h
- mute 1d
- ban 7d
cheating:
name: "Cheating"
reason: "Unfair advantages (hacked client)"
window: 0
steps:
- ban 30d
- ban
- A step is
warn,kick,mute [duration],chatmute [duration],voicemute [duration]orban [duration]; without a duration mutes and bans are permanent. - Which step: the number of the player's earlier punishments with the
same template within the window decides - none: the first step, one: the
second, and so on; after the last step the last one repeats.
windowper template (ordefault-window, 90 days);0orforever= every earlier use counts. Lifted bans and mutes still count, removed warnings do not - removing a warning is how a mistake is taken back. - Where:
/punish <player> <template> [note](the note is added to the reason); the Punish with Template button in the moderation menu, which shows the step each template would give this player (/punish <player>opens that menu, as before 1.3.0; from the console it lists the templates instead); and/punishin the Discord bot. - Permissions:
universaladmin.moderation.punish, plus the permission of the step's punishment (warn,kick,mute/tempmute,ban/tempban) - a moderator who may not ban cannot ban through a template either; the reply says which step was refused. - Underneath a template runs the normal punishment action, so enforcement, the network, staff notifications and the audit log work as for any punishment; the punishment remembers its template (history and detail page show it).
- Changes to the file apply after
/admin reload; a broken template is skipped with a warning in the log, the others keep working. In a network, use the same file on every server - the punishments that count are shared anyway.
Alt detection¶
Off by default (moderation.alts.enabled: false). Once switched on, every
login stores a keyed hash of the address the player connected from - never
the address itself - and accounts that share one count as possible alts:
/admin alts <player>and the Possible Alts button on a player's profile list them, with how many addresses they share and when they were last seen; banned accounts are marked.- When a player joins who shares an address with a banned account, staff
with
universaladmin.moderation.altsget a hint - in a network on every server (moderation.alts.notify-staff). moderation.alts.refuse-banned-alts: truerefuses such players instead;universaladmin.bypass.alt-checklets individual players in. It is off by default for a reason: families, schools, dorms and mobile networks share addresses too.- Looking at someone's alts and deleting their addresses
(
/admin alts <player> forget,universaladmin.moderation.alts.forget) are both recorded in the audit log.
What is stored, in ua_player_addresses: the player's UUID and name, an
HMAC-SHA256 of the address - for IPv4 the whole address, for IPv6 its /64
network, because providers give every household one and devices keep
changing the rest - and when it was first and last seen. The key of the hash
is random, created once and kept in ua_alt_key, so every server of a
network computes the same hashes. Addresses not seen within
moderation.alts.retention (90 days) are deleted every hour, also while alt
detection is off. A hash is not the address, but whoever has both the
database and the key could test which address it was - there are only so
many IPv4 addresses. Treat the database as you treat the server logs, which
contain addresses anyway.
Team notifications¶
Every punishment and pardon - from the GUI, a command or an extension - is
announced to other online staff with universaladmin.moderation.notify:
"Mod banned Steve (7d): griefing". Switch it off with
moderation.notify-staff: false.
Staff notes¶
Notes are short texts staff keep about a player ("verbal warning about spawn-camping", "probably an alt of X"). They are never shown to the player.
- Profile → Staff notes lists them; Add a note writes one, right-click deletes one.
/admin notes <player>lists,/admin notes <player> add <text>adds,/admin notes remove <id>deletes.- When a player with notes joins, staff with
universaladmin.moderation.notesget a clickable hint (moderation.notes.join-alert).
Adding and removing notes is audited.
Profile button¶
The Players module's profile page shows a Moderate button that opens
this module's wizard for that player. It is contributed through the
platform's PlayerMenuRegistry - neither module imports the other, and an
extension can add its own profile buttons the same way. If Moderation is
disabled, the button simply isn't there.
Policy hook (staff hierarchy)¶
There is no rank/hierarchy system in UniversalAdmin today, and this module
does not build one - but every punishing action checks a ModerationPolicy
(canPunish(actor, type, targetId)) before persisting anything. The
default implementation allows everything; ModerationModule looks up
ServiceRegistry for an existing ModerationPolicy before falling back to
that default, so a future rank/hierarchy extension (declaring a dependency
on this module) can register its own policy and every action here picks it
up automatically, with zero hierarchy logic shipped in this module itself.
Permissions¶
| Node | Default | Meaning |
|---|---|---|
universaladmin.moderation.use |
op | Open the Moderation GUI |
universaladmin.moderation.view |
op | View punishment history, warnings, bans, and mutes |
universaladmin.moderation.kick |
op | Kick a player |
universaladmin.moderation.ban |
op | Permanently ban a player |
universaladmin.moderation.tempban |
op | Temporarily ban a player |
universaladmin.moderation.ipban |
op | Ban a player's IP address |
universaladmin.moderation.mute |
op | Permanently mute a player |
universaladmin.moderation.tempmute |
op | Temporarily mute a player |
universaladmin.moderation.warn |
op | Warn a player |
universaladmin.moderation.punish |
op | Punish with a template (each step also needs its own punishment's node) |
universaladmin.moderation.unban |
op | Revoke an active ban |
universaladmin.moderation.unmute |
op | Revoke an active mute |
universaladmin.moderation.removewarn |
op | Remove a single warning |
universaladmin.moderation.freeze |
op | Freeze a player |
universaladmin.moderation.unfreeze |
op | Unfreeze a player |
universaladmin.moderation.notes |
op | Read/write staff notes, get the join hint |
universaladmin.moderation.notify |
op | Be told when staff punish or pardon someone |
universaladmin.moderation.alts |
op | See possible alts, get the hint when an alt of a banned account joins |
universaladmin.moderation.alts.forget |
op | Delete the stored addresses of a player |
universaladmin.bypass.alt-check |
op | Join although an account sharing the address is banned (with refuse-banned-alts) |
See docs/user/permissions.md for how these fit into the platform-wide list, and staff-tools.md for Vanish/Godmode/Collision/Staff Mode's nodes.
Settings¶
| Setting | Default | Meaning |
|---|---|---|
moderation.reasons.presets |
Cheating, Griefing, Spam, Advertising, Harassment, Bug Abuse, Other | Reason presets offered in the GUI wizard |
moderation.durations.presets |
10m, 30m, 1h, 6h, 1d, 3d, 7d, 30d, permanent | Duration presets offered in the GUI wizard (and in tab completion) |
moderation.notify-staff |
true | Announce punishments/pardons to staff |
moderation.notes.join-alert |
true | Hint for staff when a player with notes joins |
moderation.alts.enabled |
false | Alt detection on/off - see Alt detection |
moderation.alts.retention |
90d | How long a login address is kept (at least 1d) |
moderation.alts.notify-staff |
true | Hint for staff when an alt of a banned account joins |
moderation.alts.refuse-banned-alts |
false | Refuse such players instead |
moderation.expiry.date-pattern |
yyyy-MM-dd HH:mm | Date format for punishment times |
moderation.expiry.show-timezone |
true | Append the server time zone to dates |
All of them are ordinary settings - editable in config.yml or in-game
under /admin → Settings → moderation. A custom reason/duration is always
available alongside the presets. Vanish, freeze and staff-mode settings are
listed in staff-tools.md.
Database¶
punishments (id, type, target_id, target_last_known_name, target_ip,
actor_id, actor_name, reason, created_at, expires_at, active, revoked_at,
revoked_by, metadata, template, mute_kind) - created by ModerationPunishmentMigration (version
1002), template added by PunishmentTemplateMigration (version 1013, with an
index on (target_id, template)), mute_kind (CHAT/VOICE/FULL, empty
reads as FULL) by MuteKindMigration (version 1016); ModerationPunishmentIndexMigration (version 1003) indexes
(target_id, type, active) for the join/chat checks, (active,
expires_at) for the housekeeping sweep, and target_ip for the IP-ban
join check. Staff notes live in ua_player_notes (PlayerNotesMigration,
version 1008); alt detection uses ua_player_addresses (player, address
hash, name, first and last seen; index on the hash) and ua_alt_key
(AltsMigration, version 1015).
Tests¶
DurationParserTest (every preset shape, compound durations, invalid
input, permanent/perm), PunishmentServiceTest (in-memory repository
fake - issuance, expiry, revoke), JdbcPunishmentRepositoryTest (real
temp SQLite - save/find/query, expiry-boundary correctness, the bulk
expiry sweep, and two punishments racing to revoke the same row
concurrently - only one may win, guarded by WHERE id = ? AND active =
TRUE on the UPDATE), and ModerationActionsWiringTest (permission/
module/validator wiring, mirroring PlayerActionRegistrarTest).