功能修改
This commit is contained in:
+8
-1
@@ -13,7 +13,8 @@ A game management plugin defines how the platform creates and manages one type o
|
||||
- Optional remote access methods and remote run capabilities.
|
||||
- Optional runtime profiles for discovery, lifecycle modes, dependency probes, install plans, log sources, transports, and client-manager builds.
|
||||
- Optional plugin pages hosted by platform_web.
|
||||
- AI/file/log permissions declared for platform authorization.
|
||||
- AI/file/log permissions declared for platform authorization, including `ai.mediation=platform` and `ai.configWritePolicy=review-required`.
|
||||
- Production lifecycle operations, dependency policy, and disruptive approval requirements.
|
||||
|
||||
## Required Directory Plan
|
||||
|
||||
@@ -50,6 +51,7 @@ Plugin pages may request these operations only through bridge helpers:
|
||||
- `createDependencyActionRequest`: check or install declared dependency probes/plans.
|
||||
- `createLogBackfillRequest`: request historical log cursors for declared sources.
|
||||
- `createClientManagerRequest`: generate/download/reset or request safe status/deploy/control/update/rollback/revoke/retry/uninstall operations for declared client-manager packages.
|
||||
- `createProductionPluginLifecycleRequest`: request server-bound install/enable/disable/upgrade/rollback/retire/dependency-check through Platform governance.
|
||||
- `parseClientManagerLifecycleStatus`: whitelist the plugin-visible status, version, health, artifact/job IDs, deployment generation, and allowed actions without component secrets or machine details.
|
||||
|
||||
Bridge envelopes carry operation names, profile keys, target platforms, artifact IDs, checkpoint refs, immutable reviewed dependency plan digests, and idempotency keys only. Dependency install bridge helpers require a `sha256:<64 hex>` reviewed plan digest; Platform re-resolves the declaration and rejects stale or missing approvals. The plugin SDK and manifest validation reject raw run keys, client-manager keys, FTP passwords, rsync endpoints, SQL DSNs, RCON passwords, direct run sockets, host paths, and arbitrary shell snippets.
|
||||
@@ -76,3 +78,8 @@ npm run validate:manifest
|
||||
Current plugin behavior includes SDK bridge contracts, manifest schema validation, the `examples/dev-game-plugin`, `examples/scum-server-plugin`, and `examples/minecraft-server-plugin` fixtures, platform registry metadata registration, marketplace projections, hosted plugin-page bridge execution, platform-mediated lifecycle job dispatch, declared remote access envelopes, runtime profile declarations, target-matched typed dependency plan requests, run distribution envelopes, typed dependency/log backfill requests, and a SCUM-style client-manager declaration with a complete bounded lifecycle contract. Minecraft deliberately remains a no-client-manager example so action gating proves the feature is optional. Marketplace package acquisition, private source credentials, public build-worker sandboxing, remote plugin hosting policies, production KMS/code signing/fleet rollout, and external package distribution remain future work.
|
||||
|
||||
Runtime-profile declarations do not provide a general secret vault, arbitrary machine execution, production code signing/KMS, or fleet orchestration. The durable Client Manager installation/session state, bounded scheduler, process supervisor, and isolated log/artifact/control channels are Platform/Run capabilities; plugins receive only declarations and safe status projections.
|
||||
# Client Manager profile contract
|
||||
|
||||
Plugins may declare a Client Manager profile with version/revision, supported targets, fixed relative executable, deployment mode, lifecycle capabilities, bounded startup/stop/health settings, compatibility constraints, and update policy. Platform enables lifecycle actions only after a real available distribution, complete server binding, an owned online Run endpoint, matching target/revision, and the current component-key generation.
|
||||
|
||||
Plugin pages and SDK bridge responses expose profile declarations, action availability, logical status/health, version/revision, job progress, and safe failure reasons only. They never receive component keys, sessions, secret refs/values, host paths, PIDs, sockets, credentials, DSNs, or direct Run endpoints. Arbitrary shell, raw credentials, and endpoint-bearing declarations are rejected during manifest validation.
|
||||
|
||||
@@ -22,3 +22,6 @@ scripts/local-debug-smoke.sh
|
||||
```
|
||||
|
||||
That workflow registers `plugins/examples/dev-game-plugin/manifest.json`, creates a safe `server-local-debug` fixture through platform APIs, and verifies that plugin/browser evidence exposes only logical IDs, platform routes, job refs, log refs, artifact refs, and safe metadata.
|
||||
# Lifecycle development checks
|
||||
|
||||
When exercising a Client Manager locally, use the real Platform build job and the independent Run checkout. Verify build-to-deploy with a pinned approved HTTPS revision, checksum and chunk resume, then register/heartbeat through the component contract. Key reset must make the old generation unavailable and require rebuild/redeploy. Do not replace lifecycle jobs with JSON plans, synthetic artifacts, shell commands, or local success flags.
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"process.stop",
|
||||
"process.restart",
|
||||
"process.status",
|
||||
"config.write",
|
||||
"files.list",
|
||||
"files.read",
|
||||
"files.patch",
|
||||
@@ -33,6 +34,7 @@
|
||||
"logs.query",
|
||||
"artifacts.open",
|
||||
"files.request",
|
||||
"plugin-lifecycle.request",
|
||||
"ai.invoke"
|
||||
]
|
||||
},
|
||||
@@ -54,6 +56,11 @@
|
||||
"restart": "actions/restart.json",
|
||||
"status": "actions/status.json"
|
||||
},
|
||||
"productionLifecycle": {
|
||||
"operations": ["install", "enable", "disable", "upgrade", "rollback", "retire", "dependency-check"],
|
||||
"dependencyPolicy": "optional",
|
||||
"approvalRequired": ["disable", "rollback", "retire"]
|
||||
},
|
||||
"pages": [
|
||||
{
|
||||
"key": "overview",
|
||||
@@ -78,6 +85,8 @@
|
||||
}
|
||||
],
|
||||
"ai": {
|
||||
"purposes": ["config.suggest", "logs.diagnose"]
|
||||
"purposes": ["config.suggest", "logs.diagnose"],
|
||||
"mediation": "platform",
|
||||
"configWritePolicy": "review-required"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,7 +68,8 @@
|
||||
"ai.invoke",
|
||||
"run.distribution.request",
|
||||
"dependencies.request",
|
||||
"logs.backfill.request"
|
||||
"logs.backfill.request",
|
||||
"plugin-lifecycle.request"
|
||||
]
|
||||
},
|
||||
"permissions": [
|
||||
@@ -92,6 +93,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",
|
||||
@@ -138,7 +144,9 @@
|
||||
"purposes": [
|
||||
"config.suggest",
|
||||
"logs.diagnose"
|
||||
]
|
||||
],
|
||||
"mediation": "platform",
|
||||
"configWritePolicy": "review-required"
|
||||
},
|
||||
"runtimeProfiles": {
|
||||
"discovery": [
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+37
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+29
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+26
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+45
@@ -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_.:-]+$"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+10
@@ -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}$" }
|
||||
}
|
||||
}
|
||||
+27
@@ -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" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+15
@@ -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}$"
|
||||
}
|
||||
}
|
||||
}
|
||||
+26
@@ -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" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -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 }
|
||||
}
|
||||
}
|
||||
+27
@@ -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" }
|
||||
}
|
||||
}
|
||||
+11
@@ -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 }
|
||||
}
|
||||
}
|
||||
+26
@@ -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" }
|
||||
}
|
||||
}
|
||||
+10
@@ -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}$" }
|
||||
}
|
||||
}
|
||||
+26
@@ -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"] }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+24
@@ -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" }
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
"$id": "https://browser.local/schemas/game-plugin.manifest.schema.json",
|
||||
"title": "GamePluginManifest",
|
||||
"type": "object",
|
||||
"required": ["id", "name", "version", "kind", "server", "capabilities", "permissions"],
|
||||
"required": ["id", "name", "version", "kind", "server", "capabilities", "permissions", "productionLifecycle"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"$schema": { "type": "string" },
|
||||
@@ -42,6 +42,9 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"gameClientBridge": {
|
||||
"$ref": "#/$defs/gameClientBridgeManifest"
|
||||
},
|
||||
"capabilities": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/$defs/runCapability" },
|
||||
@@ -106,6 +109,12 @@
|
||||
"items": { "$ref": "#/$defs/runtimeLogSource" },
|
||||
"uniqueItems": true
|
||||
},
|
||||
"logEvents": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/$defs/runtimeLogEvent" },
|
||||
"uniqueItems": true,
|
||||
"maxItems": 128
|
||||
},
|
||||
"transportProfiles": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/$defs/runtimeTransportProfile" },
|
||||
@@ -130,6 +139,25 @@
|
||||
"status": { "$ref": "#/$defs/relativeJsonRef" }
|
||||
}
|
||||
},
|
||||
"productionLifecycle": {
|
||||
"type": "object",
|
||||
"required": ["operations", "dependencyPolicy", "approvalRequired"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"operations": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/$defs/productionLifecycleOperation" },
|
||||
"uniqueItems": true,
|
||||
"minItems": 1
|
||||
},
|
||||
"dependencyPolicy": { "enum": ["required", "optional"] },
|
||||
"approvalRequired": {
|
||||
"type": "array",
|
||||
"items": { "enum": ["disable", "rollback", "retire"] },
|
||||
"uniqueItems": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"pages": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
@@ -147,13 +175,124 @@
|
||||
},
|
||||
"ai": {
|
||||
"type": "object",
|
||||
"required": ["mediation", "configWritePolicy"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"purposes": { "type": "array", "items": { "$ref": "#/$defs/aiPurpose" }, "uniqueItems": true }
|
||||
"purposes": { "type": "array", "items": { "$ref": "#/$defs/aiPurpose" }, "uniqueItems": true },
|
||||
"mediation": { "const": "platform" },
|
||||
"configWritePolicy": { "const": "review-required" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"$defs": {
|
||||
"gameClientBridgeManifest": {
|
||||
"type": "object",
|
||||
"required": ["commands", "snapshots", "commandRetentionSeconds", "maxCommands"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"commands": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/$defs/gameClientBridgeCommand" },
|
||||
"maxItems": 128
|
||||
},
|
||||
"snapshots": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/$defs/gameClientBridgeSnapshot" },
|
||||
"maxItems": 128
|
||||
},
|
||||
"queryTemplates": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/$defs/gameClientBridgeQueryTemplate" },
|
||||
"maxItems": 128
|
||||
},
|
||||
"commandRetentionSeconds": { "type": "integer", "minimum": 1, "maximum": 31536000 },
|
||||
"maxCommands": { "type": "integer", "minimum": 1, "maximum": 100000 },
|
||||
"pages": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/$defs/gameClientBridgePageContract" },
|
||||
"maxItems": 64
|
||||
},
|
||||
"companion": { "$ref": "#/$defs/gameClientBridgeCompanion" }
|
||||
}
|
||||
},
|
||||
"gameClientBridgeCompanion": {
|
||||
"type": "object",
|
||||
"required": ["profileKey", "configTemplateKey", "configSchemaRef", "configFormat", "platformBaseUrlSource", "registrationProof", "proofMaterialSource", "proofMaterialEnv", "sessionMode", "tlsPolicy", "heartbeatIntervalSeconds", "commandPollIntervalSeconds", "requestTimeoutSeconds"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"profileKey": { "$ref": "#/$defs/logicalKey" },
|
||||
"configTemplateKey": { "$ref": "#/$defs/logicalKey" },
|
||||
"configSchemaRef": { "$ref": "#/$defs/relativeJsonRef" },
|
||||
"configFormat": { "const": "yaml" },
|
||||
"platformBaseUrlSource": { "const": "run-control" },
|
||||
"registrationProof": { "const": "hmac-sha256" },
|
||||
"proofMaterialSource": { "const": "component-package" },
|
||||
"proofMaterialEnv": { "type": "string", "pattern": "^[A-Z][A-Z0-9_]{2,63}$" },
|
||||
"sessionMode": { "const": "component-session" },
|
||||
"tlsPolicy": { "const": "verify-system-roots" },
|
||||
"heartbeatIntervalSeconds": { "type": "integer", "minimum": 5, "maximum": 300 },
|
||||
"commandPollIntervalSeconds": { "type": "integer", "minimum": 1, "maximum": 60 },
|
||||
"requestTimeoutSeconds": { "type": "integer", "minimum": 1, "maximum": 60 }
|
||||
}
|
||||
},
|
||||
"gameClientBridgeCommand": {
|
||||
"type": "object",
|
||||
"required": ["type", "title", "permission", "approvalLevel", "payloadSchemaRef", "timeoutSeconds", "maxPayloadBytes"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"type": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,159}$" },
|
||||
"title": { "type": "string", "minLength": 1, "maxLength": 80 },
|
||||
"permission": { "$ref": "#/$defs/pluginPermission" },
|
||||
"approvalLevel": { "enum": ["none", "operator", "platform-admin"] },
|
||||
"payloadSchemaRef": { "$ref": "#/$defs/relativeJsonRef" },
|
||||
"resultSchemaRef": { "$ref": "#/$defs/relativeJsonRef" },
|
||||
"timeoutSeconds": { "type": "integer", "minimum": 1, "maximum": 3600 },
|
||||
"maxPayloadBytes": { "type": "integer", "minimum": 1, "maximum": 65536 }
|
||||
}
|
||||
},
|
||||
"gameClientBridgeSnapshot": {
|
||||
"type": "object",
|
||||
"required": ["type", "schemaVersion", "schemaRef", "keepForSeconds", "maxRecords"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"type": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,159}$" },
|
||||
"schemaVersion": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,159}$" },
|
||||
"schemaRef": { "$ref": "#/$defs/relativeJsonRef" },
|
||||
"keepForSeconds": { "type": "integer", "minimum": 1, "maximum": 2678400 },
|
||||
"maxRecords": { "type": "integer", "minimum": 1, "maximum": 10000 }
|
||||
}
|
||||
},
|
||||
"gameClientBridgeQueryTemplate": {
|
||||
"type": "object",
|
||||
"required": ["key", "title", "permission", "engine", "transportKey", "targetKey", "parameterSchemaRef", "resultSchemaRef", "maxRows", "timeoutSeconds"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"key": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,159}$" },
|
||||
"title": { "type": "string", "minLength": 1, "maxLength": 80 },
|
||||
"permission": { "$ref": "#/$defs/pluginPermission" },
|
||||
"engine": { "const": "sqlite" },
|
||||
"transportKey": { "$ref": "#/$defs/logicalKey" },
|
||||
"targetKey": { "$ref": "#/$defs/logicalKey" },
|
||||
"parameterSchemaRef": { "$ref": "#/$defs/relativeJsonRef" },
|
||||
"resultSchemaRef": { "$ref": "#/$defs/relativeJsonRef" },
|
||||
"maxRows": { "type": "integer", "minimum": 1, "maximum": 500 },
|
||||
"timeoutSeconds": { "type": "integer", "minimum": 1, "maximum": 60 }
|
||||
}
|
||||
},
|
||||
"gameClientBridgePageContract": {
|
||||
"type": "object",
|
||||
"required": ["pageKey"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"pageKey": { "type": "string", "pattern": "^[a-z0-9][a-z0-9-]*$" },
|
||||
"commandTypes": { "type": "array", "items": { "type": "string" }, "uniqueItems": true },
|
||||
"snapshotTypes": { "type": "array", "items": { "type": "string" }, "uniqueItems": true },
|
||||
"queryTemplateKeys": { "type": "array", "items": { "type": "string" }, "uniqueItems": true }
|
||||
}
|
||||
},
|
||||
"productionLifecycleOperation": {
|
||||
"enum": ["install", "enable", "disable", "upgrade", "rollback", "retire", "dependency-check"]
|
||||
},
|
||||
"relativeJsonRef": {
|
||||
"type": "string",
|
||||
"pattern": "^(?!/)(?![A-Za-z]:)(?!.*://)(?!.*\\.\\.)[a-zA-Z0-9_./-]+\\.json$"
|
||||
@@ -165,6 +304,7 @@
|
||||
"process.stop",
|
||||
"process.restart",
|
||||
"process.status",
|
||||
"config.write",
|
||||
"files.list",
|
||||
"files.read",
|
||||
"files.write",
|
||||
@@ -206,6 +346,9 @@
|
||||
"server.run.distribution",
|
||||
"server.dependencies.manage",
|
||||
"server.client-manager.manage",
|
||||
"server.game-client.read",
|
||||
"server.game-client.command",
|
||||
"server.game-client.maintenance",
|
||||
"ai.invoke"
|
||||
]
|
||||
},
|
||||
@@ -221,6 +364,7 @@
|
||||
"dependencies.request",
|
||||
"logs.backfill.request",
|
||||
"client-manager.request",
|
||||
"plugin-lifecycle.request",
|
||||
"ai.invoke"
|
||||
]
|
||||
},
|
||||
@@ -361,6 +505,21 @@
|
||||
"retentionDays": { "type": "integer", "minimum": 1, "maximum": 365 }
|
||||
}
|
||||
},
|
||||
"runtimeLogEvent": {
|
||||
"type": "object",
|
||||
"required": ["key", "title", "sourceKey", "eventType", "permission", "schemaRef", "retentionDays", "severity"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"key": { "$ref": "#/$defs/logicalKey" },
|
||||
"title": { "type": "string", "minLength": 1, "maxLength": 80 },
|
||||
"sourceKey": { "$ref": "#/$defs/logicalKey" },
|
||||
"eventType": { "type": "string", "pattern": "^[a-z0-9][a-z0-9._-]{0,119}$" },
|
||||
"permission": { "$ref": "#/$defs/pluginPermission" },
|
||||
"schemaRef": { "$ref": "#/$defs/relativeJsonRef" },
|
||||
"retentionDays": { "type": "integer", "minimum": 1, "maximum": 365 },
|
||||
"severity": { "enum": ["info", "notice", "warning", "critical"] }
|
||||
}
|
||||
},
|
||||
"runtimeTransportProfile": {
|
||||
"type": "object",
|
||||
"required": ["key", "kind", "capabilities"],
|
||||
|
||||
@@ -115,6 +115,179 @@ function isSafeRelativeJsonRef(value: string): boolean {
|
||||
return /^(?!\/)(?![A-Za-z]:)(?!.*:\/\/)(?!.*\.\.)[a-zA-Z0-9_./-]+\.json$/.test(value);
|
||||
}
|
||||
|
||||
function identifierTokens(value: string): string[] {
|
||||
return value
|
||||
.replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2")
|
||||
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
||||
.toLowerCase()
|
||||
.split(/[^a-z0-9]+/)
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function unsafeGameClientBridgeCommandTypeReason(value: string): string | undefined {
|
||||
const tokens = identifierTokens(value);
|
||||
const tokenSet = new Set(tokens);
|
||||
if (
|
||||
tokenSet.has("sql") ||
|
||||
((tokenSet.has("database") || tokenSet.has("db")) && tokens.some((token) => ["execute", "exec", "eval", "run", "query", "statement"].includes(token))) ||
|
||||
(tokenSet.has("query") && tokens.some((token) => ["execute", "exec", "eval", "raw", "statement"].includes(token)))
|
||||
) {
|
||||
return "arbitrary SQL or database execution command declarations are not allowed";
|
||||
}
|
||||
if (
|
||||
tokens.some((token) => token === "shell" || token === "powershell" || token === "script" || token === "terminal") ||
|
||||
tokens.some((token) => token === "execute" || token === "exec" || token === "eval") ||
|
||||
((tokenSet.has("command") || tokenSet.has("cmd") || tokenSet.has("process") || tokenSet.has("system") || tokenSet.has("os") || tokenSet.has("executor")) && tokenSet.has("run"))
|
||||
) {
|
||||
return "arbitrary shell, script, terminal, or generic execution command declarations are not allowed";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function unsafeBridgeSchemaFieldReason(fieldName: string): string | undefined {
|
||||
const tokens = identifierTokens(fieldName);
|
||||
const compact = tokens.join("");
|
||||
const generalReason = unsafeFieldReason(fieldName);
|
||||
if (generalReason) {
|
||||
return generalReason;
|
||||
}
|
||||
if ((tokens.includes("sql") || tokens.includes("query")) && tokens.includes("template") && (tokens.includes("key") || tokens.includes("ref"))) {
|
||||
return undefined;
|
||||
}
|
||||
if (["sql", "rawsql", "sqltext", "sqlquery", "sqlstatement", "rawquery", "statement"].includes(compact)) {
|
||||
return "arbitrary SQL field is not allowed";
|
||||
}
|
||||
if (["shell", "shellcommand", "shellscript", "script", "scriptbody", "terminalcommand", "commandline", "powershell"].includes(compact)) {
|
||||
return "arbitrary shell or script field is not allowed";
|
||||
}
|
||||
if (["hostpath", "rawpath", "absolutepath", "filesystempath"].includes(compact)) {
|
||||
return "raw host path field is not allowed";
|
||||
}
|
||||
if (["runcapability", "executorcapability", "runendpoint", "runsocket", "directrun"].includes(compact)) {
|
||||
return "unsafe executor capability or direct Run field is not allowed";
|
||||
}
|
||||
if (tokens.some((token) => ["socket", "password", "credential", "secret", "token", "dsn"].includes(token))) {
|
||||
return "direct socket or raw credential field is not allowed";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function unsafeBridgeSchemaStringReasons(value: string): string[] {
|
||||
const reasons = [...unsafeStringReasons(value)];
|
||||
const trimmed = value.trim();
|
||||
const fieldReason = unsafeBridgeSchemaFieldReason(trimmed);
|
||||
if (fieldReason) {
|
||||
reasons.push(fieldReason);
|
||||
}
|
||||
if (/\bselect\b[\s\S]{0,240}\bfrom\b/i.test(trimmed) || /\b(?:insert\s+into|update\s+[a-z0-9_.]+\s+set|delete\s+from|drop\s+table|alter\s+table|create\s+table|attach\s+database|pragma\s+[a-z0-9_]+)/i.test(trimmed)) {
|
||||
reasons.push("arbitrary SQL content is not allowed");
|
||||
}
|
||||
if (/^\s*(?:sh|bash|zsh|powershell|pwsh)\s+-[a-z]*c\b/i.test(trimmed) || /^\s*cmd(?:\.exe)?\s+\/c\b/i.test(trimmed)) {
|
||||
reasons.push("arbitrary shell content is not allowed");
|
||||
}
|
||||
if (/^(?:run|executor|shell|script|terminal)\.(?:socket|endpoint|exec|execute|command)$/i.test(trimmed)) {
|
||||
reasons.push("unsafe executor capability is not allowed");
|
||||
}
|
||||
return [...new Set(reasons)];
|
||||
}
|
||||
|
||||
function scanUnsafeBridgeSchema(value: unknown, location: string): string[] {
|
||||
if (typeof value === "string") {
|
||||
return unsafeBridgeSchemaStringReasons(value).map((reason) => `${location}: ${reason}`);
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.flatMap((item, index) => scanUnsafeBridgeSchema(item, `${location}[${index}]`));
|
||||
}
|
||||
if (typeof value === "object" && value !== null) {
|
||||
return Object.entries(value).flatMap(([key, child]) => {
|
||||
const keyReason = unsafeBridgeSchemaFieldReason(key);
|
||||
const keyErrors = keyReason ? [`${location}.${key}: ${keyReason}`] : [];
|
||||
return [...keyErrors, ...scanUnsafeBridgeSchema(child, `${location}.${key}`)];
|
||||
});
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function validateBoundedBridgeSchema(value: unknown, location: string): string[] {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
||||
return [`${location}: bridge schema root must be an object schema`];
|
||||
}
|
||||
const root = value as Record<string, unknown>;
|
||||
const errors: string[] = [];
|
||||
if (root.type !== "object") {
|
||||
errors.push(`${location}: bridge schema root type must be object`);
|
||||
}
|
||||
const visit = (node: unknown, nodeLocation: string): void => {
|
||||
if (Array.isArray(node)) {
|
||||
node.forEach((item, index) => visit(item, `${nodeLocation}[${index}]`));
|
||||
return;
|
||||
}
|
||||
if (typeof node !== "object" || node === null) {
|
||||
return;
|
||||
}
|
||||
const record = node as Record<string, unknown>;
|
||||
if ((record.type === "object" || Object.hasOwn(record, "properties")) && record.additionalProperties !== false) {
|
||||
errors.push(`${nodeLocation}.additionalProperties: bounded object schemas must set additionalProperties to false`);
|
||||
}
|
||||
for (const [key, child] of Object.entries(record)) {
|
||||
visit(child, `${nodeLocation}.${key}`);
|
||||
}
|
||||
};
|
||||
visit(root, location);
|
||||
return errors;
|
||||
}
|
||||
|
||||
function unsafeSemanticLogEventTypeReason(value: string): string | undefined {
|
||||
const tokens = identifierTokens(value);
|
||||
const tokenSet = new Set(tokens);
|
||||
if (
|
||||
tokens.some((token) => ["shell", "powershell", "script", "terminal", "execute", "exec", "eval"].includes(token)) ||
|
||||
tokens.some((token) => ["credential", "password", "secret", "socket"].includes(token)) ||
|
||||
(tokenSet.has("run") && (tokenSet.has("direct") || tokenSet.has("socket"))) ||
|
||||
(tokenSet.has("path") && (tokenSet.has("host") || tokenSet.has("raw"))) ||
|
||||
(tokenSet.has("sql") && tokens.some((token) => ["query", "statement", "raw", "execute", "exec"].includes(token)))
|
||||
) {
|
||||
return "unsafe SQL, shell, path, credential, or socket event types are not allowed";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function validateBoundedSemanticLogSchema(value: unknown, location: string): string[] {
|
||||
const errors = validateBoundedBridgeSchema(value, location);
|
||||
const visit = (node: unknown, nodeLocation: string): void => {
|
||||
if (Array.isArray(node)) {
|
||||
node.forEach((item, index) => visit(item, `${nodeLocation}[${index}]`));
|
||||
return;
|
||||
}
|
||||
if (typeof node !== "object" || node === null) {
|
||||
return;
|
||||
}
|
||||
const record = node as Record<string, unknown>;
|
||||
if (record.type === "array") {
|
||||
if (!Number.isInteger(record.maxItems) || (record.maxItems as number) < 1 || (record.maxItems as number) > 1000) {
|
||||
errors.push(`${nodeLocation}.maxItems: bounded event arrays must set maxItems between 1 and 1000`);
|
||||
}
|
||||
}
|
||||
if (record.type === "string" && !Object.hasOwn(record, "enum") && !Object.hasOwn(record, "const")) {
|
||||
if (!Number.isInteger(record.maxLength) || (record.maxLength as number) < 1 || (record.maxLength as number) > 4096) {
|
||||
errors.push(`${nodeLocation}.maxLength: bounded event strings must set maxLength between 1 and 4096`);
|
||||
}
|
||||
}
|
||||
if (record.type === "integer" || record.type === "number") {
|
||||
if (typeof record.minimum !== "number" || !Number.isFinite(record.minimum) || typeof record.maximum !== "number" || !Number.isFinite(record.maximum)) {
|
||||
errors.push(`${nodeLocation}: bounded event numbers must set finite minimum and maximum values`);
|
||||
} else if (record.minimum > record.maximum) {
|
||||
errors.push(`${nodeLocation}: event number minimum must not exceed maximum`);
|
||||
}
|
||||
}
|
||||
for (const [key, child] of Object.entries(record)) {
|
||||
visit(child, `${nodeLocation}.${key}`);
|
||||
}
|
||||
};
|
||||
visit(value, location);
|
||||
return errors;
|
||||
}
|
||||
|
||||
export function validateLifecycleActionFile(actionPath: string, expectedAction?: string): string[] {
|
||||
const action = readJson(path.resolve(rootDir, actionPath));
|
||||
const ajv = new Ajv2020({ allErrors: true });
|
||||
@@ -288,6 +461,539 @@ function validateClientManagerProfiles(manifest: unknown): string[] {
|
||||
return errors;
|
||||
}
|
||||
|
||||
export function validateGameClientBridgeCatalog(manifest: unknown): string[] {
|
||||
if (typeof manifest !== "object" || manifest === null) {
|
||||
return [];
|
||||
}
|
||||
type BridgeCommand = { type?: string; approvalLevel?: string; payloadSchemaRef?: string; resultSchemaRef?: string };
|
||||
type BridgeQueryTemplate = {
|
||||
key?: string;
|
||||
permission?: string;
|
||||
engine?: string;
|
||||
transportKey?: string;
|
||||
targetKey?: string;
|
||||
parameterSchemaRef?: string;
|
||||
resultSchemaRef?: string;
|
||||
maxRows?: number;
|
||||
timeoutSeconds?: number;
|
||||
};
|
||||
type BridgePage = { pageKey?: string; commandTypes?: string[]; snapshotTypes?: string[]; queryTemplateKeys?: string[] };
|
||||
type BridgeCompanion = {
|
||||
profileKey?: string;
|
||||
configTemplateKey?: string;
|
||||
configSchemaRef?: string;
|
||||
heartbeatIntervalSeconds?: number;
|
||||
commandPollIntervalSeconds?: number;
|
||||
requestTimeoutSeconds?: number;
|
||||
registrationProof?: string;
|
||||
proofMaterialSource?: string;
|
||||
proofMaterialEnv?: string;
|
||||
sessionMode?: string;
|
||||
tlsPolicy?: string;
|
||||
};
|
||||
type PluginPage = { key?: string; permissions?: string[]; bridgeActions?: string[] };
|
||||
type RuntimeTransportProfile = { key?: string; kind?: string; targetKey?: string; capabilities?: string[] };
|
||||
type RuntimeClientManager = { key?: string; configTemplates?: Array<{ key?: string; outputRef?: string }>; health?: { intervalSeconds?: number; requiredCapabilities?: string[] } };
|
||||
const declaration = manifest as {
|
||||
capabilities?: string[];
|
||||
permissions?: string[];
|
||||
remoteAccess?: { runCapabilities?: string[]; databaseEngines?: string[] };
|
||||
pages?: PluginPage[];
|
||||
runtimeProfiles?: { transportProfiles?: RuntimeTransportProfile[]; clientManagers?: RuntimeClientManager[] };
|
||||
gameClientBridge?: { commands?: BridgeCommand[]; snapshots?: Array<{ type?: string }>; queryTemplates?: BridgeQueryTemplate[]; pages?: BridgePage[]; companion?: BridgeCompanion };
|
||||
};
|
||||
const bridge = declaration.gameClientBridge;
|
||||
if (!bridge) {
|
||||
return [];
|
||||
}
|
||||
const errors: string[] = [];
|
||||
const commands = new Set<string>();
|
||||
const snapshots = new Set((bridge.snapshots ?? []).map((snapshot) => snapshot.type ?? ""));
|
||||
const queryTemplates = new Map<string, BridgeQueryTemplate>();
|
||||
const declaredPermissions = new Set(declaration.permissions ?? []);
|
||||
const declaredCapabilities = new Set(declaration.capabilities ?? []);
|
||||
const remoteCapabilities = new Set(declaration.remoteAccess?.runCapabilities ?? []);
|
||||
const remoteDatabaseEngines = new Set(declaration.remoteAccess?.databaseEngines ?? []);
|
||||
const transportProfiles = declaration.runtimeProfiles?.transportProfiles ?? [];
|
||||
const companion = bridge.companion;
|
||||
if (companion) {
|
||||
const location = "manifest.gameClientBridge.companion";
|
||||
const manager = declaration.runtimeProfiles?.clientManagers?.find((candidate) => candidate.key === companion.profileKey);
|
||||
if (!manager) {
|
||||
errors.push(`${location}.profileKey: must reference a declared Client Manager profile`);
|
||||
} else {
|
||||
const template = manager.configTemplates?.find((candidate) => candidate.key === companion.configTemplateKey);
|
||||
if (!template) {
|
||||
errors.push(`${location}.configTemplateKey: must reference the Client Manager profile`);
|
||||
} else if (template.outputRef !== "config.yaml") {
|
||||
errors.push(`${location}.configTemplateKey: config template must materialize config.yaml`);
|
||||
}
|
||||
if (manager.health?.intervalSeconds !== companion.heartbeatIntervalSeconds) {
|
||||
errors.push(`${location}.heartbeatIntervalSeconds: must match the Client Manager health interval`);
|
||||
}
|
||||
for (const capability of ["component.register", "component.heartbeat", "component.health", "game-client.bridge"]) {
|
||||
if (!manager.health?.requiredCapabilities?.includes(capability)) {
|
||||
errors.push(`${location}: Client Manager health must require ${capability}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!companion.configSchemaRef || !isSafeRelativeJsonRef(companion.configSchemaRef)) {
|
||||
errors.push(`${location}.configSchemaRef: raw host paths and unsafe schema references are not allowed`);
|
||||
}
|
||||
if (companion.registrationProof !== "hmac-sha256" || companion.proofMaterialSource !== "component-package" || companion.sessionMode !== "component-session" || companion.tlsPolicy !== "verify-system-roots") {
|
||||
errors.push(`${location}: secure component registration/session/TLS policy is required`);
|
||||
}
|
||||
const reservedProofEnvironments = new Set(["COMSPEC", "DYLD_INSERT_LIBRARIES", "DYLD_LIBRARY_PATH", "HOME", "LD_LIBRARY_PATH", "LD_PRELOAD", "PATH", "PATHEXT", "SHELL", "SYSTEMROOT", "TEMP", "TMP", "USERPROFILE", "WINDIR"]);
|
||||
if (!/^[A-Z][A-Z0-9_]{2,63}$/.test(companion.proofMaterialEnv ?? "") || reservedProofEnvironments.has(companion.proofMaterialEnv ?? "")) {
|
||||
errors.push(`${location}.proofMaterialEnv: must be a bounded environment variable name`);
|
||||
}
|
||||
if (!Number.isInteger(companion.commandPollIntervalSeconds) || (companion.commandPollIntervalSeconds ?? 0) < 1 || (companion.commandPollIntervalSeconds ?? 0) > 60) {
|
||||
errors.push(`${location}.commandPollIntervalSeconds: must be between 1 and 60`);
|
||||
}
|
||||
if (!Number.isInteger(companion.requestTimeoutSeconds) || (companion.requestTimeoutSeconds ?? 0) < 1 || (companion.requestTimeoutSeconds ?? 0) > 60) {
|
||||
errors.push(`${location}.requestTimeoutSeconds: must be between 1 and 60`);
|
||||
}
|
||||
}
|
||||
for (const [index, command] of (bridge.commands ?? []).entries()) {
|
||||
const location = `manifest.gameClientBridge.commands[${index}]`;
|
||||
const type = command.type ?? "";
|
||||
const unsafeTypeReason = unsafeGameClientBridgeCommandTypeReason(type);
|
||||
if (unsafeTypeReason) {
|
||||
errors.push(`${location}.type: ${unsafeTypeReason}`);
|
||||
}
|
||||
if (!command.approvalLevel) {
|
||||
errors.push(`${location}.approvalLevel: approval metadata is required`);
|
||||
}
|
||||
for (const [field, ref] of [["payloadSchemaRef", command.payloadSchemaRef], ["resultSchemaRef", command.resultSchemaRef]] as const) {
|
||||
if (ref && !isSafeRelativeJsonRef(ref)) {
|
||||
errors.push(`${location}.${field}: raw host paths and unsafe schema references are not allowed`);
|
||||
}
|
||||
}
|
||||
commands.add(type);
|
||||
}
|
||||
for (const [index, queryTemplate] of (bridge.queryTemplates ?? []).entries()) {
|
||||
const location = `manifest.gameClientBridge.queryTemplates[${index}]`;
|
||||
const key = queryTemplate.key ?? "";
|
||||
if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,159}$/.test(key)) {
|
||||
errors.push(`${location}.key: query template key is unsafe`);
|
||||
}
|
||||
if (queryTemplates.has(key)) {
|
||||
errors.push(`${location}.key: duplicate query template ${key}`);
|
||||
}
|
||||
queryTemplates.set(key, queryTemplate);
|
||||
if (!queryTemplate.permission || !declaredPermissions.has(queryTemplate.permission)) {
|
||||
errors.push(`${location}.permission: permission must be declared by the plugin manifest`);
|
||||
}
|
||||
if (queryTemplate.engine !== "sqlite") {
|
||||
errors.push(`${location}.engine: only sqlite read-only query templates are allowed`);
|
||||
}
|
||||
for (const [field, ref] of [["parameterSchemaRef", queryTemplate.parameterSchemaRef], ["resultSchemaRef", queryTemplate.resultSchemaRef]] as const) {
|
||||
if (!ref || !isSafeRelativeJsonRef(ref)) {
|
||||
errors.push(`${location}.${field}: raw host paths and unsafe schema references are not allowed`);
|
||||
}
|
||||
}
|
||||
if (!Number.isInteger(queryTemplate.maxRows) || (queryTemplate.maxRows ?? 0) < 1 || (queryTemplate.maxRows ?? 0) > 500) {
|
||||
errors.push(`${location}.maxRows: must be an integer between 1 and 500`);
|
||||
}
|
||||
if (!Number.isInteger(queryTemplate.timeoutSeconds) || (queryTemplate.timeoutSeconds ?? 0) < 1 || (queryTemplate.timeoutSeconds ?? 0) > 60) {
|
||||
errors.push(`${location}.timeoutSeconds: must be an integer between 1 and 60`);
|
||||
}
|
||||
const transportProfile = transportProfiles.find((profile) => profile.key === queryTemplate.transportKey);
|
||||
if (!transportProfile) {
|
||||
errors.push(`${location}.transportKey: undeclared transport profile ${queryTemplate.transportKey ?? ""}`);
|
||||
continue;
|
||||
}
|
||||
if (transportProfile.kind !== "sqlite") {
|
||||
errors.push(`${location}.transportKey: transport profile must use sqlite`);
|
||||
}
|
||||
if (!queryTemplate.targetKey || transportProfile.targetKey !== queryTemplate.targetKey) {
|
||||
errors.push(`${location}.targetKey: must match the declared sqlite transport target`);
|
||||
}
|
||||
if (!transportProfile.capabilities?.includes("remote.run.db.sqlite.query")) {
|
||||
errors.push(`${location}.transportKey: sqlite transport must declare remote.run.db.sqlite.query`);
|
||||
}
|
||||
if (!declaredCapabilities.has("remote.run.db.sqlite.query") || !remoteCapabilities.has("remote.run.db.sqlite.query") || !remoteDatabaseEngines.has("sqlite")) {
|
||||
errors.push(`${location}: sqlite query templates require the plugin and remote-access sqlite query capability`);
|
||||
}
|
||||
}
|
||||
for (const [index, page] of (bridge.pages ?? []).entries()) {
|
||||
for (const commandType of page.commandTypes ?? []) {
|
||||
if (!commands.has(commandType)) {
|
||||
errors.push(`manifest.gameClientBridge.pages[${index}].commandTypes: undeclared command ${commandType}`);
|
||||
}
|
||||
}
|
||||
for (const snapshotType of page.snapshotTypes ?? []) {
|
||||
if (!snapshots.has(snapshotType)) {
|
||||
errors.push(`manifest.gameClientBridge.pages[${index}].snapshotTypes: undeclared snapshot ${snapshotType}`);
|
||||
}
|
||||
}
|
||||
for (const queryTemplateKey of page.queryTemplateKeys ?? []) {
|
||||
const queryTemplate = queryTemplates.get(queryTemplateKey);
|
||||
if (!queryTemplate) {
|
||||
errors.push(`manifest.gameClientBridge.pages[${index}].queryTemplateKeys: undeclared query template ${queryTemplateKey}`);
|
||||
continue;
|
||||
}
|
||||
const pluginPage = declaration.pages?.find((candidate) => candidate.key === page.pageKey);
|
||||
if (!pluginPage?.permissions?.includes(queryTemplate.permission ?? "")) {
|
||||
errors.push(`manifest.gameClientBridge.pages[${index}].queryTemplateKeys: page must declare query template permission ${queryTemplate.permission ?? ""}`);
|
||||
}
|
||||
if (!pluginPage?.bridgeActions?.includes("remote.access.request")) {
|
||||
errors.push(`manifest.gameClientBridge.pages[${index}].queryTemplateKeys: page must declare remote.access.request`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
export function validateRuntimeLogEventCatalog(manifest: unknown): string[] {
|
||||
if (typeof manifest !== "object" || manifest === null) {
|
||||
return [];
|
||||
}
|
||||
type RuntimeLogSource = { key?: string; retentionDays?: number };
|
||||
type RuntimeLogEvent = {
|
||||
key?: string;
|
||||
sourceKey?: string;
|
||||
eventType?: string;
|
||||
permission?: string;
|
||||
schemaRef?: string;
|
||||
retentionDays?: number;
|
||||
severity?: string;
|
||||
};
|
||||
const declaration = manifest as {
|
||||
permissions?: string[];
|
||||
runtimeProfiles?: { logSources?: RuntimeLogSource[]; logEvents?: RuntimeLogEvent[] };
|
||||
};
|
||||
const logEvents = declaration.runtimeProfiles?.logEvents ?? [];
|
||||
const logSources = new Map((declaration.runtimeProfiles?.logSources ?? []).map((source) => [source.key ?? "", source]));
|
||||
const permissions = new Set(declaration.permissions ?? []);
|
||||
const keys = new Set<string>();
|
||||
const eventTypes = new Set<string>();
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const [index, event] of logEvents.entries()) {
|
||||
const location = `manifest.runtimeProfiles.logEvents[${index}]`;
|
||||
const key = event.key ?? "";
|
||||
const eventType = event.eventType ?? "";
|
||||
if (keys.has(key)) {
|
||||
errors.push(`${location}.key: duplicate semantic log event key ${key}`);
|
||||
}
|
||||
keys.add(key);
|
||||
if (eventTypes.has(eventType)) {
|
||||
errors.push(`${location}.eventType: duplicate semantic log event type ${eventType}`);
|
||||
}
|
||||
eventTypes.add(eventType);
|
||||
const unsafeTypeReason = unsafeSemanticLogEventTypeReason(eventType);
|
||||
if (unsafeTypeReason) {
|
||||
errors.push(`${location}.eventType: ${unsafeTypeReason}`);
|
||||
}
|
||||
const source = logSources.get(event.sourceKey ?? "");
|
||||
if (!source) {
|
||||
errors.push(`${location}.sourceKey: undeclared log source ${event.sourceKey ?? ""}`);
|
||||
}
|
||||
if (!event.permission || !permissions.has(event.permission)) {
|
||||
errors.push(`${location}.permission: permission must be declared by the plugin manifest`);
|
||||
}
|
||||
if (!event.schemaRef || !isSafeRelativeJsonRef(event.schemaRef)) {
|
||||
errors.push(`${location}.schemaRef: raw host paths and unsafe schema references are not allowed`);
|
||||
}
|
||||
if (!Number.isInteger(event.retentionDays) || (event.retentionDays ?? 0) < 1 || (event.retentionDays ?? 0) > 365) {
|
||||
errors.push(`${location}.retentionDays: must be an integer between 1 and 365`);
|
||||
}
|
||||
if (source?.retentionDays && (event.retentionDays ?? 0) > source.retentionDays) {
|
||||
errors.push(`${location}.retentionDays: must not exceed source retentionDays`);
|
||||
}
|
||||
if (!event.severity || !["info", "notice", "warning", "critical"].includes(event.severity)) {
|
||||
errors.push(`${location}.severity: must be info, notice, warning, or critical`);
|
||||
}
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
type GameClientBridgeSchemaReference = {
|
||||
location: string;
|
||||
ref: string;
|
||||
};
|
||||
|
||||
function referencedGameClientBridgeSchemas(manifest: unknown): GameClientBridgeSchemaReference[] {
|
||||
if (typeof manifest !== "object" || manifest === null) {
|
||||
return [];
|
||||
}
|
||||
type BridgeCommand = { payloadSchemaRef?: string; resultSchemaRef?: string };
|
||||
type BridgeSnapshot = { schemaRef?: string };
|
||||
type BridgeQueryTemplate = { parameterSchemaRef?: string; resultSchemaRef?: string };
|
||||
const bridge = (manifest as { gameClientBridge?: { commands?: BridgeCommand[]; snapshots?: BridgeSnapshot[]; queryTemplates?: BridgeQueryTemplate[] } }).gameClientBridge;
|
||||
if (!bridge) {
|
||||
return [];
|
||||
}
|
||||
const refs: GameClientBridgeSchemaReference[] = [];
|
||||
for (const [index, command] of (bridge.commands ?? []).entries()) {
|
||||
if (command.payloadSchemaRef) {
|
||||
refs.push({ location: `manifest.gameClientBridge.commands[${index}].payloadSchemaRef`, ref: command.payloadSchemaRef });
|
||||
}
|
||||
if (command.resultSchemaRef) {
|
||||
refs.push({ location: `manifest.gameClientBridge.commands[${index}].resultSchemaRef`, ref: command.resultSchemaRef });
|
||||
}
|
||||
}
|
||||
for (const [index, snapshot] of (bridge.snapshots ?? []).entries()) {
|
||||
if (snapshot.schemaRef) {
|
||||
refs.push({ location: `manifest.gameClientBridge.snapshots[${index}].schemaRef`, ref: snapshot.schemaRef });
|
||||
}
|
||||
}
|
||||
for (const [index, queryTemplate] of (bridge.queryTemplates ?? []).entries()) {
|
||||
if (queryTemplate.parameterSchemaRef) {
|
||||
refs.push({ location: `manifest.gameClientBridge.queryTemplates[${index}].parameterSchemaRef`, ref: queryTemplate.parameterSchemaRef });
|
||||
}
|
||||
if (queryTemplate.resultSchemaRef) {
|
||||
refs.push({ location: `manifest.gameClientBridge.queryTemplates[${index}].resultSchemaRef`, ref: queryTemplate.resultSchemaRef });
|
||||
}
|
||||
}
|
||||
return refs;
|
||||
}
|
||||
|
||||
function validateGameClientBridgeSchemaFiles(manifest: unknown, manifestDir: string): string[] {
|
||||
const errors: string[] = [];
|
||||
for (const declaration of referencedGameClientBridgeSchemas(manifest)) {
|
||||
if (!isSafeRelativeJsonRef(declaration.ref)) {
|
||||
errors.push(`${declaration.location}: raw host paths and unsafe schema references are not allowed`);
|
||||
continue;
|
||||
}
|
||||
const schemaPath = path.resolve(manifestDir, declaration.ref);
|
||||
if (!fs.existsSync(schemaPath) || !fs.statSync(schemaPath).isFile()) {
|
||||
errors.push(`${declaration.location}: missing bridge schema file ${declaration.ref}`);
|
||||
continue;
|
||||
}
|
||||
const relativeRealPath = path.relative(fs.realpathSync(manifestDir), fs.realpathSync(schemaPath));
|
||||
if (relativeRealPath === ".." || relativeRealPath.startsWith(`..${path.sep}`) || path.isAbsolute(relativeRealPath)) {
|
||||
errors.push(`${declaration.location}: bridge schema must remain inside the plugin manifest directory`);
|
||||
continue;
|
||||
}
|
||||
let schema: unknown;
|
||||
try {
|
||||
schema = readJson(schemaPath);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "invalid JSON";
|
||||
errors.push(`${declaration.location}: bridge schema is not valid JSON: ${message}`);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const schemaAjv = new Ajv2020({ allErrors: true, strict: false, validateFormats: false });
|
||||
if (!schemaAjv.validateSchema(schema as AnySchema)) {
|
||||
errors.push(...formatErrors(`${declaration.location}.schema`, schemaAjv.errors));
|
||||
} else {
|
||||
schemaAjv.compile(schema as AnySchema);
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "invalid JSON Schema";
|
||||
errors.push(`${declaration.location}: bridge schema is invalid: ${message}`);
|
||||
}
|
||||
errors.push(...scanUnsafeBridgeSchema(schema, `${declaration.location}.schema`));
|
||||
errors.push(...validateBoundedBridgeSchema(schema, `${declaration.location}.schema`));
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
function validateRuntimeLogEventSchemaFiles(manifest: unknown, manifestDir: string): string[] {
|
||||
if (typeof manifest !== "object" || manifest === null) {
|
||||
return [];
|
||||
}
|
||||
const logEvents = (manifest as { runtimeProfiles?: { logEvents?: Array<{ schemaRef?: string }> } }).runtimeProfiles?.logEvents ?? [];
|
||||
const errors: string[] = [];
|
||||
for (const [index, event] of logEvents.entries()) {
|
||||
const location = `manifest.runtimeProfiles.logEvents[${index}].schemaRef`;
|
||||
const ref = event.schemaRef;
|
||||
if (!ref || !isSafeRelativeJsonRef(ref)) {
|
||||
errors.push(`${location}: raw host paths and unsafe schema references are not allowed`);
|
||||
continue;
|
||||
}
|
||||
const schemaPath = path.resolve(manifestDir, ref);
|
||||
if (!fs.existsSync(schemaPath) || !fs.statSync(schemaPath).isFile()) {
|
||||
errors.push(`${location}: missing semantic log event schema file ${ref}`);
|
||||
continue;
|
||||
}
|
||||
const relativeRealPath = path.relative(fs.realpathSync(manifestDir), fs.realpathSync(schemaPath));
|
||||
if (relativeRealPath === ".." || relativeRealPath.startsWith(`..${path.sep}`) || path.isAbsolute(relativeRealPath)) {
|
||||
errors.push(`${location}: semantic log event schema must remain inside the plugin manifest directory`);
|
||||
continue;
|
||||
}
|
||||
let schema: unknown;
|
||||
try {
|
||||
schema = readJson(schemaPath);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "invalid JSON";
|
||||
errors.push(`${location}: semantic log event schema is not valid JSON: ${message}`);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const schemaAjv = new Ajv2020({ allErrors: true, strict: false, validateFormats: false });
|
||||
if (!schemaAjv.validateSchema(schema as AnySchema)) {
|
||||
errors.push(...formatErrors(`${location}.schema`, schemaAjv.errors));
|
||||
} else {
|
||||
schemaAjv.compile(schema as AnySchema);
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "invalid JSON Schema";
|
||||
errors.push(`${location}: semantic log event schema is invalid: ${message}`);
|
||||
}
|
||||
errors.push(...scanUnsafeBridgeSchema(schema, `${location}.schema`));
|
||||
errors.push(...validateBoundedSemanticLogSchema(schema, `${location}.schema`));
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
type CompanionConfigDeclaration = {
|
||||
profileKey?: string;
|
||||
configSchemaRef?: string;
|
||||
registrationProof?: string;
|
||||
proofMaterialEnv?: string;
|
||||
sessionMode?: string;
|
||||
tlsPolicy?: string;
|
||||
heartbeatIntervalSeconds?: number;
|
||||
commandPollIntervalSeconds?: number;
|
||||
requestTimeoutSeconds?: number;
|
||||
};
|
||||
|
||||
type CompanionRuntimeProfile = {
|
||||
key?: string;
|
||||
version?: string;
|
||||
supportedTargets?: Array<{ os?: string; arch?: string }>;
|
||||
health?: { requiredCapabilities?: string[] };
|
||||
};
|
||||
|
||||
function recordValue(value: unknown): Record<string, unknown> | undefined {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : undefined;
|
||||
}
|
||||
|
||||
function sameStringSet(left: unknown, right: string[]): boolean {
|
||||
if (!Array.isArray(left) || !left.every((item) => typeof item === "string") || left.length !== right.length) {
|
||||
return false;
|
||||
}
|
||||
const leftSet = new Set(left);
|
||||
const rightSet = new Set(right);
|
||||
return leftSet.size === left.length && rightSet.size === right.length && [...leftSet].every((item) => rightSet.has(item));
|
||||
}
|
||||
|
||||
function isSafeHTTPSBaseURL(value: unknown): boolean {
|
||||
if (typeof value !== "string" || value.length === 0 || /\s/.test(value)) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(value);
|
||||
return parsed.protocol === "https:" && parsed.hostname !== "" && parsed.username === "" && parsed.password === "" && parsed.search === "" && parsed.hash === "" && (parsed.pathname === "" || parsed.pathname === "/");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function validateGeneratedCompanionConfig(manifest: unknown, companion: CompanionConfigDeclaration, example: unknown, location: string): string[] {
|
||||
const declaration = manifest as {
|
||||
id?: string;
|
||||
runtimeProfiles?: { clientManagers?: CompanionRuntimeProfile[] };
|
||||
};
|
||||
const manager = declaration.runtimeProfiles?.clientManagers?.find((candidate) => candidate.key === companion.profileKey);
|
||||
const root = recordValue(example);
|
||||
const platform = recordValue(root?.platform);
|
||||
const component = recordValue(root?.component);
|
||||
const proof = recordValue(root?.proof);
|
||||
const session = recordValue(root?.session);
|
||||
const timing = recordValue(root?.timing);
|
||||
const tls = recordValue(root?.tls);
|
||||
const errors: string[] = [];
|
||||
|
||||
if (!isSafeHTTPSBaseURL(platform?.baseUrl)) {
|
||||
errors.push(`${location}.example.platform.baseUrl: must be an HTTPS origin without userinfo, path, query, or fragment`);
|
||||
}
|
||||
if (component?.pluginId !== declaration.id) {
|
||||
errors.push(`${location}.example.component.pluginId: must match the plugin manifest id`);
|
||||
}
|
||||
if (component?.profileKey !== companion.profileKey) {
|
||||
errors.push(`${location}.example.component.profileKey: must match the companion profileKey`);
|
||||
}
|
||||
if (manager?.version && component?.version !== manager.version) {
|
||||
errors.push(`${location}.example.component.version: must match the Client Manager profile version`);
|
||||
}
|
||||
if (manager && !manager.supportedTargets?.some((target) => target.os === component?.targetOs && target.arch === component?.targetArch)) {
|
||||
errors.push(`${location}.example.component: targetOs and targetArch must match a supported Client Manager target`);
|
||||
}
|
||||
if (proof?.mode !== companion.registrationProof) {
|
||||
errors.push(`${location}.example.proof.mode: must match the companion registrationProof`);
|
||||
}
|
||||
if (proof?.materialEnv !== companion.proofMaterialEnv) {
|
||||
errors.push(`${location}.example.proof.materialEnv: must match the companion proofMaterialEnv`);
|
||||
}
|
||||
if (session?.mode !== companion.sessionMode) {
|
||||
errors.push(`${location}.example.session.mode: must match the companion sessionMode`);
|
||||
}
|
||||
if (tls?.policy !== companion.tlsPolicy) {
|
||||
errors.push(`${location}.example.tls.policy: must match the companion tlsPolicy`);
|
||||
}
|
||||
for (const [field, expected] of [
|
||||
["heartbeatIntervalSeconds", companion.heartbeatIntervalSeconds],
|
||||
["commandPollIntervalSeconds", companion.commandPollIntervalSeconds],
|
||||
["requestTimeoutSeconds", companion.requestTimeoutSeconds]
|
||||
] as const) {
|
||||
if (timing?.[field] !== expected) {
|
||||
errors.push(`${location}.example.timing.${field}: must match the companion declaration`);
|
||||
}
|
||||
}
|
||||
if (!manager || !sameStringSet(root?.capabilities, manager.health?.requiredCapabilities ?? [])) {
|
||||
errors.push(`${location}.example.capabilities: must exactly match the Client Manager requiredCapabilities`);
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
function validateGameClientBridgeCompanionConfig(manifest: unknown, manifestDir: string): string[] {
|
||||
if (typeof manifest !== "object" || manifest === null) {
|
||||
return [];
|
||||
}
|
||||
const companion = (manifest as { gameClientBridge?: { companion?: CompanionConfigDeclaration } }).gameClientBridge?.companion;
|
||||
if (!companion) {
|
||||
return [];
|
||||
}
|
||||
const location = "manifest.gameClientBridge.companion.configSchemaRef";
|
||||
const ref = companion.configSchemaRef ?? "";
|
||||
if (!isSafeRelativeJsonRef(ref)) {
|
||||
return [`${location}: raw host paths and unsafe schema references are not allowed`];
|
||||
}
|
||||
const schemaPath = path.resolve(manifestDir, ref);
|
||||
if (!fs.existsSync(schemaPath) || !fs.statSync(schemaPath).isFile()) {
|
||||
return [`${location}: missing companion config schema file ${ref}`];
|
||||
}
|
||||
const relativeRealPath = path.relative(fs.realpathSync(manifestDir), fs.realpathSync(schemaPath));
|
||||
if (relativeRealPath === ".." || relativeRealPath.startsWith(`..${path.sep}`) || path.isAbsolute(relativeRealPath)) {
|
||||
return [`${location}: companion config schema must remain inside the plugin manifest directory`];
|
||||
}
|
||||
const errors: string[] = [];
|
||||
try {
|
||||
const schema = readJson(schemaPath) as AnySchema;
|
||||
const ajv = new Ajv2020({ allErrors: true, strict: false, validateFormats: false });
|
||||
if (!ajv.validateSchema(schema)) {
|
||||
errors.push(...formatErrors(`${location}.schema`, ajv.errors));
|
||||
return errors;
|
||||
}
|
||||
errors.push(...scanUnsafeBridgeSchema(schema, `${location}.schema`));
|
||||
errors.push(...validateBoundedBridgeSchema(schema, `${location}.schema`));
|
||||
const validate = ajv.compile(schema);
|
||||
const examplePath = schemaPath.replace(/\.schema\.json$/, ".generated.example.json");
|
||||
if (!fs.existsSync(examplePath)) {
|
||||
errors.push(`${location}: missing generated companion config example`);
|
||||
return errors;
|
||||
}
|
||||
const example = readJson(examplePath);
|
||||
if (!validate(example)) {
|
||||
errors.push(...formatErrors(`${location}.example`, validate.errors));
|
||||
}
|
||||
errors.push(...scanUnsafeValues(example, `${location}.example`));
|
||||
errors.push(...validateGeneratedCompanionConfig(manifest, companion, example, location));
|
||||
const serialized = `${JSON.stringify(schema)}\n${JSON.stringify(example)}`;
|
||||
if (/\/api\/v1\/scum-clients\//i.test(serialized) || /InsecureSkipVerify/i.test(serialized) || /"(?:authKey|componentKey|credential|password|sessionToken|secret)"\s*:/i.test(serialized)) {
|
||||
errors.push(`${location}: companion config must not contain legacy endpoints, insecure TLS, or inline proof/session material`);
|
||||
}
|
||||
} catch (error) {
|
||||
errors.push(`${location}: companion config schema or example is invalid: ${error instanceof Error ? error.message : "invalid JSON"}`);
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
export function validateManifestFile(manifestPath: string): string[] {
|
||||
const absoluteManifestPath = path.resolve(rootDir, manifestPath);
|
||||
const manifest = readJson(absoluteManifestPath);
|
||||
@@ -305,6 +1011,11 @@ export function validateManifestFile(manifestPath: string): string[] {
|
||||
errors.push(...scanUnsafeValues(manifest, "manifest"));
|
||||
errors.push(...validateDependencyPlans(manifest));
|
||||
errors.push(...validateClientManagerProfiles(manifest));
|
||||
errors.push(...validateGameClientBridgeCatalog(manifest));
|
||||
errors.push(...validateGameClientBridgeSchemaFiles(manifest, manifestDir));
|
||||
errors.push(...validateGameClientBridgeCompanionConfig(manifest, manifestDir));
|
||||
errors.push(...validateRuntimeLogEventCatalog(manifest));
|
||||
errors.push(...validateRuntimeLogEventSchemaFiles(manifest, manifestDir));
|
||||
|
||||
for (const declaration of referencedLifecycleActions(manifest)) {
|
||||
if (!isSafeRelativeJsonRef(declaration.ref)) {
|
||||
|
||||
@@ -14,8 +14,10 @@ Plugins use the platform bridge for every privileged action.
|
||||
- `dependencies.request`: request typed dependency checks or approved install plans declared by the plugin runtime profile.
|
||||
- `logs.backfill.request`: request historical log backfill for a declared log source.
|
||||
- `client-manager.request`: request generation/download/key reset or a typed status, deploy, start, stop, restart, update, rollback, session-revoke, retry, or uninstall operation for a plugin-declared companion client manager.
|
||||
- `plugin-lifecycle.request`: request a server-bound install, enable, disable, upgrade, rollback, retire, or dependency-check operation through Platform capacity and compatibility gates.
|
||||
- `ai.invoke`: request platform-mediated AI assistance.
|
||||
- `theme.tokens`: read safe platform theme tokens.
|
||||
- `game-client-bridge`: query declared companion health and snapshots, and queue or cancel only manifest-declared typed commands through Platform.
|
||||
|
||||
## Execution Envelopes
|
||||
|
||||
@@ -23,16 +25,22 @@ Plugin pages build execution requests with `createBridgeExecutionRequest` and ha
|
||||
|
||||
Execution responses use `requestId`, plugin/page/server scope, action, status, optional result refs, and optional safe errors. Use `parseBridgeExecutionResponse` before reading results so plugin code handles denied, deferred, and failed states uniformly.
|
||||
|
||||
AI requests use `createAIInvocationRequest` with an explicit purpose, prompt, scoped context refs, and optional current config. Use `parseAIInvocationResponse` to consume recommendations and safe errors. Plugin code must not include provider API keys, provider base URLs, bearer tokens, or direct transport details in AI request payloads.
|
||||
AI requests use `createAIInvocationRequest` with an explicit manifest-declared purpose, prompt, and scoped context refs. Use `parseAIInvocationResponse` to consume recommendations, reviewable `diffId` metadata, and safe errors. Plugin code must not choose or receive provider API keys, provider base URLs, bearer tokens, or direct transport details. Config writes require a separate Platform operator approval.
|
||||
|
||||
Artifact open requests use `createArtifactOpenRequest` with an artifact ID that belongs to the current server/job scope. Use `parseArtifactReference` to consume the bridge result. Parsed references contain platform-owned download URLs, filename, content type, size, checksum, expiry, range support, and chunk size; they do not contain bytes or raw storage adapter locations.
|
||||
|
||||
Remote access requests use `createRemoteAccessRequest` with a plugin-declared `remote.*` capability, logical target key, optional scoped `input://` or `artifact://` ref, and idempotency key. The SDK never accepts FTP passwords, rsync endpoints, database DSNs, RCON passwords, run sockets, or raw host paths in these envelopes.
|
||||
|
||||
SQLite reads use manifest-declared `gameClientBridge.queryTemplates`. Plugin pages send only a declared template key plus typed inputs; Platform verifies the page contract, permission, SQLite transport/target, timeout, and row limit before dispatch. Query declarations and browser envelopes never contain SQL text, DSNs, credentials, sockets, or host paths.
|
||||
|
||||
Run distribution, dependency, log backfill, and client-manager requests use `createRunDistributionRequest`, `createDependencyActionRequest`, `createLogBackfillRequest`, and `createClientManagerRequest`. Client-manager lifecycle envelopes carry only operation names, logical profile/installation IDs, target OS/architecture, artifact IDs, expected deployment generations, and idempotency keys. `parseClientManagerLifecycleStatus` whitelists safe state, version, health, job, artifact, and action fields. Raw run/client-manager keys, component sessions, secret refs, host paths, PIDs, sockets, credentials, and direct Run endpoint details are never plugin bridge fields.
|
||||
|
||||
Client-manager lifecycle requests remain Platform-mediated. A plugin declaration does not grant access by itself: Platform rechecks the installed plugin, server owner/administrator scope, runtime binding, assigned Run endpoint capabilities, current distribution target/revision/key generation, and durable installation state before dispatching a typed job.
|
||||
|
||||
Game-client plugin pages receive a host-provided `GameClientBridgePageClient`. The SDK defines status, command, result, snapshot, approval, and manifest declaration types but never creates its own HTTP client. Queue requests carry only a declared command type, logical profile key, bounded typed payload, expiry, priority, and idempotency key. Browser-facing types intentionally have no component session, component key, installation fence, host path, DSN, Run endpoint, socket, or storage credential fields.
|
||||
|
||||
Production plugin lifecycle requests use `createProductionPluginLifecycleRequest`. Envelopes contain only plugin/server scope, enumerated operation, optional target version, confirmation, and idempotency key. Platform rechecks the manifest `productionLifecycle` declaration, dependency policy, disruptive approval, endpoint capacity, compatibility, and prior idempotency inputs before dispatch.
|
||||
|
||||
## Forbidden Data
|
||||
|
||||
The bridge must not expose:
|
||||
@@ -45,3 +53,6 @@ The bridge must not expose:
|
||||
- unrestricted artifact storage credentials.
|
||||
- direct storage URLs or presigned backend URLs.
|
||||
- FTP, rsync, database, or RCON credentials.
|
||||
# Client Manager lifecycle bridge
|
||||
|
||||
The bridge may request typed `deploy`, `start`, `stop`, `restart`, `status`, `update`, `rollback`, `revoke`, `retry`, or `uninstall` intents when Platform action gating says they are available. Results are safe logical projections with real job phase/progress and redacted recovery guidance. The bridge is not a transport for Run sessions, component keys, artifact bytes, machine paths, process IDs, sockets, or credentials; component registration and heartbeat remain component-to-Platform contracts outside the plugin page.
|
||||
|
||||
+238
-8
@@ -11,6 +11,9 @@ export type PluginPermission =
|
||||
| "server.run.distribution"
|
||||
| "server.dependencies.manage"
|
||||
| "server.client-manager.manage"
|
||||
| "server.game-client.read"
|
||||
| "server.game-client.command"
|
||||
| "server.game-client.maintenance"
|
||||
| "ai.invoke";
|
||||
|
||||
export type RunCapability =
|
||||
@@ -58,6 +61,7 @@ export type PluginBridgeAction =
|
||||
| "dependencies.request"
|
||||
| "logs.backfill.request"
|
||||
| "client-manager.request"
|
||||
| "plugin-lifecycle.request"
|
||||
| "ai.invoke";
|
||||
|
||||
export type PluginBridgeRequestPayload = Record<string, unknown>;
|
||||
@@ -115,7 +119,7 @@ export interface PluginAIInvocationResponse {
|
||||
purpose: AIPurpose;
|
||||
status: "ok" | "denied" | "error" | string;
|
||||
recommendation?: string;
|
||||
suggestedConfig?: string;
|
||||
configRecommendation?: { diffId: string; key: string; suggestedConfig: string; diffSummary: string; expiresAt: string };
|
||||
usage?: { model?: string; mocked?: boolean; inputTokens?: number; outputTokens?: number };
|
||||
error?: PluginBridgeError;
|
||||
}
|
||||
@@ -131,6 +135,15 @@ export type PluginLifecycleDispatchPayload = Record<string, string> & {
|
||||
idempotencyKey: string;
|
||||
};
|
||||
|
||||
export type ProductionPluginLifecycleOperation = "install" | "enable" | "disable" | "upgrade" | "rollback" | "retire" | "dependency-check";
|
||||
|
||||
export type PluginProductionLifecyclePayload = Record<string, string> & {
|
||||
operation: ProductionPluginLifecycleOperation;
|
||||
targetVersion: string;
|
||||
idempotencyKey: string;
|
||||
confirmed: "true" | "false";
|
||||
};
|
||||
|
||||
export type PluginRemoteAccessPayload = Record<string, string> & {
|
||||
capability: Extract<RunCapability, `remote.${string}`>;
|
||||
targetKey?: string;
|
||||
@@ -196,6 +209,165 @@ export interface GamePluginRemoteAccess {
|
||||
logTransfer?: boolean;
|
||||
}
|
||||
|
||||
export type GameClientBridgeApprovalLevel = "none" | "operator" | "platform-admin";
|
||||
export type GameClientBridgeApprovalState = "not_required" | "pending" | "approved" | "rejected";
|
||||
export type GameClientBridgeCommandState = "pending" | "claimed" | "succeeded" | "failed" | "cancelled" | "expired";
|
||||
|
||||
export interface GameClientBridgeCommandDeclaration {
|
||||
type: string;
|
||||
title: string;
|
||||
permission: PluginPermission;
|
||||
approvalLevel: GameClientBridgeApprovalLevel;
|
||||
payloadSchemaRef: string;
|
||||
resultSchemaRef?: string;
|
||||
timeoutSeconds: number;
|
||||
maxPayloadBytes: number;
|
||||
}
|
||||
|
||||
export interface GameClientBridgeSnapshotDeclaration {
|
||||
type: string;
|
||||
schemaVersion: string;
|
||||
schemaRef: string;
|
||||
keepForSeconds: number;
|
||||
maxRecords: number;
|
||||
}
|
||||
|
||||
export interface GameClientBridgeQueryTemplateDeclaration {
|
||||
key: string;
|
||||
title: string;
|
||||
permission: PluginPermission;
|
||||
engine: "sqlite";
|
||||
transportKey: string;
|
||||
targetKey: string;
|
||||
parameterSchemaRef: string;
|
||||
resultSchemaRef: string;
|
||||
maxRows: number;
|
||||
timeoutSeconds: number;
|
||||
}
|
||||
|
||||
export interface GameClientBridgePageContract {
|
||||
pageKey: string;
|
||||
commandTypes?: string[];
|
||||
snapshotTypes?: string[];
|
||||
queryTemplateKeys?: string[];
|
||||
}
|
||||
|
||||
export interface GameClientBridgeCompanionDeclaration {
|
||||
profileKey: string;
|
||||
configTemplateKey: string;
|
||||
configSchemaRef: string;
|
||||
configFormat: "yaml";
|
||||
platformBaseUrlSource: "run-control";
|
||||
registrationProof: "hmac-sha256";
|
||||
proofMaterialSource: "component-package";
|
||||
proofMaterialEnv: string;
|
||||
sessionMode: "component-session";
|
||||
tlsPolicy: "verify-system-roots";
|
||||
heartbeatIntervalSeconds: number;
|
||||
commandPollIntervalSeconds: number;
|
||||
requestTimeoutSeconds: number;
|
||||
}
|
||||
|
||||
export interface GameClientBridgeManifest {
|
||||
commands: GameClientBridgeCommandDeclaration[];
|
||||
snapshots: GameClientBridgeSnapshotDeclaration[];
|
||||
queryTemplates?: GameClientBridgeQueryTemplateDeclaration[];
|
||||
commandRetentionSeconds: number;
|
||||
maxCommands: number;
|
||||
pages?: GameClientBridgePageContract[];
|
||||
companion?: GameClientBridgeCompanionDeclaration;
|
||||
}
|
||||
|
||||
export interface GameClientBridgeProfileStatus {
|
||||
pluginId: string;
|
||||
profileKey: string;
|
||||
available: boolean;
|
||||
reason?: string;
|
||||
commandTypes: string[];
|
||||
snapshotTypes: string[];
|
||||
queryTemplateKeys: string[];
|
||||
}
|
||||
|
||||
export interface GameClientBridgeStatus {
|
||||
serverInstanceId: string;
|
||||
pluginId: string;
|
||||
available: boolean;
|
||||
reason?: string;
|
||||
profiles: GameClientBridgeProfileStatus[];
|
||||
}
|
||||
|
||||
export interface GameClientBridgeCommandResult {
|
||||
status: "succeeded" | "failed" | "cancelled";
|
||||
summary?: string;
|
||||
payload?: Record<string, unknown>;
|
||||
completedAt: string;
|
||||
}
|
||||
|
||||
export interface GameClientBridgeCommand {
|
||||
id: string;
|
||||
serverInstanceId: string;
|
||||
pluginId: string;
|
||||
profileKey: string;
|
||||
commandType: string;
|
||||
priority: number;
|
||||
state: GameClientBridgeCommandState;
|
||||
approvalState: GameClientBridgeApprovalState;
|
||||
result?: GameClientBridgeCommandResult;
|
||||
auditReferences?: string[];
|
||||
expiresAt: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
completedAt?: string;
|
||||
}
|
||||
|
||||
export interface GameClientBridgeSnapshot<TPayload extends Record<string, unknown> = Record<string, unknown>> {
|
||||
id: string;
|
||||
serverInstanceId: string;
|
||||
pluginId: string;
|
||||
profileKey: string;
|
||||
type: string;
|
||||
schemaVersion: string;
|
||||
streamKey: string;
|
||||
sequence: number;
|
||||
observedAt: string;
|
||||
payload: TPayload;
|
||||
auditReferences?: string[];
|
||||
createdAt: string;
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
export interface GameClientBridgeQueueRequest<TPayload extends Record<string, unknown> = Record<string, unknown>> {
|
||||
profileKey: string;
|
||||
commandType: string;
|
||||
payload: TPayload;
|
||||
idempotencyKey: string;
|
||||
priority?: number;
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
export interface GameClientBridgeSnapshotQuery {
|
||||
profileKey?: string;
|
||||
type?: string;
|
||||
streamKey?: string;
|
||||
observedAfter?: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface GameClientBridgePageClient {
|
||||
getStatus(serverInstanceId: string): Promise<GameClientBridgeStatus>;
|
||||
listCommands(serverInstanceId: string): Promise<GameClientBridgeCommand[]>;
|
||||
queueCommand<TPayload extends Record<string, unknown>>(serverInstanceId: string, request: GameClientBridgeQueueRequest<TPayload>): Promise<GameClientBridgeCommand>;
|
||||
cancelCommand(serverInstanceId: string, commandId: string, reason?: string): Promise<GameClientBridgeCommand>;
|
||||
querySnapshots<TPayload extends Record<string, unknown> = Record<string, unknown>>(serverInstanceId: string, query?: GameClientBridgeSnapshotQuery): Promise<Array<GameClientBridgeSnapshot<TPayload>>>;
|
||||
}
|
||||
|
||||
export function createGameClientBridgeQueueRequest<TPayload extends Record<string, unknown>>(input: GameClientBridgeQueueRequest<TPayload>): GameClientBridgeQueueRequest<TPayload> {
|
||||
if (!input.profileKey || !input.commandType || !input.idempotencyKey || !input.expiresAt) {
|
||||
throw new Error("profileKey, commandType, idempotencyKey, and expiresAt are required");
|
||||
}
|
||||
return { profileKey: input.profileKey, commandType: input.commandType, payload: { ...input.payload }, idempotencyKey: input.idempotencyKey, priority: input.priority, expiresAt: input.expiresAt };
|
||||
}
|
||||
|
||||
export type RuntimePlatform = "windows" | "linux" | "darwin";
|
||||
export type RuntimeArch = "amd64" | "arm64";
|
||||
export type RuntimeTarget = { os: RuntimePlatform; arch: RuntimeArch };
|
||||
@@ -254,6 +426,19 @@ export interface RuntimeLogSource {
|
||||
retentionDays?: number;
|
||||
}
|
||||
|
||||
export type RuntimeLogEventSeverity = "info" | "notice" | "warning" | "critical";
|
||||
|
||||
export interface RuntimeLogEventDeclaration {
|
||||
key: string;
|
||||
title: string;
|
||||
sourceKey: string;
|
||||
eventType: string;
|
||||
permission: PluginPermission;
|
||||
schemaRef: string;
|
||||
retentionDays: number;
|
||||
severity: RuntimeLogEventSeverity;
|
||||
}
|
||||
|
||||
export interface RuntimeTransportProfile {
|
||||
key: string;
|
||||
kind: "file" | "ftp" | "rsync" | "mysql" | "sqlite" | "rcon";
|
||||
@@ -318,6 +503,7 @@ export interface GamePluginRuntimeProfiles {
|
||||
dependencyProbes?: RuntimeDependencyProbe[];
|
||||
installPlans?: RuntimeInstallPlan[];
|
||||
logSources?: RuntimeLogSource[];
|
||||
logEvents?: RuntimeLogEventDeclaration[];
|
||||
transportProfiles?: RuntimeTransportProfile[];
|
||||
clientManagers?: RuntimeClientManagerProfile[];
|
||||
}
|
||||
@@ -380,6 +566,7 @@ export const pluginBridgeActionPolicies: Record<PluginBridgeAction, PluginBridge
|
||||
"dependencies.request": { permissions: ["server.dependencies.manage"] },
|
||||
"logs.backfill.request": { permissions: ["server.logs.read"] },
|
||||
"client-manager.request": { permissions: ["server.client-manager.manage"] },
|
||||
"plugin-lifecycle.request": { permissions: ["server.lifecycle"] },
|
||||
"ai.invoke": { permissions: ["ai.invoke"], aiPurposeRequired: true }
|
||||
};
|
||||
|
||||
@@ -432,10 +619,18 @@ export interface GamePluginManifest {
|
||||
permissions: PluginPermission[];
|
||||
remoteAccess?: GamePluginRemoteAccess;
|
||||
runtimeProfiles?: GamePluginRuntimeProfiles;
|
||||
gameClientBridge?: GameClientBridgeManifest;
|
||||
actions?: GamePluginActions;
|
||||
productionLifecycle: {
|
||||
operations: ProductionPluginLifecycleOperation[];
|
||||
dependencyPolicy: "required" | "optional";
|
||||
approvalRequired: Array<"disable" | "rollback" | "retire">;
|
||||
};
|
||||
pages?: GamePluginPage[];
|
||||
ai?: {
|
||||
purposes?: AIPurpose[];
|
||||
mediation: "platform";
|
||||
configWritePolicy: "review-required";
|
||||
};
|
||||
}
|
||||
|
||||
@@ -533,24 +728,59 @@ export function createLifecycleDispatchRequest(input: {
|
||||
});
|
||||
}
|
||||
|
||||
export function createProductionPluginLifecycleRequest(input: {
|
||||
requestId: string;
|
||||
context: PluginBridgeContext;
|
||||
operation: ProductionPluginLifecycleOperation;
|
||||
targetVersion?: string;
|
||||
idempotencyKey: string;
|
||||
confirmed?: boolean;
|
||||
}): PluginBridgeExecutionRequest<PluginProductionLifecyclePayload> {
|
||||
if (!input.context.serverInstanceId) {
|
||||
throw new Error("serverInstanceId is required for plugin lifecycle requests");
|
||||
}
|
||||
if (["disable", "rollback", "retire"].includes(input.operation) && !input.confirmed) {
|
||||
throw new Error("disruptive plugin lifecycle requests require confirmation");
|
||||
}
|
||||
return createBridgeExecutionRequest({
|
||||
requestId: input.requestId,
|
||||
context: input.context,
|
||||
action: "plugin-lifecycle.request",
|
||||
payload: {
|
||||
operation: input.operation,
|
||||
targetVersion: input.targetVersion ?? "",
|
||||
idempotencyKey: input.idempotencyKey,
|
||||
confirmed: input.confirmed ? "true" : "false"
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function createRemoteAccessRequest(input: {
|
||||
requestId: string;
|
||||
context: PluginBridgeContext;
|
||||
capability: PluginRemoteAccessPayload["capability"];
|
||||
targetKey?: string;
|
||||
inputRef?: string;
|
||||
inputs?: Record<string, string>;
|
||||
idempotencyKey: string;
|
||||
}): PluginBridgeExecutionRequest<PluginRemoteAccessPayload> {
|
||||
const payload: PluginRemoteAccessPayload = {
|
||||
capability: input.capability,
|
||||
targetKey: input.targetKey ?? "",
|
||||
inputRef: input.inputRef ?? "",
|
||||
idempotencyKey: input.idempotencyKey
|
||||
};
|
||||
for (const [key, value] of Object.entries(input.inputs ?? {})) {
|
||||
if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,159}$/.test(key) || /(sql|query|shell|script|password|secret|token|credential|dsn|path)/i.test(key)) {
|
||||
throw new Error(`remote adapter input key is unsafe: ${key}`);
|
||||
}
|
||||
payload[`input.${key}`] = value;
|
||||
}
|
||||
return createBridgeExecutionRequest({
|
||||
requestId: input.requestId,
|
||||
context: input.context,
|
||||
action: "remote.access.request",
|
||||
payload: {
|
||||
capability: input.capability,
|
||||
targetKey: input.targetKey ?? "",
|
||||
inputRef: input.inputRef ?? "",
|
||||
idempotencyKey: input.idempotencyKey
|
||||
}
|
||||
payload
|
||||
});
|
||||
}
|
||||
|
||||
@@ -781,7 +1011,7 @@ export function parseAIInvocationResponse(response: PluginAIInvocationResponse):
|
||||
purpose: response.purpose,
|
||||
status: response.status,
|
||||
recommendation: response.recommendation,
|
||||
suggestedConfig: response.suggestedConfig,
|
||||
configRecommendation: response.configRecommendation ? { ...response.configRecommendation } : undefined,
|
||||
usage: response.usage ? { ...response.usage } : undefined,
|
||||
error: response.error ? bridgeError(response.error.code, response.error.message, response.error.details ?? []) : undefined
|
||||
};
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { Ajv2020 } from "ajv/dist/2020.js";
|
||||
|
||||
import {
|
||||
bridgeError,
|
||||
@@ -8,9 +13,11 @@ import {
|
||||
createClientManagerRequest,
|
||||
createBridgeExecutionRequest,
|
||||
createLifecycleDispatchRequest,
|
||||
createProductionPluginLifecycleRequest,
|
||||
createBridgeRequest,
|
||||
createDependencyActionRequest,
|
||||
createLogBackfillRequest,
|
||||
createGameClientBridgeQueueRequest,
|
||||
createRemoteAccessRequest,
|
||||
createRunDistributionRequest,
|
||||
hasPluginPermission,
|
||||
@@ -18,13 +25,145 @@ import {
|
||||
parseClientManagerLifecycleStatus,
|
||||
parseBridgeExecutionResponse,
|
||||
parseAIInvocationResponse,
|
||||
type GameClientBridgeQueryTemplateDeclaration,
|
||||
type GameClientBridgeCompanionDeclaration,
|
||||
type GamePluginManifest,
|
||||
type RuntimeLogEventDeclaration,
|
||||
type RuntimeClientManagerProfile,
|
||||
type PluginLifecycleActionDeclaration,
|
||||
type PluginBridgeContext
|
||||
} from "../sdk/index.js";
|
||||
import { validateLifecycleActionFile, validateManifestFile } from "../scripts/validate-manifest.js";
|
||||
|
||||
const pluginsRoot = fileURLToPath(new URL("..", import.meta.url));
|
||||
|
||||
type MutableBridgeManifest = {
|
||||
capabilities: string[];
|
||||
permissions: string[];
|
||||
remoteAccess?: {
|
||||
methods: string[];
|
||||
runCapabilities?: string[];
|
||||
databaseEngines?: string[];
|
||||
};
|
||||
runtimeProfiles?: {
|
||||
transportProfiles?: Array<Record<string, unknown>>;
|
||||
};
|
||||
pages?: Array<{ key?: string; permissions?: string[]; bridgeActions?: string[] }>;
|
||||
gameClientBridge: {
|
||||
commands: Array<Record<string, unknown>>;
|
||||
snapshots: Array<Record<string, unknown>>;
|
||||
queryTemplates?: Array<Record<string, unknown>>;
|
||||
commandRetentionSeconds: number;
|
||||
maxCommands: number;
|
||||
pages: Array<Record<string, unknown>>;
|
||||
};
|
||||
};
|
||||
|
||||
function bridgeObjectSchema(properties: Record<string, unknown>, required: string[] = []): Record<string, unknown> {
|
||||
return {
|
||||
$schema: "https://json-schema.org/draft/2020-12/schema",
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
properties,
|
||||
...(required.length > 0 ? { required } : {})
|
||||
};
|
||||
}
|
||||
|
||||
function writeFixtureJSON(fixtureDir: string, relativePath: string, value: unknown): void {
|
||||
const target = path.join(fixtureDir, relativePath);
|
||||
fs.mkdirSync(path.dirname(target), { recursive: true });
|
||||
fs.writeFileSync(target, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
||||
}
|
||||
|
||||
function listProductionGoFiles(directory: string): string[] {
|
||||
return fs.readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
|
||||
const target = path.join(directory, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
return listProductionGoFiles(target);
|
||||
}
|
||||
return entry.isFile() && entry.name.endsWith(".go") && !entry.name.endsWith("_test.go") ? [target] : [];
|
||||
});
|
||||
}
|
||||
|
||||
function validateTemporaryBridgeManifest(mutate?: (manifest: MutableBridgeManifest, fixtureDir: string) => void): string[] {
|
||||
const fixtureDir = fs.mkdtempSync(path.join(os.tmpdir(), "browser-bridge-manifest-"));
|
||||
try {
|
||||
fs.cpSync(path.join(pluginsRoot, "examples/dev-game-plugin"), fixtureDir, { recursive: true });
|
||||
const manifestPath = path.join(fixtureDir, "manifest.json");
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")) as MutableBridgeManifest;
|
||||
manifest.capabilities = [...manifest.capabilities, "remote.run.db.sqlite.query"];
|
||||
manifest.permissions = [...manifest.permissions, "server.game-client.command", "server.game-client.read"];
|
||||
manifest.remoteAccess = { methods: ["run"], runCapabilities: ["remote.run.db.sqlite.query"], databaseEngines: ["sqlite"] };
|
||||
manifest.runtimeProfiles = {
|
||||
transportProfiles: [{ key: "sqlite-db", kind: "sqlite", targetKey: "db/sqlite", capabilities: ["remote.run.db.sqlite.query"] }]
|
||||
};
|
||||
const overviewPage = manifest.pages?.find((page) => page.key === "overview");
|
||||
if (overviewPage) {
|
||||
overviewPage.permissions = [...(overviewPage.permissions ?? []), "server.game-client.read", "server.remote.access"];
|
||||
overviewPage.bridgeActions = [...(overviewPage.bridgeActions ?? []), "remote.access.request"];
|
||||
}
|
||||
manifest.gameClientBridge = {
|
||||
commands: [{ type: "announcement.send", title: "Send announcement", permission: "server.game-client.command", approvalLevel: "operator", payloadSchemaRef: "schemas/bridge/announcement.schema.json", resultSchemaRef: "schemas/bridge/announcement-result.schema.json", timeoutSeconds: 60, maxPayloadBytes: 4096 }],
|
||||
snapshots: [{ type: "players", schemaVersion: "1", schemaRef: "schemas/bridge/players.schema.json", keepForSeconds: 3600, maxRecords: 100 }],
|
||||
queryTemplates: [{ key: "player.by-id", title: "Find player by ID", permission: "server.game-client.read", engine: "sqlite", transportKey: "sqlite-db", targetKey: "db/sqlite", parameterSchemaRef: "schemas/bridge/player-by-id.parameters.schema.json", resultSchemaRef: "schemas/bridge/player-by-id.result.schema.json", maxRows: 1, timeoutSeconds: 10 }],
|
||||
commandRetentionSeconds: 86400,
|
||||
maxCommands: 1000,
|
||||
pages: [{ pageKey: "overview", commandTypes: ["announcement.send"], snapshotTypes: ["players"], queryTemplateKeys: ["player.by-id"] }]
|
||||
};
|
||||
writeFixtureJSON(fixtureDir, "schemas/bridge/announcement.schema.json", bridgeObjectSchema({ message: { type: "string", minLength: 1, maxLength: 200 } }, ["message"]));
|
||||
writeFixtureJSON(fixtureDir, "schemas/bridge/announcement-result.schema.json", bridgeObjectSchema({ accepted: { type: "boolean" } }, ["accepted"]));
|
||||
writeFixtureJSON(fixtureDir, "schemas/bridge/players.schema.json", bridgeObjectSchema({ players: { type: "array", maxItems: 100, items: bridgeObjectSchema({ id: { type: "string", minLength: 1, maxLength: 80 } }, ["id"]) } }, ["players"]));
|
||||
writeFixtureJSON(fixtureDir, "schemas/bridge/player-by-id.parameters.schema.json", bridgeObjectSchema({ playerId: { type: "string", minLength: 1, maxLength: 96 } }, ["playerId"]));
|
||||
writeFixtureJSON(fixtureDir, "schemas/bridge/player-by-id.result.schema.json", bridgeObjectSchema({ players: { type: "array", maxItems: 1, items: bridgeObjectSchema({ playerId: { type: "string", minLength: 1, maxLength: 96 } }, ["playerId"]) } }, ["players"]));
|
||||
mutate?.(manifest, fixtureDir);
|
||||
writeFixtureJSON(fixtureDir, "manifest.json", manifest);
|
||||
return validateManifestFile(manifestPath);
|
||||
} finally {
|
||||
fs.rmSync(fixtureDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
type MutableLogEventManifest = {
|
||||
permissions: string[];
|
||||
runtimeProfiles?: {
|
||||
logSources?: Array<Record<string, unknown>>;
|
||||
logEvents?: Array<Record<string, unknown>>;
|
||||
};
|
||||
};
|
||||
|
||||
function validateTemporaryLogEventManifest(mutate?: (manifest: MutableLogEventManifest, fixtureDir: string) => void): string[] {
|
||||
const fixtureDir = fs.mkdtempSync(path.join(os.tmpdir(), "browser-log-event-manifest-"));
|
||||
try {
|
||||
fs.cpSync(path.join(pluginsRoot, "examples/dev-game-plugin"), fixtureDir, { recursive: true });
|
||||
const manifestPath = path.join(fixtureDir, "manifest.json");
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")) as MutableLogEventManifest;
|
||||
manifest.runtimeProfiles = {
|
||||
logSources: [{ key: "server-events", kind: "file.tail", targetKey: "logs/server", streamKey: "game.server", cursorKind: "fingerprint", retentionDays: 30 }],
|
||||
logEvents: [{ key: "player-login", title: "Player login", sourceKey: "server-events", eventType: "game.login", permission: "server.logs.read", schemaRef: "schemas/log-events/login.event.schema.json", retentionDays: 30, severity: "info" }]
|
||||
};
|
||||
writeFixtureJSON(fixtureDir, "schemas/log-events/login.event.schema.json", bridgeObjectSchema({ occurredAt: { type: "string", minLength: 1, maxLength: 40 }, playerId: { type: "string", minLength: 1, maxLength: 96 } }, ["occurredAt", "playerId"]));
|
||||
mutate?.(manifest, fixtureDir);
|
||||
writeFixtureJSON(fixtureDir, "manifest.json", manifest);
|
||||
return validateManifestFile(manifestPath);
|
||||
} finally {
|
||||
fs.rmSync(fixtureDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function validateTemporaryScumCompanionManifest(mutate: (manifest: Record<string, any>, fixtureDir: string) => void): string[] {
|
||||
const fixtureDir = fs.mkdtempSync(path.join(os.tmpdir(), "browser-scum-companion-manifest-"));
|
||||
try {
|
||||
fs.cpSync(path.join(pluginsRoot, "examples/scum-server-plugin"), fixtureDir, { recursive: true });
|
||||
const manifestPath = path.join(fixtureDir, "manifest.json");
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")) as Record<string, any>;
|
||||
mutate(manifest, fixtureDir);
|
||||
writeFixtureJSON(fixtureDir, "manifest.json", manifest);
|
||||
return validateManifestFile(manifestPath);
|
||||
} finally {
|
||||
fs.rmSync(fixtureDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
describe("plugin manifest validation", () => {
|
||||
it("accepts the development example manifest", () => {
|
||||
expect(validateManifestFile("examples/dev-game-plugin/manifest.json")).toEqual([]);
|
||||
@@ -34,10 +173,688 @@ describe("plugin manifest validation", () => {
|
||||
expect(validateManifestFile("examples/scum-server-plugin/manifest.json")).toEqual([]);
|
||||
});
|
||||
|
||||
it("defines a generated SCUM companion config without inline proof or session material", () => {
|
||||
const pluginDir = path.join(pluginsRoot, "examples/scum-server-plugin");
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(pluginDir, "manifest.json"), "utf8")) as {
|
||||
gameClientBridge: { companion?: GameClientBridgeCompanionDeclaration };
|
||||
};
|
||||
const companion = manifest.gameClientBridge.companion;
|
||||
expect(companion).toMatchObject({
|
||||
profileKey: "scum-client-manager",
|
||||
configTemplateKey: "client-config",
|
||||
configFormat: "yaml",
|
||||
platformBaseUrlSource: "run-control",
|
||||
registrationProof: "hmac-sha256",
|
||||
proofMaterialSource: "component-package",
|
||||
proofMaterialEnv: "SCUM_COMPONENT_PROOF",
|
||||
sessionMode: "component-session",
|
||||
tlsPolicy: "verify-system-roots",
|
||||
heartbeatIntervalSeconds: 30,
|
||||
requestTimeoutSeconds: 15
|
||||
});
|
||||
const schema = JSON.parse(fs.readFileSync(path.join(pluginDir, companion!.configSchemaRef), "utf8"));
|
||||
const example = JSON.parse(fs.readFileSync(path.join(pluginDir, "schemas/companion/config.generated.example.json"), "utf8"));
|
||||
const validate = new Ajv2020({ strict: false, validateFormats: false }).compile(schema);
|
||||
expect(validate(example), JSON.stringify(validate.errors)).toBe(true);
|
||||
expect(JSON.stringify(example)).not.toMatch(/authKey|componentKey|credential|password|sessionToken|secret|\/api\/v1\/scum-clients\//i);
|
||||
expect(example).toMatchObject({ proof: { materialEnv: "SCUM_COMPONENT_PROOF" }, session: { mode: "component-session" }, tls: { policy: "verify-system-roots" } });
|
||||
});
|
||||
|
||||
it("rejects unsafe SCUM companion bootstrap policy and inline session material", () => {
|
||||
const policyErrors = validateTemporaryScumCompanionManifest((manifest) => {
|
||||
manifest.gameClientBridge.companion.tlsPolicy = "skip-verification";
|
||||
});
|
||||
expect(policyErrors.some((error) => error.includes("tlsPolicy") || error.includes("secure component registration/session/TLS policy"))).toBe(true);
|
||||
|
||||
const materialErrors = validateTemporaryScumCompanionManifest((_manifest, fixtureDir) => {
|
||||
const examplePath = path.join(fixtureDir, "schemas/companion/config.generated.example.json");
|
||||
const example = JSON.parse(fs.readFileSync(examplePath, "utf8"));
|
||||
example.proof.sessionToken = "inline-session-material";
|
||||
writeFixtureJSON(fixtureDir, "schemas/companion/config.generated.example.json", example);
|
||||
});
|
||||
expect(materialErrors.some((error) => error.includes("inline proof/session material") || error.includes("additional properties"))).toBe(true);
|
||||
|
||||
const environmentErrors = validateTemporaryScumCompanionManifest((manifest) => {
|
||||
manifest.gameClientBridge.companion.proofMaterialEnv = "LD_PRELOAD";
|
||||
});
|
||||
expect(environmentErrors.some((error) => error.includes("proofMaterialEnv"))).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects generated SCUM companion configs with incomplete capabilities or unsafe Platform URLs", () => {
|
||||
const capabilityErrors = validateTemporaryScumCompanionManifest((_manifest, fixtureDir) => {
|
||||
const examplePath = path.join(fixtureDir, "schemas/companion/config.generated.example.json");
|
||||
const example = JSON.parse(fs.readFileSync(examplePath, "utf8"));
|
||||
example.capabilities = ["component.register", "component.heartbeat", "component.health", "game-client.bridge"];
|
||||
writeFixtureJSON(fixtureDir, "schemas/companion/config.generated.example.json", example);
|
||||
});
|
||||
expect(capabilityErrors.some((error) => error.includes("capabilities"))).toBe(true);
|
||||
|
||||
for (const unsafeURL of ["https://user:raw-token@example.test?session=raw-token#fragment", "https://?missing-host"]) {
|
||||
const urlErrors = validateTemporaryScumCompanionManifest((_manifest, fixtureDir) => {
|
||||
const examplePath = path.join(fixtureDir, "schemas/companion/config.generated.example.json");
|
||||
const example = JSON.parse(fs.readFileSync(examplePath, "utf8"));
|
||||
example.platform.baseUrl = unsafeURL;
|
||||
writeFixtureJSON(fixtureDir, "schemas/companion/config.generated.example.json", example);
|
||||
});
|
||||
expect(urlErrors.some((error) => error.includes("platform.baseUrl"))).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("cross-validates generated SCUM companion config against its declaration and runtime profile", () => {
|
||||
const proofErrors = validateTemporaryScumCompanionManifest((manifest) => {
|
||||
manifest.gameClientBridge.companion.proofMaterialEnv = "OTHER_COMPONENT_PROOF";
|
||||
});
|
||||
expect(proofErrors.some((error) => error.includes("proof.materialEnv") && error.includes("proofMaterialEnv"))).toBe(true);
|
||||
|
||||
const profileErrors = validateTemporaryScumCompanionManifest((manifest) => {
|
||||
const manager = manifest.runtimeProfiles.clientManagers.find((candidate: Record<string, unknown>) => candidate.key === "scum-client-manager");
|
||||
manager.health.requiredCapabilities = manager.health.requiredCapabilities.filter((capability: string) => capability !== "logs.stream");
|
||||
});
|
||||
expect(profileErrors.some((error) => error.includes("capabilities") && error.includes("requiredCapabilities"))).toBe(true);
|
||||
|
||||
const sessionErrors = validateTemporaryScumCompanionManifest((_manifest, fixtureDir) => {
|
||||
const examplePath = path.join(fixtureDir, "schemas/companion/config.generated.example.json");
|
||||
const example = JSON.parse(fs.readFileSync(examplePath, "utf8"));
|
||||
example.session.mode = "legacy-shared-token";
|
||||
writeFixtureJSON(fixtureDir, "schemas/companion/config.generated.example.json", example);
|
||||
});
|
||||
expect(sessionErrors.some((error) => error.includes("session"))).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects unsafe optional fields declared only by the SCUM companion config schema", () => {
|
||||
const schemaErrors = validateTemporaryScumCompanionManifest((_manifest, fixtureDir) => {
|
||||
const schemaPath = path.join(fixtureDir, "schemas/companion/config.schema.json");
|
||||
const schema = JSON.parse(fs.readFileSync(schemaPath, "utf8"));
|
||||
schema.properties.hostPath = { type: "string", minLength: 1, maxLength: 200 };
|
||||
writeFixtureJSON(fixtureDir, "schemas/companion/config.schema.json", schema);
|
||||
});
|
||||
expect(schemaErrors.some((error) => error.includes("hostPath") && error.includes("raw host path"))).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects legacy companion endpoints, insecure TLS flags, and credential keys", () => {
|
||||
const cases = [
|
||||
{
|
||||
name: "legacy shared-token endpoint",
|
||||
mutate(schema: Record<string, any>, _example: Record<string, any>): void {
|
||||
schema.description = "legacy /api/v1/scum-clients/commands endpoint";
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "insecure TLS flag",
|
||||
mutate(schema: Record<string, any>, _example: Record<string, any>): void {
|
||||
schema.description = "InsecureSkipVerify";
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "inline credential key",
|
||||
mutate(schema: Record<string, any>, example: Record<string, any>): void {
|
||||
schema.properties.credential = { type: "string", minLength: 1, maxLength: 200 };
|
||||
example.credential = "legacy-shared-value";
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
for (const testCase of cases) {
|
||||
const errors = validateTemporaryScumCompanionManifest((_manifest, fixtureDir) => {
|
||||
const schemaPath = path.join(fixtureDir, "schemas/companion/config.schema.json");
|
||||
const examplePath = path.join(fixtureDir, "schemas/companion/config.generated.example.json");
|
||||
const schema = JSON.parse(fs.readFileSync(schemaPath, "utf8")) as Record<string, any>;
|
||||
const example = JSON.parse(fs.readFileSync(examplePath, "utf8")) as Record<string, any>;
|
||||
testCase.mutate(schema, example);
|
||||
writeFixtureJSON(fixtureDir, "schemas/companion/config.schema.json", schema);
|
||||
writeFixtureJSON(fixtureDir, "schemas/companion/config.generated.example.json", example);
|
||||
});
|
||||
|
||||
expect(errors, testCase.name).toContain(
|
||||
"manifest.gameClientBridge.companion.configSchemaRef: companion config must not contain legacy endpoints, insecure TLS, or inline proof/session material"
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the production SCUM companion free of unsafe legacy transport and updater patterns", () => {
|
||||
const companionDir = path.join(pluginsRoot, "examples/scum-server-plugin/companion");
|
||||
const forbiddenPatterns = [
|
||||
{ name: "legacy shared-token endpoint", pattern: /\/api\/v1\/scum-clients\//i },
|
||||
{ name: "legacy shared credential", pattern: /\b(?:SCUMClientCredential|scum_client_credential)\b/i },
|
||||
{ name: "disabled TLS verification", pattern: /\bInsecureSkipVerify\s*:\s*true\b/ },
|
||||
{ name: "arbitrary process or shell execution", pattern: /(?:\b(?:os\/exec|exec\.Command(?:Context)?|os\.StartProcess|syscall\.Exec)\b|\b(?:bash|zsh|powershell|pwsh|cmd(?:\.exe)?)\s+-[a-z/])/i },
|
||||
{ name: "direct socket transport", pattern: /(?:\bnet\.(?:Dial|DialTimeout)\s*\(|\b(?:tcp|unix|ws):\/\/)/i },
|
||||
{ name: "arbitrary URL self-update or download", pattern: /(?:\b(?:self_?update|update_?url|download_?url|updater)\b|\bhttp\.(?:Get|DefaultClient\.Get)\s*\(|\b(?:curl|wget)\b)/i }
|
||||
];
|
||||
|
||||
const productionFiles = listProductionGoFiles(companionDir);
|
||||
expect(productionFiles.length).toBeGreaterThan(0);
|
||||
for (const file of productionFiles) {
|
||||
const source = fs.readFileSync(file, "utf8");
|
||||
const relativeFile = path.relative(companionDir, file);
|
||||
for (const forbidden of forbiddenPatterns) {
|
||||
const match = source.match(forbidden.pattern);
|
||||
expect(match ? `${relativeFile}: ${match[0]}` : null, forbidden.name).toBeNull();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("covers the SCUM 4.1 bridge and lifecycle declarations", () => {
|
||||
const manifestPath = path.join(pluginsRoot, "examples/scum-server-plugin/manifest.json");
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")) as {
|
||||
permissions: string[];
|
||||
gameClientBridge: {
|
||||
commands: Array<{
|
||||
type: string;
|
||||
permission: string;
|
||||
approvalLevel: string;
|
||||
payloadSchemaRef: string;
|
||||
resultSchemaRef?: string;
|
||||
timeoutSeconds: number;
|
||||
maxPayloadBytes: number;
|
||||
}>;
|
||||
snapshots: Array<{ type: string; schemaVersion: string; schemaRef: string }>;
|
||||
pages: Array<{ pageKey: string; commandTypes?: string[]; snapshotTypes?: string[] }>;
|
||||
};
|
||||
pages: Array<{ key: string }>;
|
||||
runtimeProfiles?: {
|
||||
lifecycleProfiles?: Array<{ key: string; capabilities?: string[] }>;
|
||||
logSources?: Array<{ key: string }>;
|
||||
clientManagers?: Array<{
|
||||
key: string;
|
||||
build?: { workspaceRef?: string; entryRef?: string };
|
||||
configTemplates?: Array<{ key?: string; templateRef?: string; outputRef?: string }>;
|
||||
deployment?: { arguments?: string[] };
|
||||
health?: { intervalSeconds?: number; degradedAfterSeconds?: number; offlineAfterSeconds?: number };
|
||||
}>;
|
||||
};
|
||||
};
|
||||
const installAction = JSON.parse(fs.readFileSync(path.join(pluginsRoot, "examples/scum-server-plugin/actions/install.json"), "utf8")) as { environment?: Record<string, string> };
|
||||
const serialized = JSON.stringify(manifest).toLowerCase();
|
||||
|
||||
expect(serialized).not.toContain("local-proof");
|
||||
expect(installAction.environment?.SERVER_TEMPLATE).toBe("scum-server");
|
||||
expect(manifest.permissions).toEqual(expect.arrayContaining(["server.game-client.read", "server.game-client.command", "server.game-client.maintenance"]));
|
||||
expect(manifest.gameClientBridge.commands.map((command) => command.type)).toEqual(expect.arrayContaining([
|
||||
"announcement.send",
|
||||
"companion.diagnostics",
|
||||
"player.lookup",
|
||||
"reward.deliver",
|
||||
"event.start",
|
||||
"restart.prepare",
|
||||
"maintenance.prepare"
|
||||
]));
|
||||
expect(manifest.gameClientBridge.snapshots.map((snapshot) => snapshot.type)).toEqual(expect.arrayContaining(["companion.health", "online.sessions", "players", "squads", "vehicles", "flags"]));
|
||||
expect(manifest.gameClientBridge.pages.map((page) => page.pageKey)).toContain("operations");
|
||||
expect(manifest.gameClientBridge.pages.find((page) => page.pageKey === "operations")?.commandTypes).toEqual(expect.arrayContaining([
|
||||
"announcement.send",
|
||||
"companion.diagnostics",
|
||||
"player.lookup",
|
||||
"reward.deliver",
|
||||
"event.start",
|
||||
"restart.prepare",
|
||||
"maintenance.prepare"
|
||||
]));
|
||||
expect(manifest.pages.map((page) => page.key)).toContain("operations");
|
||||
expect(manifest.runtimeProfiles?.lifecycleProfiles?.find((profile) => profile.key === "scum-client")?.capabilities).not.toContain("remote.run.rcon.command");
|
||||
expect(manifest.runtimeProfiles?.logSources?.map((source) => source.key)).toEqual(expect.arrayContaining(["scum-chat-events", "scum-server-events", "scum-client-events"]));
|
||||
});
|
||||
|
||||
it("declares bounded and permissioned SCUM bridge commands", () => {
|
||||
const pluginDir = path.join(pluginsRoot, "examples/scum-server-plugin");
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(pluginDir, "manifest.json"), "utf8")) as {
|
||||
gameClientBridge: {
|
||||
commands: Array<{
|
||||
type: string;
|
||||
permission: string;
|
||||
approvalLevel: string;
|
||||
payloadSchemaRef: string;
|
||||
resultSchemaRef?: string;
|
||||
timeoutSeconds: number;
|
||||
maxPayloadBytes: number;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
const expected = {
|
||||
"announcement.send": { permission: "server.game-client.command", approvalLevel: "operator" },
|
||||
"companion.diagnostics": { permission: "server.game-client.read", approvalLevel: "none" },
|
||||
"player.lookup": { permission: "server.game-client.read", approvalLevel: "none" },
|
||||
"reward.deliver": { permission: "server.game-client.command", approvalLevel: "operator" },
|
||||
"event.start": { permission: "server.game-client.command", approvalLevel: "operator" },
|
||||
"restart.prepare": { permission: "server.game-client.maintenance", approvalLevel: "operator" },
|
||||
"maintenance.prepare": { permission: "server.game-client.maintenance", approvalLevel: "platform-admin" }
|
||||
} as const;
|
||||
|
||||
expect(manifest.gameClientBridge.commands.map((command) => command.type)).toEqual(expect.arrayContaining(Object.keys(expected)));
|
||||
for (const command of manifest.gameClientBridge.commands) {
|
||||
const policy = expected[command.type as keyof typeof expected];
|
||||
if (!policy) {
|
||||
continue;
|
||||
}
|
||||
expect(command.permission).toBe(policy.permission);
|
||||
expect(command.approvalLevel).toBe(policy.approvalLevel);
|
||||
expect(command.timeoutSeconds).toBeGreaterThan(0);
|
||||
expect(command.timeoutSeconds).toBeLessThanOrEqual(3600);
|
||||
expect(command.maxPayloadBytes).toBeGreaterThan(0);
|
||||
expect(command.maxPayloadBytes).toBeLessThanOrEqual(65536);
|
||||
expect(command.resultSchemaRef).toBeTruthy();
|
||||
}
|
||||
|
||||
const schemaRefs = manifest.gameClientBridge.commands.flatMap((command) => [command.payloadSchemaRef, command.resultSchemaRef].filter((ref): ref is string => Boolean(ref)));
|
||||
for (const schemaRef of schemaRefs) {
|
||||
const schema = JSON.parse(fs.readFileSync(path.join(pluginDir, schemaRef), "utf8")) as Record<string, unknown>;
|
||||
const visit = (value: unknown): void => {
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach(visit);
|
||||
return;
|
||||
}
|
||||
if (typeof value !== "object" || value === null) {
|
||||
return;
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
if (record.type === "object" || Object.hasOwn(record, "properties")) {
|
||||
expect(record.additionalProperties).toBe(false);
|
||||
}
|
||||
if (record.type === "array") {
|
||||
expect(record.maxItems).toBeGreaterThan(0);
|
||||
}
|
||||
if (record.type === "string") {
|
||||
expect(record.maxLength).toBeGreaterThan(0);
|
||||
}
|
||||
if (record.type === "integer" || record.type === "number") {
|
||||
expect(record.maximum).toBeDefined();
|
||||
}
|
||||
Object.values(record).forEach(visit);
|
||||
};
|
||||
expect(schema.type).toBe("object");
|
||||
expect(schema.additionalProperties).toBe(false);
|
||||
visit(schema.properties);
|
||||
}
|
||||
});
|
||||
|
||||
it("declares bounded SCUM snapshot schemas for operations projections", () => {
|
||||
const manifestPath = path.join(pluginsRoot, "examples/scum-server-plugin/manifest.json");
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")) as {
|
||||
gameClientBridge: {
|
||||
snapshots: Array<{ type: string; schemaVersion: string; schemaRef: string }>;
|
||||
pages: Array<{ pageKey: string; snapshotTypes?: string[] }>;
|
||||
};
|
||||
};
|
||||
const expectedTypes = ["companion.health", "online.sessions", "players", "squads", "vehicles", "flags"];
|
||||
const snapshotsByType = new Map(manifest.gameClientBridge.snapshots.map((snapshot) => [snapshot.type, snapshot]));
|
||||
|
||||
expect([...snapshotsByType.keys()]).toEqual(expect.arrayContaining(expectedTypes));
|
||||
for (const type of expectedTypes) {
|
||||
const snapshot = snapshotsByType.get(type);
|
||||
expect(snapshot?.schemaVersion).toBe("1");
|
||||
expect(snapshot?.schemaRef).toMatch(/^schemas\/bridge\/[a-z-]+\.snapshot\.schema\.json$/);
|
||||
const schema = JSON.parse(fs.readFileSync(path.join(pluginsRoot, "examples/scum-server-plugin", snapshot!.schemaRef), "utf8")) as {
|
||||
type?: string;
|
||||
additionalProperties?: boolean;
|
||||
properties?: Record<string, { type?: string; maxItems?: number }>;
|
||||
};
|
||||
expect(schema.type).toBe("object");
|
||||
expect(schema.additionalProperties).toBe(false);
|
||||
for (const property of Object.values(schema.properties ?? {})) {
|
||||
if (property.type === "array") {
|
||||
expect(property.maxItems).toBeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const operationsPage = manifest.gameClientBridge.pages.find((page) => page.pageKey === "operations");
|
||||
expect(operationsPage?.snapshotTypes).toEqual(expect.arrayContaining(expectedTypes));
|
||||
});
|
||||
|
||||
it("declares read-only bounded SCUM database query templates", () => {
|
||||
const pluginDir = path.join(pluginsRoot, "examples/scum-server-plugin");
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(pluginDir, "manifest.json"), "utf8")) as {
|
||||
permissions: string[];
|
||||
gameClientBridge: {
|
||||
queryTemplates: Array<{
|
||||
key: string;
|
||||
permission: string;
|
||||
engine: string;
|
||||
transportKey: string;
|
||||
targetKey: string;
|
||||
parameterSchemaRef: string;
|
||||
resultSchemaRef: string;
|
||||
maxRows: number;
|
||||
timeoutSeconds: number;
|
||||
}>;
|
||||
pages: Array<{ pageKey: string; queryTemplateKeys?: string[] }>;
|
||||
};
|
||||
pages: Array<{ key: string; permissions?: string[]; bridgeActions?: string[] }>;
|
||||
runtimeProfiles?: { transportProfiles?: Array<{ key: string; kind: string; targetKey?: string; capabilities: string[] }> };
|
||||
};
|
||||
const expectedKeys = ["scum.player.by-id", "scum.player.search", "scum.squad.members", "scum.vehicle.owner", "scum.flag.ownership"];
|
||||
const transport = manifest.runtimeProfiles?.transportProfiles?.find((profile) => profile.key === "sqlite-db");
|
||||
|
||||
expect(transport).toMatchObject({ kind: "sqlite", targetKey: "db/sqlite" });
|
||||
expect(transport?.capabilities).toContain("remote.run.db.sqlite.query");
|
||||
expect(manifest.gameClientBridge.queryTemplates.map((template) => template.key)).toEqual(expect.arrayContaining(expectedKeys));
|
||||
expect(new Set(manifest.gameClientBridge.queryTemplates.map((template) => template.key)).size).toBe(manifest.gameClientBridge.queryTemplates.length);
|
||||
for (const template of manifest.gameClientBridge.queryTemplates) {
|
||||
expect(template.engine).toBe("sqlite");
|
||||
expect(template.permission).toBe("server.game-client.read");
|
||||
expect(manifest.permissions).toContain(template.permission);
|
||||
expect(template.transportKey).toBe("sqlite-db");
|
||||
expect(template.targetKey).toBe("db/sqlite");
|
||||
expect(template.maxRows).toBeGreaterThanOrEqual(1);
|
||||
expect(template.maxRows).toBeLessThanOrEqual(500);
|
||||
expect(template.timeoutSeconds).toBeGreaterThanOrEqual(1);
|
||||
expect(template.timeoutSeconds).toBeLessThanOrEqual(60);
|
||||
expect(JSON.stringify(template).toLowerCase()).not.toMatch(/\bselect\b|\binsert\b|\bupdate\b|\bdelete\b|\bpragma\b|dsn|hostpath|socket|password|credential/);
|
||||
|
||||
for (const schemaRef of [template.parameterSchemaRef, template.resultSchemaRef]) {
|
||||
const schema = JSON.parse(fs.readFileSync(path.join(pluginDir, schemaRef), "utf8")) as Record<string, unknown>;
|
||||
const visit = (value: unknown): void => {
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach(visit);
|
||||
return;
|
||||
}
|
||||
if (typeof value !== "object" || value === null) {
|
||||
return;
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
if (record.type === "object" || Object.hasOwn(record, "properties")) {
|
||||
expect(record.additionalProperties).toBe(false);
|
||||
}
|
||||
if (record.type === "array") {
|
||||
expect(record.maxItems).toBeGreaterThan(0);
|
||||
}
|
||||
if (record.type === "string") {
|
||||
expect(record.maxLength).toBeGreaterThan(0);
|
||||
}
|
||||
if (record.type === "integer" || record.type === "number") {
|
||||
expect(record.maximum).toBeDefined();
|
||||
}
|
||||
Object.values(record).forEach(visit);
|
||||
};
|
||||
expect(schema.type).toBe("object");
|
||||
expect(schema.additionalProperties).toBe(false);
|
||||
expect(JSON.stringify(schema).toLowerCase()).not.toMatch(/\bselect\b[\s\S]*\bfrom\b|\binsert\s+into\b|\bdelete\s+from\b|\bpragma\b|dsn|hostpath|runsocket|password|credential/);
|
||||
visit(schema);
|
||||
}
|
||||
}
|
||||
|
||||
const operationsPage = manifest.gameClientBridge.pages.find((page) => page.pageKey === "operations");
|
||||
const operationsPluginPage = manifest.pages.find((page) => page.key === "operations");
|
||||
expect(operationsPage?.queryTemplateKeys).toEqual(expect.arrayContaining(expectedKeys));
|
||||
expect(operationsPluginPage?.permissions).toEqual(expect.arrayContaining(["server.game-client.read", "server.remote.access"]));
|
||||
expect(operationsPluginPage?.bridgeActions).toContain("remote.access.request");
|
||||
});
|
||||
|
||||
it("declares typed SCUM semantic log events with bounded schemas", () => {
|
||||
const pluginDir = path.join(pluginsRoot, "examples/scum-server-plugin");
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(pluginDir, "manifest.json"), "utf8")) as {
|
||||
permissions: string[];
|
||||
runtimeProfiles?: {
|
||||
logSources?: Array<{ key: string; retentionDays?: number }>;
|
||||
logEvents?: Array<RuntimeLogEventDeclaration>;
|
||||
};
|
||||
};
|
||||
const expectedTypes = ["scum.chat", "scum.login", "scum.logout", "scum.kill", "scum.trade", "scum.mine", "scum.unlock", "scum.admin", "scum.performance"];
|
||||
const logSources = new Map((manifest.runtimeProfiles?.logSources ?? []).map((source) => [source.key, source]));
|
||||
const logEvents = manifest.runtimeProfiles?.logEvents ?? [];
|
||||
|
||||
expect(logEvents.map((event) => event.eventType)).toEqual(expect.arrayContaining(expectedTypes));
|
||||
expect(new Set(logEvents.map((event) => event.key)).size).toBe(logEvents.length);
|
||||
expect(new Set(logEvents.map((event) => event.eventType)).size).toBe(logEvents.length);
|
||||
for (const event of logEvents) {
|
||||
const source = logSources.get(event.sourceKey);
|
||||
expect(source).toBeDefined();
|
||||
expect(manifest.permissions).toContain(event.permission);
|
||||
expect(event.retentionDays).toBeGreaterThanOrEqual(1);
|
||||
expect(event.retentionDays).toBeLessThanOrEqual(source?.retentionDays ?? 365);
|
||||
expect(["info", "notice", "warning", "critical"]).toContain(event.severity);
|
||||
expect(event.schemaRef).toMatch(/^schemas\/log-events\/[a-z-]+\.event\.schema\.json$/);
|
||||
|
||||
const schema = JSON.parse(fs.readFileSync(path.join(pluginDir, event.schemaRef), "utf8")) as Record<string, unknown>;
|
||||
expect((schema.properties as Record<string, Record<string, unknown>>).occurredAt).toMatchObject({ type: "string", format: "date-time" });
|
||||
const visit = (value: unknown): void => {
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach(visit);
|
||||
return;
|
||||
}
|
||||
if (typeof value !== "object" || value === null) {
|
||||
return;
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
if (record.type === "object" || Object.hasOwn(record, "properties")) {
|
||||
expect(record.additionalProperties).toBe(false);
|
||||
}
|
||||
if (record.type === "array") {
|
||||
expect(record.maxItems).toBeGreaterThan(0);
|
||||
}
|
||||
if (record.type === "string" && !Object.hasOwn(record, "enum") && !Object.hasOwn(record, "const")) {
|
||||
expect(record.maxLength).toBeGreaterThan(0);
|
||||
}
|
||||
if (record.type === "integer" || record.type === "number") {
|
||||
expect(record.minimum).toBeDefined();
|
||||
expect(record.maximum).toBeDefined();
|
||||
}
|
||||
Object.values(record).forEach(visit);
|
||||
};
|
||||
expect(schema.type).toBe("object");
|
||||
expect(JSON.stringify(schema).toLowerCase()).not.toMatch(/sqltext|shellcommand|hostpath|rawpath|password|credential|runsocket|directsocket/);
|
||||
visit(schema);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects unsafe semantic log declarations and missing references", () => {
|
||||
const errors = validateTemporaryLogEventManifest((manifest) => {
|
||||
const event = manifest.runtimeProfiles!.logEvents![0];
|
||||
event.eventType = "shell.execute";
|
||||
event.sourceKey = "missing-source";
|
||||
event.permission = "server.game-client.read";
|
||||
event.schemaRef = "schemas/log-events/missing.event.schema.json";
|
||||
event.retentionDays = 366;
|
||||
event.severity = "urgent";
|
||||
});
|
||||
|
||||
expect(errors.some((error) => error.includes("eventType") && error.includes("not allowed"))).toBe(true);
|
||||
expect(errors.some((error) => error.includes("sourceKey") && error.includes("undeclared log source"))).toBe(true);
|
||||
expect(errors.some((error) => error.includes("permission") && error.includes("declared"))).toBe(true);
|
||||
expect(errors.some((error) => error.includes("schemaRef") && error.includes("missing semantic log event schema"))).toBe(true);
|
||||
expect(errors.some((error) => error.includes("retentionDays"))).toBe(true);
|
||||
expect(errors.some((error) => error.includes("severity"))).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects unsafe or unbounded semantic log event schemas", () => {
|
||||
const errors = validateTemporaryLogEventManifest((_manifest, fixtureDir) => {
|
||||
writeFixtureJSON(fixtureDir, "schemas/log-events/login.event.schema.json", bridgeObjectSchema({ hostPath: { type: "string" }, details: { type: "string" }, count: { type: "integer" } }, ["hostPath", "details", "count"]));
|
||||
});
|
||||
|
||||
expect(errors.some((error) => error.includes("schemaRef") && error.includes("raw host path"))).toBe(true);
|
||||
expect(errors.some((error) => error.includes("maxLength") && error.includes("bounded event strings"))).toBe(true);
|
||||
expect(errors.some((error) => error.includes("bounded event numbers"))).toBe(true);
|
||||
});
|
||||
|
||||
it("aligns the SCUM Client Manager declaration with the real Go bootstrap", () => {
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(pluginsRoot, "examples/scum-server-plugin/manifest.json"), "utf8")) as {
|
||||
runtimeProfiles?: { clientManagers?: Array<{
|
||||
key: string;
|
||||
build?: { workspaceRef?: string; entryRef?: string };
|
||||
configTemplates?: Array<{ key?: string; templateRef?: string; outputRef?: string }>;
|
||||
deployment?: { arguments?: string[] };
|
||||
health?: { intervalSeconds?: number; degradedAfterSeconds?: number; offlineAfterSeconds?: number };
|
||||
}> };
|
||||
};
|
||||
const manager = manifest.runtimeProfiles?.clientManagers?.find((profile) => profile.key === "scum-client-manager");
|
||||
expect(manager?.build).toMatchObject({ entryRef: "main.go" });
|
||||
expect(manager?.build).not.toHaveProperty("workspaceRef");
|
||||
expect(manager?.configTemplates).toEqual([{ key: "client-config", templateRef: "config.yaml.example", outputRef: "config.yaml" }]);
|
||||
expect(manager?.deployment?.arguments).toBeUndefined();
|
||||
expect(manager?.health).toMatchObject({ intervalSeconds: 30, degradedAfterSeconds: 90, offlineAfterSeconds: 120 });
|
||||
});
|
||||
|
||||
it("accepts the Minecraft server plugin manifest", () => {
|
||||
expect(validateManifestFile("examples/minecraft-server-plugin/manifest.json")).toEqual([]);
|
||||
});
|
||||
|
||||
it("validates typed game-client bridge catalogs", () => {
|
||||
const schema = JSON.parse(fs.readFileSync(path.join(pluginsRoot, "manifests/game-plugin.manifest.schema.json"), "utf8"));
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(pluginsRoot, "examples/dev-game-plugin/manifest.json"), "utf8"));
|
||||
manifest.permissions = [...manifest.permissions, "server.game-client.command", "server.game-client.read"];
|
||||
manifest.gameClientBridge = {
|
||||
commands: [{ type: "announcement.send", title: "Send announcement", permission: "server.game-client.command", approvalLevel: "operator", payloadSchemaRef: "schemas/bridge/announcement.schema.json", resultSchemaRef: "schemas/bridge/announcement-result.schema.json", timeoutSeconds: 60, maxPayloadBytes: 4096 }],
|
||||
snapshots: [{ type: "players", schemaVersion: "1", schemaRef: "schemas/bridge/players.schema.json", keepForSeconds: 3600, maxRecords: 100 }],
|
||||
commandRetentionSeconds: 86400,
|
||||
maxCommands: 1000,
|
||||
pages: []
|
||||
};
|
||||
const validate = new Ajv2020({ allErrors: true }).compile(schema);
|
||||
expect(validate(manifest), JSON.stringify(validate.errors)).toBe(true);
|
||||
manifest.gameClientBridge.commands[0].approvalLevel = "automatic";
|
||||
expect(validate(manifest)).toBe(false);
|
||||
});
|
||||
|
||||
it("loads and validates every schema referenced by a safe game-client bridge manifest", () => {
|
||||
expect(validateTemporaryBridgeManifest()).toEqual([]);
|
||||
});
|
||||
|
||||
it.each(["sql.execute", "sqlExecute", "database.execute", "database.query"])("rejects arbitrary SQL command type %s independently", (commandType) => {
|
||||
const errors = validateTemporaryBridgeManifest((manifest) => {
|
||||
manifest.gameClientBridge.commands[0].type = commandType;
|
||||
});
|
||||
expect(errors.some((error) => error.includes("commands[0].type") && error.includes("arbitrary SQL"))).toBe(true);
|
||||
});
|
||||
|
||||
it.each(["shell.execute", "powershell.execute", "script.run", "terminal.execute", "command.run"])("rejects arbitrary shell command type %s independently", (commandType) => {
|
||||
const errors = validateTemporaryBridgeManifest((manifest) => {
|
||||
manifest.gameClientBridge.commands[0].type = commandType;
|
||||
});
|
||||
expect(errors.some((error) => error.includes("commands[0].type") && error.includes("arbitrary shell"))).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects raw bridge schema paths end to end", () => {
|
||||
const errors = validateTemporaryBridgeManifest((manifest) => {
|
||||
manifest.gameClientBridge.commands[0].payloadSchemaRef = "/etc/scum-query.json";
|
||||
});
|
||||
expect(errors.some((error) => error.includes("payloadSchemaRef") && error.includes("raw host paths"))).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects undeclared page command references end to end", () => {
|
||||
const errors = validateTemporaryBridgeManifest((manifest) => {
|
||||
manifest.gameClientBridge.pages[0].commandTypes = ["undeclared.command"];
|
||||
});
|
||||
expect(errors.some((error) => error.includes("undeclared command undeclared.command"))).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects duplicate and undeclared page query template keys", () => {
|
||||
const duplicateErrors = validateTemporaryBridgeManifest((manifest) => {
|
||||
manifest.gameClientBridge.queryTemplates?.push({ ...manifest.gameClientBridge.queryTemplates[0] });
|
||||
});
|
||||
expect(duplicateErrors.some((error) => error.includes("duplicate query template player.by-id"))).toBe(true);
|
||||
|
||||
const pageErrors = validateTemporaryBridgeManifest((manifest) => {
|
||||
manifest.gameClientBridge.pages[0].queryTemplateKeys = ["undeclared.query"];
|
||||
});
|
||||
expect(pageErrors.some((error) => error.includes("undeclared query template undeclared.query"))).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects unsafe or unbounded query template declarations", () => {
|
||||
const errors = validateTemporaryBridgeManifest((manifest) => {
|
||||
Object.assign(manifest.gameClientBridge.queryTemplates?.[0] ?? {}, {
|
||||
key: "../raw-query",
|
||||
engine: "mysql",
|
||||
permission: "server.not-declared",
|
||||
parameterSchemaRef: "/etc/query.json",
|
||||
maxRows: 501,
|
||||
timeoutSeconds: 61
|
||||
});
|
||||
});
|
||||
expect(errors.some((error) => error.includes("queryTemplates/0/key") || error.includes("queryTemplates[0].key"))).toBe(true);
|
||||
expect(errors.some((error) => error.includes("engine") && error.includes("sqlite"))).toBe(true);
|
||||
expect(errors.some((error) => error.includes("permission") && error.includes("declared"))).toBe(true);
|
||||
expect(errors.some((error) => error.includes("parameterSchemaRef") && error.includes("raw host paths"))).toBe(true);
|
||||
expect(errors.some((error) => error.includes("maxRows"))).toBe(true);
|
||||
expect(errors.some((error) => error.includes("timeoutSeconds"))).toBe(true);
|
||||
});
|
||||
|
||||
it("requires query templates to match a declared sqlite transport target and capability", () => {
|
||||
const targetErrors = validateTemporaryBridgeManifest((manifest) => {
|
||||
manifest.gameClientBridge.queryTemplates![0].targetKey = "db/other";
|
||||
});
|
||||
expect(targetErrors.some((error) => error.includes("targetKey") && error.includes("sqlite transport target"))).toBe(true);
|
||||
|
||||
const capabilityErrors = validateTemporaryBridgeManifest((manifest) => {
|
||||
manifest.runtimeProfiles!.transportProfiles![0].capabilities = ["remote.run.files.read"];
|
||||
});
|
||||
expect(capabilityErrors.some((error) => error.includes("transportKey") && error.includes("remote.run.db.sqlite.query"))).toBe(true);
|
||||
});
|
||||
|
||||
it("requires query template pages to declare template permission and remote access", () => {
|
||||
const permissionErrors = validateTemporaryBridgeManifest((manifest) => {
|
||||
const overviewPage = manifest.pages?.find((page) => page.key === "overview");
|
||||
overviewPage!.permissions = overviewPage!.permissions?.filter((permission) => permission !== "server.game-client.read");
|
||||
});
|
||||
expect(permissionErrors.some((error) => error.includes("page must declare query template permission"))).toBe(true);
|
||||
|
||||
const actionErrors = validateTemporaryBridgeManifest((manifest) => {
|
||||
const overviewPage = manifest.pages?.find((page) => page.key === "overview");
|
||||
overviewPage!.bridgeActions = overviewPage!.bridgeActions?.filter((action) => action !== "remote.access.request");
|
||||
});
|
||||
expect(actionErrors.some((error) => error.includes("page must declare remote.access.request"))).toBe(true);
|
||||
});
|
||||
|
||||
it.each(["sqlText", "dsn", "hostPath", "shellCommand", "socketAddress", "accessToken", "credential"])("rejects unsafe query parameter schema field %s", (fieldName) => {
|
||||
const errors = validateTemporaryBridgeManifest((_manifest, fixtureDir) => {
|
||||
writeFixtureJSON(fixtureDir, "schemas/bridge/player-by-id.parameters.schema.json", bridgeObjectSchema({ [fieldName]: { type: "string", minLength: 1, maxLength: 120 } }, [fieldName]));
|
||||
});
|
||||
expect(errors.some((error) => error.includes("queryTemplates[0].parameterSchemaRef") && error.includes("not allowed"))).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects SQL text embedded in a query result schema", () => {
|
||||
const errors = validateTemporaryBridgeManifest((_manifest, fixtureDir) => {
|
||||
writeFixtureJSON(fixtureDir, "schemas/bridge/player-by-id.result.schema.json", bridgeObjectSchema({ summary: { type: "string", minLength: 1, maxLength: 200, const: "SELECT id FROM players" } }, ["summary"]));
|
||||
});
|
||||
expect(errors.some((error) => error.includes("queryTemplates[0].resultSchemaRef") && error.includes("arbitrary SQL content"))).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects missing bridge schema files end to end", () => {
|
||||
const errors = validateTemporaryBridgeManifest((manifest) => {
|
||||
manifest.gameClientBridge.commands[0].resultSchemaRef = "schemas/bridge/missing.schema.json";
|
||||
});
|
||||
expect(errors.some((error) => error.includes("resultSchemaRef") && error.includes("missing bridge schema file"))).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects missing bridge command approval metadata end to end", () => {
|
||||
const errors = validateTemporaryBridgeManifest((manifest) => {
|
||||
delete manifest.gameClientBridge.commands[0].approvalLevel;
|
||||
});
|
||||
expect(errors.some((error) => error.includes("approvalLevel") && (error.includes("required") || error.includes("approval metadata")))).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects unsafe executor capabilities end to end", () => {
|
||||
const errors = validateTemporaryBridgeManifest((manifest) => {
|
||||
manifest.capabilities = [...manifest.capabilities, "shell.exec"];
|
||||
});
|
||||
expect(errors.some((error) => error.includes("capabilities") && error.includes("allowed values"))).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects invalid bridge schema JSON without throwing", () => {
|
||||
const errors = validateTemporaryBridgeManifest((_manifest, fixtureDir) => {
|
||||
fs.writeFileSync(path.join(fixtureDir, "schemas/bridge/announcement.schema.json"), "{ invalid", "utf8");
|
||||
});
|
||||
expect(errors.some((error) => error.includes("payloadSchemaRef") && error.includes("not valid JSON"))).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects dangerous fields and values in payload, result, and snapshot schemas", () => {
|
||||
const errors = validateTemporaryBridgeManifest((_manifest, fixtureDir) => {
|
||||
writeFixtureJSON(fixtureDir, "schemas/bridge/announcement.schema.json", bridgeObjectSchema({ sqlText: { type: "string" } }, ["sqlText"]));
|
||||
writeFixtureJSON(fixtureDir, "schemas/bridge/announcement-result.schema.json", bridgeObjectSchema({ shellCommand: { type: "string", const: "bash -c whoami" } }, ["shellCommand"]));
|
||||
writeFixtureJSON(fixtureDir, "schemas/bridge/players.schema.json", bridgeObjectSchema({ hostPath: { type: "string" }, mode: { type: "string", const: "run.socket" }, runCapability: { type: "string" } }, ["hostPath", "mode", "runCapability"]));
|
||||
});
|
||||
expect(errors.some((error) => error.includes("payloadSchemaRef") && error.includes("arbitrary SQL field"))).toBe(true);
|
||||
expect(errors.some((error) => error.includes("resultSchemaRef") && error.includes("arbitrary shell"))).toBe(true);
|
||||
expect(errors.some((error) => error.includes("snapshots[0].schemaRef") && error.includes("raw host path"))).toBe(true);
|
||||
expect(errors.some((error) => error.includes("snapshots[0].schemaRef") && error.includes("unsafe executor capability"))).toBe(true);
|
||||
});
|
||||
|
||||
it("requires bounded object schemas for every bridge reference", () => {
|
||||
const errors = validateTemporaryBridgeManifest((_manifest, fixtureDir) => {
|
||||
writeFixtureJSON(fixtureDir, "schemas/bridge/announcement.schema.json", { type: "object", properties: { message: { type: "string" } } });
|
||||
});
|
||||
expect(errors.some((error) => error.includes("payloadSchemaRef") && error.includes("additionalProperties to false"))).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects a manifest with an invalid create form schema", () => {
|
||||
const errors = validateManifestFile("tests/fixtures/invalid-create-form-manifest.json");
|
||||
|
||||
@@ -79,6 +896,53 @@ describe("plugin manifest validation", () => {
|
||||
});
|
||||
|
||||
describe("plugin SDK", () => {
|
||||
|
||||
it("types generic runtime semantic log event declarations", () => {
|
||||
const declaration: RuntimeLogEventDeclaration = {
|
||||
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"
|
||||
};
|
||||
expect(declaration).toMatchObject({ eventType: "scum.performance", permission: "server.logs.read", severity: "info" });
|
||||
});
|
||||
|
||||
it("types read-only SQLite query template declarations", () => {
|
||||
const declaration: GameClientBridgeQueryTemplateDeclaration = {
|
||||
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
|
||||
};
|
||||
expect(declaration).toMatchObject({ engine: "sqlite", transportKey: "sqlite-db", targetKey: "db/sqlite", maxRows: 1 });
|
||||
expect(JSON.stringify(declaration).toLowerCase()).not.toMatch(/sqltext|dsn|hostpath|socket|credential/);
|
||||
});
|
||||
|
||||
it("builds safe game-client bridge requests without component transport material", () => {
|
||||
const request = createGameClientBridgeQueueRequest({
|
||||
profileKey: "scum-client",
|
||||
commandType: "announcement.send",
|
||||
payload: { message: "hello" },
|
||||
idempotencyKey: "announcement-1",
|
||||
expiresAt: "2026-07-20T12:00:00Z"
|
||||
});
|
||||
expect(request.commandType).toBe("announcement.send");
|
||||
expect(request).not.toHaveProperty("sessionToken");
|
||||
expect(request).not.toHaveProperty("componentKey");
|
||||
expect(request).not.toHaveProperty("runEndpoint");
|
||||
expect(request).not.toHaveProperty("hostPath");
|
||||
expect(request).not.toHaveProperty("dsn");
|
||||
});
|
||||
it("checks declared bridge permissions", () => {
|
||||
const context: PluginBridgeContext = {
|
||||
pluginId: "game.example",
|
||||
@@ -321,6 +1185,7 @@ describe("plugin SDK", () => {
|
||||
capability: "remote.run.rcon.command",
|
||||
targetKey: "rcon/command",
|
||||
inputRef: "input://server-1/rcon/command/1",
|
||||
inputs: { playerId: "steam-123", limit: "25" },
|
||||
idempotencyKey: "idem-remote-rcon"
|
||||
});
|
||||
|
||||
@@ -335,6 +1200,8 @@ describe("plugin SDK", () => {
|
||||
capability: "remote.run.rcon.command",
|
||||
targetKey: "rcon/command",
|
||||
inputRef: "input://server-1/rcon/command/1",
|
||||
"input.playerId": "steam-123",
|
||||
"input.limit": "25",
|
||||
idempotencyKey: "idem-remote-rcon"
|
||||
}
|
||||
});
|
||||
@@ -370,6 +1237,7 @@ describe("plugin SDK", () => {
|
||||
server: { type: "runtime", displayName: "Runtime Fixture", createFormSchema: "schemas/create-form.schema.json" },
|
||||
capabilities: ["process.start", "process.stop", "logs.read"],
|
||||
permissions: ["server.read", "server.lifecycle", "server.logs.read"],
|
||||
productionLifecycle: { operations: ["install", "enable", "disable", "upgrade", "rollback", "retire", "dependency-check"], dependencyPolicy: "required", approvalRequired: ["disable", "rollback", "retire"] },
|
||||
runtimeProfiles: {
|
||||
discovery: [{ key: "java", kind: "command.version", targetKey: "java", required: true }],
|
||||
dependencyProbes: [{ key: "java-21", kind: "java.version", targetKey: "java", minimumVersion: "21" }],
|
||||
@@ -442,4 +1310,17 @@ describe("plugin SDK", () => {
|
||||
})).toBeUndefined();
|
||||
});
|
||||
|
||||
it("builds full plugin lifecycle envelopes through Platform only", () => {
|
||||
const context: PluginBridgeContext = {
|
||||
pluginId: "game.scum",
|
||||
routeKey: "overview",
|
||||
serverInstanceId: "server-1",
|
||||
permissions: ["server.lifecycle"]
|
||||
};
|
||||
const request = createProductionPluginLifecycleRequest({ requestId: "plugin-upgrade-1", context, operation: "upgrade", targetVersion: "1.2.0", idempotencyKey: "plugin-upgrade-v1" });
|
||||
expect(request).toMatchObject({ action: "plugin-lifecycle.request", payload: { operation: "upgrade", targetVersion: "1.2.0", confirmed: "false" } });
|
||||
expect(JSON.stringify(request)).not.toMatch(/apiKey|providerBaseUrl|runSocket|runEndpoint|hostPath|credential/i);
|
||||
expect(() => createProductionPluginLifecycleRequest({ requestId: "plugin-retire-1", context, operation: "retire", idempotencyKey: "plugin-retire-v1" })).toThrow(/confirmation/);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user