diff --git a/.env.example b/.env.example index 8fed7e5..3f79040 100644 --- a/.env.example +++ b/.env.example @@ -11,7 +11,7 @@ PLATFORM_STORAGE_BACKEND=file # MySQL metadata example. Change PLATFORM_STORAGE_BACKEND above from file to mysql, # then uncomment and adjust PLATFORM_MYSQL_DSN. -# MySQL is for users/plugins/servers/jobs/audit/log stream metadata, not row-per-log-line bodies. +# MySQL is for users/plugins/servers/jobs/log stream metadata, not row-per-log-line bodies. # PLATFORM_MYSQL_DSN=platform:platform@tcp(127.0.0.1:3306)/platform?parseTime=true PLATFORM_DATA_DIR=.platform-data diff --git a/README.md b/README.md index a71e922..8b623b2 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ The platform must not use one overloaded channel for everything. Run communicati Logs are historical data, not a UI-only stream. Browser realtime tail may use platform SSE/WebSocket later, but run-to-platform logs must use durable ingest semantics. -## Governance +## Repository Rules Read `AGENTS.md` before changing code. Each subproject also has a local `AGENTS.md` with stricter rules for that area. diff --git a/platform/.platform-data/metadata.json b/platform/.platform-data/metadata.json index 250dc7e..79c3a81 100644 --- a/platform/.platform-data/metadata.json +++ b/platform/.platform-data/metadata.json @@ -59,6 +59,5 @@ "runEndpoints": [], "jobs": [], "artifacts": [], - "logStreams": [], - "auditEvents": [] -} \ No newline at end of file + "logStreams": [] +} diff --git a/platform/api/game_client_bridge_handlers_test.go b/platform/api/game_client_bridge_handlers_test.go index 3b4c90f..0c29eab 100644 --- a/platform/api/game_client_bridge_handlers_test.go +++ b/platform/api/game_client_bridge_handlers_test.go @@ -49,7 +49,7 @@ func TestGameClientBridgeOperatorRoutes(t *testing.T) { plugin.RequiredRunCapabilities = append(plugin.RequiredRunCapabilities, domain.JobCapabilityClientManagerDeploy, domain.JobCapabilityClientManagerControl, domain.JobCapabilityClientManagerUpdate, domain.JobCapabilityClientManagerRollback, domain.JobCapabilityClientManagerUninstall, domain.JobCapabilityRemoteRunDBSQLiteQuery) plugin.RuntimeProfiles.TransportProfiles = []domain.RuntimeTransportProfile{{Key: "sqlite-db", Kind: "sqlite", TargetKey: "db/sqlite", Capabilities: []string{domain.JobCapabilityRemoteRunDBSQLiteQuery}}} plugin.RuntimeProfiles.ClientManagers = []domain.RuntimeClientManagerProfile{{Key: "scum-client", DisplayName: "SCUM Client", Version: "1.0.0", RepositoryURL: "https://github.com/example/scum-client.git", RevisionPolicy: "pinned", Revision: "0123456789abcdef", SupportedTargets: []domain.RuntimeTarget{{OS: "linux", Arch: "amd64"}}, BuildSystem: "go", EntryRef: "main.go", OutputArtifacts: []string{"scum-client"}, Deployment: domain.RuntimeClientManagerDeployment{Mode: "run-supervised", ExecutableRef: "scum-client", RequiredRunCapabilities: []string{domain.JobCapabilityClientManagerDeploy, domain.JobCapabilityClientManagerControl, domain.JobCapabilityClientManagerUpdate, domain.JobCapabilityClientManagerRollback, domain.JobCapabilityClientManagerUninstall}}, Lifecycle: domain.RuntimeClientManagerLifecycle{Actions: []string{"start", "stop", "restart", "status", "update", "rollback", "uninstall"}, StartupTimeoutSeconds: 60, StopTimeoutSeconds: 30}, Health: domain.RuntimeClientManagerHealth{Mode: "component-heartbeat", IntervalSeconds: 15, DegradedAfterSeconds: 45, OfflineAfterSeconds: 120, RequiredCapabilities: []string{"component.register", "component.heartbeat", "component.health", "game-client.bridge"}}, Compatibility: domain.RuntimeClientManagerCompatibility{MinimumVersion: "1.0.0"}, UpdatePolicy: domain.RuntimeClientManagerUpdatePolicy{Strategy: "manual-staged", RequireApproval: true, HealthConfirmationSeconds: 60, RetainPrevious: true}}} - plugin.GameClientBridge = domain.GameClientBridgeManifest{Commands: []domain.GameClientBridgeCommandDeclaration{{Type: "diagnostic.ping", Title: "Diagnostic ping", Permission: "server.game-client.command", ApprovalLevel: domain.GameClientBridgeApprovalLevelNone, PayloadSchemaRef: "schemas/bridge/diagnostic-ping.schema.json", ResultSchemaRef: "schemas/bridge/diagnostic-ping-result.schema.json", TimeoutSeconds: 3600, MaxPayloadBytes: 4096}}, QueryTemplates: []domain.GameClientBridgeQueryTemplateDeclaration{{Key: "player.lookup", Title: "Player lookup", Permission: "server.game-client.read", Engine: "sqlite", TransportKey: "sqlite-db", TargetKey: "db/sqlite", ParameterSchemaRef: "schemas/bridge/query/player-lookup.parameters.schema.json", ResultSchemaRef: "schemas/bridge/query/player-lookup.result.schema.json", MaxRows: 50, TimeoutSeconds: 10}}, Retention: domain.GameClientBridgeRetention{KeepForSeconds: 86400, MaxRecords: 1000}} + plugin.GameClientBridge = domain.GameClientBridgeManifest{Commands: []domain.GameClientBridgeCommandDeclaration{{Type: "diagnostic.ping", Title: "Diagnostic ping", Permission: "server.game-client.command", PayloadSchemaRef: "schemas/bridge/diagnostic-ping.schema.json", ResultSchemaRef: "schemas/bridge/diagnostic-ping-result.schema.json", TimeoutSeconds: 3600, MaxPayloadBytes: 4096}}, QueryTemplates: []domain.GameClientBridgeQueryTemplateDeclaration{{Key: "player.lookup", Title: "Player lookup", Permission: "server.game-client.read", Engine: "sqlite", TransportKey: "sqlite-db", TargetKey: "db/sqlite", ParameterSchemaRef: "schemas/bridge/query/player-lookup.parameters.schema.json", ResultSchemaRef: "schemas/bridge/query/player-lookup.result.schema.json", MaxRows: 50, TimeoutSeconds: 10}}, Retention: domain.GameClientBridgeRetention{KeepForSeconds: 86400, MaxRecords: 1000}} if _, err := coreService.CreateGamePlugin(plugin); err != nil { t.Fatalf("create bridge plugin: %v", err) } diff --git a/platform/api/resource_handlers_test.go b/platform/api/resource_handlers_test.go index 48c4ebb..ab923cc 100644 --- a/platform/api/resource_handlers_test.go +++ b/platform/api/resource_handlers_test.go @@ -1519,7 +1519,7 @@ func TestPluginLifecycleAndAIConfigRoutesAreDurableAndRedacted(t *testing.T) { serverID := createRuntimeAPIFixtures(t, router, adminSession) createAIProviderFixture(t, router, adminSession) - lifecycleRecorder := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/plugin-lifecycles/server.runtime/actions", dto.PluginLifecycleActionRequest{ServerInstanceID: serverID, Operation: "install", TargetVersion: "1.0.0", IdempotencyKey: "api-plugin-install", Confirmed: false}, adminSession) + lifecycleRecorder := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/plugin-lifecycles/server.runtime/actions", dto.PluginLifecycleActionRequest{ServerInstanceID: serverID, Operation: "install", TargetVersion: "1.0.0", IdempotencyKey: "api-plugin-install"}, adminSession) assertStatus(t, lifecycleRecorder, http.StatusAccepted) lifecycle := decodeBody[dto.PluginLifecycleActionResponse](t, lifecycleRecorder) if lifecycle.Status != "queued" || lifecycle.Job.ID == "" || lifecycle.Installation.ID == "" { @@ -1939,7 +1939,7 @@ func validGamePluginManifestRegistrationRequest() dto.GamePluginManifestRegistra }, }, AI: dto.GamePluginManifestAIBody{Purposes: []string{"logs.diagnose"}, Mediation: "platform", ConfigWritePolicy: "review-required"}, - ProductionLifecycle: dto.GamePluginProductionLifecycleBody{Operations: []string{"install", "enable", "disable", "upgrade", "rollback", "retire", "dependency-check"}, DependencyPolicy: "optional", ApprovalRequired: []string{"disable", "rollback", "retire"}}, + ProductionLifecycle: dto.GamePluginProductionLifecycleBody{Operations: []string{"install", "enable", "disable", "upgrade", "rollback", "retire", "dependency-check"}, DependencyPolicy: "optional"}, RuntimeProfiles: dto.GamePluginRuntimeProfilesBody{LifecycleProfiles: []dto.RuntimeLifecycleProfileBody{{Key: "local", Mode: "local-process", Capabilities: []string{"process.install", "process.start", "process.stop"}}}}, }, } diff --git a/platform/domain/game_client_bridge.go b/platform/domain/game_client_bridge.go index bf777b8..b92e944 100644 --- a/platform/domain/game_client_bridge.go +++ b/platform/domain/game_client_bridge.go @@ -14,28 +14,10 @@ const ( GameClientBridgeCommandExpired GameClientBridgeCommandState = "expired" ) -type GameClientBridgeApprovalState string - -const ( - GameClientBridgeApprovalNotRequired GameClientBridgeApprovalState = "not_required" - GameClientBridgeApprovalPending GameClientBridgeApprovalState = "pending" - GameClientBridgeApprovalApproved GameClientBridgeApprovalState = "approved" - GameClientBridgeApprovalRejected GameClientBridgeApprovalState = "rejected" -) - -type GameClientBridgeApprovalLevel string - -const ( - GameClientBridgeApprovalLevelNone GameClientBridgeApprovalLevel = "none" - GameClientBridgeApprovalLevelOperator GameClientBridgeApprovalLevel = "operator" - GameClientBridgeApprovalLevelPlatformAdmin GameClientBridgeApprovalLevel = "platform-admin" -) - type GameClientBridgeCommandDeclaration struct { Type string Title string Permission string - ApprovalLevel GameClientBridgeApprovalLevel PayloadSchemaRef string ResultSchemaRef string TimeoutSeconds int @@ -113,57 +95,11 @@ type GameClientBridgeDataPackDeclaration struct { DataRefs []string } -type GameClientBridgeOperationKind string - -const ( - GameClientBridgeOperationKindRCON GameClientBridgeOperationKind = "rcon" - GameClientBridgeOperationKindSQLiteMutation GameClientBridgeOperationKind = "sqlite-mutation" -) - -type GameClientBridgeOperationTemplateDeclaration struct { - Key string - Title string - Permission string - ApprovalLevel GameClientBridgeApprovalLevel - Kind GameClientBridgeOperationKind - TransportKey string - TargetKey string - PayloadSchemaRef string - ResultSchemaRef string - ConfirmationSchemaRef string - TimeoutSeconds int - MaxPayloadBytes int - MaxRowsAffected int - Mutation GameClientBridgeOperationMutationDeclaration - Safety GameClientBridgeOperationSafety -} - -type GameClientBridgeOperationMutationDeclaration struct { - FieldKey string - TableKey string - IdentityKey string - ValueKey string - ConfirmationQueryKey string - AllowedValueType string - MinValue float64 - MaxValue float64 -} - -type GameClientBridgeOperationSafety struct { - RequiresApproval bool - RequiresOfflinePlayer bool - RequiresMaintenanceWindow bool - RequiresBeforeValue bool - RequiresConfirmation bool - BackupRequired bool -} - type GameClientBridgePageContract struct { PageKey string CommandTypes []string SnapshotTypes []string QueryTemplateKeys []string - OperationKeys []string FeatureKeys []string } @@ -192,16 +128,15 @@ type GameClientBridgeCompanionDeclaration struct { } type GameClientBridgeManifest struct { - Commands []GameClientBridgeCommandDeclaration - Snapshots []GameClientBridgeSnapshotDeclaration - QueryTemplates []GameClientBridgeQueryTemplateDeclaration - LogProjections []GameClientBridgeLogProjectionDeclaration - DataPacks []GameClientBridgeDataPackDeclaration - OperationTemplates []GameClientBridgeOperationTemplateDeclaration - Retention GameClientBridgeRetention - Pages []GameClientBridgePageContract - Features []GameClientBridgeFeatureDeclaration - Companion GameClientBridgeCompanionDeclaration + Commands []GameClientBridgeCommandDeclaration + Snapshots []GameClientBridgeSnapshotDeclaration + QueryTemplates []GameClientBridgeQueryTemplateDeclaration + LogProjections []GameClientBridgeLogProjectionDeclaration + DataPacks []GameClientBridgeDataPackDeclaration + Retention GameClientBridgeRetention + Pages []GameClientBridgePageContract + Features []GameClientBridgeFeatureDeclaration + Companion GameClientBridgeCompanionDeclaration } type GameClientBridgeResultStatus string @@ -224,7 +159,6 @@ type GameClientBridgeCommand struct { IdempotencyKey string Priority int State GameClientBridgeCommandState - ApprovalState GameClientBridgeApprovalState RequesterID string Claim GameClientBridgeClaim Cancellation GameClientBridgeCancellation @@ -489,14 +423,12 @@ func CopyGameClientBridgeManifest(value GameClientBridgeManifest) GameClientBrid value.DataPacks[index].ConfigMapRefs = CopyStringSlice(value.DataPacks[index].ConfigMapRefs) value.DataPacks[index].DataRefs = CopyStringSlice(value.DataPacks[index].DataRefs) } - value.OperationTemplates = append([]GameClientBridgeOperationTemplateDeclaration(nil), value.OperationTemplates...) 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].OperationKeys = CopyStringSlice(value.Pages[index].OperationKeys) value.Pages[index].FeatureKeys = CopyStringSlice(value.Pages[index].FeatureKeys) } for index := range value.Features { diff --git a/platform/domain/game_client_bridge_test.go b/platform/domain/game_client_bridge_test.go index d0695f2..b0e085e 100644 --- a/platform/domain/game_client_bridge_test.go +++ b/platform/domain/game_client_bridge_test.go @@ -10,9 +10,8 @@ func TestCopyGameClientBridgeDeclarationsCopiesQueryTemplateSlices(t *testing.T) Target: GameClientBridgeLogProjectionTargetDeclaration{Collection: "users", UpsertKeys: []string{"steamId"}, CaptureMappings: map[string]string{"steamId": "steamId"}, FixedValues: map[string]string{"source": "stdout"}, ObservedAtField: "lastLoginAt"}, Presence: &GameClientBridgeLogProjectionPresenceDeclaration{TimestampField: "lastLoginAt", ActiveWindowSeconds: 600, ActivityTarget: &GameClientBridgeLogProjectionTargetDeclaration{Collection: "activity", UpsertKeys: []string{"steamId"}, CaptureMappings: map[string]string{"steamId": "steamId"}}}, }}, - DataPacks: []GameClientBridgeDataPackDeclaration{{Key: "db-v1", LogParserRefs: []string{"logs.json"}, ConfigMapRefs: []string{"config.json"}, DataRefs: []string{"data.json"}}}, - OperationTemplates: []GameClientBridgeOperationTemplateDeclaration{{Key: "player.fame.set"}}, - Pages: []GameClientBridgePageContract{{PageKey: "players", QueryTemplateKeys: []string{"player.lookup"}, OperationKeys: []string{"player.fame.set"}}}, + DataPacks: []GameClientBridgeDataPackDeclaration{{Key: "db-v1", LogParserRefs: []string{"logs.json"}, ConfigMapRefs: []string{"config.json"}, DataRefs: []string{"data.json"}}}, + Pages: []GameClientBridgePageContract{{PageKey: "players", QueryTemplateKeys: []string{"player.lookup"}}}, } manifestCopy := CopyGameClientBridgeManifest(manifest) manifestCopy.QueryTemplates[0].Key = "mutated" @@ -22,10 +21,8 @@ func TestCopyGameClientBridgeDeclarationsCopiesQueryTemplateSlices(t *testing.T) manifestCopy.LogProjections[0].Presence.ActivityTarget.CaptureMappings["steamId"] = "mutated" manifestCopy.DataPacks[0].LogParserRefs[0] = "mutated" manifestCopy.DataPacks[0].DataRefs[0] = "mutated" - manifestCopy.OperationTemplates[0].Key = "mutated" manifestCopy.Pages[0].QueryTemplateKeys[0] = "mutated" - manifestCopy.Pages[0].OperationKeys[0] = "mutated" - if manifest.QueryTemplates[0].Key != "player.lookup" || manifest.QueryTemplates[0].RowTarget.ColumnMappings["userId"] != "user_id" || manifest.LogProjections[0].StreamKeys[0] != "process.stdout" || manifest.LogProjections[0].Target.CaptureMappings["steamId"] != "steamId" || manifest.LogProjections[0].Presence.ActivityTarget.CaptureMappings["steamId"] != "steamId" || manifest.DataPacks[0].LogParserRefs[0] != "logs.json" || manifest.DataPacks[0].DataRefs[0] != "data.json" || manifest.OperationTemplates[0].Key != "player.fame.set" || manifest.Pages[0].QueryTemplateKeys[0] != "player.lookup" || manifest.Pages[0].OperationKeys[0] != "player.fame.set" { + if manifest.QueryTemplates[0].Key != "player.lookup" || manifest.QueryTemplates[0].RowTarget.ColumnMappings["userId"] != "user_id" || manifest.LogProjections[0].StreamKeys[0] != "process.stdout" || manifest.LogProjections[0].Target.CaptureMappings["steamId"] != "steamId" || manifest.LogProjections[0].Presence.ActivityTarget.CaptureMappings["steamId"] != "steamId" || manifest.DataPacks[0].LogParserRefs[0] != "logs.json" || manifest.DataPacks[0].DataRefs[0] != "data.json" || manifest.Pages[0].QueryTemplateKeys[0] != "player.lookup" { t.Fatalf("manifest copy aliases query template declarations: source=%#v copy=%#v", manifest, manifestCopy) } diff --git a/platform/domain/plugin_operations.go b/platform/domain/plugin_operations.go index 66ddf35..0114438 100644 --- a/platform/domain/plugin_operations.go +++ b/platform/domain/plugin_operations.go @@ -58,7 +58,6 @@ type PluginLifecycleRequest struct { Operation PluginLifecycleOperation TargetVersion string IdempotencyKey string - Confirmed bool } type PluginLifecycleResult struct { diff --git a/platform/domain/resources.go b/platform/domain/resources.go index 4acafc7..e7d3a7f 100644 --- a/platform/domain/resources.go +++ b/platform/domain/resources.go @@ -372,7 +372,6 @@ type GamePluginManifestAI struct { type GamePluginProductionLifecycle struct { Operations []string DependencyPolicy string - ApprovalRequired []string } type GamePluginRemoteAccess struct { @@ -1068,7 +1067,6 @@ const ( JobCapabilityRemoteRunDBSQLiteQuery = "remote.run.db.sqlite.query" JobCapabilityRemoteRunLogsTransfer = "remote.run.logs.transfer" JobCapabilityRemoteRunRCONCommand = "remote.run.rcon.command" - JobCapabilityRemoteRunProtectedSQL = "remote.run.protected.sql" JobCapabilityRemoteRunProgram = "remote.run.program.command" JobCapabilityRunSelfUpdate = "run.self-update" JobCapabilityDistributionBuild = "distribution.build" @@ -1749,7 +1747,6 @@ func CopyPluginCreateFields(fields []PluginCreateField) []PluginCreateField { func CopyGamePluginProductionLifecycle(lifecycle GamePluginProductionLifecycle) GamePluginProductionLifecycle { lifecycle.Operations = CopyStringSlice(lifecycle.Operations) - lifecycle.ApprovalRequired = CopyStringSlice(lifecycle.ApprovalRequired) return lifecycle } diff --git a/platform/dto/game_client_bridge.go b/platform/dto/game_client_bridge.go index 43e5eb8..c08c52c 100644 --- a/platform/dto/game_client_bridge.go +++ b/platform/dto/game_client_bridge.go @@ -82,7 +82,6 @@ type GameClientBridgeCommandResponse struct { RunJobID string `json:"runJobId,omitempty"` Priority int `json:"priority"` State string `json:"state"` - ApprovalState string `json:"approvalState"` RequesterID string `json:"requesterId,omitempty"` ResultSummary string `json:"resultSummary,omitempty"` Result *GameClientBridgeCommandResultResponse `json:"result,omitempty"` @@ -244,7 +243,6 @@ func GameClientBridgeCommandFromDomain(value domain.GameClientBridgeCommand) Gam RunJobID: value.RunJobID, Priority: value.Priority, State: string(value.State), - ApprovalState: string(value.ApprovalState), RequesterID: value.RequesterID, ResultSummary: value.Result.Summary, ExpiresAt: value.ExpiresAt, diff --git a/platform/dto/game_client_bridge_test.go b/platform/dto/game_client_bridge_test.go index 023f2b8..8a80c9f 100644 --- a/platform/dto/game_client_bridge_test.go +++ b/platform/dto/game_client_bridge_test.go @@ -34,7 +34,7 @@ func TestGameClientBridgeBrowserProjectionsAreCompleteAndOmitInternalData(t *tes now := time.Date(2026, 7, 20, 10, 0, 0, 0, time.UTC) command := domain.GameClientBridgeCommand{ ID: "command-1", ServerInstanceID: "server-1", PluginID: "game.scum", ProfileKey: "scum-client", CommandType: "diagnostic.ping", Payload: map[string]any{"message": "internal command payload"}, IdempotencyKey: "internal-idempotency", Priority: 9, - State: domain.GameClientBridgeCommandCancelled, ApprovalState: domain.GameClientBridgeApprovalApproved, RequesterID: "operator-1", + State: domain.GameClientBridgeCommandCancelled, RequesterID: "operator-1", Claim: domain.GameClientBridgeClaim{SessionID: "internal-session-secret", InstallationID: "internal-installation", DeploymentGeneration: 9, FencingToken: 42, LeaseExpiresAt: now.Add(time.Minute)}, Result: domain.GameClientBridgeResult{Status: domain.GameClientBridgeResultCancelled, Summary: "cancelled safely", Payload: map[string]any{"code": "cancelled"}, CompletedBy: "internal-completing-session", CompletedAt: now.Add(3 * time.Minute)}, Cancellation: domain.GameClientBridgeCancellation{RequestedBy: "operator-2", Reason: "operator request", CancelledAt: now.Add(2 * time.Minute)}, diff --git a/platform/dto/plugin_operations.go b/platform/dto/plugin_operations.go index d6ec277..1e23cf0 100644 --- a/platform/dto/plugin_operations.go +++ b/platform/dto/plugin_operations.go @@ -11,7 +11,6 @@ type PluginLifecycleActionRequest struct { Operation string `json:"operation"` TargetVersion string `json:"targetVersion,omitempty"` IdempotencyKey string `json:"idempotencyKey"` - Confirmed bool `json:"confirmed"` } type PluginLifecycleInstallationResponse struct { @@ -82,7 +81,7 @@ type AIConfigDiffApprovalResponse struct { } func (request PluginLifecycleActionRequest) ToDomain(pluginID string) domain.PluginLifecycleRequest { - return domain.PluginLifecycleRequest{PluginID: pluginID, ServerInstanceID: request.ServerInstanceID, Operation: domain.PluginLifecycleOperation(request.Operation), TargetVersion: request.TargetVersion, IdempotencyKey: request.IdempotencyKey, Confirmed: request.Confirmed} + return domain.PluginLifecycleRequest{PluginID: pluginID, ServerInstanceID: request.ServerInstanceID, Operation: domain.PluginLifecycleOperation(request.Operation), TargetVersion: request.TargetVersion, IdempotencyKey: request.IdempotencyKey} } func PluginLifecycleFromDomain(installation domain.PluginLifecycleInstallation) PluginLifecycleInstallationResponse { diff --git a/platform/dto/resources.go b/platform/dto/resources.go index 5e1685c..c4ae523 100644 --- a/platform/dto/resources.go +++ b/platform/dto/resources.go @@ -250,7 +250,6 @@ type GamePluginManifestAIBody struct { type GamePluginProductionLifecycleBody struct { Operations []string `json:"operations"` DependencyPolicy string `json:"dependencyPolicy"` - ApprovalRequired []string `json:"approvalRequired"` } type GamePluginRemoteAccessBody struct { @@ -265,7 +264,6 @@ type GameClientBridgeCommandDeclarationBody struct { Type string `json:"type"` Title string `json:"title"` Permission string `json:"permission"` - ApprovalLevel string `json:"approvalLevel"` PayloadSchemaRef string `json:"payloadSchemaRef"` ResultSchemaRef string `json:"resultSchemaRef,omitempty"` TimeoutSeconds int `json:"timeoutSeconds"` @@ -339,50 +337,11 @@ type GameClientBridgeDataPackDeclarationBody struct { DataRefs []string `json:"dataRefs,omitempty"` } -type GameClientBridgeOperationSafetyBody struct { - RequiresApproval bool `json:"requiresApproval,omitempty"` - RequiresOfflinePlayer bool `json:"requiresOfflinePlayer,omitempty"` - RequiresMaintenanceWindow bool `json:"requiresMaintenanceWindow,omitempty"` - RequiresBeforeValue bool `json:"requiresBeforeValue,omitempty"` - RequiresConfirmation bool `json:"requiresConfirmation,omitempty"` - BackupRequired bool `json:"backupRequired,omitempty"` -} - -type GameClientBridgeOperationMutationDeclarationBody struct { - FieldKey string `json:"fieldKey"` - TableKey string `json:"tableKey"` - IdentityKey string `json:"identityKey"` - ValueKey string `json:"valueKey"` - ConfirmationQueryKey string `json:"confirmationQueryKey"` - AllowedValueType string `json:"allowedValueType"` - MinValue float64 `json:"minValue,omitempty"` - MaxValue float64 `json:"maxValue,omitempty"` -} - -type GameClientBridgeOperationTemplateDeclarationBody struct { - Key string `json:"key"` - Title string `json:"title"` - Permission string `json:"permission"` - ApprovalLevel string `json:"approvalLevel"` - Kind string `json:"kind"` - TransportKey string `json:"transportKey"` - TargetKey string `json:"targetKey"` - PayloadSchemaRef string `json:"payloadSchemaRef"` - ResultSchemaRef string `json:"resultSchemaRef,omitempty"` - ConfirmationSchemaRef string `json:"confirmationSchemaRef,omitempty"` - TimeoutSeconds int `json:"timeoutSeconds"` - MaxPayloadBytes int `json:"maxPayloadBytes"` - MaxRowsAffected int `json:"maxRowsAffected,omitempty"` - Mutation GameClientBridgeOperationMutationDeclarationBody `json:"mutation,omitempty"` - Safety GameClientBridgeOperationSafetyBody `json:"safety,omitempty"` -} - type GameClientBridgePageContractBody struct { PageKey string `json:"pageKey"` CommandTypes []string `json:"commandTypes,omitempty"` SnapshotTypes []string `json:"snapshotTypes,omitempty"` QueryTemplateKeys []string `json:"queryTemplateKeys,omitempty"` - OperationKeys []string `json:"operationKeys,omitempty"` FeatureKeys []string `json:"featureKeys,omitempty"` } @@ -411,17 +370,16 @@ type GameClientBridgeCompanionDeclarationBody struct { } type GameClientBridgeManifestBody struct { - Commands []GameClientBridgeCommandDeclarationBody `json:"commands"` - Snapshots []GameClientBridgeSnapshotDeclarationBody `json:"snapshots"` - QueryTemplates []GameClientBridgeQueryTemplateDeclarationBody `json:"queryTemplates,omitempty"` - LogProjections []GameClientBridgeLogProjectionDeclarationBody `json:"logProjections,omitempty"` - DataPacks []GameClientBridgeDataPackDeclarationBody `json:"dataPacks,omitempty"` - OperationTemplates []GameClientBridgeOperationTemplateDeclarationBody `json:"operationTemplates,omitempty"` - CommandRetentionSeconds int `json:"commandRetentionSeconds"` - MaxCommands int `json:"maxCommands"` - Pages []GameClientBridgePageContractBody `json:"pages,omitempty"` - Features []GameClientBridgeFeatureDeclarationBody `json:"features,omitempty"` - Companion *GameClientBridgeCompanionDeclarationBody `json:"companion,omitempty"` + Commands []GameClientBridgeCommandDeclarationBody `json:"commands"` + Snapshots []GameClientBridgeSnapshotDeclarationBody `json:"snapshots"` + QueryTemplates []GameClientBridgeQueryTemplateDeclarationBody `json:"queryTemplates,omitempty"` + LogProjections []GameClientBridgeLogProjectionDeclarationBody `json:"logProjections,omitempty"` + DataPacks []GameClientBridgeDataPackDeclarationBody `json:"dataPacks,omitempty"` + CommandRetentionSeconds int `json:"commandRetentionSeconds"` + MaxCommands int `json:"maxCommands"` + Pages []GameClientBridgePageContractBody `json:"pages,omitempty"` + Features []GameClientBridgeFeatureDeclarationBody `json:"features,omitempty"` + Companion *GameClientBridgeCompanionDeclarationBody `json:"companion,omitempty"` } type GamePluginManifestBody struct { ID string `json:"id"` @@ -1163,7 +1121,7 @@ func (ai GamePluginManifestAIBody) ToDomain() domain.GamePluginManifestAI { } func (lifecycle GamePluginProductionLifecycleBody) ToDomain() domain.GamePluginProductionLifecycle { - return domain.GamePluginProductionLifecycle{Operations: domain.CopyStringSlice(lifecycle.Operations), DependencyPolicy: lifecycle.DependencyPolicy, ApprovalRequired: domain.CopyStringSlice(lifecycle.ApprovalRequired)} + return domain.GamePluginProductionLifecycle{Operations: domain.CopyStringSlice(lifecycle.Operations), DependencyPolicy: lifecycle.DependencyPolicy} } func (remote GamePluginRemoteAccessBody) ToDomain() domain.GamePluginRemoteAccess { @@ -1179,7 +1137,7 @@ func (remote GamePluginRemoteAccessBody) ToDomain() domain.GamePluginRemoteAcces func (body GameClientBridgeManifestBody) ToDomain() domain.GameClientBridgeManifest { commands := make([]domain.GameClientBridgeCommandDeclaration, len(body.Commands)) for index, command := range body.Commands { - commands[index] = domain.GameClientBridgeCommandDeclaration{Type: command.Type, Title: command.Title, Permission: command.Permission, ApprovalLevel: domain.GameClientBridgeApprovalLevel(command.ApprovalLevel), PayloadSchemaRef: command.PayloadSchemaRef, ResultSchemaRef: command.ResultSchemaRef, TimeoutSeconds: command.TimeoutSeconds, MaxPayloadBytes: command.MaxPayloadBytes} + commands[index] = domain.GameClientBridgeCommandDeclaration{Type: command.Type, Title: command.Title, Permission: command.Permission, PayloadSchemaRef: command.PayloadSchemaRef, ResultSchemaRef: command.ResultSchemaRef, TimeoutSeconds: command.TimeoutSeconds, MaxPayloadBytes: command.MaxPayloadBytes} } snapshots := make([]domain.GameClientBridgeSnapshotDeclaration, len(body.Snapshots)) for index, snapshot := range body.Snapshots { @@ -1202,13 +1160,9 @@ func (body GameClientBridgeManifestBody) ToDomain() domain.GameClientBridgeManif for index, dataPack := range body.DataPacks { dataPacks[index] = domain.GameClientBridgeDataPackDeclaration{Key: dataPack.Key, DatabaseUserVersion: dataPack.DatabaseUserVersion, LogParserRefs: domain.CopyStringSlice(dataPack.LogParserRefs), ConfigMapRefs: domain.CopyStringSlice(dataPack.ConfigMapRefs), DataRefs: domain.CopyStringSlice(dataPack.DataRefs)} } - operationTemplates := make([]domain.GameClientBridgeOperationTemplateDeclaration, len(body.OperationTemplates)) - for index, template := range body.OperationTemplates { - operationTemplates[index] = domain.GameClientBridgeOperationTemplateDeclaration{Key: template.Key, Title: template.Title, Permission: template.Permission, ApprovalLevel: domain.GameClientBridgeApprovalLevel(template.ApprovalLevel), Kind: domain.GameClientBridgeOperationKind(template.Kind), TransportKey: template.TransportKey, TargetKey: template.TargetKey, PayloadSchemaRef: template.PayloadSchemaRef, ResultSchemaRef: template.ResultSchemaRef, ConfirmationSchemaRef: template.ConfirmationSchemaRef, TimeoutSeconds: template.TimeoutSeconds, MaxPayloadBytes: template.MaxPayloadBytes, MaxRowsAffected: template.MaxRowsAffected, Mutation: domain.GameClientBridgeOperationMutationDeclaration{FieldKey: template.Mutation.FieldKey, TableKey: template.Mutation.TableKey, IdentityKey: template.Mutation.IdentityKey, ValueKey: template.Mutation.ValueKey, ConfirmationQueryKey: template.Mutation.ConfirmationQueryKey, AllowedValueType: template.Mutation.AllowedValueType, MinValue: template.Mutation.MinValue, MaxValue: template.Mutation.MaxValue}, Safety: domain.GameClientBridgeOperationSafety{RequiresApproval: template.Safety.RequiresApproval, RequiresOfflinePlayer: template.Safety.RequiresOfflinePlayer, RequiresMaintenanceWindow: template.Safety.RequiresMaintenanceWindow, RequiresBeforeValue: template.Safety.RequiresBeforeValue, RequiresConfirmation: template.Safety.RequiresConfirmation, BackupRequired: template.Safety.BackupRequired}} - } 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), OperationKeys: domain.CopyStringSlice(page.OperationKeys), FeatureKeys: domain.CopyStringSlice(page.FeatureKeys)} + 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 { @@ -1218,7 +1172,7 @@ func (body GameClientBridgeManifestBody) ToDomain() domain.GameClientBridgeManif 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, LogProjections: logProjections, DataPacks: dataPacks, OperationTemplates: operationTemplates, Retention: domain.GameClientBridgeRetention{KeepForSeconds: body.CommandRetentionSeconds, MaxRecords: body.MaxCommands}, Pages: pages, Features: features, Companion: companion} + return domain.GameClientBridgeManifest{Commands: commands, Snapshots: snapshots, QueryTemplates: queryTemplates, LogProjections: logProjections, DataPacks: dataPacks, Retention: domain.GameClientBridgeRetention{KeepForSeconds: body.CommandRetentionSeconds, MaxRecords: body.MaxCommands}, Pages: pages, Features: features, Companion: companion} } func gameClientBridgeLogProjectionToDomain(value GameClientBridgeLogProjectionDeclarationBody) domain.GameClientBridgeLogProjectionDeclaration { @@ -1633,14 +1587,14 @@ func MarketplacePluginFromDomain(plugin domain.PluginMarketplacePlugin) Marketpl func productionLifecycleFromDomain(lifecycle domain.GamePluginProductionLifecycle) GamePluginProductionLifecycleBody { lifecycle = domain.CopyGamePluginProductionLifecycle(lifecycle) - return GamePluginProductionLifecycleBody{Operations: lifecycle.Operations, DependencyPolicy: lifecycle.DependencyPolicy, ApprovalRequired: lifecycle.ApprovalRequired} + return GamePluginProductionLifecycleBody{Operations: lifecycle.Operations, DependencyPolicy: lifecycle.DependencyPolicy} } func gameClientBridgeManifestFromDomain(value domain.GameClientBridgeManifest) GameClientBridgeManifestBody { value = domain.CopyGameClientBridgeManifest(value) commands := make([]GameClientBridgeCommandDeclarationBody, len(value.Commands)) for index, command := range value.Commands { - commands[index] = GameClientBridgeCommandDeclarationBody{Type: command.Type, Title: command.Title, Permission: command.Permission, ApprovalLevel: string(command.ApprovalLevel), PayloadSchemaRef: command.PayloadSchemaRef, ResultSchemaRef: command.ResultSchemaRef, TimeoutSeconds: command.TimeoutSeconds, MaxPayloadBytes: command.MaxPayloadBytes} + commands[index] = GameClientBridgeCommandDeclarationBody{Type: command.Type, Title: command.Title, Permission: command.Permission, PayloadSchemaRef: command.PayloadSchemaRef, ResultSchemaRef: command.ResultSchemaRef, TimeoutSeconds: command.TimeoutSeconds, MaxPayloadBytes: command.MaxPayloadBytes} } snapshots := make([]GameClientBridgeSnapshotDeclarationBody, len(value.Snapshots)) for index, snapshot := range value.Snapshots { @@ -1662,13 +1616,9 @@ func gameClientBridgeManifestFromDomain(value domain.GameClientBridgeManifest) G for index, dataPack := range value.DataPacks { dataPacks[index] = GameClientBridgeDataPackDeclarationBody{Key: dataPack.Key, DatabaseUserVersion: dataPack.DatabaseUserVersion, LogParserRefs: domain.CopyStringSlice(dataPack.LogParserRefs), ConfigMapRefs: domain.CopyStringSlice(dataPack.ConfigMapRefs), DataRefs: domain.CopyStringSlice(dataPack.DataRefs)} } - operationTemplates := make([]GameClientBridgeOperationTemplateDeclarationBody, len(value.OperationTemplates)) - for index, template := range value.OperationTemplates { - operationTemplates[index] = GameClientBridgeOperationTemplateDeclarationBody{Key: template.Key, Title: template.Title, Permission: template.Permission, ApprovalLevel: string(template.ApprovalLevel), Kind: string(template.Kind), TransportKey: template.TransportKey, TargetKey: template.TargetKey, PayloadSchemaRef: template.PayloadSchemaRef, ResultSchemaRef: template.ResultSchemaRef, ConfirmationSchemaRef: template.ConfirmationSchemaRef, TimeoutSeconds: template.TimeoutSeconds, MaxPayloadBytes: template.MaxPayloadBytes, MaxRowsAffected: template.MaxRowsAffected, Mutation: GameClientBridgeOperationMutationDeclarationBody{FieldKey: template.Mutation.FieldKey, TableKey: template.Mutation.TableKey, IdentityKey: template.Mutation.IdentityKey, ValueKey: template.Mutation.ValueKey, ConfirmationQueryKey: template.Mutation.ConfirmationQueryKey, AllowedValueType: template.Mutation.AllowedValueType, MinValue: template.Mutation.MinValue, MaxValue: template.Mutation.MaxValue}, Safety: GameClientBridgeOperationSafetyBody{RequiresApproval: template.Safety.RequiresApproval, RequiresOfflinePlayer: template.Safety.RequiresOfflinePlayer, RequiresMaintenanceWindow: template.Safety.RequiresMaintenanceWindow, RequiresBeforeValue: template.Safety.RequiresBeforeValue, RequiresConfirmation: template.Safety.RequiresConfirmation, BackupRequired: template.Safety.BackupRequired}} - } 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, OperationKeys: page.OperationKeys, FeatureKeys: page.FeatureKeys} + 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 { @@ -1678,7 +1628,7 @@ func gameClientBridgeManifestFromDomain(value domain.GameClientBridgeManifest) G 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, LogProjections: logProjections, DataPacks: dataPacks, OperationTemplates: operationTemplates, CommandRetentionSeconds: value.Retention.KeepForSeconds, MaxCommands: value.Retention.MaxRecords, Pages: pages, Features: features, Companion: companion} + return GameClientBridgeManifestBody{Commands: commands, Snapshots: snapshots, QueryTemplates: queryTemplates, LogProjections: logProjections, DataPacks: dataPacks, CommandRetentionSeconds: value.Retention.KeepForSeconds, MaxCommands: value.Retention.MaxRecords, Pages: pages, Features: features, Companion: companion} } func gameClientBridgeLogProjectionFromDomain(value domain.GameClientBridgeLogProjectionDeclaration) GameClientBridgeLogProjectionDeclarationBody { diff --git a/platform/dto/resources_test.go b/platform/dto/resources_test.go index c2ae6df..d8c95b8 100644 --- a/platform/dto/resources_test.go +++ b/platform/dto/resources_test.go @@ -3,7 +3,6 @@ package dto import ( "encoding/json" "reflect" - "strings" "testing" "browser.local/platform/domain" @@ -204,40 +203,3 @@ func TestGameClientBridgeQueryTemplateDeclarationRoundTripIsSafe(t *testing.T) { } } } - -func TestGameClientBridgeOperationTemplateDeclarationRoundTripIsSafe(t *testing.T) { - body := GameClientBridgeManifestBody{ - OperationTemplates: []GameClientBridgeOperationTemplateDeclarationBody{{ - Key: "player.attribute.855.set", Title: "Set player attribute 855", Permission: "server.game-client.maintenance", ApprovalLevel: "platform-admin", Kind: "sqlite-mutation", TransportKey: "scum-mutation-db", TargetKey: "scum-mutation-db", - PayloadSchemaRef: "schemas/bridge/operations/player-attribute-855-set.payload.schema.json", ResultSchemaRef: "schemas/bridge/operations/player-attribute-855-set.result.schema.json", ConfirmationSchemaRef: "schemas/bridge/operations/player-attribute-855-set.confirmation.schema.json", TimeoutSeconds: 120, MaxPayloadBytes: 4096, MaxRowsAffected: 1, - Safety: GameClientBridgeOperationSafetyBody{RequiresApproval: true, RequiresOfflinePlayer: true, RequiresBeforeValue: true, RequiresConfirmation: true, BackupRequired: true}, - }}, - CommandRetentionSeconds: 86400, - MaxCommands: 1000, - Pages: []GameClientBridgePageContractBody{{PageKey: "players", OperationKeys: []string{"player.attribute.855.set"}}}, - } - - domainManifest := body.ToDomain() - if len(domainManifest.OperationTemplates) != 1 || domainManifest.OperationTemplates[0].Kind != "sqlite-mutation" || domainManifest.OperationTemplates[0].MaxRowsAffected != 1 || !domainManifest.OperationTemplates[0].Safety.RequiresBeforeValue || domainManifest.Pages[0].OperationKeys[0] != "player.attribute.855.set" { - t.Fatalf("operation template conversion lost declaration fields: %#v", domainManifest) - } - domainManifest.Pages[0].OperationKeys[0] = "mutated" - if body.Pages[0].OperationKeys[0] != "player.attribute.855.set" { - t.Fatal("operation template page keys alias request DTO data") - } - domainManifest.Pages[0].OperationKeys[0] = "player.attribute.855.set" - - response := gameClientBridgeManifestFromDomain(domainManifest) - response.Pages[0].OperationKeys[0] = "mutated" - if domainManifest.Pages[0].OperationKeys[0] != "player.attribute.855.set" { - t.Fatal("operation template page keys alias domain data") - } - - encoded, err := json.Marshal(response.OperationTemplates[0]) - if err != nil { - t.Fatalf("marshal safe operation template projection: %v", err) - } - if strings.Contains(strings.ToLower(string(encoded)), "sqltext") || strings.Contains(strings.ToLower(string(encoded)), "dsn") || strings.Contains(strings.ToLower(string(encoded)), "hostpath") || strings.Contains(strings.ToLower(string(encoded)), "socket") || strings.Contains(strings.ToLower(string(encoded)), "credential") { - t.Fatalf("operation template projection leaked unsafe material: %s", encoded) - } -} diff --git a/platform/model/resources_test.go b/platform/model/resources_test.go index fef7519..4ad8872 100644 --- a/platform/model/resources_test.go +++ b/platform/model/resources_test.go @@ -50,7 +50,6 @@ func TestGamePluginModelRoundTripCopiesSlices(t *testing.T) { ProductionLifecycle: domain.GamePluginProductionLifecycle{ Operations: []string{"install", "upgrade", "rollback"}, DependencyPolicy: "required", - ApprovalRequired: []string{"rollback"}, }, RuntimeProfiles: domain.GamePluginRuntimeProfiles{LifecycleProfiles: []domain.RuntimeLifecycleProfile{{Key: "local", Mode: "local-process", Capabilities: []string{"process.start"}}}}, Permissions: domain.PluginPermissions{ @@ -69,7 +68,6 @@ func TestGamePluginModelRoundTripCopiesSlices(t *testing.T) { roundTrip.Tags[0] = "mutated" roundTrip.AIPurposes[0] = "config.suggest" roundTrip.ProductionLifecycle.Operations[0] = "retire" - roundTrip.ProductionLifecycle.ApprovalRequired[0] = "disable" roundTrip.RuntimeProfiles.LifecycleProfiles[0].Capabilities[0] = "process.stop" if source.RequiredRunCapabilities[0] != "process.start" { @@ -84,7 +82,7 @@ func TestGamePluginModelRoundTripCopiesSlices(t *testing.T) { if row.DeclaredPermissions[0] != "server.logs.read" || row.Pages[0].Permissions[0] != "server.logs.read" || row.Tags[0] != "survival" || row.AIPurposes[0] != "logs.diagnose" { t.Fatalf("expected model plugin registry metadata to remain unchanged, got %+v", row) } - if source.ProductionLifecycle.Operations[0] != "install" || row.ProductionLifecycle.Operations[0] != "install" || source.ProductionLifecycle.ApprovalRequired[0] != "rollback" || row.ProductionLifecycle.ApprovalRequired[0] != "rollback" { + if source.ProductionLifecycle.Operations[0] != "install" || row.ProductionLifecycle.Operations[0] != "install" { t.Fatalf("expected production lifecycle declaration to round-trip without aliasing, source=%+v row=%+v", source.ProductionLifecycle, row.ProductionLifecycle) } if source.RuntimeProfiles.LifecycleProfiles[0].Capabilities[0] != "process.start" || row.RuntimeProfiles.LifecycleProfiles[0].Capabilities[0] != "process.start" { diff --git a/platform/service/game_client_bridge.go b/platform/service/game_client_bridge.go index cb1f5c3..cc2ecdb 100644 --- a/platform/service/game_client_bridge.go +++ b/platform/service/game_client_bridge.go @@ -284,16 +284,6 @@ func (svc *CoreService) queueGameClientBridgeCommand(requesterID string, request if !request.ExpiresAt.After(stamp) { return domain.GameClientBridgeCommand{}, validationError("bridge command expiresAt must be in the future") } - approvalState := domain.GameClientBridgeApprovalNotRequired - if declaration.ApprovalLevel == domain.GameClientBridgeApprovalLevelOperator { - approvalState = domain.GameClientBridgeApprovalApproved - } - if declaration.ApprovalLevel == domain.GameClientBridgeApprovalLevelPlatformAdmin { - approvalState = domain.GameClientBridgeApprovalPending - if requester, requesterErr := svc.store.Users().Get(requesterID); requesterErr == nil && isPlatformAdmin(requester) { - approvalState = domain.GameClientBridgeApprovalApproved - } - } svc.bridgeSeq++ command := domain.GameClientBridgeCommand{ ID: fmt.Sprintf("bridge-command-%d-%d", stamp.UnixNano(), svc.bridgeSeq), @@ -305,7 +295,6 @@ func (svc *CoreService) queueGameClientBridgeCommand(requesterID string, request IdempotencyKey: request.IdempotencyKey, Priority: request.Priority, State: domain.GameClientBridgeCommandPending, - ApprovalState: approvalState, RequesterID: requesterID, ExpiresAt: request.ExpiresAt, CreatedAt: stamp, @@ -351,9 +340,6 @@ func (svc *CoreService) claimGameClientBridgeCommands(component gameClientBridge if command.RunJobID != "" { continue } - if command.ApprovalState != domain.GameClientBridgeApprovalNotRequired && command.ApprovalState != domain.GameClientBridgeApprovalApproved { - continue - } fencingToken := command.Claim.FencingToken + 1 command.State = domain.GameClientBridgeCommandClaimed command.Claim = domain.GameClientBridgeClaim{SessionID: component.Session.ID, InstallationID: component.Installation.ID, DeploymentGeneration: component.Session.DeploymentGeneration, FencingToken: fencingToken, LeaseExpiresAt: gameClientBridgeClaimLeaseExpiry(stamp, command.ExpiresAt), ClaimedAt: stamp} diff --git a/platform/service/game_client_bridge_test.go b/platform/service/game_client_bridge_test.go index 8a14607..c7a369a 100644 --- a/platform/service/game_client_bridge_test.go +++ b/platform/service/game_client_bridge_test.go @@ -12,7 +12,7 @@ func newGameClientBridgeService(t *testing.T) (*CoreService, *time.Time) { t.Helper() now := time.Date(2026, 7, 20, 10, 0, 0, 0, time.UTC) store := repo.NewMemoryStore() - plugin := domain.GamePlugin{ID: "game.scum", RuntimeProfiles: domain.GamePluginRuntimeProfiles{ClientManagers: []domain.RuntimeClientManagerProfile{{Key: "scum-client", Health: domain.RuntimeClientManagerHealth{RequiredCapabilities: []string{gameClientBridgeCapability}}}}}, GameClientBridge: domain.GameClientBridgeManifest{Commands: []domain.GameClientBridgeCommandDeclaration{{Type: "diagnostic.ping", ApprovalLevel: domain.GameClientBridgeApprovalLevelNone, TimeoutSeconds: 600, MaxPayloadBytes: 4096}}, Snapshots: []domain.GameClientBridgeSnapshotDeclaration{{Type: "players", SchemaVersion: "1", Retention: domain.GameClientBridgeRetention{KeepForSeconds: 3600, MaxRecords: 100}}, {Type: "health", SchemaVersion: "1", Retention: domain.GameClientBridgeRetention{KeepForSeconds: 60}}, {Type: "companion.health", SchemaVersion: "1", Retention: domain.GameClientBridgeRetention{KeepForSeconds: 3600, MaxRecords: 100}}}, Retention: domain.GameClientBridgeRetention{KeepForSeconds: 86400, MaxRecords: 1000}}} + plugin := domain.GamePlugin{ID: "game.scum", RuntimeProfiles: domain.GamePluginRuntimeProfiles{ClientManagers: []domain.RuntimeClientManagerProfile{{Key: "scum-client", Health: domain.RuntimeClientManagerHealth{RequiredCapabilities: []string{gameClientBridgeCapability}}}}}, GameClientBridge: domain.GameClientBridgeManifest{Commands: []domain.GameClientBridgeCommandDeclaration{{Type: "diagnostic.ping", TimeoutSeconds: 600, MaxPayloadBytes: 4096}}, Snapshots: []domain.GameClientBridgeSnapshotDeclaration{{Type: "players", SchemaVersion: "1", Retention: domain.GameClientBridgeRetention{KeepForSeconds: 3600, MaxRecords: 100}}, {Type: "health", SchemaVersion: "1", Retention: domain.GameClientBridgeRetention{KeepForSeconds: 60}}, {Type: "companion.health", SchemaVersion: "1", Retention: domain.GameClientBridgeRetention{KeepForSeconds: 3600, MaxRecords: 100}}}, Retention: domain.GameClientBridgeRetention{KeepForSeconds: 86400, MaxRecords: 1000}}} if err := store.GamePlugins().Create(plugin); err != nil { t.Fatalf("seed bridge plugin: %v", err) } diff --git a/platform/service/plugin_operations_test.go b/platform/service/plugin_operations_test.go index 0e8ffeb..5de74a6 100644 --- a/platform/service/plugin_operations_test.go +++ b/platform/service/plugin_operations_test.go @@ -53,7 +53,7 @@ func TestPluginLifecycleBridgeDispatchesBoundedJob(t *testing.T) { response, err := svc.ExecutePluginBridgeAction(session, domain.PluginBridgeExecuteRequest{ RequestID: "bridge-plugin-install", PluginID: plugin.ID, RouteKey: "operations", ServerInstanceID: instance.ID, Action: domain.PluginBridgeActionPluginLifecycle, - Payload: map[string]string{"operation": "install", "targetVersion": plugin.Version, "idempotencyKey": "bridge-plugin-install-v1", "confirmed": "false"}, + Payload: map[string]string{"operation": "install", "targetVersion": plugin.Version, "idempotencyKey": "bridge-plugin-install-v1"}, }) if err != nil { t.Fatalf("execute lifecycle bridge: %v", err) diff --git a/platform/service/resources.go b/platform/service/resources.go index 9a41b82..6053660 100644 --- a/platform/service/resources.go +++ b/platform/service/resources.go @@ -981,13 +981,7 @@ func (svc *CoreService) executeBridgeAIInvoke(sessionID string, base domain.Plug } func (svc *CoreService) executeBridgePluginLifecycle(sessionID string, base domain.PluginBridgeExecuteResponse, request domain.PluginBridgeExecuteRequest) domain.PluginBridgeExecuteResponse { - confirmed, err := strconv.ParseBool(defaultBridgeValue(request.Payload["confirmed"], "false")) - if err != nil { - base.Status = "error" - base.Error = &domain.PluginBridgeSafeError{Code: "validation", Message: "confirmed must be true or false"} - return base - } - result, err := svc.RunPluginLifecycleForSession(sessionID, domain.PluginLifecycleRequest{PluginID: request.PluginID, ServerInstanceID: request.ServerInstanceID, Operation: domain.PluginLifecycleOperation(request.Payload["operation"]), TargetVersion: request.Payload["targetVersion"], IdempotencyKey: defaultBridgeValue(request.Payload["idempotencyKey"], request.RequestID), Confirmed: confirmed}) + result, err := svc.RunPluginLifecycleForSession(sessionID, domain.PluginLifecycleRequest{PluginID: request.PluginID, ServerInstanceID: request.ServerInstanceID, Operation: domain.PluginLifecycleOperation(request.Payload["operation"]), TargetVersion: request.Payload["targetVersion"], IdempotencyKey: defaultBridgeValue(request.Payload["idempotencyKey"], request.RequestID)}) if err != nil { return bridgeExecutionError(base, err) } @@ -1579,9 +1573,6 @@ func normalizedProductionLifecycle(lifecycle domain.GamePluginProductionLifecycl if lifecycle.DependencyPolicy == "" { lifecycle.DependencyPolicy = "optional" } - if len(lifecycle.ApprovalRequired) == 0 { - lifecycle.ApprovalRequired = []string{"disable", "rollback", "retire"} - } return lifecycle } diff --git a/platform/service/resources_test.go b/platform/service/resources_test.go index ffdbd15..62bbf1c 100644 --- a/platform/service/resources_test.go +++ b/platform/service/resources_test.go @@ -1957,7 +1957,7 @@ func validPluginManifestRegistration() domain.GamePluginManifestRegistration { }, }, AI: domain.GamePluginManifestAI{Purposes: []string{"logs.diagnose"}, Mediation: "platform", ConfigWritePolicy: "review-required"}, - ProductionLifecycle: domain.GamePluginProductionLifecycle{Operations: []string{"install", "enable", "disable", "upgrade", "rollback", "retire", "dependency-check"}, DependencyPolicy: "optional", ApprovalRequired: []string{"disable", "rollback", "retire"}}, + ProductionLifecycle: domain.GamePluginProductionLifecycle{Operations: []string{"install", "enable", "disable", "upgrade", "rollback", "retire", "dependency-check"}, DependencyPolicy: "optional"}, RuntimeProfiles: domain.GamePluginRuntimeProfiles{LifecycleProfiles: []domain.RuntimeLifecycleProfile{{Key: "local", Mode: "local-process", Capabilities: []string{"process.install", "process.start", "process.stop"}}}}, }, } diff --git a/platform/validator/plugin_operations.go b/platform/validator/plugin_operations.go index b201281..7643b1d 100644 --- a/platform/validator/plugin_operations.go +++ b/platform/validator/plugin_operations.go @@ -36,9 +36,6 @@ func ValidatePluginLifecycleRequest(request domain.PluginLifecycleRequest) error if !validPluginLifecycleOperation(request.Operation) { violations = append(violations, "operation is invalid") } - if (request.Operation == domain.PluginLifecycleOperationDisable || request.Operation == domain.PluginLifecycleOperationRollback || request.Operation == domain.PluginLifecycleOperationRetire) && !request.Confirmed { - violations = append(violations, "confirmed is required for disruptive plugin lifecycle operation") - } for _, value := range []fieldString{{field: "targetVersion", value: request.TargetVersion}, {field: "idempotencyKey", value: request.IdempotencyKey}} { if unsafeProductionText(value.value) { violations = append(violations, value.field+" is unsafe") diff --git a/platform/validator/resources.go b/platform/validator/resources.go index e66be54..14c79dc 100644 --- a/platform/validator/resources.go +++ b/platform/validator/resources.go @@ -434,7 +434,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.LogProjections) == 0 && len(bridge.DataPacks) == 0 && len(bridge.OperationTemplates) == 0 && len(bridge.Pages) == 0 && len(bridge.Features) == 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.LogProjections) == 0 && len(bridge.DataPacks) == 0 && len(bridge.Pages) == 0 && len(bridge.Features) == 0 && bridge.Retention.KeepForSeconds == 0 && bridge.Retention.MaxRecords == 0 && !companionPresent { return nil } var violations []string @@ -513,9 +513,6 @@ func validateGameClientBridgeManifest(field string, bridge domain.GameClientBrid if !containsString(permissions, command.Permission) { violations = append(violations, prefix+".permission must be declared by the plugin") } - if command.ApprovalLevel != domain.GameClientBridgeApprovalLevelNone && command.ApprovalLevel != domain.GameClientBridgeApprovalLevelOperator && command.ApprovalLevel != domain.GameClientBridgeApprovalLevelPlatformAdmin { - violations = append(violations, prefix+".approvalLevel is invalid") - } if !safeRelativeJSONRef(command.PayloadSchemaRef) || command.ResultSchemaRef != "" && !safeRelativeJSONRef(command.ResultSchemaRef) { violations = append(violations, prefix+" schema references must be safe relative JSON references") } @@ -647,72 +644,6 @@ func validateGameClientBridgeManifest(field string, bridge domain.GameClientBrid } } } - operationTemplates := map[string]domain.GameClientBridgeOperationTemplateDeclaration{} - for index, template := range bridge.OperationTemplates { - prefix := fmt.Sprintf("%s.operationTemplates[%d]", field, index) - if !clientManagerIdentifierPattern.MatchString(template.Key) || unsafeGameClientBridgeCommandType(template.Key) { - violations = append(violations, prefix+".key is invalid or unsafe") - } - if _, exists := operationTemplates[template.Key]; exists { - violations = append(violations, prefix+".key is duplicated") - } - operationTemplates[template.Key] = template - if strings.TrimSpace(template.Title) == "" || len([]rune(template.Title)) > 80 { - violations = append(violations, prefix+".title is invalid") - } - if !containsString(permissions, template.Permission) { - violations = append(violations, prefix+".permission must be declared by the plugin") - } - if template.ApprovalLevel != domain.GameClientBridgeApprovalLevelNone && template.ApprovalLevel != domain.GameClientBridgeApprovalLevelOperator && template.ApprovalLevel != domain.GameClientBridgeApprovalLevelPlatformAdmin { - violations = append(violations, prefix+".approvalLevel is invalid") - } - if template.Kind != domain.GameClientBridgeOperationKindRCON && template.Kind != domain.GameClientBridgeOperationKindSQLiteMutation { - violations = append(violations, prefix+".kind is invalid") - } - if !safeRelativeJSONRef(template.PayloadSchemaRef) || template.ResultSchemaRef != "" && !safeRelativeJSONRef(template.ResultSchemaRef) || template.ConfirmationSchemaRef != "" && !safeRelativeJSONRef(template.ConfirmationSchemaRef) { - violations = append(violations, prefix+" schema references must be safe relative JSON references") - } - if template.TimeoutSeconds < 1 || template.TimeoutSeconds > 3600 { - violations = append(violations, prefix+".timeoutSeconds is invalid") - } - if template.MaxPayloadBytes < 1 || template.MaxPayloadBytes > maxGameClientBridgePayloadSize { - violations = append(violations, prefix+".maxPayloadBytes is invalid") - } - transport, exists := transports[template.TransportKey] - if !exists { - violations = append(violations, prefix+".transportKey must reference a declared runtime transport profile") - continue - } - if transport.TargetKey != template.TargetKey || strings.TrimSpace(template.TargetKey) == "" { - violations = append(violations, prefix+".targetKey must match the declared runtime transport profile") - } - switch template.Kind { - case domain.GameClientBridgeOperationKindRCON: - if transport.Kind != "rcon" || !containsString(transport.Capabilities, domain.JobCapabilityRemoteRunRCONCommand) { - violations = append(violations, prefix+" transport must be rcon with remote.run.rcon.command capability") - } - if template.MaxRowsAffected != 0 { - violations = append(violations, prefix+".maxRowsAffected is only valid for sqlite-mutation") - } - if !emptyGameClientBridgeOperationMutation(template.Mutation) { - violations = append(violations, prefix+".mutation is only valid for sqlite-mutation") - } - case domain.GameClientBridgeOperationKindSQLiteMutation: - if transport.Kind != "sqlite" || !containsString(transport.Capabilities, domain.JobCapabilityRemoteRunProtectedSQL) { - violations = append(violations, prefix+" transport must be sqlite with remote.run.protected.sql capability") - } - if template.ApprovalLevel != domain.GameClientBridgeApprovalLevelPlatformAdmin { - violations = append(violations, prefix+".approvalLevel must require platform-admin approval for sqlite-mutation") - } - if template.MaxRowsAffected < 1 || template.MaxRowsAffected > 10 { - violations = append(violations, prefix+".maxRowsAffected is invalid") - } - if !template.Safety.RequiresBeforeValue || !template.Safety.RequiresConfirmation || (!template.Safety.RequiresOfflinePlayer && !template.Safety.RequiresMaintenanceWindow) { - violations = append(violations, prefix+".safety must require before value, confirmation, and offline or maintenance protection") - } - violations = append(violations, validateGameClientBridgeOperationMutation(prefix+".mutation", template.Mutation, queryTemplates)...) - } - } pageDeclarations := map[string]domain.GamePluginPage{} for _, page := range pages { pageDeclarations[page.Key] = page @@ -797,16 +728,6 @@ func validateGameClientBridgeManifest(field string, bridge domain.GameClientBrid violations = append(violations, prefix+" must declare remote.access.request for query templates") } } - for _, operationKey := range page.OperationKeys { - operation, exists := operationTemplates[operationKey] - if !exists { - violations = append(violations, prefix+" references undeclared operation template "+operationKey) - continue - } - if !containsString(pageDeclaration.Permissions, operation.Permission) { - violations = append(violations, prefix+" must declare operation template permission "+operation.Permission) - } - } for _, featureKey := range page.FeatureKeys { feature, exists := features[featureKey] if !exists { @@ -994,31 +915,6 @@ func unsafeGameClientBridgeCommandType(value string) bool { return has("shell", "powershell", "script", "terminal", "execute", "exec", "eval") || has("command", "cmd", "process", "system", "os", "executor") && has("run") } -func emptyGameClientBridgeOperationMutation(value domain.GameClientBridgeOperationMutationDeclaration) bool { - return value.FieldKey == "" && value.TableKey == "" && value.IdentityKey == "" && value.ValueKey == "" && value.ConfirmationQueryKey == "" && value.AllowedValueType == "" && value.MinValue == 0 && value.MaxValue == 0 -} - -func validateGameClientBridgeOperationMutation(prefix string, value domain.GameClientBridgeOperationMutationDeclaration, queryTemplates map[string]domain.GameClientBridgeQueryTemplateDeclaration) []string { - var violations []string - for field, item := range map[string]string{"fieldKey": value.FieldKey, "tableKey": value.TableKey, "identityKey": value.IdentityKey, "valueKey": value.ValueKey, "confirmationQueryKey": value.ConfirmationQueryKey} { - if !validDistributionLogicalKey(item) || unsafeGameClientBridgePayloadKey(item) { - violations = append(violations, prefix+"."+field+" must be a safe logical key") - } - } - if !oneOf(value.AllowedValueType, "integer", "number", "string", "boolean") { - violations = append(violations, prefix+".allowedValueType is invalid") - } - if value.MaxValue != 0 && value.MinValue > value.MaxValue { - violations = append(violations, prefix+".minValue must not exceed maxValue") - } - if value.ConfirmationQueryKey != "" { - if _, exists := queryTemplates[value.ConfirmationQueryKey]; !exists { - violations = append(violations, prefix+".confirmationQueryKey must reference a declared query template") - } - } - return violations -} - func ValidatePluginBridgeAuthorizeRequest(request domain.PluginBridgeAuthorizeRequest) error { var violations []string violations = appendRequired(violations, "pluginId", request.PluginID) @@ -2078,7 +1974,6 @@ func pluginSafeStrings(plugin domain.GamePlugin) []fieldString { values = appendStringSliceFields(values, "tags", plugin.Tags) values = appendStringSliceFields(values, "aiPurposes", plugin.AIPurposes) values = appendStringSliceFields(values, "productionLifecycle.operations", plugin.ProductionLifecycle.Operations) - values = appendStringSliceFields(values, "productionLifecycle.approvalRequired", plugin.ProductionLifecycle.ApprovalRequired) values = append(values, fieldString{field: "productionLifecycle.dependencyPolicy", value: plugin.ProductionLifecycle.DependencyPolicy}) values = appendStringSliceFields(values, "bridgeActions", plugin.BridgeActions) values = appendStringSliceFields(values, "remoteAccess.methods", plugin.RemoteAccess.Methods) @@ -2125,7 +2020,6 @@ func manifestSafeStrings(registration domain.GamePluginManifestRegistration) []f values = appendStringSliceFields(values, "ai.purposes", manifest.AI.Purposes) values = append(values, fieldString{field: "ai.mediation", value: manifest.AI.Mediation}, fieldString{field: "ai.configWritePolicy", value: manifest.AI.ConfigWritePolicy}, fieldString{field: "productionLifecycle.dependencyPolicy", value: manifest.ProductionLifecycle.DependencyPolicy}) values = appendStringSliceFields(values, "productionLifecycle.operations", manifest.ProductionLifecycle.Operations) - values = appendStringSliceFields(values, "productionLifecycle.approvalRequired", manifest.ProductionLifecycle.ApprovalRequired) values = appendStringSliceFields(values, "remoteAccess.methods", manifest.RemoteAccess.Methods) values = appendStringSliceFields(values, "remoteAccess.runCapabilities", manifest.RemoteAccess.RunCapabilities) values = appendStringSliceFields(values, "remoteAccess.databaseEngines", manifest.RemoteAccess.DatabaseEngines) @@ -2339,7 +2233,7 @@ func validPluginRunCapability(capability string) bool { domain.JobCapabilityRemoteRunProcessStart, domain.JobCapabilityRemoteRunProcessStop, domain.JobCapabilityRemoteRunDBMySQLQuery, domain.JobCapabilityRemoteRunDBSQLiteQuery, domain.JobCapabilityRemoteRunLogsTransfer, domain.JobCapabilityRemoteRunRCONCommand, - domain.JobCapabilityRemoteRunProtectedSQL, domain.JobCapabilityRemoteRunProgram, + domain.JobCapabilityRemoteRunProgram, domain.JobCapabilityRunSelfUpdate, domain.JobCapabilityDependenciesCheck, domain.JobCapabilityDependenciesInstall, domain.JobCapabilityDeploymentPlan, domain.JobCapabilityDeploymentShellPosix, domain.JobCapabilityDeploymentShellPowerShell, domain.JobCapabilityDeploymentShellCmd, domain.JobCapabilityClientManagerDeploy, domain.JobCapabilityClientManagerControl, domain.JobCapabilityClientManagerUpdate, @@ -2372,7 +2266,7 @@ func remoteCapabilityRequiresInputRef(capability string) bool { domain.JobCapabilityRemoteRunFilesWrite, domain.JobCapabilityRemoteRunDBMySQLQuery, domain.JobCapabilityRemoteRunDBSQLiteQuery, - domain.JobCapabilityRemoteRunRCONCommand, domain.JobCapabilityRemoteRunProtectedSQL, domain.JobCapabilityRemoteRunProgram: + domain.JobCapabilityRemoteRunRCONCommand, domain.JobCapabilityRemoteRunProgram: return true default: return false @@ -2516,16 +2410,6 @@ func validateProductionLifecycle(field string, lifecycle domain.GamePluginProduc if lifecycle.DependencyPolicy != "required" && lifecycle.DependencyPolicy != "optional" { violations = append(violations, field+".dependencyPolicy must be required or optional") } - for i, operation := range lifecycle.ApprovalRequired { - if operation != string(domain.PluginLifecycleOperationDisable) && operation != string(domain.PluginLifecycleOperationRollback) && operation != string(domain.PluginLifecycleOperationRetire) { - violations = append(violations, fmt.Sprintf("%s.approvalRequired[%d] is invalid", field, i)) - } - } - for _, operation := range []string{string(domain.PluginLifecycleOperationDisable), string(domain.PluginLifecycleOperationRollback), string(domain.PluginLifecycleOperationRetire)} { - if containsString(lifecycle.Operations, operation) && !containsString(lifecycle.ApprovalRequired, operation) { - violations = append(violations, field+".approvalRequired must include "+operation) - } - } return violations } diff --git a/platform/validator/resources_test.go b/platform/validator/resources_test.go index 013ad43..d002b8d 100644 --- a/platform/validator/resources_test.go +++ b/platform/validator/resources_test.go @@ -130,24 +130,19 @@ func TestValidateGamePluginManifestRegistrationValidatesRuntimeProfiles(t *testi func TestValidateGamePluginManifestRegistrationValidatesGameClientBridgeCatalog(t *testing.T) { registration := validGamePluginManifestRegistration() registration.Manifest.Permissions = append(registration.Manifest.Permissions, "server.game-client.read", "server.game-client.command", "server.game-client.maintenance", "server.remote.access") - registration.Manifest.Capabilities = append(registration.Manifest.Capabilities, domain.JobCapabilityRemoteRunDBSQLiteQuery, domain.JobCapabilityRemoteRunRCONCommand, domain.JobCapabilityRemoteRunProtectedSQL) + registration.Manifest.Capabilities = append(registration.Manifest.Capabilities, domain.JobCapabilityRemoteRunDBSQLiteQuery, domain.JobCapabilityRemoteRunRCONCommand) registration.Manifest.Pages[0].Permissions = append(registration.Manifest.Pages[0].Permissions, "server.game-client.read", "server.game-client.command", "server.game-client.maintenance", "server.remote.access") registration.Manifest.Pages[0].BridgeActions = append(registration.Manifest.Pages[0].BridgeActions, string(domain.PluginBridgeActionRemoteAccessRequest)) registration.Manifest.RuntimeProfiles.TransportProfiles = []domain.RuntimeTransportProfile{ {Key: "sqlite-db", Kind: "sqlite", TargetKey: "db/sqlite", Capabilities: []string{domain.JobCapabilityRemoteRunDBSQLiteQuery}}, {Key: "scum-rcon", Kind: "rcon", TargetKey: "scum-rcon", Capabilities: []string{domain.JobCapabilityRemoteRunRCONCommand}}, - {Key: "scum-mutation-db", Kind: "sqlite", TargetKey: "scum-mutation-db", Capabilities: []string{domain.JobCapabilityRemoteRunProtectedSQL}}, } registration.Manifest.GameClientBridge = domain.GameClientBridgeManifest{ - Commands: []domain.GameClientBridgeCommandDeclaration{{Type: "diagnostic.ping", Title: "Diagnostic ping", Permission: "server.game-client.command", ApprovalLevel: domain.GameClientBridgeApprovalLevelNone, PayloadSchemaRef: "schemas/bridge/diagnostic-ping.schema.json", ResultSchemaRef: "schemas/bridge/diagnostic-ping-result.schema.json", TimeoutSeconds: 60, MaxPayloadBytes: 4096}}, + Commands: []domain.GameClientBridgeCommandDeclaration{{Type: "diagnostic.ping", Title: "Diagnostic ping", Permission: "server.game-client.command", PayloadSchemaRef: "schemas/bridge/diagnostic-ping.schema.json", ResultSchemaRef: "schemas/bridge/diagnostic-ping-result.schema.json", TimeoutSeconds: 60, MaxPayloadBytes: 4096}}, Snapshots: []domain.GameClientBridgeSnapshotDeclaration{{Type: "players", SchemaVersion: "1", SchemaRef: "schemas/bridge/players.schema.json", Retention: domain.GameClientBridgeRetention{KeepForSeconds: 3600, MaxRecords: 100}}}, QueryTemplates: []domain.GameClientBridgeQueryTemplateDeclaration{{Key: "player.lookup", Title: "Player lookup", Permission: "server.game-client.read", Engine: "sqlite", TransportKey: "sqlite-db", TargetKey: "db/sqlite", ParameterSchemaRef: "schemas/bridge/query/player-lookup.parameters.schema.json", ResultSchemaRef: "schemas/bridge/query/player-lookup.result.schema.json", MaxRows: 50, TimeoutSeconds: 10}}, - OperationTemplates: []domain.GameClientBridgeOperationTemplateDeclaration{ - {Key: "player.fame.set", Title: "Set player fame", Permission: "server.game-client.command", ApprovalLevel: domain.GameClientBridgeApprovalLevelNone, Kind: domain.GameClientBridgeOperationKindRCON, TransportKey: "scum-rcon", TargetKey: "scum-rcon", PayloadSchemaRef: "schemas/bridge/operations/player-fame-set.payload.schema.json", ResultSchemaRef: "schemas/bridge/operations/player-fame-set.result.schema.json", TimeoutSeconds: 60, MaxPayloadBytes: 2048}, - {Key: "player.attribute.855.set", Title: "Set player attribute 855", Permission: "server.game-client.maintenance", ApprovalLevel: domain.GameClientBridgeApprovalLevelPlatformAdmin, Kind: domain.GameClientBridgeOperationKindSQLiteMutation, TransportKey: "scum-mutation-db", TargetKey: "scum-mutation-db", PayloadSchemaRef: "schemas/bridge/operations/player-attribute-855-set.payload.schema.json", ResultSchemaRef: "schemas/bridge/operations/player-attribute-855-set.result.schema.json", ConfirmationSchemaRef: "schemas/bridge/operations/player-attribute-855-set.confirmation.schema.json", TimeoutSeconds: 120, MaxPayloadBytes: 4096, MaxRowsAffected: 1, Mutation: domain.GameClientBridgeOperationMutationDeclaration{FieldKey: "855", TableKey: "prisoner", IdentityKey: "user_profile_id", ValueKey: "value", ConfirmationQueryKey: "player.lookup", AllowedValueType: "integer", MinValue: 0, MaxValue: 100000}, Safety: domain.GameClientBridgeOperationSafety{RequiresApproval: true, RequiresOfflinePlayer: true, RequiresBeforeValue: true, RequiresConfirmation: true, BackupRequired: true}}, - }, - Retention: domain.GameClientBridgeRetention{KeepForSeconds: 86400, MaxRecords: 1000}, - Pages: []domain.GameClientBridgePageContract{{PageKey: "logs", CommandTypes: []string{"diagnostic.ping"}, SnapshotTypes: []string{"players"}, QueryTemplateKeys: []string{"player.lookup"}, OperationKeys: []string{"player.fame.set", "player.attribute.855.set"}}}, + Retention: domain.GameClientBridgeRetention{KeepForSeconds: 86400, MaxRecords: 1000}, + Pages: []domain.GameClientBridgePageContract{{PageKey: "logs", CommandTypes: []string{"diagnostic.ping"}, SnapshotTypes: []string{"players"}, QueryTemplateKeys: []string{"player.lookup"}}}, } if err := ValidateGamePluginManifestRegistration(registration); err != nil { t.Fatalf("expected bridge catalog to validate, got %v", err) @@ -168,14 +163,13 @@ func TestValidateGamePluginManifestRegistrationValidatesGameClientBridgeCatalog( unsafe := registration unsafe.Manifest.GameClientBridge = domain.CopyGameClientBridgeManifest(registration.Manifest.GameClientBridge) unsafe.Manifest.GameClientBridge.Commands[0].Type = "shell.execute" - unsafe.Manifest.GameClientBridge.Commands[0].ApprovalLevel = "" unsafe.Manifest.GameClientBridge.Commands[0].PayloadSchemaRef = "/etc/command.json" unsafe.Manifest.GameClientBridge.Pages[0].CommandTypes = []string{"undeclared.command"} err := ValidateGamePluginManifestRegistration(unsafe) if err == nil { t.Fatal("expected unsafe bridge catalog rejection") } - for _, expected := range []string{"type is invalid or unsafe", "approvalLevel is invalid", "schema references", "undeclared command"} { + for _, expected := range []string{"type is invalid or unsafe", "schema references", "undeclared command"} { if !strings.Contains(err.Error(), expected) { t.Fatalf("expected %q in validation error: %v", expected, err) } @@ -241,54 +235,6 @@ func TestValidateGamePluginManifestRegistrationValidatesGameClientBridgeCatalog( }) } - operationTemplateTests := []struct { - name string - expected string - mutate func(*domain.GamePluginManifestRegistration) - }{ - {name: "duplicate key", expected: "key is duplicated", mutate: func(value *domain.GamePluginManifestRegistration) { - value.Manifest.GameClientBridge.OperationTemplates = append(value.Manifest.GameClientBridge.OperationTemplates, value.Manifest.GameClientBridge.OperationTemplates[0]) - }}, - {name: "unsafe key", expected: "key is invalid or unsafe", mutate: func(value *domain.GamePluginManifestRegistration) { - value.Manifest.GameClientBridge.OperationTemplates[0].Key = "raw.sql.execute" - }}, - {name: "mutation missing approval metadata", expected: "approvalLevel must require", mutate: func(value *domain.GamePluginManifestRegistration) { - value.Manifest.GameClientBridge.OperationTemplates[1].ApprovalLevel = domain.GameClientBridgeApprovalLevelNone - }}, - {name: "unsafe schema", expected: "schema references", mutate: func(value *domain.GamePluginManifestRegistration) { - value.Manifest.GameClientBridge.OperationTemplates[0].PayloadSchemaRef = "/etc/operation.json" - }}, - {name: "rcon wrong transport", expected: "transport must be rcon", mutate: func(value *domain.GamePluginManifestRegistration) { - value.Manifest.GameClientBridge.OperationTemplates[0].TransportKey = "sqlite-db" - value.Manifest.GameClientBridge.OperationTemplates[0].TargetKey = "db/sqlite" - }}, - {name: "mutation wrong transport", expected: "transport must be sqlite", mutate: func(value *domain.GamePluginManifestRegistration) { - value.Manifest.GameClientBridge.OperationTemplates[1].TransportKey = "scum-rcon" - value.Manifest.GameClientBridge.OperationTemplates[1].TargetKey = "scum-rcon" - }}, - {name: "mutation row bound", expected: "maxRowsAffected is invalid", mutate: func(value *domain.GamePluginManifestRegistration) { - value.Manifest.GameClientBridge.OperationTemplates[1].MaxRowsAffected = 0 - }}, - {name: "mutation missing safety", expected: "safety must require", mutate: func(value *domain.GamePluginManifestRegistration) { - value.Manifest.GameClientBridge.OperationTemplates[1].Safety.RequiresBeforeValue = false - }}, - {name: "undeclared page operation", expected: "undeclared operation template", mutate: func(value *domain.GamePluginManifestRegistration) { - value.Manifest.GameClientBridge.Pages[0].OperationKeys = []string{"missing.operation"} - }}, - {name: "page missing operation permission", expected: "must declare operation template permission", mutate: func(value *domain.GamePluginManifestRegistration) { - value.Manifest.Pages[0].Permissions = []string{"server.game-client.read", "server.remote.access"} - }}, - } - for _, test := range operationTemplateTests { - t.Run("operation template "+test.name, func(t *testing.T) { - invalid := domain.CopyGamePluginManifestRegistration(registration) - test.mutate(&invalid) - err := ValidateGamePluginManifestRegistration(invalid) - if err == nil || !strings.Contains(err.Error(), test.expected) { - t.Fatalf("expected %q rejection, got %v", test.expected, err) - } - }) - } } func TestValidateGamePluginManifestRegistrationRejectsUnsafeCapabilitiesAndPermissions(t *testing.T) { @@ -452,7 +398,7 @@ func validGamePluginManifestRegistration() domain.GamePluginManifestRegistration {Key: "logs", Title: "Logs", Path: "/logs", Permissions: []string{"server.logs.read"}}, }, AI: domain.GamePluginManifestAI{Purposes: []string{"logs.diagnose"}, Mediation: "platform", ConfigWritePolicy: "review-required"}, - ProductionLifecycle: domain.GamePluginProductionLifecycle{Operations: []string{"install", "enable", "disable", "upgrade", "rollback", "retire", "dependency-check"}, DependencyPolicy: "optional", ApprovalRequired: []string{"disable", "rollback", "retire"}}, + ProductionLifecycle: domain.GamePluginProductionLifecycle{Operations: []string{"install", "enable", "disable", "upgrade", "rollback", "retire", "dependency-check"}, DependencyPolicy: "optional"}, }, } } diff --git a/platform_web/acceptance/browser-acceptance.mjs b/platform_web/acceptance/browser-acceptance.mjs index 4f8cebd..ade483c 100644 --- a/platform_web/acceptance/browser-acceptance.mjs +++ b/platform_web/acceptance/browser-acceptance.mjs @@ -565,8 +565,7 @@ async function preparePluginOperations(headers, server, plugin) { serverInstanceId: server.id, operation: "install", targetVersion: plugin.version, - idempotencyKey: `browser-acceptance-plugin-install-${stamp}`, - confirmed: false + idempotencyKey: `browser-acceptance-plugin-install-${stamp}` }, headers ); diff --git a/platform_web/api/client.test.ts b/platform_web/api/client.test.ts index b4bdd74..9477bcf 100644 --- a/platform_web/api/client.test.ts +++ b/platform_web/api/client.test.ts @@ -35,7 +35,7 @@ const plugin: GamePluginResponse = { pages: [{ key: "logs", title: "Logs", path: "/logs", permissions: ["server.logs.read"], bridgeActions: ["logs.query"] }], tags: ["example"], aiPurposes: ["logs.diagnose"], - productionLifecycle: { operations: ["install", "enable", "disable", "upgrade", "rollback", "retire", "dependency-check"], dependencyPolicy: "optional", approvalRequired: ["disable", "rollback", "retire"] }, + productionLifecycle: { operations: ["install", "enable", "disable", "upgrade", "rollback", "retire", "dependency-check"], dependencyPolicy: "optional" }, runtimeProfiles: { lifecycleProfiles: [{ key: "local", mode: "local-process", capabilities: ["process.install", "process.start", "process.stop"] }] }, status: "installed" }; @@ -58,7 +58,7 @@ const marketplacePlugin: MarketplacePluginResponse = { pages: [{ key: "logs", title: "Logs", path: "/logs", permissions: ["server.logs.read"], bridgeActions: ["logs.query"] }], tags: ["example"], aiPurposes: ["logs.diagnose"], - productionLifecycle: { operations: ["install", "enable", "disable", "upgrade", "rollback", "retire", "dependency-check"], dependencyPolicy: "optional", approvalRequired: ["disable", "rollback", "retire"] }, + productionLifecycle: { operations: ["install", "enable", "disable", "upgrade", "rollback", "retire", "dependency-check"], dependencyPolicy: "optional" }, status: "installed", source: "platform-registry" }; diff --git a/platform_web/api/gameClientBridge.test.ts b/platform_web/api/gameClientBridge.test.ts index 6a2ecad..32438df 100644 --- a/platform_web/api/gameClientBridge.test.ts +++ b/platform_web/api/gameClientBridge.test.ts @@ -30,7 +30,6 @@ const pendingCommand = { commandType: "scum.diagnostic.ping", priority: 20, state: "pending", - approvalState: "pending", requesterId: "user-1", expiresAt: later, createdAt: now, @@ -40,7 +39,6 @@ const pendingCommand = { const completedCommand = { ...pendingCommand, state: "succeeded", - approvalState: "approved", resultSummary: "diagnostic completed", result: { status: "succeeded", @@ -80,7 +78,6 @@ const manifestDeclaration: GameClientBridgeManifestResponse = { type: "scum.diagnostic.ping", title: "Diagnostic ping", permission: "server.game-client.command", - approvalLevel: "operator", payloadSchemaRef: "schemas/bridge/commands/diagnostic-ping.request.json", resultSchemaRef: "schemas/bridge/commands/diagnostic-ping.result.json", timeoutSeconds: 30, @@ -126,8 +123,8 @@ const pluginBridgeProjection: Pick & Pic describe("PlatformApiClient Game Client Bridge operator API", () => { afterEach(() => vi.unstubAllGlobals()); - it("types plugin and marketplace manifest declarations with approval metadata", () => { - expect(pluginBridgeProjection.gameClientBridge).toMatchObject({ commands: [{ approvalLevel: "operator" }], queryTemplates: [{ engine: "sqlite" }], companion: { tlsPolicy: "verify-system-roots", sessionMode: "component-session" } }); + it("types plugin and marketplace manifest declarations", () => { + expect(pluginBridgeProjection.gameClientBridge).toMatchObject({ commands: [{ type: "scum.diagnostic.ping" }], queryTemplates: [{ engine: "sqlite" }], companion: { tlsPolicy: "verify-system-roots", sessionMode: "component-session" } }); expect(JSON.stringify(pluginBridgeProjection)).not.toMatch(/authKey|componentKey|sessionToken|credential|secretRef/i); }); @@ -157,7 +154,7 @@ describe("PlatformApiClient Game Client Bridge operator API", () => { await expect(client.getGameClientBridgeStatus("server-1")).resolves.toMatchObject({ available: false, profiles: [{ profileKey: "scum-client" }] }); await expect(client.listGameClientBridgeCommands("server-1", { profileKey: "scum-client", state: "pending", commandType: "scum.diagnostic.ping" })).resolves.toMatchObject({ count: 1 }); - await expect(client.queueGameClientBridgeCommand("server-1", queueRequest)).resolves.toMatchObject({ state: "pending", approvalState: "pending" }); + await expect(client.queueGameClientBridgeCommand("server-1", queueRequest)).resolves.toMatchObject({ state: "pending" }); await expect(client.getGameClientBridgeCommand("server-1", pendingCommand.id)).resolves.toMatchObject({ result: { status: "succeeded", payload: { delivered: true } } }); await expect(client.cancelGameClientBridgeCommand("server-1", pendingCommand.id, { reason: "maintenance window changed" })).resolves.toMatchObject({ state: "cancelled" }); await expect(client.listGameClientBridgeSnapshots("server-1", { profileKey: "scum-client", type: "scum.players", streamKey: "current", observedAfter: now, limit: 20 })).resolves.toMatchObject({ count: 1, items: [{ sequence: 7 }] }); diff --git a/platform_web/api/pluginOperations.test.ts b/platform_web/api/pluginOperations.test.ts index d757649..595c496 100644 --- a/platform_web/api/pluginOperations.test.ts +++ b/platform_web/api/pluginOperations.test.ts @@ -14,7 +14,7 @@ describe("PlatformApiClient plugin operations", () => { const client = new PlatformApiClient("/api/v1", () => "session-token"); await client.listPluginLifecycles({ pluginId: "game.scum" }); - await client.runPluginLifecycle("game.scum", { serverInstanceId: "server-1", operation: "upgrade", targetVersion: "1.2.0", idempotencyKey: "upgrade-1", confirmed: false }); + await client.runPluginLifecycle("game.scum", { serverInstanceId: "server-1", operation: "upgrade", targetVersion: "1.2.0", idempotencyKey: "upgrade-1" }); await client.listAIConfigDiffs({ state: "pending" }); await client.approveAIConfigDiff("diff-1", "approve-1"); @@ -26,6 +26,6 @@ describe("PlatformApiClient plugin operations", () => { ]); const serialized = JSON.stringify(calls); expect(serialized).not.toMatch(/apiKey|token|secret|providerBaseUrl|runSocket|runEndpointUrl|hostPath|credential|dsn|rcon/i); - expect(calls[1]?.body).toEqual({ serverInstanceId: "server-1", operation: "upgrade", targetVersion: "1.2.0", idempotencyKey: "upgrade-1", confirmed: false }); + expect(calls[1]?.body).toEqual({ serverInstanceId: "server-1", operation: "upgrade", targetVersion: "1.2.0", idempotencyKey: "upgrade-1" }); }); }); diff --git a/platform_web/api/types.ts b/platform_web/api/types.ts index 9f74b32..9c6e0e7 100644 --- a/platform_web/api/types.ts +++ b/platform_web/api/types.ts @@ -14,8 +14,6 @@ export type RunUpdatePhase = "queued" | "downloading" | "staged" | "restart-requ export type ServerLifecycleAction = "create" | "start" | "stop" | "status"; export type GameClientBridgeCommandState = "pending" | "claimed" | "succeeded" | "failed" | "cancelled" | "expired" | "unknown"; -export type GameClientBridgeApprovalState = "not_required" | "pending" | "approved" | "rejected"; -export type GameClientBridgeApprovalLevel = "none" | "operator" | "platform-admin"; export type GameClientBridgeResultStatus = "succeeded" | "failed" | "cancelled" | "unknown"; export type GameClientBridgeJsonValue = string | number | boolean | null | GameClientBridgeJsonValue[] | GameClientBridgeJsonObject; @@ -27,7 +25,6 @@ export interface GameClientBridgeCommandDeclarationResponse { type: string; title: string; permission: string; - approvalLevel: GameClientBridgeApprovalLevel; payloadSchemaRef: string; resultSchemaRef?: string; timeoutSeconds: number; @@ -141,7 +138,6 @@ export interface GameClientBridgeCommandResponse { commandType: string; priority: number; state: GameClientBridgeCommandState; - approvalState: GameClientBridgeApprovalState; requesterId?: string; resultSummary?: string; result?: GameClientBridgeCommandResultResponse; @@ -1589,7 +1585,6 @@ export interface AIConfigRecommendationResponse { export interface PluginProductionLifecycleDeclaration { operations: PluginLifecycleOperation[]; dependencyPolicy: "required" | "optional"; - approvalRequired: Array<"disable" | "rollback" | "retire">; } export type PluginLifecycleOperation = "install" | "enable" | "disable" | "upgrade" | "rollback" | "retire" | "dependency-check"; @@ -1611,7 +1606,7 @@ export interface PluginLifecycleInstallationResponse { updatedAt: string; } export interface PluginLifecycleListResponse { items: PluginLifecycleInstallationResponse[]; count: number; } -export interface PluginLifecycleActionRequest { serverInstanceId: string; operation: PluginLifecycleOperation; targetVersion?: string; idempotencyKey: string; confirmed: boolean; } +export interface PluginLifecycleActionRequest { serverInstanceId: string; operation: PluginLifecycleOperation; targetVersion?: string; idempotencyKey: string; } export interface PluginLifecycleActionResponse { status: string; installation: PluginLifecycleInstallationResponse; job: JobResponse; } export interface AIConfigDiffPreviewResponse { diff --git a/platform_web/components/PluginLifecycleWorkbench.tsx b/platform_web/components/PluginLifecycleWorkbench.tsx index a92a2a8..2ee6ec6 100644 --- a/platform_web/components/PluginLifecycleWorkbench.tsx +++ b/platform_web/components/PluginLifecycleWorkbench.tsx @@ -3,7 +3,6 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { platformApiClient } from "../api/client"; import type { PluginLifecycleInstallationResponse, PluginLifecycleOperation, ServerInstanceResponse } from "../api/types"; -import { ConfirmDialog } from "./OperationControls"; import { ErrorState, LoadingState, ResultBadge } from "./StateViews"; interface PluginLifecycleWorkbenchProps { @@ -22,7 +21,6 @@ export function PluginLifecycleWorkbench({ pluginId, pluginName, operations = li const [targetVersion, setTargetVersion] = useState(""); const [loading, setLoading] = useState(true); const [error, setError] = useState(""); - const [confirming, setConfirming] = useState(false); const [busy, setBusy] = useState(false); const [result, setResult] = useState<{ status: "succeeded" | "failed" | "pending"; label: string } | null>(null); @@ -60,16 +58,13 @@ export function PluginLifecycleWorkbench({ pluginId, pluginName, operations = li serverInstanceId: selectedServerId, operation, targetVersion: targetVersion.trim() || undefined, - idempotencyKey: `web:plugin.lifecycle:${pluginId}:${selectedServerId}:${operation}:${Date.now()}`, - confirmed: disruptiveOperations.includes(operation) + idempotencyKey: `web:plugin.lifecycle:${pluginId}:${selectedServerId}:${operation}:${Date.now()}` }); const evidence = [response.job?.id && `任务 ${response.job.id}`].filter(Boolean).join(" · "); setResult({ status: response.status === "queued" || response.status === "accepted" ? "succeeded" : response.status === "deferred" ? "pending" : "failed", label: `${lifecycleOperationLabel(operation)}:${response.status}${evidence ? ` · ${evidence}` : ""}` }); - setConfirming(false); await refresh(); } catch (caught) { setResult({ status: "failed", label: caught instanceof Error ? caught.message : "插件生命周期操作失败" }); - setConfirming(false); } finally { setBusy(false); } @@ -95,7 +90,7 @@ export function PluginLifecycleWorkbench({ pluginId, pluginName, operations = li {operations.map((item) => )} {(operation === "install" || operation === "upgrade") && setTargetVersion(event.target.value)} />} - + {installation ? (
@@ -109,14 +104,11 @@ export function PluginLifecycleWorkbench({ pluginId, pluginName, operations = li ) :

该服务器尚无插件生命周期记录。

} )} - { if (!busy) setConfirming(false); }} onConfirm={() => void submit()} />
); } const lifecycleOperations: PluginLifecycleOperation[] = ["install", "enable", "disable", "upgrade", "rollback", "retire", "dependency-check"]; -const disruptiveOperations: PluginLifecycleOperation[] = ["disable", "rollback", "retire"]; - function lifecycleOperationLabel(operation: PluginLifecycleOperation) { return ({ install: "安装", enable: "启用", disable: "停用", upgrade: "升级", rollback: "回滚", retire: "退役", "dependency-check": "依赖检查" } as Record)[operation]; } diff --git a/platform_web/components/ServerDeploymentWorkflow.test.tsx b/platform_web/components/ServerDeploymentWorkflow.test.tsx index 74ff3d5..0216e46 100644 --- a/platform_web/components/ServerDeploymentWorkflow.test.tsx +++ b/platform_web/components/ServerDeploymentWorkflow.test.tsx @@ -25,7 +25,7 @@ const plugin: GamePluginResponse = { pages: [], tags: [], aiPurposes: [], - productionLifecycle: { operations: ["install"], dependencyPolicy: "optional", approvalRequired: [] }, + productionLifecycle: { operations: ["install"], dependencyPolicy: "optional" }, status: "installed", runtimeProfiles: { transportProfiles: [{ key: "rcon", kind: "rcon", targetKey: "rcon.password", capabilities: ["remote.run.rcon.command"] }], diff --git a/platform_web/pages/PluginPageHostPage.test.tsx b/platform_web/pages/PluginPageHostPage.test.tsx index f397ed3..00d3dff 100644 --- a/platform_web/pages/PluginPageHostPage.test.tsx +++ b/platform_web/pages/PluginPageHostPage.test.tsx @@ -69,9 +69,9 @@ const plugin: GamePluginResponse = { }], tags: ["scum"], aiPurposes: [], - productionLifecycle: { operations: ["install", "enable", "disable", "upgrade", "rollback", "retire", "dependency-check"], dependencyPolicy: "required", approvalRequired: ["disable", "rollback", "retire"] }, + productionLifecycle: { operations: ["install", "enable", "disable", "upgrade", "rollback", "retire", "dependency-check"], dependencyPolicy: "required" }, gameClientBridge: { - commands: [{ type: "diagnostic.ping", title: "Diagnostic ping", permission: "server.game-client.command", approvalLevel: "operator", payloadSchemaRef: "schemas/bridge/diagnostic-ping.json", timeoutSeconds: 30, maxPayloadBytes: 4096 }], + commands: [{ type: "diagnostic.ping", title: "Diagnostic ping", permission: "server.game-client.command", payloadSchemaRef: "schemas/bridge/diagnostic-ping.json", timeoutSeconds: 30, maxPayloadBytes: 4096 }], snapshots: [{ type: "companion.health", schemaVersion: "1", schemaRef: "schemas/bridge/health.json", keepForSeconds: 3600, maxRecords: 24 }], commandRetentionSeconds: 86400, maxCommands: 1000, diff --git a/platform_web/pages/PluginsPage.test.tsx b/platform_web/pages/PluginsPage.test.tsx index 3d0f601..fdd2740 100644 --- a/platform_web/pages/PluginsPage.test.tsx +++ b/platform_web/pages/PluginsPage.test.tsx @@ -23,7 +23,7 @@ const marketplacePlugin: MarketplacePluginResponse = { pages: [{ key: "logs", title: "Logs", path: "/logs", permissions: ["server.logs.read"], bridgeActions: ["logs.query"] }], tags: ["example"], aiPurposes: ["logs.diagnose"], - productionLifecycle: { operations: ["install", "enable", "disable", "upgrade", "rollback", "retire", "dependency-check"], dependencyPolicy: "optional", approvalRequired: ["disable", "rollback", "retire"] }, + productionLifecycle: { operations: ["install", "enable", "disable", "upgrade", "rollback", "retire", "dependency-check"], dependencyPolicy: "optional" }, runtimeProfiles: { dllExtensions: [{ key: "scum-simple-rcon-ue4ss", diff --git a/platform_web/schemas/gameClientBridge.test.ts b/platform_web/schemas/gameClientBridge.test.ts index 5042cc5..ead801c 100644 --- a/platform_web/schemas/gameClientBridge.test.ts +++ b/platform_web/schemas/gameClientBridge.test.ts @@ -16,7 +16,6 @@ const safeCommand = { commandType: "scum.player.lookup", priority: 10, state: "succeeded", - approvalState: "not_required", requesterId: "user-1", result: { status: "succeeded", summary: "player found", payload: { found: true }, completedAt: now }, expiresAt: now, @@ -45,14 +44,14 @@ const safeSnapshotList = { }; describe("Game Client Bridge safe projection schema", () => { - it("preserves declarations, approval, result, retention and typed snapshot payloads", () => { + it("preserves declarations, result, retention and typed snapshot payloads", () => { expect(parseSafeGameClientBridgeStatus({ serverInstanceId: "server-1", pluginId: "game.scum", available: true, profiles: [{ pluginId: "game.scum", profileKey: "scum-client", available: true, commandTypes: ["scum.player.lookup"], snapshotTypes: ["scum.players"], queryTemplateKeys: ["scum.player.search"] }] })).toMatchObject({ available: true, profiles: [{ queryTemplateKeys: ["scum.player.search"] }] }); - expect(parseSafeGameClientBridgeCommand(safeCommand)).toMatchObject({ approvalState: "not_required", result: { payload: { found: true } } }); + expect(parseSafeGameClientBridgeCommand(safeCommand)).toMatchObject({ result: { payload: { found: true } } }); expect(parseSafeGameClientBridgeSnapshotList(safeSnapshotList)).toMatchObject({ count: 1, items: [{ retention: { maxRecords: 24 } }] }); expect(parseSafeGameClientBridgeSnapshotList({ ...safeSnapshotList, diff --git a/platform_web/schemas/gameClientBridge.ts b/platform_web/schemas/gameClientBridge.ts index 959b1f9..1e3d566 100644 --- a/platform_web/schemas/gameClientBridge.ts +++ b/platform_web/schemas/gameClientBridge.ts @@ -1,5 +1,4 @@ import type { - GameClientBridgeApprovalState, GameClientBridgeCancelResponse, GameClientBridgeCommandCancellationResponse, GameClientBridgeCommandListResponse, @@ -16,7 +15,6 @@ import type { } from "../api/types"; const commandStates = new Set(["pending", "claimed", "succeeded", "failed", "cancelled", "expired", "unknown"]); -const approvalStates = new Set(["not_required", "pending", "approved", "rejected"]); const resultStatuses = new Set(["succeeded", "failed", "cancelled", "unknown"]); const forbiddenKeys = new Set([ "apikey", @@ -84,7 +82,6 @@ export function parseSafeGameClientBridgeCommand(value: unknown): GameClientBrid commandType: string(record.commandType, "commandType"), priority: number(record.priority, "priority"), state: commandState(record.state), - approvalState: approvalState(record.approvalState), expiresAt: string(record.expiresAt, "expiresAt"), createdAt: string(record.createdAt, "createdAt"), updatedAt: string(record.updatedAt, "updatedAt") @@ -284,12 +281,6 @@ function commandState(value: unknown): GameClientBridgeCommandState { return parsed; } -function approvalState(value: unknown): GameClientBridgeApprovalState { - const parsed = string(value, "approvalState") as GameClientBridgeApprovalState; - if (!approvalStates.has(parsed)) throw new Error("Game Client Bridge approval state is invalid"); - return parsed; -} - function resultStatus(value: unknown): GameClientBridgeResultStatus { const parsed = string(value, "result.status") as GameClientBridgeResultStatus; if (!resultStatuses.has(parsed)) throw new Error("Game Client Bridge result status is invalid"); diff --git a/platform_web/schemas/serverManagement.test.ts b/platform_web/schemas/serverManagement.test.ts index 1cb2cef..9701e73 100644 --- a/platform_web/schemas/serverManagement.test.ts +++ b/platform_web/schemas/serverManagement.test.ts @@ -23,7 +23,7 @@ const plugin: GamePluginResponse = { pages: [], tags: [], aiPurposes: [], - productionLifecycle: { operations: ["install", "enable", "disable", "upgrade", "rollback", "retire", "dependency-check"], dependencyPolicy: "optional", approvalRequired: ["disable", "rollback", "retire"] }, + productionLifecycle: { operations: ["install", "enable", "disable", "upgrade", "rollback", "retire", "dependency-check"], dependencyPolicy: "optional" }, status: "installed", runtimeProfiles: { discovery: [{ key: "root-check", kind: "file.exists", targetKey: "server-root", required: true }], diff --git a/plugins/examples/dev-game-plugin/manifest.json b/plugins/examples/dev-game-plugin/manifest.json index 7aa50f7..dd41b17 100644 --- a/plugins/examples/dev-game-plugin/manifest.json +++ b/plugins/examples/dev-game-plugin/manifest.json @@ -5,11 +5,18 @@ "description": "Development game management plugin for local registry and lifecycle testing.", "version": "0.1.0", "kind": "game-plugin", - "tags": ["example", "development"], + "tags": [ + "example", + "development" + ], "server": { "type": "example", "displayName": "Example Server", - "supportedOS": ["windows", "linux", "darwin"], + "supportedOS": [ + "windows", + "linux", + "darwin" + ], "createFormSchema": "schemas/create-form.schema.json" }, "capabilities": [ @@ -57,18 +64,46 @@ "status": "actions/status.json" }, "assetFiles": [ - { "path": "actions/install.json", "mode": 384 }, - { "path": "actions/start.json", "mode": 384 }, - { "path": "actions/stop.json", "mode": 384 }, - { "path": "actions/restart.json", "mode": 384 }, - { "path": "actions/status.json", "mode": 384 }, - { "path": "bin/install-server", "mode": 448 }, - { "path": "bin/game-server", "mode": 448 } + { + "path": "actions/install.json", + "mode": 384 + }, + { + "path": "actions/start.json", + "mode": 384 + }, + { + "path": "actions/stop.json", + "mode": 384 + }, + { + "path": "actions/restart.json", + "mode": 384 + }, + { + "path": "actions/status.json", + "mode": 384 + }, + { + "path": "bin/install-server", + "mode": 448 + }, + { + "path": "bin/game-server", + "mode": 448 + } ], "productionLifecycle": { - "operations": ["install", "enable", "disable", "upgrade", "rollback", "retire", "dependency-check"], - "dependencyPolicy": "optional", - "approvalRequired": ["disable", "rollback", "retire"] + "operations": [ + "install", + "enable", + "disable", + "upgrade", + "rollback", + "retire", + "dependency-check" + ], + "dependencyPolicy": "optional" }, "pages": [ { @@ -78,8 +113,12 @@ "bundleKey": "dev-game-plugin", "bundleVersion": "1.0.0", "bundleIntegritySha256": "sha256:1111111111111111111111111111111111111111111111111111111111111111", - "permissions": ["server.read"], - "bridgeActions": ["server.instances.read"] + "permissions": [ + "server.read" + ], + "bridgeActions": [ + "server.instances.read" + ] }, { "key": "config", @@ -88,8 +127,15 @@ "bundleKey": "dev-game-plugin", "bundleVersion": "1.0.0", "bundleIntegritySha256": "sha256:1111111111111111111111111111111111111111111111111111111111111111", - "permissions": ["server.files.read", "server.files.write", "ai.invoke"], - "bridgeActions": ["files.request", "ai.invoke"] + "permissions": [ + "server.files.read", + "server.files.write", + "ai.invoke" + ], + "bridgeActions": [ + "files.request", + "ai.invoke" + ] }, { "key": "logs", @@ -98,12 +144,21 @@ "bundleKey": "dev-game-plugin", "bundleVersion": "1.0.0", "bundleIntegritySha256": "sha256:1111111111111111111111111111111111111111111111111111111111111111", - "permissions": ["server.logs.read", "ai.invoke"], - "bridgeActions": ["logs.query", "ai.invoke"] + "permissions": [ + "server.logs.read", + "ai.invoke" + ], + "bridgeActions": [ + "logs.query", + "ai.invoke" + ] } ], "ai": { - "purposes": ["config.suggest", "logs.diagnose"], + "purposes": [ + "config.suggest", + "logs.diagnose" + ], "mediation": "platform", "configWritePolicy": "review-required" } diff --git a/plugins/examples/minecraft-server-plugin/manifest.json b/plugins/examples/minecraft-server-plugin/manifest.json index e9cc7af..5230e48 100644 --- a/plugins/examples/minecraft-server-plugin/manifest.json +++ b/plugins/examples/minecraft-server-plugin/manifest.json @@ -21,9 +21,29 @@ ], "createFormSchema": "schemas/create-form.schema.json", "createFields": [ - { "key": "serverName", "label": "服务器名称", "type": "text", "required": true, "configKey": "motd" }, - { "key": "gamePort", "label": "游戏端口", "type": "port", "required": true, "defaultValue": "25565", "configKey": "serverPort" }, - { "key": "rconPort", "label": "RCON 端口", "type": "port", "required": true, "defaultValue": "25575", "configKey": "rconPort" } + { + "key": "serverName", + "label": "服务器名称", + "type": "text", + "required": true, + "configKey": "motd" + }, + { + "key": "gamePort", + "label": "游戏端口", + "type": "port", + "required": true, + "defaultValue": "25565", + "configKey": "serverPort" + }, + { + "key": "rconPort", + "label": "RCON 端口", + "type": "port", + "required": true, + "defaultValue": "25575", + "configKey": "rconPort" + } ] }, "capabilities": [ @@ -99,18 +119,46 @@ "status": "actions/status.json" }, "assetFiles": [ - { "path": "actions/install.json", "mode": 384 }, - { "path": "actions/start.json", "mode": 384 }, - { "path": "actions/stop.json", "mode": 384 }, - { "path": "actions/restart.json", "mode": 384 }, - { "path": "actions/status.json", "mode": 384 }, - { "path": "bin/install-server", "mode": 448 }, - { "path": "bin/game-server", "mode": 448 } + { + "path": "actions/install.json", + "mode": 384 + }, + { + "path": "actions/start.json", + "mode": 384 + }, + { + "path": "actions/stop.json", + "mode": 384 + }, + { + "path": "actions/restart.json", + "mode": 384 + }, + { + "path": "actions/status.json", + "mode": 384 + }, + { + "path": "bin/install-server", + "mode": 448 + }, + { + "path": "bin/game-server", + "mode": 448 + } ], "productionLifecycle": { - "operations": ["install", "enable", "disable", "upgrade", "rollback", "retire", "dependency-check"], - "dependencyPolicy": "required", - "approvalRequired": ["disable", "rollback", "retire"] + "operations": [ + "install", + "enable", + "disable", + "upgrade", + "rollback", + "retire", + "dependency-check" + ], + "dependencyPolicy": "required" }, "pages": [ { @@ -153,20 +201,92 @@ "fileWorkspace": { "defaultDirectoryKey": "minecraft-config", "directories": [ - { "key": "minecraft-config", "label": "服务器配置", "scope": "config" }, - { "key": "minecraft-logs", "label": "日志文件", "scope": "logs" } + { + "key": "minecraft-config", + "label": "服务器配置", + "scope": "config" + }, + { + "key": "minecraft-logs", + "label": "日志文件", + "scope": "logs" + } ], "files": [ - { "key": "server-properties", "directoryKey": "minecraft-config", "label": "server.properties", "kind": "config", "editable": true }, - { "key": "ops-json", "directoryKey": "minecraft-config", "label": "ops.json", "kind": "config", "editable": true }, - { "key": "whitelist-json", "directoryKey": "minecraft-config", "label": "whitelist.json", "kind": "config", "editable": true }, - { "key": "minecraft-latest-log", "directoryKey": "minecraft-logs", "label": "latest.log", "kind": "log", "streamKey": "minecraft.latest" } + { + "key": "server-properties", + "directoryKey": "minecraft-config", + "label": "server.properties", + "kind": "config", + "editable": true + }, + { + "key": "ops-json", + "directoryKey": "minecraft-config", + "label": "ops.json", + "kind": "config", + "editable": true + }, + { + "key": "whitelist-json", + "directoryKey": "minecraft-config", + "label": "whitelist.json", + "kind": "config", + "editable": true + }, + { + "key": "minecraft-latest-log", + "directoryKey": "minecraft-logs", + "label": "latest.log", + "kind": "log", + "streamKey": "minecraft.latest" + } ], "configFields": [ - { "key": "motd", "fileKey": "server-properties", "configKey": "motd", "label": "服务器名称", "description": "显示在 Minecraft 服务器列表中的 MOTD。", "control": "text", "defaultValue": "Minecraft Server", "restartImpact": "restart-required" }, - { "key": "server-port", "fileKey": "server-properties", "configKey": "server-port", "label": "游戏端口", "description": "Minecraft Java 客户端连接端口。", "control": "port", "minimum": 1, "maximum": 65535, "defaultValue": "25565", "restartImpact": "restart-required" }, - { "key": "max-players", "fileKey": "server-properties", "configKey": "max-players", "label": "最大玩家数", "description": "允许同时进入服务器的玩家上限。", "control": "number", "minimum": 1, "maximum": 200, "defaultValue": "20", "restartImpact": "restart-required" }, - { "key": "online-mode", "fileKey": "server-properties", "configKey": "online-mode", "label": "正版验证", "description": "是否启用 Mojang 在线身份验证。", "control": "boolean", "defaultValue": "true", "restartImpact": "restart-required" } + { + "key": "motd", + "fileKey": "server-properties", + "configKey": "motd", + "label": "服务器名称", + "description": "显示在 Minecraft 服务器列表中的 MOTD。", + "control": "text", + "defaultValue": "Minecraft Server", + "restartImpact": "restart-required" + }, + { + "key": "server-port", + "fileKey": "server-properties", + "configKey": "server-port", + "label": "游戏端口", + "description": "Minecraft Java 客户端连接端口。", + "control": "port", + "minimum": 1, + "maximum": 65535, + "defaultValue": "25565", + "restartImpact": "restart-required" + }, + { + "key": "max-players", + "fileKey": "server-properties", + "configKey": "max-players", + "label": "最大玩家数", + "description": "允许同时进入服务器的玩家上限。", + "control": "number", + "minimum": 1, + "maximum": 200, + "defaultValue": "20", + "restartImpact": "restart-required" + }, + { + "key": "online-mode", + "fileKey": "server-properties", + "configKey": "online-mode", + "label": "正版验证", + "description": "是否启用 Mojang 在线身份验证。", + "control": "boolean", + "defaultValue": "true", + "restartImpact": "restart-required" + } ] }, "ai": { diff --git a/plugins/examples/scum-server-plugin/manifest.json b/plugins/examples/scum-server-plugin/manifest.json index d1ec79c..d947cc8 100644 --- a/plugins/examples/scum-server-plugin/manifest.json +++ b/plugins/examples/scum-server-plugin/manifest.json @@ -74,7 +74,6 @@ "remote.run.process.start", "remote.run.process.stop", "remote.run.logs.transfer", - "remote.run.protected.sql", "remote.run.rcon.command", "remote.run.program.command", "client-manager.deploy", @@ -99,7 +98,6 @@ "remote.run.process.start", "remote.run.process.stop", "remote.run.logs.transfer", - "remote.run.protected.sql", "remote.run.rcon.command", "remote.run.program.command" ], @@ -127,7 +125,6 @@ "type": "companion.diagnostics", "title": "Collect companion diagnostics", "permission": "server.game-client.read", - "approvalLevel": "none", "payloadSchemaRef": "schemas/bridge/diagnostics.payload.schema.json", "resultSchemaRef": "schemas/bridge/diagnostics.result.schema.json", "timeoutSeconds": 30, @@ -137,7 +134,6 @@ "type": "player.lookup", "title": "Look up SCUM player", "permission": "server.game-client.read", - "approvalLevel": "none", "payloadSchemaRef": "schemas/bridge/player-lookup.payload.schema.json", "resultSchemaRef": "schemas/bridge/player-lookup.result.schema.json", "timeoutSeconds": 30, @@ -147,7 +143,6 @@ "type": "reward.deliver", "title": "Deliver SCUM reward", "permission": "server.game-client.command", - "approvalLevel": "none", "payloadSchemaRef": "schemas/bridge/reward-deliver.payload.schema.json", "resultSchemaRef": "schemas/bridge/reward-deliver.result.schema.json", "timeoutSeconds": 60, @@ -157,7 +152,6 @@ "type": "player.notify", "title": "Notify SCUM player about approved gift", "permission": "server.game-client.command", - "approvalLevel": "none", "payloadSchemaRef": "schemas/bridge/player-notify.payload.schema.json", "resultSchemaRef": "schemas/bridge/player-notify.result.schema.json", "timeoutSeconds": 60, @@ -167,7 +161,6 @@ "type": "vehicle.spawn", "title": "Spawn catalogued SCUM vehicle", "permission": "server.game-client.command", - "approvalLevel": "none", "payloadSchemaRef": "schemas/bridge/vehicle-spawn.payload.schema.json", "resultSchemaRef": "schemas/bridge/vehicle-spawn.result.schema.json", "timeoutSeconds": 60, @@ -177,7 +170,6 @@ "type": "event.start", "title": "Start SCUM event", "permission": "server.game-client.command", - "approvalLevel": "none", "payloadSchemaRef": "schemas/bridge/event-start.payload.schema.json", "resultSchemaRef": "schemas/bridge/event-start.result.schema.json", "timeoutSeconds": 60, @@ -187,7 +179,6 @@ "type": "restart.prepare", "title": "Prepare SCUM restart", "permission": "server.game-client.maintenance", - "approvalLevel": "none", "payloadSchemaRef": "schemas/bridge/restart-prepare.payload.schema.json", "resultSchemaRef": "schemas/bridge/restart-prepare.result.schema.json", "timeoutSeconds": 120, @@ -197,7 +188,6 @@ "type": "maintenance.prepare", "title": "Prepare SCUM maintenance", "permission": "server.game-client.maintenance", - "approvalLevel": "none", "payloadSchemaRef": "schemas/bridge/maintenance-prepare.payload.schema.json", "resultSchemaRef": "schemas/bridge/maintenance-prepare.result.schema.json", "timeoutSeconds": 120, @@ -207,7 +197,6 @@ "type": "game-state.patch", "title": "Patch SCUM player state", "permission": "server.game-client.maintenance", - "approvalLevel": "none", "payloadSchemaRef": "schemas/bridge/game-state-patch.payload.schema.json", "resultSchemaRef": "schemas/bridge/game-state-patch.result.schema.json", "timeoutSeconds": 120, @@ -286,9 +275,26 @@ "pollIntervalSeconds": 3, "rowTarget": { "collection": "scum_users", - "upsertKeys": ["steamId"], + "upsertKeys": [ + "steamId" + ], "writeMode": "merge", - "columnMappings": { "userProfileId": "userProfileId", "steamId": "steamId", "gamePlayerId": "gamePlayerId", "displayName": "displayName", "squadId": "squadId", "squadName": "squadName", "famePoints": "famePoints", "normalBalance": "normalBalance", "goldBalance": "goldBalance", "x": "x", "y": "y", "z": "z", "lastLoginTime": "lastLoginTime", "lastSaveTime": "lastSaveTime" } + "columnMappings": { + "userProfileId": "userProfileId", + "steamId": "steamId", + "gamePlayerId": "gamePlayerId", + "displayName": "displayName", + "squadId": "squadId", + "squadName": "squadName", + "famePoints": "famePoints", + "normalBalance": "normalBalance", + "goldBalance": "goldBalance", + "x": "x", + "y": "y", + "z": "z", + "lastLoginTime": "lastLoginTime", + "lastSaveTime": "lastSaveTime" + } }, "maxRows": 500, "timeoutSeconds": 15 @@ -306,9 +312,22 @@ "pollIntervalSeconds": 1800, "rowTarget": { "collection": "scum_squads", - "upsertKeys": ["squadId"], + "upsertKeys": [ + "squadId" + ], "writeMode": "replace", - "columnMappings": { "squadId": "squadId", "name": "name", "leaderProfileId": "leaderProfileId", "leaderPlayerId": "leaderPlayerId", "memberCount": "memberCount", "score": "score", "memberLimit": "memberLimit", "message": "message", "info": "info", "lastMemberLoginTime": "lastMemberLoginTime" } + "columnMappings": { + "squadId": "squadId", + "name": "name", + "leaderProfileId": "leaderProfileId", + "leaderPlayerId": "leaderPlayerId", + "memberCount": "memberCount", + "score": "score", + "memberLimit": "memberLimit", + "message": "message", + "info": "info", + "lastMemberLoginTime": "lastMemberLoginTime" + } }, "maxRows": 500, "timeoutSeconds": 15 @@ -326,9 +345,20 @@ "pollIntervalSeconds": 1800, "rowTarget": { "collection": "scum_squad_members", - "upsertKeys": ["squadId", "steamId"], + "upsertKeys": [ + "squadId", + "steamId" + ], "writeMode": "replace", - "columnMappings": { "squadId": "squadId", "userProfileId": "userProfileId", "gamePlayerId": "gamePlayerId", "steamId": "steamId", "displayName": "displayName", "rank": "rank", "isLeader": "isLeader" } + "columnMappings": { + "squadId": "squadId", + "userProfileId": "userProfileId", + "gamePlayerId": "gamePlayerId", + "steamId": "steamId", + "displayName": "displayName", + "rank": "rank", + "isLeader": "isLeader" + } }, "maxRows": 500, "timeoutSeconds": 15 @@ -346,9 +376,21 @@ "pollIntervalSeconds": 3, "rowTarget": { "collection": "scum_vehicles", - "upsertKeys": ["vehicleId"], + "upsertKeys": [ + "vehicleId" + ], "writeMode": "replace", - "columnMappings": { "vehicleId": "vehicleId", "entityId": "entityId", "className": "className", "label": "label", "x": "x", "y": "y", "z": "z", "lastAccessTime": "lastAccessTime", "isFunctional": "isFunctional" } + "columnMappings": { + "vehicleId": "vehicleId", + "entityId": "entityId", + "className": "className", + "label": "label", + "x": "x", + "y": "y", + "z": "z", + "lastAccessTime": "lastAccessTime", + "isFunctional": "isFunctional" + } }, "maxRows": 500, "timeoutSeconds": 15 @@ -366,9 +408,25 @@ "pollIntervalSeconds": 1800, "rowTarget": { "collection": "scum_flags", - "upsertKeys": ["flagId"], + "upsertKeys": [ + "flagId" + ], "writeMode": "replace", - "columnMappings": { "flagId": "flagId", "entityId": "entityId", "baseId": "baseId", "ownerProfileId": "ownerProfileId", "ownerPlayerId": "ownerPlayerId", "ownerSquadId": "ownerSquadId", "ownerSquadName": "ownerSquadName", "overtakerProfileId": "overtakerProfileId", "overtakeEndTime": "overtakeEndTime", "ownershipConfidence": "ownershipConfidence", "x": "x", "y": "y", "z": "z" } + "columnMappings": { + "flagId": "flagId", + "entityId": "entityId", + "baseId": "baseId", + "ownerProfileId": "ownerProfileId", + "ownerPlayerId": "ownerPlayerId", + "ownerSquadId": "ownerSquadId", + "ownerSquadName": "ownerSquadName", + "overtakerProfileId": "overtakerProfileId", + "overtakeEndTime": "overtakeEndTime", + "ownershipConfidence": "ownershipConfidence", + "x": "x", + "y": "y", + "z": "z" + } }, "maxRows": 500, "timeoutSeconds": 15 @@ -386,9 +444,24 @@ "pollIntervalSeconds": 3, "rowTarget": { "collection": "scum_map_points", - "upsertKeys": ["subjectType", "subjectId"], + "upsertKeys": [ + "subjectType", + "subjectId" + ], "writeMode": "replace", - "columnMappings": { "subjectType": "subjectType", "subjectId": "subjectId", "userProfileId": "userProfileId", "gamePlayerId": "gamePlayerId", "vehicleId": "vehicleId", "entityId": "entityId", "baseId": "baseId", "x": "x", "y": "y", "z": "z", "observedAt": "observedAt" } + "columnMappings": { + "subjectType": "subjectType", + "subjectId": "subjectId", + "userProfileId": "userProfileId", + "gamePlayerId": "gamePlayerId", + "vehicleId": "vehicleId", + "entityId": "entityId", + "baseId": "baseId", + "x": "x", + "y": "y", + "z": "z", + "observedAt": "observedAt" + } }, "maxRows": 500, "timeoutSeconds": 15 @@ -406,9 +479,22 @@ "pollIntervalSeconds": 1800, "rowTarget": { "collection": "scum_tasks", - "upsertKeys": ["taskRecordId"], + "upsertKeys": [ + "taskRecordId" + ], "writeMode": "replace", - "columnMappings": { "taskRecordId": "taskRecordId", "taskKind": "taskKind", "userProfileId": "userProfileId", "mapId": "mapId", "trackingDataSetId": "trackingDataSetId", "dataAssetPath": "dataAssetPath", "sequenceIndex": "sequenceIndex", "isTracked": "isTracked", "state": "state", "completionDeadline": "completionDeadline" } + "columnMappings": { + "taskRecordId": "taskRecordId", + "taskKind": "taskKind", + "userProfileId": "userProfileId", + "mapId": "mapId", + "trackingDataSetId": "trackingDataSetId", + "dataAssetPath": "dataAssetPath", + "sequenceIndex": "sequenceIndex", + "isTracked": "isTracked", + "state": "state", + "completionDeadline": "completionDeadline" + } }, "maxRows": 500, "timeoutSeconds": 15 @@ -426,9 +512,25 @@ "pollIntervalSeconds": 1800, "rowTarget": { "collection": "scum_native_event_rounds", - "upsertKeys": ["eventRecordId"], + "upsertKeys": [ + "eventRecordId" + ], "writeMode": "replace", - "columnMappings": { "eventRecordId": "eventRecordId", "eventId": "eventId", "roundId": "roundId", "userProfileId": "userProfileId", "startTime": "startTime", "endTime": "endTime", "state": "state", "score": "score", "enemyKills": "enemyKills", "teamKills": "teamKills", "deaths": "deaths", "assists": "assists", "headshots": "headshots" } + "columnMappings": { + "eventRecordId": "eventRecordId", + "eventId": "eventId", + "roundId": "roundId", + "userProfileId": "userProfileId", + "startTime": "startTime", + "endTime": "endTime", + "state": "state", + "score": "score", + "enemyKills": "enemyKills", + "teamKills": "teamKills", + "deaths": "deaths", + "assists": "assists", + "headshots": "headshots" + } }, "maxRows": 500, "timeoutSeconds": 15 @@ -446,9 +548,17 @@ "pollIntervalSeconds": 1800, "rowTarget": { "collection": "scum_timed_gift_events", - "upsertKeys": ["timedGiftId"], + "upsertKeys": [ + "timedGiftId" + ], "writeMode": "replace", - "columnMappings": { "timedGiftId": "timedGiftId", "userProfileId": "userProfileId", "mapId": "mapId", "spawnTime": "spawnTime", "spawnAt": "spawnAt" } + "columnMappings": { + "timedGiftId": "timedGiftId", + "userProfileId": "userProfileId", + "mapId": "mapId", + "spawnTime": "spawnTime", + "spawnAt": "spawnAt" + } }, "maxRows": 500, "timeoutSeconds": 15 @@ -457,18 +567,35 @@ "logProjections": [ { "key": "scum.battleye.login", - "streamKeys": ["scum.console.stdout"], + "streamKeys": [ + "scum.console.stdout" + ], "steps": [ - { "pattern": "Player \"(?P[^\"]+)\" reported as player (?P\\d+)" }, - { "pattern": "Player (?P\\d+) SteamID \\(assumed\\): (?P\\d+)" } + { + "pattern": "Player \"(?P[^\"]+)\" reported as player (?P\\d+)" + }, + { + "pattern": "Player (?P\\d+) SteamID \\(assumed\\): (?P\\d+)" + } + ], + "correlationFields": [ + "slot" ], - "correlationFields": ["slot"], "maxInterveningLines": 8, "target": { "collection": "scum_users", - "upsertKeys": ["steamId"], - "captureMappings": { "steamId": "steamId", "displayName": "displayName", "slot": "slot" }, - "fixedValues": { "online": "true", "source": "process.stdout" }, + "upsertKeys": [ + "steamId" + ], + "captureMappings": { + "steamId": "steamId", + "displayName": "displayName", + "slot": "slot" + }, + "fixedValues": { + "online": "true", + "source": "process.stdout" + }, "observedAtField": "lastLoginObservedAt" }, "presence": { @@ -476,9 +603,18 @@ "activeWindowSeconds": 600, "activityTarget": { "collection": "scum_activity_events", - "upsertKeys": ["steamId", "observedAt"], - "captureMappings": { "steamId": "steamId", "displayName": "displayName" }, - "fixedValues": { "eventType": "login", "source": "process.stdout" }, + "upsertKeys": [ + "steamId", + "observedAt" + ], + "captureMappings": { + "steamId": "steamId", + "displayName": "displayName" + }, + "fixedValues": { + "eventType": "login", + "source": "process.stdout" + }, "observedAtField": "observedAt" } } @@ -488,109 +624,16 @@ { "key": "scum-db-v57", "databaseUserVersion": 57, - "logParserRefs": ["data-packs/scum-db-v57/log-parsers.json"], - "configMapRefs": ["data-packs/scum-db-v57/config-maps.json"], - "dataRefs": ["data-packs/scum-db-v57/gift-items.json", "data-packs/scum-db-v57/map-geometry.json"] - } - ], - "operationTemplates": [ - { - "key": "player.fame.set", - "title": "Set SCUM player fame through RCON", - "permission": "server.game-client.command", - "approvalLevel": "none", - "kind": "rcon", - "transportKey": "scum-management", - "targetKey": "scum-management", - "payloadSchemaRef": "schemas/bridge/player-fame-set.payload.schema.json", - "resultSchemaRef": "schemas/bridge/player-rcon-set.result.schema.json", - "timeoutSeconds": 60, - "maxPayloadBytes": 2048 - }, - { - "key": "player.currency.normal.set", - "title": "Set SCUM normal currency through RCON", - "permission": "server.game-client.command", - "approvalLevel": "none", - "kind": "rcon", - "transportKey": "scum-management", - "targetKey": "scum-management", - "payloadSchemaRef": "schemas/bridge/player-currency-set.payload.schema.json", - "resultSchemaRef": "schemas/bridge/player-rcon-set.result.schema.json", - "timeoutSeconds": 60, - "maxPayloadBytes": 2048 - }, - { - "key": "player.currency.gold.set", - "title": "Set SCUM gold currency through RCON", - "permission": "server.game-client.command", - "approvalLevel": "none", - "kind": "rcon", - "transportKey": "scum-management", - "targetKey": "scum-management", - "payloadSchemaRef": "schemas/bridge/player-currency-set.payload.schema.json", - "resultSchemaRef": "schemas/bridge/player-rcon-set.result.schema.json", - "timeoutSeconds": 60, - "maxPayloadBytes": 2048 - }, - { - "key": "player.notify", - "title": "Notify SCUM player through RCON chat", - "permission": "server.game-client.command", - "approvalLevel": "none", - "kind": "rcon", - "transportKey": "scum-management", - "targetKey": "scum-management", - "payloadSchemaRef": "schemas/bridge/player-notify.payload.schema.json", - "resultSchemaRef": "schemas/bridge/player-notify.result.schema.json", - "timeoutSeconds": 60, - "maxPayloadBytes": 2048 - }, - { - "key": "reward.deliver", - "title": "Deliver SCUM reward through typed command workflow", - "permission": "server.game-client.command", - "approvalLevel": "none", - "kind": "rcon", - "transportKey": "scum-management", - "targetKey": "scum-management", - "payloadSchemaRef": "schemas/bridge/reward-deliver.payload.schema.json", - "resultSchemaRef": "schemas/bridge/reward-deliver.result.schema.json", - "timeoutSeconds": 60, - "maxPayloadBytes": 4096 - }, - { - "key": "player.attribute.855.set", - "title": "Set SCUM DB-only player attribute 855", - "permission": "server.game-client.maintenance", - "approvalLevel": "platform-admin", - "kind": "sqlite-mutation", - "transportKey": "scum-database", - "targetKey": "scum-database", - "payloadSchemaRef": "schemas/bridge/player-attribute-855-set.payload.schema.json", - "resultSchemaRef": "schemas/bridge/player-attribute-855-set.result.schema.json", - "confirmationSchemaRef": "schemas/bridge/player-attribute-855-set.confirmation.schema.json", - "timeoutSeconds": 120, - "maxPayloadBytes": 4096, - "maxRowsAffected": 1, - "mutation": { - "fieldKey": "855", - "tableKey": "prisoner", - "identityKey": "user_profile_id", - "valueKey": "value", - "confirmationQueryKey": "scum.player.profile", - "allowedValueType": "integer", - "minValue": 0, - "maxValue": 100000 - }, - "safety": { - "requiresApproval": true, - "requiresOfflinePlayer": true, - "requiresMaintenanceWindow": true, - "requiresBeforeValue": true, - "requiresConfirmation": true, - "backupRequired": true - } + "logParserRefs": [ + "data-packs/scum-db-v57/log-parsers.json" + ], + "configMapRefs": [ + "data-packs/scum-db-v57/config-maps.json" + ], + "dataRefs": [ + "data-packs/scum-db-v57/gift-items.json", + "data-packs/scum-db-v57/map-geometry.json" + ] } ], "commandRetentionSeconds": 604800, @@ -652,13 +695,6 @@ "scum.player.profile", "scum.positions" ], - "operationKeys": [ - "player.fame.set", - "player.currency.normal.set", - "player.currency.gold.set", - "player.notify", - "player.attribute.855.set" - ], "featureKeys": [ "player.intelligence", "state.patch" @@ -707,10 +743,6 @@ "commandTypes": [ "reward.deliver" ], - "operationKeys": [ - "reward.deliver", - "player.notify" - ], "featureKeys": [ "reward.delivery" ] @@ -873,12 +905,7 @@ "retire", "dependency-check" ], - "dependencyPolicy": "required", - "approvalRequired": [ - "disable", - "rollback", - "retire" - ] + "dependencyPolicy": "required" }, "pages": [ { @@ -944,8 +971,8 @@ "trajectory.collect" ] }, - { - "key": "gifts", + { + "key": "gifts", "title": "礼包管理", "path": "/gifts", "bundleKey": "scum-server-plugin", @@ -957,16 +984,16 @@ "server.game-client.read", "server.game-client.command" ], - "bridgeActions": [ - "server.instances.read", - "remote.access.request" - ], + "bridgeActions": [ + "server.instances.read", + "remote.access.request" + ], "featureKeys": [ "reward.delivery" ] }, - { - "key": "workflows", + { + "key": "workflows", "title": "活动管理", "path": "/activity", "bundleKey": "scum-server-plugin", @@ -978,10 +1005,10 @@ "server.game-client.read", "server.game-client.command" ], - "bridgeActions": [ - "server.instances.read", - "remote.access.request" - ], + "bridgeActions": [ + "server.instances.read", + "remote.access.request" + ], "featureKeys": [ "player.intelligence" ] @@ -1359,8 +1386,7 @@ "kind": "sqlite", "targetKey": "scum-database", "capabilities": [ - "remote.run.db.sqlite.query", - "remote.run.protected.sql" + "remote.run.db.sqlite.query" ] }, { diff --git a/plugins/manifests/game-plugin.manifest.schema.json b/plugins/manifests/game-plugin.manifest.schema.json index 74f0c9a..21753dc 100644 --- a/plugins/manifests/game-plugin.manifest.schema.json +++ b/plugins/manifests/game-plugin.manifest.schema.json @@ -164,7 +164,7 @@ }, "productionLifecycle": { "type": "object", - "required": ["operations", "dependencyPolicy", "approvalRequired"], + "required": ["operations", "dependencyPolicy"], "additionalProperties": false, "properties": { "operations": { @@ -173,12 +173,7 @@ "uniqueItems": true, "minItems": 1 }, - "dependencyPolicy": { "enum": ["required", "optional"] }, - "approvalRequired": { - "type": "array", - "items": { "enum": ["disable", "rollback", "retire"] }, - "uniqueItems": true - } + "dependencyPolicy": { "enum": ["required", "optional"] } } }, "pages": { @@ -269,11 +264,6 @@ "items": { "$ref": "#/$defs/gameClientBridgeDataPack" }, "maxItems": 64 }, - "operationTemplates": { - "type": "array", - "items": { "$ref": "#/$defs/gameClientBridgeOperationTemplate" }, - "maxItems": 128 - }, "commandRetentionSeconds": { "type": "integer", "minimum": 1, "maximum": 31536000 }, "maxCommands": { "type": "integer", "minimum": 1, "maximum": 100000 }, "pages": { @@ -307,13 +297,12 @@ }, "gameClientBridgeCommand": { "type": "object", - "required": ["type", "title", "permission", "approvalLevel", "payloadSchemaRef", "timeoutSeconds", "maxPayloadBytes"], + "required": ["type", "title", "permission", "payloadSchemaRef", "timeoutSeconds", "maxPayloadBytes"], "additionalProperties": false, "properties": { "type": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,159}$" }, "title": { "type": "string", "minLength": 1, "maxLength": 80 }, "permission": { "$ref": "#/$defs/pluginPermission" }, - "approvalLevel": { "enum": ["none", "operator", "platform-admin"] }, "payloadSchemaRef": { "$ref": "#/$defs/relativeJsonRef" }, "resultSchemaRef": { "$ref": "#/$defs/relativeJsonRef" }, "timeoutSeconds": { "type": "integer", "minimum": 1, "maximum": 3600 }, @@ -419,55 +408,6 @@ "dataRefs": { "type": "array", "items": { "$ref": "#/$defs/relativeJsonRef" }, "uniqueItems": true } } }, - "gameClientBridgeOperationSafety": { - "type": "object", - "additionalProperties": false, - "properties": { - "requiresApproval": { "type": "boolean" }, - "requiresOfflinePlayer": { "type": "boolean" }, - "requiresMaintenanceWindow": { "type": "boolean" }, - "requiresBeforeValue": { "type": "boolean" }, - "requiresConfirmation": { "type": "boolean" }, - "backupRequired": { "type": "boolean" } - } - }, - "gameClientBridgeOperationMutation": { - "type": "object", - "required": ["fieldKey", "tableKey", "identityKey", "valueKey", "confirmationQueryKey", "allowedValueType"], - "additionalProperties": false, - "properties": { - "fieldKey": { "$ref": "#/$defs/logicalKey" }, - "tableKey": { "$ref": "#/$defs/logicalKey" }, - "identityKey": { "$ref": "#/$defs/logicalKey" }, - "valueKey": { "$ref": "#/$defs/logicalKey" }, - "confirmationQueryKey": { "$ref": "#/$defs/logicalKey" }, - "allowedValueType": { "enum": ["integer", "number", "string", "boolean"] }, - "minValue": { "type": "number" }, - "maxValue": { "type": "number" } - } - }, - "gameClientBridgeOperationTemplate": { - "type": "object", - "required": ["key", "title", "permission", "approvalLevel", "kind", "transportKey", "targetKey", "payloadSchemaRef", "timeoutSeconds", "maxPayloadBytes"], - "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" }, - "approvalLevel": { "enum": ["none", "operator", "platform-admin"] }, - "kind": { "enum": ["rcon", "sqlite-mutation"] }, - "transportKey": { "$ref": "#/$defs/logicalKey" }, - "targetKey": { "$ref": "#/$defs/logicalKey" }, - "payloadSchemaRef": { "$ref": "#/$defs/relativeJsonRef" }, - "resultSchemaRef": { "$ref": "#/$defs/relativeJsonRef" }, - "confirmationSchemaRef": { "$ref": "#/$defs/relativeJsonRef" }, - "timeoutSeconds": { "type": "integer", "minimum": 1, "maximum": 3600 }, - "maxPayloadBytes": { "type": "integer", "minimum": 1, "maximum": 65536 }, - "maxRowsAffected": { "type": "integer", "minimum": 1, "maximum": 10 }, - "mutation": { "$ref": "#/$defs/gameClientBridgeOperationMutation" }, - "safety": { "$ref": "#/$defs/gameClientBridgeOperationSafety" } - } - }, "gameClientBridgePageContract": { "type": "object", "required": ["pageKey"], @@ -477,7 +417,6 @@ "commandTypes": { "type": "array", "items": { "type": "string" }, "uniqueItems": true }, "snapshotTypes": { "type": "array", "items": { "type": "string" }, "uniqueItems": true }, "queryTemplateKeys": { "type": "array", "items": { "type": "string" }, "uniqueItems": true }, - "operationKeys": { "type": "array", "items": { "type": "string" }, "uniqueItems": true }, "featureKeys": { "type": "array", "items": { "type": "string" }, "uniqueItems": true } } }, @@ -525,7 +464,6 @@ "remote.run.db.sqlite.query", "remote.run.logs.transfer", "remote.run.rcon.command", - "remote.run.protected.sql", "remote.run.program.command", "client-manager.deploy", "client-manager.control", diff --git a/plugins/scripts/validate-manifest.ts b/plugins/scripts/validate-manifest.ts index 7de7f1e..07f5ba3 100644 --- a/plugins/scripts/validate-manifest.ts +++ b/plugins/scripts/validate-manifest.ts @@ -675,7 +675,7 @@ export function validateGameClientBridgeCatalog(manifest: unknown): string[] { if (typeof manifest !== "object" || manifest === null) { return []; } - type BridgeCommand = { type?: string; approvalLevel?: string; payloadSchemaRef?: string; resultSchemaRef?: string }; + type BridgeCommand = { type?: string; payloadSchemaRef?: string; resultSchemaRef?: string }; type BridgeQueryTemplate = { key?: string; permission?: string; @@ -704,25 +704,7 @@ export function validateGameClientBridgeCatalog(manifest: unknown): string[] { activityTarget?: BridgeLogProjectionTarget; }; }; - type BridgeOperationSafety = { requiresApproval?: boolean; requiresOfflinePlayer?: boolean; requiresMaintenanceWindow?: boolean; requiresBeforeValue?: boolean; requiresConfirmation?: boolean; backupRequired?: boolean }; - type BridgeOperationMutation = { fieldKey?: string; tableKey?: string; identityKey?: string; valueKey?: string; confirmationQueryKey?: string; allowedValueType?: string; minValue?: number; maxValue?: number }; - type BridgeOperationTemplate = { - key?: string; - permission?: string; - approvalLevel?: string; - kind?: string; - transportKey?: string; - targetKey?: string; - payloadSchemaRef?: string; - resultSchemaRef?: string; - confirmationSchemaRef?: string; - timeoutSeconds?: number; - maxPayloadBytes?: number; - maxRowsAffected?: number; - mutation?: BridgeOperationMutation; - safety?: BridgeOperationSafety; - }; - type BridgePage = { pageKey?: string; commandTypes?: string[]; snapshotTypes?: string[]; queryTemplateKeys?: string[]; operationKeys?: string[] }; + type BridgePage = { pageKey?: string; commandTypes?: string[]; snapshotTypes?: string[]; queryTemplateKeys?: string[] }; type BridgeCompanion = { profileKey?: string; configTemplateKey?: string; @@ -745,7 +727,7 @@ export function validateGameClientBridgeCatalog(manifest: unknown): string[] { remoteAccess?: { runCapabilities?: string[]; databaseEngines?: string[] }; pages?: PluginPage[]; runtimeProfiles?: { transportProfiles?: RuntimeTransportProfile[]; clientManagers?: RuntimeClientManager[] }; - gameClientBridge?: { commands?: BridgeCommand[]; snapshots?: Array<{ type?: string }>; queryTemplates?: BridgeQueryTemplate[]; logProjections?: BridgeLogProjection[]; operationTemplates?: BridgeOperationTemplate[]; pages?: BridgePage[]; companion?: BridgeCompanion }; + gameClientBridge?: { commands?: BridgeCommand[]; snapshots?: Array<{ type?: string }>; queryTemplates?: BridgeQueryTemplate[]; logProjections?: BridgeLogProjection[]; pages?: BridgePage[]; companion?: BridgeCompanion }; }; const bridge = declaration.gameClientBridge; if (!bridge) { @@ -756,7 +738,6 @@ export function validateGameClientBridgeCatalog(manifest: unknown): string[] { const snapshots = new Set((bridge.snapshots ?? []).map((snapshot) => snapshot.type ?? "")); const queryTemplates = new Map(); const logProjections = new Set(); - const operationTemplates = new Map(); const declaredPermissions = new Set(declaration.permissions ?? []); const declaredCapabilities = new Set(declaration.capabilities ?? []); const remoteCapabilities = new Set(declaration.remoteAccess?.runCapabilities ?? []); @@ -931,92 +912,6 @@ export function validateGameClientBridgeCatalog(manifest: unknown): string[] { if (!Number.isInteger(presence.activeWindowSeconds) || (presence.activeWindowSeconds ?? 0) < 1 || (presence.activeWindowSeconds ?? 0) > 31536000) errors.push(`${location}.presence.activeWindowSeconds: must be between 1 and 31536000`); if (presence.activityTarget) errors.push(...validateProjectionTarget(`${location}.presence.activityTarget`, presence.activityTarget, captures)); } - for (const [index, operationTemplate] of (bridge.operationTemplates ?? []).entries()) { - const location = `manifest.gameClientBridge.operationTemplates[${index}]`; - const key = operationTemplate.key ?? ""; - const unsafeReason = unsafeGameClientBridgeCommandTypeReason(key); - if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,159}$/.test(key) || unsafeReason) { - errors.push(`${location}.key: ${unsafeReason ?? "operation template key is unsafe"}`); - } - if (operationTemplates.has(key)) { - errors.push(`${location}.key: duplicate operation template ${key}`); - } - operationTemplates.set(key, operationTemplate); - if (!operationTemplate.permission || !declaredPermissions.has(operationTemplate.permission)) { - errors.push(`${location}.permission: permission must be declared by the plugin manifest`); - } - if (!new Set(["none", "operator", "platform-admin"]).has(operationTemplate.approvalLevel ?? "")) { - errors.push(`${location}.approvalLevel: must be none, operator, or platform-admin`); - } - if (!new Set(["rcon", "sqlite-mutation"]).has(operationTemplate.kind ?? "")) { - errors.push(`${location}.kind: must be rcon or sqlite-mutation`); - } - for (const [field, ref] of [["payloadSchemaRef", operationTemplate.payloadSchemaRef], ["resultSchemaRef", operationTemplate.resultSchemaRef], ["confirmationSchemaRef", operationTemplate.confirmationSchemaRef]] as const) { - if ((field === "payloadSchemaRef" && !ref) || (ref && !isSafeRelativeJsonRef(ref))) { - errors.push(`${location}.${field}: raw host paths and unsafe schema references are not allowed`); - } - } - if (!Number.isInteger(operationTemplate.timeoutSeconds) || (operationTemplate.timeoutSeconds ?? 0) < 1 || (operationTemplate.timeoutSeconds ?? 0) > 3600) { - errors.push(`${location}.timeoutSeconds: must be an integer between 1 and 3600`); - } - if (!Number.isInteger(operationTemplate.maxPayloadBytes) || (operationTemplate.maxPayloadBytes ?? 0) < 1 || (operationTemplate.maxPayloadBytes ?? 0) > 65536) { - errors.push(`${location}.maxPayloadBytes: must be an integer between 1 and 65536`); - } - const transport = transportProfiles.find((profile) => profile.key === operationTemplate.transportKey); - if (!transport) { - errors.push(`${location}.transportKey: undeclared transport profile ${operationTemplate.transportKey ?? ""}`); - continue; - } - if (!operationTemplate.targetKey || transport.targetKey !== operationTemplate.targetKey) { - errors.push(`${location}.targetKey: must match the declared runtime transport target`); - } - if (operationTemplate.kind === "rcon") { - if (transport.kind !== "rcon" || !transport.capabilities?.includes("remote.run.rcon.command")) { - errors.push(`${location}.transportKey: rcon operations require remote.run.rcon.command transport`); - } - if (operationTemplate.maxRowsAffected !== undefined) { - errors.push(`${location}.maxRowsAffected: only sqlite-mutation operations may declare affected row bounds`); - } - if (operationTemplate.mutation !== undefined) { - errors.push(`${location}.mutation: only sqlite-mutation operations may declare mutation metadata`); - } - } - if (operationTemplate.kind === "sqlite-mutation") { - if (transport.kind !== "sqlite" || !transport.capabilities?.includes("remote.run.protected.sql")) { - errors.push(`${location}.transportKey: sqlite-mutation operations require sqlite remote.run.protected.sql transport`); - } - if (operationTemplate.approvalLevel !== "platform-admin") { - errors.push(`${location}.approvalLevel: sqlite-mutation operations require platform-admin approval`); - } - if (!Number.isInteger(operationTemplate.maxRowsAffected) || (operationTemplate.maxRowsAffected ?? 0) < 1 || (operationTemplate.maxRowsAffected ?? 0) > 10) { - errors.push(`${location}.maxRowsAffected: must be an integer between 1 and 10`); - } - const safety = operationTemplate.safety; - if (!safety?.requiresBeforeValue || !safety.requiresConfirmation || (!safety.requiresOfflinePlayer && !safety.requiresMaintenanceWindow)) { - errors.push(`${location}.safety: sqlite-mutation operations require before value, confirmation, and offline or maintenance protection`); - } - const mutation = operationTemplate.mutation; - if (!mutation) { - errors.push(`${location}.mutation: sqlite-mutation operations require field/table/identity metadata`); - } else { - for (const field of ["fieldKey", "tableKey", "identityKey", "valueKey", "confirmationQueryKey"] as const) { - const value = mutation[field] ?? ""; - if (!/^[A-Za-z0-9][A-Za-z0-9._:/-]{0,159}$/.test(value) || unsafeGameClientBridgePayloadKey(value)) { - errors.push(`${location}.mutation.${field}: must be a safe logical key`); - } - } - if (!new Set(["integer", "number", "string", "boolean"]).has(mutation.allowedValueType ?? "")) { - errors.push(`${location}.mutation.allowedValueType: must be integer, number, string, or boolean`); - } - if (mutation.minValue !== undefined && mutation.maxValue !== undefined && mutation.minValue > mutation.maxValue) { - errors.push(`${location}.mutation: minValue must not exceed maxValue`); - } - if (mutation.confirmationQueryKey && !queryTemplates.has(mutation.confirmationQueryKey)) { - errors.push(`${location}.mutation.confirmationQueryKey: must reference a declared query template`); - } - } - } - } for (const [index, page] of (bridge.pages ?? []).entries()) { for (const commandType of page.commandTypes ?? []) { if (!commands.has(commandType)) { @@ -1042,17 +937,6 @@ export function validateGameClientBridgeCatalog(manifest: unknown): string[] { errors.push(`manifest.gameClientBridge.pages[${index}].queryTemplateKeys: page must declare remote.access.request`); } } - for (const operationKey of page.operationKeys ?? []) { - const operationTemplate = operationTemplates.get(operationKey); - if (!operationTemplate) { - errors.push(`manifest.gameClientBridge.pages[${index}].operationKeys: undeclared operation template ${operationKey}`); - continue; - } - const pluginPage = declaration.pages?.find((candidate) => candidate.key === page.pageKey); - if (!pluginPage?.permissions?.includes(operationTemplate.permission ?? "")) { - errors.push(`manifest.gameClientBridge.pages[${index}].operationKeys: page must declare operation template permission ${operationTemplate.permission ?? ""}`); - } - } } return errors; } @@ -1183,8 +1067,7 @@ function referencedGameClientBridgeSchemas(manifest: unknown): GameClientBridgeS type BridgeCommand = { payloadSchemaRef?: string; resultSchemaRef?: string }; type BridgeSnapshot = { schemaRef?: string }; type BridgeQueryTemplate = { parameterSchemaRef?: string; resultSchemaRef?: string }; - type BridgeOperationTemplate = { payloadSchemaRef?: string; resultSchemaRef?: string; confirmationSchemaRef?: string }; - const bridge = (manifest as { gameClientBridge?: { commands?: BridgeCommand[]; snapshots?: BridgeSnapshot[]; queryTemplates?: BridgeQueryTemplate[]; operationTemplates?: BridgeOperationTemplate[] } }).gameClientBridge; + const bridge = (manifest as { gameClientBridge?: { commands?: BridgeCommand[]; snapshots?: BridgeSnapshot[]; queryTemplates?: BridgeQueryTemplate[] } }).gameClientBridge; if (!bridge) { return []; } @@ -1210,17 +1093,6 @@ function referencedGameClientBridgeSchemas(manifest: unknown): GameClientBridgeS refs.push({ location: `manifest.gameClientBridge.queryTemplates[${index}].resultSchemaRef`, ref: queryTemplate.resultSchemaRef }); } } - for (const [index, operationTemplate] of (bridge.operationTemplates ?? []).entries()) { - if (operationTemplate.payloadSchemaRef) { - refs.push({ location: `manifest.gameClientBridge.operationTemplates[${index}].payloadSchemaRef`, ref: operationTemplate.payloadSchemaRef }); - } - if (operationTemplate.resultSchemaRef) { - refs.push({ location: `manifest.gameClientBridge.operationTemplates[${index}].resultSchemaRef`, ref: operationTemplate.resultSchemaRef }); - } - if (operationTemplate.confirmationSchemaRef) { - refs.push({ location: `manifest.gameClientBridge.operationTemplates[${index}].confirmationSchemaRef`, ref: operationTemplate.confirmationSchemaRef }); - } - } return refs; } diff --git a/plugins/sdk/index.ts b/plugins/sdk/index.ts index 5f5f5f5..2a5fc2e 100644 --- a/plugins/sdk/index.ts +++ b/plugins/sdk/index.ts @@ -46,7 +46,6 @@ export type RunCapability = | "remote.run.db.sqlite.query" | "remote.run.logs.transfer" | "remote.run.rcon.command" - | "remote.run.protected.sql" | "remote.run.program.command" | "client-manager.deploy" | "client-manager.control" @@ -150,7 +149,6 @@ export type PluginProductionLifecyclePayload = Record & { operation: ProductionPluginLifecycleOperation; targetVersion: string; idempotencyKey: string; - confirmed: "true" | "false"; }; export type PluginRemoteAccessPayload = Record & { @@ -218,15 +216,12 @@ export interface GamePluginRemoteAccess { logTransfer?: boolean; } -export type GameClientBridgeApprovalLevel = "none" | "operator" | "platform-admin"; -export type GameClientBridgeApprovalState = "not_required" | "pending" | "approved" | "rejected"; export type GameClientBridgeCommandState = "pending" | "claimed" | "succeeded" | "failed" | "unknown" | "cancelled" | "expired"; export interface GameClientBridgeCommandDeclaration { type: string; title: string; permission: PluginPermission; - approvalLevel: GameClientBridgeApprovalLevel; payloadSchemaRef: string; resultSchemaRef?: string; timeoutSeconds: number; @@ -299,52 +294,11 @@ export interface GameClientBridgeDataPackDeclaration { configMapRefs: string[]; } -export type GameClientBridgeOperationKind = "rcon" | "sqlite-mutation"; - -export interface GameClientBridgeOperationSafety { - requiresApproval?: boolean; - requiresOfflinePlayer?: boolean; - requiresMaintenanceWindow?: boolean; - requiresBeforeValue?: boolean; - requiresConfirmation?: boolean; - backupRequired?: boolean; -} - -export interface GameClientBridgeOperationMutationDeclaration { - fieldKey: string; - tableKey: string; - identityKey: string; - valueKey: string; - confirmationQueryKey: string; - allowedValueType: "integer" | "number" | "string" | "boolean"; - minValue?: number; - maxValue?: number; -} - -export interface GameClientBridgeOperationTemplateDeclaration { - key: string; - title: string; - permission: PluginPermission; - approvalLevel: Exclude; - kind: GameClientBridgeOperationKind; - transportKey: string; - targetKey: string; - payloadSchemaRef: string; - resultSchemaRef?: string; - confirmationSchemaRef?: string; - timeoutSeconds: number; - maxPayloadBytes: number; - maxRowsAffected?: number; - mutation?: GameClientBridgeOperationMutationDeclaration; - safety?: GameClientBridgeOperationSafety; -} - export interface GameClientBridgePageContract { pageKey: string; commandTypes?: string[]; snapshotTypes?: string[]; queryTemplateKeys?: string[]; - operationKeys?: string[]; featureKeys?: string[]; } @@ -372,7 +326,6 @@ export interface GameClientBridgeManifest { queryTemplates?: GameClientBridgeQueryTemplateDeclaration[]; logProjections?: GameClientBridgeLogProjectionDeclaration[]; dataPacks?: GameClientBridgeDataPackDeclaration[]; - operationTemplates?: GameClientBridgeOperationTemplateDeclaration[]; commandRetentionSeconds: number; maxCommands: number; pages?: GameClientBridgePageContract[]; @@ -418,7 +371,6 @@ export interface GameClientBridgeCommand { commandType: string; priority: number; state: GameClientBridgeCommandState; - approvalState: GameClientBridgeApprovalState; result?: GameClientBridgeCommandResult; expiresAt: string; createdAt: string; @@ -760,7 +712,6 @@ export interface GamePluginManifest { productionLifecycle: { operations: ProductionPluginLifecycleOperation[]; dependencyPolicy: "required" | "optional"; - approvalRequired: Array<"disable" | "rollback" | "retire">; }; pages?: GamePluginPage[]; ai?: { @@ -870,14 +821,10 @@ export function createProductionPluginLifecycleRequest(input: { operation: ProductionPluginLifecycleOperation; targetVersion?: string; idempotencyKey: string; - confirmed?: boolean; }): PluginBridgeExecutionRequest { if (!input.context.serverInstanceId) { throw new Error("serverInstanceId is required for plugin lifecycle requests"); } - if (["disable", "rollback", "retire"].includes(input.operation) && !input.confirmed) { - throw new Error("disruptive plugin lifecycle requests require confirmation"); - } return createBridgeExecutionRequest({ requestId: input.requestId, context: input.context, @@ -885,8 +832,7 @@ export function createProductionPluginLifecycleRequest(input: { payload: { operation: input.operation, targetVersion: input.targetVersion ?? "", - idempotencyKey: input.idempotencyKey, - confirmed: input.confirmed ? "true" : "false" + idempotencyKey: input.idempotencyKey } }); } diff --git a/plugins/tests/manifest-validation.test.ts b/plugins/tests/manifest-validation.test.ts index 5d27509..de57c57 100644 --- a/plugins/tests/manifest-validation.test.ts +++ b/plugins/tests/manifest-validation.test.ts @@ -27,7 +27,6 @@ import { parseAIInvocationResponse, type GameClientBridgeQueryTemplateDeclaration, type GameClientBridgeLogProjectionDeclaration, - type GameClientBridgeOperationTemplateDeclaration, type GameClientBridgeCompanionDeclaration, type GamePluginManifest, type RuntimeLogEventDeclaration, @@ -55,7 +54,6 @@ type MutableBridgeManifest = { commands: Array>; snapshots: Array>; queryTemplates?: Array>; - operationTemplates?: Array>; commandRetentionSeconds: number; maxCommands: number; pages: Array>; @@ -94,14 +92,13 @@ function validateTemporaryBridgeManifest(mutate?: (manifest: MutableBridgeManife fs.cpSync(path.join(pluginsRoot, "examples/dev-game-plugin"), fixtureDir, { recursive: true }); const manifestPath = path.join(fixtureDir, "manifest.json"); const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")) as MutableBridgeManifest; - manifest.capabilities = [...manifest.capabilities, "remote.run.db.sqlite.query", "remote.run.rcon.command", "remote.run.protected.sql"]; + manifest.capabilities = [...manifest.capabilities, "remote.run.db.sqlite.query", "remote.run.rcon.command"]; manifest.permissions = [...manifest.permissions, "server.game-client.command", "server.game-client.read", "server.game-client.maintenance"]; - manifest.remoteAccess = { methods: ["run"], runCapabilities: ["remote.run.db.sqlite.query", "remote.run.rcon.command", "remote.run.protected.sql"], databaseEngines: ["sqlite"] }; + manifest.remoteAccess = { methods: ["run"], runCapabilities: ["remote.run.db.sqlite.query", "remote.run.rcon.command"], databaseEngines: ["sqlite"] }; manifest.runtimeProfiles = { transportProfiles: [ { key: "sqlite-db", kind: "sqlite", targetKey: "db/sqlite", capabilities: ["remote.run.db.sqlite.query"] }, - { key: "scum-rcon", kind: "rcon", targetKey: "scum-rcon", capabilities: ["remote.run.rcon.command"] }, - { key: "scum-mutation-db", kind: "sqlite", targetKey: "scum-mutation-db", capabilities: ["remote.run.protected.sql"] } + { key: "scum-rcon", kind: "rcon", targetKey: "scum-rcon", capabilities: ["remote.run.rcon.command"] } ] }; const overviewPage = manifest.pages?.find((page) => page.key === "overview"); @@ -110,24 +107,18 @@ function validateTemporaryBridgeManifest(mutate?: (manifest: MutableBridgeManife overviewPage.bridgeActions = [...(overviewPage.bridgeActions ?? []), "remote.access.request"]; } manifest.gameClientBridge = { - commands: [{ type: "diagnostic.ping", title: "Diagnostic ping", permission: "server.game-client.command", approvalLevel: "none", payloadSchemaRef: "schemas/bridge/diagnostic-ping.schema.json", resultSchemaRef: "schemas/bridge/diagnostic-ping-result.schema.json", timeoutSeconds: 60, maxPayloadBytes: 4096 }], + commands: [{ type: "diagnostic.ping", title: "Diagnostic ping", permission: "server.game-client.command", payloadSchemaRef: "schemas/bridge/diagnostic-ping.schema.json", resultSchemaRef: "schemas/bridge/diagnostic-ping-result.schema.json", timeoutSeconds: 60, maxPayloadBytes: 4096 }], snapshots: [{ type: "players", schemaVersion: "1", schemaRef: "schemas/bridge/players.schema.json", keepForSeconds: 3600, maxRecords: 100 }], queryTemplates: [{ key: "player.by-id", title: "Find player by ID", permission: "server.game-client.read", engine: "sqlite", transportKey: "sqlite-db", targetKey: "db/sqlite", parameterSchemaRef: "schemas/bridge/player-by-id.parameters.schema.json", resultSchemaRef: "schemas/bridge/player-by-id.result.schema.json", maxRows: 1, timeoutSeconds: 10 }], - operationTemplates: [ - { key: "player.fame.set", title: "Set player fame", permission: "server.game-client.command", approvalLevel: "none", kind: "rcon", transportKey: "scum-rcon", targetKey: "scum-rcon", payloadSchemaRef: "schemas/bridge/player-fame-set.payload.schema.json", resultSchemaRef: "schemas/bridge/player-fame-set.result.schema.json", timeoutSeconds: 60, maxPayloadBytes: 2048 }, - { key: "player.attribute.855.set", title: "Set player attribute 855", permission: "server.game-client.maintenance", approvalLevel: "platform-admin", kind: "sqlite-mutation", transportKey: "scum-mutation-db", targetKey: "scum-mutation-db", payloadSchemaRef: "schemas/bridge/player-attribute-855-set.payload.schema.json", resultSchemaRef: "schemas/bridge/player-attribute-855-set.result.schema.json", confirmationSchemaRef: "schemas/bridge/player-attribute-855-set.confirmation.schema.json", timeoutSeconds: 120, maxPayloadBytes: 4096, maxRowsAffected: 1, mutation: { fieldKey: "855", tableKey: "prisoner", identityKey: "user_profile_id", valueKey: "value", confirmationQueryKey: "player.by-id", allowedValueType: "integer", minValue: 0, maxValue: 100000 }, safety: { requiresApproval: true, requiresOfflinePlayer: true, requiresBeforeValue: true, requiresConfirmation: true, backupRequired: true } } - ], commandRetentionSeconds: 86400, maxCommands: 1000, - pages: [{ pageKey: "overview", commandTypes: ["diagnostic.ping"], snapshotTypes: ["players"], queryTemplateKeys: ["player.by-id"], operationKeys: ["player.fame.set", "player.attribute.855.set"] }] + pages: [{ pageKey: "overview", commandTypes: ["diagnostic.ping"], snapshotTypes: ["players"], queryTemplateKeys: ["player.by-id"] }] }; writeFixtureJSON(fixtureDir, "schemas/bridge/diagnostic-ping.schema.json", bridgeObjectSchema({ message: { type: "string", minLength: 1, maxLength: 200 } }, ["message"])); writeFixtureJSON(fixtureDir, "schemas/bridge/diagnostic-ping-result.schema.json", bridgeObjectSchema({ accepted: { type: "boolean" } }, ["accepted"])); writeFixtureJSON(fixtureDir, "schemas/bridge/players.schema.json", bridgeObjectSchema({ players: { type: "array", maxItems: 100, items: bridgeObjectSchema({ id: { type: "string", minLength: 1, maxLength: 80 } }, ["id"]) } }, ["players"])); writeFixtureJSON(fixtureDir, "schemas/bridge/player-by-id.parameters.schema.json", bridgeObjectSchema({ playerId: { type: "string", minLength: 1, maxLength: 96 } }, ["playerId"])); writeFixtureJSON(fixtureDir, "schemas/bridge/player-by-id.result.schema.json", bridgeObjectSchema({ players: { type: "array", maxItems: 1, items: bridgeObjectSchema({ playerId: { type: "string", minLength: 1, maxLength: 96 } }, ["playerId"]) } }, ["players"])); - writeFixtureJSON(fixtureDir, "schemas/bridge/player-fame-set.payload.schema.json", bridgeObjectSchema({ playerId: { type: "string", minLength: 1, maxLength: 96 }, fame: { type: "integer", minimum: 0, maximum: 2147483647 } }, ["playerId", "fame"])); - writeFixtureJSON(fixtureDir, "schemas/bridge/player-fame-set.result.schema.json", bridgeObjectSchema({ outcome: { enum: ["queued", "succeeded", "failed", "unknown"] } }, ["outcome"])); writeFixtureJSON(fixtureDir, "schemas/bridge/player-fame-set.confirmation.schema.json", bridgeObjectSchema({ playerId: { type: "string" }, fame: { type: "integer" } }, ["playerId", "fame"])); writeFixtureJSON(fixtureDir, "schemas/bridge/player-attribute-855-set.payload.schema.json", bridgeObjectSchema({ playerId: { type: "string", minLength: 1, maxLength: 96 }, before: { type: "number" }, after: { type: "number" }, safetyWindow: { type: "string", minLength: 1, maxLength: 96 } }, ["playerId", "before", "after", "safetyWindow"])); writeFixtureJSON(fixtureDir, "schemas/bridge/player-attribute-855-set.result.schema.json", bridgeObjectSchema({ outcome: { enum: ["succeeded", "failed", "unknown"] }, rowsAffected: { type: "integer", minimum: 0, maximum: 1 } }, ["outcome", "rowsAffected"])); @@ -192,12 +183,10 @@ describe("plugin manifest validation", () => { it("removes raw SQL command surfaces", () => { const pluginDir = path.join(pluginsRoot, "examples/scum-server-plugin"); - const manifest = JSON.parse(fs.readFileSync(path.join(pluginDir, "manifest.json"), "utf8")) as { gameClientBridge: { commands: Array<{ type: string; payloadSchemaRef: string }>; queryTemplates: Array<{ key: string }>; operationTemplates: Array<{ key: string; kind: string }> } }; + const manifest = JSON.parse(fs.readFileSync(path.join(pluginDir, "manifest.json"), "utf8")) as { gameClientBridge: { commands: Array<{ type: string; payloadSchemaRef: string }>; queryTemplates: Array<{ key: string }> } }; expect(manifest.gameClientBridge.commands.some((command) => command.type === "diagnostic.ping")).toBe(false); expect(manifest.gameClientBridge.commands.map((command) => command.type)).not.toEqual(expect.arrayContaining(["config.read", "config.patch", "database.request", "management.rcon.request", "management.program.request"])); expect(manifest.gameClientBridge.queryTemplates.map((query) => query.key)).toEqual(expect.arrayContaining(["scum.player.profile", "scum.squads", "scum.squad-members", "scum.vehicles", "scum.flags", "scum.positions"])); - expect(manifest.gameClientBridge.operationTemplates.map((operation) => operation.key)).toEqual(expect.arrayContaining(["player.fame.set", "player.currency.normal.set", "player.currency.gold.set", "player.notify", "reward.deliver", "player.attribute.855.set"])); - expect(manifest.gameClientBridge.operationTemplates.find((operation) => operation.key === "player.attribute.855.set")?.kind).toBe("sqlite-mutation"); expect(fs.existsSync(path.join(pluginDir, "schemas/bridge/queries/SCUM_DB_CONTRACT.md"))).toBe(true); }); @@ -286,7 +275,7 @@ describe("plugin manifest validation", () => { expect(local?.capabilities).toContain("remote.run.rcon.command"); expect(local?.transportKeys).toContain("scum-management"); expect(manifest.runtimeProfiles?.transportProfiles).toEqual(expect.arrayContaining([ - expect.objectContaining({ key: "scum-database", kind: "sqlite", capabilities: expect.arrayContaining(["remote.run.db.sqlite.query", "remote.run.protected.sql"]) }), + expect.objectContaining({ key: "scum-database", kind: "sqlite", capabilities: expect.arrayContaining(["remote.run.db.sqlite.query"]) }), expect.objectContaining({ key: "scum-management", kind: "rcon", capabilities: ["remote.run.rcon.command"] }), expect.objectContaining({ key: "scum-program", kind: "program", capabilities: ["remote.run.program.command"] }) ])); @@ -461,14 +450,13 @@ describe("plugin manifest validation", () => { commands: Array<{ type: string; permission: string; - approvalLevel: string; payloadSchemaRef: string; resultSchemaRef?: string; timeoutSeconds: number; maxPayloadBytes: number; }>; snapshots: Array<{ type: string; schemaVersion: string; schemaRef: string }>; - pages: Array<{ pageKey: string; commandTypes?: string[]; snapshotTypes?: string[]; queryTemplateKeys?: string[]; operationKeys?: string[] }>; + pages: Array<{ pageKey: string; commandTypes?: string[]; snapshotTypes?: string[]; queryTemplateKeys?: string[] }>; }; pages: Array<{ key: string; permissions?: string[] }>; fileWorkspace?: { @@ -511,13 +499,6 @@ describe("plugin manifest validation", () => { expect(manifest.gameClientBridge.snapshots.map((snapshot) => snapshot.type)).toEqual(expect.arrayContaining(["companion.health", "online.sessions", "players", "squads", "vehicles", "flags"])); expect(manifest.gameClientBridge.pages.map((page) => page.pageKey)).toEqual(expect.arrayContaining(["players", "squads", "live-map", "gifts", "workflows"])); expect(manifest.gameClientBridge.pages.map((page) => page.pageKey)).not.toContain("files-config"); - expect(manifest.gameClientBridge.pages.find((page) => page.pageKey === "players")?.operationKeys).toEqual(expect.arrayContaining([ - "player.fame.set", - "player.currency.normal.set", - "player.currency.gold.set", - "player.notify", - "player.attribute.855.set" - ])); expect(manifest.gameClientBridge.pages.find((page) => page.pageKey === "workflows")?.queryTemplateKeys).toEqual(expect.arrayContaining(["scum.player.profile", "scum.squads", "scum.vehicles", "scum.flags", "scum.positions"])); expect(manifest.pages.map((page) => page.key)).toEqual(expect.arrayContaining(["players", "squads", "live-map", "gifts", "workflows"])); expect(manifest.pages.map((page) => page.key)).not.toContain("files-config"); @@ -534,7 +515,6 @@ describe("plugin manifest validation", () => { commands: Array<{ type: string; permission: string; - approvalLevel: string; payloadSchemaRef: string; resultSchemaRef?: string; timeoutSeconds: number; @@ -543,15 +523,15 @@ describe("plugin manifest validation", () => { }; }; const expected = { - "companion.diagnostics": { permission: "server.game-client.read", approvalLevel: "none" }, - "player.lookup": { permission: "server.game-client.read", approvalLevel: "none" }, - "reward.deliver": { permission: "server.game-client.command", approvalLevel: "none" }, - "player.notify": { permission: "server.game-client.command", approvalLevel: "none" }, - "vehicle.spawn": { permission: "server.game-client.command", approvalLevel: "none" }, - "event.start": { permission: "server.game-client.command", approvalLevel: "none" }, - "restart.prepare": { permission: "server.game-client.maintenance", approvalLevel: "none" }, - "maintenance.prepare": { permission: "server.game-client.maintenance", approvalLevel: "none" }, - "game-state.patch": { permission: "server.game-client.maintenance", approvalLevel: "none" } + "companion.diagnostics": { permission: "server.game-client.read" }, + "player.lookup": { permission: "server.game-client.read" }, + "reward.deliver": { permission: "server.game-client.command" }, + "player.notify": { permission: "server.game-client.command" }, + "vehicle.spawn": { permission: "server.game-client.command" }, + "event.start": { permission: "server.game-client.command" }, + "restart.prepare": { permission: "server.game-client.maintenance" }, + "maintenance.prepare": { permission: "server.game-client.maintenance" }, + "game-state.patch": { permission: "server.game-client.maintenance" } } as const; expect(manifest.gameClientBridge.commands.map((command) => command.type)).toEqual(expect.arrayContaining(Object.keys(expected))); @@ -561,7 +541,6 @@ describe("plugin manifest validation", () => { continue; } expect(command.permission).toBe(policy.permission); - expect(command.approvalLevel).toBe(policy.approvalLevel); expect(command.timeoutSeconds).toBeGreaterThan(0); expect(command.timeoutSeconds).toBeLessThanOrEqual(3600); expect(command.maxPayloadBytes).toBeGreaterThan(0); @@ -742,37 +721,6 @@ describe("plugin manifest validation", () => { } }); - it("declares typed SCUM RCON operations without arbitrary command inputs", () => { - const pluginDir = path.join(pluginsRoot, "examples/scum-server-plugin"); - const manifest = JSON.parse(fs.readFileSync(path.join(pluginDir, "manifest.json"), "utf8")) as { - gameClientBridge: { - operationTemplates: Array<{ key: string; kind: string; permission: string; approvalLevel: string; payloadSchemaRef: string; resultSchemaRef?: string; confirmationSchemaRef?: string; safety?: Record }>; - pages: Array<{ pageKey: string; operationKeys?: string[] }>; - }; - pages: Array<{ key: string; permissions?: string[] }>; - }; - const expectedKeys = ["player.fame.set", "player.currency.normal.set", "player.currency.gold.set", "player.notify", "reward.deliver"]; - const operationsByKey = new Map(manifest.gameClientBridge.operationTemplates.map((operation) => [operation.key, operation])); - expect([...operationsByKey.keys()]).toEqual(expect.arrayContaining(expectedKeys)); - for (const key of expectedKeys) { - const operation = operationsByKey.get(key)!; - expect(operation.kind).toBe("rcon"); - expect(operation.permission).toBe("server.game-client.command"); - expect(operation.approvalLevel).toBe("none"); - const payload = JSON.parse(fs.readFileSync(path.join(pluginDir, operation.payloadSchemaRef), "utf8")); - const result = JSON.parse(fs.readFileSync(path.join(pluginDir, operation.resultSchemaRef!), "utf8")); - expect(payload).toMatchObject({ type: "object", additionalProperties: false }); - expect(result).toMatchObject({ type: "object", additionalProperties: false }); - expect(JSON.stringify(payload).toLowerCase()).not.toMatch(/rcon|commandtext|requesttext|sql|dsn|hostpath/); - } - const playersPage = manifest.gameClientBridge.pages.find((page) => page.pageKey === "players"); - const giftsPage = manifest.gameClientBridge.pages.find((page) => page.pageKey === "gifts"); - expect(playersPage?.operationKeys).toEqual(expect.arrayContaining(["player.fame.set", "player.currency.normal.set", "player.currency.gold.set", "player.notify"])); - expect(giftsPage?.operationKeys).toEqual(expect.arrayContaining(["reward.deliver", "player.notify"])); - expect(manifest.pages.find((page) => page.key === "players")?.permissions).toContain("server.game-client.command"); - expect(manifest.pages.find((page) => page.key === "gifts")?.permissions).toContain("server.game-client.command"); - }); - it("packages SCUM v57 config, UTF-16LE logs, and gift metadata inside the plugin", () => { const pluginDir = path.join(pluginsRoot, "examples/scum-server-plugin"); const manifest = JSON.parse(fs.readFileSync(path.join(pluginDir, "manifest.json"), "utf8")) as { @@ -912,7 +860,7 @@ describe("plugin manifest validation", () => { const manifest = JSON.parse(fs.readFileSync(path.join(pluginsRoot, "examples/dev-game-plugin/manifest.json"), "utf8")); manifest.permissions = [...manifest.permissions, "server.game-client.command", "server.game-client.read"]; manifest.gameClientBridge = { - commands: [{ type: "diagnostic.ping", title: "Diagnostic ping", permission: "server.game-client.command", approvalLevel: "operator", payloadSchemaRef: "schemas/bridge/diagnostic-ping.schema.json", resultSchemaRef: "schemas/bridge/diagnostic-ping-result.schema.json", timeoutSeconds: 60, maxPayloadBytes: 4096 }], + commands: [{ type: "diagnostic.ping", title: "Diagnostic ping", permission: "server.game-client.command", payloadSchemaRef: "schemas/bridge/diagnostic-ping.schema.json", resultSchemaRef: "schemas/bridge/diagnostic-ping-result.schema.json", timeoutSeconds: 60, maxPayloadBytes: 4096 }], snapshots: [{ type: "players", schemaVersion: "1", schemaRef: "schemas/bridge/players.schema.json", keepForSeconds: 3600, maxRecords: 100 }], logProjections: [{ key: "player.login", streamKeys: ["process.stdout"], steps: [{ pattern: "Player \\\"(?[^\\\"]+)\\\" reported as player (?\\\\d+)" }, { pattern: "Player (?\\\\d+) SteamID: (?\\\\d+)" }], correlationFields: ["slot"], maxInterveningLines: 16, target: { collection: "users", upsertKeys: ["steamId"], captureMappings: { steamId: "steamId", name: "name" }, observedAtField: "lastLoginAt" }, presence: { timestampField: "lastLoginAt", activeWindowSeconds: 600 } }], commandRetentionSeconds: 86400, @@ -921,7 +869,7 @@ describe("plugin manifest validation", () => { }; const validate = new Ajv2020({ allErrors: true }).compile(schema); expect(validate(manifest), JSON.stringify(validate.errors)).toBe(true); - manifest.gameClientBridge.commands[0].approvalLevel = "automatic"; + manifest.gameClientBridge.commands[0].unexpectedControlGate = "automatic"; expect(validate(manifest)).toBe(false); }); @@ -946,7 +894,7 @@ describe("plugin manifest validation", () => { permissions: ["server.game-client.command"], runtimeProfiles: { clientManagers: [{ key: "scum-client", health: { requiredCapabilities: ["game-client.bridge"] } }] }, gameClientBridge: { - commands: [{ type: "diagnostic.ping", approvalLevel: "none", payloadSchemaRef: "schemas/bridge/diagnostic-ping.schema.json" }], + commands: [{ type: "diagnostic.ping", payloadSchemaRef: "schemas/bridge/diagnostic-ping.schema.json" }], snapshots: [], logProjections: [projection] } @@ -1047,35 +995,6 @@ describe("plugin manifest validation", () => { expect(actionErrors.some((error) => error.includes("page must declare remote.access.request"))).toBe(true); }); - it("validates typed operation templates and page operation bindings", () => { - expect(validateTemporaryBridgeManifest()).toEqual([]); - - const unsafeKeyErrors = validateTemporaryBridgeManifest((manifest) => { - manifest.gameClientBridge.operationTemplates![0].key = "raw.sql.execute"; - }); - expect(unsafeKeyErrors.some((error) => error.includes("operationTemplates") && error.includes("arbitrary SQL"))).toBe(true); - - const approvalErrors = validateTemporaryBridgeManifest((manifest) => { - manifest.gameClientBridge.operationTemplates![0].approvalLevel = "automatic"; - }); - expect(approvalErrors.some((error) => error.includes("approvalLevel") && error.includes("none, operator, or platform-admin"))).toBe(true); - - const rconTransportErrors = validateTemporaryBridgeManifest((manifest) => { - Object.assign(manifest.gameClientBridge.operationTemplates![0], { transportKey: "sqlite-db", targetKey: "db/sqlite" }); - }); - expect(rconTransportErrors.some((error) => error.includes("rcon operations require"))).toBe(true); - - const mutationSafetyErrors = validateTemporaryBridgeManifest((manifest) => { - manifest.gameClientBridge.operationTemplates![1].safety = { requiresConfirmation: true }; - }); - expect(mutationSafetyErrors.some((error) => error.includes("sqlite-mutation operations require before value"))).toBe(true); - - const pageErrors = validateTemporaryBridgeManifest((manifest) => { - manifest.gameClientBridge.pages[0].operationKeys = ["missing.operation"]; - }); - expect(pageErrors.some((error) => error.includes("undeclared operation template missing.operation"))).toBe(true); - }); - it.each(["sqlText", "dsn", "hostPath", "shellCommand", "socketAddress", "accessToken", "credential"])("rejects unsafe query parameter schema field %s", (fieldName) => { const errors = validateTemporaryBridgeManifest((_manifest, fixtureDir) => { writeFixtureJSON(fixtureDir, "schemas/bridge/player-by-id.parameters.schema.json", bridgeObjectSchema({ [fieldName]: { type: "string", minLength: 1, maxLength: 120 } }, [fieldName])); @@ -1097,13 +1016,6 @@ describe("plugin manifest validation", () => { expect(errors.some((error) => error.includes("resultSchemaRef") && error.includes("missing bridge schema file"))).toBe(true); }); - it("rejects missing bridge command approval metadata end to end", () => { - const errors = validateTemporaryBridgeManifest((manifest) => { - delete manifest.gameClientBridge.commands[0].approvalLevel; - }); - expect(errors.some((error) => error.includes("approvalLevel") && (error.includes("required") || error.includes("approval metadata")))).toBe(true); - }); - it("rejects unsafe executor capabilities end to end", () => { const errors = validateTemporaryBridgeManifest((manifest) => { manifest.capabilities = [...manifest.capabilities, "shell.exec"]; @@ -1223,28 +1135,6 @@ describe("plugin SDK", () => { expect(declaration).toMatchObject({ key: "scum.player.login", correlationFields: ["slot"] }); }); - it("types plugin operation template declarations", () => { - const declaration: GameClientBridgeOperationTemplateDeclaration = { - key: "player.attribute.855.set", - title: "Set player attribute 855", - permission: "server.game-client.maintenance", - approvalLevel: "platform-admin", - kind: "sqlite-mutation", - transportKey: "scum-mutation-db", - targetKey: "scum-mutation-db", - payloadSchemaRef: "schemas/bridge/operations/player-attribute-855-set.payload.schema.json", - resultSchemaRef: "schemas/bridge/operations/player-attribute-855-set.result.schema.json", - confirmationSchemaRef: "schemas/bridge/operations/player-attribute-855-set.confirmation.schema.json", - timeoutSeconds: 120, - maxPayloadBytes: 4096, - maxRowsAffected: 1, - mutation: { fieldKey: "855", tableKey: "prisoner", identityKey: "user_profile_id", valueKey: "value", confirmationQueryKey: "player.lookup", allowedValueType: "integer", minValue: 0, maxValue: 100000 }, - safety: { requiresApproval: true, requiresOfflinePlayer: true, requiresBeforeValue: true, requiresConfirmation: true, backupRequired: true } - }; - expect(declaration).toMatchObject({ kind: "sqlite-mutation", approvalLevel: "platform-admin", maxRowsAffected: 1 }); - expect(JSON.stringify(declaration).toLowerCase()).not.toMatch(/sqltext|dsn|hostpath|socket|credential|password/); - }); - it("builds safe game-client bridge requests without component transport material", () => { const request = createGameClientBridgeQueueRequest({ profileKey: "scum-client", @@ -1555,7 +1445,7 @@ describe("plugin SDK", () => { server: { type: "runtime", displayName: "Runtime Fixture", createFormSchema: "schemas/create-form.schema.json" }, capabilities: ["process.start", "process.stop", "logs.read"], permissions: ["server.read", "server.lifecycle", "server.logs.read"], - productionLifecycle: { operations: ["install", "enable", "disable", "upgrade", "rollback", "retire", "dependency-check"], dependencyPolicy: "required", approvalRequired: ["disable", "rollback", "retire"] }, + productionLifecycle: { operations: ["install", "enable", "disable", "upgrade", "rollback", "retire", "dependency-check"], dependencyPolicy: "required" }, runtimeProfiles: { discovery: [{ key: "java", kind: "command.version", targetKey: "java", required: true }], dependencyProbes: [{ key: "java-21", kind: "java.version", targetKey: "java", minimumVersion: "21" }], @@ -1635,10 +1525,9 @@ describe("plugin SDK", () => { serverInstanceId: "server-1", permissions: ["server.lifecycle"] }; - const request = createProductionPluginLifecycleRequest({ requestId: "plugin-upgrade-1", context, operation: "upgrade", targetVersion: "1.2.0", idempotencyKey: "plugin-upgrade-v1" }); - expect(request).toMatchObject({ action: "plugin-lifecycle.request", payload: { operation: "upgrade", targetVersion: "1.2.0", confirmed: "false" } }); - expect(JSON.stringify(request)).not.toMatch(/apiKey|providerBaseUrl|runSocket|runEndpoint|hostPath|credential/i); - expect(() => createProductionPluginLifecycleRequest({ requestId: "plugin-retire-1", context, operation: "retire", idempotencyKey: "plugin-retire-v1" })).toThrow(/confirmation/); - }); + const request = createProductionPluginLifecycleRequest({ requestId: "plugin-upgrade-1", context, operation: "upgrade", targetVersion: "1.2.0", idempotencyKey: "plugin-upgrade-v1" }); + expect(request).toMatchObject({ action: "plugin-lifecycle.request", payload: { operation: "upgrade", targetVersion: "1.2.0", idempotencyKey: "plugin-upgrade-v1" } }); + expect(JSON.stringify(request)).not.toMatch(/apiKey|providerBaseUrl|runSocket|runEndpoint|hostPath|credential/i); + }); });