From 8bdfa27b6101315dbc9ebdb37cf4fb565569467b Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Thu, 12 Nov 2026 23:48:44 +0100 Subject: [PATCH 01/10] Staff alt links: staff add more Minecraft accounts with a /linkdiscord code Staff whose own Minecraft account is linked can add up to three alts on Profile > Accounts by pasting a /linkdiscord code from each alt. An alt counts as linked through its owner's Discord link while the owner is staff, so it passes the Discord gate and can mint /token codes for Profile and the Wardrobe. Owner bans are copied to the alts so ban sync applies them. Admins can remove any alt link from the player page. Co-Authored-By: Claude Opus 5.5 --- backend/src/api/admin_routes.py | 15 + backend/src/api/auth_routes.py | 69 +++- backend/src/auth/players.py | 3 + backend/src/auth/roles.py | 2 + backend/src/auth/test_staff_alts.py | 306 ++++++++++++++++++ .../migrations/0010_staff_alt_links.sql | 13 + backend/src/skins/discord_link.py | 134 ++++++-- backend/src/skins/moderation.py | 44 ++- backend/src/skins/staff_alts.py | 247 ++++++++++++++ docs/docs/identity/tfmcweb.md | 28 +- .../app/components/account/LinkedAccounts.tsx | 140 ++++++++ .../components/account/MinecraftLinkForm.tsx | 27 +- frontend/app/components/admin/PlayerFrame.tsx | 88 +++++ .../components/profile/ProfilePanel.test.tsx | 82 +++++ frontend/lib/account/api.ts | 39 +++ frontend/lib/admin/api.ts | 22 ++ 16 files changed, 1219 insertions(+), 40 deletions(-) create mode 100644 backend/src/auth/test_staff_alts.py create mode 100644 backend/src/database/migrations/0010_staff_alt_links.sql create mode 100644 backend/src/skins/staff_alts.py diff --git a/backend/src/api/admin_routes.py b/backend/src/api/admin_routes.py index 41b918fb..11cec578 100644 --- a/backend/src/api/admin_routes.py +++ b/backend/src/api/admin_routes.py @@ -28,6 +28,8 @@ staff_cookie, ) from src.auth import admin, audit, players, roles, users, view_as +from src.skins import staff_alts +from src.skins.discord_link import LinkError from src.auth import cases as person_cases from src.precedent.db import PrecedentDBError from src.coreprotect.reader import CoreProtectConfig @@ -381,6 +383,19 @@ 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(player_uuid: str, request: Request, response: Response): + """Remove this account's staff alt link, whoever owns it; audited.""" + require_same_origin(request, _config()) + actor = _capable(request, "manage_staff_alts") + _no_store(response) + try: + staff_alts.unlink(player_uuid, actor, any_owner=True) + except LinkError: + raise HTTPException(404, detail="staff_alt_not_found") from None + return staff_alts.for_player(player_uuid) + + # 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..d265ba3d 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,13 @@ 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.""" 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"}) @@ -692,6 +698,61 @@ 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.""" + 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 LinkError as e: + raise HTTPException(404, detail=str(e)) 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..51a93dff 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 @@ -410,6 +411,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). + **staff_alts.for_player(key), "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..21707ad3 --- /dev/null +++ b/backend/src/auth/test_staff_alts.py @@ -0,0 +1,306 @@ +"""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, testing, users +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["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_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 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["detail"]["player_uuid"]) == ("admin.staff_alt.unlink", "ryan", 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 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..696d8740 --- /dev/null +++ b/backend/src/database/migrations/0010_staff_alt_links.sql @@ -0,0 +1,13 @@ +-- 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. +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); 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..d8776c42 100644 --- a/backend/src/skins/moderation.py +++ b/backend/src/skins/moderation.py @@ -246,6 +246,42 @@ def _record_ban( 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 it with theirs. + + Stored like any ban, with the owner's times, so ban sync applies and lifts it on the + servers; no Discord notice, the owner already gets one. A stored repeat changes nothing. + """ + from .staff_alts import alts_of + + owner = conn.execute("SELECT * FROM player_bans WHERE player_uuid = %s", (owner_uuid,)).fetchone() + if owner is None: + return + now = _utcnow() + active = owner["banned"] and (owner["expires_at"] is None or owner["expires_at"] > now) + if owner["banned"] and not active: + return + for alt in alts_of(conn, owner_uuid): + if not active: + alt_ban = conn.execute( + "SELECT banned FROM player_bans WHERE player_uuid = %s", (alt["player_uuid"],) + ).fetchone() + if alt_ban is None or not alt_ban["banned"]: + continue + name = alt["owner_name"] or owner["minecraft_name"] or "a banned player" + reason = f"Staff alt of {name}" + (f": {owner['reason']}" if owner["reason"] else "") + _record_ban( + conn, + "ban" if active else "unban", + alt["player_uuid"], + minecraft_name=alt["minecraft_name"], + reason=reason[:_REASON_MAX], + staff_name=owner["staff_name"], + banned_at=owner["banned_at"], + expires_at=owner["expires_at"], + ) + + def enqueue_ban_event( event: str, *, @@ -299,7 +335,7 @@ 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( + stored = bool(uuid) and _record_ban( conn, etype, uuid, @@ -308,7 +344,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..1f345bd1 --- /dev/null +++ b/backend/src/skins/staff_alts.py @@ -0,0 +1,247 @@ +"""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 ( + LinkError, + _code_candidate, + _code_hash, + _link_write, + _refuse_code, + _spend_code, + _utcnow, +) +from .plugin_notices import enqueue_link_success + +MAX_ALTS = 3 + + +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 anyone's when any_owner (admins); audited.""" + 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() + if row is None or (not any_owner and row["discord_user_id"] != actor.get("discord_user_id")): + raise LinkError("No staff alt with that UUID") + owner_id = row["discord_user_id"] + with _link_write(owner_id, uuid) as conn: + 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 LinkError("No staff alt with that UUID") + audit.record( + conn, actor=actor, + action="admin.staff_alt.unlink" if any_owner else "account.staff_alt.unlink", outcome="ok", + target={"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). + """ + 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, + } + + +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..2bab719e 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,32 @@ 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`). + +- 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 (staff player page, + `manage_staff_alts`) removes it. +- A ban or unban reported for the owner's account is copied to their alts, with the owner's times + and reason ("Staff alt of …"), so ban sync applies and lifts it on every syncing server. Only + the owner's ban goes to Discord. + +| 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. diff --git a/frontend/app/components/account/LinkedAccounts.tsx b/frontend/app/components/account/LinkedAccounts.tsx index 0513c456..6b51e39d 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,148 @@ 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); + const { alts, max, active } = staffAlts; + const canAdd = active && alts.length < max; + + async function onRemove(playerUuid: string) { + setBusy(true); + onError(null); + try { + await unlinkStaffAlt(playerUuid); + 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); + await refresh(); + } + + 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 ? ( +

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

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

    {name}

    + {added ?

    Added {added}

    : null} + {confirm === alt.player_uuid ? ( +
    + Remove {name} from your staff alts? + + +
    + ) : null} +
    + ); + })} + {canAdd ? ( + } + action={ + + } + // A new form after each add: a finished one would reopen on its "Linking…" step. + 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..e0ca4da5 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"; @@ -18,6 +20,9 @@ const quietButtonClass = function errorText(err: unknown): string { if (isNotGuildMember(err)) return "Linking needs you to be in the TFMC Discord server."; + if (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,7 +51,7 @@ 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)); } finally { @@ -56,7 +63,7 @@ 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)) { @@ -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..de95429a 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, ...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 }) }), + ); + 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..dbf8f9fd 100644 --- a/frontend/lib/account/api.ts +++ b/frontend/lib/account/api.ts @@ -21,6 +21,20 @@ 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; +}; + export type AccountActivity = { first_seen: number | null; last_seen: number | null; @@ -53,6 +67,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 +147,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, From dc1342bf8c53cb816fb40c72a73be07a51d8c076 Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Fri, 13 Nov 2026 00:00:40 +0100 Subject: [PATCH 02/10] Staff alts: keep an alt's own ban, no Discord notice for alt bans, review fixes Copied bans are marked with copied_from, so the owner's unban lifts only the copies, even after the alt link is gone, and a copy never replaces a ban staff gave the alt or re-bans an alt unbanned since. Ban events for an alt no longer carry the owner's Discord id. Admin removal checks rank and audits refusals; the staff player page opens for an alt never seen in game. Co-Authored-By: Claude Opus 5.5 --- backend/src/api/admin_routes.py | 20 +-- backend/src/api/auth_routes.py | 10 +- backend/src/auth/players.py | 5 +- backend/src/auth/test_staff_alts.py | 134 +++++++++++++++++- .../migrations/0010_staff_alt_links.sql | 6 + backend/src/skins/moderation.py | 66 ++++++--- backend/src/skins/staff_alts.py | 33 ++++- docs/docs/identity/tfmcweb.md | 16 ++- .../app/components/account/LinkedAccounts.tsx | 19 ++- .../components/account/MinecraftLinkForm.tsx | 8 +- .../app/components/admin/PlayerFrame.test.tsx | 57 +++++++- 11 files changed, 321 insertions(+), 53 deletions(-) diff --git a/backend/src/api/admin_routes.py b/backend/src/api/admin_routes.py index 11cec578..f085df0f 100644 --- a/backend/src/api/admin_routes.py +++ b/backend/src/api/admin_routes.py @@ -29,7 +29,6 @@ ) from src.auth import admin, audit, players, roles, users, view_as from src.skins import staff_alts -from src.skins.discord_link import LinkError from src.auth import cases as person_cases from src.precedent.db import PrecedentDBError from src.coreprotect.reader import CoreProtectConfig @@ -385,15 +384,20 @@ def post_view_as(player_uuid: str, request: Request): @admin_router.post("/players/{player_uuid}/staff-alt/unlink") def post_staff_alt_unlink(player_uuid: str, request: Request, response: Response): - """Remove this account's staff alt link, whoever owns it; audited.""" - require_same_origin(request, _config()) + """Remove this account's staff alt link, if you outrank its owner; audited, refusals too.""" + action, target = "account.staff_alt.remove", {"player_uuid": player_uuid[:64]} actor = _capable(request, "manage_staff_alts") _no_store(response) - try: - staff_alts.unlink(player_uuid, actor, any_owner=True) - except LinkError: - raise HTTPException(404, detail="staff_alt_not_found") from None - return staff_alts.for_player(player_uuid) + + 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 diff --git a/backend/src/api/auth_routes.py b/backend/src/api/auth_routes.py index d265ba3d..4b103215 100644 --- a/backend/src/api/auth_routes.py +++ b/backend/src/api/auth_routes.py @@ -706,7 +706,11 @@ def minecraft_unlink(request: Request, response: Response): def _staff_alt_user(request: Request, config: AuthConfig) -> dict: - """A same-origin request from signed-in staff, within the link attempt limit.""" + """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"]): @@ -748,8 +752,8 @@ def staff_alt_unlink(request: Request, response: Response, player_uuid: str = Pa _no_store(response) try: staff_alts.unlink(player_uuid, user) - except LinkError as e: - raise HTTPException(404, detail=str(e)) from e + except staff_alts.StaffAltRefused as e: + raise HTTPException(e.status, detail=e.code) from e return {"staff_alts": staff_alts.account_view(user)} diff --git a/backend/src/auth/players.py b/backend/src/auth/players.py index 51a93dff..38aa5bb0 100644 --- a/backend/src/auth/players.py +++ b/backend/src/auth/players.py @@ -389,11 +389,12 @@ 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) @@ -412,7 +413,7 @@ def profile(config: CoreProtectConfig, text: str) -> dict: "discord": _discord(link), "account": _account(link), # Whose staff alt this account is, and its own alts (src/skins/staff_alts.py). - **staff_alts.for_player(key), + **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/test_staff_alts.py b/backend/src/auth/test_staff_alts.py index 21707ad3..4d1ea3d6 100644 --- a/backend/src/auth/test_staff_alts.py +++ b/backend/src/auth/test_staff_alts.py @@ -10,7 +10,8 @@ from src.api import admin_routes, auth_routes from src.api.view_as_guard import ViewAsGuard -from src.auth import account_overview, testing, users +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 @@ -241,7 +242,8 @@ def test_admins_remove_anyones_alt(app): 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["detail"]["player_uuid"]) == ("admin.staff_alt.unlink", "ryan", ALT) + 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 @@ -304,3 +306,131 @@ def test_unbanning_an_owner_leaves_unbanned_alts_alone(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"] == [] + 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() == [] diff --git a/backend/src/database/migrations/0010_staff_alt_links.sql b/backend/src/database/migrations/0010_staff_alt_links.sql index 696d8740..dc1a40ed 100644 --- a/backend/src/database/migrations/0010_staff_alt_links.sql +++ b/backend/src/database/migrations/0010_staff_alt_links.sql @@ -2,6 +2,7 @@ -- 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)), @@ -11,3 +12,8 @@ CREATE TABLE staff_alt_links ( ); 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/moderation.py b/backend/src/skins/moderation.py index d8776c42..892c4b96 100644 --- a/backend/src/skins/moderation.py +++ b/backend/src/skins/moderation.py @@ -190,10 +190,13 @@ 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; any other ban or unban + stored for the player clears it, and a repeat keeps it. """ database.advisory_lock(conn, f"moderation:ban:{uuid.lower()}") row = conn.execute( @@ -214,8 +217,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 +226,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"]: @@ -239,7 +243,8 @@ def _record_ban( 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 = NULL """, (uuid, minecraft_name, now), ) @@ -247,38 +252,60 @@ def _record_ban( 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 it with theirs. - - Stored like any ban, with the owner's times, so ban sync applies and lifts it on the - servers; no Discord notice, the owner already gets one. A stored repeat changes nothing. + """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 holds, nor re-bans an alt + unbanned after the owner's ban was made. 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() - active = owner["banned"] and (owner["expires_at"] is None or owner["expires_at"] > now) - if owner["banned"] and not active: + 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"]) + return + if owner["expires_at"] is not None and owner["expires_at"] <= now: return + since = owner["banned_at"] or owner["updated_at"] for alt in alts_of(conn, owner_uuid): - if not active: - alt_ban = conn.execute( - "SELECT banned FROM player_bans WHERE player_uuid = %s", (alt["player_uuid"],) - ).fetchone() - if alt_ban is None or not alt_ban["banned"]: + if alt["player_uuid"] == owner_key: + continue + 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) + unbanned_since = not held["banned"] and held["updated_at"] >= since + if own_ban or unbanned_since: continue name = alt["owner_name"] or owner["minecraft_name"] or "a banned player" reason = f"Staff alt of {name}" + (f": {owner['reason']}" if owner["reason"] else "") _record_ban( conn, - "ban" if active else "unban", + "ban", alt["player_uuid"], minecraft_name=alt["minecraft_name"], reason=reason[:_REASON_MAX], staff_name=owner["staff_name"], banned_at=owner["banned_at"], expires_at=owner["expires_at"], + copied_from=owner_key, ) @@ -335,6 +362,11 @@ def enqueue_ban_event( raise ModerationError("ban times are out of range") from e with database.connect() as conn: + 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, diff --git a/backend/src/skins/staff_alts.py b/backend/src/skins/staff_alts.py index 1f345bd1..3ba734f0 100644 --- a/backend/src/skins/staff_alts.py +++ b/backend/src/skins/staff_alts.py @@ -32,6 +32,15 @@ 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( @@ -133,27 +142,39 @@ def link(code: str, actor: dict) -> dict: def unlink(player_uuid: str, actor: dict, *, any_owner: bool = False) -> dict: - """Remove a staff alt: the actor's own, or anyone's when any_owner (admins); audited.""" + """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() - if row is None or (not any_owner and row["discord_user_id"] != actor.get("discord_user_id")): - raise LinkError("No staff alt with that UUID") + 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"] 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 LinkError("No staff alt with that UUID") + raise StaffAltRefused(404, "staff_alt_not_found") audit.record( conn, actor=actor, - action="admin.staff_alt.unlink" if any_owner else "account.staff_alt.unlink", outcome="ok", - target={"discord_user_id": owner_id}, + 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} diff --git a/docs/docs/identity/tfmcweb.md b/docs/docs/identity/tfmcweb.md index 2bab719e..ea5e149e 100644 --- a/docs/docs/identity/tfmcweb.md +++ b/docs/docs/identity/tfmcweb.md @@ -91,18 +91,22 @@ Staff (site role mod or above) whose own Minecraft account is linked can add up 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`). +(`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 (staff player page, - `manage_staff_alts`) removes it. -- A ban or unban reported for the owner's account is copied to their alts, with the owner's times - and reason ("Staff alt of …"), so ban sync applies and lifts it on every syncing server. Only - the owner's ban goes to Discord. + 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, nor re-bans an alt unbanned after + the owner's ban was made. 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. | Route | Auth | Role | |-------|------|------| diff --git a/frontend/app/components/account/LinkedAccounts.tsx b/frontend/app/components/account/LinkedAccounts.tsx index 6b51e39d..14618d0c 100644 --- a/frontend/app/components/account/LinkedAccounts.tsx +++ b/frontend/app/components/account/LinkedAccounts.tsx @@ -357,6 +357,8 @@ function StaffAltsCard({ 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 } = staffAlts; const canAdd = active && alts.length < max; @@ -364,7 +366,12 @@ function StaffAltsCard({ setBusy(true); onError(null); try { - await unlinkStaffAlt(playerUuid); + 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 { @@ -376,7 +383,12 @@ function StaffAltsCard({ async function onAdded() { setShowForm(false); - await refresh(); + 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 ( @@ -453,8 +465,7 @@ function StaffAltsCard({ Add a staff alt } - // A new form after each add: a finished one would reopen on its "Linking…" step. - below={} + below={} belowOpen={showForm} >

diff --git a/frontend/app/components/account/MinecraftLinkForm.tsx b/frontend/app/components/account/MinecraftLinkForm.tsx index e0ca4da5..96aa65d5 100644 --- a/frontend/app/components/account/MinecraftLinkForm.tsx +++ b/frontend/app/components/account/MinecraftLinkForm.tsx @@ -18,9 +18,9 @@ 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 (err instanceof AccountApiError && err.status === 403 && err.message === "forbidden") { + 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; @@ -53,7 +53,7 @@ export default function MinecraftLinkForm({ discordName, onLinked, staffAlt = fa try { setPreview(await (staffAlt ? previewStaffAlt : previewMinecraftLink)(code.trim())); } catch (err) { - setError(errorText(err)); + setError(errorText(err, staffAlt)); } finally { setBusy(false); } @@ -69,7 +69,7 @@ export default function MinecraftLinkForm({ discordName, onLinked, staffAlt = fa if (needsGuildRecheck(err)) { setRecheck(true); } else { - setError(errorText(err)); + setError(errorText(err, staffAlt)); setPreview(null); } setBusy(false); diff --git a/frontend/app/components/admin/PlayerFrame.test.tsx b/frontend/app/components/admin/PlayerFrame.test.tsx index 0242134b..e86707e5 100644 --- a/frontend/app/components/admin/PlayerFrame.test.tsx +++ b/frontend/app/components/admin/PlayerFrame.test.tsx @@ -5,7 +5,7 @@ import { afterEach, beforeEach, expect, it, vi } from "vitest"; import PlayerFrame, { usePlayer } from "./PlayerFrame"; import { AccountApiError } from "../../../lib/account/api"; import { viewSiteAs } from "../../../lib/account/viewAs"; -import { getAdminMe, getPlayer, type PlayerProfile as Profile } from "../../../lib/admin/api"; +import { getAdminMe, getPlayer, unlinkStaffAltAsAdmin, type PlayerProfile as Profile } from "../../../lib/admin/api"; const UUID = "0615a817-8cb4-4aef-95f7-f6c9bf7611b8"; vi.mock("next/navigation", () => ({ @@ -17,6 +17,7 @@ vi.mock("../../../lib/admin/api", async (importOriginal) => ({ ...(await importOriginal()), getAdminMe: vi.fn(), getPlayer: vi.fn(), + unlinkStaffAltAsAdmin: vi.fn(), })); const PROFILE = { @@ -119,3 +120,57 @@ it.each([ await Promise.resolve(); expect(screen.queryByRole("button", { name: /View the site as/ })).toBeNull(); }); + +const OWNER = { + discord_user_id: "42", + player_uuid: "owner-uuid", + minecraft_name: "WrenPlays", + linked_at: "2026-11-12T10:00:00Z", +}; + +it("names a staff alt's owner and lets an admin remove the link after confirming", async () => { + vi.mocked(getPlayer) + .mockResolvedValueOnce({ ...PROFILE, staff_alt_of: OWNER, staff_alts: [] } as unknown as Profile) + .mockResolvedValueOnce({ ...PROFILE, staff_alt_of: null, staff_alts: [] } as unknown as Profile); + vi.mocked(getAdminMe).mockResolvedValue({ capabilities: ["manage_staff_alts"] } as never); + vi.mocked(unlinkStaffAltAsAdmin).mockResolvedValue({ staff_alt_of: null, staff_alts: [] }); + render( + + + , + ); + const owner = await screen.findByRole("link", { name: "WrenPlays" }); + expect(owner.getAttribute("href")).toBe("/admin/players/owner-uuid"); + fireEvent.click(await screen.findByRole("button", { name: "Remove link" })); + expect(unlinkStaffAltAsAdmin).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole("button", { name: "Remove" })); + await vi.waitFor(() => expect(screen.queryByText(/Staff alt of/)).toBeNull()); + expect(unlinkStaffAltAsAdmin).toHaveBeenCalledWith(UUID); +}); + +it("shows mods a staff alt's owner without a way to remove it", async () => { + vi.mocked(getPlayer).mockResolvedValue({ ...PROFILE, staff_alt_of: OWNER, staff_alts: [] } as unknown as Profile); + render( + + + , + ); + expect(await screen.findByRole("link", { name: "WrenPlays" })).toBeTruthy(); + expect(screen.queryByRole("button", { name: "Remove link" })).toBeNull(); +}); + +it("lists the staff alts of an owner's account", async () => { + vi.mocked(getPlayer).mockResolvedValue({ + ...PROFILE, + staff_alt_of: null, + staff_alts: [{ player_uuid: "alt-uuid", minecraft_name: "ShayminPlays", linked_at: "2026-11-12T10:00:00Z" }], + } as unknown as Profile); + render( + + + , + ); + const alt = await screen.findByRole("link", { name: "ShayminPlays" }); + expect(alt.getAttribute("href")).toBe("/admin/players/alt-uuid"); + expect(alt.parentElement?.parentElement?.textContent).toContain("Staff alts:"); +}); From 991d39f4c228ebeb0ee524b2cde3095074b09784 Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Fri, 13 Nov 2026 00:18:17 +0100 Subject: [PATCH 03/10] Staff alts: re-copy after the owner's own unban, longer bans win, review nits A copy lifted by the owner's unban keeps copied_from, so the owner's next ban is copied again even when its report arrives late. An owner ban now replaces an alt's own shorter ban. The alt's ban row is read under its lock. Account and staff pages explain paused alts by role and refresh after a removal made elsewhere. Co-Authored-By: Claude Opus 5.5 --- backend/src/auth/test_staff_alts.py | 32 +++++++++++++++++++ backend/src/skins/moderation.py | 27 ++++++++++------ docs/docs/identity/tfmcweb.md | 1 + .../app/components/account/LinkedAccounts.tsx | 12 +++++-- frontend/app/components/admin/PlayerFrame.tsx | 2 ++ 5 files changed, 62 insertions(+), 12 deletions(-) diff --git a/backend/src/auth/test_staff_alts.py b/backend/src/auth/test_staff_alts.py index 4d1ea3d6..3e030a09 100644 --- a/backend/src/auth/test_staff_alts.py +++ b/backend/src/auth/test_staff_alts.py @@ -434,3 +434,35 @@ def test_an_alts_ban_sends_nothing_to_its_owners_discord(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_a_longer_owner_ban_replaces_an_alts_own_shorter_ban(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) + 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_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"] == [] diff --git a/backend/src/skins/moderation.py b/backend/src/skins/moderation.py index 892c4b96..947addf8 100644 --- a/backend/src/skins/moderation.py +++ b/backend/src/skins/moderation.py @@ -195,8 +195,9 @@ def _record_ban( """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; any other ban or unban - stored for the player clears it, and a repeat keeps it. + 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. """ database.advisory_lock(conn, f"moderation:ban:{uuid.lower()}") row = conn.execute( @@ -238,15 +239,15 @@ 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, - copied_from = NULL + copied_from = EXCLUDED.copied_from """, - (uuid, minecraft_name, now), + (uuid, minecraft_name, now, copied_from), ) return True @@ -256,8 +257,10 @@ def _copy_to_staff_alts(conn, owner_uuid: str) -> None: 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 holds, nor re-bans an alt - unbanned after the owner's ban was made. The owner's unban lifts only the copies, and + It never replaces a ban staff gave the alt itself that lasts at least as long, nor + re-bans an alt staff unbanned after the owner's ban was made (a copy lifted by the + owner's own unban keeps copied_from, so the owner's next ban is copied again). + 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). @@ -277,7 +280,8 @@ def _copy_to_staff_alts(conn, owner_uuid: str) -> None: ).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"]) + 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 @@ -285,12 +289,15 @@ def _copy_to_staff_alts(conn, owner_uuid: str) -> None: 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) + own_ban = held["banned"] and (held["expires_at"] is None or ( + owner["expires_at"] is not None and held["expires_at"] >= owner["expires_at"])) unbanned_since = not held["banned"] and held["updated_at"] >= since if own_ban or unbanned_since: continue diff --git a/docs/docs/identity/tfmcweb.md b/docs/docs/identity/tfmcweb.md index ea5e149e..a1901d74 100644 --- a/docs/docs/identity/tfmcweb.md +++ b/docs/docs/identity/tfmcweb.md @@ -107,6 +107,7 @@ accounts on Profile → Accounts: log in to the alt, run `/linkdiscord`, paste t the owner's ban was made. 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. | Route | Auth | Role | |-------|------|------| diff --git a/frontend/app/components/account/LinkedAccounts.tsx b/frontend/app/components/account/LinkedAccounts.tsx index 14618d0c..4dc8f88e 100644 --- a/frontend/app/components/account/LinkedAccounts.tsx +++ b/frontend/app/components/account/LinkedAccounts.tsx @@ -330,6 +330,7 @@ export default function LinkedAccounts({ Promise; onError: (message: string | null) => void; @@ -402,9 +408,11 @@ function StaffAltsCard({

{!active ? (

- {linked + {!staff ? "Your staff alts are paused while you’re not on staff. You can still remove them." - : "Link your own Minecraft account above to use staff alts."} + : !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 ? ( diff --git a/frontend/app/components/admin/PlayerFrame.tsx b/frontend/app/components/admin/PlayerFrame.tsx index 0a616044..ae205c8d 100644 --- a/frontend/app/components/admin/PlayerFrame.tsx +++ b/frontend/app/components/admin/PlayerFrame.tsx @@ -187,6 +187,8 @@ function StaffAltLine({ profile, canRemove, reload }: { profile: Profile; canRem reload(); } catch (err) { setError(adminErrorMessage(err)); + // Already removed elsewhere: show the player as they are now. + if (err instanceof AccountApiError && err.status === 404) reload(); } finally { setBusy(false); } From 198ba44b12e1f1135178b97c6e0357c16c7e6a66 Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Fri, 13 Nov 2026 00:28:53 +0100 Subject: [PATCH 04/10] Staff alts: keep an alt's own active ban, compare on the site's clock An alt's own ban is never replaced by a copy, so the owner's unban can't lift it early. Whether staff unbanned an alt since the owner's ban is judged on the site's clock. The ban copy takes the owner's link lock so an alt linked meanwhile is either copied or refused. Microsoft links of a staff alt read as taken; admin route checks the UUID; docs match. Co-Authored-By: Claude Opus 5.5 --- backend/src/api/admin_routes.py | 5 +++-- backend/src/api/auth_routes.py | 1 + backend/src/auth/test_staff_alts.py | 20 +++++++++++++++-- backend/src/skins/moderation.py | 22 ++++++++++++++----- backend/src/skins/staff_alts.py | 1 + docs/docs/identity/tfmcweb.md | 9 ++++---- .../app/components/account/LinkedAccounts.tsx | 4 ++-- 7 files changed, 46 insertions(+), 16 deletions(-) diff --git a/backend/src/api/admin_routes.py b/backend/src/api/admin_routes.py index f085df0f..587b1b91 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 @@ -383,7 +383,8 @@ def post_view_as(player_uuid: str, request: Request): @admin_router.post("/players/{player_uuid}/staff-alt/unlink") -def post_staff_alt_unlink(player_uuid: str, request: Request, response: Response): +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 too.""" action, target = "account.staff_alt.remove", {"player_uuid": player_uuid[:64]} actor = _capable(request, "manage_staff_alts") diff --git a/backend/src/api/auth_routes.py b/backend/src/api/auth_routes.py index 4b103215..aa649e8b 100644 --- a/backend/src/api/auth_routes.py +++ b/backend/src/api/auth_routes.py @@ -593,6 +593,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", } diff --git a/backend/src/auth/test_staff_alts.py b/backend/src/auth/test_staff_alts.py index 3e030a09..81f92825 100644 --- a/backend/src/auth/test_staff_alts.py +++ b/backend/src/auth/test_staff_alts.py @@ -186,6 +186,10 @@ def test_alts_are_capped(app): 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 @@ -448,14 +452,26 @@ def test_an_owner_unbanned_and_banned_again_bans_the_alt_again(app): assert {b["player_uuid"] for b in list_ban_sync()["bans"]} == {OWNER, ALT} -def test_a_longer_owner_ban_replaces_an_alts_own_shorter_ban(app): +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"]) == ("Staff alt of Wren: Abuse", 0) + 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") + # 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): diff --git a/backend/src/skins/moderation.py b/backend/src/skins/moderation.py index 947addf8..bb772342 100644 --- a/backend/src/skins/moderation.py +++ b/backend/src/skins/moderation.py @@ -257,9 +257,11 @@ def _copy_to_staff_alts(conn, owner_uuid: str) -> None: 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 that lasts at least as long, nor - re-bans an alt staff unbanned after the owner's ban was made (a copy lifted by the - owner's own unban keeps copied_from, so the owner's next ban is copied again). + 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 unbanned 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 ends while the + owner's still holds gets the copy only with the owner's next report. 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 @@ -285,7 +287,16 @@ def _copy_to_staff_alts(conn, owner_uuid: str) -> None: return if owner["expires_at"] is not None and owner["expires_at"] <= now: return - since = owner["banned_at"] or owner["updated_at"] + # 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 @@ -296,8 +307,7 @@ def _copy_to_staff_alts(conn, owner_uuid: str) -> None: (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 ( - owner["expires_at"] is not None and held["expires_at"] >= owner["expires_at"])) + own_ban = held["banned"] and (held["expires_at"] is None or held["expires_at"] > now) unbanned_since = not held["banned"] and held["updated_at"] >= since if own_ban or unbanned_since: continue diff --git a/backend/src/skins/staff_alts.py b/backend/src/skins/staff_alts.py index 3ba734f0..5da6aa6c 100644 --- a/backend/src/skins/staff_alts.py +++ b/backend/src/skins/staff_alts.py @@ -19,6 +19,7 @@ from .codes import api_time from .discord_link import ( + STAFF_ALT_TAKEN, LinkError, _code_candidate, _code_hash, diff --git a/docs/docs/identity/tfmcweb.md b/docs/docs/identity/tfmcweb.md index a1901d74..a29dddd0 100644 --- a/docs/docs/identity/tfmcweb.md +++ b/docs/docs/identity/tfmcweb.md @@ -103,11 +103,12 @@ accounts on Profile → Accounts: log in to the alt, run `/linkdiscord`, paste t 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, nor re-bans an alt unbanned after - the owner's ban was made. 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. + server. A copy never replaces a ban staff gave the alt itself while it holds, nor re-bans an alt + unbanned after the site stored the owner's ban. 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. + 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 | |-------|------|------| diff --git a/frontend/app/components/account/LinkedAccounts.tsx b/frontend/app/components/account/LinkedAccounts.tsx index 4dc8f88e..cbc3868a 100644 --- a/frontend/app/components/account/LinkedAccounts.tsx +++ b/frontend/app/components/account/LinkedAccounts.tsx @@ -422,7 +422,7 @@ function StaffAltsCard({ > {alts.map((alt) => { const name = alt.minecraft_name || "Minecraft account"; - const added = formatDate(alt.linked_at); + const addedOn = formatDate(alt.linked_at); return (

{name}

- {added ?

Added {added}

: null} + {addedOn ?

Added {addedOn}

: null} {confirm === alt.player_uuid ? (
Remove {name} from your staff alts? From 111fdaf39d22ed4dc3a2860914f9e9bae254ed0d Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Fri, 13 Nov 2026 00:33:34 +0100 Subject: [PATCH 05/10] Staff alts: say when an alt whose own ban ended gets the owner's copy Docs and docstring only. Co-Authored-By: Claude Opus 5.5 --- backend/src/skins/moderation.py | 3 ++- docs/docs/identity/tfmcweb.md | 5 +++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/backend/src/skins/moderation.py b/backend/src/skins/moderation.py index bb772342..7d0ec324 100644 --- a/backend/src/skins/moderation.py +++ b/backend/src/skins/moderation.py @@ -261,7 +261,8 @@ def _copy_to_staff_alts(conn, owner_uuid: str) -> None: unban would otherwise lift it), nor re-bans an alt staff unbanned 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 ends while the - owner's still holds gets the copy only with the owner's next report. + owner's still holds, by a lapse or an unban a server reports, stays unbanned until + the owner's next new ban: repeats of the owner's current ban don't copy it. 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 diff --git a/docs/docs/identity/tfmcweb.md b/docs/docs/identity/tfmcweb.md index a29dddd0..319524cf 100644 --- a/docs/docs/identity/tfmcweb.md +++ b/docs/docs/identity/tfmcweb.md @@ -104,8 +104,9 @@ accounts on Profile → Accounts: log in to the alt, run `/linkdiscord`, paste t - 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 - unbanned after the site stored the owner's ban. 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. + unbanned after the site stored the owner's ban; an alt whose own ban ends or is lifted while the + owner's holds stays unbanned until the owner's next new ban. 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. From 63ea78ae0f62fa5372cea97bd98751f728271375 Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Fri, 13 Nov 2026 01:23:41 +0100 Subject: [PATCH 06/10] Staff alts: staff flag from the API, alt names on staff pages, more tests The account payload says whether the role is staff, so the page no longer keeps its own list of staff roles. The staff player page names an alt never seen in game by its linked name. Tests cover the admin removal from another site and from a view, and the late-report test no longer depends on clock resolution. Co-Authored-By: Claude Opus 5.5 --- backend/src/auth/players.py | 3 +++ backend/src/auth/test_staff_alts.py | 23 +++++++++++++++++-- backend/src/skins/staff_alts.py | 13 ++++++++++- .../app/components/account/LinkedAccounts.tsx | 8 +------ .../components/profile/ProfilePanel.test.tsx | 4 ++-- frontend/lib/account/api.ts | 2 ++ 6 files changed, 41 insertions(+), 12 deletions(-) diff --git a/backend/src/auth/players.py b/backend/src/auth/players.py index 38aa5bb0..f22a2beb 100644 --- a/backend/src/auth/players.py +++ b/backend/src/auth/players.py @@ -398,6 +398,9 @@ def profile(config: CoreProtectConfig, text: str) -> dict: 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} diff --git a/backend/src/auth/test_staff_alts.py b/backend/src/auth/test_staff_alts.py index 81f92825..acd01b38 100644 --- a/backend/src/auth/test_staff_alts.py +++ b/backend/src/auth/test_staff_alts.py @@ -106,7 +106,7 @@ def test_staff_link_an_alt_with_its_code(app): 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["max"] == staff_alts.MAX_ALTS + 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 @@ -213,7 +213,7 @@ def test_an_alt_counts_only_while_its_owner_is_linked_staff(app): 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 len(view["alts"]) == 1 + 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 @@ -389,6 +389,7 @@ def test_staff_player_page_shows_an_alt_never_seen_in_game(app, coreprotect): 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"] @@ -469,6 +470,8 @@ def test_an_alt_unbanned_before_a_late_owner_report_gets_the_copy(app): 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} @@ -482,3 +485,19 @@ def test_a_server_echo_inside_the_same_ban_window_keeps_the_copy(app): _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 diff --git a/backend/src/skins/staff_alts.py b/backend/src/skins/staff_alts.py index 5da6aa6c..b0411d9f 100644 --- a/backend/src/skins/staff_alts.py +++ b/backend/src/skins/staff_alts.py @@ -192,7 +192,8 @@ def _alt_json(row) -> dict: 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). + 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: @@ -211,9 +212,19 @@ def account_view(user: dict) -> dict | None: "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: diff --git a/frontend/app/components/account/LinkedAccounts.tsx b/frontend/app/components/account/LinkedAccounts.tsx index cbc3868a..9f004f2e 100644 --- a/frontend/app/components/account/LinkedAccounts.tsx +++ b/frontend/app/components/account/LinkedAccounts.tsx @@ -330,7 +330,6 @@ export default function LinkedAccounts({ Promise; onError: (message: string | null) => void; @@ -365,7 +359,7 @@ function StaffAltsCard({ 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 } = staffAlts; + const { alts, max, active, staff } = staffAlts; const canAdd = active && alts.length < max; async function onRemove(playerUuid: string) { diff --git a/frontend/app/components/profile/ProfilePanel.test.tsx b/frontend/app/components/profile/ProfilePanel.test.tsx index de95429a..a57c284e 100644 --- a/frontend/app/components/profile/ProfilePanel.test.tsx +++ b/frontend/app/components/profile/ProfilePanel.test.tsx @@ -492,7 +492,7 @@ it("leaves Patreon out when Patreon linking is off", async () => { }); function staffAlts(overrides: Partial = {}): StaffAlts { - return { alts: [], max: 3, active: true, ...overrides }; + 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" }; @@ -548,7 +548,7 @@ it("removes a staff alt after confirming", async () => { 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 }) }), + account({ minecraft: linked(), staff_alts: staffAlts({ alts: [SHAYMIN], active: false, staff: false }) }), ); visit("accounts"); render(); diff --git a/frontend/lib/account/api.ts b/frontend/lib/account/api.ts index dbf8f9fd..30c06a4a 100644 --- a/frontend/lib/account/api.ts +++ b/frontend/lib/account/api.ts @@ -33,6 +33,8 @@ export type StaffAlts = { 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 = { From 8dab26f14f3ed86f00c6f7fbfe613dee02495205 Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Fri, 13 Nov 2026 01:27:38 +0100 Subject: [PATCH 07/10] Staff alts: comments on the head fallback, admin audit scope and unlink recheck Comments and docstrings only. Co-Authored-By: Claude Opus 5.5 --- backend/src/api/admin_routes.py | 6 +++++- backend/src/api/auth_routes.py | 6 +++++- backend/src/skins/staff_alts.py | 2 ++ 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/backend/src/api/admin_routes.py b/backend/src/api/admin_routes.py index 587b1b91..a252cece 100644 --- a/backend/src/api/admin_routes.py +++ b/backend/src/api/admin_routes.py @@ -385,7 +385,11 @@ def post_view_as(player_uuid: str, request: Request): @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 too.""" + """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) diff --git a/backend/src/api/auth_routes.py b/backend/src/api/auth_routes.py index aa649e8b..636081cf 100644 --- a/backend/src/api/auth_routes.py +++ b/backend/src/api/auth_routes.py @@ -516,7 +516,11 @@ def get_account_overview(request: Request, response: Response): @auth_router.get("/account/minecraft/head") 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.""" + 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) alt = (u or "").strip() diff --git a/backend/src/skins/staff_alts.py b/backend/src/skins/staff_alts.py index b0411d9f..088041ed 100644 --- a/backend/src/skins/staff_alts.py +++ b/backend/src/skins/staff_alts.py @@ -157,6 +157,8 @@ def unlink(player_uuid: str, actor: dict, *, any_owner: bool = False) -> dict: 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 " From 4ea3e658110e0bae7ae826dbcae1799cdce78545 Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Fri, 13 Nov 2026 01:39:02 +0100 Subject: [PATCH 08/10] Staff alts: lapsed own bans let the owner's ban copy; own bans over a copy stay An unban that came as the alt's own ban ran out is not a pardon, so the owner's next report (repeat or new) copies the ban whether or not a server reported the lapse. A report over a copy that gives another reason is the alt's own ban, so the owner's unban no longer lifts it. Success criteria in the identity docs now describe staff alts. Co-Authored-By: Claude Opus 5.5 --- backend/src/auth/test_staff_alts.py | 33 +++++++++++++++++++++++++++++ backend/src/skins/moderation.py | 27 +++++++++++++++-------- docs/docs/identity/tfmcweb.md | 7 +++--- 3 files changed, 55 insertions(+), 12 deletions(-) diff --git a/backend/src/auth/test_staff_alts.py b/backend/src/auth/test_staff_alts.py index acd01b38..f12ba60f 100644 --- a/backend/src/auth/test_staff_alts.py +++ b/backend/src/auth/test_staff_alts.py @@ -501,3 +501,36 @@ def test_the_admin_removal_refuses_other_sites_and_views(app): 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" diff --git a/backend/src/skins/moderation.py b/backend/src/skins/moderation.py index 7d0ec324..5384ad27 100644 --- a/backend/src/skins/moderation.py +++ b/backend/src/skins/moderation.py @@ -197,16 +197,23 @@ def _record_ban( 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. + 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 can't tell, so it 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"] + ) + 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( @@ -258,11 +265,10 @@ def _copy_to_staff_alts(conn, owner_uuid: str) -> None: 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 unbanned after the site + 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 ends while the - owner's still holds, by a lapse or an unban a server reports, stays unbanned until - the owner's next new ban: repeats of the owner's current ban don't copy it. + 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 @@ -309,8 +315,11 @@ def _copy_to_staff_alts(conn, owner_uuid: str) -> None: ).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) - unbanned_since = not held["banned"] and held["updated_at"] >= since - if own_ban or unbanned_since: + # 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. + lapsed = held["expires_at"] is not None and held["expires_at"] <= 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"Staff alt of {name}" + (f": {owner['reason']}" if owner["reason"] else "") diff --git a/docs/docs/identity/tfmcweb.md b/docs/docs/identity/tfmcweb.md index 319524cf..ec047985 100644 --- a/docs/docs/identity/tfmcweb.md +++ b/docs/docs/identity/tfmcweb.md @@ -104,8 +104,8 @@ accounts on Profile → Accounts: log in to the alt, run `/linkdiscord`, paste t - 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 - unbanned after the site stored the owner's ban; an alt whose own ban ends or is lifted while the - owner's holds stays unbanned until the owner's next new ban. The owner's unban lifts only the + 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. 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 @@ -245,7 +245,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. From 2c71bf264efcfa0252c6cbbf09a903b43c5a7add Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Fri, 13 Nov 2026 01:44:26 +0100 Subject: [PATCH 09/10] Staff alts: an echo worded as a copy stays a copy; trim cut copy reasons A ban reported over a copy whose reason still starts "Staff alt of" is the copy repeated, so a server trimming or cutting the text can't turn it into an own ban the owner's unban wouldn't lift. Cut copy reasons lose trailing spaces. Tests for no-reason and reworded echoes, own bans over a tempban copy, and an early pardon of the alt's own ban; docs list the echo rules. Co-Authored-By: Claude Opus 5.5 --- backend/src/auth/test_staff_alts.py | 46 +++++++++++++++++++++++++++++ backend/src/skins/moderation.py | 17 +++++++---- docs/docs/identity/tfmcweb.md | 4 ++- 3 files changed, 61 insertions(+), 6 deletions(-) diff --git a/backend/src/auth/test_staff_alts.py b/backend/src/auth/test_staff_alts.py index f12ba60f..df830b48 100644 --- a/backend/src/auth/test_staff_alts.py +++ b/backend/src/auth/test_staff_alts.py @@ -534,3 +534,49 @@ def test_an_own_ban_over_a_copy_survives_the_owners_unban(app): 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) + long_reason = ("x" * 475) + " tail words" + _ban(OWNER, start=now, hours=None, reason=long_reason) + alt = {b["player_uuid"]: b for b in list_ban_sync()["bans"]}[ALT] + assert len(alt["reason"]) <= 500 and alt["reason"] == alt["reason"].rstrip() + diff --git a/backend/src/skins/moderation.py b/backend/src/skins/moderation.py index 5384ad27..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 @@ -199,7 +201,7 @@ def _record_ban( 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 can't tell, so it counts as a repeat. + 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( @@ -212,6 +214,8 @@ def _record_ban( 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: @@ -316,19 +320,22 @@ def _copy_to_staff_alts(conn, owner_uuid: str) -> None: 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. - lapsed = held["expires_at"] is not None and held["expires_at"] <= held["updated_at"] + _SAME_BAN_WINDOW + # 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"Staff alt of {name}" + (f": {owner['reason']}" if owner["reason"] else "") + 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], + reason=reason[:_REASON_MAX].rstrip(), staff_name=owner["staff_name"], banned_at=owner["banned_at"], expires_at=owner["expires_at"], diff --git a/docs/docs/identity/tfmcweb.md b/docs/docs/identity/tfmcweb.md index ec047985..9a51f541 100644 --- a/docs/docs/identity/tfmcweb.md +++ b/docs/docs/identity/tfmcweb.md @@ -105,7 +105,9 @@ accounts on Profile → Accounts: log in to the alt, run `/linkdiscord`, paste t 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. The owner's unban lifts only 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 From 63e5f3115b3043c162b3ee8fcd70aa886ae61cfc Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Fri, 13 Nov 2026 01:46:39 +0100 Subject: [PATCH 10/10] Staff alts: long-reason test computes where the cut lands Test only. Co-Authored-By: Claude Opus 5.5 --- backend/src/auth/test_staff_alts.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/backend/src/auth/test_staff_alts.py b/backend/src/auth/test_staff_alts.py index df830b48..216fa568 100644 --- a/backend/src/auth/test_staff_alts.py +++ b/backend/src/auth/test_staff_alts.py @@ -575,8 +575,10 @@ 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) - long_reason = ("x" * 475) + " tail words" - _ban(OWNER, start=now, hours=None, reason=long_reason) + 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 len(alt["reason"]) <= 500 and alt["reason"] == alt["reason"].rstrip() + assert alt["reason"] == prefix + pad