From 0cc8a42e6cb1976c6be30b9e815da5ff63a7b853 Mon Sep 17 00:00:00 2001
From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com>
Date: Wed, 7 Oct 2026 13:39:15 +0000
Subject: [PATCH 1/6] LuckPerms in the staff panel: mirror, change outbox and
rights
The TFMCWeb bridge publishes LuckPerms snapshots to the site and applies
changes staff queue there. Mods view; admins change the groups and
permissions policy.yaml allows on players; root changes everything,
including group definitions and tracks. Roots can also make other
accounts root from the panel.
Co-Authored-By: Claude Opus 5.5 (1M context)
---
backend/src/api/admin_routes.py | 7 +-
backend/src/api/luckperms_routes.py | 122 ++++++
backend/src/auth/admin_cli.py | 5 +-
backend/src/auth/roles.py | 14 +-
backend/src/auth/test_admin.py | 13 +-
backend/src/luckperms/README.md | 123 ++++++
backend/src/luckperms/__init__.py | 0
backend/src/luckperms/changes.py | 528 ++++++++++++++++++++++++
backend/src/luckperms/conftest.py | 18 +
backend/src/luckperms/mirror.py | 366 ++++++++++++++++
backend/src/luckperms/nodes.py | 152 +++++++
backend/src/luckperms/policy.py | 75 ++++
backend/src/luckperms/policy.yaml | 41 ++
backend/src/luckperms/test_luckperms.py | 407 ++++++++++++++++++
backend/src/luckperms/views.py | 229 ++++++++++
backend/src/skins/schema.sql | 82 ++++
16 files changed, 2171 insertions(+), 11 deletions(-)
create mode 100644 backend/src/api/luckperms_routes.py
create mode 100644 backend/src/luckperms/README.md
create mode 100644 backend/src/luckperms/__init__.py
create mode 100644 backend/src/luckperms/changes.py
create mode 100644 backend/src/luckperms/conftest.py
create mode 100644 backend/src/luckperms/mirror.py
create mode 100644 backend/src/luckperms/nodes.py
create mode 100644 backend/src/luckperms/policy.py
create mode 100644 backend/src/luckperms/policy.yaml
create mode 100644 backend/src/luckperms/test_luckperms.py
create mode 100644 backend/src/luckperms/views.py
diff --git a/backend/src/api/admin_routes.py b/backend/src/api/admin_routes.py
index 0c743d23..0287732a 100644
--- a/backend/src/api/admin_routes.py
+++ b/backend/src/api/admin_routes.py
@@ -185,6 +185,7 @@ def post_revoke_sessions(user_id: int, body: ReasonBody, request: Request, respo
_WRITE_ROUTES = (
(re.compile(r"^/admin/accounts/([^/]+)/role$"), "account.role.change"),
(re.compile(r"^/admin/accounts/([^/]+)/sessions/revoke$"), "account.sessions.revoke"),
+ (re.compile(r"^/admin/luckperms/changes$"), "luckperms.change"),
)
@@ -203,7 +204,7 @@ def _audit_invalid_write(request: Request, exc: RequestValidationError) -> None:
return
if actor is None:
return
- raw_id = match.group(1)
+ raw_id = match.group(1) if match.groups() else ""
fields = sorted({".".join(str(part) for part in error.get("loc", ())[1:]) for error in exc.errors()})
audit.record_refusal(
actor=actor, action=action, outcome="invalid",
@@ -219,5 +220,9 @@ async def _validation_handler(request: Request, exc: RequestValidationError):
def install(app: FastAPI) -> None:
"""Mount the staff routes and audit their malformed writes."""
+ from src.api.luckperms_routes import plugin_router, staff_router
+
app.include_router(admin_router)
+ app.include_router(staff_router)
+ app.include_router(plugin_router)
app.add_exception_handler(RequestValidationError, _validation_handler)
diff --git a/backend/src/api/luckperms_routes.py b/backend/src/api/luckperms_routes.py
new file mode 100644
index 00000000..a46267ed
--- /dev/null
+++ b/backend/src/api/luckperms_routes.py
@@ -0,0 +1,122 @@
+"""LuckPerms in the staff panel, and the TFMCWeb bridge that feeds and applies it.
+
+Plugin routes need the primary plugin key: LuckPerms storage is shared, so
+only one server applies changes. Staff routes follow admin_routes: reads need
+a staff role, writes also need the site's own origin, and refusals by a
+signed-in caller are audited.
+"""
+from __future__ import annotations
+
+from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
+from pydantic import BaseModel, Field
+from starlette.concurrency import run_in_threadpool
+
+from src.api.admin_routes import _capable, _write
+from src.api.auth_routes import _no_store
+from src.api.patreon_routes import require_writer
+from src.api.request_body import read_json_body
+from src.auth import admin
+from src.luckperms import changes, mirror, views
+
+SNAPSHOT_MAX_BYTES = 16 * 1024 * 1024
+RESULTS_MAX_BYTES = 4 * 1024 * 1024
+
+plugin_router = APIRouter(prefix="/luckperms/plugin", tags=["luckperms"], dependencies=[Depends(require_writer)])
+staff_router = APIRouter(prefix="/admin/luckperms", tags=["admin"])
+
+
+@plugin_router.put("/snapshot")
+async def put_snapshot(request: Request):
+ payload = await read_json_body(request, SNAPSHOT_MAX_BYTES)
+ try:
+ return await run_in_threadpool(mirror.replace_snapshot, payload)
+ except mirror.SnapshotError as exc:
+ raise HTTPException(400, detail=exc.code) from None
+
+
+class HashBody(BaseModel):
+ hash: str = Field(..., max_length=128)
+ revision: int = Field(..., gt=0)
+
+
+@plugin_router.post("/snapshot/unchanged")
+def post_unchanged(body: HashBody):
+ if mirror.mark_unchanged(body.hash, body.revision):
+ return {"ok": True}
+ return {"ok": False, "need_full": True}
+
+
+@plugin_router.get("/changes")
+def get_changes(response: Response):
+ _no_store(response)
+ return {"changes": changes.fetch_for_bridge()}
+
+
+@plugin_router.post("/changes/results")
+async def post_results(request: Request):
+ payload = await read_json_body(request, RESULTS_MAX_BYTES)
+ if not isinstance(payload, dict):
+ raise HTTPException(400, detail="bad_results")
+ try:
+ return await run_in_threadpool(changes.record_results, payload.get("results"))
+ except admin.AdminError as exc:
+ raise HTTPException(exc.status, detail=exc.code) from None
+
+
+# --------------------
+# Staff
+# --------------------
+
+
+def _read(request: Request, response: Response, run):
+ viewer = _capable(request, "view_luckperms")
+ _no_store(response)
+ try:
+ return run(viewer)
+ except admin.AdminError as exc:
+ raise HTTPException(exc.status, detail=exc.code) from None
+
+
+@staff_router.get("")
+def get_overview(request: Request, response: Response):
+ return _read(request, response, views.overview)
+
+
+@staff_router.get("/groups/{name}")
+def get_group(name: str, request: Request, response: Response):
+ return _read(request, response, lambda viewer: views.group_detail(viewer, name))
+
+
+@staff_router.get("/players")
+def get_players(request: Request, response: Response, q: str = "", group: str | None = None,
+ page: int = Query(1, ge=1, le=10_000)):
+ return _read(request, response, lambda viewer: views.users(viewer, q, group, page))
+
+
+@staff_router.get("/players/{player_uuid}")
+def get_player(player_uuid: str, request: Request, response: Response):
+ return _read(request, response, lambda viewer: views.user_detail(viewer, player_uuid))
+
+
+@staff_router.get("/changes")
+def get_recent_changes(request: Request, response: Response):
+ return _read(request, response, lambda _viewer: {"changes": changes.list_changes()})
+
+
+@staff_router.get("/changes/{change_id}")
+def get_change(change_id: int, request: Request, response: Response):
+ return _read(request, response, lambda _viewer: changes.get_change(change_id))
+
+
+class ChangeBody(BaseModel):
+ target_type: str = Field(..., max_length=8)
+ target: str = Field(..., max_length=64)
+ ops: list[dict] = Field(..., max_length=changes.OPS_MAX)
+ reason: str = Field(..., max_length=admin.REASON_MAX * 2)
+
+
+@staff_router.post("/changes")
+def post_change(body: ChangeBody, request: Request, response: Response):
+ _no_store(response)
+ return _write(request, "luckperms.change", None,
+ lambda token: changes.queue_change(token, body.target_type, body.target, body.ops, body.reason))
diff --git a/backend/src/auth/admin_cli.py b/backend/src/auth/admin_cli.py
index 084c75e0..ef52fcbc 100644
--- a/backend/src/auth/admin_cli.py
+++ b/backend/src/auth/admin_cli.py
@@ -1,4 +1,4 @@
-"""Operator commands for root accounts. The website never grants or removes root.
+"""Operator commands for root accounts, for the first owner or when no root can sign in.
Run inside the backend container, for example:
python -m src.auth.admin_cli grant-root --discord-id 123 --reason "First owner"
@@ -6,7 +6,8 @@
python -m src.auth.admin_cli list-staff
The account must have signed in to the website once. Each change is audited
-as an operator action and signs the account out everywhere.
+as an operator action and signs the account out everywhere. Once one root
+exists, roots can also give and take root in the staff panel.
"""
from __future__ import annotations
diff --git a/backend/src/auth/roles.py b/backend/src/auth/roles.py
index 33aee780..f96f03b5 100644
--- a/backend/src/auth/roles.py
+++ b/backend/src/auth/roles.py
@@ -14,6 +14,10 @@
"view_player_movement": "admin",
"revoke_sessions": "mod",
"change_role": "admin",
+ # In-game LuckPerms ranks: everyone on staff sees them; what an admin may
+ # change, beyond viewing, is decided per node by src/luckperms/policy.py.
+ "view_luckperms": "mod",
+ "change_luckperms": "admin",
}
@@ -34,12 +38,16 @@ def capabilities(role: str | None) -> list[str]:
def outranks(actor_role: str | None, target_role: str | None) -> bool:
- """Staff may only act on accounts strictly below their own role."""
+ """Staff may only act on accounts below their own role; root may also act on other roots."""
+ if actor_role == "root" and target_role == "root":
+ return True
return rank(actor_role) > rank(target_role) >= 0
def assignable_roles(actor_role: str | None) -> list[str]:
- """Roles below the actor's own. Root is granted only by an operator."""
+ """Roles below the actor's own; root may give any role, root included."""
if not can(actor_role, "change_role"):
return []
- return [role for role in ROLES if role != "root" and rank(role) < rank(actor_role)]
+ if actor_role == "root":
+ return list(ROLES)
+ return [role for role in ROLES if rank(role) < rank(actor_role)]
diff --git a/backend/src/auth/test_admin.py b/backend/src/auth/test_admin.py
index 0d50630b..36463b30 100644
--- a/backend/src/auth/test_admin.py
+++ b/backend/src/auth/test_admin.py
@@ -75,14 +75,16 @@ def sessions_of(db, user_id):
# --------------------
def test_role_rules():
- assert roles.assignable_roles("root") == ["player", "mod", "admin"]
+ assert roles.assignable_roles("root") == ["player", "mod", "admin", "root"]
assert roles.assignable_roles("admin") == ["player", "mod"]
assert roles.assignable_roles("mod") == []
assert roles.assignable_roles("player") == []
assert roles.outranks("admin", "mod") and not roles.outranks("admin", "admin")
assert not roles.outranks("root", "unknown") and not roles.outranks("unknown", "player")
+ # Roots may act on other roots; cannot_act_on_self still covers their own account.
+ assert roles.outranks("root", "root") and not roles.outranks("admin", "root")
assert not roles.is_staff("player") and not roles.is_staff("wizard") and roles.is_staff("mod")
- assert roles.capabilities("mod") == ["view_admin", "view_players", "revoke_sessions"]
+ assert roles.capabilities("mod") == ["view_admin", "view_players", "revoke_sessions", "view_luckperms"]
assert roles.capabilities("bogus") == []
@@ -159,7 +161,7 @@ def test_panel_reads_need_staff(app, env):
_, mod = account(env, "mona", "mod")
me = client(app, mod).get("/admin/me")
assert me.status_code == 200 and me.headers["cache-control"] == "no-store"
- assert me.json()["capabilities"] == ["view_admin", "view_players", "revoke_sessions"]
+ assert me.json()["capabilities"] == ["view_admin", "view_players", "revoke_sessions", "view_luckperms"]
assert me.json()["assignable_roles"] == []
@@ -215,8 +217,9 @@ def test_root_can_make_admins_and_target_is_signed_out(app, env):
("admin", "admin", "mod", 403, "target_outranks_you"),
("admin", "root", "player", 403, "target_outranks_you"),
("root", "admin", "player", 200, None),
- ("root", "player", "root", 403, "role_not_assignable"),
- ("root", "root", "admin", 403, "target_outranks_you"),
+ ("root", "player", "root", 200, None),
+ ("root", "root", "admin", 200, None),
+ ("admin", "player", "root", 403, "role_not_assignable"),
("mod", "player", "mod", 403, "forbidden"),
("player", "player", "mod", 403, "forbidden"),
("admin", "player", "player", 409, "role_unchanged"),
diff --git a/backend/src/luckperms/README.md b/backend/src/luckperms/README.md
new file mode 100644
index 00000000..0af784df
--- /dev/null
+++ b/backend/src/luckperms/README.md
@@ -0,0 +1,123 @@
+# LuckPerms in the staff panel
+
+The website shows and edits LuckPerms without touching its database. One
+server's TFMCWeb (Main's) is the bridge: it publishes a snapshot of LuckPerms
+to the site and applies changes that staff queue on the site, through the
+LuckPerms API. Dev and Main share one LuckPerms MariaDB, so exactly one server
+may apply changes. Another server may publish to its own site (Dev publishes to
+dev.tfminecraft.net) without applying, which makes that site read-only.
+
+## Rights
+
+| Who | May |
+| --- | --- |
+| mod | view everything: groups, tracks, every player's groups and permissions, change history |
+| admin | add or remove the groups in `policy.yaml` `admin_groups` on players, promote/demote players along tracks between those groups, set or unset player permissions matching `admin_permissions` |
+| root | everything, including staff groups, other permissions, meta (prefix/suffix/weight) on players, group definitions and tracks |
+
+A listed group counts as an admin group only while everything it inherits, in
+any context, is listed too, so a root edit to a group definition cannot hand
+admins a staff group by inheritance. Unknown permissions, wildcards and regex
+nodes are root-only.
+
+Admins may not change anything on their own Minecraft account, on a player
+whose linked website account is admin or root, or on a player who holds or
+inherits a root-only group (in-game staff, linked or not). Rights are checked
+when a change is queued and again when the bridge collects it, so a demotion
+in between cancels it. Every change carries a
+reason and is written to `admin_audit`, and LuckPerms logs it (`/lp log`) with
+the source `web:`.
+
+## Plugin protocol
+
+All routes need the primary `X-Plugin-Key`; secondary keys are refused.
+
+### Snapshot
+
+`PUT /luckperms/plugin/snapshot` (JSON, at most 16 MiB):
+
+```json
+{
+ "server": "main",
+ "generated_at": 1791321779,
+ "revision": 1791321779123,
+ "hash": "",
+ "groups": [{"name": "staff", "display_name": null, "weight": 200, "nodes": [NODE]}],
+ "tracks": [{"name": "staff", "groups": ["staff_player", "staff_inactive", "staff"]}],
+ "users": [{"uuid": "…", "name": "drefvelin", "nodes": [NODE]}]
+}
+```
+
+`NODE` is `{"key": "group.staff", "value": true, "contexts": {"server": ["main"]}, "expiry": 0}`;
+`expiry` is unix seconds, 0 for permanent; `contexts` maps each key to its values (empty object for global).
+`users` holds every user with at least one stored node (`UserManager.searchAll` with an empty key prefix).
+The site replaces its whole mirror in one transaction and answers `{"ok": true}`.
+
+`revision` orders everything the bridge reports: it strictly increases across snapshots and
+results, also across restarts (the bridge uses `max(last + 1, current time in ms)`). The site
+ignores a snapshot or result state whose revision is not above the newest it has stored
+(answering `{"ok": true, "stale": true}`), so a request delayed past a newer one cannot undo it.
+
+`POST /luckperms/plugin/snapshot/unchanged` `{"hash": "…", "revision": …}` answers `{"ok": true}` and
+marks the mirror current when the hash matches the stored one, else `{"ok": false, "need_full": true}`.
+
+The bridge builds a snapshot every `snapshot-seconds` (default 30) and right after applying changes,
+on the same single worker thread that applies changes, so a snapshot never predates an applied change.
+
+### Changes
+
+`GET /luckperms/plugin/changes` returns up to 20 changes in id order and records the poll time
+(the site shows the bridge as connected while polls are recent):
+
+```json
+{"changes": [{
+ "id": 12,
+ "target_type": "user", // user | group | track
+ "target": "5b0c…-uuid", // user UUID, group name or track name
+ "target_name": "drefvelin",
+ "actor_name": "web:w.o.n", // LuckPerms action source name
+ "actor_uuid": null, // the actor's linked Minecraft UUID, if any
+ "description": "parent add staff", // LuckPerms action log text
+ "ops": [OP]
+}]}
+```
+
+Ops, applied in order to one holder and saved once:
+
+| target | op | fields | precondition |
+| --- | --- | --- | --- |
+| user, group | `add_node` | `node` | the exact node (key, value, contexts, expiry) is absent |
+| user, group | `remove_node` | `node` | the exact node is present |
+| group | `create_group` | | the group does not exist |
+| group | `delete_group` | | the group exists |
+| track | `create_track` | | the track does not exist |
+| track | `delete_track` | | the track exists |
+| track | `set_groups` | `groups` (ordered) | the track exists and every group exists |
+
+The bridge checks every precondition in op order against a staged copy of the live LuckPerms data
+(so create-then-add and remove-then-add work) before mutating anything; if one fails, nothing is
+mutated or saved and the result is `{"ok": false, "error": ""}` with codes
+`node_exists`, `node_missing`, `group_exists`, `group_missing`, `track_exists`, `track_missing`,
+`bad_op`, `bad_target`, `save_failed`. A `group.` node whose group does not exist is `group_missing`.
+If saving fails after mutating a loaded holder, the bridge reloads that holder from storage so the
+server does not keep an unsaved change in memory. After saving a user it pushes a user update; after
+a group or track change it pushes a full update. It submits one LuckPerms action per change
+(source UUID: the actor's linked Minecraft UUID, else the nil UUID).
+
+`POST /luckperms/plugin/changes/results`:
+
+```json
+{"results": [{"id": 12, "ok": true, "error": null, "revision": 1791321780456, "state": STATE}]}
+```
+
+`STATE` (optional) is the target after the change: a user `{"uuid", "name", "nodes"}`, a group
+`{"name", "display_name", "weight", "nodes"}` or `null` (deleted, or tracks). The site updates its
+mirror for that target at once. The bridge keeps results it could not post and posts them again
+on later ticks until the site answers `ok`.
+
+Site side, a change is `pending` until fetched, then `sent`, and the site never offers it again: a
+change is applied at most once. A sent change with no result after 300 s becomes `unknown` (staff
+are told to check the player); a late result still settles it. A change not fetched within
+10 minutes `expired`. New changes are refused with `bridge_offline` unless the bridge polled in the
+last 60 s, and always on a site with `LUCKPERMS_READ_ONLY=1` (dev.tfminecraft.net, whose server
+shares LuckPerms with Main).
diff --git a/backend/src/luckperms/__init__.py b/backend/src/luckperms/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/backend/src/luckperms/changes.py b/backend/src/luckperms/changes.py
new file mode 100644
index 00000000..56eda219
--- /dev/null
+++ b/backend/src/luckperms/changes.py
@@ -0,0 +1,528 @@
+"""LuckPerms changes staff queue for the TFMCWeb bridge.
+
+A change is checked against the mirror and the actor's rights inside one
+immediate transaction, which also reloads the actor's session, so a demotion
+that lands first always wins. The change row and its audit row commit
+together; a refused attempt rolls back and is then logged on its own.
+
+Only one unfinished change per target is allowed, so every change is checked
+against the state it will actually meet. The bridge checks the same
+preconditions again against live LuckPerms before it saves anything.
+"""
+from __future__ import annotations
+
+import json
+import os
+import time
+from datetime import datetime, timezone
+
+from src.auth import audit, roles
+from src.auth.admin import AdminError, clean_reason
+from src.auth.players import canonical_uuid
+from src.auth.users import session_user_in
+from src.skins.db import connect
+
+from . import mirror, nodes, policy
+
+OPS_MAX = 20
+TRACK_GROUPS_MAX = 32
+DESCRIPTION_MAX = 300
+FETCH_LIMIT = 20
+# Unfetched changes are dropped after this; staff queued them against an old view.
+PENDING_TTL_SECONDS = 600
+# A sent change is never offered again; with no result by then its outcome is unknown.
+RESULT_WAIT_SECONDS = 300
+HISTORY_LIMIT = 50
+
+USER_OPS = {"add_node", "remove_node", "promote", "demote"}
+GROUP_OPS = {"add_node", "remove_node", "create_group", "delete_group"}
+TRACK_OPS = {"create_track", "delete_track", "set_groups"}
+UNFINISHED = ("pending", "sent")
+
+
+def read_only() -> bool:
+ """dev.tfminecraft.net shows LuckPerms but must not change it: Dev shares Main's storage."""
+ return os.environ.get("LUCKPERMS_READ_ONLY", "").strip().lower() in {"1", "true", "yes"}
+
+
+def _now() -> int:
+ return int(time.time())
+
+
+def _iso(ts: float | None = None) -> str:
+ return datetime.fromtimestamp(ts if ts is not None else time.time(), timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
+
+
+def _parse(stamp: str) -> float:
+ return datetime.strptime(stamp, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc).timestamp()
+
+
+def _bad(code: str) -> AdminError:
+ return AdminError(400, code, "invalid")
+
+
+def _conflict(code: str) -> AdminError:
+ return AdminError(409, code, "conflict")
+
+
+def _forbidden(code: str = "forbidden") -> AdminError:
+ return AdminError(403, code, "denied")
+
+
+def _node(raw, *, new: bool) -> dict:
+ try:
+ return nodes.from_request(raw, new=new)
+ except nodes.NodeError as exc:
+ raise _bad(exc.code) from None
+
+
+def _find(holder: list[dict], node: dict) -> int | None:
+ for index, held in enumerate(holder):
+ if nodes.same(held, node):
+ return index
+ return None
+
+
+def _inherits(groups: dict[str, dict], start: str, wanted: str, now: int) -> bool:
+ return wanted in mirror.inherited(groups, [start], now)
+
+
+# --------------------
+# Building ops
+# --------------------
+
+
+def _node_ops(holder: list[dict], raw_op: dict, groups: dict[str, dict], now: int, *, self_group: str | None = None):
+ """Check one add/remove against the holder's simulated nodes and apply it there."""
+ op = raw_op["op"]
+ node = _node(raw_op.get("node"), new=op == "add_node")
+ group = nodes.group_of(node["key"])
+ if op == "add_node":
+ if _find(holder, node) is not None:
+ raise _conflict("node_exists")
+ if group is not None:
+ if group not in groups:
+ raise _conflict("group_missing")
+ if self_group is not None and node["value"] and (
+ group == self_group or _inherits(groups, group, self_group, now)):
+ raise _conflict("inheritance_cycle")
+ holder.append(node)
+ return {"op": "add_node", "node": node}, nodes.describe(node)
+ index = _find(holder, node)
+ if index is None:
+ raise _conflict("node_missing")
+ holder.pop(index)
+ return {"op": "remove_node", "node": node}, nodes.describe_removal(node)
+
+
+def _track_step(holder: list[dict], track: dict, direction: int):
+ """Promote (+1) or demote (-1) along a track in the global context, as explicit node ops."""
+ positions = [
+ (index, held) for held in holder
+ if held["value"] and not held["contexts"] and nodes.group_of(held["key"]) in track["groups"]
+ for index in [track["groups"].index(nodes.group_of(held["key"]))]
+ ]
+ if len(positions) > 1:
+ raise _conflict("ambiguous_track")
+ steps = []
+ if not positions:
+ if direction < 0:
+ raise _conflict("not_on_track")
+ if not track["groups"]:
+ raise _conflict("track_empty")
+ target = track["groups"][0]
+ else:
+ index, held = positions[0]
+ holder.remove(held)
+ steps.append({"op": "remove_node", "node": held})
+ target_index = index + direction
+ if target_index >= len(track["groups"]):
+ raise _conflict("track_end")
+ target = track["groups"][target_index] if target_index >= 0 else None
+ if target is not None:
+ added = {"key": f"group.{target}", "value": True, "contexts": {}, "expiry": 0}
+ if _find(holder, added) is None:
+ holder.append(added)
+ steps.append({"op": "add_node", "node": added})
+ return steps
+
+
+def build_user_ops(conn, uuid: str, raw_ops: list, now: int) -> tuple[list[dict], list[str]]:
+ groups = mirror.load_groups(conn)
+ tracks = {t["name"]: t for t in mirror.load_tracks(conn)}
+ holder = mirror.user_nodes(conn, uuid)
+ ops, words = [], []
+ for raw in raw_ops:
+ if raw["op"] in ("promote", "demote"):
+ track = tracks.get(str(raw.get("track") or "").lower())
+ if track is None:
+ raise _conflict("track_missing")
+ steps = _track_step(holder, track, 1 if raw["op"] == "promote" else -1)
+ ops.extend(steps)
+ words.append(f"{raw['op']} {track['name']}")
+ else:
+ op, word = _node_ops(holder, raw, groups, now)
+ ops.append(op)
+ words.append(word)
+ return ops, words
+
+
+def build_group_ops(conn, name: str, raw_ops: list, now: int) -> tuple[list[dict], list[str]]:
+ groups = mirror.load_groups(conn)
+ exists = name in groups
+ holder = list(groups[name]["nodes"]) if exists else []
+ ops, words = [], []
+ for raw in raw_ops:
+ op = raw["op"]
+ if op == "create_group":
+ if exists:
+ raise _conflict("group_exists")
+ exists = True
+ groups[name] = {"name": name, "display_name": None, "weight": None, "nodes": holder}
+ ops.append({"op": "create_group"})
+ words.append("create")
+ continue
+ if not exists:
+ raise _conflict("group_missing")
+ if op == "delete_group":
+ if name == "default":
+ raise _forbidden("cannot_delete_default")
+ exists = False
+ ops.append({"op": "delete_group"})
+ words.append("delete")
+ continue
+ op_json, word = _node_ops(holder, raw, groups, now, self_group=name)
+ groups[name]["nodes"] = holder
+ ops.append(op_json)
+ words.append(word)
+ return ops, words
+
+
+def build_track_ops(conn, name: str, raw_ops: list) -> tuple[list[dict], list[str]]:
+ groups = mirror.load_groups(conn)
+ exists = any(t["name"] == name for t in mirror.load_tracks(conn))
+ ops, words = [], []
+ for raw in raw_ops:
+ op = raw["op"]
+ if op == "create_track":
+ if exists:
+ raise _conflict("track_exists")
+ exists = True
+ ops.append({"op": "create_track"})
+ words.append("create")
+ continue
+ if not exists:
+ raise _conflict("track_missing")
+ if op == "delete_track":
+ exists = False
+ ops.append({"op": "delete_track"})
+ words.append("delete")
+ continue
+ listed = raw.get("groups")
+ if not isinstance(listed, list) or len(listed) > TRACK_GROUPS_MAX:
+ raise _bad("bad_track_groups")
+ clean = [str(g).strip().lower() for g in listed if isinstance(g, str)]
+ if len(clean) != len(listed) or len(set(clean)) != len(clean):
+ raise _bad("bad_track_groups")
+ if any(g not in groups for g in clean):
+ raise _conflict("group_missing")
+ ops.append({"op": "set_groups", "groups": clean})
+ words.append("set " + " ".join(clean) if clean else "clear")
+ return ops, words
+
+
+# --------------------
+# Rights
+# --------------------
+
+
+def _linked_uuid(conn, discord_user_id: str | None) -> str | None:
+ if not discord_user_id:
+ return None
+ row = conn.execute("SELECT player_uuid FROM discord_links WHERE discord_user_id = ?", (discord_user_id,)).fetchone()
+ return canonical_uuid(row["player_uuid"]) if row else None
+
+
+def _target_account_role(conn, uuid: str) -> str | None:
+ row = conn.execute(
+ "SELECT u.role FROM discord_links dl JOIN users u ON u.discord_user_id = dl.discord_user_id "
+ "WHERE lower(dl.player_uuid) = ?",
+ (uuid,),
+ ).fetchone()
+ return row["role"] if row else None
+
+
+def _holds_root_only_group(conn, uuid: str, groups: dict[str, dict]) -> bool:
+ """Whether the player holds, in any context, a group only root may manage: in-game staff."""
+ rules = policy.load()
+ for node in mirror.user_nodes(conn, uuid):
+ group = nodes.group_of(node["key"])
+ if group and node["value"] and policy.group_role(rules, groups, group) == "root":
+ return True
+ return False
+
+
+def check_user_target(conn, actor: dict, uuid: str) -> None:
+ """Admins may not change their own account, fellow admins and roots, or in-game staff."""
+ if actor["role"] == "root":
+ return
+ if _linked_uuid(conn, actor["discord_user_id"]) == uuid:
+ raise _forbidden("cannot_act_on_self")
+ target_role = _target_account_role(conn, uuid)
+ if target_role is not None and roles.rank(target_role) >= roles.rank(actor["role"]):
+ raise _forbidden("target_outranks_you")
+ if _holds_root_only_group(conn, uuid, mirror.load_groups(conn)):
+ raise _forbidden("target_is_staff")
+
+
+def check_ops_allowed(conn, actor_role: str, target_type: str, ops: list[dict]) -> None:
+ if target_type != "user":
+ if not policy.may_edit_definitions(actor_role):
+ raise _forbidden("root_only")
+ return
+ rules = policy.load()
+ groups = mirror.load_groups(conn)
+ for op in ops:
+ if not policy.may_edit_node(rules, groups, actor_role, op["node"]):
+ raise _forbidden("root_only")
+
+
+# --------------------
+# Queueing
+# --------------------
+
+
+def _shape(target_type, target, raw_ops) -> tuple[str, str, list[dict]]:
+ if target_type not in ("user", "group", "track"):
+ raise _bad("bad_target")
+ if not isinstance(target, str) or not target.strip():
+ raise _bad("bad_target")
+ if target_type == "user":
+ target = canonical_uuid(target)
+ if target is None:
+ raise _bad("bad_uuid")
+ else:
+ target = target.strip().lower()
+ if not nodes.GROUP_NAME.match(target):
+ raise _bad("bad_group_name" if target_type == "group" else "bad_track_name")
+ if not isinstance(raw_ops, list) or not raw_ops or len(raw_ops) > OPS_MAX:
+ raise _bad("bad_ops")
+ allowed = {"user": USER_OPS, "group": GROUP_OPS, "track": TRACK_OPS}[target_type]
+ for raw in raw_ops:
+ if not isinstance(raw, dict) or raw.get("op") not in allowed:
+ raise _bad("bad_ops")
+ return target_type, target, raw_ops
+
+
+def queue_change(token: str, target_type, target, raw_ops, reason: str | None) -> dict:
+ action = "luckperms.change"
+ actor = None
+ clean = None
+ detail_target = {"target_type": target_type if isinstance(target_type, str) else None,
+ "target": target[:64] if isinstance(target, str) else None}
+ try:
+ with connect() as conn:
+ conn.execute("BEGIN IMMEDIATE")
+ try:
+ actor = session_user_in(conn, token)
+ if actor is None:
+ raise AdminError(401, "not_signed_in", "denied")
+ if not roles.can(actor["role"], "change_luckperms"):
+ raise _forbidden()
+ clean = clean_reason(reason)
+ target_type, target, raw_ops = _shape(target_type, target, raw_ops)
+ detail_target = {"target_type": target_type, "target": target}
+ state = mirror.status(conn)
+ if read_only() or not state["applying"]:
+ raise AdminError(503, "bridge_offline", "unavailable")
+ if conn.execute(
+ "SELECT 1 FROM lp_changes WHERE target_type = ? AND target = ? AND status IN ('pending', 'sent')",
+ (target_type, target)).fetchone():
+ raise _conflict("change_pending")
+ now = _now()
+ target_name = target
+ if target_type == "user":
+ row = mirror.user_row(conn, target)
+ if row is None:
+ raise AdminError(404, "player_not_found", "not_found")
+ target_name = row["name"] or target
+ check_user_target(conn, actor, target)
+ ops, words = build_user_ops(conn, target, raw_ops, now)
+ elif target_type == "group":
+ ops, words = build_group_ops(conn, target, raw_ops, now)
+ else:
+ ops, words = build_track_ops(conn, target, raw_ops)
+ check_ops_allowed(conn, actor["role"], target_type, ops)
+ if not ops:
+ raise _conflict("nothing_to_change")
+ description = "; ".join(words)[:DESCRIPTION_MAX]
+ change_id = conn.execute(
+ """
+ INSERT INTO lp_changes (
+ created_at, actor_user_id, actor_name, actor_role, actor_minecraft_uuid,
+ target_type, target, target_name, description, ops_json, reason
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ """,
+ (_iso(), actor["user_id"], audit.display_name(actor), actor["role"],
+ _linked_uuid(conn, actor["discord_user_id"]), target_type, target, target_name,
+ description, json.dumps(ops, sort_keys=True), clean),
+ ).lastrowid
+ audit.record(conn, actor=actor, action=action, outcome="ok", reason=clean,
+ detail={**detail_target, "target_name": target_name, "change_id": change_id,
+ "description": description})
+ conn.commit()
+ except BaseException:
+ conn.rollback()
+ raise
+ except AdminError as exc:
+ if actor is not None:
+ audit.record_refusal(actor=actor, action=action, outcome=exc.outcome, reason=clean,
+ detail={**detail_target, "error": exc.code})
+ raise
+ return get_change(change_id)
+
+
+# --------------------
+# Reading changes
+# --------------------
+
+
+def _change_json(row) -> dict:
+ return {
+ "id": row["id"],
+ "created_at": row["created_at"],
+ "actor_name": row["actor_name"],
+ "actor_role": row["actor_role"],
+ "target_type": row["target_type"],
+ "target": row["target"],
+ "target_name": row["target_name"],
+ "description": row["description"],
+ "reason": row["reason"],
+ "status": row["status"],
+ "finished_at": row["finished_at"],
+ "error": row["error"],
+ }
+
+
+def get_change(change_id: int) -> dict:
+ with connect() as conn:
+ row = conn.execute("SELECT * FROM lp_changes WHERE id = ?", (change_id,)).fetchone()
+ if row is None:
+ raise AdminError(404, "change_not_found", "not_found")
+ return _change_json(row)
+
+
+def list_changes(target_type: str | None = None, target: str | None = None, limit: int = HISTORY_LIMIT) -> list[dict]:
+ where, args = [], []
+ if target_type and target:
+ where.append("target_type = ? AND target = ?")
+ args += [target_type, target]
+ clause = f"WHERE {' AND '.join(where)}" if where else ""
+ with connect() as conn:
+ rows = conn.execute(f"SELECT * FROM lp_changes {clause} ORDER BY id DESC LIMIT ?",
+ [*args, max(1, min(limit, HISTORY_LIMIT))]).fetchall()
+ return [_change_json(row) for row in rows]
+
+
+# --------------------
+# Bridge side
+# --------------------
+
+
+def _actor_name(row) -> str:
+ return f"web:{row['actor_name'] or row['actor_user_id']}"[:100]
+
+
+def _still_allowed(conn, row) -> bool:
+ """The actor's rights again at collection time: a demotion since queueing cancels the change."""
+ actor = conn.execute("SELECT id AS user_id, discord_user_id, role FROM users WHERE id = ?",
+ (row["actor_user_id"],)).fetchone()
+ if actor is None or not roles.can(actor["role"], "change_luckperms"):
+ return False
+ actor = dict(actor)
+ try:
+ if row["target_type"] == "user":
+ check_user_target(conn, actor, row["target"])
+ check_ops_allowed(conn, actor["role"], row["target_type"], json.loads(row["ops_json"]))
+ except AdminError:
+ return False
+ return True
+
+
+def fetch_for_bridge() -> list[dict]:
+ """Changes for the bridge, oldest first. Each is handed out once; records the poll."""
+ now = time.time()
+ with connect() as conn:
+ conn.execute("BEGIN IMMEDIATE")
+ try:
+ mirror.record_poll(conn)
+ out = []
+ rows = conn.execute(
+ "SELECT * FROM lp_changes WHERE status IN ('pending', 'sent') ORDER BY id").fetchall()
+ for row in rows:
+ if row["status"] == "sent":
+ if now - _parse(row["sent_at"]) > RESULT_WAIT_SECONDS:
+ conn.execute("UPDATE lp_changes SET status = 'unknown', finished_at = ?, error = 'no_result' "
+ "WHERE id = ?", (_iso(now), row["id"]))
+ continue
+ if now - _parse(row["created_at"]) > PENDING_TTL_SECONDS:
+ conn.execute("UPDATE lp_changes SET status = 'expired', finished_at = ?, error = 'expired' "
+ "WHERE id = ?", (_iso(now), row["id"]))
+ continue
+ if read_only():
+ continue
+ if not _still_allowed(conn, row):
+ conn.execute("UPDATE lp_changes SET status = 'failed', finished_at = ?, error = 'actor_changed' "
+ "WHERE id = ?", (_iso(now), row["id"]))
+ continue
+ if len(out) >= FETCH_LIMIT:
+ continue
+ conn.execute("UPDATE lp_changes SET status = 'sent', sent_at = ?, attempts = attempts + 1 "
+ "WHERE id = ?", (_iso(now), row["id"]))
+ out.append({
+ "id": row["id"],
+ "target_type": row["target_type"],
+ "target": row["target"],
+ "target_name": row["target_name"],
+ "actor_name": _actor_name(row),
+ "actor_uuid": row["actor_minecraft_uuid"],
+ "description": row["description"],
+ "ops": json.loads(row["ops_json"]),
+ })
+ conn.commit()
+ except BaseException:
+ conn.rollback()
+ raise
+ return out
+
+
+def record_results(results) -> dict:
+ if not isinstance(results, list):
+ raise _bad("bad_results")
+ recorded = 0
+ with connect() as conn:
+ conn.execute("BEGIN IMMEDIATE")
+ try:
+ for result in results:
+ if not isinstance(result, dict) or not isinstance(result.get("id"), int):
+ continue
+ row = conn.execute("SELECT * FROM lp_changes WHERE id = ?", (result["id"],)).fetchone()
+ # A late result still settles a change whose outcome was unknown.
+ if row is None or row["status"] not in ("sent", "unknown"):
+ continue
+ ok = result.get("ok") is True
+ error = None if ok else str(result.get("error") or "failed")[:64]
+ conn.execute("UPDATE lp_changes SET status = ?, finished_at = ?, error = ? WHERE id = ?",
+ ("applied" if ok else "failed", _iso(), error, row["id"]))
+ if ok and "state" in result:
+ try:
+ mirror.apply_state(conn, row["target_type"], row["target"], result["state"],
+ result.get("revision"))
+ except mirror.SnapshotError:
+ conn.execute("UPDATE lp_state SET hash = NULL WHERE id = 1")
+ recorded += 1
+ conn.commit()
+ except BaseException:
+ conn.rollback()
+ raise
+ return {"ok": True, "recorded": recorded}
diff --git a/backend/src/luckperms/conftest.py b/backend/src/luckperms/conftest.py
new file mode 100644
index 00000000..ec0ffa99
--- /dev/null
+++ b/backend/src/luckperms/conftest.py
@@ -0,0 +1,18 @@
+import sys
+from pathlib import Path
+import pytest
+
+BACKEND = Path(__file__).resolve().parents[2]
+if str(BACKEND) not in sys.path:
+ sys.path.insert(0, str(BACKEND))
+
+
+@pytest.fixture
+def database(tmp_path, monkeypatch):
+ from src.skins import db
+ monkeypatch.setattr(db, "DB_PATH", tmp_path / "province.db")
+ monkeypatch.setattr(db, "DATA_DIR", tmp_path)
+ for name in ("SKINS_DIR", "WARDROBE_DIR", "DRINKS_DIR"):
+ monkeypatch.setattr(db, name, tmp_path / name.lower())
+ db.migrate()
+ return db
diff --git a/backend/src/luckperms/mirror.py b/backend/src/luckperms/mirror.py
new file mode 100644
index 00000000..3f3dbc74
--- /dev/null
+++ b/backend/src/luckperms/mirror.py
@@ -0,0 +1,366 @@
+"""The site's copy of LuckPerms, as the TFMCWeb bridge last published it.
+
+The bridge's snapshot replaces every table in one transaction. A change
+result carries its target's state, which replaces just that target, so the
+panel shows an applied change before the next snapshot arrives.
+"""
+from __future__ import annotations
+
+import json
+import time
+from datetime import datetime, timezone
+
+from src.auth.players import canonical_uuid
+from src.skins.db import connect
+
+from . import nodes
+
+USERS_MAX = 50_000
+NODES_MAX = 2_000_000
+NAME_MAX = 36
+# The bridge polls every few seconds; a minute of silence means it is gone.
+CONNECTED_SECONDS = 60
+PAGE_SIZE = 50
+QUERY_MAX = 64
+
+
+class SnapshotError(Exception):
+ def __init__(self, code: str):
+ super().__init__(code)
+ self.code = code
+
+
+def _iso(ts: float | None = None) -> str:
+ return datetime.fromtimestamp(ts if ts is not None else time.time(), timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
+
+
+def _age(stamp: str | None, now: float) -> float | None:
+ if not stamp:
+ return None
+ then = datetime.strptime(stamp, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc).timestamp()
+ return max(0.0, now - then)
+
+
+def _node_rows(raw_nodes) -> list[dict]:
+ if not isinstance(raw_nodes, list):
+ raise SnapshotError("bad_snapshot")
+ try:
+ return [nodes.from_snapshot(node) for node in raw_nodes]
+ except nodes.NodeError:
+ raise SnapshotError("bad_snapshot") from None
+
+
+def _group(raw) -> tuple[dict, list[dict]]:
+ if not isinstance(raw, dict) or not isinstance(raw.get("name"), str) or not raw["name"]:
+ raise SnapshotError("bad_snapshot")
+ weight = raw.get("weight")
+ display = raw.get("display_name")
+ return (
+ {
+ "name": raw["name"].lower()[:NAME_MAX * 2],
+ "display_name": display if isinstance(display, str) else None,
+ "weight": weight if isinstance(weight, int) and not isinstance(weight, bool) else None,
+ },
+ _node_rows(raw.get("nodes", [])),
+ )
+
+
+def _user(raw) -> tuple[dict, list[dict]]:
+ if not isinstance(raw, dict) or not isinstance(raw.get("uuid"), str):
+ raise SnapshotError("bad_snapshot")
+ uuid = canonical_uuid(raw["uuid"])
+ if uuid is None:
+ raise SnapshotError("bad_snapshot")
+ name = raw.get("name")
+ return {"uuid": uuid, "name": name[:NAME_MAX] if isinstance(name, str) and name else None}, _node_rows(
+ raw.get("nodes", []))
+
+
+def _track(raw) -> dict:
+ if (not isinstance(raw, dict) or not isinstance(raw.get("name"), str) or not raw["name"]
+ or not isinstance(raw.get("groups"), list)
+ or not all(isinstance(g, str) for g in raw["groups"])):
+ raise SnapshotError("bad_snapshot")
+ return {"name": raw["name"].lower(), "groups": [g.lower() for g in raw["groups"]]}
+
+
+def _insert_group(conn, group: dict, group_nodes: list[dict]) -> None:
+ conn.execute("INSERT OR REPLACE INTO lp_groups (name, display_name, weight) VALUES (?, ?, ?)",
+ (group["name"], group["display_name"], group["weight"]))
+ conn.executemany(
+ "INSERT INTO lp_group_nodes (group_name, key, value, contexts, expiry) VALUES (?, ?, ?, ?, ?)",
+ [(group["name"], n["key"], int(n["value"]), nodes.contexts_json(n["contexts"]), n["expiry"])
+ for n in group_nodes],
+ )
+
+
+def _insert_user(conn, user: dict, user_nodes: list[dict]) -> None:
+ conn.execute("INSERT OR REPLACE INTO lp_users (uuid, name) VALUES (?, ?)", (user["uuid"], user["name"]))
+ conn.executemany(
+ "INSERT INTO lp_user_nodes (uuid, key, value, contexts, expiry) VALUES (?, ?, ?, ?, ?)",
+ [(user["uuid"], n["key"], int(n["value"]), nodes.contexts_json(n["contexts"]), n["expiry"])
+ for n in user_nodes],
+ )
+
+
+def replace_snapshot(payload) -> dict:
+ if not isinstance(payload, dict):
+ raise SnapshotError("bad_snapshot")
+ for field in ("groups", "tracks", "users"):
+ if not isinstance(payload.get(field), list):
+ raise SnapshotError("bad_snapshot")
+ if len(payload["users"]) > USERS_MAX:
+ raise SnapshotError("snapshot_too_large")
+ snapshot_hash = payload.get("hash")
+ server = payload.get("server")
+ generated_at = payload.get("generated_at")
+ revision = _revision(payload.get("revision"))
+ groups = [_group(raw) for raw in payload["groups"]]
+ tracks = [_track(raw) for raw in payload["tracks"]]
+ users = [_user(raw) for raw in payload["users"]]
+ if sum(len(n) for _, n in users) + sum(len(n) for _, n in groups) > NODES_MAX:
+ raise SnapshotError("snapshot_too_large")
+ stamp = _iso()
+ with connect() as conn:
+ conn.execute("BEGIN IMMEDIATE")
+ try:
+ if not _newer(conn, revision):
+ conn.rollback()
+ return {"ok": True, "stale": True}
+ for table in ("lp_group_nodes", "lp_groups", "lp_tracks", "lp_user_nodes", "lp_users"):
+ conn.execute(f"DELETE FROM {table}")
+ for group, group_nodes in groups:
+ _insert_group(conn, group, group_nodes)
+ conn.executemany("INSERT OR REPLACE INTO lp_tracks (name, groups_json) VALUES (?, ?)",
+ [(t["name"], json.dumps(t["groups"])) for t in tracks])
+ for user, user_nodes in users:
+ _insert_user(conn, user, user_nodes)
+ conn.execute(
+ """
+ INSERT INTO lp_state (id, server, hash, generated_at, revision, received_at, checked_at)
+ VALUES (1, ?, ?, ?, ?, ?, ?)
+ ON CONFLICT(id) DO UPDATE SET server = excluded.server, hash = excluded.hash,
+ generated_at = excluded.generated_at, revision = excluded.revision,
+ received_at = excluded.received_at, checked_at = excluded.checked_at
+ """,
+ (str(server)[:32] if server else None, str(snapshot_hash)[:128] if snapshot_hash else None,
+ generated_at if isinstance(generated_at, int) else None, revision, stamp, stamp),
+ )
+ conn.commit()
+ except BaseException:
+ conn.rollback()
+ raise
+ return {"ok": True, "groups": len(groups), "tracks": len(tracks), "users": len(users)}
+
+
+def _revision(raw) -> int:
+ if not isinstance(raw, int) or isinstance(raw, bool) or raw <= 0:
+ raise SnapshotError("bad_revision")
+ return raw
+
+
+def _newer(conn, revision: int) -> bool:
+ row = conn.execute("SELECT revision FROM lp_state WHERE id = 1").fetchone()
+ return row is None or row["revision"] is None or revision > row["revision"]
+
+
+def mark_unchanged(snapshot_hash, revision) -> bool:
+ if not isinstance(snapshot_hash, str) or not snapshot_hash:
+ return False
+ revision = _revision(revision)
+ with connect() as conn:
+ updated = conn.execute(
+ "UPDATE lp_state SET checked_at = ?, revision = ? WHERE id = 1 AND hash = ? AND revision < ?",
+ (_iso(), revision, snapshot_hash, revision)).rowcount
+ conn.commit()
+ return updated == 1
+
+
+def apply_state(conn, target_type: str, target: str, state, revision) -> None:
+ """Replace one target with the state a change result reported, on the caller's transaction.
+
+ Ignored unless newer than everything stored. The stored hash no longer
+ describes the mirror, so the next unchanged check asks for a full snapshot.
+ """
+ if target_type not in ("user", "group"):
+ return
+ revision = _revision(revision)
+ if not _newer(conn, revision):
+ return
+ if target_type == "user":
+ conn.execute("DELETE FROM lp_user_nodes WHERE uuid = ?", (target,))
+ conn.execute("DELETE FROM lp_users WHERE uuid = ?", (target,))
+ if isinstance(state, dict):
+ user, user_nodes = _user(state)
+ if user["uuid"] == target:
+ _insert_user(conn, user, user_nodes)
+ elif target_type == "group":
+ conn.execute("DELETE FROM lp_group_nodes WHERE group_name = ?", (target,))
+ conn.execute("DELETE FROM lp_groups WHERE name = ?", (target,))
+ if isinstance(state, dict):
+ group, group_nodes = _group(state)
+ if group["name"] == target:
+ _insert_group(conn, group, group_nodes)
+ conn.execute("UPDATE lp_state SET hash = NULL, revision = ? WHERE id = 1", (revision,))
+
+
+def record_poll(conn) -> None:
+ conn.execute(
+ "INSERT INTO lp_state (id, polled_at) VALUES (1, ?) ON CONFLICT(id) DO UPDATE SET polled_at = excluded.polled_at",
+ (_iso(),),
+ )
+
+
+def status(conn=None) -> dict:
+ def read(c):
+ return c.execute("SELECT server, received_at, checked_at, polled_at FROM lp_state WHERE id = 1").fetchone()
+
+ if conn is None:
+ with connect() as own:
+ row = read(own)
+ else:
+ row = read(conn)
+ now = time.time()
+ polled = _age(row["polled_at"], now) if row else None
+ return {
+ "server": row["server"] if row else None,
+ "snapshot_at": row["received_at"] if row else None,
+ "checked_at": row["checked_at"] if row else None,
+ "has_snapshot": bool(row and row["received_at"]),
+ "applying": polled is not None and polled <= CONNECTED_SECONDS,
+ "polled_at": row["polled_at"] if row else None,
+ }
+
+
+# --------------------
+# Reads
+# --------------------
+
+
+def _node(row) -> dict:
+ return {"key": row["key"], "value": bool(row["value"]), "contexts": json.loads(row["contexts"]),
+ "expiry": row["expiry"]}
+
+
+def _sort_key(node: dict):
+ return (node["key"].lower(), json.dumps(node["contexts"], sort_keys=True), node["expiry"], not node["value"])
+
+
+def load_groups(conn) -> dict[str, dict]:
+ groups = {row["name"]: {"name": row["name"], "display_name": row["display_name"], "weight": row["weight"],
+ "nodes": []}
+ for row in conn.execute("SELECT name, display_name, weight FROM lp_groups")}
+ for row in conn.execute("SELECT group_name, key, value, contexts, expiry FROM lp_group_nodes"):
+ if row["group_name"] in groups:
+ groups[row["group_name"]]["nodes"].append(_node(row))
+ for group in groups.values():
+ group["nodes"].sort(key=_sort_key)
+ return groups
+
+
+def load_tracks(conn) -> list[dict]:
+ return [{"name": row["name"], "groups": json.loads(row["groups_json"])}
+ for row in conn.execute("SELECT name, groups_json FROM lp_tracks ORDER BY name")]
+
+
+def user_nodes(conn, uuid: str) -> list[dict]:
+ rows = conn.execute("SELECT key, value, contexts, expiry FROM lp_user_nodes WHERE uuid = ?", (uuid,))
+ return sorted((_node(row) for row in rows), key=_sort_key)
+
+
+def user_row(conn, uuid: str) -> dict | None:
+ row = conn.execute("SELECT uuid, name FROM lp_users WHERE uuid = ?", (uuid,)).fetchone()
+ return dict(row) if row else None
+
+
+def parents(group: dict, now: int, *, global_only: bool = True) -> list[str]:
+ """Groups this group inherits directly."""
+ out = []
+ for node in group["nodes"]:
+ name = nodes.group_of(node["key"])
+ if name and node["value"] and nodes.active(node, now) and (not global_only or not node["contexts"]):
+ out.append(name)
+ return out
+
+
+def meta_value(group: dict, prefix: str, now: int) -> tuple[int, str] | None:
+ """The highest-priority prefix or suffix a group sets itself (global, active)."""
+ best = None
+ for node in group["nodes"]:
+ key = node["key"]
+ if not key.lower().startswith(prefix + ".") or not node["value"] or node["contexts"]:
+ continue
+ if not nodes.active(node, now):
+ continue
+ parts = key.split(".", 2)
+ if len(parts) != 3:
+ continue
+ try:
+ priority = int(parts[1])
+ except ValueError:
+ continue
+ if best is None or priority > best[0]:
+ best = (priority, parts[2])
+ return best
+
+
+def inherited(groups: dict[str, dict], direct: list[str], now: int) -> list[str]:
+ """Every group reached from these, in breadth-first order, each once."""
+ seen: list[str] = []
+ queue = list(direct)
+ while queue:
+ name = queue.pop(0)
+ if name in seen or name not in groups:
+ continue
+ seen.append(name)
+ queue.extend(parents(groups[name], now))
+ return seen
+
+
+def rank_group(groups: dict[str, dict], user_node_list: list[dict], now: int) -> str | None:
+ """The heaviest group a player inherits globally: what LuckPerms shows as their rank."""
+ direct = [nodes.group_of(n["key"]) for n in user_node_list
+ if nodes.group_of(n["key"]) and n["value"] and not n["contexts"] and nodes.active(n, now)]
+ chain = inherited(groups, [d for d in direct if d], now) or (["default"] if "default" in groups else [])
+ if not chain:
+ return None
+ return max(chain, key=lambda name: (groups[name]["weight"] or 0, name))
+
+
+def search_users(conn, q: str, group: str | None, page: int) -> dict:
+ """Players by name or UUID prefix, optionally only those holding a group directly."""
+ text = (q or "").strip().lower()
+ if len(text) > QUERY_MAX:
+ raise SnapshotError("query_too_long")
+ where, args = [], []
+ if text:
+ where.append("(lower(u.name) LIKE ? ESCAPE '\\' OR u.uuid LIKE ? ESCAPE '\\')")
+ escaped = text.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
+ args += [f"%{escaped}%", f"{escaped}%"]
+ if group:
+ where.append("u.uuid IN (SELECT uuid FROM lp_user_nodes WHERE key = ?)")
+ args.append(f"group.{group.lower()}")
+ clause = f"WHERE {' AND '.join(where)}" if where else ""
+ total = conn.execute(f"SELECT COUNT(*) FROM lp_users u {clause}", args).fetchone()[0]
+ rows = conn.execute(
+ f"""
+ SELECT u.uuid, u.name FROM lp_users u {clause}
+ ORDER BY CASE WHEN lower(u.name) = ? THEN 0 WHEN lower(u.name) LIKE ? ESCAPE '\\' THEN 1 ELSE 2 END,
+ u.name IS NULL, lower(u.name), u.uuid
+ LIMIT ? OFFSET ?
+ """,
+ [*args, text, (text.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + "%"),
+ PAGE_SIZE, (page - 1) * PAGE_SIZE],
+ ).fetchall()
+ return {"total": total, "page": page, "page_size": PAGE_SIZE, "rows": [dict(r) for r in rows]}
+
+
+def group_counts(conn) -> dict[str, int]:
+ """Players holding each group directly, in any context."""
+ out: dict[str, int] = {}
+ for row in conn.execute(
+ "SELECT key, COUNT(DISTINCT uuid) AS n FROM lp_user_nodes WHERE key LIKE 'group.%' GROUP BY key"):
+ name = nodes.group_of(row["key"])
+ if name:
+ out[name] = out.get(name, 0) + row["n"]
+ return out
diff --git a/backend/src/luckperms/nodes.py b/backend/src/luckperms/nodes.py
new file mode 100644
index 00000000..96474848
--- /dev/null
+++ b/backend/src/luckperms/nodes.py
@@ -0,0 +1,152 @@
+"""LuckPerms nodes as the site stores and checks them.
+
+A node is {"key", "value", "contexts", "expiry"}: contexts map each context
+key to a sorted list of values ({} when global) and expiry is unix seconds,
+0 when permanent. Two nodes are the same node only when all four match, as in
+LuckPerms' exact equality.
+"""
+from __future__ import annotations
+
+import json
+import re
+import time
+
+KEY_MAX = 200
+CONTEXT_KEYS_MAX = 8
+CONTEXT_VALUE_MAX = 64
+# Ten years: anything later is a mistake rather than a temporary grant.
+EXPIRY_MAX_SECONDS = 10 * 365 * 24 * 3600
+GROUP_NAME = re.compile(r"^[a-z0-9_+\-]{1,36}$")
+_CONTEXT_KEY = re.compile(r"^[a-z0-9_.:\-]{1,64}$")
+META_PREFIXES = ("prefix.", "suffix.", "meta.", "weight.", "displayname.")
+
+
+class NodeError(Exception):
+ def __init__(self, code: str):
+ super().__init__(code)
+ self.code = code
+
+
+def _printable(text: str) -> bool:
+ return all(ord(ch) >= 0x20 and ord(ch) != 0x7F for ch in text)
+
+
+def kind(key: str) -> str:
+ """group, meta or permission."""
+ lowered = key.lower()
+ if lowered.startswith("group."):
+ return "group"
+ if lowered.startswith(META_PREFIXES):
+ return "meta"
+ return "permission"
+
+
+def group_of(key: str) -> str | None:
+ return key[len("group."):].lower() if kind(key) == "group" else None
+
+
+def clean_contexts(raw) -> dict[str, list[str]]:
+ if raw is None:
+ return {}
+ if not isinstance(raw, dict) or len(raw) > CONTEXT_KEYS_MAX:
+ raise NodeError("bad_contexts")
+ out: dict[str, list[str]] = {}
+ for key, values in raw.items():
+ if not isinstance(key, str) or not _CONTEXT_KEY.match(key.lower()):
+ raise NodeError("bad_contexts")
+ if isinstance(values, str):
+ values = [values]
+ if not isinstance(values, list) or not values or len(values) > CONTEXT_KEYS_MAX:
+ raise NodeError("bad_contexts")
+ clean = set()
+ for value in values:
+ if (not isinstance(value, str) or not value.strip() or len(value) > CONTEXT_VALUE_MAX
+ or not _printable(value)):
+ raise NodeError("bad_contexts")
+ clean.add(value.strip().lower())
+ out[key.lower()] = sorted(clean)
+ return dict(sorted(out.items()))
+
+
+def contexts_json(contexts: dict[str, list[str]]) -> str:
+ return json.dumps(contexts, sort_keys=True, separators=(",", ":"))
+
+
+def from_snapshot(raw) -> dict:
+ """A node as the bridge reported it. Trusted, but still shaped."""
+ if not isinstance(raw, dict) or not isinstance(raw.get("key"), str) or not raw["key"]:
+ raise NodeError("bad_node")
+ expiry = raw.get("expiry") or 0
+ if not isinstance(expiry, int) or expiry < 0:
+ raise NodeError("bad_node")
+ return {
+ "key": raw["key"][:KEY_MAX * 2],
+ "value": bool(raw.get("value", True)),
+ "contexts": clean_contexts(raw.get("contexts")),
+ "expiry": expiry,
+ }
+
+
+def from_request(raw, *, now: int | None = None, new: bool) -> dict:
+ """A node staff sent. New nodes are normalised the way LuckPerms would
+ store them; nodes being removed must name an existing node exactly."""
+ if not isinstance(raw, dict):
+ raise NodeError("bad_node")
+ key = raw.get("key")
+ if not isinstance(key, str):
+ raise NodeError("bad_node")
+ key = key.strip()
+ if not key or len(key) > KEY_MAX or not _printable(key):
+ raise NodeError("bad_node")
+ value = raw.get("value", True)
+ if not isinstance(value, bool):
+ raise NodeError("bad_node")
+ expiry = raw.get("expiry") or 0
+ if not isinstance(expiry, int) or isinstance(expiry, bool) or expiry < 0:
+ raise NodeError("bad_expiry")
+ if new:
+ node_kind = kind(key)
+ if node_kind == "group":
+ group = group_of(key)
+ if not GROUP_NAME.match(group or ""):
+ raise NodeError("bad_group_name")
+ key = f"group.{group}"
+ elif node_kind == "permission":
+ if " " in key:
+ raise NodeError("bad_node")
+ key = key.lower()
+ if expiry:
+ current = int(time.time()) if now is None else now
+ if expiry <= current or expiry > current + EXPIRY_MAX_SECONDS:
+ raise NodeError("bad_expiry")
+ return {"key": key, "value": value, "contexts": clean_contexts(raw.get("contexts")), "expiry": expiry}
+
+
+def same(a: dict, b: dict) -> bool:
+ return (a["key"] == b["key"] and a["value"] == b["value"] and a["contexts"] == b["contexts"]
+ and a["expiry"] == b["expiry"])
+
+
+def active(node: dict, now: int) -> bool:
+ return not node["expiry"] or node["expiry"] > now
+
+
+def describe(node: dict) -> str:
+ """LuckPerms command wording for the action log: "parent add staff server=main"."""
+ tail = "".join(f" {key}={value}" for key, values in node["contexts"].items() for value in values)
+ temp = node["expiry"] > 0
+ group = group_of(node["key"])
+ if group is not None and node["value"]:
+ verb = "parent addtemp" if temp else "parent add"
+ return f"{verb} {group}{tail}"
+ verb = "permission settemp" if temp else "permission set"
+ return f"{verb} {node['key']} {str(node['value']).lower()}{tail}"
+
+
+def describe_removal(node: dict) -> str:
+ tail = "".join(f" {key}={value}" for key, values in node["contexts"].items() for value in values)
+ temp = node["expiry"] > 0
+ group = group_of(node["key"])
+ if group is not None and node["value"]:
+ return f"{'parent removetemp' if temp else 'parent remove'} {group}{tail}"
+ return f"{'permission unsettemp' if temp else 'permission unset'} {node['key']}{tail}"
diff --git a/backend/src/luckperms/policy.py b/backend/src/luckperms/policy.py
new file mode 100644
index 00000000..2022433a
--- /dev/null
+++ b/backend/src/luckperms/policy.py
@@ -0,0 +1,75 @@
+"""Who may change which LuckPerms nodes. The rules are in policy.yaml."""
+from __future__ import annotations
+
+import fnmatch
+from dataclasses import dataclass
+from functools import lru_cache
+from pathlib import Path
+
+import yaml
+
+from src.auth import roles
+
+from . import nodes
+
+POLICY_PATH = Path(__file__).with_name("policy.yaml")
+
+
+@dataclass(frozen=True)
+class Policy:
+ admin_groups: frozenset[str]
+ patreon_groups: frozenset[str]
+ admin_permissions: tuple[str, ...]
+
+
+@lru_cache(maxsize=1)
+def load() -> Policy:
+ raw = yaml.safe_load(POLICY_PATH.read_text(encoding="utf-8")) or {}
+ return Policy(
+ admin_groups=frozenset(str(g).lower() for g in raw.get("admin_groups") or ()),
+ patreon_groups=frozenset(str(g).lower() for g in raw.get("patreon_groups") or ()),
+ admin_permissions=tuple(str(p).lower() for p in raw.get("admin_permissions") or ()),
+ )
+
+
+def _closure(groups: dict[str, dict], name: str) -> set[str]:
+ """The group and everything it inherits, in any context, expired or not."""
+ seen: set[str] = set()
+ queue = [name]
+ while queue:
+ current = queue.pop()
+ if current in seen:
+ continue
+ seen.add(current)
+ for node in groups.get(current, {}).get("nodes", ()):
+ parent = nodes.group_of(node["key"])
+ if parent and node["value"]:
+ queue.append(parent)
+ return seen
+
+
+def group_role(policy: Policy, groups: dict[str, dict], group: str) -> str:
+ """admin when the group and everything it inherits are admin groups; else root."""
+ return "admin" if _closure(groups, group.lower()) <= policy.admin_groups else "root"
+
+
+def node_role(policy: Policy, groups: dict[str, dict], node: dict) -> str:
+ """The lowest site role that may add or remove this node on a player, whatever its value."""
+ key = node["key"].lower()
+ node_kind = nodes.kind(key)
+ if node_kind == "group":
+ return group_role(policy, groups, nodes.group_of(key) or "")
+ if node_kind == "meta" or "*" in key or key.startswith("r="):
+ return "root"
+ if any(fnmatch.fnmatchcase(key, pattern) for pattern in policy.admin_permissions):
+ return "admin"
+ return "root"
+
+
+def may_edit_node(policy: Policy, groups: dict[str, dict], role: str | None, node: dict) -> bool:
+ return roles.rank(role) >= roles.rank(node_role(policy, groups, node))
+
+
+def may_edit_definitions(role: str | None) -> bool:
+ """Group definitions and tracks change what everyone in them can do."""
+ return role == "root"
diff --git a/backend/src/luckperms/policy.yaml b/backend/src/luckperms/policy.yaml
new file mode 100644
index 00000000..5d260b60
--- /dev/null
+++ b/backend/src/luckperms/policy.yaml
@@ -0,0 +1,41 @@
+# Who may change what in LuckPerms from the staff panel. Mods only view.
+# Group definitions and tracks are root-only, and so is every group not listed here.
+
+# Groups admins may add to or remove from players, and move players between on tracks.
+# A listed group only counts while every group it inherits is listed too.
+admin_groups:
+ - default
+ - commoner
+ - legacy
+ - noble
+ - gilded
+ - ascended
+ - helper_player
+ - helper_inactive
+ - helper
+
+# Granted by the Patreon sync (TFMCWeb on Main). Its reconcile resets permanent,
+# global grants of these groups for linked patrons, so the panel warns about that.
+patreon_groups:
+ - noble
+ - gilded
+ - ascended
+
+# Player permissions admins may set or unset. Shell-style patterns, any case.
+# Every other permission, and any key containing "*" or written as a regex
+# ("r=..."), is root-only. These are the progress and preference nodes in use.
+admin_permissions:
+ - "professions.*"
+ - "rulequiz.completed"
+ - "tfmc.has_spawned"
+ - "tfmcresourcepack.enable"
+ - "tips.off"
+ - "pvp.non_lethal"
+ - "chatbubble.see"
+ - "armourshop.*"
+ - "mural.*"
+ - "mcpet.*"
+ - "deepsea.*"
+ - "bloodmagic.complete"
+ - "necromancy.complete"
+ - "ia.user.craft.*"
diff --git a/backend/src/luckperms/test_luckperms.py b/backend/src/luckperms/test_luckperms.py
new file mode 100644
index 00000000..0d85fc7f
--- /dev/null
+++ b/backend/src/luckperms/test_luckperms.py
@@ -0,0 +1,407 @@
+import json
+import time
+
+import pytest
+from fastapi import FastAPI
+from fastapi.testclient import TestClient
+
+from src.api import admin_routes, auth_routes
+from src.auth import users
+from src.luckperms import changes, mirror, nodes, policy
+
+SITE = "https://www.tfminecraft.net"
+ORIGIN = {"Origin": SITE}
+SESSION = "__Host-tfmc_session"
+PLUGIN = {"X-Plugin-Key": "primary-key"}
+SECONDARY = {"X-Plugin-Key": "tutorial-key"}
+
+ALICE = "11111111-1111-1111-1111-111111111111"
+BOB = "22222222-2222-2222-2222-222222222222"
+ADMIN_MC = "33333333-3333-3333-3333-333333333333"
+ROOT_MC = "44444444-4444-4444-4444-444444444444"
+
+
+def node(key, value=True, contexts=None, expiry=0):
+ return {"key": key, "value": value, "contexts": contexts or {}, "expiry": expiry}
+
+
+def group(name, weight, *keys, prefix=None):
+ group_nodes = [node(k) for k in keys]
+ if weight is not None:
+ group_nodes.append(node(f"weight.{weight}"))
+ if prefix:
+ group_nodes.append(node(f"prefix.{weight}.{prefix}"))
+ return {"name": name, "display_name": None, "weight": weight, "nodes": group_nodes}
+
+
+def snapshot(**overrides):
+ payload = {
+ "server": "main",
+ "generated_at": 1791321779,
+ "revision": 1000,
+ "hash": "h1",
+ "groups": [
+ group("default", None),
+ group("commoner", 10, "group.default", prefix="&7Commoner"),
+ group("noble", 20, "group.commoner", prefix="&6Noble"),
+ group("helper_player", 120, "group.commoner"),
+ group("helper_inactive", 130, "group.helper_player"),
+ group("helper", 140, "group.helper_inactive", "essentials.kick", prefix="&dHelper"),
+ group("staff_player", 180, "group.commoner"),
+ group("staff_inactive", 190, "group.staff_player"),
+ group("staff", 200, "group.staff_inactive", "*", prefix="&cStaff"),
+ ],
+ "tracks": [
+ {"name": "helper", "groups": ["helper_player", "helper_inactive", "helper"]},
+ {"name": "staff", "groups": ["staff_player", "staff_inactive", "staff"]},
+ ],
+ "users": [
+ {"uuid": ALICE, "name": "Alice", "nodes": [node("group.commoner"), node("professions.chef_1"),
+ node("tips.off", contexts={"server": ["main"]})]},
+ {"uuid": BOB, "name": "Bob", "nodes": [node("group.helper_player")]},
+ {"uuid": ADMIN_MC, "name": "AdminMC", "nodes": [node("group.staff")]},
+ {"uuid": ROOT_MC, "name": "RootMC", "nodes": [node("group.staff")]},
+ ],
+ }
+ payload.update(overrides)
+ return payload
+
+
+@pytest.fixture
+def env(database, monkeypatch):
+ monkeypatch.setenv("DISCORD_AUTH_ENABLED", "1")
+ monkeypatch.setenv("DISCORD_CLIENT_ID", "client-id")
+ monkeypatch.setenv("DISCORD_CLIENT_SECRET", "client-secret")
+ monkeypatch.setenv("DISCORD_GUILD_ID", "999999999999999999")
+ monkeypatch.setenv("SITE_PUBLIC_URL", SITE)
+ monkeypatch.setenv("PLUGIN_KEY", "primary-key")
+ monkeypatch.setenv("PLUGIN_KEYS_SECONDARY", "tutorial-key")
+ monkeypatch.delenv("DISCORD_REDIRECT_URI", raising=False)
+ monkeypatch.delenv("PS_PRODUCTION", raising=False)
+ monkeypatch.delenv("LUCKPERMS_READ_ONLY", raising=False)
+ return database
+
+
+@pytest.fixture
+def app(env):
+ app = FastAPI()
+ app.include_router(auth_routes.auth_router)
+ admin_routes.install(app)
+ return app
+
+
+def account(db, name, role="player", minecraft=None):
+ discord_id = str(100000000000000000 + abs(hash(name)) % 10**15)
+ token = users.sign_in(
+ {"discord_user_id": discord_id, "discord_username": name, "discord_global_name": name.title(),
+ "discord_avatar": None},
+ guild_member=True,
+ )
+ with db.connect() as conn:
+ conn.execute("UPDATE users SET role = ? WHERE discord_user_id = ?", (role, discord_id))
+ if minecraft:
+ conn.execute(
+ "INSERT INTO discord_links (player_uuid, discord_user_id, discord_username, minecraft_name, linked_at) "
+ "VALUES (?, ?, ?, ?, ?)", (minecraft, discord_id, name, name, "2026-10-01T00:00:00Z"))
+ conn.commit()
+ return token
+
+
+def client(app, token=None):
+ c = TestClient(app, base_url="https://testserver")
+ if token:
+ c.cookies.set(SESSION, token, domain="testserver.local")
+ return c
+
+
+@pytest.fixture
+def bridge(app):
+ plugin = client(app)
+ assert plugin.put("/luckperms/plugin/snapshot", headers=PLUGIN, json=snapshot()).json()["users"] == 4
+ assert plugin.get("/luckperms/plugin/changes", headers=PLUGIN).json() == {"changes": []}
+ return plugin
+
+
+@pytest.fixture
+def staff(env):
+ return {
+ "mod": account(env, "mona", "mod"),
+ "admin": account(env, "adam", "admin", ADMIN_MC),
+ "root": account(env, "rory", "root", ROOT_MC),
+ }
+
+
+def submit(app, token, target_type, target, ops, reason="Event reward"):
+ return client(app, token).post("/admin/luckperms/changes", headers=ORIGIN,
+ json={"target_type": target_type, "target": target, "ops": ops, "reason": reason})
+
+
+def audit_rows(db):
+ with db.connect() as conn:
+ return [dict(r) for r in conn.execute("SELECT * FROM admin_audit ORDER BY id")]
+
+
+# --------------------
+# Nodes and policy
+# --------------------
+
+
+def test_new_nodes_are_normalised_and_checked():
+ assert nodes.from_request({"key": " Essentials.Fly ", "contexts": {"Server": "MAIN"}}, new=True) == node(
+ "essentials.fly", contexts={"server": ["main"]})
+ assert nodes.from_request({"key": "group.Helper"}, new=True)["key"] == "group.helper"
+ for bad in ({"key": ""}, {"key": "a b"}, {"key": "x", "value": "yes"}, {"key": "group.no spaces"},
+ {"key": "x", "contexts": {"server": []}}, {"key": "x\x07y"}):
+ with pytest.raises(nodes.NodeError):
+ nodes.from_request(bad, new=True)
+ now = int(time.time())
+ with pytest.raises(nodes.NodeError, match="bad_expiry"):
+ nodes.from_request({"key": "x", "expiry": now - 5}, new=True, now=now)
+ assert nodes.from_request({"key": "x", "expiry": now + 60}, new=True, now=now)["expiry"] == now + 60
+ assert nodes.describe(node("group.staff", contexts={"server": ["main"]})) == "parent add staff server=main"
+ assert nodes.describe(node("tips.off", value=False, expiry=5)) == "permission settemp tips.off false"
+ assert nodes.describe_removal(node("group.staff")) == "parent remove staff"
+
+
+def test_policy_puts_staff_power_out_of_admin_reach():
+ rules = policy.load()
+ groups = {g["name"]: g for g in snapshot()["groups"]}
+ assert policy.node_role(rules, groups, node("group.helper")) == "admin"
+ assert policy.node_role(rules, groups, node("group.helper+")) == "root"
+ assert policy.node_role(rules, groups, node("group.staff")) == "root"
+ assert policy.node_role(rules, groups, node("professions.chef_1")) == "admin"
+ assert policy.node_role(rules, groups, node("professions.chef_1", value=False)) == "admin"
+ for key in ("*", "professions.*", "essentials.kick", "tfmc.staff", "luckperms.user.parent.add",
+ "minecraft.command.op", "r=professions.*", "prefix.300.&cOwner", "weight.999", "meta.x.y"):
+ assert policy.node_role(rules, groups, node(key)) == "root", key
+ # A listed group that inherits a staff group, in any context, is no longer an admin group.
+ groups["commoner"]["nodes"].append(node("group.staff_player", contexts={"server": ["dev"]}))
+ assert policy.node_role(rules, groups, node("group.noble")) == "root"
+
+
+# --------------------
+# Plugin routes
+# --------------------
+
+
+def test_plugin_routes_need_the_primary_key(app):
+ plugin = client(app)
+ assert plugin.put("/luckperms/plugin/snapshot", json=snapshot()).status_code == 401
+ assert plugin.put("/luckperms/plugin/snapshot", headers=SECONDARY, json=snapshot()).status_code == 403
+ assert plugin.get("/luckperms/plugin/changes", headers=SECONDARY).status_code == 403
+
+
+def test_snapshot_replaces_mirror_and_unchanged_checks_hash(bridge, env):
+ assert bridge.put("/luckperms/plugin/snapshot", headers=PLUGIN, json={"groups": "x"}).status_code == 400
+ assert bridge.put("/luckperms/plugin/snapshot", headers=PLUGIN,
+ json=snapshot(revision=None)).json()["detail"] == "bad_revision"
+ assert bridge.post("/luckperms/plugin/snapshot/unchanged", headers=PLUGIN,
+ json={"hash": "other", "revision": 1001}).json() == {"ok": False, "need_full": True}
+ assert bridge.post("/luckperms/plugin/snapshot/unchanged", headers=PLUGIN,
+ json={"hash": "h1", "revision": 1001}).json() == {"ok": True}
+ smaller = snapshot(hash="h2", revision=1002, users=[{"uuid": BOB.upper(), "name": "Bob", "nodes": []}])
+ assert bridge.put("/luckperms/plugin/snapshot", headers=PLUGIN, json=smaller).json()["users"] == 1
+ # A delayed older snapshot cannot undo a newer one.
+ late = bridge.put("/luckperms/plugin/snapshot", headers=PLUGIN, json=snapshot(hash="old", revision=1001))
+ assert late.json() == {"ok": True, "stale": True}
+ with env.connect() as conn:
+ assert [r["uuid"] for r in conn.execute("SELECT uuid FROM lp_users")] == [BOB]
+ assert mirror.status(conn)["applying"] is True
+
+
+# --------------------
+# Staff reads
+# --------------------
+
+
+def test_mods_see_everything_but_change_nothing(app, bridge, staff, env):
+ mod = client(app, staff["mod"])
+ overview = mod.get("/admin/luckperms").json()
+ assert [g["name"] for g in overview["groups"]][:3] == ["staff", "staff_inactive", "staff_player"]
+ staff_group = overview["groups"][0]
+ assert staff_group["prefix"] == "&cStaff" and staff_group["members"] == 2 and staff_group["min_role"] == "root"
+ assert overview["rights"] == {"read_only": False, "change_players": False, "edit_definitions": False}
+ player = mod.get(f"/admin/luckperms/players/{ALICE}").json()["player"]
+ assert player["rank"] == "commoner" and player["rank_prefix"] == "&7Commoner"
+ assert player["inherits"] == ["commoner", "default"]
+ assert not any(n["editable"] for n in player["nodes"])
+ listing = mod.get("/admin/luckperms/players", params={"q": "ali"}).json()
+ assert [r["name"] for r in listing["rows"]] == ["Alice"] and listing["rows"][0]["rank"] == "commoner"
+ members = mod.get("/admin/luckperms/players", params={"group": "staff"}).json()
+ assert {r["name"] for r in members["rows"]} == {"AdminMC", "RootMC"}
+ detail = mod.get("/admin/luckperms/groups/helper").json()["group"]
+ assert detail["inherits"] == ["helper_inactive", "helper_player", "commoner", "default"]
+ assert detail["tracks"] == ["helper"] and detail["children"] == []
+ assert submit(app, staff["mod"], "user", ALICE, [{"op": "add_node", "node": node("tips.off")}]).status_code == 403
+
+
+def test_views_mark_what_an_admin_may_change(app, bridge, staff):
+ admin = client(app, staff["admin"])
+ bob = admin.get(f"/admin/luckperms/players/{BOB}").json()
+ helper = next(t for t in bob["player"]["tracks"] if t["name"] == "helper")
+ assert helper["position"] == 0 and helper["can_promote"] and helper["can_demote"]
+ assert not next(t for t in bob["player"]["tracks"] if t["name"] == "staff")["can_promote"]
+ addable = {g["name"] for g in bob["groups"] if g["addable"]}
+ assert "helper" in addable and "staff" not in addable
+ own = admin.get(f"/admin/luckperms/players/{ADMIN_MC}").json()
+ assert own["rights"]["change_this_player"] is False
+ assert admin.get(f"/admin/luckperms/players/{ROOT_MC}").json()["rights"]["change_this_player"] is False
+
+
+# --------------------
+# Changes
+# --------------------
+
+
+def test_admin_change_round_trip(app, bridge, staff, env):
+ response = submit(app, staff["admin"], "user", ALICE, [
+ {"op": "add_node", "node": {"key": "group.Noble"}},
+ {"op": "remove_node", "node": node("professions.chef_1")},
+ ])
+ assert response.status_code == 200, response.text
+ change = response.json()
+ assert change["status"] == "pending"
+ assert change["description"] == "parent add noble; permission unset professions.chef_1"
+ again = submit(app, staff["admin"], "user", ALICE, [{"op": "add_node", "node": node("tips.on")}])
+ assert again.json()["detail"] == "change_pending"
+
+ sent = bridge.get("/luckperms/plugin/changes", headers=PLUGIN).json()["changes"]
+ assert len(sent) == 1 and sent[0]["actor_name"] == "web:adam" and sent[0]["actor_uuid"] == ADMIN_MC
+ assert sent[0]["ops"] == [{"op": "add_node", "node": node("group.noble")},
+ {"op": "remove_node", "node": node("professions.chef_1")}]
+ assert bridge.get("/luckperms/plugin/changes", headers=PLUGIN).json() == {"changes": []}
+
+ state = {"uuid": ALICE, "name": "Alice", "nodes": [node("group.commoner"), node("group.noble")]}
+ result = bridge.post("/luckperms/plugin/changes/results", headers=PLUGIN,
+ json={"results": [{"id": change["id"], "ok": True, "revision": 2000, "state": state}]})
+ assert result.json() == {"ok": True, "recorded": 1}
+ admin = client(app, staff["admin"])
+ assert admin.get(f"/admin/luckperms/changes/{change['id']}").json()["status"] == "applied"
+ player = admin.get(f"/admin/luckperms/players/{ALICE}").json()["player"]
+ assert player["rank"] == "noble" and [n["key"] for n in player["nodes"]] == ["group.commoner", "group.noble"]
+ # The mirror changed under the stored hash, so the bridge is asked for a full snapshot.
+ assert bridge.post("/luckperms/plugin/snapshot/unchanged", headers=PLUGIN,
+ json={"hash": "h1", "revision": 2001}).json()["ok"] is False
+ # And a snapshot built before the change cannot hide it again.
+ assert bridge.put("/luckperms/plugin/snapshot", headers=PLUGIN, json=snapshot(revision=1500)).json()["stale"]
+ rows = [r for r in audit_rows(env) if r["action"] == "luckperms.change"]
+ assert [(r["outcome"], json.loads(r["detail_json"]).get("error")) for r in rows] == [("ok", None), ("conflict", "change_pending")]
+
+
+@pytest.mark.parametrize("role,target,ops,status,code", [
+ ("admin", ALICE, [{"op": "add_node", "node": {"key": "group.staff"}}], 403, "root_only"),
+ ("admin", ALICE, [{"op": "add_node", "node": {"key": "essentials.*"}}], 403, "root_only"),
+ ("admin", ALICE, [{"op": "add_node", "node": {"key": "essentials.fly"}}], 403, "root_only"),
+ ("admin", ALICE, [{"op": "add_node", "node": {"key": "professions.chef_2", "value": False}}], 200, None),
+ ("admin", ALICE, [{"op": "add_node", "node": {"key": "prefix.500.&cKing"}}], 403, "root_only"),
+ ("admin", ADMIN_MC, [{"op": "add_node", "node": {"key": "tips.off"}}], 403, "cannot_act_on_self"),
+ ("admin", ROOT_MC, [{"op": "add_node", "node": {"key": "tips.off"}}], 403, "target_outranks_you"),
+ ("admin", ALICE, [{"op": "add_node", "node": node("group.commoner")}], 409, "node_exists"),
+ ("admin", ALICE, [{"op": "remove_node", "node": node("tips.off")}], 409, "node_missing"),
+ ("admin", ALICE, [{"op": "add_node", "node": {"key": "group.ghost"}}], 409, "group_missing"),
+ ("admin", ALICE, [{"op": "demote", "track": "helper"}], 409, "not_on_track"),
+ ("admin", BOB, [{"op": "promote", "track": "staff"}], 403, "root_only"),
+ ("admin", "nope", [{"op": "add_node", "node": {"key": "x"}}], 400, "bad_uuid"),
+ ("admin", ALICE, [{"op": "create_group"}], 400, "bad_ops"),
+ ("admin", "staff", [{"op": "add_node", "node": {"key": "x"}}], 400, "bad_uuid"),
+ ("root", ALICE, [{"op": "add_node", "node": {"key": "group.staff"}}], 200, None),
+ ("root", ROOT_MC, [{"op": "add_node", "node": {"key": "essentials.*"}}], 200, None),
+])
+def test_user_change_rules(app, bridge, staff, role, target, ops, status, code):
+ response = submit(app, staff[role], "user", target, ops)
+ assert response.status_code == status, response.text
+ if code:
+ assert response.json()["detail"] == code
+
+
+def test_promote_and_demote_become_explicit_ops(app, bridge, staff):
+ change = submit(app, staff["admin"], "user", BOB, [{"op": "promote", "track": "helper"}]).json()
+ assert change["description"] == "promote helper"
+ ops = bridge.get("/luckperms/plugin/changes", headers=PLUGIN).json()["changes"][0]["ops"]
+ assert ops == [{"op": "remove_node", "node": node("group.helper_player")},
+ {"op": "add_node", "node": node("group.helper_inactive")}]
+ bridge.post("/luckperms/plugin/changes/results", headers=PLUGIN,
+ json={"results": [{"id": change["id"], "ok": False, "error": "node_missing"}]})
+ assert client(app, staff["admin"]).get(f"/admin/luckperms/changes/{change['id']}").json()["error"] == "node_missing"
+ demote = submit(app, staff["admin"], "user", BOB, [{"op": "demote", "track": "helper"}])
+ assert demote.status_code == 200
+ ops = bridge.get("/luckperms/plugin/changes", headers=PLUGIN).json()["changes"][0]["ops"]
+ assert ops == [{"op": "remove_node", "node": node("group.helper_player")}]
+
+
+def test_group_and_track_definitions_are_root_only(app, bridge, staff):
+ ops = [{"op": "create_group"}, {"op": "add_node", "node": {"key": "group.commoner"}},
+ {"op": "add_node", "node": {"key": "weight.60"}}]
+ assert submit(app, staff["admin"], "group", "knight", ops).json()["detail"] == "root_only"
+ created = submit(app, staff["root"], "group", "knight", ops)
+ assert created.status_code == 200 and created.json()["description"] == (
+ "create; parent add commoner; permission set weight.60 true")
+ cycle = submit(app, staff["root"], "group", "commoner", [{"op": "add_node", "node": {"key": "group.staff"}}])
+ assert cycle.json()["detail"] == "inheritance_cycle"
+ assert submit(app, staff["root"], "group", "default", [{"op": "delete_group"}]).json()["detail"] == "cannot_delete_default"
+ track = submit(app, staff["root"], "track", "helper", [{"op": "set_groups", "groups": ["helper_player", "helper"]}])
+ assert track.status_code == 200
+ assert submit(app, staff["root"], "track", "staff", [{"op": "set_groups", "groups": ["ghost"]}]).json()[
+ "detail"] == "group_missing"
+
+
+def test_changes_need_a_connected_bridge_and_expire(app, staff, env, monkeypatch):
+ monkeypatch.delenv("LUCKPERMS_READ_ONLY", raising=False)
+ plugin = client(app)
+ plugin.put("/luckperms/plugin/snapshot", headers=PLUGIN, json=snapshot())
+ response = submit(app, staff["admin"], "user", ALICE, [{"op": "add_node", "node": {"key": "tips.off"}}])
+ assert response.status_code == 503 and response.json()["detail"] == "bridge_offline"
+ plugin.get("/luckperms/plugin/changes", headers=PLUGIN)
+ change = submit(app, staff["admin"], "user", ALICE, [{"op": "add_node", "node": {"key": "tips.off"}}]).json()
+ later = time.time() + changes.PENDING_TTL_SECONDS + 5
+ monkeypatch.setattr(changes.time, "time", lambda: later)
+ assert changes.fetch_for_bridge() == []
+ assert changes.get_change(change["id"])["status"] == "expired"
+
+
+def test_changes_are_sent_once_and_unanswered_ones_become_unknown(app, bridge, staff, monkeypatch):
+ change = submit(app, staff["admin"], "user", ALICE, [{"op": "add_node", "node": {"key": "tips.off"}}]).json()
+ clock = [time.time()]
+ monkeypatch.setattr(changes.time, "time", lambda: clock[0])
+ assert len(changes.fetch_for_bridge()) == 1
+ clock[0] += changes.RESULT_WAIT_SECONDS + 1
+ assert changes.fetch_for_bridge() == []
+ assert changes.get_change(change["id"])["status"] == "unknown"
+ # A late result still settles it.
+ changes.record_results([{"id": change["id"], "ok": True, "revision": 5000, "state": None}])
+ assert changes.get_change(change["id"])["status"] == "applied"
+
+
+def test_admins_cannot_touch_in_game_staff_even_unlinked(app, bridge, staff):
+ plugin = client(app)
+ users_now = snapshot()["users"] + [{"uuid": "55555555-5555-5555-5555-555555555555", "name": "Mod",
+ "nodes": [node("group.staff_player", contexts={"server": ["main"]})]}]
+ plugin.put("/luckperms/plugin/snapshot", headers=PLUGIN, json=snapshot(revision=1100, users=users_now))
+ response = submit(app, staff["admin"], "user", "55555555-5555-5555-5555-555555555555",
+ [{"op": "add_node", "node": {"key": "tips.off"}}])
+ assert response.json()["detail"] == "target_is_staff"
+
+
+def test_demotion_after_queueing_cancels_the_change(app, bridge, staff, env):
+ change = submit(app, staff["admin"], "user", ALICE, [{"op": "add_node", "node": {"key": "tips.off"}}]).json()
+ with env.connect() as conn:
+ conn.execute("UPDATE users SET role = 'mod' WHERE discord_username = 'adam'")
+ conn.commit()
+ assert changes.fetch_for_bridge() == []
+ assert changes.get_change(change["id"])["error"] == "actor_changed"
+
+
+def test_read_only_site_shows_but_never_changes(app, bridge, staff, monkeypatch):
+ monkeypatch.setenv("LUCKPERMS_READ_ONLY", "1")
+ root = client(app, staff["root"])
+ assert root.get("/admin/luckperms").json()["rights"] == {
+ "read_only": True, "change_players": False, "edit_definitions": False}
+ response = submit(app, staff["root"], "user", ALICE, [{"op": "add_node", "node": {"key": "tips.off"}}])
+ assert response.json()["detail"] == "bridge_offline"
+
+
+def test_malformed_change_is_audited(app, bridge, staff, env):
+ response = client(app, staff["admin"]).post("/admin/luckperms/changes", headers=ORIGIN, json={"target": ALICE})
+ assert response.status_code == 422
+ row = audit_rows(env)[-1]
+ assert (row["action"], row["outcome"]) == ("luckperms.change", "invalid")
diff --git a/backend/src/luckperms/views.py b/backend/src/luckperms/views.py
new file mode 100644
index 00000000..61c8dc15
--- /dev/null
+++ b/backend/src/luckperms/views.py
@@ -0,0 +1,229 @@
+"""What the staff panel shows of LuckPerms, with what the viewer may change marked on it.
+
+The marks only decide which controls appear; changes.queue_change checks
+everything again when a change is made.
+"""
+from __future__ import annotations
+
+import time
+
+from src.auth import roles
+from src.auth.admin import AdminError
+from src.auth.players import canonical_uuid
+from src.skins.db import connect
+
+from . import changes, mirror, nodes, policy
+
+
+def _can_change(viewer: dict) -> bool:
+ return roles.can(viewer["role"], "change_luckperms")
+
+
+def _group_summary(group: dict, groups: dict[str, dict], counts: dict[str, int], rules: policy.Policy,
+ now: int) -> dict:
+ prefix = mirror.meta_value(group, "prefix", now)
+ suffix = mirror.meta_value(group, "suffix", now)
+ return {
+ "name": group["name"],
+ "display_name": group["display_name"],
+ "weight": group["weight"],
+ "prefix": prefix[1] if prefix else None,
+ "suffix": suffix[1] if suffix else None,
+ "parents": mirror.parents(group, now),
+ "members": counts.get(group["name"], 0),
+ "permissions": sum(1 for n in group["nodes"] if nodes.kind(n["key"]) == "permission"),
+ "min_role": policy.group_role(rules, groups, group["name"]),
+ "patreon": group["name"] in rules.patreon_groups,
+ }
+
+
+def _sorted_groups(groups: dict[str, dict]) -> list[dict]:
+ return sorted(groups.values(), key=lambda g: (-(g["weight"] or 0), g["name"]))
+
+
+def _rights(viewer: dict) -> dict:
+ writable = not changes.read_only()
+ return {
+ "read_only": not writable,
+ "change_players": writable and _can_change(viewer),
+ "edit_definitions": writable and policy.may_edit_definitions(viewer["role"]),
+ }
+
+
+def overview(viewer: dict) -> dict:
+ rules = policy.load()
+ now = int(time.time())
+ with connect() as conn:
+ groups = mirror.load_groups(conn)
+ counts = mirror.group_counts(conn)
+ tracks = mirror.load_tracks(conn)
+ status = mirror.status(conn)
+ players = conn.execute("SELECT COUNT(*) FROM lp_users").fetchone()[0]
+ return {
+ "status": status,
+ "players": players,
+ "groups": [_group_summary(g, groups, counts, rules, now) for g in _sorted_groups(groups)],
+ "tracks": tracks,
+ "rights": _rights(viewer),
+ "patreon_groups": sorted(rules.patreon_groups),
+ "recent": changes.list_changes(limit=20),
+ }
+
+
+def _annotate(node: dict, editable: bool) -> dict:
+ return {**node, "kind": nodes.kind(node["key"]), "group": nodes.group_of(node["key"]), "editable": editable}
+
+
+def group_detail(viewer: dict, name: str) -> dict:
+ name = (name or "").strip().lower()
+ if not nodes.GROUP_NAME.match(name):
+ raise AdminError(400, "bad_group_name", "invalid")
+ rules = policy.load()
+ now = int(time.time())
+ with connect() as conn:
+ groups = mirror.load_groups(conn)
+ if name not in groups:
+ raise AdminError(404, "group_missing", "not_found")
+ counts = mirror.group_counts(conn)
+ tracks = mirror.load_tracks(conn)
+ members = mirror.search_users(conn, "", name, 1)
+ status = mirror.status(conn)
+ group = groups[name]
+ editable = policy.may_edit_definitions(viewer["role"])
+ children = sorted(g["name"] for g in groups.values() if name in mirror.parents(g, now))
+ return {
+ "status": status,
+ "group": {
+ **_group_summary(group, groups, counts, rules, now),
+ "nodes": [_annotate(n, editable) for n in group["nodes"]],
+ "inherits": mirror.inherited(groups, [name], now)[1:],
+ "children": children,
+ "tracks": [t["name"] for t in tracks if name in t["groups"]],
+ },
+ "members": members,
+ "all_groups": [g["name"] for g in _sorted_groups(groups)],
+ "rights": _rights(viewer),
+ "changes": changes.list_changes("group", name, 20),
+ "pending": _pending("group", name),
+ }
+
+
+def _pending(target_type: str, target: str) -> dict | None:
+ for change in changes.list_changes(target_type, target, 5):
+ if change["status"] in changes.UNFINISHED:
+ return change
+ return None
+
+
+def _account(conn, uuid: str) -> dict | None:
+ row = conn.execute(
+ """
+ SELECT dl.discord_user_id, dl.discord_username, u.id AS user_id, u.role
+ FROM discord_links dl LEFT JOIN users u ON u.discord_user_id = dl.discord_user_id
+ WHERE lower(dl.player_uuid) = ?
+ """,
+ (uuid,),
+ ).fetchone()
+ return dict(row) if row else None
+
+
+def _track_moves(conn, uuid: str, tracks: list[dict], viewer: dict, allowed: bool, now: int) -> list[dict]:
+ """Where the player stands on each track, and whether the viewer may move them."""
+ held = mirror.user_nodes(conn, uuid)
+ out = []
+ for track in tracks:
+ positions = [track["groups"].index(nodes.group_of(n["key"])) for n in held
+ if n["value"] and not n["contexts"] and nodes.group_of(n["key"]) in track["groups"]]
+ moves = {}
+ for op in ("promote", "demote"):
+ moves[op] = False
+ if not allowed:
+ continue
+ try:
+ ops, _ = changes.build_user_ops(conn, uuid, [{"op": op, "track": track["name"]}], now)
+ changes.check_ops_allowed(conn, viewer["role"], "user", ops)
+ moves[op] = bool(ops)
+ except AdminError:
+ pass
+ out.append({
+ "name": track["name"],
+ "groups": track["groups"],
+ "position": positions[0] if len(positions) == 1 else None,
+ "ambiguous": len(positions) > 1,
+ "can_promote": moves["promote"],
+ "can_demote": moves["demote"],
+ })
+ return out
+
+
+def user_detail(viewer: dict, raw_uuid: str) -> dict:
+ uuid = canonical_uuid(raw_uuid)
+ if uuid is None:
+ raise AdminError(400, "bad_uuid", "invalid")
+ rules = policy.load()
+ now = int(time.time())
+ with connect() as conn:
+ row = mirror.user_row(conn, uuid)
+ if row is None:
+ raise AdminError(404, "player_not_found", "not_found")
+ groups = mirror.load_groups(conn)
+ held = mirror.user_nodes(conn, uuid)
+ tracks = mirror.load_tracks(conn)
+ status = mirror.status(conn)
+ account = _account(conn, uuid)
+ allowed = _can_change(viewer) and not changes.read_only()
+ if allowed:
+ try:
+ changes.check_user_target(conn, viewer, uuid)
+ except AdminError:
+ allowed = False
+ track_moves = _track_moves(conn, uuid, tracks, viewer, allowed, now)
+ direct = [nodes.group_of(n["key"]) for n in held
+ if nodes.group_of(n["key"]) and n["value"] and not n["contexts"] and nodes.active(n, now)]
+ rank = mirror.rank_group(groups, held, now)
+ rank_group = groups.get(rank) if rank else None
+ prefix = mirror.meta_value(rank_group, "prefix", now) if rank_group else None
+ return {
+ "status": status,
+ "player": {
+ "uuid": uuid,
+ "name": row["name"],
+ "rank": rank,
+ "rank_prefix": prefix[1] if prefix else None,
+ "inherits": mirror.inherited(groups, [d for d in direct if d], now),
+ "nodes": [_annotate(n, allowed and policy.may_edit_node(rules, groups, viewer["role"], n)) for n in held],
+ "tracks": track_moves,
+ "account": account,
+ },
+ "groups": [{"name": g["name"], "weight": g["weight"],
+ "min_role": policy.group_role(rules, groups, g["name"]),
+ "patreon": g["name"] in rules.patreon_groups,
+ "addable": allowed and policy.may_edit_node(rules, groups, viewer["role"],
+ {"key": f"group.{g['name']}"})}
+ for g in _sorted_groups(groups)],
+ "rights": {**_rights(viewer), "change_this_player": allowed,
+ "admin_permissions": list(rules.admin_permissions)},
+ "changes": changes.list_changes("user", uuid, 20),
+ "pending": _pending("user", uuid),
+ }
+
+
+def users(viewer: dict, q: str, group: str | None, page: int) -> dict:
+ group = (group or "").strip().lower() or None
+ if group is not None and not nodes.GROUP_NAME.match(group):
+ raise AdminError(400, "bad_group_name", "invalid")
+ now = int(time.time())
+ with connect() as conn:
+ try:
+ listing = mirror.search_users(conn, q, group, page)
+ except mirror.SnapshotError as exc:
+ raise AdminError(400, exc.code, "invalid") from None
+ groups = mirror.load_groups(conn)
+ for row in listing["rows"]:
+ held = mirror.user_nodes(conn, row["uuid"])
+ row["groups"] = [
+ {"name": nodes.group_of(n["key"]), "contexts": n["contexts"], "expiry": n["expiry"]}
+ for n in held if nodes.group_of(n["key"]) and n["value"] and nodes.active(n, now)
+ ]
+ row["rank"] = mirror.rank_group(groups, held, now)
+ return listing
diff --git a/backend/src/skins/schema.sql b/backend/src/skins/schema.sql
index 0e76e53d..3ca23c18 100644
--- a/backend/src/skins/schema.sql
+++ b/backend/src/skins/schema.sql
@@ -716,3 +716,85 @@ WHEN NEW.id IS NOT NULL AND EXISTS (SELECT 1 FROM admin_audit WHERE id = NEW.id)
BEGIN
SELECT RAISE(ABORT, 'admin_audit is append-only');
END;
+
+-- LuckPerms mirror for the staff panel. The TFMCWeb bridge publishes a whole
+-- snapshot, which replaces these tables in one transaction; see
+-- src/luckperms/README.md. Contexts are canonical JSON ({} when global) and
+-- expiry is unix seconds, 0 when permanent.
+CREATE TABLE IF NOT EXISTS lp_state (
+ id INTEGER PRIMARY KEY CHECK (id = 1),
+ server TEXT,
+ hash TEXT,
+ generated_at INTEGER,
+ -- Newest bridge revision applied: a snapshot's, or a change result's.
+ revision INTEGER,
+ received_at TEXT,
+ -- Last time the bridge confirmed the snapshot is still current.
+ checked_at TEXT,
+ -- Last time an applying bridge asked for changes.
+ polled_at TEXT
+);
+
+CREATE TABLE IF NOT EXISTS lp_groups (
+ name TEXT PRIMARY KEY,
+ display_name TEXT,
+ weight INTEGER
+);
+
+CREATE TABLE IF NOT EXISTS lp_group_nodes (
+ group_name TEXT NOT NULL,
+ key TEXT NOT NULL,
+ value INTEGER NOT NULL,
+ contexts TEXT NOT NULL,
+ expiry INTEGER NOT NULL
+);
+
+CREATE INDEX IF NOT EXISTS idx_lp_group_nodes_group ON lp_group_nodes(group_name);
+
+CREATE TABLE IF NOT EXISTS lp_tracks (
+ name TEXT PRIMARY KEY,
+ groups_json TEXT NOT NULL
+);
+
+CREATE TABLE IF NOT EXISTS lp_users (
+ uuid TEXT PRIMARY KEY,
+ name TEXT
+);
+
+CREATE INDEX IF NOT EXISTS idx_lp_users_name ON lp_users(lower(name));
+
+CREATE TABLE IF NOT EXISTS lp_user_nodes (
+ uuid TEXT NOT NULL,
+ key TEXT NOT NULL,
+ value INTEGER NOT NULL,
+ contexts TEXT NOT NULL,
+ expiry INTEGER NOT NULL
+);
+
+CREATE INDEX IF NOT EXISTS idx_lp_user_nodes_uuid ON lp_user_nodes(uuid);
+CREATE INDEX IF NOT EXISTS idx_lp_user_nodes_key ON lp_user_nodes(key);
+
+-- Changes staff queued for the bridge. Rows are kept as the change history.
+CREATE TABLE IF NOT EXISTS lp_changes (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ created_at TEXT NOT NULL,
+ actor_user_id INTEGER NOT NULL,
+ actor_name TEXT,
+ actor_role TEXT,
+ actor_minecraft_uuid TEXT,
+ target_type TEXT NOT NULL CHECK (target_type IN ('user', 'group', 'track')),
+ target TEXT NOT NULL,
+ target_name TEXT,
+ description TEXT NOT NULL,
+ ops_json TEXT NOT NULL,
+ reason TEXT NOT NULL,
+ status TEXT NOT NULL DEFAULT 'pending'
+ CHECK (status IN ('pending', 'sent', 'applied', 'failed', 'expired', 'unknown')),
+ attempts INTEGER NOT NULL DEFAULT 0,
+ sent_at TEXT,
+ finished_at TEXT,
+ error TEXT
+);
+
+CREATE INDEX IF NOT EXISTS idx_lp_changes_status ON lp_changes(status, id);
+CREATE INDEX IF NOT EXISTS idx_lp_changes_target ON lp_changes(target_type, target, id);
From d487c3be73869bb9b040d26f785b9ffddbe4e439 Mon Sep 17 00:00:00 2001
From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com>
Date: Wed, 7 Oct 2026 13:47:01 +0000
Subject: [PATCH 2/6] Ranks pages in the staff panel
Overview with player search, groups and tracks; a player page with
track promote/demote, groups and permissions; a group page with its
settings, nodes and members. Changes ask for a reason and follow the
bridge until applied. Player profiles link to their ranks and offer
the website role actions.
Co-Authored-By: Claude Opus 5.5 (1M context)
---
.../app/admin/ranks/groups/[name]/page.tsx | 20 +
frontend/app/admin/ranks/page.tsx | 17 +
.../app/admin/ranks/players/[uuid]/page.tsx | 21 +
frontend/app/components/admin/AdminNav.tsx | 3 +-
.../app/components/admin/AdminPanel.test.tsx | 3 +
.../app/components/admin/PlayerProfile.tsx | 44 +-
.../components/admin/ranks/ChangeConfirm.tsx | 118 ++++
.../app/components/admin/ranks/McText.tsx | 22 +
.../app/components/admin/ranks/RankGroup.tsx | 441 +++++++++++++++
.../admin/ranks/RankPlayer.test.tsx | 102 ++++
.../app/components/admin/ranks/RankPlayer.tsx | 522 ++++++++++++++++++
.../admin/ranks/RanksOverview.test.tsx | 112 ++++
.../components/admin/ranks/RanksOverview.tsx | 445 +++++++++++++++
frontend/app/components/admin/ranks/parts.tsx | 122 ++++
frontend/app/components/admin/ranks/ui.ts | 17 +
frontend/lib/admin/api.ts | 6 +-
frontend/lib/admin/luckperms.test.ts | 41 ++
frontend/lib/admin/luckperms.ts | 338 ++++++++++++
18 files changed, 2385 insertions(+), 9 deletions(-)
create mode 100644 frontend/app/admin/ranks/groups/[name]/page.tsx
create mode 100644 frontend/app/admin/ranks/page.tsx
create mode 100644 frontend/app/admin/ranks/players/[uuid]/page.tsx
create mode 100644 frontend/app/components/admin/ranks/ChangeConfirm.tsx
create mode 100644 frontend/app/components/admin/ranks/McText.tsx
create mode 100644 frontend/app/components/admin/ranks/RankGroup.tsx
create mode 100644 frontend/app/components/admin/ranks/RankPlayer.test.tsx
create mode 100644 frontend/app/components/admin/ranks/RankPlayer.tsx
create mode 100644 frontend/app/components/admin/ranks/RanksOverview.test.tsx
create mode 100644 frontend/app/components/admin/ranks/RanksOverview.tsx
create mode 100644 frontend/app/components/admin/ranks/parts.tsx
create mode 100644 frontend/app/components/admin/ranks/ui.ts
create mode 100644 frontend/lib/admin/luckperms.test.ts
create mode 100644 frontend/lib/admin/luckperms.ts
diff --git a/frontend/app/admin/ranks/groups/[name]/page.tsx b/frontend/app/admin/ranks/groups/[name]/page.tsx
new file mode 100644
index 00000000..3a7f3e2b
--- /dev/null
+++ b/frontend/app/admin/ranks/groups/[name]/page.tsx
@@ -0,0 +1,20 @@
+import Link from "next/link";
+import AdminNav from "../../../../components/admin/AdminNav";
+import RankGroup from "../../../../components/admin/ranks/RankGroup";
+
+export default async function AdminRankGroupPage({ params }: { params: Promise<{ name: string }> }) {
+ const { name } = await params;
+ const group = decodeURIComponent(name);
+ return (
+
+
+ Staff panel
+
+
+
+ ← Ranks
+
+
+
+ );
+}
diff --git a/frontend/app/admin/ranks/page.tsx b/frontend/app/admin/ranks/page.tsx
new file mode 100644
index 00000000..4bbfd190
--- /dev/null
+++ b/frontend/app/admin/ranks/page.tsx
@@ -0,0 +1,17 @@
+import AdminNav from "../../components/admin/AdminNav";
+import RanksOverview from "../../components/admin/ranks/RanksOverview";
+
+type SearchParams = Promise>;
+
+export default async function AdminRanksPage({ searchParams }: { searchParams: SearchParams }) {
+ const { q } = await searchParams;
+ return (
+
+
+ Staff panel
+
+
+
+
+ );
+}
diff --git a/frontend/app/admin/ranks/players/[uuid]/page.tsx b/frontend/app/admin/ranks/players/[uuid]/page.tsx
new file mode 100644
index 00000000..37fd3955
--- /dev/null
+++ b/frontend/app/admin/ranks/players/[uuid]/page.tsx
@@ -0,0 +1,21 @@
+import Link from "next/link";
+import AdminNav from "../../../../components/admin/AdminNav";
+import RankPlayer from "../../../../components/admin/ranks/RankPlayer";
+
+export default async function AdminRankPlayerPage({ params }: { params: Promise<{ uuid: string }> }) {
+ const { uuid } = await params;
+ const id = decodeURIComponent(uuid);
+ return (
+
+
+ Staff panel
+
+
+
+ ← Ranks
+
+ {/* Keyed so moving between players starts afresh. */}
+
+
+ );
+}
diff --git a/frontend/app/components/admin/AdminNav.tsx b/frontend/app/components/admin/AdminNav.tsx
index e3a3c084..eebf36ba 100644
--- a/frontend/app/components/admin/AdminNav.tsx
+++ b/frontend/app/components/admin/AdminNav.tsx
@@ -4,11 +4,12 @@ const LINKS = [
{ href: "/admin", label: "Accounts", key: "accounts" },
{ href: "/admin/players", label: "Players", key: "players" },
{ href: "/admin/movement", label: "Movement", key: "movement" },
+ { href: "/admin/ranks", label: "Ranks", key: "ranks" },
] as const;
export default function AdminNav({ current }: { current: (typeof LINKS)[number]["key"] }) {
return (
-
+
{LINKS.map((link) => (
{
expect(canManage(ADMIN, acct(2, "x", "mod"))).toBe(true);
expect(canManage(ADMIN, acct(2, "x", "admin"))).toBe(false);
expect(canManage(ADMIN, acct(1, "adam", "player"))).toBe(false);
+ const ROOT: AdminMe = { ...ADMIN, user_id: 9, role: "root" };
+ expect(canManage(ROOT, acct(3, "co-owner", "root"))).toBe(true);
+ expect(canManage(ROOT, acct(9, "self", "root"))).toBe(false);
});
it("offers the account's new choices after a role change", async () => {
diff --git a/frontend/app/components/admin/PlayerProfile.tsx b/frontend/app/components/admin/PlayerProfile.tsx
index f53da4aa..d6ae77ce 100644
--- a/frontend/app/components/admin/PlayerProfile.tsx
+++ b/frontend/app/components/admin/PlayerProfile.tsx
@@ -10,8 +10,11 @@ import {
getPlayer,
getPlayerActivity,
getPlayerSessions,
+ canManage,
roleLabel,
type ActivityEntry,
+ type AdminAccount,
+ type AdminMe,
type PlayerProfile as Profile,
type PlayerSession,
type WorldPoint,
@@ -21,6 +24,7 @@ import { formatAgo, formatDate, formatEpoch } from "../../../lib/admin/time";
import { groupByDay, sessionRow } from "../../../lib/admin/sessionDays";
import { StaffGateMessage, gateKind, type GateKind } from "./StaffGate";
import MovementCard from "./MovementCard";
+import AccountActions from "./AccountActions";
const panelClass =
"mt-6 rounded-sm border border-[color-mix(in_srgb,var(--tfmc-cream)_18%,transparent)] bg-[color-mix(in_srgb,var(--tfmc-forest)_28%,transparent)] p-5";
@@ -49,20 +53,21 @@ type Load = { kind: "loading" } | { kind: GateKind } | { kind: "failed"; message
export default function PlayerProfile({ uuid }: { uuid: string }) {
const [load, setLoad] = useState({ kind: "loading" });
+ const [me, setMe] = useState(null);
// Movement is for admins and the owner; mods see the profile without it.
- const [movement, setMovement] = useState(false);
+ const movement = me?.capabilities.includes("view_player_movement") ?? false;
useEffect(() => {
let live = true;
getAdminMe()
- .then((me) => live && setMovement(me.capabilities.includes("view_player_movement")))
+ .then((value) => live && setMe(value))
.catch(() => undefined);
return () => {
live = false;
};
}, []);
- useEffect(() => {
+ const loadProfile = useCallback(() => {
let live = true;
getPlayer(uuid)
.then((profile) => live && setLoad({ kind: "ready", profile }))
@@ -76,6 +81,8 @@ export default function PlayerProfile({ uuid }: { uuid: string }) {
};
}, [uuid]);
+ useEffect(() => loadProfile(), [loadProfile]);
+
if (load.kind === "loading") return Loading…
;
if (load.kind === "failed") {
return (
@@ -107,6 +114,11 @@ export default function PlayerProfile({ uuid }: { uuid: string }) {
Previously {profile.past_names.map((n) => n.name).join(", ")}
) : null}
+
+
+ In-game ranks and permissions →
+
+
{notice ? (
{notice}
@@ -152,9 +164,13 @@ export default function PlayerProfile({ uuid }: { uuid: string }) {
: "Hasn’t signed in"}
- ) : (
-
Not linked to Discord.
- )}
+ ) : null}
+ {me && profile.discord && profile.account && canManage(me, websiteAccount(profile)) ? (
+ {
+ loadProfile();
+ }} />
+ ) : null}
+ {profile.discord ? null : Not linked to Discord.
}
@@ -181,6 +197,22 @@ export default function PlayerProfile({ uuid }: { uuid: string }) {
);
}
+/** The profile's website account in the shape the account actions use. */
+function websiteAccount(profile: Profile): AdminAccount {
+ const account = profile.account!;
+ return {
+ user_id: account.user_id,
+ discord_user_id: profile.discord!.discord_user_id,
+ discord_username: profile.discord!.discord_username,
+ discord_global_name: account.discord_global_name,
+ avatar_url: account.avatar_url,
+ role: account.role,
+ minecraft_name: profile.minecraft_name,
+ created_at: account.created_at,
+ last_login_at: account.last_login_at,
+ };
+}
+
function place(point: WorldPoint | null): string {
if (!point) return "";
return `${point.world ?? "?"} ${point.x}, ${point.y}, ${point.z}`;
diff --git a/frontend/app/components/admin/ranks/ChangeConfirm.tsx b/frontend/app/components/admin/ranks/ChangeConfirm.tsx
new file mode 100644
index 00000000..312d3bfe
--- /dev/null
+++ b/frontend/app/components/admin/ranks/ChangeConfirm.tsx
@@ -0,0 +1,118 @@
+"use client";
+
+import { FormEvent, ReactNode, useEffect, useRef, useState } from "react";
+import { AccountApiError } from "../../../../lib/account/api";
+import { adminErrorMessage } from "../../../../lib/admin/api";
+import {
+ LP_ERRORS,
+ lpErrorMessage,
+ STATUS_LABELS,
+ submitLpChange,
+ waitForChange,
+ type LpChange,
+ type LpOp,
+ type LpTargetType,
+} from "../../../../lib/admin/luckperms";
+import { buttonClass, errorClass, inputClass, mutedClass, quietButtonClass, warnClass } from "./ui";
+
+/**
+ * Asks for a reason, queues the change, then follows it until the server has
+ * applied it. onDone runs once it is settled, whatever the outcome.
+ */
+export default function ChangeConfirm({
+ summary,
+ warning,
+ targetType,
+ target,
+ ops,
+ confirmLabel = "Apply",
+ onDone,
+ onCancel,
+}: {
+ summary: ReactNode;
+ warning?: ReactNode;
+ targetType: LpTargetType;
+ target: string;
+ ops: LpOp[];
+ confirmLabel?: string;
+ onDone: (change: LpChange | null) => void;
+ onCancel: () => void;
+}) {
+ const [reason, setReason] = useState("");
+ const [busy, setBusy] = useState(false);
+ const [error, setError] = useState(null);
+ const [change, setChange] = useState(null);
+ const aborter = useRef(null);
+
+ useEffect(() => () => aborter.current?.abort(), []);
+
+ async function onSubmit(e: FormEvent) {
+ e.preventDefault();
+ setBusy(true);
+ setError(null);
+ try {
+ const queued = await submitLpChange(targetType, target, ops, reason);
+ setChange(queued);
+ aborter.current = new AbortController();
+ const settled = await waitForChange(queued.id, { signal: aborter.current.signal });
+ setChange(settled);
+ if (settled.status === "applied") onDone(settled);
+ } catch (err) {
+ setError(err instanceof AccountApiError && err.message in LP_ERRORS ? LP_ERRORS[err.message] : adminErrorMessage(err));
+ } finally {
+ setBusy(false);
+ }
+ }
+
+ if (change && change.status !== "applied") {
+ const settled = change.status !== "pending" && change.status !== "sent";
+ return (
+
+
+ {STATUS_LABELS[change.status]}
+ {change.error ? `: ${lpErrorMessage(change.error)}` : "…"}
+ {change.status === "unknown" ? " Check the player before trying again." : ""}
+ {!settled && !busy ? " It is still queued; this page will show it once the server applies it." : ""}
+
+ {settled || !busy ? (
+
onDone(change)}>
+ Close
+
+ ) : null}
+
+ );
+ }
+
+ return (
+
+ );
+}
diff --git a/frontend/app/components/admin/ranks/McText.tsx b/frontend/app/components/admin/ranks/McText.tsx
new file mode 100644
index 00000000..5581bb8d
--- /dev/null
+++ b/frontend/app/components/admin/ranks/McText.tsx
@@ -0,0 +1,22 @@
+import { parseMcText } from "../../../../lib/admin/luckperms";
+
+/** A prefix or suffix drawn with its Minecraft colour codes. */
+export default function McText({ text, className = "" }: { text: string; className?: string }) {
+ return (
+
+ {parseMcText(text).map((segment, index) => (
+
+ {segment.text}
+
+ ))}
+
+ );
+}
diff --git a/frontend/app/components/admin/ranks/RankGroup.tsx b/frontend/app/components/admin/ranks/RankGroup.tsx
new file mode 100644
index 00000000..2b4a38f4
--- /dev/null
+++ b/frontend/app/components/admin/ranks/RankGroup.tsx
@@ -0,0 +1,441 @@
+"use client";
+
+import Link from "next/link";
+import { useRouter } from "next/navigation";
+import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
+import { AccountApiError } from "../../../../lib/account/api";
+import { adminErrorMessage } from "../../../../lib/admin/api";
+import {
+ contextLabel,
+ expiryLabel,
+ getLpGroup,
+ getLpPlayers,
+ STATUS_LABELS,
+ type LpGroupDetail,
+ type LpNode,
+ type LpOp,
+ type LpPlayerPage,
+ type LpShownNode,
+} from "../../../../lib/admin/luckperms";
+import { StaffGateMessage, gateKind, type GateKind } from "../StaffGate";
+import ChangeConfirm from "./ChangeConfirm";
+import McText from "./McText";
+import { ChangeHistory, GroupChip, StatusLine } from "./parts";
+import { PlayerResults } from "./RanksOverview";
+import { SERVERS } from "./RankPlayer";
+import { badgeClass, buttonClass, headingClass, inputClass, mutedClass, panelClass, quietButtonClass, rowClass, warnClass } from "./ui";
+
+type Load = { kind: "loading" } | { kind: GateKind } | { kind: "failed"; message: string } | { kind: "ready"; data: LpGroupDetail };
+type Pending = { key: string; summary: ReactNode; ops: LpOp[]; label?: string; warning?: string };
+
+function plain(node: LpShownNode): LpNode {
+ return { key: node.key, value: node.value, contexts: node.contexts, expiry: node.expiry };
+}
+
+function nodeKey(node: LpNode): string {
+ return `${node.key}|${node.value}|${contextLabel(node.contexts)}|${node.expiry}`;
+}
+
+export default function RankGroup({ name }: { name: string }) {
+ const router = useRouter();
+ const [load, setLoad] = useState({ kind: "loading" });
+ const [pending, setPending] = useState(null);
+ const [members, setMembers] = useState(null);
+
+ const refresh = useCallback(async () => {
+ try {
+ const data = await getLpGroup(name);
+ setLoad({ kind: "ready", data });
+ setMembers(data.members);
+ } catch (err) {
+ const status = err instanceof AccountApiError ? err.status : 0;
+ setLoad(status === 400 || status === 404 ? { kind: "failed", message: adminErrorMessage(err) } : { kind: gateKind(err) });
+ }
+ }, [name]);
+
+ useEffect(() => {
+ void refresh();
+ }, [refresh]);
+
+ if (load.kind === "loading") return Loading…
;
+ if (load.kind === "failed") {
+ return (
+
+ {load.message === "group_missing" ? "That group doesn’t exist." : load.message}
+
+ );
+ }
+ if (load.kind !== "ready") return ;
+
+ const { data } = load;
+ const { group, rights } = data;
+ const canEdit = rights.edit_definitions && data.status.applying && !data.pending;
+ const weightNode = group.nodes.find((n) => n.key.startsWith("weight.") && !Object.keys(n.contexts).length);
+ const prefixNode = group.nodes.find((n) => n.key.startsWith("prefix.") && !Object.keys(n.contexts).length);
+
+ const confirm = (key: string) =>
+ pending?.key === key ? (
+ {
+ setPending(null);
+ if (pending.key === "delete") router.push("/admin/ranks");
+ else void refresh();
+ }}
+ onCancel={() => setPending(null)}
+ />
+ ) : null;
+
+ return (
+ <>
+
+
+ {group.name}
+ {group.min_role === "root" ? Owner only : null}
+ {group.patreon ? Patreon : null}
+
+
+ {group.prefix ? (
+ <>
+ Prefix ·{" "}
+ >
+ ) : null}
+ Weight {group.weight ?? "none"} · {group.members.toLocaleString()} players hold it directly
+ {group.tracks.length ? ` · on track ${group.tracks.join(", ")}` : ""}
+
+
+ {data.pending ? (
+
+ {STATUS_LABELS[data.pending.status]}: {data.pending.description} .
+
+ ) : null}
+
+
+
+ Inheritance
+
+ Inherits:{" "}
+ {group.inherits.length ? group.inherits.map((g) => ) : "nothing"}
+
+
+ Inherited by: {group.children.length ? group.children.map((g) => ) : "no other group"}
+
+
+
+ {canEdit ? (
+
+ Settings
+ Changes here affect everyone in {group.name} and every group that inherits it.
+
+ {confirm("settings")}
+
+ ) : null}
+
+ g !== group.name)}
+ canEdit={canEdit}
+ onAsk={setPending}
+ confirm={pending && pending.key !== "settings" && pending.key !== "delete" ? confirm(pending.key) : null}
+ />
+
+
+ Players in {group.name}
+ Players who hold it themselves, in any context. Inheriting groups aren’t listed.
+ {members ? (
+
+ void getLpPlayers({ group: group.name, page })
+ .then(setMembers)
+ .catch(() => undefined)
+ }
+ />
+ ) : null}
+
+
+
+ Changes from the website
+
+
+
+ {canEdit && group.name !== "default" ? (
+
+ Delete group
+ {pending?.key === "delete" ? (
+ confirm("delete")
+ ) : (
+
+ setPending({
+ key: "delete",
+ summary: (
+ <>
+ Delete {group.name} .
+ >
+ ),
+ warning: `${group.members} players hold it directly and keep a dangling entry; ${group.children.length} groups inherit it. Remove it from tracks first.`,
+ ops: [{ op: "delete_group" }],
+ label: "Delete group",
+ })
+ }
+ >
+ Delete {group.name}…
+
+ )}
+
+ ) : null}
+
+
+ ← All groups
+
+
+ >
+ );
+}
+
+function SettingsForm({
+ group,
+ weightNode,
+ prefixNode,
+ onAsk,
+}: {
+ group: string;
+ weightNode?: LpShownNode;
+ prefixNode?: LpShownNode;
+ onAsk: (pending: Pending) => void;
+}) {
+ const currentWeight = weightNode ? weightNode.key.slice("weight.".length) : "";
+ const prefixParts = prefixNode ? prefixNode.key.split(".") : [];
+ const currentPriority = prefixParts[1] ?? "";
+ const currentPrefix = prefixNode ? prefixParts.slice(2).join(".") : "";
+ const [weight, setWeight] = useState(currentWeight);
+ const [prefix, setPrefix] = useState(currentPrefix);
+ const [priority, setPriority] = useState(currentPriority || currentWeight || "0");
+
+ function save() {
+ const ops: LpOp[] = [];
+ if (weight !== currentWeight) {
+ if (weightNode) ops.push({ op: "remove_node", node: plain(weightNode) });
+ if (weight) ops.push({ op: "add_node", node: { key: `weight.${weight}` } });
+ }
+ if (prefix !== currentPrefix || priority !== currentPriority) {
+ if (prefixNode) ops.push({ op: "remove_node", node: plain(prefixNode) });
+ if (prefix) ops.push({ op: "add_node", node: { key: `prefix.${priority || "0"}.${prefix}` } });
+ }
+ if (!ops.length) return;
+ onAsk({
+ key: "settings",
+ summary: (
+ <>
+ {weight !== currentWeight ? <>Weight {currentWeight || "none"} → {weight || "none"}. > : null}
+ {prefix !== currentPrefix || priority !== currentPriority ? (
+ <>
+ Prefix {currentPrefix ? : "none"} → {prefix ? : "none"}
+ {prefix ? ` (priority ${priority || 0})` : ""}.
+ >
+ ) : null}
+ >
+ ),
+ ops,
+ label: `Save ${group}`,
+ });
+ }
+
+ return (
+
+
+ Weight
+ setWeight(e.target.value.replace(/[^0-9]/g, ""))} inputMode="numeric" className={inputClass} />
+
+
+ Prefix (& colour codes)
+ setPrefix(e.target.value)} spellCheck={false} className={`${inputClass} font-mono`} />
+
+
+ Priority
+ setPriority(e.target.value.replace(/[^0-9]/g, ""))} inputMode="numeric" className={inputClass} />
+
+ {prefix ? (
+
+ Preview
+
+ ) : null}
+
+ Save
+
+
+ );
+}
+
+function NodesPanel({
+ group,
+ nodes,
+ allGroups,
+ canEdit,
+ onAsk,
+ confirm,
+}: {
+ group: string;
+ nodes: LpShownNode[];
+ allGroups: string[];
+ canEdit: boolean;
+ onAsk: (pending: Pending) => void;
+ confirm: ReactNode;
+}) {
+ const [filter, setFilter] = useState("");
+ const [key, setKey] = useState("");
+ const [value, setValue] = useState(true);
+ const [server, setServer] = useState("");
+ const [parent, setParent] = useState("");
+ const shown = useMemo(() => {
+ const text = filter.trim().toLowerCase();
+ return text ? nodes.filter((n) => n.key.toLowerCase().includes(text)) : nodes;
+ }, [nodes, filter]);
+
+ function addNode(node: Partial & { key: string }, words: ReactNode) {
+ onAsk({ key: "add", summary: words, ops: [{ op: "add_node", node }], label: "Add" });
+ }
+
+ return (
+
+ );
+}
diff --git a/frontend/app/components/admin/ranks/RankPlayer.test.tsx b/frontend/app/components/admin/ranks/RankPlayer.test.tsx
new file mode 100644
index 00000000..e74ca851
--- /dev/null
+++ b/frontend/app/components/admin/ranks/RankPlayer.test.tsx
@@ -0,0 +1,102 @@
+/** @vitest-environment jsdom */
+import { cleanup, fireEvent, render, screen, within } from "@testing-library/react";
+import { afterEach, beforeEach, expect, it, vi } from "vitest";
+import RankPlayer from "./RankPlayer";
+import { AccountApiError } from "../../../../lib/account/api";
+import { getLpPlayer, submitLpChange, waitForChange, type LpChange, type LpPlayer } from "../../../../lib/admin/luckperms";
+
+vi.mock("../../../../lib/admin/luckperms", async (importOriginal) => ({
+ ...await importOriginal(),
+ getLpPlayer: vi.fn(),
+ waitForChange: vi.fn(),
+ submitLpChange: vi.fn(),
+}));
+
+const UUID = "22222222-2222-2222-2222-222222222222";
+
+function player(overrides: Partial = {}): LpPlayer {
+ return {
+ status: { server: "main", snapshot_at: "2026-10-07T12:00:00Z", checked_at: new Date().toISOString(), has_snapshot: true, applying: true, polled_at: null },
+ player: {
+ uuid: UUID,
+ name: "Bob",
+ rank: "helper_player",
+ rank_prefix: "&dHelper",
+ inherits: ["helper_player", "commoner", "default"],
+ nodes: [
+ { key: "group.helper_player", value: true, contexts: {}, expiry: 0, kind: "group", group: "helper_player", editable: true },
+ { key: "professions.chef_1", value: true, contexts: { server: ["main"] }, expiry: 0, kind: "permission", group: null, editable: true },
+ { key: "essentials.fly", value: true, contexts: {}, expiry: 0, kind: "permission", group: null, editable: false },
+ ],
+ tracks: [
+ { name: "helper", groups: ["helper_player", "helper_inactive", "helper"], position: 0, ambiguous: false, can_promote: true, can_demote: true },
+ { name: "staff", groups: ["staff_player", "staff_inactive", "staff"], position: null, ambiguous: false, can_promote: false, can_demote: false },
+ ],
+ account: { discord_user_id: "1", discord_username: "bobby", user_id: 4, role: "player" },
+ },
+ groups: [
+ { name: "staff", weight: 200, min_role: "root", patreon: false, addable: false },
+ { name: "noble", weight: 20, min_role: "admin", patreon: true, addable: true },
+ ],
+ rights: { read_only: false, change_players: true, edit_definitions: false, change_this_player: true, admin_permissions: ["professions.*"], ...overrides },
+ changes: [],
+ pending: null,
+ };
+}
+
+beforeEach(() => vi.stubEnv("NEXT_PUBLIC_API_URL", "https://www.tfminecraft.net/api"));
+afterEach(() => {
+ cleanup();
+ vi.clearAllMocks();
+ vi.unstubAllEnvs();
+});
+
+it("shows rank, tracks, groups and permissions, with actions only where allowed", async () => {
+ vi.mocked(getLpPlayer).mockResolvedValue(player());
+ render( );
+ expect(await screen.findByText("Helper")).toBeTruthy();
+ const tracks = screen.getByRole("region", { name: "Tracks" });
+ const helper = within(tracks).getByText("helper_player", { selector: "[aria-current]" });
+ expect(helper).toBeTruthy();
+ expect(within(tracks).getAllByRole("button", { name: "Promote" })).toHaveLength(1);
+ const permissions = screen.getByRole("region", { name: "Permissions" });
+ expect(within(permissions).getByText("server=main")).toBeTruthy();
+ // The essentials node is root-only, so it has no Remove button.
+ expect(within(permissions).getAllByRole("button", { name: "Remove" })).toHaveLength(1);
+ expect(within(permissions).getByText(/Admins may set professions\.\*/)).toBeTruthy();
+});
+
+it("promotes along a track and refreshes once applied", async () => {
+ vi.mocked(getLpPlayer).mockResolvedValue(player());
+ const queued = { id: 3, status: "pending", description: "promote helper" } as LpChange;
+ vi.mocked(submitLpChange).mockResolvedValue(queued);
+ vi.mocked(waitForChange).mockResolvedValue({ ...queued, status: "applied" });
+ render( );
+ fireEvent.click(await screen.findByRole("button", { name: "Promote" }));
+ expect(screen.getByText(/to helper_inactive/)).toBeTruthy();
+ fireEvent.change(screen.getByLabelText("Reason (recorded)"), { target: { value: "Passed helper training" } });
+ fireEvent.click(screen.getAllByRole("button", { name: "Promote" }).at(-1)!);
+ await vi.waitFor(() => expect(getLpPlayer).toHaveBeenCalledTimes(2));
+ expect(submitLpChange).toHaveBeenCalledWith("user", UUID, [{ op: "promote", track: "helper" }], "Passed helper training");
+});
+
+it("warns about Patreon groups and explains refusals", async () => {
+ vi.mocked(getLpPlayer).mockResolvedValue(player());
+ vi.mocked(submitLpChange).mockRejectedValue(new AccountApiError("bridge_offline", 503));
+ render( );
+ const groups = await screen.findByRole("region", { name: "Groups" });
+ fireEvent.change(within(groups).getByLabelText("Add to group"), { target: { value: "noble" } });
+ fireEvent.click(within(groups).getByRole("button", { name: "Add" }));
+ expect(within(groups).getByText(/Patreon-managed/)).toBeTruthy();
+ fireEvent.change(within(groups).getByLabelText("Reason (recorded)"), { target: { value: "Gift" } });
+ fireEvent.click(within(groups).getAllByRole("button", { name: "Add" }).at(-1)!);
+ expect(await within(groups).findByText(/No server is applying rank changes/)).toBeTruthy();
+});
+
+it("is read-only when the viewer may not change this player", async () => {
+ vi.mocked(getLpPlayer).mockResolvedValue(player({ change_this_player: false }));
+ render( );
+ expect(await screen.findByText(/You can view Bob’s ranks but not change them/)).toBeTruthy();
+ expect(screen.queryByRole("button", { name: "Promote" })).toBeNull();
+ expect(screen.queryByRole("button", { name: "Remove" })).toBeNull();
+});
diff --git a/frontend/app/components/admin/ranks/RankPlayer.tsx b/frontend/app/components/admin/ranks/RankPlayer.tsx
new file mode 100644
index 00000000..d36e5359
--- /dev/null
+++ b/frontend/app/components/admin/ranks/RankPlayer.tsx
@@ -0,0 +1,522 @@
+"use client";
+
+import Link from "next/link";
+import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
+import { AccountApiError } from "../../../../lib/account/api";
+import { adminErrorMessage, roleLabel } from "../../../../lib/admin/api";
+import {
+ contextLabel,
+ DURATIONS,
+ expiryLabel,
+ getLpPlayer,
+ STATUS_LABELS,
+ type LpNode,
+ type LpOp,
+ type LpPlayer,
+ type LpShownNode,
+} from "../../../../lib/admin/luckperms";
+import { StaffGateMessage, gateKind, type GateKind } from "../StaffGate";
+import ChangeConfirm from "./ChangeConfirm";
+import McText from "./McText";
+import { ChangeHistory, GroupChip, StatusLine } from "./parts";
+import { buttonClass, chipClass, headingClass, inputClass, mutedClass, panelClass, quietButtonClass, rowClass, warnClass } from "./ui";
+
+/** Servers that share this LuckPerms storage; a node may apply to just one. */
+export const SERVERS = ["main", "dev", "tutorial"] as const;
+const PATREON_NOTE =
+ "Patreon-managed: for a linked patron, the Patreon sync resets permanent all-server grants of this group within 30 minutes.";
+
+type Load = { kind: "loading" } | { kind: GateKind } | { kind: "failed"; message: string } | { kind: "ready"; data: LpPlayer };
+
+type Pending = { key: string; summary: ReactNode; ops: LpOp[]; warning?: string; label?: string };
+
+export default function RankPlayer({ uuid }: { uuid: string }) {
+ const [load, setLoad] = useState({ kind: "loading" });
+ const [pending, setPending] = useState(null);
+
+ const refresh = useCallback(async () => {
+ try {
+ setLoad({ kind: "ready", data: await getLpPlayer(uuid) });
+ } catch (err) {
+ const status = err instanceof AccountApiError ? err.status : 0;
+ setLoad(status === 400 || status === 404 ? { kind: "failed", message: adminErrorMessage(err) } : { kind: gateKind(err) });
+ }
+ }, [uuid]);
+
+ useEffect(() => {
+ void refresh();
+ }, [refresh]);
+
+ if (load.kind === "loading") return Loading…
;
+ if (load.kind === "failed") {
+ return (
+
+ {load.message}
+
+ );
+ }
+ if (load.kind !== "ready") return ;
+
+ const { data } = load;
+ const { player, rights } = data;
+ const canChange = rights.change_this_player && data.status.applying && !data.pending;
+ const name = player.name ?? "this player";
+
+ function ask(next: Pending) {
+ setPending(next);
+ }
+
+ function confirmFor(key: string) {
+ if (!pending || pending.key !== key) return null;
+ return (
+ {
+ setPending(null);
+ void refresh();
+ }}
+ onCancel={() => setPending(null)}
+ />
+ );
+ }
+
+ const groupNodes = player.nodes.filter((n) => n.kind === "group");
+ const otherNodes = player.nodes.filter((n) => n.kind !== "group");
+
+ return (
+ <>
+
+
+ {player.rank_prefix ? (
+ <>
+ {" "}
+ >
+ ) : null}
+ {player.name ?? "Unknown name"}
+
+ {player.uuid}
+
+ Rank {player.rank ?? "default"}
+ {player.account?.discord_username ? ` · Discord @${player.account.discord_username}` : " · No Discord link"}
+ {player.account?.role && player.account.role !== "player" ? ` · Website ${roleLabel(player.account.role)}` : ""}
+ {" · "}
+
+ Player profile
+
+
+
+ {data.pending ? (
+
+ {STATUS_LABELS[data.pending.status]}: {data.pending.description} .{" "}
+ void refresh()}>
+ Refresh
+
+
+ ) : null}
+ {rights.change_players && !rights.change_this_player && !rights.read_only ? (
+
+ You can view {name}’s ranks but not change them: they are you, website staff at or above your role, or in-game staff.
+
+ ) : null}
+
+
+ {player.tracks.length ? (
+
+ Tracks
+
+ {player.tracks.map((track) => (
+
+
+ {track.name}
+ {track.groups.map((group, index) => (
+
+ {index ? → : null}
+
+ {group}
+
+
+ ))}
+ {canChange && (track.can_promote || track.can_demote) ? (
+
+ {track.can_demote ? (
+
+ ask({
+ key: `track-${track.name}`,
+ summary: (
+ <>
+ Demote {name} on {track.name}
+ {track.position === 0 ? `, off the track` : ` to ${track.groups[(track.position ?? 1) - 1]}`}.
+ >
+ ),
+ ops: [{ op: "demote", track: track.name }],
+ label: "Demote",
+ })
+ }
+ >
+ Demote
+
+ ) : null}
+ {track.can_promote ? (
+
+ ask({
+ key: `track-${track.name}`,
+ summary: (
+ <>
+ Promote {name} on {track.name} to{" "}
+ {track.groups[track.position === null ? 0 : track.position + 1]}.
+ >
+ ),
+ ops: [{ op: "promote", track: track.name }],
+ label: "Promote",
+ })
+ }
+ >
+ Promote
+
+ ) : null}
+
+ ) : null}
+
+ {track.ambiguous ? Holds more than one group on this track.
: null}
+ {confirmFor(`track-${track.name}`)}
+
+ ))}
+
+
+ ) : null}
+
+
+ Groups
+ {player.inherits.length ? (
+ Inherits {player.inherits.join(" → ")}.
+ ) : null}
+ {groupNodes.length ? (
+
+ {groupNodes.map((node) => (
+
+ ask({
+ key: nodeKey(node),
+ summary: (
+ <>
+ Remove {name} from {node.group}
+ {describeScope(node)}.
+ >
+ ),
+ ops: [{ op: "remove_node", node: plain(node) }],
+ warning: data.groups.find((g) => g.name === node.group)?.patreon ? PATREON_NOTE : undefined,
+ label: "Remove",
+ })
+ }
+ >
+
+ {!node.value ? denied : null}
+
+ ))}
+
+ ) : (
+ No groups of their own: they have the default rank.
+ )}
+ {pending && groupNodes.some((n) => nodeKey(n) === pending.key) ? confirmFor(pending.key) : null}
+ {canChange ? (
+ g.addable).map((g) => g.name)}
+ patreon={data.groups.filter((g) => g.patreon).map((g) => g.name)}
+ name={name}
+ onAsk={ask}
+ />
+ ) : null}
+ {pending?.key === "add-group" ? confirmFor("add-group") : null}
+
+
+ nodeKey(n) === pending.key)) ? confirmFor(pending.key) : null}
+ />
+
+
+ Changes from the website
+
+
+ >
+ );
+}
+
+function nodeKey(node: LpNode): string {
+ return `${node.key}|${node.value}|${contextLabel(node.contexts)}|${node.expiry}`;
+}
+
+function plain(node: LpShownNode): LpNode {
+ return { key: node.key, value: node.value, contexts: node.contexts, expiry: node.expiry };
+}
+
+function describeScope(node: LpNode): string {
+ const parts = [contextLabel(node.contexts) ? `on ${contextLabel(node.contexts)}` : "", expiryLabel(node.expiry).toLowerCase()];
+ const text = parts.filter(Boolean).join(", ");
+ return text ? ` (${text})` : "";
+}
+
+function NodeRow({
+ node,
+ canChange,
+ onRemove,
+ children,
+}: {
+ node: LpShownNode;
+ canChange: boolean;
+ onRemove: () => void;
+ children: ReactNode;
+}) {
+ return (
+
+ {children}
+ {contextLabel(node.contexts) ? {contextLabel(node.contexts)} : null}
+ {node.expiry ? {expiryLabel(node.expiry)} : null}
+ {canChange && node.editable ? (
+
+ Remove
+
+ ) : null}
+
+ );
+}
+
+function scopeFields(server: string, duration: number): Pick {
+ return {
+ contexts: server ? { server: [server] } : {},
+ expiry: duration ? Math.floor(Date.now() / 1000) + duration : 0,
+ };
+}
+
+function ScopeInputs({
+ server,
+ setServer,
+ duration,
+ setDuration,
+}: {
+ server: string;
+ setServer: (value: string) => void;
+ duration: number;
+ setDuration: (value: number) => void;
+}) {
+ return (
+ <>
+
+ Where
+ setServer(e.target.value)} className={inputClass}>
+ All servers
+ {SERVERS.map((s) => (
+
+ {s} only
+
+ ))}
+
+
+
+ For
+ setDuration(Number(e.target.value))} className={inputClass}>
+ {DURATIONS.map((d) => (
+
+ {d.label}
+
+ ))}
+
+
+ >
+ );
+}
+
+function AddForm({
+ kind,
+ groups,
+ patreon,
+ name,
+ onAsk,
+}: {
+ kind: "group";
+ groups: string[];
+ patreon: string[];
+ name: string;
+ onAsk: (pending: Pending) => void;
+}) {
+ const [group, setGroup] = useState("");
+ const [server, setServer] = useState("");
+ const [duration, setDuration] = useState(0);
+ if (!groups.length) return null;
+ return (
+
+
+ Add to group
+ setGroup(e.target.value)} className={inputClass}>
+ Choose…
+ {groups.map((g) => (
+
+ {g}
+
+ ))}
+
+
+
+ {
+ const node = { key: `group.${group}`, value: true, ...scopeFields(server, duration) };
+ onAsk({
+ key: `add-${kind}`,
+ summary: (
+ <>
+ Add {name} to {group}
+ {describeScope(node)}.
+ >
+ ),
+ ops: [{ op: "add_node", node }],
+ warning: patreon.includes(group) ? PATREON_NOTE : undefined,
+ label: "Add",
+ });
+ }}
+ >
+ Add
+
+
+ );
+}
+
+function PermissionsPanel({
+ nodes,
+ canChange,
+ name,
+ adminPermissions,
+ onAsk,
+ confirm,
+}: {
+ nodes: LpShownNode[];
+ canChange: boolean;
+ name: string;
+ adminPermissions: string[] | null;
+ onAsk: (pending: Pending) => void;
+ confirm: ReactNode;
+}) {
+ const [filter, setFilter] = useState("");
+ const [key, setKey] = useState("");
+ const [value, setValue] = useState(true);
+ const [server, setServer] = useState("");
+ const [duration, setDuration] = useState(0);
+ const shown = useMemo(() => {
+ const text = filter.trim().toLowerCase();
+ return text ? nodes.filter((n) => n.key.toLowerCase().includes(text)) : nodes;
+ }, [nodes, filter]);
+
+ return (
+
+
+
Permissions
+ {nodes.length} of their own; groups add more
+
+ {nodes.length > 8 ? (
+ setFilter(e.target.value)}
+ placeholder="Filter"
+ aria-label="Filter permissions"
+ spellCheck={false}
+ className={`${inputClass} mt-3 w-full`}
+ />
+ ) : null}
+ {shown.length ? (
+
+ {shown.map((node) => (
+
+ onAsk({
+ key: nodeKey(node),
+ summary: (
+ <>
+ Unset {node.key} for {name}
+ {describeScope(node)}.
+ >
+ ),
+ ops: [{ op: "remove_node", node: plain(node) }],
+ label: "Unset",
+ })
+ }
+ >
+
+ {node.kind === "meta" ? : node.key}
+
+ {!node.value ? false : null}
+
+ ))}
+
+ ) : (
+ {nodes.length ? "Nothing matches." : "None of their own."}
+ )}
+ {canChange ? (
+ {
+ e.preventDefault();
+ const node = { key: key.trim(), value, ...scopeFields(server, duration) };
+ onAsk({
+ key: "add-permission",
+ summary: (
+ <>
+ Set {node.key} to {String(value)} for {name}
+ {describeScope(node)}.
+ >
+ ),
+ ops: [{ op: "add_node", node }],
+ label: "Set",
+ });
+ }}
+ >
+
+ Set permission
+ setKey(e.target.value)} placeholder="professions.chef_1" spellCheck={false} autoCapitalize="none" className={`${inputClass} font-mono`} />
+
+
+ Value
+ setValue(e.target.value === "true")} className={inputClass}>
+ true
+ false
+
+
+
+
+ Set
+
+
+ ) : null}
+ {canChange && adminPermissions ? (
+ Admins may set {adminPermissions.join(", ")}. Anything else needs the owner.
+ ) : null}
+ {confirm}
+
+ );
+}
diff --git a/frontend/app/components/admin/ranks/RanksOverview.test.tsx b/frontend/app/components/admin/ranks/RanksOverview.test.tsx
new file mode 100644
index 00000000..0b371022
--- /dev/null
+++ b/frontend/app/components/admin/ranks/RanksOverview.test.tsx
@@ -0,0 +1,112 @@
+/** @vitest-environment jsdom */
+import { cleanup, fireEvent, render, screen, within } from "@testing-library/react";
+import { afterEach, beforeEach, expect, it, vi } from "vitest";
+import RanksOverview from "./RanksOverview";
+import RankGroup from "./RankGroup";
+import { getLpGroup, getLpPlayers, getLuckPerms, submitLpChange, waitForChange, type LpChange, type LpOverview } from "../../../../lib/admin/luckperms";
+
+vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }) }));
+vi.mock("../../../../lib/admin/luckperms", async (importOriginal) => ({
+ ...await importOriginal(),
+ getLuckPerms: vi.fn(),
+ getLpPlayers: vi.fn(),
+ getLpGroup: vi.fn(),
+ submitLpChange: vi.fn(),
+ waitForChange: vi.fn(),
+}));
+
+const STATUS = { server: "main", snapshot_at: "2026-10-07T12:00:00Z", checked_at: new Date().toISOString(), has_snapshot: true, applying: true, polled_at: null };
+
+function overview(editDefinitions: boolean): LpOverview {
+ return {
+ status: STATUS,
+ players: 2852,
+ groups: [
+ { name: "staff", display_name: null, weight: 200, prefix: "&cStaff", suffix: null, parents: ["staff_inactive"], members: 4, permissions: 3, min_role: "root", patreon: false },
+ { name: "noble", display_name: null, weight: 20, prefix: "&6Noble", suffix: null, parents: ["commoner"], members: 13, permissions: 0, min_role: "admin", patreon: true },
+ ],
+ tracks: [{ name: "staff", groups: ["staff_player", "staff_inactive", "staff"] }],
+ rights: { read_only: false, change_players: true, edit_definitions: editDefinitions },
+ patreon_groups: ["noble"],
+ recent: [
+ { id: 1, created_at: "2026-10-07T12:00:00Z", actor_name: "rory", actor_role: "root", target_type: "user", target: "u", target_name: "Bob", description: "promote helper", reason: "Trained", status: "applied", finished_at: null, error: null },
+ ],
+ };
+}
+
+beforeEach(() => vi.stubEnv("NEXT_PUBLIC_API_URL", "https://www.tfminecraft.net/api"));
+afterEach(() => {
+ cleanup();
+ vi.clearAllMocks();
+ vi.unstubAllEnvs();
+});
+
+it("lists groups, tracks and recent changes, and finds players", async () => {
+ vi.mocked(getLuckPerms).mockResolvedValue(overview(false));
+ vi.mocked(getLpPlayers).mockResolvedValue({
+ total: 1, page: 1, page_size: 50,
+ rows: [{ uuid: "u1", name: "Alice", rank: "noble", groups: [{ name: "noble", contexts: { server: ["main"] }, expiry: 0 }] }],
+ });
+ render( );
+ const groups = await screen.findByRole("region", { name: "Groups" });
+ expect(within(groups).getByText("Owner only")).toBeTruthy();
+ expect(within(groups).getByText("Patreon")).toBeTruthy();
+ expect(within(groups).queryByRole("button", { name: "New group" })).toBeNull();
+ expect(screen.getByRole("region", { name: "Recent changes" }).textContent).toContain("promote helper");
+ fireEvent.change(screen.getByLabelText("Minecraft name or UUID"), { target: { value: "ali" } });
+ fireEvent.click(screen.getByRole("button", { name: "Find" }));
+ expect(await screen.findByRole("link", { name: "Alice" })).toBeTruthy();
+ expect(screen.getByText("(server=main)")).toBeTruthy();
+});
+
+it("lets root create a group", async () => {
+ vi.mocked(getLuckPerms).mockResolvedValue(overview(true));
+ vi.mocked(submitLpChange).mockResolvedValue({ id: 2, status: "pending" } as LpChange);
+ vi.mocked(waitForChange).mockResolvedValue({ id: 2, status: "applied" } as LpChange);
+ render( );
+ fireEvent.click(await screen.findByRole("button", { name: "New group" }));
+ fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Knight" } });
+ fireEvent.change(screen.getByLabelText("Weight"), { target: { value: "60" } });
+ fireEvent.change(screen.getByLabelText("Reason (recorded)"), { target: { value: "New rank" } });
+ fireEvent.click(screen.getByRole("button", { name: "Create group" }));
+ await vi.waitFor(() => expect(getLuckPerms).toHaveBeenCalledTimes(2));
+ expect(submitLpChange).toHaveBeenCalledWith("group", "knight", [
+ { op: "create_group" },
+ { op: "add_node", node: { key: "group.default" } },
+ { op: "add_node", node: { key: "weight.60" } },
+ ], "New rank");
+});
+
+it("edits a group's weight as a remove and an add", async () => {
+ vi.mocked(getLpGroup).mockResolvedValue({
+ status: STATUS,
+ group: {
+ ...overview(true).groups[1],
+ nodes: [
+ { key: "group.commoner", value: true, contexts: {}, expiry: 0, kind: "group", group: "commoner", editable: true },
+ { key: "weight.20", value: true, contexts: {}, expiry: 0, kind: "meta", group: null, editable: true },
+ ],
+ inherits: ["commoner", "default"],
+ children: ["gilded"],
+ tracks: [],
+ },
+ members: { total: 0, page: 1, page_size: 50, rows: [] },
+ all_groups: ["noble", "commoner", "default"],
+ rights: { read_only: false, change_players: true, edit_definitions: true },
+ changes: [],
+ pending: null,
+ });
+ vi.mocked(submitLpChange).mockResolvedValue({ id: 4, status: "pending" } as LpChange);
+ vi.mocked(waitForChange).mockResolvedValue({ id: 4, status: "applied" } as LpChange);
+ render( );
+ const settings = await screen.findByRole("region", { name: "Settings" });
+ fireEvent.change(within(settings).getByLabelText("Weight"), { target: { value: "25" } });
+ fireEvent.click(within(settings).getByRole("button", { name: "Save" }));
+ fireEvent.change(within(settings).getByLabelText("Reason (recorded)"), { target: { value: "Reorder ranks" } });
+ fireEvent.click(within(settings).getByRole("button", { name: "Save noble" }));
+ await vi.waitFor(() => expect(submitLpChange).toHaveBeenCalled());
+ expect(vi.mocked(submitLpChange).mock.calls[0].slice(0, 3)).toEqual(["group", "noble", [
+ { op: "remove_node", node: { key: "weight.20", value: true, contexts: {}, expiry: 0 } },
+ { op: "add_node", node: { key: "weight.25" } },
+ ]]);
+});
diff --git a/frontend/app/components/admin/ranks/RanksOverview.tsx b/frontend/app/components/admin/ranks/RanksOverview.tsx
new file mode 100644
index 00000000..0721e786
--- /dev/null
+++ b/frontend/app/components/admin/ranks/RanksOverview.tsx
@@ -0,0 +1,445 @@
+"use client";
+
+import Link from "next/link";
+import { FormEvent, useCallback, useEffect, useState } from "react";
+import { adminErrorMessage } from "../../../../lib/admin/api";
+import {
+ getLpPlayers,
+ getLuckPerms,
+ type LpOverview,
+ type LpPlayerPage,
+ type LpTrack,
+} from "../../../../lib/admin/luckperms";
+import { StaffGateMessage, gateKind, type GateKind } from "../StaffGate";
+import ChangeConfirm from "./ChangeConfirm";
+import { ChangeHistory, GroupChip, StatusLine } from "./parts";
+import McText from "./McText";
+import { badgeClass, buttonClass, errorClass, headingClass, inputClass, mutedClass, panelClass, quietButtonClass, rowClass } from "./ui";
+
+type Load = { kind: "loading" } | { kind: GateKind } | { kind: "ready"; data: LpOverview };
+
+export default function RanksOverview({ initialQuery = "" }: { initialQuery?: string }) {
+ const [load, setLoad] = useState({ kind: "loading" });
+
+ const refresh = useCallback(async () => {
+ try {
+ setLoad({ kind: "ready", data: await getLuckPerms() });
+ } catch (err) {
+ setLoad({ kind: gateKind(err) });
+ }
+ }, []);
+
+ useEffect(() => {
+ void refresh();
+ }, [refresh]);
+
+ if (load.kind === "loading") return Loading…
;
+ if (load.kind !== "ready") return ;
+ const { data } = load;
+
+ return (
+ <>
+
+
+
+
+
+ >
+ );
+}
+
+function PlayerSearch({ initialQuery, total }: { initialQuery: string; total: number }) {
+ const [query, setQuery] = useState(initialQuery);
+ const [page, setPage] = useState(null);
+ const [busy, setBusy] = useState(false);
+ const [error, setError] = useState(null);
+
+ const search = useCallback(async (text: string, pageNumber = 1) => {
+ if (!text.trim()) return;
+ setBusy(true);
+ setError(null);
+ try {
+ setPage(await getLpPlayers({ q: text, page: pageNumber }));
+ const url = new URL(window.location.href);
+ url.searchParams.set("q", text.trim());
+ window.history.replaceState(null, "", url);
+ } catch (err) {
+ setError(adminErrorMessage(err));
+ } finally {
+ setBusy(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ if (initialQuery.trim()) void search(initialQuery);
+ }, [initialQuery, search]);
+
+ function onSubmit(e: FormEvent) {
+ e.preventDefault();
+ void search(query);
+ }
+
+ return (
+
+ );
+}
+
+export function PlayerResults({ page, onPage }: { page: LpPlayerPage; onPage: (page: number) => void }) {
+ if (!page.rows.length) return No players match.
;
+ const pages = Math.max(1, Math.ceil(page.total / page.page_size));
+ return (
+ <>
+
+ {page.rows.map((row) => (
+
+
+ {row.name ?? {row.uuid} }
+
+
+ {row.groups.map((group, index) => (
+
+ ))}
+
+
+ ))}
+
+ {pages > 1 ? (
+
+ onPage(page.page - 1)}>
+ ← Previous
+
+
+ Page {page.page} of {pages}
+
+ = pages} onClick={() => onPage(page.page + 1)}>
+ Next →
+
+
+ ) : null}
+ >
+ );
+}
+
+function GroupsPanel({ data, onChanged }: { data: LpOverview; onChanged: () => Promise }) {
+ const [creating, setCreating] = useState(false);
+ const [name, setName] = useState("");
+ const [inherit, setInherit] = useState("default");
+ const [weight, setWeight] = useState("");
+ const clean = name.trim().toLowerCase();
+ const weightNumber = Number.parseInt(weight, 10);
+
+ return (
+
+
+
Groups
+ {data.rights.edit_definitions && !creating ? (
+ setCreating(true)}>
+ New group
+
+ ) : null}
+
+ Heaviest first: a player shows the rank of the heaviest group they inherit.
+ {creating ? (
+
+ ) : null}
+
+
+
+
+ Group
+ Prefix
+ Weight
+ Inherits
+ Players
+
+
+
+ {data.groups.map((group) => (
+
+
+
+ {group.name}
+
+ {group.min_role === "root" ? Owner only : null}
+ {group.patreon ? Patreon : null}
+
+ {group.prefix ? : — }
+ {group.weight ?? "—"}
+ {group.parents.join(", ") || "—"}
+
+
+ {group.members.toLocaleString()}
+
+
+
+ ))}
+
+
+
+
+ );
+}
+
+function TracksPanel({ data, onChanged }: { data: LpOverview; onChanged: () => Promise }) {
+ const [editing, setEditing] = useState(null);
+ const [creating, setCreating] = useState(false);
+ return (
+
+
+
Tracks
+ {data.rights.edit_definitions && !creating ? (
+ setCreating(true)}>
+ New track
+
+ ) : null}
+
+ Ladders players are promoted and demoted along, lowest first.
+ {creating ? (
+ g.name)}
+ isNew
+ onDone={() => {
+ setCreating(false);
+ void onChanged();
+ }}
+ onCancel={() => setCreating(false)}
+ />
+ ) : null}
+ {data.tracks.length ? (
+
+ ) : (
+ No tracks.
+ )}
+
+ );
+}
+
+function TrackEditor({
+ track,
+ allGroups,
+ isNew = false,
+ onDone,
+ onCancel,
+}: {
+ track: LpTrack;
+ allGroups: string[];
+ isNew?: boolean;
+ onDone: () => void;
+ onCancel: () => void;
+}) {
+ const [name, setName] = useState(track.name);
+ const [groups, setGroups] = useState(track.groups);
+ const [adding, setAdding] = useState("");
+ const [confirm, setConfirm] = useState(null);
+ const clean = name.trim().toLowerCase();
+ const unused = allGroups.filter((g) => !groups.includes(g));
+
+ function move(index: number, by: number) {
+ const next = [...groups];
+ const [item] = next.splice(index, 1);
+ next.splice(index + by, 0, item);
+ setGroups(next);
+ }
+
+ return (
+
+ {isNew ? (
+
+ Track name
+ setName(e.target.value)} className={inputClass} spellCheck={false} />
+
+ ) : null}
+
+ {groups.map((group, index) => (
+
+ {index + 1}
+ {group}
+ move(index, -1)}>
+ ↑
+
+ move(index, 1)}>
+ ↓
+
+ setGroups(groups.filter((g) => g !== group))}>
+ Remove
+
+
+ ))}
+
+
+ setAdding(e.target.value)} className={inputClass} aria-label="Group to add">
+ Add a group…
+ {unused.map((group) => (
+
+ {group}
+
+ ))}
+
+ {
+ setGroups([...groups, adding]);
+ setAdding("");
+ }}
+ >
+ Add to the top
+
+
+ {confirm ? (
+
Delete track {track.name} . Players keep their groups.>
+ ) : (
+ <>
+ {isNew ? "Create" : "Save"} track {clean} : {groups.join(" → ") || "no groups"}.
+ >
+ )
+ }
+ targetType="track"
+ target={isNew ? clean : track.name}
+ ops={
+ confirm === "delete"
+ ? [{ op: "delete_track" }]
+ : [...(isNew ? [{ op: "create_track" as const }] : []), { op: "set_groups", groups }]
+ }
+ confirmLabel={confirm === "delete" ? "Delete track" : "Save track"}
+ onDone={onDone}
+ onCancel={() => setConfirm(null)}
+ />
+ ) : (
+
+ setConfirm("save")}>
+ {isNew ? "Create track" : "Save track"}
+
+ {!isNew ? (
+ setConfirm("delete")}>
+ Delete track
+
+ ) : null}
+
+ Cancel
+
+
+ )}
+
+ );
+}
diff --git a/frontend/app/components/admin/ranks/parts.tsx b/frontend/app/components/admin/ranks/parts.tsx
new file mode 100644
index 00000000..1794647c
--- /dev/null
+++ b/frontend/app/components/admin/ranks/parts.tsx
@@ -0,0 +1,122 @@
+import Link from "next/link";
+import { formatAgo } from "../../../../lib/admin/time";
+import {
+ contextLabel,
+ expiryLabel,
+ lpErrorMessage,
+ STATUS_LABELS,
+ type LpChange,
+ type LpRights,
+ type LpStatus,
+} from "../../../../lib/admin/luckperms";
+import { roleLabel } from "../../../../lib/admin/api";
+import { chipClass, mutedClass, rowClass, warnClass } from "./ui";
+
+function seconds(stamp: string | null): number | null {
+ return stamp ? Date.parse(stamp) / 1000 : null;
+}
+
+/** Where the data comes from, how fresh it is, and whether changes can be made. */
+export function StatusLine({ status, rights }: { status: LpStatus; rights: LpRights }) {
+ if (!status.has_snapshot) {
+ return (
+
+ No server has sent LuckPerms data to this site yet. It appears once TFMCWeb’s LuckPerms bridge is on.
+
+ );
+ }
+ const checked = seconds(status.checked_at);
+ const stale = checked !== null && Date.now() / 1000 - checked > 180;
+ return (
+
+ LuckPerms from {status.server ?? "the server"}, checked {formatAgo(checked).toLowerCase()}.{" "}
+ {rights.read_only
+ ? "This site only shows ranks; change them on the main site."
+ : status.applying
+ ? "Changes apply within seconds."
+ : "No server is applying changes right now, so nothing can be changed."}
+
+ );
+}
+
+export function GroupChip({
+ name,
+ contexts = {},
+ expiry = 0,
+ href = true,
+}: {
+ name: string;
+ contexts?: Record;
+ expiry?: number;
+ href?: boolean;
+}) {
+ const extra = [contextLabel(contexts), expiryLabel(expiry)].filter(Boolean).join(" · ");
+ const body = (
+ <>
+ {name}
+ {extra ? ({extra}) : null}
+ >
+ );
+ if (!href) return {body} ;
+ return (
+
+ {body}
+
+ );
+}
+
+const STATUS_TONE: Record = {
+ pending: "text-[var(--tfmc-mist)]",
+ sent: "text-[var(--tfmc-mist)]",
+ applied: "text-[#9fd59f]",
+ failed: "text-[#e8a0a0]",
+ expired: "text-[var(--tfmc-stone)]",
+ unknown: "text-[#e8c48a]",
+};
+
+function targetHref(change: LpChange): string | null {
+ if (change.target_type === "user") return `/admin/ranks/players/${change.target}`;
+ if (change.target_type === "group") return `/admin/ranks/groups/${encodeURIComponent(change.target)}`;
+ return null;
+}
+
+export function ChangeHistory({ changes, showTarget = false }: { changes: LpChange[]; showTarget?: boolean }) {
+ if (!changes.length) return No changes made from the website yet.
;
+ return (
+
+ {changes.map((change) => {
+ const href = targetHref(change);
+ const label = change.target_name ?? change.target;
+ return (
+
+
+ {showTarget ? (
+ <>
+ {href ? (
+
+ {label}
+
+ ) : (
+ {change.target_type} {label}
+ )}
+ {": "}
+ >
+ ) : null}
+ {change.description}
+
+
+
+ {STATUS_LABELS[change.status]}
+ {change.error && change.status !== "expired" ? ` (${lpErrorMessage(change.error)})` : ""}
+
+ {" · "}
+ {change.actor_name ? `@${change.actor_name}` : "someone"}
+ {change.actor_role ? ` (${roleLabel(change.actor_role)})` : ""} · {formatAgo(seconds(change.created_at)).toLowerCase()} · “
+ {change.reason}”
+
+
+ );
+ })}
+
+ );
+}
diff --git a/frontend/app/components/admin/ranks/ui.ts b/frontend/app/components/admin/ranks/ui.ts
new file mode 100644
index 00000000..9f7a2092
--- /dev/null
+++ b/frontend/app/components/admin/ranks/ui.ts
@@ -0,0 +1,17 @@
+/** Class names shared by the Ranks pages, matching the rest of the staff panel. */
+export const panelClass =
+ "mt-6 rounded-sm border border-[color-mix(in_srgb,var(--tfmc-cream)_18%,transparent)] bg-[color-mix(in_srgb,var(--tfmc-forest)_28%,transparent)] p-5";
+export const headingClass = "font-[family-name:var(--font-fraunces)] text-xl text-[var(--tfmc-cream)]";
+export const buttonClass =
+ "inline-flex items-center justify-center rounded-sm bg-[var(--tfmc-accent)] px-3 py-1.5 text-sm font-semibold text-[var(--tfmc-forest-deep)] transition-opacity hover:opacity-90 disabled:opacity-50";
+export const quietButtonClass =
+ "text-sm text-[var(--tfmc-stone)] underline-offset-2 hover:text-[var(--tfmc-cream)] hover:underline disabled:opacity-50";
+export const inputClass =
+ "rounded-sm border border-[color-mix(in_srgb,var(--tfmc-cream)_25%,transparent)] bg-[color-mix(in_srgb,var(--tfmc-forest)_40%,transparent)] px-3 py-2 text-base text-[var(--tfmc-cream)] outline-none placeholder:text-[color-mix(in_srgb,var(--tfmc-mist)_60%,transparent)] focus:border-[var(--tfmc-accent)] sm:text-sm";
+export const mutedClass = "text-sm text-[var(--tfmc-mist)]";
+export const errorClass = "text-sm text-[#e8a0a0]";
+export const warnClass = "text-sm text-[#e8c48a]";
+export const chipClass =
+ "inline-flex items-center gap-1 rounded-sm border border-[color-mix(in_srgb,var(--tfmc-cream)_20%,transparent)] bg-[color-mix(in_srgb,var(--tfmc-forest)_45%,transparent)] px-2 py-0.5 text-xs text-[var(--tfmc-cream)]";
+export const badgeClass = "rounded-sm px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wider";
+export const rowClass = "divide-y divide-[color-mix(in_srgb,var(--tfmc-cream)_10%,transparent)]";
diff --git a/frontend/lib/admin/api.ts b/frontend/lib/admin/api.ts
index 08ebe7cc..82c4f1b8 100644
--- a/frontend/lib/admin/api.ts
+++ b/frontend/lib/admin/api.ts
@@ -29,9 +29,11 @@ export function isStaffRole(role: string | null | undefined): boolean {
return (RANK[role ?? ""] ?? -1) >= RANK.mod;
}
-/** Mirrors the server's rule for showing actions; the server still decides. */
+/** Mirrors the server's rule for showing actions; the server still decides. Roots may manage other roots. */
export function canManage(me: AdminMe, target: AdminAccount): boolean {
- return me.user_id !== target.user_id && (RANK[me.role] ?? -1) > (RANK[target.role] ?? 99);
+ if (me.user_id === target.user_id) return false;
+ if (me.role === "root" && target.role === "root") return true;
+ return (RANK[me.role] ?? -1) > (RANK[target.role] ?? 99);
}
const ROLE_LABELS: Record = {
diff --git a/frontend/lib/admin/luckperms.test.ts b/frontend/lib/admin/luckperms.test.ts
new file mode 100644
index 00000000..b64b4019
--- /dev/null
+++ b/frontend/lib/admin/luckperms.test.ts
@@ -0,0 +1,41 @@
+import { afterEach, expect, it, vi } from "vitest";
+import { expiryLabel, getLpChange, parseMcText, stripMcText, waitForChange, type LpChange } from "./luckperms";
+
+vi.mock("./api", async (importOriginal) => ({
+ ...await importOriginal(),
+ adminRequest: vi.fn(),
+}));
+
+afterEach(() => vi.clearAllMocks());
+
+it("reads Minecraft colour and style codes", () => {
+ expect(parseMcText("&6Noble &lLord&r!")).toEqual([
+ { text: "Noble ", colour: "#FFAA00", bold: false, italic: false, underline: false, strike: false },
+ { text: "Lord", colour: "#FFAA00", bold: true, italic: false, underline: false, strike: false },
+ { text: "!", colour: null, bold: false, italic: false, underline: false, strike: false },
+ ]);
+ expect(parseMcText("ff8800Hex")[0].colour).toBe("#FF8800");
+ expect(stripMcText("§cStaff & co &z")).toBe("Staff & co &z");
+});
+
+it("says when temporary nodes run out", () => {
+ expect(expiryLabel(0)).toBe("");
+ expect(expiryLabel(1000, 2000)).toBe("Expired");
+ expect(expiryLabel(1000 + 600, 1000)).toBe("Expires in 10 min");
+ expect(expiryLabel(1000 + 3 * 86400, 1000)).toBe("Expires in 3 d");
+});
+
+it("follows a change until the server settles it", async () => {
+ const { adminRequest } = await import("./api");
+ const base = { id: 7, status: "pending" } as LpChange;
+ vi.mocked(adminRequest)
+ .mockResolvedValueOnce(base)
+ .mockResolvedValueOnce({ ...base, status: "sent" })
+ .mockResolvedValueOnce({ ...base, status: "applied" });
+ const settled = await waitForChange(7, { intervalMs: 1 });
+ expect(settled.status).toBe("applied");
+ expect(vi.mocked(adminRequest)).toHaveBeenCalledWith("/admin/luckperms/changes/7");
+ vi.mocked(adminRequest).mockResolvedValue(base);
+ expect((await waitForChange(7, { intervalMs: 1, limitMs: 5 })).status).toBe("pending");
+ expect(getLpChange).toBeDefined();
+});
diff --git a/frontend/lib/admin/luckperms.ts b/frontend/lib/admin/luckperms.ts
new file mode 100644
index 00000000..8d4cba09
--- /dev/null
+++ b/frontend/lib/admin/luckperms.ts
@@ -0,0 +1,338 @@
+import { adminRequest } from "./api";
+import type { StaffRole } from "./api";
+
+/** A LuckPerms node. Contexts are {} when global; expiry is Unix seconds, 0 when permanent. */
+export type LpNode = {
+ key: string;
+ value: boolean;
+ contexts: Record;
+ expiry: number;
+};
+
+export type LpShownNode = LpNode & {
+ kind: "group" | "meta" | "permission";
+ group: string | null;
+ /** Whether the viewer may remove it; the server checks again. */
+ editable: boolean;
+};
+
+export type LpStatus = {
+ server: string | null;
+ snapshot_at: string | null;
+ checked_at: string | null;
+ has_snapshot: boolean;
+ /** An applying server asked for changes within the last minute. */
+ applying: boolean;
+ polled_at: string | null;
+};
+
+export type LpRights = {
+ read_only: boolean;
+ change_players: boolean;
+ edit_definitions: boolean;
+};
+
+export type LpGroupSummary = {
+ name: string;
+ display_name: string | null;
+ weight: number | null;
+ prefix: string | null;
+ suffix: string | null;
+ parents: string[];
+ members: number;
+ permissions: number;
+ min_role: "admin" | "root";
+ patreon: boolean;
+};
+
+export type LpTrack = { name: string; groups: string[] };
+
+export type LpChangeStatus = "pending" | "sent" | "applied" | "failed" | "expired" | "unknown";
+
+export type LpChange = {
+ id: number;
+ created_at: string;
+ actor_name: string | null;
+ actor_role: StaffRole | null;
+ target_type: "user" | "group" | "track";
+ target: string;
+ target_name: string | null;
+ description: string;
+ reason: string;
+ status: LpChangeStatus;
+ finished_at: string | null;
+ error: string | null;
+};
+
+export type LpOverview = {
+ status: LpStatus;
+ players: number;
+ groups: LpGroupSummary[];
+ tracks: LpTrack[];
+ rights: LpRights;
+ patreon_groups: string[];
+ recent: LpChange[];
+};
+
+export type LpPlayerRow = {
+ uuid: string;
+ name: string | null;
+ rank: string | null;
+ groups: { name: string; contexts: Record; expiry: number }[];
+};
+
+export type LpPlayerPage = { total: number; page: number; page_size: number; rows: LpPlayerRow[] };
+
+export type LpTrackPosition = LpTrack & {
+ position: number | null;
+ ambiguous: boolean;
+ can_promote: boolean;
+ can_demote: boolean;
+};
+
+export type LpPlayer = {
+ status: LpStatus;
+ player: {
+ uuid: string;
+ name: string | null;
+ rank: string | null;
+ rank_prefix: string | null;
+ inherits: string[];
+ nodes: LpShownNode[];
+ tracks: LpTrackPosition[];
+ account: { discord_user_id: string; discord_username: string | null; user_id: number | null; role: StaffRole | null } | null;
+ };
+ groups: { name: string; weight: number | null; min_role: "admin" | "root"; patreon: boolean; addable: boolean }[];
+ rights: LpRights & { change_this_player: boolean; admin_permissions: string[] };
+ changes: LpChange[];
+ pending: LpChange | null;
+};
+
+export type LpGroupDetail = {
+ status: LpStatus;
+ group: LpGroupSummary & {
+ nodes: LpShownNode[];
+ inherits: string[];
+ children: string[];
+ tracks: string[];
+ };
+ members: LpPlayerPage;
+ all_groups: string[];
+ rights: LpRights;
+ changes: LpChange[];
+ pending: LpChange | null;
+};
+
+export type LpOp =
+ | { op: "add_node"; node: Partial & { key: string } }
+ | { op: "remove_node"; node: LpNode }
+ | { op: "promote" | "demote"; track: string }
+ | { op: "create_group" | "delete_group" | "create_track" | "delete_track" }
+ | { op: "set_groups"; groups: string[] };
+
+export type LpTargetType = "user" | "group" | "track";
+
+function query(params: Record): string {
+ const search = new URLSearchParams();
+ for (const [key, value] of Object.entries(params)) {
+ if (value !== null && value !== undefined && value !== "") search.set(key, String(value));
+ }
+ const text = search.toString();
+ return text ? `?${text}` : "";
+}
+
+export function getLuckPerms(): Promise {
+ return adminRequest("/admin/luckperms");
+}
+
+export function getLpPlayers(params: { q?: string; group?: string; page?: number }): Promise {
+ return adminRequest(`/admin/luckperms/players${query({ q: params.q?.trim(), group: params.group, page: params.page })}`);
+}
+
+export function getLpPlayer(uuid: string): Promise {
+ return adminRequest(`/admin/luckperms/players/${encodeURIComponent(uuid)}`);
+}
+
+export function getLpGroup(name: string): Promise {
+ return adminRequest(`/admin/luckperms/groups/${encodeURIComponent(name)}`);
+}
+
+export function getLpChange(id: number): Promise {
+ return adminRequest(`/admin/luckperms/changes/${id}`);
+}
+
+export function submitLpChange(targetType: LpTargetType, target: string, ops: LpOp[], reason: string): Promise {
+ return adminRequest("/admin/luckperms/changes", {
+ method: "POST",
+ body: JSON.stringify({ target_type: targetType, target, ops, reason }),
+ });
+}
+
+export function isFinished(status: LpChangeStatus): boolean {
+ return status !== "pending" && status !== "sent";
+}
+
+const POLL_MS = 1000;
+const POLL_LIMIT_MS = 30_000;
+
+/** Wait for the server to apply a change, or give up after half a minute and return what is known. */
+export async function waitForChange(
+ id: number,
+ { signal, intervalMs = POLL_MS, limitMs = POLL_LIMIT_MS }: { signal?: AbortSignal; intervalMs?: number; limitMs?: number } = {}
+): Promise {
+ const started = Date.now();
+ let change = await getLpChange(id);
+ while (!isFinished(change.status) && Date.now() - started < limitMs && !signal?.aborted) {
+ await new Promise((resolve) => setTimeout(resolve, intervalMs));
+ change = await getLpChange(id);
+ }
+ return change;
+}
+
+// --------------------
+// Wording
+// --------------------
+
+export const LP_ERRORS: Record = {
+ bridge_offline: "No server is applying rank changes right now, so nothing can be changed.",
+ change_pending: "Another change for this is still being applied. Wait a moment and try again.",
+ root_only: "Only the owner can make that change.",
+ target_is_staff: "In-game staff ranks can only be changed by the owner.",
+ cannot_act_on_self: "You can’t change your own in-game ranks.",
+ target_outranks_you: "That player’s website account is at or above your role.",
+ node_exists: "They already have that. Reload to see the latest.",
+ node_missing: "That has already changed. Reload to see the latest.",
+ group_missing: "That group doesn’t exist.",
+ group_exists: "A group with that name already exists.",
+ track_missing: "That track doesn’t exist.",
+ track_exists: "A track with that name already exists.",
+ track_end: "They are already at the top of that track.",
+ not_on_track: "They aren’t on that track.",
+ ambiguous_track: "They hold more than one group on that track; remove the extras first.",
+ track_empty: "That track has no groups.",
+ inheritance_cycle: "That would make the group inherit itself.",
+ cannot_delete_default: "The default group can’t be deleted.",
+ nothing_to_change: "There is nothing to change.",
+ bad_node: "That permission isn’t valid.",
+ bad_expiry: "Choose an expiry in the future.",
+ bad_contexts: "That server or world isn’t valid.",
+ bad_group_name: "Use lowercase letters, numbers, _, + or - for group names.",
+ bad_track_name: "Use lowercase letters, numbers, _, + or - for track names.",
+ bad_track_groups: "List each group once.",
+ player_not_found: "LuckPerms has no record of that player.",
+ save_failed: "LuckPerms couldn’t save the change.",
+ no_result: "The server never reported back.",
+ actor_changed: "Cancelled: the person who made it no longer had the rights.",
+ expired: "The server didn’t pick it up in time.",
+};
+
+export function lpErrorMessage(code: string | null | undefined): string {
+ if (!code) return "Something went wrong.";
+ return LP_ERRORS[code] ?? code;
+}
+
+export const STATUS_LABELS: Record = {
+ pending: "Waiting for the server",
+ sent: "Applying",
+ applied: "Applied",
+ failed: "Failed",
+ expired: "Expired",
+ unknown: "Outcome unknown",
+};
+
+export function contextLabel(contexts: Record): string {
+ return Object.entries(contexts)
+ .flatMap(([key, values]) => values.map((value) => `${key}=${value}`))
+ .join(", ");
+}
+
+export function nodeLabel(node: LpNode): string {
+ return node.key;
+}
+
+/** "Expires in 3 d" for temporary nodes, "" for permanent ones. */
+export function expiryLabel(expiry: number, now = Date.now() / 1000): string {
+ if (!expiry) return "";
+ const left = expiry - now;
+ if (left <= 0) return "Expired";
+ if (left < 3600) return `Expires in ${Math.max(1, Math.round(left / 60))} min`;
+ if (left < 86400) return `Expires in ${Math.round(left / 3600)} h`;
+ return `Expires in ${Math.round(left / 86400)} d`;
+}
+
+export const DURATIONS: { label: string; seconds: number }[] = [
+ { label: "Permanent", seconds: 0 },
+ { label: "1 hour", seconds: 3600 },
+ { label: "1 day", seconds: 86400 },
+ { label: "7 days", seconds: 7 * 86400 },
+ { label: "30 days", seconds: 30 * 86400 },
+];
+
+// --------------------
+// Minecraft colour codes
+// --------------------
+
+const COLOURS: Record = {
+ "0": "#000000",
+ "1": "#0000AA",
+ "2": "#00AA00",
+ "3": "#00AAAA",
+ "4": "#AA0000",
+ "5": "#AA00AA",
+ "6": "#FFAA00",
+ "7": "#AAAAAA",
+ "8": "#555555",
+ "9": "#5555FF",
+ a: "#55FF55",
+ b: "#55FFFF",
+ c: "#FF5555",
+ d: "#FF55FF",
+ e: "#FFFF55",
+ f: "#FFFFFF",
+};
+
+export type McSegment = { text: string; colour: string | null; bold: boolean; italic: boolean; underline: boolean; strike: boolean };
+
+/** Split "&6Noble &lLord" (or § codes, or rrggbb) into styled runs. Unknown codes stay as text. */
+export function parseMcText(text: string): McSegment[] {
+ const out: McSegment[] = [];
+ let style = { colour: null as string | null, bold: false, italic: false, underline: false, strike: false };
+ let buffer = "";
+ const flush = () => {
+ if (buffer) out.push({ text: buffer, ...style });
+ buffer = "";
+ };
+ for (let i = 0; i < text.length; i += 1) {
+ const ch = text[i];
+ if ((ch === "&" || ch === "§") && i + 1 < text.length) {
+ const hex = /^#[0-9a-fA-F]{6}$/.exec(text.slice(i + 1, i + 8));
+ if (hex) {
+ flush();
+ style = { colour: hex[0].toUpperCase(), bold: false, italic: false, underline: false, strike: false };
+ i += 7;
+ continue;
+ }
+ const code = text[i + 1].toLowerCase();
+ if (code in COLOURS || "lmnokr".includes(code)) {
+ flush();
+ if (code in COLOURS) style = { colour: COLOURS[code], bold: false, italic: false, underline: false, strike: false };
+ else if (code === "l") style = { ...style, bold: true };
+ else if (code === "o") style = { ...style, italic: true };
+ else if (code === "n") style = { ...style, underline: true };
+ else if (code === "m") style = { ...style, strike: true };
+ else if (code === "r") style = { colour: null, bold: false, italic: false, underline: false, strike: false };
+ i += 1;
+ continue;
+ }
+ }
+ buffer += ch;
+ }
+ flush();
+ return out;
+}
+
+export function stripMcText(text: string): string {
+ return parseMcText(text)
+ .map((segment) => segment.text)
+ .join("");
+}
From 1deae1e11e767c1889b9919f66149176afc11399 Mon Sep 17 00:00:00 2001
From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com>
Date: Wed, 7 Oct 2026 14:06:48 +0000
Subject: [PATCH 3/6] Say inherited groups as a list, not a chain
Co-Authored-By: Claude Opus 5.5 (1M context)
---
frontend/app/components/admin/ranks/RankPlayer.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/frontend/app/components/admin/ranks/RankPlayer.tsx b/frontend/app/components/admin/ranks/RankPlayer.tsx
index d36e5359..dd8f33cb 100644
--- a/frontend/app/components/admin/ranks/RankPlayer.tsx
+++ b/frontend/app/components/admin/ranks/RankPlayer.tsx
@@ -202,7 +202,7 @@ export default function RankPlayer({ uuid }: { uuid: string }) {
Groups
{player.inherits.length ? (
- Inherits {player.inherits.join(" → ")}.
+ With inheritance, everywhere: {player.inherits.join(", ")}.
) : null}
{groupNodes.length ? (
From 887cf70acedda229d43e615ae55718e46082ff94 Mon Sep 17 00:00:00 2001
From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com>
Date: Wed, 7 Oct 2026 14:16:09 +0000
Subject: [PATCH 4/6] Close the review gaps: admin-namespace denials, live
guard, definitions alone
- root_only_permissions keeps armourshop.admin and the like out of admin
reach inside allowed namespaces.
- Admin changes carry a guard the bridge checks against live LuckPerms.
- A group or track change is handed out alone and holds later changes
until its result arrives, so they are checked against the new groups.
- Change timeouts settle on staff reads, not only on bridge polls.
- Prefix and suffix keys keep their spaces; removals match exactly.
- The confirm box follows a change until it is settled.
Co-Authored-By: Claude Opus 5.5 (1M context)
---
backend/src/luckperms/README.md | 18 ++++-
backend/src/luckperms/changes.py | 79 ++++++++++++++-----
backend/src/luckperms/nodes.py | 6 +-
backend/src/luckperms/policy.py | 4 +
backend/src/luckperms/policy.yaml | 14 ++++
backend/src/luckperms/test_luckperms.py | 44 ++++++++++-
.../components/admin/ranks/ChangeConfirm.tsx | 2 +-
frontend/lib/admin/luckperms.ts | 9 ++-
8 files changed, 144 insertions(+), 32 deletions(-)
diff --git a/backend/src/luckperms/README.md b/backend/src/luckperms/README.md
index 0af784df..874d5652 100644
--- a/backend/src/luckperms/README.md
+++ b/backend/src/luckperms/README.md
@@ -12,7 +12,7 @@ dev.tfminecraft.net) without applying, which makes that site read-only.
| Who | May |
| --- | --- |
| mod | view everything: groups, tracks, every player's groups and permissions, change history |
-| admin | add or remove the groups in `policy.yaml` `admin_groups` on players, promote/demote players along tracks between those groups, set or unset player permissions matching `admin_permissions` |
+| admin | add or remove the groups in `policy.yaml` `admin_groups` on players, promote/demote players along tracks between those groups, set or unset player permissions matching `admin_permissions` but not `root_only_permissions` (such as `armourshop.admin`) |
| root | everything, including staff groups, other permissions, meta (prefix/suffix/weight) on players, group definitions and tracks |
A listed group counts as an admin group only while everything it inherits, in
@@ -24,7 +24,7 @@ Admins may not change anything on their own Minecraft account, on a player
whose linked website account is admin or root, or on a player who holds or
inherits a root-only group (in-game staff, linked or not). Rights are checked
when a change is queued and again when the bridge collects it, so a demotion
-in between cancels it. Every change carries a
+in between cancels it (`no_longer_allowed`). Every change carries a
reason and is written to `admin_audit`, and LuckPerms logs it (`/lp log`) with
the source `web:`.
@@ -78,10 +78,19 @@ on the same single worker thread that applies changes, so a snapshot never preda
"actor_name": "web:w.o.n", // LuckPerms action source name
"actor_uuid": null, // the actor's linked Minecraft UUID, if any
"description": "parent add staff", // LuckPerms action log text
- "ops": [OP]
+ "ops": [OP],
+ "guard": {"admin_groups": ["default", "commoner"]} // admin changes only
}]}
```
+A group or track change is handed out on its own, and nothing else is handed out until its
+result arrives, so later changes are checked against the definitions it leaves.
+
+`guard` is on changes an admin made. The mirror can lag in-game edits, so the bridge checks live
+LuckPerms before mutating: the player must hold no group (any context) outside `admin_groups`
+(else `target_is_staff`), and every group an `add_node` grants must inherit only groups in
+`admin_groups` (else `root_only`).
+
Ops, applied in order to one holder and saved once:
| target | op | fields | precondition |
@@ -118,6 +127,7 @@ on later ticks until the site answers `ok`.
Site side, a change is `pending` until fetched, then `sent`, and the site never offers it again: a
change is applied at most once. A sent change with no result after 300 s becomes `unknown` (staff
are told to check the player); a late result still settles it. A change not fetched within
-10 minutes `expired`. New changes are refused with `bridge_offline` unless the bridge polled in the
+10 minutes `expired`. Both timeouts are settled on staff reads too, so they happen even when the
+bridge is gone. New changes are refused with `bridge_offline` unless the bridge polled in the
last 60 s, and always on a site with `LUCKPERMS_READ_ONLY=1` (dev.tfminecraft.net, whose server
shares LuckPerms with Main).
diff --git a/backend/src/luckperms/changes.py b/backend/src/luckperms/changes.py
index 56eda219..0e49dea7 100644
--- a/backend/src/luckperms/changes.py
+++ b/backend/src/luckperms/changes.py
@@ -333,6 +333,7 @@ def queue_change(token: str, target_type, target, raw_ops, reason: str | None) -
target_type, target, raw_ops = _shape(target_type, target, raw_ops)
detail_target = {"target_type": target_type, "target": target}
state = mirror.status(conn)
+ settle_timeouts(conn)
if read_only() or not state["applying"]:
raise AdminError(503, "bridge_offline", "unavailable")
if conn.execute(
@@ -404,8 +405,14 @@ def _change_json(row) -> dict:
}
+def _settled_reads(conn) -> None:
+ settle_timeouts(conn)
+ conn.commit()
+
+
def get_change(change_id: int) -> dict:
with connect() as conn:
+ _settled_reads(conn)
row = conn.execute("SELECT * FROM lp_changes WHERE id = ?", (change_id,)).fetchone()
if row is None:
raise AdminError(404, "change_not_found", "not_found")
@@ -419,6 +426,7 @@ def list_changes(target_type: str | None = None, target: str | None = None, limi
args += [target_type, target]
clause = f"WHERE {' AND '.join(where)}" if where else ""
with connect() as conn:
+ _settled_reads(conn)
rows = conn.execute(f"SELECT * FROM lp_changes {clause} ORDER BY id DESC LIMIT ?",
[*args, max(1, min(limit, HISTORY_LIMIT))]).fetchall()
return [_change_json(row) for row in rows]
@@ -449,37 +457,64 @@ def _still_allowed(conn, row) -> bool:
return True
+def settle_timeouts(conn, now: float | None = None) -> None:
+ """Expire unfetched changes and mark unanswered ones unknown, whether or not the bridge polls."""
+ now = time.time() if now is None else now
+ for row in conn.execute("SELECT id, status, created_at, sent_at FROM lp_changes WHERE status IN ('pending', 'sent')"):
+ if row["status"] == "sent" and now - _parse(row["sent_at"]) > RESULT_WAIT_SECONDS:
+ conn.execute("UPDATE lp_changes SET status = 'unknown', finished_at = ?, error = 'no_result' WHERE id = ?",
+ (_iso(now), row["id"]))
+ elif row["status"] == "pending" and now - _parse(row["created_at"]) > PENDING_TTL_SECONDS:
+ conn.execute("UPDATE lp_changes SET status = 'expired', finished_at = ?, error = 'expired' WHERE id = ?",
+ (_iso(now), row["id"]))
+
+
+def _guard(conn, row) -> dict | None:
+ """What the bridge must recheck against live LuckPerms for an admin's change.
+
+ The mirror can lag in-game edits, so the bridge refuses the change if the
+ player now holds a group outside the admin groups, or a group being added
+ now inherits one.
+ """
+ if row["target_type"] != "user" or row["actor_role"] == "root":
+ return None
+ rules = policy.load()
+ groups = mirror.load_groups(conn)
+ allowed = sorted(name for name in groups if policy.group_role(rules, groups, name) == "admin")
+ return {"admin_groups": allowed}
+
+
def fetch_for_bridge() -> list[dict]:
- """Changes for the bridge, oldest first. Each is handed out once; records the poll."""
+ """Changes for the bridge, oldest first. Each is handed out once; records the poll.
+
+ A group or track change is handed out on its own, and nothing more until
+ its result arrives: later changes are checked against the definitions it
+ leaves behind.
+ """
now = time.time()
with connect() as conn:
conn.execute("BEGIN IMMEDIATE")
try:
mirror.record_poll(conn)
+ settle_timeouts(conn, now)
out = []
- rows = conn.execute(
- "SELECT * FROM lp_changes WHERE status IN ('pending', 'sent') ORDER BY id").fetchall()
+ definition_sent = conn.execute(
+ "SELECT 1 FROM lp_changes WHERE status = 'sent' AND target_type != 'user'").fetchone()
+ rows = [] if read_only() or definition_sent else conn.execute(
+ "SELECT * FROM lp_changes WHERE status = 'pending' ORDER BY id").fetchall()
for row in rows:
- if row["status"] == "sent":
- if now - _parse(row["sent_at"]) > RESULT_WAIT_SECONDS:
- conn.execute("UPDATE lp_changes SET status = 'unknown', finished_at = ?, error = 'no_result' "
- "WHERE id = ?", (_iso(now), row["id"]))
- continue
- if now - _parse(row["created_at"]) > PENDING_TTL_SECONDS:
- conn.execute("UPDATE lp_changes SET status = 'expired', finished_at = ?, error = 'expired' "
- "WHERE id = ?", (_iso(now), row["id"]))
- continue
- if read_only():
- continue
+ if len(out) >= FETCH_LIMIT:
+ break
+ definition = row["target_type"] != "user"
+ if definition and out:
+ break
if not _still_allowed(conn, row):
- conn.execute("UPDATE lp_changes SET status = 'failed', finished_at = ?, error = 'actor_changed' "
+ conn.execute("UPDATE lp_changes SET status = 'failed', finished_at = ?, error = 'no_longer_allowed' "
"WHERE id = ?", (_iso(now), row["id"]))
continue
- if len(out) >= FETCH_LIMIT:
- continue
conn.execute("UPDATE lp_changes SET status = 'sent', sent_at = ?, attempts = attempts + 1 "
"WHERE id = ?", (_iso(now), row["id"]))
- out.append({
+ change = {
"id": row["id"],
"target_type": row["target_type"],
"target": row["target"],
@@ -488,7 +523,13 @@ def fetch_for_bridge() -> list[dict]:
"actor_uuid": row["actor_minecraft_uuid"],
"description": row["description"],
"ops": json.loads(row["ops_json"]),
- })
+ }
+ guard = _guard(conn, row)
+ if guard is not None:
+ change["guard"] = guard
+ out.append(change)
+ if definition:
+ break
conn.commit()
except BaseException:
conn.rollback()
diff --git a/backend/src/luckperms/nodes.py b/backend/src/luckperms/nodes.py
index 96474848..3ecdf12b 100644
--- a/backend/src/luckperms/nodes.py
+++ b/backend/src/luckperms/nodes.py
@@ -95,8 +95,10 @@ def from_request(raw, *, now: int | None = None, new: bool) -> dict:
key = raw.get("key")
if not isinstance(key, str):
raise NodeError("bad_node")
- key = key.strip()
- if not key or len(key) > KEY_MAX or not _printable(key):
+ # Removals name an existing node exactly; prefixes and suffixes may end in a space on purpose.
+ if new and kind(key.strip()) != "meta":
+ key = key.strip()
+ if not key.strip() or len(key) > KEY_MAX or not _printable(key):
raise NodeError("bad_node")
value = raw.get("value", True)
if not isinstance(value, bool):
diff --git a/backend/src/luckperms/policy.py b/backend/src/luckperms/policy.py
index 2022433a..77f03d98 100644
--- a/backend/src/luckperms/policy.py
+++ b/backend/src/luckperms/policy.py
@@ -20,6 +20,7 @@ class Policy:
admin_groups: frozenset[str]
patreon_groups: frozenset[str]
admin_permissions: tuple[str, ...]
+ root_only_permissions: tuple[str, ...]
@lru_cache(maxsize=1)
@@ -29,6 +30,7 @@ def load() -> Policy:
admin_groups=frozenset(str(g).lower() for g in raw.get("admin_groups") or ()),
patreon_groups=frozenset(str(g).lower() for g in raw.get("patreon_groups") or ()),
admin_permissions=tuple(str(p).lower() for p in raw.get("admin_permissions") or ()),
+ root_only_permissions=tuple(str(p).lower() for p in raw.get("root_only_permissions") or ()),
)
@@ -61,6 +63,8 @@ def node_role(policy: Policy, groups: dict[str, dict], node: dict) -> str:
return group_role(policy, groups, nodes.group_of(key) or "")
if node_kind == "meta" or "*" in key or key.startswith("r="):
return "root"
+ if any(fnmatch.fnmatchcase(key, pattern) for pattern in policy.root_only_permissions):
+ return "root"
if any(fnmatch.fnmatchcase(key, pattern) for pattern in policy.admin_permissions):
return "admin"
return "root"
diff --git a/backend/src/luckperms/policy.yaml b/backend/src/luckperms/policy.yaml
index 5d260b60..8f6e5bb4 100644
--- a/backend/src/luckperms/policy.yaml
+++ b/backend/src/luckperms/policy.yaml
@@ -21,6 +21,20 @@ patreon_groups:
- gilded
- ascended
+# Never admin-editable, even inside an allowed namespace (armourshop.admin,
+# professions.admin...). Checked before admin_permissions.
+root_only_permissions:
+ - "*.admin"
+ - "*.admin.*"
+ - "*.staff"
+ - "*.staff.*"
+ - "*.mod"
+ - "*.moderator"
+ - "*.bypass"
+ - "*.bypass.*"
+ - "*.reload"
+ - "*.op"
+
# Player permissions admins may set or unset. Shell-style patterns, any case.
# Every other permission, and any key containing "*" or written as a regex
# ("r=..."), is root-only. These are the progress and preference nodes in use.
diff --git a/backend/src/luckperms/test_luckperms.py b/backend/src/luckperms/test_luckperms.py
index 0d85fc7f..94139fa8 100644
--- a/backend/src/luckperms/test_luckperms.py
+++ b/backend/src/luckperms/test_luckperms.py
@@ -171,7 +171,8 @@ def test_policy_puts_staff_power_out_of_admin_reach():
assert policy.node_role(rules, groups, node("group.staff")) == "root"
assert policy.node_role(rules, groups, node("professions.chef_1")) == "admin"
assert policy.node_role(rules, groups, node("professions.chef_1", value=False)) == "admin"
- for key in ("*", "professions.*", "essentials.kick", "tfmc.staff", "luckperms.user.parent.add",
+ assert policy.node_role(rules, groups, node("armourshop.ravoukar")) == "admin"
+ for key in ("*", "professions.*", "essentials.kick", "armourshop.admin", "professions.admin", "mural.bypass.x", "tfmc.staff", "luckperms.user.parent.add",
"minecraft.command.op", "r=professions.*", "prefix.300.&cOwner", "weight.999", "meta.x.y"):
assert policy.node_role(rules, groups, node(key)) == "root", key
# A listed group that inherits a staff group, in any context, is no longer an admin group.
@@ -388,7 +389,7 @@ def test_demotion_after_queueing_cancels_the_change(app, bridge, staff, env):
conn.execute("UPDATE users SET role = 'mod' WHERE discord_username = 'adam'")
conn.commit()
assert changes.fetch_for_bridge() == []
- assert changes.get_change(change["id"])["error"] == "actor_changed"
+ assert changes.get_change(change["id"])["error"] == "no_longer_allowed"
def test_read_only_site_shows_but_never_changes(app, bridge, staff, monkeypatch):
@@ -405,3 +406,42 @@ def test_malformed_change_is_audited(app, bridge, staff, env):
assert response.status_code == 422
row = audit_rows(env)[-1]
assert (row["action"], row["outcome"]) == ("luckperms.change", "invalid")
+
+
+def test_meta_keys_keep_their_spaces():
+ assert nodes.from_request({"key": "prefix.100.&6[Noble] "}, new=True)["key"] == "prefix.100.&6[Noble] "
+ assert nodes.from_request({"key": "prefix.100.&6[Noble] "}, new=False)["key"] == "prefix.100.&6[Noble] "
+ assert nodes.from_request({"key": " tips.off "}, new=True)["key"] == "tips.off"
+
+
+def test_bridge_rechecks_admin_changes_against_live_groups(app, bridge, staff):
+ submit(app, staff["admin"], "user", ALICE, [{"op": "add_node", "node": {"key": "tips.off"}}])
+ submit(app, staff["root"], "user", BOB, [{"op": "add_node", "node": {"key": "tips.off"}}])
+ sent = {c["target"]: c for c in bridge.get("/luckperms/plugin/changes", headers=PLUGIN).json()["changes"]}
+ assert set(sent[ALICE]["guard"]["admin_groups"]) == {
+ "default", "commoner", "noble", "helper_player", "helper_inactive", "helper"}
+ assert "guard" not in sent[BOB]
+
+
+def test_definition_changes_go_alone_and_hold_later_changes(app, bridge, staff):
+ group_change = submit(app, staff["root"], "group", "helper",
+ [{"op": "add_node", "node": {"key": "group.staff_player"}}]).json()
+ submit(app, staff["admin"], "user", ALICE, [{"op": "add_node", "node": {"key": "group.helper"}}])
+ first = bridge.get("/luckperms/plugin/changes", headers=PLUGIN).json()["changes"]
+ assert [c["id"] for c in first] == [group_change["id"]]
+ assert bridge.get("/luckperms/plugin/changes", headers=PLUGIN).json()["changes"] == []
+ helper = group("helper", 140, "group.helper_inactive", "group.staff_player", prefix="&dHelper")
+ bridge.post("/luckperms/plugin/changes/results", headers=PLUGIN,
+ json={"results": [{"id": group_change["id"], "ok": True, "revision": 3000, "state": helper}]})
+ # helper now inherits a staff group, so the admin's queued grant is cancelled.
+ assert bridge.get("/luckperms/plugin/changes", headers=PLUGIN).json()["changes"] == []
+ alice = client(app, staff["admin"]).get(f"/admin/luckperms/players/{ALICE}").json()
+ assert alice["changes"][0]["error"] == "no_longer_allowed"
+
+
+def test_timeouts_settle_without_the_bridge(app, bridge, staff, monkeypatch):
+ change = submit(app, staff["admin"], "user", ALICE, [{"op": "add_node", "node": {"key": "tips.off"}}]).json()
+ changes.fetch_for_bridge()
+ later = time.time() + changes.RESULT_WAIT_SECONDS + 5
+ monkeypatch.setattr(changes.time, "time", lambda: later)
+ assert client(app, staff["mod"]).get(f"/admin/luckperms/changes/{change['id']}").json()["status"] == "unknown"
diff --git a/frontend/app/components/admin/ranks/ChangeConfirm.tsx b/frontend/app/components/admin/ranks/ChangeConfirm.tsx
index 312d3bfe..35c6e491 100644
--- a/frontend/app/components/admin/ranks/ChangeConfirm.tsx
+++ b/frontend/app/components/admin/ranks/ChangeConfirm.tsx
@@ -72,7 +72,7 @@ export default function ChangeConfirm({
{STATUS_LABELS[change.status]}
{change.error ? `: ${lpErrorMessage(change.error)}` : "…"}
{change.status === "unknown" ? " Check the player before trying again." : ""}
- {!settled && !busy ? " It is still queued; this page will show it once the server applies it." : ""}
+ {!settled && !busy ? " Reload the page later to see whether it applied." : ""}
{settled || !busy ? (
onDone(change)}>
diff --git a/frontend/lib/admin/luckperms.ts b/frontend/lib/admin/luckperms.ts
index 8d4cba09..5f11593a 100644
--- a/frontend/lib/admin/luckperms.ts
+++ b/frontend/lib/admin/luckperms.ts
@@ -172,10 +172,11 @@ export function isFinished(status: LpChangeStatus): boolean {
return status !== "pending" && status !== "sent";
}
-const POLL_MS = 1000;
-const POLL_LIMIT_MS = 30_000;
+const POLL_MS = 1500;
+// Past the site's 5-minute wait for a result, so every change ends settled.
+const POLL_LIMIT_MS = 330_000;
-/** Wait for the server to apply a change, or give up after half a minute and return what is known. */
+/** Wait until the change is settled: applied, failed, expired or of unknown outcome. */
export async function waitForChange(
id: number,
{ signal, intervalMs = POLL_MS, limitMs = POLL_LIMIT_MS }: { signal?: AbortSignal; intervalMs?: number; limitMs?: number } = {}
@@ -222,7 +223,7 @@ export const LP_ERRORS: Record = {
player_not_found: "LuckPerms has no record of that player.",
save_failed: "LuckPerms couldn’t save the change.",
no_result: "The server never reported back.",
- actor_changed: "Cancelled: the person who made it no longer had the rights.",
+ no_longer_allowed: "Cancelled: it was no longer allowed when the server picked it up (rights or groups changed).",
expired: "The server didn’t pick it up in time.",
};
From f97c5be319157010589cb045a9d166844bbbe218 Mon Sep 17 00:00:00 2001
From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com>
Date: Wed, 7 Oct 2026 14:23:58 +0000
Subject: [PATCH 5/6] Guard from the actor's current role; hold changes behind
unsettled definitions
- The bridge guard follows the actor's role at collection, the same one
the rights check uses.
- A group or track change of unknown outcome keeps holding later changes
until a snapshot taken after it arrives.
- The node forms send prefixes and suffixes with their spaces.
Co-Authored-By: Claude Opus 5.5 (1M context)
---
backend/src/luckperms/changes.py | 26 ++++++++++++-------
backend/src/luckperms/test_luckperms.py | 22 ++++++++++++++++
.../app/components/admin/ranks/RankGroup.tsx | 2 +-
.../app/components/admin/ranks/RankPlayer.tsx | 2 +-
4 files changed, 41 insertions(+), 11 deletions(-)
diff --git a/backend/src/luckperms/changes.py b/backend/src/luckperms/changes.py
index 0e49dea7..2199b410 100644
--- a/backend/src/luckperms/changes.py
+++ b/backend/src/luckperms/changes.py
@@ -441,20 +441,20 @@ def _actor_name(row) -> str:
return f"web:{row['actor_name'] or row['actor_user_id']}"[:100]
-def _still_allowed(conn, row) -> bool:
+def _current_actor(conn, row) -> dict | None:
"""The actor's rights again at collection time: a demotion since queueing cancels the change."""
actor = conn.execute("SELECT id AS user_id, discord_user_id, role FROM users WHERE id = ?",
(row["actor_user_id"],)).fetchone()
if actor is None or not roles.can(actor["role"], "change_luckperms"):
- return False
+ return None
actor = dict(actor)
try:
if row["target_type"] == "user":
check_user_target(conn, actor, row["target"])
check_ops_allowed(conn, actor["role"], row["target_type"], json.loads(row["ops_json"]))
except AdminError:
- return False
- return True
+ return None
+ return actor
def settle_timeouts(conn, now: float | None = None) -> None:
@@ -469,14 +469,14 @@ def settle_timeouts(conn, now: float | None = None) -> None:
(_iso(now), row["id"]))
-def _guard(conn, row) -> dict | None:
+def _guard(conn, row, actor: dict) -> dict | None:
"""What the bridge must recheck against live LuckPerms for an admin's change.
The mirror can lag in-game edits, so the bridge refuses the change if the
player now holds a group outside the admin groups, or a group being added
now inherits one.
"""
- if row["target_type"] != "user" or row["actor_role"] == "root":
+ if row["target_type"] != "user" or actor["role"] == "root":
return None
rules = policy.load()
groups = mirror.load_groups(conn)
@@ -498,8 +498,15 @@ def fetch_for_bridge() -> list[dict]:
mirror.record_poll(conn)
settle_timeouts(conn, now)
out = []
+ # Until a definition change's result arrives, or a snapshot taken after it was
+ # sent shows LuckPerms as it now is, nothing else is checked against old groups.
definition_sent = conn.execute(
- "SELECT 1 FROM lp_changes WHERE status = 'sent' AND target_type != 'user'").fetchone()
+ """
+ SELECT 1 FROM lp_changes c
+ WHERE c.target_type != 'user'
+ AND (c.status = 'sent' OR (c.status = 'unknown' AND NOT EXISTS (
+ SELECT 1 FROM lp_state s WHERE s.id = 1 AND s.checked_at > c.sent_at)))
+ """).fetchone()
rows = [] if read_only() or definition_sent else conn.execute(
"SELECT * FROM lp_changes WHERE status = 'pending' ORDER BY id").fetchall()
for row in rows:
@@ -508,7 +515,8 @@ def fetch_for_bridge() -> list[dict]:
definition = row["target_type"] != "user"
if definition and out:
break
- if not _still_allowed(conn, row):
+ actor = _current_actor(conn, row)
+ if actor is None:
conn.execute("UPDATE lp_changes SET status = 'failed', finished_at = ?, error = 'no_longer_allowed' "
"WHERE id = ?", (_iso(now), row["id"]))
continue
@@ -524,7 +532,7 @@ def fetch_for_bridge() -> list[dict]:
"description": row["description"],
"ops": json.loads(row["ops_json"]),
}
- guard = _guard(conn, row)
+ guard = _guard(conn, row, actor)
if guard is not None:
change["guard"] = guard
out.append(change)
diff --git a/backend/src/luckperms/test_luckperms.py b/backend/src/luckperms/test_luckperms.py
index 94139fa8..9ba60b6f 100644
--- a/backend/src/luckperms/test_luckperms.py
+++ b/backend/src/luckperms/test_luckperms.py
@@ -445,3 +445,25 @@ def test_timeouts_settle_without_the_bridge(app, bridge, staff, monkeypatch):
later = time.time() + changes.RESULT_WAIT_SECONDS + 5
monkeypatch.setattr(changes.time, "time", lambda: later)
assert client(app, staff["mod"]).get(f"/admin/luckperms/changes/{change['id']}").json()["status"] == "unknown"
+
+
+def test_guard_follows_the_actors_current_role(app, bridge, staff, env):
+ submit(app, staff["root"], "user", ALICE, [{"op": "add_node", "node": {"key": "tips.off"}}])
+ with env.connect() as conn:
+ conn.execute("UPDATE users SET role = 'admin' WHERE discord_username = 'rory'")
+ conn.commit()
+ sent = changes.fetch_for_bridge()
+ assert len(sent) == 1 and "admin_groups" in sent[0]["guard"]
+
+
+def test_unanswered_definition_change_holds_others_until_a_snapshot(app, bridge, staff, monkeypatch):
+ submit(app, staff["root"], "group", "noble", [{"op": "add_node", "node": {"key": "tips.off"}}])
+ assert len(changes.fetch_for_bridge()) == 1
+ submit(app, staff["root"], "user", ALICE, [{"op": "add_node", "node": {"key": "tips.off"}}])
+ later = time.time() + changes.RESULT_WAIT_SECONDS + 5
+ monkeypatch.setattr(changes.time, "time", lambda: later)
+ monkeypatch.setattr(mirror.time, "time", lambda: later)
+ assert changes.fetch_for_bridge() == [] # unknown, and no snapshot since
+ monkeypatch.setattr(mirror, "_iso", lambda ts=None: changes._iso(later + 1))
+ bridge.put("/luckperms/plugin/snapshot", headers=PLUGIN, json=snapshot(revision=9000))
+ assert [c["target"] for c in changes.fetch_for_bridge()] == [ALICE]
diff --git a/frontend/app/components/admin/ranks/RankGroup.tsx b/frontend/app/components/admin/ranks/RankGroup.tsx
index 2b4a38f4..7815bf71 100644
--- a/frontend/app/components/admin/ranks/RankGroup.tsx
+++ b/frontend/app/components/admin/ranks/RankGroup.tsx
@@ -398,7 +398,7 @@ function NodesPanel({
onSubmit={(e) => {
e.preventDefault();
const contexts: Record = server ? { server: [server] } : {};
- const node = { key: key.trim(), value, contexts };
+ const node = { key, value, contexts };
addNode(node, (
<>
Set {node.key} to {String(value)} on {group}
diff --git a/frontend/app/components/admin/ranks/RankPlayer.tsx b/frontend/app/components/admin/ranks/RankPlayer.tsx
index dd8f33cb..62c7200f 100644
--- a/frontend/app/components/admin/ranks/RankPlayer.tsx
+++ b/frontend/app/components/admin/ranks/RankPlayer.tsx
@@ -482,7 +482,7 @@ function PermissionsPanel({
className="mt-4 flex flex-wrap items-end gap-3"
onSubmit={(e) => {
e.preventDefault();
- const node = { key: key.trim(), value, ...scopeFields(server, duration) };
+ const node = { key, value, ...scopeFields(server, duration) };
onAsk({
key: "add-permission",
summary: (
From 41c4b64e47b127aefec3518ba3a2fb363f4bc4b4 Mon Sep 17 00:00:00 2001
From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com>
Date: Wed, 7 Oct 2026 14:35:12 +0000
Subject: [PATCH 6/6] Address CodeRabbit: validate result state first,
prefix-change flag, error wording, search paging
Co-Authored-By: Claude Opus 5.5 (1M context)
---
backend/src/luckperms/mirror.py | 23 +++++++++++--------
backend/src/luckperms/test_luckperms.py | 10 ++++++++
.../components/admin/ranks/ChangeConfirm.tsx | 6 ++---
.../app/components/admin/ranks/RankGroup.tsx | 14 ++++++-----
.../app/components/admin/ranks/RankPlayer.tsx | 5 ++--
.../components/admin/ranks/RanksOverview.tsx | 17 ++++++++++----
frontend/lib/admin/luckperms.ts | 9 +++++++-
7 files changed, 57 insertions(+), 27 deletions(-)
diff --git a/backend/src/luckperms/mirror.py b/backend/src/luckperms/mirror.py
index 3f3dbc74..0d96f5f7 100644
--- a/backend/src/luckperms/mirror.py
+++ b/backend/src/luckperms/mirror.py
@@ -187,20 +187,25 @@ def apply_state(conn, target_type: str, target: str, state, revision) -> None:
revision = _revision(revision)
if not _newer(conn, revision):
return
+ # Parse and match before deleting anything, so a bad state leaves the mirror alone.
+ if state is not None and not isinstance(state, dict):
+ raise SnapshotError("bad_snapshot")
if target_type == "user":
+ parsed = _user(state) if state is not None else None
+ if parsed is not None and parsed[0]["uuid"] != target:
+ raise SnapshotError("bad_snapshot")
conn.execute("DELETE FROM lp_user_nodes WHERE uuid = ?", (target,))
conn.execute("DELETE FROM lp_users WHERE uuid = ?", (target,))
- if isinstance(state, dict):
- user, user_nodes = _user(state)
- if user["uuid"] == target:
- _insert_user(conn, user, user_nodes)
- elif target_type == "group":
+ if parsed is not None:
+ _insert_user(conn, *parsed)
+ else:
+ parsed = _group(state) if state is not None else None
+ if parsed is not None and parsed[0]["name"] != target:
+ raise SnapshotError("bad_snapshot")
conn.execute("DELETE FROM lp_group_nodes WHERE group_name = ?", (target,))
conn.execute("DELETE FROM lp_groups WHERE name = ?", (target,))
- if isinstance(state, dict):
- group, group_nodes = _group(state)
- if group["name"] == target:
- _insert_group(conn, group, group_nodes)
+ if parsed is not None:
+ _insert_group(conn, *parsed)
conn.execute("UPDATE lp_state SET hash = NULL, revision = ? WHERE id = 1", (revision,))
diff --git a/backend/src/luckperms/test_luckperms.py b/backend/src/luckperms/test_luckperms.py
index 9ba60b6f..98607b12 100644
--- a/backend/src/luckperms/test_luckperms.py
+++ b/backend/src/luckperms/test_luckperms.py
@@ -467,3 +467,13 @@ def test_unanswered_definition_change_holds_others_until_a_snapshot(app, bridge,
monkeypatch.setattr(mirror, "_iso", lambda ts=None: changes._iso(later + 1))
bridge.put("/luckperms/plugin/snapshot", headers=PLUGIN, json=snapshot(revision=9000))
assert [c["target"] for c in changes.fetch_for_bridge()] == [ALICE]
+
+
+def test_a_bad_result_state_leaves_the_mirror_alone(app, bridge, staff, env):
+ change = submit(app, staff["admin"], "user", ALICE, [{"op": "add_node", "node": {"key": "tips.off"}}]).json()
+ changes.fetch_for_bridge()
+ wrong = {"uuid": BOB, "name": "Bob", "nodes": []}
+ changes.record_results([{"id": change["id"], "ok": True, "revision": 7000, "state": wrong}])
+ with env.connect() as conn:
+ assert mirror.user_row(conn, ALICE) is not None
+ assert conn.execute("SELECT hash FROM lp_state").fetchone()["hash"] is None
diff --git a/frontend/app/components/admin/ranks/ChangeConfirm.tsx b/frontend/app/components/admin/ranks/ChangeConfirm.tsx
index 35c6e491..b26187bc 100644
--- a/frontend/app/components/admin/ranks/ChangeConfirm.tsx
+++ b/frontend/app/components/admin/ranks/ChangeConfirm.tsx
@@ -1,11 +1,9 @@
"use client";
import { FormEvent, ReactNode, useEffect, useRef, useState } from "react";
-import { AccountApiError } from "../../../../lib/account/api";
-import { adminErrorMessage } from "../../../../lib/admin/api";
import {
- LP_ERRORS,
lpErrorMessage,
+ lpRequestMessage,
STATUS_LABELS,
submitLpChange,
waitForChange,
@@ -58,7 +56,7 @@ export default function ChangeConfirm({
setChange(settled);
if (settled.status === "applied") onDone(settled);
} catch (err) {
- setError(err instanceof AccountApiError && err.message in LP_ERRORS ? LP_ERRORS[err.message] : adminErrorMessage(err));
+ setError(lpRequestMessage(err));
} finally {
setBusy(false);
}
diff --git a/frontend/app/components/admin/ranks/RankGroup.tsx b/frontend/app/components/admin/ranks/RankGroup.tsx
index 7815bf71..b0122427 100644
--- a/frontend/app/components/admin/ranks/RankGroup.tsx
+++ b/frontend/app/components/admin/ranks/RankGroup.tsx
@@ -4,12 +4,12 @@ import Link from "next/link";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
import { AccountApiError } from "../../../../lib/account/api";
-import { adminErrorMessage } from "../../../../lib/admin/api";
import {
contextLabel,
expiryLabel,
getLpGroup,
getLpPlayers,
+ lpRequestMessage,
STATUS_LABELS,
type LpGroupDetail,
type LpNode,
@@ -49,7 +49,7 @@ export default function RankGroup({ name }: { name: string }) {
setMembers(data.members);
} catch (err) {
const status = err instanceof AccountApiError ? err.status : 0;
- setLoad(status === 400 || status === 404 ? { kind: "failed", message: adminErrorMessage(err) } : { kind: gateKind(err) });
+ setLoad(status === 400 || status === 404 ? { kind: "failed", message: lpRequestMessage(err) } : { kind: gateKind(err) });
}
}, [name]);
@@ -61,7 +61,7 @@ export default function RankGroup({ name }: { name: string }) {
if (load.kind === "failed") {
return (
- {load.message === "group_missing" ? "That group doesn’t exist." : load.message}
+ {load.message}
);
}
@@ -225,6 +225,8 @@ function SettingsForm({
const [weight, setWeight] = useState(currentWeight);
const [prefix, setPrefix] = useState(currentPrefix);
const [priority, setPriority] = useState(currentPriority || currentWeight || "0");
+ // A priority on its own is not a change: it only counts with a prefix.
+ const prefixChanged = prefix !== currentPrefix || (Boolean(prefix) && priority !== currentPriority);
function save() {
const ops: LpOp[] = [];
@@ -232,7 +234,7 @@ function SettingsForm({
if (weightNode) ops.push({ op: "remove_node", node: plain(weightNode) });
if (weight) ops.push({ op: "add_node", node: { key: `weight.${weight}` } });
}
- if (prefix !== currentPrefix || priority !== currentPriority) {
+ if (prefixChanged) {
if (prefixNode) ops.push({ op: "remove_node", node: plain(prefixNode) });
if (prefix) ops.push({ op: "add_node", node: { key: `prefix.${priority || "0"}.${prefix}` } });
}
@@ -242,7 +244,7 @@ function SettingsForm({
summary: (
<>
{weight !== currentWeight ? <>Weight {currentWeight || "none"} → {weight || "none"}. > : null}
- {prefix !== currentPrefix || priority !== currentPriority ? (
+ {prefixChanged ? (
<>
Prefix {currentPrefix ? : "none"} → {prefix ? : "none"}
{prefix ? ` (priority ${priority || 0})` : ""}.
@@ -274,7 +276,7 @@ function SettingsForm({
Preview
) : null}
-
+
Save
diff --git a/frontend/app/components/admin/ranks/RankPlayer.tsx b/frontend/app/components/admin/ranks/RankPlayer.tsx
index 62c7200f..a2d30f18 100644
--- a/frontend/app/components/admin/ranks/RankPlayer.tsx
+++ b/frontend/app/components/admin/ranks/RankPlayer.tsx
@@ -3,12 +3,13 @@
import Link from "next/link";
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
import { AccountApiError } from "../../../../lib/account/api";
-import { adminErrorMessage, roleLabel } from "../../../../lib/admin/api";
+import { roleLabel } from "../../../../lib/admin/api";
import {
contextLabel,
DURATIONS,
expiryLabel,
getLpPlayer,
+ lpRequestMessage,
STATUS_LABELS,
type LpNode,
type LpOp,
@@ -39,7 +40,7 @@ export default function RankPlayer({ uuid }: { uuid: string }) {
setLoad({ kind: "ready", data: await getLpPlayer(uuid) });
} catch (err) {
const status = err instanceof AccountApiError ? err.status : 0;
- setLoad(status === 400 || status === 404 ? { kind: "failed", message: adminErrorMessage(err) } : { kind: gateKind(err) });
+ setLoad(status === 400 || status === 404 ? { kind: "failed", message: lpRequestMessage(err) } : { kind: gateKind(err) });
}
}, [uuid]);
diff --git a/frontend/app/components/admin/ranks/RanksOverview.tsx b/frontend/app/components/admin/ranks/RanksOverview.tsx
index 0721e786..5a74dd1e 100644
--- a/frontend/app/components/admin/ranks/RanksOverview.tsx
+++ b/frontend/app/components/admin/ranks/RanksOverview.tsx
@@ -1,7 +1,7 @@
"use client";
import Link from "next/link";
-import { FormEvent, useCallback, useEffect, useState } from "react";
+import { FormEvent, useCallback, useEffect, useRef, useState } from "react";
import { adminErrorMessage } from "../../../../lib/admin/api";
import {
getLpPlayers,
@@ -56,20 +56,27 @@ function PlayerSearch({ initialQuery, total }: { initialQuery: string; total: nu
const [page, setPage] = useState(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState(null);
+ // The search the shown results belong to, for paging; and the newest request, so older answers drop.
+ const [searched, setSearched] = useState("");
+ const latest = useRef(0);
const search = useCallback(async (text: string, pageNumber = 1) => {
if (!text.trim()) return;
+ const request = ++latest.current;
setBusy(true);
setError(null);
try {
- setPage(await getLpPlayers({ q: text, page: pageNumber }));
+ const result = await getLpPlayers({ q: text, page: pageNumber });
+ if (request !== latest.current) return;
+ setPage(result);
+ setSearched(text);
const url = new URL(window.location.href);
url.searchParams.set("q", text.trim());
window.history.replaceState(null, "", url);
} catch (err) {
- setError(adminErrorMessage(err));
+ if (request === latest.current) setError(adminErrorMessage(err));
} finally {
- setBusy(false);
+ if (request === latest.current) setBusy(false);
}
}, []);
@@ -110,7 +117,7 @@ function PlayerSearch({ initialQuery, total }: { initialQuery: string; total: nu
{error}
) : null}
- {page ? void search(query, n)} /> : null}
+ {page ? void search(searched, n)} /> : null}
);
}
diff --git a/frontend/lib/admin/luckperms.ts b/frontend/lib/admin/luckperms.ts
index 5f11593a..6487acab 100644
--- a/frontend/lib/admin/luckperms.ts
+++ b/frontend/lib/admin/luckperms.ts
@@ -1,4 +1,5 @@
-import { adminRequest } from "./api";
+import { AccountApiError } from "../account/api";
+import { adminErrorMessage, adminRequest } from "./api";
import type { StaffRole } from "./api";
/** A LuckPerms node. Contexts are {} when global; expiry is Unix seconds, 0 when permanent. */
@@ -227,6 +228,12 @@ export const LP_ERRORS: Record = {
expired: "The server didn’t pick it up in time.",
};
+/** A failed request in words: LuckPerms codes first, then the staff panel's own. */
+export function lpRequestMessage(err: unknown): string {
+ if (err instanceof AccountApiError && err.message in LP_ERRORS) return LP_ERRORS[err.message];
+ return adminErrorMessage(err);
+}
+
export function lpErrorMessage(code: string | null | undefined): string {
if (!code) return "Something went wrong.";
return LP_ERRORS[code] ?? code;