Remove legacy client-manager workflows

This commit is contained in:
npc0-hue
2026-09-03 13:08:08 +08:00
parent bf3c382d15
commit fe09d21a56
56 changed files with 304 additions and 4121 deletions
+1 -4
View File
@@ -29,10 +29,7 @@ func (h *coreHandlers) requireAuthorizedAPI(next http.Handler) http.Handler {
func publicAPIRequest(r *http.Request) bool {
path := r.URL.Path
if path == "/api/v1/auth/login" || path == "/api/v1/auth/register" || path == "/api/v1/client-managers/register" || path == "/api/v1/client-managers/heartbeat" {
return true
}
if strings.HasPrefix(path, "/api/v1/game-client-bridge/companion/") {
if path == "/api/v1/auth/login" || path == "/api/v1/auth/register" {
return true
}
if r.Method != http.MethodGet {
@@ -1,352 +0,0 @@
package api
import (
"net/http"
"browser.local/platform/dto"
)
// serverClientManagerLifecycles godoc
// @Summary List Client Manager lifecycle installations
// @Description Returns safe durable lifecycle, health, real job progress, and action availability for the authorized server without component secrets or machine details.
// @Tags client-managers
// @Produce json
// @Param id path string true "Server instance ID"
// @Success 200 {object} dto.ClientManagerInstallationListResponse
// @Failure 401 {object} dto.ErrorResponse
// @Failure 403 {object} dto.ErrorResponse
// @Router /api/v1/server-instances/{id}/client-managers [get]
func (h *coreHandlers) serverClientManagerLifecycles(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeMethodNotAllowed(w, http.MethodGet)
return
}
views, err := h.core.ListClientManagerLifecyclesForSession(bearerToken(r), r.PathValue("id"))
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, dto.ClientManagerLifecycleViewsFromDomain(views))
}
// serverClientManagerLifecycleDetail godoc
// @Summary Get one Client Manager lifecycle installation
// @Tags client-managers
// @Produce json
// @Param id path string true "Server instance ID"
// @Param profileKey path string true "Client Manager profile key"
// @Success 200 {object} dto.ClientManagerInstallationResponse
// @Failure 401 {object} dto.ErrorResponse
// @Failure 403 {object} dto.ErrorResponse
// @Failure 404 {object} dto.ErrorResponse
// @Router /api/v1/server-instances/{id}/client-managers/{profileKey} [get]
func (h *coreHandlers) serverClientManagerLifecycleDetail(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeMethodNotAllowed(w, http.MethodGet)
return
}
view, err := h.core.GetClientManagerLifecycleForSession(bearerToken(r), r.PathValue("id"), r.PathValue("profileKey"))
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, dto.ClientManagerLifecycleViewFromDomain(view))
}
// serverClientManagerDeploy godoc
// @Summary Deploy an available Client Manager distribution
// @Description Queues a typed Run deployment after server, endpoint, artifact, target, revision, and key-generation authorization.
// @Tags client-managers
// @Accept json
// @Produce json
// @Param id path string true "Server instance ID"
// @Param body body dto.ClientManagerDeployRequest true "Deployment request"
// @Success 202 {object} dto.ClientManagerInstallationResponse
// @Failure 400 {object} dto.ErrorResponse
// @Failure 401 {object} dto.ErrorResponse
// @Failure 403 {object} dto.ErrorResponse
// @Router /api/v1/server-instances/{id}/client-managers/deploy [post]
func (h *coreHandlers) serverClientManagerDeploy(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeMethodNotAllowed(w, http.MethodPost)
return
}
request, err := decodeJSON[dto.ClientManagerDeployRequest](r)
if err != nil {
writeDecodeError(w, err)
return
}
view, err := h.core.DeployClientManagerForSession(bearerToken(r), request.ToDomain(r.PathValue("id")))
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusAccepted, dto.ClientManagerLifecycleViewFromDomain(view))
}
// serverClientManagerControl godoc
// @Summary Control a deployed Client Manager
// @Description Queues a declared typed start, stop, restart, status, or rollback operation.
// @Tags client-managers
// @Accept json
// @Produce json
// @Param id path string true "Server instance ID"
// @Param body body dto.ClientManagerControlRequest true "Control request"
// @Success 202 {object} dto.ClientManagerInstallationResponse
// @Failure 400 {object} dto.ErrorResponse
// @Failure 401 {object} dto.ErrorResponse
// @Failure 403 {object} dto.ErrorResponse
// @Router /api/v1/server-instances/{id}/client-managers/control [post]
func (h *coreHandlers) serverClientManagerControl(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeMethodNotAllowed(w, http.MethodPost)
return
}
request, err := decodeJSON[dto.ClientManagerControlRequest](r)
if err != nil {
writeDecodeError(w, err)
return
}
view, err := h.core.ControlClientManagerForSession(bearerToken(r), request.ToDomain(r.PathValue("id")))
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusAccepted, dto.ClientManagerLifecycleViewFromDomain(view))
}
// serverClientManagerUpdateLifecycle godoc
// @Summary Update a Client Manager through staged activation
// @Tags client-managers
// @Accept json
// @Produce json
// @Param id path string true "Server instance ID"
// @Param body body dto.ClientManagerUpdateRequest true "Approved staged update request"
// @Success 202 {object} dto.ClientManagerInstallationResponse
// @Failure 400 {object} dto.ErrorResponse
// @Failure 401 {object} dto.ErrorResponse
// @Failure 403 {object} dto.ErrorResponse
// @Router /api/v1/server-instances/{id}/client-managers/update [post]
func (h *coreHandlers) serverClientManagerUpdateLifecycle(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeMethodNotAllowed(w, http.MethodPost)
return
}
request, err := decodeJSON[dto.ClientManagerUpdateRequest](r)
if err != nil {
writeDecodeError(w, err)
return
}
view, err := h.core.UpdateClientManagerForSession(bearerToken(r), request.ToDomain(r.PathValue("id")))
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusAccepted, dto.ClientManagerLifecycleViewFromDomain(view))
}
// serverClientManagerRetry godoc
// @Summary Retry a failed Client Manager lifecycle intent
// @Tags client-managers
// @Accept json
// @Produce json
// @Param id path string true "Server instance ID"
// @Param body body dto.ClientManagerRetryRequest true "Retry request"
// @Success 202 {object} dto.ClientManagerInstallationResponse
// @Failure 400 {object} dto.ErrorResponse
// @Failure 401 {object} dto.ErrorResponse
// @Failure 403 {object} dto.ErrorResponse
// @Router /api/v1/server-instances/{id}/client-managers/retry [post]
func (h *coreHandlers) serverClientManagerRetry(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeMethodNotAllowed(w, http.MethodPost)
return
}
request, err := decodeJSON[dto.ClientManagerRetryRequest](r)
if err != nil {
writeDecodeError(w, err)
return
}
view, err := h.core.RetryClientManagerLifecycleForSession(bearerToken(r), request.ToDomain(r.PathValue("id")))
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusAccepted, dto.ClientManagerLifecycleViewFromDomain(view))
}
// serverClientManagerRevokeSession godoc
// @Summary Revoke the active Client Manager component session
// @Tags client-managers
// @Accept json
// @Produce json
// @Param id path string true "Server instance ID"
// @Param body body dto.ClientManagerRevokeSessionRequest true "Session revoke request"
// @Success 200 {object} dto.ClientManagerInstallationResponse
// @Failure 401 {object} dto.ErrorResponse
// @Failure 403 {object} dto.ErrorResponse
// @Router /api/v1/server-instances/{id}/client-managers/revoke-session [post]
func (h *coreHandlers) serverClientManagerRevokeSession(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeMethodNotAllowed(w, http.MethodPost)
return
}
request, err := decodeJSON[dto.ClientManagerRevokeSessionRequest](r)
if err != nil {
writeDecodeError(w, err)
return
}
view, err := h.core.RevokeClientManagerSessionForSession(bearerToken(r), request.ToDomain(r.PathValue("id")))
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, dto.ClientManagerLifecycleViewFromDomain(view))
}
// serverClientManagerUninstall godoc
// @Summary Safely uninstall a Client Manager
// @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
// @Param id path string true "Server instance ID"
// @Param body body dto.ClientManagerUninstallRequest true "Confirmed uninstall request"
// @Success 202 {object} dto.ClientManagerInstallationResponse
// @Failure 400 {object} dto.ErrorResponse
// @Failure 401 {object} dto.ErrorResponse
// @Failure 403 {object} dto.ErrorResponse
// @Router /api/v1/server-instances/{id}/client-managers/uninstall [post]
func (h *coreHandlers) serverClientManagerUninstall(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeMethodNotAllowed(w, http.MethodPost)
return
}
request, err := decodeJSON[dto.ClientManagerUninstallRequest](r)
if err != nil {
writeDecodeError(w, err)
return
}
view, err := h.core.UninstallClientManagerForSession(bearerToken(r), request.ToDomain(r.PathValue("id")))
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusAccepted, dto.ClientManagerLifecycleViewFromDomain(view))
}
// runClientManagerLifecycleInput godoc
// @Summary Get fenced Client Manager lifecycle input
// @Description Returns a safe typed lifecycle contract only to the authenticated Run endpoint holding the active job lease.
// @Tags run-job-channel
// @Accept json
// @Produce json
// @Param body body dto.ClientManagerLifecycleInputRequest true "Fenced lifecycle input request"
// @Success 200 {object} dto.ClientManagerLifecycleInputResponse
// @Failure 400 {object} dto.ErrorResponse
// @Failure 401 {object} dto.ErrorResponse
// @Router /api/v1/run/jobs/client-manager-input [post]
func (h *coreHandlers) runClientManagerLifecycleInput(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeMethodNotAllowed(w, http.MethodPost)
return
}
request, err := decodeJSON[dto.ClientManagerLifecycleInputRequest](r)
if err != nil {
writeDecodeError(w, err)
return
}
input, err := h.core.GetClientManagerLifecycleInput(request.ToDomain())
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, dto.ClientManagerLifecycleInputFromDomain(input))
}
// runClientManagerLifecycleChunk godoc
// @Summary Read one fenced Client Manager artifact chunk
// @Description Streams a checksummed artifact chunk only to the active typed lifecycle job lease.
// @Tags run-job-channel
// @Accept json
// @Produce json
// @Param body body dto.RunUpdateChunkRequest true "Fenced chunk request"
// @Success 200 {object} dto.RunUpdateChunkResponse
// @Failure 400 {object} dto.ErrorResponse
// @Failure 401 {object} dto.ErrorResponse
// @Router /api/v1/run/jobs/client-manager-chunk [post]
func (h *coreHandlers) runClientManagerLifecycleChunk(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeMethodNotAllowed(w, http.MethodPost)
return
}
request, err := decodeJSON[dto.RunUpdateChunkRequest](r)
if err != nil {
writeDecodeError(w, err)
return
}
chunk, err := h.core.ReadClientManagerLifecycleChunk(request.ToDomain())
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, dto.RunUpdateChunkFromDomain(chunk))
}
// clientManagerRegister godoc
// @Summary Register an installed Client Manager component
// @Description Verifies a current component-key HMAC, nonce, deployment fence, target, revision, and capabilities before issuing an isolated expiring component session.
// @Tags client-manager-component
// @Accept json
// @Produce json
// @Param body body dto.ClientManagerRegisterRequest true "Signed component registration"
// @Success 200 {object} dto.ClientManagerRegisterResponse
// @Failure 400 {object} dto.ErrorResponse
// @Failure 401 {object} dto.ErrorResponse
// @Router /api/v1/client-managers/register [post]
func (h *coreHandlers) clientManagerRegister(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeMethodNotAllowed(w, http.MethodPost)
return
}
request, err := decodeJSON[dto.ClientManagerRegisterRequest](r)
if err != nil {
writeDecodeError(w, err)
return
}
result, err := h.core.RegisterClientManager(request.ToDomain())
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, dto.ClientManagerRegisterFromDomain(result))
}
// clientManagerHeartbeat godoc
// @Summary Accept a Client Manager component heartbeat
// @Description Accepts monotonic health reports using the isolated component session; Run control credentials are not valid here.
// @Tags client-manager-component
// @Accept json
// @Produce json
// @Param body body dto.ClientManagerHeartbeatRequest true "Component heartbeat"
// @Success 200 {object} dto.ClientManagerHeartbeatResponse
// @Failure 400 {object} dto.ErrorResponse
// @Failure 401 {object} dto.ErrorResponse
// @Router /api/v1/client-managers/heartbeat [post]
func (h *coreHandlers) clientManagerHeartbeat(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeMethodNotAllowed(w, http.MethodPost)
return
}
request, err := decodeJSON[dto.ClientManagerHeartbeatRequest](r)
if err != nil {
writeDecodeError(w, err)
return
}
result, err := h.core.AcceptClientManagerHeartbeat(request.ToDomain())
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, dto.ClientManagerHeartbeatFromDomain(result))
}
@@ -1,101 +0,0 @@
package api
import (
"net/http"
"browser.local/platform/dto"
)
func (h *coreHandlers) gameClientBridgeCompanionClaim(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeMethodNotAllowed(w, http.MethodPost)
return
}
request, err := decodeJSON[dto.GameClientBridgeClaimRequest](r)
if err != nil {
writeDecodeError(w, err)
return
}
commands, err := h.core.ClaimGameClientBridgeCommands(request.ToDomain())
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, dto.GameClientBridgeClaimResponseFromDomain(commands))
}
func (h *coreHandlers) gameClientBridgeCompanionAck(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeMethodNotAllowed(w, http.MethodPost)
return
}
request, err := decodeJSON[dto.GameClientBridgeAckRequest](r)
if err != nil {
writeDecodeError(w, err)
return
}
command, err := h.core.AckGameClientBridgeCommand(request.ToDomain(r.PathValue("commandId")))
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, dto.GameClientBridgeAckFromDomain(command))
}
func (h *coreHandlers) gameClientBridgeCompanionResult(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeMethodNotAllowed(w, http.MethodPost)
return
}
request, err := decodeJSON[dto.GameClientBridgeResultRequest](r)
if err != nil {
writeDecodeError(w, err)
return
}
command, err := h.core.CompleteGameClientBridgeCommand(request.ToDomain(r.PathValue("commandId")))
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, dto.GameClientBridgeResultFromDomain(command))
}
func (h *coreHandlers) gameClientBridgeCompanionSnapshot(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeMethodNotAllowed(w, http.MethodPost)
return
}
request, err := decodeJSON[dto.GameClientBridgeSnapshotIngestRequest](r)
if err != nil {
writeDecodeError(w, err)
return
}
snapshot, err := h.core.UploadGameClientBridgeSnapshot(request.ToDomain())
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusAccepted, dto.GameClientBridgeSnapshotIngestFromDomain(snapshot))
}
func (h *coreHandlers) gameClientBridgeCompanionDiagnostics(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeMethodNotAllowed(w, http.MethodPost)
return
}
request, err := decodeJSON[dto.GameClientBridgeSnapshotIngestRequest](r)
if err != nil {
writeDecodeError(w, err)
return
}
if request.Type != "companion.health" && request.Type != "bridge.diagnostics" {
writeAPIError(w, http.StatusBadRequest, errorCodeValidation, "validation failed", []string{"diagnostic snapshot type is invalid"})
return
}
snapshot, err := h.core.UploadGameClientBridgeSnapshot(request.ToDomain())
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusAccepted, dto.GameClientBridgeSnapshotIngestFromDomain(snapshot))
}
@@ -12,32 +12,6 @@ import (
"browser.local/platform/service"
)
type gameClientBridgeCompanionCore struct {
service.Core
command domain.GameClientBridgeCommand
snapshot domain.GameClientBridgeSnapshot
}
func (core *gameClientBridgeCompanionCore) ClaimGameClientBridgeCommands(domain.GameClientBridgeClaimRequest) ([]domain.GameClientBridgeCommand, error) {
return []domain.GameClientBridgeCommand{domain.CopyGameClientBridgeCommand(core.command)}, nil
}
func (core *gameClientBridgeCompanionCore) AckGameClientBridgeCommand(domain.GameClientBridgeAckRequest) (domain.GameClientBridgeCommand, error) {
return domain.CopyGameClientBridgeCommand(core.command), nil
}
func (core *gameClientBridgeCompanionCore) CompleteGameClientBridgeCommand(domain.GameClientBridgeResultRequest) (domain.GameClientBridgeCommand, error) {
value := domain.CopyGameClientBridgeCommand(core.command)
value.State = domain.GameClientBridgeCommandSucceeded
value.Result = domain.GameClientBridgeResult{Status: domain.GameClientBridgeResultSucceeded, Summary: "done", CompletedBy: "internal-session", CompletedAt: time.Now().UTC()}
value.CompletedAt = value.Result.CompletedAt
return value, nil
}
func (core *gameClientBridgeCompanionCore) UploadGameClientBridgeSnapshot(domain.GameClientBridgeSnapshotIngestRequest) (domain.GameClientBridgeSnapshot, error) {
return domain.CopyGameClientBridgeSnapshot(core.snapshot), nil
}
func TestGameClientBridgeOperatorRoutes(t *testing.T) {
store := repo.NewMemoryStore()
coreService := service.NewCoreService(store)
@@ -46,15 +20,14 @@ func TestGameClientBridgeOperatorRoutes(t *testing.T) {
}
plugin := validGamePluginRequest().ToDomain()
plugin.DeclaredPermissions = append(plugin.DeclaredPermissions, "server.game-client.command", "server.game-client.read")
plugin.RequiredRunCapabilities = append(plugin.RequiredRunCapabilities, domain.JobCapabilityClientManagerDeploy, domain.JobCapabilityClientManagerControl, domain.JobCapabilityClientManagerUpdate, domain.JobCapabilityClientManagerRollback, domain.JobCapabilityClientManagerUninstall, domain.JobCapabilityRemoteRunDBSQLiteQuery)
plugin.RequiredRunCapabilities = append(plugin.RequiredRunCapabilities, domain.JobCapabilityRemoteRunDBSQLiteQuery)
plugin.RuntimeProfiles.TransportProfiles = []domain.RuntimeTransportProfile{{Key: "sqlite-db", Kind: "sqlite", TargetKey: "db/sqlite", Capabilities: []string{domain.JobCapabilityRemoteRunDBSQLiteQuery}}}
plugin.RuntimeProfiles.ClientManagers = []domain.RuntimeClientManagerProfile{{Key: "scum-client", DisplayName: "SCUM Client", Version: "1.0.0", RepositoryURL: "https://github.com/example/scum-client.git", RevisionPolicy: "pinned", Revision: "0123456789abcdef", SupportedTargets: []domain.RuntimeTarget{{OS: "linux", Arch: "amd64"}}, BuildSystem: "go", EntryRef: "main.go", OutputArtifacts: []string{"scum-client"}, Deployment: domain.RuntimeClientManagerDeployment{Mode: "run-supervised", ExecutableRef: "scum-client", RequiredRunCapabilities: []string{domain.JobCapabilityClientManagerDeploy, domain.JobCapabilityClientManagerControl, domain.JobCapabilityClientManagerUpdate, domain.JobCapabilityClientManagerRollback, domain.JobCapabilityClientManagerUninstall}}, Lifecycle: domain.RuntimeClientManagerLifecycle{Actions: []string{"start", "stop", "restart", "status", "update", "rollback", "uninstall"}, StartupTimeoutSeconds: 60, StopTimeoutSeconds: 30}, Health: domain.RuntimeClientManagerHealth{Mode: "component-heartbeat", IntervalSeconds: 15, DegradedAfterSeconds: 45, OfflineAfterSeconds: 120, RequiredCapabilities: []string{"component.register", "component.heartbeat", "component.health", "game-client.bridge"}}, Compatibility: domain.RuntimeClientManagerCompatibility{MinimumVersion: "1.0.0"}, UpdatePolicy: domain.RuntimeClientManagerUpdatePolicy{Strategy: "manual-staged", RequireApproval: true, HealthConfirmationSeconds: 60, RetainPrevious: true}}}
plugin.GameClientBridge = domain.GameClientBridgeManifest{Commands: []domain.GameClientBridgeCommandDeclaration{{Type: "diagnostic.ping", Title: "Diagnostic ping", Permission: "server.game-client.command", 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)
}
endpoint := validRunEndpointRequest().ToDomain()
endpoint.Capabilities = append(endpoint.Capabilities, domain.JobCapabilityClientManagerDeploy, domain.JobCapabilityClientManagerControl, domain.JobCapabilityClientManagerUpdate, domain.JobCapabilityClientManagerRollback, domain.JobCapabilityClientManagerUninstall, domain.JobCapabilityRemoteRunDBSQLiteQuery)
endpoint.Capabilities = append(endpoint.Capabilities, domain.JobCapabilityRemoteRunDBSQLiteQuery)
if _, err := coreService.CreateRunEndpoint(endpoint); err != nil {
t.Fatalf("create run endpoint: %v", err)
}
@@ -65,14 +38,14 @@ func TestGameClientBridgeOperatorRoutes(t *testing.T) {
}
status := getJSONWithAuth[dto.GameClientBridgeStatusResponse](t, router, "/api/v1/server-instances/server-bridge/game-client-bridge", adminSession)
if status.ServerInstanceID != "server-bridge" || status.PluginID != "server.scum" || status.Available || status.Profiles == nil {
if status.ServerInstanceID != "server-bridge" || status.PluginID != "server.scum" || !status.Available || status.Profiles == nil {
t.Fatalf("unexpected bridge status: %#v", status)
}
if len(status.Profiles) != 1 || len(status.Profiles[0].QueryTemplateKeys) != 1 || status.Profiles[0].QueryTemplateKeys[0] != "player.lookup" {
if len(status.Profiles) != 1 || status.Profiles[0].ProfileKey != "plugin-owned" || len(status.Profiles[0].QueryTemplateKeys) != 1 || status.Profiles[0].QueryTemplateKeys[0] != "player.lookup" {
t.Fatalf("bridge status did not safely expose query template availability: %#v", status)
}
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)}
queue := dto.GameClientBridgeQueueRequest{ProfileKey: "plugin-owned", 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)
@@ -112,40 +85,3 @@ func TestGameClientBridgeOperatorRoutes(t *testing.T) {
}
}
}
func TestGameClientBridgeCompanionRoutesAreComponentSessionMediated(t *testing.T) {
now := time.Now().UTC()
core := &gameClientBridgeCompanionCore{
Core: service.NewCoreService(repo.NewMemoryStore()),
command: domain.GameClientBridgeCommand{ID: "command-1", ProfileKey: "scum-client", CommandType: "diagnostic.safe", Payload: map[string]any{"scope": "health"}, State: domain.GameClientBridgeCommandClaimed, Claim: domain.GameClientBridgeClaim{SessionID: "internal-session-secret", InstallationID: "internal-installation", FencingToken: 9, ClaimedAt: now, LeaseExpiresAt: now.Add(time.Minute)}, ExpiresAt: now.Add(time.Hour)},
snapshot: domain.GameClientBridgeSnapshot{ID: "snapshot-1", ProfileKey: "scum-client", Type: "companion.health", SchemaVersion: "1", StreamKey: "current", Sequence: 1, SourceSessionID: "internal-source-session", CreatedAt: now, ExpiresAt: now.Add(time.Hour)},
}
router := NewAuthorizedRouterWithCore(core)
claim := performJSON(t, router, http.MethodPost, "/api/v1/game-client-bridge/companion/commands/claim", dto.GameClientBridgeClaimRequest{SessionToken: "component-token", Limit: 5})
assertStatus(t, claim, http.StatusOK)
if strings.Contains(claim.Body.String(), "internal-session-secret") || strings.Contains(claim.Body.String(), "internal-installation") {
t.Fatalf("claim response leaked component identity: %s", claim.Body.String())
}
claimed := decodeBody[dto.GameClientBridgeClaimResponse](t, claim)
if claimed.Count != 1 || claimed.Items[0].FencingToken != 9 {
t.Fatalf("unexpected claim response: %#v", claimed)
}
ack := performJSON(t, router, http.MethodPost, "/api/v1/game-client-bridge/companion/commands/command-1/ack", dto.GameClientBridgeAckRequest{SessionToken: "component-token", FencingToken: 9})
assertStatus(t, ack, http.StatusOK)
result := performJSON(t, router, http.MethodPost, "/api/v1/game-client-bridge/companion/commands/command-1/result", dto.GameClientBridgeResultRequest{SessionToken: "component-token", FencingToken: 9, Status: "succeeded", Summary: "done"})
assertStatus(t, result, http.StatusOK)
if strings.Contains(result.Body.String(), "internal-session") {
t.Fatalf("result response leaked completing session: %s", result.Body.String())
}
snapshotRequest := dto.GameClientBridgeSnapshotIngestRequest{SessionToken: "component-token", Type: "companion.health", SchemaVersion: "1", StreamKey: "current", Sequence: 1, ObservedAt: now, Payload: map[string]any{"healthy": true}, KeepForSeconds: 3600}
snapshot := performJSON(t, router, http.MethodPost, "/api/v1/game-client-bridge/companion/snapshots", snapshotRequest)
assertStatus(t, snapshot, http.StatusAccepted)
if strings.Contains(snapshot.Body.String(), "internal-source-session") || strings.Contains(snapshot.Body.String(), "component-token") {
t.Fatalf("snapshot response leaked component material: %s", snapshot.Body.String())
}
diagnostic := performJSON(t, router, http.MethodPost, "/api/v1/game-client-bridge/companion/diagnostics", snapshotRequest)
assertStatus(t, diagnostic, http.StatusAccepted)
}
@@ -1,30 +0,0 @@
package api
import (
"net/http"
"browser.local/platform/dto"
)
// gameClientBridgeCompanionLogEvents authorizes a component session and
// forwards the current opaque log channel. Platform does not parse, redact,
// filter, or derive records from the log body.
func (h *coreHandlers) gameClientBridgeCompanionLogEvents(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeMethodNotAllowed(w, http.MethodPost)
return
}
request, err := decodeJSON[dto.GameClientBridgeLogStreamRequest](r)
if err != nil {
writeDecodeError(w, err)
return
}
instance, err := h.core.AuthorizeGameClientBridgeLogStream(request.ToDomain())
if err != nil {
writeServiceError(w, err)
return
}
forward := r.Clone(withComponentLogServer(r, instance.ID).Context())
forward.Method = http.MethodGet
h.serverLogEvents(w, forward)
}
+86 -27
View File
@@ -74,17 +74,6 @@ func (h *coreHandlers) register(mux *http.ServeMux) {
mux.HandleFunc("/api/v1/server-instances/{id}/run/download", h.serverRunDownload)
mux.HandleFunc("/api/v1/server-instances/{id}/run/key/reset", h.serverRunKeyReset)
mux.HandleFunc("/api/v1/server-instances/{id}/run/update", h.serverRunUpdate)
mux.HandleFunc("/api/v1/server-instances/{id}/client-managers/generate", h.serverClientManagerGenerate)
mux.HandleFunc("/api/v1/server-instances/{id}/client-managers/download", h.serverClientManagerDownload)
mux.HandleFunc("/api/v1/server-instances/{id}/client-managers/key/reset", h.serverClientManagerKeyReset)
mux.HandleFunc("/api/v1/server-instances/{id}/client-managers", h.serverClientManagerLifecycles)
mux.HandleFunc("/api/v1/server-instances/{id}/client-managers/{profileKey}", h.serverClientManagerLifecycleDetail)
mux.HandleFunc("/api/v1/server-instances/{id}/client-managers/deploy", h.serverClientManagerDeploy)
mux.HandleFunc("/api/v1/server-instances/{id}/client-managers/control", h.serverClientManagerControl)
mux.HandleFunc("/api/v1/server-instances/{id}/client-managers/update", h.serverClientManagerUpdateLifecycle)
mux.HandleFunc("/api/v1/server-instances/{id}/client-managers/retry", h.serverClientManagerRetry)
mux.HandleFunc("/api/v1/server-instances/{id}/client-managers/revoke-session", h.serverClientManagerRevokeSession)
mux.HandleFunc("/api/v1/server-instances/{id}/client-managers/uninstall", h.serverClientManagerUninstall)
mux.HandleFunc("/api/v1/server-instances/{id}/game-client-bridge", h.serverGameClientBridgeStatus)
mux.HandleFunc("/api/v1/server-instances/{id}/game-client-bridge/commands", h.serverGameClientBridgeCommands)
mux.HandleFunc("/api/v1/server-instances/{id}/game-client-bridge/commands/{commandId}/cancel", h.serverGameClientBridgeCommandCancel)
@@ -125,8 +114,6 @@ func (h *coreHandlers) register(mux *http.ServeMux) {
mux.HandleFunc("/api/v1/run/jobs/update-chunk", h.requireRunSignature(h.runJobUpdateChunk))
mux.HandleFunc("/api/v1/run/files/input-chunk", h.requireRunSignature(h.runFileInputChunk))
mux.HandleFunc("/api/v1/run/jobs/update-health", h.requireRunSignature(h.runJobUpdateHealth))
mux.HandleFunc("/api/v1/run/jobs/client-manager-input", h.requireRunSignature(h.runClientManagerLifecycleInput))
mux.HandleFunc("/api/v1/run/jobs/client-manager-chunk", h.requireRunSignature(h.runClientManagerLifecycleChunk))
mux.HandleFunc("/api/v1/run/jobs/cancel", h.requireRunSignature(h.runJobCancelPoll))
mux.HandleFunc("/api/v1/run/jobs/reconcile", h.requireRunSignature(h.runJobReconcile))
mux.HandleFunc("/api/v1/run/logs/batches", h.requireRunSignature(h.runLogBatchIngest))
@@ -148,14 +135,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/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)
mux.HandleFunc("/api/v1/game-client-bridge/companion/commands/{commandId}/ack", h.gameClientBridgeCompanionAck)
mux.HandleFunc("/api/v1/game-client-bridge/companion/commands/{commandId}/result", h.gameClientBridgeCompanionResult)
mux.HandleFunc("/api/v1/game-client-bridge/companion/logs/events", h.gameClientBridgeCompanionLogEvents)
mux.HandleFunc("/api/v1/game-client-bridge/companion/snapshots", h.gameClientBridgeCompanionSnapshot)
mux.HandleFunc("/api/v1/game-client-bridge/companion/diagnostics", h.gameClientBridgeCompanionDiagnostics)
}
// pluginLifecycles godoc
@@ -2208,9 +2187,15 @@ func (h *coreHandlers) runArtifactOpen(w http.ResponseWriter, r *http.Request) {
// @Summary Upload run artifact chunk
// @Description Accepts one bounded artifact chunk from a registered run endpoint and returns resumable acknowledgement state.
// @Tags run-artifacts
// @Accept json
// @Accept octet-stream
// @Produce json
// @Param body body dto.ArtifactChunkUploadRequest true "Artifact chunk upload request"
// @Param X-Artifact-Transfer-Id header string true "Artifact transfer ID"
// @Param X-Artifact-Id header string true "Artifact ID"
// @Param X-Artifact-Chunk-Index header int true "Chunk index"
// @Param X-Artifact-Offset header int true "Byte offset"
// @Param X-Artifact-Size header int true "Chunk size"
// @Param X-Artifact-Checksum header string true "Chunk sha256 checksum"
// @Param body body file true "Artifact chunk bytes"
// @Success 200 {object} dto.ArtifactChunkUploadResponse
// @Failure 400 {object} dto.ErrorResponse
// @Failure 404 {object} dto.ErrorResponse
@@ -2221,9 +2206,9 @@ func (h *coreHandlers) runArtifactChunkUpload(w http.ResponseWriter, r *http.Req
writeMethodNotAllowed(w, http.MethodPost)
return
}
request, err := decodeJSON[dto.ArtifactChunkUploadRequest](r)
request, err := decodeArtifactChunkUploadRequest(r)
if err != nil {
writeDecodeError(w, err)
writeServiceError(w, err)
return
}
result, err := h.core.UploadArtifactChunk(request.ToDomain())
@@ -2234,6 +2219,78 @@ func (h *coreHandlers) runArtifactChunkUpload(w http.ResponseWriter, r *http.Req
writeJSON(w, http.StatusOK, dto.ArtifactChunkUploadFromDomain(result))
}
const (
artifactTransferIDHeader = "X-Artifact-Transfer-Id"
artifactIDHeader = "X-Artifact-Id"
artifactChunkIndexHeader = "X-Artifact-Chunk-Index"
artifactChunkOffsetHeader = "X-Artifact-Offset"
artifactChunkSizeHeader = "X-Artifact-Size"
artifactChunkHashHeader = "X-Artifact-Checksum"
)
func decodeArtifactChunkUploadRequest(r *http.Request) (dto.ArtifactChunkUploadRequest, error) {
if !isOctetStream(r.Header.Get("Content-Type")) {
return dto.ArtifactChunkUploadRequest{}, validator.ValidationError{Violations: []string{"contentType must be application/octet-stream"}}
}
chunkIndex, err := parseRequiredIntHeader(r, artifactChunkIndexHeader)
if err != nil {
return dto.ArtifactChunkUploadRequest{}, err
}
offset, err := parseRequiredInt64Header(r, artifactChunkOffsetHeader)
if err != nil {
return dto.ArtifactChunkUploadRequest{}, err
}
sizeBytes, err := parseRequiredIntHeader(r, artifactChunkSizeHeader)
if err != nil {
return dto.ArtifactChunkUploadRequest{}, err
}
if sizeBytes <= 0 || sizeBytes > validator.MaxArtifactChunkBytes {
return dto.ArtifactChunkUploadRequest{}, validator.ValidationError{Violations: []string{fmt.Sprintf("%s must be between 1 and %d", artifactChunkSizeHeader, validator.MaxArtifactChunkBytes)}}
}
payload, err := io.ReadAll(io.LimitReader(r.Body, int64(validator.MaxArtifactChunkBytes)+1))
if err != nil {
return dto.ArtifactChunkUploadRequest{}, err
}
if len(payload) != sizeBytes {
return dto.ArtifactChunkUploadRequest{}, validator.ValidationError{Violations: []string{"request body size must match artifact chunk size"}}
}
return dto.ArtifactChunkUploadRequest{
RunEndpointID: strings.TrimSpace(r.Header.Get(runEndpointHeader)),
SessionToken: strings.TrimSpace(r.Header.Get(runSessionTokenHeader)),
TransferID: strings.TrimSpace(r.Header.Get(artifactTransferIDHeader)),
ArtifactID: strings.TrimSpace(r.Header.Get(artifactIDHeader)),
ChunkIndex: chunkIndex,
Offset: offset,
SizeBytes: sizeBytes,
Checksum: strings.TrimSpace(r.Header.Get(artifactChunkHashHeader)),
Payload: payload,
}, nil
}
func parseRequiredIntHeader(r *http.Request, name string) (int, error) {
value := strings.TrimSpace(r.Header.Get(name))
if value == "" {
return 0, validator.ValidationError{Violations: []string{name + " is required"}}
}
parsed, err := strconv.Atoi(value)
if err != nil {
return 0, validator.ValidationError{Violations: []string{name + " must be a number"}}
}
return parsed, nil
}
func parseRequiredInt64Header(r *http.Request, name string) (int64, error) {
value := strings.TrimSpace(r.Header.Get(name))
if value == "" {
return 0, validator.ValidationError{Violations: []string{name + " is required"}}
}
parsed, err := strconv.ParseInt(value, 10, 64)
if err != nil {
return 0, validator.ValidationError{Violations: []string{name + " must be a number"}}
}
return parsed, nil
}
// runArtifactStatus godoc
// @Summary Query run artifact upload status
// @Description Returns resumable chunk acknowledgement state for one artifact transfer.
@@ -2597,11 +2654,13 @@ func (h *coreHandlers) artifactContent(w http.ResponseWriter, r *http.Request) {
writeServiceError(w, err)
return
}
content, err := h.core.ReadArtifactContentForSession(bearerToken(r), domain.ArtifactContentRequest{ArtifactID: r.PathValue("id"), Offset: offset, Limit: limit})
stream, err := h.core.OpenArtifactContentStreamForSession(bearerToken(r), domain.ArtifactContentRequest{ArtifactID: r.PathValue("id"), Offset: offset, Limit: limit})
if err != nil {
writeServiceError(w, err)
return
}
defer stream.Body.Close()
content := stream.Content
w.Header().Set("Content-Type", content.ContentType)
w.Header().Set("Content-Disposition", "attachment; filename=\""+strings.ReplaceAll(content.Filename, "\"", "")+"\"")
w.Header().Set("Accept-Ranges", "bytes")
@@ -2617,7 +2676,7 @@ func (h *coreHandlers) artifactContent(w http.ResponseWriter, r *http.Request) {
} else {
w.WriteHeader(http.StatusOK)
}
_, _ = w.Write(content.Payload)
_, _ = io.Copy(w, stream.Body)
}
func artifactRangeRequest(r *http.Request) (int64, int, error) {
+8 -38
View File
@@ -320,11 +320,14 @@ func TestCoreAPIServerRuntimeDistributionAndJobWorkflows(t *testing.T) {
for _, action := range actions.Actions {
availability[action.Key] = action.Available
}
for _, key := range []string{"generate-run", "push-run-update", "generate-client-manager", "dependencies-check", "dependencies-install"} {
for _, key := range []string{"generate-run", "push-run-update", "dependencies-check", "dependencies-install"} {
if !availability[key] {
t.Fatalf("expected action %q available in %+v", key, actions.Actions)
}
}
if availability["generate-client-manager"] {
t.Fatalf("legacy client-manager action should not be advertised in %+v", actions.Actions)
}
runDistribution := postJSONWithAuth[dto.RunDistributionResponse](t, router, "/api/v1/server-instances/"+serverID+"/run/generate", dto.RunDistributionGenerateRequest{TargetOS: "linux", TargetArch: "amd64", IdempotencyKey: "api-run-generate"}, adminSession)
if runDistribution.ArtifactID == "" || runDistribution.BuildJobID == "" || runDistribution.KeyGeneration != 1 || runDistribution.SecretRef == "" || runDistribution.Status != string(domain.DistributionStatusBuilding) {
@@ -333,25 +336,8 @@ func TestCoreAPIServerRuntimeDistributionAndJobWorkflows(t *testing.T) {
runDownloadRecorder := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/"+serverID+"/run/download", map[string]string{}, adminSession)
assertErrorResponse(t, runDownloadRecorder, http.StatusNotFound, errorCodeNotFound)
clientDistribution := postJSONWithAuth[dto.ClientManagerDistributionResponse](t, router, "/api/v1/server-instances/"+serverID+"/client-managers/generate", dto.ClientManagerBuildRequest{ProfileKey: "scum-client-manager", TargetOS: "windows", TargetArch: "amd64", RepositoryURL: "https://github.com/F88888/scum_client.git", SourceRevision: "main", IdempotencyKey: "api-client-manager"}, adminSession)
if clientDistribution.ArtifactID == "" || clientDistribution.BuildJobID == "" || clientDistribution.SecretRef == runDistribution.SecretRef {
t.Fatalf("unexpected client distribution: %+v", clientDistribution)
}
clientDownloadRecorder := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/"+serverID+"/client-managers/download", dto.ClientManagerDownloadRequest{ProfileKey: "scum-client-manager"}, adminSession)
assertErrorResponse(t, clientDownloadRecorder, http.StatusNotFound, errorCodeNotFound)
clientLinux := postJSONWithAuth[dto.ClientManagerDistributionResponse](t, router, "/api/v1/server-instances/"+serverID+"/client-managers/generate", dto.ClientManagerBuildRequest{ProfileKey: "scum-client-manager", TargetOS: "linux", TargetArch: "amd64", RepositoryURL: "https://github.com/F88888/scum_client.git", SourceRevision: "main", IdempotencyKey: "api-client-manager-linux"}, adminSession)
lifecycleList := getJSONWithAuth[dto.ClientManagerInstallationListResponse](t, router, "/api/v1/server-instances/"+serverID+"/client-managers", adminSession)
if lifecycleList.Count != 1 || lifecycleList.Items[0].Status != string(domain.ClientManagerLifecycleBuilding) || lifecycleList.Items[0].Distribution == nil {
t.Fatalf("expected safe client-manager lifecycle projection, got %+v", lifecycleList)
}
deployRecorder := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/"+serverID+"/client-managers/deploy", dto.ClientManagerDeployRequest{ProfileKey: "scum-client-manager", DistributionID: clientLinux.ID, IdempotencyKey: "api-client-manager-deploy"}, adminSession)
assertErrorResponse(t, deployRecorder, http.StatusBadRequest, errorCodeValidation)
detail := getJSONWithAuth[dto.ClientManagerInstallationResponse](t, router, "/api/v1/server-instances/"+serverID+"/client-managers/scum-client-manager", adminSession)
if detail.CurrentJobID != "" || detail.KeyGeneration <= 0 {
t.Fatalf("unexpected client-manager lifecycle detail: %+v", detail)
}
unauthorizedLifecycle := requestWithAuth(t, router, http.MethodGet, "/api/v1/server-instances/"+serverID+"/client-managers", "", "")
assertErrorResponse(t, unauthorizedLifecycle, http.StatusUnauthorized, errorCodeUnauthorized)
assertStatus(t, requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/"+serverID+"/client-managers/generate", map[string]string{"profileKey": "scum-client-manager"}, adminSession), http.StatusNotFound)
assertStatus(t, requestWithAuth(t, router, http.MethodGet, "/api/v1/server-instances/"+serverID+"/client-managers", "", adminSession), http.StatusNotFound)
dependencyCheckRecorder := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/"+serverID+"/dependencies/check", dto.DependencyJobRequest{ProbeKey: "java-runtime", IdempotencyKey: "api-dependency-check"}, adminSession)
assertStatus(t, dependencyCheckRecorder, http.StatusAccepted)
@@ -379,12 +365,9 @@ func TestCoreAPIServerRuntimeDistributionAndJobWorkflows(t *testing.T) {
if runReset.Generation != 2 || runReset.SecretRef == "" {
t.Fatalf("unexpected run key reset: %+v", runReset)
}
clientReset := postOKJSONWithAuth[dto.ComponentKeyResponse](t, router, "/api/v1/server-instances/"+serverID+"/client-managers/key/reset", dto.ComponentKeyResetRequest{ComponentKey: "scum-client-manager"}, adminSession)
if clientReset.Generation != 2 || clientReset.SecretRef == runReset.SecretRef {
t.Fatalf("unexpected client key reset: %+v", clientReset)
}
assertStatus(t, requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/"+serverID+"/client-managers/key/reset", dto.ComponentKeyResetRequest{ComponentKey: "scum-client-manager"}, adminSession), http.StatusNotFound)
for _, body := range []string{mustJSON(t, runDistribution), mustJSON(t, clientDistribution), mustJSON(t, runReset), mustJSON(t, clientReset), mustJSON(t, dependencyInstall)} {
for _, body := range []string{mustJSON(t, runDistribution), mustJSON(t, runReset), mustJSON(t, dependencyInstall)} {
for _, forbidden := range []string{"authKey", "enc:v1", "password=", "unix://", "tcp://", "/Users/", "mysql://", "sqlite://"} {
if strings.Contains(body, forbidden) {
t.Fatalf("runtime API response exposed forbidden fragment %q: %s", forbidden, body)
@@ -1841,30 +1824,22 @@ func createRuntimeAPIFixtures(t *testing.T, router http.Handler, adminSession st
domain.JobCapabilityDependenciesCheck,
domain.JobCapabilityDependenciesInstall,
domain.JobCapabilityLogsBackfill,
domain.JobCapabilityClientManagerDeploy,
domain.JobCapabilityClientManagerControl,
domain.JobCapabilityClientManagerUpdate,
domain.JobCapabilityClientManagerRollback,
domain.JobCapabilityClientManagerUninstall,
}
pluginRequest.DeclaredPermissions = []string{
"server.read",
"server.logs.read",
"server.run.distribution",
"server.client-manager.manage",
"server.dependencies.manage",
"server.artifacts.read",
}
pluginRequest.BridgeActions = []string{
string(domain.PluginBridgeActionRunDistribution),
string(domain.PluginBridgeActionClientManager),
string(domain.PluginBridgeActionDependenciesRequest),
string(domain.PluginBridgeActionLogsBackfillRequest),
}
pluginRequest.RuntimeProfiles.DependencyProbes = []dto.RuntimeDependencyProbeBody{{Key: "java-runtime", Kind: "command.version", TargetKey: "java", Platforms: []string{"linux"}}}
pluginRequest.RuntimeProfiles.InstallPlans = []dto.RuntimeInstallPlanBody{{Key: "java-install", Title: "Install Java", Platforms: []string{"linux"}, Steps: []dto.RuntimeInstallStepBody{{Type: "package", TargetKey: "java", PackageManager: "apt", PackageName: "openjdk-21-jre"}}}}
pluginRequest.RuntimeProfiles.LogSources = []dto.RuntimeLogSourceBody{{Key: "latest", Kind: "file.tail", TargetKey: "logs/latest", StreamKey: "latest-log", CursorKind: "offset", RetentionDays: 30}}
pluginRequest.RuntimeProfiles.ClientManagers = []dto.RuntimeClientManagerProfileBody{{Key: "scum-client-manager", DisplayName: "SCUM Client Manager", Version: "1.0.0", Repository: dto.RuntimeRepositoryBody{URL: "https://github.com/F88888/scum_client.git", RevisionPolicy: "branch", Branch: "main"}, SupportedTargets: []dto.RuntimeTargetBody{{OS: "windows", Arch: "amd64"}, {OS: "linux", Arch: "amd64"}}, Build: dto.RuntimeBuildBody{System: "go", EntryRef: "main.go"}, OutputArtifacts: []string{"scum_client.exe"}, Deployment: dto.RuntimeClientManagerDeploymentBody{Mode: "run-supervised", ExecutableRef: "scum_client.exe", RequiredRunCapabilities: []string{domain.JobCapabilityClientManagerDeploy, domain.JobCapabilityClientManagerControl, domain.JobCapabilityClientManagerUpdate, domain.JobCapabilityClientManagerRollback, domain.JobCapabilityClientManagerUninstall}}, Lifecycle: dto.RuntimeClientManagerLifecycleBody{Actions: []string{"start", "stop", "restart", "status", "update", "rollback", "uninstall"}, StartupTimeoutSeconds: 60, StopTimeoutSeconds: 30}, Health: dto.RuntimeClientManagerHealthBody{Mode: "component-heartbeat", IntervalSeconds: 15, DegradedAfterSeconds: 45, OfflineAfterSeconds: 120, RequiredCapabilities: []string{"component.register", "component.heartbeat", "component.health"}}, Compatibility: dto.RuntimeClientManagerCompatibilityBody{MinimumVersion: "1.0.0"}, UpdatePolicy: dto.RuntimeClientManagerUpdatePolicyBody{Strategy: "manual-staged", RequireApproval: true, HealthConfirmationSeconds: 60, RetainPrevious: true}}}
postJSON[dto.GamePluginResponse](t, router, "/api/v1/game-plugins", pluginRequest)
endpoint := validRunEndpointRequest()
@@ -1877,11 +1852,6 @@ func createRuntimeAPIFixtures(t *testing.T, router http.Handler, adminSession st
domain.JobCapabilityDependenciesCheck,
domain.JobCapabilityDependenciesInstall,
domain.JobCapabilityLogsBackfill,
domain.JobCapabilityClientManagerDeploy,
domain.JobCapabilityClientManagerControl,
domain.JobCapabilityClientManagerUpdate,
domain.JobCapabilityClientManagerRollback,
domain.JobCapabilityClientManagerUninstall,
)
postJSON[dto.RunEndpointResponse](t, router, "/api/v1/run/endpoints", endpoint)
+6 -12
View File
@@ -14,7 +14,7 @@ Routes use JSON request and response bodies unless a route explicitly accepts fi
| Plugin marketplace | `GET /api/v1/plugin-marketplace/plugins` | `GET /api/v1/plugin-marketplace/plugins/{id}`, `POST /api/v1/plugin-marketplace/plugins/{id}/state` | `MarketplacePluginResponse`, `MarketplacePluginListResponse`, `MarketplacePluginStateRequest` |
| Plugin bridge | `POST /api/v1/plugin-bridge/authorize`, `POST /api/v1/plugin-bridge/execute` | n/a | `PluginBridgeAuthorizeRequest`, `PluginBridgeAuthorizeResponse`, `PluginBridgeExecuteRequest`, `PluginBridgeExecuteResponse` |
| Server instances | `GET /api/v1/server-instances`, `POST /api/v1/server-instances` | `GET /api/v1/server-instances/{id}`, `PUT /api/v1/server-instances/{id}`, `DELETE /api/v1/server-instances/{id}` | `ServerInstanceCreateRequest`, `ServerInstanceUpdateRequest`, `ServerInstanceResponse`, `ServerInstanceListResponse` |
| Server runtime distribution | n/a | `GET /api/v1/server-instances/{id}/runtime/actions`, `POST /api/v1/server-instances/{id}/run/generate`, `POST /api/v1/server-instances/{id}/run/download`, `POST /api/v1/server-instances/{id}/run/key/reset`, `POST /api/v1/server-instances/{id}/run/update`, `GET /api/v1/server-instances/{id}/run/update`, `POST /api/v1/server-instances/{id}/client-managers/generate`, `POST /api/v1/server-instances/{id}/client-managers/download`, `POST /api/v1/server-instances/{id}/client-managers/key/reset`, `GET /api/v1/server-instances/{id}/dependencies`, `POST /api/v1/server-instances/{id}/dependencies/check`, `POST /api/v1/server-instances/{id}/dependencies/install` | `ServerRuntimeActionsResponse`, `RunDistributionGenerateRequest`, `RunDistributionResponse`, `RunUpdateRequest`, `RunUpdateJobResponse`/`RunUpdateJobListResponse`, `ClientManagerBuildRequest`, `ClientManagerDistributionResponse`, `ClientManagerDownloadRequest`, `ComponentKeyResetRequest`, `ComponentKeyResponse`, `DependencyCatalogResponse`, `DependencyJobRequest` |
| Server runtime distribution | n/a | `GET /api/v1/server-instances/{id}/runtime/actions`, `POST /api/v1/server-instances/{id}/run/generate`, `POST /api/v1/server-instances/{id}/run/download`, `POST /api/v1/server-instances/{id}/run/key/reset`, `POST /api/v1/server-instances/{id}/run/update`, `GET /api/v1/server-instances/{id}/run/update`, `GET /api/v1/server-instances/{id}/dependencies`, `POST /api/v1/server-instances/{id}/dependencies/check`, `POST /api/v1/server-instances/{id}/dependencies/install` | `ServerRuntimeActionsResponse`, `RunDistributionGenerateRequest`, `RunDistributionResponse`, `RunUpdateRequest`, `RunUpdateJobResponse`/`RunUpdateJobListResponse`, `ComponentKeyResetRequest`, `ComponentKeyResponse`, `DependencyCatalogResponse`, `DependencyJobRequest` |
| Metrics | `GET /api/v1/metrics/platform`, `GET /api/v1/metrics/server-instances` | n/a | `PlatformResourceUsageResponse`, `ServerMetricsResponse`, `ServerMetricsListResponse` |
| File operations | `POST /api/v1/file-operations/dispatch` | n/a | `FileOperationDispatchRequest`, `FileOperationDispatchResponse` |
| Server file manager | `GET /api/v1/server-instances/{id}/files/workspace`, `POST /api/v1/server-instances/{id}/files/browse`, `GET /api/v1/server-instances/{id}/files/list`, `POST /api/v1/server-instances/{id}/files/refresh`, `POST /api/v1/server-instances/{id}/files/read`, `POST /api/v1/server-instances/{id}/files/write`, `POST /api/v1/server-instances/{id}/files/upload`, `POST /api/v1/server-instances/{id}/files/download` | `GET /api/v1/server-instances/{id}/files/read-snapshot` | `ServerFileWorkspaceResponse`, `ServerFileListResponse`, `DeclaredFileReadSnapshotResponse`, `ServerFileReadRequest`, `ServerFileWriteRequest`, `ServerFileUploadResponse`, `ServerFileDownloadRequest`, `ServerFileDownloadResponse` |
@@ -27,8 +27,6 @@ Routes use JSON request and response bodies unless a route explicitly accepts fi
Plugin-owned data is an independent, server-scoped plugin store. It is not a projection of the game-server database and never aliases platform user/auth storage.
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.
## Implemented Query Filters
- `GET /api/v1/users?status=active`
@@ -138,7 +136,7 @@ Artifact bridge execution returns safe metadata and platform content routes only
Lifecycle workflow responses include accepted status, action, bounded server instance metadata, and bounded job metadata. They do not expose run credentials, host paths, raw credentials, AI provider keys, direct sockets, plugin action file contents, or large result bodies.
## Implemented Runtime Distribution And Client Manager Actions
## Implemented Runtime Distribution Actions
- `GET /api/v1/server-instances/{id}/runtime-binding`: returns the visible server's selected profile and redacted logical binding readiness. Values are represented only by configured/secret-backed flags.
- `PUT /api/v1/server-instances/{id}/runtime-binding`: lets the server owner or a platform administrator select a declared profile and patch safe logical refs for non-deleted servers. Undeclared keys, unsafe paths/sockets/credentials, and plaintext secrets are rejected.
@@ -148,15 +146,12 @@ Lifecycle workflow responses include accepted status, action, bounded server ins
- `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 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`.
- `GET /api/v1/server-instances/{id}/dependencies`: returns the target-matched plugin/profile dependency catalog, current safe probe status/evidence, typed plan summaries, and deterministic immutable plan digests.
- `POST /api/v1/server-instances/{id}/dependencies/check`: accepts `DependencyJobRequest` and queues a `dependencies.check` run job for a declared logical probe key.
- `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 live log SSE events only. The component stream (`POST /api/v1/game-client-bridge/companion/logs/events`) forwards the current declared streams to the bound plugin companion. Neither route replays retained log entries or interprets their body; the raw log list/backfill routes (`logs/live` and `logs/backfill`) remain unavailable as product APIs, and internal log ingest and cursor query remain available for run/platform maintenance flows.
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 live log SSE events only. It does not replay retained log entries or interpret their body; the raw log list/backfill routes (`logs/live` and `logs/backfill`) remain unavailable as product APIs, and 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 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 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, 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 bounded summaries. They never expose SCUM.db SQL text, DB paths, DSNs, RCON command text, raw request payloads, run sockets, host paths, or credentials.
@@ -198,7 +193,6 @@ Run file input chunks are used only for browser-staged file uploads that produce
- `POST /api/v1/run/logs/batches`: accept `LogBatchIngestRequest`, validate run session and stream metadata, store contiguous entries verbatim, update `LogStream.LatestSeq`, and return `LogBatchIngestResponse` with the acknowledged range.
- `POST /api/v1/log-streams/query`: accept `LogStreamCursorRequest` and return `LogStreamCursorResponse` with bounded ordered entries after a cursor.
- `POST /api/v1/game-client-bridge/companion/logs/events`: authorize the component session and forward the current declared log channel as SSE. The payload is opaque; parsing, redaction, and user/business projections belong to the plugin companion.
Server-scoped SSE log streaming remains available for the terminal drawer. `POST /api/v1/log-streams/query` remains the bounded cursor contract for internal maintenance/debug reads.
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.
@@ -209,11 +203,11 @@ Platform storage is configured by `PLATFORM_STORAGE_BACKEND`. The default `file`
## Implemented Run Artifact Actions
- `POST /api/v1/run/artifacts/open`: accept `ArtifactTransferOpenRequest`, validate active run session and scoped artifact owner, create or reuse uploading artifact metadata, and return `ArtifactTransferOpenResponse` with transfer resume state.
- `POST /api/v1/run/artifacts/chunks`: accept `ArtifactChunkUploadRequest`, validate chunk range and checksum, store idempotent chunk state, and return `ArtifactChunkUploadResponse` with acknowledged chunk indexes.
- `POST /api/v1/run/artifacts/chunks`: accept `application/octet-stream` chunk bytes with transfer metadata in headers, validate chunk range and checksum, store idempotent chunk state, and return `ArtifactChunkUploadResponse` with acknowledged chunk indexes.
- `POST /api/v1/run/artifacts/status`: accept `ArtifactTransferStatusRequest` and return `ArtifactTransferStatusResponse` with received chunks and next missing chunk index.
- `POST /api/v1/run/artifacts/complete`: accept `ArtifactTransferCompleteRequest`, verify all chunks and final checksum, mark the artifact available, and return `ArtifactTransferCompleteResponse`.
Run artifact actions carry bounded upload metadata and chunk payloads only: run endpoint ID, session token, transfer ID, artifact ID, owner metadata, chunk indexes, byte ranges, checksums, and JSON chunk payload bytes. They do not carry control heartbeat metadata beyond session identity, job result bodies, logs, host paths, raw credentials, direct sockets, or plugin/browser storage credentials.
Run artifact actions carry bounded upload metadata and raw chunk bodies only: run endpoint ID, session token, transfer ID, artifact ID, owner metadata, chunk indexes, byte ranges, checksums, and `application/octet-stream` chunk bytes. They do not carry control heartbeat metadata beyond session identity, job result bodies, logs, host paths, raw credentials, direct sockets, or plugin/browser storage credentials.
Artifact/file transfer is lower priority than control, job lifecycle metadata, and durable log ingest. Slow or retrying chunks must not block heartbeat, job ack/result delivery, cancellation/reconcile calls, or log batch acknowledgement; lightweight routes reject heavy transfer payloads rather than storing them.
## Implemented Browser Artifact Download Actions
-56
View File
@@ -155,62 +155,6 @@ func (h *coreHandlers) serverDependencies(w http.ResponseWriter, r *http.Request
writeJSON(w, http.StatusOK, dto.DependencyCatalogFromDomain(catalog))
}
func (h *coreHandlers) serverClientManagerGenerate(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeMethodNotAllowed(w, http.MethodPost)
return
}
request, err := decodeJSON[dto.ClientManagerBuildRequest](r)
if err != nil {
writeDecodeError(w, err)
return
}
distribution, err := h.core.GenerateClientManagerDistributionForSession(bearerToken(r), request.ToDomain(r.PathValue("id")))
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusCreated, dto.ClientManagerDistributionFromDomain(distribution))
}
func (h *coreHandlers) serverClientManagerDownload(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeMethodNotAllowed(w, http.MethodPost)
return
}
request, err := decodeJSON[dto.ClientManagerDownloadRequest](r)
if err != nil {
writeDecodeError(w, err)
return
}
reference, err := h.core.OpenLatestClientManagerDistributionDownloadForSession(bearerToken(r), r.PathValue("id"), request.ProfileKey)
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, dto.ArtifactDownloadReferenceFromDomain(reference))
}
func (h *coreHandlers) serverClientManagerKeyReset(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeMethodNotAllowed(w, http.MethodPost)
return
}
request, err := decodeJSON[dto.ComponentKeyResetRequest](r)
if err != nil {
writeDecodeError(w, err)
return
}
reset := request.ToDomain(r.PathValue("id"))
reset.ComponentKind = domain.DistributionComponentClientManager
key, err := h.core.ResetComponentKeyForSession(bearerToken(r), reset)
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, dto.ComponentKeyFromDomain(key))
}
func (h *coreHandlers) serverDependenciesCheck(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeMethodNotAllowed(w, http.MethodPost)