Compare commits
45
Commits
0a98ea319c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d715173719 | ||
|
|
fd00ef5357 | ||
|
|
f2b2541b46 | ||
|
|
a7f1c65d17 | ||
|
|
48653af3dc | ||
|
|
870f13b150 | ||
|
|
b678de2f6f | ||
|
|
90b94911c5 | ||
|
|
428aefdf24 | ||
|
|
aca728b557 | ||
|
|
ef96a2c308 | ||
|
|
83f1c91877 | ||
|
|
59f88ac33d | ||
|
|
d723f23d67 | ||
|
|
7f2205e9c4 | ||
|
|
4501eba873 | ||
|
|
f3b9699317 | ||
|
|
b9bc897d6e | ||
|
|
2e6eb0bdb0 | ||
|
|
d93a766578 | ||
|
|
ba9bed6a0d | ||
|
|
b5037a70fe | ||
|
|
4e04191330 | ||
|
|
8b372fa2c1 | ||
|
|
c91d49c298 | ||
|
|
c1758cce25 | ||
|
|
68616327ec | ||
|
|
1fc329b123 | ||
|
|
428df7f03a | ||
|
|
be7f6c77c1 | ||
|
|
dedf15d9af | ||
|
|
61dbfb2c5f | ||
|
|
171f657876 | ||
|
|
09396ab584 | ||
|
|
eceb38a6bc | ||
|
|
82e07ea33e | ||
|
|
e85dc58130 | ||
|
|
d6147077d7 | ||
|
|
d9fa15cdf6 | ||
|
|
4e3df81786 | ||
|
|
2932b74f55 | ||
|
|
537b9841a7 | ||
|
|
fa0baa8db5 | ||
|
|
54f4301994 | ||
|
|
95160de5b4 |
@@ -1,5 +1,14 @@
|
|||||||
# Controller URL: use HTTPS with a trusted certificate for enrolled hosts.
|
# Controller URL: use HTTPS with a trusted certificate for enrolled hosts.
|
||||||
EC_PUBLIC_URL=https://electric-cherry.example.com
|
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
|
# Generate separate secrets with: openssl rand -hex 32
|
||||||
# Setup token authorizes creation of the first administrator only. Remove it
|
# Setup token authorizes creation of the first administrator only. Remove it
|
||||||
# after setup. Never reuse the telemetry token as a setup or host credential.
|
# after setup. Never reuse the telemetry token as a setup or host credential.
|
||||||
@@ -7,6 +16,13 @@ EC_SETUP_TOKEN=
|
|||||||
# Game reporters send Authorization: Bearer <token> or X-EC-Telemetry-Token.
|
# Game reporters send Authorization: Bearer <token> or X-EC-Telemetry-Token.
|
||||||
# Telemetry ingestion is disabled until a token of at least 32 chars is set.
|
# Telemetry ingestion is disabled until a token of at least 32 chars is set.
|
||||||
EC_TELEMETRY_TOKEN=
|
EC_TELEMETRY_TOKEN=
|
||||||
|
# Optional native Discord companion. Keep these in the controller environment;
|
||||||
|
# enable the integration and optionally exclude servers under Admin → Discord.
|
||||||
|
# The application ID and public key are in the Discord Developer Portal.
|
||||||
|
# The bot token is a secret: never put it in channel messages or screenshots.
|
||||||
|
EC_DISCORD_APPLICATION_ID=
|
||||||
|
EC_DISCORD_PUBLIC_KEY=
|
||||||
|
EC_DISCORD_BOT_TOKEN=
|
||||||
# Bank credentials are generated and injected automatically per native instance.
|
# Bank credentials are generated and injected automatically per native instance.
|
||||||
EC_BANK_MAX_BALANCE=250000
|
EC_BANK_MAX_BALANCE=250000
|
||||||
# Persistent SQLite metadata, game-file storage, and beta archives.
|
# Persistent SQLite metadata, game-file storage, and beta archives.
|
||||||
|
|||||||
@@ -13,9 +13,10 @@ _Fleet overview with sample data._
|
|||||||
|
|
||||||
- Launch individual servers or saved collections of maps and lobbies.
|
- Launch individual servers or saved collections of maps and lobbies.
|
||||||
- Monitor players, host capacity, match history, and leaderboards.
|
- Monitor players, host capacity, match history, and leaderboards.
|
||||||
|
- Moderate players with scoped restrictions, live actions, preserved evidence and review history.
|
||||||
- Manage scripts, plugins, and configuration with beta testing and rollback.
|
- Manage scripts, plugins, and configuration with beta testing and rollback.
|
||||||
- Queue maintenance around active games and scale spare lobbies with opt-in policies.
|
- Queue maintenance around active games and scale spare lobbies with opt-in policies.
|
||||||
- Control access with administrator, operator, and viewer accounts.
|
- Control access with administrator, moderator, operator, and read-only accounts.
|
||||||
|
|
||||||
## Getting started
|
## Getting started
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
# 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)
|
||||||
|
|
||||||
|
- Retain the complete verified replacement file set before stopping the current
|
||||||
|
container, including inline files and private bank configuration, within the
|
||||||
|
existing 64 MiB operation limit. Staged-file comparison, replacement validation
|
||||||
|
and writing reuse those bytes without further artifact downloads.
|
||||||
|
- Keep prepared bytes local to the operation. Failed and held attempts discard
|
||||||
|
their files; later operations prepare a fresh set. Immutable specifications,
|
||||||
|
journal requests and container identity labels remain unchanged.
|
||||||
|
- Preserve checksum, path, per-file size, player-safety, bank-settlement and
|
||||||
|
generation checks. Add regressions for preflight failures, lost connectivity
|
||||||
|
after preflight, file-set limits and operation isolation.
|
||||||
|
- Package release artifacts for offline signing before publication. No protocol
|
||||||
|
change or controller migration is required.
|
||||||
|
|
||||||
|
## 2026-10-13
|
||||||
|
|
||||||
|
### 1.10.0 — Host temperature telemetry
|
||||||
|
|
||||||
|
- Read supported Linux hwmon CPU package/core, AMD physical die/CCD, NVMe and
|
||||||
|
Intel PCH temperatures with the Python standard library. Sensor failures,
|
||||||
|
faults and disabled inputs are skipped independently; unsupported hosts report
|
||||||
|
an empty list. AMD Tctl control offsets are excluded from physical readings.
|
||||||
|
- Include labeled, bounded sensors in heartbeats with the `host.temperatures`
|
||||||
|
capability. Physical device/channel identities survive hwmon renumbering.
|
||||||
|
- The controller shows current CPU readings and expandable sensor details, and
|
||||||
|
adds temperature history to Capacity. Fleet history has one CPU line per host;
|
||||||
|
individual hosts can add NVMe composite and chipset lines. History retains
|
||||||
|
minute peaks using the host-resource retention setting; missing data stays
|
||||||
|
unavailable and no thermal alerts or placement rules are introduced.
|
||||||
|
- Adds database migration 23. Existing agents remain compatible. Release
|
||||||
|
artifacts are packaged and the stale signature is removed. Offline signing
|
||||||
|
and host rollout are still required; neither was performed here.
|
||||||
|
|
||||||
|
## 2026-10-12
|
||||||
|
|
||||||
|
### 1.9.3 — Protect private bank configuration (EC #152)
|
||||||
|
|
||||||
|
- Exclude the generated bank config from file manifests and reject file reads,
|
||||||
|
staged changes, stale activations, rollback snapshots and log reads targeting
|
||||||
|
it. Creation and rebuilding still inject the private instance credential.
|
||||||
|
- Package and offline-sign this release, then update agents before resuming
|
||||||
|
drift recovery. Old private-file stages fail safely and require a fresh plan.
|
||||||
|
|
||||||
|
### 1.9.1 — Public game-bank endpoint
|
||||||
|
|
||||||
|
- Companion to [TWS scripts #13](https://gitea.thewrightserver.net/josh/tws-zombies-scripts/issues/13).
|
||||||
|
In `agent/ec_agent.py`, stop generating `tws_bank_url` in
|
||||||
|
`electric_cherry_bank.cfg`; continue injecting the private per-instance token.
|
||||||
|
Launch configuration still supplies `tws_server_id`. The host's enrolled
|
||||||
|
controller address and authentication remain unchanged.
|
||||||
|
- Recognize journals for `https://ec.thewrightserver.net/api/bank` and the legacy
|
||||||
|
bank URL derived from the enrolled controller. Reject other URLs and preserve
|
||||||
|
unresolved storage. Settlement still goes only through the enrolled controller's
|
||||||
|
authenticated, operation- and instance-scoped API. Original transaction IDs and
|
||||||
|
commit/cancel decisions are preserved across lost replies and retries.
|
||||||
|
- Compatibility: pair with T5/T6 bank scripts `2.0.0` and existing TWS plugins
|
||||||
|
`1.1.0+`. No new Python dependency, controller migration or protocol change.
|
||||||
|
Old scripts still fall back to the public endpoint when the dvar is absent.
|
||||||
|
- Deployment: package and offline-sign the release, update/restart host agents,
|
||||||
|
then rebuild through EC when a server's bank endpoint changes. Rebuild stops
|
||||||
|
the game and drains old decisions before starting the replacement or deleting
|
||||||
|
storage. An endpoint that already matches needs only a map restart for the
|
||||||
|
script update. Follow the [bank rollout procedure](../docs/guides/bank.md#upgrading-from-a-controller-derived-game-endpoint).
|
||||||
|
Packaging refreshes `static/agent/ec-agent.py` and `manifest.json` and removes
|
||||||
|
the old signature; a new offline signature is required before publishing.
|
||||||
|
No signing or deployment was performed.
|
||||||
|
- Regression coverage in `agent/tests/test_agent.py`: generated credentials,
|
||||||
|
public and legacy journals for both games, rebuild/removal, lost replies,
|
||||||
|
agent restart, unchanged identities and rejection of untrusted endpoints.
|
||||||
|
Live Windows/Plutonium endpoint migration and signed host rollout remain to
|
||||||
|
be validated.
|
||||||
+377
-32
@@ -35,7 +35,7 @@ import urllib.parse
|
|||||||
import urllib.request
|
import urllib.request
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
VERSION = "1.9.0"
|
VERSION = "1.12.0"
|
||||||
LABEL = "io.electric-cherry"
|
LABEL = "io.electric-cherry"
|
||||||
MAX_FILE = 32 * 1024 * 1024
|
MAX_FILE = 32 * 1024 * 1024
|
||||||
MANIFEST_MAX_ENTRIES = 10000
|
MANIFEST_MAX_ENTRIES = 10000
|
||||||
@@ -48,6 +48,10 @@ PLUTONIUM_MANIFEST_URL = "https://cdn.plutoniummod.com/updater/prod/info.json"
|
|||||||
PLUTONIUM_MANIFEST_LIMIT = 2 * 1024 * 1024
|
PLUTONIUM_MANIFEST_LIMIT = 2 * 1024 * 1024
|
||||||
PLUTONIUM_FILE_LIMIT = 512 * 1024 * 1024
|
PLUTONIUM_FILE_LIMIT = 512 * 1024 * 1024
|
||||||
PLUTONIUM_CACHE_LIMIT = 4 * 1024 * 1024 * 1024
|
PLUTONIUM_CACHE_LIMIT = 4 * 1024 * 1024 * 1024
|
||||||
|
BANK_GAME_ENDPOINT = "https://ec.thewrightserver.net/api/bank"
|
||||||
|
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}$")
|
ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$")
|
||||||
AGENT_CONFIG = Path("/etc/electric-cherry/agent.json")
|
AGENT_CONFIG = Path("/etc/electric-cherry/agent.json")
|
||||||
AGENT_EXECUTABLE = Path("/usr/local/libexec/electric-cherry-agent.py")
|
AGENT_EXECUTABLE = Path("/usr/local/libexec/electric-cherry-agent.py")
|
||||||
@@ -62,6 +66,7 @@ UPDATE_TIMER_ENABLED = Path("/etc/systemd/system/timers.target.wants/electric-ch
|
|||||||
UPDATE_JOURNAL = Path("/etc/electric-cherry/update/journal.json")
|
UPDATE_JOURNAL = Path("/etc/electric-cherry/update/journal.json")
|
||||||
UPDATE_STATUS = Path("/etc/electric-cherry/update-status.json")
|
UPDATE_STATUS = Path("/etc/electric-cherry/update-status.json")
|
||||||
AGENT_HEALTH = Path("/etc/electric-cherry/agent-health.json")
|
AGENT_HEALTH = Path("/etc/electric-cherry/agent-health.json")
|
||||||
|
HWMON_ROOT = Path("/sys/class/hwmon")
|
||||||
ROOT_MARKER = ".electric-cherry.json"
|
ROOT_MARKER = ".electric-cherry.json"
|
||||||
|
|
||||||
|
|
||||||
@@ -83,6 +88,7 @@ class AutoscalingInvalidated(Rejected):
|
|||||||
|
|
||||||
OPERATION_ERROR_PHASES = {
|
OPERATION_ERROR_PHASES = {
|
||||||
"executing": "executing the operation",
|
"executing": "executing the operation",
|
||||||
|
"configuring-moderation": "configuring private game moderation",
|
||||||
"checking-instance": "checking the existing instance",
|
"checking-instance": "checking the existing instance",
|
||||||
"validating-create-spec": "validating the container specification",
|
"validating-create-spec": "validating the container specification",
|
||||||
"preparing-create-directory": "preparing the instance directory",
|
"preparing-create-directory": "preparing the instance directory",
|
||||||
@@ -399,7 +405,17 @@ def managed_open(root, value):
|
|||||||
os.close(fd)
|
os.close(fd)
|
||||||
|
|
||||||
|
|
||||||
|
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_instance_config(value):
|
||||||
|
raise Rejected("Private bank configuration and moderation configuration cannot be read or changed by file operations")
|
||||||
|
|
||||||
|
|
||||||
def managed_read(root, value):
|
def managed_read(root, value):
|
||||||
|
require_public_file(value)
|
||||||
try:
|
try:
|
||||||
with managed_open(root, value) as stream:
|
with managed_open(root, value) as stream:
|
||||||
if os.fstat(stream.fileno()).st_size > MAX_FILE:
|
if os.fstat(stream.fileno()).st_size > MAX_FILE:
|
||||||
@@ -445,6 +461,8 @@ def managed_manifest(root, payload, stopping):
|
|||||||
|
|
||||||
def validate_paths():
|
def validate_paths():
|
||||||
for value in paths:
|
for value in paths:
|
||||||
|
if private_instance_config(value):
|
||||||
|
continue
|
||||||
check_budget()
|
check_budget()
|
||||||
try:
|
try:
|
||||||
with managed_open(root, value):
|
with managed_open(root, value):
|
||||||
@@ -479,7 +497,7 @@ def managed_manifest(root, payload, stopping):
|
|||||||
entries += 1
|
entries += 1
|
||||||
if entries > MANIFEST_MAX_ENTRIES:
|
if entries > MANIFEST_MAX_ENTRIES:
|
||||||
raise AgentError("Manifest scan exceeded its entry limit")
|
raise AgentError("Manifest scan exceeded its entry limit")
|
||||||
if child.name in ignored:
|
if child.name in ignored or private_instance_config(relative + "/" + child.name):
|
||||||
continue
|
continue
|
||||||
info = os.stat(child.name, dir_fd=directory, follow_symlinks=False)
|
info = os.stat(child.name, dir_fd=directory, follow_symlinks=False)
|
||||||
if stat_module.S_ISLNK(info.st_mode):
|
if stat_module.S_ISLNK(info.st_mode):
|
||||||
@@ -1036,6 +1054,86 @@ class LibraryTransfer:
|
|||||||
self.persist(force=True)
|
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:
|
class Agent:
|
||||||
def __init__(self, config, docker=None):
|
def __init__(self, config, docker=None):
|
||||||
self.config = config
|
self.config = config
|
||||||
@@ -1295,6 +1393,59 @@ class Agent:
|
|||||||
return None
|
return None
|
||||||
return round(100 * (total - deltas[3] - deltas[4]) / total, 1)
|
return round(100 * (total - deltas[3] - deltas[4]) / total, 1)
|
||||||
|
|
||||||
|
def temperatures(self):
|
||||||
|
"""Best-effort, read-only samples from known millidegree-Celsius drivers."""
|
||||||
|
samples = []
|
||||||
|
try:
|
||||||
|
chips = sorted(HWMON_ROOT.glob("hwmon*"))[:64]
|
||||||
|
except OSError:
|
||||||
|
return samples
|
||||||
|
for chip in chips:
|
||||||
|
try:
|
||||||
|
device = (chip / "name").read_text().strip()
|
||||||
|
if device not in ("coretemp", "k10temp", "nvme") and not device.startswith("pch_"):
|
||||||
|
continue
|
||||||
|
# hwmon numbers can change on reboot. The physical sysfs path
|
||||||
|
# and channel identify separate CPU sockets and NVMe devices.
|
||||||
|
identity = re.sub(r"/(?:hwmon/)?hwmon\d+$", "", str(chip.resolve()))
|
||||||
|
inputs = sorted(chip.glob("temp[0-9]*_input"))[:128]
|
||||||
|
except (OSError, ValueError):
|
||||||
|
continue
|
||||||
|
for source in inputs:
|
||||||
|
try:
|
||||||
|
channel = source.name.removesuffix("_input")
|
||||||
|
label_path = chip / (channel + "_label")
|
||||||
|
label = label_path.read_text().strip() if label_path.exists() else channel
|
||||||
|
if not label or len(label) > 120:
|
||||||
|
continue
|
||||||
|
fault = chip / (channel + "_fault")
|
||||||
|
enabled = chip / (channel + "_enable")
|
||||||
|
if (fault.exists() and int(fault.read_text()) != 0) or (enabled.exists() and int(enabled.read_text()) == 0):
|
||||||
|
continue
|
||||||
|
if device == "coretemp":
|
||||||
|
kind = "cpu-package" if label.startswith("Package id ") else "cpu-core" if label.startswith("Core ") else None
|
||||||
|
elif device == "k10temp":
|
||||||
|
# Tctl can contain a fan-control offset. Never present
|
||||||
|
# it as the physical die temperature or a fleet metric.
|
||||||
|
kind = "cpu-die" if label == "Tdie" or re.fullmatch(r"Tccd\d+", label) else None
|
||||||
|
else:
|
||||||
|
kind = "nvme" if device == "nvme" else "chipset"
|
||||||
|
if kind is None:
|
||||||
|
continue
|
||||||
|
celsius = int(source.read_text()) / 1000
|
||||||
|
if not -100 <= celsius <= 200:
|
||||||
|
continue
|
||||||
|
sensor_id = hashlib.sha256((identity + "/" + channel).encode()).hexdigest()[:24]
|
||||||
|
samples.append({"id": sensor_id, "device": device, "label": label,
|
||||||
|
"kind": kind, "celsius": round(celsius, 1)})
|
||||||
|
if len(samples) >= 128:
|
||||||
|
return samples
|
||||||
|
except (OSError, ValueError):
|
||||||
|
# Read permission alone does not imply data is available
|
||||||
|
# (e.g. ENODATA), and a device can disappear during a read.
|
||||||
|
continue
|
||||||
|
return samples
|
||||||
|
|
||||||
def instance_resources(self, observations=None):
|
def instance_resources(self, observations=None):
|
||||||
"""One bounded Docker stats batch; unavailable samples are never zero load."""
|
"""One bounded Docker stats batch; unavailable samples are never zero load."""
|
||||||
ids = self.docker.call("container", "ls", "--all", "--filter", "label=" + LABEL + ".managed=true",
|
ids = self.docker.call("container", "ls", "--all", "--filter", "label=" + LABEL + ".managed=true",
|
||||||
@@ -1379,6 +1530,7 @@ class Agent:
|
|||||||
|
|
||||||
def inventory(self):
|
def inventory(self):
|
||||||
cpu_usage = self.cpu_usage_percent()
|
cpu_usage = self.cpu_usage_percent()
|
||||||
|
temperatures = self.temperatures()
|
||||||
usage = shutil.disk_usage(self.root)
|
usage = shutil.disk_usage(self.root)
|
||||||
memory = {}
|
memory = {}
|
||||||
try:
|
try:
|
||||||
@@ -1503,9 +1655,9 @@ class Agent:
|
|||||||
if line.startswith("PRETTY_NAME="):
|
if line.startswith("PRETTY_NAME="):
|
||||||
os_name = line.split("=", 1)[1].strip('"')
|
os_name = line.split("=", 1)[1].strip('"')
|
||||||
update = self.update_inventory()
|
update = self.update_inventory()
|
||||||
capabilities = ["library.remove", "plutonium.cache", "instance.cancel-create", "instance.safety-override", "instance.unhealthy-recovery", "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()
|
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, **resources, **({"cpuUsagePercent": cpu_usage} if cpu_usage is not None else {}), "memoryTotal": memory.get("MemTotal", 0), "memoryAvailable": memory.get("MemAvailable", 0), "storageTotal": usage.total, "storageAvailable": usage.free, "dockerVersion": docker_version, "dockerRunning": docker_running, "directoriesWritable": os.access(self.root, os.W_OK), "runtimeImages": sorted(set(images)), "preparedRuntimeImages": sorted(set(prepared_images)), **({"plutoniumCache": plutonium_cache} if plutonium_cache else {}), "libraries": libraries, "libraryCaches": caches, "currentOperation": self.current_operation, "portRange": self.config.get("portRange", {"min": 28960, "max": 29060})}
|
return {"hostname": socket.gethostname(), "agentVersion": VERSION, "protocolVersion": 1, "capabilities": capabilities, "agentUpdate": update, "agentLanes": self.lane_inventory(), "os": os_name, "osVersion": platform.release(), "architecture": platform.machine(), "cpu": platform.processor() or platform.machine(), "cpuCount": os.cpu_count() or 0, "temperatures": temperatures, **resources, **({"cpuUsagePercent": cpu_usage} if cpu_usage is not None else {}), "memoryTotal": memory.get("MemTotal", 0), "memoryAvailable": memory.get("MemAvailable", 0), "storageTotal": usage.total, "storageAvailable": usage.free, "dockerVersion": docker_version, "dockerRunning": docker_running, "directoriesWritable": os.access(self.root, os.W_OK), "runtimeImages": sorted(set(images)), "preparedRuntimeImages": sorted(set(prepared_images)), **({"plutoniumCache": plutonium_cache} if plutonium_cache else {}), "libraries": libraries, "libraryCaches": caches, "currentOperation": self.current_operation, "portRange": self.config.get("portRange", {"min": 28960, "max": 29060})}
|
||||||
|
|
||||||
def safe_to_disrupt(self, instance_id, generation, *, allow_unhealthy=False):
|
def safe_to_disrupt(self, instance_id, generation, *, allow_unhealthy=False):
|
||||||
health = self.inspect(instance_id, generation)
|
health = self.inspect(instance_id, generation)
|
||||||
@@ -1617,7 +1769,7 @@ class Agent:
|
|||||||
players = status_players(text) if command == "status" else None
|
players = status_players(text) if command == "status" else None
|
||||||
return {"ok": True, "text": text, "players": players}
|
return {"ok": True, "text": text, "players": players}
|
||||||
|
|
||||||
def validate_spec(self, payload):
|
def validate_spec(self, payload, *, prepared_files=None):
|
||||||
game = payload.get("game")
|
game = payload.get("game")
|
||||||
if game not in ("t5", "t6"):
|
if game not in ("t5", "t6"):
|
||||||
raise AgentError("Only T5 and T6 are supported")
|
raise AgentError("Only T5 and T6 are supported")
|
||||||
@@ -1660,16 +1812,22 @@ class Agent:
|
|||||||
files = self.initial_files(payload)
|
files = self.initial_files(payload)
|
||||||
if not isinstance(files, list) or len(files) > 10000:
|
if not isinstance(files, list) or len(files) > 10000:
|
||||||
raise Rejected("Invalid initial file list")
|
raise Rejected("Invalid initial file list")
|
||||||
|
if prepared_files is not None:
|
||||||
|
if set(prepared_files) != {item["path"] for item in files} or any(not isinstance(content, bytes) for content in prepared_files.values()):
|
||||||
|
raise Rejected("Prepared files do not match the replacement specification")
|
||||||
|
verified_files = {}
|
||||||
total, paths = 0, set()
|
total, paths = 0, set()
|
||||||
for item in files:
|
for item in files:
|
||||||
scoped(self.root / "agent" / "validation", item["path"])
|
scoped(self.root / "agent" / "validation", item["path"])
|
||||||
if item["path"] in paths:
|
if item["path"] in paths:
|
||||||
raise Rejected("Duplicate initial file path")
|
raise Rejected("Duplicate initial file path")
|
||||||
paths.add(item["path"])
|
paths.add(item["path"])
|
||||||
total += len(self.decode_file(item))
|
content = self.decode_file(item, content=None if prepared_files is None else prepared_files[item["path"]])
|
||||||
|
total += len(content)
|
||||||
if total > 64 * 1024 * 1024:
|
if total > 64 * 1024 * 1024:
|
||||||
raise Rejected("Initial files exceed the 64 MiB operation limit")
|
raise Rejected("Initial files exceed the 64 MiB operation limit")
|
||||||
return game, library, image, image_id, port, bind, env
|
verified_files[item["path"]] = content
|
||||||
|
return game, library, image, image_id, port, bind, env, verified_files
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def plutonium_manifest(data):
|
def plutonium_manifest(data):
|
||||||
@@ -1888,8 +2046,10 @@ class Agent:
|
|||||||
managed_write(app, marker, raw, uid, gid)
|
managed_write(app, marker, raw, uid, gid)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def create(self, instance_id, generation, payload):
|
def create(self, instance_id, generation, payload, *, before=None, prepared_files=None):
|
||||||
self.operation_phase = "checking-instance"
|
self.operation_phase = "checking-instance"
|
||||||
|
if payload.get("moderation") is not None:
|
||||||
|
self.moderation_config(payload["moderation"], generation)
|
||||||
self.check_creation_cancellation()
|
self.check_creation_cancellation()
|
||||||
directory = self.instance_dir(instance_id, generation)
|
directory = self.instance_dir(instance_id, generation)
|
||||||
current_marker = directory.parent / "current.json"
|
current_marker = directory.parent / "current.json"
|
||||||
@@ -1903,7 +2063,7 @@ class Agent:
|
|||||||
raise AgentError("Instance already exists with a different specification")
|
raise AgentError("Instance already exists with a different specification")
|
||||||
return existing
|
return existing
|
||||||
self.operation_phase = "validating-create-spec"
|
self.operation_phase = "validating-create-spec"
|
||||||
game, library, image, image_id, port, bind, env = self.validate_spec(payload)
|
game, library, image, image_id, port, bind, env, prepared_files = self.validate_spec(payload, prepared_files=prepared_files)
|
||||||
app = directory / "app"
|
app = directory / "app"
|
||||||
self.operation_phase = "preparing-create-directory"
|
self.operation_phase = "preparing-create-directory"
|
||||||
app.mkdir(parents=True, exist_ok=True)
|
app.mkdir(parents=True, exist_ok=True)
|
||||||
@@ -1911,7 +2071,7 @@ class Agent:
|
|||||||
self.operation_phase = "seeding-runtime"
|
self.operation_phase = "seeding-runtime"
|
||||||
self.seed_plutonium(app, image)
|
self.seed_plutonium(app, image)
|
||||||
self.operation_phase = "writing-create-files"
|
self.operation_phase = "writing-create-files"
|
||||||
self.write_initial_files(app, self.initial_files(payload))
|
self.write_initial_files(app, prepared_files)
|
||||||
self.operation_phase = "persisting-create-metadata"
|
self.operation_phase = "persisting-create-metadata"
|
||||||
atomic_json(directory / "spec.json", payload)
|
atomic_json(directory / "spec.json", payload)
|
||||||
env_path = directory / "environment"
|
env_path = directory / "environment"
|
||||||
@@ -1929,7 +2089,7 @@ class Agent:
|
|||||||
args.append(image_id)
|
args.append(image_id)
|
||||||
self.check_creation_cancellation()
|
self.check_creation_cancellation()
|
||||||
self.operation_phase = "creating-container"
|
self.operation_phase = "creating-container"
|
||||||
self.checkpoint({"phase": "creating", "generation": generation, "specSha256": spec_sha})
|
self.checkpoint({"phase": "creating", "generation": generation, "specSha256": spec_sha, **({"before": before} if before is not None else {})})
|
||||||
self.docker.call(*args, timeout=120)
|
self.docker.call(*args, timeout=120)
|
||||||
self.check_creation_cancellation()
|
self.check_creation_cancellation()
|
||||||
self.operation_phase = "starting-container"
|
self.operation_phase = "starting-container"
|
||||||
@@ -1948,33 +2108,106 @@ class Agent:
|
|||||||
files = payload.get("files", [])
|
files = payload.get("files", [])
|
||||||
if not isinstance(files, list):
|
if not isinstance(files, list):
|
||||||
raise Rejected("Invalid initial file list")
|
raise Rejected("Invalid initial file list")
|
||||||
|
for item in files:
|
||||||
|
require_public_file(item["path"])
|
||||||
bank = payload.get("bank")
|
bank = payload.get("bank")
|
||||||
if bank is None:
|
if bank is not None:
|
||||||
return files
|
|
||||||
if not isinstance(bank, dict) or not isinstance(bank.get("token"), str) or not re.fullmatch(r"ec_bank_[A-Za-z0-9_-]{32,128}", bank["token"]):
|
if not isinstance(bank, dict) or not isinstance(bank.get("token"), str) or not re.fullmatch(r"ec_bank_[A-Za-z0-9_-]{32,128}", bank["token"]):
|
||||||
raise Rejected("Invalid instance bank credential")
|
raise Rejected("Invalid instance bank credential")
|
||||||
path = bank.get("configPath")
|
path = bank.get("configPath")
|
||||||
scoped(self.root / "agent" / "validation", path)
|
scoped(self.root / "agent" / "validation", path)
|
||||||
if PurePosixPath(path).name != "electric_cherry_bank.cfg":
|
if PurePosixPath(path).name != "electric_cherry_bank.cfg":
|
||||||
raise Rejected("Invalid private bank config path")
|
raise Rejected("Invalid private bank config path")
|
||||||
controller = self.config["controller"].rstrip("/")
|
# Game transport uses its public URL, never the agent's enrollment URL.
|
||||||
if any(char in controller for char in ('"', "\\", "\n", "\r", "\x00")):
|
content = f'set tws_bank_token "{bank["token"]}"\n'.encode()
|
||||||
raise Rejected("Invalid bank controller URL")
|
files = files + [{"path": path, "contentBase64": base64.b64encode(content).decode()}]
|
||||||
content = f'set tws_bank_token "{bank["token"]}"\nset tws_bank_url "{controller}/api/bank"\n'.encode()
|
if payload.get("moderation") is not None:
|
||||||
return files + [{"path": path, "contentBase64": base64.b64encode(content).decode()}]
|
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
|
||||||
|
|
||||||
def write_initial_files(self, root, files):
|
@staticmethod
|
||||||
if not isinstance(files, list) or len(files) > 10000:
|
def config_exec(args, filename):
|
||||||
raise AgentError("Invalid file list")
|
return isinstance(args, str) and re.search(r"(?:^|\s)\+exec\s+" + re.escape(filename) + r"(?=\s|$)", args) is not None
|
||||||
for item in files:
|
|
||||||
content = self.decode_file(item)
|
|
||||||
managed_write(root, item["path"], content, int(self.config.get("runtimeUid", 1000)), int(self.config.get("runtimeGid", 1000)))
|
|
||||||
|
|
||||||
def decode_file(self, item):
|
@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():
|
||||||
|
managed_write(root, path, content, int(self.config.get("runtimeUid", 1000)), int(self.config.get("runtimeGid", 1000)))
|
||||||
|
|
||||||
|
def decode_file(self, item, *, content=None):
|
||||||
if "contentSha256" in item:
|
if "contentSha256" in item:
|
||||||
sha = item["contentSha256"]
|
sha = item["contentSha256"]
|
||||||
if not isinstance(sha, str) or not re.fullmatch(r"[a-f0-9]{64}", sha):
|
if not isinstance(sha, str) or not re.fullmatch(r"[a-f0-9]{64}", sha):
|
||||||
raise AgentError("Invalid file content hash")
|
raise AgentError("Invalid file content hash")
|
||||||
|
if content is None:
|
||||||
url = self.config["controller"].rstrip("/") + "/api/agent/blobs/" + sha
|
url = self.config["controller"].rstrip("/") + "/api/agent/blobs/" + sha
|
||||||
with self.artifact_open("GET", url) as response:
|
with self.artifact_open("GET", url) as response:
|
||||||
content = response.read(MAX_FILE + 1)
|
content = response.read(MAX_FILE + 1)
|
||||||
@@ -1984,9 +2217,13 @@ class Agent:
|
|||||||
raise AgentError("Staged file checksum mismatch")
|
raise AgentError("Staged file checksum mismatch")
|
||||||
return content
|
return content
|
||||||
try:
|
try:
|
||||||
content = base64.b64decode(item["contentBase64"], validate=True)
|
decoded = base64.b64decode(item["contentBase64"], validate=True)
|
||||||
except (ValueError, KeyError, TypeError):
|
except (ValueError, KeyError, TypeError):
|
||||||
raise AgentError("Invalid encoded file content") from None
|
raise AgentError("Invalid encoded file content") from None
|
||||||
|
if content is None:
|
||||||
|
content = decoded
|
||||||
|
elif content != decoded:
|
||||||
|
raise Rejected("Prepared files do not match the replacement specification")
|
||||||
if len(content) > MAX_FILE:
|
if len(content) > MAX_FILE:
|
||||||
raise AgentError("File exceeds staging size limit")
|
raise AgentError("File exceeds staging size limit")
|
||||||
if item.get("sha256") and item["sha256"] != digest(content):
|
if item.get("sha256") and item["sha256"] != digest(content):
|
||||||
@@ -2057,8 +2294,13 @@ class Agent:
|
|||||||
try:
|
try:
|
||||||
journal = json.loads(raw)
|
journal = json.loads(raw)
|
||||||
reports = journal["reports"]
|
reports = journal["reports"]
|
||||||
if journal["endpoint"].rstrip("/") != self.config["controller"].rstrip("/") + "/api/bank":
|
# Accept the fixed game endpoint and this host's legacy generated
|
||||||
raise AgentError("Bank journal belongs to a different controller; runtime storage retained")
|
# endpoint so rebuild/removal can drain pre-upgrade decisions.
|
||||||
|
# Neither value controls transport: settlement still uses the
|
||||||
|
# enrolled controller, host credential and immutable operation.
|
||||||
|
endpoints = {BANK_GAME_ENDPOINT, self.config["controller"].rstrip("/") + "/api/bank"}
|
||||||
|
if journal["endpoint"].rstrip("/") not in endpoints:
|
||||||
|
raise AgentError("Bank journal has an unrecognized endpoint; runtime storage retained")
|
||||||
if type(journal["version"]) is not int or journal["version"] != 1 or not isinstance(reports, list) or len(reports) > 4096:
|
if type(journal["version"]) is not int or journal["version"] != 1 or not isinstance(reports, list) or len(reports) > 4096:
|
||||||
raise ValueError()
|
raise ValueError()
|
||||||
# Controller independently binds this operation to the instance/server.
|
# Controller independently binds this operation to the instance/server.
|
||||||
@@ -2076,6 +2318,52 @@ class Agent:
|
|||||||
raise AgentError("Bank settlement was not acknowledged; runtime storage retained")
|
raise AgentError("Bank settlement was not acknowledged; runtime storage retained")
|
||||||
except (ValueError, TypeError, KeyError, AttributeError):
|
except (ValueError, TypeError, KeyError, AttributeError):
|
||||||
raise AgentError("Bank journal is invalid; runtime storage retained") from None
|
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):
|
def lifecycle(self, kind, instance_id, generation, payload):
|
||||||
unhealthy_recovery = payload.get("unhealthyRecovery") is True
|
unhealthy_recovery = payload.get("unhealthyRecovery") is True
|
||||||
@@ -2087,6 +2375,8 @@ class Agent:
|
|||||||
return self.create(instance_id, generation, payload)
|
return self.create(instance_id, generation, payload)
|
||||||
if kind == "instance.inspect":
|
if kind == "instance.inspect":
|
||||||
return self.inspect(instance_id, generation)
|
return self.inspect(instance_id, generation)
|
||||||
|
if kind == "instance.configure-moderation":
|
||||||
|
return self.configure_moderation(instance_id, generation, payload)
|
||||||
health = self.inspect(instance_id, generation)
|
health = self.inspect(instance_id, generation)
|
||||||
name = self.container_name(instance_id, generation)
|
name = self.container_name(instance_id, generation)
|
||||||
before = []
|
before = []
|
||||||
@@ -2116,12 +2406,21 @@ class Agent:
|
|||||||
if isinstance(next_generation, bool) or not isinstance(next_generation, int) or next_generation <= generation:
|
if isinstance(next_generation, bool) or not isinstance(next_generation, int) or next_generation <= generation:
|
||||||
raise AgentError("Rebuild must use a newer immutable generation")
|
raise AgentError("Rebuild must use a newer immutable generation")
|
||||||
spec = payload.get("spec") or {key: value for key, value in payload.items() if key not in ("nextGeneration", "newGeneration", "unhealthyRecovery")}
|
spec = payload.get("spec") or {key: value for key, value in payload.items() if key not in ("nextGeneration", "newGeneration", "unhealthyRecovery")}
|
||||||
self.validate_spec(spec)
|
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.
|
||||||
|
*_, prepared_files = self.validate_spec(spec)
|
||||||
if not override_safety:
|
if not override_safety:
|
||||||
self.safe_to_disrupt(instance_id, generation, allow_unhealthy=unhealthy_recovery)
|
self.safe_to_disrupt(instance_id, generation, allow_unhealthy=unhealthy_recovery)
|
||||||
if "autoscaling" in payload:
|
if "autoscaling" in payload:
|
||||||
self.authorize_autoscaling_removal(instance_id, generation, payload)
|
self.authorize_autoscaling_removal(instance_id, generation, payload)
|
||||||
self.checkpoint({"phase": "disrupting", "containerId": health.get("containerId"), "startedAt": health.get("startedAt")})
|
if kind == "instance.rebuild" and payload.get("stageId"):
|
||||||
|
before = self.rebuild_before(instance_id, generation, payload["stageId"], prepared_files)
|
||||||
|
if not override_safety:
|
||||||
|
self.safe_to_disrupt(instance_id, generation)
|
||||||
|
self.checkpoint({"phase": "disrupting", "containerId": health.get("containerId"), "startedAt": health.get("startedAt"), **({"before": before} if kind == "instance.rebuild" and payload.get("stageId") else {})})
|
||||||
if payload.get("stageId") and kind == "instance.restart":
|
if payload.get("stageId") and kind == "instance.restart":
|
||||||
before = self.activate(instance_id, generation, payload["stageId"], override_safety=override_safety)
|
before = self.activate(instance_id, generation, payload["stageId"], override_safety=override_safety)
|
||||||
if kind in ("instance.remove", "instance.rebuild"):
|
if kind in ("instance.remove", "instance.rebuild"):
|
||||||
@@ -2130,12 +2429,12 @@ class Agent:
|
|||||||
self.docker.call("container", "rm", name)
|
self.docker.call("container", "rm", name)
|
||||||
self.settle_bank(instance_id, generation)
|
self.settle_bank(instance_id, generation)
|
||||||
if kind == "instance.rebuild":
|
if kind == "instance.rebuild":
|
||||||
result = self.create(instance_id, next_generation, spec)
|
result = self.create(instance_id, next_generation, spec, before=before if payload.get("stageId") else None, prepared_files=prepared_files)
|
||||||
self.operation_phase = "removing-old-instance"
|
self.operation_phase = "removing-old-instance"
|
||||||
old = self.instance_dir(instance_id, generation)
|
old = self.instance_dir(instance_id, generation)
|
||||||
if old.exists():
|
if old.exists():
|
||||||
shutil.rmtree(old, ignore_errors=False)
|
shutil.rmtree(old, ignore_errors=False)
|
||||||
return result
|
return {**result, "before": before}
|
||||||
directory = self.instance_dir(instance_id, generation)
|
directory = self.instance_dir(instance_id, generation)
|
||||||
if directory.exists():
|
if directory.exists():
|
||||||
shutil.rmtree(directory)
|
shutil.rmtree(directory)
|
||||||
@@ -2210,6 +2509,7 @@ class Agent:
|
|||||||
paths = set()
|
paths = set()
|
||||||
total_size = 0
|
total_size = 0
|
||||||
for item in files:
|
for item in files:
|
||||||
|
require_public_file(item["path"])
|
||||||
scoped(directory / "app", item["path"])
|
scoped(directory / "app", item["path"])
|
||||||
if item["path"] in paths:
|
if item["path"] in paths:
|
||||||
raise AgentError("Duplicate staged file path")
|
raise AgentError("Duplicate staged file path")
|
||||||
@@ -2230,11 +2530,36 @@ class Agent:
|
|||||||
atomic_json(marker, manifest)
|
atomic_json(marker, manifest)
|
||||||
return {"stageId": stage_id, "files": len(planned), "state": "staged"}
|
return {"stageId": stage_id, "files": len(planned), "state": "staged"}
|
||||||
|
|
||||||
|
def rebuild_before(self, instance_id, generation, stage_id, prepared_files):
|
||||||
|
"""Validate requested replacement bytes and snapshot the old generation before teardown."""
|
||||||
|
directory = self.instance_dir(instance_id, generation)
|
||||||
|
marker = directory / "stages" / identifier(stage_id) / "manifest.json"
|
||||||
|
saved = json.loads(marker.read_text())
|
||||||
|
if saved["state"] != "staged":
|
||||||
|
raise Rejected("Rebuild requires an unactivated file stage")
|
||||||
|
before = []
|
||||||
|
for item in saved["files"]:
|
||||||
|
require_public_file(item["path"])
|
||||||
|
target = prepared_files.get(item["path"])
|
||||||
|
if item["delete"]:
|
||||||
|
if target is not None:
|
||||||
|
raise Rejected("Rebuild includes a file requested for removal")
|
||||||
|
elif target is None or digest(target) != item["sha256"]:
|
||||||
|
raise Rejected("Rebuild does not match staged file content")
|
||||||
|
content = managed_read(directory / "app", item["path"])
|
||||||
|
actual_sha = None if content is None else digest(content)
|
||||||
|
if item.get("checkBefore") and actual_sha != item.get("expectedBeforeSha"):
|
||||||
|
raise Rejected("Target changed after campaign review")
|
||||||
|
before.append({"id": item.get("id"), "path": item["path"], "contentBase64": None if content is None else base64.b64encode(content).decode()})
|
||||||
|
return before
|
||||||
|
|
||||||
def activate(self, instance_id, generation, stage_id, *, override_safety=False):
|
def activate(self, instance_id, generation, stage_id, *, override_safety=False):
|
||||||
directory = self.instance_dir(instance_id, generation)
|
directory = self.instance_dir(instance_id, generation)
|
||||||
stage = directory / "stages" / identifier(stage_id)
|
stage = directory / "stages" / identifier(stage_id)
|
||||||
marker = stage / "manifest.json"
|
marker = stage / "manifest.json"
|
||||||
saved = json.loads(marker.read_text())
|
saved = json.loads(marker.read_text())
|
||||||
|
for item in saved["files"]:
|
||||||
|
require_public_file(item["path"])
|
||||||
if saved["state"] == "activated":
|
if saved["state"] == "activated":
|
||||||
return saved["before"]
|
return saved["before"]
|
||||||
if saved["state"] != "staged":
|
if saved["state"] != "staged":
|
||||||
@@ -2265,6 +2590,8 @@ class Agent:
|
|||||||
return before
|
return before
|
||||||
|
|
||||||
def restore_files(self, app, before):
|
def restore_files(self, app, before):
|
||||||
|
for item in before:
|
||||||
|
require_public_file(item["path"])
|
||||||
for item in before:
|
for item in before:
|
||||||
managed_write(app, item["path"], None if item["contentBase64"] is None else self.decode_file(item), int(self.config.get("runtimeUid", 1000)), int(self.config.get("runtimeGid", 1000)))
|
managed_write(app, item["path"], None if item["contentBase64"] is None else self.decode_file(item), int(self.config.get("runtimeUid", 1000)), int(self.config.get("runtimeGid", 1000)))
|
||||||
|
|
||||||
@@ -2319,6 +2646,7 @@ class Agent:
|
|||||||
def read_log(self, instance_id, generation, payload):
|
def read_log(self, instance_id, generation, payload):
|
||||||
app = self.instance_dir(instance_id, generation) / "app"
|
app = self.instance_dir(instance_id, generation) / "app"
|
||||||
value = payload["path"]
|
value = payload["path"]
|
||||||
|
require_public_file(value)
|
||||||
count = min(2000, max(1, int(payload.get("lines", 200))))
|
count = min(2000, max(1, int(payload.get("lines", 200))))
|
||||||
# Validate the original relative spelling independently of symlinks.
|
# Validate the original relative spelling independently of symlinks.
|
||||||
scoped(self.root / "agent" / "log-validation", value)
|
scoped(self.root / "agent" / "log-validation", value)
|
||||||
@@ -2335,6 +2663,7 @@ class Agent:
|
|||||||
raise AgentError("Log symlink resolves outside the managed instance") from None
|
raise AgentError("Log symlink resolves outside the managed instance") from None
|
||||||
# Reopen the resolved relative target through no-follow directory FDs;
|
# Reopen the resolved relative target through no-follow directory FDs;
|
||||||
# swapping either the original link or a target component cannot escape.
|
# swapping either the original link or a target component cannot escape.
|
||||||
|
require_public_file(relative)
|
||||||
with managed_open(app, relative) as stream:
|
with managed_open(app, relative) as stream:
|
||||||
stat = os.fstat(stream.fileno())
|
stat = os.fstat(stream.fileno())
|
||||||
if payload.get("mode") == "incremental":
|
if payload.get("mode") == "incremental":
|
||||||
@@ -2882,6 +3211,16 @@ class Agent:
|
|||||||
return self.journal.update(operation["id"], "failed", {"autoscalingInvalidated": True}, error="Interrupted autoscaling removal requires a new decision")
|
return self.journal.update(operation["id"], "failed", {"autoscalingInvalidated": True}, error="Interrupted autoscaling removal requires a new decision")
|
||||||
if kind == "instance.cancel-create":
|
if kind == "instance.cancel-create":
|
||||||
return self.journal.update(operation["id"], "completed", self.cancel_creation(instance_id, generation, payload, operation["id"]))
|
return self.journal.update(operation["id"], "completed", self.cancel_creation(instance_id, generation, payload, operation["id"]))
|
||||||
|
if kind == "instance.configure-moderation":
|
||||||
|
current = json.loads((self.instance_dir(instance_id, generation).parent / "current.json").read_text())
|
||||||
|
if current.get("generation") != generation or current.get("removed"):
|
||||||
|
return self.journal.update(operation["id"], "failed", error="Moderation configuration generation was superseded or removed")
|
||||||
|
content = self.moderation_config(payload.get("moderation"), generation)
|
||||||
|
app = self.instance_dir(instance_id, generation) / "app"
|
||||||
|
bank = self.moderation_bootstrap(instance_id, generation)
|
||||||
|
if self.private_config_bytes(app, MODERATION_CONFIG_PATH) != content or bank is not None and self.private_config_bytes(app, "configs/electric_cherry_bank.cfg") != bank:
|
||||||
|
return row
|
||||||
|
return self.journal.update(operation["id"], "completed", {"configured": True, "generation": generation, "liveLoad": "unconfirmed", "reconciled": True})
|
||||||
if kind == "instance.create":
|
if kind == "instance.create":
|
||||||
marker = self.instance_dir(instance_id, generation).parent / "current.json"
|
marker = self.instance_dir(instance_id, generation).parent / "current.json"
|
||||||
if marker.exists():
|
if marker.exists():
|
||||||
@@ -2961,6 +3300,12 @@ class Agent:
|
|||||||
health = self.inspect(instance_id, observed_generation)
|
health = self.inspect(instance_id, observed_generation)
|
||||||
before = []
|
before = []
|
||||||
if payload.get("stageId"):
|
if payload.get("stageId"):
|
||||||
|
if kind == "instance.rebuild":
|
||||||
|
# The creating checkpoint retains snapshots after old storage is deleted.
|
||||||
|
if not isinstance(checkpoint.get("before"), list):
|
||||||
|
return row
|
||||||
|
before = checkpoint["before"]
|
||||||
|
else:
|
||||||
stage = self.instance_dir(instance_id, generation) / "stages" / identifier(payload["stageId"]) / "manifest.json"
|
stage = self.instance_dir(instance_id, generation) / "stages" / identifier(payload["stageId"]) / "manifest.json"
|
||||||
saved = json.loads(stage.read_text())
|
saved = json.loads(stage.read_text())
|
||||||
if saved["state"] != "activated":
|
if saved["state"] != "activated":
|
||||||
|
|||||||
+588
-6
@@ -778,6 +778,12 @@ class AgentTests(unittest.TestCase):
|
|||||||
else:
|
else:
|
||||||
self.assertEqual(inventory["cpuUsagePercent"], usage)
|
self.assertEqual(inventory["cpuUsagePercent"], usage)
|
||||||
|
|
||||||
|
def test_inventory_reports_optional_temperature_capability_and_empty_samples(self):
|
||||||
|
with patch.object(self.agent, "temperatures", return_value=[]):
|
||||||
|
inventory = self.agent.inventory()
|
||||||
|
self.assertEqual(inventory["temperatures"], [])
|
||||||
|
self.assertIn("host.temperatures", inventory["capabilities"])
|
||||||
|
|
||||||
def resource_container(self):
|
def resource_container(self):
|
||||||
return {"Name": self.agent.container_name(INSTANCE, 1),
|
return {"Name": self.agent.container_name(INSTANCE, 1),
|
||||||
"Config": {"Labels": {module.LABEL + ".managed": "true", module.LABEL + ".instance": INSTANCE, module.LABEL + ".generation": "1"}},
|
"Config": {"Labels": {module.LABEL + ".managed": "true", module.LABEL + ".instance": INSTANCE, module.LABEL + ".generation": "1"}},
|
||||||
@@ -1644,24 +1650,304 @@ class AgentTests(unittest.TestCase):
|
|||||||
self.assertEqual(self.agent.execute(self.operation("instance.remove"))["state"], "failed")
|
self.assertEqual(self.agent.execute(self.operation("instance.remove"))["state"], "failed")
|
||||||
self.assertFalse(any(call[:2] == ("container", "stop") for call in self.docker.calls))
|
self.assertFalse(any(call[:2] == ("container", "stop") for call in self.docker.calls))
|
||||||
|
|
||||||
def test_create_materializes_private_bank_config_from_instance_credential(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"
|
||||||
|
target = app / value
|
||||||
|
secret = b'set tws_bank_token "ec_bank_private_regression"\n'
|
||||||
|
target.write_bytes(secret)
|
||||||
|
files = [{"id": 9, "path": value, "delete": True, "contentBase64": None}]
|
||||||
|
with self.assertRaisesRegex(AgentError, "Private bank"):
|
||||||
|
self.agent.stage(INSTANCE, 1, {"stageId": "private", "files": files}, "stage")
|
||||||
|
marker = self.agent.instance_dir(INSTANCE, 1) / "stages/private/manifest.json"
|
||||||
|
# A stage written by a previous agent must be rejected on activation too.
|
||||||
|
module.atomic_json(marker, {"state": "staged", "files": files})
|
||||||
|
with self.assertRaisesRegex(AgentError, "Private bank"):
|
||||||
|
self.agent.activate(INSTANCE, 1, "private", override_safety=True)
|
||||||
|
with self.assertRaisesRegex(AgentError, "Private bank"):
|
||||||
|
self.agent.rebuild_before(INSTANCE, 1, "private", {})
|
||||||
|
with self.assertRaisesRegex(AgentError, "Private bank"):
|
||||||
|
self.agent.restore_files(app, [{"path": value, "contentBase64": None}])
|
||||||
|
with self.assertRaisesRegex(AgentError, "Private bank"):
|
||||||
|
self.agent.file_operation("files.read", INSTANCE, 1, {"path": value}, "read")
|
||||||
|
with self.assertRaisesRegex(AgentError, "Private bank"):
|
||||||
|
self.agent.read_log(INSTANCE, 1, {"path": value})
|
||||||
|
manifest = self.agent.file_operation("files.manifest", INSTANCE, 1,
|
||||||
|
{"roots": ["configs"], "paths": [value], "ignoreNames": []}, "manifest")
|
||||||
|
self.assertNotIn(value, [item["path"] for item in manifest["files"]])
|
||||||
|
self.assertEqual(target.read_bytes(), secret)
|
||||||
|
self.assertNotIn("before", json.loads(marker.read_text()))
|
||||||
|
with self.assertRaisesRegex(AgentError, "Private bank"):
|
||||||
|
self.agent.initial_files({"files": [{"path": value, "contentBase64": base64.b64encode(secret).decode()}]})
|
||||||
|
|
||||||
|
def prepare_t6_library(self):
|
||||||
|
target = self.agent.library_dir("t6", self.library_id)
|
||||||
|
module.shutil.copytree(self.library, target)
|
||||||
|
manifest = json.loads((target / "manifest.json").read_text())
|
||||||
|
manifest["game"] = "t6"
|
||||||
|
module.atomic_json(target / "manifest.json", manifest)
|
||||||
|
|
||||||
|
def test_t6_create_materializes_private_bank_config(self):
|
||||||
|
self.prepare_t6_library()
|
||||||
|
self.test_create_materializes_private_bank_config_from_instance_credential(game="t6")
|
||||||
|
|
||||||
|
def test_create_materializes_private_bank_config_from_instance_credential(self, game="t5"):
|
||||||
secret = "ec_bank_" + "a" * 43
|
secret = "ec_bank_" + "a" * 43
|
||||||
payload = {"name": "server", "game": "t5", "libraryId": self.library_id,
|
payload = {"name": "server", "game": game, "libraryId": self.library_id,
|
||||||
"image": "ghcr.io/ayymoss/plutainer:latest", "port": 28960,
|
"image": "ghcr.io/ayymoss/plutainer:latest", "port": 28960,
|
||||||
"bank": {"token": secret, "configPath": "configs/electric_cherry_bank.cfg"}}
|
"bank": {"token": secret, "configPath": "configs/electric_cherry_bank.cfg"}}
|
||||||
operation = self.operation("instance.create", payload, "create")
|
operation = self.operation("instance.create", payload, "create")
|
||||||
self.assertEqual(self.agent.execute(operation)["state"], "completed")
|
self.assertEqual(self.agent.execute(operation)["state"], "completed")
|
||||||
path = self.agent.instance_dir(INSTANCE, 1) / "app/configs/electric_cherry_bank.cfg"
|
path = self.agent.instance_dir(INSTANCE, 1) / "app/configs/electric_cherry_bank.cfg"
|
||||||
self.assertEqual(path.read_text(), f'set tws_bank_token "{secret}"\nset tws_bank_url "https://controller.example/api/bank"\n')
|
self.assertEqual(path.read_text(), f'set tws_bank_token "{secret}"\n')
|
||||||
|
self.assertEqual(self.agent.config["controller"], "https://controller.example")
|
||||||
self.assertIn("bank.settlement", self.agent.inventory()["capabilities"])
|
self.assertIn("bank.settlement", self.agent.inventory()["capabilities"])
|
||||||
payload["bank"]["token"] = secret + '\nset bad "1"'
|
payload["bank"]["token"] = secret + '\nset bad "1"'
|
||||||
with self.assertRaises(AgentError):
|
with self.assertRaises(AgentError):
|
||||||
self.agent.initial_files(payload)
|
self.agent.initial_files(payload)
|
||||||
|
|
||||||
def bank_journal(self, decisions=("commit", "cancel"), game="t5"):
|
def moderation_configuration(self, generation=1):
|
||||||
path = self.agent.instance_dir(INSTANCE, 1) / f"app/runtime/tws-bank/{game}/scope.json"
|
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)]
|
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)]
|
||||||
module.atomic_json(path, {"version": 1, "server": "t5_zombie_test_1", "endpoint": "https://controller.example/api/bank", "reports": reports})
|
module.atomic_json(path, {"version": 1, "server": "t5_zombie_test_1", "endpoint": endpoint, "reports": reports})
|
||||||
return path
|
return path
|
||||||
|
|
||||||
def bank_acknowledgment(self, method, path, payload):
|
def bank_acknowledgment(self, method, path, payload):
|
||||||
@@ -1698,6 +1984,84 @@ class AgentTests(unittest.TestCase):
|
|||||||
self.assertFalse(path.exists())
|
self.assertFalse(path.exists())
|
||||||
self.assertEqual(self.docker.calls, calls)
|
self.assertEqual(self.docker.calls, calls)
|
||||||
|
|
||||||
|
def test_t6_rebuild_preserves_private_bank_configuration(self):
|
||||||
|
self.prepare_t6_library()
|
||||||
|
self.test_endpoint_upgrade_rebuild_retries_original_decisions_before_starting_public_bank(game="t6")
|
||||||
|
|
||||||
|
def test_endpoint_upgrade_rebuild_retries_original_decisions_before_starting_public_bank(self, game="t5"):
|
||||||
|
secret = "ec_bank_" + "a" * 43
|
||||||
|
payload = self.create()
|
||||||
|
payload["game"] = game
|
||||||
|
payload["bank"] = {"token": secret, "configPath": "configs/electric_cherry_bank.cfg"}
|
||||||
|
paths = [self.bank_journal(game=game, endpoint=endpoint)
|
||||||
|
for game in ("t5", "t6")
|
||||||
|
for endpoint in (module.BANK_GAME_ENDPOINT, "https://controller.example/api/bank")]
|
||||||
|
originals = {path: path.read_bytes() for path in paths}
|
||||||
|
operation = self.operation("instance.rebuild", {"spec": payload, "nextGeneration": 2, "safetyOverride": True}, "rebuild")
|
||||||
|
applied, calls = {}, []
|
||||||
|
|
||||||
|
def acknowledge(method, path, data):
|
||||||
|
self.assertFalse(self.agent.inspect(INSTANCE, 2)["exists"])
|
||||||
|
self.assertEqual(self.agent.config["controller"], "https://controller.example")
|
||||||
|
for journal, raw in originals.items():
|
||||||
|
self.assertEqual(journal.read_bytes(), raw)
|
||||||
|
transfer = data["transfer"]
|
||||||
|
request_id = transfer["request_id"]
|
||||||
|
calls.append(request_id)
|
||||||
|
if request_id in applied:
|
||||||
|
self.assertEqual(applied[request_id], transfer)
|
||||||
|
applied[request_id] = transfer
|
||||||
|
response = self.bank_acknowledgment(method, path, data)
|
||||||
|
if len(calls) == 1:
|
||||||
|
raise AgentError("Lost reply after settlement")
|
||||||
|
return response
|
||||||
|
|
||||||
|
with patch.object(self.agent, "request", side_effect=acknowledge):
|
||||||
|
self.assertEqual(self.agent.execute(operation)["state"], "unknown")
|
||||||
|
self.agent.journal.close()
|
||||||
|
self.agent = Agent(self.config, self.docker)
|
||||||
|
with patch.object(self.agent, "request", side_effect=acknowledge):
|
||||||
|
self.assertEqual(self.agent.execute(operation)["state"], "failed")
|
||||||
|
retry = self.operation("instance.rebuild", operation["payload"], "replacement")
|
||||||
|
result = self.agent.execute(retry)
|
||||||
|
self.assertEqual(result["state"], "completed", result)
|
||||||
|
# Reconciliation drains once, then the replacement operation drains
|
||||||
|
# again before deleting the unchanged journals. All retries keep IDs.
|
||||||
|
self.assertEqual(len(calls), 17)
|
||||||
|
self.assertEqual(len(applied), 4)
|
||||||
|
self.assertEqual({item["decision"] for item in applied.values()}, {"commit", "cancel"})
|
||||||
|
self.assertTrue(all(not path.exists() for path in paths))
|
||||||
|
self.assertTrue(self.agent.inspect(INSTANCE, 2)["running"])
|
||||||
|
config = self.agent.instance_dir(INSTANCE, 2) / "app/configs/electric_cherry_bank.cfg"
|
||||||
|
self.assertEqual(config.read_text(), f'set tws_bank_token "{secret}"\n')
|
||||||
|
|
||||||
|
def test_remove_drains_legacy_and_public_endpoints_without_following_journal_urls(self):
|
||||||
|
self.create()
|
||||||
|
paths = [self.bank_journal(game=game, endpoint=endpoint)
|
||||||
|
for game in ("t5", "t6")
|
||||||
|
for endpoint in (module.BANK_GAME_ENDPOINT, "https://controller.example/api/bank")]
|
||||||
|
with patch.object(self.agent, "request", side_effect=self.bank_acknowledgment) as request:
|
||||||
|
result = self.agent.execute(self.operation("instance.remove", {"safetyOverride": True}, "remove"))
|
||||||
|
self.assertEqual(result["state"], "completed", result)
|
||||||
|
self.assertEqual(request.call_count, 8)
|
||||||
|
self.assertTrue(all(not path.exists() for path in paths))
|
||||||
|
|
||||||
|
def test_unrecognized_bank_endpoints_retain_storage_without_settlement(self):
|
||||||
|
self.create()
|
||||||
|
path = self.bank_journal()
|
||||||
|
operation = self.operation("instance.remove", {"safetyOverride": True}, "remove")
|
||||||
|
for endpoint in ("https://untrusted.example/api/bank", "http://ec.thewrightserver.net/api/bank",
|
||||||
|
"https://ec.thewrightserver.net.evil/api/bank", module.BANK_GAME_ENDPOINT + "?redirect=evil",
|
||||||
|
"https://controller.example/other/api/bank", "", None):
|
||||||
|
with self.subTest(endpoint=endpoint):
|
||||||
|
journal = json.loads(path.read_text())
|
||||||
|
journal["endpoint"] = endpoint
|
||||||
|
module.atomic_json(path, journal)
|
||||||
|
with patch.object(self.agent, "request") as request:
|
||||||
|
self.assertEqual(self.agent.execute(operation)["state"], "unknown")
|
||||||
|
request.assert_not_called()
|
||||||
|
self.assertTrue(path.exists())
|
||||||
|
|
||||||
def test_rebuild_settles_before_starting_replacement_and_retains_unknown_decisions(self):
|
def test_rebuild_settles_before_starting_replacement_and_retains_unknown_decisions(self):
|
||||||
payload = self.create()
|
payload = self.create()
|
||||||
path = self.bank_journal(("commit",))
|
path = self.bank_journal(("commit",))
|
||||||
@@ -1747,6 +2111,224 @@ class AgentTests(unittest.TestCase):
|
|||||||
self.assertTrue(path.is_symlink())
|
self.assertTrue(path.is_symlink())
|
||||||
request.assert_not_called()
|
request.assert_not_called()
|
||||||
|
|
||||||
|
def staged_rebuild(self, deleting=False):
|
||||||
|
spec = self.create()
|
||||||
|
self.agent.rcon = lambda *_: {"ok": True, "players": 0}
|
||||||
|
file = {"id": 9, "path": "configs/server.cfg", "contentBase64": None if deleting else base64.b64encode(b"replacement").decode(), "checkBefore": True, "expectedBeforeSha": module.digest(b"original")}
|
||||||
|
staged = self.agent.execute(self.operation("files.stage", {"stageId": "campaign", "files": [file]}, "stage"))
|
||||||
|
self.assertEqual(staged["state"], "completed")
|
||||||
|
spec["files"] = [] if deleting else [file]
|
||||||
|
return self.operation("instance.rebuild", {"spec": spec, "stageId": "campaign", "nextGeneration": 2}, "rebuild")
|
||||||
|
|
||||||
|
def test_file_rebuild_replaces_generation_and_retains_before_images(self):
|
||||||
|
operation = self.staged_rebuild()
|
||||||
|
result = self.agent.execute(operation)
|
||||||
|
self.assertEqual(result["state"], "completed", result)
|
||||||
|
receipt = json.loads(result["result"])
|
||||||
|
self.assertEqual(receipt["before"], [{"id": 9, "path": "configs/server.cfg", "contentBase64": base64.b64encode(b"original").decode()}])
|
||||||
|
self.assertEqual(receipt["generation"], 2)
|
||||||
|
self.assertFalse(self.agent.instance_dir(INSTANCE, 1).exists())
|
||||||
|
self.assertEqual((self.agent.instance_dir(INSTANCE, 2) / "app/configs/server.cfg").read_bytes(), b"replacement")
|
||||||
|
self.assertIn("instance.rebuild-files", self.agent.inventory()["capabilities"])
|
||||||
|
calls = list(self.docker.calls)
|
||||||
|
self.assertEqual(self.agent.execute(operation)["state"], "completed")
|
||||||
|
self.assertEqual(self.docker.calls, calls)
|
||||||
|
|
||||||
|
def test_file_rebuild_removes_requested_files(self):
|
||||||
|
operation = self.staged_rebuild(deleting=True)
|
||||||
|
result = self.agent.execute(operation)
|
||||||
|
self.assertEqual(result["state"], "completed", result)
|
||||||
|
self.assertFalse((self.agent.instance_dir(INSTANCE, 2) / "app/configs/server.cfg").exists())
|
||||||
|
self.assertEqual(len(json.loads(result["result"])["before"]), 1)
|
||||||
|
|
||||||
|
def test_file_rebuild_rejects_changed_targets_and_mismatched_replacement(self):
|
||||||
|
operation = self.staged_rebuild()
|
||||||
|
target = self.agent.instance_dir(INSTANCE, 1) / "app/configs/server.cfg"
|
||||||
|
target.write_bytes(b"changed")
|
||||||
|
self.assertEqual(self.agent.execute(operation)["state"], "failed")
|
||||||
|
self.assertTrue(self.agent.inspect(INSTANCE, 1)["running"])
|
||||||
|
target.write_bytes(b"original")
|
||||||
|
operation["id"] = "mismatched"
|
||||||
|
operation["payload"]["spec"]["files"] = []
|
||||||
|
self.assertEqual(self.agent.execute(operation)["state"], "failed")
|
||||||
|
self.assertTrue(self.agent.inspect(INSTANCE, 1)["running"])
|
||||||
|
self.assertFalse(any(call[:2] == ("container", "rm") for call in self.docker.calls))
|
||||||
|
|
||||||
|
def test_file_rebuild_rechecks_players_after_snapshot_preparation(self):
|
||||||
|
operation = self.staged_rebuild()
|
||||||
|
with patch.object(self.agent, "rcon", side_effect=[{"ok": True, "players": 0}, {"ok": True, "players": 1}]):
|
||||||
|
self.assertEqual(self.agent.execute(operation)["state"], "accepted")
|
||||||
|
self.assertTrue(self.agent.inspect(INSTANCE, 1)["running"])
|
||||||
|
self.assertFalse(self.agent.inspect(INSTANCE, 2)["exists"])
|
||||||
|
|
||||||
|
def test_file_rebuild_reconciles_snapshots_after_old_storage_was_removed(self):
|
||||||
|
operation = self.staged_rebuild()
|
||||||
|
self.assertEqual(self.agent.execute(operation)["state"], "completed")
|
||||||
|
self.agent.journal.update(operation["id"], "unknown", error="Lost completion")
|
||||||
|
calls = list(self.docker.calls)
|
||||||
|
result = self.agent.execute(operation)
|
||||||
|
self.assertEqual(result["state"], "completed", result)
|
||||||
|
self.assertEqual(json.loads(result["result"])["before"][0]["contentBase64"], base64.b64encode(b"original").decode())
|
||||||
|
self.assertEqual(self.docker.calls, calls)
|
||||||
|
|
||||||
|
def artifact_rebuild(self, staged=False):
|
||||||
|
if staged:
|
||||||
|
operation = self.staged_rebuild()
|
||||||
|
else:
|
||||||
|
spec = self.create()
|
||||||
|
self.agent.rcon = lambda *_: {"ok": True, "players": 0}
|
||||||
|
operation = self.operation("instance.rebuild", {"spec": spec, "nextGeneration": 2}, "rebuild")
|
||||||
|
spec = operation["payload"]["spec"]
|
||||||
|
contents = {"configs/server.cfg": b"replacement", "scripts/replacement.gsc": b"verified artifact bytes"}
|
||||||
|
spec["files"] = [{"path": path, "contentSha256": module.digest(content), "sha256": module.digest(content)} for path, content in contents.items()]
|
||||||
|
spec["files"].append({"path": "configs/empty.cfg", "contentBase64": ""})
|
||||||
|
spec["bank"] = {"configPath": "configs/electric_cherry_bank.cfg", "token": "ec_bank_" + "a" * 32}
|
||||||
|
return operation, contents
|
||||||
|
|
||||||
|
def assert_rebuild_uses_preflight_artifacts(self, staged):
|
||||||
|
operation, contents = self.artifact_rebuild(staged)
|
||||||
|
original_request = json.dumps(operation, sort_keys=True)
|
||||||
|
available = {module.digest(content): content for content in contents.values()}
|
||||||
|
|
||||||
|
def fetch(method, url):
|
||||||
|
# Connectivity disappears as soon as the complete preflight set has
|
||||||
|
# been read, before staged comparison or destructive replacement.
|
||||||
|
if not available:
|
||||||
|
raise module.urllib.error.URLError(TimeoutError())
|
||||||
|
self.assertTrue(self.agent.inspect(INSTANCE, 1)["running"])
|
||||||
|
return Response(available.pop(url.rsplit("/", 1)[-1]))
|
||||||
|
|
||||||
|
with patch.object(self.agent, "artifact_open", side_effect=fetch) as downloads:
|
||||||
|
result = self.agent.execute(operation)
|
||||||
|
self.assertEqual(result["state"], "completed", result)
|
||||||
|
self.assertEqual(downloads.call_count, len(contents))
|
||||||
|
self.assertFalse(available)
|
||||||
|
self.assertFalse(self.agent.inspect(INSTANCE, 1)["exists"])
|
||||||
|
self.assertTrue(self.agent.inspect(INSTANCE, 2)["running"])
|
||||||
|
app = self.agent.instance_dir(INSTANCE, 2) / "app"
|
||||||
|
for path, content in contents.items():
|
||||||
|
self.assertEqual((app / path).read_bytes(), content)
|
||||||
|
self.assertEqual((app / "configs/empty.cfg").read_bytes(), b"")
|
||||||
|
self.assertEqual((app / "configs/electric_cherry_bank.cfg").read_text(), 'set tws_bank_token "ec_bank_' + "a" * 32 + '"\n')
|
||||||
|
self.assertEqual(json.dumps(operation, sort_keys=True), original_request)
|
||||||
|
self.assertEqual(json.loads(result["request"]), operation)
|
||||||
|
self.assertEqual(self.agent.spec(INSTANCE, 2), operation["payload"]["spec"])
|
||||||
|
labels = self.docker.inspect(self.agent.container_name(INSTANCE, 2))["Config"]["Labels"]
|
||||||
|
self.assertEqual(labels[module.LABEL + ".operation"], operation["id"])
|
||||||
|
self.assertEqual(labels[module.LABEL + ".spec-sha256"], module.digest(json.dumps(operation["payload"]["spec"], sort_keys=True, separators=(",", ":")).encode()))
|
||||||
|
receipt = json.loads(result["result"])
|
||||||
|
self.assertEqual(receipt["before"], [{"id": 9, "path": "configs/server.cfg", "contentBase64": base64.b64encode(b"original").decode()}] if staged else [])
|
||||||
|
for content in contents.values():
|
||||||
|
self.assertNotIn(base64.b64encode(content).decode(), json.dumps(result))
|
||||||
|
|
||||||
|
# A later operation with the same artifacts must download its own set.
|
||||||
|
later = self.operation("instance.rebuild", {"spec": operation["payload"]["spec"], "nextGeneration": 3}, "later-rebuild", generation=2)
|
||||||
|
with patch.object(self.agent, "artifact_open", side_effect=module.urllib.error.URLError(TimeoutError())) as downloads:
|
||||||
|
self.assertEqual(self.agent.execute(later)["state"], "failed")
|
||||||
|
downloads.assert_called_once()
|
||||||
|
self.assertTrue(self.agent.inspect(INSTANCE, 2)["running"])
|
||||||
|
self.assertFalse(self.agent.inspect(INSTANCE, 3)["exists"])
|
||||||
|
|
||||||
|
def test_unstaged_rebuild_succeeds_without_artifact_connectivity_after_preflight(self):
|
||||||
|
self.assert_rebuild_uses_preflight_artifacts(staged=False)
|
||||||
|
|
||||||
|
def test_staged_rebuild_succeeds_without_artifact_connectivity_after_preflight(self):
|
||||||
|
self.assert_rebuild_uses_preflight_artifacts(staged=True)
|
||||||
|
|
||||||
|
def test_rebuild_preflight_download_failure_preserves_original_and_discards_partial_set(self):
|
||||||
|
operation, contents = self.artifact_rebuild()
|
||||||
|
with patch.object(self.agent, "artifact_open", side_effect=[Response(contents["configs/server.cfg"]), module.urllib.error.URLError(TimeoutError())]) as downloads:
|
||||||
|
result = self.agent.execute(operation)
|
||||||
|
self.assertEqual(result["state"], "failed", result)
|
||||||
|
self.assertEqual(downloads.call_count, 2)
|
||||||
|
self.assertTrue(self.agent.inspect(INSTANCE, 1)["running"])
|
||||||
|
self.assertFalse(self.agent.instance_dir(INSTANCE, 2).exists())
|
||||||
|
self.assertFalse(any(args[:2] in (("container", "stop"), ("container", "rm")) for args in self.docker.calls))
|
||||||
|
operation["id"] = "retry-rebuild"
|
||||||
|
with patch.object(self.agent, "artifact_open", side_effect=module.urllib.error.URLError(TimeoutError())) as downloads:
|
||||||
|
self.assertEqual(self.agent.execute(operation)["state"], "failed")
|
||||||
|
self.assertTrue(downloads.call_args.args[1].endswith(module.digest(contents["configs/server.cfg"])))
|
||||||
|
self.assertTrue(self.agent.inspect(INSTANCE, 1)["running"])
|
||||||
|
|
||||||
|
def test_held_rebuild_discards_prepared_artifacts_before_retry(self):
|
||||||
|
operation, contents = self.artifact_rebuild(staged=True)
|
||||||
|
with patch.object(self.agent, "artifact_open", side_effect=[Response(content) for content in contents.values()]), patch.object(self.agent, "rcon", return_value={"ok": True, "players": 1}):
|
||||||
|
self.assertEqual(self.agent.execute(operation)["state"], "accepted")
|
||||||
|
with patch.object(self.agent, "artifact_open", side_effect=module.urllib.error.URLError(TimeoutError())) as downloads:
|
||||||
|
self.assertEqual(self.agent.execute(operation)["state"], "failed")
|
||||||
|
downloads.assert_called_once()
|
||||||
|
self.assertTrue(self.agent.inspect(INSTANCE, 1)["running"])
|
||||||
|
self.assertFalse(self.agent.inspect(INSTANCE, 2)["exists"])
|
||||||
|
|
||||||
|
def test_rebuild_preflight_rejects_corrupt_and_oversized_artifacts(self):
|
||||||
|
operation, contents = self.artifact_rebuild()
|
||||||
|
for index, content in enumerate((b"corrupt", b"x" * (module.MAX_FILE + 1))):
|
||||||
|
with self.subTest(index=index):
|
||||||
|
operation["id"] = f"invalid-artifact-{index}"
|
||||||
|
with patch.object(self.agent, "artifact_open", return_value=Response(content)):
|
||||||
|
result = self.agent.execute(operation)
|
||||||
|
self.assertEqual(result["state"], "failed", result)
|
||||||
|
self.assertIn("checksum mismatch" if index == 0 else "size limit", result["error"])
|
||||||
|
self.assertTrue(self.agent.inspect(INSTANCE, 1)["running"])
|
||||||
|
self.assertFalse(any(args[:2] in (("container", "stop"), ("container", "rm")) for args in self.docker.calls))
|
||||||
|
|
||||||
|
def test_rebuild_preflight_bounds_complete_file_set_at_64_mib(self):
|
||||||
|
operation, _ = self.artifact_rebuild()
|
||||||
|
spec = operation["payload"]["spec"]
|
||||||
|
content = b"x" * module.MAX_FILE
|
||||||
|
spec["files"] = [{"path": f"scripts/{index}.gsc", "contentSha256": module.digest(content)} for index in range(2)]
|
||||||
|
# The generated private bank file also counts toward the operation limit.
|
||||||
|
with patch.object(self.agent, "artifact_open", side_effect=lambda *args: Response(content)):
|
||||||
|
result = self.agent.execute(operation)
|
||||||
|
self.assertEqual(result["state"], "failed", result)
|
||||||
|
self.assertIn("64 MiB operation limit", result["error"])
|
||||||
|
self.assertTrue(self.agent.inspect(INSTANCE, 1)["running"])
|
||||||
|
self.assertFalse(any(args[:2] in (("container", "stop"), ("container", "rm")) for args in self.docker.calls))
|
||||||
|
del spec["bank"]
|
||||||
|
operation["id"] = "at-limit"
|
||||||
|
with patch.object(self.agent, "artifact_open", side_effect=lambda *args: Response(content)), patch.object(self.agent, "rcon", return_value={"ok": True, "players": 1}):
|
||||||
|
self.assertEqual(self.agent.execute(operation)["state"], "accepted")
|
||||||
|
|
||||||
|
def test_replacement_validation_rejects_incomplete_or_mismatched_prepared_bytes_without_downloads(self):
|
||||||
|
operation, contents = self.artifact_rebuild()
|
||||||
|
spec = operation["payload"]["spec"]
|
||||||
|
del spec["bank"]
|
||||||
|
prepared = {**contents, "configs/empty.cfg": b""}
|
||||||
|
invalid_sets = [
|
||||||
|
{path: content for path, content in prepared.items() if path != "configs/server.cfg"},
|
||||||
|
{**prepared, "configs/extra.cfg": b"unexpected"},
|
||||||
|
{**prepared, "configs/server.cfg": b"wrong artifact"},
|
||||||
|
{**prepared, "configs/empty.cfg": b"wrong inline bytes"},
|
||||||
|
{**prepared, "configs/server.cfg": None},
|
||||||
|
]
|
||||||
|
with patch.object(self.agent, "artifact_open", side_effect=AssertionError("Prepared validation must not download")) as downloads:
|
||||||
|
for index, files in enumerate(invalid_sets):
|
||||||
|
with self.subTest(index=index), self.assertRaises(AgentError):
|
||||||
|
self.agent.validate_spec(spec, prepared_files=files)
|
||||||
|
downloads.assert_not_called()
|
||||||
|
|
||||||
|
def test_rebuild_preflight_preserves_file_path_and_duplicate_checks(self):
|
||||||
|
operation, _ = self.artifact_rebuild()
|
||||||
|
spec = operation["payload"]["spec"]
|
||||||
|
for index, paths in enumerate((["../escape.cfg"], ["configs/electric_cherry_bank.cfg"], ["configs/same.cfg"] * 2)):
|
||||||
|
with self.subTest(paths=paths):
|
||||||
|
operation["id"] = f"invalid-paths-{index}"
|
||||||
|
spec["files"] = [{"path": path, "contentBase64": ""} for path in paths]
|
||||||
|
self.assertEqual(self.agent.execute(operation)["state"], "failed")
|
||||||
|
self.assertTrue(self.agent.inspect(INSTANCE, 1)["running"])
|
||||||
|
self.assertFalse(any(args[:2] in (("container", "stop"), ("container", "rm")) for args in self.docker.calls))
|
||||||
|
|
||||||
|
def test_staged_rebuild_rejects_verified_artifacts_that_differ_from_the_stage(self):
|
||||||
|
operation, contents = self.artifact_rebuild(staged=True)
|
||||||
|
contents["configs/server.cfg"] = b"different verified replacement"
|
||||||
|
operation["payload"]["spec"]["files"][0].update(contentSha256=module.digest(contents["configs/server.cfg"]), sha256=module.digest(contents["configs/server.cfg"]))
|
||||||
|
with patch.object(self.agent, "artifact_open", side_effect=[Response(content) for content in contents.values()]):
|
||||||
|
result = self.agent.execute(operation)
|
||||||
|
self.assertEqual(result["state"], "failed", result)
|
||||||
|
self.assertIn("does not match staged file content", result["error"])
|
||||||
|
self.assertTrue(self.agent.inspect(INSTANCE, 1)["running"])
|
||||||
|
self.assertFalse(any(args[:2] in (("container", "stop"), ("container", "rm")) for args in self.docker.calls))
|
||||||
|
|
||||||
def test_rebuild_moves_generation_and_rejects_old_commands(self):
|
def test_rebuild_moves_generation_and_rejects_old_commands(self):
|
||||||
payload = self.create()
|
payload = self.create()
|
||||||
self.agent.rcon = lambda *_: {"ok": True, "players": 0}
|
self.agent.rcon = lambda *_: {"ok": True, "players": 0}
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import errno
|
||||||
|
import importlib.util
|
||||||
|
from pathlib import Path
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
spec = importlib.util.spec_from_file_location("ec_agent_temperatures", Path(__file__).resolve().parents[1] / "ec_agent.py")
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
|
||||||
|
|
||||||
|
class TemperatureTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.temp = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(self.temp.cleanup)
|
||||||
|
self.root = Path(self.temp.name)
|
||||||
|
self.hwmon = self.root / "class"
|
||||||
|
self.hwmon.mkdir()
|
||||||
|
self.patch = patch.object(module, "HWMON_ROOT", self.hwmon)
|
||||||
|
self.patch.start()
|
||||||
|
self.addCleanup(self.patch.stop)
|
||||||
|
self.agent = object.__new__(module.Agent)
|
||||||
|
|
||||||
|
def chip(self, number, device, readings):
|
||||||
|
physical = self.root / (device + str(number)) / "hwmon" / ("hwmon" + str(number))
|
||||||
|
physical.mkdir(parents=True)
|
||||||
|
(self.hwmon / physical.name).symlink_to(physical, target_is_directory=True)
|
||||||
|
(physical / "name").write_text(device)
|
||||||
|
for channel, label, value in readings:
|
||||||
|
(physical / (channel + "_input")).write_text(str(value))
|
||||||
|
if label is not None:
|
||||||
|
(physical / (channel + "_label")).write_text(label)
|
||||||
|
return physical
|
||||||
|
|
||||||
|
def test_ec2_sensors_and_units_without_guessing_unknown_devices(self):
|
||||||
|
self.chip(0, "nvme", [("temp1", "Composite", 50900), ("temp2", "Sensor 1", 50900)])
|
||||||
|
self.chip(1, "pch_cometlake", [("temp1", None, 62000)])
|
||||||
|
self.chip(2, "coretemp", [("temp1", "Package id 0", 65000), ("temp2", "Core 0", 63000)])
|
||||||
|
self.chip(3, "iwlwifi_1", [("temp1", None, "unavailable")])
|
||||||
|
samples = self.agent.temperatures()
|
||||||
|
self.assertEqual([s["celsius"] for s in samples], [50.9, 50.9, 62.0, 65.0, 63.0])
|
||||||
|
self.assertEqual([s["kind"] for s in samples], ["nvme", "nvme", "chipset", "cpu-package", "cpu-core"])
|
||||||
|
self.assertEqual(len({s["id"] for s in samples}), len(samples))
|
||||||
|
|
||||||
|
def test_fault_disabled_bad_value_and_enodata_do_not_lose_other_sensors(self):
|
||||||
|
chip = self.chip(0, "coretemp", [("temp" + str(i), "Core " + str(i), value)
|
||||||
|
for i, value in enumerate([65000, 60000, "bad", 500000, 61000, 63000, 0], 1)])
|
||||||
|
(chip / "temp1_fault").write_text("1")
|
||||||
|
(chip / "temp2_enable").write_text("0")
|
||||||
|
original = Path.read_text
|
||||||
|
def read(path, *args, **kwargs):
|
||||||
|
if path.name == "temp5_input":
|
||||||
|
raise OSError(errno.ENODATA, "No data available")
|
||||||
|
return original(path, *args, **kwargs)
|
||||||
|
with patch.object(Path, "read_text", read):
|
||||||
|
self.assertEqual([s["celsius"] for s in self.agent.temperatures()], [63.0, 0.0])
|
||||||
|
|
||||||
|
def test_amd_keeps_physical_die_readings_and_excludes_control_offsets(self):
|
||||||
|
self.chip(0, "k10temp", [("temp1", "Tctl", 85000), ("temp2", "Tdie", 65000), ("temp3", "Tccd1", 64000)])
|
||||||
|
self.assertEqual([s["label"] for s in self.agent.temperatures()], ["Tdie", "Tccd1"])
|
||||||
|
|
||||||
|
def test_sensor_identity_survives_hwmon_renumbering_and_separates_drives(self):
|
||||||
|
physical = self.chip(1, "nvme", [("temp1", "Composite", 50000)])
|
||||||
|
self.chip(2, "nvme", [("temp1", "Composite", 51000)])
|
||||||
|
before = {s["celsius"]: s["id"] for s in self.agent.temperatures()}
|
||||||
|
renamed = physical.with_name("hwmon9")
|
||||||
|
physical.rename(renamed)
|
||||||
|
(self.hwmon / "hwmon1").unlink()
|
||||||
|
(self.hwmon / "hwmon9").symlink_to(renamed, target_is_directory=True)
|
||||||
|
self.assertEqual(before, {s["celsius"]: s["id"] for s in self.agent.temperatures()})
|
||||||
|
self.assertEqual(len(set(before.values())), 2)
|
||||||
|
|
||||||
|
def test_missing_hwmon_and_disappearing_devices_are_optional(self):
|
||||||
|
self.assertEqual(self.agent.temperatures(), [])
|
||||||
|
(self.hwmon / "hwmon0").symlink_to(self.root / "missing")
|
||||||
|
self.assertEqual(self.agent.temperatures(), [])
|
||||||
|
with patch.object(Path, "glob", side_effect=PermissionError()):
|
||||||
|
self.assertEqual(self.agent.temperatures(), [])
|
||||||
|
|
||||||
|
def test_direct_hwmon_child_keeps_identity_when_renumbered(self):
|
||||||
|
physical = self.chip(1, "nvme", [("temp1", "Composite", 50000)])
|
||||||
|
direct = physical.parent.parent / "hwmon8"
|
||||||
|
physical.rename(direct)
|
||||||
|
(self.hwmon / "hwmon1").unlink()
|
||||||
|
(self.hwmon / "hwmon8").symlink_to(direct, target_is_directory=True)
|
||||||
|
before = self.agent.temperatures()[0]["id"]
|
||||||
|
renamed = direct.with_name("hwmon9")
|
||||||
|
direct.rename(renamed)
|
||||||
|
(self.hwmon / "hwmon8").unlink()
|
||||||
|
(self.hwmon / "hwmon9").symlink_to(renamed, target_is_directory=True)
|
||||||
|
self.assertEqual(self.agent.temperatures()[0]["id"], before)
|
||||||
|
|
||||||
|
def test_inventory_size_is_bounded(self):
|
||||||
|
self.chip(0, "coretemp", [("temp" + str(i), "Core " + str(i), 65000) for i in range(1, 200)])
|
||||||
|
self.assertEqual(len(self.agent.temperatures()), 128)
|
||||||
@@ -15,8 +15,14 @@ services:
|
|||||||
ORIGIN: ${EC_PUBLIC_URL:-http://localhost:3000}
|
ORIGIN: ${EC_PUBLIC_URL:-http://localhost:3000}
|
||||||
EC_SETUP_TOKEN: ${EC_SETUP_TOKEN:-}
|
EC_SETUP_TOKEN: ${EC_SETUP_TOKEN:-}
|
||||||
EC_TELEMETRY_TOKEN: ${EC_TELEMETRY_TOKEN:-}
|
EC_TELEMETRY_TOKEN: ${EC_TELEMETRY_TOKEN:-}
|
||||||
|
EC_DISCORD_APPLICATION_ID: ${EC_DISCORD_APPLICATION_ID:-}
|
||||||
|
EC_DISCORD_PUBLIC_KEY: ${EC_DISCORD_PUBLIC_KEY:-}
|
||||||
|
EC_DISCORD_BOT_TOKEN: ${EC_DISCORD_BOT_TOKEN:-}
|
||||||
EC_BANK_MAX_BALANCE: ${EC_BANK_MAX_BALANCE:-250000}
|
EC_BANK_MAX_BALANCE: ${EC_BANK_MAX_BALANCE:-250000}
|
||||||
EC_AUTH_ALLOW_HTTP: ${EC_AUTH_ALLOW_HTTP:-false}
|
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.
|
# Legacy host imports stream whole game files; buffering routes enforce their own caps.
|
||||||
BODY_SIZE_LIMIT: 'Infinity'
|
BODY_SIZE_LIMIT: 'Infinity'
|
||||||
DB_PATH: /data/cherry.db
|
DB_PATH: /data/cherry.db
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ and launch your first server.
|
|||||||
- [Native hosts](guides/native-hosts.md) — enrollment, game files, capacity, agent updates, and recovery.
|
- [Native hosts](guides/native-hosts.md) — enrollment, game files, capacity, agent updates, and recovery.
|
||||||
- [Users and access](guides/authentication.md) — roles, sessions, telemetry credentials, and password recovery.
|
- [Users and access](guides/authentication.md) — roles, sessions, telemetry credentials, and password recovery.
|
||||||
- [Fleet settings](guides/settings.md) — concurrency, retention, and cleanup.
|
- [Fleet settings](guides/settings.md) — concurrency, retention, and cleanup.
|
||||||
|
- [Discord companion](guides/discord.md) — live server boards, private browsing, leaderboards, and availability alerts.
|
||||||
|
|
||||||
### Fleet operations
|
### Fleet operations
|
||||||
|
|
||||||
@@ -26,6 +27,8 @@ and launch your first server.
|
|||||||
- [Plugin releases](guides/plugin-releases.md) — GitHub release discovery and imports.
|
- [Plugin releases](guides/plugin-releases.md) — GitHub release discovery and imports.
|
||||||
- [Metrics](guides/metrics.md) — utilization, capacity pressure, and hosting experiments.
|
- [Metrics](guides/metrics.md) — utilization, capacity pressure, and hosting experiments.
|
||||||
- [Player bank](guides/bank.md) — shared balances, per-server credentials, transfers and recovery.
|
- [Player bank](guides/bank.md) — shared balances, per-server credentials, transfers and recovery.
|
||||||
|
- [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.
|
- [Leaderboard and matches](guides/leaderboard.md) — player statistics, match history, and the read API.
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -31,6 +31,13 @@ guessable token. An unset setup token holds setup closed.
|
|||||||
|
|
||||||
## Roles
|
## Roles
|
||||||
|
|
||||||
|
The Moderator role has Viewer-level fleet access plus private player moderation.
|
||||||
|
It permits live player actions, case review and server/game restrictions lasting
|
||||||
|
up to seven days, without granting fleet operations or access to raw configs and
|
||||||
|
credentials. Administrators also control permanent/fleet restrictions, integration
|
||||||
|
configuration and revoking another moderator's restriction. Super Viewers can
|
||||||
|
read moderation; Operators and Viewers cannot. See [Player moderation](moderation.md).
|
||||||
|
|
||||||
| Capability | Viewer | Super Viewer | Operator | Administrator |
|
| Capability | Viewer | Super Viewer | Operator | Administrator |
|
||||||
| --------------------------------------------------------------------------------------------- | -------------- | ------------ | ----------- | ------------- |
|
| --------------------------------------------------------------------------------------------- | -------------- | ------------ | ----------- | ------------- |
|
||||||
| Fleet, metrics, matches and operation summaries | Yes | Yes | Yes | Yes |
|
| Fleet, metrics, matches and operation summaries | Yes | Yes | Yes | Yes |
|
||||||
@@ -76,22 +83,61 @@ passwords created or reset by administrators must be changed before any fleet
|
|||||||
access. Password changes require the current password.
|
access. Password changes require the current password.
|
||||||
|
|
||||||
Sessions use random tokens in HTTP-only cookies, with only token hashes stored in
|
Sessions use random tokens in HTTP-only cookies, with only token hashes stored in
|
||||||
SQLite. HTTPS uses a Secure `__Host-` cookie. Sessions expire after 12 hours, or
|
SQLite. HTTPS uses a Secure `__Host-` cookie. By default, sessions expire after
|
||||||
30 minutes without authenticated requests; background dashboard polling counts
|
12 hours, or 30 minutes without authenticated requests; background dashboard
|
||||||
as activity. Login replaces the existing browser session. Logout revokes it.
|
polling counts as activity.
|
||||||
|
|
||||||
|
Select **Keep me signed in on this device** at sign-in to stay signed in for
|
||||||
|
30 days, including between visits from a phone's home-screen app. This option is
|
||||||
|
unchecked by default and applies only to that browser or home-screen app. Remembered
|
||||||
|
sessions have no inactivity timeout, but still expire 30 days after sign-in;
|
||||||
|
activity does not extend that deadline. Use this option only on a device you trust.
|
||||||
|
Existing sessions keep their original limits after upgrading.
|
||||||
|
|
||||||
|
Password changes always require the current password. Remembered administrator
|
||||||
|
sessions also require the administrator's current password when creating accounts
|
||||||
|
or editing users, including access changes and password resets. A successful
|
||||||
|
password change replaces the current session, preserving its remember-device
|
||||||
|
choice with a fresh lifetime, and revokes all other sessions.
|
||||||
|
|
||||||
|
For API clients, `POST /api/auth/login` accepts an optional boolean `rememberMe`
|
||||||
|
(omitted or `false` keeps the default limits). When using a remembered session,
|
||||||
|
`POST /api/users` and `PATCH /api/users/:id` require the administrator's password
|
||||||
|
in `currentPassword`; the `password` field remains the target user's temporary
|
||||||
|
password. The password-change endpoint preserves the current session's choice.
|
||||||
|
|
||||||
|
Login replaces the existing browser session. Logout revokes it.
|
||||||
Changing a password, changing a role, disabling an account, or revoking that
|
Changing a password, changing a role, disabling an account, or revoking that
|
||||||
user's sessions invalidates their existing sessions. Live event streams check
|
user's sessions invalidates their existing sessions. Live event streams check
|
||||||
revocation every 15 seconds and then close.
|
revocation every 15 seconds and then close.
|
||||||
|
|
||||||
Login, initial setup and password verification are rate-limited with persistent
|
Login and password changes for enabled Administrators and Super Viewers are exempt
|
||||||
15-minute windows. The database stores audit entries for login results, account
|
from per-account and per-address rate limits, including existing exhausted windows.
|
||||||
changes and successful management requests without recording passwords, tokens
|
Their attempts do not consume either limit. Other accounts use persistent
|
||||||
or request bodies. Administrators and Super Viewers can review recent entries in Users.
|
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.
|
||||||
|
|
||||||
The password service runs at most two scrypt operations at once and queues up to
|
A successful password login also sets a separate, HTTP-only login-device cookie
|
||||||
16 more for up to two seconds. If that queue fills or the wait expires, the
|
for 30 days. This cookie lets that browser skip its username-and-address limit,
|
||||||
request returns HTTP 429 with `Retry-After: 2`. The persistent rate limits still
|
while the password and shared address limit still apply. It does not sign you in,
|
||||||
return `Retry-After: 900` when an account or address reaches its attempt limit.
|
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.
|
||||||
|
|
||||||
|
For every role, the password service runs at most two scrypt operations at once
|
||||||
|
and queues up to 16 more for up to two seconds. If that queue fills or the wait
|
||||||
|
expires, the request returns HTTP 429 with `Retry-After: 2`. The persistent rate
|
||||||
|
limits return `Retry-After: 900` when a non-exempt account or address reaches its
|
||||||
|
attempt limit.
|
||||||
|
|
||||||
An administrator can reset another user's password from Users. If the only
|
An administrator can reset another user's password from Users. If the only
|
||||||
administrator forgets their password, an operator with local controller/container
|
administrator forgets their password, an operator with local controller/container
|
||||||
@@ -105,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
|
Replace `admin` with the existing administrator's username. The command prompts
|
||||||
without echoing the password, resets that enabled administrator only, revokes
|
without echoing the password, resets that enabled administrator only, revokes
|
||||||
their sessions and records the recovery. It does not expose a remote recovery
|
their sessions and trusted login devices, clears their account-specific login and
|
||||||
endpoint or accept passwords as command arguments.
|
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
|
## Agent and telemetry connections
|
||||||
|
|
||||||
@@ -148,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
|
incoming origins with your trusted origin. Machine endpoints use their own token
|
||||||
checks and do not require a browser Origin.
|
checks and do not require a browser Origin.
|
||||||
|
|
||||||
Use the adapter's real client-address configuration only with a trusted proxy
|
Login throttling uses the address returned by SvelteKit's adapter. Without
|
||||||
that strips spoofed forwarding headers; login throttling otherwise uses the direct
|
`ADDRESS_HEADER`, this is the direct peer address, so users behind a reverse proxy
|
||||||
peer address. Forwarded host or user headers do not authenticate users.
|
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
|
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
|
hostname (`localhost`, `127.0.0.1` or `::1`). This uses a separate development
|
||||||
|
|||||||
@@ -19,7 +19,9 @@ Choose **Save policy** to apply changes directly. Saving checks the current flee
|
|||||||
|
|
||||||
Plutonium keys belong to a game pool shared by every map, manual launch, collection, beta, and autoscaling policy for that game. An eligible creation uses the lowest available lobby number from 1–20 and reserves any free game key atomically. Key labels do not select a map or lobby. The policy shows saved and available game keys; policy maxima can exceed pool size, and a policy can be saved before any keys are added. An empty or exhausted pool holds new growth with a clear reason; it does not prevent surplus removal. Queued launches reserve their key, stopped instances retain it, and confirmed cleanup returns it to the pool. Failed or uncertain launches keep their reservation until safely reconciled. Existing lobbies and unfinished operations must be healthy and settled before activation. Automatic removal also requires agent **1.7.0 or later** advertising `instance.autoscaling-remove` on each affected host.
|
Plutonium keys belong to a game pool shared by every map, manual launch, collection, beta, and autoscaling policy for that game. An eligible creation uses the lowest available lobby number from 1–20 and reserves any free game key atomically. Key labels do not select a map or lobby. The policy shows saved and available game keys; policy maxima can exceed pool size, and a policy can be saved before any keys are added. An empty or exhausted pool holds new growth with a clear reason; it does not prevent surplus removal. Queued launches reserve their key, stopped instances retain it, and confirmed cleanup returns it to the pool. Failed or uncertain launches keep their reservation until safely reconciled. Existing lobbies and unfinished operations must be healthy and settled before activation. Automatic removal also requires agent **1.7.0 or later** advertising `instance.autoscaling-remove` on each affected host.
|
||||||
|
|
||||||
The desired count is `min(maximum, max(minimum, occupied lobbies + empty spare target))`. The controller evaluates policies every 15 seconds and requests at most one unresolved operation per policy. Game-wide policies allow one outstanding operation across their maps, prioritizing additions before removals. Once an operation settles, the next evaluation can act immediately on another eligible lobby. Surplus lobbies must still satisfy the configured continuous empty duration before removal. Create uses the existing startup concurrency, CPU, memory, library, port, and host admission checks. Lack of host capacity leaves one request waiting; it does not create additional requests every tick. Creates use current promoted launch files when provisioning begins.
|
The desired count is `min(maximum, max(minimum, occupied lobbies + empty spare target))`. Each occupied lobby must reach **round 2 or greater**, confirmed by fresh telemetry for its current match, before it increases the creation target. Brief joins, round-one matches, and unavailable round evidence do not trigger an additional spare. The minimum and baseline spare target can still bootstrap a map without an active match. Existing spares remain protected by the ordinary desired count even when a match restarts or round evidence becomes unavailable.
|
||||||
|
|
||||||
|
The controller evaluates policies every 15 seconds and requests at most one unresolved operation per policy. Game-wide policies apply the round requirement independently to each map and allow one outstanding operation across their maps, prioritizing additions before removals. Once an operation settles, the next evaluation can act immediately on another eligible lobby. Unsubmitted creates are withdrawn if they no longer meet the round or capacity requirements. Surplus lobbies must still satisfy the configured continuous empty duration before removal. Create uses the existing startup concurrency, CPU, memory, library, port, and host admission checks. Lack of host capacity leaves one request waiting; it does not create additional requests every tick. Creates use current promoted launch files when provisioning begins.
|
||||||
|
|
||||||
When trimming a map, autoscaling keeps occupied lobbies and the lowest-numbered
|
When trimming a map, autoscaling keeps occupied lobbies and the lowest-numbered
|
||||||
empty lobbies needed for its minimum and spare targets. It selects the
|
empty lobbies needed for its minimum and spare targets. It selects the
|
||||||
|
|||||||
+76
-12
@@ -9,9 +9,10 @@ IDs. This release does not introduce separate bank-assisted rankings.
|
|||||||
|
|
||||||
## Setup and rollout
|
## Setup and rollout
|
||||||
|
|
||||||
1. Back up the controller database. Update every host agent to **1.9.0 or later**
|
1. Back up the controller database. Update every host agent to **1.9.1 or later**
|
||||||
before enabling the bank. This version settles game journals before removing
|
before installing the public-endpoint bank scripts. This version settles both
|
||||||
runtime storage; older agents do not. Package and offline-sign the agent
|
public and legacy controller-derived journals before removing runtime storage.
|
||||||
|
Package and offline-sign the agent
|
||||||
release using the [host update workflow](native-hosts.md).
|
release using the [host update workflow](native-hosts.md).
|
||||||
2. Deploy Electric Cherry and restart the controller. Migration 20 creates
|
2. Deploy Electric Cherry and restart the controller. Migration 20 creates
|
||||||
accounts, requests and the ledger; migration 21 adds instance credentials and
|
accounts, requests and the ledger; migration 21 adds instance credentials and
|
||||||
@@ -21,13 +22,20 @@ IDs. This release does not introduce separate bank-assisted rankings.
|
|||||||
prepared deposits; withdrawals remain available.
|
prepared deposits; withdrawals remain available.
|
||||||
4. Build/install TWS plugins `t5-plugin`/`t6-plugin` 1.1.0 or later and restart
|
4. Build/install TWS plugins `t5-plugin`/`t6-plugin` 1.1.0 or later and restart
|
||||||
the **whole game-server process**. Install `tws_t5_bank.gsc` or
|
the **whole game-server process**. Install `tws_t5_bank.gsc` or
|
||||||
`tws_t6_bank.gsc` 1.0.0, common 1.0.0+ and telemetry 1.0.0+.
|
`tws_t6_bank.gsc` 2.0.0, common 1.0.0+ and telemetry 1.0.0+.
|
||||||
|
For existing servers changing endpoints, use the rebuild procedure below.
|
||||||
5. Create or rebuild the servers through EC. Launch preparation generates a
|
5. Create or rebuild the servers through EC. Launch preparation generates a
|
||||||
cryptographically random `ec_bank_` credential for each immutable instance,
|
cryptographically random `ec_bank_` credential for each immutable instance,
|
||||||
stores it in the private controller database, and injects it through
|
stores it in the private controller database, and injects it through
|
||||||
`configs/electric_cherry_bank.cfg`. The launch command executes this private
|
`configs/electric_cherry_bank.cfg`. The launch command executes this private
|
||||||
file and sets `tws_server_id`. The host agent writes the private file and derives `tws_bank_url` from its
|
file and sets `tws_server_id`. The host agent writes only `tws_bank_token` in
|
||||||
enrolled controller URL. Use an HTTPS controller URL.
|
that private file; it no longer generates `tws_bank_url`.
|
||||||
|
|
||||||
|
Both game scripts always send bank requests to
|
||||||
|
`https://ec.thewrightserver.net/api/bank`, even if an old `tws_bank_url` remains
|
||||||
|
in configuration. The host agent's enrolled HTTPS controller URL is separate
|
||||||
|
and continues to carry agent traffic, including authenticated bank settlement.
|
||||||
|
It may differ from the public endpoint reachable by game containers.
|
||||||
|
|
||||||
There is **no `EC_BANK_TOKENS` environment map** and no per-server secret setup.
|
There is **no `EC_BANK_TOKENS` environment map** and no per-server secret setup.
|
||||||
The private configuration is sent only in that host's authenticated operation
|
The private configuration is sent only in that host's authenticated operation
|
||||||
@@ -36,6 +44,15 @@ Do not upload `electric_cherry_bank.cfg`: its filename is reserved. Protect the
|
|||||||
controller database, host operation journal and per-instance runtime directory,
|
controller database, host operation journal and per-instance runtime directory,
|
||||||
which contain private credentials.
|
which contain private credentials.
|
||||||
|
|
||||||
|
Drift scans exclude the exact generated configuration path for both games.
|
||||||
|
A maintenance rescan resolves old bank-only incidents and retains other file
|
||||||
|
changes in mixed incidents. Private-file campaign plans saved before this fix
|
||||||
|
are rejected at activation; cancel them and rescan rather than retrying the old
|
||||||
|
plan. Upgrade host agents to **1.9.3 or later** before resuming drift recovery
|
||||||
|
so stages already saved on a host also reject private-file reads and changes.
|
||||||
|
Legacy private bank-file campaign diffs and their blob downloads are blocked;
|
||||||
|
this does not erase previously captured bytes from databases or backups.
|
||||||
|
|
||||||
Retries, rebuilds and host moves of the same instance reuse its credential.
|
Retries, rebuilds and host moves of the same instance reuse its credential.
|
||||||
Retiring the instance immediately revokes authentication. Creating another
|
Retiring the instance immediately revokes authentication. Creating another
|
||||||
server with the same display name produces a new instance ID and secret; it
|
server with the same display name produces a new instance ID and secret; it
|
||||||
@@ -49,6 +66,28 @@ transfers while the old controller/configuration still runs, then deploy and
|
|||||||
rebuild. Remove the obsolete environment map and any manually supplied token.
|
rebuild. Remove the obsolete environment map and any manually supplied token.
|
||||||
Do not replace identities or discard pending journals to force an upgrade.
|
Do not replace identities or discard pending journals to force an upgrade.
|
||||||
|
|
||||||
|
### Upgrading from a controller-derived game endpoint
|
||||||
|
|
||||||
|
Update agents to `1.9.1+` first. If an existing server's `tws_bank_url` differs
|
||||||
|
from `https://ec.thewrightserver.net/api/bank`, rebuild that server through EC
|
||||||
|
with bank scripts `2.0.0`. The agent stops the old game, settles its pending
|
||||||
|
commit/cancel decisions with their original request IDs, then starts the
|
||||||
|
replacement and removes old storage. The server identity and instance credential
|
||||||
|
are preserved. Lost acknowledgments can replay the same decisions; EC's retained
|
||||||
|
request IDs prevent duplicate ledger changes. An outage or invalid journal
|
||||||
|
retains storage and blocks replacement until recovery succeeds.
|
||||||
|
|
||||||
|
Do not deploy the new scripts with only a map restart when the endpoint changes:
|
||||||
|
native journals are keyed by server and endpoint, and the new scripts cannot load
|
||||||
|
the old scope. Do not rename, rewrite or delete pending journals. Keep the
|
||||||
|
agent's enrolled controller URL unchanged until legacy journals drain; the
|
||||||
|
agent accepts only the fixed public endpoint and the legacy `/api/bank` URL
|
||||||
|
derived from that enrolled controller. Other legacy URLs require recovery with
|
||||||
|
their original configuration before switching. If the old URL already matches
|
||||||
|
the exact public endpoint, existing journals retain their identity and a map
|
||||||
|
restart is sufficient for the script upgrade. No new native plugin build is
|
||||||
|
required beyond `1.1.0`.
|
||||||
|
|
||||||
## Machine API
|
## Machine API
|
||||||
|
|
||||||
All routes below are `POST /api/bank/<action>`, with `Content-Type:
|
All routes below are `POST /api/bank/<action>`, with `Content-Type:
|
||||||
@@ -136,14 +175,15 @@ original journal to resolve holds; never delete a pending journal to unblock
|
|||||||
the bank. If storage is permanently lost, reconcile against the retained
|
the bank. If storage is permanently lost, reconcile against the retained
|
||||||
request and ledger records before any manual repair.
|
request and ledger records before any manual repair.
|
||||||
|
|
||||||
During removal, rebuild, host migration or launch cancellation, agent 1.9.0+
|
During removal, rebuild, host migration or launch cancellation, agent 1.9.1+
|
||||||
stops the game and drains its journals **before** deleting runtime files or
|
stops the game and drains its journals **before** deleting runtime files or
|
||||||
starting a replacement. It uses its host credential at
|
starting a replacement. It uses its host credential at
|
||||||
`POST /api/agent/bank/settle`; EC checks the assigned host, immutable instance,
|
`POST /api/agent/bank/settle`; EC checks the assigned host, immutable instance,
|
||||||
unfinished teardown operation, server and original request identity. This
|
unfinished teardown operation, server and original request identity. This
|
||||||
endpoint permits only commit/cancel, never new deposits or withdrawals. Journal
|
endpoint permits only commit/cancel, never new deposits or withdrawals. Journal
|
||||||
URLs must match that host's configured controller; the agent never follows a
|
URLs must match the public game-bank endpoint or that host's legacy
|
||||||
journal URL. Corruption, a mismatched controller, failed acknowledgment or an
|
controller-derived bank endpoint; the agent never follows a journal URL.
|
||||||
|
Corruption, an unrecognized endpoint, failed acknowledgment or an
|
||||||
outage retains storage and leaves the operation unresolved for retry. An
|
outage retains storage and leaves the operation unresolved for retry. An
|
||||||
interrupted rebuild uses the existing rebuild recovery workflow to retry with
|
interrupted rebuild uses the existing rebuild recovery workflow to retry with
|
||||||
a fresh operation after settlement. Do not manually erase its old directory.
|
a fresh operation after settlement. Do not manually erase its old directory.
|
||||||
@@ -155,14 +195,33 @@ in the actual Windows/Plutonium deployment before rollout.
|
|||||||
|
|
||||||
## History and validation
|
## History and validation
|
||||||
|
|
||||||
Player profiles show the shared balance, pending status and 20 newest ledger
|
The **Players → Bank** page shows fleet-wide balances, funded accounts, completed
|
||||||
entries, including signed amounts, resulting balances, server, time and match.
|
transfers in the last 24 hours and pending transfers. Accounts can be searched by
|
||||||
|
player name or GUID, sorted by balance or recent completed activity, and filtered
|
||||||
|
to funded accounts, accounts at or above the configured limit, or pending transfers.
|
||||||
|
Balances always remain shared across both games.
|
||||||
|
|
||||||
|
Transactions can be filtered by player, game, server, direction and inclusive UTC
|
||||||
|
dates. Pending transfers show oldest first, with an attention indicator after five
|
||||||
|
minutes and details explaining the reservation. This indicator does not expire or
|
||||||
|
cancel a transfer. Both views retain historical server identities and match IDs
|
||||||
|
after retention; unavailable matches have no live link.
|
||||||
|
|
||||||
|
Selecting a player opens a drawer with their balance, available points, pending
|
||||||
|
transfer and paginated history. Filters and pagination are retained in the URL,
|
||||||
|
including when following a match or player-profile link. The page refreshes every
|
||||||
|
30 seconds while visible. Summary counts always cover the whole fleet. Bank is
|
||||||
|
read-only for all browser roles; transfers continue to originate in game.
|
||||||
|
|
||||||
|
Player profiles show the shared balance, pending status and three newest ledger
|
||||||
|
entries, with a **View bank history** link to the drawer. Entries include signed
|
||||||
|
amounts, resulting balances, server, time and match.
|
||||||
Accounts, request IDs and ledger entries have no cascading match/server foreign
|
Accounts, request IDs and ledger entries have no cascading match/server foreign
|
||||||
keys and are not deleted by match retention or server deletion. Retained IDs
|
keys and are not deleted by match retention or server deletion. Retained IDs
|
||||||
also keep old retries idempotent. Expired match links become plain historical
|
also keep old retries idempotent. Expired match links become plain historical
|
||||||
IDs. Include these tables in normal database backups.
|
IDs. Include these tables in normal database backups.
|
||||||
|
|
||||||
Run `npx vitest run tests/unit/bank.test.ts`, then the usual lint, type checks,
|
Run `npx vitest run tests/unit/bank.test.ts tests/unit/bank-overview.test.ts tests/client/bank-page.test.ts`, then the usual lint, type checks,
|
||||||
tests and build. TWS supplies `node tools/check_bank.mjs` and
|
tests and build. TWS supplies `node tools/check_bank.mjs` and
|
||||||
`python3 tools/test_plugin_bank.py`. Live validation must cover both games:
|
`python3 tools/test_plugin_bank.py`. Live validation must cover both games:
|
||||||
deposit/withdraw amounts and `all`, insufficient funds and caps, joining before
|
deposit/withdraw amounts and `all`, insufficient funds and caps, joining before
|
||||||
@@ -170,3 +229,8 @@ telemetry, `.help`, score HUD updates, death/respawn/disconnect during requests,
|
|||||||
lost prepare/commit replies, controller outage, disk failures and map/process
|
lost prepare/commit replies, controller outage, disk failures and map/process
|
||||||
restart with pending work. Confirm earned score and leaderboard behavior, and
|
restart with pending work. Confirm earned score and leaderboard behavior, and
|
||||||
verify that another server's credential and the telemetry credential fail.
|
verify that another server's credential and the telemetry credential fail.
|
||||||
|
For the endpoint upgrade, also test a different enrolled controller address and
|
||||||
|
a stale `tws_bank_url`, `.b` with an empty balance, `.w all` with no funds, and
|
||||||
|
rebuild/removal with both legacy and public journals. Drop a settlement reply,
|
||||||
|
restart the agent and verify the original IDs settle without duplicate ledger
|
||||||
|
changes before the replacement starts or runtime storage is removed.
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ file applies to all participants. Every file needs at least one matching test
|
|||||||
participant. For example, a T5 script and a T6 script can share one beta without
|
participant. For example, a T5 script and a T6 script can share one beta without
|
||||||
either script being installed on the other game's servers.
|
either script being installed on the other game's servers.
|
||||||
|
|
||||||
|
Choose **Add all eligible files** to add the latest unpromoted version of every
|
||||||
|
remaining eligible file at once. Files and exact versions you already selected
|
||||||
|
stay unchanged.
|
||||||
|
|
||||||
Setup defaults to automatic selection. Enter one **Instances per game** number,
|
Setup defaults to automatic selection. Enter one **Instances per game** number,
|
||||||
which defaults to 1 and applies to every game represented by the selected files
|
which defaults to 1 and applies to every game represented by the selected files
|
||||||
and their matching servers. Every automatically selected instance must pass
|
and their matching servers. Every automatically selected instance must pass
|
||||||
@@ -29,8 +33,8 @@ servers. Servers held by another active beta or with pending deployment work
|
|||||||
are excluded, as are instances on unavailable, draining, or disabled hosts.
|
are excluded, as are instances on unavailable, draining, or disabled hosts.
|
||||||
Unknown or stale player counts are not treated as empty. If the
|
Unknown or stale player counts are not treated as empty. If the
|
||||||
requested counts cannot be met, including coverage of every selected map-scoped
|
requested counts cannot be met, including coverage of every selected map-scoped
|
||||||
file, the beta does not start. Deployment and restarts still wait for confirmed
|
file, the beta does not start. File deployment rebuilds servers after waiting
|
||||||
empty lobbies and restart capacity.
|
for confirmed empty lobbies and rebuild capacity.
|
||||||
|
|
||||||
Open **Advanced options → Choose specific servers** for manual selection. The
|
Open **Advanced options → Choose specific servers** for manual selection. The
|
||||||
picker includes servers matching any file in the bundle; each file must have a
|
picker includes servers matching any file in the bundle; each file must have a
|
||||||
@@ -349,7 +353,7 @@ Health monitoring continues after a pass until promotion.
|
|||||||
|
|
||||||
For failures, select hold as failed, pause for investigation, or automatic
|
For failures, select hold as failed, pause for investigation, or automatic
|
||||||
rollback. Rollback queues restoration only for files touched by the beta.
|
rollback. Rollback queues restoration only for files touched by the beta.
|
||||||
Restoration and restart both wait for an empty server and restart capacity.
|
Restoration rebuilds the server after waiting for an empty lobby and rebuild capacity.
|
||||||
The manual option holds those changes in a rollback campaign until you select
|
The manual option holds those changes in a rollback campaign until you select
|
||||||
**Resume when empty**. Promotion is always manual.
|
**Resume when empty**. Promotion is always manual.
|
||||||
|
|
||||||
@@ -361,9 +365,9 @@ Review the promotion preview before confirming. API clients fetch it with
|
|||||||
409 and requires a fresh preview.
|
409 and requires a fresh preview.
|
||||||
|
|
||||||
Confirming promotion creates a campaign without reading the existing files on
|
Confirming promotion creates a campaign without reading the existing files on
|
||||||
additional servers. Each server waits for an empty lobby and restart capacity,
|
additional servers. Each server waits for an empty lobby and rebuild capacity,
|
||||||
then captures rollback snapshots immediately before applying its queued files
|
then captures rollback snapshots before rebuilding with its queued files.
|
||||||
and restarting. Campaign history records snapshot capture, and file comparisons
|
Campaign history records snapshot capture, and file comparisons
|
||||||
become available once those snapshots are confirmed. Tested servers retain their
|
become available once those snapshots are confirmed. Tested servers retain their
|
||||||
original pre-beta snapshots; their composed configuration is still checked at
|
original pre-beta snapshots; their composed configuration is still checked at
|
||||||
promotion to determine whether another activation is needed.
|
promotion to determine whether another activation is needed.
|
||||||
@@ -393,9 +397,9 @@ binary and large files show their checksums. Files must actually change.
|
|||||||
|
|
||||||
The beta keeps its ID, servers, scope and test policy. Each revision creates new
|
The beta keeps its ID, servers, scope and test policy. Each revision creates new
|
||||||
file versions, clears current test progress and checklist sign-offs, repeats the
|
file versions, clears current test progress and checklist sign-offs, repeats the
|
||||||
configured waves, and starts a fresh testing window at submission. Files install
|
configured waves, and starts a fresh testing window at submission. Servers rebuild
|
||||||
and servers restart through the normal empty-server and capacity gates. Promotion
|
with the requested files through the normal empty-server and capacity gates.
|
||||||
requires the new revision to pass. Pause and retry retain the current revision's
|
Promotion requires the new revision to pass. Pause and retry retain the current revision's
|
||||||
deadline; they do not create a revision.
|
deadline; they do not create a revision.
|
||||||
|
|
||||||
Revision history retains downloadable bundle files and previous test results,
|
Revision history retains downloadable bundle files and previous test results,
|
||||||
@@ -407,7 +411,7 @@ new revision is waiting to deploy. Files originally absent are removed.
|
|||||||
Updates are available until promotion or rollback, including paused, failed and
|
Updates are available until promotion or rollback, including paused, failed and
|
||||||
expired tests. An activation already in progress must settle first; interrupted
|
expired tests. An activation already in progress must settle first; interrupted
|
||||||
file writes and deleted participants must be resolved before an update. Pending,
|
file writes and deleted participants must be resolved before an update. Pending,
|
||||||
unsubmitted restarts are superseded. Adding/removing files, changing targets or
|
unsubmitted activations are superseded. Adding/removing files, changing targets or
|
||||||
changing the policy still requires a new beta.
|
changing the policy still requires a new beta.
|
||||||
|
|
||||||
## API and storage
|
## API and storage
|
||||||
|
|||||||
@@ -304,6 +304,27 @@ before continuing eligible interrupted startups. Unknown command results retain
|
|||||||
their original command ID and occupied slots until the host confirms the outcome.
|
their original command ID and occupied slots until the host confirms the outcome.
|
||||||
Missing server registrations and later destruction attempts require inspection.
|
Missing server registrations and later destruction attempts require inspection.
|
||||||
|
|
||||||
|
### Recovering a completed rebuild generation
|
||||||
|
|
||||||
|
A completed rebuild records the replacement generation before EC verifies health.
|
||||||
|
If fresh host inventory disagrees, verification pauses with the controller and
|
||||||
|
host generations in the operation's recovery message.
|
||||||
|
|
||||||
|
For an existing mismatch, open the **completed rebuild's Provisioning detail**
|
||||||
|
(even if that operation was later cancelled), then choose **Reconcile replacement
|
||||||
|
generation**. EC requires a completed command for the original host and immutable
|
||||||
|
instance ID, the command's requested replacement generation, and matching host
|
||||||
|
inventory observed within the last 30 seconds. Wait for that host to reconnect
|
||||||
|
and report fresh inventory if the action is unavailable. A server name alone is
|
||||||
|
not sufficient evidence.
|
||||||
|
|
||||||
|
Reconciliation preserves cancellation and submitted commands. Use **Resume health
|
||||||
|
verification** when offered, or request a new lifecycle operation after resolving
|
||||||
|
any remaining active operation. Fresh health checks, log reads, and new lifecycle
|
||||||
|
commands use the replacement generation. EC requires fresh container and game
|
||||||
|
evidence before reporting the server online. The agent continues to reject stale
|
||||||
|
commands; an admin player-safety override does not bypass generation checks.
|
||||||
|
|
||||||
## API
|
## API
|
||||||
|
|
||||||
Collections retain the existing `/api/blueprints` endpoints and `blueprintId` /
|
Collections retain the existing `/api/blueprints` endpoints and `blueprintId` /
|
||||||
@@ -351,6 +372,9 @@ there while preserving query parameters.
|
|||||||
- `GET /api/deployments/:id`: includes `recipe`, `blueprintName`, and
|
- `GET /api/deployments/:id`: includes `recipe`, `blueprintName`, and
|
||||||
`canResumeVerification` and `canRetryWithRebuild` for lifecycle operations.
|
`canResumeVerification` and `canRetryWithRebuild` for lifecycle operations.
|
||||||
- `POST /api/deployments/:id/resume`: resume eligible creation health checks.
|
- `POST /api/deployments/:id/resume`: resume eligible creation health checks.
|
||||||
|
- `POST /api/provisioning/:id/reconcile-generation`: reconcile a completed rebuild
|
||||||
|
using fresh inventory from its original host. Returns 409 when identity or
|
||||||
|
completion evidence cannot be validated; repeated application is safe.
|
||||||
- `POST /api/provisioning/:id/retry-rebuild`: request a new rebuild for an eligible
|
- `POST /api/provisioning/:id/retry-rebuild`: request a new rebuild for an eligible
|
||||||
failed creation and link it from the original operation.
|
failed creation and link it from the original operation.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
# Discord companion
|
||||||
|
|
||||||
|
Electric Cherry can publish live Black Ops I and Black Ops II server boards in
|
||||||
|
Discord, answer commands privately, and send requested map availability alerts by
|
||||||
|
DM. Configure it under **Admin → Discord**. Administrators can make changes;
|
||||||
|
Super Viewers can inspect configuration, the saved preview, and delivery health.
|
||||||
|
The integration is disabled by default. Once enabled, it publishes all current
|
||||||
|
and newly created game servers unless an administrator excludes them.
|
||||||
|
|
||||||
|
## Install the application
|
||||||
|
|
||||||
|
1. Create an application in the [Discord Developer Portal](https://discord.com/developers/applications).
|
||||||
|
Copy its Application ID and Public Key, and create a bot token.
|
||||||
|
2. Set the controller environment variables below. Keep the token private; the
|
||||||
|
settings page reports whether it is configured without displaying its value.
|
||||||
|
With Docker Compose, put the values in the deployment's `.env` file and recreate
|
||||||
|
the controller to apply them. Do not commit that file.
|
||||||
|
|
||||||
|
```dotenv
|
||||||
|
EC_DISCORD_APPLICATION_ID=
|
||||||
|
EC_DISCORD_PUBLIC_KEY=
|
||||||
|
EC_DISCORD_BOT_TOKEN=
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Install the application into your Discord server using the `bot` and
|
||||||
|
`applications.commands` scopes. Grant **View Channel**, **Send Messages**, and
|
||||||
|
**Read Message History** in each board channel. No Administrator permission or
|
||||||
|
privileged Gateway intents are needed. Channel overrides must also permit the
|
||||||
|
bot to read and write those channels.
|
||||||
|
4. In the Developer Portal, set **Interactions Endpoint URL** to
|
||||||
|
`https://your-controller.example/api/discord/interactions`. Use your actual
|
||||||
|
public controller origin with a trusted HTTPS certificate. Discord verifies
|
||||||
|
the endpoint before accepting it. The controller checks Discord's request
|
||||||
|
signatures; this endpoint does not use the Electric Cherry login session.
|
||||||
|
5. In Discord, enable Developer Mode, then copy the Discord server ID and the
|
||||||
|
board channel IDs. Open **Admin → Discord**, enter those IDs, and uncheck any
|
||||||
|
game servers you want to exclude. Either game may omit its board channel.
|
||||||
|
6. Review the information-sharing switches, enable the companion, and save.
|
||||||
|
Select **Register commands** to install the commands in the saved Discord
|
||||||
|
server. Saving configuration and registering commands are separate actions.
|
||||||
|
|
||||||
|
The deployment's reverse proxy must route the interaction endpoint to the
|
||||||
|
controller and preserve Discord's signature headers and request body. No Gateway
|
||||||
|
connection is required. Installation scopes and channel permissions follow
|
||||||
|
Discord's [OAuth2 documentation](https://docs.discord.com/developers/topics/oauth2)
|
||||||
|
and [permissions reference](https://docs.discord.com/developers/topics/permissions).
|
||||||
|
See [HTTP interactions](https://docs.discord.com/developers/interactions/receiving-and-responding)
|
||||||
|
for Discord's endpoint verification and response requirements.
|
||||||
|
|
||||||
|
Use a dedicated Discord application for this controller. The command registration
|
||||||
|
action updates that application's command set in the configured Discord server.
|
||||||
|
|
||||||
|
## Choose what is published
|
||||||
|
|
||||||
|
All current and newly created game servers are included by default. Uncheck a
|
||||||
|
server to exclude it from boards, private server browsing, server details, and
|
||||||
|
availability watches. Exclusions are retained if a game server is removed, so a
|
||||||
|
server with the same name remains excluded if it returns. Check an excluded
|
||||||
|
server again to remove that exclusion.
|
||||||
|
|
||||||
|
Player names in server details and public connection addresses are separate,
|
||||||
|
disabled-by-default options. Connection instructions use an explicitly configured
|
||||||
|
native host public endpoint; internal host addresses and RCON credentials are not
|
||||||
|
published. Capacity, player counts, map, round, and observation state can appear
|
||||||
|
without either switch enabled.
|
||||||
|
|
||||||
|
**Publish fleet leaderboards** is a separate decision. It exposes recorded player
|
||||||
|
names and statistics from the whole fleet, including historical matches on
|
||||||
|
excluded servers. Leave it disabled if those
|
||||||
|
statistics should remain available only to Electric Cherry users.
|
||||||
|
|
||||||
|
The settings page's preview uses the **saved settings**. Pending form edits do
|
||||||
|
not change the preview or Discord. The preview remains available while the
|
||||||
|
integration is disabled.
|
||||||
|
|
||||||
|
## Boards and browsing
|
||||||
|
|
||||||
|
Each configured game channel receives a persistent server board, split into
|
||||||
|
additional messages when needed. The controller revisits delivery about every
|
||||||
|
30 seconds and edits changed content. Stored message IDs survive controller
|
||||||
|
restarts. Periodic existence checks recreate messages that were removed.
|
||||||
|
|
||||||
|
Boards show observed occupancy, capacity and current round where available,
|
||||||
|
with separate empty, in-progress, full, starting, offline, not-responding, and
|
||||||
|
unknown states. Freshness is part of the observation. **Empty slots do not prove
|
||||||
|
that a match accepts new players**; this release does not label games joinable.
|
||||||
|
Capacity defaults to four players, matching the fleet UI, until a fresh reported
|
||||||
|
capacity overrides it. A default capacity does not make stale player counts
|
||||||
|
current or allow a stale observation to trigger an availability alert.
|
||||||
|
|
||||||
|
Members can use the board controls or slash commands. Browsing, filters, details,
|
||||||
|
leaderboards, and command confirmations are visible only to the member who
|
||||||
|
requested them. They do not rearrange the shared board.
|
||||||
|
|
||||||
|
| Command | Purpose |
|
||||||
|
| ------------------------------- | ------------------------------------------------------------------------------------------ |
|
||||||
|
| `/servers` | Browse published servers; filter by game, map and state, and page through results. |
|
||||||
|
| `/server name` | Inspect one published server, including optional roster names and connection instructions. |
|
||||||
|
| `/leaderboard` | Browse recorded fleet rankings by game, map, metric, squad size and page, when enabled. |
|
||||||
|
| `/watch add game map condition` | Request a one-shot DM when a map has an empty lobby or observed space. |
|
||||||
|
| `/watch list` | See your active watches and their IDs. |
|
||||||
|
| `/watch cancel id` | Cancel one of your own watches. |
|
||||||
|
| `/help` | Explain the commands and their limits. |
|
||||||
|
|
||||||
|
Commands are limited to the configured Discord server. Map options preserve the
|
||||||
|
game's map variants. Leaderboard results reflect recorded gameplay, not a new
|
||||||
|
claim that a match was won or that a player survived continuously.
|
||||||
|
|
||||||
|
## Availability alerts
|
||||||
|
|
||||||
|
A watch lasts up to 24 hours and sends one DM when its condition is satisfied.
|
||||||
|
Each member may have up to ten active watches. Requesting the same map and
|
||||||
|
condition again reuses the existing watch. Members can cancel only their own
|
||||||
|
watches.
|
||||||
|
|
||||||
|
An **empty** watch needs an observed empty lobby. A **space** watch needs fewer
|
||||||
|
players than the current capacity, using the four-player default when no fresh
|
||||||
|
capacity override is available. Neither condition promises that
|
||||||
|
joining is allowed. Unknown, stale, and offline observations never satisfy a
|
||||||
|
watch. The worker requires two qualifying observations at least 15 seconds apart
|
||||||
|
before queueing a notification.
|
||||||
|
|
||||||
|
Watches and pending notifications persist across controller restarts. Delivery
|
||||||
|
handles transient failures and Discord rate limits. A permanent DM failure, such
|
||||||
|
as a member blocking messages from the bot, ends that watch. Members should allow
|
||||||
|
DMs from the bot before creating a watch and can inspect active watches with
|
||||||
|
`/watch list`.
|
||||||
|
|
||||||
|
## Delivery and troubleshooting
|
||||||
|
|
||||||
|
The settings page shows the last attempt, last success, current error, active
|
||||||
|
watches, pending notifications, and links to recorded board channels. Refreshing
|
||||||
|
that page preserves pending edits. A failure keeps the last loaded information
|
||||||
|
visible.
|
||||||
|
|
||||||
|
- **Credentials need attention:** check all three environment variables and
|
||||||
|
restart the controller after changing them. A readiness indicator checks local
|
||||||
|
configuration; it does not establish that Discord has accepted the token.
|
||||||
|
- **Discord rejects the interaction endpoint:** verify HTTPS routing, the
|
||||||
|
application's Public Key, and that the reverse proxy preserves signed
|
||||||
|
requests. Discord must reach the endpoint without an interactive login.
|
||||||
|
- **Commands are missing:** save the correct Discord server ID, install the bot
|
||||||
|
with `applications.commands`, then select **Register commands**. Register again
|
||||||
|
after upgrading to a release that changes the command schema.
|
||||||
|
- **Board delivery is denied:** check the bot's permissions and channel overrides.
|
||||||
|
Authorization failures pause retries. After correcting access, select **Save
|
||||||
|
and retry** in Electric Cherry.
|
||||||
|
- **A board was deleted:** the controller's periodic check recreates it. Keep
|
||||||
|
Read Message History enabled so those checks can verify existing messages.
|
||||||
|
- **Discord rate limits or temporary outages:** delivery honors retry delays and
|
||||||
|
retries transient failures with increasing delays. Fleet operations continue
|
||||||
|
independently.
|
||||||
|
- **Old board messages remain after a channel change or disable:** the controller
|
||||||
|
attempts to remove old messages. If permissions were removed first, an
|
||||||
|
administrator may need to delete those messages in Discord.
|
||||||
|
|
||||||
|
Disabling the companion stops new publication and notifications. Cleanup may
|
||||||
|
still remove previously recorded board messages. Data already seen or copied by
|
||||||
|
members cannot be recalled by changing publication settings.
|
||||||
|
|
||||||
|
This release does not link Discord accounts to game identities, expose personal
|
||||||
|
bank or AFK accounts, mutate servers or moderation state, or announce
|
||||||
|
achievements. Those workflows require separate authorization and implementation.
|
||||||
@@ -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
|
precompresses static JavaScript and CSS assets; dynamically generated responses
|
||||||
need compression at the proxy.
|
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
|
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
|
remove `EC_SETUP_TOKEN` from `.env` and run `docker compose up -d` to recreate the
|
||||||
controller. Setup stays closed once an account exists.
|
controller. Setup stays closed once an account exists.
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ Campaigns retain managed-file deployment evidence; Collections define lineups.
|
|||||||
when the running revision is unverified.
|
when the running revision is unverified.
|
||||||
- **Policies & schedules** controls health recovery, configuration drift, automatic
|
- **Policies & schedules** controls health recovery, configuration drift, automatic
|
||||||
Plutonium updates, and the optional daily restart schedule independently.
|
Plutonium updates, and the optional daily restart schedule independently.
|
||||||
- **Activity** uses the shared Activity log, scoped to maintenance and recovery.
|
- **Activity** shows retained history scoped to maintenance and recovery.
|
||||||
Filter retained findings, update results, and scheduled restarts in one timeline.
|
Filter retained findings, update results, and scheduled restarts in one timeline.
|
||||||
Follow a finding for evidence or an operation for execution progress. Use
|
Follow a finding for evidence or an operation for execution progress. Use
|
||||||
Overview’s **Active work & attention** to cancel waiting work or retry failed
|
Overview’s **Active work & attention** to cancel waiting work or retry failed
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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,
|
tail, the controller requests only appended bytes, handles rotation and truncation,
|
||||||
and buffers incomplete lines. Unchanged logs need no content transfer or chat
|
and buffers incomplete lines. Unchanged logs need no content transfer or chat
|
||||||
parsing. Background collection skips offline hosts, stopped servers and known
|
parsing. Background collection skips offline hosts, stopped servers and known
|
||||||
empty servers, with one final read after players leave. Opening chat still reads
|
empty servers, with one final read after players leave. Opening chat reuses the
|
||||||
on demand. Older agents use tail reads until they update.
|
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
|
### New game files and cached versions
|
||||||
|
|
||||||
@@ -354,6 +357,40 @@ journalctl -u electric-cherry-update.service -n 100
|
|||||||
|
|
||||||
## Placement and resource capacity
|
## Placement and resource capacity
|
||||||
|
|
||||||
|
### Host temperature
|
||||||
|
|
||||||
|
Agent **1.10.0** reports temperature sensors exposed by supported Linux hwmon
|
||||||
|
drivers without installing additional packages. **Hosts** shows CPU temperature
|
||||||
|
beneath CPU usage. A host's **Overview → Inventory** shows the selected reading
|
||||||
|
and expandable **Temperature sensors**, including per-core and storage details.
|
||||||
|
Stale readings are marked as last reported; absent or failed readings are
|
||||||
|
**Unavailable**, never zero or a readiness failure.
|
||||||
|
|
||||||
|
**Fleet → Capacity → Temperature history** uses the existing host selector and
|
||||||
|
Last hour / 24 hours / 7 days controls. **All hosts** displays a separate CPU line
|
||||||
|
for each host. Select one host to add its NVMe composite and chipset lines using
|
||||||
|
the legend. Hover, tap, or use the arrow keys on the chart to inspect a period.
|
||||||
|
Each host page's **Capacity** button opens the view filtered to that host.
|
||||||
|
The game filter affects launch estimates, not temperatures.
|
||||||
|
|
||||||
|
The CPU reading is the hottest reported package, falling back to a physical
|
||||||
|
AMD die/CCD and then a core reading. AMD Tctl cooling-control values are excluded
|
||||||
|
because they may include an offset. Supported device drivers are `coretemp`,
|
||||||
|
`k10temp`, `nvme` and `pch_*`; sensor availability depends on hardware and drivers.
|
||||||
|
Virtual machines and older agents may have no supported readings. Missing,
|
||||||
|
faulty and disabled sensors are skipped individually.
|
||||||
|
|
||||||
|
Temperature history starts after the agent upgrade. The controller stores one
|
||||||
|
peak per sensor per minute, limited to the CPU summary and up to sixteen NVMe
|
||||||
|
composite/chipset inputs per host. Longer chart periods show the maximum of
|
||||||
|
those peaks; a period without readings remains a gap. Brief gaps within an
|
||||||
|
aggregated period may still leave a peak for that period. **Host resource history
|
||||||
|
retention** applies to CPU, memory and temperature samples (seven days by default;
|
||||||
|
zero keeps them indefinitely). Series colors identify hosts/sensors and do not
|
||||||
|
indicate thermal safety. Temperature does not change placement or trigger alerts.
|
||||||
|
|
||||||
|
### CPU and memory capacity
|
||||||
|
|
||||||
### Host inventory and eligibility
|
### Host inventory and eligibility
|
||||||
|
|
||||||
The Inventory panel shows CPU usage with agent 1.4.2 and later. It measures
|
The Inventory panel shows CPU usage with agent 1.4.2 and later. It measures
|
||||||
@@ -525,6 +562,10 @@ local credentials already exist.
|
|||||||
|
|
||||||
## Host and game-file API
|
## Host and game-file API
|
||||||
|
|
||||||
|
Open **Fleet → Hosts → Operation history** for retained operations across all
|
||||||
|
hosts, including deleted hosts. A host's **Operation history** action scopes the
|
||||||
|
same history to that host. Filters and pagination retain the selected scope.
|
||||||
|
|
||||||
- `GET /api/hosts/:id`: host details and deletion blockers.
|
- `GET /api/hosts/:id`: host details and deletion blockers.
|
||||||
- `GET /api/hosts/operations?page=1&filter=important`: operation history across
|
- `GET /api/hosts/operations?page=1&filter=important`: operation history across
|
||||||
hosts, newest first, with 25 summaries per page and filtered total/page counts.
|
hosts, newest first, with 25 summaries per page and filtered total/page counts.
|
||||||
@@ -616,6 +657,13 @@ restart and create complete only after post-operation health evidence; creation
|
|||||||
and rebuild also verify the selected map and running Plutonium revision. The
|
and rebuild also verify the selected map and running Plutonium revision. The
|
||||||
base-game library and runtime revision are independent records.
|
base-game library and runtime revision are independent records.
|
||||||
|
|
||||||
|
Agent 1.10.1 prepares and verifies the complete replacement file set before
|
||||||
|
stopping the existing container, within the 64 MiB operation limit. Staged-file
|
||||||
|
comparison, replacement validation and writing reuse those bytes, so losing
|
||||||
|
artifact connectivity after preflight does not prevent replacement. A preflight
|
||||||
|
download failure leaves the original container running. Prepared bytes last only
|
||||||
|
for that operation attempt; held or failed work must prepare its files again.
|
||||||
|
|
||||||
## Rebalance instances
|
## Rebalance instances
|
||||||
|
|
||||||
Open **Fleet → Capacity → Rebalance** to spread existing instances across active
|
Open **Fleet → Capacity → Rebalance** to spread existing instances across active
|
||||||
@@ -1038,9 +1086,37 @@ game hosts. Run:
|
|||||||
|
|
||||||
### Bank journals during server teardown
|
### Bank journals during server teardown
|
||||||
|
|
||||||
Agent 1.9.0 adds `bank.settlement`: removal, rebuild, migration and creation
|
Agent 1.9.0 introduced `bank.settlement`: removal, rebuild, migration and creation
|
||||||
cancellation finish pending TWS bank decisions before deleting the old runtime.
|
cancellation finish pending TWS bank decisions before deleting the old runtime.
|
||||||
An unavailable controller or corrupt journal leaves the operation unresolved
|
An unavailable controller or corrupt journal leaves the operation unresolved
|
||||||
and preserves storage. Update all agents before enabling the
|
and preserves storage. Update all agents before enabling the
|
||||||
[player bank](bank.md). EC generates and injects bank credentials per instance;
|
[player bank](bank.md). EC generates and injects bank credentials per instance;
|
||||||
no token map or manual server-secret provisioning is required.
|
no token map or manual server-secret provisioning is required.
|
||||||
|
|
||||||
|
Agent **1.9.1+** recognizes both the fixed public game-bank endpoint
|
||||||
|
`https://ec.thewrightserver.net/api/bank` and its own legacy controller-derived
|
||||||
|
bank endpoint in journals. It stops generating `tws_bank_url` in the private
|
||||||
|
game configuration. Agent transport still uses the enrolled controller URL and
|
||||||
|
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.
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
# Provisioning
|
# Provisioning
|
||||||
|
|
||||||
Open **Activity → Activity log** for retained lifecycle, host, campaign, and
|
Open **Activity → Provisioning** for server queues, bulk cancellation, lifecycle
|
||||||
maintenance history. Use **Manage servers** from that page or command search
|
actions, and retained lifecycle history. **Fleet → Collections** owns reusable lineups and collection launch
|
||||||
for server queues, bulk cancellation, and lifecycle actions. **Fleet → Collections** owns reusable lineups and collection launch
|
|
||||||
progress; their individual operations remain visible in Provisioning.
|
progress; their individual operations remain visible in Provisioning.
|
||||||
Provisioning owns the queues, safety checks, execution, and
|
Provisioning owns the queues, safety checks, execution, and
|
||||||
recovery for all six actions. Create a server using **Create server**. For an existing
|
recovery for all six actions. Create a server using **Create server**. For an existing
|
||||||
@@ -150,14 +149,19 @@ and post-action health checks still apply.
|
|||||||
|
|
||||||
## Campaigns, betas and maintenance
|
## Campaigns, betas and maintenance
|
||||||
|
|
||||||
Campaigns, beta runs, and maintenance submit durable restart requests to
|
Campaign and beta file changes submit durable rebuild requests to
|
||||||
Provisioning and observe the resulting operation by ID. Their pages link to that
|
Provisioning and observe the resulting operation by ID. Their pages link to that
|
||||||
operation, and its history links back to the requesting work. Provisioning owns
|
operation, and its history links back to the requesting work. Provisioning owns
|
||||||
the empty-server check, conflict handling, restart capacity, remote command, and
|
the empty-server check, conflict handling, capacity limits, remote command, and
|
||||||
health verification. The callers retain file deployment, beta evidence, and
|
health verification. The callers retain file deployment, beta evidence, and
|
||||||
maintenance outcome verification. Jobs remain visible while waiting for players,
|
maintenance outcome verification. Jobs remain visible while waiting for players,
|
||||||
other work, or integration configuration.
|
other work, or integration configuration.
|
||||||
|
|
||||||
|
File rebuilds require host agent 1.9.2 or later. They capture rollback snapshots
|
||||||
|
before replacing the instance, and install the requested files over the promoted
|
||||||
|
setup. Queued file activations wait for the agent update when necessary.
|
||||||
|
Maintenance jobs and beta recovery cycles without file changes still use restarts.
|
||||||
|
|
||||||
[Maintenance](maintenance.md) also submits recovery operations and links each attempt
|
[Maintenance](maintenance.md) also submits recovery operations and links each attempt
|
||||||
to its incident. A failed Maintenance restart can hand its reserved slot to a rebuild
|
to its incident. A failed Maintenance restart can hand its reserved slot to a rebuild
|
||||||
after five minutes; unrelated recovery slots remain held. Drift campaigns follow
|
after five minutes; unrelated recovery slots remain held. Drift campaigns follow
|
||||||
@@ -300,6 +304,21 @@ query parameters. Collection launch links at `/provisioning/collections/:id` or
|
|||||||
query parameters. `/api/deployments` remains compatible, including old campaign
|
query parameters. `/api/deployments` remains compatible, including old campaign
|
||||||
detail endpoints. No history or IDs are renamed or discarded.
|
detail endpoints. No history or IDs are renamed or discarded.
|
||||||
|
|
||||||
|
The standalone Activity log has been retired. Use a server's **History** tab for
|
||||||
|
its combined history, **Activity → Maintenance → Activity** for maintenance and
|
||||||
|
recovery, **Releases → Campaigns** or **Releases → Betas → History** for release
|
||||||
|
history, and **Fleet → Hosts → Operation history** for retained host operations.
|
||||||
|
Host operation history remains available after a host is deleted.
|
||||||
|
|
||||||
|
Legacy `/activity` and `/operations` bookmarks redirect to Provisioning history
|
||||||
|
by default. Server-scoped bookmarks open the server's History tab; host-scoped
|
||||||
|
bookmarks open host operation history. Source-only bookmarks open the matching
|
||||||
|
Campaigns, Betas, Maintenance, host operations, or autoscaling provisioning
|
||||||
|
history. Shared history filters are retained when opening server or Maintenance
|
||||||
|
history; pagination is reset when switching to a different kind of history.
|
||||||
|
`GET /api/activity` and the shared history views remain available. Historical
|
||||||
|
records, event collection, and retention are unchanged.
|
||||||
|
|
||||||
Start and Stop use durable `instance.start` and `instance.stop` host-agent
|
Start and Stop use durable `instance.start` and `instance.stop` host-agent
|
||||||
operations. Rebuild uses `instance.rebuild` and replaces the instance files.
|
operations. Rebuild uses `instance.rebuild` and replaces the instance files.
|
||||||
See [native host management](native-hosts.md) for command delivery and recovery.
|
See [native host management](native-hosts.md) for command delivery and recovery.
|
||||||
|
|||||||
+80
-24
@@ -1,9 +1,13 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
// Local recovery only: requires filesystem/container access to the controller.
|
// Local recovery only: requires filesystem/container access to the controller.
|
||||||
import Database from 'better-sqlite3';
|
import Database from 'better-sqlite3';
|
||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
import { emitKeypressEvents } from 'node:readline';
|
import { emitKeypressEvents } from 'node:readline';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
import { hashPassword } from '../src/lib/server/auth/passwords.js';
|
import { hashPassword } from '../src/lib/server/auth/passwords.js';
|
||||||
|
|
||||||
|
/** @param {string} prompt @returns {Promise<string>} */
|
||||||
function secret(prompt) {
|
function secret(prompt) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
let value = '';
|
let value = '';
|
||||||
@@ -17,6 +21,7 @@ function secret(prompt) {
|
|||||||
process.stdin.pause();
|
process.stdin.pause();
|
||||||
process.stdout.write('\n');
|
process.stdout.write('\n');
|
||||||
};
|
};
|
||||||
|
/** @param {string | undefined} text @param {import('node:readline').Key | undefined} key */
|
||||||
const listener = (text, key) => {
|
const listener = (text, key) => {
|
||||||
if (key?.ctrl && key.name === 'c') {
|
if (key?.ctrl && key.name === 'c') {
|
||||||
cleanup();
|
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();
|
const username = process.argv[2]?.toLowerCase();
|
||||||
if (process.argv.length !== 3 || !username || !/^[a-z0-9][a-z0-9_.-]{2,63}$/.test(username))
|
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>');
|
throw new Error('Usage: node scripts/reset-admin.mjs <administrator-username>');
|
||||||
@@ -51,34 +116,25 @@ try {
|
|||||||
fileMustExist: true
|
fileMustExist: true
|
||||||
});
|
});
|
||||||
database.pragma('busy_timeout=5000');
|
database.pragma('busy_timeout=5000');
|
||||||
const user = database
|
try {
|
||||||
.prepare("SELECT id FROM auth_users WHERE username=? AND role='admin' AND disabled=0")
|
administrator(database, username);
|
||||||
.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 password = await secret('New administrator password (8–128 characters): ');
|
||||||
const confirmation = await secret('Repeat password: ');
|
const confirmation = await secret('Repeat password: ');
|
||||||
if (password !== confirmation) throw new Error('Passwords did not match.');
|
if (password !== confirmation) throw new Error('Passwords did not match.');
|
||||||
const passwordHash = await hashPassword(password);
|
await recoverAdministrator(database, username, password);
|
||||||
database
|
console.log(
|
||||||
.transaction(() => {
|
'Administrator password reset. Sessions and trusted login devices revoked; account rate limits cleared.'
|
||||||
const changed = database
|
);
|
||||||
.prepare(
|
} finally {
|
||||||
"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();
|
database.close();
|
||||||
console.log('Administrator password reset. Existing sessions have been revoked.');
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||||
|
try {
|
||||||
|
await main();
|
||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
console.error(cause instanceof Error ? cause.message : 'Administrator recovery failed.');
|
console.error(cause instanceof Error ? cause.message : 'Administrator recovery failed.');
|
||||||
process.exitCode = 1;
|
process.exitCode = 1;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { invalidateFleet } from '$lib/server/pipeline/bus.js';
|
|||||||
import { startPerformanceMonitor } from '$lib/server/performance.js';
|
import { startPerformanceMonitor } from '$lib/server/performance.js';
|
||||||
import { withOperationSource } from '$lib/server/hosts/operation-context.js';
|
import { withOperationSource } from '$lib/server/hosts/operation-context.js';
|
||||||
import { withHostRequestContext } from '$lib/server/hosts/request-context.js';
|
import { withHostRequestContext } from '$lib/server/hosts/request-context.js';
|
||||||
|
import { startDiscordWorker } from '$lib/server/discord/worker.js';
|
||||||
|
|
||||||
let booted = false;
|
let booted = false;
|
||||||
let rejectionLoggerRegistered = false;
|
let rejectionLoggerRegistered = false;
|
||||||
@@ -21,6 +22,7 @@ function boot(): void {
|
|||||||
migrate();
|
migrate();
|
||||||
interruptDeployments();
|
interruptDeployments();
|
||||||
startWorker();
|
startWorker();
|
||||||
|
startDiscordWorker();
|
||||||
startPerformanceMonitor();
|
startPerformanceMonitor();
|
||||||
booted = true;
|
booted = true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
const historyFilters = ['host', 'type', 'source', 'state', 'days', 'q', 'limit', 'offset'];
|
||||||
|
|
||||||
|
/** Route retired activity bookmarks to the existing history that owns their scope. */
|
||||||
|
export function activityRedirectHref(url: URL): string {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
const copy = (keys: string[]) => {
|
||||||
|
for (const key of keys) {
|
||||||
|
const value = url.searchParams.get(key);
|
||||||
|
if (value) params.set(key, value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const server = url.searchParams.get('server')?.trim();
|
||||||
|
const host = url.searchParams.get('host')?.trim();
|
||||||
|
const source = url.searchParams.get('source');
|
||||||
|
let path: string;
|
||||||
|
let hash = '';
|
||||||
|
if (server) {
|
||||||
|
path = `/servers/${encodeURIComponent(server)}`;
|
||||||
|
params.set('tab', 'history');
|
||||||
|
copy(historyFilters);
|
||||||
|
hash = url.hash;
|
||||||
|
} else if (host || source === 'host') {
|
||||||
|
path = '/hosts/operations';
|
||||||
|
if (host) params.set('host', host);
|
||||||
|
} else if (source === 'maintenance' || source === 'recovery') {
|
||||||
|
path = '/maintenance';
|
||||||
|
params.set('view', 'activity');
|
||||||
|
copy(historyFilters);
|
||||||
|
hash = url.hash;
|
||||||
|
} else if (source === 'campaign') {
|
||||||
|
path = '/campaigns';
|
||||||
|
copy(['q']);
|
||||||
|
} else if (source === 'beta') {
|
||||||
|
path = '/betas';
|
||||||
|
params.set('view', 'history');
|
||||||
|
copy(['q']);
|
||||||
|
} else {
|
||||||
|
path = '/provisioning';
|
||||||
|
params.set('view', 'history');
|
||||||
|
if (source === 'autoscaling') params.set('source', source);
|
||||||
|
}
|
||||||
|
copy(['returnTo']);
|
||||||
|
return path + (params.size ? `?${params}` : '') + hash;
|
||||||
|
}
|
||||||
+143
@@ -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>;
|
||||||
|
}
|
||||||
@@ -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. */
|
/** Keep the upload number visible: two builds may share a publisher's release tag. */
|
||||||
export function artifactVersionLabel(
|
export function artifactVersionLabel(
|
||||||
version: { version: number; release_version?: string | null } | null | undefined,
|
version: { version: number; release_version?: string | null } | null | undefined,
|
||||||
@@ -10,3 +12,14 @@ export function artifactVersionLabel(
|
|||||||
? `Unversioned (upload ${version.version})`
|
? `Unversioned (upload ${version.version})`
|
||||||
: `v${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
@@ -1,4 +1,6 @@
|
|||||||
export type UserRole = 'admin' | 'super_viewer' | 'operator' | 'viewer';
|
export type UserRole = 'admin' | 'super_viewer' | 'operator' | 'moderator' | 'viewer';
|
||||||
|
|
||||||
|
export const moderatorMaximumRestrictionMs = 7 * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
/** Reserved identity for work requested by the controller. */
|
/** Reserved identity for work requested by the controller. */
|
||||||
export const systemUsername = 'Maxis';
|
export const systemUsername = 'Maxis';
|
||||||
@@ -19,6 +21,7 @@ export const roleLabels: Record<UserRole, string> = {
|
|||||||
admin: 'Administrator',
|
admin: 'Administrator',
|
||||||
super_viewer: 'Super Viewer',
|
super_viewer: 'Super Viewer',
|
||||||
operator: 'Operator',
|
operator: 'Operator',
|
||||||
|
moderator: 'Moderator',
|
||||||
viewer: 'Viewer'
|
viewer: 'Viewer'
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -33,3 +36,11 @@ export function canReadAdmin(role: UserRole | null | undefined): boolean {
|
|||||||
export function canReadSensitive(role: UserRole | null | undefined): boolean {
|
export function canReadSensitive(role: UserRole | null | undefined): boolean {
|
||||||
return canOperate(role) || role === 'super_viewer';
|
return canOperate(role) || role === 'super_viewer';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function canModerate(role: UserRole | null | undefined): boolean {
|
||||||
|
return role === 'admin' || role === 'moderator';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canReadModeration(role: UserRole | null | undefined): boolean {
|
||||||
|
return canModerate(role) || role === 'super_viewer';
|
||||||
|
}
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ export interface ScalingMember {
|
|||||||
identity: string;
|
identity: string;
|
||||||
state: 'occupied' | 'empty' | 'creating' | 'removing' | 'stopped' | 'unknown' | 'protected';
|
state: 'occupied' | 'empty' | 'creating' | 'removing' | 'stopped' | 'unknown' | 'protected';
|
||||||
players: number | null;
|
players: number | null;
|
||||||
|
/** Current occupied match round from fresh telemetry; null when unavailable. */
|
||||||
|
round: number | null;
|
||||||
observedAt: number | null;
|
observedAt: number | null;
|
||||||
source: string | null;
|
source: string | null;
|
||||||
emptySince: number | null;
|
emptySince: number | null;
|
||||||
|
|||||||
+116
@@ -0,0 +1,116 @@
|
|||||||
|
export const bankPageSize = 25;
|
||||||
|
export const bankAttentionAgeMs = 5 * 60_000;
|
||||||
|
|
||||||
|
export interface BankQuery {
|
||||||
|
view: 'accounts' | 'transactions' | 'pending';
|
||||||
|
q: string;
|
||||||
|
balance: '' | 'funded' | 'limit' | 'pending';
|
||||||
|
sort: 'balance' | 'recent';
|
||||||
|
game: '' | 't5' | 't6';
|
||||||
|
server: string;
|
||||||
|
operation: '' | 'deposit' | 'withdraw';
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
offset: number;
|
||||||
|
player: string;
|
||||||
|
historyOffset: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readBankQuery(params: URLSearchParams): BankQuery {
|
||||||
|
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 offset(key: string) {
|
||||||
|
const value = Number(params.get(key) || 0);
|
||||||
|
if (!Number.isSafeInteger(value) || value < 0) throw new Error('Invalid page offset.');
|
||||||
|
return Math.floor(value / bankPageSize) * bankPageSize;
|
||||||
|
}
|
||||||
|
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 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', 'transactions', 'pending'], 'accounts'),
|
||||||
|
q: text('q', 100),
|
||||||
|
balance: choice('balance', ['', 'funded', 'limit', 'pending'], ''),
|
||||||
|
sort: choice('sort', ['balance', 'recent'], 'balance'),
|
||||||
|
game: choice('game', ['', 't5', 't6'], ''),
|
||||||
|
server: text('server', 256),
|
||||||
|
operation: choice('operation', ['', 'deposit', 'withdraw'], ''),
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
offset: offset('offset'),
|
||||||
|
player: text('player', 128),
|
||||||
|
historyOffset: offset('historyOffset')
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BankIdentity {
|
||||||
|
guid: string;
|
||||||
|
name: string | null;
|
||||||
|
}
|
||||||
|
export interface BankAccount extends BankIdentity {
|
||||||
|
balance: number;
|
||||||
|
available: number;
|
||||||
|
pendingOperation: 'deposit' | 'withdraw' | null;
|
||||||
|
pendingAmount: number | null;
|
||||||
|
lastActivity: string | null;
|
||||||
|
lastAmount: number | null;
|
||||||
|
}
|
||||||
|
export interface BankTransfer extends BankIdentity {
|
||||||
|
id: number;
|
||||||
|
server: string;
|
||||||
|
game: string | null;
|
||||||
|
matchId: number | null;
|
||||||
|
matchAvailable: number;
|
||||||
|
amount: number;
|
||||||
|
balance: number;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
export interface BankPending extends BankIdentity {
|
||||||
|
requestId: string;
|
||||||
|
server: string;
|
||||||
|
game: string | null;
|
||||||
|
matchId: number | null;
|
||||||
|
matchAvailable: number;
|
||||||
|
operation: 'deposit' | 'withdraw';
|
||||||
|
amount: number;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
export interface BankPage<T> {
|
||||||
|
entries: T[];
|
||||||
|
total: number;
|
||||||
|
offset: number;
|
||||||
|
}
|
||||||
|
export interface BankOverview {
|
||||||
|
query: BankQuery;
|
||||||
|
maximum: number | null;
|
||||||
|
checkedAt: string;
|
||||||
|
summary: { totalBanked: number; fundedAccounts: number; transfers24h: number; pending: number };
|
||||||
|
servers: string[];
|
||||||
|
accounts: BankPage<BankAccount>;
|
||||||
|
transactions: BankPage<BankTransfer>;
|
||||||
|
pending: BankPage<BankPending>;
|
||||||
|
detail: {
|
||||||
|
account: BankAccount;
|
||||||
|
pending: BankPending | null;
|
||||||
|
history: BankPage<BankTransfer>;
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
@@ -30,6 +30,14 @@ export interface CapacityHistory {
|
|||||||
endAt: number;
|
endAt: number;
|
||||||
bucketMs: number;
|
bucketMs: number;
|
||||||
points: CapacityHistoryPoint[];
|
points: CapacityHistoryPoint[];
|
||||||
|
temperatures: TemperatureHistorySeries[];
|
||||||
|
}
|
||||||
|
export interface TemperatureHistorySeries {
|
||||||
|
id: string;
|
||||||
|
hostId: string;
|
||||||
|
label: string;
|
||||||
|
kind: 'cpu' | 'nvme' | 'chipset';
|
||||||
|
points: { at: number; celsius: number | null }[];
|
||||||
}
|
}
|
||||||
export interface CapacityHost {
|
export interface CapacityHost {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ export interface LaunchFileSummary {
|
|||||||
path: string;
|
path: string;
|
||||||
versionId: number | null;
|
versionId: number | null;
|
||||||
version: number | null;
|
version: number | null;
|
||||||
|
/** Missing on snapshots recorded before publisher release versions were retained. */
|
||||||
|
releaseVersion?: string | null;
|
||||||
sha256: string;
|
sha256: string;
|
||||||
size: number;
|
size: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
export type DiscordGame = 't5' | 't6';
|
||||||
|
|
||||||
|
export interface DiscordSettings {
|
||||||
|
enabled: boolean;
|
||||||
|
guildId: string;
|
||||||
|
channels: Record<DiscordGame, string>;
|
||||||
|
excludedServers: string[];
|
||||||
|
showPlayers: boolean;
|
||||||
|
showAddresses: boolean;
|
||||||
|
leaderboardEnabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const defaultDiscordSettings: DiscordSettings = {
|
||||||
|
enabled: false,
|
||||||
|
guildId: '',
|
||||||
|
channels: { t5: '', t6: '' },
|
||||||
|
excludedServers: [],
|
||||||
|
showPlayers: false,
|
||||||
|
showAddresses: false,
|
||||||
|
leaderboardEnabled: false
|
||||||
|
};
|
||||||
|
|
||||||
|
export const discordSnowflake = (value: unknown): value is string =>
|
||||||
|
typeof value === 'string' && /^[1-9][0-9]{16,19}$/.test(value);
|
||||||
|
|
||||||
|
export function validateDiscordSettings(value: unknown): DiscordSettings {
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value))
|
||||||
|
throw new Error('Provide Discord settings.');
|
||||||
|
const input = value as Record<string, unknown>;
|
||||||
|
for (const key of ['enabled', 'showPlayers', 'showAddresses', 'leaderboardEnabled']) {
|
||||||
|
if (typeof input[key] !== 'boolean') throw new Error(`Invalid ${key} setting.`);
|
||||||
|
}
|
||||||
|
if (typeof input.guildId !== 'string' || (input.guildId && !discordSnowflake(input.guildId)))
|
||||||
|
throw new Error('Provide a valid Discord server ID.');
|
||||||
|
if (!input.channels || typeof input.channels !== 'object' || Array.isArray(input.channels))
|
||||||
|
throw new Error('Provide Discord channels for each game.');
|
||||||
|
const channels = input.channels as Record<string, unknown>;
|
||||||
|
for (const game of ['t5', 't6'] as const) {
|
||||||
|
if (
|
||||||
|
typeof channels[game] !== 'string' ||
|
||||||
|
(channels[game] && !discordSnowflake(channels[game]))
|
||||||
|
)
|
||||||
|
throw new Error('Channel IDs must be valid Discord IDs or empty.');
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!Array.isArray(input.excludedServers) ||
|
||||||
|
input.excludedServers.length > 100 ||
|
||||||
|
input.excludedServers.some(
|
||||||
|
(name) => typeof name !== 'string' || !/^[A-Za-z0-9_.-]{1,128}$/.test(name)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
throw new Error('Choose up to 100 servers to exclude from Discord.');
|
||||||
|
if (input.enabled && (!input.guildId || (!channels.t5 && !channels.t6)))
|
||||||
|
throw new Error('Choose a Discord server and at least one board channel before enabling.');
|
||||||
|
return {
|
||||||
|
enabled: input.enabled as boolean,
|
||||||
|
guildId: input.guildId,
|
||||||
|
channels: { t5: channels.t5 as string, t6: channels.t6 as string },
|
||||||
|
excludedServers: [...new Set(input.excludedServers as string[])].sort(),
|
||||||
|
showPlayers: input.showPlayers as boolean,
|
||||||
|
showAddresses: input.showAddresses as boolean,
|
||||||
|
leaderboardEnabled: input.leaderboardEnabled as boolean
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DiscordPublicServer {
|
||||||
|
name: string;
|
||||||
|
game: DiscordGame;
|
||||||
|
label: string;
|
||||||
|
map: string;
|
||||||
|
mapKey: string;
|
||||||
|
state: 'empty' | 'in-progress' | 'full' | 'starting' | 'offline' | 'stale' | 'unknown';
|
||||||
|
players: number | null;
|
||||||
|
capacity: number | null;
|
||||||
|
round: number | null;
|
||||||
|
observedAt: string | null;
|
||||||
|
playerNames: string[];
|
||||||
|
connection: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DiscordMessage {
|
||||||
|
content?: string;
|
||||||
|
flags?: number;
|
||||||
|
components?: Record<string, unknown>[];
|
||||||
|
allowed_mentions: { parse: string[] };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DiscordDeliveryStatus {
|
||||||
|
lastAttemptAt: string | null;
|
||||||
|
lastSuccessAt: string | null;
|
||||||
|
lastError: string | null;
|
||||||
|
boards: { game: DiscordGame; channelId: string; messageCount: number }[];
|
||||||
|
activeSubscriptions: number;
|
||||||
|
pendingNotifications: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DiscordAdminState {
|
||||||
|
settings: DiscordSettings;
|
||||||
|
credentials: {
|
||||||
|
applicationId: string;
|
||||||
|
publicKeyConfigured: boolean;
|
||||||
|
botTokenConfigured: boolean;
|
||||||
|
};
|
||||||
|
status: DiscordDeliveryStatus;
|
||||||
|
servers: { name: string; game: DiscordGame; label: string; map: string }[];
|
||||||
|
preview: Record<DiscordGame, DiscordPublicServer[]>;
|
||||||
|
}
|
||||||
@@ -58,7 +58,10 @@ export interface FleetOperation {
|
|||||||
export type FleetFilter = 'all' | 'occupied' | 'empty' | 'attention' | 'testing' | 'pinned';
|
export type FleetFilter = 'all' | 'occupied' | 'empty' | 'attention' | 'testing' | 'pinned';
|
||||||
export type FleetSort = 'name' | 'players' | 'round' | 'duration';
|
export type FleetSort = 'name' | 'players' | 'round' | 'duration';
|
||||||
|
|
||||||
export function freshServer(server: FleetServer, now: number): boolean {
|
export function freshServer(
|
||||||
|
server: Pick<FleetServer, 'health' | 'last_error' | 'last_seen_at'>,
|
||||||
|
now: number
|
||||||
|
): boolean {
|
||||||
return (
|
return (
|
||||||
server.health === 'online' &&
|
server.health === 'online' &&
|
||||||
!server.last_error &&
|
!server.last_error &&
|
||||||
@@ -93,7 +96,18 @@ export function fleetIdentity(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function currentRound(
|
export function currentRound(
|
||||||
server: FleetServer,
|
server: Pick<
|
||||||
|
FleetServer,
|
||||||
|
| 'telemetry'
|
||||||
|
| 'telemetry_after'
|
||||||
|
| 'health'
|
||||||
|
| 'last_error'
|
||||||
|
| 'last_seen_at'
|
||||||
|
| 'last_players'
|
||||||
|
| 'active_since'
|
||||||
|
| 'game_started_at'
|
||||||
|
| 'game_ended_at'
|
||||||
|
>,
|
||||||
report: GameTelemetry | undefined,
|
report: GameTelemetry | undefined,
|
||||||
now: number
|
now: number
|
||||||
): number | null {
|
): number | null {
|
||||||
|
|||||||
@@ -305,7 +305,7 @@ export const settingHelp: Partial<Record<keyof FleetSettings, string>> = {
|
|||||||
'Finished non-update maintenance. Update attempts remain durable to prevent duplicate automatic retries.',
|
'Finished non-update maintenance. Update attempts remain durable to prevent duplicate automatic retries.',
|
||||||
recoveryKeepDays:
|
recoveryKeepDays:
|
||||||
'Resolved watchdog incidents with no unfinished recovery. Dismissed incidents remain as suppression evidence.',
|
'Resolved watchdog incidents with no unfinished recovery. Dismissed incidents remain as suppression evidence.',
|
||||||
hostResourceKeepDays: 'Host CPU and memory history.',
|
hostResourceKeepDays: 'Host CPU, memory and temperature history.',
|
||||||
instanceResourceKeepDays:
|
instanceResourceKeepDays:
|
||||||
'Minute resource samples. Capacity learning continues to use its independent 24-hour window.',
|
'Minute resource samples. Capacity learning continues to use its independent 24-hour window.',
|
||||||
metricsKeepDays: 'Historical fleet occupancy and utilization samples.',
|
metricsKeepDays: 'Historical fleet occupancy and utilization samples.',
|
||||||
|
|||||||
+15
-7
@@ -4,6 +4,8 @@ export interface ChatMessage {
|
|||||||
slot: number;
|
slot: number;
|
||||||
player: string;
|
player: string;
|
||||||
message: string;
|
message: string;
|
||||||
|
/** Omitted for public chat to preserve existing archived message identities. */
|
||||||
|
channel?: 'team';
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ChatEntry =
|
export type ChatEntry =
|
||||||
@@ -17,7 +19,12 @@ export interface ChatContext {
|
|||||||
round: number | null;
|
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 {
|
export interface ChatMatch {
|
||||||
id?: number;
|
id?: number;
|
||||||
@@ -93,18 +100,19 @@ function indexedTimeline(lines: readonly string[]): { entry: ChatEntry; lineInde
|
|||||||
previousSeconds = seconds;
|
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;
|
if (!match) continue;
|
||||||
const message = plainText(match[5]);
|
const message = plainText(match[6]);
|
||||||
if (!message) continue;
|
if (!message) continue;
|
||||||
push({
|
push({
|
||||||
type: 'message',
|
type: 'message',
|
||||||
message: {
|
message: {
|
||||||
time: match[1],
|
time: match[1],
|
||||||
guid: match[2],
|
guid: match[3],
|
||||||
slot: Number(match[3]),
|
slot: Number(match[4]),
|
||||||
player: plainText(match[4]) || 'Unknown player',
|
player: plainText(match[5]) || 'Unknown player',
|
||||||
message
|
message,
|
||||||
|
...(match[2] === 'sayteam' ? { channel: 'team' as const } : {})
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
export const temperatureKinds = ['cpu-package', 'cpu-die', 'cpu-core', 'nvme', 'chipset'] as const;
|
||||||
|
export type TemperatureKind = (typeof temperatureKinds)[number];
|
||||||
|
export interface HostTemperatureSensor {
|
||||||
|
id: string;
|
||||||
|
device: string;
|
||||||
|
label: string;
|
||||||
|
kind: TemperatureKind;
|
||||||
|
celsius: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Optional telemetry must never make an otherwise valid heartbeat fail. */
|
||||||
|
export function validateTemperatures(value: unknown): HostTemperatureSensor[] {
|
||||||
|
if (!Array.isArray(value)) return [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
return value.slice(0, 128).flatMap((sensor) => {
|
||||||
|
if (
|
||||||
|
!sensor ||
|
||||||
|
typeof sensor !== 'object' ||
|
||||||
|
Array.isArray(sensor) ||
|
||||||
|
typeof sensor.id !== 'string' ||
|
||||||
|
!/^[a-zA-Z0-9_-]{1,100}$/.test(sensor.id) ||
|
||||||
|
seen.has(sensor.id) ||
|
||||||
|
!['device', 'label'].every(
|
||||||
|
(key) =>
|
||||||
|
typeof sensor[key] === 'string' &&
|
||||||
|
sensor[key].length > 0 &&
|
||||||
|
sensor[key].length <= 120
|
||||||
|
) ||
|
||||||
|
!temperatureKinds.includes(sensor.kind) ||
|
||||||
|
typeof sensor.celsius !== 'number' ||
|
||||||
|
!Number.isFinite(sensor.celsius) ||
|
||||||
|
sensor.celsius < -100 ||
|
||||||
|
sensor.celsius > 200
|
||||||
|
)
|
||||||
|
return [];
|
||||||
|
seen.add(sensor.id);
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: sensor.id,
|
||||||
|
device: sensor.device,
|
||||||
|
label: sensor.label,
|
||||||
|
kind: sensor.kind,
|
||||||
|
celsius: sensor.celsius
|
||||||
|
}
|
||||||
|
];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cpuTemperature(
|
||||||
|
sensors: HostTemperatureSensor[] = []
|
||||||
|
): HostTemperatureSensor | null {
|
||||||
|
// Prefer physical package/die readings. Cores are a fallback, never mixed
|
||||||
|
// into an average that might hide a hot package on a multi-socket host.
|
||||||
|
for (const kind of ['cpu-package', 'cpu-die', 'cpu-core'] as const) {
|
||||||
|
const candidates = sensors.filter((sensor) => sensor.kind === kind);
|
||||||
|
if (candidates.length)
|
||||||
|
return candidates.reduce((hot, sensor) =>
|
||||||
|
sensor.celsius > hot.celsius ? sensor : hot
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function temperatureLabel(sensor: HostTemperatureSensor): string {
|
||||||
|
if (sensor.kind === 'cpu-package')
|
||||||
|
return `CPU package ${sensor.label.replace(/^Package id /, '')}`;
|
||||||
|
if (sensor.kind === 'cpu-core') return `CPU ${sensor.label.toLowerCase()}`;
|
||||||
|
if (sensor.kind === 'cpu-die') return `CPU die · ${sensor.label}`;
|
||||||
|
if (sensor.kind === 'chipset') return 'Chipset';
|
||||||
|
return `NVMe · ${sensor.label}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatTemperature(celsius: number | null | undefined): string {
|
||||||
|
return typeof celsius === 'number' && Number.isFinite(celsius)
|
||||||
|
? `${Number(celsius.toFixed(1))} °C`
|
||||||
|
: 'Unavailable';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One CPU series per host, plus bounded device series for the host drill-down. */
|
||||||
|
export function historyTemperatures(
|
||||||
|
sensors: HostTemperatureSensor[] = []
|
||||||
|
): HostTemperatureSensor[] {
|
||||||
|
const cpu = cpuTemperature(sensors);
|
||||||
|
const devices = sensors
|
||||||
|
.filter(
|
||||||
|
(sensor) =>
|
||||||
|
sensor.kind === 'chipset' ||
|
||||||
|
(sensor.kind === 'nvme' && sensor.label.toLowerCase() === 'composite')
|
||||||
|
)
|
||||||
|
.slice(0, 16);
|
||||||
|
return [...(cpu ? [{ ...cpu, id: 'cpu', label: 'CPU temperature' }] : []), ...devices];
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { HostLibraryCache } from './game-files.js';
|
import type { HostLibraryCache } from './game-files.js';
|
||||||
|
import type { HostTemperatureSensor } from './host-temperatures.js';
|
||||||
|
|
||||||
export type HostMode = 'active' | 'draining' | 'disabled';
|
export type HostMode = 'active' | 'draining' | 'disabled';
|
||||||
export type HostReadiness =
|
export type HostReadiness =
|
||||||
@@ -149,6 +150,7 @@ export interface HostInventory {
|
|||||||
cpu?: string;
|
cpu?: string;
|
||||||
cpuCount?: number;
|
cpuCount?: number;
|
||||||
cpuUsagePercent?: number;
|
cpuUsagePercent?: number;
|
||||||
|
temperatures?: HostTemperatureSensor[];
|
||||||
memoryTotal?: number;
|
memoryTotal?: number;
|
||||||
memoryAvailable?: number;
|
memoryAvailable?: number;
|
||||||
storageTotal?: number;
|
storageTotal?: number;
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export interface MatchHistoryQuery {
|
|||||||
sort: MatchSort;
|
sort: MatchSort;
|
||||||
direction: 'asc' | 'desc';
|
direction: 'asc' | 'desc';
|
||||||
page: number;
|
page: number;
|
||||||
|
archive: boolean;
|
||||||
}
|
}
|
||||||
export const matchQueryKeys = [
|
export const matchQueryKeys = [
|
||||||
'game',
|
'game',
|
||||||
@@ -19,7 +20,8 @@ export const matchQueryKeys = [
|
|||||||
'status',
|
'status',
|
||||||
'sort',
|
'sort',
|
||||||
'direction',
|
'direction',
|
||||||
'page'
|
'page',
|
||||||
|
'archive'
|
||||||
] as const;
|
] as const;
|
||||||
export function readMatchQuery(params: URLSearchParams): MatchHistoryQuery {
|
export function readMatchQuery(params: URLSearchParams): MatchHistoryQuery {
|
||||||
const game = params.get('game') ?? '';
|
const game = params.get('game') ?? '';
|
||||||
@@ -30,6 +32,7 @@ export function readMatchQuery(params: URLSearchParams): MatchHistoryQuery {
|
|||||||
const sort = params.get('sort') || 'started';
|
const sort = params.get('sort') || 'started';
|
||||||
const direction = params.get('direction') || 'desc';
|
const direction = params.get('direction') || 'desc';
|
||||||
const page = Number(params.get('page') || 1);
|
const page = Number(params.get('page') || 1);
|
||||||
|
const archive = params.get('archive') ?? '';
|
||||||
if (
|
if (
|
||||||
!['', 't5', 't6'].includes(game) ||
|
!['', 't5', 't6'].includes(game) ||
|
||||||
map.length > 1024 ||
|
map.length > 1024 ||
|
||||||
@@ -40,7 +43,8 @@ export function readMatchQuery(params: URLSearchParams): MatchHistoryQuery {
|
|||||||
!['asc', 'desc'].includes(direction) ||
|
!['asc', 'desc'].includes(direction) ||
|
||||||
!Number.isSafeInteger(page) ||
|
!Number.isSafeInteger(page) ||
|
||||||
page < 1 ||
|
page < 1 ||
|
||||||
page > 1000000
|
page > 1000000 ||
|
||||||
|
!['', '0', '1'].includes(archive)
|
||||||
)
|
)
|
||||||
throw new Error('Invalid match history filters.');
|
throw new Error('Invalid match history filters.');
|
||||||
return {
|
return {
|
||||||
@@ -51,7 +55,8 @@ export function readMatchQuery(params: URLSearchParams): MatchHistoryQuery {
|
|||||||
status: status as MatchHistoryQuery['status'],
|
status: status as MatchHistoryQuery['status'],
|
||||||
sort: sort as MatchSort,
|
sort: sort as MatchSort,
|
||||||
direction: direction as 'asc' | 'desc',
|
direction: direction as 'asc' | 'desc',
|
||||||
page
|
page,
|
||||||
|
archive: archive === '1'
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
/** Namespaced filters also work inside an instance's Matches tab. */
|
/** Namespaced filters also work inside an instance's Matches tab. */
|
||||||
|
|||||||
@@ -47,7 +47,6 @@ export interface MatchPlayer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface MatchDetail extends ServerMatch {
|
export interface MatchDetail extends ServerMatch {
|
||||||
status: 'completed';
|
|
||||||
game: string;
|
game: string;
|
||||||
startObserved: boolean;
|
startObserved: boolean;
|
||||||
players: MatchPlayer[];
|
players: MatchPlayer[];
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
|
import { activityRedirectHref } from './activity-redirect.js';
|
||||||
|
|
||||||
const origin = 'https://electric-cherry.invalid';
|
const origin = 'https://electric-cherry.invalid';
|
||||||
const pagePath =
|
const pagePath =
|
||||||
/^\/(?:$|activity|operations|autoscaling|account|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. */
|
/** Only return to application pages, never an external URL or a mutation/API endpoint. */
|
||||||
export function safeReturnHref(value: string | null | undefined, fallback: string): string {
|
export function safeReturnHref(value: string | null | undefined, fallback: string): string {
|
||||||
@@ -16,6 +18,8 @@ export function safeReturnHref(value: string | null | undefined, fallback: strin
|
|||||||
try {
|
try {
|
||||||
const url = new URL(value, origin);
|
const url = new URL(value, origin);
|
||||||
if (url.origin !== origin || !pagePath.test(url.pathname)) return fallback;
|
if (url.origin !== origin || !pagePath.test(url.pathname)) return fallback;
|
||||||
|
if (url.pathname === '/activity' || url.pathname === '/operations')
|
||||||
|
return activityRedirectHref(url);
|
||||||
return url.pathname + url.search + url.hash;
|
return url.pathname + url.search + url.hash;
|
||||||
} catch {
|
} catch {
|
||||||
return fallback;
|
return fallback;
|
||||||
@@ -45,8 +49,6 @@ export function returnLabel(href: string): string {
|
|||||||
if (path.startsWith('/hosts/')) return 'Back to host';
|
if (path.startsWith('/hosts/')) return 'Back to host';
|
||||||
const names: Record<string, string> = {
|
const names: Record<string, string> = {
|
||||||
'': 'Servers',
|
'': 'Servers',
|
||||||
activity: 'Activity',
|
|
||||||
operations: 'Activity',
|
|
||||||
autoscaling: 'Autoscaling',
|
autoscaling: 'Autoscaling',
|
||||||
collections: 'Collections',
|
collections: 'Collections',
|
||||||
hosts: 'Hosts',
|
hosts: 'Hosts',
|
||||||
@@ -60,6 +62,8 @@ export function returnLabel(href: string): string {
|
|||||||
maintenance: 'Maintenance',
|
maintenance: 'Maintenance',
|
||||||
provisioning: 'Server queues',
|
provisioning: 'Server queues',
|
||||||
leaderboard: 'Leaderboard',
|
leaderboard: 'Leaderboard',
|
||||||
|
bank: 'Bank',
|
||||||
|
afk: 'AFK',
|
||||||
matches: 'Match History',
|
matches: 'Match History',
|
||||||
metrics: 'Metrics'
|
metrics: 'Metrics'
|
||||||
};
|
};
|
||||||
|
|||||||
+13
-11
@@ -1,9 +1,10 @@
|
|||||||
import { canReadAdmin, type UserRole } from './auth.js';
|
import { canReadAdmin, canReadModeration, type UserRole } from './auth.js';
|
||||||
|
|
||||||
export interface NavigationItem {
|
export interface NavigationItem {
|
||||||
label: string;
|
label: string;
|
||||||
href: string;
|
href: string;
|
||||||
shortcut?: string;
|
shortcut?: string;
|
||||||
|
moderation?: boolean;
|
||||||
matches?: (path: string) => boolean;
|
matches?: (path: string) => boolean;
|
||||||
}
|
}
|
||||||
export interface NavigationGroup {
|
export interface NavigationGroup {
|
||||||
@@ -28,8 +29,7 @@ export const navigationGroups: NavigationGroup[] = [
|
|||||||
label: 'Hosts',
|
label: 'Hosts',
|
||||||
href: '/hosts',
|
href: '/hosts',
|
||||||
shortcut: 'o',
|
shortcut: 'o',
|
||||||
matches: (path) =>
|
matches: (path) => path === '/hosts' || path.startsWith('/hosts/')
|
||||||
path === '/hosts' || /^\/hosts\/(?!operations(?:\/|$))/.test(path)
|
|
||||||
},
|
},
|
||||||
{ label: 'Capacity', href: '/capacity' },
|
{ label: 'Capacity', href: '/capacity' },
|
||||||
{ label: 'Autoscaling', href: '/autoscaling' },
|
{ label: 'Autoscaling', href: '/autoscaling' },
|
||||||
@@ -52,13 +52,6 @@ export const navigationGroups: NavigationGroup[] = [
|
|||||||
id: 'activity',
|
id: 'activity',
|
||||||
label: 'Activity',
|
label: 'Activity',
|
||||||
items: [
|
items: [
|
||||||
{
|
|
||||||
label: 'Activity log',
|
|
||||||
href: '/activity',
|
|
||||||
shortcut: 'd',
|
|
||||||
matches: (path) =>
|
|
||||||
path === '/activity' || path === '/operations' || path === '/hosts/operations'
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
label: 'Provisioning',
|
label: 'Provisioning',
|
||||||
href: '/provisioning',
|
href: '/provisioning',
|
||||||
@@ -78,6 +71,9 @@ export const navigationGroups: NavigationGroup[] = [
|
|||||||
items: [
|
items: [
|
||||||
{ label: 'Leaderboard', href: '/leaderboard', shortcut: 'l' },
|
{ label: 'Leaderboard', href: '/leaderboard', shortcut: 'l' },
|
||||||
{ label: 'Matches', href: '/matches' },
|
{ label: 'Matches', href: '/matches' },
|
||||||
|
{ label: 'Bank', href: '/bank' },
|
||||||
|
{ label: 'AFK', href: '/afk' },
|
||||||
|
{ label: 'Moderation', href: '/moderation', moderation: true },
|
||||||
{ label: 'Usage', href: '/metrics', shortcut: 'm' }
|
{ label: 'Usage', href: '/metrics', shortcut: 'm' }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -88,6 +84,7 @@ export const navigationGroups: NavigationGroup[] = [
|
|||||||
items: [
|
items: [
|
||||||
{ label: 'Users', href: '/settings/users' },
|
{ label: 'Users', href: '/settings/users' },
|
||||||
{ label: 'Keys', href: '/server-keys', shortcut: 'k' },
|
{ label: 'Keys', href: '/server-keys', shortcut: 'k' },
|
||||||
|
{ label: 'Discord', href: '/settings/integrations/discord' },
|
||||||
{
|
{
|
||||||
label: 'Settings',
|
label: 'Settings',
|
||||||
href: '/settings',
|
href: '/settings',
|
||||||
@@ -99,7 +96,12 @@ export const navigationGroups: NavigationGroup[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export function visibleNavigation(role: UserRole): NavigationGroup[] {
|
export function visibleNavigation(role: UserRole): NavigationGroup[] {
|
||||||
return navigationGroups.filter((group) => !group.admin || canReadAdmin(role));
|
return navigationGroups
|
||||||
|
.filter((group) => !group.admin || canReadAdmin(role))
|
||||||
|
.map((group) => ({
|
||||||
|
...group,
|
||||||
|
items: group.items.filter((item) => !item.moderation || canReadModeration(role))
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function navigationItemActive(item: NavigationItem, path: string): boolean {
|
export function navigationItemActive(item: NavigationItem, path: string): boolean {
|
||||||
|
|||||||
@@ -117,6 +117,13 @@ export interface ProvisioningCapacity {
|
|||||||
}[];
|
}[];
|
||||||
}
|
}
|
||||||
export interface ProvisioningDetail {
|
export interface ProvisioningDetail {
|
||||||
|
generationRecovery?: {
|
||||||
|
expectedGeneration: number;
|
||||||
|
replacementGeneration: number;
|
||||||
|
observedGeneration: number | null;
|
||||||
|
available: boolean;
|
||||||
|
reason: string | null;
|
||||||
|
} | null;
|
||||||
canOverrideSafety?: boolean;
|
canOverrideSafety?: boolean;
|
||||||
safetyOverrideUnavailableReason?: string | null;
|
safetyOverrideUnavailableReason?: string | null;
|
||||||
safetyOverride?: { actorId: string; actorName: string; confirmedAt: string } | null;
|
safetyOverride?: { actorId: string; actorName: string; confirmedAt: string } | null;
|
||||||
|
|||||||
@@ -38,7 +38,11 @@ export function scriptStatus(s: LibraryScript): ScriptStatus {
|
|||||||
if (s.latestVersion.id === s.current_version_id) return 'promoted';
|
if (s.latestVersion.id === s.current_version_id) return 'promoted';
|
||||||
return s.latestVersion.state === 'test-failed' ? 'failed' : s.latestVersion.state;
|
return s.latestVersion.state === 'test-failed' ? 'failed' : s.latestVersion.state;
|
||||||
}
|
}
|
||||||
export function canTest(s: LibraryScript): boolean {
|
export function canTest(s: {
|
||||||
|
runningBetaId: number | null;
|
||||||
|
latestVersion: { id: number } | null;
|
||||||
|
current_version_id: number | null;
|
||||||
|
}): boolean {
|
||||||
return (
|
return (
|
||||||
s.runningBetaId === null &&
|
s.runningBetaId === null &&
|
||||||
s.latestVersion !== null &&
|
s.latestVersion !== null &&
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { UserRole } from '../../auth.js';
|
import { canModerate, canReadModeration, type UserRole } from '../../auth.js';
|
||||||
|
|
||||||
export type RoutePermission =
|
export type RoutePermission =
|
||||||
| 'public'
|
| 'public'
|
||||||
@@ -7,6 +7,8 @@ export type RoutePermission =
|
|||||||
| 'viewer'
|
| 'viewer'
|
||||||
| 'operator-read'
|
| 'operator-read'
|
||||||
| 'admin-read'
|
| 'admin-read'
|
||||||
|
| 'moderation-read'
|
||||||
|
| 'moderation'
|
||||||
| 'operator'
|
| 'operator'
|
||||||
| 'admin';
|
| 'admin';
|
||||||
|
|
||||||
@@ -37,8 +39,12 @@ export function routePermission(routeId: string | null, method: string): RoutePe
|
|||||||
return 'admin';
|
return 'admin';
|
||||||
|
|
||||||
if (within(routeId, '/api/agent')) return 'machine';
|
if (within(routeId, '/api/agent')) return 'machine';
|
||||||
if (routeId === '/api/bank/[action]' && method === 'POST') return 'machine';
|
if (routeId === '/api/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/telemetry' && method === 'POST') return 'machine';
|
||||||
|
if (routeId === '/api/discord/interactions' && method === 'POST') return 'machine';
|
||||||
|
if (routeId === '/api/moderation/runtime' && method === 'POST') return 'machine';
|
||||||
if (routeId === '/api/leaderboard' && reading) return 'public';
|
if (routeId === '/api/leaderboard' && reading) return 'public';
|
||||||
if ((routeId === '/login' || routeId === '/setup') && reading) return 'public';
|
if ((routeId === '/login' || routeId === '/setup') && reading) return 'public';
|
||||||
if ((routeId === '/api/auth/login' || routeId === '/api/auth/setup') && method === 'POST')
|
if ((routeId === '/api/auth/login' || routeId === '/api/auth/setup') && method === 'POST')
|
||||||
@@ -53,8 +59,12 @@ export function routePermission(routeId: string | null, method: string): RoutePe
|
|||||||
if (routeId === '/api/settings/retention/preview' && method === 'POST') return 'admin-read';
|
if (routeId === '/api/settings/retention/preview' && method === 'POST') return 'admin-read';
|
||||||
if (within(routeId, '/api/auth')) return 'admin';
|
if (within(routeId, '/api/auth')) return 'admin';
|
||||||
|
|
||||||
|
if (within(routeId, '/moderation') || within(routeId, '/api/moderation'))
|
||||||
|
return reading ? 'moderation-read' : 'moderation';
|
||||||
|
|
||||||
if (
|
if (
|
||||||
within(routeId, '/api/users') ||
|
within(routeId, '/api/users') ||
|
||||||
|
within(routeId, '/api/integrations') ||
|
||||||
within(routeId, '/settings') ||
|
within(routeId, '/settings') ||
|
||||||
within(routeId, '/api/settings') ||
|
within(routeId, '/api/settings') ||
|
||||||
within(routeId, '/api/server-keys') ||
|
within(routeId, '/api/server-keys') ||
|
||||||
@@ -72,7 +82,12 @@ export function routePermission(routeId: string | null, method: string): RoutePe
|
|||||||
return 'admin';
|
return 'admin';
|
||||||
|
|
||||||
// These reads initiate fleet file scans or spend external API quota.
|
// 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
|
// Summaries remain visible to viewers; raw configuration, historical file
|
||||||
// bytes, and unfiltered diagnostics may contain runtime credentials.
|
// bytes, and unfiltered diagnostics may contain runtime credentials.
|
||||||
@@ -94,11 +109,13 @@ export function routePermission(routeId: string | null, method: string): RoutePe
|
|||||||
|
|
||||||
/** A browser role never substitutes for a machine credential. */
|
/** A browser role never substitutes for a machine credential. */
|
||||||
export function roleAllows(role: UserRole, permission: RoutePermission): boolean {
|
export function roleAllows(role: UserRole, permission: RoutePermission): boolean {
|
||||||
if (!['viewer', 'super_viewer', 'operator', 'admin'].includes(role)) return false;
|
if (!['viewer', 'super_viewer', 'operator', 'moderator', 'admin'].includes(role)) return false;
|
||||||
if (permission === 'machine') return false;
|
if (permission === 'machine') return false;
|
||||||
if (permission === 'public' || permission === 'authenticated' || permission === 'viewer')
|
if (permission === 'public' || permission === 'authenticated' || permission === 'viewer')
|
||||||
return true;
|
return true;
|
||||||
if (permission === 'operator') return role === 'operator' || role === 'admin';
|
if (permission === 'operator') return role === 'operator' || role === 'admin';
|
||||||
|
if (permission === 'moderation') return canModerate(role);
|
||||||
|
if (permission === 'moderation-read') return canReadModeration(role);
|
||||||
if (permission === 'operator-read')
|
if (permission === 'operator-read')
|
||||||
return role === 'super_viewer' || role === 'operator' || role === 'admin';
|
return role === 'super_viewer' || role === 'operator' || role === 'admin';
|
||||||
if (permission === 'admin-read') return role === 'super_viewer' || role === 'admin';
|
if (permission === 'admin-read') return role === 'super_viewer' || role === 'admin';
|
||||||
|
|||||||
@@ -167,6 +167,10 @@ export const authenticationHandle: Handle = async ({ event, resolve }) => {
|
|||||||
? 'Administrator access is required.'
|
? 'Administrator access is required.'
|
||||||
: permission === 'admin-read'
|
: permission === 'admin-read'
|
||||||
? 'Administrator or Super Viewer access is required.'
|
? 'Administrator or Super Viewer access is required.'
|
||||||
|
: permission === 'moderation'
|
||||||
|
? 'Moderator or Administrator access is required.'
|
||||||
|
: permission === 'moderation-read'
|
||||||
|
? 'Moderator, Administrator or Super Viewer access is required.'
|
||||||
: permission === 'operator-read'
|
: permission === 'operator-read'
|
||||||
? 'Operator, Administrator or Super Viewer access is required.'
|
? 'Operator, Administrator or Super Viewer access is required.'
|
||||||
: 'Operator access is required for this action.';
|
: 'Operator access is required for this action.';
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import { error, isHttpError, json, type Cookies } from '@sveltejs/kit';
|
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 { PasswordServiceBusyError } from './passwords.js';
|
||||||
import type { AuthUser } from '../../auth.js';
|
import type { AuthUser } from '../../auth.js';
|
||||||
|
|
||||||
export function sessionCookie(url: URL): string {
|
export function sessionCookie(url: URL): string {
|
||||||
return url.protocol === 'https:' ? SESSION_COOKIE : 'ec_session_dev';
|
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 {
|
export function requireUserTransport(url: URL): void {
|
||||||
if (url.protocol === 'https:') return;
|
if (url.protocol === 'https:') return;
|
||||||
if (
|
if (
|
||||||
@@ -34,6 +37,14 @@ export function sessionResponse(result: AuthResult, cookies: Cookies, url: URL)
|
|||||||
sameSite: 'lax',
|
sameSite: 'lax',
|
||||||
expires: new Date(result.session.expiresAt)
|
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' } });
|
return json({ user: result.user }, { headers: { 'Cache-Control': 'no-store' } });
|
||||||
}
|
}
|
||||||
export function requireUser(user: AuthUser | null): AuthUser {
|
export function requireUser(user: AuthUser | null): AuthUser {
|
||||||
|
|||||||
+131
-29
@@ -6,6 +6,9 @@ import { systemUsername, type AuthUser, type UserRole } from '../../auth.js';
|
|||||||
export const SESSION_COOKIE = '__Host-ec_session';
|
export const SESSION_COOKIE = '__Host-ec_session';
|
||||||
export const SESSION_LIFETIME_MS = 12 * 60 * 60_000;
|
export const SESSION_LIFETIME_MS = 12 * 60 * 60_000;
|
||||||
export const SESSION_IDLE_MS = 30 * 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');
|
const digest = (value: string) => createHash('sha256').update(value).digest('hex');
|
||||||
function database() {
|
function database() {
|
||||||
return db();
|
return db();
|
||||||
@@ -81,8 +84,8 @@ function displayName(value: unknown, fallback: string): string {
|
|||||||
return name.trim();
|
return name.trim();
|
||||||
}
|
}
|
||||||
function role(value: unknown): UserRole {
|
function role(value: unknown): UserRole {
|
||||||
if (!['admin', 'super_viewer', 'operator', 'viewer'].includes(value as string))
|
if (!['admin', 'super_viewer', 'operator', 'moderator', 'viewer'].includes(value as string))
|
||||||
throw new AuthError('Choose administrator, super viewer, operator or viewer.');
|
throw new AuthError('Choose administrator, super viewer, operator, moderator or viewer.');
|
||||||
return value as UserRole;
|
return value as UserRole;
|
||||||
}
|
}
|
||||||
export function recordAudit(
|
export function recordAudit(
|
||||||
@@ -106,21 +109,43 @@ export function listAuthAudit() {
|
|||||||
.all();
|
.all();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Persistent fixed windows are consumed before hashing, including failed setup. */
|
/** Persistent fixed windows are consumed before hashing, except for enabled admin roles. */
|
||||||
export function consumeAuthAttempt(clientAddress: string, account: string, kind = 'login'): void {
|
export function consumeAuthAttempt(
|
||||||
|
clientAddress: string,
|
||||||
|
account: string,
|
||||||
|
kind: 'login' | 'setup' | 'password' = 'login',
|
||||||
|
loginDevice?: string
|
||||||
|
): void {
|
||||||
|
// Resolve the current role from storage; setup never has an existing account exemption.
|
||||||
|
if (
|
||||||
|
kind !== 'setup' &&
|
||||||
|
database()
|
||||||
|
.prepare(
|
||||||
|
`SELECT 1 FROM auth_users WHERE ${kind === 'login' ? 'username' : 'id'}=?
|
||||||
|
AND disabled=0 AND role IN ('admin','super_viewer')`
|
||||||
|
)
|
||||||
|
.get(kind === 'login' ? account.toLowerCase() : account)
|
||||||
|
)
|
||||||
|
return;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const windowMs = 15 * 60_000;
|
const windowMs = 15 * 60_000;
|
||||||
const ipKey = digest(`${kind}:ip:${clientAddress}`);
|
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()
|
database()
|
||||||
.transaction(() => {
|
.transaction(() => {
|
||||||
database()
|
database()
|
||||||
.prepare('DELETE FROM auth_rate_limits WHERE window_start < ?')
|
.prepare('DELETE FROM auth_rate_limits WHERE window_start < ?')
|
||||||
.run(now - 24 * 60 * 60_000);
|
.run(now - 24 * 60 * 60_000);
|
||||||
for (const [key, limit] of [
|
for (const [key, limit] of limits) {
|
||||||
[ipKey, 30],
|
|
||||||
[accountKey, 10]
|
|
||||||
] as const) {
|
|
||||||
const previous = database()
|
const previous = database()
|
||||||
.prepare('SELECT window_start,attempts FROM auth_rate_limits WHERE key=?')
|
.prepare('SELECT window_start,attempts FROM auth_rate_limits WHERE key=?')
|
||||||
.get(key) as { window_start: number; attempts: number } | undefined;
|
.get(key) as { window_start: number; attempts: number } | undefined;
|
||||||
@@ -131,7 +156,7 @@ export function consumeAuthAttempt(clientAddress: string, account: string, kind
|
|||||||
)
|
)
|
||||||
throw new AuthError('Too many attempts. Try again in 15 minutes.', 429);
|
throw new AuthError('Too many attempts. Try again in 15 minutes.', 429);
|
||||||
}
|
}
|
||||||
for (const key of [ipKey, accountKey])
|
for (const [key] of limits)
|
||||||
database()
|
database()
|
||||||
.prepare(
|
.prepare(
|
||||||
`INSERT INTO auth_rate_limits(key,window_start,attempts) VALUES(?,?,1)
|
`INSERT INTO auth_rate_limits(key,window_start,attempts) VALUES(?,?,1)
|
||||||
@@ -173,6 +198,7 @@ function insertUser(input: {
|
|||||||
export interface AuthResult {
|
export interface AuthResult {
|
||||||
user: AuthUser;
|
user: AuthUser;
|
||||||
session: { token: string; expiresAt: number };
|
session: { token: string; expiresAt: number };
|
||||||
|
loginDevice?: { token: string; expiresAt: number };
|
||||||
}
|
}
|
||||||
export async function bootstrapAdmin(
|
export async function bootstrapAdmin(
|
||||||
input: Record<string, unknown>,
|
input: Record<string, unknown>,
|
||||||
@@ -214,11 +240,14 @@ export async function bootstrapAdmin(
|
|||||||
}
|
}
|
||||||
export async function authenticatePassword(
|
export async function authenticatePassword(
|
||||||
input: Record<string, unknown>,
|
input: Record<string, unknown>,
|
||||||
clientAddress: string
|
clientAddress: string,
|
||||||
|
loginDevice?: string
|
||||||
): Promise<AuthResult> {
|
): Promise<AuthResult> {
|
||||||
|
if (input.rememberMe !== undefined && typeof input.rememberMe !== 'boolean')
|
||||||
|
throw new AuthError('Keep me signed in must be true or false.');
|
||||||
const name =
|
const name =
|
||||||
typeof input.username === 'string' ? input.username.slice(0, 64).toLowerCase() : '';
|
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
|
const row = database().prepare('SELECT * FROM auth_users WHERE username=?').get(name) as
|
||||||
UserRow | undefined;
|
UserRow | undefined;
|
||||||
const valid = await verifyPassword(input.password, row?.password_hash ?? null);
|
const valid = await verifyPassword(input.password, row?.password_hash ?? null);
|
||||||
@@ -238,54 +267,94 @@ export async function authenticatePassword(
|
|||||||
.prepare('UPDATE auth_users SET last_login_at=? WHERE id=?')
|
.prepare('UPDATE auth_users SET last_login_at=? WHERE id=?')
|
||||||
.run(Date.now(), current.id);
|
.run(Date.now(), current.id);
|
||||||
recordAudit(current.id, 'login', current.id);
|
recordAudit(current.id, 'login', current.id);
|
||||||
return { user: getUser(current.id)!, session: createSession(current.id) };
|
return {
|
||||||
|
user: getUser(current.id)!,
|
||||||
|
session: createSession(current.id, input.rememberMe === true),
|
||||||
|
loginDevice: createLoginDevice(current, loginDevice)
|
||||||
|
};
|
||||||
})
|
})
|
||||||
.immediate();
|
.immediate();
|
||||||
if (!result) throw new AuthError('Invalid username or password.', 401);
|
if (!result) throw new AuthError('Invalid username or password.', 401);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createSession(userId: string): { token: string; expiresAt: number } {
|
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
|
||||||
|
): { token: string; expiresAt: number } {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const user = userRow(userId);
|
const user = userRow(userId);
|
||||||
if (!user || user.disabled) throw new AuthError('Account is unavailable.', 401);
|
if (!user || user.disabled) throw new AuthError('Account is unavailable.', 401);
|
||||||
const token = randomBytes(32).toString('base64url');
|
const token = randomBytes(32).toString('base64url');
|
||||||
const expiresAt = now + SESSION_LIFETIME_MS;
|
const expiresAt = now + (remembered ? REMEMBERED_SESSION_LIFETIME_MS : SESSION_LIFETIME_MS);
|
||||||
database()
|
database()
|
||||||
.prepare('DELETE FROM auth_sessions WHERE expires_at <= ? OR last_seen_at <= ?')
|
.prepare(
|
||||||
|
'DELETE FROM auth_sessions WHERE expires_at <= ? OR (remembered=0 AND last_seen_at <= ?)'
|
||||||
|
)
|
||||||
.run(now, now - SESSION_IDLE_MS);
|
.run(now, now - SESSION_IDLE_MS);
|
||||||
database()
|
database()
|
||||||
.prepare(
|
.prepare(
|
||||||
'INSERT INTO auth_sessions(token_hash,user_id,created_at,expires_at,last_seen_at) VALUES(?,?,?,?,?)'
|
'INSERT INTO auth_sessions(token_hash,user_id,created_at,expires_at,last_seen_at,remembered) VALUES(?,?,?,?,?,?)'
|
||||||
)
|
)
|
||||||
.run(digest(token), userId, now, expiresAt, now);
|
.run(digest(token), userId, now, expiresAt, now, Number(remembered));
|
||||||
return { token, expiresAt };
|
return { token, expiresAt };
|
||||||
}
|
}
|
||||||
export function readSession(
|
export function readSession(
|
||||||
token: string | undefined,
|
token: string | undefined,
|
||||||
touch = true
|
touch = true
|
||||||
): { user: AuthUser; tokenHash: string; expiresAt: number } | null {
|
): { user: AuthUser; tokenHash: string; expiresAt: number; remembered: boolean } | null {
|
||||||
if (!token || !/^[A-Za-z0-9_-]{43}$/.test(token)) return null;
|
if (!token || !/^[A-Za-z0-9_-]{43}$/.test(token)) return null;
|
||||||
return readSessionHash(digest(token), touch);
|
return readSessionHash(digest(token), touch);
|
||||||
}
|
}
|
||||||
export function readSessionHash(
|
export function readSessionHash(
|
||||||
tokenHash: string,
|
tokenHash: string,
|
||||||
touch = false
|
touch = false
|
||||||
): { user: AuthUser; tokenHash: string; expiresAt: number } | null {
|
): { user: AuthUser; tokenHash: string; expiresAt: number; remembered: boolean } | null {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const row = database()
|
const row = database()
|
||||||
.prepare(
|
.prepare(
|
||||||
`SELECT u.*,s.expires_at,s.last_seen_at FROM auth_sessions s JOIN auth_users u ON u.id=s.user_id
|
`SELECT u.*,s.expires_at,s.last_seen_at,s.remembered FROM auth_sessions s JOIN auth_users u ON u.id=s.user_id
|
||||||
WHERE s.token_hash=? AND s.expires_at>? AND s.last_seen_at>? AND u.disabled=0`
|
WHERE s.token_hash=? AND s.expires_at>? AND (s.remembered=1 OR s.last_seen_at>?) AND u.disabled=0`
|
||||||
)
|
)
|
||||||
.get(tokenHash, now, now - SESSION_IDLE_MS) as
|
.get(tokenHash, now, now - SESSION_IDLE_MS) as
|
||||||
(UserRow & { expires_at: number; last_seen_at: number }) | undefined;
|
(UserRow & { expires_at: number; last_seen_at: number; remembered: number }) | undefined;
|
||||||
if (!row) return null;
|
if (!row) return null;
|
||||||
if (touch && row.last_seen_at < now - 60_000)
|
if (touch && row.last_seen_at < now - 60_000)
|
||||||
database()
|
database()
|
||||||
.prepare('UPDATE auth_sessions SET last_seen_at=? WHERE token_hash=?')
|
.prepare('UPDATE auth_sessions SET last_seen_at=? WHERE token_hash=?')
|
||||||
.run(now, tokenHash);
|
.run(now, tokenHash);
|
||||||
return { user: present(row), tokenHash, expiresAt: row.expires_at };
|
return {
|
||||||
|
user: present(row),
|
||||||
|
tokenHash,
|
||||||
|
expiresAt: row.expires_at,
|
||||||
|
remembered: !!row.remembered
|
||||||
|
};
|
||||||
}
|
}
|
||||||
export function revokeSession(token: string | undefined): void {
|
export function revokeSession(token: string | undefined): void {
|
||||||
if (token)
|
if (token)
|
||||||
@@ -296,6 +365,7 @@ export function revokeUserSessions(actorId: string, userId: string): void {
|
|||||||
requireAdministrator(actorId);
|
requireAdministrator(actorId);
|
||||||
if (!userRow(userId)) throw new AuthError('User not found.', 404);
|
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_sessions WHERE user_id=?').run(userId);
|
||||||
|
database().prepare('DELETE FROM auth_login_devices WHERE user_id=?').run(userId);
|
||||||
recordAudit(actorId, 'sessions.revoked', userId);
|
recordAudit(actorId, 'sessions.revoked', userId);
|
||||||
})();
|
})();
|
||||||
}
|
}
|
||||||
@@ -304,11 +374,30 @@ function requireAdministrator(actorId: string) {
|
|||||||
if (!actor || actor.disabled || actor.role !== 'admin' || actor.must_change_password)
|
if (!actor || actor.disabled || actor.role !== 'admin' || actor.must_change_password)
|
||||||
throw new AuthError('Administrator access is required.', 403);
|
throw new AuthError('Administrator access is required.', 403);
|
||||||
}
|
}
|
||||||
|
type AccountSession = { tokenHash: string; clientAddress: string };
|
||||||
|
function requireSessionUser(userId: string, tokenHash: string) {
|
||||||
|
const session = readSessionHash(tokenHash);
|
||||||
|
if (!session || session.user.id !== userId) throw new AuthError('Sign in to continue.', 401);
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
async function confirmAccountPassword(
|
||||||
|
actorId: string,
|
||||||
|
currentPassword: unknown,
|
||||||
|
session: AccountSession
|
||||||
|
): Promise<void> {
|
||||||
|
if (!requireSessionUser(actorId, session.tokenHash).remembered) return;
|
||||||
|
consumeAuthAttempt(session.clientAddress, actorId, 'password');
|
||||||
|
if (!(await verifyPassword(currentPassword, userRow(actorId)?.password_hash ?? null)))
|
||||||
|
throw new AuthError('Enter your current password to confirm account changes.', 403);
|
||||||
|
requireSessionUser(actorId, session.tokenHash);
|
||||||
|
}
|
||||||
export async function createUser(
|
export async function createUser(
|
||||||
actorId: string,
|
actorId: string,
|
||||||
input: Record<string, unknown>
|
input: Record<string, unknown>,
|
||||||
|
session?: AccountSession
|
||||||
): Promise<AuthUser> {
|
): Promise<AuthUser> {
|
||||||
requireAdministrator(actorId);
|
requireAdministrator(actorId);
|
||||||
|
if (session) await confirmAccountPassword(actorId, input.currentPassword, session);
|
||||||
const name = username(input.username),
|
const name = username(input.username),
|
||||||
label = displayName(input.displayName, name),
|
label = displayName(input.displayName, name),
|
||||||
userRole = role(input.role);
|
userRole = role(input.role);
|
||||||
@@ -316,6 +405,7 @@ export async function createUser(
|
|||||||
return database()
|
return database()
|
||||||
.transaction(() => {
|
.transaction(() => {
|
||||||
requireAdministrator(actorId);
|
requireAdministrator(actorId);
|
||||||
|
if (session) requireSessionUser(actorId, session.tokenHash);
|
||||||
const user = insertUser({
|
const user = insertUser({
|
||||||
username: name,
|
username: name,
|
||||||
displayName: label,
|
displayName: label,
|
||||||
@@ -331,9 +421,11 @@ export async function createUser(
|
|||||||
export async function updateUser(
|
export async function updateUser(
|
||||||
actorId: string,
|
actorId: string,
|
||||||
id: string,
|
id: string,
|
||||||
input: Record<string, unknown>
|
input: Record<string, unknown>,
|
||||||
|
session?: AccountSession
|
||||||
): Promise<AuthUser> {
|
): Promise<AuthUser> {
|
||||||
requireAdministrator(actorId);
|
requireAdministrator(actorId);
|
||||||
|
if (session) await confirmAccountPassword(actorId, input.currentPassword, session);
|
||||||
if (input.disabled !== undefined && typeof input.disabled !== 'boolean')
|
if (input.disabled !== undefined && typeof input.disabled !== 'boolean')
|
||||||
throw new AuthError('Disabled must be true or false.');
|
throw new AuthError('Disabled must be true or false.');
|
||||||
if (input.password !== undefined && actorId === id)
|
if (input.password !== undefined && actorId === id)
|
||||||
@@ -342,6 +434,7 @@ export async function updateUser(
|
|||||||
return database()
|
return database()
|
||||||
.transaction(() => {
|
.transaction(() => {
|
||||||
requireAdministrator(actorId);
|
requireAdministrator(actorId);
|
||||||
|
if (session) requireSessionUser(actorId, session.tokenHash);
|
||||||
const current = userRow(id);
|
const current = userRow(id);
|
||||||
if (!current) throw new AuthError('User not found.', 404);
|
if (!current) throw new AuthError('User not found.', 404);
|
||||||
const nextRole = input.role === undefined ? current.role : role(input.role);
|
const nextRole = input.role === undefined ? current.role : role(input.role);
|
||||||
@@ -373,8 +466,10 @@ export async function updateUser(
|
|||||||
Date.now(),
|
Date.now(),
|
||||||
id
|
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_sessions WHERE user_id=?').run(id);
|
||||||
|
database().prepare('DELETE FROM auth_login_devices WHERE user_id=?').run(id);
|
||||||
|
}
|
||||||
recordAudit(actorId, 'user.updated', id, {
|
recordAudit(actorId, 'user.updated', id, {
|
||||||
role: nextRole,
|
role: nextRole,
|
||||||
disabled: !!disabled,
|
disabled: !!disabled,
|
||||||
@@ -387,7 +482,8 @@ export async function updateUser(
|
|||||||
export async function changePassword(
|
export async function changePassword(
|
||||||
userId: string,
|
userId: string,
|
||||||
currentPassword: unknown,
|
currentPassword: unknown,
|
||||||
newPassword: unknown
|
newPassword: unknown,
|
||||||
|
sessionHash?: string
|
||||||
): Promise<AuthResult> {
|
): Promise<AuthResult> {
|
||||||
const original = userRow(userId);
|
const original = userRow(userId);
|
||||||
if (
|
if (
|
||||||
@@ -400,6 +496,8 @@ export async function changePassword(
|
|||||||
const passwordHash = await hashPassword(newPassword);
|
const passwordHash = await hashPassword(newPassword);
|
||||||
return database()
|
return database()
|
||||||
.transaction(() => {
|
.transaction(() => {
|
||||||
|
const session =
|
||||||
|
sessionHash === undefined ? null : requireSessionUser(userId, sessionHash);
|
||||||
const current = userRow(userId);
|
const current = userRow(userId);
|
||||||
if (!current || current.disabled || current.password_hash !== original.password_hash)
|
if (!current || current.disabled || current.password_hash !== original.password_hash)
|
||||||
throw new AuthError(
|
throw new AuthError(
|
||||||
@@ -412,8 +510,12 @@ export async function changePassword(
|
|||||||
)
|
)
|
||||||
.run(passwordHash, Date.now(), userId);
|
.run(passwordHash, Date.now(), userId);
|
||||||
database().prepare('DELETE FROM auth_sessions WHERE user_id=?').run(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);
|
recordAudit(userId, 'password.changed', userId);
|
||||||
return { user: getUser(userId)!, session: createSession(userId) };
|
return {
|
||||||
|
user: getUser(userId)!,
|
||||||
|
session: createSession(userId, session?.remembered ?? false)
|
||||||
|
};
|
||||||
})
|
})
|
||||||
.immediate();
|
.immediate();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,26 @@ export function migrateAuthSchema(handle: Database.Database): void {
|
|||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function migrateRememberedSessions(handle: Database.Database): void {
|
||||||
|
const columns = handle.pragma('table_info(auth_sessions)') as { name: string }[];
|
||||||
|
if (columns.some((column) => column.name === 'remembered')) return;
|
||||||
|
handle.exec(`
|
||||||
|
ALTER TABLE auth_sessions ADD COLUMN remembered INTEGER NOT NULL DEFAULT 0
|
||||||
|
CHECK(remembered IN (0,1));
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
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. */
|
/** Run with foreign keys disabled by the migration runner before its transaction. */
|
||||||
export function migrateSuperViewerRole(handle: Database.Database): void {
|
export function migrateSuperViewerRole(handle: Database.Database): void {
|
||||||
const schema = handle
|
const schema = handle
|
||||||
@@ -52,3 +72,32 @@ export function migrateSuperViewerRole(handle: Database.Database): void {
|
|||||||
ALTER TABLE auth_users_super_viewer RENAME TO auth_users;
|
ALTER TABLE auth_users_super_viewer RENAME TO auth_users;
|
||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Run with foreign keys disabled by the migration runner before its transaction. */
|
||||||
|
export function migrateModeratorRole(handle: Database.Database): void {
|
||||||
|
const schema = handle
|
||||||
|
.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='auth_users'")
|
||||||
|
.get() as { sql: string };
|
||||||
|
if (schema.sql.includes("'moderator'")) return;
|
||||||
|
if (handle.pragma('foreign_keys', { simple: true }))
|
||||||
|
throw new Error('The auth role migration requires foreign keys to be disabled');
|
||||||
|
|
||||||
|
// Rebuild without renaming the parent: existing session references must stay
|
||||||
|
// attached to auth_users, including remembered sessions added after v17.
|
||||||
|
handle.exec(`
|
||||||
|
CREATE TABLE auth_users_moderator (
|
||||||
|
id TEXT PRIMARY KEY, username TEXT NOT NULL COLLATE NOCASE UNIQUE,
|
||||||
|
display_name TEXT NOT NULL, password_hash TEXT NOT NULL,
|
||||||
|
role TEXT NOT NULL CHECK(role IN ('admin','super_viewer','operator','moderator','viewer')),
|
||||||
|
disabled INTEGER NOT NULL DEFAULT 0, must_change_password INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, last_login_at INTEGER
|
||||||
|
);
|
||||||
|
INSERT INTO auth_users_moderator (
|
||||||
|
id,username,display_name,password_hash,role,disabled,must_change_password,
|
||||||
|
created_at,updated_at,last_login_at
|
||||||
|
) SELECT id,username,display_name,password_hash,role,disabled,must_change_password,
|
||||||
|
created_at,updated_at,last_login_at FROM auth_users;
|
||||||
|
DROP TABLE auth_users;
|
||||||
|
ALTER TABLE auth_users_moderator RENAME TO auth_users;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|||||||
@@ -120,6 +120,21 @@ export function planScaling(
|
|||||||
)
|
)
|
||||||
return result('wait', 'Waiting for fresh occupancy evidence.');
|
return result('wait', 'Waiting for fresh occupancy evidence.');
|
||||||
if (occupied + empty < desired) {
|
if (occupied + empty < desired) {
|
||||||
|
// Brief joins do not increase spare demand until their match reaches round two.
|
||||||
|
// Keep the ordinary desired count for reporting and surplus removal so a
|
||||||
|
// new match or missing telemetry cannot retire an existing spare.
|
||||||
|
const established = members.filter(
|
||||||
|
(m) => m.state === 'occupied' && m.round !== null && m.round >= 2
|
||||||
|
).length;
|
||||||
|
const growthTarget = Math.min(
|
||||||
|
policy.maximum,
|
||||||
|
Math.max(policy.minimum, established + policy.spares)
|
||||||
|
);
|
||||||
|
if (occupied + empty >= growthTarget)
|
||||||
|
return result(
|
||||||
|
'wait',
|
||||||
|
'Waiting for occupied lobbies to reach round 2 before adding a spare.'
|
||||||
|
);
|
||||||
if (snapshot.reservedNames.length >= policy.maximum)
|
if (snapshot.reservedNames.length >= policy.maximum)
|
||||||
return result('wait', 'Maximum lobby reservations reached.');
|
return result('wait', 'Maximum lobby reservations reached.');
|
||||||
if (!snapshot.keyPool.available)
|
if (!snapshot.keyPool.available)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { listServers, type ServerRow } from '../fleet/servers.js';
|
|||||||
import { observationReader } from '../fleet/status.js';
|
import { observationReader } from '../fleet/status.js';
|
||||||
import { parseServerName, zombieMaps } from '../../map-aliases.js';
|
import { parseServerName, zombieMaps } from '../../map-aliases.js';
|
||||||
import { freshStatusTimestamp } from '../../server-freshness.js';
|
import { freshStatusTimestamp } from '../../server-freshness.js';
|
||||||
|
import { currentRound } from '../../fleet-overview.js';
|
||||||
import type { ScalingMember, ScalingPolicy, ScalingSnapshot } from '../../autoscaling.js';
|
import type { ScalingMember, ScalingPolicy, ScalingSnapshot } from '../../autoscaling.js';
|
||||||
import { emptyObservation, type EmptyObservation } from './planner.js';
|
import { emptyObservation, type EmptyObservation } from './planner.js';
|
||||||
|
|
||||||
@@ -164,6 +165,12 @@ export function scalingSnapshot(
|
|||||||
identity,
|
identity,
|
||||||
state,
|
state,
|
||||||
players,
|
players,
|
||||||
|
round:
|
||||||
|
state === 'occupied' &&
|
||||||
|
observation.telemetry &&
|
||||||
|
Date.parse(observation.telemetry.receivedAt) <= now
|
||||||
|
? currentRound(observation, observation.telemetry, now)
|
||||||
|
: null,
|
||||||
observedAt,
|
observedAt,
|
||||||
source: source ?? null,
|
source: source ?? null,
|
||||||
emptySince: null,
|
emptySince: null,
|
||||||
@@ -181,6 +188,7 @@ export function scalingSnapshot(
|
|||||||
identity: 'reserved',
|
identity: 'reserved',
|
||||||
state: 'creating',
|
state: 'creating',
|
||||||
players: null,
|
players: null,
|
||||||
|
round: null,
|
||||||
observedAt: null,
|
observedAt: null,
|
||||||
source: null,
|
source: null,
|
||||||
emptySince: null,
|
emptySince: null,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { isPrivateConfigPath } from '../fleet/private-config.js';
|
||||||
import * as fs from 'node:fs/promises';
|
import * as fs from 'node:fs/promises';
|
||||||
import { join, posix, relative, resolve, sep } from 'node:path';
|
import { join, posix, relative, resolve, sep } from 'node:path';
|
||||||
import { config } from '../config.js';
|
import { config } from '../config.js';
|
||||||
@@ -85,6 +86,13 @@ export async function nativeFileManifest(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function readFile(path: string): Promise<Buffer> {
|
export async function readFile(path: string): Promise<Buffer> {
|
||||||
|
for (const root of [config.serversRoot, config.t6ServersRoot]) {
|
||||||
|
const [, ...parts] = relative(resolve(root), resolve(path)).split(sep);
|
||||||
|
if (isPrivateConfigPath(parts.join('/')))
|
||||||
|
throw new Error(
|
||||||
|
'Private bank configuration is not available through shared file reads.'
|
||||||
|
);
|
||||||
|
}
|
||||||
const target = remote(path);
|
const target = remote(path);
|
||||||
if (!target) return fs.readFile(path);
|
if (!target) return fs.readFile(path);
|
||||||
const result = await nativeRequest<{
|
const result = await nativeRequest<{
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ function num(name: string, fallback: number): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
|
publicUrl: env.EC_PUBLIC_URL ?? '',
|
||||||
serversRoot: req('SERVERS_ROOT', '/root/zombies/t5'),
|
serversRoot: req('SERVERS_ROOT', '/root/zombies/t5'),
|
||||||
t6ServersRoot:
|
t6ServersRoot:
|
||||||
env.T6_SERVERS_ROOT ?? join(dirname(req('SERVERS_ROOT', '/root/zombies/t5')), 't6'),
|
env.T6_SERVERS_ROOT ?? join(dirname(req('SERVERS_ROOT', '/root/zombies/t5')), 't6'),
|
||||||
|
|||||||
@@ -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);
|
||||||
|
`);
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { isPrivateConfigEvidencePath } from '../fleet/private-config.js';
|
||||||
import { createHash } from 'node:crypto';
|
import { createHash } from 'node:crypto';
|
||||||
import type Database from 'better-sqlite3';
|
import type Database from 'better-sqlite3';
|
||||||
import { db } from './index.js';
|
import { db } from './index.js';
|
||||||
@@ -22,7 +23,18 @@ export function putFileBlob(
|
|||||||
return sha256;
|
return sha256;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isPrivateFileBlob(sha256: string, handle: Database.Database = db()): boolean {
|
||||||
|
const rows = handle
|
||||||
|
.prepare(
|
||||||
|
`SELECT rel_path FROM deployment_files
|
||||||
|
WHERE content_before_hash=? OR content_after_hash=?`
|
||||||
|
)
|
||||||
|
.all(sha256, sha256) as { rel_path: string }[];
|
||||||
|
return rows.some((row) => isPrivateConfigEvidencePath(row.rel_path));
|
||||||
|
}
|
||||||
|
|
||||||
export function getFileBlob(sha256: string, handle: Database.Database = db()): Buffer {
|
export function getFileBlob(sha256: string, handle: Database.Database = db()): Buffer {
|
||||||
|
if (isPrivateFileBlob(sha256, handle)) throw new Error('Private file content is unavailable.');
|
||||||
const row = handle.prepare('SELECT content FROM file_blobs WHERE sha256=?').get(sha256) as
|
const row = handle.prepare('SELECT content FROM file_blobs WHERE sha256=?').get(sha256) as
|
||||||
{ content: Buffer } | undefined;
|
{ content: Buffer } | undefined;
|
||||||
if (!row) throw new Error('File content is unavailable or has expired.');
|
if (!row) throw new Error('File content is unavailable or has expired.');
|
||||||
@@ -58,6 +70,12 @@ export function packFileJson(
|
|||||||
if (Array.isArray(value))
|
if (Array.isArray(value))
|
||||||
return { value: value.map((item) => packFileJson(item, handle, hashes).value), hashes };
|
return { value: value.map((item) => packFileJson(item, handle, hashes).value), hashes };
|
||||||
if (!value || typeof value !== 'object') return { value, hashes };
|
if (!value || typeof value !== 'object') return { value, hashes };
|
||||||
|
if (
|
||||||
|
'path' in value &&
|
||||||
|
typeof value.path === 'string' &&
|
||||||
|
isPrivateConfigEvidencePath(value.path)
|
||||||
|
)
|
||||||
|
return { value: { path: value.path, redacted: true }, hashes };
|
||||||
const result: Record<string, unknown> = {};
|
const result: Record<string, unknown> = {};
|
||||||
for (const [key, item] of Object.entries(value)) {
|
for (const [key, item] of Object.entries(value)) {
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -14,12 +14,32 @@ import {
|
|||||||
} from '../reports/revisions.js';
|
} from '../reports/revisions.js';
|
||||||
import { enableIncrementalVacuum, retentionClocks } from './retention-clocks.js';
|
import { enableIncrementalVacuum, retentionClocks } from './retention-clocks.js';
|
||||||
import { migrateHostSchema } from '../hosts/schema.js';
|
import { migrateHostSchema } from '../hosts/schema.js';
|
||||||
import { migrateAuthSchema, migrateSuperViewerRole } from '../auth/schema.js';
|
import {
|
||||||
|
migrateAuthSchema,
|
||||||
|
migrateLoginDevices,
|
||||||
|
migrateRememberedSessions,
|
||||||
|
migrateSuperViewerRole,
|
||||||
|
migrateModeratorRole
|
||||||
|
} from '../auth/schema.js';
|
||||||
import { migrateGameFileSchema } from '../game-files/schema.js';
|
import { migrateGameFileSchema } from '../game-files/schema.js';
|
||||||
import { migrateLeaderboardReports } from './leaderboard-reports.js';
|
import { migrateLeaderboardReports } from './leaderboard-reports.js';
|
||||||
import { migratePlayerScoreTotal } from './player-score-total.js';
|
import { migratePlayerScoreTotal } from './player-score-total.js';
|
||||||
import { migrateBank } from './bank.js';
|
import { migrateBank } from './bank.js';
|
||||||
|
import { migrateAfk } from './afk.js';
|
||||||
|
import { migrateDiscord } from '../discord/schema.js';
|
||||||
|
import { migrateDiscordPublicationDefaults } from '../discord/publication-defaults.js';
|
||||||
import { migrateBankCredentials } from './bank-credentials.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';
|
import type { Migration } from './migration-runner.js';
|
||||||
|
|
||||||
// Append only: never renumber entries or change an applied migration to add an
|
// Append only: never renumber entries or change an applied migration to add an
|
||||||
@@ -113,7 +133,92 @@ export const migrations: readonly Migration[] = [
|
|||||||
run: migratePlayerScoreTotal
|
run: migratePlayerScoreTotal
|
||||||
},
|
},
|
||||||
{ version: 20, name: 'persistent player bank and transfer ledger', run: migrateBank },
|
{ version: 20, name: 'persistent player bank and transfer ledger', run: migrateBank },
|
||||||
{ version: 21, name: 'instance-owned bank credentials', run: migrateBankCredentials }
|
{ version: 21, name: 'instance-owned bank credentials', run: migrateBankCredentials },
|
||||||
|
{ version: 22, name: 'remembered browser sessions', run: migrateRememberedSessions },
|
||||||
|
{ version: 23, name: 'host temperature history', run: migrateTemperatures },
|
||||||
|
{
|
||||||
|
version: 24,
|
||||||
|
name: 'rebuild generation evidence boundary',
|
||||||
|
run: (handle) => {
|
||||||
|
if (
|
||||||
|
!(handle.pragma('table_info(native_instances)') as { name: string }[]).some(
|
||||||
|
(column) => column.name === 'generation_reconciled_at'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
handle.exec(
|
||||||
|
'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
|
||||||
|
},
|
||||||
|
{ version: 37, name: 'Discord boards and availability subscriptions', run: migrateDiscord },
|
||||||
|
{
|
||||||
|
version: 38,
|
||||||
|
name: 'Discord default server publication',
|
||||||
|
run: migrateDiscordPublicationDefaults
|
||||||
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
export const databaseVersion = migrations.length;
|
export const databaseVersion = migrations.length;
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import type { DiscordAdminState } from '../../discord.js';
|
||||||
|
import { fleetIdentity } from '../../fleet-overview.js';
|
||||||
|
import { listServers } from '../fleet/servers.js';
|
||||||
|
import { discordCredentialStatus } from './config.js';
|
||||||
|
import { publicFleet } from './projection.js';
|
||||||
|
import { discordStatus, getDiscordSettings } from './store.js';
|
||||||
|
|
||||||
|
export function discordAdminState(): DiscordAdminState {
|
||||||
|
const settings = getDiscordSettings();
|
||||||
|
const published = publicFleet(settings);
|
||||||
|
return {
|
||||||
|
settings,
|
||||||
|
credentials: discordCredentialStatus(),
|
||||||
|
status: discordStatus(),
|
||||||
|
servers: listServers().flatMap((server) => {
|
||||||
|
const identity = fleetIdentity(server);
|
||||||
|
if (identity.game !== 't5' && identity.game !== 't6') return [];
|
||||||
|
return [
|
||||||
|
{ name: server.name, game: identity.game, label: identity.title, map: identity.map }
|
||||||
|
];
|
||||||
|
}),
|
||||||
|
preview: {
|
||||||
|
t5: published.filter((server) => server.game === 't5'),
|
||||||
|
t6: published.filter((server) => server.game === 't6')
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import { leaderboardMetrics } from '../../leaderboard.js';
|
||||||
|
import { zombieMaps } from '../../map-aliases.js';
|
||||||
|
|
||||||
|
const games = [
|
||||||
|
{ name: 'Black Ops I', value: 't5' },
|
||||||
|
{ name: 'Black Ops II', value: 't6' }
|
||||||
|
];
|
||||||
|
export const discordMaps = (['t5', 't6'] as const).flatMap((game) =>
|
||||||
|
zombieMaps(game).map((map) => ({
|
||||||
|
name: `${game === 't5' ? 'BO1' : 'BO2'} · ${map.friendly}`,
|
||||||
|
value: [game, map.canonical, ...(game === 't6' ? [map.slug] : [])].join(':')
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
const gameOption = { type: 3, name: 'game', description: 'Game', choices: games };
|
||||||
|
const mapOption = { type: 3, name: 'map', description: 'Map and ruleset', choices: discordMaps };
|
||||||
|
const pageOption = {
|
||||||
|
type: 4,
|
||||||
|
name: 'page',
|
||||||
|
description: 'Page number',
|
||||||
|
min_value: 1,
|
||||||
|
max_value: 100001
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Guild commands only: no Gateway connection or privileged member/content intents. */
|
||||||
|
export const discordCommands = [
|
||||||
|
{
|
||||||
|
name: 'servers',
|
||||||
|
description: 'Browse Electric Cherry servers privately',
|
||||||
|
type: 1,
|
||||||
|
options: [
|
||||||
|
gameOption,
|
||||||
|
mapOption,
|
||||||
|
{
|
||||||
|
type: 3,
|
||||||
|
name: 'status',
|
||||||
|
description: 'Observed occupancy',
|
||||||
|
choices: [
|
||||||
|
{ name: 'All', value: 'all' },
|
||||||
|
{ name: 'Empty', value: 'empty' },
|
||||||
|
{ name: 'Space available', value: 'space' },
|
||||||
|
{ name: 'Full', value: 'full' },
|
||||||
|
{ name: 'In progress', value: 'in-progress' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
pageOption
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'server',
|
||||||
|
description: 'Show details of a published server',
|
||||||
|
type: 1,
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
type: 3,
|
||||||
|
name: 'name',
|
||||||
|
description: 'Published server name (or use a Details button)',
|
||||||
|
required: true,
|
||||||
|
max_length: 128
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'leaderboard',
|
||||||
|
description: 'View Electric Cherry completed-game rankings',
|
||||||
|
type: 1,
|
||||||
|
options: [
|
||||||
|
gameOption,
|
||||||
|
mapOption,
|
||||||
|
{
|
||||||
|
type: 3,
|
||||||
|
name: 'metric',
|
||||||
|
description: 'Ranking metric',
|
||||||
|
choices: leaderboardMetrics.map((metric) => ({
|
||||||
|
name: metric.label,
|
||||||
|
value: metric.key
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 4,
|
||||||
|
name: 'squad',
|
||||||
|
description: 'Number of players at match start',
|
||||||
|
min_value: 1,
|
||||||
|
max_value: 4
|
||||||
|
},
|
||||||
|
pageOption
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'watch',
|
||||||
|
description: 'Receive one private map availability notification',
|
||||||
|
type: 1,
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
type: 1,
|
||||||
|
name: 'add',
|
||||||
|
description: 'Watch a map for up to 24 hours',
|
||||||
|
options: [
|
||||||
|
{ ...gameOption, required: true },
|
||||||
|
{ ...mapOption, required: true },
|
||||||
|
{
|
||||||
|
type: 3,
|
||||||
|
name: 'condition',
|
||||||
|
description: 'Observed occupancy to watch for',
|
||||||
|
required: true,
|
||||||
|
choices: [
|
||||||
|
{ name: 'An empty lobby', value: 'empty' },
|
||||||
|
{ name: 'A lobby with space available', value: 'space' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{ type: 1, name: 'list', description: 'List your active watches' },
|
||||||
|
{
|
||||||
|
type: 1,
|
||||||
|
name: 'cancel',
|
||||||
|
description: 'Cancel one of your watches',
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
type: 3,
|
||||||
|
name: 'id',
|
||||||
|
description: 'Watch ID shown by /watch list',
|
||||||
|
required: true,
|
||||||
|
max_length: 100
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{ name: 'help', description: 'How to use the Electric Cherry companion', type: 1 }
|
||||||
|
];
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { discordSnowflake } from '../../discord.js';
|
||||||
|
|
||||||
|
/** Credentials belong to the deployment environment and never to browser settings. */
|
||||||
|
export function discordCredentials() {
|
||||||
|
return {
|
||||||
|
applicationId: (process.env.EC_DISCORD_APPLICATION_ID ?? '').trim(),
|
||||||
|
publicKey: (process.env.EC_DISCORD_PUBLIC_KEY ?? '').trim(),
|
||||||
|
botToken: (process.env.EC_DISCORD_BOT_TOKEN ?? '').trim()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function discordCredentialStatus() {
|
||||||
|
const credentials = discordCredentials();
|
||||||
|
return {
|
||||||
|
applicationId: credentials.applicationId,
|
||||||
|
publicKeyConfigured: /^[a-fA-F0-9]{64}$/.test(credentials.publicKey),
|
||||||
|
botTokenConfigured: !!credentials.botToken
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requireDiscordCredentials(): ReturnType<typeof discordCredentials> {
|
||||||
|
const credentials = discordCredentials();
|
||||||
|
if (
|
||||||
|
!discordSnowflake(credentials.applicationId) ||
|
||||||
|
!/^[a-fA-F0-9]{64}$/.test(credentials.publicKey) ||
|
||||||
|
!credentials.botToken
|
||||||
|
)
|
||||||
|
throw new Error('Configure the Discord application ID, public key and bot token first.');
|
||||||
|
return credentials;
|
||||||
|
}
|
||||||
@@ -0,0 +1,322 @@
|
|||||||
|
import {
|
||||||
|
discordSnowflake,
|
||||||
|
type DiscordGame,
|
||||||
|
type DiscordMessage,
|
||||||
|
type DiscordPublicServer
|
||||||
|
} from '../../discord.js';
|
||||||
|
import {
|
||||||
|
leaderboardMetrics,
|
||||||
|
type LeaderboardMetric,
|
||||||
|
type LeaderboardOptions,
|
||||||
|
type LeaderboardPlayerCount
|
||||||
|
} from '../../leaderboard.js';
|
||||||
|
import { leaderboardReport } from '../reports/index.js';
|
||||||
|
import { discordCredentials } from './config.js';
|
||||||
|
import { discordMaps } from './commands.js';
|
||||||
|
import { discordServerKey, discordText, publicFleet } from './projection.js';
|
||||||
|
import {
|
||||||
|
cancelSubscription,
|
||||||
|
getDiscordSettings,
|
||||||
|
listSubscriptions,
|
||||||
|
subscribeToMap
|
||||||
|
} from './store.js';
|
||||||
|
import {
|
||||||
|
LEADERBOARD_PAGE_SIZE,
|
||||||
|
privateMessage,
|
||||||
|
renderBrowser,
|
||||||
|
renderLeaderboard,
|
||||||
|
renderServer,
|
||||||
|
renderWatchChoice,
|
||||||
|
renderWatchMaps,
|
||||||
|
type BrowserFilter,
|
||||||
|
type BrowserStatus
|
||||||
|
} from './render.js';
|
||||||
|
|
||||||
|
interface DiscordOption {
|
||||||
|
type?: number;
|
||||||
|
name: string;
|
||||||
|
value?: string | number;
|
||||||
|
options?: DiscordOption[];
|
||||||
|
}
|
||||||
|
export interface DiscordInteraction {
|
||||||
|
type: number;
|
||||||
|
application_id?: string;
|
||||||
|
guild_id?: string;
|
||||||
|
member?: { user?: { id?: string } };
|
||||||
|
data?: { name?: string; options?: DiscordOption[]; custom_id?: string; values?: string[] };
|
||||||
|
}
|
||||||
|
export interface DiscordInteractionResponse {
|
||||||
|
type: 4;
|
||||||
|
data: DiscordMessage;
|
||||||
|
}
|
||||||
|
class InputError extends Error {}
|
||||||
|
const response = (data: DiscordMessage): DiscordInteractionResponse => ({ type: 4, data });
|
||||||
|
export const privateDiscordError = (message: string) =>
|
||||||
|
response(privateMessage(discordText(message, 400)));
|
||||||
|
const statuses: BrowserStatus[] = ['all', 'empty', 'space', 'full', 'in-progress'];
|
||||||
|
|
||||||
|
function optionValues(options: DiscordOption[] | undefined): Map<string, string | number> {
|
||||||
|
if (options !== undefined && (!Array.isArray(options) || options.length > 25))
|
||||||
|
throw new InputError('Invalid command options.');
|
||||||
|
const values = new Map<string, string | number>();
|
||||||
|
for (const option of options ?? []) {
|
||||||
|
if (
|
||||||
|
!option ||
|
||||||
|
typeof option.name !== 'string' ||
|
||||||
|
values.has(option.name) ||
|
||||||
|
!['string', 'number'].includes(typeof option.value)
|
||||||
|
)
|
||||||
|
throw new InputError('Invalid command options.');
|
||||||
|
values.set(option.name, option.value!);
|
||||||
|
}
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringOption(
|
||||||
|
values: Map<string, string | number>,
|
||||||
|
key: string,
|
||||||
|
limit = 128
|
||||||
|
): string | undefined {
|
||||||
|
const value = values.get(key);
|
||||||
|
if (value === undefined) return undefined;
|
||||||
|
if (typeof value !== 'string' || !value.trim() || value.length > limit)
|
||||||
|
throw new InputError(`Invalid ${key} option.`);
|
||||||
|
return value.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function scope(values: Map<string, string | number>): { game?: DiscordGame; map?: string } {
|
||||||
|
const game = stringOption(values, 'game');
|
||||||
|
const map = stringOption(values, 'map');
|
||||||
|
if (game && game !== 't5' && game !== 't6')
|
||||||
|
throw new InputError('Choose Black Ops I or Black Ops II.');
|
||||||
|
if (map && !discordMaps.some((entry) => entry.value === map))
|
||||||
|
throw new InputError('Choose a map from the command options.');
|
||||||
|
if (game && map && !map.startsWith(`${game}:`))
|
||||||
|
throw new InputError('Choose a map from the selected game.');
|
||||||
|
return { game: (game ?? map?.slice(0, 2)) as DiscordGame | undefined, map };
|
||||||
|
}
|
||||||
|
|
||||||
|
function page(values: Map<string, string | number>): number {
|
||||||
|
const value = values.get('page') ?? 1;
|
||||||
|
if (!Number.isSafeInteger(value) || Number(value) < 1 || Number(value) > 100001)
|
||||||
|
throw new InputError('Choose a page between 1 and 100001.');
|
||||||
|
return Number(value) - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function watchMap(
|
||||||
|
servers: DiscordPublicServer[],
|
||||||
|
key: string,
|
||||||
|
game?: DiscordGame
|
||||||
|
): DiscordPublicServer {
|
||||||
|
const found = servers.find(
|
||||||
|
(server) =>
|
||||||
|
(server.mapKey === key || discordServerKey(server.mapKey) === key) &&
|
||||||
|
(!game || server.game === game)
|
||||||
|
);
|
||||||
|
if (!found)
|
||||||
|
throw new InputError('That map is no longer published. Browse the current server list.');
|
||||||
|
if (!/^[A-Za-z0-9_.:-]{1,100}$/.test(found.mapKey))
|
||||||
|
throw new InputError('A map watch is unavailable for this reported map.');
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function leaderboard(values: Map<string, string | number>): Promise<DiscordMessage> {
|
||||||
|
const settings = getDiscordSettings();
|
||||||
|
if (!settings.leaderboardEnabled)
|
||||||
|
return privateMessage('Leaderboard publication is not enabled for this Discord server.');
|
||||||
|
const metric = stringOption(values, 'metric') ?? 'highestRound';
|
||||||
|
if (!leaderboardMetrics.some((entry) => entry.key === metric))
|
||||||
|
throw new InputError('Choose a ranking metric from the command options.');
|
||||||
|
const squad = values.get('squad');
|
||||||
|
if (
|
||||||
|
squad !== undefined &&
|
||||||
|
(!Number.isSafeInteger(squad) || Number(squad) < 1 || Number(squad) > 4)
|
||||||
|
)
|
||||||
|
throw new InputError('Squad size must be between 1 and 4.');
|
||||||
|
const query: LeaderboardOptions = {
|
||||||
|
...scope(values),
|
||||||
|
metric: metric as LeaderboardMetric,
|
||||||
|
players: squad as LeaderboardPlayerCount | undefined,
|
||||||
|
limit: LEADERBOARD_PAGE_SIZE,
|
||||||
|
offset: page(values) * LEADERBOARD_PAGE_SIZE
|
||||||
|
};
|
||||||
|
const report = await leaderboardReport(query);
|
||||||
|
// Publishing may have been disabled while the report worker was running.
|
||||||
|
const current = getDiscordSettings();
|
||||||
|
if (!current.enabled || current.guildId !== settings.guildId || !current.leaderboardEnabled)
|
||||||
|
return privateMessage('Leaderboard publication is no longer enabled.');
|
||||||
|
return renderLeaderboard(report, query);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addWatch(
|
||||||
|
servers: DiscordPublicServer[],
|
||||||
|
key: string,
|
||||||
|
mode: string | undefined,
|
||||||
|
guildId: string,
|
||||||
|
userId: string,
|
||||||
|
game?: DiscordGame
|
||||||
|
): DiscordMessage {
|
||||||
|
if (mode !== 'empty' && mode !== 'space')
|
||||||
|
throw new InputError('Choose an empty-lobby or space-available watch.');
|
||||||
|
const server = watchMap(servers, key, game);
|
||||||
|
let watch: { id: string; expiresAt: number };
|
||||||
|
try {
|
||||||
|
watch = subscribeToMap({ guildId, userId, game: server.game, mapKey: server.mapKey, mode });
|
||||||
|
} catch (error) {
|
||||||
|
if (
|
||||||
|
error instanceof Error &&
|
||||||
|
(error.message.startsWith('You already have 10') ||
|
||||||
|
error.message.startsWith('Map watches are currently full'))
|
||||||
|
)
|
||||||
|
throw new InputError(error.message);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return privateMessage(
|
||||||
|
`Watching **${discordText(server.map)}** for ${mode === 'empty' ? 'an empty lobby' : 'space available'}.\nYou will receive one DM after stable observations. Enable DMs from this server to receive it. This does not guarantee match admission.\nExpires <t:${Math.floor(watch.expiresAt / 1000)}:R> · Cancel with /watch cancel\nWatch ID: ${discordText(watch.id)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseBrowserId(id: string): BrowserFilter {
|
||||||
|
const match =
|
||||||
|
/^ec:b:(all|t5|t6):(all|empty|space|full|in-progress):(\d{1,6}):([A-Za-z0-9_-]{0,70})(?::(?:prev|next))?$/.exec(
|
||||||
|
id
|
||||||
|
);
|
||||||
|
if (!match) throw new InputError('This browser control is invalid. Run /servers again.');
|
||||||
|
const map = Buffer.from(match[4], 'base64url').toString();
|
||||||
|
if (map && !discordMaps.some((entry) => entry.value === map))
|
||||||
|
throw new InputError('This map filter is unavailable. Run /servers again.');
|
||||||
|
return {
|
||||||
|
game: match[1] === 'all' ? undefined : (match[1] as DiscordGame),
|
||||||
|
status: match[2] as BrowserStatus,
|
||||||
|
page: Number(match[3]),
|
||||||
|
map: map || undefined
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** All responses are private new messages; components never mutate the shared board. */
|
||||||
|
export async function handleDiscordInteraction(
|
||||||
|
interaction: DiscordInteraction
|
||||||
|
): Promise<DiscordInteractionResponse> {
|
||||||
|
const userId = interaction.member?.user?.id;
|
||||||
|
if (
|
||||||
|
!discordSnowflake(userId) ||
|
||||||
|
!discordSnowflake(interaction.guild_id) ||
|
||||||
|
interaction.application_id !== discordCredentials().applicationId
|
||||||
|
)
|
||||||
|
return privateDiscordError('This interaction is not available here.');
|
||||||
|
const settings = getDiscordSettings();
|
||||||
|
if (!settings.enabled || settings.guildId !== interaction.guild_id)
|
||||||
|
return privateDiscordError('Electric Cherry is not enabled for this Discord server.');
|
||||||
|
if (interaction.type !== 2 && interaction.type !== 3)
|
||||||
|
return privateDiscordError('This interaction is not supported.');
|
||||||
|
const data = interaction.data;
|
||||||
|
if (!data || typeof data !== 'object')
|
||||||
|
return privateDiscordError('This interaction is invalid.');
|
||||||
|
try {
|
||||||
|
if (interaction.type === 2 && data.name === 'leaderboard')
|
||||||
|
return response(await leaderboard(optionValues(data.options)));
|
||||||
|
if (interaction.type === 2 && data.name === 'help')
|
||||||
|
return response(
|
||||||
|
privateMessage(
|
||||||
|
'## 🍒 Electric Cherry\n/servers — browse live servers privately\n/server — inspect a published server\n/leaderboard — completed-game rankings, when enabled\n/watch add — one DM when a map has an empty lobby or space\n/watch list · /watch cancel — manage your watches\n\nWatches expire after 24 hours. Observed spaces do not guarantee that a match accepts new players.'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const servers = publicFleet(settings);
|
||||||
|
if (interaction.type === 3) {
|
||||||
|
const id = data.custom_id;
|
||||||
|
if (typeof id !== 'string' || id.length > 100)
|
||||||
|
throw new InputError('This control is invalid.');
|
||||||
|
if (id.startsWith('ec:b:')) return response(renderBrowser(servers, parseBrowserId(id)));
|
||||||
|
if (/^ec:d:[a-f0-9]{20}$/.test(id)) {
|
||||||
|
const server = servers.find(
|
||||||
|
(entry) => discordServerKey(entry.name) === id.slice(5)
|
||||||
|
);
|
||||||
|
if (!server) throw new InputError('This server is no longer published.');
|
||||||
|
return response(renderServer(server));
|
||||||
|
}
|
||||||
|
if (/^ec:maps:(t5|t6)$/.test(id))
|
||||||
|
return response(renderWatchMaps(servers, id.slice(8) as DiscordGame));
|
||||||
|
if (
|
||||||
|
id === 'ec:map' &&
|
||||||
|
Array.isArray(data.values) &&
|
||||||
|
data.values.length === 1 &&
|
||||||
|
typeof data.values[0] === 'string'
|
||||||
|
)
|
||||||
|
return response(renderWatchChoice(watchMap(servers, data.values[0])));
|
||||||
|
const watch = /^ec:w:(empty|space):([a-f0-9]{20})$/.exec(id);
|
||||||
|
if (watch)
|
||||||
|
return response(addWatch(servers, watch[2], watch[1], settings.guildId, userId));
|
||||||
|
if (/^ec:leader:(t5|t6)$/.test(id))
|
||||||
|
return response(await leaderboard(new Map([['game', id.slice(10)]])));
|
||||||
|
throw new InputError(
|
||||||
|
'This control is no longer available. Run /help for current commands.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (data.name === 'watch') {
|
||||||
|
if (!Array.isArray(data.options) || data.options.length !== 1)
|
||||||
|
throw new InputError('Choose watch add, list, or cancel.');
|
||||||
|
const command = data.options[0];
|
||||||
|
const values = optionValues(command.options);
|
||||||
|
if (command.name === 'add') {
|
||||||
|
const selected = scope(values);
|
||||||
|
if (!selected.game || !selected.map) throw new InputError('Choose a game and map.');
|
||||||
|
return response(
|
||||||
|
addWatch(
|
||||||
|
servers,
|
||||||
|
selected.map,
|
||||||
|
stringOption(values, 'condition'),
|
||||||
|
settings.guildId,
|
||||||
|
userId,
|
||||||
|
selected.game
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (command.name === 'cancel') {
|
||||||
|
const id = stringOption(values, 'id', 100);
|
||||||
|
if (!id) throw new InputError('Provide the watch ID shown by /watch list.');
|
||||||
|
return response(
|
||||||
|
privateMessage(
|
||||||
|
cancelSubscription(id, settings.guildId, userId)
|
||||||
|
? 'Watch cancelled.'
|
||||||
|
: 'No active watch with that ID belongs to you.'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (command.name === 'list') {
|
||||||
|
const watches = listSubscriptions(userId, settings.guildId);
|
||||||
|
return response(
|
||||||
|
privateMessage(
|
||||||
|
`## Your map watches\n${watches.length ? watches.map((watch) => `**${discordText(servers.find((server) => server.mapKey === watch.mapKey)?.map ?? discordMaps.find((map) => map.value === watch.mapKey)?.name ?? 'Unpublished map', 60)}** · ${watch.mode === 'empty' ? 'Empty lobby' : 'Space available'} · Expires <t:${Math.floor(watch.expiresAt / 1000)}:R>\nID: ${discordText(watch.id)}`).join('\n\n') : 'No active watches. Use /watch add to create one.'}`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw new InputError('Choose watch add, list, or cancel.');
|
||||||
|
}
|
||||||
|
const values = optionValues(data.options);
|
||||||
|
if (data.name === 'servers') {
|
||||||
|
const status = stringOption(values, 'status') ?? 'all';
|
||||||
|
if (!statuses.includes(status as BrowserStatus))
|
||||||
|
throw new InputError('Choose an occupancy filter from the command options.');
|
||||||
|
return response(
|
||||||
|
renderBrowser(servers, {
|
||||||
|
...scope(values),
|
||||||
|
status: status as BrowserStatus,
|
||||||
|
page: page(values)
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (data.name === 'server') {
|
||||||
|
const name = stringOption(values, 'name');
|
||||||
|
const server = servers.find((entry) => entry.name === name || entry.label === name);
|
||||||
|
if (!server)
|
||||||
|
throw new InputError(
|
||||||
|
'That server is not published. Use /servers and click Details.'
|
||||||
|
);
|
||||||
|
return response(renderServer(server));
|
||||||
|
}
|
||||||
|
throw new InputError('This command is not supported. Run /help for available commands.');
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof InputError) return privateDiscordError(error.message);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import type { DiscordPublicServer, DiscordSettings } from '../../discord.js';
|
||||||
|
import {
|
||||||
|
currentRound,
|
||||||
|
fleetIdentity,
|
||||||
|
freshServer,
|
||||||
|
type FleetServer
|
||||||
|
} from '../../fleet-overview.js';
|
||||||
|
import { currentCapacity } from '../../server-capacity.js';
|
||||||
|
import { parseServerName, selectedZombieMap, zombieMaps } from '../../map-aliases.js';
|
||||||
|
import { fleetSnapshot } from '../fleet/snapshot.js';
|
||||||
|
|
||||||
|
type SourceServer = FleetServer & {
|
||||||
|
network?: { publicAddress: string | null; publicPort: number | null } | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DEFAULT_DISCORD_LOBBY_CAPACITY = 4;
|
||||||
|
|
||||||
|
/** IDs stay short enough for Discord components, even for long registry names. */
|
||||||
|
export function discordServerKey(name: string): string {
|
||||||
|
return createHash('sha256').update(name).digest('hex').slice(0, 20);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rendering never interprets a game nickname as Markdown, a link, or a mention. */
|
||||||
|
export function discordText(value: string, limit = 160): string {
|
||||||
|
return (
|
||||||
|
value
|
||||||
|
.replace(/[\r\n\t]/g, ' ')
|
||||||
|
// eslint-disable-next-line no-control-regex -- Strip untrusted game-name control characters.
|
||||||
|
.replace(/[\u0000-\u001f\u007f\u202a-\u202e\u2066-\u2069]/g, '')
|
||||||
|
.slice(0, limit)
|
||||||
|
.replace(/@/g, '@\u200b')
|
||||||
|
.replace(/</g, '‹')
|
||||||
|
.replace(/>/g, '›')
|
||||||
|
.replace(/([\\`*_{}[\]()~|])/g, '\\$1')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapIdentity(server: SourceServer, game: 't5' | 't6') {
|
||||||
|
const parsed = parseServerName(server.name);
|
||||||
|
const map = server.last_map ?? server.map ?? parsed?.mapCanonical ?? 'unknown';
|
||||||
|
const slug = server.map === map ? (server.map_slug ?? parsed?.mapSlug) : undefined;
|
||||||
|
const candidates = zombieMaps(game).filter((entry) => entry.canonical === map);
|
||||||
|
const selected =
|
||||||
|
game === 't6' && candidates.length > 1 && !slug
|
||||||
|
? undefined
|
||||||
|
: selectedZombieMap(game, map, slug);
|
||||||
|
return {
|
||||||
|
map: selected?.friendly ?? fleetIdentity(server).map ?? 'Unknown map',
|
||||||
|
mapKey: [
|
||||||
|
game,
|
||||||
|
map,
|
||||||
|
...(game === 't6' ? [selected?.slug ?? slug ?? 'unspecified'] : [])
|
||||||
|
].join(':')
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function publicConnection(server: SourceServer): string | null {
|
||||||
|
const address = server.network?.publicAddress?.trim();
|
||||||
|
const port = server.network?.publicPort;
|
||||||
|
// Never fall back to agent, RCON, bind, or inferred host addresses.
|
||||||
|
if (!address || !/^[A-Za-z0-9.:[\]-]{1,253}$/.test(address)) return null;
|
||||||
|
if (!Number.isSafeInteger(port) || port! < 1 || port! > 65535) return null;
|
||||||
|
const host = address.includes(':') && !address.startsWith('[') ? `[${address}]` : address;
|
||||||
|
return `${host}:${port}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Explicit field projection is the boundary between private fleet data and Discord. */
|
||||||
|
export function projectFleet(
|
||||||
|
servers: readonly SourceServer[],
|
||||||
|
settings: DiscordSettings,
|
||||||
|
now = Date.now()
|
||||||
|
): DiscordPublicServer[] {
|
||||||
|
const excluded = new Set(settings.excludedServers);
|
||||||
|
return servers
|
||||||
|
.flatMap((server): DiscordPublicServer[] => {
|
||||||
|
if (excluded.has(server.name)) return [];
|
||||||
|
const identity = fleetIdentity(server);
|
||||||
|
if (identity.game !== 't5' && identity.game !== 't6') return [];
|
||||||
|
const game = identity.game;
|
||||||
|
const fresh =
|
||||||
|
freshServer(server, now) &&
|
||||||
|
(!server.telemetry_after ||
|
||||||
|
Date.parse(server.last_seen_at ?? '') > Date.parse(server.telemetry_after));
|
||||||
|
const players =
|
||||||
|
fresh && Number.isSafeInteger(server.last_players) && server.last_players! >= 0
|
||||||
|
? server.last_players
|
||||||
|
: null;
|
||||||
|
// These lobbies have four slots when the live status omits the limit.
|
||||||
|
// Occupancy still needs fresh, current-process evidence below.
|
||||||
|
const capacity = currentCapacity(server, now) ?? DEFAULT_DISCORD_LOBBY_CAPACITY;
|
||||||
|
const state: DiscordPublicServer['state'] =
|
||||||
|
server.health === 'starting' || server.health === 'provisioning'
|
||||||
|
? 'starting'
|
||||||
|
: server.health === 'offline'
|
||||||
|
? 'offline'
|
||||||
|
: server.health === 'stale' || (server.health === 'online' && !fresh)
|
||||||
|
? 'stale'
|
||||||
|
: players === null
|
||||||
|
? 'unknown'
|
||||||
|
: players === 0
|
||||||
|
? 'empty'
|
||||||
|
: capacity !== null && players >= capacity
|
||||||
|
? 'full'
|
||||||
|
: 'in-progress';
|
||||||
|
const observed = Date.parse(server.last_seen_at ?? '');
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
name: server.name,
|
||||||
|
game,
|
||||||
|
label: identity.title,
|
||||||
|
...mapIdentity(server, game),
|
||||||
|
state,
|
||||||
|
players,
|
||||||
|
capacity,
|
||||||
|
round: players === null ? null : currentRound(server, server.telemetry, now),
|
||||||
|
observedAt:
|
||||||
|
Number.isFinite(observed) && observed <= now + 1000
|
||||||
|
? new Date(observed).toISOString()
|
||||||
|
: null,
|
||||||
|
playerNames:
|
||||||
|
settings.showPlayers && fresh && players !== null
|
||||||
|
? (server.roster ?? []).slice(0, players).map((player) => player.name)
|
||||||
|
: [],
|
||||||
|
connection: settings.showAddresses ? publicConnection(server) : null
|
||||||
|
}
|
||||||
|
];
|
||||||
|
})
|
||||||
|
.sort(
|
||||||
|
(a, b) =>
|
||||||
|
a.game.localeCompare(b.game) ||
|
||||||
|
a.label.localeCompare(b.label) ||
|
||||||
|
a.name.localeCompare(b.name)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function publicFleet(settings: DiscordSettings, now = Date.now()): DiscordPublicServer[] {
|
||||||
|
return projectFleet(fleetSnapshot().servers, settings, now);
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import type Database from 'better-sqlite3';
|
||||||
|
|
||||||
|
/** Replace the old opt-in default while preserving an existing nonempty selection. */
|
||||||
|
export function migrateDiscordPublicationDefaults(handle: Database.Database): void {
|
||||||
|
const row = handle.prepare('SELECT value FROM discord_configuration WHERE id=1').get() as
|
||||||
|
{ value: string } | undefined;
|
||||||
|
if (!row) return;
|
||||||
|
const settings = JSON.parse(row.value) as Record<string, unknown>;
|
||||||
|
if ('excludedServers' in settings) return;
|
||||||
|
const published = new Set(settings.publishedServers as string[]);
|
||||||
|
settings.excludedServers = published.size
|
||||||
|
? (
|
||||||
|
handle
|
||||||
|
.prepare('SELECT name FROM servers WHERE destroyed_at IS NULL ORDER BY name')
|
||||||
|
.all() as { name: string }[]
|
||||||
|
)
|
||||||
|
.map((server) => server.name)
|
||||||
|
.filter((name) => !published.has(name))
|
||||||
|
: [];
|
||||||
|
delete settings.publishedServers;
|
||||||
|
handle
|
||||||
|
.prepare('UPDATE discord_configuration SET value=? WHERE id=1')
|
||||||
|
.run(JSON.stringify(settings));
|
||||||
|
}
|
||||||
@@ -0,0 +1,285 @@
|
|||||||
|
import type { DiscordGame, DiscordMessage, DiscordPublicServer } from '../../discord.js';
|
||||||
|
import type { LeaderboardOptions, LeaderboardResponse } from '../../leaderboard.js';
|
||||||
|
import { leaderboardMetrics } from '../../leaderboard.js';
|
||||||
|
import {
|
||||||
|
efficiencyRequirement,
|
||||||
|
formatEfficiencyValue,
|
||||||
|
isEfficiencyMetric
|
||||||
|
} from '../../leaderboard-efficiency.js';
|
||||||
|
import { MIN_RECORDED_ROUND } from '../../match-history.js';
|
||||||
|
import { gameTitle } from '../../ui/server-labels.js';
|
||||||
|
import { DEFAULT_DISCORD_LOBBY_CAPACITY, discordServerKey, discordText } from './projection.js';
|
||||||
|
|
||||||
|
export const BOARD_PAGE_SIZE = 8;
|
||||||
|
export const BROWSER_PAGE_SIZE = 5;
|
||||||
|
export const LEADERBOARD_PAGE_SIZE = 10;
|
||||||
|
const COMPONENTS_V2 = 1 << 15;
|
||||||
|
export const EPHEMERAL = 1 << 6;
|
||||||
|
type Component = Record<string, unknown>;
|
||||||
|
export type BrowserStatus = 'all' | 'empty' | 'space' | 'full' | 'in-progress';
|
||||||
|
export interface BrowserFilter {
|
||||||
|
game?: DiscordGame;
|
||||||
|
map?: string;
|
||||||
|
status?: BrowserStatus;
|
||||||
|
page?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = (content: string): Component => ({ type: 10, content });
|
||||||
|
const button = (label: string, custom_id: string, disabled = false): Component => ({
|
||||||
|
type: 2,
|
||||||
|
style: 2,
|
||||||
|
label,
|
||||||
|
custom_id,
|
||||||
|
disabled
|
||||||
|
});
|
||||||
|
const row = (...components: Component[]): Component => ({ type: 1, components });
|
||||||
|
const container = (components: Component[]): Component => ({
|
||||||
|
type: 17,
|
||||||
|
accent_color: 0xd94868,
|
||||||
|
components
|
||||||
|
});
|
||||||
|
|
||||||
|
export function discordMessage(components: Component[], privateReply = true): DiscordMessage {
|
||||||
|
return {
|
||||||
|
flags: COMPONENTS_V2 | (privateReply ? EPHEMERAL : 0),
|
||||||
|
components,
|
||||||
|
allowed_mentions: { parse: [] }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function privateMessage(message: string): DiscordMessage {
|
||||||
|
return discordMessage([text(message)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const states: Record<DiscordPublicServer['state'], string> = {
|
||||||
|
empty: '🟢 Empty lobby',
|
||||||
|
'in-progress': '🔵 In progress',
|
||||||
|
full: '🟠 Full',
|
||||||
|
starting: '🟡 Starting',
|
||||||
|
offline: '⚫ Offline',
|
||||||
|
stale: '🟡 Stale observation',
|
||||||
|
unknown: '⚪ Status unknown'
|
||||||
|
};
|
||||||
|
|
||||||
|
function summary(server: DiscordPublicServer): string {
|
||||||
|
const count =
|
||||||
|
server.players === null
|
||||||
|
? 'Players unknown'
|
||||||
|
: `${server.players}/${server.capacity ?? DEFAULT_DISCORD_LOBBY_CAPACITY} players`;
|
||||||
|
return `${count}${server.round === null ? '' : ` · Round ${server.round}`}\n${states[server.state]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function observed(server: DiscordPublicServer): string {
|
||||||
|
return server.observedAt
|
||||||
|
? `Observed <t:${Math.floor(Date.parse(server.observedAt) / 1000)}:R>`
|
||||||
|
: 'No observation available';
|
||||||
|
}
|
||||||
|
|
||||||
|
function serverSection(server: DiscordPublicServer): Component {
|
||||||
|
const map = server.label.startsWith(server.map) ? '' : `Map: ${discordText(server.map, 50)}\n`;
|
||||||
|
return {
|
||||||
|
type: 9,
|
||||||
|
components: [
|
||||||
|
text(
|
||||||
|
`**${discordText(server.label, 90)}**\n${map}${summary(server)}\n-# ${observed(server)}`
|
||||||
|
)
|
||||||
|
],
|
||||||
|
accessory: button('Details', `ec:d:${discordServerKey(server.name)}`)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Each page has <= 32 total components and comfortably fits Discord's 4,000 text limit. */
|
||||||
|
export function renderBoard(
|
||||||
|
game: DiscordGame,
|
||||||
|
servers: readonly DiscordPublicServer[]
|
||||||
|
): DiscordMessage[] {
|
||||||
|
const selected = servers.filter((server) => server.game === game);
|
||||||
|
const pages = Math.max(1, Math.ceil(selected.length / BOARD_PAGE_SIZE));
|
||||||
|
return Array.from({ length: pages }, (_, page) => {
|
||||||
|
const players = selected.reduce((sum, server) => sum + (server.players ?? 0), 0);
|
||||||
|
const sections = selected
|
||||||
|
.slice(page * BOARD_PAGE_SIZE, (page + 1) * BOARD_PAGE_SIZE)
|
||||||
|
.map(serverSection);
|
||||||
|
return discordMessage(
|
||||||
|
[
|
||||||
|
container([
|
||||||
|
text(
|
||||||
|
`## 🍒 Electric Cherry · ${gameTitle(game)}\n${selected.length} servers · ${players} observed players${pages > 1 ? ` · Page ${page + 1}/${pages}` : ''}`
|
||||||
|
),
|
||||||
|
...(sections.length
|
||||||
|
? sections
|
||||||
|
: [text('No servers are published for this game yet.')]),
|
||||||
|
text(
|
||||||
|
'-# Available spaces do not establish whether an in-progress match accepts players.'
|
||||||
|
),
|
||||||
|
row(
|
||||||
|
button('Browse servers', browserId({ game })),
|
||||||
|
button('Watch a map', `ec:maps:${game}`),
|
||||||
|
button('Leaderboards', `ec:leader:${game}`)
|
||||||
|
)
|
||||||
|
])
|
||||||
|
],
|
||||||
|
false
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function browserId(filter: BrowserFilter): string {
|
||||||
|
return `ec:b:${filter.game ?? 'all'}:${filter.status ?? 'all'}:${filter.page ?? 0}:${Buffer.from(filter.map ?? '').toString('base64url')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterServers(
|
||||||
|
servers: readonly DiscordPublicServer[],
|
||||||
|
filter: BrowserFilter
|
||||||
|
): DiscordPublicServer[] {
|
||||||
|
return servers.filter(
|
||||||
|
(server) =>
|
||||||
|
(!filter.game || server.game === filter.game) &&
|
||||||
|
(!filter.map || server.mapKey === filter.map) &&
|
||||||
|
(!filter.status ||
|
||||||
|
filter.status === 'all' ||
|
||||||
|
(filter.status === 'space'
|
||||||
|
? server.players !== null &&
|
||||||
|
server.capacity !== null &&
|
||||||
|
server.players < server.capacity
|
||||||
|
: server.state === filter.status))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderBrowser(
|
||||||
|
servers: readonly DiscordPublicServer[],
|
||||||
|
filter: BrowserFilter = {}
|
||||||
|
): DiscordMessage {
|
||||||
|
const selected = filterServers(servers, filter);
|
||||||
|
const pages = Math.max(1, Math.ceil(selected.length / BROWSER_PAGE_SIZE));
|
||||||
|
const page = Math.max(0, Math.min(filter.page ?? 0, pages - 1));
|
||||||
|
const current = { ...filter, page };
|
||||||
|
return discordMessage([
|
||||||
|
container([
|
||||||
|
text(
|
||||||
|
`## Browse servers\n${filter.game ? gameTitle(filter.game) : 'All games'} · ${selected.length} matching servers · Page ${page + 1}/${pages}`
|
||||||
|
),
|
||||||
|
...selected
|
||||||
|
.slice(page * BROWSER_PAGE_SIZE, (page + 1) * BROWSER_PAGE_SIZE)
|
||||||
|
.map(serverSection),
|
||||||
|
...(selected.length ? [] : [text('No servers match these filters.')]),
|
||||||
|
row(
|
||||||
|
button('All', browserId({ ...current, status: 'all', page: 0 })),
|
||||||
|
button('Empty', browserId({ ...current, status: 'empty', page: 0 })),
|
||||||
|
button('Space available', browserId({ ...current, status: 'space', page: 0 })),
|
||||||
|
button('Full', browserId({ ...current, status: 'full', page: 0 }))
|
||||||
|
),
|
||||||
|
row(
|
||||||
|
button(
|
||||||
|
'Previous',
|
||||||
|
`${browserId({ ...current, page: Math.max(0, page - 1) })}:prev`,
|
||||||
|
page === 0
|
||||||
|
),
|
||||||
|
button(
|
||||||
|
'Next',
|
||||||
|
`${browserId({ ...current, page: page + 1 })}:next`,
|
||||||
|
page + 1 >= pages
|
||||||
|
)
|
||||||
|
),
|
||||||
|
text(
|
||||||
|
'-# Your filters are private. Use /servers for game and map filters. Space available describes occupancy only.'
|
||||||
|
)
|
||||||
|
])
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderServer(server: DiscordPublicServer): DiscordMessage {
|
||||||
|
return discordMessage([
|
||||||
|
container([
|
||||||
|
text(
|
||||||
|
`## ${discordText(server.label, 100)}\n${gameTitle(server.game)} · ${discordText(server.map, 100)}\n${summary(server)}\n${observed(server)}`
|
||||||
|
),
|
||||||
|
...(server.playerNames.length
|
||||||
|
? [
|
||||||
|
text(
|
||||||
|
`**Players**\n${server.playerNames
|
||||||
|
.slice(0, 16)
|
||||||
|
.map((name) => discordText(name, 60))
|
||||||
|
.join(' · ')}`
|
||||||
|
)
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
text(
|
||||||
|
`**Connection**\n${server.connection ? `Public endpoint: ${discordText(server.connection, 270)}\n` : ''}${server.game === 't5' ? 'Open the Black Ops I Plutonium server browser and find this lobby.' : server.connection ? 'Open the Black Ops II Plutonium console and enter connect followed by the public endpoint above.' : 'Open the Black Ops II Plutonium server browser and find this lobby.'}\nMatch admission is not reported by Electric Cherry.`
|
||||||
|
),
|
||||||
|
row(
|
||||||
|
button('Watch for empty', `ec:w:empty:${discordServerKey(server.mapKey)}`),
|
||||||
|
button('Watch for space', `ec:w:space:${discordServerKey(server.mapKey)}`),
|
||||||
|
button('Browse servers', browserId({ game: server.game }))
|
||||||
|
)
|
||||||
|
])
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderWatchMaps(
|
||||||
|
servers: readonly DiscordPublicServer[],
|
||||||
|
game?: DiscordGame
|
||||||
|
): DiscordMessage {
|
||||||
|
const maps = [
|
||||||
|
...new Map(
|
||||||
|
servers
|
||||||
|
.filter((server) => !game || server.game === game)
|
||||||
|
.map((server) => [server.mapKey, server])
|
||||||
|
).values()
|
||||||
|
];
|
||||||
|
if (!maps.length) return privateMessage('No published maps are available to watch.');
|
||||||
|
return discordMessage([
|
||||||
|
text(
|
||||||
|
'## Watch a map\nChoose a map, then choose empty or space available. Watches send one DM, expire after 24 hours, and can be cancelled with /watch.'
|
||||||
|
),
|
||||||
|
row({
|
||||||
|
type: 3,
|
||||||
|
custom_id: 'ec:map',
|
||||||
|
placeholder: 'Choose a published map',
|
||||||
|
options: maps.slice(0, 25).map((server) => ({
|
||||||
|
label: `${gameTitle(server.game, true)} · ${discordText(server.map, 40)}`.slice(
|
||||||
|
0,
|
||||||
|
100
|
||||||
|
),
|
||||||
|
value: discordServerKey(server.mapKey)
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderWatchChoice(server: DiscordPublicServer): DiscordMessage {
|
||||||
|
return discordMessage([
|
||||||
|
text(
|
||||||
|
`## Watch ${discordText(server.map)}\nEmpty means zero observed players. Space means fewer players than the lobby capacity. Neither guarantees that a match accepts new players.\nOne DM · Expires in 24 hours`
|
||||||
|
),
|
||||||
|
row(
|
||||||
|
button('Empty lobby', `ec:w:empty:${discordServerKey(server.mapKey)}`),
|
||||||
|
button('Space available', `ec:w:space:${discordServerKey(server.mapKey)}`)
|
||||||
|
)
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderLeaderboard(
|
||||||
|
report: LeaderboardResponse,
|
||||||
|
query: LeaderboardOptions
|
||||||
|
): DiscordMessage {
|
||||||
|
const metric = report.metric;
|
||||||
|
const label = leaderboardMetrics.find((item) => item.key === metric)!.label;
|
||||||
|
const map = report.maps.find((item) => item.key === query.map)?.name;
|
||||||
|
const items = report.entries.map((entry) => {
|
||||||
|
const value = isEfficiencyMetric(metric)
|
||||||
|
? formatEfficiencyValue(entry[metric], metric)
|
||||||
|
: entry[metric].toLocaleString('en-US');
|
||||||
|
return `**${entry.rank}.** ${discordText(entry.name, 60)} — **${value}**`;
|
||||||
|
});
|
||||||
|
return discordMessage([
|
||||||
|
container([
|
||||||
|
text(
|
||||||
|
`## ${label}\n${query.game ? gameTitle(query.game) : 'All games'}${map ? ` · ${discordText(map)}` : ''}${query.players ? ` · ${query.players}-player starts` : ''}\n${items.length ? items.join('\n') : 'No qualifying records in this scope.'}`
|
||||||
|
),
|
||||||
|
text(
|
||||||
|
`-# Completed matches with player records reaching round ${MIN_RECORDED_ROUND} or later.${isEfficiencyMetric(metric) ? ` ${efficiencyRequirement(metric)}.` : ''}\n-# Page ${Math.floor(report.offset / LEADERBOARD_PAGE_SIZE) + 1} · ${report.total} ranked players. Use /leaderboard to change filters or page.`
|
||||||
|
)
|
||||||
|
])
|
||||||
|
]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import type Database from 'better-sqlite3';
|
||||||
|
|
||||||
|
export function migrateDiscord(handle: Database.Database): void {
|
||||||
|
handle.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS discord_configuration (
|
||||||
|
id INTEGER PRIMARY KEY CHECK (id=1), value TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS discord_health (
|
||||||
|
id INTEGER PRIMARY KEY CHECK (id=1), last_attempt_at INTEGER,
|
||||||
|
last_success_at INTEGER, last_error TEXT
|
||||||
|
);
|
||||||
|
INSERT OR IGNORE INTO discord_health(id) VALUES(1);
|
||||||
|
CREATE TABLE IF NOT EXISTS discord_boards (
|
||||||
|
id TEXT PRIMARY KEY, game TEXT NOT NULL CHECK(game IN ('t5','t6')),
|
||||||
|
guild_id TEXT NOT NULL, channel_id TEXT NOT NULL, page INTEGER NOT NULL, message_id TEXT,
|
||||||
|
content_hash TEXT, verified_at INTEGER NOT NULL DEFAULT 0, generation INTEGER NOT NULL DEFAULT 0,
|
||||||
|
UNIQUE(game,guild_id,channel_id,page)
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS discord_subscriptions (
|
||||||
|
id TEXT PRIMARY KEY, guild_id TEXT NOT NULL, user_id TEXT NOT NULL,
|
||||||
|
game TEXT NOT NULL CHECK(game IN ('t5','t6')), map_key TEXT NOT NULL,
|
||||||
|
mode TEXT NOT NULL CHECK(mode IN ('empty','space')),
|
||||||
|
state TEXT NOT NULL CHECK(state IN ('active','queued','delivered','failed','cancelled','expired')),
|
||||||
|
created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL,
|
||||||
|
candidate TEXT, candidate_at INTEGER, error TEXT
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS discord_subscription_active
|
||||||
|
ON discord_subscriptions(guild_id,user_id,game,map_key,mode)
|
||||||
|
WHERE state IN ('active','queued');
|
||||||
|
CREATE INDEX IF NOT EXISTS discord_subscription_expiry ON discord_subscriptions(expires_at);
|
||||||
|
CREATE TABLE IF NOT EXISTS discord_outbox (
|
||||||
|
id TEXT PRIMARY KEY, kind TEXT NOT NULL CHECK(kind IN ('board','delete','alert')),
|
||||||
|
target_id TEXT NOT NULL UNIQUE, payload TEXT NOT NULL, content_hash TEXT,
|
||||||
|
channel_id TEXT, attempts INTEGER NOT NULL DEFAULT 0,
|
||||||
|
next_at INTEGER, created_at INTEGER NOT NULL, error TEXT
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS discord_outbox_due ON discord_outbox(next_at);
|
||||||
|
CREATE TABLE IF NOT EXISTS discord_interactions (
|
||||||
|
id TEXT PRIMARY KEY, expires_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS discord_interactions_expiry ON discord_interactions(expires_at);
|
||||||
|
`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,503 @@
|
|||||||
|
import { createHash, randomUUID } from 'node:crypto';
|
||||||
|
import type Database from 'better-sqlite3';
|
||||||
|
import {
|
||||||
|
defaultDiscordSettings,
|
||||||
|
discordSnowflake,
|
||||||
|
validateDiscordSettings,
|
||||||
|
type DiscordDeliveryStatus,
|
||||||
|
type DiscordGame,
|
||||||
|
type DiscordMessage,
|
||||||
|
type DiscordSettings
|
||||||
|
} from '../../discord.js';
|
||||||
|
import { db } from '../db/index.js';
|
||||||
|
|
||||||
|
export interface DiscordSubscription {
|
||||||
|
id: string;
|
||||||
|
guildId: string;
|
||||||
|
userId: string;
|
||||||
|
game: DiscordGame;
|
||||||
|
mapKey: string;
|
||||||
|
mode: 'empty' | 'space';
|
||||||
|
state: 'active' | 'queued' | 'delivered' | 'failed' | 'cancelled' | 'expired';
|
||||||
|
expiresAt: number;
|
||||||
|
candidate: string | null;
|
||||||
|
candidateAt: number | null;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
export interface DiscordBoard {
|
||||||
|
id: string;
|
||||||
|
game: DiscordGame;
|
||||||
|
guildId: string;
|
||||||
|
channelId: string;
|
||||||
|
page: number;
|
||||||
|
messageId: string | null;
|
||||||
|
contentHash: string | null;
|
||||||
|
verifiedAt: number;
|
||||||
|
generation: number;
|
||||||
|
}
|
||||||
|
export interface DiscordJob {
|
||||||
|
id: string;
|
||||||
|
kind: 'board' | 'delete' | 'alert';
|
||||||
|
targetId: string;
|
||||||
|
payload: DiscordMessage;
|
||||||
|
contentHash: string | null;
|
||||||
|
channelId: string | null;
|
||||||
|
attempts: number;
|
||||||
|
createdAt: number;
|
||||||
|
}
|
||||||
|
export interface MapSubscriptionInput {
|
||||||
|
guildId: string;
|
||||||
|
userId: string;
|
||||||
|
game: DiscordGame;
|
||||||
|
mapKey: string;
|
||||||
|
mode: 'empty' | 'space';
|
||||||
|
}
|
||||||
|
const subscriptionColumns = `id,guild_id AS guildId,user_id AS userId,game,map_key AS mapKey,
|
||||||
|
mode,state,expires_at AS expiresAt,candidate,candidate_at AS candidateAt,error`;
|
||||||
|
const boardColumns = `id,game,guild_id AS guildId,channel_id AS channelId,page,message_id AS messageId,
|
||||||
|
content_hash AS contentHash,verified_at AS verifiedAt,generation`;
|
||||||
|
|
||||||
|
/** Synchronous, bounded transactions; delivery never holds a database transaction open. */
|
||||||
|
export class DiscordStore {
|
||||||
|
constructor(
|
||||||
|
readonly handle: Database.Database,
|
||||||
|
readonly now: () => number = Date.now
|
||||||
|
) {}
|
||||||
|
|
||||||
|
settings(): DiscordSettings {
|
||||||
|
const row = this.handle
|
||||||
|
.prepare('SELECT value FROM discord_configuration WHERE id=1')
|
||||||
|
.get() as { value: string } | undefined;
|
||||||
|
return row
|
||||||
|
? validateDiscordSettings(JSON.parse(row.value))
|
||||||
|
: structuredClone(defaultDiscordSettings);
|
||||||
|
}
|
||||||
|
|
||||||
|
saveSettings(input: unknown): DiscordSettings {
|
||||||
|
const settings = validateDiscordSettings(input);
|
||||||
|
this.handle.transaction(() => {
|
||||||
|
this.handle
|
||||||
|
.prepare(
|
||||||
|
'INSERT INTO discord_configuration(id,value) VALUES(1,?) ON CONFLICT(id) DO UPDATE SET value=excluded.value'
|
||||||
|
)
|
||||||
|
.run(JSON.stringify(settings));
|
||||||
|
this.handle
|
||||||
|
.prepare(
|
||||||
|
"UPDATE discord_subscriptions SET state='cancelled' WHERE state IN ('active','queued') AND (?=0 OR guild_id!=?)"
|
||||||
|
)
|
||||||
|
.run(Number(settings.enabled), settings.guildId);
|
||||||
|
this.handle
|
||||||
|
.prepare(
|
||||||
|
"DELETE FROM discord_outbox WHERE kind='alert' AND target_id IN (SELECT id FROM discord_subscriptions WHERE state!='queued')"
|
||||||
|
)
|
||||||
|
.run();
|
||||||
|
this.retryFailed();
|
||||||
|
})();
|
||||||
|
return settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
status(): DiscordDeliveryStatus {
|
||||||
|
const row = this.handle.prepare('SELECT * FROM discord_health WHERE id=1').get() as {
|
||||||
|
last_attempt_at: number | null;
|
||||||
|
last_success_at: number | null;
|
||||||
|
last_error: string | null;
|
||||||
|
};
|
||||||
|
const iso = (value: number | null) =>
|
||||||
|
value === null ? null : new Date(value).toISOString();
|
||||||
|
return {
|
||||||
|
lastAttemptAt: iso(row.last_attempt_at),
|
||||||
|
lastSuccessAt: iso(row.last_success_at),
|
||||||
|
lastError:
|
||||||
|
row.last_error ??
|
||||||
|
(
|
||||||
|
this.handle
|
||||||
|
.prepare(
|
||||||
|
"SELECT error FROM discord_subscriptions WHERE state='failed' AND expires_at>? ORDER BY created_at DESC LIMIT 1"
|
||||||
|
)
|
||||||
|
.get(this.now()) as { error: string } | undefined
|
||||||
|
)?.error ??
|
||||||
|
null,
|
||||||
|
boards: this.handle
|
||||||
|
.prepare(
|
||||||
|
'SELECT game,channel_id AS channelId,COUNT(message_id) AS messageCount FROM discord_boards GROUP BY game,channel_id'
|
||||||
|
)
|
||||||
|
.all() as DiscordDeliveryStatus['boards'],
|
||||||
|
activeSubscriptions: this.count(
|
||||||
|
"SELECT COUNT(*) AS n FROM discord_subscriptions WHERE state IN ('active','queued') AND expires_at>?",
|
||||||
|
this.now()
|
||||||
|
),
|
||||||
|
pendingNotifications: this.count(
|
||||||
|
"SELECT COUNT(*) AS n FROM discord_outbox WHERE kind='alert' AND next_at IS NOT NULL"
|
||||||
|
)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
subscribe(input: MapSubscriptionInput): { id: string; expiresAt: number } {
|
||||||
|
if (
|
||||||
|
!discordSnowflake(input.userId) ||
|
||||||
|
!discordSnowflake(input.guildId) ||
|
||||||
|
!['t5', 't6'].includes(input.game) ||
|
||||||
|
!['empty', 'space'].includes(input.mode) ||
|
||||||
|
typeof input.mapKey !== 'string' ||
|
||||||
|
!/^[a-zA-Z0-9_.:-]{1,100}$/.test(input.mapKey)
|
||||||
|
)
|
||||||
|
throw new Error('Invalid map watch.');
|
||||||
|
const settings = this.settings();
|
||||||
|
if (!settings.enabled || settings.guildId !== input.guildId)
|
||||||
|
throw new Error('Discord map watches are not enabled for this server.');
|
||||||
|
return this.handle.transaction(() => {
|
||||||
|
this.expireSubscriptions();
|
||||||
|
const existing = this.handle
|
||||||
|
.prepare(
|
||||||
|
"SELECT id,expires_at AS expiresAt FROM discord_subscriptions WHERE guild_id=? AND user_id=? AND game=? AND map_key=? AND mode=? AND state IN ('active','queued')"
|
||||||
|
)
|
||||||
|
.get(input.guildId, input.userId, input.game, input.mapKey, input.mode) as
|
||||||
|
{ id: string; expiresAt: number } | undefined;
|
||||||
|
if (existing) return existing;
|
||||||
|
if (
|
||||||
|
this.count(
|
||||||
|
"SELECT COUNT(*) AS n FROM discord_subscriptions WHERE user_id=? AND state IN ('active','queued')",
|
||||||
|
input.userId
|
||||||
|
) >= 10
|
||||||
|
)
|
||||||
|
throw new Error(
|
||||||
|
'You already have 10 active map watches. Cancel one before adding another.'
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
this.count(
|
||||||
|
"SELECT COUNT(*) AS n FROM discord_subscriptions WHERE state IN ('active','queued')"
|
||||||
|
) >= 1000
|
||||||
|
)
|
||||||
|
throw new Error('Map watches are currently full. Please try later.');
|
||||||
|
const id = randomUUID();
|
||||||
|
const expiresAt = this.now() + 24 * 60 * 60_000;
|
||||||
|
this.handle
|
||||||
|
.prepare(
|
||||||
|
"INSERT INTO discord_subscriptions(id,guild_id,user_id,game,map_key,mode,state,created_at,expires_at) VALUES(?,?,?,?,?,?,'active',?,?)"
|
||||||
|
)
|
||||||
|
.run(
|
||||||
|
id,
|
||||||
|
input.guildId,
|
||||||
|
input.userId,
|
||||||
|
input.game,
|
||||||
|
input.mapKey,
|
||||||
|
input.mode,
|
||||||
|
this.now(),
|
||||||
|
expiresAt
|
||||||
|
);
|
||||||
|
return { id, expiresAt };
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
|
subscriptions(userId?: string, guildId?: string): DiscordSubscription[] {
|
||||||
|
this.expireSubscriptions();
|
||||||
|
return this.handle
|
||||||
|
.prepare(
|
||||||
|
`SELECT ${subscriptionColumns} FROM discord_subscriptions WHERE state IN ('active','queued') ${userId ? 'AND user_id=? AND guild_id=?' : ''} ORDER BY created_at LIMIT 1000`
|
||||||
|
)
|
||||||
|
.all(...(userId ? [userId, guildId ?? ''] : [])) as DiscordSubscription[];
|
||||||
|
}
|
||||||
|
|
||||||
|
subscription(id: string): DiscordSubscription | undefined {
|
||||||
|
return this.handle
|
||||||
|
.prepare(`SELECT ${subscriptionColumns} FROM discord_subscriptions WHERE id=?`)
|
||||||
|
.get(id) as DiscordSubscription | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
cancel(id: string, guildId: string, userId: string): boolean {
|
||||||
|
return this.handle.transaction(() => {
|
||||||
|
const updated = this.handle
|
||||||
|
.prepare(
|
||||||
|
"UPDATE discord_subscriptions SET state='cancelled' WHERE id=? AND guild_id=? AND user_id=? AND state IN ('active','queued')"
|
||||||
|
)
|
||||||
|
.run(id, guildId, userId).changes;
|
||||||
|
if (updated)
|
||||||
|
this.handle
|
||||||
|
.prepare("DELETE FROM discord_outbox WHERE kind='alert' AND target_id=?")
|
||||||
|
.run(id);
|
||||||
|
return updated > 0;
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
|
expireSubscriptions(): void {
|
||||||
|
this.handle
|
||||||
|
.prepare(
|
||||||
|
"UPDATE discord_subscriptions SET state='expired' WHERE state IN ('active','queued') AND expires_at<=?"
|
||||||
|
)
|
||||||
|
.run(this.now());
|
||||||
|
this.handle
|
||||||
|
.prepare(
|
||||||
|
"DELETE FROM discord_outbox WHERE kind='alert' AND target_id IN (SELECT id FROM discord_subscriptions WHERE state NOT IN ('active','queued'))"
|
||||||
|
)
|
||||||
|
.run();
|
||||||
|
this.handle
|
||||||
|
.prepare(
|
||||||
|
"DELETE FROM discord_subscriptions WHERE state NOT IN ('active','queued') AND (expires_at<? OR id IN (SELECT id FROM discord_subscriptions ORDER BY created_at DESC LIMIT -1 OFFSET 10000))"
|
||||||
|
)
|
||||||
|
.run(this.now() - 7 * 86400_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
candidate(id: string, candidate: string | null): void {
|
||||||
|
this.handle
|
||||||
|
.prepare(
|
||||||
|
"UPDATE discord_subscriptions SET candidate=?,candidate_at=CASE WHEN candidate IS ? THEN candidate_at ELSE ? END WHERE id=? AND state='active'"
|
||||||
|
)
|
||||||
|
.run(candidate, candidate, candidate ? this.now() : null, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
queueAlert(subscription: DiscordSubscription, message: DiscordMessage): void {
|
||||||
|
this.handle.transaction(() => {
|
||||||
|
const current = this.subscription(subscription.id);
|
||||||
|
if (!current || current.state !== 'active' || current.expiresAt <= this.now()) return;
|
||||||
|
this.enqueue('alert', subscription.id, message, null);
|
||||||
|
this.handle
|
||||||
|
.prepare("UPDATE discord_subscriptions SET state='queued' WHERE id=?")
|
||||||
|
.run(subscription.id);
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
|
boards(): DiscordBoard[] {
|
||||||
|
return this.handle
|
||||||
|
.prepare(`SELECT ${boardColumns} FROM discord_boards ORDER BY game,channel_id,page`)
|
||||||
|
.all() as DiscordBoard[];
|
||||||
|
}
|
||||||
|
|
||||||
|
board(id: string): DiscordBoard | undefined {
|
||||||
|
return this.handle
|
||||||
|
.prepare(`SELECT ${boardColumns} FROM discord_boards WHERE id=?`)
|
||||||
|
.get(id) as DiscordBoard | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
reconcileBoards(
|
||||||
|
desired: {
|
||||||
|
game: DiscordGame;
|
||||||
|
guildId: string;
|
||||||
|
channelId: string;
|
||||||
|
messages: DiscordMessage[];
|
||||||
|
}[]
|
||||||
|
): void {
|
||||||
|
this.handle.transaction(() => {
|
||||||
|
const retained = new Set<string>();
|
||||||
|
for (const group of desired) {
|
||||||
|
for (const [page, message] of group.messages.entries()) {
|
||||||
|
let board = this.boards().find(
|
||||||
|
(item) =>
|
||||||
|
item.game === group.game &&
|
||||||
|
item.guildId === group.guildId &&
|
||||||
|
item.channelId === group.channelId &&
|
||||||
|
item.page === page
|
||||||
|
);
|
||||||
|
if (!board) {
|
||||||
|
const id = randomUUID();
|
||||||
|
this.handle
|
||||||
|
.prepare(
|
||||||
|
'INSERT INTO discord_boards(id,game,guild_id,channel_id,page) VALUES(?,?,?,?,?)'
|
||||||
|
)
|
||||||
|
.run(id, group.game, group.guildId, group.channelId, page);
|
||||||
|
board = this.board(id)!;
|
||||||
|
}
|
||||||
|
retained.add(board.id);
|
||||||
|
this.handle
|
||||||
|
.prepare("DELETE FROM discord_outbox WHERE target_id=? AND kind='delete'")
|
||||||
|
.run(board.id);
|
||||||
|
const hash = createHash('sha256').update(JSON.stringify(message)).digest('hex');
|
||||||
|
if (board.contentHash !== hash || !board.messageId)
|
||||||
|
this.enqueue('board', board.id, message, hash);
|
||||||
|
else
|
||||||
|
this.handle
|
||||||
|
.prepare(
|
||||||
|
"DELETE FROM discord_outbox WHERE target_id=? AND kind='board'"
|
||||||
|
)
|
||||||
|
.run(board.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const board of this.boards()) {
|
||||||
|
if (!retained.has(board.id)) {
|
||||||
|
if (board.messageId)
|
||||||
|
this.enqueue('delete', board.id, { allowed_mentions: { parse: [] } }, null);
|
||||||
|
else this.removeBoard(board.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
|
private enqueue(
|
||||||
|
kind: DiscordJob['kind'],
|
||||||
|
targetId: string,
|
||||||
|
payload: DiscordMessage,
|
||||||
|
hash: string | null
|
||||||
|
): void {
|
||||||
|
const existing = this.handle
|
||||||
|
.prepare('SELECT kind FROM discord_outbox WHERE target_id=?')
|
||||||
|
.get(targetId) as { kind: string } | undefined;
|
||||||
|
if (!existing && this.count('SELECT COUNT(*) AS n FROM discord_outbox') >= 500)
|
||||||
|
throw new Error('Discord delivery queue is full.');
|
||||||
|
this.handle
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO discord_outbox(id,kind,target_id,payload,content_hash,next_at,created_at) VALUES(?,?,?,?,?,?,?)
|
||||||
|
ON CONFLICT(target_id) DO UPDATE SET kind=excluded.kind,payload=excluded.payload,content_hash=excluded.content_hash,
|
||||||
|
attempts=CASE WHEN kind!=excluded.kind THEN 0 ELSE attempts END,
|
||||||
|
next_at=CASE WHEN kind!=excluded.kind THEN excluded.next_at ELSE next_at END`
|
||||||
|
)
|
||||||
|
.run(
|
||||||
|
randomUUID(),
|
||||||
|
kind,
|
||||||
|
targetId,
|
||||||
|
JSON.stringify(payload),
|
||||||
|
hash,
|
||||||
|
this.now(),
|
||||||
|
this.now()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
jobs(limit = 10): DiscordJob[] {
|
||||||
|
return (
|
||||||
|
this.handle
|
||||||
|
.prepare(
|
||||||
|
`SELECT id,kind,target_id AS targetId,payload,content_hash AS contentHash,
|
||||||
|
channel_id AS channelId,attempts,created_at AS createdAt FROM discord_outbox WHERE next_at<=? ORDER BY CASE kind WHEN 'delete' THEN 0 WHEN 'alert' THEN 1 ELSE 2 END,created_at LIMIT ?`
|
||||||
|
)
|
||||||
|
.all(this.now(), limit) as (Omit<DiscordJob, 'payload'> & { payload: string })[]
|
||||||
|
).map((row) => ({ ...row, payload: JSON.parse(row.payload) }));
|
||||||
|
}
|
||||||
|
|
||||||
|
cacheDm(jobId: string, channelId: string): void {
|
||||||
|
this.handle
|
||||||
|
.prepare('UPDATE discord_outbox SET channel_id=? WHERE id=?')
|
||||||
|
.run(channelId, jobId);
|
||||||
|
}
|
||||||
|
|
||||||
|
dropJob(id: string): void {
|
||||||
|
this.handle.prepare('DELETE FROM discord_outbox WHERE id=?').run(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
resumeAlert(id: string): void {
|
||||||
|
this.handle.transaction(() => {
|
||||||
|
this.handle
|
||||||
|
.prepare("DELETE FROM discord_outbox WHERE kind='alert' AND target_id=?")
|
||||||
|
.run(id);
|
||||||
|
this.handle
|
||||||
|
.prepare(
|
||||||
|
"UPDATE discord_subscriptions SET state='active',candidate=NULL,candidate_at=NULL WHERE id=? AND state='queued'"
|
||||||
|
)
|
||||||
|
.run(id);
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
|
delivered(job: DiscordJob, messageId?: string): void {
|
||||||
|
this.handle.transaction(() => {
|
||||||
|
if (job.kind === 'board')
|
||||||
|
this.handle
|
||||||
|
.prepare(
|
||||||
|
'UPDATE discord_boards SET message_id=?,content_hash=?,verified_at=? WHERE id=?'
|
||||||
|
)
|
||||||
|
.run(messageId, job.contentHash, this.now(), job.targetId);
|
||||||
|
if (job.kind === 'delete')
|
||||||
|
this.handle.prepare('DELETE FROM discord_boards WHERE id=?').run(job.targetId);
|
||||||
|
if (job.kind === 'alert')
|
||||||
|
this.handle
|
||||||
|
.prepare(
|
||||||
|
"UPDATE discord_subscriptions SET state='delivered',error=NULL WHERE id=? AND state='queued'"
|
||||||
|
)
|
||||||
|
.run(job.targetId);
|
||||||
|
// A newer desired board can replace a payload while an HTTP call is in flight.
|
||||||
|
this.handle
|
||||||
|
.prepare('DELETE FROM discord_outbox WHERE id=? AND payload=?')
|
||||||
|
.run(job.id, JSON.stringify(job.payload));
|
||||||
|
this.handle
|
||||||
|
.prepare(
|
||||||
|
'UPDATE discord_health SET last_success_at=?,last_error=CASE WHEN EXISTS(SELECT 1 FROM discord_outbox WHERE error IS NOT NULL) THEN last_error ELSE NULL END WHERE id=1'
|
||||||
|
)
|
||||||
|
.run(this.now());
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
|
removeBoard(id: string): void {
|
||||||
|
this.handle.prepare('DELETE FROM discord_outbox WHERE target_id=?').run(id);
|
||||||
|
this.handle.prepare('DELETE FROM discord_boards WHERE id=?').run(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
missingMessage(id: string): void {
|
||||||
|
this.handle
|
||||||
|
.prepare(
|
||||||
|
'UPDATE discord_boards SET message_id=NULL,content_hash=NULL,verified_at=0,generation=generation+1 WHERE id=?'
|
||||||
|
)
|
||||||
|
.run(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
verified(id: string): void {
|
||||||
|
this.handle
|
||||||
|
.prepare('UPDATE discord_boards SET verified_at=? WHERE id=?')
|
||||||
|
.run(this.now(), id);
|
||||||
|
}
|
||||||
|
|
||||||
|
attempt(): void {
|
||||||
|
this.handle
|
||||||
|
.prepare('UPDATE discord_health SET last_attempt_at=? WHERE id=1')
|
||||||
|
.run(this.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
error(message: string): void {
|
||||||
|
this.handle
|
||||||
|
.prepare('UPDATE discord_health SET last_error=? WHERE id=1')
|
||||||
|
.run(message.slice(0, 300));
|
||||||
|
}
|
||||||
|
|
||||||
|
failed(job: DiscordJob, error: string, retryAt: number | null): void {
|
||||||
|
this.handle.transaction(() => {
|
||||||
|
this.handle
|
||||||
|
.prepare(
|
||||||
|
'UPDATE discord_outbox SET attempts=attempts+1,next_at=?,error=? WHERE id=?'
|
||||||
|
)
|
||||||
|
.run(retryAt, error.slice(0, 300), job.id);
|
||||||
|
if (retryAt === null && job.kind === 'alert') {
|
||||||
|
this.handle
|
||||||
|
.prepare("UPDATE discord_subscriptions SET state='failed',error=? WHERE id=?")
|
||||||
|
.run(error.slice(0, 300), job.targetId);
|
||||||
|
this.handle.prepare('DELETE FROM discord_outbox WHERE id=?').run(job.id);
|
||||||
|
}
|
||||||
|
this.error(error);
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
|
retryFailed(): void {
|
||||||
|
this.handle
|
||||||
|
.prepare(
|
||||||
|
'UPDATE discord_outbox SET next_at=?,attempts=0,error=NULL WHERE next_at IS NULL'
|
||||||
|
)
|
||||||
|
.run(this.now());
|
||||||
|
this.handle.prepare('UPDATE discord_health SET last_error=NULL WHERE id=1').run();
|
||||||
|
}
|
||||||
|
|
||||||
|
claimInteraction(id: string): boolean {
|
||||||
|
if (!discordSnowflake(id)) throw new Error('Invalid Discord interaction ID.');
|
||||||
|
return this.handle.transaction(() => {
|
||||||
|
this.handle
|
||||||
|
.prepare('DELETE FROM discord_interactions WHERE expires_at<=?')
|
||||||
|
.run(this.now());
|
||||||
|
if (this.handle.prepare('SELECT 1 FROM discord_interactions WHERE id=?').get(id))
|
||||||
|
return false;
|
||||||
|
if (this.count('SELECT COUNT(*) AS n FROM discord_interactions') >= 10000)
|
||||||
|
throw new Error('Discord is processing too many interactions. Please try later.');
|
||||||
|
this.handle
|
||||||
|
.prepare('INSERT INTO discord_interactions(id,expires_at) VALUES(?,?)')
|
||||||
|
.run(id, this.now() + 15 * 60_000);
|
||||||
|
return true;
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
|
private count(sql: string, ...args: (string | number)[]): number {
|
||||||
|
return (this.handle.prepare(sql).get(...args) as { n: number }).n;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const discordStore = () => new DiscordStore(db());
|
||||||
|
export const getDiscordSettings = () => discordStore().settings();
|
||||||
|
export const saveDiscordSettings = (input: unknown) => discordStore().saveSettings(input);
|
||||||
|
export const discordStatus = () => discordStore().status();
|
||||||
|
export const subscribeToMap = (input: MapSubscriptionInput) => discordStore().subscribe(input);
|
||||||
|
export const listSubscriptions = (userId: string, guildId: string) =>
|
||||||
|
discordStore().subscriptions(userId, guildId);
|
||||||
|
export const cancelSubscription = (id: string, guildId: string, userId: string) =>
|
||||||
|
discordStore().cancel(id, guildId, userId);
|
||||||
|
export const claimDiscordInteraction = (id: string) => discordStore().claimInteraction(id);
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
/** Discord HTTP failures never include response bodies, which can contain private data. */
|
||||||
|
export class DiscordDeliveryError extends Error {
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
readonly status: number,
|
||||||
|
readonly retryAt: number | null = null,
|
||||||
|
readonly code: number | null = null
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'DiscordDeliveryError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DiscordTransport {
|
||||||
|
request<T = Record<string, unknown>>(method: string, path: string, body?: unknown): Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One serialized queue shares Discord's bucket/global cooldowns with every caller. */
|
||||||
|
export class DiscordRest implements DiscordTransport {
|
||||||
|
private tail: Promise<unknown> = Promise.resolve();
|
||||||
|
private globalUntil = 0;
|
||||||
|
private unauthorized = false;
|
||||||
|
private routeBuckets = new Map<string, string>();
|
||||||
|
private blockedUntil = new Map<string, number>();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly token: string,
|
||||||
|
private readonly fetcher: typeof fetch = fetch,
|
||||||
|
private readonly now: () => number = Date.now
|
||||||
|
) {}
|
||||||
|
|
||||||
|
request<T = Record<string, unknown>>(method: string, path: string, body?: unknown): Promise<T> {
|
||||||
|
const pending = this.tail.then(() => this.send<T>(method, path, body));
|
||||||
|
this.tail = pending.catch(() => undefined);
|
||||||
|
return pending;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async send<T>(method: string, path: string, body?: unknown): Promise<T> {
|
||||||
|
// Callers supply resource paths, never arbitrary hosts or interaction tokens.
|
||||||
|
if (!/^\/(?:channels|users|applications)\/[A-Za-z0-9_@/-]+$/.test(path))
|
||||||
|
throw new Error('Invalid Discord API path.');
|
||||||
|
if (this.unauthorized)
|
||||||
|
throw new DiscordDeliveryError('Discord bot credentials were rejected.', 401);
|
||||||
|
const segments = path.split('/');
|
||||||
|
const major = `${segments[1]}/${segments[2]}`;
|
||||||
|
const route = `${method}:${major}/${segments
|
||||||
|
.slice(3)
|
||||||
|
.map((part) => (/^\d+$/.test(part) ? ':id' : part))
|
||||||
|
.join('/')}`;
|
||||||
|
const key = this.routeBuckets.get(route) ?? route;
|
||||||
|
const retryAt = Math.max(this.globalUntil, this.blockedUntil.get(key) ?? 0);
|
||||||
|
if (retryAt > this.now())
|
||||||
|
throw new DiscordDeliveryError(
|
||||||
|
'Discord is waiting for a rate limit to reset.',
|
||||||
|
429,
|
||||||
|
retryAt
|
||||||
|
);
|
||||||
|
let response: Response;
|
||||||
|
try {
|
||||||
|
response = await this.fetcher(`https://discord.com/api/v10${path}`, {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bot ${this.token}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'User-Agent': 'ElectricCherry/1.0'
|
||||||
|
},
|
||||||
|
body: body === undefined ? undefined : JSON.stringify(body),
|
||||||
|
signal: AbortSignal.timeout(10_000),
|
||||||
|
redirect: 'error'
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
throw new DiscordDeliveryError('Discord could not be reached.', 0, this.now() + 5_000);
|
||||||
|
}
|
||||||
|
const bucket = response.headers.get('X-RateLimit-Bucket');
|
||||||
|
const bucketKey = bucket ? `${major}:${bucket}` : key;
|
||||||
|
if (bucket) this.routeBuckets.set(route, bucketKey);
|
||||||
|
const reset = seconds(response.headers.get('X-RateLimit-Reset-After'));
|
||||||
|
if (response.headers.get('X-RateLimit-Remaining') === '0' && reset !== null)
|
||||||
|
this.blockedUntil.set(bucketKey, this.now() + reset);
|
||||||
|
let data: Record<string, unknown> = {};
|
||||||
|
if (response.status !== 204) {
|
||||||
|
try {
|
||||||
|
data = await response.json();
|
||||||
|
} catch {
|
||||||
|
if (response.ok)
|
||||||
|
throw new DiscordDeliveryError(
|
||||||
|
'Discord returned an invalid response.',
|
||||||
|
502,
|
||||||
|
this.now() + 5_000
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (response.status === 429) {
|
||||||
|
const delay = Math.max(
|
||||||
|
1000,
|
||||||
|
seconds(data.retry_after) ??
|
||||||
|
seconds(response.headers.get('Retry-After')) ??
|
||||||
|
reset ??
|
||||||
|
5_000
|
||||||
|
);
|
||||||
|
const until = this.now() + delay;
|
||||||
|
if (data.global === true || response.headers.get('X-RateLimit-Global') === 'true')
|
||||||
|
this.globalUntil = until;
|
||||||
|
else this.blockedUntil.set(bucketKey, until);
|
||||||
|
throw new DiscordDeliveryError('Discord rate limited this request.', 429, until);
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 401) this.unauthorized = true;
|
||||||
|
const message =
|
||||||
|
response.status === 401
|
||||||
|
? 'Discord bot credentials were rejected.'
|
||||||
|
: response.status === 403
|
||||||
|
? 'Discord denied permission or the recipient does not accept direct messages.'
|
||||||
|
: response.status === 404
|
||||||
|
? 'Discord channel or message no longer exists.'
|
||||||
|
: `Discord request failed (HTTP ${response.status}).`;
|
||||||
|
throw new DiscordDeliveryError(
|
||||||
|
message,
|
||||||
|
response.status,
|
||||||
|
response.status >= 500 ? this.now() + 5_000 : null,
|
||||||
|
typeof data.code === 'number' ? data.code : null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return data as T;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function seconds(value: unknown): number | null {
|
||||||
|
if (value === null || value === undefined || value === '') return null;
|
||||||
|
const parsed = Number(value);
|
||||||
|
return Number.isFinite(parsed) && parsed >= 0 ? Math.ceil(parsed * 1000) : null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import { createPublicKey, verify } from 'node:crypto';
|
||||||
|
import { error } from '@sveltejs/kit';
|
||||||
|
import { boundedBody } from '../request-body.js';
|
||||||
|
import { discordCredentials } from './config.js';
|
||||||
|
import { discordSnowflake } from '../../discord.js';
|
||||||
|
import type { DiscordMessage } from '../../discord.js';
|
||||||
|
|
||||||
|
export const DISCORD_INTERACTION_BODY_LIMIT = 64 * 1024;
|
||||||
|
|
||||||
|
/** Authenticate the exact request bytes before interpreting any interaction fields. */
|
||||||
|
export async function verifiedDiscordBody(
|
||||||
|
request: Request,
|
||||||
|
now = Date.now()
|
||||||
|
): Promise<Record<string, unknown>> {
|
||||||
|
const signature = request.headers.get('x-signature-ed25519') ?? '';
|
||||||
|
const timestamp = request.headers.get('x-signature-timestamp') ?? '';
|
||||||
|
const { publicKey } = discordCredentials();
|
||||||
|
if (
|
||||||
|
!/^[a-fA-F0-9]{128}$/.test(signature) ||
|
||||||
|
!/^[0-9]{10,12}$/.test(timestamp) ||
|
||||||
|
Math.abs(now - Number(timestamp) * 1000) > 5 * 60_000 ||
|
||||||
|
!/^[a-fA-F0-9]{64}$/.test(publicKey)
|
||||||
|
)
|
||||||
|
error(401, 'Invalid Discord signature.');
|
||||||
|
const body = await boundedBody(request, DISCORD_INTERACTION_BODY_LIMIT);
|
||||||
|
const key = createPublicKey({
|
||||||
|
key: Buffer.concat([
|
||||||
|
Buffer.from('302a300506032b6570032100', 'hex'),
|
||||||
|
Buffer.from(publicKey, 'hex')
|
||||||
|
]),
|
||||||
|
format: 'der',
|
||||||
|
type: 'spki'
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
!verify(
|
||||||
|
null,
|
||||||
|
Buffer.concat([Buffer.from(timestamp), body]),
|
||||||
|
key,
|
||||||
|
Buffer.from(signature, 'hex')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
error(401, 'Invalid Discord signature.');
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(body.toString('utf8'));
|
||||||
|
} catch {
|
||||||
|
error(400, 'Invalid Discord interaction.');
|
||||||
|
}
|
||||||
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
|
||||||
|
error(400, 'Invalid Discord interaction.');
|
||||||
|
return parsed as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validInteractionEnvelope(body: Record<string, unknown>): boolean {
|
||||||
|
return (
|
||||||
|
discordSnowflake(body.id) &&
|
||||||
|
body.application_id === discordCredentials().applicationId &&
|
||||||
|
discordSnowflake(body.guild_id) &&
|
||||||
|
typeof body.token === 'string' &&
|
||||||
|
body.token.length >= 10 &&
|
||||||
|
body.token.length <= 1000 &&
|
||||||
|
!!body.member &&
|
||||||
|
typeof body.member === 'object' &&
|
||||||
|
discordSnowflake((body.member as { user?: { id?: unknown } }).user?.id)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Interaction webhooks use their short-lived token, never the deployment bot token. */
|
||||||
|
export async function completeDiscordInteraction(
|
||||||
|
applicationId: string,
|
||||||
|
token: string,
|
||||||
|
message: DiscordMessage,
|
||||||
|
fetcher: typeof fetch = fetch,
|
||||||
|
publicationAllowed: () => boolean = () => true
|
||||||
|
): Promise<void> {
|
||||||
|
for (let attempt = 0; attempt < 3; attempt++) {
|
||||||
|
const response = await fetcher(
|
||||||
|
`https://discord.com/api/v10/webhooks/${applicationId}/${encodeURIComponent(token)}/messages/@original`,
|
||||||
|
{
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
...(publicationAllowed()
|
||||||
|
? message
|
||||||
|
: {
|
||||||
|
flags: 32768 | 64,
|
||||||
|
components: [
|
||||||
|
{
|
||||||
|
type: 10,
|
||||||
|
content:
|
||||||
|
'Publication settings changed. Please run your command again.'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
allowed_mentions: { parse: [] }
|
||||||
|
}),
|
||||||
|
signal: AbortSignal.timeout(10_000),
|
||||||
|
redirect: 'error'
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (response.ok) return;
|
||||||
|
if (response.status === 429 && attempt < 2) {
|
||||||
|
let delay = Number(response.headers.get('retry-after') ?? NaN);
|
||||||
|
try {
|
||||||
|
const result = (await response.json()) as { retry_after?: number };
|
||||||
|
delay = Number(result.retry_after ?? delay);
|
||||||
|
} catch {
|
||||||
|
/* A valid retry header is sufficient. */
|
||||||
|
}
|
||||||
|
if (Number.isFinite(delay) && delay >= 0 && delay <= 30) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, Math.max(250, delay * 1000)));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Do not log a response body or URL: both can contain interaction credentials.
|
||||||
|
throw new Error(`Discord interaction delivery failed (${response.status}).`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,415 @@
|
|||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import type {
|
||||||
|
DiscordGame,
|
||||||
|
DiscordMessage,
|
||||||
|
DiscordPublicServer,
|
||||||
|
DiscordSettings
|
||||||
|
} from '../../discord.js';
|
||||||
|
import { requireDiscordCredentials } from './config.js';
|
||||||
|
import { DEFAULT_DISCORD_LOBBY_CAPACITY, discordText, publicFleet } from './projection.js';
|
||||||
|
import { renderBoard } from './render.js';
|
||||||
|
import { DiscordStore, discordStore, type DiscordJob, type DiscordSubscription } from './store.js';
|
||||||
|
import { DiscordDeliveryError, DiscordRest, type DiscordTransport } from './transport.js';
|
||||||
|
|
||||||
|
const TICK_MS = 30_000;
|
||||||
|
const VERIFY_MS = 15 * 60_000;
|
||||||
|
const FRESH_MS = 90_000;
|
||||||
|
let client: { token: string; rest: DiscordRest } | undefined;
|
||||||
|
|
||||||
|
export function discordRest(): DiscordRest {
|
||||||
|
const { botToken } = requireDiscordCredentials();
|
||||||
|
if (!client || client.token !== botToken)
|
||||||
|
client = { token: botToken, rest: new DiscordRest(botToken) };
|
||||||
|
return client.rest;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WorkerDependencies {
|
||||||
|
store: DiscordStore;
|
||||||
|
transport: () => DiscordTransport;
|
||||||
|
fleet: (settings: DiscordSettings, now: number) => DiscordPublicServer[];
|
||||||
|
render: (game: DiscordGame, servers: DiscordPublicServer[]) => DiscordMessage[];
|
||||||
|
now?: () => number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A single independent reconciliation loop; it never issues game-server commands. */
|
||||||
|
export class DiscordWorker {
|
||||||
|
private running: Promise<void> | null = null;
|
||||||
|
private readonly now: () => number;
|
||||||
|
constructor(private readonly dependencies: WorkerDependencies) {
|
||||||
|
this.now = dependencies.now ?? Date.now;
|
||||||
|
}
|
||||||
|
|
||||||
|
tick(): Promise<void> {
|
||||||
|
if (this.running) return this.running;
|
||||||
|
this.running = this.run()
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
// No raw fetch/config/SQL exceptions are persisted in operator-facing status.
|
||||||
|
try {
|
||||||
|
this.dependencies.store.error(
|
||||||
|
error instanceof DiscordDeliveryError
|
||||||
|
? error.message
|
||||||
|
: 'Discord delivery could not complete. Check the integration configuration.'
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
console.error('[discord] Could not record delivery health.');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
this.running = null;
|
||||||
|
});
|
||||||
|
return this.running;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async run(): Promise<void> {
|
||||||
|
const { store, fleet, render } = this.dependencies;
|
||||||
|
store.expireSubscriptions();
|
||||||
|
const settings = store.settings();
|
||||||
|
const servers = settings.enabled ? fleet(settings, this.now()) : [];
|
||||||
|
store.reconcileBoards(
|
||||||
|
settings.enabled
|
||||||
|
? (['t5', 't6'] as const)
|
||||||
|
.filter((game) => settings.channels[game])
|
||||||
|
.map((game) => ({
|
||||||
|
game,
|
||||||
|
guildId: settings.guildId,
|
||||||
|
channelId: settings.channels[game],
|
||||||
|
messages: render(game, servers)
|
||||||
|
}))
|
||||||
|
: []
|
||||||
|
);
|
||||||
|
if (settings.enabled) {
|
||||||
|
for (const watch of store.subscriptions()) {
|
||||||
|
if (watch.state !== 'active') continue;
|
||||||
|
if (watch.guildId !== settings.guildId) {
|
||||||
|
store.cancel(watch.id, watch.guildId, watch.userId);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const match = matchingServer(watch, servers, this.now());
|
||||||
|
if (!match) {
|
||||||
|
store.candidate(watch.id, null);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Persist the server identity, not each telemetry timestamp: two fresh
|
||||||
|
// observations spanning a tick stabilize brief capacity fluctuations.
|
||||||
|
if (watch.candidate?.split('|')[0] !== match.name) {
|
||||||
|
store.candidate(watch.id, `${match.name}|${match.observedAt ?? ''}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
watch.candidateAt !== null &&
|
||||||
|
this.now() - watch.candidateAt >= 15_000 &&
|
||||||
|
watch.candidate !== `${match.name}|${match.observedAt ?? ''}`
|
||||||
|
)
|
||||||
|
store.queueAlert(watch, alertMessage(watch, match));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const jobs = store.jobs(10);
|
||||||
|
const toVerify = store
|
||||||
|
.boards()
|
||||||
|
.find(
|
||||||
|
(board) =>
|
||||||
|
board.messageId &&
|
||||||
|
board.verifiedAt < this.now() - VERIFY_MS &&
|
||||||
|
!jobs.some((job) => job.targetId === board.id)
|
||||||
|
);
|
||||||
|
if (!jobs.length && (!settings.enabled || !toVerify)) return;
|
||||||
|
const rest = this.dependencies.transport();
|
||||||
|
const channelGuilds = new Map<string, string>();
|
||||||
|
const ensureChannel = async (channelId: string, guildId: string) => {
|
||||||
|
if (!channelGuilds.has(channelId)) {
|
||||||
|
const channel = await rest.request<{ guild_id?: string; type?: number }>(
|
||||||
|
'GET',
|
||||||
|
`/channels/${channelId}`
|
||||||
|
);
|
||||||
|
if (![0, 5].includes(channel.type ?? -1))
|
||||||
|
throw new DiscordDeliveryError(
|
||||||
|
'Choose a Discord text or announcement channel for the board.',
|
||||||
|
403
|
||||||
|
);
|
||||||
|
channelGuilds.set(channelId, channel.guild_id ?? '');
|
||||||
|
}
|
||||||
|
if (channelGuilds.get(channelId) !== guildId)
|
||||||
|
throw new DiscordDeliveryError(
|
||||||
|
'The board channel does not belong to the configured Discord server.',
|
||||||
|
403
|
||||||
|
);
|
||||||
|
};
|
||||||
|
for (const job of jobs) {
|
||||||
|
store.attempt();
|
||||||
|
try {
|
||||||
|
await this.deliver(job, rest, ensureChannel);
|
||||||
|
} catch (error) {
|
||||||
|
const failure =
|
||||||
|
error instanceof DiscordDeliveryError
|
||||||
|
? error
|
||||||
|
: new DiscordDeliveryError(
|
||||||
|
'Discord delivery could not complete.',
|
||||||
|
0,
|
||||||
|
this.now() + 5_000
|
||||||
|
);
|
||||||
|
const canRetry =
|
||||||
|
failure.retryAt !== null && (failure.status === 429 || job.attempts < 7);
|
||||||
|
const delay = Math.min(15 * 60_000, 5_000 * 2 ** Math.min(job.attempts, 8));
|
||||||
|
store.failed(
|
||||||
|
job,
|
||||||
|
failure.message,
|
||||||
|
canRetry
|
||||||
|
? Math.max(
|
||||||
|
failure.retryAt!,
|
||||||
|
this.now() + (failure.status === 429 ? 0 : delay)
|
||||||
|
)
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
if (failure.status === 401 || failure.status === 429) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (settings.enabled && toVerify) {
|
||||||
|
const current = store.settings();
|
||||||
|
if (
|
||||||
|
!current.enabled ||
|
||||||
|
current.guildId !== toVerify.guildId ||
|
||||||
|
current.channels[toVerify.game] !== toVerify.channelId
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
store.attempt();
|
||||||
|
try {
|
||||||
|
await ensureChannel(toVerify.channelId, current.guildId);
|
||||||
|
await rest.request(
|
||||||
|
'GET',
|
||||||
|
`/channels/${toVerify.channelId}/messages/${toVerify.messageId}`
|
||||||
|
);
|
||||||
|
store.verified(toVerify.id);
|
||||||
|
} catch (error) {
|
||||||
|
if (
|
||||||
|
error instanceof DiscordDeliveryError &&
|
||||||
|
error.status === 404 &&
|
||||||
|
error.code === 10008
|
||||||
|
)
|
||||||
|
store.missingMessage(toVerify.id);
|
||||||
|
else {
|
||||||
|
store.verified(toVerify.id); // Check permissions again later, not every tick.
|
||||||
|
store.error(
|
||||||
|
error instanceof DiscordDeliveryError
|
||||||
|
? error.message
|
||||||
|
: 'Discord board verification failed.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async deliver(
|
||||||
|
job: DiscordJob,
|
||||||
|
rest: DiscordTransport,
|
||||||
|
ensureChannel: (channelId: string, guildId: string) => Promise<void>
|
||||||
|
): Promise<void> {
|
||||||
|
const { store, fleet, render } = this.dependencies;
|
||||||
|
const settings = store.settings();
|
||||||
|
if (job.kind === 'alert') {
|
||||||
|
const watch = store.subscription(job.targetId);
|
||||||
|
if (
|
||||||
|
!watch ||
|
||||||
|
watch.state !== 'queued' ||
|
||||||
|
watch.expiresAt <= this.now() ||
|
||||||
|
!settings.enabled ||
|
||||||
|
settings.guildId !== watch.guildId
|
||||||
|
) {
|
||||||
|
store.dropJob(job.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const match = matchingServer(watch, fleet(settings, this.now()), this.now());
|
||||||
|
if (!match) {
|
||||||
|
store.resumeAlert(watch.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let channelId = job.channelId;
|
||||||
|
if (!channelId) {
|
||||||
|
const channel = await rest.request<{ id: string }>('POST', '/users/@me/channels', {
|
||||||
|
recipient_id: watch.userId
|
||||||
|
});
|
||||||
|
channelId = channel.id;
|
||||||
|
if (!/^\d+$/.test(channelId))
|
||||||
|
throw new DiscordDeliveryError(
|
||||||
|
'Discord returned an invalid channel.',
|
||||||
|
502,
|
||||||
|
this.now() + 5_000
|
||||||
|
);
|
||||||
|
store.cacheDm(job.id, channelId);
|
||||||
|
}
|
||||||
|
// Recheck opt-in/config after the asynchronous channel lookup.
|
||||||
|
const current = store.settings();
|
||||||
|
const stillWatching = store.subscription(watch.id);
|
||||||
|
if (
|
||||||
|
!current.enabled ||
|
||||||
|
current.guildId !== watch.guildId ||
|
||||||
|
stillWatching?.state !== 'queued' ||
|
||||||
|
stillWatching.expiresAt <= this.now()
|
||||||
|
) {
|
||||||
|
store.dropJob(job.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const fresh = matchingServer(watch, fleet(current, this.now()), this.now());
|
||||||
|
if (!fresh) {
|
||||||
|
store.resumeAlert(watch.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await rest.request('POST', `/channels/${channelId}/messages`, {
|
||||||
|
...alertMessage(watch, fresh),
|
||||||
|
nonce: nonce(job.id),
|
||||||
|
enforce_nonce: true
|
||||||
|
});
|
||||||
|
store.delivered(job);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const board = store.board(job.targetId);
|
||||||
|
if (!board) {
|
||||||
|
store.dropJob(job.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (job.kind === 'delete') {
|
||||||
|
if (board.messageId) {
|
||||||
|
try {
|
||||||
|
await rest.request(
|
||||||
|
'DELETE',
|
||||||
|
`/channels/${board.channelId}/messages/${board.messageId}`
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (!(error instanceof DiscordDeliveryError && error.status === 404))
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
store.delivered(job);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!settings.enabled ||
|
||||||
|
settings.guildId !== board.guildId ||
|
||||||
|
settings.channels[board.game] !== board.channelId
|
||||||
|
) {
|
||||||
|
store.dropJob(job.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await ensureChannel(board.channelId, settings.guildId);
|
||||||
|
// Rebuild after awaits so pending jobs cannot publish withdrawn fields or servers.
|
||||||
|
const current = store.settings();
|
||||||
|
if (
|
||||||
|
!current.enabled ||
|
||||||
|
current.guildId !== settings.guildId ||
|
||||||
|
current.channels[board.game] !== board.channelId
|
||||||
|
) {
|
||||||
|
store.dropJob(job.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const message = render(board.game, fleet(current, this.now()))[board.page];
|
||||||
|
if (!message) {
|
||||||
|
store.dropJob(job.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const updated = {
|
||||||
|
...job,
|
||||||
|
payload: message,
|
||||||
|
contentHash: createHash('sha256').update(JSON.stringify(message)).digest('hex')
|
||||||
|
};
|
||||||
|
if (board.messageId) {
|
||||||
|
try {
|
||||||
|
await rest.request(
|
||||||
|
'PATCH',
|
||||||
|
`/channels/${board.channelId}/messages/${board.messageId}`,
|
||||||
|
message
|
||||||
|
);
|
||||||
|
store.delivered(updated, board.messageId);
|
||||||
|
return;
|
||||||
|
} catch (error) {
|
||||||
|
if (!(
|
||||||
|
error instanceof DiscordDeliveryError &&
|
||||||
|
error.status === 404 &&
|
||||||
|
error.code === 10008
|
||||||
|
))
|
||||||
|
throw error;
|
||||||
|
store.missingMessage(board.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const result = await rest.request<{ id: string }>(
|
||||||
|
'POST',
|
||||||
|
`/channels/${board.channelId}/messages`,
|
||||||
|
{
|
||||||
|
...message,
|
||||||
|
nonce: nonce(`${board.id}:${store.board(board.id)?.generation ?? 0}`),
|
||||||
|
enforce_nonce: true
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (!/^\d+$/.test(result.id))
|
||||||
|
throw new DiscordDeliveryError(
|
||||||
|
'Discord returned an invalid message.',
|
||||||
|
502,
|
||||||
|
this.now() + 5_000
|
||||||
|
);
|
||||||
|
store.delivered(updated, result.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchingServer(
|
||||||
|
watch: DiscordSubscription,
|
||||||
|
servers: DiscordPublicServer[],
|
||||||
|
now: number
|
||||||
|
): DiscordPublicServer | undefined {
|
||||||
|
return servers.find(
|
||||||
|
(server) =>
|
||||||
|
server.game === watch.game &&
|
||||||
|
server.mapKey === watch.mapKey &&
|
||||||
|
server.observedAt !== null &&
|
||||||
|
now - Date.parse(server.observedAt) >= 0 &&
|
||||||
|
now - Date.parse(server.observedAt) <= FRESH_MS &&
|
||||||
|
['empty', 'in-progress'].includes(server.state) &&
|
||||||
|
server.players !== null &&
|
||||||
|
(watch.mode === 'empty'
|
||||||
|
? server.players === 0
|
||||||
|
: server.capacity !== null &&
|
||||||
|
server.capacity > 0 &&
|
||||||
|
server.players < server.capacity)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function alertMessage(watch: DiscordSubscription, server: DiscordPublicServer): DiscordMessage {
|
||||||
|
const safe = (value: string) => discordText(value, 100);
|
||||||
|
return {
|
||||||
|
content: `🍒 **${safe(server.map)}** ${watch.mode === 'empty' ? 'has an empty lobby' : 'has an open player slot'}: **${safe(server.label)}** (${server.players}/${server.capacity ?? DEFAULT_DISCORD_LOBBY_CAPACITY}).\nThis describes occupancy; joining an in-progress game may still be restricted.\nYour one-time map watch is complete. Use /servers to browse or /watch to watch again.`,
|
||||||
|
allowed_mentions: { parse: [] }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function nonce(id: string): string {
|
||||||
|
return createHash('sha256').update(id).digest('hex').slice(0, 24);
|
||||||
|
}
|
||||||
|
|
||||||
|
let worker: DiscordWorker | undefined;
|
||||||
|
let timer: ReturnType<typeof setInterval> | undefined;
|
||||||
|
export function tickDiscordWorker(): Promise<void> {
|
||||||
|
worker ??= new DiscordWorker({
|
||||||
|
store: discordStore(),
|
||||||
|
transport: discordRest,
|
||||||
|
fleet: publicFleet,
|
||||||
|
render: renderBoard
|
||||||
|
});
|
||||||
|
return worker.tick();
|
||||||
|
}
|
||||||
|
export function startDiscordWorker(): void {
|
||||||
|
if (timer) return;
|
||||||
|
void tickDiscordWorker();
|
||||||
|
timer = setInterval(() => {
|
||||||
|
void tickDiscordWorker();
|
||||||
|
}, TICK_MS);
|
||||||
|
timer.unref();
|
||||||
|
}
|
||||||
|
export function stopDiscordWorker(): void {
|
||||||
|
clearInterval(timer);
|
||||||
|
timer = undefined;
|
||||||
|
}
|
||||||
|
export function resetDiscordClient(): void {
|
||||||
|
client = undefined;
|
||||||
|
}
|
||||||
|
export function retryDiscordDelivery(): Promise<void> {
|
||||||
|
resetDiscordClient();
|
||||||
|
discordStore().retryFailed();
|
||||||
|
return tickDiscordWorker();
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
import { db } from '../db/index.js';
|
||||||
|
import { bankMaximum } from './bank.js';
|
||||||
|
import {
|
||||||
|
bankPageSize,
|
||||||
|
type BankAccount,
|
||||||
|
type BankOverview,
|
||||||
|
type BankPage,
|
||||||
|
type BankPending,
|
||||||
|
type BankQuery,
|
||||||
|
type BankTransfer
|
||||||
|
} from '../../bank.js';
|
||||||
|
|
||||||
|
// A GUID owns one account even when telemetry has identities 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
|
||||||
|
)`;
|
||||||
|
const accounts = `${identities}, account_guids AS (
|
||||||
|
SELECT guid FROM bank_accounts UNION SELECT guid FROM bank_requests WHERE state = 'prepared'
|
||||||
|
), accounts AS (
|
||||||
|
SELECT g.guid, p.name, COALESCE(a.balance, 0) AS balance,
|
||||||
|
COALESCE(a.balance, 0) - CASE WHEN r.operation = 'withdraw' THEN r.amount ELSE 0 END AS available,
|
||||||
|
r.operation AS pendingOperation, r.amount AS pendingAmount,
|
||||||
|
l.created_at AS lastActivity, l.amount AS lastAmount
|
||||||
|
FROM account_guids g
|
||||||
|
LEFT JOIN bank_accounts a ON a.guid = g.guid
|
||||||
|
LEFT JOIN identities p ON p.guid = g.guid
|
||||||
|
LEFT JOIN bank_requests r ON r.guid = g.guid AND r.state = 'prepared'
|
||||||
|
LEFT JOIN bank_ledger l ON l.id = (SELECT id FROM bank_ledger WHERE guid = g.guid ORDER BY id DESC LIMIT 1)
|
||||||
|
)`;
|
||||||
|
// Canonical server names retain the game after match retention or 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 transfers = `${identities}, transfers AS (
|
||||||
|
SELECT l.id, l.guid, p.name, l.server, ${game('l')} AS game,
|
||||||
|
l.match_id AS matchId, m.id IS NOT NULL AS matchAvailable,
|
||||||
|
l.amount, l.balance, l.created_at AS createdAt
|
||||||
|
FROM bank_ledger l LEFT JOIN identities p ON p.guid = l.guid
|
||||||
|
LEFT JOIN leaderboard_matches m ON m.id = l.match_id
|
||||||
|
)`;
|
||||||
|
const pending = `${identities}, pending AS (
|
||||||
|
SELECT r.guid, p.name, r.request_id AS requestId, r.server, ${game('r')} AS game,
|
||||||
|
r.match_id AS matchId, m.id IS NOT NULL AS matchAvailable,
|
||||||
|
r.operation, r.amount, r.created_at AS createdAt
|
||||||
|
FROM bank_requests r LEFT JOIN identities p ON p.guid = r.guid
|
||||||
|
LEFT JOIN leaderboard_matches m ON m.id = r.match_id WHERE r.state = 'prepared'
|
||||||
|
)`;
|
||||||
|
const emptyPage = <T>(): BankPage<T> => ({ entries: [], total: 0, offset: 0 });
|
||||||
|
|
||||||
|
export function bankOverview(query: BankQuery, now = new Date()): BankOverview {
|
||||||
|
const handle = db();
|
||||||
|
return handle.transaction(() => {
|
||||||
|
let maximum: number | null;
|
||||||
|
try {
|
||||||
|
maximum = bankMaximum();
|
||||||
|
} catch {
|
||||||
|
// Invalid deposit configuration must not hide balances or recovery information.
|
||||||
|
maximum = null;
|
||||||
|
}
|
||||||
|
const checkedAt = now.toISOString();
|
||||||
|
const summary = handle
|
||||||
|
.prepare(
|
||||||
|
`SELECT
|
||||||
|
(SELECT COALESCE(SUM(balance), 0) FROM bank_accounts) AS totalBanked,
|
||||||
|
(SELECT COUNT(*) FROM bank_accounts WHERE balance > 0) AS fundedAccounts,
|
||||||
|
(SELECT COUNT(*) FROM bank_ledger WHERE created_at >= ? AND created_at <= ?) AS transfers24h,
|
||||||
|
(SELECT COUNT(*) FROM bank_requests WHERE state = 'prepared') AS pending
|
||||||
|
`
|
||||||
|
)
|
||||||
|
.get(
|
||||||
|
new Date(now.getTime() - 86_400_000).toISOString(),
|
||||||
|
checkedAt
|
||||||
|
) as BankOverview['summary'];
|
||||||
|
const servers = (
|
||||||
|
handle
|
||||||
|
.prepare(
|
||||||
|
`SELECT server FROM bank_ledger
|
||||||
|
UNION SELECT server FROM bank_requests WHERE state = 'prepared' 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,
|
||||||
|
requested: number
|
||||||
|
): BankPage<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(
|
||||||
|
requested,
|
||||||
|
Math.max(0, Math.floor((total - 1) / bankPageSize) * bankPageSize)
|
||||||
|
);
|
||||||
|
const entries = handle
|
||||||
|
.prepare(`${cte} SELECT * ${scope} ORDER BY ${order} LIMIT ? OFFSET ?`)
|
||||||
|
.all(...values, bankPageSize, 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);
|
||||||
|
}
|
||||||
|
let accountPage = emptyPage<BankAccount>();
|
||||||
|
let transactionPage = emptyPage<BankTransfer>();
|
||||||
|
let pendingPage = emptyPage<BankPending>();
|
||||||
|
if (query.view === 'accounts') {
|
||||||
|
if (query.balance === 'funded') where.push('balance > 0');
|
||||||
|
if (query.balance === 'pending') where.push('pendingOperation IS NOT NULL');
|
||||||
|
if (query.balance === 'limit') {
|
||||||
|
where.push('balance >= ?');
|
||||||
|
values.push(maximum ?? 1_000_001);
|
||||||
|
}
|
||||||
|
accountPage = page(
|
||||||
|
accounts,
|
||||||
|
'accounts',
|
||||||
|
where,
|
||||||
|
values,
|
||||||
|
query.sort === 'recent' ? 'lastActivity DESC, guid' : 'balance DESC, guid',
|
||||||
|
query.offset
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
if (query.game) {
|
||||||
|
where.push('game = ?');
|
||||||
|
values.push(query.game);
|
||||||
|
}
|
||||||
|
if (query.server) {
|
||||||
|
where.push('server = ?');
|
||||||
|
values.push(query.server);
|
||||||
|
}
|
||||||
|
if (query.operation) {
|
||||||
|
where.push(
|
||||||
|
query.view === 'pending'
|
||||||
|
? 'operation = ?'
|
||||||
|
: query.operation === 'deposit'
|
||||||
|
? 'amount > 0'
|
||||||
|
: 'amount < 0'
|
||||||
|
);
|
||||||
|
if (query.view === 'pending') values.push(query.operation);
|
||||||
|
}
|
||||||
|
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 === 'transactions')
|
||||||
|
transactionPage = page(
|
||||||
|
transfers,
|
||||||
|
'transfers',
|
||||||
|
where,
|
||||||
|
values,
|
||||||
|
'createdAt DESC, id DESC',
|
||||||
|
query.offset
|
||||||
|
);
|
||||||
|
else
|
||||||
|
pendingPage = page(
|
||||||
|
pending,
|
||||||
|
'pending',
|
||||||
|
where,
|
||||||
|
values,
|
||||||
|
'createdAt, server, requestId',
|
||||||
|
query.offset
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let detail: BankOverview['detail'] = null;
|
||||||
|
if (query.player) {
|
||||||
|
let account = handle
|
||||||
|
.prepare(`${accounts} SELECT * FROM accounts WHERE guid = ?`)
|
||||||
|
.get(query.player) as BankAccount | undefined;
|
||||||
|
if (!account) {
|
||||||
|
const identity = handle
|
||||||
|
.prepare(`${identities} SELECT * FROM identities WHERE guid = ?`)
|
||||||
|
.get(query.player) as { guid: string; name: string } | undefined;
|
||||||
|
if (identity)
|
||||||
|
account = {
|
||||||
|
...identity,
|
||||||
|
balance: 0,
|
||||||
|
available: 0,
|
||||||
|
pendingOperation: null,
|
||||||
|
pendingAmount: null,
|
||||||
|
lastActivity: null,
|
||||||
|
lastAmount: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (account)
|
||||||
|
detail = {
|
||||||
|
account,
|
||||||
|
pending:
|
||||||
|
(handle
|
||||||
|
.prepare(`${pending} SELECT * FROM pending WHERE guid = ?`)
|
||||||
|
.get(query.player) as BankPending | undefined) ?? null,
|
||||||
|
history: page(
|
||||||
|
transfers,
|
||||||
|
'transfers',
|
||||||
|
['guid = ?'],
|
||||||
|
[query.player],
|
||||||
|
'createdAt DESC, id DESC',
|
||||||
|
query.historyOffset
|
||||||
|
)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
query,
|
||||||
|
maximum,
|
||||||
|
checkedAt,
|
||||||
|
summary,
|
||||||
|
servers,
|
||||||
|
accounts: accountPage,
|
||||||
|
transactions: transactionPage,
|
||||||
|
pending: pendingPage,
|
||||||
|
detail
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
}
|
||||||
+101
-11
@@ -123,9 +123,15 @@ export function serverChatTimeline(server: ServerRow, tail: TailResult): Context
|
|||||||
export function matchChatTimeline(matchId: number): ContextualChatEntry[] {
|
export function matchChatTimeline(matchId: number): ContextualChatEntry[] {
|
||||||
return (
|
return (
|
||||||
db()
|
db()
|
||||||
.prepare('SELECT entry FROM match_chat WHERE match_id = ? ORDER BY rowid')
|
.prepare(
|
||||||
.all(matchId) as { entry: string }[]
|
'SELECT message_key,occurrence,entry FROM match_chat WHERE match_id = ? ORDER BY rowid'
|
||||||
).map((row) => JSON.parse(row.entry));
|
)
|
||||||
|
.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. */
|
/** Save before filtering the live view so completed matches retain their messages. */
|
||||||
@@ -206,17 +212,48 @@ interface ChatReadState {
|
|||||||
entries: ContextualChatEntry[];
|
entries: ContextualChatEntry[];
|
||||||
contextVersion?: string;
|
contextVersion?: string;
|
||||||
wasOccupied: boolean;
|
wasOccupied: boolean;
|
||||||
|
needsDrain: boolean;
|
||||||
|
bytesRead: number;
|
||||||
}
|
}
|
||||||
const readers = new Map<string, ChatReadState>();
|
const readers = new Map<string, ChatReadState>();
|
||||||
const failures = new Map<string, number>();
|
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. */
|
/** Shared by the live API and the worker; collection does not require a viewer. */
|
||||||
export function collectServerChat(
|
export function collectServerChat(
|
||||||
server: ServerRow
|
server: ServerRow
|
||||||
): Promise<{ entries: ContextualChatEntry[]; missing: boolean }> {
|
): 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);
|
const existing = pending.get(key);
|
||||||
if (existing) return existing;
|
if (existing) return existing;
|
||||||
|
let progressed = false;
|
||||||
const work = (async () => {
|
const work = (async () => {
|
||||||
const path = resolveLogPath(server.name, gameLogKey(server.name));
|
const path = resolveLogPath(server.name, gameLogKey(server.name));
|
||||||
if (!path) throw new Error('Invalid server log path');
|
if (!path) throw new Error('Invalid server log path');
|
||||||
@@ -231,7 +268,9 @@ export function collectServerChat(
|
|||||||
partial: Buffer.alloc(0),
|
partial: Buffer.alloc(0),
|
||||||
skipLine: false,
|
skipLine: false,
|
||||||
entries: [],
|
entries: [],
|
||||||
wasOccupied: false
|
wasOccupied: false,
|
||||||
|
needsDrain: false,
|
||||||
|
bytesRead: 0
|
||||||
};
|
};
|
||||||
const tail = await readLogChunk(path, state.cursor);
|
const tail = await readLogChunk(path, state.cursor);
|
||||||
const current = getServer(server.name);
|
const current = getServer(server.name);
|
||||||
@@ -258,7 +297,10 @@ export function collectServerChat(
|
|||||||
state.entries = [];
|
state.entries = [];
|
||||||
}
|
}
|
||||||
state.cursor = tail.cursor;
|
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;
|
const end = bytes.lastIndexOf(10) + 1;
|
||||||
if (end) {
|
if (end) {
|
||||||
const lines = bytes
|
const lines = bytes
|
||||||
@@ -286,14 +328,17 @@ export function collectServerChat(
|
|||||||
state.skipLine = true;
|
state.skipLine = true;
|
||||||
}
|
}
|
||||||
// File mtime anchors the final line only after the backlog has been read.
|
// 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;
|
state.signature = undefined;
|
||||||
readers.set(server.name, state);
|
readers.set(server.name, state);
|
||||||
return { entries: currentChatEntries(current, state.entries), missing: false };
|
return { entries: currentChatEntries(current, state.entries), missing: false };
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Older agents remain readable during a rolling update.
|
// Older agents remain readable during a rolling update.
|
||||||
|
state.needsDrain = false;
|
||||||
changed = state.signature !== `${tail.size}:${tail.mtime}`;
|
changed = state.signature !== `${tail.size}:${tail.mtime}`;
|
||||||
|
progressed = changed;
|
||||||
if (changed) state.lines = tail.lines;
|
if (changed) state.lines = tail.lines;
|
||||||
}
|
}
|
||||||
const signature = `${tail.size}:${tail.mtime}`;
|
const signature = `${tail.size}:${tail.mtime}`;
|
||||||
@@ -319,7 +364,22 @@ export function collectServerChat(
|
|||||||
state.signature = signature;
|
state.signature = signature;
|
||||||
readers.set(server.name, state);
|
readers.set(server.name, state);
|
||||||
return { entries: currentChatEntries(current, state.entries), missing: false };
|
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);
|
pending.set(key, work);
|
||||||
return work;
|
return work;
|
||||||
}
|
}
|
||||||
@@ -329,27 +389,57 @@ export async function collectFleetChat(): Promise<void> {
|
|||||||
const observe = observationReader();
|
const observe = observationReader();
|
||||||
const live = listServers().filter((server) => !server.destroyed_at);
|
const live = listServers().filter((server) => !server.destroyed_at);
|
||||||
const names = new Set(live.map((server) => server.name));
|
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 readers.keys()) if (!names.has(name)) readers.delete(name);
|
||||||
for (const name of failures.keys()) if (!names.has(name)) failures.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) => {
|
const servers = live.filter((server) => {
|
||||||
if (server.host_id && !hosts.get(server.host_id)?.connected) return false;
|
if (server.host_id && !hosts.get(server.host_id)?.connected) return false;
|
||||||
if (server.container_state && !['running', 'unknown'].includes(server.container_state))
|
if (server.container_state && !['running', 'unknown'].includes(server.container_state))
|
||||||
return false;
|
return false;
|
||||||
const players = observe(server).last_players ?? server.last_players;
|
const players = observe(server).last_players ?? server.last_players;
|
||||||
// One final read after players leave retains messages written at match end.
|
const key = chatReadKey(server);
|
||||||
return players !== 0 || readers.get(server.name)?.wasOccupied;
|
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.
|
// Bound agent traffic and isolate failures to their own server.
|
||||||
async function collect() {
|
async function collect() {
|
||||||
for (let server = servers.shift(); server; server = servers.shift()) {
|
for (let server = servers.shift(); server; server = servers.shift()) {
|
||||||
|
const key = chatReadKey(server);
|
||||||
|
const before = readers.get(server.name);
|
||||||
try {
|
try {
|
||||||
await collectServerChat(server);
|
await collectServerChat(server);
|
||||||
const state = readers.get(server.name);
|
const state = readers.get(server.name);
|
||||||
if (state)
|
if (state?.key === key) {
|
||||||
state.wasOccupied =
|
state.wasOccupied =
|
||||||
(observe(server).last_players ?? server.last_players ?? 0) > 0;
|
(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);
|
failures.delete(server.name);
|
||||||
} catch (error) {
|
} 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) {
|
if (Date.now() - (failures.get(server.name) ?? -Infinity) >= 5 * 60_000) {
|
||||||
console.error(`[chat] Could not collect chat for ${server.name}`, error);
|
console.error(`[chat] Could not collect chat for ${server.name}`, error);
|
||||||
failures.set(server.name, Date.now());
|
failures.set(server.name, Date.now());
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { isPrivateConfigTarget } from './private-config.js';
|
||||||
import { putFileBlob } from '../db/file-blobs.js';
|
import { putFileBlob } from '../db/file-blobs.js';
|
||||||
import { getVersionContent } from './scripts.js';
|
import { getVersionContent } from './scripts.js';
|
||||||
import {
|
import {
|
||||||
@@ -213,6 +214,7 @@ async function scanServerFiles(server: string): Promise<DriftEntry[]> {
|
|||||||
if (onDisk.length === 0) continue;
|
if (onDisk.length === 0) continue;
|
||||||
|
|
||||||
const untracked = await scanBatch(onDisk, async (relPath): Promise<DriftEntry | null> => {
|
const untracked = await scanBatch(onDisk, async (relPath): Promise<DriftEntry | null> => {
|
||||||
|
if (isPrivateConfigTarget(server, kind, relPath)) return null;
|
||||||
if (managed.has(`${kind}:${relPath}`)) return null;
|
if (managed.has(`${kind}:${relPath}`)) return null;
|
||||||
if (keepDefaultConfig && kind === 'config' && relPath === configFileOf(server))
|
if (keepDefaultConfig && kind === 'config' && relPath === configFileOf(server))
|
||||||
return null;
|
return null;
|
||||||
@@ -438,6 +440,7 @@ function driftTargets(servers?: string[]): string[] {
|
|||||||
async function describePlan(servers: string[], entries: DriftEntry[]): Promise<DriftPlan> {
|
async function describePlan(servers: string[], entries: DriftEntry[]): Promise<DriftPlan> {
|
||||||
const files: DriftPlanFile[] = [];
|
const files: DriftPlanFile[] = [];
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
|
if (isPrivateConfigTarget(entry.server, entry.kind, entry.relPath)) continue;
|
||||||
const before = await readFile(
|
const before = await readFile(
|
||||||
join(destOfKind(entry.server, entry.kind), entry.relPath)
|
join(destOfKind(entry.server, entry.kind), entry.relPath)
|
||||||
).catch((error: NodeJS.ErrnoException) => {
|
).catch((error: NodeJS.ErrnoException) => {
|
||||||
@@ -506,7 +509,9 @@ export interface PreparedDriftCampaign {
|
|||||||
/** Capture a rebuild's file evidence without changing the running instance. */
|
/** Capture a rebuild's file evidence without changing the running instance. */
|
||||||
export async function prepareDriftCampaign(server: string): Promise<PreparedDriftCampaign> {
|
export async function prepareDriftCampaign(server: string): Promise<PreparedDriftCampaign> {
|
||||||
const servers = driftTargets([server]);
|
const servers = driftTargets([server]);
|
||||||
const entries = await scanServerDrift(server);
|
const entries = (await scanServerDrift(server)).filter(
|
||||||
|
(entry) => !isPrivateConfigTarget(server, entry.kind, entry.relPath)
|
||||||
|
);
|
||||||
const files = await scanBatch(entries, async (entry) => {
|
const files = await scanBatch(entries, async (entry) => {
|
||||||
const before = await readExistingFile(join(destOfKind(server, entry.kind), entry.relPath));
|
const before = await readExistingFile(join(destOfKind(server, entry.kind), entry.relPath));
|
||||||
const after =
|
const after =
|
||||||
@@ -547,7 +552,11 @@ export function createDriftRebuildCampaign(
|
|||||||
incidentId: string | number,
|
incidentId: string | number,
|
||||||
prepared: PreparedDriftCampaign
|
prepared: PreparedDriftCampaign
|
||||||
): string | null {
|
): string | null {
|
||||||
if (!prepared.files.length) return null;
|
// Prepared evidence may have been created before the private-file exemption.
|
||||||
|
const files = prepared.files.filter(
|
||||||
|
({ file }) => !isPrivateConfigTarget(file.server, file.kind, file.relPath)
|
||||||
|
);
|
||||||
|
if (!files.length) return null;
|
||||||
if (
|
if (
|
||||||
prepared.plan.servers.length !== 1 ||
|
prepared.plan.servers.length !== 1 ||
|
||||||
prepared.plan.servers[0] !== server ||
|
prepared.plan.servers[0] !== server ||
|
||||||
@@ -575,7 +584,7 @@ export function createDriftRebuildCampaign(
|
|||||||
const insertFile = db().prepare(`INSERT INTO deployment_files
|
const insertFile = db().prepare(`INSERT INTO deployment_files
|
||||||
(deployment_id, server, verb, rel_path, sha256_before, sha256_after, content_before_hash, content_after_hash)
|
(deployment_id, server, verb, rel_path, sha256_before, sha256_after, content_before_hash, content_after_hash)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`);
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`);
|
||||||
for (const { file, before, after } of prepared.files) {
|
for (const { file, before, after } of files) {
|
||||||
insertFile.run(
|
insertFile.run(
|
||||||
id,
|
id,
|
||||||
server,
|
server,
|
||||||
@@ -592,7 +601,7 @@ export function createDriftRebuildCampaign(
|
|||||||
`INSERT INTO deployment_servers (deployment_id, server, files_changed, restart_state, provisioning_id)
|
`INSERT INTO deployment_servers (deployment_id, server, files_changed, restart_state, provisioning_id)
|
||||||
VALUES (?, ?, ?, 'pending', ?)`
|
VALUES (?, ?, ?, 'pending', ?)`
|
||||||
)
|
)
|
||||||
.run(id, server, prepared.files.length, operationId);
|
.run(id, server, files.length, operationId);
|
||||||
db()
|
db()
|
||||||
.prepare(
|
.prepare(
|
||||||
"INSERT INTO provisioning_sources (operation_id, kind, source_id, server) VALUES (?, 'campaign', ?, ?)"
|
"INSERT INTO provisioning_sources (operation_id, kind, source_id, server) VALUES (?, 'campaign', ?, ?)"
|
||||||
@@ -627,7 +636,9 @@ export async function runDriftDeployment(input: RunDriftInput = {}): Promise<Run
|
|||||||
entries: DriftEntry[];
|
entries: DriftEntry[];
|
||||||
}> = [];
|
}> = [];
|
||||||
for (const name of scanTargets) {
|
for (const name of scanTargets) {
|
||||||
const entries = await scanServerDrift(name);
|
const entries = (await scanServerDrift(name)).filter(
|
||||||
|
(entry) => !isPrivateConfigTarget(name, entry.kind, entry.relPath)
|
||||||
|
);
|
||||||
perServer.push({ server: name, removed: [], restored: [], skipped: [], entries });
|
perServer.push({ server: name, removed: [], restored: [], skipped: [], entries });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -692,6 +703,7 @@ export async function runDriftDeployment(input: RunDriftInput = {}): Promise<Run
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
for (const entry of s.entries) {
|
for (const entry of s.entries) {
|
||||||
|
if (isPrivateConfigTarget(s.server, entry.kind, entry.relPath)) continue;
|
||||||
const dest = destOfKind(s.server, entry.kind);
|
const dest = destOfKind(s.server, entry.kind);
|
||||||
const abs = join(dest, entry.relPath);
|
const abs = join(dest, entry.relPath);
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -171,6 +171,7 @@ function buildRecipe(
|
|||||||
path: script.target_rel_path,
|
path: script.target_rel_path,
|
||||||
versionId: version.id,
|
versionId: version.id,
|
||||||
version: version.version,
|
version: version.version,
|
||||||
|
releaseVersion: version.release_version,
|
||||||
sha256: version.sha256,
|
sha256: version.sha256,
|
||||||
size: version.size
|
size: version.size
|
||||||
});
|
});
|
||||||
@@ -187,6 +188,7 @@ function buildRecipe(
|
|||||||
kind: 'config',
|
kind: 'config',
|
||||||
versionId: null,
|
versionId: null,
|
||||||
version: null,
|
version: null,
|
||||||
|
releaseVersion: null,
|
||||||
sha256: sha256Buffer(content),
|
sha256: sha256Buffer(content),
|
||||||
size: content.length,
|
size: content.length,
|
||||||
...(includeContent ? { content: content.toString('base64') } : {})
|
...(includeContent ? { content: content.toString('base64') } : {})
|
||||||
@@ -249,18 +251,41 @@ export function recipeSummaryForDeployment(
|
|||||||
)
|
)
|
||||||
.get(id) as { recipe_summary: string | null; blueprint_name: string | null } | undefined;
|
.get(id) as { recipe_summary: string | null; blueprint_name: string | null } | undefined;
|
||||||
if (!row) return null;
|
if (!row) return null;
|
||||||
if (row.recipe_summary !== null)
|
if (row.recipe_summary !== null) {
|
||||||
return { recipe: JSON.parse(row.recipe_summary), blueprintName: row.blueprint_name };
|
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
|
// Old snapshots remain readable. Populate on first access, without making
|
||||||
// controller startup scan and decode every retained plugin in its history.
|
// controller startup scan and decode every retained plugin in its history.
|
||||||
const launch = recipeForDeployment(id)!;
|
const launch = recipeForDeployment(id)!;
|
||||||
const summary = summarizeRecipe(launch.recipe);
|
const summary = withHistoricalReleaseVersions(summarizeRecipe(launch.recipe));
|
||||||
db()
|
db()
|
||||||
.prepare('UPDATE server_launch_recipes SET recipe_summary = ? WHERE deployment_id = ?')
|
.prepare('UPDATE server_launch_recipes SET recipe_summary = ? WHERE deployment_id = ?')
|
||||||
.run(JSON.stringify(summary), id);
|
.run(JSON.stringify(summary), id);
|
||||||
return { recipe: summary, blueprintName: row.blueprint_name };
|
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(
|
export function recipeForDeployment(
|
||||||
id: string
|
id: string
|
||||||
): { recipe: LaunchRecipePlan; blueprintName: string | null } | null {
|
): { recipe: LaunchRecipePlan; blueprintName: string | null } | null {
|
||||||
|
|||||||
@@ -7,9 +7,10 @@ export function latestGameSubmission(server: string): string | null {
|
|||||||
.prepare(
|
.prepare(
|
||||||
`SELECT MAX(at) AS at FROM (
|
`SELECT MAX(at) AS at FROM (
|
||||||
SELECT MAX(submitted_at) AS at FROM server_deployments WHERE server=? AND server_deleted_at IS NULL AND action IN ('create','start','restart','rebuild')
|
SELECT MAX(submitted_at) AS at FROM server_deployments WHERE server=? AND server_deleted_at IS NULL AND action IN ('create','start','restart','rebuild')
|
||||||
UNION ALL SELECT requested_at AS at FROM restart_rollout WHERE server=?)`
|
UNION ALL SELECT requested_at AS at FROM restart_rollout WHERE server=?
|
||||||
|
UNION ALL SELECT generation_reconciled_at AS at FROM native_instances WHERE server=? AND retired_at IS NULL)`
|
||||||
)
|
)
|
||||||
.get(server, server) as { at: string | null }
|
.get(server, server, server) as { at: string | null }
|
||||||
).at;
|
).at;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -21,7 +22,8 @@ export function latestGameSubmissions(): Map<string, string | null> {
|
|||||||
SELECT s.name AS server, (SELECT MAX(submitted_at) FROM server_deployments
|
SELECT s.name AS server, (SELECT MAX(submitted_at) FROM server_deployments
|
||||||
WHERE server=s.name AND server_deleted_at IS NULL AND action IN ('create','start','restart','rebuild')) AS at
|
WHERE server=s.name AND server_deleted_at IS NULL AND action IN ('create','start','restart','rebuild')) AS at
|
||||||
FROM servers s
|
FROM servers s
|
||||||
UNION ALL SELECT server, requested_at AS at FROM restart_rollout) GROUP BY server`
|
UNION ALL SELECT server, requested_at AS at FROM restart_rollout
|
||||||
|
UNION ALL SELECT server, generation_reconciled_at AS at FROM native_instances WHERE retired_at IS NULL) GROUP BY server`
|
||||||
)
|
)
|
||||||
.all() as { server: string; at: string | null }[];
|
.all() as { server: string; at: string | null }[];
|
||||||
return new Map(rows.map((row) => [row.server, row.at]));
|
return new Map(rows.map((row) => [row.server, row.at]));
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,7 +32,7 @@ export function serverMatchHistory(
|
|||||||
now = Date.now()
|
now = Date.now()
|
||||||
): MatchHistoryResponse {
|
): MatchHistoryResponse {
|
||||||
const handle = db();
|
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> = [];
|
const parameters: Array<string | number> = [];
|
||||||
if (server !== undefined) {
|
if (server !== undefined) {
|
||||||
clauses.push('m.server = ?');
|
clauses.push('m.server = ?');
|
||||||
@@ -93,14 +93,18 @@ export function serverMatchHistory(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Read durable match records without depending on a current server or scoreboard. */
|
/** 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;
|
if (!Number.isSafeInteger(id) || id < 1) return null;
|
||||||
const handle = db();
|
const handle = db();
|
||||||
const match = handle
|
const match = handle
|
||||||
.prepare(
|
.prepare(
|
||||||
`SELECT ${matchSummary}, m.start_observed AS startObserved
|
`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}`
|
AND m.highest_round >= ${MIN_RECORDED_ROUND}`
|
||||||
|
}`
|
||||||
)
|
)
|
||||||
.get(id) as
|
.get(id) as
|
||||||
| (Omit<MatchDetail, 'players' | 'chat' | 'startObserved'> & { startObserved: number })
|
| (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
|
(SELECT identity.name FROM leaderboard_players identity
|
||||||
WHERE identity.guid = p.guid AND identity.game = ?), p.guid) AS name,
|
WHERE identity.guid = p.guid AND identity.game = ?), p.guid) AS name,
|
||||||
p.has_stats AS hasStats,
|
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,
|
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.highest_round END AS highestRound,
|
||||||
CASE WHEN p.has_stats = 1 THEN p.rounds_survived END AS roundsSurvived,
|
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 = ?
|
FROM leaderboard_match_players p WHERE p.match_id = ?
|
||||||
ORDER BY hasStats DESC, kills DESC, highestRound DESC, name COLLATE NOCASE, p.guid`
|
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'> & {
|
Omit<MatchPlayer, 'hasStats' | 'presentAtEnd' | 'canViewProfile'> & {
|
||||||
hasStats: number;
|
hasStats: number;
|
||||||
presentAtEnd: number;
|
presentAtEnd: number;
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ function readOperations(now: number, server?: string): FleetOperation[] {
|
|||||||
? 'Completed with warnings'
|
? 'Completed with warnings'
|
||||||
: 'Complete'
|
: 'Complete'
|
||||||
: state === 'attention'
|
: state === 'attention'
|
||||||
? `${actionLabels[row.action as ProvisioningAction]} needs attention`
|
? `${actionLabels[row.action as ProvisioningAction]} operation needs attention`
|
||||||
: row.stage,
|
: row.stage,
|
||||||
detail: row.error ?? row.stage,
|
detail: row.error ?? row.stage,
|
||||||
href: `/provisioning/${encodeURIComponent(row.id)}`
|
href: `/provisioning/${encodeURIComponent(row.id)}`
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
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 {
|
||||||
|
// 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)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isPrivateConfigTarget(server: string, kind: string, path: string): boolean {
|
||||||
|
if (kind !== 'script' && kind !== 'config' && kind !== 'baseline' && kind !== 'plugin')
|
||||||
|
throw new Error('Invalid artifact kind');
|
||||||
|
const game = gameOfServer(server);
|
||||||
|
return isPrivateConfigPath(
|
||||||
|
posix.join(relDestForKind(kind, game), path.replaceAll('\\', '/')),
|
||||||
|
game
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertPublicFileTarget(server: string, kind: ArtifactKind, path: string): void {
|
||||||
|
if (isPrivateConfigTarget(server, kind, path))
|
||||||
|
throw new Error(
|
||||||
|
'Private bank configuration and moderation configuration cannot be captured or changed by a file campaign.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isReservedConfigFilename(path: string): boolean {
|
||||||
|
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 (
|
||||||
|
PRIVATE_CONFIG_FILES.some(
|
||||||
|
(filename) => posix.normalize(path.replaceAll('\\', '/')) === filename
|
||||||
|
) || isPrivateConfigPath(path)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Credential injection uses a private configPath, never a shared file's path field. */
|
||||||
|
export class PrivateFileOperationError extends Error {
|
||||||
|
constructor() {
|
||||||
|
super(
|
||||||
|
'Private bank configuration and moderation configuration cannot be read or changed by file operations.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertPublicFilePayload(value: unknown): void {
|
||||||
|
if (!value || typeof value !== 'object') return;
|
||||||
|
if ('path' in value && typeof value.path === 'string' && isPrivateConfigPath(value.path))
|
||||||
|
throw new PrivateFileOperationError();
|
||||||
|
for (const item of Object.values(value)) assertPublicFilePayload(item);
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { isReservedConfigFilename } from './private-config.js';
|
||||||
import { scriptVersionError } from '../../script-version.js';
|
import { scriptVersionError } from '../../script-version.js';
|
||||||
import { putFileBlob, blobContent } from '../db/file-blobs.js';
|
import { putFileBlob, blobContent } from '../db/file-blobs.js';
|
||||||
import { createHash } from 'node:crypto';
|
import { createHash } from 'node:crypto';
|
||||||
@@ -62,6 +63,7 @@ export class ScriptError extends Error {
|
|||||||
| 'name-empty'
|
| 'name-empty'
|
||||||
| 'name-taken'
|
| 'name-taken'
|
||||||
| 'path-empty'
|
| 'path-empty'
|
||||||
|
| 'path-reserved'
|
||||||
| 'no-such-script'
|
| 'no-such-script'
|
||||||
| 'no-such-version'
|
| 'no-such-version'
|
||||||
| 'invalid-scope'
|
| 'invalid-scope'
|
||||||
@@ -190,12 +192,17 @@ export function getVersion(id: number): ScriptVersionRow | undefined {
|
|||||||
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. */
|
/** Planning a launch never needs to load potentially large script or plugin bytes. */
|
||||||
export function getVersionMetadata(id: number): ScriptVersionMetadata | undefined {
|
export function getVersionMetadata(id: number): ScriptVersionMetadata | undefined {
|
||||||
return db()
|
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;
|
.get(id) as ScriptVersionMetadata | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -249,6 +256,11 @@ export function createScript(input: CreateScriptInput): {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (!targetRelPath) throw new ScriptError('target rel path is required', 'path-empty');
|
if (!targetRelPath) throw new ScriptError('target rel path is required', 'path-empty');
|
||||||
|
if (isReservedConfigFilename(targetRelPath))
|
||||||
|
throw new ScriptError(
|
||||||
|
`${targetRelPath} is reserved for instance credentials.`,
|
||||||
|
'path-reserved'
|
||||||
|
);
|
||||||
|
|
||||||
// Kind-specific validation.
|
// Kind-specific validation.
|
||||||
if (kind === 'baseline') {
|
if (kind === 'baseline') {
|
||||||
|
|||||||
@@ -190,14 +190,17 @@ export function listServers(): ServerRow[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Beta ownership lasts until explicit closure, regardless of test outcome or stage. */
|
/** Beta ownership lasts until explicit closure, regardless of test outcome or stage. */
|
||||||
export function activeBetaRemovalBlocker(server: string): string | null {
|
export function activeBetaRemovalBlocker(server: string, activationId?: string): string | null {
|
||||||
const betas = db()
|
const betas = db()
|
||||||
.prepare(
|
.prepare(
|
||||||
`SELECT b.id FROM beta_servers bs JOIN betas b ON b.id = bs.beta_id
|
`SELECT b.id FROM beta_servers bs JOIN betas b ON b.id = bs.beta_id
|
||||||
WHERE bs.server = ? AND bs.server_deleted_at IS NULL AND b.state IN ('running', 'ready-promote')
|
WHERE bs.server = ? AND bs.server_deleted_at IS NULL AND b.state IN ('running', 'ready-promote')
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM provisioning_sources s WHERE s.operation_id=?
|
||||||
|
AND s.kind='beta' AND s.source_id=CAST(b.id AS TEXT) AND s.server=bs.server
|
||||||
|
AND bs.provisioning_id=s.operation_id)
|
||||||
ORDER BY b.id`
|
ORDER BY b.id`
|
||||||
)
|
)
|
||||||
.all(server) as { id: number }[];
|
.all(server, activationId ?? null) as { id: number }[];
|
||||||
if (!betas.length) return null;
|
if (!betas.length) return null;
|
||||||
return `${server} is linked to active beta${betas.length === 1 ? '' : 's'} ${betas.map((beta) => '#' + beta.id).join(', ')}. Promote or roll back the beta${betas.length === 1 ? '' : 's'} before destroying or rebuilding this server.`;
|
return `${server} is linked to active beta${betas.length === 1 ? '' : 's'} ${betas.map((beta) => '#' + beta.id).join(', ')}. Promote or roll back the beta${betas.length === 1 ? '' : 's'} before destroying or rebuilding this server.`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { assertPublicFileTarget } from './private-config.js';
|
||||||
import { putFileBlob, blobContent } from '../db/file-blobs.js';
|
import { putFileBlob, blobContent } from '../db/file-blobs.js';
|
||||||
import { mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
import { mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
||||||
import { dirname, relative, resolve, isAbsolute } from 'node:path';
|
import { dirname, relative, resolve, isAbsolute } from 'node:path';
|
||||||
@@ -39,6 +40,7 @@ export interface StagedFileChange {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function targetPath(server: string, kind: ArtifactKind, relPath: string): string {
|
function targetPath(server: string, kind: ArtifactKind, relPath: string): string {
|
||||||
|
assertPublicFileTarget(server, kind, relPath);
|
||||||
const root = resolve(destOfKind(server, kind));
|
const root = resolve(destOfKind(server, kind));
|
||||||
const target = resolve(root, relPath);
|
const target = resolve(root, relPath);
|
||||||
const rel = relative(root, target);
|
const rel = relative(root, target);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { GameTelemetry, ServerTiming } from '../../telemetry.js';
|
|||||||
import { MIN_RECORDED_ROUND } from '../../match-history.js';
|
import { MIN_RECORDED_ROUND } from '../../match-history.js';
|
||||||
import { recordLeaderboardTelemetry } from './leaderboard-ingest.js';
|
import { recordLeaderboardTelemetry } from './leaderboard-ingest.js';
|
||||||
import { latestGameSubmission } from './status-evidence.js';
|
import { latestGameSubmission } from './status-evidence.js';
|
||||||
|
import { observeModerationPlayers } from '../moderation/core.js';
|
||||||
|
|
||||||
export function listTelemetry(): GameTelemetry[] {
|
export function listTelemetry(): GameTelemetry[] {
|
||||||
const rows = db()
|
const rows = db()
|
||||||
@@ -33,6 +34,13 @@ export function saveTelemetryWithOutcome(report: GameTelemetry): {
|
|||||||
? JSON.parse(row.payload)
|
? JSON.parse(row.payload)
|
||||||
: undefined;
|
: undefined;
|
||||||
const recorded = recordLeaderboardTelemetry(handle, report);
|
const recorded = recordLeaderboardTelemetry(handle, report);
|
||||||
|
const game = (
|
||||||
|
handle
|
||||||
|
.prepare('SELECT game FROM leaderboard_matches WHERE id=?')
|
||||||
|
.get(recorded.matchId) as { game: string }
|
||||||
|
).game;
|
||||||
|
if (game === 't5' || game === 't6')
|
||||||
|
observeModerationPlayers(handle, game, report.players, report.receivedAt);
|
||||||
if (!recorded.current) {
|
if (!recorded.current) {
|
||||||
return {
|
return {
|
||||||
telemetry: { ...report, timing: { matchId: recorded.matchId, players: {} } },
|
telemetry: { ...report, timing: { matchId: recorded.matchId, players: {} } },
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { db } from '../db/index.js';
|
import { db } from '../db/index.js';
|
||||||
import type { CapacityHistory, CapacityQuery, CapacityRange } from '../../capacity.js';
|
import type { CapacityHistory, CapacityQuery, CapacityRange } from '../../capacity.js';
|
||||||
|
import type { HostMode } from '../../hosts.js';
|
||||||
|
import { readTemperatureHistory } from './temperatures.js';
|
||||||
|
|
||||||
const windows: Record<CapacityRange, { duration: number; bucketMs: number }> = {
|
const windows: Record<CapacityRange, { duration: number; bucketMs: number }> = {
|
||||||
'1h': { duration: 3_600_000, bucketMs: 60_000 },
|
'1h': { duration: 3_600_000, bucketMs: 60_000 },
|
||||||
@@ -15,10 +17,15 @@ export function readCapacityHistory(query: CapacityQuery, now = Date.now()): Cap
|
|||||||
const count = duration / bucketMs;
|
const count = duration / bucketMs;
|
||||||
const hosts = handle
|
const hosts = handle
|
||||||
.prepare(
|
.prepare(
|
||||||
`SELECT id,created_at FROM ec_hosts
|
`SELECT id,name,created_at,mode FROM ec_hosts
|
||||||
WHERE deleted_at IS NULL ${query.hostId ? 'AND id=?' : ''} ORDER BY id`
|
WHERE deleted_at IS NULL ${query.hostId ? 'AND id=?' : ''} ORDER BY id`
|
||||||
)
|
)
|
||||||
.all(...(query.hostId ? [query.hostId] : [])) as { id: string; created_at: string }[];
|
.all(...(query.hostId ? [query.hostId] : [])) as {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
created_at: string;
|
||||||
|
mode: HostMode;
|
||||||
|
}[];
|
||||||
// The primary key bounds each host scan to the selected interval. Aggregate
|
// The primary key bounds each host scan to the selected interval. Aggregate
|
||||||
// before crossing the worker boundary; do not return raw heartbeat rows.
|
// before crossing the worker boundary; do not return raw heartbeat rows.
|
||||||
const samples = handle.prepare(`SELECT
|
const samples = handle.prepare(`SELECT
|
||||||
@@ -39,6 +46,9 @@ export function readCapacityHistory(query: CapacityQuery, now = Date.now()): Cap
|
|||||||
expectedHosts: 0
|
expectedHosts: 0
|
||||||
}));
|
}));
|
||||||
for (const host of hosts) {
|
for (const host of hosts) {
|
||||||
|
// Current mode determines available capacity; keep inactive hosts in
|
||||||
|
// the separate temperature history for diagnostics.
|
||||||
|
if (host.mode !== 'active') continue;
|
||||||
const createdAt = Date.parse(host.created_at);
|
const createdAt = Date.parse(host.created_at);
|
||||||
for (const point of points) {
|
for (const point of points) {
|
||||||
if (createdAt <= Math.min(now, point.at + bucketMs)) point.expectedHosts++;
|
if (createdAt <= Math.min(now, point.at + bucketMs)) point.expectedHosts++;
|
||||||
@@ -69,5 +79,12 @@ export function readCapacityHistory(query: CapacityQuery, now = Date.now()): Cap
|
|||||||
point.memoryBytesAvailable = null;
|
point.memoryBytesAvailable = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { range: query.range, startAt, endAt: now, bucketMs, points };
|
return {
|
||||||
|
range: query.range,
|
||||||
|
startAt,
|
||||||
|
endAt: now,
|
||||||
|
bucketMs,
|
||||||
|
points,
|
||||||
|
temperatures: readTemperatureHistory(query, hosts, startAt, now, bucketMs)
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
|
import {
|
||||||
|
assertPublicFilePayload,
|
||||||
|
PRIVATE_CONFIG_FILES,
|
||||||
|
PrivateFileOperationError,
|
||||||
|
isPrivateConfigPath
|
||||||
|
} from '../fleet/private-config.js';
|
||||||
import { packFileJson, unpackFileJson, referenceBlobs } from '../db/file-blobs.js';
|
import { packFileJson, unpackFileJson, referenceBlobs } from '../db/file-blobs.js';
|
||||||
import { pruneHistory } from '../retention.js';
|
import { pruneHistory } from '../retention.js';
|
||||||
import { recordAgentReceipt } from '../provisioning/agent-events.js';
|
import { recordAgentReceipt } from '../provisioning/agent-events.js';
|
||||||
@@ -17,6 +23,8 @@ import { logResultMetadata } from './log-results.js';
|
|||||||
import { parseHostInventory } from './request-context.js';
|
import { parseHostInventory } from './request-context.js';
|
||||||
import { fleetSettings } from '../settings.js';
|
import { fleetSettings } from '../settings.js';
|
||||||
import { recordInstanceResources, validateInstanceResources } from './calibration.js';
|
import { recordInstanceResources, validateInstanceResources } from './calibration.js';
|
||||||
|
import { validateTemperatures } from '../../host-temperatures.js';
|
||||||
|
import { recordTemperatures } from './temperatures.js';
|
||||||
import { refreshHostAvailability, scheduleAvailabilityRefresh } from './availability.js';
|
import { refreshHostAvailability, scheduleAvailabilityRefresh } from './availability.js';
|
||||||
import { hostAgentLaneLabel, hostAgentLaneState } from '../../hosts.js';
|
import { hostAgentLaneLabel, hostAgentLaneState } from '../../hosts.js';
|
||||||
import type {
|
import type {
|
||||||
@@ -616,6 +624,8 @@ export function validateInventory(value: unknown): HostInventory {
|
|||||||
throw new Error('Host inventory must be an object.');
|
throw new Error('Host inventory must be an object.');
|
||||||
const inventory = value as HostInventory;
|
const inventory = value as HostInventory;
|
||||||
if (JSON.stringify(inventory).length > 256_000) throw new Error('Host inventory is too large.');
|
if (JSON.stringify(inventory).length > 256_000) throw new Error('Host inventory is too large.');
|
||||||
|
if (inventory.temperatures !== undefined)
|
||||||
|
inventory.temperatures = validateTemperatures(inventory.temperatures);
|
||||||
for (const key of ['hostname', 'agentVersion', 'os', 'architecture', 'cpu', 'dockerVersion']) {
|
for (const key of ['hostname', 'agentVersion', 'os', 'architecture', 'cpu', 'dockerVersion']) {
|
||||||
if (
|
if (
|
||||||
inventory[key] !== undefined &&
|
inventory[key] !== undefined &&
|
||||||
@@ -918,6 +928,7 @@ export function heartbeat(hostId: string, value: unknown): Host {
|
|||||||
|
|
||||||
function recordResources(hostId: string, inventory: HostInventory, timestamp: string): void {
|
function recordResources(hostId: string, inventory: HostInventory, timestamp: string): void {
|
||||||
recordInstanceResources(hostId, inventory, timestamp);
|
recordInstanceResources(hostId, inventory, timestamp);
|
||||||
|
recordTemperatures(hostId, inventory, timestamp);
|
||||||
database()
|
database()
|
||||||
.prepare(
|
.prepare(
|
||||||
`INSERT OR REPLACE INTO ec_host_resource_samples
|
`INSERT OR REPLACE INTO ec_host_resource_samples
|
||||||
@@ -1248,7 +1259,9 @@ function enqueueHostOperation(
|
|||||||
)
|
)
|
||||||
throw new Error('Host is disabled. Enable it before requesting operations.');
|
throw new Error('Host is disabled. Enable it before requesting operations.');
|
||||||
const requestHash = createHash('sha256').update(payload).digest('hex');
|
const requestHash = createHash('sha256').update(payload).digest('hex');
|
||||||
const packed = packFileJson(JSON.parse(payload.toString()));
|
const parsedPayload = JSON.parse(payload.toString());
|
||||||
|
assertPublicFilePayload(parsedPayload);
|
||||||
|
const packed = packFileJson(parsedPayload);
|
||||||
payload = JSON.stringify(packed.value);
|
payload = JSON.stringify(packed.value);
|
||||||
const id = options.id ?? randomUUID();
|
const id = options.id ?? randomUUID();
|
||||||
const timestamp = now();
|
const timestamp = now();
|
||||||
@@ -1405,6 +1418,15 @@ export function pendingHostOperationRows(
|
|||||||
)
|
)
|
||||||
: new Set<string>();
|
: new Set<string>();
|
||||||
const admitted = rows.filter((operation) => {
|
const admitted = rows.filter((operation) => {
|
||||||
|
// Withhold unsafe legacy commands without breaking the entire delivery stream.
|
||||||
|
if (operation.kind.startsWith('files.') || operation.kind === 'logs.read') {
|
||||||
|
try {
|
||||||
|
getHostOperationPayload(hostId, operation.id);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof PrivateFileOperationError) return false;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
!libraryKinds.includes(operation.kind) ||
|
!libraryKinds.includes(operation.kind) ||
|
||||||
preparing.has(hostId) ||
|
preparing.has(hostId) ||
|
||||||
@@ -1431,6 +1453,9 @@ export function getHostOperationPayload(hostId: string, id: string): Buffer {
|
|||||||
)
|
)
|
||||||
.get(id, hostId) as { payload: Buffer } | undefined;
|
.get(id, hostId) as { payload: Buffer } | undefined;
|
||||||
if (!row) throw new Error('Host operation was not found for this host.');
|
if (!row) throw new Error('Host operation was not found for this host.');
|
||||||
|
// Block old private-file commands before delivery to agents predating the guard.
|
||||||
|
if (PRIVATE_CONFIG_FILES.some((filename) => row.payload.includes(filename)))
|
||||||
|
assertPublicFilePayload(JSON.parse(row.payload.toString()));
|
||||||
if (
|
if (
|
||||||
!row.payload.includes('"contentSha256"') ||
|
!row.payload.includes('"contentSha256"') ||
|
||||||
getHost(hostId)?.inventory.capabilities?.includes('files.blobs')
|
getHost(hostId)?.inventory.capabilities?.includes('files.blobs')
|
||||||
@@ -1460,16 +1485,33 @@ export function reportHostOperationStatus(
|
|||||||
if (['completed', 'failed'].includes(operation.state)) return operation;
|
if (['completed', 'failed'].includes(operation.state)) return operation;
|
||||||
const state = input.state as HostOperationState;
|
const state = input.state as HostOperationState;
|
||||||
if (operation.state === 'running' && state === 'accepted') return operation;
|
if (operation.state === 'running' && state === 'accepted') return operation;
|
||||||
const error =
|
let error =
|
||||||
input.error === undefined || input.error === null
|
input.error === undefined || input.error === null
|
||||||
? null
|
? null
|
||||||
: String(input.error).slice(0, 4000);
|
: String(input.error).slice(0, 4000);
|
||||||
|
if (operation.kind === 'instance.configure-moderation' && error)
|
||||||
|
error = error.replace(
|
||||||
|
/ec_(?:moderation|bank)_[A-Za-z0-9_-]+/g,
|
||||||
|
'[redacted credential]'
|
||||||
|
);
|
||||||
let result = input.result === undefined ? null : JSON.stringify(input.result);
|
let result = input.result === undefined ? null : JSON.stringify(input.result);
|
||||||
if (result !== null && Buffer.byteLength(result) > 128 * 1024 * 1024)
|
if (result !== null && Buffer.byteLength(result) > 128 * 1024 * 1024)
|
||||||
throw new Error('Operation result exceeds the limit.');
|
throw new Error('Operation result exceeds the limit.');
|
||||||
if (result !== null) {
|
if (result !== null) {
|
||||||
|
const target =
|
||||||
|
operation.kind === 'files.read'
|
||||||
|
? (database()
|
||||||
|
.prepare('SELECT target_path FROM ec_host_operations WHERE id=?')
|
||||||
|
.get(id) as { target_path: string | null })
|
||||||
|
: null;
|
||||||
const packed = packFileJson(
|
const packed = packFileJson(
|
||||||
operation.kind === 'logs.read' ? logResultMetadata(input.result) : input.result
|
target?.target_path && isPrivateConfigPath(target.target_path)
|
||||||
|
? { redacted: true }
|
||||||
|
: operation.kind === 'logs.read'
|
||||||
|
? logResultMetadata(input.result)
|
||||||
|
: operation.kind === 'instance.configure-moderation'
|
||||||
|
? moderationConfigurationReceipt(input.result)
|
||||||
|
: input.result
|
||||||
);
|
);
|
||||||
result = JSON.stringify(packed.value);
|
result = JSON.stringify(packed.value);
|
||||||
referenceBlobs('operation-result', id, packed.hashes);
|
referenceBlobs('operation-result', id, packed.hashes);
|
||||||
@@ -1533,6 +1575,22 @@ export function reportHostOperationStatus(
|
|||||||
queueMicrotask(() => workEvents.emit(`operation:${id}`));
|
queueMicrotask(() => workEvents.emit(`operation:${id}`));
|
||||||
return operation;
|
return operation;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Configuration replies contain status only, never echoed dvars, file bodies or credentials. */
|
||||||
|
function moderationConfigurationReceipt(value: unknown): Record<string, unknown> {
|
||||||
|
const result =
|
||||||
|
value && typeof value === 'object' && !Array.isArray(value)
|
||||||
|
? (value as Record<string, unknown>)
|
||||||
|
: {};
|
||||||
|
return {
|
||||||
|
configured: result.configured === true,
|
||||||
|
...(Number.isSafeInteger(result.generation) ? { generation: result.generation } : {}),
|
||||||
|
liveLoad: ['sent', 'unconfirmed', 'deferred'].includes(result.liveLoad as string)
|
||||||
|
? result.liveLoad
|
||||||
|
: 'unconfirmed',
|
||||||
|
...(result.reconciled === true ? { reconciled: true } : {})
|
||||||
|
};
|
||||||
|
}
|
||||||
export function reportHostOperation(
|
export function reportHostOperation(
|
||||||
hostId: string,
|
hostId: string,
|
||||||
id: string,
|
id: string,
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import type Database from 'better-sqlite3';
|
||||||
|
import { db } from '../db/index.js';
|
||||||
|
import { historyTemperatures } from '../../host-temperatures.js';
|
||||||
|
import type { HostInventory } from '../../hosts.js';
|
||||||
|
import type { CapacityQuery, TemperatureHistorySeries } from '../../capacity.js';
|
||||||
|
|
||||||
|
export function migrateTemperatures(handle: Database.Database): void {
|
||||||
|
handle.exec(`CREATE TABLE IF NOT EXISTS ec_host_temperature_samples (
|
||||||
|
host_id TEXT NOT NULL REFERENCES ec_hosts(id), bucket TEXT NOT NULL,
|
||||||
|
sensor_id TEXT NOT NULL, kind TEXT NOT NULL, label TEXT NOT NULL,
|
||||||
|
celsius REAL NOT NULL CHECK(celsius BETWEEN -100 AND 200),
|
||||||
|
PRIMARY KEY(host_id,bucket,sensor_id)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS ec_host_temperatures_retention ON ec_host_temperature_samples(bucket);`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function recordTemperatures(
|
||||||
|
hostId: string,
|
||||||
|
inventory: HostInventory,
|
||||||
|
timestamp: string
|
||||||
|
): void {
|
||||||
|
const sensors = historyTemperatures(inventory.temperatures);
|
||||||
|
if (!sensors.length) return;
|
||||||
|
const bucket = new Date(Math.floor(Date.parse(timestamp) / 60_000) * 60_000).toISOString();
|
||||||
|
const insert = db().prepare(`INSERT INTO ec_host_temperature_samples
|
||||||
|
(host_id,bucket,sensor_id,kind,label,celsius) VALUES(?,?,?,?,?,?)
|
||||||
|
ON CONFLICT(host_id,bucket,sensor_id) DO UPDATE SET
|
||||||
|
celsius=MAX(celsius,excluded.celsius),kind=excluded.kind,label=excluded.label`);
|
||||||
|
for (const sensor of sensors)
|
||||||
|
insert.run(hostId, bucket, sensor.id, sensor.kind, sensor.label, sensor.celsius);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Runs on the capacity report worker, returning at most 168 points per series. */
|
||||||
|
export function readTemperatureHistory(
|
||||||
|
query: CapacityQuery,
|
||||||
|
hosts: { id: string; name: string; created_at: string }[],
|
||||||
|
startAt: number,
|
||||||
|
endAt: number,
|
||||||
|
bucketMs: number
|
||||||
|
): TemperatureHistorySeries[] {
|
||||||
|
const count = Math.round((endAt - startAt) / bucketMs);
|
||||||
|
const statement = db().prepare(`SELECT sensor_id,kind,label,
|
||||||
|
MIN(?,MAX(0,CAST((CAST(strftime('%s',bucket) AS INTEGER)*1000-?) / ? AS INTEGER))) AS period,
|
||||||
|
MAX(celsius) AS celsius
|
||||||
|
FROM ec_host_temperature_samples WHERE host_id=? AND bucket>=? AND bucket<=?
|
||||||
|
${query.hostId ? '' : "AND sensor_id='cpu'"}
|
||||||
|
GROUP BY sensor_id,period ORDER BY sensor_id,period`);
|
||||||
|
const result: TemperatureHistorySeries[] = [];
|
||||||
|
for (const host of hosts) {
|
||||||
|
const bySensor = new Map<string, TemperatureHistorySeries>();
|
||||||
|
const add = (id: string, kind: TemperatureHistorySeries['kind'], label: string) => {
|
||||||
|
const series = {
|
||||||
|
id: `${host.id}:${id}`,
|
||||||
|
hostId: host.id,
|
||||||
|
kind,
|
||||||
|
label,
|
||||||
|
points: Array.from({ length: count }, (_, i) => ({
|
||||||
|
at: startAt + i * bucketMs,
|
||||||
|
celsius: null as number | null
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
bySensor.set(id, series);
|
||||||
|
result.push(series);
|
||||||
|
return series;
|
||||||
|
};
|
||||||
|
add('cpu', 'cpu', query.hostId ? 'CPU temperature' : host.name);
|
||||||
|
const rows = statement.all(
|
||||||
|
count - 1,
|
||||||
|
startAt,
|
||||||
|
bucketMs,
|
||||||
|
host.id,
|
||||||
|
new Date(
|
||||||
|
Math.max(
|
||||||
|
Math.floor(Date.parse(host.created_at) / 60_000) * 60_000,
|
||||||
|
Math.floor(startAt / 60_000) * 60_000
|
||||||
|
)
|
||||||
|
).toISOString(),
|
||||||
|
new Date(endAt).toISOString()
|
||||||
|
) as { sensor_id: string; kind: string; label: string; period: number; celsius: number }[];
|
||||||
|
for (const row of rows) {
|
||||||
|
let series = bySensor.get(row.sensor_id);
|
||||||
|
if (!series) {
|
||||||
|
if (!['nvme', 'chipset'].includes(row.kind) || bySensor.size >= 17) continue;
|
||||||
|
const kind = row.kind as 'nvme' | 'chipset';
|
||||||
|
const number = [...bySensor.values()].filter((s) => s.kind === kind).length + 1;
|
||||||
|
series = add(
|
||||||
|
row.sensor_id,
|
||||||
|
kind,
|
||||||
|
`${kind === 'nvme' ? 'NVMe drive' : 'Chipset'} ${number}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (series.points[row.period]) series.points[row.period].celsius = row.celsius;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
@@ -710,7 +710,15 @@ async function scanServer(server: string, forceScan: boolean): Promise<void> {
|
|||||||
setSetting(driftKey, JSON.stringify({ identity, at: iso() }));
|
setSetting(driftKey, JSON.stringify({ identity, at: iso() }));
|
||||||
if (!prepared.plan.files.length) {
|
if (!prepared.plan.files.length) {
|
||||||
const current = active(server, 'drift');
|
const current = active(server, 'drift');
|
||||||
if (current) transition(current.id, 'resolved', 'Server files match the promoted setup');
|
if (current) {
|
||||||
|
db()
|
||||||
|
.prepare('UPDATE watchdog_incidents SET details=? WHERE id=?')
|
||||||
|
.run(
|
||||||
|
JSON.stringify({ files: [], fingerprint: prepared.plan.fingerprint }),
|
||||||
|
current.id
|
||||||
|
);
|
||||||
|
transition(current.id, 'resolved', 'Server files match the promoted setup');
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const problem = detect(
|
const problem = detect(
|
||||||
|
|||||||
@@ -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.');
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { AuthError } from '../auth/index.js';
|
||||||
|
import { nativeInstance } from '../backends/native.js';
|
||||||
|
import { db } from '../db/index.js';
|
||||||
|
import { getHost, queueHostOperation } from '../hosts/index.js';
|
||||||
|
import { moderationActor, moderationEvent } from './core.js';
|
||||||
|
import { moderationCredential } from './credentials.js';
|
||||||
|
import { moderationConfiguration, moderationConfigureCapability } from './configuration.js';
|
||||||
|
|
||||||
|
/** Persist a private, generation-bound bootstrap without exposing the credential to the browser. */
|
||||||
|
export function configureModerationServer(actorId: string, server: string): { id: string } {
|
||||||
|
return db()
|
||||||
|
.transaction(() => {
|
||||||
|
const actor = moderationActor(actorId, true);
|
||||||
|
if (actor.role !== 'admin')
|
||||||
|
throw new AuthError(
|
||||||
|
'Administrator access is required to configure game moderation.',
|
||||||
|
403
|
||||||
|
);
|
||||||
|
const instance = nativeInstance(server);
|
||||||
|
const live = db()
|
||||||
|
.prepare('SELECT destroyed_at FROM servers WHERE name=?')
|
||||||
|
.get(server) as { destroyed_at: string | null } | undefined;
|
||||||
|
if (!instance || !live || live.destroyed_at)
|
||||||
|
throw new AuthError('Choose an active native server.', 404);
|
||||||
|
const host = getHost(instance.host_id);
|
||||||
|
if (!host?.inventory.capabilities?.includes(moderationConfigureCapability))
|
||||||
|
throw new AuthError(
|
||||||
|
'Update the host agent before configuring game moderation.',
|
||||||
|
409
|
||||||
|
);
|
||||||
|
const moderation = moderationConfiguration(
|
||||||
|
moderationCredential(instance.id, instance.generation),
|
||||||
|
instance.generation
|
||||||
|
);
|
||||||
|
const operation = queueHostOperation(
|
||||||
|
instance.host_id,
|
||||||
|
'instance.configure-moderation',
|
||||||
|
{ moderation },
|
||||||
|
{
|
||||||
|
instanceId: instance.id,
|
||||||
|
generation: instance.generation
|
||||||
|
}
|
||||||
|
);
|
||||||
|
moderationEvent(
|
||||||
|
actor,
|
||||||
|
'runtime.configure',
|
||||||
|
{},
|
||||||
|
operation.id,
|
||||||
|
`Configure game moderation on ${server}.`
|
||||||
|
);
|
||||||
|
return { id: operation.id };
|
||||||
|
})
|
||||||
|
.immediate();
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import type Database from 'better-sqlite3';
|
||||||
|
import { db } from '../db/index.js';
|
||||||
|
import { getUser, AuthError } from '../auth/index.js';
|
||||||
|
import { canModerate, canReadModeration, type AuthUser } from '../../auth.js';
|
||||||
|
import { moderationGuid, type ModerationGame } from '../../moderation.js';
|
||||||
|
|
||||||
|
export const moderationNow = () => new Date().toISOString();
|
||||||
|
export function moderationActor(id: string, write = false): AuthUser {
|
||||||
|
const user = getUser(id);
|
||||||
|
if (
|
||||||
|
!user ||
|
||||||
|
user.disabled ||
|
||||||
|
user.mustChangePassword ||
|
||||||
|
!(write ? canModerate(user.role) : canReadModeration(user.role))
|
||||||
|
)
|
||||||
|
throw new AuthError('Moderation access is required.', 403);
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
export function textField(value: unknown, label: string, maximum = 500, optional = false): string {
|
||||||
|
if (optional && (value === undefined || value === null || value === '')) return '';
|
||||||
|
if (
|
||||||
|
typeof value !== 'string' ||
|
||||||
|
!value.trim() ||
|
||||||
|
value.length > maximum ||
|
||||||
|
// eslint-disable-next-line no-control-regex -- Bound all user-controlled moderation text.
|
||||||
|
/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/.test(value)
|
||||||
|
)
|
||||||
|
throw new AuthError(`${label} must contain 1–${maximum} printable characters.`);
|
||||||
|
return value.trim();
|
||||||
|
}
|
||||||
|
export function subject(input: Record<string, unknown>): { game: ModerationGame; guid: string } {
|
||||||
|
if (input.game !== 't5' && input.game !== 't6')
|
||||||
|
throw new AuthError('Choose Black Ops I or Black Ops II.');
|
||||||
|
const raw = textField(input.guid, 'Player GUID', 128);
|
||||||
|
if (!/^[A-Za-z0-9:_-]+$/.test(raw)) throw new AuthError('Invalid player GUID.');
|
||||||
|
return { game: input.game, guid: moderationGuid(input.game, raw) };
|
||||||
|
}
|
||||||
|
export function observeModerationPlayers(
|
||||||
|
handle: Database.Database,
|
||||||
|
game: ModerationGame,
|
||||||
|
players: { guid: string; name: string }[],
|
||||||
|
now: string
|
||||||
|
): void {
|
||||||
|
const upsert = handle.prepare(`INSERT INTO moderation_players VALUES(?,?,?,?,?)
|
||||||
|
ON CONFLICT(game,guid) DO UPDATE SET name=CASE WHEN excluded.last_seen_at>=last_seen_at THEN excluded.name ELSE name END,
|
||||||
|
first_seen_at=CASE WHEN first_seen_at='' THEN excluded.first_seen_at ELSE MIN(first_seen_at,excluded.first_seen_at) END,last_seen_at=MAX(last_seen_at,excluded.last_seen_at)`);
|
||||||
|
const aliases = handle.prepare(`INSERT INTO moderation_aliases VALUES(?,?,?,?,?)
|
||||||
|
ON CONFLICT(game,guid,name) DO UPDATE SET first_seen_at=MIN(first_seen_at,excluded.first_seen_at),last_seen_at=MAX(last_seen_at,excluded.last_seen_at)`);
|
||||||
|
for (const player of players) {
|
||||||
|
const values = [game, moderationGuid(game, player.guid), player.name, now, now];
|
||||||
|
upsert.run(...values);
|
||||||
|
aliases.run(...values);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export function ensureSubject(game: ModerationGame, guid: string): void {
|
||||||
|
db()
|
||||||
|
.prepare('INSERT INTO moderation_players VALUES(?,?,?,?,?) ON CONFLICT DO NOTHING')
|
||||||
|
.run(game, guid, guid, '', '');
|
||||||
|
}
|
||||||
|
export function moderationEvent(
|
||||||
|
actor: Pick<AuthUser, 'id' | 'username'> | null,
|
||||||
|
action: string,
|
||||||
|
target: { game?: string | null; guid?: string | null },
|
||||||
|
subjectId: string | null,
|
||||||
|
detail: string,
|
||||||
|
now = moderationNow()
|
||||||
|
): void {
|
||||||
|
db()
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO moderation_events(game,guid,actor_id,actor,action,subject_id,detail,created_at) VALUES(?,?,?,?,?,?,?,?)`
|
||||||
|
)
|
||||||
|
.run(
|
||||||
|
target.game ?? null,
|
||||||
|
target.guid ?? null,
|
||||||
|
actor?.id ?? null,
|
||||||
|
actor?.username ?? 'Game server',
|
||||||
|
action,
|
||||||
|
subjectId,
|
||||||
|
detail,
|
||||||
|
now
|
||||||
|
);
|
||||||
|
}
|
||||||
|
export function policyRevision(): number {
|
||||||
|
return (
|
||||||
|
db().prepare('SELECT revision FROM moderation_policy WHERE id=1').get() as {
|
||||||
|
revision: number;
|
||||||
|
}
|
||||||
|
).revision;
|
||||||
|
}
|
||||||
|
export function advancePolicy(): void {
|
||||||
|
db().prepare('UPDATE moderation_policy SET revision=revision+1 WHERE id=1').run();
|
||||||
|
}
|
||||||
|
/** The request and its durable result commit atomically, including repeated browser submissions. */
|
||||||
|
export function moderationMutation(
|
||||||
|
actorId: string,
|
||||||
|
input: Record<string, unknown>,
|
||||||
|
work: (actor: AuthUser, now: string) => { id: string }
|
||||||
|
): { ok: true; id: string } {
|
||||||
|
const key = textField(input.idempotencyKey, 'Request identifier', 128);
|
||||||
|
const canonical = JSON.stringify(
|
||||||
|
Object.fromEntries(Object.entries(input).sort(([a], [b]) => a.localeCompare(b)))
|
||||||
|
);
|
||||||
|
const fingerprint = createHash('sha256').update(canonical).digest('hex');
|
||||||
|
return db()
|
||||||
|
.transaction(() => {
|
||||||
|
const actor = moderationActor(actorId, true);
|
||||||
|
const prior = db()
|
||||||
|
.prepare(
|
||||||
|
'SELECT fingerprint,result FROM moderation_requests WHERE actor_id=? AND request_key=?'
|
||||||
|
)
|
||||||
|
.get(actor.id, key) as { fingerprint: string; result: string } | undefined;
|
||||||
|
if (prior) {
|
||||||
|
if (prior.fingerprint !== fingerprint)
|
||||||
|
throw new AuthError(
|
||||||
|
'This request identifier was already used for a different action.',
|
||||||
|
409
|
||||||
|
);
|
||||||
|
return JSON.parse(prior.result) as { ok: true; id: string };
|
||||||
|
}
|
||||||
|
const now = moderationNow();
|
||||||
|
const result = { ok: true as const, ...work(actor, now) };
|
||||||
|
db()
|
||||||
|
.prepare('INSERT INTO moderation_requests VALUES(?,?,?,?,?)')
|
||||||
|
.run(actor.id, key, fingerprint, JSON.stringify(result), now);
|
||||||
|
return result;
|
||||||
|
})
|
||||||
|
.immediate();
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { createHash, randomBytes } from 'node:crypto';
|
||||||
|
import { db } from '../db/index.js';
|
||||||
|
import type { ModerationGame } from '../../moderation.js';
|
||||||
|
|
||||||
|
const digest = (value: string) => createHash('sha256').update(value).digest('hex');
|
||||||
|
export function moderationCredential(instanceId: string, generation: number): string {
|
||||||
|
return db()
|
||||||
|
.transaction(() => {
|
||||||
|
if (
|
||||||
|
!Number.isSafeInteger(generation) ||
|
||||||
|
!db()
|
||||||
|
.prepare(
|
||||||
|
'SELECT 1 FROM native_instances WHERE id=? AND generation IN (?,?) AND retired_at IS NULL'
|
||||||
|
)
|
||||||
|
.get(instanceId, generation, generation - 1)
|
||||||
|
)
|
||||||
|
throw new Error('Moderation credentials require an active instance generation.');
|
||||||
|
const existing = db()
|
||||||
|
.prepare(
|
||||||
|
'SELECT token FROM moderation_credentials WHERE instance_id=? AND generation=?'
|
||||||
|
)
|
||||||
|
.get(instanceId, generation) as { token: string } | undefined;
|
||||||
|
if (existing) return existing.token;
|
||||||
|
const token = 'ec_moderation_' + randomBytes(32).toString('base64url');
|
||||||
|
db()
|
||||||
|
.prepare('INSERT INTO moderation_credentials VALUES(?,?,?,?,?)')
|
||||||
|
.run(instanceId, generation, token, digest(token), new Date().toISOString());
|
||||||
|
return token;
|
||||||
|
})
|
||||||
|
.immediate();
|
||||||
|
}
|
||||||
|
export interface ModerationRuntimeIdentity {
|
||||||
|
instanceId: string;
|
||||||
|
generation: number;
|
||||||
|
server: string;
|
||||||
|
game: ModerationGame;
|
||||||
|
serverCreatedAt: string;
|
||||||
|
credentialHash: string;
|
||||||
|
}
|
||||||
|
export function authenticateModerationRuntime(request: Request): ModerationRuntimeIdentity | null {
|
||||||
|
const token = request.headers
|
||||||
|
.get('authorization')
|
||||||
|
?.match(/^Bearer (ec_moderation_[A-Za-z0-9_-]{43})$/)?.[1];
|
||||||
|
if (!token) return null;
|
||||||
|
return (
|
||||||
|
(db()
|
||||||
|
.prepare(
|
||||||
|
`SELECT n.id AS instanceId,n.generation,n.server,s.game,s.created_at AS serverCreatedAt,c.token_hash AS credentialHash
|
||||||
|
FROM moderation_credentials c JOIN native_instances n ON n.id=c.instance_id AND n.generation=c.generation
|
||||||
|
JOIN servers s ON s.name=n.server
|
||||||
|
WHERE c.token_hash=? AND n.retired_at IS NULL AND s.destroyed_at IS NULL AND s.game IN ('t5','t6')`
|
||||||
|
)
|
||||||
|
.get(digest(token)) as ModerationRuntimeIdentity | undefined) ?? null
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,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)
|
||||||
|
})
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
})();
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}));
|
||||||
|
}
|
||||||
@@ -0,0 +1,313 @@
|
|||||||
|
import { db } from '../db/index.js';
|
||||||
|
import { AuthError, getUser } from '../auth/index.js';
|
||||||
|
import { canModerate } from '../../auth.js';
|
||||||
|
import { moderationGuid } from '../../moderation.js';
|
||||||
|
import {
|
||||||
|
moderationEvent,
|
||||||
|
moderationNow,
|
||||||
|
observeModerationPlayers,
|
||||||
|
policyRevision,
|
||||||
|
textField
|
||||||
|
} from './core.js';
|
||||||
|
import type { ModerationRuntimeIdentity } from './credentials.js';
|
||||||
|
export { moderationCredential } from './credentials.js';
|
||||||
|
|
||||||
|
const capabilities = ['warn', 'message', 'kick', 'announce', 'ban', 'mute'];
|
||||||
|
export function refreshActionStates(now = moderationNow()): void {
|
||||||
|
const rows = db()
|
||||||
|
.prepare(
|
||||||
|
"SELECT id,game,guid,state FROM moderation_actions WHERE state IN ('queued','delivered') AND expires_at<=?"
|
||||||
|
)
|
||||||
|
.all(now) as { id: string; game: string; guid: string | null; state: string }[];
|
||||||
|
for (const row of rows) {
|
||||||
|
const state = row.state === 'queued' ? 'expired' : 'unknown';
|
||||||
|
const detail =
|
||||||
|
state === 'expired'
|
||||||
|
? 'The action expired before delivery.'
|
||||||
|
: 'The game did not acknowledge the delivered action before its deadline.';
|
||||||
|
db()
|
||||||
|
.prepare(
|
||||||
|
'UPDATE moderation_actions SET state=?,detail=?,finished_at=? WHERE id=? AND state=?'
|
||||||
|
)
|
||||||
|
.run(state, detail, now, row.id, row.state);
|
||||||
|
moderationEvent(null, `action.${state}`, row, row.id, detail, now);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function token(value: unknown, label: string): string {
|
||||||
|
const result = textField(value, label, 128);
|
||||||
|
if (!/^[A-Za-z0-9:_-]+$/.test(result)) throw new AuthError(`Invalid ${label.toLowerCase()}.`);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
export function syncModerationRuntime(
|
||||||
|
identity: ModerationRuntimeIdentity,
|
||||||
|
input: Record<string, unknown>
|
||||||
|
) {
|
||||||
|
if (
|
||||||
|
input.protocol !== 1 ||
|
||||||
|
input.server !== identity.server ||
|
||||||
|
input.generation !== identity.generation
|
||||||
|
)
|
||||||
|
throw new AuthError('The runtime identity does not match this credential.', 403);
|
||||||
|
const session = token(input.session, 'Runtime session');
|
||||||
|
if (!Number.isSafeInteger(input.sessionSequence) || (input.sessionSequence as number) < 1)
|
||||||
|
throw new AuthError('A positive runtime session sequence is required.');
|
||||||
|
if (
|
||||||
|
!Array.isArray(input.players) ||
|
||||||
|
input.players.length > 64 ||
|
||||||
|
!Array.isArray(input.receipts) ||
|
||||||
|
input.receipts.length > 256 ||
|
||||||
|
!Array.isArray(input.capabilities) ||
|
||||||
|
input.capabilities.length > capabilities.length ||
|
||||||
|
!Number.isSafeInteger(input.appliedRevision) ||
|
||||||
|
(input.appliedRevision as number) < 0
|
||||||
|
)
|
||||||
|
throw new AuthError('Invalid moderation synchronization request.');
|
||||||
|
const supported = [...new Set(input.capabilities)];
|
||||||
|
if (supported.some((c) => typeof c !== 'string' || !capabilities.includes(c)))
|
||||||
|
throw new AuthError('Unknown moderation capability.');
|
||||||
|
const players = input.players.map((value) => {
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value))
|
||||||
|
throw new AuthError('Invalid player.');
|
||||||
|
const raw = token(value.guid, 'Player GUID');
|
||||||
|
const name = textField(value.name, 'Player name', 255);
|
||||||
|
if (Buffer.byteLength(name, 'utf8') > 255)
|
||||||
|
throw new AuthError('Player names must fit within 255 UTF-8 bytes.');
|
||||||
|
return {
|
||||||
|
guid: moderationGuid(identity.game, raw),
|
||||||
|
name,
|
||||||
|
connection: token(value.connection, 'Player connection')
|
||||||
|
};
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
new Set(players.map((p) => p.connection)).size !== players.length ||
|
||||||
|
new Set(players.map((p) => p.guid)).size !== players.length
|
||||||
|
)
|
||||||
|
throw new AuthError('Player identities and connections must be unique.');
|
||||||
|
const receipts = input.receipts.map((value) => {
|
||||||
|
if (
|
||||||
|
!value ||
|
||||||
|
typeof value !== 'object' ||
|
||||||
|
Array.isArray(value) ||
|
||||||
|
!['confirmed', 'failed', 'unknown', 'expired'].includes(value.state)
|
||||||
|
)
|
||||||
|
throw new AuthError('Invalid action receipt.');
|
||||||
|
return {
|
||||||
|
id: token(value.id, 'Action ID'),
|
||||||
|
state: String(value.state),
|
||||||
|
detail: textField(value.detail, 'Receipt detail', 500, true)
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return db()
|
||||||
|
.transaction(() => {
|
||||||
|
// Reading a request body yields: retirement/rebuild/credential replacement may
|
||||||
|
// have completed since the HTTP handler first authenticated this request.
|
||||||
|
const current = db()
|
||||||
|
.prepare(
|
||||||
|
`SELECT 1 FROM native_instances n
|
||||||
|
JOIN servers s ON s.name=n.server
|
||||||
|
JOIN moderation_credentials c ON c.instance_id=n.id AND c.generation=n.generation
|
||||||
|
WHERE n.id=? AND n.generation=? AND n.retired_at IS NULL AND n.server=?
|
||||||
|
AND s.destroyed_at IS NULL AND s.game=? AND s.created_at=? AND c.token_hash=?`
|
||||||
|
)
|
||||||
|
.get(
|
||||||
|
identity.instanceId,
|
||||||
|
identity.generation,
|
||||||
|
identity.server,
|
||||||
|
identity.game,
|
||||||
|
identity.serverCreatedAt,
|
||||||
|
identity.credentialHash
|
||||||
|
);
|
||||||
|
if (!current)
|
||||||
|
throw new AuthError('This moderation runtime credential is no longer active.', 403);
|
||||||
|
const previous = db()
|
||||||
|
.prepare(
|
||||||
|
'SELECT session,session_sequence FROM moderation_runtimes WHERE instance_id=? AND generation=?'
|
||||||
|
)
|
||||||
|
.get(identity.instanceId, identity.generation) as
|
||||||
|
{ session: string; session_sequence: number } | undefined;
|
||||||
|
if (
|
||||||
|
previous &&
|
||||||
|
((input.sessionSequence as number) < previous.session_sequence ||
|
||||||
|
(input.sessionSequence === previous.session_sequence &&
|
||||||
|
session !== previous.session))
|
||||||
|
)
|
||||||
|
throw new AuthError('This runtime session has been superseded.', 409);
|
||||||
|
const now = moderationNow();
|
||||||
|
const revision = policyRevision();
|
||||||
|
if ((input.appliedRevision as number) > revision)
|
||||||
|
throw new AuthError(
|
||||||
|
'The runtime has a newer policy revision than this controller. Review the controller restore before reconfiguring this integration.',
|
||||||
|
409
|
||||||
|
);
|
||||||
|
refreshActionStates(now);
|
||||||
|
for (const receipt of receipts) {
|
||||||
|
const action = db()
|
||||||
|
.prepare(
|
||||||
|
'SELECT * FROM moderation_actions WHERE id=? AND instance_id=? AND generation=?'
|
||||||
|
)
|
||||||
|
.get(receipt.id, identity.instanceId, identity.generation) as
|
||||||
|
| {
|
||||||
|
id: string;
|
||||||
|
game: string;
|
||||||
|
guid: string | null;
|
||||||
|
session: string;
|
||||||
|
state: string;
|
||||||
|
delivered_at: string | null;
|
||||||
|
detail: string | null;
|
||||||
|
}
|
||||||
|
| undefined;
|
||||||
|
// Old journal receipts can outlive controller retention/restores, but cannot target other instances.
|
||||||
|
if (!action) continue;
|
||||||
|
if (!action.delivered_at)
|
||||||
|
throw new AuthError('An undelivered action cannot be acknowledged.', 409);
|
||||||
|
if (['confirmed', 'failed', 'expired'].includes(action.state)) continue;
|
||||||
|
// Durable native receipts may be resubmitted after a game process restart.
|
||||||
|
if (action.state === receipt.state && action.detail === receipt.detail) continue;
|
||||||
|
db()
|
||||||
|
.prepare(
|
||||||
|
'UPDATE moderation_actions SET state=?,detail=?,finished_at=? WHERE id=?'
|
||||||
|
)
|
||||||
|
.run(receipt.state, receipt.detail || null, now, action.id);
|
||||||
|
moderationEvent(
|
||||||
|
null,
|
||||||
|
`action.${receipt.state}`,
|
||||||
|
action,
|
||||||
|
action.id,
|
||||||
|
receipt.detail || receipt.state,
|
||||||
|
now
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (previous && previous.session !== session) {
|
||||||
|
const abandoned = db()
|
||||||
|
.prepare(
|
||||||
|
"SELECT id,game,guid,state FROM moderation_actions WHERE instance_id=? AND generation=? AND session<>? AND state IN ('queued','delivered')"
|
||||||
|
)
|
||||||
|
.all(identity.instanceId, identity.generation, session) as {
|
||||||
|
id: string;
|
||||||
|
game: string;
|
||||||
|
guid: string | null;
|
||||||
|
state: string;
|
||||||
|
}[];
|
||||||
|
for (const action of abandoned) {
|
||||||
|
const state = action.state === 'queued' ? 'expired' : 'unknown';
|
||||||
|
db()
|
||||||
|
.prepare(
|
||||||
|
'UPDATE moderation_actions SET state=?,detail=?,finished_at=? WHERE id=?'
|
||||||
|
)
|
||||||
|
.run(state, 'The game runtime changed before completion.', now, action.id);
|
||||||
|
moderationEvent(
|
||||||
|
null,
|
||||||
|
`action.${state}`,
|
||||||
|
action,
|
||||||
|
action.id,
|
||||||
|
'The game runtime changed before completion.',
|
||||||
|
now
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
db()
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO moderation_runtimes(instance_id,generation,session,capabilities,players,revision,connected_at,session_sequence) VALUES(?,?,?,?,?,?,?,?) ON CONFLICT(instance_id) DO UPDATE SET
|
||||||
|
generation=excluded.generation,session=excluded.session,capabilities=excluded.capabilities,players=excluded.players,
|
||||||
|
revision=excluded.revision,connected_at=excluded.connected_at,session_sequence=excluded.session_sequence`
|
||||||
|
)
|
||||||
|
.run(
|
||||||
|
identity.instanceId,
|
||||||
|
identity.generation,
|
||||||
|
session,
|
||||||
|
JSON.stringify(supported),
|
||||||
|
JSON.stringify(players),
|
||||||
|
input.appliedRevision,
|
||||||
|
now,
|
||||||
|
input.sessionSequence
|
||||||
|
);
|
||||||
|
observeModerationPlayers(db(), identity.game, players, now);
|
||||||
|
const sanctions = db()
|
||||||
|
.prepare(
|
||||||
|
`SELECT id,revision,guid,kind,reason,expires_at AS expiresAt FROM moderation_sanctions
|
||||||
|
WHERE revoked_at IS NULL AND (expires_at IS NULL OR expires_at>?) AND
|
||||||
|
((scope='fleet') OR (game=? AND (scope='game' OR (scope='server' AND server=? AND server_created_at=?)))) ORDER BY id LIMIT 1001`
|
||||||
|
)
|
||||||
|
.all(now, identity.game, identity.server, identity.serverCreatedAt);
|
||||||
|
if (sanctions.length > 1000)
|
||||||
|
throw new AuthError(
|
||||||
|
'The moderation policy exceeds the runtime’s supported size.',
|
||||||
|
503
|
||||||
|
);
|
||||||
|
const pending = db()
|
||||||
|
.prepare(
|
||||||
|
`SELECT * FROM moderation_actions WHERE instance_id=? AND generation=? AND session=?
|
||||||
|
AND state IN ('queued','delivered') AND expires_at>? ORDER BY created_at,id LIMIT 100`
|
||||||
|
)
|
||||||
|
.all(identity.instanceId, identity.generation, session, now) as {
|
||||||
|
id: string;
|
||||||
|
guid: string | null;
|
||||||
|
connection: string | null;
|
||||||
|
kind: string;
|
||||||
|
reason: string;
|
||||||
|
expires_at: string;
|
||||||
|
actor_id: string;
|
||||||
|
game: string;
|
||||||
|
state: string;
|
||||||
|
}[];
|
||||||
|
const actions = [];
|
||||||
|
for (const action of pending) {
|
||||||
|
const actor = getUser(action.actor_id);
|
||||||
|
let failure: string | null = null;
|
||||||
|
if (
|
||||||
|
!actor ||
|
||||||
|
actor.disabled ||
|
||||||
|
actor.mustChangePassword ||
|
||||||
|
!canModerate(actor.role)
|
||||||
|
)
|
||||||
|
failure = 'The requesting moderator no longer has access.';
|
||||||
|
else if (!supported.includes(action.kind))
|
||||||
|
failure = 'The runtime no longer supports this action.';
|
||||||
|
else if (
|
||||||
|
action.guid &&
|
||||||
|
!players.some(
|
||||||
|
(p) => p.guid === action.guid && p.connection === action.connection
|
||||||
|
)
|
||||||
|
)
|
||||||
|
failure = 'The targeted player connection has ended.';
|
||||||
|
if (failure) {
|
||||||
|
const state = action.state === 'delivered' ? 'unknown' : 'failed';
|
||||||
|
db()
|
||||||
|
.prepare(
|
||||||
|
'UPDATE moderation_actions SET state=?,detail=?,finished_at=? WHERE id=?'
|
||||||
|
)
|
||||||
|
.run(state, failure, now, action.id);
|
||||||
|
moderationEvent(null, `action.${state}`, action, action.id, failure, now);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
db()
|
||||||
|
.prepare(
|
||||||
|
"UPDATE moderation_actions SET state='delivered',delivered_at=COALESCE(delivered_at,?) WHERE id=?"
|
||||||
|
)
|
||||||
|
.run(now, action.id);
|
||||||
|
actions.push({
|
||||||
|
id: action.id,
|
||||||
|
guid: action.guid,
|
||||||
|
connection: action.connection,
|
||||||
|
kind: action.kind,
|
||||||
|
reason: action.reason,
|
||||||
|
expiresAt: action.expires_at
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const response = {
|
||||||
|
ok: true,
|
||||||
|
revision,
|
||||||
|
serverTime: now,
|
||||||
|
sanctions,
|
||||||
|
actions,
|
||||||
|
pollAfterMs: 3000
|
||||||
|
};
|
||||||
|
if (Buffer.byteLength(JSON.stringify(response)) > 1024 * 1024)
|
||||||
|
throw new AuthError(
|
||||||
|
'The moderation response exceeds the runtime’s supported size.',
|
||||||
|
503
|
||||||
|
);
|
||||||
|
return response;
|
||||||
|
})
|
||||||
|
.immediate();
|
||||||
|
}
|
||||||
@@ -0,0 +1,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);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user