Integrate SCUM real ops workflows
This commit is contained in:
@@ -20,19 +20,7 @@ const (
|
||||
logEventHeartbeatInterval = 15 * time.Second
|
||||
)
|
||||
|
||||
// serverLogEvents godoc
|
||||
// @Summary Stream server log events
|
||||
// @Description Streams safe server log entries over Server-Sent Events. Durable cursor query remains available for history and reconnect repair.
|
||||
// @Tags logs
|
||||
// @Produce text/event-stream
|
||||
// @Param id path string true "Server instance ID"
|
||||
// @Param historyLimit query int false "Total recent entries to replay across this server's streams"
|
||||
// @Success 200 {string} string "event-stream"
|
||||
// @Failure 401 {object} dto.ErrorResponse
|
||||
// @Failure 403 {object} dto.ErrorResponse
|
||||
// @Failure 404 {object} dto.ErrorResponse
|
||||
// @Failure 405 {object} dto.ErrorResponse
|
||||
// @Router /api/v1/server-instances/{id}/logs/events [get]
|
||||
// serverLogEvents is kept as legacy service plumbing but is not registered as a browser product route.
|
||||
func (h *coreHandlers) serverLogEvents(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeMethodNotAllowed(w, http.MethodGet)
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -44,24 +41,7 @@ func TestLogEventsSSEReplaysHistory(t *testing.T) {
|
||||
batch := validLogBatchRequest(t, hello.SessionToken, 1, 2)
|
||||
assertStatus(t, performJSON(t, router, http.MethodPost, "/api/v1/run/logs/batches", batch), http.StatusOK)
|
||||
|
||||
server := httptest.NewServer(router)
|
||||
defer server.Close()
|
||||
client := server.Client()
|
||||
client.Timeout = 2 * time.Second
|
||||
response, err := client.Get(server.URL + "/api/v1/server-instances/server-1/logs/events?historyLimit=2")
|
||||
if err != nil {
|
||||
t.Fatalf("open log event stream: %v", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != http.StatusOK || !strings.HasPrefix(response.Header.Get("Content-Type"), "text/event-stream") {
|
||||
t.Fatalf("unexpected event stream response: status=%d content-type=%q", response.StatusCode, response.Header.Get("Content-Type"))
|
||||
}
|
||||
body := readSSEUntil(t, response, "event: ready")
|
||||
for _, fragment := range []string{"event: stream", "event: log", `"streamId":"log-1"`, `"seq":1`, `"seq":2`} {
|
||||
if !strings.Contains(body, fragment) {
|
||||
t.Fatalf("expected SSE body to contain %q, got:\n%s", fragment, body)
|
||||
}
|
||||
}
|
||||
assertStatus(t, performJSON(t, router, http.MethodGet, "/api/v1/server-instances/server-1/logs/events?historyLimit=2", nil), http.StatusNotFound)
|
||||
}
|
||||
|
||||
func TestLogEventsSSEUsesServerWideNewestHistory(t *testing.T) {
|
||||
@@ -74,22 +54,7 @@ func TestLogEventsSSEUsesServerWideNewestHistory(t *testing.T) {
|
||||
assertStatus(t, performJSON(t, router, http.MethodPost, "/api/v1/run/logs/batches", validLogBatchRequestForStream(t, hello.SessionToken, "log-1", "stdout", 1, 2, 0)), http.StatusOK)
|
||||
assertStatus(t, performJSON(t, router, http.MethodPost, "/api/v1/run/logs/batches", validLogBatchRequestForStream(t, hello.SessionToken, "log-2", "stderr", 1, 2, 10)), http.StatusOK)
|
||||
|
||||
server := httptest.NewServer(router)
|
||||
defer server.Close()
|
||||
client := server.Client()
|
||||
client.Timeout = 2 * time.Second
|
||||
response, err := client.Get(server.URL + "/api/v1/server-instances/server-1/logs/events?historyLimit=2")
|
||||
if err != nil {
|
||||
t.Fatalf("open log event stream: %v", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
body := readSSEUntil(t, response, "event: ready")
|
||||
if strings.Contains(body, `"streamId":"log-1"`) {
|
||||
t.Fatalf("expected no history entries from older stream, got:\n%s", body)
|
||||
}
|
||||
if strings.Count(body, `"streamId":"log-2"`) != 2 || !strings.Contains(body, `"seq":2`) {
|
||||
t.Fatalf("expected newest two entries from stream log-2, got:\n%s", body)
|
||||
}
|
||||
assertStatus(t, performJSON(t, router, http.MethodGet, "/api/v1/server-instances/server-1/logs/events?historyLimit=2", nil), http.StatusNotFound)
|
||||
}
|
||||
|
||||
func TestLogIngestAPIDuplicateAndErrors(t *testing.T) {
|
||||
@@ -134,20 +99,6 @@ func createLogIngestAPIFixtures(t *testing.T, router http.Handler) dto.RunContro
|
||||
return hello
|
||||
}
|
||||
|
||||
func readSSEUntil(t *testing.T, response *http.Response, marker string) string {
|
||||
t.Helper()
|
||||
reader := bufio.NewReader(response.Body)
|
||||
var body strings.Builder
|
||||
for !strings.Contains(body.String(), marker) {
|
||||
line, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
t.Fatalf("read event stream: %v\n%s", err, body.String())
|
||||
}
|
||||
body.WriteString(line)
|
||||
}
|
||||
return body.String()
|
||||
}
|
||||
|
||||
func validLogBatchRequest(t *testing.T, sessionToken string, firstSeq uint64, lastSeq uint64) dto.LogBatchIngestRequest {
|
||||
return validLogBatchRequestForStream(t, sessionToken, "log-1", "stdout", firstSeq, lastSeq, 0)
|
||||
}
|
||||
|
||||
@@ -72,7 +72,6 @@ func (h *coreHandlers) register(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/deployment", h.serverDeployment)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/deploy", h.serverInstanceDeploy)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/remote-adapters", h.remoteAdapters)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/rcon/commands", h.sourceRCONCommands)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/run/generate", h.serverRunGenerate)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/run/download", h.serverRunDownload)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/run/key/reset", h.serverRunKeyReset)
|
||||
@@ -104,16 +103,19 @@ func (h *coreHandlers) register(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/game-gifts/{catalogId}/revisions", h.serverGameGiftCatalogRevisions)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/game-gift-grants", h.serverGameGiftGrants)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/game-gift-grants/{grantId}/approve", h.serverGameGiftGrantApprove)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/scum/players", h.serverSCUMPlayers)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/scum/squads", h.serverSCUMSquads)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/scum/squad-members", h.serverSCUMSquadMembers)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/scum/vehicles", h.serverSCUMVehicles)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/scum/flags", h.serverSCUMFlags)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/scum/positions", h.serverSCUMPositions)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/scum/operations", h.serverSCUMOperations)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/scum/operations/{operationId}/approve", h.serverSCUMOperationApprove)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/scum/workflows", h.serverSCUMWorkflows)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/scum/workflow-steps", h.serverSCUMWorkflowSteps)
|
||||
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)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/logs/live", h.serverLiveLogs)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/logs/events", h.serverLogEvents)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/logs/backfill", h.serverLogsBackfill)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/files/read-snapshot", h.serverDeclaredFileReadSnapshot)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/config/diff", h.serverInstanceConfigDiff)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/config/approve", h.serverInstanceConfigApprove)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/config", h.serverInstanceConfig)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/administrators/candidates", h.serverAdministratorCandidates)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/administrators", h.serverAdministrators)
|
||||
mux.HandleFunc("/api/v1/server-instances/{id}/administrators/{userId}", h.serverAdministratorDetail)
|
||||
@@ -1407,18 +1409,7 @@ func (h *coreHandlers) serverInstanceMetrics(w http.ResponseWriter, r *http.Requ
|
||||
writeJSON(w, http.StatusOK, dto.ServerMetricsListFromDomain(metrics))
|
||||
}
|
||||
|
||||
// serverInstanceConfig godoc
|
||||
// @Summary Read server configuration
|
||||
// @Description Returns logical server configuration content for an authorized server instance without exposing run internals.
|
||||
// @Tags server-instances
|
||||
// @Produce json
|
||||
// @Param id path string true "Server instance ID"
|
||||
// @Success 200 {object} dto.ServerConfigResponse
|
||||
// @Failure 401 {object} dto.ErrorResponse
|
||||
// @Failure 403 {object} dto.ErrorResponse
|
||||
// @Failure 404 {object} dto.ErrorResponse
|
||||
// @Failure 405 {object} dto.ErrorResponse
|
||||
// @Router /api/v1/server-instances/{id}/config [get]
|
||||
// serverInstanceConfig is kept as legacy service plumbing but is not registered as a browser product route.
|
||||
func (h *coreHandlers) serverInstanceConfig(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeMethodNotAllowed(w, http.MethodGet)
|
||||
@@ -1432,20 +1423,7 @@ func (h *coreHandlers) serverInstanceConfig(w http.ResponseWriter, r *http.Reque
|
||||
writeJSON(w, http.StatusOK, dto.ServerConfigFromDomain(config))
|
||||
}
|
||||
|
||||
// serverDeclaredFileReadSnapshot godoc
|
||||
// @Summary Read the latest declared file snapshot
|
||||
// @Description Returns a redacted bounded result only for an authorized plugin-declared logical file key.
|
||||
// @Tags server-instances
|
||||
// @Produce json
|
||||
// @Param id path string true "Server instance ID"
|
||||
// @Param key query string true "Plugin-declared logical file key"
|
||||
// @Success 200 {object} dto.DeclaredFileReadSnapshotResponse
|
||||
// @Failure 400 {object} dto.ErrorResponse
|
||||
// @Failure 401 {object} dto.ErrorResponse
|
||||
// @Failure 403 {object} dto.ErrorResponse
|
||||
// @Failure 404 {object} dto.ErrorResponse
|
||||
// @Failure 405 {object} dto.ErrorResponse
|
||||
// @Router /api/v1/server-instances/{id}/files/read-snapshot [get]
|
||||
// serverDeclaredFileReadSnapshot is kept as legacy service plumbing but is not registered as a browser product route.
|
||||
func (h *coreHandlers) serverDeclaredFileReadSnapshot(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeMethodNotAllowed(w, http.MethodGet)
|
||||
@@ -1459,21 +1437,7 @@ func (h *coreHandlers) serverDeclaredFileReadSnapshot(w http.ResponseWriter, r *
|
||||
writeJSON(w, http.StatusOK, dto.DeclaredFileReadSnapshotFromDomain(snapshot))
|
||||
}
|
||||
|
||||
// serverInstanceConfigDiff godoc
|
||||
// @Summary Preview server config diff
|
||||
// @Description Compares current logical server config with proposed content without dispatching a write job.
|
||||
// @Tags server-instances
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "Server instance ID"
|
||||
// @Param body body dto.ServerConfigDiffPreviewRequest true "Config diff preview request"
|
||||
// @Success 200 {object} dto.ServerConfigDiffPreviewResponse
|
||||
// @Failure 400 {object} dto.ErrorResponse
|
||||
// @Failure 401 {object} dto.ErrorResponse
|
||||
// @Failure 403 {object} dto.ErrorResponse
|
||||
// @Failure 404 {object} dto.ErrorResponse
|
||||
// @Failure 405 {object} dto.ErrorResponse
|
||||
// @Router /api/v1/server-instances/{id}/config/diff [post]
|
||||
// serverInstanceConfigDiff is kept as legacy service plumbing but is not registered as a browser product route.
|
||||
func (h *coreHandlers) serverInstanceConfigDiff(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeMethodNotAllowed(w, http.MethodPost)
|
||||
@@ -1492,21 +1456,7 @@ func (h *coreHandlers) serverInstanceConfigDiff(w http.ResponseWriter, r *http.R
|
||||
writeJSON(w, http.StatusOK, dto.ServerConfigDiffPreviewFromDomain(preview))
|
||||
}
|
||||
|
||||
// serverInstanceConfigApprove godoc
|
||||
// @Summary Approve server config write
|
||||
// @Description Validates a reviewed config diff and queues a scoped config.write run job without exposing host paths or raw credentials.
|
||||
// @Tags server-instances
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "Server instance ID"
|
||||
// @Param body body dto.ServerConfigWriteApprovalRequest true "Config write approval request"
|
||||
// @Success 202 {object} dto.ServerConfigWriteDispatchResponse
|
||||
// @Failure 400 {object} dto.ErrorResponse
|
||||
// @Failure 401 {object} dto.ErrorResponse
|
||||
// @Failure 403 {object} dto.ErrorResponse
|
||||
// @Failure 404 {object} dto.ErrorResponse
|
||||
// @Failure 405 {object} dto.ErrorResponse
|
||||
// @Router /api/v1/server-instances/{id}/config/approve [post]
|
||||
// serverInstanceConfigApprove is kept as legacy service plumbing but is not registered as a browser product route.
|
||||
func (h *coreHandlers) serverInstanceConfigApprove(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeMethodNotAllowed(w, http.MethodPost)
|
||||
|
||||
@@ -189,18 +189,9 @@ func TestMetricsAndConfigReadAPIAreSafeAndRoleScoped(t *testing.T) {
|
||||
t.Fatalf("expected no metrics for other user, got %+v", otherMetrics)
|
||||
}
|
||||
|
||||
configRecorder := requestWithAuth(t, router, http.MethodGet, "/api/v1/server-instances/server-metrics-api/config", "", ownerSession)
|
||||
assertStatus(t, configRecorder, http.StatusOK)
|
||||
config := decodeBody[dto.ServerConfigResponse](t, configRecorder)
|
||||
if config.ServerInstanceID != instance.ID || config.ConfigVersion != instance.ConfigVersion || !strings.Contains(config.Content, "server.name=Metrics API Server") {
|
||||
t.Fatalf("unexpected config response: %+v", config)
|
||||
}
|
||||
for _, forbidden := range []string{"/Users/", "unix://", "Bearer ", "sk-", "password="} {
|
||||
if strings.Contains(configRecorder.Body.String(), forbidden) {
|
||||
t.Fatalf("config response exposed forbidden fragment %q: %s", forbidden, configRecorder.Body.String())
|
||||
}
|
||||
}
|
||||
assertErrorResponse(t, requestWithAuth(t, router, http.MethodGet, "/api/v1/server-instances/server-metrics-api/config", "", otherSession), http.StatusForbidden, errorCodeForbidden)
|
||||
_ = instance
|
||||
assertStatus(t, requestWithAuth(t, router, http.MethodGet, "/api/v1/server-instances/server-metrics-api/config", "", ownerSession), http.StatusNotFound)
|
||||
assertStatus(t, requestWithAuth(t, router, http.MethodGet, "/api/v1/server-instances/server-metrics-api/config", "", otherSession), http.StatusNotFound)
|
||||
}
|
||||
|
||||
func TestConfigWriteAndFileDispatchAPIAreScopedAndSafe(t *testing.T) {
|
||||
@@ -235,52 +226,9 @@ func TestConfigWriteAndFileDispatchAPIAreScopedAndSafe(t *testing.T) {
|
||||
State: domain.ServerInstanceStateRunning,
|
||||
}, ownerSession)
|
||||
putJSONWithAuth[dto.RuntimeBindingResponse](t, router, "/api/v1/server-instances/"+instance.ID+"/runtime-binding", dto.RuntimeBindingUpdateRequest{ProfileKey: "local", Bindings: map[string]string{}}, ownerSession)
|
||||
config := getJSONWithAuth[dto.ServerConfigResponse](t, router, "/api/v1/server-instances/server-config-api/config", ownerSession)
|
||||
proposed := strings.Replace(config.Content, "state=running", "state=running\nmotd=Approved", 1)
|
||||
|
||||
previewRecorder := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/server-config-api/config/diff", dto.ServerConfigDiffPreviewRequest{
|
||||
ExpectedConfigVersion: config.ConfigVersion,
|
||||
Key: config.Key,
|
||||
ProposedContent: proposed,
|
||||
}, ownerSession)
|
||||
assertStatus(t, previewRecorder, http.StatusOK)
|
||||
preview := decodeBody[dto.ServerConfigDiffPreviewResponse](t, previewRecorder)
|
||||
if !preview.HasChanges || preview.Source != "platform-review" || preview.ServerInstanceID != instance.ID {
|
||||
t.Fatalf("unexpected preview: %+v", preview)
|
||||
}
|
||||
jobsAfterPreview := getJSONWithAuth[dto.JobListResponse](t, router, "/api/v1/jobs?serverInstanceId=server-config-api", ownerSession)
|
||||
if jobsAfterPreview.Count != 0 {
|
||||
t.Fatalf("preview must not create jobs: %+v", jobsAfterPreview)
|
||||
}
|
||||
|
||||
approveRecorder := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/server-config-api/config/approve", dto.ServerConfigWriteApprovalRequest{
|
||||
ExpectedConfigVersion: config.ConfigVersion,
|
||||
Key: config.Key,
|
||||
ProposedContent: proposed,
|
||||
IdempotencyKey: "idem-config-api",
|
||||
}, ownerSession)
|
||||
assertStatus(t, approveRecorder, http.StatusAccepted)
|
||||
dispatch := decodeBody[dto.ServerConfigWriteDispatchResponse](t, approveRecorder)
|
||||
if dispatch.Status != "queued" || dispatch.Job.Capability != domain.JobCapabilityConfigWrite || dispatch.Job.TargetKey != config.Key || dispatch.Job.InputRef == "" {
|
||||
t.Fatalf("unexpected approval dispatch: %+v", dispatch)
|
||||
}
|
||||
for _, forbidden := range []string{"/Users/", "unix://", "Bearer ", "sk-", "password="} {
|
||||
if strings.Contains(approveRecorder.Body.String(), forbidden) {
|
||||
t.Fatalf("approval response exposed forbidden fragment %q: %s", forbidden, approveRecorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
assertErrorResponse(t, requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/server-config-api/config/diff", dto.ServerConfigDiffPreviewRequest{
|
||||
ExpectedConfigVersion: config.ConfigVersion + 1,
|
||||
Key: config.Key,
|
||||
ProposedContent: proposed,
|
||||
}, ownerSession), http.StatusBadRequest, errorCodeValidation)
|
||||
assertErrorResponse(t, requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/server-config-api/config/approve", dto.ServerConfigWriteApprovalRequest{
|
||||
ExpectedConfigVersion: config.ConfigVersion,
|
||||
Key: config.Key,
|
||||
ProposedContent: proposed,
|
||||
IdempotencyKey: "idem-forbidden-api",
|
||||
}, otherSession), http.StatusForbidden, errorCodeForbidden)
|
||||
assertStatus(t, requestWithAuth(t, router, http.MethodGet, "/api/v1/server-instances/server-config-api/config", "", ownerSession), http.StatusNotFound)
|
||||
assertStatus(t, requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/server-config-api/config/diff", dto.ServerConfigDiffPreviewRequest{ExpectedConfigVersion: instance.ConfigVersion, Key: "server.properties", ProposedContent: "state=running\n"}, ownerSession), http.StatusNotFound)
|
||||
assertStatus(t, requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/server-config-api/config/approve", dto.ServerConfigWriteApprovalRequest{ExpectedConfigVersion: instance.ConfigVersion, Key: "server.properties", ProposedContent: "state=running\n", IdempotencyKey: "idem-config-api"}, otherSession), http.StatusNotFound)
|
||||
assertErrorResponse(t, requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/file-operations/dispatch", dto.FileOperationDispatchRequest{
|
||||
ServerInstanceID: "server-config-api",
|
||||
Operation: domain.FileOperationRead,
|
||||
@@ -304,42 +252,7 @@ func TestConfigWriteAndFileDispatchAPIAreScopedAndSafe(t *testing.T) {
|
||||
func TestCoreAPIDeclaredFileReadSnapshotRouteIsScopedAndRedacted(t *testing.T) {
|
||||
router := newTestRouter()
|
||||
adminSession := createAdminSession(t, router)
|
||||
postJSONWithAuth[dto.UserResponse](t, router, "/api/v1/users", dto.UserCreateRequest{
|
||||
ID: "user-owner-file-snapshot-api",
|
||||
DisplayName: "File Snapshot API Owner",
|
||||
Email: "owner-file-snapshot-api@example.test",
|
||||
Roles: []string{"server-owner"},
|
||||
Password: "secret-password",
|
||||
}, adminSession)
|
||||
postJSONWithAuth[dto.UserResponse](t, router, "/api/v1/users", dto.UserCreateRequest{
|
||||
ID: "user-other-file-snapshot-api",
|
||||
DisplayName: "File Snapshot API Other",
|
||||
Email: "other-file-snapshot-api@example.test",
|
||||
Roles: []string{"server-admin"},
|
||||
Password: "secret-password",
|
||||
}, adminSession)
|
||||
ownerSession := postOKJSON[dto.AuthSessionResponse](t, router, "/api/v1/auth/login", dto.LoginRequest{Account: "owner-file-snapshot-api@example.test", Password: "secret-password"}).SessionID
|
||||
otherSession := postOKJSON[dto.AuthSessionResponse](t, router, "/api/v1/auth/login", dto.LoginRequest{Account: "other-file-snapshot-api@example.test", Password: "secret-password"}).SessionID
|
||||
|
||||
pluginRequest := validGamePluginRequest()
|
||||
pluginRequest.RequiredRunCapabilities = append(pluginRequest.RequiredRunCapabilities, domain.JobCapabilityFilesRead)
|
||||
pluginRequest.DeclaredPermissions = []string{"server.files.read", "server.files.write"}
|
||||
pluginRequest.Permissions.Files = true
|
||||
pluginRequest.FileWorkspace = dto.PluginFileWorkspaceBody{
|
||||
DefaultDirectoryKey: "scum-config",
|
||||
Directories: []dto.PluginLogicalDirectoryBody{
|
||||
{Key: "scum-config", Label: "服务器配置", Scope: "config"},
|
||||
{Key: "scum-logs", Label: "日志文件", Scope: "logs"},
|
||||
},
|
||||
Files: []dto.PluginLogicalFileBody{
|
||||
{Key: "scum-server-settings", DirectoryKey: "scum-config", Label: "ServerSettings.ini", Kind: "config", Editable: true},
|
||||
{Key: "scum-chat-log", DirectoryKey: "scum-logs", Label: "Chat.log", Kind: "log", StreamKey: "scum.chat"},
|
||||
},
|
||||
ConfigFields: []dto.PluginConfigFieldBody{
|
||||
{Key: "max-players", FileKey: "scum-server-settings", ConfigKey: "MaxPlayers", Label: "最大玩家数", Description: "玩家上限", Control: "number", Minimum: 1, Maximum: 128, DefaultValue: "128", RestartImpact: "restart-required"},
|
||||
},
|
||||
}
|
||||
postJSON[dto.GamePluginResponse](t, router, "/api/v1/game-plugins", pluginRequest)
|
||||
postJSON[dto.GamePluginResponse](t, router, "/api/v1/game-plugins", validGamePluginRequest())
|
||||
postJSON[dto.RunEndpointResponse](t, router, "/api/v1/run/endpoints", validRunEndpointRequest())
|
||||
instance := postJSONWithAuth[dto.ServerInstanceResponse](t, router, "/api/v1/server-instances", dto.ServerInstanceCreateRequest{
|
||||
ID: "server-file-snapshot-api",
|
||||
@@ -347,57 +260,9 @@ func TestCoreAPIDeclaredFileReadSnapshotRouteIsScopedAndRedacted(t *testing.T) {
|
||||
RunEndpointID: "run-local",
|
||||
Name: "File Snapshot API Server",
|
||||
State: domain.ServerInstanceStateRunning,
|
||||
}, ownerSession)
|
||||
}, adminSession)
|
||||
|
||||
snapshot := getJSONWithAuth[dto.DeclaredFileReadSnapshotResponse](t, router, "/api/v1/server-instances/"+instance.ID+"/files/read-snapshot?key=scum-server-settings", ownerSession)
|
||||
if snapshot.State != "not-read" || snapshot.Content != "" {
|
||||
t.Fatalf("expected not-read snapshot, got %+v", snapshot)
|
||||
}
|
||||
assertErrorResponse(t, requestWithAuth(t, router, http.MethodGet, "/api/v1/server-instances/"+instance.ID+"/files/read-snapshot?key=logs/latest.log", "", ownerSession), http.StatusBadRequest, errorCodeValidation)
|
||||
assertErrorResponse(t, requestWithAuth(t, router, http.MethodGet, "/api/v1/server-instances/"+instance.ID+"/files/read-snapshot?key=scum-server-settings", "", otherSession), http.StatusForbidden, errorCodeForbidden)
|
||||
|
||||
postJSON[dto.JobResponse](t, router, "/api/v1/jobs", dto.JobCreateRequest{
|
||||
ID: "job-file-snapshot-api-read",
|
||||
ServerInstanceID: instance.ID,
|
||||
RunEndpointID: "run-local",
|
||||
Capability: domain.JobCapabilityFilesRead,
|
||||
TargetKey: "scum-server-settings",
|
||||
IdempotencyKey: "idem-file-snapshot-api-read",
|
||||
})
|
||||
helloRequest := validRunControlHelloRequest()
|
||||
helloRequest.CapabilityReport.Capabilities = append(helloRequest.CapabilityReport.Capabilities, domain.JobCapabilityFilesRead)
|
||||
hello := decodeBody[dto.RunControlHelloResponse](t, performRunControlHello(t, router, helloRequest))
|
||||
claim := decodeBody[dto.RunJobClaimResponse](t, performJSON(t, router, http.MethodPost, "/api/v1/run/jobs/claim", dto.RunJobClaimRequest{
|
||||
RunEndpointID: "run-local",
|
||||
SessionToken: hello.SessionToken,
|
||||
Capabilities: []string{domain.JobCapabilityFilesRead},
|
||||
Capacity: dto.RunCapacityResponse{MaxJobs: 4},
|
||||
}))
|
||||
if !claim.HasJob || claim.Job.JobID != "job-file-snapshot-api-read" {
|
||||
t.Fatalf("expected file read job claim, got %+v", claim)
|
||||
}
|
||||
content := "ServerName=API\nRconPassword=secret\n"
|
||||
resultRecorder := performJSON(t, router, http.MethodPost, "/api/v1/run/jobs/result", dto.RunJobResultRequest{
|
||||
RunEndpointID: "run-local",
|
||||
SessionToken: hello.SessionToken,
|
||||
JobID: claim.Job.JobID,
|
||||
LeaseToken: claim.Job.LeaseToken,
|
||||
Attempt: claim.Job.Attempt,
|
||||
State: domain.JobStateSucceeded,
|
||||
Progress: dto.JobProgressBody{Percent: 100, Message: "file read completed"},
|
||||
Message: "file read completed",
|
||||
ExecutionResult: dto.RunJobExecutionResultBody{
|
||||
Kind: "file.read",
|
||||
Version: 9,
|
||||
SizeBytes: int64(len(content)),
|
||||
Content: content,
|
||||
},
|
||||
})
|
||||
assertStatus(t, resultRecorder, http.StatusOK)
|
||||
ready := getJSONWithAuth[dto.DeclaredFileReadSnapshotResponse](t, router, "/api/v1/server-instances/"+instance.ID+"/files/read-snapshot?key=scum-server-settings", ownerSession)
|
||||
if ready.State != "ready" || ready.Version != 9 || !strings.Contains(ready.Content, "ServerName=API") || !strings.Contains(ready.Content, "RconPassword=<redacted>") || strings.Contains(ready.Content, "secret") {
|
||||
t.Fatalf("expected ready redacted snapshot, got %+v", ready)
|
||||
}
|
||||
assertStatus(t, requestWithAuth(t, router, http.MethodGet, "/api/v1/server-instances/"+instance.ID+"/files/read-snapshot?key=scum-server-settings", "", adminSession), http.StatusNotFound)
|
||||
}
|
||||
|
||||
func TestCoreAPIServerRuntimeDistributionAndJobWorkflows(t *testing.T) {
|
||||
@@ -412,7 +277,7 @@ 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", "historical-logs"} {
|
||||
for _, key := range []string{"generate-run", "push-run-update", "generate-client-manager", "dependencies-check", "dependencies-install"} {
|
||||
if !availability[key] {
|
||||
t.Fatalf("expected action %q available in %+v", key, actions.Actions)
|
||||
}
|
||||
@@ -464,22 +329,8 @@ func TestCoreAPIServerRuntimeDistributionAndJobWorkflows(t *testing.T) {
|
||||
unsafeDependency := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/"+serverID+"/dependencies/install", dto.DependencyJobRequest{ProbeKey: "java-runtime", InstallPlanKey: "bash -c whoami", IdempotencyKey: "api-dependency-unsafe"}, adminSession)
|
||||
assertErrorResponse(t, unsafeDependency, http.StatusBadRequest, errorCodeValidation)
|
||||
|
||||
backfillRecorder := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/"+serverID+"/logs/backfill", dto.LogBackfillRequest{SourceKey: "latest", CheckpointRef: "input://logs/" + serverID + "/latest/v1", Limit: 500, IdempotencyKey: "api-logs-backfill"}, adminSession)
|
||||
assertStatus(t, backfillRecorder, http.StatusAccepted)
|
||||
backfill := decodeBody[dto.JobResponse](t, backfillRecorder)
|
||||
if backfill.Capability != domain.JobCapabilityLogsBackfill || backfill.ResultRef != "" || backfill.InputRef == "" {
|
||||
t.Fatalf("unexpected log backfill job: %+v", backfill)
|
||||
}
|
||||
liveLogs := getJSONWithAuth[dto.LogStreamListResponse](t, router, "/api/v1/server-instances/"+serverID+"/logs/live", adminSession)
|
||||
foundFileLog := false
|
||||
for _, stream := range liveLogs.Items {
|
||||
if stream.Source == domain.LogStreamSourceFile && stream.StreamKey == "latest-log" {
|
||||
foundFileLog = true
|
||||
}
|
||||
}
|
||||
if !foundFileLog {
|
||||
t.Fatalf("unexpected live logs: %+v", liveLogs)
|
||||
}
|
||||
assertStatus(t, requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/"+serverID+"/logs/backfill", dto.LogBackfillRequest{SourceKey: "latest", CheckpointRef: "input://logs/" + serverID + "/latest/v1", Limit: 500, IdempotencyKey: "api-logs-backfill"}, adminSession), http.StatusNotFound)
|
||||
assertStatus(t, requestWithAuth(t, router, http.MethodGet, "/api/v1/server-instances/"+serverID+"/logs/live", "", adminSession), http.StatusNotFound)
|
||||
|
||||
runReset := postOKJSONWithAuth[dto.ComponentKeyResponse](t, router, "/api/v1/server-instances/"+serverID+"/run/key/reset", map[string]string{}, adminSession)
|
||||
if runReset.Generation != 2 || runReset.SecretRef == "" {
|
||||
@@ -490,7 +341,7 @@ func TestCoreAPIServerRuntimeDistributionAndJobWorkflows(t *testing.T) {
|
||||
t.Fatalf("unexpected client key reset: %+v", clientReset)
|
||||
}
|
||||
|
||||
for _, body := range []string{mustJSON(t, runDistribution), mustJSON(t, clientDistribution), mustJSON(t, runReset), mustJSON(t, clientReset), mustJSON(t, dependencyInstall), mustJSON(t, backfill)} {
|
||||
for _, body := range []string{mustJSON(t, runDistribution), mustJSON(t, clientDistribution), mustJSON(t, runReset), mustJSON(t, clientReset), 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)
|
||||
@@ -503,7 +354,7 @@ func TestCoreAPIServerRuntimeDistributionAndJobWorkflows(t *testing.T) {
|
||||
for _, audit := range audits.Items {
|
||||
auditActions[audit.Action] = true
|
||||
}
|
||||
for _, action := range []string{"run.generate", "client-manager.build", "dependency.install", "logs.backfill", "runtime-key.reset"} {
|
||||
for _, action := range []string{"run.generate", "client-manager.build", "dependency.install", "runtime-key.reset"} {
|
||||
if !auditActions[action] {
|
||||
t.Fatalf("expected audit action %q in %+v", action, audits.Items)
|
||||
}
|
||||
|
||||
+10
-12
@@ -14,10 +14,10 @@ All routes use JSON request and response bodies. Collection routes support `GET`
|
||||
| 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`, `GET /api/v1/server-instances/{id}/logs/live`, `GET /api/v1/server-instances/{id}/logs/events`, `POST /api/v1/server-instances/{id}/logs/backfill` | `ServerRuntimeActionsResponse`, `RunDistributionGenerateRequest`, `RunDistributionResponse`, `RunUpdateRequest`, `RunUpdateJobResponse`/`RunUpdateJobListResponse`, `ClientManagerBuildRequest`, `ClientManagerDistributionResponse`, `ClientManagerDownloadRequest`, `ComponentKeyResetRequest`, `ComponentKeyResponse`, `DependencyCatalogResponse`, `DependencyJobRequest`, `LogBackfillRequest`, `LogStreamEventResponse` |
|
||||
| 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` |
|
||||
| Metrics | `GET /api/v1/metrics/platform`, `GET /api/v1/metrics/server-instances` | n/a | `PlatformResourceUsageResponse`, `ServerMetricsResponse`, `ServerMetricsListResponse` |
|
||||
| Server config | n/a | `GET /api/v1/server-instances/{id}/config`, `POST /api/v1/server-instances/{id}/config/diff`, `POST /api/v1/server-instances/{id}/config/approve` | `ServerConfigResponse`, `ServerConfigDiffPreviewRequest`, `ServerConfigDiffPreviewResponse`, `ServerConfigWriteApprovalRequest`, `ServerConfigWriteDispatchResponse` |
|
||||
| File operations | `POST /api/v1/file-operations/dispatch` | n/a | `FileOperationDispatchRequest`, `FileOperationDispatchResponse` |
|
||||
| SCUM projections and workflows | n/a | `GET /api/v1/server-instances/{id}/scum/players`, `GET .../scum/squads`, `GET .../scum/squad-members`, `GET .../scum/vehicles`, `GET .../scum/flags`, `GET .../scum/positions`, `GET/POST .../scum/operations`, `POST .../scum/operations/{operationId}/approve`, `GET/POST .../scum/workflows`, `GET .../scum/workflow-steps` | `SCUM*Response`, `SCUMOperationRequestBody`, `SCUMWorkflowCreateRequest`, safe operation/workflow summaries |
|
||||
| Server administrators | `GET /api/v1/server-instances/{id}/administrators/candidates`, `POST /api/v1/server-instances/{id}/administrators` | `DELETE /api/v1/server-instances/{id}/administrators/{userId}` | `ServerMemberRequest`, `ServerMemberResponse`, `ServerMemberListResponse`, `ServerInstanceResponse` |
|
||||
| Run endpoints | `GET /api/v1/run/endpoints`, `POST /api/v1/run/endpoints` | `GET /api/v1/run/endpoints/{id}` | `RunEndpointCreateRequest`, `RunEndpointResponse`, `RunEndpointListResponse` |
|
||||
| Jobs | `GET /api/v1/jobs`, `POST /api/v1/jobs` | `GET /api/v1/jobs/{id}` | `JobCreateRequest`, `JobResponse`, `JobListResponse` |
|
||||
@@ -70,17 +70,15 @@ Server owner membership actions hide and reject platform administrators. Server
|
||||
|
||||
- `GET /api/v1/metrics/platform`: returns bounded platform CPU, memory, disk, source, and timestamp metadata for platform administrators.
|
||||
- `GET /api/v1/metrics/server-instances`: returns bounded per-server metrics only for server instances visible to the authenticated user.
|
||||
- `GET /api/v1/server-instances/{id}/config`: returns logical server config content, format, key, config version, and update timestamp for an authorized server instance.
|
||||
Server-scoped raw config routes are intentionally not registered as product APIs. AI config assistance returns reviewable typed diff metadata through AI config-diff approval flows rather than raw config text.
|
||||
|
||||
Observability and config read responses are read-only. They do not expose host filesystem paths, raw credentials, direct run sockets, storage backend credentials, raw AI provider keys, or run session tokens.
|
||||
Observability responses are read-only. They do not expose host filesystem paths, raw credentials, direct run sockets, storage backend credentials, raw AI provider keys, or run session tokens.
|
||||
|
||||
## Implemented Config Write And File Dispatch Actions
|
||||
## Implemented File Dispatch Actions
|
||||
|
||||
- `POST /api/v1/server-instances/{id}/config/diff`: accepts `ServerConfigDiffPreviewRequest`, validates server access, expected config version, logical config key, bounded proposed content, and returns a platform-computed `ServerConfigDiffPreviewResponse` without creating a run job.
|
||||
- `POST /api/v1/server-instances/{id}/config/approve`: accepts `ServerConfigWriteApprovalRequest`, revalidates the reviewed diff, rejects stale/no-change/unsafe writes, and queues a scoped `config.write` job using `ServerConfigWriteDispatchResponse`.
|
||||
- `POST /api/v1/file-operations/dispatch`: accepts `FileOperationDispatchRequest`, validates server visibility plus optional plugin permissions, rejects unsafe targets, and queues `files.read` or `files.write` jobs using logical keys and refs.
|
||||
|
||||
Config write and file dispatch responses expose only logical target keys, scoped input/artifact refs, and bounded job metadata. They do not expose host filesystem paths, raw credentials, direct sockets, run session tokens, raw AI provider keys, or inline large file contents.
|
||||
File dispatch responses expose only logical target keys, scoped input/artifact refs, and bounded job metadata. They do not expose host filesystem paths, raw credentials, direct sockets, run session tokens, raw AI provider keys, or inline large file contents.
|
||||
|
||||
## Implemented AI Provider Management Actions
|
||||
|
||||
@@ -159,12 +157,12 @@ Lifecycle workflow responses include accepted status, action, bounded server ins
|
||||
- `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.
|
||||
- `GET /api/v1/server-instances/{id}/logs/live`: returns safe live log stream metadata for the selected server using `LogStreamListResponse`.
|
||||
- `GET /api/v1/server-instances/{id}/logs/events`: streams selected server log metadata and entries as `text/event-stream`; the optional `historyLimit` query replays recent stored entries before live push events.
|
||||
- `POST /api/v1/server-instances/{id}/logs/backfill`: accepts `LogBackfillRequest`, queues a `logs.backfill` job with source key, checkpoint ref, limit, and idempotency metadata, and keeps log bodies out of job results.
|
||||
Server-scoped raw log routes (`logs/live`, `logs/events`, and `logs/backfill`) are intentionally not registered as product APIs. Internal log ingest and cursor query remain available for run/platform maintenance flows.
|
||||
|
||||
Runtime distribution and client-manager APIs require the current bearer session, server visibility, plugin-declared permissions, complete runtime bindings only for actions that truly depend on external logical bindings, and platform-builder readiness. Run-side lifecycle commands separately require run endpoint capability support and use plugin-declared lifecycle actions without making manual runtime-profile binding a user prerequisite. Responses and audit summaries expose artifact IDs, job IDs, checksums, key generations, fingerprints, status, and redacted `secret://runtime-keys/.../current` refs only. They do not expose raw run keys, client-manager keys, FTP passwords, database DSNs, RCON passwords, host paths, direct sockets, run endpoint private addresses, build workspace paths, or large inline logs.
|
||||
|
||||
SCUM product APIs expose only safe local projections, typed operation/workflow requests, approval status, confirmation status, blocker reasons, and audit-safe summaries. They never expose SCUM.db SQL text, DB paths, DSNs, RCON command text, raw protected request payloads, run sockets, host paths, or credentials.
|
||||
|
||||
`POST /api/v1/server-instances/workflows/create` requires only the plugin type and server name. A runtime binding may still be maintained internally for advanced logical transports, but browser lifecycle controls must not force operators to choose a runtime profile before start/stop or run-package generation when the plugin deployment/lifecycle declaration is sufficient. Platform builds distributions itself and never needs a registered Run endpoint with `distribution.build` to do so.
|
||||
|
||||
## Private Run Dependency And Update Routes
|
||||
@@ -197,7 +195,7 @@ Job ack/progress/result/cancel/reconcile calls remain lightweight and independen
|
||||
|
||||
- `POST /api/v1/run/logs/batches`: accept `LogBatchIngestRequest`, validate run session and stream metadata, store contiguous entries, 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.
|
||||
- `GET /api/v1/server-instances/{id}/logs/events`: authorize the browser session for the server, replay bounded recent entries, and push newly ingested log entries over SSE without polling log stream queries.
|
||||
Server-scoped SSE log streaming is removed from product routes. `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.
|
||||
Log ingest is durable and independently retried. Artifact/file transfer backlog must not prevent log acknowledgement, duplicate acknowledgement, cursor state updates, or spool cleanup.
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
"browser.local/platform/dto"
|
||||
)
|
||||
|
||||
func (h *coreHandlers) serverSCUMPlayers(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeMethodNotAllowed(w, http.MethodGet)
|
||||
return
|
||||
}
|
||||
items, err := h.core.ListSCUMPlayerLiveStatesForSession(bearerToken(r), scumProjectionFilterFromRequest(r, r.PathValue("id")))
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, dto.SCUMPlayerLiveStatesFromDomain(items))
|
||||
}
|
||||
|
||||
func (h *coreHandlers) serverSCUMSquads(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeMethodNotAllowed(w, http.MethodGet)
|
||||
return
|
||||
}
|
||||
items, err := h.core.ListSCUMSquadsForSession(bearerToken(r), scumProjectionFilterFromRequest(r, r.PathValue("id")))
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, dto.SCUMSquadsFromDomain(items))
|
||||
}
|
||||
|
||||
func (h *coreHandlers) serverSCUMSquadMembers(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeMethodNotAllowed(w, http.MethodGet)
|
||||
return
|
||||
}
|
||||
items, err := h.core.ListSCUMSquadMembersForSession(bearerToken(r), scumProjectionFilterFromRequest(r, r.PathValue("id")))
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, dto.SCUMSquadMembersFromDomain(items))
|
||||
}
|
||||
|
||||
func (h *coreHandlers) serverSCUMVehicles(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeMethodNotAllowed(w, http.MethodGet)
|
||||
return
|
||||
}
|
||||
items, err := h.core.ListSCUMVehiclesForSession(bearerToken(r), scumProjectionFilterFromRequest(r, r.PathValue("id")))
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, dto.SCUMVehiclesFromDomain(items))
|
||||
}
|
||||
|
||||
func (h *coreHandlers) serverSCUMFlags(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeMethodNotAllowed(w, http.MethodGet)
|
||||
return
|
||||
}
|
||||
items, err := h.core.ListSCUMFlagsForSession(bearerToken(r), scumProjectionFilterFromRequest(r, r.PathValue("id")))
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, dto.SCUMFlagsFromDomain(items))
|
||||
}
|
||||
|
||||
func (h *coreHandlers) serverSCUMPositions(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeMethodNotAllowed(w, http.MethodGet)
|
||||
return
|
||||
}
|
||||
items, err := h.core.ListSCUMCurrentPositionsForSession(bearerToken(r), scumProjectionFilterFromRequest(r, r.PathValue("id")))
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, dto.SCUMCurrentPositionsFromDomain(items))
|
||||
}
|
||||
|
||||
func (h *coreHandlers) serverSCUMOperations(w http.ResponseWriter, r *http.Request) {
|
||||
serverID := r.PathValue("id")
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
items, err := h.core.ListSCUMOperationsForSession(bearerToken(r), scumOperationFilterFromRequest(r, serverID))
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, dto.SCUMOperationsFromDomain(items))
|
||||
case http.MethodPost:
|
||||
request, err := decodeJSON[dto.SCUMOperationRequestBody](r)
|
||||
if err != nil {
|
||||
writeDecodeError(w, err)
|
||||
return
|
||||
}
|
||||
operation, err := h.core.RequestSCUMOperationForSession(bearerToken(r), serverID, dto.SCUMOperationRequestBodyToDomain(request))
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, dto.SCUMOperationFromDomain(operation))
|
||||
default:
|
||||
writeMethodNotAllowed(w, "GET, POST")
|
||||
}
|
||||
}
|
||||
|
||||
func (h *coreHandlers) serverSCUMOperationApprove(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeMethodNotAllowed(w, http.MethodPost)
|
||||
return
|
||||
}
|
||||
operation, err := h.core.ApproveSCUMOperationForSession(bearerToken(r), r.PathValue("operationId"))
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, dto.SCUMOperationFromDomain(operation))
|
||||
}
|
||||
|
||||
func (h *coreHandlers) serverSCUMWorkflows(w http.ResponseWriter, r *http.Request) {
|
||||
serverID := r.PathValue("id")
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
items, err := h.core.ListSCUMWorkflowsForSession(bearerToken(r), scumWorkflowFilterFromRequest(r, serverID))
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, dto.SCUMWorkflowsFromDomain(items))
|
||||
case http.MethodPost:
|
||||
request, err := decodeJSON[dto.SCUMWorkflowCreateRequest](r)
|
||||
if err != nil {
|
||||
writeDecodeError(w, err)
|
||||
return
|
||||
}
|
||||
workflow, err := h.core.CreateSCUMWorkflowForSession(bearerToken(r), serverID, dto.SCUMWorkflowCreateRequestToDomain(request))
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, dto.SCUMWorkflowFromDomain(workflow))
|
||||
default:
|
||||
writeMethodNotAllowed(w, "GET, POST")
|
||||
}
|
||||
}
|
||||
|
||||
func (h *coreHandlers) serverSCUMWorkflowSteps(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeMethodNotAllowed(w, http.MethodGet)
|
||||
return
|
||||
}
|
||||
items, err := h.core.ListSCUMWorkflowStepsForSession(bearerToken(r), scumWorkflowStepFilterFromRequest(r, r.PathValue("id")))
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, dto.SCUMWorkflowStepsFromDomain(items))
|
||||
}
|
||||
|
||||
func scumProjectionFilterFromRequest(r *http.Request, serverID string) domain.SCUMProjectionFilter {
|
||||
query := r.URL.Query()
|
||||
return domain.SCUMProjectionFilter{ServerInstanceID: serverID, GamePlayerID: query.Get("gamePlayerId"), GamePlayerRecordID: query.Get("gamePlayerRecordId"), UserProfileID: query.Get("userProfileId"), SteamID: query.Get("steamId"), SquadID: query.Get("squadId"), VehicleID: query.Get("vehicleId"), FlagID: query.Get("flagId"), SubjectType: domain.SCUMProjectionSubject(query.Get("subjectType")), QueryKey: query.Get("queryKey"), Freshness: domain.SCUMProjectionFreshness(query.Get("freshness")), Search: query.Get("search"), Limit: boundedQueryLimit(query.Get("limit"), 200)}
|
||||
}
|
||||
|
||||
func scumOperationFilterFromRequest(r *http.Request, serverID string) domain.SCUMOperationRequestFilter {
|
||||
query := r.URL.Query()
|
||||
return domain.SCUMOperationRequestFilter{ServerInstanceID: serverID, TemplateKey: query.Get("templateKey"), PlayerID: query.Get("playerId"), RequesterID: query.Get("requesterId"), Status: domain.SCUMWorkflowStepStatus(query.Get("status")), IdempotencyKey: query.Get("idempotencyKey"), Limit: boundedQueryLimit(query.Get("limit"), 100)}
|
||||
}
|
||||
|
||||
func scumWorkflowFilterFromRequest(r *http.Request, serverID string) domain.SCUMWorkflowInstanceFilter {
|
||||
query := r.URL.Query()
|
||||
return domain.SCUMWorkflowInstanceFilter{ServerInstanceID: serverID, TemplateKey: query.Get("templateKey"), RequestedBy: query.Get("requestedBy"), Status: domain.SCUMWorkflowStatus(query.Get("status")), IdempotencyKey: query.Get("idempotencyKey"), Limit: boundedQueryLimit(query.Get("limit"), 100)}
|
||||
}
|
||||
|
||||
func scumWorkflowStepFilterFromRequest(r *http.Request, serverID string) domain.SCUMWorkflowStepFilter {
|
||||
query := r.URL.Query()
|
||||
return domain.SCUMWorkflowStepFilter{ServerInstanceID: serverID, WorkflowID: query.Get("workflowId"), StepKey: query.Get("stepKey"), Status: domain.SCUMWorkflowStepStatus(query.Get("status")), Limit: boundedQueryLimit(query.Get("limit"), 200)}
|
||||
}
|
||||
|
||||
func boundedQueryLimit(raw string, fallback int) int {
|
||||
if raw == "" {
|
||||
return fallback
|
||||
}
|
||||
parsed, err := strconv.Atoi(raw)
|
||||
if err != nil || parsed < 1 {
|
||||
return fallback
|
||||
}
|
||||
if parsed > 500 {
|
||||
return 500
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
"browser.local/platform/dto"
|
||||
"browser.local/platform/repo"
|
||||
"browser.local/platform/service"
|
||||
)
|
||||
|
||||
func TestSCUMProjectionOperationAndWorkflowAPIsExposeSafeTypedSurfaces(t *testing.T) {
|
||||
store := repo.NewMemoryStore()
|
||||
core := service.NewCoreService(store)
|
||||
if _, err := core.CreateUser(domain.User{ID: "scum-api-owner", DisplayName: "SCUM API Owner", Email: "scum-api-owner@example.test", Status: domain.UserStatusActive, Roles: []string{"server-owner"}, PasswordHash: "secret-password"}); err != nil {
|
||||
t.Fatalf("create owner: %v", err)
|
||||
}
|
||||
plugin := validGamePluginRequest().ToDomain()
|
||||
plugin.DeclaredPermissions = append(plugin.DeclaredPermissions, "server.game-client.command", "server.game-client.read")
|
||||
plugin.RequiredRunCapabilities = append(plugin.RequiredRunCapabilities, domain.JobCapabilityRemoteRunRCONCommand, domain.JobCapabilityRemoteRunProtectedRCON)
|
||||
plugin.RuntimeProfiles.TransportProfiles = []domain.RuntimeTransportProfile{{Key: "scum-management", Kind: "rcon", TargetKey: "scum-management", Capabilities: []string{domain.JobCapabilityRemoteRunRCONCommand, domain.JobCapabilityRemoteRunProtectedRCON}}}
|
||||
plugin.GameClientBridge.OperationTemplates = []domain.GameClientBridgeOperationTemplateDeclaration{{Key: "player.fame.set", Title: "Set fame", Permission: "server.game-client.command", ApprovalLevel: domain.GameClientBridgeApprovalLevelOperator, Kind: domain.GameClientBridgeOperationKindRCON, TransportKey: "scum-management", TargetKey: "scum-management", PayloadSchemaRef: "schemas/bridge/player-fame-set.payload.schema.json", TimeoutSeconds: 60, MaxPayloadBytes: 2048, Safety: domain.GameClientBridgeOperationSafety{RequiresApproval: true, RequiresConfirmation: true}}}
|
||||
plugin.GameClientBridge.Retention = domain.GameClientBridgeRetention{KeepForSeconds: 86400, MaxRecords: 1000}
|
||||
if _, err := core.CreateGamePlugin(plugin); err != nil {
|
||||
t.Fatalf("create plugin: %v", err)
|
||||
}
|
||||
endpoint := validRunEndpointRequest().ToDomain()
|
||||
endpoint.Capabilities = append(endpoint.Capabilities, domain.JobCapabilityRemoteRunDBSQLiteQuery, domain.JobCapabilityRemoteRunLogsTransfer, domain.JobCapabilityRemoteRunRCONCommand, domain.JobCapabilityRemoteRunProtectedRCON)
|
||||
endpoint.LastHeartbeatAt = time.Now().UTC()
|
||||
if _, err := core.CreateRunEndpoint(endpoint); err != nil {
|
||||
t.Fatalf("create endpoint: %v", err)
|
||||
}
|
||||
if _, err := core.CreateServerInstance(domain.ServerInstance{ID: "server-scum-api", PluginID: plugin.ID, RunEndpointID: endpoint.ID, Name: "SCUM API", OwnerUserID: "scum-api-owner", State: domain.ServerInstanceStateRunning, ConfigVersion: 1}); err != nil {
|
||||
t.Fatalf("create server: %v", err)
|
||||
}
|
||||
if _, err := core.ApplySCUMObservationResult(domain.SCUMObservationResult{ServerInstanceID: "server-scum-api", PluginID: plugin.ID, Source: "run.sqlite.read", QueryKey: "scum.player.profile", Sequence: 1, Checksum: "sha256:api-profile", ObservedAt: time.Now().UTC(), Rows: []map[string]any{{"gamePlayerId": "steam-api", "displayName": "API Player", "normalBalance": 25, "x": 1, "y": 2, "z": 3}}}); err != nil {
|
||||
t.Fatalf("seed projection: %v", err)
|
||||
}
|
||||
auth, err := core.LoginUser(domain.UserLogin{Account: "scum-api-owner@example.test", Password: "secret-password"})
|
||||
if err != nil {
|
||||
t.Fatalf("login: %v", err)
|
||||
}
|
||||
router := NewAuthorizedRouterWithCore(core)
|
||||
players := getJSONWithAuth[dto.SCUMPlayerLiveStateListResponse](t, router, "/api/v1/server-instances/server-scum-api/scum/players", auth.SessionID)
|
||||
if players.Count != 1 || players.Items[0].GamePlayerID != "steam-api" || players.Items[0].Position.X != 1 {
|
||||
t.Fatalf("unexpected SCUM players response: %+v", players)
|
||||
}
|
||||
operation := postJSONWithAuth[dto.SCUMOperationResponse](t, router, "/api/v1/server-instances/server-scum-api/scum/operations", dto.SCUMOperationRequestBody{TemplateKey: "player.fame.set", PlayerID: "steam-api", Payload: map[string]any{"fame": 12}, Reason: "api typed op", IdempotencyKey: "api-fame-1"}, auth.SessionID)
|
||||
if operation.Status != string(domain.SCUMWorkflowStepWaiting) || operation.TemplateKey != "player.fame.set" {
|
||||
t.Fatalf("unexpected SCUM operation response: %+v", operation)
|
||||
}
|
||||
operations := getJSONWithAuth[dto.SCUMOperationListResponse](t, router, "/api/v1/server-instances/server-scum-api/scum/operations", auth.SessionID)
|
||||
if operations.Count != 1 || operations.Items[0].ID != operation.ID {
|
||||
t.Fatalf("unexpected SCUM operation list: %+v", operations)
|
||||
}
|
||||
workflow := postJSONWithAuth[dto.SCUMWorkflowResponse](t, router, "/api/v1/server-instances/server-scum-api/scum/workflows", dto.SCUMWorkflowCreateRequest{TemplateKey: "scum.world-refresh", IdempotencyKey: "api-world-1"}, auth.SessionID)
|
||||
if workflow.Status != string(domain.SCUMWorkflowQueued) || workflow.TemplateKey != "scum.world-refresh" {
|
||||
t.Fatalf("unexpected SCUM workflow response: %+v", workflow)
|
||||
}
|
||||
steps := getJSONWithAuth[dto.SCUMWorkflowStepListResponse](t, router, "/api/v1/server-instances/server-scum-api/scum/workflow-steps?workflowId="+workflow.ID, auth.SessionID)
|
||||
if steps.Count == 0 {
|
||||
t.Fatalf("expected workflow steps: %+v", steps)
|
||||
}
|
||||
body, err := json.Marshal([]any{players, operation, operations, workflow, steps})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal responses: %v", err)
|
||||
}
|
||||
for _, forbidden := range []string{"#SetFamePoints", "requestText", "SELECT ", "UPDATE ", "SCUM.db", "password", "run token", "hostPath"} {
|
||||
if strings.Contains(strings.ToUpper(string(body)), strings.ToUpper(forbidden)) {
|
||||
t.Fatalf("SCUM safe API leaked %q: %s", forbidden, body)
|
||||
}
|
||||
}
|
||||
for _, legacy := range []struct{ method, path string }{
|
||||
{http.MethodPost, "/api/v1/server-instances/server-scum-api/rcon/commands"},
|
||||
{http.MethodGet, "/api/v1/server-instances/server-scum-api/logs/live"},
|
||||
{http.MethodGet, "/api/v1/server-instances/server-scum-api/logs/events"},
|
||||
{http.MethodPost, "/api/v1/server-instances/server-scum-api/logs/backfill"},
|
||||
{http.MethodGet, "/api/v1/server-instances/server-scum-api/files/read-snapshot?key=scum-server-log"},
|
||||
{http.MethodGet, "/api/v1/server-instances/server-scum-api/config"},
|
||||
{http.MethodPost, "/api/v1/server-instances/server-scum-api/config/diff"},
|
||||
{http.MethodPost, "/api/v1/server-instances/server-scum-api/config/approve"},
|
||||
} {
|
||||
recorder := requestWithAuth(t, router, legacy.method, legacy.path, `{}`, auth.SessionID)
|
||||
assertStatus(t, recorder, http.StatusNotFound)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSCUMAPIsEnforceServerAuthorization(t *testing.T) {
|
||||
store := repo.NewMemoryStore()
|
||||
core := service.NewCoreService(store)
|
||||
if _, err := core.CreateUser(domain.User{ID: "scum-api-owner", DisplayName: "SCUM API Owner", Email: "scum-api-owner-authz@example.test", Status: domain.UserStatusActive, Roles: []string{"server-owner"}, PasswordHash: "secret-password"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := core.CreateUser(domain.User{ID: "scum-api-other", DisplayName: "SCUM API Other", Email: "scum-api-other-authz@example.test", Status: domain.UserStatusActive, Roles: []string{"server-owner"}, PasswordHash: "secret-password"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := core.CreateGamePlugin(validGamePluginRequest().ToDomain()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := core.CreateRunEndpoint(validRunEndpointRequest().ToDomain()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := core.CreateServerInstance(domain.ServerInstance{ID: "server-scum-authz", PluginID: "server.scum", RunEndpointID: "run-local", Name: "SCUM Authz", OwnerUserID: "scum-api-owner", State: domain.ServerInstanceStateRunning}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
auth, err := core.LoginUser(domain.UserLogin{Account: "scum-api-other-authz@example.test", Password: "secret-password"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
router := NewAuthorizedRouterWithCore(core)
|
||||
assertErrorResponse(t, requestWithAuth(t, router, http.MethodGet, "/api/v1/server-instances/server-scum-authz/scum/players", "", auth.SessionID), http.StatusForbidden, errorCodeForbidden)
|
||||
}
|
||||
|
||||
func TestSCUMAPIsRequirePlatformAdminForDBMutationApproval(t *testing.T) {
|
||||
store := repo.NewMemoryStore()
|
||||
core := service.NewCoreService(store)
|
||||
if _, err := core.CreateUser(domain.User{ID: "scum-api-owner", DisplayName: "SCUM API Owner", Email: "scum-api-owner-mutation@example.test", Status: domain.UserStatusActive, Roles: []string{"server-owner"}, PasswordHash: "secret-password"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
plugin := validGamePluginRequest().ToDomain()
|
||||
plugin.DeclaredPermissions = append(plugin.DeclaredPermissions, "server.game-client.read", "server.game-client.maintenance")
|
||||
plugin.RequiredRunCapabilities = append(plugin.RequiredRunCapabilities, domain.JobCapabilityRemoteRunDBSQLiteQuery, domain.JobCapabilityRemoteRunProtectedSQL)
|
||||
plugin.RuntimeProfiles.TransportProfiles = []domain.RuntimeTransportProfile{{Key: "scum-database", Kind: "sqlite", TargetKey: "scum-database", Capabilities: []string{domain.JobCapabilityRemoteRunDBSQLiteQuery, domain.JobCapabilityRemoteRunProtectedSQL}}}
|
||||
plugin.GameClientBridge.QueryTemplates = []domain.GameClientBridgeQueryTemplateDeclaration{{Key: "scum.player.profile", Title: "Read player profile", Permission: "server.game-client.read", Engine: "sqlite", TransportKey: "scum-database", TargetKey: "scum-database", ParameterSchemaRef: "schemas/bridge/queries/scum-player-profile.parameters.schema.json", ResultSchemaRef: "schemas/bridge/queries/scum-player-profile.result.schema.json", MaxRows: 10, TimeoutSeconds: 15}}
|
||||
plugin.GameClientBridge.OperationTemplates = []domain.GameClientBridgeOperationTemplateDeclaration{{Key: "player.attribute.855.set", Title: "Set attribute 855", Permission: "server.game-client.maintenance", ApprovalLevel: domain.GameClientBridgeApprovalLevelPlatformAdmin, Kind: domain.GameClientBridgeOperationKindSQLiteMutation, TransportKey: "scum-database", TargetKey: "scum-database", PayloadSchemaRef: "schemas/bridge/player-attribute-855-set.payload.schema.json", ResultSchemaRef: "schemas/bridge/player-attribute-855-set.result.schema.json", ConfirmationSchemaRef: "schemas/bridge/player-attribute-855-set.confirmation.schema.json", TimeoutSeconds: 120, MaxPayloadBytes: 4096, MaxRowsAffected: 1, Mutation: domain.GameClientBridgeOperationMutationDeclaration{FieldKey: "855", TableKey: "prisoner", IdentityKey: "user_profile_id", ValueKey: "value", ConfirmationQueryKey: "scum.player.profile", AllowedValueType: "integer", MinValue: 0, MaxValue: 100000}, Safety: domain.GameClientBridgeOperationSafety{RequiresApproval: true, RequiresOfflinePlayer: true, RequiresMaintenanceWindow: true, RequiresBeforeValue: true, RequiresConfirmation: true, BackupRequired: true}}}
|
||||
plugin.GameClientBridge.Retention = domain.GameClientBridgeRetention{KeepForSeconds: 86400, MaxRecords: 1000}
|
||||
if _, err := core.CreateGamePlugin(plugin); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
endpoint := validRunEndpointRequest().ToDomain()
|
||||
endpoint.Capabilities = append(endpoint.Capabilities, domain.JobCapabilityRemoteRunDBSQLiteQuery, domain.JobCapabilityRemoteRunProtectedSQL)
|
||||
if _, err := core.CreateRunEndpoint(endpoint); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := core.CreateServerInstance(domain.ServerInstance{ID: "server-scum-mutation-authz", PluginID: plugin.ID, RunEndpointID: endpoint.ID, Name: "SCUM Mutation Authz", OwnerUserID: "scum-api-owner", State: domain.ServerInstanceStateStopped}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
auth, err := core.LoginUser(domain.UserLogin{Account: "scum-api-owner-mutation@example.test", Password: "secret-password"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
router := NewAuthorizedRouterWithCore(core)
|
||||
operation := postJSONWithAuth[dto.SCUMOperationResponse](t, router, "/api/v1/server-instances/server-scum-mutation-authz/scum/operations", dto.SCUMOperationRequestBody{TemplateKey: "player.attribute.855.set", PlayerID: "steam-api", Payload: map[string]any{"fieldKey": "855", "before": 10, "after": 12, "safetyWindow": "maintenance-2026-08-10", "backupRef": "backup://scum/1"}, Reason: "api typed db op", IdempotencyKey: "api-855-1"}, auth.SessionID)
|
||||
assertErrorResponse(t, requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/server-scum-mutation-authz/scum/operations/"+operation.ID+"/approve", map[string]string{}, auth.SessionID), http.StatusForbidden, errorCodeForbidden)
|
||||
}
|
||||
@@ -6,20 +6,7 @@ import (
|
||||
"browser.local/platform/dto"
|
||||
)
|
||||
|
||||
// sourceRCONCommands godoc
|
||||
// @Summary Queue a direct SCUM Source RCON chat or command
|
||||
// @Description Queues one non-retryable command without persisting the raw command, RCON password, or response body.
|
||||
// @Tags scum-rcon
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "Server instance ID"
|
||||
// @Param body body dto.SourceRCONCommandRequestBody true "SCUM Source RCON chat or command request"
|
||||
// @Success 202 {object} dto.SourceRCONCommandResponse
|
||||
// @Failure 400 {object} dto.ErrorResponse
|
||||
// @Failure 401 {object} dto.ErrorResponse
|
||||
// @Failure 403 {object} dto.ErrorResponse
|
||||
// @Failure 405 {object} dto.ErrorResponse
|
||||
// @Router /api/v1/server-instances/{id}/rcon/commands [post]
|
||||
// sourceRCONCommands is kept as legacy service plumbing but is not registered as a browser product route.
|
||||
func (h *coreHandlers) sourceRCONCommands(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeMethodNotAllowed(w, http.MethodPost)
|
||||
|
||||
Reference in New Issue
Block a user