Remove SCUM server-management client path

This commit is contained in:
npc0-hue
2026-09-02 17:56:49 +08:00
parent 6e614d3fa3
commit a82f1ff01a
52 changed files with 245 additions and 5131 deletions
+5 -11
View File
@@ -11,7 +11,7 @@ A game management plugin defines how the platform creates and manages one type o
- Lifecycle action definitions.
- Required run capabilities.
- Optional remote access methods and remote run capabilities.
- Optional runtime profiles for discovery, lifecycle modes, dependency probes, install plans, log sources, transports, and client-manager builds.
- Optional runtime profiles for discovery, lifecycle modes, dependency probes, install plans, log sources, transports, and plugin-owned component declarations.
- Optional plugin pages hosted by platform_web.
- AI/file/log permissions declared for platform authorization, including `ai.mediation=platform` and `ai.configWritePolicy=review-required`.
- Production lifecycle operations, dependency policy, and disruptive approval requirements.
@@ -30,29 +30,23 @@ Plugins must use the platform bridge and must not connect directly to run, log s
Manifest validation is the plugin-side installability gate. The shared manifest schema defines identity, version, server type/display metadata, create form schema reference, lifecycle action references, required run capabilities, scoped permissions, remote access declarations, runtime profiles, optional pages, tags, and AI purposes. `scripts/validate-manifest.ts` also scans manifest and create-form content for unsafe raw host path, raw credential, direct run, and raw AI/provider key requests.
Remote access declarations describe whether a plugin can use `ftp`, `rsync`, or `run`, and which `remote.*` run capabilities are enabled for that game. Runtime profiles describe how run discovers servers, checks dependencies, tails live logs, backfills historical logs, resolves transports, and builds optional client managers. Plugin pages use platform-mediated bridge actions; they never receive FTP passwords, rsync endpoints, database DSNs, RCON credentials, run/client keys, run sockets, or host paths.
Remote access declarations describe whether a plugin can use `ftp`, `rsync`, or `run`, and which `remote.*` run capabilities are enabled for that game. Runtime profiles describe how Run discovers servers, checks dependencies, carries declared log channels verbatim, resolves transports, and executes plugin-owned lifecycle declarations. Plugin pages use platform-mediated bridge actions; they never receive FTP passwords, rsync endpoints, database DSNs, RCON credentials, run/client keys, run sockets, or host paths.
Runtime profiles are declarative contracts, not executable scripts. A profile can declare:
- discovery probes for logical targets such as Java, Steam app, service, file, or toolchain checks.
- lifecycle modes such as `local-process`, `hosted-ftp-rcon`, `ftp-only`, or `custom-client`.
- dependency probes and typed install plans for supported OS targets.
- log sources for stdout/stderr, file tailing, FTP polling, SQL cursors, or plugin-specific client-manager logs.
- log sources for stdout/stderr, file tailing, FTP polling, or plugin-declared typed channels. Log bodies remain opaque and verbatim to Run and Platform.
- transport profiles for declared file, FTP/rsync, SQL, RCON, and run-mediated operations.
- client-manager build profiles for games such as SCUM that need a separate companion executable.
Client-manager profiles declare repository URL, revision policy, semantic version, supported target OS/architecture pairs, a fixed build adapter, config template keys, produced artifacts, and an optional complete lifecycle contract. The lifecycle contract names a safe relative executable, fixed arguments, required Run capabilities, bounded start/stop/restart/status/update/rollback/uninstall actions, heartbeat/process health thresholds, component capabilities, compatibility bounds, and a manual staged-update policy. It cannot contain arbitrary shell, absolute/traversing paths, direct sockets, endpoints, raw credentials, or secret/session values.
Platform performs target and lifecycle validation, creates a real build record, injects a distinct server/component key into the generated package config, redacts build logs, and publishes a downloadable artifact. For profiles with a complete lifecycle contract, the artifact can then be deployed by a typed Run job into a managed workspace, registered using a separate short-lived component session, health-checked, started/stopped, updated/rolled back, revoked, and safely uninstalled. The Run key and client-manager key/session remain separate; resetting the client-manager key revokes old packages and sessions and requires a current-generation rebuild and redeploy.
Plugin-owned component declarations may describe their own typed bridge records and lifecycle, while the generic Run/Platform path only transports declared envelopes and opaque log channels. Neither Run nor Platform parses, filters, normalizes, correlates, redacts, or derives plugin records from stdout, stderr, or declared file tails.
Plugin pages may request these operations only through bridge helpers:
- `createRunDistributionRequest`: generate/download/reset/update run packages.
- `createDependencyActionRequest`: check or install declared dependency probes/plans.
- `createLogBackfillRequest`: request historical log cursors for declared sources.
- `createClientManagerRequest`: generate/download/reset or request safe status/deploy/control/update/rollback/revoke/retry/uninstall operations for declared client-manager packages.
- `createProductionPluginLifecycleRequest`: request server-bound install/enable/disable/upgrade/rollback/retire/dependency-check through Platform operations.
- `parseClientManagerLifecycleStatus`: whitelist the plugin-visible status, version, health, artifact/job IDs, deployment generation, and allowed actions without component secrets or machine details.
Bridge envelopes carry operation names, profile keys, target platforms, artifact IDs, checkpoint refs, immutable reviewed dependency plan digests, and idempotency keys only. Dependency install bridge helpers require a `sha256:<64 hex>` reviewed plan digest; Platform re-resolves the declaration and rejects stale or missing approvals. The plugin SDK and manifest validation reject raw run keys, client-manager keys, FTP passwords, rsync endpoints, SQL DSNs, RCON passwords, direct run sockets, host paths, and arbitrary shell snippets.
@@ -75,7 +69,7 @@ npm run test
npm run validate:manifest
```
Current plugin behavior includes SDK bridge contracts, manifest schema validation, the `examples/dev-game-plugin`, `examples/scum-server-plugin`, and `examples/minecraft-server-plugin` fixtures, platform registry metadata registration, marketplace projections, hosted plugin-page bridge execution, platform-mediated lifecycle job dispatch, declared remote access envelopes, runtime profile declarations, target-matched typed dependency plan requests, run distribution envelopes, typed dependency/log backfill requests, and a SCUM-style client-manager declaration with a complete bounded lifecycle contract. Minecraft deliberately remains a no-client-manager example so action gating proves the feature is optional. Marketplace package acquisition, private source credentials, public build-worker sandboxing, remote plugin hosting policies, production KMS/code signing/fleet rollout, and external package distribution remain future work.
Current plugin behavior includes SDK bridge contracts, manifest schema validation, the `examples/dev-game-plugin`, `examples/scum-server-plugin`, and `examples/minecraft-server-plugin` fixtures, platform registry metadata registration, marketplace projections, hosted plugin-page bridge execution, platform-mediated lifecycle job dispatch, declared remote access envelopes, runtime profile declarations, target-matched typed dependency plan requests, run distribution envelopes, typed dependency/log backfill requests, and plugin-owned typed SCUM RCON data flows. Marketplace package acquisition, private source credentials, public build-worker sandboxing, remote plugin hosting policies, production KMS/code signing/fleet rollout, and external package distribution remain future work.
Runtime-profile declarations do not provide a general secret vault, arbitrary machine execution, production code signing/KMS, or fleet orchestration. The durable Client Manager installation/session state, bounded scheduler, process supervisor, and isolated log/artifact/control channels are Platform/Run capabilities; plugins receive only declarations and safe status projections.
# Client Manager profile contract
@@ -1,43 +0,0 @@
# SCUM Companion One-Shot Smoke
Run stdout/stderr and the plugin-declared SCUM log streams arrive as opaque
records. See [UE4SS_CAPABILITY.md](UE4SS_CAPABILITY.md) for the runtime
boundary; this companion alone parses the SCUM login format and publishes the
typed player snapshot used by the plugin page. Platform and Run never inspect
or redact those log bodies.
The long-running companion stores only its typed snapshot sequence state in
`snapshot-sequences.json` beside `config.yaml`. This keeps the `players/current`
stream monotonic across process restarts; it contains no raw log records.
This plugin-owned fixture proves the Platform Client Manager and Game Client Bridge integration without adding SCUM behavior to Run. The command registers the deployed component, sends one heartbeat, claims at most one command, processes only `companion.diagnostics`, and uploads one typed `companion.health` snapshot.
Use it only with a dedicated non-production server instance whose bridge queue contains no shared or production work. The claim API cannot filter by command type, so this smoke command must never target a shared or production queue.
Before starting it, confirm that the isolated queue is otherwise empty and queue exactly one `companion.diagnostics` command through the Platform SCUM operations page. Use the bounded payload `includeWindowState=false` and `maxEntries=1`. Do not pass an operator session or API token to the companion process.
## Package
Build the one-shot command from this directory:
```bash
go build -o scum-companion-smoke ./cmd/scum-companion-smoke
```
Place the generated `config.yaml` beside the executable. The command intentionally has no `--config` flag and reads only that sidecar filename from its working directory. `config.yaml.example` documents the generated shape; deployed identity and generation values must come from the fenced Client Manager lifecycle input.
The Platform base URL must be a trusted HTTPS origin. The client uses host system certificate roots, requires TLS 1.2 or newer, and does not follow redirects. Local acceptance therefore needs a hostname and certificate already trusted by the machine account running the package. The certificate SAN must cover the configured hostname or IP; a certificate for `localhost` does not cover `127.0.0.1` unless that IP is also present. Do not use HTTP fallback, certificate-skip flags, custom root overrides, or other verification bypasses for local testing.
The supervisor supplies the component proof through the environment variable named by `proof.materialEnv`. Bind it from the protected component package at process start. Do not place the proof in `config.yaml`, command arguments, command-line environment assignments, shell history, documentation, or logs.
The process environment must also set `SCUM_COMPANION_SMOKE_SCOPE` to `isolated-non-production`. This value is a non-secret safety acknowledgement; configure it in the supervisor rather than placing component proof material on a command line.
Run the executable from the package working directory:
```bash
./scum-companion-smoke
```
The JSON output contains only claimed/completed/unsupported counts, command IDs, and the accepted snapshot ID. It never prints the component proof, component session, command payloads, host paths, or transport details. The run proceeds only when exactly one live `companion.diagnostics` command with a valid bounded payload is claimed. Zero, multiple, expired, malformed, or unsupported commands stop the smoke immediately; they are not acknowledged, completed, or executed, and no snapshot is uploaded. Platform lease fencing remains authoritative.
Each invocation uploads health to a fresh bounded `smoke-<random>` stream with sequence `1`. This avoids reusing production stream state and remains safe across process restarts without storing a host path or local sequence file.
@@ -1,17 +0,0 @@
# Runtime capability boundary
UE4SS is only a possible Companion-local implementation detail. It is not a
feature gate: no SCUM game, database, UE4SS build, or source revision controls
plugin availability.
The Companion declares availability from its server-bound typed ports and
runtime schema probes. Plugin-generated SQL, RCON, and management-program
request text is declared through the protected Platform-to-Run transport, not
executed by the Companion. Callers never receive a socket, credential, path,
or raw transport reply. A management-program request is not host OS shell
access.
Semantic events come from bounded Run stdout/stderr console records. Unknown
records create diagnostics and never produce fabricated events. DSNs, rows,
connections, and credentials do not leave Run; request text is protected and
redacted from browser and platform projections.
@@ -1,565 +0,0 @@
package companion
import (
"context"
"errors"
"fmt"
"math"
"sort"
"strings"
"unicode/utf8"
)
var errAdapterUnsupported = errors.New("runtime capability is unavailable")
// AuthorizedConfigPort is supplied through the platform-authorized Run channel.
// It exposes logical, declared configuration values only; it never exposes a
// path, DSN, credential, arbitrary command, or database handle.
type AuthorizedConfigPort interface {
ReadConfig(context.Context) (map[string]string, error)
ApplyConfigPatch(context.Context, string, []ConfigFieldPatch) (map[string]string, error)
}
type ConfigFieldPatch struct {
Key string
Value string
}
// AuthorizedGameDataPort is a typed, Run-owned read/patch boundary. Implementations
// must probe their local schema, use declared fields and safe windows, and return
// bounded snapshots rather than rows or connection details.
type AuthorizedGameDataPort interface {
ReadPlayerState(context.Context, string, []string) (PlayerStateSnapshot, error)
ApplyPlayerState(context.Context, PlayerStatePatch) (PlayerStateSnapshot, error)
}
type PlayerStateSnapshot struct {
PlayerID string
StateVersion string
SafeWindow bool
Fields map[string]float64
}
type PlayerStatePatch struct {
PlayerID string
ExpectedStateVersion string
Fields []StateFieldPatch
}
type StateFieldPatch struct {
Key string
Before float64
After float64
}
// AuthorizedRewardPort accepts a frozen grant with typed items and operations.
type AuthorizedRewardPort interface {
DeliverReward(context.Context, RewardGrant) (DeliveryReceipt, error)
}
type RewardGrant struct {
GrantID string
PlayerID string
Items []RewardItem
Operations []string
}
type RewardItem struct {
CatalogCode string
Quantity int
}
type DeliveryReceipt struct {
Outcome string
DeliveryID string
}
type AuthorizedEventPort interface {
StartEvent(context.Context, EventStartRequest) (EventStartReceipt, error)
}
type EventStartRequest struct {
EventID string
EventType string
Class int
Title string
Placard string
Percent int
NPC int
Item int
Zombie int
Animal int
Produces []EventProduceRequest
DurationSeconds int
MaxParticipants int
Announce bool
}
type EventProduceRequest struct {
TradeGoodsID string
Percent int
Value int
Radius float64
X float64
Y float64
Z float64
}
type EventStartReceipt struct {
Accepted bool
Status string
EventID string
Message string
}
const fixedNotificationType = 4
type UE4SSNotificationPort interface {
SendPlayerNotification(context.Context, ue4SSPlayerNotification) (UE4SSNotificationReceipt, error)
}
type UE4SSNotificationReceipt struct{ Accepted bool }
type ue4SSPlayerNotification struct {
ServerID string
RecipientSteamID string
Message string
chatType int
localCommandPreview string
}
type UE4SSVehicleSpawnPort interface {
SpawnVehicle(context.Context, ue4SSVehicleSpawn) (UE4SSVehicleSpawnReceipt, error)
}
type UE4SSVehicleSpawnOutcome string
const (
UE4SSVehicleSpawnAccepted UE4SSVehicleSpawnOutcome = "accepted"
UE4SSVehicleSpawnRejected UE4SSVehicleSpawnOutcome = "rejected"
UE4SSVehicleSpawnUnknown UE4SSVehicleSpawnOutcome = "unknown"
)
type UE4SSVehicleSpawnReceipt struct{ Outcome UE4SSVehicleSpawnOutcome }
type ue4SSVehicleSpawn struct {
ServerID string
VehicleCode string
localCommandPreview string
}
// RuntimeAdapter is bound to one server. Availability is discovered from its
// configured typed ports and declared capabilities. A failed probe affects
// only its operation.
type RuntimeAdapter struct {
BoundServerID string
Config AuthorizedConfigPort
GameData AuthorizedGameDataPort
Rewards AuthorizedRewardPort
Events AuthorizedEventPort
Notification UE4SSNotificationPort
VehicleSpawn UE4SSVehicleSpawnPort
DiagnosticsState map[string]string
}
func (adapter RuntimeAdapter) ServerBinding() string { return adapter.BoundServerID }
func (adapter RuntimeAdapter) ReadConfiguration(ctx context.Context) (map[string]any, error) {
if adapter.Config == nil {
return nil, errAdapterUnsupported
}
fields, err := adapter.Config.ReadConfig(ctx)
if err != nil {
return nil, err
}
return map[string]any{"fields": redactConfigValues(fields)}, nil
}
func (adapter RuntimeAdapter) PatchConfiguration(ctx context.Context, payload map[string]any) (map[string]any, error) {
if adapter.Config == nil {
return nil, errAdapterUnsupported
}
revision, _ := payload["revision"].(string)
raw, _ := payload["fields"].([]any)
fields := make([]ConfigFieldPatch, 0, len(raw))
for _, value := range raw {
item, ok := value.(map[string]any)
if !ok {
return nil, fmt.Errorf("configuration patch payload is invalid")
}
key, keyOK := item["key"].(string)
fieldValue, valueOK := item["value"].(string)
if !keyOK || !valueOK || !supportedConfigKey(key) {
return nil, fmt.Errorf("configuration patch field is unsupported")
}
fields = append(fields, ConfigFieldPatch{Key: key, Value: fieldValue})
}
applied, err := adapter.Config.ApplyConfigPatch(ctx, revision, fields)
if err != nil {
return nil, err
}
return map[string]any{"appliedFields": redactConfigValues(applied)}, nil
}
func (adapter RuntimeAdapter) Diagnostics(context.Context) (map[string]any, error) {
state := map[string]any{"adapter": "runtime-capability"}
for key, value := range adapter.DiagnosticsState {
if safeDiagnosticField(key, value) {
state[key] = value
}
}
return state, nil
}
func (adapter RuntimeAdapter) PatchGameState(ctx context.Context, payload map[string]any) (map[string]any, error) {
if adapter.GameData == nil {
return nil, errAdapterUnsupported
}
playerID, _ := payload["playerId"].(string)
expected, _ := payload["expectedStateVersion"].(string)
raw, _ := payload["changes"].([]any)
fields, err := statePatchFields(raw)
if err != nil {
return nil, err
}
before, err := adapter.GameData.ReadPlayerState(ctx, playerID, statePatchKeys(fields))
if err != nil {
return nil, err
}
if before.PlayerID != playerID || before.StateVersion != expected || !before.SafeWindow || !stateMatches(before.Fields, fields) {
return map[string]any{"outcome": "failed"}, nil
}
after, err := adapter.GameData.ApplyPlayerState(ctx, PlayerStatePatch{PlayerID: playerID, ExpectedStateVersion: expected, Fields: fields})
if err != nil {
return map[string]any{"outcome": "unknown"}, nil
}
confirmed, err := adapter.GameData.ReadPlayerState(ctx, playerID, statePatchKeys(fields))
if err != nil || after.PlayerID != playerID || !stateApplied(confirmed.Fields, fields) {
return map[string]any{"outcome": "unknown"}, nil
}
return map[string]any{"outcome": "succeeded", "changedFields": len(fields)}, nil
}
func (adapter RuntimeAdapter) DeliverReward(ctx context.Context, payload map[string]any) (map[string]any, error) {
if adapter.Rewards == nil {
return nil, errAdapterUnsupported
}
grant, err := rewardGrant(payload)
if err != nil {
return nil, err
}
receipt, err := adapter.Rewards.DeliverReward(ctx, grant)
if err != nil || receipt.Outcome == "unknown" {
return map[string]any{"accepted": false, "status": "rejected", "message": "reward delivery result is unknown"}, nil
}
if receipt.Outcome != "delivered" {
return map[string]any{"accepted": false, "status": "rejected"}, nil
}
result := map[string]any{"accepted": true, "status": "delivered"}
if receipt.DeliveryID != "" {
result["deliveryId"] = receipt.DeliveryID
}
return result, nil
}
func (adapter RuntimeAdapter) StartEvent(ctx context.Context, payload map[string]any) (map[string]any, error) {
if adapter.Events == nil {
return nil, errAdapterUnsupported
}
request, err := eventStartRequest(payload)
if err != nil {
return nil, err
}
receipt, err := adapter.Events.StartEvent(ctx, request)
if err != nil {
return nil, err
}
status := receipt.Status
if status == "" {
status = "rejected"
}
if status != "started" && status != "queued" && status != "rejected" {
return nil, fmt.Errorf("event start receipt is invalid")
}
eventID := receipt.EventID
if eventID == "" {
eventID = request.EventID
}
result := map[string]any{"accepted": receipt.Accepted, "status": status, "eventId": eventID}
if receipt.Message != "" {
result["message"] = receipt.Message
}
return result, nil
}
func (adapter RuntimeAdapter) NotifyPlayer(ctx context.Context, payload map[string]any) (map[string]any, error) {
if adapter.BoundServerID == "" || adapter.Notification == nil {
return nil, errAdapterUnsupported
}
playerID, playerOK := payload["playerId"].(string)
message, messageOK := payload["message"].(string)
notification, err := newUE4SSPlayerNotification(adapter.BoundServerID, playerID, message)
if !playerOK || !messageOK || err != nil {
return nil, fmt.Errorf("notification payload is invalid")
}
receipt, err := adapter.Notification.SendPlayerNotification(ctx, notification)
if err != nil {
return nil, fmt.Errorf("notification transport failed")
}
return map[string]any{"accepted": receipt.Accepted}, nil
}
func (adapter RuntimeAdapter) SpawnVehicle(ctx context.Context, payload map[string]any) (map[string]any, error) {
if adapter.BoundServerID == "" || adapter.VehicleSpawn == nil {
return nil, errAdapterUnsupported
}
vehicleCode, ok := payload["vehicleCode"].(string)
spawn, err := newUE4SSVehicleSpawn(adapter.BoundServerID, vehicleCode)
if !ok || err != nil {
return nil, fmt.Errorf("vehicle spawn payload is invalid")
}
receipt, err := adapter.VehicleSpawn.SpawnVehicle(ctx, spawn)
if err != nil || receipt.Outcome == UE4SSVehicleSpawnUnknown {
return map[string]any{"outcome": "unknown"}, nil
}
if receipt.Outcome == UE4SSVehicleSpawnRejected {
return map[string]any{"outcome": "failed"}, nil
}
if receipt.Outcome != UE4SSVehicleSpawnAccepted {
return map[string]any{"outcome": "unknown"}, nil
}
return map[string]any{"outcome": "succeeded"}, nil
}
func newUE4SSPlayerNotification(serverID, playerID, message string) (ue4SSPlayerNotification, error) {
if strings.TrimSpace(serverID) == "" || !steamID64(playerID) || !validNotificationMessage(message) {
return ue4SSPlayerNotification{}, fmt.Errorf("invalid typed notification")
}
return ue4SSPlayerNotification{ServerID: serverID, RecipientSteamID: playerID, Message: message, chatType: fixedNotificationType, localCommandPreview: "SendChat 4 \"" + escapeUE4SSChatMessage(message) + "\" " + playerID}, nil
}
func newUE4SSVehicleSpawn(serverID, vehicleCode string) (ue4SSVehicleSpawn, error) {
if strings.TrimSpace(serverID) == "" || !supportedVehicleSpawnCode(vehicleCode) {
return ue4SSVehicleSpawn{}, fmt.Errorf("invalid typed vehicle spawn")
}
return ue4SSVehicleSpawn{ServerID: serverID, VehicleCode: vehicleCode, localCommandPreview: "#spawnvehicle " + vehicleCode}, nil
}
func steamID64(value string) bool {
if len(value) != 17 {
return false
}
for _, character := range value {
if character < '0' || character > '9' {
return false
}
}
return true
}
func validNotificationMessage(value string) bool {
if value == "" || len(value) > 200 || !utf8.ValidString(value) {
return false
}
for _, character := range value {
if character < 0x20 || character == 0x7f {
return false
}
}
return true
}
func escapeUE4SSChatMessage(value string) string {
return strings.NewReplacer("\\", "\\\\", "\"", "\\\"").Replace(value)
}
func supportedVehicleSpawnCode(value string) bool {
return map[string]bool{"BPC_Laika_C": true, "BPC_WolfsWagen_C": true}[value]
}
func supportedConfigKey(key string) bool {
return map[string]bool{"ServerName": true, "GamePort": true, "QueryPort": true, "MaxPlayers": true, "WelcomeMessage": true}[key]
}
func redactConfigValues(values map[string]string) map[string]string {
result := map[string]string{}
keys := make([]string, 0, len(values))
for key := range values {
keys = append(keys, key)
}
sort.Strings(keys)
for _, key := range keys {
if supportedConfigKey(key) && safeDiagnosticField(key, values[key]) {
result[key] = values[key]
}
}
return result
}
func safeDiagnosticField(key, value string) bool {
lowered := strings.ToLower(key + "=" + value)
return !strings.Contains(lowered, "path") && !strings.Contains(lowered, "credential") && !strings.Contains(lowered, "password") && !strings.Contains(lowered, "bearer ") && !strings.Contains(lowered, "rcon") && !strings.Contains(lowered, "sql") && !strings.Contains(lowered, "://")
}
func statePatchFields(raw []any) ([]StateFieldPatch, error) {
if len(raw) == 0 || len(raw) > 8 {
return nil, fmt.Errorf("state patch payload is invalid")
}
fields := make([]StateFieldPatch, 0, len(raw))
for _, value := range raw {
item, ok := value.(map[string]any)
if !ok {
return nil, fmt.Errorf("state patch payload is invalid")
}
key, _ := item["fieldKey"].(string)
before, beforeOK := item["before"].(float64)
after, afterOK := item["after"].(float64)
if key == "" || !beforeOK || !afterOK {
return nil, fmt.Errorf("state patch payload is invalid")
}
fields = append(fields, StateFieldPatch{Key: key, Before: before, After: after})
}
return fields, nil
}
func statePatchKeys(fields []StateFieldPatch) []string {
keys := make([]string, 0, len(fields))
for _, field := range fields {
keys = append(keys, field.Key)
}
return keys
}
func stateMatches(values map[string]float64, fields []StateFieldPatch) bool {
for _, field := range fields {
if values[field.Key] != field.Before {
return false
}
}
return true
}
func stateApplied(values map[string]float64, fields []StateFieldPatch) bool {
for _, field := range fields {
if values[field.Key] != field.After {
return false
}
}
return true
}
func rewardGrant(payload map[string]any) (RewardGrant, error) {
grantID, grantOK := payload["grantId"].(string)
playerID, playerOK := payload["playerId"].(string)
rawItems, itemsOK := payload["items"].([]any)
rawOperations, operationsOK := payload["operations"].([]any)
if !grantOK || !playerOK || !itemsOK || !operationsOK || (len(rawItems) == 0 && len(rawOperations) == 0) {
return RewardGrant{}, fmt.Errorf("reward payload is invalid")
}
items := make([]RewardItem, 0, len(rawItems))
for _, value := range rawItems {
item, ok := value.(map[string]any)
if !ok || len(item) != 2 {
return RewardGrant{}, fmt.Errorf("reward payload is invalid")
}
code, codeOK := item["catalogCode"].(string)
quantity, quantityOK := integerPayloadValue(item["quantity"])
if !codeOK || !supportedCatalogCode(code) || !quantityOK || quantity < 1 {
return RewardGrant{}, fmt.Errorf("reward payload is invalid")
}
items = append(items, RewardItem{CatalogCode: code, Quantity: quantity})
}
operations := make([]string, 0, len(rawOperations))
for _, value := range rawOperations {
operation, ok := value.(string)
if !ok || operation == "" || !utf8.ValidString(operation) {
return RewardGrant{}, fmt.Errorf("reward payload is invalid")
}
operations = append(operations, operation)
}
return RewardGrant{GrantID: grantID, PlayerID: playerID, Items: items, Operations: operations}, nil
}
func eventStartRequest(payload map[string]any) (EventStartRequest, error) {
eventID, eventIDOK := payload["eventId"].(string)
eventType, eventTypeOK := payload["eventType"].(string)
eventClass, classOK := integerPayloadValue(payload["class"])
title, titleOK := payload["title"].(string)
duration, durationOK := integerPayloadValue(payload["durationSeconds"])
percent, percentOK := integerPayloadValue(payload["percent"])
placard, placardOK := payload["placard"].(string)
produces, producesOK := eventProduceRequests(payload["produces"])
counts := make([]int, 4)
for index, key := range []string{"npc", "item", "zombie", "animal"} {
if value, exists := payload[key]; exists {
count, ok := integerPayloadValue(value)
if !ok || count < 0 {
return EventStartRequest{}, fmt.Errorf("event start payload is invalid")
}
counts[index] = count
}
}
participants := 0
if value, exists := payload["maxParticipants"]; exists {
var ok bool
participants, ok = integerPayloadValue(value)
if !ok || participants < 1 {
return EventStartRequest{}, fmt.Errorf("event start payload is invalid")
}
}
announce := false
if value, exists := payload["announce"]; exists {
var ok bool
announce, ok = value.(bool)
if !ok {
return EventStartRequest{}, fmt.Errorf("event start payload is invalid")
}
}
if !eventIDOK || !eventTypeOK || !classOK || !titleOK || !durationOK || !percentOK || !placardOK || !producesOK || !supportedEventType(eventType) || eventClass < 1 || eventClass > 2 || (eventClass == 1) != (eventType == "range") || strings.TrimSpace(eventID) == "" || strings.TrimSpace(title) == "" || len(placard) > 500 || percent < 0 || percent > 100 || duration < 1 {
return EventStartRequest{}, fmt.Errorf("event start payload is invalid")
}
return EventStartRequest{EventID: eventID, EventType: eventType, Class: eventClass, Title: title, Placard: placard, Percent: percent, NPC: counts[0], Item: counts[1], Zombie: counts[2], Animal: counts[3], Produces: produces, DurationSeconds: duration, MaxParticipants: participants, Announce: announce}, nil
}
func eventProduceRequests(value any) ([]EventProduceRequest, bool) {
raw, ok := value.([]any)
if !ok {
return nil, false
}
result := make([]EventProduceRequest, 0, len(raw))
for _, candidate := range raw {
produce, ok := candidate.(map[string]any)
if !ok || len(produce) != 7 {
return nil, false
}
tradeGoodsID, idOK := produce["tradeGoodsId"].(string)
percent, percentOK := integerPayloadValue(produce["percent"])
quantity, quantityOK := integerPayloadValue(produce["value"])
radius, radiusOK := numberPayloadValue(produce["r"])
x, xOK := numberPayloadValue(produce["x"])
y, yOK := numberPayloadValue(produce["y"])
z, zOK := numberPayloadValue(produce["z"])
if !idOK || strings.TrimSpace(tradeGoodsID) == "" || len(tradeGoodsID) > 128 || !percentOK || percent < 0 || percent > 100 || !quantityOK || quantity < 1 || !radiusOK || radius < 0 || !xOK || !yOK || !zOK {
return nil, false
}
result = append(result, EventProduceRequest{TradeGoodsID: tradeGoodsID, Percent: percent, Value: quantity, Radius: radius, X: x, Y: y, Z: z})
}
return result, true
}
func integerPayloadValue(value any) (int, bool) {
switch number := value.(type) {
case float64:
if number != float64(int(number)) {
return 0, false
}
return int(number), true
case int:
return number, true
case int32:
return int(number), true
case int64:
return int(number), true
default:
return 0, false
}
}
func numberPayloadValue(value any) (float64, bool) {
var result float64
switch number := value.(type) {
case float64:
result = number
case float32:
result = float64(number)
case int:
result = float64(number)
case int32:
result = float64(number)
case int64:
result = float64(number)
default:
return 0, false
}
return result, !math.IsNaN(result) && !math.IsInf(result, 0)
}
func supportedCatalogCode(value string) bool {
if value == "" || len(value) > 128 {
return false
}
for _, character := range value {
if (character < 'a' || character > 'z') && (character < 'A' || character > 'Z') && (character < '0' || character > '9') && character != '_' && character != '-' && character != '.' {
return false
}
}
return true
}
func supportedEventType(value string) bool {
return value == "range" || value == "fixed"
}
@@ -1,229 +0,0 @@
package companion
import (
"context"
"errors"
"os"
"path/filepath"
"regexp"
"runtime"
"strings"
"testing"
"time"
)
// isolatedAdapterPort is a non-production typed-port fixture. It records only
// the adapter's typed inputs and never opens a connection or accepts a raw
// game command.
type isolatedAdapterPort struct {
configFields map[string]string
configErr error
patchErr error
patches []ConfigFieldPatch
notifications []ue4SSPlayerNotification
notifyErr error
notifyAccept bool
spawns []ue4SSVehicleSpawn
spawnReceipts []UE4SSVehicleSpawnReceipt
spawnErrors []error
}
func (port *isolatedAdapterPort) ReadConfig(context.Context) (map[string]string, error) {
return port.configFields, port.configErr
}
func (port *isolatedAdapterPort) ApplyConfigPatch(_ context.Context, _ string, fields []ConfigFieldPatch) (map[string]string, error) {
port.patches = append(port.patches, fields...)
if port.patchErr != nil {
return nil, port.patchErr
}
return map[string]string{"ServerName": "Moonlight", "hostPath": "C:/private/server.ini"}, nil
}
func (port *isolatedAdapterPort) SendPlayerNotification(_ context.Context, notification ue4SSPlayerNotification) (UE4SSNotificationReceipt, error) {
port.notifications = append(port.notifications, notification)
return UE4SSNotificationReceipt{Accepted: port.notifyAccept}, port.notifyErr
}
func (port *isolatedAdapterPort) SpawnVehicle(_ context.Context, spawn ue4SSVehicleSpawn) (UE4SSVehicleSpawnReceipt, error) {
port.spawns = append(port.spawns, spawn)
index := len(port.spawns) - 1
if index >= len(port.spawnReceipts) {
return UE4SSVehicleSpawnReceipt{Outcome: UE4SSVehicleSpawnUnknown}, nil
}
return port.spawnReceipts[index], port.spawnErrors[index]
}
type isolatedDispatchGateway struct {
commands []ClaimedCommand
acks []string
completed map[string][]CommandResult
}
func (gateway *isolatedDispatchGateway) ClaimCommands(context.Context, int) ([]ClaimedCommand, error) {
return append([]ClaimedCommand(nil), gateway.commands...), nil
}
func (gateway *isolatedDispatchGateway) AckCommand(_ context.Context, id string, _ uint64) (CommandAck, error) {
gateway.acks = append(gateway.acks, id)
return CommandAck{CommandID: id, State: "claimed", FencingToken: 1}, nil
}
func (gateway *isolatedDispatchGateway) CompleteCommand(_ context.Context, id string, _ uint64, result CommandResult) (CompletedCommand, error) {
if gateway.completed == nil {
gateway.completed = map[string][]CommandResult{}
}
gateway.completed[id] = append(gateway.completed[id], result)
return CompletedCommand{State: "completed"}, nil
}
func e2eClaim(id, commandType string, payload map[string]any, stamp time.Time) ClaimedCommand {
return ClaimedCommand{ID: id, ProfileKey: ProfileKey, CommandType: commandType, Payload: payload, FencingToken: 1, LeaseExpiresAt: stamp.Add(time.Minute), ExpiresAt: stamp.Add(time.Minute)}
}
func TestSupportedAdaptersDispatchThroughIsolatedTypedPorts(t *testing.T) {
stamp := time.Now().UTC()
port := &isolatedAdapterPort{
configFields: map[string]string{"ServerName": "Moonlight", "Password": "never-return", "hostPath": "C:/private/server.ini"},
notifyAccept: true,
spawnReceipts: []UE4SSVehicleSpawnReceipt{{Outcome: UE4SSVehicleSpawnAccepted}, {Outcome: UE4SSVehicleSpawnRejected}, {Outcome: UE4SSVehicleSpawnUnknown}},
spawnErrors: []error{nil, nil, errors.New("receipt unavailable")},
}
adapter := RuntimeAdapter{BoundServerID: "server-1", Config: port, Notification: port, VehicleSpawn: port}
registry := NewHandlerRegistry(HandlerAvailability{BoundServerID: "server-1", Approved: true, Capabilities: map[string]bool{"config.read": true, "config.patch": true, "player.notify": true, "vehicle.spawn": true}}, adapter)
gateway := &isolatedDispatchGateway{commands: []ClaimedCommand{
e2eClaim("config-read", "config.read", map[string]any{}, stamp),
e2eClaim("config-patch", "config.patch", map[string]any{"revision": "r1", "fields": []any{map[string]any{"key": "ServerName", "value": "Moonlight"}}}, stamp),
e2eClaim("notify", "player.notify", map[string]any{"playerId": "76561198000000001", "message": "Moonlight ready"}, stamp),
e2eClaim("notify", "player.notify", map[string]any{"playerId": "76561198000000001", "message": "Moonlight ready"}, stamp),
e2eClaim("spawn-success", "vehicle.spawn", map[string]any{"vehicleCode": "BPC_Laika_C"}, stamp),
e2eClaim("spawn-failed", "vehicle.spawn", map[string]any{"vehicleCode": "BPC_WolfsWagen_C"}, stamp),
e2eClaim("spawn-unknown", "vehicle.spawn", map[string]any{"vehicleCode": "BPC_Laika_C"}, stamp),
e2eClaim("spawn-unknown", "vehicle.spawn", map[string]any{"vehicleCode": "BPC_Laika_C"}, stamp),
}}
dispatcher := Dispatcher{Client: gateway, Registry: registry, Now: func() time.Time { return stamp }}
if err := dispatcher.DispatchOnce(context.Background()); err != nil {
t.Fatalf("dispatch supported adapters: %v", err)
}
if len(port.notifications) != 1 || port.notifications[0].ServerID != "server-1" || port.notifications[0].localCommandPreview == "" {
t.Fatalf("notification did not remain server-bound and idempotent: %+v", port.notifications)
}
if len(port.patches) != 1 || gateway.completed["config-read"][0].Payload["fields"].(map[string]string)["ServerName"] != "Moonlight" || gateway.completed["config-patch"][0].Payload["appliedFields"].(map[string]string)["ServerName"] != "Moonlight" || gateway.completed["notify"][0].Payload["accepted"] != true {
t.Fatalf("supported adapters did not return their bounded successful results: patches=%+v completed=%+v", port.patches, gateway.completed)
}
if len(port.spawns) != 3 || port.spawns[0].localCommandPreview != "#spawnvehicle BPC_Laika_C" || port.spawns[1].localCommandPreview != "#spawnvehicle BPC_WolfsWagen_C" || port.spawns[2].localCommandPreview != "#spawnvehicle BPC_Laika_C" {
t.Fatalf("vehicle adapter did not use only fixed private templates: %+v", port.spawns)
}
if gateway.completed["spawn-success"][0].Payload["outcome"] != "succeeded" || gateway.completed["spawn-failed"][0].Payload["outcome"] != "failed" || gateway.completed["spawn-unknown"][0].Payload["outcome"] != "unknown" {
t.Fatalf("unexpected bounded vehicle outcomes: %+v", gateway.completed)
}
for id, results := range gateway.completed {
for _, result := range results {
serialized := result.Summary + " " + stringifyPayload(result.Payload)
for _, secret := range []string{"C:/private/server.ini", "never-return", "#spawnvehicle", "SendChat"} {
if strings.Contains(serialized, secret) {
t.Fatalf("%s exposed protected adapter output %q: %+v", id, secret, result)
}
}
}
}
}
func TestSupportedAdaptersFailClosedForBindingApprovalAndCapability(t *testing.T) {
stamp := time.Now().UTC()
for name, testCase := range map[string]struct {
availability HandlerAvailability
adapter RuntimeAdapter
}{
"binding": {availability: HandlerAvailability{BoundServerID: "server-1", Approved: true, Capabilities: map[string]bool{"vehicle.spawn": true}}, adapter: RuntimeAdapter{BoundServerID: "server-2"}},
"approval": {availability: HandlerAvailability{BoundServerID: "server-1", Approved: false, Capabilities: map[string]bool{"vehicle.spawn": true}}, adapter: RuntimeAdapter{BoundServerID: "server-1"}},
"capability": {availability: HandlerAvailability{BoundServerID: "server-1", Approved: true, Capabilities: map[string]bool{}}, adapter: RuntimeAdapter{BoundServerID: "server-1"}},
} {
t.Run(name, func(t *testing.T) {
port := &isolatedAdapterPort{}
testCase.adapter.VehicleSpawn = port
gateway := &isolatedDispatchGateway{commands: []ClaimedCommand{e2eClaim(name, "vehicle.spawn", map[string]any{"vehicleCode": "BPC_Laika_C"}, stamp)}}
dispatcher := Dispatcher{Client: gateway, Registry: NewHandlerRegistry(testCase.availability, testCase.adapter), Now: func() time.Time { return stamp }}
if err := dispatcher.DispatchOnce(context.Background()); err != nil {
t.Fatalf("dispatch fail-closed case: %v", err)
}
result := gateway.completed[name][0]
if result.Payload["result"] != "unsupported" || len(port.spawns) != 0 {
t.Fatalf("unsafe adapter reached transport: result=%+v spawns=%+v", result, port.spawns)
}
})
}
}
func TestSupportedAdapterTransportFailuresCompleteWithoutProtectedOutput(t *testing.T) {
stamp := time.Now().UTC()
port := &isolatedAdapterPort{patchErr: errors.New("private port failed"), notifyErr: errors.New("private notification failed")}
adapter := RuntimeAdapter{BoundServerID: "server-1", Config: port, Notification: port}
gateway := &isolatedDispatchGateway{commands: []ClaimedCommand{
e2eClaim("patch-failure", "config.patch", map[string]any{"revision": "r1", "fields": []any{map[string]any{"key": "ServerName", "value": "Moonlight"}}}, stamp),
e2eClaim("notification-failure", "player.notify", map[string]any{"playerId": "76561198000000001", "message": "Moonlight ready"}, stamp),
}}
dispatcher := Dispatcher{Client: gateway, Registry: NewHandlerRegistry(HandlerAvailability{BoundServerID: "server-1", Approved: true, Capabilities: map[string]bool{"config.patch": true, "player.notify": true}}, adapter), Now: func() time.Time { return stamp }}
if err := dispatcher.DispatchOnce(context.Background()); err != nil {
t.Fatalf("dispatch adapter failures: %v", err)
}
for id, results := range gateway.completed {
result := results[0]
if result.Payload["result"] != "failed" || strings.Contains(stringifyPayload(result.Payload), "private") || strings.Contains(result.Summary, "private") {
t.Fatalf("%s did not redact failed typed-port output: %+v", id, result)
}
}
if len(port.notifications) != 1 || port.notifications[0].localCommandPreview == "" {
t.Fatalf("notification fixture did not receive one typed request: %+v", port.notifications)
}
}
func stringifyPayload(payload map[string]any) string {
parts := make([]string, 0, len(payload))
for key, value := range payload {
parts = append(parts, key+"="+strings.TrimSpace(strings.ReplaceAll(strings.TrimSpace(toText(value)), "\n", " ")))
}
return strings.Join(parts, " ")
}
func toText(value any) string {
if text, ok := value.(string); ok {
return text
}
return ""
}
func TestCompanionProductionSourceHasNoForbiddenAdapterPaths(t *testing.T) {
_, currentFile, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("resolve companion source directory")
}
forbidden := map[string]*regexp.Regexp{
"raw SQL or direct database access": regexp.MustCompile(`(?im)"(?:database/sql|github\.com/(?:mattn/go-sqlite3|go-sql-driver/mysql)|gorm\.io/gorm)"|\b(?:sql|db|database)\.(?:Open|Exec(?:Context)?|Query(?:Context)?|Prepare(?:Context)?)\s*\(`),
"unrestricted RCON or command execution": regexp.MustCompile(`(?i)\b(?:send|execute|run|dispatch)[a-z0-9_]*(?:rcon|rawcommand|command)\s*\(`),
"desktop automation or screen capture": regexp.MustCompile(`(?i)\b(?:tesseract|gosseract|screenshot|robotgo|autogui|keybd_event|mouse_event|sendinput)\b`),
"direct socket transport": regexp.MustCompile(`\bnet\.(?:Dial|DialTimeout)\s*\(`),
}
root := filepath.Dir(currentFile)
err := filepath.WalkDir(root, func(path string, entry os.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if entry.IsDir() || filepath.Ext(path) != ".go" || strings.HasSuffix(path, "_test.go") {
return nil
}
source, err := os.ReadFile(path)
if err != nil {
return err
}
for name, pattern := range forbidden {
if match := pattern.FindString(string(source)); match != "" {
t.Fatalf("%s contains forbidden %s: %q", filepath.Base(path), name, match)
}
}
return nil
})
if err != nil {
t.Fatalf("scan production companion source: %v", err)
}
}
@@ -1,288 +0,0 @@
package companion
import (
"context"
"errors"
"testing"
"time"
)
type configPortFixture struct {
fields map[string]string
patches []ConfigFieldPatch
}
func (fixture *configPortFixture) ReadConfig(context.Context) (map[string]string, error) {
return fixture.fields, nil
}
func (fixture *configPortFixture) ApplyConfigPatch(_ context.Context, _ string, fields []ConfigFieldPatch) (map[string]string, error) {
fixture.patches = fields
return map[string]string{"ServerName": "Moon", "hostPath": "C:/secret"}, nil
}
type notificationPortFixture struct {
deliveries []ue4SSPlayerNotification
accepted bool
}
type rewardPortFixture struct {
grants []RewardGrant
receipt DeliveryReceipt
err error
}
func (fixture *rewardPortFixture) DeliverReward(_ context.Context, grant RewardGrant) (DeliveryReceipt, error) {
fixture.grants = append(fixture.grants, grant)
return fixture.receipt, fixture.err
}
type eventPortFixture struct {
requests []EventStartRequest
receipt EventStartReceipt
err error
}
func (fixture *eventPortFixture) StartEvent(_ context.Context, request EventStartRequest) (EventStartReceipt, error) {
fixture.requests = append(fixture.requests, request)
return fixture.receipt, fixture.err
}
// nonProductionVehicleSpawnPortFixture is an isolated test double. It has no
// network, socket, credential, or raw-command entry point; it can observe only
// the Companion's private typed request and return a bounded receipt.
type nonProductionVehicleSpawnPortFixture struct {
requests []ue4SSVehicleSpawn
receipt UE4SSVehicleSpawnReceipt
err error
}
func (fixture *nonProductionVehicleSpawnPortFixture) SpawnVehicle(_ context.Context, request ue4SSVehicleSpawn) (UE4SSVehicleSpawnReceipt, error) {
fixture.requests = append(fixture.requests, request)
return fixture.receipt, fixture.err
}
func (fixture *notificationPortFixture) SendPlayerNotification(_ context.Context, notification ue4SSPlayerNotification) (UE4SSNotificationReceipt, error) {
fixture.deliveries = append(fixture.deliveries, notification)
return UE4SSNotificationReceipt{Accepted: fixture.accepted}, nil
}
func TestRuntimeAdapterUsesOnlyLogicalConfigValuesAndRedactsDiagnostics(t *testing.T) {
port := &configPortFixture{fields: map[string]string{"ServerName": "Moon", "hostPath": "C:/secret", "Password": "nope"}}
adapter := RuntimeAdapter{Config: port, DiagnosticsState: map[string]string{"status": "healthy", "hostPath": "C:/secret"}}
read, err := adapter.ReadConfiguration(context.Background())
if err != nil {
t.Fatalf("read config: %v", err)
}
fields := read["fields"].(map[string]string)
if fields["ServerName"] != "Moon" || len(fields) != 1 {
t.Fatalf("unsafe config was exposed: %+v", fields)
}
patched, err := adapter.PatchConfiguration(context.Background(), map[string]any{"revision": "r1", "fields": []any{map[string]any{"key": "ServerName", "value": "Moon"}}})
if err != nil {
t.Fatalf("patch config: %v", err)
}
if len(port.patches) != 1 || patched["appliedFields"].(map[string]string)["hostPath"] != "" {
t.Fatalf("patch leaked unsafe details: %+v", patched)
}
diagnostics, _ := adapter.Diagnostics(context.Background())
if diagnostics["hostPath"] != nil || diagnostics["status"] != "healthy" {
t.Fatalf("diagnostics leaked unsafe details: %+v", diagnostics)
}
}
func TestRewardDeliveryAcceptsRealSCUMCatalogCodesAndReturnsDeclaredResult(t *testing.T) {
port := &rewardPortFixture{receipt: DeliveryReceipt{Outcome: "delivered", DeliveryID: "delivery-1"}}
adapter := RuntimeAdapter{Rewards: port}
result, err := adapter.DeliverReward(context.Background(), map[string]any{
"grantId": "grant-1", "playerId": "76561198000000001",
"items": []any{map[string]any{"catalogCode": "BPC_Improvised_Backpack.01", "quantity": float64(2)}},
"operations": []any{"#SpawnItem BPC_Improvised_Backpack.01 2"},
})
if err != nil || result["accepted"] != true || result["status"] != "delivered" || result["deliveryId"] != "delivery-1" {
t.Fatalf("reward result did not match the bridge schema: result=%+v err=%v", result, err)
}
if len(port.grants) != 1 || port.grants[0].Items[0] != (RewardItem{CatalogCode: "BPC_Improvised_Backpack.01", Quantity: 2}) || len(port.grants[0].Operations) != 1 || port.grants[0].Operations[0] != "#SpawnItem BPC_Improvised_Backpack.01 2" {
t.Fatalf("reward items or operations did not reach the typed reward port: %+v", port.grants)
}
manyItems := make([]any, 9)
for index := range manyItems {
manyItems[index] = map[string]any{"catalogCode": "BPC_Apple", "quantity": float64(101 + index)}
}
if _, err := adapter.DeliverReward(context.Background(), map[string]any{"grantId": "grant-many", "playerId": "76561198000000001", "items": manyItems, "operations": []any{}}); err != nil || len(port.grants) != 2 || len(port.grants[1].Items) != 9 || port.grants[1].Items[0].Quantity != 101 {
t.Fatalf("valid reward count or quantity was rejected: grants=%+v err=%v", port.grants, err)
}
before := len(port.grants)
if _, err := adapter.DeliverReward(context.Background(), map[string]any{
"grantId": "grant-2", "playerId": "76561198000000001",
"items": []any{map[string]any{"catalogCode": "#SpawnItem BPC_Bad", "quantity": float64(1)}},
"operations": []any{},
}); err == nil || len(port.grants) != before {
t.Fatal("invalid catalog text reached the typed reward port")
}
}
func TestEventStartAcceptsPositiveCountsAndDurationWithoutInventedUpperLimits(t *testing.T) {
produces := make([]any, 101)
for index := range produces {
produces[index] = map[string]any{"tradeGoodsId": "cargo-drop", "percent": float64(80), "value": float64(10001 + index), "r": float64(2000001 + index), "x": float64(3000000 + index), "y": float64(-3000000 - index), "z": float64(index)}
}
request, err := eventStartRequest(map[string]any{"eventId": "event-large", "eventType": "range", "class": float64(1), "title": "Large Event", "placard": "", "percent": float64(100), "npc": float64(10001), "item": float64(10002), "zombie": float64(10003), "animal": float64(10004), "produces": produces, "durationSeconds": float64(86401), "maxParticipants": float64(1001)})
if err != nil || request.DurationSeconds != 86401 || request.MaxParticipants != 1001 || request.NPC != 10001 || len(request.Produces) != 101 || request.Produces[0].Value != 10001 || request.Produces[0].Radius != 2000001 {
t.Fatalf("valid event values above old limits were rejected: request=%+v err=%v", request, err)
}
}
func TestRewardDeliverySupportsOperationsWithoutItemsAndRejectsEmptyGrant(t *testing.T) {
port := &rewardPortFixture{receipt: DeliveryReceipt{Outcome: "delivered"}}
adapter := RuntimeAdapter{BoundServerID: "server-1", Rewards: port}
registry := NewHandlerRegistry(HandlerAvailability{BoundServerID: "server-1", Approved: true, Capabilities: map[string]bool{"reward.deliver": true}}, adapter)
stamp := time.Now().UTC()
result, err := registry.Execute(context.Background(), ClaimedCommand{
ID: "reward-operations", ProfileKey: ProfileKey, CommandType: "reward.deliver", FencingToken: 1,
LeaseExpiresAt: stamp.Add(time.Minute), ExpiresAt: stamp.Add(time.Minute),
Payload: map[string]any{"grantId": "grant-operations", "playerId": "76561198000000001", "items": []any{}, "operations": []any{"#SetFamePoints 250"}},
})
if err != nil || result.Status != "succeeded" || result.Payload["accepted"] != true || len(port.grants) != 1 || len(port.grants[0].Items) != 0 || len(port.grants[0].Operations) != 1 || port.grants[0].Operations[0] != "#SetFamePoints 250" {
t.Fatalf("operation-only reward was not preserved: result=%+v grants=%+v err=%v", result, port.grants, err)
}
if _, err := adapter.DeliverReward(context.Background(), map[string]any{
"grantId": "grant-empty", "playerId": "76561198000000001", "items": []any{}, "operations": []any{},
}); err == nil || len(port.grants) != 1 {
t.Fatalf("empty reward reached the typed reward port: grants=%+v err=%v", port.grants, err)
}
}
func TestEventStartRequiresMatchingClassAndType(t *testing.T) {
payload := map[string]any{"eventId": "event-fixed", "eventType": "fixed", "class": float64(2), "title": "Fixed Event", "placard": "Hold the point", "percent": float64(100), "produces": []any{}, "durationSeconds": float64(600)}
request, err := eventStartRequest(payload)
if err != nil || request.EventType != "fixed" || request.Class != 2 {
t.Fatalf("fixed class event was not accepted: request=%+v err=%v", request, err)
}
payload["class"] = float64(1)
if _, err := eventStartRequest(payload); err == nil {
t.Fatal("divergent event class and type was accepted")
}
}
func TestEventStartHandlerInvokesTypedPortAndReturnsCachedCommandResult(t *testing.T) {
stamp := time.Now().UTC()
port := &eventPortFixture{receipt: EventStartReceipt{Accepted: true, Status: "started", EventID: "event-1", Message: "range event started"}}
adapter := RuntimeAdapter{BoundServerID: "server-1", Events: port}
registry := NewHandlerRegistry(HandlerAvailability{BoundServerID: "server-1", Approved: true, Capabilities: map[string]bool{"event.start": true}}, adapter)
command := ClaimedCommand{
ID: "event-command-1", ProfileKey: ProfileKey, CommandType: "event.start", FencingToken: 1,
LeaseExpiresAt: stamp.Add(time.Minute), ExpiresAt: stamp.Add(time.Minute),
Payload: map[string]any{"eventId": "event-1", "eventType": "range", "class": float64(1), "title": "Friday Range", "placard": "Event starting", "percent": float64(75), "npc": float64(2), "item": float64(3), "zombie": float64(4), "animal": float64(1), "produces": []any{map[string]any{"tradeGoodsId": "cargo-drop", "percent": float64(80), "value": float64(2), "r": float64(500), "x": float64(1000), "y": float64(2000), "z": float64(300)}}, "durationSeconds": float64(1800), "maxParticipants": float64(40), "announce": true},
}
for range 2 {
result, err := registry.Execute(context.Background(), command)
if err != nil || result.Status != "succeeded" || result.Payload["accepted"] != true || result.Payload["status"] != "started" || result.Payload["eventId"] != "event-1" {
t.Fatalf("event.start did not return its executable result: result=%+v err=%v", result, err)
}
}
if len(port.requests) != 1 {
t.Fatalf("event.start handler did not execute exactly once: %+v", port.requests)
}
request := port.requests[0]
if request.EventID != "event-1" || request.EventType != "range" || request.Class != 1 || request.Title != "Friday Range" || request.Placard != "Event starting" || request.Percent != 75 || request.NPC != 2 || request.Item != 3 || request.Zombie != 4 || request.Animal != 1 || len(request.Produces) != 1 || request.Produces[0].TradeGoodsID != "cargo-drop" || request.DurationSeconds != 1800 || request.MaxParticipants != 40 || !request.Announce {
t.Fatalf("event.start payload did not reach the typed event port: %+v", request)
}
}
func TestUE4SSNotificationIsTypedAndRedacted(t *testing.T) {
port := &notificationPortFixture{accepted: true}
adapter := RuntimeAdapter{BoundServerID: "server-1", Notification: port}
result, err := adapter.NotifyPlayer(context.Background(), map[string]any{"playerId": "76561198000000001", "message": "Moon \"gift\""})
if err != nil || !result["accepted"].(bool) || len(port.deliveries) != 1 {
t.Fatalf("typed notification was not delivered: result=%+v err=%v deliveries=%+v", result, err, port.deliveries)
}
delivery := port.deliveries[0]
if delivery.ServerID != "server-1" || delivery.chatType != fixedNotificationType || delivery.localCommandPreview != "SendChat 4 \"Moon \\\"gift\\\"\" 76561198000000001" {
t.Fatalf("notification did not use the fixed UE4SS contract: %+v", delivery)
}
if result["message"] == delivery.localCommandPreview || result["command"] != nil || result["rcon"] != nil {
t.Fatalf("notification leaked protected transport details: %+v", result)
}
}
func TestRuntimeNotificationRejectsInvalidRecipient(t *testing.T) {
port := &notificationPortFixture{accepted: true}
adapter := RuntimeAdapter{BoundServerID: "server-1", Notification: port}
if _, err := adapter.NotifyPlayer(context.Background(), map[string]any{"playerId": "not-a-steam-id", "message": "Moonlight"}); err == nil {
t.Fatal("unverified recipient identity must be rejected")
}
}
func TestNotificationFailureIsCachedWithoutInvokingRewardDelivery(t *testing.T) {
stamp := time.Now().UTC()
port := &notificationPortFixture{accepted: false}
adapter := RuntimeAdapter{BoundServerID: "server-1", Notification: port}
registry := NewHandlerRegistry(HandlerAvailability{BoundServerID: "server-1", Approved: true, Capabilities: map[string]bool{"player.notify": true}}, adapter)
command := ClaimedCommand{ID: "notification-1", ProfileKey: ProfileKey, CommandType: "player.notify", Payload: map[string]any{"playerId": "76561198000000001", "message": "Moonlight"}, FencingToken: 1, LeaseExpiresAt: stamp.Add(time.Minute), ExpiresAt: stamp.Add(time.Minute)}
for range 2 {
result, err := registry.Execute(context.Background(), command)
if err != nil || result.Payload["accepted"] != false {
t.Fatalf("notification failure was not typed: result=%+v err=%v", result, err)
}
}
if len(port.deliveries) != 1 {
t.Fatalf("duplicate notification attempted transport %d times", len(port.deliveries))
}
}
func TestVersionedVehicleSpawnUsesFixedTemplateAndPrivatePreviewOnly(t *testing.T) {
port := &nonProductionVehicleSpawnPortFixture{receipt: UE4SSVehicleSpawnReceipt{Outcome: UE4SSVehicleSpawnAccepted}}
adapter := RuntimeAdapter{BoundServerID: "server-1", VehicleSpawn: port}
result, err := adapter.SpawnVehicle(context.Background(), map[string]any{"vehicleCode": "BPC_Laika_C"})
if err != nil || result["outcome"] != "succeeded" || len(port.requests) != 1 {
t.Fatalf("fixed vehicle spawn was not delivered: result=%+v err=%v requests=%+v", result, err, port.requests)
}
request := port.requests[0]
if request.ServerID != "server-1" || request.VehicleCode != "BPC_Laika_C" || request.localCommandPreview != "#spawnvehicle BPC_Laika_C" {
t.Fatalf("vehicle spawn did not use the fixed template: %+v", request)
}
if result["command"] != nil || result["rcon"] != nil || result["outcome"] == request.localCommandPreview {
t.Fatalf("vehicle spawn leaked protected transport details: %+v", result)
}
}
func TestVersionedVehicleSpawnFailsClosedAndClassifiesBoundedReceipts(t *testing.T) {
port := &nonProductionVehicleSpawnPortFixture{receipt: UE4SSVehicleSpawnReceipt{Outcome: UE4SSVehicleSpawnRejected}}
adapter := RuntimeAdapter{BoundServerID: "server-1", VehicleSpawn: port}
for name, testCase := range map[string]struct {
outcome string
receipt UE4SSVehicleSpawnReceipt
err error
}{
"failed": {outcome: "failed", receipt: UE4SSVehicleSpawnReceipt{Outcome: UE4SSVehicleSpawnRejected}},
"unknown": {outcome: "unknown", receipt: UE4SSVehicleSpawnReceipt{Outcome: UE4SSVehicleSpawnUnknown}},
"unknown-transport": {outcome: "unknown", receipt: UE4SSVehicleSpawnReceipt{}, err: errors.New("isolated transport timeout")},
} {
port.receipt, port.err = testCase.receipt, testCase.err
result, err := adapter.SpawnVehicle(context.Background(), map[string]any{"vehicleCode": "BPC_WolfsWagen_C"})
if err != nil || result["outcome"] != testCase.outcome {
t.Fatalf("receipt %s was not safely classified: result=%+v err=%v", name, result, err)
}
}
before := len(port.requests)
if _, err := adapter.SpawnVehicle(context.Background(), map[string]any{"vehicleCode": "#spawnvehicle BPC_Laika_C"}); err == nil || len(port.requests) != before {
t.Fatal("raw command text must not reach the vehicle transport")
}
}
func TestVehicleSpawnUnknownOutcomeIsCachedWithoutRetry(t *testing.T) {
stamp := time.Now().UTC()
port := &nonProductionVehicleSpawnPortFixture{receipt: UE4SSVehicleSpawnReceipt{Outcome: UE4SSVehicleSpawnUnknown}}
adapter := RuntimeAdapter{BoundServerID: "server-1", VehicleSpawn: port}
registry := NewHandlerRegistry(HandlerAvailability{BoundServerID: "server-1", Approved: true, Capabilities: map[string]bool{"vehicle.spawn": true}}, adapter)
command := ClaimedCommand{ID: "vehicle-unknown-1", ProfileKey: ProfileKey, CommandType: "vehicle.spawn", Payload: map[string]any{"vehicleCode": "BPC_Laika_C"}, FencingToken: 1, LeaseExpiresAt: stamp.Add(time.Minute), ExpiresAt: stamp.Add(time.Minute)}
for range 2 {
result, err := registry.Execute(context.Background(), command)
if err != nil || result.Payload["outcome"] != "unknown" {
t.Fatalf("unknown vehicle outcome was not retained: result=%+v err=%v", result, err)
}
}
if len(port.requests) != 1 {
t.Fatalf("unknown vehicle outcome retried transport %d times", len(port.requests))
}
}
@@ -1,516 +0,0 @@
package companion
import (
"bytes"
"context"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"crypto/tls"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"os"
"sort"
"strconv"
"strings"
"sync"
"time"
)
const (
registerPath = "/api/v1/client-managers/register"
heartbeatPath = "/api/v1/client-managers/heartbeat"
claimPath = "/api/v1/game-client-bridge/companion/commands/claim"
snapshotPath = "/api/v1/game-client-bridge/companion/snapshots"
maxRequestBytes = 128 * 1024
maxResponseBytes = 4 * 1024 * 1024
)
type Options struct {
Now func() time.Time
Nonce func() (string, error)
}
type Client struct {
config Config
proof string
httpClient *http.Client
now func() time.Time
nonce func() (string, error)
mu sync.Mutex
sessionToken string
sessionExpiresAt time.Time
heartbeatSequence uint64
snapshotSequences map[string]uint64
}
type Registration struct {
ExpiresAt time.Time
HeartbeatEverySeconds int
ServerTime time.Time
}
type HealthReport struct {
Status string
Reason string
}
type HeartbeatResult struct {
Status string
Health string
NextHeartbeatSeconds int
SessionExpiresAt time.Time
ServerTime time.Time
}
type ClaimedCommand struct {
ID string `json:"id"`
ProfileKey string `json:"profileKey"`
CommandType string `json:"commandType"`
Payload map[string]any `json:"payload"`
Priority int `json:"priority"`
FencingToken uint64 `json:"fencingToken"`
ClaimedAt time.Time `json:"claimedAt"`
LeaseExpiresAt time.Time `json:"leaseExpiresAt"`
ExpiresAt time.Time `json:"expiresAt"`
}
type CommandAck struct {
CommandID string `json:"commandId"`
State string `json:"state"`
FencingToken uint64 `json:"fencingToken"`
AcknowledgedAt time.Time `json:"acknowledgedAt"`
}
type CommandResult struct {
Status string
Summary string
Payload map[string]any
}
type CompletedCommand struct {
CommandID string `json:"commandId"`
State string `json:"state"`
UpdatedAt time.Time `json:"updatedAt"`
CompletedAt time.Time `json:"completedAt"`
}
type Snapshot struct {
Type string
SchemaVersion string
StreamKey string
Sequence uint64
ObservedAt time.Time
Payload map[string]any
KeepForSeconds int
MaxRecords int
}
type AcceptedSnapshot struct {
SnapshotID string `json:"snapshotId"`
ProfileKey string `json:"profileKey"`
Type string `json:"type"`
SchemaVersion string `json:"schemaVersion"`
StreamKey string `json:"streamKey"`
Sequence uint64 `json:"sequence"`
AcceptedAt time.Time `json:"acceptedAt"`
ExpiresAt time.Time `json:"expiresAt"`
}
type HTTPError struct {
StatusCode int
ExpectedStatus int
}
func (err HTTPError) Error() string {
return fmt.Sprintf("platform request returned HTTP %d; expected %d", err.StatusCode, err.ExpectedStatus)
}
func NewClient(config Config, options Options) (*Client, error) {
if err := config.Validate(); err != nil {
return nil, err
}
proof, configured := os.LookupEnv(config.Proof.MaterialEnv)
if !configured || proof == "" {
return nil, fmt.Errorf("component proof environment variable is not configured")
}
if len(proof) > 4096 {
return nil, fmt.Errorf("component proof environment variable is invalid")
}
baseURL, err := canonicalPlatformOrigin(config.Platform.BaseURL)
if err != nil {
return nil, err
}
transport := secureDefaultTransport()
timeout := time.Duration(config.Timing.RequestTimeoutSeconds) * time.Second
httpClient := &http.Client{
Transport: transport,
Timeout: timeout,
CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
return http.ErrUseLastResponse
},
}
now := options.Now
if now == nil {
now = time.Now
}
nonce := options.Nonce
if nonce == nil {
nonce = randomNonce
}
config.Capabilities = append([]string(nil), config.Capabilities...)
config.Platform.BaseURL = baseURL
return &Client{config: config, proof: proof, httpClient: httpClient, now: now, nonce: nonce, snapshotSequences: make(map[string]uint64)}, nil
}
func secureDefaultTransport() *http.Transport {
return &http.Transport{
Proxy: http.ProxyFromEnvironment,
DialContext: (&net.Dialer{Timeout: 30 * time.Second, KeepAlive: 30 * time.Second}).DialContext,
ForceAttemptHTTP2: true,
MaxIdleConns: 100,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
ExpectContinueTimeout: time.Second,
TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12},
}
}
func (client *Client) Register(ctx context.Context) (Registration, error) {
nonce, err := client.nonce()
if err != nil {
return Registration{}, fmt.Errorf("create component registration nonce: %w", err)
}
if !validNonce(nonce) {
return Registration{}, fmt.Errorf("component registration nonce is invalid")
}
component := client.config.Component
request := registerRequest{
InstallationID: component.InstallationID,
ServerInstanceID: component.ServerInstanceID,
ProfileKey: component.ProfileKey,
ArtifactID: component.ArtifactID,
Version: component.Version,
SourceRevision: component.SourceRevision,
TargetOS: component.TargetOS,
TargetArch: component.TargetArch,
KeyGeneration: component.KeyGeneration,
DeploymentGeneration: component.DeploymentGeneration,
Capabilities: append([]string(nil), client.config.Capabilities...),
Timestamp: client.now().UTC(),
Nonce: nonce,
}
request.Signature = registrationSignature(client.proof, request)
var response registerResponse
if err := client.postJSON(ctx, registerPath, http.StatusOK, request, &response); err != nil {
return Registration{}, err
}
if !response.Accepted || response.InstallationID != component.InstallationID || response.SessionToken == "" || response.ExpiresAt.IsZero() || !client.now().Before(response.ExpiresAt) {
return Registration{}, fmt.Errorf("platform returned an invalid component registration")
}
client.mu.Lock()
client.sessionToken = response.SessionToken
client.sessionExpiresAt = response.ExpiresAt
client.heartbeatSequence = 0
client.mu.Unlock()
return Registration{ExpiresAt: response.ExpiresAt, HeartbeatEverySeconds: response.HeartbeatEverySeconds, ServerTime: response.ServerTime}, nil
}
func (client *Client) Heartbeat(ctx context.Context, report HealthReport) (HeartbeatResult, error) {
if report.Status != "healthy" && report.Status != "degraded" && report.Status != "unhealthy" && report.Status != "offline" {
return HeartbeatResult{}, fmt.Errorf("component health status is invalid")
}
token, sessionExpiresAt, sequence, err := client.nextHeartbeat()
if err != nil {
return HeartbeatResult{}, err
}
request := heartbeatRequest{
InstallationID: client.config.Component.InstallationID,
SessionToken: token,
Sequence: sequence,
Health: report.Status,
HealthReason: report.Reason,
Capabilities: append([]string(nil), client.config.Capabilities...),
SentAt: client.now().UTC(),
}
var response heartbeatResponse
if err := client.postJSON(ctx, heartbeatPath, http.StatusOK, request, &response); err != nil {
return HeartbeatResult{}, err
}
if !response.Accepted || response.InstallationID != client.config.Component.InstallationID || !response.SessionExpiresAt.Equal(sessionExpiresAt) {
return HeartbeatResult{}, fmt.Errorf("platform returned an invalid component heartbeat")
}
return HeartbeatResult{Status: response.Status, Health: response.Health, NextHeartbeatSeconds: response.NextHeartbeatSeconds, SessionExpiresAt: response.SessionExpiresAt, ServerTime: response.ServerTime}, nil
}
func (client *Client) ClaimCommands(ctx context.Context, limit int) ([]ClaimedCommand, error) {
if limit < 0 || limit > 50 {
return nil, fmt.Errorf("claim limit must be between 0 and 50")
}
token, err := client.currentSession()
if err != nil {
return nil, err
}
var response claimResponse
if err := client.postJSON(ctx, claimPath, http.StatusOK, claimRequest{SessionToken: token, Limit: limit}, &response); err != nil {
return nil, err
}
if response.Count != len(response.Items) {
return nil, fmt.Errorf("platform returned an invalid command claim batch")
}
return append([]ClaimedCommand(nil), response.Items...), nil
}
func (client *Client) AckCommand(ctx context.Context, commandID string, fencingToken uint64) (CommandAck, error) {
if commandID == "" || fencingToken == 0 {
return CommandAck{}, fmt.Errorf("command ID and fencing token are required")
}
token, err := client.currentSession()
if err != nil {
return CommandAck{}, err
}
path := "/api/v1/game-client-bridge/companion/commands/" + url.PathEscape(commandID) + "/ack"
var response CommandAck
if err := client.postJSON(ctx, path, http.StatusOK, ackRequest{SessionToken: token, FencingToken: fencingToken}, &response); err != nil {
return CommandAck{}, err
}
return response, nil
}
func (client *Client) CompleteCommand(ctx context.Context, commandID string, fencingToken uint64, result CommandResult) (CompletedCommand, error) {
if commandID == "" || fencingToken == 0 {
return CompletedCommand{}, fmt.Errorf("command ID and fencing token are required")
}
if result.Status != "succeeded" && result.Status != "failed" && result.Status != "cancelled" {
return CompletedCommand{}, fmt.Errorf("command result status is invalid")
}
token, err := client.currentSession()
if err != nil {
return CompletedCommand{}, err
}
path := "/api/v1/game-client-bridge/companion/commands/" + url.PathEscape(commandID) + "/result"
request := resultRequest{SessionToken: token, FencingToken: fencingToken, Status: result.Status, Summary: result.Summary, Payload: result.Payload}
var response CompletedCommand
if err := client.postJSON(ctx, path, http.StatusOK, request, &response); err != nil {
return CompletedCommand{}, err
}
return response, nil
}
func (client *Client) UploadSnapshot(ctx context.Context, snapshot Snapshot) (AcceptedSnapshot, error) {
if snapshot.Type == "" || snapshot.SchemaVersion == "" || snapshot.StreamKey == "" || snapshot.Sequence == 0 || snapshot.ObservedAt.IsZero() || snapshot.Payload == nil || snapshot.KeepForSeconds <= 0 {
return AcceptedSnapshot{}, fmt.Errorf("typed snapshot is incomplete")
}
if err := client.reserveSnapshotSequence(snapshot); err != nil {
return AcceptedSnapshot{}, err
}
token, err := client.currentSession()
if err != nil {
return AcceptedSnapshot{}, err
}
request := snapshotRequest{
SessionToken: token, Type: snapshot.Type, SchemaVersion: snapshot.SchemaVersion, StreamKey: snapshot.StreamKey,
Sequence: snapshot.Sequence, ObservedAt: snapshot.ObservedAt.UTC(), Payload: snapshot.Payload,
KeepForSeconds: snapshot.KeepForSeconds, MaxRecords: snapshot.MaxRecords,
}
var response AcceptedSnapshot
if err := client.postJSON(ctx, snapshotPath, http.StatusAccepted, request, &response); err != nil {
return AcceptedSnapshot{}, err
}
return response, nil
}
func (client *Client) currentSession() (string, error) {
client.mu.Lock()
defer client.mu.Unlock()
if client.sessionToken == "" || client.sessionExpiresAt.IsZero() || !client.now().Before(client.sessionExpiresAt) {
return "", errors.New("component session is unavailable or expired")
}
return client.sessionToken, nil
}
func (client *Client) nextHeartbeat() (string, time.Time, uint64, error) {
client.mu.Lock()
defer client.mu.Unlock()
if client.sessionToken == "" || client.sessionExpiresAt.IsZero() || !client.now().Before(client.sessionExpiresAt) {
return "", time.Time{}, 0, errors.New("component session is unavailable or expired")
}
client.heartbeatSequence++
return client.sessionToken, client.sessionExpiresAt, client.heartbeatSequence, nil
}
func (client *Client) reserveSnapshotSequence(snapshot Snapshot) error {
client.mu.Lock()
defer client.mu.Unlock()
key := snapshot.Type + "\x00" + snapshot.StreamKey
if snapshot.Sequence <= client.snapshotSequences[key] {
return fmt.Errorf("snapshot sequence must increase for its typed stream")
}
client.snapshotSequences[key] = snapshot.Sequence
return nil
}
func (client *Client) postJSON(ctx context.Context, path string, expectedStatus int, payload any, target any) error {
encoded, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("encode platform request: %w", err)
}
if len(encoded) > maxRequestBytes {
return fmt.Errorf("platform request exceeds the bounded payload size")
}
request, err := http.NewRequestWithContext(ctx, http.MethodPost, client.config.Platform.BaseURL+path, bytes.NewReader(encoded))
if err != nil {
return fmt.Errorf("create platform request: %w", err)
}
request.Header.Set("Accept", "application/json")
request.Header.Set("Content-Type", "application/json")
response, err := client.httpClient.Do(request)
if err != nil {
return fmt.Errorf("send platform request: %w", err)
}
defer response.Body.Close()
if response.StatusCode != expectedStatus {
_, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 4096))
return HTTPError{StatusCode: response.StatusCode, ExpectedStatus: expectedStatus}
}
decoder := json.NewDecoder(io.LimitReader(response.Body, maxResponseBytes))
if err := decoder.Decode(target); err != nil {
return fmt.Errorf("decode platform response: %w", err)
}
return nil
}
func registrationSignature(proof string, request registerRequest) string {
capabilities := append([]string(nil), request.Capabilities...)
sort.Strings(capabilities)
canonical := strings.Join([]string{
request.InstallationID,
request.ServerInstanceID,
request.ProfileKey,
request.ArtifactID,
request.Version,
request.SourceRevision,
request.TargetOS,
request.TargetArch,
strconv.Itoa(request.KeyGeneration),
strconv.Itoa(request.DeploymentGeneration),
request.Timestamp.UTC().Format(time.RFC3339Nano),
request.Nonce,
strings.Join(capabilities, ","),
}, "\n")
mac := hmac.New(sha256.New, []byte(proof))
_, _ = mac.Write([]byte(canonical))
return "sha256:" + hex.EncodeToString(mac.Sum(nil))
}
func randomNonce() (string, error) {
value := make([]byte, 24)
if _, err := rand.Read(value); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(value), nil
}
func validNonce(value string) bool {
if len(value) < 16 || len(value) > 128 {
return false
}
for _, character := range value {
if character >= 'A' && character <= 'Z' || character >= 'a' && character <= 'z' || character >= '0' && character <= '9' || character == '_' || character == '-' {
continue
}
return false
}
return true
}
type registerRequest struct {
InstallationID string `json:"installationId"`
ServerInstanceID string `json:"serverInstanceId"`
ProfileKey string `json:"profileKey"`
ArtifactID string `json:"artifactId"`
Version string `json:"version"`
SourceRevision string `json:"sourceRevision"`
TargetOS string `json:"targetOs"`
TargetArch string `json:"targetArch"`
KeyGeneration int `json:"keyGeneration"`
DeploymentGeneration int `json:"deploymentGeneration"`
Capabilities []string `json:"capabilities"`
Timestamp time.Time `json:"timestamp"`
Nonce string `json:"nonce"`
Signature string `json:"signature"`
}
type registerResponse struct {
Accepted bool `json:"accepted"`
InstallationID string `json:"installationId"`
SessionToken string `json:"sessionToken"`
ExpiresAt time.Time `json:"expiresAt"`
HeartbeatEverySeconds int `json:"heartbeatEverySeconds"`
ServerTime time.Time `json:"serverTime"`
}
type heartbeatRequest struct {
InstallationID string `json:"installationId"`
SessionToken string `json:"sessionToken"`
Sequence uint64 `json:"sequence"`
Health string `json:"health"`
HealthReason string `json:"healthReason,omitempty"`
Capabilities []string `json:"capabilities"`
SentAt time.Time `json:"sentAt"`
}
type heartbeatResponse struct {
Accepted bool `json:"accepted"`
InstallationID string `json:"installationId"`
Status string `json:"status"`
Health string `json:"health"`
NextHeartbeatSeconds int `json:"nextHeartbeatSeconds"`
SessionExpiresAt time.Time `json:"sessionExpiresAt"`
ServerTime time.Time `json:"serverTime"`
}
type claimRequest struct {
SessionToken string `json:"sessionToken"`
Limit int `json:"limit,omitempty"`
}
type claimResponse struct {
Items []ClaimedCommand `json:"items"`
Count int `json:"count"`
}
type ackRequest struct {
SessionToken string `json:"sessionToken"`
FencingToken uint64 `json:"fencingToken"`
}
type resultRequest struct {
SessionToken string `json:"sessionToken"`
FencingToken uint64 `json:"fencingToken"`
Status string `json:"status"`
Summary string `json:"summary,omitempty"`
Payload map[string]any `json:"payload,omitempty"`
}
type snapshotRequest struct {
SessionToken string `json:"sessionToken"`
Type string `json:"type"`
SchemaVersion string `json:"schemaVersion"`
StreamKey string `json:"streamKey"`
Sequence uint64 `json:"sequence"`
ObservedAt time.Time `json:"observedAt"`
Payload map[string]any `json:"payload"`
KeepForSeconds int `json:"keepForSeconds"`
MaxRecords int `json:"maxRecords,omitempty"`
}
@@ -1,519 +0,0 @@
package companion
import (
"context"
"crypto/hmac"
"crypto/sha256"
"crypto/tls"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"sort"
"strconv"
"strings"
"testing"
"time"
)
const testProof = "fixture-component-proof-material"
type roundTripFunc func(*http.Request) (*http.Response, error)
func (fn roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) {
return fn(request)
}
func TestLoadConfigKeepsProofOutOfConfig(t *testing.T) {
file, err := os.Open("config.yaml.example")
if err != nil {
t.Fatalf("open config fixture: %v", err)
}
defer file.Close()
config, err := LoadConfig(file)
if err != nil {
t.Fatalf("load config fixture: %v", err)
}
encoded, err := json.Marshal(config)
if err != nil {
t.Fatalf("marshal config fixture: %v", err)
}
serialized := string(encoded)
for _, forbidden := range []string{testProof, "scum_client_credential", "sessionToken", "/api/v1/scum-clients/", "InsecureSkipVerify"} {
if strings.Contains(serialized, forbidden) {
t.Fatalf("config exposed forbidden content %q: %s", forbidden, serialized)
}
}
t.Setenv(ProofEnvironment, "")
if _, err := NewClient(config, Options{}); err == nil {
t.Fatal("expected missing proof environment to be rejected")
}
t.Setenv(ProofEnvironment, testProof)
if _, err := NewClient(config, Options{}); err != nil {
t.Fatalf("create client from proof environment: %v", err)
}
}
func TestLoadConfigRejectsLegacySharedCredentialFields(t *testing.T) {
fixture, err := os.ReadFile("config.yaml.example")
if err != nil {
t.Fatalf("read config fixture: %v", err)
}
for name, legacy := range map[string]string{
"legacy endpoint": "server_url: https://legacy.example.test\n",
"shared credential": "scum_client_credential: legacy-shared-value\n",
} {
t.Run(name, func(t *testing.T) {
if _, err := LoadConfig(strings.NewReader(string(fixture) + legacy)); err == nil {
t.Fatalf("expected legacy config field to be rejected: %s", legacy)
}
})
}
}
func TestNewClientRejectsHTTPPlatformBaseURL(t *testing.T) {
config := loadTestConfig(t)
config.Platform.BaseURL = "http://platform.example.test"
t.Setenv(ProofEnvironment, testProof)
if _, err := NewClient(config, Options{}); err == nil || !strings.Contains(err.Error(), "HTTPS") {
t.Fatalf("expected HTTP platform origin to be rejected, got %v", err)
}
}
func TestNewClientCanonicalizesPlatformOriginAndRejectsInvalidPorts(t *testing.T) {
t.Setenv(ProofEnvironment, testProof)
config := loadTestConfig(t)
config.Platform.BaseURL = " https://platform.example.test:8443/ "
client, err := NewClient(config, Options{})
if err != nil {
t.Fatalf("create client: %v", err)
}
if client.config.Platform.BaseURL != "https://platform.example.test:8443" {
t.Fatalf("platform origin was not canonicalized: %q", client.config.Platform.BaseURL)
}
for _, baseURL := range []string{"https://:443", "https://platform.example.test:", "https://platform.example.test:65536"} {
config.Platform.BaseURL = baseURL
if _, err := NewClient(config, Options{}); err == nil {
t.Fatalf("expected invalid platform origin to be rejected: %q", baseURL)
}
}
}
func TestNewClientUsesBoundedConfiguredRequestTimeout(t *testing.T) {
config := loadTestConfig(t)
config.Timing.RequestTimeoutSeconds = 7
t.Setenv(ProofEnvironment, testProof)
client, err := NewClient(config, Options{})
if err != nil {
t.Fatalf("create client: %v", err)
}
if client.httpClient.Timeout != 7*time.Second {
t.Fatalf("unexpected request timeout: %s", client.httpClient.Timeout)
}
}
func TestNewClientDefaultTransportVerifiesSystemRootsWithTLS12Minimum(t *testing.T) {
config := loadTestConfig(t)
t.Setenv(ProofEnvironment, testProof)
client, err := NewClient(config, Options{})
if err != nil {
t.Fatalf("create client: %v", err)
}
transport, ok := client.httpClient.Transport.(*http.Transport)
if !ok {
t.Fatalf("unexpected transport type: %T", client.httpClient.Transport)
}
if transport == http.DefaultTransport {
t.Fatal("client must own its transport")
}
if transport.TLSClientConfig == nil {
t.Fatal("client transport must declare an explicit TLS policy")
}
if transport.TLSClientConfig.InsecureSkipVerify {
t.Fatal("client transport must verify server certificates")
}
if transport.TLSClientConfig.MinVersion < tls.VersionTLS12 {
t.Fatalf("client transport allows TLS below 1.2: %x", transport.TLSClientConfig.MinVersion)
}
if transport.TLSClientConfig.RootCAs != nil {
t.Fatal("nil RootCAs must select the host system root pool")
}
if client.httpClient.CheckRedirect == nil {
t.Fatal("client must explicitly reject redirects")
}
}
func TestNewClientRejectsUntrustedTLSServerByDefault(t *testing.T) {
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{}`)
}))
defer server.Close()
config := loadTestConfig(t)
config.Platform.BaseURL = server.URL
t.Setenv(ProofEnvironment, testProof)
client, err := NewClient(config, Options{
Now: func() time.Time { return time.Date(2026, 7, 20, 9, 0, 0, 0, time.UTC) },
Nonce: func() (string, error) { return "nonce-fixture-untrusted-tls", nil },
})
if err != nil {
t.Fatalf("create client: %v", err)
}
_, err = client.Register(context.Background())
var verificationError *tls.CertificateVerificationError
if !errors.As(err, &verificationError) {
t.Fatalf("expected untrusted TLS certificate verification failure, got %v", err)
}
}
func TestClientRejectsRedirectWithoutReplayingComponentSession(t *testing.T) {
stamp := time.Date(2026, 7, 20, 9, 30, 0, 0, time.UTC)
config := loadTestConfig(t)
requests := 0
transport := roundTripFunc(func(request *http.Request) (*http.Response, error) {
requests++
switch requests {
case 1:
assertPlatformRequest(t, request, registerPath)
return jsonHTTPResponse(http.StatusOK, registerResponse{
Accepted: true, InstallationID: config.Component.InstallationID, SessionToken: "component-session-one",
ExpiresAt: stamp.Add(15 * time.Minute), HeartbeatEverySeconds: 30, ServerTime: stamp,
}), nil
case 2:
assertPlatformRequest(t, request, claimPath)
var body claimRequest
decodeRequest(t, request, &body)
if body.SessionToken != "component-session-one" {
t.Fatalf("claim did not carry the component session: %+v", body)
}
response := jsonHTTPResponse(http.StatusTemporaryRedirect, nil)
response.Header.Set("Location", "https://redirect.example.test/capture")
return response, nil
default:
t.Fatalf("redirect replayed component material to %s", request.URL.String())
return nil, fmt.Errorf("unexpected redirected request")
}
})
client := newTestClient(t, config, transport, stamp)
if _, err := client.Register(context.Background()); err != nil {
t.Fatalf("register client: %v", err)
}
_, err := client.ClaimCommands(context.Background(), 1)
var statusError HTTPError
if !errors.As(err, &statusError) || statusError.StatusCode != http.StatusTemporaryRedirect {
t.Fatalf("expected redirect response to be rejected, got %v", err)
}
if requests != 2 {
t.Fatalf("redirect unexpectedly triggered %d requests", requests)
}
}
func TestRegisterUsesExactCanonicalHMAC(t *testing.T) {
stamp := time.Date(2026, 7, 20, 5, 4, 3, 123456789, time.UTC)
config := loadTestConfig(t)
for left, right := 0, len(config.Capabilities)-1; left < right; left, right = left+1, right-1 {
config.Capabilities[left], config.Capabilities[right] = config.Capabilities[right], config.Capabilities[left]
}
var captured registerRequest
transport := roundTripFunc(func(request *http.Request) (*http.Response, error) {
assertPlatformRequest(t, request, registerPath)
decodeRequest(t, request, &captured)
if strings.Contains(captured.Signature, testProof) {
t.Fatal("registration signature contains raw proof")
}
return jsonHTTPResponse(http.StatusOK, registerResponse{
Accepted: true, InstallationID: config.Component.InstallationID, SessionToken: "component-session-one",
ExpiresAt: stamp.Add(15 * time.Minute), HeartbeatEverySeconds: 30, ServerTime: stamp,
}), nil
})
client := newTestClient(t, config, transport, stamp)
registration, err := client.Register(context.Background())
if err != nil {
t.Fatalf("register client: %v", err)
}
if registration.HeartbeatEverySeconds != 30 || !registration.ExpiresAt.Equal(stamp.Add(15*time.Minute)) {
t.Fatalf("unexpected registration projection: %+v", registration)
}
if captured.Timestamp.Format(time.RFC3339Nano) != "2026-07-20T05:04:03.123456789Z" {
t.Fatalf("registration timestamp was not RFC3339Nano UTC: %s", captured.Timestamp.Format(time.RFC3339Nano))
}
expected := independentRegistrationSignature(testProof, captured)
if captured.Signature != expected {
t.Fatalf("unexpected registration signature: got %s want %s", captured.Signature, expected)
}
if captured.Nonce != "nonce-fixture-registration-0001" {
t.Fatalf("unexpected registration nonce: %s", captured.Nonce)
}
}
func TestSnapshotSequenceContinuesAcrossComponentSessions(t *testing.T) {
stamp := time.Date(2026, 7, 20, 7, 0, 0, 0, time.UTC)
config := loadTestConfig(t)
step := 0
transport := roundTripFunc(func(request *http.Request) (*http.Response, error) {
switch step {
case 0, 2:
assertPlatformRequest(t, request, registerPath)
step++
session := "component-session-one"
if step == 3 {
session = "component-session-two"
}
return jsonHTTPResponse(http.StatusOK, registerResponse{Accepted: true, InstallationID: config.Component.InstallationID, SessionToken: session, ExpiresAt: stamp.Add(15 * time.Minute), HeartbeatEverySeconds: 30, ServerTime: stamp}), nil
case 1, 3:
assertPlatformRequest(t, request, snapshotPath)
var body snapshotRequest
decodeRequest(t, request, &body)
expectedSession := "component-session-one"
expectedSequence := uint64(10)
if step == 3 {
expectedSession = "component-session-two"
expectedSequence = 11
}
if body.SessionToken != expectedSession || body.Sequence != expectedSequence {
t.Fatalf("snapshot did not continue across component sessions: %+v", body)
}
step++
return jsonHTTPResponse(http.StatusAccepted, AcceptedSnapshot{SnapshotID: fmt.Sprintf("snapshot-%d", body.Sequence), ProfileKey: ProfileKey, Type: body.Type, SchemaVersion: body.SchemaVersion, StreamKey: body.StreamKey, Sequence: body.Sequence, AcceptedAt: stamp, ExpiresAt: stamp.Add(7 * 24 * time.Hour)}), nil
default:
return nil, fmt.Errorf("unexpected request %s", request.URL.Path)
}
})
t.Setenv(ProofEnvironment, testProof)
nonce := 0
client, err := NewClient(config, Options{
Now: func() time.Time { return stamp },
Nonce: func() (string, error) {
nonce++
return fmt.Sprintf("nonce-fixture-session-%04d", nonce), nil
},
})
if err != nil {
t.Fatalf("create client: %v", err)
}
client.httpClient.Transport = transport
upload := func(sequence uint64) error {
_, err := client.UploadSnapshot(context.Background(), Snapshot{Type: "companion.health", SchemaVersion: "1", StreamKey: "current", Sequence: sequence, ObservedAt: stamp, Payload: map[string]any{"status": "online", "observedAt": stamp.Format(time.RFC3339)}, KeepForSeconds: 604800, MaxRecords: 1000})
return err
}
if _, err := client.Register(context.Background()); err != nil {
t.Fatalf("register first session: %v", err)
}
if err := upload(10); err != nil {
t.Fatalf("upload first session snapshot: %v", err)
}
if _, err := client.Register(context.Background()); err != nil {
t.Fatalf("register replacement session: %v", err)
}
if err := upload(10); err == nil || !strings.Contains(err.Error(), "sequence") {
t.Fatalf("expected stale sequence to be rejected before transport, got %v", err)
}
if err := upload(11); err != nil {
t.Fatalf("upload replacement session snapshot: %v", err)
}
if step != 4 {
t.Fatalf("expected four transport requests, got %d", step)
}
}
func TestSnapshotRequiresHTTPAccepted(t *testing.T) {
stamp := time.Date(2026, 7, 20, 8, 0, 0, 0, time.UTC)
config := loadTestConfig(t)
step := 0
transport := roundTripFunc(func(request *http.Request) (*http.Response, error) {
step++
if step == 1 {
return jsonHTTPResponse(http.StatusOK, registerResponse{Accepted: true, InstallationID: config.Component.InstallationID, SessionToken: "component-session-one", ExpiresAt: stamp.Add(15 * time.Minute), HeartbeatEverySeconds: 30, ServerTime: stamp}), nil
}
return jsonHTTPResponse(http.StatusOK, AcceptedSnapshot{}), nil
})
client := newTestClient(t, config, transport, stamp)
if _, err := client.Register(context.Background()); err != nil {
t.Fatalf("register client: %v", err)
}
_, err := client.UploadSnapshot(context.Background(), Snapshot{Type: "companion.health", SchemaVersion: "1", StreamKey: "current", Sequence: 1, ObservedAt: stamp, Payload: map[string]any{"status": "online", "observedAt": stamp.Format(time.RFC3339)}, KeepForSeconds: 604800, MaxRecords: 1000})
var statusError HTTPError
if !errors.As(err, &statusError) || statusError.StatusCode != http.StatusOK || statusError.ExpectedStatus != http.StatusAccepted {
t.Fatalf("expected exact 202 enforcement, got %v", err)
}
}
func TestComponentSessionHeartbeatAndBridgeTransport(t *testing.T) {
stamp := time.Date(2026, 7, 20, 6, 0, 0, 0, time.UTC)
config := loadTestConfig(t)
sessionExpiry := stamp.Add(15 * time.Minute)
step := 0
transport := roundTripFunc(func(request *http.Request) (*http.Response, error) {
if strings.Contains(request.URL.Path, "/api/v1/scum-clients/") {
t.Fatalf("legacy endpoint used: %s", request.URL.Path)
}
switch step {
case 0:
assertPlatformRequest(t, request, registerPath)
var body registerRequest
decodeRequest(t, request, &body)
step++
return jsonHTTPResponse(http.StatusOK, registerResponse{Accepted: true, InstallationID: config.Component.InstallationID, SessionToken: "component-session-one", ExpiresAt: sessionExpiry, HeartbeatEverySeconds: 30, ServerTime: stamp}), nil
case 1:
assertPlatformRequest(t, request, heartbeatPath)
var body heartbeatRequest
decodeRequest(t, request, &body)
if body.SessionToken != "component-session-one" || body.Sequence != 1 || body.Health != "healthy" || body.HealthReason != "bridge ready" {
t.Fatalf("unexpected heartbeat body: %+v", body)
}
step++
return jsonHTTPResponse(http.StatusOK, heartbeatResponse{Accepted: true, InstallationID: config.Component.InstallationID, Status: "online", Health: "healthy", NextHeartbeatSeconds: 30, SessionExpiresAt: sessionExpiry, ServerTime: stamp}), nil
case 2:
assertPlatformRequest(t, request, claimPath)
var body claimRequest
decodeRequest(t, request, &body)
if body.SessionToken != "component-session-one" || body.Limit != 5 {
t.Fatalf("unexpected claim body: %+v", body)
}
step++
return jsonHTTPResponse(http.StatusOK, claimResponse{Items: []ClaimedCommand{{ID: "command-1", ProfileKey: ProfileKey, CommandType: "companion.diagnostics", Payload: map[string]any{"includeWindowState": true}, FencingToken: 9, LeaseExpiresAt: stamp.Add(time.Minute), ExpiresAt: stamp.Add(5 * time.Minute)}}, Count: 1}), nil
case 3:
assertPlatformRequest(t, request, "/api/v1/game-client-bridge/companion/commands/command-1/ack")
var body ackRequest
decodeRequest(t, request, &body)
if body.SessionToken != "component-session-one" || body.FencingToken != 9 {
t.Fatalf("unexpected ack body: %+v", body)
}
step++
return jsonHTTPResponse(http.StatusOK, CommandAck{CommandID: "command-1", State: "claimed", FencingToken: 9, AcknowledgedAt: stamp}), nil
case 4:
assertPlatformRequest(t, request, "/api/v1/game-client-bridge/companion/commands/command-1/result")
var body resultRequest
decodeRequest(t, request, &body)
if body.SessionToken != "component-session-one" || body.FencingToken != 9 || body.Status != "succeeded" || body.Summary != "diagnostics collected" || body.Payload["entries"] != float64(3) {
t.Fatalf("unexpected result body: %+v", body)
}
step++
return jsonHTTPResponse(http.StatusOK, map[string]any{"commandId": "command-1", "state": "succeeded", "result": map[string]any{"status": "succeeded", "summary": "diagnostics collected", "completedAt": stamp}, "updatedAt": stamp, "completedAt": stamp}), nil
case 5:
assertPlatformRequest(t, request, snapshotPath)
var body snapshotRequest
decodeRequest(t, request, &body)
if body.SessionToken != "component-session-one" || body.Type != "companion.health" || body.SchemaVersion != "1" || body.StreamKey != "current" || body.Sequence != 1 || body.KeepForSeconds != 604800 || body.MaxRecords != 1000 {
t.Fatalf("unexpected snapshot body: %+v", body)
}
step++
return jsonHTTPResponse(http.StatusAccepted, AcceptedSnapshot{SnapshotID: "snapshot-1", ProfileKey: ProfileKey, Type: body.Type, SchemaVersion: body.SchemaVersion, StreamKey: body.StreamKey, Sequence: body.Sequence, AcceptedAt: stamp, ExpiresAt: stamp.Add(7 * 24 * time.Hour)}), nil
default:
return nil, fmt.Errorf("unexpected request %s", request.URL.Path)
}
})
client := newTestClient(t, config, transport, stamp)
if _, err := client.Register(context.Background()); err != nil {
t.Fatalf("register client: %v", err)
}
if _, err := client.Heartbeat(context.Background(), HealthReport{Status: "healthy", Reason: "bridge ready"}); err != nil {
t.Fatalf("heartbeat: %v", err)
}
commands, err := client.ClaimCommands(context.Background(), 5)
if err != nil || len(commands) != 1 {
t.Fatalf("claim commands: commands=%+v err=%v", commands, err)
}
if _, err := client.AckCommand(context.Background(), commands[0].ID, commands[0].FencingToken); err != nil {
t.Fatalf("ack command: %v", err)
}
if _, err := client.CompleteCommand(context.Background(), commands[0].ID, commands[0].FencingToken, CommandResult{Status: "succeeded", Summary: "diagnostics collected", Payload: map[string]any{"entries": 3}}); err != nil {
t.Fatalf("complete command: %v", err)
}
snapshot := Snapshot{Type: "companion.health", SchemaVersion: "1", StreamKey: "current", Sequence: 1, ObservedAt: stamp, Payload: map[string]any{"status": "online", "observedAt": stamp.Format(time.RFC3339)}, KeepForSeconds: 604800, MaxRecords: 1000}
accepted, err := client.UploadSnapshot(context.Background(), snapshot)
if err != nil || accepted.SnapshotID != "snapshot-1" {
t.Fatalf("upload snapshot: accepted=%+v err=%v", accepted, err)
}
if step != 6 {
t.Fatalf("expected six fixed API requests, got %d", step)
}
}
func loadTestConfig(t *testing.T) Config {
t.Helper()
file, err := os.Open("config.yaml.example")
if err != nil {
t.Fatalf("open config fixture: %v", err)
}
defer file.Close()
config, err := LoadConfig(file)
if err != nil {
t.Fatalf("load config fixture: %v", err)
}
return config
}
func newTestClient(t *testing.T, config Config, transport http.RoundTripper, stamp time.Time) *Client {
t.Helper()
t.Setenv(ProofEnvironment, testProof)
client, err := NewClient(config, Options{
Now: func() time.Time { return stamp },
Nonce: func() (string, error) { return "nonce-fixture-registration-0001", nil },
})
if err != nil {
t.Fatalf("create test client: %v", err)
}
client.httpClient.Transport = transport
return client
}
func assertPlatformRequest(t *testing.T, request *http.Request, expectedPath string) {
t.Helper()
if request.Method != http.MethodPost || request.URL.Scheme != "https" || request.URL.Host != "platform.example.test" || request.URL.Path != expectedPath {
t.Fatalf("unexpected platform request: %s %s", request.Method, request.URL.String())
}
if request.Header.Get("Authorization") != "" {
t.Fatalf("component session must be carried in the typed JSON body, got Authorization header")
}
if request.Header.Get("Content-Type") != "application/json" || request.Header.Get("Accept") != "application/json" {
t.Fatalf("unexpected request headers: %+v", request.Header)
}
}
func decodeRequest(t *testing.T, request *http.Request, target any) {
t.Helper()
decoder := json.NewDecoder(request.Body)
if err := decoder.Decode(target); err != nil {
t.Fatalf("decode request body: %v", err)
}
}
func jsonHTTPResponse(status int, payload any) *http.Response {
encoded, _ := json.Marshal(payload)
return &http.Response{StatusCode: status, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(string(encoded)))}
}
func independentRegistrationSignature(proof string, request registerRequest) string {
capabilities := append([]string(nil), request.Capabilities...)
sort.Strings(capabilities)
canonical := strings.Join([]string{
request.InstallationID,
request.ServerInstanceID,
request.ProfileKey,
request.ArtifactID,
request.Version,
request.SourceRevision,
request.TargetOS,
request.TargetArch,
strconv.Itoa(request.KeyGeneration),
strconv.Itoa(request.DeploymentGeneration),
request.Timestamp.UTC().Format(time.RFC3339Nano),
request.Nonce,
strings.Join(capabilities, ","),
}, "\n")
mac := hmac.New(sha256.New, []byte(proof))
_, _ = mac.Write([]byte(canonical))
return "sha256:" + hex.EncodeToString(mac.Sum(nil))
}
@@ -1,51 +0,0 @@
package main
import (
"context"
"encoding/json"
"os"
"time"
companion "browser.local/plugins/scum-server-plugin/companion"
)
func main() {
result := companion.SmokeResult{CompletedCommandIDs: []string{}, UnsupportedCommandIDs: []string{}}
if len(os.Args) != 1 {
writeResult(result)
os.Exit(1)
}
file, err := os.Open("config.yaml")
if err != nil {
writeResult(result)
os.Exit(1)
}
config, err := companion.LoadConfig(file)
closeErr := file.Close()
if err != nil || closeErr != nil {
writeResult(result)
os.Exit(1)
}
client, err := companion.NewClient(config, companion.Options{})
if err != nil {
writeResult(result)
os.Exit(1)
}
_ = os.Unsetenv(companion.ProofEnvironment)
isolated := os.Getenv(companion.SmokeIsolationEnvironment) == companion.SmokeIsolationValue
requestBudget := time.Duration(config.Timing.RequestTimeoutSeconds) * time.Second * time.Duration(2*companion.SmokeClaimLimit+4)
if requestBudget > 45*time.Second {
requestBudget = 45 * time.Second
}
ctx, cancel := context.WithTimeout(context.Background(), requestBudget)
defer cancel()
result, err = companion.RunOneShotSmoke(ctx, client, companion.SmokeOptions{IsolatedNonProduction: isolated})
writeResult(result)
if err != nil {
os.Exit(1)
}
}
func writeResult(result companion.SmokeResult) {
_ = json.NewEncoder(os.Stdout).Encode(result)
}
@@ -1,72 +0,0 @@
package main
import (
"context"
"errors"
"log"
"os"
"os/signal"
"syscall"
"time"
companion "browser.local/plugins/scum-server-plugin/companion"
)
func main() {
log.SetFlags(log.Ldate | log.Ltime | log.LUTC)
config, err := loadConfig()
if err != nil {
log.Printf("SCUM companion configuration failed: %v", err)
os.Exit(1)
}
client, err := companion.NewClient(config, companion.Options{})
if err != nil {
log.Printf("SCUM companion client failed: %v", err)
os.Exit(1)
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
sequenceStore, err := companion.NewFileSnapshotSequenceStore(companion.SnapshotSequenceFilename)
if err != nil {
log.Printf("SCUM companion sequence state failed: %v", err)
os.Exit(1)
}
projection := companion.NewSCUMPlayerLogProjection(client, config.Component.ServerInstanceID)
projection.SequenceStore = sequenceStore
collector := companion.NewConsoleLogCollector(client, noopLogStore{}, config.Component.ServerInstanceID, os.Getenv(config.Proof.MaterialEnv))
collector.OnSemanticEvents = projection.Handle
runtime := companion.Runtime{
Client: client,
Dispatcher: companion.Dispatcher{Client: client, Registry: companion.NewHandlerRegistry(companion.HandlerAvailability{BoundServerID: config.Component.ServerInstanceID, Approved: true, Capabilities: map[string]bool{"companion.diagnostics": true}}, companion.RuntimeAdapter{BoundServerID: config.Component.ServerInstanceID}), PollLimit: 10, Backoff: 2 * time.Second},
HeartbeatEvery: time.Duration(config.Timing.HeartbeatIntervalSeconds) * time.Second,
PollEvery: time.Duration(config.Timing.CommandPollIntervalSeconds) * time.Second,
Backoff: 2 * time.Second,
}
errorsCh := make(chan error, 2)
go func() { errorsCh <- runtime.Run(ctx) }()
go func() { errorsCh <- collector.Run(ctx) }()
if err := <-errorsCh; err != nil && !errors.Is(err, context.Canceled) {
log.Printf("SCUM companion stopped: %v", err)
os.Exit(1)
}
}
func loadConfig() (companion.Config, error) {
file, err := os.Open("config.yaml")
if err != nil {
return companion.Config{}, err
}
defer file.Close()
return companion.LoadConfig(file)
}
type noopLogStore struct{}
func (noopLogStore) EnsureSchema(context.Context) error { return nil }
func (noopLogStore) StoreConsoleRecords(context.Context, []companion.ConsoleRecord) (int, error) {
return 0, nil
}
func (noopLogStore) StoreSemanticEventBatch(_ context.Context, batch companion.SemanticEventBatch) (int, error) {
return len(batch.Events), nil
}
@@ -1,179 +0,0 @@
package companion
import (
"fmt"
"io"
"net/url"
"strconv"
"strings"
"gopkg.in/yaml.v3"
)
const (
ConfigSchemaVersion = 1
PluginID = "game.scum"
ProfileKey = "scum-client-manager"
ProofEnvironment = "SCUM_COMPONENT_PROOF"
)
var requiredCapabilities = []string{
"component.register",
"component.heartbeat",
"component.health",
"component.control",
"game-client.bridge",
"logs.stream",
}
var optionalCapabilities = map[string]struct{}{
"handler.vehicle.spawn": {},
}
var requiredCapabilitySet = map[string]struct{}{
"component.register": {}, "component.heartbeat": {}, "component.health": {}, "component.control": {}, "game-client.bridge": {}, "logs.stream": {},
}
type Config struct {
SchemaVersion int `json:"schemaVersion" yaml:"schemaVersion"`
Platform PlatformConfig `json:"platform" yaml:"platform"`
Component ComponentConfig `json:"component" yaml:"component"`
Proof ProofConfig `json:"proof" yaml:"proof"`
Session SessionConfig `json:"session" yaml:"session"`
Capabilities []string `json:"capabilities" yaml:"capabilities"`
Timing TimingConfig `json:"timing" yaml:"timing"`
TLS TransportTLSConfig `json:"tls" yaml:"tls"`
}
type PlatformConfig struct {
BaseURL string `json:"baseUrl" yaml:"baseUrl"`
}
type ComponentConfig struct {
InstallationID string `json:"installationId" yaml:"installationId"`
ServerInstanceID string `json:"serverInstanceId" yaml:"serverInstanceId"`
PluginID string `json:"pluginId" yaml:"pluginId"`
ProfileKey string `json:"profileKey" yaml:"profileKey"`
ArtifactID string `json:"artifactId" yaml:"artifactId"`
Version string `json:"version" yaml:"version"`
SourceRevision string `json:"sourceRevision" yaml:"sourceRevision"`
TargetOS string `json:"targetOs" yaml:"targetOs"`
TargetArch string `json:"targetArch" yaml:"targetArch"`
KeyGeneration int `json:"keyGeneration" yaml:"keyGeneration"`
DeploymentGeneration int `json:"deploymentGeneration" yaml:"deploymentGeneration"`
}
type ProofConfig struct {
Mode string `json:"mode" yaml:"mode"`
MaterialEnv string `json:"materialEnv" yaml:"materialEnv"`
}
type SessionConfig struct {
Mode string `json:"mode" yaml:"mode"`
}
type TimingConfig struct {
HeartbeatIntervalSeconds int `json:"heartbeatIntervalSeconds" yaml:"heartbeatIntervalSeconds"`
CommandPollIntervalSeconds int `json:"commandPollIntervalSeconds" yaml:"commandPollIntervalSeconds"`
RequestTimeoutSeconds int `json:"requestTimeoutSeconds" yaml:"requestTimeoutSeconds"`
}
type TransportTLSConfig struct {
Policy string `json:"policy" yaml:"policy"`
}
func LoadConfig(reader io.Reader) (Config, error) {
decoder := yaml.NewDecoder(reader)
decoder.KnownFields(true)
var config Config
if err := decoder.Decode(&config); err != nil {
return Config{}, fmt.Errorf("decode companion config: %w", err)
}
var trailing any
if err := decoder.Decode(&trailing); err != io.EOF {
if err == nil {
return Config{}, fmt.Errorf("decode companion config: multiple documents are not allowed")
}
return Config{}, fmt.Errorf("decode companion config: %w", err)
}
if err := config.Validate(); err != nil {
return Config{}, err
}
config.Platform.BaseURL, _ = canonicalPlatformOrigin(config.Platform.BaseURL)
config.Capabilities = append([]string(nil), config.Capabilities...)
return config, nil
}
func (config Config) Validate() error {
if config.SchemaVersion != ConfigSchemaVersion {
return fmt.Errorf("companion config schema version is unsupported")
}
if _, err := canonicalPlatformOrigin(config.Platform.BaseURL); err != nil {
return err
}
component := config.Component
if component.InstallationID == "" || component.ServerInstanceID == "" || component.ArtifactID == "" || component.Version == "" || component.SourceRevision == "" {
return fmt.Errorf("component identity is incomplete")
}
if component.PluginID != PluginID || component.ProfileKey != ProfileKey || component.TargetOS != "windows" || component.TargetArch != "amd64" {
return fmt.Errorf("component identity does not match the SCUM companion profile")
}
if component.KeyGeneration <= 0 || component.DeploymentGeneration <= 0 {
return fmt.Errorf("component generations must be positive")
}
if config.Proof.Mode != "hmac-sha256" || config.Proof.MaterialEnv != ProofEnvironment {
return fmt.Errorf("component proof policy is unsupported")
}
if config.Session.Mode != "component-session" {
return fmt.Errorf("component session policy is unsupported")
}
if config.TLS.Policy != "verify-system-roots" {
return fmt.Errorf("TLS policy must verify system roots")
}
if err := validateCapabilities(config.Capabilities); err != nil {
return err
}
if config.Timing.HeartbeatIntervalSeconds < 5 || config.Timing.HeartbeatIntervalSeconds > 300 || config.Timing.CommandPollIntervalSeconds < 1 || config.Timing.CommandPollIntervalSeconds > 60 || config.Timing.RequestTimeoutSeconds < 1 || config.Timing.RequestTimeoutSeconds > 60 {
return fmt.Errorf("companion timing policy is invalid")
}
return nil
}
func canonicalPlatformOrigin(value string) (string, error) {
parsed, err := url.Parse(strings.TrimSpace(value))
if err != nil || parsed.Scheme != "https" || parsed.Host == "" || parsed.Hostname() == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" || parsed.Path != "" && parsed.Path != "/" {
return "", fmt.Errorf("platform base URL must be a credential-free HTTPS origin")
}
port := parsed.Port()
if strings.HasSuffix(parsed.Host, ":") || port != "" {
value, err := strconv.Atoi(port)
if err != nil || value < 1 || value > 65535 {
return "", fmt.Errorf("platform base URL must use a valid HTTPS port")
}
}
return (&url.URL{Scheme: parsed.Scheme, Host: parsed.Host}).String(), nil
}
func validateCapabilities(capabilities []string) error {
if len(capabilities) < len(requiredCapabilities) || len(capabilities) > len(requiredCapabilities)+len(optionalCapabilities) {
return fmt.Errorf("component capabilities do not match the SCUM companion profile")
}
actual := make(map[string]struct{}, len(capabilities))
for _, capability := range capabilities {
if _, exists := actual[capability]; exists {
return fmt.Errorf("component capabilities must be unique")
}
actual[capability] = struct{}{}
}
for _, capability := range requiredCapabilities {
if _, exists := actual[capability]; !exists {
return fmt.Errorf("component capabilities do not match the SCUM companion profile")
}
}
for capability := range actual {
if _, required := requiredCapabilitySet[capability]; !required {
if _, optional := optionalCapabilities[capability]; !optional {
return fmt.Errorf("component capabilities do not match the SCUM companion profile")
}
}
}
return nil
}
@@ -1,33 +0,0 @@
schemaVersion: 1
platform:
baseUrl: https://platform.example.test
component:
installationId: client-manager-installation-example
serverInstanceId: server-example
pluginId: game.scum
profileKey: scum-client-manager
artifactId: artifact-example
version: 1.0.0
sourceRevision: example-revision
targetOs: windows
targetArch: amd64
keyGeneration: 1
deploymentGeneration: 1
proof:
mode: hmac-sha256
materialEnv: SCUM_COMPONENT_PROOF
session:
mode: component-session
capabilities:
- component.register
- component.heartbeat
- component.health
- component.control
- game-client.bridge
- logs.stream
timing:
heartbeatIntervalSeconds: 30
commandPollIntervalSeconds: 5
requestTimeoutSeconds: 15
tls:
policy: verify-system-roots
@@ -1,16 +0,0 @@
package companion
import "testing"
func TestCompanionVehicleHandlerCapabilityIsExplicitAndBounded(t *testing.T) {
base := append([]string(nil), requiredCapabilities...)
if err := validateCapabilities(base); err != nil {
t.Fatalf("base companion profile must remain valid: %v", err)
}
if err := validateCapabilities(append(base, "handler.vehicle.spawn")); err != nil {
t.Fatalf("explicit vehicle handler declaration must be valid: %v", err)
}
if err := validateCapabilities(append(base, "handler.raw.rcon")); err == nil {
t.Fatal("undeclared raw command handler capability must be rejected")
}
}
@@ -1,118 +0,0 @@
package companion
import (
"context"
"fmt"
"strings"
"time"
)
type ConsoleLogStreamClient interface {
StreamLogEvents(context.Context, func(LogStreamEvent) error) error
}
type ConsoleLogStore interface {
EnsureSchema(context.Context) error
StoreConsoleRecords(context.Context, []ConsoleRecord) (int, error)
StoreSemanticEventBatch(context.Context, SemanticEventBatch) (int, error)
}
type ConsoleLogCollector struct {
Client ConsoleLogStreamClient
Store ConsoleLogStore
ServerInstanceID string
CorrelationSecret string
Backoff time.Duration
OnSemanticEvents func(context.Context, SemanticEventBatch) error
}
func NewConsoleLogCollector(client ConsoleLogStreamClient, store ConsoleLogStore, serverInstanceID string, correlationSecret string) *ConsoleLogCollector {
return &ConsoleLogCollector{Client: client, Store: store, ServerInstanceID: serverInstanceID, CorrelationSecret: correlationSecret, Backoff: 2 * time.Second}
}
func (collector *ConsoleLogCollector) Run(ctx context.Context) error {
if collector == nil || collector.Client == nil || collector.Store == nil || strings.TrimSpace(collector.ServerInstanceID) == "" {
return fmt.Errorf("console log collector is not configured")
}
if err := collector.Store.EnsureSchema(ctx); err != nil {
return err
}
backoff := collector.Backoff
if backoff <= 0 {
backoff = 2 * time.Second
}
for {
err := collector.Client.StreamLogEvents(ctx, collector.handleEvent(ctx))
if ctx.Err() != nil {
return ctx.Err()
}
if err != nil {
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(backoff):
}
continue
}
}
}
func (collector *ConsoleLogCollector) handleEvent(ctx context.Context) func(LogStreamEvent) error {
return func(event LogStreamEvent) error {
record, ok := consoleRecordFromLogEvent(collector.ServerInstanceID, event)
if !ok {
return nil
}
if _, err := collector.Store.StoreConsoleRecords(ctx, []ConsoleRecord{record}); err != nil {
return err
}
batch := ParseConsoleRecords(collector.ServerInstanceID, []ConsoleRecord{record}, collector.CorrelationSecret)
if _, err := collector.Store.StoreSemanticEventBatch(ctx, batch); err != nil {
return err
}
if collector.OnSemanticEvents != nil {
if err := collector.OnSemanticEvents(ctx, batch); err != nil {
return err
}
}
return nil
}
}
func consoleRecordFromLogEvent(serverInstanceID string, event LogStreamEvent) (ConsoleRecord, bool) {
if event.ServerInstanceID != serverInstanceID || event.Entry.Seq == 0 || strings.TrimSpace(event.Entry.Line) == "" {
return ConsoleRecord{}, false
}
stream := consoleStreamName(event.StreamKey)
if stream == "" {
if !knownPluginLogStream(event.StreamKey) {
return ConsoleRecord{}, false
}
stream = strings.ToLower(strings.TrimSpace(event.StreamKey))
}
occurredAt := event.Entry.Timestamp
if occurredAt.IsZero() {
occurredAt = time.Now().UTC()
}
return ConsoleRecord{ServerID: serverInstanceID, Stream: stream, Sequence: event.Entry.Seq, OccurredAt: occurredAt.UTC(), Text: event.Entry.Line}, true
}
func knownPluginLogStream(streamKey string) bool {
switch strings.ToLower(strings.TrimSpace(streamKey)) {
case "scum.login", "scum.chat", "scum.server", "scum.kill", "scum.trade", "scum.admin", "scum.performance":
return true
default:
return false
}
}
func consoleStreamName(streamKey string) string {
key := strings.ToLower(strings.TrimSpace(streamKey))
if strings.Contains(key, "stderr") || strings.HasSuffix(key, ".err") || strings.HasSuffix(key, "-err") {
return "stderr"
}
if strings.Contains(key, "stdout") || strings.Contains(key, "console") || strings.HasSuffix(key, ".out") || strings.HasSuffix(key, "-out") {
return "stdout"
}
return ""
}
@@ -1,408 +0,0 @@
package companion
import (
"context"
"errors"
"fmt"
"strings"
"sync"
"time"
)
// SafeAdapter is intentionally narrow: it receives typed values only and has
// no direct transport, host-path, credential, or shell access. Protected SQL,
// RCON, and management-program text is forwarded to Run by Platform, not here.
type SafeAdapter interface {
ReadConfiguration(context.Context) (map[string]any, error)
PatchConfiguration(context.Context, map[string]any) (map[string]any, error)
Diagnostics(context.Context) (map[string]any, error)
PatchGameState(context.Context, map[string]any) (map[string]any, error)
DeliverReward(context.Context, map[string]any) (map[string]any, error)
StartEvent(context.Context, map[string]any) (map[string]any, error)
NotifyPlayer(context.Context, map[string]any) (map[string]any, error)
SpawnVehicle(context.Context, map[string]any) (map[string]any, error)
}
// ServerBoundAdapter lets a runtime adapter prove that it is configured for
// the same server as the registration which declared handler availability.
// Generic test adapters do not need this optional assertion.
type ServerBoundAdapter interface{ ServerBinding() string }
type HandlerAvailability struct {
BoundServerID string
Capabilities map[string]bool
Approved bool
}
type CommandHandler func(context.Context, map[string]any) (map[string]any, error)
type HandlerRegistry struct {
availability HandlerAvailability
handlers map[string]CommandHandler
adapterServer string
mu sync.Mutex
completed map[string]CommandResult
}
func NewHandlerRegistry(availability HandlerAvailability, adapter SafeAdapter) *HandlerRegistry {
registry := &HandlerRegistry{availability: availability, handlers: map[string]CommandHandler{}, completed: map[string]CommandResult{}}
if adapter == nil {
return registry
}
if bound, ok := adapter.(ServerBoundAdapter); ok {
registry.adapterServer = bound.ServerBinding()
}
registry.handlers["config.read"] = func(ctx context.Context, _ map[string]any) (map[string]any, error) {
return adapter.ReadConfiguration(ctx)
}
registry.handlers["config.patch"] = func(ctx context.Context, payload map[string]any) (map[string]any, error) {
return adapter.PatchConfiguration(ctx, payload)
}
registry.handlers["companion.diagnostics"] = func(ctx context.Context, _ map[string]any) (map[string]any, error) { return adapter.Diagnostics(ctx) }
registry.handlers["game-state.patch"] = func(ctx context.Context, payload map[string]any) (map[string]any, error) {
return adapter.PatchGameState(ctx, payload)
}
registry.handlers["reward.deliver"] = func(ctx context.Context, payload map[string]any) (map[string]any, error) {
return adapter.DeliverReward(ctx, payload)
}
registry.handlers["event.start"] = func(ctx context.Context, payload map[string]any) (map[string]any, error) {
return adapter.StartEvent(ctx, payload)
}
registry.handlers["player.notify"] = func(ctx context.Context, payload map[string]any) (map[string]any, error) {
return adapter.NotifyPlayer(ctx, payload)
}
registry.handlers["vehicle.spawn"] = func(ctx context.Context, payload map[string]any) (map[string]any, error) {
return adapter.SpawnVehicle(ctx, payload)
}
return registry
}
func (registry *HandlerRegistry) Execute(ctx context.Context, command ClaimedCommand) (CommandResult, error) {
registry.mu.Lock()
cached, done := registry.completed[command.ID]
registry.mu.Unlock()
if done {
return cached, nil
}
if err := validateDeclaredCommandAt(command, time.Now); err != nil {
return unsupportedResult("validation-failed"), nil
}
if strings.TrimSpace(registry.availability.BoundServerID) == "" || !registry.adapterBindingMatches() || !registry.availability.Approved || !registry.availability.Capabilities[command.CommandType] {
return unsupportedResult("unsupported"), nil
}
handler, exists := registry.handlers[command.CommandType]
if !exists {
return unsupportedResult("unsupported"), nil
}
payload, err := handler(ctx, command.Payload)
if err != nil {
if errors.Is(err, errAdapterUnsupported) {
return unsupportedResult("unsupported"), nil
}
return CommandResult{Status: "failed", Summary: "typed adapter failed", Payload: map[string]any{"result": "failed"}}, nil
}
result := CommandResult{Status: "succeeded", Summary: "typed adapter completed", Payload: redactTypedPayload(payload)}
registry.mu.Lock()
registry.completed[command.ID] = result
registry.mu.Unlock()
return result, nil
}
func (registry *HandlerRegistry) adapterBindingMatches() bool {
return registry.adapterServer == "" || registry.adapterServer == registry.availability.BoundServerID
}
func validateDeclaredCommandAt(command ClaimedCommand, now func() time.Time) error {
if command.ID == "" || command.ProfileKey != ProfileKey || command.FencingToken == 0 || command.Payload == nil || command.LeaseExpiresAt.IsZero() || command.ExpiresAt.IsZero() || !now().Before(command.LeaseExpiresAt) || !now().Before(command.ExpiresAt) {
return fmt.Errorf("invalid command")
}
for key, value := range command.Payload {
if !safeCommandField(key, value) {
return fmt.Errorf("unsafe payload")
}
}
return validateCommandPayload(command.CommandType, command.Payload)
}
func validateCommandPayload(commandType string, payload map[string]any) error {
require := func(keys ...string) error {
for _, key := range keys {
if _, ok := payload[key]; !ok {
return fmt.Errorf("payload is incomplete")
}
}
return nil
}
noUnknown := func(keys ...string) error {
allowed := map[string]bool{}
for _, key := range keys {
allowed[key] = true
}
for key := range payload {
if !allowed[key] {
return fmt.Errorf("payload has unsupported field")
}
}
return nil
}
switch commandType {
case "config.read":
return noUnknown()
case "config.patch":
if err := require("revision", "fields"); err != nil {
return err
}
if err := noUnknown("revision", "fields"); err != nil {
return err
}
_, revisionOK := payload["revision"].(string)
fields, fieldsOK := payload["fields"].([]any)
if !revisionOK || !fieldsOK || len(fields) == 0 || len(fields) > 32 {
return fmt.Errorf("config patch payload is invalid")
}
return nil
case "companion.diagnostics":
if err := noUnknown("includeWindowState", "maxEntries"); err != nil {
return err
}
if value, ok := payload["includeWindowState"]; ok {
if _, valid := value.(bool); !valid {
return fmt.Errorf("diagnostics payload is invalid")
}
}
if value, ok := payload["maxEntries"]; ok {
if !boundedDiagnosticsEntries(value) {
return fmt.Errorf("diagnostics payload is invalid")
}
}
return nil
case "game-state.patch":
if err := require("playerId", "expectedStateVersion", "safetyWindow", "reason", "changes"); err != nil {
return err
}
if err := noUnknown("playerId", "expectedStateVersion", "safetyWindow", "reason", "changes"); err != nil {
return err
}
changes, changesOK := payload["changes"].([]any)
if !changesOK || len(changes) == 0 || len(changes) > 8 {
return fmt.Errorf("state patch payload is invalid")
}
return nil
case "reward.deliver":
if err := require("grantId", "playerId", "items", "operations"); err != nil {
return err
}
if err := noUnknown("grantId", "playerId", "items", "operations"); err != nil {
return err
}
_, err := rewardGrant(payload)
return err
case "event.start":
if err := require("eventId", "eventType", "class", "title", "placard", "percent", "produces", "durationSeconds"); err != nil {
return err
}
if err := noUnknown("eventId", "eventType", "class", "title", "placard", "percent", "npc", "item", "zombie", "animal", "produces", "durationSeconds", "maxParticipants", "announce"); err != nil {
return err
}
_, err := eventStartRequest(payload)
return err
case "player.notify":
if err := require("playerId", "message"); err != nil {
return err
}
if err := noUnknown("playerId", "message"); err != nil {
return err
}
_, playerOK := payload["playerId"].(string)
message, messageOK := payload["message"].(string)
if !playerOK || !messageOK || strings.TrimSpace(message) == "" || len(message) > 200 {
return fmt.Errorf("notification payload is invalid")
}
return nil
case "vehicle.spawn":
if err := require("vehicleCode"); err != nil {
return err
}
if err := noUnknown("vehicleCode"); err != nil {
return err
}
vehicleCode, vehicleOK := payload["vehicleCode"].(string)
if !vehicleOK || !supportedVehicleSpawnCode(vehicleCode) {
return fmt.Errorf("vehicle spawn payload is invalid")
}
return nil
default:
return fmt.Errorf("command type is not declared")
}
}
func safeCommandField(key string, value any) bool {
lower := strings.ToLower(strings.TrimSpace(key))
if lower == "" || strings.Contains(lower, "path") || strings.Contains(lower, "credential") || strings.Contains(lower, "password") || strings.Contains(lower, "sql") || strings.Contains(lower, "rcon") || strings.Contains(lower, "command") {
return false
}
if text, ok := value.(string); ok {
compact := strings.ToLower(text)
return !strings.Contains(compact, "bearer ") && !strings.Contains(compact, "password=") && !strings.Contains(compact, "select ") && !strings.Contains(compact, "/users/")
}
return true
}
func unsupportedResult(code string) CommandResult {
return CommandResult{Status: "failed", Summary: "typed operation unavailable", Payload: map[string]any{"result": code}}
}
func redactTypedPayload(payload map[string]any) map[string]any {
result := map[string]any{}
for key, value := range payload {
if safeCommandField(key, value) {
result[key] = value
}
}
return result
}
type CommandGateway interface {
ClaimCommands(context.Context, int) ([]ClaimedCommand, error)
AckCommand(context.Context, string, uint64) (CommandAck, error)
CompleteCommand(context.Context, string, uint64, CommandResult) (CompletedCommand, error)
}
type Dispatcher struct {
Client CommandGateway
Registry *HandlerRegistry
PollLimit int
Backoff time.Duration
Now func() time.Time
}
func (dispatcher Dispatcher) DispatchOnce(ctx context.Context) error {
if dispatcher.Client == nil || dispatcher.Registry == nil {
return fmt.Errorf("dispatcher is not configured")
}
limit := dispatcher.PollLimit
if limit == 0 {
limit = 10
}
if limit < 1 || limit > 50 {
return fmt.Errorf("dispatcher poll limit is invalid")
}
commands, err := dispatcher.Client.ClaimCommands(ctx, limit)
if err != nil {
return err
}
for _, command := range commands {
if err := validateDeclaredCommandAt(command, dispatcher.clock()); err != nil {
if completeErr := dispatcher.completeValidationFailure(ctx, command); completeErr != nil {
return completeErr
}
continue
}
if _, err = dispatcher.Client.AckCommand(ctx, command.ID, command.FencingToken); err != nil {
return err
}
result, executionErr := dispatcher.Registry.Execute(ctx, command)
if executionErr != nil {
result = CommandResult{Status: "failed", Summary: "typed adapter failed", Payload: map[string]any{"result": "failed"}}
}
if _, err = dispatcher.Client.CompleteCommand(ctx, command.ID, command.FencingToken, result); err != nil {
return err
}
}
return nil
}
func (dispatcher Dispatcher) completeValidationFailure(ctx context.Context, command ClaimedCommand) error {
if command.ID == "" || command.FencingToken == 0 {
return fmt.Errorf("claimed command is invalid")
}
_, err := dispatcher.Client.CompleteCommand(ctx, command.ID, command.FencingToken, unsupportedResult("validation-failed"))
return err
}
func (dispatcher Dispatcher) clock() func() time.Time {
if dispatcher.Now != nil {
return dispatcher.Now
}
return time.Now
}
func (dispatcher Dispatcher) Run(ctx context.Context) error {
backoff := dispatcher.Backoff
if backoff <= 0 {
backoff = 2 * time.Second
}
for {
if err := dispatcher.DispatchOnce(ctx); err != nil {
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(backoff):
continue
}
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(backoff):
}
}
}
// Runtime keeps the registered companion alive with bounded heartbeat and
// polling intervals. It owns no host connection or game credential; handlers
// are the only route to runtime capability adapters.
type RuntimeGateway interface {
CommandGateway
Register(context.Context) (Registration, error)
Heartbeat(context.Context, HealthReport) (HeartbeatResult, error)
}
type Runtime struct {
Client RuntimeGateway
Dispatcher Dispatcher
HeartbeatEvery time.Duration
PollEvery time.Duration
Backoff time.Duration
Health func() HealthReport
}
func (runtime Runtime) Run(ctx context.Context) error {
if runtime.Client == nil || runtime.Dispatcher.Registry == nil {
return fmt.Errorf("runtime is not configured")
}
if _, err := runtime.Client.Register(ctx); err != nil {
return err
}
heartbeatEvery, pollEvery := runtime.HeartbeatEvery, runtime.PollEvery
if heartbeatEvery < 5*time.Second {
heartbeatEvery = 30 * time.Second
}
if pollEvery < time.Second {
pollEvery = 5 * time.Second
}
heartbeat := time.NewTicker(heartbeatEvery)
defer heartbeat.Stop()
poll := time.NewTicker(pollEvery)
defer poll.Stop()
for {
select {
case <-ctx.Done():
return ctx.Err()
case <-heartbeat.C:
report := HealthReport{Status: "healthy", Reason: "typed companion dispatcher ready"}
if runtime.Health != nil {
report = runtime.Health()
}
if _, err := runtime.Client.Heartbeat(ctx, report); err != nil {
return err
}
case <-poll.C:
if err := runtime.Dispatcher.DispatchOnce(ctx); err != nil {
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(runtime.backoff()):
}
}
}
}
}
func (runtime Runtime) backoff() time.Duration {
if runtime.Backoff > 0 {
return runtime.Backoff
}
return 2 * time.Second
}
@@ -1,46 +0,0 @@
package companion
import (
"context"
"testing"
"time"
)
// This exercises the bounded claim/ack/complete boundary as one flow. The
// fixture deliberately mixes replay, expired, malformed, unsupported, and
// redaction-sensitive commands so none can fall through to an adapter.
func TestDispatcherIntegrationContainsUnsafeAndUnavailableCommands(t *testing.T) {
stamp := time.Now().UTC()
adapter := &adapterFixture{}
registry := NewHandlerRegistry(HandlerAvailability{
BoundServerID: "server-1", Approved: true,
Capabilities: map[string]bool{"config.read": true},
}, adapter)
fixture := &dispatchFixture{commands: []ClaimedCommand{
{ID: "safe-read", ProfileKey: ProfileKey, CommandType: "config.read", Payload: map[string]any{}, FencingToken: 1, LeaseExpiresAt: stamp.Add(time.Minute), ExpiresAt: stamp.Add(time.Minute)},
{ID: "safe-read", ProfileKey: ProfileKey, CommandType: "config.read", Payload: map[string]any{}, FencingToken: 1, LeaseExpiresAt: stamp.Add(time.Minute), ExpiresAt: stamp.Add(time.Minute)},
{ID: "cancelled-before-claim", ProfileKey: ProfileKey, CommandType: "config.read", Payload: map[string]any{}, FencingToken: 2, LeaseExpiresAt: stamp.Add(-time.Second), ExpiresAt: stamp.Add(-time.Second)},
{ID: "malformed", ProfileKey: ProfileKey, CommandType: "raw.rcon", Payload: map[string]any{}, FencingToken: 3, LeaseExpiresAt: stamp.Add(time.Minute), ExpiresAt: stamp.Add(time.Minute)},
{ID: "unsupported-version", ProfileKey: ProfileKey, CommandType: "player.notify", Payload: map[string]any{"playerId": "76561198000000001", "message": "Moonlight"}, FencingToken: 4, LeaseExpiresAt: stamp.Add(time.Minute), ExpiresAt: stamp.Add(time.Minute)},
}}
dispatcher := Dispatcher{Client: fixture, Registry: registry, Now: func() time.Time { return stamp }}
if err := dispatcher.DispatchOnce(context.Background()); err != nil {
t.Fatalf("dispatch integration: %v", err)
}
if adapter.reads != 1 {
t.Fatalf("duplicate delivery invoked the adapter %d times", adapter.reads)
}
if len(fixture.acks) != 3 || fixture.acks[0] != "safe-read" || fixture.acks[1] != "safe-read" || fixture.acks[2] != "unsupported-version" {
t.Fatalf("only live, validated commands may be acknowledged: %v", fixture.acks)
}
if len(fixture.completed) != 5 {
t.Fatalf("every claimed command needs a terminal result: %+v", fixture.completed)
}
if fixture.completed[0].Payload["hostPath"] != nil || fixture.completed[1].Payload["hostPath"] != nil {
t.Fatalf("adapter output leaked protected details: %+v", fixture.completed[:2])
}
if fixture.completed[2].Payload["result"] != "validation-failed" || fixture.completed[3].Payload["result"] != "validation-failed" || fixture.completed[4].Payload["result"] != "unsupported" {
t.Fatalf("terminal failure classification is unsafe: %+v", fixture.completed)
}
}
@@ -1,115 +0,0 @@
package companion
import (
"context"
"testing"
"time"
)
type dispatchFixture struct {
commands []ClaimedCommand
acks []string
completed []CommandResult
}
func (fixture *dispatchFixture) ClaimCommands(_ context.Context, _ int) ([]ClaimedCommand, error) {
return append([]ClaimedCommand(nil), fixture.commands...), nil
}
func (fixture *dispatchFixture) AckCommand(_ context.Context, id string, _ uint64) (CommandAck, error) {
fixture.acks = append(fixture.acks, id)
return CommandAck{CommandID: id, State: "claimed", FencingToken: 7}, nil
}
func (fixture *dispatchFixture) CompleteCommand(_ context.Context, _ string, _ uint64, result CommandResult) (CompletedCommand, error) {
fixture.completed = append(fixture.completed, result)
return CompletedCommand{State: "succeeded"}, nil
}
type adapterFixture struct{ reads int }
func (adapter *adapterFixture) ReadConfiguration(context.Context) (map[string]any, error) {
adapter.reads++
return map[string]any{"version": "0.9.700.90357", "hostPath": "C:/must-redact"}, nil
}
func (*adapterFixture) PatchConfiguration(context.Context, map[string]any) (map[string]any, error) {
return nil, nil
}
func (*adapterFixture) Diagnostics(context.Context) (map[string]any, error) {
return map[string]any{"status": "healthy"}, nil
}
func (*adapterFixture) PatchGameState(context.Context, map[string]any) (map[string]any, error) {
return nil, nil
}
func (*adapterFixture) DeliverReward(context.Context, map[string]any) (map[string]any, error) {
return nil, nil
}
func (*adapterFixture) StartEvent(context.Context, map[string]any) (map[string]any, error) {
return nil, nil
}
func (*adapterFixture) NotifyPlayer(context.Context, map[string]any) (map[string]any, error) {
return nil, nil
}
func (*adapterFixture) SpawnVehicle(context.Context, map[string]any) (map[string]any, error) {
return nil, nil
}
func TestDispatcherAcknowledgesOnlyLiveValidatedTypedCommands(t *testing.T) {
stamp := time.Now().UTC()
adapter := &adapterFixture{}
registry := NewHandlerRegistry(HandlerAvailability{BoundServerID: "server-1", Approved: true, Capabilities: map[string]bool{"config.read": true}}, adapter)
fixture := &dispatchFixture{commands: []ClaimedCommand{{ID: "read-1", ProfileKey: ProfileKey, CommandType: "config.read", Payload: map[string]any{}, FencingToken: 7, LeaseExpiresAt: stamp.Add(time.Minute), ExpiresAt: stamp.Add(time.Minute)}, {ID: "expired-1", ProfileKey: ProfileKey, CommandType: "config.read", Payload: map[string]any{}, FencingToken: 8, LeaseExpiresAt: stamp.Add(-time.Second), ExpiresAt: stamp.Add(-time.Second)}}}
dispatcher := Dispatcher{Client: fixture, Registry: registry, Now: func() time.Time { return stamp }}
if err := dispatcher.DispatchOnce(context.Background()); err != nil {
t.Fatalf("dispatch: %v", err)
}
if len(fixture.acks) != 1 || fixture.acks[0] != "read-1" || len(fixture.completed) != 2 || adapter.reads != 1 {
t.Fatalf("unexpected bounded dispatch: acks=%v completed=%+v reads=%d", fixture.acks, fixture.completed, adapter.reads)
}
if fixture.completed[0].Payload["hostPath"] != nil || fixture.completed[1].Payload["result"] != "validation-failed" {
t.Fatalf("unsafe or malformed result: %+v", fixture.completed)
}
}
func TestRegistryReturnsCachedResultForDuplicateDelivery(t *testing.T) {
stamp := time.Now().UTC()
adapter := &adapterFixture{}
registry := NewHandlerRegistry(HandlerAvailability{BoundServerID: "server-1", Approved: true, Capabilities: map[string]bool{"config.read": true}}, adapter)
command := ClaimedCommand{ID: "duplicate-1", ProfileKey: ProfileKey, CommandType: "config.read", Payload: map[string]any{}, FencingToken: 7, LeaseExpiresAt: stamp.Add(time.Minute), ExpiresAt: stamp.Add(time.Minute)}
if _, err := registry.Execute(context.Background(), command); err != nil {
t.Fatalf("first execute: %v", err)
}
if _, err := registry.Execute(context.Background(), command); err != nil {
t.Fatalf("second execute: %v", err)
}
if adapter.reads != 1 {
t.Fatalf("duplicate delivery invoked adapter %d times", adapter.reads)
}
}
func TestRegistryRejectsUndeclaredAndMalformedPayloads(t *testing.T) {
stamp := time.Now().UTC()
registry := NewHandlerRegistry(HandlerAvailability{BoundServerID: "server-1", Approved: true, Capabilities: map[string]bool{"config.patch": true}}, &adapterFixture{})
for _, command := range []ClaimedCommand{{ID: "bad-type", ProfileKey: ProfileKey, CommandType: "raw.rcon", Payload: map[string]any{}, FencingToken: 1, LeaseExpiresAt: stamp.Add(time.Minute), ExpiresAt: stamp.Add(time.Minute)}, {ID: "bad-payload", ProfileKey: ProfileKey, CommandType: "config.patch", Payload: map[string]any{"revision": "r1"}, FencingToken: 1, LeaseExpiresAt: stamp.Add(time.Minute), ExpiresAt: stamp.Add(time.Minute)}, {ID: "unsafe-vehicle", ProfileKey: ProfileKey, CommandType: "vehicle.spawn", Payload: map[string]any{"vehicleCode": "BPC_Laika_C", "command": "#spawnvehicle BPC_Laika_C"}, FencingToken: 1, LeaseExpiresAt: stamp.Add(time.Minute), ExpiresAt: stamp.Add(time.Minute)}} {
result, err := registry.Execute(context.Background(), command)
if err != nil || result.Payload["result"] != "validation-failed" {
t.Fatalf("unsafe command was not rejected: result=%+v err=%v", result, err)
}
}
}
func TestRewardPayloadValidationRequiresItemsOrOperations(t *testing.T) {
validBase := map[string]any{"grantId": "grant-1", "playerId": "76561198000000001"}
for name, payload := range map[string]map[string]any{
"items": {"grantId": validBase["grantId"], "playerId": validBase["playerId"], "items": []any{map[string]any{"catalogCode": "BPC_Apple", "quantity": float64(1)}}, "operations": []any{}},
"operations": {"grantId": validBase["grantId"], "playerId": validBase["playerId"], "items": []any{}, "operations": []any{"#SetFamePoints 250"}},
"both": {"grantId": validBase["grantId"], "playerId": validBase["playerId"], "items": []any{map[string]any{"catalogCode": "BPC_Apple", "quantity": float64(1)}}, "operations": []any{"#SetFamePoints 250"}},
} {
t.Run(name, func(t *testing.T) {
if err := validateCommandPayload("reward.deliver", payload); err != nil {
t.Fatalf("valid reward payload was rejected: %v", err)
}
})
}
if err := validateCommandPayload("reward.deliver", map[string]any{"grantId": "grant-empty", "playerId": "76561198000000001", "items": []any{}, "operations": []any{}}); err == nil {
t.Fatal("reward payload with no items or operations was accepted")
}
}
@@ -1,120 +0,0 @@
package companion
import (
"crypto/sha256"
"encoding/hex"
"regexp"
"strings"
"time"
)
var scumLoginLogLine = regexp.MustCompile(`^\d{4}\.\d{2}\.\d{2}-\d{2}\.\d{2}\.\d{2}: '([0-9.]+) (\d{1,50}):([^']{1,80})\(\d+\)' logged (in|out)(?: .*)?$`)
// ConsoleRecord is supplied by Run's stdout/stderr stream, not by the server
// execution log. The channel never accepts a file path or a raw log archive.
type ConsoleRecord struct {
ServerID string
Stream string
Sequence uint64
OccurredAt time.Time
Text string
}
type SemanticEvent struct {
ServerID string
Sequence uint64
Type string
PlayerID string
DisplayName string
OccurredAt time.Time
NetworkCorrelation string
}
type EventDiagnostic struct {
ServerID string
Sequence uint64
Code string
}
type SemanticEventBatch struct {
ServerID string
FirstSequence uint64
Events []SemanticEvent
Diagnostics []EventDiagnostic
}
// ParseConsoleRecords accepts only bounded stdout/stderr records. Unknown
// formats produce a bounded diagnostic and are skipped; they never fabricate
// events or carry raw console text across the plugin boundary.
func ParseConsoleRecords(serverID string, records []ConsoleRecord, correlationSecret string) SemanticEventBatch {
batch := SemanticEventBatch{ServerID: serverID}
if len(records) > 100 {
records = records[:100]
}
for _, record := range records {
if record.ServerID != serverID || strings.TrimSpace(record.Stream) == "" || len(record.Stream) > 64 || record.Sequence == 0 || record.OccurredAt.IsZero() || len(record.Text) > 1024 {
batch.Diagnostics = appendDiagnostic(batch.Diagnostics, EventDiagnostic{ServerID: serverID, Sequence: record.Sequence, Code: "invalid-console-record"})
continue
}
if batch.FirstSequence == 0 {
batch.FirstSequence = record.Sequence
}
event, ok := parseConsoleRecord(record, correlationSecret)
if !ok {
batch.Diagnostics = appendDiagnostic(batch.Diagnostics, EventDiagnostic{ServerID: serverID, Sequence: record.Sequence, Code: "unknown-console-format"})
continue
}
batch.Events = append(batch.Events, event)
}
return batch
}
func parseConsoleRecord(record ConsoleRecord, secret string) (SemanticEvent, bool) {
if event, ok := parseLoginLogRecord(record, secret); ok {
return event, true
}
fields := strings.Fields(record.Text)
if len(fields) < 3 || fields[0] != "SCUM" || (fields[1] != "LOGIN" && fields[1] != "LOGOUT") || !steamID64(fields[2]) {
return SemanticEvent{}, false
}
eventType := "scum.login"
if fields[1] == "LOGOUT" {
eventType = "scum.logout"
}
event := SemanticEvent{ServerID: record.ServerID, Sequence: record.Sequence, Type: eventType, PlayerID: fields[2], OccurredAt: record.OccurredAt}
if len(fields) == 4 && secret != "" {
event.NetworkCorrelation = networkCorrelation(record.ServerID, fields[3], secret)
}
return event, true
}
func parseLoginLogRecord(record ConsoleRecord, secret string) (SemanticEvent, bool) {
match := scumLoginLogLine.FindStringSubmatch(record.Text)
if match == nil || !steamID64(match[2]) {
return SemanticEvent{}, false
}
eventType := "scum.login"
if match[4] == "out" {
eventType = "scum.logout"
}
event := SemanticEvent{ServerID: record.ServerID, Sequence: record.Sequence, Type: eventType, PlayerID: match[2], DisplayName: match[3], OccurredAt: record.OccurredAt}
if secret != "" {
event.NetworkCorrelation = networkCorrelation(record.ServerID, match[1], secret)
}
return event, true
}
func networkCorrelation(serverID, value, secret string) string {
digest := sha256.Sum256([]byte(serverID + "\x00" + secret + "\x00" + value))
return hex.EncodeToString(digest[:])
}
func appendDiagnostic(existing []EventDiagnostic, diagnostic EventDiagnostic) []EventDiagnostic {
if len(existing) >= 32 {
return existing
}
return append(existing, diagnostic)
}
func VerifiedSemanticEventProducer() SemanticEventProducerAvailability {
return SemanticEventProducerAvailability{Available: true, Reason: "Run stdout/stderr semantic parser is available"}
}
type SemanticEventProducerAvailability struct {
Available bool
Reason string
}
@@ -1,5 +0,0 @@
module browser.local/plugins/scum-server-plugin/companion
go 1.25.1
require gopkg.in/yaml.v3 v3.0.1
@@ -1,4 +0,0 @@
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
@@ -1,124 +0,0 @@
package companion
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
const logEventsPath = "/api/v1/game-client-bridge/companion/logs/events"
type LogEntry struct {
Seq uint64 `json:"seq"`
Timestamp time.Time `json:"timestamp"`
Level string `json:"level,omitempty"`
Line string `json:"line"`
Fields map[string]string `json:"fields,omitempty"`
}
type LogStreamEvent struct {
ServerInstanceID string `json:"serverInstanceId"`
StreamID string `json:"streamId"`
Source string `json:"source"`
StreamKey string `json:"streamKey"`
LogSessionID string `json:"logSessionId,omitempty"`
SessionStartedAt time.Time `json:"sessionStartedAt,omitempty"`
LatestSeq uint64 `json:"latestSeq"`
Entry LogEntry `json:"entry"`
}
type logStreamRequest struct {
SessionToken string `json:"sessionToken"`
}
func (client *Client) StreamLogEvents(ctx context.Context, handle func(LogStreamEvent) error) error {
if handle == nil {
return fmt.Errorf("log event handler is required")
}
token, err := client.currentSession()
if err != nil {
return err
}
encoded, err := json.Marshal(logStreamRequest{SessionToken: token})
if err != nil {
return fmt.Errorf("encode log stream request: %w", err)
}
if len(encoded) > maxRequestBytes {
return fmt.Errorf("platform request exceeds the bounded payload size")
}
request, err := http.NewRequestWithContext(ctx, http.MethodPost, client.config.Platform.BaseURL+logEventsPath, bytes.NewReader(encoded))
if err != nil {
return fmt.Errorf("create log stream request: %w", err)
}
request.Header.Set("Accept", "text/event-stream")
request.Header.Set("Content-Type", "application/json")
response, err := client.httpClient.Do(request)
if err != nil {
return fmt.Errorf("open log stream: %w", err)
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
_, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 4096))
return HTTPError{StatusCode: response.StatusCode, ExpectedStatus: http.StatusOK}
}
return readLogEventStream(ctx, response.Body, handle)
}
func readLogEventStream(ctx context.Context, body io.Reader, handle func(LogStreamEvent) error) error {
reader := bufio.NewReader(body)
var eventName string
var dataLines []string
flush := func() error {
if len(dataLines) == 0 {
eventName = ""
return nil
}
name := eventName
if name == "" {
name = "message"
}
payload := strings.Join(dataLines, "\n")
eventName = ""
dataLines = nil
if name != "log" {
return nil
}
var event LogStreamEvent
if err := json.Unmarshal([]byte(payload), &event); err != nil {
return fmt.Errorf("decode log event: %w", err)
}
return handle(event)
}
for {
line, err := reader.ReadString('\n')
if len(line) > 0 {
line = strings.TrimRight(line, "\r\n")
switch {
case line == "":
if flushErr := flush(); flushErr != nil {
return flushErr
}
case strings.HasPrefix(line, ":"):
case strings.HasPrefix(line, "event:"):
eventName = strings.TrimSpace(strings.TrimPrefix(line, "event:"))
case strings.HasPrefix(line, "data:"):
dataLines = append(dataLines, strings.TrimSpace(strings.TrimPrefix(line, "data:")))
}
}
if err != nil {
if ctx != nil && ctx.Err() != nil {
return ctx.Err()
}
if err == io.EOF {
return flush()
}
return fmt.Errorf("read log stream: %w", err)
}
}
}
@@ -1,166 +0,0 @@
package companion
import (
"context"
"fmt"
"time"
)
const (
SmokeClaimLimit = 1
SmokeIsolationEnvironment = "SCUM_COMPANION_SMOKE_SCOPE"
SmokeIsolationValue = "isolated-non-production"
companionDiagnosticsCommandType = "companion.diagnostics"
companionHealthSnapshotType = "companion.health"
companionHealthSchemaVersion = "1"
companionHealthKeepForSeconds = 7 * 24 * 60 * 60
companionHealthMaxRecords = 1000
)
type SmokeOptions struct {
IsolatedNonProduction bool
StreamSuffix func() (string, error)
}
type SmokeResult struct {
ClaimedCount int `json:"claimedCount"`
CompletedCount int `json:"completedCount"`
UnsupportedCount int `json:"unsupportedCount"`
CompletedCommandIDs []string `json:"completedCommandIds"`
UnsupportedCommandIDs []string `json:"unsupportedCommandIds"`
SnapshotID string `json:"snapshotId,omitempty"`
}
func RunOneShotSmoke(ctx context.Context, client *Client, options SmokeOptions) (SmokeResult, error) {
result := SmokeResult{CompletedCommandIDs: []string{}, UnsupportedCommandIDs: []string{}}
if client == nil {
return result, fmt.Errorf("companion client is required")
}
if !options.IsolatedNonProduction {
return result, fmt.Errorf("one-shot smoke requires an isolated non-production queue")
}
streamSuffix := options.StreamSuffix
if streamSuffix == nil {
streamSuffix = randomNonce
}
suffix, err := streamSuffix()
if err != nil || !validNonce(suffix) {
return result, fmt.Errorf("create isolated smoke stream")
}
streamKey := "smoke-" + suffix
if len(streamKey) > 80 {
return result, fmt.Errorf("isolated smoke stream is too long")
}
if _, err := client.Register(ctx); err != nil {
return result, err
}
observedAt := client.now().UTC()
if _, err := client.Heartbeat(ctx, HealthReport{Status: "healthy", Reason: "one-shot smoke ready"}); err != nil {
return result, err
}
commands, err := client.ClaimCommands(ctx, SmokeClaimLimit)
if err != nil {
return result, err
}
result.ClaimedCount = len(commands)
if len(commands) != 1 {
return result, fmt.Errorf("one-shot smoke requires exactly one isolated command")
}
command := commands[0]
if err := validateSmokeDiagnosticsCommand(command, observedAt); err != nil {
result.UnsupportedCount = 1
if command.ID != "" {
result.UnsupportedCommandIDs = append(result.UnsupportedCommandIDs, command.ID)
}
return result, err
}
ack, err := client.AckCommand(ctx, command.ID, command.FencingToken)
if err != nil {
return result, err
}
if ack.CommandID != command.ID || ack.FencingToken != command.FencingToken || ack.State != "claimed" {
return result, fmt.Errorf("platform returned an invalid smoke acknowledgement")
}
diagnostics := map[string]any{
"status": "online",
"version": client.config.Component.Version,
"lastHeartbeatAt": observedAt.Format(time.RFC3339Nano),
}
completed, err := client.CompleteCommand(ctx, command.ID, command.FencingToken, CommandResult{Status: "succeeded", Summary: "companion diagnostics available", Payload: diagnostics})
if err != nil {
return result, err
}
if completed.CommandID != command.ID || completed.State != "succeeded" {
return result, fmt.Errorf("platform returned an invalid smoke command result")
}
result.CompletedCount = 1
result.CompletedCommandIDs = append(result.CompletedCommandIDs, command.ID)
health := map[string]any{
"status": "online",
"version": client.config.Component.Version,
"observedAt": observedAt.Format(time.RFC3339Nano),
"capabilities": append([]string(nil), client.config.Capabilities...),
}
accepted, err := client.UploadSnapshot(ctx, Snapshot{
Type: companionHealthSnapshotType,
SchemaVersion: companionHealthSchemaVersion,
StreamKey: streamKey,
Sequence: 1,
ObservedAt: observedAt,
Payload: health,
KeepForSeconds: companionHealthKeepForSeconds,
MaxRecords: companionHealthMaxRecords,
})
if err != nil {
return result, err
}
if accepted.SnapshotID == "" || accepted.ProfileKey != ProfileKey || accepted.Type != companionHealthSnapshotType || accepted.SchemaVersion != companionHealthSchemaVersion || accepted.StreamKey != streamKey || accepted.Sequence != 1 {
return result, fmt.Errorf("platform returned an invalid smoke snapshot acknowledgement")
}
result.SnapshotID = accepted.SnapshotID
return result, nil
}
func validateSmokeDiagnosticsCommand(command ClaimedCommand, stamp time.Time) error {
if command.CommandType != companionDiagnosticsCommandType {
return fmt.Errorf("isolated smoke claimed an unsupported command")
}
if command.ID == "" || command.ProfileKey != ProfileKey || command.FencingToken == 0 {
return fmt.Errorf("isolated smoke command identity is invalid")
}
if command.LeaseExpiresAt.IsZero() || command.ExpiresAt.IsZero() || !stamp.Before(command.LeaseExpiresAt) || !stamp.Before(command.ExpiresAt) {
return fmt.Errorf("isolated smoke command lease is not live")
}
if command.Payload == nil {
return fmt.Errorf("isolated smoke diagnostics payload is invalid")
}
for key, value := range command.Payload {
switch key {
case "includeWindowState":
if _, ok := value.(bool); !ok {
return fmt.Errorf("isolated smoke diagnostics payload is invalid")
}
case "maxEntries":
if !boundedDiagnosticsEntries(value) {
return fmt.Errorf("isolated smoke diagnostics payload is invalid")
}
default:
return fmt.Errorf("isolated smoke diagnostics payload is invalid")
}
}
return nil
}
func boundedDiagnosticsEntries(value any) bool {
switch typed := value.(type) {
case int:
return typed >= 1 && typed <= 20
case int64:
return typed >= 1 && typed <= 20
case float64:
return typed >= 1 && typed <= 20 && typed == float64(int(typed))
default:
return false
}
}
@@ -1,213 +0,0 @@
package companion
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"testing"
"time"
)
func TestRunOneShotSmokeProcessesOnlySafeDiagnosticsAndUploadsIsolatedHealth(t *testing.T) {
stamp := time.Date(2026, 7, 20, 9, 10, 11, 123456789, time.UTC)
config := loadTestConfig(t)
sessionExpiry := stamp.Add(15 * time.Minute)
step := 0
transport := roundTripFunc(func(request *http.Request) (*http.Response, error) {
switch step {
case 0:
assertPlatformRequest(t, request, registerPath)
step++
return jsonHTTPResponse(http.StatusOK, registerResponse{Accepted: true, InstallationID: config.Component.InstallationID, SessionToken: "component-session-smoke", ExpiresAt: sessionExpiry, HeartbeatEverySeconds: 30, ServerTime: stamp}), nil
case 1:
assertPlatformRequest(t, request, heartbeatPath)
var body heartbeatRequest
decodeRequest(t, request, &body)
if body.SessionToken != "component-session-smoke" || body.HealthReason != "one-shot smoke ready" {
t.Fatalf("unexpected smoke heartbeat: %+v", body)
}
step++
return jsonHTTPResponse(http.StatusOK, heartbeatResponse{Accepted: true, InstallationID: config.Component.InstallationID, Status: "online", Health: "healthy", NextHeartbeatSeconds: 30, SessionExpiresAt: sessionExpiry, ServerTime: stamp}), nil
case 2:
assertPlatformRequest(t, request, claimPath)
var body claimRequest
decodeRequest(t, request, &body)
if body.SessionToken != "component-session-smoke" || body.Limit != 1 {
t.Fatalf("unexpected smoke claim: %+v", body)
}
step++
return jsonHTTPResponse(http.StatusOK, claimResponse{Items: []ClaimedCommand{{ID: "diagnostics-1", ProfileKey: ProfileKey, CommandType: companionDiagnosticsCommandType, Payload: map[string]any{"includeWindowState": true, "maxEntries": 3}, FencingToken: 17, LeaseExpiresAt: stamp.Add(time.Minute), ExpiresAt: stamp.Add(5 * time.Minute)}}, Count: 1}), nil
case 3:
assertPlatformRequest(t, request, "/api/v1/game-client-bridge/companion/commands/diagnostics-1/ack")
var body ackRequest
decodeRequest(t, request, &body)
if body.SessionToken != "component-session-smoke" || body.FencingToken != 17 {
t.Fatalf("unexpected smoke ack: %+v", body)
}
step++
return jsonHTTPResponse(http.StatusOK, CommandAck{CommandID: "diagnostics-1", State: "claimed", FencingToken: 17, AcknowledgedAt: stamp}), nil
case 4:
assertPlatformRequest(t, request, "/api/v1/game-client-bridge/companion/commands/diagnostics-1/result")
var body resultRequest
decodeRequest(t, request, &body)
if body.SessionToken != "component-session-smoke" || body.FencingToken != 17 || body.Status != "succeeded" || body.Summary != "companion diagnostics available" {
t.Fatalf("unexpected smoke result: %+v", body)
}
if body.Payload["status"] != "online" || body.Payload["version"] != config.Component.Version || body.Payload["lastHeartbeatAt"] != stamp.Format(time.RFC3339Nano) {
t.Fatalf("unexpected bounded diagnostics payload: %+v", body.Payload)
}
serialized, _ := json.Marshal(body)
if strings.Contains(string(serialized), testProof) {
t.Fatalf("result exposed proof: %s", serialized)
}
step++
return jsonHTTPResponse(http.StatusOK, map[string]any{"commandId": "diagnostics-1", "state": "succeeded", "result": map[string]any{"status": "succeeded", "summary": "companion diagnostics available", "completedAt": stamp}, "updatedAt": stamp, "completedAt": stamp}), nil
case 5:
assertPlatformRequest(t, request, snapshotPath)
var body snapshotRequest
decodeRequest(t, request, &body)
if body.SessionToken != "component-session-smoke" || body.Type != companionHealthSnapshotType || body.SchemaVersion != companionHealthSchemaVersion || body.StreamKey != "smoke-fixture-stream-nonce-0001" || body.Sequence != 1 {
t.Fatalf("unexpected isolated smoke snapshot identity: %+v", body)
}
if !body.ObservedAt.Equal(stamp) || body.KeepForSeconds != 604800 || body.MaxRecords != 1000 || body.Payload["status"] != "online" || body.Payload["observedAt"] != stamp.Format(time.RFC3339Nano) {
t.Fatalf("unexpected typed health snapshot: %+v", body)
}
step++
return jsonHTTPResponse(http.StatusAccepted, AcceptedSnapshot{SnapshotID: "health-snapshot-1", ProfileKey: ProfileKey, Type: body.Type, SchemaVersion: body.SchemaVersion, StreamKey: body.StreamKey, Sequence: body.Sequence, AcceptedAt: stamp, ExpiresAt: stamp.Add(7 * 24 * time.Hour)}), nil
default:
return nil, fmt.Errorf("unexpected smoke transport: %s", request.URL.Path)
}
})
client := newTestClient(t, config, transport, stamp)
result, err := RunOneShotSmoke(context.Background(), client, SmokeOptions{IsolatedNonProduction: true, StreamSuffix: func() (string, error) { return "fixture-stream-nonce-0001", nil }})
if err != nil {
t.Fatalf("run one-shot smoke: %v", err)
}
if result.ClaimedCount != 1 || result.CompletedCount != 1 || result.UnsupportedCount != 0 || result.SnapshotID != "health-snapshot-1" {
t.Fatalf("unexpected safe smoke result: %+v", result)
}
if len(result.CompletedCommandIDs) != 1 || result.CompletedCommandIDs[0] != "diagnostics-1" || len(result.UnsupportedCommandIDs) != 0 {
t.Fatalf("unexpected smoke IDs: %+v", result)
}
assertSafeSmokeOutput(t, result)
if step != 6 {
t.Fatalf("expected diagnostics ack/result plus snapshot, got %d requests", step)
}
}
func TestRunOneShotSmokeLeavesUnsupportedCommandUnackedAndUnexecuted(t *testing.T) {
stamp := time.Date(2026, 7, 20, 10, 0, 0, 0, time.UTC)
config := loadTestConfig(t)
sessionExpiry := stamp.Add(15 * time.Minute)
step := 0
transport := roundTripFunc(func(request *http.Request) (*http.Response, error) {
switch step {
case 0:
step++
return jsonHTTPResponse(http.StatusOK, registerResponse{Accepted: true, InstallationID: config.Component.InstallationID, SessionToken: "component-session-unsupported", ExpiresAt: sessionExpiry, HeartbeatEverySeconds: 30, ServerTime: stamp}), nil
case 1:
step++
return jsonHTTPResponse(http.StatusOK, heartbeatResponse{Accepted: true, InstallationID: config.Component.InstallationID, Status: "online", Health: "healthy", NextHeartbeatSeconds: 30, SessionExpiresAt: sessionExpiry, ServerTime: stamp}), nil
case 2:
step++
return jsonHTTPResponse(http.StatusOK, claimResponse{Items: []ClaimedCommand{{ID: "unsupported-1", ProfileKey: ProfileKey, CommandType: "diagnostic.unsupported", Payload: map[string]any{"message": "must not execute"}, FencingToken: 18, LeaseExpiresAt: stamp.Add(time.Minute), ExpiresAt: stamp.Add(5 * time.Minute)}}, Count: 1}), nil
default:
return nil, fmt.Errorf("unsupported command triggered transport: %s", request.URL.Path)
}
})
client := newTestClient(t, config, transport, stamp)
result, err := RunOneShotSmoke(context.Background(), client, SmokeOptions{IsolatedNonProduction: true, StreamSuffix: func() (string, error) { return "fixture-stream-nonce-0002", nil }})
if err == nil || !strings.Contains(err.Error(), "unsupported") {
t.Fatalf("expected unsupported smoke to stop, result=%+v err=%v", result, err)
}
if result.ClaimedCount != 1 || result.CompletedCount != 0 || result.UnsupportedCount != 1 || len(result.CompletedCommandIDs) != 0 || len(result.UnsupportedCommandIDs) != 1 || result.UnsupportedCommandIDs[0] != "unsupported-1" {
t.Fatalf("unsupported command did not fail safely: %+v", result)
}
assertSafeSmokeOutput(t, result)
if step != 3 {
t.Fatalf("expected no unsupported ack/result requests, got %d steps", step)
}
}
func TestRunOneShotSmokeRejectsNonSingletonClaimBeforeExecution(t *testing.T) {
stamp := time.Date(2026, 7, 20, 10, 30, 0, 0, time.UTC)
for _, test := range []struct {
name string
items []ClaimedCommand
}{
{name: "zero"},
{name: "multiple", items: []ClaimedCommand{{ID: "diagnostics-1"}, {ID: "diagnostics-2"}}},
} {
t.Run(test.name, func(t *testing.T) {
config := loadTestConfig(t)
step := 0
transport := roundTripFunc(func(request *http.Request) (*http.Response, error) {
step++
switch step {
case 1:
return jsonHTTPResponse(http.StatusOK, registerResponse{Accepted: true, InstallationID: config.Component.InstallationID, SessionToken: "component-session-singleton", ExpiresAt: stamp.Add(15 * time.Minute), HeartbeatEverySeconds: 30, ServerTime: stamp}), nil
case 2:
return jsonHTTPResponse(http.StatusOK, heartbeatResponse{Accepted: true, InstallationID: config.Component.InstallationID, Status: "online", Health: "healthy", NextHeartbeatSeconds: 30, SessionExpiresAt: stamp.Add(15 * time.Minute), ServerTime: stamp}), nil
case 3:
return jsonHTTPResponse(http.StatusOK, claimResponse{Items: test.items, Count: len(test.items)}), nil
default:
return nil, fmt.Errorf("non-singleton claim triggered execution: %s", request.URL.Path)
}
})
client := newTestClient(t, config, transport, stamp)
result, err := RunOneShotSmoke(context.Background(), client, SmokeOptions{IsolatedNonProduction: true, StreamSuffix: func() (string, error) { return "fixture-singleton-nonce", nil }})
if err == nil || !strings.Contains(err.Error(), "exactly one") || result.ClaimedCount != len(test.items) || step != 3 {
t.Fatalf("non-singleton claim was not rejected: result=%+v step=%d err=%v", result, step, err)
}
})
}
}
func TestValidateSmokeDiagnosticsCommandRejectsUnsafeClaims(t *testing.T) {
stamp := time.Date(2026, 7, 20, 11, 0, 0, 0, time.UTC)
valid := ClaimedCommand{ID: "diagnostics-1", ProfileKey: ProfileKey, CommandType: companionDiagnosticsCommandType, Payload: map[string]any{"includeWindowState": false, "maxEntries": float64(5)}, FencingToken: 7, LeaseExpiresAt: stamp.Add(time.Minute), ExpiresAt: stamp.Add(2 * time.Minute)}
tests := []struct {
name string
mutate func(*ClaimedCommand)
}{
{name: "profile", mutate: func(command *ClaimedCommand) { command.ProfileKey = "other" }},
{name: "fence", mutate: func(command *ClaimedCommand) { command.FencingToken = 0 }},
{name: "lease", mutate: func(command *ClaimedCommand) { command.LeaseExpiresAt = stamp }},
{name: "expiry", mutate: func(command *ClaimedCommand) { command.ExpiresAt = stamp.Add(-time.Second) }},
{name: "nil payload", mutate: func(command *ClaimedCommand) { command.Payload = nil }},
{name: "unknown payload", mutate: func(command *ClaimedCommand) { command.Payload["message"] = "not allowed" }},
{name: "payload type", mutate: func(command *ClaimedCommand) { command.Payload["maxEntries"] = 2.5 }},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
candidate := valid
candidate.Payload = map[string]any{"includeWindowState": false, "maxEntries": float64(5)}
test.mutate(&candidate)
if err := validateSmokeDiagnosticsCommand(candidate, stamp); err == nil {
t.Fatalf("expected unsafe diagnostics claim to be rejected: %+v", candidate)
}
})
}
}
func TestRunOneShotSmokeRequiresIsolatedNonProductionQueue(t *testing.T) {
result, err := RunOneShotSmoke(context.Background(), &Client{}, SmokeOptions{})
if err == nil || !strings.Contains(err.Error(), "isolated non-production") {
t.Fatalf("expected isolation gate, result=%+v err=%v", result, err)
}
}
func assertSafeSmokeOutput(t *testing.T, result SmokeResult) {
t.Helper()
serialized, err := json.Marshal(result)
if err != nil {
t.Fatalf("marshal smoke result: %v", err)
}
for _, forbidden := range []string{testProof, "component-session", "includeWindowState", "must not execute", "payload"} {
if strings.Contains(string(serialized), forbidden) {
t.Fatalf("smoke output exposed %q: %s", forbidden, serialized)
}
}
}
@@ -1,122 +0,0 @@
package companion
import (
"context"
"fmt"
"sort"
"strings"
"sync"
"time"
)
// SCUMPlayerLogProjection is plugin-owned business logic. It consumes the
// opaque log channel after the platform has forwarded it and publishes a typed
// players snapshot; Run and Platform never inspect the source text.
type SCUMPlayerLogProjection struct {
Client *Client
ServerInstanceID string
KeepForSeconds int
MaxRecords int
Now func() time.Time
SequenceStore SnapshotSequenceStore
mu sync.Mutex
players map[string]scumPlayerProjection
}
type scumPlayerProjection struct {
PlayerID string
PlayerName string
Status string
LastSeenAt time.Time
}
func NewSCUMPlayerLogProjection(client *Client, serverInstanceID string) *SCUMPlayerLogProjection {
return &SCUMPlayerLogProjection{Client: client, ServerInstanceID: serverInstanceID, KeepForSeconds: 86400, MaxRecords: 1000, Now: time.Now, SequenceStore: NewMemorySnapshotSequenceStore(), players: map[string]scumPlayerProjection{}}
}
func (projection *SCUMPlayerLogProjection) Handle(ctx context.Context, batch SemanticEventBatch) error {
if projection == nil || projection.Client == nil || strings.TrimSpace(projection.ServerInstanceID) == "" {
return fmt.Errorf("SCUM player log projection is not configured")
}
if batch.ServerID != projection.ServerInstanceID {
return fmt.Errorf("SCUM player log projection server scope mismatch")
}
now := time.Now
if projection.Now != nil {
now = projection.Now
}
observedAt := now().UTC()
projection.mu.Lock()
for _, event := range batch.Events {
playerID := strings.TrimSpace(event.PlayerID)
if playerID == "" {
continue
}
name := strings.TrimSpace(event.DisplayName)
if name == "" {
name = playerID
}
player := projection.players[playerID]
player.PlayerID = playerID
player.PlayerName = name
player.LastSeenAt = event.OccurredAt.UTC()
if player.LastSeenAt.IsZero() {
player.LastSeenAt = now().UTC()
}
switch event.Type {
case "scum.login":
player.Status = "online"
case "scum.logout":
player.Status = "offline"
default:
continue
}
projection.players[playerID] = player
}
if len(batch.Events) == 0 {
projection.mu.Unlock()
return nil
}
players := make([]scumPlayerProjection, 0, len(projection.players))
for _, player := range projection.players {
players = append(players, player)
}
store := projection.SequenceStore
if store == nil {
store = NewMemorySnapshotSequenceStore()
projection.SequenceStore = store
}
sequence, err := store.Next("players", "current", snapshotSequenceFloor(observedAt))
if err != nil {
projection.mu.Unlock()
return err
}
projection.mu.Unlock()
sort.Slice(players, func(i, j int) bool { return players[i].PlayerID < players[j].PlayerID })
payloadPlayers := make([]map[string]any, 0, len(players))
for _, player := range players {
payloadPlayers = append(payloadPlayers, map[string]any{
"playerId": player.PlayerID,
"playerName": player.PlayerName,
"status": player.Status,
"lastSeenAt": player.LastSeenAt.Format(time.RFC3339Nano),
})
}
_, err = projection.Client.UploadSnapshot(ctx, Snapshot{
Type: "players", SchemaVersion: "1", StreamKey: "current", Sequence: sequence,
ObservedAt: observedAt, Payload: map[string]any{"observedAt": observedAt.Format(time.RFC3339Nano), "players": payloadPlayers},
KeepForSeconds: projection.KeepForSeconds, MaxRecords: projection.MaxRecords,
})
return err
}
type noopConsoleLogStore struct{}
func (noopConsoleLogStore) EnsureSchema(context.Context) error { return nil }
func (noopConsoleLogStore) StoreConsoleRecords(context.Context, []ConsoleRecord) (int, error) {
return 0, nil
}
func (noopConsoleLogStore) StoreSemanticEventBatch(_ context.Context, batch SemanticEventBatch) (int, error) {
return len(batch.Events), nil
}
@@ -1,99 +0,0 @@
package companion
import (
"context"
"encoding/json"
"io"
"net/http"
"os"
"strings"
"testing"
"time"
)
func TestSCUMPlayerLogProjectionCreatesTypedUserSnapshot(t *testing.T) {
stamp := time.Date(2026, 9, 2, 2, 0, 0, 0, time.UTC)
config := loadTestConfig(t)
var snapshot Snapshot
client := newTestClient(t, config, roundTripFunc(func(request *http.Request) (*http.Response, error) {
if request.URL.Path != snapshotPath {
t.Fatalf("unexpected projection request path: %s", request.URL.Path)
}
var body snapshotRequest
if err := json.NewDecoder(request.Body).Decode(&body); err != nil {
t.Fatalf("decode snapshot request: %v", err)
}
snapshot = Snapshot{Type: body.Type, SchemaVersion: body.SchemaVersion, StreamKey: body.StreamKey, Sequence: body.Sequence, ObservedAt: body.ObservedAt, Payload: body.Payload, KeepForSeconds: body.KeepForSeconds, MaxRecords: body.MaxRecords}
return &http.Response{StatusCode: http.StatusAccepted, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"snapshotId":"snapshot-1","profileKey":"scum-client-manager","type":"players","schemaVersion":"1","streamKey":"current","sequence":1}`))}, nil
}), stamp)
client.mu.Lock()
client.sessionToken = "component-session"
client.sessionExpiresAt = stamp.Add(time.Hour)
client.mu.Unlock()
projection := NewSCUMPlayerLogProjection(client, "server-example")
projection.Now = func() time.Time { return stamp }
if err := projection.Handle(context.Background(), SemanticEventBatch{ServerID: "server-example", Events: []SemanticEvent{{ServerID: "server-example", Sequence: 7, Type: "scum.login", PlayerID: "76561198000000001", DisplayName: "Ada", OccurredAt: stamp}}}); err != nil {
t.Fatalf("project login event: %v", err)
}
players, ok := snapshot.Payload["players"].([]any)
if !ok || len(players) != 1 {
t.Fatalf("expected one projected player, payload=%#v", snapshot.Payload)
}
player, ok := players[0].(map[string]any)
if !ok || player["playerId"] != "76561198000000001" || player["playerName"] != "Ada" || player["status"] != "online" {
t.Fatalf("unexpected projected player: %#v", players[0])
}
}
func TestSCUMPlayerLogProjectionKeepsSequenceAcrossRestart(t *testing.T) {
stamp := time.Date(2026, 9, 2, 2, 0, 0, 0, time.UTC)
config := loadTestConfig(t)
sequences := make([]uint64, 0, 2)
client := newTestClient(t, config, roundTripFunc(func(request *http.Request) (*http.Response, error) {
if request.URL.Path != snapshotPath {
t.Fatalf("unexpected projection request path: %s", request.URL.Path)
}
var body snapshotRequest
if err := json.NewDecoder(request.Body).Decode(&body); err != nil {
t.Fatalf("decode snapshot request: %v", err)
}
sequences = append(sequences, body.Sequence)
return &http.Response{StatusCode: http.StatusAccepted, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"snapshotId":"snapshot","profileKey":"scum-client-manager","type":"players","schemaVersion":"1","streamKey":"current"}`))}, nil
}), stamp)
client.mu.Lock()
client.sessionToken = "component-session"
client.sessionExpiresAt = stamp.Add(time.Hour)
client.mu.Unlock()
originalDirectory, err := os.Getwd()
if err != nil {
t.Fatalf("get working directory: %v", err)
}
t.Chdir(t.TempDir())
t.Cleanup(func() { _ = os.Chdir(originalDirectory) })
store, err := NewFileSnapshotSequenceStore(SnapshotSequenceFilename)
if err != nil {
t.Fatalf("create sequence store: %v", err)
}
project := func(name string) error {
projection := NewSCUMPlayerLogProjection(client, "server-example")
projection.Now = func() time.Time { return stamp }
projection.SequenceStore = store
return projection.Handle(context.Background(), SemanticEventBatch{ServerID: "server-example", Events: []SemanticEvent{{ServerID: "server-example", Sequence: 1, Type: "scum.login", PlayerID: "76561198000000001", DisplayName: name, OccurredAt: stamp}}})
}
if err := project("Ada"); err != nil {
t.Fatalf("project before restart: %v", err)
}
restarted, err := NewFileSnapshotSequenceStore(SnapshotSequenceFilename)
if err != nil {
t.Fatalf("restart sequence store: %v", err)
}
store = restarted
if err := project("Ada Lovelace"); err != nil {
t.Fatalf("project after restart: %v", err)
}
if len(sequences) != 2 || sequences[1] <= sequences[0] {
t.Fatalf("expected restart-safe monotonic snapshots, got %v", sequences)
}
}
@@ -1,166 +0,0 @@
package companion
import (
"encoding/json"
"fmt"
"math"
"os"
"path/filepath"
"strings"
"sync"
"time"
)
const SnapshotSequenceFilename = "snapshot-sequences.json"
// SnapshotSequenceStore is plugin-local state used only to keep the plugin's
// typed snapshot stream monotonic. It never stores or examines raw log text.
type SnapshotSequenceStore interface {
Next(snapshotType string, streamKey string, floor uint64) (uint64, error)
}
type MemorySnapshotSequenceStore struct {
mu sync.Mutex
sequences map[string]uint64
}
func NewMemorySnapshotSequenceStore() *MemorySnapshotSequenceStore {
return &MemorySnapshotSequenceStore{sequences: map[string]uint64{}}
}
func (store *MemorySnapshotSequenceStore) Next(snapshotType string, streamKey string, floor uint64) (uint64, error) {
if store == nil {
return 0, fmt.Errorf("snapshot sequence store is not configured")
}
key, err := snapshotSequenceKey(snapshotType, streamKey)
if err != nil {
return 0, err
}
store.mu.Lock()
defer store.mu.Unlock()
next, err := nextSnapshotSequence(store.sequences[key], floor)
if err != nil {
return 0, err
}
store.sequences[key] = next
return next, nil
}
type FileSnapshotSequenceStore struct {
filename string
mu sync.Mutex
}
type snapshotSequenceDocument struct {
SchemaVersion int `json:"schemaVersion"`
Sequences map[string]uint64 `json:"sequences"`
}
func NewFileSnapshotSequenceStore(filename string) (*FileSnapshotSequenceStore, error) {
filename = strings.TrimSpace(filename)
if filename == "" || filepath.Base(filename) != filename || filename == "." {
return nil, fmt.Errorf("snapshot sequence filename is invalid")
}
return &FileSnapshotSequenceStore{filename: filename}, nil
}
func (store *FileSnapshotSequenceStore) Next(snapshotType string, streamKey string, floor uint64) (uint64, error) {
if store == nil {
return 0, fmt.Errorf("snapshot sequence store is not configured")
}
key, err := snapshotSequenceKey(snapshotType, streamKey)
if err != nil {
return 0, err
}
store.mu.Lock()
defer store.mu.Unlock()
document, err := store.read()
if err != nil {
return 0, err
}
next, err := nextSnapshotSequence(document.Sequences[key], floor)
if err != nil {
return 0, err
}
document.Sequences[key] = next
if err := store.write(document); err != nil {
return 0, err
}
return next, nil
}
func (store *FileSnapshotSequenceStore) read() (snapshotSequenceDocument, error) {
document := snapshotSequenceDocument{SchemaVersion: 1, Sequences: map[string]uint64{}}
body, err := os.ReadFile(store.filename)
if os.IsNotExist(err) {
return document, nil
}
if err != nil {
return snapshotSequenceDocument{}, fmt.Errorf("read snapshot sequences: %w", err)
}
if err := json.Unmarshal(body, &document); err != nil {
return snapshotSequenceDocument{}, fmt.Errorf("decode snapshot sequences: %w", err)
}
if document.SchemaVersion != 1 || document.Sequences == nil {
return snapshotSequenceDocument{}, fmt.Errorf("snapshot sequence state is invalid")
}
return document, nil
}
func (store *FileSnapshotSequenceStore) write(document snapshotSequenceDocument) error {
body, err := json.Marshal(document)
if err != nil {
return err
}
temporary, err := os.CreateTemp(".", ".snapshot-sequences-")
if err != nil {
return fmt.Errorf("create snapshot sequence state: %w", err)
}
temporaryName := temporary.Name()
defer os.Remove(temporaryName)
if _, err := temporary.Write(body); err != nil {
temporary.Close()
return fmt.Errorf("write snapshot sequence state: %w", err)
}
if err := temporary.Chmod(0o600); err != nil {
temporary.Close()
return fmt.Errorf("protect snapshot sequence state: %w", err)
}
if err := temporary.Close(); err != nil {
return fmt.Errorf("close snapshot sequence state: %w", err)
}
if err := os.Rename(temporaryName, store.filename); err != nil {
return fmt.Errorf("commit snapshot sequence state: %w", err)
}
return nil
}
func snapshotSequenceKey(snapshotType string, streamKey string) (string, error) {
snapshotType = strings.TrimSpace(snapshotType)
streamKey = strings.TrimSpace(streamKey)
if snapshotType == "" || streamKey == "" || len(snapshotType) > 80 || len(streamKey) > 80 {
return "", fmt.Errorf("snapshot stream identity is invalid")
}
return snapshotType + "\x00" + streamKey, nil
}
func nextSnapshotSequence(current uint64, floor uint64) (uint64, error) {
if current == math.MaxUint64 {
return 0, fmt.Errorf("snapshot sequence is exhausted")
}
next := current + 1
if floor > next {
next = floor
}
if next == 0 {
return 0, fmt.Errorf("snapshot sequence is exhausted")
}
return next, nil
}
func snapshotSequenceFloor(observedAt time.Time) uint64 {
if observedAt.IsZero() || observedAt.UnixNano() <= 0 {
return 1
}
return uint64(observedAt.UnixNano())
}
@@ -1,31 +0,0 @@
package companion
import "testing"
func TestFileSnapshotSequenceStorePersistsMonotonicSequence(t *testing.T) {
t.Chdir(t.TempDir())
store, err := NewFileSnapshotSequenceStore(SnapshotSequenceFilename)
if err != nil {
t.Fatalf("create sequence store: %v", err)
}
first, err := store.Next("players", "current", 100)
if err != nil || first != 100 {
t.Fatalf("reserve first sequence: sequence=%d err=%v", first, err)
}
restarted, err := NewFileSnapshotSequenceStore(SnapshotSequenceFilename)
if err != nil {
t.Fatalf("restart sequence store: %v", err)
}
second, err := restarted.Next("players", "current", 1)
if err != nil || second != 101 {
t.Fatalf("reserve persisted sequence: sequence=%d err=%v", second, err)
}
}
func TestFileSnapshotSequenceStoreRejectsHostPaths(t *testing.T) {
for _, filename := range []string{"../snapshot-sequences.json", "/tmp/snapshot-sequences.json", ""} {
if _, err := NewFileSnapshotSequenceStore(filename); err == nil {
t.Fatalf("expected invalid filename rejection: %q", filename)
}
}
}
@@ -1,23 +0,0 @@
import type { SCUMCommandResult, SCUMConfigPatch, SCUMConfigRead, SCUMFeatureAvailability, SCUMFeatureKey, SCUMStatePatch, SCUMStateSnapshot, SCUMVehicleSpawn } from "./contracts.js";
import { validateConfigPatch, validateStatePatch, validateVehicleSpawn } from "./schemas.js";
export type PluginFeatureBridge = { dispatch(action: "game-client.command", payload: Record<string, string>): Promise<{ status: string; result?: Record<string, string>; error?: { message: string } }> };
export type SCUMFeatureAPI = {
availability(feature: SCUMFeatureKey): Promise<SCUMFeatureAvailability>; readConfig(): Promise<SCUMConfigRead | null>; patchConfig(patch: SCUMConfigPatch): Promise<SCUMCommandResult>;
stateSnapshot(playerId: string): Promise<SCUMStateSnapshot | null>; requestStatePatch(patch: SCUMStatePatch): Promise<SCUMCommandResult>; requestVehicleSpawn(spawn: SCUMVehicleSpawn): Promise<SCUMCommandResult>;
};
export function createSCUMFeatureAPI(bridge: PluginFeatureBridge, availableFeatures: readonly SCUMFeatureAvailability[]): SCUMFeatureAPI {
const availability = async (feature: SCUMFeatureKey) => availableFeatures.find((item) => item.feature === feature) ?? { feature, available: false, reason: "插件未声明此功能。" };
return {
availability,
async readConfig() { const result = await bridge.dispatch("game-client.command", { type: "config.read" }); return result.status === "ok" ? decode<SCUMConfigRead>(result.result) : null; },
async patchConfig(patch) { const error = validateConfigPatch(patch); if (error) return { status: "validation-failed", summary: error }; return commandResult(await bridge.dispatch("game-client.command", { type: "config.patch", patch: JSON.stringify(patch) })); },
async stateSnapshot(playerId) { const result = await bridge.dispatch("game-client.command", { type: "player.lookup", playerId }); return result.status === "ok" ? decode<SCUMStateSnapshot>(result.result) : null; },
async requestStatePatch(patch) { const error = validateStatePatch(patch.changes); if (error) return { status: "validation-failed", summary: error }; return commandResult(await bridge.dispatch("game-client.command", { type: "game-state.patch", patch: JSON.stringify(patch) })); },
async requestVehicleSpawn(spawn) { const error = validateVehicleSpawn(spawn); if (error) return { status: "validation-failed", summary: error }; return commandResult(await bridge.dispatch("game-client.command", { type: "vehicle.spawn", vehicleCode: spawn.vehicleCode })); }
};
}
function commandResult(result: { status: string; result?: Record<string, string>; error?: { message: string } }): SCUMCommandResult { if (result.status === "queued") return { status: "queued", summary: result.result?.summary ?? "已进入任务队列。" }; if (result.status === "unsupported") return { status: "unsupported", summary: result.error?.message ?? "当前运行时不支持此操作。" }; return { status: "failed", summary: result.error?.message ?? "操作未被接受。" }; }
function decode<T>(result: Record<string, string> | undefined): T | null { const payload = result?.payload; if (!payload) return null; try { return JSON.parse(payload) as T; } catch { return null; } }
@@ -6,8 +6,8 @@ export function pluginOwned<T extends Record<string, unknown>>(payload: T, recor
// The authority flag is exact-server. Missing, duplicate, or
// transitional flags fail closed: history remains readable, but plugin writes
// are not enabled. Execution still additionally requires Companion feature
// availability; this flag never authorizes a command by itself.
// are not enabled. Execution still additionally requires the plugin-declared
// feature surface to be available; this flag never authorizes a command by itself.
export function migrationStatus(flags: readonly SCUMFeatureMigrationAuthority[], serverInstanceId: string, feature: SCUMFeatureKey): SCUMFeatureMigrationStatus {
const matches = flags.filter((flag) => flag.serverInstanceId === serverInstanceId && flag.feature === feature);
if (matches.length !== 1) return { authority: "transitional-read-only", readOnlyHistory: true, pluginWritesEnabled: false, reason: matches.length ? "迁移标记冲突,已保持只读。" : "当前服务器尚未启用插件权威记录。" };
@@ -8,15 +8,6 @@ export type PluginDataActions = {
transact: (collection: string, mutations: PluginDataMutation[]) => Promise<unknown>;
};
export type PluginGameClientQueueRequest = {
profileKey: string;
commandType: string;
payload: RecordMap;
idempotencyKey: string;
priority?: number;
expiresAt: string;
};
export type PluginBridgeExecuteEnvelope = { requestId: string; action: string; payload?: Record<string, string> };
export type PluginBridgeExecutionResult = { status?: string; result?: Record<string, string>; error?: { message?: string } };
@@ -82,12 +73,6 @@ export async function queuePlayerAttributePatch(actions: SCUMWorkspaceActions, p
export type SCUMWorkspaceActions = {
pluginData?: PluginDataActions;
gameClient?: {
queue: (request: PluginGameClientQueueRequest) => Promise<unknown>;
get: (commandId: string) => Promise<unknown>;
list: (filter?: { profileKey?: string; state?: string; commandType?: string }) => Promise<unknown>;
snapshots: (query?: { profileKey?: string; type?: string; streamKey?: string; observedAfter?: string; limit?: number }) => Promise<unknown>;
};
dispatch?: (envelope: PluginBridgeExecuteEnvelope, signal?: AbortSignal) => Promise<PluginBridgeExecutionResult>;
};
@@ -163,14 +148,6 @@ export async function loadSCUMSurface(actions: SCUMWorkspaceActions, pageKey: st
const keys = pageCollections[canonicalPageKey(pageKey)];
const records = await Promise.all(keys.map(async (key) => [key, await actions.pluginData!.list(scumCollections[key])] as const));
for (const [key, response] of records) data[key] = collectionRecords(response);
if (keys.includes("players") && actions.gameClient) {
const playersSnapshot = await actions.gameClient.snapshots({ profileKey: "scum-client-manager", type: "players", streamKey: "current", limit: 1 }).catch(() => undefined);
data.players = mergePlayerSnapshots(data.players, playersSnapshot);
}
if (keys.includes("vehicles") && actions.gameClient) {
const vehiclesSnapshot = await actions.gameClient.snapshots({ profileKey: "scum-client-manager", type: "vehicles", streamKey: "current", limit: 1 }).catch(() => undefined);
data.vehicles = mergeVehicleSnapshots(data.vehicles, vehiclesSnapshot);
}
return data;
}
@@ -238,7 +215,7 @@ export async function createGiftDelivery(actions: SCUMWorkspaceActions, delivery
}
export async function queueGiftDelivery(actions: SCUMWorkspaceActions, gift: RecordMap, player: RecordMap): Promise<unknown> {
if (!actions.gameClient) throw new Error("通用 gameClient 能力不可用。");
if (!actions.dispatch) throw new Error("通用 remote.access.request 能力不可用,无法通过 SCUM RCON 发放礼包。");
const giftCode = requiredKey(gift, "code", "礼包编号");
const playerId = firstText(player, "gamePlayerId", "playerId", "steamId", "id");
if (!playerId) throw new Error("用户编号不能为空。");
@@ -247,16 +224,15 @@ export async function queueGiftDelivery(actions: SCUMWorkspaceActions, gift: Rec
if (!items.length && !operations.length) throw new Error("礼包必须包含物品或命令。");
const now = Date.now();
const grantId = safeCommandId(`gift:${giftCode}:${playerId}:${now}`);
const command = await actions.gameClient.queue({
profileKey: "scum-client-manager",
commandType: "reward.deliver",
payload: { grantId, playerId, items, operations },
idempotencyKey: grantId,
expiresAt: new Date(now + 5 * 60_000).toISOString()
});
const record = { id: grantId, giftCode, giftName: textValue(gift.name), playerId, playerName: firstText(player, "displayName", "playerName", "name"), status: "queued", commandId: isRecord(command) ? textValue(command.id) : "", createdAt: new Date(now).toISOString() };
const commands = [...items.map((item) => `#SpawnItem ${item.catalogCode} ${item.quantity}`), ...operations].map(normalizeRCONCommand);
const results = [];
for (const [index, command] of commands.entries()) {
results.push(await dispatchSCUMRCONCommand(actions, command, `${grantId}:${index + 1}`));
}
const commandIds = results.map((result) => textValue(result.result?.jobId)).filter(Boolean);
const record = { id: grantId, giftCode, giftName: textValue(gift.name), playerId, playerName: firstText(player, "displayName", "playerName", "name"), status: "queued", commandId: commandIds[0] ?? "", commandIds, createdAt: new Date(now).toISOString() };
await createGiftDelivery(actions, record);
return command;
return { status: "queued", result: { jobIds: commandIds.join(",") } };
}
export async function saveEventDefinition(actions: SCUMWorkspaceActions, event: RecordMap): Promise<unknown> {
@@ -282,28 +258,15 @@ export async function deleteEventProduce(actions: SCUMWorkspaceActions, produce:
}
export async function startEvent(actions: SCUMWorkspaceActions, event: RecordMap, produces: RecordMap[] = []): Promise<unknown> {
if (!actions.gameClient) throw new Error("通用 gameClient 能力不可用。");
if (!actions.dispatch) throw new Error("通用 remote.access.request 能力不可用,无法通过 SCUM RCON 启动活动。");
const eventId = requiredKey(event, "id", "活动编号");
const eventClass = Number(event.class) === 2 || firstText(event, "eventType") === "fixed" ? 2 : 1;
const eventType = eventClass === 2 ? "fixed" : "range";
const queuedProduces = normalizeEventProduces(produces);
const command = firstText(event, "rconCommand", "command");
if (!command) throw new Error("活动未声明可执行的 SCUM RCON 命令。");
const now = Date.now();
const runId = safeCommandId(`event:${eventId}:${now}`);
const command = await actions.gameClient.queue({
profileKey: "scum-client-manager",
commandType: "event.start",
payload: {
eventId, eventType, class: eventClass, title: textValue(event.name) || eventId,
placard: firstText(event, "placard", "announcement"), percent: boundedInteger(event.percent ?? event.probability, 0, 100, 100),
npc: minimumInteger(event.npc, 0, 0), item: minimumInteger(event.item, 0, 0), zombie: minimumInteger(event.zombie, 0, 0), animal: minimumInteger(event.animal, 0, 0),
produces: queuedProduces,
durationSeconds: minimumInteger(event.durationSeconds, 1, 1800), announce: event.announce !== false
},
idempotencyKey: runId,
expiresAt: new Date(now + 5 * 60_000).toISOString()
});
await requirePluginData(actions).put(scumCollections.eventRuns, runId, { id: runId, eventId, eventName: textValue(event.name), status: "queued", commandId: isRecord(command) ? textValue(command.id) : "", definition: event, produces, startedAt: new Date(now).toISOString() });
return command;
const dispatched = await dispatchSCUMRCONCommand(actions, normalizeRCONCommand(command), runId);
await requirePluginData(actions).put(scumCollections.eventRuns, runId, { id: runId, eventId, eventName: textValue(event.name), status: "queued", commandId: textValue(dispatched.result?.jobId), definition: event, produces, startedAt: new Date(now).toISOString() });
return dispatched;
}
export function parseGiftItems(input: string): Array<{ catalogCode: string; quantity: number }> {
@@ -414,13 +377,6 @@ function requiredKey(value: RecordMap, key: string, label: string): string {
function requiredRecordKey(value: RecordMap, label: string): string { const key = firstText(value, "_recordKey", "id", "produceId"); if (!key) throw new Error(`${label}编号不能为空。`); return key.includes(":") ? key : `${firstText(value, "eventId", "event")}:${key}`; }
function boundedInteger(value: unknown, min: number, max: number, fallback: number): number {
const number = Number(value);
return Number.isSafeInteger(number) && number >= min && number <= max ? number : fallback;
}
function minimumInteger(value: unknown, min: number, fallback: number): number { const number = Number(value); return Number.isSafeInteger(number) && number >= min ? number : fallback; }
function normalizeGiftItems(value: unknown): Array<{ catalogCode: string; quantity: number }> {
if (value === undefined || value === null) return [];
if (!Array.isArray(value)) throw new Error("礼包物品格式无效。");
@@ -435,20 +391,30 @@ function normalizeGiftItems(value: unknown): Array<{ catalogCode: string; quanti
function normalizeGiftOperations(value: unknown): string[] { if (value === undefined || value === null) return []; if (!Array.isArray(value)) throw new Error("礼包命令格式无效。"); return value.map((item) => { const command = isRecord(item) ? firstText(item, "command", "value") : textValue(item); if (!command.trim()) throw new Error("礼包命令不能为空。"); return command.trim(); }); }
function normalizeEventProduces(produces: RecordMap[]): RecordMap[] {
return produces.map((produce) => ({
tradeGoodsId: firstText(produce, "tradeGoodsId"),
percent: boundedInteger(produce.percent, 0, 100, 100),
value: minimumInteger(produce.value, 1, 1),
r: minimumNumber(produce.r, 0, 0),
x: finiteNumber(produce.x, 0),
y: finiteNumber(produce.y, 0),
z: finiteNumber(produce.z, 0)
}));
function normalizeRCONCommand(command: string): string {
const normalized = command.trim();
if (!normalized || /[\r\n]/.test(normalized)) throw new Error("SCUM RCON 命令必须是单行文本。");
return normalized;
}
function minimumNumber(value: unknown, min: number, fallback: number): number { const number = Number(value); return Number.isFinite(number) && number >= min ? number : fallback; }
function finiteNumber(value: unknown, fallback: number): number { const number = Number(value); return Number.isFinite(number) ? number : fallback; }
async function dispatchSCUMRCONCommand(actions: SCUMWorkspaceActions, command: string, idempotencyKey: string): Promise<PluginBridgeExecutionResult> {
if (!actions.dispatch) throw new Error("通用 remote.access.request 能力不可用。");
const result = await actions.dispatch({
requestId: idempotencyKey,
action: "remote.access.request",
payload: {
capability: "remote.run.rcon.command",
declarationKey: "scum-management",
targetKey: "scum-management",
idempotencyKey,
timeoutSeconds: "30",
maxAttempts: "1",
"input.command": command
}
});
if (result.status && !["queued", "ok"].includes(result.status)) throw new Error(result.error?.message || "SCUM RCON 命令未进入 Run 队列。");
return result;
}
function safeCommandId(value: string): string { return value.replace(/[^A-Za-z0-9_.:-]/g, "-").slice(0, 96); }
function firstText(value: RecordMap, ...keys: string[]): string { for (const key of keys) { const result = textValue(value[key]); if (result) return result; } return ""; }
@@ -84,6 +84,7 @@ export function renderSCUMFeaturePage(react: ReactLike, input: SCUMPageContext)
const [eventName, setEventName] = usePluginState(react, "");
const [eventType, setEventType] = usePluginState(react, "range");
const [eventSchedule, setEventSchedule] = usePluginState(react, "");
const [eventCommand, setEventCommand] = usePluginState(react, "");
const [eventClass, setEventClass] = usePluginState(react, "1");
const [eventPlacard, setEventPlacard] = usePluginState(react, "");
const [eventPercent, setEventPercent] = usePluginState(react, "100");
@@ -152,7 +153,7 @@ export function renderSCUMFeaturePage(react: ReactLike, input: SCUMPageContext)
state.status === "ready" ? renderSurfaceBody(e, pageKey, data, input, {
playerSearch, setPlayerSearch, playerStatus, setPlayerStatus, playerPanel, setPlayerPanel, attributeDrafts, setAttributeDrafts, squadSearch, setSquadSearch, selectedSquadId, setSelectedSquadId,
activityStatus, setActivityStatus, eventId, setEventId, eventName, setEventName, eventType, setEventType, eventSchedule, setEventSchedule,
eventClass, setEventClass, eventPlacard, setEventPlacard, eventPercent, setEventPercent, eventDuration, setEventDuration, eventNpc, setEventNpc, eventItem, setEventItem, eventZombie, setEventZombie, eventAnimal, setEventAnimal,
eventCommand, setEventCommand, eventClass, setEventClass, eventPlacard, setEventPlacard, eventPercent, setEventPercent, eventDuration, setEventDuration, eventNpc, setEventNpc, eventItem, setEventItem, eventZombie, setEventZombie, eventAnimal, setEventAnimal,
produceEventId, setProduceEventId, produceId, setProduceId, produceTradeGoodsId, setProduceTradeGoodsId, producePercent, setProducePercent, produceValue, setProduceValue, produceRadius, setProduceRadius, produceX, setProduceX, produceY, setProduceY, produceZ, setProduceZ,
giftTab, setGiftTab, giftCode, setGiftCode, giftName, setGiftName, giftItems, setGiftItems, giftCommands, setGiftCommands, giftClass, setGiftClass, giftAudience, setGiftAudience, giftNumber, setGiftNumber, giftAchievement, setGiftAchievement, giftAchievementNumber, setGiftAchievementNumber,
deliveryGift, setDeliveryGift, deliveryPlayer, setDeliveryPlayer, mapSearch, setMapSearch, mapLayers, setMapLayers, selectedMapPoint, setSelectedMapPoint,
@@ -170,6 +171,7 @@ type ViewState = {
activityStatus: string; setActivityStatus: StateSetter<string>; giftTab: GiftTab; setGiftTab: StateSetter<GiftTab>;
eventId: string; setEventId: StateSetter<string>; eventName: string; setEventName: StateSetter<string>;
eventType: string; setEventType: StateSetter<string>; eventSchedule: string; setEventSchedule: StateSetter<string>;
eventCommand: string; setEventCommand: StateSetter<string>;
eventClass: string; setEventClass: StateSetter<string>; eventPlacard: string; setEventPlacard: StateSetter<string>; eventPercent: string; setEventPercent: StateSetter<string>;
eventDuration: string; setEventDuration: StateSetter<string>;
eventNpc: string; setEventNpc: StateSetter<string>; eventItem: string; setEventItem: StateSetter<string>; eventZombie: string; setEventZombie: StateSetter<string>; eventAnimal: string; setEventAnimal: StateSetter<string>;
@@ -368,6 +370,7 @@ function activitiesSurface(e: ReactLike["createElement"], data: SCUMSurfaceData,
if (!id || !name) throw new Error("活动编号和名称不能为空。");
await saveEventDefinition(actions ?? {}, {
id, name, eventType: view.eventClass === "2" ? "fixed" : "range", class: integerInput(view.eventClass, 1), schedule: view.eventSchedule.trim(), corn: view.eventSchedule.trim(),
rconCommand: view.eventCommand.trim(), command: view.eventCommand.trim(),
placard: view.eventPlacard.trim(), announcement: view.eventPlacard.trim(), percent: integerInput(view.eventPercent, 100), probability: integerInput(view.eventPercent, 100),
npc: minimumIntegerInput(view.eventNpc, 0, "NPC 数量"), item: minimumIntegerInput(view.eventItem, 0, "物品数量"), zombie: minimumIntegerInput(view.eventZombie, 0, "僵尸数量"), animal: minimumIntegerInput(view.eventAnimal, 0, "动物数量"),
status: "enabled", announce: Boolean(view.eventPlacard.trim()), durationSeconds: minimumIntegerInput(view.eventDuration, 1, "活动持续秒数"), updatedAt: new Date().toISOString()
@@ -395,6 +398,7 @@ function activitiesSurface(e: ReactLike["createElement"], data: SCUMSurfaceData,
labeledField(e, "活动名称", e("input", { value: view.eventName, "aria-label": "活动名称", placeholder: "例如 空投补给", onChange: (event: InputEvent) => view.setEventName(inputValue(event)) })),
labeledField(e, "生成类型", e("select", { value: view.eventClass, "aria-label": "生成类型", onChange: (event: InputEvent) => view.setEventClass(inputValue(event)) }, e("option", { value: "1" }, "范围生成"), e("option", { value: "2" }, "固定坐标生成"))),
labeledField(e, "活动计划", e("input", { value: view.eventSchedule, "aria-label": "活动计划", placeholder: "Cron(留空为手动)", onChange: (event: InputEvent) => view.setEventSchedule(inputValue(event)) })),
labeledField(e, "RCON 命令", e("input", { value: view.eventCommand, "aria-label": "RCON 命令", placeholder: "例如 #start_event event_supply_drop", onChange: (event: InputEvent) => view.setEventCommand(inputValue(event)) })),
labeledField(e, "活动公告", e("input", { value: view.eventPlacard, "aria-label": "活动公告", placeholder: "活动开始时显示的公告", onChange: (event: InputEvent) => view.setEventPlacard(inputValue(event)) })),
labeledField(e, "触发概率 (%)", e("input", { value: view.eventPercent, "aria-label": "活动概率", type: "number", placeholder: "100", onChange: (event: InputEvent) => view.setEventPercent(inputValue(event)) })),
labeledField(e, "持续秒数", e("input", { value: view.eventDuration, "aria-label": "活动持续秒数", type: "number", min: 1, placeholder: "1800", onChange: (event: InputEvent) => view.setEventDuration(inputValue(event)) })),
@@ -438,8 +442,8 @@ function activitiesSurface(e: ReactLike["createElement"], data: SCUMSurfaceData,
e("div", { className: "console-record-head" }, e("strong", null, textField(event, "name", "title") || eventId || "未命名活动"), e("span", { className: `status-pill ${activeStatus(status) ? "status-active" : "status-disabled"}` }, status)),
e("div", { className: "console-record-meta" }, e("span", null, `生成 ${Number(field(event, "class")) === 2 ? "固定坐标" : "范围"}`), e("span", null, `计划 ${textField(event, "schedule", "corn") || "手动"}`), e("span", null, `概率 ${numField(event, "percent", "probability")}%`), e("span", null, `NPC/物品/僵尸/动物 ${numField(event, "npc")}/${numField(event, "item")}/${numField(event, "zombie")}/${numField(event, "animal")}`), e("span", null, textField(event, "placard", "announcement") || "无公告")),
e("div", { className: "console-row-actions" },
e("button", { type: "button", className: "primary-command", disabled: !actions?.gameClient || !actions?.pluginData, onClick: () => runAction(view.setAction, "正在启动活动…", async () => { await startEvent(actions ?? {}, event, data.eventProduces.filter((produce) => textField(produce, "eventId", "event") === eventId)); view.refresh(); return "活动命令已进入执行队列。"; }) }, "立即启动"),
e("button", { type: "button", className: "icon-command", onClick: () => { view.setEventEditorOpen(true); view.setEventId(eventId); view.setEventName(textField(event, "name")); view.setEventClass(numField(event, "class") === "--" ? "1" : numField(event, "class")); view.setEventSchedule(textField(event, "schedule", "corn")); view.setEventPlacard(textField(event, "placard", "announcement")); view.setEventPercent(numField(event, "percent", "probability")); view.setEventDuration(numField(event, "durationSeconds") === "--" ? "1800" : numField(event, "durationSeconds")); view.setEventNpc(numField(event, "npc")); view.setEventItem(numField(event, "item")); view.setEventZombie(numField(event, "zombie")); view.setEventAnimal(numField(event, "animal")); } }, "编辑"),
e("button", { type: "button", className: "primary-command", disabled: !actions?.dispatch || !actions?.pluginData, onClick: () => runAction(view.setAction, "正在启动活动…", async () => { await startEvent(actions ?? {}, event, data.eventProduces.filter((produce) => textField(produce, "eventId", "event") === eventId)); view.refresh(); return "活动命令已进入执行队列。"; }) }, "立即启动"),
e("button", { type: "button", className: "icon-command", onClick: () => { view.setEventEditorOpen(true); view.setEventId(eventId); view.setEventName(textField(event, "name")); view.setEventClass(numField(event, "class") === "--" ? "1" : numField(event, "class")); view.setEventSchedule(textField(event, "schedule", "corn")); view.setEventCommand(textField(event, "rconCommand", "command")); view.setEventPlacard(textField(event, "placard", "announcement")); view.setEventPercent(numField(event, "percent", "probability")); view.setEventDuration(numField(event, "durationSeconds") === "--" ? "1800" : numField(event, "durationSeconds")); view.setEventNpc(numField(event, "npc")); view.setEventItem(numField(event, "item")); view.setEventZombie(numField(event, "zombie")); view.setEventAnimal(numField(event, "animal")); } }, "编辑"),
e("button", { type: "button", className: "icon-command", disabled: !actions?.pluginData, onClick: () => runAction(view.setAction, "正在删除活动…", async () => { await deleteEventDefinition(actions ?? {}, eventId, data.eventProduces); view.refresh(); return "活动定义已删除。"; }) }, "删除")
)
);
@@ -520,7 +524,7 @@ function giftsSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, inpu
e("article", { className: "console-module" }, e("h2", null, "创建发放记录"),
labeledField(e, "礼包", e("select", { value: view.deliveryGift, "aria-label": "选择礼包", onChange: (event: InputEvent) => view.setDeliveryGift(inputValue(event)) }, e("option", { value: "" }, "选择礼包"), data.gifts.map((gift, index) => { const key = textField(gift, "code", "id"); return e("option", { key: idOf(gift, `gift-option-${index}`), value: key }, textField(gift, "name") || key); }))),
labeledField(e, "用户", e("select", { value: view.deliveryPlayer, "aria-label": "选择用户", onChange: (event: InputEvent) => view.setDeliveryPlayer(inputValue(event)) }, e("option", { value: "" }, "选择用户"), data.players.map((player, index) => { const key = textField(player, "gamePlayerId", "steamId", "id"); return e("option", { key: idOf(player, `player-option-${index}`), value: key }, textField(player, "displayName") || key); }))),
e("button", { type: "button", className: "primary-command", disabled: !actions?.pluginData || !actions?.gameClient, onClick: queueDelivery }, "立即发放")
e("button", { type: "button", className: "primary-command", disabled: !actions?.pluginData || !actions?.dispatch, onClick: queueDelivery }, "立即发放")
),
tablePanel(e, "发放记录", data.giftDeliveries, (delivery) => [textField(delivery, "playerName", "playerId") || "unknown", textField(delivery, "giftName", "giftCode") || "unknown", textField(delivery, "status") || "unknown", dateField(delivery, "deliveredAt", "createdAt")])
) : null,
@@ -1,7 +1,7 @@
import type { SCUMConfigField, SCUMConfigPatch, SCUMFeatureAvailability, SCUMStateField, SCUMVehicleSpawn, SCUMVehicleSpawnOption } from "./contracts.js";
// These are safe fallback plugin catalogs. A Companion schema probe may narrow them
// per server, but a game version never enables or disables a feature.
// These are safe fallback plugin catalogs. Plugin-owned declarations may narrow
// them per server, but a game version never enables or disables a feature.
export const configurationCatalog: readonly SCUMConfigField[] = [
{ key: "server-name", fileKey: "scum-server-settings", configKey: "ServerName", label: "服务器名称", description: "显示在服务器浏览器与玩家连接界面。", control: "text", defaultValue: "SCUM Server", restartImpact: "restart-required" },
{ key: "game-port", fileKey: "scum-server-settings", configKey: "GamePort", label: "游戏端口", description: "玩家连接所使用的游戏端口。", control: "port", minimum: 1, maximum: 65535, defaultValue: "7779", restartImpact: "restart-required" },
@@ -2,7 +2,7 @@
"$schema": "../../manifests/game-plugin.manifest.schema.json",
"id": "game.scum",
"name": "SCUM Server",
"description": "First-party SCUM game server operations plugin with platform-mediated lifecycle and companion bridge support.",
"description": "First-party SCUM game server operations plugin with platform-mediated lifecycle and plugin-owned RCON data flows.",
"version": "0.1.15",
"kind": "game-plugin",
"tags": [
@@ -10,7 +10,7 @@
"survival",
"dedicated-server",
"game-operations",
"companion-client"
"rcon"
],
"server": {
"type": "scum",
@@ -77,11 +77,6 @@
"remote.run.logs.transfer",
"remote.run.rcon.command",
"remote.run.program.command",
"client-manager.deploy",
"client-manager.control",
"client-manager.update",
"client-manager.rollback",
"client-manager.uninstall",
"artifacts.read",
"artifacts.write",
"ai.invoke"
@@ -118,21 +113,11 @@
"ai.invoke",
"run.distribution.request",
"dependencies.request",
"client-manager.request",
"plugin-lifecycle.request"
]
},
"gameClientBridge": {
"commands": [
{
"type": "companion.diagnostics",
"title": "Collect companion diagnostics",
"permission": "server.game-client.read",
"payloadSchemaRef": "schemas/bridge/diagnostics.payload.schema.json",
"resultSchemaRef": "schemas/bridge/diagnostics.result.schema.json",
"timeoutSeconds": 30,
"maxPayloadBytes": 2048
},
{
"type": "player.lookup",
"title": "Look up SCUM player",
@@ -198,13 +183,6 @@
}
],
"snapshots": [
{
"type": "companion.health",
"schemaVersion": "1",
"schemaRef": "schemas/bridge/companion-health.snapshot.schema.json",
"keepForSeconds": 604800,
"maxRecords": 1000
},
{
"type": "online.sessions",
"schemaVersion": "1",
@@ -755,22 +733,7 @@
"player.intelligence"
]
}
],
"companion": {
"profileKey": "scum-client-manager",
"configTemplateKey": "client-config",
"configSchemaRef": "schemas/companion/config.schema.json",
"configFormat": "yaml",
"platformBaseUrlSource": "run-control",
"registrationProof": "hmac-sha256",
"proofMaterialSource": "component-package",
"proofMaterialEnv": "SCUM_COMPONENT_PROOF",
"sessionMode": "component-session",
"tlsPolicy": "verify-system-roots",
"heartbeatIntervalSeconds": 30,
"commandPollIntervalSeconds": 5,
"requestTimeoutSeconds": 15
}
]
},
"permissions": [
"server.create",
@@ -785,7 +748,6 @@
"ai.invoke",
"server.run.distribution",
"server.dependencies.manage",
"server.client-manager.manage",
"server.game-client.read",
"server.game-client.command",
"server.game-client.maintenance"
@@ -1255,19 +1217,6 @@
"platforms": [
"windows"
]
},
{
"key": "scum-client",
"mode": "custom-client",
"capabilities": [
"client-manager.deploy",
"client-manager.control",
"logs.read"
],
"clientManagerRef": "scum-client-manager",
"platforms": [
"windows"
]
}
],
"dependencyProbes": [
@@ -1396,14 +1345,6 @@
"streamKey": "scum.performance",
"cursorKind": "fingerprint",
"retentionDays": 30
},
{
"key": "scum-client-events",
"kind": "client-manager",
"targetKey": "scum-client-manager",
"streamKey": "scum.client",
"cursorKind": "sequence",
"retentionDays": 30
}
],
"transportProfiles": [
@@ -1460,21 +1401,6 @@
]
}
],
"dataTargets": [
{
"key": "scum-database",
"kind": "sqlite.snapshot",
"transportKey": "scum-database",
"sourceRootKey": "server-root",
"sourcePath": "SCUM/Saved/SaveFiles/SCUM.db",
"workspaceKey": "databases/scum-database",
"refreshPolicy": "on-demand-snapshot",
"maxBytes": 1073741824,
"platforms": [
"windows"
]
}
],
"dllExtensions": [
{
"key": "scum-simple-rcon",
@@ -1500,83 +1426,6 @@
"updateOnStart": true,
"rconPort": 27015
}
],
"clientManagers": [
{
"key": "scum-client-manager",
"displayName": "SCUM Client Manager",
"version": "1.0.0",
"repository": {
"url": "https://github.com/F88888/scum_client.git",
"revisionPolicy": "branch",
"branch": "main"
},
"supportedTargets": [
{
"os": "windows",
"arch": "amd64"
}
],
"build": {
"system": "go",
"entryRef": "main.go"
},
"configTemplates": [
{
"key": "client-config",
"templateRef": "config.yaml.example",
"outputRef": "config.yaml"
}
],
"outputArtifacts": [
"scum_client.exe"
],
"deployment": {
"mode": "run-supervised",
"executableRef": "scum_client.exe",
"autoStart": true,
"requiredRunCapabilities": [
"client-manager.deploy",
"client-manager.control",
"client-manager.update",
"client-manager.rollback",
"client-manager.uninstall"
]
},
"lifecycle": {
"actions": [
"start",
"stop",
"restart",
"status",
"update",
"rollback",
"uninstall"
],
"startupTimeoutSeconds": 60,
"stopTimeoutSeconds": 30
},
"health": {
"mode": "component-heartbeat",
"intervalSeconds": 30,
"degradedAfterSeconds": 90,
"offlineAfterSeconds": 120,
"requiredCapabilities": [
"component.register",
"component.heartbeat",
"component.health",
"component.control",
"game-client.bridge",
"logs.stream"
]
},
"updatePolicy": {
"strategy": "manual-staged",
"requireApproval": true,
"healthConfirmationSeconds": 60,
"retainPrevious": true
}
}
]
}
}
@@ -1,37 +0,0 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "SCUMCompanionHealthSnapshot",
"type": "object",
"additionalProperties": false,
"required": ["status", "observedAt"],
"properties": {
"status": {
"type": "string",
"enum": ["online", "degraded", "offline"]
},
"version": {
"type": "string",
"minLength": 1,
"maxLength": 40
},
"observedAt": {
"type": "string",
"maxLength": 64,
"format": "date-time"
},
"latencyMs": {
"type": "integer",
"minimum": 0,
"maximum": 30000
},
"capabilities": {
"type": "array",
"maxItems": 16,
"items": {
"type": "string",
"pattern": "^[a-z][a-z0-9.-]{0,79}$"
},
"uniqueItems": true
}
}
}
@@ -1,16 +0,0 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "SCUMCompanionDiagnosticsPayload",
"type": "object",
"additionalProperties": false,
"properties": {
"includeWindowState": {
"type": "boolean"
},
"maxEntries": {
"type": "integer",
"minimum": 1,
"maximum": 20
}
}
}
@@ -1,24 +0,0 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "SCUMCompanionDiagnosticsResult",
"type": "object",
"additionalProperties": false,
"required": ["status"],
"properties": {
"status": {
"type": "string",
"maxLength": 16,
"enum": ["online", "degraded", "offline"]
},
"version": {
"type": "string",
"minLength": 1,
"maxLength": 40
},
"lastHeartbeatAt": {
"type": "string",
"maxLength": 64,
"format": "date-time"
}
}
}
@@ -8,7 +8,7 @@
"scope": {
"type": "string",
"maxLength": 16,
"enum": ["server", "database", "companion"]
"enum": ["server", "database", "bridge"]
},
"noticeSeconds": {
"type": "integer",
@@ -1,42 +0,0 @@
{
"schemaVersion": 1,
"platform": {
"baseUrl": "https://platform.example.test"
},
"component": {
"installationId": "client-manager-installation-example",
"serverInstanceId": "server-example",
"pluginId": "game.scum",
"profileKey": "scum-client-manager",
"artifactId": "artifact-example",
"version": "1.0.0",
"sourceRevision": "example-revision",
"targetOs": "windows",
"targetArch": "amd64",
"keyGeneration": 1,
"deploymentGeneration": 1
},
"proof": {
"mode": "hmac-sha256",
"materialEnv": "SCUM_COMPONENT_PROOF"
},
"session": {
"mode": "component-session"
},
"capabilities": [
"component.register",
"component.heartbeat",
"component.health",
"component.control",
"game-client.bridge",
"logs.stream"
],
"timing": {
"heartbeatIntervalSeconds": 30,
"commandPollIntervalSeconds": 5,
"requestTimeoutSeconds": 15
},
"tls": {
"policy": "verify-system-roots"
}
}
@@ -1,97 +0,0 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "SCUMPlatformCompanionConfig",
"type": "object",
"additionalProperties": false,
"required": ["schemaVersion", "platform", "component", "proof", "session", "capabilities", "timing", "tls"],
"properties": {
"schemaVersion": { "const": 1 },
"platform": {
"type": "object",
"additionalProperties": false,
"required": ["baseUrl"],
"properties": {
"baseUrl": {
"type": "string",
"format": "uri",
"pattern": "^https://(?:[A-Za-z0-9](?:[A-Za-z0-9.-]{0,251}[A-Za-z0-9])?|\\[[0-9A-Fa-f:.]+\\])(?::[1-9][0-9]{0,4})?/?$",
"maxLength": 228
}
}
},
"component": {
"type": "object",
"additionalProperties": false,
"required": ["installationId", "serverInstanceId", "pluginId", "profileKey", "artifactId", "version", "sourceRevision", "targetOs", "targetArch", "keyGeneration", "deploymentGeneration"],
"properties": {
"installationId": { "$ref": "#/$defs/identifier" },
"serverInstanceId": { "$ref": "#/$defs/identifier" },
"pluginId": { "const": "game.scum" },
"profileKey": { "const": "scum-client-manager" },
"artifactId": { "$ref": "#/$defs/identifier" },
"version": { "type": "string", "minLength": 1, "maxLength": 40 },
"sourceRevision": { "type": "string", "minLength": 1, "maxLength": 120 },
"targetOs": { "const": "windows" },
"targetArch": { "const": "amd64" },
"keyGeneration": { "type": "integer", "minimum": 1, "maximum": 2147483647 },
"deploymentGeneration": { "type": "integer", "minimum": 1, "maximum": 2147483647 }
}
},
"proof": {
"type": "object",
"additionalProperties": false,
"required": ["mode", "materialEnv"],
"properties": {
"mode": { "const": "hmac-sha256" },
"materialEnv": { "const": "SCUM_COMPONENT_PROOF" }
}
},
"session": {
"type": "object",
"additionalProperties": false,
"required": ["mode"],
"properties": {
"mode": { "const": "component-session" }
}
},
"capabilities": {
"type": "array",
"minItems": 6,
"maxItems": 7,
"uniqueItems": true,
"items": { "enum": ["component.register", "component.heartbeat", "component.health", "component.control", "game-client.bridge", "logs.stream", "handler.vehicle.spawn"] },
"allOf": [
{ "contains": { "const": "component.register" } },
{ "contains": { "const": "component.heartbeat" } },
{ "contains": { "const": "component.health" } },
{ "contains": { "const": "component.control" } },
{ "contains": { "const": "game-client.bridge" } },
{ "contains": { "const": "logs.stream" } }
]
},
"timing": {
"type": "object",
"additionalProperties": false,
"required": ["heartbeatIntervalSeconds", "commandPollIntervalSeconds", "requestTimeoutSeconds"],
"properties": {
"heartbeatIntervalSeconds": { "const": 30 },
"commandPollIntervalSeconds": { "type": "integer", "minimum": 1, "maximum": 60 },
"requestTimeoutSeconds": { "type": "integer", "minimum": 1, "maximum": 60 }
}
},
"tls": {
"type": "object",
"additionalProperties": false,
"required": ["policy"],
"properties": {
"policy": { "const": "verify-system-roots" }
}
}
},
"$defs": {
"identifier": {
"type": "string",
"pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,179}$"
}
}
}
+21 -206
View File
@@ -26,9 +26,7 @@ import {
parseBridgeExecutionResponse,
parseAIInvocationResponse,
type GameClientBridgeQueryTemplateDeclaration,
type GameClientBridgeCompanionDeclaration,
type GamePluginManifest,
type RuntimeClientManagerProfile,
type PluginLifecycleActionDeclaration,
type PluginBridgeContext
} from "../sdk/index.js";
@@ -74,16 +72,6 @@ function writeFixtureJSON(fixtureDir: string, relativePath: string, value: unkno
fs.writeFileSync(target, `${JSON.stringify(value, null, 2)}\n`, "utf8");
}
function listProductionGoFiles(directory: string): string[] {
return fs.readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
const target = path.join(directory, entry.name);
if (entry.isDirectory()) {
return listProductionGoFiles(target);
}
return entry.isFile() && entry.name.endsWith(".go") && !entry.name.endsWith("_test.go") ? [target] : [];
});
}
function validateTemporaryBridgeManifest(mutate?: (manifest: MutableBridgeManifest, fixtureDir: string) => void): string[] {
const fixtureDir = fs.mkdtempSync(path.join(os.tmpdir(), "browser-bridge-manifest-"));
try {
@@ -129,8 +117,8 @@ function validateTemporaryBridgeManifest(mutate?: (manifest: MutableBridgeManife
}
}
function validateTemporaryScumCompanionManifest(mutate: (manifest: Record<string, any>, fixtureDir: string) => void): string[] {
const fixtureDir = fs.mkdtempSync(path.join(os.tmpdir(), "browser-scum-companion-manifest-"));
function validateTemporaryScumManifest(mutate: (manifest: Record<string, any>, fixtureDir: string) => void): string[] {
const fixtureDir = fs.mkdtempSync(path.join(os.tmpdir(), "browser-scum-manifest-"));
try {
fs.cpSync(path.join(pluginsRoot, "examples/scum-server-plugin"), fixtureDir, { recursive: true });
const manifestPath = path.join(fixtureDir, "manifest.json");
@@ -209,18 +197,18 @@ describe("plugin manifest validation", () => {
});
it("rejects lifecycle executable assets missing from the manifest seed declaration", () => {
const errors = validateTemporaryScumCompanionManifest((manifest) => {
const errors = validateTemporaryScumManifest((manifest) => {
manifest.assetFiles = manifest.assetFiles.filter((file: { path: string }) => file.path !== "bin/scum-start.cmd");
});
expect(errors.some((error) => error.includes("lifecycleAction.start.executableKey") && error.includes("manifest.assetFiles"))).toBe(true);
});
it("rejects unsupported or unsafe inline create-field declarations", () => {
const malformed = validateTemporaryScumCompanionManifest((manifest) => {
const malformed = validateTemporaryScumManifest((manifest) => {
manifest.server.createFields[0].type = "path";
});
expect(malformed.some((error) => error.includes("createFields") && error.includes("type"))).toBe(true);
const unsafe = validateTemporaryScumCompanionManifest((manifest) => {
const unsafe = validateTemporaryScumManifest((manifest) => {
manifest.server.createFields[0].defaultValue = "/srv/hidden-server";
});
expect(unsafe.some((error) => error.includes("raw host path"))).toBe(true);
@@ -244,167 +232,6 @@ describe("plugin manifest validation", () => {
]));
});
it("defines a generated SCUM companion config without inline proof or session material", () => {
const pluginDir = path.join(pluginsRoot, "examples/scum-server-plugin");
const manifest = JSON.parse(fs.readFileSync(path.join(pluginDir, "manifest.json"), "utf8")) as {
gameClientBridge: { companion?: GameClientBridgeCompanionDeclaration };
};
const companion = manifest.gameClientBridge.companion;
expect(companion).toMatchObject({
profileKey: "scum-client-manager",
configTemplateKey: "client-config",
configFormat: "yaml",
platformBaseUrlSource: "run-control",
registrationProof: "hmac-sha256",
proofMaterialSource: "component-package",
proofMaterialEnv: "SCUM_COMPONENT_PROOF",
sessionMode: "component-session",
tlsPolicy: "verify-system-roots",
heartbeatIntervalSeconds: 30,
requestTimeoutSeconds: 15
});
const schema = JSON.parse(fs.readFileSync(path.join(pluginDir, companion!.configSchemaRef), "utf8"));
const example = JSON.parse(fs.readFileSync(path.join(pluginDir, "schemas/companion/config.generated.example.json"), "utf8"));
const validate = new Ajv2020({ strict: false, validateFormats: false }).compile(schema);
expect(validate(example), JSON.stringify(validate.errors)).toBe(true);
expect(JSON.stringify(example)).not.toMatch(/authKey|componentKey|credential|password|sessionToken|secret|\/api\/v1\/scum-clients\//i);
expect(example).toMatchObject({ proof: { materialEnv: "SCUM_COMPONENT_PROOF" }, session: { mode: "component-session" }, tls: { policy: "verify-system-roots" } });
});
it("rejects unsafe SCUM companion bootstrap policy and inline session material", () => {
const policyErrors = validateTemporaryScumCompanionManifest((manifest) => {
manifest.gameClientBridge.companion.tlsPolicy = "skip-verification";
});
expect(policyErrors.some((error) => error.includes("tlsPolicy") || error.includes("secure component registration/session/TLS policy"))).toBe(true);
const materialErrors = validateTemporaryScumCompanionManifest((_manifest, fixtureDir) => {
const examplePath = path.join(fixtureDir, "schemas/companion/config.generated.example.json");
const example = JSON.parse(fs.readFileSync(examplePath, "utf8"));
example.proof.sessionToken = "inline-session-material";
writeFixtureJSON(fixtureDir, "schemas/companion/config.generated.example.json", example);
});
expect(materialErrors.some((error) => error.includes("inline proof/session material") || error.includes("additional properties"))).toBe(true);
const environmentErrors = validateTemporaryScumCompanionManifest((manifest) => {
manifest.gameClientBridge.companion.proofMaterialEnv = "LD_PRELOAD";
});
expect(environmentErrors.some((error) => error.includes("proofMaterialEnv"))).toBe(true);
});
it("rejects generated SCUM companion configs with incomplete capabilities or unsafe Platform URLs", () => {
const capabilityErrors = validateTemporaryScumCompanionManifest((_manifest, fixtureDir) => {
const examplePath = path.join(fixtureDir, "schemas/companion/config.generated.example.json");
const example = JSON.parse(fs.readFileSync(examplePath, "utf8"));
example.capabilities = ["component.register", "component.heartbeat", "component.health", "game-client.bridge"];
writeFixtureJSON(fixtureDir, "schemas/companion/config.generated.example.json", example);
});
expect(capabilityErrors.some((error) => error.includes("capabilities"))).toBe(true);
for (const unsafeURL of ["https://user:raw-token@example.test?session=raw-token#fragment", "https://?missing-host"]) {
const urlErrors = validateTemporaryScumCompanionManifest((_manifest, fixtureDir) => {
const examplePath = path.join(fixtureDir, "schemas/companion/config.generated.example.json");
const example = JSON.parse(fs.readFileSync(examplePath, "utf8"));
example.platform.baseUrl = unsafeURL;
writeFixtureJSON(fixtureDir, "schemas/companion/config.generated.example.json", example);
});
expect(urlErrors.some((error) => error.includes("platform.baseUrl"))).toBe(true);
}
});
it("cross-validates generated SCUM companion config against its declaration and runtime profile", () => {
const proofErrors = validateTemporaryScumCompanionManifest((manifest) => {
manifest.gameClientBridge.companion.proofMaterialEnv = "OTHER_COMPONENT_PROOF";
});
expect(proofErrors.some((error) => error.includes("proof.materialEnv") && error.includes("proofMaterialEnv"))).toBe(true);
const profileErrors = validateTemporaryScumCompanionManifest((manifest) => {
const manager = manifest.runtimeProfiles.clientManagers.find((candidate: Record<string, unknown>) => candidate.key === "scum-client-manager");
manager.health.requiredCapabilities = manager.health.requiredCapabilities.filter((capability: string) => capability !== "logs.stream");
});
expect(profileErrors.some((error) => error.includes("capabilities") && error.includes("requiredCapabilities"))).toBe(true);
const sessionErrors = validateTemporaryScumCompanionManifest((_manifest, fixtureDir) => {
const examplePath = path.join(fixtureDir, "schemas/companion/config.generated.example.json");
const example = JSON.parse(fs.readFileSync(examplePath, "utf8"));
example.session.mode = "legacy-shared-token";
writeFixtureJSON(fixtureDir, "schemas/companion/config.generated.example.json", example);
});
expect(sessionErrors.some((error) => error.includes("session"))).toBe(true);
});
it("rejects unsafe optional fields declared only by the SCUM companion config schema", () => {
const schemaErrors = validateTemporaryScumCompanionManifest((_manifest, fixtureDir) => {
const schemaPath = path.join(fixtureDir, "schemas/companion/config.schema.json");
const schema = JSON.parse(fs.readFileSync(schemaPath, "utf8"));
schema.properties.hostPath = { type: "string", minLength: 1, maxLength: 200 };
writeFixtureJSON(fixtureDir, "schemas/companion/config.schema.json", schema);
});
expect(schemaErrors.some((error) => error.includes("hostPath") && error.includes("raw host path"))).toBe(true);
});
it("rejects legacy companion endpoints, insecure TLS flags, and credential keys", () => {
const cases = [
{
name: "legacy shared-token endpoint",
mutate(schema: Record<string, any>, _example: Record<string, any>): void {
schema.description = "legacy /api/v1/scum-clients/commands endpoint";
}
},
{
name: "insecure TLS flag",
mutate(schema: Record<string, any>, _example: Record<string, any>): void {
schema.description = "InsecureSkipVerify";
}
},
{
name: "inline credential key",
mutate(schema: Record<string, any>, example: Record<string, any>): void {
schema.properties.credential = { type: "string", minLength: 1, maxLength: 200 };
example.credential = "legacy-shared-value";
}
}
];
for (const testCase of cases) {
const errors = validateTemporaryScumCompanionManifest((_manifest, fixtureDir) => {
const schemaPath = path.join(fixtureDir, "schemas/companion/config.schema.json");
const examplePath = path.join(fixtureDir, "schemas/companion/config.generated.example.json");
const schema = JSON.parse(fs.readFileSync(schemaPath, "utf8")) as Record<string, any>;
const example = JSON.parse(fs.readFileSync(examplePath, "utf8")) as Record<string, any>;
testCase.mutate(schema, example);
writeFixtureJSON(fixtureDir, "schemas/companion/config.schema.json", schema);
writeFixtureJSON(fixtureDir, "schemas/companion/config.generated.example.json", example);
});
expect(errors, testCase.name).toContain(
"manifest.gameClientBridge.companion.configSchemaRef: companion config must not contain legacy endpoints, insecure TLS, or inline proof/session material"
);
}
});
it("keeps the production SCUM companion free of unsafe legacy transport and updater patterns", () => {
const companionDir = path.join(pluginsRoot, "examples/scum-server-plugin/companion");
const forbiddenPatterns = [
{ name: "legacy shared-token endpoint", pattern: /\/api\/v1\/scum-clients\//i },
{ name: "legacy shared credential", pattern: /\b(?:SCUMClientCredential|scum_client_credential)\b/i },
{ name: "disabled TLS verification", pattern: /\bInsecureSkipVerify\s*:\s*true\b/ },
{ name: "arbitrary process or shell execution", pattern: /(?:\b(?:os\/exec|exec\.Command(?:Context)?|os\.StartProcess|syscall\.Exec)\b|\b(?:bash|zsh|powershell|pwsh|cmd(?:\.exe)?)\s+-[a-z/])/i },
{ name: "direct socket transport", pattern: /(?:\bnet\.(?:Dial|DialTimeout)\s*\(|\b(?:tcp|unix|ws):\/\/)/i },
{ name: "arbitrary URL self-update or download", pattern: /(?:\b(?:self_?update|update_?url|download_?url|updater)\b|\bhttp\.(?:Get|DefaultClient\.Get)\s*\(|\b(?:curl|wget)\b)/i }
];
const productionFiles = listProductionGoFiles(companionDir);
expect(productionFiles.length).toBeGreaterThan(0);
for (const file of productionFiles) {
const source = fs.readFileSync(file, "utf8");
const relativeFile = path.relative(companionDir, file);
for (const forbidden of forbiddenPatterns) {
const match = source.match(forbidden.pattern);
expect(match ? `${relativeFile}: ${match[0]}` : null, forbidden.name).toBeNull();
}
}
});
it("covers the SCUM 4.1 bridge and lifecycle declarations", () => {
const manifestPath = path.join(pluginsRoot, "examples/scum-server-plugin/manifest.json");
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")) as {
@@ -450,7 +277,6 @@ describe("plugin manifest validation", () => {
expect(installAction.environment?.SERVER_TEMPLATE).toBe("scum-server");
expect(manifest.permissions).toEqual(expect.arrayContaining(["server.game-client.read", "server.game-client.command", "server.game-client.maintenance"]));
expect(manifest.gameClientBridge.commands.map((command) => command.type)).toEqual(expect.arrayContaining([
"companion.diagnostics",
"player.lookup",
"reward.deliver",
"player.notify",
@@ -459,7 +285,8 @@ describe("plugin manifest validation", () => {
"restart.prepare",
"maintenance.prepare"
]));
expect(manifest.gameClientBridge.snapshots.map((snapshot) => snapshot.type)).toEqual(expect.arrayContaining(["companion.health", "online.sessions", "players", "squads", "vehicles", "flags"]));
expect(manifest.gameClientBridge.snapshots.map((snapshot) => snapshot.type)).toEqual(expect.arrayContaining(["online.sessions", "players", "squads", "vehicles", "flags"]));
expect(manifest.runtimeProfiles?.clientManagers).toBeUndefined();
expect(manifest.gameClientBridge.queryTemplates.find((template) => template.key === "scum.vehicles")?.projections).toEqual(expect.arrayContaining([
expect.objectContaining({ collection: "scum_trade_goods", mergeExisting: true, fixedValues: expect.objectContaining({ catalogType: "vehicle", type: "21", typeName: "其他载具" }) })
]));
@@ -473,8 +300,8 @@ describe("plugin manifest validation", () => {
expect(manifest.fileWorkspace?.directories.map((directory) => `${directory.key}:${directory.scope}`)).toEqual(expect.arrayContaining(["scum-config:config", "scum-logs:logs"]));
expect(manifest.fileWorkspace?.files.map((file) => file.key)).toEqual(expect.arrayContaining(["scum-server-settings", "scum-admin-users", "scum-chat-log", "scum-performance-log"]));
expect(manifest.fileWorkspace?.configFields.map((field) => field.key)).toEqual(expect.arrayContaining(["server-name", "game-port", "query-port", "max-players", "welcome-message"]));
expect(manifest.runtimeProfiles?.lifecycleProfiles?.find((profile) => profile.key === "scum-client")?.capabilities).not.toContain("remote.run.rcon.command");
expect(manifest.runtimeProfiles?.logSources?.map((source) => source.key)).toEqual(expect.arrayContaining(["scum-chat-events", "scum-server-events", "scum-login-events", "scum-trade-events", "scum-client-events"]));
expect(manifest.runtimeProfiles?.lifecycleProfiles?.find((profile) => profile.key === "scum-client")).toBeUndefined();
expect(manifest.runtimeProfiles?.logSources?.map((source) => source.key)).toEqual(expect.arrayContaining(["scum-chat-events", "scum-server-events", "scum-login-events", "scum-trade-events"]));
});
it("declares bounded and permissioned SCUM bridge commands", () => {
@@ -492,7 +319,6 @@ describe("plugin manifest validation", () => {
};
};
const expected = {
"companion.diagnostics": { permission: "server.game-client.read" },
"player.lookup": { permission: "server.game-client.read" },
"reward.deliver": { permission: "server.game-client.command" },
"player.notify": { permission: "server.game-client.command" },
@@ -559,7 +385,7 @@ describe("plugin manifest validation", () => {
pages: Array<{ pageKey: string; snapshotTypes?: string[] }>;
};
};
const expectedTypes = ["companion.health", "online.sessions", "players", "squads", "vehicles", "flags"];
const expectedTypes = ["online.sessions", "players", "squads", "vehicles", "flags"];
const snapshotsByType = new Map(manifest.gameClientBridge.snapshots.map((snapshot) => [snapshot.type, snapshot]));
expect([...snapshotsByType.keys()]).toEqual(expect.arrayContaining(expectedTypes));
@@ -639,7 +465,7 @@ describe("plugin manifest validation", () => {
const sqliteTransport = manifest.runtimeProfiles?.transportProfiles?.find((profile) => profile.key === "scum-database");
expect(sqliteTransport).toMatchObject({ kind: "sqlite", targetKey: "scum-database" });
expect(sqliteTransport?.capabilities).toEqual(expect.arrayContaining(["remote.run.db.sqlite.query", "remote.run.db.sqlite.execute"]));
expect(manifest.runtimeProfiles?.dataTargets).toEqual(expect.arrayContaining([expect.objectContaining({ key: "scum-database", kind: "sqlite.snapshot", sourcePath: "SCUM/Saved/SaveFiles/SCUM.db", workspaceKey: "databases/scum-database" })]));
expect(manifest.runtimeProfiles?.dataTargets).toBeUndefined();
for (const key of expectedKeys) {
const template = templatesByKey.get(key)!;
expect(template.engine).toBe("sqlite");
@@ -703,22 +529,11 @@ describe("plugin manifest validation", () => {
});
it("aligns the SCUM Client Manager declaration with the real Go bootstrap", () => {
it("does not declare a SCUM client-manager profile", () => {
const manifest = JSON.parse(fs.readFileSync(path.join(pluginsRoot, "examples/scum-server-plugin/manifest.json"), "utf8")) as {
runtimeProfiles?: { clientManagers?: Array<{
key: string;
build?: { workspaceRef?: string; entryRef?: string };
configTemplates?: Array<{ key?: string; templateRef?: string; outputRef?: string }>;
deployment?: { arguments?: string[] };
health?: { intervalSeconds?: number; degradedAfterSeconds?: number; offlineAfterSeconds?: number };
}> };
runtimeProfiles?: { clientManagers?: unknown[] };
};
const manager = manifest.runtimeProfiles?.clientManagers?.find((profile) => profile.key === "scum-client-manager");
expect(manager?.build).toMatchObject({ entryRef: "main.go" });
expect(manager?.build).not.toHaveProperty("workspaceRef");
expect(manager?.configTemplates).toEqual([{ key: "client-config", templateRef: "config.yaml.example", outputRef: "config.yaml" }]);
expect(manager?.deployment?.arguments).toBeUndefined();
expect(manager?.health).toMatchObject({ intervalSeconds: 30, degradedAfterSeconds: 90, offlineAfterSeconds: 120 });
expect(manifest.runtimeProfiles?.clientManagers).toBeUndefined();
});
it("accepts the Minecraft server plugin manifest", () => {
@@ -953,7 +768,7 @@ describe("plugin SDK", () => {
it("builds safe game-client bridge requests without component transport material", () => {
const request = createGameClientBridgeQueueRequest({
profileKey: "scum-client",
profileKey: "example-client",
commandType: "diagnostic.ping",
payload: { message: "hello" },
idempotencyKey: "diagnostic-1",
@@ -1302,18 +1117,18 @@ describe("plugin SDK", () => {
action: "logs.backfill.request",
payload: { sourceKey: "chat-log", limit: "500" }
});
expect(createClientManagerRequest({ requestId: "client-1", context, operation: "generate", profileKey: "scum-client-manager", targetOS: "windows", targetArch: "amd64", idempotencyKey: "idem-client" })).toMatchObject({
expect(createClientManagerRequest({ requestId: "client-1", context, operation: "generate", profileKey: "example-client-manager", targetOS: "windows", targetArch: "amd64", idempotencyKey: "idem-client" })).toMatchObject({
action: "client-manager.request",
payload: { operation: "generate", profileKey: "scum-client-manager" }
payload: { operation: "generate", profileKey: "example-client-manager" }
});
expect(JSON.stringify(createClientManagerRequest({ requestId: "client-2", context, operation: "reset-key", profileKey: "scum-client-manager", idempotencyKey: "idem-reset" }))).not.toContain("secret");
expect(createClientManagerRequest({ requestId: "client-3", context, operation: "deploy", profileKey: "scum-client-manager", installationId: "cm-install-1", artifactId: "artifact-1", expectedDeploymentGeneration: 2, idempotencyKey: "idem-deploy" })).toMatchObject({
expect(JSON.stringify(createClientManagerRequest({ requestId: "client-2", context, operation: "reset-key", profileKey: "example-client-manager", idempotencyKey: "idem-reset" }))).not.toContain("secret");
expect(createClientManagerRequest({ requestId: "client-3", context, operation: "deploy", profileKey: "example-client-manager", installationId: "cm-install-1", artifactId: "artifact-1", expectedDeploymentGeneration: 2, idempotencyKey: "idem-deploy" })).toMatchObject({
action: "client-manager.request",
payload: { operation: "deploy", installationId: "cm-install-1", artifactId: "artifact-1", expectedDeploymentGeneration: "2" }
});
expect(parseClientManagerLifecycleStatus({
installationId: "cm-install-1",
profileKey: "scum-client-manager",
profileKey: "example-client-manager",
status: "online",
phase: "healthy",
targetOS: "windows",
@@ -1326,7 +1141,7 @@ describe("plugin SDK", () => {
})).toMatchObject({ installationId: "cm-install-1", deploymentGeneration: 2, actions: ["stop", "restart", "update", "uninstall"] });
expect(parseClientManagerLifecycleStatus({
installationId: "cm-install-1",
profileKey: "scum-client-manager",
profileKey: "example-client-manager",
status: "online",
deploymentGeneration: "2",
actions: "stop",
+24 -45
View File
@@ -18,7 +18,7 @@ const surfaceData: SCUMSurfaceData = {
players: [{ gamePlayerId: "steam-1", steamId: "76561198000000001", userProfileId: "profile-1", displayName: "Mira", squadName: "Wolves", squadId: "squad-1", online: true, famePoints: 42, normalBalance: 1000, goldBalance: 3, lastLoginIp: "203.0.113.7", position: { x: 10, y: 20, z: 3 }, freshness: { status: "fresh" } }],
squads: [{ squadId: "squad-1", name: "Wolves", memberCount: 1, memberLimit: 12, leaderProfileId: "profile-1", score: 88, message: "Hold the north", freshness: { status: "fresh" } }],
members: [{ gamePlayerId: "steam-1", steamId: "76561198000000001", displayName: "Mira", squadId: "squad-1", rank: "Leader", score: 42, lastLoginAt: "2026-08-10T00:00:00Z", freshness: { status: "fresh" } }],
events: [{ id: "event-1", name: "Friday Range", eventType: "range", class: 1, corn: "0 20 * * 5", placard: "Event starting", percent: 75, npc: 1, item: 3, zombie: 12, animal: 2, status: "enabled" }],
events: [{ id: "event-1", name: "Friday Range", eventType: "range", class: 1, corn: "0 20 * * 5", rconCommand: "#start_event event-1", placard: "Event starting", percent: 75, npc: 1, item: 3, zombie: 12, animal: 2, status: "enabled" }],
eventProduces: [{ _recordKey: "event-1:produce-1", id: "produce-1", eventId: "event-1", tradeGoodsId: "goods-1", percent: 80, value: 2, r: 100, x: 10, y: 20, z: 3 }],
eventRuns: [{ id: "run-1", eventId: "event-1", status: "running", startedAt: "2026-08-10T00:00:00Z", summary: "Round 1" }],
nativeEventRounds: [{ eventRecordId: "native-1", eventId: "native-event", state: "active", startTime: "2026-08-10T00:00:00Z", enemyKills: 2 }],
@@ -93,17 +93,14 @@ describe("SCUM plugin feature module", () => {
expect(data.gifts[0]).toMatchObject({ collection: scumCollections.gifts, _recordKey: `${scumCollections.gifts}-1` });
});
it("merges player snapshots only by stable identifiers and ignores name-only online sessions", async () => {
it("reads player records only from plugin-owned collections", async () => {
const pluginData = pluginDataActions({ list: async (collection) => collection === scumCollections.players ? { items: [{ key: "steam-1", value: { gamePlayerId: "steam-1", displayName: "Mira", online: false } }] } : { items: [] } });
const gameClient = gameClientActions();
gameClient.snapshots.mockResolvedValue({ items: [{ sequence: 2, observedAt: "2026-08-10T00:00:00Z", payload: { players: [{ playerId: "steam-1", playerName: "Mira", status: "online", pingMs: 32 }] } }] });
const data = await loadSCUMSurface({ pluginData, gameClient }, "players");
expect(gameClient.snapshots.mock.calls.map(([query]) => query?.type)).toEqual(["players", "vehicles"]);
expect(data.players[0]).toMatchObject({ gamePlayerId: "steam-1", status: "online", online: true, pingMs: 32, onlineObservedAt: "2026-08-10T00:00:00Z" });
const data = await loadSCUMSurface({ pluginData }, "players");
expect(data.players[0]).toMatchObject({ gamePlayerId: "steam-1", displayName: "Mira", online: false });
const sameName = mergePlayerSnapshots([{ steamId: "steam-2", displayName: "Noah", online: false }], { items: [{ observedAt: "2026-08-10T00:02:00Z", payload: { players: [{ playerId: "steam-3", playerName: "Noah", status: "online" }] } }] });
expect(sameName).toHaveLength(2);
expect(sameName.find((player) => player.steamId === "steam-2")).toMatchObject({ online: false });
expect(dataClientSource).not.toContain('type: "online.sessions"');
expect(dataClientSource).not.toContain("scum-client-manager");
});
it("uses workflows as the manifest activity key and keeps activity as a compatibility alias", async () => {
@@ -131,14 +128,13 @@ describe("SCUM plugin feature module", () => {
it("persists event produces, event runs, and gift resets in plugin-owned collections", async () => {
const pluginData = pluginDataActions();
const gameClient = gameClientActions();
const actions: SCUMWorkspaceActions = { pluginData, gameClient };
const dispatch = vi.fn<NonNullable<SCUMWorkspaceActions["dispatch"]>>(async (envelope) => ({ status: "queued", result: { jobId: envelope.requestId } }));
const actions: SCUMWorkspaceActions = { pluginData, dispatch };
await saveEventProduce(actions, { id: "produce-1", eventId: "event-1", tradeGoodsId: "goods-1", percent: 80, value: 2, r: 100, x: 10, y: 20, z: 3 });
expect(pluginData.put).toHaveBeenCalledWith(scumCollections.eventProduces, "event-1:produce-1", expect.objectContaining({ eventId: "event-1", tradeGoodsId: "goods-1" }));
await startEvent(actions, surfaceData.events[0], surfaceData.eventProduces);
expect(gameClient.queue).toHaveBeenLastCalledWith(expect.objectContaining({ commandType: "event.start", payload: expect.objectContaining({
eventType: "range", class: 1, placard: "Event starting", percent: 75, npc: 1, item: 3, zombie: 12, animal: 2,
produces: [{ tradeGoodsId: "goods-1", percent: 80, value: 2, r: 100, x: 10, y: 20, z: 3 }]
await startEvent(actions, { ...surfaceData.events[0], rconCommand: "#start_event event-1" }, surfaceData.eventProduces);
expect(dispatch).toHaveBeenLastCalledWith(expect.objectContaining({ action: "remote.access.request", payload: expect.objectContaining({
capability: "remote.run.rcon.command", "input.command": "#start_event event-1"
}) }));
expect(pluginData.put).toHaveBeenCalledWith(scumCollections.eventRuns, expect.any(String), expect.objectContaining({ eventId: "event-1", status: "queued", produces: surfaceData.eventProduces }));
await resetGiftClaim(actions, { _recordKey: "claim-1" });
@@ -147,38 +143,31 @@ describe("SCUM plugin feature module", () => {
expect(pluginData.put).toHaveBeenCalledWith(scumCollections.pendingGifts, "pending-1", expect.objectContaining({ status: "pending", receivedAt: null }));
});
it("queues gift and event commands through the host-compatible generic gameClient bridge", async () => {
it("queues gift and event commands through platform-mediated SCUM RCON", async () => {
const pluginData = pluginDataActions();
const gameClient = gameClientActions();
const actions: SCUMWorkspaceActions = { pluginData, gameClient };
const dispatch = vi.fn<NonNullable<SCUMWorkspaceActions["dispatch"]>>(async (envelope) => ({ status: "queued", result: { jobId: envelope.requestId } }));
const actions: SCUMWorkspaceActions = { pluginData, dispatch };
await queueGiftDelivery(actions, { ...surfaceData.gifts[0], operations: ["#announce Starter pack", "#SetFamePoints 250"] }, surfaceData.players[0]);
expect(gameClient.queue).toHaveBeenCalledWith(expect.objectContaining({ profileKey: "scum-client-manager", commandType: "reward.deliver", payload: expect.objectContaining({ playerId: "steam-1", items: [{ catalogCode: "BP_Cash_01", quantity: 2 }], operations: ["#announce Starter pack", "#SetFamePoints 250"] }) }));
expect(dispatch.mock.calls.map(([request]) => request.payload?.["input.command"])).toEqual(["#SpawnItem BP_Cash_01 2", "#announce Starter pack", "#SetFamePoints 250"]);
expect(pluginData.put).toHaveBeenCalledWith(scumCollections.giftDeliveries, expect.any(String), expect.objectContaining({ giftCode: "starter-pack", playerId: "steam-1", status: "queued" }));
await startEvent(actions, surfaceData.events[0], surfaceData.eventProduces);
expect(gameClient.queue).toHaveBeenLastCalledWith(expect.objectContaining({ profileKey: "scum-client-manager", commandType: "event.start", payload: expect.objectContaining({ eventId: "event-1", eventType: "range", class: 1, placard: "Event starting", percent: 75, produces: [{ tradeGoodsId: "goods-1", percent: 80, value: 2, r: 100, x: 10, y: 20, z: 3 }] }) }));
expect(Object.keys(gameClient).sort()).toEqual(["get", "list", "queue", "snapshots"]);
await startEvent(actions, { ...surfaceData.events[0], rconCommand: "#start_event event-1" }, surfaceData.eventProduces);
expect(dispatch).toHaveBeenLastCalledWith(expect.objectContaining({ payload: expect.objectContaining({ "input.command": "#start_event event-1" }) }));
});
it("defaults activity class to range and strips collection metadata from queued produces", async () => {
const pluginData = pluginDataActions();
const gameClient = gameClientActions();
await startEvent({ pluginData, gameClient }, { id: "event-default", name: "Default Event" }, [{
const dispatch = vi.fn<NonNullable<SCUMWorkspaceActions["dispatch"]>>(async (envelope) => ({ status: "queued", result: { jobId: envelope.requestId } }));
await startEvent({ pluginData, dispatch }, { id: "event-default", name: "Default Event", rconCommand: "#start_event event-default" }, [{
_recordKey: "event-default:produce-1", id: "produce-1", eventId: "event-default", updatedAt: "2026-08-10T00:00:00Z",
tradeGoodsId: "cargo-drop", percent: 80, value: 2, r: 500, x: 1000, y: 2000, z: 300
}]);
expect(gameClient.queue).toHaveBeenCalledWith(expect.objectContaining({ commandType: "event.start", payload: expect.objectContaining({
eventType: "range", class: 1, npc: 0, item: 0, zombie: 0, animal: 0,
produces: [{ tradeGoodsId: "cargo-drop", percent: 80, value: 2, r: 500, x: 1000, y: 2000, z: 300 }]
}) }));
expect(dispatch).toHaveBeenCalledWith(expect.objectContaining({ payload: expect.objectContaining({ "input.command": "#start_event event-default" }) }));
});
it("keeps positive event duration and counts above the removed arbitrary limits", async () => {
const gameClient = gameClientActions();
await startEvent({ pluginData: pluginDataActions(), gameClient }, { id: "event-large", name: "Large Event", durationSeconds: 86401, npc: 10001, item: 10002, zombie: 10003, animal: 10004 }, [{ tradeGoodsId: "cargo-drop", percent: 80, value: 10001, r: 2000001, x: 3000000, y: -3000000, z: 0 }]);
expect(gameClient.queue).toHaveBeenCalledWith(expect.objectContaining({ payload: expect.objectContaining({
durationSeconds: 86401, npc: 10001, item: 10002, zombie: 10003, animal: 10004,
produces: [{ tradeGoodsId: "cargo-drop", percent: 80, value: 10001, r: 2000001, x: 3000000, y: -3000000, z: 0 }]
}) }));
const dispatch = vi.fn<NonNullable<SCUMWorkspaceActions["dispatch"]>>(async (envelope) => ({ status: "queued", result: { jobId: envelope.requestId } }));
await startEvent({ pluginData: pluginDataActions(), dispatch }, { id: "event-large", name: "Large Event", rconCommand: "#start_event event-large", durationSeconds: 86401, npc: 10001, item: 10002, zombie: 10003, animal: 10004 }, [{ tradeGoodsId: "cargo-drop", percent: 80, value: 10001, r: 2000001, x: 3000000, y: -3000000, z: 0 }]);
expect(dispatch).toHaveBeenCalledWith(expect.objectContaining({ payload: expect.objectContaining({ "input.command": "#start_event event-large" }) }));
});
it("renders searchable user management from real collection values", () => {
@@ -241,7 +230,7 @@ describe("SCUM plugin feature module", () => {
it("renders activity definitions, status filters, runs, and records", () => {
const view = renderAndCollect({ pageKey: "workflows", pageTitle: "活动管理" });
expect(view.inputs.map((input) => input.label)).toContain("活动状态");
expect(view.inputs.map((input) => input.label)).toEqual(expect.arrayContaining(["生成类型", "活动公告", "活动概率", "活动持续秒数", "生成物品编号", "生成半径", "生成 X", "生成 Y", "生成 Z"]));
expect(view.inputs.map((input) => input.label)).toEqual(expect.arrayContaining(["生成类型", "RCON 命令", "活动公告", "活动概率", "活动持续秒数", "生成物品编号", "生成半径", "生成 X", "生成 Y", "生成 Z"]));
expect(view.texts).toContain("Friday Range");
expect(view.texts).toContain("running");
expect(view.texts).toContain("最近活动记录");
@@ -318,16 +307,6 @@ function pluginDataActions(overrides: Partial<{ list: (collection: string, key?:
};
}
function gameClientActions() {
const queue = vi.fn<NonNullable<SCUMWorkspaceActions["gameClient"]>["queue"]>(async () => ({ id: "command-1", state: "pending" }));
const get = vi.fn<NonNullable<SCUMWorkspaceActions["gameClient"]>["get"]>(async () => ({ id: "command-1", state: "pending" }));
const list = vi.fn<NonNullable<SCUMWorkspaceActions["gameClient"]>["list"]>(async () => ({ items: [], count: 0 }));
const snapshots = vi.fn<NonNullable<SCUMWorkspaceActions["gameClient"]>["snapshots"]>(async () => ({ items: [], count: 0 }));
return {
queue, get, list, snapshots
};
}
function renderAndCollect(options: { data?: SCUMSurfaceData; permissions?: string[]; pageKey?: string; pageTitle?: string; giftTab?: "definitions" | "claims" | "deliveries" | "timed"; playerSearch?: string } = {}) {
const nodes: string[] = [];
const texts: string[] = [];
@@ -355,7 +334,7 @@ function renderAndCollect(options: { data?: SCUMSurfaceData; permissions?: strin
return [value, () => undefined];
}
};
const actions: SCUMWorkspaceActions = { pluginData: pluginDataActions(), gameClient: gameClientActions(), dispatch: async () => ({ status: "queued", result: { jobId: "job-1" } }) };
const actions: SCUMWorkspaceActions = { pluginData: pluginDataActions(), dispatch: async () => ({ status: "queued", result: { jobId: "job-1" } }) };
renderPluginPage(react, {
page: { key: options.pageKey ?? "players", title: options.pageTitle ?? "用户管理" },
context: { serverInstanceId: "server-1", permissions: options.permissions ?? ["server.read", "server.remote.access"] },