diff --git a/backend/src/api/admin_routes.py b/backend/src/api/admin_routes.py index 41b918fb..a252cece 100644 --- a/backend/src/api/admin_routes.py +++ b/backend/src/api/admin_routes.py @@ -11,7 +11,7 @@ import urllib.parse import psycopg -from fastapi import APIRouter, FastAPI, HTTPException, Query, Request, Response +from fastapi import APIRouter, FastAPI, HTTPException, Path, Query, Request, Response from fastapi.exception_handlers import request_validation_exception_handler from fastapi.exceptions import RequestValidationError from pydantic import BaseModel, Field, StrictBool @@ -28,6 +28,7 @@ staff_cookie, ) from src.auth import admin, audit, players, roles, users, view_as +from src.skins import staff_alts from src.auth import cases as person_cases from src.precedent.db import PrecedentDBError from src.coreprotect.reader import CoreProtectConfig @@ -381,6 +382,29 @@ def post_view_as(player_uuid: str, request: Request): return response +@admin_router.post("/players/{player_uuid}/staff-alt/unlink") +def post_staff_alt_unlink(request: Request, response: Response, + player_uuid: str = Path(pattern=r"^[0-9A-Fa-f]{8}(-[0-9A-Fa-f]{4}){3}-[0-9A-Fa-f]{12}$")): + """Remove this account's staff alt link, if you outrank its owner; audited. + + Refusals from another site and by rank are audited too; a missing capability (403) + and writes during a view (ViewAsGuard) are refused before this, as on other routes. + """ + action, target = "account.staff_alt.remove", {"player_uuid": player_uuid[:64]} + actor = _capable(request, "manage_staff_alts") + _no_store(response) + + def remove(_token): + try: + staff_alts.unlink(player_uuid, actor, any_owner=True) + except staff_alts.StaffAltRefused as exc: + audit.record_refusal(actor=actor, action=action, outcome="denied", detail={"error": exc.code, **target}) + raise admin.AdminError(exc.status, exc.code, "denied") from None + return staff_alts.for_player(player_uuid) + + return _write(request, action, target, remove) + + # Request validation runs before the handlers above, so malformed writes are # audited here: the action, the target and which fields failed, never values. _WRITE_ROUTES = ( diff --git a/backend/src/api/auth_routes.py b/backend/src/api/auth_routes.py index b013550e..636081cf 100644 --- a/backend/src/api/auth_routes.py +++ b/backend/src/api/auth_routes.py @@ -16,11 +16,12 @@ from urllib.parse import urlencode, urlsplit from fastapi import APIRouter, HTTPException, Request, Response +from fastapi import Path as PathParam from fastapi.responses import RedirectResponse from pydantic import BaseModel, Field from src.api.prod_guard import is_production -from src.auth import account_overview, guild_check, preview_sign_in, users, view_as +from src.auth import account_overview, guild_check, preview_sign_in, roles, users, view_as from src.auth.config import AuthConfig from src.auth import microsoft_link from src.auth.discord import DiscordClient, DiscordError @@ -28,6 +29,7 @@ from src.patreon import linking as patreon_linking from src.patreon import service as patreon_service from src.patreon.config import Config as PatreonConfig +from src.skins import staff_alts from src.skins.codes import CodeError, start_linked_profile_session from src.skins.discord_link import ( LinkError, @@ -480,6 +482,8 @@ def get_account(request: Request, response: Response): "minecraft": minecraft, "microsoft_link": MicrosoftConfig.from_env().usable, "patreon": _patreon(user), + # Staff only (or anyone who still has alts): src/skins/staff_alts.py. + "staff_alts": staff_alts.account_view(user), "viewing_as": ( {"staff_name": user["acting_name"], "expires_at": user["expires_at"]} if user["acting_user_id"] is not None else None @@ -510,11 +514,17 @@ def get_account_overview(request: Request, response: Response): @auth_router.get("/account/minecraft/head") -def get_minecraft_head(request: Request): - """The linked player's skin face. Only your own, so the site is no open Mojang proxy.""" +def get_minecraft_head(request: Request, u: str | None = None): + """The linked player's skin face, or with ?u= one of your staff alts'. Only your own + accounts, so the site is no open Mojang proxy. + + Any other ?u= gets your own face, not an error: the page has always sent ?u= as a cache key, so a u that is not one of your alts means your linked account. + """ config = _config() user = current_user(request, config) - png = account_overview.head(_linked_player(user)) + alt = (u or "").strip() + png = account_overview.head(alt if alt and staff_alts.owns(user, alt) else _linked_player(user)) if png is None: raise HTTPException(404, detail="head_unavailable") return Response(content=png, media_type="image/png", headers={"Cache-Control": "private, max-age=3600"}) @@ -587,6 +597,7 @@ def minecraft_link(request: Request, body: CodeBody, response: Response): "This Discord account is already linked to a different Minecraft player": "discord_taken", "This Minecraft account is already linked to a different Discord account": "minecraft_taken", "This link attempt is no longer valid": "expired", + staff_alts.STAFF_ALT_TAKEN: "minecraft_taken", } @@ -692,6 +703,65 @@ def minecraft_unlink(request: Request, response: Response): return {"minecraft": None} +# -------------------- +# Staff alts (src/skins/staff_alts.py) +# -------------------- + +_UUID_PATH = r"^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}$" + + +def _staff_alt_user(request: Request, config: AuthConfig) -> dict: + """A same-origin request from signed-in staff, within the link attempt limit. + + No fresh guild check, unlike linking your own account: an alt counts only through + the owner's own link, whose guild-leave grace ends it. + """ + require_same_origin(request, config) + user = current_user(request, config) + if not roles.is_staff(user["role"]): + raise HTTPException(403, detail="forbidden") + _check_link_rate(user["user_id"]) + return user + + +@auth_router.post("/account/staff-alts/preview") +def staff_alt_preview(request: Request, body: CodeBody, response: Response): + """Name the account a /linkdiscord code from an alt would add, without using the code.""" + config = _config() + user = _staff_alt_user(request, config) + _no_store(response) + try: + return staff_alts.preview(normalise_code(body.code), user["discord_user_id"]) + except LinkError as e: + raise HTTPException(400, detail=str(e)) from e + + +@auth_router.post("/account/staff-alts/link") +def staff_alt_link(request: Request, body: CodeBody, response: Response): + config = _config() + user = _staff_alt_user(request, config) + _no_store(response) + try: + staff_alts.link(normalise_code(body.code), user) + except LinkError as e: + raise HTTPException(400, detail=str(e)) from e + return {"staff_alts": staff_alts.account_view(user)} + + +@auth_router.post("/account/staff-alts/{player_uuid}/unlink") +def staff_alt_unlink(request: Request, response: Response, player_uuid: str = PathParam(pattern=_UUID_PATH)): + """Remove one of your staff alts; also open to owners no longer on staff.""" + config = _config() + require_same_origin(request, config) + user = current_user(request, config) + _no_store(response) + try: + staff_alts.unlink(player_uuid, user) + except staff_alts.StaffAltRefused as e: + raise HTTPException(e.status, detail=e.code) from e + return {"staff_alts": staff_alts.account_view(user)} + + @auth_router.post("/account/patreon/unlink") def patreon_unlink(request: Request, response: Response): config = _config() diff --git a/backend/src/auth/players.py b/backend/src/auth/players.py index d4c9f9b4..f22a2beb 100644 --- a/backend/src/auth/players.py +++ b/backend/src/auth/players.py @@ -21,6 +21,7 @@ from src import database from src.characters.roster import list_roster from src.characters.wardrobe import active_face_png, active_figure_png, WardrobeError +from src.skins import staff_alts from . import audit, discord_names, roles, users @@ -388,14 +389,18 @@ def profile(config: CoreProtectConfig, text: str) -> dict: except Unavailable as exc: error = exc links, roster = _site_rows(key) + alt_links = staff_alts.for_player(key) # A link whose handle is unknown: ask Discord now (with a bot token), so the profile shows it. if links and not (links[0]["account_username"] or links[0]["discord_username"]): if discord_names.refresh_one(links[0]["discord_user_id"]): links, roster = _site_rows(key) - if not accounts and not links and not roster and error is None: + if not accounts and not links and not roster and not alt_links["staff_alt_of"] and error is None: raise PlayerError(404, "player_not_found") link = links[0] if links else None current = accounts[-1]["user"] if accounts else (link["minecraft_name"] if link else None) + if current is None and alt_links["staff_alt_of"]: + # An alt never seen in game: the name it had when linked. + current = staff_alts.alt_name(key) sheets = { (realm, c["id"]): c for uuid, realm in {(c["player_uuid"], c["realm_id"]) for c in roster} @@ -410,6 +415,8 @@ def profile(config: CoreProtectConfig, text: str) -> dict: "online": sessions.is_fresh(event, now, config.ping_seconds), "discord": _discord(link), "account": _account(link), + # Whose staff alt this account is, and its own alts (src/skins/staff_alts.py). + **alt_links, "characters": [ {**sheets.get((c["realm_id"], c["character_id"]), {}), **{k: c[k] for k in ("realm_id", "character_id", "name", "race", "class", "updated_at")}, diff --git a/backend/src/auth/roles.py b/backend/src/auth/roles.py index 72dea23c..c269e7d7 100644 --- a/backend/src/auth/roles.py +++ b/backend/src/auth/roles.py @@ -27,6 +27,8 @@ "change_luckperms": "admin", # Moderation case log; the Discord bot reaches it with its own key. "use_precedent": "mod", + # Remove anyone's staff alt link (src/skins/staff_alts.py); audited. + "manage_staff_alts": "admin", # Read-only lookup of skin and drink codes. "inspect_codes": "mod", # Reports players send from the /diagnostic page. diff --git a/backend/src/auth/test_staff_alts.py b/backend/src/auth/test_staff_alts.py new file mode 100644 index 00000000..216fa568 --- /dev/null +++ b/backend/src/auth/test_staff_alts.py @@ -0,0 +1,584 @@ +"""Staff alt links: staff add a second Minecraft account with its own /linkdiscord code.""" + +from __future__ import annotations + +import time + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from src.api import admin_routes, auth_routes +from src.api.view_as_guard import ViewAsGuard +from src.auth import account_overview, players, testing, users +from src.coreprotect.reader import CoreProtectConfig +from src.skins import codes, discord_link, staff_alts, submissions +from src.skins.discord_link import STAFF_ALT_TAKEN, LinkError +from src.skins.moderation import enqueue_ban_event, list_ban_sync, list_undelivered_moderation +from src.skins.plugin_notices import list_undelivered_plugin_notices +from src.skins.testing import link + +SITE = "https://www.tfminecraft.net" +ORIGIN = {"Origin": SITE} +SESSION = "__Host-tfmc_session" +DOMAIN = "testserver.local" +OWNER = "00000000-0000-4000-8000-0000000000a1" +ALT = "00000000-0000-4000-8000-0000000000a2" +OTHER = "00000000-0000-4000-8000-0000000000a3" +HOUR = 3_600_000 + + +@pytest.fixture +def env(database, postgres, 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("PATREON_ENABLED", "0") + monkeypatch.delenv("DISCORD_REDIRECT_URI", raising=False) + monkeypatch.delenv("PS_PRODUCTION", raising=False) + auth_routes._LINK_RATE_BUCKETS.clear() + return database + + +@pytest.fixture +def app(env): + app = FastAPI() + app.include_router(auth_routes.auth_router) + admin_routes.install(app) + app.add_middleware(ViewAsGuard) + return app + + +def account(name, role="player", player_uuid=None): + """A signed-in account, linked to player_uuid when given; (discord id, client).""" + discord_id = str(100000000000000000 + sum(map(ord, name)) * 7919) + token = users.sign_in( + {"discord_user_id": discord_id, "discord_username": name, "discord_global_name": name.title(), + "discord_avatar": None}, + guild_member=True, + ) + testing.set_role(discord_id, role) + if player_uuid: + link(player_uuid, discord_id, name.title(), name, linked_at="2026-10-01T00:00:00Z") + return discord_id, token + + +def client(app, token): + c = TestClient(app, base_url="https://testserver") + c.cookies.set(SESSION, token, domain=DOMAIN) + return c + + +def code(player_uuid=ALT, name="ShayminPlays"): + return discord_link.start_link(player_uuid, name)["code"] + + +def raw_code(player_uuid=ALT, name="ShayminPlays"): + """A /linkdiscord code even for an account that start_link would call linked.""" + plain = codes.generate_plaintext_code() + testing.execute( + "INSERT INTO discord_link_codes (code_hash, player_uuid, minecraft_name, created_at, expires_at) " + "VALUES (%s, %s, %s, now(), now() + interval '15 minutes')", + (codes.hash_secret(plain), player_uuid, name), + ) + return plain + + +def wren(app, role="mod"): + discord_id, token = account("wren", role, OWNER) + return discord_id, client(app, token) + + +def add_alt(c, player_uuid=ALT, name="ShayminPlays"): + return c.post("/account/staff-alts/link", json={"code": code(player_uuid, name)}, headers=ORIGIN) + + +def test_staff_link_an_alt_with_its_code(app): + discord_id, c = wren(app) + plain = code() + preview = c.post("/account/staff-alts/preview", json={"code": plain}, headers=ORIGIN) + assert preview.status_code == 200, preview.text + assert preview.json()["minecraft_name"] == "ShayminPlays" and preview.json()["player_uuid"] == ALT + + res = c.post("/account/staff-alts/link", json={"code": plain}, headers=ORIGIN) + assert res.status_code == 200, res.text + view = res.json()["staff_alts"] + assert [a["minecraft_name"] for a in view["alts"]] == ["ShayminPlays"] + assert view["active"] is True and view["staff"] is True and view["max"] == staff_alts.MAX_ALTS + assert c.get("/account").json()["staff_alts"] == view + # The code is spent, the alt hears it is linked, and the link is on record. + assert c.post("/account/staff-alts/link", json={"code": plain}, headers=ORIGIN).status_code == 400 + assert any(n["type"] == "link_success" and n["player_uuid"] == ALT for n in list_undelivered_plugin_notices()) + audit = testing.audit_rows()[-1] + assert (audit["action"], audit["detail"]["player_uuid"]) == ("account.staff_alt.link", ALT) + + # The alt now counts as linked through its owner: the gate, codes and skin uploads. + status = discord_link.get_identity_status(ALT) + assert status["linked"] and status["eligible"] and status["discord_user_id"] == discord_id + assert status["player_uuid"] == ALT and status["minecraft_name"] == "ShayminPlays" + assert status["staff_alt_of"] == {"player_uuid": OWNER, "minecraft_name": "Wren"} + assert codes.issue_code(ALT, "profile")["scope"] == "profile" + assert submissions._link_names(ALT) == { + "minecraft_name": "ShayminPlays", "discord_username": "wren", "discord_user_id": discord_id} + assert discord_link.start_link(ALT, "ShayminPlays") == {"already_linked": True, "discord_username": "wren"} + # The owner's own link is unchanged. + assert discord_link.get_identity_status(OWNER)["player_uuid"] == OWNER + + +def test_players_cannot_link_alts(app): + _, token = account("steve", "player", OWNER) + c = client(app, token) + assert c.post("/account/staff-alts/preview", json={"code": code()}, headers=ORIGIN).status_code == 403 + assert c.post("/account/staff-alts/link", json={"code": code()}, headers=ORIGIN).status_code == 403 + assert c.get("/account").json()["staff_alts"] is None + assert testing.value("SELECT count(*) FROM staff_alt_links") == 0 + + +def test_cross_origin_writes_are_refused(app): + _, c = wren(app) + assert c.post("/account/staff-alts/link", json={"code": code()}).status_code == 403 + assert testing.value("SELECT count(*) FROM staff_alt_links") == 0 + + +@pytest.mark.parametrize("case,message", [ + ("own", "That is your own linked Minecraft account"), + ("discord_linked", "This Minecraft account is already linked to a Discord account"), + ("mine", "This Minecraft account is already one of your staff alts"), + ("theirs", STAFF_ALT_TAKEN), + ("banned", "Your linked Minecraft account is banned"), +]) +def test_refused_alts(app, case, message): + _, c = wren(app) + target = ALT + if case == "own": + target = OWNER + elif case == "discord_linked": + account("someone", "player", ALT) + elif case == "mine": + assert add_alt(c).status_code == 200 + elif case == "theirs": + _, token = account("ryan", "admin", OTHER) + assert add_alt(client(app, token)).status_code == 200 + elif case == "banned": + enqueue_ban_event("ban", player_uuid=OWNER, minecraft_name="Wren", banned_at_ms=1, expires_at_ms=0) + plain = raw_code(target) + for path in ("/account/staff-alts/preview", "/account/staff-alts/link"): + res = c.post(path, json={"code": plain}, headers=ORIGIN) + assert (res.status_code, res.json()["detail"]) == (400, message) + # A refused link rolls back its spend of the code. + assert testing.value("SELECT used_at FROM discord_link_codes WHERE player_uuid = %s ORDER BY id DESC LIMIT 1", + (target,)) is None + + +def test_staff_without_their_own_link_cannot_add_alts(app): + _, token = account("wren", "mod") + res = client(app, token).post("/account/staff-alts/link", json={"code": code()}, headers=ORIGIN) + assert (res.status_code, res.json()["detail"]) == (400, "Link your own Minecraft account before adding a staff alt") + + +def test_alts_are_capped(app): + _, c = wren(app) + for n in range(staff_alts.MAX_ALTS): + assert add_alt(c, f"00000000-0000-4000-8000-0000000001{n:02d}", f"Alt{n}").status_code == 200 + res = add_alt(c) + assert (res.status_code, res.json()["detail"]) == (400, f"You can link up to {staff_alts.MAX_ALTS} staff alts") + + +def test_a_microsoft_link_of_an_alt_reads_as_taken(): + assert auth_routes._LINK_CONFLICTS[STAFF_ALT_TAKEN] == "minecraft_taken" + + +def test_an_alt_is_never_linked_as_an_own_account(app): + _, c = wren(app) + assert add_alt(c).status_code == 200 + with pytest.raises(LinkError, match=STAFF_ALT_TAKEN): + discord_link.preview_link(raw_code()) + with pytest.raises(LinkError, match=STAFF_ALT_TAKEN): + discord_link.complete_link(raw_code(), "555555555555555555") + with pytest.raises(LinkError, match=STAFF_ALT_TAKEN): + discord_link.link_verified_profile(ALT, "ShayminPlays", "555555555555555555") + assert testing.value("SELECT count(*) FROM discord_links WHERE player_uuid = %s", (ALT,)) == 0 + + +def test_an_alt_counts_only_while_its_owner_is_linked_staff(app): + discord_id, c = wren(app) + assert add_alt(c).status_code == 200 + + testing.set_role(discord_id, "player") + assert discord_link.get_identity_status(ALT)["linked"] is False + assert discord_link.get_link_for_uuid(ALT) is None + with pytest.raises(codes.CodeError): + codes.issue_code(ALT, "profile") + # The link stays, shown as inactive, so they can still remove it; it works again if they return. + view = c.get("/account").json()["staff_alts"] + assert view["active"] is False and view["staff"] is False and len(view["alts"]) == 1 + testing.set_role(discord_id, "admin") + assert discord_link.get_identity_status(ALT)["eligible"] is True + + discord_link.unlink_by_discord_id(discord_id) + assert discord_link.get_identity_status(ALT)["linked"] is False + + +def test_owner_removes_an_alt_and_others_cannot(app): + _, c = wren(app) + assert add_alt(c).status_code == 200 + _, token = account("ryan", "mod", OTHER) + other = client(app, token) + assert other.post(f"/account/staff-alts/{ALT}/unlink", headers=ORIGIN).status_code == 404 + + res = c.post(f"/account/staff-alts/{ALT}/unlink", headers=ORIGIN) + assert res.status_code == 200 and res.json()["staff_alts"]["alts"] == [] + assert testing.audit_rows()[-1]["action"] == "account.staff_alt.unlink" + assert discord_link.get_identity_status(ALT)["linked"] is False + # Removed, the account can link to Discord normally again. + assert discord_link.complete_link(code(), "555555555555555555")["player_uuid"] == ALT + + +def test_admins_remove_anyones_alt(app): + _, c = wren(app) + assert add_alt(c).status_code == 200 + _, mod_token = account("moddy", "mod") + assert client(app, mod_token).post(f"/admin/players/{ALT}/staff-alt/unlink", headers=ORIGIN).status_code == 403 + _, admin_token = account("ryan", "admin") + admin = client(app, admin_token) + res = admin.post(f"/admin/players/{ALT}/staff-alt/unlink", headers=ORIGIN) + assert res.status_code == 200 and res.json() == {"staff_alt_of": None, "staff_alts": []} + row = testing.audit_rows()[-1] + assert (row["action"], row["actor_name"], row["target_name"], row["detail"]["player_uuid"]) == ( + "account.staff_alt.remove", "ryan", "wren", ALT) + assert admin.post(f"/admin/players/{ALT}/staff-alt/unlink", headers=ORIGIN).status_code == 404 + + +def test_staff_tools_show_both_sides(app): + discord_id, c = wren(app) + assert add_alt(c).status_code == 200 + alt = staff_alts.for_player(ALT) + assert alt["staff_alt_of"]["player_uuid"] == OWNER and alt["staff_alt_of"]["discord_user_id"] == discord_id + owner = staff_alts.for_player(OWNER.upper()) + assert owner["staff_alt_of"] is None and [a["player_uuid"] for a in owner["staff_alts"]] == [ALT] + + +def test_head_serves_only_your_own_alts(app, monkeypatch): + asked = [] + monkeypatch.setattr(account_overview, "head", lambda uuid: asked.append(uuid) or b"png") + _, c = wren(app) + assert add_alt(c).status_code == 200 + assert c.get("/account/minecraft/head", params={"u": ALT}).status_code == 200 + assert c.get("/account/minecraft/head", params={"u": OTHER}).status_code == 200 + assert asked == [ALT, OWNER] + + +def test_owner_bans_reach_the_alts(app): + discord_id, c = wren(app) + assert add_alt(c).status_code == 200 + now = int(time.time() * 1000) + + enqueue_ban_event("ban", player_uuid=OWNER, discord_user_id=discord_id, minecraft_name="Wren", + reason="Abuse", staff_name="Ryan", banned_at_ms=now, expires_at_ms=now + 24 * HOUR) + bans = {b["player_uuid"]: b for b in list_ban_sync()["bans"]} + assert bans[ALT]["reason"] == "Staff alt of Wren: Abuse" and bans[ALT]["staff_name"] == "Ryan" + assert (bans[ALT]["banned_at_ms"], bans[ALT]["expires_at_ms"]) == (now, now + 24 * HOUR) + assert bans[ALT]["minecraft_name"] == "ShayminPlays" + # Only the owner's ban is sent to Discord. + assert [(n["type"], n["player_uuid"]) for n in list_undelivered_moderation()] == [("ban", OWNER)] + # A server reporting the alt's ban back changes nothing. + assert enqueue_ban_event("ban", player_uuid=ALT, discord_user_id=discord_id, minecraft_name="ShayminPlays", + banned_at_ms=now, expires_at_ms=now + 24 * HOUR)["duplicate"] is True + + enqueue_ban_event("unban", player_uuid=OWNER, discord_user_id=discord_id, minecraft_name="Wren", + banned_at_ms=now, expires_at_ms=now + 24 * HOUR) + sync = list_ban_sync() + assert sync["bans"] == [] and {u["player_uuid"] for u in sync["unbans"]} == {OWNER, ALT} + + +def test_alt_ban_gets_times_from_a_later_report(app): + discord_id, c = wren(app) + assert add_alt(c).status_code == 200 + now = int(time.time() * 1000) + # A server that sends no times first: nothing can sync yet. + enqueue_ban_event("ban", player_uuid=OWNER, minecraft_name="Wren", reason="Abuse") + assert list_ban_sync()["bans"] == [] + enqueue_ban_event("ban", player_uuid=OWNER, minecraft_name="Wren", reason="Abuse", + banned_at_ms=now, expires_at_ms=0) + assert {b["player_uuid"] for b in list_ban_sync()["bans"]} == {OWNER, ALT} + + +def test_unbanning_an_owner_leaves_unbanned_alts_alone(app): + discord_id, c = wren(app) + assert add_alt(c).status_code == 200 + enqueue_ban_event("unban", player_uuid=OWNER, minecraft_name="Wren") + assert testing.value("SELECT count(*) FROM player_bans WHERE player_uuid = %s", (ALT,)) == 0 + + +def test_admins_cannot_remove_the_alts_of_staff_who_outrank_them(app): + discord_id, c = wren(app, "root") + assert add_alt(c).status_code == 200 + _, admin_token = account("ryan", "admin") + res = client(app, admin_token).post(f"/admin/players/{ALT}/staff-alt/unlink", headers=ORIGIN) + assert (res.status_code, res.json()["detail"]) == (403, "target_outranks_you") + row = testing.audit_rows()[-1] + assert (row["action"], row["outcome"], row["detail"]["error"]) == ( + "account.staff_alt.remove", "denied", "target_outranks_you") + assert testing.value("SELECT count(*) FROM staff_alt_links") == 1 + + +def test_former_staff_can_still_remove_their_alts(app): + discord_id, c = wren(app) + assert add_alt(c).status_code == 200 + testing.set_role(discord_id, "player") + res = c.post(f"/account/staff-alts/{ALT}/unlink", headers=ORIGIN) + assert res.status_code == 200 and res.json()["staff_alts"] is None + + +def test_a_view_of_the_site_cannot_change_staff_alts(app): + discord_id, c = wren(app) + assert add_alt(c, OTHER, "Other").status_code == 200 + viewer = testing.value("SELECT id FROM users WHERE discord_user_id = %s", (discord_id,)) + testing.execute("UPDATE user_sessions SET acting_user_id = %s", (viewer,)) + for path, body in (("/account/staff-alts/preview", {"code": code()}), ("/account/staff-alts/link", {"code": code()}), + (f"/account/staff-alts/{OTHER}/unlink", None)): + res = c.post(path, json=body, headers=ORIGIN) + assert (res.status_code, res.json()["detail"]) == (403, "viewing_as_player") + assert testing.value("SELECT count(*) FROM staff_alt_links") == 1 + + +def test_uuid_case_does_not_matter(app): + discord_id, c = wren(app) + res = c.post("/account/staff-alts/link", json={"code": raw_code(ALT.upper())}, headers=ORIGIN) + assert res.status_code == 200 and res.json()["staff_alts"]["alts"][0]["player_uuid"] == ALT + assert discord_link.get_identity_status(ALT.upper())["discord_user_id"] == discord_id + with pytest.raises(LinkError, match=STAFF_ALT_TAKEN): + discord_link.complete_link(raw_code(ALT.upper()), "555555555555555555") + + +def test_expired_and_used_codes_are_refused(app): + _, c = wren(app) + plain = code() + testing.execute("UPDATE discord_link_codes SET expires_at = now() - interval '1 minute'") + for path in ("/account/staff-alts/preview", "/account/staff-alts/link"): + res = c.post(path, json={"code": plain}, headers=ORIGIN) + assert (res.status_code, res.json()["detail"]) == (400, "Link code has expired") + assert add_alt(c, OTHER, "Other").status_code == 200 + used = testing.value("SELECT count(*) FROM discord_link_codes WHERE used_at IS NOT NULL") + assert used == 1 + + +def test_an_alt_follows_its_owners_guild_grace(app): + discord_id, c = wren(app) + assert add_alt(c).status_code == 200 + discord_link.record_guild_left(discord_id) + assert discord_link.get_identity_status(ALT)["in_grace"] is True + testing.execute("UPDATE discord_links SET grace_until = now() - interval '1 minute'") + assert discord_link.get_identity_status(ALT)["linked"] is False + + +def test_an_inactive_alt_still_answers_already_linked(app): + discord_id, c = wren(app) + assert add_alt(c).status_code == 200 + testing.set_role(discord_id, "player") + assert discord_link.start_link(ALT, "ShayminPlays")["already_linked"] is True + with pytest.raises(LinkError, match=STAFF_ALT_TAKEN): + discord_link.preview_link(raw_code()) + + +def test_staff_player_page_shows_an_alt_never_seen_in_game(app, coreprotect): + discord_id, c = wren(app) + assert add_alt(c).status_code == 200 + config = CoreProtectConfig.from_env() + alt = players.profile(config, ALT) + assert alt["staff_alt_of"]["player_uuid"] == OWNER and alt["staff_alts"] == [] + assert alt["minecraft_name"] == "ShayminPlays" + owner = players.profile(config, OWNER) + assert owner["staff_alt_of"] is None and [a["minecraft_name"] for a in owner["staff_alts"]] == ["ShayminPlays"] + + +def _ban(uuid, *, start, hours=None, reason="Abuse", discord_id=None, name="Wren"): + return enqueue_ban_event("ban", player_uuid=uuid, discord_user_id=discord_id, minecraft_name=name, reason=reason, + staff_name="Ryan", banned_at_ms=start, expires_at_ms=start + hours * HOUR if hours else 0) + + +def test_an_alts_own_ban_is_kept_through_the_owners_ban_and_unban(app): + _, c = wren(app) + assert add_alt(c).status_code == 200 + now = int(time.time() * 1000) + _ban(ALT, start=now - HOUR, reason="Own ban", name="ShayminPlays") + _ban(OWNER, start=now, hours=1) + own = {b["player_uuid"]: b for b in list_ban_sync()["bans"]}[ALT] + assert (own["reason"], own["expires_at_ms"]) == ("Own ban", 0) + enqueue_ban_event("unban", player_uuid=OWNER, minecraft_name="Wren", banned_at_ms=now, expires_at_ms=now + HOUR) + assert {b["player_uuid"] for b in list_ban_sync()["bans"]} == {ALT} + + +def test_an_alt_unbanned_by_staff_is_not_banned_again_by_a_repeat_report(app): + _, c = wren(app) + assert add_alt(c).status_code == 200 + now = int(time.time() * 1000) + _ban(OWNER, start=now, hours=24) + enqueue_ban_event("unban", player_uuid=ALT, minecraft_name="ShayminPlays") + _ban(OWNER, start=now, hours=24) # another server reports the same ban + assert {b["player_uuid"] for b in list_ban_sync()["bans"]} == {OWNER} + # A new ban of the owner is copied again. + _ban(OWNER, start=now + HOUR, hours=48) + assert {b["player_uuid"] for b in list_ban_sync()["bans"]} == {OWNER, ALT} + + +def test_the_owners_unban_lifts_copies_after_the_alt_link_is_gone(app): + _, c = wren(app) + assert add_alt(c).status_code == 200 + now = int(time.time() * 1000) + _ban(OWNER, start=now, hours=24) + assert c.post(f"/account/staff-alts/{ALT}/unlink", headers=ORIGIN).status_code == 200 + enqueue_ban_event("unban", player_uuid=OWNER, minecraft_name="Wren", banned_at_ms=now, expires_at_ms=now + 24 * HOUR) + assert list_ban_sync()["bans"] == [] + + +def test_an_alts_ban_sends_nothing_to_its_owners_discord(app): + discord_id, c = wren(app) + assert add_alt(c).status_code == 200 + result = _ban(ALT, start=int(time.time() * 1000), discord_id=discord_id, name="ShayminPlays") + assert result["mirrored"] is False and list_undelivered_moderation() == [] + + +def test_an_owner_unbanned_and_banned_again_bans_the_alt_again(app): + _, c = wren(app) + assert add_alt(c).status_code == 200 + now = int(time.time() * 1000) + _ban(OWNER, start=now - 60_000, hours=1) + enqueue_ban_event("unban", player_uuid=OWNER, minecraft_name="Wren", banned_at_ms=now - 60_000, + expires_at_ms=now - 60_000 + HOUR) + # The new ban was made before the unban was reported, as when a server's report is late. + _ban(OWNER, start=now - 30_000, hours=24) + assert {b["player_uuid"] for b in list_ban_sync()["bans"]} == {OWNER, ALT} + + +def test_an_alts_own_shorter_ban_survives_the_owners_ban_and_unban(app): + _, c = wren(app) + assert add_alt(c).status_code == 200 + now = int(time.time() * 1000) + _ban(ALT, start=now - HOUR, hours=24, reason="Own ban", name="ShayminPlays") + _ban(OWNER, start=now, hours=None) + enqueue_ban_event("unban", player_uuid=OWNER, minecraft_name="Wren", banned_at_ms=now, expires_at_ms=0) + alt = {b["player_uuid"]: b for b in list_ban_sync()["bans"]}[ALT] + assert (alt["reason"], alt["expires_at_ms"]) == ("Own ban", now - HOUR + 24 * HOUR) + + +def test_an_alt_unbanned_before_a_late_owner_report_gets_the_copy(app): + _, c = wren(app) + assert add_alt(c).status_code == 200 + now = int(time.time() * 1000) + _ban(ALT, start=now - 2 * HOUR, hours=24, reason="Own ban", name="ShayminPlays") + enqueue_ban_event("unban", player_uuid=ALT, minecraft_name="ShayminPlays") + # A clear gap on the site's clock, which a coarse timer could otherwise tie. + testing.execute("UPDATE player_bans SET updated_at = updated_at - interval '1 minute' WHERE player_uuid = %s", (ALT,)) + # The owner's ban was made on a server before that unban, but reaches the site after it. + _ban(OWNER, start=now - HOUR, hours=24) + assert {b["player_uuid"] for b in list_ban_sync()["bans"]} == {OWNER, ALT} + + +def test_a_server_echo_inside_the_same_ban_window_keeps_the_copy(app): + _, c = wren(app) + assert add_alt(c).status_code == 200 + now = int(time.time() * 1000) + _ban(OWNER, start=now, hours=24) + _ban(ALT, start=now + 1000, hours=24, name="ShayminPlays", reason="Staff alt of Wren: Abuse") + enqueue_ban_event("unban", player_uuid=OWNER, minecraft_name="Wren", banned_at_ms=now, expires_at_ms=now + 24 * HOUR) + assert list_ban_sync()["bans"] == [] + + +def test_the_admin_removal_refuses_other_sites_and_views(app): + discord_id, c = wren(app) + assert add_alt(c).status_code == 200 + admin_id, admin_token = account("ryan", "admin") + admin = client(app, admin_token) + res = admin.post(f"/admin/players/{ALT}/staff-alt/unlink") + assert res.status_code == 403 + row = testing.audit_rows()[-1] + assert (row["action"], row["outcome"]) == ("account.staff_alt.remove", "denied") + viewer = testing.value("SELECT id FROM users WHERE discord_user_id = %s", (admin_id,)) + testing.execute("UPDATE user_sessions SET acting_user_id = %s WHERE user_id = %s", (viewer, viewer)) + res = admin.post(f"/admin/players/{ALT}/staff-alt/unlink", headers=ORIGIN) + assert (res.status_code, res.json()["detail"]) == (403, "viewing_as_player") + assert testing.value("SELECT count(*) FROM staff_alt_links") == 1 + + +@pytest.mark.parametrize("reported", [False, True]) +def test_an_alt_whose_own_ban_ran_out_gets_the_copy_on_a_repeat(app, reported): + _, c = wren(app) + assert add_alt(c).status_code == 200 + now = int(time.time() * 1000) + _ban(ALT, start=now - 2 * HOUR, hours=3, reason="Own ban", name="ShayminPlays") + _ban(OWNER, start=now - 30 * 60_000, hours=None) + assert {b["player_uuid"]: b for b in list_ban_sync()["bans"]}[ALT]["reason"] == "Own ban" + # The alt's own ban runs out while the owner's holds. + testing.execute("UPDATE player_bans SET expires_at = now() - interval '1 minute' WHERE player_uuid = %s", + (ALT,)) + if reported: # a server reports the lapse as an unban + lapsed_at = int(testing.value("SELECT extract(epoch FROM expires_at) * 1000 FROM player_bans " + "WHERE player_uuid = %s", (ALT,))) + enqueue_ban_event("unban", player_uuid=ALT, minecraft_name="ShayminPlays", + banned_at_ms=now - 2 * HOUR, expires_at_ms=lapsed_at) + _ban(OWNER, start=now - 30 * 60_000, hours=None) # another server repeats the owner's ban + alt = {b["player_uuid"]: b for b in list_ban_sync()["bans"]}[ALT] + assert (alt["reason"], alt["expires_at_ms"]) == ("Staff alt of Wren: Abuse", 0) + + +def test_an_own_ban_over_a_copy_survives_the_owners_unban(app): + _, c = wren(app) + assert add_alt(c).status_code == 200 + now = int(time.time() * 1000) + _ban(OWNER, start=now, hours=None) + # Staff permanently ban the alt itself; same (permanent) times as the copy. + _ban(ALT, start=now + 60_000, hours=None, reason="Own offence", name="ShayminPlays") + enqueue_ban_event("unban", player_uuid=OWNER, minecraft_name="Wren", banned_at_ms=now, expires_at_ms=0) + alt = {b["player_uuid"]: b for b in list_ban_sync()["bans"]}[ALT] + assert alt["reason"] == "Own offence" + + +@pytest.mark.parametrize("reason", [None, "Staff alt of Wren: Abu"]) +def test_an_echo_of_a_copy_is_still_lifted_by_the_owners_unban(app, reason): + _, c = wren(app) + assert add_alt(c).status_code == 200 + now = int(time.time() * 1000) + _ban(OWNER, start=now, hours=None) + enqueue_ban_event("ban", player_uuid=ALT, minecraft_name="ShayminPlays", reason=reason, + banned_at_ms=now, expires_at_ms=0) + enqueue_ban_event("unban", player_uuid=OWNER, minecraft_name="Wren", banned_at_ms=now, expires_at_ms=0) + assert list_ban_sync()["bans"] == [] + + +def test_an_own_ban_over_a_tempban_copy_survives_the_owners_unban(app): + _, c = wren(app) + assert add_alt(c).status_code == 200 + now = int(time.time() * 1000) + _ban(OWNER, start=now, hours=24) + _ban(ALT, start=now, hours=24, reason="Own offence", name="ShayminPlays") + enqueue_ban_event("unban", player_uuid=OWNER, minecraft_name="Wren", banned_at_ms=now, + expires_at_ms=now + 24 * HOUR) + assert {b["player_uuid"]: b for b in list_ban_sync()["bans"]}[ALT]["reason"] == "Own offence" + + +def test_an_early_pardon_of_an_alts_own_ban_blocks_a_repeat_copy(app): + _, c = wren(app) + assert add_alt(c).status_code == 200 + now = int(time.time() * 1000) + _ban(ALT, start=now - HOUR, hours=24, reason="Own ban", name="ShayminPlays") + _ban(OWNER, start=now - 30 * 60_000, hours=None) + enqueue_ban_event("unban", player_uuid=ALT, minecraft_name="ShayminPlays", + banned_at_ms=now - HOUR, expires_at_ms=now + 23 * HOUR) + _ban(OWNER, start=now - 30 * 60_000, hours=None) # a repeat of the owner's ban + assert {b["player_uuid"] for b in list_ban_sync()["bans"]} == {OWNER} + + +def test_a_long_owner_reason_is_cut_without_a_trailing_space(app): + _, c = wren(app) + assert add_alt(c).status_code == 200 + now = int(time.time() * 1000) + prefix = "Staff alt of Wren: " + # The 500-character cut lands on the space before "tail". + pad = "x" * (500 - len(prefix) - 1) + _ban(OWNER, start=now, hours=None, reason=pad + " tail words") + alt = {b["player_uuid"]: b for b in list_ban_sync()["bans"]}[ALT] + assert alt["reason"] == prefix + pad + diff --git a/backend/src/database/migrations/0010_staff_alt_links.sql b/backend/src/database/migrations/0010_staff_alt_links.sql new file mode 100644 index 00000000..dc1a40ed --- /dev/null +++ b/backend/src/database/migrations/0010_staff_alt_links.sql @@ -0,0 +1,19 @@ +-- Staff alt links: more Minecraft accounts a staff member plays on, tied to their own +-- Discord link. discord_links stays one Discord account to one Minecraft account; an alt +-- counts as linked only while its owner's link stands and the owner is still staff. +-- See src/skins/staff_alts.py and docs/docs/identity/tfmcweb.md#staff-alts. +-- No foreign key to the owner: the row outlives the owner's link and staff role on purpose. +CREATE TABLE staff_alt_links ( + -- Lowercase, as Minecraft writes UUIDs. + player_uuid text COLLATE "C" PRIMARY KEY CHECK (player_uuid = lower(player_uuid)), + discord_user_id text COLLATE "C" NOT NULL, + minecraft_name text COLLATE "C", + linked_at timestamptz NOT NULL +); + +CREATE INDEX staff_alt_links_discord_idx ON staff_alt_links (discord_user_id); + +-- Set on an alt's ban when it is a copy of its owner's ban (the owner's lowercase UUID), so +-- the owner's unban lifts only that copy, never a ban staff gave the alt itself. +ALTER TABLE player_bans ADD COLUMN copied_from text COLLATE "C"; +CREATE INDEX player_bans_copied_from_idx ON player_bans (copied_from) WHERE copied_from IS NOT NULL; diff --git a/backend/src/skins/discord_link.py b/backend/src/skins/discord_link.py index 0b0c31b4..fc9a03a4 100644 --- a/backend/src/skins/discord_link.py +++ b/backend/src/skins/discord_link.py @@ -10,6 +10,7 @@ from .codes import api_time, generate_plaintext_code, hash_secret from .plugin_notices import enqueue_link_success, enqueue_plugin_notice from src import database +from src.auth.roles import ROLES, is_staff from src.coreprotect.reader import CoreProtectConfig, Reader, Unavailable from src.text_validation import TextValidationError, assert_optional_display_name @@ -18,6 +19,37 @@ class LinkError(ValueError): """Invalid, expired, used, or conflicting Discord link.""" +# Site roles that keep a staff alt linked (src/skins/staff_alts.py). +STAFF_ROLES = [role for role in ROLES if is_staff(role)] +STAFF_ALT_TAKEN = "This Minecraft account is a staff alt of a Discord account" + +# A staff alt with its owner's Discord link, while the owner is still on staff. +_ACTIVE_ALT = """ + SELECT a.player_uuid AS alt_uuid, a.minecraft_name AS alt_name, a.linked_at AS alt_linked_at, dl.* + FROM staff_alt_links a + JOIN discord_links dl ON dl.discord_user_id = a.discord_user_id + JOIN users u ON u.discord_user_id = a.discord_user_id AND NOT u.placeholder + WHERE a.player_uuid = lower(%s) AND u.role = ANY(%s) +""" + + +def _active_alt(conn, player_uuid: str): + return conn.execute(_ACTIVE_ALT, (player_uuid, STAFF_ROLES)).fetchone() + + +def _alt_status(row, *, now: datetime | None = None) -> dict: + """The owner's link status, told for the alt: its own UUID, name and link time.""" + status = _status_from_row(row, now=now) + status.update( + player_uuid=str(row["alt_uuid"]), + minecraft_name=row["alt_name"], + linked_at=api_time(row["alt_linked_at"]), + link_method="staff_alt", + staff_alt_of={"player_uuid": str(row["player_uuid"]), "minecraft_name": row["minecraft_name"]}, + ) + return status + + _MC_NAME_MAX = 16 _DISCORD_USERNAME_MAX = 32 _USERNAME_UPDATES_MAX = 500 @@ -119,6 +151,14 @@ def start_link( """, (uuid,), ).fetchone() + if existing is None: + # A staff alt is linked through its owner; the owner removes it on the website. + existing = conn.execute( + "SELECT dl.discord_username FROM staff_alt_links a " + "LEFT JOIN discord_links dl ON dl.discord_user_id = a.discord_user_id " + "WHERE a.player_uuid = lower(%s)", + (uuid,), + ).fetchone() if existing is not None: username = existing["discord_username"] if username is not None: @@ -294,6 +334,46 @@ def _refuse_code(row, now: datetime) -> None: raise LinkError("Link code has expired") +def _is_staff_alt(conn, player_uuid: str) -> bool: + return conn.execute( + "SELECT 1 FROM staff_alt_links WHERE player_uuid = lower(%s)", (player_uuid,) + ).fetchone() is not None + + +def _code_candidate(code_hash: str) -> str: + """The player a code names, read before taking that player's link lock.""" + with database.connect() as conn: + candidate = conn.execute("SELECT player_uuid FROM discord_link_codes WHERE code_hash = %s", + (code_hash,)).fetchone() + if candidate is None: + raise LinkError("Invalid link code") + return candidate["player_uuid"] + + +def _spend_code(conn, code_hash: str): + """Spend a code inside the caller's link write; return its row, or raise why it can't be used. + + The update that checks the code spends it, so of two redemptions only one gets it. + The caller rechecks expires_at before committing, after any later lock waits. + """ + conn.execute("SELECT id FROM discord_link_codes WHERE code_hash = %s FOR UPDATE", (code_hash,)).fetchone() + now = _utcnow() + row = conn.execute( + """ + UPDATE discord_link_codes SET used_at = %s + WHERE code_hash = %s AND used_at IS NULL AND expires_at >= %s + RETURNING player_uuid, minecraft_name, expires_at + """, + (now, code_hash, now), + ).fetchone() + if row is None: + _refuse_code(conn.execute( + "SELECT used_at, expires_at FROM discord_link_codes WHERE code_hash = %s", (code_hash,) + ).fetchone(), now) + raise LinkError("Link code has already been used") + return row + + def preview_link(code: str) -> dict: """Name the Minecraft account a code would link, without using the code.""" with database.connect() as conn: @@ -306,6 +386,8 @@ def preview_link(code: str) -> dict: "SELECT 1 FROM discord_links WHERE player_uuid = %s", (row["player_uuid"],), ).fetchone() + if linked is None and _is_staff_alt(conn, row["player_uuid"]): + raise LinkError(STAFF_ALT_TAKEN) if linked is not None: raise LinkError("This Minecraft account is already linked to a Discord account") return { @@ -337,29 +419,11 @@ def complete_link( raise LinkError("discord_user_id is required") username = _sanitize_discord_username(discord_username) - with database.connect() as conn: - candidate = conn.execute("SELECT player_uuid FROM discord_link_codes WHERE code_hash = %s", - (code_hash,)).fetchone() - if candidate is None: - raise LinkError("Invalid link code") + candidate = _code_candidate(code_hash) - with _link_write(discord_id, candidate["player_uuid"]) as conn: - conn.execute("SELECT id FROM discord_link_codes WHERE code_hash = %s FOR UPDATE", (code_hash,)).fetchone() - now = _utcnow() - row = conn.execute( - """ - UPDATE discord_link_codes SET used_at = %s - WHERE code_hash = %s AND used_at IS NULL AND expires_at >= %s - RETURNING player_uuid, minecraft_name, expires_at - """, - (now, code_hash, now), - ).fetchone() - if row is None: - _refuse_code(conn.execute( - "SELECT used_at, expires_at FROM discord_link_codes WHERE code_hash = %s", (code_hash,) - ).fetchone(), now) - raise LinkError("Link code has already been used") - result = _bind(conn, row["player_uuid"], row["minecraft_name"], discord_id, username, now, "code") + with _link_write(discord_id, candidate) as conn: + row = _spend_code(conn, code_hash) + result = _bind(conn, row["player_uuid"], row["minecraft_name"], discord_id, username, _utcnow(), "code") # Binding may wait on a link or sibling code after the original code lock. # Keep all spends and the outbox insert in the rollback on expiry. if row["expires_at"] < _utcnow(): @@ -407,7 +471,11 @@ def _bind(conn, player_uuid: str, minecraft_name: str | None, discord_id: str, u The insert does nothing when either side is already linked, waiting first for a link of either side that is being committed; the existing row then says why. + A staff alt is never linked as anyone's own account: its owner removes it first. + The caller holds this player's link lock, which a staff alt link also takes. """ + if _is_staff_alt(conn, player_uuid): + raise LinkError(STAFF_ALT_TAKEN) for _attempt in range(2): inserted = conn.execute( """ @@ -491,7 +559,10 @@ def get_discord_id_for_uuid(player_uuid: str) -> str | None: def get_link_for_uuid(player_uuid: str) -> dict | None: - """Active Discord link row for UUID (includes grace fields).""" + """Active Discord link row for UUID (includes grace fields). + + An active staff alt answers with its owner's Discord link under its own UUID and name. + """ uuid = (player_uuid or "").strip() if not uuid: return None @@ -505,6 +576,15 @@ def get_link_for_uuid(player_uuid: str) -> dict | None: """, (uuid,), ).fetchone() + alt = _active_alt(conn, uuid) if row is None else None + if alt is not None: + return { + **_row_to_link(alt), + "player_uuid": str(alt["alt_uuid"]), + "minecraft_name": alt["alt_name"], + "linked_at": api_time(alt["alt_linked_at"]), + "link_method": "staff_alt", + } if row is None: return None return _row_to_link(row) @@ -676,7 +756,10 @@ def expire_due_graces() -> int: def get_identity_status(player_uuid: str) -> dict: - """Plugin-facing status; expires due graces first.""" + """Plugin-facing status; expires due graces first. + + An active staff alt is linked through its owner (src/skins/staff_alts.py). + """ uuid = (player_uuid or "").strip() if not uuid: raise LinkError("player_uuid is required") @@ -686,6 +769,9 @@ def get_identity_status(player_uuid: str) -> dict: "SELECT * FROM discord_links WHERE player_uuid = %s", (uuid,), ).fetchone() + alt = _active_alt(conn, uuid) if row is None else None + if alt is not None: + return _alt_status(alt) if row is None: return _unlinked_status(uuid) return _status_from_row(row) diff --git a/backend/src/skins/moderation.py b/backend/src/skins/moderation.py index c28513db..04f16b73 100644 --- a/backend/src/skins/moderation.py +++ b/backend/src/skins/moderation.py @@ -138,6 +138,8 @@ def record_warning( # Two reports of one ban: Main and Lobby each ban the player, or a server # reports a ban again. Their end times differ by the seconds between them. _SAME_BAN_WINDOW = timedelta(minutes=10) +# How a staff alt's copy of its owner's ban begins (_copy_to_staff_alts). +_COPY_REASON = "Staff alt of " # How long an unban stays in the list the servers sync from. _UNBAN_SYNC_DAYS = 30 @@ -190,19 +192,32 @@ def _record_ban( staff_name: str | None, banned_at: datetime | None, expires_at: datetime | None, + copied_from: str | None = None, ) -> bool: """Store a ban or unban for the player. False when it repeats what is stored. For an unban, banned_at and expires_at describe the ban the server lifted. + copied_from marks a staff alt's copy of its owner's ban, and an unban that lifted + such a copy; any other ban or unban stored for the player clears it, and a repeat + keeps it. A report over a copy that gives another reason is the alt's own ban, not a + server repeating the copy, even when its times match: the owner's unban must not lift + it. A report with no reason, or one still worded as a copy, counts as a repeat. """ database.advisory_lock(conn, f"moderation:ban:{uuid.lower()}") row = conn.execute( - "SELECT banned, banned_at, expires_at, updated_at FROM player_bans WHERE player_uuid = %s", + "SELECT banned, banned_at, expires_at, updated_at, reason, copied_from FROM player_bans " + "WHERE player_uuid = %s", (uuid,), ).fetchone() now = _utcnow() if etype == "ban": - if row is not None and _same_ban(row, banned_at, expires_at, now): + own_over_copy = ( + row is not None and copied_from is None and row["copied_from"] is not None + and reason is not None and reason != row["reason"] + # A server echoing the copy may trim or cut its text; it still reads as a copy. + and not reason.startswith(_COPY_REASON) + ) + if row is not None and not own_over_copy and _same_ban(row, banned_at, expires_at, now): if row["banned_at"] is None and banned_at is not None: # A server without times reported it first; keep the times so it can sync. conn.execute( @@ -214,8 +229,8 @@ def _record_ban( """ INSERT INTO player_bans ( player_uuid, minecraft_name, banned, reason, staff_name, - banned_at, expires_at, updated_at - ) VALUES (%s, %s, true, %s, %s, %s, %s, %s) + banned_at, expires_at, updated_at, copied_from + ) VALUES (%s, %s, true, %s, %s, %s, %s, %s, %s) ON CONFLICT (player_uuid) DO UPDATE SET minecraft_name = COALESCE(EXCLUDED.minecraft_name, player_bans.minecraft_name), banned = true, @@ -223,9 +238,10 @@ def _record_ban( staff_name = EXCLUDED.staff_name, banned_at = EXCLUDED.banned_at, expires_at = EXCLUDED.expires_at, - updated_at = EXCLUDED.updated_at + updated_at = EXCLUDED.updated_at, + copied_from = EXCLUDED.copied_from """, - (uuid, minecraft_name, reason, staff_name, banned_at, expires_at, now), + (uuid, minecraft_name, reason, staff_name, banned_at, expires_at, now, copied_from), ) return True if row is not None and not row["banned"]: @@ -234,18 +250,99 @@ def _record_ban( return False conn.execute( """ - INSERT INTO player_bans (player_uuid, minecraft_name, banned, updated_at) - VALUES (%s, %s, false, %s) + INSERT INTO player_bans (player_uuid, minecraft_name, banned, updated_at, copied_from) + VALUES (%s, %s, false, %s, %s) ON CONFLICT (player_uuid) DO UPDATE SET minecraft_name = COALESCE(EXCLUDED.minecraft_name, player_bans.minecraft_name), banned = false, - updated_at = EXCLUDED.updated_at + updated_at = EXCLUDED.updated_at, + copied_from = EXCLUDED.copied_from """, - (uuid, minecraft_name, now), + (uuid, minecraft_name, now, copied_from), ) return True +def _copy_to_staff_alts(conn, owner_uuid: str) -> None: + """Give the owner's staff alts (src/skins/staff_alts.py) the owner's ban, or lift the copies. + + A copy is stored like any ban, with the owner's times and copied_from set, so ban sync + applies and lifts it on the servers; no Discord notice, the owner already gets one. + It never replaces a ban staff gave the alt itself while that ban holds (the owner's + unban would otherwise lift it), nor re-bans an alt staff pardoned after the site + stored the owner's ban (a copy lifted by the owner's own unban keeps copied_from, + so the owner's next ban is copied again). An alt whose own ban runs out while the + owner's holds gets the copy with the owner's next report, repeat or new. + The owner's unban lifts only the copies, and + finds them by copied_from, so it still works after the alt link or the owner's own + Discord link is gone. Alts are found through the owner's current Discord link, so an + owner who is unlinked when banned gives no copies (their alts don't count as linked). + """ + from .staff_alts import alts_of + + owner_key = owner_uuid.lower() + owner = conn.execute("SELECT * FROM player_bans WHERE player_uuid = %s", (owner_uuid,)).fetchone() + if owner is None: + return + now = _utcnow() + if not owner["banned"]: + copies = conn.execute( + "SELECT player_uuid, minecraft_name, banned_at, expires_at FROM player_bans " + "WHERE copied_from = %s AND banned ORDER BY player_uuid", + (owner_key,), + ).fetchall() + for copy in copies: + _record_ban(conn, "unban", copy["player_uuid"], minecraft_name=copy["minecraft_name"], reason=None, + staff_name=None, banned_at=copy["banned_at"], expires_at=copy["expires_at"], + copied_from=owner_key) + return + if owner["expires_at"] is not None and owner["expires_at"] <= now: + return + # The site's own clock on both sides: when it stored the owner's ban and the alt's unban. + since = owner["updated_at"] + link = conn.execute( + "SELECT discord_user_id FROM discord_links WHERE lower(player_uuid) = %s", (owner_key,) + ).fetchone() + if link is None: + return + # The lock a staff alt link takes first, so an alt added meanwhile is either seen here or + # sees the owner's ban and is refused. + database.advisory_lock(conn, f"discord_link:{link['discord_user_id']}") + for alt in alts_of(conn, owner_uuid): + if alt["player_uuid"] == owner_key: + continue + # Read under the lock _record_ban takes, so a pardon committing meanwhile is seen. + database.advisory_lock(conn, f"moderation:ban:{alt['player_uuid']}") + held = conn.execute( + "SELECT banned, expires_at, updated_at, copied_from FROM player_bans WHERE player_uuid = %s", + (alt["player_uuid"],), + ).fetchone() + if held is not None and held["copied_from"] != owner_key: + own_ban = held["banned"] and (held["expires_at"] is None or held["expires_at"] > now) + # An unban is a pardon unless it came as the ban ran out (servers report lapses as + # unbans); a lapsed own ban, reported or not, lets the owner's ban be copied. With + # the cross-server window as tolerance, a pardon in a ban's last 10 minutes reads as + # a lapse; a ban stored without times has no end, so its lapse reads as a pardon. + ends = held["expires_at"] + lapsed = ends is not None and ends <= held["updated_at"] + _SAME_BAN_WINDOW + pardoned_since = not held["banned"] and not lapsed and held["updated_at"] >= since + if own_ban or pardoned_since: + continue + name = alt["owner_name"] or owner["minecraft_name"] or "a banned player" + reason = f"{_COPY_REASON}{name}" + (f": {owner['reason']}" if owner["reason"] else "") + _record_ban( + conn, + "ban", + alt["player_uuid"], + minecraft_name=alt["minecraft_name"], + reason=reason[:_REASON_MAX].rstrip(), + staff_name=owner["staff_name"], + banned_at=owner["banned_at"], + expires_at=owner["expires_at"], + copied_from=owner_key, + ) + + def enqueue_ban_event( event: str, *, @@ -299,7 +396,12 @@ def enqueue_ban_event( raise ModerationError("ban times are out of range") from e with database.connect() as conn: - if uuid and not _record_ban( + if uuid and discord_id and conn.execute( + "SELECT 1 FROM staff_alt_links WHERE player_uuid = lower(%s)", (uuid,) + ).fetchone() is not None: + # A staff alt reports its owner's Discord account; its ban is no reason to message them. + discord_id = None + stored = bool(uuid) and _record_ban( conn, etype, uuid, @@ -308,7 +410,11 @@ def enqueue_ban_event( staff_name=staff, banned_at=banned_at, expires_at=expires_at, - ): + ) + if uuid: + # Also on a repeat: a later report may fill in the times the alts need to sync. + _copy_to_staff_alts(conn, uuid) + if uuid and not stored: return {"ok": True, "mirrored": False, "duplicate": True, "notification_id": None} if not discord_id: return {"ok": True, "mirrored": False, "notification_id": None} diff --git a/backend/src/skins/staff_alts.py b/backend/src/skins/staff_alts.py new file mode 100644 index 00000000..088041ed --- /dev/null +++ b/backend/src/skins/staff_alts.py @@ -0,0 +1,282 @@ +"""Staff alt links: more Minecraft accounts a staff member plays on, tied to their own Discord link. + +One Discord account still links one Minecraft account (discord_links). A staff member +(site role mod or above) whose own Minecraft account is linked may add up to MAX_ALTS +more by pasting a /linkdiscord code from each alt into their Account page; only someone +who can log into the alt can get that code. Linked alts can mint /token codes and open +Profile (wardrobe, skins, drinks) like the owner's own account. + +An alt counts as linked (src/skins/discord_link.py get_identity_status) only while its +owner's Discord link stands and the owner is still staff. The row stays when that stops, +so the alt works again if they return; the owner or an admin removes it for good. A ban +of the owner's account is copied to the alts (src/skins/moderation.py). +""" + +from __future__ import annotations + +from src import database +from src.auth import audit, roles + +from .codes import api_time +from .discord_link import ( + STAFF_ALT_TAKEN, + LinkError, + _code_candidate, + _code_hash, + _link_write, + _refuse_code, + _spend_code, + _utcnow, +) +from .plugin_notices import enqueue_link_success + +MAX_ALTS = 3 + + +class StaffAltRefused(LinkError): + """A refused removal: an HTTP status and a stable code for the staff tools.""" + + def __init__(self, status: int, code: str): + super().__init__(code) + self.status = status + self.code = code + + +def _owner(conn, discord_id: str): + """The Discord account's own link and site role, or None without a link.""" + return conn.execute( + """ + SELECT dl.player_uuid, dl.minecraft_name, dl.discord_username, u.role + FROM discord_links dl + LEFT JOIN users u ON u.discord_user_id = dl.discord_user_id AND NOT u.placeholder + WHERE dl.discord_user_id = %s + """, + (discord_id,), + ).fetchone() + + +def _banned(conn, player_uuid: str) -> bool: + row = conn.execute( + "SELECT banned, expires_at FROM player_bans WHERE lower(player_uuid) = lower(%s)", (player_uuid,) + ).fetchone() + return bool(row and row["banned"] and (row["expires_at"] is None or row["expires_at"] > _utcnow())) + + +def _check(conn, discord_id: str, alt_uuid: str): + """The owner's link when this Discord account may add the alt; raise why not.""" + owner = _owner(conn, discord_id) + if owner is None: + raise LinkError("Link your own Minecraft account before adding a staff alt") + if not roles.is_staff(owner["role"]): + raise LinkError("Only staff can link staff alts") + if str(owner["player_uuid"]).lower() == alt_uuid: + raise LinkError("That is your own linked Minecraft account") + if conn.execute( + "SELECT 1 FROM discord_links WHERE lower(player_uuid) = %s", (alt_uuid,) + ).fetchone() is not None: + raise LinkError("This Minecraft account is already linked to a Discord account") + existing = conn.execute( + "SELECT discord_user_id FROM staff_alt_links WHERE player_uuid = %s", (alt_uuid,) + ).fetchone() + if existing is not None: + if existing["discord_user_id"] == discord_id: + raise LinkError("This Minecraft account is already one of your staff alts") + raise LinkError("This Minecraft account is a staff alt of a Discord account") + count = conn.execute( + "SELECT count(*) AS n FROM staff_alt_links WHERE discord_user_id = %s", (discord_id,) + ).fetchone()["n"] + if count >= MAX_ALTS: + raise LinkError(f"You can link up to {MAX_ALTS} staff alts") + if _banned(conn, owner["player_uuid"]): + raise LinkError("Your linked Minecraft account is banned") + return owner + + +def preview(code: str, discord_user_id: str) -> dict: + """Name the account a code would add as a staff alt, without using the code.""" + discord_id = (discord_user_id or "").strip() + with database.connect() as conn: + row = conn.execute( + "SELECT * FROM discord_link_codes WHERE code_hash = %s", (_code_hash(code),) + ).fetchone() + _refuse_code(row, _utcnow()) + _check(conn, discord_id, str(row["player_uuid"]).lower()) + return { + "player_uuid": str(row["player_uuid"]).lower(), + "minecraft_name": row["minecraft_name"], + "expires_at": api_time(row["expires_at"]), + } + + +def link(code: str, actor: dict) -> dict: + """Add the code's account as a staff alt of the signed-in actor; audited. + + Spends the code like a Discord link does, under the same player lock, so a normal + link and an alt link of one account never both succeed. + """ + discord_id = str(actor.get("discord_user_id") or "").strip() + if not discord_id: + raise LinkError("discord_user_id is required") + code_hash = _code_hash(code) + candidate = _code_candidate(code_hash) + with _link_write(discord_id, candidate) as conn: + row = _spend_code(conn, code_hash) + alt_uuid = str(row["player_uuid"]).lower() + owner = _check(conn, discord_id, alt_uuid) + now = _utcnow() + conn.execute( + "INSERT INTO staff_alt_links (player_uuid, discord_user_id, minecraft_name, linked_at) " + "VALUES (%s, %s, %s, %s)", + (alt_uuid, discord_id, row["minecraft_name"], now), + ) + # Like a link: older codes for the alt are spent, and the alt hears it is linked. + conn.execute( + "UPDATE discord_link_codes SET used_at = %s WHERE lower(player_uuid) = %s AND used_at IS NULL", + (now, alt_uuid), + ) + enqueue_link_success(alt_uuid, discord_username=owner["discord_username"], conn=conn) + audit.record(conn, actor=actor, action="account.staff_alt.link", outcome="ok", + detail={"player_uuid": alt_uuid, "minecraft_name": row["minecraft_name"]}) + if row["expires_at"] < _utcnow(): + raise LinkError("Link code has expired") + return {"player_uuid": alt_uuid, "minecraft_name": row["minecraft_name"], "linked_at": api_time(now)} + + +def unlink(player_uuid: str, actor: dict, *, any_owner: bool = False) -> dict: + """Remove a staff alt: the actor's own, or with any_owner (admins) anyone's whose owner + they outrank, as for other staff actions on accounts; audited. + + Raises StaffAltRefused 404 staff_alt_not_found, or 403 target_outranks_you. + """ + uuid = (player_uuid or "").strip().lower() + with database.connect() as conn: + row = conn.execute( + "SELECT discord_user_id FROM staff_alt_links WHERE player_uuid = %s", (uuid,) + ).fetchone() + mine = row is not None and row["discord_user_id"] == actor.get("discord_user_id") + if row is None or not (mine or any_owner): + raise StaffAltRefused(404, "staff_alt_not_found") + owner_id = row["discord_user_id"] + # Read before the lock to know which owner's lock to take; the DELETE below rechecks + # the owner under it, so a row removed or moved meanwhile is refused, never misdeleted. + with _link_write(owner_id, uuid) as conn: + owner = conn.execute( + "SELECT id AS user_id, discord_user_id, discord_username, discord_global_name, role FROM users " + "WHERE discord_user_id = %s AND NOT placeholder", + (owner_id,), + ).fetchone() + if not mine and owner is not None and not roles.outranks(actor.get("role"), owner["role"]): + raise StaffAltRefused(403, "target_outranks_you") + removed = conn.execute( + "DELETE FROM staff_alt_links WHERE player_uuid = %s AND discord_user_id = %s " + "RETURNING minecraft_name", + (uuid, owner_id), + ).fetchone() + if removed is None: + raise StaffAltRefused(404, "staff_alt_not_found") + audit.record( + conn, actor=actor, + action="account.staff_alt.unlink" if mine else "account.staff_alt.remove", outcome="ok", + target=dict(owner) if owner is not None else {"discord_user_id": owner_id}, + detail={"player_uuid": uuid, "minecraft_name": removed["minecraft_name"]}, + ) + return {"ok": True, "player_uuid": uuid} + + +def _alt_json(row) -> dict: + return { + "player_uuid": row["player_uuid"], + "minecraft_name": row["minecraft_name"], + "linked_at": api_time(row["linked_at"]), + } + + +def account_view(user: dict) -> dict | None: + """What the Account page shows: staff, or anyone who still has alts, may see them. + + active says whether the alts count as linked now (own link and staff role); staff + says whether the site role is staff, so the page can say why they are paused. + """ + discord_id = user["discord_user_id"] + with database.connect() as conn: + alts = conn.execute( + "SELECT player_uuid, minecraft_name, linked_at FROM staff_alt_links " + "WHERE discord_user_id = %s ORDER BY linked_at, player_uuid", + (discord_id,), + ).fetchall() + linked = conn.execute( + "SELECT 1 FROM discord_links WHERE discord_user_id = %s", (discord_id,) + ).fetchone() is not None + staff = roles.is_staff(user.get("role")) + if not staff and not alts: + return None + return { + "alts": [_alt_json(row) for row in alts], + "max": MAX_ALTS, + "active": staff and linked, + "staff": staff, + } + + +def alt_name(player_uuid: str) -> str | None: + """The name a staff alt had when linked, for staff tools that know no other.""" + with database.connect() as conn: + row = conn.execute( + "SELECT minecraft_name FROM staff_alt_links WHERE player_uuid = lower(%s)", ((player_uuid or "").strip(),) + ).fetchone() + return row["minecraft_name"] if row else None + + +def owns(user: dict, player_uuid: str) -> bool: + """Whether the account is one of this user's staff alts.""" + with database.connect() as conn: + return conn.execute( + "SELECT 1 FROM staff_alt_links WHERE player_uuid = lower(%s) AND discord_user_id = %s", + ((player_uuid or "").strip(), user["discord_user_id"]), + ).fetchone() is not None + + +def for_player(player_uuid: str) -> dict: + """For staff tools: whose alt this account is, and the alts of this account's Discord link.""" + uuid = (player_uuid or "").strip().lower() + with database.connect() as conn: + owner = conn.execute( + """ + SELECT a.discord_user_id, a.linked_at, dl.player_uuid, dl.minecraft_name + FROM staff_alt_links a + LEFT JOIN discord_links dl ON dl.discord_user_id = a.discord_user_id + WHERE a.player_uuid = %s + """, + (uuid,), + ).fetchone() + alts = conn.execute( + """ + SELECT a.player_uuid, a.minecraft_name, a.linked_at + FROM discord_links dl JOIN staff_alt_links a ON a.discord_user_id = dl.discord_user_id + WHERE lower(dl.player_uuid) = %s + ORDER BY a.linked_at, a.player_uuid + """, + (uuid,), + ).fetchall() + return { + "staff_alt_of": None if owner is None else { + "discord_user_id": owner["discord_user_id"], + "player_uuid": owner["player_uuid"], + "minecraft_name": owner["minecraft_name"], + "linked_at": api_time(owner["linked_at"]), + }, + "staff_alts": [_alt_json(row) for row in alts], + } + + +def alts_of(conn, owner_uuid: str) -> list: + """The alts of the Discord account linked to this player, on the caller's connection.""" + return conn.execute( + """ + SELECT a.player_uuid, a.minecraft_name, dl.minecraft_name AS owner_name + FROM discord_links dl JOIN staff_alt_links a ON a.discord_user_id = dl.discord_user_id + WHERE lower(dl.player_uuid) = lower(%s) + ORDER BY a.player_uuid + """, + (owner_uuid,), + ).fetchall() diff --git a/docs/docs/identity/tfmcweb.md b/docs/docs/identity/tfmcweb.md index 41658dc1..9a51f541 100644 --- a/docs/docs/identity/tfmcweb.md +++ b/docs/docs/identity/tfmcweb.md @@ -19,7 +19,7 @@ Companion: [integrations/discord-bot.md](../integrations/discord-bot.md) · [int | Does **not** own | Pack writing (ArmourShop/DrinkBuilder), map regen (SimpleFactions), character data (RPCharacters), Essentials ban execution | | Discord required | Every player must link Discord **and** be in the TFMC guild to play Survival | | Staff / helpers | Discord gate applies **only in Survival** | -| Alts | **None** - one Discord ↔ one Minecraft UUID | +| Alts | One Discord ↔ one Minecraft UUID; staff may add up to 3 [staff alts](#staff-alts) | | Leave Discord | **1 hour grace** - rejoin within 1h stays linked; after grace → unlink + freeze | | Freeze | **RPCharacters** freeze reason `DISCORD_REQUIRED`; do not touch characters | | Bans | Essentials `/tempban` / `/ban`; TFMCWeb mirrors to Discord bot | @@ -85,6 +85,41 @@ Link tables live in the site PostgreSQL database (`discord_links`, `discord_link | `POST /skins/discord/link/unlink` | Plugin | Explicit unlink | | Guild leave/join | Staff (bot) | 1h grace on leave; clear on rejoin | +### Staff alts + +Staff (site role mod or above) whose own Minecraft account is linked can add up to three more +accounts on Profile → Accounts: log in to the alt, run `/linkdiscord`, paste the code under +**Staff alts**. Only someone who can log in to the alt gets that code. The alt is stored in +`staff_alt_links` (not `discord_links`, which stays one to one) and audited +(`account.staff_alt.link`, `.unlink` by the owner, `.remove` by an admin). + +- While the owner's Discord link stands and the owner is staff, `GET /skins/discord/status/{alt}` + answers linked with the owner's Discord account (`link_method: staff_alt`, `staff_alt_of`), so + the alt passes the Discord gate and can mint `/token` codes (Profile, Wardrobe, skins, drinks). + When either stops, the alt reads unlinked but keeps its row, and works again if they return. +- A staff alt can't be linked as anyone's own account, and `/linkdiscord` on it answers "already + linked" with the owner's handle, until the owner (Profile) or an admin who outranks the owner + (staff player page, `manage_staff_alts`) removes it. The plugin hears of a removal or a lapsed + owner when the alt next joins, as with any web unlink. +- A ban reported for the owner's account is copied to their alts (`player_bans.copied_from`), + with the owner's times and reason ("Staff alt of …"), so ban sync applies it on every syncing + server. A copy never replaces a ban staff gave the alt itself while it holds, nor re-bans an alt + staff pardoned after the site stored the owner's ban; an alt whose own ban runs out while the + owner's holds gets the copy with the owner's next report. A ban reported over a copy with + another reason is the alt's own ban and stays after the owner's unban; a report with no reason, + or still worded "Staff alt of …", is the copy repeated. The owner's unban lifts only the + copies, even after the alt link is gone. Bans reported for an alt never go to the owner's Discord; only the owner's own ban does. + Copies follow the owner's current Discord link, so an owner unlinked when banned gives none. + Warnings given to an alt do reach the owner's Discord, since it is the same person, and staff + case lookups for an active alt show the owner's Discord cases. + +| Route | Auth | Role | +|-------|------|------| +| `POST /account/staff-alts/preview` | Staff session | Name the account a code would add | +| `POST /account/staff-alts/link` | Staff session | Add the alt; spends the code | +| `POST /account/staff-alts/{uuid}/unlink` | Owner session | Remove one of your alts | +| `POST /admin/players/{uuid}/staff-alt/unlink` | Admin session | Remove anyone's alt link | + ## War declare codes One-time codes that gate an in-game war declaration or coalition offensive. Staff mint one in Discord (`tfmc_bot` `factions` cog, `/warcode`) and the leader types it in SimpleFactions. Rows live in `war_declaration_codes`, realm-scoped, hashed with **no plaintext column**: staff see the code once and a lost code is revoked and reminted. @@ -212,7 +247,8 @@ Non-primary servers therefore use their own key: list it in `PLUGIN_KEYS_SECONDA - Survival player without Discord link (or past leave grace) is frozen via RPCharacters; characters untouched. - Staff in non-Survival are not Discord-gated. - Leave Discord → 1h grace → rejoin OK; after 1h → freeze. -- No alts (one Discord ↔ one UUID). +- One Discord ↔ one UUID, apart from staff alts: staff can link up to 3, each only with a + `/linkdiscord` code from that alt, and an alt counts as linked only while its owner is linked staff. - `/token create skin`, `drink`, and `profile` work from TFMCWeb. - Shared skin↔drink mint cooldown enforced on TFMCWeb. - `/tempban` mirrors to Discord; `/warning` hits chat + Discord. diff --git a/frontend/app/components/account/LinkedAccounts.tsx b/frontend/app/components/account/LinkedAccounts.tsx index 0513c456..9f004f2e 100644 --- a/frontend/app/components/account/LinkedAccounts.tsx +++ b/frontend/app/components/account/LinkedAccounts.tsx @@ -12,9 +12,11 @@ import { startMicrosoftLink, unlinkAccountPatreon, unlinkMinecraft, + unlinkStaffAlt, AccountApiError, type Account, type AccountMinecraft, + type StaffAlts, } from "../../../lib/account/api"; import { endLinkedProfileSession } from "../../../lib/account/profileSession"; import type { PatreonStatus } from "../../../lib/profile/patreon"; @@ -324,10 +326,161 @@ export default function LinkedAccounts({ /> ) : null} + {account.staff_alts ? ( + + ) : null} ); } +/** Staff only: more Minecraft accounts that count as linked through this Discord account. */ +function StaffAltsCard({ + staffAlts, + linked, + discordName, + refresh, + onError, +}: { + staffAlts: StaffAlts; + /** Whether their own Minecraft account is linked. */ + linked: boolean; + discordName: string; + refresh: () => Promise; + onError: (message: string | null) => void; +}) { + const [busy, setBusy] = useState(false); + const [confirm, setConfirm] = useState(null); + const [showForm, setShowForm] = useState(false); + // Counts adds, so each add starts a new form; a finished one would reopen on "Linking…". + const [added, setAdded] = useState(0); + const { alts, max, active, staff } = staffAlts; + const canAdd = active && alts.length < max; + + async function onRemove(playerUuid: string) { + setBusy(true); + onError(null); + try { + try { + await unlinkStaffAlt(playerUuid); + } catch (err) { + // Already removed, in another tab or by an admin: show the list as it is now. + if (!(err instanceof AccountApiError && err.status === 404)) throw err; + } + setConfirm(null); + await refresh(); + } catch { + onError("We couldn’t remove that staff alt just now. Please try again."); + } finally { + setBusy(false); + } + } + + async function onAdded() { + setShowForm(false); + setAdded((n) => n + 1); + try { + await refresh(); + } catch { + onError("Your staff alt was added, but the page couldn’t reload. Refresh to see it."); + } + } + + return ( +
+

