Integrate SCUM real ops workflows

This commit is contained in:
npc0-hue
2026-08-10 21:12:53 +08:00
parent 1063330710
commit a770bc6250
88 changed files with 6375 additions and 2719 deletions
+1 -13
View File
@@ -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)
+2 -51
View File
@@ -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)
}
+14 -64
View File
@@ -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)
+14 -163
View File
@@ -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
View File
@@ -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.
+201
View File
@@ -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
}
+149
View File
@@ -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)
}
+1 -14
View File
@@ -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)
+56 -7
View File
@@ -73,11 +73,57 @@ type GameClientBridgeQueryTemplateDeclaration struct {
TimeoutSeconds int
}
type GameClientBridgeOperationKind string
const (
GameClientBridgeOperationKindRCON GameClientBridgeOperationKind = "rcon"
GameClientBridgeOperationKindSQLiteMutation GameClientBridgeOperationKind = "sqlite-mutation"
)
type GameClientBridgeOperationTemplateDeclaration struct {
Key string
Title string
Permission string
ApprovalLevel GameClientBridgeApprovalLevel
Kind GameClientBridgeOperationKind
TransportKey string
TargetKey string
PayloadSchemaRef string
ResultSchemaRef string
ConfirmationSchemaRef string
TimeoutSeconds int
MaxPayloadBytes int
MaxRowsAffected int
Mutation GameClientBridgeOperationMutationDeclaration
Safety GameClientBridgeOperationSafety
}
type GameClientBridgeOperationMutationDeclaration struct {
FieldKey string
TableKey string
IdentityKey string
ValueKey string
ConfirmationQueryKey string
AllowedValueType string
MinValue float64
MaxValue float64
}
type GameClientBridgeOperationSafety struct {
RequiresApproval bool
RequiresOfflinePlayer bool
RequiresMaintenanceWindow bool
RequiresBeforeValue bool
RequiresConfirmation bool
BackupRequired bool
}
type GameClientBridgePageContract struct {
PageKey string
CommandTypes []string
SnapshotTypes []string
QueryTemplateKeys []string
OperationKeys []string
FeatureKeys []string
}
@@ -106,13 +152,14 @@ type GameClientBridgeCompanionDeclaration struct {
}
type GameClientBridgeManifest struct {
Commands []GameClientBridgeCommandDeclaration
Snapshots []GameClientBridgeSnapshotDeclaration
QueryTemplates []GameClientBridgeQueryTemplateDeclaration
Retention GameClientBridgeRetention
Pages []GameClientBridgePageContract
Features []GameClientBridgeFeatureDeclaration
Companion GameClientBridgeCompanionDeclaration
Commands []GameClientBridgeCommandDeclaration
Snapshots []GameClientBridgeSnapshotDeclaration
QueryTemplates []GameClientBridgeQueryTemplateDeclaration
OperationTemplates []GameClientBridgeOperationTemplateDeclaration
Retention GameClientBridgeRetention
Pages []GameClientBridgePageContract
Features []GameClientBridgeFeatureDeclaration
Companion GameClientBridgeCompanionDeclaration
}
type GameClientBridgeResultStatus string
@@ -406,12 +453,14 @@ func CopyGameClientBridgeManifest(value GameClientBridgeManifest) GameClientBrid
}
value.Snapshots = append([]GameClientBridgeSnapshotDeclaration(nil), value.Snapshots...)
value.QueryTemplates = append([]GameClientBridgeQueryTemplateDeclaration(nil), value.QueryTemplates...)
value.OperationTemplates = append([]GameClientBridgeOperationTemplateDeclaration(nil), value.OperationTemplates...)
value.Pages = append([]GameClientBridgePageContract(nil), value.Pages...)
value.Features = append([]GameClientBridgeFeatureDeclaration(nil), value.Features...)
for index := range value.Pages {
value.Pages[index].CommandTypes = CopyStringSlice(value.Pages[index].CommandTypes)
value.Pages[index].SnapshotTypes = CopyStringSlice(value.Pages[index].SnapshotTypes)
value.Pages[index].QueryTemplateKeys = CopyStringSlice(value.Pages[index].QueryTemplateKeys)
value.Pages[index].OperationKeys = CopyStringSlice(value.Pages[index].OperationKeys)
value.Pages[index].FeatureKeys = CopyStringSlice(value.Pages[index].FeatureKeys)
}
for index := range value.Features {
+6 -3
View File
@@ -4,13 +4,16 @@ import "testing"
func TestCopyGameClientBridgeDeclarationsCopiesQueryTemplateSlices(t *testing.T) {
manifest := GameClientBridgeManifest{
QueryTemplates: []GameClientBridgeQueryTemplateDeclaration{{Key: "player.lookup"}},
Pages: []GameClientBridgePageContract{{PageKey: "players", QueryTemplateKeys: []string{"player.lookup"}}},
QueryTemplates: []GameClientBridgeQueryTemplateDeclaration{{Key: "player.lookup"}},
OperationTemplates: []GameClientBridgeOperationTemplateDeclaration{{Key: "player.fame.set"}},
Pages: []GameClientBridgePageContract{{PageKey: "players", QueryTemplateKeys: []string{"player.lookup"}, OperationKeys: []string{"player.fame.set"}}},
}
manifestCopy := CopyGameClientBridgeManifest(manifest)
manifestCopy.QueryTemplates[0].Key = "mutated"
manifestCopy.OperationTemplates[0].Key = "mutated"
manifestCopy.Pages[0].QueryTemplateKeys[0] = "mutated"
if manifest.QueryTemplates[0].Key != "player.lookup" || manifest.Pages[0].QueryTemplateKeys[0] != "player.lookup" {
manifestCopy.Pages[0].OperationKeys[0] = "mutated"
if manifest.QueryTemplates[0].Key != "player.lookup" || manifest.OperationTemplates[0].Key != "player.fame.set" || manifest.Pages[0].QueryTemplateKeys[0] != "player.lookup" || manifest.Pages[0].OperationKeys[0] != "player.fame.set" {
t.Fatalf("manifest copy aliases query template declarations: source=%#v copy=%#v", manifest, manifestCopy)
}
+185
View File
@@ -0,0 +1,185 @@
package domain
import "time"
type SCUMProjectionSubject string
const (
SCUMProjectionSubjectPlayer SCUMProjectionSubject = "player"
SCUMProjectionSubjectLiveState SCUMProjectionSubject = "player-live-state"
SCUMProjectionSubjectSquad SCUMProjectionSubject = "squad"
SCUMProjectionSubjectMember SCUMProjectionSubject = "squad-member"
SCUMProjectionSubjectVehicle SCUMProjectionSubject = "vehicle"
SCUMProjectionSubjectFlag SCUMProjectionSubject = "flag"
SCUMProjectionSubjectPosition SCUMProjectionSubject = "position"
)
type SCUMProjectionFilter struct {
ServerInstanceID string
GamePlayerID string
GamePlayerRecordID string
UserProfileID string
SteamID string
SquadID string
VehicleID string
FlagID string
SubjectType SCUMProjectionSubject
QueryKey string
Freshness SCUMProjectionFreshness
Search string
Limit int
}
type SCUMPlayerLiveState struct {
ID string
ServerInstanceID string
GamePlayerRecordID string
GamePlayerID string
UserProfileID string
SteamID string
DisplayName string
SquadID string
SquadName string
Online bool
FamePoints float64
NormalBalance float64
GoldBalance float64
LastLoginAt time.Time
LastLogoutAt time.Time
LastSaveTime time.Time
Position SCUMCurrentPosition
UnknownFields map[string]any
Freshness SCUMProjectionFreshnessState
CreatedAt time.Time
UpdatedAt time.Time
}
type SCUMSquad struct {
ID string
ServerInstanceID string
SquadID string
Name string
LeaderProfileID string
LeaderPlayerID string
MemberCount int
Score float64
UnknownFields map[string]any
Freshness SCUMProjectionFreshnessState
CreatedAt time.Time
UpdatedAt time.Time
}
type SCUMSquadMember struct {
ID string
ServerInstanceID string
SquadID string
UserProfileID string
GamePlayerRecordID string
GamePlayerID string
SteamID string
DisplayName string
Rank string
IsLeader bool
JoinedAt time.Time
UnknownFields map[string]any
Freshness SCUMProjectionFreshnessState
CreatedAt time.Time
UpdatedAt time.Time
}
type SCUMVehicle struct {
ID string
ServerInstanceID string
VehicleID string
EntityID string
ClassName string
Label string
OwnerProfileID string
OwnerPlayerID string
SquadID string
Position SCUMCurrentPosition
UnknownFields map[string]any
Freshness SCUMProjectionFreshnessState
CreatedAt time.Time
UpdatedAt time.Time
}
type SCUMFlag struct {
ID string
ServerInstanceID string
FlagID string
EntityID string
OwnerProfileID string
OwnerPlayerID string
OwnerSquadID string
OwnerSquadName string
OwnershipConfidence string
Position SCUMCurrentPosition
UnknownFields map[string]any
Freshness SCUMProjectionFreshnessState
CreatedAt time.Time
UpdatedAt time.Time
}
type SCUMCurrentPosition struct {
ID string
ServerInstanceID string
SubjectType SCUMProjectionSubject
SubjectID string
GamePlayerRecordID string
GamePlayerID string
VehicleID string
EntityID string
MapID string
MapVersion string
X float64
Y float64
Z float64
HasCoordinates bool
LastSaveTime time.Time
Freshness SCUMProjectionFreshnessState
CreatedAt time.Time
UpdatedAt time.Time
}
func SCUMProjectionStateUnknown() SCUMProjectionFreshnessState {
return SCUMProjectionFreshnessState{Status: SCUMProjectionUnknown}
}
func CopySCUMPlayerLiveState(value SCUMPlayerLiveState) SCUMPlayerLiveState {
value.Position = CopySCUMCurrentPosition(value.Position)
value.UnknownFields = CopyGameClientBridgePayload(value.UnknownFields)
value.Freshness = CopySCUMProjectionFreshnessState(value.Freshness)
return value
}
func CopySCUMSquad(value SCUMSquad) SCUMSquad {
value.UnknownFields = CopyGameClientBridgePayload(value.UnknownFields)
value.Freshness = CopySCUMProjectionFreshnessState(value.Freshness)
return value
}
func CopySCUMSquadMember(value SCUMSquadMember) SCUMSquadMember {
value.UnknownFields = CopyGameClientBridgePayload(value.UnknownFields)
value.Freshness = CopySCUMProjectionFreshnessState(value.Freshness)
return value
}
func CopySCUMVehicle(value SCUMVehicle) SCUMVehicle {
value.Position = CopySCUMCurrentPosition(value.Position)
value.UnknownFields = CopyGameClientBridgePayload(value.UnknownFields)
value.Freshness = CopySCUMProjectionFreshnessState(value.Freshness)
return value
}
func CopySCUMFlag(value SCUMFlag) SCUMFlag {
value.Position = CopySCUMCurrentPosition(value.Position)
value.UnknownFields = CopyGameClientBridgePayload(value.UnknownFields)
value.Freshness = CopySCUMProjectionFreshnessState(value.Freshness)
return value
}
func CopySCUMCurrentPosition(value SCUMCurrentPosition) SCUMCurrentPosition {
value.Freshness = CopySCUMProjectionFreshnessState(value.Freshness)
return value
}
+282
View File
@@ -0,0 +1,282 @@
package domain
import "time"
type SCUMObservationStatus string
const (
SCUMObservationAccepted SCUMObservationStatus = "accepted"
SCUMObservationStale SCUMObservationStatus = "stale"
SCUMObservationFailed SCUMObservationStatus = "failed"
)
type SCUMProjectionFreshness string
const (
SCUMProjectionFresh SCUMProjectionFreshness = "fresh"
SCUMProjectionStale SCUMProjectionFreshness = "stale"
SCUMProjectionUnknown SCUMProjectionFreshness = "unknown"
)
type SCUMWorkflowStatus string
const (
SCUMWorkflowDraft SCUMWorkflowStatus = "draft"
SCUMWorkflowQueued SCUMWorkflowStatus = "queued"
SCUMWorkflowRunning SCUMWorkflowStatus = "running"
SCUMWorkflowWaiting SCUMWorkflowStatus = "waiting"
SCUMWorkflowBlocked SCUMWorkflowStatus = "blocked"
SCUMWorkflowConfirming SCUMWorkflowStatus = "confirming"
SCUMWorkflowConfirmed SCUMWorkflowStatus = "confirmed"
SCUMWorkflowFailed SCUMWorkflowStatus = "failed"
SCUMWorkflowUnknown SCUMWorkflowStatus = "unknown"
SCUMWorkflowCancelled SCUMWorkflowStatus = "cancelled"
)
type SCUMWorkflowStepStatus string
const (
SCUMWorkflowStepQueued SCUMWorkflowStepStatus = "queued"
SCUMWorkflowStepRunning SCUMWorkflowStepStatus = "running"
SCUMWorkflowStepWaiting SCUMWorkflowStepStatus = "waiting"
SCUMWorkflowStepBlocked SCUMWorkflowStepStatus = "blocked"
SCUMWorkflowStepConfirming SCUMWorkflowStepStatus = "confirming"
SCUMWorkflowStepConfirmed SCUMWorkflowStepStatus = "confirmed"
SCUMWorkflowStepFailed SCUMWorkflowStepStatus = "failed"
SCUMWorkflowStepUnknown SCUMWorkflowStepStatus = "unknown"
SCUMWorkflowStepCancelled SCUMWorkflowStepStatus = "cancelled"
)
type SCUMSafeSummary struct {
Title string
Message string
Details map[string]string
}
type SCUMDataObservation struct {
ID string
ServerInstanceID string
PluginID string
Source string
QueryKey string
SubjectType string
SubjectID string
Sequence uint64
Checksum string
Status SCUMObservationStatus
ErrorCode string
SafeSummary SCUMSafeSummary
ObservedAt time.Time
ReceivedAt time.Time
}
type SCUMObservationResult struct {
ServerInstanceID string
PluginID string
Source string
QueryKey string
Sequence uint64
Checksum string
Status SCUMObservationStatus
ErrorCode string
SafeSummary SCUMSafeSummary
ObservedAt time.Time
ReceivedAt time.Time
Rows []map[string]any
}
type SCUMProjectionFreshnessState struct {
Status SCUMProjectionFreshness
ObservationID string
Source string
QueryKey string
Sequence uint64
Checksum string
StaleReason string
ObservedAt time.Time
ReceivedAt time.Time
}
type SCUMMutationGuard struct {
FieldKey string
Before any
After any
MaxRowsAffected int
SafetyWindow string
BackupRef string
RequiresOfflinePlayer bool
RequiresMaintenance bool
RequiresBackup bool
}
type SCUMOperationConfirmation struct {
Status string
ObservationID string
ConfirmedFields map[string]any
AffectedRows int
MutationChecksum string
Checksum string
ObservedAt time.Time
SafeSummary SCUMSafeSummary
}
type SCUMOperationRequest struct {
ID string
ServerInstanceID string
PluginID string
TemplateKey string
PlayerID string
RequesterID string
ApproverID string
ApprovalLevel GameClientBridgeApprovalLevel
Payload map[string]any
Guard SCUMMutationGuard
Confirmation SCUMOperationConfirmation
Status SCUMWorkflowStepStatus
Reason string
IdempotencyKey string
RunJobID string
SafeSummary SCUMSafeSummary
AuditReferences []string
CreatedAt time.Time
ApprovedAt time.Time
CompletedAt time.Time
UpdatedAt time.Time
}
type SCUMOperationRequestFilter struct {
ServerInstanceID string
PluginID string
TemplateKey string
PlayerID string
RequesterID string
Status SCUMWorkflowStepStatus
IdempotencyKey string
Limit int
}
type SCUMWorkflowInstanceFilter struct {
ServerInstanceID string
PluginID string
TemplateKey string
RequestedBy string
Status SCUMWorkflowStatus
IdempotencyKey string
Limit int
}
type SCUMWorkflowStepFilter struct {
WorkflowID string
ServerInstanceID string
StepKey string
Status SCUMWorkflowStepStatus
MutatesState *bool
Limit int
}
type SCUMWorkflowInstance struct {
ID string
ServerInstanceID string
PluginID string
TemplateKey string
RequestedBy string
IdempotencyKey string
Status SCUMWorkflowStatus
CurrentStepKey string
Input map[string]any
SafeSummary SCUMSafeSummary
BlockerReason string
AuditReferences []string
CreatedAt time.Time
UpdatedAt time.Time
CompletedAt time.Time
}
type SCUMWorkflowStep struct {
ID string
WorkflowID string
ServerInstanceID string
StepKey string
DependsOn []string
Status SCUMWorkflowStepStatus
OperationKey string
QueryTemplateKey string
Capability string
TargetKey string
JobID string
Attempt int
MaxAttempts int
MutatesState bool
Confirmation SCUMOperationConfirmation
SafeSummary SCUMSafeSummary
BlockerReason string
AuditReferences []string
CreatedAt time.Time
UpdatedAt time.Time
CompletedAt time.Time
}
func CopySCUMSafeSummary(value SCUMSafeSummary) SCUMSafeSummary {
value.Details = CopyStringMap(value.Details)
return value
}
func CopySCUMDataObservation(value SCUMDataObservation) SCUMDataObservation {
value.SafeSummary = CopySCUMSafeSummary(value.SafeSummary)
return value
}
func CopySCUMObservationResult(value SCUMObservationResult) SCUMObservationResult {
value.SafeSummary = CopySCUMSafeSummary(value.SafeSummary)
value.Rows = CopyGameClientBridgeRows(value.Rows)
return value
}
func CopyGameClientBridgeRows(values []map[string]any) []map[string]any {
if values == nil {
return nil
}
out := make([]map[string]any, len(values))
for index, row := range values {
out[index] = CopyGameClientBridgePayload(row)
}
return out
}
func CopySCUMProjectionFreshnessState(value SCUMProjectionFreshnessState) SCUMProjectionFreshnessState {
return value
}
func CopySCUMMutationGuard(value SCUMMutationGuard) SCUMMutationGuard {
return value
}
func CopySCUMOperationConfirmation(value SCUMOperationConfirmation) SCUMOperationConfirmation {
value.ConfirmedFields = CopyGameClientBridgePayload(value.ConfirmedFields)
value.SafeSummary = CopySCUMSafeSummary(value.SafeSummary)
return value
}
func CopySCUMOperationRequest(value SCUMOperationRequest) SCUMOperationRequest {
value.Payload = CopyGameClientBridgePayload(value.Payload)
value.Guard = CopySCUMMutationGuard(value.Guard)
value.Confirmation = CopySCUMOperationConfirmation(value.Confirmation)
value.SafeSummary = CopySCUMSafeSummary(value.SafeSummary)
value.AuditReferences = CopyStringSlice(value.AuditReferences)
return value
}
func CopySCUMWorkflowInstance(value SCUMWorkflowInstance) SCUMWorkflowInstance {
value.Input = CopyGameClientBridgePayload(value.Input)
value.SafeSummary = CopySCUMSafeSummary(value.SafeSummary)
value.AuditReferences = CopyStringSlice(value.AuditReferences)
return value
}
func CopySCUMWorkflowStep(value SCUMWorkflowStep) SCUMWorkflowStep {
value.DependsOn = CopyStringSlice(value.DependsOn)
value.Confirmation = CopySCUMOperationConfirmation(value.Confirmation)
value.SafeSummary = CopySCUMSafeSummary(value.SafeSummary)
value.AuditReferences = CopyStringSlice(value.AuditReferences)
return value
}
+60 -12
View File
@@ -302,11 +302,50 @@ type GameClientBridgeQueryTemplateDeclarationBody struct {
TimeoutSeconds int `json:"timeoutSeconds"`
}
type GameClientBridgeOperationSafetyBody struct {
RequiresApproval bool `json:"requiresApproval,omitempty"`
RequiresOfflinePlayer bool `json:"requiresOfflinePlayer,omitempty"`
RequiresMaintenanceWindow bool `json:"requiresMaintenanceWindow,omitempty"`
RequiresBeforeValue bool `json:"requiresBeforeValue,omitempty"`
RequiresConfirmation bool `json:"requiresConfirmation,omitempty"`
BackupRequired bool `json:"backupRequired,omitempty"`
}
type GameClientBridgeOperationMutationDeclarationBody struct {
FieldKey string `json:"fieldKey"`
TableKey string `json:"tableKey"`
IdentityKey string `json:"identityKey"`
ValueKey string `json:"valueKey"`
ConfirmationQueryKey string `json:"confirmationQueryKey"`
AllowedValueType string `json:"allowedValueType"`
MinValue float64 `json:"minValue,omitempty"`
MaxValue float64 `json:"maxValue,omitempty"`
}
type GameClientBridgeOperationTemplateDeclarationBody struct {
Key string `json:"key"`
Title string `json:"title"`
Permission string `json:"permission"`
ApprovalLevel string `json:"approvalLevel"`
Kind string `json:"kind"`
TransportKey string `json:"transportKey"`
TargetKey string `json:"targetKey"`
PayloadSchemaRef string `json:"payloadSchemaRef"`
ResultSchemaRef string `json:"resultSchemaRef,omitempty"`
ConfirmationSchemaRef string `json:"confirmationSchemaRef,omitempty"`
TimeoutSeconds int `json:"timeoutSeconds"`
MaxPayloadBytes int `json:"maxPayloadBytes"`
MaxRowsAffected int `json:"maxRowsAffected,omitempty"`
Mutation GameClientBridgeOperationMutationDeclarationBody `json:"mutation,omitempty"`
Safety GameClientBridgeOperationSafetyBody `json:"safety,omitempty"`
}
type GameClientBridgePageContractBody struct {
PageKey string `json:"pageKey"`
CommandTypes []string `json:"commandTypes,omitempty"`
SnapshotTypes []string `json:"snapshotTypes,omitempty"`
QueryTemplateKeys []string `json:"queryTemplateKeys,omitempty"`
OperationKeys []string `json:"operationKeys,omitempty"`
FeatureKeys []string `json:"featureKeys,omitempty"`
}
@@ -335,14 +374,15 @@ type GameClientBridgeCompanionDeclarationBody struct {
}
type GameClientBridgeManifestBody struct {
Commands []GameClientBridgeCommandDeclarationBody `json:"commands"`
Snapshots []GameClientBridgeSnapshotDeclarationBody `json:"snapshots"`
QueryTemplates []GameClientBridgeQueryTemplateDeclarationBody `json:"queryTemplates,omitempty"`
CommandRetentionSeconds int `json:"commandRetentionSeconds"`
MaxCommands int `json:"maxCommands"`
Pages []GameClientBridgePageContractBody `json:"pages,omitempty"`
Features []GameClientBridgeFeatureDeclarationBody `json:"features,omitempty"`
Companion *GameClientBridgeCompanionDeclarationBody `json:"companion,omitempty"`
Commands []GameClientBridgeCommandDeclarationBody `json:"commands"`
Snapshots []GameClientBridgeSnapshotDeclarationBody `json:"snapshots"`
QueryTemplates []GameClientBridgeQueryTemplateDeclarationBody `json:"queryTemplates,omitempty"`
OperationTemplates []GameClientBridgeOperationTemplateDeclarationBody `json:"operationTemplates,omitempty"`
CommandRetentionSeconds int `json:"commandRetentionSeconds"`
MaxCommands int `json:"maxCommands"`
Pages []GameClientBridgePageContractBody `json:"pages,omitempty"`
Features []GameClientBridgeFeatureDeclarationBody `json:"features,omitempty"`
Companion *GameClientBridgeCompanionDeclarationBody `json:"companion,omitempty"`
}
type GameMapTrajectoryDeclarationBody struct {
MapID string `json:"mapId"`
@@ -1167,9 +1207,13 @@ func (body GameClientBridgeManifestBody) ToDomain() domain.GameClientBridgeManif
for index, template := range body.QueryTemplates {
queryTemplates[index] = domain.GameClientBridgeQueryTemplateDeclaration{Key: template.Key, Title: template.Title, Permission: template.Permission, Engine: template.Engine, TransportKey: template.TransportKey, TargetKey: template.TargetKey, ParameterSchemaRef: template.ParameterSchemaRef, ResultSchemaRef: template.ResultSchemaRef, MaxRows: template.MaxRows, TimeoutSeconds: template.TimeoutSeconds}
}
operationTemplates := make([]domain.GameClientBridgeOperationTemplateDeclaration, len(body.OperationTemplates))
for index, template := range body.OperationTemplates {
operationTemplates[index] = domain.GameClientBridgeOperationTemplateDeclaration{Key: template.Key, Title: template.Title, Permission: template.Permission, ApprovalLevel: domain.GameClientBridgeApprovalLevel(template.ApprovalLevel), Kind: domain.GameClientBridgeOperationKind(template.Kind), TransportKey: template.TransportKey, TargetKey: template.TargetKey, PayloadSchemaRef: template.PayloadSchemaRef, ResultSchemaRef: template.ResultSchemaRef, ConfirmationSchemaRef: template.ConfirmationSchemaRef, TimeoutSeconds: template.TimeoutSeconds, MaxPayloadBytes: template.MaxPayloadBytes, MaxRowsAffected: template.MaxRowsAffected, Mutation: domain.GameClientBridgeOperationMutationDeclaration{FieldKey: template.Mutation.FieldKey, TableKey: template.Mutation.TableKey, IdentityKey: template.Mutation.IdentityKey, ValueKey: template.Mutation.ValueKey, ConfirmationQueryKey: template.Mutation.ConfirmationQueryKey, AllowedValueType: template.Mutation.AllowedValueType, MinValue: template.Mutation.MinValue, MaxValue: template.Mutation.MaxValue}, Safety: domain.GameClientBridgeOperationSafety{RequiresApproval: template.Safety.RequiresApproval, RequiresOfflinePlayer: template.Safety.RequiresOfflinePlayer, RequiresMaintenanceWindow: template.Safety.RequiresMaintenanceWindow, RequiresBeforeValue: template.Safety.RequiresBeforeValue, RequiresConfirmation: template.Safety.RequiresConfirmation, BackupRequired: template.Safety.BackupRequired}}
}
pages := make([]domain.GameClientBridgePageContract, len(body.Pages))
for index, page := range body.Pages {
pages[index] = domain.GameClientBridgePageContract{PageKey: page.PageKey, CommandTypes: domain.CopyStringSlice(page.CommandTypes), SnapshotTypes: domain.CopyStringSlice(page.SnapshotTypes), QueryTemplateKeys: domain.CopyStringSlice(page.QueryTemplateKeys), FeatureKeys: domain.CopyStringSlice(page.FeatureKeys)}
pages[index] = domain.GameClientBridgePageContract{PageKey: page.PageKey, CommandTypes: domain.CopyStringSlice(page.CommandTypes), SnapshotTypes: domain.CopyStringSlice(page.SnapshotTypes), QueryTemplateKeys: domain.CopyStringSlice(page.QueryTemplateKeys), OperationKeys: domain.CopyStringSlice(page.OperationKeys), FeatureKeys: domain.CopyStringSlice(page.FeatureKeys)}
}
features := make([]domain.GameClientBridgeFeatureDeclaration, len(body.Features))
for index, feature := range body.Features {
@@ -1179,7 +1223,7 @@ func (body GameClientBridgeManifestBody) ToDomain() domain.GameClientBridgeManif
if body.Companion != nil {
companion = domain.GameClientBridgeCompanionDeclaration{ProfileKey: body.Companion.ProfileKey, ConfigTemplateKey: body.Companion.ConfigTemplateKey, ConfigSchemaRef: body.Companion.ConfigSchemaRef, ConfigFormat: body.Companion.ConfigFormat, PlatformBaseURLSource: body.Companion.PlatformBaseURLSource, RegistrationProof: body.Companion.RegistrationProof, ProofMaterialSource: body.Companion.ProofMaterialSource, ProofMaterialEnv: body.Companion.ProofMaterialEnv, SessionMode: body.Companion.SessionMode, TLSPolicy: body.Companion.TLSPolicy, HeartbeatIntervalSeconds: body.Companion.HeartbeatIntervalSeconds, CommandPollIntervalSeconds: body.Companion.CommandPollIntervalSeconds, RequestTimeoutSeconds: body.Companion.RequestTimeoutSeconds}
}
return domain.GameClientBridgeManifest{Commands: commands, Snapshots: snapshots, QueryTemplates: queryTemplates, Retention: domain.GameClientBridgeRetention{KeepForSeconds: body.CommandRetentionSeconds, MaxRecords: body.MaxCommands}, Pages: pages, Features: features, Companion: companion}
return domain.GameClientBridgeManifest{Commands: commands, Snapshots: snapshots, QueryTemplates: queryTemplates, OperationTemplates: operationTemplates, Retention: domain.GameClientBridgeRetention{KeepForSeconds: body.CommandRetentionSeconds, MaxRecords: body.MaxCommands}, Pages: pages, Features: features, Companion: companion}
}
func protectedRequestToDomain(value *GameClientBridgeProtectedRequestDeclarationBody) *domain.GameClientBridgeProtectedRequestDeclaration {
@@ -1595,9 +1639,13 @@ func gameClientBridgeManifestFromDomain(value domain.GameClientBridgeManifest) G
for index, template := range value.QueryTemplates {
queryTemplates[index] = GameClientBridgeQueryTemplateDeclarationBody{Key: template.Key, Title: template.Title, Permission: template.Permission, Engine: template.Engine, TransportKey: template.TransportKey, TargetKey: template.TargetKey, ParameterSchemaRef: template.ParameterSchemaRef, ResultSchemaRef: template.ResultSchemaRef, MaxRows: template.MaxRows, TimeoutSeconds: template.TimeoutSeconds}
}
operationTemplates := make([]GameClientBridgeOperationTemplateDeclarationBody, len(value.OperationTemplates))
for index, template := range value.OperationTemplates {
operationTemplates[index] = GameClientBridgeOperationTemplateDeclarationBody{Key: template.Key, Title: template.Title, Permission: template.Permission, ApprovalLevel: string(template.ApprovalLevel), Kind: string(template.Kind), TransportKey: template.TransportKey, TargetKey: template.TargetKey, PayloadSchemaRef: template.PayloadSchemaRef, ResultSchemaRef: template.ResultSchemaRef, ConfirmationSchemaRef: template.ConfirmationSchemaRef, TimeoutSeconds: template.TimeoutSeconds, MaxPayloadBytes: template.MaxPayloadBytes, MaxRowsAffected: template.MaxRowsAffected, Mutation: GameClientBridgeOperationMutationDeclarationBody{FieldKey: template.Mutation.FieldKey, TableKey: template.Mutation.TableKey, IdentityKey: template.Mutation.IdentityKey, ValueKey: template.Mutation.ValueKey, ConfirmationQueryKey: template.Mutation.ConfirmationQueryKey, AllowedValueType: template.Mutation.AllowedValueType, MinValue: template.Mutation.MinValue, MaxValue: template.Mutation.MaxValue}, Safety: GameClientBridgeOperationSafetyBody{RequiresApproval: template.Safety.RequiresApproval, RequiresOfflinePlayer: template.Safety.RequiresOfflinePlayer, RequiresMaintenanceWindow: template.Safety.RequiresMaintenanceWindow, RequiresBeforeValue: template.Safety.RequiresBeforeValue, RequiresConfirmation: template.Safety.RequiresConfirmation, BackupRequired: template.Safety.BackupRequired}}
}
pages := make([]GameClientBridgePageContractBody, len(value.Pages))
for index, page := range value.Pages {
pages[index] = GameClientBridgePageContractBody{PageKey: page.PageKey, CommandTypes: page.CommandTypes, SnapshotTypes: page.SnapshotTypes, QueryTemplateKeys: page.QueryTemplateKeys, FeatureKeys: page.FeatureKeys}
pages[index] = GameClientBridgePageContractBody{PageKey: page.PageKey, CommandTypes: page.CommandTypes, SnapshotTypes: page.SnapshotTypes, QueryTemplateKeys: page.QueryTemplateKeys, OperationKeys: page.OperationKeys, FeatureKeys: page.FeatureKeys}
}
features := make([]GameClientBridgeFeatureDeclarationBody, len(value.Features))
for index, feature := range value.Features {
@@ -1607,7 +1655,7 @@ func gameClientBridgeManifestFromDomain(value domain.GameClientBridgeManifest) G
if value.Companion.ProfileKey != "" {
companion = &GameClientBridgeCompanionDeclarationBody{ProfileKey: value.Companion.ProfileKey, ConfigTemplateKey: value.Companion.ConfigTemplateKey, ConfigSchemaRef: value.Companion.ConfigSchemaRef, ConfigFormat: value.Companion.ConfigFormat, PlatformBaseURLSource: value.Companion.PlatformBaseURLSource, RegistrationProof: value.Companion.RegistrationProof, ProofMaterialSource: value.Companion.ProofMaterialSource, ProofMaterialEnv: value.Companion.ProofMaterialEnv, SessionMode: value.Companion.SessionMode, TLSPolicy: value.Companion.TLSPolicy, HeartbeatIntervalSeconds: value.Companion.HeartbeatIntervalSeconds, CommandPollIntervalSeconds: value.Companion.CommandPollIntervalSeconds, RequestTimeoutSeconds: value.Companion.RequestTimeoutSeconds}
}
return GameClientBridgeManifestBody{Commands: commands, Snapshots: snapshots, QueryTemplates: queryTemplates, CommandRetentionSeconds: value.Retention.KeepForSeconds, MaxCommands: value.Retention.MaxRecords, Pages: pages, Features: features, Companion: companion}
return GameClientBridgeManifestBody{Commands: commands, Snapshots: snapshots, QueryTemplates: queryTemplates, OperationTemplates: operationTemplates, CommandRetentionSeconds: value.Retention.KeepForSeconds, MaxCommands: value.Retention.MaxRecords, Pages: pages, Features: features, Companion: companion}
}
func protectedRequestFromDomain(value *domain.GameClientBridgeProtectedRequestDeclaration) *GameClientBridgeProtectedRequestDeclarationBody {
+38
View File
@@ -3,6 +3,7 @@ package dto
import (
"encoding/json"
"reflect"
"strings"
"testing"
"browser.local/platform/domain"
@@ -177,3 +178,40 @@ func TestGameClientBridgeQueryTemplateDeclarationRoundTripIsSafe(t *testing.T) {
}
}
}
func TestGameClientBridgeOperationTemplateDeclarationRoundTripIsSafe(t *testing.T) {
body := GameClientBridgeManifestBody{
OperationTemplates: []GameClientBridgeOperationTemplateDeclarationBody{{
Key: "player.attribute.855.set", Title: "Set player attribute 855", Permission: "server.game-client.maintenance", ApprovalLevel: "platform-admin", Kind: "sqlite-mutation", TransportKey: "scum-mutation-db", TargetKey: "scum-mutation-db",
PayloadSchemaRef: "schemas/bridge/operations/player-attribute-855-set.payload.schema.json", ResultSchemaRef: "schemas/bridge/operations/player-attribute-855-set.result.schema.json", ConfirmationSchemaRef: "schemas/bridge/operations/player-attribute-855-set.confirmation.schema.json", TimeoutSeconds: 120, MaxPayloadBytes: 4096, MaxRowsAffected: 1,
Safety: GameClientBridgeOperationSafetyBody{RequiresApproval: true, RequiresOfflinePlayer: true, RequiresBeforeValue: true, RequiresConfirmation: true, BackupRequired: true},
}},
CommandRetentionSeconds: 86400,
MaxCommands: 1000,
Pages: []GameClientBridgePageContractBody{{PageKey: "players", OperationKeys: []string{"player.attribute.855.set"}}},
}
domainManifest := body.ToDomain()
if len(domainManifest.OperationTemplates) != 1 || domainManifest.OperationTemplates[0].Kind != "sqlite-mutation" || domainManifest.OperationTemplates[0].MaxRowsAffected != 1 || !domainManifest.OperationTemplates[0].Safety.RequiresBeforeValue || domainManifest.Pages[0].OperationKeys[0] != "player.attribute.855.set" {
t.Fatalf("operation template conversion lost declaration fields: %#v", domainManifest)
}
domainManifest.Pages[0].OperationKeys[0] = "mutated"
if body.Pages[0].OperationKeys[0] != "player.attribute.855.set" {
t.Fatal("operation template page keys alias request DTO data")
}
domainManifest.Pages[0].OperationKeys[0] = "player.attribute.855.set"
response := gameClientBridgeManifestFromDomain(domainManifest)
response.Pages[0].OperationKeys[0] = "mutated"
if domainManifest.Pages[0].OperationKeys[0] != "player.attribute.855.set" {
t.Fatal("operation template page keys alias domain data")
}
encoded, err := json.Marshal(response.OperationTemplates[0])
if err != nil {
t.Fatalf("marshal safe operation template projection: %v", err)
}
if strings.Contains(strings.ToLower(string(encoded)), "sqltext") || strings.Contains(strings.ToLower(string(encoded)), "dsn") || strings.Contains(strings.ToLower(string(encoded)), "hostpath") || strings.Contains(strings.ToLower(string(encoded)), "socket") || strings.Contains(strings.ToLower(string(encoded)), "credential") {
t.Fatalf("operation template projection leaked unsafe material: %s", encoded)
}
}
+81
View File
@@ -0,0 +1,81 @@
package dto
import "browser.local/platform/domain"
type SCUMPlayerLiveStateListResponse struct {
Items []domain.SCUMPlayerLiveState `json:"items"`
Count int `json:"count"`
}
type SCUMSquadListResponse struct {
Items []domain.SCUMSquad `json:"items"`
Count int `json:"count"`
}
type SCUMSquadMemberListResponse struct {
Items []domain.SCUMSquadMember `json:"items"`
Count int `json:"count"`
}
type SCUMVehicleListResponse struct {
Items []domain.SCUMVehicle `json:"items"`
Count int `json:"count"`
}
type SCUMFlagListResponse struct {
Items []domain.SCUMFlag `json:"items"`
Count int `json:"count"`
}
type SCUMCurrentPositionListResponse struct {
Items []domain.SCUMCurrentPosition `json:"items"`
Count int `json:"count"`
}
func SCUMPlayerLiveStatesFromDomain(values []domain.SCUMPlayerLiveState) SCUMPlayerLiveStateListResponse {
out := make([]domain.SCUMPlayerLiveState, len(values))
for index, value := range values {
out[index] = domain.CopySCUMPlayerLiveState(value)
}
return SCUMPlayerLiveStateListResponse{Items: out, Count: len(out)}
}
func SCUMSquadsFromDomain(values []domain.SCUMSquad) SCUMSquadListResponse {
out := make([]domain.SCUMSquad, len(values))
for index, value := range values {
out[index] = domain.CopySCUMSquad(value)
}
return SCUMSquadListResponse{Items: out, Count: len(out)}
}
func SCUMSquadMembersFromDomain(values []domain.SCUMSquadMember) SCUMSquadMemberListResponse {
out := make([]domain.SCUMSquadMember, len(values))
for index, value := range values {
out[index] = domain.CopySCUMSquadMember(value)
}
return SCUMSquadMemberListResponse{Items: out, Count: len(out)}
}
func SCUMVehiclesFromDomain(values []domain.SCUMVehicle) SCUMVehicleListResponse {
out := make([]domain.SCUMVehicle, len(values))
for index, value := range values {
out[index] = domain.CopySCUMVehicle(value)
}
return SCUMVehicleListResponse{Items: out, Count: len(out)}
}
func SCUMFlagsFromDomain(values []domain.SCUMFlag) SCUMFlagListResponse {
out := make([]domain.SCUMFlag, len(values))
for index, value := range values {
out[index] = domain.CopySCUMFlag(value)
}
return SCUMFlagListResponse{Items: out, Count: len(out)}
}
func SCUMCurrentPositionsFromDomain(values []domain.SCUMCurrentPosition) SCUMCurrentPositionListResponse {
out := make([]domain.SCUMCurrentPosition, len(values))
for index, value := range values {
out[index] = domain.CopySCUMCurrentPosition(value)
}
return SCUMCurrentPositionListResponse{Items: out, Count: len(out)}
}
+243
View File
@@ -0,0 +1,243 @@
package dto
import (
"time"
"browser.local/platform/domain"
)
type SCUMSafeSummaryBody struct {
Title string `json:"title,omitempty"`
Message string `json:"message,omitempty"`
Details map[string]string `json:"details,omitempty"`
}
type SCUMDataObservationResponse struct {
ID string `json:"id"`
ServerInstanceID string `json:"serverInstanceId"`
PluginID string `json:"pluginId"`
Source string `json:"source"`
QueryKey string `json:"queryKey,omitempty"`
SubjectType string `json:"subjectType,omitempty"`
SubjectID string `json:"subjectId,omitempty"`
Sequence uint64 `json:"sequence"`
Checksum string `json:"checksum,omitempty"`
Status string `json:"status"`
ErrorCode string `json:"errorCode,omitempty"`
SafeSummary SCUMSafeSummaryBody `json:"safeSummary,omitempty"`
ObservedAt time.Time `json:"observedAt"`
ReceivedAt time.Time `json:"receivedAt"`
}
type SCUMProjectionFreshnessBody struct {
Status string `json:"status"`
ObservationID string `json:"observationId,omitempty"`
Source string `json:"source,omitempty"`
QueryKey string `json:"queryKey,omitempty"`
Sequence uint64 `json:"sequence,omitempty"`
Checksum string `json:"checksum,omitempty"`
StaleReason string `json:"staleReason,omitempty"`
ObservedAt time.Time `json:"observedAt,omitempty"`
ReceivedAt time.Time `json:"receivedAt,omitempty"`
}
type SCUMMutationGuardBody struct {
FieldKey string `json:"fieldKey,omitempty"`
Before any `json:"before,omitempty"`
After any `json:"after,omitempty"`
MaxRowsAffected int `json:"maxRowsAffected,omitempty"`
SafetyWindow string `json:"safetyWindow,omitempty"`
BackupRef string `json:"backupRef,omitempty"`
RequiresOfflinePlayer bool `json:"requiresOfflinePlayer,omitempty"`
RequiresMaintenance bool `json:"requiresMaintenance,omitempty"`
RequiresBackup bool `json:"requiresBackup,omitempty"`
}
type SCUMOperationConfirmationBody struct {
Status string `json:"status,omitempty"`
ObservationID string `json:"observationId,omitempty"`
ConfirmedFields map[string]any `json:"confirmedFields,omitempty"`
AffectedRows int `json:"affectedRows,omitempty"`
MutationChecksum string `json:"mutationChecksum,omitempty"`
Checksum string `json:"checksum,omitempty"`
ObservedAt time.Time `json:"observedAt,omitempty"`
SafeSummary SCUMSafeSummaryBody `json:"safeSummary,omitempty"`
}
type SCUMOperationRequestBody struct {
TemplateKey string `json:"templateKey"`
PlayerID string `json:"playerId,omitempty"`
Payload map[string]any `json:"payload,omitempty"`
Guard SCUMMutationGuardBody `json:"guard,omitempty"`
Reason string `json:"reason"`
IdempotencyKey string `json:"idempotencyKey"`
}
type SCUMWorkflowCreateRequest struct {
TemplateKey string `json:"templateKey"`
IdempotencyKey string `json:"idempotencyKey"`
Input map[string]any `json:"input,omitempty"`
}
type SCUMOperationResponse struct {
ID string `json:"id"`
ServerInstanceID string `json:"serverInstanceId"`
PluginID string `json:"pluginId"`
TemplateKey string `json:"templateKey"`
PlayerID string `json:"playerId,omitempty"`
RequesterID string `json:"requesterId,omitempty"`
ApproverID string `json:"approverId,omitempty"`
ApprovalLevel string `json:"approvalLevel"`
Payload map[string]any `json:"payload,omitempty"`
Guard SCUMMutationGuardBody `json:"guard,omitempty"`
Confirmation SCUMOperationConfirmationBody `json:"confirmation,omitempty"`
Status string `json:"status"`
Reason string `json:"reason,omitempty"`
RunJobID string `json:"runJobId,omitempty"`
SafeSummary SCUMSafeSummaryBody `json:"safeSummary,omitempty"`
AuditReferences []string `json:"auditReferences,omitempty"`
CreatedAt time.Time `json:"createdAt"`
ApprovedAt time.Time `json:"approvedAt,omitempty"`
CompletedAt time.Time `json:"completedAt,omitempty"`
UpdatedAt time.Time `json:"updatedAt"`
}
type SCUMOperationListResponse struct {
Items []SCUMOperationResponse `json:"items"`
Count int `json:"count"`
}
type SCUMWorkflowResponse struct {
ID string `json:"id"`
ServerInstanceID string `json:"serverInstanceId"`
PluginID string `json:"pluginId"`
TemplateKey string `json:"templateKey"`
RequestedBy string `json:"requestedBy,omitempty"`
IdempotencyKey string `json:"idempotencyKey,omitempty"`
Status string `json:"status"`
CurrentStepKey string `json:"currentStepKey,omitempty"`
Input map[string]any `json:"input,omitempty"`
SafeSummary SCUMSafeSummaryBody `json:"safeSummary,omitempty"`
BlockerReason string `json:"blockerReason,omitempty"`
AuditReferences []string `json:"auditReferences,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
CompletedAt time.Time `json:"completedAt,omitempty"`
}
type SCUMWorkflowListResponse struct {
Items []SCUMWorkflowResponse `json:"items"`
Count int `json:"count"`
}
type SCUMWorkflowStepResponse struct {
ID string `json:"id"`
WorkflowID string `json:"workflowId"`
ServerInstanceID string `json:"serverInstanceId"`
StepKey string `json:"stepKey"`
DependsOn []string `json:"dependsOn,omitempty"`
Status string `json:"status"`
OperationKey string `json:"operationKey,omitempty"`
QueryTemplateKey string `json:"queryTemplateKey,omitempty"`
Capability string `json:"capability,omitempty"`
TargetKey string `json:"targetKey,omitempty"`
JobID string `json:"jobId,omitempty"`
Attempt int `json:"attempt,omitempty"`
MaxAttempts int `json:"maxAttempts,omitempty"`
MutatesState bool `json:"mutatesState,omitempty"`
Confirmation SCUMOperationConfirmationBody `json:"confirmation,omitempty"`
SafeSummary SCUMSafeSummaryBody `json:"safeSummary,omitempty"`
BlockerReason string `json:"blockerReason,omitempty"`
AuditReferences []string `json:"auditReferences,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
CompletedAt time.Time `json:"completedAt,omitempty"`
}
type SCUMWorkflowStepListResponse struct {
Items []SCUMWorkflowStepResponse `json:"items"`
Count int `json:"count"`
}
func SCUMSafeSummaryFromDomain(value domain.SCUMSafeSummary) SCUMSafeSummaryBody {
value = domain.CopySCUMSafeSummary(value)
return SCUMSafeSummaryBody{Title: value.Title, Message: value.Message, Details: value.Details}
}
func scumSafeSummaryToDomain(value SCUMSafeSummaryBody) domain.SCUMSafeSummary {
return domain.SCUMSafeSummary{Title: value.Title, Message: value.Message, Details: domain.CopyStringMap(value.Details)}
}
func SCUMDataObservationFromDomain(value domain.SCUMDataObservation) SCUMDataObservationResponse {
value = domain.CopySCUMDataObservation(value)
return SCUMDataObservationResponse{ID: value.ID, ServerInstanceID: value.ServerInstanceID, PluginID: value.PluginID, Source: value.Source, QueryKey: value.QueryKey, SubjectType: value.SubjectType, SubjectID: value.SubjectID, Sequence: value.Sequence, Checksum: value.Checksum, Status: string(value.Status), ErrorCode: value.ErrorCode, SafeSummary: SCUMSafeSummaryFromDomain(value.SafeSummary), ObservedAt: value.ObservedAt, ReceivedAt: value.ReceivedAt}
}
func SCUMProjectionFreshnessFromDomain(value domain.SCUMProjectionFreshnessState) SCUMProjectionFreshnessBody {
value = domain.CopySCUMProjectionFreshnessState(value)
return SCUMProjectionFreshnessBody{Status: string(value.Status), ObservationID: value.ObservationID, Source: value.Source, QueryKey: value.QueryKey, Sequence: value.Sequence, Checksum: value.Checksum, StaleReason: value.StaleReason, ObservedAt: value.ObservedAt, ReceivedAt: value.ReceivedAt}
}
func SCUMOperationRequestBodyToDomain(request SCUMOperationRequestBody) domain.SCUMOperationRequest {
return domain.SCUMOperationRequest{TemplateKey: request.TemplateKey, PlayerID: request.PlayerID, Payload: domain.CopyGameClientBridgePayload(request.Payload), Guard: scumMutationGuardToDomain(request.Guard), Reason: request.Reason, IdempotencyKey: request.IdempotencyKey}
}
func SCUMWorkflowCreateRequestToDomain(request SCUMWorkflowCreateRequest) domain.SCUMWorkflowInstance {
return domain.SCUMWorkflowInstance{TemplateKey: request.TemplateKey, IdempotencyKey: request.IdempotencyKey, Input: domain.CopyGameClientBridgePayload(request.Input)}
}
func SCUMOperationFromDomain(value domain.SCUMOperationRequest) SCUMOperationResponse {
value = domain.CopySCUMOperationRequest(value)
return SCUMOperationResponse{ID: value.ID, ServerInstanceID: value.ServerInstanceID, PluginID: value.PluginID, TemplateKey: value.TemplateKey, PlayerID: value.PlayerID, RequesterID: value.RequesterID, ApproverID: value.ApproverID, ApprovalLevel: string(value.ApprovalLevel), Payload: value.Payload, Guard: scumMutationGuardFromDomain(value.Guard), Confirmation: scumOperationConfirmationFromDomain(value.Confirmation), Status: string(value.Status), Reason: value.Reason, RunJobID: value.RunJobID, SafeSummary: SCUMSafeSummaryFromDomain(value.SafeSummary), AuditReferences: value.AuditReferences, CreatedAt: value.CreatedAt, ApprovedAt: value.ApprovedAt, CompletedAt: value.CompletedAt, UpdatedAt: value.UpdatedAt}
}
func SCUMOperationsFromDomain(values []domain.SCUMOperationRequest) SCUMOperationListResponse {
items := make([]SCUMOperationResponse, len(values))
for index, value := range values {
items[index] = SCUMOperationFromDomain(value)
}
return SCUMOperationListResponse{Items: items, Count: len(items)}
}
func SCUMWorkflowFromDomain(value domain.SCUMWorkflowInstance) SCUMWorkflowResponse {
value = domain.CopySCUMWorkflowInstance(value)
return SCUMWorkflowResponse{ID: value.ID, ServerInstanceID: value.ServerInstanceID, PluginID: value.PluginID, TemplateKey: value.TemplateKey, RequestedBy: value.RequestedBy, IdempotencyKey: value.IdempotencyKey, Status: string(value.Status), CurrentStepKey: value.CurrentStepKey, Input: value.Input, SafeSummary: SCUMSafeSummaryFromDomain(value.SafeSummary), BlockerReason: value.BlockerReason, AuditReferences: value.AuditReferences, CreatedAt: value.CreatedAt, UpdatedAt: value.UpdatedAt, CompletedAt: value.CompletedAt}
}
func SCUMWorkflowsFromDomain(values []domain.SCUMWorkflowInstance) SCUMWorkflowListResponse {
items := make([]SCUMWorkflowResponse, len(values))
for index, value := range values {
items[index] = SCUMWorkflowFromDomain(value)
}
return SCUMWorkflowListResponse{Items: items, Count: len(items)}
}
func SCUMWorkflowStepFromDomain(value domain.SCUMWorkflowStep) SCUMWorkflowStepResponse {
value = domain.CopySCUMWorkflowStep(value)
return SCUMWorkflowStepResponse{ID: value.ID, WorkflowID: value.WorkflowID, ServerInstanceID: value.ServerInstanceID, StepKey: value.StepKey, DependsOn: value.DependsOn, Status: string(value.Status), OperationKey: value.OperationKey, QueryTemplateKey: value.QueryTemplateKey, Capability: value.Capability, TargetKey: value.TargetKey, JobID: value.JobID, Attempt: value.Attempt, MaxAttempts: value.MaxAttempts, MutatesState: value.MutatesState, Confirmation: scumOperationConfirmationFromDomain(value.Confirmation), SafeSummary: SCUMSafeSummaryFromDomain(value.SafeSummary), BlockerReason: value.BlockerReason, AuditReferences: value.AuditReferences, CreatedAt: value.CreatedAt, UpdatedAt: value.UpdatedAt, CompletedAt: value.CompletedAt}
}
func SCUMWorkflowStepsFromDomain(values []domain.SCUMWorkflowStep) SCUMWorkflowStepListResponse {
items := make([]SCUMWorkflowStepResponse, len(values))
for index, value := range values {
items[index] = SCUMWorkflowStepFromDomain(value)
}
return SCUMWorkflowStepListResponse{Items: items, Count: len(items)}
}
func scumMutationGuardFromDomain(value domain.SCUMMutationGuard) SCUMMutationGuardBody {
return SCUMMutationGuardBody{FieldKey: value.FieldKey, Before: value.Before, After: value.After, MaxRowsAffected: value.MaxRowsAffected, SafetyWindow: value.SafetyWindow, BackupRef: value.BackupRef, RequiresOfflinePlayer: value.RequiresOfflinePlayer, RequiresMaintenance: value.RequiresMaintenance, RequiresBackup: value.RequiresBackup}
}
func scumMutationGuardToDomain(value SCUMMutationGuardBody) domain.SCUMMutationGuard {
return domain.SCUMMutationGuard{FieldKey: value.FieldKey, Before: value.Before, After: value.After, MaxRowsAffected: value.MaxRowsAffected, SafetyWindow: value.SafetyWindow, BackupRef: value.BackupRef, RequiresOfflinePlayer: value.RequiresOfflinePlayer, RequiresMaintenance: value.RequiresMaintenance, RequiresBackup: value.RequiresBackup}
}
func scumOperationConfirmationFromDomain(value domain.SCUMOperationConfirmation) SCUMOperationConfirmationBody {
value = domain.CopySCUMOperationConfirmation(value)
return SCUMOperationConfirmationBody{Status: value.Status, ObservationID: value.ObservationID, ConfirmedFields: value.ConfirmedFields, AffectedRows: value.AffectedRows, MutationChecksum: value.MutationChecksum, Checksum: value.Checksum, ObservedAt: value.ObservedAt, SafeSummary: SCUMSafeSummaryFromDomain(value.SafeSummary)}
}
func scumOperationConfirmationToDomain(value SCUMOperationConfirmationBody) domain.SCUMOperationConfirmation {
return domain.SCUMOperationConfirmation{Status: value.Status, ObservationID: value.ObservationID, ConfirmedFields: domain.CopyGameClientBridgePayload(value.ConfirmedFields), AffectedRows: value.AffectedRows, MutationChecksum: value.MutationChecksum, Checksum: value.Checksum, ObservedAt: value.ObservedAt, SafeSummary: scumSafeSummaryToDomain(value.SafeSummary)}
}
+2 -1
View File
@@ -67,11 +67,12 @@ Autonomous lifecycle reports use `POST /api/v1/run/lifecycle/report` with the ac
## Log Ingest
SCUM-specific read/write execution requirements are defined in `platform/protocol/scum-run-integration.md`. The implementation still belongs to the independent run repository and uses the generic signed job/log channels described here.
Implemented HTTP JSON routes:
- `POST /api/v1/run/logs/batches`
- `POST /api/v1/log-streams/query`
- `GET /api/v1/server-instances/{id}/logs/events`
Named log DTOs:
+67
View File
@@ -0,0 +1,67 @@
# SCUM Run Integration Contract
This repository defines the platform/plugin side of SCUM real-data operations. The executable machine-side implementation belongs in the independent `git@git.npc0.com:admin343/run.git` repository and must not be added here.
## Ownership Boundary
- Platform owns server instances, authorization, audit, local projections, typed operation/workflow records, idempotency, approval state, and safe browser APIs.
- The SCUM plugin owns query template keys, operation template keys, result schemas, safety rules, confirmation schemas, and lifecycle action assets.
- Run owns local machine execution beside the current SCUM service: locating the declared logical SCUM.db/log/RCON targets from its scoped package, executing bounded jobs, and returning typed results through existing signed job channels.
Run must never send host paths, DSNs, sockets, credentials, raw SQL, raw RCON text, or protected request bodies to browser/product APIs. Platform persists only safe job metadata, projection rows, checksums, confirmation summaries, and audit references.
## Read Observation Jobs
Run must implement plugin-declared SQLite read templates for the current server binding and return rows matching the referenced schema files under `plugins/examples/scum-server-plugin/schemas/bridge/queries/`.
Required template keys:
| Key | Required behavior |
| --- | --- |
| `scum.player.profile` | Read player identity, profile ID, optional Steam/user ID, character/prisoner fields, economy balances, squad summary, and current coordinates where available. |
| `scum.squads` | Read squad IDs, names, leader/profile references, and bounded member counts. |
| `scum.squad-members` | Read roster membership, ranks, player/profile references, and unknown fields without fabricating missing identities. |
| `scum.vehicles` | Read vehicle/entity rows and coordinates; unknown class/name mappings remain unknown. |
| `scum.flags` | Read base flag/entity ownership, squad/player confidence, and coordinates where available. |
| `scum.positions` | Read current player, vehicle, and flag coordinate projections. |
Each successful result must include the server binding, template key, observed time, monotonically comparable sequence, row count within manifest bounds, and `sha256:<hex>` checksum. Failures must return safe error codes such as missing database, locked database, schema mismatch, timeout, or row-bound exceeded; platform will mark affected projections stale while keeping last-known-good records.
Login/logout evidence comes from plugin-declared log sources. A login line can create/update a local player/session projection; `last_save_time` is only freshness evidence and must not be treated as online-state proof by itself.
## Controlled Write Jobs
Run must execute only typed operations declared by the SCUM plugin manifest.
| Operation key | Transport | Required behavior |
| --- | --- | --- |
| `player.fame.set` | RCON | Use the declared command template for fame and confirm through follow-up readback. |
| `player.currency.normal.set` | RCON | Use the declared command template for normal currency and confirm through follow-up readback. |
| `player.currency.gold.set` | RCON | Use the declared command template for gold and confirm through follow-up readback. |
| `player.notify` | RCON/declared notification command | Deliver bounded player notification text and report unknown if delivery cannot be proven. |
| `reward.deliver` | Declared reward transport | Deliver catalogued reward/notification only once per idempotency key and confirmation state. |
| `player.attribute.855.set` | SQLite mutation | Execute the declared DB-only mutation with before-value guard, max affected rows = 1, maintenance/offline evidence, backup/snapshot reference, and confirmation query. |
RCON-supported fame/currency writes must not be converted to DB mutations. DB-only mutations must fail safely when the current value differs from the approved `before` value, the affected row bound is exceeded, backup evidence is missing, or the player safety state is online/unknown.
## Result And Confirmation Contract
Run job results for SCUM reads, RCON writes, and SQLite mutations must return:
- `kind` identifying the declared result type.
- `checksum` as `sha256:<64 hex chars>`.
- Bounded JSON content matching the plugin result/confirmation schema.
- `affectedRows` for mutations and zero/one row confirmation details where applicable.
- A safe audit summary that excludes raw SQL, raw RCON text, SCUM.db paths, host paths, tokens, sockets, and credentials.
If execution may have happened but confirmation is missing, run should report an unknown/pending-confirmation state rather than success. Platform will read back before retrying so gifts, currency, fame, and DB fields are not duplicated or overwritten.
## External Run Tasks
The independent run repository needs implementation work for:
1. Resolve package-scoped logical SCUM.db and log targets from the generated run plan without exposing resolved host paths to Platform Web.
2. Execute the six declared SQLite read templates with row/time bounds and schema-compatible JSON rows.
3. Execute typed RCON operation templates for fame, currency, notification, and reward delivery without accepting arbitrary browser command text.
4. Execute `player.attribute.855.set` through a guarded SQLite mutation with backup, maintenance/offline checks, before-value match, affected-row bound, and confirmation read.
5. Report observation failures and write unknown states with safe codes and checksums so platform projections and workflows can reconcile deterministically.
+2 -4
View File
@@ -51,15 +51,13 @@ A server instance is created from one installed game management plugin and is la
- `POST /api/v1/server-instances/workflows/create` validates an installed plugin, server name, idempotency key, and plugin-declared create inputs when provided. It creates the instance without requiring a deployment target, run endpoint, or runtime profile. Generated Run packages carry the autonomous lifecycle plan that Run consumes on startup; registration confirms binding/auth and does not enqueue bootstrap lifecycle jobs.
- `POST /api/v1/server-instances/{id}/start` validates the instance is `ready` or `stopped`, checks the expected config version, verifies the plugin start action and run endpoint `process.start` capability, and queues a start job.
- `POST /api/v1/server-instances/{id}/stop` validates the instance is `running`, checks the expected config version, verifies the plugin stop action and run endpoint `process.stop` capability, and queues a stop job.
- `GET /api/v1/server-instances/{id}/config` returns logical read-only config content for an authorized server instance with config version, format, key, source, and update timestamp metadata.
- `POST /api/v1/server-instances/{id}/config/diff` validates an authorized proposed config write against the current config version and returns a bounded platform diff without queuing work.
- `POST /api/v1/server-instances/{id}/config/approve` revalidates an explicitly reviewed config diff and queues a scoped `config.write` job using a logical config key and input ref.
- Server-scoped raw config read/diff/approve routes are not product APIs. AI-assisted configuration uses reviewable AI config-diff approvals and typed dispatch metadata without returning raw config text to plugin pages.
- `POST /api/v1/file-operations/dispatch` queues scoped `files.read` or `files.write` jobs for logical server/plugin file keys after role and permission checks.
- `GET /api/v1/metrics/server-instances` returns bounded per-server metrics for instances visible to the authenticated user.
Workflow route responses include the accepted action, bounded server instance metadata, and bounded job metadata. They do not expose run session tokens, host paths, raw credentials, direct sockets, AI provider keys, or plugin action file contents.
Config read and server metrics responses are also bounded and platform-mediated. They do not expose host filesystem paths, run sockets, raw credentials, direct storage backends, or AI provider keys.
Server metrics responses are bounded and platform-mediated. They do not expose host filesystem paths, run sockets, raw credentials, direct storage backends, or AI provider keys.
Config write approval and file dispatch are platform-mediated. They carry logical keys such as `server.properties` or `logs/latest.log`, scoped refs such as `input://...` or `artifact://...`, and bounded job metadata only. They do not mutate local files in the platform process and do not expose raw host paths, run credentials, direct sockets, AI provider keys, or inline large payloads.
+51 -1
View File
@@ -53,6 +53,16 @@ type StoreSnapshot struct {
GameGiftCatalogs []domain.GameGiftCatalog `json:"gameGiftCatalogs"`
GameGiftRevisions []domain.GameGiftRevision `json:"gameGiftRevisions"`
GameGiftGrants []domain.GameGiftGrant `json:"gameGiftGrants"`
SCUMDataObservations []domain.SCUMDataObservation `json:"scumDataObservations"`
SCUMPlayerLiveStates []domain.SCUMPlayerLiveState `json:"scumPlayerLiveStates"`
SCUMSquads []domain.SCUMSquad `json:"scumSquads"`
SCUMSquadMembers []domain.SCUMSquadMember `json:"scumSquadMembers"`
SCUMVehicles []domain.SCUMVehicle `json:"scumVehicles"`
SCUMFlags []domain.SCUMFlag `json:"scumFlags"`
SCUMCurrentPositions []domain.SCUMCurrentPosition `json:"scumCurrentPositions"`
SCUMOperationRequests []domain.SCUMOperationRequest `json:"scumOperationRequests"`
SCUMWorkflowInstances []domain.SCUMWorkflowInstance `json:"scumWorkflowInstances"`
SCUMWorkflowSteps []domain.SCUMWorkflowStep `json:"scumWorkflowSteps"`
}
type FileStore struct {
@@ -234,6 +244,36 @@ func (store *FileStore) GameGiftRevisions() GameGiftRevisionRepository {
func (store *FileStore) GameGiftGrants() GameGiftGrantRepository {
return &persistentRepository[domain.GameGiftGrant, domain.GameGiftGrantFilter]{repository: store.MemoryStore.gameGiftGrants, persist: store.persist}
}
func (store *FileStore) SCUMDataObservations() SCUMDataObservationRepository {
return &persistentRepository[domain.SCUMDataObservation, domain.SCUMProjectionFilter]{repository: store.MemoryStore.scumDataObservations, persist: store.persist}
}
func (store *FileStore) SCUMPlayerLiveStates() SCUMPlayerLiveStateRepository {
return &persistentRepository[domain.SCUMPlayerLiveState, domain.SCUMProjectionFilter]{repository: store.MemoryStore.scumPlayerLiveStates, persist: store.persist}
}
func (store *FileStore) SCUMSquads() SCUMSquadRepository {
return &persistentRepository[domain.SCUMSquad, domain.SCUMProjectionFilter]{repository: store.MemoryStore.scumSquads, persist: store.persist}
}
func (store *FileStore) SCUMSquadMembers() SCUMSquadMemberRepository {
return &persistentRepository[domain.SCUMSquadMember, domain.SCUMProjectionFilter]{repository: store.MemoryStore.scumSquadMembers, persist: store.persist}
}
func (store *FileStore) SCUMVehicles() SCUMVehicleRepository {
return &persistentRepository[domain.SCUMVehicle, domain.SCUMProjectionFilter]{repository: store.MemoryStore.scumVehicles, persist: store.persist}
}
func (store *FileStore) SCUMFlags() SCUMFlagRepository {
return &persistentRepository[domain.SCUMFlag, domain.SCUMProjectionFilter]{repository: store.MemoryStore.scumFlags, persist: store.persist}
}
func (store *FileStore) SCUMCurrentPositions() SCUMCurrentPositionRepository {
return &persistentRepository[domain.SCUMCurrentPosition, domain.SCUMProjectionFilter]{repository: store.MemoryStore.scumCurrentPositions, persist: store.persist}
}
func (store *FileStore) SCUMOperationRequests() SCUMOperationRequestRepository {
return &persistentRepository[domain.SCUMOperationRequest, domain.SCUMOperationRequestFilter]{repository: store.MemoryStore.scumOperationRequests, persist: store.persist}
}
func (store *FileStore) SCUMWorkflowInstances() SCUMWorkflowInstanceRepository {
return &persistentRepository[domain.SCUMWorkflowInstance, domain.SCUMWorkflowInstanceFilter]{repository: store.MemoryStore.scumWorkflowInstances, persist: store.persist}
}
func (store *FileStore) SCUMWorkflowSteps() SCUMWorkflowStepRepository {
return &persistentRepository[domain.SCUMWorkflowStep, domain.SCUMWorkflowStepFilter]{repository: store.MemoryStore.scumWorkflowSteps, persist: store.persist}
}
func (store *FileStore) load() error {
data, err := os.ReadFile(store.path)
@@ -307,7 +347,7 @@ func (store *FileStore) snapshot() StoreSnapshot {
GameClientBridgeCommands: snapshotRepository(store.MemoryStore.bridgeCommands.memoryRepository),
GameClientBridgeSnapshots: snapshotRepository(store.MemoryStore.bridgeSnapshots.memoryRepository),
GameClientBridgeStreams: snapshotRepository(store.MemoryStore.bridgeStreams),
GamePlayers: snapshotRepository(store.MemoryStore.gamePlayers), GamePlayerAliases: snapshotRepository(store.MemoryStore.gamePlayerAliases), GamePlayerSessions: snapshotRepository(store.MemoryStore.gamePlayerSessions), GameAccessAttempts: snapshotRepository(store.MemoryStore.gameAccessAttempts), GameSecuritySignals: snapshotRepository(store.MemoryStore.gameSecuritySignals), GamePlayerStatePatches: snapshotRepository(store.MemoryStore.gamePlayerStatePatches), GameMapTrackPoints: snapshotRepository(store.MemoryStore.gameMapTrackPoints), GamePlayerVehicleSegments: snapshotRepository(store.MemoryStore.gamePlayerVehicleSegments), GameGiftCatalogs: snapshotRepository(store.MemoryStore.gameGiftCatalogs), GameGiftRevisions: snapshotRepository(store.MemoryStore.gameGiftRevisions), GameGiftGrants: snapshotRepository(store.MemoryStore.gameGiftGrants),
GamePlayers: snapshotRepository(store.MemoryStore.gamePlayers), GamePlayerAliases: snapshotRepository(store.MemoryStore.gamePlayerAliases), GamePlayerSessions: snapshotRepository(store.MemoryStore.gamePlayerSessions), GameAccessAttempts: snapshotRepository(store.MemoryStore.gameAccessAttempts), GameSecuritySignals: snapshotRepository(store.MemoryStore.gameSecuritySignals), GamePlayerStatePatches: snapshotRepository(store.MemoryStore.gamePlayerStatePatches), GameMapTrackPoints: snapshotRepository(store.MemoryStore.gameMapTrackPoints), GamePlayerVehicleSegments: snapshotRepository(store.MemoryStore.gamePlayerVehicleSegments), GameGiftCatalogs: snapshotRepository(store.MemoryStore.gameGiftCatalogs), GameGiftRevisions: snapshotRepository(store.MemoryStore.gameGiftRevisions), GameGiftGrants: snapshotRepository(store.MemoryStore.gameGiftGrants), SCUMDataObservations: snapshotRepository(store.MemoryStore.scumDataObservations), SCUMPlayerLiveStates: snapshotRepository(store.MemoryStore.scumPlayerLiveStates), SCUMSquads: snapshotRepository(store.MemoryStore.scumSquads), SCUMSquadMembers: snapshotRepository(store.MemoryStore.scumSquadMembers), SCUMVehicles: snapshotRepository(store.MemoryStore.scumVehicles), SCUMFlags: snapshotRepository(store.MemoryStore.scumFlags), SCUMCurrentPositions: snapshotRepository(store.MemoryStore.scumCurrentPositions), SCUMOperationRequests: snapshotRepository(store.MemoryStore.scumOperationRequests), SCUMWorkflowInstances: snapshotRepository(store.MemoryStore.scumWorkflowInstances), SCUMWorkflowSteps: snapshotRepository(store.MemoryStore.scumWorkflowSteps),
}
}
@@ -352,6 +392,16 @@ func (store *FileStore) loadSnapshot(snapshot StoreSnapshot) {
loadRepository(store.MemoryStore.gameGiftCatalogs, snapshot.GameGiftCatalogs)
loadRepository(store.MemoryStore.gameGiftRevisions, snapshot.GameGiftRevisions)
loadRepository(store.MemoryStore.gameGiftGrants, snapshot.GameGiftGrants)
loadRepository(store.MemoryStore.scumDataObservations, snapshot.SCUMDataObservations)
loadRepository(store.MemoryStore.scumPlayerLiveStates, snapshot.SCUMPlayerLiveStates)
loadRepository(store.MemoryStore.scumSquads, snapshot.SCUMSquads)
loadRepository(store.MemoryStore.scumSquadMembers, snapshot.SCUMSquadMembers)
loadRepository(store.MemoryStore.scumVehicles, snapshot.SCUMVehicles)
loadRepository(store.MemoryStore.scumFlags, snapshot.SCUMFlags)
loadRepository(store.MemoryStore.scumCurrentPositions, snapshot.SCUMCurrentPositions)
loadRepository(store.MemoryStore.scumOperationRequests, snapshot.SCUMOperationRequests)
loadRepository(store.MemoryStore.scumWorkflowInstances, snapshot.SCUMWorkflowInstances)
loadRepository(store.MemoryStore.scumWorkflowSteps, snapshot.SCUMWorkflowSteps)
}
type mutableRepository[T any, F any] interface {
+41 -1
View File
@@ -204,6 +204,36 @@ func (store *MySQLStore) GameGiftRevisions() GameGiftRevisionRepository {
func (store *MySQLStore) GameGiftGrants() GameGiftGrantRepository {
return &persistentRepository[domain.GameGiftGrant, domain.GameGiftGrantFilter]{repository: store.MemoryStore.gameGiftGrants, persist: store.persist}
}
func (store *MySQLStore) SCUMDataObservations() SCUMDataObservationRepository {
return &persistentRepository[domain.SCUMDataObservation, domain.SCUMProjectionFilter]{repository: store.MemoryStore.scumDataObservations, persist: store.persist}
}
func (store *MySQLStore) SCUMPlayerLiveStates() SCUMPlayerLiveStateRepository {
return &persistentRepository[domain.SCUMPlayerLiveState, domain.SCUMProjectionFilter]{repository: store.MemoryStore.scumPlayerLiveStates, persist: store.persist}
}
func (store *MySQLStore) SCUMSquads() SCUMSquadRepository {
return &persistentRepository[domain.SCUMSquad, domain.SCUMProjectionFilter]{repository: store.MemoryStore.scumSquads, persist: store.persist}
}
func (store *MySQLStore) SCUMSquadMembers() SCUMSquadMemberRepository {
return &persistentRepository[domain.SCUMSquadMember, domain.SCUMProjectionFilter]{repository: store.MemoryStore.scumSquadMembers, persist: store.persist}
}
func (store *MySQLStore) SCUMVehicles() SCUMVehicleRepository {
return &persistentRepository[domain.SCUMVehicle, domain.SCUMProjectionFilter]{repository: store.MemoryStore.scumVehicles, persist: store.persist}
}
func (store *MySQLStore) SCUMFlags() SCUMFlagRepository {
return &persistentRepository[domain.SCUMFlag, domain.SCUMProjectionFilter]{repository: store.MemoryStore.scumFlags, persist: store.persist}
}
func (store *MySQLStore) SCUMCurrentPositions() SCUMCurrentPositionRepository {
return &persistentRepository[domain.SCUMCurrentPosition, domain.SCUMProjectionFilter]{repository: store.MemoryStore.scumCurrentPositions, persist: store.persist}
}
func (store *MySQLStore) SCUMOperationRequests() SCUMOperationRequestRepository {
return &persistentRepository[domain.SCUMOperationRequest, domain.SCUMOperationRequestFilter]{repository: store.MemoryStore.scumOperationRequests, persist: store.persist}
}
func (store *MySQLStore) SCUMWorkflowInstances() SCUMWorkflowInstanceRepository {
return &persistentRepository[domain.SCUMWorkflowInstance, domain.SCUMWorkflowInstanceFilter]{repository: store.MemoryStore.scumWorkflowInstances, persist: store.persist}
}
func (store *MySQLStore) SCUMWorkflowSteps() SCUMWorkflowStepRepository {
return &persistentRepository[domain.SCUMWorkflowStep, domain.SCUMWorkflowStepFilter]{repository: store.MemoryStore.scumWorkflowSteps, persist: store.persist}
}
func (store *MySQLStore) initialize() error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
@@ -294,7 +324,7 @@ func (store *MySQLStore) snapshot() StoreSnapshot {
GameClientBridgeCommands: snapshotRepository(store.MemoryStore.bridgeCommands.memoryRepository),
GameClientBridgeSnapshots: snapshotRepository(store.MemoryStore.bridgeSnapshots.memoryRepository),
GameClientBridgeStreams: snapshotRepository(store.MemoryStore.bridgeStreams),
GamePlayers: snapshotRepository(store.MemoryStore.gamePlayers), GamePlayerAliases: snapshotRepository(store.MemoryStore.gamePlayerAliases), GamePlayerSessions: snapshotRepository(store.MemoryStore.gamePlayerSessions), GameAccessAttempts: snapshotRepository(store.MemoryStore.gameAccessAttempts), GameSecuritySignals: snapshotRepository(store.MemoryStore.gameSecuritySignals), GamePlayerStatePatches: snapshotRepository(store.MemoryStore.gamePlayerStatePatches), GameMapTrackPoints: snapshotRepository(store.MemoryStore.gameMapTrackPoints), GamePlayerVehicleSegments: snapshotRepository(store.MemoryStore.gamePlayerVehicleSegments), GameGiftCatalogs: snapshotRepository(store.MemoryStore.gameGiftCatalogs), GameGiftRevisions: snapshotRepository(store.MemoryStore.gameGiftRevisions), GameGiftGrants: snapshotRepository(store.MemoryStore.gameGiftGrants),
GamePlayers: snapshotRepository(store.MemoryStore.gamePlayers), GamePlayerAliases: snapshotRepository(store.MemoryStore.gamePlayerAliases), GamePlayerSessions: snapshotRepository(store.MemoryStore.gamePlayerSessions), GameAccessAttempts: snapshotRepository(store.MemoryStore.gameAccessAttempts), GameSecuritySignals: snapshotRepository(store.MemoryStore.gameSecuritySignals), GamePlayerStatePatches: snapshotRepository(store.MemoryStore.gamePlayerStatePatches), GameMapTrackPoints: snapshotRepository(store.MemoryStore.gameMapTrackPoints), GamePlayerVehicleSegments: snapshotRepository(store.MemoryStore.gamePlayerVehicleSegments), GameGiftCatalogs: snapshotRepository(store.MemoryStore.gameGiftCatalogs), GameGiftRevisions: snapshotRepository(store.MemoryStore.gameGiftRevisions), GameGiftGrants: snapshotRepository(store.MemoryStore.gameGiftGrants), SCUMDataObservations: snapshotRepository(store.MemoryStore.scumDataObservations), SCUMPlayerLiveStates: snapshotRepository(store.MemoryStore.scumPlayerLiveStates), SCUMSquads: snapshotRepository(store.MemoryStore.scumSquads), SCUMSquadMembers: snapshotRepository(store.MemoryStore.scumSquadMembers), SCUMVehicles: snapshotRepository(store.MemoryStore.scumVehicles), SCUMFlags: snapshotRepository(store.MemoryStore.scumFlags), SCUMCurrentPositions: snapshotRepository(store.MemoryStore.scumCurrentPositions), SCUMOperationRequests: snapshotRepository(store.MemoryStore.scumOperationRequests), SCUMWorkflowInstances: snapshotRepository(store.MemoryStore.scumWorkflowInstances), SCUMWorkflowSteps: snapshotRepository(store.MemoryStore.scumWorkflowSteps),
}
}
@@ -339,4 +369,14 @@ func (store *MySQLStore) loadSnapshot(snapshot StoreSnapshot) {
loadRepository(store.MemoryStore.gameGiftCatalogs, snapshot.GameGiftCatalogs)
loadRepository(store.MemoryStore.gameGiftRevisions, snapshot.GameGiftRevisions)
loadRepository(store.MemoryStore.gameGiftGrants, snapshot.GameGiftGrants)
loadRepository(store.MemoryStore.scumDataObservations, snapshot.SCUMDataObservations)
loadRepository(store.MemoryStore.scumPlayerLiveStates, snapshot.SCUMPlayerLiveStates)
loadRepository(store.MemoryStore.scumSquads, snapshot.SCUMSquads)
loadRepository(store.MemoryStore.scumSquadMembers, snapshot.SCUMSquadMembers)
loadRepository(store.MemoryStore.scumVehicles, snapshot.SCUMVehicles)
loadRepository(store.MemoryStore.scumFlags, snapshot.SCUMFlags)
loadRepository(store.MemoryStore.scumCurrentPositions, snapshot.SCUMCurrentPositions)
loadRepository(store.MemoryStore.scumOperationRequests, snapshot.SCUMOperationRequests)
loadRepository(store.MemoryStore.scumWorkflowInstances, snapshot.SCUMWorkflowInstances)
loadRepository(store.MemoryStore.scumWorkflowSteps, snapshot.SCUMWorkflowSteps)
}
+211
View File
@@ -295,6 +295,66 @@ type GameGiftGrantRepository interface {
List(domain.GameGiftGrantFilter) ([]domain.GameGiftGrant, error)
Update(domain.GameGiftGrant) error
}
type SCUMDataObservationRepository interface {
Create(domain.SCUMDataObservation) error
Get(string) (domain.SCUMDataObservation, error)
List(domain.SCUMProjectionFilter) ([]domain.SCUMDataObservation, error)
Update(domain.SCUMDataObservation) error
}
type SCUMPlayerLiveStateRepository interface {
Create(domain.SCUMPlayerLiveState) error
Get(string) (domain.SCUMPlayerLiveState, error)
List(domain.SCUMProjectionFilter) ([]domain.SCUMPlayerLiveState, error)
Update(domain.SCUMPlayerLiveState) error
}
type SCUMSquadRepository interface {
Create(domain.SCUMSquad) error
Get(string) (domain.SCUMSquad, error)
List(domain.SCUMProjectionFilter) ([]domain.SCUMSquad, error)
Update(domain.SCUMSquad) error
}
type SCUMSquadMemberRepository interface {
Create(domain.SCUMSquadMember) error
Get(string) (domain.SCUMSquadMember, error)
List(domain.SCUMProjectionFilter) ([]domain.SCUMSquadMember, error)
Update(domain.SCUMSquadMember) error
}
type SCUMVehicleRepository interface {
Create(domain.SCUMVehicle) error
Get(string) (domain.SCUMVehicle, error)
List(domain.SCUMProjectionFilter) ([]domain.SCUMVehicle, error)
Update(domain.SCUMVehicle) error
}
type SCUMFlagRepository interface {
Create(domain.SCUMFlag) error
Get(string) (domain.SCUMFlag, error)
List(domain.SCUMProjectionFilter) ([]domain.SCUMFlag, error)
Update(domain.SCUMFlag) error
}
type SCUMCurrentPositionRepository interface {
Create(domain.SCUMCurrentPosition) error
Get(string) (domain.SCUMCurrentPosition, error)
List(domain.SCUMProjectionFilter) ([]domain.SCUMCurrentPosition, error)
Update(domain.SCUMCurrentPosition) error
}
type SCUMOperationRequestRepository interface {
Create(domain.SCUMOperationRequest) error
Get(string) (domain.SCUMOperationRequest, error)
List(domain.SCUMOperationRequestFilter) ([]domain.SCUMOperationRequest, error)
Update(domain.SCUMOperationRequest) error
}
type SCUMWorkflowInstanceRepository interface {
Create(domain.SCUMWorkflowInstance) error
Get(string) (domain.SCUMWorkflowInstance, error)
List(domain.SCUMWorkflowInstanceFilter) ([]domain.SCUMWorkflowInstance, error)
Update(domain.SCUMWorkflowInstance) error
}
type SCUMWorkflowStepRepository interface {
Create(domain.SCUMWorkflowStep) error
Get(string) (domain.SCUMWorkflowStep, error)
List(domain.SCUMWorkflowStepFilter) ([]domain.SCUMWorkflowStep, error)
Update(domain.SCUMWorkflowStep) error
}
type Store interface {
Users() UserRepository
@@ -337,6 +397,16 @@ type Store interface {
GameGiftCatalogs() GameGiftCatalogRepository
GameGiftRevisions() GameGiftRevisionRepository
GameGiftGrants() GameGiftGrantRepository
SCUMDataObservations() SCUMDataObservationRepository
SCUMPlayerLiveStates() SCUMPlayerLiveStateRepository
SCUMSquads() SCUMSquadRepository
SCUMSquadMembers() SCUMSquadMemberRepository
SCUMVehicles() SCUMVehicleRepository
SCUMFlags() SCUMFlagRepository
SCUMCurrentPositions() SCUMCurrentPositionRepository
SCUMOperationRequests() SCUMOperationRequestRepository
SCUMWorkflowInstances() SCUMWorkflowInstanceRepository
SCUMWorkflowSteps() SCUMWorkflowStepRepository
}
type MemoryStore struct {
@@ -380,6 +450,16 @@ type MemoryStore struct {
gameGiftCatalogs *memoryRepository[domain.GameGiftCatalog, domain.GameGiftCatalogFilter]
gameGiftRevisions *memoryRepository[domain.GameGiftRevision, domain.GameGiftRevisionFilter]
gameGiftGrants *memoryRepository[domain.GameGiftGrant, domain.GameGiftGrantFilter]
scumDataObservations *memoryRepository[domain.SCUMDataObservation, domain.SCUMProjectionFilter]
scumPlayerLiveStates *memoryRepository[domain.SCUMPlayerLiveState, domain.SCUMProjectionFilter]
scumSquads *memoryRepository[domain.SCUMSquad, domain.SCUMProjectionFilter]
scumSquadMembers *memoryRepository[domain.SCUMSquadMember, domain.SCUMProjectionFilter]
scumVehicles *memoryRepository[domain.SCUMVehicle, domain.SCUMProjectionFilter]
scumFlags *memoryRepository[domain.SCUMFlag, domain.SCUMProjectionFilter]
scumCurrentPositions *memoryRepository[domain.SCUMCurrentPosition, domain.SCUMProjectionFilter]
scumOperationRequests *memoryRepository[domain.SCUMOperationRequest, domain.SCUMOperationRequestFilter]
scumWorkflowInstances *memoryRepository[domain.SCUMWorkflowInstance, domain.SCUMWorkflowInstanceFilter]
scumWorkflowSteps *memoryRepository[domain.SCUMWorkflowStep, domain.SCUMWorkflowStepFilter]
}
func NewMemoryStore() *MemoryStore {
@@ -528,6 +608,16 @@ func NewMemoryStore() *MemoryStore {
gameGiftCatalogs: newMemoryRepository(func(v domain.GameGiftCatalog) string { return v.ID }, domain.CopyGameGiftCatalog, matchGameGiftCatalog),
gameGiftRevisions: newMemoryRepository(func(v domain.GameGiftRevision) string { return v.ID }, domain.CopyGameGiftRevision, matchGameGiftRevision),
gameGiftGrants: newMemoryRepository(func(v domain.GameGiftGrant) string { return v.ID }, domain.CopyGameGiftGrant, matchGameGiftGrant),
scumDataObservations: newMemoryRepository(func(v domain.SCUMDataObservation) string { return v.ID }, domain.CopySCUMDataObservation, matchSCUMDataObservation),
scumPlayerLiveStates: newMemoryRepository(func(v domain.SCUMPlayerLiveState) string { return v.ID }, domain.CopySCUMPlayerLiveState, matchSCUMPlayerLiveState),
scumSquads: newMemoryRepository(func(v domain.SCUMSquad) string { return v.ID }, domain.CopySCUMSquad, matchSCUMSquad),
scumSquadMembers: newMemoryRepository(func(v domain.SCUMSquadMember) string { return v.ID }, domain.CopySCUMSquadMember, matchSCUMSquadMember),
scumVehicles: newMemoryRepository(func(v domain.SCUMVehicle) string { return v.ID }, domain.CopySCUMVehicle, matchSCUMVehicle),
scumFlags: newMemoryRepository(func(v domain.SCUMFlag) string { return v.ID }, domain.CopySCUMFlag, matchSCUMFlag),
scumCurrentPositions: newMemoryRepository(func(v domain.SCUMCurrentPosition) string { return v.ID }, domain.CopySCUMCurrentPosition, matchSCUMCurrentPosition),
scumOperationRequests: newMemoryRepository(func(v domain.SCUMOperationRequest) string { return v.ID }, domain.CopySCUMOperationRequest, matchSCUMOperationRequest),
scumWorkflowInstances: newMemoryRepository(func(v domain.SCUMWorkflowInstance) string { return v.ID }, domain.CopySCUMWorkflowInstance, matchSCUMWorkflowInstance),
scumWorkflowSteps: newMemoryRepository(func(v domain.SCUMWorkflowStep) string { return v.ID }, domain.CopySCUMWorkflowStep, matchSCUMWorkflowStep),
}
}
@@ -607,6 +697,30 @@ func (store *MemoryStore) GameGiftRevisions() GameGiftRevisionRepository {
return store.gameGiftRevisions
}
func (store *MemoryStore) GameGiftGrants() GameGiftGrantRepository { return store.gameGiftGrants }
func (store *MemoryStore) SCUMDataObservations() SCUMDataObservationRepository {
return store.scumDataObservations
}
func (store *MemoryStore) SCUMPlayerLiveStates() SCUMPlayerLiveStateRepository {
return store.scumPlayerLiveStates
}
func (store *MemoryStore) SCUMSquads() SCUMSquadRepository { return store.scumSquads }
func (store *MemoryStore) SCUMSquadMembers() SCUMSquadMemberRepository {
return store.scumSquadMembers
}
func (store *MemoryStore) SCUMVehicles() SCUMVehicleRepository { return store.scumVehicles }
func (store *MemoryStore) SCUMFlags() SCUMFlagRepository { return store.scumFlags }
func (store *MemoryStore) SCUMCurrentPositions() SCUMCurrentPositionRepository {
return store.scumCurrentPositions
}
func (store *MemoryStore) SCUMOperationRequests() SCUMOperationRequestRepository {
return store.scumOperationRequests
}
func (store *MemoryStore) SCUMWorkflowInstances() SCUMWorkflowInstanceRepository {
return store.scumWorkflowInstances
}
func (store *MemoryStore) SCUMWorkflowSteps() SCUMWorkflowStepRepository {
return store.scumWorkflowSteps
}
type memoryRepository[T any, F any] struct {
mu sync.RWMutex
@@ -941,3 +1055,100 @@ func matchGameGiftRevision(v domain.GameGiftRevision, f domain.GameGiftRevisionF
func matchGameGiftGrant(v domain.GameGiftGrant, f domain.GameGiftGrantFilter) bool {
return (f.ServerInstanceID == "" || v.ServerInstanceID == f.ServerInstanceID) && (f.GamePlayerRecordID == "" || v.GamePlayerRecordID == f.GamePlayerRecordID) && (f.IdempotencyKey == "" || v.IdempotencyKey == f.IdempotencyKey)
}
func matchSCUMDataObservation(v domain.SCUMDataObservation, f domain.SCUMProjectionFilter) bool {
return (f.ServerInstanceID == "" || v.ServerInstanceID == f.ServerInstanceID) &&
(f.SubjectType == "" || v.SubjectType == string(f.SubjectType)) &&
(f.GamePlayerRecordID == "" || v.SubjectID == f.GamePlayerRecordID) &&
(f.QueryKey == "" || v.QueryKey == f.QueryKey) &&
(f.Freshness == "" || domain.SCUMProjectionFreshness(v.Status) == f.Freshness)
}
func matchSCUMPlayerLiveState(v domain.SCUMPlayerLiveState, f domain.SCUMProjectionFilter) bool {
search := strings.ToLower(strings.TrimSpace(f.Search))
return (f.ServerInstanceID == "" || v.ServerInstanceID == f.ServerInstanceID) &&
(f.GamePlayerID == "" || v.GamePlayerID == f.GamePlayerID) &&
(f.GamePlayerRecordID == "" || v.GamePlayerRecordID == f.GamePlayerRecordID) &&
(f.UserProfileID == "" || v.UserProfileID == f.UserProfileID) &&
(f.SteamID == "" || v.SteamID == f.SteamID) &&
(f.SquadID == "" || v.SquadID == f.SquadID) &&
(f.Freshness == "" || v.Freshness.Status == f.Freshness) &&
(search == "" || strings.Contains(strings.ToLower(v.DisplayName), search) || strings.Contains(strings.ToLower(v.GamePlayerID), search) || strings.Contains(strings.ToLower(v.UserProfileID), search) || strings.Contains(strings.ToLower(v.SteamID), search))
}
func matchSCUMSquad(v domain.SCUMSquad, f domain.SCUMProjectionFilter) bool {
search := strings.ToLower(strings.TrimSpace(f.Search))
return (f.ServerInstanceID == "" || v.ServerInstanceID == f.ServerInstanceID) &&
(f.SquadID == "" || v.SquadID == f.SquadID) &&
(f.UserProfileID == "" || v.LeaderProfileID == f.UserProfileID) &&
(f.Freshness == "" || v.Freshness.Status == f.Freshness) &&
(search == "" || strings.Contains(strings.ToLower(v.Name), search) || strings.Contains(strings.ToLower(v.SquadID), search))
}
func matchSCUMSquadMember(v domain.SCUMSquadMember, f domain.SCUMProjectionFilter) bool {
search := strings.ToLower(strings.TrimSpace(f.Search))
return (f.ServerInstanceID == "" || v.ServerInstanceID == f.ServerInstanceID) &&
(f.SquadID == "" || v.SquadID == f.SquadID) &&
(f.GamePlayerID == "" || v.GamePlayerID == f.GamePlayerID) &&
(f.GamePlayerRecordID == "" || v.GamePlayerRecordID == f.GamePlayerRecordID) &&
(f.UserProfileID == "" || v.UserProfileID == f.UserProfileID) &&
(f.SteamID == "" || v.SteamID == f.SteamID) &&
(f.Freshness == "" || v.Freshness.Status == f.Freshness) &&
(search == "" || strings.Contains(strings.ToLower(v.DisplayName), search) || strings.Contains(strings.ToLower(v.GamePlayerID), search) || strings.Contains(strings.ToLower(v.UserProfileID), search))
}
func matchSCUMVehicle(v domain.SCUMVehicle, f domain.SCUMProjectionFilter) bool {
search := strings.ToLower(strings.TrimSpace(f.Search))
return (f.ServerInstanceID == "" || v.ServerInstanceID == f.ServerInstanceID) &&
(f.VehicleID == "" || v.VehicleID == f.VehicleID) &&
(f.UserProfileID == "" || v.OwnerProfileID == f.UserProfileID) &&
(f.GamePlayerID == "" || v.OwnerPlayerID == f.GamePlayerID) &&
(f.SquadID == "" || v.SquadID == f.SquadID) &&
(f.Freshness == "" || v.Freshness.Status == f.Freshness) &&
(search == "" || strings.Contains(strings.ToLower(v.Label), search) || strings.Contains(strings.ToLower(v.ClassName), search) || strings.Contains(strings.ToLower(v.VehicleID), search))
}
func matchSCUMFlag(v domain.SCUMFlag, f domain.SCUMProjectionFilter) bool {
return (f.ServerInstanceID == "" || v.ServerInstanceID == f.ServerInstanceID) &&
(f.FlagID == "" || v.FlagID == f.FlagID) &&
(f.UserProfileID == "" || v.OwnerProfileID == f.UserProfileID) &&
(f.GamePlayerID == "" || v.OwnerPlayerID == f.GamePlayerID) &&
(f.SquadID == "" || v.OwnerSquadID == f.SquadID) &&
(f.Freshness == "" || v.Freshness.Status == f.Freshness)
}
func matchSCUMCurrentPosition(v domain.SCUMCurrentPosition, f domain.SCUMProjectionFilter) bool {
return (f.ServerInstanceID == "" || v.ServerInstanceID == f.ServerInstanceID) &&
(f.SubjectType == "" || v.SubjectType == f.SubjectType) &&
(f.GamePlayerID == "" || v.GamePlayerID == f.GamePlayerID) &&
(f.GamePlayerRecordID == "" || v.GamePlayerRecordID == f.GamePlayerRecordID) &&
(f.VehicleID == "" || v.VehicleID == f.VehicleID) &&
(f.Freshness == "" || v.Freshness.Status == f.Freshness)
}
func matchSCUMOperationRequest(v domain.SCUMOperationRequest, f domain.SCUMOperationRequestFilter) bool {
return (f.ServerInstanceID == "" || v.ServerInstanceID == f.ServerInstanceID) &&
(f.PluginID == "" || v.PluginID == f.PluginID) &&
(f.TemplateKey == "" || v.TemplateKey == f.TemplateKey) &&
(f.PlayerID == "" || v.PlayerID == f.PlayerID) &&
(f.RequesterID == "" || v.RequesterID == f.RequesterID) &&
(f.Status == "" || v.Status == f.Status) &&
(f.IdempotencyKey == "" || v.IdempotencyKey == f.IdempotencyKey)
}
func matchSCUMWorkflowInstance(v domain.SCUMWorkflowInstance, f domain.SCUMWorkflowInstanceFilter) bool {
return (f.ServerInstanceID == "" || v.ServerInstanceID == f.ServerInstanceID) &&
(f.PluginID == "" || v.PluginID == f.PluginID) &&
(f.TemplateKey == "" || v.TemplateKey == f.TemplateKey) &&
(f.RequestedBy == "" || v.RequestedBy == f.RequestedBy) &&
(f.Status == "" || v.Status == f.Status) &&
(f.IdempotencyKey == "" || v.IdempotencyKey == f.IdempotencyKey)
}
func matchSCUMWorkflowStep(v domain.SCUMWorkflowStep, f domain.SCUMWorkflowStepFilter) bool {
return (f.WorkflowID == "" || v.WorkflowID == f.WorkflowID) &&
(f.ServerInstanceID == "" || v.ServerInstanceID == f.ServerInstanceID) &&
(f.StepKey == "" || v.StepKey == f.StepKey) &&
(f.Status == "" || v.Status == f.Status) &&
(f.MutatesState == nil || v.MutatesState == *f.MutatesState)
}
+67
View File
@@ -0,0 +1,67 @@
package repo
import (
"path/filepath"
"testing"
"time"
"browser.local/platform/domain"
)
func TestSCUMProjectionRepositoriesCopyFilterAndPersist(t *testing.T) {
path := filepath.Join(t.TempDir(), "metadata.json")
store, err := NewFileStore(path)
if err != nil {
t.Fatalf("create file store: %v", err)
}
stamp := time.Date(2026, 8, 10, 9, 0, 0, 0, time.UTC)
freshness := domain.SCUMProjectionFreshnessState{Status: domain.SCUMProjectionFresh, ObservationID: "obs-1", Source: "run", QueryKey: "scum.player.profile", Sequence: 7, Checksum: "sha256:projection", ObservedAt: stamp, ReceivedAt: stamp.Add(time.Second)}
state := domain.SCUMPlayerLiveState{ID: "state-1", ServerInstanceID: "server-1", GamePlayerRecordID: "game-player-1", GamePlayerID: "steam-1", UserProfileID: "profile-1", SteamID: "steam-1", DisplayName: "Moon", SquadID: "squad-1", UnknownFields: map[string]any{"schemaField": "kept"}, Freshness: freshness, CreatedAt: stamp, UpdatedAt: stamp}
if err := store.SCUMPlayerLiveStates().Create(state); err != nil {
t.Fatalf("create state: %v", err)
}
got, err := store.SCUMPlayerLiveStates().Get(state.ID)
if err != nil {
t.Fatalf("get state: %v", err)
}
got.UnknownFields["schemaField"] = "mutated"
again, err := store.SCUMPlayerLiveStates().Get(state.ID)
if err != nil {
t.Fatalf("get state again: %v", err)
}
if again.UnknownFields["schemaField"] != "kept" {
t.Fatalf("state was not copy-isolated: %+v", again.UnknownFields)
}
filtered, err := store.SCUMPlayerLiveStates().List(domain.SCUMProjectionFilter{ServerInstanceID: "server-1", UserProfileID: "profile-1", Search: "moon"})
if err != nil || len(filtered) != 1 {
t.Fatalf("filter states=%+v err=%v", filtered, err)
}
if err := store.SCUMSquads().Create(domain.SCUMSquad{ID: "squad-1", ServerInstanceID: "server-1", SquadID: "squad-1", Name: "Crystal", Freshness: freshness}); err != nil {
t.Fatalf("create squad: %v", err)
}
if err := store.SCUMVehicles().Create(domain.SCUMVehicle{ID: "vehicle-1", ServerInstanceID: "server-1", VehicleID: "veh-1", Label: "Unknown vehicle", Freshness: freshness}); err != nil {
t.Fatalf("create vehicle: %v", err)
}
if err := store.SCUMFlags().Create(domain.SCUMFlag{ID: "flag-1", ServerInstanceID: "server-1", FlagID: "flag-1", OwnerSquadID: "squad-1", Freshness: freshness}); err != nil {
t.Fatalf("create flag: %v", err)
}
if err := store.SCUMCurrentPositions().Create(domain.SCUMCurrentPosition{ID: "position-1", ServerInstanceID: "server-1", SubjectType: domain.SCUMProjectionSubjectPlayer, SubjectID: "steam-1", GamePlayerRecordID: "game-player-1", X: 1, Y: 2, HasCoordinates: true, Freshness: freshness}); err != nil {
t.Fatalf("create position: %v", err)
}
reloaded, err := NewFileStore(path)
if err != nil {
t.Fatalf("reload file store: %v", err)
}
reloadedStates, err := reloaded.SCUMPlayerLiveStates().List(domain.SCUMProjectionFilter{ServerInstanceID: "server-1", SquadID: "squad-1"})
if err != nil || len(reloadedStates) != 1 || reloadedStates[0].Freshness.QueryKey != "scum.player.profile" {
t.Fatalf("unexpected reloaded states=%+v err=%v", reloadedStates, err)
}
vehicles, err := reloaded.SCUMVehicles().List(domain.SCUMProjectionFilter{ServerInstanceID: "server-1", Search: "unknown"})
if err != nil || len(vehicles) != 1 {
t.Fatalf("unexpected reloaded vehicles=%+v err=%v", vehicles, err)
}
positions, err := reloaded.SCUMCurrentPositions().List(domain.SCUMProjectionFilter{ServerInstanceID: "server-1", SubjectType: domain.SCUMProjectionSubjectPlayer})
if err != nil || len(positions) != 1 || !positions[0].HasCoordinates {
t.Fatalf("unexpected reloaded positions=%+v err=%v", positions, err)
}
}
+6
View File
@@ -125,6 +125,9 @@ func (svc *CoreService) projectGamePlayerEvent(batch domain.LogBatchIngest, entr
if eventType == "scum.login" {
outcome := strings.TrimSpace(fields["outcome"])
if outcome == "accepted" {
if err := svc.projectSCUMLoginLiveState(player, batch, entry, occurred, true, ""); err != nil {
return err
}
if err := svc.recordSuccessfulGameAccess(player, batch, entry, occurred, strings.TrimSpace(fields["networkFingerprint"])); err != nil {
return err
}
@@ -132,6 +135,9 @@ func (svc *CoreService) projectGamePlayerEvent(batch domain.LogBatchIngest, entr
}
return svc.recordFailedGameAccess(player, batch, entry, occurred, strings.TrimSpace(fields["networkFingerprint"]))
}
if err := svc.projectSCUMLoginLiveState(player, batch, entry, occurred, false, strings.TrimSpace(fields["reason"])); err != nil {
return err
}
return svc.closeGamePlayerSession(player, sourceSession, occurred, strings.TrimSpace(fields["reason"]))
}
+14
View File
@@ -225,6 +225,20 @@ type Core interface {
RequestGameGiftGrantForSession(string, string, domain.GameGiftGrantRequest) (domain.GameGiftGrant, error)
ApproveGameGiftGrantForSession(string, string) (domain.GameGiftGrant, error)
ListGameGiftGrantsForSession(string, string) ([]domain.GameGiftGrant, error)
ListSCUMPlayerLiveStatesForSession(string, domain.SCUMProjectionFilter) ([]domain.SCUMPlayerLiveState, error)
ListSCUMSquadsForSession(string, domain.SCUMProjectionFilter) ([]domain.SCUMSquad, error)
ListSCUMSquadMembersForSession(string, domain.SCUMProjectionFilter) ([]domain.SCUMSquadMember, error)
ListSCUMVehiclesForSession(string, domain.SCUMProjectionFilter) ([]domain.SCUMVehicle, error)
ListSCUMFlagsForSession(string, domain.SCUMProjectionFilter) ([]domain.SCUMFlag, error)
ListSCUMCurrentPositionsForSession(string, domain.SCUMProjectionFilter) ([]domain.SCUMCurrentPosition, error)
RequestSCUMOperationForSession(string, string, domain.SCUMOperationRequest) (domain.SCUMOperationRequest, error)
ListSCUMOperationsForSession(string, domain.SCUMOperationRequestFilter) ([]domain.SCUMOperationRequest, error)
ApproveSCUMOperationForSession(string, string) (domain.SCUMOperationRequest, error)
ReconcileSCUMOperation(string) (domain.SCUMOperationRequest, error)
ConfirmSCUMOperation(string, domain.SCUMOperationConfirmation) (domain.SCUMOperationRequest, error)
CreateSCUMWorkflowForSession(string, string, domain.SCUMWorkflowInstance) (domain.SCUMWorkflowInstance, error)
ListSCUMWorkflowsForSession(string, domain.SCUMWorkflowInstanceFilter) ([]domain.SCUMWorkflowInstance, error)
ListSCUMWorkflowStepsForSession(string, domain.SCUMWorkflowStepFilter) ([]domain.SCUMWorkflowStep, error)
CreateAuditEvent(domain.AuditEvent) (domain.AuditEvent, error)
GetAuditEvent(string) (domain.AuditEvent, error)
ListAuditEvents(domain.AuditEventFilter) ([]domain.AuditEvent, error)
+770
View File
@@ -0,0 +1,770 @@
package service
import (
"encoding/json"
"fmt"
"strconv"
"strings"
"browser.local/platform/domain"
"browser.local/platform/repo"
)
type scumSQLiteMutationJobResult struct {
Outcome string `json:"outcome"`
AffectedRows int `json:"affectedRows"`
MutationChecksum string `json:"mutationChecksum"`
ConfirmationRows []map[string]any `json:"confirmationRows"`
SafeMessage string `json:"safeMessage"`
}
func (svc *CoreService) RequestSCUMOperationForSession(sessionID, serverID string, request domain.SCUMOperationRequest) (domain.SCUMOperationRequest, error) {
request = domain.CopySCUMOperationRequest(request)
user, err := svc.GetCurrentUser(sessionID)
if err != nil {
return domain.SCUMOperationRequest{}, err
}
if err := svc.authorizeServerLifecycle(sessionID, serverID); err != nil {
return domain.SCUMOperationRequest{}, err
}
instance, err := svc.store.ServerInstances().Get(serverID)
if err != nil {
return domain.SCUMOperationRequest{}, err
}
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
if err != nil {
return domain.SCUMOperationRequest{}, err
}
template, ok := scumOperationTemplate(plugin, request.TemplateKey)
if !ok {
return domain.SCUMOperationRequest{}, validationError("SCUM operation template is not declared")
}
if !containsString(plugin.DeclaredPermissions, template.Permission) {
return domain.SCUMOperationRequest{}, validationError("SCUM operation permission is not declared")
}
if strings.TrimSpace(request.IdempotencyKey) == "" || len(request.IdempotencyKey) > 120 {
return domain.SCUMOperationRequest{}, validationError("operation idempotency key is required")
}
existing, err := svc.store.SCUMOperationRequests().List(domain.SCUMOperationRequestFilter{ServerInstanceID: serverID, IdempotencyKey: request.IdempotencyKey})
if err != nil {
return domain.SCUMOperationRequest{}, err
}
if len(existing) > 0 {
return domain.CopySCUMOperationRequest(existing[0]), nil
}
playerID := coalesceString(request.PlayerID, firstString(request.Payload, "playerId", "steamId"))
if playerID == "" && request.TemplateKey != "server.reward.command.deliver" {
return domain.SCUMOperationRequest{}, validationError("operation playerId is required")
}
summary := operationSafeSummary(request.TemplateKey, playerID, request.Payload)
switch template.Kind {
case domain.GameClientBridgeOperationKindRCON:
if err := validateSCUMRCONOperationPayload(request.TemplateKey, playerID, request.Payload); err != nil {
return domain.SCUMOperationRequest{}, err
}
case domain.GameClientBridgeOperationKindSQLiteMutation:
guard, payload, err := normalizeSCUMSQLiteMutationRequest(template, playerID, request.Payload, request.Guard)
if err != nil {
return domain.SCUMOperationRequest{}, err
}
request.Guard = guard
request.Payload = payload
summary = scumSQLiteMutationSafeSummary(request.TemplateKey, playerID, guard)
default:
return domain.SCUMOperationRequest{}, validationError("SCUM operation kind is unsupported")
}
stamp := svc.now()
operation := domain.SCUMOperationRequest{ID: "scum-operation-" + fingerprintID(serverID, request.IdempotencyKey), ServerInstanceID: serverID, PluginID: instance.PluginID, TemplateKey: request.TemplateKey, PlayerID: playerID, RequesterID: user.ID, ApprovalLevel: template.ApprovalLevel, Payload: domain.CopyGameClientBridgePayload(request.Payload), Guard: request.Guard, Status: domain.SCUMWorkflowStepWaiting, Reason: bounded(request.Reason, 240), IdempotencyKey: request.IdempotencyKey, SafeSummary: summary, CreatedAt: stamp, UpdatedAt: stamp}
if err := svc.store.SCUMOperationRequests().Create(operation); err != nil {
return domain.SCUMOperationRequest{}, err
}
_, err = svc.recordAuditEventWithID(user.ID, "scum.operation.request", "scum-operation", operation.ID, domain.AuditResultQueued, "typed SCUM operation awaiting approval")
return domain.CopySCUMOperationRequest(operation), err
}
func (svc *CoreService) ListSCUMOperationsForSession(sessionID string, filter domain.SCUMOperationRequestFilter) ([]domain.SCUMOperationRequest, error) {
if err := svc.authorizeServerLifecycle(sessionID, filter.ServerInstanceID); err != nil {
return nil, err
}
values, err := svc.store.SCUMOperationRequests().List(filter)
if err != nil {
return nil, err
}
limitSCUMProjectionSlice(&values, filter.Limit)
return values, nil
}
func (svc *CoreService) ApproveSCUMOperationForSession(sessionID, operationID string) (domain.SCUMOperationRequest, error) {
operation, err := svc.store.SCUMOperationRequests().Get(operationID)
if err != nil {
return domain.SCUMOperationRequest{}, err
}
user, err := svc.GetCurrentUser(sessionID)
if err != nil {
return domain.SCUMOperationRequest{}, err
}
if err := svc.authorizeServerLifecycle(sessionID, operation.ServerInstanceID); err != nil {
return domain.SCUMOperationRequest{}, err
}
if operation.ApprovalLevel == domain.GameClientBridgeApprovalLevelPlatformAdmin && !isPlatformAdmin(user) {
return domain.SCUMOperationRequest{}, ErrForbidden
}
if operation.Status != domain.SCUMWorkflowStepWaiting {
return domain.SCUMOperationRequest{}, validationError("SCUM operation is not awaiting approval")
}
instance, err := svc.store.ServerInstances().Get(operation.ServerInstanceID)
if err != nil {
return domain.SCUMOperationRequest{}, err
}
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
if err != nil {
return domain.SCUMOperationRequest{}, err
}
template, ok := scumOperationTemplate(plugin, operation.TemplateKey)
if !ok {
return domain.SCUMOperationRequest{}, validationError("SCUM operation template is not declared")
}
var jobID string
var auditSummary string
switch template.Kind {
case domain.GameClientBridgeOperationKindRCON:
request, err := svc.sourceRCONRequestForSCUMOperation(operation)
if err != nil {
return domain.SCUMOperationRequest{}, err
}
dispatch, err := svc.DispatchSourceRCONCommandForSession(sessionID, request)
if err != nil {
return domain.SCUMOperationRequest{}, err
}
jobID = dispatch.JobID
auditSummary = "typed SCUM operation dispatched through transient RCON input"
case domain.GameClientBridgeOperationKindSQLiteMutation:
gated, ready, err := svc.applySCUMSQLiteMutationApprovalGate(operation, template)
if err != nil || !ready {
return gated, err
}
operation = gated
job, err := svc.dispatchSCUMSQLiteMutationOperation(operation, template)
if err != nil {
return domain.SCUMOperationRequest{}, err
}
jobID = job.ID
auditSummary = "typed SCUM DB mutation dispatched through template-bound Run job"
default:
return domain.SCUMOperationRequest{}, validationError("SCUM operation kind is unsupported")
}
stamp := svc.now()
operation.ApproverID = user.ID
operation.ApprovedAt = stamp
operation.Status = domain.SCUMWorkflowStepQueued
operation.RunJobID = jobID
operation.UpdatedAt = stamp
operation.AuditReferences = append(operation.AuditReferences, "job:"+jobID)
if err := svc.store.SCUMOperationRequests().Update(operation); err != nil {
return domain.SCUMOperationRequest{}, err
}
_, err = svc.recordAuditEventWithID(user.ID, "scum.operation.approve", "scum-operation", operation.ID, domain.AuditResultQueued, auditSummary)
return domain.CopySCUMOperationRequest(operation), err
}
func (svc *CoreService) ReconcileSCUMOperation(operationID string) (domain.SCUMOperationRequest, error) {
operation, err := svc.store.SCUMOperationRequests().Get(operationID)
if err != nil {
return domain.SCUMOperationRequest{}, err
}
if strings.TrimSpace(operation.RunJobID) == "" {
return domain.CopySCUMOperationRequest(operation), nil
}
job, err := svc.store.Jobs().Get(operation.RunJobID)
if err != nil {
return domain.SCUMOperationRequest{}, err
}
instance, err := svc.store.ServerInstances().Get(operation.ServerInstanceID)
if err != nil {
return domain.SCUMOperationRequest{}, err
}
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
if err != nil {
return domain.SCUMOperationRequest{}, err
}
template, _ := scumOperationTemplate(plugin, operation.TemplateKey)
stamp := svc.now()
switch job.State {
case domain.JobStateSucceeded:
if template.Kind == domain.GameClientBridgeOperationKindSQLiteMutation {
if updated, terminal := reconcileSCUMSQLiteMutationJobResult(operation, template, job); terminal {
operation = updated
} else {
operation = updated
operation.Status = domain.SCUMWorkflowStepConfirming
}
} else if operation.Confirmation.Status == "confirmed" {
operation.Status = domain.SCUMWorkflowStepConfirmed
} else {
operation.Status = domain.SCUMWorkflowStepConfirming
}
case domain.JobStateFailed:
if strings.Contains(strings.ToLower(job.ExecutionResult.Kind), "unknown") || strings.Contains(strings.ToLower(job.ExecutionResult.AuditSummary), "unknown") {
operation.Status = domain.SCUMWorkflowStepUnknown
} else {
operation.Status = domain.SCUMWorkflowStepFailed
}
operation.CompletedAt = stamp
case domain.JobStateCancelled:
operation.Status = domain.SCUMWorkflowStepUnknown
operation.CompletedAt = stamp
}
operation.UpdatedAt = stamp
if (operation.Status == domain.SCUMWorkflowStepConfirmed || operation.Status == domain.SCUMWorkflowStepFailed || operation.Status == domain.SCUMWorkflowStepUnknown) && operation.CompletedAt.IsZero() {
operation.CompletedAt = stamp
}
if err := svc.store.SCUMOperationRequests().Update(operation); err != nil {
return domain.SCUMOperationRequest{}, err
}
return domain.CopySCUMOperationRequest(operation), nil
}
func (svc *CoreService) ConfirmSCUMOperation(operationID string, confirmation domain.SCUMOperationConfirmation) (domain.SCUMOperationRequest, error) {
operation, err := svc.store.SCUMOperationRequests().Get(operationID)
if err != nil {
return domain.SCUMOperationRequest{}, err
}
instance, err := svc.store.ServerInstances().Get(operation.ServerInstanceID)
if err != nil {
return domain.SCUMOperationRequest{}, err
}
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
if err != nil {
return domain.SCUMOperationRequest{}, err
}
template, _ := scumOperationTemplate(plugin, operation.TemplateKey)
confirmation = domain.CopySCUMOperationConfirmation(confirmation)
stamp := svc.now()
if confirmation.Status != "confirmed" {
operation.Status = domain.SCUMWorkflowStepFailed
operation.Confirmation = confirmation
operation.CompletedAt = stamp
operation.UpdatedAt = stamp
if err := svc.store.SCUMOperationRequests().Update(operation); err != nil {
return domain.SCUMOperationRequest{}, err
}
return domain.CopySCUMOperationRequest(operation), nil
}
if template.Kind == domain.GameClientBridgeOperationKindSQLiteMutation && !scumSQLiteMutationConfirmationMatches(operation, confirmation.ConfirmedFields) {
confirmation.Status = "failed"
confirmation.SafeSummary = domain.SCUMSafeSummary{Title: "DB mutation confirmation mismatch", Message: "Run readback did not prove the requested SCUM player field value."}
operation.Status = domain.SCUMWorkflowStepFailed
operation.Confirmation = confirmation
operation.CompletedAt = stamp
operation.UpdatedAt = stamp
if err := svc.store.SCUMOperationRequests().Update(operation); err != nil {
return domain.SCUMOperationRequest{}, err
}
return domain.CopySCUMOperationRequest(operation), nil
}
operation.Confirmation = confirmation
operation.Status = domain.SCUMWorkflowStepConfirmed
operation.CompletedAt = stamp
operation.UpdatedAt = stamp
if err := svc.store.SCUMOperationRequests().Update(operation); err != nil {
return domain.SCUMOperationRequest{}, err
}
return domain.CopySCUMOperationRequest(operation), nil
}
func (svc *CoreService) sourceRCONRequestForSCUMOperation(operation domain.SCUMOperationRequest) (domain.SourceRCONCommandRequest, error) {
command, chat, err := scumRCONCommandForOperation(operation)
if err != nil {
return domain.SourceRCONCommandRequest{}, err
}
request := domain.SourceRCONCommandRequest{ServerInstanceID: operation.ServerInstanceID, IdempotencyKey: "scum-operation-" + operation.IdempotencyKey}
if chat != "" {
request.Kind = domain.SourceRCONCommandKindChat
request.ChatType = 4
request.TargetSteamID = operation.PlayerID
request.Message = chat
return request, nil
}
request.Kind = domain.SourceRCONCommandKindCommand
request.Command = command
return request, nil
}
func scumRCONCommandForOperation(operation domain.SCUMOperationRequest) (command string, chat string, err error) {
playerID := operation.PlayerID
switch operation.TemplateKey {
case "player.fame.set":
amount, ok := operationInteger(operation.Payload, "fame", "amount", "value")
if !ok {
return "", "", validationError("fame amount is required")
}
return fmt.Sprintf("#SetFamePoints %d %q", amount, playerID), "", nil
case "player.currency.normal.set":
amount, ok := operationInteger(operation.Payload, "amount", "balance", "normalBalance")
if !ok {
return "", "", validationError("normal currency amount is required")
}
return fmt.Sprintf("#SetCurrencyBalance Normal %d %q", amount, playerID), "", nil
case "player.currency.gold.set":
amount, ok := operationInteger(operation.Payload, "amount", "balance", "goldBalance")
if !ok {
return "", "", validationError("gold currency amount is required")
}
return fmt.Sprintf("#SetCurrencyBalance Gold %d %q", amount, playerID), "", nil
case "player.notify":
message := strings.TrimSpace(firstString(operation.Payload, "message", "notice"))
if message == "" || len(message) > 200 {
return "", "", validationError("notification message is required")
}
return "", message, nil
default:
return "", "", validationError("unsupported SCUM RCON operation template")
}
}
func validateSCUMRCONOperationPayload(templateKey, playerID string, payload map[string]any) error {
operation := domain.SCUMOperationRequest{TemplateKey: templateKey, PlayerID: playerID, Payload: payload}
command, chat, err := scumRCONCommandForOperation(operation)
if err != nil {
return err
}
if strings.ContainsAny(command, "\r\n") || strings.ContainsAny(chat, "\r\n") {
return validationError("operation payload contains invalid control characters")
}
return nil
}
func normalizeSCUMSQLiteMutationRequest(template domain.GameClientBridgeOperationTemplateDeclaration, playerID string, payload map[string]any, guard domain.SCUMMutationGuard) (domain.SCUMMutationGuard, map[string]any, error) {
lowerKey := strings.ToLower(template.Key)
if strings.Contains(lowerKey, "fame") || strings.Contains(lowerKey, "currency") {
return domain.SCUMMutationGuard{}, nil, validationError("SCUM fame and currency edits must use RCON operation templates")
}
if template.ApprovalLevel != domain.GameClientBridgeApprovalLevelPlatformAdmin {
return domain.SCUMMutationGuard{}, nil, validationError("SCUM DB mutation requires platform-admin approval")
}
if template.Mutation.FieldKey == "" || template.Mutation.ConfirmationQueryKey == "" || template.Mutation.TableKey == "" || template.Mutation.IdentityKey == "" || template.Mutation.ValueKey == "" {
return domain.SCUMMutationGuard{}, nil, validationError("SCUM DB mutation metadata is incomplete")
}
if template.MaxRowsAffected < 1 {
return domain.SCUMMutationGuard{}, nil, validationError("SCUM DB mutation row bound is required")
}
payload = domain.CopyGameClientBridgePayload(payload)
if guard.FieldKey == "" {
guard.FieldKey = coalesceString(firstString(payload, "fieldKey"), template.Mutation.FieldKey)
}
if guard.Before == nil {
guard.Before = payload["before"]
}
if guard.After == nil {
guard.After = payload["after"]
if guard.After == nil {
guard.After = payload["value"]
}
}
if guard.MaxRowsAffected == 0 {
guard.MaxRowsAffected = template.MaxRowsAffected
}
guard.SafetyWindow = coalesceString(guard.SafetyWindow, firstString(payload, "safetyWindow", "maintenanceWindow"))
guard.BackupRef = coalesceString(guard.BackupRef, firstString(payload, "backupRef", "snapshotRef"))
guard.RequiresOfflinePlayer = template.Safety.RequiresOfflinePlayer
guard.RequiresMaintenance = template.Safety.RequiresMaintenanceWindow
guard.RequiresBackup = template.Safety.BackupRequired
if playerID == "" {
return domain.SCUMMutationGuard{}, nil, validationError("SCUM DB mutation playerId is required")
}
if guard.FieldKey != template.Mutation.FieldKey {
return domain.SCUMMutationGuard{}, nil, validationError("SCUM DB mutation field key does not match template")
}
if guard.Before == nil || guard.After == nil {
return domain.SCUMMutationGuard{}, nil, validationError("SCUM DB mutation before and after values are required")
}
if guard.MaxRowsAffected < 1 || guard.MaxRowsAffected > template.MaxRowsAffected {
return domain.SCUMMutationGuard{}, nil, validationError("SCUM DB mutation maxRowsAffected exceeds template bound")
}
if err := validateSCUMMutationValue(template, guard.Before, "before"); err != nil {
return domain.SCUMMutationGuard{}, nil, err
}
if err := validateSCUMMutationValue(template, guard.After, "after"); err != nil {
return domain.SCUMMutationGuard{}, nil, err
}
for key, value := range map[string]any{"playerId": playerID, "fieldKey": guard.FieldKey, "before": guard.Before, "after": guard.After, "safetyWindow": guard.SafetyWindow, "backupRef": guard.BackupRef} {
if value != nil && value != "" {
payload[key] = value
}
}
return guard, payload, nil
}
func validateSCUMMutationValue(template domain.GameClientBridgeOperationTemplateDeclaration, value any, label string) error {
switch template.Mutation.AllowedValueType {
case "integer":
parsed, ok := anyInt64(value)
if !ok {
return validationError("SCUM DB mutation " + label + " value must be an integer")
}
if template.Mutation.MinValue != 0 && float64(parsed) < template.Mutation.MinValue || template.Mutation.MaxValue != 0 && float64(parsed) > template.Mutation.MaxValue {
return validationError("SCUM DB mutation " + label + " value is outside the template range")
}
case "number":
parsed, ok := anyFloat64(value)
if !ok {
return validationError("SCUM DB mutation " + label + " value must be numeric")
}
if template.Mutation.MinValue != 0 && parsed < template.Mutation.MinValue || template.Mutation.MaxValue != 0 && parsed > template.Mutation.MaxValue {
return validationError("SCUM DB mutation " + label + " value is outside the template range")
}
case "string":
if strings.TrimSpace(fmt.Sprint(value)) == "" || strings.ContainsAny(fmt.Sprint(value), "\r\n") {
return validationError("SCUM DB mutation " + label + " value is invalid")
}
case "boolean":
if _, ok := value.(bool); !ok {
return validationError("SCUM DB mutation " + label + " value must be boolean")
}
default:
return validationError("SCUM DB mutation value type is unsupported")
}
return nil
}
func (svc *CoreService) applySCUMSQLiteMutationApprovalGate(operation domain.SCUMOperationRequest, template domain.GameClientBridgeOperationTemplateDeclaration) (domain.SCUMOperationRequest, bool, error) {
state, err := svc.latestSCUMPlayerLiveState(operation.ServerInstanceID, operation.PlayerID)
if err != nil {
if err == repo.ErrNotFound {
return svc.updateSCUMOperationGate(operation, domain.SCUMWorkflowStepWaiting, "等待真实玩家投影", "需要先从当前服务的登录日志或 SCUM.db 读取玩家数据。")
}
return domain.SCUMOperationRequest{}, false, err
}
if state.Freshness.Status != domain.SCUMProjectionFresh {
return svc.updateSCUMOperationGate(operation, domain.SCUMWorkflowStepWaiting, "等待新鲜投影", "玩家投影不是 fresh,需先刷新 SCUM.db/readback。")
}
if template.Safety.RequiresOfflinePlayer && state.Online {
return svc.updateSCUMOperationGate(operation, domain.SCUMWorkflowStepWaiting, "等待玩家离线", "DB-only 玩家字段修改必须等玩家离线或进入维护窗口。")
}
if template.Safety.RequiresMaintenanceWindow && strings.TrimSpace(operation.Guard.SafetyWindow) == "" {
return svc.updateSCUMOperationGate(operation, domain.SCUMWorkflowStepWaiting, "缺少维护窗口", "DB mutation 需要记录维护窗口/离线安全证据。")
}
if template.Safety.BackupRequired && strings.TrimSpace(operation.Guard.BackupRef) == "" {
return svc.updateSCUMOperationGate(operation, domain.SCUMWorkflowStepWaiting, "缺少备份快照", "DB mutation 需要 run 或管理员提供 backup/snapshot evidence。")
}
current, ok := scumCurrentMutationFieldValue(state, operation.Guard.FieldKey)
if !ok {
return svc.updateSCUMOperationGate(operation, domain.SCUMWorkflowStepWaiting, "等待字段读回", "当前投影没有该 DB-only 字段,需先执行确认查询。")
}
if !scumScalarEqual(current, operation.Guard.Before) {
return svc.updateSCUMOperationGate(operation, domain.SCUMWorkflowStepBlocked, "before value 已过期", "当前投影值与审批时 before guard 不一致,已阻止写入。")
}
return domain.CopySCUMOperationRequest(operation), true, nil
}
func (svc *CoreService) updateSCUMOperationGate(operation domain.SCUMOperationRequest, status domain.SCUMWorkflowStepStatus, title string, message string) (domain.SCUMOperationRequest, bool, error) {
operation.Status = status
operation.SafeSummary = domain.SCUMSafeSummary{Title: title, Message: message, Details: map[string]string{"template": operation.TemplateKey, "playerId": operation.PlayerID}}
operation.UpdatedAt = svc.now()
if err := svc.store.SCUMOperationRequests().Update(operation); err != nil {
return domain.SCUMOperationRequest{}, false, err
}
return domain.CopySCUMOperationRequest(operation), false, nil
}
func (svc *CoreService) latestSCUMPlayerLiveState(serverID, playerID string) (domain.SCUMPlayerLiveState, error) {
states, err := svc.store.SCUMPlayerLiveStates().List(domain.SCUMProjectionFilter{ServerInstanceID: serverID, GamePlayerID: playerID})
if err != nil {
return domain.SCUMPlayerLiveState{}, err
}
if len(states) == 0 {
states, err = svc.store.SCUMPlayerLiveStates().List(domain.SCUMProjectionFilter{ServerInstanceID: serverID, SteamID: playerID})
if err != nil {
return domain.SCUMPlayerLiveState{}, err
}
}
if len(states) == 0 {
return domain.SCUMPlayerLiveState{}, repo.ErrNotFound
}
best := states[0]
for _, state := range states[1:] {
if state.Freshness.ObservedAt.After(best.Freshness.ObservedAt) || state.UpdatedAt.After(best.UpdatedAt) {
best = state
}
}
return domain.CopySCUMPlayerLiveState(best), nil
}
func scumCurrentMutationFieldValue(state domain.SCUMPlayerLiveState, fieldKey string) (any, bool) {
if state.UnknownFields != nil {
for _, key := range []string{fieldKey, "field" + fieldKey, "attribute" + fieldKey, "attribute_" + fieldKey, "stat" + fieldKey, "stat_" + fieldKey} {
if value, ok := state.UnknownFields[key]; ok {
return value, true
}
}
}
return nil, false
}
func (svc *CoreService) dispatchSCUMSQLiteMutationOperation(operation domain.SCUMOperationRequest, template domain.GameClientBridgeOperationTemplateDeclaration) (domain.Job, error) {
instance, err := svc.store.ServerInstances().Get(operation.ServerInstanceID)
if err != nil {
return domain.Job{}, err
}
jobID := jobIDFromParts("job-scum-sqlite-mutation", instance.ID, operation.IdempotencyKey)
job := domain.Job{ID: jobID, ServerInstanceID: instance.ID, RunEndpointID: instance.RunEndpointID, Capability: domain.JobCapabilityRemoteRunProtectedSQL, TargetKey: template.TargetKey, InputRef: "input://scum-operation/" + operation.ID, IdempotencyKey: "scum-sqlite-mutation:" + operation.IdempotencyKey, Progress: domain.JobProgress{Percent: 0, Message: "typed SCUM DB mutation queued"}, RetryPolicy: domain.JobRetryPolicy{MaxAttempts: 1, InitialBackoffSeconds: 1, MaxBackoffSeconds: 1}, ExecutionInput: domain.JobExecutionInput{WorkspaceScope: svc.runtimeProfileScope(instance.ID), RemoteAdapterKey: template.TransportKey, RemoteAdapterKind: "protected-sql", TimeoutSeconds: template.TimeoutSeconds, PluginID: operation.PluginID, Inputs: scumSQLiteMutationJobInputs(operation, template)}}
created, err := svc.CreateJob(job)
if err != nil {
return domain.Job{}, err
}
if created.ID != jobID || created.Capability != domain.JobCapabilityRemoteRunProtectedSQL || created.TargetKey != template.TargetKey || created.ExecutionInput.RemoteAdapterKey != template.TransportKey {
return domain.Job{}, validationError("SCUM DB mutation idempotency key is already bound")
}
return created, nil
}
func scumSQLiteMutationJobInputs(operation domain.SCUMOperationRequest, template domain.GameClientBridgeOperationTemplateDeclaration) map[string]string {
return map[string]string{
"operationId": operation.ID,
"templateKey": operation.TemplateKey,
"playerId": operation.PlayerID,
"fieldKey": operation.Guard.FieldKey,
"tableKey": template.Mutation.TableKey,
"identityKey": template.Mutation.IdentityKey,
"valueKey": template.Mutation.ValueKey,
"before": scumScalarString(operation.Guard.Before),
"after": scumScalarString(operation.Guard.After),
"maxRowsAffected": strconv.Itoa(operation.Guard.MaxRowsAffected),
"confirmationQueryKey": template.Mutation.ConfirmationQueryKey,
"safetyWindow": operation.Guard.SafetyWindow,
"backupRef": operation.Guard.BackupRef,
}
}
func reconcileSCUMSQLiteMutationJobResult(operation domain.SCUMOperationRequest, template domain.GameClientBridgeOperationTemplateDeclaration, job domain.Job) (domain.SCUMOperationRequest, bool) {
result, ok := parseSCUMSQLiteMutationJobResult(job.ExecutionResult.Content)
if !ok || result.Outcome == "unknown" || strings.Contains(strings.ToLower(job.ExecutionResult.Kind), "unknown") {
operation.Status = domain.SCUMWorkflowStepUnknown
operation.Confirmation = domain.SCUMOperationConfirmation{Status: "unknown", SafeSummary: domain.SCUMSafeSummary{Title: "DB mutation state unknown", Message: "Run did not return a valid bounded mutation result."}}
return operation, true
}
operation.Confirmation.AffectedRows = result.AffectedRows
operation.Confirmation.MutationChecksum = result.MutationChecksum
operation.Confirmation.Checksum = coalesceString(operation.Confirmation.Checksum, coalesceString(result.MutationChecksum, job.ExecutionResult.Checksum))
if result.Outcome == "stale-before" {
operation.Status = domain.SCUMWorkflowStepFailed
operation.Confirmation.Status = "failed"
operation.SafeSummary = domain.SCUMSafeSummary{Title: "before value 已过期", Message: "Run 在写入前发现当前 DB 值与 approved before guard 不一致。"}
return operation, true
}
if result.Outcome != "succeeded" || result.AffectedRows < 1 {
operation.Status = domain.SCUMWorkflowStepFailed
operation.Confirmation.Status = "failed"
operation.SafeSummary = domain.SCUMSafeSummary{Title: "DB mutation failed", Message: bounded(coalesceString(result.SafeMessage, "Run reported the mutation did not succeed."), 240)}
return operation, true
}
if result.AffectedRows > template.MaxRowsAffected || result.AffectedRows > operation.Guard.MaxRowsAffected || strings.TrimSpace(result.MutationChecksum) == "" {
operation.Status = domain.SCUMWorkflowStepUnknown
operation.Confirmation.Status = "unknown"
operation.SafeSummary = domain.SCUMSafeSummary{Title: "DB mutation row bound unknown", Message: "Run result exceeded declared row bounds or omitted mutation checksum."}
return operation, true
}
if len(result.ConfirmationRows) > 0 {
for _, row := range result.ConfirmationRows {
if scumSQLiteMutationConfirmationMatches(operation, row) {
operation.Status = domain.SCUMWorkflowStepConfirmed
operation.Confirmation.Status = "confirmed"
operation.Confirmation.ConfirmedFields = domain.CopyGameClientBridgePayload(row)
return operation, true
}
}
operation.Status = domain.SCUMWorkflowStepFailed
operation.Confirmation.Status = "failed"
operation.SafeSummary = domain.SCUMSafeSummary{Title: "DB mutation confirmation mismatch", Message: "Run confirmation rows did not match the requested after value."}
return operation, true
}
operation.Confirmation.Status = "executed"
return operation, false
}
func parseSCUMSQLiteMutationJobResult(content string) (scumSQLiteMutationJobResult, bool) {
if strings.TrimSpace(content) == "" {
return scumSQLiteMutationJobResult{}, false
}
var result scumSQLiteMutationJobResult
if err := json.Unmarshal([]byte(content), &result); err != nil {
return scumSQLiteMutationJobResult{}, false
}
result.Outcome = strings.TrimSpace(result.Outcome)
return result, result.Outcome != ""
}
func scumSQLiteMutationConfirmationMatches(operation domain.SCUMOperationRequest, row map[string]any) bool {
if row == nil {
return false
}
rowPlayerID := firstString(row, "playerId", "gamePlayerId", "steamId", "steam_id")
if rowPlayerID != "" && rowPlayerID != operation.PlayerID {
return false
}
if field := firstString(row, "fieldKey", "field", "attributeKey"); field != "" && field != operation.Guard.FieldKey {
return false
}
for _, key := range []string{"value", "after", operation.Guard.FieldKey, "field" + operation.Guard.FieldKey, "attribute" + operation.Guard.FieldKey, "attribute_" + operation.Guard.FieldKey} {
if value, ok := row[key]; ok && scumScalarEqual(value, operation.Guard.After) {
return true
}
}
return false
}
func scumSQLiteMutationSafeSummary(templateKey, playerID string, guard domain.SCUMMutationGuard) domain.SCUMSafeSummary {
details := map[string]string{"template": templateKey, "fieldKey": guard.FieldKey, "maxRowsAffected": strconv.Itoa(guard.MaxRowsAffected)}
if playerID != "" {
details["playerId"] = playerID
}
if guard.SafetyWindow != "" {
details["safetyWindow"] = guard.SafetyWindow
}
if guard.BackupRef != "" {
details["backupRef"] = guard.BackupRef
}
return domain.SCUMSafeSummary{Title: "Typed SCUM DB mutation", Message: "Run executes this through a declared mutation template with before-value and row-bound guards; raw SQL is not stored.", Details: details}
}
func operationInteger(payload map[string]any, keys ...string) (int64, bool) {
for _, key := range keys {
value, exists := payload[key]
if !exists {
continue
}
switch typed := value.(type) {
case int:
return int64(typed), true
case int64:
return typed, true
case uint64:
if typed > uint64(^uint64(0)>>1) {
return 0, false
}
return int64(typed), true
case float64:
if typed == float64(int64(typed)) {
return int64(typed), true
}
case string:
parsed, err := strconv.ParseInt(strings.TrimSpace(typed), 10, 64)
if err == nil {
return parsed, true
}
}
}
return 0, false
}
func anyInt64(value any) (int64, bool) {
switch typed := value.(type) {
case int:
return int64(typed), true
case int8:
return int64(typed), true
case int16:
return int64(typed), true
case int32:
return int64(typed), true
case int64:
return typed, true
case uint:
return int64(typed), true
case uint8:
return int64(typed), true
case uint16:
return int64(typed), true
case uint32:
return int64(typed), true
case uint64:
if typed > uint64(^uint64(0)>>1) {
return 0, false
}
return int64(typed), true
case float64:
if typed == float64(int64(typed)) {
return int64(typed), true
}
case float32:
if typed == float32(int64(typed)) {
return int64(typed), true
}
case json.Number:
parsed, err := typed.Int64()
return parsed, err == nil
case string:
parsed, err := strconv.ParseInt(strings.TrimSpace(typed), 10, 64)
return parsed, err == nil
}
return 0, false
}
func anyFloat64(value any) (float64, bool) {
switch typed := value.(type) {
case int:
return float64(typed), true
case int64:
return float64(typed), true
case uint64:
return float64(typed), true
case float64:
return typed, true
case float32:
return float64(typed), true
case json.Number:
parsed, err := typed.Float64()
return parsed, err == nil
case string:
parsed, err := strconv.ParseFloat(strings.TrimSpace(typed), 64)
return parsed, err == nil
}
return 0, false
}
func scumScalarEqual(left any, right any) bool {
if leftInt, ok := anyInt64(left); ok {
if rightInt, rightOK := anyInt64(right); rightOK {
return leftInt == rightInt
}
}
if leftFloat, ok := anyFloat64(left); ok {
if rightFloat, rightOK := anyFloat64(right); rightOK {
return leftFloat == rightFloat
}
}
return strings.TrimSpace(fmt.Sprint(left)) == strings.TrimSpace(fmt.Sprint(right))
}
func scumScalarString(value any) string {
if parsed, ok := anyInt64(value); ok {
return strconv.FormatInt(parsed, 10)
}
if parsed, ok := anyFloat64(value); ok {
return strconv.FormatFloat(parsed, 'f', -1, 64)
}
if typed, ok := value.(bool); ok {
return strconv.FormatBool(typed)
}
return bounded(strings.TrimSpace(fmt.Sprint(value)), 512)
}
func operationSafeSummary(templateKey, playerID string, payload map[string]any) domain.SCUMSafeSummary {
details := map[string]string{"template": templateKey}
if playerID != "" {
details["playerId"] = playerID
}
if amount, ok := operationInteger(payload, "fame", "amount", "balance", "value", "normalBalance", "goldBalance"); ok {
details["value"] = fmt.Sprintf("%d", amount)
}
return domain.SCUMSafeSummary{Title: "Typed SCUM operation", Message: "RCON text is generated server-side and is not stored in the operation record.", Details: details}
}
func scumOperationTemplate(plugin domain.GamePlugin, key string) (domain.GameClientBridgeOperationTemplateDeclaration, bool) {
for _, template := range plugin.GameClientBridge.OperationTemplates {
if template.Key == key {
return template, true
}
}
return domain.GameClientBridgeOperationTemplateDeclaration{}, false
}
+273
View File
@@ -0,0 +1,273 @@
package service
import (
"encoding/json"
"strings"
"testing"
"time"
"browser.local/platform/domain"
)
func TestSCUMRCONOperationApprovalDispatchesTransientCommandAndConfirms(t *testing.T) {
svc, session, runSession, instance := newSourceRCONFixture(t)
seedSCUMOperationTemplates(t, svc, instance.PluginID)
request := domain.SCUMOperationRequest{TemplateKey: "player.fame.set", PlayerID: "76561198000000001", Payload: map[string]any{"fame": 123}, Reason: "restore fame", IdempotencyKey: "fame-restore-1"}
operation, err := svc.RequestSCUMOperationForSession(session, instance.ID, request)
if err != nil || operation.Status != domain.SCUMWorkflowStepWaiting {
t.Fatalf("request operation=%+v err=%v", operation, err)
}
duplicate, err := svc.RequestSCUMOperationForSession(session, instance.ID, request)
if err != nil || duplicate.ID != operation.ID {
t.Fatalf("duplicate should return original operation: duplicate=%+v err=%v", duplicate, err)
}
approved, err := svc.ApproveSCUMOperationForSession(session, operation.ID)
if err != nil || approved.Status != domain.SCUMWorkflowStepQueued || approved.RunJobID == "" {
t.Fatalf("approve operation=%+v err=%v", approved, err)
}
job, err := svc.store.Jobs().Get(approved.RunJobID)
if err != nil {
t.Fatalf("get operation job: %v", err)
}
serializedOperation, _ := json.Marshal(approved)
serializedJob, _ := json.Marshal(job)
for _, forbidden := range []string{"#SetFamePoints", "SetCurrencyBalance", "password="} {
if strings.Contains(string(serializedOperation), forbidden) || strings.Contains(string(serializedJob), forbidden) {
t.Fatalf("operation/job persisted raw RCON text %q: operation=%s job=%s", forbidden, serializedOperation, serializedJob)
}
}
claim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: "run-local", SessionToken: runSession, Capabilities: []string{domain.JobCapabilityRemoteRunRCONCommand}, Capacity: domain.RunCapacity{MaxJobs: 1}})
if err != nil || !claim.HasJob || claim.Job == nil || claim.Job.JobID != approved.RunJobID {
t.Fatalf("claim operation RCON job: claim=%+v err=%v", claim, err)
}
ack, err := svc.AckRunJob(domain.RunJobAck{RunEndpointID: "run-local", SessionToken: runSession, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt, Message: "accepted"})
if err != nil || !ack.Accepted {
t.Fatalf("ack operation RCON job: ack=%+v err=%v", ack, err)
}
input, err := svc.GetSourceRCONExecutionInput(domain.SourceRCONExecutionInputRequest{RunEndpointID: "run-local", SessionToken: runSession, JobID: claim.Job.JobID, LeaseToken: ack.Job.LeaseToken, Attempt: ack.Job.Attempt})
if err != nil {
t.Fatalf("read transient operation command: %v", err)
}
if input.Command != "#SetFamePoints 123 \"76561198000000001\"" {
t.Fatalf("unexpected generated RCON command: %q", input.Command)
}
if _, err := svc.CompleteRunJob(domain.RunJobResult{RunEndpointID: "run-local", SessionToken: runSession, JobID: claim.Job.JobID, LeaseToken: ack.Job.LeaseToken, Attempt: ack.Job.Attempt, State: domain.JobStateSucceeded, Progress: domain.RunJobProgressReport{Percent: 100}, ExecutionResult: domain.JobExecutionResult{Kind: "source-rcon.succeeded", AuditSummary: "typed RCON delivered"}}); err != nil {
t.Fatalf("complete operation job: %v", err)
}
reconciled, err := svc.ReconcileSCUMOperation(approved.ID)
if err != nil || reconciled.Status != domain.SCUMWorkflowStepConfirming {
t.Fatalf("expected confirming after delivery before readback: %+v err=%v", reconciled, err)
}
confirmed, err := svc.ConfirmSCUMOperation(approved.ID, domain.SCUMOperationConfirmation{Status: "confirmed", ConfirmedFields: map[string]any{"fame": 123}, ObservedAt: fixedTime.Add(time.Minute)})
if err != nil || confirmed.Status != domain.SCUMWorkflowStepConfirmed || confirmed.CompletedAt.IsZero() {
t.Fatalf("confirm operation=%+v err=%v", confirmed, err)
}
}
func TestSCUMRCONOperationPermissionUnknownAndConfirmationFailure(t *testing.T) {
svc, session, runSession, instance := newSourceRCONFixture(t)
seedSCUMOperationTemplates(t, svc, instance.PluginID)
adminOnly, err := svc.RequestSCUMOperationForSession(session, instance.ID, domain.SCUMOperationRequest{TemplateKey: "player.currency.gold.set", PlayerID: "76561198000000002", Payload: map[string]any{"amount": 9}, Reason: "admin-only", IdempotencyKey: "gold-admin-only"})
if err != nil {
t.Fatalf("request admin-only operation: %v", err)
}
if _, err := svc.ApproveSCUMOperationForSession(session, adminOnly.ID); err != ErrForbidden {
t.Fatalf("expected platform-admin approval denial, got %v", err)
}
operation, err := svc.RequestSCUMOperationForSession(session, instance.ID, domain.SCUMOperationRequest{TemplateKey: "player.currency.normal.set", PlayerID: "76561198000000002", Payload: map[string]any{"amount": 500}, Reason: "repair balance", IdempotencyKey: "normal-unknown"})
if err != nil {
t.Fatalf("request normal currency operation: %v", err)
}
approved, err := svc.ApproveSCUMOperationForSession(session, operation.ID)
if err != nil {
t.Fatalf("approve normal currency operation: %v", err)
}
claim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: "run-local", SessionToken: runSession, Capabilities: []string{domain.JobCapabilityRemoteRunRCONCommand}, Capacity: domain.RunCapacity{MaxJobs: 1}})
if err != nil || !claim.HasJob || claim.Job == nil || claim.Job.JobID != approved.RunJobID {
t.Fatalf("claim normal currency job: claim=%+v err=%v", claim, err)
}
ack, err := svc.AckRunJob(domain.RunJobAck{RunEndpointID: "run-local", SessionToken: runSession, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt})
if err != nil {
t.Fatalf("ack normal currency job: %v", err)
}
if _, err := svc.GetSourceRCONExecutionInput(domain.SourceRCONExecutionInputRequest{RunEndpointID: "run-local", SessionToken: runSession, JobID: claim.Job.JobID, LeaseToken: ack.Job.LeaseToken, Attempt: ack.Job.Attempt}); err != nil {
t.Fatalf("consume normal currency command: %v", err)
}
if _, err := svc.CompleteRunJob(domain.RunJobResult{RunEndpointID: "run-local", SessionToken: runSession, JobID: claim.Job.JobID, LeaseToken: ack.Job.LeaseToken, Attempt: ack.Job.Attempt, State: domain.JobStateFailed, Progress: domain.RunJobProgressReport{Percent: 100}, ExecutionResult: domain.JobExecutionResult{Kind: "source-rcon.unknown", AuditSummary: "unknown command state"}}); err != nil {
t.Fatalf("complete unknown operation job: %v", err)
}
unknown, err := svc.ReconcileSCUMOperation(approved.ID)
if err != nil || unknown.Status != domain.SCUMWorkflowStepUnknown {
t.Fatalf("expected unknown terminal state: %+v err=%v", unknown, err)
}
failure, err := svc.ConfirmSCUMOperation(operation.ID, domain.SCUMOperationConfirmation{Status: "failed", SafeSummary: domain.SCUMSafeSummary{Title: "Readback mismatch", Message: "Projection did not match expected currency."}, ObservedAt: time.Date(2026, 8, 10, 12, 0, 0, 0, time.UTC)})
if err != nil || failure.Status != domain.SCUMWorkflowStepFailed {
t.Fatalf("expected confirmation failure: %+v err=%v", failure, err)
}
}
func TestSCUMSQLiteMutationOperationSafetyGatesAndDispatchesTypedJob(t *testing.T) {
svc, session, runSession, instance := newSourceRCONFixture(t)
seedSCUMOperationTemplates(t, svc, instance.PluginID)
adminSession := enableSCUMSQLiteMutationOperationSupport(t, svc, instance)
if _, err := svc.ApplySCUMObservationResult(domain.SCUMObservationResult{ServerInstanceID: instance.ID, PluginID: instance.PluginID, Source: "run.sqlite.read", QueryKey: "scum.player.profile", Sequence: 1, Checksum: "sha256:profile-online", ObservedAt: fixedTime, Rows: []map[string]any{{"gamePlayerId": "76561198000000855", "displayName": "Attribute Tester", "online": true, "855": 100}}}); err != nil {
t.Fatalf("seed online projection: %v", err)
}
operation, err := svc.RequestSCUMOperationForSession(session, instance.ID, domain.SCUMOperationRequest{TemplateKey: "player.attribute.855.set", PlayerID: "76561198000000855", Payload: map[string]any{"fieldKey": "855", "before": 100, "after": 150, "safetyWindow": "maintenance-2026-08-10", "backupRef": "snapshot://scum/server-rcon/20260810"}, Reason: "repair attribute 855", IdempotencyKey: "attribute-855-1"})
if err != nil || operation.Status != domain.SCUMWorkflowStepWaiting {
t.Fatalf("request sqlite mutation=%+v err=%v", operation, err)
}
waiting, err := svc.ApproveSCUMOperationForSession(adminSession, operation.ID)
if err != nil || waiting.Status != domain.SCUMWorkflowStepWaiting || waiting.RunJobID != "" || !strings.Contains(waiting.SafeSummary.Title, "离线") {
t.Fatalf("online player should block dispatch: %+v err=%v", waiting, err)
}
if _, err := svc.ApplySCUMObservationResult(domain.SCUMObservationResult{ServerInstanceID: instance.ID, PluginID: instance.PluginID, Source: "run.sqlite.read", QueryKey: "scum.player.profile", Sequence: 2, Checksum: "sha256:profile-offline", ObservedAt: fixedTime.Add(time.Minute), Rows: []map[string]any{{"gamePlayerId": "76561198000000855", "displayName": "Attribute Tester", "online": false, "855": 100}}}); err != nil {
t.Fatalf("seed offline projection: %v", err)
}
approved, err := svc.ApproveSCUMOperationForSession(adminSession, operation.ID)
if err != nil || approved.Status != domain.SCUMWorkflowStepQueued || approved.RunJobID == "" {
t.Fatalf("approve sqlite mutation=%+v err=%v", approved, err)
}
job, err := svc.store.Jobs().Get(approved.RunJobID)
if err != nil {
t.Fatalf("get sqlite mutation job: %v", err)
}
if job.Capability != domain.JobCapabilityRemoteRunProtectedSQL || job.ExecutionInput.Inputs["fieldKey"] != "855" || job.ExecutionInput.Inputs["before"] != "100" || job.ExecutionInput.Inputs["after"] != "150" || job.ExecutionInput.Inputs["maxRowsAffected"] != "1" {
t.Fatalf("unexpected typed mutation job: %+v", job)
}
serializedOperation, _ := json.Marshal(approved)
serializedJob, _ := json.Marshal(job)
for _, forbidden := range []string{"UPDATE ", "DELETE ", "INSERT ", "SELECT ", "SCUM.db", "/Saved/", "requestText"} {
if strings.Contains(strings.ToUpper(string(serializedOperation)), strings.ToUpper(forbidden)) || strings.Contains(strings.ToUpper(string(serializedJob)), strings.ToUpper(forbidden)) {
t.Fatalf("operation/job persisted raw DB material %q: operation=%s job=%s", forbidden, serializedOperation, serializedJob)
}
}
claim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: "run-local", SessionToken: runSession, Capabilities: []string{domain.JobCapabilityRemoteRunProtectedSQL}, Capacity: domain.RunCapacity{MaxJobs: 1}})
if err != nil || !claim.HasJob || claim.Job == nil || claim.Job.JobID != approved.RunJobID {
t.Fatalf("claim sqlite mutation job: claim=%+v err=%v", claim, err)
}
ack, err := svc.AckRunJob(domain.RunJobAck{RunEndpointID: "run-local", SessionToken: runSession, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt, Message: "accepted"})
if err != nil || !ack.Accepted {
t.Fatalf("ack sqlite mutation job: ack=%+v err=%v", ack, err)
}
mutationChecksum := "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
content := mustJSON(t, map[string]any{"outcome": "succeeded", "affectedRows": 1, "mutationChecksum": mutationChecksum, "confirmationRows": []map[string]any{{"playerId": "76561198000000855", "fieldKey": "855", "value": 150}}})
if _, err := svc.CompleteRunJob(domain.RunJobResult{RunEndpointID: "run-local", SessionToken: runSession, JobID: claim.Job.JobID, LeaseToken: ack.Job.LeaseToken, Attempt: ack.Job.Attempt, State: domain.JobStateSucceeded, Progress: domain.RunJobProgressReport{Percent: 100}, ExecutionResult: domain.JobExecutionResult{Kind: "scum.sqlite-mutation.succeeded", Checksum: mutationChecksum, AuditSummary: "typed SCUM DB mutation result", Content: content}}); err != nil {
t.Fatalf("complete sqlite mutation job: %v", err)
}
confirmed, err := svc.ReconcileSCUMOperation(approved.ID)
if err != nil || confirmed.Status != domain.SCUMWorkflowStepConfirmed || confirmed.Confirmation.AffectedRows != 1 || confirmed.Confirmation.MutationChecksum != mutationChecksum {
t.Fatalf("expected confirmed sqlite mutation: %+v err=%v", confirmed, err)
}
}
func TestSCUMSQLiteMutationBlocksMissingSafetyAndStaleBefore(t *testing.T) {
svc, session, _, instance := newSourceRCONFixture(t)
seedSCUMOperationTemplates(t, svc, instance.PluginID)
adminSession := enableSCUMSQLiteMutationOperationSupport(t, svc, instance)
if _, err := svc.ApplySCUMObservationResult(domain.SCUMObservationResult{ServerInstanceID: instance.ID, PluginID: instance.PluginID, Source: "run.sqlite.read", QueryKey: "scum.player.profile", Sequence: 1, Checksum: "sha256:profile-855", ObservedAt: fixedTime, Rows: []map[string]any{{"gamePlayerId": "steam-855", "displayName": "Guarded", "online": false, "855": 100}}}); err != nil {
t.Fatalf("seed projection: %v", err)
}
missingSafety, err := svc.RequestSCUMOperationForSession(session, instance.ID, domain.SCUMOperationRequest{TemplateKey: "player.attribute.855.set", PlayerID: "steam-855", Payload: map[string]any{"fieldKey": "855", "before": 100, "after": 101}, Reason: "missing maintenance", IdempotencyKey: "attribute-855-missing-safety"})
if err != nil {
t.Fatalf("request missing safety mutation: %v", err)
}
waiting, err := svc.ApproveSCUMOperationForSession(adminSession, missingSafety.ID)
if err != nil || waiting.Status != domain.SCUMWorkflowStepWaiting || waiting.RunJobID != "" || !strings.Contains(waiting.SafeSummary.Title, "维护") {
t.Fatalf("expected missing maintenance/backup wait: %+v err=%v", waiting, err)
}
stale, err := svc.RequestSCUMOperationForSession(session, instance.ID, domain.SCUMOperationRequest{TemplateKey: "player.attribute.855.set", PlayerID: "steam-855", Payload: map[string]any{"fieldKey": "855", "before": 99, "after": 101, "safetyWindow": "maintenance-2026-08-10", "backupRef": "snapshot://scum/server-rcon/stale"}, Reason: "stale before", IdempotencyKey: "attribute-855-stale-before"})
if err != nil {
t.Fatalf("request stale mutation: %v", err)
}
blocked, err := svc.ApproveSCUMOperationForSession(adminSession, stale.ID)
if err != nil || blocked.Status != domain.SCUMWorkflowStepBlocked || blocked.RunJobID != "" || !strings.Contains(blocked.SafeSummary.Title, "before") {
t.Fatalf("expected stale before block: %+v err=%v", blocked, err)
}
}
func TestSCUMSQLiteMutationResultValidationRejectsOverBoundRows(t *testing.T) {
svc, session, runSession, instance := newSourceRCONFixture(t)
seedSCUMOperationTemplates(t, svc, instance.PluginID)
adminSession := enableSCUMSQLiteMutationOperationSupport(t, svc, instance)
if _, err := svc.ApplySCUMObservationResult(domain.SCUMObservationResult{ServerInstanceID: instance.ID, PluginID: instance.PluginID, Source: "run.sqlite.read", QueryKey: "scum.player.profile", Sequence: 1, Checksum: "sha256:profile-overbound", ObservedAt: fixedTime, Rows: []map[string]any{{"gamePlayerId": "steam-overbound", "online": false, "855": 10}}}); err != nil {
t.Fatalf("seed projection: %v", err)
}
operation, err := svc.RequestSCUMOperationForSession(session, instance.ID, domain.SCUMOperationRequest{TemplateKey: "player.attribute.855.set", PlayerID: "steam-overbound", Payload: map[string]any{"fieldKey": "855", "before": 10, "after": 11, "safetyWindow": "maintenance-2026-08-10", "backupRef": "snapshot://scum/server-rcon/overbound"}, Reason: "overbound test", IdempotencyKey: "attribute-855-overbound"})
if err != nil {
t.Fatalf("request overbound mutation: %v", err)
}
approved, err := svc.ApproveSCUMOperationForSession(adminSession, operation.ID)
if err != nil || approved.RunJobID == "" {
t.Fatalf("approve overbound mutation=%+v err=%v", approved, err)
}
claim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: "run-local", SessionToken: runSession, Capabilities: []string{domain.JobCapabilityRemoteRunProtectedSQL}, Capacity: domain.RunCapacity{MaxJobs: 1}})
if err != nil || !claim.HasJob || claim.Job == nil {
t.Fatalf("claim overbound job: claim=%+v err=%v", claim, err)
}
ack, err := svc.AckRunJob(domain.RunJobAck{RunEndpointID: "run-local", SessionToken: runSession, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt})
if err != nil {
t.Fatalf("ack overbound job: %v", err)
}
content := mustJSON(t, map[string]any{"outcome": "succeeded", "affectedRows": 2, "mutationChecksum": "sha256:mutation-overbound"})
if _, err := svc.CompleteRunJob(domain.RunJobResult{RunEndpointID: "run-local", SessionToken: runSession, JobID: claim.Job.JobID, LeaseToken: ack.Job.LeaseToken, Attempt: ack.Job.Attempt, State: domain.JobStateSucceeded, Progress: domain.RunJobProgressReport{Percent: 100}, ExecutionResult: domain.JobExecutionResult{Kind: "scum.sqlite-mutation.succeeded", Content: content, AuditSummary: "typed SCUM DB mutation result"}}); err != nil {
t.Fatalf("complete overbound job: %v", err)
}
unknown, err := svc.ReconcileSCUMOperation(approved.ID)
if err != nil || unknown.Status != domain.SCUMWorkflowStepUnknown {
t.Fatalf("expected over-bound rows to become unknown: %+v err=%v", unknown, err)
}
}
func seedSCUMOperationTemplates(t *testing.T, svc *CoreService, pluginID string) {
t.Helper()
plugin, err := svc.store.GamePlugins().Get(pluginID)
if err != nil {
t.Fatal(err)
}
plugin.DeclaredPermissions = append(plugin.DeclaredPermissions, "server.game-client.command")
plugin.GameClientBridge.OperationTemplates = []domain.GameClientBridgeOperationTemplateDeclaration{
{Key: "player.fame.set", Title: "Set player fame", Permission: "server.game-client.command", ApprovalLevel: domain.GameClientBridgeApprovalLevelOperator, Kind: domain.GameClientBridgeOperationKindRCON, TransportKey: "rcon", TargetKey: "rcon", PayloadSchemaRef: "schemas/bridge/player-fame-set.payload.schema.json", TimeoutSeconds: 60, MaxPayloadBytes: 2048, Safety: domain.GameClientBridgeOperationSafety{RequiresApproval: true, RequiresConfirmation: true}},
{Key: "player.currency.normal.set", Title: "Set player normal currency", Permission: "server.game-client.command", ApprovalLevel: domain.GameClientBridgeApprovalLevelOperator, Kind: domain.GameClientBridgeOperationKindRCON, TransportKey: "rcon", TargetKey: "rcon", PayloadSchemaRef: "schemas/bridge/player-currency-set.payload.schema.json", TimeoutSeconds: 60, MaxPayloadBytes: 2048, Safety: domain.GameClientBridgeOperationSafety{RequiresApproval: true, RequiresConfirmation: true}},
{Key: "player.currency.gold.set", Title: "Set player gold currency", Permission: "server.game-client.command", ApprovalLevel: domain.GameClientBridgeApprovalLevelPlatformAdmin, Kind: domain.GameClientBridgeOperationKindRCON, TransportKey: "rcon", TargetKey: "rcon", PayloadSchemaRef: "schemas/bridge/player-currency-set.payload.schema.json", TimeoutSeconds: 60, MaxPayloadBytes: 2048, Safety: domain.GameClientBridgeOperationSafety{RequiresApproval: true, RequiresConfirmation: true}},
}
if err := svc.store.GamePlugins().Update(plugin); err != nil {
t.Fatalf("update plugin operation templates: %v", err)
}
}
func enableSCUMSQLiteMutationOperationSupport(t *testing.T, svc *CoreService, instance domain.ServerInstance) string {
t.Helper()
adminSession := createServiceUserAndLogin(t, svc, domain.User{ID: "platform-admin-scum", DisplayName: "SCUM Admin", Email: "scum-admin@example.test", Roles: []string{"platform-admin"}, PasswordHash: "secret-password"})
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
if err != nil {
t.Fatal(err)
}
plugin.DeclaredPermissions = append(plugin.DeclaredPermissions, "server.game-client.maintenance")
plugin.RequiredRunCapabilities = append(plugin.RequiredRunCapabilities, domain.JobCapabilityRemoteRunProtectedSQL)
plugin.RemoteAccess.RunCapabilities = append(plugin.RemoteAccess.RunCapabilities, domain.JobCapabilityRemoteRunProtectedSQL)
plugin.RemoteAccess.DatabaseEngines = append(plugin.RemoteAccess.DatabaseEngines, "sqlite")
plugin.RuntimeProfiles.TransportProfiles = append(plugin.RuntimeProfiles.TransportProfiles, domain.RuntimeTransportProfile{Key: "scum-database", Kind: "sqlite", TargetKey: "scum-database", Capabilities: []string{domain.JobCapabilityRemoteRunProtectedSQL}})
plugin.GameClientBridge.OperationTemplates = append(plugin.GameClientBridge.OperationTemplates, domain.GameClientBridgeOperationTemplateDeclaration{Key: "player.attribute.855.set", Title: "Set player 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}})
if err := svc.store.GamePlugins().Update(plugin); err != nil {
t.Fatalf("update SCUM DB mutation plugin: %v", err)
}
endpoint, err := svc.store.RunEndpoints().Get(instance.RunEndpointID)
if err != nil {
t.Fatal(err)
}
endpoint.Capabilities = append(endpoint.Capabilities, domain.JobCapabilityRemoteRunProtectedSQL)
if err := svc.store.RunEndpoints().Update(endpoint); err != nil {
t.Fatalf("update SCUM DB mutation endpoint: %v", err)
}
return adminSession
}
func mustJSON(t *testing.T, value any) string {
t.Helper()
encoded, err := json.Marshal(value)
if err != nil {
t.Fatalf("marshal test JSON: %v", err)
}
return string(encoded)
}
+803
View File
@@ -0,0 +1,803 @@
package service
import (
"fmt"
"math"
"strconv"
"strings"
"time"
"browser.local/platform/domain"
"browser.local/platform/repo"
"browser.local/platform/validator"
)
func (svc *CoreService) ApplySCUMObservationResult(result domain.SCUMObservationResult) (domain.SCUMDataObservation, error) {
result = domain.CopySCUMObservationResult(result)
if strings.TrimSpace(result.ServerInstanceID) == "" {
return domain.SCUMDataObservation{}, validationError("serverInstanceId is required")
}
instance, err := svc.store.ServerInstances().Get(result.ServerInstanceID)
if err != nil {
return domain.SCUMDataObservation{}, err
}
if strings.TrimSpace(result.PluginID) == "" {
result.PluginID = instance.PluginID
}
if result.PluginID != instance.PluginID {
return domain.SCUMDataObservation{}, validationError("pluginId must match server instance")
}
if strings.TrimSpace(result.QueryKey) == "" {
return domain.SCUMDataObservation{}, validationError("queryKey is required")
}
if result.ReceivedAt.IsZero() {
result.ReceivedAt = svc.now()
}
if result.ObservedAt.IsZero() {
result.ObservedAt = result.ReceivedAt
}
if result.Status == "" {
result.Status = domain.SCUMObservationAccepted
}
latest, err := svc.latestSCUMObservation(result.ServerInstanceID, result.PluginID, result.QueryKey)
if err != nil {
return domain.SCUMDataObservation{}, err
}
if result.Status == domain.SCUMObservationAccepted && !latest.ObservedAt.IsZero() && scumObservationOlder(result, latest) {
result.Status = domain.SCUMObservationStale
result.ErrorCode = "older_observation"
result.SafeSummary = domain.SCUMSafeSummary{Title: "旧观察已忽略", Message: "Run 返回的 SCUM.db 观察早于当前本地投影,未覆盖 last-known-good 数据。"}
}
observation := domain.SCUMDataObservation{ID: scumObservationID(result), ServerInstanceID: result.ServerInstanceID, PluginID: result.PluginID, Source: result.Source, QueryKey: result.QueryKey, Sequence: result.Sequence, Checksum: result.Checksum, Status: result.Status, ErrorCode: result.ErrorCode, SafeSummary: result.SafeSummary, ObservedAt: result.ObservedAt, ReceivedAt: result.ReceivedAt}
if err := svc.upsertSCUMObservation(observation); err != nil {
return domain.SCUMDataObservation{}, err
}
if result.Status != domain.SCUMObservationAccepted {
if result.Status == domain.SCUMObservationFailed {
return observation, svc.markSCUMQueryStale(result, "observation_failed")
}
return observation, nil
}
freshness := domain.SCUMProjectionFreshnessState{Status: domain.SCUMProjectionFresh, ObservationID: observation.ID, Source: observation.Source, QueryKey: observation.QueryKey, Sequence: observation.Sequence, Checksum: observation.Checksum, ObservedAt: observation.ObservedAt, ReceivedAt: observation.ReceivedAt}
if err := svc.applySCUMRows(result.QueryKey, result.ServerInstanceID, result.Rows, freshness); err != nil {
return domain.SCUMDataObservation{}, err
}
return observation, nil
}
func (svc *CoreService) ListSCUMPlayerLiveStatesForSession(sessionID string, filter domain.SCUMProjectionFilter) ([]domain.SCUMPlayerLiveState, error) {
if err := svc.authorizeServerLifecycle(sessionID, filter.ServerInstanceID); err != nil {
return nil, err
}
values, err := svc.store.SCUMPlayerLiveStates().List(filter)
if err != nil {
return nil, err
}
limitSCUMProjectionSlice(&values, filter.Limit)
return values, nil
}
func (svc *CoreService) ListSCUMSquadsForSession(sessionID string, filter domain.SCUMProjectionFilter) ([]domain.SCUMSquad, error) {
if err := svc.authorizeServerLifecycle(sessionID, filter.ServerInstanceID); err != nil {
return nil, err
}
values, err := svc.store.SCUMSquads().List(filter)
if err != nil {
return nil, err
}
limitSCUMProjectionSlice(&values, filter.Limit)
return values, nil
}
func (svc *CoreService) ListSCUMSquadMembersForSession(sessionID string, filter domain.SCUMProjectionFilter) ([]domain.SCUMSquadMember, error) {
if err := svc.authorizeServerLifecycle(sessionID, filter.ServerInstanceID); err != nil {
return nil, err
}
values, err := svc.store.SCUMSquadMembers().List(filter)
if err != nil {
return nil, err
}
limitSCUMProjectionSlice(&values, filter.Limit)
return values, nil
}
func (svc *CoreService) ListSCUMVehiclesForSession(sessionID string, filter domain.SCUMProjectionFilter) ([]domain.SCUMVehicle, error) {
if err := svc.authorizeServerLifecycle(sessionID, filter.ServerInstanceID); err != nil {
return nil, err
}
values, err := svc.store.SCUMVehicles().List(filter)
if err != nil {
return nil, err
}
limitSCUMProjectionSlice(&values, filter.Limit)
return values, nil
}
func (svc *CoreService) ListSCUMFlagsForSession(sessionID string, filter domain.SCUMProjectionFilter) ([]domain.SCUMFlag, error) {
if err := svc.authorizeServerLifecycle(sessionID, filter.ServerInstanceID); err != nil {
return nil, err
}
values, err := svc.store.SCUMFlags().List(filter)
if err != nil {
return nil, err
}
limitSCUMProjectionSlice(&values, filter.Limit)
return values, nil
}
func (svc *CoreService) ListSCUMCurrentPositionsForSession(sessionID string, filter domain.SCUMProjectionFilter) ([]domain.SCUMCurrentPosition, error) {
if err := svc.authorizeServerLifecycle(sessionID, filter.ServerInstanceID); err != nil {
return nil, err
}
values, err := svc.store.SCUMCurrentPositions().List(filter)
if err != nil {
return nil, err
}
limitSCUMProjectionSlice(&values, filter.Limit)
return values, nil
}
func (svc *CoreService) latestSCUMObservation(serverID, pluginID, queryKey string) (domain.SCUMDataObservation, error) {
observations, err := svc.store.SCUMDataObservations().List(domain.SCUMProjectionFilter{ServerInstanceID: serverID, QueryKey: queryKey})
if err != nil {
return domain.SCUMDataObservation{}, err
}
var latest domain.SCUMDataObservation
for _, observation := range observations {
if pluginID != "" && observation.PluginID != pluginID {
continue
}
if latest.ObservedAt.IsZero() || observation.Sequence > latest.Sequence || (observation.Sequence == latest.Sequence && observation.ObservedAt.After(latest.ObservedAt)) {
latest = observation
}
}
return latest, nil
}
func scumObservationOlder(next domain.SCUMObservationResult, latest domain.SCUMDataObservation) bool {
if next.Sequence > 0 && latest.Sequence > 0 && next.Sequence <= latest.Sequence {
return true
}
return !next.ObservedAt.IsZero() && !latest.ObservedAt.IsZero() && next.ObservedAt.Before(latest.ObservedAt)
}
func (svc *CoreService) upsertSCUMObservation(observation domain.SCUMDataObservation) error {
if existing, err := svc.store.SCUMDataObservations().Get(observation.ID); err == nil {
existing.Status = observation.Status
existing.ErrorCode = observation.ErrorCode
existing.SafeSummary = observation.SafeSummary
existing.ReceivedAt = observation.ReceivedAt
return svc.store.SCUMDataObservations().Update(existing)
} else if err != repo.ErrNotFound {
return err
}
return svc.store.SCUMDataObservations().Create(observation)
}
func (svc *CoreService) applySCUMRows(queryKey, serverID string, rows []map[string]any, freshness domain.SCUMProjectionFreshnessState) error {
lower := strings.ToLower(queryKey)
if strings.Contains(lower, "player") || strings.Contains(lower, "profile") || strings.Contains(lower, "economy") {
for _, row := range rows {
if err := svc.applySCUMPlayerRow(serverID, row, freshness); err != nil {
return err
}
}
}
if strings.Contains(lower, "squad-member") || strings.Contains(lower, "squad.member") || strings.Contains(lower, "member") {
for _, row := range rows {
if err := svc.applySCUMSquadMemberRow(serverID, row, freshness); err != nil {
return err
}
}
} else if strings.Contains(lower, "squad") {
for _, row := range rows {
if err := svc.applySCUMSquadRow(serverID, row, freshness); err != nil {
return err
}
}
}
if strings.Contains(lower, "vehicle") {
for _, row := range rows {
if err := svc.applySCUMVehicleRow(serverID, row, freshness); err != nil {
return err
}
}
}
if strings.Contains(lower, "flag") {
for _, row := range rows {
if err := svc.applySCUMFlagRow(serverID, row, freshness); err != nil {
return err
}
}
}
if strings.Contains(lower, "position") || strings.Contains(lower, "coordinate") {
for _, row := range rows {
if err := svc.applySCUMPositionRow(serverID, row, freshness); err != nil {
return err
}
}
}
return nil
}
func (svc *CoreService) applySCUMPlayerRow(serverID string, row map[string]any, freshness domain.SCUMProjectionFreshnessState) error {
gamePlayerID := firstString(row, "gamePlayerId", "playerId", "steamId", "steam_id")
profileID := firstString(row, "userProfileId", "user_profile_id", "profileId")
steamID := firstString(row, "steamId", "steam_id")
name := firstString(row, "displayName", "name", "playerName")
if gamePlayerID == "" && steamID != "" {
gamePlayerID = steamID
}
if gamePlayerID == "" && profileID == "" {
return nil
}
playerRecordID := ""
if gamePlayerID != "" {
playerRecordID = gamePlayerRecordID(serverID, gamePlayerID)
if err := svc.upsertSCUMGamePlayer(serverID, playerRecordID, gamePlayerID, name, freshness.ObservedAt); err != nil {
return err
}
}
idSource := gamePlayerID
if idSource == "" {
idSource = "profile-" + profileID
}
id := scumProjectionID("player-live", serverID, idSource)
state, err := svc.store.SCUMPlayerLiveStates().Get(id)
if err == repo.ErrNotFound {
state = domain.SCUMPlayerLiveState{ID: id, ServerInstanceID: serverID, GamePlayerRecordID: playerRecordID, GamePlayerID: gamePlayerID, UserProfileID: profileID, SteamID: steamID, DisplayName: name, Freshness: domain.SCUMProjectionStateUnknown(), CreatedAt: svc.now()}
} else if err != nil {
return err
}
if isProjectionOlder(freshness, state.Freshness) {
return nil
}
state.GamePlayerRecordID = coalesceString(playerRecordID, state.GamePlayerRecordID)
state.GamePlayerID = coalesceString(gamePlayerID, state.GamePlayerID)
state.UserProfileID = coalesceString(profileID, state.UserProfileID)
state.SteamID = coalesceString(steamID, state.SteamID)
state.DisplayName = coalesceString(name, state.DisplayName)
state.SquadID = coalesceString(firstString(row, "squadId", "squad_id"), state.SquadID)
state.SquadName = coalesceString(firstString(row, "squadName", "squad_name"), state.SquadName)
if value, ok := firstFloat(row, "famePoints", "fame_points", "fame"); ok {
state.FamePoints = value
}
if value, ok := firstFloat(row, "normalBalance", "currencyNormal", "money", "normal_balance"); ok {
state.NormalBalance = value
}
if value, ok := firstFloat(row, "goldBalance", "currencyGold", "gold", "gold_balance"); ok {
state.GoldBalance = value
}
if value, ok := firstBool(row, "online", "isOnline"); ok {
state.Online = value
}
state.LastLoginAt = coalesceTime(firstTime(row, "lastLoginAt", "last_login_at"), state.LastLoginAt)
state.LastLogoutAt = coalesceTime(firstTime(row, "lastLogoutAt", "last_logout_at"), state.LastLogoutAt)
state.LastSaveTime = coalesceTime(firstTime(row, "lastSaveTime", "last_save_time"), state.LastSaveTime)
if position, ok := scumPositionFromRow(serverID, domain.SCUMProjectionSubjectPlayer, gamePlayerID, row, freshness); ok {
position.GamePlayerRecordID = playerRecordID
position.GamePlayerID = gamePlayerID
state.Position = position
if err := svc.upsertSCUMPosition(position); err != nil {
return err
}
}
state.UnknownFields = unknownRowFields(row, "gamePlayerId", "playerId", "steamId", "steam_id", "userProfileId", "user_profile_id", "profileId", "displayName", "name", "playerName", "squadId", "squad_id", "squadName", "squad_name", "famePoints", "fame_points", "fame", "normalBalance", "currencyNormal", "money", "normal_balance", "goldBalance", "currencyGold", "gold", "gold_balance", "online", "isOnline", "lastLoginAt", "last_login_at", "lastLogoutAt", "last_logout_at", "lastSaveTime", "last_save_time", "x", "y", "z", "worldX", "worldY", "worldZ", "mapId", "mapVersion")
state.Freshness = freshness
state.UpdatedAt = svc.now()
if err == repo.ErrNotFound {
return svc.store.SCUMPlayerLiveStates().Create(state)
}
return svc.store.SCUMPlayerLiveStates().Update(state)
}
func (svc *CoreService) applySCUMSquadRow(serverID string, row map[string]any, freshness domain.SCUMProjectionFreshnessState) error {
squadID := firstString(row, "squadId", "squad_id", "id")
if squadID == "" {
return nil
}
id := scumProjectionID("squad", serverID, squadID)
value, err := svc.store.SCUMSquads().Get(id)
if err == repo.ErrNotFound {
value = domain.SCUMSquad{ID: id, ServerInstanceID: serverID, SquadID: squadID, Freshness: domain.SCUMProjectionStateUnknown(), CreatedAt: svc.now()}
} else if err != nil {
return err
}
if isProjectionOlder(freshness, value.Freshness) {
return nil
}
value.Name = coalesceString(firstString(row, "name", "squadName", "squad_name"), value.Name)
value.LeaderProfileID = coalesceString(firstString(row, "leaderProfileId", "leader_profile_id"), value.LeaderProfileID)
value.LeaderPlayerID = coalesceString(firstString(row, "leaderPlayerId", "leader_player_id", "leaderSteamId"), value.LeaderPlayerID)
if memberCount, ok := firstInt(row, "memberCount", "member_count"); ok {
value.MemberCount = memberCount
}
if score, ok := firstFloat(row, "score", "fame", "points"); ok {
value.Score = score
}
value.UnknownFields = unknownRowFields(row, "squadId", "squad_id", "id", "name", "squadName", "squad_name", "leaderProfileId", "leader_profile_id", "leaderPlayerId", "leader_player_id", "leaderSteamId", "memberCount", "member_count", "score", "fame", "points")
value.Freshness = freshness
value.UpdatedAt = svc.now()
if err == repo.ErrNotFound {
return svc.store.SCUMSquads().Create(value)
}
return svc.store.SCUMSquads().Update(value)
}
func (svc *CoreService) applySCUMSquadMemberRow(serverID string, row map[string]any, freshness domain.SCUMProjectionFreshnessState) error {
squadID := firstString(row, "squadId", "squad_id")
profileID := firstString(row, "userProfileId", "user_profile_id", "profileId")
gamePlayerID := firstString(row, "gamePlayerId", "playerId", "steamId", "steam_id")
if squadID == "" || (profileID == "" && gamePlayerID == "") {
return nil
}
playerRecordID := ""
if gamePlayerID != "" {
playerRecordID = gamePlayerRecordID(serverID, gamePlayerID)
if err := svc.upsertSCUMGamePlayer(serverID, playerRecordID, gamePlayerID, firstString(row, "displayName", "name", "playerName"), freshness.ObservedAt); err != nil {
return err
}
}
id := scumProjectionID("squad-member", serverID, squadID+"/"+coalesceString(profileID, gamePlayerID))
value, err := svc.store.SCUMSquadMembers().Get(id)
if err == repo.ErrNotFound {
value = domain.SCUMSquadMember{ID: id, ServerInstanceID: serverID, SquadID: squadID, UserProfileID: profileID, GamePlayerRecordID: playerRecordID, GamePlayerID: gamePlayerID, Freshness: domain.SCUMProjectionStateUnknown(), CreatedAt: svc.now()}
} else if err != nil {
return err
}
if isProjectionOlder(freshness, value.Freshness) {
return nil
}
value.UserProfileID = coalesceString(profileID, value.UserProfileID)
value.GamePlayerRecordID = coalesceString(playerRecordID, value.GamePlayerRecordID)
value.GamePlayerID = coalesceString(gamePlayerID, value.GamePlayerID)
value.SteamID = coalesceString(firstString(row, "steamId", "steam_id"), value.SteamID)
value.DisplayName = coalesceString(firstString(row, "displayName", "name", "playerName"), value.DisplayName)
value.Rank = coalesceString(firstString(row, "rank", "role"), value.Rank)
if isLeader, ok := firstBool(row, "isLeader", "leader"); ok {
value.IsLeader = isLeader
}
value.JoinedAt = coalesceTime(firstTime(row, "joinedAt", "joined_at"), value.JoinedAt)
value.UnknownFields = unknownRowFields(row, "squadId", "squad_id", "userProfileId", "user_profile_id", "profileId", "gamePlayerId", "playerId", "steamId", "steam_id", "displayName", "name", "playerName", "rank", "role", "isLeader", "leader", "joinedAt", "joined_at")
value.Freshness = freshness
value.UpdatedAt = svc.now()
if err == repo.ErrNotFound {
return svc.store.SCUMSquadMembers().Create(value)
}
return svc.store.SCUMSquadMembers().Update(value)
}
func (svc *CoreService) applySCUMVehicleRow(serverID string, row map[string]any, freshness domain.SCUMProjectionFreshnessState) error {
vehicleID := firstString(row, "vehicleId", "vehicle_id", "id")
entityID := firstString(row, "entityId", "entity_id")
if vehicleID == "" && entityID != "" {
vehicleID = entityID
}
if vehicleID == "" {
return nil
}
id := scumProjectionID("vehicle", serverID, vehicleID)
value, err := svc.store.SCUMVehicles().Get(id)
if err == repo.ErrNotFound {
value = domain.SCUMVehicle{ID: id, ServerInstanceID: serverID, VehicleID: vehicleID, Freshness: domain.SCUMProjectionStateUnknown(), CreatedAt: svc.now()}
} else if err != nil {
return err
}
if isProjectionOlder(freshness, value.Freshness) {
return nil
}
value.EntityID = coalesceString(entityID, value.EntityID)
value.ClassName = coalesceString(firstString(row, "className", "class", "type"), value.ClassName)
value.Label = coalesceString(firstString(row, "label", "vehicleName", "name"), value.Label)
if value.Label == "" {
value.Label = coalesceString(value.ClassName, "Unknown vehicle")
}
value.OwnerProfileID = coalesceString(firstString(row, "ownerProfileId", "owner_profile_id", "userProfileId", "user_profile_id"), value.OwnerProfileID)
value.OwnerPlayerID = coalesceString(firstString(row, "ownerPlayerId", "owner_player_id", "steamId", "steam_id"), value.OwnerPlayerID)
value.SquadID = coalesceString(firstString(row, "squadId", "squad_id"), value.SquadID)
if position, ok := scumPositionFromRow(serverID, domain.SCUMProjectionSubjectVehicle, vehicleID, row, freshness); ok {
position.VehicleID = vehicleID
position.EntityID = entityID
value.Position = position
if err := svc.upsertSCUMPosition(position); err != nil {
return err
}
}
value.UnknownFields = unknownRowFields(row, "vehicleId", "vehicle_id", "id", "entityId", "entity_id", "className", "class", "type", "label", "vehicleName", "name", "ownerProfileId", "owner_profile_id", "userProfileId", "user_profile_id", "ownerPlayerId", "owner_player_id", "steamId", "steam_id", "squadId", "squad_id", "x", "y", "z", "worldX", "worldY", "worldZ", "mapId", "mapVersion")
value.Freshness = freshness
value.UpdatedAt = svc.now()
if err == repo.ErrNotFound {
return svc.store.SCUMVehicles().Create(value)
}
return svc.store.SCUMVehicles().Update(value)
}
func (svc *CoreService) applySCUMFlagRow(serverID string, row map[string]any, freshness domain.SCUMProjectionFreshnessState) error {
flagID := firstString(row, "flagId", "flag_id", "baseElementId", "base_element_id", "id")
entityID := firstString(row, "entityId", "entity_id")
if flagID == "" && entityID != "" {
flagID = entityID
}
if flagID == "" {
return nil
}
id := scumProjectionID("flag", serverID, flagID)
value, err := svc.store.SCUMFlags().Get(id)
if err == repo.ErrNotFound {
value = domain.SCUMFlag{ID: id, ServerInstanceID: serverID, FlagID: flagID, Freshness: domain.SCUMProjectionStateUnknown(), CreatedAt: svc.now()}
} else if err != nil {
return err
}
if isProjectionOlder(freshness, value.Freshness) {
return nil
}
value.EntityID = coalesceString(entityID, value.EntityID)
value.OwnerProfileID = coalesceString(firstString(row, "ownerProfileId", "owner_profile_id", "userProfileId", "user_profile_id"), value.OwnerProfileID)
value.OwnerPlayerID = coalesceString(firstString(row, "ownerPlayerId", "owner_player_id", "steamId", "steam_id"), value.OwnerPlayerID)
value.OwnerSquadID = coalesceString(firstString(row, "ownerSquadId", "owner_squad_id", "squadId", "squad_id"), value.OwnerSquadID)
value.OwnerSquadName = coalesceString(firstString(row, "ownerSquadName", "owner_squad_name", "squadName", "squad_name"), value.OwnerSquadName)
value.OwnershipConfidence = coalesceString(firstString(row, "ownershipConfidence", "ownership_confidence"), value.OwnershipConfidence)
if value.OwnershipConfidence == "" {
value.OwnershipConfidence = "unknown"
}
if position, ok := scumPositionFromRow(serverID, domain.SCUMProjectionSubjectFlag, flagID, row, freshness); ok {
position.EntityID = entityID
value.Position = position
if err := svc.upsertSCUMPosition(position); err != nil {
return err
}
}
value.UnknownFields = unknownRowFields(row, "flagId", "flag_id", "baseElementId", "base_element_id", "id", "entityId", "entity_id", "ownerProfileId", "owner_profile_id", "userProfileId", "user_profile_id", "ownerPlayerId", "owner_player_id", "steamId", "steam_id", "ownerSquadId", "owner_squad_id", "squadId", "squad_id", "ownerSquadName", "owner_squad_name", "squadName", "squad_name", "ownershipConfidence", "ownership_confidence", "x", "y", "z", "worldX", "worldY", "worldZ", "mapId", "mapVersion")
value.Freshness = freshness
value.UpdatedAt = svc.now()
if err == repo.ErrNotFound {
return svc.store.SCUMFlags().Create(value)
}
return svc.store.SCUMFlags().Update(value)
}
func (svc *CoreService) applySCUMPositionRow(serverID string, row map[string]any, freshness domain.SCUMProjectionFreshnessState) error {
subjectType := domain.SCUMProjectionSubject(firstString(row, "subjectType", "subject_type"))
if subjectType == "" {
if firstString(row, "vehicleId", "vehicle_id") != "" {
subjectType = domain.SCUMProjectionSubjectVehicle
} else {
subjectType = domain.SCUMProjectionSubjectPlayer
}
}
subjectID := firstString(row, "subjectId", "subject_id", "gamePlayerId", "playerId", "vehicleId", "flagId", "entityId", "id")
position, ok := scumPositionFromRow(serverID, subjectType, subjectID, row, freshness)
if !ok {
return nil
}
position.GamePlayerID = firstString(row, "gamePlayerId", "playerId", "steamId", "steam_id")
if position.GamePlayerID != "" {
position.GamePlayerRecordID = gamePlayerRecordID(serverID, position.GamePlayerID)
}
position.VehicleID = firstString(row, "vehicleId", "vehicle_id")
position.EntityID = firstString(row, "entityId", "entity_id")
return svc.upsertSCUMPosition(position)
}
func (svc *CoreService) upsertSCUMGamePlayer(serverID, recordID, gamePlayerID, displayName string, observedAt time.Time) error {
if gamePlayerID == "" || recordID == "" {
return nil
}
if observedAt.IsZero() {
observedAt = svc.now()
}
player, err := svc.store.GamePlayers().Get(recordID)
if err == repo.ErrNotFound {
return svc.store.GamePlayers().Create(domain.GamePlayer{ID: recordID, ServerInstanceID: serverID, GamePlayerID: gamePlayerID, DisplayName: displayName, FirstSeenAt: observedAt, LastSeenAt: observedAt, LastEventAt: observedAt, CreatedAt: svc.now(), UpdatedAt: svc.now()})
}
if err != nil {
return err
}
if observedAt.Before(player.LastEventAt) {
return nil
}
player.DisplayName = coalesceString(displayName, player.DisplayName)
player.LastSeenAt = maxTime(player.LastSeenAt, observedAt)
player.LastEventAt = observedAt
player.UpdatedAt = svc.now()
return svc.store.GamePlayers().Update(player)
}
func (svc *CoreService) projectSCUMLoginLiveState(player domain.GamePlayer, batch domain.LogBatchIngest, entry domain.LogEntry, observedAt time.Time, online bool, reason string) error {
if player.ID == "" || player.GamePlayerID == "" {
return nil
}
freshness := domain.SCUMProjectionFreshnessState{Status: domain.SCUMProjectionFresh, ObservationID: entryID(batch.LogStreamID, entry.Seq), Source: "login-log", QueryKey: strings.TrimSpace(entry.Fields["eventType"]), Sequence: entry.Seq, Checksum: validator.LogLineChecksum(entry.Line), ObservedAt: observedAt, ReceivedAt: svc.now()}
id := scumProjectionID("player-live", player.ServerInstanceID, player.GamePlayerID)
state, err := svc.store.SCUMPlayerLiveStates().Get(id)
if err == repo.ErrNotFound {
state = domain.SCUMPlayerLiveState{ID: id, ServerInstanceID: player.ServerInstanceID, GamePlayerRecordID: player.ID, GamePlayerID: player.GamePlayerID, DisplayName: player.DisplayName, Freshness: domain.SCUMProjectionStateUnknown(), CreatedAt: svc.now()}
} else if err != nil {
return err
}
if isProjectionOlder(freshness, state.Freshness) {
return nil
}
state.GamePlayerRecordID = player.ID
state.GamePlayerID = player.GamePlayerID
state.DisplayName = player.DisplayName
state.Online = online
if online {
state.LastLoginAt = observedAt
} else {
state.LastLogoutAt = observedAt
}
state.Freshness = freshness
if reason != "" {
state.UnknownFields = domain.CopyGameClientBridgePayload(map[string]any{"lastLogoutReason": bounded(reason, 80)})
}
state.UpdatedAt = svc.now()
if err == repo.ErrNotFound {
return svc.store.SCUMPlayerLiveStates().Create(state)
}
return svc.store.SCUMPlayerLiveStates().Update(state)
}
func (svc *CoreService) upsertSCUMPosition(position domain.SCUMCurrentPosition) error {
existing, err := svc.store.SCUMCurrentPositions().Get(position.ID)
if err == repo.ErrNotFound {
position.CreatedAt = svc.now()
position.UpdatedAt = svc.now()
return svc.store.SCUMCurrentPositions().Create(position)
}
if err != nil {
return err
}
if isProjectionOlder(position.Freshness, existing.Freshness) {
return nil
}
position.CreatedAt = existing.CreatedAt
position.UpdatedAt = svc.now()
return svc.store.SCUMCurrentPositions().Update(position)
}
func (svc *CoreService) markSCUMQueryStale(result domain.SCUMObservationResult, reason string) error {
freshness := domain.SCUMProjectionFreshnessState{Status: domain.SCUMProjectionStale, ObservationID: scumObservationID(result), Source: result.Source, QueryKey: result.QueryKey, Sequence: result.Sequence, Checksum: result.Checksum, StaleReason: reason, ObservedAt: result.ObservedAt, ReceivedAt: result.ReceivedAt}
lower := strings.ToLower(result.QueryKey)
if strings.Contains(lower, "player") || strings.Contains(lower, "profile") || strings.Contains(lower, "economy") {
values, err := svc.store.SCUMPlayerLiveStates().List(domain.SCUMProjectionFilter{ServerInstanceID: result.ServerInstanceID})
if err != nil {
return err
}
for _, value := range values {
if !isProjectionOlder(freshness, value.Freshness) {
value.Freshness = freshness
value.UpdatedAt = svc.now()
if err := svc.store.SCUMPlayerLiveStates().Update(value); err != nil {
return err
}
}
}
}
if strings.Contains(lower, "squad") {
values, err := svc.store.SCUMSquads().List(domain.SCUMProjectionFilter{ServerInstanceID: result.ServerInstanceID})
if err != nil {
return err
}
for _, value := range values {
if !isProjectionOlder(freshness, value.Freshness) {
value.Freshness = freshness
value.UpdatedAt = svc.now()
if err := svc.store.SCUMSquads().Update(value); err != nil {
return err
}
}
}
}
if strings.Contains(lower, "vehicle") {
values, err := svc.store.SCUMVehicles().List(domain.SCUMProjectionFilter{ServerInstanceID: result.ServerInstanceID})
if err != nil {
return err
}
for _, value := range values {
if !isProjectionOlder(freshness, value.Freshness) {
value.Freshness = freshness
value.UpdatedAt = svc.now()
if err := svc.store.SCUMVehicles().Update(value); err != nil {
return err
}
}
}
}
if strings.Contains(lower, "flag") {
values, err := svc.store.SCUMFlags().List(domain.SCUMProjectionFilter{ServerInstanceID: result.ServerInstanceID})
if err != nil {
return err
}
for _, value := range values {
if !isProjectionOlder(freshness, value.Freshness) {
value.Freshness = freshness
value.UpdatedAt = svc.now()
if err := svc.store.SCUMFlags().Update(value); err != nil {
return err
}
}
}
}
return nil
}
func scumPositionFromRow(serverID string, subjectType domain.SCUMProjectionSubject, subjectID string, row map[string]any, freshness domain.SCUMProjectionFreshnessState) (domain.SCUMCurrentPosition, bool) {
x, hasX := firstFloat(row, "x", "worldX", "world_x", "locationX")
y, hasY := firstFloat(row, "y", "worldY", "world_y", "locationY")
z, hasZ := firstFloat(row, "z", "worldZ", "world_z", "locationZ")
if !hasX || !hasY {
return domain.SCUMCurrentPosition{}, false
}
if subjectID == "" {
return domain.SCUMCurrentPosition{}, false
}
position := domain.SCUMCurrentPosition{ID: scumProjectionID("position-"+string(subjectType), serverID, subjectID), ServerInstanceID: serverID, SubjectType: subjectType, SubjectID: subjectID, MapID: coalesceString(firstString(row, "mapId", "map_id"), domain.SCUMMapTrajectoryMapID), MapVersion: coalesceString(firstString(row, "mapVersion", "map_version"), "0.9"), X: x, Y: y, HasCoordinates: true, LastSaveTime: firstTime(row, "lastSaveTime", "last_save_time"), Freshness: freshness}
if hasZ && !math.IsNaN(z) {
position.Z = z
}
return position, true
}
func isProjectionOlder(next, current domain.SCUMProjectionFreshnessState) bool {
if current.Status == "" || current.Status == domain.SCUMProjectionUnknown {
return false
}
if next.Source == current.Source && next.QueryKey == current.QueryKey && next.Sequence > 0 && current.Sequence > 0 && next.Sequence < current.Sequence {
return true
}
return !next.ObservedAt.IsZero() && !current.ObservedAt.IsZero() && next.ObservedAt.Before(current.ObservedAt)
}
func scumObservationID(result domain.SCUMObservationResult) string {
seed := fmt.Sprintf("%s/%s/%s/%d/%s", result.ServerInstanceID, result.PluginID, result.QueryKey, result.Sequence, result.Checksum)
if result.Checksum == "" {
seed = fmt.Sprintf("%s/%s/%s/%d/%s", result.ServerInstanceID, result.PluginID, result.QueryKey, result.Sequence, result.ObservedAt.Format(time.RFC3339Nano))
}
return "scum-observation-" + fingerprintID(result.ServerInstanceID, seed)
}
func scumProjectionID(kind, serverID, subject string) string {
return "scum-" + kind + "-" + fingerprintID(serverID, subject)
}
func firstString(row map[string]any, keys ...string) string {
for _, key := range keys {
if value, ok := row[key]; ok {
switch typed := value.(type) {
case string:
if trimmed := strings.TrimSpace(typed); trimmed != "" {
return trimmed
}
case fmt.Stringer:
if trimmed := strings.TrimSpace(typed.String()); trimmed != "" {
return trimmed
}
case int, int64, uint64, float64:
return fmt.Sprint(typed)
}
}
}
return ""
}
func firstFloat(row map[string]any, keys ...string) (float64, bool) {
for _, key := range keys {
if value, ok := row[key]; ok {
switch typed := value.(type) {
case float64:
return typed, true
case float32:
return float64(typed), true
case int:
return float64(typed), true
case int64:
return float64(typed), true
case uint64:
return float64(typed), true
case string:
parsed, err := strconv.ParseFloat(strings.TrimSpace(typed), 64)
if err == nil {
return parsed, true
}
}
}
}
return 0, false
}
func firstInt(row map[string]any, keys ...string) (int, bool) {
value, ok := firstFloat(row, keys...)
if !ok {
return 0, false
}
return int(value), true
}
func firstBool(row map[string]any, keys ...string) (bool, bool) {
for _, key := range keys {
if value, ok := row[key]; ok {
switch typed := value.(type) {
case bool:
return typed, true
case string:
parsed, err := strconv.ParseBool(strings.TrimSpace(typed))
if err == nil {
return parsed, true
}
case int:
return typed != 0, true
case int64:
return typed != 0, true
case float64:
return typed != 0, true
}
}
}
return false, false
}
func firstTime(row map[string]any, keys ...string) time.Time {
for _, key := range keys {
if value, ok := row[key]; ok {
switch typed := value.(type) {
case time.Time:
return typed
case string:
trimmed := strings.TrimSpace(typed)
if trimmed == "" {
continue
}
if parsed, err := time.Parse(time.RFC3339Nano, trimmed); err == nil {
return parsed
}
if parsed, err := time.Parse("2006-01-02 15:04:05", trimmed); err == nil {
return parsed.UTC()
}
case int64:
return time.Unix(typed, 0).UTC()
case float64:
return time.Unix(int64(typed), 0).UTC()
}
}
}
return time.Time{}
}
func unknownRowFields(row map[string]any, known ...string) map[string]any {
knownSet := map[string]struct{}{}
for _, key := range known {
knownSet[key] = struct{}{}
}
unknown := map[string]any{}
for key, value := range row {
if _, ok := knownSet[key]; ok {
continue
}
unknown[key] = value
}
if len(unknown) == 0 {
return nil
}
return domain.CopyGameClientBridgePayload(unknown)
}
func coalesceString(next, current string) string {
if strings.TrimSpace(next) != "" {
return strings.TrimSpace(next)
}
return current
}
func coalesceTime(next, current time.Time) time.Time {
if !next.IsZero() {
return next
}
return current
}
func limitSCUMProjectionSlice[T any](values *[]T, limit int) {
if limit > 0 && len(*values) > limit {
*values = (*values)[:limit]
}
}
+110
View File
@@ -0,0 +1,110 @@
package service
import (
"testing"
"time"
"browser.local/platform/domain"
)
func TestSCUMObservationProjectsRealRowsAndSeparatesProfileFromSteamID(t *testing.T) {
svc, _ := newRegisteredLogIngestService(t)
observed := time.Date(2026, 8, 10, 9, 0, 0, 0, time.UTC)
observation, err := svc.ApplySCUMObservationResult(domain.SCUMObservationResult{
ServerInstanceID: "server-1",
PluginID: "server.scum",
Source: "run.sqlite.read",
QueryKey: "scum.player.profile",
Sequence: 10,
Checksum: "sha256:profile-10",
ObservedAt: observed,
Rows: []map[string]any{{
"gamePlayerId": "steam-1",
"userProfileId": "profile-99",
"steamId": "steam-1",
"displayName": "Moon",
"squadId": "squad-1",
"famePoints": 42,
"normalBalance": 500.0,
"goldBalance": 7.0,
"x": 100,
"y": 200,
"z": 30,
"lastSaveTime": observed.Add(-time.Minute).Format(time.RFC3339),
"future_column": "preserved",
}},
})
if err != nil || observation.Status != domain.SCUMObservationAccepted {
t.Fatalf("apply observation=%+v err=%v", observation, err)
}
player, err := svc.store.GamePlayers().Get(gamePlayerRecordID("server-1", "steam-1"))
if err != nil || player.DisplayName != "Moon" {
t.Fatalf("expected game player from real row: player=%+v err=%v", player, err)
}
states, err := svc.store.SCUMPlayerLiveStates().List(domain.SCUMProjectionFilter{ServerInstanceID: "server-1", UserProfileID: "profile-99"})
if err != nil || len(states) != 1 {
t.Fatalf("states=%+v err=%v", states, err)
}
state := states[0]
if state.GamePlayerID != "steam-1" || state.UserProfileID != "profile-99" || state.SteamID != "steam-1" || state.NormalBalance != 500 || state.Online {
t.Fatalf("identity/economy projection mixed IDs or inferred online incorrectly: %+v", state)
}
if !state.Position.HasCoordinates || state.Position.X != 100 || state.Position.Y != 200 || state.UnknownFields["future_column"] != "preserved" {
t.Fatalf("position/unknown fields not projected safely: %+v", state)
}
stale, err := svc.ApplySCUMObservationResult(domain.SCUMObservationResult{ServerInstanceID: "server-1", PluginID: "server.scum", Source: "run.sqlite.read", QueryKey: "scum.player.profile", Sequence: 9, Checksum: "sha256:profile-9", ObservedAt: observed.Add(-time.Hour), Rows: []map[string]any{{"gamePlayerId": "steam-1", "userProfileId": "profile-99", "displayName": "Old", "normalBalance": 9999}}})
if err != nil || stale.Status != domain.SCUMObservationStale || stale.ErrorCode != "older_observation" {
t.Fatalf("expected older observation stale, got %+v err=%v", stale, err)
}
again, err := svc.store.SCUMPlayerLiveStates().Get(state.ID)
if err != nil || again.DisplayName != "Moon" || again.NormalBalance != 500 {
t.Fatalf("older observation overwrote last-known-good: %+v err=%v", again, err)
}
}
func TestSCUMFailedObservationMarksStaleWithoutOverwritingProjection(t *testing.T) {
svc, _ := newRegisteredLogIngestService(t)
observed := time.Date(2026, 8, 10, 10, 0, 0, 0, time.UTC)
if _, err := svc.ApplySCUMObservationResult(domain.SCUMObservationResult{ServerInstanceID: "server-1", PluginID: "server.scum", Source: "run.sqlite.read", QueryKey: "scum.player.profile", Sequence: 1, Checksum: "sha256:ok", ObservedAt: observed, Rows: []map[string]any{{"gamePlayerId": "steam-2", "userProfileId": "profile-2", "displayName": "Nova", "normalBalance": 125}}}); err != nil {
t.Fatalf("apply initial observation: %v", err)
}
failed, err := svc.ApplySCUMObservationResult(domain.SCUMObservationResult{ServerInstanceID: "server-1", PluginID: "server.scum", Source: "run.sqlite.read", QueryKey: "scum.player.profile", Sequence: 2, Checksum: "sha256:failed", Status: domain.SCUMObservationFailed, ErrorCode: "sqlite_busy", ObservedAt: observed.Add(time.Minute)})
if err != nil || failed.Status != domain.SCUMObservationFailed {
t.Fatalf("failed observation=%+v err=%v", failed, err)
}
states, err := svc.store.SCUMPlayerLiveStates().List(domain.SCUMProjectionFilter{ServerInstanceID: "server-1", GamePlayerID: "steam-2"})
if err != nil || len(states) != 1 {
t.Fatalf("states=%+v err=%v", states, err)
}
if states[0].NormalBalance != 125 || states[0].Freshness.Status != domain.SCUMProjectionStale || states[0].Freshness.StaleReason != "observation_failed" {
t.Fatalf("failed query did not preserve values and mark stale: %+v", states[0])
}
}
func TestSCUMLoginLogsProjectLiveStateAndDatabaseSaveTimeDoesNotProveOnline(t *testing.T) {
svc, token := newRegisteredLogIngestService(t)
createLogStreamFixture(t, svc)
base := time.Date(2026, 8, 10, 11, 0, 0, 0, time.UTC)
login := gamePlayerBatch(t, token, 1, []domain.LogEntry{{Seq: 1, Timestamp: base, Line: "login accepted", Fields: map[string]string{"eventType": "scum.login", "playerId": "steam-3", "playerName": "Comet", "sessionId": "session-3", "outcome": "accepted"}}})
if _, err := svc.IngestLogBatch(login); err != nil {
t.Fatalf("ingest login: %v", err)
}
states, err := svc.store.SCUMPlayerLiveStates().List(domain.SCUMProjectionFilter{ServerInstanceID: "server-1", GamePlayerID: "steam-3"})
if err != nil || len(states) != 1 || !states[0].Online {
t.Fatalf("login did not mark live state online: states=%+v err=%v", states, err)
}
logout := gamePlayerBatch(t, token, 2, []domain.LogEntry{{Seq: 2, Timestamp: base.Add(time.Minute), Line: "logout", Fields: map[string]string{"eventType": "scum.logout", "playerId": "steam-3", "playerName": "Comet", "sessionId": "session-3", "reason": "disconnect"}}})
if _, err := svc.IngestLogBatch(logout); err != nil {
t.Fatalf("ingest logout: %v", err)
}
if _, err := svc.ApplySCUMObservationResult(domain.SCUMObservationResult{ServerInstanceID: "server-1", PluginID: "server.scum", Source: "run.sqlite.read", QueryKey: "scum.player.profile", Sequence: 3, Checksum: "sha256:save-time", ObservedAt: base.Add(2 * time.Minute), Rows: []map[string]any{{"gamePlayerId": "steam-3", "userProfileId": "profile-3", "displayName": "Comet", "lastSaveTime": base.Add(90 * time.Second).Format(time.RFC3339)}}}); err != nil {
t.Fatalf("apply save-time observation: %v", err)
}
states, err = svc.store.SCUMPlayerLiveStates().List(domain.SCUMProjectionFilter{ServerInstanceID: "server-1", GamePlayerID: "steam-3"})
if err != nil || len(states) != 1 {
t.Fatalf("states=%+v err=%v", states, err)
}
if states[0].Online || states[0].LastSaveTime.IsZero() {
t.Fatalf("last_save_time was incorrectly treated as online proof: %+v", states[0])
}
}
+393
View File
@@ -0,0 +1,393 @@
package service
import (
"fmt"
"sort"
"strings"
"time"
"browser.local/platform/domain"
"browser.local/platform/repo"
)
type scumWorkflowTemplateDefinition struct {
Key string
Title string
Steps []scumWorkflowStepDefinition
}
type scumWorkflowStepDefinition struct {
Key string
DependsOn []string
OperationKey string
QueryTemplateKey string
Capability string
TargetKey string
MutatesState bool
MaxAttempts int
Summary string
}
func (svc *CoreService) CreateSCUMWorkflowForSession(sessionID, serverID string, request domain.SCUMWorkflowInstance) (domain.SCUMWorkflowInstance, error) {
request = domain.CopySCUMWorkflowInstance(request)
user, err := svc.GetCurrentUser(sessionID)
if err != nil {
return domain.SCUMWorkflowInstance{}, err
}
if err := svc.authorizeServerLifecycle(sessionID, serverID); err != nil {
return domain.SCUMWorkflowInstance{}, err
}
instance, err := svc.store.ServerInstances().Get(serverID)
if err != nil {
return domain.SCUMWorkflowInstance{}, err
}
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
if err != nil {
return domain.SCUMWorkflowInstance{}, err
}
template, ok := scumWorkflowTemplates()[request.TemplateKey]
if !ok {
return domain.SCUMWorkflowInstance{}, validationError("SCUM workflow template is not declared")
}
if strings.TrimSpace(request.IdempotencyKey) == "" || len(request.IdempotencyKey) > 120 {
return domain.SCUMWorkflowInstance{}, validationError("workflow idempotency key is required")
}
if existing, err := svc.store.SCUMWorkflowInstances().List(domain.SCUMWorkflowInstanceFilter{ServerInstanceID: serverID, IdempotencyKey: request.IdempotencyKey}); err == nil && len(existing) > 0 {
return domain.CopySCUMWorkflowInstance(existing[0]), nil
} else if err != nil {
return domain.SCUMWorkflowInstance{}, err
}
stamp := svc.now()
workflow := domain.SCUMWorkflowInstance{ID: "scum-workflow-" + fingerprintID(serverID, request.IdempotencyKey), ServerInstanceID: serverID, PluginID: plugin.ID, TemplateKey: template.Key, RequestedBy: user.ID, IdempotencyKey: request.IdempotencyKey, Status: domain.SCUMWorkflowQueued, Input: domain.CopyGameClientBridgePayload(request.Input), SafeSummary: domain.SCUMSafeSummary{Title: template.Title, Message: "SCUM workflow queued with typed steps and safe summaries."}, CreatedAt: stamp, UpdatedAt: stamp}
if err := svc.store.SCUMWorkflowInstances().Create(workflow); err != nil {
return domain.SCUMWorkflowInstance{}, err
}
for index, step := range template.Steps {
maxAttempts := step.MaxAttempts
if maxAttempts == 0 {
maxAttempts = 1
}
record := domain.SCUMWorkflowStep{ID: fmt.Sprintf("%s.step.%02d.%s", workflow.ID, index+1, step.Key), WorkflowID: workflow.ID, ServerInstanceID: serverID, StepKey: step.Key, DependsOn: domain.CopyStringSlice(step.DependsOn), Status: domain.SCUMWorkflowStepQueued, OperationKey: step.OperationKey, QueryTemplateKey: step.QueryTemplateKey, Capability: step.Capability, TargetKey: step.TargetKey, MaxAttempts: maxAttempts, MutatesState: step.MutatesState, SafeSummary: domain.SCUMSafeSummary{Title: step.Key, Message: step.Summary}, CreatedAt: stamp, UpdatedAt: stamp}
if err := svc.store.SCUMWorkflowSteps().Create(record); err != nil {
return domain.SCUMWorkflowInstance{}, err
}
}
_, err = svc.recordAuditEventWithID(user.ID, "scum.workflow.create", "scum-workflow", workflow.ID, domain.AuditResultQueued, "typed SCUM workflow queued")
return domain.CopySCUMWorkflowInstance(workflow), err
}
func (svc *CoreService) ListSCUMWorkflowsForSession(sessionID string, filter domain.SCUMWorkflowInstanceFilter) ([]domain.SCUMWorkflowInstance, error) {
if err := svc.authorizeServerLifecycle(sessionID, filter.ServerInstanceID); err != nil {
return nil, err
}
values, err := svc.store.SCUMWorkflowInstances().List(filter)
if err != nil {
return nil, err
}
limitSCUMProjectionSlice(&values, filter.Limit)
return values, nil
}
func (svc *CoreService) ListSCUMWorkflowStepsForSession(sessionID string, filter domain.SCUMWorkflowStepFilter) ([]domain.SCUMWorkflowStep, error) {
if err := svc.authorizeServerLifecycle(sessionID, filter.ServerInstanceID); err != nil {
return nil, err
}
values, err := svc.store.SCUMWorkflowSteps().List(filter)
if err != nil {
return nil, err
}
limitSCUMProjectionSlice(&values, filter.Limit)
return values, nil
}
func (svc *CoreService) DispatchNextSCUMWorkflowSteps(serverID string, limit int) ([]domain.SCUMWorkflowStep, error) {
if limit <= 0 {
limit = 1
}
workflows, err := svc.store.SCUMWorkflowInstances().List(domain.SCUMWorkflowInstanceFilter{ServerInstanceID: serverID})
if err != nil {
return nil, err
}
sort.SliceStable(workflows, func(i, j int) bool {
if workflows[i].CreatedAt.Equal(workflows[j].CreatedAt) {
return workflows[i].IdempotencyKey < workflows[j].IdempotencyKey
}
return workflows[i].CreatedAt.Before(workflows[j].CreatedAt)
})
dispatched := []domain.SCUMWorkflowStep{}
activeMutating, err := svc.hasActiveSCUMMutatingStep(serverID)
if err != nil {
return nil, err
}
for _, workflow := range workflows {
if !scumWorkflowRunnable(workflow.Status) || len(dispatched) >= limit {
continue
}
steps, err := svc.sortedSCUMWorkflowSteps(workflow.ID)
if err != nil {
return nil, err
}
for _, step := range steps {
if len(dispatched) >= limit || !scumWorkflowStepRunnable(step.Status) || !scumWorkflowDependenciesConfirmed(step, steps) {
continue
}
if step.MutatesState && activeMutating {
return dispatched, nil
}
if blocked, err := svc.blockSCUMStepIfRunUnavailable(workflow, step); err != nil || blocked.ID != "" {
if err != nil {
return nil, err
}
dispatched = append(dispatched, blocked)
return dispatched, nil
}
step.Status = domain.SCUMWorkflowStepRunning
step.Attempt++
step.UpdatedAt = svc.now()
if err := svc.store.SCUMWorkflowSteps().Update(step); err != nil {
return nil, err
}
workflow.Status = domain.SCUMWorkflowRunning
workflow.CurrentStepKey = step.StepKey
workflow.UpdatedAt = step.UpdatedAt
if err := svc.store.SCUMWorkflowInstances().Update(workflow); err != nil {
return nil, err
}
dispatched = append(dispatched, domain.CopySCUMWorkflowStep(step))
if step.MutatesState {
activeMutating = true
return dispatched, nil
}
}
}
return dispatched, nil
}
func (svc *CoreService) CompleteSCUMWorkflowStep(stepID string, status domain.SCUMWorkflowStepStatus, confirmation domain.SCUMOperationConfirmation) (domain.SCUMWorkflowInstance, error) {
step, err := svc.store.SCUMWorkflowSteps().Get(stepID)
if err != nil {
return domain.SCUMWorkflowInstance{}, err
}
workflow, err := svc.store.SCUMWorkflowInstances().Get(step.WorkflowID)
if err != nil {
return domain.SCUMWorkflowInstance{}, err
}
if !scumWorkflowStepTerminal(status) {
return domain.SCUMWorkflowInstance{}, validationError("SCUM workflow step completion status must be terminal")
}
stamp := svc.now()
step.Status = status
step.Confirmation = domain.CopySCUMOperationConfirmation(confirmation)
step.CompletedAt = stamp
step.UpdatedAt = stamp
if err := svc.store.SCUMWorkflowSteps().Update(step); err != nil {
return domain.SCUMWorkflowInstance{}, err
}
return svc.refreshSCUMWorkflowStatus(workflow)
}
func (svc *CoreService) RetrySCUMWorkflowStep(stepID string) (domain.SCUMWorkflowStep, error) {
step, err := svc.store.SCUMWorkflowSteps().Get(stepID)
if err != nil {
return domain.SCUMWorkflowStep{}, err
}
workflow, err := svc.store.SCUMWorkflowInstances().Get(step.WorkflowID)
if err != nil {
return domain.SCUMWorkflowStep{}, err
}
if step.Attempt >= step.MaxAttempts {
return domain.SCUMWorkflowStep{}, validationError("SCUM workflow step retry limit reached")
}
if step.MutatesState && step.Status == domain.SCUMWorkflowStepUnknown && step.Confirmation.Status != "confirmed" {
step.SafeSummary = domain.SCUMSafeSummary{Title: "确认后才能重试", Message: "State-changing SCUM step is unknown; workflow must run confirmation/readback before retry to avoid duplicate effects."}
step.UpdatedAt = svc.now()
if err := svc.store.SCUMWorkflowSteps().Update(step); err != nil {
return domain.SCUMWorkflowStep{}, err
}
return domain.CopySCUMWorkflowStep(step), nil
}
step.Status = domain.SCUMWorkflowStepQueued
step.Confirmation = domain.SCUMOperationConfirmation{}
step.CompletedAt = time.Time{}
step.UpdatedAt = svc.now()
if err := svc.store.SCUMWorkflowSteps().Update(step); err != nil {
return domain.SCUMWorkflowStep{}, err
}
workflow.Status = domain.SCUMWorkflowQueued
workflow.BlockerReason = ""
workflow.UpdatedAt = step.UpdatedAt
if err := svc.store.SCUMWorkflowInstances().Update(workflow); err != nil {
return domain.SCUMWorkflowStep{}, err
}
return domain.CopySCUMWorkflowStep(step), nil
}
func (svc *CoreService) sortedSCUMWorkflowSteps(workflowID string) ([]domain.SCUMWorkflowStep, error) {
steps, err := svc.store.SCUMWorkflowSteps().List(domain.SCUMWorkflowStepFilter{WorkflowID: workflowID})
if err != nil {
return nil, err
}
sort.SliceStable(steps, func(i, j int) bool {
if steps[i].CreatedAt.Equal(steps[j].CreatedAt) {
return steps[i].ID < steps[j].ID
}
return steps[i].CreatedAt.Before(steps[j].CreatedAt)
})
return steps, nil
}
func (svc *CoreService) hasActiveSCUMMutatingStep(serverID string) (bool, error) {
mutates := true
for _, status := range []domain.SCUMWorkflowStepStatus{domain.SCUMWorkflowStepRunning, domain.SCUMWorkflowStepConfirming} {
steps, err := svc.store.SCUMWorkflowSteps().List(domain.SCUMWorkflowStepFilter{ServerInstanceID: serverID, Status: status, MutatesState: &mutates})
if err != nil {
return false, err
}
if len(steps) > 0 {
return true, nil
}
}
return false, nil
}
func (svc *CoreService) blockSCUMStepIfRunUnavailable(workflow domain.SCUMWorkflowInstance, step domain.SCUMWorkflowStep) (domain.SCUMWorkflowStep, error) {
if strings.TrimSpace(step.Capability) == "" {
return domain.SCUMWorkflowStep{}, nil
}
instance, err := svc.store.ServerInstances().Get(workflow.ServerInstanceID)
if err != nil {
return domain.SCUMWorkflowStep{}, err
}
endpoint, err := svc.store.RunEndpoints().Get(instance.RunEndpointID)
if err != nil {
if err == repo.ErrNotFound {
return svc.blockSCUMWorkflowStep(workflow, step, "Run unavailable", "No bound run endpoint is available for this typed SCUM workflow step.")
}
return domain.SCUMWorkflowStep{}, err
}
if err := svc.validateRunnableEndpoint(endpoint, step.Capability); err != nil {
return svc.blockSCUMWorkflowStep(workflow, step, "Run unavailable", "Bound run cannot currently claim the declared workflow capability.")
}
return domain.SCUMWorkflowStep{}, nil
}
func (svc *CoreService) blockSCUMWorkflowStep(workflow domain.SCUMWorkflowInstance, step domain.SCUMWorkflowStep, title string, message string) (domain.SCUMWorkflowStep, error) {
stamp := svc.now()
step.Status = domain.SCUMWorkflowStepBlocked
step.SafeSummary = domain.SCUMSafeSummary{Title: title, Message: message, Details: map[string]string{"stepKey": step.StepKey, "capability": step.Capability}}
step.UpdatedAt = stamp
workflow.Status = domain.SCUMWorkflowBlocked
workflow.CurrentStepKey = step.StepKey
workflow.BlockerReason = title
workflow.SafeSummary = step.SafeSummary
workflow.UpdatedAt = stamp
if err := svc.store.SCUMWorkflowSteps().Update(step); err != nil {
return domain.SCUMWorkflowStep{}, err
}
if err := svc.store.SCUMWorkflowInstances().Update(workflow); err != nil {
return domain.SCUMWorkflowStep{}, err
}
return domain.CopySCUMWorkflowStep(step), nil
}
func (svc *CoreService) refreshSCUMWorkflowStatus(workflow domain.SCUMWorkflowInstance) (domain.SCUMWorkflowInstance, error) {
steps, err := svc.sortedSCUMWorkflowSteps(workflow.ID)
if err != nil {
return domain.SCUMWorkflowInstance{}, err
}
allConfirmed := len(steps) > 0
stamp := svc.now()
for _, step := range steps {
switch step.Status {
case domain.SCUMWorkflowStepFailed:
workflow.Status = domain.SCUMWorkflowFailed
case domain.SCUMWorkflowStepUnknown:
workflow.Status = domain.SCUMWorkflowUnknown
case domain.SCUMWorkflowStepCancelled:
workflow.Status = domain.SCUMWorkflowCancelled
case domain.SCUMWorkflowStepConfirmed:
default:
allConfirmed = false
}
if workflow.Status == domain.SCUMWorkflowFailed || workflow.Status == domain.SCUMWorkflowUnknown || workflow.Status == domain.SCUMWorkflowCancelled {
workflow.CurrentStepKey = step.StepKey
workflow.CompletedAt = stamp
workflow.UpdatedAt = stamp
return domain.CopySCUMWorkflowInstance(workflow), svc.store.SCUMWorkflowInstances().Update(workflow)
}
}
if allConfirmed {
workflow.Status = domain.SCUMWorkflowConfirmed
workflow.CurrentStepKey = ""
workflow.CompletedAt = stamp
} else {
workflow.Status = domain.SCUMWorkflowQueued
workflow.CurrentStepKey = ""
}
workflow.UpdatedAt = stamp
if err := svc.store.SCUMWorkflowInstances().Update(workflow); err != nil {
return domain.SCUMWorkflowInstance{}, err
}
return domain.CopySCUMWorkflowInstance(workflow), nil
}
func scumWorkflowDependenciesConfirmed(step domain.SCUMWorkflowStep, steps []domain.SCUMWorkflowStep) bool {
if len(step.DependsOn) == 0 {
return true
}
statuses := map[string]domain.SCUMWorkflowStepStatus{}
for _, candidate := range steps {
statuses[candidate.StepKey] = candidate.Status
}
for _, dependency := range step.DependsOn {
if statuses[dependency] != domain.SCUMWorkflowStepConfirmed {
return false
}
}
return true
}
func scumWorkflowRunnable(status domain.SCUMWorkflowStatus) bool {
switch status {
case domain.SCUMWorkflowQueued, domain.SCUMWorkflowRunning, domain.SCUMWorkflowWaiting:
return true
default:
return false
}
}
func scumWorkflowStepRunnable(status domain.SCUMWorkflowStepStatus) bool {
switch status {
case domain.SCUMWorkflowStepQueued, domain.SCUMWorkflowStepWaiting:
return true
default:
return false
}
}
func scumWorkflowStepTerminal(status domain.SCUMWorkflowStepStatus) bool {
switch status {
case domain.SCUMWorkflowStepConfirmed, domain.SCUMWorkflowStepFailed, domain.SCUMWorkflowStepUnknown, domain.SCUMWorkflowStepCancelled:
return true
default:
return false
}
}
func scumWorkflowTemplates() map[string]scumWorkflowTemplateDefinition {
read := domain.JobCapabilityRemoteRunDBSQLiteQuery
logs := domain.JobCapabilityRemoteRunLogsTransfer
protectedSQL := domain.JobCapabilityRemoteRunProtectedSQL
rcon := domain.JobCapabilityRemoteRunRCONCommand
return map[string]scumWorkflowTemplateDefinition{
"scum.bootstrap-real-data": {Key: "scum.bootstrap-real-data", Title: "Bootstrap SCUM real data", Steps: []scumWorkflowStepDefinition{{Key: "verify-run-binding", Capability: read, TargetKey: "scum-database", Summary: "Verify run binding and SCUM.db query capability."}, {Key: "schema-probe", DependsOn: []string{"verify-run-binding"}, Capability: read, TargetKey: "scum-database", QueryTemplateKey: "scum.schema.probe", Summary: "Probe SCUM.db schema before projection refresh."}, {Key: "login-cursor", DependsOn: []string{"schema-probe"}, Capability: logs, TargetKey: "scum-login", Summary: "Initialize login log observation cursor."}}},
"scum.player-refresh": {Key: "scum.player-refresh", Title: "Refresh SCUM player", Steps: []scumWorkflowStepDefinition{{Key: "login-evidence", Capability: logs, TargetKey: "scum-login", Summary: "Sync login/logout evidence."}, {Key: "player-profile", DependsOn: []string{"login-evidence"}, Capability: read, TargetKey: "scum-database", QueryTemplateKey: "scum.player.profile", Summary: "Read player profile/economy facts."}, {Key: "position-read", DependsOn: []string{"player-profile"}, Capability: read, TargetKey: "scum-database", QueryTemplateKey: "scum.positions", Summary: "Read current player coordinates."}}},
"scum.world-refresh": {Key: "scum.world-refresh", Title: "Refresh SCUM world", Steps: []scumWorkflowStepDefinition{{Key: "squad-read", Capability: read, TargetKey: "scum-database", QueryTemplateKey: "scum.squads", MaxAttempts: 2, Summary: "Refresh squads."}, {Key: "vehicle-read", Capability: read, TargetKey: "scum-database", QueryTemplateKey: "scum.vehicles", MaxAttempts: 2, Summary: "Refresh vehicles."}, {Key: "flag-read", Capability: read, TargetKey: "scum-database", QueryTemplateKey: "scum.flags", MaxAttempts: 2, Summary: "Refresh flags."}, {Key: "position-read", Capability: read, TargetKey: "scum-database", QueryTemplateKey: "scum.positions", MaxAttempts: 2, Summary: "Refresh map positions."}}},
"scum.player-correction": {Key: "scum.player-correction", Title: "SCUM player correction", Steps: []scumWorkflowStepDefinition{{Key: "safety-check", Capability: read, TargetKey: "scum-database", QueryTemplateKey: "scum.player.profile", Summary: "Verify current projection, before value, offline state, and backup evidence."}, {Key: "apply-operation", DependsOn: []string{"safety-check"}, Capability: protectedSQL, TargetKey: "scum-database", OperationKey: "player.attribute.855.set", MutatesState: true, Summary: "Apply the approved typed operation through Run."}, {Key: "confirmation-read", DependsOn: []string{"apply-operation"}, Capability: read, TargetKey: "scum-database", QueryTemplateKey: "scum.player.profile", Summary: "Confirm the requested value by readback."}}},
"scum.gift-delivery": {Key: "scum.gift-delivery", Title: "SCUM gift delivery", Steps: []scumWorkflowStepDefinition{{Key: "eligibility-check", Summary: "Evaluate gift eligibility and idempotency."}, {Key: "deliver-reward", DependsOn: []string{"eligibility-check"}, Capability: rcon, TargetKey: "scum-management", OperationKey: "reward.deliver", MutatesState: true, MaxAttempts: 2, Summary: "Deliver approved reward through typed operation."}, {Key: "notify-player", DependsOn: []string{"deliver-reward"}, Capability: rcon, TargetKey: "scum-management", OperationKey: "player.notify", MutatesState: true, Summary: "Notify the player after delivery."}, {Key: "confirmation-read", DependsOn: []string{"notify-player"}, Capability: read, TargetKey: "scum-database", QueryTemplateKey: "scum.player.profile", Summary: "Confirm grant state/readback before marking delivered."}}},
"scum.territory-audit": {Key: "scum.territory-audit", Title: "SCUM territory audit", Steps: []scumWorkflowStepDefinition{{Key: "squad-roster", Capability: read, TargetKey: "scum-database", QueryTemplateKey: "scum.squad-members", Summary: "Refresh squad rosters."}, {Key: "flag-ownership", Capability: read, TargetKey: "scum-database", QueryTemplateKey: "scum.flags", Summary: "Refresh flag ownership."}, {Key: "risk-signal", DependsOn: []string{"squad-roster", "flag-ownership"}, Summary: "Project stale owner/member risk signals."}}},
"scum.vehicle-audit": {Key: "scum.vehicle-audit", Title: "SCUM vehicle audit", Steps: []scumWorkflowStepDefinition{{Key: "vehicle-read", Capability: read, TargetKey: "scum-database", QueryTemplateKey: "scum.vehicles", Summary: "Refresh vehicle inventory."}, {Key: "vehicle-map", DependsOn: []string{"vehicle-read"}, Capability: read, TargetKey: "scum-database", QueryTemplateKey: "scum.positions", Summary: "Refresh vehicle map overlays."}}},
"scum.ai-assist": {Key: "scum.ai-assist", Title: "SCUM AI assist", Steps: []scumWorkflowStepDefinition{{Key: "collect-allowed-fields", Summary: "Collect plugin-declared config fields and workflow inputs."}, {Key: "draft-review", DependsOn: []string{"collect-allowed-fields"}, Summary: "Create a reviewable typed diff or workflow draft."}, {Key: "approved-dispatch", DependsOn: []string{"draft-review"}, MutatesState: true, Summary: "Dispatch only after human approval through typed paths."}}},
"scum.product-cleanup": {Key: "scum.product-cleanup", Title: "SCUM product cleanup", Steps: []scumWorkflowStepDefinition{{Key: "remove-raw-routes", Summary: "Remove raw logs, terminal, config, and operation-history product routes."}, {Key: "publish-safe-status", DependsOn: []string{"remove-raw-routes"}, Summary: "Route users to safe workflow/status surfaces."}}},
}
}
+132
View File
@@ -0,0 +1,132 @@
package service
import (
"strings"
"testing"
"time"
"browser.local/platform/domain"
"browser.local/platform/repo"
)
func TestSCUMWorkflowDispatchesReadStepsWithBoundedConcurrencyAndIdempotency(t *testing.T) {
svc, session, instance := newSCUMWorkflowFixture(t, true)
workflow, err := svc.CreateSCUMWorkflowForSession(session, instance.ID, domain.SCUMWorkflowInstance{TemplateKey: "scum.world-refresh", IdempotencyKey: "world-refresh-1", Input: map[string]any{"scope": "world"}})
if err != nil || workflow.Status != domain.SCUMWorkflowQueued {
t.Fatalf("create world workflow=%+v err=%v", workflow, err)
}
duplicate, err := svc.CreateSCUMWorkflowForSession(session, instance.ID, domain.SCUMWorkflowInstance{TemplateKey: "scum.world-refresh", IdempotencyKey: "world-refresh-1"})
if err != nil || duplicate.ID != workflow.ID {
t.Fatalf("expected idempotent workflow create: duplicate=%+v err=%v", duplicate, err)
}
dispatched, err := svc.DispatchNextSCUMWorkflowSteps(instance.ID, 3)
if err != nil || len(dispatched) != 3 {
t.Fatalf("expected three bounded read steps dispatched: steps=%+v err=%v", dispatched, err)
}
for _, step := range dispatched {
if step.MutatesState || step.Status != domain.SCUMWorkflowStepRunning || step.Attempt != 1 {
t.Fatalf("unexpected read step dispatch: %+v", step)
}
}
}
func TestSCUMWorkflowSerializesMutatingStepsPerServer(t *testing.T) {
svc, session, instance := newSCUMWorkflowFixture(t, true)
first, err := svc.CreateSCUMWorkflowForSession(session, instance.ID, domain.SCUMWorkflowInstance{TemplateKey: "scum.gift-delivery", IdempotencyKey: "gift-1"})
if err != nil {
t.Fatalf("create first gift workflow: %v", err)
}
if _, err := svc.CreateSCUMWorkflowForSession(session, instance.ID, domain.SCUMWorkflowInstance{TemplateKey: "scum.gift-delivery", IdempotencyKey: "gift-2"}); err != nil {
t.Fatalf("create second gift workflow: %v", err)
}
steps, err := svc.DispatchNextSCUMWorkflowSteps(instance.ID, 1)
if err != nil || len(steps) != 1 || steps[0].StepKey != "eligibility-check" {
t.Fatalf("expected first eligibility step: steps=%+v err=%v", steps, err)
}
if _, err := svc.CompleteSCUMWorkflowStep(steps[0].ID, domain.SCUMWorkflowStepConfirmed, domain.SCUMOperationConfirmation{Status: "confirmed"}); err != nil {
t.Fatalf("complete eligibility: %v", err)
}
steps, err = svc.DispatchNextSCUMWorkflowSteps(instance.ID, 1)
if err != nil || len(steps) != 1 || steps[0].StepKey != "deliver-reward" || !steps[0].MutatesState {
t.Fatalf("expected first mutating reward step: steps=%+v err=%v", steps, err)
}
if steps[0].WorkflowID != first.ID {
t.Fatalf("expected first workflow to keep the mutation slot: step=%+v first=%+v", steps[0], first)
}
blockedByActiveMutation, err := svc.DispatchNextSCUMWorkflowSteps(instance.ID, 1)
if err != nil {
t.Fatalf("dispatch while mutation active: %v", err)
}
for _, step := range blockedByActiveMutation {
if step.MutatesState {
t.Fatalf("second state-changing step should wait for first terminal state: steps=%+v", blockedByActiveMutation)
}
}
}
func TestSCUMWorkflowBlocksWhenRunUnavailable(t *testing.T) {
svc, session, instance := newSCUMWorkflowFixture(t, false)
workflow, err := svc.CreateSCUMWorkflowForSession(session, instance.ID, domain.SCUMWorkflowInstance{TemplateKey: "scum.player-refresh", IdempotencyKey: "player-refresh-blocked"})
if err != nil {
t.Fatalf("create player refresh workflow: %v", err)
}
steps, err := svc.DispatchNextSCUMWorkflowSteps(instance.ID, 1)
if err != nil || len(steps) != 1 || steps[0].Status != domain.SCUMWorkflowStepBlocked {
t.Fatalf("expected blocked run step: steps=%+v err=%v", steps, err)
}
updated, err := svc.store.SCUMWorkflowInstances().Get(workflow.ID)
if err != nil || updated.Status != domain.SCUMWorkflowBlocked || strings.Contains(updated.SafeSummary.Message, "/") || strings.Contains(strings.ToLower(updated.SafeSummary.Message), "token") {
t.Fatalf("workflow blocker should be safe: workflow=%+v err=%v", updated, err)
}
}
func TestSCUMWorkflowRetryRequiresConfirmationAfterUnknownMutation(t *testing.T) {
svc, session, instance := newSCUMWorkflowFixture(t, true)
if _, err := svc.CreateSCUMWorkflowForSession(session, instance.ID, domain.SCUMWorkflowInstance{TemplateKey: "scum.gift-delivery", IdempotencyKey: "gift-unknown"}); err != nil {
t.Fatalf("create gift workflow: %v", err)
}
steps, err := svc.DispatchNextSCUMWorkflowSteps(instance.ID, 1)
if err != nil || len(steps) != 1 {
t.Fatalf("dispatch eligibility: steps=%+v err=%v", steps, err)
}
if _, err := svc.CompleteSCUMWorkflowStep(steps[0].ID, domain.SCUMWorkflowStepConfirmed, domain.SCUMOperationConfirmation{Status: "confirmed"}); err != nil {
t.Fatalf("complete eligibility: %v", err)
}
steps, err = svc.DispatchNextSCUMWorkflowSteps(instance.ID, 1)
if err != nil || len(steps) != 1 || !steps[0].MutatesState {
t.Fatalf("dispatch mutating reward: steps=%+v err=%v", steps, err)
}
if _, err := svc.CompleteSCUMWorkflowStep(steps[0].ID, domain.SCUMWorkflowStepUnknown, domain.SCUMOperationConfirmation{Status: "unknown"}); err != nil {
t.Fatalf("complete unknown mutation: %v", err)
}
retry, err := svc.RetrySCUMWorkflowStep(steps[0].ID)
if err != nil || retry.Status != domain.SCUMWorkflowStepUnknown || !strings.Contains(retry.SafeSummary.Title, "确认") {
t.Fatalf("unknown mutating retry should require confirmation: step=%+v err=%v", retry, err)
}
}
func newSCUMWorkflowFixture(t *testing.T, runAvailable bool) (*CoreService, string, domain.ServerInstance) {
t.Helper()
svc := newCoreService(repo.NewMemoryStore(), func() time.Time { return fixedTime })
capabilities := []string{domain.JobCapabilityRemoteRunDBSQLiteQuery, domain.JobCapabilityRemoteRunLogsTransfer, domain.JobCapabilityRemoteRunProtectedSQL, domain.JobCapabilityRemoteRunRCONCommand}
plugin, err := svc.CreateGamePlugin(domain.GamePlugin{ID: "server.scum", Name: "SCUM", Version: "1.0.0", ServerType: "scum", ManifestRef: "artifact://manifests/server.scum/1.0.0", CreateFormSchemaRef: "artifact://schemas/server.scum/create-form/1.0.0", RequiredRunCapabilities: capabilities, DeclaredPermissions: []string{"server.game-client.read", "server.game-client.command", "server.game-client.maintenance"}, Permissions: domain.PluginPermissions{Jobs: true, RemoteAccess: true}, RemoteAccess: domain.GamePluginRemoteAccess{Methods: []string{"run"}, RunCapabilities: capabilities, DatabaseEngines: []string{"sqlite"}, RCON: true, LogTransfer: true}, LifecycleActions: domain.PluginLifecycleActions{Start: "actions/start.json"}, RuntimeProfiles: domain.GamePluginRuntimeProfiles{TransportProfiles: []domain.RuntimeTransportProfile{{Key: "scum-database", Kind: "sqlite", TargetKey: "scum-database", Capabilities: []string{domain.JobCapabilityRemoteRunDBSQLiteQuery, domain.JobCapabilityRemoteRunProtectedSQL}}, {Key: "scum-management", Kind: "rcon", TargetKey: "scum-management", Capabilities: []string{domain.JobCapabilityRemoteRunRCONCommand}}}}})
if err != nil {
t.Fatalf("create workflow plugin: %v", err)
}
endpoint, err := svc.CreateRunEndpoint(domain.RunEndpoint{ID: "run-local", DisplayName: "Local Run", Version: "0.1.0", Platform: "windows", Architecture: "amd64", Status: domain.RunEndpointStatusOnline, Capabilities: capabilities, Capacity: domain.RunCapacity{MaxJobs: 4}, LastHeartbeatAt: fixedTime})
if err != nil {
t.Fatalf("create workflow endpoint: %v", err)
}
session := createServiceUserAndLogin(t, svc, domain.User{ID: "workflow-owner", DisplayName: "Workflow Owner", Email: "workflow-owner@example.test", Roles: []string{"server-owner"}, PasswordHash: "secret-password"})
instance, err := svc.CreateServerInstanceForSession(session, domain.ServerInstance{ID: "server-workflow", PluginID: plugin.ID, RunEndpointID: endpoint.ID, Name: "Workflow Server", State: domain.ServerInstanceStateRunning})
if err != nil {
t.Fatalf("create workflow server: %v", err)
}
if !runAvailable {
endpoint.Status = domain.RunEndpointStatusOffline
if err := svc.store.RunEndpoints().Update(endpoint); err != nil {
t.Fatalf("mark workflow endpoint offline: %v", err)
}
}
return svc, session, instance
}
+102 -1
View File
@@ -439,7 +439,7 @@ func ValidatePluginCreateInputs(fields []domain.PluginCreateField, inputs map[st
func validateGameClientBridgeManifest(field string, bridge domain.GameClientBridgeManifest, permissions []string, pages []domain.GamePluginPage, runtimeProfiles domain.GamePluginRuntimeProfiles) []string {
companionPresent := bridge.Companion != (domain.GameClientBridgeCompanionDeclaration{})
if len(bridge.Commands) == 0 && len(bridge.Snapshots) == 0 && len(bridge.QueryTemplates) == 0 && len(bridge.Pages) == 0 && len(bridge.Features) == 0 && bridge.Retention.KeepForSeconds == 0 && bridge.Retention.MaxRecords == 0 && !companionPresent {
if len(bridge.Commands) == 0 && len(bridge.Snapshots) == 0 && len(bridge.QueryTemplates) == 0 && len(bridge.OperationTemplates) == 0 && len(bridge.Pages) == 0 && len(bridge.Features) == 0 && bridge.Retention.KeepForSeconds == 0 && bridge.Retention.MaxRecords == 0 && !companionPresent {
return nil
}
var violations []string
@@ -590,6 +590,72 @@ func validateGameClientBridgeManifest(field string, bridge domain.GameClientBrid
violations = append(violations, prefix+" transport must be sqlite with remote.run.db.sqlite.query capability")
}
}
operationTemplates := map[string]domain.GameClientBridgeOperationTemplateDeclaration{}
for index, template := range bridge.OperationTemplates {
prefix := fmt.Sprintf("%s.operationTemplates[%d]", field, index)
if !clientManagerIdentifierPattern.MatchString(template.Key) || unsafeGameClientBridgeCommandType(template.Key) {
violations = append(violations, prefix+".key is invalid or unsafe")
}
if _, exists := operationTemplates[template.Key]; exists {
violations = append(violations, prefix+".key is duplicated")
}
operationTemplates[template.Key] = template
if strings.TrimSpace(template.Title) == "" || len([]rune(template.Title)) > 80 {
violations = append(violations, prefix+".title is invalid")
}
if !containsString(permissions, template.Permission) {
violations = append(violations, prefix+".permission must be declared by the plugin")
}
if template.ApprovalLevel != domain.GameClientBridgeApprovalLevelOperator && template.ApprovalLevel != domain.GameClientBridgeApprovalLevelPlatformAdmin {
violations = append(violations, prefix+".approvalLevel must require operator or platform-admin approval")
}
if template.Kind != domain.GameClientBridgeOperationKindRCON && template.Kind != domain.GameClientBridgeOperationKindSQLiteMutation {
violations = append(violations, prefix+".kind is invalid")
}
if !safeRelativeJSONRef(template.PayloadSchemaRef) || template.ResultSchemaRef != "" && !safeRelativeJSONRef(template.ResultSchemaRef) || template.ConfirmationSchemaRef != "" && !safeRelativeJSONRef(template.ConfirmationSchemaRef) {
violations = append(violations, prefix+" schema references must be safe relative JSON references")
}
if template.TimeoutSeconds < 1 || template.TimeoutSeconds > 3600 {
violations = append(violations, prefix+".timeoutSeconds is invalid")
}
if template.MaxPayloadBytes < 1 || template.MaxPayloadBytes > maxGameClientBridgePayloadSize {
violations = append(violations, prefix+".maxPayloadBytes is invalid")
}
transport, exists := transports[template.TransportKey]
if !exists {
violations = append(violations, prefix+".transportKey must reference a declared runtime transport profile")
continue
}
if transport.TargetKey != template.TargetKey || strings.TrimSpace(template.TargetKey) == "" {
violations = append(violations, prefix+".targetKey must match the declared runtime transport profile")
}
switch template.Kind {
case domain.GameClientBridgeOperationKindRCON:
if transport.Kind != "rcon" || !containsString(transport.Capabilities, domain.JobCapabilityRemoteRunProtectedRCON) {
violations = append(violations, prefix+" transport must be rcon with remote.run.protected.rcon capability")
}
if template.MaxRowsAffected != 0 {
violations = append(violations, prefix+".maxRowsAffected is only valid for sqlite-mutation")
}
if !emptyGameClientBridgeOperationMutation(template.Mutation) {
violations = append(violations, prefix+".mutation is only valid for sqlite-mutation")
}
case domain.GameClientBridgeOperationKindSQLiteMutation:
if transport.Kind != "sqlite" || !containsString(transport.Capabilities, domain.JobCapabilityRemoteRunProtectedSQL) {
violations = append(violations, prefix+" transport must be sqlite with remote.run.protected.sql capability")
}
if template.ApprovalLevel != domain.GameClientBridgeApprovalLevelPlatformAdmin {
violations = append(violations, prefix+".approvalLevel must require platform-admin approval for sqlite-mutation")
}
if template.MaxRowsAffected < 1 || template.MaxRowsAffected > 10 {
violations = append(violations, prefix+".maxRowsAffected is invalid")
}
if !template.Safety.RequiresBeforeValue || !template.Safety.RequiresConfirmation || (!template.Safety.RequiresOfflinePlayer && !template.Safety.RequiresMaintenanceWindow) {
violations = append(violations, prefix+".safety must require before value, confirmation, and offline or maintenance protection")
}
violations = append(violations, validateGameClientBridgeOperationMutation(prefix+".mutation", template.Mutation, queryTemplates)...)
}
}
pageDeclarations := map[string]domain.GamePluginPage{}
for _, page := range pages {
pageDeclarations[page.Key] = page
@@ -674,6 +740,16 @@ func validateGameClientBridgeManifest(field string, bridge domain.GameClientBrid
violations = append(violations, prefix+" must declare remote.access.request for query templates")
}
}
for _, operationKey := range page.OperationKeys {
operation, exists := operationTemplates[operationKey]
if !exists {
violations = append(violations, prefix+" references undeclared operation template "+operationKey)
continue
}
if !containsString(pageDeclaration.Permissions, operation.Permission) {
violations = append(violations, prefix+" must declare operation template permission "+operation.Permission)
}
}
for _, featureKey := range page.FeatureKeys {
feature, exists := features[featureKey]
if !exists {
@@ -772,6 +848,31 @@ func validateGameClientBridgeProtectedRequest(prefix string, request *domain.Gam
return violations
}
func emptyGameClientBridgeOperationMutation(value domain.GameClientBridgeOperationMutationDeclaration) bool {
return value.FieldKey == "" && value.TableKey == "" && value.IdentityKey == "" && value.ValueKey == "" && value.ConfirmationQueryKey == "" && value.AllowedValueType == "" && value.MinValue == 0 && value.MaxValue == 0
}
func validateGameClientBridgeOperationMutation(prefix string, value domain.GameClientBridgeOperationMutationDeclaration, queryTemplates map[string]domain.GameClientBridgeQueryTemplateDeclaration) []string {
var violations []string
for field, item := range map[string]string{"fieldKey": value.FieldKey, "tableKey": value.TableKey, "identityKey": value.IdentityKey, "valueKey": value.ValueKey, "confirmationQueryKey": value.ConfirmationQueryKey} {
if !validDistributionLogicalKey(item) || unsafeGameClientBridgePayloadKey(item) {
violations = append(violations, prefix+"."+field+" must be a safe logical key")
}
}
if !oneOf(value.AllowedValueType, "integer", "number", "string", "boolean") {
violations = append(violations, prefix+".allowedValueType is invalid")
}
if value.MaxValue != 0 && value.MinValue > value.MaxValue {
violations = append(violations, prefix+".minValue must not exceed maxValue")
}
if value.ConfirmationQueryKey != "" {
if _, exists := queryTemplates[value.ConfirmationQueryKey]; !exists {
violations = append(violations, prefix+".confirmationQueryKey must reference a declared query template")
}
}
return violations
}
func ValidatePluginBridgeAuthorizeRequest(request domain.PluginBridgeAuthorizeRequest) error {
var violations []string
violations = appendRequired(violations, "pluginId", request.PluginID)
+63 -6
View File
@@ -129,17 +129,25 @@ func TestValidateGamePluginManifestRegistrationValidatesRuntimeProfiles(t *testi
func TestValidateGamePluginManifestRegistrationValidatesGameClientBridgeCatalog(t *testing.T) {
registration := validGamePluginManifestRegistration()
registration.Manifest.Permissions = append(registration.Manifest.Permissions, "server.game-client.read", "server.game-client.command", "server.remote.access")
registration.Manifest.Capabilities = append(registration.Manifest.Capabilities, domain.JobCapabilityRemoteRunDBSQLiteQuery)
registration.Manifest.Pages[0].Permissions = append(registration.Manifest.Pages[0].Permissions, "server.game-client.read", "server.remote.access")
registration.Manifest.Permissions = append(registration.Manifest.Permissions, "server.game-client.read", "server.game-client.command", "server.game-client.maintenance", "server.remote.access")
registration.Manifest.Capabilities = append(registration.Manifest.Capabilities, domain.JobCapabilityRemoteRunDBSQLiteQuery, domain.JobCapabilityRemoteRunProtectedRCON, domain.JobCapabilityRemoteRunProtectedSQL)
registration.Manifest.Pages[0].Permissions = append(registration.Manifest.Pages[0].Permissions, "server.game-client.read", "server.game-client.command", "server.game-client.maintenance", "server.remote.access")
registration.Manifest.Pages[0].BridgeActions = append(registration.Manifest.Pages[0].BridgeActions, string(domain.PluginBridgeActionRemoteAccessRequest))
registration.Manifest.RuntimeProfiles.TransportProfiles = []domain.RuntimeTransportProfile{{Key: "sqlite-db", Kind: "sqlite", TargetKey: "db/sqlite", Capabilities: []string{domain.JobCapabilityRemoteRunDBSQLiteQuery}}}
registration.Manifest.RuntimeProfiles.TransportProfiles = []domain.RuntimeTransportProfile{
{Key: "sqlite-db", Kind: "sqlite", TargetKey: "db/sqlite", Capabilities: []string{domain.JobCapabilityRemoteRunDBSQLiteQuery}},
{Key: "scum-rcon", Kind: "rcon", TargetKey: "scum-rcon", Capabilities: []string{domain.JobCapabilityRemoteRunProtectedRCON}},
{Key: "scum-mutation-db", Kind: "sqlite", TargetKey: "scum-mutation-db", Capabilities: []string{domain.JobCapabilityRemoteRunProtectedSQL}},
}
registration.Manifest.GameClientBridge = domain.GameClientBridgeManifest{
Commands: []domain.GameClientBridgeCommandDeclaration{{Type: "announcement.send", Title: "Send announcement", Permission: "server.game-client.command", ApprovalLevel: domain.GameClientBridgeApprovalLevelOperator, PayloadSchemaRef: "schemas/bridge/announcement.schema.json", ResultSchemaRef: "schemas/bridge/announcement-result.schema.json", TimeoutSeconds: 60, MaxPayloadBytes: 4096}},
Snapshots: []domain.GameClientBridgeSnapshotDeclaration{{Type: "players", SchemaVersion: "1", SchemaRef: "schemas/bridge/players.schema.json", Retention: domain.GameClientBridgeRetention{KeepForSeconds: 3600, MaxRecords: 100}}},
QueryTemplates: []domain.GameClientBridgeQueryTemplateDeclaration{{Key: "player.lookup", Title: "Player lookup", Permission: "server.game-client.read", Engine: "sqlite", TransportKey: "sqlite-db", TargetKey: "db/sqlite", ParameterSchemaRef: "schemas/bridge/query/player-lookup.parameters.schema.json", ResultSchemaRef: "schemas/bridge/query/player-lookup.result.schema.json", MaxRows: 50, TimeoutSeconds: 10}},
Retention: domain.GameClientBridgeRetention{KeepForSeconds: 86400, MaxRecords: 1000},
Pages: []domain.GameClientBridgePageContract{{PageKey: "logs", CommandTypes: []string{"announcement.send"}, SnapshotTypes: []string{"players"}, QueryTemplateKeys: []string{"player.lookup"}}},
OperationTemplates: []domain.GameClientBridgeOperationTemplateDeclaration{
{Key: "player.fame.set", Title: "Set player fame", Permission: "server.game-client.command", ApprovalLevel: domain.GameClientBridgeApprovalLevelOperator, Kind: domain.GameClientBridgeOperationKindRCON, TransportKey: "scum-rcon", TargetKey: "scum-rcon", PayloadSchemaRef: "schemas/bridge/operations/player-fame-set.payload.schema.json", ResultSchemaRef: "schemas/bridge/operations/player-fame-set.result.schema.json", ConfirmationSchemaRef: "schemas/bridge/operations/player-fame-set.confirmation.schema.json", TimeoutSeconds: 60, MaxPayloadBytes: 2048, Safety: domain.GameClientBridgeOperationSafety{RequiresApproval: true, RequiresConfirmation: true}},
{Key: "player.attribute.855.set", Title: "Set player attribute 855", Permission: "server.game-client.maintenance", ApprovalLevel: domain.GameClientBridgeApprovalLevelPlatformAdmin, Kind: domain.GameClientBridgeOperationKindSQLiteMutation, TransportKey: "scum-mutation-db", TargetKey: "scum-mutation-db", PayloadSchemaRef: "schemas/bridge/operations/player-attribute-855-set.payload.schema.json", ResultSchemaRef: "schemas/bridge/operations/player-attribute-855-set.result.schema.json", ConfirmationSchemaRef: "schemas/bridge/operations/player-attribute-855-set.confirmation.schema.json", TimeoutSeconds: 120, MaxPayloadBytes: 4096, MaxRowsAffected: 1, Mutation: domain.GameClientBridgeOperationMutationDeclaration{FieldKey: "855", TableKey: "prisoner", IdentityKey: "user_profile_id", ValueKey: "value", ConfirmationQueryKey: "player.lookup", AllowedValueType: "integer", MinValue: 0, MaxValue: 100000}, Safety: domain.GameClientBridgeOperationSafety{RequiresApproval: true, RequiresOfflinePlayer: true, RequiresBeforeValue: true, RequiresConfirmation: true, BackupRequired: true}},
},
Retention: domain.GameClientBridgeRetention{KeepForSeconds: 86400, MaxRecords: 1000},
Pages: []domain.GameClientBridgePageContract{{PageKey: "logs", CommandTypes: []string{"announcement.send"}, SnapshotTypes: []string{"players"}, QueryTemplateKeys: []string{"player.lookup"}, OperationKeys: []string{"player.fame.set", "player.attribute.855.set"}}},
}
if err := ValidateGamePluginManifestRegistration(registration); err != nil {
t.Fatalf("expected bridge catalog to validate, got %v", err)
@@ -225,6 +233,55 @@ func TestValidateGamePluginManifestRegistrationValidatesGameClientBridgeCatalog(
}
})
}
operationTemplateTests := []struct {
name string
expected string
mutate func(*domain.GamePluginManifestRegistration)
}{
{name: "duplicate key", expected: "key is duplicated", mutate: func(value *domain.GamePluginManifestRegistration) {
value.Manifest.GameClientBridge.OperationTemplates = append(value.Manifest.GameClientBridge.OperationTemplates, value.Manifest.GameClientBridge.OperationTemplates[0])
}},
{name: "unsafe key", expected: "key is invalid or unsafe", mutate: func(value *domain.GamePluginManifestRegistration) {
value.Manifest.GameClientBridge.OperationTemplates[0].Key = "raw.sql.execute"
}},
{name: "missing approval", expected: "approvalLevel must require", mutate: func(value *domain.GamePluginManifestRegistration) {
value.Manifest.GameClientBridge.OperationTemplates[0].ApprovalLevel = domain.GameClientBridgeApprovalLevelNone
}},
{name: "unsafe schema", expected: "schema references", mutate: func(value *domain.GamePluginManifestRegistration) {
value.Manifest.GameClientBridge.OperationTemplates[0].PayloadSchemaRef = "/etc/operation.json"
}},
{name: "rcon wrong transport", expected: "transport must be rcon", mutate: func(value *domain.GamePluginManifestRegistration) {
value.Manifest.GameClientBridge.OperationTemplates[0].TransportKey = "sqlite-db"
value.Manifest.GameClientBridge.OperationTemplates[0].TargetKey = "db/sqlite"
}},
{name: "mutation wrong transport", expected: "transport must be sqlite", mutate: func(value *domain.GamePluginManifestRegistration) {
value.Manifest.GameClientBridge.OperationTemplates[1].TransportKey = "scum-rcon"
value.Manifest.GameClientBridge.OperationTemplates[1].TargetKey = "scum-rcon"
}},
{name: "mutation row bound", expected: "maxRowsAffected is invalid", mutate: func(value *domain.GamePluginManifestRegistration) {
value.Manifest.GameClientBridge.OperationTemplates[1].MaxRowsAffected = 0
}},
{name: "mutation missing safety", expected: "safety must require", mutate: func(value *domain.GamePluginManifestRegistration) {
value.Manifest.GameClientBridge.OperationTemplates[1].Safety.RequiresBeforeValue = false
}},
{name: "undeclared page operation", expected: "undeclared operation template", mutate: func(value *domain.GamePluginManifestRegistration) {
value.Manifest.GameClientBridge.Pages[0].OperationKeys = []string{"missing.operation"}
}},
{name: "page missing operation permission", expected: "must declare operation template permission", mutate: func(value *domain.GamePluginManifestRegistration) {
value.Manifest.Pages[0].Permissions = []string{"server.game-client.read", "server.remote.access"}
}},
}
for _, test := range operationTemplateTests {
t.Run("operation template "+test.name, func(t *testing.T) {
invalid := domain.CopyGamePluginManifestRegistration(registration)
test.mutate(&invalid)
err := ValidateGamePluginManifestRegistration(invalid)
if err == nil || !strings.Contains(err.Error(), test.expected) {
t.Fatalf("expected %q rejection, got %v", test.expected, err)
}
})
}
}
func TestValidateGamePluginManifestRegistrationRejectsUnsafeCapabilitiesAndPermissions(t *testing.T) {