diff --git a/AGENTS.md b/AGENTS.md index 5ff4806..a910391 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -89,9 +89,9 @@ Do not define business structs inside functions. Do not define request/response The external run executor must not expose host paths, raw credentials, or direct sockets to plugins or platform_web. -Platform, plugin, and run lifecycle ownership must stay separated. Run is the lifecycle authority for machine execution; Platform records desired lifecycle intent, registration/auth, audit, generated package inputs, and persisted projections from Run-reported facts, not observed process truth: +Platform, plugin, and run lifecycle ownership must stay separated. Run is the lifecycle authority for machine execution; Platform records desired lifecycle intent, registration/auth, generated package inputs, and persisted projections from Run-reported facts, not observed process truth: -- Platform owns server instances, plugin manifest validation, platform-side distribution builds, generated Run package inputs, run registration binding, authorization/audit, and persisted lifecycle projections. +- Platform owns server instances, plugin manifest validation, platform-side distribution builds, generated Run package inputs, run registration binding, authorization, and persisted lifecycle projections. - Plugins own game-specific lifecycle declarations: init/install/update/pre-start checks, dependency probes/install plans, start arguments, stop logic, status/readiness probes, executable paths, Steam app IDs, and game-specific dependency commands. - Run owns generic machine lifecycle execution and the observed runtime/process state it supervises: local bootstrap from generated package plans, scoped file operations, bounded process execution/supervision, declared capability enforcement, logs, artifacts, and channel transport. diff --git a/platform/AGENTS.md b/platform/AGENTS.md index b12dce9..9c4d36e 100644 --- a/platform/AGENTS.md +++ b/platform/AGENTS.md @@ -27,7 +27,7 @@ Plugins and platform_web must never receive run credentials, raw host paths, or Platform may read plugin manifests, validate lifecycle/action declarations, package plugin-owned action assets into generated run distributions, and dispatch lifecycle jobs with typed inputs. Platform must not implement game-specific install/update/start behavior itself. -Platform may persist desired lifecycle state, last-known run reports, audit history, and UI projections. It must not treat those persisted fields as the authoritative source for the current machine/process state; current runtime status must come from the registered run endpoint's reports, heartbeats, supervised process facts, and job/log channels. +Platform may persist desired lifecycle state, last-known run reports, and UI projections. It must not treat those persisted fields as the authoritative source for the current machine/process state; current runtime status must come from the registered run endpoint's reports, heartbeats, supervised process facts, and job/log channels. Do not add platform service code that hardcodes a game's executable path, Steam app ID, SteamCMD command line, process name, default launch flags, or update policy. For SCUM specifically, `SCUMServer.exe`, app `3792580`, `+app_update 3792580 validate`, `-port`, `-MaxPlayers`, and `-log` must come from the SCUM plugin action assets or plugin-declared startup fields. diff --git a/platform/README.md b/platform/README.md index 5cf03e6..f2360bb 100644 --- a/platform/README.md +++ b/platform/README.md @@ -4,7 +4,7 @@ Backend control plane for the game server management platform. ## Responsibilities -- Users, roles, permissions, sessions, and audit. +- Users, roles, permissions, and sessions. - Game management plugin installation metadata and marketplace views. - Server instance records and lifecycle orchestration. - AI provider configuration and platform-mediated AI invocation. @@ -83,7 +83,7 @@ export PLATFORM_LOG_DIR=.platform-data/logs go run ./cmd/platform ``` -MySQL is the platform metadata database here. It stores the platform metadata snapshot table and should later hold normalized users/plugins/servers/jobs/audit/log stream indexes. It is not the high-volume log body store; keep log bodies in segmented files locally, or add a future ClickHouse/Loki/OpenSearch/object-storage `LogBodyStore` adapter for production scale. +MySQL is the platform metadata database here. It stores the platform metadata snapshot table and should later hold normalized users/plugins/servers/jobs/log stream indexes. It is not the high-volume log body store; keep log bodies in segmented files locally, or add a future ClickHouse/Loki/OpenSearch/object-storage `LogBodyStore` adapter for production scale. For local direct debugging, copy `platform/.env.example` to `platform/.env`, edit the values, and run: @@ -103,4 +103,4 @@ Client Manager installations are durable aggregates separate from Run distributi Component registration uses the current client-manager key generation, a timestamped nonce, and a short-lived hashed component session. It never reuses a Run session or job lease. Key reset revokes old sessions/artifacts and marks the installation for current-generation rebuild/redeploy. Run reports only logical health, phase, and bounded execution evidence; host paths, PIDs, sockets, raw keys, and credential material are not operator or plugin projections. Production KMS/code-signing, private source credentials, and fleet rollout remain explicit non-goals. -Validated plugin runtime profiles and per-server runtime bindings are part of durable metadata for advanced logical transports. Server creation requires only the plugin type and server name, and plugin-declared deployment/lifecycle actions must be enough for user-facing start/stop and generated Run package flows without forcing operators through a manual runtime-profile binding screen. Browser and plugin-facing responses expose readiness only, not binding values. Platform-owned Docker builds need no registered Run endpoint with `distribution.build`; component keys remain in platform-controlled per-job input. This change uses controlled secret references and an injectable AES-GCM component-key envelope. The built-in envelope key is a disposable-development compatibility fallback; deployments must set `PLATFORM_SECRET_ENVELOPE_KEY`. This is not a production vault/KMS or machine-side runtime resolver. Durable scheduling, process supervision, durable log/artifact bodies, bounded metrics/backups, declaration-backed remote adapter envelopes, typed dependency installation, and transactional Run self-update are implemented. Client-manager lifecycle, production signing/fleet rollout, external provider/storage adapters, production scaling/alerts, plugin lifecycle, and real AI-provider integration remain separate tasks. +Validated plugin runtime profiles and per-server runtime bindings are part of durable metadata for advanced logical transports. Server creation requires only the plugin type and server name, and plugin-declared deployment/lifecycle actions must be enough for user-facing start/stop and generated Run package flows without forcing operators through a manual runtime-profile binding screen. Browser and plugin-facing responses expose readiness only, not binding values. Platform-owned Docker builds need no registered Run endpoint with `distribution.build`; component keys remain in platform-held per-job input. This change uses scoped secret references and an injectable AES-GCM component-key envelope. The built-in envelope key is a disposable-development compatibility fallback; deployments must set `PLATFORM_SECRET_ENVELOPE_KEY`. This is not a production vault/KMS or machine-side runtime resolver. Durable scheduling, process supervision, durable log/artifact bodies, bounded metrics/backups, declaration-backed remote adapter envelopes, typed dependency installation, and transactional Run self-update are implemented. Client-manager lifecycle, production signing/fleet rollout, external provider/storage adapters, production scaling/alerts, plugin lifecycle, and real AI-provider integration remain separate tasks. diff --git a/platform/api/authorization.go b/platform/api/authorization.go index ed52dce..b3ca444 100644 --- a/platform/api/authorization.go +++ b/platform/api/authorization.go @@ -59,8 +59,7 @@ func platformAdminRequest(r *http.Request) bool { if path == "/api/v1/users" || strings.HasPrefix(path, "/api/v1/users/") || path == "/api/v1/ai-providers" || strings.HasPrefix(path, "/api/v1/ai-providers/") || path == "/api/v1/metrics/platform" || - path == "/api/v1/run/endpoints" || strings.HasPrefix(path, "/api/v1/run/endpoints/") || - path == "/api/v1/audit-events" || strings.HasPrefix(path, "/api/v1/audit-events/") { + path == "/api/v1/run/endpoints" || strings.HasPrefix(path, "/api/v1/run/endpoints/") { return true } if r.Method != http.MethodGet && (path == "/api/v1/game-plugins" || strings.HasPrefix(path, "/api/v1/game-plugins/") || strings.Contains(path, "/plugin-marketplace/plugins/")) { diff --git a/platform/api/authorization_test.go b/platform/api/authorization_test.go index 020dd41..039bbb3 100644 --- a/platform/api/authorization_test.go +++ b/platform/api/authorization_test.go @@ -142,13 +142,12 @@ func TestRunHTTPEnvelopeRequiresValidSignatureAndRejectsReplay(t *testing.T) { assertErrorResponse(t, staleClaim, http.StatusUnauthorized, errorCodeUnauthorized) privateUpdateBodies := map[string]any{ - "/api/v1/run/lifecycle/report": dto.RunLifecycleReportRequest{RunEndpointID: "run-local", SessionToken: token, ServerInstanceID: "server-signed", Capability: domain.LifecycleCapabilityStart, State: domain.JobStateSucceeded, Progress: dto.JobProgressBody{Percent: 100}, ExecutionResult: dto.RunJobExecutionResultBody{Kind: "process", ProcessState: "running"}}, - "/api/v1/run/jobs/dependency-input": dto.DependencyExecutionInputRequest{RunEndpointID: "run-local", SessionToken: token, JobID: "job-signed", LeaseToken: "lease", Attempt: 1}, - "/api/v1/run/jobs/protected-request-input": dto.ProtectedRequestExecutionInputRequest{RunEndpointID: "run-local", SessionToken: token, JobID: "job-signed", LeaseToken: "lease", Attempt: 1, FencingToken: 1}, - "/api/v1/run/jobs/source-rcon-input": dto.SourceRCONExecutionInputRequest{RunEndpointID: "run-local", SessionToken: token, JobID: "job-signed", LeaseToken: "lease", Attempt: 1}, - "/api/v1/run/jobs/update-input": dto.RunUpdateInputRequest{RunEndpointID: "run-local", SessionToken: token, JobID: "job-signed", LeaseToken: "lease", Attempt: 1}, - "/api/v1/run/jobs/update-chunk": dto.RunUpdateChunkRequest{RunEndpointID: "run-local", SessionToken: token, JobID: "job-signed", LeaseToken: "lease", Attempt: 1, Offset: 0, Length: 8}, - "/api/v1/run/jobs/update-health": dto.RunUpdateHealthRequest{RunEndpointID: "run-local", SessionToken: token, JobID: "job-signed", LeaseToken: "lease", Attempt: 1, Outcome: "succeeded", Version: "0.1.1"}, + "/api/v1/run/lifecycle/report": dto.RunLifecycleReportRequest{RunEndpointID: "run-local", SessionToken: token, ServerInstanceID: "server-signed", Capability: domain.LifecycleCapabilityStart, State: domain.JobStateSucceeded, Progress: dto.JobProgressBody{Percent: 100}, ExecutionResult: dto.RunJobExecutionResultBody{Kind: "process", ProcessState: "running"}}, + "/api/v1/run/jobs/dependency-input": dto.DependencyExecutionInputRequest{RunEndpointID: "run-local", SessionToken: token, JobID: "job-signed", LeaseToken: "lease", Attempt: 1}, + "/api/v1/run/jobs/source-rcon-input": dto.SourceRCONExecutionInputRequest{RunEndpointID: "run-local", SessionToken: token, JobID: "job-signed", LeaseToken: "lease", Attempt: 1}, + "/api/v1/run/jobs/update-input": dto.RunUpdateInputRequest{RunEndpointID: "run-local", SessionToken: token, JobID: "job-signed", LeaseToken: "lease", Attempt: 1}, + "/api/v1/run/jobs/update-chunk": dto.RunUpdateChunkRequest{RunEndpointID: "run-local", SessionToken: token, JobID: "job-signed", LeaseToken: "lease", Attempt: 1, Offset: 0, Length: 8}, + "/api/v1/run/jobs/update-health": dto.RunUpdateHealthRequest{RunEndpointID: "run-local", SessionToken: token, JobID: "job-signed", LeaseToken: "lease", Attempt: 1, Outcome: "succeeded", Version: "0.1.1"}, } nonce := 10 for path, request := range privateUpdateBodies { diff --git a/platform/api/client_manager_lifecycle_handlers.go b/platform/api/client_manager_lifecycle_handlers.go index 04fa9d3..23e4893 100644 --- a/platform/api/client_manager_lifecycle_handlers.go +++ b/platform/api/client_manager_lifecycle_handlers.go @@ -206,7 +206,7 @@ func (h *coreHandlers) serverClientManagerRevokeSession(w http.ResponseWriter, r // serverClientManagerUninstall godoc // @Summary Safely uninstall a Client Manager -// @Description Stops the supervised process and removes only the controlled installation workspace while retaining audit and distribution history. +// @Description Stops the supervised process and removes the Client Manager installation workspace while retaining build and distribution records. // @Tags client-managers // @Accept json // @Produce json diff --git a/platform/api/control_handlers_test.go b/platform/api/control_handlers_test.go index 78659fd..ea744e9 100644 --- a/platform/api/control_handlers_test.go +++ b/platform/api/control_handlers_test.go @@ -52,7 +52,7 @@ func TestRunLifecycleReportAPIProjectsServerState(t *testing.T) { hello := decodeBody[dto.RunControlHelloResponse](t, performRunControlHello(t, router, helloRequest)) server := postJSONWithAuth[dto.ServerInstanceResponse](t, router, "/api/v1/server-instances", dto.ServerInstanceCreateRequest{ID: "server-lifecycle-report-api", PluginID: "server.scum", RunEndpointID: "run-local", Name: "Lifecycle Report API", State: domain.ServerInstanceStateReady}, adminSession) - recorder := performJSON(t, router, http.MethodPost, "/api/v1/run/lifecycle/report", dto.RunLifecycleReportRequest{RunEndpointID: "run-local", SessionToken: hello.SessionToken, ServerInstanceID: server.ID, Capability: domain.LifecycleCapabilityStart, State: domain.JobStateSucceeded, Progress: dto.JobProgressBody{Percent: 100, Message: "autonomous start complete"}, Message: "autonomous start complete", ExecutionResult: dto.RunJobExecutionResultBody{Kind: "process", ProcessState: "running", AuditSummary: "private supervised process identity"}}) + recorder := performJSON(t, router, http.MethodPost, "/api/v1/run/lifecycle/report", dto.RunLifecycleReportRequest{RunEndpointID: "run-local", SessionToken: hello.SessionToken, ServerInstanceID: server.ID, Capability: domain.LifecycleCapabilityStart, State: domain.JobStateSucceeded, Progress: dto.JobProgressBody{Percent: 100, Message: "autonomous start complete"}, Message: "autonomous start complete", ExecutionResult: dto.RunJobExecutionResultBody{Kind: "process", ProcessState: "running", Summary: "private supervised process identity"}}) assertStatus(t, recorder, http.StatusOK) response := decodeBody[dto.RunLifecycleReportResponse](t, recorder) if !response.Accepted || response.ProjectedState != domain.ServerInstanceStateRunning { diff --git a/platform/api/game_client_bridge_handlers_test.go b/platform/api/game_client_bridge_handlers_test.go index 8af28bd..3b4c90f 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: "announcement.send", Title: "Send announcement", Permission: "server.game-client.command", ApprovalLevel: domain.GameClientBridgeApprovalLevelOperator, PayloadSchemaRef: "schemas/bridge/announcement.schema.json", ResultSchemaRef: "schemas/bridge/announcement-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", 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}} if _, err := coreService.CreateGamePlugin(plugin); err != nil { t.Fatalf("create bridge plugin: %v", err) } @@ -72,7 +72,7 @@ func TestGameClientBridgeOperatorRoutes(t *testing.T) { t.Fatalf("bridge status did not safely expose query template availability: %#v", status) } - queue := dto.GameClientBridgeQueueRequest{ProfileKey: "scum-client", CommandType: "announcement.send", Payload: map[string]any{"message": "hello"}, IdempotencyKey: "announce-1", ExpiresAt: time.Now().UTC().Add(time.Hour)} + queue := dto.GameClientBridgeQueueRequest{ProfileKey: "scum-client", CommandType: "diagnostic.ping", Payload: map[string]any{"message": "hello"}, IdempotencyKey: "diag-1", ExpiresAt: time.Now().UTC().Add(time.Hour)} queuedRecorder := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/server-bridge/game-client-bridge/commands", queue, adminSession) assertStatus(t, queuedRecorder, http.StatusAccepted) queued := decodeBody[dto.GameClientBridgeCommandResponse](t, queuedRecorder) diff --git a/platform/api/protected_request_handlers.go b/platform/api/protected_request_handlers.go deleted file mode 100644 index 781f502..0000000 --- a/platform/api/protected_request_handlers.go +++ /dev/null @@ -1,37 +0,0 @@ -package api - -import ( - "net/http" - - "browser.local/platform/dto" -) - -// runProtectedRequestInput godoc -// @Summary Read one protected request for the active Run lease -// @Description Returns approved SQL, RCON, or management-program text exactly once to its signed, fenced Run lease. Browser and plugin clients never receive this payload. -// @Tags run-job-channel -// @Accept json -// @Produce json -// @Param body body dto.ProtectedRequestExecutionInputRequest true "Fenced protected request input request" -// @Success 200 {object} dto.ProtectedRequestExecutionInputResponse -// @Failure 400 {object} dto.ErrorResponse -// @Failure 401 {object} dto.ErrorResponse -// @Failure 405 {object} dto.ErrorResponse -// @Router /api/v1/run/jobs/protected-request-input [post] -func (h *coreHandlers) runProtectedRequestInput(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - writeMethodNotAllowed(w, http.MethodPost) - return - } - request, err := decodeJSON[dto.ProtectedRequestExecutionInputRequest](r) - if err != nil { - writeDecodeError(w, err) - return - } - input, err := h.core.GetProtectedRequestExecutionInput(request.ToDomain()) - if err != nil { - writeServiceError(w, err) - return - } - writeJSON(w, http.StatusOK, dto.ProtectedRequestExecutionInputFromDomain(input)) -} diff --git a/platform/api/resource_handlers.go b/platform/api/resource_handlers.go index 884cf82..e6c4a5a 100644 --- a/platform/api/resource_handlers.go +++ b/platform/api/resource_handlers.go @@ -47,12 +47,6 @@ func (h *coreHandlers) register(mux *http.ServeMux) { mux.HandleFunc("/api/v1/game-plugins/{id}", h.gamePluginDetail) mux.HandleFunc("/api/v1/metrics/platform", h.platformMetrics) mux.HandleFunc("/api/v1/metrics/server-instances", h.serverInstanceMetrics) - mux.HandleFunc("/api/v1/production/capacity", h.productionCapacity) - mux.HandleFunc("/api/v1/production/capacity/admission", h.productionCapacityAdmission) - mux.HandleFunc("/api/v1/alerts", h.alerts) - mux.HandleFunc("/api/v1/alerts/{id}/acknowledge", h.alertAcknowledge) - mux.HandleFunc("/api/v1/alerts/{id}/resolve", h.alertResolve) - mux.HandleFunc("/api/v1/alerts/{id}/retry", h.alertRetry) mux.HandleFunc("/api/v1/plugin-lifecycles", h.pluginLifecycles) mux.HandleFunc("/api/v1/plugin-lifecycles/{pluginId}/actions", h.pluginLifecycleAction) mux.HandleFunc("/api/v1/ai/config-diffs", h.aiConfigDiffs) @@ -98,6 +92,7 @@ func (h *coreHandlers) register(mux *http.ServeMux) { mux.HandleFunc("/api/v1/server-instances/{id}/dependencies/install", h.serverDependenciesInstall) mux.HandleFunc("/api/v1/server-instances/{id}/dependencies", h.serverDependencies) mux.HandleFunc("/api/v1/server-instances/{id}/logs/events", h.serverLogEvents) + mux.HandleFunc("/api/v1/server-instances/{id}/rcon/commands", h.sourceRCONCommands) mux.HandleFunc("/api/v1/server-instances/{id}/administrators/candidates", h.serverAdministratorCandidates) mux.HandleFunc("/api/v1/server-instances/{id}/administrators", h.serverAdministrators) mux.HandleFunc("/api/v1/server-instances/{id}/administrators/{userId}", h.serverAdministratorDetail) @@ -112,7 +107,6 @@ func (h *coreHandlers) register(mux *http.ServeMux) { mux.HandleFunc("/api/v1/run/jobs/build-input", h.requireRunSignature(h.runJobBuildInput)) mux.HandleFunc("/api/v1/run/jobs/dependency-input", h.requireRunSignature(h.runJobDependencyInput)) mux.HandleFunc("/api/v1/run/jobs/source-rcon-input", h.requireRunSignature(h.runSourceRCONInput)) - mux.HandleFunc("/api/v1/run/jobs/protected-request-input", h.requireRunSignature(h.runProtectedRequestInput)) mux.HandleFunc("/api/v1/run/jobs/update-input", h.requireRunSignature(h.runJobUpdateInput)) mux.HandleFunc("/api/v1/run/jobs/update-chunk", h.requireRunSignature(h.runJobUpdateChunk)) mux.HandleFunc("/api/v1/run/jobs/update-health", h.requireRunSignature(h.runJobUpdateHealth)) @@ -139,8 +133,6 @@ func (h *coreHandlers) register(mux *http.ServeMux) { mux.HandleFunc("/api/v1/log-streams", h.logStreams) mux.HandleFunc("/api/v1/log-streams/query", h.logStreamQuery) mux.HandleFunc("/api/v1/log-streams/{id}", h.logStreamDetail) - mux.HandleFunc("/api/v1/audit-events", h.auditEvents) - mux.HandleFunc("/api/v1/audit-events/{id}", h.auditEventDetail) mux.HandleFunc("/api/v1/client-managers/register", h.clientManagerRegister) mux.HandleFunc("/api/v1/client-managers/heartbeat", h.clientManagerHeartbeat) mux.HandleFunc("/api/v1/game-client-bridge/companion/commands/claim", h.gameClientBridgeCompanionClaim) @@ -150,180 +142,9 @@ func (h *coreHandlers) register(mux *http.ServeMux) { mux.HandleFunc("/api/v1/game-client-bridge/companion/diagnostics", h.gameClientBridgeCompanionDiagnostics) } -// productionCapacity godoc -// @Summary Get production capacity governance state -// @Description Returns bounded Run endpoint capacity and durable pressure counts visible to the current operator. -// @Tags production-operations -// @Produce json -// @Success 200 {object} dto.ProductionCapacitySummaryResponse -// @Failure 401 {object} dto.ErrorResponse -// @Failure 405 {object} dto.ErrorResponse -// @Router /api/v1/production/capacity [get] -func (h *coreHandlers) productionCapacity(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - writeMethodNotAllowed(w, http.MethodGet) - return - } - summary, err := h.core.GetProductionCapacityForSession(bearerToken(r)) - if err != nil { - writeServiceError(w, err) - return - } - writeJSON(w, http.StatusOK, dto.ProductionCapacityFromDomain(summary)) -} - -// productionCapacityAdmission godoc -// @Summary Check production capacity admission -// @Description Evaluates endpoint heartbeat, capability, durable jobs, and bounded backlog pressure without dispatching work. -// @Tags production-operations -// @Accept json -// @Produce json -// @Param body body dto.CapacityAdmissionRequest true "Capacity admission request" -// @Success 200 {object} dto.CapacityAdmissionDecisionResponse -// @Failure 400 {object} dto.ErrorResponse -// @Failure 401 {object} dto.ErrorResponse -// @Failure 403 {object} dto.ErrorResponse -// @Failure 405 {object} dto.ErrorResponse -// @Router /api/v1/production/capacity/admission [post] -func (h *coreHandlers) productionCapacityAdmission(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - writeMethodNotAllowed(w, http.MethodPost) - return - } - request, err := decodeJSON[dto.CapacityAdmissionRequest](r) - if err != nil { - writeDecodeError(w, err) - return - } - decision, err := h.core.CheckCapacityAdmissionForSession(bearerToken(r), request.ToDomain()) - if err != nil { - writeServiceError(w, err) - return - } - writeJSON(w, http.StatusOK, dto.CapacityDecisionFromDomain(decision)) -} - -// alerts godoc -// @Summary List durable production alerts -// @Description Lists alerts visible to the current operator with optional safe state/source/severity filters. -// @Tags production-operations -// @Produce json -// @Success 200 {object} dto.AlertListResponse -// @Failure 401 {object} dto.ErrorResponse -// @Failure 405 {object} dto.ErrorResponse -// @Router /api/v1/alerts [get] -func (h *coreHandlers) alerts(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - writeMethodNotAllowed(w, http.MethodGet) - return - } - alerts, err := h.core.ListAlertsForSession(bearerToken(r), domain.AlertFilter{State: domain.AlertState(r.URL.Query().Get("state")), SourceKind: r.URL.Query().Get("sourceKind"), SourceID: r.URL.Query().Get("sourceId"), Severity: domain.AlertSeverity(r.URL.Query().Get("severity"))}) - if err != nil { - writeServiceError(w, err) - return - } - writeJSON(w, http.StatusOK, dto.AlertListFromDomain(alerts)) -} - -// alertAcknowledge godoc -// @Summary Acknowledge a durable alert -// @Description Persists acknowledgement actor, timestamp, and linked audit evidence for one alert. -// @Tags production-operations -// @Accept json -// @Produce json -// @Param id path string true "Alert ID" -// @Param body body dto.AlertAcknowledgeRequest true "Acknowledgement request" -// @Success 200 {object} dto.AlertResponse -// @Failure 400 {object} dto.ErrorResponse -// @Failure 401 {object} dto.ErrorResponse -// @Failure 403 {object} dto.ErrorResponse -// @Failure 405 {object} dto.ErrorResponse -// @Router /api/v1/alerts/{id}/acknowledge [post] -func (h *coreHandlers) alertAcknowledge(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - writeMethodNotAllowed(w, http.MethodPost) - return - } - request, err := decodeJSON[dto.AlertAcknowledgeRequest](r) - if err != nil { - writeDecodeError(w, err) - return - } - alert, err := h.core.AcknowledgeAlertForSession(bearerToken(r), domain.AlertAcknowledgeRequest{AlertID: r.PathValue("id"), Note: request.Note}) - if err != nil { - writeServiceError(w, err) - return - } - writeJSON(w, http.StatusOK, dto.AlertFromDomain(alert)) -} - -// alertResolve godoc -// @Summary Resolve a durable alert -// @Description Resolves one alert with a safe operator note and linked audit evidence. -// @Tags production-operations -// @Accept json -// @Produce json -// @Param id path string true "Alert ID" -// @Param body body dto.AlertResolveRequest true "Resolution request" -// @Success 200 {object} dto.AlertResponse -// @Failure 400 {object} dto.ErrorResponse -// @Failure 401 {object} dto.ErrorResponse -// @Failure 403 {object} dto.ErrorResponse -// @Failure 405 {object} dto.ErrorResponse -// @Router /api/v1/alerts/{id}/resolve [post] -func (h *coreHandlers) alertResolve(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - writeMethodNotAllowed(w, http.MethodPost) - return - } - request, err := decodeJSON[dto.AlertResolveRequest](r) - if err != nil { - writeDecodeError(w, err) - return - } - alert, err := h.core.ResolveAlertForSession(bearerToken(r), domain.AlertResolveRequest{AlertID: r.PathValue("id"), Note: request.Note}) - if err != nil { - writeServiceError(w, err) - return - } - writeJSON(w, http.StatusOK, dto.AlertFromDomain(alert)) -} - -// alertRetry godoc -// @Summary Retry one durable alert source -// @Description Retries only the bounded source represented by an alert and preserves idempotency. -// @Tags production-operations -// @Accept json -// @Produce json -// @Param id path string true "Alert ID" -// @Param body body dto.AlertRetryRequest true "Scoped retry request" -// @Success 202 {object} dto.AlertRetryResponse -// @Failure 400 {object} dto.ErrorResponse -// @Failure 401 {object} dto.ErrorResponse -// @Failure 403 {object} dto.ErrorResponse -// @Failure 405 {object} dto.ErrorResponse -// @Router /api/v1/alerts/{id}/retry [post] -func (h *coreHandlers) alertRetry(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - writeMethodNotAllowed(w, http.MethodPost) - return - } - request, err := decodeJSON[dto.AlertRetryRequest](r) - if err != nil { - writeDecodeError(w, err) - return - } - result, err := h.core.RetryAlertForSession(bearerToken(r), domain.AlertRetryRequest{AlertID: r.PathValue("id"), IdempotencyKey: request.IdempotencyKey}) - if err != nil { - writeServiceError(w, err) - return - } - writeJSON(w, http.StatusAccepted, dto.AlertRetryFromDomain(result)) -} - // pluginLifecycles godoc // @Summary List server-bound plugin lifecycle state -// @Description Lists durable plugin installation, desired/current state, compatibility, dependency, job, alert, and audit metadata. +// @Description Lists durable plugin installation, desired/current state, compatibility, dependency, and job metadata. // @Tags production-operations // @Produce json // @Success 200 {object} dto.PluginLifecycleListResponse @@ -2598,70 +2419,3 @@ func (h *coreHandlers) logStreamDetail(w http.ResponseWriter, r *http.Request) { } writeJSON(w, http.StatusOK, dto.LogStreamFromDomain(stream)) } - -// auditEvents godoc -// @Summary Create or list audit events -// @Description Creates or lists audit event metadata. -// @Tags audit-events -// @Accept json -// @Produce json -// @Param body body dto.AuditEventCreateRequest false "Audit event create request" -// @Success 200 {object} dto.AuditEventListResponse -// @Success 201 {object} dto.AuditEventResponse -// @Failure 400 {object} dto.ErrorResponse -// @Failure 405 {object} dto.ErrorResponse -// @Router /api/v1/audit-events [get] -// @Router /api/v1/audit-events [post] -func (h *coreHandlers) auditEvents(w http.ResponseWriter, r *http.Request) { - switch r.Method { - case http.MethodGet: - events, err := h.core.ListAuditEvents(domain.AuditEventFilter{ - ActorID: r.URL.Query().Get("actorId"), - ResourceKind: r.URL.Query().Get("resourceKind"), - ResourceID: r.URL.Query().Get("resourceId"), - Result: domain.AuditResult(r.URL.Query().Get("result")), - }) - if err != nil { - writeServiceError(w, err) - return - } - writeJSON(w, http.StatusOK, dto.AuditEventListFromDomain(events)) - case http.MethodPost: - request, err := decodeJSON[dto.AuditEventCreateRequest](r) - if err != nil { - writeDecodeError(w, err) - return - } - event, err := h.core.CreateAuditEvent(request.ToDomain()) - if err != nil { - writeServiceError(w, err) - return - } - writeJSON(w, http.StatusCreated, dto.AuditEventFromDomain(event)) - default: - writeMethodNotAllowed(w, "GET, POST") - } -} - -// auditEventDetail godoc -// @Summary Get audit event -// @Description Returns one audit event by ID. -// @Tags audit-events -// @Produce json -// @Param id path string true "Audit event ID" -// @Success 200 {object} dto.AuditEventResponse -// @Failure 404 {object} dto.ErrorResponse -// @Failure 405 {object} dto.ErrorResponse -// @Router /api/v1/audit-events/{id} [get] -func (h *coreHandlers) auditEventDetail(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - writeMethodNotAllowed(w, http.MethodGet) - return - } - event, err := h.core.GetAuditEvent(r.PathValue("id")) - if err != nil { - writeServiceError(w, err) - return - } - writeJSON(w, http.StatusOK, dto.AuditEventFromDomain(event)) -} diff --git a/platform/api/resource_handlers_test.go b/platform/api/resource_handlers_test.go index 6110b58..4ce0188 100644 --- a/platform/api/resource_handlers_test.go +++ b/platform/api/resource_handlers_test.go @@ -127,21 +127,6 @@ func TestCoreAPICreateListDetailWorkflows(t *testing.T) { t.Fatalf("expected explicitly created stream in list, got %+v", streams) } - auditResponse := postJSON[dto.AuditEventResponse](t, router, "/api/v1/audit-events", dto.AuditEventCreateRequest{ - ID: "audit-1", - ActorID: "user-1", - Action: "server.create", - ResourceKind: "server-instance", - ResourceID: "server-1", - Result: domain.AuditResultSuccess, - Summary: "created server instance", - }) - if auditResponse.Result != domain.AuditResultSuccess { - t.Fatalf("expected successful audit event, got %+v", auditResponse) - } - getJSON[dto.AuditEventResponse](t, router, "/api/v1/audit-events/audit-1") - auditEvents := getJSON[dto.AuditEventListResponse](t, router, "/api/v1/audit-events?actorId=user-1&resourceKind=server-instance&resourceId=server-1&result=success") - assertListCount(t, auditEvents.Count, 1) } func TestMetricsAndConfigReadAPIAreSafeAndRoleScoped(t *testing.T) { @@ -349,16 +334,6 @@ func TestCoreAPIServerRuntimeDistributionAndJobWorkflows(t *testing.T) { } } - audits := getJSONWithAuth[dto.AuditEventListResponse](t, router, "/api/v1/audit-events?resourceId="+serverID, adminSession) - auditActions := map[string]bool{} - for _, audit := range audits.Items { - auditActions[audit.Action] = true - } - for _, action := range []string{"run.generate", "client-manager.build", "dependency.install", "runtime-key.reset"} { - if !auditActions[action] { - t.Fatalf("expected audit action %q in %+v", action, audits.Items) - } - } } func TestCoreAPIRunDistributionDenialNamesMissingPluginPermission(t *testing.T) { @@ -1538,31 +1513,19 @@ func TestGamePluginRegistryResponseDoesNotExposeRawInternals(t *testing.T) { } } -func TestProductionOperationsGovernanceRoutesAreDurableAndRedacted(t *testing.T) { +func TestPluginLifecycleAndAIConfigRoutesAreDurableAndRedacted(t *testing.T) { router := newTestRouter() adminSession := createAdminSession(t, router) serverID := createRuntimeAPIFixtures(t, router, adminSession) createAIProviderFixture(t, router, adminSession) - capacity := getJSONWithAuth[dto.ProductionCapacitySummaryResponse](t, router, "/api/v1/production/capacity", adminSession) - if len(capacity.Endpoints) == 0 { - t.Fatalf("expected persisted capacity endpoints, got %+v", capacity) + lifecycle := postOKJSONWithAuth[dto.PluginLifecycleActionResponse](t, router, "/api/v1/plugin-lifecycles/server.runtime/actions", dto.PluginLifecycleActionRequest{ServerInstanceID: serverID, Operation: "install", TargetVersion: "1.0.0", IdempotencyKey: "api-plugin-install", Confirmed: false}, adminSession) + if lifecycle.Status != "queued" || lifecycle.Job.ID == "" || lifecycle.Installation.ID == "" { + t.Fatalf("expected queued plugin lifecycle job, got %+v", lifecycle) } - decision := postOKJSONWithAuth[dto.CapacityAdmissionDecisionResponse](t, router, "/api/v1/production/capacity/admission", dto.CapacityAdmissionRequest{ServerInstanceID: serverID, Capability: domain.JobCapabilityRemoteRunRCONCommand, IdempotencyKey: "api-capacity-check"}, adminSession) - if decision.Accepted || decision.AlertID == "" || decision.AuditEventID == "" { - t.Fatalf("expected unavailable capability admission to create durable evidence, got %+v", decision) - } - alerts := getJSONWithAuth[dto.AlertListResponse](t, router, "/api/v1/alerts?state=active", adminSession) - if alerts.Count == 0 { - t.Fatalf("expected durable alert list, got %+v", alerts) - } - acknowledged := postOKJSONWithAuth[dto.AlertResponse](t, router, "/api/v1/alerts/"+decision.AlertID+"/acknowledge", dto.AlertAcknowledgeRequest{Note: "operator review"}, adminSession) - if acknowledged.State != string(domain.AlertStateAcknowledged) { - t.Fatalf("expected acknowledged state, got %+v", acknowledged) - } - resolved := postOKJSONWithAuth[dto.AlertResponse](t, router, "/api/v1/alerts/"+decision.AlertID+"/resolve", dto.AlertResolveRequest{Note: "review complete"}, adminSession) - if resolved.State != string(domain.AlertStateResolved) { - t.Fatalf("expected resolved state, got %+v", resolved) + lifecycles := getJSONWithAuth[dto.PluginLifecycleListResponse](t, router, "/api/v1/plugin-lifecycles?serverInstanceId="+serverID, adminSession) + if lifecycles.Count != 1 || lifecycles.Items[0].JobID != lifecycle.Job.ID { + t.Fatalf("expected persisted plugin lifecycle, got %+v", lifecycles) } invocation := postOKJSONWithAuth[dto.AIInvocationResponse](t, router, "/api/v1/ai/invocations", dto.AIInvocationRequest{RequestID: "api-ai-config-diff", ServerInstanceID: serverID, Purpose: "config.suggest", ProviderID: "ai.openai", Prompt: "disable pvp"}, adminSession) @@ -1580,10 +1543,10 @@ func TestProductionOperationsGovernanceRoutesAreDurableAndRedacted(t *testing.T) t.Fatalf("expected approved diff with config write job, got %+v", approval) } - evidence := fmt.Sprintf("%+v %+v %+v %+v %+v %+v", capacity, decision, alerts, resolved, diffs, approval) + evidence := fmt.Sprintf("%+v %+v %+v %+v", lifecycle, lifecycles, diffs, approval) for _, forbidden := range []string{"/Users/", "/private/", "unix://", "tcp://", "Bearer ", "sk-", "password=", "apiKeyRef", "rawApiKey", "https://api.openai.com"} { if strings.Contains(evidence, forbidden) { - t.Fatalf("production governance response leaked forbidden fragment %q: %s", forbidden, evidence) + t.Fatalf("production operations response leaked forbidden fragment %q: %s", forbidden, evidence) } } } diff --git a/platform/api/routes.md b/platform/api/routes.md index 8bcf39a..a158402 100644 --- a/platform/api/routes.md +++ b/platform/api/routes.md @@ -23,7 +23,6 @@ All routes use JSON request and response bodies. Collection routes support `GET` | Jobs | `GET /api/v1/jobs`, `POST /api/v1/jobs` | `GET /api/v1/jobs/{id}` | `JobCreateRequest`, `JobResponse`, `JobListResponse` | | Artifacts | `GET /api/v1/artifacts`, `POST /api/v1/artifacts` | `GET /api/v1/artifacts/{id}`, `POST /api/v1/artifacts/{id}/download`, `GET /api/v1/artifacts/{id}/content` | `ArtifactCreateRequest`, `ArtifactResponse`, `ArtifactListResponse`, `ArtifactDownloadReferenceResponse`, `ArtifactContentRequest` | | Log streams | `GET /api/v1/log-streams`, `POST /api/v1/log-streams` | `GET /api/v1/log-streams/{id}` | `LogStreamCreateRequest`, `LogStreamResponse`, `LogStreamListResponse` | -| Audit events | `GET /api/v1/audit-events`, `POST /api/v1/audit-events` | `GET /api/v1/audit-events/{id}` | `AuditEventCreateRequest`, `AuditEventResponse`, `AuditEventListResponse` | Client Manager lifecycle routes are grouped under the server instance and return only the safe installation projection: `GET /api/v1/server-instances/{id}/client-managers`, `GET .../{profileKey}`, and typed `POST` routes for `deploy`, `control`, `update`, `retry`, `revoke-session`, and confirmed `uninstall`. Component-only `POST /api/v1/client-managers/register` and `/heartbeat` use the separate signed component identity/session contract. Run-only input/chunk routes are fenced by the active Run job lease. None of these DTOs return raw component keys, bearer sessions, secret refs/values, host paths, PIDs, sockets, or endpoint addresses. @@ -40,7 +39,6 @@ Client Manager lifecycle routes are grouped under the server instance and return - `GET /api/v1/jobs?serverInstanceId=server-1&runEndpointId=run-local&state=queued` - `GET /api/v1/artifacts?ownerKind=job&ownerId=job-1&state=uploading` - `GET /api/v1/log-streams?serverInstanceId=server-1&streamKey=stdout` -- `GET /api/v1/audit-events?actorId=user-1&resourceKind=server-instance&resourceId=server-1&result=success` ## Implemented Authentication And Current User Actions @@ -52,7 +50,7 @@ Client Manager lifecycle routes are grouped under the server instance and return - `PUT /api/v1/users/current/profile`: update bounded current-user profile fields using `UserProfileBody`. - `PUT /api/v1/users/current/theme`: persist current-user console theme preferences using `UserThemePreferenceRequest`. -Bearer sessions are stored as SHA-256 verifiers with issued/expiry/revocation timestamps and rotation generation; raw tokens are never written to FileStore/MySQLStore snapshots. Browser sessions use HttpOnly SameSite cookies, while explicit CLI bearer mode returns the token once. Production router construction requires authentication for sensitive API paths, reserves user/provider/plugin install/Run endpoint/audit/global create operations for platform administrators, and repeats server/job/log/artifact ownership checks in services. After the first account exists, public registration defaults to `pending` plus server-scoped roles and does not grant platform administrator privileges. A bootstrap administrator is created only when `PLATFORM_BOOTSTRAP_ADMIN_PASSWORD` is explicitly configured; local debug scripts provide their own development-only value. +Bearer sessions are stored as SHA-256 verifiers with issued/expiry/revocation timestamps and rotation generation; raw tokens are never written to FileStore/MySQLStore snapshots. Browser sessions use HttpOnly SameSite cookies, while explicit CLI bearer mode returns the token once. Production router construction requires authentication for sensitive API paths, reserves user/provider/plugin install/Run endpoint/global create operations for platform administrators, and repeats server/job/log/artifact ownership checks in services. After the first account exists, public registration defaults to `pending` plus server-scoped roles and does not grant platform administrator privileges. A bootstrap administrator is created only when `PLATFORM_BOOTSTRAP_ADMIN_PASSWORD` is explicitly configured; local debug scripts provide their own development-only value. ## Implemented Role-Scoped Server Access @@ -91,7 +89,7 @@ File dispatch responses expose only logical target keys, scoped input/artifact r AI invocation is platform-mediated. `PLATFORM_AI_PROVIDER_MODE=live` uses the Platform-owned HTTP client and environment secret resolver; local verification explicitly uses `mock`. Invocation responses do not expose provider base URLs, API key refs, raw keys, bearer tokens, host paths, run sockets, or storage credentials. Config suggestions persist an expiring diff and never dispatch run-side writes before separate approval. AI Provider management responses also return only `baseUrlConfigured` and `apiKeyConfigured`; an empty base URL or secret reference in an update preserves the Platform-owned value instead of round-tripping it through the browser. -AI provider management responses expose `apiKeyConfigured` only. Create/update requests may carry a controlled secret reference, and a blank update preserves an existing configured secret; the stored reference is not returned to the browser. +AI provider management responses expose `apiKeyConfigured` only. Create/update requests may carry a scoped secret reference, and a blank update preserves an existing configured secret; the stored reference is not returned to the browser. ## Implemented Game Plugin Registry Actions @@ -111,18 +109,18 @@ Marketplace state actions are metadata-only in this change. `install` and `enabl Marketplace catalog state remains separate from production lifecycle installations. Server-bound install/enable/disable/upgrade/rollback/retire operations use the production lifecycle routes below. -## Production Operations Governance +## Production Operations - `GET /api/v1/production/capacity`: return bounded endpoint capacity, durable job pressure, backlog counts, pressure codes, and active-alert count visible to the session. - `POST /api/v1/production/capacity/admission`: evaluate server binding, endpoint heartbeat/capability, job limits, queue pressure, and spool pressure without dispatching work. - `GET /api/v1/alerts`: list durable alerts with state/source/severity filters. -- `POST /api/v1/alerts/{id}/acknowledge`, `/resolve`, and `/retry`: persist one scoped alert transition or source retry with actor/audit evidence. +- `POST /api/v1/alerts/{id}/acknowledge`, `/resolve`, and `/retry`: persist one scoped alert transition or source retry with actor evidence. - `GET /api/v1/plugin-lifecycles`: list server-bound plugin lifecycle installations. - `POST /api/v1/plugin-lifecycles/{pluginId}/actions`: validate manifest declaration, compatibility, confirmation, idempotency, and capacity before creating one durable Run job. - `GET /api/v1/ai/config-diffs`: list reviewable AI config recommendations visible to the session. - `POST /api/v1/ai/config-diffs/{id}/approve`: revalidate actor/server/config revision/checksum/expiry and dispatch exactly one bounded `config.write` job. -These responses expose logical IDs, counts, states, pressure codes, safe diagnostics, and job/audit links only. They never project raw credentials, provider transport configuration, Run sessions/endpoints, host paths, PIDs, sockets, DSNs, or RCON material. +These responses expose logical IDs, counts, states, pressure codes, safe diagnostics, and job links only. They never project raw credentials, provider transport configuration, Run sessions/endpoints, host paths, PIDs, sockets, DSNs, or RCON material. ## Implemented Plugin Bridge Actions @@ -150,7 +148,7 @@ Lifecycle workflow responses include accepted status, action, bounded server ins - `POST /api/v1/server-instances/{id}/run/download`: opens the latest available run package through `ArtifactDownloadReferenceResponse` after server-scoped authorization. - `POST /api/v1/server-instances/{id}/run/key/reset`: resets the server's single active run key, increments generation, revokes previous run packages, and returns `ComponentKeyResponse`. - `POST /api/v1/server-instances/{id}/run/update`: accepts `RunUpdateRequest` with an approved artifact ID/checksum and queues a bounded `run.self-update` job through `RunUpdateJobResponse`. -- `GET /api/v1/server-instances/{id}/run/update`: lists safe update phase, target, progress message, artifact checksum, release identity, rollback, and audit summary for the authorized server. +- `GET /api/v1/server-instances/{id}/run/update`: lists safe update phase, target, progress message, artifact checksum, release identity, rollback, and summary for the authorized server. - `POST /api/v1/server-instances/{id}/client-managers/generate`: accepts `ClientManagerBuildRequest`, validates the plugin-declared client-manager profile and target platform, injects a distinct current client-manager key into the package config, publishes a downloadable artifact, and returns `ClientManagerDistributionResponse`. - `POST /api/v1/server-instances/{id}/client-managers/download`: accepts `ClientManagerDownloadRequest` and opens the latest authorized client-manager artifact through `ArtifactDownloadReferenceResponse`. - `POST /api/v1/server-instances/{id}/client-managers/key/reset`: accepts `ComponentKeyResetRequest`, resets only the named client-manager component key, increments generation, revokes older client-manager packages, and returns `ComponentKeyResponse`. @@ -159,15 +157,15 @@ Lifecycle workflow responses include accepted status, action, bounded server ins - `POST /api/v1/server-instances/{id}/dependencies/install`: accepts `DependencyJobRequest` with an install plan key and the exact catalog `planDigest`; stale/missing digests are denied before job creation. Server-scoped terminal log streaming (`GET /api/v1/server-instances/{id}/logs/events`) is registered for the server detail terminal drawer and emits platform-accepted log SSE history/live events only. The raw log list/backfill routes (`logs/live` and `logs/backfill`) remain unavailable as product APIs; internal log ingest and cursor query remain available for run/platform maintenance flows. -Runtime distribution and client-manager APIs require the current bearer session, server visibility, plugin-declared permissions, complete runtime bindings only for actions that truly depend on external logical bindings, and platform-builder readiness. Run-side lifecycle commands separately require run endpoint capability support and use plugin-declared lifecycle actions without making manual runtime-profile binding a user prerequisite. Responses and audit summaries expose artifact IDs, job IDs, checksums, key generations, fingerprints, status, and redacted `secret://runtime-keys/.../current` refs only. They do not expose raw run keys, client-manager keys, FTP passwords, database DSNs, RCON passwords, host paths, direct sockets, run endpoint private addresses, build workspace paths, or large inline logs. +Runtime distribution and client-manager APIs require the current bearer session, server visibility, plugin-declared permissions, complete runtime bindings only for actions that truly depend on external logical bindings, and platform-builder readiness. Run-side lifecycle commands separately require run endpoint capability support and use plugin-declared lifecycle actions without making manual runtime-profile binding a user prerequisite. Responses and summaries expose artifact IDs, job IDs, checksums, key generations, fingerprints, status, and redacted `secret://runtime-keys/.../current` refs only. They do not expose raw run keys, client-manager keys, FTP passwords, database DSNs, RCON passwords, host paths, direct sockets, run endpoint private addresses, build workspace paths, or large inline logs. -SCUM product APIs expose only safe local projections, typed operation/workflow requests, approval status, confirmation status, blocker reasons, and audit-safe summaries. They never expose SCUM.db SQL text, DB paths, DSNs, RCON command text, raw protected request payloads, run sockets, host paths, or credentials. +SCUM product APIs expose only safe local projections, typed operation/workflow requests, approval status, confirmation status, blocker reasons, and bounded summaries. They never expose SCUM.db SQL text, DB paths, DSNs, RCON command text, raw protected request payloads, run sockets, host paths, or credentials. `POST /api/v1/server-instances/workflows/create` requires only the plugin type and server name. A runtime binding may still be maintained internally for advanced logical transports, but browser lifecycle controls must not force operators to choose a runtime profile before start/stop or run-package generation when the plugin deployment/lifecycle declaration is sufficient. Platform builds distributions itself and never needs a registered Run endpoint with `distribution.build` to do so. ## Private Run Dependency And Update Routes -The following signed routes are Run-only and never part of browser/plugin DTOs: `POST /api/v1/run/jobs/dependency-input`, `POST /api/v1/run/jobs/protected-request-input`, `POST /api/v1/run/jobs/update-input`, `POST /api/v1/run/jobs/update-chunk`, and `POST /api/v1/run/jobs/update-health`. They require the current endpoint/session signature; input/chunk calls additionally require active attempt/lease/cancel fencing. Protected-request input additionally requires the current fencing token and returns approved text exactly once for the server-bound logical transport; the text is not persisted in a job, bridge command, journal, response projection, or audit summary. Update chunks are bounded to 1 MiB and resolve only an available same-server target-matched Run distribution. Health reports are accepted only after the terminal staged job, matching attempt/lease proof, current online endpoint release, and reconciliation-capable session are verified. These routes never return raw artifact paths, browser download tokens, host paths, credentials, secret refs, or session/lease hashes. +The following signed routes are Run-only and never part of browser/plugin DTOs: `POST /api/v1/run/jobs/dependency-input`, `POST /api/v1/run/jobs/source-rcon-input`, `POST /api/v1/run/jobs/update-input`, `POST /api/v1/run/jobs/update-chunk`, and `POST /api/v1/run/jobs/update-health`. They require the current endpoint/session signature; input/chunk calls additionally require active attempt/lease/cancel fencing. Source RCON input returns one queued command exactly once to the active Run lease. Update chunks are bounded to 1 MiB and resolve only an available same-server target-matched Run distribution. Health reports are accepted only after the terminal staged job, matching attempt/lease proof, current online endpoint release, and reconciliation-capable session are verified. These routes never return raw artifact paths, browser download tokens, host paths, credentials, secret refs, or session/lease hashes. ## Implemented Run Control Actions @@ -185,7 +183,6 @@ Control is the highest-priority run-facing channel; artifact/file transfer press - `POST /api/v1/run/jobs/result`: accept `RunJobResultRequest` and write an idempotent terminal result or durable retry-wait transition with capped exponential backoff. - `POST /api/v1/run/jobs/cancel`: accept fenced `RunJobCancelPollRequest` and return durable pending cancellation intent for the current attempt. - `POST /api/v1/run/jobs/reconcile`: accept persisted Run journal evidence (`jobId`, `attempt`, `leaseToken`), rebind only matching active attempts to the current authenticated session generation, persist reconciliation metadata, retry/cancel platform-active missing work, and return confirmed assignments plus discard IDs. -- `POST /api/v1/run/jobs/protected-request-input`: accept `ProtectedRequestExecutionInputRequest`, fence endpoint/session/attempt/lease/token, and return one approved, unexpired SQL, RCON, or management-program request only for its exact server-bound logical transport. The route never returns credentials, DSNs, paths, sockets, raw connections, or host shell material. - `POST /api/v1/jobs/{id}/cancel`: authorize the server owner/administrator or platform administrator and durably record cancellation intent; queued/retrying work becomes cancelled immediately while active work completes through fenced Run polling/result. Run job actions carry bounded job metadata only: job ID, run endpoint ID, server instance ID, capability, idempotency key, lease token, attempt/retry limits, deadlines, progress, terminal state, message, error code, result reference, and timing hints. Raw lease tokens exist only on the signed Run job channel; platform persistence stores their hashes. User-facing Job responses expose safe attempt, retry, cancel, terminal, and reconcile projections but never raw/hashed leases, Run sessions, secret refs, host paths, sockets, or credentials. @@ -200,7 +197,7 @@ Server-scoped SSE log streaming is removed from product routes. `POST /api/v1/lo Log ingest actions carry durable log metadata and bounded entries only: run endpoint ID, session token, stream identity, source, sequence range, compression metadata, checksum, entries, and cursor limits. They do not carry artifact chunks, host paths, raw credentials, direct sockets, or unbounded inline data. Log ingest is durable and independently retried. Artifact/file transfer backlog must not prevent log acknowledgement, duplicate acknowledgement, cursor state updates, or spool cleanup. -Platform storage is configured by `PLATFORM_STORAGE_BACKEND`. The default `file` backend writes metadata snapshots to `PLATFORM_METADATA_PATH` and log bodies to segmented files in `PLATFORM_LOG_DIR`; `memory` remains available for tests and ephemeral local runs. Relational stores such as MySQL/Postgres are reserved for metadata, stream cursors, indexes, retention state, and audit trails. High-volume log bodies for hundreds or thousands of servers should use a log-optimized backend behind `LogBodyStore`, such as ClickHouse, Loki, OpenSearch/Elasticsearch, or object-storage segments. +Platform storage is configured by `PLATFORM_STORAGE_BACKEND`. The default `file` backend writes metadata snapshots to `PLATFORM_METADATA_PATH` and log bodies to segmented files in `PLATFORM_LOG_DIR`; `memory` remains available for tests and ephemeral local runs. Relational stores such as MySQL/Postgres are reserved for metadata, stream cursors, indexes, retention state, and operational records. High-volume log bodies for hundreds or thousands of servers should use a log-optimized backend behind `LogBodyStore`, such as ClickHouse, Loki, OpenSearch/Elasticsearch, or object-storage segments. ## Implemented Run Artifact Actions diff --git a/platform/api/source_rcon_handlers.go b/platform/api/source_rcon_handlers.go index 9812258..9e06d08 100644 --- a/platform/api/source_rcon_handlers.go +++ b/platform/api/source_rcon_handlers.go @@ -6,7 +6,7 @@ import ( "browser.local/platform/dto" ) -// sourceRCONCommands is kept as legacy service plumbing but is not registered as a browser product route. +// sourceRCONCommands dispatches a one-time SCUM Source RCON command through Run. func (h *coreHandlers) sourceRCONCommands(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { writeMethodNotAllowed(w, http.MethodPost) diff --git a/platform/domain/game_client_bridge.go b/platform/domain/game_client_bridge.go index 1b7c9dc..bf777b8 100644 --- a/platform/domain/game_client_bridge.go +++ b/platform/domain/game_client_bridge.go @@ -40,17 +40,6 @@ type GameClientBridgeCommandDeclaration struct { ResultSchemaRef string TimeoutSeconds int MaxPayloadBytes int - ProtectedRequest *GameClientBridgeProtectedRequestDeclaration -} - -// GameClientBridgeProtectedRequestDeclaration binds plugin-generated text to a -// logical server transport. It never carries its resolved connection details. -type GameClientBridgeProtectedRequestDeclaration struct { - Kind string - TransportKey string - TargetKey string - TextField string - MaxTextBytes int } type GameClientBridgeSnapshotDeclaration struct { @@ -100,19 +89,10 @@ type GameClientBridgeLogProjectionTargetDeclaration struct { ObservedAtField string } -type GameClientBridgeLogProjectionAnnouncementDeclaration struct { - ProfileKey string - CommandType string - TextField string - NewTextTemplate string - ReturningTextTemplate string -} - type GameClientBridgeLogProjectionPresenceDeclaration struct { TimestampField string ActiveWindowSeconds int ActivityTarget *GameClientBridgeLogProjectionTargetDeclaration - Announcement GameClientBridgeLogProjectionAnnouncementDeclaration } type GameClientBridgeLogProjectionDeclaration struct { @@ -130,7 +110,7 @@ type GameClientBridgeDataPackDeclaration struct { DatabaseUserVersion int LogParserRefs []string ConfigMapRefs []string - DataRefs []string + DataRefs []string } type GameClientBridgeOperationKind string @@ -249,7 +229,6 @@ type GameClientBridgeCommand struct { Claim GameClientBridgeClaim Cancellation GameClientBridgeCancellation Result GameClientBridgeResult - AuditReferences []string ExpiresAt time.Time CreatedAt time.Time UpdatedAt time.Time @@ -293,7 +272,6 @@ type GameClientBridgeSnapshot struct { ObservedAt time.Time Payload map[string]any Retention GameClientBridgeRetention - AuditReferences []string CreatedAt time.Time ExpiresAt time.Time } @@ -314,14 +292,6 @@ type GameClientBridgeRetention struct { MaxRecords int } -type GameClientBridgeAuditReference struct { - ID string - CommandID string - SnapshotID string - AuditEventID string - CreatedAt time.Time -} - type GameClientBridgeCommandFilter struct { ServerInstanceID string PluginID string @@ -460,7 +430,6 @@ func CopyGameClientBridgeCommand(value GameClientBridgeCommand) GameClientBridge value.Claim = CopyGameClientBridgeClaim(value.Claim) value.Cancellation = CopyGameClientBridgeCancellation(value.Cancellation) value.Result = CopyGameClientBridgeResult(value.Result) - value.AuditReferences = CopyStringSlice(value.AuditReferences) return value } @@ -478,7 +447,6 @@ func CopyGameClientBridgeResult(value GameClientBridgeResult) GameClientBridgeRe func CopyGameClientBridgeSnapshot(value GameClientBridgeSnapshot) GameClientBridgeSnapshot { value.Payload = CopyGameClientBridgePayload(value.Payload) value.Retention = CopyGameClientBridgeRetention(value.Retention) - value.AuditReferences = CopyStringSlice(value.AuditReferences) return value } @@ -490,10 +458,6 @@ func CopyGameClientBridgeRetention(value GameClientBridgeRetention) GameClientBr return value } -func CopyGameClientBridgeAuditReference(value GameClientBridgeAuditReference) GameClientBridgeAuditReference { - return value -} - func CopyGameClientBridgePayload(value map[string]any) map[string]any { if value == nil { return nil @@ -507,12 +471,6 @@ func CopyGameClientBridgePayload(value map[string]any) map[string]any { func CopyGameClientBridgeManifest(value GameClientBridgeManifest) GameClientBridgeManifest { value.Commands = append([]GameClientBridgeCommandDeclaration(nil), value.Commands...) - for index := range value.Commands { - if value.Commands[index].ProtectedRequest != nil { - copy := *value.Commands[index].ProtectedRequest - value.Commands[index].ProtectedRequest = © - } - } value.Snapshots = append([]GameClientBridgeSnapshotDeclaration(nil), value.Snapshots...) value.QueryTemplates = append([]GameClientBridgeQueryTemplateDeclaration(nil), value.QueryTemplates...) for index := range value.QueryTemplates { @@ -529,7 +487,7 @@ func CopyGameClientBridgeManifest(value GameClientBridgeManifest) GameClientBrid for index := range value.DataPacks { value.DataPacks[index].LogParserRefs = CopyStringSlice(value.DataPacks[index].LogParserRefs) value.DataPacks[index].ConfigMapRefs = CopyStringSlice(value.DataPacks[index].ConfigMapRefs) - value.DataPacks[index].DataRefs = CopyStringSlice(value.DataPacks[index].DataRefs) + value.DataPacks[index].DataRefs = CopyStringSlice(value.DataPacks[index].DataRefs) } value.OperationTemplates = append([]GameClientBridgeOperationTemplateDeclaration(nil), value.OperationTemplates...) value.Pages = append([]GameClientBridgePageContract(nil), value.Pages...) diff --git a/platform/domain/game_client_bridge_test.go b/platform/domain/game_client_bridge_test.go index e96d68f..d0695f2 100644 --- a/platform/domain/game_client_bridge_test.go +++ b/platform/domain/game_client_bridge_test.go @@ -10,7 +10,7 @@ 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"}}}, + 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"}}}, } @@ -21,11 +21,11 @@ func TestCopyGameClientBridgeDeclarationsCopiesQueryTemplateSlices(t *testing.T) manifestCopy.LogProjections[0].Target.CaptureMappings["steamId"] = "mutated" manifestCopy.LogProjections[0].Presence.ActivityTarget.CaptureMappings["steamId"] = "mutated" manifestCopy.DataPacks[0].LogParserRefs[0] = "mutated" - manifestCopy.DataPacks[0].DataRefs[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.OperationTemplates[0].Key != "player.fame.set" || manifest.Pages[0].QueryTemplateKeys[0] != "player.lookup" || manifest.Pages[0].OperationKeys[0] != "player.fame.set" { t.Fatalf("manifest copy aliases query template declarations: source=%#v copy=%#v", manifest, manifestCopy) } diff --git a/platform/domain/job_channel.go b/platform/domain/job_channel.go index 13dc73c..d9b622f 100644 --- a/platform/domain/job_channel.go +++ b/platform/domain/job_channel.go @@ -264,32 +264,6 @@ type SourceRCONExecutionInput struct { Command string } -// ProtectedRequestExecutionInput is returned exactly once to the active, -// fenced Run lease. RequestText is never persisted in a job or bridge command. -type ProtectedRequestExecutionInputRequest struct { - RunEndpointID string - SessionToken string - JobID string - LeaseToken string - Attempt int - FencingToken uint64 -} - -type ProtectedRequestExecutionInput struct { - JobID string - ServerInstanceID string - RunEndpointID string - FencingToken uint64 - Authorized bool - ApprovalState string - QueueState string - ExpiresAt time.Time - Kind string - TransportKey string - TargetKey string - RequestText string -} - type RunUpdateInputRequest struct { RunEndpointID string SessionToken string @@ -510,10 +484,6 @@ func CopySourceRCONExecutionInput(input SourceRCONExecutionInput) SourceRCONExec return input } -func CopyProtectedRequestExecutionInput(input ProtectedRequestExecutionInput) ProtectedRequestExecutionInput { - return input -} - func CopyRunUpdateChunk(chunk RunUpdateChunk) RunUpdateChunk { chunk.Payload = append([]byte(nil), chunk.Payload...) return chunk diff --git a/platform/domain/observability.go b/platform/domain/observability.go index 980e9e2..ec9776f 100644 --- a/platform/domain/observability.go +++ b/platform/domain/observability.go @@ -108,7 +108,6 @@ type RemoteAdapterResult struct { Retryable bool Message string ResultRef string - AuditEventID string CompletedAt time.Time } diff --git a/platform/domain/production_ops.go b/platform/domain/production_ops.go index 57f5eb6..66ddf35 100644 --- a/platform/domain/production_ops.go +++ b/platform/domain/production_ops.go @@ -2,146 +2,6 @@ package domain import "time" -type CapacityAdmissionState string - -const ( - CapacityAdmissionAccepted CapacityAdmissionState = "accepted" - CapacityAdmissionDeferred CapacityAdmissionState = "deferred" - CapacityAdmissionDenied CapacityAdmissionState = "denied" -) - -type CapacityPressureCode string - -const ( - CapacityPressureEndpointOffline CapacityPressureCode = "endpoint.offline" - CapacityPressureEndpointStale CapacityPressureCode = "endpoint.stale" - CapacityPressureCapabilityGap CapacityPressureCode = "capability.missing" - CapacityPressureJobLimit CapacityPressureCode = "job.limit" - CapacityPressureQueueLimit CapacityPressureCode = "queue.limit" - CapacityPressureBacklog CapacityPressureCode = "spool.backlog" -) - -type CapacityAdmissionRequest struct { - ServerInstanceID string - RunEndpointID string - Capability string - TargetKey string - IdempotencyKey string -} - -type CapacityAdmissionDecision struct { - Accepted bool - State CapacityAdmissionState - Reason string - RetryAfterSeconds int - ServerInstanceID string - RunEndpointID string - Capability string - TargetKey string - MaxJobs int - RunningJobs int - QueuedJobs int - PressureCodes []CapacityPressureCode - CheckedAt time.Time - AlertID string - AuditEventID string -} - -type EndpointCapacityProjection struct { - RunEndpointID string - DisplayName string - Status RunEndpointStatus - Capabilities []string - MaxJobs int - RunningJobs int - QueuedJobs int - LogBacklogBatches int - ArtifactBacklogChunks int - PressureCodes []CapacityPressureCode - Summary string - LastHeartbeatAt time.Time - LastAdmissionDecision CapacityAdmissionState - LastAdmissionReason string - LastAdmissionCheckedAt time.Time -} - -type ProductionCapacitySummary struct { - Endpoints []EndpointCapacityProjection - TotalMaxJobs int - TotalRunningJobs int - TotalQueuedJobs int - ActiveAlerts int - GeneratedAt time.Time -} - -type AlertSeverity string - -const ( - AlertSeverityInfo AlertSeverity = "info" - AlertSeverityWarning AlertSeverity = "warning" - AlertSeverityCritical AlertSeverity = "critical" -) - -type AlertState string - -const ( - AlertStateActive AlertState = "active" - AlertStateAcknowledged AlertState = "acknowledged" - AlertStateResolved AlertState = "resolved" -) - -type AlertRecord struct { - ID string - SourceKind string - SourceID string - RuleKey string - Severity AlertSeverity - State AlertState - Title string - Message string - OccurrenceCount int - Retryable bool - RetryAfterSeconds int - LastJobID string - LastAuditEventID string - LastSeenAt time.Time - AcknowledgedBy string - AcknowledgedAt time.Time - ResolvedBy string - ResolvedAt time.Time - ResolutionNote string - CreatedAt time.Time - UpdatedAt time.Time -} - -type AlertFilter struct { - State AlertState - SourceKind string - SourceID string - Severity AlertSeverity -} - -type AlertAcknowledgeRequest struct { - AlertID string - Note string -} - -type AlertResolveRequest struct { - AlertID string - Note string -} - -type AlertRetryRequest struct { - AlertID string - IdempotencyKey string -} - -type AlertRetryResult struct { - Alert AlertRecord - Decision CapacityAdmissionDecision - Status string -} - type PluginLifecycleState string const ( @@ -180,8 +40,6 @@ type PluginLifecycleInstallation struct { Compatibility string DependencyState DependencyState JobID string - AlertID string - AuditEventID string FailureReason string IdempotencyKey string CreatedAt time.Time @@ -206,8 +64,6 @@ type PluginLifecycleRequest struct { type PluginLifecycleResult struct { Installation PluginLifecycleInstallation Job Job - Decision CapacityAdmissionDecision - Alert *AlertRecord Status string } @@ -261,53 +117,6 @@ type AIConfigDiffApprovalResult struct { Dispatch ServerConfigWriteDispatch } -func CopyCapacityAdmissionDecision(decision CapacityAdmissionDecision) CapacityAdmissionDecision { - decision.PressureCodes = CopyCapacityPressureCodes(decision.PressureCodes) - return decision -} - -func CopyEndpointCapacityProjection(projection EndpointCapacityProjection) EndpointCapacityProjection { - projection.Capabilities = CopyStringSlice(projection.Capabilities) - projection.PressureCodes = CopyCapacityPressureCodes(projection.PressureCodes) - return projection -} - -func CopyProductionCapacitySummary(summary ProductionCapacitySummary) ProductionCapacitySummary { - if summary.Endpoints != nil { - summary.Endpoints = append([]EndpointCapacityProjection(nil), summary.Endpoints...) - for i := range summary.Endpoints { - summary.Endpoints[i] = CopyEndpointCapacityProjection(summary.Endpoints[i]) - } - } - return summary -} - -func CopyCapacityPressureCodes(codes []CapacityPressureCode) []CapacityPressureCode { - if codes == nil { - return nil - } - out := make([]CapacityPressureCode, len(codes)) - copy(out, codes) - return out -} - -func CopyAlertRecord(alert AlertRecord) AlertRecord { return alert } - -func CopyAlertRecords(alerts []AlertRecord) []AlertRecord { - if alerts == nil { - return nil - } - out := make([]AlertRecord, len(alerts)) - copy(out, alerts) - return out -} - -func CopyAlertRetryResult(result AlertRetryResult) AlertRetryResult { - result.Alert = CopyAlertRecord(result.Alert) - result.Decision = CopyCapacityAdmissionDecision(result.Decision) - return result -} - func CopyPluginLifecycleInstallation(installation PluginLifecycleInstallation) PluginLifecycleInstallation { return installation } @@ -324,11 +133,6 @@ func CopyPluginLifecycleInstallations(installations []PluginLifecycleInstallatio func CopyPluginLifecycleResult(result PluginLifecycleResult) PluginLifecycleResult { result.Installation = CopyPluginLifecycleInstallation(result.Installation) result.Job = CopyJob(result.Job) - result.Decision = CopyCapacityAdmissionDecision(result.Decision) - if result.Alert != nil { - alert := CopyAlertRecord(*result.Alert) - result.Alert = &alert - } return result } diff --git a/platform/domain/resources.go b/platform/domain/resources.go index f65d16f..4acafc7 100644 --- a/platform/domain/resources.go +++ b/platform/domain/resources.go @@ -187,15 +187,6 @@ const ( LogStorageBackendElasticsearch LogStorageBackend = "elasticsearch" ) -type AuditResult string - -const ( - AuditResultSuccess AuditResult = "success" - AuditResultDenied AuditResult = "denied" - AuditResultFailed AuditResult = "failed" - AuditResultQueued AuditResult = "queued" -) - type User struct { ID string DisplayName string @@ -1078,7 +1069,6 @@ const ( JobCapabilityRemoteRunLogsTransfer = "remote.run.logs.transfer" JobCapabilityRemoteRunRCONCommand = "remote.run.rcon.command" JobCapabilityRemoteRunProtectedSQL = "remote.run.protected.sql" - JobCapabilityRemoteRunProtectedRCON = "remote.run.protected.rcon" JobCapabilityRemoteRunProgram = "remote.run.program.command" JobCapabilityRunSelfUpdate = "run.self-update" JobCapabilityDistributionBuild = "distribution.build" @@ -1176,7 +1166,7 @@ type JobExecutionResult struct { Version int Checksum string SizeBytes int64 - AuditSummary string + Summary string Content string ServerDeploymentEvidence *ServerDeploymentEvidence DeploymentReceipt *ServerDeploymentExecutionReceipt @@ -1515,17 +1505,6 @@ type LogStream struct { UpdatedAt time.Time } -type AuditEvent struct { - ID string - ActorID string - Action string - ResourceKind string - ResourceID string - Result AuditResult - Summary string - CreatedAt time.Time -} - type UserFilter struct { Status UserStatus } @@ -1626,13 +1605,6 @@ type LogStreamFilter struct { StreamKey string } -type AuditEventFilter struct { - ActorID string - ResourceKind string - ResourceID string - Result AuditResult -} - func CopyStringSlice(values []string) []string { if values == nil { return nil @@ -2145,7 +2117,3 @@ func CopyLogBackfillRequest(request LogBackfillRequest) LogBackfillRequest { func CopyLogStream(stream LogStream) LogStream { return stream } - -func CopyAuditEvent(event AuditEvent) AuditEvent { - return event -} diff --git a/platform/domain/resources.md b/platform/domain/resources.md index 9fdb796..2454578 100644 --- a/platform/domain/resources.md +++ b/platform/domain/resources.md @@ -4,7 +4,7 @@ This file defines the first platform resource contracts. Concrete Go domain stru ## Implemented Boundaries -- Domain constants centralize allowed status, state, provider kind, relay mode, artifact owner, storage backend, and audit result values. +- Domain constants centralize allowed status, state, provider kind, relay mode, artifact owner, storage backend, and result summary values. - DTO responses expose AI-provider secret presence only (`apiKeyConfigured`), never the stored reference or raw key material. - Model structs include JSON/database tags and explicit `TableName()` mappings for future persistence work. - `platform/repo.NewFileStore` provides durable local metadata snapshots for platform startup, while `platform/repo.NewMemoryStore` provides deterministic in-memory repository behavior for unit tests and disposable local runs. @@ -193,16 +193,6 @@ Failed or cancelled lifecycle jobs project the server instance to `failed`. Acti - `storageBackend`: `local-segments`, `loki`, `clickhouse`, `opensearch`, or `elasticsearch`. - `retentionPolicy`: retention key. -## AuditEvent - -- `id`: audit event ID. -- `actorId`: user or system actor. -- `action`: stable action key. -- `resourceKind`: resource kind. -- `resourceId`: resource ID. -- `result`: `success`, `denied`, `failed`, or `queued`. -- `summary`: bounded redacted summary. -- `createdAt`: event time. # Client Manager lifecycle aggregates `ClientManagerInstallation` owns desired/active/previous artifact and version metadata, target and key/deployment generations, the current job, logical health/last-seen projection, retry/fencing flags, and uninstall history. Valid statuses are `requested`, `building`, `available`, `deploying`, `installed`, `registering`, `online`, `degraded`, `offline`, `updating`, `rolling_back`, `stopping`, `failed`, and `uninstalled`. `ClientManagerSession` is a separate short-lived component identity bound to installation, endpoint, artifact, key generation, and deployment generation; it is not a Run session or lease. diff --git a/platform/dto/control.go b/platform/dto/control.go index 716b676..3588a8c 100644 --- a/platform/dto/control.go +++ b/platform/dto/control.go @@ -128,7 +128,7 @@ func (request RunLifecycleReportRequest) ToDomain() domain.RunLifecycleReport { ManagedProcessID: request.ManagedProcessID, ObservationSeq: request.ObservationSeq, ObservedAt: request.ObservedAt, - ExecutionResult: domain.JobExecutionResult{Kind: request.ExecutionResult.Kind, ProcessState: request.ExecutionResult.ProcessState, ExitClassification: request.ExecutionResult.ExitClassification, ExitCode: request.ExecutionResult.ExitCode, Version: request.ExecutionResult.Version, Checksum: request.ExecutionResult.Checksum, SizeBytes: request.ExecutionResult.SizeBytes, AuditSummary: request.ExecutionResult.AuditSummary, Content: request.ExecutionResult.Content, ServerDeploymentEvidence: serverDeploymentEvidenceToDomain(request.ExecutionResult.ServerDeploymentEvidence), DeploymentReceipt: deploymentReceiptToDomain(request.ExecutionResult.DeploymentReceipt)}, + ExecutionResult: domain.JobExecutionResult{Kind: request.ExecutionResult.Kind, ProcessState: request.ExecutionResult.ProcessState, ExitClassification: request.ExecutionResult.ExitClassification, ExitCode: request.ExecutionResult.ExitCode, Version: request.ExecutionResult.Version, Checksum: request.ExecutionResult.Checksum, SizeBytes: request.ExecutionResult.SizeBytes, Summary: request.ExecutionResult.Summary, Content: request.ExecutionResult.Content, ServerDeploymentEvidence: serverDeploymentEvidenceToDomain(request.ExecutionResult.ServerDeploymentEvidence), DeploymentReceipt: deploymentReceiptToDomain(request.ExecutionResult.DeploymentReceipt)}, } } diff --git a/platform/dto/game_client_bridge.go b/platform/dto/game_client_bridge.go index 8abe8dc..43e5eb8 100644 --- a/platform/dto/game_client_bridge.go +++ b/platform/dto/game_client_bridge.go @@ -87,7 +87,6 @@ type GameClientBridgeCommandResponse struct { ResultSummary string `json:"resultSummary,omitempty"` Result *GameClientBridgeCommandResultResponse `json:"result,omitempty"` Cancellation *GameClientBridgeCommandCancellationResponse `json:"cancellation,omitempty"` - AuditReferences []string `json:"auditReferences,omitempty"` ExpiresAt time.Time `json:"expiresAt"` CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` @@ -111,7 +110,6 @@ type GameClientBridgeSnapshotResponse struct { ObservedAt time.Time `json:"observedAt"` Payload map[string]any `json:"payload"` Retention GameClientBridgeRetentionResponse `json:"retention"` - AuditReferences []string `json:"auditReferences,omitempty"` CreatedAt time.Time `json:"createdAt"` ExpiresAt time.Time `json:"expiresAt"` } @@ -163,11 +161,10 @@ type GameClientBridgeResultResponse struct { } type GameClientBridgeCancelResponse struct { - CommandID string `json:"commandId"` - State string `json:"state"` - Cancellation GameClientBridgeCommandCancellationResponse `json:"cancellation"` - AuditReferences []string `json:"auditReferences,omitempty"` - UpdatedAt time.Time `json:"updatedAt"` + CommandID string `json:"commandId"` + State string `json:"state"` + Cancellation GameClientBridgeCommandCancellationResponse `json:"cancellation"` + UpdatedAt time.Time `json:"updatedAt"` } type GameClientBridgeSnapshotIngestResponse struct { @@ -181,19 +178,6 @@ type GameClientBridgeSnapshotIngestResponse struct { ExpiresAt time.Time `json:"expiresAt"` } -type GameClientBridgeAuditReferenceResponse struct { - ID string `json:"id"` - CommandID string `json:"commandId,omitempty"` - SnapshotID string `json:"snapshotId,omitempty"` - AuditEventID string `json:"auditEventId"` - CreatedAt time.Time `json:"createdAt"` -} - -type GameClientBridgeAuditReferenceListResponse struct { - Items []GameClientBridgeAuditReferenceResponse `json:"items"` - Count int `json:"count"` -} - type GameClientBridgeProfileDeclarationResponse struct { PluginID string `json:"pluginId"` ProfileKey string `json:"profileKey"` @@ -263,7 +247,6 @@ func GameClientBridgeCommandFromDomain(value domain.GameClientBridgeCommand) Gam ApprovalState: string(value.ApprovalState), RequesterID: value.RequesterID, ResultSummary: value.Result.Summary, - AuditReferences: copyStrings(value.AuditReferences), ExpiresAt: value.ExpiresAt, CreatedAt: value.CreatedAt, UpdatedAt: value.UpdatedAt, @@ -294,7 +277,6 @@ func GameClientBridgeSnapshotFromDomain(value domain.GameClientBridgeSnapshot) G ObservedAt: value.ObservedAt, Payload: value.Payload, Retention: gameClientBridgeRetentionFromDomain(value.Retention), - AuditReferences: copyStrings(value.AuditReferences), CreatedAt: value.CreatedAt, ExpiresAt: value.ExpiresAt, } @@ -344,25 +326,13 @@ func GameClientBridgeResultFromDomain(value domain.GameClientBridgeCommand) Game func GameClientBridgeCancelFromDomain(value domain.GameClientBridgeCommand) GameClientBridgeCancelResponse { value = domain.CopyGameClientBridgeCommand(value) - return GameClientBridgeCancelResponse{CommandID: value.ID, State: string(value.State), Cancellation: gameClientBridgeCommandCancellationFromDomain(value.Cancellation), AuditReferences: copyStrings(value.AuditReferences), UpdatedAt: value.UpdatedAt} + return GameClientBridgeCancelResponse{CommandID: value.ID, State: string(value.State), Cancellation: gameClientBridgeCommandCancellationFromDomain(value.Cancellation), UpdatedAt: value.UpdatedAt} } func GameClientBridgeSnapshotIngestFromDomain(value domain.GameClientBridgeSnapshot) GameClientBridgeSnapshotIngestResponse { return GameClientBridgeSnapshotIngestResponse{SnapshotID: value.ID, ProfileKey: value.ProfileKey, Type: value.Type, SchemaVersion: value.SchemaVersion, StreamKey: value.StreamKey, Sequence: value.Sequence, AcceptedAt: value.CreatedAt, ExpiresAt: value.ExpiresAt} } -func GameClientBridgeAuditReferenceFromDomain(value domain.GameClientBridgeAuditReference) GameClientBridgeAuditReferenceResponse { - return GameClientBridgeAuditReferenceResponse{ID: value.ID, CommandID: value.CommandID, SnapshotID: value.SnapshotID, AuditEventID: value.AuditEventID, CreatedAt: value.CreatedAt} -} - -func GameClientBridgeAuditReferencesFromDomain(values []domain.GameClientBridgeAuditReference) GameClientBridgeAuditReferenceListResponse { - items := make([]GameClientBridgeAuditReferenceResponse, len(values)) - for index, value := range values { - items[index] = GameClientBridgeAuditReferenceFromDomain(value) - } - return GameClientBridgeAuditReferenceListResponse{Items: items, Count: len(items)} -} - func GameClientBridgeStatusFromDomain(value domain.GameClientBridgeStatus) GameClientBridgeStatusResponse { value = domain.CopyGameClientBridgeStatus(value) profiles := make([]GameClientBridgeProfileDeclarationResponse, len(value.Profiles)) diff --git a/platform/dto/game_client_bridge_test.go b/platform/dto/game_client_bridge_test.go index 43a3bf0..023f2b8 100644 --- a/platform/dto/game_client_bridge_test.go +++ b/platform/dto/game_client_bridge_test.go @@ -33,29 +33,27 @@ func TestGameClientBridgeRequestConversionsInjectScopeAndCopyPayloads(t *testing func TestGameClientBridgeBrowserProjectionsAreCompleteAndOmitInternalData(t *testing.T) { 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: "announcement.send", Payload: map[string]any{"message": "internal command payload"}, IdempotencyKey: "internal-idempotency", Priority: 9, + 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", - 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)}, - AuditReferences: []string{"audit-1"}, ExpiresAt: now.Add(10 * time.Minute), CreatedAt: now, UpdatedAt: now.Add(3 * time.Minute), CompletedAt: now.Add(3 * time.Minute), + 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)}, + ExpiresAt: now.Add(10 * time.Minute), CreatedAt: now, UpdatedAt: now.Add(3 * time.Minute), CompletedAt: now.Add(3 * time.Minute), } - snapshot := domain.GameClientBridgeSnapshot{ID: "snapshot-1", ServerInstanceID: "server-1", PluginID: "game.scum", ProfileKey: "scum-client", Type: "players", SchemaVersion: "1", StreamKey: "current", Sequence: 2, SourceSessionID: "internal-source-session", ObservedAt: now, Payload: map[string]any{"players": []any{map[string]any{"name": "Alice"}}}, Retention: domain.GameClientBridgeRetention{KeepForSeconds: 3600, MaxRecords: 10}, AuditReferences: []string{"audit-2"}, CreatedAt: now.Add(time.Second), ExpiresAt: now.Add(time.Hour)} + snapshot := domain.GameClientBridgeSnapshot{ID: "snapshot-1", ServerInstanceID: "server-1", PluginID: "game.scum", ProfileKey: "scum-client", Type: "players", SchemaVersion: "1", StreamKey: "current", Sequence: 2, SourceSessionID: "internal-source-session", ObservedAt: now, Payload: map[string]any{"players": []any{map[string]any{"name": "Alice"}}}, Retention: domain.GameClientBridgeRetention{KeepForSeconds: 3600, MaxRecords: 10}, CreatedAt: now.Add(time.Second), ExpiresAt: now.Add(time.Hour)} commandProjection := GameClientBridgeCommandFromDomain(command) snapshotProjection := GameClientBridgeSnapshotFromDomain(snapshot) - if commandProjection.Result == nil || commandProjection.Result.Status != "cancelled" || commandProjection.Cancellation == nil || commandProjection.Cancellation.Reason != "operator request" || commandProjection.Priority != 9 || commandProjection.AuditReferences[0] != "audit-1" || !commandProjection.CompletedAt.Equal(command.CompletedAt) { + if commandProjection.Result == nil || commandProjection.Result.Status != "cancelled" || commandProjection.Cancellation == nil || commandProjection.Cancellation.Reason != "operator request" || commandProjection.Priority != 9 || !commandProjection.CompletedAt.Equal(command.CompletedAt) { t.Fatalf("incomplete command projection: %#v", commandProjection) } - if snapshotProjection.ProfileKey != "scum-client" || snapshotProjection.Retention.KeepForSeconds != 3600 || snapshotProjection.AuditReferences[0] != "audit-2" || !snapshotProjection.CreatedAt.Equal(snapshot.CreatedAt) { + if snapshotProjection.ProfileKey != "scum-client" || snapshotProjection.Retention.KeepForSeconds != 3600 || !snapshotProjection.CreatedAt.Equal(snapshot.CreatedAt) { t.Fatalf("incomplete snapshot projection: %#v", snapshotProjection) } commandProjection.Result.Payload["code"] = "changed" - commandProjection.AuditReferences[0] = "changed" snapshotProjection.Payload["players"].([]any)[0].(map[string]any)["name"] = "changed" - snapshotProjection.AuditReferences[0] = "changed" - if command.Result.Payload["code"] != "cancelled" || command.AuditReferences[0] != "audit-1" || snapshot.Payload["players"].([]any)[0].(map[string]any)["name"] != "Alice" || snapshot.AuditReferences[0] != "audit-2" { + if command.Result.Payload["code"] != "cancelled" || snapshot.Payload["players"].([]any)[0].(map[string]any)["name"] != "Alice" { t.Fatal("browser projection aliases domain data") } @@ -80,7 +78,7 @@ func TestGameClientBridgeListAndCompanionResponseConversions(t *testing.T) { ID: "command-1", ProfileKey: "scum-client", CommandType: "diagnostic.safe", Payload: map[string]any{"scope": "health"}, Priority: 3, State: domain.GameClientBridgeCommandSucceeded, Claim: domain.GameClientBridgeClaim{SessionID: "private-session", FencingToken: 7, ClaimedAt: now, AcknowledgedAt: now.Add(time.Second), LeaseExpiresAt: now.Add(time.Minute)}, Result: domain.GameClientBridgeResult{Status: domain.GameClientBridgeResultSucceeded, Summary: "ok", Payload: map[string]any{"healthy": true}, CompletedBy: "private-session", CompletedAt: now.Add(2 * time.Second)}, - Cancellation: domain.GameClientBridgeCancellation{RequestedBy: "operator", Reason: "superseded", CancelledAt: now.Add(3 * time.Second)}, AuditReferences: []string{"audit-1"}, ExpiresAt: now.Add(5 * time.Minute), UpdatedAt: now.Add(2 * time.Second), CompletedAt: now.Add(2 * time.Second), + Cancellation: domain.GameClientBridgeCancellation{RequestedBy: "operator", Reason: "superseded", CancelledAt: now.Add(3 * time.Second)}, ExpiresAt: now.Add(5 * time.Minute), UpdatedAt: now.Add(2 * time.Second), CompletedAt: now.Add(2 * time.Second), } snapshot := domain.GameClientBridgeSnapshot{ID: "snapshot-1", ProfileKey: "scum-client", Type: "players", SchemaVersion: "1", StreamKey: "current", Sequence: 4, CreatedAt: now, ExpiresAt: now.Add(time.Hour)} @@ -91,8 +89,7 @@ func TestGameClientBridgeListAndCompanionResponseConversions(t *testing.T) { ingest := GameClientBridgeSnapshotIngestFromDomain(snapshot) commands := GameClientBridgeCommandsFromDomain([]domain.GameClientBridgeCommand{command}) snapshots := GameClientBridgeSnapshotsFromDomain([]domain.GameClientBridgeSnapshot{snapshot}) - audits := GameClientBridgeAuditReferencesFromDomain([]domain.GameClientBridgeAuditReference{{ID: "reference-1", CommandID: "command-1", AuditEventID: "audit-1", CreatedAt: now}}) - if claim.Count != 1 || claim.Items[0].FencingToken != 7 || claim.Items[0].ProfileKey != "scum-client" || ack.CommandID != "command-1" || !ack.AcknowledgedAt.Equal(now.Add(time.Second)) || result.Result.Summary != "ok" || cancel.Cancellation.Reason != "superseded" || ingest.SnapshotID != "snapshot-1" || commands.Count != 1 || snapshots.Count != 1 || audits.Count != 1 { + if claim.Count != 1 || claim.Items[0].FencingToken != 7 || claim.Items[0].ProfileKey != "scum-client" || ack.CommandID != "command-1" || !ack.AcknowledgedAt.Equal(now.Add(time.Second)) || result.Result.Summary != "ok" || cancel.Cancellation.Reason != "superseded" || ingest.SnapshotID != "snapshot-1" || commands.Count != 1 || snapshots.Count != 1 { t.Fatalf("unexpected responses: claim=%#v ack=%#v result=%#v cancel=%#v ingest=%#v", claim, ack, result, cancel, ingest) } claim.Items[0].Payload["scope"] = "changed" @@ -100,7 +97,7 @@ func TestGameClientBridgeListAndCompanionResponseConversions(t *testing.T) { if command.Payload["scope"] != "health" || command.Result.Payload["healthy"] != true { t.Fatal("companion response aliases domain payload") } - for name, value := range map[string]any{"claim": claim, "ack": ack, "result": result, "cancel": cancel, "ingest": ingest, "commands": commands, "snapshots": snapshots, "audits": audits} { + for name, value := range map[string]any{"claim": claim, "ack": ack, "result": result, "cancel": cancel, "ingest": ingest, "commands": commands, "snapshots": snapshots} { encoded, err := json.Marshal(value) if err != nil { t.Fatalf("marshal %s response: %v", name, err) @@ -110,7 +107,7 @@ func TestGameClientBridgeListAndCompanionResponseConversions(t *testing.T) { } } - if GameClientBridgeCommandsFromDomain(nil).Items == nil || GameClientBridgeSnapshotsFromDomain(nil).Items == nil || GameClientBridgeClaimBatchFromDomain(nil).Items == nil || GameClientBridgeAuditReferencesFromDomain(nil).Items == nil { + if GameClientBridgeCommandsFromDomain(nil).Items == nil || GameClientBridgeSnapshotsFromDomain(nil).Items == nil || GameClientBridgeClaimBatchFromDomain(nil).Items == nil { t.Fatal("list converters must serialize empty items as [] instead of null") } } diff --git a/platform/dto/job_channel.go b/platform/dto/job_channel.go index 20f5a77..db7b7c1 100644 --- a/platform/dto/job_channel.go +++ b/platform/dto/job_channel.go @@ -187,7 +187,7 @@ type RunJobExecutionResultBody struct { Version int `json:"version,omitempty"` Checksum string `json:"checksum,omitempty"` SizeBytes int64 `json:"sizeBytes,omitempty"` - AuditSummary string `json:"auditSummary,omitempty"` + Summary string `json:"summary,omitempty"` Content string `json:"content,omitempty"` ServerDeploymentEvidence *ServerDeploymentEvidenceBody `json:"serverDeploymentEvidence,omitempty"` DeploymentReceipt *ServerDeploymentExecutionReceiptBody `json:"deploymentReceipt,omitempty"` @@ -267,30 +267,6 @@ type SourceRCONExecutionInputResponse struct { Command string `json:"command"` } -type ProtectedRequestExecutionInputRequest struct { - RunEndpointID string `json:"runEndpointId"` - SessionToken string `json:"sessionToken"` - JobID string `json:"jobId"` - LeaseToken string `json:"leaseToken"` - Attempt int `json:"attempt"` - FencingToken uint64 `json:"fencingToken"` -} - -type ProtectedRequestExecutionInputResponse struct { - JobID string `json:"jobId"` - ServerInstanceID string `json:"serverInstanceId"` - RunEndpointID string `json:"runEndpointId"` - FencingToken uint64 `json:"fencingToken"` - Authorized bool `json:"authorized"` - ApprovalState string `json:"approvalState"` - QueueState string `json:"queueState"` - ExpiresAt time.Time `json:"expiresAt"` - Kind string `json:"kind"` - TransportKey string `json:"transportKey"` - TargetKey string `json:"targetKey"` - RequestText string `json:"requestText"` -} - type RunUpdateInputRequest struct { RunEndpointID string `json:"runEndpointId"` SessionToken string `json:"sessionToken"` @@ -448,7 +424,7 @@ func (request RunJobResultRequest) ToDomain() domain.RunJobResult { Message: request.Message, ErrorCode: request.ErrorCode, Retryable: request.Retryable, - ExecutionResult: domain.JobExecutionResult{Kind: request.ExecutionResult.Kind, ProcessState: request.ExecutionResult.ProcessState, ExitClassification: request.ExecutionResult.ExitClassification, ExitCode: request.ExecutionResult.ExitCode, Version: request.ExecutionResult.Version, Checksum: request.ExecutionResult.Checksum, SizeBytes: request.ExecutionResult.SizeBytes, AuditSummary: request.ExecutionResult.AuditSummary, Content: request.ExecutionResult.Content, ServerDeploymentEvidence: serverDeploymentEvidenceToDomain(request.ExecutionResult.ServerDeploymentEvidence), DeploymentReceipt: deploymentReceiptToDomain(request.ExecutionResult.DeploymentReceipt)}, + ExecutionResult: domain.JobExecutionResult{Kind: request.ExecutionResult.Kind, ProcessState: request.ExecutionResult.ProcessState, ExitClassification: request.ExecutionResult.ExitClassification, ExitCode: request.ExecutionResult.ExitCode, Version: request.ExecutionResult.Version, Checksum: request.ExecutionResult.Checksum, SizeBytes: request.ExecutionResult.SizeBytes, Summary: request.ExecutionResult.Summary, Content: request.ExecutionResult.Content, ServerDeploymentEvidence: serverDeploymentEvidenceToDomain(request.ExecutionResult.ServerDeploymentEvidence), DeploymentReceipt: deploymentReceiptToDomain(request.ExecutionResult.DeploymentReceipt)}, } } @@ -470,10 +446,6 @@ func (request SourceRCONExecutionInputRequest) ToDomain() domain.SourceRCONExecu return domain.SourceRCONExecutionInputRequest{RunEndpointID: request.RunEndpointID, SessionToken: request.SessionToken, JobID: request.JobID, LeaseToken: request.LeaseToken, Attempt: request.Attempt} } -func (request ProtectedRequestExecutionInputRequest) ToDomain() domain.ProtectedRequestExecutionInputRequest { - return domain.ProtectedRequestExecutionInputRequest{RunEndpointID: request.RunEndpointID, SessionToken: request.SessionToken, JobID: request.JobID, LeaseToken: request.LeaseToken, Attempt: request.Attempt, FencingToken: request.FencingToken} -} - func (request RunUpdateInputRequest) ToDomain() domain.RunUpdateInputRequest { return domain.RunUpdateInputRequest{RunEndpointID: request.RunEndpointID, SessionToken: request.SessionToken, JobID: request.JobID, LeaseToken: request.LeaseToken, Attempt: request.Attempt} } @@ -588,10 +560,6 @@ func SourceRCONExecutionInputFromDomain(input domain.SourceRCONExecutionInput) S return SourceRCONExecutionInputResponse{JobID: input.JobID, ServerInstanceID: input.ServerInstanceID, RunEndpointID: input.RunEndpointID, Command: input.Command} } -func ProtectedRequestExecutionInputFromDomain(input domain.ProtectedRequestExecutionInput) ProtectedRequestExecutionInputResponse { - return ProtectedRequestExecutionInputResponse{JobID: input.JobID, ServerInstanceID: input.ServerInstanceID, RunEndpointID: input.RunEndpointID, FencingToken: input.FencingToken, Authorized: input.Authorized, ApprovalState: input.ApprovalState, QueueState: input.QueueState, ExpiresAt: input.ExpiresAt, Kind: input.Kind, TransportKey: input.TransportKey, TargetKey: input.TargetKey, RequestText: input.RequestText} -} - func RunUpdateInputFromDomain(input domain.RunUpdateInput) RunUpdateInputResponse { return RunUpdateInputResponse{JobID: input.JobID, ServerInstanceID: input.ServerInstanceID, RunEndpointID: input.RunEndpointID, ArtifactID: input.ArtifactID, Checksum: input.Checksum, SizeBytes: input.SizeBytes, TargetOS: input.TargetOS, TargetArch: input.TargetArch, PackageFormat: input.PackageFormat, ExecutableName: input.ExecutableName, TargetRelease: input.TargetRelease, ChunkSizeBytes: input.ChunkSizeBytes} } diff --git a/platform/dto/observability.go b/platform/dto/observability.go index 0084e50..4b4e985 100644 --- a/platform/dto/observability.go +++ b/platform/dto/observability.go @@ -102,7 +102,6 @@ type RemoteAdapterResponse struct { Retryable bool `json:"retryable"` Message string `json:"message"` ResultRef string `json:"resultRef,omitempty"` - AuditEventID string `json:"auditEventId,omitempty"` CompletedAt time.Time `json:"completedAt,omitempty"` } @@ -171,7 +170,7 @@ func (request RemoteAdapterRequestBody) ToDomain(serverInstanceID string) domain } func RemoteAdapterFromDomain(result domain.RemoteAdapterResult) RemoteAdapterResponse { - return RemoteAdapterResponse{RequestID: result.RequestID, ServerInstanceID: result.ServerInstanceID, DeclarationKey: result.DeclarationKey, TargetKey: result.TargetKey, Kind: result.Kind, Status: result.Status, Retryable: result.Retryable, Message: result.Message, ResultRef: result.ResultRef, AuditEventID: result.AuditEventID, CompletedAt: result.CompletedAt} + return RemoteAdapterResponse{RequestID: result.RequestID, ServerInstanceID: result.ServerInstanceID, DeclarationKey: result.DeclarationKey, TargetKey: result.TargetKey, Kind: result.Kind, Status: result.Status, Retryable: result.Retryable, Message: result.Message, ResultRef: result.ResultRef, CompletedAt: result.CompletedAt} } func (request SourceRCONCommandRequestBody) ToDomain(serverInstanceID string) domain.SourceRCONCommandRequest { diff --git a/platform/dto/production_ops.go b/platform/dto/production_ops.go index 59d8bc9..d6ec277 100644 --- a/platform/dto/production_ops.go +++ b/platform/dto/production_ops.go @@ -6,106 +6,6 @@ import ( "browser.local/platform/domain" ) -type CapacityAdmissionRequest struct { - ServerInstanceID string `json:"serverInstanceId,omitempty"` - RunEndpointID string `json:"runEndpointId,omitempty"` - Capability string `json:"capability"` - TargetKey string `json:"targetKey,omitempty"` - IdempotencyKey string `json:"idempotencyKey,omitempty"` -} - -type CapacityAdmissionDecisionResponse struct { - Accepted bool `json:"accepted"` - State string `json:"state"` - Reason string `json:"reason"` - RetryAfterSeconds int `json:"retryAfterSeconds,omitempty"` - ServerInstanceID string `json:"serverInstanceId,omitempty"` - RunEndpointID string `json:"runEndpointId,omitempty"` - Capability string `json:"capability"` - TargetKey string `json:"targetKey,omitempty"` - MaxJobs int `json:"maxJobs"` - RunningJobs int `json:"runningJobs"` - QueuedJobs int `json:"queuedJobs"` - PressureCodes []string `json:"pressureCodes,omitempty"` - CheckedAt time.Time `json:"checkedAt"` - AlertID string `json:"alertId,omitempty"` - AuditEventID string `json:"auditEventId,omitempty"` -} - -type EndpointCapacityProjectionResponse struct { - RunEndpointID string `json:"runEndpointId"` - DisplayName string `json:"displayName"` - Status string `json:"status"` - Capabilities []string `json:"capabilities"` - MaxJobs int `json:"maxJobs"` - RunningJobs int `json:"runningJobs"` - QueuedJobs int `json:"queuedJobs"` - LogBacklogBatches int `json:"logBacklogBatches,omitempty"` - ArtifactBacklogChunks int `json:"artifactBacklogChunks,omitempty"` - PressureCodes []string `json:"pressureCodes,omitempty"` - Summary string `json:"summary,omitempty"` - LastHeartbeatAt time.Time `json:"lastHeartbeatAt"` - LastAdmissionDecision string `json:"lastAdmissionDecision,omitempty"` - LastAdmissionReason string `json:"lastAdmissionReason,omitempty"` - LastAdmissionCheckedAt time.Time `json:"lastAdmissionCheckedAt,omitempty"` -} - -type ProductionCapacitySummaryResponse struct { - Endpoints []EndpointCapacityProjectionResponse `json:"endpoints"` - TotalMaxJobs int `json:"totalMaxJobs"` - TotalRunningJobs int `json:"totalRunningJobs"` - TotalQueuedJobs int `json:"totalQueuedJobs"` - ActiveAlerts int `json:"activeAlerts"` - GeneratedAt time.Time `json:"generatedAt"` -} - -type AlertResponse struct { - ID string `json:"id"` - SourceKind string `json:"sourceKind"` - SourceID string `json:"sourceId"` - RuleKey string `json:"ruleKey"` - Severity string `json:"severity"` - State string `json:"state"` - Title string `json:"title"` - Message string `json:"message"` - OccurrenceCount int `json:"occurrenceCount"` - Retryable bool `json:"retryable"` - RetryAfterSeconds int `json:"retryAfterSeconds,omitempty"` - LastJobID string `json:"lastJobId,omitempty"` - LastAuditEventID string `json:"lastAuditEventId,omitempty"` - LastSeenAt time.Time `json:"lastSeenAt"` - AcknowledgedBy string `json:"acknowledgedBy,omitempty"` - AcknowledgedAt time.Time `json:"acknowledgedAt,omitempty"` - ResolvedBy string `json:"resolvedBy,omitempty"` - ResolvedAt time.Time `json:"resolvedAt,omitempty"` - ResolutionNote string `json:"resolutionNote,omitempty"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` -} - -type AlertListResponse struct { - Items []AlertResponse `json:"items"` - Count int `json:"count"` -} - -type AlertAcknowledgeRequest struct { - Note string `json:"note,omitempty"` -} - -type AlertResolveRequest struct { - Note string `json:"note,omitempty"` -} - -type AlertRetryRequest struct { - IdempotencyKey string `json:"idempotencyKey"` -} - -type AlertRetryResponse struct { - Status string `json:"status"` - Alert AlertResponse `json:"alert"` - Decision CapacityAdmissionDecisionResponse `json:"decision"` -} - type PluginLifecycleActionRequest struct { ServerInstanceID string `json:"serverInstanceId"` Operation string `json:"operation"` @@ -127,8 +27,6 @@ type PluginLifecycleInstallationResponse struct { Compatibility string `json:"compatibility,omitempty"` DependencyState string `json:"dependencyState,omitempty"` JobID string `json:"jobId,omitempty"` - AlertID string `json:"alertId,omitempty"` - AuditEventID string `json:"auditEventId,omitempty"` FailureReason string `json:"failureReason,omitempty"` CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` @@ -143,8 +41,6 @@ type PluginLifecycleActionResponse struct { Status string `json:"status"` Installation PluginLifecycleInstallationResponse `json:"installation"` Job JobResponse `json:"job"` - Decision CapacityAdmissionDecisionResponse `json:"decision"` - Alert *AlertResponse `json:"alert,omitempty"` } type AIConfigDiffPreviewResponse struct { @@ -185,49 +81,13 @@ type AIConfigDiffApprovalResponse struct { Dispatch ServerConfigWriteDispatchResponse `json:"dispatch"` } -func (request CapacityAdmissionRequest) ToDomain() domain.CapacityAdmissionRequest { - return domain.CapacityAdmissionRequest{ServerInstanceID: request.ServerInstanceID, RunEndpointID: request.RunEndpointID, Capability: request.Capability, TargetKey: request.TargetKey, IdempotencyKey: request.IdempotencyKey} -} - -func CapacityDecisionFromDomain(decision domain.CapacityAdmissionDecision) CapacityAdmissionDecisionResponse { - decision = domain.CopyCapacityAdmissionDecision(decision) - return CapacityAdmissionDecisionResponse{Accepted: decision.Accepted, State: string(decision.State), Reason: decision.Reason, RetryAfterSeconds: decision.RetryAfterSeconds, ServerInstanceID: decision.ServerInstanceID, RunEndpointID: decision.RunEndpointID, Capability: decision.Capability, TargetKey: decision.TargetKey, MaxJobs: decision.MaxJobs, RunningJobs: decision.RunningJobs, QueuedJobs: decision.QueuedJobs, PressureCodes: pressureCodesFromDomain(decision.PressureCodes), CheckedAt: decision.CheckedAt, AlertID: decision.AlertID, AuditEventID: decision.AuditEventID} -} - -func ProductionCapacityFromDomain(summary domain.ProductionCapacitySummary) ProductionCapacitySummaryResponse { - summary = domain.CopyProductionCapacitySummary(summary) - items := make([]EndpointCapacityProjectionResponse, len(summary.Endpoints)) - for i, endpoint := range summary.Endpoints { - items[i] = EndpointCapacityProjectionResponse{RunEndpointID: endpoint.RunEndpointID, DisplayName: endpoint.DisplayName, Status: string(endpoint.Status), Capabilities: endpoint.Capabilities, MaxJobs: endpoint.MaxJobs, RunningJobs: endpoint.RunningJobs, QueuedJobs: endpoint.QueuedJobs, LogBacklogBatches: endpoint.LogBacklogBatches, ArtifactBacklogChunks: endpoint.ArtifactBacklogChunks, PressureCodes: pressureCodesFromDomain(endpoint.PressureCodes), Summary: endpoint.Summary, LastHeartbeatAt: endpoint.LastHeartbeatAt, LastAdmissionDecision: string(endpoint.LastAdmissionDecision), LastAdmissionReason: endpoint.LastAdmissionReason, LastAdmissionCheckedAt: endpoint.LastAdmissionCheckedAt} - } - return ProductionCapacitySummaryResponse{Endpoints: items, TotalMaxJobs: summary.TotalMaxJobs, TotalRunningJobs: summary.TotalRunningJobs, TotalQueuedJobs: summary.TotalQueuedJobs, ActiveAlerts: summary.ActiveAlerts, GeneratedAt: summary.GeneratedAt} -} - -func AlertFromDomain(alert domain.AlertRecord) AlertResponse { - alert = domain.CopyAlertRecord(alert) - return AlertResponse{ID: alert.ID, SourceKind: alert.SourceKind, SourceID: alert.SourceID, RuleKey: alert.RuleKey, Severity: string(alert.Severity), State: string(alert.State), Title: alert.Title, Message: alert.Message, OccurrenceCount: alert.OccurrenceCount, Retryable: alert.Retryable, RetryAfterSeconds: alert.RetryAfterSeconds, LastJobID: alert.LastJobID, LastAuditEventID: alert.LastAuditEventID, LastSeenAt: alert.LastSeenAt, AcknowledgedBy: alert.AcknowledgedBy, AcknowledgedAt: alert.AcknowledgedAt, ResolvedBy: alert.ResolvedBy, ResolvedAt: alert.ResolvedAt, ResolutionNote: alert.ResolutionNote, CreatedAt: alert.CreatedAt, UpdatedAt: alert.UpdatedAt} -} - -func AlertListFromDomain(alerts []domain.AlertRecord) AlertListResponse { - items := make([]AlertResponse, len(alerts)) - for i, alert := range alerts { - items[i] = AlertFromDomain(alert) - } - return AlertListResponse{Items: items, Count: len(items)} -} - -func AlertRetryFromDomain(result domain.AlertRetryResult) AlertRetryResponse { - result = domain.CopyAlertRetryResult(result) - return AlertRetryResponse{Status: result.Status, Alert: AlertFromDomain(result.Alert), Decision: CapacityDecisionFromDomain(result.Decision)} -} - 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} } func PluginLifecycleFromDomain(installation domain.PluginLifecycleInstallation) PluginLifecycleInstallationResponse { installation = domain.CopyPluginLifecycleInstallation(installation) - return PluginLifecycleInstallationResponse{ID: installation.ID, PluginID: installation.PluginID, ServerInstanceID: installation.ServerInstanceID, CurrentVersion: installation.CurrentVersion, TargetVersion: installation.TargetVersion, PreviousVersion: installation.PreviousVersion, DesiredState: string(installation.DesiredState), CurrentState: string(installation.CurrentState), LastOperation: string(installation.LastOperation), Compatibility: installation.Compatibility, DependencyState: string(installation.DependencyState), JobID: installation.JobID, AlertID: installation.AlertID, AuditEventID: installation.AuditEventID, FailureReason: installation.FailureReason, CreatedAt: installation.CreatedAt, UpdatedAt: installation.UpdatedAt} + return PluginLifecycleInstallationResponse{ID: installation.ID, PluginID: installation.PluginID, ServerInstanceID: installation.ServerInstanceID, CurrentVersion: installation.CurrentVersion, TargetVersion: installation.TargetVersion, PreviousVersion: installation.PreviousVersion, DesiredState: string(installation.DesiredState), CurrentState: string(installation.CurrentState), LastOperation: string(installation.LastOperation), Compatibility: installation.Compatibility, DependencyState: string(installation.DependencyState), JobID: installation.JobID, FailureReason: installation.FailureReason, CreatedAt: installation.CreatedAt, UpdatedAt: installation.UpdatedAt} } func PluginLifecycleListFromDomain(installations []domain.PluginLifecycleInstallation) PluginLifecycleListResponse { @@ -240,12 +100,7 @@ func PluginLifecycleListFromDomain(installations []domain.PluginLifecycleInstall func PluginLifecycleResultFromDomain(result domain.PluginLifecycleResult) PluginLifecycleActionResponse { result = domain.CopyPluginLifecycleResult(result) - var alert *AlertResponse - if result.Alert != nil { - item := AlertFromDomain(*result.Alert) - alert = &item - } - return PluginLifecycleActionResponse{Status: result.Status, Installation: PluginLifecycleFromDomain(result.Installation), Job: JobFromDomain(result.Job), Decision: CapacityDecisionFromDomain(result.Decision), Alert: alert} + return PluginLifecycleActionResponse{Status: result.Status, Installation: PluginLifecycleFromDomain(result.Installation), Job: JobFromDomain(result.Job)} } func AIConfigDiffFromDomain(preview domain.AIConfigDiffPreview) AIConfigDiffPreviewResponse { @@ -269,14 +124,3 @@ func AIConfigDiffApprovalFromDomain(result domain.AIConfigDiffApprovalResult) AI result = domain.CopyAIConfigDiffApprovalResult(result) return AIConfigDiffApprovalResponse{Preview: AIConfigDiffFromDomain(result.Preview), Dispatch: ServerConfigWriteDispatchFromDomain(result.Dispatch)} } - -func pressureCodesFromDomain(codes []domain.CapacityPressureCode) []string { - if codes == nil { - return nil - } - out := make([]string, len(codes)) - for i, code := range codes { - out[i] = string(code) - } - return out -} diff --git a/platform/dto/resources.go b/platform/dto/resources.go index 3419ea9..5e1685c 100644 --- a/platform/dto/resources.go +++ b/platform/dto/resources.go @@ -262,23 +262,14 @@ type GamePluginRemoteAccessBody struct { } 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"` - MaxPayloadBytes int `json:"maxPayloadBytes"` - ProtectedRequest *GameClientBridgeProtectedRequestDeclarationBody `json:"protectedRequest,omitempty"` -} - -type GameClientBridgeProtectedRequestDeclarationBody struct { - Kind string `json:"kind"` - TransportKey string `json:"transportKey"` - TargetKey string `json:"targetKey"` - TextField string `json:"textField"` - MaxTextBytes int `json:"maxTextBytes"` + 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"` + MaxPayloadBytes int `json:"maxPayloadBytes"` } type GameClientBridgeSnapshotDeclarationBody struct { @@ -324,19 +315,10 @@ type GameClientBridgeLogProjectionTargetDeclarationBody struct { ObservedAtField string `json:"observedAtField,omitempty"` } -type GameClientBridgeLogProjectionAnnouncementDeclarationBody struct { - ProfileKey string `json:"profileKey"` - CommandType string `json:"commandType"` - TextField string `json:"textField"` - NewTextTemplate string `json:"newTextTemplate"` - ReturningTextTemplate string `json:"returningTextTemplate"` -} - type GameClientBridgeLogProjectionPresenceDeclarationBody struct { - TimestampField string `json:"timestampField"` - ActiveWindowSeconds int `json:"activeWindowSeconds"` - ActivityTarget *GameClientBridgeLogProjectionTargetDeclarationBody `json:"activityTarget,omitempty"` - Announcement GameClientBridgeLogProjectionAnnouncementDeclarationBody `json:"announcement"` + TimestampField string `json:"timestampField"` + ActiveWindowSeconds int `json:"activeWindowSeconds"` + ActivityTarget *GameClientBridgeLogProjectionTargetDeclarationBody `json:"activityTarget,omitempty"` } type GameClientBridgeLogProjectionDeclarationBody struct { @@ -895,7 +877,7 @@ type JobExecutionResultResponse struct { Version int `json:"version,omitempty"` Checksum string `json:"checksum,omitempty"` SizeBytes int64 `json:"sizeBytes,omitempty"` - AuditSummary string `json:"auditSummary,omitempty"` + Summary string `json:"summary,omitempty"` ServerDeploymentEvidence *ServerDeploymentEvidenceBody `json:"serverDeploymentEvidence,omitempty"` } @@ -958,32 +940,6 @@ type LogStreamListResponse struct { Count int `json:"count"` } -type AuditEventCreateRequest struct { - ID string `json:"id"` - ActorID string `json:"actorId"` - Action string `json:"action"` - ResourceKind string `json:"resourceKind"` - ResourceID string `json:"resourceId"` - Result domain.AuditResult `json:"result"` - Summary string `json:"summary"` -} - -type AuditEventResponse struct { - ID string `json:"id"` - ActorID string `json:"actorId"` - Action string `json:"action"` - ResourceKind string `json:"resourceKind"` - ResourceID string `json:"resourceId"` - Result domain.AuditResult `json:"result"` - Summary string `json:"summary"` - CreatedAt time.Time `json:"createdAt"` -} - -type AuditEventListResponse struct { - Items []AuditEventResponse `json:"items"` - Count int `json:"count"` -} - type ErrorResponse struct { Code string `json:"code"` Message string `json:"message"` @@ -1223,7 +1179,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, ProtectedRequest: protectedRequestToDomain(command.ProtectedRequest)} + 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} } snapshots := make([]domain.GameClientBridgeSnapshotDeclaration, len(body.Snapshots)) for index, snapshot := range body.Snapshots { @@ -1276,13 +1232,6 @@ func gameClientBridgeLogProjectionToDomain(value GameClientBridgeLogProjectionDe TimestampField: value.Presence.TimestampField, ActiveWindowSeconds: value.Presence.ActiveWindowSeconds, ActivityTarget: gameClientBridgeLogProjectionTargetToDomainPointer(value.Presence.ActivityTarget), - Announcement: domain.GameClientBridgeLogProjectionAnnouncementDeclaration{ - ProfileKey: value.Presence.Announcement.ProfileKey, - CommandType: value.Presence.Announcement.CommandType, - TextField: value.Presence.Announcement.TextField, - NewTextTemplate: value.Presence.Announcement.NewTextTemplate, - ReturningTextTemplate: value.Presence.Announcement.ReturningTextTemplate, - }, } } return domain.GameClientBridgeLogProjectionDeclaration{ @@ -1308,13 +1257,6 @@ func gameClientBridgeLogProjectionTargetToDomainPointer(value *GameClientBridgeL return &target } -func protectedRequestToDomain(value *GameClientBridgeProtectedRequestDeclarationBody) *domain.GameClientBridgeProtectedRequestDeclaration { - if value == nil { - return nil - } - return &domain.GameClientBridgeProtectedRequestDeclaration{Kind: value.Kind, TransportKey: value.TransportKey, TargetKey: value.TargetKey, TextField: value.TextField, MaxTextBytes: value.MaxTextBytes} -} - func (actions PluginLifecycleActionsBody) ToDomain() domain.PluginLifecycleActions { return domain.PluginLifecycleActions{ Install: actions.Install, @@ -1461,18 +1403,6 @@ func (request LogStreamCreateRequest) ToDomain() domain.LogStream { } } -func (request AuditEventCreateRequest) ToDomain() domain.AuditEvent { - return domain.AuditEvent{ - ID: request.ID, - ActorID: request.ActorID, - Action: request.Action, - ResourceKind: request.ResourceKind, - ResourceID: request.ResourceID, - Result: request.Result, - Summary: request.Summary, - } -} - func UserFromDomain(user domain.User) UserResponse { user = domain.CopyUser(user) return UserResponse{ @@ -1710,7 +1640,7 @@ func gameClientBridgeManifestFromDomain(value domain.GameClientBridgeManifest) G 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, ProtectedRequest: protectedRequestFromDomain(command.ProtectedRequest)} + 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} } snapshots := make([]GameClientBridgeSnapshotDeclarationBody, len(value.Snapshots)) for index, snapshot := range value.Snapshots { @@ -1762,13 +1692,6 @@ func gameClientBridgeLogProjectionFromDomain(value domain.GameClientBridgeLogPro TimestampField: value.Presence.TimestampField, ActiveWindowSeconds: value.Presence.ActiveWindowSeconds, ActivityTarget: gameClientBridgeLogProjectionTargetFromDomainPointer(value.Presence.ActivityTarget), - Announcement: GameClientBridgeLogProjectionAnnouncementDeclarationBody{ - ProfileKey: value.Presence.Announcement.ProfileKey, - CommandType: value.Presence.Announcement.CommandType, - TextField: value.Presence.Announcement.TextField, - NewTextTemplate: value.Presence.Announcement.NewTextTemplate, - ReturningTextTemplate: value.Presence.Announcement.ReturningTextTemplate, - }, } } return GameClientBridgeLogProjectionDeclarationBody{ @@ -1794,13 +1717,6 @@ func gameClientBridgeLogProjectionTargetFromDomainPointer(value *domain.GameClie return &target } -func protectedRequestFromDomain(value *domain.GameClientBridgeProtectedRequestDeclaration) *GameClientBridgeProtectedRequestDeclarationBody { - if value == nil { - return nil - } - return &GameClientBridgeProtectedRequestDeclarationBody{Kind: value.Kind, TransportKey: value.TransportKey, TargetKey: value.TargetKey, TextField: value.TextField, MaxTextBytes: value.MaxTextBytes} -} - func MarketplacePluginListFromDomain(plugins []domain.PluginMarketplacePlugin) MarketplacePluginListResponse { items := make([]MarketplacePluginResponse, len(plugins)) for i, plugin := range plugins { @@ -2012,7 +1928,7 @@ func JobFromDomain(job domain.Job) JobResponse { State: job.State, Progress: progressFromDomain(job.Progress), ResultRef: job.ResultRef, - ExecutionResult: JobExecutionResultResponse{Kind: job.ExecutionResult.Kind, ProcessState: job.ExecutionResult.ProcessState, ExitClassification: job.ExecutionResult.ExitClassification, ExitCode: job.ExecutionResult.ExitCode, Version: job.ExecutionResult.Version, Checksum: job.ExecutionResult.Checksum, SizeBytes: job.ExecutionResult.SizeBytes, AuditSummary: job.ExecutionResult.AuditSummary, ServerDeploymentEvidence: serverDeploymentEvidenceFromDomain(job.ExecutionResult.ServerDeploymentEvidence)}, + ExecutionResult: JobExecutionResultResponse{Kind: job.ExecutionResult.Kind, ProcessState: job.ExecutionResult.ProcessState, ExitClassification: job.ExecutionResult.ExitClassification, ExitCode: job.ExecutionResult.ExitCode, Version: job.ExecutionResult.Version, Checksum: job.ExecutionResult.Checksum, SizeBytes: job.ExecutionResult.SizeBytes, Summary: job.ExecutionResult.Summary, ServerDeploymentEvidence: serverDeploymentEvidenceFromDomain(job.ExecutionResult.ServerDeploymentEvidence)}, RetryPolicy: JobRetryPolicyResponse{ MaxAttempts: job.RetryPolicy.MaxAttempts, InitialBackoffSeconds: job.RetryPolicy.InitialBackoffSeconds, @@ -2095,27 +2011,6 @@ func LogStreamListFromDomain(streams []domain.LogStream) LogStreamListResponse { return LogStreamListResponse{Items: items, Count: len(items)} } -func AuditEventFromDomain(event domain.AuditEvent) AuditEventResponse { - return AuditEventResponse{ - ID: event.ID, - ActorID: event.ActorID, - Action: event.Action, - ResourceKind: event.ResourceKind, - ResourceID: event.ResourceID, - Result: event.Result, - Summary: event.Summary, - CreatedAt: event.CreatedAt, - } -} - -func AuditEventListFromDomain(events []domain.AuditEvent) AuditEventListResponse { - items := make([]AuditEventResponse, len(events)) - for i, event := range events { - items[i] = AuditEventFromDomain(event) - } - return AuditEventListResponse{Items: items, Count: len(items)} -} - func permissionsFromDomain(permissions domain.PluginPermissions) PluginPermissionsResponse { return PluginPermissionsResponse{ AI: permissions.AI, diff --git a/platform/dto/resources_test.go b/platform/dto/resources_test.go index aa55e16..c2ae6df 100644 --- a/platform/dto/resources_test.go +++ b/platform/dto/resources_test.go @@ -143,16 +143,16 @@ func TestGameClientBridgeQueryTemplateDeclarationRoundTripIsSafe(t *testing.T) { LogProjections: []GameClientBridgeLogProjectionDeclarationBody{{ Key: "player.login", StreamKeys: []string{"process.stdout"}, Steps: []GameClientBridgeLogProjectionStepDeclarationBody{{Pattern: `Player (?\d+)`}}, CorrelationFields: []string{"slot"}, MaxInterveningLines: 8, Target: GameClientBridgeLogProjectionTargetDeclarationBody{Collection: "users", UpsertKeys: []string{"steamId"}, CaptureMappings: map[string]string{"steamId": "steamId"}, FixedValues: map[string]string{"source": "stdout"}, ObservedAtField: "lastLoginAt"}, - Presence: &GameClientBridgeLogProjectionPresenceDeclarationBody{TimestampField: "lastLoginAt", ActiveWindowSeconds: 600, ActivityTarget: &GameClientBridgeLogProjectionTargetDeclarationBody{Collection: "activity", UpsertKeys: []string{"steamId"}, CaptureMappings: map[string]string{"steamId": "steamId"}}, Announcement: GameClientBridgeLogProjectionAnnouncementDeclarationBody{ProfileKey: "scum-client", CommandType: "announcement.send", TextField: "message", NewTextTemplate: "welcome {{name}}", ReturningTextTemplate: "welcome back {{name}}"}}, + Presence: &GameClientBridgeLogProjectionPresenceDeclarationBody{TimestampField: "lastLoginAt", ActiveWindowSeconds: 600, ActivityTarget: &GameClientBridgeLogProjectionTargetDeclarationBody{Collection: "activity", UpsertKeys: []string{"steamId"}, CaptureMappings: map[string]string{"steamId": "steamId"}}}, }}, - DataPacks: []GameClientBridgeDataPackDeclarationBody{{Key: "db-v1", DatabaseUserVersion: 1, LogParserRefs: []string{"data/logs.json"}, ConfigMapRefs: []string{"data/config.json"}, DataRefs: []string{"data/items.json"}}}, + DataPacks: []GameClientBridgeDataPackDeclarationBody{{Key: "db-v1", DatabaseUserVersion: 1, LogParserRefs: []string{"data/logs.json"}, ConfigMapRefs: []string{"data/config.json"}, DataRefs: []string{"data/items.json"}}}, CommandRetentionSeconds: 86400, MaxCommands: 1000, Pages: []GameClientBridgePageContractBody{{PageKey: "players", QueryTemplateKeys: []string{"player.lookup"}}}, } domainManifest := body.ToDomain() - if len(domainManifest.QueryTemplates) != 1 || domainManifest.QueryTemplates[0].SQLRef != "sql/player-lookup.sql" || domainManifest.QueryTemplates[0].PollIntervalSeconds != 3 || domainManifest.QueryTemplates[0].RowTarget.Collection != "users" || domainManifest.QueryTemplates[0].RowTarget.WriteMode != "merge" || len(domainManifest.LogProjections) != 1 || domainManifest.LogProjections[0].Presence.ActiveWindowSeconds != 600 || len(domainManifest.DataPacks) != 1 || domainManifest.DataPacks[0].DataRefs[0] != "data/items.json" || domainManifest.Pages[0].QueryTemplateKeys[0] != "player.lookup" { + if len(domainManifest.QueryTemplates) != 1 || domainManifest.QueryTemplates[0].SQLRef != "sql/player-lookup.sql" || domainManifest.QueryTemplates[0].PollIntervalSeconds != 3 || domainManifest.QueryTemplates[0].RowTarget.Collection != "users" || domainManifest.QueryTemplates[0].RowTarget.WriteMode != "merge" || len(domainManifest.LogProjections) != 1 || domainManifest.LogProjections[0].Presence.ActiveWindowSeconds != 600 || len(domainManifest.DataPacks) != 1 || domainManifest.DataPacks[0].DataRefs[0] != "data/items.json" || domainManifest.Pages[0].QueryTemplateKeys[0] != "player.lookup" { t.Fatalf("query template conversion lost declaration fields: %#v", domainManifest) } domainManifest.QueryTemplates[0].RowTarget.ColumnMappings["userId"] = "mutated" @@ -170,11 +170,11 @@ func TestGameClientBridgeQueryTemplateDeclarationRoundTripIsSafe(t *testing.T) { t.Fatal("query template page keys alias request DTO data") } domainManifest.Pages[0].QueryTemplateKeys[0] = "player.lookup" - domainManifest.DataPacks[0].DataRefs[0] = "mutated" - if body.DataPacks[0].DataRefs[0] != "data/items.json" { - t.Fatal("data pack data refs alias request DTO data") - } - domainManifest.DataPacks[0].DataRefs[0] = "data/items.json" + domainManifest.DataPacks[0].DataRefs[0] = "mutated" + if body.DataPacks[0].DataRefs[0] != "data/items.json" { + t.Fatal("data pack data refs alias request DTO data") + } + domainManifest.DataPacks[0].DataRefs[0] = "data/items.json" response := gameClientBridgeManifestFromDomain(domainManifest) response.LogProjections[0].Target.FixedValues["source"] = "mutated" diff --git a/platform/model/README.md b/platform/model/README.md index 86f1ce6..1c7cfe7 100644 --- a/platform/model/README.md +++ b/platform/model/README.md @@ -12,12 +12,12 @@ Required model groups: - jobs and job events. - artifacts and chunks. - log streams and ingestion cursors. -- audit events. -- durable alerts and their acknowledgement/resolution audit links. +- operational events. +- durable alerts and their acknowledgement/resolution metadata. - server-bound plugin lifecycle installations and linked jobs. - reviewable AI config diffs and approval fences. Every implemented database model must include field comments, JSON/database tags, and an explicit table name function or equivalent mapping in the chosen stack. # Durable Client Manager persistence -File and MySQL stores persist the Client Manager installation aggregate, component sessions, registration nonce fences, and lifecycle audit references. Session tokens are stored only as hashes; nonce and heartbeat sequence checks are bounded and restart-safe. Reset, ownership/endpoint reassignment, update activation, rollback, revoke, and uninstall fence or expire component sessions without deleting distribution or audit history. +File and MySQL stores persist the Client Manager installation aggregate, component sessions, registration nonce fences, and lifecycle metadata. Session tokens are stored only as hashes; nonce and heartbeat sequence checks are bounded and restart-safe. Reset, ownership/endpoint reassignment, update activation, rollback, revoke, and uninstall fence or expire component sessions without deleting distribution history. diff --git a/platform/model/resources.go b/platform/model/resources.go index 5669b6c..4b60be4 100644 --- a/platform/model/resources.go +++ b/platform/model/resources.go @@ -183,7 +183,7 @@ type GamePlugin struct { Tags []string `json:"tags" db:"tags"` // AIPurposes stores platform-mediated AI usage purposes. AIPurposes []string `json:"aiPurposes" db:"ai_purposes"` - // ProductionLifecycle stores declared server-bound plugin lifecycle governance. + // ProductionLifecycle stores declared server-bound plugin lifecycle operations. ProductionLifecycle domain.GamePluginProductionLifecycle `json:"productionLifecycle" db:"production_lifecycle"` // RemoteAccess stores plugin-declared remote access metadata. RemoteAccess GamePluginRemoteAccess `json:"remoteAccess" db:"remote_access"` @@ -319,7 +319,7 @@ type JobExecutionResult struct { Version int `json:"version,omitempty" db:"version"` Checksum string `json:"checksum,omitempty" db:"checksum"` SizeBytes int64 `json:"sizeBytes,omitempty" db:"size_bytes"` - AuditSummary string `json:"auditSummary,omitempty" db:"audit_summary"` + Summary string `json:"summary,omitempty" db:"summary"` Content string `json:"content,omitempty" db:"content"` } @@ -433,53 +433,6 @@ type LogStream struct { func (LogStream) TableName() string { return "log_streams" } -type AuditEvent struct { - // ID is the stable audit event identifier. - ID string `json:"id" db:"id"` - // ActorID references the user or system actor. - ActorID string `json:"actorId" db:"actor_id"` - // Action is the stable action key. - Action string `json:"action" db:"action"` - // ResourceKind identifies the audited resource type. - ResourceKind string `json:"resourceKind" db:"resource_kind"` - // ResourceID identifies the audited resource. - ResourceID string `json:"resourceId" db:"resource_id"` - // Result is the audit outcome. - Result domain.AuditResult `json:"result" db:"result"` - // Summary is a bounded redacted summary. - Summary string `json:"summary" db:"summary"` - // CreatedAt is the audit timestamp. - CreatedAt time.Time `json:"createdAt" db:"created_at"` -} - -func (AuditEvent) TableName() string { return "audit_events" } - -type Alert struct { - ID string `json:"id" db:"id"` - SourceKind string `json:"sourceKind" db:"source_kind"` - SourceID string `json:"sourceId" db:"source_id"` - RuleKey string `json:"ruleKey" db:"rule_key"` - Severity domain.AlertSeverity `json:"severity" db:"severity"` - State domain.AlertState `json:"state" db:"state"` - Title string `json:"title" db:"title"` - Message string `json:"message" db:"message"` - OccurrenceCount int `json:"occurrenceCount" db:"occurrence_count"` - Retryable bool `json:"retryable" db:"retryable"` - RetryAfterSeconds int `json:"retryAfterSeconds" db:"retry_after_seconds"` - LastJobID string `json:"lastJobId,omitempty" db:"last_job_id"` - LastAuditEventID string `json:"lastAuditEventId,omitempty" db:"last_audit_event_id"` - LastSeenAt time.Time `json:"lastSeenAt" db:"last_seen_at"` - AcknowledgedBy string `json:"acknowledgedBy,omitempty" db:"acknowledged_by"` - AcknowledgedAt time.Time `json:"acknowledgedAt,omitempty" db:"acknowledged_at"` - ResolvedBy string `json:"resolvedBy,omitempty" db:"resolved_by"` - ResolvedAt time.Time `json:"resolvedAt,omitempty" db:"resolved_at"` - ResolutionNote string `json:"resolutionNote,omitempty" db:"resolution_note"` - CreatedAt time.Time `json:"createdAt" db:"created_at"` - UpdatedAt time.Time `json:"updatedAt" db:"updated_at"` -} - -func (Alert) TableName() string { return "alerts" } - type PluginLifecycleInstallation struct { ID string `json:"id" db:"id"` PluginID string `json:"pluginId" db:"plugin_id"` @@ -493,8 +446,6 @@ type PluginLifecycleInstallation struct { Compatibility string `json:"compatibility" db:"compatibility"` DependencyState domain.DependencyState `json:"dependencyState" db:"dependency_state"` JobID string `json:"jobId,omitempty" db:"job_id"` - AlertID string `json:"alertId,omitempty" db:"alert_id"` - AuditEventID string `json:"auditEventId,omitempty" db:"audit_event_id"` FailureReason string `json:"failureReason,omitempty" db:"failure_reason"` IdempotencyKey string `json:"idempotencyKey" db:"idempotency_key"` CreatedAt time.Time `json:"createdAt" db:"created_at"` @@ -930,11 +881,11 @@ func (input JobExecutionInput) ToDomain() domain.JobExecutionInput { } func executionResultFromDomain(result domain.JobExecutionResult) JobExecutionResult { - return JobExecutionResult{Kind: result.Kind, ProcessState: result.ProcessState, ExitClassification: result.ExitClassification, ExitCode: result.ExitCode, Version: result.Version, Checksum: result.Checksum, SizeBytes: result.SizeBytes, AuditSummary: result.AuditSummary, Content: result.Content} + return JobExecutionResult{Kind: result.Kind, ProcessState: result.ProcessState, ExitClassification: result.ExitClassification, ExitCode: result.ExitCode, Version: result.Version, Checksum: result.Checksum, SizeBytes: result.SizeBytes, Summary: result.Summary, Content: result.Content} } func (result JobExecutionResult) ToDomain() domain.JobExecutionResult { - return domain.JobExecutionResult{Kind: result.Kind, ProcessState: result.ProcessState, ExitClassification: result.ExitClassification, ExitCode: result.ExitCode, Version: result.Version, Checksum: result.Checksum, SizeBytes: result.SizeBytes, AuditSummary: result.AuditSummary, Content: result.Content} + return domain.JobExecutionResult{Kind: result.Kind, ProcessState: result.ProcessState, ExitClassification: result.ExitClassification, ExitCode: result.ExitCode, Version: result.Version, Checksum: result.Checksum, SizeBytes: result.SizeBytes, Summary: result.Summary, Content: result.Content} } func (policy JobRetryPolicy) ToDomain() domain.JobRetryPolicy { @@ -1020,29 +971,3 @@ func (stream LogStream) ToDomain() domain.LogStream { UpdatedAt: stream.UpdatedAt, } } - -func AuditEventFromDomain(event domain.AuditEvent) AuditEvent { - return AuditEvent{ - ID: event.ID, - ActorID: event.ActorID, - Action: event.Action, - ResourceKind: event.ResourceKind, - ResourceID: event.ResourceID, - Result: event.Result, - Summary: event.Summary, - CreatedAt: event.CreatedAt, - } -} - -func (event AuditEvent) ToDomain() domain.AuditEvent { - return domain.AuditEvent{ - ID: event.ID, - ActorID: event.ActorID, - Action: event.Action, - ResourceKind: event.ResourceKind, - ResourceID: event.ResourceID, - Result: event.Result, - Summary: event.Summary, - CreatedAt: event.CreatedAt, - } -} diff --git a/platform/model/resources_test.go b/platform/model/resources_test.go index 51959ab..fef7519 100644 --- a/platform/model/resources_test.go +++ b/platform/model/resources_test.go @@ -17,8 +17,6 @@ func TestTableNames(t *testing.T) { Job{}.TableName(): "jobs", Artifact{}.TableName(): "artifacts", LogStream{}.TableName(): "log_streams", - AuditEvent{}.TableName(): "audit_events", - Alert{}.TableName(): "alerts", PluginLifecycleInstallation{}.TableName(): "plugin_lifecycle_installations", AIConfigDiff{}.TableName(): "ai_config_diffs", ClientManagerInstallation{}.TableName(): "client_manager_installations", diff --git a/platform/protocol/ai-provider-contracts.md b/platform/protocol/ai-provider-contracts.md index 04efa41..7736394 100644 --- a/platform/protocol/ai-provider-contracts.md +++ b/platform/protocol/ai-provider-contracts.md @@ -38,4 +38,4 @@ AI invocation responses must be bounded and must not include raw provider creden Management endpoints reject raw key-shaped values in `apiKeyRef`. In `live` mode Platform resolves `env://NAME` or `secret://providers/` inside the service boundary and invokes OpenAI-compatible, OpenAI, Claude, Gemini, Ollama, or custom HTTP providers with bounded requests. Local debug uses explicit `mock` mode. -Provider failures create redacted audit/alert evidence and return a stable safe error without URL, header, key, request-body secret, or stack details. Config suggestions persist `AIConfigDiffPreview` with actor/server/plugin/provider/model, config version/checksum, expiry, and proposed content. Only `POST /api/v1/ai/config-diffs/{id}/approve` may dispatch the matching `config.write` job, and stale/expired/mismatched approvals are rejected. +Provider failures create redacted alert evidence and return a stable safe error without URL, header, key, request-body secret, or stack details. Config suggestions persist `AIConfigDiffPreview` with actor/server/plugin/provider/model, config version/checksum, expiry, and proposed content. Only `POST /api/v1/ai/config-diffs/{id}/approve` may dispatch the matching `config.write` job, and stale/expired/mismatched approvals are rejected. diff --git a/platform/protocol/auth-contracts.md b/platform/protocol/auth-contracts.md index 7b47a53..3307951 100644 --- a/platform/protocol/auth-contracts.md +++ b/platform/protocol/auth-contracts.md @@ -10,7 +10,7 @@ ## Authorization roles -- `platform-admin`: user/provider/plugin installation and state, Run endpoint administration, platform metrics, audit, and global internal resource creation. +- `platform-admin`: user/provider/plugin installation and state, Run endpoint administration, platform metrics, and global internal resource creation. - server owner: server membership, runtime binding changes, destructive/archive operations, and all visible server actions. - server administrator: non-owner operational access to assigned server resources, but no owner-only membership or secret/key rotation. - Run service: control/job/log/artifact channels for its current endpoint session; it cannot use browser bearer authority. @@ -25,6 +25,6 @@ The canonical payload is `METHOD + "\n" + PATH + "\n" + TIMESTAMP + "\n" + NONCE ## Secret boundary -Platform snapshots may contain password verifiers, bearer/Run token hashes, encrypted component-key ciphertext, fingerprints, generations, and controlled `secret://`/`vault://` references. They never contain raw bearer tokens, raw component keys, provider key values, host paths, or direct sockets. Browser DTOs expose secret presence/configured flags only. +Platform snapshots may contain password verifiers, bearer/Run token hashes, encrypted component-key ciphertext, fingerprints, generations, and scoped `secret://`/`vault://` references. They never contain raw bearer tokens, raw component keys, provider key values, host paths, or direct sockets. Browser DTOs expose secret presence/configured flags only. Component-key ciphertext uses an injectable AES-GCM envelope derived from `PLATFORM_SECRET_ENVELOPE_KEY`; the built-in key is a disposable-development fallback only. This boundary is not a production KMS/vault. External key wrapping, KMS/HSM integration, multi-node replay coordination, envelope-key migration, and secret-value rotation remain deferred risks. diff --git a/platform/protocol/dependency-update-contracts.md b/platform/protocol/dependency-update-contracts.md index 602eee5..d81d7fc 100644 --- a/platform/protocol/dependency-update-contracts.md +++ b/platform/protocol/dependency-update-contracts.md @@ -1,11 +1,11 @@ # Dependency And Run Update Contracts -Platform owns the reviewable dependency catalog, immutable plan digest, selected server/profile/binding, endpoint target, distribution artifact, job attempt, and audit projection. Plugins and `platform_web` see only catalog/status/update projections. They never receive resolved host paths, commands, raw bindings, credentials, secret refs, Run/session/lease values, fencing hashes, PIDs, sockets, or artifact bodies. +Platform owns the reviewable dependency catalog, immutable plan digest, selected server/profile/binding, endpoint target, distribution artifact, job attempt, and status projection. Plugins and `platform_web` see only catalog/status/update projections. They never receive resolved host paths, commands, raw bindings, credentials, secret refs, Run/session/lease values, fencing hashes, PIDs, sockets, or artifact bodies. ## Dependency flow 1. `GET /api/v1/server-instances/{id}/dependencies` resolves the installed plugin version, complete runtime binding, online Run endpoint OS/architecture, target-matched probes/plans, and canonical SHA-256 digest. -2. An install request must submit that exact digest. Platform re-resolves the declaration before creating `dependencies.install`; missing or stale approval is denied and audited. +2. An install request must submit that exact digest. Platform re-resolves the declaration before creating `dependencies.install`; missing or stale plan evidence is denied and reported. 3. Run retrieves private input through signed `POST /api/v1/run/jobs/dependency-input` only for the active endpoint/session/attempt/lease and non-cancelled job. It executes closed command-version, Java, Docker, package, service, Steam, file, package-manager, verified HTTPS download, and SteamCMD adapters with bounded output/timeouts and a durable step journal. 4. Terminal evidence is typed and redacted. Platform verifies probe key, plan digest, result checksum, and job attempt before updating `DependencyStatus`. diff --git a/platform/protocol/run-contracts.md b/platform/protocol/run-contracts.md index 94a812d..cbfe306 100644 --- a/platform/protocol/run-contracts.md +++ b/platform/protocol/run-contracts.md @@ -34,7 +34,6 @@ Implemented HTTP JSON routes: - `POST /api/v1/run/jobs/result` - `POST /api/v1/run/jobs/cancel` - `POST /api/v1/run/jobs/reconcile` -- `POST /api/v1/run/jobs/protected-request-input` Named job DTOs: @@ -44,8 +43,6 @@ Named job DTOs: - `RunJobProgressRequest` - `RunJobResultRequest` - `RunJobCancelPollRequest` -- `ProtectedRequestExecutionInputRequest` -- `ProtectedRequestExecutionInputResponse` - `RunJobReconcileRequest` - `RunJobReconcileResponse` @@ -63,7 +60,7 @@ Platform-owned Run distribution builds embed an autonomous lifecycle plan for th The plan is build input for the generated package, not a machine-side job-channel payload. Generated Run registration must not be treated as a trigger to enqueue `process.start`, `process.install`, or `process.status` work; Platform state converges from Run heartbeats, logs, lifecycle reports, supervised process facts, and terminal job/report messages. Platform and Run must not add game-specific hardcoding to interpret the plan. -Autonomous lifecycle reports use `POST /api/v1/run/lifecycle/report` with the active Run session and signed envelope when required. The route accepts only bounded terminal lifecycle facts for `process.install`, `process.start`, `process.stop`, or `process.status`; it validates the server/run binding, records audit evidence, and projects server state from Run-reported process facts without creating or completing a Platform job. A managed-process report includes an opaque `managedProcessId`, monotonic `observationSeq`, and `observedAt`; retries are idempotent and a lower sequence cannot regress a newer fact for that process. +Autonomous lifecycle reports use `POST /api/v1/run/lifecycle/report` with the active Run session and signed envelope when required. The route accepts only bounded terminal lifecycle facts for `process.install`, `process.start`, `process.stop`, or `process.status`; it validates the server/run binding, records bounded evidence, and projects server state from Run-reported process facts without creating or completing a Platform job. A managed-process report includes an opaque `managedProcessId`, monotonic `observationSeq`, and `observedAt`; retries are idempotent and a lower sequence cannot regress a newer fact for that process. ## Log Ingest @@ -89,7 +86,7 @@ Run-assigned Platform jobs use `job..` log stream IDs. Autonom Log ingest is durable and independently retried. Artifact/file transfer backlog must not prevent log batch acknowledgement, duplicate acknowledgement, cursor state updates, or spool cleanup. -The platform stores log stream metadata through `repo.Store` and stores log bodies through the configured `LogBodyStore`. The default `file` backend persists platform metadata to `PLATFORM_METADATA_PATH` and appends log entries to segmented JSONL files under `PLATFORM_LOG_DIR`; the `memory` backend is only for tests and disposable local development. MySQL/Postgres are appropriate for platform metadata, stream state, retention policy, indexes, and audit records, but should not be the primary row-per-log-line store for hundreds or thousands of servers. Production log bodies should move behind the same boundary to append/query backends such as ClickHouse, Loki, OpenSearch/Elasticsearch, or object-storage segments with compact indexes. +The platform stores log stream metadata through `repo.Store` and stores log bodies through the configured `LogBodyStore`. The default `file` backend persists platform metadata to `PLATFORM_METADATA_PATH` and appends log entries to segmented JSONL files under `PLATFORM_LOG_DIR`; the `memory` backend is only for tests and disposable local development. MySQL/Postgres are appropriate for platform metadata, stream state, retention policy, indexes, and operational records, but should not be the primary row-per-log-line store for hundreds or thousands of servers. Production log bodies should move behind the same boundary to append/query backends such as ClickHouse, Loki, OpenSearch/Elasticsearch, or object-storage segments with compact indexes. ## Artifact @@ -122,7 +119,7 @@ Client Manager lifecycle jobs use the independent capabilities `client-manager.d Run materializes the declared output such as `config.yaml` from the fenced values and its own configured Platform control URL. Source template values are not credentials and must not override the generated component identity or policy. The lifecycle input never contains the component proof itself, a component session, a browser credential, a host path, or a direct socket; proof remains inside the component package and is supplied to the supervised process only through the declared environment-variable name. -Run persists staging/active/previous slots and a local journal. It rejects stale lease/attempt/generation/target fences, traversal/link/device-file archives, checksum mismatches, undeclared executables, and arbitrary shell. Terminal results use `client-manager.deployed`, `client-manager.controlled`, `client-manager.updated`, `client-manager.rolled-back`, `client-manager.rollback.restored`, or `client-manager.uninstalled` with logical process/health state only. A stalled Client Manager download must not delay Run heartbeat, job ack/result/cancel, or log spool acknowledgement. +Run persists staging/active/previous slots and a local journal. It rejects stale lease/attempt/generation/target fences, traversal/link/device-file archives, checksum mismatches, undeclared executables, and arbitrary shell. Terminal results use `client-manager.deployed`, `client-manager.control`, `client-manager.updated`, `client-manager.rolled-back`, `client-manager.rollback.restored`, or `client-manager.uninstalled` with logical process/health state only. A stalled Client Manager download must not delay Run heartbeat, job ack/result/cancel, or log spool acknowledgement. ## Game Client Bridge diff --git a/platform/repo/file_store.go b/platform/repo/file_store.go index 3357448..fb89dd7 100644 --- a/platform/repo/file_store.go +++ b/platform/repo/file_store.go @@ -33,10 +33,8 @@ type StoreSnapshot struct { ClientManagerBuildJobs []domain.ClientManagerBuildJob `json:"clientManagerBuildJobs"` RunUpdateJobs []domain.RunUpdateJob `json:"runUpdateJobs"` LogStreams []domain.LogStream `json:"logStreams"` - AuditEvents []domain.AuditEvent `json:"auditEvents"` MetricSamples []domain.MetricSample `json:"metricSamples"` Backups []domain.BackupRecord `json:"backups"` - Alerts []domain.AlertRecord `json:"alerts"` PluginLifecycles []domain.PluginLifecycleInstallation `json:"pluginLifecycles"` AIConfigDiffs []domain.AIConfigDiffPreview `json:"aiConfigDiffs"` GameClientBridgeCommands []domain.GameClientBridgeCommand `json:"gameClientBridgeCommands"` @@ -153,10 +151,6 @@ func (store *FileStore) LogStreams() LogStreamRepository { return &persistentRepository[domain.LogStream, domain.LogStreamFilter]{repository: store.MemoryStore.logStreams, persist: store.persist} } -func (store *FileStore) AuditEvents() AuditEventRepository { - return &persistentRepository[domain.AuditEvent, domain.AuditEventFilter]{repository: store.MemoryStore.auditEvents, persist: store.persist} -} - func (store *FileStore) MetricSamples() MetricSampleRepository { return &persistentRepository[domain.MetricSample, domain.MetricSampleFilter]{repository: store.MemoryStore.metricSamples, persist: store.persist} } @@ -165,10 +159,6 @@ func (store *FileStore) Backups() BackupRepository { return &persistentRepository[domain.BackupRecord, domain.BackupFilter]{repository: store.MemoryStore.backups, persist: store.persist} } -func (store *FileStore) Alerts() AlertRepository { - return &persistentRepository[domain.AlertRecord, domain.AlertFilter]{repository: store.MemoryStore.alerts, persist: store.persist} -} - func (store *FileStore) PluginLifecycles() PluginLifecycleRepository { return &persistentRepository[domain.PluginLifecycleInstallation, domain.PluginLifecycleFilter]{repository: store.MemoryStore.pluginLifecycle, persist: store.persist} } @@ -257,10 +247,8 @@ func (store *FileStore) snapshot() StoreSnapshot { ClientManagerBuildJobs: snapshotRepository(store.MemoryStore.buildJobs), RunUpdateJobs: snapshotRepository(store.MemoryStore.updateJobs), LogStreams: snapshotRepository(store.MemoryStore.logStreams), - AuditEvents: snapshotRepository(store.MemoryStore.auditEvents), MetricSamples: snapshotRepository(store.MemoryStore.metricSamples), Backups: snapshotRepository(store.MemoryStore.backups), - Alerts: snapshotRepository(store.MemoryStore.alerts), PluginLifecycles: snapshotRepository(store.MemoryStore.pluginLifecycle), AIConfigDiffs: snapshotRepository(store.MemoryStore.aiConfigDiffs), GameClientBridgeCommands: snapshotRepository(store.MemoryStore.bridgeCommands.memoryRepository), @@ -291,10 +279,8 @@ func (store *FileStore) loadSnapshot(snapshot StoreSnapshot) { loadRepository(store.MemoryStore.buildJobs, snapshot.ClientManagerBuildJobs) loadRepository(store.MemoryStore.updateJobs, snapshot.RunUpdateJobs) loadRepository(store.MemoryStore.logStreams, snapshot.LogStreams) - loadRepository(store.MemoryStore.auditEvents, snapshot.AuditEvents) loadRepository(store.MemoryStore.metricSamples, snapshot.MetricSamples) loadRepository(store.MemoryStore.backups, snapshot.Backups) - loadRepository(store.MemoryStore.alerts, snapshot.Alerts) loadRepository(store.MemoryStore.pluginLifecycle, snapshot.PluginLifecycles) loadRepository(store.MemoryStore.aiConfigDiffs, snapshot.AIConfigDiffs) loadRepository(store.MemoryStore.bridgeCommands.memoryRepository, snapshot.GameClientBridgeCommands) diff --git a/platform/repo/game_client_bridge_test.go b/platform/repo/game_client_bridge_test.go index b8038a1..4616b92 100644 --- a/platform/repo/game_client_bridge_test.go +++ b/platform/repo/game_client_bridge_test.go @@ -12,19 +12,19 @@ import ( func TestGameClientBridgeCommandRepositoryIdempotencyAndRetention(t *testing.T) { store := NewMemoryStore() now := time.Now().UTC() - command := domain.GameClientBridgeCommand{ID: "command-1", ServerInstanceID: "server-1", PluginID: "game.scum", ProfileKey: "scum-client", CommandType: "announcement.send", IdempotencyKey: "same-key", RequesterID: "user-1", State: domain.GameClientBridgeCommandSucceeded, Payload: map[string]any{"nested": map[string]any{"value": "original"}}, ExpiresAt: now.Add(-time.Minute), CompletedAt: now.Add(-time.Minute)} + command := domain.GameClientBridgeCommand{ID: "command-1", ServerInstanceID: "server-1", PluginID: "game.scum", ProfileKey: "scum-client", CommandType: "diagnostic.ping", IdempotencyKey: "same-key", RequesterID: "user-1", State: domain.GameClientBridgeCommandSucceeded, Payload: map[string]any{"nested": map[string]any{"value": "original"}}, ExpiresAt: now.Add(-time.Minute), CompletedAt: now.Add(-time.Minute)} if err := store.GameClientBridgeCommands().Create(command); err != nil { t.Fatalf("create command: %v", err) } command.Payload["nested"].(map[string]any)["value"] = "mutated" - loaded, err := store.GameClientBridgeCommands().GetByIdempotency("server-1", "user-1", "announcement.send", "same-key") + loaded, err := store.GameClientBridgeCommands().GetByIdempotency("server-1", "user-1", "diagnostic.ping", "same-key") if err != nil { t.Fatalf("get by idempotency: %v", err) } if loaded.Payload["nested"].(map[string]any)["value"] != "original" { t.Fatal("repository did not isolate nested payload") } - for _, lookup := range [][4]string{{"server-2", "user-1", "announcement.send", "same-key"}, {"server-1", "user-2", "announcement.send", "same-key"}, {"server-1", "user-1", "diagnostic.safe", "same-key"}} { + for _, lookup := range [][4]string{{"server-2", "user-1", "diagnostic.ping", "same-key"}, {"server-1", "user-2", "diagnostic.ping", "same-key"}, {"server-1", "user-1", "diagnostic.safe", "same-key"}} { if _, err := store.GameClientBridgeCommands().GetByIdempotency(lookup[0], lookup[1], lookup[2], lookup[3]); !errors.Is(err, ErrNotFound) { t.Fatalf("idempotency scope leaked for %v: %v", lookup, err) } diff --git a/platform/repo/mysql_store.go b/platform/repo/mysql_store.go index b8499c7..9bc8aaf 100644 --- a/platform/repo/mysql_store.go +++ b/platform/repo/mysql_store.go @@ -133,10 +133,6 @@ func (store *MySQLStore) LogStreams() LogStreamRepository { return &persistentRepository[domain.LogStream, domain.LogStreamFilter]{repository: store.MemoryStore.logStreams, persist: store.persist} } -func (store *MySQLStore) AuditEvents() AuditEventRepository { - return &persistentRepository[domain.AuditEvent, domain.AuditEventFilter]{repository: store.MemoryStore.auditEvents, persist: store.persist} -} - func (store *MySQLStore) MetricSamples() MetricSampleRepository { return &persistentRepository[domain.MetricSample, domain.MetricSampleFilter]{repository: store.MemoryStore.metricSamples, persist: store.persist} } @@ -145,10 +141,6 @@ func (store *MySQLStore) Backups() BackupRepository { return &persistentRepository[domain.BackupRecord, domain.BackupFilter]{repository: store.MemoryStore.backups, persist: store.persist} } -func (store *MySQLStore) Alerts() AlertRepository { - return &persistentRepository[domain.AlertRecord, domain.AlertFilter]{repository: store.MemoryStore.alerts, persist: store.persist} -} - func (store *MySQLStore) PluginLifecycles() PluginLifecycleRepository { return &persistentRepository[domain.PluginLifecycleInstallation, domain.PluginLifecycleFilter]{repository: store.MemoryStore.pluginLifecycle, persist: store.persist} } @@ -254,10 +246,8 @@ func (store *MySQLStore) snapshot() StoreSnapshot { ClientManagerBuildJobs: snapshotRepository(store.MemoryStore.buildJobs), RunUpdateJobs: snapshotRepository(store.MemoryStore.updateJobs), LogStreams: snapshotRepository(store.MemoryStore.logStreams), - AuditEvents: snapshotRepository(store.MemoryStore.auditEvents), MetricSamples: snapshotRepository(store.MemoryStore.metricSamples), Backups: snapshotRepository(store.MemoryStore.backups), - Alerts: snapshotRepository(store.MemoryStore.alerts), PluginLifecycles: snapshotRepository(store.MemoryStore.pluginLifecycle), AIConfigDiffs: snapshotRepository(store.MemoryStore.aiConfigDiffs), GameClientBridgeCommands: snapshotRepository(store.MemoryStore.bridgeCommands.memoryRepository), @@ -288,10 +278,8 @@ func (store *MySQLStore) loadSnapshot(snapshot StoreSnapshot) { loadRepository(store.MemoryStore.buildJobs, snapshot.ClientManagerBuildJobs) loadRepository(store.MemoryStore.updateJobs, snapshot.RunUpdateJobs) loadRepository(store.MemoryStore.logStreams, snapshot.LogStreams) - loadRepository(store.MemoryStore.auditEvents, snapshot.AuditEvents) loadRepository(store.MemoryStore.metricSamples, snapshot.MetricSamples) loadRepository(store.MemoryStore.backups, snapshot.Backups) - loadRepository(store.MemoryStore.alerts, snapshot.Alerts) loadRepository(store.MemoryStore.pluginLifecycle, snapshot.PluginLifecycles) loadRepository(store.MemoryStore.aiConfigDiffs, snapshot.AIConfigDiffs) loadRepository(store.MemoryStore.bridgeCommands.memoryRepository, snapshot.GameClientBridgeCommands) diff --git a/platform/repo/resources.go b/platform/repo/resources.go index ac0b551..e140ce3 100644 --- a/platform/repo/resources.go +++ b/platform/repo/resources.go @@ -155,13 +155,6 @@ type LogStreamRepository interface { Update(domain.LogStream) error } -type AuditEventRepository interface { - Create(domain.AuditEvent) error - Get(id string) (domain.AuditEvent, error) - List(domain.AuditEventFilter) ([]domain.AuditEvent, error) - Update(domain.AuditEvent) error -} - type MetricSampleRepository interface { Create(domain.MetricSample) error Get(id string) (domain.MetricSample, error) @@ -178,13 +171,6 @@ type BackupRepository interface { Delete(id string) error } -type AlertRepository interface { - Create(domain.AlertRecord) error - Get(id string) (domain.AlertRecord, error) - List(domain.AlertFilter) ([]domain.AlertRecord, error) - Update(domain.AlertRecord) error -} - type PluginLifecycleRepository interface { Create(domain.PluginLifecycleInstallation) error Get(id string) (domain.PluginLifecycleInstallation, error) @@ -254,10 +240,8 @@ type Store interface { ClientManagerBuildJobs() ClientManagerBuildJobRepository RunUpdateJobs() RunUpdateJobRepository LogStreams() LogStreamRepository - AuditEvents() AuditEventRepository MetricSamples() MetricSampleRepository Backups() BackupRepository - Alerts() AlertRepository PluginLifecycles() PluginLifecycleRepository AIConfigDiffs() AIConfigDiffRepository GameClientBridgeCommands() GameClientBridgeCommandRepository @@ -287,10 +271,8 @@ type MemoryStore struct { buildJobs *memoryRepository[domain.ClientManagerBuildJob, domain.ClientManagerBuildJobFilter] updateJobs *memoryRepository[domain.RunUpdateJob, domain.RunUpdateJobFilter] logStreams *memoryRepository[domain.LogStream, domain.LogStreamFilter] - auditEvents *memoryRepository[domain.AuditEvent, domain.AuditEventFilter] metricSamples *memoryRepository[domain.MetricSample, domain.MetricSampleFilter] backups *memoryRepository[domain.BackupRecord, domain.BackupFilter] - alerts *memoryRepository[domain.AlertRecord, domain.AlertFilter] pluginLifecycle *memoryRepository[domain.PluginLifecycleInstallation, domain.PluginLifecycleFilter] aiConfigDiffs *memoryRepository[domain.AIConfigDiffPreview, domain.AIConfigDiffFilter] bridgeCommands *memoryGameClientBridgeCommandRepository @@ -397,11 +379,6 @@ func NewMemoryStore() *MemoryStore { domain.CopyLogStream, matchLogStream, ), - auditEvents: newMemoryRepository( - func(event domain.AuditEvent) string { return event.ID }, - domain.CopyAuditEvent, - matchAuditEvent, - ), metricSamples: newMemoryRepository( func(sample domain.MetricSample) string { return sample.ID }, domain.CopyMetricSample, @@ -412,11 +389,6 @@ func NewMemoryStore() *MemoryStore { domain.CopyBackupRecord, matchBackup, ), - alerts: newMemoryRepository( - func(alert domain.AlertRecord) string { return alert.ID }, - domain.CopyAlertRecord, - matchAlert, - ), pluginLifecycle: newMemoryRepository( func(installation domain.PluginLifecycleInstallation) string { return installation.ID }, domain.CopyPluginLifecycleInstallation, @@ -470,10 +442,8 @@ func (store *MemoryStore) ClientManagerBuildJobs() ClientManagerBuildJobReposito } func (store *MemoryStore) RunUpdateJobs() RunUpdateJobRepository { return store.updateJobs } func (store *MemoryStore) LogStreams() LogStreamRepository { return store.logStreams } -func (store *MemoryStore) AuditEvents() AuditEventRepository { return store.auditEvents } func (store *MemoryStore) MetricSamples() MetricSampleRepository { return store.metricSamples } func (store *MemoryStore) Backups() BackupRepository { return store.backups } -func (store *MemoryStore) Alerts() AlertRepository { return store.alerts } func (store *MemoryStore) PluginLifecycles() PluginLifecycleRepository { return store.pluginLifecycle } @@ -737,13 +707,6 @@ func matchLogStream(stream domain.LogStream, filter domain.LogStreamFilter) bool (filter.StreamKey == "" || stream.StreamKey == filter.StreamKey) } -func matchAuditEvent(event domain.AuditEvent, filter domain.AuditEventFilter) bool { - return (filter.ActorID == "" || event.ActorID == filter.ActorID) && - (filter.ResourceKind == "" || event.ResourceKind == filter.ResourceKind) && - (filter.ResourceID == "" || event.ResourceID == filter.ResourceID) && - (filter.Result == "" || event.Result == filter.Result) -} - func matchMetricSample(sample domain.MetricSample, filter domain.MetricSampleFilter) bool { return (filter.ServerInstanceID == "" || sample.ServerInstanceID == filter.ServerInstanceID) && (filter.After.IsZero() || sample.CollectedAt.After(filter.After)) && @@ -755,13 +718,6 @@ func matchBackup(record domain.BackupRecord, filter domain.BackupFilter) bool { (filter.State == "" || record.State == filter.State) } -func matchAlert(alert domain.AlertRecord, filter domain.AlertFilter) bool { - return (filter.State == "" || alert.State == filter.State) && - (filter.SourceKind == "" || alert.SourceKind == filter.SourceKind) && - (filter.SourceID == "" || alert.SourceID == filter.SourceID) && - (filter.Severity == "" || alert.Severity == filter.Severity) -} - func matchPluginLifecycle(installation domain.PluginLifecycleInstallation, filter domain.PluginLifecycleFilter) bool { return (filter.PluginID == "" || installation.PluginID == filter.PluginID) && (filter.ServerInstanceID == "" || installation.ServerInstanceID == filter.ServerInstanceID) && diff --git a/platform/repo/resources_test.go b/platform/repo/resources_test.go index e5ed41a..e92ebce 100644 --- a/platform/repo/resources_test.go +++ b/platform/repo/resources_test.go @@ -145,7 +145,7 @@ func TestFileStorePersistsAndReloadsResources(t *testing.T) { }, ServerDeploymentPlan: &domain.ServerDeploymentPlan{SchemaVersion: "1", Operation: "install", PluginID: "game.runtime", Prerequisites: []domain.RuntimeServerPrerequisite{{Key: "steamcmd", Kind: "tool"}}}, }, - ExecutionResult: domain.JobExecutionResult{Kind: "file.read", Version: 2, Checksum: "sha256:" + strings.Repeat("2", 64), SizeBytes: 15, AuditSummary: "bounded read", Content: "private-read"}, + ExecutionResult: domain.JobExecutionResult{Kind: "file.read", Version: 2, Checksum: "sha256:" + strings.Repeat("2", 64), SizeBytes: 15, Summary: "bounded read", Content: "private-read"}, } if err := store.Jobs().Create(job); err != nil { t.Fatalf("create job: %v", err) @@ -277,7 +277,7 @@ func TestMySQLSnapshotRoundTripsDurableJobSchedulingMetadata(t *testing.T) { PluginID: "game.runtime", }, }, - ExecutionResult: domain.JobExecutionResult{Kind: "file.write", Version: 2, Checksum: "sha256:" + strings.Repeat("4", 64), SizeBytes: 14, AuditSummary: "atomic write"}, + ExecutionResult: domain.JobExecutionResult{Kind: "file.write", Version: 2, Checksum: "sha256:" + strings.Repeat("4", 64), SizeBytes: 14, Summary: "atomic write"}, } if err := source.MemoryStore.Jobs().Create(job); err != nil { t.Fatalf("create source job: %v", err) @@ -313,20 +313,13 @@ func TestFileStorePersistsProductionOperationsStateAcrossRestart(t *testing.T) { t.Fatalf("create file store: %v", err) } stamp := time.Date(2026, 7, 18, 14, 0, 0, 0, time.UTC) - alert := domain.AlertRecord{ - ID: "alert-capacity", SourceKind: "run-endpoint", SourceID: "run-1", RuleKey: "capacity.pressure", - Severity: domain.AlertSeverityWarning, State: domain.AlertStateAcknowledged, Title: "Capacity pressure", - Message: "endpoint capacity is temporarily under pressure", OccurrenceCount: 2, Retryable: true, - RetryAfterSeconds: 30, LastAuditEventID: "audit-1", LastSeenAt: stamp, AcknowledgedBy: "operator-1", - AcknowledgedAt: stamp, CreatedAt: stamp.Add(-time.Minute), UpdatedAt: stamp, - } installation := domain.PluginLifecycleInstallation{ ID: "plugin-lifecycle-1", PluginID: "game.scum", ServerInstanceID: "server-1", CurrentVersion: "1.0.0", TargetVersion: "2.0.0", PreviousVersion: "0.9.0", DesiredState: domain.PluginLifecycleStateEnabled, CurrentState: domain.PluginLifecycleStateUpgrading, LastOperation: domain.PluginLifecycleOperationUpgrade, Compatibility: "compatible", - DependencyState: domain.DependencyStatePresent, JobID: "job-upgrade", AlertID: alert.ID, - AuditEventID: "audit-2", IdempotencyKey: "upgrade-once", CreatedAt: stamp.Add(-time.Hour), UpdatedAt: stamp, + DependencyState: domain.DependencyStatePresent, JobID: "job-upgrade", + IdempotencyKey: "upgrade-once", CreatedAt: stamp.Add(-time.Hour), UpdatedAt: stamp, } diff := domain.AIConfigDiffPreview{ ID: "ai-config-diff-1", RequestID: "ai-request-1", CreatedBy: "operator-1", @@ -335,9 +328,6 @@ func TestFileStorePersistsProductionOperationsStateAcrossRestart(t *testing.T) { ProposedConfig: "MaxPlayers=80\n", DiffSummary: "review required before config write dispatch", State: domain.AIConfigDiffStatePending, ExpiresAt: stamp.Add(30 * time.Minute), CreatedAt: stamp, UpdatedAt: stamp, } - if err := store.Alerts().Create(alert); err != nil { - t.Fatalf("create alert: %v", err) - } if err := store.PluginLifecycles().Create(installation); err != nil { t.Fatalf("create plugin lifecycle: %v", err) } @@ -349,10 +339,6 @@ func TestFileStorePersistsProductionOperationsStateAcrossRestart(t *testing.T) { if err != nil { t.Fatalf("restart file store: %v", err) } - gotAlert, alertErr := restarted.Alerts().Get(alert.ID) - if alertErr != nil || gotAlert.State != alert.State || gotAlert.OccurrenceCount != 2 || gotAlert.LastAuditEventID != alert.LastAuditEventID { - t.Fatalf("unexpected durable alert: alert=%+v err=%v", gotAlert, alertErr) - } gotInstallation, lifecycleErr := restarted.PluginLifecycles().Get(installation.ID) if lifecycleErr != nil || gotInstallation.CurrentState != installation.CurrentState || gotInstallation.TargetVersion != installation.TargetVersion || gotInstallation.JobID != installation.JobID { t.Fatalf("unexpected durable plugin lifecycle: installation=%+v err=%v", gotInstallation, lifecycleErr) diff --git a/platform/service/ai_invocation.go b/platform/service/ai_invocation.go index 1738189..7a78ea3 100644 --- a/platform/service/ai_invocation.go +++ b/platform/service/ai_invocation.go @@ -91,16 +91,6 @@ func (svc *CoreService) InvokeAIForSession(sessionID string, request domain.AIIn } result, err := svc.aiProviderClient.Invoke(provider, request) if err != nil { - auditID, auditErr := svc.recordAuditEventWithID(user.ID, "ai.provider.invoke.failed", "ai-provider", provider.ID, domain.AuditResultFailed, "AI provider invocation failed safely") - if auditErr != nil { - return domain.AIInvocationResponse{}, auditErr - } - svc.productionMu.Lock() - _, alertErr := svc.upsertAlert(domain.AlertRecord{SourceKind: "ai-provider", SourceID: provider.ID, RuleKey: "ai.provider.failed", Severity: domain.AlertSeverityWarning, Title: "AI provider invocation failed", Message: "AI provider invocation failed safely", Retryable: false, LastAuditEventID: auditID}) - svc.productionMu.Unlock() - if alertErr != nil { - return domain.AIInvocationResponse{}, alertErr - } return domain.CopyAIInvocationResponse(domain.AIInvocationResponse{ RequestID: request.RequestID, Purpose: request.Purpose, diff --git a/platform/service/artifact_download.go b/platform/service/artifact_download.go index 42348ff..72d6471 100644 --- a/platform/service/artifact_download.go +++ b/platform/service/artifact_download.go @@ -58,9 +58,6 @@ func (svc *CoreService) OpenArtifactDownloadForSession(sessionID string, request if err := validator.ValidateArtifactDownloadReference(reference); err != nil { return domain.ArtifactDownloadReference{}, err } - if err := svc.auditArtifactDownload(sessionID, artifact); err != nil { - return domain.ArtifactDownloadReference{}, err - } return domain.CopyArtifactDownloadReference(reference), nil } diff --git a/platform/service/client_manager_lifecycle.go b/platform/service/client_manager_lifecycle.go index 09b47c4..d4a0813 100644 --- a/platform/service/client_manager_lifecycle.go +++ b/platform/service/client_manager_lifecycle.go @@ -28,13 +28,12 @@ func (svc *CoreService) DeployClientManagerForSession(sessionID string, request if err := validator.ValidateClientManagerDeployRequest(request); err != nil { return domain.ClientManagerLifecycleView{}, err } - user, instance, plugin, profile, endpoint, err := svc.authorizeClientManagerLifecycle(sessionID, request.ServerInstanceID, request.ProfileKey, domain.JobCapabilityClientManagerDeploy, "client-manager.deploy.denied") + _, instance, plugin, profile, endpoint, err := svc.authorizeClientManagerLifecycle(sessionID, request.ServerInstanceID, request.ProfileKey, domain.JobCapabilityClientManagerDeploy, "client-manager.deploy.denied") if err != nil { return domain.ClientManagerLifecycleView{}, err } distribution, err := svc.authorizedClientManagerDistribution(instance, profile, request.DistributionID) if err != nil { - _ = svc.recordAuditEvent(user.ID, "client-manager.deploy.denied", "server-instance", instance.ID, domain.AuditResultDenied, "client-manager deployment denied: distribution ownership, target, revision, or key fence is invalid") return domain.ClientManagerLifecycleView{}, err } installation, err := svc.ensureClientManagerInstallationFromDistribution(instance, plugin, distribution) @@ -93,9 +92,6 @@ func (svc *CoreService) DeployClientManagerForSession(sessionID string, request if job.ID != installation.CurrentJobID { return domain.ClientManagerLifecycleView{}, validationError("client-manager deploy job fence is invalid") } - if err := svc.recordAuditEvent(user.ID, "client-manager.deploy", "client-manager-installation", installation.ID, domain.AuditResultQueued, "queued typed client-manager deployment for current artifact and key generation"); err != nil { - return domain.ClientManagerLifecycleView{}, err - } return svc.clientManagerLifecycleView(installation) } @@ -107,12 +103,11 @@ func (svc *CoreService) ControlClientManagerForSession(sessionID string, request if request.Operation == domain.ClientManagerOperationRollback { capability = domain.JobCapabilityClientManagerRollback } - user, instance, _, profile, endpoint, err := svc.authorizeClientManagerLifecycle(sessionID, request.ServerInstanceID, request.ProfileKey, capability, "client-manager."+string(request.Operation)+".denied") + _, instance, _, profile, endpoint, err := svc.authorizeClientManagerLifecycle(sessionID, request.ServerInstanceID, request.ProfileKey, capability, "client-manager."+string(request.Operation)+".denied") if err != nil { return domain.ClientManagerLifecycleView{}, err } if !containsString(profile.Lifecycle.Actions, string(request.Operation)) { - _ = svc.recordAuditEvent(user.ID, "client-manager."+string(request.Operation)+".denied", "server-instance", instance.ID, domain.AuditResultDenied, "client-manager control denied: action is not declared") return domain.ClientManagerLifecycleView{}, ErrForbidden } installation, err := svc.getClientManagerInstallation(instance.ID, profile.Key) @@ -175,7 +170,6 @@ func (svc *CoreService) ControlClientManagerForSession(sessionID string, request if job.ID != installation.CurrentJobID { return domain.ClientManagerLifecycleView{}, validationError("client-manager control job fence is invalid") } - _ = svc.recordAuditEvent(user.ID, "client-manager."+string(request.Operation), "client-manager-installation", installation.ID, domain.AuditResultQueued, "queued typed client-manager "+string(request.Operation)+" operation") return svc.clientManagerLifecycleView(installation) } @@ -183,7 +177,7 @@ func (svc *CoreService) UpdateClientManagerForSession(sessionID string, request if err := validator.ValidateClientManagerUpdateRequest(request); err != nil { return domain.ClientManagerLifecycleView{}, err } - user, instance, _, profile, endpoint, err := svc.authorizeClientManagerLifecycle(sessionID, request.ServerInstanceID, request.ProfileKey, domain.JobCapabilityClientManagerUpdate, "client-manager.update.denied") + _, instance, _, profile, endpoint, err := svc.authorizeClientManagerLifecycle(sessionID, request.ServerInstanceID, request.ProfileKey, domain.JobCapabilityClientManagerUpdate, "client-manager.update.denied") if err != nil { return domain.ClientManagerLifecycleView{}, err } @@ -199,11 +193,9 @@ func (svc *CoreService) UpdateClientManagerForSession(sessionID string, request } distribution, err := svc.authorizedClientManagerDistribution(instance, profile, request.DistributionID) if err != nil { - _ = svc.recordAuditEvent(user.ID, "client-manager.update.denied", "client-manager-installation", installation.ID, domain.AuditResultDenied, "client-manager update denied: artifact scope or generation is invalid") return domain.ClientManagerLifecycleView{}, err } if distribution.ArtifactID == installation.ActiveArtifactID || !clientManagerVersionAllowed(profile, installation.ActiveVersion, clientManagerDistributionVersion(distribution, profile)) { - _ = svc.recordAuditEvent(user.ID, "client-manager.update.denied", "client-manager-installation", installation.ID, domain.AuditResultDenied, "client-manager update denied: artifact is not compatible with the active deployment") return domain.ClientManagerLifecycleView{}, validationError("client-manager update artifact is incompatible") } if existing, err := svc.store.Jobs().GetByIdempotency(endpoint.ID, request.IdempotencyKey); err == nil { @@ -246,7 +238,6 @@ func (svc *CoreService) UpdateClientManagerForSession(sessionID string, request if job.ID != installation.CurrentJobID { return domain.ClientManagerLifecycleView{}, validationError("client-manager update job fence is invalid") } - _ = svc.recordAuditEvent(user.ID, "client-manager.update", "client-manager-installation", installation.ID, domain.AuditResultQueued, "queued approved staged client-manager update with rollback retention") return svc.clientManagerLifecycleView(installation) } @@ -254,7 +245,7 @@ func (svc *CoreService) UninstallClientManagerForSession(sessionID string, reque if err := validator.ValidateClientManagerUninstallRequest(request); err != nil { return domain.ClientManagerLifecycleView{}, err } - user, instance, _, profile, endpoint, err := svc.authorizeClientManagerLifecycle(sessionID, request.ServerInstanceID, request.ProfileKey, domain.JobCapabilityClientManagerUninstall, "client-manager.uninstall.denied") + _, instance, _, profile, endpoint, err := svc.authorizeClientManagerLifecycle(sessionID, request.ServerInstanceID, request.ProfileKey, domain.JobCapabilityClientManagerUninstall, "client-manager.uninstall.denied") if err != nil { return domain.ClientManagerLifecycleView{}, err } @@ -304,13 +295,11 @@ func (svc *CoreService) UninstallClientManagerForSession(sessionID string, reque if job.ID != installation.CurrentJobID { return domain.ClientManagerLifecycleView{}, validationError("client-manager uninstall job fence is invalid") } - _ = svc.recordAuditEvent(user.ID, "client-manager.uninstall", "client-manager-installation", installation.ID, domain.AuditResultQueued, "queued safe controlled-workspace uninstall") return svc.clientManagerLifecycleView(installation) } func (svc *CoreService) RevokeClientManagerSessionForSession(sessionID string, request domain.ClientManagerRevokeSessionRequest) (domain.ClientManagerLifecycleView, error) { - user, err := svc.GetCurrentUser(sessionID) - if err != nil { + if _, err := svc.GetCurrentUser(sessionID); err != nil { return domain.ClientManagerLifecycleView{}, err } instance, err := svc.GetServerInstanceForSession(sessionID, request.ServerInstanceID) @@ -334,7 +323,6 @@ func (svc *CoreService) RevokeClientManagerSessionForSession(sessionID string, r return domain.ClientManagerLifecycleView{}, err } } - _ = svc.recordAuditEvent(user.ID, "client-manager.revoke", "client-manager-installation", installation.ID, domain.AuditResultSuccess, "revoked Client Manager component session without exposing token material") return svc.clientManagerLifecycleView(installation) } @@ -360,7 +348,7 @@ func (svc *CoreService) RetryClientManagerLifecycleForSession(sessionID string, case domain.ClientManagerOperationUninstall: capability = domain.JobCapabilityClientManagerUninstall } - user, _, _, _, endpoint, err := svc.authorizeClientManagerLifecycle(sessionID, request.ServerInstanceID, request.ProfileKey, capability, "client-manager.retry.denied") + _, _, _, _, endpoint, err := svc.authorizeClientManagerLifecycle(sessionID, request.ServerInstanceID, request.ProfileKey, capability, "client-manager.retry.denied") if err != nil { return domain.ClientManagerLifecycleView{}, err } @@ -402,7 +390,6 @@ func (svc *CoreService) RetryClientManagerLifecycleForSession(sessionID string, if job.ID != installation.CurrentJobID { return domain.ClientManagerLifecycleView{}, validationError("client-manager retry job fence is invalid") } - _ = svc.recordAuditEvent(user.ID, "client-manager.retry", "client-manager-installation", installation.ID, domain.AuditResultQueued, "queued bounded retry using the existing deployment generation fence") return svc.clientManagerLifecycleView(installation) } @@ -472,7 +459,6 @@ func (svc *CoreService) authorizeClientManagerLifecycle(sessionID, serverInstanc } profile, err := findRuntimeClientManagerProfile(plugin, profileKey) if err != nil || profile.Deployment.Mode != "run-supervised" || !containsString(profile.Deployment.RequiredRunCapabilities, capability) { - _ = svc.recordAuditEvent(user.ID, deniedAction, "server-instance", instance.ID, domain.AuditResultDenied, "client-manager lifecycle denied: profile or capability is not declared") return domain.User{}, domain.ServerInstance{}, domain.GamePlugin{}, domain.RuntimeClientManagerProfile{}, domain.RunEndpoint{}, ErrForbidden } endpoint, err := svc.store.RunEndpoints().Get(instance.RunEndpointID) @@ -480,7 +466,6 @@ func (svc *CoreService) authorizeClientManagerLifecycle(sessionID, serverInstanc return domain.User{}, domain.ServerInstance{}, domain.GamePlugin{}, domain.RuntimeClientManagerProfile{}, domain.RunEndpoint{}, err } if err := svc.validateRunnableEndpoint(endpoint, capability); err != nil { - _ = svc.recordAuditEvent(user.ID, deniedAction, "server-instance", instance.ID, domain.AuditResultDenied, "client-manager lifecycle denied: assigned Run endpoint is offline or unsupported") return domain.User{}, domain.ServerInstance{}, domain.GamePlugin{}, domain.RuntimeClientManagerProfile{}, domain.RunEndpoint{}, err } return user, instance, plugin, profile, endpoint, nil @@ -974,7 +959,7 @@ func (svc *CoreService) projectClientManagerLifecycleResult(job domain.Job, stam installation.LastHeartbeatSequence = 0 case domain.ClientManagerOperationUninstall: installation.Status = domain.ClientManagerLifecycleUninstalled - installation.Phase = "controlled workspace removed" + installation.Phase = "managed workspace removed" installation.Health = domain.ClientManagerHealthOffline installation.HealthReason = "uninstalled" installation.ActiveArtifactID = "" @@ -994,11 +979,7 @@ func (svc *CoreService) projectClientManagerLifecycleResult(job domain.Job, stam if err := svc.store.ClientManagerInstallations().Update(installation); err != nil { return err } - result := domain.AuditResultSuccess - if !success { - result = domain.AuditResultFailed - } - return svc.recordAuditEvent("run:"+installation.RunEndpointID, "client-manager."+string(installation.LastOperation), "client-manager-installation", installation.ID, result, "client-manager lifecycle job reached a bounded terminal result") + return nil } return repo.ErrNotFound } @@ -1134,7 +1115,6 @@ func (svc *CoreService) RegisterClientManager(request domain.ClientManagerRegist } stamp := svc.now() if request.Timestamp.Before(stamp.Add(-clientManagerRegistrationWindow)) || request.Timestamp.After(stamp.Add(clientManagerRegistrationWindow)) { - _ = svc.recordAuditEvent("client-manager:"+request.InstallationID, "client-manager.register.denied", "client-manager-installation", request.InstallationID, domain.AuditResultDenied, "client-manager registration denied: timestamp expired") return domain.ClientManagerRegisterResult{}, ErrUnauthorized } installation, err := svc.store.ClientManagerInstallations().Get(request.InstallationID) @@ -1142,7 +1122,6 @@ func (svc *CoreService) RegisterClientManager(request domain.ClientManagerRegist return domain.ClientManagerRegisterResult{}, ErrUnauthorized } if installation.ServerInstanceID != request.ServerInstanceID || installation.ProfileKey != request.ProfileKey || installation.ActiveArtifactID != request.ArtifactID || installation.ActiveVersion != request.Version || installation.ActiveRevision != request.SourceRevision || installation.TargetOS != request.TargetOS || installation.TargetArch != request.TargetArch || installation.KeyGeneration != request.KeyGeneration || installation.DeploymentGeneration != request.DeploymentGeneration || installation.RequiresRedeploy || installation.Status == domain.ClientManagerLifecycleUninstalled { - _ = svc.recordAuditEvent("client-manager:"+request.InstallationID, "client-manager.register.denied", "client-manager-installation", request.InstallationID, domain.AuditResultDenied, "client-manager registration denied: identity or deployment fence is stale") return domain.ClientManagerRegisterResult{}, ErrUnauthorized } instance, err := svc.store.ServerInstances().Get(installation.ServerInstanceID) @@ -1155,7 +1134,6 @@ func (svc *CoreService) RegisterClientManager(request domain.ClientManagerRegist } profile, err := findRuntimeClientManagerProfile(plugin, installation.ProfileKey) if err != nil || !clientManagerCapabilitiesMatch(profile.Health.RequiredCapabilities, request.Capabilities) { - _ = svc.recordAuditEvent("client-manager:"+request.InstallationID, "client-manager.register.denied", "client-manager-installation", request.InstallationID, domain.AuditResultDenied, "client-manager registration denied: capabilities do not match declaration") return domain.ClientManagerRegisterResult{}, ErrUnauthorized } key, err := svc.activeComponentKey(installation.ServerInstanceID, domain.DistributionComponentClientManager, installation.ProfileKey) @@ -1168,12 +1146,10 @@ func (svc *CoreService) RegisterClientManager(request domain.ClientManagerRegist } expected := clientManagerRegistrationSignature(plainKey, request) if subtle.ConstantTimeCompare([]byte(expected), []byte(request.Signature)) != 1 { - _ = svc.recordAuditEvent("client-manager:"+request.InstallationID, "client-manager.register.denied", "client-manager-installation", request.InstallationID, domain.AuditResultDenied, "client-manager registration denied: signature mismatch") return domain.ClientManagerRegisterResult{}, ErrUnauthorized } nonceID := clientManagerNonceID(request.InstallationID, request.Nonce) if _, err := svc.store.ClientManagerNonces().Get(nonceID); err == nil { - _ = svc.recordAuditEvent("client-manager:"+request.InstallationID, "client-manager.register.denied", "client-manager-installation", request.InstallationID, domain.AuditResultDenied, "client-manager registration denied: nonce replay") return domain.ClientManagerRegisterResult{}, ErrUnauthorized } else if !errors.Is(err, repo.ErrNotFound) { return domain.ClientManagerRegisterResult{}, err @@ -1209,7 +1185,6 @@ func (svc *CoreService) RegisterClientManager(request domain.ClientManagerRegist if err := svc.store.ClientManagerInstallations().Update(installation); err != nil { return domain.ClientManagerRegisterResult{}, err } - _ = svc.recordAuditEvent("client-manager:"+installation.ID, "client-manager.register", "client-manager-installation", installation.ID, domain.AuditResultSuccess, "Client Manager registered with an isolated expiring component session") return domain.ClientManagerRegisterResult{Accepted: true, InstallationID: installation.ID, SessionToken: token, ExpiresAt: session.ExpiresAt, HeartbeatEvery: profile.Health.IntervalSeconds, ServerTime: stamp}, nil } diff --git a/platform/service/client_manager_lifecycle_test.go b/platform/service/client_manager_lifecycle_test.go index 92ac0b1..b4b553a 100644 --- a/platform/service/client_manager_lifecycle_test.go +++ b/platform/service/client_manager_lifecycle_test.go @@ -265,7 +265,7 @@ func claimClientManagerJob(t *testing.T, svc *CoreService, sessionToken, capabil func completeClientManagerJob(t *testing.T, svc *CoreService, sessionToken string, claim domain.RunJobClaimResult, state domain.JobState, kind, processState string) { t.Helper() - _, err := svc.CompleteRunJob(domain.RunJobResult{RunEndpointID: "run-local", SessionToken: sessionToken, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt, State: state, Progress: domain.RunJobProgressReport{Percent: 100, Message: "client-manager lifecycle terminal"}, Message: "client-manager lifecycle terminal", ExecutionResult: domain.JobExecutionResult{Kind: kind, ProcessState: processState, AuditSummary: "bounded lifecycle result"}}) + _, err := svc.CompleteRunJob(domain.RunJobResult{RunEndpointID: "run-local", SessionToken: sessionToken, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt, State: state, Progress: domain.RunJobProgressReport{Percent: 100, Message: "client-manager lifecycle terminal"}, Message: "client-manager lifecycle terminal", ExecutionResult: domain.JobExecutionResult{Kind: kind, ProcessState: processState, Summary: "bounded lifecycle result"}}) if err != nil { t.Fatalf("complete lifecycle job: %v", err) } diff --git a/platform/service/control_test.go b/platform/service/control_test.go index 27e4145..9739763 100644 --- a/platform/service/control_test.go +++ b/platform/service/control_test.go @@ -441,7 +441,7 @@ func TestCoreServiceRunLifecycleReportProjectsGeneratedRunFacts(t *testing.T) { } registered := registerGeneratedRunForStatusTest(t, svc, instance, plugin.ID) - reported, err := svc.ReportRunLifecycle(domain.RunLifecycleReport{RunEndpointID: instance.RunEndpointID, SessionToken: registered.SessionToken, ServerInstanceID: instance.ID, Capability: domain.LifecycleCapabilityStart, State: domain.JobStateSucceeded, Progress: domain.RunJobProgressReport{Percent: 100, Message: "autonomous start complete"}, Message: "autonomous start complete", ExecutionResult: domain.JobExecutionResult{Kind: "process", ProcessState: "running", AuditSummary: "private supervised process identity"}}) + reported, err := svc.ReportRunLifecycle(domain.RunLifecycleReport{RunEndpointID: instance.RunEndpointID, SessionToken: registered.SessionToken, ServerInstanceID: instance.ID, Capability: domain.LifecycleCapabilityStart, State: domain.JobStateSucceeded, Progress: domain.RunJobProgressReport{Percent: 100, Message: "autonomous start complete"}, Message: "autonomous start complete", ExecutionResult: domain.JobExecutionResult{Kind: "process", ProcessState: "running", Summary: "private supervised process identity"}}) if err != nil || !reported.Accepted || reported.ProjectedState != domain.ServerInstanceStateRunning { t.Fatalf("expected accepted lifecycle report projected running, result=%+v err=%v", reported, err) } diff --git a/platform/service/dependency_updates.go b/platform/service/dependency_updates.go index 9388325..f008934 100644 --- a/platform/service/dependency_updates.go +++ b/platform/service/dependency_updates.go @@ -461,7 +461,7 @@ func (svc *CoreService) projectDependencyAndRunUpdateResult(job domain.Job, stam if err := svc.store.DependencyStatuses().Update(status); err != nil { return err } - return svc.recordAuditEvent("run", "dependency.result", "server-instance", job.ServerInstanceID, auditResultForJob(job), status.Message) + return nil } if job.Capability != domain.JobCapabilityRunSelfUpdate { return nil @@ -494,7 +494,7 @@ func (svc *CoreService) projectDependencyAndRunUpdateResult(job domain.Job, stam if err := svc.store.RunUpdateJobs().Update(update); err != nil { return err } - return svc.recordAuditEvent("run", "run.update.result", "server-instance", job.ServerInstanceID, auditResultForJob(job), update.Message) + return nil } func (svc *CoreService) projectDependencyAndRunUpdateProgress(job domain.Job, stamp time.Time) error { @@ -589,26 +589,9 @@ func (svc *CoreService) ReportRunUpdateHealth(report domain.RunUpdateHealthRepor if err := svc.store.RunUpdateJobs().Update(update); err != nil { return domain.RunUpdateHealthResult{}, err } - auditResult := domain.AuditResultSuccess - if report.Outcome == "rolled-back" { - auditResult = domain.AuditResultFailed - } - if err := svc.recordAuditEvent("run", "run.update.health", "server-instance", update.ServerInstanceID, auditResult, update.Message); err != nil { - return domain.RunUpdateHealthResult{}, err - } return domain.RunUpdateHealthResult{Accepted: true, JobID: job.ID, Phase: update.Phase, ServerTime: stamp}, nil } -func auditResultForJob(job domain.Job) domain.AuditResult { - if job.State == domain.JobStateSucceeded { - return domain.AuditResultSuccess - } - if job.State == domain.JobStateCancelled { - return domain.AuditResultDenied - } - return domain.AuditResultFailed -} - func sameRunUpdateTarget(existing, expected domain.RunUpdateJob) bool { return existing.ServerInstanceID == expected.ServerInstanceID && existing.RunEndpointID == expected.RunEndpointID && existing.ArtifactID == expected.ArtifactID && existing.Checksum == expected.Checksum && existing.TargetOS == expected.TargetOS && existing.TargetArch == expected.TargetArch && existing.TargetRelease == expected.TargetRelease && existing.JobID == expected.JobID && existing.IdempotencyKey == expected.IdempotencyKey } diff --git a/platform/service/dependency_updates_test.go b/platform/service/dependency_updates_test.go index 32daf15..c9d9ea9 100644 --- a/platform/service/dependency_updates_test.go +++ b/platform/service/dependency_updates_test.go @@ -35,18 +35,6 @@ func TestDependencyCatalogRequiresCurrentReviewedDigest(t *testing.T) { t.Fatalf("stale digest created a job: %+v", job) } } - audits, err := svc.ListAuditEvents(domain.AuditEventFilter{ResourceID: instance.ID}) - if err != nil { - t.Fatalf("list audits: %v", err) - } - foundDenied := false - for _, audit := range audits { - foundDenied = foundDenied || audit.Action == "dependency.install.denied" - } - if !foundDenied { - t.Fatalf("expected stale digest audit, got %+v", audits) - } - request.PlanDigest = catalog.Plans[0].Digest request.IdempotencyKey = "dependency-current-digest" job, err := svc.QueueDependencyJobForSession(session, request) @@ -136,7 +124,7 @@ func TestDependencyInputFencingCancellationAndTerminalProjection(t *testing.T) { } evidence, _ := json.Marshal(domain.DependencyExecutionEvidence{ProbeKey: input.Probe.Key, PlanDigest: input.PlanDigest, State: string(domain.DependencyStatePresent), Evidence: "OpenJDK 21"}) - if _, err := svc.CompleteRunJob(domain.RunJobResult{RunEndpointID: instance.RunEndpointID, SessionToken: runSession, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt, State: domain.JobStateSucceeded, Progress: domain.RunJobProgressReport{Percent: 100, Message: "dependency probe completed"}, ResultRef: "artifact://jobs/dependency-check/result", Message: "dependency probe completed", ExecutionResult: domain.JobExecutionResult{Kind: "dependency.check", Checksum: input.PlanDigest, AuditSummary: "dependency probe completed", Content: string(evidence)}}); err != nil { + if _, err := svc.CompleteRunJob(domain.RunJobResult{RunEndpointID: instance.RunEndpointID, SessionToken: runSession, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt, State: domain.JobStateSucceeded, Progress: domain.RunJobProgressReport{Percent: 100, Message: "dependency probe completed"}, ResultRef: "artifact://jobs/dependency-check/result", Message: "dependency probe completed", ExecutionResult: domain.JobExecutionResult{Kind: "dependency.check", Checksum: input.PlanDigest, Summary: "dependency probe completed", Content: string(evidence)}}); err != nil { t.Fatalf("complete dependency result: %v", err) } projected, err := svc.GetDependencyCatalogForSession(session, instance.ID) @@ -248,7 +236,7 @@ func TestRunUpdateTargetFencingChunksHealthAndRollbackProjection(t *testing.T) { } evidence, _ := json.Marshal(domain.RunUpdateExecutionEvidence{TargetRelease: update.TargetRelease, Phase: "staged"}) - if _, err := svc.CompleteRunJob(domain.RunJobResult{RunEndpointID: instance.RunEndpointID, SessionToken: runSession, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt, State: domain.JobStateSucceeded, Progress: domain.RunJobProgressReport{Percent: 100, Message: "Run update verified and staged"}, ResultRef: "artifact://jobs/run-update/staged", Message: "Run update verified and staged", ExecutionResult: domain.JobExecutionResult{Kind: "run.update.staged", Checksum: update.Checksum, SizeBytes: int64(len(payload)), AuditSummary: "verified update staged", Content: string(evidence)}}); err != nil { + if _, err := svc.CompleteRunJob(domain.RunJobResult{RunEndpointID: instance.RunEndpointID, SessionToken: runSession, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt, State: domain.JobStateSucceeded, Progress: domain.RunJobProgressReport{Percent: 100, Message: "Run update verified and staged"}, ResultRef: "artifact://jobs/run-update/staged", Message: "Run update verified and staged", ExecutionResult: domain.JobExecutionResult{Kind: "run.update.staged", Checksum: update.Checksum, SizeBytes: int64(len(payload)), Summary: "verified update staged", Content: string(evidence)}}); err != nil { t.Fatalf("complete staged Run update: %v", err) } updates, err := svc.ListRunUpdateJobsForSession(session, instance.ID) diff --git a/platform/service/distributions.go b/platform/service/distributions.go index 25b47d5..da20b1f 100644 --- a/platform/service/distributions.go +++ b/platform/service/distributions.go @@ -38,7 +38,6 @@ func (svc *CoreService) GenerateRunDistributionForSession(sessionID string, requ return domain.RunDistribution{}, err } if err := validatePluginTarget(plugin, request.TargetOS); err != nil { - _ = svc.recordAuditEvent(user.ID, "run.generate.denied", "server-instance", instance.ID, domain.AuditResultDenied, "run generation denied: unsupported target") return domain.RunDistribution{}, err } if deploymentNeedsCompleteRuntimeBinding(plugin, instance.Deployment) { @@ -47,7 +46,6 @@ func (svc *CoreService) GenerateRunDistributionForSession(sessionID string, requ } } if ready, reason := svc.distributionBuilderReadiness(); !ready { - _ = svc.recordAuditEvent(user.ID, "run.generate.denied", "server-instance", instance.ID, domain.AuditResultDenied, reason) return domain.RunDistribution{}, validationError(reason) } @@ -114,9 +112,6 @@ func (svc *CoreService) GenerateRunDistributionForSession(sessionID string, requ return domain.RunDistribution{}, validationError("distribution build idempotency key conflicts with another job") } svc.enqueueDistributionBuild(job) - if err := svc.recordAuditEvent(user.ID, "run.generate", "server-instance", instance.ID, domain.AuditResultQueued, "queued run binary build job in the platform builder with redacted runtime key ref"); err != nil { - return domain.RunDistribution{}, err - } return domain.CopyRunDistribution(distribution), nil } @@ -144,26 +139,22 @@ func (svc *CoreService) GenerateClientManagerDistributionForSession(sessionID st return domain.ClientManagerDistribution{}, err } if err := validatePluginTarget(plugin, request.TargetOS); err != nil { - _ = svc.recordAuditEvent(user.ID, "client-manager.build.denied", "server-instance", instance.ID, domain.AuditResultDenied, "client-manager build denied: unsupported target") return domain.ClientManagerDistribution{}, err } profile, err := findRuntimeClientManagerProfile(plugin, request.ProfileKey) if err != nil { - _ = svc.recordAuditEvent(user.ID, "client-manager.build.denied", "server-instance", instance.ID, domain.AuditResultDenied, "client-manager build denied: profile is not declared") return domain.ClientManagerDistribution{}, err } if strings.TrimSpace(request.SourceRevision) == "" { request.SourceRevision = clientManagerProfileRevision(profile) } if !clientManagerProfileSupportsTarget(profile, request.TargetOS, request.TargetArch) || request.RepositoryURL != profile.RepositoryURL || !clientManagerProfileAllowsRevision(profile, request.SourceRevision) { - _ = svc.recordAuditEvent(user.ID, "client-manager.build.denied", "server-instance", instance.ID, domain.AuditResultDenied, "client-manager build denied: repository, revision, or target is not declared") return domain.ClientManagerDistribution{}, validationError("client-manager build must match the declared profile repository, revision, and target") } if err := svc.requireCompleteRuntimeBindings(user.ID, instance.ID, "client-manager.build.denied"); err != nil { return domain.ClientManagerDistribution{}, err } if ready, reason := svc.distributionBuilderReadiness(); !ready { - _ = svc.recordAuditEvent(user.ID, "client-manager.build.denied", "server-instance", instance.ID, domain.AuditResultDenied, reason) return domain.ClientManagerDistribution{}, validationError(reason) } @@ -271,9 +262,6 @@ func (svc *CoreService) GenerateClientManagerDistributionForSession(sessionID st return domain.ClientManagerDistribution{}, validationError("distribution build idempotency key conflicts with another job") } svc.enqueueDistributionBuild(job) - if err := svc.recordAuditEvent(user.ID, "client-manager.build", "server-instance", instance.ID, domain.AuditResultQueued, "queued client-manager source build in the platform builder with redacted runtime key ref"); err != nil { - return domain.ClientManagerDistribution{}, err - } return domain.CopyClientManagerDistribution(distribution), nil } @@ -376,9 +364,6 @@ func (svc *CoreService) ResetComponentKeyForSession(sessionID string, request do return domain.EncryptedComponentKey{}, err } } - if err := svc.recordAuditEvent(user.ID, "runtime-key.reset", "server-instance", instance.ID, domain.AuditResultSuccess, "reset "+string(request.ComponentKind)+" key; previous packages revoked"); err != nil { - return domain.EncryptedComponentKey{}, err - } return domain.CopyEncryptedComponentKey(newKey), nil } @@ -404,7 +389,6 @@ func (svc *CoreService) AuthenticateComponent(request domain.ComponentAuthentica } if key.Generation != request.Generation { result.Reason = "key generation is no longer current" - _ = svc.recordAuditEvent("runtime", "runtime-key.auth", "server-instance", request.ServerInstanceID, domain.AuditResultDenied, "component authentication denied: stale generation") return domain.CopyComponentAuthenticationResult(result), nil } plainKey, err := svc.decryptRuntimeKey(key.EncryptedKey) @@ -413,7 +397,6 @@ func (svc *CoreService) AuthenticateComponent(request domain.ComponentAuthentica } if subtle.ConstantTimeCompare([]byte(plainKey), []byte(request.Key)) != 1 { result.Reason = "key is not current" - _ = svc.recordAuditEvent("runtime", "runtime-key.auth", "server-instance", request.ServerInstanceID, domain.AuditResultDenied, "component authentication denied: key mismatch") return domain.CopyComponentAuthenticationResult(result), nil } result.Allowed = true @@ -523,14 +506,12 @@ func (svc *CoreService) PushRunUpdateForSession(sessionID string, request domain return domain.RunUpdateJob{}, err } if artifact.State != domain.ArtifactStateAvailable { - _ = svc.recordAuditEvent(user.ID, "run.update.denied", "server-instance", instance.ID, domain.AuditResultDenied, "run update denied: artifact is unavailable") return domain.RunUpdateJob{}, validationError("artifact must be available") } if request.Checksum == "" { request.Checksum = artifact.Checksum } if request.Checksum != artifact.Checksum { - _ = svc.recordAuditEvent(user.ID, "run.update.denied", "server-instance", instance.ID, domain.AuditResultDenied, "run update denied: checksum mismatch") return domain.RunUpdateJob{}, validationError("checksum must match artifact") } endpoint, err := svc.store.RunEndpoints().Get(instance.RunEndpointID) @@ -546,7 +527,6 @@ func (svc *CoreService) PushRunUpdateForSession(sessionID string, request domain } distribution, err := findRunDistributionForArtifact(distributions, artifact.ID) if err != nil || distribution.RunEndpointID != endpoint.ID || distribution.TargetOS != endpoint.Platform || distribution.TargetArch != endpoint.Architecture || distribution.Checksum != artifact.Checksum || artifact.OwnerKind != domain.ArtifactOwnerKindJob || artifact.OwnerID != distribution.BuildJobID { - _ = svc.recordAuditEvent(user.ID, "run.update.denied", "server-instance", instance.ID, domain.AuditResultDenied, "run update denied: artifact is not an approved target-matched Run distribution") return domain.RunUpdateJob{}, validationError("artifact must be an approved target-matched Run distribution") } job, err := svc.CreateJob(domain.Job{ @@ -560,7 +540,6 @@ func (svc *CoreService) PushRunUpdateForSession(sessionID string, request domain Progress: domain.JobProgress{Percent: 0, Message: "run self-update queued"}, }) if err != nil { - _ = svc.recordAuditEvent(user.ID, "run.update.denied", "server-instance", instance.ID, domain.AuditResultDenied, "run update denied: endpoint unsupported or offline") return domain.RunUpdateJob{}, err } stamp := svc.now() @@ -598,9 +577,6 @@ func (svc *CoreService) PushRunUpdateForSession(sessionID string, request domain } return domain.RunUpdateJob{}, err } - if err := svc.recordAuditEvent(user.ID, "run.update", "server-instance", instance.ID, domain.AuditResultQueued, "queued run self-update job with artifact checksum"); err != nil { - return domain.RunUpdateJob{}, err - } return domain.CopyRunUpdateJob(updateJob), nil } @@ -625,7 +601,6 @@ func (svc *CoreService) QueueDependencyJobForSession(sessionID string, request d return domain.Job{}, err } if !pluginDeclares(plugin, "server.dependencies.manage") { - _ = svc.recordAuditEvent(user.ID, "dependency.install.denied", "server-instance", instance.ID, domain.AuditResultDenied, "dependency operation denied: plugin permission is not declared") return domain.Job{}, forbiddenError("plugin does not declare required permission: server.dependencies.manage") } if err := svc.requireCompleteRuntimeBindings(user.ID, instance.ID, "dependency.install.denied"); err != nil { @@ -656,20 +631,17 @@ func (svc *CoreService) QueueDependencyJobForSession(sessionID string, request d } expectedDigest := dependencyPlanDigest(resolution, probe, plan) if request.Install && request.PlanDigest != expectedDigest { - _ = svc.recordAuditEvent(user.ID, "dependency.install.denied", "server-instance", instance.ID, domain.AuditResultDenied, "dependency install denied: reviewed plan digest is stale or missing") return domain.Job{}, validationError("planDigest must match the current reviewed install plan") } request.PlanDigest = expectedDigest capability := domain.JobCapabilityDependenciesCheck targetKey := "dependencies/" + request.ProbeKey message := "dependency check queued" - auditAction := "dependency.check" state := domain.DependencyStateUnknown if request.Install { capability = domain.JobCapabilityDependenciesInstall targetKey = "dependencies/install/" + request.InstallPlanKey message = "dependency install queued" - auditAction = "dependency.install" state = domain.DependencyStateInstalling } job, err := svc.CreateJob(domain.Job{ @@ -682,15 +654,11 @@ func (svc *CoreService) QueueDependencyJobForSession(sessionID string, request d Progress: domain.JobProgress{Percent: 0, Message: message}, }) if err != nil { - _ = svc.recordAuditEvent(user.ID, auditAction+".denied", "server-instance", instance.ID, domain.AuditResultDenied, "dependency operation denied: endpoint unsupported or offline") return domain.Job{}, err } if err := svc.upsertDependencyStatus(instance, request, job.ID, probe.Required, state, "queued through platform job"); err != nil { return domain.Job{}, err } - if err := svc.recordAuditEvent(user.ID, auditAction, "server-instance", instance.ID, domain.AuditResultQueued, message); err != nil { - return domain.Job{}, err - } return domain.CopyJob(job), nil } @@ -715,12 +683,10 @@ func (svc *CoreService) QueueLogBackfillForSession(sessionID string, request dom return domain.Job{}, err } if !pluginSupports(plugin, domain.JobCapabilityLogsBackfill) { - _ = svc.recordAuditEvent(user.ID, "logs.backfill.denied", "server-instance", instance.ID, domain.AuditResultDenied, "log backfill denied: plugin capability is not declared") return domain.Job{}, ErrForbidden } source, err := declaredFileLogSource(plugin, request.SourceKey) if err != nil { - _ = svc.recordAuditEvent(user.ID, "logs.backfill.denied", "server-instance", instance.ID, domain.AuditResultDenied, "log backfill denied: log source is not declared") return domain.Job{}, err } if err := svc.requireCompleteRuntimeBindings(user.ID, instance.ID, "logs.backfill.denied"); err != nil { @@ -738,10 +704,6 @@ func (svc *CoreService) QueueLogBackfillForSession(sessionID string, request dom ExecutionInput: domain.JobExecutionInput{LogSource: &source}, }) if err != nil { - _ = svc.recordAuditEvent(user.ID, "logs.backfill.denied", "server-instance", instance.ID, domain.AuditResultDenied, "log backfill denied: endpoint unsupported or offline") - return domain.Job{}, err - } - if err := svc.recordAuditEvent(user.ID, "logs.backfill", "server-instance", instance.ID, domain.AuditResultQueued, "queued historical log backfill without log bodies in job result"); err != nil { return domain.Job{}, err } return domain.CopyJob(job), nil @@ -763,11 +725,9 @@ func declaredFileLogSource(plugin domain.GamePlugin, sourceKey string) (domain.R func (svc *CoreService) validateDistributionPluginPermission(actorID string, plugin domain.GamePlugin, serverInstanceID string, permission string, deniedAction string) error { if plugin.Status != domain.GamePluginStatusInstalled { - _ = svc.recordAuditEvent(actorID, deniedAction, "server-instance", serverInstanceID, domain.AuditResultDenied, "distribution operation denied: plugin is not installed") return forbiddenError("plugin is not installed") } if !containsString(plugin.DeclaredPermissions, permission) { - _ = svc.recordAuditEvent(actorID, deniedAction, "server-instance", serverInstanceID, domain.AuditResultDenied, "distribution operation denied: plugin permission is not declared") return forbiddenError("plugin does not declare required permission: " + permission) } return nil @@ -1033,63 +993,6 @@ func (svc *CoreService) upsertDependencyStatus(instance domain.ServerInstance, r return svc.store.DependencyStatuses().Create(status) } -func (svc *CoreService) recordAuditEvent(actorID string, action string, resourceKind string, resourceID string, result domain.AuditResult, summary string) error { - _, err := svc.recordAuditEventWithID(actorID, action, resourceKind, resourceID, result, summary) - return err -} - -func (svc *CoreService) recordAuditEventWithID(actorID string, action string, resourceKind string, resourceID string, result domain.AuditResult, summary string) (string, error) { - svc.auditMu.Lock() - svc.auditSeq++ - seq := svc.auditSeq - svc.auditMu.Unlock() - - stamp := svc.now() - event := domain.AuditEvent{ - ID: fmt.Sprintf("audit-%s-%d-%d", strings.ReplaceAll(action, ".", "-"), stamp.UnixNano(), seq), - ActorID: actorID, - Action: action, - ResourceKind: resourceKind, - ResourceID: resourceID, - Result: result, - Summary: safeBridgeReason(summary), - CreatedAt: stamp, - } - if err := validator.ValidateAuditEvent(event); err != nil { - return "", err - } - if err := svc.store.AuditEvents().Create(event); err != nil { - return "", err - } - return event.ID, nil -} - -func (svc *CoreService) auditArtifactDownload(sessionID string, artifact domain.Artifact) error { - user, err := svc.GetCurrentUser(sessionID) - if err != nil { - return err - } - runDistributions, err := svc.store.RunDistributions().List(domain.RunDistributionFilter{}) - if err != nil { - return err - } - for _, distribution := range runDistributions { - if distribution.ArtifactID == artifact.ID { - return svc.recordAuditEvent(user.ID, "run.download", "server-instance", distribution.ServerInstanceID, domain.AuditResultSuccess, "downloaded run package artifact with redacted runtime key ref") - } - } - clientDistributions, err := svc.store.ClientManagerDistributions().List(domain.ClientManagerDistributionFilter{}) - if err != nil { - return err - } - for _, distribution := range clientDistributions { - if distribution.ArtifactID == artifact.ID { - return svc.recordAuditEvent(user.ID, "client-manager.download", "server-instance", distribution.ServerInstanceID, domain.AuditResultSuccess, "downloaded client-manager package artifact with redacted runtime key ref") - } - } - return svc.recordAuditEvent(user.ID, "artifact.download", string(artifact.OwnerKind), artifact.OwnerID, domain.AuditResultSuccess, "downloaded platform artifact") -} - func validatePluginTarget(plugin domain.GamePlugin, targetOS string) error { if len(plugin.SupportedOS) == 0 || containsString(plugin.SupportedOS, targetOS) { return nil @@ -1251,7 +1154,6 @@ func (svc *CoreService) requireCompleteRuntimeBindings(actorID string, serverIns if complete { return nil } - _ = svc.recordAuditEvent(actorID, deniedAction, "server-instance", serverInstanceID, domain.AuditResultDenied, "operation denied: "+reason) return validationError(reason) } diff --git a/platform/service/distributions_test.go b/platform/service/distributions_test.go index 9530634..cc75288 100644 --- a/platform/service/distributions_test.go +++ b/platform/service/distributions_test.go @@ -496,7 +496,7 @@ func TestCoreServiceResetRunKeyRevokesOldPackagesAndRequiresRegeneration(t *test } } -func TestCoreServiceBuildsClientManagerWithDistinctKeyAndAuditsSensitiveOperations(t *testing.T) { +func TestCoreServiceBuildsClientManagerWithDistinctKeyAndRedactsSensitiveOperations(t *testing.T) { svc, session, instance := newDistributionTestFixture(t) runDistribution, err := svc.GenerateRunDistributionForSession(session, domain.RunDistributionGenerateRequest{ ServerInstanceID: instance.ID, @@ -578,24 +578,6 @@ func TestCoreServiceBuildsClientManagerWithDistinctKeyAndAuditsSensitiveOperatio t.Fatalf("expected old client-manager key to be denied after reset, got %+v", auth) } - audits, err := svc.ListAuditEvents(domain.AuditEventFilter{ResourceID: instance.ID}) - if err != nil { - t.Fatalf("list audits: %v", err) - } - actions := map[string]bool{} - for _, audit := range audits { - actions[audit.Action] = true - for _, forbidden := range []string{runConfig.AuthKey, clientConfig.AuthKey, "password=", "unix://", "/Users/"} { - if strings.Contains(audit.Summary, forbidden) { - t.Fatalf("audit leaked forbidden fragment %q in %+v", forbidden, audit) - } - } - } - for _, action := range []string{"run.generate", "run.download", "client-manager.build", "client-manager.build.denied", "runtime-key.reset"} { - if !actions[action] { - t.Fatalf("expected audit action %q in %+v", action, audits) - } - } } func newDistributionTestFixture(t *testing.T) (*CoreService, string, domain.ServerInstance) { diff --git a/platform/service/game_client_bridge.go b/platform/service/game_client_bridge.go index 00281a1..cb1f5c3 100644 --- a/platform/service/game_client_bridge.go +++ b/platform/service/game_client_bridge.go @@ -1,7 +1,6 @@ package service import ( - "crypto/sha256" "encoding/json" "fmt" "reflect" @@ -252,30 +251,6 @@ func gameClientBridgeQueryTemplateKeys(declarations []domain.GameClientBridgeQue return values } -func validateProtectedGameClientBridgePayload(declaration *domain.GameClientBridgeProtectedRequestDeclaration, payload map[string]any) error { - if declaration == nil { - return nil - } - if len(payload) != 1 { - return validationError("protected bridge request must contain only its declared text field") - } - value, exists := payload[declaration.TextField] - if !exists { - return validationError("protected bridge request text field is required") - } - text, ok := value.(string) - if !ok || len([]byte(text)) == 0 || len([]byte(text)) > declaration.MaxTextBytes { - return validationError("protected bridge request text is invalid") - } - return nil -} - -func protectedGameClientBridgeAuditSummary(declaration *domain.GameClientBridgeProtectedRequestDeclaration, payload map[string]any) string { - text, _ := payload[declaration.TextField].(string) - digest := sha256.Sum256([]byte(text)) - return fmt.Sprintf("queued protected %s request transport=%s target=%s text=redacted sha256=%x", declaration.Kind, declaration.TransportKey, declaration.TargetKey, digest[:8]) -} - func (svc *CoreService) queueGameClientBridgeCommand(requesterID string, request domain.GameClientBridgeQueueRequest) (domain.GameClientBridgeCommand, error) { request.Payload = domain.CopyGameClientBridgePayload(request.Payload) if err := validator.ValidateGameClientBridgeQueueRequest(request); err != nil { @@ -299,13 +274,6 @@ func (svc *CoreService) queueGameClientBridgeCommand(requesterID string, request if request.ExpiresAt.After(stamp.Add(time.Duration(declaration.TimeoutSeconds) * time.Second)) { return domain.GameClientBridgeCommand{}, validationError("bridge command expiry exceeds declared timeout") } - if err := validateProtectedGameClientBridgePayload(declaration.ProtectedRequest, request.Payload); err != nil { - return domain.GameClientBridgeCommand{}, err - } - if declaration.ProtectedRequest != nil && declaration.TimeoutSeconds > protectedRequestMaxTimeoutSeconds { - return domain.GameClientBridgeCommand{}, validationError("protected bridge request timeout exceeds Run policy") - } - existing, err := svc.store.GameClientBridgeCommands().GetByIdempotency(request.ServerInstanceID, requesterID, request.CommandType, request.IdempotencyKey) if err == nil { return domain.CopyGameClientBridgeCommand(existing), nil @@ -343,32 +311,9 @@ func (svc *CoreService) queueGameClientBridgeCommand(requesterID string, request CreatedAt: stamp, UpdatedAt: stamp, } - if declaration.ProtectedRequest != nil { - command.Payload = redactedProtectedRequestPayload(declaration.ProtectedRequest) - if approvalState == domain.GameClientBridgeApprovalApproved { - command.RunJobID = jobIDFromParts("job-protected-request", command.ServerInstanceID, command.ID) - } - } - summary := "queued declared game client bridge command" - if declaration.ProtectedRequest != nil { - summary = protectedGameClientBridgeAuditSummary(declaration.ProtectedRequest, request.Payload) - } - auditID, err := svc.recordAuditEventWithID(requesterID, "game-client-bridge.command.queue", "game-client-bridge-command", command.ID, domain.AuditResultQueued, summary) - if err != nil { - return domain.GameClientBridgeCommand{}, err - } - command.AuditReferences = []string{auditID} if err := svc.store.GameClientBridgeCommands().Create(command); err != nil { return domain.GameClientBridgeCommand{}, err } - if declaration.ProtectedRequest != nil && command.RunJobID != "" { - if err := svc.dispatchProtectedRequest(command, declaration, request.Payload); err != nil { - if deleteErr := svc.store.GameClientBridgeCommands().Delete(command.ID); deleteErr != nil { - return domain.GameClientBridgeCommand{}, deleteErr - } - return domain.GameClientBridgeCommand{}, err - } - } return domain.CopyGameClientBridgeCommand(command), nil } @@ -413,11 +358,6 @@ func (svc *CoreService) claimGameClientBridgeCommands(component gameClientBridge 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} command.UpdatedAt = stamp - auditID, auditErr := svc.recordAuditEventWithID("component:"+component.Session.ProfileKey, "game-client-bridge.command.claim", "game-client-bridge-command", command.ID, domain.AuditResultSuccess, "companion claimed bridge command") - if auditErr != nil { - return nil, auditErr - } - command.AuditReferences = append(command.AuditReferences, auditID) if err := svc.store.GameClientBridgeCommands().Update(command); err != nil { return nil, err } @@ -440,11 +380,6 @@ func (svc *CoreService) ackGameClientBridgeCommand(component gameClientBridgeCom command.Claim.AcknowledgedAt = stamp command.Claim.LeaseExpiresAt = gameClientBridgeClaimLeaseExpiry(stamp, command.ExpiresAt) command.UpdatedAt = stamp - auditID, err := svc.recordAuditEventWithID("component:"+component.Session.ProfileKey, "game-client-bridge.command.ack", "game-client-bridge-command", command.ID, domain.AuditResultSuccess, "companion acknowledged bridge command") - if err != nil { - return domain.GameClientBridgeCommand{}, err - } - command.AuditReferences = append(command.AuditReferences, auditID) if err := svc.store.GameClientBridgeCommands().Update(command); err != nil { return domain.GameClientBridgeCommand{}, err } @@ -486,17 +421,9 @@ func (svc *CoreService) completeGameClientBridgeCommand(component gameClientBrid command.Result = domain.GameClientBridgeResult{Status: request.Status, Summary: request.Summary, Payload: domain.CopyGameClientBridgePayload(request.Payload), CompletedBy: component.Session.ID, CompletedAt: stamp} command.CompletedAt = stamp command.UpdatedAt = stamp - auditID, err := svc.recordAuditEventWithID("component:"+component.Session.ProfileKey, "game-client-bridge.command.result", "game-client-bridge-command", command.ID, domain.AuditResultSuccess, "companion recorded terminal bridge command result") - if err != nil { - return domain.GameClientBridgeCommand{}, err - } - command.AuditReferences = append(command.AuditReferences, auditID) if err := svc.store.GameClientBridgeCommands().Update(command); err != nil { return domain.GameClientBridgeCommand{}, err } - if command.RunJobID != "" { - svc.protectedRequests.Delete(command.RunJobID) - } return domain.CopyGameClientBridgeCommand(command), nil } @@ -539,17 +466,9 @@ func (svc *CoreService) CancelGameClientBridgeCommandForSession(sessionID string command.Result = domain.GameClientBridgeResult{Status: domain.GameClientBridgeResultCancelled, Summary: "cancelled by operator", CompletedAt: stamp} command.CompletedAt = stamp command.UpdatedAt = stamp - auditID, err := svc.recordAuditEventWithID(user.ID, "game-client-bridge.command.cancel", "game-client-bridge-command", command.ID, domain.AuditResultSuccess, "operator cancelled bridge command") - if err != nil { - return domain.GameClientBridgeCommand{}, err - } - command.AuditReferences = append(command.AuditReferences, auditID) if err := svc.store.GameClientBridgeCommands().Update(command); err != nil { return domain.GameClientBridgeCommand{}, err } - if command.RunJobID != "" { - svc.protectedRequests.Delete(command.RunJobID) - } return domain.CopyGameClientBridgeCommand(command), nil } @@ -682,11 +601,6 @@ func (svc *CoreService) sweepGameClientBridgeCommandsLocked(stamp time.Time) err command.State = domain.GameClientBridgeCommandPending command.Claim = domain.GameClientBridgeClaim{FencingToken: fencingToken} command.UpdatedAt = stamp - auditID, auditErr := svc.recordAuditEventWithID("platform", "game-client-bridge.command.lease-expire", "game-client-bridge-command", command.ID, domain.AuditResultSuccess, "expired bridge claim returned to pending") - if auditErr != nil { - return auditErr - } - command.AuditReferences = append(command.AuditReferences, auditID) if err := svc.store.GameClientBridgeCommands().Update(command); err != nil { return err } @@ -722,11 +636,6 @@ func (svc *CoreService) expireGameClientBridgeCommandLocked(command domain.GameC command.State = domain.GameClientBridgeCommandExpired command.CompletedAt = stamp command.UpdatedAt = stamp - auditID, err := svc.recordAuditEventWithID("platform", "game-client-bridge.command.expire", "game-client-bridge-command", command.ID, domain.AuditResultSuccess, "bridge command expired before completion") - if err != nil { - return err - } - command.AuditReferences = append(command.AuditReferences, auditID) return svc.store.GameClientBridgeCommands().Update(command) } diff --git a/platform/service/game_client_bridge_sessions.go b/platform/service/game_client_bridge_sessions.go index 10df5fd..01b1b9a 100644 --- a/platform/service/game_client_bridge_sessions.go +++ b/platform/service/game_client_bridge_sessions.go @@ -111,11 +111,6 @@ func (svc *CoreService) UploadGameClientBridgeSnapshot(request domain.GameClient CreatedAt: stamp, ExpiresAt: stamp.Add(time.Duration(request.Retention.KeepForSeconds) * time.Second), } - auditID, err := svc.recordAuditEventWithID("component:"+component.Session.ProfileKey, "game-client-bridge.snapshot.ingest", "game-client-bridge-snapshot", snapshot.ID, domain.AuditResultSuccess, "companion uploaded typed bridge snapshot") - if err != nil { - return domain.GameClientBridgeSnapshot{}, err - } - snapshot.AuditReferences = []string{auditID} if err := svc.store.GameClientBridgeSnapshots().Create(snapshot); err != nil { return domain.GameClientBridgeSnapshot{}, err } diff --git a/platform/service/game_client_bridge_test.go b/platform/service/game_client_bridge_test.go index 8937786..8a14607 100644 --- a/platform/service/game_client_bridge_test.go +++ b/platform/service/game_client_bridge_test.go @@ -1,8 +1,6 @@ package service import ( - "encoding/json" - "strings" "testing" "time" @@ -14,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: "announcement.send", ApprovalLevel: domain.GameClientBridgeApprovalLevelOperator, 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", 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}}} if err := store.GamePlugins().Create(plugin); err != nil { t.Fatalf("seed bridge plugin: %v", err) } @@ -23,7 +21,7 @@ func newGameClientBridgeService(t *testing.T) (*CoreService, *time.Time) { } func bridgeQueueRequest(now time.Time, key string) domain.GameClientBridgeQueueRequest { - return domain.GameClientBridgeQueueRequest{ServerInstanceID: "server-1", PluginID: "game.scum", ProfileKey: "scum-client", CommandType: "announcement.send", Payload: map[string]any{"message": "hello"}, IdempotencyKey: key, Priority: 10, ExpiresAt: now.Add(5 * time.Minute)} + return domain.GameClientBridgeQueueRequest{ServerInstanceID: "server-1", PluginID: "game.scum", ProfileKey: "scum-client", CommandType: "diagnostic.ping", Payload: map[string]any{"message": "hello"}, IdempotencyKey: key, Priority: 10, ExpiresAt: now.Add(5 * time.Minute)} } func bridgeComponent() gameClientBridgeComponentSession { @@ -45,8 +43,8 @@ func TestGameClientBridgeCommandLifecycleAndIdempotency(t *testing.T) { t.Fatalf("idempotency reuse: command=%#v err=%v", duplicate, err) } commands, _ := svc.store.GameClientBridgeCommands().List(domain.GameClientBridgeCommandFilter{}) - if len(commands) != 1 || len(command.AuditReferences) != 1 { - t.Fatalf("expected one durable audited command: %#v", commands) + if len(commands) != 1 { + t.Fatalf("expected one durable command: %#v", commands) } component := bridgeComponent() @@ -71,12 +69,8 @@ func TestGameClientBridgeCommandLifecycleAndIdempotency(t *testing.T) { if err != nil || completed.State != domain.GameClientBridgeCommandSucceeded || completed.Result.Status != domain.GameClientBridgeResultSucceeded || completed.CompletedAt.IsZero() { t.Fatalf("complete bridge command: %#v err=%v", completed, err) } - if len(completed.AuditReferences) < 3 { - t.Fatalf("expected queue, claim, and result audit references: %#v", completed.AuditReferences) - } - auditReferenceCount := len(completed.AuditReferences) replayed, err := svc.completeGameClientBridgeCommand(component, resultRequest) - if err != nil || replayed.ID != completed.ID || replayed.State != completed.State || !replayed.CompletedAt.Equal(completed.CompletedAt) || len(replayed.AuditReferences) != auditReferenceCount { + if err != nil || replayed.ID != completed.ID || replayed.State != completed.State || !replayed.CompletedAt.Equal(completed.CompletedAt) { t.Fatalf("exact terminal result retry was not idempotent: replayed=%#v err=%v", replayed, err) } if _, err := svc.completeGameClientBridgeCommand(component, domain.GameClientBridgeResultRequest{SessionToken: "session-token", CommandID: command.ID, FencingToken: 1, Status: domain.GameClientBridgeResultFailed, Summary: "conflict"}); err == nil { @@ -84,111 +78,6 @@ func TestGameClientBridgeCommandLifecycleAndIdempotency(t *testing.T) { } } -func TestProtectedGameClientBridgeRequestIsScopedAndRedacted(t *testing.T) { - svc, clock := newGameClientBridgeService(t) - plugin, err := svc.store.GamePlugins().Get("game.scum") - if err != nil { - t.Fatal(err) - } - plugin.RuntimeProfiles.TransportProfiles = []domain.RuntimeTransportProfile{{Key: "database", Kind: "sqlite", TargetKey: "database", Capabilities: []string{domain.JobCapabilityRemoteRunProtectedSQL}}} - plugin.GameClientBridge.Commands = append(plugin.GameClientBridge.Commands, domain.GameClientBridgeCommandDeclaration{Type: "database.request", ApprovalLevel: domain.GameClientBridgeApprovalLevelPlatformAdmin, TimeoutSeconds: 60, MaxPayloadBytes: 4096, ProtectedRequest: &domain.GameClientBridgeProtectedRequestDeclaration{Kind: "sql", TransportKey: "database", TargetKey: "database", TextField: "requestText", MaxTextBytes: 1024}}) - if err := svc.store.GamePlugins().Update(plugin); err != nil { - t.Fatal(err) - } - text := "UPDATE players SET rank = 2 WHERE id = 7" - request := domain.GameClientBridgeQueueRequest{ServerInstanceID: "server-1", PluginID: "game.scum", ProfileKey: "scum-client", CommandType: "database.request", Payload: map[string]any{"requestText": text}, IdempotencyKey: "protected-1", ExpiresAt: clock.Add(time.Minute)} - command, err := svc.queueGameClientBridgeCommand("user-1", request) - if err != nil { - t.Fatalf("queue protected request: %v", err) - } - if command.ApprovalState != domain.GameClientBridgeApprovalPending { - t.Fatalf("protected request bypassed approval: %#v", command) - } - if _, err := svc.queueGameClientBridgeCommand("user-1", domain.GameClientBridgeQueueRequest{ServerInstanceID: "server-1", PluginID: "game.scum", ProfileKey: "scum-client", CommandType: "database.request", Payload: map[string]any{"requestText": text, "unexpected": true}, IdempotencyKey: "protected-extra", ExpiresAt: clock.Add(time.Minute)}); err == nil { - t.Fatal("protected request accepted undeclared payload field") - } - events, err := svc.store.AuditEvents().List(domain.AuditEventFilter{ResourceID: command.ID}) - if err != nil || len(events) != 1 { - t.Fatalf("protected request audit: events=%#v err=%v", events, err) - } - if strings.Contains(events[0].Summary, text) || !strings.Contains(events[0].Summary, "text=redacted") { - t.Fatalf("audit leaked protected request: %#v", events[0]) - } -} - -func TestProtectedGameClientBridgeRequestDispatchesOneTimeRunInput(t *testing.T) { - svc, clock := newGameClientBridgeService(t) - plugin, err := svc.store.GamePlugins().Get("game.scum") - if err != nil { - t.Fatal(err) - } - plugin.RequiredRunCapabilities = []string{domain.JobCapabilityRemoteRunProtectedSQL} - plugin.RuntimeProfiles.TransportProfiles = []domain.RuntimeTransportProfile{{Key: "scum-database", Kind: "sqlite", TargetKey: "scum-database", Capabilities: []string{domain.JobCapabilityRemoteRunProtectedSQL}}} - plugin.GameClientBridge.Commands = append(plugin.GameClientBridge.Commands, domain.GameClientBridgeCommandDeclaration{Type: "database.request", ApprovalLevel: domain.GameClientBridgeApprovalLevelPlatformAdmin, TimeoutSeconds: 120, MaxPayloadBytes: 4096, ProtectedRequest: &domain.GameClientBridgeProtectedRequestDeclaration{Kind: "sql", TransportKey: "scum-database", TargetKey: "scum-database", TextField: "requestText", MaxTextBytes: 1024}}) - if err := svc.store.GamePlugins().Update(plugin); err != nil { - t.Fatal(err) - } - if err := svc.store.Users().Create(domain.User{ID: "platform-admin", Email: "admin@example.test", Roles: []string{"platform-admin"}}); err != nil { - t.Fatal(err) - } - if err := svc.store.ServerInstances().Create(domain.ServerInstance{ID: "server-1", PluginID: plugin.ID, RunEndpointID: "run-local", Name: "Protected Bridge", State: domain.ServerInstanceStateRunning}); err != nil { - t.Fatal(err) - } - hello := validRunControlHello() - hello.CapabilityReport.Capabilities = []string{domain.JobCapabilityRemoteRunProtectedSQL} - hello.CapabilityReport.Fingerprint = "protected-request-capabilities" - run, err := svc.RegisterRunHello(hello) - if err != nil { - t.Fatalf("register Run: %v", err) - } - - text := "SELECT player_id, position FROM players WHERE player_id = 7" - command, err := svc.queueGameClientBridgeCommand("platform-admin", domain.GameClientBridgeQueueRequest{ServerInstanceID: "server-1", PluginID: plugin.ID, ProfileKey: "scum-client", CommandType: "database.request", Payload: map[string]any{"requestText": text}, IdempotencyKey: "protected-run-1", ExpiresAt: clock.Add(time.Minute)}) - if err != nil { - t.Fatalf("queue protected request: %v", err) - } - if command.RunJobID == "" || command.Payload["requestText"] != "redacted" || command.ApprovalState != domain.GameClientBridgeApprovalApproved { - t.Fatalf("protected command was not redacted and dispatched: %#v", command) - } - commandJSON, _ := json.Marshal(command) - if strings.Contains(string(commandJSON), text) { - t.Fatalf("protected bridge command persisted request text: %s", commandJSON) - } - if claimed, err := svc.claimGameClientBridgeCommands(bridgeComponent(), 10); err != nil || len(claimed) != 0 { - t.Fatalf("protected request must not be exposed to the Companion: commands=%#v err=%v", claimed, err) - } - - claim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: "run-local", SessionToken: run.SessionToken, Capabilities: []string{domain.JobCapabilityRemoteRunProtectedSQL}, Capacity: domain.RunCapacity{MaxJobs: 1}}) - if err != nil || !claim.HasJob || claim.Job == nil || claim.Job.JobID != command.RunJobID || claim.Job.FencingToken == 0 { - t.Fatalf("claim protected Run job: claim=%#v err=%v", claim, err) - } - assignmentJSON, _ := json.Marshal(claim.Job) - if strings.Contains(string(assignmentJSON), text) { - t.Fatalf("Run assignment exposed protected request text: %s", assignmentJSON) - } - ack, err := svc.AckRunJob(domain.RunJobAck{RunEndpointID: "run-local", SessionToken: run.SessionToken, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt, Message: "accepted"}) - if err != nil || !ack.Accepted { - t.Fatalf("ack protected Run job: ack=%#v err=%v", ack, err) - } - if _, err := svc.GetProtectedRequestExecutionInput(domain.ProtectedRequestExecutionInputRequest{RunEndpointID: "run-local", SessionToken: run.SessionToken, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt, FencingToken: claim.Job.FencingToken + 1}); err == nil { - t.Fatal("expected fencing mismatch rejection") - } - input, err := svc.GetProtectedRequestExecutionInput(domain.ProtectedRequestExecutionInputRequest{RunEndpointID: "run-local", SessionToken: run.SessionToken, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt, FencingToken: claim.Job.FencingToken}) - if err != nil || input.RequestText != text || input.Kind != "sql" || input.TransportKey != "scum-database" || !input.Authorized { - t.Fatalf("read protected Run input: input=%#v err=%v", input, err) - } - if _, err := svc.GetProtectedRequestExecutionInput(domain.ProtectedRequestExecutionInputRequest{RunEndpointID: "run-local", SessionToken: run.SessionToken, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt, FencingToken: claim.Job.FencingToken}); err == nil { - t.Fatal("expected one-time protected input rejection") - } - if _, err := svc.CompleteRunJob(domain.RunJobResult{RunEndpointID: "run-local", SessionToken: run.SessionToken, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt, State: domain.JobStateFailed, Progress: domain.RunJobProgressReport{Percent: 100, Message: "unknown request"}, ErrorCode: "protected_request_unknown", ExecutionResult: domain.JobExecutionResult{Kind: "protected.sql.unknown", AuditSummary: "protected request outcome is unknown"}}); err != nil { - t.Fatalf("complete protected Run job: %v", err) - } - completed, err := svc.store.GameClientBridgeCommands().Get(command.ID) - if err != nil || completed.State != domain.GameClientBridgeCommandUnknown || completed.Result.Status != domain.GameClientBridgeResultUnknown || strings.Contains(completed.Result.Summary, text) { - t.Fatalf("project protected Run result: command=%#v err=%v", completed, err) - } -} - func TestGameClientBridgeIdempotencyScopeIsAppliedByService(t *testing.T) { svc, clock := newGameClientBridgeService(t) request := bridgeQueueRequest(*clock, "scope-key") @@ -266,7 +155,7 @@ func TestGameClientBridgePendingCommandExpiresBeforeFirstClaim(t *testing.T) { t.Fatalf("expired pending command was claimable: %#v err=%v", claimed, err) } expired, err := svc.store.GameClientBridgeCommands().Get(command.ID) - if err != nil || expired.State != domain.GameClientBridgeCommandExpired || expired.CompletedAt.IsZero() || expired.Claim.FencingToken != 0 || len(expired.AuditReferences) != 2 { + if err != nil || expired.State != domain.GameClientBridgeCommandExpired || expired.CompletedAt.IsZero() || expired.Claim.FencingToken != 0 { t.Fatalf("first claim did not persist pending command expiry: %#v err=%v", expired, err) } } @@ -293,7 +182,7 @@ func TestGameClientBridgeExpiredLeaseRejectsMutationsBeforeReclaim(t *testing.T) } for _, command := range claimed { protected, getErr := svc.store.GameClientBridgeCommands().Get(command.ID) - if getErr != nil || protected.State != domain.GameClientBridgeCommandClaimed || !protected.Claim.AcknowledgedAt.IsZero() || protected.Result.Status != "" || !protected.CompletedAt.IsZero() || protected.Claim.FencingToken != command.Claim.FencingToken || len(protected.AuditReferences) != 2 { + if getErr != nil || protected.State != domain.GameClientBridgeCommandClaimed || !protected.Claim.AcknowledgedAt.IsZero() || protected.Result.Status != "" || !protected.CompletedAt.IsZero() || protected.Claim.FencingToken != command.Claim.FencingToken { t.Fatalf("expired lease mutation changed protected command: %#v err=%v", protected, getErr) } } @@ -341,8 +230,8 @@ func TestGameClientBridgeClaimMutationsExpireAtCommandDeadline(t *testing.T) { } for _, command := range commands { expired, err := svc.store.GameClientBridgeCommands().Get(command.ID) - if err != nil || expired.State != domain.GameClientBridgeCommandExpired || expired.CompletedAt.IsZero() || len(expired.AuditReferences) < 3 { - t.Fatalf("deadline mutation did not persist audited expiry: %#v err=%v", expired, err) + if err != nil || expired.State != domain.GameClientBridgeCommandExpired || expired.CompletedAt.IsZero() { + t.Fatalf("deadline mutation did not persist expiry: %#v err=%v", expired, err) } } } @@ -360,11 +249,11 @@ func TestGameClientBridgeFailedResultIsPersisted(t *testing.T) { } request := domain.GameClientBridgeResultRequest{SessionToken: "session-token", CommandID: command.ID, FencingToken: claimed[0].Claim.FencingToken, Status: domain.GameClientBridgeResultFailed, Summary: "game window unavailable", Payload: map[string]any{"retryable": true}} failed, err := svc.completeGameClientBridgeCommand(component, request) - if err != nil || failed.State != domain.GameClientBridgeCommandFailed || failed.Result.Status != domain.GameClientBridgeResultFailed || failed.Result.Summary != request.Summary || failed.Result.CompletedBy != component.Session.ID || failed.CompletedAt.IsZero() || len(failed.AuditReferences) != 3 { + if err != nil || failed.State != domain.GameClientBridgeCommandFailed || failed.Result.Status != domain.GameClientBridgeResultFailed || failed.Result.Summary != request.Summary || failed.Result.CompletedBy != component.Session.ID || failed.CompletedAt.IsZero() { t.Fatalf("record failed result: %#v err=%v", failed, err) } persisted, err := svc.store.GameClientBridgeCommands().Get(command.ID) - if err != nil || persisted.State != domain.GameClientBridgeCommandFailed || persisted.Result.Status != domain.GameClientBridgeResultFailed || persisted.Result.Payload["retryable"] != true || !persisted.CompletedAt.Equal(failed.CompletedAt) || len(persisted.AuditReferences) != len(failed.AuditReferences) { + if err != nil || persisted.State != domain.GameClientBridgeCommandFailed || persisted.Result.Status != domain.GameClientBridgeResultFailed || persisted.Result.Payload["retryable"] != true || !persisted.CompletedAt.Equal(failed.CompletedAt) { t.Fatalf("failed result was not persisted: %#v err=%v", persisted, err) } } @@ -393,8 +282,8 @@ func TestGameClientBridgeOperatorCancellationExpiresAtCommandDeadline(t *testing t.Fatal("expected cancellation at command deadline to be rejected") } expired, err := svc.store.GameClientBridgeCommands().Get(command.ID) - if err != nil || expired.State != domain.GameClientBridgeCommandExpired || expired.CompletedAt.IsZero() || expired.Cancellation.RequestedBy != "" || len(expired.AuditReferences) != 2 { - t.Fatalf("deadline cancellation did not preserve audited expiry: %#v err=%v", expired, err) + if err != nil || expired.State != domain.GameClientBridgeCommandExpired || expired.CompletedAt.IsZero() || expired.Cancellation.RequestedBy != "" { + t.Fatalf("deadline cancellation did not preserve expiry: %#v err=%v", expired, err) } } @@ -424,9 +313,8 @@ func TestGameClientBridgeOperatorCancellationRejectsLateSuccess(t *testing.T) { if err != nil || cancelled.State != domain.GameClientBridgeCommandCancelled || cancelled.Cancellation.RequestedBy != user.ID { t.Fatalf("cancel bridge command: %#v err=%v", cancelled, err) } - auditReferenceCount := len(cancelled.AuditReferences) repeated, err := svc.CancelGameClientBridgeCommandForSession(auth.SessionID, domain.GameClientBridgeCancelRequest{CommandID: command.ID, Reason: "operator requested"}) - if err != nil || repeated.State != domain.GameClientBridgeCommandCancelled || !repeated.Cancellation.CancelledAt.Equal(cancelled.Cancellation.CancelledAt) || len(repeated.AuditReferences) != auditReferenceCount { + if err != nil || repeated.State != domain.GameClientBridgeCommandCancelled || !repeated.Cancellation.CancelledAt.Equal(cancelled.Cancellation.CancelledAt) { t.Fatalf("repeated cancellation was not idempotent: %#v err=%v", repeated, err) } remaining, err := svc.claimGameClientBridgeCommands(component, 1) diff --git a/platform/service/job_channel.go b/platform/service/job_channel.go index 751611c..43f0d67 100644 --- a/platform/service/job_channel.go +++ b/platform/service/job_channel.go @@ -269,9 +269,6 @@ func (svc *CoreService) CompleteRunJob(result domain.RunJobResult) (domain.RunJo if err := svc.updateScheduledJob(job); err != nil { return domain.RunJobResultResult{}, err } - if err := svc.projectProtectedRequestJobResult(job, result, stamp); err != nil { - return domain.RunJobResultResult{}, err - } if err := svc.projectLifecycleJobResult(job, stamp); err != nil { return domain.RunJobResultResult{}, err } @@ -345,7 +342,7 @@ func validateExecutionResultForJob(job domain.Job, result domain.RunJobResult) e return validationError("client-manager deploy result type is invalid") } case domain.JobCapabilityClientManagerControl: - if result.State == domain.JobStateSucceeded && result.ExecutionResult.Kind != "client-manager.controlled" { + if result.State == domain.JobStateSucceeded && result.ExecutionResult.Kind != "client-manager.control" { return validationError("client-manager control result type is invalid") } case domain.JobCapabilityClientManagerUpdate: @@ -630,10 +627,6 @@ func firstEligibleSupportedJob(jobs []domain.Job, capabilities []string, stamp t } func assignmentFromJob(job domain.Job, leaseToken string) domain.RunJobAssignment { - fencingToken := uint64(0) - if isProtectedRequestCapability(job.Capability) { - fencingToken = uint64(job.Attempt) - } return domain.RunJobAssignment{ JobID: job.ID, ServerInstanceID: job.ServerInstanceID, @@ -648,7 +641,7 @@ func assignmentFromJob(job domain.Job, leaseToken string) domain.RunJobAssignmen ExecutionInput: domain.JobExecutionInput{WorkspaceScope: job.ExecutionInput.WorkspaceScope, Content: job.ExecutionInput.Content, ExpectedVersion: job.ExecutionInput.ExpectedVersion, ExpectedChecksum: job.ExecutionInput.ExpectedChecksum, MaxReadBytes: job.ExecutionInput.MaxReadBytes, RemoteAdapterKey: job.ExecutionInput.RemoteAdapterKey, RemoteAdapterKind: job.ExecutionInput.RemoteAdapterKind, TimeoutSeconds: job.ExecutionInput.TimeoutSeconds, PluginID: job.ExecutionInput.PluginID, LifecycleOperation: job.ExecutionInput.LifecycleOperation, TargetVersion: job.ExecutionInput.TargetVersion, Inputs: domain.CopyStringMap(job.ExecutionInput.Inputs), LogSource: domain.CopyRuntimeLogSourcePtr(job.ExecutionInput.LogSource), LogSources: domain.CopyRuntimeLogSources(job.ExecutionInput.LogSources), DLLExtensions: append([]domain.RuntimeDLLExtensionPlan(nil), job.ExecutionInput.DLLExtensions...), SourceRCON: domain.CopyRuntimeSourceRCONPlan(job.ExecutionInput.SourceRCON), Deployment: deploymentPlanForDispatchValue(job.ExecutionInput.Deployment), ServerDeploymentPlan: domain.CopyServerDeploymentPlan(job.ExecutionInput.ServerDeploymentPlan)}, LeaseToken: leaseToken, Attempt: job.Attempt, - FencingToken: fencingToken, + FencingToken: 0, MaxAttempts: job.RetryPolicy.MaxAttempts, AckDeadlineAt: job.AckDeadlineAt, LeaseExpiresAt: job.LeaseExpiresAt, diff --git a/platform/service/observability.go b/platform/service/observability.go index dff0fa2..d03a3b7 100644 --- a/platform/service/observability.go +++ b/platform/service/observability.go @@ -132,13 +132,6 @@ func (svc *CoreService) CreateBackupForSession(sessionID string, record domain.B if err := svc.store.Backups().Create(record); err != nil { return domain.BackupRecord{}, err } - user, err := svc.GetCurrentUser(sessionID) - if err != nil { - return domain.BackupRecord{}, err - } - if err := svc.recordAuditEvent(user.ID, "backup.create", "server-instance", instance.ID, domain.AuditResultQueued, "created bounded backup record with artifact checksum"); err != nil { - return domain.BackupRecord{}, err - } if err := svc.pruneBackups(instance.ID); err != nil { return domain.BackupRecord{}, err } @@ -189,9 +182,6 @@ func (svc *CoreService) RecoverIncompleteBackups() error { if err := svc.store.Backups().Update(record); err != nil { return err } - if err := svc.recordAuditEvent("platform-recovery", "backup.recover", "server-instance", record.ServerInstanceID, domain.AuditResultFailed, "marked interrupted backup recoverable without exposing storage details"); err != nil { - return err - } } return nil } @@ -207,7 +197,7 @@ func (svc *CoreService) pruneMetricSamples(serverInstanceID string) error { return err } } - return svc.recordAuditEvent("platform-retention", "metrics.retention", "server-instance", serverInstanceID, domain.AuditResultSuccess, "pruned oldest metric samples to bounded retention") + return nil } func (svc *CoreService) pruneBackups(serverInstanceID string) error { @@ -222,7 +212,6 @@ func (svc *CoreService) pruneBackups(serverInstanceID string) error { total += record.SizeBytes } } - pruned := false for len(items) > maxBackupsPerServer || total > maxBackupBytesPerServer { record := items[0] items = items[1:] @@ -235,10 +224,6 @@ func (svc *CoreService) pruneBackups(serverInstanceID string) error { if err := svc.store.Backups().Update(record); err != nil { return err } - pruned = true - } - if pruned { - return svc.recordAuditEvent("platform-retention", "backup.retention", "server-instance", serverInstanceID, domain.AuditResultSuccess, "expired oldest backup records to bounded retention") } return nil } diff --git a/platform/service/plugin_log_projection.go b/platform/service/plugin_log_projection.go index 7b43064..b9f1de6 100644 --- a/platform/service/plugin_log_projection.go +++ b/platform/service/plugin_log_projection.go @@ -184,17 +184,6 @@ func (svc *CoreService) applyPluginLogProjection(instance domain.ServerInstance, if insideWindow && !sameObservation { return nil } - announcementAlreadyQueued := false - announcementIdempotencyKey := "" - if projection.Presence != nil { - announcementIdempotencyKey = fmt.Sprintf("log-projection:%s:%s:%d", projection.Key, key, observedAt.Unix()/int64(projection.Presence.ActiveWindowSeconds)) - _, commandErr := svc.store.GameClientBridgeCommands().GetByIdempotency(instance.ID, "system:log-projection", projection.Presence.Announcement.CommandType, announcementIdempotencyKey) - if commandErr == nil { - announcementAlreadyQueued = true - } else if !errors.Is(commandErr, repo.ErrNotFound) { - return commandErr - } - } if !isNew { value = mergePluginDataValues(existing.Value, value) } @@ -211,39 +200,9 @@ func (svc *CoreService) applyPluginLogProjection(instance domain.ServerInstance, return applyErr } } - if projection.Presence != nil && !announcementAlreadyQueued { - announcement := projection.Presence.Announcement - template := announcement.ReturningTextTemplate - if isNew || sameObservation { - template = announcement.NewTextTemplate - } - requestText := renderLogProjectionTemplate(template, captures) - expiresAt := svc.now().Add(gameClientBridgeCommandTimeout(plugin, announcement.CommandType)) - if _, err := svc.queueGameClientBridgeCommand("system:log-projection", domain.GameClientBridgeQueueRequest{ - ServerInstanceID: instance.ID, - PluginID: plugin.ID, - ProfileKey: announcement.ProfileKey, - CommandType: announcement.CommandType, - Payload: map[string]any{announcement.TextField: requestText}, - IdempotencyKey: announcementIdempotencyKey, - Priority: 100, - ExpiresAt: expiresAt, - }); err != nil { - return err - } - } return nil } -func gameClientBridgeCommandTimeout(plugin domain.GamePlugin, commandType string) time.Duration { - for _, declaration := range plugin.GameClientBridge.Commands { - if declaration.Type == commandType && declaration.TimeoutSeconds > 0 { - return time.Duration(declaration.TimeoutSeconds) * time.Second - } - } - return time.Minute -} - func pluginLogProjectionValue(target domain.GameClientBridgeLogProjectionTargetDeclaration, captures map[string]string, observedAt time.Time) map[string]any { value := make(map[string]any, len(target.CaptureMappings)+len(target.FixedValues)+1) for destination, capture := range target.CaptureMappings { diff --git a/platform/service/plugin_log_projection_test.go b/platform/service/plugin_log_projection_test.go index 5787c56..03f283c 100644 --- a/platform/service/plugin_log_projection_test.go +++ b/platform/service/plugin_log_projection_test.go @@ -1,25 +1,16 @@ package service import ( - "strings" "testing" "time" "browser.local/platform/domain" ) -func TestDurableStdoutProjectionCreatesUsersSuppressesRapidDuplicatesAndAnnouncesReturns(t *testing.T) { +func TestDurableStdoutProjectionCreatesUsersAndSuppressesRapidDuplicates(t *testing.T) { svc := newTestCoreService() plugin, endpoint := createPluginAndRunEndpoint(t, svc) - capability := domain.JobCapabilityRemoteRunProtectedRCON - plugin.RequiredRunCapabilities = append(plugin.RequiredRunCapabilities, capability) - plugin.RuntimeProfiles.TransportProfiles = append(plugin.RuntimeProfiles.TransportProfiles, domain.RuntimeTransportProfile{Key: "scum-management", Kind: "rcon", TargetKey: "scum-management", Capabilities: []string{capability}}) plugin.RuntimeProfiles.ClientManagers = append(plugin.RuntimeProfiles.ClientManagers, domain.RuntimeClientManagerProfile{Key: "scum-client", Health: domain.RuntimeClientManagerHealth{RequiredCapabilities: []string{"game-client.bridge"}}}) - plugin.GameClientBridge.Commands = []domain.GameClientBridgeCommandDeclaration{{ - Type: "presence.announce", Title: "Presence announcement", Permission: "server.game-client.command", ApprovalLevel: domain.GameClientBridgeApprovalLevelOperator, - PayloadSchemaRef: "schemas/presence-announcement.json", TimeoutSeconds: 60, MaxPayloadBytes: 4096, - ProtectedRequest: &domain.GameClientBridgeProtectedRequestDeclaration{Kind: "rcon", TransportKey: "scum-management", TargetKey: "scum-management", TextField: "requestText", MaxTextBytes: 1024}, - }} plugin.GameClientBridge.LogProjections = []domain.GameClientBridgeLogProjectionDeclaration{{ Key: "player.login", StreamKeys: []string{"stdout"}, CorrelationFields: []string{"playerSlot"}, MaxInterveningLines: 4, Steps: []domain.GameClientBridgeLogProjectionStepDeclaration{ @@ -37,25 +28,16 @@ func TestDurableStdoutProjectionCreatesUsersSuppressesRapidDuplicatesAndAnnounce Collection: "scum_activity_events", UpsertKeys: []string{"steamId", "observedAt"}, CaptureMappings: map[string]string{"steamId": "steamId", "displayName": "displayName"}, FixedValues: map[string]string{"eventType": "login"}, ObservedAtField: "observedAt", }, - Announcement: domain.GameClientBridgeLogProjectionAnnouncementDeclaration{ - ProfileKey: "scum-client", CommandType: "presence.announce", TextField: "requestText", - NewTextTemplate: "#announce Welcome {{displayName}}", ReturningTextTemplate: "#announce Welcome back {{displayName}}", - }, }, }} if err := svc.store.GamePlugins().Update(plugin); err != nil { t.Fatalf("update plugin projection: %v", err) } - endpoint.Capabilities = append(endpoint.Capabilities, capability) - if err := svc.store.RunEndpoints().Update(endpoint); err != nil { - t.Fatalf("update Run capability: %v", err) - } instance, err := svc.CreateServerInstance(domain.ServerInstance{ID: "server-log-projection", PluginID: plugin.ID, RunEndpointID: endpoint.ID, Name: "SCUM projection", State: domain.ServerInstanceStateRunning}) if err != nil { t.Fatalf("create server: %v", err) } helloRequest := validRunControlHello() - helloRequest.CapabilityReport.Capabilities = append(helloRequest.CapabilityReport.Capabilities, capability) helloRequest.CapabilityReport.Fingerprint = "cap-log-projection" hello, err := svc.RegisterRunHello(helloRequest) if err != nil { @@ -74,29 +56,19 @@ func TestDurableStdoutProjectionCreatesUsersSuppressesRapidDuplicatesAndAnnounce ingestProjectionLines(t, svc, hello.SessionToken, endpoint.ID, instance.ID, stream.ID, 3, base.Add(2*time.Second), []string{ `LogBattlEye: Display: Player 0 SteamID (assumed): 76561199510658111`, }) - assertPresenceProjectionCounts(t, svc, plugin.ID, instance.ID, 1, 1, 1) + assertPresenceProjectionCounts(t, svc, plugin.ID, instance.ID, 1, 1, 0) ingestProjectionLines(t, svc, hello.SessionToken, endpoint.ID, instance.ID, stream.ID, 4, base.Add(5*time.Minute), []string{ `LogBattlEye: Display: Player "love_fitting" reported as player 0`, `LogBattlEye: Display: Player 0 SteamID (assumed): 76561199510658111`, }) - assertPresenceProjectionCounts(t, svc, plugin.ID, instance.ID, 1, 1, 1) + assertPresenceProjectionCounts(t, svc, plugin.ID, instance.ID, 1, 1, 0) ingestProjectionLines(t, svc, hello.SessionToken, endpoint.ID, instance.ID, stream.ID, 6, base.Add(11*time.Minute), []string{ `LogBattlEye: Display: Player "love_fitting" reported as player 0`, `LogBattlEye: Display: Player 0 SteamID (assumed): 76561199510658111`, }) - assertPresenceProjectionCounts(t, svc, plugin.ID, instance.ID, 1, 2, 2) - - svc.protectedRequests.mu.Lock() - texts := make([]string, 0, len(svc.protectedRequests.payloads)) - for _, payload := range svc.protectedRequests.payloads { - texts = append(texts, payload.requestText) - } - svc.protectedRequests.mu.Unlock() - if len(texts) != 2 || !containsText(texts, "#announce Welcome love_fitting") || !containsText(texts, "#announce Welcome back love_fitting") { - t.Fatalf("unexpected plugin-declared announcement requests: %v", texts) - } + assertPresenceProjectionCounts(t, svc, plugin.ID, instance.ID, 1, 2, 0) } func ingestProjectionLines(t *testing.T, svc *CoreService, sessionToken, endpointID, serverID, streamID string, firstSeq uint64, observedAt time.Time, lines []string) { @@ -124,15 +96,6 @@ func assertPresenceProjectionCounts(t *testing.T, svc *CoreService, pluginID, se } queued, err := svc.store.GameClientBridgeCommands().List(domain.GameClientBridgeCommandFilter{ServerInstanceID: serverID, PluginID: pluginID}) if err != nil || len(queued) != commands { - t.Fatalf("presence announcements=%+v err=%v", queued, err) + t.Fatalf("presence bridge commands=%+v err=%v", queued, err) } } - -func containsText(values []string, expected string) bool { - for _, value := range values { - if strings.Contains(value, expected) { - return true - } - } - return false -} diff --git a/platform/service/production_ops.go b/platform/service/production_ops.go index 70f02ad..e2487fb 100644 --- a/platform/service/production_ops.go +++ b/platform/service/production_ops.go @@ -14,297 +14,9 @@ import ( ) const ( - capacityHeartbeatStaleAfter = 2 * time.Minute - capacityRetryAfterSeconds = 30 - capacityLogBacklogLimit = 256 - capacityArtifactBacklogLimit = 128 - aiConfigDiffTTL = 30 * time.Minute + aiConfigDiffTTL = 30 * time.Minute ) -func (svc *CoreService) GetProductionCapacityForSession(sessionID string) (domain.ProductionCapacitySummary, error) { - user, err := svc.GetCurrentUser(sessionID) - if err != nil { - return domain.ProductionCapacitySummary{}, err - } - endpoints, err := svc.store.RunEndpoints().List(domain.RunEndpointFilter{}) - if err != nil { - return domain.ProductionCapacitySummary{}, err - } - visibleEndpointIDs, err := svc.visibleEndpointIDs(user) - if err != nil { - return domain.ProductionCapacitySummary{}, err - } - alerts, err := svc.store.Alerts().List(domain.AlertFilter{}) - if err != nil { - return domain.ProductionCapacitySummary{}, err - } - - summary := domain.ProductionCapacitySummary{GeneratedAt: svc.now()} - for _, endpoint := range endpoints { - if !isPlatformAdmin(user) { - if _, visible := visibleEndpointIDs[endpoint.ID]; !visible { - continue - } - } - running, queued, err := svc.capacityJobCounts(endpoint.ID) - if err != nil { - return domain.ProductionCapacitySummary{}, err - } - projection := svc.capacityProjection(endpoint, running, queued) - for _, alert := range alerts { - if alert.SourceKind == "run-endpoint" && alert.SourceID == endpoint.ID && alert.RuleKey == "capacity.pressure" && alert.State != domain.AlertStateResolved { - projection.LastAdmissionDecision = domain.CapacityAdmissionDeferred - projection.LastAdmissionReason = alert.Message - projection.LastAdmissionCheckedAt = alert.LastSeenAt - } - } - summary.Endpoints = append(summary.Endpoints, projection) - summary.TotalMaxJobs += projection.MaxJobs - summary.TotalRunningJobs += projection.RunningJobs - summary.TotalQueuedJobs += projection.QueuedJobs - } - for _, alert := range alerts { - if alert.State != domain.AlertStateResolved && svc.canAccessAlert(user, alert) { - summary.ActiveAlerts++ - } - } - sort.Slice(summary.Endpoints, func(i, j int) bool { return summary.Endpoints[i].RunEndpointID < summary.Endpoints[j].RunEndpointID }) - return domain.CopyProductionCapacitySummary(summary), nil -} - -func (svc *CoreService) CheckCapacityAdmissionForSession(sessionID string, request domain.CapacityAdmissionRequest) (domain.CapacityAdmissionDecision, error) { - if err := validator.ValidateCapacityAdmissionRequest(request); err != nil { - return domain.CapacityAdmissionDecision{}, err - } - user, err := svc.GetCurrentUser(sessionID) - if err != nil { - return domain.CapacityAdmissionDecision{}, err - } - request, err = svc.authorizeCapacityRequest(user, request) - if err != nil { - return domain.CapacityAdmissionDecision{}, err - } - svc.productionMu.Lock() - defer svc.productionMu.Unlock() - return svc.checkCapacityAdmission(user.ID, request) -} - -func (svc *CoreService) checkCapacityAdmission(actorID string, request domain.CapacityAdmissionRequest) (domain.CapacityAdmissionDecision, error) { - endpoint, err := svc.store.RunEndpoints().Get(request.RunEndpointID) - if err != nil { - return domain.CapacityAdmissionDecision{}, err - } - running, queued, err := svc.capacityJobCounts(endpoint.ID) - if err != nil { - return domain.CapacityAdmissionDecision{}, err - } - projection := svc.capacityProjection(endpoint, running, queued) - decision := domain.CapacityAdmissionDecision{ - Accepted: true, State: domain.CapacityAdmissionAccepted, Reason: "capacity available", - ServerInstanceID: request.ServerInstanceID, RunEndpointID: endpoint.ID, Capability: request.Capability, - TargetKey: request.TargetKey, MaxJobs: projection.MaxJobs, RunningJobs: projection.RunningJobs, - QueuedJobs: projection.QueuedJobs, CheckedAt: svc.now(), - } - pressure := append([]domain.CapacityPressureCode(nil), projection.PressureCodes...) - if len(validator.MissingCapabilities(endpoint.Capabilities, []string{request.Capability})) > 0 { - pressure = appendCapacityPressure(pressure, domain.CapacityPressureCapabilityGap) - } - decision.PressureCodes = pressure - - hardDenied := containsCapacityPressure(pressure, domain.CapacityPressureEndpointOffline) || containsCapacityPressure(pressure, domain.CapacityPressureCapabilityGap) - if hardDenied { - decision.Accepted = false - decision.State = domain.CapacityAdmissionDenied - decision.Reason = "endpoint is unavailable or missing the required capability" - } else if len(pressure) > 0 { - decision.Accepted = false - decision.State = domain.CapacityAdmissionDeferred - decision.Reason = "endpoint capacity is temporarily under pressure" - decision.RetryAfterSeconds = capacityRetryAfterSeconds - } - - auditResult := domain.AuditResultSuccess - auditAction := "capacity.admission.accepted" - if !decision.Accepted { - auditResult = domain.AuditResultDenied - auditAction = "capacity.admission.denied" - } - auditID, err := svc.recordAuditEventWithID(actorID, auditAction, "run-endpoint", endpoint.ID, auditResult, decision.Reason) - if err != nil { - return domain.CapacityAdmissionDecision{}, err - } - decision.AuditEventID = auditID - if !decision.Accepted { - severity := domain.AlertSeverityWarning - if hardDenied { - severity = domain.AlertSeverityCritical - } - alert, err := svc.upsertAlert(domain.AlertRecord{ - SourceKind: "run-endpoint", SourceID: endpoint.ID, RuleKey: "capacity.pressure", Severity: severity, - Title: "Run endpoint capacity admission blocked", Message: decision.Reason, Retryable: true, - RetryAfterSeconds: decision.RetryAfterSeconds, LastAuditEventID: auditID, - }) - if err != nil { - return domain.CapacityAdmissionDecision{}, err - } - decision.AlertID = alert.ID - } else if err := svc.resolveAlertForSource("run-endpoint", endpoint.ID, "capacity.pressure", actorID, "capacity returned to an admissible state", auditID); err != nil { - return domain.CapacityAdmissionDecision{}, err - } - if err := validator.ValidateCapacityAdmissionDecision(decision); err != nil { - return domain.CapacityAdmissionDecision{}, err - } - return domain.CopyCapacityAdmissionDecision(decision), nil -} - -func (svc *CoreService) ListAlertsForSession(sessionID string, filter domain.AlertFilter) ([]domain.AlertRecord, error) { - user, err := svc.GetCurrentUser(sessionID) - if err != nil { - return nil, err - } - alerts, err := svc.store.Alerts().List(filter) - if err != nil { - return nil, err - } - visible := make([]domain.AlertRecord, 0, len(alerts)) - for _, alert := range alerts { - if svc.canAccessAlert(user, alert) { - visible = append(visible, alert) - } - } - sort.Slice(visible, func(i, j int) bool { return visible[i].UpdatedAt.After(visible[j].UpdatedAt) }) - return domain.CopyAlertRecords(visible), nil -} - -func (svc *CoreService) AcknowledgeAlertForSession(sessionID string, request domain.AlertAcknowledgeRequest) (domain.AlertRecord, error) { - if err := validator.ValidateAlertAcknowledgeRequest(request); err != nil { - return domain.AlertRecord{}, err - } - user, err := svc.GetCurrentUser(sessionID) - if err != nil { - return domain.AlertRecord{}, err - } - svc.productionMu.Lock() - defer svc.productionMu.Unlock() - alert, err := svc.store.Alerts().Get(request.AlertID) - if err != nil { - return domain.AlertRecord{}, err - } - if !svc.canAccessAlert(user, alert) { - return domain.AlertRecord{}, ErrForbidden - } - if alert.State == domain.AlertStateResolved { - return domain.AlertRecord{}, validationError("resolved alerts cannot be acknowledged") - } - stamp := svc.now() - auditID, err := svc.recordAuditEventWithID(user.ID, "alert.acknowledge", "alert", alert.ID, domain.AuditResultSuccess, defaultAlertNote(request.Note, "alert acknowledged")) - if err != nil { - return domain.AlertRecord{}, err - } - alert.State = domain.AlertStateAcknowledged - alert.AcknowledgedBy = user.ID - alert.AcknowledgedAt = stamp - alert.LastAuditEventID = auditID - alert.UpdatedAt = stamp - if err := validator.ValidateAlertRecord(alert); err != nil { - return domain.AlertRecord{}, err - } - if err := svc.store.Alerts().Update(alert); err != nil { - return domain.AlertRecord{}, err - } - return domain.CopyAlertRecord(alert), nil -} - -func (svc *CoreService) ResolveAlertForSession(sessionID string, request domain.AlertResolveRequest) (domain.AlertRecord, error) { - if err := validator.ValidateAlertResolveRequest(request); err != nil { - return domain.AlertRecord{}, err - } - user, err := svc.GetCurrentUser(sessionID) - if err != nil { - return domain.AlertRecord{}, err - } - svc.productionMu.Lock() - defer svc.productionMu.Unlock() - alert, err := svc.store.Alerts().Get(request.AlertID) - if err != nil { - return domain.AlertRecord{}, err - } - if !svc.canAccessAlert(user, alert) { - return domain.AlertRecord{}, ErrForbidden - } - if alert.State == domain.AlertStateResolved { - return domain.CopyAlertRecord(alert), nil - } - stamp := svc.now() - note := defaultAlertNote(request.Note, "alert resolved after operator review") - auditID, err := svc.recordAuditEventWithID(user.ID, "alert.resolve", "alert", alert.ID, domain.AuditResultSuccess, note) - if err != nil { - return domain.AlertRecord{}, err - } - alert.State = domain.AlertStateResolved - alert.ResolvedBy = user.ID - alert.ResolvedAt = stamp - alert.ResolutionNote = note - alert.LastAuditEventID = auditID - alert.UpdatedAt = stamp - if err := validator.ValidateAlertRecord(alert); err != nil { - return domain.AlertRecord{}, err - } - if err := svc.store.Alerts().Update(alert); err != nil { - return domain.AlertRecord{}, err - } - return domain.CopyAlertRecord(alert), nil -} - -func (svc *CoreService) RetryAlertForSession(sessionID string, request domain.AlertRetryRequest) (domain.AlertRetryResult, error) { - if err := validator.ValidateAlertRetryRequest(request); err != nil { - return domain.AlertRetryResult{}, err - } - user, err := svc.GetCurrentUser(sessionID) - if err != nil { - return domain.AlertRetryResult{}, err - } - alert, err := svc.store.Alerts().Get(request.AlertID) - if err != nil { - return domain.AlertRetryResult{}, err - } - if !svc.canAccessAlert(user, alert) { - return domain.AlertRetryResult{}, ErrForbidden - } - if !alert.Retryable { - return domain.AlertRetryResult{}, validationError("alert source is not retryable") - } - switch alert.SourceKind { - case "run-endpoint": - endpoint, err := svc.store.RunEndpoints().Get(alert.SourceID) - if err != nil { - return domain.AlertRetryResult{}, err - } - capability := firstCapacityCapability(endpoint.Capabilities) - decision, err := svc.CheckCapacityAdmissionForSession(sessionID, domain.CapacityAdmissionRequest{RunEndpointID: endpoint.ID, Capability: capability, IdempotencyKey: request.IdempotencyKey}) - if err != nil { - return domain.AlertRetryResult{}, err - } - updated, err := svc.store.Alerts().Get(alert.ID) - if err != nil { - return domain.AlertRetryResult{}, err - } - return domain.CopyAlertRetryResult(domain.AlertRetryResult{Alert: updated, Decision: decision, Status: string(decision.State)}), nil - case "plugin-lifecycle": - installation, err := svc.store.PluginLifecycles().Get(alert.SourceID) - if err != nil { - return domain.AlertRetryResult{}, err - } - result, err := svc.RunPluginLifecycleForSession(sessionID, domain.PluginLifecycleRequest{PluginID: installation.PluginID, ServerInstanceID: installation.ServerInstanceID, Operation: installation.LastOperation, TargetVersion: installation.TargetVersion, IdempotencyKey: request.IdempotencyKey, Confirmed: true}) - if err != nil { - return domain.AlertRetryResult{}, err - } - return domain.CopyAlertRetryResult(domain.AlertRetryResult{Alert: alert, Decision: result.Decision, Status: result.Status}), nil - default: - return domain.AlertRetryResult{}, validationError("alert source does not support scoped retry") - } -} - func (svc *CoreService) ListPluginLifecyclesForSession(sessionID string, filter domain.PluginLifecycleFilter) ([]domain.PluginLifecycleInstallation, error) { user, err := svc.GetCurrentUser(sessionID) if err != nil { @@ -383,13 +95,6 @@ func (svc *CoreService) RunPluginLifecycleForSession(sessionID string, request d if !errors.Is(jobErr, repo.ErrNotFound) { return domain.PluginLifecycleResult{}, jobErr } - decision, err := svc.checkCapacityAdmission(user.ID, domain.CapacityAdmissionRequest{ServerInstanceID: instance.ID, RunEndpointID: endpoint.ID, Capability: capability, TargetKey: targetKey, IdempotencyKey: request.IdempotencyKey}) - if err != nil { - return domain.PluginLifecycleResult{}, err - } - if !decision.Accepted { - return domain.CopyPluginLifecycleResult(domain.PluginLifecycleResult{Installation: installation, Decision: decision, Status: string(decision.State)}), nil - } job, err := svc.CreateJob(domain.Job{ ID: jobIDFromParts("job-plugin-lifecycle", installation.ID, request.IdempotencyKey), ServerInstanceID: instance.ID, RunEndpointID: endpoint.ID, Capability: capability, TargetKey: targetKey, IdempotencyKey: request.IdempotencyKey, @@ -409,11 +114,6 @@ func (svc *CoreService) RunPluginLifecycleForSession(sessionID string, request d installation.IdempotencyKey = request.IdempotencyKey installation.FailureReason = "" installation.UpdatedAt = stamp - auditID, err := svc.recordAuditEventWithID(user.ID, "plugin.lifecycle."+string(request.Operation), "plugin-lifecycle", installation.ID, domain.AuditResultQueued, "plugin lifecycle operation admitted and queued") - if err != nil { - return domain.PluginLifecycleResult{}, err - } - installation.AuditEventID = auditID if err := validator.ValidatePluginLifecycleInstallation(installation); err != nil { return domain.PluginLifecycleResult{}, err } @@ -425,7 +125,7 @@ func (svc *CoreService) RunPluginLifecycleForSession(sessionID string, request d if err != nil { return domain.PluginLifecycleResult{}, err } - return domain.CopyPluginLifecycleResult(domain.PluginLifecycleResult{Installation: installation, Job: job, Decision: decision, Status: "queued"}), nil + return domain.CopyPluginLifecycleResult(domain.PluginLifecycleResult{Installation: installation, Job: job, Status: "queued"}), nil } func (svc *CoreService) ListAIConfigDiffsForSession(sessionID string, filter domain.AIConfigDiffFilter) ([]domain.AIConfigDiffPreview, error) { @@ -503,9 +203,6 @@ func (svc *CoreService) ApproveAIConfigDiffForSession(sessionID string, request if err := svc.store.AIConfigDiffs().Update(preview); err != nil { return domain.AIConfigDiffApprovalResult{}, err } - if _, err := svc.recordAuditEventWithID(user.ID, "ai.config-diff.approve", "ai-config-diff", preview.ID, domain.AuditResultQueued, "approved reviewed AI config diff and queued one config write job"); err != nil { - return domain.AIConfigDiffApprovalResult{}, err - } return domain.CopyAIConfigDiffApprovalResult(domain.AIConfigDiffApprovalResult{Preview: preview, Dispatch: dispatch}), nil } @@ -525,17 +222,9 @@ func (svc *CoreService) projectProductionOpsJobResult(job domain.Job, stamp time if job.State == domain.JobStateSucceeded { applyPluginLifecycleSuccess(&installation) installation.FailureReason = "" - if err := svc.resolveAlertForSource("plugin-lifecycle", installation.ID, "plugin.lifecycle.failed", "run:"+job.RunEndpointID, "plugin lifecycle job completed", installation.AuditEventID); err != nil { - return err - } } else { installation.CurrentState = domain.PluginLifecycleStateFailed installation.FailureReason = "plugin lifecycle job did not complete successfully" - alert, err := svc.upsertAlert(domain.AlertRecord{SourceKind: "plugin-lifecycle", SourceID: installation.ID, RuleKey: "plugin.lifecycle.failed", Severity: domain.AlertSeverityWarning, Title: "Plugin lifecycle operation failed", Message: installation.FailureReason, Retryable: true, LastJobID: job.ID, LastAuditEventID: installation.AuditEventID}) - if err != nil { - return err - } - installation.AlertID = alert.ID } installation.UpdatedAt = stamp return svc.store.PluginLifecycles().Update(installation) @@ -598,193 +287,6 @@ func (svc *CoreService) getServerConfigForUser(userID, serverInstanceID string) return config, validator.ValidateServerConfig(config) } -func (svc *CoreService) authorizeCapacityRequest(user domain.User, request domain.CapacityAdmissionRequest) (domain.CapacityAdmissionRequest, error) { - if request.ServerInstanceID != "" { - instance, err := svc.store.ServerInstances().Get(request.ServerInstanceID) - if err != nil { - return request, err - } - if !canAccessServer(user, instance) { - return request, ErrForbidden - } - if request.RunEndpointID != "" && request.RunEndpointID != instance.RunEndpointID { - return request, validationError("runEndpointId must match server instance") - } - request.RunEndpointID = instance.RunEndpointID - return request, nil - } - if request.RunEndpointID == "" { - return request, validationError("serverInstanceId or runEndpointId is required") - } - if isPlatformAdmin(user) { - return request, nil - } - visible, err := svc.visibleEndpointIDs(user) - if err != nil { - return request, err - } - if _, ok := visible[request.RunEndpointID]; !ok { - return request, ErrForbidden - } - return request, nil -} - -func (svc *CoreService) visibleEndpointIDs(user domain.User) (map[string]struct{}, error) { - instances, err := svc.store.ServerInstances().List(domain.ServerInstanceFilter{}) - if err != nil { - return nil, err - } - ids := map[string]struct{}{} - for _, instance := range instances { - if isPlatformAdmin(user) || canAccessServer(user, instance) { - ids[instance.RunEndpointID] = struct{}{} - } - } - return ids, nil -} - -func (svc *CoreService) capacityJobCounts(endpointID string) (int, int, error) { - jobs, err := svc.store.Jobs().List(domain.JobFilter{RunEndpointID: endpointID}) - if err != nil { - return 0, 0, err - } - running, queued := 0, 0 - for _, job := range jobs { - switch job.State { - case domain.JobStateAccepted, domain.JobStateRunning: - running++ - case domain.JobStateQueued, domain.JobStateRetrying: - queued++ - } - } - return running, queued, nil -} - -func (svc *CoreService) capacityProjection(endpoint domain.RunEndpoint, durableRunning, durableQueued int) domain.EndpointCapacityProjection { - running := maxInt(endpoint.Capacity.RunningJobs, durableRunning) - queued := maxInt(endpoint.Capacity.QueuedJobs, durableQueued) - projection := domain.EndpointCapacityProjection{RunEndpointID: endpoint.ID, DisplayName: endpoint.DisplayName, Status: endpoint.Status, Capabilities: endpoint.Capabilities, MaxJobs: endpoint.Capacity.MaxJobs, RunningJobs: running, QueuedJobs: queued, LogBacklogBatches: endpoint.Capacity.LogBacklogBatches, ArtifactBacklogChunks: endpoint.Capacity.ArtifactBacklogChunks, Summary: safeBridgeReason(endpoint.Capacity.Summary), LastHeartbeatAt: endpoint.LastHeartbeatAt} - if endpoint.Status != domain.RunEndpointStatusOnline && endpoint.Status != domain.RunEndpointStatusDegraded { - projection.PressureCodes = appendCapacityPressure(projection.PressureCodes, domain.CapacityPressureEndpointOffline) - } - if endpoint.LastHeartbeatAt.IsZero() || svc.now().Sub(endpoint.LastHeartbeatAt) > capacityHeartbeatStaleAfter { - projection.PressureCodes = appendCapacityPressure(projection.PressureCodes, domain.CapacityPressureEndpointStale) - } - if projection.MaxJobs <= 0 || running >= projection.MaxJobs { - projection.PressureCodes = appendCapacityPressure(projection.PressureCodes, domain.CapacityPressureJobLimit) - } - queueLimit := maxInt(4, projection.MaxJobs*2) - if queued >= queueLimit { - projection.PressureCodes = appendCapacityPressure(projection.PressureCodes, domain.CapacityPressureQueueLimit) - } - if projection.LogBacklogBatches >= capacityLogBacklogLimit || projection.ArtifactBacklogChunks >= capacityArtifactBacklogLimit || len(endpoint.Capacity.PressureCodes) > 0 { - projection.PressureCodes = appendCapacityPressure(projection.PressureCodes, domain.CapacityPressureBacklog) - } - return projection -} - -func (svc *CoreService) upsertAlert(candidate domain.AlertRecord) (domain.AlertRecord, error) { - stamp := svc.now() - candidate.ID = alertIDForSource(candidate.SourceKind, candidate.SourceID, candidate.RuleKey) - existing, err := svc.store.Alerts().Get(candidate.ID) - if err == nil { - existing.Severity = candidate.Severity - existing.State = domain.AlertStateActive - existing.Title = candidate.Title - existing.Message = safeBridgeReason(candidate.Message) - existing.OccurrenceCount++ - existing.Retryable = candidate.Retryable - existing.RetryAfterSeconds = candidate.RetryAfterSeconds - existing.LastJobID = candidate.LastJobID - existing.LastAuditEventID = candidate.LastAuditEventID - existing.LastSeenAt = stamp - existing.ResolvedBy = "" - existing.ResolvedAt = time.Time{} - existing.ResolutionNote = "" - existing.UpdatedAt = stamp - if err := validator.ValidateAlertRecord(existing); err != nil { - return domain.AlertRecord{}, err - } - if err := svc.store.Alerts().Update(existing); err != nil { - return domain.AlertRecord{}, err - } - return existing, nil - } - if !errors.Is(err, repo.ErrNotFound) { - return domain.AlertRecord{}, err - } - candidate.State = domain.AlertStateActive - candidate.Message = safeBridgeReason(candidate.Message) - candidate.OccurrenceCount = 1 - candidate.LastSeenAt = stamp - candidate.CreatedAt = stamp - candidate.UpdatedAt = stamp - if err := validator.ValidateAlertRecord(candidate); err != nil { - return domain.AlertRecord{}, err - } - if err := svc.store.Alerts().Create(candidate); err != nil { - return domain.AlertRecord{}, err - } - return candidate, nil -} - -func (svc *CoreService) resolveAlertForSource(sourceKind, sourceID, ruleKey, actorID, note, auditID string) error { - alert, err := svc.store.Alerts().Get(alertIDForSource(sourceKind, sourceID, ruleKey)) - if errors.Is(err, repo.ErrNotFound) { - return nil - } - if err != nil { - return err - } - if alert.State == domain.AlertStateResolved { - return nil - } - stamp := svc.now() - alert.State = domain.AlertStateResolved - alert.ResolvedBy = actorID - alert.ResolvedAt = stamp - alert.ResolutionNote = note - alert.LastAuditEventID = auditID - alert.UpdatedAt = stamp - return svc.store.Alerts().Update(alert) -} - -func (svc *CoreService) canAccessAlert(user domain.User, alert domain.AlertRecord) bool { - if isPlatformAdmin(user) { - return true - } - switch alert.SourceKind { - case "server-instance": - instance, err := svc.store.ServerInstances().Get(alert.SourceID) - return err == nil && canAccessServer(user, instance) - case "run-endpoint": - instances, err := svc.store.ServerInstances().List(domain.ServerInstanceFilter{RunEndpointID: alert.SourceID}) - if err != nil { - return false - } - for _, instance := range instances { - if canAccessServer(user, instance) { - return true - } - } - case "plugin-lifecycle": - installation, err := svc.store.PluginLifecycles().Get(alert.SourceID) - if err != nil { - return false - } - instance, err := svc.store.ServerInstances().Get(installation.ServerInstanceID) - return err == nil && canAccessServer(user, instance) - case "ai-config-diff": - preview, err := svc.store.AIConfigDiffs().Get(alert.SourceID) - if err != nil { - return false - } - instance, err := svc.store.ServerInstances().Get(preview.ServerInstanceID) - return err == nil && canAccessServer(user, instance) - } - return false -} - func (svc *CoreService) pluginLifecycleDenied(actorID string, instance domain.ServerInstance, plugin domain.GamePlugin, request domain.PluginLifecycleRequest, reason string) (domain.PluginLifecycleResult, error) { svc.productionMu.Lock() defer svc.productionMu.Unlock() @@ -798,21 +300,11 @@ func (svc *CoreService) pluginLifecycleDenied(actorID string, instance domain.Se func (svc *CoreService) pluginLifecycleDeniedLocked(actorID string, installation domain.PluginLifecycleInstallation, request domain.PluginLifecycleRequest, reason string) (domain.PluginLifecycleResult, error) { stamp := svc.now() - auditID, err := svc.recordAuditEventWithID(actorID, "plugin.lifecycle.denied", "plugin-lifecycle", installation.ID, domain.AuditResultDenied, reason) - if err != nil { - return domain.PluginLifecycleResult{}, err - } installation.CurrentState = domain.PluginLifecycleStateFailed installation.LastOperation = request.Operation installation.TargetVersion = request.TargetVersion installation.FailureReason = safeBridgeReason(reason) - installation.AuditEventID = auditID installation.UpdatedAt = stamp - alert, err := svc.upsertAlert(domain.AlertRecord{SourceKind: "plugin-lifecycle", SourceID: installation.ID, RuleKey: "plugin.lifecycle.compatibility", Severity: domain.AlertSeverityWarning, Title: "Plugin lifecycle compatibility check failed", Message: installation.FailureReason, Retryable: true, LastAuditEventID: auditID}) - if err != nil { - return domain.PluginLifecycleResult{}, err - } - installation.AlertID = alert.ID if err := validator.ValidatePluginLifecycleInstallation(installation); err != nil { return domain.PluginLifecycleResult{}, err } @@ -824,7 +316,7 @@ func (svc *CoreService) pluginLifecycleDeniedLocked(actorID string, installation if err != nil { return domain.PluginLifecycleResult{}, err } - return domain.CopyPluginLifecycleResult(domain.PluginLifecycleResult{Installation: installation, Alert: &alert, Status: "denied"}), nil + return domain.CopyPluginLifecycleResult(domain.PluginLifecycleResult{Installation: installation, Status: "denied"}), nil } func pluginLifecycleDispatchMetadata(plugin domain.GamePlugin, operation domain.PluginLifecycleOperation) (string, string, error) { @@ -937,11 +429,6 @@ func applyPluginLifecycleSuccess(installation *domain.PluginLifecycleInstallatio } } -func alertIDForSource(sourceKind, sourceID, ruleKey string) string { - sum := sha256.Sum256([]byte(sourceKind + "\x00" + sourceID + "\x00" + ruleKey)) - return "alert-" + hex.EncodeToString(sum[:12]) -} - func pluginLifecycleInstallationID(pluginID, serverInstanceID string) string { sum := sha256.Sum256([]byte(pluginID + "\x00" + serverInstanceID)) return "plugin-lifecycle-" + hex.EncodeToString(sum[:12]) @@ -951,42 +438,3 @@ func aiConfigDiffID(requestID, serverInstanceID string) string { sum := sha256.Sum256([]byte(requestID + "\x00" + serverInstanceID)) return "ai-config-diff-" + hex.EncodeToString(sum[:12]) } - -func appendCapacityPressure(codes []domain.CapacityPressureCode, code domain.CapacityPressureCode) []domain.CapacityPressureCode { - if !containsCapacityPressure(codes, code) { - return append(codes, code) - } - return codes -} - -func containsCapacityPressure(codes []domain.CapacityPressureCode, target domain.CapacityPressureCode) bool { - for _, code := range codes { - if code == target { - return true - } - } - return false -} - -func firstCapacityCapability(capabilities []string) string { - for _, capability := range capabilities { - if strings.TrimSpace(capability) != "" { - return capability - } - } - return "control.heartbeat" -} - -func defaultAlertNote(note, fallback string) string { - if strings.TrimSpace(note) == "" { - return fallback - } - return safeBridgeReason(note) -} - -func maxInt(a, b int) int { - if a > b { - return a - } - return b -} diff --git a/platform/service/production_ops_test.go b/platform/service/production_ops_test.go index 44dd45e..bf39215 100644 --- a/platform/service/production_ops_test.go +++ b/platform/service/production_ops_test.go @@ -9,38 +9,6 @@ import ( "browser.local/platform/repo" ) -func TestProductionCapacityCreatesDurableAlertAndSupportsClosure(t *testing.T) { - svc, session, instance := newProductionOpsFixture(t) - endpoint, err := svc.store.RunEndpoints().Get(instance.RunEndpointID) - if err != nil { - t.Fatalf("get endpoint: %v", err) - } - endpoint.Capacity.RunningJobs = endpoint.Capacity.MaxJobs - if err := svc.store.RunEndpoints().Update(endpoint); err != nil { - t.Fatalf("update endpoint pressure: %v", err) - } - - decision, err := svc.CheckCapacityAdmissionForSession(session, domain.CapacityAdmissionRequest{ServerInstanceID: instance.ID, Capability: domain.LifecycleCapabilityInstall, IdempotencyKey: "capacity-pressure"}) - if err != nil { - t.Fatalf("check capacity: %v", err) - } - if decision.Accepted || decision.State != domain.CapacityAdmissionDeferred || decision.AlertID == "" || decision.AuditEventID == "" { - t.Fatalf("expected durable deferred decision, got %+v", decision) - } - alerts, err := svc.ListAlertsForSession(session, domain.AlertFilter{State: domain.AlertStateActive}) - if err != nil || len(alerts) != 1 || alerts[0].OccurrenceCount != 1 { - t.Fatalf("expected one active alert, got %+v err=%v", alerts, err) - } - acknowledged, err := svc.AcknowledgeAlertForSession(session, domain.AlertAcknowledgeRequest{AlertID: decision.AlertID, Note: "operator reviewing queue pressure"}) - if err != nil || acknowledged.State != domain.AlertStateAcknowledged || acknowledged.AcknowledgedBy == "" { - t.Fatalf("acknowledge alert: %+v err=%v", acknowledged, err) - } - resolved, err := svc.ResolveAlertForSession(session, domain.AlertResolveRequest{AlertID: decision.AlertID, Note: "capacity policy reviewed"}) - if err != nil || resolved.State != domain.AlertStateResolved || resolved.ResolvedBy == "" { - t.Fatalf("resolve alert: %+v err=%v", resolved, err) - } -} - func TestPluginLifecycleDispatchIsIdempotentAndRejectsInputDrift(t *testing.T) { svc, session, instance := newProductionOpsFixture(t) request := domain.PluginLifecycleRequest{PluginID: instance.PluginID, ServerInstanceID: instance.ID, Operation: domain.PluginLifecycleOperationInstall, TargetVersion: "1.0.0", IdempotencyKey: "plugin-install-v1"} @@ -66,7 +34,7 @@ func TestPluginLifecycleDispatchIsIdempotentAndRejectsInputDrift(t *testing.T) { } } -func TestPluginLifecycleBridgeDispatchesOnlyPlatformGovernedJob(t *testing.T) { +func TestPluginLifecycleBridgeDispatchesBoundedJob(t *testing.T) { svc, session, instance := newProductionOpsFixture(t) plugin, err := svc.store.GamePlugins().Get(instance.PluginID) if err != nil { @@ -90,13 +58,12 @@ func TestPluginLifecycleBridgeDispatchesOnlyPlatformGovernedJob(t *testing.T) { if err != nil { t.Fatalf("execute lifecycle bridge: %v", err) } - if response.Status != "queued" || response.Result["jobId"] == "" || response.Result["installationId"] == "" || response.Result["admissionState"] != string(domain.CapacityAdmissionAccepted) { - t.Fatalf("expected Platform-governed lifecycle job, got %+v", response) + if response.Status != "queued" || response.Result["jobId"] == "" || response.Result["installationId"] == "" { + t.Fatalf("expected plugin lifecycle job, got %+v", response) } serialized := strings.ToLower(strings.Join([]string{ response.Result["jobId"], response.Result["installationId"], response.Result["currentState"], - response.Result["desiredState"], response.Result["alertId"], response.Result["auditEventId"], - response.Result["admissionState"], response.Result["admissionReason"], + response.Result["desiredState"], }, " ")) for _, forbidden := range []string{"password", "apikey", "token", "secret://", "baseurl", "hostpath", "socket", "pid", "dsn", "rcon", "runendpoint"} { if strings.Contains(serialized, forbidden) { diff --git a/platform/service/protected_requests.go b/platform/service/protected_requests.go deleted file mode 100644 index 7ddf100..0000000 --- a/platform/service/protected_requests.go +++ /dev/null @@ -1,254 +0,0 @@ -package service - -import ( - "strings" - "sync" - "time" - - "browser.local/platform/domain" - "browser.local/platform/validator" -) - -const protectedRequestMaxTimeoutSeconds = 120 - -type protectedRequestPayload struct { - commandID string - kind string - transportKey string - targetKey string - requestText string - expiresAt time.Time -} - -// protectedRequestBroker keeps opaque request text out of durable jobs and -// bridge records. It releases a payload exactly once to a current Run lease. -type protectedRequestBroker struct { - mu sync.Mutex - now func() time.Time - payloads map[string]protectedRequestPayload -} - -func newProtectedRequestBroker(now func() time.Time) *protectedRequestBroker { - return &protectedRequestBroker{now: now, payloads: map[string]protectedRequestPayload{}} -} - -func (broker *protectedRequestBroker) Put(jobID string, payload protectedRequestPayload) error { - broker.mu.Lock() - defer broker.mu.Unlock() - broker.pruneLocked() - if _, exists := broker.payloads[jobID]; exists { - return validationError("protected request idempotency key is already pending") - } - broker.payloads[jobID] = payload - return nil -} - -func (broker *protectedRequestBroker) Consume(jobID string) (protectedRequestPayload, error) { - broker.mu.Lock() - defer broker.mu.Unlock() - broker.pruneLocked() - payload, exists := broker.payloads[jobID] - if !exists { - return protectedRequestPayload{}, validationError("protected request input is unavailable") - } - delete(broker.payloads, jobID) - return payload, nil -} - -func (broker *protectedRequestBroker) Delete(jobID string) { - broker.mu.Lock() - defer broker.mu.Unlock() - delete(broker.payloads, jobID) -} - -func (broker *protectedRequestBroker) pruneLocked() { - stamp := broker.now() - for jobID, payload := range broker.payloads { - if !stamp.Before(payload.expiresAt) { - delete(broker.payloads, jobID) - } - } -} - -func protectedRequestCapability(kind string) (string, string, error) { - switch kind { - case "sql": - return domain.JobCapabilityRemoteRunProtectedSQL, "protected-sql", nil - case "rcon": - return domain.JobCapabilityRemoteRunProtectedRCON, "protected-rcon", nil - case "program": - return domain.JobCapabilityRemoteRunProgram, "protected-program", nil - default: - return "", "", validationError("protected request kind is unsupported") - } -} - -func redactedProtectedRequestPayload(declaration *domain.GameClientBridgeProtectedRequestDeclaration) map[string]any { - return map[string]any{declaration.TextField: "redacted"} -} - -func (svc *CoreService) dispatchProtectedRequest(command domain.GameClientBridgeCommand, declaration domain.GameClientBridgeCommandDeclaration, payload map[string]any) error { - if declaration.ProtectedRequest == nil || declaration.TimeoutSeconds < 1 || declaration.TimeoutSeconds > protectedRequestMaxTimeoutSeconds { - return validationError("protected request timeout is out of bounds") - } - requestText, _ := payload[declaration.ProtectedRequest.TextField].(string) - capability, adapterKind, err := protectedRequestCapability(declaration.ProtectedRequest.Kind) - if err != nil { - return err - } - jobID := command.RunJobID - if jobID == "" { - return validationError("protected request job binding is missing") - } - executionInput := domain.JobExecutionInput{ - WorkspaceScope: command.ProfileKey, - RemoteAdapterKey: declaration.ProtectedRequest.TransportKey, - RemoteAdapterKind: adapterKind, - TimeoutSeconds: declaration.TimeoutSeconds, - PluginID: command.PluginID, - } - if declaration.ProtectedRequest.Kind == "rcon" { - if resolution, resolveErr := svc.resolveProtectedSourceRCONDispatch(command.ServerInstanceID, declaration.ProtectedRequest); resolveErr == nil { - executionInput.WorkspaceScope = resolution.binding.ProfileKey - executionInput.SourceRCON = resolution.plan - } - } - if err := svc.protectedRequests.Put(jobID, protectedRequestPayload{commandID: command.ID, kind: declaration.ProtectedRequest.Kind, transportKey: declaration.ProtectedRequest.TransportKey, targetKey: declaration.ProtectedRequest.TargetKey, requestText: requestText, expiresAt: command.ExpiresAt}); err != nil { - return err - } - job := domain.Job{ - ID: jobID, - ServerInstanceID: command.ServerInstanceID, - RunEndpointID: mustProtectedRequestRunEndpoint(svc, command.ServerInstanceID), - Capability: capability, - TargetKey: declaration.ProtectedRequest.TargetKey, - InputRef: "input://protected-request/" + command.ID, - IdempotencyKey: "protected-request:" + command.ID, - Progress: domain.JobProgress{Percent: 0, Message: "protected request queued"}, - RetryPolicy: domain.JobRetryPolicy{MaxAttempts: 1, InitialBackoffSeconds: 1, MaxBackoffSeconds: 1}, - ExecutionInput: executionInput, - } - if job.RunEndpointID == "" { - svc.protectedRequests.Delete(jobID) - return validationError("protected request server binding is unavailable") - } - created, err := svc.CreateJob(job) - if err != nil { - svc.protectedRequests.Delete(jobID) - return err - } - if created.ID != jobID || created.ServerInstanceID != job.ServerInstanceID || created.Capability != capability || created.TargetKey != job.TargetKey || created.ExecutionInput.RemoteAdapterKey != job.ExecutionInput.RemoteAdapterKey || created.ExecutionInput.RemoteAdapterKind != adapterKind { - svc.protectedRequests.Delete(jobID) - return validationError("protected request idempotency key is already bound") - } - return nil -} - -func mustProtectedRequestRunEndpoint(svc *CoreService, serverInstanceID string) string { - instance, err := svc.store.ServerInstances().Get(serverInstanceID) - if err != nil { - return "" - } - return instance.RunEndpointID -} - -func (svc *CoreService) GetProtectedRequestExecutionInput(request domain.ProtectedRequestExecutionInputRequest) (domain.ProtectedRequestExecutionInput, error) { - if err := validator.ValidateProtectedRequestExecutionInputRequest(request); err != nil { - return domain.ProtectedRequestExecutionInput{}, err - } - job, err := svc.activeFencedInputJob(request.RunEndpointID, request.SessionToken, request.JobID, request.LeaseToken, request.Attempt) - if err != nil { - return domain.ProtectedRequestExecutionInput{}, err - } - if request.FencingToken != uint64(job.Attempt) || !isProtectedRequestCapability(job.Capability) || job.RetryPolicy.MaxAttempts != 1 || !strings.HasPrefix(job.InputRef, "input://protected-request/") { - return domain.ProtectedRequestExecutionInput{}, validationError("job is not a fenced protected request") - } - commands, err := svc.store.GameClientBridgeCommands().List(domain.GameClientBridgeCommandFilter{ServerInstanceID: job.ServerInstanceID}) - if err != nil { - return domain.ProtectedRequestExecutionInput{}, err - } - var command domain.GameClientBridgeCommand - for _, candidate := range commands { - if candidate.RunJobID == job.ID { - command = candidate - break - } - } - if command.ID == "" || command.ApprovalState != domain.GameClientBridgeApprovalApproved || command.State != domain.GameClientBridgeCommandPending || !command.ExpiresAt.After(svc.now()) { - return domain.ProtectedRequestExecutionInput{}, validationError("protected request is not currently authorized") - } - payload, err := svc.protectedRequests.Consume(job.ID) - if err != nil { - return domain.ProtectedRequestExecutionInput{}, err - } - capability, adapterKind, capabilityErr := protectedRequestCapability(payload.kind) - if capabilityErr != nil || capability != job.Capability || payload.targetKey != job.TargetKey || payload.transportKey != job.ExecutionInput.RemoteAdapterKey || adapterKind != job.ExecutionInput.RemoteAdapterKind { - return domain.ProtectedRequestExecutionInput{}, validationError("protected request logical binding is invalid") - } - return domain.CopyProtectedRequestExecutionInput(domain.ProtectedRequestExecutionInput{JobID: job.ID, ServerInstanceID: job.ServerInstanceID, RunEndpointID: job.RunEndpointID, FencingToken: request.FencingToken, Authorized: true, ApprovalState: "approved", QueueState: "claimed", ExpiresAt: payload.expiresAt, Kind: payload.kind, TransportKey: payload.transportKey, TargetKey: payload.targetKey, RequestText: payload.requestText}), nil -} - -func isProtectedRequestCapability(capability string) bool { - _, _, err := protectedRequestCapabilityForCapability(capability) - return err == nil -} - -func protectedRequestCapabilityForCapability(capability string) (string, string, error) { - switch capability { - case domain.JobCapabilityRemoteRunProtectedSQL: - return "sql", "protected-sql", nil - case domain.JobCapabilityRemoteRunProtectedRCON: - return "rcon", "protected-rcon", nil - case domain.JobCapabilityRemoteRunProgram: - return "program", "protected-program", nil - default: - return "", "", validationError("job is not a protected request") - } -} - -func (svc *CoreService) projectProtectedRequestJobResult(job domain.Job, result domain.RunJobResult, stamp time.Time) error { - if !isProtectedRequestCapability(job.Capability) { - return nil - } - svc.protectedRequests.Delete(job.ID) - svc.bridgeMu.Lock() - defer svc.bridgeMu.Unlock() - commands, err := svc.store.GameClientBridgeCommands().List(domain.GameClientBridgeCommandFilter{ServerInstanceID: job.ServerInstanceID}) - if err != nil { - return err - } - for _, command := range commands { - if command.RunJobID != job.ID || isTerminalGameClientBridgeCommandState(command.State) { - continue - } - switch result.State { - case domain.JobStateSucceeded: - command.State = domain.GameClientBridgeCommandSucceeded - command.Result.Status = domain.GameClientBridgeResultSucceeded - case domain.JobStateCancelled: - command.State = domain.GameClientBridgeCommandCancelled - command.Result.Status = domain.GameClientBridgeResultCancelled - case domain.JobStateFailed: - command.State = domain.GameClientBridgeCommandFailed - command.Result.Status = domain.GameClientBridgeResultFailed - if result.ErrorCode == "protected_request_unknown" || strings.HasSuffix(result.ExecutionResult.Kind, ".unknown") { - command.State = domain.GameClientBridgeCommandUnknown - command.Result.Status = domain.GameClientBridgeResultUnknown - } - default: - return nil - } - command.Result.Summary = "protected request completed by Run" - command.Result.CompletedBy = "run" - command.Result.CompletedAt = stamp - command.CompletedAt = stamp - command.UpdatedAt = stamp - auditID, auditErr := svc.recordAuditEventWithID("run", "game-client-bridge.command.result", "game-client-bridge-command", command.ID, domain.AuditResultSuccess, "Run recorded protected bridge command result") - if auditErr != nil { - return auditErr - } - command.AuditReferences = append(command.AuditReferences, auditID) - return svc.store.GameClientBridgeCommands().Update(command) - } - return nil -} diff --git a/platform/service/remote_adapters.go b/platform/service/remote_adapters.go index b2ccbd6..5424877 100644 --- a/platform/service/remote_adapters.go +++ b/platform/service/remote_adapters.go @@ -66,8 +66,6 @@ func (svc *CoreService) RequestRemoteAdapterForSession(sessionID string, request selected = domain.RemoteAdapterDeclaration{Key: "legacy-" + string(remoteAdapterKindForCapability(request.Capability)), Kind: remoteAdapterKindForCapability(request.Capability), TargetKeys: []string{request.TargetKey}, Capabilities: []string{request.Capability}, TimeoutSeconds: 30, MaxAttempts: 3} } if selected.Key == "" { - user, _ := svc.GetCurrentUser(sessionID) - _ = svc.recordAuditEvent(user.ID, "remote-adapter.authorize", "server-instance", instance.ID, domain.AuditResultDenied, "remote adapter declaration, target, or capability was not approved") return domain.RemoteAdapterResult{}, ErrForbidden } } @@ -102,15 +100,7 @@ func (svc *CoreService) RequestRemoteAdapterForSession(sessionID string, request if err != nil { return domain.RemoteAdapterResult{}, err } - user, err := svc.GetCurrentUser(sessionID) - if err != nil { - return domain.RemoteAdapterResult{}, err - } - auditID, err := svc.recordAuditEventWithID(user.ID, "remote-adapter.authorize", "server-instance", instance.ID, domain.AuditResultQueued, "authorized declared remote adapter target with bounded timeout and retry") - if err != nil { - return domain.RemoteAdapterResult{}, err - } - return domain.RemoteAdapterResult{RequestID: created.ID, ServerInstanceID: instance.ID, DeclarationKey: selected.Key, TargetKey: request.TargetKey, Kind: selected.Kind, Status: string(created.State), Retryable: attempts > 1, Message: "scoped remote adapter queued", ResultRef: "job://" + created.ID, AuditEventID: auditID}, nil + return domain.RemoteAdapterResult{RequestID: created.ID, ServerInstanceID: instance.ID, DeclarationKey: selected.Key, TargetKey: request.TargetKey, Kind: selected.Kind, Status: string(created.State), Retryable: attempts > 1, Message: "scoped remote adapter queued", ResultRef: "job://" + created.ID}, nil } func intersectRemoteCapabilities(profile []string, declared []string, endpoint []string) []string { diff --git a/platform/service/resources.go b/platform/service/resources.go index 4ca8057..4b7d252 100644 --- a/platform/service/resources.go +++ b/platform/service/resources.go @@ -107,12 +107,6 @@ type Core interface { DeleteServerInstanceForSession(string, string, domain.ServerDeletionRequest) (domain.ServerInstance, error) GetPlatformResourceUsage() (domain.PlatformResourceUsage, error) ListServerMetricsForSession(string) ([]domain.ServerMetrics, error) - GetProductionCapacityForSession(string) (domain.ProductionCapacitySummary, error) - CheckCapacityAdmissionForSession(string, domain.CapacityAdmissionRequest) (domain.CapacityAdmissionDecision, error) - ListAlertsForSession(string, domain.AlertFilter) ([]domain.AlertRecord, error) - AcknowledgeAlertForSession(string, domain.AlertAcknowledgeRequest) (domain.AlertRecord, error) - ResolveAlertForSession(string, domain.AlertResolveRequest) (domain.AlertRecord, error) - RetryAlertForSession(string, domain.AlertRetryRequest) (domain.AlertRetryResult, error) ListPluginLifecyclesForSession(string, domain.PluginLifecycleFilter) ([]domain.PluginLifecycleInstallation, error) RunPluginLifecycleForSession(string, domain.PluginLifecycleRequest) (domain.PluginLifecycleResult, error) ListAIConfigDiffsForSession(string, domain.AIConfigDiffFilter) ([]domain.AIConfigDiffPreview, error) @@ -144,7 +138,6 @@ type Core interface { GetDependencyExecutionInput(domain.DependencyExecutionInputRequest) (domain.DependencyExecutionInput, error) DispatchSourceRCONCommandForSession(string, domain.SourceRCONCommandRequest) (domain.SourceRCONCommandDispatch, error) GetSourceRCONExecutionInput(domain.SourceRCONExecutionInputRequest) (domain.SourceRCONExecutionInput, error) - GetProtectedRequestExecutionInput(domain.ProtectedRequestExecutionInputRequest) (domain.ProtectedRequestExecutionInput, error) GetRunUpdateInput(domain.RunUpdateInputRequest) (domain.RunUpdateInput, error) ReadRunUpdateChunk(domain.RunUpdateChunkRequest) (domain.RunUpdateChunk, error) ReportRunUpdateHealth(domain.RunUpdateHealthReport) (domain.RunUpdateHealthResult, error) @@ -215,9 +208,6 @@ type Core interface { IngestLogBatch(domain.LogBatchIngest) (domain.LogBatchIngestResult, error) GetRunLogStreamProgress(domain.RunLogStreamProgress) (domain.RunLogStreamProgressResult, error) QueryLogStream(domain.LogStreamCursorQuery) (domain.LogStreamCursorResult, error) - CreateAuditEvent(domain.AuditEvent) (domain.AuditEvent, error) - GetAuditEvent(string) (domain.AuditEvent, error) - ListAuditEvents(domain.AuditEventFilter) ([]domain.AuditEvent, error) SeedPlatformAdmin(string, string) error } @@ -244,11 +234,8 @@ type CoreService struct { artifactTransfers map[string]domain.ArtifactTransferSession artifactPayloads map[string][]byte artifactTransferSeq uint64 - auditMu sync.Mutex - auditSeq uint64 productionMu sync.Mutex sourceRCONCommands *sourceRCONCommandBroker - protectedRequests *protectedRequestBroker aiProviderClient AIProviderClient secretEnvelope SecretEnvelope networkFingerprintKey []byte @@ -288,7 +275,6 @@ func newCoreServiceWithLogStore(store repo.Store, logStore LogBodyStore, now fun artifactTransfers: map[string]domain.ArtifactTransferSession{}, artifactPayloads: map[string][]byte{}, sourceRCONCommands: newSourceRCONCommandBroker(now), - protectedRequests: newProtectedRequestBroker(now), aiProviderClient: MockAIProviderClient{}, secretEnvelope: newSecretEnvelope(developmentSecretEnvelopeKey), networkFingerprintKey: []byte(developmentSecretEnvelopeKey), @@ -703,23 +689,6 @@ func (svc *CoreService) TestAIProvider(id string) (domain.AIProviderTestResult, result.Success = false result.Message = "provider invocation failed safely" result.Violations = []string{"provider invocation failed safely"} - auditID, auditErr := svc.recordAuditEventWithID("platform", "ai.provider.test.failed", "ai-provider", provider.ID, domain.AuditResultFailed, result.Message) - if auditErr != nil { - return domain.AIProviderTestResult{}, auditErr - } - svc.productionMu.Lock() - _, alertErr := svc.upsertAlert(domain.AlertRecord{SourceKind: "ai-provider", SourceID: provider.ID, RuleKey: "ai.provider.failed", Severity: domain.AlertSeverityWarning, Title: "AI provider health check failed", Message: result.Message, Retryable: false, LastAuditEventID: auditID}) - svc.productionMu.Unlock() - if alertErr != nil { - return domain.AIProviderTestResult{}, alertErr - } - } else { - svc.productionMu.Lock() - resolveErr := svc.resolveAlertForSource("ai-provider", provider.ID, "ai.provider.failed", "platform", "AI provider health check passed", "") - svc.productionMu.Unlock() - if resolveErr != nil { - return domain.AIProviderTestResult{}, resolveErr - } } return domain.CopyAIProviderTestResult(result), nil } @@ -1020,7 +989,7 @@ func (svc *CoreService) executeBridgePluginLifecycle(sessionID string, base doma return bridgeExecutionError(base, err) } base.Status = result.Status - base.Result = map[string]string{"installationId": result.Installation.ID, "currentState": string(result.Installation.CurrentState), "desiredState": string(result.Installation.DesiredState), "jobId": result.Job.ID, "alertId": result.Installation.AlertID, "auditEventId": result.Installation.AuditEventID, "admissionState": string(result.Decision.State), "admissionReason": result.Decision.Reason} + base.Result = map[string]string{"installationId": result.Installation.ID, "currentState": string(result.Installation.CurrentState), "desiredState": string(result.Installation.DesiredState), "jobId": result.Job.ID} return base } @@ -2668,27 +2637,6 @@ func (svc *CoreService) ListLogStreams(filter domain.LogStreamFilter) ([]domain. return svc.store.LogStreams().List(filter) } -func (svc *CoreService) CreateAuditEvent(event domain.AuditEvent) (domain.AuditEvent, error) { - if event.CreatedAt.IsZero() { - event.CreatedAt = svc.now() - } - if err := validator.ValidateAuditEvent(event); err != nil { - return domain.AuditEvent{}, err - } - if err := svc.store.AuditEvents().Create(event); err != nil { - return domain.AuditEvent{}, err - } - return domain.CopyAuditEvent(event), nil -} - -func (svc *CoreService) GetAuditEvent(id string) (domain.AuditEvent, error) { - return svc.store.AuditEvents().Get(id) -} - -func (svc *CoreService) ListAuditEvents(filter domain.AuditEventFilter) ([]domain.AuditEvent, error) { - return svc.store.AuditEvents().List(filter) -} - func (svc *CoreService) validateRunnableEndpoint(endpoint domain.RunEndpoint, capability string) error { if endpoint.Status != domain.RunEndpointStatusOnline && endpoint.Status != domain.RunEndpointStatusDegraded { return validationError("run endpoint must be online or degraded") diff --git a/platform/service/resources_test.go b/platform/service/resources_test.go index 4653c05..ffdbd15 100644 --- a/platform/service/resources_test.go +++ b/platform/service/resources_test.go @@ -163,28 +163,6 @@ func TestCoreServiceCreateListGetWorkflows(t *testing.T) { t.Fatalf("list log streams: len=%d err=%v", len(streams), err) } - audit, err := svc.CreateAuditEvent(domain.AuditEvent{ - ID: "audit-1", - ActorID: user.ID, - Action: "server.create", - ResourceKind: "server-instance", - ResourceID: instance.ID, - Result: domain.AuditResultSuccess, - Summary: "created server instance", - }) - if err != nil { - t.Fatalf("create audit event: %v", err) - } - if !audit.CreatedAt.Equal(fixedTime) { - t.Fatalf("expected audit timestamp default, got %+v", audit) - } - if _, err := svc.GetAuditEvent(audit.ID); err != nil { - t.Fatalf("get audit event: %v", err) - } - auditEvents, err := svc.ListAuditEvents(domain.AuditEventFilter{ResourceID: instance.ID}) - if err != nil || len(auditEvents) != 1 { - t.Fatalf("list audit events: len=%d err=%v", len(auditEvents), err) - } } func TestCoreServiceCreateRemoteProgramJobCreatesManagementLogStreams(t *testing.T) { @@ -1070,7 +1048,7 @@ func TestConfigWriteTerminalResultAppliesDurableTypedProjection(t *testing.T) { t.Fatalf("claim typed config job: claim=%+v err=%v", claim, err) } checksum := validator.BytesChecksum([]byte(proposed)) - if _, err := svc.CompleteRunJob(domain.RunJobResult{RunEndpointID: endpoint.ID, SessionToken: hello.SessionToken, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt, State: domain.JobStateSucceeded, Progress: domain.RunJobProgressReport{Percent: 100, Message: "config write completed"}, Message: "config write completed", ExecutionResult: domain.JobExecutionResult{Kind: "file.write", Version: dispatch.Job.ExecutionInput.ExpectedVersion + 1, Checksum: checksum, SizeBytes: int64(len(proposed)), AuditSummary: "atomic compare-and-swap file write"}}); err != nil { + if _, err := svc.CompleteRunJob(domain.RunJobResult{RunEndpointID: endpoint.ID, SessionToken: hello.SessionToken, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt, State: domain.JobStateSucceeded, Progress: domain.RunJobProgressReport{Percent: 100, Message: "config write completed"}, Message: "config write completed", ExecutionResult: domain.JobExecutionResult{Kind: "file.write", Version: dispatch.Job.ExecutionInput.ExpectedVersion + 1, Checksum: checksum, SizeBytes: int64(len(proposed)), Summary: "atomic compare-and-swap file write"}}); err != nil { t.Fatalf("complete typed config job: %v", err) } updated, err := svc.GetServerConfigForSession(ownerSession, instance.ID) diff --git a/platform/service/server_deployment_test.go b/platform/service/server_deployment_test.go index f63b8b5..18854c0 100644 --- a/platform/service/server_deployment_test.go +++ b/platform/service/server_deployment_test.go @@ -202,7 +202,7 @@ func TestCoreServiceGuidedPluginLifecycleSuccessDoesNotRequireExecutionReceipt(t if _, err := svc.CompleteRunJob(domain.RunJobResult{ RunEndpointID: "run-local", SessionToken: sessionToken, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt, State: domain.JobStateSucceeded, Progress: domain.RunJobProgressReport{Percent: 100, Message: "done"}, Message: "done", ResultRef: "artifact://jobs/guided-success/lifecycle-result", - ExecutionResult: domain.JobExecutionResult{Kind: "process", ProcessState: "running", AuditSummary: "bounded process state"}, + ExecutionResult: domain.JobExecutionResult{Kind: "process", ProcessState: "running", Summary: "bounded process state"}, }); err != nil { t.Fatalf("guided plugin lifecycle success without receipt should be terminal: %v", err) } diff --git a/platform/service/server_lifecycle_projection.go b/platform/service/server_lifecycle_projection.go index f27ac01..11d521e 100644 --- a/platform/service/server_lifecycle_projection.go +++ b/platform/service/server_lifecycle_projection.go @@ -52,13 +52,6 @@ func (svc *CoreService) ReportRunLifecycle(report domain.RunLifecycleReport) (do } svc.publishLogProcessState(instance) } - auditResult := domain.AuditResultSuccess - if report.State == domain.JobStateFailed || report.State == domain.JobStateCancelled { - auditResult = domain.AuditResultFailed - } - if err := svc.recordAuditEvent("run:"+report.RunEndpointID, "lifecycle.report", "server-instance", instance.ID, auditResult, lifecycleReportSummary(report, nextState, projected)); err != nil { - return domain.RunLifecycleReportResult{}, err - } return domain.CopyRunLifecycleReportResult(domain.RunLifecycleReportResult{Accepted: true, RunEndpointID: report.RunEndpointID, ServerInstanceID: report.ServerInstanceID, ProjectedState: nextState, ServerTime: stamp}), nil } @@ -72,40 +65,17 @@ func lifecycleObservationIsStale(instance domain.ServerInstance, report domain.R return !report.ObservedAt.IsZero() && !instance.LifecycleObservedAt.IsZero() && report.ObservedAt.Before(instance.LifecycleObservedAt) } -func lifecycleReportSummary(report domain.RunLifecycleReport, projectedState domain.ServerInstanceState, projected bool) string { - for _, candidate := range []string{report.ExecutionResult.AuditSummary, report.Progress.Message, report.Message, report.ErrorCode} { - if strings.TrimSpace(candidate) != "" { - return candidate - } - } - if projected { - return "run reported " + report.Capability + " " + string(report.State) + "; projected server state " + string(projectedState) - } - return "run reported " + report.Capability + " " + string(report.State) -} - func (svc *CoreService) projectRemoteAdapterJobResult(job domain.Job, stamp time.Time) error { if !strings.HasPrefix(job.Capability, "remote.") || job.ServerInstanceID == "" || !isTerminalJobState(job.State) { return nil } - result := domain.AuditResultSuccess - if job.State == domain.JobStateFailed || job.State == domain.JobStateCancelled { - result = domain.AuditResultFailed - } - summary := "remote adapter " + job.Capability + " completed with bounded result reference" - if job.State == domain.JobStateFailed { - summary = "remote adapter " + job.Capability + " failed or timed out; retry/fencing remained platform-owned" - } - if job.State == domain.JobStateCancelled { - summary = "remote adapter " + job.Capability + " was cancelled before terminal projection" - } - return svc.recordAuditEvent("run:"+job.RunEndpointID, "remote-adapter.result", "server-instance", job.ServerInstanceID, result, summary) + return nil } func (svc *CoreService) projectLifecycleJobResult(job domain.Job, stamp time.Time) error { if job.Capability == domain.JobCapabilityConfigWrite { if job.State != domain.JobStateSucceeded { - return svc.recordAuditEvent("run:"+job.RunEndpointID, "config.write.result", "server-instance", job.ServerInstanceID, domain.AuditResultFailed, job.ExecutionResult.AuditSummary) + return nil } instance, err := svc.store.ServerInstances().Get(job.ServerInstanceID) if err != nil { @@ -123,7 +93,7 @@ func (svc *CoreService) projectLifecycleJobResult(job domain.Job, stamp time.Tim if err := svc.store.ServerInstances().Update(instance); err != nil { return err } - return svc.recordAuditEvent("run:"+job.RunEndpointID, "config.write.result", "server-instance", instance.ID, domain.AuditResultSuccess, job.ExecutionResult.AuditSummary) + return nil } nextState, ok := lifecycleProjectedState(job.Capability, job.State, job.ExecutionResult) if !ok || job.ServerInstanceID == "" { @@ -142,11 +112,7 @@ func (svc *CoreService) projectLifecycleJobResult(job domain.Job, stamp time.Tim return err } svc.publishLogProcessState(instance) - auditResult := domain.AuditResultSuccess - if job.State == domain.JobStateFailed || job.State == domain.JobStateCancelled { - auditResult = domain.AuditResultFailed - } - return svc.recordAuditEvent("run:"+job.RunEndpointID, "lifecycle.result", "server-instance", instance.ID, auditResult, job.Progress.Message) + return nil } func (svc *CoreService) projectServerDeploymentProgress(job domain.Job, stamp time.Time) error { diff --git a/platform/service/server_lifecycle_test.go b/platform/service/server_lifecycle_test.go index a36e68a..7a8d6a6 100644 --- a/platform/service/server_lifecycle_test.go +++ b/platform/service/server_lifecycle_test.go @@ -515,9 +515,9 @@ func claimAndCompleteLifecycleJobForServer(t *testing.T, svc *CoreService, sessi if state == domain.JobStateSucceeded { switch capability { case domain.LifecycleCapabilityStart, domain.LifecycleCapabilityStatus: - executionResult = domain.JobExecutionResult{Kind: "process", ProcessState: "running", AuditSummary: "bounded process state"} + executionResult = domain.JobExecutionResult{Kind: "process", ProcessState: "running", Summary: "bounded process state"} case domain.LifecycleCapabilityStop: - executionResult = domain.JobExecutionResult{Kind: "process", ProcessState: "stopped", ExitClassification: "requested-stop", AuditSummary: "bounded process state"} + executionResult = domain.JobExecutionResult{Kind: "process", ProcessState: "stopped", ExitClassification: "requested-stop", Summary: "bounded process state"} } } if _, err := svc.CompleteRunJob(domain.RunJobResult{ diff --git a/platform/service/source_rcon.go b/platform/service/source_rcon.go index 01bd3b8..a05df71 100644 --- a/platform/service/source_rcon.go +++ b/platform/service/source_rcon.go @@ -235,62 +235,6 @@ func sourceRCONTransportForCapability(profiles domain.GamePluginRuntimeProfiles, return selected, nil } -func (svc *CoreService) resolveProtectedSourceRCONDispatch(serverInstanceID string, request *domain.GameClientBridgeProtectedRequestDeclaration) (sourceRCONDispatchResolution, error) { - if request == nil || request.Kind != "rcon" { - return sourceRCONDispatchResolution{}, validationError("protected request is not RCON") - } - instance, err := svc.store.ServerInstances().Get(serverInstanceID) - if err != nil { - return sourceRCONDispatchResolution{}, err - } - plugin, err := svc.store.GamePlugins().Get(instance.PluginID) - if err != nil { - return sourceRCONDispatchResolution{}, err - } - capability := domain.JobCapabilityRemoteRunProtectedRCON - if plugin.Status != domain.GamePluginStatusInstalled || plugin.Version != instance.PluginVersion || !plugin.Permissions.RemoteAccess || !plugin.RemoteAccess.RCON || !containsString(plugin.RequiredRunCapabilities, capability) || !containsString(plugin.RemoteAccess.RunCapabilities, capability) { - return sourceRCONDispatchResolution{}, forbiddenError("plugin does not declare protected SCUM RCON access") - } - endpoint, err := svc.store.RunEndpoints().Get(instance.RunEndpointID) - if err != nil { - return sourceRCONDispatchResolution{}, err - } - if err := svc.validateRunnableEndpoint(endpoint, capability); err != nil { - return sourceRCONDispatchResolution{}, err - } - if !strings.EqualFold(endpoint.Platform, "windows") || !strings.EqualFold(endpoint.Architecture, "amd64") { - return sourceRCONDispatchResolution{}, validationError("unsupported_extension_platform: SCUM Source RCON requires windows/amd64") - } - binding, err := svc.runtimeBindingForServer(instance.ID) - if err != nil { - return sourceRCONDispatchResolution{}, err - } - binding, err = normalizeRuntimeBinding(plugin, binding) - if err != nil { - return sourceRCONDispatchResolution{}, err - } - if binding.Status != domain.RuntimeBindingStatusComplete || binding.PluginVersion != plugin.Version { - return sourceRCONDispatchResolution{}, validationError("runtime binding is incomplete or stale") - } - profile, exists := runtimeLifecycleProfileForKey(plugin.RuntimeProfiles, binding.ProfileKey) - if !exists || !containsString(profile.Capabilities, capability) || !runtimePlatformsContain(profile.Platforms, "windows") { - return sourceRCONDispatchResolution{}, validationError("selected runtime profile does not support protected SCUM RCON") - } - transport, err := sourceRCONTransportForCapability(plugin.RuntimeProfiles, profile, request.TransportKey, capability) - if err != nil { - return sourceRCONDispatchResolution{}, err - } - if transport.TargetKey != request.TargetKey { - return sourceRCONDispatchResolution{}, validationError("protected RCON transport target is invalid") - } - extension, err := sourceRCONExtension(plugin.RuntimeProfiles, profile, endpoint) - if err != nil { - return sourceRCONDispatchResolution{}, err - } - plan := &domain.RuntimeSourceRCONPlan{Protocol: "source-rcon", ExtensionKey: extension.Key, ModKey: extension.ModKey, ConfigRef: "ue4ss/Mods/" + extension.ModKey + "/config.ini", DeploymentStateRef: "runtime/ue4ss-dll/" + extension.TargetKey + "/release.json", Port: extension.RCONPort} - return sourceRCONDispatchResolution{plugin: plugin, binding: binding, transport: transport, plan: plan}, nil -} - func sourceRCONExtension(profiles domain.GamePluginRuntimeProfiles, profile domain.RuntimeLifecycleProfile, endpoint domain.RunEndpoint) (domain.RuntimeDLLExtensionProfile, error) { byKey := make(map[string]domain.RuntimeDLLExtensionProfile, len(profiles.DLLExtensions)) for _, extension := range profiles.DLLExtensions { diff --git a/platform/service/source_rcon_test.go b/platform/service/source_rcon_test.go index acdf175..e99f461 100644 --- a/platform/service/source_rcon_test.go +++ b/platform/service/source_rcon_test.go @@ -80,68 +80,6 @@ func TestSourceRCONDispatchUsesOneTimeRedactedInput(t *testing.T) { if strings.Contains(string(storedJSON), input.Command) || strings.Contains(string(storedJSON), request.Message) { t.Fatalf("consumed command was persisted: %s", storedJSON) } - if events, err := svc.store.AuditEvents().List(domain.AuditEventFilter{ResourceID: instance.ID}); err != nil || len(events) != 0 { - t.Fatalf("RCON command must not add an audit event, events=%+v err=%v", events, err) - } -} - -func TestProtectedRCONBridgeDispatchCarriesSourceRCONPlan(t *testing.T) { - svc, _, _, instance := newSourceRCONFixture(t) - plugin, err := svc.store.GamePlugins().Get(instance.PluginID) - if err != nil { - t.Fatal(err) - } - protectedCapability := domain.JobCapabilityRemoteRunProtectedRCON - plugin.RequiredRunCapabilities = append(plugin.RequiredRunCapabilities, protectedCapability) - plugin.RemoteAccess.RunCapabilities = append(plugin.RemoteAccess.RunCapabilities, protectedCapability) - plugin.RuntimeProfiles.ClientManagers = []domain.RuntimeClientManagerProfile{{Key: "scum-client-manager", Health: domain.RuntimeClientManagerHealth{RequiredCapabilities: []string{gameClientBridgeCapability}}}} - plugin.RuntimeProfiles.LifecycleProfiles[0].Capabilities = append(plugin.RuntimeProfiles.LifecycleProfiles[0].Capabilities, protectedCapability) - plugin.RuntimeProfiles.LifecycleProfiles[0].TransportKeys = append(plugin.RuntimeProfiles.LifecycleProfiles[0].TransportKeys, "scum-management") - plugin.RuntimeProfiles.TransportProfiles = append(plugin.RuntimeProfiles.TransportProfiles, domain.RuntimeTransportProfile{Key: "scum-management", Kind: "rcon", TargetKey: "scum-management", Capabilities: []string{protectedCapability}}) - plugin.GameClientBridge.Commands = append(plugin.GameClientBridge.Commands, domain.GameClientBridgeCommandDeclaration{Type: "management.rcon.request", ApprovalLevel: domain.GameClientBridgeApprovalLevelOperator, TimeoutSeconds: 120, MaxPayloadBytes: 8192, ProtectedRequest: &domain.GameClientBridgeProtectedRequestDeclaration{Kind: "rcon", TransportKey: "scum-management", TargetKey: "scum-management", TextField: "requestText", MaxTextBytes: 8192}}) - if err := svc.store.GamePlugins().Update(plugin); err != nil { - t.Fatal(err) - } - binding, err := svc.buildRuntimeBinding(instance, plugin, domain.RuntimeBindingUpdate{ProfileKey: "local", Bindings: map[string]string{"rcon": "runtime-rcon", "scum-management": "runtime-rcon"}}, true) - if err != nil { - t.Fatalf("refresh protected RCON binding: %v", err) - } - if err := svc.store.RuntimeBindings().Update(binding); err != nil { - t.Fatalf("store protected RCON binding: %v", err) - } - endpoint, err := svc.store.RunEndpoints().Get(instance.RunEndpointID) - if err != nil { - t.Fatal(err) - } - endpoint.Capabilities = append(endpoint.Capabilities, protectedCapability) - if err := svc.store.RunEndpoints().Update(endpoint); err != nil { - t.Fatal(err) - } - if _, err := svc.resolveProtectedSourceRCONDispatch(instance.ID, plugin.GameClientBridge.Commands[len(plugin.GameClientBridge.Commands)-1].ProtectedRequest); err != nil { - t.Fatalf("resolve protected Source RCON plan: %v", err) - } - - command, err := svc.queueGameClientBridgeCommand("user-rcon-owner", domain.GameClientBridgeQueueRequest{ServerInstanceID: instance.ID, PluginID: plugin.ID, ProfileKey: "scum-client-manager", CommandType: "management.rcon.request", Payload: map[string]any{"requestText": "#ListPlayers"}, IdempotencyKey: "protected-rcon-1", ExpiresAt: fixedTime.Add(time.Minute)}) - if err != nil { - t.Fatalf("queue protected RCON: %v", err) - } - job, err := svc.store.Jobs().Get(command.RunJobID) - if err != nil { - t.Fatalf("get protected RCON job: %v", err) - } - if job.Capability != protectedCapability || job.InputRef == "" || !strings.HasPrefix(job.InputRef, "input://protected-request/") || job.ExecutionInput.SourceRCON == nil { - t.Fatalf("expected protected RCON job with frozen Source RCON plan, got %+v", job) - } - if job.ExecutionInput.WorkspaceScope != "local" || job.ExecutionInput.RemoteAdapterKey != "scum-management" || job.ExecutionInput.RemoteAdapterKind != "protected-rcon" || job.ExecutionInput.SourceRCON.Port != 27015 { - t.Fatalf("protected RCON plan did not preserve logical runtime binding: %+v", job.ExecutionInput) - } - serialized, err := json.Marshal(job) - if err != nil { - t.Fatal(err) - } - if strings.Contains(string(serialized), "#ListPlayers") || strings.Contains(string(serialized), "password=") { - t.Fatalf("protected RCON job leaked transient input: %s", serialized) - } } func TestSourceRCONDispatchRejectsUnsafeOrIncompatibleState(t *testing.T) { diff --git a/platform/validator/game_client_bridge_log_projection_test.go b/platform/validator/game_client_bridge_log_projection_test.go index 910ec70..5bafe37 100644 --- a/platform/validator/game_client_bridge_log_projection_test.go +++ b/platform/validator/game_client_bridge_log_projection_test.go @@ -9,10 +9,6 @@ import ( func TestValidateGameClientBridgeLogProjectionDeclaration(t *testing.T) { bridge := domain.GameClientBridgeManifest{ - Commands: []domain.GameClientBridgeCommandDeclaration{{ - Type: "announcement.send", Title: "Send announcement", Permission: "server.game-client.command", ApprovalLevel: domain.GameClientBridgeApprovalLevelNone, - PayloadSchemaRef: "schemas/bridge/announcement.schema.json", TimeoutSeconds: 60, MaxPayloadBytes: 4096, - }}, LogProjections: []domain.GameClientBridgeLogProjectionDeclaration{{ Key: "player.login", StreamKeys: []string{"process.stdout"}, CorrelationFields: []string{"slot"}, MaxInterveningLines: 16, Steps: []domain.GameClientBridgeLogProjectionStepDeclaration{ @@ -23,7 +19,6 @@ func TestValidateGameClientBridgeLogProjectionDeclaration(t *testing.T) { Presence: &domain.GameClientBridgeLogProjectionPresenceDeclaration{ TimestampField: "lastLoginAt", ActiveWindowSeconds: 600, ActivityTarget: &domain.GameClientBridgeLogProjectionTargetDeclaration{Collection: "scum_activity", UpsertKeys: []string{"steamId"}, CaptureMappings: map[string]string{"steamId": "steamId"}, ObservedAtField: "observedAt"}, - Announcement: domain.GameClientBridgeLogProjectionAnnouncementDeclaration{ProfileKey: "scum-client", CommandType: "announcement.send", TextField: "message", NewTextTemplate: "welcome {{name}}", ReturningTextTemplate: "welcome back {{name}}"}, }, }}, Retention: domain.GameClientBridgeRetention{KeepForSeconds: 86400, MaxRecords: 1000}, @@ -44,11 +39,11 @@ func TestValidateGameClientBridgeLogProjectionDeclaration(t *testing.T) { {name: "missing capture", expected: "references undeclared capture missing", mutate: func(value *domain.GameClientBridgeManifest, _ *domain.GamePluginRuntimeProfiles) { value.LogProjections[0].Target.CaptureMappings["steamId"] = "missing" }}, - {name: "missing profile", expected: "must reference a declared game-client bridge profile", mutate: func(_ *domain.GameClientBridgeManifest, value *domain.GamePluginRuntimeProfiles) { - value.ClientManagers = nil + {name: "invalid timestamp field", expected: "presence.timestampField must reference", mutate: func(value *domain.GameClientBridgeManifest, _ *domain.GamePluginRuntimeProfiles) { + value.LogProjections[0].Presence.TimestampField = "missingAt" }}, - {name: "missing command", expected: "must reference a declared command", mutate: func(value *domain.GameClientBridgeManifest, _ *domain.GamePluginRuntimeProfiles) { - value.LogProjections[0].Presence.Announcement.CommandType = "missing.command" + {name: "invalid activity target", expected: "presence.activityTarget.upsertKeys field missing is not projected", mutate: func(value *domain.GameClientBridgeManifest, _ *domain.GamePluginRuntimeProfiles) { + value.LogProjections[0].Presence.ActivityTarget.UpsertKeys = []string{"missing"} }}, } for _, test := range tests { diff --git a/platform/validator/game_client_bridge_test.go b/platform/validator/game_client_bridge_test.go index 6431415..10716ab 100644 --- a/platform/validator/game_client_bridge_test.go +++ b/platform/validator/game_client_bridge_test.go @@ -12,7 +12,7 @@ import ( ) func validBridgeQueueRequest() domain.GameClientBridgeQueueRequest { - return domain.GameClientBridgeQueueRequest{ServerInstanceID: "server-1", PluginID: "game.scum", ProfileKey: "scum-client", CommandType: "announcement.send", Payload: map[string]any{"message": "hello"}, IdempotencyKey: "announce-1", ExpiresAt: time.Now().UTC().Add(time.Minute)} + return domain.GameClientBridgeQueueRequest{ServerInstanceID: "server-1", PluginID: "game.scum", ProfileKey: "scum-client", CommandType: "diagnostic.ping", Payload: map[string]any{"message": "hello"}, IdempotencyKey: "diag-1", ExpiresAt: time.Now().UTC().Add(time.Minute)} } func TestValidateGameClientBridgeRequests(t *testing.T) { diff --git a/platform/validator/job_channel.go b/platform/validator/job_channel.go index c2ea5ed..99de394 100644 --- a/platform/validator/job_channel.go +++ b/platform/validator/job_channel.go @@ -98,14 +98,6 @@ func ValidateSourceRCONExecutionInputRequest(request domain.SourceRCONExecutionI return finish(appendLeaseFields(nil, request.RunEndpointID, request.SessionToken, request.JobID, request.LeaseToken, request.Attempt)) } -func ValidateProtectedRequestExecutionInputRequest(request domain.ProtectedRequestExecutionInputRequest) error { - violations := appendLeaseFields(nil, request.RunEndpointID, request.SessionToken, request.JobID, request.LeaseToken, request.Attempt) - if request.FencingToken == 0 { - violations = append(violations, "fencingToken is required") - } - return finish(violations) -} - func ValidateRunUpdateInputRequest(request domain.RunUpdateInputRequest) error { return finish(appendLeaseFields(nil, request.RunEndpointID, request.SessionToken, request.JobID, request.LeaseToken, request.Attempt)) } diff --git a/platform/validator/production_ops.go b/platform/validator/production_ops.go index 4aab15e..b201281 100644 --- a/platform/validator/production_ops.go +++ b/platform/validator/production_ops.go @@ -1,93 +1,11 @@ package validator import ( - "fmt" "strings" "browser.local/platform/domain" ) -func ValidateCapacityAdmissionRequest(request domain.CapacityAdmissionRequest) error { - var violations []string - violations = appendRequired(violations, "capability", request.Capability) - if request.ServerInstanceID != "" && !safeIdentifier(request.ServerInstanceID) { - violations = append(violations, "serverInstanceId is invalid") - } - if request.RunEndpointID != "" && !safeIdentifier(request.RunEndpointID) { - violations = append(violations, "runEndpointId is invalid") - } - if request.TargetKey != "" && !validLogicalFileKey(request.TargetKey) { - violations = append(violations, "targetKey is invalid") - } - if unsafeProductionText(request.Capability) || unsafeProductionText(request.IdempotencyKey) { - violations = append(violations, "capacity request contains unsafe content") - } - return finish(violations) -} - -func ValidateCapacityAdmissionDecision(decision domain.CapacityAdmissionDecision) error { - var violations []string - if !validCapacityAdmissionState(decision.State) { - violations = append(violations, "state is invalid") - } - violations = appendRequired(violations, "reason", decision.Reason) - if len(decision.Reason) > maxProductionMessageLength || unsafeProductionText(decision.Reason) { - violations = append(violations, "reason is unsafe") - } - for i, code := range decision.PressureCodes { - if !validCapacityPressureCode(code) { - violations = append(violations, fmt.Sprintf("pressureCodes[%d] is invalid", i)) - } - } - if decision.RunningJobs < 0 || decision.QueuedJobs < 0 || decision.MaxJobs < 0 { - violations = append(violations, "capacity counts must not be negative") - } - return finish(violations) -} - -func ValidateAlertRecord(alert domain.AlertRecord) error { - var violations []string - violations = appendRequired(violations, "id", alert.ID) - violations = appendRequired(violations, "sourceKind", alert.SourceKind) - violations = appendRequired(violations, "sourceId", alert.SourceID) - violations = appendRequired(violations, "ruleKey", alert.RuleKey) - violations = appendRequired(violations, "title", alert.Title) - violations = appendRequired(violations, "message", alert.Message) - if !validAlertSeverity(alert.Severity) { - violations = append(violations, "severity is invalid") - } - if !validAlertState(alert.State) { - violations = append(violations, "state is invalid") - } - if alert.OccurrenceCount <= 0 { - violations = append(violations, "occurrenceCount must be positive") - } - for _, value := range []fieldString{{field: "title", value: alert.Title}, {field: "message", value: alert.Message}, {field: "resolutionNote", value: alert.ResolutionNote}} { - if len(value.value) > maxProductionMessageLength || unsafeProductionText(value.value) { - violations = append(violations, value.field+" is unsafe") - } - } - return finish(violations) -} - -func ValidateAlertAcknowledgeRequest(request domain.AlertAcknowledgeRequest) error { - return validateAlertNoteRequest(request.AlertID, request.Note) -} - -func ValidateAlertResolveRequest(request domain.AlertResolveRequest) error { - return validateAlertNoteRequest(request.AlertID, request.Note) -} - -func ValidateAlertRetryRequest(request domain.AlertRetryRequest) error { - var violations []string - violations = appendRequired(violations, "alertId", request.AlertID) - violations = appendRequired(violations, "idempotencyKey", request.IdempotencyKey) - if unsafeProductionText(request.AlertID) || unsafeProductionText(request.IdempotencyKey) { - violations = append(violations, "alert retry request is unsafe") - } - return finish(violations) -} - func ValidatePluginLifecycleInstallation(installation domain.PluginLifecycleInstallation) error { var violations []string violations = appendRequired(violations, "id", installation.ID) @@ -165,51 +83,6 @@ func ValidateAIConfigDiffApprovalRequest(request domain.AIConfigDiffApprovalRequ return finish(violations) } -func validateAlertNoteRequest(alertID string, note string) error { - var violations []string - violations = appendRequired(violations, "alertId", alertID) - if unsafeProductionText(alertID) || len(note) > maxProductionMessageLength || unsafeProductionText(note) { - violations = append(violations, "alert note request is unsafe") - } - return finish(violations) -} - -func validCapacityAdmissionState(state domain.CapacityAdmissionState) bool { - switch state { - case domain.CapacityAdmissionAccepted, domain.CapacityAdmissionDeferred, domain.CapacityAdmissionDenied: - return true - default: - return false - } -} - -func validCapacityPressureCode(code domain.CapacityPressureCode) bool { - switch code { - case domain.CapacityPressureEndpointOffline, domain.CapacityPressureEndpointStale, domain.CapacityPressureCapabilityGap, domain.CapacityPressureJobLimit, domain.CapacityPressureQueueLimit, domain.CapacityPressureBacklog: - return true - default: - return false - } -} - -func validAlertSeverity(severity domain.AlertSeverity) bool { - switch severity { - case domain.AlertSeverityInfo, domain.AlertSeverityWarning, domain.AlertSeverityCritical: - return true - default: - return false - } -} - -func validAlertState(state domain.AlertState) bool { - switch state { - case domain.AlertStateActive, domain.AlertStateAcknowledged, domain.AlertStateResolved: - return true - default: - return false - } -} - func validPluginLifecycleState(state domain.PluginLifecycleState) bool { switch state { case domain.PluginLifecycleStatePending, domain.PluginLifecycleStateInstalled, domain.PluginLifecycleStateEnabled, domain.PluginLifecycleStateDisabled, domain.PluginLifecycleStateUpgrading, domain.PluginLifecycleStateRollingBack, domain.PluginLifecycleStateRetired, domain.PluginLifecycleStateFailed: diff --git a/platform/validator/resources.go b/platform/validator/resources.go index cd1886f..e66be54 100644 --- a/platform/validator/resources.go +++ b/platform/validator/resources.go @@ -10,7 +10,7 @@ import ( ) const ( - maxAuditSummaryLength = 512 + maxSummaryLength = 512 maxContactNoteLength = 160 maxMarketplaceKeywordSize = 80 maxMarketplaceListSize = 500 @@ -500,7 +500,7 @@ func validateGameClientBridgeManifest(field string, bridge domain.GameClientBrid commandTypes := map[string]struct{}{} for index, command := range bridge.Commands { prefix := fmt.Sprintf("%s.commands[%d]", field, index) - if !clientManagerIdentifierPattern.MatchString(command.Type) || command.ProtectedRequest == nil && unsafeGameClientBridgeCommandType(command.Type) { + if !clientManagerIdentifierPattern.MatchString(command.Type) || unsafeGameClientBridgeCommandType(command.Type) { violations = append(violations, prefix+".type is invalid or unsafe") } if _, exists := commandTypes[command.Type]; exists { @@ -525,7 +525,6 @@ func validateGameClientBridgeManifest(field string, bridge domain.GameClientBrid if command.MaxPayloadBytes <= 0 || command.MaxPayloadBytes > maxGameClientBridgePayloadSize { violations = append(violations, prefix+".maxPayloadBytes is invalid") } - violations = append(violations, validateGameClientBridgeProtectedRequest(prefix+".protectedRequest", command.ProtectedRequest, transports)...) } snapshotTypes := map[string]struct{}{} for index, snapshot := range bridge.Snapshots { @@ -625,7 +624,7 @@ func validateGameClientBridgeManifest(field string, bridge domain.GameClientBrid violations = append(violations, prefix+".key is duplicated") } logProjectionKeys[projection.Key] = struct{}{} - violations = append(violations, validateGameClientBridgeLogProjection(prefix, projection, bridge.Commands, runtimeProfiles.ClientManagers)...) + violations = append(violations, validateGameClientBridgeLogProjection(prefix, projection)...) } dataPackKeys := map[string]struct{}{} for index, dataPack := range bridge.DataPacks { @@ -640,9 +639,9 @@ func validateGameClientBridgeManifest(field string, bridge domain.GameClientBrid if dataPack.DatabaseUserVersion < 1 || len(dataPack.LogParserRefs) == 0 || len(dataPack.ConfigMapRefs) == 0 { violations = append(violations, prefix+" must declare a database version and parser/config assets") } - refs := append(domain.CopyStringSlice(dataPack.LogParserRefs), dataPack.ConfigMapRefs...) - refs = append(refs, dataPack.DataRefs...) - for _, ref := range refs { + refs := append(domain.CopyStringSlice(dataPack.LogParserRefs), dataPack.ConfigMapRefs...) + refs = append(refs, dataPack.DataRefs...) + for _, ref := range refs { if !safeRelativeJSONRef(ref) { violations = append(violations, prefix+" asset reference is invalid") } @@ -664,8 +663,8 @@ func validateGameClientBridgeManifest(field string, bridge domain.GameClientBrid if !containsString(permissions, template.Permission) { violations = append(violations, prefix+".permission must be declared by the plugin") } - if template.ApprovalLevel != domain.GameClientBridgeApprovalLevelOperator && template.ApprovalLevel != domain.GameClientBridgeApprovalLevelPlatformAdmin { - violations = append(violations, prefix+".approvalLevel must require operator or platform-admin approval") + 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") @@ -689,8 +688,8 @@ func validateGameClientBridgeManifest(field string, bridge domain.GameClientBrid } switch template.Kind { case domain.GameClientBridgeOperationKindRCON: - if transport.Kind != "rcon" || !containsString(transport.Capabilities, domain.JobCapabilityRemoteRunProtectedRCON) { - violations = append(violations, prefix+" transport must be rcon with remote.run.protected.rcon capability") + 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") @@ -822,7 +821,7 @@ func validateGameClientBridgeManifest(field string, bridge domain.GameClientBrid return violations } -func validateGameClientBridgeLogProjection(prefix string, projection domain.GameClientBridgeLogProjectionDeclaration, commands []domain.GameClientBridgeCommandDeclaration, clientManagers []domain.RuntimeClientManagerProfile) []string { +func validateGameClientBridgeLogProjection(prefix string, projection domain.GameClientBridgeLogProjectionDeclaration) []string { var violations []string if len(projection.StreamKeys) < 1 || len(projection.StreamKeys) > 64 { violations = append(violations, prefix+".streamKeys must contain between 1 and 64 streams") @@ -887,43 +886,6 @@ func validateGameClientBridgeLogProjection(prefix string, projection domain.Game if presence.ActivityTarget != nil { violations = append(violations, validateGameClientBridgeLogProjectionTarget(prefix+".presence.activityTarget", *presence.ActivityTarget, captures)...) } - - announcement := presence.Announcement - if !clientManagerIdentifierPattern.MatchString(announcement.ProfileKey) { - violations = append(violations, prefix+".presence.announcement.profileKey is invalid") - } else { - profileFound := false - for _, profile := range clientManagers { - if profile.Key == announcement.ProfileKey && containsString(profile.Health.RequiredCapabilities, "game-client.bridge") { - profileFound = true - break - } - } - if !profileFound { - violations = append(violations, prefix+".presence.announcement.profileKey must reference a declared game-client bridge profile") - } - } - var command *domain.GameClientBridgeCommandDeclaration - for index := range commands { - if commands[index].Type == announcement.CommandType { - command = &commands[index] - break - } - } - if command == nil { - violations = append(violations, prefix+".presence.announcement.commandType must reference a declared command") - } - if !gameClientBridgeFieldPattern.MatchString(announcement.TextField) { - violations = append(violations, prefix+".presence.announcement.textField is invalid") - } else if command != nil && command.ProtectedRequest != nil && command.ProtectedRequest.TextField != announcement.TextField { - violations = append(violations, prefix+".presence.announcement.textField must match the command protected request") - } - if strings.TrimSpace(announcement.NewTextTemplate) == "" || len([]rune(announcement.NewTextTemplate)) > 4096 { - violations = append(violations, prefix+".presence.announcement.newTextTemplate is empty or too large") - } - if strings.TrimSpace(announcement.ReturningTextTemplate) == "" || len([]rune(announcement.ReturningTextTemplate)) > 4096 { - violations = append(violations, prefix+".presence.announcement.returningTextTemplate is empty or too large") - } return violations } @@ -1032,50 +994,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 validateGameClientBridgeProtectedRequest(prefix string, request *domain.GameClientBridgeProtectedRequestDeclaration, transports map[string]domain.RuntimeTransportProfile) []string { - if request == nil { - return nil - } - var violations []string - if !oneOf(request.Kind, "sql", "rcon", "program") { - violations = append(violations, prefix+".kind is invalid") - } - for field, value := range map[string]string{"transportKey": request.TransportKey, "targetKey": request.TargetKey, "textField": request.TextField} { - if !validDistributionLogicalKey(value) || unsafeGameClientBridgePayloadKey(value) { - violations = append(violations, prefix+"."+field+" is invalid") - } - } - if request.MaxTextBytes < 1 || request.MaxTextBytes > maxGameClientBridgePayloadString { - violations = append(violations, prefix+".maxTextBytes is invalid") - } - transport, exists := transports[request.TransportKey] - if !exists { - return append(violations, prefix+".transportKey must reference a declared runtime transport profile") - } - if transport.TargetKey != request.TargetKey { - violations = append(violations, prefix+".targetKey must match the declared runtime transport profile") - } - wantKind, wantCapability := "", "" - switch request.Kind { - case "sql": - wantCapability = domain.JobCapabilityRemoteRunProtectedSQL - case "rcon": - wantKind, wantCapability = "rcon", domain.JobCapabilityRemoteRunProtectedRCON - case "program": - wantKind, wantCapability = "program", domain.JobCapabilityRemoteRunProgram - } - if request.Kind == "sql" && transport.Kind != "mysql" && transport.Kind != "sqlite" { - violations = append(violations, prefix+".transportKey must use mysql or sqlite for sql requests") - } - if wantKind != "" && transport.Kind != wantKind { - violations = append(violations, prefix+".transportKey does not match protected request kind") - } - if wantCapability != "" && !containsString(transport.Capabilities, wantCapability) { - violations = append(violations, prefix+".transportKey is missing required protected transport capability") - } - return violations -} - 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 } @@ -1744,12 +1662,7 @@ func ValidateJob(job domain.Job) error { if job.ExecutionInput.SourceRCON != nil { violations = append(violations, validateRuntimeSourceRCONPlan("executionInput.sourceRcon", job.ExecutionInput.SourceRCON)...) isSourceCommand := job.Capability == domain.JobCapabilityRemoteRunRCONCommand - isProtectedRCON := job.Capability == domain.JobCapabilityRemoteRunProtectedRCON - wantAdapterKind := "rcon" - if isProtectedRCON { - wantAdapterKind = "protected-rcon" - } - if (!isSourceCommand && !isProtectedRCON) || job.ExecutionInput.RemoteAdapterKind != wantAdapterKind { + if !isSourceCommand || job.ExecutionInput.RemoteAdapterKind != "rcon" { violations = append(violations, "executionInput.sourceRcon is allowed only for rcon jobs") } if job.RetryPolicy.MaxAttempts != 1 { @@ -1766,8 +1679,8 @@ func ValidateJob(job domain.Job) error { if len([]byte(job.ExecutionResult.Content)) > maxJobExecutionContentSize { violations = append(violations, "executionResult.content is too large") } - if len(job.ExecutionResult.AuditSummary) > maxAuditSummaryLength { - violations = append(violations, "executionResult.auditSummary is too long") + if len(job.ExecutionResult.Summary) > maxSummaryLength { + violations = append(violations, "executionResult.summary is too long") } if job.Capability == domain.JobCapabilityConfigWrite || job.Capability == domain.JobCapabilityFilesRead || job.Capability == domain.JobCapabilityFilesWrite { if job.ServerInstanceID == "" { @@ -1875,26 +1788,6 @@ func ValidateLogStream(stream domain.LogStream) error { return finish(violations) } -func ValidateAuditEvent(event domain.AuditEvent) error { - var violations []string - violations = appendRequired(violations, "id", event.ID) - violations = appendRequired(violations, "actorId", event.ActorID) - violations = appendRequired(violations, "action", event.Action) - violations = appendRequired(violations, "resourceKind", event.ResourceKind) - violations = appendRequired(violations, "resourceId", event.ResourceID) - violations = appendRequired(violations, "summary", event.Summary) - if !validAuditResult(event.Result) { - violations = append(violations, "result is invalid") - } - if len(event.Summary) > maxAuditSummaryLength { - violations = append(violations, "summary is too long") - } - if looksLikeRawSecret(event.Summary) { - violations = append(violations, "summary must be redacted") - } - return finish(violations) -} - func MissingCapabilities(actual []string, required []string) []string { actualSet := make(map[string]struct{}, len(actual)) for _, capability := range actual { @@ -2446,7 +2339,7 @@ func validPluginRunCapability(capability string) bool { domain.JobCapabilityRemoteRunProcessStart, domain.JobCapabilityRemoteRunProcessStop, domain.JobCapabilityRemoteRunDBMySQLQuery, domain.JobCapabilityRemoteRunDBSQLiteQuery, domain.JobCapabilityRemoteRunLogsTransfer, domain.JobCapabilityRemoteRunRCONCommand, - domain.JobCapabilityRemoteRunProtectedSQL, domain.JobCapabilityRemoteRunProtectedRCON, domain.JobCapabilityRemoteRunProgram, + domain.JobCapabilityRemoteRunProtectedSQL, domain.JobCapabilityRemoteRunProgram, domain.JobCapabilityRunSelfUpdate, domain.JobCapabilityDependenciesCheck, domain.JobCapabilityDependenciesInstall, domain.JobCapabilityDeploymentPlan, domain.JobCapabilityDeploymentShellPosix, domain.JobCapabilityDeploymentShellPowerShell, domain.JobCapabilityDeploymentShellCmd, domain.JobCapabilityClientManagerDeploy, domain.JobCapabilityClientManagerControl, domain.JobCapabilityClientManagerUpdate, @@ -2479,8 +2372,7 @@ func remoteCapabilityRequiresInputRef(capability string) bool { domain.JobCapabilityRemoteRunFilesWrite, domain.JobCapabilityRemoteRunDBMySQLQuery, domain.JobCapabilityRemoteRunDBSQLiteQuery, - domain.JobCapabilityRemoteRunRCONCommand, domain.JobCapabilityRemoteRunProtectedSQL, - domain.JobCapabilityRemoteRunProtectedRCON, domain.JobCapabilityRemoteRunProgram: + domain.JobCapabilityRemoteRunRCONCommand, domain.JobCapabilityRemoteRunProtectedSQL, domain.JobCapabilityRemoteRunProgram: return true default: return false @@ -2781,12 +2673,3 @@ func validLogStorageBackend(backend domain.LogStorageBackend) bool { return false } } - -func validAuditResult(result domain.AuditResult) bool { - switch result { - case domain.AuditResultSuccess, domain.AuditResultDenied, domain.AuditResultFailed, domain.AuditResultQueued: - return true - default: - return false - } -} diff --git a/platform/validator/resources_test.go b/platform/validator/resources_test.go index 042e32c..013ad43 100644 --- a/platform/validator/resources_test.go +++ b/platform/validator/resources_test.go @@ -130,24 +130,24 @@ 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.JobCapabilityRemoteRunProtectedRCON, domain.JobCapabilityRemoteRunProtectedSQL) + registration.Manifest.Capabilities = append(registration.Manifest.Capabilities, domain.JobCapabilityRemoteRunDBSQLiteQuery, domain.JobCapabilityRemoteRunRCONCommand, domain.JobCapabilityRemoteRunProtectedSQL) 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.JobCapabilityRemoteRunProtectedRCON}}, + {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: "announcement.send", Title: "Send announcement", Permission: "server.game-client.command", ApprovalLevel: domain.GameClientBridgeApprovalLevelOperator, PayloadSchemaRef: "schemas/bridge/announcement.schema.json", ResultSchemaRef: "schemas/bridge/announcement-result.schema.json", TimeoutSeconds: 60, MaxPayloadBytes: 4096}}, + 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}}, 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.GameClientBridgeApprovalLevelOperator, 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", ConfirmationSchemaRef: "schemas/bridge/operations/player-fame-set.confirmation.schema.json", TimeoutSeconds: 60, MaxPayloadBytes: 2048, Safety: domain.GameClientBridgeOperationSafety{RequiresApproval: true, RequiresConfirmation: true}}, + {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{"announcement.send"}, SnapshotTypes: []string{"players"}, QueryTemplateKeys: []string{"player.lookup"}, OperationKeys: []string{"player.fame.set", "player.attribute.855.set"}}}, + 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"}}}, } if err := ValidateGamePluginManifestRegistration(registration); err != nil { t.Fatalf("expected bridge catalog to validate, got %v", err) @@ -252,8 +252,8 @@ func TestValidateGamePluginManifestRegistrationValidatesGameClientBridgeCatalog( {name: "unsafe key", expected: "key is invalid or unsafe", mutate: func(value *domain.GamePluginManifestRegistration) { value.Manifest.GameClientBridge.OperationTemplates[0].Key = "raw.sql.execute" }}, - {name: "missing approval", expected: "approvalLevel must require", mutate: func(value *domain.GamePluginManifestRegistration) { - value.Manifest.GameClientBridge.OperationTemplates[0].ApprovalLevel = domain.GameClientBridgeApprovalLevelNone + {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" @@ -381,7 +381,7 @@ func TestValidateJobBoundsProgress(t *testing.T) { } } -func TestValidateArtifactLogAndAudit(t *testing.T) { +func TestValidateArtifactLogAndRuntimeMetadata(t *testing.T) { artifact := domain.Artifact{ ID: "artifact-1", OwnerKind: domain.ArtifactOwnerKindJob, @@ -406,23 +406,6 @@ func TestValidateArtifactLogAndAudit(t *testing.T) { t.Fatalf("expected log stream to validate, got %v", err) } - audit := domain.AuditEvent{ - ID: "audit-1", - ActorID: "user-1", - Action: "server.create", - ResourceKind: "server-instance", - ResourceID: "server-1", - Result: domain.AuditResultSuccess, - Summary: "created server instance", - } - if err := ValidateAuditEvent(audit); err != nil { - t.Fatalf("expected audit event to validate, got %v", err) - } - - audit.Summary = "bearer raw-secret" - if err := ValidateAuditEvent(audit); err == nil || !strings.Contains(err.Error(), "summary must be redacted") { - t.Fatalf("expected audit redaction error, got %v", err) - } } func validAIProvider() domain.AIProvider { diff --git a/platform/validator/rules.md b/platform/validator/rules.md index 1a82b97..eb9fe9c 100644 --- a/platform/validator/rules.md +++ b/platform/validator/rules.md @@ -2,10 +2,10 @@ - API handlers must use named DTOs from `platform/dto`. - Platform services must not accept raw plugin-provided host paths. -- AI provider secrets must be stored by reference and redacted from logs, audit, and plugin bridge responses. +- AI provider secrets must be stored by reference and redacted from logs and plugin bridge responses. - Game management plugin installation must validate manifest identity, server type, required run capabilities, pages, permissions, and schema references. - Server instance creation must validate plugin installation state and run endpoint capability compatibility. -- `platform/validator/resources.go` validates required IDs, enum values, AI key-reference shape, bounded progress/audit summaries, artifact metadata, log stream cursors, and run capability compatibility. +- `platform/validator/resources.go` validates required IDs, enum values, AI key-reference shape, bounded progress summaries, artifact metadata, log stream cursors, and run capability compatibility. - `platform/service.Core` must call validators before repository writes and must reject server creation when the plugin is not installed, the run endpoint is disabled/offline, or required run capabilities are missing. - Job creation must require an idempotency key and return the existing job for duplicate `(runEndpointId, idempotencyKey)` pairs. # Client Manager lifecycle validation diff --git a/platform/validator/runtime_log_events_test.go b/platform/validator/runtime_log_events_test.go index 4618599..77781ba 100644 --- a/platform/validator/runtime_log_events_test.go +++ b/platform/validator/runtime_log_events_test.go @@ -87,8 +87,8 @@ func TestValidateGamePluginRuntimeProfilesRejectsUnsafeLogEventSemantics(t *test unsafeEventTypes := []string{ "ops.shell.execute", "ops.execute", - "audit.sql.query", - "audit.raw-host-path", + "ops.sql.query", + "ops.raw-host-path", "run.socket.open", "auth.credential.exposed", "auth.api-key.exposed", diff --git a/platform_web/acceptance/browser-acceptance.mjs b/platform_web/acceptance/browser-acceptance.mjs index 3d30657..f0c69e8 100644 --- a/platform_web/acceptance/browser-acceptance.mjs +++ b/platform_web/acceptance/browser-acceptance.mjs @@ -78,7 +78,7 @@ async function main() { assertIncludes(plugin.bridgeActions, "artifacts.open", "plugin exposes artifacts.open bridge action"); assertIncludes(plugin.bridgeActions, "plugin-lifecycle.request", "plugin exposes Platform lifecycle bridge action"); assertIncludes(plugin.bridgeActions, "ai.invoke", "plugin exposes Platform AI bridge action"); - assertIncludes(plugin.productionLifecycle?.operations, "rollback", "plugin declares rollback governance"); + assertIncludes(plugin.productionLifecycle?.operations, "rollback", "plugin declares rollback operation"); assertIncludes(marketplacePlugin.capabilities, "process.start", "marketplace exposes lifecycle capability"); assertEqual(aiProvider.apiKeyConfigured, true, "AI provider key presence projection"); assertEqual(aiProvider.baseUrlConfigured, true, "AI provider base URL presence projection"); @@ -170,7 +170,7 @@ async function main() { { name: "系统维护", hash: "#/maintenance", - markers: ["系统维护", "容量治理与告警闭环", "运行槽位", productionSeed.alert.title] + markers: ["系统维护", "容量与告警闭环", "运行槽位", productionSeed.alert.title] }, { name: "服务器详情", @@ -598,7 +598,7 @@ async function prepareProductionOperations(headers, server, plugin) { }, headers ); - if (admission.accepted || admission.state !== "denied" || !admission.alertId || !admission.auditEventId) { + if (admission.accepted || admission.state !== "denied" || !admission.alertId) { throw new Error(`capacity admission did not create durable denied evidence: ${JSON.stringify(admission)}`); } @@ -622,13 +622,13 @@ async function prepareProductionOperations(headers, server, plugin) { alert, diff, apiProof: { - admission: pick(admission, ["accepted", "state", "reason", "pressureCodes", "alertId", "auditEventId"]), + admission: pick(admission, ["accepted", "state", "reason", "pressureCodes", "alertId"]), capacity: { ...pick(capacity, ["totalMaxJobs", "totalRunningJobs", "totalQueuedJobs", "activeAlerts", "generatedAt"]), endpoints: capacity.endpoints.map((item) => pick(item, ["runEndpointId", "status", "maxJobs", "runningJobs", "queuedJobs", "logBacklogBatches", "artifactBacklogChunks", "pressureCodes"])) }, - alert: pick(alert, ["id", "sourceKind", "sourceId", "ruleKey", "severity", "state", "occurrenceCount", "lastAuditEventId"]), - pluginLifecycle: pick(installation, ["id", "pluginId", "serverInstanceId", "currentVersion", "targetVersion", "desiredState", "currentState", "lastOperation", "compatibility", "dependencyState", "jobId", "auditEventId"]), + alert: pick(alert, ["id", "sourceKind", "sourceId", "ruleKey", "severity", "state", "occurrenceCount"]), + pluginLifecycle: pick(installation, ["id", "pluginId", "serverInstanceId", "currentVersion", "targetVersion", "desiredState", "currentState", "lastOperation", "compatibility", "dependencyState", "jobId"]), aiConfigDiff: pick(diff, ["id", "requestId", "serverInstanceId", "pluginId", "providerId", "model", "key", "configVersion", "diffSummary", "state", "expiresAt"]) } }; @@ -676,7 +676,7 @@ async function verifyAlertInteraction(chrome, headers, seededAlert) { assertNoForbiddenProjection(acknowledged, "acknowledged alert response"); return { cancelPreservedState: stillActive.state, - persisted: pick(acknowledged, ["id", "state", "acknowledgedBy", "acknowledgedAt", "lastAuditEventId"]), + persisted: pick(acknowledged, ["id", "state", "acknowledgedBy", "acknowledgedAt"]), forbiddenFragmentScan: "passed", textSample: (await chrome.visibleText()).slice(0, 1200) }; @@ -714,12 +714,12 @@ async function verifyPluginLifecycleInteraction(chrome, headers, plugin, server) const response = await getJson(`/plugin-lifecycles?pluginId=${encodeURIComponent(plugin.id)}&serverInstanceId=${encodeURIComponent(server.id)}`, headers); const installation = findRequired(response.items, (item) => item.pluginId === plugin.id && item.serverInstanceId === server.id, "plugin lifecycle after browser dispatch"); assertEqual(installation.lastOperation, "enable", "browser lifecycle operation persisted"); - if (!installation.jobId || !installation.auditEventId) { - throw new Error(`browser lifecycle dispatch missed job/audit linkage: ${JSON.stringify(installation)}`); + if (!installation.jobId) { + throw new Error(`browser lifecycle dispatch missed job linkage: ${JSON.stringify(installation)}`); } assertNoForbiddenProjection(installation, "browser plugin lifecycle response"); return { - persisted: pick(installation, ["id", "pluginId", "serverInstanceId", "currentState", "desiredState", "lastOperation", "dependencyState", "jobId", "auditEventId", "alertId"]), + persisted: pick(installation, ["id", "pluginId", "serverInstanceId", "currentState", "desiredState", "lastOperation", "dependencyState", "jobId", "alertId"]), forbiddenFragmentScan: "passed", textSample: (await chrome.visibleText()).slice(0, 1200) }; diff --git a/platform_web/api/client.test.ts b/platform_web/api/client.test.ts index 969b1f2..b4bdd74 100644 --- a/platform_web/api/client.test.ts +++ b/platform_web/api/client.test.ts @@ -312,7 +312,7 @@ describe("PlatformApiClient AI providers", () => { return jsonResponse({ accepted: true, action: "stop", instance: server, job: { ...job, capability: "process.stop" } }); } if (url.endsWith("/api/v1/server-instances/server-1/process/status") && init?.method === "POST") { - return jsonResponse({ accepted: true, action: "status", instance: server, job: { ...job, capability: "process.status", executionResult: { kind: "process", processState: "running", auditSummary: "private supervised process identity" } } }); + return jsonResponse({ accepted: true, action: "status", instance: server, job: { ...job, capability: "process.status", executionResult: { kind: "process", processState: "running", summary: "private supervised process identity" } } }); } if (url.endsWith("/api/v1/server-instances/server-1/administrators/candidates") && (!init?.method || init.method === "GET")) { return jsonResponse({ items: [{ id: "user-2", displayName: "Helper", status: "active", roles: ["server-admin"] }], count: 1 }); diff --git a/platform_web/api/client.ts b/platform_web/api/client.ts index e697192..22f199c 100644 --- a/platform_web/api/client.ts +++ b/platform_web/api/client.ts @@ -19,7 +19,6 @@ import type { ArtifactFilterRequest, ArtifactListResponse, AuthSessionResponse, - AuditEventListResponse, ClientManagerBuildRequest, ClientManagerControlRequest, ClientManagerDeployRequest, @@ -95,6 +94,8 @@ import type { ServerMemberListResponse, ServerMemberRequest, ServerMetricsListResponse, + SourceRCONCommandRequest, + SourceRCONCommandResponse, MetricSampleListResponse, BackupListResponse, BackupResponse, @@ -405,6 +406,10 @@ export class PlatformApiClient { return parseSafeGameClientBridgeCommand(await this.request(`/server-instances/${encodeURIComponent(id)}/game-client-bridge/commands`, { method: "POST", body: request })); } + async dispatchSourceRCONCommand(id: string, request: SourceRCONCommandRequest): Promise { + return this.request(`/server-instances/${encodeURIComponent(id)}/rcon/commands`, { method: "POST", body: request }); + } + async getGameClientBridgeCommand(id: string, commandId: string): Promise { return parseSafeGameClientBridgeCommand(await this.request(`/server-instances/${encodeURIComponent(id)}/game-client-bridge/commands/${encodeURIComponent(commandId)}`)); } @@ -627,10 +632,6 @@ export class PlatformApiClient { return this.request("/log-streams/query", { method: "POST", body: request }); } - async listAuditEvents(): Promise { - return this.request("/audit-events"); - } - async suggestServerConfig(request: LlmConfigSuggestionRequest): Promise { return this.request("/ai/config-suggestions", { method: "POST", body: request }); } diff --git a/platform_web/api/contracts.md b/platform_web/api/contracts.md index eded2e8..c74bd94 100644 --- a/platform_web/api/contracts.md +++ b/platform_web/api/contracts.md @@ -34,7 +34,7 @@ Normal browser login uses the platform's HttpOnly SameSite cookie and `credentia - `invokeAI` posts `AIInvocationRequest` to `/ai/invocations` for platform-mediated AI assistance. Responses carry redacted recommendations, usage metadata, optional reviewable config suggestions, and safe errors; they must not include provider base URLs, key refs, raw keys, or direct provider transport details. - `listRunEndpoints` and `listJobs` provide refresh data for endpoint availability, capacity, and durable lifecycle status. Job projections include `retrying`, attempt/max-attempt counts, next retry timing, safe ack/lease deadlines, cancellation timestamps/reason, terminal time, and reconciliation outcome/count. - `getDependencyCatalog` reads `GET /server-instances/{id}/dependencies` and returns only target-matched probe state/evidence, typed plan step summaries, approved download hosts, and immutable SHA-256 `planDigest` values. Install requests must submit the selected digest; the browser never receives bindings, commands, paths, credentials, tokens, or private download refs. -- `listRunUpdates` reads `GET /server-instances/{id}/run/update` and returns only target, artifact checksum, release identity, phase, bounded audit message, rollback flag, and timestamps. The UI treats `restart-requested`/`activating` as non-terminal until a later safe projection confirms health. +- `listRunUpdates` reads `GET /server-instances/{id}/run/update` and returns only target, artifact checksum, release identity, phase, bounded status message, rollback flag, and timestamps. The UI treats `restart-requested`/`activating` as non-terminal until a later safe projection confirms health. - `listMetricHistory`, `listBackups`, and `getBackup` read bounded owner-scoped metric and backup projections. Backup responses contain artifact IDs/checksums and recovery/retention state only; they never include body bytes or storage paths. - `listRemoteAdapters` and `requestRemoteAdapter` use declaration-backed logical target keys and return queued status/result references. The browser never receives adapter credentials, host addresses, sockets, Run tokens, leases, session hashes, or secret refs. - Server management DTOs may include bounded `ownerUserId` and `adminUserIds` metadata, but must not include raw run credentials, host paths, direct socket details, user password hashes, or AI provider keys. @@ -43,7 +43,7 @@ Normal browser login uses the platform's HttpOnly SameSite cookie and `credentia ## Redesign Contract Gaps (redesign-platform-web-interactions) -Existing platform APIs already cover server lifecycle, jobs, log stream metadata and cursor query, audit events, users, run endpoints, game plugins, plugin bridge authorization, and AI provider health/test. The redesigned UI additionally declares the following frontend contracts; where the platform backend does not yet serve them, the UI must degrade to a clearly labeled local/unavailable state instead of failing silently: +Existing platform APIs already cover server lifecycle, jobs, log stream metadata and cursor query, users, run endpoints, game plugins, plugin bridge authorization, and AI provider health/test. The redesigned UI additionally declares the following frontend contracts; where the platform backend does not yet serve them, the UI must degrade to a clearly labeled local/unavailable state instead of failing silently: - `POST /api/v1/auth/register` (`RegisterRequest`/`AuthSessionResponse`): visitor registration. Implemented: the first registered user becomes an active platform administrator; later self-registered users become pending server-scoped users and do not receive platform administrator privileges. - `POST /api/v1/auth/login` (`LoginRequest`/`AuthSessionResponse`), `POST /api/v1/auth/rotate`, and `POST /api/v1/auth/logout`: implemented bounded, durable bearer session lifecycle for authenticated workspace entry. @@ -56,7 +56,7 @@ Existing platform APIs already cover server lifecycle, jobs, log stream metadata - `POST /api/v1/file-operations/dispatch` (`FileOperationDispatchRequest`/`FileOperationDispatchResponse`): implemented scoped file operation dispatch using logical keys and refs only. - `POST /api/v1/ai/config-suggestions` (`LlmConfigSuggestionRequest`/`LlmConfigSuggestionResponse`) and `POST /api/v1/ai/invocations` (`AIInvocationRequest`/`AIInvocationResponse`): platform-mediated AI recommendation or diff scoped to one server. Provider keys stay in `platform/`; responses carry only recommendation text, usage metadata, and reviewable suggestions, never keys or provider secrets. - Per-server plugin controls are rendered from installed plugin manifests (`bridgeActions`, `lifecycleActions`, `pages`, `declaredPermissions`); a richer declared-control schema remains a future plugin contract. Hosted bridge execution uses `POST /api/v1/plugin-bridge/execute` for server context, scoped file, log, job, artifact reference, and AI action envelopes instead of direct plugin fetches to platform internals. -- Operation/job traceability reuses `GET /api/v1/jobs`, `GET /api/v1/jobs/{id}`, `POST /api/v1/jobs/{id}/cancel`, and `GET /api/v1/audit-events`; the frontend wraps these in one visible operation lifecycle per user intent. +- Operation/job traceability reuses `GET /api/v1/jobs`, `GET /api/v1/jobs/{id}`, and `POST /api/v1/jobs/{id}/cancel`; the frontend wraps these in one visible operation lifecycle per user intent. Browser Job contracts explicitly exclude raw or hashed lease tokens, Run session tokens/generations, secret refs, host paths, sockets, and credentials. The safe schema rejects those keys, and existing API client 401/403 behavior remains authoritative for expired sessions and cross-owner access. - Server-scoped terminal log streaming (`GET /api/v1/server-instances/{id}/logs/events`) is used by the server detail terminal drawer for platform-accepted SSE history/live output. Raw log list/backfill routes (`GET .../logs/live`, `POST .../logs/backfill`) and direct management-terminal/RCON input routes remain removed from product clients; internal log ingest and cursor query remain available to platform services and maintenance/debug flows. diff --git a/platform_web/api/gameClientBridge.test.ts b/platform_web/api/gameClientBridge.test.ts index 6755cc2..6a2ecad 100644 --- a/platform_web/api/gameClientBridge.test.ts +++ b/platform_web/api/gameClientBridge.test.ts @@ -16,7 +16,7 @@ const status = { profileKey: "scum-client", available: false, reason: "component heartbeat is unavailable", - commandTypes: ["scum.announcement.send"], + commandTypes: ["scum.diagnostic.ping"], snapshotTypes: ["scum.players"], queryTemplateKeys: ["scum.player.search"] }] @@ -27,12 +27,11 @@ const pendingCommand = { serverInstanceId: "server-1", pluginId: "game.scum", profileKey: "scum-client", - commandType: "scum.announcement.send", + commandType: "scum.diagnostic.ping", priority: 20, state: "pending", approvalState: "pending", requesterId: "user-1", - auditReferences: ["audit-command-1"], expiresAt: later, createdAt: now, updatedAt: now @@ -42,10 +41,10 @@ const completedCommand = { ...pendingCommand, state: "succeeded", approvalState: "approved", - resultSummary: "announcement delivered", + resultSummary: "diagnostic completed", result: { status: "succeeded", - summary: "announcement delivered", + summary: "diagnostic completed", payload: { delivered: true, recipientCount: 12 }, completedAt: later }, @@ -57,7 +56,6 @@ const cancellation = { commandId: pendingCommand.id, state: "cancelled", cancellation: { requestedBy: "user-1", reason: "maintenance window changed", cancelledAt: later }, - auditReferences: ["audit-command-1", "audit-command-cancel-1"], updatedAt: later } as const; @@ -73,19 +71,18 @@ const snapshot = { observedAt: now, payload: { players: [{ playerId: "player-1", displayName: "Moonlight" }] }, retention: { keepForSeconds: 3600, maxRecords: 24 }, - auditReferences: ["audit-snapshot-1"], createdAt: now, expiresAt: later } as const; const manifestDeclaration: GameClientBridgeManifestResponse = { commands: [{ - type: "scum.announcement.send", - title: "Send announcement", + type: "scum.diagnostic.ping", + title: "Diagnostic ping", permission: "server.game-client.command", approvalLevel: "operator", - payloadSchemaRef: "schemas/bridge/commands/announcement.request.json", - resultSchemaRef: "schemas/bridge/commands/announcement.result.json", + payloadSchemaRef: "schemas/bridge/commands/diagnostic-ping.request.json", + resultSchemaRef: "schemas/bridge/commands/diagnostic-ping.result.json", timeoutSeconds: 30, maxPayloadBytes: 4096 }], @@ -104,7 +101,7 @@ const manifestDeclaration: GameClientBridgeManifestResponse = { }], commandRetentionSeconds: 86400, maxCommands: 1000, - pages: [{ pageKey: "operations", commandTypes: ["scum.announcement.send"], snapshotTypes: ["scum.players"], queryTemplateKeys: ["scum.player.search"] }], + pages: [{ pageKey: "operations", commandTypes: ["scum.diagnostic.ping"], snapshotTypes: ["scum.players"], queryTemplateKeys: ["scum.player.search"] }], companion: { profileKey: "scum-client-manager", configTemplateKey: "client-config", @@ -151,15 +148,15 @@ describe("PlatformApiClient Game Client Bridge operator API", () => { const client = new PlatformApiClient("/api/v1", () => "operator-session"); const queueRequest: GameClientBridgeQueueRequest = { profileKey: "scum-client", - commandType: "scum.announcement.send", + commandType: "scum.diagnostic.ping", payload: { message: "Restart in ten minutes", channels: ["global"] }, - idempotencyKey: "announcement-1", + idempotencyKey: "diagnostic-1", priority: 20, expiresAt: later }; 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.announcement.send" })).resolves.toMatchObject({ count: 1 }); + 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.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" }); @@ -167,7 +164,7 @@ describe("PlatformApiClient Game Client Bridge operator API", () => { expect(calls.map((call) => `${call.method} ${call.url}`)).toEqual([ "GET /api/v1/server-instances/server-1/game-client-bridge", - "GET /api/v1/server-instances/server-1/game-client-bridge/commands?profileKey=scum-client&state=pending&commandType=scum.announcement.send", + "GET /api/v1/server-instances/server-1/game-client-bridge/commands?profileKey=scum-client&state=pending&commandType=scum.diagnostic.ping", "POST /api/v1/server-instances/server-1/game-client-bridge/commands", "GET /api/v1/server-instances/server-1/game-client-bridge/commands/command-1", "POST /api/v1/server-instances/server-1/game-client-bridge/commands/command-1/cancel", diff --git a/platform_web/api/productionOperations.test.ts b/platform_web/api/productionOperations.test.ts index afe4134..9b8b14c 100644 --- a/platform_web/api/productionOperations.test.ts +++ b/platform_web/api/productionOperations.test.ts @@ -5,7 +5,7 @@ import { PlatformApiClient } from "./client"; describe("PlatformApiClient production operations", () => { afterEach(() => vi.unstubAllGlobals()); - it("uses Platform-only governance routes and bounded request bodies", async () => { + it("uses Platform-only operations routes and bounded request bodies", async () => { const calls: Array<{ url: string; method: string; body?: unknown }> = []; vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { calls.push({ url: String(input), method: init?.method ?? "GET", body: init?.body ? JSON.parse(String(init.body)) : undefined }); diff --git a/platform_web/api/types.ts b/platform_web/api/types.ts index 0844f7d..f809269 100644 --- a/platform_web/api/types.ts +++ b/platform_web/api/types.ts @@ -146,7 +146,6 @@ export interface GameClientBridgeCommandResponse { resultSummary?: string; result?: GameClientBridgeCommandResultResponse; cancellation?: GameClientBridgeCommandCancellationResponse; - auditReferences?: string[]; expiresAt: string; createdAt: string; updatedAt: string; @@ -181,7 +180,6 @@ export interface GameClientBridgeCancelResponse { commandId: string; state: GameClientBridgeCommandState; cancellation: GameClientBridgeCommandCancellationResponse; - auditReferences?: string[]; updatedAt: string; } @@ -202,7 +200,6 @@ export interface GameClientBridgeSnapshotResponse { observedAt: string; payload: GameClientBridgeJsonObject; retention: GameClientBridgeRetentionResponse; - auditReferences?: string[]; createdAt: string; expiresAt: string; } @@ -695,7 +692,7 @@ export interface JobExecutionResultResponse { version?: number; checksum?: string; sizeBytes?: number; - auditSummary?: string; + summary?: string; } export interface JobListResponse { @@ -1537,22 +1534,6 @@ export interface LogStreamEventOptions { historyLimit?: number; } -export interface AuditEventResponse { - id: string; - actorId: string; - action: string; - resourceKind: string; - resourceId: string; - result: string; - summary: string; - createdAt: string; -} - -export interface AuditEventListResponse { - items: AuditEventResponse[]; - count: number; -} - export interface JobCreateRequest { id: string; serverInstanceId?: string; @@ -1628,7 +1609,6 @@ export interface CapacityAdmissionDecisionResponse { pressureCodes?: string[]; checkedAt: string; alertId?: string; - auditEventId?: string; } export interface EndpointCapacityProjectionResponse { @@ -1672,7 +1652,6 @@ export interface AlertResponse { retryable: boolean; retryAfterSeconds?: number; lastJobId?: string; - lastAuditEventId?: string; lastSeenAt: string; acknowledgedBy?: string; acknowledgedAt?: string; @@ -1700,7 +1679,6 @@ export interface PluginLifecycleInstallationResponse { dependencyState?: string; jobId?: string; alertId?: string; - auditEventId?: string; failureReason?: string; createdAt: string; updatedAt: string; diff --git a/platform_web/components/ClientManagerLifecyclePanel.tsx b/platform_web/components/ClientManagerLifecyclePanel.tsx index 4127f9b..3cd11d1 100644 --- a/platform_web/components/ClientManagerLifecyclePanel.tsx +++ b/platform_web/components/ClientManagerLifecyclePanel.tsx @@ -181,7 +181,7 @@ function ClientManagerLifecycleRow({ item, serverName, runCommand, confirmComman {(item.retryable || item.requiresRedeploy || item.status === "failed") && (
- {item.requiresRedeploy ? "组件密钥 generation 已变化:旧 artifact/session 已被围栏。请重新构建当前 generation,再执行重新部署。" : item.retryable ? "Run 保留了可恢复状态,可重试当前 intent;界面不会在 job 成功前推进阶段。" : "检查 Platform 审计与 job 失败原因后选择重新部署、回滚或卸载。"} + {item.requiresRedeploy ? "组件密钥 generation 已变化:旧 artifact/session 已被围栏。请重新构建当前 generation,再执行重新部署。" : item.retryable ? "Run 保留了可恢复状态,可重试当前 intent;界面不会在 job 成功前推进阶段。" : "检查 job 失败原因后选择重新部署、回滚或卸载。"}
)} @@ -196,7 +196,7 @@ function ClientManagerLifecycleRow({ item, serverName, runCommand, confirmComman } label="重试" disabled={!item.retryable} reason="当前失败不可重试" onClick={() => void runCommand(item, "重试 Client Manager", () => platformApiClient.retryClientManagerLifecycle(item.serverInstanceId, { profileKey: item.profileKey, expectedDeploymentGeneration: item.deploymentGeneration, idempotencyKey: idempotency("retry") }))} /> } label="撤销会话" disabled={!item.activeArtifactId || item.status === "uninstalled"} reason="组件尚未安装" onClick={() => confirmCommand({ title: "撤销 Client Manager 会话", description: `撤销 ${item.profileKey} 的独立组件 session。Run session 与 job lease 不受影响,组件必须使用当前 key generation 重新注册。`, danger: true, execute: () => runCommand(item, "撤销 Client Manager 会话", () => platformApiClient.revokeClientManagerSession(item.serverInstanceId, { profileKey: item.profileKey, reason: "operator revoked component session" })) })} /> } label="重置密钥" disabled={item.status === "uninstalled"} reason="已卸载" onClick={() => confirmCommand({ title: "重置 Client Manager 密钥", description: `重置 ${item.profileKey} 的 component key 会撤销旧 session/artifact generation。必须重新构建并重新部署,不会显示或导出原始密钥。`, danger: true, execute: async () => { await platformApiClient.resetClientManagerKey(item.serverInstanceId, { componentKind: "client-manager", componentKey: item.profileKey }); await runCommand(item, "刷新密钥重置状态", () => platformApiClient.getClientManagerLifecycle(item.serverInstanceId, item.profileKey)); } })} /> - } label="卸载" danger disabled={!available("uninstall")} reason={reason("uninstall")} onClick={() => confirmCommand({ title: "卸载 Client Manager", description: `确认停止并卸载 ${serverName} 的 ${item.profileKey}?Run 只会清理受控 Client Manager workspace,Platform 保留 build、artifact 与审计历史。`, danger: true, execute: () => runCommand(item, "卸载 Client Manager", () => platformApiClient.uninstallClientManager(item.serverInstanceId, { profileKey: item.profileKey, expectedDeploymentGeneration: item.deploymentGeneration, confirmed: true, idempotencyKey: idempotency("uninstall") })) })} /> + } label="卸载" danger disabled={!available("uninstall")} reason={reason("uninstall")} onClick={() => confirmCommand({ title: "卸载 Client Manager", description: `确认停止并卸载 ${serverName} 的 ${item.profileKey}?Run 只会清理 Client Manager workspace,Platform 保留 build 与 artifact 记录。`, danger: true, execute: () => runCommand(item, "卸载 Client Manager", () => platformApiClient.uninstallClientManager(item.serverInstanceId, { profileKey: item.profileKey, expectedDeploymentGeneration: item.deploymentGeneration, confirmed: true, idempotencyKey: idempotency("uninstall") })) })} /> ); diff --git a/platform_web/components/OperationControls.test.tsx b/platform_web/components/OperationControls.test.tsx index c71f529..3c8700c 100644 --- a/platform_web/components/OperationControls.test.tsx +++ b/platform_web/components/OperationControls.test.tsx @@ -10,7 +10,7 @@ describe("shared operation dialogs", () => { undefined} diff --git a/platform_web/components/OperationsTray.tsx b/platform_web/components/OperationsTray.tsx index efbc754..795a229 100644 --- a/platform_web/components/OperationsTray.tsx +++ b/platform_web/components/OperationsTray.tsx @@ -38,7 +38,7 @@ export function OperationsTray({ operations }: OperationsTrayProps) {
当前浏览器会话 - 持久任务与审计记录以 Platform 页面为准 + 持久任务与告警记录以 Platform 页面为准
{items.length === 0 ? (

本会话尚未提交资源变更。

diff --git a/platform_web/components/PluginLifecycleWorkbench.tsx b/platform_web/components/PluginLifecycleWorkbench.tsx index 63cddd5..73af888 100644 --- a/platform_web/components/PluginLifecycleWorkbench.tsx +++ b/platform_web/components/PluginLifecycleWorkbench.tsx @@ -63,7 +63,7 @@ export function PluginLifecycleWorkbench({ pluginId, pluginName, operations = li idempotencyKey: `web:plugin.lifecycle:${pluginId}:${selectedServerId}:${operation}:${Date.now()}`, confirmed: disruptiveOperations.includes(operation) }); - const evidence = [response.job?.id && `任务 ${response.job.id}`, response.installation.auditEventId && `审计 ${response.installation.auditEventId}`, response.installation.alertId && `告警 ${response.installation.alertId}`].filter(Boolean).join(" · "); + const evidence = [response.job?.id && `任务 ${response.job.id}`, response.installation.alertId && `告警 ${response.installation.alertId}`].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(); @@ -102,7 +102,7 @@ export function PluginLifecycleWorkbench({ pluginId, pluginName, operations = li
{installation.currentState} → {installation.desiredState}{installation.compatibility || "pending"}
当前 {installation.currentVersion || "--"}目标 {installation.targetVersion || "--"}依赖 {installation.dependencyState || "unknown"} - {installation.jobId && 任务 {installation.jobId}}{installation.auditEventId && 审计 {installation.auditEventId}}{installation.alertId && 告警 {installation.alertId}} + {installation.jobId && 任务 {installation.jobId}}{installation.alertId && 告警 {installation.alertId}}
{installation.failureReason &&

{installation.failureReason}

}
diff --git a/platform_web/components/ProductionOperations.test.tsx b/platform_web/components/ProductionOperations.test.tsx index 76bf2b4..50e89e7 100644 --- a/platform_web/components/ProductionOperations.test.tsx +++ b/platform_web/components/ProductionOperations.test.tsx @@ -2,21 +2,21 @@ import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it } from "vitest"; import { AIConfigDiffReviewPanel } from "./AIConfigDiffReviewPanel"; -import { ProductionGovernancePanel } from "./ProductionGovernancePanel"; -import governanceSource from "./ProductionGovernancePanel.tsx?raw"; +import { ProductionOperationsPanel } from "./ProductionOperationsPanel"; +import operationsSource from "./ProductionOperationsPanel.tsx?raw"; import lifecycleSource from "./PluginLifecycleWorkbench.tsx?raw"; import diffSource from "./AIConfigDiffReviewPanel.tsx?raw"; describe("production operations components", () => { it("renders persisted loading states without optimistic terminal success", () => { - expect(renderToStaticMarkup()).toContain("正在同步容量与告警"); + expect(renderToStaticMarkup()).toContain("正在同步容量与告警"); expect(renderToStaticMarkup()).toContain("正在同步 AI 配置差异"); - for (const source of [governanceSource, lifecycleSource, diffSource]) { + for (const source of [operationsSource, lifecycleSource, diffSource]) { expect(source).not.toContain("setTimeout"); expect(source).not.toMatch(/apiKeyRef|rawApiKey|runSocket|providerBaseUrl|hostPath|directRun/i); expect(source).toContain("disabled="); } - expect(governanceSource).toContain("if (!intent || busyKey) return"); + expect(operationsSource).toContain("if (!intent || busyKey) return"); expect(lifecycleSource).toContain("if (!selectedServerId || busy) return"); expect(diffSource).toContain("if (!selected || busyId) return"); }); diff --git a/platform_web/components/ProductionGovernancePanel.tsx b/platform_web/components/ProductionOperationsPanel.tsx similarity index 93% rename from platform_web/components/ProductionGovernancePanel.tsx rename to platform_web/components/ProductionOperationsPanel.tsx index 5e2b3da..8d543ba 100644 --- a/platform_web/components/ProductionGovernancePanel.tsx +++ b/platform_web/components/ProductionOperationsPanel.tsx @@ -9,12 +9,12 @@ import { ErrorState, LoadingState, ResultBadge } from "./StateViews"; type AlertAction = "acknowledge" | "resolve" | "retry"; -interface ProductionGovernancePanelProps { +interface ProductionOperationsPanelProps { compact?: boolean; title?: string; } -export function ProductionGovernancePanel({ compact = false, title = "容量与告警" }: ProductionGovernancePanelProps) { +export function ProductionOperationsPanel({ compact = false, title = "容量与告警" }: ProductionOperationsPanelProps) { const [capacity, setCapacity] = useState(null); const [alerts, setAlerts] = useState([]); const [loading, setLoading] = useState(true); @@ -31,7 +31,7 @@ export function ProductionGovernancePanel({ compact = false, title = "容量与 setCapacity(capacityResponse); setAlerts(alertResponse.items); } catch (caught) { - setError(caught instanceof Error ? caught.message : "生产治理状态加载失败"); + setError(caught instanceof Error ? caught.message : "生产状态加载失败"); } finally { setLoading(false); } @@ -69,7 +69,7 @@ export function ProductionGovernancePanel({ compact = false, title = "容量与 const visibleEndpoints = compact ? capacity?.endpoints.slice(0, 3) ?? [] : capacity?.endpoints ?? []; return ( -
+

{title}

{result && } {loading && } - {!loading && error && void refresh()} compact />} + {!loading && error && void refresh()} compact />} {!loading && !error && capacity && ( <>
@@ -111,7 +111,6 @@ export function ProductionGovernancePanel({ compact = false, title = "容量与 {alert.sourceKind} · {alert.sourceId} 发生 {alert.occurrenceCount} 次 {alert.lastJobId && 任务 {alert.lastJobId}} - {alert.lastAuditEventId && 审计 {alert.lastAuditEventId}} {alert.state !== "resolved" && (
diff --git a/platform_web/components/RuntimeTaskProgress.tsx b/platform_web/components/RuntimeTaskProgress.tsx index 08dabbb..810f744 100644 --- a/platform_web/components/RuntimeTaskProgress.tsx +++ b/platform_web/components/RuntimeTaskProgress.tsx @@ -87,7 +87,7 @@ export const runtimeKeyResetStages: RuntimeTaskStage[] = [ export const runtimeDependencyStages: RuntimeTaskStage[] = [ { key: "profile_read", label: "读取声明", description: "读取插件声明的 probe 和 install plan。" }, { key: "env_probe", label: "环境检查", description: "让 run 节点评估当前运行环境。" }, - { key: "install_prepare", label: "安装环境", description: "准备安全、可审计的依赖安装任务。" }, + { key: "install_prepare", label: "安装环境", description: "准备依赖安装任务。" }, { key: "job_track", label: "等待确认", description: "记录 job id 并刷新后台任务状态。" } ]; diff --git a/platform_web/components/ServerDeploymentWorkflow.tsx b/platform_web/components/ServerDeploymentWorkflow.tsx index 9e6b80e..df40151 100644 --- a/platform_web/components/ServerDeploymentWorkflow.tsx +++ b/platform_web/components/ServerDeploymentWorkflow.tsx @@ -111,14 +111,14 @@ export function ServerDeploymentWorkflow({ open, kind, plugins, initialForm, dep
创建基础信息插件决定下一步显示哪些部署方式和游戏参数。
配置启动项新建安装、接管已有和自定义启动分别填写自己的字段。
平台构建 Run 包平台在自有构建器中打包,Run 启动后自动上报心跳。
} - {step === modeStep &&

选择这台服务器的创建方式;下一步只显示该方式需要的启动项。

{isScum &&
SCUM 受控链路Run 会按预检 → 安装或扫描 → 配置映射 → 健康验证执行;目录本身不代表安装完成。
}
+ {step === modeStep &&

选择这台服务器的创建方式;下一步只显示该方式需要的启动项。

{isScum &&
SCUM 自动部署链路Run 会按预检 → 安装或扫描 → 配置映射 → 健康验证执行;目录本身不代表安装完成。
}
setForm((current) => ({ ...current, deploymentMode: "guided-install" }))} /> setForm((current) => ({ ...current, deploymentMode: "existing-server" }))} /> setForm((current) => ({ ...current, deploymentMode: "custom-command" }))} />
} {step === configurationStep &&
{kind === "edit" && onReveal &&
已读取受保护配置{revealBusy ? "正在读取已保存的目录和命令…" : "这些值只保留在当前编辑窗口,关闭后会清除。"}{revealError && <>{revealError}}
}
{kind === "edit" && } - {form.deploymentMode === "guided-install" && } + {form.deploymentMode === "guided-install" && } {form.deploymentMode === "existing-server" && } {form.deploymentMode === "custom-command" && } {form.deploymentMode === "guided-install" && pluginFields.map((field) => ( @@ -150,7 +150,7 @@ function GuidedInstallPlan({ pluginName, isScum }: { pluginName: string; isScum: ] : [ { icon: ScanSearch, title: "预检目录与 Run", copy: "确认安装目录、权限、端口与 Run 环境可用。" }, { icon: Download, title: "安装游戏服务端", copy: "按插件声明的推荐方案安装到该目录。" }, - { icon: SlidersHorizontal, title: "写入游戏配置", copy: "将本页填写的游戏参数交给受控部署流程。" }, + { icon: SlidersHorizontal, title: "写入游戏配置", copy: "将本页填写的游戏参数交给自动部署流程。" }, { icon: HeartPulse, title: "启动并健康验证", copy: "只有启动与插件要求的验证通过才会显示成功。" } ]; @@ -162,15 +162,15 @@ function ExistingServerAdoptionPlan({ pluginName, isScum }: { pluginName: string { icon: FolderCog, title: "定位服务端根目录", copy: "填写包含 SCUM 服务端文件、数据与配置的目录,不是 Steam 库或 SteamCMD 目录。" }, { icon: ScanSearch, title: "Run 本机预检", copy: "检查目录权限、可执行文件、版本、Steam App 标记和所需端口。" }, { icon: SlidersHorizontal, title: "只读扫描配置", copy: "识别 ServerSettings.ini 与现有参数;接管不会写入或覆盖它们。" }, - { icon: ServerCog, title: "建立受控生命周期", copy: "Run 自动识别这台实例,后续启动、停止和日志仍走受控通道。" }, + { icon: ServerCog, title: "建立生命周期", copy: "Run 自动识别这台实例,后续启动、停止和日志仍走平台通道。" }, { icon: HeartPulse, title: "健康验证", copy: "确认端口、进程与配置可读后,才标记为接管成功。" } ] : [ { icon: FolderCog, title: "定位服务端根目录", copy: "填写已有服务端文件、数据与配置所在的主目录。" }, { icon: ScanSearch, title: "Run 本机预检", copy: "检查目录权限、插件识别和端口是否可用。" }, { icon: SlidersHorizontal, title: "只读扫描配置", copy: "读取插件需要的现有状态,不把新建默认值写进服务器。" }, - { icon: ServerCog, title: "建立受控生命周期", copy: "后续运行操作由自动识别的 Run 通过平台通道执行。" }, + { icon: ServerCog, title: "建立生命周期", copy: "后续运行操作由自动识别的 Run 通过平台通道执行。" }, { icon: HeartPulse, title: "健康验证", copy: "验证通过后才标记为接管成功。" } ]; - return
确认后,{pluginName} 会这样接管目录只会交给 Run 在本机使用;平台、浏览器和日志都不会显示原始路径。
先扫描,后自动识别
    {steps.map(({ icon: Icon, title, copy }, index) =>
  1. {index + 1}. {title}{copy}
  2. )}
{isScum ?

SCUM 与 SteamCMD:接管只需要服务端根目录,不需要填写 SteamCMD 目录。Run 可能按本机策略检查 SteamCMD 是否可用,但它不是接管输入。
升级:接管不会升级游戏;当前平台尚未提供 SCUM 服务端的受控升级任务,不能承诺自动升级。升级能力需要单独的 SteamCMD 更新任务与备份/健康验证流程。

:

不会做:不会重新安装、覆盖已有游戏配置,或把受保护路径回显给浏览器。

}
; + return
确认后,{pluginName} 会这样接管目录只会交给 Run 在本机使用;平台、浏览器和日志都不会显示原始路径。
先扫描,后自动识别
    {steps.map(({ icon: Icon, title, copy }, index) =>
  1. {index + 1}. {title}{copy}
  2. )}
{isScum ?

SCUM 与 SteamCMD:接管只需要服务端根目录,不需要填写 SteamCMD 目录。Run 可能按本机策略检查 SteamCMD 是否可用,但它不是接管输入。
升级:接管不会升级游戏;当前平台尚未提供 SCUM 服务端的自动升级任务,不能承诺自动升级。升级能力需要单独的 SteamCMD 更新任务与备份/健康验证流程。

:

不会做:不会重新安装、覆盖已有游戏配置,或把受保护路径回显给浏览器。

}
; } diff --git a/platform_web/components/ServerManagementTerminalDrawer.test.tsx b/platform_web/components/ServerManagementTerminalDrawer.test.tsx index ad36612..2bb1f13 100644 --- a/platform_web/components/ServerManagementTerminalDrawer.test.tsx +++ b/platform_web/components/ServerManagementTerminalDrawer.test.tsx @@ -4,15 +4,15 @@ import { act } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { GameClientBridgeCommandResponse, LogEntryBody, LogStreamResponse } from "../api/types"; +import type { JobResponse, LogEntryBody, LogStreamResponse, SourceRCONCommandResponse } from "../api/types"; import { ServerManagementTerminalDrawer } from "./ServerManagementTerminalDrawer"; const apiMocks = vi.hoisted(() => ({ - getGameClientBridgeCommand: vi.fn(), + dispatchSourceRCONCommand: vi.fn(), + getJob: vi.fn(), listLogStreams: vi.fn(), openServerLogEvents: vi.fn(), - queryLogStream: vi.fn(), - queueGameClientBridgeCommand: vi.fn() + queryLogStream: vi.fn() })); vi.mock("../api/client", () => ({ platformApiClient: apiMocks })); @@ -85,7 +85,7 @@ describe("ServerManagementTerminalDrawer", () => { expect(Array.from(container?.querySelectorAll(".terminal-text") ?? []).filter((node) => node.textContent === "generation A current replay")).toHaveLength(1); expect(apiMocks.openServerLogEvents).toHaveBeenCalledTimes(1); expect(apiMocks.listLogStreams).not.toHaveBeenCalled(); - expect(apiMocks.queueGameClientBridgeCommand).not.toHaveBeenCalled(); + expect(apiMocks.dispatchSourceRCONCommand).not.toHaveBeenCalled(); }); it("renders an empty current session without accepting unrelated or sessionless logs", async () => { @@ -97,7 +97,7 @@ describe("ServerManagementTerminalDrawer", () => { expect(container?.textContent).toContain("当前没有可跟随的受管进程输出"); expect(container?.textContent).not.toContain("legacy output must stay historical"); - expect(apiMocks.queueGameClientBridgeCommand).not.toHaveBeenCalled(); + expect(apiMocks.dispatchSourceRCONCommand).not.toHaveBeenCalled(); }); it("clears generation A on a new session and rejects late generation A events", async () => { @@ -166,18 +166,18 @@ describe("ServerManagementTerminalDrawer", () => { expect(container?.textContent).not.toContain("selected historical output"); expect(apiMocks.listLogStreams).toHaveBeenCalledWith("server-1"); expect(apiMocks.queryLogStream).toHaveBeenCalledWith({ logStreamId: oldStream.id, afterSeq: 400, limit: 500 }); - expect(apiMocks.queueGameClientBridgeCommand).not.toHaveBeenCalled(); + expect(apiMocks.dispatchSourceRCONCommand).not.toHaveBeenCalled(); }); it("uses RCON only after an operator submits a command", async () => { - const pending = bridgeCommand("pending"); - const succeeded = bridgeCommand("succeeded"); - apiMocks.queueGameClientBridgeCommand.mockResolvedValue(pending); - apiMocks.getGameClientBridgeCommand.mockResolvedValue(succeeded); + const pending = sourceRCONDispatch("queued"); + const succeeded = jobResponse("succeeded"); + apiMocks.dispatchSourceRCONCommand.mockResolvedValue(pending); + apiMocks.getJob.mockResolvedValue(succeeded); await renderDrawer(); await emitSession("session-current"); await emitLog("stdout-current", "session-current", "process.stdout", logEntry(1, "ordinary live output")); - expect(apiMocks.queueGameClientBridgeCommand).not.toHaveBeenCalled(); + expect(apiMocks.dispatchSourceRCONCommand).not.toHaveBeenCalled(); const input = container?.querySelector('.terminal-command-form input'); const form = container?.querySelector('.terminal-command-form'); @@ -186,8 +186,9 @@ describe("ServerManagementTerminalDrawer", () => { await act(async () => form.dispatchEvent(new SubmitEvent("submit", { bubbles: true, cancelable: true }))); await flushPromises(); - expect(apiMocks.queueGameClientBridgeCommand).toHaveBeenCalledTimes(1); - expect(apiMocks.getGameClientBridgeCommand).toHaveBeenCalledWith("server-1", pending.id); + expect(apiMocks.dispatchSourceRCONCommand).toHaveBeenCalledTimes(1); + expect(apiMocks.dispatchSourceRCONCommand).toHaveBeenCalledWith("server-1", expect.objectContaining({ kind: "command", command: "#ListPlayers" })); + expect(apiMocks.getJob).toHaveBeenCalledWith(pending.jobId); expect(container?.textContent).toContain("ordinary live output"); }); }); @@ -246,10 +247,24 @@ function logEntry(seq: number, line: string, timestamp = `2026-08-14T00:00:0${se return { seq, timestamp, line, redacted: true }; } -function bridgeCommand(state: GameClientBridgeCommandResponse["state"]): GameClientBridgeCommandResponse { +function sourceRCONDispatch(status: SourceRCONCommandResponse["status"]): SourceRCONCommandResponse { + return { jobId: "job-rcon-1", serverInstanceId: "server-1", status, message: "queued" }; +} + +function jobResponse(state: JobResponse["state"]): JobResponse { return { - id: "command-1", serverInstanceId: "server-1", pluginId: "game.scum", profileKey: "scum-rcon", commandType: "management.command", priority: 50, state, approvalState: "not_required", - result: state === "succeeded" ? { status: "succeeded", summary: "command completed", completedAt: "2026-08-14T00:00:03Z" } : undefined, - expiresAt: "2026-08-14T00:01:00Z", createdAt: "2026-08-14T00:00:00Z", updatedAt: "2026-08-14T00:00:03Z", completedAt: state === "succeeded" ? "2026-08-14T00:00:03Z" : undefined + id: "job-rcon-1", + serverInstanceId: "server-1", + runEndpointId: "run-1", + capability: "remote.run.rcon.command", + targetKey: "source-rcon/command", + idempotencyKey: "idem-rcon-1", + state, + progress: { percent: state === "succeeded" ? 100 : 50, message: state === "succeeded" ? "command completed" : "running" }, + retryPolicy: { maxAttempts: 1, initialBackoffSeconds: 1, maxBackoffSeconds: 1 }, + attempt: 1, + reconcileCount: 0, + createdAt: "2026-08-14T00:00:00Z", + updatedAt: "2026-08-14T00:00:03Z" }; } diff --git a/platform_web/components/ServerManagementTerminalDrawer.tsx b/platform_web/components/ServerManagementTerminalDrawer.tsx index c68e64e..9695070 100644 --- a/platform_web/components/ServerManagementTerminalDrawer.tsx +++ b/platform_web/components/ServerManagementTerminalDrawer.tsx @@ -2,8 +2,8 @@ import { History, ListChecks, Send, Sparkles, Terminal, Trash2, X } from "lucide import { type FormEvent, type KeyboardEvent as ReactKeyboardEvent, type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { platformApiClient } from "../api/client"; -import type { GameClientBridgeCommandResponse, LogEntryBody, LogStreamResponse } from "../api/types"; -import { scumManagementRCONCommandRequest } from "../schemas/scumManagementRcon"; +import type { JobResponse, LogEntryBody, LogStreamResponse } from "../api/types"; +import { scumSourceRCONCommandRequest } from "../schemas/scumManagementRcon"; import { cx } from "../utils/classes"; import { mergeLogStreams, parseLogSessionEvent, parseLogStreamEvent, parseServerLogEvent, streamFromServerLogEvent } from "../utils/logEvents"; import { EmptyState, ResultBadge } from "./StateViews"; @@ -13,8 +13,8 @@ type HistoryLineState = { status: "idle" } | LoadState; type TerminalLine = { id: string; tone: "input" | "info" | "success" | "warn" | "error"; text: string; at: string; sortKey: number; streamKey?: string; level?: string; seq?: number }; type TerminalQuickCommand = { label: string; command: string; hint: string }; -const terminalBridgeResultPollMs = 1000; -const terminalBridgeResultPollAttempts = 30; +const terminalJobResultPollMs = 1000; +const terminalJobResultPollAttempts = 30; const terminalInitialHistoryWindow = 500; const maxTerminalLines = 10000; const terminalQuickCommandCatalog: Record = { @@ -285,17 +285,17 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu setResult({ status: "pending", label: "正在提交命令" }); appendLines([terminalSystemLine("input", `> ${submitted}`, "COMMAND")]); try { - const response = await platformApiClient.queueGameClientBridgeCommand(serverId, scumManagementRCONCommandRequest(serverId, submitted)); - const label = bridgeCommandDispatchLabel(response.state, response.id); + const response = await platformApiClient.dispatchSourceRCONCommand(serverId, scumSourceRCONCommandRequest(serverId, submitted)); + const label = rconJobDispatchLabel(response.status, response.jobId); setResult({ status: "pending", label: `${label} · 等待 Run 返回结果` }); - appendLines([terminalSystemLine("success", `${label} · protected RCON`, "PLATFORM", `ok-${response.id}`)]); - const finalCommand = await waitForBridgeCommandTerminal(response.id); - if (finalCommand) { - const outcome = terminalLineFromBridgeCommand(finalCommand); + appendLines([terminalSystemLine("success", `${label} · Source RCON`, "PLATFORM", `ok-${response.jobId}`)]); + const finalJob = await waitForRCONJobTerminal(response.jobId); + if (finalJob) { + const outcome = terminalLineFromJob(finalJob); setResult({ status: outcome.tone === "success" ? "succeeded" : "failed", label: outcome.text }); appendLines([outcome]); } else { - const timeoutLine = terminalSystemLine("warn", `桥接命令 ${response.id} 已排队,但尚未返回终态;继续观察实时日志。`, "PLATFORM", `pending-${response.id}`); + const timeoutLine = terminalSystemLine("warn", `RCON 任务 ${response.jobId} 已排队,但尚未返回终态;继续观察实时日志。`, "PLATFORM", `pending-${response.jobId}`); setResult({ status: "pending", label: "等待 Run 返回结果" }); appendLines([timeoutLine]); } @@ -308,11 +308,11 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu } } - async function waitForBridgeCommandTerminal(commandId: string): Promise { - for (let attempt = 0; attempt < terminalBridgeResultPollAttempts; attempt += 1) { - const current = await platformApiClient.getGameClientBridgeCommand(serverId, commandId); - if (isTerminalBridgeCommandState(current.state)) return current; - await delay(terminalBridgeResultPollMs); + async function waitForRCONJobTerminal(jobId: string): Promise { + for (let attempt = 0; attempt < terminalJobResultPollAttempts; attempt += 1) { + const current = await platformApiClient.getJob(jobId); + if (isTerminalJobState(current.state)) return current; + await delay(terminalJobResultPollMs); } return null; } @@ -401,8 +401,8 @@ function terminalQuickCommandsForPlugin(pluginId: string): TerminalQuickCommand[ return terminalQuickCommandCatalog[pluginId] ?? []; } -function bridgeCommandDispatchLabel(state: string, commandId: string): string { - return `已${state === "pending" ? "排队" : "提交"} · 桥接命令 ${commandId}`; +function rconJobDispatchLabel(state: string, jobId: string): string { + return `已${state === "queued" ? "排队" : "提交"} · RCON 任务 ${jobId}`; } function terminalLineFromLog(stream: LogStreamResponse, entry: LogEntryBody): TerminalLine { @@ -433,7 +433,6 @@ function terminalSourceClass(value?: string): string { if (key.includes("stdout")) return "stdout"; if (key.includes("command")) return "command"; if (key.includes("platform")) return "platform"; - if (key.includes("bridge")) return "bridge"; if (key.includes("system")) return "system"; return "log"; } @@ -448,27 +447,27 @@ function eventBelongsToLiveSession(eventSessionId: string | undefined, liveSessi return Boolean(normalizedEventSessionId && normalizedEventSessionId === liveSessionId); } -function isTerminalBridgeCommandState(state: GameClientBridgeCommandResponse["state"]): boolean { - return state === "succeeded" || state === "failed" || state === "cancelled" || state === "expired" || state === "unknown"; +function isTerminalJobState(state: JobResponse["state"]): boolean { + return state === "succeeded" || state === "failed" || state === "cancelled"; } -function terminalLineFromBridgeCommand(command: GameClientBridgeCommandResponse): TerminalLine { - const summary = command.result?.summary || command.resultSummary || command.cancellation?.reason || bridgeCommandStateLabel(command.state); - const completed = command.completedAt || command.result?.completedAt || command.cancellation?.cancelledAt || command.updatedAt; +function terminalLineFromJob(job: JobResponse): TerminalLine { + const summary = job.progress.message || job.executionResult?.summary || job.cancelReason || jobStateLabel(job.state); + const completed = job.updatedAt; const sortKey = Date.parse(completed) || Date.now(); - const tone: TerminalLine["tone"] = command.state === "succeeded" ? "success" : command.state === "failed" ? "error" : "warn"; - return { id: `bridge-${command.id}-${command.state}`, tone, text: `桥接命令 ${command.id} · ${bridgeCommandStateLabel(command.state)} · ${summary}`, at: new Date(sortKey).toLocaleTimeString(), sortKey, streamKey: "BRIDGE" }; + const tone: TerminalLine["tone"] = job.state === "succeeded" ? "success" : job.state === "failed" ? "error" : "warn"; + return { id: `rcon-job-${job.id}-${job.state}`, tone, text: `RCON 任务 ${job.id} · ${jobStateLabel(job.state)} · ${summary}`, at: new Date(sortKey).toLocaleTimeString(), sortKey, streamKey: "PLATFORM" }; } -function bridgeCommandStateLabel(state: GameClientBridgeCommandResponse["state"]): string { +function jobStateLabel(state: JobResponse["state"]): string { switch (state) { case "succeeded": return "已成功"; case "failed": return "已失败"; case "cancelled": return "已取消"; - case "expired": return "已过期"; - case "unknown": return "状态未知"; - case "claimed": return "Run 已领取"; - case "pending": return "已排队"; + case "accepted": return "Run 已领取"; + case "running": return "运行中"; + case "retrying": return "等待重试"; + case "queued": return "已排队"; } } diff --git a/platform_web/contracts/operationsConsole.test.ts b/platform_web/contracts/operationsConsole.test.ts index f242ac9..b3c3279 100644 --- a/platform_web/contracts/operationsConsole.test.ts +++ b/platform_web/contracts/operationsConsole.test.ts @@ -106,7 +106,7 @@ describe("operations console contracts", () => { expect(summarizeEndpointOperations(endpoints)).toEqual({ total: 2, online: 1, degraded: 1, offline: 0, disabled: 0, activeJobs: 3, queuedJobs: 4 }); }); - it("projects an allowlisted operation tray shape and protects unsafe target strings", () => { + it("projects a declared operation tray shape and protects unsafe target strings", () => { const operation = { id: "op-1", intent: "更新配置", diff --git a/platform_web/contracts/pages.md b/platform_web/contracts/pages.md index ea85a4e..7547861 100644 --- a/platform_web/contracts/pages.md +++ b/platform_web/contracts/pages.md @@ -12,7 +12,7 @@ All first-party pages inherit the platform_web game-operations style with black- ## 平台概览(原首页) -Platform-administrator-only first screen. Shows online/offline server counts, abnormal instance count, run endpoint health, game type distribution, CPU/memory/disk load, LLM provider connectivity, and recent operational signals (faults, failed jobs, provider errors, audit events) that link to the relevant server, plugin, or AI provider context. Each module loads independently with scoped loading/empty/error states. +Platform-administrator-only first screen. Shows online/offline server counts, abnormal instance count, run endpoint health, game type distribution, CPU/memory/disk load, LLM provider connectivity, and recent operational signals (faults, failed jobs, provider errors) that link to the relevant server, plugin, or AI provider context. Each module loads independently with scoped loading/empty/error states. ## 服务器管理 @@ -31,7 +31,7 @@ Shows available and installed game management plugins, versions, capabilities, a ## 用户管理 -Shows users, roles, permissions, status, and audit entry points. Platform administrators only. +Shows users, roles, permissions, status, and access-management entry points. Platform administrators only. ## AI 提供商管理 @@ -39,4 +39,4 @@ Shows configured model providers, base URL, model list, relay mode, status, and ## 系统维护 -Shows run endpoint health/capacity and audit events. Platform administrators only. +Shows run endpoint health/capacity and operational events. Platform administrators only. diff --git a/platform_web/pages/ConsolePages.test.tsx b/platform_web/pages/ConsolePages.test.tsx index edbc47e..af943d2 100644 --- a/platform_web/pages/ConsolePages.test.tsx +++ b/platform_web/pages/ConsolePages.test.tsx @@ -103,15 +103,13 @@ describe("first-party console pages", () => { }, metrics: { status: "error", reason: "metrics unavailable", diagnosticId: "metrics-1" }, usage: { status: "error", reason: "usage unavailable", diagnosticId: "usage-1" }, - providers: { status: "error", reason: "providers unavailable", diagnosticId: "providers-1" }, - signals: { status: "error", reason: "audit unavailable", diagnosticId: "audit-1" } + providers: { status: "error", reason: "providers unavailable", diagnosticId: "providers-1" } }} /> ); - expect(html).toContain("4 个模块不可用"); + expect(html).toContain("3 个模块不可用"); expect(html).toContain("资源指标不可用"); - expect(html).toContain("审计信号不可用"); expect(html).toContain("AI 提供商信号不可用"); expect(html).toContain("server.lifecycle.start"); expect(html).not.toContain("暂无异常信号"); @@ -131,10 +129,9 @@ describe("first-party console pages", () => { core: { status: "ready", data: { instances: [], endpoints: [], jobs: [] }, refreshedAt: "2026-07-18T10:00:00Z" }, metrics: { status: "ready", data: [], refreshedAt: "2026-07-18T10:00:00Z" }, usage: { status: "error", reason: "unavailable", diagnosticId: "usage" }, - providers: { status: "ready", data: [], refreshedAt: "2026-07-18T10:00:00Z" }, - signals: { status: "ready", data: [], refreshedAt: "2026-07-18T10:00:00Z" } - }} - /> + providers: { status: "ready", data: [], refreshedAt: "2026-07-18T10:00:00Z" } + }} + /> ); expect(html).toContain("没有创建服务器的权限"); @@ -196,7 +193,7 @@ describe("first-party console pages", () => { expect(serverDeploymentWorkflowSource).toContain("不会重装或覆盖现有游戏配置"); expect(serverDeploymentWorkflowSource).toContain("接管已有服务器执行流程"); expect(serverDeploymentWorkflowSource).toContain("不需要填写 SteamCMD 目录"); - expect(serverDeploymentWorkflowSource).toContain("当前平台尚未提供 SCUM 服务端的受控升级任务"); + expect(serverDeploymentWorkflowSource).toContain("当前平台尚未提供 SCUM 服务端的自动升级任务"); expect(serverDeploymentWorkflowSource).toContain("Run 会按心跳自动识别服务器"); expect(serversPageSource).toContain('onNavigate("serverDetail", { serverId: result.instance.id })'); expect(serverDetailPageSource).not.toContain("运行配置绑定"); @@ -229,7 +226,7 @@ describe("first-party console pages", () => { expect(serverDetailPageSource).not.toContain("操作历史"); expect(serverDetailPageSource).toContain("打开终端"); expect(serverManagementTerminalSource).toContain("openServerLogEvents"); - expect(serverManagementTerminalSource).toContain("queueGameClientBridgeCommand"); + expect(serverManagementTerminalSource).toContain("dispatchSourceRCONCommand"); expect(serverManagementTerminalSource).toContain("terminalQuickCommandCatalog"); expect(serverManagementTerminalSource).not.toContain("password"); expect(serverManagementTerminalSource).not.toContain("direct socket"); @@ -342,7 +339,7 @@ describe("first-party console pages", () => { expect(serverDetailPageSource).toContain("打开终端"); expect(serverManagementTerminalSource).toContain("terminalQuickCommandCatalog"); expect(serverDetailPageSource).not.toContain("scumManagementRCONCommandRequest"); - expect(serverManagementTerminalSource).toContain("queueGameClientBridgeCommand"); + expect(serverManagementTerminalSource).toContain("dispatchSourceRCONCommand"); expect(serverDetailPageSource).not.toContain("commandHistory"); }); @@ -379,7 +376,7 @@ describe("first-party console pages", () => { expect(html).toContain("维护排障入口"); expect(html).toContain("节点详情"); expect(html).toContain("最近失败任务"); - expect(html).toContain("审计异常"); + expect(html).toContain("最近失败任务"); }); it("renders profile settings as a full page", () => { diff --git a/platform_web/pages/HomePage.tsx b/platform_web/pages/HomePage.tsx index c1f88d5..9552c75 100644 --- a/platform_web/pages/HomePage.tsx +++ b/platform_web/pages/HomePage.tsx @@ -17,7 +17,6 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { platformApiClient } from "../api/client"; import type { AiProviderResponse, - AuditEventResponse, JobResponse, PlatformResourceUsageResponse, RunEndpointResponse, @@ -25,7 +24,7 @@ import type { ServerMetricsResponse } from "../api/types"; import { UsageMeter } from "../components/OperationControls"; -import { ProductionGovernancePanel } from "../components/ProductionGovernancePanel"; +import { ProductionOperationsPanel } from "../components/ProductionOperationsPanel"; import { EmptyState, ErrorState, LoadingState } from "../components/StateViews"; import { jobBuckets, moduleFreshnessLabel, summarizeEndpointOperations, type OperationsModuleState } from "../contracts/operationsConsole"; import { jobCapabilityLabel } from "../contracts/jobPresentation"; @@ -45,7 +44,6 @@ export interface HomePageInitialState { metrics?: OperationsModuleState; usage?: OperationsModuleState; providers?: OperationsModuleState; - signals?: OperationsModuleState; } interface HomePageProps extends PageComponentProps { @@ -57,7 +55,6 @@ export function HomePage({ session, onNavigate, initialState }: HomePageProps) { const [metrics, setMetrics] = useState>(initialState?.metrics ?? { status: "loading" }); const [usage, setUsage] = useState>(initialState?.usage ?? { status: "loading" }); const [providers, setProviders] = useState>(initialState?.providers ?? { status: "loading" }); - const [signals, setSignals] = useState>(initialState?.signals ?? { status: "loading" }); const refreshCore = useCallback(async () => { setCore({ status: "loading" }); @@ -103,23 +100,12 @@ export function HomePage({ session, onNavigate, initialState }: HomePageProps) { } }, []); - const refreshSignals = useCallback(async () => { - setSignals({ status: "loading" }); - try { - const response = await platformApiClient.listAuditEvents(); - setSignals({ status: "ready", data: response.items, refreshedAt: refreshedNow() }); - } catch (error) { - setSignals({ status: "error", reason: errorMessage(error, "审计事件加载失败"), diagnosticId: "overview-audit-events" }); - } - }, []); - const refreshAll = useCallback(() => { void refreshCore(); void refreshMetrics(); void refreshUsage(); void refreshProviders(); - void refreshSignals(); - }, [refreshCore, refreshMetrics, refreshProviders, refreshSignals, refreshUsage]); + }, [refreshCore, refreshMetrics, refreshProviders, refreshUsage]); useEffect(() => { if (initialState) { @@ -139,14 +125,14 @@ export function HomePage({ session, onNavigate, initialState }: HomePageProps) { return [...counts.entries()].map(([serverType, count]) => ({ serverType, label: serverType, count })); }, [core]); - const overviewSignals = useMemo(() => buildOverviewSignals(core, providers, signals), [core, providers, signals]); + const overviewSignals = useMemo(() => buildOverviewSignals(core, providers), [core, providers]); const jobs = core.status === "ready" ? jobBuckets(core.data.jobs) : null; const endpointSummary = core.status === "ready" ? summarizeEndpointOperations(core.data.endpoints) : null; const onlineCount = core.status === "ready" ? core.data.instances.filter((item) => serverIsOnline(item.state)).length : 0; const offlineCount = core.status === "ready" ? core.data.instances.length - onlineCount : 0; const activeProviders = providers.status === "ready" ? providers.data.filter((item) => item.status === "active").length : 0; const errorProviders = providers.status === "ready" ? providers.data.filter((item) => item.status === "error").length : 0; - const moduleFailureCount = [core, metrics, usage, providers, signals].filter((module) => module.status === "error").length; + const moduleFailureCount = [core, metrics, usage, providers].filter((module) => module.status === "error").length; const canManageServers = session.capabilities.includes("servers.manage"); const metricAverages = useMemo(() => { @@ -293,7 +279,7 @@ export function HomePage({ session, onNavigate, initialState }: HomePageProps) {
- +
@@ -342,15 +328,14 @@ export function HomePage({ session, onNavigate, initialState }: HomePageProps) {

最近运营信号

- {moduleFreshnessLabel(signals.status === "ready" ? signals.refreshedAt : signals.refreshedAt)} - + {moduleFreshnessLabel(core.status === "ready" ? core.refreshedAt : providers.status === "ready" ? providers.refreshedAt : undefined)} +
- {signals.status === "loading" && core.status === "loading" && } - {signals.status === "error" && void refreshSignals()} compact />} + {core.status === "loading" && providers.status === "loading" && } {providers.status === "error" && void refreshProviders()} compact />} - {overviewSignals.length === 0 && signals.status === "ready" && core.status === "ready" && providers.status === "ready" ? ( - + {overviewSignals.length === 0 && core.status === "ready" && providers.status === "ready" ? ( + ) : (
{overviewSignals.map((signal) => ( @@ -374,8 +359,7 @@ export function HomePage({ session, onNavigate, initialState }: HomePageProps) { function buildOverviewSignals( core: OperationsModuleState, - providers: OperationsModuleState, - signals: OperationsModuleState + providers: OperationsModuleState ): PlatformOverviewSignal[] { const collected: PlatformOverviewSignal[] = []; if (core.status === "ready") { @@ -391,11 +375,6 @@ function buildOverviewSignals( collected.push({ id: `ai-${provider.id}`, kind: "aiProvider", summary: `AI 提供商 ${provider.name} 连接异常`, detail: "LLM 辅助暂不可用,请在提供商管理中检查已保存配置。", targetPage: "aiProviders", targetId: provider.id, tone: "warning", at: "" }); } } - if (signals.status === "ready") { - for (const event of signals.data.slice(0, 5)) { - collected.push({ id: `audit-${event.id}`, kind: "log", summary: event.summary || `${event.action} ${event.resourceKind}`, detail: `${event.resourceKind}/${event.resourceId}:${event.result}`, targetPage: event.resourceKind === "server-instance" ? "servers" : "maintenance", targetId: event.resourceId, tone: event.result === "failure" ? "warning" : "info", at: event.createdAt }); - } - } return collected.sort((left, right) => Date.parse(right.at || "") - Date.parse(left.at || "")).slice(0, 8); } diff --git a/platform_web/pages/MaintenancePage.tsx b/platform_web/pages/MaintenancePage.tsx index 0e87bc7..32694ad 100644 --- a/platform_web/pages/MaintenancePage.tsx +++ b/platform_web/pages/MaintenancePage.tsx @@ -1,10 +1,10 @@ -import { Activity, ListChecks, RotateCcw, ServerCog, Sparkles, WandSparkles } from "lucide-react"; +import { ListChecks, RotateCcw, ServerCog, Sparkles, WandSparkles } from "lucide-react"; import { useCallback, useEffect, useMemo, useState } from "react"; import { platformApiClient } from "../api/client"; -import type { AuditEventResponse, JobResponse, RunEndpointResponse, ServerInstanceResponse } from "../api/types"; +import type { JobResponse, RunEndpointResponse, ServerInstanceResponse } from "../api/types"; import { EmptyState, ErrorState, LoadingState, ResultBadge } from "../components/StateViews"; -import { ProductionGovernancePanel } from "../components/ProductionGovernancePanel"; +import { ProductionOperationsPanel } from "../components/ProductionOperationsPanel"; import { jobCapabilityLabel } from "../contracts/jobPresentation"; import type { PageComponentProps } from "../contracts/page"; import { cx } from "../utils/classes"; @@ -13,7 +13,6 @@ type ModuleState = { status: "loading" } | { status: "error"; reason: string export function MaintenancePage({ session, operations, onNavigate }: PageComponentProps) { const [endpoints, setEndpoints] = useState>({ status: "loading" }); - const [events, setEvents] = useState>({ status: "loading" }); const [jobs, setJobs] = useState>({ status: "loading" }); const [servers, setServers] = useState>({ status: "loading" }); const [triageResult, setTriageResult] = useState<{ status: "pending" | "succeeded" | "failed"; label: string } | null>(null); @@ -28,16 +27,6 @@ export function MaintenancePage({ session, operations, onNavigate }: PageCompone } }, []); - const refreshEvents = useCallback(async () => { - setEvents({ status: "loading" }); - try { - const response = await platformApiClient.listAuditEvents(); - setEvents({ status: "ready", data: response.items }); - } catch (error) { - setEvents({ status: "error", reason: error instanceof Error ? error.message : "加载失败" }); - } - }, []); - const refreshJobs = useCallback(async () => { setJobs({ status: "loading" }); try { @@ -60,23 +49,20 @@ export function MaintenancePage({ session, operations, onNavigate }: PageCompone const refreshAll = useCallback(() => { void refreshEndpoints(); - void refreshEvents(); void refreshJobs(); void refreshServers(); - }, [refreshEndpoints, refreshEvents, refreshJobs, refreshServers]); + }, [refreshEndpoints, refreshJobs, refreshServers]); useEffect(() => { refreshAll(); }, [refreshAll]); const endpointItems = endpoints.status === "ready" ? endpoints.data : []; - const eventItems = events.status === "ready" ? events.data : []; const jobItems = jobs.status === "ready" ? jobs.data : []; const serverItems = servers.status === "ready" ? servers.data : []; const failedJobs = useMemo(() => jobItems.filter((job) => job.state === "failed").slice(0, 8), [jobItems]); const serverById = useMemo(() => new Map(serverItems.map((server) => [server.id, server])), [serverItems]); const endpointById = useMemo(() => new Map(endpointItems.map((endpoint) => [endpoint.id, endpoint])), [endpointItems]); - const failedAuditCount = eventItems.filter((event) => event.result !== "success").length; const unhealthyEndpointCount = endpointItems.filter((endpoint) => endpoint.status !== "online" || heartbeatAgeMinutes(endpoint.lastHeartbeatAt) > 5).length; async function retryJob(job: JobResponse) { @@ -121,7 +107,7 @@ export function MaintenancePage({ session, operations, onNavigate }: PageCompone
维护排障入口 - 从节点详情、最近失败任务和审计异常进入重试、查看相关服务器、查看日志链路,不需要直接接触 run 端。 + 从节点详情和最近失败任务进入重试、查看相关服务器、查看日志链路,不需要直接接触 run 端。
@@ -137,17 +123,11 @@ export function MaintenancePage({ session, operations, onNavigate }: PageCompone {jobs.status === "ready" ? `${failedJobs.length} 个失败` : "加载中"} 从失败任务进入重试、查看相关服务器和查看日志链路。 -
{triageResult && } - +
@@ -262,53 +242,6 @@ export function MaintenancePage({ session, operations, onNavigate }: PageCompone )}
-
-
-

审计事件

-
- {events.status === "loading" && } - {events.status === "error" && ( - void refreshEvents()} compact /> - )} - {events.status === "ready" && events.data.length === 0 && ( - void refreshEvents()} /> - )} - {events.status === "ready" && events.data.length > 0 && ( -
- {events.data.slice(0, 30).map((event) => ( -
-
- {event.summary || `${event.action} ${event.resourceKind}`} - {event.result} -
-
- - 事件 {event.id} - - 操作者 {event.actorId} - - 资源 {event.resourceKind}/{event.resourceId} - - {formatTimestamp(event.createdAt)} -
-
- - -
-
- ))} -
- )} -
); } @@ -355,10 +288,6 @@ function heartbeatAgeMinutes(value: string): number { return (Date.now() - timestamp) / 60000; } -function auditResultClass(event: AuditEventResponse): string { - return event.result === "success" ? "status-active" : "status-error"; -} - function jobStateLabel(state: JobResponse["state"]): string { switch (state) { case "queued": diff --git a/platform_web/pages/PluginPageHostPage.test.tsx b/platform_web/pages/PluginPageHostPage.test.tsx index e0d868c..f397ed3 100644 --- a/platform_web/pages/PluginPageHostPage.test.tsx +++ b/platform_web/pages/PluginPageHostPage.test.tsx @@ -71,11 +71,11 @@ const plugin: GamePluginResponse = { aiPurposes: [], productionLifecycle: { operations: ["install", "enable", "disable", "upgrade", "rollback", "retire", "dependency-check"], dependencyPolicy: "required", approvalRequired: ["disable", "rollback", "retire"] }, gameClientBridge: { - commands: [{ type: "announcement.send", title: "Send announcement", permission: "server.game-client.command", approvalLevel: "operator", payloadSchemaRef: "schemas/bridge/announcement.json", timeoutSeconds: 30, maxPayloadBytes: 4096 }], + commands: [{ type: "diagnostic.ping", title: "Diagnostic ping", permission: "server.game-client.command", approvalLevel: "operator", 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, - pages: [{ pageKey: "players", commandTypes: ["announcement.send"], snapshotTypes: ["companion.health"] }] + pages: [{ pageKey: "players", commandTypes: ["diagnostic.ping"], snapshotTypes: ["companion.health"] }] }, status: "installed" }; diff --git a/platform_web/pages/ServerDetailPage.test.tsx b/platform_web/pages/ServerDetailPage.test.tsx index 0fa5ba6..1e8675f 100644 --- a/platform_web/pages/ServerDetailPage.test.tsx +++ b/platform_web/pages/ServerDetailPage.test.tsx @@ -98,7 +98,7 @@ describe("ServerDetailPage config write approval", () => { it("uses the historical terminal drawer without restoring separate raw command panels", () => { expect(serverDetailPageSource).not.toContain("SourceRCONCommandPanel"); - expect(serverManagementTerminalSource).toContain("queueGameClientBridgeCommand"); + expect(serverManagementTerminalSource).toContain("dispatchSourceRCONCommand"); expect(serverDetailPageSource).not.toContain("scumManagementRCONCommandRequest"); expect(serverDetailPageSource).toContain("打开终端"); expect(serverDetailPageSource).toContain("ServerManagementTerminalDrawer"); diff --git a/platform_web/pages/ServerDetailPage.tsx b/platform_web/pages/ServerDetailPage.tsx index 254a1a0..fc64b7b 100644 --- a/platform_web/pages/ServerDetailPage.tsx +++ b/platform_web/pages/ServerDetailPage.tsx @@ -398,7 +398,7 @@ function ServerDeploymentSection({ instance, deployment }: ServerDeploymentSecti

部署定义

{view.mode || "未配置"} · 修订 {view.revision}

服务器目录是主目录;执行目录只用于高级自定义启动,留空时继承服务器目录。路径和命令均为受保护输入,不会回显。

服务器目录{view.serverRootConfigured ? "已配置" : "未配置"}
高级执行目录{view.workingDirectoryConfigured ? "已配置" : "使用服务器目录"}
启动设置{view.startCommandConfigured ? "已配置" : view.mode === "custom-command" ? "未配置" : "插件引导"}
{view.latestDispatch &&
最近 Run 调度{view.latestDispatch.deploymentDefinitionIncluded ? `部署定义已随任务发送 · r${view.latestDispatch.deploymentRevision} · ${view.latestDispatch.jobState}` : "未携带部署定义"}
}{view.latestDispatch?.runConfirmed &&
Run 执行确认已按 r{view.latestDispatch.deploymentRevision} 确认执行
}
- {isScumTemplate &&
SCUM 受控模板{projection?.templateVersion ? `${projection.templateKey ?? "已选择"} · v${projection.templateVersion}` : "等待 Run 预检"}
预检 / 扫描{deploymentProjectionLabel(projection?.preflightState)} / {deploymentProjectionLabel(projection?.discoveryState)}
配置映射 / 健康验证{deploymentProjectionLabel(projection?.mappingState)} / {deploymentProjectionLabel(projection?.verificationState)}
{projection?.failureCode &&
失败原因{projection.failureCode}
}
} + {isScumTemplate &&
SCUM 部署模板{projection?.templateVersion ? `${projection.templateKey ?? "已选择"} · v${projection.templateVersion}` : "等待 Run 预检"}
预检 / 扫描{deploymentProjectionLabel(projection?.preflightState)} / {deploymentProjectionLabel(projection?.discoveryState)}
配置映射 / 健康验证{deploymentProjectionLabel(projection?.mappingState)} / {deploymentProjectionLabel(projection?.verificationState)}
{projection?.failureCode &&
失败原因{projection.failureCode}
}
}
; } diff --git a/platform_web/pages/UsersPage.tsx b/platform_web/pages/UsersPage.tsx index f42e3b9..24c9e74 100644 --- a/platform_web/pages/UsersPage.tsx +++ b/platform_web/pages/UsersPage.tsx @@ -477,7 +477,7 @@ export function UsersPage({ session, operations, initialState }: UsersPageProps) { const parsed = parseSafeJobResponse({ ...retryingJob, state: "succeeded", - executionResult: { kind: "file.write", version: 2, checksum: "sha256:" + "a".repeat(64), sizeBytes: 18, auditSummary: "atomic compare-and-swap file write" } + executionResult: { kind: "file.write", version: 2, checksum: "sha256:" + "a".repeat(64), sizeBytes: 18, summary: "atomic compare-and-swap file write" } }); expect(parsed.executionResult).toMatchObject({ kind: "file.write", version: 2, sizeBytes: 18 }); expect(parsed.executionResult).not.toHaveProperty("content"); diff --git a/platform_web/schemas/jobs.ts b/platform_web/schemas/jobs.ts index 1919b25..25034a2 100644 --- a/platform_web/schemas/jobs.ts +++ b/platform_web/schemas/jobs.ts @@ -57,7 +57,7 @@ function optionalExecutionResult(value: Record, key: string): J version: optionalNumber(field, "version"), checksum: optionalString(field, "checksum"), sizeBytes: optionalNumber(field, "sizeBytes"), - auditSummary: optionalString(field, "auditSummary") + summary: optionalString(field, "summary") }; return result; } diff --git a/platform_web/schemas/scumManagementRcon.test.ts b/platform_web/schemas/scumManagementRcon.test.ts index f48a612..9ed9125 100644 --- a/platform_web/schemas/scumManagementRcon.test.ts +++ b/platform_web/schemas/scumManagementRcon.test.ts @@ -1,24 +1,21 @@ import { describe, expect, it } from "vitest"; -import { scumAnnouncementCommand, scumManagementRCONCommandRequest } from "./scumManagementRcon"; +import { scumSourceRCONChatRequest, scumSourceRCONCommandRequest } from "./scumManagementRcon"; -describe("SCUM management RCON bridge schema", () => { - it("builds a protected bridge request without connection material", () => { +describe("SCUM management Source RCON schema", () => { + it("builds a direct RCON job request without connection material", () => { const stamp = Date.UTC(2026, 7, 3, 8, 0, 0); - expect(scumManagementRCONCommandRequest("server/unsafe", " ListPlayers ", stamp)).toEqual({ - profileKey: "scum-client-manager", - commandType: "management.rcon.request", - payload: { requestText: "#ListPlayers" }, - idempotencyKey: `web:scum-rcon:server-unsafe:${stamp}`, - priority: 20, - expiresAt: "2026-08-03T08:02:00.000Z" + expect(scumSourceRCONCommandRequest("server/unsafe", " ListPlayers ", stamp)).toEqual({ + kind: "command", + command: "#ListPlayers", + idempotencyKey: `web:scum-rcon:server-unsafe:${stamp}` }); }); - it("formats announcements and rejects framed command text", () => { - expect(scumAnnouncementCommand("Restart in ten minutes")).toBe("#Announce Restart in ten minutes"); - expect(scumManagementRCONCommandRequest("server-1", "#SetTime 12").payload).toEqual({ requestText: "#SetTime 12" }); - expect(() => scumManagementRCONCommandRequest("server-1", "ListPlayers\nSetTime 12")).toThrow("管理指令必须是受限的单行文本"); + it("formats direct commands and rejects framed text", () => { + expect(scumSourceRCONChatRequest("server-1", "Restart in ten minutes", 42)).toEqual({ kind: "chat", chatType: 4, message: "Restart in ten minutes", idempotencyKey: "web:scum-rcon-chat:server-1:42" }); + expect(scumSourceRCONCommandRequest("server-1", "#SetTime 12").command).toBe("#SetTime 12"); + expect(() => scumSourceRCONCommandRequest("server-1", "ListPlayers\nSetTime 12")).toThrow("管理指令必须是单行文本"); }); }); diff --git a/platform_web/schemas/scumManagementRcon.ts b/platform_web/schemas/scumManagementRcon.ts index ca8660b..85fa5a1 100644 --- a/platform_web/schemas/scumManagementRcon.ts +++ b/platform_web/schemas/scumManagementRcon.ts @@ -1,31 +1,30 @@ -import type { GameClientBridgeQueueRequest } from "../api/types"; - -export const scumManagementRCONProfileKey = "scum-client-manager"; -export const scumManagementRCONCommandType = "management.rcon.request"; +import type { SourceRCONCommandRequest } from "../api/types"; const maxManagementCommandBytes = 8192; -const managementCommandTtlMs = 120_000; -export function scumManagementRCONCommandRequest(serverInstanceId: string, command: string, sequence = Date.now()): GameClientBridgeQueueRequest { +export function scumSourceRCONCommandRequest(serverInstanceId: string, command: string, sequence = Date.now()): SourceRCONCommandRequest { const stamp = Math.max(0, Math.floor(sequence)); return { - profileKey: scumManagementRCONProfileKey, - commandType: scumManagementRCONCommandType, - payload: { requestText: normalizeSCUMManagementCommand(command, "管理指令") }, + kind: "command", + command: normalizeSCUMManagementCommand(command, "管理指令"), idempotencyKey: `web:scum-rcon:${safeBridgeIdentifierPart(serverInstanceId)}:${stamp}`, - priority: 20, - expiresAt: new Date(stamp + managementCommandTtlMs).toISOString() }; } -export function scumAnnouncementCommand(message: string): string { - return `#Announce ${validateSCUMManagementRCONText(message, "公告内容")}`; +export function scumSourceRCONChatRequest(serverInstanceId: string, message: string, sequence = Date.now()): SourceRCONCommandRequest { + const stamp = Math.max(0, Math.floor(sequence)); + return { + kind: "chat", + chatType: 4, + message: validateSCUMManagementRCONText(message, "聊天内容"), + idempotencyKey: `web:scum-rcon-chat:${safeBridgeIdentifierPart(serverInstanceId)}:${stamp}` + }; } export function validateSCUMManagementRCONText(value: string, label: string): string { const normalized = value.trim(); if (!normalized || new TextEncoder().encode(normalized).byteLength > maxManagementCommandBytes || /[\u0000\r\n]/.test(normalized)) { - throw new Error(`${label}必须是受限的单行文本。`); + throw new Error(`${label}必须是单行文本。`); } return normalized; } diff --git a/platform_web/theme/base.css b/platform_web/theme/base.css index 8803589..3ac27c1 100644 --- a/platform_web/theme/base.css +++ b/platform_web/theme/base.css @@ -589,7 +589,7 @@ to{transform:translate(-50%,-50%) rotate(calc(var(--construct-drift) + 360deg))} .console-row-actions .theme-upload,.maintenance-actions .theme-upload,.user-actions .theme-upload{min-height:30px} .console-record-list,.operation-list{display:grid;gap:10px} .console-record,.operation-item{display:grid;gap:8px;padding:12px 14px;border:1px solid var(--line);border-radius:8px;background:var(--glass-wash),var(--glass-tint),var(--surface);box-shadow:inset 0 1px 0 var(--crystal-rim);position:relative;overflow:hidden;min-width:0} -.ai-diff-review-panel,.production-governance-panel{margin-block:14px} +.ai-diff-review-panel,.production-operations-panel{margin-block:14px} .console-stat-strip-spaced,.production-capacity-strip{margin-bottom:12px} .console-record-list-spaced,.production-alert-list{margin-top:12px} .plugin-lifecycle-controls,.production-alert-actions{flex-wrap:wrap} diff --git a/plugins/README.md b/plugins/README.md index 48b1c2d..f51731e 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -43,7 +43,7 @@ Runtime profiles are declarative contracts, not executable scripts. A profile ca Client-manager profiles declare repository URL, revision policy, semantic version, supported target OS/architecture pairs, a fixed build adapter, config template keys, produced artifacts, and an optional complete lifecycle contract. The lifecycle contract names a safe relative executable, fixed arguments, required Run capabilities, bounded start/stop/restart/status/update/rollback/uninstall actions, heartbeat/process health thresholds, component capabilities, compatibility bounds, and a manual staged-update policy. It cannot contain arbitrary shell, absolute/traversing paths, direct sockets, endpoints, raw credentials, or secret/session values. -Platform performs target and lifecycle validation, creates a real build record, injects a distinct server/component key into the generated package config, redacts build logs, and publishes a downloadable artifact. For profiles with a complete lifecycle contract, the artifact can then be deployed by a typed Run job into a controlled workspace, registered using a separate short-lived component session, health-checked, controlled, updated/rolled back, revoked, and safely uninstalled. The Run key and client-manager key/session remain separate; resetting the client-manager key revokes old packages and sessions and requires a current-generation rebuild and redeploy. +Platform performs target and lifecycle validation, creates a real build record, injects a distinct server/component key into the generated package config, redacts build logs, and publishes a downloadable artifact. For profiles with a complete lifecycle contract, the artifact can then be deployed by a typed Run job into a managed workspace, registered using a separate short-lived component session, health-checked, started/stopped, updated/rolled back, revoked, and safely uninstalled. The Run key and client-manager key/session remain separate; resetting the client-manager key revokes old packages and sessions and requires a current-generation rebuild and redeploy. Plugin pages may request these operations only through bridge helpers: @@ -51,7 +51,7 @@ Plugin pages may request these operations only through bridge helpers: - `createDependencyActionRequest`: check or install declared dependency probes/plans. - `createLogBackfillRequest`: request historical log cursors for declared sources. - `createClientManagerRequest`: generate/download/reset or request safe status/deploy/control/update/rollback/revoke/retry/uninstall operations for declared client-manager packages. -- `createProductionPluginLifecycleRequest`: request server-bound install/enable/disable/upgrade/rollback/retire/dependency-check through Platform governance. +- `createProductionPluginLifecycleRequest`: request server-bound install/enable/disable/upgrade/rollback/retire/dependency-check through Platform operations. - `parseClientManagerLifecycleStatus`: whitelist the plugin-visible status, version, health, artifact/job IDs, deployment generation, and allowed actions without component secrets or machine details. Bridge envelopes carry operation names, profile keys, target platforms, artifact IDs, checkpoint refs, immutable reviewed dependency plan digests, and idempotency keys only. Dependency install bridge helpers require a `sha256:<64 hex>` reviewed plan digest; Platform re-resolves the declaration and rejects stale or missing approvals. The plugin SDK and manifest validation reject raw run keys, client-manager keys, FTP passwords, rsync endpoints, SQL DSNs, RCON passwords, direct run sockets, host paths, and arbitrary shell snippets. diff --git a/plugins/examples/dev-game-plugin/page-bundle/index.ts b/plugins/examples/dev-game-plugin/page-bundle/index.ts index be8889e..97f1aaf 100644 --- a/plugins/examples/dev-game-plugin/page-bundle/index.ts +++ b/plugins/examples/dev-game-plugin/page-bundle/index.ts @@ -19,7 +19,7 @@ function renderOverviewPage(e: ReactLike["createElement"], input: any) { } function renderConfigPage(e: ReactLike["createElement"], input: any) { - return renderPanel(e, "配置工作台", "配置入口由插件页面声明,平台只提供受控上下文。", input, [ + return renderPanel(e, "配置工作台", "配置入口由插件页面声明,平台只提供运行上下文。", input, [ ["文件权限", (input.context?.permissions ?? []).filter((value: string) => value.includes("files")).join(" / ") || "未声明"], ["AI 能力", (input.context?.permissions ?? []).includes("ai.invoke") ? "可请求平台 AI" : "未声明"], ["写入策略", "平台审查后派发"] diff --git a/plugins/examples/minecraft-server-plugin/page-bundle/index.ts b/plugins/examples/minecraft-server-plugin/page-bundle/index.ts index 1be43b0..ff302e7 100644 --- a/plugins/examples/minecraft-server-plugin/page-bundle/index.ts +++ b/plugins/examples/minecraft-server-plugin/page-bundle/index.ts @@ -14,7 +14,7 @@ function renderFiles(e: ReactLike["createElement"], input: any, workspace: any) const files = workspace?.files ?? []; return e("section", { className: "console-panel", "aria-label": "Minecraft 文件管理" }, e("div", { className: "panel-header" }, - e("div", null, e("h2", null, "文件管理"), e("p", { className: "provider-id" }, "server.properties、白名单、OP 列表和日志由 Minecraft 插件声明。")), + e("div", null, e("h2", null, "文件管理"), e("p", { className: "provider-id" }, "server.properties、whitelist.json、OP 列表和日志由 Minecraft 插件声明。")), e("span", { className: "page-status" }, input.context?.serverInstanceId ? "已绑定服务器" : "未绑定服务器") ), e("div", { className: "console-row-list" }, diff --git a/plugins/examples/scum-server-plugin/companion/UE4SS_CAPABILITY.md b/plugins/examples/scum-server-plugin/companion/UE4SS_CAPABILITY.md index 6b23398..04caff8 100644 --- a/plugins/examples/scum-server-plugin/companion/UE4SS_CAPABILITY.md +++ b/plugins/examples/scum-server-plugin/companion/UE4SS_CAPABILITY.md @@ -14,4 +14,4 @@ access. Semantic events come from bounded Run stdout/stderr console records. Unknown records create diagnostics and never produce fabricated events. DSNs, rows, connections, and credentials do not leave Run; request text is protected and -redacted from browser and audit projections. +redacted from browser and platform projections. diff --git a/plugins/examples/scum-server-plugin/companion/adapters.go b/plugins/examples/scum-server-plugin/companion/adapters.go index 52bf874..eb59c39 100644 --- a/plugins/examples/scum-server-plugin/companion/adapters.go +++ b/plugins/examples/scum-server-plugin/companion/adapters.go @@ -13,7 +13,7 @@ import ( var errAdapterUnsupported = errors.New("runtime capability is unavailable") // AuthorizedConfigPort is supplied through the platform-authorized Run channel. -// It exposes logical, allowlisted configuration values only; it never exposes a +// It exposes logical, declared configuration values only; it never exposes a // path, DSN, credential, arbitrary command, or database handle. type AuthorizedConfigPort interface { ReadConfig(context.Context) (map[string]string, error) @@ -25,7 +25,7 @@ type ConfigFieldPatch struct { } // AuthorizedGameDataPort is a typed, Run-owned read/patch boundary. Implementations -// must probe their local schema, use field allowlists and safe windows, and return +// must probe their local schema, use declared fields and safe windows, and return // bounded snapshots rather than rows or connection details. type AuthorizedGameDataPort interface { ReadPlayerState(context.Context, string, []string) (PlayerStateSnapshot, error) @@ -109,11 +109,11 @@ type UE4SSNotificationPort interface { } type UE4SSNotificationReceipt struct{ Accepted bool } type ue4SSPlayerNotification struct { - ServerID string - RecipientSteamID string - Message string - chatType int - protectedAuditCommand string + ServerID string + RecipientSteamID string + Message string + chatType int + localCommandPreview string } type UE4SSVehicleSpawnPort interface { SpawnVehicle(context.Context, ue4SSVehicleSpawn) (UE4SSVehicleSpawnReceipt, error) @@ -128,9 +128,9 @@ const ( type UE4SSVehicleSpawnReceipt struct{ Outcome UE4SSVehicleSpawnOutcome } type ue4SSVehicleSpawn struct { - ServerID string - VehicleCode string - protectedAuditCommand string + ServerID string + VehicleCode string + localCommandPreview string } // RuntimeAdapter is bound to one server. Availability is discovered from its @@ -312,13 +312,13 @@ func newUE4SSPlayerNotification(serverID, playerID, message string) (ue4SSPlayer if strings.TrimSpace(serverID) == "" || !steamID64(playerID) || !validNotificationMessage(message) { return ue4SSPlayerNotification{}, fmt.Errorf("invalid typed notification") } - return ue4SSPlayerNotification{ServerID: serverID, RecipientSteamID: playerID, Message: message, chatType: fixedNotificationType, protectedAuditCommand: "SendChat 4 \"" + escapeUE4SSChatMessage(message) + "\" " + playerID}, nil + return ue4SSPlayerNotification{ServerID: serverID, RecipientSteamID: playerID, Message: message, chatType: fixedNotificationType, localCommandPreview: "SendChat 4 \"" + escapeUE4SSChatMessage(message) + "\" " + playerID}, nil } func newUE4SSVehicleSpawn(serverID, vehicleCode string) (ue4SSVehicleSpawn, error) { if strings.TrimSpace(serverID) == "" || !supportedVehicleSpawnCode(vehicleCode) { return ue4SSVehicleSpawn{}, fmt.Errorf("invalid typed vehicle spawn") } - return ue4SSVehicleSpawn{ServerID: serverID, VehicleCode: vehicleCode, protectedAuditCommand: "#spawnvehicle " + vehicleCode}, nil + return ue4SSVehicleSpawn{ServerID: serverID, VehicleCode: vehicleCode, localCommandPreview: "#spawnvehicle " + vehicleCode}, nil } func steamID64(value string) bool { if len(value) != 17 { diff --git a/plugins/examples/scum-server-plugin/companion/adapters_e2e_test.go b/plugins/examples/scum-server-plugin/companion/adapters_e2e_test.go index c7e0026..b1c6b28 100644 --- a/plugins/examples/scum-server-plugin/companion/adapters_e2e_test.go +++ b/plugins/examples/scum-server-plugin/companion/adapters_e2e_test.go @@ -105,13 +105,13 @@ func TestSupportedAdaptersDispatchThroughIsolatedTypedPorts(t *testing.T) { if err := dispatcher.DispatchOnce(context.Background()); err != nil { t.Fatalf("dispatch supported adapters: %v", err) } - if len(port.notifications) != 1 || port.notifications[0].ServerID != "server-1" || port.notifications[0].protectedAuditCommand == "" { + if len(port.notifications) != 1 || port.notifications[0].ServerID != "server-1" || port.notifications[0].localCommandPreview == "" { t.Fatalf("notification did not remain server-bound and idempotent: %+v", port.notifications) } if len(port.patches) != 1 || gateway.completed["config-read"][0].Payload["fields"].(map[string]string)["ServerName"] != "Moonlight" || gateway.completed["config-patch"][0].Payload["appliedFields"].(map[string]string)["ServerName"] != "Moonlight" || gateway.completed["notify"][0].Payload["accepted"] != true { t.Fatalf("supported adapters did not return their bounded successful results: patches=%+v completed=%+v", port.patches, gateway.completed) } - if len(port.spawns) != 3 || port.spawns[0].protectedAuditCommand != "#spawnvehicle BPC_Laika_C" || port.spawns[1].protectedAuditCommand != "#spawnvehicle BPC_WolfsWagen_C" || port.spawns[2].protectedAuditCommand != "#spawnvehicle BPC_Laika_C" { + if len(port.spawns) != 3 || port.spawns[0].localCommandPreview != "#spawnvehicle BPC_Laika_C" || port.spawns[1].localCommandPreview != "#spawnvehicle BPC_WolfsWagen_C" || port.spawns[2].localCommandPreview != "#spawnvehicle BPC_Laika_C" { t.Fatalf("vehicle adapter did not use only fixed private templates: %+v", port.spawns) } if gateway.completed["spawn-success"][0].Payload["outcome"] != "succeeded" || gateway.completed["spawn-failed"][0].Payload["outcome"] != "failed" || gateway.completed["spawn-unknown"][0].Payload["outcome"] != "unknown" { @@ -173,8 +173,8 @@ func TestSupportedAdapterTransportFailuresCompleteWithoutProtectedOutput(t *test t.Fatalf("%s did not redact failed typed-port output: %+v", id, result) } } - if len(port.notifications) != 1 || port.notifications[0].protectedAuditCommand == "" { - t.Fatalf("notification fixture did not receive one protected typed request: %+v", port.notifications) + if len(port.notifications) != 1 || port.notifications[0].localCommandPreview == "" { + t.Fatalf("notification fixture did not receive one typed request: %+v", port.notifications) } } diff --git a/plugins/examples/scum-server-plugin/companion/adapters_test.go b/plugins/examples/scum-server-plugin/companion/adapters_test.go index bf2d5c8..c1de996 100644 --- a/plugins/examples/scum-server-plugin/companion/adapters_test.go +++ b/plugins/examples/scum-server-plugin/companion/adapters_test.go @@ -197,10 +197,10 @@ func TestUE4SSNotificationIsTypedAndRedacted(t *testing.T) { t.Fatalf("typed notification was not delivered: result=%+v err=%v deliveries=%+v", result, err, port.deliveries) } delivery := port.deliveries[0] - if delivery.ServerID != "server-1" || delivery.chatType != fixedNotificationType || delivery.protectedAuditCommand != "SendChat 4 \"Moon \\\"gift\\\"\" 76561198000000001" { + if delivery.ServerID != "server-1" || delivery.chatType != fixedNotificationType || delivery.localCommandPreview != "SendChat 4 \"Moon \\\"gift\\\"\" 76561198000000001" { t.Fatalf("notification did not use the fixed UE4SS contract: %+v", delivery) } - if result["message"] == delivery.protectedAuditCommand || result["command"] != nil || result["rcon"] != nil { + if result["message"] == delivery.localCommandPreview || result["command"] != nil || result["rcon"] != nil { t.Fatalf("notification leaked protected transport details: %+v", result) } } @@ -230,7 +230,7 @@ func TestNotificationFailureIsCachedWithoutInvokingRewardDelivery(t *testing.T) } } -func TestVersionedVehicleSpawnUsesFixedTemplateAndPrivateAuditOnly(t *testing.T) { +func TestVersionedVehicleSpawnUsesFixedTemplateAndPrivatePreviewOnly(t *testing.T) { port := &nonProductionVehicleSpawnPortFixture{receipt: UE4SSVehicleSpawnReceipt{Outcome: UE4SSVehicleSpawnAccepted}} adapter := RuntimeAdapter{BoundServerID: "server-1", VehicleSpawn: port} result, err := adapter.SpawnVehicle(context.Background(), map[string]any{"vehicleCode": "BPC_Laika_C"}) @@ -238,10 +238,10 @@ func TestVersionedVehicleSpawnUsesFixedTemplateAndPrivateAuditOnly(t *testing.T) t.Fatalf("fixed vehicle spawn was not delivered: result=%+v err=%v requests=%+v", result, err, port.requests) } request := port.requests[0] - if request.ServerID != "server-1" || request.VehicleCode != "BPC_Laika_C" || request.protectedAuditCommand != "#spawnvehicle BPC_Laika_C" { + if request.ServerID != "server-1" || request.VehicleCode != "BPC_Laika_C" || request.localCommandPreview != "#spawnvehicle BPC_Laika_C" { t.Fatalf("vehicle spawn did not use the fixed template: %+v", request) } - if result["command"] != nil || result["rcon"] != nil || result["audit"] != nil || result["outcome"] == request.protectedAuditCommand { + if result["command"] != nil || result["rcon"] != nil || result["outcome"] == request.localCommandPreview { t.Fatalf("vehicle spawn leaked protected transport details: %+v", result) } } diff --git a/plugins/examples/scum-server-plugin/companion/orchestration_test.go b/plugins/examples/scum-server-plugin/companion/orchestration_test.go index dd334e5..9579a21 100644 --- a/plugins/examples/scum-server-plugin/companion/orchestration_test.go +++ b/plugins/examples/scum-server-plugin/companion/orchestration_test.go @@ -112,7 +112,7 @@ func TestRunOneShotSmokeLeavesUnsupportedCommandUnackedAndUnexecuted(t *testing. return jsonHTTPResponse(http.StatusOK, heartbeatResponse{Accepted: true, InstallationID: config.Component.InstallationID, Status: "online", Health: "healthy", NextHeartbeatSeconds: 30, SessionExpiresAt: sessionExpiry, ServerTime: stamp}), nil case 2: step++ - return jsonHTTPResponse(http.StatusOK, claimResponse{Items: []ClaimedCommand{{ID: "unsupported-1", ProfileKey: ProfileKey, CommandType: "announcement.send", Payload: map[string]any{"message": "must not execute"}, FencingToken: 18, LeaseExpiresAt: stamp.Add(time.Minute), ExpiresAt: stamp.Add(5 * time.Minute)}}, Count: 1}), nil + return jsonHTTPResponse(http.StatusOK, claimResponse{Items: []ClaimedCommand{{ID: "unsupported-1", ProfileKey: ProfileKey, CommandType: "diagnostic.unsupported", Payload: map[string]any{"message": "must not execute"}, FencingToken: 18, LeaseExpiresAt: stamp.Add(time.Minute), ExpiresAt: stamp.Add(5 * time.Minute)}}, Count: 1}), nil default: return nil, fmt.Errorf("unsupported command triggered transport: %s", request.URL.Path) } diff --git a/plugins/examples/scum-server-plugin/features/api.ts b/plugins/examples/scum-server-plugin/features/api.ts index 3fa9378..4936e73 100644 --- a/plugins/examples/scum-server-plugin/features/api.ts +++ b/plugins/examples/scum-server-plugin/features/api.ts @@ -24,5 +24,5 @@ export function createSCUMFeatureAPI(bridge: PluginFeatureBridge, availableFeatu }; } -function commandResult(result: { status: string; result?: Record; error?: { message: string } }): SCUMCommandResult { if (result.status === "queued") return { status: "queued", summary: result.result?.summary ?? "已进入受控队列。" }; if (result.status === "unsupported") return { status: "unsupported", summary: result.error?.message ?? "当前运行时不支持此操作。" }; return { status: "failed", summary: result.error?.message ?? "受控操作未被接受。" }; } +function commandResult(result: { status: string; result?: Record; error?: { message: string } }): SCUMCommandResult { if (result.status === "queued") return { status: "queued", summary: result.result?.summary ?? "已进入任务队列。" }; if (result.status === "unsupported") return { status: "unsupported", summary: result.error?.message ?? "当前运行时不支持此操作。" }; return { status: "failed", summary: result.error?.message ?? "操作未被接受。" }; } function decode(result: Record | undefined): T | null { const payload = result?.payload; if (!payload) return null; try { return JSON.parse(payload) as T; } catch { return null; } } diff --git a/plugins/examples/scum-server-plugin/features/contracts.ts b/plugins/examples/scum-server-plugin/features/contracts.ts index f8ac608..c5b77dd 100644 --- a/plugins/examples/scum-server-plugin/features/contracts.ts +++ b/plugins/examples/scum-server-plugin/features/contracts.ts @@ -6,7 +6,7 @@ export type SCUMMigrationProvenance = "plugin" | "transitional-read-only"; export type SCUMMigrationRecord> = { provenance: SCUMMigrationProvenance; readOnly: boolean; payload: T; recordedAt: string; sourceRecordId?: string }; export type SCUMFeatureMigrationAuthority = { serverInstanceId: string; feature: SCUMFeatureKey; authority: "plugin" | "transitional-read-only"; reason?: string }; export type SCUMFeatureMigrationStatus = { authority: "plugin" | "transitional-read-only"; readOnlyHistory: true; pluginWritesEnabled: boolean; reason?: string }; -export type SCUMCommandResult = { status: "delivered" | "failed" | "unknown" | "unsupported" | "validation-failed" | "queued"; summary: string; audit?: Record }; +export type SCUMCommandResult = { status: "delivered" | "failed" | "unknown" | "unsupported" | "validation-failed" | "queued"; summary: string }; export type SCUMVehicleSpawn = { vehicleCode: string }; export type SCUMVehicleSpawnOption = { code: string; label: string }; export type SCUMLogicalDirectory = { key: string; label: string; scope: "config" | "logs" }; diff --git a/plugins/examples/scum-server-plugin/features/migration.ts b/plugins/examples/scum-server-plugin/features/migration.ts index 34e9b4b..f6ab7b8 100644 --- a/plugins/examples/scum-server-plugin/features/migration.ts +++ b/plugins/examples/scum-server-plugin/features/migration.ts @@ -23,7 +23,7 @@ export function migratePlayerRecord(record: Record): SCUMMigrat } export function migrateConfigurationRecord(record: Record): SCUMMigrationRecord | null { - const fields = allowlistedConfigFields(record.fields); const observedAt = timestamp(record.observedAt) ?? timestamp(record.updatedAt); if (!fields || !observedAt) return null; + const fields = declaredConfigFields(record.fields); const observedAt = timestamp(record.observedAt) ?? timestamp(record.updatedAt); if (!fields || !observedAt) return null; return transitionalReadOnly({ fields, observedAt }, observedAt, text(record.id)); } @@ -63,7 +63,7 @@ function migratePoint(value: unknown, defaultSubjectId: string, defaultSubjectTy function migrateSession(value: unknown, defaultPlayerId: string): SCUMPlayerSession | null { const record = object(value); const id = record && text(record.id); const playerId = record && (text(record.gamePlayerRecordId) ?? text(record.playerId) ?? defaultPlayerId); const startedAt = record && timestamp(record.startedAt); if (!id || !playerId || !startedAt) return null; const endedAt = timestamp(record.endedAt); return { id, playerId, kind: endedAt ? "logout" : "login", occurredAt: endedAt ?? startedAt }; } function migrateRisk(value: unknown): SCUMPlayerRisk | null { const record = object(value); const observedAt = record && (timestamp(record.occurredAt) ?? timestamp(record.lastObservedAt)); const kind = record && (text(record.ruleKey) ?? text(record.outcome)); const summary = record && (text(record.summary) ?? text(record.reason)); if (!observedAt || !kind || !summary) return null; return { kind, level: "medium", observedAt, summary }; } function migrateStateChange(value: unknown): { fieldKey: string; before: number; after: number } | null { const record = object(value); if (!record) return null; const fieldKey = text(record.fieldKey); const before = number(record.before); const after = number(record.after); return fieldKey && before !== undefined && after !== undefined ? { fieldKey, before, after } : null; } -function allowlistedConfigFields(value: unknown): Record | null { const fields = object(value); const allowed = new Set(configurationCatalog.map((field) => field.configKey)); if (!fields || !allowed.size) return null; const result: Record = {}; for (const [key, field] of Object.entries(fields)) { if (allowed.has(key) && (typeof field === "string" || typeof field === "number" || typeof field === "boolean")) result[key] = String(field); } return Object.keys(result).length ? result : null; } +function declaredConfigFields(value: unknown): Record | null { const fields = object(value); const allowed = new Set(configurationCatalog.map((field) => field.configKey)); if (!fields || !allowed.size) return null; const result: Record = {}; for (const [key, field] of Object.entries(fields)) { if (allowed.has(key) && (typeof field === "string" || typeof field === "number" || typeof field === "boolean")) result[key] = String(field); } return Object.keys(result).length ? result : null; } function giftStatus(value: unknown): SCUMGiftGrant["status"] | null { return value === "pending-approval" || value === "queued" || value === "delivered" || value === "notification_failed" || value === "failed" || value === "unknown" ? value : null; } function stateStatus(value: unknown): SCUMStatePatch["status"] | null { if (value === "pending-approval" || value === "queued" || value === "unsupported" || value === "unknown" || value === "execution-unknown") return value === "execution-unknown" ? "unknown" : value; if (value === "confirmed") return "succeeded"; return value === "execution-failed" || value === "confirmation-failed" || value === "failed" ? "failed" : null; } function trajectorySubjectType(record: Record): SCUMTrajectoryPoint["subjectType"] | null { if (record.kind === "player" || record.kind === "vehicle") return record.kind; return text(record.playerRecordId) || text(record.gamePlayerRecordId) ? "player" : text(record.vehicleId) ? "vehicle" : null; } diff --git a/plugins/examples/scum-server-plugin/features/schemas.ts b/plugins/examples/scum-server-plugin/features/schemas.ts index 0ed2614..83d61fa 100644 --- a/plugins/examples/scum-server-plugin/features/schemas.ts +++ b/plugins/examples/scum-server-plugin/features/schemas.ts @@ -1,6 +1,6 @@ import type { SCUMConfigField, SCUMConfigPatch, SCUMFeatureAvailability, SCUMStateField, SCUMVehicleSpawn, SCUMVehicleSpawnOption } from "./contracts.js"; -// These are safe fallback allowlists. A Companion schema probe may narrow them +// These are safe fallback plugin catalogs. A Companion schema probe may narrow them // per server, but a game version never enables or disables a feature. export const configurationCatalog: readonly SCUMConfigField[] = [ { key: "server-name", fileKey: "scum-server-settings", configKey: "ServerName", label: "服务器名称", description: "显示在服务器浏览器与玩家连接界面。", control: "text", defaultValue: "SCUM Server", restartImpact: "restart-required" }, @@ -13,6 +13,6 @@ export const vehicleSpawnCatalog: readonly SCUMVehicleSpawnOption[] = [{ code: " export const stateFieldCatalog: readonly Omit[] = [{ key: "skills.running", label: "跑步技能", minimum: 0, maximum: 1000000 }, { key: "attributes.strength", label: "力量属性", minimum: 1, maximum: 8 }]; export function supportsStateField(field: string): boolean { return stateFieldCatalog.some((candidate) => candidate.key === field); } export function featureUnavailable(reason: string): SCUMFeatureAvailability { return { feature: "configuration", available: false, reason }; } -export function validateConfigPatch(patch: SCUMConfigPatch): string | null { if (!patch.idempotencyKey.trim() || !patch.reason.trim() || !patch.changes.length) return "配置修改必须包含原因、幂等键和至少一项变更。"; for (const change of patch.changes) { const field = configurationCatalog.find((candidate) => candidate.key === change.key); if (!field) return `字段 ${change.key} 不在受控目录中。`; if (!change.value.trim()) return `字段 ${field.label} 不能为空。`; if ((field.control === "number" || field.control === "port") && (!Number.isInteger(Number(change.value)) || (field.minimum !== undefined && Number(change.value) < field.minimum) || (field.maximum !== undefined && Number(change.value) > field.maximum))) return `字段 ${field.label} 超出允许范围。`; } return null; } -export function validateStatePatch(fields: Array<{ fieldKey: string; before: number; after: number }>): string | null { if (!fields.length) return "状态修改至少需要一个字段。"; for (const field of fields) { const definition = stateFieldCatalog.find((candidate) => candidate.key === field.fieldKey); if (!definition) return `字段 ${field.fieldKey} 不在运行时字段白名单中。`; if (!Number.isFinite(field.before) || !Number.isFinite(field.after) || field.after < definition.minimum || field.after > definition.maximum) return `字段 ${definition.label} 超出允许范围。`; } return null; } -export function validateVehicleSpawn(spawn: SCUMVehicleSpawn): string | null { if (!/^[A-Za-z][A-Za-z0-9_]{2,63}$/.test(spawn.vehicleCode)) return "载具代码格式无效。"; if (!vehicleSpawnCatalog.some((candidate) => candidate.code === spawn.vehicleCode)) return "载具代码未在受控目录中声明。"; return null; } +export function validateConfigPatch(patch: SCUMConfigPatch): string | null { if (!patch.idempotencyKey.trim() || !patch.reason.trim() || !patch.changes.length) return "配置修改必须包含原因、幂等键和至少一项变更。"; for (const change of patch.changes) { const field = configurationCatalog.find((candidate) => candidate.key === change.key); if (!field) return `字段 ${change.key} 不在插件目录中。`; if (!change.value.trim()) return `字段 ${field.label} 不能为空。`; if ((field.control === "number" || field.control === "port") && (!Number.isInteger(Number(change.value)) || (field.minimum !== undefined && Number(change.value) < field.minimum) || (field.maximum !== undefined && Number(change.value) > field.maximum))) return `字段 ${field.label} 超出允许范围。`; } return null; } +export function validateStatePatch(fields: Array<{ fieldKey: string; before: number; after: number }>): string | null { if (!fields.length) return "状态修改至少需要一个字段。"; for (const field of fields) { const definition = stateFieldCatalog.find((candidate) => candidate.key === field.fieldKey); if (!definition) return `字段 ${field.fieldKey} 未在插件运行时目录中声明。`; if (!Number.isFinite(field.before) || !Number.isFinite(field.after) || field.after < definition.minimum || field.after > definition.maximum) return `字段 ${definition.label} 超出允许范围。`; } return null; } +export function validateVehicleSpawn(spawn: SCUMVehicleSpawn): string | null { if (!/^[A-Za-z][A-Za-z0-9_]{2,63}$/.test(spawn.vehicleCode)) return "载具代码格式无效。"; if (!vehicleSpawnCatalog.some((candidate) => candidate.code === spawn.vehicleCode)) return "载具代码未在插件目录中声明。"; return null; } diff --git a/plugins/examples/scum-server-plugin/manifest.json b/plugins/examples/scum-server-plugin/manifest.json index 7306d66..d1ec79c 100644 --- a/plugins/examples/scum-server-plugin/manifest.json +++ b/plugins/examples/scum-server-plugin/manifest.json @@ -75,7 +75,7 @@ "remote.run.process.stop", "remote.run.logs.transfer", "remote.run.protected.sql", - "remote.run.protected.rcon", + "remote.run.rcon.command", "remote.run.program.command", "client-manager.deploy", "client-manager.control", @@ -100,7 +100,7 @@ "remote.run.process.stop", "remote.run.logs.transfer", "remote.run.protected.sql", - "remote.run.protected.rcon", + "remote.run.rcon.command", "remote.run.program.command" ], "databaseEngines": [ @@ -123,23 +123,6 @@ }, "gameClientBridge": { "commands": [ - { - "type": "announcement.send", - "title": "Send SCUM announcement", - "permission": "server.game-client.command", - "approvalLevel": "operator", - "payloadSchemaRef": "schemas/bridge/announcement.payload.schema.json", - "resultSchemaRef": "schemas/bridge/announcement.result.schema.json", - "timeoutSeconds": 60, - "maxPayloadBytes": 4096, - "protectedRequest": { - "kind": "rcon", - "transportKey": "scum-management", - "targetKey": "scum-management", - "textField": "requestText", - "maxTextBytes": 2048 - } - }, { "type": "companion.diagnostics", "title": "Collect companion diagnostics", @@ -497,13 +480,6 @@ "captureMappings": { "steamId": "steamId", "displayName": "displayName" }, "fixedValues": { "eventType": "login", "source": "process.stdout" }, "observedAtField": "observedAt" - }, - "announcement": { - "profileKey": "scum-client-manager", - "commandType": "announcement.send", - "textField": "requestText", - "newTextTemplate": "#announce 欢迎新玩家 {{displayName}} 加入服务器!", - "returningTextTemplate": "#announce 欢迎 {{displayName}} 继续游戏!" } } } @@ -522,91 +498,66 @@ "key": "player.fame.set", "title": "Set SCUM player fame through RCON", "permission": "server.game-client.command", - "approvalLevel": "operator", + "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", - "confirmationSchemaRef": "schemas/bridge/player-fame-set.confirmation.schema.json", "timeoutSeconds": 60, - "maxPayloadBytes": 2048, - "safety": { - "requiresApproval": true, - "requiresConfirmation": true - } + "maxPayloadBytes": 2048 }, { "key": "player.currency.normal.set", "title": "Set SCUM normal currency through RCON", "permission": "server.game-client.command", - "approvalLevel": "operator", + "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", - "confirmationSchemaRef": "schemas/bridge/player-currency-set.confirmation.schema.json", "timeoutSeconds": 60, - "maxPayloadBytes": 2048, - "safety": { - "requiresApproval": true, - "requiresConfirmation": true - } + "maxPayloadBytes": 2048 }, { "key": "player.currency.gold.set", "title": "Set SCUM gold currency through RCON", "permission": "server.game-client.command", - "approvalLevel": "operator", + "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", - "confirmationSchemaRef": "schemas/bridge/player-currency-set.confirmation.schema.json", "timeoutSeconds": 60, - "maxPayloadBytes": 2048, - "safety": { - "requiresApproval": true, - "requiresConfirmation": true - } + "maxPayloadBytes": 2048 }, { "key": "player.notify", "title": "Notify SCUM player through RCON chat", "permission": "server.game-client.command", - "approvalLevel": "operator", + "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", - "confirmationSchemaRef": "schemas/bridge/player-notify.confirmation.schema.json", "timeoutSeconds": 60, - "maxPayloadBytes": 2048, - "safety": { - "requiresApproval": true, - "requiresConfirmation": true - } + "maxPayloadBytes": 2048 }, { "key": "reward.deliver", - "title": "Deliver approved SCUM reward through typed command workflow", + "title": "Deliver SCUM reward through typed command workflow", "permission": "server.game-client.command", - "approvalLevel": "operator", + "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", - "confirmationSchemaRef": "schemas/bridge/reward-deliver.confirmation.schema.json", "timeoutSeconds": 60, - "maxPayloadBytes": 4096, - "safety": { - "requiresApproval": true, - "requiresConfirmation": true - } + "maxPayloadBytes": 4096 }, { "key": "player.attribute.855.set", @@ -1072,7 +1023,8 @@ "process.restart", "process.status", "remote.run.process.start", - "remote.run.process.stop" + "remote.run.process.stop", + "remote.run.rcon.command" ], "actionRefs": { "install": "actions/install.json", @@ -1082,7 +1034,11 @@ "status": "actions/status.json" }, "transportKeys": [ - "server-files" + "server-files", + "scum-management" + ], + "dllExtensionRefs": [ + "scum-simple-rcon" ], "platforms": [ "windows" @@ -1412,7 +1368,7 @@ "kind": "rcon", "targetKey": "scum-management", "capabilities": [ - "remote.run.protected.rcon" + "remote.run.rcon.command" ] }, { @@ -1424,6 +1380,32 @@ ] } ], + "dllExtensions": [ + { + "key": "scum-simple-rcon", + "displayName": "SCUM Simple RCON", + "kind": "ue4ss-dll", + "activation": "server-start", + "version": "0.1.0", + "releaseState": "ready", + "releaseUrl": "https://cdn.npc0.com/scum_simple_rcon_ue4s.dll", + "checksum": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "sizeBytes": 1024, + "targetKey": "ue4ss/scum-simple-rcon", + "modKey": "scum_simple_rcon", + "dllRef": "ue4ss/Mods/scum_simple_rcon/dlls/main.dll", + "scumExecutableChecksum": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "ue4ssAbi": "ue4ss-3.0", + "supportedTargets": [ + { + "os": "windows", + "arch": "amd64" + } + ], + "updateOnStart": true, + "rconPort": 27015 + } + ], "clientManagers": [ { "key": "scum-client-manager", diff --git a/plugins/examples/scum-server-plugin/schemas/bridge/announcement.payload.schema.json b/plugins/examples/scum-server-plugin/schemas/bridge/announcement.payload.schema.json deleted file mode 100644 index cc7c7bd..0000000 --- a/plugins/examples/scum-server-plugin/schemas/bridge/announcement.payload.schema.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "title": "SCUMAnnouncementPayload", - "type": "object", - "additionalProperties": false, - "required": ["requestText"], - "properties": { - "requestText": { - "type": "string", - "minLength": 1, - "maxLength": 2048 - } - } -} diff --git a/plugins/examples/scum-server-plugin/schemas/bridge/announcement.result.schema.json b/plugins/examples/scum-server-plugin/schemas/bridge/announcement.result.schema.json deleted file mode 100644 index 2888252..0000000 --- a/plugins/examples/scum-server-plugin/schemas/bridge/announcement.result.schema.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "title": "SCUMAnnouncementResult", - "type": "object", - "additionalProperties": false, - "required": ["accepted"], - "properties": { - "accepted": { - "type": "boolean" - }, - "messageId": { - "type": "string", - "minLength": 1, - "maxLength": 120 - } - } -} diff --git a/plugins/manifests/game-plugin.manifest.schema.json b/plugins/manifests/game-plugin.manifest.schema.json index 9ce551d..74f0c9a 100644 --- a/plugins/manifests/game-plugin.manifest.schema.json +++ b/plugins/manifests/game-plugin.manifest.schema.json @@ -317,20 +317,7 @@ "payloadSchemaRef": { "$ref": "#/$defs/relativeJsonRef" }, "resultSchemaRef": { "$ref": "#/$defs/relativeJsonRef" }, "timeoutSeconds": { "type": "integer", "minimum": 1, "maximum": 3600 }, - "maxPayloadBytes": { "type": "integer", "minimum": 1, "maximum": 65536 }, - "protectedRequest": { "$ref": "#/$defs/gameClientBridgeProtectedRequest" } - } - }, - "gameClientBridgeProtectedRequest": { - "type": "object", - "required": ["kind", "transportKey", "targetKey", "textField", "maxTextBytes"], - "additionalProperties": false, - "properties": { - "kind": { "enum": ["sql", "rcon", "program"] }, - "transportKey": { "$ref": "#/$defs/logicalKey" }, - "targetKey": { "$ref": "#/$defs/logicalKey" }, - "textField": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9._-]{0,79}$" }, - "maxTextBytes": { "type": "integer", "minimum": 1, "maximum": 16384 } + "maxPayloadBytes": { "type": "integer", "minimum": 1, "maximum": 65536 } } }, "gameClientBridgeSnapshot": { @@ -412,25 +399,12 @@ }, "gameClientBridgeLogProjectionPresence": { "type": "object", - "required": ["timestampField", "activeWindowSeconds", "announcement"], + "required": ["timestampField", "activeWindowSeconds"], "additionalProperties": false, "properties": { "timestampField": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9._-]{0,79}$" }, "activeWindowSeconds": { "type": "integer", "minimum": 1, "maximum": 31536000 }, - "activityTarget": { "$ref": "#/$defs/gameClientBridgeLogProjectionTarget" }, - "announcement": { "$ref": "#/$defs/gameClientBridgeLogProjectionAnnouncement" } - } - }, - "gameClientBridgeLogProjectionAnnouncement": { - "type": "object", - "required": ["profileKey", "commandType", "textField", "newTextTemplate", "returningTextTemplate"], - "additionalProperties": false, - "properties": { - "profileKey": { "$ref": "#/$defs/logicalKey" }, - "commandType": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,159}$" }, - "textField": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9._-]{0,79}$" }, - "newTextTemplate": { "type": "string", "minLength": 1, "maxLength": 4096 }, - "returningTextTemplate": { "type": "string", "minLength": 1, "maxLength": 4096 } + "activityTarget": { "$ref": "#/$defs/gameClientBridgeLogProjectionTarget" } } }, "gameClientBridgeDataPack": { @@ -480,7 +454,7 @@ "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": ["operator", "platform-admin"] }, + "approvalLevel": { "enum": ["none", "operator", "platform-admin"] }, "kind": { "enum": ["rcon", "sqlite-mutation"] }, "transportKey": { "$ref": "#/$defs/logicalKey" }, "targetKey": { "$ref": "#/$defs/logicalKey" }, @@ -552,7 +526,6 @@ "remote.run.logs.transfer", "remote.run.rcon.command", "remote.run.protected.sql", - "remote.run.protected.rcon", "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 f5d287c..7de7f1e 100644 --- a/plugins/scripts/validate-manifest.ts +++ b/plugins/scripts/validate-manifest.ts @@ -675,8 +675,7 @@ export function validateGameClientBridgeCatalog(manifest: unknown): string[] { if (typeof manifest !== "object" || manifest === null) { return []; } - type ProtectedRequest = { kind?: string; transportKey?: string; targetKey?: string; textField?: string; maxTextBytes?: number }; - type BridgeCommand = { type?: string; approvalLevel?: string; payloadSchemaRef?: string; resultSchemaRef?: string; protectedRequest?: ProtectedRequest }; + type BridgeCommand = { type?: string; approvalLevel?: string; payloadSchemaRef?: string; resultSchemaRef?: string }; type BridgeQueryTemplate = { key?: string; permission?: string; @@ -703,7 +702,6 @@ export function validateGameClientBridgeCatalog(manifest: unknown): string[] { timestampField?: string; activeWindowSeconds?: number; activityTarget?: BridgeLogProjectionTarget; - announcement?: { profileKey?: string; commandType?: string; textField?: string; newTextTemplate?: string; returningTextTemplate?: string }; }; }; type BridgeOperationSafety = { requiresApproval?: boolean; requiresOfflinePlayer?: boolean; requiresMaintenanceWindow?: boolean; requiresBeforeValue?: boolean; requiresConfirmation?: boolean; backupRequired?: boolean }; @@ -806,44 +804,10 @@ export function validateGameClientBridgeCatalog(manifest: unknown): string[] { for (const [index, command] of (bridge.commands ?? []).entries()) { const location = `manifest.gameClientBridge.commands[${index}]`; const type = command.type ?? ""; - const unsafeTypeReason = command.protectedRequest ? undefined : unsafeGameClientBridgeCommandTypeReason(type); + const unsafeTypeReason = unsafeGameClientBridgeCommandTypeReason(type); if (unsafeTypeReason) { errors.push(`${location}.type: ${unsafeTypeReason}`); } - if (!command.approvalLevel) { - errors.push(`${location}.approvalLevel: approval metadata is required`); - } - - const protectedRequest = command.protectedRequest; - if (protectedRequest) { - if (!new Set(["sql", "rcon", "program"]).has(protectedRequest.kind ?? "")) { - errors.push(`${location}.protectedRequest.kind: must be sql, rcon, or program`); - } - if (!/^[A-Za-z][A-Za-z0-9._-]{0,79}$/.test(protectedRequest.textField ?? "")) { - errors.push(`${location}.protectedRequest.textField: must be a safe bounded field name`); - } - if (!Number.isInteger(protectedRequest.maxTextBytes) || (protectedRequest.maxTextBytes ?? 0) < 1 || (protectedRequest.maxTextBytes ?? 0) > 16384) { - errors.push(`${location}.protectedRequest.maxTextBytes: must be between 1 and 16384`); - } - const transport = transportProfiles.find((candidate) => candidate.key === protectedRequest.transportKey); - if (!transport) { - errors.push(`${location}.protectedRequest.transportKey: must reference a declared runtime transport profile`); - } else { - if (!protectedRequest.targetKey || protectedRequest.targetKey !== transport.targetKey) { - errors.push(`${location}.protectedRequest.targetKey: must match the declared runtime transport target`); - } - const expectedCapability = { sql: "remote.run.protected.sql", rcon: "remote.run.protected.rcon", program: "remote.run.program.command" }[protectedRequest.kind ?? ""]; - if (protectedRequest.kind === "sql" && transport.kind !== "mysql" && transport.kind !== "sqlite") { - errors.push(`${location}.protectedRequest.transportKey: sql requests require mysql or sqlite transport`); - } - if ((protectedRequest.kind === "rcon" && transport.kind !== "rcon") || (protectedRequest.kind === "program" && transport.kind !== "program")) { - errors.push(`${location}.protectedRequest.transportKey: transport kind does not match protected request kind`); - } - if (expectedCapability && !transport.capabilities?.includes(expectedCapability)) { - errors.push(`${location}.protectedRequest.transportKey: is missing required protected transport capability`); - } - } - } for (const [field, ref] of [["payloadSchemaRef", command.payloadSchemaRef], ["resultSchemaRef", command.resultSchemaRef]] as const) { if (ref && !isSafeRelativeJsonRef(ref)) { errors.push(`${location}.${field}: raw host paths and unsafe schema references are not allowed`); @@ -966,14 +930,6 @@ export function validateGameClientBridgeCatalog(manifest: unknown): string[] { if (!fieldNamePattern.test(presence.timestampField ?? "") || !targetFields.has(presence.timestampField ?? "")) errors.push(`${location}.presence.timestampField: must reference a projected target field`); 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)); - const announcement = presence.announcement; - const manager = declaration.runtimeProfiles?.clientManagers?.find((candidate) => candidate.key === announcement?.profileKey && candidate.health?.requiredCapabilities?.includes("game-client.bridge")); - if (!manager) errors.push(`${location}.presence.announcement.profileKey: must reference a declared game-client bridge profile`); - const command = (bridge.commands ?? []).find((candidate) => candidate.type === announcement?.commandType); - if (!command) errors.push(`${location}.presence.announcement.commandType: must reference a declared command`); - if (!fieldNamePattern.test(announcement?.textField ?? "") || (command?.protectedRequest && command.protectedRequest.textField !== announcement?.textField)) errors.push(`${location}.presence.announcement.textField: must be safe and match the command protected request`); - if (!announcement?.newTextTemplate || announcement.newTextTemplate.length > 4096) errors.push(`${location}.presence.announcement.newTextTemplate: must be a non-empty bounded template`); - if (!announcement?.returningTextTemplate || announcement.returningTextTemplate.length > 4096) errors.push(`${location}.presence.announcement.returningTextTemplate: must be a non-empty bounded template`); } for (const [index, operationTemplate] of (bridge.operationTemplates ?? []).entries()) { const location = `manifest.gameClientBridge.operationTemplates[${index}]`; @@ -989,8 +945,8 @@ export function validateGameClientBridgeCatalog(manifest: unknown): string[] { if (!operationTemplate.permission || !declaredPermissions.has(operationTemplate.permission)) { errors.push(`${location}.permission: permission must be declared by the plugin manifest`); } - if (!new Set(["operator", "platform-admin"]).has(operationTemplate.approvalLevel ?? "")) { - errors.push(`${location}.approvalLevel: must require operator or platform-admin approval`); + 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`); @@ -1015,8 +971,8 @@ export function validateGameClientBridgeCatalog(manifest: unknown): string[] { errors.push(`${location}.targetKey: must match the declared runtime transport target`); } if (operationTemplate.kind === "rcon") { - if (transport.kind !== "rcon" || !transport.capabilities?.includes("remote.run.protected.rcon")) { - errors.push(`${location}.transportKey: rcon operations require remote.run.protected.rcon transport`); + 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`); diff --git a/plugins/sdk/index.ts b/plugins/sdk/index.ts index 1966a28..5f5f5f5 100644 --- a/plugins/sdk/index.ts +++ b/plugins/sdk/index.ts @@ -47,7 +47,6 @@ export type RunCapability = | "remote.run.logs.transfer" | "remote.run.rcon.command" | "remote.run.protected.sql" - | "remote.run.protected.rcon" | "remote.run.program.command" | "client-manager.deploy" | "client-manager.control" @@ -223,26 +222,15 @@ export type GameClientBridgeApprovalLevel = "none" | "operator" | "platform-admi export type GameClientBridgeApprovalState = "not_required" | "pending" | "approved" | "rejected"; export type GameClientBridgeCommandState = "pending" | "claimed" | "succeeded" | "failed" | "unknown" | "cancelled" | "expired"; -export type GameClientBridgeProtectedRequestKind = "sql" | "rcon" | "program"; - -export interface GameClientBridgeProtectedRequestDeclaration { - kind: GameClientBridgeProtectedRequestKind; - transportKey: string; - targetKey: string; - textField: string; - maxTextBytes: number; -} - export interface GameClientBridgeCommandDeclaration { type: string; title: string; permission: PluginPermission; approvalLevel: GameClientBridgeApprovalLevel; payloadSchemaRef: string; - resultSchemaRef?: string; - timeoutSeconds: number; + resultSchemaRef?: string; + timeoutSeconds: number; maxPayloadBytes: number; - protectedRequest?: GameClientBridgeProtectedRequestDeclaration; } export interface GameClientBridgeSnapshotDeclaration { @@ -288,19 +276,10 @@ export interface GameClientBridgeLogProjectionTargetDeclaration { observedAtField?: string; } -export interface GameClientBridgeLogProjectionAnnouncementDeclaration { - profileKey: string; - commandType: string; - textField: string; - newTextTemplate: string; - returningTextTemplate: string; -} - export interface GameClientBridgeLogProjectionPresenceDeclaration { timestampField: string; activeWindowSeconds: number; activityTarget?: GameClientBridgeLogProjectionTargetDeclaration; - announcement: GameClientBridgeLogProjectionAnnouncementDeclaration; } export interface GameClientBridgeLogProjectionDeclaration { @@ -441,7 +420,6 @@ export interface GameClientBridgeCommand { state: GameClientBridgeCommandState; approvalState: GameClientBridgeApprovalState; result?: GameClientBridgeCommandResult; - auditReferences?: string[]; expiresAt: string; createdAt: string; updatedAt: string; @@ -459,7 +437,6 @@ export interface GameClientBridgeSnapshot { expect(validateManifestFile("examples/scum-server-plugin/manifest.json")).toEqual([]); }); - it("removes raw SQL command surfaces and keeps announcements as a typed protected RCON request", () => { + 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; protectedRequest?: { kind: string; transportKey: string; targetKey: string; textField: string; maxTextBytes: number } }>; queryTemplates: Array<{ key: string }>; operationTemplates: Array<{ key: string; kind: string }> } }; - const commands = manifest.gameClientBridge.commands.filter((candidate) => candidate.protectedRequest); - expect(commands).toEqual([expect.objectContaining({ type: "announcement.send", protectedRequest: { kind: "rcon", transportKey: "scum-management", targetKey: "scum-management", textField: "requestText", maxTextBytes: 2048 } })]); - const announcementPayload = JSON.parse(fs.readFileSync(path.join(pluginDir, commands[0].payloadSchemaRef), "utf8")); - expect(announcementPayload).toMatchObject({ required: ["requestText"], properties: { requestText: { type: "string", minLength: 1, maxLength: 2048 } } }); + 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 }> } }; + 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"])); @@ -205,13 +201,13 @@ describe("plugin manifest validation", () => { expect(fs.existsSync(path.join(pluginDir, "schemas/bridge/queries/SCUM_DB_CONTRACT.md"))).toBe(true); }); - it("declares BattlEye login projection, presence deduplication, and plugin-owned welcome messages", () => { + it("declares BattlEye login projection and presence deduplication", () => { const manifest = JSON.parse(fs.readFileSync(path.join(pluginsRoot, "examples/scum-server-plugin/manifest.json"), "utf8")) as any; const projection = manifest.gameClientBridge.logProjections.find((candidate: { key: string }) => candidate.key === "scum.battleye.login"); expect(projection).toMatchObject({ streamKeys: ["scum.console.stdout"], correlationFields: ["slot"], maxInterveningLines: 8, target: { collection: "scum_users", upsertKeys: ["steamId"], captureMappings: { steamId: "steamId", displayName: "displayName", slot: "slot" }, fixedValues: { online: "true", source: "process.stdout" }, observedAtField: "lastLoginObservedAt" }, - presence: { timestampField: "lastLoginObservedAt", activeWindowSeconds: 600, activityTarget: { collection: "scum_activity_events", upsertKeys: ["steamId", "observedAt"], captureMappings: { steamId: "steamId", displayName: "displayName" }, fixedValues: { eventType: "login", source: "process.stdout" }, observedAtField: "observedAt" }, announcement: { profileKey: "scum-client-manager", commandType: "announcement.send", textField: "requestText", newTextTemplate: "#announce 欢迎新玩家 {{displayName}} 加入服务器!", returningTextTemplate: "#announce 欢迎 {{displayName}} 继续游戏!" } } + presence: { timestampField: "lastLoginObservedAt", activeWindowSeconds: 600, activityTarget: { collection: "scum_activity_events", upsertKeys: ["steamId", "observedAt"], captureMappings: { steamId: "steamId", displayName: "displayName" }, fixedValues: { eventType: "login", source: "process.stdout" }, observedAtField: "observedAt" } } }); expect(projection.steps.map((step: { pattern: string }) => step.pattern)).toEqual([ 'Player "(?P[^\"]+)" reported as player (?P\\d+)', @@ -278,7 +274,7 @@ describe("plugin manifest validation", () => { expect(unsafe.some((error) => error.includes("raw host path"))).toBe(true); }); - it("declares protected database and management transports without direct access", () => { + it("declares database and management transports for direct run jobs", () => { const manifestPath = path.join(pluginsRoot, "examples/scum-server-plugin/manifest.json"); const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")) as { runtimeProfiles?: { @@ -287,11 +283,11 @@ describe("plugin manifest validation", () => { }; }; const local = manifest.runtimeProfiles?.lifecycleProfiles?.find((profile) => profile.key === "run-local"); - expect(local?.capabilities).not.toContain("remote.run.rcon.command"); - expect(local?.transportKeys).not.toContain("rcon"); + 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-management", kind: "rcon", capabilities: ["remote.run.protected.rcon"] }), + expect.objectContaining({ key: "scum-management", kind: "rcon", capabilities: ["remote.run.rcon.command"] }), expect.objectContaining({ key: "scum-program", kind: "program", capabilities: ["remote.run.program.command"] }) ])); }); @@ -502,13 +498,15 @@ describe("plugin manifest validation", () => { expect(installAction.environment?.SERVER_TEMPLATE).toBe("scum-server"); expect(manifest.permissions).toEqual(expect.arrayContaining(["server.game-client.read", "server.game-client.command", "server.game-client.maintenance"])); expect(manifest.gameClientBridge.commands.map((command) => command.type)).toEqual(expect.arrayContaining([ - "announcement.send", "companion.diagnostics", "player.lookup", "reward.deliver", + "player.notify", + "vehicle.spawn", "event.start", "restart.prepare", - "maintenance.prepare" + "maintenance.prepare", + "game-state.patch" ])); 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"])); @@ -545,13 +543,15 @@ describe("plugin manifest validation", () => { }; }; const expected = { - "announcement.send": { permission: "server.game-client.command", approvalLevel: "operator" }, "companion.diagnostics": { permission: "server.game-client.read", approvalLevel: "none" }, "player.lookup": { permission: "server.game-client.read", approvalLevel: "none" }, "reward.deliver": { permission: "server.game-client.command", approvalLevel: "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" } + "maintenance.prepare": { permission: "server.game-client.maintenance", approvalLevel: "none" }, + "game-state.patch": { permission: "server.game-client.maintenance", approvalLevel: "none" } } as const; expect(manifest.gameClientBridge.commands.map((command) => command.type)).toEqual(expect.arrayContaining(Object.keys(expected))); @@ -758,14 +758,11 @@ describe("plugin manifest validation", () => { const operation = operationsByKey.get(key)!; expect(operation.kind).toBe("rcon"); expect(operation.permission).toBe("server.game-client.command"); - expect(operation.approvalLevel).toBe("operator"); - expect(operation.safety).toMatchObject({ requiresApproval: true, requiresConfirmation: true }); + 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")); - const confirmation = JSON.parse(fs.readFileSync(path.join(pluginDir, operation.confirmationSchemaRef!), "utf8")); expect(payload).toMatchObject({ type: "object", additionalProperties: false }); expect(result).toMatchObject({ type: "object", additionalProperties: false }); - expect(confirmation).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"); @@ -915,9 +912,9 @@ 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: "announcement.send", title: "Send announcement", permission: "server.game-client.command", approvalLevel: "operator", payloadSchemaRef: "schemas/bridge/announcement.schema.json", resultSchemaRef: "schemas/bridge/announcement-result.schema.json", timeoutSeconds: 60, maxPayloadBytes: 4096 }], + 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 }], 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, announcement: { profileKey: "scum-client", commandType: "announcement.send", textField: "message", newTextTemplate: "welcome {{name}}", returningTextTemplate: "welcome back {{name}}" } } }], + 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, maxCommands: 1000, pages: [] @@ -942,15 +939,14 @@ describe("plugin manifest validation", () => { presence: { timestampField: "lastLoginAt", activeWindowSeconds: 600, - activityTarget: { collection: "activity", upsertKeys: ["steamId"], captureMappings: { steamId: "steamId" }, observedAtField: "observedAt" }, - announcement: { profileKey: "scum-client", commandType: "announcement.send", textField: "message", newTextTemplate: "welcome {{name}}", returningTextTemplate: "welcome back {{name}}" } + activityTarget: { collection: "activity", upsertKeys: ["steamId"], captureMappings: { steamId: "steamId" }, observedAtField: "observedAt" } } }; const manifest = { permissions: ["server.game-client.command"], runtimeProfiles: { clientManagers: [{ key: "scum-client", health: { requiredCapabilities: ["game-client.bridge"] } }] }, gameClientBridge: { - commands: [{ type: "announcement.send", approvalLevel: "none", payloadSchemaRef: "schemas/bridge/announcement.schema.json" }], + commands: [{ type: "diagnostic.ping", approvalLevel: "none", payloadSchemaRef: "schemas/bridge/diagnostic-ping.schema.json" }], snapshots: [], logProjections: [projection] } @@ -1060,9 +1056,9 @@ describe("plugin manifest validation", () => { expect(unsafeKeyErrors.some((error) => error.includes("operationTemplates") && error.includes("arbitrary SQL"))).toBe(true); const approvalErrors = validateTemporaryBridgeManifest((manifest) => { - manifest.gameClientBridge.operationTemplates![0].approvalLevel = "none"; + manifest.gameClientBridge.operationTemplates![0].approvalLevel = "automatic"; }); - expect(approvalErrors.some((error) => error.includes("approvalLevel") && error.includes("operator"))).toBe(true); + 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" }); @@ -1117,15 +1113,15 @@ describe("plugin manifest validation", () => { it("rejects invalid bridge schema JSON without throwing", () => { const errors = validateTemporaryBridgeManifest((_manifest, fixtureDir) => { - fs.writeFileSync(path.join(fixtureDir, "schemas/bridge/announcement.schema.json"), "{ invalid", "utf8"); + fs.writeFileSync(path.join(fixtureDir, "schemas/bridge/diagnostic-ping.schema.json"), "{ invalid", "utf8"); }); expect(errors.some((error) => error.includes("payloadSchemaRef") && error.includes("not valid JSON"))).toBe(true); }); it("rejects dangerous fields and values in payload, result, and snapshot schemas", () => { const errors = validateTemporaryBridgeManifest((_manifest, fixtureDir) => { - writeFixtureJSON(fixtureDir, "schemas/bridge/announcement.schema.json", bridgeObjectSchema({ sqlText: { type: "string" } }, ["sqlText"])); - writeFixtureJSON(fixtureDir, "schemas/bridge/announcement-result.schema.json", bridgeObjectSchema({ shellCommand: { type: "string", const: "bash -c whoami" } }, ["shellCommand"])); + writeFixtureJSON(fixtureDir, "schemas/bridge/diagnostic-ping.schema.json", bridgeObjectSchema({ sqlText: { type: "string" } }, ["sqlText"])); + writeFixtureJSON(fixtureDir, "schemas/bridge/diagnostic-ping-result.schema.json", bridgeObjectSchema({ shellCommand: { type: "string", const: "bash -c whoami" } }, ["shellCommand"])); writeFixtureJSON(fixtureDir, "schemas/bridge/players.schema.json", bridgeObjectSchema({ hostPath: { type: "string" }, mode: { type: "string", const: "run.socket" }, runCapability: { type: "string" } }, ["hostPath", "mode", "runCapability"])); }); expect(errors.some((error) => error.includes("payloadSchemaRef") && error.includes("arbitrary SQL field"))).toBe(true); @@ -1136,7 +1132,7 @@ describe("plugin manifest validation", () => { it("requires bounded object schemas for every bridge reference", () => { const errors = validateTemporaryBridgeManifest((_manifest, fixtureDir) => { - writeFixtureJSON(fixtureDir, "schemas/bridge/announcement.schema.json", { type: "object", properties: { message: { type: "string" } } }); + writeFixtureJSON(fixtureDir, "schemas/bridge/diagnostic-ping.schema.json", { type: "object", properties: { message: { type: "string" } } }); }); expect(errors.some((error) => error.includes("payloadSchemaRef") && error.includes("additionalProperties to false"))).toBe(true); }); @@ -1227,7 +1223,7 @@ describe("plugin SDK", () => { expect(declaration).toMatchObject({ key: "scum.player.login", correlationFields: ["slot"] }); }); - it("types controlled operation template declarations", () => { + it("types plugin operation template declarations", () => { const declaration: GameClientBridgeOperationTemplateDeclaration = { key: "player.attribute.855.set", title: "Set player attribute 855", @@ -1252,12 +1248,12 @@ describe("plugin SDK", () => { it("builds safe game-client bridge requests without component transport material", () => { const request = createGameClientBridgeQueueRequest({ profileKey: "scum-client", - commandType: "announcement.send", + commandType: "diagnostic.ping", payload: { message: "hello" }, - idempotencyKey: "announcement-1", + idempotencyKey: "diagnostic-1", expiresAt: "2026-07-20T12:00:00Z" }); - expect(request.commandType).toBe("announcement.send"); + expect(request.commandType).toBe("diagnostic.ping"); expect(request).not.toHaveProperty("sessionToken"); expect(request).not.toHaveProperty("componentKey"); expect(request).not.toHaveProperty("runEndpoint"); @@ -1265,16 +1261,6 @@ describe("plugin SDK", () => { expect(request).not.toHaveProperty("dsn"); }); - it("types protected request declarations while retaining text redaction boundaries", () => { - const declaration: GameClientBridgeProtectedRequestDeclaration = { kind: "sql", transportKey: "scum-database", targetKey: "scum-database", textField: "requestText", maxTextBytes: 4096 }; - expect(declaration).toMatchObject({ kind: "sql", textField: "requestText" }); - expect(JSON.stringify(declaration).toLowerCase()).not.toMatch(/dsn|hostpath|socket|credential|password/); - const errors = validateTemporaryBridgeManifest((manifest) => { - manifest.gameClientBridge.commands[0].type = "database.request"; - manifest.gameClientBridge.commands[0].protectedRequest = { kind: "sql", transportKey: "missing", targetKey: "missing", textField: "requestText", maxTextBytes: 512 }; - }); - expect(errors.some((error) => error.includes("protectedRequest.transportKey"))).toBe(true); - }); it("checks declared bridge permissions", () => { const context: PluginBridgeContext = { pluginId: "game.example", diff --git a/plugins/tests/scum-feature-module.test.ts b/plugins/tests/scum-feature-module.test.ts index 03e51b4..e007662 100644 --- a/plugins/tests/scum-feature-module.test.ts +++ b/plugins/tests/scum-feature-module.test.ts @@ -37,15 +37,15 @@ const surfaceData: SCUMSurfaceData = { }; describe("SCUM plugin feature module", () => { - it("owns runtime allowlists without a version gate", () => { + it("owns runtime catalogs without a version gate", () => { expect(configurationCatalog.map((field) => field.key)).toContain("welcome-message"); expect(validateConfigPatch({ reason: "adjust capacity", idempotencyKey: "cfg-1", changes: [{ key: "max-players", value: "129" }] })).toContain("超出允许范围"); expect(validateStatePatch([{ fieldKey: "skills.running", before: 1, after: 2 }])).toBeNull(); - expect(validateStatePatch([{ fieldKey: "unknown", before: 1, after: 2 }])).toContain("白名单"); + expect(validateStatePatch([{ fieldKey: "unknown", before: 1, after: 2 }])).toContain("插件运行时目录"); expect(vehicleSpawnCatalog.map((vehicle) => vehicle.code)).toEqual(["BPC_Laika_C", "BPC_WolfsWagen_C"]); expect(validateVehicleSpawn({ vehicleCode: "BPC_Laika_C" })).toBeNull(); expect(validateVehicleSpawn({ vehicleCode: "#spawnvehicle BPC_Laika_C" })).toContain("格式无效"); - expect(validateVehicleSpawn({ vehicleCode: "BPC_Unknown_C" })).toContain("受控目录"); + expect(validateVehicleSpawn({ vehicleCode: "BPC_Unknown_C" })).toContain("插件目录"); }); it("maps transitional records only as read-only provenance", () => { @@ -53,7 +53,7 @@ describe("SCUM plugin feature module", () => { expect(migrateTrajectoryRecord({ playerRecordId: "p-1", points: [{ recordedAt: "2026-07-29T00:00:00Z", mapX: 10, mapY: 20 }] })).toMatchObject({ provenance: "transitional-read-only", points: [{ x: 10, y: 20 }] }); }); - it("preserves only allowlisted transitional history for every feature area", () => { + it("preserves only declared transitional history for every feature area", () => { expect(migrateConfigurationRecord({ id: "cfg-1", version: "0.9.700.90357", fields: { MaxPlayers: 64 }, observedAt: "2026-07-29T00:00:00Z", hostPath: "C:/secret" })).toMatchObject({ readOnly: true, payload: { fields: { MaxPlayers: "64" } } }); expect(migratePlayerProfileRecord({ player: { id: "p-1", gamePlayerId: "steam-1", displayName: "Mira", updatedAt: "2026-07-29T00:00:00Z" }, sessions: [{ id: "s-1", gamePlayerRecordId: "p-1", startedAt: "2026-07-29T00:00:00Z", networkFingerprint: "never-copy" }], accessAttempts: [{ occurredAt: "2026-07-29T00:01:00Z", outcome: "review", reason: "manual" }] })).toMatchObject({ payload: { sessions: [{ kind: "login" }], risks: [{ summary: "manual" }] } }); expect(migrateGiftGrantRecord({ id: "gift-1", revisionId: "r-1", gamePlayerRecordId: "p-1", status: "unknown", createdAt: "2026-07-29T00:00:00Z" })).toMatchObject({ payload: { status: "unknown" }, readOnly: true }); @@ -61,7 +61,7 @@ describe("SCUM plugin feature module", () => { expect(migrateTrajectoryHistoryRecord({ id: "track-1", playerRecordId: "p-1", points: [{ recordedAt: "2026-07-29T00:00:00Z", mapX: 10, mapY: 20 }] })).toMatchObject({ sourceRecordId: "track-1", readOnly: true }); }); - it("matches controlled transitional fixtures without carrying sensitive fields into plugin history", () => { + it("matches transitional fixtures without carrying sensitive fields into plugin history", () => { expect(migrateConfigurationRecord(scumMigrationParityFixtures.configuration.source)).toEqual(scumMigrationParityFixtures.configuration.expected); expect(migratePlayerProfileRecord(scumMigrationParityFixtures.playerHistory.source)).toEqual(scumMigrationParityFixtures.playerHistory.expected); expect(migrateGiftGrantRecord(scumMigrationParityFixtures.gift.source)).toEqual(scumMigrationParityFixtures.gift.expected);