feat(scum): remove legacy workflow client surfaces

This commit is contained in:
npc0-hue
2026-08-11 14:31:05 +08:00
parent a58da47c60
commit df48d8b283
22 changed files with 59 additions and 654 deletions
@@ -141,15 +141,15 @@
## 12. Projection, Workflow, Intelligence, and Placeholder Removal
- [ ] 12.1 Inventory references before deletion and distinguish SCUM-only projection/Workflow/player-intelligence code from generic durable Run jobs, internal write evidence, and non-SCUM consumers.
- [x] 12.1 Inventory references before deletion and distinguish SCUM-only projection/Workflow/player-intelligence code from generic durable Run jobs, internal write evidence, and non-SCUM consumers.
- [ ] 12.2 Remove SCUM Workflow instance/step/status APIs, repositories, services, routes, clients, manifest declarations, page components, workflow creation/listing, pending-review counters, operation approval routes, and approval/confirmation queue surfaces without removing generic Run job execution.
- [ ] 12.3 Remove SCUM projection/observation/freshness snapshot types, ingestion, metadata fields, refresh/audit services, page actions, and manual synchronization endpoints; removed endpoints must return not found or a stable removal response and dispatch no job.
- [ ] 12.4 Remove SCUM dependencies on alias history, shared IP/fingerprint, access attempts, automatic risk/security signals, and player intelligence; delete shared implementation only after proving it has no remaining non-SCUM consumer.
- [x] 12.5 Remove the standalone `管理` and `Workflow 状态` tabs, legacy placeholders/routes, fake maintenance/backup evidence, hard-coded increments, opaque `855` action, hard-coded `starter-pack`, fixed notification, gradient-only map, arbitrary percentage points, and sample/generated players/world data.
- [ ] 12.6 Remove runtime product copy including `Workflow 状态`, `投影`, `真实投影`, `玩家投影`, `刷新投影`, `刷新世界投影`, `刷新真实数据`, `发起审计`, `创建发放 workflow`, `typed workflow`, `typed observation`, `typed operation`, `待审操作`, `审批/确认队列`, `清理旧入口`, `目前暂无真实投影数据`, `暂无真实投影数据`, `暂无玩家投影`, and `Companion 可用`.
- [x] 12.6 Remove runtime product copy including `Workflow 状态`, `投影`, `真实投影`, `玩家投影`, `刷新投影`, `刷新世界投影`, `刷新真实数据`, `发起审计`, `创建发放 workflow`, `typed workflow`, `typed observation`, `typed operation`, `待审操作`, `审批/确认队列`, `清理旧入口`, `目前暂无真实投影数据`, `暂无真实投影数据`, `暂无玩家投影`, and `Companion 可用`.
- [ ] 12.7 Add upgrade behavior that starts the new SCUM stores empty, populates only from post-upgrade authenticated logs/current-service sync, invalidates incompatible bindings, and never translates old snapshot values into real facts.
- [ ] 12.8 Add a rollback/feature-disable path that disables incompatible SCUM reads/writes while leaving diagnostic local records intact and never re-enables fake projection or Workflow data.
- [ ] 12.9 Add scoped runtime-source/manifest/API tests or assertions proving banned copy/actions/routes are absent, removed endpoints cannot dispatch jobs, and generic lifecycle, logs, jobs, AI provider management, and non-SCUM plugin navigation still work.
- [x] 12.9 Add scoped runtime-source/manifest/API tests or assertions proving banned copy/actions/routes are absent, removed endpoints cannot dispatch jobs, and generic lifecycle, logs, jobs, AI provider management, and non-SCUM plugin navigation still work.
- [ ] 12.10 Record the supersession mapping from the completed-but-unarchived legacy SCUM changes to these unique replacement capabilities; do not archive obsolete deltas into the main baseline, and leave any history consolidation to a separate reviewed skip-specs/equivalent archival task.
## 13. End-to-End Verification and Release
+2 -2
View File
@@ -17,7 +17,7 @@ All routes use JSON request and response bodies. Collection routes support `GET`
| 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` |
| 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 |
| SCUM local resources | 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`; removed legacy SCUM execution routes return `404` and dispatch no job | `SCUM*Response`, `ErrorResponse` |
| 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` |
@@ -161,7 +161,7 @@ Server-scoped terminal log streaming (`GET /api/v1/server-instances/{id}/logs/ev
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.
SCUM product APIs expose only safe local resource rows, capability availability, collected timestamps, and redacted status reasons. Removed legacy SCUM execution routes return `404` and dispatch no job. SCUM APIs 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.
+10 -67
View File
@@ -87,27 +87,9 @@ func (h *coreHandlers) serverSCUMPositions(w http.ResponseWriter, r *http.Reques
}
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))
case http.MethodGet, http.MethodPost:
writeRemovedSCUMEndpoint(w)
default:
writeMethodNotAllowed(w, "GET, POST")
}
@@ -118,36 +100,13 @@ func (h *coreHandlers) serverSCUMOperationApprove(w http.ResponseWriter, r *http
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))
writeRemovedSCUMEndpoint(w)
}
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))
case http.MethodGet, http.MethodPost:
writeRemovedSCUMEndpoint(w)
default:
writeMethodNotAllowed(w, "GET, POST")
}
@@ -158,12 +117,11 @@ func (h *coreHandlers) serverSCUMWorkflowSteps(w http.ResponseWriter, r *http.Re
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))
writeRemovedSCUMEndpoint(w)
}
func writeRemovedSCUMEndpoint(w http.ResponseWriter) {
writeAPIError(w, http.StatusNotFound, errorCodeNotFound, "legacy SCUM endpoint removed; use the local SCUM management APIs", nil)
}
func scumProjectionFilterFromRequest(r *http.Request, serverID string) domain.SCUMProjectionFilter {
@@ -171,21 +129,6 @@ func scumProjectionFilterFromRequest(r *http.Request, serverID string) domain.SC
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
+13 -55
View File
@@ -13,17 +13,14 @@ import (
"browser.local/platform/service"
)
func TestSCUMProjectionOperationAndWorkflowAPIsExposeSafeTypedSurfaces(t *testing.T) {
func TestSCUMResourceAPIsExposeLocalRowsAndRemovedLegacyEndpoints(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.DeclaredPermissions = append(plugin.DeclaredPermissions, "server.game-client.read")
plugin.GameClientBridge.Retention = domain.GameClientBridgeRetention{KeepForSeconds: 86400, MaxRecords: 1000}
if _, err := core.CreateGamePlugin(plugin); err != nil {
t.Fatalf("create plugin: %v", err)
@@ -49,23 +46,7 @@ func TestSCUMProjectionOperationAndWorkflowAPIsExposeSafeTypedSurfaces(t *testin
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})
body, err := json.Marshal(players)
if err != nil {
t.Fatalf("marshal responses: %v", err)
}
@@ -82,10 +63,20 @@ func TestSCUMProjectionOperationAndWorkflowAPIsExposeSafeTypedSurfaces(t *testin
{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"},
{http.MethodGet, "/api/v1/server-instances/server-scum-api/scum/operations"},
{http.MethodPost, "/api/v1/server-instances/server-scum-api/scum/operations"},
{http.MethodPost, "/api/v1/server-instances/server-scum-api/scum/operations/op-1/approve"},
{http.MethodGet, "/api/v1/server-instances/server-scum-api/scum/workflows"},
{http.MethodPost, "/api/v1/server-instances/server-scum-api/scum/workflows"},
{http.MethodGet, "/api/v1/server-instances/server-scum-api/scum/workflow-steps?workflowId=workflow-1"},
} {
recorder := requestWithAuth(t, router, legacy.method, legacy.path, `{}`, auth.SessionID)
assertStatus(t, recorder, http.StatusNotFound)
}
jobs, err := core.ListJobsForSession(auth.SessionID, domain.JobFilter{ServerInstanceID: "server-scum-api"})
if err != nil || len(jobs) != 0 {
t.Fatalf("removed SCUM endpoints must not dispatch jobs, got jobs=%+v err=%v", jobs, err)
}
}
func TestSCUMAPIsEnforceServerAuthorization(t *testing.T) {
@@ -113,36 +104,3 @@ func TestSCUMAPIsEnforceServerAuthorization(t *testing.T) {
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)
}
-243
View File
@@ -1,243 +0,0 @@
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)}
}
+6 -6
View File
@@ -431,12 +431,12 @@ func (svc *CoreService) applySCUMSQLiteMutationApprovalGate(operation domain.SCU
state, err := svc.latestSCUMPlayerLiveState(operation.ServerInstanceID, operation.PlayerID)
if err != nil {
if err == repo.ErrNotFound {
return svc.updateSCUMOperationGate(operation, domain.SCUMWorkflowStepWaiting, "等待真实玩家投影", "需要先从当前服务的登录日志或 SCUM.db 读取玩家数据。")
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。")
return svc.updateSCUMOperationGate(operation, domain.SCUMWorkflowStepWaiting, "等待当前数据", "玩家数据还未通过当前服务读回确认。")
}
if template.Safety.RequiresOfflinePlayer && state.Online {
return svc.updateSCUMOperationGate(operation, domain.SCUMWorkflowStepWaiting, "等待玩家离线", "DB-only 玩家字段修改必须等玩家离线或进入维护窗口。")
@@ -449,10 +449,10 @@ func (svc *CoreService) applySCUMSQLiteMutationApprovalGate(operation domain.SCU
}
current, ok := scumCurrentMutationFieldValue(state, operation.Guard.FieldKey)
if !ok {
return svc.updateSCUMOperationGate(operation, domain.SCUMWorkflowStepWaiting, "等待字段读回", "当前投影没有该 DB-only 字段,需先执行确认查询。")
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 svc.updateSCUMOperationGate(operation, domain.SCUMWorkflowStepBlocked, "before value 已过期", "当前字段值与提交时 before guard 不一致,已阻止写入。")
}
return domain.CopySCUMOperationRequest(operation), true, nil
}
@@ -624,7 +624,7 @@ func scumSQLiteMutationSafeSummary(templateKey, playerID string, guard domain.SC
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}
return domain.SCUMSafeSummary{Title: "Declared 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) {
@@ -757,7 +757,7 @@ func operationSafeSummary(templateKey, playerID string, payload map[string]any)
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}
return domain.SCUMSafeSummary{Title: "Declared SCUM action", Message: "RCON text is generated server-side and is not stored in the local record.", Details: details}
}
func scumOperationTemplate(plugin domain.GamePlugin, key string) (domain.GameClientBridgeOperationTemplateDeclaration, bool) {
+1 -1
View File
@@ -46,7 +46,7 @@ func (svc *CoreService) ApplySCUMObservationResult(result domain.SCUMObservation
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 数据。"}
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 {
+9 -9
View File
@@ -58,7 +58,7 @@ func (svc *CoreService) CreateSCUMWorkflowForSession(sessionID, serverID string,
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}
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 background sequence queued with declared steps and safe summaries."}, CreatedAt: stamp, UpdatedAt: stamp}
if err := svc.store.SCUMWorkflowInstances().Create(workflow); err != nil {
return domain.SCUMWorkflowInstance{}, err
}
@@ -199,7 +199,7 @@ func (svc *CoreService) RetrySCUMWorkflowStep(stepID string) (domain.SCUMWorkflo
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.SafeSummary = domain.SCUMSafeSummary{Title: "确认后才能重试", Message: "State-changing SCUM step is unknown; confirmation/readback must complete before retry to avoid duplicate effects."}
step.UpdatedAt = svc.now()
if err := svc.store.SCUMWorkflowSteps().Update(step); err != nil {
return domain.SCUMWorkflowStep{}, err
@@ -380,14 +380,14 @@ func scumWorkflowTemplates() map[string]scumWorkflowTemplateDefinition {
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.bootstrap-real-data": {Key: "scum.bootstrap-real-data", Title: "Bootstrap SCUM 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 local sync."}, {Key: "login-cursor", DependsOn: []string{"schema-probe"}, Capability: logs, TargetKey: "scum-login", Summary: "Initialize login log 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."}}},
"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 local data, 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 declared action 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 the declared action."}, {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 check", 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: "Compare owner/member consistency signals."}}},
"scum.vehicle-audit": {Key: "scum.vehicle-audit", Title: "SCUM vehicle check", 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 player draft inputs."}, {Key: "draft-review", DependsOn: []string{"collect-allowed-fields"}, Summary: "Create a reviewable config diff or named-field draft."}, {Key: "approved-dispatch", DependsOn: []string{"draft-review"}, MutatesState: true, Summary: "Dispatch only after human confirmation through declared 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 local status surfaces."}}},
}
}
+1 -31
View File
@@ -250,12 +250,6 @@ describe("PlatformApiClient AI providers", () => {
if (url.endsWith("/api/v1/server-instances/server-1/scum/vehicles")) return jsonResponse({ items: [{ id: "vehicle-1", vehicleId: "vehicle-1", label: "SUV" }], count: 1 });
if (url.endsWith("/api/v1/server-instances/server-1/scum/flags")) return jsonResponse({ items: [{ id: "flag-1", flagId: "flag-1", ownerSquadId: "squad-1" }], count: 1 });
if (url.endsWith("/api/v1/server-instances/server-1/scum/positions")) return jsonResponse({ items: [{ id: "position-1", subjectType: "player", subjectId: "steam-1", x: 1, y: 2, z: 3 }], count: 1 });
if (url.endsWith("/api/v1/server-instances/server-1/scum/operations") && (!init?.method || init.method === "GET")) return jsonResponse({ items: [], count: 0 });
if (url.endsWith("/api/v1/server-instances/server-1/scum/operations") && init?.method === "POST") return jsonResponse({ id: "op-1", serverInstanceId: server.id, pluginId: plugin.id, templateKey: "player.fame.set", status: "waiting", approvalLevel: "operator", createdAt: "2026-07-03T00:00:00Z", updatedAt: "2026-07-03T00:00:00Z" });
if (url.endsWith("/api/v1/server-instances/server-1/scum/operations/op-1/approve") && init?.method === "POST") return jsonResponse({ id: "op-1", serverInstanceId: server.id, pluginId: plugin.id, templateKey: "player.fame.set", status: "queued", approvalLevel: "operator", createdAt: "2026-07-03T00:00:00Z", updatedAt: "2026-07-03T00:00:00Z" });
if (url.endsWith("/api/v1/server-instances/server-1/scum/workflows") && (!init?.method || init.method === "GET")) return jsonResponse({ items: [], count: 0 });
if (url.endsWith("/api/v1/server-instances/server-1/scum/workflows") && init?.method === "POST") return jsonResponse({ id: "workflow-1", serverInstanceId: server.id, pluginId: plugin.id, templateKey: "scum.world-refresh", status: "queued", createdAt: "2026-07-03T00:00:00Z", updatedAt: "2026-07-03T00:00:00Z" });
if (url.endsWith("/api/v1/server-instances/server-1/scum/workflow-steps?workflowId=workflow-1")) return jsonResponse({ items: [{ id: "step-1", workflowId: "workflow-1", serverInstanceId: server.id, stepKey: "read-positions", status: "queued", createdAt: "2026-07-03T00:00:00Z", updatedAt: "2026-07-03T00:00:00Z" }], count: 1 });
if (url.endsWith("/api/v1/file-operations/dispatch") && init?.method === "POST") {
expect(JSON.parse(String(init.body))).toEqual({
serverInstanceId: server.id,
@@ -560,12 +554,6 @@ describe("PlatformApiClient AI providers", () => {
await expect(client.listSCUMVehicles(server.id)).resolves.toMatchObject({ count: 1 });
await expect(client.listSCUMFlags(server.id)).resolves.toMatchObject({ count: 1 });
await expect(client.listSCUMPositions(server.id)).resolves.toMatchObject({ count: 1 });
await expect(client.listSCUMOperations(server.id)).resolves.toMatchObject({ count: 0 });
await expect(client.createSCUMOperation(server.id, { templateKey: "player.fame.set", playerId: "steam-1", payload: { fame: 100 }, reason: "typed correction", idempotencyKey: "idem-scum-op" })).resolves.toMatchObject({ id: "op-1", status: "waiting" });
await expect(client.approveSCUMOperation(server.id, "op-1")).resolves.toMatchObject({ id: "op-1", status: "queued" });
await expect(client.listSCUMWorkflows(server.id)).resolves.toMatchObject({ count: 0 });
await expect(client.createSCUMWorkflow(server.id, { templateKey: "scum.world-refresh", idempotencyKey: "idem-scum-workflow" })).resolves.toMatchObject({ id: "workflow-1", status: "queued" });
await expect(client.listSCUMWorkflowSteps(server.id, "workflow-1")).resolves.toMatchObject({ count: 1, items: [{ stepKey: "read-positions" }] });
await expect(client.dispatchFileOperation({ serverInstanceId: server.id, operation: "read", key: "logs/latest.log", idempotencyKey: "idem-file" })).resolves.toMatchObject({
status: "queued",
job: { capability: "files.read", targetKey: "logs/latest.log" }
@@ -624,7 +612,7 @@ describe("PlatformApiClient AI providers", () => {
client.invokeAI({ requestId: "ai-1", serverInstanceId: server.id, purpose: "config.suggest", prompt: "Tune PVP safely", currentConfig: "server.name=Example Survival #1\n" })
).resolves.toMatchObject({ status: "ok", usage: { mocked: true }, configRecommendation: { diffSummary: "review required" } });
expect(fetchMock).toHaveBeenCalledTimes(48);
expect(fetchMock).toHaveBeenCalledTimes(42);
});
it("calls plugin marketplace endpoints with filter and state contracts", async () => {
@@ -653,24 +641,6 @@ describe("PlatformApiClient AI providers", () => {
expect(fetchMock).toHaveBeenCalledTimes(3);
});
it("surfaces SCUM typed operation failures from the platform", async () => {
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
if (url.endsWith("/api/v1/server-instances/server-1/scum/operations") && init?.method === "POST") {
return new Response(JSON.stringify({ code: "validation", message: "SCUM operation template is not declared" }), {
status: 400,
headers: { "Content-Type": "application/json" }
});
}
throw new Error(`unexpected request: ${url}`);
});
vi.stubGlobal("fetch", fetchMock);
const client = new PlatformApiClient();
await expect(client.createSCUMOperation(server.id, { templateKey: "raw.sql", reason: "unsafe", idempotencyKey: "bad-scum-op" })).rejects.toThrow("SCUM operation template is not declared");
});
it("keeps raw key and base URL fields out of provider responses", () => {
expect("apiKey" in provider).toBe(false);
expect("rawApiKey" in provider).toBe(false);
-34
View File
@@ -102,13 +102,6 @@ import type {
RemoteAdapterRequest,
RemoteAdapterResponse,
SCUMListResponse,
SCUMOperationListResponse,
SCUMOperationRequest,
SCUMOperationResponse,
SCUMWorkflowCreateRequest,
SCUMWorkflowListResponse,
SCUMWorkflowResponse,
SCUMWorkflowStepListResponse,
ServerRuntimeActionsResponse,
UserCreateRequest,
UserListResponse,
@@ -607,33 +600,6 @@ export class PlatformApiClient {
return this.request<SCUMListResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/positions`);
}
async listSCUMOperations(serverInstanceId: string): Promise<SCUMOperationListResponse> {
return this.request<SCUMOperationListResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/operations`);
}
async createSCUMOperation(serverInstanceId: string, request: SCUMOperationRequest): Promise<SCUMOperationResponse> {
return this.request<SCUMOperationResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/operations`, { method: "POST", body: request });
}
async approveSCUMOperation(serverInstanceId: string, operationId: string): Promise<SCUMOperationResponse> {
return this.request<SCUMOperationResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/operations/${encodeURIComponent(operationId)}/approve`, { method: "POST", body: {} });
}
async listSCUMWorkflows(serverInstanceId: string): Promise<SCUMWorkflowListResponse> {
return this.request<SCUMWorkflowListResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/workflows`);
}
async createSCUMWorkflow(serverInstanceId: string, request: SCUMWorkflowCreateRequest): Promise<SCUMWorkflowResponse> {
return this.request<SCUMWorkflowResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/workflows`, { method: "POST", body: request });
}
async listSCUMWorkflowSteps(serverInstanceId: string, workflowId?: string): Promise<SCUMWorkflowStepListResponse> {
const params = new URLSearchParams();
if (workflowId) params.set("workflowId", workflowId);
const query = params.toString();
return this.request<SCUMWorkflowStepListResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/workflow-steps${query ? `?${query}` : ""}`);
}
async dispatchFileOperation(request: FileOperationDispatchRequest): Promise<FileOperationDispatchResponse> {
return this.request<FileOperationDispatchResponse>("/file-operations/dispatch", {
method: "POST",
+2 -2
View File
@@ -6,7 +6,7 @@ API clients and DTO types live here, not inside page components.
- `users`: user and role APIs.
- `serverPlugins`: plugin marketplace and installed plugin APIs.
- `serverInstances`: create server, lifecycle, deployment/member/detail APIs, and SCUM typed projection/workflow APIs.
- `serverInstances`: create server, lifecycle, deployment/member/detail APIs, and SCUM local resource read APIs.
- `aiProviders`: provider CRUD, test, and model APIs.
- `jobs`: job status and operation APIs.
- `runEndpoints`: run endpoint status, lifecycle capabilities, and capacity APIs.
@@ -26,7 +26,7 @@ Normal browser login uses the platform's HttpOnly SameSite cookie and `credentia
- `getServerRuntimeBinding` reads `/server-instances/{id}/runtime-binding`; `updateServerRuntimeBinding` patches the selected profile and logical refs for internal/advanced logical transports. Server detail must not expose a manual runtime-binding tab or require these fields before normal start/stop when plugin-declared deployment/lifecycle data is sufficient. Responses contain only profile metadata, logical key names, configured/secret-backed flags, missing keys, and safe reasons. They never contain stored refs or secret values.
- `startServerInstance` and `stopServerInstance` post `ServerLifecycleCommandRequest` with the current config version and receive the lifecycle job response.
- `listServerAdministratorCandidates`, `addServerAdministrator`, and `removeServerAdministrator` call server membership endpoints so server owners can invite or remove active non-platform-admin server administrators.
- SCUM projection reads use `listSCUMPlayers`, `listSCUMSquads`, `listSCUMSquadMembers`, `listSCUMVehicles`, `listSCUMFlags`, and `listSCUMPositions`; SCUM writes use `createSCUMOperation`, `approveSCUMOperation`, `createSCUMWorkflow`, and workflow/step list APIs. These APIs expose only projection rows, typed template keys, status, and safe summaries, never SQL text, RCON text, DSNs, host paths, or protected payloads.
- SCUM local resource reads use `listSCUMPlayers`, `listSCUMSquads`, `listSCUMSquadMembers`, `listSCUMVehicles`, `listSCUMFlags`, and `listSCUMPositions`. Removed legacy SCUM execution endpoints have no frontend client wrappers; future writes must use the reviewed named-field/gift contracts and must never expose SQL text, RCON text, DSNs, host paths, or protected payloads.
- `dispatchFileOperation` posts `FileOperationDispatchRequest` to `/file-operations/dispatch` using logical file keys and scoped refs rather than raw host paths; it is not wired into SCUM server-detail/plugin pages as a raw file workbench.
- `listArtifacts`, `openArtifactDownload`, and `readArtifactContent` use platform artifact routes for available job/server artifacts. Browser reads are chunked through `/artifacts/{id}/content` and must render only safe filenames, checksums, progress, and platform storage behavior.
- `authorizePluginBridge` posts `PluginBridgeAuthorizeRequest` to `/plugin-bridge/authorize` for preflight decisions.
-8
View File
@@ -1375,14 +1375,6 @@ export interface RemoteAdapterResponse {
export type SCUMJsonRecord = Record<string, unknown>;
export interface SCUMListResponse<T = SCUMJsonRecord> { items: T[]; count: number; }
export interface SCUMWorkflowCreateRequest { templateKey: string; idempotencyKey: string; input?: SCUMJsonRecord; }
export interface SCUMOperationRequest { templateKey: string; playerId?: string; payload?: SCUMJsonRecord; guard?: SCUMJsonRecord; reason: string; idempotencyKey: string; }
export interface SCUMWorkflowResponse { id: string; serverInstanceId: string; pluginId: string; templateKey: string; requestedBy?: string; idempotencyKey?: string; status: string; currentStepKey?: string; input?: SCUMJsonRecord; safeSummary?: SCUMJsonRecord; blockerReason?: string; auditReferences?: string[]; createdAt: string; updatedAt: string; completedAt?: string; }
export interface SCUMWorkflowStepResponse { id: string; workflowId: string; serverInstanceId: string; stepKey: string; dependsOn?: string[]; status: string; operationKey?: string; queryTemplateKey?: string; capability?: string; targetKey?: string; jobId?: string; attempt?: number; maxAttempts?: number; mutatesState?: boolean; confirmation?: SCUMJsonRecord; safeSummary?: SCUMJsonRecord; blockerReason?: string; auditReferences?: string[]; createdAt: string; updatedAt: string; completedAt?: string; }
export interface SCUMOperationResponse { id: string; serverInstanceId: string; pluginId: string; templateKey: string; playerId?: string; requesterId?: string; approverId?: string; approvalLevel: string; payload?: SCUMJsonRecord; guard?: SCUMJsonRecord; confirmation?: SCUMJsonRecord; status: string; reason?: string; runJobId?: string; safeSummary?: SCUMJsonRecord; auditReferences?: string[]; createdAt: string; approvedAt?: string; completedAt?: string; updatedAt: string; }
export type SCUMWorkflowListResponse = SCUMListResponse<SCUMWorkflowResponse>;
export type SCUMWorkflowStepListResponse = SCUMListResponse<SCUMWorkflowStepResponse>;
export type SCUMOperationListResponse = SCUMListResponse<SCUMOperationResponse>;
export interface ServerConfigResponse {
serverInstanceId: string;
+2 -2
View File
@@ -2,7 +2,7 @@
## Shared Visual Contract
All first-party pages inherit the platform_web game-operations style with black-mecha default materials and a selectable magical-girl theme. Page implementations must use shared theme tokens and surface classes so 首页、服务器管理、插件市场、用户管理、AI 提供商管理、系统维护, server details, drawers, dialogs, safe diffs, plugin-declared pages, and workflow/status surfaces all feel like one console.
All first-party pages inherit the platform_web game-operations style with black-mecha default materials and a selectable magical-girl theme. Page implementations must use shared theme tokens and surface classes so 首页、服务器管理、插件市场、用户管理、AI 提供商管理、系统维护, server details, drawers, dialogs, safe diffs, plugin-declared pages, and job/status surfaces all feel like one console.
- Major surfaces remain transparent jelly/glass panels with visible background desktop, icy rim light, diamond borders, shine sweeps, and candy-color accents.
- Built-in magical desktops and user-uploaded backgrounds render behind readable contrast surfaces.
@@ -23,7 +23,7 @@ Default landing page for server owners and server administrators. Shows searchab
## 服务器详情
Daily operations hub for one server. Status header shows online state, player count, TPS, latency, CPU/memory/disk progress, metric freshness, and confirmed start/stop lifecycle actions. Plugin-declared pages render as first-class server tabs before platform sections, so each game owns its safe menu surface; SCUM pages use projection-backed users, squads, map, gifts, and workflows. Built-in sections are 管理 (deployment status, metadata, administrators) and AI 助手 (LLM suggestions produce reviewable config diffs or typed workflow drafts; no raw AI keys reach the frontend). Raw logs, management terminal/RCON input, arbitrary config workbench, generic operation history, runtime-binding, and generic plugin-control tabs must not be exposed in server detail.
Daily operations hub for one server. Status header shows online state, player count, TPS, latency, CPU/memory/disk progress, metric freshness, and confirmed start/stop lifecycle actions. Plugin-declared pages render as first-class server tabs before platform sections, so each game owns its safe menu surface; SCUM detail uses exactly 用户管理, 队伍管理, 实时地图, 礼包管理, and AI 助手. SCUM deployment and administrator settings move to compact header/list actions instead of a permanent management tab, and AI suggestions produce reviewable config diffs or named-field drafts without raw AI keys reaching the frontend. Raw logs, management terminal/RCON input, arbitrary config workbench, generic operation history, runtime-binding, and generic plugin-control tabs must not be exposed in server detail.
## 插件市场
+1 -1
View File
@@ -25,7 +25,7 @@ Plugin page runs with safe platform context.
- `plugin-lifecycle.request`: declared plugin lifecycle request through Platform.
- `ai.invoke`: platform-mediated AI invocation.
The host intersects manifest-level and page-level permissions/actions before exposing context. The SCUM operations page additionally intersects its command, snapshot, and query-template keys with `gameClientBridge.pages.operations`; it does not synthesize undeclared SCUM semantics.
The host intersects manifest-level and page-level permissions/actions before exposing context. SCUM pages receive only declared local-resource actions for 用户管理, 队伍管理, 实时地图, 礼包管理, and AI 助手; the host does not synthesize undeclared SCUM semantics.
## Forbidden
+3 -3
View File
@@ -44,11 +44,11 @@ describe("console shell routes", () => {
});
it("round-trips hosted plugin page hashes with server context", () => {
const hash = hashForPage("pluginPage", { pluginId: "game.scum", routeKey: "operations", serverId: "server/scum-1" });
expect(hash).toBe("#/plugin-pages/game.scum/operations?serverInstanceId=server%2Fscum-1");
const hash = hashForPage("pluginPage", { pluginId: "game.scum", routeKey: "players", serverId: "server/scum-1" });
expect(hash).toBe("#/plugin-pages/game.scum/players?serverInstanceId=server%2Fscum-1");
const resolved = resolveRouteHash(hash, platformAdmin);
expect(resolved.route).toMatchObject({ id: "pluginPage", showInNav: false, requiredCapability: "servers.read" });
expect(resolved.params).toEqual({ pluginId: "game.scum", routeKey: "operations", serverId: "server/scum-1" });
expect(resolved.params).toEqual({ pluginId: "game.scum", routeKey: "players", serverId: "server/scum-1" });
});
it("keeps route metadata available for shell navigation", () => {
@@ -23,7 +23,7 @@ describe("safeDiagnosticText", () => {
});
it("preserves safe operational wording instead of matching labels alone", () => {
const safe = "密钥状态已配置;Base URL 由平台托管;token 不会下发;RCON 数据不会投影。";
const safe = "密钥状态已配置;Base URL 由平台托管;token 不会下发;RCON 数据不会下发。";
expect(safeDiagnosticText(safe)).toBe(safe);
});
});
@@ -1,28 +0,0 @@
import type { SCUMCommandResult, SCUMConfigPatch, SCUMConfigRead, SCUMFeatureAvailability, SCUMFeatureKey, SCUMGiftGrant, SCUMPlayerProfile, SCUMStatePatch, SCUMStateSnapshot, SCUMTrajectoryCollection, SCUMVehicleSpawn } from "./contracts.js";
import { validateConfigPatch, validateStatePatch, validateVehicleSpawn } from "./schemas.js";
export type PluginFeatureBridge = { dispatch(action: "game-client.command" | "game-client.snapshot.read", payload: Record<string, string>): Promise<{ status: string; result?: Record<string, string>; error?: { message: string } }> };
export type SCUMFeatureAPI = {
availability(feature: SCUMFeatureKey): Promise<SCUMFeatureAvailability>; readConfig(): Promise<SCUMConfigRead | null>; patchConfig(patch: SCUMConfigPatch): Promise<SCUMCommandResult>;
playerProfile(playerId: string): Promise<SCUMPlayerProfile | null>; stateSnapshot(playerId: string): Promise<SCUMStateSnapshot | null>; requestStatePatch(patch: SCUMStatePatch): Promise<SCUMCommandResult>;
requestVehicleSpawn(spawn: SCUMVehicleSpawn): Promise<SCUMCommandResult>;
giftGrants(): Promise<SCUMGiftGrant[]>; trajectories(): Promise<SCUMTrajectoryCollection>;
};
export function createSCUMFeatureAPI(bridge: PluginFeatureBridge, availableFeatures: readonly SCUMFeatureAvailability[]): SCUMFeatureAPI {
const availability = async (feature: SCUMFeatureKey) => availableFeatures.find((item) => item.feature === feature) ?? { feature, available: false, reason: "插件未声明此功能。" };
return {
availability,
async readConfig() { const result = await bridge.dispatch("game-client.command", { type: "config.read" }); return result.status === "ok" ? decode<SCUMConfigRead>(result.result) : null; },
async patchConfig(patch) { const error = validateConfigPatch(patch); if (error) return { status: "validation-failed", summary: error }; return commandResult(await bridge.dispatch("game-client.command", { type: "config.patch", patch: JSON.stringify(patch) })); },
async playerProfile(playerId) { const result = await bridge.dispatch("game-client.snapshot.read", { type: "semantic.events", subjectId: playerId }); return result.status === "ok" ? decode<SCUMPlayerProfile>(result.result) : null; },
async stateSnapshot(playerId) { const result = await bridge.dispatch("game-client.command", { type: "player.lookup", playerId }); return result.status === "ok" ? decode<SCUMStateSnapshot>(result.result) : null; },
async requestStatePatch(patch) { const error = validateStatePatch(patch.changes); if (error) return { status: "validation-failed", summary: error }; return commandResult(await bridge.dispatch("game-client.command", { type: "game-state.patch", patch: JSON.stringify(patch) })); },
async requestVehicleSpawn(spawn) { const error = validateVehicleSpawn(spawn); if (error) return { status: "validation-failed", summary: error }; return commandResult(await bridge.dispatch("game-client.command", { type: "vehicle.spawn", vehicleCode: spawn.vehicleCode })); },
async giftGrants() { const result = await bridge.dispatch("game-client.snapshot.read", { type: "semantic.events", projection: "gifts" }); return result.status === "ok" ? decode<SCUMGiftGrant[]>(result.result) ?? [] : []; },
async trajectories() { const result = await bridge.dispatch("game-client.snapshot.read", { type: "semantic.events", projection: "trajectories" }); return result.status === "ok" ? decode<SCUMTrajectoryCollection>(result.result) ?? { available: false, reason: "没有已验证的位置事件源。", trajectories: [] } : { available: false, reason: result.error?.message ?? "没有已验证的位置事件源。", trajectories: [] }; }
};
}
function commandResult(result: { status: string; result?: Record<string, string>; error?: { message: string } }): SCUMCommandResult { if (result.status === "queued") return { status: "queued", summary: result.result?.summary ?? "已进入受控队列。" }; if (result.status === "unsupported") return { status: "unsupported", summary: result.error?.message ?? "当前运行时不支持此操作。" }; return { status: "failed", summary: result.error?.message ?? "受控操作未被接受。" }; }
function decode<T>(result: Record<string, string> | undefined): T | null { const payload = result?.payload; if (!payload) return null; try { return JSON.parse(payload) as T; } catch { return null; } }
@@ -1,14 +1,8 @@
export const scumFeatureKeys = ["configuration", "players", "rewards", "state-patches", "trajectories"] as const;
export const scumFeatureKeys = ["configuration", "players", "rewards"] as const;
export type SCUMFeatureKey = (typeof scumFeatureKeys)[number];
export type SCUMFeatureAvailability = { feature: SCUMFeatureKey; available: boolean; reason?: string };
export type SCUMMigrationProvenance = "plugin" | "transitional-read-only";
export type SCUMMigrationRecord<T = Record<string, unknown>> = { provenance: SCUMMigrationProvenance; readOnly: boolean; payload: T; recordedAt: string; sourceRecordId?: string };
export type SCUMFeatureMigrationAuthority = { serverInstanceId: string; feature: SCUMFeatureKey; authority: "plugin" | "transitional-read-only"; reason?: string };
export type SCUMFeatureMigrationStatus = { authority: "plugin" | "transitional-read-only"; readOnlyHistory: true; pluginWritesEnabled: boolean; reason?: string };
export type SCUMCommandResult = { status: "delivered" | "failed" | "unknown" | "unsupported" | "validation-failed" | "queued"; summary: string; audit?: Record<string, unknown> };
export type SCUMVehicleSpawn = { vehicleCode: string };
export type SCUMVehicleSpawnOption = { code: string; label: string };
export type SCUMLogicalDirectory = { key: string; label: string; scope: "config" | "logs" };
export type SCUMLogicalFile = { key: string; directoryKey: string; label: string; kind: "config" | "log"; streamKey?: string; editable?: boolean };
@@ -21,19 +15,10 @@ export type SCUMConfigPatch = { changes: Array<{ key: string; value: string }>;
export type SCUMPlayer = { id: string; gamePlayerId: string; displayName: string; lastSeenAt?: string; status: "online" | "offline" | "unknown" };
export type SCUMPlayerSession = { id: string; playerId: string; kind: "login" | "logout"; occurredAt: string; networkCorrelation?: string };
export type SCUMPlayerRisk = { kind: string; level: "low" | "medium" | "high"; observedAt: string; summary: string };
export type SCUMPlayerProfile = { player: SCUMPlayer; sessions: SCUMPlayerSession[]; risks: SCUMPlayerRisk[] };
export type SCUMPlayerProfile = { player: SCUMPlayer; sessions: SCUMPlayerSession[] };
export type SCUMGiftItem = { key: string; label: string; quantity: number };
export type SCUMGiftRevision = { id: string; catalogId: string; revision: number; items: SCUMGiftItem[]; publishedAt: string };
export type SCUMGiftGrant = { id: string; revisionId: string; playerId: string; notice: string; status: "pending-approval" | "queued" | "delivered" | "notification_failed" | "failed" | "unknown"; createdAt: string; completedAt?: string };
export type SCUMStateField = { key: string; label: string; value: number; minimum: number; maximum: number; editable: boolean; reason?: string };
export type SCUMStateSnapshot = { playerId: string; stateVersion: string; safetyWindow?: string; fields: SCUMStateField[]; observedAt: string };
export type SCUMStatePatch = { id: string; playerId: string; expectedStateVersion: string; safetyWindow: string; reason: string; changes: Array<{ fieldKey: string; before: number; after: number }>; status: "pending-approval" | "queued" | "succeeded" | "failed" | "unsupported" | "unknown"; createdAt: string };
export type SCUMTrajectoryPoint = { occurredAt: string; subjectId: string; subjectType: "player" | "vehicle"; x: number; y: number; z?: number; source: string };
export type SCUMTrajectory = { subjectId: string; subjectType: "player" | "vehicle"; points: SCUMTrajectoryPoint[]; provenance: SCUMMigrationProvenance };
export type SCUMTrajectoryCollection = { available: boolean; reason?: string; trajectories: SCUMTrajectory[] };
export type SCUMFeatureWorkspace = { defaultDirectoryKey?: string; directories?: SCUMLogicalDirectory[]; files?: SCUMLogicalFile[]; configFields?: SCUMConfigField[]; map?: { mapId: string; mapVersion: string; precision: number; sampleDistance: number; sampleIntervalSeconds: number; retentionSeconds: number } };
@@ -1,75 +0,0 @@
import type { SCUMConfigRead, SCUMFeatureKey, SCUMFeatureMigrationAuthority, SCUMFeatureMigrationStatus, SCUMGiftGrant, SCUMMigrationRecord, SCUMPlayer, SCUMPlayerProfile, SCUMPlayerRisk, SCUMPlayerSession, SCUMStatePatch, SCUMTrajectory, SCUMTrajectoryPoint } from "./contracts.js";
import { configurationCatalog } from "./schemas.js";
export function transitionalReadOnly<T extends Record<string, unknown>>(payload: T, recordedAt: string, sourceRecordId?: string): SCUMMigrationRecord<T> { return { provenance: "transitional-read-only", readOnly: true, payload, recordedAt, sourceRecordId }; }
export function pluginOwned<T extends Record<string, unknown>>(payload: T, recordedAt: string): SCUMMigrationRecord<T> { return { provenance: "plugin", readOnly: false, payload, recordedAt }; }
// The authority flag is exact-server. Missing, duplicate, or
// transitional flags fail closed: history remains readable, but plugin writes
// are not enabled. Execution still additionally requires Companion feature
// availability; this flag never authorizes a command by itself.
export function migrationStatus(flags: readonly SCUMFeatureMigrationAuthority[], serverInstanceId: string, feature: SCUMFeatureKey): SCUMFeatureMigrationStatus {
const matches = flags.filter((flag) => flag.serverInstanceId === serverInstanceId && flag.feature === feature);
if (matches.length !== 1) return { authority: "transitional-read-only", readOnlyHistory: true, pluginWritesEnabled: false, reason: matches.length ? "迁移标记冲突,已保持只读。" : "当前服务器尚未启用插件权威记录。" };
const flag = matches[0];
if (flag.authority !== "plugin") return { authority: "transitional-read-only", readOnlyHistory: true, pluginWritesEnabled: false, reason: flag.reason ?? "过渡记录仅供只读查看。" };
return { authority: "plugin", readOnlyHistory: true, pluginWritesEnabled: true, reason: flag.reason };
}
export function migratePlayerRecord(record: Record<string, unknown>): SCUMMigrationRecord<SCUMPlayer> | null {
const id = text(record.id); const gamePlayerId = text(record.gamePlayerId); const displayName = text(record.displayName); if (!id || !gamePlayerId || !displayName) return null;
const lastSeenAt = timestamp(record.lastSeenAt); const recordedAt = timestamp(record.updatedAt) ?? lastSeenAt ?? new Date(0).toISOString();
return transitionalReadOnly({ id, gamePlayerId, displayName, ...(lastSeenAt ? { lastSeenAt } : {}), status: record.online === true ? "online" : "unknown" }, recordedAt, id);
}
export function migrateConfigurationRecord(record: Record<string, unknown>): SCUMMigrationRecord<SCUMConfigRead> | null {
const fields = allowlistedConfigFields(record.fields); const observedAt = timestamp(record.observedAt) ?? timestamp(record.updatedAt); if (!fields || !observedAt) return null;
return transitionalReadOnly({ fields, observedAt }, observedAt, text(record.id));
}
export function migratePlayerProfileRecord(record: Record<string, unknown>): SCUMMigrationRecord<SCUMPlayerProfile> | null {
const player = migratePlayerRecord(object(record.player) ?? record); if (!player) return null;
const sessions = array(record.sessions).map((item) => migrateSession(item, player.payload.id)).filter((item): item is SCUMPlayerSession => item !== null);
const risks = [...array(record.accessAttempts), ...array(record.securitySignals)].map(migrateRisk).filter((item): item is SCUMPlayerRisk => item !== null);
const recordedAt = timestamp(record.updatedAt) ?? player.recordedAt;
return transitionalReadOnly({ player: player.payload, sessions, risks }, recordedAt, player.sourceRecordId);
}
export function migrateGiftGrantRecord(record: Record<string, unknown>): SCUMMigrationRecord<SCUMGiftGrant> | null {
const id = text(record.id); const revisionId = text(record.revisionId); const playerId = text(record.gamePlayerRecordId) ?? text(record.playerId); const status = giftStatus(record.status); const createdAt = timestamp(record.createdAt); if (!id || !revisionId || !playerId || !status || !createdAt) return null;
const completedAt = timestamp(record.completedAt); const notice = optionalText(record.notice) ?? "";
return transitionalReadOnly({ id, revisionId, playerId, notice, status, createdAt, ...(completedAt ? { completedAt } : {}) }, timestamp(record.updatedAt) ?? createdAt, id);
}
export function migrateStatePatchRecord(record: Record<string, unknown>): SCUMMigrationRecord<SCUMStatePatch> | null {
const id = text(record.id); const playerId = text(record.gamePlayerRecordId) ?? text(record.playerId); const expectedStateVersion = text(record.expectedStateVersion); const safetyWindow = text(record.safetyWindow); const reason = optionalText(record.reason) ?? ""; const status = stateStatus(record.status); const createdAt = timestamp(record.createdAt); const changes = array(record.changes).map(migrateStateChange).filter((item): item is { fieldKey: string; before: number; after: number } => item !== null);
if (!id || !playerId || !expectedStateVersion || !safetyWindow || !status || !createdAt || !changes.length) return null;
return transitionalReadOnly({ id, playerId, expectedStateVersion, safetyWindow, reason, changes, status, createdAt }, timestamp(record.updatedAt) ?? createdAt, id);
}
export function migrateTrajectoryRecord(record: Record<string, unknown>): SCUMTrajectory | null {
const subjectId = text(record.playerRecordId) ?? text(record.gamePlayerRecordId) ?? text(record.vehicleId) ?? text(record.entityId); const subjectType = trajectorySubjectType(record); const points = Array.isArray(record.points) && subjectId && subjectType ? record.points.map((point) => migratePoint(point, subjectId, subjectType)).filter((point): point is SCUMTrajectoryPoint => point !== null) : [];
if (!subjectId || !subjectType || !points.length) return null;
return { subjectId, subjectType, points, provenance: "transitional-read-only" };
}
export function migrateTrajectoryHistoryRecord(record: Record<string, unknown>): SCUMMigrationRecord<SCUMTrajectory> | null {
const trajectory = migrateTrajectoryRecord(record); if (!trajectory) return null;
const recordedAt = timestamp(record.updatedAt) ?? trajectory.points[trajectory.points.length - 1].occurredAt;
return transitionalReadOnly(trajectory, recordedAt, text(record.id));
}
function migratePoint(value: unknown, defaultSubjectId: string, defaultSubjectType: SCUMTrajectoryPoint["subjectType"]): SCUMTrajectoryPoint | null { const record = object(value); if (!record) return null; const x = number(record.mapX) ?? number(record.worldX); const y = number(record.mapY) ?? number(record.worldY); const occurredAt = timestamp(record.recordedAt) ?? timestamp(record.occurredAt); if (x === undefined || y === undefined || !occurredAt) return null; const subjectId = text(record.playerRecordId) ?? text(record.gamePlayerRecordId) ?? text(record.vehicleId) ?? text(record.entityId) ?? defaultSubjectId; const subjectType = trajectorySubjectType(record) ?? defaultSubjectType; return { occurredAt, subjectId, subjectType, x, y, ...(number(record.worldZ) !== undefined ? { z: number(record.worldZ) } : {}), source: "transitional-read-only" }; }
function migrateSession(value: unknown, defaultPlayerId: string): SCUMPlayerSession | null { const record = object(value); const id = record && text(record.id); const playerId = record && (text(record.gamePlayerRecordId) ?? text(record.playerId) ?? defaultPlayerId); const startedAt = record && timestamp(record.startedAt); if (!id || !playerId || !startedAt) return null; const endedAt = timestamp(record.endedAt); return { id, playerId, kind: endedAt ? "logout" : "login", occurredAt: endedAt ?? startedAt }; }
function migrateRisk(value: unknown): SCUMPlayerRisk | null { const record = object(value); const observedAt = record && (timestamp(record.occurredAt) ?? timestamp(record.lastObservedAt)); const kind = record && (text(record.ruleKey) ?? text(record.outcome)); const summary = record && (text(record.summary) ?? text(record.reason)); if (!observedAt || !kind || !summary) return null; return { kind, level: "medium", observedAt, summary }; }
function migrateStateChange(value: unknown): { fieldKey: string; before: number; after: number } | null { const record = object(value); if (!record) return null; const fieldKey = text(record.fieldKey); const before = number(record.before); const after = number(record.after); return fieldKey && before !== undefined && after !== undefined ? { fieldKey, before, after } : null; }
function allowlistedConfigFields(value: unknown): Record<string, string> | null { const fields = object(value); const allowed = new Set(configurationCatalog.map((field) => field.configKey)); if (!fields || !allowed.size) return null; const result: Record<string, string> = {}; for (const [key, field] of Object.entries(fields)) { if (allowed.has(key) && (typeof field === "string" || typeof field === "number" || typeof field === "boolean")) result[key] = String(field); } return Object.keys(result).length ? result : null; }
function giftStatus(value: unknown): SCUMGiftGrant["status"] | null { return value === "pending-approval" || value === "queued" || value === "delivered" || value === "notification_failed" || value === "failed" || value === "unknown" ? value : null; }
function stateStatus(value: unknown): SCUMStatePatch["status"] | null { if (value === "pending-approval" || value === "queued" || value === "unsupported" || value === "unknown" || value === "execution-unknown") return value === "execution-unknown" ? "unknown" : value; if (value === "confirmed") return "succeeded"; return value === "execution-failed" || value === "confirmation-failed" || value === "failed" ? "failed" : null; }
function trajectorySubjectType(record: Record<string, unknown>): SCUMTrajectoryPoint["subjectType"] | null { if (record.kind === "player" || record.kind === "vehicle") return record.kind; return text(record.playerRecordId) || text(record.gamePlayerRecordId) ? "player" : text(record.vehicleId) ? "vehicle" : null; }
function array(value: unknown): unknown[] { return Array.isArray(value) ? value : []; }
function object(value: unknown): Record<string, unknown> | null { return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : null; }
function text(value: unknown): string | undefined { return typeof value === "string" && value.trim() ? value : undefined; }
function optionalText(value: unknown): string | undefined { return text(value); }
function number(value: unknown): number | undefined { return typeof value === "number" && Number.isFinite(value) ? value : undefined; }
function timestamp(value: unknown): string | undefined { const candidate = text(value); return candidate && !Number.isNaN(Date.parse(candidate)) ? candidate : undefined; }
@@ -1,4 +1,4 @@
import type { SCUMConfigField, SCUMConfigPatch, SCUMFeatureAvailability, SCUMStateField, SCUMVehicleSpawn, SCUMVehicleSpawnOption } from "./contracts.js";
import type { SCUMConfigField, SCUMConfigPatch, SCUMFeatureAvailability } from "./contracts.js";
// These are safe fallback allowlists. A Companion schema probe may narrow them
// per server, but a game version never enables or disables a feature.
@@ -9,10 +9,5 @@ export const configurationCatalog: readonly SCUMConfigField[] = [
{ key: "max-players", fileKey: "scum-server-settings", configKey: "MaxPlayers", label: "最大玩家数", description: "允许同时进入服务器的玩家上限。", control: "number", minimum: 1, maximum: 128, defaultValue: "128", restartImpact: "restart-required" },
{ key: "welcome-message", fileKey: "scum-server-settings", configKey: "WelcomeMessage", label: "欢迎消息", description: "登录成功后由已声明的服务器扩展显示给玩家。", control: "text", defaultValue: "", restartImpact: "none" }
];
export const vehicleSpawnCatalog: readonly SCUMVehicleSpawnOption[] = [{ code: "BPC_Laika_C", label: "Laika" }, { code: "BPC_WolfsWagen_C", label: "WolfsWagen" }];
export const stateFieldCatalog: readonly Omit<SCUMStateField, "value" | "editable" | "reason">[] = [{ key: "skills.running", label: "跑步技能", minimum: 0, maximum: 1000000 }, { key: "attributes.strength", label: "力量属性", minimum: 1, maximum: 8 }];
export function supportsStateField(field: string): boolean { return stateFieldCatalog.some((candidate) => candidate.key === field); }
export function featureUnavailable(reason: string): SCUMFeatureAvailability { return { feature: "configuration", available: false, reason }; }
export function validateConfigPatch(patch: SCUMConfigPatch): string | null { if (!patch.idempotencyKey.trim() || !patch.reason.trim() || !patch.changes.length) return "配置修改必须包含原因、幂等键和至少一项变更。"; for (const change of patch.changes) { const field = configurationCatalog.find((candidate) => candidate.key === change.key); if (!field) return `字段 ${change.key} 不在受控目录中。`; if (!change.value.trim()) return `字段 ${field.label} 不能为空。`; if ((field.control === "number" || field.control === "port") && (!Number.isInteger(Number(change.value)) || (field.minimum !== undefined && Number(change.value) < field.minimum) || (field.maximum !== undefined && Number(change.value) > field.maximum))) return `字段 ${field.label} 超出允许范围。`; } return null; }
export function validateStatePatch(fields: Array<{ fieldKey: string; before: number; after: number }>): string | null { if (!fields.length) return "状态修改至少需要一个字段。"; for (const field of fields) { const definition = stateFieldCatalog.find((candidate) => candidate.key === field.fieldKey); if (!definition) return `字段 ${field.fieldKey} 不在运行时字段白名单中。`; if (!Number.isFinite(field.before) || !Number.isFinite(field.after) || field.after < definition.minimum || field.after > definition.maximum) return `字段 ${definition.label} 超出允许范围。`; } return null; }
export function validateVehicleSpawn(spawn: SCUMVehicleSpawn): string | null { if (!/^[A-Za-z][A-Za-z0-9_]{2,63}$/.test(spawn.vehicleCode)) return "载具代码格式无效。"; if (!vehicleSpawnCatalog.some((candidate) => candidate.code === spawn.vehicleCode)) return "载具代码未在受控目录中声明。"; return null; }
-22
View File
@@ -1,22 +0,0 @@
export const scumMigrationParityFixtures = {
configuration: {
source: { id: "config-1", observedAt: "2026-07-29T00:00:00Z", fields: { ServerName: "Crystal Moon", MaxPlayers: 64, hostPath: "/srv/scum", RconPassword: "not-migrated" } },
expected: { provenance: "transitional-read-only", readOnly: true, sourceRecordId: "config-1", recordedAt: "2026-07-29T00:00:00Z", payload: { observedAt: "2026-07-29T00:00:00Z", fields: { ServerName: "Crystal Moon", MaxPlayers: "64" } } }
},
playerHistory: {
source: { updatedAt: "2026-07-29T00:10:00Z", player: { id: "player-1", gamePlayerId: "steam-1", displayName: "Mira", lastSeenAt: "2026-07-29T00:09:00Z", online: true }, sessions: [{ id: "session-1", startedAt: "2026-07-29T00:01:00Z", endedAt: "2026-07-29T00:08:00Z", networkFingerprint: "not-migrated" }], accessAttempts: [{ occurredAt: "2026-07-29T00:02:00Z", outcome: "review", reason: "manual review", networkCorrelationKey: "not-migrated" }], securitySignals: [{ lastObservedAt: "2026-07-29T00:03:00Z", ruleKey: "repeat-access", summary: "manual review", evidenceCount: 2 }] },
expected: { provenance: "transitional-read-only", readOnly: true, sourceRecordId: "player-1", recordedAt: "2026-07-29T00:10:00Z", payload: { player: { id: "player-1", gamePlayerId: "steam-1", displayName: "Mira", lastSeenAt: "2026-07-29T00:09:00Z", status: "online" }, sessions: [{ id: "session-1", playerId: "player-1", kind: "logout", occurredAt: "2026-07-29T00:08:00Z" }], risks: [{ kind: "review", level: "medium", observedAt: "2026-07-29T00:02:00Z", summary: "manual review" }, { kind: "repeat-access", level: "medium", observedAt: "2026-07-29T00:03:00Z", summary: "manual review" }] } }
},
gift: {
source: { id: "gift-1", revisionId: "revision-1", gamePlayerRecordId: "player-1", notice: "Welcome", status: "unknown", createdAt: "2026-07-29T00:20:00Z", completedAt: "2026-07-29T00:21:00Z", deliveryCommandId: "not-migrated" },
expected: { provenance: "transitional-read-only", readOnly: true, sourceRecordId: "gift-1", recordedAt: "2026-07-29T00:20:00Z", payload: { id: "gift-1", revisionId: "revision-1", playerId: "player-1", notice: "Welcome", status: "unknown", createdAt: "2026-07-29T00:20:00Z", completedAt: "2026-07-29T00:21:00Z" } }
},
statePatch: {
source: { id: "patch-1", gamePlayerRecordId: "player-1", expectedStateVersion: "state-1", safetyWindow: "maintenance", reason: "verified test", status: "execution-unknown", createdAt: "2026-07-29T00:30:00Z", changes: [{ fieldKey: "skills.running", before: 1, after: 2 }], bridgeCommandId: "not-migrated" },
expected: { provenance: "transitional-read-only", readOnly: true, sourceRecordId: "patch-1", recordedAt: "2026-07-29T00:30:00Z", payload: { id: "patch-1", playerId: "player-1", expectedStateVersion: "state-1", safetyWindow: "maintenance", reason: "verified test", status: "unknown", createdAt: "2026-07-29T00:30:00Z", changes: [{ fieldKey: "skills.running", before: 1, after: 2 }] } }
},
trajectory: {
source: { id: "trajectory-1", updatedAt: "2026-07-29T00:40:00Z", kind: "player", entityId: "steam-1", gamePlayerRecordId: "player-1", points: [{ mapX: 10, mapY: 20, occurredAt: "2026-07-29T00:39:00Z", source: "log-projection" }, { mapX: 30, mapY: 40, occurredAt: "not-a-timestamp" }] },
expected: { provenance: "transitional-read-only", readOnly: true, sourceRecordId: "trajectory-1", recordedAt: "2026-07-29T00:40:00Z", payload: { subjectId: "player-1", subjectType: "player", provenance: "transitional-read-only", points: [{ occurredAt: "2026-07-29T00:39:00Z", subjectId: "player-1", subjectType: "player", x: 10, y: 20, source: "transitional-read-only" }] } }
}
} as const;
+2 -38
View File
@@ -3,10 +3,8 @@ import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { migrateConfigurationRecord, migrateGiftGrantRecord, migratePlayerProfileRecord, migratePlayerRecord, migrateStatePatchRecord, migrateTrajectoryHistoryRecord, migrateTrajectoryRecord, migrationStatus } from "../examples/scum-server-plugin/features/migration.js";
import { renderPluginPage } from "../examples/scum-server-plugin/page-bundle/index.js";
import { configurationCatalog, validateConfigPatch, validateStatePatch, validateVehicleSpawn, vehicleSpawnCatalog } from "../examples/scum-server-plugin/features/schemas.js";
import { scumMigrationParityFixtures } from "./fixtures/scum-migration-parity.js";
import { configurationCatalog, validateConfigPatch } from "../examples/scum-server-plugin/features/schemas.js";
const pageSource = readFileSync(resolve(dirname(fileURLToPath(import.meta.url)), "../examples/scum-server-plugin/features/page.ts"), "utf8");
const projectionData = {
@@ -22,41 +20,7 @@ describe("SCUM plugin feature module", () => {
it("owns runtime allowlists without a version gate", () => {
expect(configurationCatalog.map((field) => field.key)).toContain("welcome-message");
expect(validateConfigPatch({ reason: "adjust capacity", idempotencyKey: "cfg-1", changes: [{ key: "max-players", value: "129" }] })).toContain("超出允许范围");
expect(validateStatePatch([{ fieldKey: "skills.running", before: 1, after: 2 }])).toBeNull();
expect(validateStatePatch([{ fieldKey: "unknown", before: 1, after: 2 }])).toContain("白名单");
expect(vehicleSpawnCatalog.map((vehicle) => vehicle.code)).toEqual(["BPC_Laika_C", "BPC_WolfsWagen_C"]);
expect(validateVehicleSpawn({ vehicleCode: "BPC_Laika_C" })).toBeNull();
expect(validateVehicleSpawn({ vehicleCode: "#spawnvehicle BPC_Laika_C" })).toContain("格式无效");
expect(validateVehicleSpawn({ vehicleCode: "BPC_Unknown_C" })).toContain("受控目录");
});
it("maps transitional records only as read-only provenance", () => {
expect(migratePlayerRecord({ id: "p-1", gamePlayerId: "steam-1", displayName: "Mira", updatedAt: "2026-07-29T00:00:00Z" })).toMatchObject({ provenance: "transitional-read-only", readOnly: true, payload: { gamePlayerId: "steam-1" } });
expect(migrateTrajectoryRecord({ playerRecordId: "p-1", points: [{ recordedAt: "2026-07-29T00:00:00Z", mapX: 10, mapY: 20 }] })).toMatchObject({ provenance: "transitional-read-only", points: [{ x: 10, y: 20 }] });
});
it("preserves only allowlisted transitional history for every feature area", () => {
expect(migrateConfigurationRecord({ id: "cfg-1", version: "0.9.700.90357", fields: { MaxPlayers: 64 }, observedAt: "2026-07-29T00:00:00Z", hostPath: "C:/secret" })).toMatchObject({ readOnly: true, payload: { fields: { MaxPlayers: "64" } } });
expect(migratePlayerProfileRecord({ player: { id: "p-1", gamePlayerId: "steam-1", displayName: "Mira", updatedAt: "2026-07-29T00:00:00Z" }, sessions: [{ id: "s-1", gamePlayerRecordId: "p-1", startedAt: "2026-07-29T00:00:00Z", networkFingerprint: "never-copy" }], accessAttempts: [{ occurredAt: "2026-07-29T00:01:00Z", outcome: "review", reason: "manual" }] })).toMatchObject({ payload: { sessions: [{ kind: "login" }], risks: [{ summary: "manual" }] } });
expect(migrateGiftGrantRecord({ id: "gift-1", revisionId: "r-1", gamePlayerRecordId: "p-1", status: "unknown", createdAt: "2026-07-29T00:00:00Z" })).toMatchObject({ payload: { status: "unknown" }, readOnly: true });
expect(migrateStatePatchRecord({ id: "patch-1", gamePlayerRecordId: "p-1", expectedStateVersion: "state-1", safetyWindow: "maintenance", status: "confirmed", createdAt: "2026-07-29T00:00:00Z", changes: [{ fieldKey: "skills.running", before: 1, after: 2 }] })).toMatchObject({ payload: { status: "succeeded" }, readOnly: true });
expect(migrateTrajectoryHistoryRecord({ id: "track-1", playerRecordId: "p-1", points: [{ recordedAt: "2026-07-29T00:00:00Z", mapX: 10, mapY: 20 }] })).toMatchObject({ sourceRecordId: "track-1", readOnly: true });
});
it("matches controlled transitional fixtures without carrying sensitive fields into plugin history", () => {
expect(migrateConfigurationRecord(scumMigrationParityFixtures.configuration.source)).toEqual(scumMigrationParityFixtures.configuration.expected);
expect(migratePlayerProfileRecord(scumMigrationParityFixtures.playerHistory.source)).toEqual(scumMigrationParityFixtures.playerHistory.expected);
expect(migrateGiftGrantRecord(scumMigrationParityFixtures.gift.source)).toEqual(scumMigrationParityFixtures.gift.expected);
expect(migrateStatePatchRecord(scumMigrationParityFixtures.statePatch.source)).toEqual(scumMigrationParityFixtures.statePatch.expected);
expect(migrateTrajectoryHistoryRecord(scumMigrationParityFixtures.trajectory.source)).toEqual(scumMigrationParityFixtures.trajectory.expected);
expect(migrateConfigurationRecord({ version: "0.9.700.90357", observedAt: "2026-07-29T00:00:00Z", fields: { hostPath: "/srv/scum" } })).toBeNull();
});
it("enables plugin authority only for one exact server-feature flag", () => {
const flags = [{ serverInstanceId: "server-1", feature: "configuration" as const, authority: "plugin" as const }];
expect(migrationStatus(flags, "server-1", "configuration")).toMatchObject({ authority: "plugin", pluginWritesEnabled: true, readOnlyHistory: true });
expect(migrationStatus(flags, "server-2", "configuration")).toMatchObject({ authority: "transitional-read-only", pluginWritesEnabled: false });
expect(migrationStatus([...flags, flags[0]], "server-1", "configuration")).toMatchObject({ authority: "transitional-read-only", pluginWritesEnabled: false });
expect(validateConfigPatch({ reason: "adjust capacity", idempotencyKey: "cfg-2", changes: [{ key: "unknown", value: "1" }] })).toContain("受控目录");
});
it("renders local user management without projection or fake-write controls", () => {