Compare commits

..
22 Commits
Author SHA1 Message Date
josh a7f1c65d17 Separate moderation case status from decisions
CI / checks (push) Failing after 7m24s
2026-10-17 21:30:43 -04:00
josh 48653af3dc Index moderation case references for database integrity checks
CI / checks (push) Successful in 7m31s
2026-10-17 20:14:48 -04:00
josh 870f13b150 Capture team chat and expose moderation match archives 2026-10-17 20:11:49 -04:00
josh b678de2f6f Add case follow-up queues and personal update notifications 2026-10-17 20:00:13 -04:00
josh 90b94911c5 Add related cases and duplicate review suggestions 2026-10-17 19:52:50 -04:00
josh 428aefdf24 Add structured moderation assessments with revision history 2026-10-17 19:47:28 -04:00
josh aca728b557 Link prior moderation actions to case decisions 2026-10-17 19:42:05 -04:00
josh ef96a2c308 Preserve conversation excerpts and deduplicate case evidence 2026-10-17 19:35:52 -04:00
josh 83f1c91877 Sign agent 1.12.0 release (Refs #151)
CI / checks (push) Successful in 6m54s
2026-10-17 19:15:08 -04:00
josh 59f88ac33d Add AFK account and session overview (Refs #151)
CI / checks (push) Successful in 7m19s
2026-10-17 17:52:03 -04:00
josh d723f23d67 Add persistent AFK reserves and recovery (Refs #151)
CI / checks (push) Successful in 7m7s
2026-10-17 16:57:26 -04:00
josh 7f2205e9c4 Expand moderation case workflows
CI / checks (push) Successful in 6m49s
2026-10-17 16:19:33 -04:00
josh 4501eba873 Prevent cross-address login lockouts (Refs #63)
CI / checks (push) Successful in 6m30s
2026-10-17 10:55:28 -04:00
josh f3b9699317 Restrict plugin lookups and reuse cached chat reads (Refs #98) 2026-10-17 10:31:13 -04:00
josh b9bc897d6e Fix separator spacing in attention and provisioning summaries (Refs #112) 2026-10-17 10:20:37 -04:00
josh 2e6eb0bdb0 Use friendly UI labels and release feedback (Refs #120)
CI / checks (push) Successful in 6m39s
2026-10-17 09:54:01 -04:00
josh d93a766578 Fix count and status wording (Refs #121) 2026-10-17 09:48:42 -04:00
josh ba9bed6a0d Fix release version labels across deployment views 2026-10-17 09:45:53 -04:00
josh b5037a70fe Fix mobile overflow and touch targets
CI / checks (push) Successful in 6m41s
2026-10-16 17:48:06 -04:00
josh 4e04191330 Sign agent 1.11.0 release
CI / checks (push) Successful in 6m53s
2026-10-16 15:02:18 -04:00
josh 8b372fa2c1 Add player moderation workflows and game integration
CI / checks (push) Successful in 7m27s
2026-10-15 21:33:50 -04:00
josh c91d49c298 Add moderation permissions and durable player records 2026-10-15 21:06:08 -04:00
189 changed files with 22023 additions and 604 deletions
+9
View File
@@ -1,5 +1,14 @@
# Controller URL: use HTTPS with a trusted certificate for enrolled hosts.
EC_PUBLIC_URL=https://electric-cherry.example.com
# Client addresses for login throttling behind a trusted reverse proxy.
# Leave ADDRESS_HEADER empty for direct connections. Enable only when all access
# goes through a proxy that replaces untrusted forwarding headers.
# For one trusted proxy using X-Forwarded-For:
# ADDRESS_HEADER=x-forwarded-for
# XFF_DEPTH=1
# Set XFF_DEPTH to the number of trusted proxy hops; see the access guide.
ADDRESS_HEADER=
XFF_DEPTH=1
# Generate separate secrets with: openssl rand -hex 32
# Setup token authorizes creation of the first administrator only. Remove it
# after setup. Never reuse the telemetry token as a setup or host credential.
+2 -1
View File
@@ -13,9 +13,10 @@ _Fleet overview with sample data._
- Launch individual servers or saved collections of maps and lobbies.
- 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.
- 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
+30
View File
@@ -1,5 +1,35 @@
# Agent changelog
## 2026-10-17
### 1.12.0 — Recover AFK reservations before teardown (EC #151)
- Advertise `afk.settlement` and drain durable AFK decisions only after confirming
the game process has stopped. Removal, rebuild and creation cancellation retain
runtime storage until the controller acknowledges every recovery decision.
- Authenticate recovery with the assigned host and unfinished teardown operation;
never follow a journal URL or use a retired game credential. Keep uncertain
crash durations distinct from confirmed consumption.
- Deploy before enabling the matching AFK scripts/plugins. Package and offline-sign
the release before publication; no deployment or signing is performed here.
## 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
### 1.10.1 — Retain verified rebuild artifacts (EC #154)
+240 -20
View File
@@ -35,7 +35,7 @@ import urllib.parse
import urllib.request
import uuid
VERSION = "1.10.1"
VERSION = "1.12.0"
LABEL = "io.electric-cherry"
MAX_FILE = 32 * 1024 * 1024
MANIFEST_MAX_ENTRIES = 10000
@@ -49,6 +49,9 @@ PLUTONIUM_MANIFEST_LIMIT = 2 * 1024 * 1024
PLUTONIUM_FILE_LIMIT = 512 * 1024 * 1024
PLUTONIUM_CACHE_LIMIT = 4 * 1024 * 1024 * 1024
BANK_GAME_ENDPOINT = "https://ec.thewrightserver.net/api/bank"
AFK_GAME_ENDPOINT = "https://ec.thewrightserver.net/api/afk"
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}$")
AGENT_CONFIG = Path("/etc/electric-cherry/agent.json")
AGENT_EXECUTABLE = Path("/usr/local/libexec/electric-cherry-agent.py")
@@ -85,6 +88,7 @@ class AutoscalingInvalidated(Rejected):
OPERATION_ERROR_PHASES = {
"executing": "executing the operation",
"configuring-moderation": "configuring private game moderation",
"checking-instance": "checking the existing instance",
"validating-create-spec": "validating the container specification",
"preparing-create-directory": "preparing the instance directory",
@@ -401,13 +405,13 @@ def managed_open(root, value):
os.close(fd)
def private_bank_config(value):
return isinstance(value, str) and PurePosixPath(value).as_posix() == "configs/electric_cherry_bank.cfg"
def private_instance_config(value):
return isinstance(value, str) and PurePosixPath(value).as_posix() in ("configs/electric_cherry_bank.cfg", MODERATION_CONFIG_PATH)
def require_public_file(value):
if private_bank_config(value):
raise Rejected("Private bank configuration cannot be read or changed by file operations")
if private_instance_config(value):
raise Rejected("Private bank configuration and moderation configuration cannot be read or changed by file operations")
def managed_read(root, value):
@@ -457,7 +461,7 @@ def managed_manifest(root, payload, stopping):
def validate_paths():
for value in paths:
if private_bank_config(value):
if private_instance_config(value):
continue
check_budget()
try:
@@ -493,7 +497,7 @@ def managed_manifest(root, payload, stopping):
entries += 1
if entries > MANIFEST_MAX_ENTRIES:
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
info = os.stat(child.name, dir_fd=directory, follow_symlinks=False)
if stat_module.S_ISLNK(info.st_mode):
@@ -1050,6 +1054,86 @@ class LibraryTransfer:
self.persist(force=True)
def afk_recovery_requests(journal):
"""Mirror the native journal's deterministic recovery after a proven stop.
Active crashes never invent an elapsed duration. Their checkpoint is only
a lower bound; EC retains the hold for an audited correction. Requests that
may already have reached EC preserve their original identities and bodies.
"""
def identifier(value):
return isinstance(value, str) and re.fullmatch(r"[A-Za-z0-9_.:-]{1,64}", value)
def seconds(value):
return type(value) is int and 0 <= value <= 604800
def decode(raw):
if not isinstance(raw, str) or not 0 < len(raw.encode()) < 0x5000:
raise ValueError()
value = json.loads(raw)
if (not isinstance(value, dict) or value.get("server") != journal["server"]
or not all(identifier(value.get(key)) for key in ("guid", "match", "request_id"))
or value.get("action") not in ("event", "purchase", "reserve", "start", "settle")):
raise ValueError()
if value["action"] in ("reserve", "start", "settle") and not identifier(value.get("session_id")):
raise ValueError()
if value["action"] == "reserve" and (not seconds(value.get("seconds")) or value["seconds"] == 0):
raise ValueError()
return value
try:
if (not isinstance(journal, dict) or type(journal.get("version")) is not int
or journal["version"] != 1 or not isinstance(journal.get("server"), str)
or not 0 < len(journal["server"]) <= 128
or journal.get("endpoint") != AFK_GAME_ENDPOINT
or not isinstance(journal.get("requests"), list)
or not isinstance(journal.get("sessions"), list)
or len(journal["requests"]) + len(journal["sessions"]) > 4096):
raise ValueError()
requests = {}
for raw in journal["requests"]:
value = decode(raw)
if value["request_id"] in requests:
raise ValueError()
requests[value["request_id"]] = value
sessions = set()
for session in journal["sessions"]:
data = decode(session["body"])
session_id = data.get("session_id")
phase = session["phase"]
authorized, consumed = session["authorized_seconds"], session["consumed_seconds"]
if (data["action"] != "reserve" or session_id in sessions
or phase not in ("prepared", "authorized", "active")
or not seconds(authorized) or not seconds(consumed)
or consumed > authorized or authorized > data["seconds"]
or (phase == "prepared" and authorized != 0)
or (phase != "prepared" and authorized == 0)
or (phase != "active" and consumed != 0)):
raise ValueError()
sessions.add(session_id)
requests = {key: value for key, value in requests.items()
if not (value["action"] in ("reserve", "start") and value["session_id"] == session_id)}
hashed = 14695981039346656037
scope = journal["server"] + "\0" + journal["endpoint"] + "\0" + session_id
for byte in scope.encode():
hashed = ((hashed ^ byte) * 1099511628211) & 0xffffffffffffffff
transfer = {"server": journal["server"], "guid": data["guid"], "match": data["match"],
"session_id": session_id, "request_id": f"afk-stop-{hashed:016x}",
"action": "settle", "end_reason": "crash" if phase == "active" else "never_started",
"consumed_seconds": None if phase == "active" else 0}
if phase == "active":
transfer["confirmed_seconds"] = consumed
previous = requests.get(transfer["request_id"])
if previous is not None and previous != transfer:
raise ValueError()
requests[transfer["request_id"]] = transfer
if any(value["action"] in ("reserve", "start") for value in requests.values()):
raise ValueError()
return list(requests.values())
except (ValueError, TypeError, KeyError, AttributeError):
raise AgentError("AFK journal is invalid; runtime storage retained") from None
class Agent:
def __init__(self, config, docker=None):
self.config = config
@@ -1571,7 +1655,7 @@ class Agent:
if line.startswith("PRETTY_NAME="):
os_name = line.split("=", 1)[1].strip('"')
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", "afk.settlement"] + (["host.decommission"] if self.can_uninstall() else []) + (["agent.update"] if update["enabled"] else [])
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})}
@@ -1964,6 +2048,8 @@ class Agent:
def create(self, instance_id, generation, payload, *, before=None, prepared_files=None):
self.operation_phase = "checking-instance"
if payload.get("moderation") is not None:
self.moderation_config(payload["moderation"], generation)
self.check_creation_cancellation()
directory = self.instance_dir(instance_id, generation)
current_marker = directory.parent / "current.json"
@@ -2025,18 +2111,92 @@ class Agent:
for item in files:
require_public_file(item["path"])
bank = payload.get("bank")
if bank is 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"]):
raise Rejected("Invalid instance bank credential")
path = bank.get("configPath")
scoped(self.root / "agent" / "validation", path)
if PurePosixPath(path).name != "electric_cherry_bank.cfg":
raise Rejected("Invalid private bank config path")
# The game scripts use the public bank API; the host controller address
# is only for agent transport and must not become a game URL override.
content = f'set tws_bank_token "{bank["token"]}"\n'.encode()
return files + [{"path": path, "contentBase64": base64.b64encode(content).decode()}]
if bank is not None:
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")
path = bank.get("configPath")
scoped(self.root / "agent" / "validation", path)
if PurePosixPath(path).name != "electric_cherry_bank.cfg":
raise Rejected("Invalid private bank config path")
# Game transport uses its public URL, never the agent's enrollment URL.
content = f'set tws_bank_token "{bank["token"]}"\n'.encode()
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):
for path, content in prepared_files.items():
@@ -2158,6 +2318,52 @@ class Agent:
raise AgentError("Bank settlement was not acknowledged; runtime storage retained")
except (ValueError, TypeError, KeyError, AttributeError):
raise AgentError("Bank journal is invalid; runtime storage retained") from None
self.settle_afk(instance_id, generation, operation_id)
def settle_afk(self, instance_id, generation, operation_id=None):
"""Retain AFK evidence until stopped-process reconciliation is acknowledged."""
app = scoped(self.instance_dir(instance_id, generation), "app")
if not app.exists():
return
if self.inspect(instance_id, generation)["running"]:
raise AgentError("AFK settlement requires a stopped instance")
operation_id = operation_id or self.executing_id
visited, journals = 0, []
deadline = time.monotonic() + 30
def unreadable(_error):
raise AgentError("AFK journal discovery failed; runtime storage retained")
for directory, directories, files in os.walk(app, followlinks=False, onerror=unreadable):
relative = Path(directory).relative_to(app)
inside = "tws-afk" in relative.parts
visited += 1
if visited > 100000 or time.monotonic() > deadline:
raise AgentError("AFK journal discovery limit exceeded; runtime storage retained")
for name in directories + files:
if (inside or name == "tws-afk") and (Path(directory) / name).is_symlink():
raise AgentError("AFK journal paths must not be symlinks")
if inside:
journals.extend((relative / name).as_posix() for name in files if name.endswith(".json"))
if len(journals) > 4096:
raise AgentError("Too many AFK journals; runtime storage retained")
for path in sorted(journals):
try:
raw = managed_read(app, path)
if raw is None or len(raw) > 16 * 1024 * 1024:
raise ValueError()
transfers = afk_recovery_requests(json.loads(raw))
except (Rejected, ValueError, UnicodeError):
raise AgentError("AFK journal cannot be read; runtime storage retained") from None
if transfers and not operation_id:
raise AgentError("AFK settlement requires a teardown operation")
for transfer in transfers:
response = self.request("POST", "/api/agent/afk/settle", {
"operationId": operation_id, "action": transfer["action"], "transfer": transfer})
expected = ("settled", "unresolved", "corrected") if transfer["action"] == "settle" else ("rewarded", "purchased", "rejected")
if (not isinstance(response, dict) or response.get("request_id") != transfer["request_id"]
or response.get("state") not in expected):
raise AgentError("AFK recovery was not acknowledged; runtime storage retained")
def lifecycle(self, kind, instance_id, generation, payload):
unhealthy_recovery = payload.get("unhealthyRecovery") is True
@@ -2169,6 +2375,8 @@ class Agent:
return self.create(instance_id, generation, payload)
if kind == "instance.inspect":
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)
name = self.container_name(instance_id, generation)
before = []
@@ -2198,6 +2406,8 @@ class Agent:
if isinstance(next_generation, bool) or not isinstance(next_generation, int) or next_generation <= 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")}
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
# mutate the immutable spec or cache bytes on the shared agent:
# held, failed and later operations must prepare their own files.
@@ -3001,6 +3211,16 @@ class Agent:
return self.journal.update(operation["id"], "failed", {"autoscalingInvalidated": True}, error="Interrupted autoscaling removal requires a new decision")
if kind == "instance.cancel-create":
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":
marker = self.instance_dir(instance_id, generation).parent / "current.json"
if marker.exists():
+239 -2
View File
@@ -1650,10 +1650,12 @@ class AgentTests(unittest.TestCase):
self.assertEqual(self.agent.execute(self.operation("instance.remove"))["state"], "failed")
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()
app = self.agent.instance_dir(INSTANCE, 1) / "app"
value = "configs/electric_cherry_bank.cfg"
target = app / value
secret = b'set tws_bank_token "ec_bank_private_regression"\n'
target.write_bytes(secret)
@@ -1707,6 +1709,241 @@ class AgentTests(unittest.TestCase):
with self.assertRaises(AgentError):
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 afk_journal(self, game="t5", phase="active"):
path = self.agent.instance_dir(INSTANCE, 1) / f"app/runtime/tws-afk/{game}/scope.json"
reserve = {"action": "reserve", "server": "t5_zombie_test_1", "guid": "ace",
"match": "match-one", "request_id": "reserve-one", "session_id": "session-one", "seconds": 1800}
module.atomic_json(path, {"version": 1, "server": reserve["server"], "endpoint": module.AFK_GAME_ENDPOINT,
"requests": [json.dumps(reserve)], "sessions": [{"body": json.dumps(reserve), "phase": phase,
"authorized_seconds": 0 if phase == "prepared" else 1800,
"consumed_seconds": 42 if phase == "active" else 0}]})
return path
def afk_acknowledgment(self, method, path, payload):
self.assertEqual((method, path), ("POST", "/api/agent/afk/settle"))
self.assertFalse(self.agent.inspect(INSTANCE, 1)["running"])
transfer = payload["transfer"]
self.assertEqual(transfer["action"], "settle")
return {"state": "unresolved" if transfer["consumed_seconds"] is None else "settled",
"request_id": transfer["request_id"]}
def test_afk_remove_recovers_both_engines_without_fabricating_crash_duration(self):
self.create()
paths = [self.afk_journal(game) for game in ("t5", "t6")]
def acknowledge(*args):
transfer = args[2]["transfer"]
self.assertTrue(all(path.exists() for path in paths))
self.assertEqual(transfer["end_reason"], "crash")
self.assertIsNone(transfer["consumed_seconds"])
self.assertEqual(transfer["confirmed_seconds"], 42)
self.assertRegex(transfer["request_id"], r"^afk-stop-[0-9a-f]{16}$")
return self.afk_acknowledgment(*args)
with patch.object(self.agent, "request", side_effect=acknowledge) as request:
result = self.agent.execute(self.operation("instance.remove", {"safetyOverride": True}, "remove"))
self.assertEqual(result["state"], "completed", result)
self.assertEqual(request.call_count, 2)
self.assertTrue(all(not path.exists() for path in paths))
def test_afk_recovery_retains_storage_and_reuses_decisions_after_lost_reply(self):
self.create()
path = self.afk_journal(phase="authorized")
operation = self.operation("instance.remove", {"safetyOverride": True}, "remove")
original = path.read_bytes()
observed = []
def lost(method, endpoint, payload):
observed.append(payload["transfer"])
raise AgentError("Lost reply")
with patch.object(self.agent, "request", side_effect=lost):
self.assertEqual(self.agent.execute(operation)["state"], "unknown")
self.assertEqual(path.read_bytes(), original)
self.agent.journal.close()
self.agent = Agent(self.config, self.docker)
def acknowledge(method, endpoint, payload):
self.assertEqual(payload["transfer"], observed[0])
self.assertEqual(payload["transfer"]["consumed_seconds"], 0)
self.assertEqual(payload["transfer"]["end_reason"], "never_started")
return self.afk_acknowledgment(method, endpoint, payload)
with patch.object(self.agent, "request", side_effect=acknowledge):
self.assertEqual(self.agent.execute(operation)["state"], "completed")
self.assertFalse(path.exists())
def test_afk_remove_accepts_an_already_corrected_session(self):
self.create()
path = self.afk_journal()
def acknowledge(method, endpoint, payload):
response = self.afk_acknowledgment(method, endpoint, payload)
return {**response, "state": "corrected"}
with patch.object(self.agent, "request", side_effect=acknowledge):
result = self.agent.execute(self.operation("instance.remove", {"safetyOverride": True}, "remove"))
self.assertEqual(result["state"], "completed", result)
self.assertFalse(path.exists())
def test_afk_recovery_rejects_running_process_corruption_and_untrusted_paths(self):
self.create()
path = self.afk_journal()
with self.assertRaisesRegex(AgentError, "stopped instance"):
self.agent.settle_afk(INSTANCE, 1, "remove")
operation = self.operation("instance.remove", {"safetyOverride": True}, "remove")
original = json.loads(path.read_text())
for invalid in ("{broken", json.dumps({**original, "endpoint": "https://untrusted.example"}),
json.dumps({**original, "sessions": []}),
json.dumps({**original, "requests": original["requests"] * 2})):
path.write_text(invalid)
with patch.object(self.agent, "request") as request:
self.assertEqual(self.agent.execute(operation)["state"], "unknown")
request.assert_not_called()
self.assertTrue(path.exists())
outside = Path(self.temp.name) / "afk-outside.json"
outside.write_text(json.dumps(original))
path.unlink()
path.symlink_to(outside)
with patch.object(self.agent, "request") as request:
self.assertEqual(self.agent.execute(operation)["state"], "unknown")
request.assert_not_called()
self.assertTrue(outside.exists())
def test_afk_recovery_preserves_purchase_and_event_receipt_identity(self):
self.create()
path = self.afk_journal(phase="prepared")
value = json.loads(path.read_text())
for action in ("event", "purchase"):
value["requests"].append(json.dumps({"action": action, "server": value["server"], "guid": "ace",
"match": "match-one", "request_id": action + "-one", "confirmed": True}))
module.atomic_json(path, value)
expected = module.afk_recovery_requests(value)
def acknowledge(method, endpoint, payload):
self.assertEqual(endpoint, "/api/agent/afk/settle")
self.assertIn(payload["transfer"], expected)
return {"request_id": payload["transfer"]["request_id"],
"state": "settled" if payload["action"] == "settle" else "rejected"}
with patch.object(self.agent, "request", side_effect=acknowledge) as request:
result = self.agent.execute(self.operation("instance.remove", {"safetyOverride": True}, "remove"))
self.assertEqual(result["state"], "completed", result)
self.assertEqual(request.call_count, 3)
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"
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)]
+3
View File
@@ -17,6 +17,9 @@ services:
EC_TELEMETRY_TOKEN: ${EC_TELEMETRY_TOKEN:-}
EC_BANK_MAX_BALANCE: ${EC_BANK_MAX_BALANCE:-250000}
EC_AUTH_ALLOW_HTTP: ${EC_AUTH_ALLOW_HTTP:-false}
# Enable only behind a trusted proxy that sets the client-address header.
ADDRESS_HEADER: ${ADDRESS_HEADER:-}
XFF_DEPTH: ${XFF_DEPTH:-1}
# Legacy host imports stream whole game files; buffering routes enforce their own caps.
BODY_SIZE_LIMIT: 'Infinity'
DB_PATH: /data/cherry.db
+2
View File
@@ -26,6 +26,8 @@ and launch your first server.
- [Plugin releases](guides/plugin-releases.md) — GitHub release discovery and imports.
- [Metrics](guides/metrics.md) — utilization, capacity pressure, and hosting experiments.
- [Player bank](guides/bank.md) — shared balances, per-server credentials, transfers and recovery.
- [AFK reserve](guides/afk.md) — persistent break time, gameplay rewards, purchases and session 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.
## Development
+144
View File
@@ -0,0 +1,144 @@
# Persistent AFK time
AFK time is a GUID account shared by T5 and T6, separate from the points bank. The scripts own `.afk`, `.back`, explicit purchase confirmation, actual gameplay contribution detection, protection and controls, the center-screen countdown, and all-player-AFK / match-ending behavior. Electric Cherry owns policy, durable receipts, atomic points purchases, reservations, and reporting. The immutable native instance's existing bank credential authenticates both APIs; the shared telemetry token and browser sessions cannot authorize game requests.
## Policy and accounting
All balances are integer seconds. Defaults:
| Setting | Environment variable | Default |
| ---------------------------------------------- | ---------------------------------------------------------------- | ----------- |
| Free rolling allowance | `EC_AFK_FREE_SECONDS` | 1800 |
| Combined earned/purchased capacity | `EC_AFK_CAPACITY_SECONDS` | 21600 |
| Active play interval / reward | `EC_AFK_ACTIVE_INTERVAL_SECONDS`, `EC_AFK_ACTIVE_REWARD_SECONDS` | 300 / 60 |
| Participating round reward | `EC_AFK_ROUND_REWARD_SECONDS` | 15 |
| Kill interval / reward | `EC_AFK_KILL_INTERVAL`, `EC_AFK_KILL_REWARD_SECONDS` | 100 / 30 |
| Headshot interval / reward | `EC_AFK_HEADSHOT_INTERVAL`, `EC_AFK_HEADSHOT_REWARD_SECONDS` | 25 / 15 |
| Successful teammate revive | `EC_AFK_REVIVE_REWARD_SECONDS` | 30 |
| Performance reward cap per rolling active hour | `EC_AFK_BONUS_HOURLY_SECONDS` | 480 |
| Points / seconds per purchase pack | `EC_AFK_PURCHASE_POINTS`, `EC_AFK_PURCHASE_SECONDS` | 10000 / 600 |
Free time is spent first. Its available amount is the allowance minus outstanding free reservations and confirmed free consumption charged within the preceding 24 hours. Consumption is charged at the controller's receipt of a durable stop, so free time replenishes 24 hours after that receipt. This deliberately conservative rolling window cannot grant more than the allowance in a rolling day, even with delayed delivery or inaccurate server clocks. Each charge expires independently; unused days never accumulate. Reads, reconnects, map changes, and changing game/server do not reset anything. An unclosed reservation never expires, even after 24 hours. Profile `freeRenewsAt` is the next known charge expiration, not a promise to release unresolved authorization.
Active seconds and partial kill/headshot thresholds persist by GUID across matches and servers. Cumulative snapshots contribute only their increase over the last accepted snapshot for that originating immutable instance, match key, and GUID. Active play rewards use every completed interval. Participating rounds, kill thresholds, and headshot thresholds share one rolling 3600-_active-second_ budget: each performance grant occupies budget until another 3600 verified active seconds elapses. Kill and headshot rewards can stack. Spectating, AFK, idle connections, wall-clock waiting, and raw score increases do not advance this clock. Each legitimate successful teammate revive earns the full configured revive reward, including repeated revives of the same teammate; there is no revive count limit or performance cap. Only overall saved capacity applies.
Capacity includes saved seconds currently reserved by sessions. Overflow rewards and performance bonuses above the rolling budget are discarded, while partial threshold progress persists; capacity does not bank deferred whole rewards. The scripts report only actual gameplay events, eligible active seconds, and rounds with participation. The backend requires `activity:"playing"`, rejects current reward submissions while a session is reserved or started (an unresolved, stopped crash may resume gameplay), enforces monotonic counters, and rejects cumulative active time above the match's wall duration plus a 60-second telemetry tolerance. This scoped trusted-server API cannot independently observe a game engine's successful revive or verify its eligibility. The native/GSC collection paths are part of the trust boundary.
## Machine protocol
Use `POST /api/afk/{balance,event,purchase,reserve,start,settle}` with `Content-Type: application/json` and `Authorization: Bearer <instance bank credential>`. The body limit is 4096 bytes; responses have `Cache-Control: no-store`. Purchases, reservations, start acknowledgments, and balance reads require the exact current active telemetry match and a present GUID on the authenticated server. Gameplay backlog can additionally reference a retained historical match containing that GUID when the immutable instance lifetime proves origin. Historical active time is bounded to the recorded match end or last received telemetry, and its grants still respect held saved capacity. Gameplay whose historical membership evidence has been removed is rejected without granting time. Settlement remains valid for the original instance after disconnect, match completion, or match/server history deletion.
All requests contain `server`, `guid`, and `match` (the existing telemetry `reporter_match_key`, not a separate AFK match identifier). Mutations additionally require `request_id`. GUID, match, request and session IDs are 1–64 characters from `[A-Za-z0-9_.:-]`. Optional `event_time` is a parseable timestamp no more than five minutes in the future; it records the reported event time, while `created_at` records controller receipt time. Keep the complete body immutable across retries, including `event_time`.
| Action | Additional request fields |
| ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `balance` | None |
| `event` | `seq` positive integer; `active_seconds`, `kills`, `headshots`, `rounds`, `revives` nonnegative cumulative integer counters (maximum 2147483647); `activity:"playing"` |
| `purchase` | `packs` integer 1–1000; `confirmed:true`; `expected_purchase_points` and `expected_purchase_seconds`, copied from the displayed quote, per pack |
| `reserve` | `session_id`; `seconds` integer 1–604800, the requested maximum authorization |
| `start` | `session_id` |
| `settle` | `session_id`; `consumed_seconds` integer 0–604800 or `null`; `end_reason`; optional `confirmed_seconds` checkpoint |
`end_reason` is one of `manual`, `disconnect`, `death`, `match_end`, `expired`, `never_started`, or `crash`. `expired` requires exact consumption of the authorization. `never_started` requires zero. An unknown crash duration uses `consumed_seconds:null,end_reason:"crash",confirmed_seconds:<last durable elapsed lower bound>`. Confirmed consumption must fit the original authorization and cannot fall below an already-recorded checkpoint.
Every application result has:
```json
{
"ok": true,
"state": "reserved",
"code": "ok",
"request_id": "request-123",
"free_seconds": 0,
"saved_seconds": 600,
"available_seconds": 600,
"reserved_seconds": 1800,
"capacity_seconds": 21600,
"authorized_seconds": 1800,
"bank_balance": 20000,
"purchase_points": 10000,
"purchase_seconds": 600,
"max_purchase_packs": 2,
"session_id": "session-123",
"consumed_seconds": null
}
```
`available_seconds` excludes held authorizations; `reserved_seconds` is held time for this GUID across all servers. `authorized_seconds` is meaningful for session results and is the original authorization, never an extension. `session_id` and `consumed_seconds` appear on session results; `awarded_seconds` appears on reward and purchase results. `max_purchase_packs` is the smaller of affordable packs and capacity, or zero when a bank transfer is prepared. Recheck the quote through `balance` before confirmation; changed pricing rejects with `price_changed` without spending points. There is no automatic purchase.
States are `balance`, `rewarded`, `purchased`, `reserved`, `started`, `settled`, `unresolved`, `corrected`, and `rejected`. HTTP 200 can contain an application rejection (`ok:false`), such as `empty`, `session_pending`, `bank_pending`, `insufficient_balance`, `reserve_capacity`, `price_changed`, `invalid_active_time`, `invalid_consumption`, `progress_regression`, or `progress_conflict`. Authentication failures are 401/403; invalid syntax/amounts are 400. `not_in_match` is a nonreceipted HTTP 409 for unknown/current membership publication lag on new work, allowing the same immutable request to retry after telemetry catches up. Known completed/obsolete matches and recorded players who have departed (`present=0`) are terminal HTTP 200 rejections for new work. Only never-observed membership remains eligible for the publication-lag retry. Gameplay missing historical membership/origin proof is also a terminal HTTP 200 rejection so an unrecoverable old snapshot cannot block the durable queue; subsequent cumulative snapshots can still include its counters if membership is established. Changed request identity or ownership is HTTP 409 `request_conflict`/`session_conflict`. Transaction failures are 503 and can retry unchanged. Early authentication/body failures may contain only `ok` and `code`.
Purchase debits the existing bank account and grants saved time in the same immediate SQLite transaction, with linked point/time ledger entries. A prepared bank transfer blocks purchase until its own existing commit/cancel workflow completes. No second points bank is created.
## Ordering, authorization, and outages
1. Native code durably writes the immutable request before sending it. Use one stable request ID per operation, with separate IDs for reserve, start, and settle. Stable session IDs bind all session operations.
2. `reserve` atomically takes free time first and saved time second, authorizing `min(requested,available)` seconds. Only one outstanding session can hold a GUID's funds. An identical retry returns the saved result. A new operation referring to an already-known session cannot reset or extend it.
3. Durable native state records the reservation and the start intent. `start` must return the `started` acknowledgment before protection is enabled. Scripts additionally recheck current player/map eligibility and activate only once. A delayed acknowledgment cannot reactivate an ended local session. The countdown uses the locally recorded authorization and elapsed clock; it does not require heartbeat renewal.
4. Manual return, disconnect, death, match end, or zero countdown removes protection and restores controls before producing a durable stop. Native code records the confirmed elapsed seconds and immutable `settle` request, then retries delivery until acknowledged. Round elapsed time up to whole seconds and cap it to authorization. At zero the scripts immediately restore normal vulnerability and controls; there is no grace, automatic buy, or spectator transition. Manual `.back` retains its separate gameplay behavior.
5. `settle` records confirmed consumption and releases unused reserved seconds exactly once. It accepts legitimate delayed reports after membership disappears. `never_started` settlement can arrive before its reserve and installs a tombstone; delayed reserve/start cannot reopen that session. Completed/corrected sessions remain terminal. A retry of an earlier operation returns that operation's original receipt, which may predate settlement; native terminal journal state must win over such an old response.
6. If the controller is unavailable, an already-started session keeps its locally authorized countdown to zero. If reserve/start was never acknowledged, do not enable protection. A crashed process must first be proven stopped before recovery. A checkpoint is only a lower bound on elapsed time; an absent heartbeat or old checkpoint is never evidence for a refund. Recover unknown elapsed time as `unresolved`, retaining authorization until reliable stop evidence or an explicit administrator correction resolves it.
Receipts bind the complete body and action to server, immutable instance, and request ID. Reusing an ID with any changed parameter is a conflict. Accepted same/lower-sequence gameplay counters award no additional time; same sequence with changed counters conflicts, a later sequence with a decreased total is rejected, and a lower sequence cannot introduce a higher counter. Native/GSC code must preserve cumulative per-GUID totals across reconnects within the same match. A distinct revive increments `revives` even for the same teammate; retrying a snapshot never increments it.
For host teardown, `POST /api/agent/afk/settle` uses the assigned host credential and `{operationId,action,transfer}`. Only an unfinished `instance.remove`, `instance.rebuild`, or `instance.cancel-create` for that host/immutable instance is accepted. `action:"settle"` reconciles session evidence; `event` and `purchase` are receipt-only recovery and return `not_applied` if the original request never committed. Teardown cannot create a new purchase or reward. Instance retirement revokes normal game API credentials, including reads; the assigned host recovery path permits settlement after retirement without granting a replacement instance ownership of old sessions.
## Crash correction and reporting
An administrator can reconcile an explicitly stopped `unresolved` session with `POST /api/afk/corrections` using their browser session and same-origin protections:
```json
{
"session_id": "session-123",
"request_id": "admin-case-456",
"consumed_seconds": 120,
"reason": "Confirmed process stopped; reviewed native checkpoint and host logs.",
"stopped": true
}
```
The selected charge must be at least the confirmed checkpoint and no more than authorization. Correction cannot release a live reserved/started session, and does not convert an uncertain duration into an exact one: `state` becomes `corrected`, `consumedSeconds` remains null, `chargedSeconds` records the accounting decision, and `confirmedSeconds` retains the evidence lower bound. The audit ledger records the actor, reason, charge, and release. The correction ID is idempotent and rejects changed parameters. A true confirmed stop report can instead settle an unresolved session with its verified exact duration.
Open **Players → AFK** (`/afk`) to browse all AFK accounts, sessions, and time-ledger entries. The accounts view supports player-name and exact-GUID searches and links each account to its player profile. Session history includes unresolved reservations; session and ledger history can be filtered by game, server, and date. Filters and pagination remain in the URL when opening a player or retained match, so the back link restores the same overview. Refresh reads current balances without issuing grants, releasing reservations, or otherwise changing accounts. This reporting page has no purchase or correction controls.
The AFK overview and player profiles use the same signed-in viewer access as Bank. Player profiles also retain balances and recent time-ledger entries alongside bank information. Reports distinguish cumulative verified active seconds, confirmed AFK duration, unresolved sessions, expiration count, manual returns, purchases, and corrected charges. Session starts, end reasons, operation/session IDs, server, match when retained, reported event time, receipt time, resulting balances, free grants, rewards, point-linked purchases, reservations, consumption, releases, and corrections survive match retention and server deletion.
## Coordinated rollout and validation
Back up the controller database and persistent game journals. Deploy EC first;
migration 30 creates the AFK accounts, operations, progress, sessions, bonus budget
and ledger tables without match/server cascading deletion. Install host agent
**1.12.0+**, including its `afk.settlement` capability, before activating the game
scripts. Package and offline-sign the agent release using the normal host update
workflow; a local build does not sign or deploy it.
Install TWS AFK scripts **2.0.0** with matching native plugins **1.4.0+** for both
engines, common **1.4.0+**, telemetry **1.0.0+**, and Plutonium **r5334+**. Fully
restart each game-server process for the plugin update. Keep the existing private
bank credential configuration and persistent `tws-afk/t5/` / `tws-afk/t6/` storage.
See the [scripts AFK guide](https://gitea.thewrightserver.net/josh/tws-zombies-scripts/src/branch/main/docs/afk.md)
for commands, active-play criteria, protection ownership, game dvars and the
in-game validation matrix.
Run `npx vitest run tests/unit/afk.test.ts tests/unit/bank.test.ts tests/unit/ui-afk-profile.test.ts`,
the normal EC checks/build, and `python3 -m unittest discover -s agent/tests -v`.
The scripts repository supplies `node tools/check_afk.mjs` and
`python3 tools/test_plugin_afk.py` with the pinned native JSON dependency headers.
Portable native tests use sanitizers and engine adapters; script tests use engine
shims. Neither proves Windows DLL linking or live game behavior.
Before production rollout, exercise both T5 and T6 with a short test allowance:
manual return, immediate zero-time expiry, downed/dead/spectator states, `.hud`
hidden, repeated revives, partial reward thresholds across reconnects, explicit
purchase confirmation and capacity rejection. Compare the center countdown to
the authorized seconds, ensure controls/weapons and normal vulnerability return
at zero, and confirm active-play metrics exclude AFK time (manual `.back` restores reward eligibility immediately, including during its separate protection grace). Test last-active
death/disconnect and stock afterlife/Grief recovery on deployed maps.
Drop reserve/start/purchase/settle responses, reorder deliveries, disconnect EC
after authorization and restart game/controller/host processes. Confirm the local
deadline survives the outage, durable stops release unused time once, and ambiguous
crashes remain unconfirmed until an audited correction. Rebuild/remove an instance
with both games' journals and a retired game credential; host recovery must retain
files until acknowledged and must never create a new purchase or gameplay reward.
Live T5/T6 gameplay and deployment validation require the actual game servers.
+48 -8
View File
@@ -31,6 +31,13 @@ guessable token. An unset setup token holds setup closed.
## 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 |
| --------------------------------------------------------------------------------------------- | -------------- | ------------ | ----------- | ------------- |
| Fleet, metrics, matches and operation summaries | Yes | Yes | Yes | Yes |
@@ -106,9 +113,22 @@ revocation every 15 seconds and then close.
Login and password changes for enabled Administrators and Super Viewers are exempt
from per-account and per-address rate limits, including existing exhausted windows.
Their attempts do not consume either limit. Other accounts and initial setup use
persistent 15-minute windows. Exemptions use the account's current database role;
password verification is still required. The database stores audit entries for login
Their attempts do not consume either limit. Other accounts use persistent
15-minute login windows of 10 attempts per username-and-address pair and 30 per
address across accounts. Attempts from one address do not exhaust another
address's allowance for the same username. Initial setup and password changes
retain their limits of 10 per account and 30 per address. Exemptions use the
account's current database role; password verification is still required.
A successful password login also sets a separate, HTTP-only login-device cookie
for 30 days. This cookie lets that browser skip its username-and-address limit,
while the password and shared address limit still apply. It does not sign you in,
and is independent of **Keep me signed in**. It survives ordinary sign-out;
password changes/resets, role changes, disabling an account, and revoking all of
its sessions invalidate its login-device cookies. Only token hashes are stored in
SQLite. Reusing a valid cookie does not extend its original expiry.
The database stores audit entries for login
results, account changes and successful management requests without recording
passwords, tokens or request bodies. Administrators and Super Viewers can review
recent entries in Users.
@@ -131,8 +151,10 @@ DB_PATH=./data/cherry.db node scripts/reset-admin.mjs admin
Replace `admin` with the existing administrator's username. The command prompts
without echoing the password, resets that enabled administrator only, revokes
their sessions and records the recovery. It does not expose a remote recovery
endpoint or accept passwords as command arguments.
their sessions and trusted login devices, clears their account-specific login and
password-change rate limits, and records the recovery. Other users' limits and
shared address limits remain intact. It does not expose a remote recovery endpoint
or accept passwords as command arguments.
## Agent and telemetry connections
@@ -174,9 +196,27 @@ JSON requests and multipart uploads. Do not configure a proxy to replace arbitra
incoming origins with your trusted origin. Machine endpoints use their own token
checks and do not require a browser Origin.
Use the adapter's real client-address configuration only with a trusted proxy
that strips spoofed forwarding headers; login throttling otherwise uses the direct
peer address. Forwarded host or user headers do not authenticate users.
Login throttling uses the address returned by SvelteKit's adapter. Without
`ADDRESS_HEADER`, this is the direct peer address, so users behind a reverse proxy
share the proxy's address limit. Compose passes `ADDRESS_HEADER` and `XFF_DEPTH`
from `.env` to the controller; direct Node deployments must set them in the
server's environment. For one trusted proxy that sets `X-Forwarded-For`, use:
```dotenv
ADDRESS_HEADER=x-forwarded-for
XFF_DEPTH=1
```
Recreate the Compose controller after changing these values with
`docker compose up -d`. For a proxy chain, set `XFF_DEPTH` to the number of trusted
proxies: the adapter selects that many positions from the right of the header.
For example, `client, proxy1` with two trusted proxies requires `XFF_DEPTH=2`.
The public-facing proxy must replace untrusted forwarding headers, and every
trusted hop must supply the expected address chain. Restrict direct access to
port 3000 so requests cannot bypass those proxies. Do not enable these settings
for an untrusted proxy or copy a depth that does not match your deployment.
See the [adapter's client-address settings](https://svelte.dev/docs/kit/adapter-node#Environment-variables-ADDRESS_HEADER-and-XFF_DEPTH).
Forwarded host or user headers do not authenticate users.
Local HTTP browser development requires `EC_AUTH_ALLOW_HTTP=true` and a loopback
hostname (`localhost`, `127.0.0.1` or `::1`). This uses a separate development
+5
View File
@@ -56,6 +56,11 @@ including server-rendered pages, page data, and API responses. The build already
precompresses static JavaScript and CSS assets; dynamically generated responses
need compression at the proxy.
Configure `ADDRESS_HEADER` and `XFF_DEPTH` for your trusted proxy so login limits
use each client's address. Leave `ADDRESS_HEADER` empty for direct connections;
see the [proxy configuration guide](authentication.md#proxy-and-local-development)
before enabling forwarded addresses.
Open the dashboard and follow `/setup` to create the first administrator. Then
remove `EC_SETUP_TOKEN` from `.env` and run `docker compose up -d` to recreate the
controller. Setup stays closed once an account exists.
+255
View File
@@ -0,0 +1,255 @@
# 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. Each case has a dedicated,
shareable page with the player identity, evidence, staff notes, linked restrictions,
and an activity timeline. These pages require moderation access. Give a case a
priority and assign it to an eligible administrator or moderator. Filter the case
queue by status, priority, or assignment; active cases appear first, ordered by
priority and most recent update.
Use **Change status** to move a case between **Open**, **In progress**, and
**Closed**, recording a reason for each transition. Assignment is separate:
a case can have an owner at any stage. To resume a closed case, change its status
to Open or In progress. Evidence, assessments, actions, and earlier decisions
remain in its history. Editing case details does not change its status.
Existing closed outcomes are migrated to Closed. Previously escalated cases
become In progress. Their previous statuses are recorded in history; existing
decision reasons, action references, assignments, and follow-ups are preserved.
Use **Edit assessment** to keep a concise current summary, list the applicable
rules, and record a finding for each concern: hate speech, harassment, threats,
safeguarding, spam, cheating, or another concern. Each finding is **Substantiated**,
**Inconclusive**, or **Unsubstantiated**, with a short rationale. Findings describe
the evidence; case status tracks whether work remains. Record the decision and
its rationale in the assessment summary.
Every saved assessment retains its author, timestamp, and complete previous
revision. Assessments can be revised after closure without reopening the case.
Before opening a case, review existing cases for the same game and GUID. A case
also shows same-player candidates; a shared archived match is a useful potential
duplicate signal, not a finding that two reports are identical. Link cases as
**Same incident**, **Appeal of**, or **Duplicate of**, recording why they belong
together. Same-incident links may include different players; appeals and duplicates
must concern the same game and player. Links appear from both cases with their
direction made explicit. Self-links and circular appeal/duplicate chains are
rejected. Removing a mistaken relationship requires a reason and preserves its
history. Relationship changes do not close or reopen either case.
Schedule a follow-up using a date and time, or **Review when a linked mute expires**.
Custom date entry uses your browser's local time; displayed dates follow your time
zone preference. Reviews remain scheduled after a case closes or reopens. Use
**Overdue reviews** to find due work across open and closed cases, and clear the
follow-up with a reason when the review is complete.
In-app case updates notify the assignee and case creator when another staff member
adds evidence or a staff note. Assignment changes notify the previous and new assignees
and the creator, excluding the person making the change. **Your unread updates**
is personal to your account. Open a case to review and mark existing updates
read; notifications arriving afterward stay unread. Queue and case pages refresh
every 15 seconds while visible. These are in-app notifications, with no email or
external messaging delivery.
Apply a ban or mute directly from an active case. The case fixes the target game
and GUID and records the resulting restriction alongside the evidence. The same
role, scope, and duration limits apply as elsewhere in Moderation. Applying a
restriction leaves the case active so staff can finish reviewing it. Changing case status
records workflow progress; it does not send a warning, create a restriction, or
revoke one. Linked restrictions can be revoked separately with
the required permission and reason, including after a case closes. Reopening a
case also leaves its restrictions in place. Check enforcement coverage after
applying or revoking a restriction.
Use **Link existing action** to associate a warning, kick, private message, ban, or
mute already recorded for this player with the case. This preserves the original
action, author, time, delivery state, and expiry; it does not issue another action
or extend a restriction. The same action can support more than one related case.
Linking requires the current case revision and a reason, and appears in its timeline.
Linked records show what actually happened, including failed or uncertain delivery.
They remain visible when a case closes; status changes do not require choosing
or re-entering an action.
Staff notes are explicitly labeled as notes. In match chat, select up to 100
messages and choose **Attach to case** to open a case or add them to an existing
case for the selected subject. Select at least one message from that subject;
messages from other participants can be selected alongside it. Each attachment
preserves selected messages and, by default, three surrounding messages on either side, with
names, GUIDs, original timing, and highlighted subject messages. The selection
preview shows the excerpt before saving. Server verification uses stable archived
message identifiers; repeated text remains separate occurrences, and attaching
the same selected messages again does not duplicate evidence. Exact-text
attachments remain available and include every matching occurrence, without the
former ten-occurrence limit.
Snapshots remain readable after source-match cleanup. 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.
Open **Chat archive** from Moderation to include all recorded matches, including
round-one games, interrupted games, and games missing an end report. Archive
access requires moderation read permission. Filter by server, game, map, status,
or date and open a match to select its preserved messages. Incomplete matches
show their observed state rather than final results. Ordinary match history and
leaderboard qualification are unchanged.
Both public (`say`) and team (`sayteam`) log messages are captured. Team messages
carry a **Team chat** label in chat and saved excerpts. Collection runs without a live
viewer and checks surviving logs once after startup, including idle servers.
Archive coverage still depends on the source log and accepted match telemetry:
logs that were deleted or rotated before collection, or old clock segments that
cannot safely be associated with a match, cannot be reconstructed. The collector
retains a bounded log tail (up to 2,000 lines / 256 KiB); it is not a full historical
log import. Already saved case excerpts survive later source cleanup.
Case decisions, edits, and restrictions check the current revision; refresh before
retrying a decision if another staff member has changed the case. 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 reopening, 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.
+24 -2
View File
@@ -130,8 +130,11 @@ Agent 1.8.5 adds incremental game-log reads for chat collection. After the initi
tail, the controller requests only appended bytes, handles rotation and truncation,
and buffers incomplete lines. Unchanged logs need no content transfer or chat
parsing. Background collection skips offline hosts, stopped servers and known
empty servers, with one final read after players leave. Opening chat still reads
on demand. Older agents use tail reads until they update.
empty servers, with one final read after players leave. Opening chat reuses the
worker's saved reader state and only starts an on-demand read when the previous
read completed at least 15 seconds ago. This limit is shared across viewers and also
applies to missing logs and failed reads. Older agents use tail reads until they
update.
### New game files and cached versions
@@ -1098,3 +1101,22 @@ host credential; journal URLs never determine request destinations. Upgrade
agents first, then [rebuild servers changing bank endpoints](bank.md#upgrading-from-a-controller-derived-game-endpoint)
to drain pending decisions before the new scripts start. See the
[agent changelog](../../agent/CHANGELOG.md) for release and validation details.
### AFK journals during server teardown
Install agent **1.12.0+** before enabling [persistent AFK reserves](afk.md).
The `afk.settlement` capability adds stopped-process recovery for
`tws-afk/t5/` and `tws-afk/t6/` journals. The agent uses its enrolled controller
and assigned unfinished teardown operation at `/api/agent/afk/settle`.
Journal endpoints must match the fixed public AFK endpoint; they never control
the destination of an agent request.
Removal, rebuild and creation cancellation preserve runtime files until every
decision is acknowledged. Prepared sessions that never activated return their
reservation; confirmed stops retain their original elapsed seconds and IDs.
Active sessions interrupted by a crash become unresolved, retaining their
checkpoint and balance hold for administrator reconciliation. The agent does
not invent a duration from the time of its next heartbeat. Pending purchase or
reward requests only retrieve existing receipts during teardown; they cannot
create new spending after the game stopped. Corrupt, oversized, symlinked or
unacknowledged journals block runtime deletion.
+87 -31
View File
@@ -1,9 +1,13 @@
#!/usr/bin/env node
// Local recovery only: requires filesystem/container access to the controller.
import Database from 'better-sqlite3';
import { createHash } from 'node:crypto';
import { resolve } from 'node:path';
import { emitKeypressEvents } from 'node:readline';
import { fileURLToPath } from 'node:url';
import { hashPassword } from '../src/lib/server/auth/passwords.js';
/** @param {string} prompt @returns {Promise<string>} */
function secret(prompt) {
return new Promise((resolve, reject) => {
let value = '';
@@ -17,6 +21,7 @@ function secret(prompt) {
process.stdin.pause();
process.stdout.write('\n');
};
/** @param {string | undefined} text @param {import('node:readline').Key | undefined} key */
const listener = (text, key) => {
if (key?.ctrl && key.name === 'c') {
cleanup();
@@ -39,7 +44,67 @@ function secret(prompt) {
});
}
try {
/** @param {import('better-sqlite3').Database} database @param {string} username */
function administrator(database, username) {
const user = /** @type {{ id: string, username: string } | undefined} */ (
database
.prepare(
"SELECT id,username FROM auth_users WHERE username=? AND role='admin' AND disabled=0"
)
.get(username)
);
if (!user) throw new Error('An enabled administrator with that username was not found.');
return user;
}
/**
* Local recovery shared with regression tests; the CLI only accepts passwords on its TTY.
* @param {import('better-sqlite3').Database} database
* @param {string} username
* @param {string} password
*/
export async function recoverAdministrator(database, username, password) {
const user = administrator(database, username);
const passwordHash = await hashPassword(password);
const digest = (/** @type {string} */ value) =>
createHash('sha256').update(value).digest('hex');
database
.transaction(() => {
const changed = database
.prepare(
"UPDATE auth_users SET password_hash=?,must_change_password=0,updated_at=? WHERE id=? AND role='admin' AND disabled=0"
)
.run(passwordHash, Date.now(), user.id);
if (changed.changes !== 1)
throw new Error('Administrator changed during recovery. Try again.');
database.prepare('DELETE FROM auth_sessions WHERE user_id=?').run(user.id);
// Older databases may not have device credentials yet; recovery still works.
if (
database
.prepare(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='auth_login_devices'"
)
.get()
) {
database.prepare('DELETE FROM auth_login_devices WHERE user_id=?').run(user.id);
}
database
.prepare('DELETE FROM auth_rate_limits WHERE key GLOB ? OR key IN (?,?)')
.run(
`login:account:${digest(user.username.toLowerCase())}:ip:*`,
digest(`login:account:${user.username.toLowerCase()}`),
digest(`password:account:${user.id.toLowerCase()}`)
);
database
.prepare(
'INSERT INTO auth_audit(actor_id,action,target_id,details,created_at) VALUES(NULL,?,?,?,?)'
)
.run('password.recovered', user.id, '{"source":"local-cli"}', Date.now());
})
.immediate();
}
async function main() {
const username = process.argv[2]?.toLowerCase();
if (process.argv.length !== 3 || !username || !/^[a-z0-9][a-z0-9_.-]{2,63}$/.test(username))
throw new Error('Usage: node scripts/reset-admin.mjs <administrator-username>');
@@ -51,34 +116,25 @@ try {
fileMustExist: true
});
database.pragma('busy_timeout=5000');
const user = database
.prepare("SELECT id FROM auth_users WHERE username=? AND role='admin' AND disabled=0")
.get(username);
if (!user) throw new Error('An enabled administrator with that username was not found.');
const password = await secret('New administrator password (8–128 characters): ');
const confirmation = await secret('Repeat password: ');
if (password !== confirmation) throw new Error('Passwords did not match.');
const passwordHash = await hashPassword(password);
database
.transaction(() => {
const changed = database
.prepare(
"UPDATE auth_users SET password_hash=?,must_change_password=0,updated_at=? WHERE id=? AND role='admin' AND disabled=0"
)
.run(passwordHash, Date.now(), user.id);
if (changed.changes !== 1)
throw new Error('Administrator changed during recovery. Try again.');
database.prepare('DELETE FROM auth_sessions WHERE user_id=?').run(user.id);
database
.prepare(
'INSERT INTO auth_audit(actor_id,action,target_id,details,created_at) VALUES(NULL,?,?,?,?)'
)
.run('password.recovered', user.id, '{"source":"local-cli"}', Date.now());
})
.immediate();
database.close();
console.log('Administrator password reset. Existing sessions have been revoked.');
} catch (cause) {
console.error(cause instanceof Error ? cause.message : 'Administrator recovery failed.');
process.exitCode = 1;
try {
administrator(database, username);
const password = await secret('New administrator password (8–128 characters): ');
const confirmation = await secret('Repeat password: ');
if (password !== confirmation) throw new Error('Passwords did not match.');
await recoverAdministrator(database, username, password);
console.log(
'Administrator password reset. Sessions and trusted login devices revoked; account rate limits cleared.'
);
} finally {
database.close();
}
}
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
try {
await main();
} catch (cause) {
console.error(cause instanceof Error ? cause.message : 'Administrator recovery failed.');
process.exitCode = 1;
}
}
+143
View File
@@ -0,0 +1,143 @@
export const afkPageSize = 25;
export const afkLedgerKinds = [
'free_grant',
'purchase',
'reward',
'reserve',
'start',
'release',
'unresolved',
'consume',
'correction'
] as const;
export type AfkLedgerKind = (typeof afkLedgerKinds)[number];
export type AfkSessionState = 'reserved' | 'started' | 'settled' | 'unresolved' | 'corrected';
export interface AfkQuery {
view: 'accounts' | 'sessions' | 'ledger';
q: string;
balance: '' | 'saved' | 'reserved' | 'unresolved';
sort: 'saved' | 'available' | 'recent';
game: '' | 't5' | 't6';
server: string;
state: '' | AfkSessionState;
kind: '' | AfkLedgerKind;
from: string;
to: string;
offset: number;
player: string;
}
export function readAfkQuery(params: URLSearchParams): AfkQuery {
function choice<T extends string>(key: string, values: readonly T[], fallback: T): T {
const value = params.get(key) || fallback;
if (!values.includes(value as T)) throw new Error(`Invalid ${key} filter.`);
return value as T;
}
function text(key: string, max: number) {
const value = (params.get(key) ?? '').trim();
if (value.length > max) throw new Error(`${key} is too long.`);
return value;
}
function date(key: string) {
const value = text(key, 10);
if (
value &&
(!/^\d{4}-\d{2}-\d{2}$/.test(value) ||
!Number.isFinite(Date.parse(value)) ||
new Date(value).toISOString().slice(0, 10) !== value)
)
throw new Error('Use a valid date in YYYY-MM-DD format.');
return value;
}
const offset = Number(params.get('offset') || 0);
if (!Number.isSafeInteger(offset) || offset < 0) throw new Error('Invalid page offset.');
const from = date('from');
const to = date('to');
if (from && to && from > to) throw new Error('The end date must follow the start date.');
return {
view: choice('view', ['accounts', 'sessions', 'ledger'], 'accounts'),
q: text('q', 100),
balance: choice('balance', ['', 'saved', 'reserved', 'unresolved'], ''),
sort: choice('sort', ['saved', 'available', 'recent'], 'saved'),
game: choice('game', ['', 't5', 't6'], ''),
server: text('server', 256),
state: choice(
'state',
['', 'reserved', 'started', 'settled', 'unresolved', 'corrected'],
''
),
kind: choice('kind', ['', ...afkLedgerKinds], ''),
from,
to,
offset: Math.floor(offset / afkPageSize) * afkPageSize,
player: text('player', 128)
};
}
export interface AfkIdentity {
guid: string;
name: string | null;
}
export interface AfkAccount extends AfkIdentity {
freeSeconds: number;
savedSeconds: number;
availableSeconds: number;
reservedSeconds: number;
reservedSavedSeconds: number;
unresolvedSessions: number;
lastActivity: string | null;
freeRenewsAt: string | null;
}
export interface AfkSession extends AfkIdentity {
sessionId: string;
server: string;
game: string | null;
matchId: number | null;
matchAvailable: number;
state: AfkSessionState;
authorizedSeconds: number;
consumedSeconds: number | null;
chargedSeconds: number | null;
confirmedSeconds: number;
endReason: string | null;
createdAt: string;
startedAt: string | null;
endedAt: string | null;
}
export interface AfkLedgerEntry extends AfkIdentity {
id: number;
server: string;
game: string | null;
matchId: number | null;
matchAvailable: number;
kind: AfkLedgerKind;
freeDelta: number;
savedDelta: number;
freeSeconds: number;
savedSeconds: number;
operationId: string;
sessionId: string | null;
eventAt: string;
createdAt: string;
}
export interface AfkPage<T> {
entries: T[];
total: number;
offset: number;
}
export interface AfkOverview {
query: AfkQuery;
checkedAt: string;
capacitySeconds: number;
summary: {
accounts: number;
savedSeconds: number;
reservedSeconds: number;
unresolvedSessions: number;
};
servers: string[];
accounts: AfkPage<AfkAccount>;
sessions: AfkPage<AfkSession>;
ledger: AfkPage<AfkLedgerEntry>;
}
+13
View File
@@ -1,3 +1,5 @@
import type { LaunchFileSummary } from './deployment-plan.js';
/** Keep the upload number visible: two builds may share a publisher's release tag. */
export function artifactVersionLabel(
version: { version: number; release_version?: string | null } | null | undefined,
@@ -10,3 +12,14 @@ export function artifactVersionLabel(
? `Unversioned (upload ${version.version})`
: `v${version.version}`;
}
export function launchFileVersionLabel(
file: Pick<LaunchFileSummary, 'version' | 'releaseVersion' | 'kind'>
): string {
return file.version === null
? 'Generated'
: artifactVersionLabel(
{ version: file.version, release_version: file.releaseVersion },
file.kind
);
}
+12 -1
View File
@@ -1,4 +1,6 @@
export type UserRole = 'admin' | 'super_viewer' | 'operator' | 'viewer';
export type UserRole = 'admin' | 'super_viewer' | 'operator' | 'moderator' | 'viewer';
export const moderatorMaximumRestrictionMs = 7 * 24 * 60 * 60 * 1000;
/** Reserved identity for work requested by the controller. */
export const systemUsername = 'Maxis';
@@ -19,6 +21,7 @@ export const roleLabels: Record<UserRole, string> = {
admin: 'Administrator',
super_viewer: 'Super Viewer',
operator: 'Operator',
moderator: 'Moderator',
viewer: 'Viewer'
};
@@ -33,3 +36,11 @@ export function canReadAdmin(role: UserRole | null | undefined): boolean {
export function canReadSensitive(role: UserRole | null | undefined): boolean {
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';
}
+2
View File
@@ -6,6 +6,8 @@ export interface LaunchFileSummary {
path: string;
versionId: number | null;
version: number | null;
/** Missing on snapshots recorded before publisher release versions were retained. */
releaseVersion?: string | null;
sha256: string;
size: number;
}
+15 -7
View File
@@ -4,6 +4,8 @@ export interface ChatMessage {
slot: number;
player: string;
message: string;
/** Omitted for public chat to preserve existing archived message identities. */
channel?: 'team';
}
export type ChatEntry =
@@ -17,7 +19,12 @@ export interface ChatContext {
round: number | null;
}
export type ContextualChatEntry = ChatEntry & { context: ChatContext; matchId?: number };
export type ContextualChatEntry = ChatEntry & {
context: ChatContext;
matchId?: number;
/** Stable archive identity, independent of estimated wall-clock timestamps. */
messageId?: string;
};
export interface ChatMatch {
id?: number;
@@ -93,18 +100,19 @@ function indexedTimeline(lines: readonly string[]): { entry: ChatEntry; lineInde
previousSeconds = seconds;
}
const match = /^\s*(\d+:[0-5]\d)\s+say;([^;]+);(\d+);([^;]*);(.*)$/.exec(line);
const match = /^\s*(\d+:[0-5]\d)\s+(say|sayteam);([^;]+);(\d+);([^;]*);(.*)$/.exec(line);
if (!match) continue;
const message = plainText(match[5]);
const message = plainText(match[6]);
if (!message) continue;
push({
type: 'message',
message: {
time: match[1],
guid: match[2],
slot: Number(match[3]),
player: plainText(match[4]) || 'Unknown player',
message
guid: match[3],
slot: Number(match[4]),
player: plainText(match[5]) || 'Unknown player',
message,
...(match[2] === 'sayteam' ? { channel: 'team' as const } : {})
}
});
}
+8 -3
View File
@@ -10,6 +10,7 @@ export interface MatchHistoryQuery {
sort: MatchSort;
direction: 'asc' | 'desc';
page: number;
archive: boolean;
}
export const matchQueryKeys = [
'game',
@@ -19,7 +20,8 @@ export const matchQueryKeys = [
'status',
'sort',
'direction',
'page'
'page',
'archive'
] as const;
export function readMatchQuery(params: URLSearchParams): MatchHistoryQuery {
const game = params.get('game') ?? '';
@@ -30,6 +32,7 @@ export function readMatchQuery(params: URLSearchParams): MatchHistoryQuery {
const sort = params.get('sort') || 'started';
const direction = params.get('direction') || 'desc';
const page = Number(params.get('page') || 1);
const archive = params.get('archive') ?? '';
if (
!['', 't5', 't6'].includes(game) ||
map.length > 1024 ||
@@ -40,7 +43,8 @@ export function readMatchQuery(params: URLSearchParams): MatchHistoryQuery {
!['asc', 'desc'].includes(direction) ||
!Number.isSafeInteger(page) ||
page < 1 ||
page > 1000000
page > 1000000 ||
!['', '0', '1'].includes(archive)
)
throw new Error('Invalid match history filters.');
return {
@@ -51,7 +55,8 @@ export function readMatchQuery(params: URLSearchParams): MatchHistoryQuery {
status: status as MatchHistoryQuery['status'],
sort: sort as MatchSort,
direction: direction as 'asc' | 'desc',
page
page,
archive: archive === '1'
};
}
/** Namespaced filters also work inside an instance's Matches tab. */
-1
View File
@@ -47,7 +47,6 @@ export interface MatchPlayer {
}
export interface MatchDetail extends ServerMatch {
status: 'completed';
game: string;
startObserved: boolean;
players: MatchPlayer[];
+284
View File
@@ -0,0 +1,284 @@
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';
/** Legacy decisions retained for historical events and older API clients, never lifecycle states. */
export const moderationCaseOutcomes = [
'resolved',
'dismissed',
'warning_issued',
'restriction_applied',
'no_action',
'duplicate',
'appeal_upheld',
'appeal_denied'
] as const;
export const moderationCaseStatuses = ['open', 'in_review', 'closed'] as const;
export type ModerationCaseStatus = (typeof moderationCaseStatuses)[number];
export type ModerationCasePriority = 'low' | 'normal' | 'high' | 'urgent';
export const moderationCaseRelations = ['same_incident', 'appeal_of', 'duplicate_of'] as const;
export type ModerationCaseRelation = (typeof moderationCaseRelations)[number];
export const moderationCaseRelationLabels: Record<ModerationCaseRelation, string> = {
same_incident: 'Same incident',
appeal_of: 'Appeal of',
duplicate_of: 'Duplicate of'
};
export const moderationCaseStatusLabels: Record<
ModerationCaseStatus | (typeof moderationCaseOutcomes)[number] | 'escalated',
string
> = {
open: 'Open',
in_review: 'In progress',
closed: 'Closed',
escalated: 'Escalated',
resolved: 'Resolved',
dismissed: 'Dismissed',
warning_issued: 'Warning issued',
restriction_applied: 'Restriction applied',
no_action: 'No action needed',
duplicate: 'Duplicate',
appeal_upheld: 'Appeal upheld',
appeal_denied: 'Appeal denied'
};
export function isModerationCaseOpen(status: ModerationCaseStatus): boolean {
return status === 'open' || status === 'in_review';
}
export const moderationConcernCategories = [
'hate_speech',
'harassment',
'threats',
'safeguarding',
'spam',
'cheating',
'other'
] as const;
export type ModerationConcernCategory = (typeof moderationConcernCategories)[number];
export const moderationConcernLabels: Record<ModerationConcernCategory, string> = {
hate_speech: 'Hate speech',
harassment: 'Harassment',
threats: 'Threats',
safeguarding: 'Safeguarding',
spam: 'Spam',
cheating: 'Cheating',
other: 'Other'
};
export const moderationFindingOutcomes = [
'substantiated',
'inconclusive',
'unsubstantiated'
] as const;
export type ModerationFindingOutcome = (typeof moderationFindingOutcomes)[number];
export const moderationFindingLabels: Record<ModerationFindingOutcome, string> = {
substantiated: 'Substantiated',
inconclusive: 'Inconclusive',
unsubstantiated: 'Unsubstantiated'
};
export interface ModerationCaseAssessment {
revision: number;
summary: string;
rules: string[];
findings: {
category: ModerationConcernCategory;
finding: ModerationFindingOutcome;
detail: string;
}[];
actor: string;
createdAt: string;
}
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;
caseId?: string | null;
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: ModerationCaseStatus;
priority: ModerationCasePriority;
assigneeId: string | null;
assignee: string | null;
actor: string;
createdAt: string;
updatedAt: string;
revision: number;
assessment?: ModerationCaseAssessment | null;
reviewAt?: string | null;
reviewReason?: string;
reviewSanctionId?: string | null;
unreadUpdates?: number;
evidence: {
id: string;
text: string;
source: string;
snapshot?: string | null;
createdAt: string;
actor: string;
}[];
}
export interface ModerationCaseDetail {
case: ModerationCase;
player: ModerationPlayer;
sanctions: ModerationSanction[];
linkedActions?: ModerationCaseAction[];
availableActions?: ModerationCaseAction[];
assessment?: ModerationCaseAssessment | null;
assessmentHistory?: ModerationCaseAssessment[];
relatedCases?: ModerationCaseRelationship[];
candidateCases?: ModerationCaseCandidate[];
notifications?: ModerationCaseNotification[];
events: ModerationEvent[];
coverage: ModerationCoverage[];
assignees: { id: string; username: string }[];
checkedAt: string;
}
export type ModerationCaseReference = Pick<
ModerationCase,
'id' | 'title' | 'status' | 'game' | 'guid'
>;
export interface ModerationCaseRelationship {
id: string;
relation: ModerationCaseRelation;
direction: 'outgoing' | 'incoming';
case: ModerationCaseReference;
actor: string;
createdAt: string;
}
export interface ModerationCaseCandidate extends ModerationCaseReference {
sharedMatch: boolean;
}
export interface ModerationCaseNotification {
id: number;
caseId: string;
caseTitle: string;
kind: 'evidence' | 'assignment';
createdAt: string;
readAt: string | null;
}
export interface ModerationCaseAction {
id: string;
type: 'restriction' | 'action';
kind: string;
reason: string;
actor: string;
createdAt: string;
state: string;
expiresAt: string | null;
}
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;
caseStatus?: '' | 'active' | 'closed' | ModerationCaseStatus;
priority?: '' | ModerationCasePriority;
assignedTo?: string;
review?: '' | 'due' | 'scheduled' | 'updates';
}
export interface ModerationOverview {
query: ModerationQuery;
players: ModerationPlayer[];
sanctions: ModerationSanction[];
cases: ModerationCase[];
events: ModerationEvent[];
actions: ModerationAction[];
coverage: ModerationCoverage[];
total: number;
limit: number;
checkedAt: string;
notifications?: ModerationCaseNotification[];
unreadCount?: number;
}
/** 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;
}
+2 -1
View File
@@ -2,7 +2,7 @@ import { activityRedirectHref } from './activity-redirect.js';
const origin = 'https://electric-cherry.invalid';
const pagePath =
/^\/(?:$|activity|operations|autoscaling|account|bank|capacity|metrics|server-keys|settings(?:\/users)?|servers\/[^/]+|hosts(?:\/[^/]+)?|collections(?:\/launches\/[^/]+)?|(?:scripts|configs|plugins|betas|campaigns|maintenance|provisioning|matches)(?:\/[^/]+)?|game-files|leaderboard(?:\/players\/[^/]+(?:\/[^/]+)?)?)$/;
/^\/(?:$|activity|operations|autoscaling|account|bank|afk|capacity|metrics|server-keys|settings(?:\/users)?|servers\/[^/]+|hosts(?:\/[^/]+)?|collections(?:\/launches\/[^/]+)?|(?:scripts|configs|plugins|betas|campaigns|maintenance|provisioning|matches)(?:\/[^/]+)?|game-files|leaderboard(?:\/players\/[^/]+(?:\/[^/]+)?)?)$/;
/** Only return to application pages, never an external URL or a mutation/API endpoint. */
export function safeReturnHref(value: string | null | undefined, fallback: string): string {
@@ -63,6 +63,7 @@ export function returnLabel(href: string): string {
provisioning: 'Server queues',
leaderboard: 'Leaderboard',
bank: 'Bank',
afk: 'AFK',
matches: 'Match History',
metrics: 'Metrics'
};
+10 -2
View File
@@ -1,9 +1,10 @@
import { canReadAdmin, type UserRole } from './auth.js';
import { canReadAdmin, canReadModeration, type UserRole } from './auth.js';
export interface NavigationItem {
label: string;
href: string;
shortcut?: string;
moderation?: boolean;
matches?: (path: string) => boolean;
}
export interface NavigationGroup {
@@ -71,6 +72,8 @@ export const navigationGroups: NavigationGroup[] = [
{ label: 'Leaderboard', href: '/leaderboard', shortcut: 'l' },
{ label: 'Matches', href: '/matches' },
{ label: 'Bank', href: '/bank' },
{ label: 'AFK', href: '/afk' },
{ label: 'Moderation', href: '/moderation', moderation: true },
{ label: 'Usage', href: '/metrics', shortcut: 'm' }
]
},
@@ -92,7 +95,12 @@ export const navigationGroups: 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 {
+19 -4
View File
@@ -1,4 +1,4 @@
import type { UserRole } from '../../auth.js';
import { canModerate, canReadModeration, type UserRole } from '../../auth.js';
export type RoutePermission =
| 'public'
@@ -7,6 +7,8 @@ export type RoutePermission =
| 'viewer'
| 'operator-read'
| 'admin-read'
| 'moderation-read'
| 'moderation'
| 'operator'
| 'admin';
@@ -37,8 +39,11 @@ export function routePermission(routeId: string | null, method: string): RoutePe
return 'admin';
if (within(routeId, '/api/agent')) return 'machine';
if (routeId === '/api/bank/[action]' && method === 'POST') return 'machine';
if (routeId === '/api/afk/corrections') return 'admin';
if (['/api/bank/[action]', '/api/afk/[action]'].includes(routeId) && 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 === '/login' || routeId === '/setup') && reading) return 'public';
if ((routeId === '/api/auth/login' || routeId === '/api/auth/setup') && method === 'POST')
@@ -53,6 +58,9 @@ export function routePermission(routeId: string | null, method: string): RoutePe
if (routeId === '/api/settings/retention/preview' && method === 'POST') return 'admin-read';
if (within(routeId, '/api/auth')) return 'admin';
if (within(routeId, '/moderation') || within(routeId, '/api/moderation'))
return reading ? 'moderation-read' : 'moderation';
if (
within(routeId, '/api/users') ||
within(routeId, '/settings') ||
@@ -72,7 +80,12 @@ export function routePermission(routeId: string | null, method: string): RoutePe
return 'admin';
// These reads initiate fleet file scans or spend external API quota.
if (routeId === '/api/drift' || routeId === '/api/plugins/github/release') return 'operator';
if (
routeId === '/api/drift' ||
routeId === '/api/plugins/github/release' ||
routeId === '/api/plugins/updates'
)
return 'operator';
// Summaries remain visible to viewers; raw configuration, historical file
// bytes, and unfiltered diagnostics may contain runtime credentials.
@@ -94,11 +107,13 @@ export function routePermission(routeId: string | null, method: string): RoutePe
/** A browser role never substitutes for a machine credential. */
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 === 'public' || permission === 'authenticated' || permission === 'viewer')
return true;
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')
return role === 'super_viewer' || role === 'operator' || role === 'admin';
if (permission === 'admin-read') return role === 'super_viewer' || role === 'admin';
+7 -3
View File
@@ -167,9 +167,13 @@ export const authenticationHandle: Handle = async ({ event, resolve }) => {
? 'Administrator access is required.'
: permission === 'admin-read'
? 'Administrator or Super Viewer access is required.'
: permission === 'operator-read'
? 'Operator, Administrator or Super Viewer access is required.'
: 'Operator access is required for this action.';
: permission === 'moderation'
? 'Moderator or Administrator access is required.'
: permission === 'moderation-read'
? 'Moderator, Administrator or Super Viewer access is required.'
: permission === 'operator-read'
? 'Operator, Administrator or Super Viewer access is required.'
: 'Operator access is required for this action.';
if (api)
return json(
{ error: message },
+12 -1
View File
@@ -1,11 +1,14 @@
import { error, isHttpError, json, type Cookies } from '@sveltejs/kit';
import { AuthError, SESSION_COOKIE, type AuthResult } from './index.js';
import { AuthError, LOGIN_DEVICE_COOKIE, SESSION_COOKIE, type AuthResult } from './index.js';
import { PasswordServiceBusyError } from './passwords.js';
import type { AuthUser } from '../../auth.js';
export function sessionCookie(url: URL): string {
return url.protocol === 'https:' ? SESSION_COOKIE : 'ec_session_dev';
}
export function loginDeviceCookie(url: URL): string {
return url.protocol === 'https:' ? LOGIN_DEVICE_COOKIE : 'ec_login_device_dev';
}
export function requireUserTransport(url: URL): void {
if (url.protocol === 'https:') return;
if (
@@ -34,6 +37,14 @@ export function sessionResponse(result: AuthResult, cookies: Cookies, url: URL)
sameSite: 'lax',
expires: new Date(result.session.expiresAt)
});
if (result.loginDevice)
cookies.set(loginDeviceCookie(url), result.loginDevice.token, {
path: '/',
httpOnly: true,
secure: url.protocol === 'https:',
sameSite: 'lax',
expires: new Date(result.loginDevice.expiresAt)
});
return json({ user: result.user }, { headers: { 'Cache-Control': 'no-store' } });
}
export function requireUser(user: AuthUser | null): AuthUser {
+55 -13
View File
@@ -7,6 +7,8 @@ export const SESSION_COOKIE = '__Host-ec_session';
export const SESSION_LIFETIME_MS = 12 * 60 * 60_000;
export const SESSION_IDLE_MS = 30 * 60_000;
export const REMEMBERED_SESSION_LIFETIME_MS = 30 * 24 * 60 * 60_000;
export const LOGIN_DEVICE_COOKIE = '__Host-ec_login_device';
export const LOGIN_DEVICE_LIFETIME_MS = 30 * 24 * 60 * 60_000;
const digest = (value: string) => createHash('sha256').update(value).digest('hex');
function database() {
return db();
@@ -82,8 +84,8 @@ function displayName(value: unknown, fallback: string): string {
return name.trim();
}
function role(value: unknown): UserRole {
if (!['admin', 'super_viewer', 'operator', 'viewer'].includes(value as string))
throw new AuthError('Choose administrator, super viewer, operator or viewer.');
if (!['admin', 'super_viewer', 'operator', 'moderator', 'viewer'].includes(value as string))
throw new AuthError('Choose administrator, super viewer, operator, moderator or viewer.');
return value as UserRole;
}
export function recordAudit(
@@ -111,7 +113,8 @@ export function listAuthAudit() {
export function consumeAuthAttempt(
clientAddress: string,
account: string,
kind: 'login' | 'setup' | 'password' = 'login'
kind: 'login' | 'setup' | 'password' = 'login',
loginDevice?: string
): void {
// Resolve the current role from storage; setup never has an existing account exemption.
if (
@@ -127,16 +130,22 @@ export function consumeAuthAttempt(
const now = Date.now();
const windowMs = 15 * 60_000;
const ipKey = digest(`${kind}:ip:${clientAddress}`);
const accountKey = digest(`${kind}:account:${account.toLowerCase()}`);
// Keep the hashed account prefix identifiable for local account recovery.
const accountKey =
kind === 'login'
? `login:account:${digest(account.toLowerCase())}:ip:${digest(clientAddress)}`
: digest(`${kind}:account:${account.toLowerCase()}`);
// A prior successful login permits another password check even when this
// account/address pair is exhausted. The shared address limit still applies.
const knownDevice = kind === 'login' && readLoginDevice(account, loginDevice);
const limits: [string, number][] = [[ipKey, 30]];
if (!knownDevice) limits.push([accountKey, 10]);
database()
.transaction(() => {
database()
.prepare('DELETE FROM auth_rate_limits WHERE window_start < ?')
.run(now - 24 * 60 * 60_000);
for (const [key, limit] of [
[ipKey, 30],
[accountKey, 10]
] as const) {
for (const [key, limit] of limits) {
const previous = database()
.prepare('SELECT window_start,attempts FROM auth_rate_limits WHERE key=?')
.get(key) as { window_start: number; attempts: number } | undefined;
@@ -147,7 +156,7 @@ export function consumeAuthAttempt(
)
throw new AuthError('Too many attempts. Try again in 15 minutes.', 429);
}
for (const key of [ipKey, accountKey])
for (const [key] of limits)
database()
.prepare(
`INSERT INTO auth_rate_limits(key,window_start,attempts) VALUES(?,?,1)
@@ -189,6 +198,7 @@ function insertUser(input: {
export interface AuthResult {
user: AuthUser;
session: { token: string; expiresAt: number };
loginDevice?: { token: string; expiresAt: number };
}
export async function bootstrapAdmin(
input: Record<string, unknown>,
@@ -230,13 +240,14 @@ export async function bootstrapAdmin(
}
export async function authenticatePassword(
input: Record<string, unknown>,
clientAddress: string
clientAddress: string,
loginDevice?: string
): Promise<AuthResult> {
if (input.rememberMe !== undefined && typeof input.rememberMe !== 'boolean')
throw new AuthError('Keep me signed in must be true or false.');
const name =
typeof input.username === 'string' ? input.username.slice(0, 64).toLowerCase() : '';
consumeAuthAttempt(clientAddress, name);
consumeAuthAttempt(clientAddress, name, 'login', loginDevice);
const row = database().prepare('SELECT * FROM auth_users WHERE username=?').get(name) as
UserRow | undefined;
const valid = await verifyPassword(input.password, row?.password_hash ?? null);
@@ -258,7 +269,8 @@ export async function authenticatePassword(
recordAudit(current.id, 'login', current.id);
return {
user: getUser(current.id)!,
session: createSession(current.id, input.rememberMe === true)
session: createSession(current.id, input.rememberMe === true),
loginDevice: createLoginDevice(current, loginDevice)
};
})
.immediate();
@@ -266,6 +278,32 @@ export async function authenticatePassword(
return result;
}
function readLoginDevice(account: string, token: string | undefined) {
if (!token || !/^[A-Za-z0-9_-]{43}$/.test(token)) return null;
return database()
.prepare(
`SELECT d.expires_at FROM auth_login_devices d JOIN auth_users u ON u.id=d.user_id
WHERE d.token_hash=? AND d.expires_at>? AND u.username=? AND u.disabled=0`
)
.get(digest(token), Date.now(), account) as { expires_at: number } | undefined;
}
/** Proof of a previous password login only; this token never authenticates a session. */
function createLoginDevice(user: UserRow, previousToken?: string) {
const now = Date.now();
database().prepare('DELETE FROM auth_login_devices WHERE expires_at<=?').run(now);
const previous = readLoginDevice(user.username, previousToken);
if (previous && previousToken) return { token: previousToken, expiresAt: previous.expires_at };
const token = randomBytes(32).toString('base64url');
const expiresAt = now + LOGIN_DEVICE_LIFETIME_MS;
database()
.prepare(
'INSERT INTO auth_login_devices(token_hash,user_id,created_at,expires_at) VALUES(?,?,?,?)'
)
.run(digest(token), user.id, now, expiresAt);
return { token, expiresAt };
}
export function createSession(
userId: string,
remembered = false
@@ -327,6 +365,7 @@ export function revokeUserSessions(actorId: string, userId: string): void {
requireAdministrator(actorId);
if (!userRow(userId)) throw new AuthError('User not found.', 404);
database().prepare('DELETE FROM auth_sessions WHERE user_id=?').run(userId);
database().prepare('DELETE FROM auth_login_devices WHERE user_id=?').run(userId);
recordAudit(actorId, 'sessions.revoked', userId);
})();
}
@@ -427,8 +466,10 @@ export async function updateUser(
Date.now(),
id
);
if (passwordHash || disabled !== current.disabled || nextRole !== current.role)
if (passwordHash || disabled !== current.disabled || nextRole !== current.role) {
database().prepare('DELETE FROM auth_sessions WHERE user_id=?').run(id);
database().prepare('DELETE FROM auth_login_devices WHERE user_id=?').run(id);
}
recordAudit(actorId, 'user.updated', id, {
role: nextRole,
disabled: !!disabled,
@@ -469,6 +510,7 @@ export async function changePassword(
)
.run(passwordHash, Date.now(), userId);
database().prepare('DELETE FROM auth_sessions WHERE user_id=?').run(userId);
database().prepare('DELETE FROM auth_login_devices WHERE user_id=?').run(userId);
recordAudit(userId, 'password.changed', userId);
return {
user: getUser(userId)!,
+40
View File
@@ -33,6 +33,17 @@ export function migrateRememberedSessions(handle: Database.Database): void {
`);
}
export function migrateLoginDevices(handle: Database.Database): void {
handle.exec(`
CREATE TABLE IF NOT EXISTS auth_login_devices (
token_hash TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES auth_users(id) ON DELETE CASCADE,
created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS auth_login_devices_user ON auth_login_devices(user_id);
CREATE INDEX IF NOT EXISTS auth_login_devices_expires_at ON auth_login_devices(expires_at);
`);
}
/** Run with foreign keys disabled by the migration runner before its transaction. */
export function migrateSuperViewerRole(handle: Database.Database): void {
const schema = handle
@@ -61,3 +72,32 @@ export function migrateSuperViewerRole(handle: Database.Database): void {
ALTER TABLE auth_users_super_viewer RENAME TO auth_users;
`);
}
/** Run with foreign keys disabled by the migration runner before its transaction. */
export function migrateModeratorRole(handle: Database.Database): void {
const schema = handle
.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='auth_users'")
.get() as { sql: string };
if (schema.sql.includes("'moderator'")) return;
if (handle.pragma('foreign_keys', { simple: true }))
throw new Error('The auth role migration requires foreign keys to be disabled');
// Rebuild without renaming the parent: existing session references must stay
// attached to auth_users, including remembered sessions added after v17.
handle.exec(`
CREATE TABLE auth_users_moderator (
id TEXT PRIMARY KEY, username TEXT NOT NULL COLLATE NOCASE UNIQUE,
display_name TEXT NOT NULL, password_hash TEXT NOT NULL,
role TEXT NOT NULL CHECK(role IN ('admin','super_viewer','operator','moderator','viewer')),
disabled INTEGER NOT NULL DEFAULT 0, must_change_password INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, last_login_at INTEGER
);
INSERT INTO auth_users_moderator (
id,username,display_name,password_hash,role,disabled,must_change_password,
created_at,updated_at,last_login_at
) SELECT id,username,display_name,password_hash,role,disabled,must_change_password,
created_at,updated_at,last_login_at FROM auth_users;
DROP TABLE auth_users;
ALTER TABLE auth_users_moderator RENAME TO auth_users;
`);
}
+1
View File
@@ -16,6 +16,7 @@ function num(name: string, fallback: number): number {
}
export const config = {
publicUrl: env.EC_PUBLIC_URL ?? '',
serversRoot: req('SERVERS_ROOT', '/root/zombies/t5'),
t6ServersRoot:
env.T6_SERVERS_ROOT ?? join(dirname(req('SERVERS_ROOT', '/root/zombies/t5')), 't6'),
+49
View File
@@ -0,0 +1,49 @@
import type Database from 'better-sqlite3';
/** Accounts, receipts and audit evidence deliberately outlive matches and servers. */
export function migrateAfk(handle: Database.Database): void {
handle.exec(`
CREATE TABLE IF NOT EXISTS afk_accounts (
guid TEXT PRIMARY KEY, free_seconds INTEGER NOT NULL CHECK(free_seconds >= 0),
saved_seconds INTEGER NOT NULL DEFAULT 0 CHECK(saved_seconds >= 0),
free_started_at INTEGER NOT NULL, active_seconds INTEGER NOT NULL DEFAULT 0,
kill_progress INTEGER NOT NULL DEFAULT 0, headshot_progress INTEGER NOT NULL DEFAULT 0,
bonus_hour INTEGER NOT NULL DEFAULT 0, bonus_seconds INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS afk_operations (
server TEXT NOT NULL, request_id TEXT NOT NULL, instance_id TEXT NOT NULL,
guid TEXT NOT NULL, fingerprint TEXT NOT NULL, result TEXT NOT NULL,
created_at TEXT NOT NULL, PRIMARY KEY(server,request_id)
);
CREATE TABLE IF NOT EXISTS afk_progress (
instance_id TEXT NOT NULL, match_key TEXT NOT NULL, guid TEXT NOT NULL,
seq INTEGER NOT NULL, active_seconds INTEGER NOT NULL, kills INTEGER NOT NULL,
headshots INTEGER NOT NULL, rounds INTEGER NOT NULL, revives INTEGER NOT NULL,
PRIMARY KEY(instance_id,match_key,guid)
);
CREATE TABLE IF NOT EXISTS afk_bonus_rewards (
guid TEXT NOT NULL, active_second INTEGER NOT NULL, seconds INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS afk_bonus_rewards_guid ON afk_bonus_rewards(guid,active_second);
CREATE TABLE IF NOT EXISTS afk_sessions (
session_id TEXT PRIMARY KEY, server TEXT NOT NULL, instance_id TEXT NOT NULL,
guid TEXT NOT NULL, match_key TEXT NOT NULL, match_id INTEGER,
state TEXT NOT NULL CHECK(state IN ('reserved','started','settled','unresolved','corrected')),
authorized_seconds INTEGER NOT NULL, free_seconds INTEGER NOT NULL,
saved_seconds INTEGER NOT NULL, free_started_at INTEGER NOT NULL,
consumed_seconds INTEGER, charged_seconds INTEGER, free_charge_at INTEGER, confirmed_seconds INTEGER NOT NULL DEFAULT 0,
end_reason TEXT, created_at TEXT NOT NULL, started_at TEXT, ended_at TEXT
);
CREATE UNIQUE INDEX IF NOT EXISTS afk_pending_guid ON afk_sessions(guid)
WHERE state IN ('reserved','started','unresolved');
CREATE TABLE IF NOT EXISTS afk_ledger (
id INTEGER PRIMARY KEY AUTOINCREMENT, guid TEXT NOT NULL, server TEXT NOT NULL,
match_id INTEGER, operation_id TEXT NOT NULL, session_id TEXT,
kind TEXT NOT NULL, free_delta INTEGER NOT NULL, saved_delta INTEGER NOT NULL,
free_seconds INTEGER NOT NULL, saved_seconds INTEGER NOT NULL,
event_at TEXT NOT NULL, created_at TEXT NOT NULL, details TEXT NOT NULL DEFAULT '{}'
);
CREATE INDEX IF NOT EXISTS afk_ledger_guid ON afk_ledger(guid,id DESC);
CREATE INDEX IF NOT EXISTS afk_sessions_guid ON afk_sessions(guid,created_at DESC);
`);
}
+77 -1
View File
@@ -16,15 +16,28 @@ import { enableIncrementalVacuum, retentionClocks } from './retention-clocks.js'
import { migrateHostSchema } from '../hosts/schema.js';
import {
migrateAuthSchema,
migrateLoginDevices,
migrateRememberedSessions,
migrateSuperViewerRole
migrateSuperViewerRole,
migrateModeratorRole
} from '../auth/schema.js';
import { migrateGameFileSchema } from '../game-files/schema.js';
import { migrateLeaderboardReports } from './leaderboard-reports.js';
import { migratePlayerScoreTotal } from './player-score-total.js';
import { migrateBank } from './bank.js';
import { migrateAfk } from './afk.js';
import { migrateBankCredentials } from './bank-credentials.js';
import { migrateTemperatures } from '../hosts/temperatures.js';
import {
migrateModeration,
migrateModerationCases,
migrateModerationCaseActions,
migrateModerationCaseAssessments,
migrateModerationCaseRelationships,
migrateModerationCaseFollowups,
migrateModerationCaseReferenceIndexes,
migrateModerationCaseStates
} from '../moderation/schema.js';
import type { Migration } from './migration-runner.js';
// Append only: never renumber entries or change an applied migration to add an
@@ -134,6 +147,69 @@ export const migrations: readonly Migration[] = [
'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'
);
}
},
{ version: 28, name: 'known login devices', run: migrateLoginDevices },
{
version: 29,
name: 'moderation case workflow and linked restrictions',
mode: 'foreign-keys-off',
run: migrateModerationCases
},
{ version: 30, name: 'persistent AFK reserves and gameplay rewards', run: migrateAfk },
{
version: 31,
name: 'moderation case links to existing actions',
run: migrateModerationCaseActions
},
{
version: 32,
name: 'moderation case assessment revision history',
run: migrateModerationCaseAssessments
},
{
version: 33,
name: 'moderation case relationships and duplicate review',
run: migrateModerationCaseRelationships
},
{
version: 34,
name: 'moderation follow-up dates and personal updates',
run: migrateModerationCaseFollowups
},
{
version: 35,
name: 'moderation case foreign key reference indexes',
run: migrateModerationCaseReferenceIndexes
},
{
version: 36,
name: 'moderation case lifecycle states',
mode: 'foreign-keys-off',
run: migrateModerationCaseStates
}
];
+197
View File
@@ -0,0 +1,197 @@
import { db } from '../db/index.js';
import { afkPolicy } from './afk.js';
import {
afkPageSize,
type AfkAccount,
type AfkLedgerEntry,
type AfkOverview,
type AfkPage,
type AfkQuery,
type AfkSession
} from '../../afk.js';
const day = 86_400_000;
// A string GUID owns one reserve, including players who appear in both games.
const identities = `WITH identities AS (
SELECT guid, name FROM (
SELECT guid, name, ROW_NUMBER() OVER (PARTITION BY guid ORDER BY last_seen_at DESC, game) AS rank
FROM leaderboard_players WHERE game IN ('t5', 't6')
) WHERE rank = 1
)`;
// Mirror freeAvailability in afk.ts: held time never expires, and confirmed free
// consumption becomes available 24 hours after its durable stop receipt.
const accounts = `${identities}, session_totals AS (
SELECT guid,
SUM(CASE WHEN state IN ('reserved','started','unresolved') THEN authorized_seconds ELSE 0 END) AS reserved,
SUM(CASE WHEN state IN ('reserved','started','unresolved') THEN saved_seconds ELSE 0 END) AS reserved_saved,
SUM(CASE WHEN state = 'unresolved' THEN 1 ELSE 0 END) AS unresolved,
SUM(CASE WHEN state IN ('reserved','started','unresolved') THEN free_seconds
WHEN free_charge_at > ? THEN MIN(free_seconds, charged_seconds) ELSE 0 END) AS free_used,
MIN(CASE WHEN free_charge_at > ? AND MIN(free_seconds, charged_seconds) > 0 THEN free_charge_at END) AS next_free_at,
MAX(MAX(created_at, COALESCE(started_at, created_at), COALESCE(ended_at, created_at))) AS last_activity
FROM afk_sessions GROUP BY guid
), ledger_totals AS (
SELECT guid, MAX(created_at) AS last_activity FROM afk_ledger GROUP BY guid
), account_guids AS (
SELECT guid FROM afk_accounts UNION SELECT guid FROM session_totals UNION SELECT guid FROM ledger_totals
), balances AS (
SELECT g.guid, p.name,
CASE WHEN a.guid IS NOT NULL THEN MAX(0, ? - COALESCE(s.free_used, 0)) ELSE 0 END AS freeSeconds,
COALESCE(a.saved_seconds, 0) AS savedSeconds,
COALESCE(s.reserved, 0) AS reservedSeconds,
COALESCE(s.reserved_saved, 0) AS reservedSavedSeconds,
COALESCE(s.unresolved, 0) AS unresolvedSessions,
NULLIF(MAX(COALESCE(s.last_activity, ''), COALESCE(l.last_activity, '')), '') AS lastActivity,
strftime('%Y-%m-%dT%H:%M:%fZ', (s.next_free_at + ${day}) / 1000.0, 'unixepoch') AS freeRenewsAt
FROM account_guids g
LEFT JOIN afk_accounts a ON a.guid = g.guid
LEFT JOIN identities p ON p.guid = g.guid
LEFT JOIN session_totals s ON s.guid = g.guid
LEFT JOIN ledger_totals l ON l.guid = g.guid
), accounts AS (
SELECT *, freeSeconds + savedSeconds AS availableSeconds FROM balances
)`;
// Retained canonical server names identify the game after match/server removal.
const game = (alias: string) => `COALESCE(m.game, CASE
WHEN substr(${alias}.server, 1, 3) = 't5_' THEN 't5'
WHEN substr(${alias}.server, 1, 3) = 't6_' THEN 't6' END)`;
const sessions = `${identities}, sessions AS (
SELECT s.guid, p.name, s.session_id AS sessionId, s.server, ${game('s')} AS game,
s.match_id AS matchId, m.id IS NOT NULL AS matchAvailable, s.state,
s.authorized_seconds AS authorizedSeconds, s.consumed_seconds AS consumedSeconds,
s.charged_seconds AS chargedSeconds, s.confirmed_seconds AS confirmedSeconds,
s.end_reason AS endReason, s.created_at AS createdAt, s.started_at AS startedAt, s.ended_at AS endedAt
FROM afk_sessions s LEFT JOIN identities p ON p.guid = s.guid
LEFT JOIN leaderboard_matches m ON m.id = s.match_id
)`;
const ledger = `${identities}, ledger AS (
SELECT l.guid, p.name, l.id, l.server, ${game('l')} AS game,
l.match_id AS matchId, m.id IS NOT NULL AS matchAvailable, l.kind,
l.free_delta AS freeDelta, l.saved_delta AS savedDelta,
l.free_seconds AS freeSeconds, l.saved_seconds AS savedSeconds,
l.operation_id AS operationId, l.session_id AS sessionId,
l.event_at AS eventAt, l.created_at AS createdAt
FROM afk_ledger l LEFT JOIN identities p ON p.guid = l.guid
LEFT JOIN leaderboard_matches m ON m.id = l.match_id
)`;
const emptyPage = <T>(): AfkPage<T> => ({ entries: [], total: 0, offset: 0 });
/** Read-only fleet report; reads never grant, settle, or update AFK balances. */
export function afkOverview(query: AfkQuery, now = new Date()): AfkOverview {
const handle = db();
return handle.transaction(() => {
const policy = afkPolicy();
const checkedAt = now.toISOString();
const accountValues = [now.getTime() - day, now.getTime() - day, policy.freeSeconds];
const summary = handle
.prepare(
`SELECT
(SELECT COUNT(*) FROM (SELECT guid FROM afk_accounts UNION SELECT guid FROM afk_sessions UNION SELECT guid FROM afk_ledger)) AS accounts,
(SELECT COALESCE(SUM(saved_seconds), 0) FROM afk_accounts) AS savedSeconds,
(SELECT COALESCE(SUM(authorized_seconds), 0) FROM afk_sessions WHERE state IN ('reserved','started','unresolved')) AS reservedSeconds,
(SELECT COUNT(*) FROM afk_sessions WHERE state = 'unresolved') AS unresolvedSessions`
)
.get() as AfkOverview['summary'];
const servers = (
handle
.prepare(
'SELECT server FROM afk_sessions UNION SELECT server FROM afk_ledger ORDER BY server'
)
.all() as { server: string }[]
).map((row) => row.server);
function page<T>(
cte: string,
table: string,
where: string[],
values: (string | number)[],
order: string
): AfkPage<T> {
const scope = `FROM ${table}${where.length ? ` WHERE ${where.join(' AND ')}` : ''}`;
const { total } = handle
.prepare(`${cte} SELECT COUNT(*) AS total ${scope}`)
.get(...values) as { total: number };
const offset = Math.min(
query.offset,
Math.max(0, Math.floor((total - 1) / afkPageSize) * afkPageSize)
);
const entries = handle
.prepare(`${cte} SELECT * ${scope} ORDER BY ${order} LIMIT ? OFFSET ?`)
.all(...values, afkPageSize, offset) as T[];
return { entries, total, offset };
}
const where: string[] = [];
const values: (string | number)[] = [];
if (query.q) {
where.push(
"(instr(lower(COALESCE(name, '')), lower(?)) > 0 OR instr(lower(guid), lower(?)) > 0)"
);
values.push(query.q, query.q);
}
if (query.player) {
where.push('guid = ?');
values.push(query.player);
}
let accountPage = emptyPage<AfkAccount>();
let sessionPage = emptyPage<AfkSession>();
let ledgerPage = emptyPage<AfkLedgerEntry>();
if (query.view === 'accounts') {
if (query.balance === 'saved') where.push('savedSeconds > 0');
if (query.balance === 'reserved') where.push('reservedSeconds > 0');
if (query.balance === 'unresolved') where.push('unresolvedSessions > 0');
const order =
query.sort === 'recent'
? 'lastActivity DESC, guid'
: query.sort === 'available'
? 'availableSeconds DESC, guid'
: 'savedSeconds DESC, guid';
accountPage = page(accounts, 'accounts', where, [...accountValues, ...values], order);
} else {
if (query.game) {
where.push('game = ?');
values.push(query.game);
}
if (query.server) {
where.push('server = ?');
values.push(query.server);
}
if (query.from) {
where.push('createdAt >= ?');
values.push(`${query.from}T00:00:00.000Z`);
}
if (query.to) {
where.push('createdAt <= ?');
values.push(`${query.to}T23:59:59.999Z`);
}
if (query.view === 'sessions') {
if (query.state) {
where.push('state = ?');
values.push(query.state);
}
sessionPage = page(
sessions,
'sessions',
where,
values,
'createdAt DESC, sessionId'
);
} else {
if (query.kind) {
where.push('kind = ?');
values.push(query.kind);
}
ledgerPage = page(ledger, 'ledger', where, values, 'createdAt DESC, id DESC');
}
}
return {
query,
checkedAt,
capacitySeconds: policy.capacitySeconds,
summary,
servers,
accounts: accountPage,
sessions: sessionPage,
ledger: ledgerPage
};
})();
}
+944
View File
@@ -0,0 +1,944 @@
import { createHash } from 'node:crypto';
import { db } from '../db/index.js';
export type AfkAction = 'balance' | 'event' | 'purchase' | 'reserve' | 'start' | 'settle';
const MAX = 2_147_483_647;
const DAY = 86_400_000;
const token = (v: unknown): v is string =>
typeof v === 'string' && /^[A-Za-z0-9_.:-]{1,64}$/.test(v);
const integer = (v: unknown, min = 0, max = MAX): v is number =>
Number.isSafeInteger(v) && Number(v) >= min && Number(v) <= max;
export function afkPolicy() {
const setting = (name: string, fallback: number, min = 0, max = MAX) => {
const value = Number(process.env[`EC_AFK_${name}`] ?? fallback);
if (!integer(value, min, max)) throw new Error(`Invalid EC_AFK_${name}`);
return value;
};
return {
freeSeconds: setting('FREE_SECONDS', 1800, 0, 604800),
capacitySeconds: setting('CAPACITY_SECONDS', 21600, 1, 604800),
activeInterval: setting('ACTIVE_INTERVAL_SECONDS', 300, 1),
activeReward: setting('ACTIVE_REWARD_SECONDS', 60),
roundReward: setting('ROUND_REWARD_SECONDS', 15),
killInterval: setting('KILL_INTERVAL', 100, 1),
killReward: setting('KILL_REWARD_SECONDS', 30),
headshotInterval: setting('HEADSHOT_INTERVAL', 25, 1),
headshotReward: setting('HEADSHOT_REWARD_SECONDS', 15),
reviveReward: setting('REVIVE_REWARD_SECONDS', 30),
bonusCap: setting('BONUS_HOURLY_SECONDS', 480),
purchasePoints: setting('PURCHASE_POINTS', 10000, 1, 1000000),
purchaseSeconds: setting('PURCHASE_SECONDS', 600, 1, 604800)
};
}
interface Account {
guid: string;
free_seconds: number;
saved_seconds: number;
free_started_at: number;
active_seconds: number;
kill_progress: number;
headshot_progress: number;
bonus_hour: number;
bonus_seconds: number;
}
interface Session {
session_id: string;
server: string;
instance_id: string;
guid: string;
match_key: string;
match_id: number | null;
state: 'reserved' | 'started' | 'settled' | 'unresolved' | 'corrected';
authorized_seconds: number;
free_seconds: number;
saved_seconds: number;
free_started_at: number;
consumed_seconds: number | null;
charged_seconds: number | null;
confirmed_seconds: number;
end_reason: string | null;
created_at: string;
started_at: string | null;
ended_at: string | null;
}
interface Progress {
seq: number;
active_seconds: number;
kills: number;
headshots: number;
rounds: number;
revives: number;
}
interface Input extends Progress {
server: string;
guid: string;
match: string;
request_id: string;
event_time?: string;
activity: string;
session_id: string;
seconds: number;
packs: number;
confirmed: boolean;
expected_purchase_points: number;
expected_purchase_seconds: number;
consumed_seconds: number | null;
confirmed_seconds?: number;
end_reason: string;
}
export interface AfkResult {
ok: boolean;
state: string;
code: string;
request_id: string;
free_seconds: number;
saved_seconds: number;
available_seconds: number;
reserved_seconds: number;
capacity_seconds: number;
authorized_seconds: number;
bank_balance: number;
purchase_points: number;
purchase_seconds: number;
max_purchase_packs: number;
session_id?: string;
consumed_seconds?: number | null;
awarded_seconds?: number;
}
/** Server-owned cumulative counters, receipts and balances commit together. */
export function afkRequest(
action: AfkAction,
value: unknown,
authenticatedServer: string,
instanceId: string,
options: { receiptOnly?: boolean } = {}
): { status: number; body: AfkResult } {
const policy = afkPolicy();
const empty = (code: string, request_id = ''): AfkResult => ({
ok: false,
state: 'rejected',
code,
request_id,
free_seconds: 0,
saved_seconds: 0,
available_seconds: 0,
reserved_seconds: 0,
capacity_seconds: policy.capacitySeconds,
authorized_seconds: 0,
bank_balance: 0,
purchase_points: policy.purchasePoints,
purchase_seconds: policy.purchaseSeconds,
max_purchase_packs: 0
});
const fail = (status: number, code: string, id = '') => ({ status, body: empty(code, id) });
if (!value || typeof value !== 'object' || Array.isArray(value))
return fail(400, 'invalid_request');
const input = value as Input;
if (input.server !== authenticatedServer) return fail(403, 'wrong_server');
if (
!token(input.guid) ||
!token(input.match) ||
(action !== 'balance' && !token(input.request_id)) ||
(input.request_id !== undefined && !token(input.request_id))
)
return fail(400, 'invalid_request');
if (
input.event_time !== undefined &&
(typeof input.event_time !== 'string' ||
!Number.isFinite(Date.parse(input.event_time)) ||
Date.parse(input.event_time) > Date.now() + 300000)
)
return fail(400, 'invalid_event_time', input.request_id);
const counters = ['active_seconds', 'kills', 'headshots', 'rounds', 'revives'] as const;
if (
action === 'event' &&
(!integer(input.seq, 1) ||
counters.some((key) => !integer(input[key])) ||
input.headshots > input.kills ||
input.activity !== 'playing')
)
return fail(400, 'invalid_activity', input.request_id);
if (
action === 'purchase' &&
(!integer(input.packs, 1, 1000) ||
input.confirmed !== true ||
!integer(input.expected_purchase_points, 1, 1000000) ||
!integer(input.expected_purchase_seconds, 1, 604800))
)
return fail(400, 'invalid_purchase', input.request_id);
if (['reserve', 'start', 'settle'].includes(action) && !token(input.session_id))
return fail(400, 'invalid_session', input.request_id);
if (action === 'reserve' && !integer(input.seconds, 1, 604800))
return fail(400, 'invalid_seconds', input.request_id);
if (
action === 'settle' &&
(![
'manual',
'disconnect',
'death',
'match_end',
'expired',
'never_started',
'crash'
].includes(input.end_reason) ||
!(
integer(input.consumed_seconds, 0, 604800) ||
(input.consumed_seconds === null && input.end_reason === 'crash')
) ||
(input.confirmed_seconds !== undefined &&
!integer(input.confirmed_seconds, 0, 604800)) ||
(input.end_reason === 'never_started' && input.consumed_seconds !== 0))
)
return fail(400, 'invalid_settlement', input.request_id);
const handle = db();
// Canonical key order also binds extra caller fields, preventing ambiguous retries.
const fingerprint = JSON.stringify([
action,
Object.fromEntries(Object.entries(input).sort(([a], [b]) => a.localeCompare(b)))
]);
return handle
.transaction(() => {
const { server, guid, match, request_id } = input;
const receipt =
action === 'balance'
? undefined
: (handle
.prepare(
'SELECT instance_id,fingerprint,result FROM afk_operations WHERE server=? AND request_id=?'
)
.get(server, request_id) as
{ instance_id: string; fingerprint: string; result: string } | undefined);
if (receipt) {
if (receipt.instance_id !== instanceId || receipt.fingerprint !== fingerprint)
return fail(409, 'request_conflict', request_id);
return { status: 200, body: JSON.parse(receipt.result) as AfkResult };
}
if (options.receiptOnly) return fail(200, 'not_applied', request_id);
const session = token(input.session_id)
? (handle
.prepare('SELECT * FROM afk_sessions WHERE session_id=?')
.get(input.session_id) as Session | undefined)
: undefined;
if (
session &&
(session.instance_id !== instanceId ||
session.server !== server ||
session.guid !== guid ||
session.match_key !== match)
)
return fail(409, 'session_conflict', request_id);
const now = new Date().toISOString();
const nowMs = Date.parse(now);
const eventAt = input.event_time ?? now;
let matchId: number | null = session?.match_id ?? null;
let account = handle.prepare('SELECT * FROM afk_accounts WHERE guid=?').get(guid) as
Account | undefined;
const held = () =>
handle
.prepare(
"SELECT COALESCE(SUM(authorized_seconds),0) AS total, COALESCE(SUM(saved_seconds),0) AS saved FROM afk_sessions WHERE guid=? AND state IN ('reserved','started','unresolved')"
)
.get(guid) as { total: number; saved: number };
const reply = (
state: string,
code = 'ok',
extra: Partial<AfkResult> = {}
): AfkResult => {
const reserved = held();
const bank =
(
handle
.prepare('SELECT balance FROM bank_accounts WHERE guid=?')
.get(guid) as { balance: number } | undefined
)?.balance ?? 0;
const pending = Boolean(
handle
.prepare("SELECT 1 FROM bank_requests WHERE guid=? AND state='prepared'")
.get(guid)
);
return {
...empty(code, request_id ?? ''),
ok: state !== 'rejected',
state,
free_seconds: account?.free_seconds ?? 0,
saved_seconds: account?.saved_seconds ?? 0,
available_seconds: (account?.free_seconds ?? 0) + (account?.saved_seconds ?? 0),
reserved_seconds: reserved.total,
bank_balance: bank,
max_purchase_packs: pending
? 0
: Math.max(
0,
Math.min(
Math.floor(bank / policy.purchasePoints),
Math.floor(
(policy.capacitySeconds -
(account?.saved_seconds ?? 0) -
reserved.saved) /
policy.purchaseSeconds
)
)
),
...extra
};
};
const finish = (body: AfkResult) => {
if (action !== 'balance')
handle
.prepare(
'INSERT INTO afk_operations(server,request_id,instance_id,guid,fingerprint,result,created_at) VALUES (?,?,?,?,?,?,?)'
)
.run(
server,
request_id,
instanceId,
guid,
fingerprint,
JSON.stringify(body),
now
);
return { status: 200, body };
};
const reject = (code: string) => finish(reply('rejected', code));
let historicalEvent = false;
// Settlement belongs to the original immutable instance, independent of current membership.
if (
action !== 'settle' &&
!(
session &&
(action === 'start' || action === 'reserve') &&
['settled', 'corrected'].includes(session.state)
)
) {
if (
!handle
.prepare(
'SELECT 1 FROM native_instances WHERE id=? AND server=? AND retired_at IS NULL'
)
.get(instanceId, server)
)
return fail(403, 'retired_instance', request_id);
let member = handle
.prepare(
`SELECT m.id,m.started_at,COALESCE(m.ended_at,m.last_received_at) AS observed_at FROM leaderboard_matches m
JOIN servers s ON s.name=m.server AND s.destroyed_at IS NULL
JOIN leaderboard_match_players p ON p.match_id=m.id AND p.guid=? AND p.present=1
WHERE m.server=? AND m.reporter_match_key=? AND m.status='active'
AND m.id=(SELECT MAX(id) FROM leaderboard_matches WHERE server=?)`
)
.get(guid, server, match, server) as
{ id: number; started_at: string; observed_at: string } | undefined;
if (!member && action === 'event') {
member = handle
.prepare(
`SELECT m.id,m.started_at,COALESCE(m.ended_at,m.last_received_at) AS observed_at
FROM leaderboard_matches m JOIN leaderboard_match_players p ON p.match_id=m.id AND p.guid=?
JOIN native_instances n ON n.id=? AND n.server=m.server
WHERE m.server=? AND m.reporter_match_key=?
AND julianday(n.created_at)<=julianday(m.started_at)+60.0/86400
AND (n.retired_at IS NULL OR julianday(n.retired_at)>=julianday(m.started_at))
AND NOT EXISTS(SELECT 1 FROM native_instances newer WHERE newer.server=m.server
AND newer.created_at>n.created_at AND newer.created_at<=m.started_at)
ORDER BY m.id DESC LIMIT 1`
)
.get(guid, instanceId, server, match) as typeof member;
historicalEvent = Boolean(member);
if (!member) return reject('not_in_match');
}
if (!member) {
const known = handle
.prepare(
'SELECT id,status FROM leaderboard_matches WHERE server=? AND reporter_match_key=? ORDER BY id DESC LIMIT 1'
)
.get(server, match) as { id: number; status: string } | undefined;
const latest = handle
.prepare('SELECT MAX(id) AS id FROM leaderboard_matches WHERE server=?')
.get(server) as { id: number | null };
const departed =
known &&
handle
.prepare(
'SELECT 1 FROM leaderboard_match_players WHERE match_id=? AND guid=? AND present=0'
)
.get(known.id, guid);
if (known && (known.status !== 'active' || known.id !== latest.id || departed))
return reject('not_in_match');
return fail(409, 'not_in_match', request_id);
}
matchId = member.id;
if (
action === 'event' &&
input.active_seconds >
Math.max(
0,
Math.floor(
((historicalEvent
? Math.min(nowMs, Date.parse(member.observed_at))
: nowMs) -
Date.parse(member.started_at)) /
1000
)
) +
60
)
return reject('invalid_active_time');
}
if (
action === 'settle' &&
!session &&
!(input.end_reason === 'never_started' && input.consumed_seconds === 0)
)
return reject('not_reserved');
if (
action === 'settle' &&
!session &&
!handle
.prepare('SELECT 1 FROM native_instances WHERE id=? AND server=?')
.get(instanceId, server)
)
return fail(403, 'unknown_instance', request_id);
const ledger = (kind: string, freeDelta = 0, savedDelta = 0, details: unknown = {}) => {
handle
.prepare(
`INSERT INTO afk_ledger(guid,server,match_id,operation_id,session_id,kind,free_delta,saved_delta,free_seconds,saved_seconds,event_at,created_at,details)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)`
)
.run(
guid,
server,
matchId,
request_id ?? `free:${nowMs}`,
input.session_id ?? null,
kind,
freeDelta,
savedDelta,
account?.free_seconds ?? 0,
account?.saved_seconds ?? 0,
eventAt,
now,
JSON.stringify(details)
);
};
const saveAccount = () => {
if (!account) throw new Error('Missing AFK account');
handle
.prepare(
`UPDATE afk_accounts SET free_seconds=?,saved_seconds=?,free_started_at=?,active_seconds=?,kill_progress=?,headshot_progress=?,bonus_hour=?,bonus_seconds=? WHERE guid=?`
)
.run(
account.free_seconds,
account.saved_seconds,
account.free_started_at,
account.active_seconds,
account.kill_progress,
account.headshot_progress,
account.bonus_hour,
account.bonus_seconds,
guid
);
};
if (!account) {
account = {
guid,
free_seconds: policy.freeSeconds,
saved_seconds: 0,
free_started_at: nowMs,
active_seconds: 0,
kill_progress: 0,
headshot_progress: 0,
bonus_hour: 0,
bonus_seconds: 0
};
handle
.prepare(
'INSERT INTO afk_accounts(guid,free_seconds,free_started_at) VALUES (?,?,?)'
)
.run(guid, policy.freeSeconds, nowMs);
ledger('free_grant', policy.freeSeconds);
} else {
const free = freeAvailability(guid, policy.freeSeconds, nowMs);
const delta = free.seconds - account.free_seconds;
if (delta !== 0) {
account.free_seconds = free.seconds;
saveAccount();
ledger('free_grant', delta, 0, { rolling: true });
}
}
const sessionReply = (row: Session) =>
reply(row.state, 'ok', {
session_id: row.session_id,
authorized_seconds: row.authorized_seconds,
consumed_seconds: row.consumed_seconds
});
if (action === 'balance') return finish(reply('balance'));
if (action === 'purchase') {
if (
input.expected_purchase_points !== policy.purchasePoints ||
input.expected_purchase_seconds !== policy.purchaseSeconds
)
return reject('price_changed');
const cost = input.packs * policy.purchasePoints;
const seconds = input.packs * policy.purchaseSeconds;
if (
handle
.prepare("SELECT 1 FROM bank_requests WHERE guid=? AND state='prepared'")
.get(guid)
)
return reject('bank_pending');
const bank =
(
handle
.prepare('SELECT balance FROM bank_accounts WHERE guid=?')
.get(guid) as { balance: number } | undefined
)?.balance ?? 0;
if (cost > bank) return reject('insufficient_balance');
if (account.saved_seconds + held().saved + seconds > policy.capacitySeconds)
return reject('reserve_capacity');
const bankId = `afk:${createHash('sha256').update(`${instanceId}:${request_id}`).digest('hex').slice(0, 60)}`;
handle
.prepare('UPDATE bank_accounts SET balance=balance-? WHERE guid=?')
.run(cost, guid);
handle
.prepare(
'INSERT INTO bank_ledger(guid,server,request_id,match_id,amount,balance,created_at) VALUES (?,?,?,?,?,?,?)'
)
.run(guid, server, bankId, matchId, -cost, bank - cost, now);
account.saved_seconds += seconds;
saveAccount();
ledger('purchase', 0, seconds, { points: cost, bank_request_id: bankId });
return finish(reply('purchased', 'ok', { awarded_seconds: seconds }));
}
if (action === 'event') {
if (
!historicalEvent &&
handle
.prepare(
"SELECT 1 FROM afk_sessions WHERE guid=? AND state IN ('reserved','started')"
)
.get(guid)
)
return reject('session_pending');
const previous = handle
.prepare(
'SELECT * FROM afk_progress WHERE instance_id=? AND match_key=? AND guid=?'
)
.get(instanceId, match, guid) as Progress | undefined;
if (
previous &&
input.seq === previous.seq &&
counters.some((key) => input[key] !== previous[key])
)
return reject('progress_conflict');
if (previous && input.seq <= previous.seq) {
if (counters.some((key) => input[key] > previous[key]))
return reject('stale_progress');
return finish(reply('rewarded', 'stale', { awarded_seconds: 0 }));
}
if (previous && counters.some((key) => input[key] < previous[key]))
return reject('progress_regression');
const delta = Object.fromEntries(
counters.map((key) => [key, input[key] - (previous?.[key] ?? 0)])
) as Omit<Progress, 'seq'>;
const activeBefore = account.active_seconds;
account.active_seconds += delta.active_seconds;
const activeAward =
(Math.floor(account.active_seconds / policy.activeInterval) -
Math.floor(activeBefore / policy.activeInterval)) *
policy.activeReward;
const kills = account.kill_progress + delta.kills;
const headshots = account.headshot_progress + delta.headshots;
const performance =
delta.rounds * policy.roundReward +
Math.floor(kills / policy.killInterval) * policy.killReward +
Math.floor(headshots / policy.headshotInterval) * policy.headshotReward;
account.kill_progress = kills % policy.killInterval;
account.headshot_progress = headshots % policy.headshotInterval;
handle
.prepare('DELETE FROM afk_bonus_rewards WHERE guid=? AND active_second<=?')
.run(guid, account.active_seconds - 3600);
const spent = (
handle
.prepare(
'SELECT COALESCE(SUM(seconds),0) AS seconds FROM afk_bonus_rewards WHERE guid=?'
)
.get(guid) as { seconds: number }
).seconds;
const bonus = Math.min(performance, Math.max(0, policy.bonusCap - spent));
if (bonus > 0)
handle
.prepare(
'INSERT INTO afk_bonus_rewards(guid,active_second,seconds) VALUES (?,?,?)'
)
.run(guid, account.active_seconds, bonus);
const reviveAward = delta.revives * policy.reviveReward;
const awarded = Math.min(
activeAward + bonus + reviveAward,
Math.max(0, policy.capacitySeconds - account.saved_seconds - held().saved)
);
account.saved_seconds += awarded;
saveAccount();
handle
.prepare(
`INSERT INTO afk_progress(instance_id,match_key,guid,seq,active_seconds,kills,headshots,rounds,revives) VALUES (?,?,?,?,?,?,?,?,?)
ON CONFLICT(instance_id,match_key,guid) DO UPDATE SET seq=excluded.seq,active_seconds=excluded.active_seconds,kills=excluded.kills,headshots=excluded.headshots,rounds=excluded.rounds,revives=excluded.revives`
)
.run(
instanceId,
match,
guid,
input.seq,
input.active_seconds,
input.kills,
input.headshots,
input.rounds,
input.revives
);
ledger('reward', 0, awarded, {
...delta,
active_reward: activeAward,
performance_reward: bonus,
revive_reward: reviveAward,
discarded_seconds: activeAward + bonus + reviveAward - awarded
});
return finish(reply('rewarded', 'ok', { awarded_seconds: awarded }));
}
if (action === 'reserve') {
if (session) return finish(sessionReply(session));
if (held().total > 0) return reject('session_pending');
const authorized = Math.min(
input.seconds,
account.free_seconds + account.saved_seconds
);
if (authorized === 0) return reject('empty');
const free = Math.min(account.free_seconds, authorized);
const saved = authorized - free;
handle
.prepare(
`INSERT INTO afk_sessions(session_id,server,instance_id,guid,match_key,match_id,state,authorized_seconds,free_seconds,saved_seconds,free_started_at,created_at)
VALUES (?,?,?,?,?,?,'reserved',?,?,?,?,?)`
)
.run(
input.session_id,
server,
instanceId,
guid,
match,
matchId,
authorized,
free,
saved,
account.free_started_at,
now
);
account.free_seconds -= free;
account.saved_seconds -= saved;
saveAccount();
ledger('reserve', -free, -saved, { authorized_seconds: authorized });
return finish(
reply('reserved', 'ok', {
session_id: input.session_id,
authorized_seconds: authorized,
consumed_seconds: null
})
);
}
if (action === 'start') {
if (!session) return reject('not_reserved');
if (session.state !== 'reserved') return finish(sessionReply(session));
handle
.prepare(
"UPDATE afk_sessions SET state='started',started_at=? WHERE session_id=?"
)
.run(eventAt, input.session_id);
ledger('start');
return finish(sessionReply({ ...session, state: 'started', started_at: eventAt }));
}
if (!session) {
handle
.prepare(
`INSERT INTO afk_sessions(session_id,server,instance_id,guid,match_key,match_id,state,authorized_seconds,free_seconds,saved_seconds,free_started_at,consumed_seconds,end_reason,created_at,ended_at)
VALUES (?,?,?,?,?,?,'settled',0,0,0,?,0,'never_started',?,?)`
)
.run(
input.session_id,
server,
instanceId,
guid,
match,
matchId,
account.free_started_at,
now,
eventAt
);
ledger('release', 0, 0, { never_started: true });
return finish(
reply('settled', 'ok', {
session_id: input.session_id,
authorized_seconds: 0,
consumed_seconds: 0
})
);
}
if (['settled', 'corrected'].includes(session.state))
return finish(sessionReply(session));
const confirmed = input.confirmed_seconds ?? input.consumed_seconds ?? 0;
if (
confirmed > session.authorized_seconds ||
(input.consumed_seconds !== null &&
(input.consumed_seconds > session.authorized_seconds ||
input.consumed_seconds < session.confirmed_seconds ||
confirmed > input.consumed_seconds)) ||
(input.end_reason === 'expired' &&
input.consumed_seconds !== session.authorized_seconds)
)
return reject('invalid_consumption');
if (input.consumed_seconds === null) {
handle
.prepare(
"UPDATE afk_sessions SET state='unresolved',confirmed_seconds=MAX(confirmed_seconds,?),end_reason='crash',ended_at=? WHERE session_id=?"
)
.run(confirmed, eventAt, input.session_id);
ledger('unresolved', 0, 0, {
confirmed_seconds: Math.max(confirmed, session.confirmed_seconds)
});
return finish(
sessionReply({ ...session, state: 'unresolved', confirmed_seconds: confirmed })
);
}
const consumed = input.consumed_seconds;
const freeConsumed = Math.min(session.free_seconds, consumed);
const savedConsumed = consumed - freeConsumed;
const priorFree = account.free_seconds;
const savedRelease = session.saved_seconds - savedConsumed;
account.saved_seconds += savedRelease;
handle
.prepare(
"UPDATE afk_sessions SET state='settled',consumed_seconds=?,charged_seconds=?,free_charge_at=?,confirmed_seconds=?,end_reason=?,ended_at=? WHERE session_id=?"
)
.run(
consumed,
consumed,
nowMs,
consumed,
input.end_reason,
eventAt,
input.session_id
);
account.free_seconds = freeAvailability(guid, policy.freeSeconds, nowMs).seconds;
const freeRelease = account.free_seconds - priorFree;
saveAccount();
ledger('consume', 0, 0, {
consumed_seconds: consumed,
free_consumed: freeConsumed,
saved_consumed: savedConsumed,
end_reason: input.end_reason
});
ledger('release', freeRelease, savedRelease, {
expired_free_seconds: session.free_seconds - freeConsumed - freeRelease
});
return finish(
sessionReply({ ...session, state: 'settled', consumed_seconds: consumed })
);
})
.immediate();
}
export function afkProfile(guid: string) {
const handle = db();
const account = handle.prepare('SELECT * FROM afk_accounts WHERE guid=?').get(guid) as
Account | undefined;
const summary = handle
.prepare(
`SELECT COALESCE(SUM(CASE WHEN state IN ('reserved','started','unresolved') THEN authorized_seconds ELSE 0 END),0) AS reservedSeconds,
COALESCE(SUM(consumed_seconds),0) AS confirmedAfkSeconds,
SUM(CASE WHEN state='unresolved' THEN 1 ELSE 0 END) AS unresolvedSessions,
SUM(CASE WHEN end_reason='expired' AND state='settled' THEN 1 ELSE 0 END) AS expiredSessions,
SUM(CASE WHEN end_reason='manual' AND state='settled' THEN 1 ELSE 0 END) AS manualReturns
FROM afk_sessions WHERE guid=?`
)
.get(guid) as Record<string, number | null>;
const purchases = handle
.prepare(
"SELECT COUNT(*) AS purchaseCount,COALESCE(SUM(saved_delta),0) AS purchasedSeconds FROM afk_ledger WHERE guid=? AND kind='purchase'"
)
.get(guid) as { purchaseCount: number; purchasedSeconds: number };
const entries = handle
.prepare(
`SELECT l.id,l.server,l.kind,l.free_delta AS freeDelta,l.saved_delta AS savedDelta,
l.free_seconds AS freeSeconds,l.saved_seconds AS savedSeconds,l.session_id AS sessionId,l.operation_id AS operationId,
l.event_at AS eventAt,l.created_at AS createdAt,l.match_id AS matchId,CASE WHEN m.id IS NOT NULL THEN 1 ELSE 0 END AS matchAvailable
FROM afk_ledger l LEFT JOIN leaderboard_matches m ON m.id=l.match_id WHERE l.guid=? ORDER BY l.id DESC LIMIT 20`
)
.all(guid) as {
id: number;
server: string;
kind: string;
freeDelta: number;
savedDelta: number;
freeSeconds: number;
savedSeconds: number;
sessionId: string | null;
operationId: string;
eventAt: string;
createdAt: string;
matchId: number | null;
matchAvailable: number;
}[];
const sessions = handle
.prepare(
`SELECT session_id AS sessionId,server,state,authorized_seconds AS authorizedSeconds,
consumed_seconds AS consumedSeconds,charged_seconds AS chargedSeconds,confirmed_seconds AS confirmedSeconds,end_reason AS endReason,started_at AS startedAt,ended_at AS endedAt
FROM afk_sessions WHERE guid=? ORDER BY created_at DESC LIMIT 20`
)
.all(guid) as {
sessionId: string;
server: string;
state: string;
authorizedSeconds: number;
consumedSeconds: number | null;
chargedSeconds: number | null;
confirmedSeconds: number;
endReason: string | null;
startedAt: string | null;
endedAt: string | null;
}[];
const free = freeAvailability(guid, afkPolicy().freeSeconds);
return {
freeSeconds: account ? free.seconds : 0,
savedSeconds: account?.saved_seconds ?? 0,
availableSeconds: (account ? free.seconds : 0) + (account?.saved_seconds ?? 0),
reservedSeconds: summary.reservedSeconds ?? 0,
capacitySeconds: afkPolicy().capacitySeconds,
freeRenewsAt: free.nextAt ? new Date(free.nextAt).toISOString() : null,
activeSeconds: account?.active_seconds ?? 0,
confirmedAfkSeconds: summary.confirmedAfkSeconds ?? 0,
unresolvedSessions: summary.unresolvedSessions ?? 0,
expiredSessions: summary.expiredSessions ?? 0,
manualReturns: summary.manualReturns ?? 0,
...purchases,
entries,
sessions
};
}
/** Held authorization never ages out. Confirmed free use replenishes 24h after stop receipt. */
function freeAvailability(guid: string, allowance: number, now = Date.now()) {
const row = db()
.prepare(
`SELECT
COALESCE(SUM(CASE WHEN state IN ('reserved','started','unresolved') THEN free_seconds
WHEN free_charge_at>? THEN MIN(free_seconds,charged_seconds) ELSE 0 END),0) AS used,
MIN(CASE WHEN free_charge_at>? AND MIN(free_seconds,charged_seconds)>0 THEN free_charge_at END) AS next_at
FROM afk_sessions WHERE guid=?`
)
.get(now - DAY, now - DAY, guid) as { used: number; next_at: number | null };
return {
seconds: Math.max(0, allowance - row.used),
nextAt: row.next_at === null ? null : row.next_at + DAY
};
}
/** An administrator records an explicit accounting correction, never a fabricated exact duration. */
export function afkCorrectSession(
value: unknown,
actor: string
): { status: number; body: AfkResult } {
const policy = afkPolicy();
const invalid = (code: string, status = 400) => ({
status,
body: {
ok: false,
state: 'rejected',
code,
request_id: '',
free_seconds: 0,
saved_seconds: 0,
available_seconds: 0,
reserved_seconds: 0,
capacity_seconds: policy.capacitySeconds,
authorized_seconds: 0,
bank_balance: 0,
purchase_points: policy.purchasePoints,
purchase_seconds: policy.purchaseSeconds,
max_purchase_packs: 0
} as AfkResult
});
if (!value || typeof value !== 'object' || Array.isArray(value))
return invalid('invalid_correction');
const input = value as {
session_id: string;
request_id: string;
consumed_seconds: number;
reason: string;
stopped: boolean;
};
if (
!token(input.session_id) ||
!token(input.request_id) ||
!integer(input.consumed_seconds, 0, 604800) ||
input.stopped !== true ||
typeof input.reason !== 'string' ||
input.reason.trim().length < 5 ||
input.reason.length > 1000
)
return invalid('invalid_correction');
const handle = db();
return handle
.transaction(() => {
const session = handle
.prepare('SELECT * FROM afk_sessions WHERE session_id=?')
.get(input.session_id) as Session | undefined;
if (!session) return invalid('not_reserved', 404);
const request = {
server: session.server,
guid: session.guid,
match: session.match_key,
request_id: `correction:${createHash('sha256').update(input.request_id).digest('hex').slice(0, 53)}`,
session_id: input.session_id,
consumed_seconds: input.consumed_seconds,
end_reason: 'crash',
correction_reason: input.reason,
correction_actor: actor
};
const prior = afkRequest('settle', request, session.server, session.instance_id, {
receiptOnly: true
});
if (prior.body.code !== 'not_applied') return prior;
if (session.state !== 'unresolved') return invalid('not_unresolved', 409);
if (
input.consumed_seconds < session.confirmed_seconds ||
input.consumed_seconds > session.authorized_seconds
)
return invalid('invalid_consumption');
const result = afkRequest('settle', request, session.server, session.instance_id);
if (!result.body.ok) return result;
handle
.prepare(
"UPDATE afk_sessions SET state='corrected',consumed_seconds=NULL,confirmed_seconds=? WHERE session_id=?"
)
.run(session.confirmed_seconds, session.session_id);
handle
.prepare(
"UPDATE afk_ledger SET kind='correction',details=? WHERE server=? AND operation_id=? AND kind='consume'"
)
.run(
JSON.stringify({
actor,
reason: input.reason,
charged_seconds: input.consumed_seconds,
confirmed_seconds: session.confirmed_seconds
}),
session.server,
request.request_id
);
result.body.state = 'corrected';
result.body.consumed_seconds = null;
handle
.prepare('UPDATE afk_operations SET result=? WHERE server=? AND request_id=?')
.run(JSON.stringify(result.body), session.server, request.request_id);
return result;
})
.immediate();
}
+101 -11
View File
@@ -123,9 +123,15 @@ export function serverChatTimeline(server: ServerRow, tail: TailResult): Context
export function matchChatTimeline(matchId: number): ContextualChatEntry[] {
return (
db()
.prepare('SELECT entry FROM match_chat WHERE match_id = ? ORDER BY rowid')
.all(matchId) as { entry: string }[]
).map((row) => JSON.parse(row.entry));
.prepare(
'SELECT message_key,occurrence,entry FROM match_chat WHERE match_id = ? ORDER BY rowid'
)
.all(matchId) as { message_key: string; occurrence: number; entry: string }[]
).map((row) => ({
...JSON.parse(row.entry),
matchId,
messageId: `${matchId}:${row.message_key}:${row.occurrence}`
}));
}
/** Save before filtering the live view so completed matches retain their messages. */
@@ -206,17 +212,48 @@ interface ChatReadState {
entries: ContextualChatEntry[];
contextVersion?: string;
wasOccupied: boolean;
needsDrain: boolean;
bytesRead: number;
}
const readers = new Map<string, ChatReadState>();
const failures = new Map<string, number>();
const CHAT_READ_MIN_AGE_MS = 15_000;
const MAX_IDLE_STALLED_READS = 3;
const idleStalls = new Map<string, number>();
type LastChatRead = { completedAt: number } & ({ missing: boolean } | { error: unknown });
const lastReads = new Map<string, LastChatRead>();
function chatReadKey(server: ServerRow): string {
return `${server.name}:${server.created_at}:${server.native_instance_id}:${server.instance_generation}`;
}
/** Live viewers reuse the worker's reader state instead of adding a read per tab. */
export async function readServerChat(
server: ServerRow
): Promise<{ entries: ContextualChatEntry[]; missing: boolean }> {
const key = chatReadKey(server);
const existing = pending.get(key);
if (existing) return existing;
const last = lastReads.get(key);
if (!last || Date.now() - last.completedAt >= CHAT_READ_MIN_AGE_MS)
return collectServerChat(server);
if ('error' in last) throw last.error;
const state = readers.get(server.name);
return {
// Telemetry may have started a new match since the last log read.
entries: currentChatEntries(server, state?.key === key ? state.entries : []),
missing: last.missing
};
}
/** Shared by the live API and the worker; collection does not require a viewer. */
export function collectServerChat(
server: ServerRow
): Promise<{ entries: ContextualChatEntry[]; missing: boolean }> {
const key = `${server.name}:${server.created_at}:${server.native_instance_id}:${server.instance_generation}`;
const key = chatReadKey(server);
const existing = pending.get(key);
if (existing) return existing;
let progressed = false;
const work = (async () => {
const path = resolveLogPath(server.name, gameLogKey(server.name));
if (!path) throw new Error('Invalid server log path');
@@ -231,7 +268,9 @@ export function collectServerChat(
partial: Buffer.alloc(0),
skipLine: false,
entries: [],
wasOccupied: false
wasOccupied: false,
needsDrain: false,
bytesRead: 0
};
const tail = await readLogChunk(path, state.cursor);
const current = getServer(server.name);
@@ -258,7 +297,10 @@ export function collectServerChat(
state.entries = [];
}
state.cursor = tail.cursor;
const bytes = Buffer.concat([state.partial, Buffer.from(tail.contentBase64, 'base64')]);
const content = Buffer.from(tail.contentBase64, 'base64');
progressed = content.length > 0;
state.bytesRead += content.length;
const bytes = Buffer.concat([state.partial, content]);
const end = bytes.lastIndexOf(10) + 1;
if (end) {
const lines = bytes
@@ -286,14 +328,17 @@ export function collectServerChat(
state.skipLine = true;
}
// File mtime anchors the final line only after the backlog has been read.
if (tail.cursor.offset < tail.size || state.partial.length) {
state.needsDrain = tail.cursor.offset < tail.size || state.partial.length > 0;
if (state.needsDrain) {
state.signature = undefined;
readers.set(server.name, state);
return { entries: currentChatEntries(current, state.entries), missing: false };
}
} else {
// Older agents remain readable during a rolling update.
state.needsDrain = false;
changed = state.signature !== `${tail.size}:${tail.mtime}`;
progressed = changed;
if (changed) state.lines = tail.lines;
}
const signature = `${tail.size}:${tail.mtime}`;
@@ -319,7 +364,22 @@ export function collectServerChat(
state.signature = signature;
readers.set(server.name, state);
return { entries: currentChatEntries(current, state.entries), missing: false };
})().finally(() => pending.delete(key));
})()
.then(
(result) => {
const state = readers.get(server.name);
if (progressed || result.missing || (state?.key === key && !state.needsDrain))
idleStalls.delete(key);
lastReads.set(key, { completedAt: Date.now(), missing: result.missing });
return result;
},
(error: unknown) => {
// Failed reads also observe the cooldown, without advancing the cursor.
lastReads.set(key, { completedAt: Date.now(), error });
throw error;
}
)
.finally(() => pending.delete(key));
pending.set(key, work);
return work;
}
@@ -329,27 +389,57 @@ export async function collectFleetChat(): Promise<void> {
const observe = observationReader();
const live = listServers().filter((server) => !server.destroyed_at);
const names = new Set(live.map((server) => server.name));
const keys = new Set(live.map(chatReadKey));
for (const name of readers.keys()) if (!names.has(name)) readers.delete(name);
for (const name of failures.keys()) if (!names.has(name)) failures.delete(name);
for (const key of lastReads.keys()) if (!keys.has(key)) lastReads.delete(key);
for (const key of idleStalls.keys()) if (!keys.has(key)) idleStalls.delete(key);
const servers = live.filter((server) => {
if (server.host_id && !hosts.get(server.host_id)?.connected) return false;
if (server.container_state && !['running', 'unknown'].includes(server.container_state))
return false;
const players = observe(server).last_players ?? server.last_players;
// One final read after players leave retains messages written at match end.
return players !== 0 || readers.get(server.name)?.wasOccupied;
const key = chatReadKey(server);
if (players !== 0) {
idleStalls.delete(key);
return true;
}
const stalls = idleStalls.get(key) ?? 0;
if (stalls >= MAX_IDLE_STALLED_READS) return false;
const last = lastReads.get(key);
if (stalls > 0 && last && Date.now() - last.completedAt < CHAT_READ_MIN_AGE_MS)
return false;
const state = readers.get(server.name);
// Pick up surviving final logs after a collector restart, then finish any
// unread chunks even after players leave. Errors and incomplete final lines
// have bounded idle retries rather than polling an empty server forever.
return (
!last ||
'error' in last ||
(state?.key === key && (state.wasOccupied || state.needsDrain))
);
});
// Bound agent traffic and isolate failures to their own server.
async function collect() {
for (let server = servers.shift(); server; server = servers.shift()) {
const key = chatReadKey(server);
const before = readers.get(server.name);
try {
await collectServerChat(server);
const state = readers.get(server.name);
if (state)
if (state?.key === key) {
state.wasOccupied =
(observe(server).last_players ?? server.last_players ?? 0) > 0;
if (!state.wasOccupied && state.needsDrain) {
const progressed =
before?.key !== key || state.bytesRead > before.bytesRead;
idleStalls.set(key, progressed ? 0 : (idleStalls.get(key) ?? 0) + 1);
} else idleStalls.delete(key);
}
failures.delete(server.name);
} catch (error) {
if ((observe(server).last_players ?? server.last_players) === 0)
idleStalls.set(key, (idleStalls.get(key) ?? 0) + 1);
if (Date.now() - (failures.get(server.name) ?? -Infinity) >= 5 * 60_000) {
console.error(`[chat] Could not collect chat for ${server.name}`, error);
failures.set(server.name, Date.now());
+28 -3
View File
@@ -171,6 +171,7 @@ function buildRecipe(
path: script.target_rel_path,
versionId: version.id,
version: version.version,
releaseVersion: version.release_version,
sha256: version.sha256,
size: version.size
});
@@ -187,6 +188,7 @@ function buildRecipe(
kind: 'config',
versionId: null,
version: null,
releaseVersion: null,
sha256: sha256Buffer(content),
size: content.length,
...(includeContent ? { content: content.toString('base64') } : {})
@@ -249,18 +251,41 @@ export function recipeSummaryForDeployment(
)
.get(id) as { recipe_summary: string | null; blueprint_name: string | null } | undefined;
if (!row) return null;
if (row.recipe_summary !== null)
return { recipe: JSON.parse(row.recipe_summary), blueprintName: row.blueprint_name };
if (row.recipe_summary !== null) {
const saved: LaunchRecipeSummary = JSON.parse(row.recipe_summary);
const summary = withHistoricalReleaseVersions(saved);
if (summary !== saved)
db()
.prepare(
'UPDATE server_launch_recipes SET recipe_summary = ? WHERE deployment_id = ?'
)
.run(JSON.stringify(summary), id);
return { recipe: summary, blueprintName: row.blueprint_name };
}
// Old snapshots remain readable. Populate on first access, without making
// controller startup scan and decode every retained plugin in its history.
const launch = recipeForDeployment(id)!;
const summary = summarizeRecipe(launch.recipe);
const summary = withHistoricalReleaseVersions(summarizeRecipe(launch.recipe));
db()
.prepare('UPDATE server_launch_recipes SET recipe_summary = ? WHERE deployment_id = ?')
.run(JSON.stringify(summary), id);
return { recipe: summary, blueprintName: row.blueprint_name };
}
/** Restore display metadata from the exact retained upload, preserving the captured recipe. */
function withHistoricalReleaseVersions(summary: LaunchRecipeSummary): LaunchRecipeSummary {
const files = summary.files.map((file) => {
if (file.releaseVersion !== undefined || file.versionId === null) return file;
const version = getVersionMetadata(file.versionId);
if (!version || version.version !== file.version || version.sha256 !== file.sha256)
return file;
return { ...file, releaseVersion: version.release_version };
});
return files.some((file, index) => file !== summary.files[index])
? { ...summary, files }
: summary;
}
export function recipeForDeployment(
id: string
): { recipe: LaunchRecipePlan; blueprintName: string | null } | null {
@@ -0,0 +1,16 @@
import { error } from '@sveltejs/kit';
import type { AuthUser } from '../../auth.js';
import { AuthError } from '../auth/index.js';
import { moderationActor } from '../moderation/core.js';
export type MatchArchiveLocals = { user: AuthUser | null };
/** Recheck current account privileges before returning the expanded moderation archive. */
export function requireMatchArchiveAccess(user: Pick<AuthUser, 'id'> | null | undefined): void {
try {
moderationActor(user?.id ?? '');
} catch (cause) {
if (cause instanceof AuthError) error(cause.status, cause.message);
throw cause;
}
}
+9 -5
View File
@@ -32,7 +32,7 @@ export function serverMatchHistory(
now = Date.now()
): MatchHistoryResponse {
const handle = db();
const clauses = [`m.highest_round >= ${MIN_RECORDED_ROUND}`];
const clauses = [query.archive ? '1=1' : `m.highest_round >= ${MIN_RECORDED_ROUND}`];
const parameters: Array<string | number> = [];
if (server !== undefined) {
clauses.push('m.server = ?');
@@ -93,14 +93,18 @@ export function serverMatchHistory(
}
/** Read durable match records without depending on a current server or scoreboard. */
export function getMatchDetail(id: number): MatchDetail | null {
export function getMatchDetail(id: number, archive = false): MatchDetail | null {
if (!Number.isSafeInteger(id) || id < 1) return null;
const handle = db();
const match = handle
.prepare(
`SELECT ${matchSummary}, m.start_observed AS startObserved
FROM leaderboard_matches m WHERE m.id = ? AND m.status = 'completed'
FROM leaderboard_matches m WHERE m.id = ?${
archive
? ''
: ` AND m.status = 'completed'
AND m.highest_round >= ${MIN_RECORDED_ROUND}`
}`
)
.get(id) as
| (Omit<MatchDetail, 'players' | 'chat' | 'startObserved'> & { startObserved: number })
@@ -116,7 +120,7 @@ export function getMatchDetail(id: number): MatchDetail | null {
(SELECT identity.name FROM leaderboard_players identity
WHERE identity.guid = p.guid AND identity.game = ?), p.guid) AS name,
p.has_stats AS hasStats,
CASE WHEN ? IS NULL THEN p.present ELSE p.completed END AS presentAtEnd,
CASE WHEN ? != 'completed' THEN 0 WHEN ? IS NULL THEN p.present ELSE p.completed END AS presentAtEnd,
p.first_seen_at AS firstSeenAt, p.last_seen_at AS lastSeenAt,
CASE WHEN p.has_stats = 1 THEN p.highest_round END AS highestRound,
CASE WHEN p.has_stats = 1 THEN p.rounds_survived END AS roundsSurvived,
@@ -131,7 +135,7 @@ export function getMatchDetail(id: number): MatchDetail | null {
FROM leaderboard_match_players p WHERE p.match_id = ?
ORDER BY hasStats DESC, kills DESC, highestRound DESC, name COLLATE NOCASE, p.guid`
)
.all(match.game, match.reporterMatchKey, id) as Array<
.all(match.game, match.status, match.reporterMatchKey, id) as Array<
Omit<MatchPlayer, 'hasStats' | 'presentAtEnd' | 'canViewProfile'> & {
hasStats: number;
presentAtEnd: number;
+1 -1
View File
@@ -137,7 +137,7 @@ function readOperations(now: number, server?: string): FleetOperation[] {
? 'Completed with warnings'
: 'Complete'
: state === 'attention'
? `${actionLabels[row.action as ProvisioningAction]} needs attention`
? `${actionLabels[row.action as ProvisioningAction]} operation needs attention`
: row.stage,
detail: row.error ?? row.stage,
href: `/provisioning/${encodeURIComponent(row.id)}`
+21 -9
View File
@@ -2,11 +2,18 @@ import { posix } from 'node:path';
import { gameOfServer, relDestForKind, type ArtifactKind } from '../config.js';
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 {
return (
posix.normalize(path.replaceAll('\\', '/')) ===
posix.join(relDestForKind('config', game), BANK_CONFIG_FILE)
// 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.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 {
if (isPrivateConfigTarget(server, kind, path))
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 {
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.
export function isPrivateConfigEvidencePath(path: string): boolean {
return (
posix.normalize(path.replaceAll('\\', '/')) === BANK_CONFIG_FILE ||
isPrivateConfigPath(path)
PRIVATE_CONFIG_FILES.some(
(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 {
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.'
);
}
}
+8 -3
View File
@@ -192,12 +192,17 @@ export function getVersion(id: number): ScriptVersionRow | undefined {
ScriptVersionRow | undefined;
}
export type ScriptVersionMetadata = Pick<ScriptVersionRow, 'id' | 'version' | 'sha256' | 'size'>;
export type ScriptVersionMetadata = Pick<
ScriptVersionRow,
'id' | 'version' | 'release_version' | 'sha256' | 'size'
>;
/** Planning a launch never needs to load potentially large script or plugin bytes. */
export function getVersionMetadata(id: number): ScriptVersionMetadata | undefined {
return db()
.prepare('SELECT id, version, sha256, size FROM script_versions WHERE id = ?')
.prepare(
'SELECT id, version, release_version, sha256, size FROM script_versions WHERE id = ?'
)
.get(id) as ScriptVersionMetadata | undefined;
}
@@ -253,7 +258,7 @@ export function createScript(input: CreateScriptInput): {
if (!targetRelPath) throw new ScriptError('target rel path is required', 'path-empty');
if (isReservedConfigFilename(targetRelPath))
throw new ScriptError(
'electric_cherry_bank.cfg is reserved for instance credentials.',
`${targetRelPath} is reserved for instance credentials.`,
'path-reserved'
);
+8
View File
@@ -3,6 +3,7 @@ import type { GameTelemetry, ServerTiming } from '../../telemetry.js';
import { MIN_RECORDED_ROUND } from '../../match-history.js';
import { recordLeaderboardTelemetry } from './leaderboard-ingest.js';
import { latestGameSubmission } from './status-evidence.js';
import { observeModerationPlayers } from '../moderation/core.js';
export function listTelemetry(): GameTelemetry[] {
const rows = db()
@@ -33,6 +34,13 @@ export function saveTelemetryWithOutcome(report: GameTelemetry): {
? JSON.parse(row.payload)
: undefined;
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) {
return {
telemetry: { ...report, timing: { matchId: recorded.matchId, players: {} } },
+27 -4
View File
@@ -1,6 +1,6 @@
import {
assertPublicFilePayload,
BANK_CONFIG_FILE,
PRIVATE_CONFIG_FILES,
PrivateFileOperationError,
isPrivateConfigPath
} from '../fleet/private-config.js';
@@ -1454,7 +1454,7 @@ export function getHostOperationPayload(hostId: string, id: string): Buffer {
.get(id, hostId) as { payload: Buffer } | undefined;
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.
if (row.payload.includes(BANK_CONFIG_FILE))
if (PRIVATE_CONFIG_FILES.some((filename) => row.payload.includes(filename)))
assertPublicFilePayload(JSON.parse(row.payload.toString()));
if (
!row.payload.includes('"contentSha256"') ||
@@ -1485,10 +1485,15 @@ export function reportHostOperationStatus(
if (['completed', 'failed'].includes(operation.state)) return operation;
const state = input.state as HostOperationState;
if (operation.state === 'running' && state === 'accepted') return operation;
const error =
let error =
input.error === undefined || input.error === null
? null
: 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);
if (result !== null && Buffer.byteLength(result) > 128 * 1024 * 1024)
throw new Error('Operation result exceeds the limit.');
@@ -1504,7 +1509,9 @@ export function reportHostOperationStatus(
? { redacted: true }
: operation.kind === 'logs.read'
? logResultMetadata(input.result)
: input.result
: operation.kind === 'instance.configure-moderation'
? moderationConfigurationReceipt(input.result)
: input.result
);
result = JSON.stringify(packed.value);
referenceBlobs('operation-result', id, packed.hashes);
@@ -1568,6 +1575,22 @@ export function reportHostOperationStatus(
queueMicrotask(() => workEvents.emit(`operation:${id}`));
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(
hostId: string,
id: string,
+684
View File
@@ -0,0 +1,684 @@
import { randomUUID } from 'node:crypto';
import { db } from '../db/index.js';
import { archivedEvidenceSnapshots } from './evidence.js';
import { caseActionReference, recordedCaseAction, isCaseActionLinked } from './case-actions.js';
import { assessmentDraft } from './assessments.js';
import { linkCases, unlinkCases } from './relationships.js';
import { notifyCase, caseReviewSchedule, readCaseUpdates } from './followups.js';
import { AuthError } from '../auth/index.js';
import { moderatorMaximumRestrictionMs, type AuthUser } from '../../auth.js';
import {
canRevokeModerationRestriction,
isModerationCaseOpen,
moderationCaseOutcomes,
moderationCaseStatuses,
moderationCaseStatusLabels,
type ModerationCaseStatus,
type ModerationCasePriority,
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;
case_id: string | null;
};
type CaseRow = {
id: string;
game: ModerationGame;
guid: string;
revision: number;
status: ModerationCaseStatus;
title: string;
priority: ModerationCasePriority;
assignee_id: string | null;
review_at: string | null;
review_reason: string;
review_sanction_id: string | null;
};
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);
const linkedCase =
input.caseId === undefined || input.caseId === null ? null : caseRow(input.caseId);
if (linkedCase) {
checkCaseRevision(linkedCase, input.caseRevision);
if (!isModerationCaseOpen(linkedCase.status))
throw new AuthError('Reopen this case before applying a restriction.', 409);
if (linkedCase.game !== target.game || linkedCase.guid !== target.guid)
throw new AuthError('A case restriction must target the case’s game and player.');
}
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,case_id)
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,
linkedCase?.id ?? null
);
advancePolicy();
moderationEvent(
actor,
'restriction.created',
target,
id,
JSON.stringify({
kind: input.kind,
scope: input.scope,
server: server?.name ?? null,
reason,
expiresAt: expires,
note,
caseId: linkedCase?.id ?? null
}),
now
);
if (linkedCase) {
touchCase(linkedCase.id, now);
moderationEvent(
actor,
'case.restriction',
linkedCase,
linkedCase.id,
JSON.stringify({ restrictionId: id, kind: input.kind, scope: input.scope, reason }),
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);
db()
.prepare(
`UPDATE moderation_cases SET updated_at=?,revision=revision+1
WHERE id=? OR id IN (SELECT case_id FROM moderation_case_action_links
WHERE action_type='restriction' AND action_id=?)`
)
.run(now, row.case_id, id);
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 checkCaseRevision(row: CaseRow, revision: unknown) {
if (row.revision !== revision)
throw new AuthError('This case changed. Refresh before trying again.', 409);
}
function touchCase(id: string, now: string) {
db()
.prepare('UPDATE moderation_cases SET updated_at=?,revision=revision+1 WHERE id=?')
.run(now, id);
}
function casePriority(value: unknown): ModerationCasePriority {
if (value !== 'low' && value !== 'normal' && value !== 'high' && value !== 'urgent')
throw new AuthError('Choose low, normal, high, or urgent priority.');
return value;
}
function caseAssignee(value: unknown): { id: string | null; username: string | null } {
if (value === null) return { id: null, username: null };
const id = textField(value, 'Assignee', 128);
const user = db()
.prepare(
`SELECT id,username FROM auth_users
WHERE id=? AND role IN ('admin','moderator') AND disabled=0 AND must_change_password=0`
)
.get(id) as { id: string; username: string } | undefined;
if (!user) throw new AuthError('Choose an active administrator or moderator to own this case.');
return user;
}
function evidence(
actor: AuthUser,
id: string,
text: string,
source: string,
snapshot: string | null,
now: string,
decision = false
) {
if (
!decision &&
(
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 priority = input.priority === undefined ? 'normal' : casePriority(input.priority);
const assignee =
input.assigneeId === undefined
? { id: null, username: null }
: caseAssignee(input.assigneeId);
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,priority,assignee_id) VALUES(?,?,?,?,'open',?,?,?,?,?,?)`
)
.run(
id,
target.game,
target.guid,
title,
actor.id,
actor.username,
now,
now,
priority,
assignee.id
);
if (note) evidence(actor, id, note, 'Staff note', null, now);
const attached =
input.evidence === undefined ? 0 : archivedEvidence(actor, id, target, input.evidence, now);
moderationEvent(actor, 'case.opened', target, id, title, now);
if (priority !== 'normal' || assignee.id)
moderationEvent(
actor,
'case.updated',
target,
id,
JSON.stringify({
priority: { from: 'normal', to: priority },
assignee: { from: null, to: assignee.username }
}),
now
);
if (note || attached) notifyCase(actor.id, id, 'evidence', now);
if (assignee.id) notifyCase(actor.id, id, 'assignment', now);
return { id };
}
function archivedEvidence(
actor: AuthUser,
id: string,
target: { game: ModerationGame; guid: string },
value: unknown,
now: string
): number {
const snapshots = archivedEvidenceSnapshots(id, target, value);
for (const snapshot of snapshots)
evidence(actor, id, snapshot.text, snapshot.source, snapshot.snapshot, now);
return snapshots.length;
}
function updateCase(actor: AuthUser, input: Record<string, unknown>, now: string) {
const row = caseRow(input.id);
checkCaseRevision(row, input.revision);
const title = input.title === undefined ? row.title : textField(input.title, 'Case title', 160);
const priority = input.priority === undefined ? row.priority : casePriority(input.priority);
const assignee =
input.assigneeId === undefined || input.assigneeId === row.assignee_id
? null
: caseAssignee(input.assigneeId);
const assigneeId = assignee ? assignee.id : row.assignee_id;
const status = input.status === undefined ? row.status : input.status;
if (
input.status !== undefined &&
!moderationCaseStatuses.includes(status as ModerationCaseStatus)
)
throw new AuthError('Choose Open, In progress, or Closed.');
const reason = textField(input.reason, 'Status change reason', 4000, status === row.status);
const changes: Record<string, unknown> = {};
if (title !== row.title) changes.title = { from: row.title, to: title };
if (priority !== row.priority) changes.priority = { from: row.priority, to: priority };
if (status !== row.status) changes.status = { from: row.status, to: status };
if (assigneeId !== row.assignee_id) {
const previous = row.assignee_id
? (db().prepare('SELECT username FROM auth_users WHERE id=?').get(row.assignee_id) as
{ username: string } | undefined)
: null;
changes.assignee = { from: previous?.username ?? null, to: assignee?.username ?? null };
changes.assigneeId = { from: row.assignee_id, to: assigneeId };
}
if (!Object.keys(changes).length) throw new AuthError('Change the case details before saving.');
db()
.prepare(
`UPDATE moderation_cases SET title=?,priority=?,assignee_id=?,status=?,updated_at=?,revision=revision+1 WHERE id=?`
)
.run(title, priority, assigneeId, status, now, row.id);
moderationEvent(
actor,
'case.updated',
row,
row.id,
JSON.stringify({ ...changes, ...(reason ? { reason } : {}) }),
now
);
if (assigneeId !== row.assignee_id)
notifyCase(actor.id, row.id, 'assignment', now, row.assignee_id);
return { id: row.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 === 'update-case') return updateCase(actor, input, now);
if (input.op === 'schedule-case-review') {
const row = caseRow(input.id);
checkCaseRevision(row, input.revision);
const schedule = caseReviewSchedule(row, input, now);
if (
row.review_at === schedule.reviewAt &&
row.review_reason === schedule.reason &&
row.review_sanction_id === schedule.restrictionId
)
return { id: row.id };
db()
.prepare(
`UPDATE moderation_cases SET review_at=?,review_reason=?,review_sanction_id=?,
updated_at=?,revision=revision+1 WHERE id=?`
)
.run(schedule.reviewAt, schedule.reason, schedule.restrictionId, now, row.id);
moderationEvent(
actor,
'case.review_scheduled',
row,
row.id,
JSON.stringify(schedule),
now
);
return { id: row.id };
}
if (input.op === 'read-case-updates') {
const row = caseRow(input.id);
readCaseUpdates(actor.id, row.id, input.throughId, now);
return { id: row.id };
}
if (input.op === 'link-case' || input.op === 'unlink-case') {
const row = caseRow(input.id);
checkCaseRevision(row, input.revision);
if (input.op === 'link-case')
linkCases(
actor,
row,
caseRow(input.relatedCaseId),
input.relation,
input.reason,
now
);
else unlinkCases(actor, row, input.relationshipId, input.reason, now);
return { id: row.id };
}
if (input.op === 'assess-case') {
const row = caseRow(input.id);
checkCaseRevision(row, input.revision);
const draft = assessmentDraft(input);
const data = JSON.stringify(draft);
const prior = db()
.prepare(
'SELECT data FROM moderation_case_assessments WHERE case_id=? ORDER BY revision DESC LIMIT 1'
)
.get(row.id) as { data: string } | undefined;
if (prior?.data === data) throw new AuthError('Change the assessment before saving.');
db()
.prepare('INSERT INTO moderation_case_assessments VALUES(?,?,?,?,?,?)')
.run(row.id, row.revision + 1, data, actor.id, actor.username, now);
touchCase(row.id, now);
moderationEvent(
actor,
'case.assessed',
row,
row.id,
JSON.stringify({ assessmentRevision: row.revision + 1, summary: draft.summary }),
now
);
return { id: row.id };
}
if (input.op === 'link-case-action') {
const row = caseRow(input.id);
checkCaseRevision(row, input.revision);
const reference = caseActionReference({ type: input.actionType, id: input.actionId });
const reason = textField(input.reason, 'Link reason', 4000);
recordedCaseAction(row, reference, now);
if (isCaseActionLinked(row.id, reference)) return { id: row.id };
db()
.prepare('INSERT INTO moderation_case_action_links VALUES(?,?,?,?,?)')
.run(row.id, reference.type, reference.id, actor.username, now);
touchCase(row.id, now);
moderationEvent(
actor,
'case.action_linked',
row,
row.id,
JSON.stringify({ actionReference: reference, reason }),
now
);
return { id: row.id };
}
if (input.op === 'case-note') {
const row = caseRow(input.id);
if (input.revision !== undefined) checkCaseRevision(row, input.revision);
const text = textField(input.text, 'Note', 4000);
evidence(actor, row.id, text, 'Staff note', null, now);
touchCase(row.id, now);
moderationEvent(actor, 'case.note', row, row.id, text, now);
notifyCase(actor.id, row.id, 'evidence', now);
return { id: row.id };
}
// Older clients may still record decisions, but every closure now stores only Closed.
if (input.op === 'resolve-case') {
const row = caseRow(input.id);
if (row.revision !== input.revision || !isModerationCaseOpen(row.status))
throw new AuthError('This case changed. Refresh before resolving it.', 409);
const outcome = moderationCaseOutcomes.find((status) => status === input.status);
if (!outcome) throw new AuthError('Choose a case outcome.');
const reason = textField(input.reason, 'Decision reason', 4000);
const reference =
input.actionReference === undefined || input.actionReference === null
? null
: caseActionReference(input.actionReference);
if (reference) {
const action = recordedCaseAction(row, reference, now);
if (!isCaseActionLinked(row.id, reference))
throw new AuthError(
'Link the recorded action to this case before citing it.',
409
);
if (
outcome === 'warning_issued' &&
(action.type !== 'action' ||
action.kind !== 'warn' ||
action.state !== 'confirmed')
)
throw new AuthError('Warning issued must reference a confirmed warning.');
if (outcome === 'restriction_applied' && action.type !== 'restriction')
throw new AuthError(
'Restriction applied must reference a recorded restriction.'
);
}
evidence(actor, row.id, reason, moderationCaseStatusLabels[outcome], null, now, true);
db()
.prepare(
'UPDATE moderation_cases SET status=?,updated_at=?,revision=revision+1 WHERE id=?'
)
.run('closed', now, row.id);
moderationEvent(
actor,
`case.${outcome}`,
row,
row.id,
reference ? JSON.stringify({ reason, actionReference: reference }) : reason,
now
);
return { id: row.id };
}
if (input.op === 'reopen-case') {
const row = caseRow(input.id);
checkCaseRevision(row, input.revision);
if (isModerationCaseOpen(row.status))
throw new AuthError('This case is already active.', 409);
const reason = textField(input.reason, 'Reopening reason', 4000);
evidence(actor, row.id, reason, 'Reopened', null, now, true);
db()
.prepare(
"UPDATE moderation_cases SET status='open',updated_at=?,revision=revision+1 WHERE id=?"
)
.run(now, row.id);
moderationEvent(
actor,
'case.reopened',
row,
row.id,
JSON.stringify({ from: row.status, reason }),
now
);
return { id: row.id };
}
if (input.op === 'attach-evidence') {
const row = caseRow(input.id);
checkCaseRevision(row, input.revision);
const count = archivedEvidence(actor, row.id, row, input.evidence, now);
if (!count) return { id: row.id };
touchCase(row.id, now);
moderationEvent(
actor,
'case.evidence_attached',
row,
row.id,
JSON.stringify({ count, matchId: (input.evidence as { matchId: number }).matchId }),
now
);
notifyCase(actor.id, row.id, 'evidence', 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.');
});
}
+63
View File
@@ -0,0 +1,63 @@
import {
moderationConcernCategories,
moderationFindingOutcomes,
type ModerationCaseAssessment
} from '../../moderation.js';
import { AuthError } from '../auth/index.js';
import { db } from '../db/index.js';
import { textField } from './core.js';
type AssessmentDraft = Pick<ModerationCaseAssessment, 'summary' | 'rules' | 'findings'>;
export function assessmentDraft(input: Record<string, unknown>): AssessmentDraft {
const summary = textField(input.summary, 'Assessment summary', 2000);
if (!Array.isArray(input.rules) || input.rules.length > 20)
throw new AuthError('Choose up to 20 applicable rules.');
const rules = input.rules.map((rule) => textField(rule, 'Applicable rule', 160));
if (
!Array.isArray(input.findings) ||
!input.findings.length ||
input.findings.length > moderationConcernCategories.length
)
throw new AuthError('Record between 1 and 7 findings, one per concern category.');
const categories = new Set<string>();
const findings = input.findings.map((value): AssessmentDraft['findings'][number] => {
if (!value || typeof value !== 'object')
throw new AuthError('Choose a concern category and finding.');
const category = moderationConcernCategories.find(
(category) => category === value.category
);
const finding = moderationFindingOutcomes.find((finding) => finding === value.finding);
if (!category || !finding)
throw new AuthError('Choose a known concern category and finding.');
if (categories.has(category))
throw new AuthError('Record each concern category only once.');
categories.add(category);
return { category, finding, detail: textField(value.detail, 'Finding detail', 1000) };
});
return { summary, rules, findings };
}
export function caseAssessmentHistory(caseId: string): ModerationCaseAssessment[] {
return (
db()
.prepare(
`SELECT revision,data,actor,created_at AS createdAt
FROM moderation_case_assessments WHERE case_id=? ORDER BY revision DESC`
)
.all(caseId) as { revision: number; data: string; actor: string; createdAt: string }[]
).map(({ data, ...revision }) => ({ ...JSON.parse(data), ...revision }));
}
export function currentCaseAssessment(caseId: string): ModerationCaseAssessment | null {
const row = db()
.prepare(
`SELECT revision,data,actor,created_at AS createdAt
FROM moderation_case_assessments WHERE case_id=? ORDER BY revision DESC LIMIT 1`
)
.get(caseId) as
{ revision: number; data: string; actor: string; createdAt: string } | undefined;
if (!row) return null;
const { data, ...revision } = row;
return { ...JSON.parse(data), ...revision };
}
+92
View File
@@ -0,0 +1,92 @@
import { db } from '../db/index.js';
import { AuthError } from '../auth/index.js';
import { textField } from './core.js';
import type { ModerationCaseAction, ModerationGame } from '../../moderation.js';
type Subject = { game: ModerationGame; guid: string };
export type CaseActionReference = Pick<ModerationCaseAction, 'type' | 'id'>;
export function caseActionReference(value: unknown): CaseActionReference {
if (!value || typeof value !== 'object') throw new AuthError('Choose a recorded action.');
const reference = value as Record<string, unknown>;
if (reference.type !== 'action' && reference.type !== 'restriction')
throw new AuthError('Choose a recorded warning, action, or restriction.');
return { type: reference.type, id: textField(reference.id, 'Action ID', 128) };
}
const restrictionColumns = `id,'restriction' AS type,kind,reason,actor,
created_at AS createdAt,expires_at AS expiresAt,
CASE WHEN revoked_at IS NOT NULL THEN 'revoked'
WHEN expires_at IS NOT NULL AND expires_at<=? THEN 'expired' ELSE 'active' END AS state`;
const actionColumns = `id,'action' AS type,kind,reason,actor,
created_at AS createdAt,expires_at AS expiresAt,state`;
/** Only actual records for this account can become case evidence. */
export function recordedCaseAction(
subject: Subject,
reference: CaseActionReference,
now: string
): ModerationCaseAction {
const handle = db();
const action =
reference.type === 'restriction'
? handle
.prepare(
`SELECT ${restrictionColumns} FROM moderation_sanctions
WHERE id=? AND guid=? AND (game=? OR scope='fleet')`
)
.get(now, reference.id, subject.guid, subject.game)
: handle
.prepare(
`SELECT ${actionColumns} FROM moderation_actions
WHERE id=? AND guid=? AND game=?`
)
.get(reference.id, subject.guid, subject.game);
if (!action)
throw new AuthError('The recorded action is unavailable for this case’s player.', 404);
return action as ModerationCaseAction;
}
export function isCaseActionLinked(caseId: string, reference: CaseActionReference): boolean {
return Boolean(
db()
.prepare(
`SELECT 1 FROM moderation_case_action_links
WHERE case_id=? AND action_type=? AND action_id=?
UNION ALL SELECT 1 FROM moderation_sanctions
WHERE ?='restriction' AND id=? AND case_id=? LIMIT 1`
)
.get(caseId, reference.type, reference.id, reference.type, reference.id, caseId)
);
}
export function caseActionLists(caseId: string, subject: Subject, now: string) {
const handle = db();
const records = handle
.prepare(
`SELECT ${restrictionColumns},
(case_id=? OR EXISTS (SELECT 1 FROM moderation_case_action_links l
WHERE l.case_id=? AND l.action_type='restriction' AND l.action_id=moderation_sanctions.id)) AS linked
FROM moderation_sanctions WHERE guid=? AND (game=? OR scope='fleet')
UNION ALL SELECT ${actionColumns},
EXISTS (SELECT 1 FROM moderation_case_action_links l
WHERE l.case_id=? AND l.action_type='action' AND l.action_id=moderation_actions.id) AS linked
FROM moderation_actions WHERE guid=? AND game=?
ORDER BY createdAt DESC,id`
)
.all(
now,
caseId,
caseId,
subject.guid,
subject.game,
caseId,
subject.guid,
subject.game
) as Array<ModerationCaseAction & { linked: number | null }>;
const linkedActions: ModerationCaseAction[] = [];
const availableActions: ModerationCaseAction[] = [];
for (const { linked, ...record } of records)
(linked ? linkedActions : availableActions).push(record);
return { linkedActions, availableActions };
}
+101
View File
@@ -0,0 +1,101 @@
import { db } from '../db/index.js';
import { AuthError } from '../auth/index.js';
import { moderationActor, textField } from './core.js';
import { moderationOverview } from './overview.js';
import { caseActionLists } from './case-actions.js';
import { caseAssessmentHistory } from './assessments.js';
import { relatedCases, candidateCases } from './relationships.js';
import { caseNotifications, unreadCaseUpdates } from './followups.js';
import type {
ModerationCase,
ModerationCaseDetail,
ModerationEvent,
ModerationSanction
} from '../../moderation.js';
/** A case owns its evidence and timeline independently of the paginated queues. */
export function moderationCaseDetail(actorId: string, id: string): ModerationCaseDetail {
moderationActor(actorId);
const caseId = textField(id, 'Case ID', 128);
return db().transaction(() => {
const handle = db();
const row = handle
.prepare(
`SELECT c.id,c.game,c.guid,c.title,c.status,c.priority,
c.assignee_id AS assigneeId,u.username AS assignee,c.actor,c.created_at AS createdAt,
c.updated_at AS updatedAt,c.revision,c.review_at AS reviewAt,
c.review_reason AS reviewReason,c.review_sanction_id AS reviewSanctionId FROM moderation_cases c
LEFT JOIN auth_users u ON u.id=c.assignee_id WHERE c.id=?`
)
.get(caseId) as Omit<ModerationCase, 'evidence'> | undefined;
if (!row) throw new AuthError('Case not found.', 404);
const overview = moderationOverview(actorId, {
view: 'players',
q: '',
game: row.game,
guid: row.guid,
offset: 0
});
const evidence = handle
.prepare(
`SELECT id,text,source,snapshot,actor,created_at AS createdAt
FROM moderation_evidence WHERE case_id=? ORDER BY created_at,rowid`
)
.all(caseId) as ModerationCase['evidence'];
const sanctions = (
handle
.prepare(
`SELECT id,case_id AS caseId,game,guid,kind,scope,server,reason,note,
created_at AS createdAt,expires_at AS expiresAt,revoked_at AS revokedAt,actor_id AS actorId,actor,revision
FROM moderation_sanctions WHERE case_id=? OR id IN
(SELECT action_id FROM moderation_case_action_links WHERE case_id=? AND action_type='restriction')
ORDER BY created_at DESC,id`
)
.all(caseId, caseId) as Omit<ModerationSanction, 'active'>[]
).map((restriction) => ({
...restriction,
active:
!restriction.revokedAt &&
(!restriction.expiresAt || restriction.expiresAt > overview.checkedAt)
}));
const events = handle
.prepare(
`SELECT id,game,guid,action,subject_id AS subjectId,actor,detail,created_at AS createdAt
FROM moderation_events WHERE subject_id=? OR subject_id IN
(SELECT id FROM moderation_sanctions WHERE case_id=?) OR subject_id IN
(SELECT action_id FROM moderation_case_action_links WHERE case_id=?) ORDER BY id DESC`
)
.all(caseId, caseId, caseId) as ModerationEvent[];
const assignees = handle
.prepare(
`SELECT id,username FROM auth_users WHERE role IN ('admin','moderator')
AND disabled=0 AND must_change_password=0 ORDER BY username COLLATE NOCASE,id`
)
.all() as ModerationCaseDetail['assignees'];
const assessmentHistory = caseAssessmentHistory(caseId);
return {
case: { ...row, evidence, unreadUpdates: unreadCaseUpdates(actorId, caseId) },
player: overview.players[0] ?? {
game: row.game,
guid: row.guid,
name: row.guid,
aliases: [],
servers: [],
restrictions: 0,
firstSeen: null,
lastSeen: null
},
sanctions,
...caseActionLists(caseId, row, overview.checkedAt),
assessment: assessmentHistory[0] ?? null,
assessmentHistory,
relatedCases: relatedCases(caseId),
candidateCases: candidateCases(row),
notifications: caseNotifications(actorId, caseId),
events,
coverage: overview.coverage,
assignees,
checkedAt: overview.checkedAt
};
})();
}
@@ -0,0 +1,49 @@
import { config } from '../config.js';
import { MODERATION_CONFIG_FILE } from '../fleet/private-config.js';
import { AuthError } from '../auth/index.js';
export const moderationConfigureCapability = 'moderation.configure';
export interface ModerationConfiguration {
token: string;
configPath: string;
url: string;
generation: number;
}
/** The game connects to the public controller, never an agent's private enrollment URL. */
export function moderationRuntimeUrl(): string {
let base: URL;
try {
base = new URL(config.publicUrl);
} catch {
throw new AuthError(
'Set EC_PUBLIC_URL to the public HTTPS controller URL before configuring moderation.',
409
);
}
if (
base.protocol !== 'https:' ||
base.username ||
base.password ||
base.search ||
base.hash ||
base.pathname !== '/'
)
throw new AuthError(
'EC_PUBLIC_URL must be a public HTTPS origin without a path, credentials, query or fragment.',
409
);
return new URL('/api/moderation/runtime', base).href;
}
export function moderationConfiguration(
token: string,
generation: number
): ModerationConfiguration {
return {
token,
generation,
configPath: `configs/${MODERATION_CONFIG_FILE}`,
url: moderationRuntimeUrl()
};
}
+54
View File
@@ -0,0 +1,54 @@
import { AuthError } from '../auth/index.js';
import { nativeInstance } from '../backends/native.js';
import { db } from '../db/index.js';
import { getHost, queueHostOperation } from '../hosts/index.js';
import { moderationActor, moderationEvent } from './core.js';
import { moderationCredential } from './credentials.js';
import { moderationConfiguration, moderationConfigureCapability } from './configuration.js';
/** Persist a private, generation-bound bootstrap without exposing the credential to the browser. */
export function configureModerationServer(actorId: string, server: string): { id: string } {
return db()
.transaction(() => {
const actor = moderationActor(actorId, true);
if (actor.role !== 'admin')
throw new AuthError(
'Administrator access is required to configure game moderation.',
403
);
const instance = nativeInstance(server);
const live = db()
.prepare('SELECT destroyed_at FROM servers WHERE name=?')
.get(server) as { destroyed_at: string | null } | undefined;
if (!instance || !live || live.destroyed_at)
throw new AuthError('Choose an active native server.', 404);
const host = getHost(instance.host_id);
if (!host?.inventory.capabilities?.includes(moderationConfigureCapability))
throw new AuthError(
'Update the host agent before configuring game moderation.',
409
);
const moderation = moderationConfiguration(
moderationCredential(instance.id, instance.generation),
instance.generation
);
const operation = queueHostOperation(
instance.host_id,
'instance.configure-moderation',
{ moderation },
{
instanceId: instance.id,
generation: instance.generation
}
);
moderationEvent(
actor,
'runtime.configure',
{},
operation.id,
`Configure game moderation on ${server}.`
);
return { id: operation.id };
})
.immediate();
}
+129
View File
@@ -0,0 +1,129 @@
import { createHash } from 'node:crypto';
import type Database from 'better-sqlite3';
import { db } from '../db/index.js';
import { getUser, AuthError } from '../auth/index.js';
import { canModerate, canReadModeration, type AuthUser } from '../../auth.js';
import { moderationGuid, type ModerationGame } from '../../moderation.js';
export const moderationNow = () => new Date().toISOString();
export function moderationActor(id: string, write = false): AuthUser {
const user = getUser(id);
if (
!user ||
user.disabled ||
user.mustChangePassword ||
!(write ? canModerate(user.role) : canReadModeration(user.role))
)
throw new AuthError('Moderation access is required.', 403);
return user;
}
export function textField(value: unknown, label: string, maximum = 500, optional = false): string {
if (optional && (value === undefined || value === null || value === '')) return '';
if (
typeof value !== 'string' ||
!value.trim() ||
value.length > maximum ||
// eslint-disable-next-line no-control-regex -- Bound all user-controlled moderation text.
/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/.test(value)
)
throw new AuthError(`${label} must contain 1–${maximum} printable characters.`);
return value.trim();
}
export function subject(input: Record<string, unknown>): { game: ModerationGame; guid: string } {
if (input.game !== 't5' && input.game !== 't6')
throw new AuthError('Choose Black Ops I or Black Ops II.');
const raw = textField(input.guid, 'Player GUID', 128);
if (!/^[A-Za-z0-9:_-]+$/.test(raw)) throw new AuthError('Invalid player GUID.');
return { game: input.game, guid: moderationGuid(input.game, raw) };
}
export function observeModerationPlayers(
handle: Database.Database,
game: ModerationGame,
players: { guid: string; name: string }[],
now: string
): void {
const upsert = handle.prepare(`INSERT INTO moderation_players VALUES(?,?,?,?,?)
ON CONFLICT(game,guid) DO UPDATE SET name=CASE WHEN excluded.last_seen_at>=last_seen_at THEN excluded.name ELSE name END,
first_seen_at=CASE WHEN first_seen_at='' THEN excluded.first_seen_at ELSE MIN(first_seen_at,excluded.first_seen_at) END,last_seen_at=MAX(last_seen_at,excluded.last_seen_at)`);
const aliases = handle.prepare(`INSERT INTO moderation_aliases VALUES(?,?,?,?,?)
ON CONFLICT(game,guid,name) DO UPDATE SET first_seen_at=MIN(first_seen_at,excluded.first_seen_at),last_seen_at=MAX(last_seen_at,excluded.last_seen_at)`);
for (const player of players) {
const values = [game, moderationGuid(game, player.guid), player.name, now, now];
upsert.run(...values);
aliases.run(...values);
}
}
export function ensureSubject(game: ModerationGame, guid: string): void {
db()
.prepare('INSERT INTO moderation_players VALUES(?,?,?,?,?) ON CONFLICT DO NOTHING')
.run(game, guid, guid, '', '');
}
export function moderationEvent(
actor: Pick<AuthUser, 'id' | 'username'> | null,
action: string,
target: { game?: string | null; guid?: string | null },
subjectId: string | null,
detail: string,
now = moderationNow()
): void {
db()
.prepare(
`INSERT INTO moderation_events(game,guid,actor_id,actor,action,subject_id,detail,created_at) VALUES(?,?,?,?,?,?,?,?)`
)
.run(
target.game ?? null,
target.guid ?? null,
actor?.id ?? null,
actor?.username ?? 'Game server',
action,
subjectId,
detail,
now
);
}
export function policyRevision(): number {
return (
db().prepare('SELECT revision FROM moderation_policy WHERE id=1').get() as {
revision: number;
}
).revision;
}
export function advancePolicy(): void {
db().prepare('UPDATE moderation_policy SET revision=revision+1 WHERE id=1').run();
}
/** The request and its durable result commit atomically, including repeated browser submissions. */
export function moderationMutation(
actorId: string,
input: Record<string, unknown>,
work: (actor: AuthUser, now: string) => { id: string }
): { ok: true; id: string } {
const key = textField(input.idempotencyKey, 'Request identifier', 128);
const canonical = JSON.stringify(
Object.fromEntries(Object.entries(input).sort(([a], [b]) => a.localeCompare(b)))
);
const fingerprint = createHash('sha256').update(canonical).digest('hex');
return db()
.transaction(() => {
const actor = moderationActor(actorId, true);
const prior = db()
.prepare(
'SELECT fingerprint,result FROM moderation_requests WHERE actor_id=? AND request_key=?'
)
.get(actor.id, key) as { fingerprint: string; result: string } | undefined;
if (prior) {
if (prior.fingerprint !== fingerprint)
throw new AuthError(
'This request identifier was already used for a different action.',
409
);
return JSON.parse(prior.result) as { ok: true; id: string };
}
const now = moderationNow();
const result = { ok: true as const, ...work(actor, now) };
db()
.prepare('INSERT INTO moderation_requests VALUES(?,?,?,?,?)')
.run(actor.id, key, fingerprint, JSON.stringify(result), now);
return result;
})
.immediate();
}
+55
View File
@@ -0,0 +1,55 @@
import { createHash, randomBytes } from 'node:crypto';
import { db } from '../db/index.js';
import type { ModerationGame } from '../../moderation.js';
const digest = (value: string) => createHash('sha256').update(value).digest('hex');
export function moderationCredential(instanceId: string, generation: number): string {
return db()
.transaction(() => {
if (
!Number.isSafeInteger(generation) ||
!db()
.prepare(
'SELECT 1 FROM native_instances WHERE id=? AND generation IN (?,?) AND retired_at IS NULL'
)
.get(instanceId, generation, generation - 1)
)
throw new Error('Moderation credentials require an active instance generation.');
const existing = db()
.prepare(
'SELECT token FROM moderation_credentials WHERE instance_id=? AND generation=?'
)
.get(instanceId, generation) as { token: string } | undefined;
if (existing) return existing.token;
const token = 'ec_moderation_' + randomBytes(32).toString('base64url');
db()
.prepare('INSERT INTO moderation_credentials VALUES(?,?,?,?,?)')
.run(instanceId, generation, token, digest(token), new Date().toISOString());
return token;
})
.immediate();
}
export interface ModerationRuntimeIdentity {
instanceId: string;
generation: number;
server: string;
game: ModerationGame;
serverCreatedAt: string;
credentialHash: string;
}
export function authenticateModerationRuntime(request: Request): ModerationRuntimeIdentity | null {
const token = request.headers
.get('authorization')
?.match(/^Bearer (ec_moderation_[A-Za-z0-9_-]{43})$/)?.[1];
if (!token) return null;
return (
(db()
.prepare(
`SELECT n.id AS instanceId,n.generation,n.server,s.game,s.created_at AS serverCreatedAt,c.token_hash AS credentialHash
FROM moderation_credentials c JOIN native_instances n ON n.id=c.instance_id AND n.generation=c.generation
JOIN servers s ON s.name=n.server
WHERE c.token_hash=? AND n.retired_at IS NULL AND s.destroyed_at IS NULL AND s.game IN ('t5','t6')`
)
.get(digest(token)) as ModerationRuntimeIdentity | undefined) ?? null
);
}
+128
View File
@@ -0,0 +1,128 @@
import { db } from '../db/index.js';
import { AuthError } from '../auth/index.js';
import { matchChatTimeline } from '../fleet/chat.js';
import { moderationGuid, type ModerationGame } from '../../moderation.js';
import type { ContextualChatEntry } from '../../game-chat.js';
import { textField } from './core.js';
type Message = Extract<ContextualChatEntry, { type: 'message' }>;
/** Verify against the archive, then preserve independent excerpts across all participants. */
export function archivedEvidenceSnapshots(
caseId: string,
target: { game: ModerationGame; guid: string },
value: unknown
) {
const requested = value as Record<string, unknown> | null;
if (!requested || !Number.isSafeInteger(requested.matchId))
throw new AuthError('Choose a recorded chat message.');
const matchId = requested.matchId as number;
const match = db()
.prepare('SELECT game,server,started_at FROM leaderboard_matches WHERE id=?')
.get(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 = matchChatTimeline(matchId).filter(
(entry): entry is Message => entry.type === 'message'
);
const byId = new Map(entries.map((entry) => [entry.messageId!, entry]));
const isSubject = (entry: Message) =>
moderationGuid(target.game, entry.message.guid) === target.guid;
const legacy = requested.messageIds === undefined;
let selected: Message[];
if (legacy) {
textField(requested.message, 'Chat message', 4096);
selected = entries.filter(
(entry) => isSubject(entry) && entry.message.message === requested.message
);
} else {
if (
!Array.isArray(requested.messageIds) ||
!requested.messageIds.length ||
requested.messageIds.length > 100 ||
requested.messageIds.some((id) => typeof id !== 'string' || !byId.has(id))
)
throw new AuthError('Select between 1 and 100 archived message identifiers.');
const ids = new Set(requested.messageIds);
selected = entries.filter((entry) => ids.has(entry.messageId));
}
if (!selected.length || !selected.some(isSubject))
throw new AuthError(
'The original chat message could not be verified in the archive. Select a message from the case subject.',
409
);
const radius = (key: string) => {
const count = requested[key] ?? 3;
if (!Number.isSafeInteger(count) || (count as number) < 0 || (count as number) > 20)
throw new AuthError('Choose between 0 and 20 surrounding messages.');
return count as number;
};
const before = radius('contextBefore'),
after = radius('contextAfter');
const attached = new Set<string>();
const prior = db()
.prepare(
'SELECT snapshot FROM moderation_evidence WHERE case_id=? AND snapshot IS NOT NULL'
)
.all(caseId) as { snapshot: string }[];
for (const row of prior) {
try {
const snapshot = JSON.parse(row.snapshot);
if (snapshot.matchId !== matchId) continue;
if (Array.isArray(snapshot.selectedMessageIds)) {
for (const id of snapshot.selectedMessageIds)
if (typeof id === 'string') attached.add(id);
} else if (snapshot.entry?.type === 'message') {
// Older evidence predates IDs. Match each occurrence once without changing history.
const old = snapshot.entry.message;
const original = entries.find(
(entry) =>
!attached.has(entry.messageId!) &&
entry.message.guid === old.guid &&
entry.message.time === old.time &&
entry.message.player === old.player &&
entry.message.channel === old.channel &&
entry.message.message === old.message
);
if (original) attached.add(original.messageId!);
}
} catch {
/* Keep malformed legacy snapshots readable without trusting them as identities. */
}
}
const fresh = selected.filter((entry) => !attached.has(entry.messageId!));
if (!fresh.length) return [];
// Keep the original selected subject as context when only a newly selected reply is fresh.
const groups = legacy ? fresh.map((entry) => [entry]) : [selected];
return groups.map((group) => {
const indexes = new Set<number>();
for (const entry of group) {
const index = entries.indexOf(entry);
for (
let i = Math.max(0, index - before);
i <= Math.min(entries.length - 1, index + after);
i++
)
indexes.add(i);
}
const primary = group.find(isSubject)!;
return {
text: group
.filter(isSubject)
.map((entry) => entry.message.message)
.join('\n'),
source: `Archived chat · ${match.server} · match ${matchId}`,
snapshot: JSON.stringify({
version: 2,
matchId,
match,
entry: primary,
entries: [...indexes].sort((a, b) => a - b).map((index) => entries[index]),
selectedMessageIds: group
.filter((entry) => !attached.has(entry.messageId!))
.map((entry) => entry.messageId),
highlightedMessageIds: group.filter(isSubject).map((entry) => entry.messageId)
})
};
});
}
+94
View File
@@ -0,0 +1,94 @@
import { db } from '../db/index.js';
import { AuthError } from '../auth/index.js';
import type { ModerationCaseNotification, ModerationGame } from '../../moderation.js';
import { textField } from './core.js';
import { recordedCaseAction, isCaseActionLinked } from './case-actions.js';
/** Called once per accepted mutation, within its existing audit transaction. */
export function notifyCase(
actorId: string,
caseId: string,
kind: ModerationCaseNotification['kind'],
now: string,
previousAssignee: string | null = null
) {
db()
.prepare(
`INSERT INTO moderation_case_notifications(user_id,case_id,kind,created_at)
SELECT u.id,?,?,? FROM auth_users u JOIN moderation_cases c ON c.id=?
WHERE (u.id=c.actor_id OR u.id=c.assignee_id OR u.id=?) AND u.id<>?
AND u.role IN ('admin','moderator') AND u.disabled=0 AND u.must_change_password=0`
)
.run(caseId, kind, now, caseId, previousAssignee, actorId);
}
export function caseNotifications(actorId: string, caseId?: string): ModerationCaseNotification[] {
return db()
.prepare(
`SELECT n.id,n.case_id AS caseId,c.title AS caseTitle,n.kind,
n.created_at AS createdAt,n.read_at AS readAt FROM moderation_case_notifications n
JOIN moderation_cases c ON c.id=n.case_id WHERE n.user_id=?
${caseId === undefined ? 'AND n.read_at IS NULL' : 'AND n.case_id=?'} ORDER BY n.id DESC LIMIT 25`
)
.all(
...(caseId === undefined ? [actorId] : [actorId, caseId])
) as ModerationCaseNotification[];
}
export function unreadCaseUpdates(actorId: string, caseId?: string): number {
return (
db()
.prepare(
`SELECT COUNT(*) AS count FROM moderation_case_notifications
WHERE user_id=? AND read_at IS NULL${caseId === undefined ? '' : ' AND case_id=?'}`
)
.get(...(caseId === undefined ? [actorId] : [actorId, caseId])) as { count: number }
).count;
}
export function readCaseUpdates(actorId: string, caseId: string, throughId: unknown, now: string) {
if (!Number.isSafeInteger(throughId) || (throughId as number) < 0)
throw new AuthError('Choose the last update you have reviewed.');
db()
.prepare(
`UPDATE moderation_case_notifications SET read_at=?
WHERE user_id=? AND case_id=? AND id<=? AND read_at IS NULL`
)
.run(now, actorId, caseId, throughId);
}
function canonicalDate(value: unknown): string {
if (
typeof value !== 'string' ||
!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(value) ||
!Number.isFinite(Date.parse(value)) ||
new Date(value).toISOString() !== value
)
throw new AuthError('Choose a valid review date and time.');
return value;
}
export function caseReviewSchedule(
subject: { id: string; game: ModerationGame; guid: string },
input: Record<string, unknown>,
now: string
) {
const reason = textField(input.reason, 'Review reason', 4000);
if ((input.reviewAt === undefined) === (input.restrictionId === undefined))
throw new AuthError('Choose either a review date or a linked mute.');
if (input.restrictionId !== undefined) {
const id = textField(input.restrictionId, 'Restriction ID', 128);
const reference = { type: 'restriction' as const, id };
const restriction = recordedCaseAction(subject, reference, now);
if (!isCaseActionLinked(subject.id, reference))
throw new AuthError('Link the mute to this case before scheduling its review.', 409);
if (restriction.kind !== 'mute' || !restriction.expiresAt)
throw new AuthError('Choose a linked mute with a recorded expiry time.');
return { reviewAt: canonicalDate(restriction.expiresAt), reason, restrictionId: id };
}
return {
reviewAt: input.reviewAt === null ? null : canonicalDate(input.reviewAt),
reason,
restrictionId: null
};
}
+265
View File
@@ -0,0 +1,265 @@
import { db } from '../db/index.js';
import { currentCaseAssessment } from './assessments.js';
import { caseNotifications, unreadCaseUpdates } from './followups.js';
import { moderationActor, moderationNow, policyRevision } from './core.js';
import { runtimeFreshMs } from './actions.js';
import { refreshActionStates } from './runtime.js';
import {
moderationCaseStatuses,
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);
const caseStatus = params.get('caseStatus') ?? '';
const priority = params.get('priority') ?? '';
const review = params.get('review') ?? '';
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,
caseStatus: ['active', 'closed', ...moderationCaseStatuses].includes(caseStatus)
? (caseStatus as ModerationQuery['caseStatus'])
: '',
priority: ['low', 'normal', 'high', 'urgent'].includes(priority)
? (priority as ModerationQuery['priority'])
: '',
assignedTo: (params.get('assignedTo') ?? '').slice(0, 128),
review: ['due', 'scheduled', 'updates'].includes(review)
? (review as ModerationQuery['review'])
: ''
};
}
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,
notifications: caseNotifications(actorId),
unreadCount: unreadCaseUpdates(actorId)
};
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.view === 'cases') {
if (query.caseStatus === 'active' || query.caseStatus === 'closed') {
where.push(
`p.status ${query.caseStatus === 'closed' ? 'NOT ' : ''}IN ('open','in_review')`
);
} else if (query.caseStatus) {
where.push('p.status=?');
values.push(query.caseStatus);
}
if (query.priority) {
where.push('p.priority=?');
values.push(query.priority);
}
if (query.assignedTo === 'unassigned') where.push('p.assignee_id IS NULL');
else if (query.assignedTo) {
where.push('p.assignee_id=?');
values.push(query.assignedTo === 'me' ? actorId : query.assignedTo);
}
if (query.review === 'due') {
where.push('p.review_at IS NOT NULL AND p.review_at<=?');
values.push(now);
} else if (query.review === 'scheduled') where.push('p.review_at IS NOT NULL');
else if (query.review === 'updates') {
where.push(
'EXISTS (SELECT 1 FROM moderation_case_notifications n WHERE n.case_id=p.id AND n.user_id=? AND n.read_at IS NULL)'
);
values.push(actorId);
}
}
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,
orderValues: 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, ...orderValues, 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.case_id AS caseId',
'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.priority,p.assignee_id AS assigneeId,(SELECT username FROM auth_users WHERE id=p.assignee_id) AS assignee,p.actor,p.created_at AS createdAt,p.updated_at AS updatedAt,p.revision,p.review_at AS reviewAt,p.review_reason AS reviewReason,p.review_sanction_id AS reviewSanctionId',
"CASE WHEN p.review_at<=? THEN 0 ELSE 1 END,CASE WHEN p.review_at<=? THEN p.review_at END,CASE WHEN p.status IN ('open','in_review') THEN 0 ELSE 1 END,CASE p.priority WHEN 'urgent' THEN 0 WHEN 'high' THEN 1 WHEN 'normal' THEN 2 ELSE 3 END,p.updated_at DESC,p.id",
[now, now]
).map((c) => ({
...c,
assessment: currentCaseAssessment(c.id),
unreadUpdates: unreadCaseUpdates(actorId, c.id),
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;
})();
}
+212
View File
@@ -0,0 +1,212 @@
import { randomUUID } from 'node:crypto';
import type { AuthUser } from '../../auth.js';
import {
moderationCaseRelations,
moderationGuid,
type ModerationCaseCandidate,
type ModerationCaseReference,
type ModerationCaseRelation,
type ModerationCaseRelationship
} from '../../moderation.js';
import { AuthError } from '../auth/index.js';
import { db } from '../db/index.js';
import { moderationEvent, textField } from './core.js';
type RelationshipRow = {
id: string;
case_id: string;
target_case_id: string;
relation: ModerationCaseRelation;
removed_at: string | null;
};
function relationAudit(
actor: AuthUser,
relationship: RelationshipRow,
cases: ModerationCaseReference[],
action: 'case.linked' | 'case.unlinked',
reason: string,
now: string
) {
db()
.prepare('UPDATE moderation_cases SET updated_at=?,revision=revision+1 WHERE id IN (?,?)')
.run(now, relationship.case_id, relationship.target_case_id);
for (const item of cases) {
const outgoing = item.id === relationship.case_id;
moderationEvent(
actor,
action,
item,
item.id,
JSON.stringify({
relationshipId: relationship.id,
relation: relationship.relation,
direction: outgoing ? 'outgoing' : 'incoming',
relatedCaseId: outgoing ? relationship.target_case_id : relationship.case_id,
reason
}),
now
);
}
}
export function linkCases(
actor: AuthUser,
source: ModerationCaseReference,
target: ModerationCaseReference,
value: unknown,
reasonValue: unknown,
now: string
) {
const relation = moderationCaseRelations.find((relation) => relation === value);
if (!relation) throw new AuthError('Choose same incident, appeal of, or duplicate of.');
const reason = textField(reasonValue, 'Relationship reason', 4000);
if (source.id === target.id) throw new AuthError('A case cannot be related to itself.');
if (
relation !== 'same_incident' &&
(source.game !== target.game || source.guid !== target.guid)
)
throw new AuthError('Appeal and duplicate relationships require the same game and player.');
const [from, to] =
relation === 'same_incident' && source.id > target.id ? [target, source] : [source, target];
const handle = db();
if (
handle
.prepare(
`SELECT 1 FROM moderation_case_relationships
WHERE case_id=? AND target_case_id=? AND relation=? AND removed_at IS NULL`
)
.get(from.id, to.id, relation)
)
return;
if (
relation !== 'same_incident' &&
handle
.prepare(
`WITH RECURSIVE descendants(id) AS (
SELECT target_case_id FROM moderation_case_relationships
WHERE case_id=? AND relation=? AND removed_at IS NULL
UNION
SELECT r.target_case_id FROM moderation_case_relationships r
JOIN descendants d ON r.case_id=d.id WHERE r.relation=? AND r.removed_at IS NULL
) SELECT 1 FROM descendants WHERE id=? LIMIT 1`
)
.get(to.id, relation, relation, from.id)
)
throw new AuthError(
'This relationship would create a cycle. Review the linked cases.',
409
);
const relationship: RelationshipRow = {
id: randomUUID(),
case_id: from.id,
target_case_id: to.id,
relation,
removed_at: null
};
handle
.prepare('INSERT INTO moderation_case_relationships VALUES(?,?,?,?,?,?,?,NULL)')
.run(relationship.id, from.id, to.id, relation, actor.id, actor.username, now);
relationAudit(actor, relationship, [from, to], 'case.linked', reason, now);
}
export function unlinkCases(
actor: AuthUser,
source: ModerationCaseReference,
value: unknown,
reasonValue: unknown,
now: string
) {
const id = textField(value, 'Relationship ID', 128);
const reason = textField(reasonValue, 'Removal reason', 4000);
const handle = db();
const relationship = handle
.prepare(
`SELECT id,case_id,target_case_id,relation,removed_at
FROM moderation_case_relationships WHERE id=? AND (case_id=? OR target_case_id=?)`
)
.get(id, source.id, source.id) as RelationshipRow | undefined;
if (!relationship) throw new AuthError('The case relationship is unavailable.', 404);
if (relationship.removed_at) return;
const otherId =
relationship.case_id === source.id ? relationship.target_case_id : relationship.case_id;
const other = handle
.prepare('SELECT id,title,status,game,guid FROM moderation_cases WHERE id=?')
.get(otherId) as ModerationCaseReference;
handle.prepare('UPDATE moderation_case_relationships SET removed_at=? WHERE id=?').run(now, id);
relationAudit(actor, relationship, [source, other], 'case.unlinked', reason, now);
}
/** Relationships remain readable from both endpoints, with the original direction intact. */
export function relatedCases(caseId: string): ModerationCaseRelationship[] {
const rows = db()
.prepare(
`SELECT r.id,r.relation,r.actor,r.created_at AS createdAt,
CASE WHEN r.case_id=? THEN 'outgoing' ELSE 'incoming' END AS direction,
c.id AS otherId,c.title,c.status,c.game,c.guid
FROM moderation_case_relationships r JOIN moderation_cases c
ON c.id=CASE WHEN r.case_id=? THEN r.target_case_id ELSE r.case_id END
WHERE (r.case_id=? OR r.target_case_id=?) AND r.removed_at IS NULL
ORDER BY r.created_at DESC,r.id`
)
.all(caseId, caseId, caseId, caseId) as Array<
Omit<ModerationCaseRelationship, 'case'> &
Omit<ModerationCaseReference, 'id'> & { otherId: string }
>;
return rows.map(({ otherId, title, status, game, guid, ...relationship }) => ({
...relationship,
case: { id: otherId, title, status, game, guid }
}));
}
function archivedMatch(value: string, subject: ModerationCaseReference): number | null {
try {
const snapshot = JSON.parse(value);
if (
!Number.isSafeInteger(snapshot?.matchId) ||
snapshot.matchId < 1 ||
snapshot.match?.game !== subject.game ||
typeof snapshot.match?.server !== 'string' ||
typeof snapshot.match?.started_at !== 'string' ||
snapshot.entry?.type !== 'message' ||
typeof snapshot.entry.message?.guid !== 'string' ||
typeof snapshot.entry.message?.message !== 'string' ||
moderationGuid(subject.game, snapshot.entry.message.guid) !== subject.guid
)
return null;
return snapshot.matchId;
} catch {
return null;
}
}
/** Sharing archived match context is a review hint, never a duplicate decision. */
export function candidateCases(subject: ModerationCaseReference): ModerationCaseCandidate[] {
const handle = db();
const candidates = handle
.prepare(
`SELECT id,title,status,game,guid FROM moderation_cases
WHERE game=? AND guid=? AND id<>? ORDER BY updated_at DESC,id LIMIT 25`
)
.all(subject.game, subject.guid, subject.id) as ModerationCaseReference[];
const ids = [subject.id, ...candidates.map((candidate) => candidate.id)];
const snapshots = handle
.prepare(
`SELECT case_id,snapshot FROM moderation_evidence
WHERE case_id IN (${ids.map(() => '?').join(',')}) AND snapshot IS NOT NULL`
)
.all(...ids) as { case_id: string; snapshot: string }[];
const matches = new Map<string, Set<number>>();
for (const snapshot of snapshots) {
const match = archivedMatch(snapshot.snapshot, subject);
if (match === null) continue;
const set = matches.get(snapshot.case_id) ?? new Set<number>();
set.add(match);
matches.set(snapshot.case_id, set);
}
const current = matches.get(subject.id) ?? new Set<number>();
return candidates.map((candidate) => ({
...candidate,
sharedMatch: [...(matches.get(candidate.id) ?? [])].some((match) => current.has(match))
}));
}
+313
View File
@@ -0,0 +1,313 @@
import { db } from '../db/index.js';
import { AuthError, getUser } from '../auth/index.js';
import { canModerate } from '../../auth.js';
import { moderationGuid } from '../../moderation.js';
import {
moderationEvent,
moderationNow,
observeModerationPlayers,
policyRevision,
textField
} from './core.js';
import type { ModerationRuntimeIdentity } from './credentials.js';
export { moderationCredential } from './credentials.js';
const capabilities = ['warn', 'message', 'kick', 'announce', 'ban', 'mute'];
export function refreshActionStates(now = moderationNow()): void {
const rows = db()
.prepare(
"SELECT id,game,guid,state FROM moderation_actions WHERE state IN ('queued','delivered') AND expires_at<=?"
)
.all(now) as { id: string; game: string; guid: string | null; state: string }[];
for (const row of rows) {
const state = row.state === 'queued' ? 'expired' : 'unknown';
const detail =
state === 'expired'
? 'The action expired before delivery.'
: 'The game did not acknowledge the delivered action before its deadline.';
db()
.prepare(
'UPDATE moderation_actions SET state=?,detail=?,finished_at=? WHERE id=? AND state=?'
)
.run(state, detail, now, row.id, row.state);
moderationEvent(null, `action.${state}`, row, row.id, detail, now);
}
}
function token(value: unknown, label: string): string {
const result = textField(value, label, 128);
if (!/^[A-Za-z0-9:_-]+$/.test(result)) throw new AuthError(`Invalid ${label.toLowerCase()}.`);
return result;
}
export function syncModerationRuntime(
identity: ModerationRuntimeIdentity,
input: Record<string, unknown>
) {
if (
input.protocol !== 1 ||
input.server !== identity.server ||
input.generation !== identity.generation
)
throw new AuthError('The runtime identity does not match this credential.', 403);
const session = token(input.session, 'Runtime session');
if (!Number.isSafeInteger(input.sessionSequence) || (input.sessionSequence as number) < 1)
throw new AuthError('A positive runtime session sequence is required.');
if (
!Array.isArray(input.players) ||
input.players.length > 64 ||
!Array.isArray(input.receipts) ||
input.receipts.length > 256 ||
!Array.isArray(input.capabilities) ||
input.capabilities.length > capabilities.length ||
!Number.isSafeInteger(input.appliedRevision) ||
(input.appliedRevision as number) < 0
)
throw new AuthError('Invalid moderation synchronization request.');
const supported = [...new Set(input.capabilities)];
if (supported.some((c) => typeof c !== 'string' || !capabilities.includes(c)))
throw new AuthError('Unknown moderation capability.');
const players = input.players.map((value) => {
if (!value || typeof value !== 'object' || Array.isArray(value))
throw new AuthError('Invalid player.');
const raw = token(value.guid, 'Player GUID');
const name = textField(value.name, 'Player name', 255);
if (Buffer.byteLength(name, 'utf8') > 255)
throw new AuthError('Player names must fit within 255 UTF-8 bytes.');
return {
guid: moderationGuid(identity.game, raw),
name,
connection: token(value.connection, 'Player connection')
};
});
if (
new Set(players.map((p) => p.connection)).size !== players.length ||
new Set(players.map((p) => p.guid)).size !== players.length
)
throw new AuthError('Player identities and connections must be unique.');
const receipts = input.receipts.map((value) => {
if (
!value ||
typeof value !== 'object' ||
Array.isArray(value) ||
!['confirmed', 'failed', 'unknown', 'expired'].includes(value.state)
)
throw new AuthError('Invalid action receipt.');
return {
id: token(value.id, 'Action ID'),
state: String(value.state),
detail: textField(value.detail, 'Receipt detail', 500, true)
};
});
return db()
.transaction(() => {
// Reading a request body yields: retirement/rebuild/credential replacement may
// have completed since the HTTP handler first authenticated this request.
const current = db()
.prepare(
`SELECT 1 FROM native_instances n
JOIN servers s ON s.name=n.server
JOIN moderation_credentials c ON c.instance_id=n.id AND c.generation=n.generation
WHERE n.id=? AND n.generation=? AND n.retired_at IS NULL AND n.server=?
AND s.destroyed_at IS NULL AND s.game=? AND s.created_at=? AND c.token_hash=?`
)
.get(
identity.instanceId,
identity.generation,
identity.server,
identity.game,
identity.serverCreatedAt,
identity.credentialHash
);
if (!current)
throw new AuthError('This moderation runtime credential is no longer active.', 403);
const previous = db()
.prepare(
'SELECT session,session_sequence FROM moderation_runtimes WHERE instance_id=? AND generation=?'
)
.get(identity.instanceId, identity.generation) as
{ session: string; session_sequence: number } | undefined;
if (
previous &&
((input.sessionSequence as number) < previous.session_sequence ||
(input.sessionSequence === previous.session_sequence &&
session !== previous.session))
)
throw new AuthError('This runtime session has been superseded.', 409);
const now = moderationNow();
const revision = policyRevision();
if ((input.appliedRevision as number) > revision)
throw new AuthError(
'The runtime has a newer policy revision than this controller. Review the controller restore before reconfiguring this integration.',
409
);
refreshActionStates(now);
for (const receipt of receipts) {
const action = db()
.prepare(
'SELECT * FROM moderation_actions WHERE id=? AND instance_id=? AND generation=?'
)
.get(receipt.id, identity.instanceId, identity.generation) as
| {
id: string;
game: string;
guid: string | null;
session: string;
state: string;
delivered_at: string | null;
detail: string | null;
}
| undefined;
// Old journal receipts can outlive controller retention/restores, but cannot target other instances.
if (!action) continue;
if (!action.delivered_at)
throw new AuthError('An undelivered action cannot be acknowledged.', 409);
if (['confirmed', 'failed', 'expired'].includes(action.state)) continue;
// Durable native receipts may be resubmitted after a game process restart.
if (action.state === receipt.state && action.detail === receipt.detail) continue;
db()
.prepare(
'UPDATE moderation_actions SET state=?,detail=?,finished_at=? WHERE id=?'
)
.run(receipt.state, receipt.detail || null, now, action.id);
moderationEvent(
null,
`action.${receipt.state}`,
action,
action.id,
receipt.detail || receipt.state,
now
);
}
if (previous && previous.session !== session) {
const abandoned = db()
.prepare(
"SELECT id,game,guid,state FROM moderation_actions WHERE instance_id=? AND generation=? AND session<>? AND state IN ('queued','delivered')"
)
.all(identity.instanceId, identity.generation, session) as {
id: string;
game: string;
guid: string | null;
state: string;
}[];
for (const action of abandoned) {
const state = action.state === 'queued' ? 'expired' : 'unknown';
db()
.prepare(
'UPDATE moderation_actions SET state=?,detail=?,finished_at=? WHERE id=?'
)
.run(state, 'The game runtime changed before completion.', now, action.id);
moderationEvent(
null,
`action.${state}`,
action,
action.id,
'The game runtime changed before completion.',
now
);
}
}
db()
.prepare(
`INSERT INTO moderation_runtimes(instance_id,generation,session,capabilities,players,revision,connected_at,session_sequence) VALUES(?,?,?,?,?,?,?,?) ON CONFLICT(instance_id) DO UPDATE SET
generation=excluded.generation,session=excluded.session,capabilities=excluded.capabilities,players=excluded.players,
revision=excluded.revision,connected_at=excluded.connected_at,session_sequence=excluded.session_sequence`
)
.run(
identity.instanceId,
identity.generation,
session,
JSON.stringify(supported),
JSON.stringify(players),
input.appliedRevision,
now,
input.sessionSequence
);
observeModerationPlayers(db(), identity.game, players, now);
const sanctions = db()
.prepare(
`SELECT id,revision,guid,kind,reason,expires_at AS expiresAt FROM moderation_sanctions
WHERE revoked_at IS NULL AND (expires_at IS NULL OR expires_at>?) AND
((scope='fleet') OR (game=? AND (scope='game' OR (scope='server' AND server=? AND server_created_at=?)))) ORDER BY id LIMIT 1001`
)
.all(now, identity.game, identity.server, identity.serverCreatedAt);
if (sanctions.length > 1000)
throw new AuthError(
'The moderation policy exceeds the runtime’s supported size.',
503
);
const pending = db()
.prepare(
`SELECT * FROM moderation_actions WHERE instance_id=? AND generation=? AND session=?
AND state IN ('queued','delivered') AND expires_at>? ORDER BY created_at,id LIMIT 100`
)
.all(identity.instanceId, identity.generation, session, now) as {
id: string;
guid: string | null;
connection: string | null;
kind: string;
reason: string;
expires_at: string;
actor_id: string;
game: string;
state: string;
}[];
const actions = [];
for (const action of pending) {
const actor = getUser(action.actor_id);
let failure: string | null = null;
if (
!actor ||
actor.disabled ||
actor.mustChangePassword ||
!canModerate(actor.role)
)
failure = 'The requesting moderator no longer has access.';
else if (!supported.includes(action.kind))
failure = 'The runtime no longer supports this action.';
else if (
action.guid &&
!players.some(
(p) => p.guid === action.guid && p.connection === action.connection
)
)
failure = 'The targeted player connection has ended.';
if (failure) {
const state = action.state === 'delivered' ? 'unknown' : 'failed';
db()
.prepare(
'UPDATE moderation_actions SET state=?,detail=?,finished_at=? WHERE id=?'
)
.run(state, failure, now, action.id);
moderationEvent(null, `action.${state}`, action, action.id, failure, now);
continue;
}
db()
.prepare(
"UPDATE moderation_actions SET state='delivered',delivered_at=COALESCE(delivered_at,?) WHERE id=?"
)
.run(now, action.id);
actions.push({
id: action.id,
guid: action.guid,
connection: action.connection,
kind: action.kind,
reason: action.reason,
expiresAt: action.expires_at
});
}
const response = {
ok: true,
revision,
serverTime: now,
sanctions,
actions,
pollAfterMs: 3000
};
if (Buffer.byteLength(JSON.stringify(response)) > 1024 * 1024)
throw new AuthError(
'The moderation response exceeds the runtime’s supported size.',
503
);
return response;
})
.immediate();
}
+276
View File
@@ -0,0 +1,276 @@
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];
}
}
/** The migration runner disables foreign keys before opening this transaction. */
export function migrateModerationCases(handle: Database.Database): void {
const columns = handle.pragma('table_info(moderation_cases)') as { name: string }[];
if (!columns.some((column) => column.name === 'priority')) {
if (handle.pragma('foreign_keys', { simple: true }))
throw new Error('The moderation case migration requires foreign keys to be disabled');
// Do not rename the existing parent: archived evidence must keep its
// original case reference through the rebuild.
handle.exec(`
CREATE TABLE moderation_cases_expanded (
id TEXT PRIMARY KEY, game TEXT NOT NULL, guid TEXT NOT NULL, title TEXT NOT NULL,
status TEXT NOT NULL CHECK(status IN ('open','in_review','escalated','resolved','dismissed',
'warning_issued','restriction_applied','no_action','duplicate','appeal_upheld','appeal_denied')),
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,
priority TEXT NOT NULL DEFAULT 'normal' CHECK(priority IN ('low','normal','high','urgent')),
assignee_id TEXT REFERENCES auth_users(id) ON DELETE SET NULL
);
INSERT INTO moderation_cases_expanded(id,game,guid,title,status,actor_id,actor,created_at,updated_at,revision)
SELECT id,game,guid,title,status,actor_id,actor,created_at,updated_at,revision FROM moderation_cases;
DROP TABLE moderation_cases;
ALTER TABLE moderation_cases_expanded RENAME TO moderation_cases;
`);
}
if (
!(handle.pragma('table_info(moderation_sanctions)') as { name: string }[]).some(
(column) => column.name === 'case_id'
)
)
handle.exec(
'ALTER TABLE moderation_sanctions ADD COLUMN case_id TEXT REFERENCES moderation_cases(id) ON DELETE SET NULL'
);
handle.exec(`
CREATE INDEX IF NOT EXISTS moderation_cases_queue ON moderation_cases(status,priority,updated_at);
CREATE INDEX IF NOT EXISTS moderation_cases_assignee ON moderation_cases(assignee_id,updated_at);
CREATE INDEX IF NOT EXISTS moderation_sanctions_case ON moderation_sanctions(case_id);
CREATE INDEX IF NOT EXISTS moderation_events_subject_id ON moderation_events(subject_id,id);
`);
}
/** Links record prior enforcement without changing its original case or issuing it again. */
export function migrateModerationCaseActions(handle: Database.Database): void {
handle.exec(`
CREATE TABLE IF NOT EXISTS moderation_case_action_links (
case_id TEXT NOT NULL REFERENCES moderation_cases(id),
action_type TEXT NOT NULL CHECK(action_type IN ('action','restriction')),
action_id TEXT NOT NULL,
actor TEXT NOT NULL,
created_at TEXT NOT NULL,
PRIMARY KEY(case_id,action_type,action_id)
);
CREATE INDEX IF NOT EXISTS moderation_case_action_source
ON moderation_case_action_links(action_type,action_id);
`);
}
export function migrateModerationCaseAssessments(handle: Database.Database): void {
handle.exec(`
CREATE TABLE IF NOT EXISTS moderation_case_assessments (
case_id TEXT NOT NULL REFERENCES moderation_cases(id),
revision INTEGER NOT NULL CHECK(revision > 0),
data TEXT NOT NULL,
actor_id TEXT NOT NULL,
actor TEXT NOT NULL,
created_at TEXT NOT NULL,
PRIMARY KEY(case_id,revision)
);
`);
}
export function migrateModerationCaseRelationships(handle: Database.Database): void {
handle.exec(`
CREATE TABLE IF NOT EXISTS moderation_case_relationships (
id TEXT PRIMARY KEY,
case_id TEXT NOT NULL REFERENCES moderation_cases(id),
target_case_id TEXT NOT NULL REFERENCES moderation_cases(id),
relation TEXT NOT NULL CHECK(relation IN ('same_incident','appeal_of','duplicate_of')),
actor_id TEXT NOT NULL,
actor TEXT NOT NULL,
created_at TEXT NOT NULL,
removed_at TEXT,
CHECK(case_id != target_case_id),
CHECK(relation != 'same_incident' OR case_id < target_case_id)
);
CREATE UNIQUE INDEX IF NOT EXISTS moderation_case_relationship_active
ON moderation_case_relationships(case_id,target_case_id,relation) WHERE removed_at IS NULL;
CREATE INDEX IF NOT EXISTS moderation_case_relationship_target
ON moderation_case_relationships(target_case_id,removed_at);
CREATE INDEX IF NOT EXISTS moderation_case_subject_recent
ON moderation_cases(game,guid,updated_at DESC,id);
`);
}
export function migrateModerationCaseFollowups(handle: Database.Database): void {
const columns = handle.pragma('table_info(moderation_cases)') as { name: string }[];
if (!columns.some((column) => column.name === 'review_at'))
handle.exec(`ALTER TABLE moderation_cases ADD COLUMN review_at TEXT;
ALTER TABLE moderation_cases ADD COLUMN review_reason TEXT NOT NULL DEFAULT '';
ALTER TABLE moderation_cases ADD COLUMN review_sanction_id TEXT REFERENCES moderation_sanctions(id) ON DELETE SET NULL;`);
handle.exec(`
CREATE INDEX IF NOT EXISTS moderation_case_review_due ON moderation_cases(review_at) WHERE review_at IS NOT NULL;
CREATE TABLE IF NOT EXISTS moderation_case_notifications (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL REFERENCES auth_users(id) ON DELETE CASCADE,
case_id TEXT NOT NULL REFERENCES moderation_cases(id),
kind TEXT NOT NULL CHECK(kind IN ('evidence','assignment')),
created_at TEXT NOT NULL,
read_at TEXT
);
CREATE INDEX IF NOT EXISTS moderation_case_notifications_unread ON moderation_case_notifications(user_id,read_at,id DESC);
CREATE INDEX IF NOT EXISTS moderation_case_notifications_case ON moderation_case_notifications(user_id,case_id,read_at,id DESC);
`);
}
/** Foreign-key lookups must cover removed relationships and every notification recipient. */
export function migrateModerationCaseReferenceIndexes(handle: Database.Database): void {
handle.exec(`
CREATE INDEX IF NOT EXISTS moderation_case_review_sanction
ON moderation_cases(review_sanction_id);
CREATE INDEX IF NOT EXISTS moderation_case_relationship_source
ON moderation_case_relationships(case_id,removed_at);
CREATE INDEX IF NOT EXISTS moderation_case_notifications_parent
ON moderation_case_notifications(case_id);
`);
}
/** Normalize lifecycle state while retaining every prior decision and child record. */
export function migrateModerationCaseStates(handle: Database.Database): void {
if (handle.pragma('foreign_keys', { simple: true }))
throw new Error('The case status migration requires foreign keys to be disabled');
const objects = handle
.prepare(
`SELECT sql FROM sqlite_master
WHERE tbl_name='moderation_cases' AND type IN ('index','trigger') AND sql IS NOT NULL`
)
.all() as { sql: string }[];
// Capture old statuses even for imported cases without a corresponding decision event.
handle.exec(`INSERT INTO moderation_events(game,guid,actor,action,subject_id,detail,created_at)
SELECT game,guid,'Maxis','case.status_migrated',id,
json_object('from',status,'to',CASE WHEN status='escalated' THEN 'in_review' ELSE 'closed' END),
strftime('%Y-%m-%dT%H:%M:%fZ','now') FROM moderation_cases
WHERE status NOT IN ('open','in_review');
CREATE TABLE moderation_cases_state (
id TEXT PRIMARY KEY, game TEXT NOT NULL, guid TEXT NOT NULL, title TEXT NOT NULL,
status TEXT NOT NULL CHECK(status IN ('open','in_review','closed')),
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,
priority TEXT NOT NULL DEFAULT 'normal' CHECK(priority IN ('low','normal','high','urgent')),
assignee_id TEXT REFERENCES auth_users(id) ON DELETE SET NULL,
review_at TEXT, review_reason TEXT NOT NULL DEFAULT '',
review_sanction_id TEXT REFERENCES moderation_sanctions(id) ON DELETE SET NULL
);
INSERT INTO moderation_cases_state
SELECT id,game,guid,title,
CASE WHEN status IN ('open','in_review') THEN status
WHEN status='escalated' THEN 'in_review' ELSE 'closed' END,
actor_id,actor,created_at,updated_at,
revision+CASE WHEN status IN ('open','in_review') THEN 0 ELSE 1 END,
priority,assignee_id,review_at,review_reason,review_sanction_id FROM moderation_cases;
DROP TABLE moderation_cases;
ALTER TABLE moderation_cases_state RENAME TO moderation_cases;
`);
for (const object of objects) handle.exec(object.sql);
}
@@ -12,7 +12,7 @@ export function applyActivationFiles(
): void {
const changes = db()
.prepare(
`SELECT f.*, ${blobSize('f.content_hash')} AS size, v.version FROM staged_file_changes f
`SELECT f.*, ${blobSize('f.content_hash')} AS size, v.version, v.release_version FROM staged_file_changes f
JOIN provisioning_sources s ON s.kind=f.source_kind AND s.source_id=f.source_id AND s.server=f.server
LEFT JOIN script_versions v ON v.id=f.script_version_id
WHERE s.operation_id=? AND f.state='pending' ORDER BY f.id`
@@ -21,6 +21,7 @@ export function applyActivationFiles(
content_hash: string | null;
size: number;
version: number | null;
release_version: string | null;
})[];
for (const change of changes) {
assertPublicFileTarget(change.server, change.kind, change.rel_path);
@@ -36,6 +37,7 @@ export function applyActivationFiles(
path: change.rel_path,
versionId: change.script_version_id,
version: change.version,
releaseVersion: change.release_version,
sha256: change.content_hash,
size: change.size,
...(includeContent
@@ -5,6 +5,7 @@ import { db } from '../db/index.js';
import type { NativeInstance } from '../backends/native.js';
import type { LaunchRecipePlan } from '../fleet/launch-recipes.js';
import type { PlacementOptions } from '../hosts/capacity.js';
import type { ModerationConfiguration } from '../moderation/configuration.js';
export interface NativeLaunchInput {
game: 't5' | 't6';
@@ -14,6 +15,7 @@ export interface NativeLaunchInput {
serverKey: string;
rconPassword: string;
bankToken: string;
moderation?: ModerationConfiguration;
rebuild: boolean;
safetyOverride?: boolean;
unhealthyRecovery?: boolean;
+11 -5
View File
@@ -12,6 +12,7 @@ import {
import { sha256Buffer } from '../fleet/files.js';
import type { NativeLaunchInput } from './launch-preparation.js';
import { choosePlacement, type PlacementOptions } from '../hosts/capacity.js';
import { isPrivateConfigPath } from '../fleet/private-config.js';
parentPort!.on(
'message',
@@ -79,12 +80,16 @@ parentPort!.on(
'electric_cherry_bank.cfg'
);
if (
recipe.files.some(
(file) =>
posix.join(relDestForKind(file.kind, recipe.game), file.path) === bankPath
recipe.files.some((file) =>
isPrivateConfigPath(
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 = {
...(input.activationId ? { stageId: input.activationId } : {}),
name: instance.server,
@@ -95,13 +100,14 @@ parentPort!.on(
bindAddress: instance.bind_address,
rconPassword: password,
bank: { token: input.bankToken, configPath: bankPath },
...(input.moderation ? { moderation: input.moderation } : {}),
env: {
PLUTAINER_SERVER_NAME: instance.server,
PLUTAINER_PORT: String(instance.port),
PLUTAINER_GAME: recipe.game === 't6' ? 't6zm' : 't5sp',
PLUTAINER_CONFIG_FILE: recipe.configFile,
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
},
files: recipe.files.map((file) => ({
+9 -2
View File
@@ -38,11 +38,17 @@ export function captureMoveRecipe(server: string): LaunchRecipePlan {
? undefined
: (db()
.prepare(
`SELECT v.version,v.kind,s.name
`SELECT v.version,v.release_version,v.kind,s.name
FROM script_versions v JOIN scripts s ON s.id=v.script_id WHERE v.id=?`
)
.get(file.script_version_id) as
{ version: number; kind: string; name: string } | undefined);
| {
version: number;
release_version: string | null;
kind: string;
name: string;
}
| undefined);
const kind = version?.kind === 'baseline' ? 'config' : (version?.kind ?? before?.kind);
if (!['script', 'plugin', 'config'].includes(kind ?? ''))
throw new Error(`The deployed file type is unavailable for ${file.rel_path}.`);
@@ -59,6 +65,7 @@ export function captureMoveRecipe(server: string): LaunchRecipePlan {
path: file.rel_path,
versionId: file.script_version_id,
version: version?.version ?? null,
releaseVersion: version?.release_version ?? null,
sha256: file.sha256,
size: file.size
};
+32 -2
View File
@@ -14,6 +14,11 @@ import { setImmediate as yieldToController } from 'node:timers/promises';
import { choosePlacement, availableHostPort, type PlacementOptions } from '../hosts/capacity.js';
import { randomBytes, randomUUID } from 'node:crypto';
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 { db } from '../db/index.js';
import { unsettledCancelledCommand } from './cancellation.js';
@@ -1144,7 +1149,8 @@ function sameLaunchFiles(first: LaunchRecipePlan, second: LaunchRecipePlan): boo
expectedMap: recipe.expectedMap,
configFile: recipe.configFile,
configSource: recipe.configSource,
files: recipe.files
// Older frozen recipes lack release labels; display metadata does not change a launch.
files: recipe.files.map(({ releaseVersion: _releaseVersion, ...file }) => file)
});
return JSON.stringify(selected(first)) === JSON.stringify(selected(second));
}
@@ -1996,6 +2002,7 @@ async function processJob(job: Job, eligibility: (job: Job) => string | null) {
let prepared: PreparedNativeLaunch | undefined;
let preparedKey: string | null = null;
let preparedPassword: string | undefined;
let preparedModeration = false;
try {
if (job.action === 'create' || job.action === 'rebuild') {
if (
@@ -2027,6 +2034,11 @@ async function processJob(job: Job, eligibility: (job: Job) => string | null) {
(files.length ? runtimePasswordForServer(job.server) : undefined) ||
config.rcon.password ||
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({
game: prior.game,
mapSlug: prior.mapSlug,
@@ -2035,7 +2047,15 @@ async function processJob(job: Job, eligibility: (job: Job) => string | null) {
serverKey: preparedKey,
rconPassword: preparedPassword,
bankToken: bankCredential(instance.id),
rebuild: job.action === 'rebuild' && !migration,
...(preparedModeration
? {
moderation: moderationConfiguration(
moderationCredential(instance.id, generation),
generation
)
}
: {}),
rebuild: rebuilding,
safetyOverride: overrideSafety,
unhealthyRecovery:
!files.length &&
@@ -2124,6 +2144,16 @@ async function processJob(job: Job, eligibility: (job: Job) => string | null) {
return;
}
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.
const expected =
migration && isRebalanceMigration(job.id)
+100
View File
@@ -0,0 +1,100 @@
interface AuditEntry {
action: string;
targetId: string | null;
details: string;
}
const actionLabels: Record<string, string> = {
setup: 'Administrator created',
login: 'Signed in',
'login.failed': 'Sign-in failed',
logout: 'Signed out',
'sessions.revoked': 'Sessions ended',
'user.created': 'User created',
'user.updated': 'Access updated',
'password.changed': 'Password changed',
request: 'Action requested'
};
const requestActions: Record<string, string> = {
'POST /api/configs/inspect': 'Inspect config',
'POST /api/scripts/inspect': 'Inspect script',
'POST /api/servers': 'Create server',
'DELETE /api/servers/[name]': 'Remove server',
'POST /api/servers/[name]/probe-rcon': 'Check server connection',
'POST /api/users': 'Create user',
'PATCH /api/users/[id]': 'Update user access',
'POST /api/users/[id]/revoke-sessions': 'Sign out sessions',
'POST /api/auth/logout': 'Sign out',
'POST /api/auth/password': 'Change password',
'PUT /api/settings': 'Save settings',
'PATCH /api/settings': 'Save settings',
'POST /api/settings/retention/preview': 'Preview data cleanup',
'POST /api/plugins/github/import': 'Import plugin',
'PUT /api/server-keys': 'Save server key',
'DELETE /api/server-keys': 'Remove server key'
};
const requestAreas: Record<string, string> = {
auth: 'account',
autoscaling: 'autoscaling',
bank: 'bank',
betas: 'beta',
blueprints: 'blueprint',
campaigns: 'campaign',
configs: 'config',
'deployment-collections': 'server collection',
deployments: 'campaign',
'game-files': 'game files',
hosts: 'host',
maintenance: 'maintenance',
moderation: 'moderation',
plugins: 'plugin',
plutonium: 'game update',
provisioning: 'server operation',
rebalancing: 'rebalancing',
scripts: 'script',
'server-keys': 'server key',
servers: 'server',
settings: 'settings',
users: 'user',
watchdog: 'recovery'
};
export function auditPresentation(event: AuditEntry): {
label: string;
requestAction: string | null;
details: string;
} {
let parsed: unknown;
try {
parsed = JSON.parse(event.details);
} catch {
parsed = null;
}
const fields =
parsed && typeof parsed === 'object' && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)
: null;
const request = event.action === 'request';
const method = typeof fields?.method === 'string' ? fields.method.toUpperCase() : '';
const route = event.targetId ?? (typeof fields?.path === 'string' ? fields.path : '');
const area = route.startsWith('/api/') ? requestAreas[route.split('/')[2]] : undefined;
// Older entries only recorded the route and HTTP method, so body-driven
// actions cannot safely be inferred more precisely than their feature area.
const requestAction = request
? (requestActions[`${method} ${route}`] ?? (area ? `Manage ${area}` : 'Other action'))
: null;
const details = request
? JSON.stringify(
{ ...(fields ?? { recordedDetails: event.details }), routeId: event.targetId },
null,
2
)
: fields
? Object.keys(fields).length
? JSON.stringify(fields, null, 2)
: ''
: event.details;
return { label: actionLabels[event.action] ?? event.action, requestAction, details };
}
+37 -10
View File
@@ -1,30 +1,57 @@
import { statusPresentation, type StatusTone } from './status.js';
import { plural } from './plural.js';
import { artifactVersionLabel } from '../artifact-version.js';
export type CampaignServerGroup = 'attention' | 'processing' | 'queued' | 'complete' | 'inactive';
export interface CampaignSummary {
interface CampaignScriptSummary {
script_name: string | null;
script_version: number | null;
script_release_version: string | null;
script_kind: string | null;
}
export interface CampaignSummary extends CampaignScriptSummary {
action: string;
beta_id: number | null;
rolled_back_by: string | null;
script_name: string | null;
script_version: number | null;
scripts: { script_name: string | null; script_version: number | null }[];
scripts: CampaignScriptSummary[];
servers: { restart_state: string; server_deleted_at?: string | null }[];
}
export function campaignActionLabel(action: string): string {
switch (action) {
case 'drift':
return 'Maintenance repair';
case 'promote':
return 'Promote';
case 'rollback':
return 'Rollback';
default:
return 'Update';
}
}
export function campaignTitle(campaign: CampaignSummary): string {
const action =
campaign.action === 'drift'
? 'Maintenance repair'
: campaign.action.charAt(0).toUpperCase() + campaign.action.slice(1);
const action = campaignActionLabel(campaign.action);
if (campaign.action === 'drift') return action;
if (campaign.action === 'rollback' && campaign.beta_id)
return `${action} · Beta #${campaign.beta_id}`;
if (campaign.scripts.length > 1) return `${action} · ${campaign.scripts.length} scripts`;
const script = campaign.scripts[0] ?? campaign;
if (script.script_name)
return `${action} · ${script.script_name}${script.script_version !== null ? ` v${script.script_version}` : ''}`;
if (script.script_name) {
const version =
script.script_version === null
? ''
: ` ${artifactVersionLabel(
{
version: script.script_version,
release_version: script.script_release_version
},
script.script_kind ?? 'script'
)}`;
return `${action} · ${script.script_name}${version}`;
}
return campaign.beta_id ? `${action} · Beta #${campaign.beta_id}` : action;
}
+206
View File
@@ -0,0 +1,206 @@
<script lang="ts">
import type { afkProfile } from '$lib/server/fleet/afk.js';
import { formatDuration } from '$ui/duration.js';
import { timeFormat } from '$ui/stores/time-zone.js';
import { serverTitle } from '$ui/server-labels.js';
import Button from './primitives/Button.svelte';
let { account, guid }: { account: ReturnType<typeof afkProfile>; guid?: string } = $props();
const duration = (seconds: number) => formatDuration(seconds * 1000);
const change = (seconds: number) =>
`${seconds > 0 ? '+' : seconds < 0 ? '−' : ''}${duration(Math.abs(seconds))}`;
const label = (value: string) => value.replaceAll('_', ' ');
</script>
<section aria-labelledby="afk-title">
<div class="heading">
<h2 id="afk-title">AFK reserve</h2>
{#if guid}<Button size="sm" href={`/afk?view=ledger&player=${encodeURIComponent(guid)}`}
>View AFK history</Button
>{/if}
</div>
<p>
<strong>{duration(account.freeSeconds)} free</strong> and
<strong>{duration(account.savedSeconds)} saved</strong>, shared across Black Ops I and II.
Saved capacity: {duration(account.capacitySeconds)}.
</p>
{#if account.freeRenewsAt}
<p>
Free time next replenishes {$timeFormat.dateTime(account.freeRenewsAt)}. Unused free
time does not accumulate.
</p>
{/if}
{#if account.reservedSeconds > 0}
<p>
{duration(account.reservedSeconds)} is reserved for a session and unavailable to other servers.
</p>
{/if}
<dl>
<div>
<dt>Recorded active play</dt>
<dd>{duration(account.activeSeconds)}</dd>
</div>
<div>
<dt>Confirmed AFK time</dt>
<dd>{duration(account.confirmedAfkSeconds)}</dd>
</div>
<div>
<dt>Manual returns</dt>
<dd>{account.manualReturns}</dd>
</div>
<div>
<dt>Expirations</dt>
<dd>{account.expiredSessions}</dd>
</div>
<div>
<dt>Purchases</dt>
<dd>{account.purchaseCount} ({duration(account.purchasedSeconds)})</dd>
</div>
</dl>
{#if account.unresolvedSessions > 0}
<p>
{account.unresolvedSessions} session(s) have an unconfirmed duration. Their time is excluded
from confirmed AFK usage.
</p>
{/if}
{#if account.entries.length}
<details>
<summary>Recent time ledger</summary>
<div class="table-scroll">
<table>
<caption>Recent AFK grants, reservations and consumption</caption>
<thead
><tr
><th scope="col">Time / event</th><th scope="col">Free change</th><th
scope="col">Saved change</th
><th scope="col">Balances after</th><th scope="col">Server / match</th
></tr
></thead
>
<tbody>
{#each account.entries as entry (entry.id)}
<tr>
<td
>{$timeFormat.dateTime(entry.eventAt)}<small
>{label(entry.kind)}</small
><small>{entry.operationId}</small></td
>
<td>{change(entry.freeDelta)}</td>
<td>{change(entry.savedDelta)}</td>
<td
>{duration(entry.freeSeconds)} free<small
>{duration(entry.savedSeconds)} saved</small
></td
>
<td
>{serverTitle(entry.server)}<small
>{#if entry.matchAvailable}<a
href={`/matches/${entry.matchId}`}
>Match #{entry.matchId}</a
>{:else if entry.matchId}Match #{entry.matchId} (history expired){:else}—{/if}</small
></td
>
</tr>
{/each}
</tbody>
</table>
</div>
</details>
{:else}
<p>No AFK activity recorded yet.</p>
{/if}
{#if account.sessions.length}
<details>
<summary>Recent AFK sessions</summary>
<div class="table-scroll">
<table>
<caption>Accepted AFK transitions</caption>
<thead
><tr
><th scope="col">Started</th><th scope="col">Server</th><th scope="col"
>Duration</th
><th scope="col">Outcome</th></tr
></thead
>
<tbody>
{#each account.sessions as session (session.sessionId)}
<tr>
<td
>{session.startedAt
? $timeFormat.dateTime(session.startedAt)
: 'Start unconfirmed'}</td
>
<td>{serverTitle(session.server)}</td>
<td
>{session.consumedSeconds === null
? 'Unconfirmed'
: duration(session.consumedSeconds)}
{#if session.state === 'corrected' && session.chargedSeconds !== null}
<small
>Corrected charge: {duration(
session.chargedSeconds
)}</small
>
{/if}</td
>
<td
>{label(
session.endReason ?? session.state
)}{#if session.endedAt}<small
>{$timeFormat.dateTime(session.endedAt)}</small
>{/if}</td
>
</tr>
{/each}
</tbody>
</table>
</div>
</details>
{/if}
</section>
<style>
section {
padding: var(--space-5);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
background: var(--surface);
}
h2 {
margin: 0 0 var(--space-2);
font-size: var(--text-lg);
}
.heading {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: var(--space-3);
}
dl {
display: flex;
flex-wrap: wrap;
gap: var(--space-5);
}
dt,
small {
color: var(--text-muted);
font-size: var(--text-sm);
}
dd {
margin: var(--space-1) 0 0;
}
small {
display: block;
overflow-wrap: anywhere;
}
details {
margin-top: var(--space-3);
}
summary {
cursor: pointer;
}
a {
color: var(--accent-primary-hover);
}
</style>
+165 -2
View File
@@ -1,25 +1,33 @@
<script lang="ts">
import { onMount, tick } from 'svelte';
import { page } from '$app/stores';
import { canModerate, canReadModeration } from '$lib/auth.js';
import { moderationCaseHref, moderationHref } from '$ui/moderation.js';
import type { ChatContext, ContextualChatEntry } from '$lib/game-chat.js';
import { formatDuration } from '$ui/duration.js';
import { groupChatTimeline, type ChatBoundaryEntry } from '$ui/chat-timeline.js';
import { pollVisible } from '$ui/poll.js';
import { timeFormat } from '$ui/stores/time-zone.js';
import Button from './primitives/Button.svelte';
import Badge from './primitives/Badge.svelte';
import Freshness from './Freshness.svelte';
import MatchEvidenceDialog from './moderation/MatchEvidenceDialog.svelte';
let {
server,
game,
history,
hostOffline = false,
hostName
}: {
server?: string;
game?: string;
history?: ContextualChatEntry[];
hostOffline?: boolean;
hostName?: string;
} = $props();
let updatedAt = $state<number | null>(null);
const moderationGame = $derived(game ?? server?.split('_')[0]);
let liveEntries = $state<ContextualChatEntry[]>([]);
const entries = $derived(history ?? liveEntries);
let timeline = $derived(groupChatTimeline(entries));
@@ -34,6 +42,41 @@
let follow = $state(true);
let paused = $state(false);
let controller: AbortController | null = null;
const selectableMessages = $derived(
history !== undefined &&
canModerate($page.data?.user?.role) &&
(moderationGame === 't5' || moderationGame === 't6')
? entries.filter(
(entry) => entry.type === 'message' && entry.messageId && entry.matchId
)
: []
);
let selectedMessageIds = $state<string[]>([]);
let evidenceOpen = $state(false);
let attachedCaseId = $state('');
const selectedMatchId = $derived(
selectableMessages.find(
(entry) => entry.messageId && selectedMessageIds.includes(entry.messageId)
)?.matchId
);
const selectionCandidates = $derived(
selectableMessages.filter(
(entry) => entry.matchId === (selectedMatchId ?? selectableMessages[0]?.matchId)
)
);
$effect(() => {
void history;
selectedMessageIds = [];
evidenceOpen = false;
attachedCaseId = '';
});
function toggleEvidence(messageId: string) {
selectedMessageIds = selectedMessageIds.includes(messageId)
? selectedMessageIds.filter((id) => id !== messageId)
: [...selectedMessageIds, messageId];
}
async function refresh(manual = false) {
if (!server || hostOffline) return;
@@ -169,6 +212,36 @@
</div>
{/if}
</div>
{#if selectableMessages.length}
<div class="evidence-toolbar" aria-label="Chat evidence selection">
<span>{selectedMessageIds.length} selected</span>
<Button
size="sm"
onclick={() =>
(selectedMessageIds = selectionCandidates
.slice(0, 100)
.map((entry) => entry.messageId!))}
>Select {selectionCandidates.length > 100 ? 'first 100' : 'all'}</Button
>
<Button
size="sm"
disabled={!selectedMessageIds.length}
onclick={() => (selectedMessageIds = [])}>Clear selection</Button
>
<Button
size="sm"
variant="primary"
disabled={!selectedMessageIds.length}
onclick={() => (evidenceOpen = true)}>Attach to case</Button
>
{#if selectedMessageIds.length >= 100}<span
>Attach up to 100 selected messages at a time.</span
>{/if}
</div>
{/if}
{#if attachedCaseId}<p class="evidence-notice" role="status">
Selected evidence saved. <a href={moderationCaseHref(attachedCaseId)}>View case</a>
</p>{/if}
{#if hostOffline}<p class="outage">
Chat resumes when {hostName ?? 'the host agent'} reconnects.{loaded
? ' Showing the last loaded messages.'
@@ -230,7 +303,11 @@
{@render matchBoundary(item.entries[item.entries.length - 1])}
</li>
{:else}
<li class="message">
<li
class="message"
class:selected={!!item.entry.messageId &&
selectedMessageIds.includes(item.entry.messageId)}
>
<span class="message-clock">
{#if item.entry.context.realTime}{@render clockTime(
item.entry.context.realTime
@@ -240,8 +317,44 @@
</span>
<div class="message-content">
<div class="message-head">
<strong>{item.entry.message.player}</strong
{#if selectableMessages.length && item.entry.messageId && item.entry.matchId}
<label class="evidence-select">
<input
type="checkbox"
checked={selectedMessageIds.includes(
item.entry.messageId
)}
disabled={!selectedMessageIds.includes(
item.entry.messageId
) &&
(selectedMessageIds.length >= 100 ||
(!!selectedMatchId &&
selectedMatchId !==
item.entry.matchId))}
onchange={() =>
toggleEvidence(item.entry.messageId!)}
aria-label={`Select message by ${item.entry.message.player} at ${item.entry.message.time}: ${item.entry.message.message}`}
/>
<span class="sr-only">Select evidence</span>
</label>
{/if}
<strong
>{item.entry.message
.player}{#if item.entry.message.channel === 'team'}
<Badge>Team chat</Badge>{/if}</strong
>{@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>
<p>{item.entry.message.message}</p>
</div>
@@ -253,6 +366,20 @@
{/if}
</section>
{#if evidenceOpen && selectedMatchId && (moderationGame === 't5' || moderationGame === 't6')}
<MatchEvidenceDialog
bind:open={evidenceOpen}
game={moderationGame}
matchId={selectedMatchId}
entries={entries.filter((entry) => entry.matchId === selectedMatchId)}
{selectedMessageIds}
onsubmitted={(id) => {
attachedCaseId = id;
selectedMessageIds = [];
}}
/>
{/if}
<style>
section {
container-type: inline-size;
@@ -265,6 +392,31 @@
gap: var(--space-3);
flex-wrap: wrap;
}
.evidence-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-3);
margin-top: var(--space-3);
font-size: var(--text-sm);
color: var(--text-secondary);
}
.evidence-notice {
font-size: var(--text-sm);
color: var(--text-secondary);
}
.evidence-select {
display: inline-flex;
align-items: center;
min-width: 24px;
min-height: 24px;
}
.evidence-select input {
accent-color: var(--accent-primary);
}
.message.selected {
background: var(--surface-raised);
}
.chat-head {
justify-content: space-between;
}
@@ -371,6 +523,9 @@
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.moderation-link {
font-size: var(--text-xs);
}
.match-events {
padding: var(--space-1) 0;
}
@@ -463,5 +618,13 @@
grid-row: 3;
margin-top: 0;
}
.moderation-link {
grid-column: 1 / -1;
grid-row: 4;
}
.evidence-select {
grid-column: 1 / -1;
grid-row: 5;
}
}
</style>
@@ -15,6 +15,7 @@
} from '$ui/collection-summary.js';
import { serverTitle } from '$ui/server-labels.js';
import { timeFormat } from '$ui/stores/time-zone.js';
import { launchFileVersionLabel } from '$lib/artifact-version.js';
import type { NativeHostSelectionStatus } from '$ui/native-game-files.js';
import type { NativeDeploymentSelection } from '$lib/hosts.js';
import type { ZombieGame } from '$lib/map-aliases.js';
@@ -609,9 +610,7 @@
<ul class="file-list">
{#each entry.recipe.files as file}<li>
<strong>{file.name}</strong><span
>{file.version === null
? 'Generated'
: `v${file.version}`} · {file.kind}</span
>{launchFileVersionLabel(file)} · {file.kind}</span
><code>{file.path}</code>
</li>{/each}
</ul>
@@ -9,6 +9,7 @@
import { createServerLaunchController, type ServerLaunchState } from '$ui/server-launch.js';
import { serverTitle } from '$ui/server-labels.js';
import { timeFormat } from '$ui/stores/time-zone.js';
import { launchFileVersionLabel } from '$lib/artifact-version.js';
import type { NativeHostSelectionStatus } from '$ui/native-game-files.js';
import type { NativeDeploymentSelection } from '$lib/hosts.js';
import type { ZombieMapEntry, ZombieGame } from '$lib/map-aliases.js';
@@ -378,7 +379,7 @@
{#each preview.recipe.files as file}<li>
<div>
<strong>{file.name}</strong><span
>{file.version === null ? 'Generated' : `v${file.version}`} ·
>{launchFileVersionLabel(file)} ·
{file.kind}</span
>
</div>
+2 -1
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import RelativeTime from '$ui/components/RelativeTime.svelte';
import { launchFileVersionLabel } from '$lib/artifact-version.js';
import type { LaunchRecipeSummary } from '$lib/deployment-plan.js';
let { recipe, historical = false }: { recipe: LaunchRecipeSummary; historical?: boolean } =
$props();
@@ -36,7 +37,7 @@
{#each recipe.files as file}<li>
<div>
<strong>{file.name}</strong><span
>{file.version !== null ? `v${file.version}` : 'Generated'} · {file.kind}
>{launchFileVersionLabel(file)} · {file.kind}
· {file.size.toLocaleString()} bytes</span
>
</div>
+53 -12
View File
@@ -5,6 +5,7 @@
import RelativeTime from '$ui/components/RelativeTime.svelte';
import { untrack } from 'svelte';
import { page } from '$app/stores';
import { canReadModeration } from '$lib/auth.js';
import { browser } from '$app/environment';
import { contextHref } from '$lib/navigation-context.js';
import { timestampISO } from '$lib/time-zone.js';
@@ -42,6 +43,7 @@
let navigationId = 0;
const now = $derived(browser ? $uiNow : Date.now());
const filters = $derived(readMatchQuery(matchApiParams($page.url)));
const canViewArchive = $derived(canReadModeration($page.data?.user?.role));
const apiUrl = $derived.by(() => {
const params = matchApiParams($page.url);
params.set('limit', String(matchPageSize));
@@ -131,6 +133,19 @@
</script>
<section class="history" aria-label="Match history">
{#if canViewArchive}<div class="archive-control">
<label
><input
type="checkbox"
checked={filters.archive}
onchange={(event) =>
update({ archive: event.currentTarget.checked ? '1' : '' })}
/> Moderation archive</label
>
{#if filters.archive}<p>
Includes round-one, interrupted, and unfinished matches for evidence review.
</p>{/if}
</div>{/if}
{#snippet filterFields(suffix: string)}
<div class="filters">
{#if server === undefined}<Field forId={`match-game${suffix}`} label="Game">
@@ -357,9 +372,12 @@
<tr>
<td><RelativeTime value={match.startedAt} /></td>
<td class="match">
{#if match.status === 'completed'}<a
{#if match.status === 'completed' || (canViewArchive && filters.archive)}<a
class="match-link"
href={contextHref(`/matches/${match.id}`, $page.url)}
href={contextHref(
`/matches/${match.id}${canViewArchive && filters.archive ? '?matchArchive=1' : ''}`,
$page.url
)}
aria-label={`View ${match.map} match ${match.id} from ${$timeFormat.dateTime(match.startedAt)}`}
>{match.map} · Match #{match.id}</a
>
@@ -379,7 +397,8 @@
? ` · ${duration}`
: ''}</small
>
{#if notReporting}<small class="mobile-match-context"
{#if notReporting || (filters.archive && match.status !== 'completed')}<small
class="mobile-match-context"
>Last report <RelativeTime
value={match.lastReportedAt}
missing="Not recorded"
@@ -403,7 +422,7 @@
class:interrupted={match.status === 'interrupted' ||
match.status === 'end-report-missing' ||
notReporting}>{status}</span
>{#if notReporting}<small
>{#if notReporting || (filters.archive && match.status !== 'completed')}<small
>Last report <RelativeTime
value={match.lastReportedAt}
missing="Not recorded"
@@ -469,14 +488,16 @@
<summary>About match history</summary>
<p>
Players counts unique players across all reports. A dash means the value is unavailable,
not zero. In-progress matches show elapsed time since their recorded start and link to
the live server while reports are current. Otherwise, they show Not reporting with the
last report time. Completed durations require a recorded start and completed ending. End
report missing means a newer game was reported before this game's end report arrived.
Missing report counts show gaps in the reporter's sequence and can decrease as delayed
reports arrive. Only completed games contribute to the leaderboard. Round shows the
final round for completed matches and the highest observed round for unfinished matches.
Open a completed match for exact timestamps and starting and ending player counts.
not zero. In-progress matches show elapsed time since their recorded start. {#if filters.archive}Archive
links open retained match reports and chat.{:else}They link to the live server while
reports are current.{/if} Otherwise, they show Not reporting with the last report time.
Completed durations require a recorded start and completed ending. End report missing means
a newer game was reported before this game's end report arrived. Missing report counts show
gaps in the reporter's sequence and can decrease as delayed reports arrive. Only completed
games contribute to the leaderboard. Round shows the final round for completed matches and
the highest observed round for unfinished matches. Open a {filters.archive
? 'recorded'
: 'completed'} match for timestamps and participant details.
</p>
</details>
</section>
@@ -487,6 +508,26 @@
gap: var(--space-4);
min-width: 0;
}
.archive-control {
display: grid;
gap: var(--space-2);
font-size: var(--text-sm);
}
.archive-control label {
display: flex;
align-items: center;
gap: var(--space-2);
width: fit-content;
cursor: pointer;
}
.archive-control input {
accent-color: var(--accent-primary);
}
.archive-control p {
margin: 0;
color: var(--text-secondary);
line-height: var(--leading-body);
}
.filters {
display: flex;
align-items: flex-start;
@@ -1,5 +1,8 @@
<script lang="ts">
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 { freshTelemetry } from '$lib/server-observation.js';
import { uiNow } from '$ui/stores/servers.js';
@@ -164,6 +167,14 @@
<a class="player-name" href={playerProfileHref(player.guid)}
>{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
class="player-details"
aria-label={`Details for ${player.name}`}
@@ -0,0 +1,97 @@
<script lang="ts">
import type { AfkAccount } from '$lib/afk.js';
import { contextHref } from '$lib/navigation-context.js';
import { formatDuration } from '$ui/duration.js';
import { timeFormat } from '$ui/stores/time-zone.js';
import RelativeTime from '../RelativeTime.svelte';
let {
entries,
capacitySeconds,
source
}: { entries: AfkAccount[]; capacitySeconds: number; source: URL } = $props();
const duration = (seconds: number) => formatDuration(seconds * 1000);
const historyHref = (guid: string, view: string) =>
`/afk?${new URLSearchParams({ view, player: guid })}`;
</script>
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Keyboard users need to scroll wide tables.) -->
<div class="afk-table-scroll" role="region" aria-label="AFK accounts" tabindex="0">
<table class="afk-table">
<caption class="sr-only">Shared player AFK accounts</caption>
<thead
><tr
><th scope="col">Player</th><th scope="col">Available time</th><th scope="col"
>Saved capacity</th
><th scope="col">Reserved time</th><th scope="col">Last activity</th><th scope="col"
>History</th
></tr
></thead
>
<tbody>
{#each entries as account (account.guid)}
<tr>
<td
><a
class="afk-player"
href={contextHref(
`/leaderboard/players/${encodeURIComponent(account.guid)}`,
source
)}>{account.name ?? account.guid}</a
><small class="afk-id">{account.guid}</small></td
>
<td class="afk-number"
><strong>{duration(account.availableSeconds)}</strong><small
>{duration(account.freeSeconds)} free · {duration(account.savedSeconds)} saved</small
>{#if account.freeRenewsAt}<small
>Free time next replenishes {$timeFormat.dateTime(
account.freeRenewsAt
)}</small
>{/if}</td
>
<td
><meter
min="0"
max={capacitySeconds}
value={Math.min(
account.savedSeconds + account.reservedSavedSeconds,
capacitySeconds
)}
aria-label={`${account.name ?? account.guid} saved time capacity`}
></meter><small
>{duration(account.savedSeconds + account.reservedSavedSeconds)} / {duration(
capacitySeconds
)}</small
>{#if account.reservedSavedSeconds > 0}<small
>Includes {duration(account.reservedSavedSeconds)} reserved</small
>{/if}</td
>
<td class="afk-number"
>{duration(account.reservedSeconds)}{#if account.unresolvedSessions}<small
class="afk-warning">Unresolved session</small
>{:else if account.reservedSeconds > 0}<small>Awaiting settlement</small
>{/if}</td
>
<td
>{#if account.lastActivity}<RelativeTime
value={account.lastActivity}
/>{:else}No activity{/if}</td
>
<td
><a href={historyHref(account.guid, 'sessions')}>Sessions</a><small
><a href={historyHref(account.guid, 'ledger')}>Time ledger</a></small
></td
>
</tr>
{/each}
</tbody>
</table>
</div>
<style>
meter {
width: 110px;
height: 12px;
accent-color: var(--accent-primary);
}
</style>
@@ -0,0 +1,68 @@
<script lang="ts">
import type { AfkLedgerEntry } from '$lib/afk.js';
import { contextHref } from '$lib/navigation-context.js';
import { formatDuration } from '$ui/duration.js';
import { timeFormat } from '$ui/stores/time-zone.js';
import ServerIdentity from '../ServerIdentity.svelte';
let { entries, source }: { entries: AfkLedgerEntry[]; source: URL } = $props();
const duration = (seconds: number) => formatDuration(seconds * 1000);
const change = (seconds: number) =>
`${seconds > 0 ? '+' : seconds < 0 ? '−' : ''}${duration(Math.abs(seconds))}`;
</script>
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Keyboard users need to scroll wide tables.) -->
<div class="afk-table-scroll" role="region" aria-label="AFK time ledger" tabindex="0">
<table class="afk-table">
<caption class="sr-only">AFK grants, purchases, reservations and consumption</caption>
<thead
><tr
><th scope="col">Player</th><th scope="col">Received / event</th><th scope="col"
>Free change</th
><th scope="col">Saved change</th><th scope="col">Balances after</th><th scope="col"
>Server / match</th
></tr
></thead
>
<tbody>
{#each entries as entry (entry.id)}
<tr>
<td
><a
class="afk-player"
href={contextHref(
`/leaderboard/players/${encodeURIComponent(entry.guid)}`,
source
)}>{entry.name ?? entry.guid}</a
><small class="afk-id">{entry.guid}</small></td
>
<td
><time datetime={entry.createdAt}
>{$timeFormat.dateTime(entry.createdAt)}</time
><small>{entry.kind.replaceAll('_', ' ')}</small
>{#if entry.eventAt !== entry.createdAt}<small
>Reported {$timeFormat.dateTime(entry.eventAt)}</small
>{/if}<small class="afk-id">{entry.operationId}</small></td
>
<td class="afk-number">{change(entry.freeDelta)}</td><td class="afk-number"
>{change(entry.savedDelta)}</td
>
<td class="afk-number"
>{duration(entry.freeSeconds)} free<small
>{duration(entry.savedSeconds)} saved</small
></td
>
<td
><ServerIdentity name={entry.server} /><small
>{#if entry.matchAvailable && entry.matchId}<a
href={contextHref(`/matches/${entry.matchId}`, source)}
>Match #{entry.matchId}</a
>{:else if entry.matchId}Match #{entry.matchId} · History expired{:else}No
match history{/if}</small
></td
>
</tr>
{/each}
</tbody>
</table>
</div>
@@ -0,0 +1,50 @@
<script lang="ts">
import { afkPageSize } from '$lib/afk.js';
import Button from '../primitives/Button.svelte';
let {
total,
offset,
disabled = false,
onchange
}: {
total: number;
offset: number;
disabled?: boolean;
onchange: (offset: number) => void;
} = $props();
</script>
<nav class="pagination" aria-label="AFK results pagination">
<span role="status"
>{total ? offset + 1 : 0}–{Math.min(offset + afkPageSize, total)} of {total.toLocaleString()}
results</span
>
{#if total > afkPageSize}<div>
<Button
size="sm"
disabled={disabled || offset === 0}
onclick={() => onchange(offset - afkPageSize)}>Previous</Button
><Button
size="sm"
disabled={disabled || offset + afkPageSize >= total}
onclick={() => onchange(offset + afkPageSize)}>Next</Button
>
</div>{/if}
</nav>
<style>
.pagination {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: var(--space-3);
padding-block: var(--space-3);
color: var(--text-secondary);
font-size: var(--text-sm);
}
.pagination div {
display: flex;
gap: var(--space-2);
}
</style>
@@ -0,0 +1,78 @@
<script lang="ts">
import type { AfkSession } from '$lib/afk.js';
import { contextHref } from '$lib/navigation-context.js';
import { formatDuration } from '$ui/duration.js';
import { timeFormat } from '$ui/stores/time-zone.js';
import ServerIdentity from '../ServerIdentity.svelte';
let { entries, source }: { entries: AfkSession[]; source: URL } = $props();
const duration = (seconds: number) => formatDuration(seconds * 1000);
const label = (value: string) => value.replaceAll('_', ' ');
</script>
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Keyboard users need to scroll wide tables.) -->
<div class="afk-table-scroll" role="region" aria-label="AFK sessions" tabindex="0">
<table class="afk-table">
<caption class="sr-only">AFK session reservations and outcomes</caption>
<thead
><tr
><th scope="col">Player</th><th scope="col">Session</th><th scope="col">Time</th><th
scope="col">Outcome</th
><th scope="col">Server / match</th></tr
></thead
>
<tbody>
{#each entries as session (session.sessionId)}
<tr>
<td
><a
class="afk-player"
href={contextHref(
`/leaderboard/players/${encodeURIComponent(session.guid)}`,
source
)}>{session.name ?? session.guid}</a
><small class="afk-id">{session.guid}</small></td
>
<td
><time datetime={session.createdAt}
>{$timeFormat.dateTime(session.createdAt)}</time
><small
>{session.startedAt
? `Started ${$timeFormat.dateTime(session.startedAt)}`
: 'Start unconfirmed'}</small
><small class="afk-id">{session.sessionId}</small></td
>
<td class="afk-number"
><strong
>{session.consumedSeconds === null
? 'Unconfirmed duration'
: `${duration(session.consumedSeconds)} used`}</strong
><small>{duration(session.authorizedSeconds)} authorized</small
>{#if session.state === 'corrected' && session.chargedSeconds !== null}<small
>Corrected charge: {duration(session.chargedSeconds)}</small
>{/if}{#if session.consumedSeconds === null && session.confirmedSeconds > 0}<small
>Confirmed at least {duration(session.confirmedSeconds)}</small
>{/if}</td
>
<td
><span class:afk-warning={session.state === 'unresolved'}
>{label(session.state)}</span
>{#if session.endReason}<small>{label(session.endReason)}</small
>{/if}{#if session.endedAt}<small
>{$timeFormat.dateTime(session.endedAt)}</small
>{/if}</td
>
<td
><ServerIdentity name={session.server} /><small
>{#if session.matchAvailable && session.matchId}<a
href={contextHref(`/matches/${session.matchId}`, source)}
>Match #{session.matchId}</a
>{:else if session.matchId}Match #{session.matchId} · History expired{:else}No
match history{/if}</small
></td
>
</tr>
{/each}
</tbody>
</table>
</div>
@@ -0,0 +1,167 @@
<script lang="ts">
import {
moderationConcernLabels,
moderationFindingLabels,
type ModerationCaseAssessment
} from '$lib/moderation.js';
import Button from '../primitives/Button.svelte';
import Badge from '../primitives/Badge.svelte';
import RelativeTime from '../RelativeTime.svelte';
import CaseAssessmentDialog from './CaseAssessmentDialog.svelte';
let {
caseId,
revision,
assessment = null,
history = [],
permitted,
onsubmitted
}: {
caseId: string;
revision: number;
assessment?: ModerationCaseAssessment | null;
history?: ModerationCaseAssessment[];
permitted: boolean;
onsubmitted: () => void;
} = $props();
let open = $state(false);
const previous = $derived(history.filter((item) => item.revision !== assessment?.revision));
$effect(() => {
void caseId;
open = false;
});
</script>
{#snippet assessmentContent(value: ModerationCaseAssessment)}
<p class="assessment-summary">{value.summary}</p>
<div>
<h3>Applicable rules</h3>
{#if value.rules.length}<ul class="rules">
{#each value.rules as rule}<li>{rule}</li>{/each}
</ul>{:else}<p class="hint">No rule references recorded.</p>{/if}
</div>
<ol class="findings" aria-label="Structured findings">
{#each value.findings as finding}<li>
<div class="heading">
<strong>{moderationConcernLabels[finding.category]}</strong><Badge
variant={finding.finding === 'substantiated'
? 'danger'
: finding.finding === 'inconclusive'
? 'warning'
: 'neutral'}>{moderationFindingLabels[finding.finding]}</Badge
>
</div>
<p>{finding.detail}</p>
</li>{/each}
</ol>
<p class="hint">
Revision {value.revision} · {value.actor} · <RelativeTime value={value.createdAt} />
</p>
{/snippet}
<section class="assessment" aria-label="Case assessment">
<div class="heading">
<h2>Case assessment</h2>
{#if permitted}<Button size="sm" onclick={() => (open = true)}
>{assessment ? 'Edit assessment' : 'Assess case'}</Button
>{/if}
</div>
{#if assessment}{@render assessmentContent(assessment)}{:else}<p class="hint">
Not assessed. Record concerns, applicable rules, and what the evidence supports.
</p>{/if}
{#if previous.length}<details>
<summary>Previous assessments ({previous.length})</summary>
<ol class="history">
{#each previous as earlier (earlier.revision)}<li>
{@render assessmentContent(earlier)}
</li>{/each}
</ol>
</details>{/if}
</section>
{#if permitted && open}<CaseAssessmentDialog
bind:open
{caseId}
{revision}
{assessment}
{onsubmitted}
/>{/if}
<style>
.assessment {
display: grid;
gap: var(--space-4);
padding: var(--space-5);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
background: var(--surface);
min-width: 0;
}
.heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
flex-wrap: wrap;
}
h2,
h3,
p {
margin: 0;
}
h2 {
font-size: var(--text-lg);
}
h3 {
font-size: var(--text-sm);
}
p,
.rules {
line-height: var(--leading-body);
overflow-wrap: anywhere;
}
.assessment-summary,
.findings p {
white-space: pre-wrap;
}
.rules {
padding-left: var(--space-5);
margin: var(--space-2) 0 0;
}
.findings,
.history {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: var(--space-3);
}
.findings > li {
display: grid;
gap: var(--space-2);
padding: var(--space-3);
background: var(--surface-sunken);
border-radius: var(--radius-md);
}
.hint {
color: var(--text-secondary);
font-size: var(--text-sm);
}
details {
border-top: 1px solid var(--border-subtle);
padding-top: var(--space-3);
}
summary {
cursor: pointer;
font-size: var(--text-sm);
}
.history {
margin-top: var(--space-4);
}
.history > li {
display: grid;
gap: var(--space-3);
padding-bottom: var(--space-4);
border-bottom: 1px solid var(--border-subtle);
}
</style>
@@ -0,0 +1,260 @@
<script lang="ts">
import { untrack } from 'svelte';
import {
moderationConcernCategories,
moderationConcernLabels,
moderationFindingOutcomes,
moderationFindingLabels,
type ModerationCaseAssessment,
type ModerationConcernCategory,
type ModerationFindingOutcome
} from '$lib/moderation.js';
import Dialog from '../primitives/Dialog.svelte';
import Field from '../primitives/Field.svelte';
import Select from '../primitives/Select.svelte';
import Textarea from '../primitives/Textarea.svelte';
import Button from '../primitives/Button.svelte';
interface DraftFinding {
category: ModerationConcernCategory | '';
finding: ModerationFindingOutcome | '';
detail: string;
key: number;
}
let {
open = $bindable(false),
caseId,
revision,
assessment,
onsubmitted
}: {
open?: boolean;
caseId: string;
revision: number;
assessment: ModerationCaseAssessment | null;
onsubmitted: () => void;
} = $props();
const openedRevision = untrack(() => revision);
let summary = $state(untrack(() => assessment?.summary ?? ''));
let rulesText = $state(untrack(() => assessment?.rules.join('\n') ?? ''));
let nextKey = 0;
let findings = $state<DraftFinding[]>(
untrack(
() =>
assessment?.findings.map((finding) => ({ ...finding, key: nextKey++ })) ?? [
{ category: '', finding: '', detail: '', key: nextKey++ }
]
)
);
let busy = $state(false);
let uncertain = $state(false);
let error = $state('');
let pending: Record<string, unknown> | null = null;
async function submit(event: SubmitEvent) {
event.preventDefault();
if (busy) return;
if (!pending) {
const rules = rulesText
.split('\n')
.map((rule) => rule.trim())
.filter(Boolean);
if (
!summary.trim() ||
!findings.length ||
findings.some((item) => !item.category || !item.finding || !item.detail.trim())
) {
error = 'Add a summary and complete each concern, finding, and explanation.';
return;
}
if (rules.length > 20 || rules.some((rule) => rule.length > 160)) {
error = 'Use up to 20 applicable rules, with no more than 160 characters per line.';
return;
}
pending = {
op: 'assess-case',
id: caseId,
revision: openedRevision,
summary: summary.trim(),
rules,
findings: findings.map(({ category, finding, detail }) => ({
category,
finding,
detail: detail.trim()
})),
idempotencyKey: crypto.randomUUID()
};
}
busy = true;
error = '';
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 the assessment.');
}
open = false;
onsubmitted();
} catch (cause) {
if (pending) uncertain = true;
error = cause instanceof Error ? cause.message : 'Could not save the assessment.';
} finally {
busy = false;
}
}
</script>
<Dialog
bind:open
title={assessment ? 'Edit case assessment' : 'Assess case'}
description="Record the current evidence assessment. Each saved revision is preserved in the case history."
{busy}
wide
>
<form onsubmit={submit}>
<fieldset disabled={busy || uncertain}>
<Field label="Current summary" required
><Textarea bind:value={summary} required rows={3} maxlength={2000} /></Field
>
<Field
label="Applicable rules"
hint="One rule or policy reference per line. Up to 20 lines, 160 characters each."
><Textarea bind:value={rulesText} rows={3} maxlength={3220} /></Field
>
<div class="findings">
{#each findings as item, index (item.key)}
<section class="finding" aria-label={`Finding ${index + 1}`}>
<div class="finding-heading">
<h3>Finding {index + 1}</h3>
<Button
size="sm"
disabled={findings.length === 1}
ariaLabel={`Remove finding ${index + 1}`}
onclick={() =>
(findings = findings.filter(
(finding) => finding.key !== item.key
))}>Remove</Button
>
</div>
<div class="paired">
<Field label={`Concern ${index + 1}`} required
><Select
bind:value={item.category}
required
options={[
{ value: '', label: 'Select a concern' },
...moderationConcernCategories.map((category) => ({
value: category,
label: moderationConcernLabels[category],
disabled: findings.some(
(other) =>
other.key !== item.key &&
other.category === category
)
}))
]}
/></Field
>
<Field label={`Finding ${index + 1}`} required
><Select
bind:value={item.finding}
required
options={[
{ value: '', label: 'Select a finding' },
...moderationFindingOutcomes.map((finding) => ({
value: finding,
label: moderationFindingLabels[finding]
}))
]}
/></Field
>
</div>
<Field
label={`Explanation ${index + 1}`}
required
hint="Explain how the evidence supports this finding, including uncertainty."
><Textarea
bind:value={item.detail}
required
rows={3}
maxlength={1000}
/></Field
>
</section>
{/each}
<Button
size="sm"
disabled={findings.length >= moderationConcernCategories.length}
onclick={() =>
(findings = [
...findings,
{ category: '', finding: '', detail: '', key: nextKey++ }
])}>Add finding</Button
>
</div>
</fieldset>
{#if error}<p class="ui-error" role="alert">{error}</p>{/if}
{#if uncertain}<p class="hint">
The result is uncertain. Retry sends the same assessment safely.
</p>{/if}
<div class="actions">
<Button disabled={busy} onclick={() => (open = false)}>Cancel</Button><Button
type="submit"
variant="primary"
loading={busy}>{uncertain ? 'Retry same request' : 'Save assessment'}</Button
>
</div>
</form>
</Dialog>
<style>
form,
fieldset,
.findings,
.finding {
display: grid;
gap: var(--space-4);
min-width: 0;
}
fieldset {
margin: 0;
padding: 0;
border: 0;
}
.finding {
padding: var(--space-4);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
}
.finding-heading,
.actions {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: var(--space-3);
}
h3 {
margin: 0;
font-size: var(--text-base);
}
.paired {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-3);
}
.actions {
justify-content: flex-end;
}
.hint {
font-size: var(--text-sm);
line-height: var(--leading-body);
color: var(--text-secondary);
}
</style>
@@ -0,0 +1,97 @@
<script lang="ts">
import type { ModerationCase, ModerationCaseAction } from '$lib/moderation.js';
import { timeFormat } from '$ui/stores/time-zone.js';
import Badge from '../primitives/Badge.svelte';
import Button from '../primitives/Button.svelte';
import CaseReviewDialog from './CaseReviewDialog.svelte';
let {
item,
actions = [],
checkedAt,
permitted,
onsubmitted
}: {
item: ModerationCase;
actions?: ModerationCaseAction[];
checkedAt: string;
permitted: boolean;
onsubmitted: () => void;
} = $props();
let open = $state(false);
let clearing = $state(false);
const overdue = $derived(!!item.reviewAt && Date.parse(item.reviewAt) <= Date.parse(checkedAt));
$effect(() => {
void item.id;
open = false;
});
</script>
<section aria-label="Follow-up review">
<div class="heading">
<h2>Follow-up review</h2>
{#if overdue}<Badge variant="warning">Overdue review</Badge>{/if}
</div>
{#if item.reviewAt}<p>
<time datetime={item.reviewAt}>{$timeFormat.dateTime(item.reviewAt)}</time>
</p>
{#if item.reviewSanctionId}<p class="hint">Review after linked mute expires.</p>{/if}
{:else}<p class="hint">No review scheduled.</p>{/if}
{#if item.reviewReason}<p class="reason">{item.reviewReason}</p>{/if}
{#if permitted}<div class="actions">
<Button
size="sm"
onclick={() => {
clearing = false;
open = true;
}}>{item.reviewAt ? 'Reschedule review' : 'Schedule review'}</Button
>
{#if item.reviewAt}<Button
size="sm"
onclick={() => {
clearing = true;
open = true;
}}>Complete or clear review</Button
>{/if}
</div>{/if}
</section>
{#if permitted && open}<CaseReviewDialog bind:open {item} {actions} {clearing} {onsubmitted} />{/if}
<style>
section {
display: grid;
gap: var(--space-3);
padding: var(--space-5);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
background: var(--surface);
min-width: 0;
}
.heading,
.actions {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--space-3);
}
.heading {
justify-content: space-between;
}
h2 {
margin: 0;
font-size: var(--text-lg);
}
p {
margin: 0;
line-height: var(--leading-body);
overflow-wrap: anywhere;
}
.reason {
white-space: pre-wrap;
}
.hint {
color: var(--text-secondary);
font-size: var(--text-sm);
}
</style>
@@ -0,0 +1,193 @@
<script lang="ts">
import { untrack } from 'svelte';
import {
moderationCaseRelations,
moderationCaseRelationLabels,
type ModerationCaseDetail,
type ModerationCaseRelationship,
type ModerationCaseRelation
} from '$lib/moderation.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),
caseId,
revision,
candidates = [],
relationship,
onsubmitted
}: {
open?: boolean;
caseId: string;
revision: number;
candidates?: NonNullable<ModerationCaseDetail['candidateCases']>;
relationship?: ModerationCaseRelationship;
onsubmitted: () => void;
} = $props();
const openedRevision = untrack(() => revision);
let selected = $state('');
let caseReference = $state('');
let relation = $state<ModerationCaseRelation>('same_incident');
let reason = $state('');
let busy = $state(false);
let uncertain = $state(false);
let error = $state('');
let pending: Record<string, unknown> | null = null;
function relatedId(): string {
if (selected) return selected;
const value = caseReference.trim();
if (value.startsWith('/') || /^https?:\/\//i.test(value)) {
const path = new URL(value, 'http://localhost').pathname;
const match = /^\/moderation\/cases\/([^/]+)\/?$/.exec(path);
if (!match) throw new Error('Enter a case ID or a moderation case URL.');
return decodeURIComponent(match[1]);
}
return value;
}
async function submit(event: SubmitEvent) {
event.preventDefault();
if (busy) return;
error = '';
if (!pending) {
try {
const target = relationship ? '' : relatedId();
if ((!relationship && !target) || !reason.trim())
throw new Error('Choose a related case and provide a reason for this change.');
pending = {
id: caseId,
revision: openedRevision,
reason: reason.trim(),
idempotencyKey: crypto.randomUUID(),
...(relationship
? { op: 'unlink-case', relationshipId: relationship.id }
: { op: 'link-case', relatedCaseId: target, relation })
};
} catch (cause) {
error = cause instanceof Error ? cause.message : 'Enter a valid case reference.';
return;
}
}
busy = true;
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 the case relationship.');
}
open = false;
onsubmitted();
} catch (cause) {
if (pending) uncertain = true;
error =
cause instanceof Error ? cause.message : 'Could not save the case relationship.';
} finally {
busy = false;
}
}
</script>
<Dialog
bind:open
title={relationship ? 'Remove case relationship' : 'Link related case'}
{busy}
description="Relationships connect case records while preserving each case and its history."
>
<form onsubmit={submit}>
<fieldset disabled={busy || uncertain}>
{#if relationship}<p>
Remove the relationship with <strong>{relationship.case.title}</strong>. The
relationship history remains recorded.
</p>
{:else}
<Field label="Related case"
><Select
bind:value={selected}
options={[
{ value: '', label: 'Enter a case ID or URL' },
...candidates.map((item) => ({
value: item.id,
label: `${item.title}${item.sharedMatch ? ' · Shared archived match' : ''}`
}))
]}
/></Field
>
{#if !selected}<Field label="Case ID or URL" required
><Input bind:value={caseReference} required maxlength={2048} /></Field
>{/if}
<Field label="Relationship"
><Select
bind:value={relation}
options={moderationCaseRelations.map((value) => ({
value,
label: moderationCaseRelationLabels[value]
}))}
/></Field
>
<p class="hint">
Appeal and duplicate relationships refer to the same player. “Same incident” can
connect cases involving different players.
</p>
{/if}
<Field label="Relationship reason" required
><Textarea bind:value={reason} required rows={3} maxlength={4000} /></Field
>
</fieldset>
{#if error}<p class="ui-error" role="alert">{error}</p>{/if}
{#if uncertain}<p class="hint">
The result is uncertain. Retry sends the same request safely.
</p>{/if}
<div class="actions">
<Button disabled={busy} onclick={() => (open = false)}>Cancel</Button><Button
type="submit"
variant="primary"
loading={busy}
>{uncertain
? 'Retry same request'
: relationship
? 'Remove relationship'
: 'Link cases'}</Button
>
</div>
</form>
</Dialog>
<style>
form,
fieldset {
display: grid;
gap: var(--space-4);
min-width: 0;
}
fieldset {
margin: 0;
padding: 0;
border: 0;
}
p {
margin: 0;
line-height: var(--leading-body);
overflow-wrap: anywhere;
}
.hint {
color: var(--text-secondary);
font-size: var(--text-sm);
}
.actions {
display: flex;
justify-content: flex-end;
flex-wrap: wrap;
gap: var(--space-3);
}
</style>
@@ -0,0 +1,153 @@
<script lang="ts">
import {
moderationCaseRelationLabels,
moderationCaseStatusLabels,
type ModerationCaseDetail,
type ModerationCaseRelationship
} from '$lib/moderation.js';
import { moderationCaseHref } from '$ui/moderation.js';
import Button from '../primitives/Button.svelte';
import Badge from '../primitives/Badge.svelte';
import RelativeTime from '../RelativeTime.svelte';
import CaseRelationshipDialog from './CaseRelationshipDialog.svelte';
let {
caseId,
revision,
relationships = [],
candidates = [],
permitted,
onsubmitted
}: {
caseId: string;
revision: number;
relationships?: ModerationCaseRelationship[];
candidates?: NonNullable<ModerationCaseDetail['candidateCases']>;
permitted: boolean;
onsubmitted: () => void;
} = $props();
let open = $state(false);
let removing = $state<ModerationCaseRelationship | undefined>();
$effect(() => {
void caseId;
open = false;
});
function label(relationship: ModerationCaseRelationship) {
if (relationship.direction === 'incoming') {
if (relationship.relation === 'appeal_of') return 'Appealed by';
if (relationship.relation === 'duplicate_of') return 'Has duplicate';
}
return moderationCaseRelationLabels[relationship.relation];
}
</script>
<section aria-label="Related cases">
<div class="heading">
<h2>Related cases</h2>
{#if permitted}<Button
size="sm"
onclick={() => {
removing = undefined;
open = true;
}}>Link case</Button
>{/if}
</div>
{#if relationships.length}<ul class="records">
{#each relationships as relationship (relationship.id)}<li>
<div class="heading">
<strong>{label(relationship)}</strong><Badge
>{moderationCaseStatusLabels[relationship.case.status]}</Badge
>
</div>
<a href={moderationCaseHref(relationship.case.id)}>{relationship.case.title}</a>
<p class="hint">
{relationship.actor} · <RelativeTime value={relationship.createdAt} />
</p>
{#if permitted}<Button
size="sm"
ariaLabel={`Remove relationship with ${relationship.case.title}`}
onclick={() => {
removing = relationship;
open = true;
}}>Remove link</Button
>{/if}
</li>{/each}
</ul>{:else}<p class="hint">No cases linked yet.</p>{/if}
{#if candidates.length}<details>
<summary>Other cases for this player ({candidates.length})</summary>
<ul class="records">
{#each candidates as candidate}<li>
<a href={moderationCaseHref(candidate.id)}>{candidate.title}</a>
<span class="hint">{moderationCaseStatusLabels[candidate.status]}</span>
{#if candidate.sharedMatch}<Badge variant="warning"
>Shared archived match</Badge
>
<p class="hint">
Review for a possible duplicate or related incident.
</p>{/if}
</li>{/each}
</ul>
</details>{/if}
</section>
{#if permitted && open}<CaseRelationshipDialog
bind:open
{caseId}
{revision}
{candidates}
relationship={removing}
{onsubmitted}
/>{/if}
<style>
section {
display: grid;
gap: var(--space-4);
padding: var(--space-5);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
background: var(--surface);
min-width: 0;
}
.heading {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: var(--space-3);
}
h2 {
margin: 0;
font-size: var(--text-lg);
}
p {
margin: 0;
line-height: var(--leading-body);
}
.hint {
color: var(--text-secondary);
font-size: var(--text-sm);
}
.records {
list-style: none;
padding: 0;
margin: 0;
display: grid;
gap: var(--space-3);
}
.records > li {
display: grid;
justify-items: start;
gap: var(--space-2);
padding-top: var(--space-3);
border-top: 1px solid var(--border-subtle);
overflow-wrap: anywhere;
}
summary {
cursor: pointer;
font-size: var(--text-sm);
}
details .records {
margin-top: var(--space-3);
}
</style>
@@ -0,0 +1,180 @@
<script lang="ts">
import { untrack } from 'svelte';
import type { ModerationCase, ModerationCaseAction } from '$lib/moderation.js';
import { timeFormat } from '$ui/stores/time-zone.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),
item,
actions,
clearing = false,
onsubmitted
}: {
open?: boolean;
item: ModerationCase;
actions: ModerationCaseAction[];
clearing?: boolean;
onsubmitted: () => void;
} = $props();
const openedRevision = untrack(() => item.revision);
const caseId = untrack(() => item.id);
const mutes = $derived(
actions.filter(
(action) => action.type === 'restriction' && action.kind === 'mute' && action.expiresAt
)
);
let source = $state(untrack(() => item.reviewSanctionId ?? 'custom'));
function browserDate(iso?: string | null): string {
if (!iso) return '';
const date = new Date(iso);
if (!Number.isFinite(date.getTime())) return '';
const pad = (value: number) => String(value).padStart(2, '0');
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
}
let date = $state(untrack(() => browserDate(item.reviewAt)));
let reason = $state(untrack(() => (clearing ? '' : (item.reviewReason ?? ''))));
let busy = $state(false);
let uncertain = $state(false);
let error = $state('');
let pending: Record<string, unknown> | null = null;
async function submit(event: SubmitEvent) {
event.preventDefault();
if (busy) return;
if (!pending) {
const chosen = new Date(date);
if (
!reason.trim() ||
(!clearing && source === 'custom' && (!date || !Number.isFinite(chosen.getTime())))
) {
error = 'Choose a review date and record a reason for this follow-up change.';
return;
}
if (!clearing && source !== 'custom' && !mutes.some((mute) => mute.id === source)) {
error = 'Choose an available linked mute or a custom review date.';
return;
}
pending = {
op: 'schedule-case-review',
id: caseId,
revision: openedRevision,
reason: reason.trim(),
idempotencyKey: crypto.randomUUID(),
...(clearing
? { reviewAt: null }
: source === 'custom'
? { reviewAt: chosen.toISOString() }
: { restrictionId: source })
};
}
busy = true;
error = '';
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 update the follow-up review.');
}
open = false;
onsubmitted();
} catch (cause) {
if (pending) uncertain = true;
error =
cause instanceof Error ? cause.message : 'Could not update the follow-up review.';
} finally {
busy = false;
}
}
</script>
<Dialog
bind:open
title={clearing ? 'Complete or clear review' : 'Schedule follow-up review'}
{busy}
description={clearing
? 'Record the review result or why the follow-up is no longer needed. This clears its due date.'
: 'Follow-up reviews remain scheduled when a case is closed.'}
>
<form onsubmit={submit}>
<fieldset disabled={busy || uncertain}>
{#if !clearing}
<Field label="Review timing"
><Select
bind:value={source}
options={[
{ value: 'custom', label: 'Choose a date and time' },
...mutes.map((mute) => ({
value: mute.id,
label: `After mute expires · ${$timeFormat.dateTime(mute.expiresAt!)} · ${mute.reason}`
}))
]}
/></Field
>
{#if source === 'custom'}<Field label="Review date (browser local time)" required
><Input type="datetime-local" bind:value={date} required /></Field
>
{:else}<p class="hint">
The review will be due at the linked mute's recorded expiration time.
</p>{/if}
{/if}
<Field
label={clearing ? 'Review result or clearing reason' : 'Follow-up reason'}
required><Textarea bind:value={reason} required rows={3} maxlength={4000} /></Field
>
</fieldset>
{#if error}<p class="ui-error" role="alert">{error}</p>{/if}
{#if uncertain}<p class="hint">
The result is uncertain. Retry sends the same request safely.
</p>{/if}
<div class="actions">
<Button disabled={busy} onclick={() => (open = false)}>Cancel</Button><Button
type="submit"
variant="primary"
loading={busy}
>{uncertain
? 'Retry same request'
: clearing
? 'Complete or clear review'
: 'Save follow-up'}</Button
>
</div>
</form>
</Dialog>
<style>
form,
fieldset {
display: grid;
gap: var(--space-4);
min-width: 0;
}
fieldset {
padding: 0;
margin: 0;
border: 0;
}
.hint {
margin: 0;
color: var(--text-secondary);
font-size: var(--text-sm);
line-height: var(--leading-body);
}
.actions {
display: flex;
justify-content: flex-end;
flex-wrap: wrap;
gap: var(--space-3);
}
</style>
@@ -0,0 +1,131 @@
<script lang="ts">
import type { ModerationCaseNotification } from '$lib/moderation.js';
import Button from '../primitives/Button.svelte';
import Badge from '../primitives/Badge.svelte';
import RelativeTime from '../RelativeTime.svelte';
let {
caseId,
notifications = [],
unread = 0,
permitted,
onsubmitted
}: {
caseId: string;
notifications?: ModerationCaseNotification[];
unread?: number;
permitted: boolean;
onsubmitted: () => void;
} = $props();
let busy = $state(false);
let error = $state('');
let pending: Record<string, unknown> | null = $state(null);
const throughId = $derived(Math.max(0, ...notifications.map((item) => item.id)));
$effect(() => {
void caseId;
pending = null;
error = '';
});
async function markSeen() {
if (busy || !permitted || (!pending && !throughId)) return;
pending ??= {
op: 'read-case-updates',
id: caseId,
throughId,
idempotencyKey: crypto.randomUUID()
};
busy = true;
error = '';
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) {
if (response.status < 500 && !response.ok) pending = null;
throw new Error(result?.error || 'Could not mark these updates as seen.');
}
pending = null;
onsubmitted();
} catch (cause) {
error =
cause instanceof Error ? cause.message : 'Could not mark these updates as seen.';
} finally {
busy = false;
}
}
</script>
<section aria-label="Your case updates">
<div class="heading">
<h2>Your updates</h2>
{#if unread}<Badge variant="warning">{unread} unread</Badge>{/if}
</div>
{#if notifications.length}<ol>
{#each notifications as update (update.id)}<li>
<div class="heading">
<strong
>{update.kind === 'evidence'
? 'Evidence or note added'
: 'Assignment changed'}</strong
>{#if !update.readAt}<Badge>Unread</Badge>{/if}
</div>
<RelativeTime value={update.createdAt} />
</li>{/each}
</ol>{:else}<p class="hint">No personal case updates.</p>{/if}
{#if notifications.length >= 25}<p class="hint">
Showing your latest 25 updates for this case.
</p>{/if}
{#if error}<p class="ui-error" role="alert">{error}</p>{/if}
{#if permitted && (unread || pending)}<p class="hint">
Marks your updates through the newest shown entry as read.
</p>
<Button size="sm" disabled={!throughId} loading={busy} onclick={markSeen}
>{pending && error ? 'Retry same request' : 'Mark updates as seen'}</Button
>{/if}
</section>
<style>
section {
display: grid;
gap: var(--space-3);
padding: var(--space-5);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
background: var(--surface);
min-width: 0;
}
.heading {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: var(--space-3);
}
h2 {
margin: 0;
font-size: var(--text-lg);
}
ol {
list-style: none;
display: grid;
gap: var(--space-3);
margin: 0;
padding: 0;
}
li {
display: grid;
gap: var(--space-2);
padding-top: var(--space-3);
border-top: 1px solid var(--border-subtle);
font-size: var(--text-sm);
}
.hint {
margin: 0;
color: var(--text-secondary);
font-size: var(--text-sm);
line-height: var(--leading-body);
}
</style>
@@ -0,0 +1,108 @@
<script lang="ts">
import type { ContextualChatEntry } from '$lib/game-chat.js';
import { timeFormat } from '$ui/stores/time-zone.js';
import Badge from '../primitives/Badge.svelte';
let {
entries,
selectedMessageIds = [],
highlightedMessageIds = []
}: {
entries: ContextualChatEntry[];
selectedMessageIds?: string[];
highlightedMessageIds?: string[];
} = $props();
</script>
<p class="excerpt-note">
Selected messages and surrounding context in archive order. Messages between separate selections
may be omitted.
</p>
<ol class="excerpt" aria-label="Preserved conversation excerpt">
{#each entries as entry}
{#if entry.type === 'message'}
{@const highlighted =
!!entry.messageId && highlightedMessageIds.includes(entry.messageId)}
{@const selected = !!entry.messageId && selectedMessageIds.includes(entry.messageId)}
<li class:highlighted>
<div class="author">
<strong>{entry.message.player}</strong>
{#if entry.message.channel === 'team'}<Badge>Team chat</Badge>{/if}
{#if highlighted}<Badge variant="warning">Subject message</Badge>
{:else if selected}<Badge>Selected context</Badge>
{:else}<span>Surrounding context</span>{/if}
</div>
<div class="metadata">
<span>GUID {entry.message.guid}</span>
{#if entry.context.realTime}<span
>Estimated time <time datetime={entry.context.realTime}
>{$timeFormat.dateTime(entry.context.realTime)}</time
></span
>{:else}<span>Message time unknown</span>{/if}
<span>Log {entry.message.time}</span>
{#if entry.context.round !== null}<span>Round {entry.context.round}</span>{/if}
</div>
<p>{entry.message.message}</p>
{#if entry.messageId}<details>
<summary>Message identifier</summary><code>{entry.messageId}</code>
</details>{/if}
</li>
{/if}
{/each}
</ol>
<style>
.excerpt {
display: grid;
gap: var(--space-2);
list-style: none;
margin: 0;
padding: 0;
}
li {
padding: var(--space-3);
border-left: 3px solid var(--border-subtle);
background: var(--surface-sunken);
min-width: 0;
}
li.highlighted {
border-left-color: var(--state-warning);
background: var(--surface-raised);
}
.author,
.metadata {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-2);
overflow-wrap: anywhere;
}
.author > span,
.metadata {
color: var(--text-secondary);
font-size: var(--text-xs);
}
.metadata {
margin-top: var(--space-1);
}
p {
white-space: pre-wrap;
overflow-wrap: anywhere;
margin: var(--space-2) 0 0;
line-height: var(--leading-body);
}
details {
margin-top: var(--space-2);
font-size: var(--text-xs);
color: var(--text-secondary);
overflow-wrap: anywhere;
}
summary {
cursor: pointer;
}
.excerpt-note {
margin: 0;
font-size: var(--text-xs);
color: var(--text-secondary);
}
</style>
@@ -0,0 +1,106 @@
<script lang="ts">
import type { ModerationGame, ModerationOverview } from '$lib/moderation.js';
import { moderationCaseStatusLabels } from '$lib/moderation.js';
import { moderationCaseHref, moderationHref } from '$ui/moderation.js';
let { game, guid }: { game: ModerationGame; guid: string } = $props();
let report = $state<ModerationOverview | null>(null);
let loading = $state(false);
let failed = $state(false);
let retry = $state(0);
$effect(() => {
const target = guid.trim();
const targetGame = game;
void retry;
const controller = new AbortController();
report = null;
failed = false;
loading = !!target;
const timer = setTimeout(async () => {
if (!target) return;
try {
const params = new URLSearchParams({
view: 'cases',
game: targetGame,
guid: target
});
const response = await fetch(`/api/moderation?${params}`, {
signal: controller.signal
});
if (!response.ok) throw new Error('Case lookup failed');
const result: ModerationOverview = await response.json();
if (!Array.isArray(result.cases) || !Number.isSafeInteger(result.total))
throw new Error('Invalid case lookup response');
if (!controller.signal.aborted) report = result;
} catch {
if (!controller.signal.aborted) failed = true;
} finally {
if (!controller.signal.aborted) loading = false;
}
}, 300);
return () => {
clearTimeout(timer);
controller.abort();
};
});
</script>
<aside aria-label="Existing cases for this player">
{#if loading}<p role="status">Checking existing cases for this player…</p>
{:else if failed}<p role="status">
Existing cases could not be loaded. You can still open a case. <button
type="button"
onclick={() => retry++}>Retry lookup</button
>
</p>
{:else if report?.cases.length}
<strong
>{report.total} existing {report.total === 1 ? 'case' : 'cases'} for this player</strong
>
<p>Review these before opening another case.</p>
<ul>
{#each report.cases as item}<li>
<a href={moderationCaseHref(item.id)} target="_blank" rel="noreferrer"
>{item.title}</a
>
· {moderationCaseStatusLabels[item.status]}
</li>{/each}
</ul>
<a href={`${moderationHref({ game, guid })}&view=cases`} target="_blank" rel="noreferrer"
>View all cases for this player</a
>
{:else if report}<p>No existing cases found for this player.</p>{/if}
</aside>
<style>
aside {
padding: var(--space-3);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
color: var(--text-secondary);
font-size: var(--text-sm);
}
p {
margin: 0;
line-height: var(--leading-body);
}
ul {
margin: var(--space-2) 0;
padding-left: var(--space-5);
max-height: 200px;
overflow: auto;
}
li {
padding: var(--space-1) 0;
overflow-wrap: anywhere;
}
button {
border: 0;
background: none;
padding: 0;
color: var(--accent-primary);
font: inherit;
cursor: pointer;
text-decoration: underline;
}
</style>
@@ -0,0 +1,161 @@
<script lang="ts">
import type { ModerationCaseAction } from '$lib/moderation.js';
import { moderationActionName } from '$ui/moderation.js';
import { timeFormat } from '$ui/stores/time-zone.js';
import Dialog from '../primitives/Dialog.svelte';
import Field from '../primitives/Field.svelte';
import Select from '../primitives/Select.svelte';
import Textarea from '../primitives/Textarea.svelte';
import Button from '../primitives/Button.svelte';
import StatusBadge from '../primitives/StatusBadge.svelte';
let {
open = $bindable(false),
caseId,
revision,
actions,
onsubmitted
}: {
open?: boolean;
caseId: string;
revision: number;
actions: ModerationCaseAction[];
onsubmitted: () => void;
} = $props();
let selected = $state('');
let reason = $state('');
let busy = $state(false);
let error = $state('');
let uncertain = $state(false);
let pending: Record<string, unknown> | null = null;
const action = $derived(actions.find((item) => `${item.type}:${item.id}` === selected));
async function submit(event: SubmitEvent) {
event.preventDefault();
if (busy) return;
if (!pending) {
if (!action || !reason.trim()) {
error = 'Select an existing action and explain its connection to this case.';
return;
}
pending = {
op: 'link-case-action',
id: caseId,
revision,
actionType: action.type,
actionId: action.id,
reason: reason.trim(),
idempotencyKey: crypto.randomUUID()
};
}
busy = true;
error = '';
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 link the existing action.');
}
open = false;
onsubmitted();
} catch (cause) {
if (pending) uncertain = true;
error = cause instanceof Error ? cause.message : 'Could not link the existing action.';
} finally {
busy = false;
}
}
</script>
<Dialog
bind:open
title="Link existing action"
description="Connect a previously recorded action to this case. Linking preserves its original date and outcome without issuing it again."
{busy}
>
<form onsubmit={submit}>
<fieldset disabled={busy || uncertain}>
<Field label="Existing action" required>
<Select
bind:value={selected}
required
options={[
{ value: '', label: 'Select an action' },
...actions.map((item) => ({
value: `${item.type}:${item.id}`,
label: `${moderationActionName(item.kind)} · ${$timeFormat.dateTime(item.createdAt)} · ${item.reason}`
}))
]}
/>
</Field>
{#if action}<div class="action-detail">
<strong>{moderationActionName(action.kind)}</strong>
<StatusBadge state={`moderation-${action.state}`} />
<p>{action.reason}</p>
<p class="hint">
Recorded by {action.actor} · {$timeFormat.dateTime(action.createdAt)}
</p>
{#if action.type === 'restriction'}<p class="hint">
{action.expiresAt
? `Expires ${$timeFormat.dateTime(action.expiresAt)}`
: 'Permanent restriction'}
</p>{/if}
</div>{/if}
<Field label="Link reason" required hint="Explain how this action relates to the case."
><Textarea bind:value={reason} rows={3} maxlength={4000} required /></Field
>
</fieldset>
{#if error}<p class="ui-error" role="alert">{error}</p>{/if}
{#if uncertain}<p class="hint">
The result is uncertain. Retry sends the same request safely.
</p>{/if}
<div class="actions">
<Button disabled={busy} onclick={() => (open = false)}>Cancel</Button><Button
type="submit"
variant="primary"
loading={busy}>{uncertain ? 'Retry same request' : 'Link action'}</Button
>
</div>
</form>
</Dialog>
<style>
form,
fieldset {
display: grid;
gap: var(--space-4);
min-width: 0;
}
fieldset {
margin: 0;
padding: 0;
border: 0;
}
.action-detail {
padding: var(--space-3);
background: var(--surface-sunken);
border-radius: var(--radius-md);
}
.action-detail p {
margin: var(--space-2) 0 0;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.hint {
color: var(--text-secondary);
font-size: var(--text-sm);
line-height: var(--leading-body);
}
.actions {
display: flex;
justify-content: flex-end;
gap: var(--space-3);
flex-wrap: wrap;
}
</style>
@@ -0,0 +1,305 @@
<script lang="ts">
import { untrack } from 'svelte';
import type { ContextualChatEntry } from '$lib/game-chat.js';
import {
moderationGuid,
moderationCaseStatusLabels,
type ModerationCase,
type ModerationGame,
type ModerationOverview
} from '$lib/moderation.js';
import { conversationExcerpt } from '$ui/moderation-evidence.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';
import EvidenceMessages from './EvidenceMessages.svelte';
let {
open = $bindable(false),
game,
matchId,
entries,
selectedMessageIds,
onsubmitted
}: {
open?: boolean;
game: ModerationGame;
matchId: number;
entries: ContextualChatEntry[];
selectedMessageIds: string[];
onsubmitted: (id: string) => void;
} = $props();
const selected = $derived(
entries.filter(
(entry) =>
entry.type === 'message' &&
!!entry.messageId &&
selectedMessageIds.includes(entry.messageId)
)
);
const participants = $derived.by(() => {
const players = new Map<string, string>();
for (const entry of selected) {
if (entry.type === 'message')
players.set(moderationGuid(game, entry.message.guid), entry.message.player);
}
return [...players].map(([guid, name]) => ({
value: guid,
label: `${name} · GUID ${guid}`
}));
});
let guid = $state(untrack(() => participants[0]?.value ?? ''));
let destination = $state('');
let title = $state('');
let note = $state('');
let contextBefore = $state<number | undefined>(3);
let contextAfter = $state<number | undefined>(3);
let cases = $state<ModerationCase[]>([]);
let loading = $state(false);
let loadError = $state('');
let reload = $state(0);
let busy = $state(false);
let uncertain = $state(false);
let error = $state('');
let pending: Record<string, unknown> | null = null;
const excerpt = $derived(
conversationExcerpt(entries, selectedMessageIds, contextBefore ?? 0, contextAfter ?? 0)
);
const highlightedMessageIds = $derived(
selected.flatMap((entry) =>
entry.type === 'message' &&
entry.messageId &&
moderationGuid(game, entry.message.guid) === guid
? [entry.messageId]
: []
)
);
$effect(() => {
const selectedGuid = guid;
const selectedGame = game;
void reload;
const controller = new AbortController();
cases = [];
destination = '';
loadError = '';
loading = true;
async function loadCases() {
try {
const loaded: ModerationCase[] = [];
let offset = 0;
while (true) {
const params = new URLSearchParams({
view: 'cases',
game: selectedGame,
guid: selectedGuid,
offset: String(offset)
});
const response = await fetch(`/api/moderation?${params}`, {
signal: controller.signal
});
if (!response.ok)
throw new Error(
'Could not load existing cases. Retry before attaching evidence.'
);
const report: ModerationOverview = await response.json();
if (controller.signal.aborted) return;
loaded.push(...report.cases);
offset += report.cases.length;
if (!report.cases.length || offset >= report.total) break;
}
cases = loaded;
} catch (cause) {
if (!controller.signal.aborted)
loadError =
cause instanceof Error ? cause.message : 'Could not load existing cases.';
} finally {
if (!controller.signal.aborted) loading = false;
}
}
void loadCases();
return () => controller.abort();
});
async function submit(event: SubmitEvent) {
event.preventDefault();
if (busy || (!pending && (loading || loadError))) return;
if (!pending) {
const target = cases.find((item) => item.id === destination);
if (!guid || !highlightedMessageIds.length || (destination && !target)) {
error = 'Choose a case subject with at least one selected message.';
return;
}
if (
![contextBefore, contextAfter].every(
(value) => Number.isInteger(value) && value! >= 0 && value! <= 20
)
) {
error = 'Choose between 0 and 20 surrounding messages on each side.';
return;
}
pending = {
...(target
? { op: 'attach-evidence', id: target.id, revision: target.revision }
: { op: 'create-case', game, guid, title: title.trim(), note: note.trim() }),
evidence: { matchId, messageIds: selectedMessageIds, contextBefore, contextAfter },
idempotencyKey: crypto.randomUUID()
};
}
busy = true;
error = '';
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;
if (response.status === 409) reload++;
throw new Error(result?.error || 'Could not attach the selected evidence.');
}
open = false;
onsubmitted(result.id);
} catch (cause) {
if (pending) uncertain = true;
error =
cause instanceof Error ? cause.message : 'Could not attach the selected evidence.';
} finally {
busy = false;
}
}
</script>
<Dialog
bind:open
title="Attach to case"
{busy}
wide
description={`${selectedMessageIds.length} selected messages from match #${matchId}`}
>
<form onsubmit={submit}>
<fieldset disabled={busy || uncertain}>
<Field
label="Case subject"
required
hint="Select the player being reviewed. Other participants remain in the preserved conversation."
>
<Select bind:value={guid} options={participants} required />
</Field>
{#if loading}<p role="status">Loading this player's cases…</p>
{:else if loadError}<p class="ui-error" role="alert">
{loadError}
<Button size="sm" onclick={() => reload++}>Retry</Button>
</p>
{:else}
<Field
label="Destination"
hint={cases.length
? 'Review existing cases before opening another.'
: 'No existing cases for this player.'}
>
<Select
bind:value={destination}
options={[
{ value: '', label: 'Open a new case' },
...cases.map((item) => ({
value: item.id,
label: `${item.title} · ${moderationCaseStatusLabels[item.status]}`
}))
]}
/>
</Field>
{#if !destination}
<Field label="Case title" required
><Input bind:value={title} maxlength={160} required /></Field
>
<Field label="Private staff note"
><Textarea bind:value={note} rows={3} maxlength={4000} /></Field
>
{/if}
{/if}
<div class="paired">
<Field label="Messages before" hint="Per selected message"
><Input
type="number"
bind:value={contextBefore}
min={0}
max={20}
step={1}
required
/></Field
>
<Field label="Messages after" hint="Per selected message"
><Input
type="number"
bind:value={contextAfter}
min={0}
max={20}
step={1}
required
/></Field
>
</div>
<p class="hint">
{excerpt.length} messages in this excerpt. Selected subject messages are highlighted.
Messages already preserved as evidence in this case are not attached again.
</p>
<EvidenceMessages entries={excerpt} {selectedMessageIds} {highlightedMessageIds} />
</fieldset>
{#if error}<p class="ui-error" role="alert">{error}</p>{/if}
{#if uncertain}<p class="hint">
The result is uncertain. Retry the same request to avoid duplicate evidence.
</p>{/if}
<div class="actions">
<Button onclick={() => (open = false)} disabled={busy}>Cancel</Button>
<Button
type="submit"
variant="primary"
loading={busy}
disabled={!uncertain && (loading || !!loadError)}
>{uncertain
? 'Retry same request'
: destination
? 'Attach evidence'
: 'Open case with evidence'}</Button
>
</div>
</form>
</Dialog>
<style>
form,
fieldset {
display: grid;
gap: var(--space-4);
min-width: 0;
}
fieldset {
border: 0;
margin: 0;
padding: 0;
}
.paired {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-3);
}
.hint {
margin: 0;
font-size: var(--text-sm);
color: var(--text-secondary);
line-height: var(--leading-body);
}
.actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: var(--space-3);
}
</style>
@@ -0,0 +1,539 @@
<script lang="ts">
import { untrack } from 'svelte';
import {
moderationCaseStatuses,
moderationCaseStatusLabels,
type ModerationCoverage,
type ModerationGame,
type ModerationScope,
type ModerationCase
} 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';
import ExistingCaseNotice from './ExistingCaseNotice.svelte';
let {
open = $bindable(false),
mode,
coverage,
admin,
seed = {},
assignees = [],
onsubmitted
}: {
open?: boolean;
mode:
| 'restriction'
| 'action'
| 'case'
| 'note'
| 'revoke'
| 'case-note'
| 'case-status'
| 'edit-case'
| 'evidence';
coverage: ModerationCoverage[];
admin: boolean;
assignees?: { id: string; username: string }[];
seed?: {
game?: ModerationGame;
guid?: string;
server?: string;
id?: string;
revision?: number;
matchId?: number;
message?: string;
caseId?: string;
caseRevision?: number;
title?: string;
priority?: ModerationCase['priority'];
assigneeId?: string | null;
assignee?: string | null;
status?: ModerationCase['status'];
};
onsubmitted: (target: { game?: ModerationGame; guid?: string | null; id?: string }) => 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(untrack(() => seed.title ?? ''));
let priority = $state(untrack(() => seed.priority ?? 'normal'));
let assigneeId = $state(untrack(() => seed.assigneeId ?? ''));
let status = $state(untrack(() => seed.status ?? 'open'));
let matchId = $state<number | undefined>(untrack(() => seed.matchId));
let message = $state(untrack(() => seed.message ?? ''));
const statusUnchanged = $derived(mode === 'case-status' && status === seed.status);
const lockedTarget = $derived(mode === 'restriction' && !!seed.caseId);
const reasonBytes = $derived(new TextEncoder().encode(reason.trim()).length);
const gameText = $derived(mode === 'restriction' || mode === 'action');
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',
'case-status': 'Change status',
'edit-case': 'Edit case',
evidence: 'Attach archived evidence'
}[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),
...(seed.caseId ? { caseId: seed.caseId, caseRevision: seed.caseRevision } : {})
};
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, revision: seed.revision, text: note.trim() };
if (mode === 'edit-case')
return {
op: 'update-case',
id: seed.id,
revision: seed.revision,
title: title.trim(),
priority,
...(assigneeId !== (seed.assigneeId ?? '')
? { assigneeId: assigneeId || null }
: {}),
reason: reason.trim()
};
if (mode === 'evidence')
return {
op: 'attach-evidence',
id: seed.id,
revision: seed.revision,
evidence: { matchId, message }
};
if (mode === 'revoke')
return {
op: 'revoke-sanction',
id: seed.id,
revision: seed.revision,
reason: reason.trim()
};
return {
op: 'update-case',
id: seed.id,
revision: seed.revision,
status,
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,
id: typeof result.id === 'string' ? result.id : 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
disabled={lockedTarget}
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
readonly={lockedTarget}
/></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 lockedTarget}<p class="hint">
This restriction will be linked to the case and its recorded player identity.
Applying it does not close the case; change its status when your work is
complete.
</p>{/if}
{#if mode === 'case' || mode === 'edit-case'}
{#if mode === 'case' && open && game && guid.trim()}<ExistingCaseNotice
{game}
{guid}
/>{/if}
<Field label="Case title" required
><Input bind:value={title} required maxlength={160} /></Field
>
{#if mode === 'case' && 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 === 'edit-case'}
<div class="paired">
<Field label="Priority"
><Select
bind:value={priority}
options={[
{ value: 'low', label: 'Low' },
{ value: 'normal', label: 'Normal' },
{ value: 'high', label: 'High' },
{ value: 'urgent', label: 'Urgent' }
]}
/></Field
>
<Field label="Assigned to"
><Select
bind:value={assigneeId}
options={[
{ value: '', label: 'Unassigned' },
...assignees.map((person) => ({
value: person.id,
label: person.username
})),
...(seed.assigneeId &&
!assignees.some((person) => person.id === seed.assigneeId)
? [
{
value: seed.assigneeId,
label: `${seed.assignee ?? 'Previous assignee'} (unavailable)`
}
]
: [])
]}
/></Field
>
</div>
<Field label="Change reason" hint="Optional note about these edits.">
<Textarea bind:value={reason} rows={3} />
</Field>
{/if}
{#if mode === 'evidence'}
<p class="hint">
Choose a match and copy the exact archived message from this player. The archive
must verify the case's game and player identity before evidence is attached.
</p>
<Field label="Match ID" required
><Input type="number" bind:value={matchId} min={1} step={1} required /></Field
>
<Field label="Exact archived message" required
><Textarea bind:value={message} required rows={4} maxlength={4096} /></Field
>
{/if}
{#if mode === 'case-status'}
<Field label="Status"
><Select
bind:value={status}
options={moderationCaseStatuses.map((value) => ({
value,
label: moderationCaseStatusLabels[value]
}))}
/></Field
>
<p class="hint">
Track whether this case is open, in progress, or closed. Findings and recorded
actions stay in the case history.
</p>
{/if}
{#if ['restriction', 'action', 'revoke', 'case-status'].includes(mode)}
<Field
label={mode === 'action' && ['message', 'announce'].includes(action)
? 'Message'
: mode === 'restriction' || mode === 'action'
? 'Player-visible reason'
: mode === 'case-status'
? 'Change 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 || statusUnchanged)}
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>
@@ -104,6 +104,13 @@
padding: var(--space-6);
border-bottom: 1px solid var(--border-subtle);
}
header > div {
min-width: 0;
overflow-wrap: anywhere;
}
header > :global(.btn) {
flex-shrink: 0;
}
h2 {
margin: 0;
font-size: var(--text-xl);
@@ -11,6 +11,7 @@
required?: boolean;
disabled?: boolean;
readonly?: boolean;
maxlength?: number;
mono?: boolean;
oninput?: (e: Event) => void;
ariaLabel?: string;
@@ -25,6 +26,7 @@
required = false,
disabled = false,
readonly = false,
maxlength,
mono = false,
oninput,
ariaLabel
@@ -41,6 +43,7 @@
{required}
{disabled}
{readonly}
{maxlength}
aria-label={ariaLabel}
aria-labelledby={ariaLabel ? undefined : field?.labelId}
aria-describedby={field?.messageId}
+55
View File
@@ -0,0 +1,55 @@
import type { ContextualChatEntry } from '$lib/game-chat.js';
/** Select surrounding messages by archive order, preserving each message once. */
export function conversationExcerpt(
entries: readonly ContextualChatEntry[],
selectedMessageIds: readonly string[],
contextBefore = 3,
contextAfter = 3
): ContextualChatEntry[] {
const messages = entries.filter((entry) => entry.type === 'message');
const selected = new Set(selectedMessageIds);
const included = new Set<number>();
messages.forEach((entry, index) => {
if (!entry.messageId || !selected.has(entry.messageId)) return;
for (
let i = Math.max(0, index - contextBefore);
i <= Math.min(messages.length - 1, index + contextAfter);
i++
)
included.add(i);
});
return messages.filter((_, index) => included.has(index));
}
/** Old single-message snapshots remain readable alongside conversation excerpts. */
export function preservedConversation(value: unknown): {
entries: ContextualChatEntry[];
selectedMessageIds: string[];
highlightedMessageIds: string[];
} | null {
if (!value || typeof value !== 'object') return null;
const snapshot = value as Record<string, unknown>;
if (!Array.isArray(snapshot.entries)) return null;
const entries = snapshot.entries.filter(
(entry): entry is Extract<ContextualChatEntry, { type: 'message' }> => {
if (!entry || typeof entry !== 'object' || entry.type !== 'message') return false;
return (
typeof entry.message?.player === 'string' &&
typeof entry.message?.guid === 'string' &&
typeof entry.message?.message === 'string' &&
typeof entry.message?.time === 'string' &&
entry.context &&
typeof entry.context === 'object'
);
}
);
if (!entries.length) return null;
const strings = (input: unknown): string[] =>
Array.isArray(input) ? input.filter((id): id is string => typeof id === 'string') : [];
return {
entries,
selectedMessageIds: strings(snapshot.selectedMessageIds),
highlightedMessageIds: strings(snapshot.highlightedMessageIds)
};
}
+206
View File
@@ -0,0 +1,206 @@
import {
moderationCaseStatusLabels,
moderationCaseRelationLabels,
type ModerationCaseRelation,
type ModerationCaseStatus,
type ModerationGame
} from '$lib/moderation.js';
import { serverTitle } from './server-labels.js';
export function moderationCaseHref(id: string): string {
return `/moderation/cases/${encodeURIComponent(id)}`;
}
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',
'reopen-case': 'Case reopened',
'update-case': 'Case updated',
'attach-evidence': 'Case evidence attached',
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',
'case.in_review': 'Case under review',
'case.escalated': 'Case escalated',
'case.warning_issued': 'Warning issued',
'case.restriction_applied': 'Restriction applied',
'case.no_action': 'Closed without action',
'case.duplicate': 'Closed as duplicate',
'case.appeal_upheld': 'Appeal upheld',
'case.appeal_denied': 'Appeal denied',
'case.reopened': 'Case reopened',
'case.updated': 'Case updated',
'case.status_migrated': 'Case status migrated',
'case.assessed': 'Assessment revised',
'case.review_scheduled': 'Follow-up updated',
'schedule-case-review': 'Follow-up updated',
'case.linked': 'Case relationship added',
'case.unlinked': 'Case relationship removed',
'link-case': 'Case relationship added',
'unlink-case': 'Case relationship removed',
'assess-case': 'Assessment revised',
'case.evidence_attached': 'Evidence attached',
'case.restriction': 'Restriction linked to case',
'case.action_linked': 'Existing action linked to case',
'link-case-action': 'Existing action linked to case',
'player.note': 'Private player note',
'runtime.configure': 'Integration configuration requested'
};
export function moderationActionName(kind: string): string {
return (
moderationActionLabels[kind] ?? (kind === 'ban' ? 'Ban' : kind === 'mute' ? 'Mute' : kind)
);
}
export function moderationEventDetail(detail: string, action: string): string {
if (action.startsWith('case.')) {
try {
const record: unknown = JSON.parse(detail);
if (!record || typeof record !== 'object') return detail;
const value = record as Record<string, unknown>;
const display = (item: unknown, status = false): string =>
item === null
? 'Unassigned'
: typeof item === 'string'
? status && Object.hasOwn(moderationCaseStatusLabels, item)
? moderationCaseStatusLabels[item as ModerationCaseStatus]
: item
: '';
if (
(action === 'case.linked' || action === 'case.unlinked') &&
typeof value.relation === 'string' &&
Object.hasOwn(moderationCaseRelationLabels, value.relation) &&
typeof value.relatedCaseId === 'string' &&
typeof value.reason === 'string'
) {
const label =
value.direction === 'incoming' && value.relation !== 'same_incident'
? value.relation === 'appeal_of'
? 'Appealed by'
: 'Has duplicate'
: moderationCaseRelationLabels[value.relation as ModerationCaseRelation];
return `${label}: ${value.relatedCaseId}\n${value.reason}`;
}
if (action === 'case.review_scheduled' && typeof value.reason === 'string')
return `${typeof value.reviewAt === 'string' ? `Review scheduled for ${value.reviewAt}` : 'Follow-up cleared'}\n${value.reason}`;
if (action === 'case.assessed' && typeof value.summary === 'string')
return `Assessment revision ${value.assessmentRevision}: ${value.summary}`;
if (action === 'case.updated') {
const changes = ['title', 'priority', 'assignee', 'status'].flatMap((key) => {
const change = value[key];
if (!change || typeof change !== 'object') return [];
const { from, to } = change as { from?: unknown; to?: unknown };
return [
`${key === 'assignee' ? 'Assigned to' : key.charAt(0).toUpperCase() + key.slice(1)}: ${display(from, key === 'status')} → ${display(to, key === 'status')}`
];
});
if (typeof value.reason === 'string') changes.push(value.reason);
return changes.join('\n') || detail;
}
if (action === 'case.status_migrated')
return `Previous status: ${display(value.from, true)} → ${display(value.to, true)}. Earlier decisions and actions are preserved.`;
if (action === 'case.reopened' && typeof value.reason === 'string')
return `Previous status: ${display(value.from, true)}\n${value.reason}`;
if (
action === 'case.evidence_attached' &&
typeof value.count === 'number' &&
typeof value.matchId === 'number'
)
return `${value.count} archived chat ${value.count === 1 ? 'entry' : 'entries'} attached from match #${value.matchId}.`;
if (action === 'case.restriction' && typeof value.reason === 'string')
return `${value.kind === 'ban' ? 'Ban' : 'Mute'} · ${display(value.scope)} scope\n${value.reason}`;
if (
typeof value.reason === 'string' &&
value.actionReference &&
typeof value.actionReference === 'object'
) {
const reference = value.actionReference as { type?: unknown; id?: unknown };
if (
(reference.type === 'restriction' || reference.type === 'action') &&
typeof reference.id === 'string'
)
return `${value.reason}\nReferenced ${reference.type === 'restriction' ? 'restriction' : 'action'}: ${reference.id}`;
}
} catch {
// Older case events and decision reasons are plain staff-authored text.
}
return detail;
}
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;
}
}
+1 -1
View File
@@ -73,7 +73,7 @@ function healthEvidence(
if (hostOffline)
return {
state: 'host-offline',
reason: `${host.name ? `${host.name}'s host agent` : 'The host agent'} is unavailable. The game may still be running.`,
reason: `The host agent${host.name ? ` on ${host.name}` : ''} is unavailable. The game may still be running.`,
actionLabel: hostId ? 'Check host' : 'Open diagnostics',
actionHref: hostId ? `/hosts/${encodeURIComponent(hostId)}` : diagnostics,
since: observedAt(host.lastHeartbeat, now)
+24
View File
@@ -16,6 +16,30 @@ export const statusShapes: Record<StatusTone, StatusShape> = {
};
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-in_review': ['In progress', 'info'],
'moderation-closed': ['Closed', 'neutral'],
'moderation-escalated': ['Escalated', 'warning'],
'moderation-resolved': ['Resolved', 'online'],
'moderation-dismissed': ['Dismissed', 'neutral'],
'moderation-warning_issued': ['Warning issued', 'online'],
'moderation-restriction_applied': ['Restriction applied', 'online'],
'moderation-no_action': ['No action needed', 'neutral'],
'moderation-duplicate': ['Duplicate', 'neutral'],
'moderation-appeal_upheld': ['Appeal upheld', 'online'],
'moderation-appeal_denied': ['Appeal denied', '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'],
online: ['Online', 'online'],
offline: ['Offline', 'danger'],
+53
View File
@@ -0,0 +1,53 @@
.afk-table-scroll {
max-width: 100%;
overflow-x: auto;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
background: var(--surface);
}
.afk-table {
width: 100%;
border-collapse: collapse;
text-align: left;
font-size: var(--text-sm);
}
.afk-table th {
color: var(--text-tertiary);
background: var(--surface-sunken);
font-weight: var(--weight-medium);
white-space: nowrap;
}
.afk-table th,
.afk-table td {
padding: var(--space-4);
vertical-align: top;
}
.afk-table tbody tr + tr {
border-top: 1px solid var(--border-subtle);
}
.afk-table td {
min-width: 130px;
}
.afk-table small {
display: block;
margin-top: var(--space-1);
font-size: var(--text-xs);
color: var(--text-secondary);
}
.afk-id {
font-family: var(--font-mono);
overflow-wrap: anywhere;
}
.afk-number {
font-variant-numeric: tabular-nums;
}
.afk-table a {
color: var(--accent-primary-hover);
}
.afk-player {
font-weight: var(--weight-semibold);
overflow-wrap: anywhere;
}
.afk-table .afk-warning {
color: var(--state-warning);
}
+6 -1
View File
@@ -738,8 +738,13 @@
min-height: 44px;
}
:global(button),
:global(summary),
:global(.btn) {
min-height: 44px;
/* Keep scoped component size rules from shrinking mobile touch targets. */
min-height: 44px !important;
}
:global(.btn) {
min-width: 44px !important;
}
}
@media (max-width: 360px) {
+1 -1
View File
@@ -413,7 +413,7 @@
><span class="concise-reason"
>{operationIssue ? operation.label : status.shortReason}</span
>{#if status.since}<span class="evidence-time" title={status.since}
><span class="evidence-separator"> · </span>Evidence {elapsedSince(
><span class="evidence-separator">{' · '}</span>Evidence {elapsedSince(
status.since,
$uiNow
)} ago</span
+5
View File
@@ -214,6 +214,7 @@
.page {
max-width: 760px;
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 24px;
}
.panel {
@@ -232,6 +233,10 @@
justify-content: space-between;
gap: 20px;
}
.identity > div {
min-width: 0;
overflow-wrap: anywhere;
}
.identity p {
margin: 8px 0 0;
color: var(--text-secondary);
+13
View File
@@ -0,0 +1,13 @@
import { error } from '@sveltejs/kit';
import { readAfkQuery } from '$lib/afk.js';
import { afkOverview } from '$lib/server/fleet/afk-overview.js';
export function load({ url }: { url: URL }) {
let query;
try {
query = readAfkQuery(url.searchParams);
} catch (cause) {
error(400, cause instanceof Error ? cause.message : 'Invalid AFK filters.');
}
return { report: afkOverview(query) };
}
+484
View File
@@ -0,0 +1,484 @@
<script lang="ts">
import { onMount, untrack } from 'svelte';
import { afterNavigate, beforeNavigate, goto } from '$app/navigation';
import { page, navigating } from '$app/stores';
import { afkLedgerKinds, type AfkOverview, type AfkQuery } from '$lib/afk.js';
import { pollVisible } from '$ui/poll.js';
import { formatDuration } from '$ui/duration.js';
import { serverTitle } from '$ui/server-labels.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 EmptyState from '$ui/components/primitives/EmptyState.svelte';
import Freshness from '$ui/components/Freshness.svelte';
import AfkAccounts from '$ui/components/afk/AfkAccounts.svelte';
import AfkSessions from '$ui/components/afk/AfkSessions.svelte';
import AfkLedger from '$ui/components/afk/AfkLedger.svelte';
import AfkPagination from '$ui/components/afk/AfkPagination.svelte';
import '$ui/styles/afk.css';
let { data }: { data: import('./$types').PageData } = $props();
const number = new Intl.NumberFormat('en-US');
const duration = (seconds: number) => formatDuration(seconds * 1000);
const label = (value: string) => value.replaceAll('_', ' ');
let snapshot = $state<AfkOverview | null>(null);
const report = $derived(snapshot ?? data.report);
let filters = $state<AfkQuery>(untrack(() => ({ ...data.report.query })));
let refreshError = $state('');
let navigationError = $state('');
let updatedAt = $state<number | null>(null);
let refreshing = $state(false);
let controller: AbortController | undefined;
let generation = 0;
const busy = $derived(!!$navigating);
const results = $derived(report[report.query.view]);
const tabs = [
{ id: 'accounts', label: 'Accounts' },
{ id: 'sessions', label: 'Sessions' },
{ id: 'ledger', label: 'Time ledger' }
];
const serverOptions = $derived([
{ value: '', label: 'All servers' },
...[...new Set([...report.servers, ...(filters.server ? [filters.server] : [])])].map(
(server) => ({ value: server, label: serverTitle(server) })
)
]);
const clearedFilters = {
q: null,
balance: null,
sort: null,
game: null,
server: null,
state: null,
kind: null,
from: null,
to: null,
offset: null
};
$effect(() => {
const initial = data.report;
untrack(() => {
cancelRefresh();
snapshot = null;
filters = { ...initial.query };
refreshError = '';
});
});
function cancelRefresh() {
generation++;
controller?.abort();
refreshing = false;
}
beforeNavigate(cancelRefresh);
afterNavigate(() => {
updatedAt = Date.now();
navigationError = '';
});
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/afk${$page.url.search}`, { signal: request.signal });
if (!response.ok) throw new Error('AFK refresh failed.');
const next: AfkOverview = await response.json();
if (current !== generation || request.signal.aborted) return;
snapshot = next;
updatedAt = Date.now();
refreshError = '';
} catch {
if (current === generation) refreshError = 'AFK updates paused.';
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>) {
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(`/afk?${params}`, { keepFocus: true, noScroll: true });
} catch {
navigationError = 'Could not load this AFK view. Please try again.';
}
}
function changeView(view: string) {
void navigate({ ...clearedFilters, q: report.query.q, view });
}
function applyFilters(event: SubmitEvent) {
event.preventDefault();
void navigate({
q: filters.q,
balance: filters.balance,
sort: filters.sort,
game: filters.game,
server: filters.server,
state: filters.state,
kind: filters.kind,
from: filters.from,
to: filters.to,
offset: null
});
}
</script>
<svelte:head><title>AFK · Electric Cherry</title></svelte:head>
<div class="afk-page">
<SectionHeader
title="AFK"
eyebrow="Players"
subtitle="Shared AFK time across Black Ops I & II."
>
{#snippet actions()}
<Freshness
{updatedAt}
error={refreshError}
intervalLabel="every 15 s"
retryDisabled={refreshing || busy}
onRetry={() => void refresh()}
/>
<Button size="sm" loading={refreshing} disabled={busy} onclick={() => void refresh()}
>Refresh</Button
>
{/snippet}
</SectionHeader>
<dl class="summary">
<div>
<dt>AFK accounts</dt>
<dd>{number.format(report.summary.accounts)}</dd>
</div>
<div>
<dt>Available saved time</dt>
<dd>{duration(report.summary.savedSeconds)}</dd>
</div>
<div>
<dt>Reserved time</dt>
<dd>{duration(report.summary.reservedSeconds)}</dd>
</div>
<div>
<dt>Unresolved sessions</dt>
<dd>
<button
class="summary-link"
disabled={busy}
onclick={() =>
void navigate({
...clearedFilters,
player: null,
view: 'sessions',
state: 'unresolved'
})}>{number.format(report.summary.unresolvedSessions)}</button
>
</dd>
</div>
</dl>
<p class="hint">
Totals cover all accounts, regardless of filters. Reserved time is held until settlement and
is excluded from available balances.
</p>
{#if navigationError}<p class="ui-error" role="alert">{navigationError}</p>{/if}
<Tabs
{tabs}
value={report.query.view}
onchange={changeView}
ariaLabel="AFK views"
panelId="afk-results"
idPrefix="afk-tab"
/>
<div
id="afk-results"
role="tabpanel"
aria-labelledby={`afk-tab-${report.query.view}`}
aria-busy={busy}
class="results"
>
{#if report.query.player}
<div class="player-filter">
<span>Player GUID: <strong>{report.query.player}</strong></span><Button
size="sm"
variant="ghost"
disabled={busy}
onclick={() => void navigate({ player: null, offset: null })}
>All players</Button
>
</div>
{/if}
<form class="filters" method="GET" action="/afk" onsubmit={applyFilters}>
<input type="hidden" name="view" value={report.query.view} />
{#if report.query.player}<input
type="hidden"
name="player"
value={report.query.player}
/>{/if}
<div class="search">
<Field label="Player name or GUID"
><Input
name="q"
type="search"
bind:value={filters.q}
maxlength={100}
placeholder="Search players…"
/></Field
>
</div>
{#if report.query.view === 'accounts'}
<Field label="Accounts"
><Select
name="balance"
bind:value={filters.balance}
options={[
{ value: '', label: 'All accounts' },
{ value: 'saved', label: 'Saved time available' },
{ value: 'reserved', label: 'Reserved time' },
{ value: 'unresolved', label: 'Unresolved sessions' }
]}
/></Field
>
<Field label="Sort by"
><Select
name="sort"
bind:value={filters.sort}
options={[
{ value: 'saved', label: 'Most saved time' },
{ value: 'available', label: 'Most available time' },
{ value: 'recent', label: 'Recent activity' }
]}
/></Field
>
{:else}
<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="Server"
><Select
name="server"
bind:value={filters.server}
options={serverOptions}
/></Field
>
{#if report.query.view === 'sessions'}
<Field label="Session state"
><Select
name="state"
bind:value={filters.state}
options={[
{ value: '', label: 'All states' },
{ value: 'reserved', label: 'Reserved' },
{ value: 'started', label: 'Started' },
{ value: 'settled', label: 'Settled' },
{ value: 'unresolved', label: 'Unresolved' },
{ value: 'corrected', label: 'Corrected' }
]}
/></Field
>
{:else}
<Field label="Event"
><Select
name="kind"
bind:value={filters.kind}
options={[
{ value: '', label: 'All events' },
...afkLedgerKinds.map((kind) => ({
value: kind,
label: label(kind)
}))
]}
/></Field
>
{/if}
<Field label="From (UTC)"
><Input
name="from"
type="date"
bind:value={filters.from}
max={filters.to || undefined}
/></Field
>
<Field label="Through (UTC)"
><Input
name="to"
type="date"
bind:value={filters.to}
min={filters.from || undefined}
/></Field
>
{/if}
<div class="filter-actions">
<Button type="submit" disabled={busy}>Apply</Button><Button
variant="ghost"
disabled={busy}
onclick={() => void navigate({ ...clearedFilters, player: null })}>Reset</Button
>
</div>
</form>
{#if report.query.view === 'accounts'}
<p class="hint">
Free time replenishes on a rolling 24-hour basis. Saved capacity is {duration(
report.capacitySeconds
)} per player, including saved time reserved for sessions.
</p>
{#if report.accounts.entries.length}<AfkAccounts
entries={report.accounts.entries}
capacitySeconds={report.capacitySeconds}
source={$page.url}
/>{:else}<EmptyState
title="No AFK accounts found"
description="Try another player name or GUID, or reset the filters. Accounts appear after their first AFK service request."
/>{/if}
{:else if report.query.view === 'sessions'}
<p class="hint">
Dates filter when reservations were created. Unresolved sessions keep their time
reserved until a confirmed stop or an administrator correction. Corrected charges
are separate from confirmed usage.
</p>
{#if report.sessions.entries.length}<AfkSessions
entries={report.sessions.entries}
source={$page.url}
/>{:else}<EmptyState
title="No AFK sessions found"
description="No sessions match these filters."
/>{/if}
{:else}
<p class="hint">
Dates filter when events were received. Balances after each event are historical
snapshots; current balances appear in Accounts.
</p>
{#if report.ledger.entries.length}<AfkLedger
entries={report.ledger.entries}
source={$page.url}
/>{:else}<EmptyState
title="No time ledger entries found"
description="No AFK grants, purchases or session activity match these filters."
/>{/if}
{/if}
<AfkPagination
total={results.total}
offset={results.offset}
disabled={busy}
onchange={(offset) => void navigate({ offset })}
/>
</div>
</div>
<style>
.afk-page,
.results {
display: grid;
gap: var(--space-5);
min-width: 0;
}
.summary {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: var(--space-4);
margin: 0;
}
.summary > div {
padding: var(--space-5);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
background: var(--surface);
}
dt {
color: var(--text-secondary);
font-size: var(--text-sm);
}
dd {
margin: var(--space-2) 0 0;
font-size: var(--text-2xl);
font-weight: var(--weight-semibold);
font-variant-numeric: tabular-nums;
overflow-wrap: anywhere;
}
.summary-link {
padding: 0;
border: 0;
background: none;
color: var(--accent-primary-hover);
font: inherit;
cursor: pointer;
}
.filters {
display: flex;
align-items: flex-end;
flex-wrap: wrap;
gap: var(--space-3);
}
.search {
flex: 1 1 240px;
}
.filter-actions {
display: flex;
gap: var(--space-2);
}
.hint {
margin: 0;
font-size: var(--text-sm);
color: var(--text-secondary);
}
.player-filter {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--space-3);
padding: var(--space-3);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
font-size: var(--text-sm);
overflow-wrap: anywhere;
}
.player-filter strong {
font-family: var(--font-mono);
}
@media (max-width: 1100px) {
.summary {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 640px) {
.summary {
gap: var(--space-2);
}
.summary > div {
padding: var(--space-3);
}
dd {
font-size: var(--text-xl);
}
}
</style>
+13
View File
@@ -0,0 +1,13 @@
import { error, json } from '@sveltejs/kit';
import { readAfkQuery } from '$lib/afk.js';
import { afkOverview } from '$lib/server/fleet/afk-overview.js';
export function GET({ url }: { url: URL }) {
let query;
try {
query = readAfkQuery(url.searchParams);
} catch (cause) {
error(400, cause instanceof Error ? cause.message : 'Invalid AFK filters.');
}
return json(afkOverview(query), { headers: { 'Cache-Control': 'no-store' } });
}
+40
View File
@@ -0,0 +1,40 @@
import { json, isHttpError } from '@sveltejs/kit';
import { authenticateBank } from '$lib/server/auth/bank.js';
import { afkRequest, type AfkAction } from '$lib/server/fleet/afk.js';
import { jsonBody } from '$lib/server/request-body.js';
export async function POST({ request, params }: { request: Request; params: { action: string } }) {
const headers = { 'Cache-Control': 'no-store' };
const authentication = authenticateBank(request);
if (!authentication.ok)
return json(
{ ok: false, code: authentication.code },
{ status: authentication.status, headers }
);
if (!['balance', 'event', 'purchase', 'reserve', 'start', 'settle'].includes(params.action))
return json({ ok: false, code: 'invalid_action' }, { status: 404, headers });
if (
request.headers.get('content-type')?.split(';')[0].trim().toLowerCase() !==
'application/json'
)
return json({ ok: false, code: 'invalid_content_type' }, { status: 415, headers });
let value: unknown;
try {
value = await jsonBody(request, 4096);
} catch (cause) {
if (isHttpError(cause, 413)) throw cause;
return json({ ok: false, code: 'invalid_request' }, { status: 400, headers });
}
try {
const { body, status } = afkRequest(
params.action as AfkAction,
value,
authentication.server,
authentication.instanceId
);
return json(body, { status, headers });
} catch {
console.error('AFK transaction failed; request may be retried with the same identity.');
return json({ ok: false, code: 'unavailable' }, { status: 503, headers });
}
}

Some files were not shown because too many files have changed in this diff Show More