feat(plugin): gate pages on companion features
This commit is contained in:
@@ -36,7 +36,7 @@ The alternative of metadata-only generic forms is insufficient for the map, play
|
|||||||
|
|
||||||
### 3. Long-running SCUM Companion adapter
|
### 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.
|
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.
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -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
|
- **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
|
### 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
|
#### Scenario: Valid login event
|
||||||
- **WHEN** a supported SCUM source produces a successful-login record containing the declared player identity and timestamp fields
|
- **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
|
## 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.
|
- [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.
|
- [x] 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.
|
- [x] 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.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
|
## 2. Build the SCUM plugin module and page bundle
|
||||||
|
|
||||||
|
|||||||
@@ -66,6 +66,15 @@ type GameClientBridgePageContract struct {
|
|||||||
CommandTypes []string
|
CommandTypes []string
|
||||||
SnapshotTypes []string
|
SnapshotTypes []string
|
||||||
QueryTemplateKeys []string
|
QueryTemplateKeys []string
|
||||||
|
FeatureKeys []string
|
||||||
|
}
|
||||||
|
|
||||||
|
type GameClientBridgeFeatureDeclaration struct {
|
||||||
|
Key string
|
||||||
|
Title string
|
||||||
|
Permission string
|
||||||
|
RequiredHandlers []string
|
||||||
|
RequiredEventProducers []string
|
||||||
}
|
}
|
||||||
|
|
||||||
type GameClientBridgeCompanionDeclaration struct {
|
type GameClientBridgeCompanionDeclaration struct {
|
||||||
@@ -90,6 +99,7 @@ type GameClientBridgeManifest struct {
|
|||||||
QueryTemplates []GameClientBridgeQueryTemplateDeclaration
|
QueryTemplates []GameClientBridgeQueryTemplateDeclaration
|
||||||
Retention GameClientBridgeRetention
|
Retention GameClientBridgeRetention
|
||||||
Pages []GameClientBridgePageContract
|
Pages []GameClientBridgePageContract
|
||||||
|
Features []GameClientBridgeFeatureDeclaration
|
||||||
Companion GameClientBridgeCompanionDeclaration
|
Companion GameClientBridgeCompanionDeclaration
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -278,13 +288,21 @@ type GameClientBridgeSnapshotQuery struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type GameClientBridgeProfileDeclaration struct {
|
type GameClientBridgeProfileDeclaration struct {
|
||||||
PluginID string
|
PluginID string
|
||||||
ProfileKey string
|
ProfileKey string
|
||||||
Available bool
|
Available bool
|
||||||
Reason string
|
Reason string
|
||||||
CommandTypes []string
|
CommandTypes []string
|
||||||
SnapshotTypes []string
|
SnapshotTypes []string
|
||||||
QueryTemplateKeys []string
|
QueryTemplateKeys []string
|
||||||
|
HandlerTypes []string
|
||||||
|
EventProducerTypes []string
|
||||||
|
}
|
||||||
|
|
||||||
|
type GameClientBridgeFeatureAvailability struct {
|
||||||
|
Key string
|
||||||
|
Available bool
|
||||||
|
Reason string
|
||||||
}
|
}
|
||||||
|
|
||||||
type GameClientBridgeStatus struct {
|
type GameClientBridgeStatus struct {
|
||||||
@@ -293,12 +311,15 @@ type GameClientBridgeStatus struct {
|
|||||||
Available bool
|
Available bool
|
||||||
Reason string
|
Reason string
|
||||||
Profiles []GameClientBridgeProfileDeclaration
|
Profiles []GameClientBridgeProfileDeclaration
|
||||||
|
Features []GameClientBridgeFeatureAvailability
|
||||||
}
|
}
|
||||||
|
|
||||||
func CopyGameClientBridgeProfileDeclaration(value GameClientBridgeProfileDeclaration) GameClientBridgeProfileDeclaration {
|
func CopyGameClientBridgeProfileDeclaration(value GameClientBridgeProfileDeclaration) GameClientBridgeProfileDeclaration {
|
||||||
value.CommandTypes = CopyStringSlice(value.CommandTypes)
|
value.CommandTypes = CopyStringSlice(value.CommandTypes)
|
||||||
value.SnapshotTypes = CopyStringSlice(value.SnapshotTypes)
|
value.SnapshotTypes = CopyStringSlice(value.SnapshotTypes)
|
||||||
value.QueryTemplateKeys = CopyStringSlice(value.QueryTemplateKeys)
|
value.QueryTemplateKeys = CopyStringSlice(value.QueryTemplateKeys)
|
||||||
|
value.HandlerTypes = CopyStringSlice(value.HandlerTypes)
|
||||||
|
value.EventProducerTypes = CopyStringSlice(value.EventProducerTypes)
|
||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -307,6 +328,7 @@ func CopyGameClientBridgeStatus(value GameClientBridgeStatus) GameClientBridgeSt
|
|||||||
for index := range value.Profiles {
|
for index := range value.Profiles {
|
||||||
value.Profiles[index] = CopyGameClientBridgeProfileDeclaration(value.Profiles[index])
|
value.Profiles[index] = CopyGameClientBridgeProfileDeclaration(value.Profiles[index])
|
||||||
}
|
}
|
||||||
|
value.Features = append([]GameClientBridgeFeatureAvailability(nil), value.Features...)
|
||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -365,10 +387,16 @@ func CopyGameClientBridgeManifest(value GameClientBridgeManifest) GameClientBrid
|
|||||||
value.Snapshots = append([]GameClientBridgeSnapshotDeclaration(nil), value.Snapshots...)
|
value.Snapshots = append([]GameClientBridgeSnapshotDeclaration(nil), value.Snapshots...)
|
||||||
value.QueryTemplates = append([]GameClientBridgeQueryTemplateDeclaration(nil), value.QueryTemplates...)
|
value.QueryTemplates = append([]GameClientBridgeQueryTemplateDeclaration(nil), value.QueryTemplates...)
|
||||||
value.Pages = append([]GameClientBridgePageContract(nil), value.Pages...)
|
value.Pages = append([]GameClientBridgePageContract(nil), value.Pages...)
|
||||||
|
value.Features = append([]GameClientBridgeFeatureDeclaration(nil), value.Features...)
|
||||||
for index := range value.Pages {
|
for index := range value.Pages {
|
||||||
value.Pages[index].CommandTypes = CopyStringSlice(value.Pages[index].CommandTypes)
|
value.Pages[index].CommandTypes = CopyStringSlice(value.Pages[index].CommandTypes)
|
||||||
value.Pages[index].SnapshotTypes = CopyStringSlice(value.Pages[index].SnapshotTypes)
|
value.Pages[index].SnapshotTypes = CopyStringSlice(value.Pages[index].SnapshotTypes)
|
||||||
value.Pages[index].QueryTemplateKeys = CopyStringSlice(value.Pages[index].QueryTemplateKeys)
|
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
|
return value
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -312,9 +312,10 @@ type GamePluginPage struct {
|
|||||||
Key string
|
Key string
|
||||||
Title string
|
Title string
|
||||||
Path string
|
Path string
|
||||||
Bundle PluginPageBundle
|
Bundle PluginPageBundle
|
||||||
Permissions []string
|
Permissions []string
|
||||||
BridgeActions []string
|
BridgeActions []string
|
||||||
|
FeatureKeys []string
|
||||||
}
|
}
|
||||||
|
|
||||||
// PluginPageBundle identifies an installed plugin-owned page module. The host
|
// PluginPageBundle identifies an installed plugin-owned page module. The host
|
||||||
@@ -1806,6 +1807,7 @@ func CopyGamePluginPageSlice(pages []GamePluginPage) []GamePluginPage {
|
|||||||
out[i] = page
|
out[i] = page
|
||||||
out[i].Permissions = CopyStringSlice(page.Permissions)
|
out[i].Permissions = CopyStringSlice(page.Permissions)
|
||||||
out[i].BridgeActions = CopyStringSlice(page.BridgeActions)
|
out[i].BridgeActions = CopyStringSlice(page.BridgeActions)
|
||||||
|
out[i].FeatureKeys = CopyStringSlice(page.FeatureKeys)
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -194,21 +194,30 @@ type GameClientBridgeAuditReferenceListResponse struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type GameClientBridgeProfileDeclarationResponse struct {
|
type GameClientBridgeProfileDeclarationResponse struct {
|
||||||
PluginID string `json:"pluginId"`
|
PluginID string `json:"pluginId"`
|
||||||
ProfileKey string `json:"profileKey"`
|
ProfileKey string `json:"profileKey"`
|
||||||
Available bool `json:"available"`
|
Available bool `json:"available"`
|
||||||
Reason string `json:"reason,omitempty"`
|
Reason string `json:"reason,omitempty"`
|
||||||
CommandTypes []string `json:"commandTypes"`
|
CommandTypes []string `json:"commandTypes"`
|
||||||
SnapshotTypes []string `json:"snapshotTypes"`
|
SnapshotTypes []string `json:"snapshotTypes"`
|
||||||
QueryTemplateKeys []string `json:"queryTemplateKeys"`
|
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 {
|
type GameClientBridgeStatusResponse struct {
|
||||||
ServerInstanceID string `json:"serverInstanceId"`
|
ServerInstanceID string `json:"serverInstanceId"`
|
||||||
PluginID string `json:"pluginId"`
|
PluginID string `json:"pluginId"`
|
||||||
Available bool `json:"available"`
|
Available bool `json:"available"`
|
||||||
Reason string `json:"reason,omitempty"`
|
Reason string `json:"reason,omitempty"`
|
||||||
Profiles []GameClientBridgeProfileDeclarationResponse `json:"profiles"`
|
Profiles []GameClientBridgeProfileDeclarationResponse `json:"profiles"`
|
||||||
|
Features []GameClientBridgeFeatureAvailabilityResponse `json:"features"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (request GameClientBridgeQueueRequest) ToDomain(serverID, pluginID string) domain.GameClientBridgeQueueRequest {
|
func (request GameClientBridgeQueueRequest) ToDomain(serverID, pluginID string) domain.GameClientBridgeQueueRequest {
|
||||||
@@ -356,9 +365,13 @@ func GameClientBridgeStatusFromDomain(value domain.GameClientBridgeStatus) GameC
|
|||||||
value = domain.CopyGameClientBridgeStatus(value)
|
value = domain.CopyGameClientBridgeStatus(value)
|
||||||
profiles := make([]GameClientBridgeProfileDeclarationResponse, len(value.Profiles))
|
profiles := make([]GameClientBridgeProfileDeclarationResponse, len(value.Profiles))
|
||||||
for index, profile := range 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 {
|
func gameClientBridgeCommandResultFromDomain(value domain.GameClientBridgeResult) GameClientBridgeCommandResultResponse {
|
||||||
|
|||||||
+40
-19
@@ -176,14 +176,15 @@ type PluginLifecycleActionsBody struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type GamePluginPageBody struct {
|
type GamePluginPageBody struct {
|
||||||
Key string `json:"key"`
|
Key string `json:"key"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Path string `json:"path"`
|
Path string `json:"path"`
|
||||||
BundleKey string `json:"bundleKey"`
|
BundleKey string `json:"bundleKey"`
|
||||||
BundleVersion string `json:"bundleVersion"`
|
BundleVersion string `json:"bundleVersion"`
|
||||||
BundleIntegritySHA256 string `json:"bundleIntegritySha256"`
|
BundleIntegritySHA256 string `json:"bundleIntegritySha256"`
|
||||||
Permissions []string `json:"permissions,omitempty"`
|
Permissions []string `json:"permissions,omitempty"`
|
||||||
BridgeActions []string `json:"bridgeActions,omitempty"`
|
BridgeActions []string `json:"bridgeActions,omitempty"`
|
||||||
|
FeatureKeys []string `json:"featureKeys,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type PluginLogicalDirectoryBody struct {
|
type PluginLogicalDirectoryBody struct {
|
||||||
@@ -297,6 +298,15 @@ type GameClientBridgePageContractBody struct {
|
|||||||
CommandTypes []string `json:"commandTypes,omitempty"`
|
CommandTypes []string `json:"commandTypes,omitempty"`
|
||||||
SnapshotTypes []string `json:"snapshotTypes,omitempty"`
|
SnapshotTypes []string `json:"snapshotTypes,omitempty"`
|
||||||
QueryTemplateKeys []string `json:"queryTemplateKeys,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 {
|
type GameClientBridgeCompanionDeclarationBody struct {
|
||||||
@@ -322,6 +332,7 @@ type GameClientBridgeManifestBody struct {
|
|||||||
CommandRetentionSeconds int `json:"commandRetentionSeconds"`
|
CommandRetentionSeconds int `json:"commandRetentionSeconds"`
|
||||||
MaxCommands int `json:"maxCommands"`
|
MaxCommands int `json:"maxCommands"`
|
||||||
Pages []GameClientBridgePageContractBody `json:"pages,omitempty"`
|
Pages []GameClientBridgePageContractBody `json:"pages,omitempty"`
|
||||||
|
Features []GameClientBridgeFeatureDeclarationBody `json:"features,omitempty"`
|
||||||
Companion *GameClientBridgeCompanionDeclarationBody `json:"companion,omitempty"`
|
Companion *GameClientBridgeCompanionDeclarationBody `json:"companion,omitempty"`
|
||||||
}
|
}
|
||||||
type GameMapTrajectoryDeclarationBody struct {
|
type GameMapTrajectoryDeclarationBody struct {
|
||||||
@@ -1111,13 +1122,17 @@ func (body GameClientBridgeManifestBody) ToDomain() domain.GameClientBridgeManif
|
|||||||
}
|
}
|
||||||
pages := make([]domain.GameClientBridgePageContract, len(body.Pages))
|
pages := make([]domain.GameClientBridgePageContract, len(body.Pages))
|
||||||
for index, page := range 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{}
|
companion := domain.GameClientBridgeCompanionDeclaration{}
|
||||||
if body.Companion != nil {
|
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}
|
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 {
|
func (actions PluginLifecycleActionsBody) ToDomain() domain.PluginLifecycleActions {
|
||||||
@@ -1524,13 +1539,17 @@ func gameClientBridgeManifestFromDomain(value domain.GameClientBridgeManifest) G
|
|||||||
}
|
}
|
||||||
pages := make([]GameClientBridgePageContractBody, len(value.Pages))
|
pages := make([]GameClientBridgePageContractBody, len(value.Pages))
|
||||||
for index, page := range 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
|
var companion *GameClientBridgeCompanionDeclarationBody
|
||||||
if value.Companion.ProfileKey != "" {
|
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}
|
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 {
|
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},
|
Bundle: domain.PluginPageBundle{Key: page.BundleKey, Version: page.BundleVersion, IntegritySHA256: page.BundleIntegritySHA256},
|
||||||
Permissions: domain.CopyStringSlice(page.Permissions),
|
Permissions: domain.CopyStringSlice(page.Permissions),
|
||||||
BridgeActions: domain.CopyStringSlice(page.BridgeActions),
|
BridgeActions: domain.CopyStringSlice(page.BridgeActions),
|
||||||
|
FeatureKeys: domain.CopyStringSlice(page.FeatureKeys),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
@@ -1898,14 +1918,15 @@ func pagesFromDomain(pages []domain.GamePluginPage) []GamePluginPageBody {
|
|||||||
out := make([]GamePluginPageBody, len(pages))
|
out := make([]GamePluginPageBody, len(pages))
|
||||||
for i, page := range pages {
|
for i, page := range pages {
|
||||||
out[i] = GamePluginPageBody{
|
out[i] = GamePluginPageBody{
|
||||||
Key: page.Key,
|
Key: page.Key,
|
||||||
Title: page.Title,
|
Title: page.Title,
|
||||||
Path: page.Path,
|
Path: page.Path,
|
||||||
BundleKey: page.Bundle.Key,
|
BundleKey: page.Bundle.Key,
|
||||||
BundleVersion: page.Bundle.Version,
|
BundleVersion: page.Bundle.Version,
|
||||||
BundleIntegritySHA256: page.Bundle.IntegritySHA256,
|
BundleIntegritySHA256: page.Bundle.IntegritySHA256,
|
||||||
Permissions: domain.CopyStringSlice(page.Permissions),
|
Permissions: domain.CopyStringSlice(page.Permissions),
|
||||||
BridgeActions: domain.CopyStringSlice(page.BridgeActions),
|
BridgeActions: domain.CopyStringSlice(page.BridgeActions),
|
||||||
|
FeatureKeys: domain.CopyStringSlice(page.FeatureKeys),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"reflect"
|
"reflect"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"browser.local/platform/domain"
|
"browser.local/platform/domain"
|
||||||
@@ -55,7 +56,7 @@ func (svc *CoreService) GetGameClientBridgeStatusForSession(sessionID, serverIns
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return domain.GameClientBridgeStatus{}, err
|
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})
|
installations, err := svc.store.ClientManagerInstallations().List(domain.ClientManagerInstallationFilter{ServerInstanceID: instance.ID})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return domain.GameClientBridgeStatus{}, err
|
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 {
|
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.Available = true
|
||||||
declaration.Reason = ""
|
declaration.Reason = ""
|
||||||
|
declaration.HandlerTypes = append(declaration.HandlerTypes, gameClientBridgeSessionCapabilityValues(session.Capabilities, "handler.")...)
|
||||||
|
declaration.EventProducerTypes = append(declaration.EventProducerTypes, gameClientBridgeSessionCapabilityValues(session.Capabilities, "event-producer.")...)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -90,9 +93,56 @@ func (svc *CoreService) GetGameClientBridgeStatusForSession(sessionID, serverIns
|
|||||||
if status.Available {
|
if status.Available {
|
||||||
status.Reason = ""
|
status.Reason = ""
|
||||||
}
|
}
|
||||||
|
status.Features = gameClientBridgeFeatureAvailability(plugin.GameClientBridge.Features, status.Profiles)
|
||||||
return domain.CopyGameClientBridgeStatus(status), nil
|
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) {
|
func (svc *CoreService) ListGameClientBridgeCommandsForSession(sessionID string, filter domain.GameClientBridgeCommandFilter) ([]domain.GameClientBridgeCommand, error) {
|
||||||
if err := svc.authorizeServerLifecycle(sessionID, filter.ServerInstanceID); err != nil {
|
if err := svc.authorizeServerLifecycle(sessionID, filter.ServerInstanceID); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -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 {
|
func validateGameClientBridgeManifest(field string, bridge domain.GameClientBridgeManifest, permissions []string, pages []domain.GamePluginPage, runtimeProfiles domain.GamePluginRuntimeProfiles) []string {
|
||||||
companionPresent := bridge.Companion != (domain.GameClientBridgeCompanionDeclaration{})
|
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
|
return nil
|
||||||
}
|
}
|
||||||
var violations []string
|
var violations []string
|
||||||
@@ -515,6 +515,50 @@ func validateGameClientBridgeManifest(field string, bridge domain.GameClientBrid
|
|||||||
for _, page := range pages {
|
for _, page := range pages {
|
||||||
pageDeclarations[page.Key] = page
|
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 {
|
for index, page := range bridge.Pages {
|
||||||
prefix := fmt.Sprintf("%s.pages[%d]", field, index)
|
prefix := fmt.Sprintf("%s.pages[%d]", field, index)
|
||||||
pageDeclaration, pageExists := pageDeclarations[page.PageKey]
|
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")
|
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
|
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.key", page.Bundle.Key)
|
||||||
violations = appendRequired(violations, prefix+".bundle.version", page.Bundle.Version)
|
violations = appendRequired(violations, prefix+".bundle.version", page.Bundle.Version)
|
||||||
violations = appendRequired(violations, prefix+".bundle.integritySha256", page.Bundle.IntegritySHA256)
|
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 page.Key != "" {
|
||||||
if _, exists := seenKeys[page.Key]; exists {
|
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, duplicateViolations(prefix+".permissions", page.Permissions)...)
|
||||||
violations = append(violations, validateBridgeActions(prefix+".bridgeActions", page.BridgeActions)...)
|
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
|
return violations
|
||||||
}
|
}
|
||||||
|
|
||||||
func validPluginPageBundleVersion(value string) bool {
|
func validPluginPageBundleVersion(value string) bool {
|
||||||
if len(value) == 0 || len(value) > 80 { return false }
|
if len(value) == 0 || len(value) > 80 {
|
||||||
for _, item := range value { if !(item >= 'a' && item <= 'z' || item >= 'A' && item <= 'Z' || item >= '0' && item <= '9' || item == '.' || item == '_' || item == '-') { return false } }
|
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
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
func validPluginPageBundleIntegrity(value string) bool {
|
func validPluginPageBundleIntegrity(value string) bool {
|
||||||
if len(value) != len("sha256:")+64 || !strings.HasPrefix(value, "sha256:") { return false }
|
if len(value) != len("sha256:")+64 || !strings.HasPrefix(value, "sha256:") {
|
||||||
for _, item := range value[len("sha256:"):] { if !(item >= 'a' && item <= 'f' || item >= '0' && item <= '9') { return false } }
|
return false
|
||||||
|
}
|
||||||
|
for _, item := range value[len("sha256:"):] {
|
||||||
|
if !(item >= 'a' && item <= 'f' || item >= '0' && item <= '9') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -60,8 +60,11 @@ export interface GameClientBridgePageContractResponse {
|
|||||||
commandTypes?: string[];
|
commandTypes?: string[];
|
||||||
snapshotTypes?: string[];
|
snapshotTypes?: string[];
|
||||||
queryTemplateKeys?: string[];
|
queryTemplateKeys?: string[];
|
||||||
|
featureKeys?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GameClientBridgeFeatureDeclarationResponse { key: string; title: string; permission: string; requiredHandlers?: string[]; requiredEventProducers?: string[]; }
|
||||||
|
|
||||||
export interface GameClientBridgeCompanionDeclarationResponse {
|
export interface GameClientBridgeCompanionDeclarationResponse {
|
||||||
profileKey: string;
|
profileKey: string;
|
||||||
configTemplateKey: string;
|
configTemplateKey: string;
|
||||||
@@ -85,6 +88,7 @@ export interface GameClientBridgeManifestResponse {
|
|||||||
commandRetentionSeconds: number;
|
commandRetentionSeconds: number;
|
||||||
maxCommands: number;
|
maxCommands: number;
|
||||||
pages?: GameClientBridgePageContractResponse[];
|
pages?: GameClientBridgePageContractResponse[];
|
||||||
|
features?: GameClientBridgeFeatureDeclarationResponse[];
|
||||||
companion?: GameClientBridgeCompanionDeclarationResponse;
|
companion?: GameClientBridgeCompanionDeclarationResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,14 +100,19 @@ export interface GameClientBridgeProfileDeclarationResponse {
|
|||||||
commandTypes: string[];
|
commandTypes: string[];
|
||||||
snapshotTypes: string[];
|
snapshotTypes: string[];
|
||||||
queryTemplateKeys: string[];
|
queryTemplateKeys: string[];
|
||||||
|
handlerTypes?: string[];
|
||||||
|
eventProducerTypes?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GameClientBridgeFeatureAvailabilityResponse { key: string; available: boolean; reason?: string; }
|
||||||
|
|
||||||
export interface GameClientBridgeStatusResponse {
|
export interface GameClientBridgeStatusResponse {
|
||||||
serverInstanceId: string;
|
serverInstanceId: string;
|
||||||
pluginId: string;
|
pluginId: string;
|
||||||
available: boolean;
|
available: boolean;
|
||||||
reason?: string;
|
reason?: string;
|
||||||
profiles: GameClientBridgeProfileDeclarationResponse[];
|
profiles: GameClientBridgeProfileDeclarationResponse[];
|
||||||
|
features?: GameClientBridgeFeatureAvailabilityResponse[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GameClientBridgeCommandResultResponse {
|
export interface GameClientBridgeCommandResultResponse {
|
||||||
@@ -224,6 +233,7 @@ export interface GamePluginPageResponse {
|
|||||||
bundleIntegritySha256?: string;
|
bundleIntegritySha256?: string;
|
||||||
permissions: string[];
|
permissions: string[];
|
||||||
bridgeActions?: string[];
|
bridgeActions?: string[];
|
||||||
|
featureKeys?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RuntimeDiscoveryProbeResponse {
|
export interface RuntimeDiscoveryProbeResponse {
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ export interface PluginPageContract {
|
|||||||
bundleIntegritySha256?: string;
|
bundleIntegritySha256?: string;
|
||||||
permissions?: PluginPermission[];
|
permissions?: PluginPermission[];
|
||||||
bridgeActions?: PluginBridgeAction[];
|
bridgeActions?: PluginBridgeAction[];
|
||||||
|
featureKeys?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PluginBridgeManifestContract {
|
export interface PluginBridgeManifestContract {
|
||||||
@@ -108,7 +109,8 @@ export function pluginBridgeManifestContractFromResponse(
|
|||||||
bundleVersion: page.bundleVersion,
|
bundleVersion: page.bundleVersion,
|
||||||
bundleIntegritySha256: page.bundleIntegritySha256,
|
bundleIntegritySha256: page.bundleIntegritySha256,
|
||||||
permissions: page.permissions.filter(isPluginPermission),
|
permissions: page.permissions.filter(isPluginPermission),
|
||||||
bridgeActions: page.bridgeActions?.filter(isPluginBridgeAction)
|
bridgeActions: page.bridgeActions?.filter(isPluginBridgeAction),
|
||||||
|
featureKeys: page.featureKeys ? [...page.featureKeys] : []
|
||||||
})),
|
})),
|
||||||
aiPurposes: [...plugin.aiPurposes]
|
aiPurposes: [...plugin.aiPurposes]
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -60,7 +60,11 @@ export function PluginPageHostPage({ params, onNavigate, initialPlugin }: Plugin
|
|||||||
setBundle(null); setBundleError("");
|
setBundle(null); setBundleError("");
|
||||||
void loadPluginPageBundle(declaredBundlePage).then((loaded) => { if (active) setBundle(() => loaded); }).catch((error) => { if (active) setBundleError(error instanceof Error ? error.message : "插件页面 bundle 加载失败。"); });
|
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; }; }
|
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; };
|
return () => { active = false; };
|
||||||
}, [declaredBundlePage, serverId]);
|
}, [declaredBundlePage, serverId]);
|
||||||
|
|
||||||
|
|||||||
@@ -69,7 +69,8 @@ export function parseSafeGameClientBridgeStatus(value: unknown): GameClientBridg
|
|||||||
pluginId: string(record.pluginId, "pluginId"),
|
pluginId: string(record.pluginId, "pluginId"),
|
||||||
available: boolean(record.available, "available"),
|
available: boolean(record.available, "available"),
|
||||||
reason: optionalString(record.reason, "reason"),
|
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"),
|
reason: optionalString(record.reason, "profile.reason"),
|
||||||
commandTypes: stringArray(record.commandTypes, "profile.commandTypes"),
|
commandTypes: stringArray(record.commandTypes, "profile.commandTypes"),
|
||||||
snapshotTypes: stringArray(record.snapshotTypes, "profile.snapshotTypes"),
|
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 {
|
function parseResult(value: unknown): GameClientBridgeCommandResultResponse {
|
||||||
const record = safeObject(value, "Game Client Bridge command result");
|
const record = safeObject(value, "Game Client Bridge command result");
|
||||||
const result: GameClientBridgeCommandResultResponse = {
|
const result: GameClientBridgeCommandResultResponse = {
|
||||||
|
|||||||
@@ -242,6 +242,13 @@
|
|||||||
],
|
],
|
||||||
"commandRetentionSeconds": 604800,
|
"commandRetentionSeconds": 604800,
|
||||||
"maxCommands": 1000,
|
"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": [
|
"pages": [
|
||||||
{
|
{
|
||||||
"pageKey": "files-config",
|
"pageKey": "files-config",
|
||||||
@@ -254,7 +261,8 @@
|
|||||||
"restart.prepare",
|
"restart.prepare",
|
||||||
"maintenance.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": {
|
"companion": {
|
||||||
@@ -303,7 +311,7 @@
|
|||||||
"dependencyPolicy": "required",
|
"dependencyPolicy": "required",
|
||||||
"approvalRequired": ["disable", "rollback", "retire"]
|
"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": {
|
"fileWorkspace": {
|
||||||
"defaultDirectoryKey": "scum-config",
|
"defaultDirectoryKey": "scum-config",
|
||||||
"directories": [{ "key": "scum-config", "label": "服务器配置", "scope": "config" }, { "key": "scum-logs", "label": "日志文件", "scope": "logs" }],
|
"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}$" },
|
"bundleVersion": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$" },
|
||||||
"bundleIntegritySha256": { "type": "string", "pattern": "^sha256:[a-f0-9]{64}$" },
|
"bundleIntegritySha256": { "type": "string", "pattern": "^sha256:[a-f0-9]{64}$" },
|
||||||
"permissions": { "type": "array", "items": { "$ref": "#/$defs/pluginPermission" }, "uniqueItems": true },
|
"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" },
|
"items": { "$ref": "#/$defs/gameClientBridgePageContract" },
|
||||||
"maxItems": 64
|
"maxItems": 64
|
||||||
},
|
},
|
||||||
|
"features": { "type": "array", "items": { "$ref": "#/$defs/gameClientBridgeFeature" }, "maxItems": 128 },
|
||||||
"companion": { "$ref": "#/$defs/gameClientBridgeCompanion" }
|
"companion": { "$ref": "#/$defs/gameClientBridgeCompanion" }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -337,7 +339,15 @@
|
|||||||
"pageKey": { "type": "string", "pattern": "^[a-z0-9][a-z0-9-]*$" },
|
"pageKey": { "type": "string", "pattern": "^[a-z0-9][a-z0-9-]*$" },
|
||||||
"commandTypes": { "type": "array", "items": { "type": "string" }, "uniqueItems": true },
|
"commandTypes": { "type": "array", "items": { "type": "string" }, "uniqueItems": true },
|
||||||
"snapshotTypes": { "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": {
|
"productionLifecycleOperation": {
|
||||||
|
|||||||
@@ -250,8 +250,11 @@ export interface GameClientBridgePageContract {
|
|||||||
commandTypes?: string[];
|
commandTypes?: string[];
|
||||||
snapshotTypes?: string[];
|
snapshotTypes?: string[];
|
||||||
queryTemplateKeys?: string[];
|
queryTemplateKeys?: string[];
|
||||||
|
featureKeys?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GameClientBridgeFeatureDeclaration { key: string; title: string; permission: PluginPermission; requiredHandlers?: string[]; requiredEventProducers?: string[]; }
|
||||||
|
|
||||||
export interface GameClientBridgeCompanionDeclaration {
|
export interface GameClientBridgeCompanionDeclaration {
|
||||||
profileKey: string;
|
profileKey: string;
|
||||||
configTemplateKey: string;
|
configTemplateKey: string;
|
||||||
@@ -275,6 +278,7 @@ export interface GameClientBridgeManifest {
|
|||||||
commandRetentionSeconds: number;
|
commandRetentionSeconds: number;
|
||||||
maxCommands: number;
|
maxCommands: number;
|
||||||
pages?: GameClientBridgePageContract[];
|
pages?: GameClientBridgePageContract[];
|
||||||
|
features?: GameClientBridgeFeatureDeclaration[];
|
||||||
companion?: GameClientBridgeCompanionDeclaration;
|
companion?: GameClientBridgeCompanionDeclaration;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -286,14 +290,19 @@ export interface GameClientBridgeProfileStatus {
|
|||||||
commandTypes: string[];
|
commandTypes: string[];
|
||||||
snapshotTypes: string[];
|
snapshotTypes: string[];
|
||||||
queryTemplateKeys: string[];
|
queryTemplateKeys: string[];
|
||||||
|
handlerTypes?: string[];
|
||||||
|
eventProducerTypes?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GameClientBridgeFeatureAvailability { key: string; available: boolean; reason?: string; }
|
||||||
|
|
||||||
export interface GameClientBridgeStatus {
|
export interface GameClientBridgeStatus {
|
||||||
serverInstanceId: string;
|
serverInstanceId: string;
|
||||||
pluginId: string;
|
pluginId: string;
|
||||||
available: boolean;
|
available: boolean;
|
||||||
reason?: string;
|
reason?: string;
|
||||||
profiles: GameClientBridgeProfileStatus[];
|
profiles: GameClientBridgeProfileStatus[];
|
||||||
|
features?: GameClientBridgeFeatureAvailability[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GameClientBridgeCommandResult {
|
export interface GameClientBridgeCommandResult {
|
||||||
|
|||||||
Reference in New Issue
Block a user