diff --git a/openspec/changes/move-scum-feature-ownership-to-plugin/design.md b/openspec/changes/move-scum-feature-ownership-to-plugin/design.md index ba054c6..661c374 100644 --- a/openspec/changes/move-scum-feature-ownership-to-plugin/design.md +++ b/openspec/changes/move-scum-feature-ownership-to-plugin/design.md @@ -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 ` 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. diff --git a/openspec/changes/move-scum-feature-ownership-to-plugin/specs/scum-companion-runtime-adapter/spec.md b/openspec/changes/move-scum-feature-ownership-to-plugin/specs/scum-companion-runtime-adapter/spec.md index cd4c79c..c611494 100644 --- a/openspec/changes/move-scum-feature-ownership-to-plugin/specs/scum-companion-runtime-adapter/spec.md +++ b/openspec/changes/move-scum-feature-ownership-to-plugin/specs/scum-companion-runtime-adapter/spec.md @@ -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 diff --git a/openspec/changes/move-scum-feature-ownership-to-plugin/tasks.md b/openspec/changes/move-scum-feature-ownership-to-plugin/tasks.md index 3b737ec..2c98293 100644 --- a/openspec/changes/move-scum-feature-ownership-to-plugin/tasks.md +++ b/openspec/changes/move-scum-feature-ownership-to-plugin/tasks.md @@ -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 diff --git a/platform/domain/game_client_bridge.go b/platform/domain/game_client_bridge.go index 5d5c402..c6ed841 100644 --- a/platform/domain/game_client_bridge.go +++ b/platform/domain/game_client_bridge.go @@ -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 } diff --git a/platform/domain/resources.go b/platform/domain/resources.go index b7f4517..fdd5133 100644 --- a/platform/domain/resources.go +++ b/platform/domain/resources.go @@ -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 } diff --git a/platform/dto/game_client_bridge.go b/platform/dto/game_client_bridge.go index 0bcb197..0c1ba23 100644 --- a/platform/dto/game_client_bridge.go +++ b/platform/dto/game_client_bridge.go @@ -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 { diff --git a/platform/dto/resources.go b/platform/dto/resources.go index ac16bc5..7b1840c 100644 --- a/platform/dto/resources.go +++ b/platform/dto/resources.go @@ -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 diff --git a/platform/service/game_client_bridge.go b/platform/service/game_client_bridge.go index d923301..ab372ad 100644 --- a/platform/service/game_client_bridge.go +++ b/platform/service/game_client_bridge.go @@ -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 diff --git a/platform/validator/resources.go b/platform/validator/resources.go index 3982f25..98a735b 100644 --- a/platform/validator/resources.go +++ b/platform/validator/resources.go @@ -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 } diff --git a/platform_web/api/types.ts b/platform_web/api/types.ts index d9c8b2f..519adc0 100644 --- a/platform_web/api/types.ts +++ b/platform_web/api/types.ts @@ -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 { diff --git a/platform_web/contracts/pluginBridge.ts b/platform_web/contracts/pluginBridge.ts index c0d724f..352dc60 100644 --- a/platform_web/contracts/pluginBridge.ts +++ b/platform_web/contracts/pluginBridge.ts @@ -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] }; diff --git a/platform_web/pages/PluginPageHostPage.tsx b/platform_web/pages/PluginPageHostPage.tsx index 7823906..c47252e 100644 --- a/platform_web/pages/PluginPageHostPage.tsx +++ b/platform_web/pages/PluginPageHostPage.tsx @@ -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]); diff --git a/platform_web/schemas/gameClientBridge.ts b/platform_web/schemas/gameClientBridge.ts index d963f37..fbdd10e 100644 --- a/platform_web/schemas/gameClientBridge.ts +++ b/platform_web/schemas/gameClientBridge.ts @@ -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 = { diff --git a/plugins/examples/scum-server-plugin/manifest.json b/plugins/examples/scum-server-plugin/manifest.json index 9902abb..2d7379b 100644 --- a/plugins/examples/scum-server-plugin/manifest.json +++ b/plugins/examples/scum-server-plugin/manifest.json @@ -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" }], diff --git a/plugins/manifests/game-plugin.manifest.schema.json b/plugins/manifests/game-plugin.manifest.schema.json index afdc048..b5c6a72 100644 --- a/plugins/manifests/game-plugin.manifest.schema.json +++ b/plugins/manifests/game-plugin.manifest.schema.json @@ -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": { diff --git a/plugins/sdk/index.ts b/plugins/sdk/index.ts index e3e40b4..d114158 100644 --- a/plugins/sdk/index.ts +++ b/plugins/sdk/index.ts @@ -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 {