功能修改

This commit is contained in:
npc0-hue
2026-07-20 16:42:33 +08:00
parent 48b8ad8d6c
commit a0e69417db
224 changed files with 22015 additions and 884 deletions
@@ -6,7 +6,7 @@
"arguments": [],
"environment": {
"GAME_ID": "scum",
"SERVER_TEMPLATE": "scum-local-proof"
"SERVER_TEMPLATE": "scum-server"
},
"timeoutMs": 30000
}
@@ -0,0 +1,33 @@
# SCUM Companion One-Shot Smoke
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.
@@ -0,0 +1,516 @@
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"`
}
@@ -0,0 +1,519 @@
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))
}
@@ -0,0 +1,51 @@
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)
}
@@ -0,0 +1,166 @@
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",
}
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) {
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")
}
}
return nil
}
@@ -0,0 +1,33 @@
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
@@ -0,0 +1,5 @@
module browser.local/plugins/scum-server-plugin/companion
go 1.25.1
require gopkg.in/yaml.v3 v3.0.1
@@ -0,0 +1,4 @@
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=
@@ -0,0 +1,166 @@
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
}
}
@@ -0,0 +1,213 @@
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: "announcement.send", 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)
}
}
}
+424 -29
View File
@@ -2,14 +2,15 @@
"$schema": "../../manifests/game-plugin.manifest.schema.json",
"id": "game.scum",
"name": "SCUM Server",
"description": "First-party local SCUM game server management plugin for platform-mediated lifecycle proof.",
"description": "First-party SCUM game server operations plugin with platform-mediated lifecycle and companion bridge support.",
"version": "0.1.0",
"kind": "game-plugin",
"tags": [
"scum",
"survival",
"dedicated-server",
"local-proof"
"game-operations",
"companion-client"
],
"server": {
"type": "scum",
@@ -86,9 +87,229 @@
"run.distribution.request",
"dependencies.request",
"logs.backfill.request",
"client-manager.request"
"client-manager.request",
"plugin-lifecycle.request"
]
},
"gameClientBridge": {
"commands": [
{
"type": "announcement.send",
"title": "Send SCUM announcement",
"permission": "server.game-client.command",
"approvalLevel": "operator",
"payloadSchemaRef": "schemas/bridge/announcement.payload.schema.json",
"resultSchemaRef": "schemas/bridge/announcement.result.schema.json",
"timeoutSeconds": 60,
"maxPayloadBytes": 4096
},
{
"type": "companion.diagnostics",
"title": "Collect companion diagnostics",
"permission": "server.game-client.read",
"approvalLevel": "none",
"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",
"permission": "server.game-client.read",
"approvalLevel": "none",
"payloadSchemaRef": "schemas/bridge/player-lookup.payload.schema.json",
"resultSchemaRef": "schemas/bridge/player-lookup.result.schema.json",
"timeoutSeconds": 30,
"maxPayloadBytes": 4096
},
{
"type": "reward.deliver",
"title": "Deliver SCUM reward",
"permission": "server.game-client.command",
"approvalLevel": "operator",
"payloadSchemaRef": "schemas/bridge/reward-deliver.payload.schema.json",
"resultSchemaRef": "schemas/bridge/reward-deliver.result.schema.json",
"timeoutSeconds": 60,
"maxPayloadBytes": 4096
},
{
"type": "event.start",
"title": "Start SCUM event",
"permission": "server.game-client.command",
"approvalLevel": "operator",
"payloadSchemaRef": "schemas/bridge/event-start.payload.schema.json",
"resultSchemaRef": "schemas/bridge/event-start.result.schema.json",
"timeoutSeconds": 60,
"maxPayloadBytes": 4096
},
{
"type": "restart.prepare",
"title": "Prepare SCUM restart",
"permission": "server.game-client.maintenance",
"approvalLevel": "operator",
"payloadSchemaRef": "schemas/bridge/restart-prepare.payload.schema.json",
"resultSchemaRef": "schemas/bridge/restart-prepare.result.schema.json",
"timeoutSeconds": 120,
"maxPayloadBytes": 4096
},
{
"type": "maintenance.prepare",
"title": "Prepare SCUM maintenance",
"permission": "server.game-client.maintenance",
"approvalLevel": "platform-admin",
"payloadSchemaRef": "schemas/bridge/maintenance-prepare.payload.schema.json",
"resultSchemaRef": "schemas/bridge/maintenance-prepare.result.schema.json",
"timeoutSeconds": 120,
"maxPayloadBytes": 4096
}
],
"snapshots": [
{
"type": "companion.health",
"schemaVersion": "1",
"schemaRef": "schemas/bridge/companion-health.snapshot.schema.json",
"keepForSeconds": 604800,
"maxRecords": 1000
},
{
"type": "online.sessions",
"schemaVersion": "1",
"schemaRef": "schemas/bridge/online-sessions.snapshot.schema.json",
"keepForSeconds": 86400,
"maxRecords": 1000
},
{
"type": "players",
"schemaVersion": "1",
"schemaRef": "schemas/bridge/players.snapshot.schema.json",
"keepForSeconds": 86400,
"maxRecords": 1000
},
{
"type": "squads",
"schemaVersion": "1",
"schemaRef": "schemas/bridge/squads.snapshot.schema.json",
"keepForSeconds": 86400,
"maxRecords": 1000
},
{
"type": "vehicles",
"schemaVersion": "1",
"schemaRef": "schemas/bridge/vehicles.snapshot.schema.json",
"keepForSeconds": 86400,
"maxRecords": 1000
},
{
"type": "flags",
"schemaVersion": "1",
"schemaRef": "schemas/bridge/flags.snapshot.schema.json",
"keepForSeconds": 86400,
"maxRecords": 1000
}
],
"queryTemplates": [
{
"key": "scum.player.by-id",
"title": "Find SCUM player by ID",
"permission": "server.game-client.read",
"engine": "sqlite",
"transportKey": "sqlite-db",
"targetKey": "db/sqlite",
"parameterSchemaRef": "schemas/bridge/queries/player-by-id.parameters.schema.json",
"resultSchemaRef": "schemas/bridge/queries/player-by-id.result.schema.json",
"maxRows": 1,
"timeoutSeconds": 10
},
{
"key": "scum.player.search",
"title": "Search SCUM players",
"permission": "server.game-client.read",
"engine": "sqlite",
"transportKey": "sqlite-db",
"targetKey": "db/sqlite",
"parameterSchemaRef": "schemas/bridge/queries/player-search.parameters.schema.json",
"resultSchemaRef": "schemas/bridge/queries/player-search.result.schema.json",
"maxRows": 50,
"timeoutSeconds": 10
},
{
"key": "scum.squad.members",
"title": "List SCUM squad members",
"permission": "server.game-client.read",
"engine": "sqlite",
"transportKey": "sqlite-db",
"targetKey": "db/sqlite",
"parameterSchemaRef": "schemas/bridge/queries/squad-members.parameters.schema.json",
"resultSchemaRef": "schemas/bridge/queries/squad-members.result.schema.json",
"maxRows": 64,
"timeoutSeconds": 10
},
{
"key": "scum.vehicle.owner",
"title": "Find SCUM vehicle owner",
"permission": "server.game-client.read",
"engine": "sqlite",
"transportKey": "sqlite-db",
"targetKey": "db/sqlite",
"parameterSchemaRef": "schemas/bridge/queries/vehicle-owner.parameters.schema.json",
"resultSchemaRef": "schemas/bridge/queries/vehicle-owner.result.schema.json",
"maxRows": 1,
"timeoutSeconds": 10
},
{
"key": "scum.flag.ownership",
"title": "Find SCUM flag ownership",
"permission": "server.game-client.read",
"engine": "sqlite",
"transportKey": "sqlite-db",
"targetKey": "db/sqlite",
"parameterSchemaRef": "schemas/bridge/queries/flag-ownership.parameters.schema.json",
"resultSchemaRef": "schemas/bridge/queries/flag-ownership.result.schema.json",
"maxRows": 1,
"timeoutSeconds": 10
}
],
"commandRetentionSeconds": 604800,
"maxCommands": 1000,
"pages": [
{
"pageKey": "operations",
"commandTypes": [
"announcement.send",
"companion.diagnostics",
"player.lookup",
"reward.deliver",
"event.start",
"restart.prepare",
"maintenance.prepare"
],
"snapshotTypes": ["companion.health", "online.sessions", "players", "squads", "vehicles", "flags"],
"queryTemplateKeys": [
"scum.player.by-id",
"scum.player.search",
"scum.squad.members",
"scum.vehicle.owner",
"scum.flag.ownership"
]
}
],
"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",
"server.read",
@@ -102,7 +323,10 @@
"ai.invoke",
"server.run.distribution",
"server.dependencies.manage",
"server.client-manager.manage"
"server.client-manager.manage",
"server.game-client.read",
"server.game-client.command",
"server.game-client.maintenance"
],
"actions": {
"install": "actions/install.json",
@@ -111,6 +335,11 @@
"restart": "actions/restart.json",
"status": "actions/status.json"
},
"productionLifecycle": {
"operations": ["install", "enable", "disable", "upgrade", "rollback", "retire", "dependency-check"],
"dependencyPolicy": "required",
"approvalRequired": ["disable", "rollback", "retire"]
},
"pages": [
{
"key": "overview",
@@ -125,6 +354,27 @@
"jobs.dispatch"
]
},
{
"key": "operations",
"title": "SCUM 运维",
"path": "/operations",
"permissions": [
"server.read",
"server.game-client.read",
"server.game-client.command",
"server.game-client.maintenance",
"server.client-manager.manage",
"server.logs.read",
"server.remote.access"
],
"bridgeActions": [
"server.instances.read",
"jobs.dispatch",
"client-manager.request",
"logs.query",
"remote.access.request"
]
},
{
"key": "config",
"title": "SCUM 配置",
@@ -177,7 +427,9 @@
"purposes": [
"config.suggest",
"logs.diagnose"
]
],
"mediation": "platform",
"configWritePolicy": "review-required"
},
"runtimeProfiles": {
"discovery": [
@@ -249,11 +501,9 @@
"key": "scum-client",
"mode": "custom-client",
"capabilities": [
"remote.run.rcon.command",
"remote.run.logs.transfer"
],
"transportKeys": [
"client-rcon"
"client-manager.deploy",
"client-manager.control",
"logs.read"
],
"clientManagerRef": "scum-client-manager",
"platforms": [
@@ -284,6 +534,21 @@
}
],
"installPlans": [
{
"key": "install-scum-server",
"title": "Install SCUM Dedicated Server",
"platforms": [
"windows"
],
"steps": [
{
"type": "steamcmd-app",
"targetKey": "server/install-root",
"packageManager": "steamcmd",
"packageName": "3792580"
}
]
},
{
"key": "install-steamcmd-linux",
"title": "Install SteamCMD",
@@ -302,30 +567,162 @@
],
"logSources": [
{
"key": "chat-log",
"kind": "ftp.poll",
"targetKey": "logs/chat",
"streamKey": "chat",
"cursorKind": "ftp-listing",
"retentionDays": 90
},
{
"key": "server-log",
"key": "scum-chat-events",
"kind": "file.tail",
"targetKey": "logs/server",
"streamKey": "server",
"targetKey": "logs/chat",
"streamKey": "scum.chat",
"cursorKind": "fingerprint",
"retentionDays": 90
},
{
"key": "client-manager",
"key": "scum-server-events",
"kind": "file.tail",
"targetKey": "logs/server",
"streamKey": "scum.server",
"cursorKind": "fingerprint",
"retentionDays": 90
},
{
"key": "scum-login-events",
"kind": "file.tail",
"targetKey": "logs/login",
"streamKey": "scum.login",
"cursorKind": "fingerprint",
"retentionDays": 90
},
{
"key": "scum-kill-events",
"kind": "file.tail",
"targetKey": "logs/kill",
"streamKey": "scum.kill",
"cursorKind": "fingerprint",
"retentionDays": 90
},
{
"key": "scum-trade-events",
"kind": "file.tail",
"targetKey": "logs/trade",
"streamKey": "scum.trade",
"cursorKind": "fingerprint",
"retentionDays": 90
},
{
"key": "scum-admin-events",
"kind": "file.tail",
"targetKey": "logs/admin",
"streamKey": "scum.admin",
"cursorKind": "fingerprint",
"retentionDays": 90
},
{
"key": "scum-performance-events",
"kind": "file.tail",
"targetKey": "logs/performance",
"streamKey": "scum.performance",
"cursorKind": "fingerprint",
"retentionDays": 30
},
{
"key": "scum-client-events",
"kind": "client-manager",
"targetKey": "scum-client-manager",
"streamKey": "client-manager",
"streamKey": "scum.client",
"cursorKind": "sequence",
"retentionDays": 30
}
],
"logEvents": [
{
"key": "scum-chat",
"title": "SCUM chat message",
"sourceKey": "scum-chat-events",
"eventType": "scum.chat",
"permission": "server.logs.read",
"schemaRef": "schemas/log-events/chat.event.schema.json",
"retentionDays": 90,
"severity": "info"
},
{
"key": "scum-login",
"title": "SCUM player login",
"sourceKey": "scum-login-events",
"eventType": "scum.login",
"permission": "server.logs.read",
"schemaRef": "schemas/log-events/login.event.schema.json",
"retentionDays": 90,
"severity": "info"
},
{
"key": "scum-logout",
"title": "SCUM player logout",
"sourceKey": "scum-login-events",
"eventType": "scum.logout",
"permission": "server.logs.read",
"schemaRef": "schemas/log-events/logout.event.schema.json",
"retentionDays": 90,
"severity": "info"
},
{
"key": "scum-kill",
"title": "SCUM player kill",
"sourceKey": "scum-kill-events",
"eventType": "scum.kill",
"permission": "server.logs.read",
"schemaRef": "schemas/log-events/kill.event.schema.json",
"retentionDays": 90,
"severity": "info"
},
{
"key": "scum-trade",
"title": "SCUM trade activity",
"sourceKey": "scum-trade-events",
"eventType": "scum.trade",
"permission": "server.logs.read",
"schemaRef": "schemas/log-events/trade.event.schema.json",
"retentionDays": 90,
"severity": "info"
},
{
"key": "scum-mine",
"title": "SCUM mine activity",
"sourceKey": "scum-server-events",
"eventType": "scum.mine",
"permission": "server.logs.read",
"schemaRef": "schemas/log-events/mine.event.schema.json",
"retentionDays": 90,
"severity": "warning"
},
{
"key": "scum-unlock",
"title": "SCUM unlock activity",
"sourceKey": "scum-server-events",
"eventType": "scum.unlock",
"permission": "server.logs.read",
"schemaRef": "schemas/log-events/unlock.event.schema.json",
"retentionDays": 90,
"severity": "warning"
},
{
"key": "scum-admin",
"title": "SCUM admin activity",
"sourceKey": "scum-admin-events",
"eventType": "scum.admin",
"permission": "server.logs.read",
"schemaRef": "schemas/log-events/admin.event.schema.json",
"retentionDays": 90,
"severity": "warning"
},
{
"key": "scum-performance",
"title": "SCUM server performance",
"sourceKey": "scum-performance-events",
"eventType": "scum.performance",
"permission": "server.logs.read",
"schemaRef": "schemas/log-events/performance.event.schema.json",
"retentionDays": 30,
"severity": "info"
}
],
"transportProfiles": [
{
"key": "server-files",
@@ -405,14 +802,13 @@
],
"build": {
"system": "go",
"workspaceRef": "scum_client",
"entryRef": "main.go"
},
"configTemplates": [
{
"key": "client-config",
"templateRef": "configs/client.template.json",
"outputRef": "config.json"
"templateRef": "config.yaml.example",
"outputRef": "config.yaml"
}
],
"outputArtifacts": [
@@ -421,7 +817,6 @@
"deployment": {
"mode": "run-supervised",
"executableRef": "scum_client.exe",
"arguments": ["--config", "config.json"],
"autoStart": true,
"requiredRunCapabilities": [
"client-manager.deploy",
@@ -438,8 +833,8 @@
},
"health": {
"mode": "component-heartbeat",
"intervalSeconds": 15,
"degradedAfterSeconds": 45,
"intervalSeconds": 30,
"degradedAfterSeconds": 90,
"offlineAfterSeconds": 120,
"requiredCapabilities": ["component.register", "component.heartbeat", "component.health", "component.control", "game-client.bridge", "logs.stream"]
},
@@ -0,0 +1,14 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "SCUMAnnouncementPayload",
"type": "object",
"additionalProperties": false,
"required": ["message"],
"properties": {
"message": {
"type": "string",
"minLength": 1,
"maxLength": 500
}
}
}
@@ -0,0 +1,17 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "SCUMAnnouncementResult",
"type": "object",
"additionalProperties": false,
"required": ["accepted"],
"properties": {
"accepted": {
"type": "boolean"
},
"messageId": {
"type": "string",
"minLength": 1,
"maxLength": 120
}
}
}
@@ -0,0 +1,37 @@
{
"$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
}
}
}
@@ -0,0 +1,16 @@
{
"$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
}
}
}
@@ -0,0 +1,24 @@
{
"$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"
}
}
}
@@ -0,0 +1,32 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "SCUMEventStartPayload",
"type": "object",
"additionalProperties": false,
"required": ["eventType"],
"properties": {
"eventType": {
"type": "string",
"maxLength": 24,
"enum": ["airdrop", "convoy", "horde", "zombie-surge"]
},
"durationSeconds": {
"type": "integer",
"minimum": 30,
"maximum": 86400
},
"maxParticipants": {
"type": "integer",
"minimum": 1,
"maximum": 1000
},
"announce": {
"type": "boolean"
},
"title": {
"type": "string",
"minLength": 1,
"maxLength": 80
}
}
}
@@ -0,0 +1,26 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "SCUMEventStartResult",
"type": "object",
"additionalProperties": false,
"required": ["accepted", "status"],
"properties": {
"accepted": {
"type": "boolean"
},
"status": {
"type": "string",
"maxLength": 16,
"enum": ["started", "queued", "rejected"]
},
"eventId": {
"type": "string",
"maxLength": 96,
"pattern": "^[A-Za-z0-9_.:-]{1,96}$"
},
"message": {
"type": "string",
"maxLength": 200
}
}
}
@@ -0,0 +1,66 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "SCUMFlagsSnapshot",
"type": "object",
"additionalProperties": false,
"required": ["observedAt", "flags"],
"properties": {
"observedAt": {
"type": "string",
"maxLength": 64,
"format": "date-time"
},
"flags": {
"type": "array",
"maxItems": 512,
"items": {
"type": "object",
"additionalProperties": false,
"required": ["flagId", "status"],
"properties": {
"flagId": {
"type": "string",
"pattern": "^[A-Za-z0-9_.:-]{1,96}$"
},
"status": {
"type": "string",
"enum": ["active", "inactive", "contested", "unknown"]
},
"ownerPlayerId": {
"type": "string",
"pattern": "^[A-Za-z0-9_.:-]{1,96}$"
},
"squadId": {
"type": "string",
"pattern": "^[A-Za-z0-9_.:-]{1,96}$"
},
"radiusMeters": {
"type": "number",
"minimum": 0,
"maximum": 5000
},
"position": {
"type": "object",
"additionalProperties": false,
"required": ["x", "y", "z"],
"properties": {
"x": { "type": "number", "minimum": -100000, "maximum": 100000 },
"y": { "type": "number", "minimum": -100000, "maximum": 100000 },
"z": { "type": "number", "minimum": -100000, "maximum": 100000 }
}
},
"capturedAt": {
"type": "string",
"maxLength": 64,
"format": "date-time"
},
"lastUpdatedAt": {
"type": "string",
"maxLength": 64,
"format": "date-time"
}
}
}
}
}
}
@@ -0,0 +1,29 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "SCUMMaintenancePreparePayload",
"type": "object",
"additionalProperties": false,
"required": ["scope", "noticeSeconds", "reason"],
"properties": {
"scope": {
"type": "string",
"maxLength": 16,
"enum": ["server", "database", "companion"]
},
"noticeSeconds": {
"type": "integer",
"minimum": 60,
"maximum": 86400
},
"estimatedDurationSeconds": {
"type": "integer",
"minimum": 60,
"maximum": 86400
},
"reason": {
"type": "string",
"minLength": 1,
"maxLength": 200
}
}
}
@@ -0,0 +1,26 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "SCUMMaintenancePrepareResult",
"type": "object",
"additionalProperties": false,
"required": ["accepted", "status"],
"properties": {
"accepted": {
"type": "boolean"
},
"status": {
"type": "string",
"maxLength": 16,
"enum": ["prepared", "queued", "rejected"]
},
"maintenanceId": {
"type": "string",
"maxLength": 96,
"pattern": "^[A-Za-z0-9_.:-]{1,96}$"
},
"message": {
"type": "string",
"maxLength": 200
}
}
}
@@ -0,0 +1,45 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "SCUMOnlineSessionsSnapshot",
"type": "object",
"additionalProperties": false,
"required": ["observedAt", "onlineCount", "sessions"],
"properties": {
"observedAt": {
"type": "string",
"maxLength": 64,
"format": "date-time"
},
"onlineCount": {
"type": "integer",
"minimum": 0,
"maximum": 10000
},
"sessions": {
"type": "array",
"maxItems": 200,
"items": {
"type": "object",
"additionalProperties": false,
"required": ["sessionId", "playerName"],
"properties": {
"sessionId": {
"type": "string",
"minLength": 1,
"maxLength": 120
},
"playerName": {
"type": "string",
"minLength": 1,
"maxLength": 80
},
"startedAt": {
"type": "string",
"maxLength": 64,
"format": "date-time"
}
}
}
}
}
}
@@ -0,0 +1,22 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "SCUMPlayerLookupPayload",
"type": "object",
"additionalProperties": false,
"required": ["query", "maxResults"],
"properties": {
"query": {
"type": "string",
"minLength": 1,
"maxLength": 80
},
"includeOffline": {
"type": "boolean"
},
"maxResults": {
"type": "integer",
"minimum": 1,
"maximum": 50
}
}
}
@@ -0,0 +1,48 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "SCUMPlayerLookupResult",
"type": "object",
"additionalProperties": false,
"required": ["players"],
"properties": {
"players": {
"type": "array",
"maxItems": 50,
"items": {
"type": "object",
"additionalProperties": false,
"required": ["playerId", "playerName", "status"],
"properties": {
"playerId": {
"type": "string",
"maxLength": 96,
"pattern": "^[A-Za-z0-9_.:-]{1,96}$"
},
"playerName": {
"type": "string",
"minLength": 1,
"maxLength": 80
},
"status": {
"type": "string",
"maxLength": 16,
"enum": ["online", "offline", "unknown"]
},
"squadId": {
"type": "string",
"maxLength": 96,
"pattern": "^[A-Za-z0-9_.:-]{1,96}$"
},
"lastSeenAt": {
"type": "string",
"maxLength": 64,
"format": "date-time"
}
}
}
},
"truncated": {
"type": "boolean"
}
}
}
@@ -0,0 +1,73 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "SCUMPlayersSnapshot",
"type": "object",
"additionalProperties": false,
"required": ["observedAt", "players"],
"properties": {
"observedAt": {
"type": "string",
"maxLength": 64,
"format": "date-time"
},
"players": {
"type": "array",
"maxItems": 1000,
"items": {
"type": "object",
"additionalProperties": false,
"required": ["playerId", "playerName", "status"],
"properties": {
"playerId": {
"type": "string",
"pattern": "^[A-Za-z0-9_.:-]{1,96}$"
},
"playerName": {
"type": "string",
"minLength": 1,
"maxLength": 80
},
"status": {
"type": "string",
"enum": ["online", "offline", "unknown"]
},
"squadId": {
"type": "string",
"pattern": "^[A-Za-z0-9_.:-]{1,96}$"
},
"pingMs": {
"type": "integer",
"minimum": 0,
"maximum": 10000
},
"lastSeenAt": {
"type": "string",
"maxLength": 64,
"format": "date-time"
},
"position": {
"type": "object",
"additionalProperties": false,
"required": ["x", "y", "z"],
"properties": {
"x": { "type": "number", "minimum": -100000, "maximum": 100000 },
"y": { "type": "number", "minimum": -100000, "maximum": 100000 },
"z": { "type": "number", "minimum": -100000, "maximum": 100000 }
}
},
"tags": {
"type": "array",
"maxItems": 16,
"uniqueItems": true,
"items": {
"type": "string",
"minLength": 1,
"maxLength": 40,
"pattern": "^[A-Za-z0-9_.:-]+$"
}
}
}
}
}
}
}
@@ -0,0 +1,10 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "SCUMFlagOwnershipParameters",
"type": "object",
"additionalProperties": false,
"required": ["flagId"],
"properties": {
"flagId": { "type": "string", "minLength": 1, "maxLength": 96, "pattern": "^[A-Za-z0-9_.:-]{1,96}$" }
}
}
@@ -0,0 +1,27 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "SCUMFlagOwnershipResult",
"type": "object",
"additionalProperties": false,
"required": ["ownership"],
"properties": {
"ownership": {
"type": "array",
"maxItems": 1,
"items": {
"type": "object",
"additionalProperties": false,
"required": ["flagId", "status"],
"properties": {
"flagId": { "type": "string", "minLength": 1, "maxLength": 96, "pattern": "^[A-Za-z0-9_.:-]{1,96}$" },
"ownerPlayerId": { "type": "string", "minLength": 1, "maxLength": 96, "pattern": "^[A-Za-z0-9_.:-]{1,96}$" },
"ownerPlayerName": { "type": "string", "minLength": 1, "maxLength": 80 },
"squadId": { "type": "string", "minLength": 1, "maxLength": 96, "pattern": "^[A-Za-z0-9_.:-]{1,96}$" },
"squadName": { "type": "string", "minLength": 1, "maxLength": 80 },
"status": { "type": "string", "minLength": 1, "maxLength": 16, "enum": ["active", "inactive", "contested", "unknown"] },
"lastUpdatedAt": { "type": "string", "minLength": 1, "maxLength": 64, "format": "date-time" }
}
}
}
}
}
@@ -0,0 +1,15 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "SCUMPlayerByIdParameters",
"type": "object",
"additionalProperties": false,
"required": ["playerId"],
"properties": {
"playerId": {
"type": "string",
"minLength": 1,
"maxLength": 96,
"pattern": "^[A-Za-z0-9_.:-]{1,96}$"
}
}
}
@@ -0,0 +1,26 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "SCUMPlayerByIdResult",
"type": "object",
"additionalProperties": false,
"required": ["players"],
"properties": {
"players": {
"type": "array",
"maxItems": 1,
"items": {
"type": "object",
"additionalProperties": false,
"required": ["playerId", "playerName"],
"properties": {
"playerId": { "type": "string", "minLength": 1, "maxLength": 96, "pattern": "^[A-Za-z0-9_.:-]{1,96}$" },
"playerName": { "type": "string", "minLength": 1, "maxLength": 80 },
"platformUserId": { "type": "string", "minLength": 1, "maxLength": 96, "pattern": "^[A-Za-z0-9_.:-]{1,96}$" },
"squadId": { "type": "string", "minLength": 1, "maxLength": 96, "pattern": "^[A-Za-z0-9_.:-]{1,96}$" },
"famePoints": { "type": "integer", "minimum": 0, "maximum": 2147483647 },
"lastSeenAt": { "type": "string", "minLength": 1, "maxLength": 64, "format": "date-time" }
}
}
}
}
}
@@ -0,0 +1,11 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "SCUMPlayerSearchParameters",
"type": "object",
"additionalProperties": false,
"required": ["nameContains", "limit"],
"properties": {
"nameContains": { "type": "string", "minLength": 1, "maxLength": 80 },
"limit": { "type": "integer", "minimum": 1, "maximum": 50 }
}
}
@@ -0,0 +1,27 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "SCUMPlayerSearchResult",
"type": "object",
"additionalProperties": false,
"required": ["players"],
"properties": {
"players": {
"type": "array",
"maxItems": 50,
"items": {
"type": "object",
"additionalProperties": false,
"required": ["playerId", "playerName", "online"],
"properties": {
"playerId": { "type": "string", "minLength": 1, "maxLength": 96, "pattern": "^[A-Za-z0-9_.:-]{1,96}$" },
"playerName": { "type": "string", "minLength": 1, "maxLength": 80 },
"platformUserId": { "type": "string", "minLength": 1, "maxLength": 96, "pattern": "^[A-Za-z0-9_.:-]{1,96}$" },
"squadId": { "type": "string", "minLength": 1, "maxLength": 96, "pattern": "^[A-Za-z0-9_.:-]{1,96}$" },
"online": { "type": "boolean" },
"lastSeenAt": { "type": "string", "minLength": 1, "maxLength": 64, "format": "date-time" }
}
}
},
"truncated": { "type": "boolean" }
}
}
@@ -0,0 +1,11 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "SCUMSquadMembersParameters",
"type": "object",
"additionalProperties": false,
"required": ["squadId", "limit"],
"properties": {
"squadId": { "type": "string", "minLength": 1, "maxLength": 96, "pattern": "^[A-Za-z0-9_.:-]{1,96}$" },
"limit": { "type": "integer", "minimum": 1, "maximum": 64 }
}
}
@@ -0,0 +1,26 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "SCUMSquadMembersResult",
"type": "object",
"additionalProperties": false,
"required": ["members"],
"properties": {
"members": {
"type": "array",
"maxItems": 64,
"items": {
"type": "object",
"additionalProperties": false,
"required": ["squadId", "playerId", "playerName", "role"],
"properties": {
"squadId": { "type": "string", "minLength": 1, "maxLength": 96, "pattern": "^[A-Za-z0-9_.:-]{1,96}$" },
"playerId": { "type": "string", "minLength": 1, "maxLength": 96, "pattern": "^[A-Za-z0-9_.:-]{1,96}$" },
"playerName": { "type": "string", "minLength": 1, "maxLength": 80 },
"role": { "type": "string", "minLength": 1, "maxLength": 16, "enum": ["leader", "member", "unknown"] },
"joinedAt": { "type": "string", "minLength": 1, "maxLength": 64, "format": "date-time" }
}
}
},
"truncated": { "type": "boolean" }
}
}
@@ -0,0 +1,10 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "SCUMVehicleOwnerParameters",
"type": "object",
"additionalProperties": false,
"required": ["vehicleId"],
"properties": {
"vehicleId": { "type": "string", "minLength": 1, "maxLength": 96, "pattern": "^[A-Za-z0-9_.:-]{1,96}$" }
}
}
@@ -0,0 +1,26 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "SCUMVehicleOwnerResult",
"type": "object",
"additionalProperties": false,
"required": ["ownership"],
"properties": {
"ownership": {
"type": "array",
"maxItems": 1,
"items": {
"type": "object",
"additionalProperties": false,
"required": ["vehicleId", "vehicleType", "status"],
"properties": {
"vehicleId": { "type": "string", "minLength": 1, "maxLength": 96, "pattern": "^[A-Za-z0-9_.:-]{1,96}$" },
"vehicleType": { "type": "string", "minLength": 1, "maxLength": 80 },
"ownerPlayerId": { "type": "string", "minLength": 1, "maxLength": 96, "pattern": "^[A-Za-z0-9_.:-]{1,96}$" },
"ownerPlayerName": { "type": "string", "minLength": 1, "maxLength": 80 },
"squadId": { "type": "string", "minLength": 1, "maxLength": 96, "pattern": "^[A-Za-z0-9_.:-]{1,96}$" },
"status": { "type": "string", "minLength": 1, "maxLength": 16, "enum": ["owned", "unowned", "unknown"] }
}
}
}
}
}
@@ -0,0 +1,24 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "SCUMRestartPreparePayload",
"type": "object",
"additionalProperties": false,
"required": ["warningSeconds", "reason"],
"properties": {
"warningSeconds": {
"type": "integer",
"minimum": 30,
"maximum": 3600
},
"reason": {
"type": "string",
"minLength": 1,
"maxLength": 200
},
"scheduleAt": {
"type": "string",
"maxLength": 64,
"format": "date-time"
}
}
}
@@ -0,0 +1,26 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "SCUMRestartPrepareResult",
"type": "object",
"additionalProperties": false,
"required": ["accepted", "status"],
"properties": {
"accepted": {
"type": "boolean"
},
"status": {
"type": "string",
"maxLength": 16,
"enum": ["prepared", "queued", "rejected"]
},
"restartId": {
"type": "string",
"maxLength": 96,
"pattern": "^[A-Za-z0-9_.:-]{1,96}$"
},
"message": {
"type": "string",
"maxLength": 200
}
}
}
@@ -0,0 +1,29 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "SCUMRewardDeliverPayload",
"type": "object",
"additionalProperties": false,
"required": ["playerId", "itemId", "quantity"],
"properties": {
"playerId": {
"type": "string",
"maxLength": 96,
"pattern": "^[A-Za-z0-9_.:-]{1,96}$"
},
"itemId": {
"type": "string",
"maxLength": 96,
"pattern": "^[A-Za-z0-9_.:-]{1,96}$"
},
"quantity": {
"type": "integer",
"minimum": 1,
"maximum": 100
},
"reason": {
"type": "string",
"minLength": 1,
"maxLength": 200
}
}
}
@@ -0,0 +1,26 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "SCUMRewardDeliverResult",
"type": "object",
"additionalProperties": false,
"required": ["accepted", "status"],
"properties": {
"accepted": {
"type": "boolean"
},
"status": {
"type": "string",
"maxLength": 16,
"enum": ["queued", "delivered", "rejected"]
},
"deliveryId": {
"type": "string",
"maxLength": 96,
"pattern": "^[A-Za-z0-9_.:-]{1,96}$"
},
"message": {
"type": "string",
"maxLength": 200
}
}
}
@@ -0,0 +1,62 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "SCUMSquadsSnapshot",
"type": "object",
"additionalProperties": false,
"required": ["observedAt", "squads"],
"properties": {
"observedAt": {
"type": "string",
"maxLength": 64,
"format": "date-time"
},
"squads": {
"type": "array",
"maxItems": 256,
"items": {
"type": "object",
"additionalProperties": false,
"required": ["squadId", "name", "memberCount"],
"properties": {
"squadId": {
"type": "string",
"pattern": "^[A-Za-z0-9_.:-]{1,96}$"
},
"name": {
"type": "string",
"minLength": 1,
"maxLength": 80
},
"memberCount": {
"type": "integer",
"minimum": 0,
"maximum": 64
},
"leaderPlayerId": {
"type": "string",
"pattern": "^[A-Za-z0-9_.:-]{1,96}$"
},
"memberPlayerIds": {
"type": "array",
"maxItems": 64,
"uniqueItems": true,
"items": {
"type": "string",
"pattern": "^[A-Za-z0-9_.:-]{1,96}$"
}
},
"createdAt": {
"type": "string",
"maxLength": 64,
"format": "date-time"
},
"lastActiveAt": {
"type": "string",
"maxLength": 64,
"format": "date-time"
}
}
}
}
}
}
@@ -0,0 +1,71 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "SCUMVehiclesSnapshot",
"type": "object",
"additionalProperties": false,
"required": ["observedAt", "vehicles"],
"properties": {
"observedAt": {
"type": "string",
"maxLength": 64,
"format": "date-time"
},
"vehicles": {
"type": "array",
"maxItems": 2000,
"items": {
"type": "object",
"additionalProperties": false,
"required": ["vehicleId", "vehicleType", "status"],
"properties": {
"vehicleId": {
"type": "string",
"pattern": "^[A-Za-z0-9_.:-]{1,96}$"
},
"vehicleType": {
"type": "string",
"minLength": 1,
"maxLength": 80
},
"status": {
"type": "string",
"enum": ["parked", "in_use", "damaged", "destroyed", "unknown"]
},
"ownerPlayerId": {
"type": "string",
"pattern": "^[A-Za-z0-9_.:-]{1,96}$"
},
"squadId": {
"type": "string",
"pattern": "^[A-Za-z0-9_.:-]{1,96}$"
},
"fuelPercent": {
"type": "number",
"minimum": 0,
"maximum": 100
},
"healthPercent": {
"type": "number",
"minimum": 0,
"maximum": 100
},
"position": {
"type": "object",
"additionalProperties": false,
"required": ["x", "y", "z"],
"properties": {
"x": { "type": "number", "minimum": -100000, "maximum": 100000 },
"y": { "type": "number", "minimum": -100000, "maximum": 100000 },
"z": { "type": "number", "minimum": -100000, "maximum": 100000 }
}
},
"lastSeenAt": {
"type": "string",
"maxLength": 64,
"format": "date-time"
}
}
}
}
}
}
@@ -0,0 +1,42 @@
{
"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"
}
}
@@ -0,0 +1,97 @@
{
"$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": 6,
"uniqueItems": true,
"items": { "enum": ["component.register", "component.heartbeat", "component.health", "component.control", "game-client.bridge", "logs.stream"] },
"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}$"
}
}
}
@@ -0,0 +1,14 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"additionalProperties": false,
"required": ["occurredAt", "adminActorId", "actionCategory", "approved"],
"properties": {
"occurredAt": { "type": "string", "format": "date-time", "minLength": 1, "maxLength": 40 },
"adminActorId": { "type": "string", "minLength": 1, "maxLength": 96 },
"actionCategory": { "type": "string", "enum": ["announcement", "teleport", "spawn", "kick", "ban", "unban", "restart", "config-review", "other"] },
"targetPlayerId": { "type": "string", "minLength": 1, "maxLength": 96 },
"reason": { "type": "string", "minLength": 1, "maxLength": 256 },
"approved": { "type": "boolean" }
}
}
@@ -0,0 +1,13 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"additionalProperties": false,
"required": ["occurredAt", "playerId", "playerName", "channel", "message"],
"properties": {
"occurredAt": { "type": "string", "format": "date-time", "minLength": 1, "maxLength": 40 },
"playerId": { "type": "string", "minLength": 1, "maxLength": 96 },
"playerName": { "type": "string", "minLength": 1, "maxLength": 80 },
"channel": { "type": "string", "enum": ["local", "global", "squad", "admin", "unknown"] },
"message": { "type": "string", "minLength": 1, "maxLength": 512 }
}
}
@@ -0,0 +1,16 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"additionalProperties": false,
"required": ["occurredAt", "victimPlayerId", "victimName", "weaponClass", "distanceMeters"],
"properties": {
"occurredAt": { "type": "string", "format": "date-time", "minLength": 1, "maxLength": 40 },
"killerPlayerId": { "type": "string", "minLength": 1, "maxLength": 96 },
"killerName": { "type": "string", "minLength": 1, "maxLength": 80 },
"victimPlayerId": { "type": "string", "minLength": 1, "maxLength": 96 },
"victimName": { "type": "string", "minLength": 1, "maxLength": 80 },
"weaponClass": { "type": "string", "minLength": 1, "maxLength": 80 },
"distanceMeters": { "type": "number", "minimum": 0, "maximum": 5000 },
"suicide": { "type": "boolean" }
}
}
@@ -0,0 +1,14 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"additionalProperties": false,
"required": ["occurredAt", "playerId", "playerName", "sessionId", "outcome"],
"properties": {
"occurredAt": { "type": "string", "format": "date-time", "minLength": 1, "maxLength": 40 },
"playerId": { "type": "string", "minLength": 1, "maxLength": 96 },
"playerName": { "type": "string", "minLength": 1, "maxLength": 80 },
"sessionId": { "type": "string", "minLength": 1, "maxLength": 96 },
"outcome": { "type": "string", "enum": ["accepted", "rejected"] },
"networkFingerprint": { "type": "string", "minLength": 1, "maxLength": 128 }
}
}
@@ -0,0 +1,13 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"additionalProperties": false,
"required": ["occurredAt", "playerId", "playerName", "sessionId", "reason"],
"properties": {
"occurredAt": { "type": "string", "format": "date-time", "minLength": 1, "maxLength": 40 },
"playerId": { "type": "string", "minLength": 1, "maxLength": 96 },
"playerName": { "type": "string", "minLength": 1, "maxLength": 80 },
"sessionId": { "type": "string", "minLength": 1, "maxLength": 96 },
"reason": { "type": "string", "enum": ["disconnect", "timeout", "kicked", "server-stop", "unknown"] }
}
}
@@ -0,0 +1,14 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"additionalProperties": false,
"required": ["occurredAt", "playerId", "action", "mineClass", "zone", "suspicious"],
"properties": {
"occurredAt": { "type": "string", "format": "date-time", "minLength": 1, "maxLength": 40 },
"playerId": { "type": "string", "minLength": 1, "maxLength": 96 },
"action": { "type": "string", "enum": ["placed", "triggered", "detonated", "disarmed", "removed"] },
"mineClass": { "type": "string", "minLength": 1, "maxLength": 80 },
"zone": { "type": "string", "minLength": 1, "maxLength": 32 },
"suspicious": { "type": "boolean" }
}
}
@@ -0,0 +1,13 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"additionalProperties": false,
"required": ["occurredAt", "serverFps", "frameTimeMs", "onlinePlayers", "entityCount"],
"properties": {
"occurredAt": { "type": "string", "format": "date-time", "minLength": 1, "maxLength": 40 },
"serverFps": { "type": "number", "minimum": 0, "maximum": 1000 },
"frameTimeMs": { "type": "number", "minimum": 0, "maximum": 1000 },
"onlinePlayers": { "type": "integer", "minimum": 0, "maximum": 1000 },
"entityCount": { "type": "integer", "minimum": 0, "maximum": 10000000 }
}
}
@@ -0,0 +1,15 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"additionalProperties": false,
"required": ["occurredAt", "playerId", "tradeKind", "itemCount", "currencyDelta", "suspicious"],
"properties": {
"occurredAt": { "type": "string", "format": "date-time", "minLength": 1, "maxLength": 40 },
"playerId": { "type": "string", "minLength": 1, "maxLength": 96 },
"counterpartyPlayerId": { "type": "string", "minLength": 1, "maxLength": 96 },
"tradeKind": { "type": "string", "enum": ["purchase", "sale", "transfer", "unknown"] },
"itemCount": { "type": "integer", "minimum": 0, "maximum": 1000 },
"currencyDelta": { "type": "integer", "minimum": -1000000000, "maximum": 1000000000 },
"suspicious": { "type": "boolean" }
}
}
@@ -0,0 +1,14 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"additionalProperties": false,
"required": ["occurredAt", "playerId", "targetKind", "targetId", "outcome", "suspicious"],
"properties": {
"occurredAt": { "type": "string", "format": "date-time", "minLength": 1, "maxLength": 40 },
"playerId": { "type": "string", "minLength": 1, "maxLength": 96 },
"targetKind": { "type": "string", "enum": ["door", "container", "vehicle", "base", "unknown"] },
"targetId": { "type": "string", "minLength": 1, "maxLength": 128 },
"outcome": { "type": "string", "enum": ["success", "failed", "cancelled"] },
"suspicious": { "type": "boolean" }
}
}