init
This commit is contained in:
@@ -0,0 +1,70 @@
|
||||
# Overview
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
RanksPlugin (entry)
|
||||
+-- RanksPermissionProvider implements Hytale's PermissionProvider SPI
|
||||
| +-- RankRepository SQL: ranks, ranks_perms
|
||||
| +-- GrantRepository SQL: ranks_grants
|
||||
| +-- UserPermRepository SQL: ranks_user_perms
|
||||
| +-- RanksCache in-memory per-user effective perms + group memberships
|
||||
+-- RankService high-level business API used by commands
|
||||
+-- ChatPrefixListener PlayerChatEvent -> rewrites formatter with primary rank prefix
|
||||
+-- Commands: /rank, /grant, /tempgrant, /revoke, /primary, /ranks
|
||||
```
|
||||
|
||||
External:
|
||||
- **NetworkCore.getDatabase()** for the MySQL pool
|
||||
- **NetworkCore.getMessageBus()** for cross-server cache invalidation
|
||||
- **PermissionsModule.get()** for `addProvider`/`removeProvider`/`getProviders`
|
||||
|
||||
## Mental model: rank vs group
|
||||
|
||||
In Hytale's permission API the unit is a **group**. We call ours a **rank** in user-facing speak but they're the same thing under the hood. A `Rank` row in the DB IS a Hytale group. Every method on `PermissionProvider` operates on group IDs - we just pass `Rank.id()` through.
|
||||
|
||||
A player can hold multiple ranks at once via `Grant` rows. Hytale's `getGroupsForUser(uuid)` returns the set of active rank IDs (not revoked, not expired). The "primary" flag is OUR extension - Hytale doesn't have a notion of primary. We use it for chat prefix selection.
|
||||
|
||||
## Lifecycle
|
||||
|
||||
- `setup()` - load config.
|
||||
- `start()`:
|
||||
1. Require NetworkCore's DB + bus.
|
||||
2. Bootstrap schema; seed Hytale's built-in groups (`hytale:Adventurer`, `hytale:Admin`, etc.) if absent.
|
||||
3. Build cache + provider.
|
||||
4. If `provider.replace_default`: remove every existing provider from `PermissionsModule`, remember them, add ours.
|
||||
5. Register the chat prefix listener and all commands.
|
||||
- `shutdown()`:
|
||||
1. Remove our provider.
|
||||
2. Re-add any providers we removed at start (so we leave the module in the state we found it).
|
||||
3. Stop the cache (closes the bus subscription).
|
||||
|
||||
## File map
|
||||
|
||||
| File | Responsibility |
|
||||
|---|---|
|
||||
| [RanksPlugin.java](../src/main/java/net/kewwbec/ranks/RanksPlugin.java) | Entry point. Lifecycle, provider registration. |
|
||||
| [RanksPermissionsNodes.java](../src/main/java/net/kewwbec/ranks/RanksPermissionsNodes.java) | String constants for `networkranks.*` nodes. |
|
||||
| [config/](../src/main/java/net/kewwbec/ranks/config/) | Config record + JSON loader. |
|
||||
| [model/Rank.java](../src/main/java/net/kewwbec/ranks/model/Rank.java) | `(id, parentId, displayName, prefix, priority, builtIn)`. |
|
||||
| [model/Grant.java](../src/main/java/net/kewwbec/ranks/model/Grant.java) | One row of `ranks_grants`. |
|
||||
| [db/SchemaBootstrap.java](../src/main/java/net/kewwbec/ranks/db/SchemaBootstrap.java) | `CREATE TABLE IF NOT EXISTS` + seeds built-in groups. |
|
||||
| [db/RankRepository.java](../src/main/java/net/kewwbec/ranks/db/RankRepository.java) | CRUD on `ranks` + `ranks_perms`, includes inheritance walk. |
|
||||
| [db/GrantRepository.java](../src/main/java/net/kewwbec/ranks/db/GrantRepository.java) | CRUD on `ranks_grants`. |
|
||||
| [db/UserPermRepository.java](../src/main/java/net/kewwbec/ranks/db/UserPermRepository.java) | CRUD on `ranks_user_perms`. |
|
||||
| [provider/RanksPermissionProvider.java](../src/main/java/net/kewwbec/ranks/provider/RanksPermissionProvider.java) | Implements `PermissionProvider`. Reads cache, writes through to MySQL. |
|
||||
| [service/RanksCache.java](../src/main/java/net/kewwbec/ranks/service/RanksCache.java) | In-memory cache + cross-server invalidation publisher/subscriber. |
|
||||
| [service/InvalidationPayload.java](../src/main/java/net/kewwbec/ranks/service/InvalidationPayload.java) | Wire payload on the invalidation channel. |
|
||||
| [service/RankService.java](../src/main/java/net/kewwbec/ranks/service/RankService.java) | High-level business API used by commands. |
|
||||
| [listener/ChatPrefixListener.java](../src/main/java/net/kewwbec/ranks/listener/ChatPrefixListener.java) | Sets `PlayerChatEvent` formatter using primary rank's prefix. |
|
||||
| [command/rank/RankCommand.java](../src/main/java/net/kewwbec/ranks/command/rank/RankCommand.java) | `/rank create|delete|addperm|removeperm|setparent|setprefix|list|info`. |
|
||||
| [command/GrantCommands.java](../src/main/java/net/kewwbec/ranks/command/GrantCommands.java) | `/grant`, `/tempgrant`, `/revoke`, `/primary`, `/ranks`. |
|
||||
| [util/DurationParser.java](../src/main/java/net/kewwbec/ranks/util/DurationParser.java) | Parses `30d`, `1mo`, `1y`, etc. |
|
||||
| [util/TargetResolver.java](../src/main/java/net/kewwbec/ranks/util/TargetResolver.java) | Resolves a name (online players only) or UUID string to a UUID. |
|
||||
|
||||
## Why this shape
|
||||
|
||||
- **One provider per server, single source of truth**: replacing Hytale's default removes the risk of two providers giving conflicting answers.
|
||||
- **Cache around the provider**: hasPermission gets called many times per tick (chat enforcement, command parsing, world actions). Hitting MySQL each time would be a disaster; caching by UUID with bus-based invalidation is the standard pattern.
|
||||
- **Repositories return models, the service orchestrates**: keeps SQL out of the provider and command handlers.
|
||||
- **`built_in` flag on ranks**: stops `/rank delete hytale:Admin` from breaking the world.
|
||||
@@ -0,0 +1,57 @@
|
||||
# Configuration
|
||||
|
||||
## Location
|
||||
|
||||
`<plugin data dir>/config.json`. Written with defaults on first boot.
|
||||
|
||||
## Full config
|
||||
|
||||
```json
|
||||
{
|
||||
"tables": {
|
||||
"ranks": "ranks",
|
||||
"rank_perms": "ranks_perms",
|
||||
"user_perms": "ranks_user_perms",
|
||||
"grants": "ranks_grants"
|
||||
},
|
||||
"provider": {
|
||||
"replace_default": true
|
||||
},
|
||||
"chat": {
|
||||
"inject_prefix": true,
|
||||
"prefix_format": "{prefix} "
|
||||
},
|
||||
"cache": {
|
||||
"invalidate_channel": "ranks.invalidate"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Fields
|
||||
|
||||
### tables
|
||||
|
||||
Table names. Change only if you must coexist with another schema that already uses these names.
|
||||
|
||||
### provider
|
||||
|
||||
| Field | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `replace_default` | `true` | When true, on `start()` we remove every existing PermissionProvider from `PermissionsModule` (including Hytale's default file-backed one) and register ours as the sole authority. When false, we add ours alongside - useful for migration, but expect duplicate answers from both providers (Hytale OR's results across providers). On `shutdown()` we restore whichever providers we removed. |
|
||||
|
||||
### chat
|
||||
|
||||
| Field | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `inject_prefix` | `true` | Toggle the PlayerChatEvent formatter rewrite. False = no prefix injection regardless of rank prefix. |
|
||||
| `prefix_format` | `"{prefix} "` | Format string. `{prefix}` is replaced with the player's primary rank prefix. Result is prepended to `<username>: <message>`. |
|
||||
|
||||
### cache
|
||||
|
||||
| Field | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `invalidate_channel` | `"ranks.invalidate"` | NetworkCore MessageBus channel for cross-server cache-invalidation events. Don't share this with another plugin. |
|
||||
|
||||
## No env var overrides
|
||||
|
||||
This plugin doesn't load any secrets from env. MySQL credentials live in NetworkCore (`MYSQL_PASSWORD`).
|
||||
@@ -0,0 +1,97 @@
|
||||
# Commands
|
||||
|
||||
Player-only (no console support yet). Every command is gated by a Hytale permission node via `requirePermission(...)` in the constructor.
|
||||
|
||||
## Permissions
|
||||
|
||||
| Command | Required permission |
|
||||
|---|---|
|
||||
| `/rank create` | `networkranks.rank.create` |
|
||||
| `/rank delete` | `networkranks.rank.delete` |
|
||||
| `/rank addperm` | `networkranks.rank.edit` |
|
||||
| `/rank removeperm` | `networkranks.rank.edit` |
|
||||
| `/rank setparent` | `networkranks.rank.edit` |
|
||||
| `/rank setprefix` | `networkranks.rank.edit` |
|
||||
| `/rank list` | `networkranks.rank.list` |
|
||||
| `/rank info` | `networkranks.rank.info` |
|
||||
| `/grant` | `networkranks.grant` |
|
||||
| `/tempgrant` | `networkranks.grant` |
|
||||
| `/revoke` | `networkranks.revoke` |
|
||||
| `/primary` | `networkranks.primary` |
|
||||
| `/ranks` | `networkranks.lookup` |
|
||||
|
||||
Grant `networkranks.*` to your admin group for full control.
|
||||
|
||||
## /rank
|
||||
|
||||
Subcommand tree for managing rank definitions (not memberships).
|
||||
|
||||
### /rank create <id> <prefix> <priority>
|
||||
|
||||
Creates a new rank. `id` should be short and lowercase (e.g. `vip`, `mvp`). `prefix` is a single token (`[VIP]`, `[Mod]`). `priority` is an integer - higher means "more important" for sorting purposes.
|
||||
|
||||
The new rank has no parent and no permissions until you add them with `/rank setparent` and `/rank addperm`.
|
||||
|
||||
### /rank delete <id>
|
||||
|
||||
Deletes a rank. Built-in groups (`hytale:Adventurer`, `hytale:Admin`, etc.) cannot be deleted - the command will report "not found or is built-in".
|
||||
|
||||
Active grants of the deleted rank become orphaned. They stay in the DB but the rank lookup returns nothing, so they have no effect on permissions.
|
||||
|
||||
### /rank addperm <id> <permission>
|
||||
|
||||
Adds a permission node to the rank's direct permissions. Wildcards (`*`, `someplugin.*`) work the same way they do in Hytale.
|
||||
|
||||
### /rank removeperm <id> <permission>
|
||||
|
||||
Removes one permission. Doesn't touch inherited permissions.
|
||||
|
||||
### /rank setparent <id> <parent|none>
|
||||
|
||||
Sets a rank's parent. Permissions are inherited up the parent chain: `Admin` parent `ServerEditor` parent `WorldEditor` ... means an Admin has every permission those parents have. Pass `none` to clear the parent.
|
||||
|
||||
### /rank setprefix <id> <prefix>
|
||||
|
||||
Updates the rank's chat prefix. Takes effect immediately (cache invalidation broadcasts to all servers).
|
||||
|
||||
### /rank list
|
||||
|
||||
Lists all ranks with their priority, prefix, parent, and built-in flag.
|
||||
|
||||
### /rank info <id>
|
||||
|
||||
Shows a rank's metadata + the full list of direct permissions (not the inherited ones).
|
||||
|
||||
## Grants
|
||||
|
||||
### /grant <player> <rank>
|
||||
|
||||
Permanently grants `rank` to `player`. Player can be an online name or a UUID string (UUIDs are required for offline players, since we don't track every name we've ever seen).
|
||||
|
||||
The grant adds to the player's set of ranks; it doesn't replace existing ranks. Use `/primary` to change which one is used for display.
|
||||
|
||||
### /tempgrant <player> <rank> <duration>
|
||||
|
||||
Same as `/grant` but the grant expires after `duration`. Duration syntax: `30s`, `5m`, `2h`, `7d`, `1w`, `1mo`, `1y`. Expired grants don't count toward effective permissions.
|
||||
|
||||
### /revoke <player> <rank>
|
||||
|
||||
Marks all active grants of `(player, rank)` as revoked. Doesn't delete the row - revoked grants stay for history.
|
||||
|
||||
### /primary <player> <rank>
|
||||
|
||||
Marks `rank` as the player's primary (display) rank. Player must already have an active grant of it. Used by the chat prefix listener.
|
||||
|
||||
### /ranks <player>
|
||||
|
||||
Shows the player's active rank IDs, primary rank, and the 10 most recent grants (active, expired, and revoked).
|
||||
|
||||
## Hytale's built-in /perm and /op commands
|
||||
|
||||
Still work as before. Hytale routes them through `PermissionsModule.get()`, which routes through our provider. `/op Bob` becomes a `setUserGroup(uuid, "hytale:Admin")` call, which we implement as a fresh grant of the `hytale:Admin` rank.
|
||||
|
||||
So you have two ways to manage permissions:
|
||||
- **Hytale's commands** (`/op`, `/perm group add`, etc.) - work with the built-in `hytale:*` groups
|
||||
- **Our commands** (`/rank create vip ...`, `/grant Bob vip`) - work with both built-ins AND custom ranks you create
|
||||
|
||||
Either set of commands updates the same MySQL tables. Use whichever feels right.
|
||||
@@ -0,0 +1,130 @@
|
||||
# Schema
|
||||
|
||||
Four MySQL tables, created on `start()` via `CREATE TABLE IF NOT EXISTS`. Prefixed `ranks_` (except for `ranks` itself).
|
||||
|
||||
## ranks
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS ranks (
|
||||
id VARCHAR(64) NOT NULL,
|
||||
parent_id VARCHAR(64) NULL,
|
||||
display_name VARCHAR(64) NOT NULL,
|
||||
prefix VARCHAR(64) NOT NULL DEFAULT '',
|
||||
priority INT NOT NULL DEFAULT 0,
|
||||
built_in TINYINT(1) NOT NULL DEFAULT 0,
|
||||
created_at BIGINT NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_priority (priority)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
```
|
||||
|
||||
- `id` is the canonical group name. Hytale's built-ins use the `hytale:` prefix (e.g. `hytale:Admin`); your custom ranks should just be lowercase (e.g. `vip`).
|
||||
- `parent_id` is null or another rank id. Inheritance walks the chain.
|
||||
- `built_in = 1` rows can't be deleted via `/rank delete`. Set by the bootstrap seeding.
|
||||
|
||||
## ranks_perms
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS ranks_perms (
|
||||
rank_id VARCHAR(64) NOT NULL,
|
||||
permission VARCHAR(255) NOT NULL,
|
||||
PRIMARY KEY (rank_id, permission),
|
||||
KEY idx_rank (rank_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
```
|
||||
|
||||
Direct permissions for each rank. `*` is the wildcard (`hytale:Admin` has it by default).
|
||||
|
||||
## ranks_user_perms
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS ranks_user_perms (
|
||||
user_uuid CHAR(36) NOT NULL,
|
||||
permission VARCHAR(255) NOT NULL,
|
||||
PRIMARY KEY (user_uuid, permission)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
```
|
||||
|
||||
Direct (per-user) permissions, bypassing ranks. We don't expose commands for this in v1 - it exists because the `PermissionProvider` SPI requires it and Hytale's `/perm user add` writes here through `addUserPermissions`.
|
||||
|
||||
## ranks_grants
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS ranks_grants (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT,
|
||||
user_uuid CHAR(36) NOT NULL,
|
||||
rank_id VARCHAR(64) NOT NULL,
|
||||
granted_at BIGINT NOT NULL,
|
||||
granted_by_uuid CHAR(36) NOT NULL,
|
||||
granted_by_name VARCHAR(64) NOT NULL,
|
||||
expires_at BIGINT NULL,
|
||||
primary_rank TINYINT(1) NOT NULL DEFAULT 0,
|
||||
revoked TINYINT(1) NOT NULL DEFAULT 0,
|
||||
revoked_at BIGINT NULL,
|
||||
revoked_by_uuid CHAR(36) NULL,
|
||||
revoked_by_name VARCHAR(64) NULL,
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_user (user_uuid),
|
||||
KEY idx_user_active (user_uuid, revoked, expires_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
```
|
||||
|
||||
Each grant is a separate row. Revokes don't delete - they flip `revoked = 1` and fill the `revoked_*` columns. Useful for "who took my rank away" forensics.
|
||||
|
||||
A user can have multiple active rows of the same rank. `getActiveRankIds` uses `DISTINCT rank_id` so the set is unique.
|
||||
|
||||
`primary_rank` flag is OUR extension on top of Hytale's group model. Only one row per user should have it set; `RankService.setPrimary` enforces this by clearing first.
|
||||
|
||||
## Built-in group seeding
|
||||
|
||||
On first boot, `SchemaBootstrap.seedBuiltInGroups` inserts these rows if absent:
|
||||
|
||||
| id | parent_id | display | built_in |
|
||||
|---|---|---|---|
|
||||
| `hytale:None` | null | None | 1 |
|
||||
| `hytale:Adventurer` | `hytale:None` | Adventurer | 1 |
|
||||
| `hytale:Builder` | `hytale:Adventurer` | Builder | 1 |
|
||||
| `hytale:WorldEditor` | `hytale:Builder` | WorldEditor | 1 |
|
||||
| `hytale:ServerEditor` | `hytale:WorldEditor` | ServerEditor | 1 |
|
||||
| `hytale:Admin` | `hytale:ServerEditor` | Admin | 1 |
|
||||
|
||||
And one permission row: `hytale:Admin` → `*` (wildcard).
|
||||
|
||||
This mirrors what Hytale's `HytalePermissionsProvider` ships with - so `/op` continuing to grant Admin and Admin having all perms keeps working without any extra setup.
|
||||
|
||||
## Migrations
|
||||
|
||||
There aren't any yet. If you change the schema, add a `migrations/00X_what.sql` and a small runner in `SchemaBootstrap` that tracks applied migrations in a `ranks_schema_version` table. Don't reach for Flyway/Liquibase until you have at least three migrations.
|
||||
|
||||
## Useful queries
|
||||
|
||||
Active ranks of a player:
|
||||
|
||||
```sql
|
||||
SELECT DISTINCT rank_id
|
||||
FROM ranks_grants
|
||||
WHERE user_uuid = ?
|
||||
AND revoked = 0
|
||||
AND (expires_at IS NULL OR expires_at > UNIX_TIMESTAMP() * 1000)
|
||||
ORDER BY primary_rank DESC, rank_id;
|
||||
```
|
||||
|
||||
How many players hold each rank right now:
|
||||
|
||||
```sql
|
||||
SELECT rank_id, COUNT(DISTINCT user_uuid) AS holders
|
||||
FROM ranks_grants
|
||||
WHERE revoked = 0
|
||||
AND (expires_at IS NULL OR expires_at > UNIX_TIMESTAMP() * 1000)
|
||||
GROUP BY rank_id
|
||||
ORDER BY holders DESC;
|
||||
```
|
||||
|
||||
All-time grants by staff member (audit):
|
||||
|
||||
```sql
|
||||
SELECT granted_at, user_uuid, rank_id, expires_at
|
||||
FROM ranks_grants
|
||||
WHERE granted_by_name = 'StaffName'
|
||||
ORDER BY granted_at DESC;
|
||||
```
|
||||
@@ -0,0 +1,95 @@
|
||||
# Provider Integration
|
||||
|
||||
How we plug into Hytale's permission system without Hytale knowing we're there.
|
||||
|
||||
## The SPI
|
||||
|
||||
```java
|
||||
package com.hypixel.hytale.server.core.permissions.provider;
|
||||
|
||||
public interface PermissionProvider {
|
||||
String getName();
|
||||
void addUserPermissions(UUID, Set<String>);
|
||||
void removeUserPermissions(UUID, Set<String>);
|
||||
Set<String> getUserPermissions(UUID);
|
||||
void addGroupPermissions(String, Set<String>);
|
||||
void removeGroupPermissions(String, Set<String>);
|
||||
Set<String> getGroupPermissions(String);
|
||||
void addUserToGroup(UUID, String);
|
||||
void removeUserFromGroup(UUID, String);
|
||||
Set<String> getGroupsForUser(UUID);
|
||||
void setUserGroup(UUID, String);
|
||||
String getGroupParent(String);
|
||||
Set<String> getAllRegisteredGroups();
|
||||
Set<String> getEffectiveGroupPermissions(String);
|
||||
}
|
||||
```
|
||||
|
||||
That's the contract. Our [RanksPermissionProvider](../src/main/java/net/kewwbec/ranks/provider/RanksPermissionProvider.java) implements every method.
|
||||
|
||||
## The module's API
|
||||
|
||||
`PermissionsModule.get()` exposes:
|
||||
|
||||
```java
|
||||
void addProvider(PermissionProvider);
|
||||
void removeProvider(PermissionProvider);
|
||||
List<PermissionProvider> getProviders();
|
||||
PermissionProvider getFirstPermissionProvider();
|
||||
boolean areProvidersTampered();
|
||||
```
|
||||
|
||||
When a `hasPermission(uuid, node)` call comes in, the module consults the providers in order. If you've replaced the default with ours, only ours answers. If you've left ours alongside the default, the module's logic combines them (typically OR for "has the permission").
|
||||
|
||||
## How we register
|
||||
|
||||
`RanksPlugin.start()`:
|
||||
|
||||
```java
|
||||
PermissionsModule perms = PermissionsModule.get();
|
||||
|
||||
if (config.provider.replace_default) {
|
||||
for (PermissionProvider existing : new ArrayList<>(perms.getProviders())) {
|
||||
if (!RanksPermissionProvider.NAME.equals(existing.getName())) {
|
||||
perms.removeProvider(existing);
|
||||
removedProviders.add(existing); // remember to restore on shutdown
|
||||
}
|
||||
}
|
||||
}
|
||||
perms.addProvider(provider);
|
||||
```
|
||||
|
||||
On `shutdown()` we reverse: remove ours, re-add anything we removed. This is so a clean reload of our plugin doesn't leave the server permission-less.
|
||||
|
||||
## What "replace" actually changes
|
||||
|
||||
When `replace_default = true`:
|
||||
|
||||
- Hytale's file-backed provider (which stores groups/users in JSON on disk) is removed.
|
||||
- Its data is **not** copied into MySQL automatically. If you had pre-existing OPs from the file, they're gone the moment we register (until you reassign them via `/op` or `/grant`, which now writes to MySQL).
|
||||
- If you ever uninstall this plugin, the file-backed provider is restored - but your MySQL data stays in MySQL, so it won't be visible to that provider.
|
||||
|
||||
If you have existing OPs you need to preserve:
|
||||
|
||||
1. Before installing Ranks, write down who's in which group from the Hytale file.
|
||||
2. Install Ranks, boot once - this creates the schema and seeds the built-in groups.
|
||||
3. Run `/op Bob`, `/op Alice` etc. once for each existing OP - this writes to our DB.
|
||||
|
||||
For larger migrations (lots of users, custom groups), a one-off SQL import is the right move; we don't ship one because every server's pre-existing state is different.
|
||||
|
||||
## Hytale's `/op`, `/perm` commands
|
||||
|
||||
Look at the `commands/` package under `com.hypixel.hytale.server.core.permissions` in the SDK. Each one calls into `PermissionsModule` which routes to providers. With our provider registered, those commands now read/write our MySQL.
|
||||
|
||||
This is the main reason "replace default" is the recommended mode: it means there's only one source of truth, and all the existing Hytale tooling keeps working.
|
||||
|
||||
## Conflicts to watch
|
||||
|
||||
- If you set `areProvidersTampered()` somehow gets flagged, Hytale might warn about non-default providers. We are intentionally replacing; the warning is expected.
|
||||
- If another plugin also registers a `PermissionProvider`, the load order matters. We register in `start()`. If a later-loading plugin also calls `addProvider`, we coexist as providers 1 and 2. The module decides who answers first.
|
||||
|
||||
## Caching considerations
|
||||
|
||||
Every `hasPermission` call goes through our provider. We aggressively cache (see [06_Cross_Server_Caching.md](06_Cross_Server_Caching.md)). The first call after a cache miss hits MySQL; subsequent calls are in-memory.
|
||||
|
||||
If the cache gets stale (e.g. the bus drops an invalidation message), permissions can drift for up to whatever interval the player stays connected. To force-refresh: revoke and re-grant their rank (which invalidates), or restart the server.
|
||||
@@ -0,0 +1,77 @@
|
||||
# Cross-Server Caching
|
||||
|
||||
How a `/grant` on lobby-1 takes effect on arena-3 within milliseconds.
|
||||
|
||||
## The cache
|
||||
|
||||
[RanksCache](../src/main/java/net/kewwbec/ranks/service/RanksCache.java) keeps two maps:
|
||||
|
||||
- `userPermsCache: UUID -> Set<String>` - effective permissions per user (direct + all rank perms walked via inheritance)
|
||||
- `userGroupsCache: UUID -> Set<String>` - active rank IDs per user
|
||||
|
||||
`RanksPermissionProvider.getUserPermissions(uuid)` checks the cache first, falls back to MySQL, then populates the cache. `getGroupsForUser(uuid)` works the same way.
|
||||
|
||||
The cache is unbounded. In practice it holds an entry per online player plus a few minutes worth of recent logouts (they age out only via invalidation).
|
||||
|
||||
## Invalidation
|
||||
|
||||
Three reasons to invalidate:
|
||||
|
||||
1. **One user changed** - e.g. `/grant Bob vip`. Drop Bob's cached entries. Other users untouched.
|
||||
2. **A rank changed** - e.g. `/rank addperm vip mycommand.use`, or `/rank setparent`. Anyone holding `vip` (transitively) is affected. Targeted invalidation is hard because of inheritance, so we drop the whole cache.
|
||||
3. **Bulk reset** - e.g. a full reload after manual SQL edits. Drop everything.
|
||||
|
||||
Each `service.grant/revoke/setPrimary/createRank/...` call funnels through methods like `cache.invalidateUser(uuid)` or `cache.invalidateRank(id)`.
|
||||
|
||||
## Cross-server propagation
|
||||
|
||||
The cache also publishes an [InvalidationPayload](../src/main/java/net/kewwbec/ranks/service/InvalidationPayload.java) on NetworkCore's `ranks.invalidate` channel for every invalidation. Other servers subscribe, receive the message, and apply the same drop locally.
|
||||
|
||||
```
|
||||
server A: /grant Bob vip
|
||||
-> grants table UPDATE
|
||||
-> cache.invalidateUser(bob)
|
||||
-> local: userPermsCache.remove(bob)
|
||||
-> publish on bus: {userUuid: bob, fromServerId: 'lobby-1'}
|
||||
|
||||
server B: receives bus message
|
||||
-> filter: fromServerId != self, so process
|
||||
-> local: userPermsCache.remove(bob)
|
||||
```
|
||||
|
||||
`fromServerId` filtering prevents servers from re-applying their own invalidations.
|
||||
|
||||
## Timing
|
||||
|
||||
| Step | Approximate latency |
|
||||
|---|---|
|
||||
| Local cache drop | microseconds |
|
||||
| MySQL UPDATE for the grant | ~5 ms (varies with DB) |
|
||||
| MessageBus publish (Redis pub/sub) | sub-millisecond |
|
||||
| Receiver processes invalidation | ~1 ms |
|
||||
| Receiver's next hasPermission call | hits MySQL once (cache miss), ~5-10 ms |
|
||||
|
||||
Total time from `/grant` to "Bob's hasPermission on server B returns true": typically under 20 ms.
|
||||
|
||||
## Failure modes
|
||||
|
||||
| What | What happens | Recovery |
|
||||
|---|---|---|
|
||||
| MySQL is down on issuing server | The grant write fails; nothing happens. Tell the player. | Bring MySQL back, retry. |
|
||||
| MessageBus is down on issuing server | Local cache invalidated but bus publish fails silently. Other servers don't drop their cache. Other servers serve stale perms until their cache entry is naturally replaced (or until a separate event invalidates them). | Bring Redis back. To force-resync remote caches: restart them (drains the cache). |
|
||||
| Receiver MessageBus is down | The receiver misses invalidations. Same drift as above. | Same. |
|
||||
| Receiver applies invalidation but the next `getUserPermissions` query against MySQL is racy (transaction not yet committed) | Receiver caches a slightly-stale set. Next invalidation arrives, drops it again, fresh read. Eventually consistent. | None needed - self-corrects on next change. |
|
||||
|
||||
The "stale until next change" failure mode is the only inconsistency window. It's narrow (sub-second usually) and self-heals.
|
||||
|
||||
## No periodic refresh
|
||||
|
||||
We do NOT do "expire cache entries after N seconds" - that would mean every player's perms are re-fetched constantly. The cache is purely invalidation-driven. This is a tradeoff: a permanently-lost invalidation message leaves stale state forever. Mitigation: NetworkCore's `network.auth_token` and reliable Redis pub/sub mean lost messages are rare.
|
||||
|
||||
If you ever need belt-and-suspenders, add a periodic full-cache-clear (e.g. every 10 min) somewhere in `start()`. Not in v1.
|
||||
|
||||
## Looking under the hood
|
||||
|
||||
`SELECT * FROM ranks_grants WHERE user_uuid = ? AND revoked = 0 ORDER BY granted_at DESC` shows you the source of truth for a user.
|
||||
|
||||
For a live look at what the cache currently holds, you'd need to add a debug command - we don't ship one. Add a `/rank cache <player>` subcommand later if it becomes useful.
|
||||
@@ -0,0 +1,93 @@
|
||||
# Operations
|
||||
|
||||
## Smoke test after deploy
|
||||
|
||||
1. `/networkcore status` shows "Database: ok" and "Network: connected".
|
||||
2. Boot log shows `Replaced default provider: <something>` and `Registered Ranks provider (kewwbec-ranks)`.
|
||||
3. `/rank list` returns at least the seven built-in groups (`hytale:None`, `hytale:Adventurer`, ..., `hytale:Admin`).
|
||||
4. `/rank create vip [VIP] 50` then `/rank info vip` shows it back.
|
||||
5. `/grant <your-alt> vip`. From the alt, send a chat message - prefix `[VIP] ` should appear.
|
||||
6. `/primary <your-alt> vip` (only needed if the alt has multiple ranks; with just `vip` it's already primary).
|
||||
7. `/revoke <your-alt> vip` - prefix disappears.
|
||||
|
||||
## Permissions don't seem to apply
|
||||
|
||||
1. `/ranks <player>` - is the rank in the active list? If no, the grant didn't take.
|
||||
2. `/rank info <rank-id>` - does the rank actually have the permission you expected?
|
||||
3. Check inheritance: if `vip` has no perm but its parent `default` does, the player should still have it via inheritance.
|
||||
4. The cache. After a `/grant`, the player's cache entry is invalidated locally and a bus message is sent. If the server they're on missed the bus message, their cache is stale. Quick fix: have them reconnect, or restart that server.
|
||||
|
||||
## `/op Bob` did nothing visible
|
||||
|
||||
It writes to MySQL but doesn't tell anyone. Verify:
|
||||
|
||||
```sql
|
||||
SELECT * FROM ranks_grants
|
||||
WHERE user_uuid = '<bob-uuid>' AND rank_id = 'hytale:Admin'
|
||||
ORDER BY granted_at DESC LIMIT 1;
|
||||
```
|
||||
|
||||
If you see the row, `/op` succeeded - Bob needs to reconnect for client-side perm changes to apply (Hytale reads perms on connect for many things).
|
||||
|
||||
## Chat prefixes aren't showing
|
||||
|
||||
1. `inject_prefix` is true in config?
|
||||
2. The player has a primary rank? `/ranks <name>` and check the "Primary:" line.
|
||||
3. The primary rank has a non-empty `prefix`? `/rank info <rank>`.
|
||||
4. The chat event isn't being cancelled by something else (e.g. mute from Staff). If muted, the formatter rewrite is irrelevant - the message never sends.
|
||||
|
||||
## A rank's permissions don't apply to existing holders
|
||||
|
||||
You added a perm with `/rank addperm`. Existing holders need their cache invalidated to pick it up. `addRankPermission` does `cache.invalidateRank(id)` which clears all caches and broadcasts. If they STILL don't see it:
|
||||
|
||||
1. Check the boot log - did our cache subscription succeed?
|
||||
2. `redis-cli -h <host> MONITOR` while you re-run `/rank addperm`. You should see a PUBLISH on `ranks.invalidate`. If not, the publish failed.
|
||||
3. Worst case: bounce the server they're on. Cache rebuilds on next call.
|
||||
|
||||
## Built-in groups missing
|
||||
|
||||
If `/rank list` doesn't show the `hytale:*` groups, schema bootstrap didn't seed them (or someone deleted them via raw SQL). To re-seed:
|
||||
|
||||
```sql
|
||||
INSERT IGNORE INTO ranks (id, parent_id, display_name, prefix, priority, built_in, created_at) VALUES
|
||||
('hytale:None', NULL, 'None', '', 0, 1, UNIX_TIMESTAMP() * 1000),
|
||||
('hytale:Adventurer', 'hytale:None', 'Adventurer', '', 10, 1, UNIX_TIMESTAMP() * 1000),
|
||||
('hytale:Builder', 'hytale:Adventurer', 'Builder', '', 20, 1, UNIX_TIMESTAMP() * 1000),
|
||||
('hytale:WorldEditor', 'hytale:Builder', 'WorldEditor', '', 30, 1, UNIX_TIMESTAMP() * 1000),
|
||||
('hytale:ServerEditor', 'hytale:WorldEditor', 'ServerEditor', '', 40, 1, UNIX_TIMESTAMP() * 1000),
|
||||
('hytale:Admin', 'hytale:ServerEditor', 'Admin', '', 100, 1, UNIX_TIMESTAMP() * 1000);
|
||||
|
||||
INSERT IGNORE INTO ranks_perms (rank_id, permission) VALUES ('hytale:Admin', '*');
|
||||
```
|
||||
|
||||
Then invalidate all caches by restarting any one server (it'll broadcast nothing useful but its own cache rebuilds, and you can run `/rank reload` if we ever add one).
|
||||
|
||||
## Auditing
|
||||
|
||||
Recent grants by any moderator:
|
||||
|
||||
```sql
|
||||
SELECT granted_at, granted_by_name, user_uuid, rank_id, expires_at
|
||||
FROM ranks_grants
|
||||
ORDER BY granted_at DESC LIMIT 50;
|
||||
```
|
||||
|
||||
All grants of a specific rank to anyone, ever:
|
||||
|
||||
```sql
|
||||
SELECT * FROM ranks_grants WHERE rank_id = 'vip' ORDER BY granted_at DESC;
|
||||
```
|
||||
|
||||
Players with active permanent grants of `hytale:Admin`:
|
||||
|
||||
```sql
|
||||
SELECT DISTINCT user_uuid FROM ranks_grants
|
||||
WHERE rank_id = 'hytale:Admin' AND revoked = 0 AND expires_at IS NULL;
|
||||
```
|
||||
|
||||
## Things this plugin deliberately doesn't do
|
||||
|
||||
- **No /perm reload-style admin command.** Cache invalidation is implicit on every write; a manual reload is rarely needed. If you need one, add a small command that calls `cache.invalidateAll()`.
|
||||
- **No per-world permissions.** Hytale's group model is global. If you need per-world, you'd need a separate layer that filters by world before checking - out of scope.
|
||||
- **No GUI for managing ranks.** Could be a Staff-style HyUI page. Not in v1.
|
||||
- **No automatic OP backup before replacing default.** If you have important OPs in the file provider, document them yourself before installing.
|
||||
@@ -0,0 +1,9 @@
|
||||
# Ranks Docs
|
||||
|
||||
1. [01_Overview.md](01_Overview.md) - architecture and pieces
|
||||
2. [02_Configuration.md](02_Configuration.md) - config.json + env vars
|
||||
3. [03_Commands.md](03_Commands.md) - every command + permission nodes
|
||||
4. [04_Schema.md](04_Schema.md) - MySQL tables, built-in group seeding
|
||||
5. [05_Provider_Integration.md](05_Provider_Integration.md) - how we replace Hytale's permission provider
|
||||
6. [06_Cross_Server_Caching.md](06_Cross_Server_Caching.md) - cache + invalidation flow
|
||||
7. [07_Operations.md](07_Operations.md) - ops scenarios, debugging
|
||||
Reference in New Issue
Block a user