Skip to content

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 - ModerationJoinListener handles AsyncPlayerPreLoginEvent (before a Player object 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 - ModerationChatListener handles the modern Paper io.papermc.paper.event.player.AsyncChatEvent (not the deprecated Bukkit AsyncPlayerChatEvent): 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 by DurationParser (10m, 1h, 3d, compound forms like 1d12h, or permanent/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.confirmations is 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; /voicemute and the "Mute in" choice only appear while it runs.
  • Every mute from before 1.5.0 is a full mute.
  • /unmute lifts every kind at once. The notice the player gets, the staff notification, the history and /checkban say which kind it is.
  • Templates take chatmute [duration] and voicemute [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] or ban [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. window per template (or default-window, 90 days); 0 or forever = 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 /punish in 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.alts get a hint - in a network on every server (moderation.alts.notify-staff).
  • moderation.alts.refuse-banned-alts: true refuses such players instead; universaladmin.bypass.alt-check lets 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.notes get 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).