Compare commits
2
Commits
c1758cce25
...
8b372fa2c1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b372fa2c1 | ||
|
|
c91d49c298 |
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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)]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|||||||
@@ -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
@@ -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';
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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.';
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
|||||||
@@ -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'
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -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.'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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'
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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: {} } },
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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()
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
})();
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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) => ({
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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'],
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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))
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
@@ -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
@@ -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():
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
-----BEGIN PGP SIGNATURE-----
|
|
||||||
|
|
||||||
iJEEABYKADkWIQSpocb6VBfQzvdIh9HF6fdSNniN+gUCas+vvhsUgAAAAAAEAA5t
|
|
||||||
YW51MiwyLjUrMS4xMiwwLDMACgkQxen3UjZ4jfqXzAD8D4zQMptxYUl1befVmlc4
|
|
||||||
pNq9N9P2o1eIx1jWDIQtmK0A/RNG50PZ97hjVkzvM565YKM9lQXiytj22vgpiGG8
|
|
||||||
B/QH
|
|
||||||
=NY2I
|
|
||||||
-----END PGP SIGNATURE-----
|
|
||||||
@@ -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' }
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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[]) {
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -3,9 +3,11 @@ 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';
|
||||||
|
|
||||||
|
describe.each(['electric_cherry_bank.cfg', 'electric_cherry_moderation.cfg'])(
|
||||||
|
'private config %s',
|
||||||
|
(filename) => {
|
||||||
let fixture: Fixture;
|
let fixture: Fixture;
|
||||||
const servers = ['t5_zombie_theater_1', 't6_zombie_town_1'];
|
const servers = ['t5_zombie_theater_1', 't6_zombie_town_1'];
|
||||||
const filename = 'electric_cherry_bank.cfg';
|
|
||||||
const secret = Buffer.from('set tws_bank_token "ec_bank_private_regression_secret"\n');
|
const secret = Buffer.from('set tws_bank_token "ec_bank_private_regression_secret"\n');
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
@@ -25,10 +27,12 @@ afterEach(async () => {
|
|||||||
|
|
||||||
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) => {
|
||||||
@@ -37,7 +41,8 @@ describe.each(servers)('private bank config on %s', (server) => {
|
|||||||
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,7 +175,9 @@ 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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -186,7 +205,8 @@ it('rejects the reserved upload name and redacts legacy receipts before packing
|
|||||||
|
|
||||||
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')"
|
||||||
@@ -213,11 +233,16 @@ it('blocks old host commands and redacts late private read receipts from older a
|
|||||||
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()
|
||||||
@@ -267,3 +299,5 @@ it('blocks old host commands and redacts late private read receipts from older a
|
|||||||
})
|
})
|
||||||
).toThrow();
|
).toThrow();
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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('&', '&'),
|
||||||
|
'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);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user