Compare commits

...
2 Commits
Author SHA1 Message Date
josh 8b372fa2c1 Add player moderation workflows and game integration
CI / checks (push) Successful in 7m27s
2026-10-15 21:33:50 -04:00
josh c91d49c298 Add moderation permissions and durable player records 2026-10-15 21:06:08 -04:00
61 changed files with 5423 additions and 355 deletions
+2 -1
View File
@@ -13,9 +13,10 @@ _Fleet overview with sample data._
- Launch individual servers or saved collections of maps and lobbies. - Launch individual servers or saved collections of maps and lobbies.
- Monitor players, host capacity, match history, and leaderboards. - Monitor players, host capacity, match history, and leaderboards.
- Moderate players with scoped restrictions, live actions, preserved evidence and review history.
- Manage scripts, plugins, and configuration with beta testing and rollback. - Manage scripts, plugins, and configuration with beta testing and rollback.
- Queue maintenance around active games and scale spare lobbies with opt-in policies. - Queue maintenance around active games and scale spare lobbies with opt-in policies.
- Control access with administrator, operator, and viewer accounts. - Control access with administrator, moderator, operator, and read-only accounts.
## Getting started ## Getting started
+17
View File
@@ -1,5 +1,22 @@
# Agent changelog # Agent changelog
## 2026-10-15
### 1.11.0 — Private player moderation configuration
- Advertise `moderation.configure` and accept separate generation-bound game
credentials through private create/rebuild configuration or the typed
`instance.configure-moderation` operation. Existing servers preserve startup
through a fixed include in their verified private bank configuration; servers
without a verified bootstrap require rebuilding.
- Keep moderation credentials out of file reads, logs, manifests, staged changes,
shared recipes and operation receipts. Configure loads only the fixed private
file, never restarts gameplay, and does not replay RCON after an unknown result.
- Controller coverage remains unavailable until the game moderation runtime
connects and reports its capabilities. Older agents retain normal launches.
- Package release artifacts for offline signing before publication; do not
publish unsigned artifacts.
## 2026-10-14 ## 2026-10-14
### 1.10.1 — Retain verified rebuild artifacts (EC #154) ### 1.10.1 — Retain verified rebuild artifacts (EC #154)
+106 -13
View File
@@ -35,7 +35,7 @@ import urllib.parse
import urllib.request import urllib.request
import uuid import uuid
VERSION = "1.10.1" VERSION = "1.11.0"
LABEL = "io.electric-cherry" LABEL = "io.electric-cherry"
MAX_FILE = 32 * 1024 * 1024 MAX_FILE = 32 * 1024 * 1024
MANIFEST_MAX_ENTRIES = 10000 MANIFEST_MAX_ENTRIES = 10000
@@ -49,6 +49,8 @@ PLUTONIUM_MANIFEST_LIMIT = 2 * 1024 * 1024
PLUTONIUM_FILE_LIMIT = 512 * 1024 * 1024 PLUTONIUM_FILE_LIMIT = 512 * 1024 * 1024
PLUTONIUM_CACHE_LIMIT = 4 * 1024 * 1024 * 1024 PLUTONIUM_CACHE_LIMIT = 4 * 1024 * 1024 * 1024
BANK_GAME_ENDPOINT = "https://ec.thewrightserver.net/api/bank" BANK_GAME_ENDPOINT = "https://ec.thewrightserver.net/api/bank"
MODERATION_CONFIG_PATH = "configs/electric_cherry_moderation.cfg"
MODERATION_INCLUDE = b"exec electric_cherry_moderation.cfg\n"
ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$") ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$")
AGENT_CONFIG = Path("/etc/electric-cherry/agent.json") AGENT_CONFIG = Path("/etc/electric-cherry/agent.json")
AGENT_EXECUTABLE = Path("/usr/local/libexec/electric-cherry-agent.py") AGENT_EXECUTABLE = Path("/usr/local/libexec/electric-cherry-agent.py")
@@ -85,6 +87,7 @@ class AutoscalingInvalidated(Rejected):
OPERATION_ERROR_PHASES = { OPERATION_ERROR_PHASES = {
"executing": "executing the operation", "executing": "executing the operation",
"configuring-moderation": "configuring private game moderation",
"checking-instance": "checking the existing instance", "checking-instance": "checking the existing instance",
"validating-create-spec": "validating the container specification", "validating-create-spec": "validating the container specification",
"preparing-create-directory": "preparing the instance directory", "preparing-create-directory": "preparing the instance directory",
@@ -401,13 +404,13 @@ def managed_open(root, value):
os.close(fd) os.close(fd)
def private_bank_config(value): def private_instance_config(value):
return isinstance(value, str) and PurePosixPath(value).as_posix() == "configs/electric_cherry_bank.cfg" return isinstance(value, str) and PurePosixPath(value).as_posix() in ("configs/electric_cherry_bank.cfg", MODERATION_CONFIG_PATH)
def require_public_file(value): def require_public_file(value):
if private_bank_config(value): if private_instance_config(value):
raise Rejected("Private bank configuration cannot be read or changed by file operations") raise Rejected("Private bank configuration and moderation configuration cannot be read or changed by file operations")
def managed_read(root, value): def managed_read(root, value):
@@ -457,7 +460,7 @@ def managed_manifest(root, payload, stopping):
def validate_paths(): def validate_paths():
for value in paths: for value in paths:
if private_bank_config(value): if private_instance_config(value):
continue continue
check_budget() check_budget()
try: try:
@@ -493,7 +496,7 @@ def managed_manifest(root, payload, stopping):
entries += 1 entries += 1
if entries > MANIFEST_MAX_ENTRIES: if entries > MANIFEST_MAX_ENTRIES:
raise AgentError("Manifest scan exceeded its entry limit") raise AgentError("Manifest scan exceeded its entry limit")
if child.name in ignored or private_bank_config(relative + "/" + child.name): if child.name in ignored or private_instance_config(relative + "/" + child.name):
continue continue
info = os.stat(child.name, dir_fd=directory, follow_symlinks=False) info = os.stat(child.name, dir_fd=directory, follow_symlinks=False)
if stat_module.S_ISLNK(info.st_mode): if stat_module.S_ISLNK(info.st_mode):
@@ -1571,7 +1574,7 @@ class Agent:
if line.startswith("PRETTY_NAME="): if line.startswith("PRETTY_NAME="):
os_name = line.split("=", 1)[1].strip('"') os_name = line.split("=", 1)[1].strip('"')
update = self.update_inventory() update = self.update_inventory()
capabilities = ["host.temperatures", "library.remove", "plutonium.cache", "instance.cancel-create", "instance.safety-override", "instance.unhealthy-recovery", "instance.rebuild-files", "instance.autoscaling-remove", "operations.read-lane", "operations.health-lane", "rcon.status-batch", "instance.heartbeat-state", "files.manifest", "files.blobs", "logs.read-offset", "bank.settlement"] + (["host.decommission"] if self.can_uninstall() else []) + (["agent.update"] if update["enabled"] else []) capabilities = ["host.temperatures", "library.remove", "plutonium.cache", "instance.cancel-create", "instance.safety-override", "instance.unhealthy-recovery", "instance.rebuild-files", "instance.autoscaling-remove", "moderation.configure", "operations.read-lane", "operations.health-lane", "rcon.status-batch", "instance.heartbeat-state", "files.manifest", "files.blobs", "logs.read-offset", "bank.settlement"] + (["host.decommission"] if self.can_uninstall() else []) + (["agent.update"] if update["enabled"] else [])
plutonium_cache = self.plutonium_cache_inventory() plutonium_cache = self.plutonium_cache_inventory()
return {"hostname": socket.gethostname(), "agentVersion": VERSION, "protocolVersion": 1, "capabilities": capabilities, "agentUpdate": update, "agentLanes": self.lane_inventory(), "os": os_name, "osVersion": platform.release(), "architecture": platform.machine(), "cpu": platform.processor() or platform.machine(), "cpuCount": os.cpu_count() or 0, "temperatures": temperatures, **resources, **({"cpuUsagePercent": cpu_usage} if cpu_usage is not None else {}), "memoryTotal": memory.get("MemTotal", 0), "memoryAvailable": memory.get("MemAvailable", 0), "storageTotal": usage.total, "storageAvailable": usage.free, "dockerVersion": docker_version, "dockerRunning": docker_running, "directoriesWritable": os.access(self.root, os.W_OK), "runtimeImages": sorted(set(images)), "preparedRuntimeImages": sorted(set(prepared_images)), **({"plutoniumCache": plutonium_cache} if plutonium_cache else {}), "libraries": libraries, "libraryCaches": caches, "currentOperation": self.current_operation, "portRange": self.config.get("portRange", {"min": 28960, "max": 29060})} return {"hostname": socket.gethostname(), "agentVersion": VERSION, "protocolVersion": 1, "capabilities": capabilities, "agentUpdate": update, "agentLanes": self.lane_inventory(), "os": os_name, "osVersion": platform.release(), "architecture": platform.machine(), "cpu": platform.processor() or platform.machine(), "cpuCount": os.cpu_count() or 0, "temperatures": temperatures, **resources, **({"cpuUsagePercent": cpu_usage} if cpu_usage is not None else {}), "memoryTotal": memory.get("MemTotal", 0), "memoryAvailable": memory.get("MemAvailable", 0), "storageTotal": usage.total, "storageAvailable": usage.free, "dockerVersion": docker_version, "dockerRunning": docker_running, "directoriesWritable": os.access(self.root, os.W_OK), "runtimeImages": sorted(set(images)), "preparedRuntimeImages": sorted(set(prepared_images)), **({"plutoniumCache": plutonium_cache} if plutonium_cache else {}), "libraries": libraries, "libraryCaches": caches, "currentOperation": self.current_operation, "portRange": self.config.get("portRange", {"min": 28960, "max": 29060})}
@@ -1964,6 +1967,8 @@ class Agent:
def create(self, instance_id, generation, payload, *, before=None, prepared_files=None): def create(self, instance_id, generation, payload, *, before=None, prepared_files=None):
self.operation_phase = "checking-instance" self.operation_phase = "checking-instance"
if payload.get("moderation") is not None:
self.moderation_config(payload["moderation"], generation)
self.check_creation_cancellation() self.check_creation_cancellation()
directory = self.instance_dir(instance_id, generation) directory = self.instance_dir(instance_id, generation)
current_marker = directory.parent / "current.json" current_marker = directory.parent / "current.json"
@@ -2025,18 +2030,92 @@ class Agent:
for item in files: for item in files:
require_public_file(item["path"]) require_public_file(item["path"])
bank = payload.get("bank") bank = payload.get("bank")
if bank is None: if bank is not None:
return files
if not isinstance(bank, dict) or not isinstance(bank.get("token"), str) or not re.fullmatch(r"ec_bank_[A-Za-z0-9_-]{32,128}", bank["token"]): if not isinstance(bank, dict) or not isinstance(bank.get("token"), str) or not re.fullmatch(r"ec_bank_[A-Za-z0-9_-]{32,128}", bank["token"]):
raise Rejected("Invalid instance bank credential") raise Rejected("Invalid instance bank credential")
path = bank.get("configPath") path = bank.get("configPath")
scoped(self.root / "agent" / "validation", path) scoped(self.root / "agent" / "validation", path)
if PurePosixPath(path).name != "electric_cherry_bank.cfg": if PurePosixPath(path).name != "electric_cherry_bank.cfg":
raise Rejected("Invalid private bank config path") raise Rejected("Invalid private bank config path")
# The game scripts use the public bank API; the host controller address # Game transport uses its public URL, never the agent's enrollment URL.
# is only for agent transport and must not become a game URL override.
content = f'set tws_bank_token "{bank["token"]}"\n'.encode() content = f'set tws_bank_token "{bank["token"]}"\n'.encode()
return files + [{"path": path, "contentBase64": base64.b64encode(content).decode()}] files = files + [{"path": path, "contentBase64": base64.b64encode(content).decode()}]
if payload.get("moderation") is not None:
content = self.moderation_config(payload["moderation"])
args = payload.get("env", {}).get("PLUTAINER_EXTRA_ARGS", "")
if not self.config_exec(args, "electric_cherry_moderation.cfg"):
raise Rejected("Moderation configuration must be loaded by the launch arguments")
files = files + [{"path": MODERATION_CONFIG_PATH, "contentBase64": base64.b64encode(content).decode()}]
return files
@staticmethod
def config_exec(args, filename):
return isinstance(args, str) and re.search(r"(?:^|\s)\+exec\s+" + re.escape(filename) + r"(?=\s|$)", args) is not None
@staticmethod
def moderation_config(moderation, generation=None):
if not isinstance(moderation, dict) or set(moderation) != {"token", "configPath", "url", "generation"}:
raise Rejected("Invalid moderation configuration")
token, url, target = moderation["token"], moderation["url"], moderation["generation"]
if not isinstance(token, str) or not re.fullmatch(r"ec_moderation_[A-Za-z0-9_-]{43}", token):
raise Rejected("Invalid instance moderation credential")
if type(target) is not int or target < 1 or generation is not None and target != generation:
raise Rejected("Moderation credential belongs to a different instance generation")
if moderation["configPath"] != MODERATION_CONFIG_PATH:
raise Rejected("Invalid private moderation config path")
if not isinstance(url, str) or len(url) > 2048 or re.search(r'[\s\x00-\x1f\x7f";\\]', url):
raise Rejected("Invalid moderation controller URL")
try:
parsed = urllib.parse.urlsplit(url)
if parsed.scheme != "https" or not parsed.hostname or parsed.username or parsed.password or parsed.path != "/api/moderation/runtime" or parsed.query or parsed.fragment or parsed.port == 0:
raise ValueError()
except ValueError:
raise Rejected("Invalid moderation controller URL") from None
return f'set tws_moderation_url "{url}"\nset tws_moderation_token "{token}"\nset tws_moderation_generation "{target}"\n'.encode()
@staticmethod
def private_config_bytes(app, path):
with managed_open(app, path) as stream:
content = stream.read(8193)
if len(content) > 8192:
raise Rejected("Private instance configuration exceeds the supported size")
return content
def moderation_bootstrap(self, instance_id, generation):
saved = self.spec(instance_id, generation)
args = saved.get("env", {}).get("PLUTAINER_EXTRA_ARGS", "")
if self.config_exec(args, "electric_cherry_moderation.cfg"):
return None
if not self.config_exec(args, "electric_cherry_bank.cfg"):
raise Rejected("Rebuild this server before configuring moderation; startup cannot load the private configuration")
app = self.instance_dir(instance_id, generation) / "app"
try:
bank = self.private_config_bytes(app, "configs/electric_cherry_bank.cfg")
except FileNotFoundError:
raise Rejected("Rebuild this server before configuring moderation; private startup configuration is missing") from None
if not re.fullmatch(rb'set tws_bank_token "ec_bank_[A-Za-z0-9_-]{32,128}"\r?\n(?:exec electric_cherry_moderation.cfg\r?\n)?', bank):
raise Rejected("Rebuild this server before configuring moderation; private startup configuration cannot be verified")
return bank if bank.splitlines()[-1] == MODERATION_INCLUDE.rstrip(b"\n") else bank + MODERATION_INCLUDE
def configure_moderation(self, instance_id, generation, payload):
if set(payload) != {"moderation"}:
raise Rejected("Invalid moderation configuration request")
content = self.moderation_config(payload["moderation"], generation)
health = self.inspect(instance_id, generation)
if not health["exists"]:
raise Rejected("Instance does not exist")
bank = self.moderation_bootstrap(instance_id, generation)
app = self.instance_dir(instance_id, generation) / "app"
self.operation_phase = "configuring-moderation"
self.checkpoint({"phase": "configuring-moderation", "generation": generation})
uid, gid = int(self.config.get("runtimeUid", 1000)), int(self.config.get("runtimeGid", 1000))
managed_write(app, MODERATION_CONFIG_PATH, content, uid, gid)
if bank is not None:
managed_write(app, "configs/electric_cherry_bank.cfg", bank, uid, gid)
# Loading fixed dvars is not a restart. Never retain a reply which could
# echo credentials, and never replay the command after a lost response.
loaded = self.rcon(instance_id, generation, "exec electric_cherry_moderation.cfg").get("ok", False) if health["running"] else False
return {"configured": True, "generation": generation, "liveLoad": "sent" if loaded else "unconfirmed" if health["running"] else "deferred"}
def write_initial_files(self, root, prepared_files): def write_initial_files(self, root, prepared_files):
for path, content in prepared_files.items(): for path, content in prepared_files.items():
@@ -2169,6 +2248,8 @@ class Agent:
return self.create(instance_id, generation, payload) return self.create(instance_id, generation, payload)
if kind == "instance.inspect": if kind == "instance.inspect":
return self.inspect(instance_id, generation) return self.inspect(instance_id, generation)
if kind == "instance.configure-moderation":
return self.configure_moderation(instance_id, generation, payload)
health = self.inspect(instance_id, generation) health = self.inspect(instance_id, generation)
name = self.container_name(instance_id, generation) name = self.container_name(instance_id, generation)
before = [] before = []
@@ -2198,6 +2279,8 @@ class Agent:
if isinstance(next_generation, bool) or not isinstance(next_generation, int) or next_generation <= generation: if isinstance(next_generation, bool) or not isinstance(next_generation, int) or next_generation <= generation:
raise AgentError("Rebuild must use a newer immutable generation") raise AgentError("Rebuild must use a newer immutable generation")
spec = payload.get("spec") or {key: value for key, value in payload.items() if key not in ("nextGeneration", "newGeneration", "unhealthyRecovery")} spec = payload.get("spec") or {key: value for key, value in payload.items() if key not in ("nextGeneration", "newGeneration", "unhealthyRecovery")}
if spec.get("moderation") is not None:
self.moderation_config(spec["moderation"], next_generation)
# Keep the complete verified set on this call's stack only. Never # Keep the complete verified set on this call's stack only. Never
# mutate the immutable spec or cache bytes on the shared agent: # mutate the immutable spec or cache bytes on the shared agent:
# held, failed and later operations must prepare their own files. # held, failed and later operations must prepare their own files.
@@ -3001,6 +3084,16 @@ class Agent:
return self.journal.update(operation["id"], "failed", {"autoscalingInvalidated": True}, error="Interrupted autoscaling removal requires a new decision") return self.journal.update(operation["id"], "failed", {"autoscalingInvalidated": True}, error="Interrupted autoscaling removal requires a new decision")
if kind == "instance.cancel-create": if kind == "instance.cancel-create":
return self.journal.update(operation["id"], "completed", self.cancel_creation(instance_id, generation, payload, operation["id"])) return self.journal.update(operation["id"], "completed", self.cancel_creation(instance_id, generation, payload, operation["id"]))
if kind == "instance.configure-moderation":
current = json.loads((self.instance_dir(instance_id, generation).parent / "current.json").read_text())
if current.get("generation") != generation or current.get("removed"):
return self.journal.update(operation["id"], "failed", error="Moderation configuration generation was superseded or removed")
content = self.moderation_config(payload.get("moderation"), generation)
app = self.instance_dir(instance_id, generation) / "app"
bank = self.moderation_bootstrap(instance_id, generation)
if self.private_config_bytes(app, MODERATION_CONFIG_PATH) != content or bank is not None and self.private_config_bytes(app, "configs/electric_cherry_bank.cfg") != bank:
return row
return self.journal.update(operation["id"], "completed", {"configured": True, "generation": generation, "liveLoad": "unconfirmed", "reconciled": True})
if kind == "instance.create": if kind == "instance.create":
marker = self.instance_dir(instance_id, generation).parent / "current.json" marker = self.instance_dir(instance_id, generation).parent / "current.json"
if marker.exists(): if marker.exists():
+127 -2
View File
@@ -1650,10 +1650,12 @@ class AgentTests(unittest.TestCase):
self.assertEqual(self.agent.execute(self.operation("instance.remove"))["state"], "failed") self.assertEqual(self.agent.execute(self.operation("instance.remove"))["state"], "failed")
self.assertFalse(any(call[:2] == ("container", "stop") for call in self.docker.calls)) self.assertFalse(any(call[:2] == ("container", "stop") for call in self.docker.calls))
def test_private_bank_file_survives_stale_file_plans_without_snapshots(self): def test_private_moderation_file_survives_stale_file_plans_without_snapshots(self):
self.test_private_bank_file_survives_stale_file_plans_without_snapshots(value=module.MODERATION_CONFIG_PATH)
def test_private_bank_file_survives_stale_file_plans_without_snapshots(self, value="configs/electric_cherry_bank.cfg"):
self.create() self.create()
app = self.agent.instance_dir(INSTANCE, 1) / "app" app = self.agent.instance_dir(INSTANCE, 1) / "app"
value = "configs/electric_cherry_bank.cfg"
target = app / value target = app / value
secret = b'set tws_bank_token "ec_bank_private_regression"\n' secret = b'set tws_bank_token "ec_bank_private_regression"\n'
target.write_bytes(secret) target.write_bytes(secret)
@@ -1707,6 +1709,129 @@ class AgentTests(unittest.TestCase):
with self.assertRaises(AgentError): with self.assertRaises(AgentError):
self.agent.initial_files(payload) self.agent.initial_files(payload)
def moderation_configuration(self, generation=1):
return {"token": "ec_moderation_" + "m" * 43, "configPath": module.MODERATION_CONFIG_PATH,
"url": "https://public-controller.example/api/moderation/runtime", "generation": generation}
def create_moderation_bootstrap(self, *, direct=False, game="t5"):
payload = {"name": "server", "game": game, "libraryId": self.library_id,
"image": "ghcr.io/ayymoss/plutainer:latest", "port": 28960, "rconPassword": "secret",
"env": {"PLUTAINER_EXTRA_ARGS": "+exec electric_cherry_bank.cfg +map_rotate"},
"bank": {"token": "ec_bank_" + "b" * 43, "configPath": "configs/electric_cherry_bank.cfg"}}
if direct:
payload["moderation"] = self.moderation_configuration()
payload["env"]["PLUTAINER_EXTRA_ARGS"] = "+exec electric_cherry_moderation.cfg +map_rotate"
row = self.agent.execute(self.operation("instance.create", payload, "create"))
self.assertEqual(row["state"], "completed", row)
return payload
def test_t6_create_materializes_private_moderation_configuration(self):
self.prepare_t6_library()
self.test_create_materializes_private_moderation_configuration(game="t6")
def test_create_materializes_private_moderation_configuration(self, game="t5"):
payload = self.create_moderation_bootstrap(direct=True, game=game)
target = self.agent.instance_dir(INSTANCE, 1) / "app" / module.MODERATION_CONFIG_PATH
self.assertEqual(target.read_bytes(), self.agent.moderation_config(payload["moderation"], 1))
self.assertNotIn(self.config["controller"], target.read_text())
self.assertIn("moderation.configure", self.agent.inventory()["capabilities"])
self.assertNotIn(payload["moderation"]["token"], self.agent.journal.get("create")["result"])
def test_configure_moderation_persists_existing_startup_and_discards_rcon_secrets(self):
saved = self.create_moderation_bootstrap()
payload = {"moderation": self.moderation_configuration()}
operation = self.operation("instance.configure-moderation", payload)
with patch.object(self.agent, "rcon", return_value={"ok": True, "text": payload["moderation"]["token"]}) as command:
row = self.agent.execute(operation)
self.assertEqual(row["state"], "completed", row)
self.assertEqual(self.agent.execute(operation), row)
command.assert_called_once_with(INSTANCE, 1, "exec electric_cherry_moderation.cfg")
self.assertNotIn(payload["moderation"]["token"], row["result"])
app = self.agent.instance_dir(INSTANCE, 1) / "app"
self.assertEqual((app / module.MODERATION_CONFIG_PATH).read_bytes(), self.agent.moderation_config(payload["moderation"], 1))
bank = (app / "configs/electric_cherry_bank.cfg").read_bytes()
self.assertEqual(bank, f'set tws_bank_token "{saved["bank"]["token"]}"\n'.encode() + module.MODERATION_INCLUDE)
self.assertEqual(self.agent.spec(INSTANCE, 1), saved)
bank = bank.replace(b"\n", b"\r\n")
(app / "configs/electric_cherry_bank.cfg").write_bytes(bank)
with patch.object(self.agent, "rcon", return_value={"ok": False, "text": "timeout"}):
again = self.agent.execute(self.operation("instance.configure-moderation", payload, "configure-again"))
self.assertEqual(json.loads(again["result"])["liveLoad"], "unconfirmed")
self.assertEqual((app / "configs/electric_cherry_bank.cfg").read_bytes(), bank)
self.assertFalse(any(call[:2] == ("container", "restart") for call in self.docker.calls))
def test_configure_moderation_rejects_unknown_startup_without_writing(self):
self.create()
with patch.object(self.agent, "rcon") as command:
row = self.agent.execute(self.operation("instance.configure-moderation", {"moderation": self.moderation_configuration()}))
self.assertEqual(row["state"], "failed", row)
self.assertIn("Rebuild this server", row["error"])
self.assertFalse((self.agent.instance_dir(INSTANCE, 1) / "app" / module.MODERATION_CONFIG_PATH).exists())
command.assert_not_called()
def test_configure_moderation_requires_readable_verified_private_startup(self):
self.create_moderation_bootstrap()
app = self.agent.instance_dir(INSTANCE, 1) / "app"
bank = app / "configs/electric_cherry_bank.cfg"
for index, content in enumerate((None, b'unknown startup commands\n')):
with self.subTest(content=content), patch.object(self.agent, "rcon") as command:
bank.unlink(missing_ok=True)
if content is not None:
bank.write_bytes(content)
row = self.agent.execute(self.operation("instance.configure-moderation", {"moderation": self.moderation_configuration()}, f"invalid-bootstrap-{index}"))
self.assertEqual(row["state"], "failed", row)
self.assertIsNone(row["checkpoint"])
self.assertFalse((app / module.MODERATION_CONFIG_PATH).exists())
command.assert_not_called()
def test_configure_moderation_validates_credentials_generation_url_and_private_path(self):
self.create_moderation_bootstrap()
for index, change in enumerate((
{"token": 'ec_moderation_' + 'm' * 43 + '\nset other "1"'},
{"generation": 2}, {"generation": True}, {"configPath": "configs/other.cfg"},
{"url": "http://controller.example/api/moderation/runtime"},
{"url": "https://user:secret@controller.example/api/moderation/runtime"},
{"url": "https://controller.example/api/moderation/runtime?secret=1"},
{"url": 'https://controller.example/api/moderation/runtime";quit'},
{"unknown": "value"}
)):
with self.subTest(change=change), patch.object(self.agent, "rcon") as command:
payload = {"moderation": {**self.moderation_configuration(), **change}}
row = self.agent.execute(self.operation("instance.configure-moderation", payload, f"bad-moderation-{index}"))
self.assertEqual(row["state"], "failed", row)
self.assertIsNone(row["checkpoint"])
command.assert_not_called()
self.assertFalse((self.agent.instance_dir(INSTANCE, 1) / "app" / module.MODERATION_CONFIG_PATH).exists())
def test_configure_moderation_reconciles_written_configuration_without_replaying_rcon(self):
self.create_moderation_bootstrap()
operation = self.operation("instance.configure-moderation", {"moderation": self.moderation_configuration()})
with patch.object(self.agent, "rcon", side_effect=RuntimeError("secret must not leak")) as command:
unknown = self.agent.execute(operation)
self.assertEqual(unknown["state"], "unknown", unknown)
row = self.agent.execute(operation)
self.assertEqual(row["state"], "completed", row)
command.assert_called_once()
self.assertEqual(json.loads(row["result"])["liveLoad"], "unconfirmed")
self.assertNotIn("secret must not leak", str(unknown.get("error")))
def test_rebuild_rejects_moderation_credentials_for_the_previous_generation_before_disruption(self):
saved = self.create_moderation_bootstrap(direct=True)
row = self.agent.execute(self.operation("instance.rebuild", {"spec": saved, "nextGeneration": 2}, "rebuild"))
self.assertEqual(row["state"], "failed", row)
self.assertTrue(self.agent.inspect(INSTANCE, 1)["running"])
self.assertFalse(any(call[:2] == ("container", "stop") for call in self.docker.calls))
def test_rebuild_writes_moderation_credentials_for_only_the_replacement_generation(self):
saved = self.create_moderation_bootstrap(direct=True)
saved["moderation"] = {**self.moderation_configuration(2), "token": "ec_moderation_" + "n" * 43}
row = self.agent.execute(self.operation("instance.rebuild", {"spec": saved, "nextGeneration": 2, "safetyOverride": True}, "rebuild"))
self.assertEqual(row["state"], "completed", row)
app = self.agent.instance_dir(INSTANCE, 2) / "app"
self.assertEqual((app / module.MODERATION_CONFIG_PATH).read_bytes(), self.agent.moderation_config(saved["moderation"], 2))
self.assertNotIn(saved["moderation"]["token"], row["result"])
self.assertTrue(self.agent.inspect(INSTANCE, 2)["running"])
def bank_journal(self, decisions=("commit", "cancel"), game="t5", endpoint=module.BANK_GAME_ENDPOINT): def bank_journal(self, decisions=("commit", "cancel"), game="t5", endpoint=module.BANK_GAME_ENDPOINT):
path = self.agent.instance_dir(INSTANCE, 1) / f"app/runtime/tws-bank/{game}/{module.digest(endpoint.encode())}.json" path = self.agent.instance_dir(INSTANCE, 1) / f"app/runtime/tws-bank/{game}/{module.digest(endpoint.encode())}.json"
reports = [json.dumps({"server": "t5_zombie_test_1", "guid": "ace", "match": "match-one", "request_id": f"{game}-{i}", "operation": "deposit", "amount": 2000, "capacity": 1000000, "decision": decision}) for i, decision in enumerate(decisions)] reports = [json.dumps({"server": "t5_zombie_test_1", "guid": "ace", "match": "match-one", "request_id": f"{game}-{i}", "operation": "deposit", "amount": 2000, "capacity": 1000000, "decision": decision}) for i, decision in enumerate(decisions)]
+1
View File
@@ -26,6 +26,7 @@ and launch your first server.
- [Plugin releases](guides/plugin-releases.md) — GitHub release discovery and imports. - [Plugin releases](guides/plugin-releases.md) — GitHub release discovery and imports.
- [Metrics](guides/metrics.md) — utilization, capacity pressure, and hosting experiments. - [Metrics](guides/metrics.md) — utilization, capacity pressure, and hosting experiments.
- [Player bank](guides/bank.md) — shared balances, per-server credentials, transfers and recovery. - [Player bank](guides/bank.md) — shared balances, per-server credentials, transfers and recovery.
- [Player moderation](guides/moderation.md) — restrictions, live actions, cases, evidence and game integration.
- [Leaderboard and matches](guides/leaderboard.md) — player statistics, match history, and the read API. - [Leaderboard and matches](guides/leaderboard.md) — player statistics, match history, and the read API.
## Development ## Development
+7
View File
@@ -31,6 +31,13 @@ guessable token. An unset setup token holds setup closed.
## Roles ## Roles
The Moderator role has Viewer-level fleet access plus private player moderation.
It permits live player actions, case review and server/game restrictions lasting
up to seven days, without granting fleet operations or access to raw configs and
credentials. Administrators also control permanent/fleet restrictions, integration
configuration and revoking another moderator's restriction. Super Viewers can
read moderation; Operators and Viewers cannot. See [Player moderation](moderation.md).
| Capability | Viewer | Super Viewer | Operator | Administrator | | Capability | Viewer | Super Viewer | Operator | Administrator |
| --------------------------------------------------------------------------------------------- | -------------- | ------------ | ----------- | ------------- | | --------------------------------------------------------------------------------------------- | -------------- | ------------ | ----------- | ------------- |
| Fleet, metrics, matches and operation summaries | Yes | Yes | Yes | Yes | | Fleet, metrics, matches and operation summaries | Yes | Yes | Yes | Yes |
+159
View File
@@ -0,0 +1,159 @@
# Player moderation
**Players → Moderation** provides player search, warnings, private messages,
kicks, announcements, bans, mutes, case records and private history. Restrictions
can cover a server, a game or the fleet. Saving a restriction records the policy;
the coverage table shows whether game runtimes have synchronized it.
## Access
Administrators manage all moderation controls and configure integrations.
Moderators can read private history, create notes/cases, send live actions, and
issue server/game restrictions lasting at most seven days. They can revoke their
own server/game restrictions originally lasting at most seven days. Applying or
revoking permanent, longer or fleet restrictions, and revoking another staff
member's restriction, require Administrator access. These limits also apply after
an administrator becomes a Moderator. Super Viewers can inspect
moderation but cannot change it. Operators and Viewers cannot read private
moderation data. Moderator does not grant fleet operation, script, plugin,
credential or account-management privileges.
Assign Moderator through **Admin → Users**. Existing users retain their roles;
changing a role revokes that user's sessions. Each service rechecks current
authorization, including immediately before delivering a queued live action.
## Player identity and restrictions
Select a player from Moderation, a live scoreboard or a player profile. Search
matches known aliases as well as GUIDs. A display name never identifies a
restriction target. Manually entered GUIDs can have case records and restrictions
before gameplay is observed; their first/last seen dates remain **Not recorded**.
IDs remain strings. Numeric game IDs are normalized without losing precision;
T6 RCON hexadecimal IDs have a separate conversion rule. Historical chat links
search for the raw log identity because its encoding is not assumed. Confirm a
known player identity before applying a restriction. Fleet restrictions require
the same numeric account ID to have been observed in both games. Otherwise use
game scope.
Every restriction has a player-visible reason, optional private note, scope,
expiry, creator and revision. Reasons and live messages must fit in 240 UTF-8
bytes. The native adapter replaces console control characters in displayed text.
Overlapping restrictions remain independent: revoking one mute does not cancel
another active mute. Revocation requires a reason and the current revision;
concurrent changes require refreshing before another decision.
Server scope follows a logical server registration across instance replacement
or rebuild. Deleting and later registering the same server name creates a new
registration, which does not inherit the old server-scoped restriction.
Game/fleet policies apply to every connected compatible runtime in that scope,
including newly provisioned servers. A new runtime must synchronize before it
has a complete policy; provisioning readiness alone does not prove moderation
coverage. The initial limit is 1,000 active restrictions across the controller.
## Enable the game integration
The controller requires `EC_PUBLIC_URL` to be a public HTTPS origin. The host
agent must advertise `moderation.configure` (agent 1.11.0+). Install the matching
TWS moderation script 1.0.0, common 1.2.0+, and TWS native plugin 1.3.0+ from
`tws-zombies-scripts` for each game. New native plugins require a full
game-server process restart. Use the normal release/beta workflow and validate
one T5 Zombies and one T6 Zombies instance before fleet rollout.
New launches/rebuilds on capable agents receive a private moderation config when
`EC_PUBLIC_URL` is configured. Existing instances can use **Enable integration**
in Moderation. This queues a typed operation that writes the fixed private config
and loads its dvars, without restarting gameplay. Existing startup persistence
uses the verified private bank config; older instances without that bootstrap
must be rebuilt. Installed moderation scripts retry missing configuration.
The private file `configs/electric_cherry_moderation.cfg` contains the HTTPS
endpoint, generation and a separate per-instance credential. It is excluded from
shared artifacts, file reads, drift scans, logs and operation receipts. Game
credentials are distinct from bank, telemetry and host credentials. They only
authorize the active immutable instance generation; retirement/rebuild revokes
the previous generation's access. Older agents retain ordinary server launches,
but cannot provide this integration.
Coverage states describe runtime communication:
- **Policy current:** a fresh runtime supports both bans and mutes and has
acknowledged the current policy revision.
- **Pending:** the runtime is connected but has not acknowledged the latest revision
or does not support both bans and mutes.
- **Offline:** no runtime synchronization has arrived for more than 15 seconds.
- **Unavailable:** this instance generation has not connected.
Policy current is not proof that an engine mute command took effect. The runtime
reports supported capabilities; unsupported live actions stay unavailable.
Native kick/mute command behavior and voice/text suppression require testing on
the deployed Plutonium build. A disconnected host cannot receive a new ban or
revocation until it reconnects.
## Live actions and offline enforcement
Live actions expire after 30 seconds and target the runtime's exact player
connection. A reconnect, reused slot, changed generation or revoked moderator
access prevents stale dispatch. Browser retries preserve the request identity.
The native adapter journals one-time actions before dispatch and never executes
the same action ID twice, including after restart.
Queued actions can become delivered, confirmed, failed, expired or unknown.
An expired undelivered action did not run. Unknown means delivery occurred but
the result could not be established; do not assume the action failed. A durable
late receipt can resolve an unknown outcome. Confirmation of a message means
server dispatch, not proof the recipient read it. Kick confirmation requires an
observed disconnect.
The native runtime caches the complete applicable policy on persistent storage,
scoped to the server, endpoint, generation and credential fingerprint. Cached
bans/mutes continue through controller outages and expire using the host clock.
Keep host time synchronized. A ban disconnects a player after connecting; it is
not a pre-authentication admission filter. The shared connection handler and a
500 ms reconciliation loop enforce it, retrying still-connected banned players
every five seconds. No native `ban.txt` entries are rewritten.
Preserve `tws-moderation/t5/` or `tws-moderation/t6/` in the game working directory.
The cache includes an increasing runtime session counter that prevents an older
process from replacing the active player roster. Losing or rolling back this
cache requires a generation rebuild with a new credential before synchronization
can resume; re-enabling the same integration does not reset the counter.
Corrupt caches and failed durable writes stop new actions and require operator
investigation. A controller restored to an older policy revision cannot silently
replace a newer cached revision; review the restore and integration before
resetting its cache or credentials.
## Cases, evidence and review
Create a case for a report, investigation or appeal. Staff notes are explicitly
labeled as notes. Selecting archived chat verifies the target GUID and original
message against the recorded match and saves an independent snapshot, including
the original timing context. Unknown or estimated timestamps remain such; the
snapshot does not turn them into exact observations. Unarchived or already
deleted chat cannot be attached as verified evidence.
Cases can be resolved or dismissed with a recorded reason. Evidence, restrictions
and append-only moderation events survive source-match cleanup and server
deletion. They currently have no automatic retention cleanup. Authentication
audit cleanup does not remove moderation history. Correct decisions by adding
notes or revoking restrictions; there is no history-erasing delete endpoint.
Leaderboard disqualification, bank balance changes, automatic escalation and a
public report/appeal portal are separate features. This workflow supports staff
review of reports and appeals without exposing private evidence publicly.
## Development and acceptance
Controller tests cover permission boundaries, migrations, idempotency, exact
instance credentials, stale connections, expired actions, late receipts,
restriction expiry/revocation, policy coverage and evidence preservation. Agent
tests cover private configuration delivery and secret redaction. Companion
native tests exercise real policy/journal code and adapters with engine shims.
Before deploying, build the x86 native plugins on Windows and validate both
games: duplicate names, account reconnects, slot reuse, kick reasons, voice/text
mute/unmute, overlapping restrictions, map/process restart, new server policy
sync, expiry, revocation, controller outage and persistent-storage failure.
Portable tests cannot establish the live Plutonium ABI or engine behavior.
Package and offline-sign the agent release before publishing it; local builds
remove any stale manifest signature and do not sign or deploy the release.
+12 -1
View File
@@ -1,4 +1,6 @@
export type UserRole = 'admin' | 'super_viewer' | 'operator' | 'viewer'; export type UserRole = 'admin' | 'super_viewer' | 'operator' | 'moderator' | 'viewer';
export const moderatorMaximumRestrictionMs = 7 * 24 * 60 * 60 * 1000;
/** Reserved identity for work requested by the controller. */ /** Reserved identity for work requested by the controller. */
export const systemUsername = 'Maxis'; export const systemUsername = 'Maxis';
@@ -19,6 +21,7 @@ export const roleLabels: Record<UserRole, string> = {
admin: 'Administrator', admin: 'Administrator',
super_viewer: 'Super Viewer', super_viewer: 'Super Viewer',
operator: 'Operator', operator: 'Operator',
moderator: 'Moderator',
viewer: 'Viewer' viewer: 'Viewer'
}; };
@@ -33,3 +36,11 @@ export function canReadAdmin(role: UserRole | null | undefined): boolean {
export function canReadSensitive(role: UserRole | null | undefined): boolean { export function canReadSensitive(role: UserRole | null | undefined): boolean {
return canOperate(role) || role === 'super_viewer'; return canOperate(role) || role === 'super_viewer';
} }
export function canModerate(role: UserRole | null | undefined): boolean {
return role === 'admin' || role === 'moderator';
}
export function canReadModeration(role: UserRole | null | undefined): boolean {
return canModerate(role) || role === 'super_viewer';
}
+128
View File
@@ -0,0 +1,128 @@
import { moderatorMaximumRestrictionMs, type AuthUser } from './auth.js';
export type ModerationGame = 't5' | 't6';
export type ModerationScope = 'server' | 'game' | 'fleet';
export type ModerationActionKind = 'warn' | 'message' | 'kick' | 'announce';
export type ModerationState =
'queued' | 'delivered' | 'confirmed' | 'failed' | 'expired' | 'unknown';
export interface ModerationPlayer {
game: ModerationGame;
guid: string;
name: string;
aliases: string[];
firstSeen: string | null;
lastSeen: string | null;
servers: string[];
restrictions: number;
}
export interface ModerationSanction {
id: string;
game: ModerationGame;
guid: string;
kind: 'ban' | 'mute';
scope: ModerationScope;
server: string | null;
reason: string;
note: string;
createdAt: string;
expiresAt: string | null;
revokedAt: string | null;
actorId: string;
actor: string;
revision: number;
active: boolean;
}
export function canRevokeModerationRestriction(
actor: Pick<AuthUser, 'id' | 'role'> | null | undefined,
restriction: Pick<ModerationSanction, 'actorId' | 'scope' | 'createdAt' | 'expiresAt'>
): boolean {
if (actor?.role === 'admin') return true;
if (
actor?.role !== 'moderator' ||
actor.id !== restriction.actorId ||
(restriction.scope !== 'server' && restriction.scope !== 'game') ||
!restriction.expiresAt
)
return false;
const duration = Date.parse(restriction.expiresAt) - Date.parse(restriction.createdAt);
return duration > 0 && duration <= moderatorMaximumRestrictionMs;
}
export interface ModerationAction {
id: string;
game: ModerationGame;
guid: string | null;
server: string;
kind: ModerationActionKind;
reason: string;
actor: string;
createdAt: string;
expiresAt: string;
state: ModerationState;
detail: string | null;
}
export interface ModerationCase {
id: string;
game: ModerationGame;
guid: string;
title: string;
status: 'open' | 'resolved' | 'dismissed';
actor: string;
createdAt: string;
updatedAt: string;
revision: number;
evidence: { id: string; text: string; source: string; createdAt: string; actor: string }[];
}
export interface ModerationEvent {
id: number;
game: ModerationGame | null;
guid: string | null;
action: string;
subjectId: string | null;
actor: string;
detail: string;
createdAt: string;
}
export interface ModerationCoverage {
server: string;
game: ModerationGame;
connectedAt: string | null;
revision: number | null;
desiredRevision: number;
state: 'current' | 'pending' | 'offline' | 'unavailable';
capabilities: string[];
}
export interface ModerationQuery {
view: 'players' | 'restrictions' | 'cases' | 'history';
q: string;
game: '' | ModerationGame;
guid: string;
offset: number;
}
export interface ModerationOverview {
query: ModerationQuery;
players: ModerationPlayer[];
sanctions: ModerationSanction[];
cases: ModerationCase[];
events: ModerationEvent[];
actions: ModerationAction[];
coverage: ModerationCoverage[];
total: number;
limit: number;
checkedAt: string;
}
/** RCON T6 IDs are hexadecimal, while the game scripts send decimal strings. */
export function moderationGuid(
game: ModerationGame,
guid: string,
source: 'game' | 'rcon' = 'game'
): string {
const value = guid.trim();
if (game === 't6' && source === 'rcon' && /^(?:0x)?[0-9a-f]+$/i.test(value))
return BigInt(`0x${value.replace(/^0x/i, '')}`).toString();
if (/^\d+$/.test(value)) return BigInt(value).toString();
return value;
}
+9 -2
View File
@@ -1,9 +1,10 @@
import { canReadAdmin, type UserRole } from './auth.js'; import { canReadAdmin, canReadModeration, type UserRole } from './auth.js';
export interface NavigationItem { export interface NavigationItem {
label: string; label: string;
href: string; href: string;
shortcut?: string; shortcut?: string;
moderation?: boolean;
matches?: (path: string) => boolean; matches?: (path: string) => boolean;
} }
export interface NavigationGroup { export interface NavigationGroup {
@@ -71,6 +72,7 @@ export const navigationGroups: NavigationGroup[] = [
{ label: 'Leaderboard', href: '/leaderboard', shortcut: 'l' }, { label: 'Leaderboard', href: '/leaderboard', shortcut: 'l' },
{ label: 'Matches', href: '/matches' }, { label: 'Matches', href: '/matches' },
{ label: 'Bank', href: '/bank' }, { label: 'Bank', href: '/bank' },
{ label: 'Moderation', href: '/moderation', moderation: true },
{ label: 'Usage', href: '/metrics', shortcut: 'm' } { label: 'Usage', href: '/metrics', shortcut: 'm' }
] ]
}, },
@@ -92,7 +94,12 @@ export const navigationGroups: NavigationGroup[] = [
]; ];
export function visibleNavigation(role: UserRole): NavigationGroup[] { export function visibleNavigation(role: UserRole): NavigationGroup[] {
return navigationGroups.filter((group) => !group.admin || canReadAdmin(role)); return navigationGroups
.filter((group) => !group.admin || canReadAdmin(role))
.map((group) => ({
...group,
items: group.items.filter((item) => !item.moderation || canReadModeration(role))
}));
} }
export function navigationItemActive(item: NavigationItem, path: string): boolean { export function navigationItemActive(item: NavigationItem, path: string): boolean {
+10 -2
View File
@@ -1,4 +1,4 @@
import type { UserRole } from '../../auth.js'; import { canModerate, canReadModeration, type UserRole } from '../../auth.js';
export type RoutePermission = export type RoutePermission =
| 'public' | 'public'
@@ -7,6 +7,8 @@ export type RoutePermission =
| 'viewer' | 'viewer'
| 'operator-read' | 'operator-read'
| 'admin-read' | 'admin-read'
| 'moderation-read'
| 'moderation'
| 'operator' | 'operator'
| 'admin'; | 'admin';
@@ -39,6 +41,7 @@ export function routePermission(routeId: string | null, method: string): RoutePe
if (within(routeId, '/api/agent')) return 'machine'; if (within(routeId, '/api/agent')) return 'machine';
if (routeId === '/api/bank/[action]' && method === 'POST') return 'machine'; if (routeId === '/api/bank/[action]' && method === 'POST') return 'machine';
if (routeId === '/api/telemetry' && method === 'POST') return 'machine'; if (routeId === '/api/telemetry' && method === 'POST') return 'machine';
if (routeId === '/api/moderation/runtime' && method === 'POST') return 'machine';
if (routeId === '/api/leaderboard' && reading) return 'public'; if (routeId === '/api/leaderboard' && reading) return 'public';
if ((routeId === '/login' || routeId === '/setup') && reading) return 'public'; if ((routeId === '/login' || routeId === '/setup') && reading) return 'public';
if ((routeId === '/api/auth/login' || routeId === '/api/auth/setup') && method === 'POST') if ((routeId === '/api/auth/login' || routeId === '/api/auth/setup') && method === 'POST')
@@ -53,6 +56,9 @@ export function routePermission(routeId: string | null, method: string): RoutePe
if (routeId === '/api/settings/retention/preview' && method === 'POST') return 'admin-read'; if (routeId === '/api/settings/retention/preview' && method === 'POST') return 'admin-read';
if (within(routeId, '/api/auth')) return 'admin'; if (within(routeId, '/api/auth')) return 'admin';
if (within(routeId, '/moderation') || within(routeId, '/api/moderation'))
return reading ? 'moderation-read' : 'moderation';
if ( if (
within(routeId, '/api/users') || within(routeId, '/api/users') ||
within(routeId, '/settings') || within(routeId, '/settings') ||
@@ -94,11 +100,13 @@ export function routePermission(routeId: string | null, method: string): RoutePe
/** A browser role never substitutes for a machine credential. */ /** A browser role never substitutes for a machine credential. */
export function roleAllows(role: UserRole, permission: RoutePermission): boolean { export function roleAllows(role: UserRole, permission: RoutePermission): boolean {
if (!['viewer', 'super_viewer', 'operator', 'admin'].includes(role)) return false; if (!['viewer', 'super_viewer', 'operator', 'moderator', 'admin'].includes(role)) return false;
if (permission === 'machine') return false; if (permission === 'machine') return false;
if (permission === 'public' || permission === 'authenticated' || permission === 'viewer') if (permission === 'public' || permission === 'authenticated' || permission === 'viewer')
return true; return true;
if (permission === 'operator') return role === 'operator' || role === 'admin'; if (permission === 'operator') return role === 'operator' || role === 'admin';
if (permission === 'moderation') return canModerate(role);
if (permission === 'moderation-read') return canReadModeration(role);
if (permission === 'operator-read') if (permission === 'operator-read')
return role === 'super_viewer' || role === 'operator' || role === 'admin'; return role === 'super_viewer' || role === 'operator' || role === 'admin';
if (permission === 'admin-read') return role === 'super_viewer' || role === 'admin'; if (permission === 'admin-read') return role === 'super_viewer' || role === 'admin';
+4
View File
@@ -167,6 +167,10 @@ export const authenticationHandle: Handle = async ({ event, resolve }) => {
? 'Administrator access is required.' ? 'Administrator access is required.'
: permission === 'admin-read' : permission === 'admin-read'
? 'Administrator or Super Viewer access is required.' ? 'Administrator or Super Viewer access is required.'
: permission === 'moderation'
? 'Moderator or Administrator access is required.'
: permission === 'moderation-read'
? 'Moderator, Administrator or Super Viewer access is required.'
: permission === 'operator-read' : permission === 'operator-read'
? 'Operator, Administrator or Super Viewer access is required.' ? 'Operator, Administrator or Super Viewer access is required.'
: 'Operator access is required for this action.'; : 'Operator access is required for this action.';
+2 -2
View File
@@ -82,8 +82,8 @@ function displayName(value: unknown, fallback: string): string {
return name.trim(); return name.trim();
} }
function role(value: unknown): UserRole { function role(value: unknown): UserRole {
if (!['admin', 'super_viewer', 'operator', 'viewer'].includes(value as string)) if (!['admin', 'super_viewer', 'operator', 'moderator', 'viewer'].includes(value as string))
throw new AuthError('Choose administrator, super viewer, operator or viewer.'); throw new AuthError('Choose administrator, super viewer, operator, moderator or viewer.');
return value as UserRole; return value as UserRole;
} }
export function recordAudit( export function recordAudit(
+29
View File
@@ -61,3 +61,32 @@ export function migrateSuperViewerRole(handle: Database.Database): void {
ALTER TABLE auth_users_super_viewer RENAME TO auth_users; ALTER TABLE auth_users_super_viewer RENAME TO auth_users;
`); `);
} }
/** Run with foreign keys disabled by the migration runner before its transaction. */
export function migrateModeratorRole(handle: Database.Database): void {
const schema = handle
.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='auth_users'")
.get() as { sql: string };
if (schema.sql.includes("'moderator'")) return;
if (handle.pragma('foreign_keys', { simple: true }))
throw new Error('The auth role migration requires foreign keys to be disabled');
// Rebuild without renaming the parent: existing session references must stay
// attached to auth_users, including remembered sessions added after v17.
handle.exec(`
CREATE TABLE auth_users_moderator (
id TEXT PRIMARY KEY, username TEXT NOT NULL COLLATE NOCASE UNIQUE,
display_name TEXT NOT NULL, password_hash TEXT NOT NULL,
role TEXT NOT NULL CHECK(role IN ('admin','super_viewer','operator','moderator','viewer')),
disabled INTEGER NOT NULL DEFAULT 0, must_change_password INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, last_login_at INTEGER
);
INSERT INTO auth_users_moderator (
id,username,display_name,password_hash,role,disabled,must_change_password,
created_at,updated_at,last_login_at
) SELECT id,username,display_name,password_hash,role,disabled,must_change_password,
created_at,updated_at,last_login_at FROM auth_users;
DROP TABLE auth_users;
ALTER TABLE auth_users_moderator RENAME TO auth_users;
`);
}
+1
View File
@@ -16,6 +16,7 @@ function num(name: string, fallback: number): number {
} }
export const config = { export const config = {
publicUrl: env.EC_PUBLIC_URL ?? '',
serversRoot: req('SERVERS_ROOT', '/root/zombies/t5'), serversRoot: req('SERVERS_ROOT', '/root/zombies/t5'),
t6ServersRoot: t6ServersRoot:
env.T6_SERVERS_ROOT ?? join(dirname(req('SERVERS_ROOT', '/root/zombies/t5')), 't6'), env.T6_SERVERS_ROOT ?? join(dirname(req('SERVERS_ROOT', '/root/zombies/t5')), 't6'),
+27 -1
View File
@@ -17,7 +17,8 @@ import { migrateHostSchema } from '../hosts/schema.js';
import { import {
migrateAuthSchema, migrateAuthSchema,
migrateRememberedSessions, migrateRememberedSessions,
migrateSuperViewerRole migrateSuperViewerRole,
migrateModeratorRole
} from '../auth/schema.js'; } from '../auth/schema.js';
import { migrateGameFileSchema } from '../game-files/schema.js'; import { migrateGameFileSchema } from '../game-files/schema.js';
import { migrateLeaderboardReports } from './leaderboard-reports.js'; import { migrateLeaderboardReports } from './leaderboard-reports.js';
@@ -25,6 +26,7 @@ import { migratePlayerScoreTotal } from './player-score-total.js';
import { migrateBank } from './bank.js'; import { migrateBank } from './bank.js';
import { migrateBankCredentials } from './bank-credentials.js'; import { migrateBankCredentials } from './bank-credentials.js';
import { migrateTemperatures } from '../hosts/temperatures.js'; import { migrateTemperatures } from '../hosts/temperatures.js';
import { migrateModeration } from '../moderation/schema.js';
import type { Migration } from './migration-runner.js'; import type { Migration } from './migration-runner.js';
// Append only: never renumber entries or change an applied migration to add an // Append only: never renumber entries or change an applied migration to add an
@@ -134,6 +136,30 @@ export const migrations: readonly Migration[] = [
'ALTER TABLE native_instances ADD COLUMN generation_reconciled_at TEXT' 'ALTER TABLE native_instances ADD COLUMN generation_reconciled_at TEXT'
); );
} }
},
{
version: 25,
name: 'moderator authentication role',
mode: 'foreign-keys-off',
run: migrateModeratorRole
},
{ version: 26, name: 'player moderation records and enforcement', run: migrateModeration },
{
version: 27,
name: 'moderation runtime sessions and evidence lookup',
run: (handle) => {
handle.exec(
'CREATE INDEX IF NOT EXISTS moderation_evidence_case ON moderation_evidence(case_id)'
);
if (
!(handle.pragma('table_info(moderation_runtimes)') as { name: string }[]).some(
(c) => c.name === 'session_sequence'
)
)
handle.exec(
'ALTER TABLE moderation_runtimes ADD COLUMN session_sequence INTEGER NOT NULL DEFAULT 0'
);
}
} }
]; ];
+20 -8
View File
@@ -2,11 +2,18 @@ import { posix } from 'node:path';
import { gameOfServer, relDestForKind, type ArtifactKind } from '../config.js'; import { gameOfServer, relDestForKind, type ArtifactKind } from '../config.js';
export const BANK_CONFIG_FILE = 'electric_cherry_bank.cfg'; export const BANK_CONFIG_FILE = 'electric_cherry_bank.cfg';
export const MODERATION_CONFIG_FILE = 'electric_cherry_moderation.cfg';
export const PRIVATE_CONFIG_FILES = [BANK_CONFIG_FILE, MODERATION_CONFIG_FILE] as const;
export function isPrivateConfigPath(path: string, game = 't5'): boolean { export function isPrivateConfigPath(path: string, game = 't5'): boolean {
return ( // Moderation bootstrap has one fixed runtime location, even when shared
// artifact destinations were customized for an older installation.
if (posix.normalize(path.replaceAll('\\', '/')) === `configs/${MODERATION_CONFIG_FILE}`)
return true;
return PRIVATE_CONFIG_FILES.some(
(filename) =>
posix.normalize(path.replaceAll('\\', '/')) === posix.normalize(path.replaceAll('\\', '/')) ===
posix.join(relDestForKind('config', game), BANK_CONFIG_FILE) posix.join(relDestForKind('config', game), filename)
); );
} }
@@ -23,26 +30,31 @@ export function isPrivateConfigTarget(server: string, kind: string, path: string
export function assertPublicFileTarget(server: string, kind: ArtifactKind, path: string): void { export function assertPublicFileTarget(server: string, kind: ArtifactKind, path: string): void {
if (isPrivateConfigTarget(server, kind, path)) if (isPrivateConfigTarget(server, kind, path))
throw new Error( throw new Error(
'Private bank configuration cannot be captured or changed by a file campaign.' 'Private bank configuration and moderation configuration cannot be captured or changed by a file campaign.'
); );
} }
export function isReservedConfigFilename(path: string): boolean { export function isReservedConfigFilename(path: string): boolean {
return posix.basename(path.replaceAll('\\', '/')).toLowerCase() === BANK_CONFIG_FILE; return PRIVATE_CONFIG_FILES.some(
(filename) => posix.basename(path.replaceAll('\\', '/')).toLowerCase() === filename
);
} }
// Legacy campaign evidence omits the kind and stores the path relative to its destination. // Legacy campaign evidence omits the kind and stores the path relative to its destination.
export function isPrivateConfigEvidencePath(path: string): boolean { export function isPrivateConfigEvidencePath(path: string): boolean {
return ( return (
posix.normalize(path.replaceAll('\\', '/')) === BANK_CONFIG_FILE || PRIVATE_CONFIG_FILES.some(
isPrivateConfigPath(path) (filename) => posix.normalize(path.replaceAll('\\', '/')) === filename
) || isPrivateConfigPath(path)
); );
} }
/** Credential injection uses bank.configPath, never a shared file's path field. */ /** Credential injection uses a private configPath, never a shared file's path field. */
export class PrivateFileOperationError extends Error { export class PrivateFileOperationError extends Error {
constructor() { constructor() {
super('Private bank configuration cannot be read or changed by file operations.'); super(
'Private bank configuration and moderation configuration cannot be read or changed by file operations.'
);
} }
} }
+1 -1
View File
@@ -253,7 +253,7 @@ export function createScript(input: CreateScriptInput): {
if (!targetRelPath) throw new ScriptError('target rel path is required', 'path-empty'); if (!targetRelPath) throw new ScriptError('target rel path is required', 'path-empty');
if (isReservedConfigFilename(targetRelPath)) if (isReservedConfigFilename(targetRelPath))
throw new ScriptError( throw new ScriptError(
'electric_cherry_bank.cfg is reserved for instance credentials.', `${targetRelPath} is reserved for instance credentials.`,
'path-reserved' 'path-reserved'
); );
+8
View File
@@ -3,6 +3,7 @@ import type { GameTelemetry, ServerTiming } from '../../telemetry.js';
import { MIN_RECORDED_ROUND } from '../../match-history.js'; import { MIN_RECORDED_ROUND } from '../../match-history.js';
import { recordLeaderboardTelemetry } from './leaderboard-ingest.js'; import { recordLeaderboardTelemetry } from './leaderboard-ingest.js';
import { latestGameSubmission } from './status-evidence.js'; import { latestGameSubmission } from './status-evidence.js';
import { observeModerationPlayers } from '../moderation/core.js';
export function listTelemetry(): GameTelemetry[] { export function listTelemetry(): GameTelemetry[] {
const rows = db() const rows = db()
@@ -33,6 +34,13 @@ export function saveTelemetryWithOutcome(report: GameTelemetry): {
? JSON.parse(row.payload) ? JSON.parse(row.payload)
: undefined; : undefined;
const recorded = recordLeaderboardTelemetry(handle, report); const recorded = recordLeaderboardTelemetry(handle, report);
const game = (
handle
.prepare('SELECT game FROM leaderboard_matches WHERE id=?')
.get(recorded.matchId) as { game: string }
).game;
if (game === 't5' || game === 't6')
observeModerationPlayers(handle, game, report.players, report.receivedAt);
if (!recorded.current) { if (!recorded.current) {
return { return {
telemetry: { ...report, timing: { matchId: recorded.matchId, players: {} } }, telemetry: { ...report, timing: { matchId: recorded.matchId, players: {} } },
+26 -3
View File
@@ -1,6 +1,6 @@
import { import {
assertPublicFilePayload, assertPublicFilePayload,
BANK_CONFIG_FILE, PRIVATE_CONFIG_FILES,
PrivateFileOperationError, PrivateFileOperationError,
isPrivateConfigPath isPrivateConfigPath
} from '../fleet/private-config.js'; } from '../fleet/private-config.js';
@@ -1454,7 +1454,7 @@ export function getHostOperationPayload(hostId: string, id: string): Buffer {
.get(id, hostId) as { payload: Buffer } | undefined; .get(id, hostId) as { payload: Buffer } | undefined;
if (!row) throw new Error('Host operation was not found for this host.'); if (!row) throw new Error('Host operation was not found for this host.');
// Block old private-file commands before delivery to agents predating the guard. // Block old private-file commands before delivery to agents predating the guard.
if (row.payload.includes(BANK_CONFIG_FILE)) if (PRIVATE_CONFIG_FILES.some((filename) => row.payload.includes(filename)))
assertPublicFilePayload(JSON.parse(row.payload.toString())); assertPublicFilePayload(JSON.parse(row.payload.toString()));
if ( if (
!row.payload.includes('"contentSha256"') || !row.payload.includes('"contentSha256"') ||
@@ -1485,10 +1485,15 @@ export function reportHostOperationStatus(
if (['completed', 'failed'].includes(operation.state)) return operation; if (['completed', 'failed'].includes(operation.state)) return operation;
const state = input.state as HostOperationState; const state = input.state as HostOperationState;
if (operation.state === 'running' && state === 'accepted') return operation; if (operation.state === 'running' && state === 'accepted') return operation;
const error = let error =
input.error === undefined || input.error === null input.error === undefined || input.error === null
? null ? null
: String(input.error).slice(0, 4000); : String(input.error).slice(0, 4000);
if (operation.kind === 'instance.configure-moderation' && error)
error = error.replace(
/ec_(?:moderation|bank)_[A-Za-z0-9_-]+/g,
'[redacted credential]'
);
let result = input.result === undefined ? null : JSON.stringify(input.result); let result = input.result === undefined ? null : JSON.stringify(input.result);
if (result !== null && Buffer.byteLength(result) > 128 * 1024 * 1024) if (result !== null && Buffer.byteLength(result) > 128 * 1024 * 1024)
throw new Error('Operation result exceeds the limit.'); throw new Error('Operation result exceeds the limit.');
@@ -1504,6 +1509,8 @@ export function reportHostOperationStatus(
? { redacted: true } ? { redacted: true }
: operation.kind === 'logs.read' : operation.kind === 'logs.read'
? logResultMetadata(input.result) ? logResultMetadata(input.result)
: operation.kind === 'instance.configure-moderation'
? moderationConfigurationReceipt(input.result)
: input.result : input.result
); );
result = JSON.stringify(packed.value); result = JSON.stringify(packed.value);
@@ -1568,6 +1575,22 @@ export function reportHostOperationStatus(
queueMicrotask(() => workEvents.emit(`operation:${id}`)); queueMicrotask(() => workEvents.emit(`operation:${id}`));
return operation; return operation;
} }
/** Configuration replies contain status only, never echoed dvars, file bodies or credentials. */
function moderationConfigurationReceipt(value: unknown): Record<string, unknown> {
const result =
value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
return {
configured: result.configured === true,
...(Number.isSafeInteger(result.generation) ? { generation: result.generation } : {}),
liveLoad: ['sent', 'unconfirmed', 'deferred'].includes(result.liveLoad as string)
? result.liveLoad
: 'unconfirmed',
...(result.reconciled === true ? { reconciled: true } : {})
};
}
export function reportHostOperation( export function reportHostOperation(
hostId: string, hostId: string,
id: string, id: string,
+396
View File
@@ -0,0 +1,396 @@
import { randomUUID } from 'node:crypto';
import { db } from '../db/index.js';
import { AuthError } from '../auth/index.js';
import { moderatorMaximumRestrictionMs, type AuthUser } from '../../auth.js';
import {
canRevokeModerationRestriction,
moderationGuid,
type ModerationGame,
type ModerationScope
} from '../../moderation.js';
import {
advancePolicy,
ensureSubject,
moderationEvent,
moderationMutation,
subject,
textField
} from './core.js';
export const runtimeFreshMs = 15_000;
function gameText(value: unknown, label: string): string {
const text = textField(value, label, 240);
if (Buffer.byteLength(text, 'utf8') > 240)
throw new AuthError(`${label} must fit within 240 UTF-8 bytes for the game server.`);
return text;
}
type SanctionRow = {
id: string;
game: ModerationGame;
guid: string;
actor_id: string;
scope: ModerationScope;
created_at: string;
expires_at: string | null;
revision: number;
revoked_at: string | null;
};
type CaseRow = { id: string; game: ModerationGame; guid: string; revision: number; status: string };
function serverTarget(name: unknown, game?: ModerationGame) {
const server = textField(name, 'Server', 255);
const row = db()
.prepare(
`SELECT s.name,s.game,s.created_at,n.id AS instance_id,n.generation
FROM servers s LEFT JOIN native_instances n ON n.server=s.name AND n.retired_at IS NULL
WHERE s.name=? AND s.destroyed_at IS NULL`
)
.get(server) as
| {
name: string;
game: ModerationGame;
created_at: string;
instance_id: string | null;
generation: number | null;
}
| undefined;
if (!row || (game && row.game !== game))
throw new AuthError('Choose an existing server for this game.', 404);
return row;
}
function createSanction(actor: AuthUser, input: Record<string, unknown>, now: string) {
const target = subject(input);
if (input.kind !== 'ban' && input.kind !== 'mute') throw new AuthError('Choose ban or mute.');
if (typeof input.scope !== 'string' || !['server', 'game', 'fleet'].includes(input.scope))
throw new AuthError('Choose a restriction scope.');
const duration = input.durationMinutes;
if (
duration !== null &&
(!Number.isSafeInteger(duration) ||
(duration as number) < 1 ||
(duration as number) > 5256000)
)
throw new AuthError('Choose a duration between 1 minute and 10 years, or permanent.');
if (
actor.role !== 'admin' &&
(input.scope === 'fleet' ||
duration === null ||
(duration as number) * 60_000 > moderatorMaximumRestrictionMs)
)
throw new AuthError(
'Moderators can apply server or game restrictions lasting up to 7 days.',
403
);
// Fleet identity is only shared where both games have reported the same numeric account ID.
if (
input.scope === 'fleet' &&
(!/^\d+$/.test(target.guid) ||
(
db()
.prepare(
'SELECT COUNT(DISTINCT game) AS count FROM moderation_aliases WHERE guid=?'
)
.get(target.guid) as { count: number }
).count !== 2)
)
throw new AuthError(
'Fleet restrictions require this account ID to have been observed in both games. Use game scope until its identity is established.'
);
const reason = gameText(input.reason, 'Player-visible reason');
const note = textField(input.note, 'Private note', 4000, true);
const server = input.scope === 'server' ? serverTarget(input.server, target.game) : null;
const activeCount = (
db()
.prepare(
'SELECT COUNT(*) AS count FROM moderation_sanctions WHERE revoked_at IS NULL AND (expires_at IS NULL OR expires_at>?)'
)
.get(now) as { count: number }
).count;
if (activeCount >= 1000)
throw new AuthError('The limit of 1,000 active restrictions has been reached.', 409);
const id = randomUUID();
const expires =
duration === null
? null
: new Date(Date.parse(now) + (duration as number) * 60_000).toISOString();
ensureSubject(target.game, target.guid);
db()
.prepare(
`INSERT INTO moderation_sanctions(id,game,guid,kind,scope,server,server_created_at,reason,note,actor_id,actor,created_at,expires_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)`
)
.run(
id,
target.game,
target.guid,
input.kind,
input.scope,
server?.name ?? null,
server?.created_at ?? null,
reason,
note,
actor.id,
actor.username,
now,
expires
);
advancePolicy();
moderationEvent(
actor,
'restriction.created',
target,
id,
JSON.stringify({
kind: input.kind,
scope: input.scope,
server: server?.name ?? null,
reason,
expiresAt: expires,
note
}),
now
);
return { id };
}
function revokeSanction(actor: AuthUser, input: Record<string, unknown>, now: string) {
const id = textField(input.id, 'Restriction ID', 128);
const row = db().prepare('SELECT * FROM moderation_sanctions WHERE id=?').get(id) as
SanctionRow | undefined;
if (!row) throw new AuthError('Restriction not found.', 404);
if (actor.role !== 'admin' && row.actor_id !== actor.id)
throw new AuthError(
'Only an administrator can revoke another moderator’s restriction.',
403
);
if (
!canRevokeModerationRestriction(actor, {
actorId: row.actor_id,
scope: row.scope,
createdAt: row.created_at,
expiresAt: row.expires_at
})
)
throw new AuthError(
'Moderators can only revoke their own server or game restrictions originally lasting up to 7 days.',
403
);
if (row.revoked_at || row.revision !== input.revision)
throw new AuthError('This restriction changed. Refresh before trying again.', 409);
const reason = textField(input.reason, 'Revocation reason', 1000);
db()
.prepare('UPDATE moderation_sanctions SET revoked_at=?,revision=revision+1 WHERE id=?')
.run(now, id);
advancePolicy();
moderationEvent(actor, 'restriction.revoked', row, id, reason, now);
return { id };
}
function liveAction(actor: AuthUser, input: Record<string, unknown>, now: string) {
if (
typeof input.kind !== 'string' ||
!['warn', 'message', 'kick', 'announce'].includes(input.kind)
)
throw new AuthError('Choose a supported live action.');
const target = input.kind === 'announce' ? null : subject(input);
const server = serverTarget(input.server, target?.game);
const runtime = db()
.prepare('SELECT * FROM moderation_runtimes WHERE instance_id=? AND generation=?')
.get(server.instance_id, server.generation) as
| {
session: string;
players: string;
capabilities: string;
connected_at: string;
}
| undefined;
if (!runtime || Date.parse(now) - Date.parse(runtime.connected_at) > runtimeFreshMs)
throw new AuthError(
'The moderation integration is not connected to this server. Wait for a fresh connection.',
409
);
if (!(JSON.parse(runtime.capabilities) as string[]).includes(String(input.kind)))
throw new AuthError('This server does not support that action.', 409);
const matches = (JSON.parse(runtime.players) as { guid: string; connection: string }[]).filter(
(p) => p.guid === target?.guid
);
if (target && matches.length !== 1)
throw new AuthError(
'This player’s current connection could not be identified. Refresh the roster.',
409
);
const recent = (
db()
.prepare(
'SELECT COUNT(*) AS count FROM moderation_actions WHERE actor_id=? AND created_at>?'
)
.get(actor.id, new Date(Date.parse(now) - 60_000).toISOString()) as { count: number }
).count;
if (recent >= 30)
throw new AuthError('Too many moderation actions. Wait a minute before continuing.', 429);
const reason = gameText(input.reason, input.kind === 'kick' ? 'Kick reason' : 'Message');
const id = randomUUID();
db()
.prepare(
`INSERT INTO moderation_actions(id,game,guid,server,instance_id,generation,session,connection,kind,reason,actor_id,actor,created_at,expires_at,state)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,'queued')`
)
.run(
id,
server.game,
target?.guid ?? null,
server.name,
server.instance_id,
server.generation,
runtime.session,
target ? matches[0].connection : null,
input.kind,
reason,
actor.id,
actor.username,
now,
new Date(Date.parse(now) + 30_000).toISOString()
);
moderationEvent(
actor,
'action.queued',
{ game: server.game, guid: target?.guid },
id,
JSON.stringify({ kind: input.kind, server: server.name, reason }),
now
);
return { id };
}
function caseRow(value: unknown): CaseRow {
const row = db()
.prepare('SELECT * FROM moderation_cases WHERE id=?')
.get(textField(value, 'Case ID', 128)) as CaseRow | undefined;
if (!row) throw new AuthError('Case not found.', 404);
return row;
}
function evidence(
actor: AuthUser,
id: string,
text: string,
source: string,
snapshot: string | null,
now: string
) {
if (
(
db()
.prepare('SELECT COUNT(*) AS count FROM moderation_evidence WHERE case_id=?')
.get(id) as { count: number }
).count >= 100
)
throw new AuthError('This case already contains 100 evidence entries.', 409);
db()
.prepare('INSERT INTO moderation_evidence VALUES(?,?,?,?,?,?,?,?)')
.run(randomUUID(), id, text, source, snapshot, actor.id, actor.username, now);
}
function createCase(actor: AuthUser, input: Record<string, unknown>, now: string) {
const target = subject(input);
const title = textField(input.title, 'Case title', 160);
const note = textField(input.note, 'Case note', 4000, true);
const id = randomUUID();
ensureSubject(target.game, target.guid);
db()
.prepare(
`INSERT INTO moderation_cases(id,game,guid,title,status,actor_id,actor,created_at,updated_at) VALUES(?,?,?,?,'open',?,?,?,?)`
)
.run(id, target.game, target.guid, title, actor.id, actor.username, now, now);
if (note) evidence(actor, id, note, 'Staff note', null, now);
if (input.evidence !== undefined) {
const requested = input.evidence as { matchId: unknown; message: unknown };
if (!requested || !Number.isSafeInteger(requested.matchId))
throw new AuthError('Choose a recorded chat message.');
const message = textField(requested.message, 'Chat message', 4096);
const match = db()
.prepare('SELECT game,server,started_at FROM leaderboard_matches WHERE id=?')
.get(requested.matchId) as
{ game: string; server: string; started_at: string } | undefined;
if (!match || match.game !== target.game)
throw new AuthError('The source match is unavailable.', 404);
const entries = db()
.prepare('SELECT entry FROM match_chat WHERE match_id=?')
.all(requested.matchId) as { entry: string }[];
const found = entries
.filter((row) => {
const entry = JSON.parse(row.entry);
return (
entry.type === 'message' &&
moderationGuid(target.game, entry.message.guid) === target.guid &&
entry.message.message === message
);
})
.slice(0, 10);
if (!found.length)
throw new AuthError(
'The original chat message could not be verified in the archive.',
409
);
for (const row of found)
evidence(
actor,
id,
message,
`Archived chat · ${match.server} · match ${requested.matchId}`,
JSON.stringify({ matchId: requested.matchId, match, entry: JSON.parse(row.entry) }),
now
);
}
moderationEvent(actor, 'case.opened', target, id, title, now);
return { id };
}
export function mutateModeration(actorId: string, input: Record<string, unknown>) {
return moderationMutation(actorId, input, (actor, now) => {
if (input.op === 'create-sanction') return createSanction(actor, input, now);
if (input.op === 'revoke-sanction') return revokeSanction(actor, input, now);
if (input.op === 'action') return liveAction(actor, input, now);
if (input.op === 'create-case') return createCase(actor, input, now);
if (input.op === 'case-note') {
const row = caseRow(input.id);
const text = textField(input.text, 'Note', 4000);
evidence(actor, row.id, text, 'Staff note', null, now);
db()
.prepare('UPDATE moderation_cases SET updated_at=?,revision=revision+1 WHERE id=?')
.run(now, row.id);
moderationEvent(actor, 'case.note', row, row.id, text, now);
return { id: row.id };
}
if (input.op === 'resolve-case') {
const row = caseRow(input.id);
if (row.revision !== input.revision || row.status !== 'open')
throw new AuthError('This case changed. Refresh before resolving it.', 409);
if (input.status !== 'resolved' && input.status !== 'dismissed')
throw new AuthError('Choose resolved or dismissed.');
const reason = textField(input.reason, 'Decision reason', 4000);
evidence(
actor,
row.id,
reason,
input.status === 'resolved' ? 'Resolution' : 'Dismissal',
null,
now
);
db()
.prepare(
'UPDATE moderation_cases SET status=?,updated_at=?,revision=revision+1 WHERE id=?'
)
.run(input.status, now, row.id);
moderationEvent(actor, `case.${input.status}`, row, row.id, reason, now);
return { id: row.id };
}
if (input.op === 'note') {
const target = subject(input);
const text = textField(input.text, 'Private note', 4000);
ensureSubject(target.game, target.guid);
moderationEvent(actor, 'player.note', target, null, text, now);
return {
id: String(
(db().prepare('SELECT last_insert_rowid() AS id').get() as { id: number }).id
)
};
}
throw new AuthError('Unknown moderation action.');
});
}
@@ -0,0 +1,49 @@
import { config } from '../config.js';
import { MODERATION_CONFIG_FILE } from '../fleet/private-config.js';
import { AuthError } from '../auth/index.js';
export const moderationConfigureCapability = 'moderation.configure';
export interface ModerationConfiguration {
token: string;
configPath: string;
url: string;
generation: number;
}
/** The game connects to the public controller, never an agent's private enrollment URL. */
export function moderationRuntimeUrl(): string {
let base: URL;
try {
base = new URL(config.publicUrl);
} catch {
throw new AuthError(
'Set EC_PUBLIC_URL to the public HTTPS controller URL before configuring moderation.',
409
);
}
if (
base.protocol !== 'https:' ||
base.username ||
base.password ||
base.search ||
base.hash ||
base.pathname !== '/'
)
throw new AuthError(
'EC_PUBLIC_URL must be a public HTTPS origin without a path, credentials, query or fragment.',
409
);
return new URL('/api/moderation/runtime', base).href;
}
export function moderationConfiguration(
token: string,
generation: number
): ModerationConfiguration {
return {
token,
generation,
configPath: `configs/${MODERATION_CONFIG_FILE}`,
url: moderationRuntimeUrl()
};
}
+54
View File
@@ -0,0 +1,54 @@
import { AuthError } from '../auth/index.js';
import { nativeInstance } from '../backends/native.js';
import { db } from '../db/index.js';
import { getHost, queueHostOperation } from '../hosts/index.js';
import { moderationActor, moderationEvent } from './core.js';
import { moderationCredential } from './credentials.js';
import { moderationConfiguration, moderationConfigureCapability } from './configuration.js';
/** Persist a private, generation-bound bootstrap without exposing the credential to the browser. */
export function configureModerationServer(actorId: string, server: string): { id: string } {
return db()
.transaction(() => {
const actor = moderationActor(actorId, true);
if (actor.role !== 'admin')
throw new AuthError(
'Administrator access is required to configure game moderation.',
403
);
const instance = nativeInstance(server);
const live = db()
.prepare('SELECT destroyed_at FROM servers WHERE name=?')
.get(server) as { destroyed_at: string | null } | undefined;
if (!instance || !live || live.destroyed_at)
throw new AuthError('Choose an active native server.', 404);
const host = getHost(instance.host_id);
if (!host?.inventory.capabilities?.includes(moderationConfigureCapability))
throw new AuthError(
'Update the host agent before configuring game moderation.',
409
);
const moderation = moderationConfiguration(
moderationCredential(instance.id, instance.generation),
instance.generation
);
const operation = queueHostOperation(
instance.host_id,
'instance.configure-moderation',
{ moderation },
{
instanceId: instance.id,
generation: instance.generation
}
);
moderationEvent(
actor,
'runtime.configure',
{},
operation.id,
`Configure game moderation on ${server}.`
);
return { id: operation.id };
})
.immediate();
}
+129
View File
@@ -0,0 +1,129 @@
import { createHash } from 'node:crypto';
import type Database from 'better-sqlite3';
import { db } from '../db/index.js';
import { getUser, AuthError } from '../auth/index.js';
import { canModerate, canReadModeration, type AuthUser } from '../../auth.js';
import { moderationGuid, type ModerationGame } from '../../moderation.js';
export const moderationNow = () => new Date().toISOString();
export function moderationActor(id: string, write = false): AuthUser {
const user = getUser(id);
if (
!user ||
user.disabled ||
user.mustChangePassword ||
!(write ? canModerate(user.role) : canReadModeration(user.role))
)
throw new AuthError('Moderation access is required.', 403);
return user;
}
export function textField(value: unknown, label: string, maximum = 500, optional = false): string {
if (optional && (value === undefined || value === null || value === '')) return '';
if (
typeof value !== 'string' ||
!value.trim() ||
value.length > maximum ||
// eslint-disable-next-line no-control-regex -- Bound all user-controlled moderation text.
/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/.test(value)
)
throw new AuthError(`${label} must contain 1–${maximum} printable characters.`);
return value.trim();
}
export function subject(input: Record<string, unknown>): { game: ModerationGame; guid: string } {
if (input.game !== 't5' && input.game !== 't6')
throw new AuthError('Choose Black Ops I or Black Ops II.');
const raw = textField(input.guid, 'Player GUID', 128);
if (!/^[A-Za-z0-9:_-]+$/.test(raw)) throw new AuthError('Invalid player GUID.');
return { game: input.game, guid: moderationGuid(input.game, raw) };
}
export function observeModerationPlayers(
handle: Database.Database,
game: ModerationGame,
players: { guid: string; name: string }[],
now: string
): void {
const upsert = handle.prepare(`INSERT INTO moderation_players VALUES(?,?,?,?,?)
ON CONFLICT(game,guid) DO UPDATE SET name=CASE WHEN excluded.last_seen_at>=last_seen_at THEN excluded.name ELSE name END,
first_seen_at=CASE WHEN first_seen_at='' THEN excluded.first_seen_at ELSE MIN(first_seen_at,excluded.first_seen_at) END,last_seen_at=MAX(last_seen_at,excluded.last_seen_at)`);
const aliases = handle.prepare(`INSERT INTO moderation_aliases VALUES(?,?,?,?,?)
ON CONFLICT(game,guid,name) DO UPDATE SET first_seen_at=MIN(first_seen_at,excluded.first_seen_at),last_seen_at=MAX(last_seen_at,excluded.last_seen_at)`);
for (const player of players) {
const values = [game, moderationGuid(game, player.guid), player.name, now, now];
upsert.run(...values);
aliases.run(...values);
}
}
export function ensureSubject(game: ModerationGame, guid: string): void {
db()
.prepare('INSERT INTO moderation_players VALUES(?,?,?,?,?) ON CONFLICT DO NOTHING')
.run(game, guid, guid, '', '');
}
export function moderationEvent(
actor: Pick<AuthUser, 'id' | 'username'> | null,
action: string,
target: { game?: string | null; guid?: string | null },
subjectId: string | null,
detail: string,
now = moderationNow()
): void {
db()
.prepare(
`INSERT INTO moderation_events(game,guid,actor_id,actor,action,subject_id,detail,created_at) VALUES(?,?,?,?,?,?,?,?)`
)
.run(
target.game ?? null,
target.guid ?? null,
actor?.id ?? null,
actor?.username ?? 'Game server',
action,
subjectId,
detail,
now
);
}
export function policyRevision(): number {
return (
db().prepare('SELECT revision FROM moderation_policy WHERE id=1').get() as {
revision: number;
}
).revision;
}
export function advancePolicy(): void {
db().prepare('UPDATE moderation_policy SET revision=revision+1 WHERE id=1').run();
}
/** The request and its durable result commit atomically, including repeated browser submissions. */
export function moderationMutation(
actorId: string,
input: Record<string, unknown>,
work: (actor: AuthUser, now: string) => { id: string }
): { ok: true; id: string } {
const key = textField(input.idempotencyKey, 'Request identifier', 128);
const canonical = JSON.stringify(
Object.fromEntries(Object.entries(input).sort(([a], [b]) => a.localeCompare(b)))
);
const fingerprint = createHash('sha256').update(canonical).digest('hex');
return db()
.transaction(() => {
const actor = moderationActor(actorId, true);
const prior = db()
.prepare(
'SELECT fingerprint,result FROM moderation_requests WHERE actor_id=? AND request_key=?'
)
.get(actor.id, key) as { fingerprint: string; result: string } | undefined;
if (prior) {
if (prior.fingerprint !== fingerprint)
throw new AuthError(
'This request identifier was already used for a different action.',
409
);
return JSON.parse(prior.result) as { ok: true; id: string };
}
const now = moderationNow();
const result = { ok: true as const, ...work(actor, now) };
db()
.prepare('INSERT INTO moderation_requests VALUES(?,?,?,?,?)')
.run(actor.id, key, fingerprint, JSON.stringify(result), now);
return result;
})
.immediate();
}
+55
View File
@@ -0,0 +1,55 @@
import { createHash, randomBytes } from 'node:crypto';
import { db } from '../db/index.js';
import type { ModerationGame } from '../../moderation.js';
const digest = (value: string) => createHash('sha256').update(value).digest('hex');
export function moderationCredential(instanceId: string, generation: number): string {
return db()
.transaction(() => {
if (
!Number.isSafeInteger(generation) ||
!db()
.prepare(
'SELECT 1 FROM native_instances WHERE id=? AND generation IN (?,?) AND retired_at IS NULL'
)
.get(instanceId, generation, generation - 1)
)
throw new Error('Moderation credentials require an active instance generation.');
const existing = db()
.prepare(
'SELECT token FROM moderation_credentials WHERE instance_id=? AND generation=?'
)
.get(instanceId, generation) as { token: string } | undefined;
if (existing) return existing.token;
const token = 'ec_moderation_' + randomBytes(32).toString('base64url');
db()
.prepare('INSERT INTO moderation_credentials VALUES(?,?,?,?,?)')
.run(instanceId, generation, token, digest(token), new Date().toISOString());
return token;
})
.immediate();
}
export interface ModerationRuntimeIdentity {
instanceId: string;
generation: number;
server: string;
game: ModerationGame;
serverCreatedAt: string;
credentialHash: string;
}
export function authenticateModerationRuntime(request: Request): ModerationRuntimeIdentity | null {
const token = request.headers
.get('authorization')
?.match(/^Bearer (ec_moderation_[A-Za-z0-9_-]{43})$/)?.[1];
if (!token) return null;
return (
(db()
.prepare(
`SELECT n.id AS instanceId,n.generation,n.server,s.game,s.created_at AS serverCreatedAt,c.token_hash AS credentialHash
FROM moderation_credentials c JOIN native_instances n ON n.id=c.instance_id AND n.generation=c.generation
JOIN servers s ON s.name=n.server
WHERE c.token_hash=? AND n.retired_at IS NULL AND s.destroyed_at IS NULL AND s.game IN ('t5','t6')`
)
.get(digest(token)) as ModerationRuntimeIdentity | undefined) ?? null
);
}
+210
View File
@@ -0,0 +1,210 @@
import { db } from '../db/index.js';
import { moderationActor, moderationNow, policyRevision } from './core.js';
import { runtimeFreshMs } from './actions.js';
import { refreshActionStates } from './runtime.js';
import {
moderationGuid,
type ModerationOverview,
type ModerationQuery,
type ModerationPlayer,
type ModerationSanction,
type ModerationCase,
type ModerationEvent,
type ModerationAction,
type ModerationCoverage
} from '../../moderation.js';
export function moderationQuery(params: URLSearchParams): ModerationQuery {
const view = params.get('view');
const game = params.get('game');
const guid = (params.get('guid') ?? '').slice(0, 128);
const offset = Number(params.get('offset') ?? 0);
return {
view: view === 'restrictions' || view === 'cases' || view === 'history' ? view : 'players',
q: (params.get('q') ?? '').trim().slice(0, 200),
game: game === 't5' || game === 't6' ? game : '',
guid: game === 't5' || game === 't6' ? moderationGuid(game, guid) : guid,
offset: Number.isSafeInteger(offset) && offset >= 0 ? offset : 0
};
}
export function moderationOverview(actorId: string, query: ModerationQuery): ModerationOverview {
moderationActor(actorId);
return db().transaction(() => {
const handle = db(),
now = moderationNow(),
limit = 25;
refreshActionStates(now);
const revision = policyRevision();
const runtimeRows = handle
.prepare(
`SELECT s.name AS server,s.game,n.id,r.connected_at AS connectedAt,r.revision,r.capabilities,r.players
FROM servers s LEFT JOIN native_instances n ON n.server=s.name AND n.retired_at IS NULL
LEFT JOIN moderation_runtimes r ON r.instance_id=n.id AND r.generation=n.generation
WHERE s.destroyed_at IS NULL AND s.game IN ('t5','t6') ORDER BY s.name`
)
.all() as {
server: string;
game: 't5' | 't6';
id: string | null;
connectedAt: string | null;
revision: number | null;
capabilities: string | null;
players: string | null;
}[];
const coverage: ModerationCoverage[] = runtimeRows.map((row) => ({
server: row.server,
game: row.game,
connectedAt: row.connectedAt,
revision: row.revision,
desiredRevision: revision,
state: !row.connectedAt
? 'unavailable'
: Date.parse(now) - Date.parse(row.connectedAt) > runtimeFreshMs
? 'offline'
: row.revision === revision &&
['ban', 'mute'].every((capability) =>
(JSON.parse(row.capabilities ?? '[]') as string[]).includes(capability)
)
? 'current'
: 'pending',
capabilities: row.capabilities ? JSON.parse(row.capabilities) : []
}));
const result: ModerationOverview = {
query: { ...query },
players: [],
sanctions: [],
cases: [],
events: [],
actions: [],
coverage,
total: 0,
limit,
checkedAt: now
};
const where: string[] = [],
values: (string | number)[] = [];
if (query.game) {
where.push(
query.view === 'restrictions' ? "(p.game=? OR p.scope='fleet')" : 'p.game=?'
);
values.push(query.game);
}
if (query.guid) {
where.push('p.guid=?');
values.push(query.guid);
}
if (query.q) {
const extra =
query.view === 'restrictions'
? 'p.reason'
: query.view === 'cases'
? 'p.title'
: query.view === 'history'
? 'p.detail'
: 'p.name';
where.push(
`(instr(lower(p.guid),lower(?))>0 OR instr(lower(${extra}),lower(?))>0 OR EXISTS (SELECT 1 FROM moderation_aliases a WHERE a.game=p.game AND a.guid=p.guid AND instr(lower(a.name),lower(?))>0))`
);
values.push(query.q, query.q, query.q);
}
const clause = where.length ? ' WHERE ' + where.join(' AND ') : '';
function rows<T>(table: string, select: string, order: string): T[] {
result.total = (
handle
.prepare(`SELECT COUNT(*) AS count FROM ${table} p${clause}`)
.get(...values) as { count: number }
).count;
result.query.offset = Math.min(
query.offset,
Math.max(0, Math.floor((result.total - 1) / limit) * limit)
);
return handle
.prepare(
`SELECT ${select} FROM ${table} p${clause} ORDER BY ${order} LIMIT ? OFFSET ?`
)
.all(...values, limit, result.query.offset) as T[];
}
if (query.view === 'players') {
result.players = rows<Omit<ModerationPlayer, 'aliases' | 'servers' | 'restrictions'>>(
'moderation_players',
"p.game,p.guid,p.name,NULLIF(p.first_seen_at,'') AS firstSeen,NULLIF(p.last_seen_at,'') AS lastSeen",
'p.last_seen_at DESC,p.game,p.guid'
).map((player) => ({
...player,
aliases: (
handle
.prepare(
'SELECT name FROM moderation_aliases WHERE game=? AND guid=? ORDER BY last_seen_at DESC LIMIT 30'
)
.all(player.game, player.guid) as { name: string }[]
).map((a) => a.name),
servers: runtimeRows
.filter(
(r) =>
r.game === player.game &&
r.connectedAt &&
Date.parse(now) - Date.parse(r.connectedAt) <= runtimeFreshMs &&
r.players &&
(JSON.parse(r.players) as { guid: string }[]).some(
(p) => p.guid === player.guid
)
)
.map((r) => r.server),
restrictions: (
handle
.prepare(
"SELECT COUNT(*) AS count FROM moderation_sanctions WHERE guid=? AND (game=? OR scope='fleet') AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at>?)"
)
.get(player.guid, player.game, now) as { count: number }
).count
}));
} else if (query.view === 'restrictions') {
result.sanctions = rows<Omit<ModerationSanction, 'active'>>(
'moderation_sanctions',
'p.id,p.game,p.guid,p.kind,p.scope,p.server,p.reason,p.note,p.created_at AS createdAt,p.expires_at AS expiresAt,p.revoked_at AS revokedAt,p.actor_id AS actorId,p.actor,p.revision',
'p.created_at DESC,p.id'
).map((s) => ({ ...s, active: !s.revokedAt && (!s.expiresAt || s.expiresAt > now) }));
} else if (query.view === 'cases') {
result.cases = rows<Omit<ModerationCase, 'evidence'>>(
'moderation_cases',
'p.id,p.game,p.guid,p.title,p.status,p.actor,p.created_at AS createdAt,p.updated_at AS updatedAt,p.revision',
'p.updated_at DESC,p.id'
).map((c) => ({
...c,
evidence: handle
.prepare(
'SELECT id,text,source,created_at AS createdAt,actor FROM moderation_evidence WHERE case_id=? ORDER BY created_at,id'
)
.all(c.id) as ModerationCase['evidence']
}));
} else {
result.events = rows<ModerationEvent>(
'moderation_events',
'p.id,p.game,p.guid,p.action,p.subject_id AS subjectId,p.actor,p.detail,p.created_at AS createdAt',
'p.id DESC'
);
}
const actionWhere: string[] = [],
actionValues: string[] = [];
if (query.game) {
actionWhere.push('game=?');
actionValues.push(query.game);
}
if (query.guid) {
actionWhere.push('guid=?');
actionValues.push(query.guid);
}
if (query.q) {
actionWhere.push(
'(instr(lower(guid),lower(?))>0 OR instr(lower(reason),lower(?))>0 OR EXISTS (SELECT 1 FROM moderation_aliases a WHERE a.game=moderation_actions.game AND a.guid=moderation_actions.guid AND instr(lower(a.name),lower(?))>0))'
);
actionValues.push(query.q, query.q, query.q);
}
result.actions = handle
.prepare(
`SELECT id,game,guid,server,kind,reason,actor,created_at AS createdAt,expires_at AS expiresAt,state,detail FROM moderation_actions${actionWhere.length ? ' WHERE ' + actionWhere.join(' AND ') : ''} ORDER BY created_at DESC,id LIMIT 25`
)
.all(...actionValues) as ModerationAction[];
return result;
})();
}
+313
View File
@@ -0,0 +1,313 @@
import { db } from '../db/index.js';
import { AuthError, getUser } from '../auth/index.js';
import { canModerate } from '../../auth.js';
import { moderationGuid } from '../../moderation.js';
import {
moderationEvent,
moderationNow,
observeModerationPlayers,
policyRevision,
textField
} from './core.js';
import type { ModerationRuntimeIdentity } from './credentials.js';
export { moderationCredential } from './credentials.js';
const capabilities = ['warn', 'message', 'kick', 'announce', 'ban', 'mute'];
export function refreshActionStates(now = moderationNow()): void {
const rows = db()
.prepare(
"SELECT id,game,guid,state FROM moderation_actions WHERE state IN ('queued','delivered') AND expires_at<=?"
)
.all(now) as { id: string; game: string; guid: string | null; state: string }[];
for (const row of rows) {
const state = row.state === 'queued' ? 'expired' : 'unknown';
const detail =
state === 'expired'
? 'The action expired before delivery.'
: 'The game did not acknowledge the delivered action before its deadline.';
db()
.prepare(
'UPDATE moderation_actions SET state=?,detail=?,finished_at=? WHERE id=? AND state=?'
)
.run(state, detail, now, row.id, row.state);
moderationEvent(null, `action.${state}`, row, row.id, detail, now);
}
}
function token(value: unknown, label: string): string {
const result = textField(value, label, 128);
if (!/^[A-Za-z0-9:_-]+$/.test(result)) throw new AuthError(`Invalid ${label.toLowerCase()}.`);
return result;
}
export function syncModerationRuntime(
identity: ModerationRuntimeIdentity,
input: Record<string, unknown>
) {
if (
input.protocol !== 1 ||
input.server !== identity.server ||
input.generation !== identity.generation
)
throw new AuthError('The runtime identity does not match this credential.', 403);
const session = token(input.session, 'Runtime session');
if (!Number.isSafeInteger(input.sessionSequence) || (input.sessionSequence as number) < 1)
throw new AuthError('A positive runtime session sequence is required.');
if (
!Array.isArray(input.players) ||
input.players.length > 64 ||
!Array.isArray(input.receipts) ||
input.receipts.length > 256 ||
!Array.isArray(input.capabilities) ||
input.capabilities.length > capabilities.length ||
!Number.isSafeInteger(input.appliedRevision) ||
(input.appliedRevision as number) < 0
)
throw new AuthError('Invalid moderation synchronization request.');
const supported = [...new Set(input.capabilities)];
if (supported.some((c) => typeof c !== 'string' || !capabilities.includes(c)))
throw new AuthError('Unknown moderation capability.');
const players = input.players.map((value) => {
if (!value || typeof value !== 'object' || Array.isArray(value))
throw new AuthError('Invalid player.');
const raw = token(value.guid, 'Player GUID');
const name = textField(value.name, 'Player name', 255);
if (Buffer.byteLength(name, 'utf8') > 255)
throw new AuthError('Player names must fit within 255 UTF-8 bytes.');
return {
guid: moderationGuid(identity.game, raw),
name,
connection: token(value.connection, 'Player connection')
};
});
if (
new Set(players.map((p) => p.connection)).size !== players.length ||
new Set(players.map((p) => p.guid)).size !== players.length
)
throw new AuthError('Player identities and connections must be unique.');
const receipts = input.receipts.map((value) => {
if (
!value ||
typeof value !== 'object' ||
Array.isArray(value) ||
!['confirmed', 'failed', 'unknown', 'expired'].includes(value.state)
)
throw new AuthError('Invalid action receipt.');
return {
id: token(value.id, 'Action ID'),
state: String(value.state),
detail: textField(value.detail, 'Receipt detail', 500, true)
};
});
return db()
.transaction(() => {
// Reading a request body yields: retirement/rebuild/credential replacement may
// have completed since the HTTP handler first authenticated this request.
const current = db()
.prepare(
`SELECT 1 FROM native_instances n
JOIN servers s ON s.name=n.server
JOIN moderation_credentials c ON c.instance_id=n.id AND c.generation=n.generation
WHERE n.id=? AND n.generation=? AND n.retired_at IS NULL AND n.server=?
AND s.destroyed_at IS NULL AND s.game=? AND s.created_at=? AND c.token_hash=?`
)
.get(
identity.instanceId,
identity.generation,
identity.server,
identity.game,
identity.serverCreatedAt,
identity.credentialHash
);
if (!current)
throw new AuthError('This moderation runtime credential is no longer active.', 403);
const previous = db()
.prepare(
'SELECT session,session_sequence FROM moderation_runtimes WHERE instance_id=? AND generation=?'
)
.get(identity.instanceId, identity.generation) as
{ session: string; session_sequence: number } | undefined;
if (
previous &&
((input.sessionSequence as number) < previous.session_sequence ||
(input.sessionSequence === previous.session_sequence &&
session !== previous.session))
)
throw new AuthError('This runtime session has been superseded.', 409);
const now = moderationNow();
const revision = policyRevision();
if ((input.appliedRevision as number) > revision)
throw new AuthError(
'The runtime has a newer policy revision than this controller. Review the controller restore before reconfiguring this integration.',
409
);
refreshActionStates(now);
for (const receipt of receipts) {
const action = db()
.prepare(
'SELECT * FROM moderation_actions WHERE id=? AND instance_id=? AND generation=?'
)
.get(receipt.id, identity.instanceId, identity.generation) as
| {
id: string;
game: string;
guid: string | null;
session: string;
state: string;
delivered_at: string | null;
detail: string | null;
}
| undefined;
// Old journal receipts can outlive controller retention/restores, but cannot target other instances.
if (!action) continue;
if (!action.delivered_at)
throw new AuthError('An undelivered action cannot be acknowledged.', 409);
if (['confirmed', 'failed', 'expired'].includes(action.state)) continue;
// Durable native receipts may be resubmitted after a game process restart.
if (action.state === receipt.state && action.detail === receipt.detail) continue;
db()
.prepare(
'UPDATE moderation_actions SET state=?,detail=?,finished_at=? WHERE id=?'
)
.run(receipt.state, receipt.detail || null, now, action.id);
moderationEvent(
null,
`action.${receipt.state}`,
action,
action.id,
receipt.detail || receipt.state,
now
);
}
if (previous && previous.session !== session) {
const abandoned = db()
.prepare(
"SELECT id,game,guid,state FROM moderation_actions WHERE instance_id=? AND generation=? AND session<>? AND state IN ('queued','delivered')"
)
.all(identity.instanceId, identity.generation, session) as {
id: string;
game: string;
guid: string | null;
state: string;
}[];
for (const action of abandoned) {
const state = action.state === 'queued' ? 'expired' : 'unknown';
db()
.prepare(
'UPDATE moderation_actions SET state=?,detail=?,finished_at=? WHERE id=?'
)
.run(state, 'The game runtime changed before completion.', now, action.id);
moderationEvent(
null,
`action.${state}`,
action,
action.id,
'The game runtime changed before completion.',
now
);
}
}
db()
.prepare(
`INSERT INTO moderation_runtimes(instance_id,generation,session,capabilities,players,revision,connected_at,session_sequence) VALUES(?,?,?,?,?,?,?,?) ON CONFLICT(instance_id) DO UPDATE SET
generation=excluded.generation,session=excluded.session,capabilities=excluded.capabilities,players=excluded.players,
revision=excluded.revision,connected_at=excluded.connected_at,session_sequence=excluded.session_sequence`
)
.run(
identity.instanceId,
identity.generation,
session,
JSON.stringify(supported),
JSON.stringify(players),
input.appliedRevision,
now,
input.sessionSequence
);
observeModerationPlayers(db(), identity.game, players, now);
const sanctions = db()
.prepare(
`SELECT id,revision,guid,kind,reason,expires_at AS expiresAt FROM moderation_sanctions
WHERE revoked_at IS NULL AND (expires_at IS NULL OR expires_at>?) AND
((scope='fleet') OR (game=? AND (scope='game' OR (scope='server' AND server=? AND server_created_at=?)))) ORDER BY id LIMIT 1001`
)
.all(now, identity.game, identity.server, identity.serverCreatedAt);
if (sanctions.length > 1000)
throw new AuthError(
'The moderation policy exceeds the runtime’s supported size.',
503
);
const pending = db()
.prepare(
`SELECT * FROM moderation_actions WHERE instance_id=? AND generation=? AND session=?
AND state IN ('queued','delivered') AND expires_at>? ORDER BY created_at,id LIMIT 100`
)
.all(identity.instanceId, identity.generation, session, now) as {
id: string;
guid: string | null;
connection: string | null;
kind: string;
reason: string;
expires_at: string;
actor_id: string;
game: string;
state: string;
}[];
const actions = [];
for (const action of pending) {
const actor = getUser(action.actor_id);
let failure: string | null = null;
if (
!actor ||
actor.disabled ||
actor.mustChangePassword ||
!canModerate(actor.role)
)
failure = 'The requesting moderator no longer has access.';
else if (!supported.includes(action.kind))
failure = 'The runtime no longer supports this action.';
else if (
action.guid &&
!players.some(
(p) => p.guid === action.guid && p.connection === action.connection
)
)
failure = 'The targeted player connection has ended.';
if (failure) {
const state = action.state === 'delivered' ? 'unknown' : 'failed';
db()
.prepare(
'UPDATE moderation_actions SET state=?,detail=?,finished_at=? WHERE id=?'
)
.run(state, failure, now, action.id);
moderationEvent(null, `action.${state}`, action, action.id, failure, now);
continue;
}
db()
.prepare(
"UPDATE moderation_actions SET state='delivered',delivered_at=COALESCE(delivered_at,?) WHERE id=?"
)
.run(now, action.id);
actions.push({
id: action.id,
guid: action.guid,
connection: action.connection,
kind: action.kind,
reason: action.reason,
expiresAt: action.expires_at
});
}
const response = {
ok: true,
revision,
serverTime: now,
sanctions,
actions,
pollAfterMs: 3000
};
if (Buffer.byteLength(JSON.stringify(response)) > 1024 * 1024)
throw new AuthError(
'The moderation response exceeds the runtime’s supported size.',
503
);
return response;
})
.immediate();
}
+111
View File
@@ -0,0 +1,111 @@
import type Database from 'better-sqlite3';
import { moderationGuid, type ModerationGame } from '../../moderation.js';
export function migrateModeration(handle: Database.Database): void {
handle.exec(`
CREATE TABLE IF NOT EXISTS moderation_players (
game TEXT NOT NULL CHECK(game IN ('t5','t6')), guid TEXT NOT NULL,
name TEXT NOT NULL, first_seen_at TEXT NOT NULL, last_seen_at TEXT NOT NULL,
PRIMARY KEY(game,guid)
);
CREATE TABLE IF NOT EXISTS moderation_aliases (
game TEXT NOT NULL, guid TEXT NOT NULL, name TEXT NOT NULL,
first_seen_at TEXT NOT NULL, last_seen_at TEXT NOT NULL,
PRIMARY KEY(game,guid,name)
);
CREATE INDEX IF NOT EXISTS moderation_alias_name ON moderation_aliases(name COLLATE NOCASE);
CREATE TABLE IF NOT EXISTS moderation_policy (id INTEGER PRIMARY KEY CHECK(id=1), revision INTEGER NOT NULL);
INSERT OR IGNORE INTO moderation_policy VALUES(1,1);
CREATE TABLE IF NOT EXISTS moderation_sanctions (
id TEXT PRIMARY KEY, game TEXT NOT NULL CHECK(game IN ('t5','t6')), guid TEXT NOT NULL,
kind TEXT NOT NULL CHECK(kind IN ('ban','mute')),
scope TEXT NOT NULL CHECK(scope IN ('server','game','fleet')),
server TEXT, server_created_at TEXT, reason TEXT NOT NULL, note TEXT NOT NULL,
actor_id TEXT NOT NULL, actor TEXT NOT NULL,
created_at TEXT NOT NULL, expires_at TEXT, revoked_at TEXT,
revision INTEGER NOT NULL DEFAULT 1,
CHECK((scope='server' AND server IS NOT NULL AND server_created_at IS NOT NULL)
OR (scope!='server' AND server IS NULL AND server_created_at IS NULL))
);
CREATE INDEX IF NOT EXISTS moderation_sanction_subject ON moderation_sanctions(guid,game,revoked_at,expires_at);
CREATE TABLE IF NOT EXISTS moderation_actions (
id TEXT PRIMARY KEY, game TEXT NOT NULL, guid TEXT, server TEXT NOT NULL,
instance_id TEXT NOT NULL, generation INTEGER NOT NULL,
session TEXT NOT NULL, connection TEXT,
kind TEXT NOT NULL CHECK(kind IN ('warn','message','kick','announce')),
reason TEXT NOT NULL, actor_id TEXT NOT NULL, actor TEXT NOT NULL,
created_at TEXT NOT NULL, expires_at TEXT NOT NULL,
state TEXT NOT NULL CHECK(state IN ('queued','delivered','confirmed','failed','expired','unknown')),
detail TEXT, delivered_at TEXT, finished_at TEXT
);
CREATE INDEX IF NOT EXISTS moderation_action_delivery ON moderation_actions(instance_id,generation,state,expires_at);
CREATE TABLE IF NOT EXISTS moderation_cases (
id TEXT PRIMARY KEY, game TEXT NOT NULL, guid TEXT NOT NULL, title TEXT NOT NULL,
status TEXT NOT NULL CHECK(status IN ('open','resolved','dismissed')),
actor_id TEXT NOT NULL, actor TEXT NOT NULL,
created_at TEXT NOT NULL, updated_at TEXT NOT NULL, revision INTEGER NOT NULL DEFAULT 1
);
CREATE TABLE IF NOT EXISTS moderation_evidence (
id TEXT PRIMARY KEY, case_id TEXT NOT NULL REFERENCES moderation_cases(id),
text TEXT NOT NULL, source TEXT NOT NULL, snapshot TEXT,
actor_id TEXT NOT NULL, actor TEXT NOT NULL, created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS moderation_events (
id INTEGER PRIMARY KEY AUTOINCREMENT, game TEXT, guid TEXT,
actor_id TEXT, actor TEXT NOT NULL, action TEXT NOT NULL,
subject_id TEXT, detail TEXT NOT NULL, created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS moderation_event_subject ON moderation_events(game,guid,id);
CREATE TABLE IF NOT EXISTS moderation_requests (
actor_id TEXT NOT NULL, request_key TEXT NOT NULL,
fingerprint TEXT NOT NULL, result TEXT NOT NULL, created_at TEXT NOT NULL,
PRIMARY KEY(actor_id,request_key)
);
CREATE TABLE IF NOT EXISTS moderation_credentials (
instance_id TEXT NOT NULL, generation INTEGER NOT NULL,
token TEXT NOT NULL, token_hash TEXT NOT NULL UNIQUE, created_at TEXT NOT NULL,
PRIMARY KEY(instance_id,generation)
);
CREATE TABLE IF NOT EXISTS moderation_runtimes (
instance_id TEXT PRIMARY KEY, generation INTEGER NOT NULL,
session TEXT NOT NULL, capabilities TEXT NOT NULL, players TEXT NOT NULL,
revision INTEGER NOT NULL DEFAULT 0, connected_at TEXT NOT NULL
);
`);
const player = handle.prepare(`INSERT INTO moderation_players VALUES(?,?,?,?,?)
ON CONFLICT(game,guid) DO UPDATE SET
name=CASE WHEN excluded.last_seen_at>=last_seen_at THEN excluded.name ELSE name END,
first_seen_at=MIN(first_seen_at,excluded.first_seen_at),last_seen_at=MAX(last_seen_at,excluded.last_seen_at)`);
const alias = handle.prepare(`INSERT INTO moderation_aliases VALUES(?,?,?,?,?)
ON CONFLICT(game,guid,name) DO UPDATE SET
first_seen_at=MIN(first_seen_at,excluded.first_seen_at),last_seen_at=MAX(last_seen_at,excluded.last_seen_at)`);
type Player = {
game: ModerationGame;
guid: string;
name: string;
first_seen_at: string;
last_seen_at: string;
};
const read = handle.prepare(`SELECT * FROM leaderboard_players
WHERE game IN ('t5','t6') AND (game,guid)>(?,?) ORDER BY game,guid LIMIT 1000`);
let cursor = ['', ''];
for (;;) {
// Finish each read before writing on this connection, without loading
// the full historical player directory into memory during an upgrade.
const rows = read.all(...cursor) as Player[];
if (!rows.length) break;
for (const row of rows) {
const values = [
row.game,
moderationGuid(row.game, row.guid),
row.name,
row.first_seen_at,
row.last_seen_at
];
player.run(...values);
alias.run(...values);
}
const last = rows[rows.length - 1];
cursor = [last.game, last.guid];
}
}
@@ -5,6 +5,7 @@ import { db } from '../db/index.js';
import type { NativeInstance } from '../backends/native.js'; import type { NativeInstance } from '../backends/native.js';
import type { LaunchRecipePlan } from '../fleet/launch-recipes.js'; import type { LaunchRecipePlan } from '../fleet/launch-recipes.js';
import type { PlacementOptions } from '../hosts/capacity.js'; import type { PlacementOptions } from '../hosts/capacity.js';
import type { ModerationConfiguration } from '../moderation/configuration.js';
export interface NativeLaunchInput { export interface NativeLaunchInput {
game: 't5' | 't6'; game: 't5' | 't6';
@@ -14,6 +15,7 @@ export interface NativeLaunchInput {
serverKey: string; serverKey: string;
rconPassword: string; rconPassword: string;
bankToken: string; bankToken: string;
moderation?: ModerationConfiguration;
rebuild: boolean; rebuild: boolean;
safetyOverride?: boolean; safetyOverride?: boolean;
unhealthyRecovery?: boolean; unhealthyRecovery?: boolean;
+11 -5
View File
@@ -12,6 +12,7 @@ import {
import { sha256Buffer } from '../fleet/files.js'; import { sha256Buffer } from '../fleet/files.js';
import type { NativeLaunchInput } from './launch-preparation.js'; import type { NativeLaunchInput } from './launch-preparation.js';
import { choosePlacement, type PlacementOptions } from '../hosts/capacity.js'; import { choosePlacement, type PlacementOptions } from '../hosts/capacity.js';
import { isPrivateConfigPath } from '../fleet/private-config.js';
parentPort!.on( parentPort!.on(
'message', 'message',
@@ -79,12 +80,16 @@ parentPort!.on(
'electric_cherry_bank.cfg' 'electric_cherry_bank.cfg'
); );
if ( if (
recipe.files.some( recipe.files.some((file) =>
(file) => isPrivateConfigPath(
posix.join(relDestForKind(file.kind, recipe.game), file.path) === bankPath posix.join(relDestForKind(file.kind, recipe.game), file.path),
recipe.game
) )
) )
throw new Error('electric_cherry_bank.cfg is reserved for instance credentials.'); )
throw new Error(
'Private configuration files are reserved for instance credentials.'
);
const payload = { const payload = {
...(input.activationId ? { stageId: input.activationId } : {}), ...(input.activationId ? { stageId: input.activationId } : {}),
name: instance.server, name: instance.server,
@@ -95,13 +100,14 @@ parentPort!.on(
bindAddress: instance.bind_address, bindAddress: instance.bind_address,
rconPassword: password, rconPassword: password,
bank: { token: input.bankToken, configPath: bankPath }, bank: { token: input.bankToken, configPath: bankPath },
...(input.moderation ? { moderation: input.moderation } : {}),
env: { env: {
PLUTAINER_SERVER_NAME: instance.server, PLUTAINER_SERVER_NAME: instance.server,
PLUTAINER_PORT: String(instance.port), PLUTAINER_PORT: String(instance.port),
PLUTAINER_GAME: recipe.game === 't6' ? 't6zm' : 't5sp', PLUTAINER_GAME: recipe.game === 't6' ? 't6zm' : 't5sp',
PLUTAINER_CONFIG_FILE: recipe.configFile, PLUTAINER_CONFIG_FILE: recipe.configFile,
PLUTAINER_MAP_ROTATE: 'false', PLUTAINER_MAP_ROTATE: 'false',
PLUTAINER_EXTRA_ARGS: `+exec electric_cherry_map.cfg +exec electric_cherry_bank.cfg +set tws_server_id ${instance.server} +map_rotate`, PLUTAINER_EXTRA_ARGS: `+exec electric_cherry_map.cfg +exec electric_cherry_bank.cfg${input.moderation ? ' +exec electric_cherry_moderation.cfg' : ''} +set tws_server_id ${instance.server} +map_rotate`,
PLUTO_SERVER_KEY: input.serverKey PLUTO_SERVER_KEY: input.serverKey
}, },
files: recipe.files.map((file) => ({ files: recipe.files.map((file) => ({
+30 -1
View File
@@ -14,6 +14,11 @@ import { setImmediate as yieldToController } from 'node:timers/promises';
import { choosePlacement, availableHostPort, type PlacementOptions } from '../hosts/capacity.js'; import { choosePlacement, availableHostPort, type PlacementOptions } from '../hosts/capacity.js';
import { randomBytes, randomUUID } from 'node:crypto'; import { randomBytes, randomUUID } from 'node:crypto';
import { bankCredential } from '../auth/bank.js'; import { bankCredential } from '../auth/bank.js';
import { moderationCredential } from '../moderation/credentials.js';
import {
moderationConfiguration,
moderationConfigureCapability
} from '../moderation/configuration.js';
import { posix } from 'node:path'; import { posix } from 'node:path';
import { db } from '../db/index.js'; import { db } from '../db/index.js';
import { unsettledCancelledCommand } from './cancellation.js'; import { unsettledCancelledCommand } from './cancellation.js';
@@ -1996,6 +2001,7 @@ async function processJob(job: Job, eligibility: (job: Job) => string | null) {
let prepared: PreparedNativeLaunch | undefined; let prepared: PreparedNativeLaunch | undefined;
let preparedKey: string | null = null; let preparedKey: string | null = null;
let preparedPassword: string | undefined; let preparedPassword: string | undefined;
let preparedModeration = false;
try { try {
if (job.action === 'create' || job.action === 'rebuild') { if (job.action === 'create' || job.action === 'rebuild') {
if ( if (
@@ -2027,6 +2033,11 @@ async function processJob(job: Job, eligibility: (job: Job) => string | null) {
(files.length ? runtimePasswordForServer(job.server) : undefined) || (files.length ? runtimePasswordForServer(job.server) : undefined) ||
config.rcon.password || config.rcon.password ||
randomBytes(32).toString('hex'); randomBytes(32).toString('hex');
const rebuilding = job.action === 'rebuild' && !migration;
const generation = instance.generation + (rebuilding ? 1 : 0);
preparedModeration =
!!config.publicUrl &&
!!host.inventory.capabilities?.includes(moderationConfigureCapability);
prepared = await prepareNativeLaunch({ prepared = await prepareNativeLaunch({
game: prior.game, game: prior.game,
mapSlug: prior.mapSlug, mapSlug: prior.mapSlug,
@@ -2035,7 +2046,15 @@ async function processJob(job: Job, eligibility: (job: Job) => string | null) {
serverKey: preparedKey, serverKey: preparedKey,
rconPassword: preparedPassword, rconPassword: preparedPassword,
bankToken: bankCredential(instance.id), bankToken: bankCredential(instance.id),
rebuild: job.action === 'rebuild' && !migration, ...(preparedModeration
? {
moderation: moderationConfiguration(
moderationCredential(instance.id, generation),
generation
)
}
: {}),
rebuild: rebuilding,
safetyOverride: overrideSafety, safetyOverride: overrideSafety,
unhealthyRecovery: unhealthyRecovery:
!files.length && !files.length &&
@@ -2124,6 +2143,16 @@ async function processJob(job: Job, eligibility: (job: Job) => string | null) {
return; return;
} }
if (prepared) { if (prepared) {
if (
preparedModeration &&
!liveHost.inventory.capabilities?.includes(moderationConfigureCapability)
) {
hold(
job,
'Host moderation support changed during preparation; preparing the current selection next pass.'
);
return;
}
// Compare the same runtime config bytes while rechecking current promoted files. // Compare the same runtime config bytes while rechecking current promoted files.
const expected = const expected =
migration && isRebalanceMigration(job.id) migration && isRebalanceMigration(job.id)
+25
View File
@@ -1,5 +1,8 @@
<script lang="ts"> <script lang="ts">
import { onMount, tick } from 'svelte'; import { onMount, tick } from 'svelte';
import { page } from '$app/stores';
import { canReadModeration } from '$lib/auth.js';
import { moderationHref } from '$ui/moderation.js';
import type { ChatContext, ContextualChatEntry } from '$lib/game-chat.js'; import type { ChatContext, ContextualChatEntry } from '$lib/game-chat.js';
import { formatDuration } from '$ui/duration.js'; import { formatDuration } from '$ui/duration.js';
import { groupChatTimeline, type ChatBoundaryEntry } from '$ui/chat-timeline.js'; import { groupChatTimeline, type ChatBoundaryEntry } from '$ui/chat-timeline.js';
@@ -10,16 +13,19 @@
let { let {
server, server,
game,
history, history,
hostOffline = false, hostOffline = false,
hostName hostName
}: { }: {
server?: string; server?: string;
game?: string;
history?: ContextualChatEntry[]; history?: ContextualChatEntry[];
hostOffline?: boolean; hostOffline?: boolean;
hostName?: string; hostName?: string;
} = $props(); } = $props();
let updatedAt = $state<number | null>(null); let updatedAt = $state<number | null>(null);
const moderationGame = $derived(game ?? server?.split('_')[0]);
let liveEntries = $state<ContextualChatEntry[]>([]); let liveEntries = $state<ContextualChatEntry[]>([]);
const entries = $derived(history ?? liveEntries); const entries = $derived(history ?? liveEntries);
let timeline = $derived(groupChatTimeline(entries)); let timeline = $derived(groupChatTimeline(entries));
@@ -242,6 +248,18 @@
<div class="message-head"> <div class="message-head">
<strong>{item.entry.message.player}</strong <strong>{item.entry.message.player}</strong
>{@render chatContext(item.entry.context)} >{@render chatContext(item.entry.context)}
{#if canReadModeration($page.data?.user?.role) && (moderationGame === 't5' || moderationGame === 't6')}<a
class="moderation-link"
href={moderationHref({
game: moderationGame,
q: item.entry.message.guid,
server,
matchId: item.entry.matchId,
message: item.entry.message.message
})}
aria-label={`Review ${item.entry.message.player} in moderation`}
>Moderation</a
>{/if}
</div> </div>
<p>{item.entry.message.message}</p> <p>{item.entry.message.message}</p>
</div> </div>
@@ -371,6 +389,9 @@
white-space: pre-wrap; white-space: pre-wrap;
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
.moderation-link {
font-size: var(--text-xs);
}
.match-events { .match-events {
padding: var(--space-1) 0; padding: var(--space-1) 0;
} }
@@ -463,5 +484,9 @@
grid-row: 3; grid-row: 3;
margin-top: 0; margin-top: 0;
} }
.moderation-link {
grid-column: 1 / -1;
grid-row: 4;
}
} }
</style> </style>
@@ -1,5 +1,8 @@
<script lang="ts"> <script lang="ts">
import { timeFormat } from '$ui/stores/time-zone.js'; import { timeFormat } from '$ui/stores/time-zone.js';
import { page } from '$app/stores';
import { canReadModeration } from '$lib/auth.js';
import { moderationHref } from '$ui/moderation.js';
import type { GameTelemetry } from '$lib/telemetry.js'; import type { GameTelemetry } from '$lib/telemetry.js';
import { freshTelemetry } from '$lib/server-observation.js'; import { freshTelemetry } from '$lib/server-observation.js';
import { uiNow } from '$ui/stores/servers.js'; import { uiNow } from '$ui/stores/servers.js';
@@ -164,6 +167,14 @@
<a class="player-name" href={playerProfileHref(player.guid)} <a class="player-name" href={playerProfileHref(player.guid)}
>{player.name}</a >{player.name}</a
> >
{#if canReadModeration($page.data?.user?.role)}<a
class="player-details"
href={moderationHref({
game: report.server.split('_')[0],
guid: player.guid,
server: report.server
})}>Moderation</a
>{/if}
<button <button
class="player-details" class="player-details"
aria-label={`Details for ${player.name}`} aria-label={`Details for ${player.name}`}
@@ -0,0 +1,408 @@
<script lang="ts">
import { untrack } from 'svelte';
import type { ModerationCoverage, ModerationGame, ModerationScope } from '$lib/moderation.js';
import { moderationGameName } from '$ui/moderation.js';
import { serverTitle } from '$ui/server-labels.js';
import { confirmAction } from '$ui/confirm.js';
import Dialog from '../primitives/Dialog.svelte';
import Field from '../primitives/Field.svelte';
import Input from '../primitives/Input.svelte';
import Select from '../primitives/Select.svelte';
import Textarea from '../primitives/Textarea.svelte';
import Button from '../primitives/Button.svelte';
let {
open = $bindable(false),
mode,
coverage,
admin,
seed = {},
onsubmitted
}: {
open?: boolean;
mode: 'restriction' | 'action' | 'case' | 'note' | 'revoke' | 'case-note' | 'resolve';
coverage: ModerationCoverage[];
admin: boolean;
seed?: {
game?: ModerationGame;
guid?: string;
server?: string;
id?: string;
revision?: number;
matchId?: number;
message?: string;
};
onsubmitted: (target: { game?: ModerationGame; guid?: string | null }) => void;
} = $props();
let game = $state<ModerationGame | ''>(untrack(() => seed.game ?? ''));
let guid = $state(untrack(() => seed.guid ?? ''));
let server = $state(untrack(() => seed.server ?? ''));
let scope = $state<ModerationScope>(untrack(() => (seed.server ? 'server' : 'game')));
let kind = $state('ban');
let action = $state('warn');
let duration = $state('60');
let reason = $state('');
let note = $state('');
let title = $state('');
const reasonBytes = $derived(new TextEncoder().encode(reason.trim()).length);
const gameText = $derived(mode === 'restriction' || mode === 'action');
let resolution = $state('resolved');
let busy = $state(false);
let error = $state('');
let uncertain = $state(false);
let pending: Record<string, unknown> | null = null;
const titleText = $derived(
{
restriction: 'Restrict player',
action: 'Live action',
case: 'Open moderation case',
note: 'Add private note',
revoke: 'Revoke restriction',
'case-note': 'Add case note',
resolve: 'Review case'
}[mode]
);
const targetRequired = $derived(['restriction', 'action', 'case', 'note'].includes(mode));
const needsServer = $derived(
mode === 'action' || (mode === 'restriction' && scope === 'server')
);
const serverOptions = $derived([
{ value: '', label: 'Select a server' },
...coverage
.filter((item) => item.game === game)
.map((item) => ({ value: item.server, label: serverTitle(item.server) }))
]);
const selectedServer = $derived(
coverage.find((item) => item.server === server && item.game === game)
);
const actionUnavailable = $derived(
mode === 'action' &&
(!selectedServer ||
['offline', 'unavailable'].includes(selectedServer.state) ||
!selectedServer.capabilities.includes(action))
);
const actionOptions = $derived(
[
{ value: 'warn', label: 'Warn player' },
{ value: 'message', label: 'Private message' },
{ value: 'kick', label: 'Kick player' },
{ value: 'announce', label: 'Server announcement' }
].map((item) => ({ ...item, disabled: !selectedServer?.capabilities.includes(item.value) }))
);
function payload(): Record<string, unknown> {
if (mode === 'restriction')
return {
op: 'create-sanction',
game,
guid: guid.trim(),
kind,
scope,
server: scope === 'server' ? server : undefined,
reason: reason.trim(),
note: note.trim(),
durationMinutes: duration === 'permanent' ? null : Number(duration)
};
if (mode === 'action')
return {
op: 'action',
game,
guid: action === 'announce' ? null : guid.trim(),
server,
kind: action,
reason: reason.trim()
};
if (mode === 'case')
return {
op: 'create-case',
game,
guid: guid.trim(),
title: title.trim(),
note: note.trim(),
...(seed.matchId && seed.message
? { evidence: { matchId: seed.matchId, message: seed.message } }
: {})
};
if (mode === 'note') return { op: 'note', game, guid: guid.trim(), text: note.trim() };
if (mode === 'case-note') return { op: 'case-note', id: seed.id, text: note.trim() };
if (mode === 'revoke')
return {
op: 'revoke-sanction',
id: seed.id,
revision: seed.revision,
reason: reason.trim()
};
return {
op: 'resolve-case',
id: seed.id,
revision: seed.revision,
status: resolution,
reason: reason.trim()
};
}
async function submit(event: SubmitEvent) {
event.preventDefault();
if (busy) return;
if (!pending && gameText && reasonBytes > 240) {
error = 'The player-visible text is too long. Shorten it to 240 bytes or fewer.';
return;
}
if (!pending && targetRequired && !game) {
error = 'Choose the game for this player identity.';
return;
}
if (!pending && actionUnavailable) {
error = 'The selected server does not currently support this action.';
return;
}
busy = true;
error = '';
if (!pending && mode === 'restriction' && (scope === 'fleet' || duration === 'permanent')) {
const accepted = await confirmAction({
title: 'Confirm restriction',
message: `${kind === 'ban' ? 'Ban' : 'Mute'} player ${guid} ${scope === 'fleet' ? 'across the fleet' : scope === 'game' ? `in ${moderationGameName(game as ModerationGame)}` : `on ${serverTitle(server)}`} ${duration === 'permanent' ? 'permanently' : `for ${Number(duration).toLocaleString()} minutes`}. Reason: ${reason}`,
confirmLabel: 'Apply restriction',
danger: true
});
if (!accepted) {
busy = false;
return;
}
}
pending ??= { ...payload(), idempotencyKey: crypto.randomUUID() };
try {
const response = await fetch('/api/moderation', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(pending)
});
const result = await response.json().catch(() => null);
if (!response.ok || !result?.ok) {
uncertain = response.status >= 500 || response.ok;
if (!uncertain) pending = null;
throw new Error(result?.error || 'Could not save this moderation action.');
}
open = false;
onsubmitted({
game: pending.game as ModerationGame | undefined,
guid: pending.guid as string | null | undefined
});
} catch (cause) {
if (pending) uncertain = true;
error =
cause instanceof Error ? cause.message : 'Could not save this moderation action.';
} finally {
busy = false;
}
}
</script>
<Dialog
bind:open
title={titleText}
{busy}
description="Every moderation change is recorded with your identity and reason."
>
<form onsubmit={submit} class="moderation-form">
<fieldset disabled={busy || uncertain}>
{#if targetRequired}
<Field label="Game" required
><Select
bind:value={game}
required
onchange={() => (server = '')}
options={[
{ value: '', label: 'Select a game' },
{ value: 't5', label: 'Black Ops I' },
{ value: 't6', label: 'Black Ops II' }
]}
/></Field
>
{#if mode === 'action'}
<Field label="Action"
><Select bind:value={action} options={actionOptions} /></Field
>
{/if}
{#if mode !== 'action' || action !== 'announce'}
<Field
label="Player GUID"
required
hint="Use the recorded player identity. Names can change."
><Input name="target-guid" bind:value={guid} required mono /></Field
>
{/if}
{/if}
{#if mode === 'restriction'}
<div class="paired">
<Field label="Restriction"
><Select
bind:value={kind}
options={[
{ value: 'ban', label: 'Ban' },
{ value: 'mute', label: 'Mute' }
]}
/></Field
>
<Field label="Scope"
><Select
bind:value={scope}
options={[
{ value: 'server', label: 'One server' },
{ value: 'game', label: 'This game' },
...(admin ? [{ value: 'fleet', label: 'Entire fleet' }] : [])
]}
/></Field
>
</div>
<Field
label="Duration"
hint={admin
? undefined
: 'Moderators can restrict a player for up to seven days.'}
>
<Select
bind:value={duration}
options={[
{ value: '15', label: '15 minutes' },
{ value: '60', label: '1 hour' },
{ value: '1440', label: '1 day' },
{ value: '10080', label: '7 days' },
...(admin
? [
{ value: '43200', label: '30 days' },
{ value: 'permanent', label: 'Permanent' }
]
: [])
]}
/>
</Field>
{#if scope === 'fleet'}<p class="hint">
Fleet restrictions require a verified player identity across games.
</p>{/if}
{/if}
{#if needsServer}<Field label="Server" required
><Select bind:value={server} options={serverOptions} required /></Field
>{/if}
{#if actionUnavailable}<p class="hint">
Select a connected server with support for this action. Unavailable actions
cannot be sent.
</p>{/if}
{#if mode === 'restriction'}<p class="hint">
Restrictions are saved immediately. They take effect only on servers with the
required capability and current policy; review enforcement coverage after
saving.
</p>{/if}
{#if mode === 'case'}
<Field label="Case title" required
><Input bind:value={title} required maxlength={160} /></Field
>
{#if seed.matchId && seed.message}<div class="evidence">
<strong>Selected chat evidence · Match #{seed.matchId}</strong>
<p>{seed.message}</p>
<small
>The original archived message and its context will be preserved when
the case is saved.</small
>
</div>{/if}
{/if}
{#if mode === 'resolve'}<Field label="Decision"
><Select
bind:value={resolution}
options={[
{ value: 'resolved', label: 'Resolved' },
{ value: 'dismissed', label: 'Dismissed' }
]}
/></Field
>{/if}
{#if ['restriction', 'action', 'revoke', 'resolve'].includes(mode)}
<Field
label={mode === 'action' && ['message', 'announce'].includes(action)
? 'Message'
: mode === 'restriction' || mode === 'action'
? 'Player-visible reason'
: 'Review reason'}
hint={gameText
? `${reasonBytes} / 240 bytes. Some characters use more than one byte.`
: undefined}
required
><Textarea
bind:value={reason}
required
rows={3}
maxlength={gameText ? 240 : undefined}
/></Field
>
{/if}
{#if ['restriction', 'case', 'note', 'case-note'].includes(mode)}
<Field label="Private staff note" required={mode === 'note' || mode === 'case-note'}
><Textarea
bind:value={note}
required={mode === 'note' || mode === 'case-note'}
rows={4}
/></Field
>
{/if}
</fieldset>
{#if error}<p class="ui-error" role="alert">{error}</p>{/if}
{#if uncertain}<p class="hint" role="status">
The outcome is not yet known. Retry sends the same request safely; check History
before starting a different action.
</p>{/if}
<div class="actions">
<Button disabled={busy} onclick={() => (open = false)}>Cancel</Button><Button
type="submit"
disabled={!uncertain && actionUnavailable}
variant={mode === 'restriction' || mode === 'revoke' ? 'danger' : 'primary'}
loading={busy}>{uncertain ? 'Retry same request' : 'Save'}</Button
>
</div>
</form>
</Dialog>
<style>
.moderation-form,
fieldset {
display: grid;
gap: var(--space-4);
min-width: 0;
}
fieldset {
border: 0;
padding: 0;
margin: 0;
}
.paired {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-3);
}
.actions {
display: flex;
justify-content: flex-end;
gap: var(--space-3);
}
.hint,
.evidence {
margin: 0;
color: var(--text-secondary);
font-size: var(--text-sm);
}
.evidence {
padding: var(--space-3);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
overflow-wrap: anywhere;
}
.evidence p {
white-space: pre-wrap;
}
.evidence small {
font-size: var(--text-xs);
}
@media (max-width: 640px) {
.paired {
grid-template-columns: 1fr;
}
}
</style>
@@ -11,6 +11,7 @@
required?: boolean; required?: boolean;
disabled?: boolean; disabled?: boolean;
readonly?: boolean; readonly?: boolean;
maxlength?: number;
mono?: boolean; mono?: boolean;
oninput?: (e: Event) => void; oninput?: (e: Event) => void;
ariaLabel?: string; ariaLabel?: string;
@@ -25,6 +26,7 @@
required = false, required = false,
disabled = false, disabled = false,
readonly = false, readonly = false,
maxlength,
mono = false, mono = false,
oninput, oninput,
ariaLabel ariaLabel
@@ -41,6 +43,7 @@
{required} {required}
{disabled} {disabled}
{readonly} {readonly}
{maxlength}
aria-label={ariaLabel} aria-label={ariaLabel}
aria-labelledby={ariaLabel ? undefined : field?.labelId} aria-labelledby={ariaLabel ? undefined : field?.labelId}
aria-describedby={field?.messageId} aria-describedby={field?.messageId}
+91
View File
@@ -0,0 +1,91 @@
import type { ModerationGame } from '$lib/moderation.js';
import { serverTitle } from './server-labels.js';
export function moderationHref(context: {
game?: string;
guid?: string;
q?: string;
server?: string;
matchId?: number;
message?: string;
}): string {
const params = new URLSearchParams();
if (context.game === 't5' || context.game === 't6') params.set('game', context.game);
if (context.guid) params.set('guid', context.guid);
if (context.q) params.set('q', context.q);
if (context.server) params.set('server', context.server);
if (context.matchId !== undefined && context.message) {
params.set('matchId', String(context.matchId));
params.set('message', context.message);
}
return `/moderation?${params}`;
}
export function moderationGameName(game: ModerationGame): string {
return game === 't5' ? 'Black Ops I' : 'Black Ops II';
}
export const moderationActionLabels: Record<string, string> = {
warn: 'Warning',
message: 'Private message',
kick: 'Kick',
announce: 'Announcement',
'create-sanction': 'Restriction created',
'revoke-sanction': 'Restriction revoked',
'create-case': 'Case opened',
'case-note': 'Case note added',
'resolve-case': 'Case reviewed',
note: 'Private note',
'restriction.created': 'Restriction created',
'restriction.revoked': 'Restriction revoked',
'action.queued': 'Live action requested',
'action.confirmed': 'Live action confirmed',
'action.failed': 'Live action failed',
'action.expired': 'Live action expired',
'action.unknown': 'Live action outcome unknown',
'case.opened': 'Case opened',
'case.note': 'Case note added',
'case.resolved': 'Case resolved',
'case.dismissed': 'Case dismissed',
'player.note': 'Private player note',
'runtime.configure': 'Integration configuration requested'
};
export function moderationEventDetail(detail: string, action: string): string {
if (!['restriction.created', 'action.queued'].includes(action)) return detail;
try {
const value: unknown = JSON.parse(detail);
if (
!value ||
typeof value !== 'object' ||
!('reason' in value) ||
typeof value.reason !== 'string'
)
return detail;
const record = value as Record<string, unknown>;
const context = [
typeof record.kind === 'string'
? (moderationActionLabels[record.kind] ??
(record.kind === 'ban' ? 'Ban' : record.kind === 'mute' ? 'Mute' : ''))
: '',
typeof record.server === 'string'
? serverTitle(record.server)
: record.scope === 'fleet'
? 'Entire fleet'
: record.scope === 'game'
? 'Game scope'
: ''
]
.filter(Boolean)
.join(' · ');
return [
context,
record.reason,
typeof record.note === 'string' && record.note ? `Private note: ${record.note}` : ''
]
.filter(Boolean)
.join('\n');
} catch {
return detail;
}
}
+15
View File
@@ -16,6 +16,21 @@ export const statusShapes: Record<StatusTone, StatusShape> = {
}; };
const states: Record<string, [string, StatusTone]> = { const states: Record<string, [string, StatusTone]> = {
'moderation-current': ['Policy current', 'online'],
'moderation-pending': ['Policy pending', 'warning'],
'moderation-offline': ['Offline', 'danger'],
'moderation-unavailable': ['Unavailable', 'warning'],
'moderation-active': ['Active', 'warning'],
'moderation-revoked': ['Revoked', 'neutral'],
'moderation-open': ['Open', 'info'],
'moderation-resolved': ['Resolved', 'online'],
'moderation-dismissed': ['Dismissed', 'neutral'],
'moderation-queued': ['Queued', 'neutral'],
'moderation-delivered': ['Delivered; awaiting confirmation', 'info'],
'moderation-confirmed': ['Confirmed', 'online'],
'moderation-failed': ['Failed', 'danger'],
'moderation-expired': ['Expired', 'neutral'],
'moderation-unknown': ['Outcome unknown', 'warning'],
provisioning: ['Provisioning', 'info'], provisioning: ['Provisioning', 'info'],
online: ['Online', 'online'], online: ['Online', 'online'],
offline: ['Offline', 'danger'], offline: ['Offline', 'danger'],
+34
View File
@@ -0,0 +1,34 @@
import { json, type RequestHandler } from '@sveltejs/kit';
import { authBody, authError, requireUser } from '$lib/server/auth/http.js';
import { moderationOverview, moderationQuery } from '$lib/server/moderation/overview.js';
import { mutateModeration } from '$lib/server/moderation/actions.js';
import { configureModerationServer } from '$lib/server/moderation/configure.js';
import { moderationMutation, textField } from '$lib/server/moderation/core.js';
const headers = { 'Cache-Control': 'no-store' };
export const GET: RequestHandler = ({ locals, url }) => {
try {
return json(
moderationOverview(requireUser(locals.user).id, moderationQuery(url.searchParams)),
{ headers }
);
} catch (cause) {
return authError(cause);
}
};
export const POST: RequestHandler = async ({ locals, request }) => {
try {
const actor = requireUser(locals.user);
const body = await authBody(request, 16 * 1024);
if (body.op === 'configure')
return json(
moderationMutation(actor.id, body, () =>
configureModerationServer(actor.id, textField(body.server, 'Server', 255))
),
{ headers }
);
return json(mutateModeration(actor.id, body), { headers });
} catch (cause) {
return authError(cause);
}
};
@@ -0,0 +1,20 @@
import { json, type RequestHandler } from '@sveltejs/kit';
import { authBody, authError } from '$lib/server/auth/http.js';
import { authenticateModerationRuntime } from '$lib/server/moderation/credentials.js';
import { syncModerationRuntime } from '$lib/server/moderation/runtime.js';
export const POST: RequestHandler = async ({ request }) => {
const identity = authenticateModerationRuntime(request);
if (!identity)
return json(
{ error: 'Unauthorized moderation runtime.' },
{ status: 401, headers: { 'Cache-Control': 'no-store' } }
);
try {
return json(syncModerationRuntime(identity, await authBody(request, 256 * 1024)), {
headers: { 'Cache-Control': 'no-store' }
});
} catch (cause) {
return authError(cause);
}
};
@@ -7,6 +7,8 @@
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { goto, invalidateAll } from '$app/navigation'; import { goto, invalidateAll } from '$app/navigation';
import { page } from '$app/stores'; import { page } from '$app/stores';
import { canReadModeration } from '$lib/auth.js';
import { moderationHref } from '$ui/moderation.js';
import { contextHref } from '$lib/navigation-context.js'; import { contextHref } from '$lib/navigation-context.js';
import { import {
leaderboardMetrics, leaderboardMetrics,
@@ -151,7 +153,17 @@
fallback={data.returnTo} fallback={data.returnTo}
items={[{ label: 'Leaderboard', href: '/leaderboard' }, { label: profile.name }]} items={[{ label: 'Leaderboard', href: '/leaderboard' }, { label: profile.name }]}
/>{/snippet} />{/snippet}
{#snippet actions()}<Freshness {#snippet actions()}{#if canReadModeration($page.data?.user?.role)}<Button
href={moderationHref({
guid: profile.guid,
game:
scope.game ||
(profile.maps.length &&
profile.maps.every((map) => map.game === profile.maps[0].game)
? profile.maps[0].game
: undefined)
})}>Moderation</Button
>{/if}<Freshness
{updatedAt} {updatedAt}
error={refreshError} error={refreshError}
intervalLabel="every 30 s" intervalLabel="every 30 s"
+1 -1
View File
@@ -183,7 +183,7 @@
{/if} {/if}
</section> </section>
<Card padding="lg"><ChatPanel history={match.chat} /></Card> <Card padding="lg"><ChatPanel history={match.chat} game={match.game} /></Card>
<details class="note"> <details class="note">
<summary>About these results</summary> <summary>About these results</summary>
+7
View File
@@ -0,0 +1,7 @@
import { requireUser } from '$lib/server/auth/http.js';
import { moderationOverview, moderationQuery } from '$lib/server/moderation/overview.js';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = ({ locals, url }) => ({
moderation: moderationOverview(requireUser(locals.user).id, moderationQuery(url.searchParams))
});
+821
View File
@@ -0,0 +1,821 @@
<script lang="ts">
import { onMount, untrack } from 'svelte';
import { beforeNavigate, goto } from '$app/navigation';
import { page, navigating } from '$app/stores';
import { canModerate } from '$lib/auth.js';
import {
canRevokeModerationRestriction,
type ModerationOverview,
type ModerationQuery,
type ModerationGame
} from '$lib/moderation.js';
import { playerProfileHref } from '$lib/leaderboard.js';
import { contextHref } from '$lib/navigation-context.js';
import {
moderationActionLabels,
moderationGameName,
moderationEventDetail
} from '$ui/moderation.js';
import { timeFormat } from '$ui/stores/time-zone.js';
import { pollVisible } from '$ui/poll.js';
import { confirmAction } from '$ui/confirm.js';
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
import Field from '$ui/components/primitives/Field.svelte';
import Input from '$ui/components/primitives/Input.svelte';
import Select from '$ui/components/primitives/Select.svelte';
import Button from '$ui/components/primitives/Button.svelte';
import Tabs from '$ui/components/primitives/Tabs.svelte';
import StatusBadge from '$ui/components/primitives/StatusBadge.svelte';
import EmptyState from '$ui/components/primitives/EmptyState.svelte';
import RelativeTime from '$ui/components/RelativeTime.svelte';
import Freshness from '$ui/components/Freshness.svelte';
import CopyValue from '$ui/components/CopyValue.svelte';
import ServerIdentity from '$ui/components/ServerIdentity.svelte';
import ModerationDialog from '$ui/components/moderation/ModerationDialog.svelte';
let { data }: { data: { moderation: ModerationOverview } } = $props();
let snapshot = $state<ModerationOverview | null>(null);
const report = $derived(snapshot ?? data.moderation);
const permitted = $derived(canModerate($page.data?.user?.role));
const admin = $derived($page.data?.user?.role === 'admin');
const busy = $derived(!!$navigating);
let filters = $state<ModerationQuery>(untrack(() => ({ ...data.moderation.query })));
let tabView = $state<string>(untrack(() => data.moderation.query.view));
let refreshing = $state(false);
let refreshError = $state('');
let navigationError = $state('');
let notice = $state('');
let configuring = $state('');
const configureKeys = new Map<string, string>();
let updatedAt = $state<number | null>(null);
let controller: AbortController | undefined;
let generation = 0;
let dialogOpen = $state(false);
let dialogKey = $state(0);
let mode = $state<
'restriction' | 'action' | 'case' | 'note' | 'revoke' | 'case-note' | 'resolve'
>('restriction');
let seed = $state<{
game?: ModerationGame;
guid?: string;
server?: string;
id?: string;
revision?: number;
matchId?: number;
message?: string;
}>({});
const tabs = [
{ id: 'players', label: 'Players' },
{ id: 'restrictions', label: 'Restrictions' },
{ id: 'cases', label: 'Cases' },
{ id: 'history', label: 'History' }
];
const currentCoverage = $derived(
report.coverage.filter((item) => item.state === 'current').length
);
$effect(() => {
const initial = data.moderation;
untrack(() => {
snapshot = null;
filters = { ...initial.query };
tabView = initial.query.view;
refreshError = '';
});
});
function cancelRefresh() {
generation++;
controller?.abort();
refreshing = false;
}
beforeNavigate(cancelRefresh);
async function refresh(signal?: AbortSignal) {
if (busy || refreshing) return;
const current = ++generation;
const request = new AbortController();
controller = request;
const abort = () => request.abort();
signal?.addEventListener('abort', abort, { once: true });
if (signal?.aborted) request.abort();
const timeout = setTimeout(abort, 30_000);
refreshing = true;
try {
const response = await fetch(`/api/moderation${$page.url.search}`, {
signal: request.signal
});
if (!response.ok) throw new Error('Refresh failed');
const next: ModerationOverview = await response.json();
if (current !== generation || request.signal.aborted) return;
snapshot = next;
updatedAt = Date.now();
refreshError = '';
} catch {
if (current === generation)
refreshError = 'Moderation updates paused. Showing the last loaded records.';
return false;
} finally {
clearTimeout(timeout);
signal?.removeEventListener('abort', abort);
if (current === generation) refreshing = false;
}
}
onMount(() => {
updatedAt = Date.now();
const stop = pollVisible(refresh, 15_000, false);
return () => {
stop();
cancelRefresh();
};
});
async function navigate(changes: Record<string, string | number | null>, refreshData = false) {
const params = new URLSearchParams($page.url.searchParams);
for (const [key, value] of Object.entries(changes)) {
if (value === null || value === '') params.delete(key);
else params.set(key, String(value));
}
navigationError = '';
try {
await goto(`/moderation?${params}`, {
keepFocus: true,
noScroll: true,
invalidateAll: refreshData
});
} catch {
tabView = report.query.view;
navigationError = 'Could not load this moderation view. Please try again.';
}
}
function openDialog(next: typeof mode, target: typeof seed = {}) {
mode = next;
const matchId = Number($page.url.searchParams.get('matchId'));
seed = {
...(report.query.game ? { game: report.query.game } : {}),
guid: report.query.guid,
server: $page.url.searchParams.get('server') ?? '',
...(next === 'case' && matchId > 0 && $page.url.searchParams.get('message')
? { matchId, message: $page.url.searchParams.get('message')! }
: {}),
...target
};
dialogKey++;
dialogOpen = true;
}
function submitted(target: { game?: ModerationGame; guid?: string | null }) {
notice = 'Saved. Enforcement and delivery status are shown below.';
cancelRefresh();
const view =
mode === 'action' || mode === 'note'
? 'history'
: mode === 'restriction' || mode === 'revoke'
? 'restrictions'
: 'cases';
void navigate(
{
view,
offset: null,
q: null,
matchId: null,
message: null,
...(target.game ? { game: target.game } : {}),
...(target.guid !== undefined ? { guid: target.guid } : {})
},
true
);
}
async function configure(server: string) {
if (configuring) return;
if (
!(await confirmAction({
title: 'Enable moderation integration',
message: `Enable moderation on ${server}? Install the matching moderation scripts and plugins first. Capabilities appear only after the game connects successfully.`,
confirmLabel: 'Enable integration'
}))
)
return;
configuring = server;
navigationError = '';
const idempotencyKey = configureKeys.get(server) ?? crypto.randomUUID();
configureKeys.set(server, idempotencyKey);
try {
const response = await fetch('/api/moderation', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ op: 'configure', server, idempotencyKey })
});
const result = await response.json().catch(() => null);
if (!response.ok || !result?.ok)
throw new Error(
result?.error ||
'Could not confirm the integration request. Retry uses the same request.'
);
configureKeys.delete(server);
notice = 'Integration configuration queued. Coverage updates when the game connects.';
cancelRefresh();
void refresh();
} catch (cause) {
navigationError =
cause instanceof Error
? cause.message
: 'Could not confirm the integration request.';
} finally {
configuring = '';
}
}
</script>
<svelte:head><title>Moderation · Electric Cherry</title></svelte:head>
<div class="moderation-page">
<SectionHeader
title="Moderation"
subtitle="Player restrictions, staff cases, and enforcement across your servers."
>
{#snippet actions()}
<Freshness
{updatedAt}
intervalLabel="every 15 s"
error={refreshError ? 'Updates paused' : ''}
/>
<Button onclick={() => refresh()} loading={refreshing}>Refresh</Button>
{#if permitted}<Button variant="primary" onclick={() => openDialog('restriction')}
>Restrict player</Button
><Button onclick={() => openDialog('action')}>Live action</Button>{/if}
{/snippet}
</SectionHeader>
{#if !permitted}<p class="notice">
Read-only moderation access. You can inspect private records and enforcement status.
</p>{/if}
{#if notice}<p class="notice" role="status">{notice}</p>{/if}
{#if refreshError}<div class="ui-error" role="alert">
{refreshError}<Button size="sm" onclick={() => refresh()} loading={refreshing}
>Retry</Button
>
</div>{/if}
{#if navigationError}<p class="ui-error" role="alert">{navigationError}</p>{/if}
<details class="coverage">
<summary
>Enforcement coverage · {currentCoverage} of {report.coverage.length} servers current</summary
>
<p class="hint">
Current means the server acknowledged the latest policy. Pending or unavailable servers
may not apply a new restriction or revocation yet. Cached restrictions continue during
an outage.
</p>
{#if report.coverage.length}
<div class="table-scroll">
<table>
<thead
><tr
><th>Server</th><th>Policy status</th><th>Revision</th><th
>Last connected</th
><th>Capabilities</th>{#if admin}<th>Setup</th>{/if}</tr
></thead
><tbody>
{#each report.coverage as item (item.server)}<tr>
<th
><a href={`/servers/${encodeURIComponent(item.server)}`}
><ServerIdentity name={item.server} /></a
></th
>
<td><StatusBadge state={`moderation-${item.state}`} /></td>
<td>{item.revision ?? '—'} / {item.desiredRevision}</td>
<td
>{#if item.connectedAt}<RelativeTime
value={item.connectedAt}
/>{:else}Not recorded{/if}</td
>
<td
>{item.capabilities.length
? item.capabilities.join(', ')
: 'Not reported'}</td
>
{#if admin}<td
>{#if item.state === 'unavailable'}<Button
size="sm"
disabled={!!configuring}
loading={configuring === item.server}
onclick={() => configure(item.server)}
>Enable integration</Button
>{/if}</td
>{/if}
</tr>{/each}
</tbody>
</table>
</div>
{:else}<p class="hint">No server enforcement reports are available yet.</p>{/if}
</details>
<Tabs
{tabs}
bind:value={tabView}
onchange={(view) => {
void navigate({ view, offset: null });
}}
ariaLabel="Moderation views"
panelId="moderation-results"
idPrefix="moderation-tab"
/>
<form
class="filters"
onsubmit={(event) => {
event.preventDefault();
void navigate({ q: filters.q, game: filters.game, guid: filters.guid, offset: null });
}}
>
<Field label="Search moderation" class="search"
><Input
name="q"
type="search"
bind:value={filters.q}
placeholder="Name, alias, GUID, or reason"
/></Field
>
<Field label="Game"
><Select
name="game"
bind:value={filters.game}
options={[
{ value: '', label: 'All games' },
{ value: 't5', label: 'Black Ops I' },
{ value: 't6', label: 'Black Ops II' }
]}
/></Field
>
<Field label="Player GUID"><Input name="guid" bind:value={filters.guid} mono /></Field>
<Button type="submit" disabled={busy}>Apply</Button>
<Button
disabled={busy}
onclick={() =>
navigate({
q: null,
game: null,
guid: null,
offset: null,
server: null,
message: null,
matchId: null
})}>Reset</Button
>
</form>
{#if $page.url.searchParams.get('matchId') && $page.url.searchParams.get('message')}<p
class="notice"
>
A chat message is selected as case evidence. Review a player with a verified GUID, then
choose Open case. The original message must match that player in the archive.
</p>{/if}
{#if report.query.guid}
<section class="selected-player" aria-label="Selected player">
<div>
<strong>Selected player</strong><CopyValue
value={report.query.guid}
label="Player GUID"
/><a href={contextHref(playerProfileHref(report.query.guid), $page.url)}
>View player profile</a
>
</div>
{#if permitted}<div class="row-actions">
<Button size="sm" onclick={() => openDialog('case')}>Open case</Button><Button
size="sm"
onclick={() => openDialog('note')}>Add private note</Button
>
</div>{/if}
</section>
{/if}
<div
id="moderation-results"
role="tabpanel"
aria-labelledby={`moderation-tab-${report.query.view}`}
aria-busy={busy}
>
{#if report.query.view === 'players'}
{#if report.players.length}<div class="table-scroll">
<table>
<thead
><tr
><th>Player</th><th>Aliases</th><th>Last seen</th><th>Servers</th
><th>Restrictions</th><th>Actions</th></tr
></thead
><tbody>
{#each report.players as player (`${player.game}:${player.guid}`)}<tr>
<th
><a
href={contextHref(
playerProfileHref(player.guid),
$page.url
)}>{player.name}</a
><small>{moderationGameName(player.game)}</small><CopyValue
value={player.guid}
label="Player GUID"
/></th
>
<td>{player.aliases.join(', ') || 'None recorded'}</td>
<td
><RelativeTime
value={player.lastSeen}
missing="Not recorded"
/></td
>
<td
>{#each player.servers as server}<div>
<ServerIdentity name={server} />
</div>{:else}Not currently observed{/each}</td
>
<td>{player.restrictions}</td>
<td
><div class="row-actions">
<Button
size="sm"
onclick={() =>
navigate({
game: player.game,
guid: player.guid,
q: null,
offset: null,
server: null,
message:
$page.url.searchParams.get('message'),
matchId:
$page.url.searchParams.get('matchId')
})}>Review player</Button
>{#if permitted}<Button
size="sm"
onclick={() =>
openDialog('restriction', {
game: player.game,
guid: player.guid,
server: player.servers[0] ?? ''
})}>Restrict</Button
>{/if}
</div></td
>
</tr>{/each}
</tbody>
</table>
</div>{:else}<EmptyState
title="No players found"
description="Search another name or GUID. You can restrict a recorded identity even when the player is offline."
/>{/if}
{:else if report.query.view === 'restrictions'}
{#if report.sanctions.length}<div class="record-list">
{#each report.sanctions as sanction (sanction.id)}<article class="record">
<div class="record-heading">
<h2>
{sanction.kind === 'ban' ? 'Ban' : 'Mute'} ·
<a
href={contextHref(
playerProfileHref(sanction.guid),
$page.url
)}>{sanction.guid}</a
>
</h2>
<StatusBadge
state={sanction.revokedAt
? 'moderation-revoked'
: sanction.active
? 'moderation-active'
: 'expired'}
/>
</div>
<p>{sanction.reason}</p>
<dl>
<div>
<dt>Scope</dt>
<dd>
{#if sanction.server}<ServerIdentity
name={sanction.server}
/>{:else}{sanction.scope === 'fleet'
? 'Entire fleet'
: moderationGameName(sanction.game)}{/if}
</dd>
</div>
<div>
<dt>Expires</dt>
<dd>
{sanction.expiresAt
? $timeFormat.dateTime(sanction.expiresAt)
: 'Permanent'}
</dd>
</div>
<div>
<dt>Created by</dt>
<dd>
{sanction.actor} · <RelativeTime
value={sanction.createdAt}
/>
</dd>
</div>
</dl>
{#if sanction.note}<p class="private-note">
<strong>Private note</strong>
{sanction.note}
</p>{/if}
<div class="record-heading">
<CopyValue
value={sanction.id}
label="Restriction ID"
/>{#if sanction.active && canRevokeModerationRestriction($page.data?.user, sanction)}<Button
size="sm"
onclick={() =>
openDialog('revoke', {
id: sanction.id,
revision: sanction.revision
})}>Revoke restriction</Button
>{/if}
</div>
</article>{/each}
</div>{:else}<EmptyState
title="No restrictions found"
description="Restrictions matching these filters will appear here, including expiry and revocation history."
/>{/if}
{:else if report.query.view === 'cases'}
{#if permitted}<div class="view-actions">
<Button onclick={() => openDialog('case')}>Open case</Button>
</div>{/if}
{#if report.cases.length}<div class="record-list">
{#each report.cases as item (item.id)}<article class="record">
<div class="record-heading">
<h2>{item.title}</h2>
<StatusBadge state={`moderation-${item.status}`} />
</div>
<p>
<a href={contextHref(playerProfileHref(item.guid), $page.url)}
>{item.guid}</a
>
· {moderationGameName(item.game)} · Opened by {item.actor} · <RelativeTime
value={item.createdAt}
/>
</p>
<details>
<summary>Evidence and staff notes ({item.evidence.length})</summary>
<ol class="evidence-list">
{#each item.evidence as evidence (evidence.id)}<li>
<p>{evidence.text}</p>
<small
>{evidence.source} · {evidence.actor} · <RelativeTime
value={evidence.createdAt}
/></small
>
</li>{:else}<li>No evidence or notes recorded.</li>{/each}
</ol>
</details>
<div class="record-heading">
<CopyValue value={item.id} label="Case ID" />{#if permitted}<div
class="row-actions"
>
<Button
size="sm"
onclick={() => openDialog('case-note', { id: item.id })}
>Add case note</Button
>{#if item.status === 'open'}<Button
size="sm"
onclick={() =>
openDialog('resolve', {
id: item.id,
revision: item.revision
})}>Review case</Button
>{/if}
</div>{/if}
</div>
</article>{/each}
</div>{:else}<EmptyState
title="No cases found"
description="Open a case to preserve evidence, record an appeal, and document a staff decision."
/>{/if}
{:else}
<h2>Recent live actions</h2>
<p class="hint">
Queued and delivered actions are awaiting confirmation. An unknown outcome requires
review before issuing another action.
</p>
{#if report.actions.length}<div class="table-scroll">
<table>
<thead
><tr
><th>Action</th><th>Target</th><th>Result</th><th
>Reason / message</th
><th>Moderator</th><th>Requested</th></tr
></thead
><tbody>
{#each report.actions as action (action.id)}<tr
><th>{moderationActionLabels[action.kind]}</th><td
>{action.guid ?? 'All players'}
<div><ServerIdentity name={action.server} /></div></td
><td
><StatusBadge
state={`moderation-${action.state}`}
/>{#if action.detail}<small>{action.detail}</small>{/if}</td
><td>{action.reason}</td><td>{action.actor}</td><td
><RelativeTime value={action.createdAt} /></td
></tr
>{/each}
</tbody>
</table>
</div>{:else}<p class="hint">No recent live actions match these filters.</p>{/if}
<h2>Audit history</h2>
{#if report.events.length}<ol class="record-list audit-list">
{#each report.events as event (event.id)}<li class="record">
<div class="record-heading">
<strong
>{moderationActionLabels[event.action] ?? event.action}</strong
><RelativeTime value={event.createdAt} />
</div>
<p>{moderationEventDetail(event.detail, event.action)}</p>
<small
>{event.actor}{event.guid
? ` · Player ${event.guid}`
: ''}{event.game
? ` · ${moderationGameName(event.game)}`
: ''}</small
>{#if event.subjectId}<CopyValue
value={event.subjectId}
label="Record ID"
/>{/if}
</li>{/each}
</ol>{:else}<EmptyState
title="No history found"
description="Moderation decisions and private notes matching these filters will appear here."
/>{/if}
{/if}
<nav class="pagination" aria-label="Moderation results pagination">
<span role="status"
>{report.total ? report.query.offset + 1 : 0}–{Math.min(
report.query.offset + report.limit,
report.total
)} of {report.total.toLocaleString()} results</span
>
{#if report.total > report.limit || report.query.offset > 0}<div class="row-actions">
<Button
size="sm"
disabled={busy || report.query.offset === 0}
onclick={() =>
navigate({ offset: Math.max(0, report.query.offset - report.limit) })}
>Previous</Button
><Button
size="sm"
disabled={busy || report.query.offset + report.limit >= report.total}
onclick={() => navigate({ offset: report.query.offset + report.limit })}
>Next</Button
>
</div>{/if}
</nav>
</div>
</div>
{#if permitted}{#key dialogKey}<ModerationDialog
bind:open={dialogOpen}
{mode}
{seed}
{admin}
coverage={report.coverage}
onsubmitted={submitted}
/>{/key}{/if}
<style>
.moderation-page,
.record-list,
.record {
display: grid;
gap: var(--space-5);
min-width: 0;
}
.filters,
.row-actions,
.record-heading,
.selected-player,
.pagination {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--space-3);
}
.filters {
align-items: flex-end;
}
.filters :global(.search) {
flex: 1 1 240px;
}
.record-heading,
.selected-player,
.pagination {
justify-content: space-between;
}
.coverage,
.selected-player,
.record {
padding: var(--space-4);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
background: var(--surface);
}
.record {
gap: var(--space-3);
overflow-wrap: anywhere;
}
.table-scroll {
width: 100%;
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
text-align: left;
font-size: var(--text-sm);
}
th,
td {
padding: var(--space-3);
border-bottom: 1px solid var(--border-subtle);
vertical-align: top;
}
thead th {
color: var(--text-secondary);
font-size: var(--text-xs);
font-weight: var(--weight-semibold);
}
tbody th {
font-weight: var(--weight-medium);
}
small,
.hint,
.notice,
.pagination {
color: var(--text-secondary);
font-size: var(--text-sm);
}
td small,
th small {
display: block;
margin-top: var(--space-2);
}
h2 {
margin: 0;
font-size: var(--text-lg);
}
p {
margin: 0;
line-height: var(--leading-body);
white-space: pre-wrap;
}
.hint {
margin-block: var(--space-3);
}
.notice {
padding: var(--space-3);
border-left: 3px solid var(--border-strong);
}
dl {
display: flex;
flex-wrap: wrap;
gap: var(--space-3) var(--space-6);
margin: 0;
font-size: var(--text-sm);
}
dt {
color: var(--text-secondary);
font-size: var(--text-xs);
margin-bottom: var(--space-1);
}
dd {
margin: 0;
}
summary {
cursor: pointer;
color: var(--text-secondary);
font-size: var(--text-sm);
}
.private-note {
font-size: var(--text-sm);
}
.evidence-list {
padding-left: var(--space-5);
}
.evidence-list li {
margin-top: var(--space-4);
}
.evidence-list small {
font-size: var(--text-xs);
}
.audit-list {
list-style: none;
padding: 0;
}
.view-actions {
margin-bottom: var(--space-4);
}
.pagination {
margin-top: var(--space-4);
}
#moderation-results > h2 {
margin-top: var(--space-5);
}
@media (max-width: 640px) {
.filters {
align-items: stretch;
}
.filters :global(.field) {
flex: 1 1 100%;
}
}
</style>
+5
View File
@@ -55,6 +55,11 @@
label: 'Operator', label: 'Operator',
description: 'Operate servers, campaigns, scripts, and configs.' description: 'Operate servers, campaigns, scripts, and configs.'
}, },
{
value: 'moderator',
label: 'Moderator',
description: 'Moderate players with server or game restrictions lasting up to 7 days.'
},
{ {
value: 'admin', value: 'admin',
label: 'Administrator', label: 'Administrator',
+106 -13
View File
@@ -35,7 +35,7 @@ import urllib.parse
import urllib.request import urllib.request
import uuid import uuid
VERSION = "1.10.1" VERSION = "1.11.0"
LABEL = "io.electric-cherry" LABEL = "io.electric-cherry"
MAX_FILE = 32 * 1024 * 1024 MAX_FILE = 32 * 1024 * 1024
MANIFEST_MAX_ENTRIES = 10000 MANIFEST_MAX_ENTRIES = 10000
@@ -49,6 +49,8 @@ PLUTONIUM_MANIFEST_LIMIT = 2 * 1024 * 1024
PLUTONIUM_FILE_LIMIT = 512 * 1024 * 1024 PLUTONIUM_FILE_LIMIT = 512 * 1024 * 1024
PLUTONIUM_CACHE_LIMIT = 4 * 1024 * 1024 * 1024 PLUTONIUM_CACHE_LIMIT = 4 * 1024 * 1024 * 1024
BANK_GAME_ENDPOINT = "https://ec.thewrightserver.net/api/bank" BANK_GAME_ENDPOINT = "https://ec.thewrightserver.net/api/bank"
MODERATION_CONFIG_PATH = "configs/electric_cherry_moderation.cfg"
MODERATION_INCLUDE = b"exec electric_cherry_moderation.cfg\n"
ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$") ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$")
AGENT_CONFIG = Path("/etc/electric-cherry/agent.json") AGENT_CONFIG = Path("/etc/electric-cherry/agent.json")
AGENT_EXECUTABLE = Path("/usr/local/libexec/electric-cherry-agent.py") AGENT_EXECUTABLE = Path("/usr/local/libexec/electric-cherry-agent.py")
@@ -85,6 +87,7 @@ class AutoscalingInvalidated(Rejected):
OPERATION_ERROR_PHASES = { OPERATION_ERROR_PHASES = {
"executing": "executing the operation", "executing": "executing the operation",
"configuring-moderation": "configuring private game moderation",
"checking-instance": "checking the existing instance", "checking-instance": "checking the existing instance",
"validating-create-spec": "validating the container specification", "validating-create-spec": "validating the container specification",
"preparing-create-directory": "preparing the instance directory", "preparing-create-directory": "preparing the instance directory",
@@ -401,13 +404,13 @@ def managed_open(root, value):
os.close(fd) os.close(fd)
def private_bank_config(value): def private_instance_config(value):
return isinstance(value, str) and PurePosixPath(value).as_posix() == "configs/electric_cherry_bank.cfg" return isinstance(value, str) and PurePosixPath(value).as_posix() in ("configs/electric_cherry_bank.cfg", MODERATION_CONFIG_PATH)
def require_public_file(value): def require_public_file(value):
if private_bank_config(value): if private_instance_config(value):
raise Rejected("Private bank configuration cannot be read or changed by file operations") raise Rejected("Private bank configuration and moderation configuration cannot be read or changed by file operations")
def managed_read(root, value): def managed_read(root, value):
@@ -457,7 +460,7 @@ def managed_manifest(root, payload, stopping):
def validate_paths(): def validate_paths():
for value in paths: for value in paths:
if private_bank_config(value): if private_instance_config(value):
continue continue
check_budget() check_budget()
try: try:
@@ -493,7 +496,7 @@ def managed_manifest(root, payload, stopping):
entries += 1 entries += 1
if entries > MANIFEST_MAX_ENTRIES: if entries > MANIFEST_MAX_ENTRIES:
raise AgentError("Manifest scan exceeded its entry limit") raise AgentError("Manifest scan exceeded its entry limit")
if child.name in ignored or private_bank_config(relative + "/" + child.name): if child.name in ignored or private_instance_config(relative + "/" + child.name):
continue continue
info = os.stat(child.name, dir_fd=directory, follow_symlinks=False) info = os.stat(child.name, dir_fd=directory, follow_symlinks=False)
if stat_module.S_ISLNK(info.st_mode): if stat_module.S_ISLNK(info.st_mode):
@@ -1571,7 +1574,7 @@ class Agent:
if line.startswith("PRETTY_NAME="): if line.startswith("PRETTY_NAME="):
os_name = line.split("=", 1)[1].strip('"') os_name = line.split("=", 1)[1].strip('"')
update = self.update_inventory() update = self.update_inventory()
capabilities = ["host.temperatures", "library.remove", "plutonium.cache", "instance.cancel-create", "instance.safety-override", "instance.unhealthy-recovery", "instance.rebuild-files", "instance.autoscaling-remove", "operations.read-lane", "operations.health-lane", "rcon.status-batch", "instance.heartbeat-state", "files.manifest", "files.blobs", "logs.read-offset", "bank.settlement"] + (["host.decommission"] if self.can_uninstall() else []) + (["agent.update"] if update["enabled"] else []) capabilities = ["host.temperatures", "library.remove", "plutonium.cache", "instance.cancel-create", "instance.safety-override", "instance.unhealthy-recovery", "instance.rebuild-files", "instance.autoscaling-remove", "moderation.configure", "operations.read-lane", "operations.health-lane", "rcon.status-batch", "instance.heartbeat-state", "files.manifest", "files.blobs", "logs.read-offset", "bank.settlement"] + (["host.decommission"] if self.can_uninstall() else []) + (["agent.update"] if update["enabled"] else [])
plutonium_cache = self.plutonium_cache_inventory() plutonium_cache = self.plutonium_cache_inventory()
return {"hostname": socket.gethostname(), "agentVersion": VERSION, "protocolVersion": 1, "capabilities": capabilities, "agentUpdate": update, "agentLanes": self.lane_inventory(), "os": os_name, "osVersion": platform.release(), "architecture": platform.machine(), "cpu": platform.processor() or platform.machine(), "cpuCount": os.cpu_count() or 0, "temperatures": temperatures, **resources, **({"cpuUsagePercent": cpu_usage} if cpu_usage is not None else {}), "memoryTotal": memory.get("MemTotal", 0), "memoryAvailable": memory.get("MemAvailable", 0), "storageTotal": usage.total, "storageAvailable": usage.free, "dockerVersion": docker_version, "dockerRunning": docker_running, "directoriesWritable": os.access(self.root, os.W_OK), "runtimeImages": sorted(set(images)), "preparedRuntimeImages": sorted(set(prepared_images)), **({"plutoniumCache": plutonium_cache} if plutonium_cache else {}), "libraries": libraries, "libraryCaches": caches, "currentOperation": self.current_operation, "portRange": self.config.get("portRange", {"min": 28960, "max": 29060})} return {"hostname": socket.gethostname(), "agentVersion": VERSION, "protocolVersion": 1, "capabilities": capabilities, "agentUpdate": update, "agentLanes": self.lane_inventory(), "os": os_name, "osVersion": platform.release(), "architecture": platform.machine(), "cpu": platform.processor() or platform.machine(), "cpuCount": os.cpu_count() or 0, "temperatures": temperatures, **resources, **({"cpuUsagePercent": cpu_usage} if cpu_usage is not None else {}), "memoryTotal": memory.get("MemTotal", 0), "memoryAvailable": memory.get("MemAvailable", 0), "storageTotal": usage.total, "storageAvailable": usage.free, "dockerVersion": docker_version, "dockerRunning": docker_running, "directoriesWritable": os.access(self.root, os.W_OK), "runtimeImages": sorted(set(images)), "preparedRuntimeImages": sorted(set(prepared_images)), **({"plutoniumCache": plutonium_cache} if plutonium_cache else {}), "libraries": libraries, "libraryCaches": caches, "currentOperation": self.current_operation, "portRange": self.config.get("portRange", {"min": 28960, "max": 29060})}
@@ -1964,6 +1967,8 @@ class Agent:
def create(self, instance_id, generation, payload, *, before=None, prepared_files=None): def create(self, instance_id, generation, payload, *, before=None, prepared_files=None):
self.operation_phase = "checking-instance" self.operation_phase = "checking-instance"
if payload.get("moderation") is not None:
self.moderation_config(payload["moderation"], generation)
self.check_creation_cancellation() self.check_creation_cancellation()
directory = self.instance_dir(instance_id, generation) directory = self.instance_dir(instance_id, generation)
current_marker = directory.parent / "current.json" current_marker = directory.parent / "current.json"
@@ -2025,18 +2030,92 @@ class Agent:
for item in files: for item in files:
require_public_file(item["path"]) require_public_file(item["path"])
bank = payload.get("bank") bank = payload.get("bank")
if bank is None: if bank is not None:
return files
if not isinstance(bank, dict) or not isinstance(bank.get("token"), str) or not re.fullmatch(r"ec_bank_[A-Za-z0-9_-]{32,128}", bank["token"]): if not isinstance(bank, dict) or not isinstance(bank.get("token"), str) or not re.fullmatch(r"ec_bank_[A-Za-z0-9_-]{32,128}", bank["token"]):
raise Rejected("Invalid instance bank credential") raise Rejected("Invalid instance bank credential")
path = bank.get("configPath") path = bank.get("configPath")
scoped(self.root / "agent" / "validation", path) scoped(self.root / "agent" / "validation", path)
if PurePosixPath(path).name != "electric_cherry_bank.cfg": if PurePosixPath(path).name != "electric_cherry_bank.cfg":
raise Rejected("Invalid private bank config path") raise Rejected("Invalid private bank config path")
# The game scripts use the public bank API; the host controller address # Game transport uses its public URL, never the agent's enrollment URL.
# is only for agent transport and must not become a game URL override.
content = f'set tws_bank_token "{bank["token"]}"\n'.encode() content = f'set tws_bank_token "{bank["token"]}"\n'.encode()
return files + [{"path": path, "contentBase64": base64.b64encode(content).decode()}] files = files + [{"path": path, "contentBase64": base64.b64encode(content).decode()}]
if payload.get("moderation") is not None:
content = self.moderation_config(payload["moderation"])
args = payload.get("env", {}).get("PLUTAINER_EXTRA_ARGS", "")
if not self.config_exec(args, "electric_cherry_moderation.cfg"):
raise Rejected("Moderation configuration must be loaded by the launch arguments")
files = files + [{"path": MODERATION_CONFIG_PATH, "contentBase64": base64.b64encode(content).decode()}]
return files
@staticmethod
def config_exec(args, filename):
return isinstance(args, str) and re.search(r"(?:^|\s)\+exec\s+" + re.escape(filename) + r"(?=\s|$)", args) is not None
@staticmethod
def moderation_config(moderation, generation=None):
if not isinstance(moderation, dict) or set(moderation) != {"token", "configPath", "url", "generation"}:
raise Rejected("Invalid moderation configuration")
token, url, target = moderation["token"], moderation["url"], moderation["generation"]
if not isinstance(token, str) or not re.fullmatch(r"ec_moderation_[A-Za-z0-9_-]{43}", token):
raise Rejected("Invalid instance moderation credential")
if type(target) is not int or target < 1 or generation is not None and target != generation:
raise Rejected("Moderation credential belongs to a different instance generation")
if moderation["configPath"] != MODERATION_CONFIG_PATH:
raise Rejected("Invalid private moderation config path")
if not isinstance(url, str) or len(url) > 2048 or re.search(r'[\s\x00-\x1f\x7f";\\]', url):
raise Rejected("Invalid moderation controller URL")
try:
parsed = urllib.parse.urlsplit(url)
if parsed.scheme != "https" or not parsed.hostname or parsed.username or parsed.password or parsed.path != "/api/moderation/runtime" or parsed.query or parsed.fragment or parsed.port == 0:
raise ValueError()
except ValueError:
raise Rejected("Invalid moderation controller URL") from None
return f'set tws_moderation_url "{url}"\nset tws_moderation_token "{token}"\nset tws_moderation_generation "{target}"\n'.encode()
@staticmethod
def private_config_bytes(app, path):
with managed_open(app, path) as stream:
content = stream.read(8193)
if len(content) > 8192:
raise Rejected("Private instance configuration exceeds the supported size")
return content
def moderation_bootstrap(self, instance_id, generation):
saved = self.spec(instance_id, generation)
args = saved.get("env", {}).get("PLUTAINER_EXTRA_ARGS", "")
if self.config_exec(args, "electric_cherry_moderation.cfg"):
return None
if not self.config_exec(args, "electric_cherry_bank.cfg"):
raise Rejected("Rebuild this server before configuring moderation; startup cannot load the private configuration")
app = self.instance_dir(instance_id, generation) / "app"
try:
bank = self.private_config_bytes(app, "configs/electric_cherry_bank.cfg")
except FileNotFoundError:
raise Rejected("Rebuild this server before configuring moderation; private startup configuration is missing") from None
if not re.fullmatch(rb'set tws_bank_token "ec_bank_[A-Za-z0-9_-]{32,128}"\r?\n(?:exec electric_cherry_moderation.cfg\r?\n)?', bank):
raise Rejected("Rebuild this server before configuring moderation; private startup configuration cannot be verified")
return bank if bank.splitlines()[-1] == MODERATION_INCLUDE.rstrip(b"\n") else bank + MODERATION_INCLUDE
def configure_moderation(self, instance_id, generation, payload):
if set(payload) != {"moderation"}:
raise Rejected("Invalid moderation configuration request")
content = self.moderation_config(payload["moderation"], generation)
health = self.inspect(instance_id, generation)
if not health["exists"]:
raise Rejected("Instance does not exist")
bank = self.moderation_bootstrap(instance_id, generation)
app = self.instance_dir(instance_id, generation) / "app"
self.operation_phase = "configuring-moderation"
self.checkpoint({"phase": "configuring-moderation", "generation": generation})
uid, gid = int(self.config.get("runtimeUid", 1000)), int(self.config.get("runtimeGid", 1000))
managed_write(app, MODERATION_CONFIG_PATH, content, uid, gid)
if bank is not None:
managed_write(app, "configs/electric_cherry_bank.cfg", bank, uid, gid)
# Loading fixed dvars is not a restart. Never retain a reply which could
# echo credentials, and never replay the command after a lost response.
loaded = self.rcon(instance_id, generation, "exec electric_cherry_moderation.cfg").get("ok", False) if health["running"] else False
return {"configured": True, "generation": generation, "liveLoad": "sent" if loaded else "unconfirmed" if health["running"] else "deferred"}
def write_initial_files(self, root, prepared_files): def write_initial_files(self, root, prepared_files):
for path, content in prepared_files.items(): for path, content in prepared_files.items():
@@ -2169,6 +2248,8 @@ class Agent:
return self.create(instance_id, generation, payload) return self.create(instance_id, generation, payload)
if kind == "instance.inspect": if kind == "instance.inspect":
return self.inspect(instance_id, generation) return self.inspect(instance_id, generation)
if kind == "instance.configure-moderation":
return self.configure_moderation(instance_id, generation, payload)
health = self.inspect(instance_id, generation) health = self.inspect(instance_id, generation)
name = self.container_name(instance_id, generation) name = self.container_name(instance_id, generation)
before = [] before = []
@@ -2198,6 +2279,8 @@ class Agent:
if isinstance(next_generation, bool) or not isinstance(next_generation, int) or next_generation <= generation: if isinstance(next_generation, bool) or not isinstance(next_generation, int) or next_generation <= generation:
raise AgentError("Rebuild must use a newer immutable generation") raise AgentError("Rebuild must use a newer immutable generation")
spec = payload.get("spec") or {key: value for key, value in payload.items() if key not in ("nextGeneration", "newGeneration", "unhealthyRecovery")} spec = payload.get("spec") or {key: value for key, value in payload.items() if key not in ("nextGeneration", "newGeneration", "unhealthyRecovery")}
if spec.get("moderation") is not None:
self.moderation_config(spec["moderation"], next_generation)
# Keep the complete verified set on this call's stack only. Never # Keep the complete verified set on this call's stack only. Never
# mutate the immutable spec or cache bytes on the shared agent: # mutate the immutable spec or cache bytes on the shared agent:
# held, failed and later operations must prepare their own files. # held, failed and later operations must prepare their own files.
@@ -3001,6 +3084,16 @@ class Agent:
return self.journal.update(operation["id"], "failed", {"autoscalingInvalidated": True}, error="Interrupted autoscaling removal requires a new decision") return self.journal.update(operation["id"], "failed", {"autoscalingInvalidated": True}, error="Interrupted autoscaling removal requires a new decision")
if kind == "instance.cancel-create": if kind == "instance.cancel-create":
return self.journal.update(operation["id"], "completed", self.cancel_creation(instance_id, generation, payload, operation["id"])) return self.journal.update(operation["id"], "completed", self.cancel_creation(instance_id, generation, payload, operation["id"]))
if kind == "instance.configure-moderation":
current = json.loads((self.instance_dir(instance_id, generation).parent / "current.json").read_text())
if current.get("generation") != generation or current.get("removed"):
return self.journal.update(operation["id"], "failed", error="Moderation configuration generation was superseded or removed")
content = self.moderation_config(payload.get("moderation"), generation)
app = self.instance_dir(instance_id, generation) / "app"
bank = self.moderation_bootstrap(instance_id, generation)
if self.private_config_bytes(app, MODERATION_CONFIG_PATH) != content or bank is not None and self.private_config_bytes(app, "configs/electric_cherry_bank.cfg") != bank:
return row
return self.journal.update(operation["id"], "completed", {"configured": True, "generation": generation, "liveLoad": "unconfirmed", "reconciled": True})
if kind == "instance.create": if kind == "instance.create":
marker = self.instance_dir(instance_id, generation).parent / "current.json" marker = self.instance_dir(instance_id, generation).parent / "current.json"
if marker.exists(): if marker.exists():
+2 -2
View File
@@ -1,8 +1,8 @@
{ {
"version": "1.10.1", "version": "1.11.0",
"protocolVersion": 1, "protocolVersion": 1,
"platform": "debian-amd64", "platform": "debian-amd64",
"sha256": "61481547f99921a5ea2159f863f263d1af7b60114a9d73b00300d098f5abd5af", "sha256": "3cb08d5199a6a3bbdc5641e17a7f752b65c79935813f299ea245ffe449a4e861",
"updaterProtocolVersion": 2, "updaterProtocolVersion": 2,
"updaterSha256": "a9d7f96b87d0ec74d8a0f476dff6e3d2a7a0bc454a93a51d7b57de00c9ce23cb", "updaterSha256": "a9d7f96b87d0ec74d8a0f476dff6e3d2a7a0bc454a93a51d7b57de00c9ce23cb",
"installerSha256": "384d5e397a51d19cb9fc9ed8793b4314353f726e217030246050859c0bc18231" "installerSha256": "384d5e397a51d19cb9fc9ed8793b4314353f726e217030246050859c0bc18231"
-8
View File
@@ -1,8 +0,0 @@
-----BEGIN PGP SIGNATURE-----
iJEEABYKADkWIQSpocb6VBfQzvdIh9HF6fdSNniN+gUCas+vvhsUgAAAAAAEAA5t
YW51MiwyLjUrMS4xMiwwLDMACgkQxen3UjZ4jfqXzAD8D4zQMptxYUl1befVmlc4
pNq9N9P2o1eIx1jWDIQtmK0A/RNG50PZ97hjVkzvM565YKM9lQXiytj22vgpiGG8
B/QH
=NY2I
-----END PGP SIGNATURE-----
+305
View File
@@ -0,0 +1,305 @@
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import { flushSync, mount, tick, unmount } from 'svelte';
import { fromStore, writable } from 'svelte/store';
import ModerationPage from '../../src/routes/moderation/+page.svelte';
import type { ModerationOverview } from '../../src/lib/moderation.js';
import type { UserRole } from '../../src/lib/auth.js';
const navigation = vi.hoisted(() => ({ goto: vi.fn(), before: [] as (() => void)[] }));
const currentPage = writable({
url: new URL('http://localhost/moderation'),
data: { user: { id: 'staff-1', role: 'moderator' as UserRole } }
});
vi.mock('$app/stores', () => ({
page: {
subscribe: (run: Parameters<typeof currentPage.subscribe>[0]) => currentPage.subscribe(run)
},
navigating: writable(null)
}));
vi.mock('$app/navigation', () => ({
goto: navigation.goto,
beforeNavigate: (fn: () => void) => navigation.before.push(fn)
}));
vi.mock('../../src/lib/ui/poll.js', () => ({ pollVisible: () => () => {} }));
let component: ReturnType<typeof mount> | undefined;
beforeEach(() => {
navigation.goto.mockReset().mockResolvedValue(undefined);
navigation.before.length = 0;
});
afterEach(async () => {
if (component) await unmount(component);
component = undefined;
document.body.replaceChildren();
vi.unstubAllGlobals();
});
const guid = '18446744073709551615';
function overview(view: ModerationOverview['query']['view'] = 'players'): ModerationOverview {
return {
query: { view, q: '', game: 't5', guid, offset: 0 },
players: [
{
game: 't5',
guid,
name: 'Dempsey',
aliases: ['Tank'],
firstSeen: '2026-10-01T12:00:00Z',
lastSeen: '2026-10-15T12:00:00Z',
servers: ['t5_zombie_theater_1'],
restrictions: 1
}
],
sanctions: [
{
id: 'restriction-1',
game: 't5',
guid,
kind: 'ban',
scope: 'game',
server: null,
reason: 'Repeated abuse',
note: 'Reviewed chat',
createdAt: '2026-10-15T12:00:00Z',
expiresAt: '2026-10-16T12:00:00Z',
revokedAt: null,
actor: 'Other moderator',
actorId: 'staff-2',
revision: 1,
active: true
}
],
cases: [],
events: [],
actions: [],
coverage: [
{
server: 't5_zombie_theater_1',
game: 't5',
connectedAt: '2026-10-15T12:00:00Z',
revision: 1,
desiredRevision: 2,
state: 'pending',
capabilities: ['ban', 'mute']
}
],
total: 1,
limit: 25,
checkedAt: '2026-10-15T12:00:00Z'
};
}
function show(initial = overview(), role: UserRole = 'moderator', extra = '') {
const data = writable({ moderation: initial });
const state = fromStore(data);
const updatePage = (next: ModerationOverview) =>
currentPage.set({
url: new URL(
`http://localhost/moderation?view=${next.query.view}&game=t5&guid=${guid}${extra}`
),
data: { user: { id: 'staff-1', role } }
});
updatePage(initial);
component = mount(ModerationPage, {
target: document.body,
props: {
get data() {
return state.current;
}
}
});
flushSync();
return (next: ModerationOverview) => {
updatePage(next);
data.set({ moderation: next });
flushSync();
};
}
const button = (label: string) =>
[...document.querySelectorAll('button')].find((node) => node.textContent?.trim() === label)!;
const enter = (node: HTMLInputElement | HTMLTextAreaElement, value: string) => {
node.value = value;
node.dispatchEvent(new Event('input', { bubbles: true }));
flushSync();
};
const submitDialog = () =>
document
.querySelector('dialog form')!
.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
it('limits moderator restrictions and preserves the decimal identity from player context', async () => {
const fetch = vi
.fn()
.mockResolvedValueOnce(new Response(JSON.stringify({ ok: true, id: 'sanction' })))
.mockResolvedValue(new Response(JSON.stringify(overview())));
vi.stubGlobal('fetch', fetch);
show();
button('Restrict player').click();
await tick();
const dialog = document.querySelector('dialog')!;
expect(dialog.open).toBe(true);
expect(dialog.querySelector<HTMLInputElement>('input[name="target-guid"]')!.value).toBe(guid);
expect(dialog.textContent).not.toContain('Permanent');
expect(dialog.textContent).not.toContain('Entire fleet');
enter(dialog.querySelector('textarea')!, 'Repeated abuse');
submitDialog();
await vi.waitFor(() => expect(fetch).toHaveBeenCalled());
expect(JSON.parse(fetch.mock.calls[0][1].body)).toMatchObject({
op: 'create-sanction',
guid,
game: 't5',
reason: 'Repeated abuse',
scope: 'game',
durationMinutes: 60
});
expect(JSON.parse(fetch.mock.calls[0][1].body).idempotencyKey).toEqual(expect.any(String));
await vi.waitFor(() => expect(navigation.goto).toHaveBeenCalled());
const destination = new URL(navigation.goto.mock.calls[0][0], 'http://localhost');
expect(destination.searchParams.get('view')).toBe('restrictions');
expect(destination.searchParams.get('guid')).toBe(guid);
expect(navigation.goto.mock.calls[0][1].invalidateAll).toBe(true);
});
it('keeps unknown submissions immutable and retries the same idempotent request', async () => {
const fetch = vi
.fn()
.mockRejectedValueOnce(new Error('Connection lost'))
.mockResolvedValueOnce(new Response(JSON.stringify({ ok: true, id: 'note-1' })))
.mockResolvedValue(new Response(JSON.stringify(overview())));
vi.stubGlobal('fetch', fetch);
show();
button('Add private note').click();
await tick();
enter(document.querySelector('dialog textarea')!, 'Investigate a report');
submitDialog();
await vi.waitFor(() =>
expect(document.body.textContent).toContain('The outcome is not yet known')
);
expect(document.querySelector('dialog fieldset')?.hasAttribute('disabled')).toBe(true);
button('Retry same request').click();
await vi.waitFor(() => expect(fetch.mock.calls.length).toBeGreaterThanOrEqual(2));
expect(fetch.mock.calls[1][1].body).toBe(fetch.mock.calls[0][1].body);
});
it('retains records on refresh failure and ignores a stale response after navigation', async () => {
let resolve!: (response: Response) => void;
const fetch = vi
.fn()
.mockRejectedValueOnce(new Error('offline'))
.mockImplementationOnce(
() =>
new Promise<Response>((done) => {
resolve = done;
})
);
vi.stubGlobal('fetch', fetch);
const update = show();
button('Refresh').click();
await vi.waitFor(() =>
expect(document.body.textContent).toContain('Moderation updates paused.')
);
expect(document.querySelector('#moderation-results')?.textContent).toContain('Dempsey');
button('Retry').click();
navigation.before.forEach((fn) => fn());
update(overview('restrictions'));
resolve(new Response(JSON.stringify(overview())));
await tick();
await tick();
expect(
document.querySelector('#moderation-tab-restrictions')?.getAttribute('aria-selected')
).toBe('true');
expect(document.querySelector('#moderation-results')?.textContent).toContain('Repeated abuse');
});
it('shows enforcement pending honestly and keeps super viewer access read-only', () => {
show(overview('restrictions'), 'super_viewer');
expect(document.body.textContent).toContain('Policy pending');
expect(document.body.textContent).toContain('0 of 1 servers current');
expect(document.body.textContent).toContain('Read-only moderation access');
expect(button('Restrict player')).toBeUndefined();
expect(button('Revoke restriction')).toBeUndefined();
expect(document.querySelector('dialog')).toBeNull();
});
it('does not offer revoking another moderator restriction', () => {
show(overview('restrictions'));
expect(button('Revoke restriction')).toBeUndefined();
});
it('limits revoke controls to the current role even for restrictions created by that staff member', () => {
const initial = overview('restrictions');
const own = { ...initial.sanctions[0], actorId: 'staff-1' };
initial.sanctions = [
{ ...own, id: 'permitted', expiresAt: '2026-10-22T12:00:00Z' },
{ ...own, id: 'fleet', scope: 'fleet' },
{ ...own, id: 'permanent', expiresAt: null },
{ ...own, id: 'longer', expiresAt: '2026-10-23T12:00:00Z' }
];
show(initial);
const controls = () =>
[...document.querySelectorAll('button')].filter(
(node) => node.textContent?.trim() === 'Revoke restriction'
);
expect(controls()).toHaveLength(1);
currentPage.update((value) => ({
...value,
data: { user: { id: 'staff-1', role: 'admin' } }
}));
flushSync();
expect(controls()).toHaveLength(4);
});
it('disables unsupported live actions and requires an explicit game for unscoped identities', async () => {
const data = overview();
data.query.game = '';
show(data);
button('Live action').click();
await tick();
const game = document.querySelector<HTMLSelectElement>('dialog select')!;
expect(game.value).toBe('');
expect(
document.querySelector<HTMLButtonElement>('dialog button[type="submit"]')!.disabled
).toBe(true);
expect(document.querySelector('dialog')!.textContent).toMatch(
/Unavailable actions\s+cannot be sent/
);
});
it('preserves player and game filters when paging and resets pagination when applying search', () => {
const data = overview();
data.total = 60;
show(data);
button('Next').click();
let destination = new URL(navigation.goto.mock.calls.at(-1)![0], 'http://localhost');
expect(destination.searchParams.get('offset')).toBe('25');
expect(destination.searchParams.get('guid')).toBe(guid);
expect(destination.searchParams.get('game')).toBe('t5');
enter(document.querySelector<HTMLInputElement>('input[name="q"]')!, 'Tank');
document
.querySelector('form.filters')!
.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
destination = new URL(navigation.goto.mock.calls.at(-1)![0], 'http://localhost');
expect(destination.searchParams.has('offset')).toBe(false);
expect(destination.searchParams.get('q')).toBe('Tank');
});
it('copies selected archived chat evidence separately from staff notes', async () => {
const fetch = vi
.fn()
.mockResolvedValueOnce(new Response(JSON.stringify({ ok: true, id: 'case' })))
.mockResolvedValue(new Response(JSON.stringify(overview())));
vi.stubGlobal('fetch', fetch);
show(overview(), 'moderator', '&matchId=17&message=abusive%20message');
button('Open case').click();
await tick();
const inputs = document.querySelectorAll<HTMLInputElement>('dialog input');
enter(inputs[inputs.length - 1], 'Chat report');
enter(document.querySelector('dialog textarea')!, 'Investigate context');
submitDialog();
await vi.waitFor(() => expect(fetch).toHaveBeenCalled());
expect(JSON.parse(fetch.mock.calls[0][1].body)).toMatchObject({
op: 'create-case',
title: 'Chat report',
note: 'Investigate context',
evidence: { matchId: 17, message: 'abusive message' }
});
});
+55 -2
View File
@@ -1,18 +1,61 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { readdirSync } from 'node:fs'; import { readdirSync } from 'node:fs';
import { relative, resolve } from 'node:path'; import { relative, resolve } from 'node:path';
import type { UserRole } from '../../src/lib/auth.js'; import {
canModerate,
canReadModeration,
moderatorMaximumRestrictionMs,
type UserRole
} from '../../src/lib/auth.js';
import { import {
roleAllows, roleAllows,
routePermission, routePermission,
type RoutePermission type RoutePermission
} from '../../src/lib/server/auth/access.js'; } from '../../src/lib/server/auth/access.js';
const roles: UserRole[] = ['viewer', 'super_viewer', 'operator', 'admin']; const roles: UserRole[] = ['viewer', 'super_viewer', 'operator', 'moderator', 'admin'];
const allows = (role: UserRole, route: string | null, method = 'GET') => const allows = (role: UserRole, route: string | null, method = 'GET') =>
roleAllows(role, routePermission(route, method)); roleAllows(role, routePermission(route, method));
describe('management route permissions', () => { describe('management route permissions', () => {
it.each([
'/moderation',
'/moderation/players/[guid]',
'/api/moderation',
'/api/moderation/players/[guid]',
'/api/moderation/sanctions/[id]/revoke'
])('limits private moderation access at %s without changing fleet privileges', (route) => {
for (const method of ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS']) {
const reading = ['GET', 'HEAD'].includes(method);
expect(routePermission(route, method)).toBe(reading ? 'moderation-read' : 'moderation');
for (const role of roles) {
expect(allows(role, route, method)).toBe(
role === 'admin' || role === 'moderator' || (reading && role === 'super_viewer')
);
}
}
});
it('keeps moderators within safe summary reads and explicit moderation operations', () => {
expect(allows('moderator', '/api/fleet')).toBe(true);
expect(allows('moderator', '/api/servers/[name]/chat')).toBe(true);
expect(allows('moderator', '/api/servers', 'POST')).toBe(false);
expect(allows('moderator', '/api/users')).toBe(false);
expect(allows('moderator', '/api/configs/[id]')).toBe(false);
expect(allows('moderator', '/api/drift')).toBe(false);
expect(allows('moderator', '/api/moderation-extra', 'POST')).toBe(false);
expect(allows('moderator', '/api/auth/password', 'POST')).toBe(true);
for (const role of roles) {
expect(canModerate(role)).toBe(role === 'moderator' || role === 'admin');
expect(canReadModeration(role)).toBe(
role === 'moderator' || role === 'admin' || role === 'super_viewer'
);
}
expect(canModerate(null)).toBe(false);
expect(canReadModeration(undefined)).toBe(false);
expect(moderatorMaximumRestrictionMs).toBe(604_800_000);
});
it('denies Super Viewer writes across every registered management route', () => { it('denies Super Viewer writes across every registered management route', () => {
const routes = resolve('src/routes'); const routes = resolve('src/routes');
const entries = readdirSync(routes, { recursive: true, withFileTypes: true }); const entries = readdirSync(routes, { recursive: true, withFileTypes: true });
@@ -184,6 +227,14 @@ describe('management route permissions', () => {
}); });
describe('authentication and machine boundaries', () => { describe('authentication and machine boundaries', () => {
it('reserves only the exact runtime POST for instance credentials', () => {
expect(routePermission('/api/moderation/runtime', 'POST')).toBe('machine');
for (const role of roles)
expect(allows(role, '/api/moderation/runtime', 'POST')).toBe(false);
expect(routePermission('/api/moderation/runtime', 'GET')).toBe('moderation-read');
expect(routePermission('/api/moderation/runtime/other', 'POST')).toBe('moderation');
expect(routePermission('/api/moderation/runtime-extra', 'POST')).toBe('moderation');
});
it('makes only leaderboard API reads public', () => { it('makes only leaderboard API reads public', () => {
for (const method of ['GET', 'HEAD']) { for (const method of ['GET', 'HEAD']) {
expect(routePermission('/api/leaderboard', method)).toBe('public'); expect(routePermission('/api/leaderboard', method)).toBe('public');
@@ -285,6 +336,8 @@ describe('authentication and machine boundaries', () => {
'operator', 'operator',
'operator-read', 'operator-read',
'admin-read', 'admin-read',
'moderation',
'moderation-read',
'admin', 'admin',
'machine' 'machine'
] as RoutePermission[]) { ] as RoutePermission[]) {
+27
View File
@@ -235,6 +235,18 @@ describe('browser authentication guard', () => {
['operator', '/api/users', 'GET', 403], ['operator', '/api/users', 'GET', 403],
['admin', '/api/hosts/[id]', 'PATCH', 200], ['admin', '/api/hosts/[id]', 'PATCH', 200],
['admin', '/api/users', 'GET', 200], ['admin', '/api/users', 'GET', 200],
['moderator', '/api/moderation', 'GET', 200],
['moderator', '/api/moderation/sanctions', 'POST', 200],
['moderator', '/api/moderation/sanctions/[id]/revoke', 'POST', 200],
['admin', '/api/moderation/sanctions', 'POST', 200],
['super_viewer', '/api/moderation', 'HEAD', 200],
['super_viewer', '/api/moderation/sanctions', 'POST', 403],
['operator', '/api/moderation', 'GET', 403],
['operator', '/api/moderation/sanctions', 'POST', 403],
['viewer', '/api/moderation', 'GET', 403],
['moderator', '/api/servers', 'POST', 403],
['moderator', '/api/configs/[id]', 'GET', 403],
['moderator', '/api/users', 'GET', 403],
['super_viewer', '/api/users', 'GET', 200], ['super_viewer', '/api/users', 'GET', 200],
['super_viewer', '/api/users/audit', 'GET', 200], ['super_viewer', '/api/users/audit', 'GET', 200],
['super_viewer', '/api/server-keys', 'HEAD', 200], ['super_viewer', '/api/server-keys', 'HEAD', 200],
@@ -278,6 +290,21 @@ describe('browser authentication guard', () => {
} }
} }
); );
it('protects moderation pages and Svelte page-data requests with the same role checks', async () => {
for (const role of ['viewer', 'super_viewer', 'operator', 'moderator', 'admin'] as const) {
signIn(role);
for (const path of ['/moderation', '/moderation/__data.json']) {
const call = requestFor('/moderation', { path });
if (role === 'viewer' || role === 'operator') {
await expect(call.invoke()).rejects.toMatchObject({ status: 403 });
expect(call.resolve).not.toHaveBeenCalled();
} else {
expect((await call.invoke()).status).toBe(200);
expect(call.resolve).toHaveBeenCalledOnce();
}
}
}
});
it.each(['GET', 'HEAD', 'POST', 'PATCH', 'DELETE'])( it.each(['GET', 'HEAD', 'POST', 'PATCH', 'DELETE'])(
'permits only administrators to write settings and Super Viewers to read them', 'permits only administrators to write settings and Super Viewers to read them',
async (method) => { async (method) => {
+82
View File
@@ -1,6 +1,7 @@
import Database from 'better-sqlite3'; import Database from 'better-sqlite3';
import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { import {
migrateModeratorRole,
migrateRememberedSessions, migrateRememberedSessions,
migrateSuperViewerRole migrateSuperViewerRole
} from '../../src/lib/server/auth/schema.js'; } from '../../src/lib/server/auth/schema.js';
@@ -153,3 +154,84 @@ describe('remembered session migration', () => {
expect(handle.pragma('foreign_key_check')).toEqual([]); expect(handle.pragma('foreign_key_check')).toEqual([]);
}); });
}); });
describe('moderator role migration', () => {
it('preserves all account roles and remembered sessions while extending the role constraint', () => {
const upgrade = migrations.find((entry) => entry.name === 'moderator authentication role')!;
runMigrations(handle, migrations.slice(0, upgrade.version - 1));
handle.exec(`
UPDATE auth_sessions SET remembered=1 WHERE user_id='admin';
INSERT INTO auth_users VALUES
('super','Super','Super','super-password-hash','super_viewer',0,0,10,20,NULL);
INSERT INTO auth_sessions VALUES ('super-token-hash','super',10,1000,100,1);
`);
const tables = ['auth_users', 'auth_audit', 'auth_rate_limits', 'servers'];
const original = tables.map(rows);
const sessions = handle.prepare('SELECT * FROM auth_sessions ORDER BY rowid').all();
expect(() =>
handle.exec("UPDATE auth_users SET role='moderator' WHERE id='viewer'")
).toThrow('CHECK constraint failed');
runMigrations(handle, migrations);
expect(tables.map(rows)).toEqual(original);
expect(handle.prepare('SELECT * FROM auth_sessions ORDER BY rowid').all()).toEqual(
sessions
);
expect(handle.pragma('foreign_keys', { simple: true })).toBe(1);
expect(handle.pragma('foreign_key_check')).toEqual([]);
expect(handle.pragma('foreign_key_list(auth_sessions)')).toEqual([
expect.objectContaining({ table: 'auth_users', from: 'user_id', on_delete: 'CASCADE' })
]);
handle.exec("UPDATE auth_users SET role='moderator' WHERE id='viewer'");
const users = rows('auth_users');
const schemaVersion = handle.pragma('schema_version', { simple: true });
migrateModeratorRole(handle);
expect(rows('auth_users')).toEqual(users);
expect(handle.prepare('SELECT * FROM auth_sessions ORDER BY rowid').all()).toEqual(
sessions
);
expect(handle.pragma('schema_version', { simple: true })).toBe(schemaVersion);
expect(() => handle.exec("UPDATE auth_users SET role='unknown' WHERE id='viewer'")).toThrow(
'CHECK constraint failed'
);
expect(() =>
handle.exec("UPDATE auth_users SET username='ADMINISTRATOR' WHERE id='viewer'")
).toThrow('UNIQUE constraint failed');
handle.exec("DELETE FROM auth_users WHERE id='super'");
expect(handle.prepare("SELECT * FROM auth_sessions WHERE user_id='super'").all()).toEqual(
[]
);
});
it('rejects an unsafe rebuild and rolls back the upgrade if existing references are broken', () => {
const upgrade = migrations.find((entry) => entry.name === 'moderator authentication role')!;
runMigrations(handle, migrations.slice(0, upgrade.version - 1));
const users = rows('auth_users');
const sessions = handle.prepare('SELECT * FROM auth_sessions ORDER BY rowid').all();
expect(() => migrateModeratorRole(handle)).toThrow('foreign keys to be disabled');
expect(rows('auth_users')).toEqual(users);
expect(handle.prepare('SELECT * FROM auth_sessions ORDER BY rowid').all()).toEqual(
sessions
);
handle.pragma('foreign_keys = OFF');
handle.exec("INSERT INTO auth_sessions VALUES ('orphan','missing',10,1000,100,1)");
handle.pragma('foreign_keys = ON');
expect(() => runMigrations(handle, migrations)).toThrow('left broken references');
expect(handle.pragma('user_version', { simple: true })).toBe(upgrade.version - 1);
expect(handle.pragma('foreign_keys', { simple: true })).toBe(1);
expect(rows('auth_users')).toEqual(users);
expect(
handle.prepare("SELECT name FROM sqlite_master WHERE name='auth_users_moderator'").get()
).toBeUndefined();
handle.exec("DELETE FROM auth_sessions WHERE token_hash='orphan'");
runMigrations(handle, migrations);
expect(rows('auth_users')).toEqual(users);
expect(handle.prepare('SELECT * FROM auth_sessions ORDER BY rowid').all()).toEqual(
sessions
);
expect(handle.pragma('foreign_key_check')).toEqual([]);
});
});
+19 -3
View File
@@ -256,7 +256,7 @@ describe('password storage and verification', () => {
describe('administrator user management', () => { describe('administrator user management', () => {
it('rejects the reserved system username for every role while allowing distinct usernames', async () => { it('rejects the reserved system username for every role while allowing distinct usernames', async () => {
const admin = seedUser(); const admin = seedUser();
for (const role of ['admin', 'super_viewer', 'operator', 'viewer'] as const) { for (const role of ['admin', 'super_viewer', 'operator', 'moderator', 'viewer'] as const) {
for (const username of ['Maxis', 'maxis', 'MAXIS', 'mAxIs']) { for (const username of ['Maxis', 'maxis', 'MAXIS', 'mAxIs']) {
await expect( await expect(
auth.createUser(admin.id, { username, role, password }) auth.createUser(admin.id, { username, role, password })
@@ -275,7 +275,7 @@ describe('administrator user management', () => {
it('creates each supported role with a forced initial password change and rejects case-insensitive duplicate names', async () => { it('creates each supported role with a forced initial password change and rejects case-insensitive duplicate names', async () => {
const admin = seedUser(); const admin = seedUser();
for (const role of ['admin', 'super_viewer', 'operator', 'viewer'] as const) { for (const role of ['admin', 'super_viewer', 'operator', 'moderator', 'viewer'] as const) {
const user = await auth.createUser(admin.id, { const user = await auth.createUser(admin.id, {
username: role + 'User', username: role + 'User',
displayName: ' ' + role + ' user ', displayName: ' ' + role + ' user ',
@@ -295,7 +295,7 @@ describe('administrator user management', () => {
await expect( await expect(
auth.createUser(admin.id, { username: 'OPERATORUSER', role: 'viewer', password }) auth.createUser(admin.id, { username: 'OPERATORUSER', role: 'viewer', password })
).rejects.toMatchObject({ status: 409 }); ).rejects.toMatchObject({ status: 409 });
expect(auth.listUsers()).toHaveLength(5); expect(auth.listUsers()).toHaveLength(6);
expect(JSON.stringify(auth.listAuthAudit())).not.toContain(password); expect(JSON.stringify(auth.listAuthAudit())).not.toContain(password);
}); });
@@ -304,6 +304,7 @@ describe('administrator user management', () => {
const actors = [ const actors = [
seedUser('superviewer', 'super_viewer'), seedUser('superviewer', 'super_viewer'),
seedUser('operator', 'operator'), seedUser('operator', 'operator'),
seedUser('moderator', 'moderator'),
seedUser('viewer', 'viewer'), seedUser('viewer', 'viewer'),
seedUser('disabledadmin', 'admin', { disabled: true }), seedUser('disabledadmin', 'admin', { disabled: true }),
seedUser('temporaryadmin', 'admin', { mustChangePassword: true }) seedUser('temporaryadmin', 'admin', { mustChangePassword: true })
@@ -333,6 +334,9 @@ describe('administrator user management', () => {
await expect( await expect(
auth.updateUser(first.id, first.id, { role: 'operator' }) auth.updateUser(first.id, first.id, { role: 'operator' })
).rejects.toMatchObject({ status: 409 }); ).rejects.toMatchObject({ status: 409 });
await expect(
auth.updateUser(first.id, first.id, { role: 'moderator' })
).rejects.toMatchObject({ status: 409 });
await expect(auth.updateUser(first.id, first.id, { disabled: true })).rejects.toMatchObject( await expect(auth.updateUser(first.id, first.id, { disabled: true })).rejects.toMatchObject(
{ status: 409 } { status: 409 }
); );
@@ -347,6 +351,18 @@ describe('administrator user management', () => {
expect(auth.getUser(second.id)?.role).toBe('admin'); expect(auth.getUser(second.id)?.role).toBe('admin');
}); });
it('revokes sessions when moderation access is granted or removed', async () => {
const admin = seedUser();
const target = seedUser('target', 'viewer');
const beforeGrant = auth.createSession(target.id, true);
await auth.updateUser(admin.id, target.id, { role: 'moderator' });
expect(auth.getUser(target.id)?.role).toBe('moderator');
expect(auth.readSession(beforeGrant.token)).toBeNull();
const beforeRemoval = auth.createSession(target.id, true);
await auth.updateUser(admin.id, target.id, { role: 'viewer' });
expect(auth.readSession(beforeRemoval.token)).toBeNull();
});
it.each([false, true])( it.each([false, true])(
'revokes every target session on role, enabled-state and password changes (remembered=%s)', 'revokes every target session on role, enabled-state and password changes (remembered=%s)',
async (remembered) => { async (remembered) => {
+145
View File
@@ -0,0 +1,145 @@
import { randomUUID } from 'node:crypto';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { newFixture, type Fixture } from '../helpers/fixture.js';
let fixture: Fixture;
let actorId: string;
let hostId: string;
let instanceId: string;
const server = 't5_zombie_theater_1';
beforeEach(async () => {
vi.resetModules();
vi.stubEnv('EC_PUBLIC_URL', 'https://public-controller.example');
fixture = await newFixture([]);
const hosts = await import('../../src/lib/server/hosts/index.js');
const { db } = await import('../../src/lib/server/db/index.js');
hostId = hosts.createHost({ name: 'Moderation host' }).id;
hosts.enrollHost(hosts.createEnrollment(hostId).token, {
capabilities: ['moderation.configure']
});
actorId = randomUUID();
instanceId = randomUUID();
db()
.prepare(
`INSERT INTO auth_users(id,username,display_name,password_hash,role,created_at,updated_at) VALUES(?,'admin','Admin','unused','admin',1,1)`
)
.run(actorId);
db()
.prepare(
`INSERT INTO native_instances(id,server,host_id,generation,library_id,image,port,created_at) VALUES(?,?,?,1,'library','image',28960,?)`
)
.run(instanceId, server, hostId, new Date().toISOString());
db().prepare(`INSERT INTO servers(name,game) VALUES(?,'t5')`).run(server);
});
afterEach(async () => {
await fixture.cleanup();
vi.unstubAllEnvs();
});
describe('moderation runtime configuration', () => {
it('protects the fixed private file even with customized shared config destinations', async () => {
const { config } = await import('../../src/lib/server/config.js');
(config as { relDestConfig: string }).relDestConfig = 'custom-configs';
const { isPrivateConfigPath } =
await import('../../src/lib/server/fleet/private-config.js');
expect(isPrivateConfigPath('configs/electric_cherry_moderation.cfg')).toBe(true);
expect(isPrivateConfigPath('custom-configs/electric_cherry_moderation.cfg')).toBe(true);
expect(isPrivateConfigPath('custom-configs/nested/electric_cherry_moderation.cfg')).toBe(
false
);
});
it('queues private configuration and returns only an operation identifier', async () => {
const { configureModerationServer } =
await import('../../src/lib/server/moderation/configure.js');
const hosts = await import('../../src/lib/server/hosts/index.js');
const { moderationCredential } =
await import('../../src/lib/server/moderation/credentials.js');
const result = configureModerationServer(actorId, server);
const credential = moderationCredential(instanceId, 1);
expect(Object.keys(result)).toEqual(['id']);
const operation = hosts.getHostOperation(result.id)!;
expect(operation).toMatchObject({
kind: 'instance.configure-moderation',
instanceId,
generation: 1,
payload: {
moderation: {
token: credential,
configPath: 'configs/electric_cherry_moderation.cfg',
url: 'https://public-controller.example/api/moderation/runtime',
generation: 1
}
}
});
expect(JSON.stringify(result)).not.toContain(credential);
expect(JSON.stringify(hosts.listHostOperationHistory())).not.toContain(credential);
const { db } = await import('../../src/lib/server/db/index.js');
expect(JSON.stringify(db().prepare('SELECT * FROM moderation_events').all())).not.toContain(
credential
);
expect(
db()
.prepare('SELECT count(*) AS n FROM file_blobs WHERE instr(content,?)>0')
.get(Buffer.from(credential))
).toEqual({ n: 0 });
hosts.reportHostOperationStatus(hostId, result.id, {
state: 'completed',
result: {
configured: true,
generation: 1,
liveLoad: 'sent',
text: credential,
contentBase64: Buffer.from(credential).toString('base64')
},
error: `Unexpected echo ${credential}`
});
const receipt = hosts.getHostOperationStatus(result.id)!;
expect(JSON.stringify(receipt)).not.toContain(credential);
expect(receipt.result).toEqual({ configured: true, generation: 1, liveLoad: 'sent' });
});
it.each(['viewer', 'operator', 'moderator', 'super_viewer'])(
'rejects %s configuration requests',
async (role) => {
const { db } = await import('../../src/lib/server/db/index.js');
db().prepare('UPDATE auth_users SET role=? WHERE id=?').run(role, actorId);
const { configureModerationServer } =
await import('../../src/lib/server/moderation/configure.js');
expect(() => configureModerationServer(actorId, server)).toThrow('access');
expect(db().prepare('SELECT COUNT(*) AS n FROM ec_host_operations').get()).toEqual({
n: 0
});
}
);
it('revalidates account state, host capability and instance identity before queueing', async () => {
const { configureModerationServer } =
await import('../../src/lib/server/moderation/configure.js');
const { db } = await import('../../src/lib/server/db/index.js');
db().prepare('UPDATE auth_users SET disabled=1 WHERE id=?').run(actorId);
expect(() => configureModerationServer(actorId, server)).toThrow('access');
db().prepare('UPDATE auth_users SET disabled=0 WHERE id=?').run(actorId);
const hosts = await import('../../src/lib/server/hosts/index.js');
hosts.heartbeat(hostId, { capabilities: [] });
expect(() => configureModerationServer(actorId, server)).toThrow('Update the host agent');
hosts.heartbeat(hostId, { capabilities: ['moderation.configure'] });
db()
.prepare('UPDATE native_instances SET retired_at=? WHERE id=?')
.run(new Date().toISOString(), instanceId);
expect(() => configureModerationServer(actorId, server)).toThrow('active native server');
expect(db().prepare('SELECT COUNT(*) AS n FROM ec_host_operations').get()).toEqual({
n: 0
});
});
it.each([
'',
'http://public-controller.example',
'https://user:password@public-controller.example',
'https://public-controller.example/prefix',
'https://public-controller.example/?token=secret'
])('rejects unsafe controller origin %s', async (url) => {
const { config } = await import('../../src/lib/server/config.js');
(config as { publicUrl: string }).publicUrl = url;
const { moderationRuntimeUrl } =
await import('../../src/lib/server/moderation/configuration.js');
expect(() => moderationRuntimeUrl()).toThrow('EC_PUBLIC_URL');
});
});
+89
View File
@@ -0,0 +1,89 @@
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import { newFixture, type Fixture } from '../helpers/fixture.js';
import { moderationGuid } from '../../src/lib/moderation.js';
let fixture: Fixture;
beforeEach(async () => {
vi.resetModules();
fixture = await newFixture();
});
afterEach(async () => fixture.cleanup());
it('keeps large identities exact and normalizes only known representations', () => {
expect(moderationGuid('t6', '110000100000001', 'rcon')).toBe('76561197960265729');
expect(moderationGuid('t6', '076561197960265729')).toBe('76561197960265729');
expect(moderationGuid('t5', 'DEADbeef', 'rcon')).toBe('DEADbeef');
expect(moderationGuid('t5', 'Some:opaque_ID')).toBe('Some:opaque_ID');
});
it('preserves aliases and moderation identity independently of gameplay retention', async () => {
const { db } = await import('../../src/lib/server/db/index.js');
const { observeModerationPlayers } = await import('../../src/lib/server/moderation/core.js');
const handle = db();
observeModerationPlayers(
handle,
't6',
[{ guid: '000123', name: 'Old name' }],
'2026-10-01T00:00:00.000Z'
);
observeModerationPlayers(
handle,
't6',
[{ guid: '123', name: 'New name' }],
'2026-10-02T00:00:00.000Z'
);
observeModerationPlayers(
handle,
't6',
[{ guid: '123', name: 'Delayed name' }],
'2026-09-30T00:00:00.000Z'
);
handle.prepare('DELETE FROM leaderboard_players').run();
expect(handle.prepare('SELECT * FROM moderation_players').get()).toMatchObject({
game: 't6',
guid: '123',
name: 'New name',
first_seen_at: '2026-09-30T00:00:00.000Z',
last_seen_at: '2026-10-02T00:00:00.000Z'
});
expect(handle.prepare('SELECT name FROM moderation_aliases ORDER BY name').all()).toEqual([
{ name: 'Delayed name' },
{ name: 'New name' },
{ name: 'Old name' }
]);
});
it('records mutation results atomically and rejects reused keys or revoked access', async () => {
const { db } = await import('../../src/lib/server/db/index.js');
const { moderationMutation, moderationEvent } =
await import('../../src/lib/server/moderation/core.js');
const handle = db();
handle
.prepare(
`INSERT INTO auth_users(id,username,display_name,password_hash,role,created_at,updated_at)
VALUES('moderator','moderator','Moderator','unused','moderator',0,0)`
)
.run();
const body = { op: 'note', idempotencyKey: 'request-1', text: 'A note' };
const work = vi.fn((actor, now) => {
moderationEvent(actor, 'note', { game: 't5', guid: '123' }, null, 'A note', now);
return { id: 'one' };
});
expect(moderationMutation('moderator', body, work)).toEqual({ ok: true, id: 'one' });
expect(moderationMutation('moderator', body, work)).toEqual({ ok: true, id: 'one' });
expect(work).toHaveBeenCalledTimes(1);
expect(() => moderationMutation('moderator', { ...body, text: 'Changed' }, work)).toThrow(
'already used'
);
expect(() =>
moderationMutation('moderator', { ...body, idempotencyKey: 'failing' }, (actor, now) => {
moderationEvent(actor, 'note', {}, null, 'Rolled back', now);
throw new Error('Failed');
})
).toThrow('Failed');
expect(handle.prepare('SELECT COUNT(*) AS count FROM moderation_events').get()).toEqual({
count: 1
});
handle.prepare("UPDATE auth_users SET role='viewer' WHERE id='moderator'").run();
expect(() => moderationMutation('moderator', body, work)).toThrow('Moderation access');
});
+590
View File
@@ -0,0 +1,590 @@
import { randomUUID } from 'node:crypto';
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import { newFixture, type Fixture } from '../helpers/fixture.js';
import type { ModerationRuntimeIdentity } from '../../src/lib/server/moderation/credentials.js';
const t5 = 't5_zombie_theater_1',
t6 = 't6_zm_transit_1';
let fixture: Fixture;
let instanceId: string;
let credential: string;
let identity: ModerationRuntimeIdentity;
const clock = '2026-10-15T12:00:00.000Z';
beforeEach(async () => {
vi.resetModules();
vi.useFakeTimers({ toFake: ['Date'] });
vi.setSystemTime(new Date(clock));
fixture = await newFixture([t5, t6]);
const { seedServerRegistry } = await import('../../src/lib/server/fleet/servers.js');
await seedServerRegistry();
const { db } = await import('../../src/lib/server/db/index.js');
const handle = db();
for (const role of ['admin', 'moderator', 'viewer', 'operator', 'super_viewer'])
handle
.prepare(
`INSERT INTO auth_users(id,username,display_name,password_hash,role,created_at,updated_at) VALUES(?,?,?,'unused',?,0,0)`
)
.run(role, role, role, role);
const { createHost } = await import('../../src/lib/server/hosts/index.js');
const host = createHost({ name: 'Moderation host' });
instanceId = randomUUID();
handle
.prepare(
`INSERT INTO native_instances(id,server,host_id,library_id,image,port,created_at) VALUES(?,?,?,'library','image',28960,?)`
)
.run(instanceId, t5, host.id, clock);
handle.prepare('UPDATE servers SET game=? WHERE name=?').run('t5', t5);
handle.prepare('UPDATE servers SET game=? WHERE name=?').run('t6', t6);
const { moderationCredential, authenticateModerationRuntime } =
await import('../../src/lib/server/moderation/credentials.js');
credential = moderationCredential(instanceId, 1);
identity = authenticateModerationRuntime(
new Request('https://test/api/moderation/runtime', {
headers: { Authorization: `Bearer ${credential}` }
})
)!;
expect(identity).toMatchObject({ instanceId, game: 't5', generation: 1 });
});
afterEach(async () => {
vi.useRealTimers();
await fixture.cleanup();
});
async function mutate(input: Record<string, unknown>, actor = 'moderator') {
const { mutateModeration } = await import('../../src/lib/server/moderation/actions.js');
return mutateModeration(actor, { idempotencyKey: randomUUID(), ...input });
}
const restriction = (overrides: Record<string, unknown> = {}) => ({
op: 'create-sanction',
game: 't5',
guid: '123',
kind: 'ban',
scope: 'game',
reason: 'Repeated abuse',
durationMinutes: 60,
...overrides
});
async function sync(overrides: Record<string, unknown> = {}) {
const { syncModerationRuntime } = await import('../../src/lib/server/moderation/runtime.js');
return syncModerationRuntime(identity, {
protocol: 1,
server: t5,
generation: 1,
session: 'boot-1',
sessionSequence: 1,
players: [{ guid: '123', name: 'Player', connection: 'boot-1:1' }],
capabilities: ['warn', 'message', 'kick', 'announce', 'ban', 'mute'],
appliedRevision: 0,
receipts: [],
...overrides
});
}
it('enforces private read/write access, duration limits, and explicit fleet identity', async () => {
const { moderationOverview, moderationQuery } =
await import('../../src/lib/server/moderation/overview.js');
const query = moderationQuery(new URLSearchParams());
for (const role of ['viewer', 'operator'])
expect(() => moderationOverview(role, query)).toThrow('Moderation access');
expect(moderationOverview('super_viewer', query).total).toBe(0);
for (const role of ['viewer', 'operator', 'super_viewer'])
await expect(mutate(restriction(), role)).rejects.toThrow('Moderation access');
for (const input of [{ durationMinutes: null }, { durationMinutes: 10081 }, { scope: 'fleet' }])
await expect(mutate(restriction(input))).rejects.toThrow('up to 7 days');
await expect(mutate(restriction({ scope: 'fleet' }), 'admin')).rejects.toThrow('both games');
expect(await mutate(restriction({ durationMinutes: 10080 }))).toHaveProperty('id');
});
it('applies current moderator limits when revoking restrictions created before an administrator downgrade', async () => {
const { db } = await import('../../src/lib/server/db/index.js');
const { observeModerationPlayers } = await import('../../src/lib/server/moderation/core.js');
for (const game of ['t5', 't6'] as const)
observeModerationPlayers(db(), game, [{ guid: '123', name: 'Verified player' }], clock);
db().prepare("UPDATE auth_users SET role='admin' WHERE id='moderator'").run();
const restricted = await Promise.all(
[
{ scope: 'fleet', durationMinutes: 10080 },
{ durationMinutes: null },
{ durationMinutes: 11520 }
].map((input) => mutate(restriction(input)))
);
const allowed = await Promise.all(
[{ scope: 'server', server: t5 }, { scope: 'game' }].map((input) =>
mutate(restriction({ ...input, durationMinutes: 10080 }))
)
);
db().prepare("UPDATE auth_users SET role='moderator' WHERE id='moderator'").run();
// The eight-day restriction now has only six days left, but its original duration governs.
vi.setSystemTime(new Date(Date.parse(clock) + 2 * 24 * 60 * 60 * 1000));
const revocation = (id: string) => ({
op: 'revoke-sanction',
id,
revision: 1,
reason: 'Appeal accepted'
});
for (const { id } of restricted)
await expect(mutate(revocation(id))).rejects.toThrow('originally lasting up to 7 days');
expect(
db()
.prepare(
'SELECT COUNT(*) AS count FROM moderation_sanctions WHERE revoked_at IS NOT NULL'
)
.get()
).toEqual({ count: 0 });
for (const { id } of allowed) expect(await mutate(revocation(id))).toMatchObject({ id });
for (const { id } of restricted)
expect(await mutate(revocation(id), 'admin')).toMatchObject({ id });
});
it('applies game and exact server registration scopes and revisioned revocation', async () => {
const game = await mutate(restriction());
const server = await mutate(restriction({ scope: 'server', server: t5, kind: 'mute' }));
const initial = await sync();
expect(initial.sanctions).toHaveLength(2);
await expect(
mutate({ op: 'revoke-sanction', id: game.id, revision: 99, reason: 'Correction' })
).rejects.toThrow('changed');
const { db } = await import('../../src/lib/server/db/index.js');
db()
.prepare(
"INSERT INTO auth_users(id,username,display_name,password_hash,role,created_at,updated_at) VALUES('other','other','Other','unused','moderator',0,0)"
)
.run();
await expect(
mutate({ op: 'revoke-sanction', id: game.id, revision: 1, reason: 'Correction' }, 'other')
).rejects.toThrow('another moderator');
await mutate(
{ op: 'revoke-sanction', id: game.id, revision: 1, reason: 'Appeal accepted' },
'admin'
);
const next = await sync({ appliedRevision: initial.revision });
expect(next.revision).toBeGreaterThan(initial.revision);
expect(next.sanctions).toMatchObject([{ id: server.id }]);
const { syncModerationRuntime } = await import('../../src/lib/server/moderation/runtime.js');
const differentRegistration = { ...identity, serverCreatedAt: '2099-01-01T00:00:00.000Z' };
db()
.prepare('UPDATE servers SET created_at=? WHERE name=?')
.run(differentRegistration.serverCreatedAt, t5);
expect(
syncModerationRuntime(differentRegistration, {
protocol: 1,
server: t5,
generation: 1,
session: 'boot-1',
sessionSequence: 1,
players: [],
capabilities: [],
appliedRevision: 0,
receipts: []
}).sanctions
).toEqual([]);
});
it('excludes expired restrictions without deleting their history', async () => {
await mutate(restriction({ durationMinutes: 1 }));
expect((await sync()).sanctions).toHaveLength(1);
vi.setSystemTime(new Date(Date.parse(clock) + 60_000));
expect((await sync()).sanctions).toEqual([]);
const { moderationOverview, moderationQuery } =
await import('../../src/lib/server/moderation/overview.js');
const data = moderationOverview(
'moderator',
moderationQuery(new URLSearchParams('view=restrictions'))
);
expect(data.sanctions).toMatchObject([{ active: false, reason: 'Repeated abuse' }]);
});
it('queues a single live action and preserves exact delivery across duplicate polling', async () => {
await sync();
const request = {
op: 'action',
kind: 'kick',
game: 't5',
guid: '123',
server: t5,
reason: 'Repeated abuse',
idempotencyKey: 'same-action'
};
const created = await mutate(request);
expect(await mutate(request)).toEqual(created);
const first = await sync();
expect(first.actions).toMatchObject([
{ id: created.id, guid: '123', connection: 'boot-1:1', kind: 'kick' }
]);
expect((await sync()).actions).toEqual(first.actions);
expect(
(
await sync({
receipts: [{ id: created.id, state: 'confirmed', detail: 'Player disconnected.' }]
})
).actions
).toEqual([]);
await sync({
receipts: [{ id: created.id, state: 'confirmed', detail: 'Player disconnected.' }]
});
const { db } = await import('../../src/lib/server/db/index.js');
expect(
db()
.prepare(
"SELECT COUNT(*) AS count FROM moderation_events WHERE action='action.confirmed'"
)
.get()
).toEqual({ count: 1 });
});
it('does not deliver to a reused connection or after permission revocation', async () => {
await sync();
await mutate({
op: 'action',
kind: 'warn',
game: 't5',
guid: '123',
server: t5,
reason: 'Warning'
});
expect(
(await sync({ players: [{ guid: '123', name: 'Player', connection: 'boot-1:2' }] })).actions
).toEqual([]);
await mutate({
op: 'action',
kind: 'warn',
game: 't5',
guid: '123',
server: t5,
reason: 'Warning again'
});
const { db } = await import('../../src/lib/server/db/index.js');
db().prepare("UPDATE auth_users SET role='viewer' WHERE id='moderator'").run();
expect(
(await sync({ players: [{ guid: '123', name: 'Player', connection: 'boot-1:2' }] })).actions
).toEqual([]);
expect(db().prepare('SELECT state FROM moderation_actions').all()).toEqual([
{ state: 'failed' },
{ state: 'failed' }
]);
});
it('keeps unacknowledged delivery uncertain and accepts durable late receipts without replay', async () => {
await sync();
const { id } = await mutate({
op: 'action',
kind: 'kick',
game: 't5',
guid: '123',
server: t5,
reason: 'Kick'
});
await sync();
vi.setSystemTime(new Date(Date.parse(clock) + 31_000));
expect((await sync()).actions).toEqual([]);
const { db } = await import('../../src/lib/server/db/index.js');
expect(db().prepare('SELECT state FROM moderation_actions WHERE id=?').get(id)).toEqual({
state: 'unknown'
});
await sync({
session: 'boot-2',
sessionSequence: 2,
players: [],
receipts: [{ id, state: 'confirmed', detail: 'Observed disconnect' }]
});
expect(db().prepare('SELECT state FROM moderation_actions WHERE id=?').get(id)).toEqual({
state: 'confirmed'
});
});
it('expires undelivered actions and refuses stale or unsupported live targeting', async () => {
await sync({ capabilities: ['warn'] });
await expect(
mutate({ op: 'action', kind: 'kick', game: 't5', guid: '123', server: t5, reason: 'Kick' })
).rejects.toThrow('does not support');
await expect(
mutate({
op: 'action',
kind: 'warn',
game: 't5',
guid: '999',
server: t5,
reason: 'Warning'
})
).rejects.toThrow('current connection');
await mutate({
op: 'action',
kind: 'warn',
game: 't5',
guid: '123',
server: t5,
reason: 'Warning'
});
vi.setSystemTime(new Date(Date.parse(clock) + 31_000));
await expect(
mutate({
op: 'action',
kind: 'warn',
game: 't5',
guid: '123',
server: t5,
reason: 'Warning'
})
).rejects.toThrow('not connected');
await sync();
const { db } = await import('../../src/lib/server/db/index.js');
expect(db().prepare('SELECT state FROM moderation_actions').get()).toEqual({
state: 'expired'
});
});
it('binds machine credentials to the active generation and authenticates before body parsing', async () => {
const { POST } = await import('../../src/routes/api/moderation/runtime/+server.js');
const invoke = (token: string) =>
POST({
request: new Request('https://test/api/moderation/runtime', {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
body: 'bad json'
})
} as Parameters<typeof POST>[0]);
expect((await invoke('ec_bank_' + 'a'.repeat(43))).status).toBe(401);
const { db } = await import('../../src/lib/server/db/index.js');
const { moderationCredential, authenticateModerationRuntime } =
await import('../../src/lib/server/moderation/credentials.js');
const future = moderationCredential(instanceId, 2);
expect(
authenticateModerationRuntime(
new Request('https://test', { headers: { Authorization: `Bearer ${future}` } })
)
).toBeNull();
db().prepare('UPDATE native_instances SET generation=2 WHERE id=?').run(instanceId);
expect((await invoke(credential)).status).toBe(401);
expect(
authenticateModerationRuntime(
new Request('https://test', { headers: { Authorization: `Bearer ${future}` } })
)
).toMatchObject({ generation: 2 });
db().prepare('UPDATE native_instances SET retired_at=? WHERE id=?').run(clock, instanceId);
expect((await invoke(future)).status).toBe(401);
});
it('preserves verified chat evidence after source match cleanup and rejects forged evidence', async () => {
const { saveTelemetry } = await import('../../src/lib/server/fleet/telemetry.js');
saveTelemetry({
server: t5,
map: 'zombie_theater',
event: 'game_start',
players: [{ guid: '123', name: 'Player' }],
receivedAt: clock
});
const { db } = await import('../../src/lib/server/db/index.js');
const { id: matchId } = db().prepare('SELECT id FROM leaderboard_matches').get() as {
id: number;
};
const entry = {
type: 'message',
message: {
guid: '123',
slot: 0,
player: 'Player',
message: 'Evidence message',
time: '1:20'
},
context: { realTime: null, round: null, matchElapsedMs: null }
};
db()
.prepare('INSERT INTO match_chat VALUES(?,?,?,?)')
.run(matchId, 'key', 0, JSON.stringify(entry));
await expect(
mutate({
op: 'create-case',
game: 't5',
guid: '123',
title: 'Review',
evidence: { matchId, message: 'Forged message' }
})
).rejects.toThrow('could not be verified');
const { id } = await mutate({
op: 'create-case',
game: 't5',
guid: '123',
title: 'Review',
note: 'Staff context',
evidence: { matchId, message: 'Evidence message' }
});
db().prepare('DELETE FROM leaderboard_matches').run();
expect(
db()
.prepare(
'SELECT text,source,snapshot FROM moderation_evidence WHERE case_id=? ORDER BY source'
)
.all(id)
).toEqual([
expect.objectContaining({
text: 'Evidence message',
snapshot: expect.stringContaining('Evidence message')
}),
expect.objectContaining({ text: 'Staff context', source: 'Staff note', snapshot: null })
]);
await mutate({
op: 'resolve-case',
id,
revision: 1,
status: 'resolved',
reason: 'Appeal accepted'
});
await expect(
mutate({
op: 'resolve-case',
id,
revision: 1,
status: 'dismissed',
reason: 'Stale decision'
})
).rejects.toThrow('changed');
});
it('retains paginated player search and reads pending policy coverage honestly', async () => {
await sync();
await mutate(restriction());
const { moderationOverview, moderationQuery } =
await import('../../src/lib/server/moderation/overview.js');
const data = moderationOverview(
'moderator',
moderationQuery(new URLSearchParams('q=Player&offset=999'))
);
expect(data.players).toMatchObject([
{ guid: '123', name: 'Player', servers: [t5], restrictions: 1 }
]);
expect(data.query.offset).toBe(0);
expect(data.coverage.find((c) => c.server === t5)?.state).toBe('pending');
const snapshot = await sync();
await sync({ appliedRevision: snapshot.revision });
expect(
moderationOverview('moderator', data.query).coverage.find((c) => c.server === t5)?.state
).toBe('current');
});
it('does not treat manually entered identities as cross-game observations', async () => {
for (const game of ['t5', 't6'])
await mutate({ op: 'note', game, guid: '123', text: 'Manual lookup' });
await expect(mutate(restriction({ scope: 'fleet' }), 'admin')).rejects.toThrow('both games');
const { moderationOverview, moderationQuery } =
await import('../../src/lib/server/moderation/overview.js');
expect(moderationOverview('admin', moderationQuery(new URLSearchParams())).players).toEqual([
expect.objectContaining({ firstSeen: null, lastSeen: null }),
expect.objectContaining({ firstSeen: null, lastSeen: null })
]);
await sync();
const { db } = await import('../../src/lib/server/db/index.js');
const { observeModerationPlayers } = await import('../../src/lib/server/moderation/core.js');
observeModerationPlayers(db(), 't6', [{ guid: '123', name: 'Verified player' }], clock);
const created = await mutate(restriction({ scope: 'fleet' }), 'admin');
expect((await sync()).sanctions).toMatchObject([{ id: created.id }]);
});
it('shows applicable fleet restrictions in either game without including other game restrictions', async () => {
const { db } = await import('../../src/lib/server/db/index.js');
const { observeModerationPlayers } = await import('../../src/lib/server/moderation/core.js');
for (const game of ['t5', 't6'] as const)
observeModerationPlayers(db(), game, [{ guid: '123', name: 'Verified player' }], clock);
const fleet = await mutate(restriction({ scope: 'fleet' }), 'admin');
const local = {
t5: await mutate(restriction()),
t6: await mutate(restriction({ game: 't6' }))
};
await mutate(restriction({ game: 't6', guid: '456' }));
const { moderationOverview, moderationQuery } =
await import('../../src/lib/server/moderation/overview.js');
for (const game of ['t5', 't6'] as const) {
const data = moderationOverview(
'moderator',
moderationQuery(new URLSearchParams({ view: 'restrictions', game, guid: '123' }))
);
expect(data.total).toBe(2);
expect(data.sanctions.map((row) => row.id).sort()).toEqual(
[fleet.id, local[game].id].sort()
);
}
});
it('rejects oversized UTF-8 game messages before policy or queue changes', async () => {
await expect(mutate(restriction({ reason: '界'.repeat(100) }))).rejects.toThrow('UTF-8 bytes');
await sync();
await expect(
mutate({
op: 'action',
kind: 'message',
game: 't5',
guid: '123',
server: t5,
reason: '界'.repeat(100)
})
).rejects.toThrow('UTF-8 bytes');
const { db } = await import('../../src/lib/server/db/index.js');
expect(db().prepare('SELECT COUNT(*) AS count FROM moderation_sanctions').get()).toEqual({
count: 0
});
expect(db().prepare('SELECT COUNT(*) AS count FROM moderation_actions').get()).toEqual({
count: 0
});
});
it('rechecks runtime authorization after a request body could have yielded', async () => {
const { db } = await import('../../src/lib/server/db/index.js');
db().prepare('UPDATE native_instances SET generation=2 WHERE id=?').run(instanceId);
await expect(sync()).rejects.toThrow('no longer active');
expect(db().prepare('SELECT COUNT(*) AS count FROM moderation_runtimes').get()).toEqual({
count: 0
});
db().prepare('UPDATE native_instances SET generation=1 WHERE id=?').run(instanceId);
db().prepare('DELETE FROM moderation_credentials WHERE instance_id=?').run(instanceId);
await expect(sync()).rejects.toThrow('no longer active');
expect(db().prepare('SELECT COUNT(*) AS count FROM moderation_players').get()).toEqual({
count: 0
});
});
it('rejects coerced enum values before moderator scope checks or SQL binding', async () => {
await expect(mutate(restriction({ scope: ['fleet'] }))).rejects.toThrow('restriction scope');
await sync();
await expect(
mutate({ op: 'action', kind: ['announce'], server: t5, reason: 'Message' })
).rejects.toThrow('supported live action');
const { db } = await import('../../src/lib/server/db/index.js');
expect(db().prepare('SELECT COUNT(*) AS count FROM moderation_sanctions').get()).toEqual({
count: 0
});
expect(db().prepare('SELECT COUNT(*) AS count FROM moderation_requests').get()).toEqual({
count: 0
});
});
it('rejects delayed polls from superseded game sessions without replacing current connections', async () => {
await sync({
session: 'new-boot',
sessionSequence: 2,
players: [{ guid: '123', name: 'Current player', connection: 'new-boot:1' }]
});
await expect(sync()).rejects.toThrow('superseded');
await expect(sync({ session: 'different-boot', sessionSequence: 2 })).rejects.toThrow(
'superseded'
);
const { db } = await import('../../src/lib/server/db/index.js');
expect(
db().prepare('SELECT session,session_sequence,players FROM moderation_runtimes').get()
).toMatchObject({
session: 'new-boot',
session_sequence: 2,
players: expect.stringContaining('Current player')
});
});
it('does not advertise current restriction coverage for a runtime lacking ban or mute support', async () => {
const first = await sync();
await sync({ capabilities: ['warn'], appliedRevision: first.revision });
const { moderationOverview, moderationQuery } =
await import('../../src/lib/server/moderation/overview.js');
expect(
moderationOverview('admin', moderationQuery(new URLSearchParams())).coverage.find(
(row) => row.server === t5
)
).toMatchObject({ state: 'pending', capabilities: ['warn'] });
});
@@ -26,6 +26,7 @@ beforeEach(async () => {
vi.stubEnv('GAME_FILES_ROOT', join(fixture.root, 'libraries')); vi.stubEnv('GAME_FILES_ROOT', join(fixture.root, 'libraries'));
vi.stubEnv('PLUTAINER_IMAGE', image); vi.stubEnv('PLUTAINER_IMAGE', image);
vi.stubEnv('RCON_PASSWORD', 'test-password'); vi.stubEnv('RCON_PASSWORD', 'test-password');
vi.stubEnv('EC_PUBLIC_URL', 'https://public-controller.example');
const { createHost, createEnrollment, enrollHost, heartbeat } = const { createHost, createEnrollment, enrollHost, heartbeat } =
await import('../../src/lib/server/hosts/index.js'); await import('../../src/lib/server/hosts/index.js');
@@ -460,3 +461,51 @@ it('injects unique persistent bank credentials only into private runtime files',
) )
).toBe(false); ).toBe(false);
}); });
it('keeps legacy agents compatible without claiming moderation configuration', async () => {
const job = await queue();
await runPass();
const op = await operationFor(job.deploymentId);
const { getHostOperation } = await import('../../src/lib/server/hosts/index.js');
const payload = getHostOperation(op!.id)!.payload as Record<string, unknown>;
expect(payload.moderation).toBeUndefined();
});
it('injects generation-bound moderation credentials privately on compatible hosts', async () => {
const hosts = await import('../../src/lib/server/hosts/index.js');
hosts.heartbeat(hostId, {
...hosts.getHost(hostId)!.inventory,
capabilities: ['moderation.configure']
});
const job = await queue();
await runPass();
const op = await operationFor(job.deploymentId);
const { nativeInstance } = await import('../../src/lib/server/backends/native.js');
const { moderationCredential } = await import('../../src/lib/server/moderation/credentials.js');
const { recipeForDeployment } = await import('../../src/lib/server/fleet/launch-recipes.js');
const instance = nativeInstance(job.name)!;
const credential = moderationCredential(instance.id, instance.generation);
const payload = hosts.getHostOperation(op!.id)!.payload as {
moderation: { token: string; configPath: string; url: string; generation: number };
files: { path: string }[];
env: Record<string, string>;
};
expect(payload.moderation).toEqual({
token: credential,
configPath: 'configs/electric_cherry_moderation.cfg',
url: 'https://public-controller.example/api/moderation/runtime',
generation: instance.generation
});
expect(payload.files.some((file) => file.path.endsWith('electric_cherry_moderation.cfg'))).toBe(
false
);
expect(payload.env.PLUTAINER_EXTRA_ARGS).toContain('+exec electric_cherry_moderation.cfg');
const { db } = await import('../../src/lib/server/db/index.js');
expect(
db()
.prepare('SELECT count(*) AS n FROM file_blobs WHERE instr(content,?) > 0')
.get(Buffer.from(credential))
).toEqual({ n: 0 });
expect(JSON.stringify(recipeForDeployment(job.deploymentId))).not.toContain(credential);
expect(JSON.stringify(hosts.listHostOperationHistory())).not.toContain(credential);
});
+38
View File
@@ -1298,6 +1298,44 @@ describe('native instance provisioning', () => {
await advance(); await advance();
expect(nativeInstance(job.name)?.generation).toBe(2); expect(nativeInstance(job.name)?.generation).toBe(2);
}); });
it('prepares separate moderation credentials for the replacement generation', async () => {
const job = await queue();
const { config } = await import('../../src/lib/server/config.js');
(config as { publicUrl: string }).publicUrl = 'https://public-controller.example';
const { heartbeat } = await import('../../src/lib/server/hosts/index.js');
heartbeat(hostId, {
...inventory(),
capabilities: [...inventory().capabilities, 'moderation.configure']
});
await advance();
const created = await acknowledge(job.deploymentId);
const oldToken = (created.payload.moderation as { token: string }).token;
const { queueNativeRebuild } = await import('../../src/lib/server/provisioning/native.js');
const rebuild = queueNativeRebuild(job.name, 'test');
if (!rebuild.ok) throw new Error(rebuild.message);
await advance();
const operation = (await operationFor(rebuild.deploymentId))!;
expect(operation).toMatchObject({
generation: 1,
payload: {
nextGeneration: 2,
moderation: { generation: 2, configPath: 'configs/electric_cherry_moderation.cfg' }
}
});
const newToken = (operation.payload.moderation as { token: string }).token;
expect(newToken).not.toBe(oldToken);
const { authenticateModerationRuntime } =
await import('../../src/lib/server/moderation/credentials.js');
const request = (token: string) =>
new Request('https://public-controller.example/api/moderation/runtime', {
headers: { Authorization: `Bearer ${token}` }
});
expect(authenticateModerationRuntime(request(oldToken))).toMatchObject({ generation: 1 });
expect(authenticateModerationRuntime(request(newToken))).toBeNull();
await acknowledge(rebuild.deploymentId);
expect(authenticateModerationRuntime(request(oldToken))).toBeNull();
expect(authenticateModerationRuntime(request(newToken))).toMatchObject({ generation: 2 });
});
it('carries confirmed admin override through serialized rebuild preparation', async () => { it('carries confirmed admin override through serialized rebuild preparation', async () => {
const job = await queue(); const job = await queue();
await advance(); await advance();
+8
View File
@@ -20,6 +20,7 @@ describe('application navigation', () => {
['/configs/42', 'releases', '/configs'], ['/configs/42', 'releases', '/configs'],
['/leaderboard/players/42', 'players', '/leaderboard'], ['/leaderboard/players/42', 'players', '/leaderboard'],
['/bank', 'players', '/bank'], ['/bank', 'players', '/bank'],
['/moderation', 'players', '/moderation'],
['/settings/users', 'admin', '/settings/users'], ['/settings/users', 'admin', '/settings/users'],
['/server-keys', 'admin', '/server-keys'] ['/server-keys', 'admin', '/server-keys']
])('gives %s exactly one canonical home', (path, group, href) => { ])('gives %s exactly one canonical home', (path, group, href) => {
@@ -61,6 +62,13 @@ describe('application navigation', () => {
expect(navigationLocation('/server-keys', 'super_viewer')?.item.shortcut).toBe('k'); expect(navigationLocation('/server-keys', 'super_viewer')?.item.shortcut).toBe('k');
}); });
it('reserves private moderation navigation for moderators, administrators, and Super Viewers', () => {
for (const role of ['admin', 'moderator', 'super_viewer'] as const)
expect(navigationLocation('/moderation', role)?.group.id).toBe('players');
for (const role of ['operator', 'viewer'] as const)
expect(navigationLocation('/moderation', role)).toBeNull();
});
it('groups file releases together and retains Campaigns as its own destination', () => { it('groups file releases together and retains Campaigns as its own destination', () => {
expect(navigationGroups.map((group) => group.label)).toEqual([ expect(navigationGroups.map((group) => group.label)).toEqual([
'Fleet', 'Fleet',
+75 -41
View File
@@ -3,11 +3,13 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { join } from 'node:path'; import { join } from 'node:path';
import { newFixture, type Fixture } from '../helpers/fixture.js'; import { newFixture, type Fixture } from '../helpers/fixture.js';
let fixture: Fixture; describe.each(['electric_cherry_bank.cfg', 'electric_cherry_moderation.cfg'])(
const servers = ['t5_zombie_theater_1', 't6_zombie_town_1']; 'private config %s',
const filename = 'electric_cherry_bank.cfg'; (filename) => {
const secret = Buffer.from('set tws_bank_token "ec_bank_private_regression_secret"\n'); let fixture: Fixture;
beforeEach(async () => { const servers = ['t5_zombie_theater_1', 't6_zombie_town_1'];
const secret = Buffer.from('set tws_bank_token "ec_bank_private_regression_secret"\n');
beforeEach(async () => {
vi.resetModules(); vi.resetModules();
fixture = await newFixture(servers); fixture = await newFixture(servers);
const { seedServerRegistry } = await import('../../src/lib/server/fleet/servers.js'); const { seedServerRegistry } = await import('../../src/lib/server/fleet/servers.js');
@@ -17,27 +19,30 @@ beforeEach(async () => {
await mkdir(destOfKind(server, 'config'), { recursive: true }); await mkdir(destOfKind(server, 'config'), { recursive: true });
await writeFile(join(destOfKind(server, 'config'), filename), secret); await writeFile(join(destOfKind(server, 'config'), filename), secret);
} }
}); });
afterEach(async () => { afterEach(async () => {
vi.restoreAllMocks(); vi.restoreAllMocks();
await fixture.cleanup(); await fixture.cleanup();
}); });
async function evidenceIsPrivate() { async function evidenceIsPrivate() {
const { db } = await import('../../src/lib/server/db/index.js'); const { db } = await import('../../src/lib/server/db/index.js');
expect(db().prepare('SELECT 1 FROM file_blobs WHERE content=?').all(secret)).toEqual([]); expect(db().prepare('SELECT 1 FROM file_blobs WHERE content=?').all(secret)).toEqual(
expect(db().prepare('SELECT 1 FROM deployment_files WHERE rel_path=?').all(filename)).toEqual(
[] []
); );
} expect(
db().prepare('SELECT 1 FROM deployment_files WHERE rel_path=?').all(filename)
).toEqual([]);
}
describe.each(servers)('private bank config on %s', (server) => { describe.each(servers)('private bank config on %s', (server) => {
it('does not read, preview, stage, or capture the generated file while repairing real drift', async () => { it('does not read, preview, stage, or capture the generated file while repairing real drift', async () => {
const drift = await import('../../src/lib/server/fleet/drift.js'); const drift = await import('../../src/lib/server/fleet/drift.js');
const { destOfKind } = await import('../../src/lib/server/config.js'); const { destOfKind } = await import('../../src/lib/server/config.js');
const { db } = await import('../../src/lib/server/db/index.js'); const { db } = await import('../../src/lib/server/db/index.js');
const { createScript } = await import('../../src/lib/server/fleet/scripts.js'); const { createScript } = await import('../../src/lib/server/fleet/scripts.js');
const { requestRestart } = await import('../../src/lib/server/provisioning/requests.js'); const { requestRestart } =
await import('../../src/lib/server/provisioning/requests.js');
const { applyStagedFilesForOperation } = const { applyStagedFilesForOperation } =
await import('../../src/lib/server/fleet/staged-files.js'); await import('../../src/lib/server/fleet/staged-files.js');
const backend = await import('../../src/lib/server/backends/files.js'); const backend = await import('../../src/lib/server/backends/files.js');
@@ -45,7 +50,9 @@ describe.each(servers)('private bank config on %s', (server) => {
expect(await drift.scanServerDrift(server)).toEqual([]); expect(await drift.scanServerDrift(server)).toEqual([]);
expect((await drift.previewDrift([server])).files).toEqual([]); expect((await drift.previewDrift([server])).files).toEqual([]);
expect((await drift.prepareDriftCampaign(server)).files).toEqual([]); expect((await drift.prepareDriftCampaign(server)).files).toEqual([]);
expect((await drift.runDriftDeployment({ servers: [server] })).deploymentId).toBeNull(); expect(
(await drift.runDriftDeployment({ servers: [server] })).deploymentId
).toBeNull();
const dest = destOfKind(server, 'config'); const dest = destOfKind(server, 'config');
await writeFile(join(dest, 'stray.cfg'), 'unwanted'); await writeFile(join(dest, 'stray.cfg'), 'unwanted');
// The exemption is for the generated path, not every matching basename. // The exemption is for the generated path, not every matching basename.
@@ -77,18 +84,24 @@ describe.each(servers)('private bank config on %s', (server) => {
servers: [server], servers: [server],
expectedFingerprint: plan.fingerprint expectedFingerprint: plan.fingerprint
}); });
const operation = requestRestart(server, { kind: 'campaign', id: result.deploymentId! }); const operation = requestRestart(server, {
kind: 'campaign',
id: result.deploymentId!
});
applyStagedFilesForOperation(operation.id, server); applyStagedFilesForOperation(operation.id, server);
expect(await drift.scanServerDrift(server)).toEqual([]); expect(await drift.scanServerDrift(server)).toEqual([]);
expect(await readFile(join(dest, filename))).toEqual(secret); expect(await readFile(join(dest, filename))).toEqual(secret);
expect(reads.mock.calls.some(([path]) => path === join(dest, filename))).toBe(false); expect(reads.mock.calls.some(([path]) => path === join(dest, filename))).toBe(
false
);
await evidenceIsPrivate(); await evidenceIsPrivate();
}); });
it('filters old prepared bank evidence while retaining legitimate campaign changes', async () => { it('filters old prepared bank evidence while retaining legitimate campaign changes', async () => {
const { prepareDriftCampaign, createDriftRebuildCampaign } = const { prepareDriftCampaign, createDriftRebuildCampaign } =
await import('../../src/lib/server/fleet/drift.js'); await import('../../src/lib/server/fleet/drift.js');
const { beginDeployment } = await import('../../src/lib/server/provisioning/history.js'); const { beginDeployment } =
await import('../../src/lib/server/provisioning/history.js');
const { destOfKind } = await import('../../src/lib/server/config.js'); const { destOfKind } = await import('../../src/lib/server/config.js');
const { db } = await import('../../src/lib/server/db/index.js'); const { db } = await import('../../src/lib/server/db/index.js');
await writeFile(join(destOfKind(server, 'config'), 'stray.cfg'), 'unwanted'); await writeFile(join(destOfKind(server, 'config'), 'stray.cfg'), 'unwanted');
@@ -103,7 +116,9 @@ describe.each(servers)('private bank config on %s', (server) => {
const operation = beginDeployment(server, 'rebuild', 'watchdog'); const operation = beginDeployment(server, 'rebuild', 'watchdog');
const id = createDriftRebuildCampaign(server, operation, 'old-incident', prepared); const id = createDriftRebuildCampaign(server, operation, 'old-incident', prepared);
expect( expect(
db().prepare('SELECT rel_path FROM deployment_files WHERE deployment_id=?').all(id) db()
.prepare('SELECT rel_path FROM deployment_files WHERE deployment_id=?')
.all(id)
).toEqual([{ rel_path: 'stray.cfg' }]); ).toEqual([{ rel_path: 'stray.cfg' }]);
expect( expect(
createDriftRebuildCampaign(server, operation, 'bank-only', { createDriftRebuildCampaign(server, operation, 'bank-only', {
@@ -117,10 +132,12 @@ describe.each(servers)('private bank config on %s', (server) => {
it('rejects new and persisted private deletions before activation or evidence capture', async () => { it('rejects new and persisted private deletions before activation or evidence capture', async () => {
const staged = await import('../../src/lib/server/fleet/staged-files.js'); const staged = await import('../../src/lib/server/fleet/staged-files.js');
const { db } = await import('../../src/lib/server/db/index.js'); const { db } = await import('../../src/lib/server/db/index.js');
const { requestRestart } = await import('../../src/lib/server/provisioning/requests.js'); const { requestRestart } =
await import('../../src/lib/server/provisioning/requests.js');
const { applyActivationFiles } = const { applyActivationFiles } =
await import('../../src/lib/server/provisioning/activation-files.js'); await import('../../src/lib/server/provisioning/activation-files.js');
const { captureRecipePlan } = await import('../../src/lib/server/fleet/launch-recipes.js'); const { captureRecipePlan } =
await import('../../src/lib/server/fleet/launch-recipes.js');
const { destOfKind } = await import('../../src/lib/server/config.js'); const { destOfKind } = await import('../../src/lib/server/config.js');
db() db()
.prepare( .prepare(
@@ -158,12 +175,14 @@ describe.each(servers)('private bank config on %s', (server) => {
expect(() => applyActivationFiles(recipe, operation.id, true)).toThrow( expect(() => applyActivationFiles(recipe, operation.id, true)).toThrow(
'Private bank configuration' 'Private bank configuration'
); );
expect(await readFile(join(destOfKind(server, 'config'), filename))).toEqual(secret); expect(await readFile(join(destOfKind(server, 'config'), filename))).toEqual(
secret
);
await evidenceIsPrivate(); await evidenceIsPrivate();
}); });
}); });
it('rejects the reserved upload name and redacts legacy receipts before packing their bytes', async () => { it('rejects the reserved upload name and redacts legacy receipts before packing their bytes', async () => {
const { createScript } = await import('../../src/lib/server/fleet/scripts.js'); const { createScript } = await import('../../src/lib/server/fleet/scripts.js');
const { packFileJson } = await import('../../src/lib/server/db/file-blobs.js'); const { packFileJson } = await import('../../src/lib/server/db/file-blobs.js');
expect(() => expect(() =>
@@ -182,11 +201,12 @@ it('rejects the reserved upload name and redacts legacy receipts before packing
expect(packed.hashes.size).toBe(0); expect(packed.hashes.size).toBe(0);
expect(JSON.stringify(packed.value)).not.toContain(secret.toString('base64')); expect(JSON.stringify(packed.value)).not.toContain(secret.toString('base64'));
await evidenceIsPrivate(); await evidenceIsPrivate();
}); });
it('blocks legacy private campaign diffs and blob downloads', async () => { it('blocks legacy private campaign diffs and blob downloads', async () => {
const { db } = await import('../../src/lib/server/db/index.js'); const { db } = await import('../../src/lib/server/db/index.js');
const { putFileBlob, getFileBlob } = await import('../../src/lib/server/db/file-blobs.js'); const { putFileBlob, getFileBlob } =
await import('../../src/lib/server/db/file-blobs.js');
db() db()
.prepare( .prepare(
"INSERT INTO deployments(id,action,started_at,triggered_by) VALUES('legacy','drift',datetime('now'),'ui')" "INSERT INTO deployments(id,action,started_at,triggered_by) VALUES('legacy','drift',datetime('now'),'ui')"
@@ -207,17 +227,22 @@ it('blocks legacy private campaign diffs and blob downloads', async () => {
).rejects.toMatchObject({ status: 404 }); ).rejects.toMatchObject({ status: 404 });
} }
expect(() => getFileBlob(hash)).toThrow('Private file content'); expect(() => getFileBlob(hash)).toThrow('Private file content');
}); });
it('blocks old host commands and redacts late private read receipts from older agents', async () => { it('blocks old host commands and redacts late private read receipts from older agents', async () => {
const hosts = await import('../../src/lib/server/hosts/index.js'); const hosts = await import('../../src/lib/server/hosts/index.js');
const { db } = await import('../../src/lib/server/db/index.js'); const { db } = await import('../../src/lib/server/db/index.js');
const host = hosts.createHost({ name: 'Legacy private command host' }); const host = hosts.createHost({ name: 'Legacy private command host' });
const credential = hosts.enrollHost(hosts.createEnrollment(host.id).token, {}).credential; const credential = hosts.enrollHost(
hosts.createEnrollment(host.id).token,
{}
).credential;
const payload = { const payload = {
files: [{ path: `configs/${filename}`, contentBase64: secret.toString('base64') }] files: [{ path: `configs/${filename}`, contentBase64: secret.toString('base64') }]
}; };
expect(() => hosts.queueHostOperation(host.id, 'files.stage', payload)).toThrow('Private bank'); expect(() => hosts.queueHostOperation(host.id, 'files.stage', payload)).toThrow(
'Private bank'
);
const stage = hosts.queueHostOperation(host.id, 'files.stage', { files: [] }); const stage = hosts.queueHostOperation(host.id, 'files.stage', { files: [] });
db() db()
.prepare('UPDATE ec_host_operations SET payload=? WHERE id=?') .prepare('UPDATE ec_host_operations SET payload=? WHERE id=?')
@@ -226,16 +251,22 @@ it('blocks old host commands and redacts late private read receipts from older a
const healthyRead = hosts.queueHostOperation(host.id, 'files.read', { const healthyRead = hosts.queueHostOperation(host.id, 'files.read', {
path: 'configs/stray.cfg' path: 'configs/stray.cfg'
}); });
expect(hosts.pendingHostOperationRows(host.id).map((operation) => operation.id)).toContain( expect(
healthyRead.id hosts.pendingHostOperationRows(host.id).map((operation) => operation.id)
); ).toContain(healthyRead.id);
expect(hosts.pendingHostOperationRows(host.id).map((operation) => operation.id)).not.toContain( expect(
stage.id hosts.pendingHostOperationRows(host.id).map((operation) => operation.id)
); ).not.toContain(stage.id);
const read = hosts.queueHostOperation(host.id, 'files.read', { path: 'configs/stray.cfg' }); const read = hosts.queueHostOperation(host.id, 'files.read', {
path: 'configs/stray.cfg'
});
db() db()
.prepare('UPDATE ec_host_operations SET payload=?,target_path=? WHERE id=?') .prepare('UPDATE ec_host_operations SET payload=?,target_path=? WHERE id=?')
.run(JSON.stringify({ path: `configs/${filename}` }), `configs/${filename}`, read.id); .run(
JSON.stringify({ path: `configs/${filename}` }),
`configs/${filename}`,
read.id
);
hosts.reportHostOperationStatus(host.id, read.id, { hosts.reportHostOperationStatus(host.id, read.id, {
state: 'completed', state: 'completed',
result: { contentBase64: secret.toString('base64') } result: { contentBase64: secret.toString('base64') }
@@ -244,7 +275,8 @@ it('blocks old host commands and redacts late private read receipts from older a
await evidenceIsPrivate(); await evidenceIsPrivate();
// Even an old operation reference cannot authorize downloading captured credentials. // Even an old operation reference cannot authorize downloading captured credentials.
const { putFileBlob, referenceBlobs } = await import('../../src/lib/server/db/file-blobs.js'); const { putFileBlob, referenceBlobs } =
await import('../../src/lib/server/db/file-blobs.js');
const hash = putFileBlob(secret)!; const hash = putFileBlob(secret)!;
referenceBlobs('operation', stage.id, [hash]); referenceBlobs('operation', stage.id, [hash]);
db() db()
@@ -266,4 +298,6 @@ it('blocks old host commands and redacts late private read receipts from older a
params: { sha256: hash } params: { sha256: hash }
}) })
).toThrow(); ).toThrow();
}); });
}
);
+9 -2
View File
@@ -45,12 +45,15 @@ describe('administrator pages', () => {
expect(body).not.toContain('You do not have access'); expect(body).not.toContain('You do not have access');
}); });
it.each(['viewer', 'operator'] as const)('keeps user accounts unavailable to %s', (role) => { it.each(['viewer', 'operator', 'moderator'] as const)(
'keeps user accounts unavailable to %s',
(role) => {
asRole(role); asRole(role);
const body = render(UsersPage).body; const body = render(UsersPage).body;
expect(body).toContain('You do not have access'); expect(body).toContain('You do not have access');
expect(body).not.toContain('Account activity'); expect(body).not.toContain('Account activity');
}); }
);
it('retains administrator user creation and describes the assignable Super Viewer role', () => { it('retains administrator user creation and describes the assignable Super Viewer role', () => {
asRole('admin'); asRole('admin');
@@ -58,6 +61,10 @@ describe('administrator pages', () => {
expect(body).toContain('Add user'); expect(body).toContain('Add user');
expect(body).toContain('Super Viewer'); expect(body).toContain('Super Viewer');
expect(body).toContain('Read all fleet and administrator pages without making changes.'); expect(body).toContain('Read all fleet and administrator pages without making changes.');
expect(body).toContain('Moderator');
expect(body).toContain(
'Moderate players with server or game restrictions lasting up to 7 days.'
);
}); });
it('keeps key searches available while hiding key creation from Super Viewers', () => { it('keeps key searches available while hiding key creation from Super Viewers', () => {
+74
View File
@@ -0,0 +1,74 @@
import { afterEach, expect, it, vi } from 'vitest';
import { render } from 'svelte/server';
import { writable } from 'svelte/store';
import ChatPanel from '../../src/lib/ui/components/ChatPanel.svelte';
import PlayerScoreboard from '../../src/lib/ui/components/PlayerScoreboard.svelte';
import { moderationHref } from '../../src/lib/ui/moderation.js';
import type { UserRole } from '../../src/lib/auth.js';
const currentPage = writable({
url: new URL('http://localhost/servers/t6_zm_transit_1'),
data: { user: { role: 'moderator' as UserRole } }
});
vi.mock('$app/stores', () => ({
page: {
subscribe: (run: Parameters<typeof currentPage.subscribe>[0]) => currentPage.subscribe(run)
}
}));
afterEach(() =>
currentPage.update((value) => ({ ...value, data: { user: { role: 'moderator' } } }))
);
it('keeps ambiguous engine chat IDs in search and preserves selected evidence for verified lookup', () => {
const body = render(ChatPanel, {
props: {
game: 't6',
history: [
{
type: 'message',
matchId: 12,
message: {
guid: 'abcdef',
player: 'Tank',
message: 'Review & investigate',
slot: 2,
time: '0:30'
},
context: { realTime: null, round: null, matchElapsedMs: null }
}
]
}
}).body;
const target = new URL(
body.match(/href="([^"]*\/moderation[^"]*)"/)![1].replaceAll('&amp;', '&'),
'http://localhost'
);
expect(target.searchParams.get('q')).toBe('abcdef');
expect(target.searchParams.has('guid')).toBe(false);
expect(target.searchParams.get('game')).toBe('t6');
expect(target.searchParams.get('matchId')).toBe('12');
expect(target.searchParams.get('message')).toBe('Review & investigate');
});
it('links scoreboard actions to the exact telemetry GUID and hides moderation from ordinary viewers', () => {
const props = {
report: {
server: 't6_zm_transit_1',
map: 'zm_transit',
event: 'game_start' as const,
receivedAt: '2026-10-15T12:00:00Z',
players: [{ guid: '18446744073709551615', name: 'Tank' }]
}
};
const body = render(PlayerScoreboard, { props }).body;
expect(body).toContain('guid=18446744073709551615');
expect(body).toContain('server=t6_zm_transit_1');
currentPage.update((value) => ({ ...value, data: { user: { role: 'viewer' } } }));
expect(render(PlayerScoreboard, { props }).body).not.toContain('/moderation?');
});
it('does not invent a game namespace for an unscoped profile', () => {
const target = new URL(moderationHref({ guid: '42', game: 'unknown' }), 'http://localhost');
expect(target.searchParams.get('guid')).toBe('42');
expect(target.searchParams.has('game')).toBe(false);
});