diff --git a/openspec/changes/replace-scum-projections-with-real-data-management/tasks.md b/openspec/changes/replace-scum-projections-with-real-data-management/tasks.md index a15b6b7..b667afc 100644 --- a/openspec/changes/replace-scum-projections-with-real-data-management/tasks.md +++ b/openspec/changes/replace-scum-projections-with-real-data-management/tasks.md @@ -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 diff --git a/platform/api/routes.md b/platform/api/routes.md index e85434b..944bebe 100644 --- a/platform/api/routes.md +++ b/platform/api/routes.md @@ -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. diff --git a/platform/api/scum_handlers.go b/platform/api/scum_handlers.go index 47ba825..70b9e89 100644 --- a/platform/api/scum_handlers.go +++ b/platform/api/scum_handlers.go @@ -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 diff --git a/platform/api/scum_handlers_test.go b/platform/api/scum_handlers_test.go index e2eee4d..d11a0f1 100644 --- a/platform/api/scum_handlers_test.go +++ b/platform/api/scum_handlers_test.go @@ -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) -} diff --git a/platform/dto/scum_workflows.go b/platform/dto/scum_workflows.go deleted file mode 100644 index 7cac527..0000000 --- a/platform/dto/scum_workflows.go +++ /dev/null @@ -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)} -} diff --git a/platform/service/scum_operations.go b/platform/service/scum_operations.go index ed2b676..a51b155 100644 --- a/platform/service/scum_operations.go +++ b/platform/service/scum_operations.go @@ -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) { diff --git a/platform/service/scum_projections.go b/platform/service/scum_projections.go index b85f308..cc71511 100644 --- a/platform/service/scum_projections.go +++ b/platform/service/scum_projections.go @@ -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 { diff --git a/platform/service/scum_workflows.go b/platform/service/scum_workflows.go index 78ec9e5..8d4c152 100644 --- a/platform/service/scum_workflows.go +++ b/platform/service/scum_workflows.go @@ -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."}}}, } } diff --git a/platform_web/api/client.test.ts b/platform_web/api/client.test.ts index 2332ca3..887d789 100644 --- a/platform_web/api/client.test.ts +++ b/platform_web/api/client.test.ts @@ -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); diff --git a/platform_web/api/client.ts b/platform_web/api/client.ts index e2464ca..51b23f3 100644 --- a/platform_web/api/client.ts +++ b/platform_web/api/client.ts @@ -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(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/positions`); } - async listSCUMOperations(serverInstanceId: string): Promise { - return this.request(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/operations`); - } - - async createSCUMOperation(serverInstanceId: string, request: SCUMOperationRequest): Promise { - return this.request(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/operations`, { method: "POST", body: request }); - } - - async approveSCUMOperation(serverInstanceId: string, operationId: string): Promise { - return this.request(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/operations/${encodeURIComponent(operationId)}/approve`, { method: "POST", body: {} }); - } - - async listSCUMWorkflows(serverInstanceId: string): Promise { - return this.request(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/workflows`); - } - - async createSCUMWorkflow(serverInstanceId: string, request: SCUMWorkflowCreateRequest): Promise { - return this.request(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/workflows`, { method: "POST", body: request }); - } - - async listSCUMWorkflowSteps(serverInstanceId: string, workflowId?: string): Promise { - const params = new URLSearchParams(); - if (workflowId) params.set("workflowId", workflowId); - const query = params.toString(); - return this.request(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/workflow-steps${query ? `?${query}` : ""}`); - } - async dispatchFileOperation(request: FileOperationDispatchRequest): Promise { return this.request("/file-operations/dispatch", { method: "POST", diff --git a/platform_web/api/contracts.md b/platform_web/api/contracts.md index a89d660..dfd8206 100644 --- a/platform_web/api/contracts.md +++ b/platform_web/api/contracts.md @@ -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. diff --git a/platform_web/api/types.ts b/platform_web/api/types.ts index b3afca7..130a717 100644 --- a/platform_web/api/types.ts +++ b/platform_web/api/types.ts @@ -1375,14 +1375,6 @@ export interface RemoteAdapterResponse { export type SCUMJsonRecord = Record; export interface SCUMListResponse { 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; -export type SCUMWorkflowStepListResponse = SCUMListResponse; -export type SCUMOperationListResponse = SCUMListResponse; export interface ServerConfigResponse { serverInstanceId: string; diff --git a/platform_web/contracts/pages.md b/platform_web/contracts/pages.md index ea85a4e..8c736d9 100644 --- a/platform_web/contracts/pages.md +++ b/platform_web/contracts/pages.md @@ -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. ## 插件市场 diff --git a/platform_web/contracts/plugin-page-bridge.md b/platform_web/contracts/plugin-page-bridge.md index f57fe08..5e3c069 100644 --- a/platform_web/contracts/plugin-page-bridge.md +++ b/platform_web/contracts/plugin-page-bridge.md @@ -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 diff --git a/platform_web/routes/routes.test.ts b/platform_web/routes/routes.test.ts index 8d6e44c..c8eeffd 100644 --- a/platform_web/routes/routes.test.ts +++ b/platform_web/routes/routes.test.ts @@ -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", () => { diff --git a/platform_web/utils/safeDiagnosticText.test.ts b/platform_web/utils/safeDiagnosticText.test.ts index df24a67..909ae41 100644 --- a/platform_web/utils/safeDiagnosticText.test.ts +++ b/platform_web/utils/safeDiagnosticText.test.ts @@ -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); }); }); diff --git a/plugins/examples/scum-server-plugin/features/api.ts b/plugins/examples/scum-server-plugin/features/api.ts deleted file mode 100644 index 3fa9378..0000000 --- a/plugins/examples/scum-server-plugin/features/api.ts +++ /dev/null @@ -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): Promise<{ status: string; result?: Record; error?: { message: string } }> }; -export type SCUMFeatureAPI = { - availability(feature: SCUMFeatureKey): Promise; readConfig(): Promise; patchConfig(patch: SCUMConfigPatch): Promise; - playerProfile(playerId: string): Promise; stateSnapshot(playerId: string): Promise; requestStatePatch(patch: SCUMStatePatch): Promise; - requestVehicleSpawn(spawn: SCUMVehicleSpawn): Promise; - giftGrants(): Promise; trajectories(): Promise; -}; - -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(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(result.result) : null; }, - async stateSnapshot(playerId) { const result = await bridge.dispatch("game-client.command", { type: "player.lookup", playerId }); return result.status === "ok" ? decode(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(result.result) ?? [] : []; }, - async trajectories() { const result = await bridge.dispatch("game-client.snapshot.read", { type: "semantic.events", projection: "trajectories" }); return result.status === "ok" ? decode(result.result) ?? { available: false, reason: "没有已验证的位置事件源。", trajectories: [] } : { available: false, reason: result.error?.message ?? "没有已验证的位置事件源。", trajectories: [] }; } - }; -} - -function commandResult(result: { status: string; result?: Record; 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(result: Record | undefined): T | null { const payload = result?.payload; if (!payload) return null; try { return JSON.parse(payload) as T; } catch { return null; } } diff --git a/plugins/examples/scum-server-plugin/features/contracts.ts b/plugins/examples/scum-server-plugin/features/contracts.ts index f8ac608..15faf12 100644 --- a/plugins/examples/scum-server-plugin/features/contracts.ts +++ b/plugins/examples/scum-server-plugin/features/contracts.ts @@ -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> = { 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 }; -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 } }; diff --git a/plugins/examples/scum-server-plugin/features/migration.ts b/plugins/examples/scum-server-plugin/features/migration.ts deleted file mode 100644 index 34e9b4b..0000000 --- a/plugins/examples/scum-server-plugin/features/migration.ts +++ /dev/null @@ -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>(payload: T, recordedAt: string, sourceRecordId?: string): SCUMMigrationRecord { return { provenance: "transitional-read-only", readOnly: true, payload, recordedAt, sourceRecordId }; } -export function pluginOwned>(payload: T, recordedAt: string): SCUMMigrationRecord { 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): SCUMMigrationRecord | 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): SCUMMigrationRecord | 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): SCUMMigrationRecord | 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): SCUMMigrationRecord | 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): SCUMMigrationRecord | 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): 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): SCUMMigrationRecord | 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 | null { const fields = object(value); const allowed = new Set(configurationCatalog.map((field) => field.configKey)); if (!fields || !allowed.size) return null; const result: Record = {}; 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): 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 | null { return value && typeof value === "object" && !Array.isArray(value) ? value as Record : 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; } diff --git a/plugins/examples/scum-server-plugin/features/schemas.ts b/plugins/examples/scum-server-plugin/features/schemas.ts index 0ed2614..253f725 100644 --- a/plugins/examples/scum-server-plugin/features/schemas.ts +++ b/plugins/examples/scum-server-plugin/features/schemas.ts @@ -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[] = [{ 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; } diff --git a/plugins/tests/fixtures/scum-migration-parity.ts b/plugins/tests/fixtures/scum-migration-parity.ts deleted file mode 100644 index 8bad2ef..0000000 --- a/plugins/tests/fixtures/scum-migration-parity.ts +++ /dev/null @@ -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; diff --git a/plugins/tests/scum-feature-module.test.ts b/plugins/tests/scum-feature-module.test.ts index 53f7d6e..de80cc8 100644 --- a/plugins/tests/scum-feature-module.test.ts +++ b/plugins/tests/scum-feature-module.test.ts @@ -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", () => {