功能修改
This commit is contained in:
@@ -32,6 +32,9 @@ func publicAPIRequest(r *http.Request) bool {
|
||||
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/") {
|
||||
return true
|
||||
}
|
||||
if r.Method != http.MethodGet {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
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))
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
"browser.local/platform/dto"
|
||||
"browser.local/platform/repo"
|
||||
)
|
||||
|
||||
func (h *coreHandlers) serverGameClientBridgeStatus(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeMethodNotAllowed(w, http.MethodGet)
|
||||
return
|
||||
}
|
||||
status, err := h.core.GetGameClientBridgeStatusForSession(bearerToken(r), r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, dto.GameClientBridgeStatusFromDomain(status))
|
||||
}
|
||||
|
||||
func (h *coreHandlers) serverGameClientBridgeCommands(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
commands, err := h.core.ListGameClientBridgeCommandsForSession(bearerToken(r), domain.GameClientBridgeCommandFilter{ServerInstanceID: r.PathValue("id"), ProfileKey: r.URL.Query().Get("profileKey"), State: domain.GameClientBridgeCommandState(r.URL.Query().Get("state")), CommandType: r.URL.Query().Get("commandType")})
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, dto.GameClientBridgeCommandsFromDomain(commands))
|
||||
case http.MethodPost:
|
||||
request, err := decodeJSON[dto.GameClientBridgeQueueRequest](r)
|
||||
if err != nil {
|
||||
writeDecodeError(w, err)
|
||||
return
|
||||
}
|
||||
instance, err := h.core.GetServerInstanceForSession(bearerToken(r), r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
command, err := h.core.QueueGameClientBridgeCommandForSession(bearerToken(r), request.ToDomain(instance.ID, instance.PluginID))
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusAccepted, dto.GameClientBridgeCommandFromDomain(command))
|
||||
default:
|
||||
w.Header().Set("Allow", http.MethodGet+", "+http.MethodPost)
|
||||
writeAPIError(w, http.StatusMethodNotAllowed, errorCodeMethodNotAllowed, "method not allowed", nil)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *coreHandlers) serverGameClientBridgeCommandDetail(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeMethodNotAllowed(w, http.MethodGet)
|
||||
return
|
||||
}
|
||||
command, err := h.core.GetGameClientBridgeCommandForSession(bearerToken(r), r.PathValue("commandId"))
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
if command.ServerInstanceID != r.PathValue("id") {
|
||||
writeServiceError(w, repo.ErrNotFound)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, dto.GameClientBridgeCommandFromDomain(command))
|
||||
}
|
||||
|
||||
func (h *coreHandlers) serverGameClientBridgeCommandCancel(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeMethodNotAllowed(w, http.MethodPost)
|
||||
return
|
||||
}
|
||||
request, err := decodeJSON[dto.GameClientBridgeCancelRequest](r)
|
||||
if err != nil {
|
||||
writeDecodeError(w, err)
|
||||
return
|
||||
}
|
||||
command, err := h.core.GetGameClientBridgeCommandForSession(bearerToken(r), r.PathValue("commandId"))
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
if command.ServerInstanceID != r.PathValue("id") {
|
||||
writeServiceError(w, repo.ErrNotFound)
|
||||
return
|
||||
}
|
||||
cancelled, err := h.core.CancelGameClientBridgeCommandForSession(bearerToken(r), request.ToDomain(command.ID))
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, dto.GameClientBridgeCancelFromDomain(cancelled))
|
||||
}
|
||||
|
||||
func (h *coreHandlers) serverGameClientBridgeSnapshots(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeMethodNotAllowed(w, http.MethodGet)
|
||||
return
|
||||
}
|
||||
instance, err := h.core.GetServerInstanceForSession(bearerToken(r), r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
limit, err := optionalPositiveInt(r.URL.Query().Get("limit"))
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusBadRequest, errorCodeBadRequest, "invalid snapshot limit", nil)
|
||||
return
|
||||
}
|
||||
var observedAfter time.Time
|
||||
if value := r.URL.Query().Get("observedAfter"); value != "" {
|
||||
observedAfter, err = time.Parse(time.RFC3339Nano, value)
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusBadRequest, errorCodeBadRequest, "invalid observedAfter timestamp", nil)
|
||||
return
|
||||
}
|
||||
}
|
||||
query := dto.GameClientBridgeSnapshotQuery{ProfileKey: r.URL.Query().Get("profileKey"), Type: r.URL.Query().Get("type"), StreamKey: r.URL.Query().Get("streamKey"), ObservedAfter: observedAfter, Limit: limit}
|
||||
snapshots, err := h.core.QueryGameClientBridgeSnapshotsForSession(bearerToken(r), query.ToDomain(instance.ID, instance.PluginID))
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, dto.GameClientBridgeSnapshotsFromDomain(snapshots))
|
||||
}
|
||||
|
||||
func optionalPositiveInt(value string) (int, error) {
|
||||
if value == "" {
|
||||
return 0, nil
|
||||
}
|
||||
return strconv.Atoi(value)
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
"browser.local/platform/dto"
|
||||
"browser.local/platform/repo"
|
||||
"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)
|
||||
if err := coreService.SeedLocalPlatformAdmin(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
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.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}}
|
||||
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)
|
||||
if _, err := coreService.CreateRunEndpoint(endpoint); err != nil {
|
||||
t.Fatalf("create run endpoint: %v", err)
|
||||
}
|
||||
router := NewTestRouterWithCore(coreService)
|
||||
adminSession := createAdminSession(t, router)
|
||||
if _, err := coreService.CreateServerInstanceForSession(adminSession, domain.ServerInstance{ID: "server-bridge", PluginID: "server.scum", RunEndpointID: "run-local", Name: "Bridge Server"}); err != nil {
|
||||
t.Fatalf("create bridge server: %v", err)
|
||||
}
|
||||
|
||||
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 {
|
||||
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" {
|
||||
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)}
|
||||
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)
|
||||
if queued.ID == "" || queued.CommandType != queue.CommandType || queued.State != "pending" {
|
||||
t.Fatalf("unexpected queued command: %#v", queued)
|
||||
}
|
||||
undeclared := queue
|
||||
undeclared.CommandType = "undeclared.command"
|
||||
undeclared.IdempotencyKey = "undeclared-1"
|
||||
undeclaredRecorder := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/server-bridge/game-client-bridge/commands", undeclared, adminSession)
|
||||
assertErrorResponse(t, undeclaredRecorder, http.StatusBadRequest, errorCodeValidation)
|
||||
|
||||
commands := getJSONWithAuth[dto.GameClientBridgeCommandListResponse](t, router, "/api/v1/server-instances/server-bridge/game-client-bridge/commands?state=pending", adminSession)
|
||||
if commands.Count != 1 || commands.Items[0].ID != queued.ID {
|
||||
t.Fatalf("unexpected command list: %#v", commands)
|
||||
}
|
||||
detail := getJSONWithAuth[dto.GameClientBridgeCommandResponse](t, router, "/api/v1/server-instances/server-bridge/game-client-bridge/commands/"+queued.ID, adminSession)
|
||||
if detail.ID != queued.ID {
|
||||
t.Fatalf("unexpected command detail: %#v", detail)
|
||||
}
|
||||
|
||||
cancelRecorder := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/server-bridge/game-client-bridge/commands/"+queued.ID+"/cancel", dto.GameClientBridgeCancelRequest{Reason: "operator requested"}, adminSession)
|
||||
assertStatus(t, cancelRecorder, http.StatusOK)
|
||||
cancelled := decodeBody[dto.GameClientBridgeCancelResponse](t, cancelRecorder)
|
||||
if cancelled.State != "cancelled" || cancelled.CommandID != queued.ID {
|
||||
t.Fatalf("unexpected cancellation: %#v", cancelled)
|
||||
}
|
||||
|
||||
snapshots := getJSONWithAuth[dto.GameClientBridgeSnapshotListResponse](t, router, "/api/v1/server-instances/server-bridge/game-client-bridge/snapshots?limit=20", adminSession)
|
||||
if snapshots.Count != 0 || snapshots.Items == nil {
|
||||
t.Fatalf("unexpected snapshot list: %#v", snapshots)
|
||||
}
|
||||
|
||||
for _, serialized := range []string{queued.ResultSummary, status.Reason} {
|
||||
if strings.Contains(strings.ToLower(serialized), "token") || strings.Contains(strings.ToLower(serialized), "secret") {
|
||||
t.Fatalf("safe operator response leaked sensitive text: %q", serialized)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"browser.local/platform/dto"
|
||||
"browser.local/platform/repo"
|
||||
@@ -60,11 +61,18 @@ func writeMethodNotAllowed(w http.ResponseWriter, allow string) {
|
||||
|
||||
func writeServiceError(w http.ResponseWriter, err error) {
|
||||
var validationErr validator.ValidationError
|
||||
var forbiddenErr service.ForbiddenError
|
||||
switch {
|
||||
case errors.As(err, &validationErr):
|
||||
writeAPIError(w, http.StatusBadRequest, errorCodeValidation, "validation failed", validationErr.Violations)
|
||||
case errors.Is(err, service.ErrUnauthorized):
|
||||
writeAPIError(w, http.StatusUnauthorized, errorCodeUnauthorized, "authentication required", nil)
|
||||
case errors.As(err, &forbiddenErr):
|
||||
message := strings.TrimSpace(forbiddenErr.Reason)
|
||||
if message == "" {
|
||||
message = "account is not allowed to access this resource"
|
||||
}
|
||||
writeAPIError(w, http.StatusForbidden, errorCodeForbidden, message, nil)
|
||||
case errors.Is(err, service.ErrForbidden):
|
||||
writeAPIError(w, http.StatusForbidden, errorCodeForbidden, "account is not allowed to access this resource", nil)
|
||||
case errors.Is(err, repo.ErrDuplicate):
|
||||
|
||||
@@ -47,6 +47,16 @@ 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)
|
||||
mux.HandleFunc("/api/v1/ai/config-diffs/{id}/approve", h.aiConfigDiffApprove)
|
||||
mux.HandleFunc("/api/v1/metrics/server-instances/history", h.metricHistory)
|
||||
mux.HandleFunc("/api/v1/run/metrics/batches", h.requireRunSignature(h.runMetricBatchIngest))
|
||||
mux.HandleFunc("/api/v1/backups", h.backups)
|
||||
@@ -74,6 +84,11 @@ func (h *coreHandlers) register(mux *http.ServeMux) {
|
||||
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)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/game-client-bridge/commands/{commandId}", h.serverGameClientBridgeCommandDetail)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/game-client-bridge/snapshots", h.serverGameClientBridgeSnapshots)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/dependencies/check", h.serverDependenciesCheck)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/dependencies/install", h.serverDependenciesInstall)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/dependencies", h.serverDependencies)
|
||||
@@ -123,6 +138,290 @@ func (h *coreHandlers) register(mux *http.ServeMux) {
|
||||
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)
|
||||
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/snapshots", h.gameClientBridgeCompanionSnapshot)
|
||||
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.
|
||||
// @Tags production-operations
|
||||
// @Produce json
|
||||
// @Success 200 {object} dto.PluginLifecycleListResponse
|
||||
// @Failure 401 {object} dto.ErrorResponse
|
||||
// @Failure 405 {object} dto.ErrorResponse
|
||||
// @Router /api/v1/plugin-lifecycles [get]
|
||||
func (h *coreHandlers) pluginLifecycles(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeMethodNotAllowed(w, http.MethodGet)
|
||||
return
|
||||
}
|
||||
items, err := h.core.ListPluginLifecyclesForSession(bearerToken(r), domain.PluginLifecycleFilter{PluginID: r.URL.Query().Get("pluginId"), ServerInstanceID: r.URL.Query().Get("serverInstanceId"), CurrentState: domain.PluginLifecycleState(r.URL.Query().Get("currentState"))})
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, dto.PluginLifecycleListFromDomain(items))
|
||||
}
|
||||
|
||||
// pluginLifecycleAction godoc
|
||||
// @Summary Dispatch a platform-mediated plugin lifecycle action
|
||||
// @Description Runs compatibility and capacity gates before creating one durable bounded Run job.
|
||||
// @Tags production-operations
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param pluginId path string true "Plugin ID"
|
||||
// @Param body body dto.PluginLifecycleActionRequest true "Plugin lifecycle action"
|
||||
// @Success 202 {object} dto.PluginLifecycleActionResponse
|
||||
// @Failure 400 {object} dto.ErrorResponse
|
||||
// @Failure 401 {object} dto.ErrorResponse
|
||||
// @Failure 403 {object} dto.ErrorResponse
|
||||
// @Failure 405 {object} dto.ErrorResponse
|
||||
// @Router /api/v1/plugin-lifecycles/{pluginId}/actions [post]
|
||||
func (h *coreHandlers) pluginLifecycleAction(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeMethodNotAllowed(w, http.MethodPost)
|
||||
return
|
||||
}
|
||||
request, err := decodeJSON[dto.PluginLifecycleActionRequest](r)
|
||||
if err != nil {
|
||||
writeDecodeError(w, err)
|
||||
return
|
||||
}
|
||||
result, err := h.core.RunPluginLifecycleForSession(bearerToken(r), request.ToDomain(r.PathValue("pluginId")))
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusAccepted, dto.PluginLifecycleResultFromDomain(result))
|
||||
}
|
||||
|
||||
// aiConfigDiffs godoc
|
||||
// @Summary List reviewable AI config diffs
|
||||
// @Description Lists persisted AI recommendations visible to the current operator without provider credentials or transport configuration.
|
||||
// @Tags production-operations
|
||||
// @Produce json
|
||||
// @Success 200 {object} dto.AIConfigDiffListResponse
|
||||
// @Failure 401 {object} dto.ErrorResponse
|
||||
// @Failure 405 {object} dto.ErrorResponse
|
||||
// @Router /api/v1/ai/config-diffs [get]
|
||||
func (h *coreHandlers) aiConfigDiffs(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeMethodNotAllowed(w, http.MethodGet)
|
||||
return
|
||||
}
|
||||
items, err := h.core.ListAIConfigDiffsForSession(bearerToken(r), domain.AIConfigDiffFilter{ServerInstanceID: r.URL.Query().Get("serverInstanceId"), PluginID: r.URL.Query().Get("pluginId"), State: domain.AIConfigDiffState(r.URL.Query().Get("state"))})
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, dto.AIConfigDiffListFromDomain(items))
|
||||
}
|
||||
|
||||
// aiConfigDiffApprove godoc
|
||||
// @Summary Approve one reviewable AI config diff
|
||||
// @Description Revalidates actor/server/config revision fences before dispatching one bounded config write job.
|
||||
// @Tags production-operations
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "AI config diff ID"
|
||||
// @Param body body dto.AIConfigDiffApprovalRequest true "AI config diff approval"
|
||||
// @Success 202 {object} dto.AIConfigDiffApprovalResponse
|
||||
// @Failure 400 {object} dto.ErrorResponse
|
||||
// @Failure 401 {object} dto.ErrorResponse
|
||||
// @Failure 403 {object} dto.ErrorResponse
|
||||
// @Failure 405 {object} dto.ErrorResponse
|
||||
// @Router /api/v1/ai/config-diffs/{id}/approve [post]
|
||||
func (h *coreHandlers) aiConfigDiffApprove(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeMethodNotAllowed(w, http.MethodPost)
|
||||
return
|
||||
}
|
||||
request, err := decodeJSON[dto.AIConfigDiffApprovalRequest](r)
|
||||
if err != nil {
|
||||
writeDecodeError(w, err)
|
||||
return
|
||||
}
|
||||
result, err := h.core.ApproveAIConfigDiffForSession(bearerToken(r), request.ToDomain(r.PathValue("id")))
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusAccepted, dto.AIConfigDiffApprovalFromDomain(result))
|
||||
}
|
||||
|
||||
// authRegister godoc
|
||||
@@ -554,7 +853,7 @@ func (h *coreHandlers) aiProviders(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// aiProviderDetail godoc
|
||||
// @Summary Get AI provider
|
||||
// @Description Returns one platform-managed AI provider by ID without raw key material.
|
||||
// @Description Returns one platform-managed AI provider by ID without raw key or base URL material.
|
||||
// @Tags ai-providers
|
||||
// @Produce json
|
||||
// @Param id path string true "AI provider ID"
|
||||
@@ -589,6 +888,9 @@ func (h *coreHandlers) aiProviderDetail(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
update := request.ToDomain(r.PathValue("id"), existing.Status)
|
||||
if strings.TrimSpace(update.BaseURL) == "" {
|
||||
update.BaseURL = existing.BaseURL
|
||||
}
|
||||
if strings.TrimSpace(update.APIKeyRef) == "" {
|
||||
update.APIKeyRef = existing.APIKeyRef
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package api
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -391,6 +392,37 @@ func TestCoreAPIServerRuntimeDistributionAndJobWorkflows(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoreAPIRunDistributionDenialNamesMissingPluginPermission(t *testing.T) {
|
||||
router := newTestRouter()
|
||||
adminSession := createAdminSession(t, router)
|
||||
postJSON[dto.GamePluginResponse](t, router, "/api/v1/game-plugins/register-manifest", validGamePluginManifestRegistrationRequest())
|
||||
postJSON[dto.RunEndpointResponse](t, router, "/api/v1/run/endpoints", validRunEndpointRequest())
|
||||
server := postJSONWithAuth[dto.ServerInstanceResponse](t, router, "/api/v1/server-instances", dto.ServerInstanceCreateRequest{
|
||||
ID: "server-plugin-denied",
|
||||
PluginID: "game.example",
|
||||
RunEndpointID: "run-local",
|
||||
Name: "Plugin Denied",
|
||||
}, adminSession)
|
||||
|
||||
recorder := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/"+server.ID+"/run/generate", dto.RunDistributionGenerateRequest{
|
||||
TargetOS: "linux",
|
||||
TargetArch: "amd64",
|
||||
IdempotencyKey: "missing-plugin-permission",
|
||||
}, adminSession)
|
||||
assertStatus(t, recorder, http.StatusForbidden)
|
||||
body := decodeBody[dto.ErrorResponse](t, recorder)
|
||||
if body.Code != errorCodeForbidden || body.Message != "plugin does not declare required permission: server.run.distribution" {
|
||||
t.Fatalf("expected plugin permission denial, got %+v", body)
|
||||
}
|
||||
|
||||
dependenciesRecorder := requestWithAuth(t, router, http.MethodGet, "/api/v1/server-instances/"+server.ID+"/dependencies", "", adminSession)
|
||||
assertStatus(t, dependenciesRecorder, http.StatusForbidden)
|
||||
dependenciesBody := decodeBody[dto.ErrorResponse](t, dependenciesRecorder)
|
||||
if dependenciesBody.Code != errorCodeForbidden || dependenciesBody.Message != "plugin does not declare required permission: server.dependencies.manage" {
|
||||
t.Fatalf("expected dependency permission denial, got %+v", dependenciesBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoreAPIErrorResponses(t *testing.T) {
|
||||
router := newTestRouter()
|
||||
adminSession := createAdminSession(t, router)
|
||||
@@ -817,7 +849,7 @@ func TestServerAccessAPIScopesOwnersAndAdministrators(t *testing.T) {
|
||||
assertErrorResponse(t, forbiddenDetail, http.StatusForbidden, errorCodeForbidden)
|
||||
}
|
||||
|
||||
func TestAIProviderAPIResponseDoesNotExposeRawKeyFields(t *testing.T) {
|
||||
func TestAIProviderAPIResponseDoesNotExposeRawSecretFields(t *testing.T) {
|
||||
router := newTestRouter()
|
||||
adminSession := createAdminSession(t, router)
|
||||
recorder := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/ai-providers", validAIProviderRequest(), adminSession)
|
||||
@@ -836,6 +868,9 @@ func TestAIProviderAPIResponseDoesNotExposeRawKeyFields(t *testing.T) {
|
||||
if _, exists := body["apiKeyRef"]; exists || body["apiKeyConfigured"] != true {
|
||||
t.Fatalf("expected API key presence only, got %+v", body)
|
||||
}
|
||||
if _, exists := body["baseUrl"]; exists || body["baseUrlConfigured"] != true {
|
||||
t.Fatalf("expected base URL presence only, got %+v", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAIProviderManagementAPI(t *testing.T) {
|
||||
@@ -844,13 +879,13 @@ func TestAIProviderManagementAPI(t *testing.T) {
|
||||
createAIProviderFixture(t, router, adminSession)
|
||||
|
||||
update := validAIProviderUpdateRequest()
|
||||
update.BaseURL = ""
|
||||
updatedRecorder := requestJSONWithAuth(t, router, http.MethodPut, "/api/v1/ai-providers/ai.openai", update, adminSession)
|
||||
assertStatus(t, updatedRecorder, http.StatusOK)
|
||||
updated := decodeBody[dto.AIProviderResponse](t, updatedRecorder)
|
||||
if updated.Name != "OpenAI Relay" || !updated.APIKeyConfigured || updated.Status != domain.AIProviderStatusActive {
|
||||
if updated.Name != "OpenAI Relay" || !updated.BaseURLConfigured || !updated.APIKeyConfigured || updated.Status != domain.AIProviderStatusActive {
|
||||
t.Fatalf("unexpected updated provider: %+v", updated)
|
||||
}
|
||||
|
||||
statusRecorder := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/ai-providers/ai.openai/status", dto.AIProviderStatusRequest{Status: domain.AIProviderStatusDisabled}, adminSession)
|
||||
assertStatus(t, statusRecorder, http.StatusOK)
|
||||
disabled := decodeBody[dto.AIProviderResponse](t, statusRecorder)
|
||||
@@ -861,7 +896,7 @@ func TestAIProviderManagementAPI(t *testing.T) {
|
||||
testRecorder := requestWithAuth(t, router, http.MethodPost, "/api/v1/ai-providers/ai.openai/test", "", adminSession)
|
||||
assertStatus(t, testRecorder, http.StatusOK)
|
||||
testResult := decodeBody[dto.AIProviderTestResponse](t, testRecorder)
|
||||
if testResult.Success || testResult.Mode != "metadata" {
|
||||
if testResult.Success || testResult.Mode != "provider" {
|
||||
t.Fatalf("expected metadata test failure for disabled provider, got %+v", testResult)
|
||||
}
|
||||
|
||||
@@ -1408,6 +1443,56 @@ func TestGamePluginRegistryResponseDoesNotExposeRawInternals(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProductionOperationsGovernanceRoutesAreDurableAndRedacted(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)
|
||||
}
|
||||
decision := postOKJSONWithAuth[dto.CapacityAdmissionDecisionResponse](t, router, "/api/v1/production/capacity/admission", dto.CapacityAdmissionRequest{ServerInstanceID: serverID, Capability: domain.JobCapabilityConfigWrite, IdempotencyKey: "api-capacity-check"}, adminSession)
|
||||
if decision.Accepted || decision.AlertID == "" || decision.AuditEventID == "" {
|
||||
t.Fatalf("expected stale endpoint 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)
|
||||
}
|
||||
|
||||
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)
|
||||
if invocation.ConfigRecommendation == nil || invocation.ConfigRecommendation.DiffID == "" {
|
||||
t.Fatalf("expected persisted AI config diff, got %+v", invocation)
|
||||
}
|
||||
diffs := getJSONWithAuth[dto.AIConfigDiffListResponse](t, router, "/api/v1/ai/config-diffs?serverInstanceId="+serverID, adminSession)
|
||||
if diffs.Count != 1 || diffs.Items[0].State != string(domain.AIConfigDiffStatePending) {
|
||||
t.Fatalf("expected one pending AI diff, got %+v", diffs)
|
||||
}
|
||||
approvalRecorder := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/ai/config-diffs/"+invocation.ConfigRecommendation.DiffID+"/approve", dto.AIConfigDiffApprovalRequest{IdempotencyKey: "api-ai-diff-approve"}, adminSession)
|
||||
assertStatus(t, approvalRecorder, http.StatusAccepted)
|
||||
approval := decodeBody[dto.AIConfigDiffApprovalResponse](t, approvalRecorder)
|
||||
if approval.Preview.State != string(domain.AIConfigDiffStateApproved) || approval.Dispatch.Job.Capability != domain.JobCapabilityConfigWrite {
|
||||
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)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func newTestRouter() http.Handler {
|
||||
core := service.NewCoreService(repo.NewMemoryStore())
|
||||
if err := core.SeedLocalPlatformAdmin(); err != nil {
|
||||
@@ -1613,6 +1698,7 @@ func createRuntimeAPIFixtures(t *testing.T, router http.Handler, adminSession st
|
||||
"process.start",
|
||||
"process.stop",
|
||||
"logs.read",
|
||||
domain.JobCapabilityConfigWrite,
|
||||
domain.JobCapabilityRunSelfUpdate,
|
||||
domain.JobCapabilityDependenciesCheck,
|
||||
domain.JobCapabilityDependenciesInstall,
|
||||
@@ -1771,8 +1857,9 @@ func validGamePluginManifestRegistrationRequest() dto.GamePluginManifestRegistra
|
||||
BridgeActions: []string{string(domain.PluginBridgeActionLogsQuery), string(domain.PluginBridgeActionFilesRequest), string(domain.PluginBridgeActionAIInvoke)},
|
||||
},
|
||||
},
|
||||
AI: dto.GamePluginManifestAIBody{Purposes: []string{"logs.diagnose"}},
|
||||
RuntimeProfiles: dto.GamePluginRuntimeProfilesBody{LifecycleProfiles: []dto.RuntimeLifecycleProfileBody{{Key: "local", Mode: "local-process", Capabilities: []string{"process.install", "process.start", "process.stop"}}}},
|
||||
AI: dto.GamePluginManifestAIBody{Purposes: []string{"logs.diagnose"}, Mediation: "platform", ConfigWritePolicy: "review-required"},
|
||||
ProductionLifecycle: dto.GamePluginProductionLifecycleBody{Operations: []string{"install", "enable", "disable", "upgrade", "rollback", "retire", "dependency-check"}, DependencyPolicy: "optional", ApprovalRequired: []string{"disable", "rollback", "retire"}},
|
||||
RuntimeProfiles: dto.GamePluginRuntimeProfilesBody{LifecycleProfiles: []dto.RuntimeLifecycleProfileBody{{Key: "local", Mode: "local-process", Capabilities: []string{"process.install", "process.start", "process.stop"}}}},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,6 +47,9 @@ func NewRouterFromConfig(cfg config.Config) (http.Handler, error) {
|
||||
if err := core.ConfigureSecretEnvelopeKey(cfg.SecretEnvelopeKey); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := core.ConfigureAIProviderMode(cfg.AIProviderMode); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(cfg.BootstrapAdminPassword) != "" {
|
||||
if err := core.SeedPlatformAdmin(cfg.BootstrapAdminEmail, cfg.BootstrapAdminPassword); err != nil {
|
||||
return nil, err
|
||||
|
||||
+20
-2
@@ -25,6 +25,8 @@ All routes use JSON request and response bodies. Collection routes support `GET`
|
||||
| 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.
|
||||
|
||||
## Implemented Query Filters
|
||||
|
||||
- `GET /api/v1/users?status=active`
|
||||
@@ -83,12 +85,13 @@ Config write and file dispatch responses expose only logical target keys, scoped
|
||||
## Implemented AI Provider Management Actions
|
||||
|
||||
- `POST /api/v1/ai-providers/{id}/status`: enable or disable one provider using `AIProviderStatusRequest`.
|
||||
- `POST /api/v1/ai-providers/{id}/test`: run local metadata validation using `AIProviderTestResponse`; this does not call external AI services.
|
||||
- `POST /api/v1/ai-providers/{id}/test`: invoke the configured provider client with a bounded health request and return only a redacted `AIProviderTestResponse`.
|
||||
- `GET /api/v1/ai-providers/{id}/models`: return configured model names using `AIProviderModelsResponse`.
|
||||
- `POST /api/v1/ai/invocations`: accept `AIInvocationRequest`, authorize explicit purposes, select an active provider, invoke a mockable provider client, and return `AIInvocationResponse` with bounded recommendation text, usage metadata, optional reviewable config recommendation, and safe errors.
|
||||
- `POST /api/v1/ai/config-suggestions`: compatibility route for console config assistance. It uses the mediated invocation service with `purpose=config.suggest` and returns `LlmConfigSuggestionResponse` for the existing review/approval workflow.
|
||||
|
||||
AI invocation is platform-mediated. Tests and local verification use a deterministic mock provider client; live external provider calls are deferred behind the same interface and are not required for this change. Invocation responses do not expose provider base URLs, API key refs, raw keys, bearer tokens, host paths, run sockets, or storage credentials. Config suggestions are recommendations only and never dispatch run-side writes directly.
|
||||
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.
|
||||
|
||||
@@ -108,6 +111,21 @@ Marketplace responses include game management plugin identity, version, display
|
||||
|
||||
Marketplace state actions are metadata-only in this change. `install` and `enable` mark the registered plugin `installed`; `disable` marks it `disabled`. These actions do not download external packages, create run jobs, execute plugin code, write files, or contact external services.
|
||||
|
||||
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
|
||||
|
||||
- `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.
|
||||
- `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.
|
||||
|
||||
## Implemented Plugin Bridge Actions
|
||||
|
||||
- `POST /api/v1/plugin-bridge/authorize`: accepts `PluginBridgeAuthorizeRequest` and returns whether an installed plugin page may use one declared bridge action with the effective route permissions.
|
||||
|
||||
Reference in New Issue
Block a user