Remove pre-1.0 audit and protected request scaffolding

This commit is contained in:
npc0-hue
2026-08-20 23:42:02 +08:00
parent 40b35b05c7
commit a7e2e4c6c0
130 changed files with 526 additions and 3767 deletions
+1 -1
View File
@@ -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.
+3 -3
View File
@@ -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.
+1 -2
View File
@@ -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/")) {
+6 -7
View File
@@ -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 {
@@ -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
+1 -1
View File
@@ -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 {
@@ -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)
@@ -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))
}
+2 -248
View File
@@ -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))
}
+9 -46
View File
@@ -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)
}
}
}
+10 -13
View File
@@ -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
+1 -1
View File
@@ -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)
+2 -44
View File
@@ -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 = &copy
}
}
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...)
+3 -3
View File
@@ -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)
}
-30
View File
@@ -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
-1
View File
@@ -108,7 +108,6 @@ type RemoteAdapterResult struct {
Retryable bool
Message string
ResultRef string
AuditEventID string
CompletedAt time.Time
}
-196
View File
@@ -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
}
+1 -33
View File
@@ -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
}
+1 -11
View File
@@ -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.
+1 -1
View File
@@ -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)},
}
}
+5 -35
View File
@@ -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))
+13 -16
View File
@@ -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")
}
}
+2 -34
View File
@@ -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}
}
+1 -2
View File
@@ -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 {
+2 -158
View File
@@ -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
}
+15 -120
View File
@@ -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,
+8 -8
View File
@@ -143,16 +143,16 @@ func TestGameClientBridgeQueryTemplateDeclarationRoundTripIsSafe(t *testing.T) {
LogProjections: []GameClientBridgeLogProjectionDeclarationBody{{
Key: "player.login", StreamKeys: []string{"process.stdout"}, Steps: []GameClientBridgeLogProjectionStepDeclarationBody{{Pattern: `Player (?<slot>\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"
+3 -3
View File
@@ -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.
+4 -79
View File
@@ -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,
}
}
-2
View File
@@ -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",
+1 -1
View File
@@ -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/<id>` 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.
+2 -2
View File
@@ -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.
@@ -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`.
+3 -6
View File
@@ -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.<jobId>.<streamKey>` 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
-14
View File
@@ -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)
+3 -3
View File
@@ -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)
}
-12
View File
@@ -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)
-44
View File
@@ -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) &&
+4 -18
View File
@@ -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)
-10
View File
@@ -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,
-3
View File
@@ -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
}
+8 -33
View File
@@ -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
}
@@ -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)
}
+1 -1
View File
@@ -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)
}
+2 -19
View File
@@ -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
}
+2 -14
View File
@@ -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)
-98
View File
@@ -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)
}
+1 -19
View File
@@ -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) {
-91
View File
@@ -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)
}
@@ -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
}
+14 -126
View File
@@ -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)
+2 -9
View File
@@ -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,
+1 -16
View File
@@ -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
}
-41
View File
@@ -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 {
+5 -42
View File
@@ -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
}
+3 -555
View File
@@ -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
}
+4 -37
View File
@@ -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) {
-254
View File
@@ -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
}
+1 -11
View File
@@ -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 {
+1 -53
View File
@@ -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")
+1 -23
View File
@@ -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)
+1 -1
View File
@@ -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)
}
@@ -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 {
+2 -2
View File
@@ -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{
-56
View File
@@ -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 {
-62
View File
@@ -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) {
@@ -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 {
@@ -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) {
-8
View File
@@ -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))
}
-127
View File
@@ -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:
+16 -133
View File
@@ -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
}
}
+8 -25
View File
@@ -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 {
+2 -2
View File
@@ -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
@@ -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",