+ Staff alts +

+

+ Other Minecraft accounts you play on as staff, up to {max}. Each can use{" "} + /token codes, Profile and the Wardrobe like your own account. +

+ {!active ? ( +

+ {!staff + ? "Your staff alts are paused while you’re not on staff. You can still remove them." + : !linked + ? "Link your own Minecraft account above to use staff alts." + : "Your staff alts are paused. Reload the page to check again."} +

+ ) : null} + {alts.length || canAdd ? ( +
    + {alts.map((alt) => { + const name = alt.minecraft_name || "Minecraft account"; + const addedOn = formatDate(alt.linked_at); + return ( + } + action={ + confirm === alt.player_uuid ? null : ( + + ) + } + > +

    {name}

    + {addedOn ?

    Added {addedOn}

    : null} + {confirm === alt.player_uuid ? ( +
    + Remove {name} from your staff alts? + + +
    + ) : null} +
    + ); + })} + {canAdd ? ( + } + action={ + + } + below={} + belowOpen={showForm} + > +

    + {alts.length ? `${alts.length} of ${max} linked` : "No staff alts yet"} +

    +
    + ) : null} +
+ ) : null} +
+ ); +} + export function PlayerHead({ uuid, fallback, size = 72 }: { uuid: string; fallback: string | null; size?: number }) { const [failed, setFailed] = useState(false); const box = size === 72 ? "h-[72px] w-[72px]" : "h-8 w-8"; diff --git a/frontend/app/components/account/MinecraftLinkForm.tsx b/frontend/app/components/account/MinecraftLinkForm.tsx index b8dc1008..96aa65d5 100644 --- a/frontend/app/components/account/MinecraftLinkForm.tsx +++ b/frontend/app/components/account/MinecraftLinkForm.tsx @@ -5,8 +5,10 @@ import { discordSignInUrl, isNotGuildMember, linkMinecraft, + linkStaffAlt, needsGuildRecheck, previewMinecraftLink, + previewStaffAlt, AccountApiError, type MinecraftLinkPreview, } from "../../../lib/account/api"; @@ -16,8 +18,11 @@ const buttonClass = const quietButtonClass = "text-sm text-[var(--tfmc-stone)] underline-offset-2 hover:text-[var(--tfmc-cream)] hover:underline disabled:opacity-50"; -function errorText(err: unknown): string { +function errorText(err: unknown, staffAlt: boolean): string { if (isNotGuildMember(err)) return "Linking needs you to be in the TFMC Discord server."; + if (staffAlt && err instanceof AccountApiError && err.status === 403 && err.message === "forbidden") { + return "Only staff can add staff alts."; + } if (err instanceof AccountApiError && err.status === 429) return err.message; if (err instanceof AccountApiError && err.status === 400) return err.message; return "We couldn’t check that code just now. Please try again."; @@ -26,9 +31,11 @@ function errorText(err: unknown): string { type Props = { discordName: string; onLinked: () => void | Promise; + /** Add the account as one of your staff alts instead of linking it as your own. */ + staffAlt?: boolean; }; -export default function MinecraftLinkForm({ discordName, onLinked }: Props) { +export default function MinecraftLinkForm({ discordName, onLinked, staffAlt = false }: Props) { const [code, setCode] = useState(""); const [preview, setPreview] = useState(null); const [busy, setBusy] = useState(false); @@ -44,9 +51,9 @@ export default function MinecraftLinkForm({ discordName, onLinked }: Props) { } setBusy(true); try { - setPreview(await previewMinecraftLink(code.trim())); + setPreview(await (staffAlt ? previewStaffAlt : previewMinecraftLink)(code.trim())); } catch (err) { - setError(errorText(err)); + setError(errorText(err, staffAlt)); } finally { setBusy(false); } @@ -56,13 +63,13 @@ export default function MinecraftLinkForm({ discordName, onLinked }: Props) { setBusy(true); setError(null); try { - await linkMinecraft(code.trim()); + await (staffAlt ? linkStaffAlt : linkMinecraft)(code.trim()); await onLinked(); } catch (err) { if (needsGuildRecheck(err)) { setRecheck(true); } else { - setError(errorText(err)); + setError(errorText(err, staffAlt)); setPreview(null); } setBusy(false); @@ -84,14 +91,15 @@ export default function MinecraftLinkForm({ discordName, onLinked }: Props) { return (

+ {staffAlt ? "Add " : "Link "} {preview.minecraft_name ? ( <> - Link Minecraft account {preview.minecraft_name} + Minecraft account {preview.minecraft_name} ) : ( - "Link this Minecraft account" + "this Minecraft account" )}{" "} - to Discord {discordName}? + {staffAlt ? "as a staff alt of" : "to"} Discord {discordName}?

Only continue if you ran /linkdiscord yourself. Never enter a code someone else gave you. @@ -103,7 +111,7 @@ export default function MinecraftLinkForm({ discordName, onLinked }: Props) { ) : null}

+ {" · "} + + + ) : ( + <> + {" · "} + + + ) + ) : null} + + ) : ( + <> + Staff alts:{" "} + {alts.map((alt, i) => ( + + {i ? ", " : null} + + {alt.minecraft_name ?? alt.player_uuid} + + + ))} + + )} + {error ? ( + + {error} + + ) : null} +

+ ); +} + /** Opens the site as this player, view only; the bar at the bottom of each page switches back. */ function ViewAsButton({ uuid }: { uuid: string }) { const [busy, setBusy] = useState(false); diff --git a/frontend/app/components/profile/ProfilePanel.test.tsx b/frontend/app/components/profile/ProfilePanel.test.tsx index 461bc2d9..a57c284e 100644 --- a/frontend/app/components/profile/ProfilePanel.test.tsx +++ b/frontend/app/components/profile/ProfilePanel.test.tsx @@ -8,13 +8,17 @@ import { getAccount, getAccountOverview, linkMinecraft, + linkStaffAlt, previewMinecraftLink, + previewStaffAlt, signOut, startLinkedProfileSession, startMicrosoftLink, unlinkAccountPatreon, + unlinkStaffAlt, type Account, type AccountMinecraft, + type StaffAlts, } from "../../../lib/account/api"; import { ProfileApiError, getProfileDashboard, type ProfileDashboard } from "../../../lib/profile/api"; import { getSession, setSession } from "../../../lib/profile/session"; @@ -77,6 +81,9 @@ vi.mock("../../../lib/account/api", async (importOriginal) => ({ startLinkedProfileSession: vi.fn(), startMicrosoftLink: vi.fn(), unlinkAccountPatreon: vi.fn(), + previewStaffAlt: vi.fn(), + linkStaffAlt: vi.fn(), + unlinkStaffAlt: vi.fn(), })); vi.mock("../../../lib/profile/api", async (importOriginal) => ({ ...(await importOriginal()), @@ -484,6 +491,81 @@ it("leaves Patreon out when Patreon linking is off", async () => { expect(screen.queryByLabelText("Patreon")).toBeNull(); }); +function staffAlts(overrides: Partial = {}): StaffAlts { + return { alts: [], max: 3, active: true, staff: true, ...overrides }; +} + +const SHAYMIN = { player_uuid: "alt-uuid", minecraft_name: "ShayminPlays", linked_at: "2026-11-12T10:00:00Z" }; + +it("adds a staff alt with a code from the alt, naming both accounts", async () => { + vi.mocked(getAccount) + .mockResolvedValueOnce(account({ minecraft: linked(), staff_alts: staffAlts() })) + .mockResolvedValueOnce(account({ minecraft: linked(), staff_alts: staffAlts({ alts: [SHAYMIN] }) })); + vi.mocked(previewStaffAlt).mockResolvedValue({ + player_uuid: "alt-uuid", + minecraft_name: "ShayminPlays", + expires_at: "z", + }); + vi.mocked(linkStaffAlt).mockResolvedValue(staffAlts({ alts: [SHAYMIN] })); + visit("accounts"); + render(); + const list = await screen.findByRole("list", { name: "Staff alts" }); + expect(list.textContent).toContain("No staff alts yet"); + fireEvent.click(within(list).getByRole("button", { name: "Add a staff alt" })); + expect(within(list).getByText(/Log in to the alt, run/)).toBeTruthy(); + fireEvent.change(within(list).getByLabelText("Link code"), { target: { value: "abcd1234ef56" } }); + fireEvent.click(within(list).getByRole("button", { name: "Continue" })); + expect((await within(list).findByText(/as a staff alt of/)).textContent).toBe( + "Add Minecraft account ShayminPlays as a staff alt of Discord @steve_tfmc?", + ); + fireEvent.click(within(list).getByRole("button", { name: "Add staff alt" })); + const row = await screen.findByLabelText("Staff alt ShayminPlays"); + expect(row.textContent).toContain("ShayminPlays"); + expect(linkStaffAlt).toHaveBeenCalledWith("abcd1234ef56"); + expect(previewMinecraftLink).not.toHaveBeenCalled(); + // A fresh form for the next alt, not the finished one. + fireEvent.click( + within(screen.getByRole("list", { name: "Staff alts" })).getByRole("button", { name: "Add a staff alt" }), + ); + expect(screen.getByRole("button", { name: "Continue" })).toBeTruthy(); +}); + +it("removes a staff alt after confirming", async () => { + vi.mocked(getAccount) + .mockResolvedValueOnce(account({ minecraft: linked(), staff_alts: staffAlts({ alts: [SHAYMIN] }) })) + .mockResolvedValueOnce(account({ minecraft: linked(), staff_alts: staffAlts() })); + vi.mocked(unlinkStaffAlt).mockResolvedValue(staffAlts()); + visit("accounts"); + render(); + const row = await screen.findByLabelText("Staff alt ShayminPlays"); + fireEvent.click(within(row).getByRole("button", { name: "Remove" })); + expect(unlinkStaffAlt).not.toHaveBeenCalled(); + expect(row.textContent).toContain("Remove ShayminPlays from your staff alts?"); + fireEvent.click(within(row).getByRole("button", { name: "Remove" })); + expect(await screen.findByText("No staff alts yet")).toBeTruthy(); + expect(unlinkStaffAlt).toHaveBeenCalledWith("alt-uuid"); +}); + +it("explains paused staff alts and offers no new ones", async () => { + vi.mocked(getAccount).mockResolvedValue( + account({ minecraft: linked(), staff_alts: staffAlts({ alts: [SHAYMIN], active: false, staff: false }) }), + ); + visit("accounts"); + render(); + expect((await screen.findByRole("status")).textContent).toContain("paused while you’re not on staff"); + const list = screen.getByRole("list", { name: "Staff alts" }); + expect(within(list).queryByRole("button", { name: "Add a staff alt" })).toBeNull(); + expect(within(list).getByRole("button", { name: "Remove" })).toBeTruthy(); +}); + +it("shows players no staff alts", async () => { + vi.mocked(getAccount).mockResolvedValue(account({ minecraft: linked() })); + visit("accounts"); + render(); + await screen.findByLabelText("Minecraft account"); + expect(screen.queryByRole("heading", { name: "Staff alts" })).toBeNull(); +}); + it("invites a connected Patreon without a tier to support, and disconnects after confirming", async () => { vi.mocked(getAccount) .mockResolvedValueOnce(account({ patreon: { linked: true, patreon_name: "Wonder" } })) diff --git a/frontend/lib/account/api.ts b/frontend/lib/account/api.ts index 82ba97cd..30c06a4a 100644 --- a/frontend/lib/account/api.ts +++ b/frontend/lib/account/api.ts @@ -21,6 +21,22 @@ export type AccountMinecraft = { link_method?: "code" | "microsoft" | null; }; +/** A second Minecraft account a staff member plays on, linked through their own Discord link. */ +export type StaffAlt = { + player_uuid: string; + minecraft_name: string | null; + linked_at: string; +}; + +export type StaffAlts = { + alts: StaffAlt[]; + max: number; + /** False when the alts don’t count as linked: no own Minecraft link, or no longer staff. */ + active: boolean; + /** Whether the site role is staff, so a paused card can say why. */ + staff: boolean; +}; + export type AccountActivity = { first_seen: number | null; last_seen: number | null; @@ -53,6 +69,8 @@ export type Account = { /** True when "Connect with Microsoft" is set up on this site. */ microsoft_link?: boolean; patreon: PatreonStatus | null; + /** Staff only, or anyone who still has staff alts; null otherwise. */ + staff_alts?: StaffAlts | null; /** Set while an admin views the site as this player; everything is view only. */ viewing_as?: { staff_name: string | null; expires_at: string } | null; }; @@ -131,6 +149,29 @@ export async function linkMinecraft(code: string): Promise { + return accountRequest("/account/staff-alts/preview", { + method: "POST", + body: JSON.stringify({ code }), + }); +} + +export async function linkStaffAlt(code: string): Promise { + const data = await accountRequest<{ staff_alts: StaffAlts | null }>("/account/staff-alts/link", { + method: "POST", + body: JSON.stringify({ code }), + }); + return data.staff_alts; +} + +export async function unlinkStaffAlt(playerUuid: string): Promise { + const data = await accountRequest<{ staff_alts: StaffAlts | null }>( + `/account/staff-alts/${encodeURIComponent(playerUuid)}/unlink`, + { method: "POST" }, + ); + return data.staff_alts; +} + export function unlinkMinecraft(): Promise { return accountRequest("/account/minecraft/unlink", { method: "POST" }); } diff --git a/frontend/lib/admin/api.ts b/frontend/lib/admin/api.ts index 1d361db6..f0116831 100644 --- a/frontend/lib/admin/api.ts +++ b/frontend/lib/admin/api.ts @@ -75,6 +75,7 @@ const ERROR_MESSAGES: Record = { directory_busy: "The player list is busy. Try again in a moment.", report_not_found: "No report with that code in the last 30 days.", audit_unavailable: "This can’t be shown because the view couldn’t be logged. Try again.", + staff_alt_not_found: "That staff alt link has already been removed.", }; export function adminErrorMessage(err: unknown): string { @@ -228,9 +229,30 @@ export type PlayerProfile = { character_id: string; updated_at: string | null; })[]; + /** Set when this account is a staff member's alt, linked through their Discord link. */ + staff_alt_of?: StaffAltOwner | null; + /** The staff alts of this account's Discord link. */ + staff_alts?: StaffAltLink[]; coreprotect: CoreProtectStatus & { server_label: string | null; ping_seconds: number | null }; }; +export type StaffAltOwner = { + discord_user_id: string; + /** The owner's own linked account; null if they have since unlinked it. */ + player_uuid: string | null; + minecraft_name: string | null; + linked_at: string; +}; + +export type StaffAltLink = { player_uuid: string; minecraft_name: string | null; linked_at: string }; + +/** Remove this account's staff alt link, whoever owns it (admins). */ +export function unlinkStaffAltAsAdmin( + playerUuid: string, +): Promise<{ staff_alt_of: StaffAltOwner | null; staff_alts: StaffAltLink[] }> { + return adminRequest(`/admin/players/${encodeURIComponent(playerUuid)}/staff-alt/unlink`, { method: "POST" }); +} + export function playerCharacterPictureUrl( uuid: string, realm: string,