feat(plugin): gate pages on companion features

This commit is contained in:
npc0-hue
2026-07-29 09:53:39 +08:00
parent 271e1e684f
commit c9494abc48
16 changed files with 300 additions and 61 deletions
@@ -36,7 +36,7 @@ The alternative of metadata-only generic forms is insufficient for the map, play
### 3. Long-running SCUM Companion adapter
The Companion SHALL run a bounded dispatch loop after registration. It SHALL claim declared commands, validate the exact command schema and SCUM capability/version, execute only a registered handler, acknowledge/completely report idempotent typed results, and produce no output containing raw paths, credentials, IPs, database rows, or RCON command text.
The Companion SHALL run a bounded dispatch loop after registration. It SHALL claim declared commands, validate the exact command schema and SCUM capability/version, execute only a registered handler, and acknowledge/completely report idempotent typed results. It SHALL produce no output containing raw paths, credentials, IPs, database rows, or arbitrary RCON command text. A version-bound typed UE4SS adapter MAY record the exact generated command text in the command's protected audit payload (for example, the fixed `#spawnvehicle <vehicleCode>` generated by `vehicle.spawn`); that audit record does not create a browser-visible or arbitrary-command RCON interface.
The adapter SHALL include independent handlers for configuration read/patch, semantic log/event production, `reward.deliver`, `player.notify`, and `game-state.patch`. A handler unavailable for a discovered server version SHALL return an explicit unsupported result; the platform must keep the operation disabled.
@@ -12,7 +12,7 @@ The SCUM Companion SHALL run a bounded authenticated command-dispatch loop and S
- **THEN** it does not invoke SCUM, RCON, a database, OCR, or desktop automation and completes the command with an explicit unsupported or validation failure result
### Requirement: Companion emits validated semantic SCUM events
The SCUM Companion SHALL collect only declared allowed sources and upload contiguous semantic event batches through the platform's durable log channel. It SHALL validate required event fields before upload and SHALL not emit raw IP addresses, network fingerprints, host paths, credentials, database rows, screenshots, or raw RCON command text.
The SCUM Companion SHALL collect only declared allowed sources and upload contiguous semantic event batches through the platform's durable log channel. It SHALL validate required event fields before upload and SHALL not emit raw IP addresses, network fingerprints, host paths, credentials, database rows, screenshots, or arbitrary RCON command text. A version-bound typed UE4SS adapter MAY retain the exact generated command text in protected command audit data; it SHALL never expose that text as a general RCON command surface or semantic event payload.
#### Scenario: Valid login event
- **WHEN** a supported SCUM source produces a successful-login record containing the declared player identity and timestamp fields
@@ -1,9 +1,9 @@
## 1. Establish generic extension primitives
- [x] 1.1 Audit every SCUM-named platform API, model, service, route, and hard-coded frontend import introduced by the five transitional deliveries; document its plugin-owned replacement and migration dependency.
- [ ] 1.2 Define and test generic plugin-scoped record/event storage, audit linkage, retention, and typed command-result primitives without SCUM field names.
- [ ] 1.3 Extend the plugin manifest/SDK with versioned page-bundle entries, feature capability declarations, and Companion handler/event-producer availability reporting.
- [ ] 1.4 Add generic platform authorization, server isolation, bundle integrity/version validation, and unavailable-feature behavior for those declarations.
- [x] 1.2 Define and test generic plugin-scoped record/event storage, audit linkage, retention, and typed command-result primitives without SCUM field names.
- [x] 1.3 Extend the plugin manifest/SDK with versioned page-bundle entries, feature capability declarations, and Companion handler/event-producer availability reporting.
- [x] 1.4 Add generic platform authorization, server isolation, bundle integrity/version validation, and unavailable-feature behavior for those declarations.
## 2. Build the SCUM plugin module and page bundle
+35 -7
View File
@@ -66,6 +66,15 @@ type GameClientBridgePageContract struct {
CommandTypes []string
SnapshotTypes []string
QueryTemplateKeys []string
FeatureKeys []string
}
type GameClientBridgeFeatureDeclaration struct {
Key string
Title string
Permission string
RequiredHandlers []string
RequiredEventProducers []string
}
type GameClientBridgeCompanionDeclaration struct {
@@ -90,6 +99,7 @@ type GameClientBridgeManifest struct {
QueryTemplates []GameClientBridgeQueryTemplateDeclaration
Retention GameClientBridgeRetention
Pages []GameClientBridgePageContract
Features []GameClientBridgeFeatureDeclaration
Companion GameClientBridgeCompanionDeclaration
}
@@ -278,13 +288,21 @@ type GameClientBridgeSnapshotQuery struct {
}
type GameClientBridgeProfileDeclaration struct {
PluginID string
ProfileKey string
Available bool
Reason string
CommandTypes []string
SnapshotTypes []string
QueryTemplateKeys []string
PluginID string
ProfileKey string
Available bool
Reason string
CommandTypes []string
SnapshotTypes []string
QueryTemplateKeys []string
HandlerTypes []string
EventProducerTypes []string
}
type GameClientBridgeFeatureAvailability struct {
Key string
Available bool
Reason string
}
type GameClientBridgeStatus struct {
@@ -293,12 +311,15 @@ type GameClientBridgeStatus struct {
Available bool
Reason string
Profiles []GameClientBridgeProfileDeclaration
Features []GameClientBridgeFeatureAvailability
}
func CopyGameClientBridgeProfileDeclaration(value GameClientBridgeProfileDeclaration) GameClientBridgeProfileDeclaration {
value.CommandTypes = CopyStringSlice(value.CommandTypes)
value.SnapshotTypes = CopyStringSlice(value.SnapshotTypes)
value.QueryTemplateKeys = CopyStringSlice(value.QueryTemplateKeys)
value.HandlerTypes = CopyStringSlice(value.HandlerTypes)
value.EventProducerTypes = CopyStringSlice(value.EventProducerTypes)
return value
}
@@ -307,6 +328,7 @@ func CopyGameClientBridgeStatus(value GameClientBridgeStatus) GameClientBridgeSt
for index := range value.Profiles {
value.Profiles[index] = CopyGameClientBridgeProfileDeclaration(value.Profiles[index])
}
value.Features = append([]GameClientBridgeFeatureAvailability(nil), value.Features...)
return value
}
@@ -365,10 +387,16 @@ func CopyGameClientBridgeManifest(value GameClientBridgeManifest) GameClientBrid
value.Snapshots = append([]GameClientBridgeSnapshotDeclaration(nil), value.Snapshots...)
value.QueryTemplates = append([]GameClientBridgeQueryTemplateDeclaration(nil), value.QueryTemplates...)
value.Pages = append([]GameClientBridgePageContract(nil), value.Pages...)
value.Features = append([]GameClientBridgeFeatureDeclaration(nil), value.Features...)
for index := range value.Pages {
value.Pages[index].CommandTypes = CopyStringSlice(value.Pages[index].CommandTypes)
value.Pages[index].SnapshotTypes = CopyStringSlice(value.Pages[index].SnapshotTypes)
value.Pages[index].QueryTemplateKeys = CopyStringSlice(value.Pages[index].QueryTemplateKeys)
value.Pages[index].FeatureKeys = CopyStringSlice(value.Pages[index].FeatureKeys)
}
for index := range value.Features {
value.Features[index].RequiredHandlers = CopyStringSlice(value.Features[index].RequiredHandlers)
value.Features[index].RequiredEventProducers = CopyStringSlice(value.Features[index].RequiredEventProducers)
}
return value
}
+3 -1
View File
@@ -312,9 +312,10 @@ type GamePluginPage struct {
Key string
Title string
Path string
Bundle PluginPageBundle
Bundle PluginPageBundle
Permissions []string
BridgeActions []string
FeatureKeys []string
}
// PluginPageBundle identifies an installed plugin-owned page module. The host
@@ -1806,6 +1807,7 @@ func CopyGamePluginPageSlice(pages []GamePluginPage) []GamePluginPage {
out[i] = page
out[i].Permissions = CopyStringSlice(page.Permissions)
out[i].BridgeActions = CopyStringSlice(page.BridgeActions)
out[i].FeatureKeys = CopyStringSlice(page.FeatureKeys)
}
return out
}
+27 -14
View File
@@ -194,21 +194,30 @@ type GameClientBridgeAuditReferenceListResponse struct {
}
type GameClientBridgeProfileDeclarationResponse struct {
PluginID string `json:"pluginId"`
ProfileKey string `json:"profileKey"`
Available bool `json:"available"`
Reason string `json:"reason,omitempty"`
CommandTypes []string `json:"commandTypes"`
SnapshotTypes []string `json:"snapshotTypes"`
QueryTemplateKeys []string `json:"queryTemplateKeys"`
PluginID string `json:"pluginId"`
ProfileKey string `json:"profileKey"`
Available bool `json:"available"`
Reason string `json:"reason,omitempty"`
CommandTypes []string `json:"commandTypes"`
SnapshotTypes []string `json:"snapshotTypes"`
QueryTemplateKeys []string `json:"queryTemplateKeys"`
HandlerTypes []string `json:"handlerTypes"`
EventProducerTypes []string `json:"eventProducerTypes"`
}
type GameClientBridgeFeatureAvailabilityResponse struct {
Key string `json:"key"`
Available bool `json:"available"`
Reason string `json:"reason,omitempty"`
}
type GameClientBridgeStatusResponse struct {
ServerInstanceID string `json:"serverInstanceId"`
PluginID string `json:"pluginId"`
Available bool `json:"available"`
Reason string `json:"reason,omitempty"`
Profiles []GameClientBridgeProfileDeclarationResponse `json:"profiles"`
ServerInstanceID string `json:"serverInstanceId"`
PluginID string `json:"pluginId"`
Available bool `json:"available"`
Reason string `json:"reason,omitempty"`
Profiles []GameClientBridgeProfileDeclarationResponse `json:"profiles"`
Features []GameClientBridgeFeatureAvailabilityResponse `json:"features"`
}
func (request GameClientBridgeQueueRequest) ToDomain(serverID, pluginID string) domain.GameClientBridgeQueueRequest {
@@ -356,9 +365,13 @@ func GameClientBridgeStatusFromDomain(value domain.GameClientBridgeStatus) GameC
value = domain.CopyGameClientBridgeStatus(value)
profiles := make([]GameClientBridgeProfileDeclarationResponse, len(value.Profiles))
for index, profile := range value.Profiles {
profiles[index] = GameClientBridgeProfileDeclarationResponse{PluginID: profile.PluginID, ProfileKey: profile.ProfileKey, Available: profile.Available, Reason: profile.Reason, CommandTypes: nonNilStrings(profile.CommandTypes), SnapshotTypes: nonNilStrings(profile.SnapshotTypes), QueryTemplateKeys: nonNilStrings(profile.QueryTemplateKeys)}
profiles[index] = GameClientBridgeProfileDeclarationResponse{PluginID: profile.PluginID, ProfileKey: profile.ProfileKey, Available: profile.Available, Reason: profile.Reason, CommandTypes: nonNilStrings(profile.CommandTypes), SnapshotTypes: nonNilStrings(profile.SnapshotTypes), QueryTemplateKeys: nonNilStrings(profile.QueryTemplateKeys), HandlerTypes: nonNilStrings(profile.HandlerTypes), EventProducerTypes: nonNilStrings(profile.EventProducerTypes)}
}
return GameClientBridgeStatusResponse{ServerInstanceID: value.ServerInstanceID, PluginID: value.PluginID, Available: value.Available, Reason: value.Reason, Profiles: profiles}
features := make([]GameClientBridgeFeatureAvailabilityResponse, len(value.Features))
for index, feature := range value.Features {
features[index] = GameClientBridgeFeatureAvailabilityResponse{Key: feature.Key, Available: feature.Available, Reason: feature.Reason}
}
return GameClientBridgeStatusResponse{ServerInstanceID: value.ServerInstanceID, PluginID: value.PluginID, Available: value.Available, Reason: value.Reason, Profiles: profiles, Features: features}
}
func gameClientBridgeCommandResultFromDomain(value domain.GameClientBridgeResult) GameClientBridgeCommandResultResponse {
+40 -19
View File
@@ -176,14 +176,15 @@ type PluginLifecycleActionsBody struct {
}
type GamePluginPageBody struct {
Key string `json:"key"`
Title string `json:"title"`
Path string `json:"path"`
BundleKey string `json:"bundleKey"`
BundleVersion string `json:"bundleVersion"`
BundleIntegritySHA256 string `json:"bundleIntegritySha256"`
Permissions []string `json:"permissions,omitempty"`
BridgeActions []string `json:"bridgeActions,omitempty"`
Key string `json:"key"`
Title string `json:"title"`
Path string `json:"path"`
BundleKey string `json:"bundleKey"`
BundleVersion string `json:"bundleVersion"`
BundleIntegritySHA256 string `json:"bundleIntegritySha256"`
Permissions []string `json:"permissions,omitempty"`
BridgeActions []string `json:"bridgeActions,omitempty"`
FeatureKeys []string `json:"featureKeys,omitempty"`
}
type PluginLogicalDirectoryBody struct {
@@ -297,6 +298,15 @@ type GameClientBridgePageContractBody struct {
CommandTypes []string `json:"commandTypes,omitempty"`
SnapshotTypes []string `json:"snapshotTypes,omitempty"`
QueryTemplateKeys []string `json:"queryTemplateKeys,omitempty"`
FeatureKeys []string `json:"featureKeys,omitempty"`
}
type GameClientBridgeFeatureDeclarationBody struct {
Key string `json:"key"`
Title string `json:"title"`
Permission string `json:"permission"`
RequiredHandlers []string `json:"requiredHandlers,omitempty"`
RequiredEventProducers []string `json:"requiredEventProducers,omitempty"`
}
type GameClientBridgeCompanionDeclarationBody struct {
@@ -322,6 +332,7 @@ type GameClientBridgeManifestBody struct {
CommandRetentionSeconds int `json:"commandRetentionSeconds"`
MaxCommands int `json:"maxCommands"`
Pages []GameClientBridgePageContractBody `json:"pages,omitempty"`
Features []GameClientBridgeFeatureDeclarationBody `json:"features,omitempty"`
Companion *GameClientBridgeCompanionDeclarationBody `json:"companion,omitempty"`
}
type GameMapTrajectoryDeclarationBody struct {
@@ -1111,13 +1122,17 @@ func (body GameClientBridgeManifestBody) ToDomain() domain.GameClientBridgeManif
}
pages := make([]domain.GameClientBridgePageContract, len(body.Pages))
for index, page := range body.Pages {
pages[index] = domain.GameClientBridgePageContract{PageKey: page.PageKey, CommandTypes: domain.CopyStringSlice(page.CommandTypes), SnapshotTypes: domain.CopyStringSlice(page.SnapshotTypes), QueryTemplateKeys: domain.CopyStringSlice(page.QueryTemplateKeys)}
pages[index] = domain.GameClientBridgePageContract{PageKey: page.PageKey, CommandTypes: domain.CopyStringSlice(page.CommandTypes), SnapshotTypes: domain.CopyStringSlice(page.SnapshotTypes), QueryTemplateKeys: domain.CopyStringSlice(page.QueryTemplateKeys), FeatureKeys: domain.CopyStringSlice(page.FeatureKeys)}
}
features := make([]domain.GameClientBridgeFeatureDeclaration, len(body.Features))
for index, feature := range body.Features {
features[index] = domain.GameClientBridgeFeatureDeclaration{Key: feature.Key, Title: feature.Title, Permission: feature.Permission, RequiredHandlers: domain.CopyStringSlice(feature.RequiredHandlers), RequiredEventProducers: domain.CopyStringSlice(feature.RequiredEventProducers)}
}
companion := domain.GameClientBridgeCompanionDeclaration{}
if body.Companion != nil {
companion = domain.GameClientBridgeCompanionDeclaration{ProfileKey: body.Companion.ProfileKey, ConfigTemplateKey: body.Companion.ConfigTemplateKey, ConfigSchemaRef: body.Companion.ConfigSchemaRef, ConfigFormat: body.Companion.ConfigFormat, PlatformBaseURLSource: body.Companion.PlatformBaseURLSource, RegistrationProof: body.Companion.RegistrationProof, ProofMaterialSource: body.Companion.ProofMaterialSource, ProofMaterialEnv: body.Companion.ProofMaterialEnv, SessionMode: body.Companion.SessionMode, TLSPolicy: body.Companion.TLSPolicy, HeartbeatIntervalSeconds: body.Companion.HeartbeatIntervalSeconds, CommandPollIntervalSeconds: body.Companion.CommandPollIntervalSeconds, RequestTimeoutSeconds: body.Companion.RequestTimeoutSeconds}
}
return domain.GameClientBridgeManifest{Commands: commands, Snapshots: snapshots, QueryTemplates: queryTemplates, Retention: domain.GameClientBridgeRetention{KeepForSeconds: body.CommandRetentionSeconds, MaxRecords: body.MaxCommands}, Pages: pages, Companion: companion}
return domain.GameClientBridgeManifest{Commands: commands, Snapshots: snapshots, QueryTemplates: queryTemplates, Retention: domain.GameClientBridgeRetention{KeepForSeconds: body.CommandRetentionSeconds, MaxRecords: body.MaxCommands}, Pages: pages, Features: features, Companion: companion}
}
func (actions PluginLifecycleActionsBody) ToDomain() domain.PluginLifecycleActions {
@@ -1524,13 +1539,17 @@ func gameClientBridgeManifestFromDomain(value domain.GameClientBridgeManifest) G
}
pages := make([]GameClientBridgePageContractBody, len(value.Pages))
for index, page := range value.Pages {
pages[index] = GameClientBridgePageContractBody{PageKey: page.PageKey, CommandTypes: page.CommandTypes, SnapshotTypes: page.SnapshotTypes, QueryTemplateKeys: page.QueryTemplateKeys}
pages[index] = GameClientBridgePageContractBody{PageKey: page.PageKey, CommandTypes: page.CommandTypes, SnapshotTypes: page.SnapshotTypes, QueryTemplateKeys: page.QueryTemplateKeys, FeatureKeys: page.FeatureKeys}
}
features := make([]GameClientBridgeFeatureDeclarationBody, len(value.Features))
for index, feature := range value.Features {
features[index] = GameClientBridgeFeatureDeclarationBody{Key: feature.Key, Title: feature.Title, Permission: feature.Permission, RequiredHandlers: feature.RequiredHandlers, RequiredEventProducers: feature.RequiredEventProducers}
}
var companion *GameClientBridgeCompanionDeclarationBody
if value.Companion.ProfileKey != "" {
companion = &GameClientBridgeCompanionDeclarationBody{ProfileKey: value.Companion.ProfileKey, ConfigTemplateKey: value.Companion.ConfigTemplateKey, ConfigSchemaRef: value.Companion.ConfigSchemaRef, ConfigFormat: value.Companion.ConfigFormat, PlatformBaseURLSource: value.Companion.PlatformBaseURLSource, RegistrationProof: value.Companion.RegistrationProof, ProofMaterialSource: value.Companion.ProofMaterialSource, ProofMaterialEnv: value.Companion.ProofMaterialEnv, SessionMode: value.Companion.SessionMode, TLSPolicy: value.Companion.TLSPolicy, HeartbeatIntervalSeconds: value.Companion.HeartbeatIntervalSeconds, CommandPollIntervalSeconds: value.Companion.CommandPollIntervalSeconds, RequestTimeoutSeconds: value.Companion.RequestTimeoutSeconds}
}
return GameClientBridgeManifestBody{Commands: commands, Snapshots: snapshots, QueryTemplates: queryTemplates, CommandRetentionSeconds: value.Retention.KeepForSeconds, MaxCommands: value.Retention.MaxRecords, Pages: pages, Companion: companion}
return GameClientBridgeManifestBody{Commands: commands, Snapshots: snapshots, QueryTemplates: queryTemplates, CommandRetentionSeconds: value.Retention.KeepForSeconds, MaxCommands: value.Retention.MaxRecords, Pages: pages, Features: features, Companion: companion}
}
func MarketplacePluginListFromDomain(plugins []domain.PluginMarketplacePlugin) MarketplacePluginListResponse {
@@ -1886,6 +1905,7 @@ func pagesToDomain(pages []GamePluginPageBody) []domain.GamePluginPage {
Bundle: domain.PluginPageBundle{Key: page.BundleKey, Version: page.BundleVersion, IntegritySHA256: page.BundleIntegritySHA256},
Permissions: domain.CopyStringSlice(page.Permissions),
BridgeActions: domain.CopyStringSlice(page.BridgeActions),
FeatureKeys: domain.CopyStringSlice(page.FeatureKeys),
}
}
return out
@@ -1898,14 +1918,15 @@ func pagesFromDomain(pages []domain.GamePluginPage) []GamePluginPageBody {
out := make([]GamePluginPageBody, len(pages))
for i, page := range pages {
out[i] = GamePluginPageBody{
Key: page.Key,
Title: page.Title,
Path: page.Path,
BundleKey: page.Bundle.Key,
BundleVersion: page.Bundle.Version,
Key: page.Key,
Title: page.Title,
Path: page.Path,
BundleKey: page.Bundle.Key,
BundleVersion: page.Bundle.Version,
BundleIntegritySHA256: page.Bundle.IntegritySHA256,
Permissions: domain.CopyStringSlice(page.Permissions),
BridgeActions: domain.CopyStringSlice(page.BridgeActions),
Permissions: domain.CopyStringSlice(page.Permissions),
BridgeActions: domain.CopyStringSlice(page.BridgeActions),
FeatureKeys: domain.CopyStringSlice(page.FeatureKeys),
}
}
return out
+51 -1
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"reflect"
"sort"
"strings"
"time"
"browser.local/platform/domain"
@@ -55,7 +56,7 @@ func (svc *CoreService) GetGameClientBridgeStatusForSession(sessionID, serverIns
if err != nil {
return domain.GameClientBridgeStatus{}, err
}
status := domain.GameClientBridgeStatus{ServerInstanceID: instance.ID, PluginID: plugin.ID, Reason: "plugin does not declare a game client bridge profile", Profiles: []domain.GameClientBridgeProfileDeclaration{}}
status := domain.GameClientBridgeStatus{ServerInstanceID: instance.ID, PluginID: plugin.ID, Reason: "plugin does not declare a game client bridge profile", Profiles: []domain.GameClientBridgeProfileDeclaration{}, Features: []domain.GameClientBridgeFeatureAvailability{}}
installations, err := svc.store.ClientManagerInstallations().List(domain.ClientManagerInstallationFilter{ServerInstanceID: instance.ID})
if err != nil {
return domain.GameClientBridgeStatus{}, err
@@ -77,6 +78,8 @@ func (svc *CoreService) GetGameClientBridgeStatusForSession(sessionID, serverIns
if svc.now().Before(session.ExpiresAt) && containsString(session.Capabilities, gameClientBridgeCapability) && session.KeyGeneration == installation.KeyGeneration && session.DeploymentGeneration == installation.DeploymentGeneration && session.ArtifactID == installation.ActiveArtifactID {
declaration.Available = true
declaration.Reason = ""
declaration.HandlerTypes = append(declaration.HandlerTypes, gameClientBridgeSessionCapabilityValues(session.Capabilities, "handler.")...)
declaration.EventProducerTypes = append(declaration.EventProducerTypes, gameClientBridgeSessionCapabilityValues(session.Capabilities, "event-producer.")...)
break
}
}
@@ -90,9 +93,56 @@ func (svc *CoreService) GetGameClientBridgeStatusForSession(sessionID, serverIns
if status.Available {
status.Reason = ""
}
status.Features = gameClientBridgeFeatureAvailability(plugin.GameClientBridge.Features, status.Profiles)
return domain.CopyGameClientBridgeStatus(status), nil
}
func gameClientBridgeSessionCapabilityValues(capabilities []string, prefix string) []string {
values := make([]string, 0, len(capabilities))
for _, capability := range capabilities {
if value, found := strings.CutPrefix(capability, prefix); found && value != "" {
values = append(values, value)
}
}
sort.Strings(values)
return values
}
func gameClientBridgeFeatureAvailability(features []domain.GameClientBridgeFeatureDeclaration, profiles []domain.GameClientBridgeProfileDeclaration) []domain.GameClientBridgeFeatureAvailability {
result := make([]domain.GameClientBridgeFeatureAvailability, 0, len(features))
for _, feature := range features {
handlers, producers := map[string]bool{}, map[string]bool{}
for _, profile := range profiles {
if !profile.Available {
continue
}
for _, handler := range profile.HandlerTypes {
handlers[handler] = true
}
for _, producer := range profile.EventProducerTypes {
producers[producer] = true
}
}
missing := make([]string, 0)
for _, handler := range feature.RequiredHandlers {
if !handlers[handler] {
missing = append(missing, "handler."+handler)
}
}
for _, producer := range feature.RequiredEventProducers {
if !producers[producer] {
missing = append(missing, "event-producer."+producer)
}
}
availability := domain.GameClientBridgeFeatureAvailability{Key: feature.Key, Available: len(missing) == 0}
if len(missing) > 0 {
availability.Reason = "compatible Companion is missing " + strings.Join(missing, ", ")
}
result = append(result, availability)
}
return result
}
func (svc *CoreService) ListGameClientBridgeCommandsForSession(sessionID string, filter domain.GameClientBridgeCommandFilter) ([]domain.GameClientBridgeCommand, error) {
if err := svc.authorizeServerLifecycle(sessionID, filter.ServerInstanceID); err != nil {
return nil, err
+80 -6
View File
@@ -361,7 +361,7 @@ func ValidatePluginCreateInputs(fields []domain.PluginCreateField, inputs map[st
func validateGameClientBridgeManifest(field string, bridge domain.GameClientBridgeManifest, permissions []string, pages []domain.GamePluginPage, runtimeProfiles domain.GamePluginRuntimeProfiles) []string {
companionPresent := bridge.Companion != (domain.GameClientBridgeCompanionDeclaration{})
if len(bridge.Commands) == 0 && len(bridge.Snapshots) == 0 && len(bridge.QueryTemplates) == 0 && len(bridge.Pages) == 0 && bridge.Retention.KeepForSeconds == 0 && bridge.Retention.MaxRecords == 0 && !companionPresent {
if len(bridge.Commands) == 0 && len(bridge.Snapshots) == 0 && len(bridge.QueryTemplates) == 0 && len(bridge.Pages) == 0 && len(bridge.Features) == 0 && bridge.Retention.KeepForSeconds == 0 && bridge.Retention.MaxRecords == 0 && !companionPresent {
return nil
}
var violations []string
@@ -515,6 +515,50 @@ func validateGameClientBridgeManifest(field string, bridge domain.GameClientBrid
for _, page := range pages {
pageDeclarations[page.Key] = page
}
features := map[string]domain.GameClientBridgeFeatureDeclaration{}
for index, feature := range bridge.Features {
prefix := fmt.Sprintf("%s.features[%d]", field, index)
if !clientManagerIdentifierPattern.MatchString(feature.Key) || unsafeGameClientBridgeCommandType(feature.Key) {
violations = append(violations, prefix+".key is invalid or unsafe")
}
if _, exists := features[feature.Key]; exists {
violations = append(violations, prefix+".key is duplicated")
}
features[feature.Key] = feature
if strings.TrimSpace(feature.Title) == "" || len([]rune(feature.Title)) > 80 {
violations = append(violations, prefix+".title is invalid")
}
if !containsString(permissions, feature.Permission) {
violations = append(violations, prefix+".permission must be declared by the plugin")
}
if len(feature.RequiredHandlers) == 0 && len(feature.RequiredEventProducers) == 0 {
violations = append(violations, prefix+" must require a handler or event producer")
}
for _, handler := range feature.RequiredHandlers {
if !clientManagerIdentifierPattern.MatchString(handler) {
violations = append(violations, prefix+".requiredHandlers contains an invalid handler")
}
}
for _, producer := range feature.RequiredEventProducers {
if !clientManagerIdentifierPattern.MatchString(producer) {
violations = append(violations, prefix+".requiredEventProducers contains an invalid producer")
}
}
violations = append(violations, duplicateViolations(prefix+".requiredHandlers", feature.RequiredHandlers)...)
violations = append(violations, duplicateViolations(prefix+".requiredEventProducers", feature.RequiredEventProducers)...)
}
for _, page := range pages {
for _, featureKey := range page.FeatureKeys {
feature, exists := features[featureKey]
if !exists {
violations = append(violations, field+" page "+page.Key+" references undeclared feature "+featureKey)
continue
}
if !containsString(page.Permissions, feature.Permission) {
violations = append(violations, field+" page "+page.Key+" must declare feature permission "+feature.Permission)
}
}
}
for index, page := range bridge.Pages {
prefix := fmt.Sprintf("%s.pages[%d]", field, index)
pageDeclaration, pageExists := pageDeclarations[page.PageKey]
@@ -551,6 +595,16 @@ func validateGameClientBridgeManifest(field string, bridge domain.GameClientBrid
violations = append(violations, prefix+" must declare remote.access.request for query templates")
}
}
for _, featureKey := range page.FeatureKeys {
feature, exists := features[featureKey]
if !exists {
violations = append(violations, prefix+" references undeclared feature "+featureKey)
continue
}
if !containsString(pageDeclaration.Permissions, feature.Permission) {
violations = append(violations, prefix+" must declare feature permission "+feature.Permission)
}
}
}
return violations
}
@@ -1401,7 +1455,9 @@ func validatePluginPages(pages []domain.GamePluginPage) []string {
violations = appendRequired(violations, prefix+".bundle.key", page.Bundle.Key)
violations = appendRequired(violations, prefix+".bundle.version", page.Bundle.Version)
violations = appendRequired(violations, prefix+".bundle.integritySha256", page.Bundle.IntegritySHA256)
if !validDistributionLogicalKey(page.Bundle.Key) || !validPluginPageBundleVersion(page.Bundle.Version) || !validPluginPageBundleIntegrity(page.Bundle.IntegritySHA256) { violations = append(violations, prefix+".bundle is invalid") }
if !validDistributionLogicalKey(page.Bundle.Key) || !validPluginPageBundleVersion(page.Bundle.Version) || !validPluginPageBundleIntegrity(page.Bundle.IntegritySHA256) {
violations = append(violations, prefix+".bundle is invalid")
}
}
if page.Key != "" {
if _, exists := seenKeys[page.Key]; exists {
@@ -1416,19 +1472,37 @@ func validatePluginPages(pages []domain.GamePluginPage) []string {
}
violations = append(violations, duplicateViolations(prefix+".permissions", page.Permissions)...)
violations = append(violations, validateBridgeActions(prefix+".bridgeActions", page.BridgeActions)...)
for _, key := range page.FeatureKeys {
if !clientManagerIdentifierPattern.MatchString(key) {
violations = append(violations, prefix+".featureKeys contains an invalid feature key")
}
}
violations = append(violations, duplicateViolations(prefix+".featureKeys", page.FeatureKeys)...)
}
return violations
}
func validPluginPageBundleVersion(value string) bool {
if len(value) == 0 || len(value) > 80 { return false }
for _, item := range value { if !(item >= 'a' && item <= 'z' || item >= 'A' && item <= 'Z' || item >= '0' && item <= '9' || item == '.' || item == '_' || item == '-') { return false } }
if len(value) == 0 || len(value) > 80 {
return false
}
for _, item := range value {
if !(item >= 'a' && item <= 'z' || item >= 'A' && item <= 'Z' || item >= '0' && item <= '9' || item == '.' || item == '_' || item == '-') {
return false
}
}
return true
}
func validPluginPageBundleIntegrity(value string) bool {
if len(value) != len("sha256:")+64 || !strings.HasPrefix(value, "sha256:") { return false }
for _, item := range value[len("sha256:"):] { if !(item >= 'a' && item <= 'f' || item >= '0' && item <= '9') { return false } }
if len(value) != len("sha256:")+64 || !strings.HasPrefix(value, "sha256:") {
return false
}
for _, item := range value[len("sha256:"):] {
if !(item >= 'a' && item <= 'f' || item >= '0' && item <= '9') {
return false
}
}
return true
}
+10
View File
@@ -60,8 +60,11 @@ export interface GameClientBridgePageContractResponse {
commandTypes?: string[];
snapshotTypes?: string[];
queryTemplateKeys?: string[];
featureKeys?: string[];
}
export interface GameClientBridgeFeatureDeclarationResponse { key: string; title: string; permission: string; requiredHandlers?: string[]; requiredEventProducers?: string[]; }
export interface GameClientBridgeCompanionDeclarationResponse {
profileKey: string;
configTemplateKey: string;
@@ -85,6 +88,7 @@ export interface GameClientBridgeManifestResponse {
commandRetentionSeconds: number;
maxCommands: number;
pages?: GameClientBridgePageContractResponse[];
features?: GameClientBridgeFeatureDeclarationResponse[];
companion?: GameClientBridgeCompanionDeclarationResponse;
}
@@ -96,14 +100,19 @@ export interface GameClientBridgeProfileDeclarationResponse {
commandTypes: string[];
snapshotTypes: string[];
queryTemplateKeys: string[];
handlerTypes?: string[];
eventProducerTypes?: string[];
}
export interface GameClientBridgeFeatureAvailabilityResponse { key: string; available: boolean; reason?: string; }
export interface GameClientBridgeStatusResponse {
serverInstanceId: string;
pluginId: string;
available: boolean;
reason?: string;
profiles: GameClientBridgeProfileDeclarationResponse[];
features?: GameClientBridgeFeatureAvailabilityResponse[];
}
export interface GameClientBridgeCommandResultResponse {
@@ -224,6 +233,7 @@ export interface GamePluginPageResponse {
bundleIntegritySha256?: string;
permissions: string[];
bridgeActions?: string[];
featureKeys?: string[];
}
export interface RuntimeDiscoveryProbeResponse {
+3 -1
View File
@@ -83,6 +83,7 @@ export interface PluginPageContract {
bundleIntegritySha256?: string;
permissions?: PluginPermission[];
bridgeActions?: PluginBridgeAction[];
featureKeys?: string[];
}
export interface PluginBridgeManifestContract {
@@ -108,7 +109,8 @@ export function pluginBridgeManifestContractFromResponse(
bundleVersion: page.bundleVersion,
bundleIntegritySha256: page.bundleIntegritySha256,
permissions: page.permissions.filter(isPluginPermission),
bridgeActions: page.bridgeActions?.filter(isPluginBridgeAction)
bridgeActions: page.bridgeActions?.filter(isPluginBridgeAction),
featureKeys: page.featureKeys ? [...page.featureKeys] : []
})),
aiPurposes: [...plugin.aiPurposes]
};
+5 -1
View File
@@ -60,7 +60,11 @@ export function PluginPageHostPage({ params, onNavigate, initialPlugin }: Plugin
setBundle(null); setBundleError("");
void loadPluginPageBundle(declaredBundlePage).then((loaded) => { if (active) setBundle(() => loaded); }).catch((error) => { if (active) setBundleError(error instanceof Error ? error.message : "插件页面 bundle 加载失败。"); });
if (!serverId) { setAvailability({ available: false, reason: "插件页面没有绑定服务器。" }); return () => { active = false; }; }
void platformApiClient.getGameClientBridgeStatus(serverId).then((status) => { if (active) setAvailability({ available: status.available, reason: status.reason }); }).catch((error) => { if (active) setAvailability({ available: false, reason: error instanceof Error ? error.message : "无法验证 Companion 可用性。" }); });
void platformApiClient.getGameClientBridgeStatus(serverId).then((status) => {
const requiredFeatures = declaredBundlePage.featureKeys ?? [];
const unavailable = requiredFeatures.map((key) => status.features?.find((feature) => feature.key === key)).find((feature) => !feature?.available);
if (active) setAvailability(unavailable ? { available: false, reason: unavailable.reason || `功能 ${unavailable.key} 没有兼容的 Companion 实现。` } : { available: status.available, reason: status.reason });
}).catch((error) => { if (active) setAvailability({ available: false, reason: error instanceof Error ? error.message : "无法验证 Companion 可用性。" }); });
return () => { active = false; };
}, [declaredBundlePage, serverId]);
+10 -2
View File
@@ -69,7 +69,8 @@ export function parseSafeGameClientBridgeStatus(value: unknown): GameClientBridg
pluginId: string(record.pluginId, "pluginId"),
available: boolean(record.available, "available"),
reason: optionalString(record.reason, "reason"),
profiles: array(record.profiles, "profiles").map(parseProfile)
profiles: array(record.profiles, "profiles").map(parseProfile),
features: array(record.features ?? [], "features").map(parseFeature)
};
}
@@ -136,10 +137,17 @@ function parseProfile(value: unknown): GameClientBridgeProfileDeclarationRespons
reason: optionalString(record.reason, "profile.reason"),
commandTypes: stringArray(record.commandTypes, "profile.commandTypes"),
snapshotTypes: stringArray(record.snapshotTypes, "profile.snapshotTypes"),
queryTemplateKeys: stringArray(record.queryTemplateKeys, "profile.queryTemplateKeys")
queryTemplateKeys: stringArray(record.queryTemplateKeys, "profile.queryTemplateKeys"),
handlerTypes: stringArray(record.handlerTypes ?? [], "profile.handlerTypes"),
eventProducerTypes: stringArray(record.eventProducerTypes ?? [], "profile.eventProducerTypes")
};
}
function parseFeature(value: unknown): { key: string; available: boolean; reason?: string } {
const record = safeObject(value, "Game Client Bridge feature");
return { key: string(record.key, "feature.key"), available: boolean(record.available, "feature.available"), reason: optionalString(record.reason, "feature.reason") };
}
function parseResult(value: unknown): GameClientBridgeCommandResultResponse {
const record = safeObject(value, "Game Client Bridge command result");
const result: GameClientBridgeCommandResultResponse = {
@@ -242,6 +242,13 @@
],
"commandRetentionSeconds": 604800,
"maxCommands": 1000,
"features": [
{ "key": "config.manage", "title": "SCUM configuration", "permission": "server.game-client.read", "requiredHandlers": ["config.read", "config.patch"] },
{ "key": "player.intelligence", "title": "SCUM player intelligence", "permission": "server.game-client.read", "requiredHandlers": ["player.lookup"], "requiredEventProducers": ["semantic.events"] },
{ "key": "reward.delivery", "title": "SCUM reward delivery", "permission": "server.game-client.command", "requiredHandlers": ["reward.deliver", "player.notify"] },
{ "key": "state.patch", "title": "SCUM player state patch", "permission": "server.game-client.maintenance", "requiredHandlers": ["game-state.patch"] },
{ "key": "trajectory.collect", "title": "SCUM trajectories", "permission": "server.game-client.read", "requiredEventProducers": ["semantic.events"] }
],
"pages": [
{
"pageKey": "files-config",
@@ -254,7 +261,8 @@
"restart.prepare",
"maintenance.prepare"
],
"snapshotTypes": ["companion.health", "online.sessions", "players", "squads", "vehicles", "flags"]
"snapshotTypes": ["companion.health", "online.sessions", "players", "squads", "vehicles", "flags"],
"featureKeys": ["config.manage", "player.intelligence", "reward.delivery", "state.patch", "trajectory.collect"]
}
],
"companion": {
@@ -303,7 +311,7 @@
"dependencyPolicy": "required",
"approvalRequired": ["disable", "rollback", "retire"]
},
"pages": [{ "key": "files-config", "title": "文件、配置与玩家档案", "path": "/files-config", "bundleKey": "scum-server-plugin", "bundleVersion": "1.0.0", "bundleIntegritySha256": "sha256:8a4216107e1d7773d42a7e13b6466fd4fcf6e6bb2dc5f5af398fb7f4ea4f623b", "permissions": ["server.read", "server.files.read", "server.files.write", "server.logs.read", "server.game-client.read", "ai.invoke"], "bridgeActions": ["server.instances.read", "files.request", "logs.query", "ai.invoke"] }],
"pages": [{ "key": "files-config", "title": "文件、配置与玩家档案", "path": "/files-config", "bundleKey": "scum-server-plugin", "bundleVersion": "1.0.0", "bundleIntegritySha256": "sha256:8a4216107e1d7773d42a7e13b6466fd4fcf6e6bb2dc5f5af398fb7f4ea4f623b", "permissions": ["server.read", "server.files.read", "server.files.write", "server.logs.read", "server.game-client.read", "server.game-client.command", "server.game-client.maintenance", "ai.invoke"], "bridgeActions": ["server.instances.read", "files.request", "logs.query", "ai.invoke"], "featureKeys": ["config.manage", "player.intelligence", "reward.delivery", "state.patch", "trajectory.collect"] }],
"fileWorkspace": {
"defaultDirectoryKey": "scum-config",
"directories": [{ "key": "scum-config", "label": "服务器配置", "scope": "config" }, { "key": "scum-logs", "label": "日志文件", "scope": "logs" }],
@@ -190,7 +190,8 @@
"bundleVersion": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$" },
"bundleIntegritySha256": { "type": "string", "pattern": "^sha256:[a-f0-9]{64}$" },
"permissions": { "type": "array", "items": { "$ref": "#/$defs/pluginPermission" }, "uniqueItems": true },
"bridgeActions": { "type": "array", "items": { "$ref": "#/$defs/bridgeAction" }, "uniqueItems": true }
"bridgeActions": { "type": "array", "items": { "$ref": "#/$defs/bridgeAction" }, "uniqueItems": true },
"featureKeys": { "type": "array", "items": { "type": "string" }, "uniqueItems": true }
}
}
},
@@ -262,6 +263,7 @@
"items": { "$ref": "#/$defs/gameClientBridgePageContract" },
"maxItems": 64
},
"features": { "type": "array", "items": { "$ref": "#/$defs/gameClientBridgeFeature" }, "maxItems": 128 },
"companion": { "$ref": "#/$defs/gameClientBridgeCompanion" }
}
},
@@ -337,7 +339,15 @@
"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 }
"queryTemplateKeys": { "type": "array", "items": { "type": "string" }, "uniqueItems": true },
"featureKeys": { "type": "array", "items": { "type": "string" }, "uniqueItems": true }
}
},
"gameClientBridgeFeature": {
"type": "object", "required": ["key", "title", "permission"], "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" },
"requiredHandlers": { "type": "array", "items": { "type": "string" }, "uniqueItems": true, "maxItems": 64 }, "requiredEventProducers": { "type": "array", "items": { "type": "string" }, "uniqueItems": true, "maxItems": 64 }
}
},
"productionLifecycleOperation": {
+9
View File
@@ -250,8 +250,11 @@ export interface GameClientBridgePageContract {
commandTypes?: string[];
snapshotTypes?: string[];
queryTemplateKeys?: string[];
featureKeys?: string[];
}
export interface GameClientBridgeFeatureDeclaration { key: string; title: string; permission: PluginPermission; requiredHandlers?: string[]; requiredEventProducers?: string[]; }
export interface GameClientBridgeCompanionDeclaration {
profileKey: string;
configTemplateKey: string;
@@ -275,6 +278,7 @@ export interface GameClientBridgeManifest {
commandRetentionSeconds: number;
maxCommands: number;
pages?: GameClientBridgePageContract[];
features?: GameClientBridgeFeatureDeclaration[];
companion?: GameClientBridgeCompanionDeclaration;
}
@@ -286,14 +290,19 @@ export interface GameClientBridgeProfileStatus {
commandTypes: string[];
snapshotTypes: string[];
queryTemplateKeys: string[];
handlerTypes?: string[];
eventProducerTypes?: string[];
}
export interface GameClientBridgeFeatureAvailability { key: string; available: boolean; reason?: string; }
export interface GameClientBridgeStatus {
serverInstanceId: string;
pluginId: string;
available: boolean;
reason?: string;
profiles: GameClientBridgeProfileStatus[];
features?: GameClientBridgeFeatureAvailability[];
}
export interface GameClientBridgeCommandResult {