Integrate SCUM real ops workflows

This commit is contained in:
npc0-hue
2026-08-10 21:12:53 +08:00
parent 1063330710
commit a770bc6250
88 changed files with 6375 additions and 2719 deletions
@@ -1,101 +1,101 @@
## 1. Workflow A - Contracts and Safety Model ## 1. Workflow A - Contracts and Safety Model
- [ ] 1.1 Positive prompt: Build the shared SCUM real-data and controlled-operation contract so platform, platform_web, and plugins agree on real observation records, typed write operations, workflow instances, and confirmation states. - [x] 1.1 Positive prompt: Build the shared SCUM real-data and controlled-operation contract so platform, platform_web, and plugins agree on real observation records, typed write operations, workflow instances, and confirmation states.
- [ ] 1.2 Directional prompt: Update `platform/domain`, `platform/dto`, `platform/validator`, `plugins/sdk`, and manifest schemas using existing job, bridge, protected request, gift, and player-state patterns; verify with Go tests and plugin type/schema tests. - [x] 1.2 Directional prompt: Update `platform/domain`, `platform/dto`, `platform/validator`, `plugins/sdk`, and manifest schemas using existing job, bridge, protected request, gift, and player-state patterns; verify with Go tests and plugin type/schema tests.
- [ ] 1.3 Boundary prompt: Do not add a `run/` tree, raw SQL browser payloads, host paths, DSNs, sockets, credentials, billing, hosting sales, or unrelated SaaS marketplace behavior. - [x] 1.3 Boundary prompt: Do not add a `run/` tree, raw SQL browser payloads, host paths, DSNs, sockets, credentials, billing, hosting sales, or unrelated SaaS marketplace behavior.
- [ ] 1.4 Define domain/DTO types for SCUM observations, projection freshness, workflow instances, workflow steps, operation requests, mutation guards, confirmation results, and safe summaries. - [x] 1.4 Define domain/DTO types for SCUM observations, projection freshness, workflow instances, workflow steps, operation requests, mutation guards, confirmation results, and safe summaries.
- [ ] 1.5 Extend validators to reject arbitrary SQL/RCON/terminal fields in browser/plugin payloads while accepting declared typed query and operation template keys. - [x] 1.5 Extend validators to reject arbitrary SQL/RCON/terminal fields in browser/plugin payloads while accepting declared typed query and operation template keys.
- [ ] 1.6 Add tests proving raw SQL, host paths, direct sockets, credentials, and undeclared operation keys are rejected before job creation. - [x] 1.6 Add tests proving raw SQL, host paths, direct sockets, credentials, and undeclared operation keys are rejected before job creation.
## 2. Workflow B - Real SCUM Data Observations ## 2. Workflow B - Real SCUM Data Observations
- [ ] 2.1 Positive prompt: Read real current-server SCUM facts from login logs and SCUM.db through the bound run/agent, then persist local projections without fake data. - [x] 2.1 Positive prompt: Read real current-server SCUM facts from login logs and SCUM.db through the bound run/agent, then persist local projections without fake data.
- [ ] 2.2 Directional prompt: Extend existing log ingest, game player services, remote adapter/query template plumbing, and job result projection in `platform/`; verify with unit tests around stale observations and schema validation. - [x] 2.2 Directional prompt: Extend existing log ingest, game player services, remote adapter/query template plumbing, and job result projection in `platform/`; verify with unit tests around stale observations and schema validation.
- [ ] 2.3 Boundary prompt: Do not query SCUM.db from platform_web, do not expose SQL text or machine paths, and do not overwrite last-known-good projections on failed observations. - [x] 2.3 Boundary prompt: Do not query SCUM.db from platform_web, do not expose SQL text or machine paths, and do not overwrite last-known-good projections on failed observations.
- [ ] 2.4 Add projection repositories for players, live states, squads, squad members, vehicles, flags, current positions, and observation metadata. - [x] 2.4 Add projection repositories for players, live states, squads, squad members, vehicles, flags, current positions, and observation metadata.
- [ ] 2.5 Wire login/logout log parsing to player/session projection creation and update, preserving source evidence and unknown fields. - [x] 2.5 Wire login/logout log parsing to player/session projection creation and update, preserving source evidence and unknown fields.
- [ ] 2.6 Add typed SCUM.db read-result projection handlers for player profile/economy, squads, squad members, vehicles, flags, and positions. - [x] 2.6 Add typed SCUM.db read-result projection handlers for player profile/economy, squads, squad members, vehicles, flags, and positions.
- [ ] 2.7 Add freshness/stale-state logic with sequence, observedAt, receivedAt, checksum, query key, and server/plugin binding validation. - [x] 2.7 Add freshness/stale-state logic with sequence, observedAt, receivedAt, checksum, query key, and server/plugin binding validation.
- [ ] 2.8 Add tests for older observation rejection, failed-query stale marking, user_profile_id vs Steam/user ID separation, and `last_save_time` freshness-only behavior. - [x] 2.8 Add tests for older observation rejection, failed-query stale marking, user_profile_id vs Steam/user ID separation, and `last_save_time` freshness-only behavior.
## 3. Workflow C - SCUM Plugin Read Templates ## 3. Workflow C - SCUM Plugin Read Templates
- [ ] 3.1 Positive prompt: Declare SCUM plugin read templates for users, squads, vehicles, flags, and coordinates using real SCUM.db tables and typed result schemas. - [x] 3.1 Positive prompt: Declare SCUM plugin read templates for users, squads, vehicles, flags, and coordinates using real SCUM.db tables and typed result schemas.
- [ ] 3.2 Directional prompt: Update `plugins/examples/scum-server-plugin/manifest.json`, `schemas/bridge/queries`, plugin tests, and manifest validation while keeping query declarations safe and route-scoped. - [x] 3.2 Directional prompt: Update `plugins/examples/scum-server-plugin/manifest.json`, `schemas/bridge/queries`, plugin tests, and manifest validation while keeping query declarations safe and route-scoped.
- [ ] 3.3 Boundary prompt: Do not copy scum_robot management-library fields as SCUM.db facts, do not infer missing fields, and do not let pages submit SQL text. - [x] 3.3 Boundary prompt: Do not copy scum_robot management-library fields as SCUM.db facts, do not infer missing fields, and do not let pages submit SQL text.
- [ ] 3.4 Add query templates for player profile/economy/position using `user_profile`, `prisoner`, `prisoner_entity`, `entity`, `bank_account_registry`, and `bank_account_registry_currencies`. - [x] 3.4 Add query templates for player profile/economy/position using `user_profile`, `prisoner`, `prisoner_entity`, `entity`, `bank_account_registry`, and `bank_account_registry_currencies`.
- [ ] 3.5 Add query templates for squads and members using `squad`, `squad_member`, and `user_profile`. - [x] 3.5 Add query templates for squads and members using `squad`, `squad_member`, and `user_profile`.
- [ ] 3.6 Add query templates for vehicles using `vehicle_spawner` and `entity`, with unknown/fallback vehicle labels when mapping is absent. - [x] 3.6 Add query templates for vehicles using `vehicle_spawner` and `entity`, with unknown/fallback vehicle labels when mapping is absent.
- [ ] 3.7 Add query templates for flags using `base_element`, `user_profile`, `squad_member`, and `squad` where available. - [x] 3.7 Add query templates for flags using `base_element`, `user_profile`, `squad_member`, and `squad` where available.
- [ ] 3.8 Add schema and validation tests for row bounds, typed result shape, page/template binding, and route permissions. - [x] 3.8 Add schema and validation tests for row bounds, typed result shape, page/template binding, and route permissions.
## 4. Workflow D - Controlled RCON Operations ## 4. Workflow D - Controlled RCON Operations
- [ ] 4.1 Positive prompt: Support safe fame, normal currency, gold, player notification, and reward command workflows through typed RCON operations where SCUM supports commands. - [x] 4.1 Positive prompt: Support safe fame, normal currency, gold, player notification, and reward command workflows through typed RCON operations where SCUM supports commands.
- [ ] 4.2 Directional prompt: Build on existing source RCON/protected RCON dispatch and game client bridge command approval paths; verify with service tests for approval, redaction, and confirmation status. - [x] 4.2 Directional prompt: Build on existing source RCON/protected RCON dispatch and game client bridge command approval paths; verify with service tests for approval, redaction, and confirmation status.
- [ ] 4.3 Boundary prompt: Do not restore a product terminal or arbitrary RCON input box; do not mark queued commands as delivered or successful. - [x] 4.3 Boundary prompt: Do not restore a product terminal or arbitrary RCON input box; do not mark queued commands as delivered or successful.
- [ ] 4.4 Declare typed RCON operation templates for `player.fame.set`, `player.currency.normal.set`, `player.currency.gold.set`, `player.notify`, and command-backed reward delivery. - [x] 4.4 Declare typed RCON operation templates for `player.fame.set`, `player.currency.normal.set`, `player.currency.gold.set`, `player.notify`, and command-backed reward delivery.
- [ ] 4.5 Add platform services to create, approve, dispatch, and reconcile typed RCON operation requests with idempotency and audit references. - [x] 4.5 Add platform services to create, approve, dispatch, and reconcile typed RCON operation requests with idempotency and audit references.
- [ ] 4.6 Add read-after-write confirmation using follow-up SCUM.db observation queries or typed companion results. - [x] 4.6 Add read-after-write confirmation using follow-up SCUM.db observation queries or typed companion results.
- [ ] 4.7 Add tests for permission denial, protected text redaction, command unknown state, confirmation failure, and duplicate prevention. - [x] 4.7 Add tests for permission denial, protected text redaction, command unknown state, confirmation failure, and duplicate prevention.
## 5. Workflow E - Controlled DB Mutations ## 5. Workflow E - Controlled DB Mutations
- [ ] 5.1 Positive prompt: Support database-only player state edits such as field `855` through typed mutation templates with maintenance/offline safety and readback confirmation. - [x] 5.1 Positive prompt: Support database-only player state edits such as field `855` through typed mutation templates with maintenance/offline safety and readback confirmation.
- [ ] 5.2 Directional prompt: Extend operation template schemas, platform operation services, and run job execution contracts without storing raw mutation SQL in browser-visible records; verify with stale-write and confirmation tests. - [x] 5.2 Directional prompt: Extend operation template schemas, platform operation services, and run job execution contracts without storing raw mutation SQL in browser-visible records; verify with stale-write and confirmation tests.
- [ ] 5.3 Boundary prompt: Do not use DB mutation for fame/currency when RCON exists, do not write more than declared row bounds, and do not proceed without before-value guards. - [x] 5.3 Boundary prompt: Do not use DB mutation for fame/currency when RCON exists, do not write more than declared row bounds, and do not proceed without before-value guards.
- [ ] 5.4 Add mutation template declarations for DB-only player fields with field key, table/identity mapping metadata, allowed range, safety level, confirmation query, and max affected rows. - [x] 5.4 Add mutation template declarations for DB-only player fields with field key, table/identity mapping metadata, allowed range, safety level, confirmation query, and max affected rows.
- [ ] 5.5 Add platform approval flow requiring current projection, `before` match, offline/maintenance window, backup/snapshot evidence, and platform-admin approval. - [x] 5.5 Add platform approval flow requiring current projection, `before` match, offline/maintenance window, backup/snapshot evidence, and platform-admin approval.
- [ ] 5.6 Add run job result validation for affected rows, mutation checksum, confirmation rows, and unknown execution states. - [x] 5.6 Add run job result validation for affected rows, mutation checksum, confirmation rows, and unknown execution states.
- [ ] 5.7 Add tests for online-player blocking, missing maintenance window, stale before-value, over-bound affected rows, and successful confirmation. - [x] 5.7 Add tests for online-player blocking, missing maintenance window, stale before-value, over-bound affected rows, and successful confirmation.
## 6. Workflow F - Sequential Workflow Engine ## 6. Workflow F - Sequential Workflow Engine
- [ ] 6.1 Positive prompt: Create a SCUM workflow queue that can run real-data refreshes and controlled operations one by one with dependency tracking. - [x] 6.1 Positive prompt: Create a SCUM workflow queue that can run real-data refreshes and controlled operations one by one with dependency tracking.
- [ ] 6.2 Directional prompt: Add platform workflow domain/repo/service/API code near existing job/game-client/gift/player-state services; keep run execution delegated through existing job channels. - [x] 6.2 Directional prompt: Add platform workflow domain/repo/service/API code near existing job/game-client/gift/player-state services; keep run execution delegated through existing job channels.
- [ ] 6.3 Boundary prompt: Do not replace the generic run job scheduler, do not block control heartbeat/log/artifact channels, and do not expose protected payload text in workflow status. - [x] 6.3 Boundary prompt: Do not replace the generic run job scheduler, do not block control heartbeat/log/artifact channels, and do not expose protected payload text in workflow status.
- [ ] 6.4 Implement workflow instance and step state transitions: draft, queued, running, waiting, blocked, confirming, confirmed, failed, unknown, cancelled. - [x] 6.4 Implement workflow instance and step state transitions: draft, queued, running, waiting, blocked, confirming, confirmed, failed, unknown, cancelled.
- [ ] 6.5 Implement per-server sequential dispatch for state-changing steps and bounded concurrency for read-only observation steps. - [x] 6.5 Implement per-server sequential dispatch for state-changing steps and bounded concurrency for read-only observation steps.
- [ ] 6.6 Add workflow templates for bootstrap real data, player refresh, world refresh, player correction, gift delivery, territory audit, vehicle audit, AI assist, and product cleanup. - [x] 6.6 Add workflow templates for bootstrap real data, player refresh, world refresh, player correction, gift delivery, territory audit, vehicle audit, AI assist, and product cleanup.
- [ ] 6.7 Add idempotency, fencing, retry, confirmation-before-retry, and blocker-safe-summary behavior. - [x] 6.7 Add idempotency, fencing, retry, confirmation-before-retry, and blocker-safe-summary behavior.
- [ ] 6.8 Add tests for ordered execution, dependency blocking, run-unavailable blocking, retry without duplicate effects, and terminal status projection. - [x] 6.8 Add tests for ordered execution, dependency blocking, run-unavailable blocking, retry without duplicate effects, and terminal status projection.
## 7. Workflow G - Product APIs and Projection Views ## 7. Workflow G - Product APIs and Projection Views
- [ ] 7.1 Positive prompt: Expose safe SCUM APIs for projection-backed users, squads, vehicles, flags, map overlays, gifts, operations, workflows, and AI drafts. - [x] 7.1 Positive prompt: Expose safe SCUM APIs for projection-backed users, squads, vehicles, flags, map overlays, gifts, operations, workflows, and AI drafts.
- [ ] 7.2 Directional prompt: Add `platform/api`, `platform/dto`, and service handlers following existing resource handler patterns; verify authorization tests and DTO round trips. - [x] 7.2 Directional prompt: Add `platform/api`, `platform/dto`, and service handlers following existing resource handler patterns; verify authorization tests and DTO round trips.
- [ ] 7.3 Boundary prompt: Do not expose raw logs, terminal input, arbitrary config file editing, generic operation-history APIs, SQL text, DSNs, host paths, or raw protected request payloads. - [x] 7.3 Boundary prompt: Do not expose raw logs, terminal input, arbitrary config file editing, generic operation-history APIs, SQL text, DSNs, host paths, or raw protected request payloads.
- [ ] 7.4 Add list/detail APIs for SCUM players, squads, squad members, vehicles, flags, current positions, map overlays, and observation freshness. - [x] 7.4 Add list/detail APIs for SCUM players, squads, squad members, vehicles, flags, current positions, map overlays, and observation freshness.
- [ ] 7.5 Add workflow APIs for creating refresh/correction/gift/audit/AI workflows, listing workflow status, approving required steps, cancelling safe pending steps, and reading audit-safe summaries. - [x] 7.5 Add workflow APIs for creating refresh/correction/gift/audit/AI workflows, listing workflow status, approving required steps, cancelling safe pending steps, and reading audit-safe summaries.
- [ ] 7.6 Update or remove legacy product APIs for raw logs, management terminal, raw config workbench, and generic operation history. - [x] 7.6 Update or remove legacy product APIs for raw logs, management terminal, raw config workbench, and generic operation history.
- [ ] 7.7 Add authorization tests for operator vs platform-admin actions and raw endpoint removal/denial. - [x] 7.7 Add authorization tests for operator vs platform-admin actions and raw endpoint removal/denial.
## 8. Workflow H - SCUM Product Surfaces ## 8. Workflow H - SCUM Product Surfaces
- [ ] 8.1 Positive prompt: Replace SCUM plugin pages with real projection-backed user management, squad management, realtime map, vehicle/flag management, gift management, workflow status, and AI assistant surfaces. - [x] 8.1 Positive prompt: Replace SCUM plugin pages with real projection-backed user management, squad management, realtime map, vehicle/flag management, gift management, workflow status, and AI assistant surfaces.
- [ ] 8.2 Directional prompt: Update `platform_web` contracts and SCUM plugin feature pages while preserving the black mecha and crystal-moonlight theme system; run typecheck and frontend tests. - [x] 8.2 Directional prompt: Update `platform_web` contracts and SCUM plugin feature pages while preserving the black mecha and crystal-moonlight theme system; run typecheck and frontend tests.
- [ ] 8.3 Boundary prompt: Do not introduce generic SaaS cards, page-local fixed decoration spans, raw terminal/log/config/history panels, or fake map/sample data. - [x] 8.3 Boundary prompt: Do not introduce generic SaaS cards, page-local fixed decoration spans, raw terminal/log/config/history panels, or fake map/sample data.
- [ ] 8.4 Build user management UI showing identities, sessions, projection source, squad, fame, balances, coordinates, freshness, and typed edit workflow launchers. - [x] 8.4 Build user management UI showing identities, sessions, projection source, squad, fame, balances, coordinates, freshness, and typed edit workflow launchers.
- [ ] 8.5 Build squad/flag UI showing rosters, ranks, leaders, flags, ownership confidence, stale state, and refresh/audit workflow controls. - [x] 8.5 Build squad/flag UI showing rosters, ranks, leaders, flags, ownership confidence, stale state, and refresh/audit workflow controls.
- [ ] 8.6 Build realtime map UI using local projections for players, vehicles, flags, squads, timestamps, stale status, and refresh controls. - [x] 8.6 Build realtime map UI using local projections for players, vehicles, flags, squads, timestamps, stale status, and refresh controls.
- [ ] 8.7 Build gift UI for catalog versions, schedules, eligibility, claims, delivery workflow status, confirmation, unknown-state reconciliation, and player notifications. - [x] 8.7 Build gift UI for catalog versions, schedules, eligibility, claims, delivery workflow status, confirmation, unknown-state reconciliation, and player notifications.
- [ ] 8.8 Keep AI assistant UI for typed config diffs and workflow drafts, wired to approval flows rather than raw file editing. - [x] 8.8 Keep AI assistant UI for typed config diffs and workflow drafts, wired to approval flows rather than raw file editing.
- [ ] 8.9 Remove raw logs, management terminal, raw config workbench, and operation history routes from server detail navigation and plugin workspace contracts. - [x] 8.9 Remove raw logs, management terminal, raw config workbench, and operation history routes from server detail navigation and plugin workspace contracts.
## 9. Workflow I - Run Integration Contract ## 9. Workflow I - Run Integration Contract
- [ ] 9.1 Positive prompt: Define the browser-repo contract expected from the independent run repository for SCUM.db reads, RCON writes, DB mutations, confirmation reads, and log ingestion. - [x] 9.1 Positive prompt: Define the browser-repo contract expected from the independent run repository for SCUM.db reads, RCON writes, DB mutations, confirmation reads, and log ingestion.
- [ ] 9.2 Directional prompt: Document and test platform/plugin protocol expectations in this repo; keep executable run implementation for `git@git.npc0.com:admin343/run.git` outside this repository. - [x] 9.2 Directional prompt: Document and test platform/plugin protocol expectations in this repo; keep executable run implementation for `git@git.npc0.com:admin343/run.git` outside this repository.
- [ ] 9.3 Boundary prompt: Do not add run source code here, do not require deployment target/run endpoint at server creation time, and do not leak component auth keys or host paths. - [x] 9.3 Boundary prompt: Do not add run source code here, do not require deployment target/run endpoint at server creation time, and do not leak component auth keys or host paths.
- [ ] 9.4 Add protocol DTOs or contract docs for read observation inputs/results, mutation inputs/results, confirmation payloads, schema probes, and safe errors. - [x] 9.4 Add protocol DTOs or contract docs for read observation inputs/results, mutation inputs/results, confirmation payloads, schema probes, and safe errors.
- [ ] 9.5 Add compatibility tests/mocks proving platform can process run-style read/mutation/RCON results without real run code in this repository. - [x] 9.5 Add compatibility tests/mocks proving platform can process run-style read/mutation/RCON results without real run code in this repository.
- [ ] 9.6 Document external run tasks needed to execute SCUM.db query templates and controlled mutation templates on the service machine. - [x] 9.6 Document external run tasks needed to execute SCUM.db query templates and controlled mutation templates on the service machine.
## 10. Workflow J - Verification, Cleanup, Commit ## 10. Workflow J - Verification, Cleanup, Commit
- [ ] 10.1 Positive prompt: Verify the full SCUM integration change with backend, frontend, plugin, structure, and OpenSpec checks before committing. - [x] 10.1 Positive prompt: Verify the full SCUM integration change with backend, frontend, plugin, structure, and OpenSpec checks before committing.
- [ ] 10.2 Directional prompt: Run targeted tests during implementation and final commands: `(cd platform && go test ./...)`, `(cd platform_web && npm run typecheck && npm run test && npm run build)`, `(cd plugins && npm run typecheck && npm run test && npm run validate:manifest)`, `scripts/check-structure.sh`, and `openspec validate integrate-real-scum-ops-workflows --strict`. - [x] 10.2 Directional prompt: Run targeted tests during implementation and final commands: `(cd platform && go test ./...)`, `(cd platform_web && npm run typecheck && npm run test && npm run build)`, `(cd plugins && npm run typecheck && npm run test && npm run validate:manifest)`, `scripts/check-structure.sh`, and `openspec validate integrate-real-scum-ops-workflows --strict`.
- [ ] 10.3 Boundary prompt: Do not mark tasks complete without verification evidence, do not stage unrelated worktree changes, and do not push if required verification or credentials fail. - [x] 10.3 Boundary prompt: Do not mark tasks complete without verification evidence, do not stage unrelated worktree changes, and do not push if required verification or credentials fail.
- [ ] 10.4 Sweep for forbidden raw SQL/RCON/terminal/config/history product surfaces and unsafe browser-visible fields. - [x] 10.4 Sweep for forbidden raw SQL/RCON/terminal/config/history product surfaces and unsafe browser-visible fields.
- [ ] 10.5 Update documentation or comments only where they clarify new contracts and workflow behavior. - [x] 10.5 Update documentation or comments only where they clarify new contracts and workflow behavior.
- [ ] 10.6 Stage only files changed for this task, commit on `main`, and push to the configured remote after verification succeeds. - [x] 10.6 Stage only files changed for this task, commit on `main`, and push to the configured remote after verification succeeds.
+1 -13
View File
@@ -20,19 +20,7 @@ const (
logEventHeartbeatInterval = 15 * time.Second logEventHeartbeatInterval = 15 * time.Second
) )
// serverLogEvents godoc // serverLogEvents is kept as legacy service plumbing but is not registered as a browser product route.
// @Summary Stream server log events
// @Description Streams safe server log entries over Server-Sent Events. Durable cursor query remains available for history and reconnect repair.
// @Tags logs
// @Produce text/event-stream
// @Param id path string true "Server instance ID"
// @Param historyLimit query int false "Total recent entries to replay across this server's streams"
// @Success 200 {string} string "event-stream"
// @Failure 401 {object} dto.ErrorResponse
// @Failure 403 {object} dto.ErrorResponse
// @Failure 404 {object} dto.ErrorResponse
// @Failure 405 {object} dto.ErrorResponse
// @Router /api/v1/server-instances/{id}/logs/events [get]
func (h *coreHandlers) serverLogEvents(w http.ResponseWriter, r *http.Request) { func (h *coreHandlers) serverLogEvents(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet { if r.Method != http.MethodGet {
writeMethodNotAllowed(w, http.MethodGet) writeMethodNotAllowed(w, http.MethodGet)
+2 -51
View File
@@ -1,10 +1,7 @@
package api package api
import ( import (
"bufio"
"net/http" "net/http"
"net/http/httptest"
"strings"
"testing" "testing"
"time" "time"
@@ -44,24 +41,7 @@ func TestLogEventsSSEReplaysHistory(t *testing.T) {
batch := validLogBatchRequest(t, hello.SessionToken, 1, 2) batch := validLogBatchRequest(t, hello.SessionToken, 1, 2)
assertStatus(t, performJSON(t, router, http.MethodPost, "/api/v1/run/logs/batches", batch), http.StatusOK) assertStatus(t, performJSON(t, router, http.MethodPost, "/api/v1/run/logs/batches", batch), http.StatusOK)
server := httptest.NewServer(router) assertStatus(t, performJSON(t, router, http.MethodGet, "/api/v1/server-instances/server-1/logs/events?historyLimit=2", nil), http.StatusNotFound)
defer server.Close()
client := server.Client()
client.Timeout = 2 * time.Second
response, err := client.Get(server.URL + "/api/v1/server-instances/server-1/logs/events?historyLimit=2")
if err != nil {
t.Fatalf("open log event stream: %v", err)
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK || !strings.HasPrefix(response.Header.Get("Content-Type"), "text/event-stream") {
t.Fatalf("unexpected event stream response: status=%d content-type=%q", response.StatusCode, response.Header.Get("Content-Type"))
}
body := readSSEUntil(t, response, "event: ready")
for _, fragment := range []string{"event: stream", "event: log", `"streamId":"log-1"`, `"seq":1`, `"seq":2`} {
if !strings.Contains(body, fragment) {
t.Fatalf("expected SSE body to contain %q, got:\n%s", fragment, body)
}
}
} }
func TestLogEventsSSEUsesServerWideNewestHistory(t *testing.T) { func TestLogEventsSSEUsesServerWideNewestHistory(t *testing.T) {
@@ -74,22 +54,7 @@ func TestLogEventsSSEUsesServerWideNewestHistory(t *testing.T) {
assertStatus(t, performJSON(t, router, http.MethodPost, "/api/v1/run/logs/batches", validLogBatchRequestForStream(t, hello.SessionToken, "log-1", "stdout", 1, 2, 0)), http.StatusOK) assertStatus(t, performJSON(t, router, http.MethodPost, "/api/v1/run/logs/batches", validLogBatchRequestForStream(t, hello.SessionToken, "log-1", "stdout", 1, 2, 0)), http.StatusOK)
assertStatus(t, performJSON(t, router, http.MethodPost, "/api/v1/run/logs/batches", validLogBatchRequestForStream(t, hello.SessionToken, "log-2", "stderr", 1, 2, 10)), http.StatusOK) assertStatus(t, performJSON(t, router, http.MethodPost, "/api/v1/run/logs/batches", validLogBatchRequestForStream(t, hello.SessionToken, "log-2", "stderr", 1, 2, 10)), http.StatusOK)
server := httptest.NewServer(router) assertStatus(t, performJSON(t, router, http.MethodGet, "/api/v1/server-instances/server-1/logs/events?historyLimit=2", nil), http.StatusNotFound)
defer server.Close()
client := server.Client()
client.Timeout = 2 * time.Second
response, err := client.Get(server.URL + "/api/v1/server-instances/server-1/logs/events?historyLimit=2")
if err != nil {
t.Fatalf("open log event stream: %v", err)
}
defer response.Body.Close()
body := readSSEUntil(t, response, "event: ready")
if strings.Contains(body, `"streamId":"log-1"`) {
t.Fatalf("expected no history entries from older stream, got:\n%s", body)
}
if strings.Count(body, `"streamId":"log-2"`) != 2 || !strings.Contains(body, `"seq":2`) {
t.Fatalf("expected newest two entries from stream log-2, got:\n%s", body)
}
} }
func TestLogIngestAPIDuplicateAndErrors(t *testing.T) { func TestLogIngestAPIDuplicateAndErrors(t *testing.T) {
@@ -134,20 +99,6 @@ func createLogIngestAPIFixtures(t *testing.T, router http.Handler) dto.RunContro
return hello return hello
} }
func readSSEUntil(t *testing.T, response *http.Response, marker string) string {
t.Helper()
reader := bufio.NewReader(response.Body)
var body strings.Builder
for !strings.Contains(body.String(), marker) {
line, err := reader.ReadString('\n')
if err != nil {
t.Fatalf("read event stream: %v\n%s", err, body.String())
}
body.WriteString(line)
}
return body.String()
}
func validLogBatchRequest(t *testing.T, sessionToken string, firstSeq uint64, lastSeq uint64) dto.LogBatchIngestRequest { func validLogBatchRequest(t *testing.T, sessionToken string, firstSeq uint64, lastSeq uint64) dto.LogBatchIngestRequest {
return validLogBatchRequestForStream(t, sessionToken, "log-1", "stdout", firstSeq, lastSeq, 0) return validLogBatchRequestForStream(t, sessionToken, "log-1", "stdout", firstSeq, lastSeq, 0)
} }
+14 -64
View File
@@ -72,7 +72,6 @@ func (h *coreHandlers) register(mux *http.ServeMux) {
mux.HandleFunc("/api/v1/server-instances/{id}/deployment", h.serverDeployment) mux.HandleFunc("/api/v1/server-instances/{id}/deployment", h.serverDeployment)
mux.HandleFunc("/api/v1/server-instances/{id}/deploy", h.serverInstanceDeploy) mux.HandleFunc("/api/v1/server-instances/{id}/deploy", h.serverInstanceDeploy)
mux.HandleFunc("/api/v1/server-instances/{id}/remote-adapters", h.remoteAdapters) mux.HandleFunc("/api/v1/server-instances/{id}/remote-adapters", h.remoteAdapters)
mux.HandleFunc("/api/v1/server-instances/{id}/rcon/commands", h.sourceRCONCommands)
mux.HandleFunc("/api/v1/server-instances/{id}/run/generate", h.serverRunGenerate) mux.HandleFunc("/api/v1/server-instances/{id}/run/generate", h.serverRunGenerate)
mux.HandleFunc("/api/v1/server-instances/{id}/run/download", h.serverRunDownload) mux.HandleFunc("/api/v1/server-instances/{id}/run/download", h.serverRunDownload)
mux.HandleFunc("/api/v1/server-instances/{id}/run/key/reset", h.serverRunKeyReset) mux.HandleFunc("/api/v1/server-instances/{id}/run/key/reset", h.serverRunKeyReset)
@@ -104,16 +103,19 @@ func (h *coreHandlers) register(mux *http.ServeMux) {
mux.HandleFunc("/api/v1/server-instances/{id}/game-gifts/{catalogId}/revisions", h.serverGameGiftCatalogRevisions) mux.HandleFunc("/api/v1/server-instances/{id}/game-gifts/{catalogId}/revisions", h.serverGameGiftCatalogRevisions)
mux.HandleFunc("/api/v1/server-instances/{id}/game-gift-grants", h.serverGameGiftGrants) mux.HandleFunc("/api/v1/server-instances/{id}/game-gift-grants", h.serverGameGiftGrants)
mux.HandleFunc("/api/v1/server-instances/{id}/game-gift-grants/{grantId}/approve", h.serverGameGiftGrantApprove) mux.HandleFunc("/api/v1/server-instances/{id}/game-gift-grants/{grantId}/approve", h.serverGameGiftGrantApprove)
mux.HandleFunc("/api/v1/server-instances/{id}/scum/players", h.serverSCUMPlayers)
mux.HandleFunc("/api/v1/server-instances/{id}/scum/squads", h.serverSCUMSquads)
mux.HandleFunc("/api/v1/server-instances/{id}/scum/squad-members", h.serverSCUMSquadMembers)
mux.HandleFunc("/api/v1/server-instances/{id}/scum/vehicles", h.serverSCUMVehicles)
mux.HandleFunc("/api/v1/server-instances/{id}/scum/flags", h.serverSCUMFlags)
mux.HandleFunc("/api/v1/server-instances/{id}/scum/positions", h.serverSCUMPositions)
mux.HandleFunc("/api/v1/server-instances/{id}/scum/operations", h.serverSCUMOperations)
mux.HandleFunc("/api/v1/server-instances/{id}/scum/operations/{operationId}/approve", h.serverSCUMOperationApprove)
mux.HandleFunc("/api/v1/server-instances/{id}/scum/workflows", h.serverSCUMWorkflows)
mux.HandleFunc("/api/v1/server-instances/{id}/scum/workflow-steps", h.serverSCUMWorkflowSteps)
mux.HandleFunc("/api/v1/server-instances/{id}/dependencies/check", h.serverDependenciesCheck) mux.HandleFunc("/api/v1/server-instances/{id}/dependencies/check", h.serverDependenciesCheck)
mux.HandleFunc("/api/v1/server-instances/{id}/dependencies/install", h.serverDependenciesInstall) mux.HandleFunc("/api/v1/server-instances/{id}/dependencies/install", h.serverDependenciesInstall)
mux.HandleFunc("/api/v1/server-instances/{id}/dependencies", h.serverDependencies) mux.HandleFunc("/api/v1/server-instances/{id}/dependencies", h.serverDependencies)
mux.HandleFunc("/api/v1/server-instances/{id}/logs/live", h.serverLiveLogs)
mux.HandleFunc("/api/v1/server-instances/{id}/logs/events", h.serverLogEvents)
mux.HandleFunc("/api/v1/server-instances/{id}/logs/backfill", h.serverLogsBackfill)
mux.HandleFunc("/api/v1/server-instances/{id}/files/read-snapshot", h.serverDeclaredFileReadSnapshot)
mux.HandleFunc("/api/v1/server-instances/{id}/config/diff", h.serverInstanceConfigDiff)
mux.HandleFunc("/api/v1/server-instances/{id}/config/approve", h.serverInstanceConfigApprove)
mux.HandleFunc("/api/v1/server-instances/{id}/config", h.serverInstanceConfig)
mux.HandleFunc("/api/v1/server-instances/{id}/administrators/candidates", h.serverAdministratorCandidates) mux.HandleFunc("/api/v1/server-instances/{id}/administrators/candidates", h.serverAdministratorCandidates)
mux.HandleFunc("/api/v1/server-instances/{id}/administrators", h.serverAdministrators) mux.HandleFunc("/api/v1/server-instances/{id}/administrators", h.serverAdministrators)
mux.HandleFunc("/api/v1/server-instances/{id}/administrators/{userId}", h.serverAdministratorDetail) mux.HandleFunc("/api/v1/server-instances/{id}/administrators/{userId}", h.serverAdministratorDetail)
@@ -1407,18 +1409,7 @@ func (h *coreHandlers) serverInstanceMetrics(w http.ResponseWriter, r *http.Requ
writeJSON(w, http.StatusOK, dto.ServerMetricsListFromDomain(metrics)) writeJSON(w, http.StatusOK, dto.ServerMetricsListFromDomain(metrics))
} }
// serverInstanceConfig godoc // serverInstanceConfig is kept as legacy service plumbing but is not registered as a browser product route.
// @Summary Read server configuration
// @Description Returns logical server configuration content for an authorized server instance without exposing run internals.
// @Tags server-instances
// @Produce json
// @Param id path string true "Server instance ID"
// @Success 200 {object} dto.ServerConfigResponse
// @Failure 401 {object} dto.ErrorResponse
// @Failure 403 {object} dto.ErrorResponse
// @Failure 404 {object} dto.ErrorResponse
// @Failure 405 {object} dto.ErrorResponse
// @Router /api/v1/server-instances/{id}/config [get]
func (h *coreHandlers) serverInstanceConfig(w http.ResponseWriter, r *http.Request) { func (h *coreHandlers) serverInstanceConfig(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet { if r.Method != http.MethodGet {
writeMethodNotAllowed(w, http.MethodGet) writeMethodNotAllowed(w, http.MethodGet)
@@ -1432,20 +1423,7 @@ func (h *coreHandlers) serverInstanceConfig(w http.ResponseWriter, r *http.Reque
writeJSON(w, http.StatusOK, dto.ServerConfigFromDomain(config)) writeJSON(w, http.StatusOK, dto.ServerConfigFromDomain(config))
} }
// serverDeclaredFileReadSnapshot godoc // serverDeclaredFileReadSnapshot is kept as legacy service plumbing but is not registered as a browser product route.
// @Summary Read the latest declared file snapshot
// @Description Returns a redacted bounded result only for an authorized plugin-declared logical file key.
// @Tags server-instances
// @Produce json
// @Param id path string true "Server instance ID"
// @Param key query string true "Plugin-declared logical file key"
// @Success 200 {object} dto.DeclaredFileReadSnapshotResponse
// @Failure 400 {object} dto.ErrorResponse
// @Failure 401 {object} dto.ErrorResponse
// @Failure 403 {object} dto.ErrorResponse
// @Failure 404 {object} dto.ErrorResponse
// @Failure 405 {object} dto.ErrorResponse
// @Router /api/v1/server-instances/{id}/files/read-snapshot [get]
func (h *coreHandlers) serverDeclaredFileReadSnapshot(w http.ResponseWriter, r *http.Request) { func (h *coreHandlers) serverDeclaredFileReadSnapshot(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet { if r.Method != http.MethodGet {
writeMethodNotAllowed(w, http.MethodGet) writeMethodNotAllowed(w, http.MethodGet)
@@ -1459,21 +1437,7 @@ func (h *coreHandlers) serverDeclaredFileReadSnapshot(w http.ResponseWriter, r *
writeJSON(w, http.StatusOK, dto.DeclaredFileReadSnapshotFromDomain(snapshot)) writeJSON(w, http.StatusOK, dto.DeclaredFileReadSnapshotFromDomain(snapshot))
} }
// serverInstanceConfigDiff godoc // serverInstanceConfigDiff is kept as legacy service plumbing but is not registered as a browser product route.
// @Summary Preview server config diff
// @Description Compares current logical server config with proposed content without dispatching a write job.
// @Tags server-instances
// @Accept json
// @Produce json
// @Param id path string true "Server instance ID"
// @Param body body dto.ServerConfigDiffPreviewRequest true "Config diff preview request"
// @Success 200 {object} dto.ServerConfigDiffPreviewResponse
// @Failure 400 {object} dto.ErrorResponse
// @Failure 401 {object} dto.ErrorResponse
// @Failure 403 {object} dto.ErrorResponse
// @Failure 404 {object} dto.ErrorResponse
// @Failure 405 {object} dto.ErrorResponse
// @Router /api/v1/server-instances/{id}/config/diff [post]
func (h *coreHandlers) serverInstanceConfigDiff(w http.ResponseWriter, r *http.Request) { func (h *coreHandlers) serverInstanceConfigDiff(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost { if r.Method != http.MethodPost {
writeMethodNotAllowed(w, http.MethodPost) writeMethodNotAllowed(w, http.MethodPost)
@@ -1492,21 +1456,7 @@ func (h *coreHandlers) serverInstanceConfigDiff(w http.ResponseWriter, r *http.R
writeJSON(w, http.StatusOK, dto.ServerConfigDiffPreviewFromDomain(preview)) writeJSON(w, http.StatusOK, dto.ServerConfigDiffPreviewFromDomain(preview))
} }
// serverInstanceConfigApprove godoc // serverInstanceConfigApprove is kept as legacy service plumbing but is not registered as a browser product route.
// @Summary Approve server config write
// @Description Validates a reviewed config diff and queues a scoped config.write run job without exposing host paths or raw credentials.
// @Tags server-instances
// @Accept json
// @Produce json
// @Param id path string true "Server instance ID"
// @Param body body dto.ServerConfigWriteApprovalRequest true "Config write approval request"
// @Success 202 {object} dto.ServerConfigWriteDispatchResponse
// @Failure 400 {object} dto.ErrorResponse
// @Failure 401 {object} dto.ErrorResponse
// @Failure 403 {object} dto.ErrorResponse
// @Failure 404 {object} dto.ErrorResponse
// @Failure 405 {object} dto.ErrorResponse
// @Router /api/v1/server-instances/{id}/config/approve [post]
func (h *coreHandlers) serverInstanceConfigApprove(w http.ResponseWriter, r *http.Request) { func (h *coreHandlers) serverInstanceConfigApprove(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost { if r.Method != http.MethodPost {
writeMethodNotAllowed(w, http.MethodPost) writeMethodNotAllowed(w, http.MethodPost)
+14 -163
View File
@@ -189,18 +189,9 @@ func TestMetricsAndConfigReadAPIAreSafeAndRoleScoped(t *testing.T) {
t.Fatalf("expected no metrics for other user, got %+v", otherMetrics) t.Fatalf("expected no metrics for other user, got %+v", otherMetrics)
} }
configRecorder := requestWithAuth(t, router, http.MethodGet, "/api/v1/server-instances/server-metrics-api/config", "", ownerSession) _ = instance
assertStatus(t, configRecorder, http.StatusOK) assertStatus(t, requestWithAuth(t, router, http.MethodGet, "/api/v1/server-instances/server-metrics-api/config", "", ownerSession), http.StatusNotFound)
config := decodeBody[dto.ServerConfigResponse](t, configRecorder) assertStatus(t, requestWithAuth(t, router, http.MethodGet, "/api/v1/server-instances/server-metrics-api/config", "", otherSession), http.StatusNotFound)
if config.ServerInstanceID != instance.ID || config.ConfigVersion != instance.ConfigVersion || !strings.Contains(config.Content, "server.name=Metrics API Server") {
t.Fatalf("unexpected config response: %+v", config)
}
for _, forbidden := range []string{"/Users/", "unix://", "Bearer ", "sk-", "password="} {
if strings.Contains(configRecorder.Body.String(), forbidden) {
t.Fatalf("config response exposed forbidden fragment %q: %s", forbidden, configRecorder.Body.String())
}
}
assertErrorResponse(t, requestWithAuth(t, router, http.MethodGet, "/api/v1/server-instances/server-metrics-api/config", "", otherSession), http.StatusForbidden, errorCodeForbidden)
} }
func TestConfigWriteAndFileDispatchAPIAreScopedAndSafe(t *testing.T) { func TestConfigWriteAndFileDispatchAPIAreScopedAndSafe(t *testing.T) {
@@ -235,52 +226,9 @@ func TestConfigWriteAndFileDispatchAPIAreScopedAndSafe(t *testing.T) {
State: domain.ServerInstanceStateRunning, State: domain.ServerInstanceStateRunning,
}, ownerSession) }, ownerSession)
putJSONWithAuth[dto.RuntimeBindingResponse](t, router, "/api/v1/server-instances/"+instance.ID+"/runtime-binding", dto.RuntimeBindingUpdateRequest{ProfileKey: "local", Bindings: map[string]string{}}, ownerSession) putJSONWithAuth[dto.RuntimeBindingResponse](t, router, "/api/v1/server-instances/"+instance.ID+"/runtime-binding", dto.RuntimeBindingUpdateRequest{ProfileKey: "local", Bindings: map[string]string{}}, ownerSession)
config := getJSONWithAuth[dto.ServerConfigResponse](t, router, "/api/v1/server-instances/server-config-api/config", ownerSession) assertStatus(t, requestWithAuth(t, router, http.MethodGet, "/api/v1/server-instances/server-config-api/config", "", ownerSession), http.StatusNotFound)
proposed := strings.Replace(config.Content, "state=running", "state=running\nmotd=Approved", 1) assertStatus(t, requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/server-config-api/config/diff", dto.ServerConfigDiffPreviewRequest{ExpectedConfigVersion: instance.ConfigVersion, Key: "server.properties", ProposedContent: "state=running\n"}, ownerSession), http.StatusNotFound)
assertStatus(t, requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/server-config-api/config/approve", dto.ServerConfigWriteApprovalRequest{ExpectedConfigVersion: instance.ConfigVersion, Key: "server.properties", ProposedContent: "state=running\n", IdempotencyKey: "idem-config-api"}, otherSession), http.StatusNotFound)
previewRecorder := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/server-config-api/config/diff", dto.ServerConfigDiffPreviewRequest{
ExpectedConfigVersion: config.ConfigVersion,
Key: config.Key,
ProposedContent: proposed,
}, ownerSession)
assertStatus(t, previewRecorder, http.StatusOK)
preview := decodeBody[dto.ServerConfigDiffPreviewResponse](t, previewRecorder)
if !preview.HasChanges || preview.Source != "platform-review" || preview.ServerInstanceID != instance.ID {
t.Fatalf("unexpected preview: %+v", preview)
}
jobsAfterPreview := getJSONWithAuth[dto.JobListResponse](t, router, "/api/v1/jobs?serverInstanceId=server-config-api", ownerSession)
if jobsAfterPreview.Count != 0 {
t.Fatalf("preview must not create jobs: %+v", jobsAfterPreview)
}
approveRecorder := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/server-config-api/config/approve", dto.ServerConfigWriteApprovalRequest{
ExpectedConfigVersion: config.ConfigVersion,
Key: config.Key,
ProposedContent: proposed,
IdempotencyKey: "idem-config-api",
}, ownerSession)
assertStatus(t, approveRecorder, http.StatusAccepted)
dispatch := decodeBody[dto.ServerConfigWriteDispatchResponse](t, approveRecorder)
if dispatch.Status != "queued" || dispatch.Job.Capability != domain.JobCapabilityConfigWrite || dispatch.Job.TargetKey != config.Key || dispatch.Job.InputRef == "" {
t.Fatalf("unexpected approval dispatch: %+v", dispatch)
}
for _, forbidden := range []string{"/Users/", "unix://", "Bearer ", "sk-", "password="} {
if strings.Contains(approveRecorder.Body.String(), forbidden) {
t.Fatalf("approval response exposed forbidden fragment %q: %s", forbidden, approveRecorder.Body.String())
}
}
assertErrorResponse(t, requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/server-config-api/config/diff", dto.ServerConfigDiffPreviewRequest{
ExpectedConfigVersion: config.ConfigVersion + 1,
Key: config.Key,
ProposedContent: proposed,
}, ownerSession), http.StatusBadRequest, errorCodeValidation)
assertErrorResponse(t, requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/server-config-api/config/approve", dto.ServerConfigWriteApprovalRequest{
ExpectedConfigVersion: config.ConfigVersion,
Key: config.Key,
ProposedContent: proposed,
IdempotencyKey: "idem-forbidden-api",
}, otherSession), http.StatusForbidden, errorCodeForbidden)
assertErrorResponse(t, requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/file-operations/dispatch", dto.FileOperationDispatchRequest{ assertErrorResponse(t, requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/file-operations/dispatch", dto.FileOperationDispatchRequest{
ServerInstanceID: "server-config-api", ServerInstanceID: "server-config-api",
Operation: domain.FileOperationRead, Operation: domain.FileOperationRead,
@@ -304,42 +252,7 @@ func TestConfigWriteAndFileDispatchAPIAreScopedAndSafe(t *testing.T) {
func TestCoreAPIDeclaredFileReadSnapshotRouteIsScopedAndRedacted(t *testing.T) { func TestCoreAPIDeclaredFileReadSnapshotRouteIsScopedAndRedacted(t *testing.T) {
router := newTestRouter() router := newTestRouter()
adminSession := createAdminSession(t, router) adminSession := createAdminSession(t, router)
postJSONWithAuth[dto.UserResponse](t, router, "/api/v1/users", dto.UserCreateRequest{ postJSON[dto.GamePluginResponse](t, router, "/api/v1/game-plugins", validGamePluginRequest())
ID: "user-owner-file-snapshot-api",
DisplayName: "File Snapshot API Owner",
Email: "owner-file-snapshot-api@example.test",
Roles: []string{"server-owner"},
Password: "secret-password",
}, adminSession)
postJSONWithAuth[dto.UserResponse](t, router, "/api/v1/users", dto.UserCreateRequest{
ID: "user-other-file-snapshot-api",
DisplayName: "File Snapshot API Other",
Email: "other-file-snapshot-api@example.test",
Roles: []string{"server-admin"},
Password: "secret-password",
}, adminSession)
ownerSession := postOKJSON[dto.AuthSessionResponse](t, router, "/api/v1/auth/login", dto.LoginRequest{Account: "owner-file-snapshot-api@example.test", Password: "secret-password"}).SessionID
otherSession := postOKJSON[dto.AuthSessionResponse](t, router, "/api/v1/auth/login", dto.LoginRequest{Account: "other-file-snapshot-api@example.test", Password: "secret-password"}).SessionID
pluginRequest := validGamePluginRequest()
pluginRequest.RequiredRunCapabilities = append(pluginRequest.RequiredRunCapabilities, domain.JobCapabilityFilesRead)
pluginRequest.DeclaredPermissions = []string{"server.files.read", "server.files.write"}
pluginRequest.Permissions.Files = true
pluginRequest.FileWorkspace = dto.PluginFileWorkspaceBody{
DefaultDirectoryKey: "scum-config",
Directories: []dto.PluginLogicalDirectoryBody{
{Key: "scum-config", Label: "服务器配置", Scope: "config"},
{Key: "scum-logs", Label: "日志文件", Scope: "logs"},
},
Files: []dto.PluginLogicalFileBody{
{Key: "scum-server-settings", DirectoryKey: "scum-config", Label: "ServerSettings.ini", Kind: "config", Editable: true},
{Key: "scum-chat-log", DirectoryKey: "scum-logs", Label: "Chat.log", Kind: "log", StreamKey: "scum.chat"},
},
ConfigFields: []dto.PluginConfigFieldBody{
{Key: "max-players", FileKey: "scum-server-settings", ConfigKey: "MaxPlayers", Label: "最大玩家数", Description: "玩家上限", Control: "number", Minimum: 1, Maximum: 128, DefaultValue: "128", RestartImpact: "restart-required"},
},
}
postJSON[dto.GamePluginResponse](t, router, "/api/v1/game-plugins", pluginRequest)
postJSON[dto.RunEndpointResponse](t, router, "/api/v1/run/endpoints", validRunEndpointRequest()) postJSON[dto.RunEndpointResponse](t, router, "/api/v1/run/endpoints", validRunEndpointRequest())
instance := postJSONWithAuth[dto.ServerInstanceResponse](t, router, "/api/v1/server-instances", dto.ServerInstanceCreateRequest{ instance := postJSONWithAuth[dto.ServerInstanceResponse](t, router, "/api/v1/server-instances", dto.ServerInstanceCreateRequest{
ID: "server-file-snapshot-api", ID: "server-file-snapshot-api",
@@ -347,57 +260,9 @@ func TestCoreAPIDeclaredFileReadSnapshotRouteIsScopedAndRedacted(t *testing.T) {
RunEndpointID: "run-local", RunEndpointID: "run-local",
Name: "File Snapshot API Server", Name: "File Snapshot API Server",
State: domain.ServerInstanceStateRunning, State: domain.ServerInstanceStateRunning,
}, ownerSession) }, adminSession)
snapshot := getJSONWithAuth[dto.DeclaredFileReadSnapshotResponse](t, router, "/api/v1/server-instances/"+instance.ID+"/files/read-snapshot?key=scum-server-settings", ownerSession) assertStatus(t, requestWithAuth(t, router, http.MethodGet, "/api/v1/server-instances/"+instance.ID+"/files/read-snapshot?key=scum-server-settings", "", adminSession), http.StatusNotFound)
if snapshot.State != "not-read" || snapshot.Content != "" {
t.Fatalf("expected not-read snapshot, got %+v", snapshot)
}
assertErrorResponse(t, requestWithAuth(t, router, http.MethodGet, "/api/v1/server-instances/"+instance.ID+"/files/read-snapshot?key=logs/latest.log", "", ownerSession), http.StatusBadRequest, errorCodeValidation)
assertErrorResponse(t, requestWithAuth(t, router, http.MethodGet, "/api/v1/server-instances/"+instance.ID+"/files/read-snapshot?key=scum-server-settings", "", otherSession), http.StatusForbidden, errorCodeForbidden)
postJSON[dto.JobResponse](t, router, "/api/v1/jobs", dto.JobCreateRequest{
ID: "job-file-snapshot-api-read",
ServerInstanceID: instance.ID,
RunEndpointID: "run-local",
Capability: domain.JobCapabilityFilesRead,
TargetKey: "scum-server-settings",
IdempotencyKey: "idem-file-snapshot-api-read",
})
helloRequest := validRunControlHelloRequest()
helloRequest.CapabilityReport.Capabilities = append(helloRequest.CapabilityReport.Capabilities, domain.JobCapabilityFilesRead)
hello := decodeBody[dto.RunControlHelloResponse](t, performRunControlHello(t, router, helloRequest))
claim := decodeBody[dto.RunJobClaimResponse](t, performJSON(t, router, http.MethodPost, "/api/v1/run/jobs/claim", dto.RunJobClaimRequest{
RunEndpointID: "run-local",
SessionToken: hello.SessionToken,
Capabilities: []string{domain.JobCapabilityFilesRead},
Capacity: dto.RunCapacityResponse{MaxJobs: 4},
}))
if !claim.HasJob || claim.Job.JobID != "job-file-snapshot-api-read" {
t.Fatalf("expected file read job claim, got %+v", claim)
}
content := "ServerName=API\nRconPassword=secret\n"
resultRecorder := performJSON(t, router, http.MethodPost, "/api/v1/run/jobs/result", dto.RunJobResultRequest{
RunEndpointID: "run-local",
SessionToken: hello.SessionToken,
JobID: claim.Job.JobID,
LeaseToken: claim.Job.LeaseToken,
Attempt: claim.Job.Attempt,
State: domain.JobStateSucceeded,
Progress: dto.JobProgressBody{Percent: 100, Message: "file read completed"},
Message: "file read completed",
ExecutionResult: dto.RunJobExecutionResultBody{
Kind: "file.read",
Version: 9,
SizeBytes: int64(len(content)),
Content: content,
},
})
assertStatus(t, resultRecorder, http.StatusOK)
ready := getJSONWithAuth[dto.DeclaredFileReadSnapshotResponse](t, router, "/api/v1/server-instances/"+instance.ID+"/files/read-snapshot?key=scum-server-settings", ownerSession)
if ready.State != "ready" || ready.Version != 9 || !strings.Contains(ready.Content, "ServerName=API") || !strings.Contains(ready.Content, "RconPassword=<redacted>") || strings.Contains(ready.Content, "secret") {
t.Fatalf("expected ready redacted snapshot, got %+v", ready)
}
} }
func TestCoreAPIServerRuntimeDistributionAndJobWorkflows(t *testing.T) { func TestCoreAPIServerRuntimeDistributionAndJobWorkflows(t *testing.T) {
@@ -412,7 +277,7 @@ func TestCoreAPIServerRuntimeDistributionAndJobWorkflows(t *testing.T) {
for _, action := range actions.Actions { for _, action := range actions.Actions {
availability[action.Key] = action.Available availability[action.Key] = action.Available
} }
for _, key := range []string{"generate-run", "push-run-update", "generate-client-manager", "dependencies-check", "dependencies-install", "historical-logs"} { for _, key := range []string{"generate-run", "push-run-update", "generate-client-manager", "dependencies-check", "dependencies-install"} {
if !availability[key] { if !availability[key] {
t.Fatalf("expected action %q available in %+v", key, actions.Actions) t.Fatalf("expected action %q available in %+v", key, actions.Actions)
} }
@@ -464,22 +329,8 @@ func TestCoreAPIServerRuntimeDistributionAndJobWorkflows(t *testing.T) {
unsafeDependency := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/"+serverID+"/dependencies/install", dto.DependencyJobRequest{ProbeKey: "java-runtime", InstallPlanKey: "bash -c whoami", IdempotencyKey: "api-dependency-unsafe"}, adminSession) unsafeDependency := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/"+serverID+"/dependencies/install", dto.DependencyJobRequest{ProbeKey: "java-runtime", InstallPlanKey: "bash -c whoami", IdempotencyKey: "api-dependency-unsafe"}, adminSession)
assertErrorResponse(t, unsafeDependency, http.StatusBadRequest, errorCodeValidation) assertErrorResponse(t, unsafeDependency, http.StatusBadRequest, errorCodeValidation)
backfillRecorder := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/"+serverID+"/logs/backfill", dto.LogBackfillRequest{SourceKey: "latest", CheckpointRef: "input://logs/" + serverID + "/latest/v1", Limit: 500, IdempotencyKey: "api-logs-backfill"}, adminSession) assertStatus(t, requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/"+serverID+"/logs/backfill", dto.LogBackfillRequest{SourceKey: "latest", CheckpointRef: "input://logs/" + serverID + "/latest/v1", Limit: 500, IdempotencyKey: "api-logs-backfill"}, adminSession), http.StatusNotFound)
assertStatus(t, backfillRecorder, http.StatusAccepted) assertStatus(t, requestWithAuth(t, router, http.MethodGet, "/api/v1/server-instances/"+serverID+"/logs/live", "", adminSession), http.StatusNotFound)
backfill := decodeBody[dto.JobResponse](t, backfillRecorder)
if backfill.Capability != domain.JobCapabilityLogsBackfill || backfill.ResultRef != "" || backfill.InputRef == "" {
t.Fatalf("unexpected log backfill job: %+v", backfill)
}
liveLogs := getJSONWithAuth[dto.LogStreamListResponse](t, router, "/api/v1/server-instances/"+serverID+"/logs/live", adminSession)
foundFileLog := false
for _, stream := range liveLogs.Items {
if stream.Source == domain.LogStreamSourceFile && stream.StreamKey == "latest-log" {
foundFileLog = true
}
}
if !foundFileLog {
t.Fatalf("unexpected live logs: %+v", liveLogs)
}
runReset := postOKJSONWithAuth[dto.ComponentKeyResponse](t, router, "/api/v1/server-instances/"+serverID+"/run/key/reset", map[string]string{}, adminSession) runReset := postOKJSONWithAuth[dto.ComponentKeyResponse](t, router, "/api/v1/server-instances/"+serverID+"/run/key/reset", map[string]string{}, adminSession)
if runReset.Generation != 2 || runReset.SecretRef == "" { if runReset.Generation != 2 || runReset.SecretRef == "" {
@@ -490,7 +341,7 @@ func TestCoreAPIServerRuntimeDistributionAndJobWorkflows(t *testing.T) {
t.Fatalf("unexpected client key reset: %+v", clientReset) t.Fatalf("unexpected client key reset: %+v", clientReset)
} }
for _, body := range []string{mustJSON(t, runDistribution), mustJSON(t, clientDistribution), mustJSON(t, runReset), mustJSON(t, clientReset), mustJSON(t, dependencyInstall), mustJSON(t, backfill)} { for _, body := range []string{mustJSON(t, runDistribution), mustJSON(t, clientDistribution), mustJSON(t, runReset), mustJSON(t, clientReset), mustJSON(t, dependencyInstall)} {
for _, forbidden := range []string{"authKey", "enc:v1", "password=", "unix://", "tcp://", "/Users/", "mysql://", "sqlite://"} { for _, forbidden := range []string{"authKey", "enc:v1", "password=", "unix://", "tcp://", "/Users/", "mysql://", "sqlite://"} {
if strings.Contains(body, forbidden) { if strings.Contains(body, forbidden) {
t.Fatalf("runtime API response exposed forbidden fragment %q: %s", forbidden, body) t.Fatalf("runtime API response exposed forbidden fragment %q: %s", forbidden, body)
@@ -503,7 +354,7 @@ func TestCoreAPIServerRuntimeDistributionAndJobWorkflows(t *testing.T) {
for _, audit := range audits.Items { for _, audit := range audits.Items {
auditActions[audit.Action] = true auditActions[audit.Action] = true
} }
for _, action := range []string{"run.generate", "client-manager.build", "dependency.install", "logs.backfill", "runtime-key.reset"} { for _, action := range []string{"run.generate", "client-manager.build", "dependency.install", "runtime-key.reset"} {
if !auditActions[action] { if !auditActions[action] {
t.Fatalf("expected audit action %q in %+v", action, audits.Items) t.Fatalf("expected audit action %q in %+v", action, audits.Items)
} }
+10 -12
View File
@@ -14,10 +14,10 @@ All routes use JSON request and response bodies. Collection routes support `GET`
| Plugin marketplace | `GET /api/v1/plugin-marketplace/plugins` | `GET /api/v1/plugin-marketplace/plugins/{id}`, `POST /api/v1/plugin-marketplace/plugins/{id}/state` | `MarketplacePluginResponse`, `MarketplacePluginListResponse`, `MarketplacePluginStateRequest` | | Plugin marketplace | `GET /api/v1/plugin-marketplace/plugins` | `GET /api/v1/plugin-marketplace/plugins/{id}`, `POST /api/v1/plugin-marketplace/plugins/{id}/state` | `MarketplacePluginResponse`, `MarketplacePluginListResponse`, `MarketplacePluginStateRequest` |
| Plugin bridge | `POST /api/v1/plugin-bridge/authorize`, `POST /api/v1/plugin-bridge/execute` | n/a | `PluginBridgeAuthorizeRequest`, `PluginBridgeAuthorizeResponse`, `PluginBridgeExecuteRequest`, `PluginBridgeExecuteResponse` | | Plugin bridge | `POST /api/v1/plugin-bridge/authorize`, `POST /api/v1/plugin-bridge/execute` | n/a | `PluginBridgeAuthorizeRequest`, `PluginBridgeAuthorizeResponse`, `PluginBridgeExecuteRequest`, `PluginBridgeExecuteResponse` |
| Server instances | `GET /api/v1/server-instances`, `POST /api/v1/server-instances` | `GET /api/v1/server-instances/{id}`, `PUT /api/v1/server-instances/{id}`, `DELETE /api/v1/server-instances/{id}` | `ServerInstanceCreateRequest`, `ServerInstanceUpdateRequest`, `ServerInstanceResponse`, `ServerInstanceListResponse` | | Server instances | `GET /api/v1/server-instances`, `POST /api/v1/server-instances` | `GET /api/v1/server-instances/{id}`, `PUT /api/v1/server-instances/{id}`, `DELETE /api/v1/server-instances/{id}` | `ServerInstanceCreateRequest`, `ServerInstanceUpdateRequest`, `ServerInstanceResponse`, `ServerInstanceListResponse` |
| Server runtime distribution | n/a | `GET /api/v1/server-instances/{id}/runtime/actions`, `POST /api/v1/server-instances/{id}/run/generate`, `POST /api/v1/server-instances/{id}/run/download`, `POST /api/v1/server-instances/{id}/run/key/reset`, `POST /api/v1/server-instances/{id}/run/update`, `GET /api/v1/server-instances/{id}/run/update`, `POST /api/v1/server-instances/{id}/client-managers/generate`, `POST /api/v1/server-instances/{id}/client-managers/download`, `POST /api/v1/server-instances/{id}/client-managers/key/reset`, `GET /api/v1/server-instances/{id}/dependencies`, `POST /api/v1/server-instances/{id}/dependencies/check`, `POST /api/v1/server-instances/{id}/dependencies/install`, `GET /api/v1/server-instances/{id}/logs/live`, `GET /api/v1/server-instances/{id}/logs/events`, `POST /api/v1/server-instances/{id}/logs/backfill` | `ServerRuntimeActionsResponse`, `RunDistributionGenerateRequest`, `RunDistributionResponse`, `RunUpdateRequest`, `RunUpdateJobResponse`/`RunUpdateJobListResponse`, `ClientManagerBuildRequest`, `ClientManagerDistributionResponse`, `ClientManagerDownloadRequest`, `ComponentKeyResetRequest`, `ComponentKeyResponse`, `DependencyCatalogResponse`, `DependencyJobRequest`, `LogBackfillRequest`, `LogStreamEventResponse` | | Server runtime distribution | n/a | `GET /api/v1/server-instances/{id}/runtime/actions`, `POST /api/v1/server-instances/{id}/run/generate`, `POST /api/v1/server-instances/{id}/run/download`, `POST /api/v1/server-instances/{id}/run/key/reset`, `POST /api/v1/server-instances/{id}/run/update`, `GET /api/v1/server-instances/{id}/run/update`, `POST /api/v1/server-instances/{id}/client-managers/generate`, `POST /api/v1/server-instances/{id}/client-managers/download`, `POST /api/v1/server-instances/{id}/client-managers/key/reset`, `GET /api/v1/server-instances/{id}/dependencies`, `POST /api/v1/server-instances/{id}/dependencies/check`, `POST /api/v1/server-instances/{id}/dependencies/install` | `ServerRuntimeActionsResponse`, `RunDistributionGenerateRequest`, `RunDistributionResponse`, `RunUpdateRequest`, `RunUpdateJobResponse`/`RunUpdateJobListResponse`, `ClientManagerBuildRequest`, `ClientManagerDistributionResponse`, `ClientManagerDownloadRequest`, `ComponentKeyResetRequest`, `ComponentKeyResponse`, `DependencyCatalogResponse`, `DependencyJobRequest` |
| Metrics | `GET /api/v1/metrics/platform`, `GET /api/v1/metrics/server-instances` | n/a | `PlatformResourceUsageResponse`, `ServerMetricsResponse`, `ServerMetricsListResponse` | | Metrics | `GET /api/v1/metrics/platform`, `GET /api/v1/metrics/server-instances` | n/a | `PlatformResourceUsageResponse`, `ServerMetricsResponse`, `ServerMetricsListResponse` |
| Server config | n/a | `GET /api/v1/server-instances/{id}/config`, `POST /api/v1/server-instances/{id}/config/diff`, `POST /api/v1/server-instances/{id}/config/approve` | `ServerConfigResponse`, `ServerConfigDiffPreviewRequest`, `ServerConfigDiffPreviewResponse`, `ServerConfigWriteApprovalRequest`, `ServerConfigWriteDispatchResponse` |
| File operations | `POST /api/v1/file-operations/dispatch` | n/a | `FileOperationDispatchRequest`, `FileOperationDispatchResponse` | | File operations | `POST /api/v1/file-operations/dispatch` | n/a | `FileOperationDispatchRequest`, `FileOperationDispatchResponse` |
| SCUM projections and workflows | n/a | `GET /api/v1/server-instances/{id}/scum/players`, `GET .../scum/squads`, `GET .../scum/squad-members`, `GET .../scum/vehicles`, `GET .../scum/flags`, `GET .../scum/positions`, `GET/POST .../scum/operations`, `POST .../scum/operations/{operationId}/approve`, `GET/POST .../scum/workflows`, `GET .../scum/workflow-steps` | `SCUM*Response`, `SCUMOperationRequestBody`, `SCUMWorkflowCreateRequest`, safe operation/workflow summaries |
| Server administrators | `GET /api/v1/server-instances/{id}/administrators/candidates`, `POST /api/v1/server-instances/{id}/administrators` | `DELETE /api/v1/server-instances/{id}/administrators/{userId}` | `ServerMemberRequest`, `ServerMemberResponse`, `ServerMemberListResponse`, `ServerInstanceResponse` | | 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` | | 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` | | Jobs | `GET /api/v1/jobs`, `POST /api/v1/jobs` | `GET /api/v1/jobs/{id}` | `JobCreateRequest`, `JobResponse`, `JobListResponse` |
@@ -70,17 +70,15 @@ Server owner membership actions hide and reject platform administrators. Server
- `GET /api/v1/metrics/platform`: returns bounded platform CPU, memory, disk, source, and timestamp metadata for platform administrators. - `GET /api/v1/metrics/platform`: returns bounded platform CPU, memory, disk, source, and timestamp metadata for platform administrators.
- `GET /api/v1/metrics/server-instances`: returns bounded per-server metrics only for server instances visible to the authenticated user. - `GET /api/v1/metrics/server-instances`: returns bounded per-server metrics only for server instances visible to the authenticated user.
- `GET /api/v1/server-instances/{id}/config`: returns logical server config content, format, key, config version, and update timestamp for an authorized server instance. Server-scoped raw config routes are intentionally not registered as product APIs. AI config assistance returns reviewable typed diff metadata through AI config-diff approval flows rather than raw config text.
Observability and config read responses are read-only. They do not expose host filesystem paths, raw credentials, direct run sockets, storage backend credentials, raw AI provider keys, or run session tokens. Observability responses are read-only. They do not expose host filesystem paths, raw credentials, direct run sockets, storage backend credentials, raw AI provider keys, or run session tokens.
## Implemented Config Write And File Dispatch Actions ## Implemented File Dispatch Actions
- `POST /api/v1/server-instances/{id}/config/diff`: accepts `ServerConfigDiffPreviewRequest`, validates server access, expected config version, logical config key, bounded proposed content, and returns a platform-computed `ServerConfigDiffPreviewResponse` without creating a run job.
- `POST /api/v1/server-instances/{id}/config/approve`: accepts `ServerConfigWriteApprovalRequest`, revalidates the reviewed diff, rejects stale/no-change/unsafe writes, and queues a scoped `config.write` job using `ServerConfigWriteDispatchResponse`.
- `POST /api/v1/file-operations/dispatch`: accepts `FileOperationDispatchRequest`, validates server visibility plus optional plugin permissions, rejects unsafe targets, and queues `files.read` or `files.write` jobs using logical keys and refs. - `POST /api/v1/file-operations/dispatch`: accepts `FileOperationDispatchRequest`, validates server visibility plus optional plugin permissions, rejects unsafe targets, and queues `files.read` or `files.write` jobs using logical keys and refs.
Config write and file dispatch responses expose only logical target keys, scoped input/artifact refs, and bounded job metadata. They do not expose host filesystem paths, raw credentials, direct sockets, run session tokens, raw AI provider keys, or inline large file contents. File dispatch responses expose only logical target keys, scoped input/artifact refs, and bounded job metadata. They do not expose host filesystem paths, raw credentials, direct sockets, run session tokens, raw AI provider keys, or inline large file contents.
## Implemented AI Provider Management Actions ## Implemented AI Provider Management Actions
@@ -159,12 +157,12 @@ Lifecycle workflow responses include accepted status, action, bounded server ins
- `GET /api/v1/server-instances/{id}/dependencies`: returns the target-matched plugin/profile dependency catalog, current safe probe status/evidence, typed plan summaries, and deterministic immutable plan digests. - `GET /api/v1/server-instances/{id}/dependencies`: returns the target-matched plugin/profile dependency catalog, current safe probe status/evidence, typed plan summaries, and deterministic immutable plan digests.
- `POST /api/v1/server-instances/{id}/dependencies/check`: accepts `DependencyJobRequest` and queues a `dependencies.check` run job for a declared logical probe key. - `POST /api/v1/server-instances/{id}/dependencies/check`: accepts `DependencyJobRequest` and queues a `dependencies.check` run job for a declared logical probe key.
- `POST /api/v1/server-instances/{id}/dependencies/install`: accepts `DependencyJobRequest` with an install plan key and the exact catalog `planDigest`; stale/missing digests are denied before job creation. - `POST /api/v1/server-instances/{id}/dependencies/install`: accepts `DependencyJobRequest` with an install plan key and the exact catalog `planDigest`; stale/missing digests are denied before job creation.
- `GET /api/v1/server-instances/{id}/logs/live`: returns safe live log stream metadata for the selected server using `LogStreamListResponse`. Server-scoped raw log routes (`logs/live`, `logs/events`, and `logs/backfill`) are intentionally not registered as product APIs. Internal log ingest and cursor query remain available for run/platform maintenance flows.
- `GET /api/v1/server-instances/{id}/logs/events`: streams selected server log metadata and entries as `text/event-stream`; the optional `historyLimit` query replays recent stored entries before live push events.
- `POST /api/v1/server-instances/{id}/logs/backfill`: accepts `LogBackfillRequest`, queues a `logs.backfill` job with source key, checkpoint ref, limit, and idempotency metadata, and keeps log bodies out of job results.
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. Runtime distribution and client-manager APIs require the current bearer session, server visibility, plugin-declared permissions, complete runtime bindings only for actions that truly depend on external logical bindings, and platform-builder readiness. Run-side lifecycle commands separately require run endpoint capability support and use plugin-declared lifecycle actions without making manual runtime-profile binding a user prerequisite. Responses and audit summaries expose artifact IDs, job IDs, checksums, key generations, fingerprints, status, and redacted `secret://runtime-keys/.../current` refs only. They do not expose raw run keys, client-manager keys, FTP passwords, database DSNs, RCON passwords, host paths, direct sockets, run endpoint private addresses, build workspace paths, or large inline logs.
SCUM product APIs expose only safe local projections, typed operation/workflow requests, approval status, confirmation status, blocker reasons, and audit-safe summaries. They never expose SCUM.db SQL text, DB paths, DSNs, RCON command text, raw protected request payloads, run sockets, host paths, or credentials.
`POST /api/v1/server-instances/workflows/create` requires only the plugin type and server name. A runtime binding may still be maintained internally for advanced logical transports, but browser lifecycle controls must not force operators to choose a runtime profile before start/stop or run-package generation when the plugin deployment/lifecycle declaration is sufficient. Platform builds distributions itself and never needs a registered Run endpoint with `distribution.build` to do so. `POST /api/v1/server-instances/workflows/create` requires only the plugin type and server name. A runtime binding may still be maintained internally for advanced logical transports, but browser lifecycle controls must not force operators to choose a runtime profile before start/stop or run-package generation when the plugin deployment/lifecycle declaration is sufficient. Platform builds distributions itself and never needs a registered Run endpoint with `distribution.build` to do so.
## Private Run Dependency And Update Routes ## Private Run Dependency And Update Routes
@@ -197,7 +195,7 @@ Job ack/progress/result/cancel/reconcile calls remain lightweight and independen
- `POST /api/v1/run/logs/batches`: accept `LogBatchIngestRequest`, validate run session and stream metadata, store contiguous entries, update `LogStream.LatestSeq`, and return `LogBatchIngestResponse` with the acknowledged range. - `POST /api/v1/run/logs/batches`: accept `LogBatchIngestRequest`, validate run session and stream metadata, store contiguous entries, update `LogStream.LatestSeq`, and return `LogBatchIngestResponse` with the acknowledged range.
- `POST /api/v1/log-streams/query`: accept `LogStreamCursorRequest` and return `LogStreamCursorResponse` with bounded ordered entries after a cursor. - `POST /api/v1/log-streams/query`: accept `LogStreamCursorRequest` and return `LogStreamCursorResponse` with bounded ordered entries after a cursor.
- `GET /api/v1/server-instances/{id}/logs/events`: authorize the browser session for the server, replay bounded recent entries, and push newly ingested log entries over SSE without polling log stream queries. Server-scoped SSE log streaming is removed from product routes. `POST /api/v1/log-streams/query` remains the bounded cursor contract for internal maintenance/debug reads.
Log ingest actions carry durable log metadata and bounded entries only: run endpoint ID, session token, stream identity, source, sequence range, compression metadata, checksum, entries, and cursor limits. They do not carry artifact chunks, host paths, raw credentials, direct sockets, or unbounded inline data. Log ingest actions carry durable log metadata and bounded entries only: run endpoint ID, session token, stream identity, source, sequence range, compression metadata, checksum, entries, and cursor limits. They do not carry artifact chunks, host paths, raw credentials, direct sockets, or unbounded inline data.
Log ingest is durable and independently retried. Artifact/file transfer backlog must not prevent log acknowledgement, duplicate acknowledgement, cursor state updates, or spool cleanup. Log ingest is durable and independently retried. Artifact/file transfer backlog must not prevent log acknowledgement, duplicate acknowledgement, cursor state updates, or spool cleanup.
+201
View File
@@ -0,0 +1,201 @@
package api
import (
"net/http"
"strconv"
"browser.local/platform/domain"
"browser.local/platform/dto"
)
func (h *coreHandlers) serverSCUMPlayers(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeMethodNotAllowed(w, http.MethodGet)
return
}
items, err := h.core.ListSCUMPlayerLiveStatesForSession(bearerToken(r), scumProjectionFilterFromRequest(r, r.PathValue("id")))
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, dto.SCUMPlayerLiveStatesFromDomain(items))
}
func (h *coreHandlers) serverSCUMSquads(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeMethodNotAllowed(w, http.MethodGet)
return
}
items, err := h.core.ListSCUMSquadsForSession(bearerToken(r), scumProjectionFilterFromRequest(r, r.PathValue("id")))
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, dto.SCUMSquadsFromDomain(items))
}
func (h *coreHandlers) serverSCUMSquadMembers(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeMethodNotAllowed(w, http.MethodGet)
return
}
items, err := h.core.ListSCUMSquadMembersForSession(bearerToken(r), scumProjectionFilterFromRequest(r, r.PathValue("id")))
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, dto.SCUMSquadMembersFromDomain(items))
}
func (h *coreHandlers) serverSCUMVehicles(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeMethodNotAllowed(w, http.MethodGet)
return
}
items, err := h.core.ListSCUMVehiclesForSession(bearerToken(r), scumProjectionFilterFromRequest(r, r.PathValue("id")))
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, dto.SCUMVehiclesFromDomain(items))
}
func (h *coreHandlers) serverSCUMFlags(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeMethodNotAllowed(w, http.MethodGet)
return
}
items, err := h.core.ListSCUMFlagsForSession(bearerToken(r), scumProjectionFilterFromRequest(r, r.PathValue("id")))
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, dto.SCUMFlagsFromDomain(items))
}
func (h *coreHandlers) serverSCUMPositions(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeMethodNotAllowed(w, http.MethodGet)
return
}
items, err := h.core.ListSCUMCurrentPositionsForSession(bearerToken(r), scumProjectionFilterFromRequest(r, r.PathValue("id")))
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, dto.SCUMCurrentPositionsFromDomain(items))
}
func (h *coreHandlers) serverSCUMOperations(w http.ResponseWriter, r *http.Request) {
serverID := r.PathValue("id")
switch r.Method {
case http.MethodGet:
items, err := h.core.ListSCUMOperationsForSession(bearerToken(r), scumOperationFilterFromRequest(r, serverID))
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, dto.SCUMOperationsFromDomain(items))
case http.MethodPost:
request, err := decodeJSON[dto.SCUMOperationRequestBody](r)
if err != nil {
writeDecodeError(w, err)
return
}
operation, err := h.core.RequestSCUMOperationForSession(bearerToken(r), serverID, dto.SCUMOperationRequestBodyToDomain(request))
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusCreated, dto.SCUMOperationFromDomain(operation))
default:
writeMethodNotAllowed(w, "GET, POST")
}
}
func (h *coreHandlers) serverSCUMOperationApprove(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeMethodNotAllowed(w, http.MethodPost)
return
}
operation, err := h.core.ApproveSCUMOperationForSession(bearerToken(r), r.PathValue("operationId"))
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, dto.SCUMOperationFromDomain(operation))
}
func (h *coreHandlers) serverSCUMWorkflows(w http.ResponseWriter, r *http.Request) {
serverID := r.PathValue("id")
switch r.Method {
case http.MethodGet:
items, err := h.core.ListSCUMWorkflowsForSession(bearerToken(r), scumWorkflowFilterFromRequest(r, serverID))
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, dto.SCUMWorkflowsFromDomain(items))
case http.MethodPost:
request, err := decodeJSON[dto.SCUMWorkflowCreateRequest](r)
if err != nil {
writeDecodeError(w, err)
return
}
workflow, err := h.core.CreateSCUMWorkflowForSession(bearerToken(r), serverID, dto.SCUMWorkflowCreateRequestToDomain(request))
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusCreated, dto.SCUMWorkflowFromDomain(workflow))
default:
writeMethodNotAllowed(w, "GET, POST")
}
}
func (h *coreHandlers) serverSCUMWorkflowSteps(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeMethodNotAllowed(w, http.MethodGet)
return
}
items, err := h.core.ListSCUMWorkflowStepsForSession(bearerToken(r), scumWorkflowStepFilterFromRequest(r, r.PathValue("id")))
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, dto.SCUMWorkflowStepsFromDomain(items))
}
func scumProjectionFilterFromRequest(r *http.Request, serverID string) domain.SCUMProjectionFilter {
query := r.URL.Query()
return domain.SCUMProjectionFilter{ServerInstanceID: serverID, GamePlayerID: query.Get("gamePlayerId"), GamePlayerRecordID: query.Get("gamePlayerRecordId"), UserProfileID: query.Get("userProfileId"), SteamID: query.Get("steamId"), SquadID: query.Get("squadId"), VehicleID: query.Get("vehicleId"), FlagID: query.Get("flagId"), SubjectType: domain.SCUMProjectionSubject(query.Get("subjectType")), QueryKey: query.Get("queryKey"), Freshness: domain.SCUMProjectionFreshness(query.Get("freshness")), Search: query.Get("search"), Limit: boundedQueryLimit(query.Get("limit"), 200)}
}
func scumOperationFilterFromRequest(r *http.Request, serverID string) domain.SCUMOperationRequestFilter {
query := r.URL.Query()
return domain.SCUMOperationRequestFilter{ServerInstanceID: serverID, TemplateKey: query.Get("templateKey"), PlayerID: query.Get("playerId"), RequesterID: query.Get("requesterId"), Status: domain.SCUMWorkflowStepStatus(query.Get("status")), IdempotencyKey: query.Get("idempotencyKey"), Limit: boundedQueryLimit(query.Get("limit"), 100)}
}
func scumWorkflowFilterFromRequest(r *http.Request, serverID string) domain.SCUMWorkflowInstanceFilter {
query := r.URL.Query()
return domain.SCUMWorkflowInstanceFilter{ServerInstanceID: serverID, TemplateKey: query.Get("templateKey"), RequestedBy: query.Get("requestedBy"), Status: domain.SCUMWorkflowStatus(query.Get("status")), IdempotencyKey: query.Get("idempotencyKey"), Limit: boundedQueryLimit(query.Get("limit"), 100)}
}
func scumWorkflowStepFilterFromRequest(r *http.Request, serverID string) domain.SCUMWorkflowStepFilter {
query := r.URL.Query()
return domain.SCUMWorkflowStepFilter{ServerInstanceID: serverID, WorkflowID: query.Get("workflowId"), StepKey: query.Get("stepKey"), Status: domain.SCUMWorkflowStepStatus(query.Get("status")), Limit: boundedQueryLimit(query.Get("limit"), 200)}
}
func boundedQueryLimit(raw string, fallback int) int {
if raw == "" {
return fallback
}
parsed, err := strconv.Atoi(raw)
if err != nil || parsed < 1 {
return fallback
}
if parsed > 500 {
return 500
}
return parsed
}
+149
View File
@@ -0,0 +1,149 @@
package api
import (
"encoding/json"
"net/http"
"strings"
"testing"
"time"
"browser.local/platform/domain"
"browser.local/platform/dto"
"browser.local/platform/repo"
"browser.local/platform/service"
)
func TestSCUMProjectionOperationAndWorkflowAPIsExposeSafeTypedSurfaces(t *testing.T) {
store := repo.NewMemoryStore()
core := service.NewCoreService(store)
if _, err := core.CreateUser(domain.User{ID: "scum-api-owner", DisplayName: "SCUM API Owner", Email: "scum-api-owner@example.test", Status: domain.UserStatusActive, Roles: []string{"server-owner"}, PasswordHash: "secret-password"}); err != nil {
t.Fatalf("create owner: %v", err)
}
plugin := validGamePluginRequest().ToDomain()
plugin.DeclaredPermissions = append(plugin.DeclaredPermissions, "server.game-client.command", "server.game-client.read")
plugin.RequiredRunCapabilities = append(plugin.RequiredRunCapabilities, domain.JobCapabilityRemoteRunRCONCommand, domain.JobCapabilityRemoteRunProtectedRCON)
plugin.RuntimeProfiles.TransportProfiles = []domain.RuntimeTransportProfile{{Key: "scum-management", Kind: "rcon", TargetKey: "scum-management", Capabilities: []string{domain.JobCapabilityRemoteRunRCONCommand, domain.JobCapabilityRemoteRunProtectedRCON}}}
plugin.GameClientBridge.OperationTemplates = []domain.GameClientBridgeOperationTemplateDeclaration{{Key: "player.fame.set", Title: "Set fame", Permission: "server.game-client.command", ApprovalLevel: domain.GameClientBridgeApprovalLevelOperator, Kind: domain.GameClientBridgeOperationKindRCON, TransportKey: "scum-management", TargetKey: "scum-management", PayloadSchemaRef: "schemas/bridge/player-fame-set.payload.schema.json", TimeoutSeconds: 60, MaxPayloadBytes: 2048, Safety: domain.GameClientBridgeOperationSafety{RequiresApproval: true, RequiresConfirmation: true}}}
plugin.GameClientBridge.Retention = domain.GameClientBridgeRetention{KeepForSeconds: 86400, MaxRecords: 1000}
if _, err := core.CreateGamePlugin(plugin); err != nil {
t.Fatalf("create plugin: %v", err)
}
endpoint := validRunEndpointRequest().ToDomain()
endpoint.Capabilities = append(endpoint.Capabilities, domain.JobCapabilityRemoteRunDBSQLiteQuery, domain.JobCapabilityRemoteRunLogsTransfer, domain.JobCapabilityRemoteRunRCONCommand, domain.JobCapabilityRemoteRunProtectedRCON)
endpoint.LastHeartbeatAt = time.Now().UTC()
if _, err := core.CreateRunEndpoint(endpoint); err != nil {
t.Fatalf("create endpoint: %v", err)
}
if _, err := core.CreateServerInstance(domain.ServerInstance{ID: "server-scum-api", PluginID: plugin.ID, RunEndpointID: endpoint.ID, Name: "SCUM API", OwnerUserID: "scum-api-owner", State: domain.ServerInstanceStateRunning, ConfigVersion: 1}); err != nil {
t.Fatalf("create server: %v", err)
}
if _, err := core.ApplySCUMObservationResult(domain.SCUMObservationResult{ServerInstanceID: "server-scum-api", PluginID: plugin.ID, Source: "run.sqlite.read", QueryKey: "scum.player.profile", Sequence: 1, Checksum: "sha256:api-profile", ObservedAt: time.Now().UTC(), Rows: []map[string]any{{"gamePlayerId": "steam-api", "displayName": "API Player", "normalBalance": 25, "x": 1, "y": 2, "z": 3}}}); err != nil {
t.Fatalf("seed projection: %v", err)
}
auth, err := core.LoginUser(domain.UserLogin{Account: "scum-api-owner@example.test", Password: "secret-password"})
if err != nil {
t.Fatalf("login: %v", err)
}
router := NewAuthorizedRouterWithCore(core)
players := getJSONWithAuth[dto.SCUMPlayerLiveStateListResponse](t, router, "/api/v1/server-instances/server-scum-api/scum/players", auth.SessionID)
if players.Count != 1 || players.Items[0].GamePlayerID != "steam-api" || players.Items[0].Position.X != 1 {
t.Fatalf("unexpected SCUM players response: %+v", players)
}
operation := postJSONWithAuth[dto.SCUMOperationResponse](t, router, "/api/v1/server-instances/server-scum-api/scum/operations", dto.SCUMOperationRequestBody{TemplateKey: "player.fame.set", PlayerID: "steam-api", Payload: map[string]any{"fame": 12}, Reason: "api typed op", IdempotencyKey: "api-fame-1"}, auth.SessionID)
if operation.Status != string(domain.SCUMWorkflowStepWaiting) || operation.TemplateKey != "player.fame.set" {
t.Fatalf("unexpected SCUM operation response: %+v", operation)
}
operations := getJSONWithAuth[dto.SCUMOperationListResponse](t, router, "/api/v1/server-instances/server-scum-api/scum/operations", auth.SessionID)
if operations.Count != 1 || operations.Items[0].ID != operation.ID {
t.Fatalf("unexpected SCUM operation list: %+v", operations)
}
workflow := postJSONWithAuth[dto.SCUMWorkflowResponse](t, router, "/api/v1/server-instances/server-scum-api/scum/workflows", dto.SCUMWorkflowCreateRequest{TemplateKey: "scum.world-refresh", IdempotencyKey: "api-world-1"}, auth.SessionID)
if workflow.Status != string(domain.SCUMWorkflowQueued) || workflow.TemplateKey != "scum.world-refresh" {
t.Fatalf("unexpected SCUM workflow response: %+v", workflow)
}
steps := getJSONWithAuth[dto.SCUMWorkflowStepListResponse](t, router, "/api/v1/server-instances/server-scum-api/scum/workflow-steps?workflowId="+workflow.ID, auth.SessionID)
if steps.Count == 0 {
t.Fatalf("expected workflow steps: %+v", steps)
}
body, err := json.Marshal([]any{players, operation, operations, workflow, steps})
if err != nil {
t.Fatalf("marshal responses: %v", err)
}
for _, forbidden := range []string{"#SetFamePoints", "requestText", "SELECT ", "UPDATE ", "SCUM.db", "password", "run token", "hostPath"} {
if strings.Contains(strings.ToUpper(string(body)), strings.ToUpper(forbidden)) {
t.Fatalf("SCUM safe API leaked %q: %s", forbidden, body)
}
}
for _, legacy := range []struct{ method, path string }{
{http.MethodPost, "/api/v1/server-instances/server-scum-api/rcon/commands"},
{http.MethodGet, "/api/v1/server-instances/server-scum-api/logs/live"},
{http.MethodGet, "/api/v1/server-instances/server-scum-api/logs/events"},
{http.MethodPost, "/api/v1/server-instances/server-scum-api/logs/backfill"},
{http.MethodGet, "/api/v1/server-instances/server-scum-api/files/read-snapshot?key=scum-server-log"},
{http.MethodGet, "/api/v1/server-instances/server-scum-api/config"},
{http.MethodPost, "/api/v1/server-instances/server-scum-api/config/diff"},
{http.MethodPost, "/api/v1/server-instances/server-scum-api/config/approve"},
} {
recorder := requestWithAuth(t, router, legacy.method, legacy.path, `{}`, auth.SessionID)
assertStatus(t, recorder, http.StatusNotFound)
}
}
func TestSCUMAPIsEnforceServerAuthorization(t *testing.T) {
store := repo.NewMemoryStore()
core := service.NewCoreService(store)
if _, err := core.CreateUser(domain.User{ID: "scum-api-owner", DisplayName: "SCUM API Owner", Email: "scum-api-owner-authz@example.test", Status: domain.UserStatusActive, Roles: []string{"server-owner"}, PasswordHash: "secret-password"}); err != nil {
t.Fatal(err)
}
if _, err := core.CreateUser(domain.User{ID: "scum-api-other", DisplayName: "SCUM API Other", Email: "scum-api-other-authz@example.test", Status: domain.UserStatusActive, Roles: []string{"server-owner"}, PasswordHash: "secret-password"}); err != nil {
t.Fatal(err)
}
if _, err := core.CreateGamePlugin(validGamePluginRequest().ToDomain()); err != nil {
t.Fatal(err)
}
if _, err := core.CreateRunEndpoint(validRunEndpointRequest().ToDomain()); err != nil {
t.Fatal(err)
}
if _, err := core.CreateServerInstance(domain.ServerInstance{ID: "server-scum-authz", PluginID: "server.scum", RunEndpointID: "run-local", Name: "SCUM Authz", OwnerUserID: "scum-api-owner", State: domain.ServerInstanceStateRunning}); err != nil {
t.Fatal(err)
}
auth, err := core.LoginUser(domain.UserLogin{Account: "scum-api-other-authz@example.test", Password: "secret-password"})
if err != nil {
t.Fatal(err)
}
router := NewAuthorizedRouterWithCore(core)
assertErrorResponse(t, requestWithAuth(t, router, http.MethodGet, "/api/v1/server-instances/server-scum-authz/scum/players", "", auth.SessionID), http.StatusForbidden, errorCodeForbidden)
}
func TestSCUMAPIsRequirePlatformAdminForDBMutationApproval(t *testing.T) {
store := repo.NewMemoryStore()
core := service.NewCoreService(store)
if _, err := core.CreateUser(domain.User{ID: "scum-api-owner", DisplayName: "SCUM API Owner", Email: "scum-api-owner-mutation@example.test", Status: domain.UserStatusActive, Roles: []string{"server-owner"}, PasswordHash: "secret-password"}); err != nil {
t.Fatal(err)
}
plugin := validGamePluginRequest().ToDomain()
plugin.DeclaredPermissions = append(plugin.DeclaredPermissions, "server.game-client.read", "server.game-client.maintenance")
plugin.RequiredRunCapabilities = append(plugin.RequiredRunCapabilities, domain.JobCapabilityRemoteRunDBSQLiteQuery, domain.JobCapabilityRemoteRunProtectedSQL)
plugin.RuntimeProfiles.TransportProfiles = []domain.RuntimeTransportProfile{{Key: "scum-database", Kind: "sqlite", TargetKey: "scum-database", Capabilities: []string{domain.JobCapabilityRemoteRunDBSQLiteQuery, domain.JobCapabilityRemoteRunProtectedSQL}}}
plugin.GameClientBridge.QueryTemplates = []domain.GameClientBridgeQueryTemplateDeclaration{{Key: "scum.player.profile", Title: "Read player profile", Permission: "server.game-client.read", Engine: "sqlite", TransportKey: "scum-database", TargetKey: "scum-database", ParameterSchemaRef: "schemas/bridge/queries/scum-player-profile.parameters.schema.json", ResultSchemaRef: "schemas/bridge/queries/scum-player-profile.result.schema.json", MaxRows: 10, TimeoutSeconds: 15}}
plugin.GameClientBridge.OperationTemplates = []domain.GameClientBridgeOperationTemplateDeclaration{{Key: "player.attribute.855.set", Title: "Set attribute 855", Permission: "server.game-client.maintenance", ApprovalLevel: domain.GameClientBridgeApprovalLevelPlatformAdmin, Kind: domain.GameClientBridgeOperationKindSQLiteMutation, TransportKey: "scum-database", TargetKey: "scum-database", PayloadSchemaRef: "schemas/bridge/player-attribute-855-set.payload.schema.json", ResultSchemaRef: "schemas/bridge/player-attribute-855-set.result.schema.json", ConfirmationSchemaRef: "schemas/bridge/player-attribute-855-set.confirmation.schema.json", TimeoutSeconds: 120, MaxPayloadBytes: 4096, MaxRowsAffected: 1, Mutation: domain.GameClientBridgeOperationMutationDeclaration{FieldKey: "855", TableKey: "prisoner", IdentityKey: "user_profile_id", ValueKey: "value", ConfirmationQueryKey: "scum.player.profile", AllowedValueType: "integer", MinValue: 0, MaxValue: 100000}, Safety: domain.GameClientBridgeOperationSafety{RequiresApproval: true, RequiresOfflinePlayer: true, RequiresMaintenanceWindow: true, RequiresBeforeValue: true, RequiresConfirmation: true, BackupRequired: true}}}
plugin.GameClientBridge.Retention = domain.GameClientBridgeRetention{KeepForSeconds: 86400, MaxRecords: 1000}
if _, err := core.CreateGamePlugin(plugin); err != nil {
t.Fatal(err)
}
endpoint := validRunEndpointRequest().ToDomain()
endpoint.Capabilities = append(endpoint.Capabilities, domain.JobCapabilityRemoteRunDBSQLiteQuery, domain.JobCapabilityRemoteRunProtectedSQL)
if _, err := core.CreateRunEndpoint(endpoint); err != nil {
t.Fatal(err)
}
if _, err := core.CreateServerInstance(domain.ServerInstance{ID: "server-scum-mutation-authz", PluginID: plugin.ID, RunEndpointID: endpoint.ID, Name: "SCUM Mutation Authz", OwnerUserID: "scum-api-owner", State: domain.ServerInstanceStateStopped}); err != nil {
t.Fatal(err)
}
auth, err := core.LoginUser(domain.UserLogin{Account: "scum-api-owner-mutation@example.test", Password: "secret-password"})
if err != nil {
t.Fatal(err)
}
router := NewAuthorizedRouterWithCore(core)
operation := postJSONWithAuth[dto.SCUMOperationResponse](t, router, "/api/v1/server-instances/server-scum-mutation-authz/scum/operations", dto.SCUMOperationRequestBody{TemplateKey: "player.attribute.855.set", PlayerID: "steam-api", Payload: map[string]any{"fieldKey": "855", "before": 10, "after": 12, "safetyWindow": "maintenance-2026-08-10", "backupRef": "backup://scum/1"}, Reason: "api typed db op", IdempotencyKey: "api-855-1"}, auth.SessionID)
assertErrorResponse(t, requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/server-scum-mutation-authz/scum/operations/"+operation.ID+"/approve", map[string]string{}, auth.SessionID), http.StatusForbidden, errorCodeForbidden)
}
+1 -14
View File
@@ -6,20 +6,7 @@ import (
"browser.local/platform/dto" "browser.local/platform/dto"
) )
// sourceRCONCommands godoc // sourceRCONCommands is kept as legacy service plumbing but is not registered as a browser product route.
// @Summary Queue a direct SCUM Source RCON chat or command
// @Description Queues one non-retryable command without persisting the raw command, RCON password, or response body.
// @Tags scum-rcon
// @Accept json
// @Produce json
// @Param id path string true "Server instance ID"
// @Param body body dto.SourceRCONCommandRequestBody true "SCUM Source RCON chat or command request"
// @Success 202 {object} dto.SourceRCONCommandResponse
// @Failure 400 {object} dto.ErrorResponse
// @Failure 401 {object} dto.ErrorResponse
// @Failure 403 {object} dto.ErrorResponse
// @Failure 405 {object} dto.ErrorResponse
// @Router /api/v1/server-instances/{id}/rcon/commands [post]
func (h *coreHandlers) sourceRCONCommands(w http.ResponseWriter, r *http.Request) { func (h *coreHandlers) sourceRCONCommands(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost { if r.Method != http.MethodPost {
writeMethodNotAllowed(w, http.MethodPost) writeMethodNotAllowed(w, http.MethodPost)
+49
View File
@@ -73,11 +73,57 @@ type GameClientBridgeQueryTemplateDeclaration struct {
TimeoutSeconds int TimeoutSeconds int
} }
type GameClientBridgeOperationKind string
const (
GameClientBridgeOperationKindRCON GameClientBridgeOperationKind = "rcon"
GameClientBridgeOperationKindSQLiteMutation GameClientBridgeOperationKind = "sqlite-mutation"
)
type GameClientBridgeOperationTemplateDeclaration struct {
Key string
Title string
Permission string
ApprovalLevel GameClientBridgeApprovalLevel
Kind GameClientBridgeOperationKind
TransportKey string
TargetKey string
PayloadSchemaRef string
ResultSchemaRef string
ConfirmationSchemaRef string
TimeoutSeconds int
MaxPayloadBytes int
MaxRowsAffected int
Mutation GameClientBridgeOperationMutationDeclaration
Safety GameClientBridgeOperationSafety
}
type GameClientBridgeOperationMutationDeclaration struct {
FieldKey string
TableKey string
IdentityKey string
ValueKey string
ConfirmationQueryKey string
AllowedValueType string
MinValue float64
MaxValue float64
}
type GameClientBridgeOperationSafety struct {
RequiresApproval bool
RequiresOfflinePlayer bool
RequiresMaintenanceWindow bool
RequiresBeforeValue bool
RequiresConfirmation bool
BackupRequired bool
}
type GameClientBridgePageContract struct { type GameClientBridgePageContract struct {
PageKey string PageKey string
CommandTypes []string CommandTypes []string
SnapshotTypes []string SnapshotTypes []string
QueryTemplateKeys []string QueryTemplateKeys []string
OperationKeys []string
FeatureKeys []string FeatureKeys []string
} }
@@ -109,6 +155,7 @@ type GameClientBridgeManifest struct {
Commands []GameClientBridgeCommandDeclaration Commands []GameClientBridgeCommandDeclaration
Snapshots []GameClientBridgeSnapshotDeclaration Snapshots []GameClientBridgeSnapshotDeclaration
QueryTemplates []GameClientBridgeQueryTemplateDeclaration QueryTemplates []GameClientBridgeQueryTemplateDeclaration
OperationTemplates []GameClientBridgeOperationTemplateDeclaration
Retention GameClientBridgeRetention Retention GameClientBridgeRetention
Pages []GameClientBridgePageContract Pages []GameClientBridgePageContract
Features []GameClientBridgeFeatureDeclaration Features []GameClientBridgeFeatureDeclaration
@@ -406,12 +453,14 @@ func CopyGameClientBridgeManifest(value GameClientBridgeManifest) GameClientBrid
} }
value.Snapshots = append([]GameClientBridgeSnapshotDeclaration(nil), value.Snapshots...) value.Snapshots = append([]GameClientBridgeSnapshotDeclaration(nil), value.Snapshots...)
value.QueryTemplates = append([]GameClientBridgeQueryTemplateDeclaration(nil), value.QueryTemplates...) value.QueryTemplates = append([]GameClientBridgeQueryTemplateDeclaration(nil), value.QueryTemplates...)
value.OperationTemplates = append([]GameClientBridgeOperationTemplateDeclaration(nil), value.OperationTemplates...)
value.Pages = append([]GameClientBridgePageContract(nil), value.Pages...) value.Pages = append([]GameClientBridgePageContract(nil), value.Pages...)
value.Features = append([]GameClientBridgeFeatureDeclaration(nil), value.Features...) value.Features = append([]GameClientBridgeFeatureDeclaration(nil), value.Features...)
for index := range value.Pages { for index := range value.Pages {
value.Pages[index].CommandTypes = CopyStringSlice(value.Pages[index].CommandTypes) value.Pages[index].CommandTypes = CopyStringSlice(value.Pages[index].CommandTypes)
value.Pages[index].SnapshotTypes = CopyStringSlice(value.Pages[index].SnapshotTypes) value.Pages[index].SnapshotTypes = CopyStringSlice(value.Pages[index].SnapshotTypes)
value.Pages[index].QueryTemplateKeys = CopyStringSlice(value.Pages[index].QueryTemplateKeys) value.Pages[index].QueryTemplateKeys = CopyStringSlice(value.Pages[index].QueryTemplateKeys)
value.Pages[index].OperationKeys = CopyStringSlice(value.Pages[index].OperationKeys)
value.Pages[index].FeatureKeys = CopyStringSlice(value.Pages[index].FeatureKeys) value.Pages[index].FeatureKeys = CopyStringSlice(value.Pages[index].FeatureKeys)
} }
for index := range value.Features { for index := range value.Features {
+5 -2
View File
@@ -5,12 +5,15 @@ import "testing"
func TestCopyGameClientBridgeDeclarationsCopiesQueryTemplateSlices(t *testing.T) { func TestCopyGameClientBridgeDeclarationsCopiesQueryTemplateSlices(t *testing.T) {
manifest := GameClientBridgeManifest{ manifest := GameClientBridgeManifest{
QueryTemplates: []GameClientBridgeQueryTemplateDeclaration{{Key: "player.lookup"}}, QueryTemplates: []GameClientBridgeQueryTemplateDeclaration{{Key: "player.lookup"}},
Pages: []GameClientBridgePageContract{{PageKey: "players", QueryTemplateKeys: []string{"player.lookup"}}}, OperationTemplates: []GameClientBridgeOperationTemplateDeclaration{{Key: "player.fame.set"}},
Pages: []GameClientBridgePageContract{{PageKey: "players", QueryTemplateKeys: []string{"player.lookup"}, OperationKeys: []string{"player.fame.set"}}},
} }
manifestCopy := CopyGameClientBridgeManifest(manifest) manifestCopy := CopyGameClientBridgeManifest(manifest)
manifestCopy.QueryTemplates[0].Key = "mutated" manifestCopy.QueryTemplates[0].Key = "mutated"
manifestCopy.OperationTemplates[0].Key = "mutated"
manifestCopy.Pages[0].QueryTemplateKeys[0] = "mutated" manifestCopy.Pages[0].QueryTemplateKeys[0] = "mutated"
if manifest.QueryTemplates[0].Key != "player.lookup" || manifest.Pages[0].QueryTemplateKeys[0] != "player.lookup" { manifestCopy.Pages[0].OperationKeys[0] = "mutated"
if manifest.QueryTemplates[0].Key != "player.lookup" || manifest.OperationTemplates[0].Key != "player.fame.set" || manifest.Pages[0].QueryTemplateKeys[0] != "player.lookup" || manifest.Pages[0].OperationKeys[0] != "player.fame.set" {
t.Fatalf("manifest copy aliases query template declarations: source=%#v copy=%#v", manifest, manifestCopy) t.Fatalf("manifest copy aliases query template declarations: source=%#v copy=%#v", manifest, manifestCopy)
} }
+185
View File
@@ -0,0 +1,185 @@
package domain
import "time"
type SCUMProjectionSubject string
const (
SCUMProjectionSubjectPlayer SCUMProjectionSubject = "player"
SCUMProjectionSubjectLiveState SCUMProjectionSubject = "player-live-state"
SCUMProjectionSubjectSquad SCUMProjectionSubject = "squad"
SCUMProjectionSubjectMember SCUMProjectionSubject = "squad-member"
SCUMProjectionSubjectVehicle SCUMProjectionSubject = "vehicle"
SCUMProjectionSubjectFlag SCUMProjectionSubject = "flag"
SCUMProjectionSubjectPosition SCUMProjectionSubject = "position"
)
type SCUMProjectionFilter struct {
ServerInstanceID string
GamePlayerID string
GamePlayerRecordID string
UserProfileID string
SteamID string
SquadID string
VehicleID string
FlagID string
SubjectType SCUMProjectionSubject
QueryKey string
Freshness SCUMProjectionFreshness
Search string
Limit int
}
type SCUMPlayerLiveState struct {
ID string
ServerInstanceID string
GamePlayerRecordID string
GamePlayerID string
UserProfileID string
SteamID string
DisplayName string
SquadID string
SquadName string
Online bool
FamePoints float64
NormalBalance float64
GoldBalance float64
LastLoginAt time.Time
LastLogoutAt time.Time
LastSaveTime time.Time
Position SCUMCurrentPosition
UnknownFields map[string]any
Freshness SCUMProjectionFreshnessState
CreatedAt time.Time
UpdatedAt time.Time
}
type SCUMSquad struct {
ID string
ServerInstanceID string
SquadID string
Name string
LeaderProfileID string
LeaderPlayerID string
MemberCount int
Score float64
UnknownFields map[string]any
Freshness SCUMProjectionFreshnessState
CreatedAt time.Time
UpdatedAt time.Time
}
type SCUMSquadMember struct {
ID string
ServerInstanceID string
SquadID string
UserProfileID string
GamePlayerRecordID string
GamePlayerID string
SteamID string
DisplayName string
Rank string
IsLeader bool
JoinedAt time.Time
UnknownFields map[string]any
Freshness SCUMProjectionFreshnessState
CreatedAt time.Time
UpdatedAt time.Time
}
type SCUMVehicle struct {
ID string
ServerInstanceID string
VehicleID string
EntityID string
ClassName string
Label string
OwnerProfileID string
OwnerPlayerID string
SquadID string
Position SCUMCurrentPosition
UnknownFields map[string]any
Freshness SCUMProjectionFreshnessState
CreatedAt time.Time
UpdatedAt time.Time
}
type SCUMFlag struct {
ID string
ServerInstanceID string
FlagID string
EntityID string
OwnerProfileID string
OwnerPlayerID string
OwnerSquadID string
OwnerSquadName string
OwnershipConfidence string
Position SCUMCurrentPosition
UnknownFields map[string]any
Freshness SCUMProjectionFreshnessState
CreatedAt time.Time
UpdatedAt time.Time
}
type SCUMCurrentPosition struct {
ID string
ServerInstanceID string
SubjectType SCUMProjectionSubject
SubjectID string
GamePlayerRecordID string
GamePlayerID string
VehicleID string
EntityID string
MapID string
MapVersion string
X float64
Y float64
Z float64
HasCoordinates bool
LastSaveTime time.Time
Freshness SCUMProjectionFreshnessState
CreatedAt time.Time
UpdatedAt time.Time
}
func SCUMProjectionStateUnknown() SCUMProjectionFreshnessState {
return SCUMProjectionFreshnessState{Status: SCUMProjectionUnknown}
}
func CopySCUMPlayerLiveState(value SCUMPlayerLiveState) SCUMPlayerLiveState {
value.Position = CopySCUMCurrentPosition(value.Position)
value.UnknownFields = CopyGameClientBridgePayload(value.UnknownFields)
value.Freshness = CopySCUMProjectionFreshnessState(value.Freshness)
return value
}
func CopySCUMSquad(value SCUMSquad) SCUMSquad {
value.UnknownFields = CopyGameClientBridgePayload(value.UnknownFields)
value.Freshness = CopySCUMProjectionFreshnessState(value.Freshness)
return value
}
func CopySCUMSquadMember(value SCUMSquadMember) SCUMSquadMember {
value.UnknownFields = CopyGameClientBridgePayload(value.UnknownFields)
value.Freshness = CopySCUMProjectionFreshnessState(value.Freshness)
return value
}
func CopySCUMVehicle(value SCUMVehicle) SCUMVehicle {
value.Position = CopySCUMCurrentPosition(value.Position)
value.UnknownFields = CopyGameClientBridgePayload(value.UnknownFields)
value.Freshness = CopySCUMProjectionFreshnessState(value.Freshness)
return value
}
func CopySCUMFlag(value SCUMFlag) SCUMFlag {
value.Position = CopySCUMCurrentPosition(value.Position)
value.UnknownFields = CopyGameClientBridgePayload(value.UnknownFields)
value.Freshness = CopySCUMProjectionFreshnessState(value.Freshness)
return value
}
func CopySCUMCurrentPosition(value SCUMCurrentPosition) SCUMCurrentPosition {
value.Freshness = CopySCUMProjectionFreshnessState(value.Freshness)
return value
}
+282
View File
@@ -0,0 +1,282 @@
package domain
import "time"
type SCUMObservationStatus string
const (
SCUMObservationAccepted SCUMObservationStatus = "accepted"
SCUMObservationStale SCUMObservationStatus = "stale"
SCUMObservationFailed SCUMObservationStatus = "failed"
)
type SCUMProjectionFreshness string
const (
SCUMProjectionFresh SCUMProjectionFreshness = "fresh"
SCUMProjectionStale SCUMProjectionFreshness = "stale"
SCUMProjectionUnknown SCUMProjectionFreshness = "unknown"
)
type SCUMWorkflowStatus string
const (
SCUMWorkflowDraft SCUMWorkflowStatus = "draft"
SCUMWorkflowQueued SCUMWorkflowStatus = "queued"
SCUMWorkflowRunning SCUMWorkflowStatus = "running"
SCUMWorkflowWaiting SCUMWorkflowStatus = "waiting"
SCUMWorkflowBlocked SCUMWorkflowStatus = "blocked"
SCUMWorkflowConfirming SCUMWorkflowStatus = "confirming"
SCUMWorkflowConfirmed SCUMWorkflowStatus = "confirmed"
SCUMWorkflowFailed SCUMWorkflowStatus = "failed"
SCUMWorkflowUnknown SCUMWorkflowStatus = "unknown"
SCUMWorkflowCancelled SCUMWorkflowStatus = "cancelled"
)
type SCUMWorkflowStepStatus string
const (
SCUMWorkflowStepQueued SCUMWorkflowStepStatus = "queued"
SCUMWorkflowStepRunning SCUMWorkflowStepStatus = "running"
SCUMWorkflowStepWaiting SCUMWorkflowStepStatus = "waiting"
SCUMWorkflowStepBlocked SCUMWorkflowStepStatus = "blocked"
SCUMWorkflowStepConfirming SCUMWorkflowStepStatus = "confirming"
SCUMWorkflowStepConfirmed SCUMWorkflowStepStatus = "confirmed"
SCUMWorkflowStepFailed SCUMWorkflowStepStatus = "failed"
SCUMWorkflowStepUnknown SCUMWorkflowStepStatus = "unknown"
SCUMWorkflowStepCancelled SCUMWorkflowStepStatus = "cancelled"
)
type SCUMSafeSummary struct {
Title string
Message string
Details map[string]string
}
type SCUMDataObservation struct {
ID string
ServerInstanceID string
PluginID string
Source string
QueryKey string
SubjectType string
SubjectID string
Sequence uint64
Checksum string
Status SCUMObservationStatus
ErrorCode string
SafeSummary SCUMSafeSummary
ObservedAt time.Time
ReceivedAt time.Time
}
type SCUMObservationResult struct {
ServerInstanceID string
PluginID string
Source string
QueryKey string
Sequence uint64
Checksum string
Status SCUMObservationStatus
ErrorCode string
SafeSummary SCUMSafeSummary
ObservedAt time.Time
ReceivedAt time.Time
Rows []map[string]any
}
type SCUMProjectionFreshnessState struct {
Status SCUMProjectionFreshness
ObservationID string
Source string
QueryKey string
Sequence uint64
Checksum string
StaleReason string
ObservedAt time.Time
ReceivedAt time.Time
}
type SCUMMutationGuard struct {
FieldKey string
Before any
After any
MaxRowsAffected int
SafetyWindow string
BackupRef string
RequiresOfflinePlayer bool
RequiresMaintenance bool
RequiresBackup bool
}
type SCUMOperationConfirmation struct {
Status string
ObservationID string
ConfirmedFields map[string]any
AffectedRows int
MutationChecksum string
Checksum string
ObservedAt time.Time
SafeSummary SCUMSafeSummary
}
type SCUMOperationRequest struct {
ID string
ServerInstanceID string
PluginID string
TemplateKey string
PlayerID string
RequesterID string
ApproverID string
ApprovalLevel GameClientBridgeApprovalLevel
Payload map[string]any
Guard SCUMMutationGuard
Confirmation SCUMOperationConfirmation
Status SCUMWorkflowStepStatus
Reason string
IdempotencyKey string
RunJobID string
SafeSummary SCUMSafeSummary
AuditReferences []string
CreatedAt time.Time
ApprovedAt time.Time
CompletedAt time.Time
UpdatedAt time.Time
}
type SCUMOperationRequestFilter struct {
ServerInstanceID string
PluginID string
TemplateKey string
PlayerID string
RequesterID string
Status SCUMWorkflowStepStatus
IdempotencyKey string
Limit int
}
type SCUMWorkflowInstanceFilter struct {
ServerInstanceID string
PluginID string
TemplateKey string
RequestedBy string
Status SCUMWorkflowStatus
IdempotencyKey string
Limit int
}
type SCUMWorkflowStepFilter struct {
WorkflowID string
ServerInstanceID string
StepKey string
Status SCUMWorkflowStepStatus
MutatesState *bool
Limit int
}
type SCUMWorkflowInstance struct {
ID string
ServerInstanceID string
PluginID string
TemplateKey string
RequestedBy string
IdempotencyKey string
Status SCUMWorkflowStatus
CurrentStepKey string
Input map[string]any
SafeSummary SCUMSafeSummary
BlockerReason string
AuditReferences []string
CreatedAt time.Time
UpdatedAt time.Time
CompletedAt time.Time
}
type SCUMWorkflowStep struct {
ID string
WorkflowID string
ServerInstanceID string
StepKey string
DependsOn []string
Status SCUMWorkflowStepStatus
OperationKey string
QueryTemplateKey string
Capability string
TargetKey string
JobID string
Attempt int
MaxAttempts int
MutatesState bool
Confirmation SCUMOperationConfirmation
SafeSummary SCUMSafeSummary
BlockerReason string
AuditReferences []string
CreatedAt time.Time
UpdatedAt time.Time
CompletedAt time.Time
}
func CopySCUMSafeSummary(value SCUMSafeSummary) SCUMSafeSummary {
value.Details = CopyStringMap(value.Details)
return value
}
func CopySCUMDataObservation(value SCUMDataObservation) SCUMDataObservation {
value.SafeSummary = CopySCUMSafeSummary(value.SafeSummary)
return value
}
func CopySCUMObservationResult(value SCUMObservationResult) SCUMObservationResult {
value.SafeSummary = CopySCUMSafeSummary(value.SafeSummary)
value.Rows = CopyGameClientBridgeRows(value.Rows)
return value
}
func CopyGameClientBridgeRows(values []map[string]any) []map[string]any {
if values == nil {
return nil
}
out := make([]map[string]any, len(values))
for index, row := range values {
out[index] = CopyGameClientBridgePayload(row)
}
return out
}
func CopySCUMProjectionFreshnessState(value SCUMProjectionFreshnessState) SCUMProjectionFreshnessState {
return value
}
func CopySCUMMutationGuard(value SCUMMutationGuard) SCUMMutationGuard {
return value
}
func CopySCUMOperationConfirmation(value SCUMOperationConfirmation) SCUMOperationConfirmation {
value.ConfirmedFields = CopyGameClientBridgePayload(value.ConfirmedFields)
value.SafeSummary = CopySCUMSafeSummary(value.SafeSummary)
return value
}
func CopySCUMOperationRequest(value SCUMOperationRequest) SCUMOperationRequest {
value.Payload = CopyGameClientBridgePayload(value.Payload)
value.Guard = CopySCUMMutationGuard(value.Guard)
value.Confirmation = CopySCUMOperationConfirmation(value.Confirmation)
value.SafeSummary = CopySCUMSafeSummary(value.SafeSummary)
value.AuditReferences = CopyStringSlice(value.AuditReferences)
return value
}
func CopySCUMWorkflowInstance(value SCUMWorkflowInstance) SCUMWorkflowInstance {
value.Input = CopyGameClientBridgePayload(value.Input)
value.SafeSummary = CopySCUMSafeSummary(value.SafeSummary)
value.AuditReferences = CopyStringSlice(value.AuditReferences)
return value
}
func CopySCUMWorkflowStep(value SCUMWorkflowStep) SCUMWorkflowStep {
value.DependsOn = CopyStringSlice(value.DependsOn)
value.Confirmation = CopySCUMOperationConfirmation(value.Confirmation)
value.SafeSummary = CopySCUMSafeSummary(value.SafeSummary)
value.AuditReferences = CopyStringSlice(value.AuditReferences)
return value
}
+52 -4
View File
@@ -302,11 +302,50 @@ type GameClientBridgeQueryTemplateDeclarationBody struct {
TimeoutSeconds int `json:"timeoutSeconds"` TimeoutSeconds int `json:"timeoutSeconds"`
} }
type GameClientBridgeOperationSafetyBody struct {
RequiresApproval bool `json:"requiresApproval,omitempty"`
RequiresOfflinePlayer bool `json:"requiresOfflinePlayer,omitempty"`
RequiresMaintenanceWindow bool `json:"requiresMaintenanceWindow,omitempty"`
RequiresBeforeValue bool `json:"requiresBeforeValue,omitempty"`
RequiresConfirmation bool `json:"requiresConfirmation,omitempty"`
BackupRequired bool `json:"backupRequired,omitempty"`
}
type GameClientBridgeOperationMutationDeclarationBody struct {
FieldKey string `json:"fieldKey"`
TableKey string `json:"tableKey"`
IdentityKey string `json:"identityKey"`
ValueKey string `json:"valueKey"`
ConfirmationQueryKey string `json:"confirmationQueryKey"`
AllowedValueType string `json:"allowedValueType"`
MinValue float64 `json:"minValue,omitempty"`
MaxValue float64 `json:"maxValue,omitempty"`
}
type GameClientBridgeOperationTemplateDeclarationBody struct {
Key string `json:"key"`
Title string `json:"title"`
Permission string `json:"permission"`
ApprovalLevel string `json:"approvalLevel"`
Kind string `json:"kind"`
TransportKey string `json:"transportKey"`
TargetKey string `json:"targetKey"`
PayloadSchemaRef string `json:"payloadSchemaRef"`
ResultSchemaRef string `json:"resultSchemaRef,omitempty"`
ConfirmationSchemaRef string `json:"confirmationSchemaRef,omitempty"`
TimeoutSeconds int `json:"timeoutSeconds"`
MaxPayloadBytes int `json:"maxPayloadBytes"`
MaxRowsAffected int `json:"maxRowsAffected,omitempty"`
Mutation GameClientBridgeOperationMutationDeclarationBody `json:"mutation,omitempty"`
Safety GameClientBridgeOperationSafetyBody `json:"safety,omitempty"`
}
type GameClientBridgePageContractBody struct { type GameClientBridgePageContractBody struct {
PageKey string `json:"pageKey"` PageKey string `json:"pageKey"`
CommandTypes []string `json:"commandTypes,omitempty"` CommandTypes []string `json:"commandTypes,omitempty"`
SnapshotTypes []string `json:"snapshotTypes,omitempty"` SnapshotTypes []string `json:"snapshotTypes,omitempty"`
QueryTemplateKeys []string `json:"queryTemplateKeys,omitempty"` QueryTemplateKeys []string `json:"queryTemplateKeys,omitempty"`
OperationKeys []string `json:"operationKeys,omitempty"`
FeatureKeys []string `json:"featureKeys,omitempty"` FeatureKeys []string `json:"featureKeys,omitempty"`
} }
@@ -338,6 +377,7 @@ type GameClientBridgeManifestBody struct {
Commands []GameClientBridgeCommandDeclarationBody `json:"commands"` Commands []GameClientBridgeCommandDeclarationBody `json:"commands"`
Snapshots []GameClientBridgeSnapshotDeclarationBody `json:"snapshots"` Snapshots []GameClientBridgeSnapshotDeclarationBody `json:"snapshots"`
QueryTemplates []GameClientBridgeQueryTemplateDeclarationBody `json:"queryTemplates,omitempty"` QueryTemplates []GameClientBridgeQueryTemplateDeclarationBody `json:"queryTemplates,omitempty"`
OperationTemplates []GameClientBridgeOperationTemplateDeclarationBody `json:"operationTemplates,omitempty"`
CommandRetentionSeconds int `json:"commandRetentionSeconds"` CommandRetentionSeconds int `json:"commandRetentionSeconds"`
MaxCommands int `json:"maxCommands"` MaxCommands int `json:"maxCommands"`
Pages []GameClientBridgePageContractBody `json:"pages,omitempty"` Pages []GameClientBridgePageContractBody `json:"pages,omitempty"`
@@ -1167,9 +1207,13 @@ func (body GameClientBridgeManifestBody) ToDomain() domain.GameClientBridgeManif
for index, template := range body.QueryTemplates { for index, template := range body.QueryTemplates {
queryTemplates[index] = domain.GameClientBridgeQueryTemplateDeclaration{Key: template.Key, Title: template.Title, Permission: template.Permission, Engine: template.Engine, TransportKey: template.TransportKey, TargetKey: template.TargetKey, ParameterSchemaRef: template.ParameterSchemaRef, ResultSchemaRef: template.ResultSchemaRef, MaxRows: template.MaxRows, TimeoutSeconds: template.TimeoutSeconds} queryTemplates[index] = domain.GameClientBridgeQueryTemplateDeclaration{Key: template.Key, Title: template.Title, Permission: template.Permission, Engine: template.Engine, TransportKey: template.TransportKey, TargetKey: template.TargetKey, ParameterSchemaRef: template.ParameterSchemaRef, ResultSchemaRef: template.ResultSchemaRef, MaxRows: template.MaxRows, TimeoutSeconds: template.TimeoutSeconds}
} }
operationTemplates := make([]domain.GameClientBridgeOperationTemplateDeclaration, len(body.OperationTemplates))
for index, template := range body.OperationTemplates {
operationTemplates[index] = domain.GameClientBridgeOperationTemplateDeclaration{Key: template.Key, Title: template.Title, Permission: template.Permission, ApprovalLevel: domain.GameClientBridgeApprovalLevel(template.ApprovalLevel), Kind: domain.GameClientBridgeOperationKind(template.Kind), TransportKey: template.TransportKey, TargetKey: template.TargetKey, PayloadSchemaRef: template.PayloadSchemaRef, ResultSchemaRef: template.ResultSchemaRef, ConfirmationSchemaRef: template.ConfirmationSchemaRef, TimeoutSeconds: template.TimeoutSeconds, MaxPayloadBytes: template.MaxPayloadBytes, MaxRowsAffected: template.MaxRowsAffected, Mutation: domain.GameClientBridgeOperationMutationDeclaration{FieldKey: template.Mutation.FieldKey, TableKey: template.Mutation.TableKey, IdentityKey: template.Mutation.IdentityKey, ValueKey: template.Mutation.ValueKey, ConfirmationQueryKey: template.Mutation.ConfirmationQueryKey, AllowedValueType: template.Mutation.AllowedValueType, MinValue: template.Mutation.MinValue, MaxValue: template.Mutation.MaxValue}, Safety: domain.GameClientBridgeOperationSafety{RequiresApproval: template.Safety.RequiresApproval, RequiresOfflinePlayer: template.Safety.RequiresOfflinePlayer, RequiresMaintenanceWindow: template.Safety.RequiresMaintenanceWindow, RequiresBeforeValue: template.Safety.RequiresBeforeValue, RequiresConfirmation: template.Safety.RequiresConfirmation, BackupRequired: template.Safety.BackupRequired}}
}
pages := make([]domain.GameClientBridgePageContract, len(body.Pages)) pages := make([]domain.GameClientBridgePageContract, len(body.Pages))
for index, page := range body.Pages { for index, page := range body.Pages {
pages[index] = domain.GameClientBridgePageContract{PageKey: page.PageKey, CommandTypes: domain.CopyStringSlice(page.CommandTypes), SnapshotTypes: domain.CopyStringSlice(page.SnapshotTypes), QueryTemplateKeys: domain.CopyStringSlice(page.QueryTemplateKeys), FeatureKeys: domain.CopyStringSlice(page.FeatureKeys)} pages[index] = domain.GameClientBridgePageContract{PageKey: page.PageKey, CommandTypes: domain.CopyStringSlice(page.CommandTypes), SnapshotTypes: domain.CopyStringSlice(page.SnapshotTypes), QueryTemplateKeys: domain.CopyStringSlice(page.QueryTemplateKeys), OperationKeys: domain.CopyStringSlice(page.OperationKeys), FeatureKeys: domain.CopyStringSlice(page.FeatureKeys)}
} }
features := make([]domain.GameClientBridgeFeatureDeclaration, len(body.Features)) features := make([]domain.GameClientBridgeFeatureDeclaration, len(body.Features))
for index, feature := range body.Features { for index, feature := range body.Features {
@@ -1179,7 +1223,7 @@ func (body GameClientBridgeManifestBody) ToDomain() domain.GameClientBridgeManif
if body.Companion != nil { if body.Companion != nil {
companion = domain.GameClientBridgeCompanionDeclaration{ProfileKey: body.Companion.ProfileKey, ConfigTemplateKey: body.Companion.ConfigTemplateKey, ConfigSchemaRef: body.Companion.ConfigSchemaRef, ConfigFormat: body.Companion.ConfigFormat, PlatformBaseURLSource: body.Companion.PlatformBaseURLSource, RegistrationProof: body.Companion.RegistrationProof, ProofMaterialSource: body.Companion.ProofMaterialSource, ProofMaterialEnv: body.Companion.ProofMaterialEnv, SessionMode: body.Companion.SessionMode, TLSPolicy: body.Companion.TLSPolicy, HeartbeatIntervalSeconds: body.Companion.HeartbeatIntervalSeconds, CommandPollIntervalSeconds: body.Companion.CommandPollIntervalSeconds, RequestTimeoutSeconds: body.Companion.RequestTimeoutSeconds} companion = domain.GameClientBridgeCompanionDeclaration{ProfileKey: body.Companion.ProfileKey, ConfigTemplateKey: body.Companion.ConfigTemplateKey, ConfigSchemaRef: body.Companion.ConfigSchemaRef, ConfigFormat: body.Companion.ConfigFormat, PlatformBaseURLSource: body.Companion.PlatformBaseURLSource, RegistrationProof: body.Companion.RegistrationProof, ProofMaterialSource: body.Companion.ProofMaterialSource, ProofMaterialEnv: body.Companion.ProofMaterialEnv, SessionMode: body.Companion.SessionMode, TLSPolicy: body.Companion.TLSPolicy, HeartbeatIntervalSeconds: body.Companion.HeartbeatIntervalSeconds, CommandPollIntervalSeconds: body.Companion.CommandPollIntervalSeconds, RequestTimeoutSeconds: body.Companion.RequestTimeoutSeconds}
} }
return domain.GameClientBridgeManifest{Commands: commands, Snapshots: snapshots, QueryTemplates: queryTemplates, Retention: domain.GameClientBridgeRetention{KeepForSeconds: body.CommandRetentionSeconds, MaxRecords: body.MaxCommands}, Pages: pages, Features: features, Companion: companion} return domain.GameClientBridgeManifest{Commands: commands, Snapshots: snapshots, QueryTemplates: queryTemplates, OperationTemplates: operationTemplates, Retention: domain.GameClientBridgeRetention{KeepForSeconds: body.CommandRetentionSeconds, MaxRecords: body.MaxCommands}, Pages: pages, Features: features, Companion: companion}
} }
func protectedRequestToDomain(value *GameClientBridgeProtectedRequestDeclarationBody) *domain.GameClientBridgeProtectedRequestDeclaration { func protectedRequestToDomain(value *GameClientBridgeProtectedRequestDeclarationBody) *domain.GameClientBridgeProtectedRequestDeclaration {
@@ -1595,9 +1639,13 @@ func gameClientBridgeManifestFromDomain(value domain.GameClientBridgeManifest) G
for index, template := range value.QueryTemplates { for index, template := range value.QueryTemplates {
queryTemplates[index] = GameClientBridgeQueryTemplateDeclarationBody{Key: template.Key, Title: template.Title, Permission: template.Permission, Engine: template.Engine, TransportKey: template.TransportKey, TargetKey: template.TargetKey, ParameterSchemaRef: template.ParameterSchemaRef, ResultSchemaRef: template.ResultSchemaRef, MaxRows: template.MaxRows, TimeoutSeconds: template.TimeoutSeconds} queryTemplates[index] = GameClientBridgeQueryTemplateDeclarationBody{Key: template.Key, Title: template.Title, Permission: template.Permission, Engine: template.Engine, TransportKey: template.TransportKey, TargetKey: template.TargetKey, ParameterSchemaRef: template.ParameterSchemaRef, ResultSchemaRef: template.ResultSchemaRef, MaxRows: template.MaxRows, TimeoutSeconds: template.TimeoutSeconds}
} }
operationTemplates := make([]GameClientBridgeOperationTemplateDeclarationBody, len(value.OperationTemplates))
for index, template := range value.OperationTemplates {
operationTemplates[index] = GameClientBridgeOperationTemplateDeclarationBody{Key: template.Key, Title: template.Title, Permission: template.Permission, ApprovalLevel: string(template.ApprovalLevel), Kind: string(template.Kind), TransportKey: template.TransportKey, TargetKey: template.TargetKey, PayloadSchemaRef: template.PayloadSchemaRef, ResultSchemaRef: template.ResultSchemaRef, ConfirmationSchemaRef: template.ConfirmationSchemaRef, TimeoutSeconds: template.TimeoutSeconds, MaxPayloadBytes: template.MaxPayloadBytes, MaxRowsAffected: template.MaxRowsAffected, Mutation: GameClientBridgeOperationMutationDeclarationBody{FieldKey: template.Mutation.FieldKey, TableKey: template.Mutation.TableKey, IdentityKey: template.Mutation.IdentityKey, ValueKey: template.Mutation.ValueKey, ConfirmationQueryKey: template.Mutation.ConfirmationQueryKey, AllowedValueType: template.Mutation.AllowedValueType, MinValue: template.Mutation.MinValue, MaxValue: template.Mutation.MaxValue}, Safety: GameClientBridgeOperationSafetyBody{RequiresApproval: template.Safety.RequiresApproval, RequiresOfflinePlayer: template.Safety.RequiresOfflinePlayer, RequiresMaintenanceWindow: template.Safety.RequiresMaintenanceWindow, RequiresBeforeValue: template.Safety.RequiresBeforeValue, RequiresConfirmation: template.Safety.RequiresConfirmation, BackupRequired: template.Safety.BackupRequired}}
}
pages := make([]GameClientBridgePageContractBody, len(value.Pages)) pages := make([]GameClientBridgePageContractBody, len(value.Pages))
for index, page := range value.Pages { for index, page := range value.Pages {
pages[index] = GameClientBridgePageContractBody{PageKey: page.PageKey, CommandTypes: page.CommandTypes, SnapshotTypes: page.SnapshotTypes, QueryTemplateKeys: page.QueryTemplateKeys, FeatureKeys: page.FeatureKeys} pages[index] = GameClientBridgePageContractBody{PageKey: page.PageKey, CommandTypes: page.CommandTypes, SnapshotTypes: page.SnapshotTypes, QueryTemplateKeys: page.QueryTemplateKeys, OperationKeys: page.OperationKeys, FeatureKeys: page.FeatureKeys}
} }
features := make([]GameClientBridgeFeatureDeclarationBody, len(value.Features)) features := make([]GameClientBridgeFeatureDeclarationBody, len(value.Features))
for index, feature := range value.Features { for index, feature := range value.Features {
@@ -1607,7 +1655,7 @@ func gameClientBridgeManifestFromDomain(value domain.GameClientBridgeManifest) G
if value.Companion.ProfileKey != "" { if value.Companion.ProfileKey != "" {
companion = &GameClientBridgeCompanionDeclarationBody{ProfileKey: value.Companion.ProfileKey, ConfigTemplateKey: value.Companion.ConfigTemplateKey, ConfigSchemaRef: value.Companion.ConfigSchemaRef, ConfigFormat: value.Companion.ConfigFormat, PlatformBaseURLSource: value.Companion.PlatformBaseURLSource, RegistrationProof: value.Companion.RegistrationProof, ProofMaterialSource: value.Companion.ProofMaterialSource, ProofMaterialEnv: value.Companion.ProofMaterialEnv, SessionMode: value.Companion.SessionMode, TLSPolicy: value.Companion.TLSPolicy, HeartbeatIntervalSeconds: value.Companion.HeartbeatIntervalSeconds, CommandPollIntervalSeconds: value.Companion.CommandPollIntervalSeconds, RequestTimeoutSeconds: value.Companion.RequestTimeoutSeconds} companion = &GameClientBridgeCompanionDeclarationBody{ProfileKey: value.Companion.ProfileKey, ConfigTemplateKey: value.Companion.ConfigTemplateKey, ConfigSchemaRef: value.Companion.ConfigSchemaRef, ConfigFormat: value.Companion.ConfigFormat, PlatformBaseURLSource: value.Companion.PlatformBaseURLSource, RegistrationProof: value.Companion.RegistrationProof, ProofMaterialSource: value.Companion.ProofMaterialSource, ProofMaterialEnv: value.Companion.ProofMaterialEnv, SessionMode: value.Companion.SessionMode, TLSPolicy: value.Companion.TLSPolicy, HeartbeatIntervalSeconds: value.Companion.HeartbeatIntervalSeconds, CommandPollIntervalSeconds: value.Companion.CommandPollIntervalSeconds, RequestTimeoutSeconds: value.Companion.RequestTimeoutSeconds}
} }
return GameClientBridgeManifestBody{Commands: commands, Snapshots: snapshots, QueryTemplates: queryTemplates, CommandRetentionSeconds: value.Retention.KeepForSeconds, MaxCommands: value.Retention.MaxRecords, Pages: pages, Features: features, Companion: companion} return GameClientBridgeManifestBody{Commands: commands, Snapshots: snapshots, QueryTemplates: queryTemplates, OperationTemplates: operationTemplates, CommandRetentionSeconds: value.Retention.KeepForSeconds, MaxCommands: value.Retention.MaxRecords, Pages: pages, Features: features, Companion: companion}
} }
func protectedRequestFromDomain(value *domain.GameClientBridgeProtectedRequestDeclaration) *GameClientBridgeProtectedRequestDeclarationBody { func protectedRequestFromDomain(value *domain.GameClientBridgeProtectedRequestDeclaration) *GameClientBridgeProtectedRequestDeclarationBody {
+38
View File
@@ -3,6 +3,7 @@ package dto
import ( import (
"encoding/json" "encoding/json"
"reflect" "reflect"
"strings"
"testing" "testing"
"browser.local/platform/domain" "browser.local/platform/domain"
@@ -177,3 +178,40 @@ func TestGameClientBridgeQueryTemplateDeclarationRoundTripIsSafe(t *testing.T) {
} }
} }
} }
func TestGameClientBridgeOperationTemplateDeclarationRoundTripIsSafe(t *testing.T) {
body := GameClientBridgeManifestBody{
OperationTemplates: []GameClientBridgeOperationTemplateDeclarationBody{{
Key: "player.attribute.855.set", Title: "Set player attribute 855", Permission: "server.game-client.maintenance", ApprovalLevel: "platform-admin", Kind: "sqlite-mutation", TransportKey: "scum-mutation-db", TargetKey: "scum-mutation-db",
PayloadSchemaRef: "schemas/bridge/operations/player-attribute-855-set.payload.schema.json", ResultSchemaRef: "schemas/bridge/operations/player-attribute-855-set.result.schema.json", ConfirmationSchemaRef: "schemas/bridge/operations/player-attribute-855-set.confirmation.schema.json", TimeoutSeconds: 120, MaxPayloadBytes: 4096, MaxRowsAffected: 1,
Safety: GameClientBridgeOperationSafetyBody{RequiresApproval: true, RequiresOfflinePlayer: true, RequiresBeforeValue: true, RequiresConfirmation: true, BackupRequired: true},
}},
CommandRetentionSeconds: 86400,
MaxCommands: 1000,
Pages: []GameClientBridgePageContractBody{{PageKey: "players", OperationKeys: []string{"player.attribute.855.set"}}},
}
domainManifest := body.ToDomain()
if len(domainManifest.OperationTemplates) != 1 || domainManifest.OperationTemplates[0].Kind != "sqlite-mutation" || domainManifest.OperationTemplates[0].MaxRowsAffected != 1 || !domainManifest.OperationTemplates[0].Safety.RequiresBeforeValue || domainManifest.Pages[0].OperationKeys[0] != "player.attribute.855.set" {
t.Fatalf("operation template conversion lost declaration fields: %#v", domainManifest)
}
domainManifest.Pages[0].OperationKeys[0] = "mutated"
if body.Pages[0].OperationKeys[0] != "player.attribute.855.set" {
t.Fatal("operation template page keys alias request DTO data")
}
domainManifest.Pages[0].OperationKeys[0] = "player.attribute.855.set"
response := gameClientBridgeManifestFromDomain(domainManifest)
response.Pages[0].OperationKeys[0] = "mutated"
if domainManifest.Pages[0].OperationKeys[0] != "player.attribute.855.set" {
t.Fatal("operation template page keys alias domain data")
}
encoded, err := json.Marshal(response.OperationTemplates[0])
if err != nil {
t.Fatalf("marshal safe operation template projection: %v", err)
}
if strings.Contains(strings.ToLower(string(encoded)), "sqltext") || strings.Contains(strings.ToLower(string(encoded)), "dsn") || strings.Contains(strings.ToLower(string(encoded)), "hostpath") || strings.Contains(strings.ToLower(string(encoded)), "socket") || strings.Contains(strings.ToLower(string(encoded)), "credential") {
t.Fatalf("operation template projection leaked unsafe material: %s", encoded)
}
}
+81
View File
@@ -0,0 +1,81 @@
package dto
import "browser.local/platform/domain"
type SCUMPlayerLiveStateListResponse struct {
Items []domain.SCUMPlayerLiveState `json:"items"`
Count int `json:"count"`
}
type SCUMSquadListResponse struct {
Items []domain.SCUMSquad `json:"items"`
Count int `json:"count"`
}
type SCUMSquadMemberListResponse struct {
Items []domain.SCUMSquadMember `json:"items"`
Count int `json:"count"`
}
type SCUMVehicleListResponse struct {
Items []domain.SCUMVehicle `json:"items"`
Count int `json:"count"`
}
type SCUMFlagListResponse struct {
Items []domain.SCUMFlag `json:"items"`
Count int `json:"count"`
}
type SCUMCurrentPositionListResponse struct {
Items []domain.SCUMCurrentPosition `json:"items"`
Count int `json:"count"`
}
func SCUMPlayerLiveStatesFromDomain(values []domain.SCUMPlayerLiveState) SCUMPlayerLiveStateListResponse {
out := make([]domain.SCUMPlayerLiveState, len(values))
for index, value := range values {
out[index] = domain.CopySCUMPlayerLiveState(value)
}
return SCUMPlayerLiveStateListResponse{Items: out, Count: len(out)}
}
func SCUMSquadsFromDomain(values []domain.SCUMSquad) SCUMSquadListResponse {
out := make([]domain.SCUMSquad, len(values))
for index, value := range values {
out[index] = domain.CopySCUMSquad(value)
}
return SCUMSquadListResponse{Items: out, Count: len(out)}
}
func SCUMSquadMembersFromDomain(values []domain.SCUMSquadMember) SCUMSquadMemberListResponse {
out := make([]domain.SCUMSquadMember, len(values))
for index, value := range values {
out[index] = domain.CopySCUMSquadMember(value)
}
return SCUMSquadMemberListResponse{Items: out, Count: len(out)}
}
func SCUMVehiclesFromDomain(values []domain.SCUMVehicle) SCUMVehicleListResponse {
out := make([]domain.SCUMVehicle, len(values))
for index, value := range values {
out[index] = domain.CopySCUMVehicle(value)
}
return SCUMVehicleListResponse{Items: out, Count: len(out)}
}
func SCUMFlagsFromDomain(values []domain.SCUMFlag) SCUMFlagListResponse {
out := make([]domain.SCUMFlag, len(values))
for index, value := range values {
out[index] = domain.CopySCUMFlag(value)
}
return SCUMFlagListResponse{Items: out, Count: len(out)}
}
func SCUMCurrentPositionsFromDomain(values []domain.SCUMCurrentPosition) SCUMCurrentPositionListResponse {
out := make([]domain.SCUMCurrentPosition, len(values))
for index, value := range values {
out[index] = domain.CopySCUMCurrentPosition(value)
}
return SCUMCurrentPositionListResponse{Items: out, Count: len(out)}
}
+243
View File
@@ -0,0 +1,243 @@
package dto
import (
"time"
"browser.local/platform/domain"
)
type SCUMSafeSummaryBody struct {
Title string `json:"title,omitempty"`
Message string `json:"message,omitempty"`
Details map[string]string `json:"details,omitempty"`
}
type SCUMDataObservationResponse struct {
ID string `json:"id"`
ServerInstanceID string `json:"serverInstanceId"`
PluginID string `json:"pluginId"`
Source string `json:"source"`
QueryKey string `json:"queryKey,omitempty"`
SubjectType string `json:"subjectType,omitempty"`
SubjectID string `json:"subjectId,omitempty"`
Sequence uint64 `json:"sequence"`
Checksum string `json:"checksum,omitempty"`
Status string `json:"status"`
ErrorCode string `json:"errorCode,omitempty"`
SafeSummary SCUMSafeSummaryBody `json:"safeSummary,omitempty"`
ObservedAt time.Time `json:"observedAt"`
ReceivedAt time.Time `json:"receivedAt"`
}
type SCUMProjectionFreshnessBody struct {
Status string `json:"status"`
ObservationID string `json:"observationId,omitempty"`
Source string `json:"source,omitempty"`
QueryKey string `json:"queryKey,omitempty"`
Sequence uint64 `json:"sequence,omitempty"`
Checksum string `json:"checksum,omitempty"`
StaleReason string `json:"staleReason,omitempty"`
ObservedAt time.Time `json:"observedAt,omitempty"`
ReceivedAt time.Time `json:"receivedAt,omitempty"`
}
type SCUMMutationGuardBody struct {
FieldKey string `json:"fieldKey,omitempty"`
Before any `json:"before,omitempty"`
After any `json:"after,omitempty"`
MaxRowsAffected int `json:"maxRowsAffected,omitempty"`
SafetyWindow string `json:"safetyWindow,omitempty"`
BackupRef string `json:"backupRef,omitempty"`
RequiresOfflinePlayer bool `json:"requiresOfflinePlayer,omitempty"`
RequiresMaintenance bool `json:"requiresMaintenance,omitempty"`
RequiresBackup bool `json:"requiresBackup,omitempty"`
}
type SCUMOperationConfirmationBody struct {
Status string `json:"status,omitempty"`
ObservationID string `json:"observationId,omitempty"`
ConfirmedFields map[string]any `json:"confirmedFields,omitempty"`
AffectedRows int `json:"affectedRows,omitempty"`
MutationChecksum string `json:"mutationChecksum,omitempty"`
Checksum string `json:"checksum,omitempty"`
ObservedAt time.Time `json:"observedAt,omitempty"`
SafeSummary SCUMSafeSummaryBody `json:"safeSummary,omitempty"`
}
type SCUMOperationRequestBody struct {
TemplateKey string `json:"templateKey"`
PlayerID string `json:"playerId,omitempty"`
Payload map[string]any `json:"payload,omitempty"`
Guard SCUMMutationGuardBody `json:"guard,omitempty"`
Reason string `json:"reason"`
IdempotencyKey string `json:"idempotencyKey"`
}
type SCUMWorkflowCreateRequest struct {
TemplateKey string `json:"templateKey"`
IdempotencyKey string `json:"idempotencyKey"`
Input map[string]any `json:"input,omitempty"`
}
type SCUMOperationResponse struct {
ID string `json:"id"`
ServerInstanceID string `json:"serverInstanceId"`
PluginID string `json:"pluginId"`
TemplateKey string `json:"templateKey"`
PlayerID string `json:"playerId,omitempty"`
RequesterID string `json:"requesterId,omitempty"`
ApproverID string `json:"approverId,omitempty"`
ApprovalLevel string `json:"approvalLevel"`
Payload map[string]any `json:"payload,omitempty"`
Guard SCUMMutationGuardBody `json:"guard,omitempty"`
Confirmation SCUMOperationConfirmationBody `json:"confirmation,omitempty"`
Status string `json:"status"`
Reason string `json:"reason,omitempty"`
RunJobID string `json:"runJobId,omitempty"`
SafeSummary SCUMSafeSummaryBody `json:"safeSummary,omitempty"`
AuditReferences []string `json:"auditReferences,omitempty"`
CreatedAt time.Time `json:"createdAt"`
ApprovedAt time.Time `json:"approvedAt,omitempty"`
CompletedAt time.Time `json:"completedAt,omitempty"`
UpdatedAt time.Time `json:"updatedAt"`
}
type SCUMOperationListResponse struct {
Items []SCUMOperationResponse `json:"items"`
Count int `json:"count"`
}
type SCUMWorkflowResponse struct {
ID string `json:"id"`
ServerInstanceID string `json:"serverInstanceId"`
PluginID string `json:"pluginId"`
TemplateKey string `json:"templateKey"`
RequestedBy string `json:"requestedBy,omitempty"`
IdempotencyKey string `json:"idempotencyKey,omitempty"`
Status string `json:"status"`
CurrentStepKey string `json:"currentStepKey,omitempty"`
Input map[string]any `json:"input,omitempty"`
SafeSummary SCUMSafeSummaryBody `json:"safeSummary,omitempty"`
BlockerReason string `json:"blockerReason,omitempty"`
AuditReferences []string `json:"auditReferences,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
CompletedAt time.Time `json:"completedAt,omitempty"`
}
type SCUMWorkflowListResponse struct {
Items []SCUMWorkflowResponse `json:"items"`
Count int `json:"count"`
}
type SCUMWorkflowStepResponse struct {
ID string `json:"id"`
WorkflowID string `json:"workflowId"`
ServerInstanceID string `json:"serverInstanceId"`
StepKey string `json:"stepKey"`
DependsOn []string `json:"dependsOn,omitempty"`
Status string `json:"status"`
OperationKey string `json:"operationKey,omitempty"`
QueryTemplateKey string `json:"queryTemplateKey,omitempty"`
Capability string `json:"capability,omitempty"`
TargetKey string `json:"targetKey,omitempty"`
JobID string `json:"jobId,omitempty"`
Attempt int `json:"attempt,omitempty"`
MaxAttempts int `json:"maxAttempts,omitempty"`
MutatesState bool `json:"mutatesState,omitempty"`
Confirmation SCUMOperationConfirmationBody `json:"confirmation,omitempty"`
SafeSummary SCUMSafeSummaryBody `json:"safeSummary,omitempty"`
BlockerReason string `json:"blockerReason,omitempty"`
AuditReferences []string `json:"auditReferences,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
CompletedAt time.Time `json:"completedAt,omitempty"`
}
type SCUMWorkflowStepListResponse struct {
Items []SCUMWorkflowStepResponse `json:"items"`
Count int `json:"count"`
}
func SCUMSafeSummaryFromDomain(value domain.SCUMSafeSummary) SCUMSafeSummaryBody {
value = domain.CopySCUMSafeSummary(value)
return SCUMSafeSummaryBody{Title: value.Title, Message: value.Message, Details: value.Details}
}
func scumSafeSummaryToDomain(value SCUMSafeSummaryBody) domain.SCUMSafeSummary {
return domain.SCUMSafeSummary{Title: value.Title, Message: value.Message, Details: domain.CopyStringMap(value.Details)}
}
func SCUMDataObservationFromDomain(value domain.SCUMDataObservation) SCUMDataObservationResponse {
value = domain.CopySCUMDataObservation(value)
return SCUMDataObservationResponse{ID: value.ID, ServerInstanceID: value.ServerInstanceID, PluginID: value.PluginID, Source: value.Source, QueryKey: value.QueryKey, SubjectType: value.SubjectType, SubjectID: value.SubjectID, Sequence: value.Sequence, Checksum: value.Checksum, Status: string(value.Status), ErrorCode: value.ErrorCode, SafeSummary: SCUMSafeSummaryFromDomain(value.SafeSummary), ObservedAt: value.ObservedAt, ReceivedAt: value.ReceivedAt}
}
func SCUMProjectionFreshnessFromDomain(value domain.SCUMProjectionFreshnessState) SCUMProjectionFreshnessBody {
value = domain.CopySCUMProjectionFreshnessState(value)
return SCUMProjectionFreshnessBody{Status: string(value.Status), ObservationID: value.ObservationID, Source: value.Source, QueryKey: value.QueryKey, Sequence: value.Sequence, Checksum: value.Checksum, StaleReason: value.StaleReason, ObservedAt: value.ObservedAt, ReceivedAt: value.ReceivedAt}
}
func SCUMOperationRequestBodyToDomain(request SCUMOperationRequestBody) domain.SCUMOperationRequest {
return domain.SCUMOperationRequest{TemplateKey: request.TemplateKey, PlayerID: request.PlayerID, Payload: domain.CopyGameClientBridgePayload(request.Payload), Guard: scumMutationGuardToDomain(request.Guard), Reason: request.Reason, IdempotencyKey: request.IdempotencyKey}
}
func SCUMWorkflowCreateRequestToDomain(request SCUMWorkflowCreateRequest) domain.SCUMWorkflowInstance {
return domain.SCUMWorkflowInstance{TemplateKey: request.TemplateKey, IdempotencyKey: request.IdempotencyKey, Input: domain.CopyGameClientBridgePayload(request.Input)}
}
func SCUMOperationFromDomain(value domain.SCUMOperationRequest) SCUMOperationResponse {
value = domain.CopySCUMOperationRequest(value)
return SCUMOperationResponse{ID: value.ID, ServerInstanceID: value.ServerInstanceID, PluginID: value.PluginID, TemplateKey: value.TemplateKey, PlayerID: value.PlayerID, RequesterID: value.RequesterID, ApproverID: value.ApproverID, ApprovalLevel: string(value.ApprovalLevel), Payload: value.Payload, Guard: scumMutationGuardFromDomain(value.Guard), Confirmation: scumOperationConfirmationFromDomain(value.Confirmation), Status: string(value.Status), Reason: value.Reason, RunJobID: value.RunJobID, SafeSummary: SCUMSafeSummaryFromDomain(value.SafeSummary), AuditReferences: value.AuditReferences, CreatedAt: value.CreatedAt, ApprovedAt: value.ApprovedAt, CompletedAt: value.CompletedAt, UpdatedAt: value.UpdatedAt}
}
func SCUMOperationsFromDomain(values []domain.SCUMOperationRequest) SCUMOperationListResponse {
items := make([]SCUMOperationResponse, len(values))
for index, value := range values {
items[index] = SCUMOperationFromDomain(value)
}
return SCUMOperationListResponse{Items: items, Count: len(items)}
}
func SCUMWorkflowFromDomain(value domain.SCUMWorkflowInstance) SCUMWorkflowResponse {
value = domain.CopySCUMWorkflowInstance(value)
return SCUMWorkflowResponse{ID: value.ID, ServerInstanceID: value.ServerInstanceID, PluginID: value.PluginID, TemplateKey: value.TemplateKey, RequestedBy: value.RequestedBy, IdempotencyKey: value.IdempotencyKey, Status: string(value.Status), CurrentStepKey: value.CurrentStepKey, Input: value.Input, SafeSummary: SCUMSafeSummaryFromDomain(value.SafeSummary), BlockerReason: value.BlockerReason, AuditReferences: value.AuditReferences, CreatedAt: value.CreatedAt, UpdatedAt: value.UpdatedAt, CompletedAt: value.CompletedAt}
}
func SCUMWorkflowsFromDomain(values []domain.SCUMWorkflowInstance) SCUMWorkflowListResponse {
items := make([]SCUMWorkflowResponse, len(values))
for index, value := range values {
items[index] = SCUMWorkflowFromDomain(value)
}
return SCUMWorkflowListResponse{Items: items, Count: len(items)}
}
func SCUMWorkflowStepFromDomain(value domain.SCUMWorkflowStep) SCUMWorkflowStepResponse {
value = domain.CopySCUMWorkflowStep(value)
return SCUMWorkflowStepResponse{ID: value.ID, WorkflowID: value.WorkflowID, ServerInstanceID: value.ServerInstanceID, StepKey: value.StepKey, DependsOn: value.DependsOn, Status: string(value.Status), OperationKey: value.OperationKey, QueryTemplateKey: value.QueryTemplateKey, Capability: value.Capability, TargetKey: value.TargetKey, JobID: value.JobID, Attempt: value.Attempt, MaxAttempts: value.MaxAttempts, MutatesState: value.MutatesState, Confirmation: scumOperationConfirmationFromDomain(value.Confirmation), SafeSummary: SCUMSafeSummaryFromDomain(value.SafeSummary), BlockerReason: value.BlockerReason, AuditReferences: value.AuditReferences, CreatedAt: value.CreatedAt, UpdatedAt: value.UpdatedAt, CompletedAt: value.CompletedAt}
}
func SCUMWorkflowStepsFromDomain(values []domain.SCUMWorkflowStep) SCUMWorkflowStepListResponse {
items := make([]SCUMWorkflowStepResponse, len(values))
for index, value := range values {
items[index] = SCUMWorkflowStepFromDomain(value)
}
return SCUMWorkflowStepListResponse{Items: items, Count: len(items)}
}
func scumMutationGuardFromDomain(value domain.SCUMMutationGuard) SCUMMutationGuardBody {
return SCUMMutationGuardBody{FieldKey: value.FieldKey, Before: value.Before, After: value.After, MaxRowsAffected: value.MaxRowsAffected, SafetyWindow: value.SafetyWindow, BackupRef: value.BackupRef, RequiresOfflinePlayer: value.RequiresOfflinePlayer, RequiresMaintenance: value.RequiresMaintenance, RequiresBackup: value.RequiresBackup}
}
func scumMutationGuardToDomain(value SCUMMutationGuardBody) domain.SCUMMutationGuard {
return domain.SCUMMutationGuard{FieldKey: value.FieldKey, Before: value.Before, After: value.After, MaxRowsAffected: value.MaxRowsAffected, SafetyWindow: value.SafetyWindow, BackupRef: value.BackupRef, RequiresOfflinePlayer: value.RequiresOfflinePlayer, RequiresMaintenance: value.RequiresMaintenance, RequiresBackup: value.RequiresBackup}
}
func scumOperationConfirmationFromDomain(value domain.SCUMOperationConfirmation) SCUMOperationConfirmationBody {
value = domain.CopySCUMOperationConfirmation(value)
return SCUMOperationConfirmationBody{Status: value.Status, ObservationID: value.ObservationID, ConfirmedFields: value.ConfirmedFields, AffectedRows: value.AffectedRows, MutationChecksum: value.MutationChecksum, Checksum: value.Checksum, ObservedAt: value.ObservedAt, SafeSummary: SCUMSafeSummaryFromDomain(value.SafeSummary)}
}
func scumOperationConfirmationToDomain(value SCUMOperationConfirmationBody) domain.SCUMOperationConfirmation {
return domain.SCUMOperationConfirmation{Status: value.Status, ObservationID: value.ObservationID, ConfirmedFields: domain.CopyGameClientBridgePayload(value.ConfirmedFields), AffectedRows: value.AffectedRows, MutationChecksum: value.MutationChecksum, Checksum: value.Checksum, ObservedAt: value.ObservedAt, SafeSummary: scumSafeSummaryToDomain(value.SafeSummary)}
}
+2 -1
View File
@@ -67,11 +67,12 @@ Autonomous lifecycle reports use `POST /api/v1/run/lifecycle/report` with the ac
## Log Ingest ## Log Ingest
SCUM-specific read/write execution requirements are defined in `platform/protocol/scum-run-integration.md`. The implementation still belongs to the independent run repository and uses the generic signed job/log channels described here.
Implemented HTTP JSON routes: Implemented HTTP JSON routes:
- `POST /api/v1/run/logs/batches` - `POST /api/v1/run/logs/batches`
- `POST /api/v1/log-streams/query` - `POST /api/v1/log-streams/query`
- `GET /api/v1/server-instances/{id}/logs/events`
Named log DTOs: Named log DTOs:
+67
View File
@@ -0,0 +1,67 @@
# SCUM Run Integration Contract
This repository defines the platform/plugin side of SCUM real-data operations. The executable machine-side implementation belongs in the independent `git@git.npc0.com:admin343/run.git` repository and must not be added here.
## Ownership Boundary
- Platform owns server instances, authorization, audit, local projections, typed operation/workflow records, idempotency, approval state, and safe browser APIs.
- The SCUM plugin owns query template keys, operation template keys, result schemas, safety rules, confirmation schemas, and lifecycle action assets.
- Run owns local machine execution beside the current SCUM service: locating the declared logical SCUM.db/log/RCON targets from its scoped package, executing bounded jobs, and returning typed results through existing signed job channels.
Run must never send host paths, DSNs, sockets, credentials, raw SQL, raw RCON text, or protected request bodies to browser/product APIs. Platform persists only safe job metadata, projection rows, checksums, confirmation summaries, and audit references.
## Read Observation Jobs
Run must implement plugin-declared SQLite read templates for the current server binding and return rows matching the referenced schema files under `plugins/examples/scum-server-plugin/schemas/bridge/queries/`.
Required template keys:
| Key | Required behavior |
| --- | --- |
| `scum.player.profile` | Read player identity, profile ID, optional Steam/user ID, character/prisoner fields, economy balances, squad summary, and current coordinates where available. |
| `scum.squads` | Read squad IDs, names, leader/profile references, and bounded member counts. |
| `scum.squad-members` | Read roster membership, ranks, player/profile references, and unknown fields without fabricating missing identities. |
| `scum.vehicles` | Read vehicle/entity rows and coordinates; unknown class/name mappings remain unknown. |
| `scum.flags` | Read base flag/entity ownership, squad/player confidence, and coordinates where available. |
| `scum.positions` | Read current player, vehicle, and flag coordinate projections. |
Each successful result must include the server binding, template key, observed time, monotonically comparable sequence, row count within manifest bounds, and `sha256:<hex>` checksum. Failures must return safe error codes such as missing database, locked database, schema mismatch, timeout, or row-bound exceeded; platform will mark affected projections stale while keeping last-known-good records.
Login/logout evidence comes from plugin-declared log sources. A login line can create/update a local player/session projection; `last_save_time` is only freshness evidence and must not be treated as online-state proof by itself.
## Controlled Write Jobs
Run must execute only typed operations declared by the SCUM plugin manifest.
| Operation key | Transport | Required behavior |
| --- | --- | --- |
| `player.fame.set` | RCON | Use the declared command template for fame and confirm through follow-up readback. |
| `player.currency.normal.set` | RCON | Use the declared command template for normal currency and confirm through follow-up readback. |
| `player.currency.gold.set` | RCON | Use the declared command template for gold and confirm through follow-up readback. |
| `player.notify` | RCON/declared notification command | Deliver bounded player notification text and report unknown if delivery cannot be proven. |
| `reward.deliver` | Declared reward transport | Deliver catalogued reward/notification only once per idempotency key and confirmation state. |
| `player.attribute.855.set` | SQLite mutation | Execute the declared DB-only mutation with before-value guard, max affected rows = 1, maintenance/offline evidence, backup/snapshot reference, and confirmation query. |
RCON-supported fame/currency writes must not be converted to DB mutations. DB-only mutations must fail safely when the current value differs from the approved `before` value, the affected row bound is exceeded, backup evidence is missing, or the player safety state is online/unknown.
## Result And Confirmation Contract
Run job results for SCUM reads, RCON writes, and SQLite mutations must return:
- `kind` identifying the declared result type.
- `checksum` as `sha256:<64 hex chars>`.
- Bounded JSON content matching the plugin result/confirmation schema.
- `affectedRows` for mutations and zero/one row confirmation details where applicable.
- A safe audit summary that excludes raw SQL, raw RCON text, SCUM.db paths, host paths, tokens, sockets, and credentials.
If execution may have happened but confirmation is missing, run should report an unknown/pending-confirmation state rather than success. Platform will read back before retrying so gifts, currency, fame, and DB fields are not duplicated or overwritten.
## External Run Tasks
The independent run repository needs implementation work for:
1. Resolve package-scoped logical SCUM.db and log targets from the generated run plan without exposing resolved host paths to Platform Web.
2. Execute the six declared SQLite read templates with row/time bounds and schema-compatible JSON rows.
3. Execute typed RCON operation templates for fame, currency, notification, and reward delivery without accepting arbitrary browser command text.
4. Execute `player.attribute.855.set` through a guarded SQLite mutation with backup, maintenance/offline checks, before-value match, affected-row bound, and confirmation read.
5. Report observation failures and write unknown states with safe codes and checksums so platform projections and workflows can reconcile deterministically.
+2 -4
View File
@@ -51,15 +51,13 @@ A server instance is created from one installed game management plugin and is la
- `POST /api/v1/server-instances/workflows/create` validates an installed plugin, server name, idempotency key, and plugin-declared create inputs when provided. It creates the instance without requiring a deployment target, run endpoint, or runtime profile. Generated Run packages carry the autonomous lifecycle plan that Run consumes on startup; registration confirms binding/auth and does not enqueue bootstrap lifecycle jobs. - `POST /api/v1/server-instances/workflows/create` validates an installed plugin, server name, idempotency key, and plugin-declared create inputs when provided. It creates the instance without requiring a deployment target, run endpoint, or runtime profile. Generated Run packages carry the autonomous lifecycle plan that Run consumes on startup; registration confirms binding/auth and does not enqueue bootstrap lifecycle jobs.
- `POST /api/v1/server-instances/{id}/start` validates the instance is `ready` or `stopped`, checks the expected config version, verifies the plugin start action and run endpoint `process.start` capability, and queues a start job. - `POST /api/v1/server-instances/{id}/start` validates the instance is `ready` or `stopped`, checks the expected config version, verifies the plugin start action and run endpoint `process.start` capability, and queues a start job.
- `POST /api/v1/server-instances/{id}/stop` validates the instance is `running`, checks the expected config version, verifies the plugin stop action and run endpoint `process.stop` capability, and queues a stop job. - `POST /api/v1/server-instances/{id}/stop` validates the instance is `running`, checks the expected config version, verifies the plugin stop action and run endpoint `process.stop` capability, and queues a stop job.
- `GET /api/v1/server-instances/{id}/config` returns logical read-only config content for an authorized server instance with config version, format, key, source, and update timestamp metadata. - Server-scoped raw config read/diff/approve routes are not product APIs. AI-assisted configuration uses reviewable AI config-diff approvals and typed dispatch metadata without returning raw config text to plugin pages.
- `POST /api/v1/server-instances/{id}/config/diff` validates an authorized proposed config write against the current config version and returns a bounded platform diff without queuing work.
- `POST /api/v1/server-instances/{id}/config/approve` revalidates an explicitly reviewed config diff and queues a scoped `config.write` job using a logical config key and input ref.
- `POST /api/v1/file-operations/dispatch` queues scoped `files.read` or `files.write` jobs for logical server/plugin file keys after role and permission checks. - `POST /api/v1/file-operations/dispatch` queues scoped `files.read` or `files.write` jobs for logical server/plugin file keys after role and permission checks.
- `GET /api/v1/metrics/server-instances` returns bounded per-server metrics for instances visible to the authenticated user. - `GET /api/v1/metrics/server-instances` returns bounded per-server metrics for instances visible to the authenticated user.
Workflow route responses include the accepted action, bounded server instance metadata, and bounded job metadata. They do not expose run session tokens, host paths, raw credentials, direct sockets, AI provider keys, or plugin action file contents. Workflow route responses include the accepted action, bounded server instance metadata, and bounded job metadata. They do not expose run session tokens, host paths, raw credentials, direct sockets, AI provider keys, or plugin action file contents.
Config read and server metrics responses are also bounded and platform-mediated. They do not expose host filesystem paths, run sockets, raw credentials, direct storage backends, or AI provider keys. Server metrics responses are bounded and platform-mediated. They do not expose host filesystem paths, run sockets, raw credentials, direct storage backends, or AI provider keys.
Config write approval and file dispatch are platform-mediated. They carry logical keys such as `server.properties` or `logs/latest.log`, scoped refs such as `input://...` or `artifact://...`, and bounded job metadata only. They do not mutate local files in the platform process and do not expose raw host paths, run credentials, direct sockets, AI provider keys, or inline large payloads. Config write approval and file dispatch are platform-mediated. They carry logical keys such as `server.properties` or `logs/latest.log`, scoped refs such as `input://...` or `artifact://...`, and bounded job metadata only. They do not mutate local files in the platform process and do not expose raw host paths, run credentials, direct sockets, AI provider keys, or inline large payloads.
+51 -1
View File
@@ -53,6 +53,16 @@ type StoreSnapshot struct {
GameGiftCatalogs []domain.GameGiftCatalog `json:"gameGiftCatalogs"` GameGiftCatalogs []domain.GameGiftCatalog `json:"gameGiftCatalogs"`
GameGiftRevisions []domain.GameGiftRevision `json:"gameGiftRevisions"` GameGiftRevisions []domain.GameGiftRevision `json:"gameGiftRevisions"`
GameGiftGrants []domain.GameGiftGrant `json:"gameGiftGrants"` GameGiftGrants []domain.GameGiftGrant `json:"gameGiftGrants"`
SCUMDataObservations []domain.SCUMDataObservation `json:"scumDataObservations"`
SCUMPlayerLiveStates []domain.SCUMPlayerLiveState `json:"scumPlayerLiveStates"`
SCUMSquads []domain.SCUMSquad `json:"scumSquads"`
SCUMSquadMembers []domain.SCUMSquadMember `json:"scumSquadMembers"`
SCUMVehicles []domain.SCUMVehicle `json:"scumVehicles"`
SCUMFlags []domain.SCUMFlag `json:"scumFlags"`
SCUMCurrentPositions []domain.SCUMCurrentPosition `json:"scumCurrentPositions"`
SCUMOperationRequests []domain.SCUMOperationRequest `json:"scumOperationRequests"`
SCUMWorkflowInstances []domain.SCUMWorkflowInstance `json:"scumWorkflowInstances"`
SCUMWorkflowSteps []domain.SCUMWorkflowStep `json:"scumWorkflowSteps"`
} }
type FileStore struct { type FileStore struct {
@@ -234,6 +244,36 @@ func (store *FileStore) GameGiftRevisions() GameGiftRevisionRepository {
func (store *FileStore) GameGiftGrants() GameGiftGrantRepository { func (store *FileStore) GameGiftGrants() GameGiftGrantRepository {
return &persistentRepository[domain.GameGiftGrant, domain.GameGiftGrantFilter]{repository: store.MemoryStore.gameGiftGrants, persist: store.persist} return &persistentRepository[domain.GameGiftGrant, domain.GameGiftGrantFilter]{repository: store.MemoryStore.gameGiftGrants, persist: store.persist}
} }
func (store *FileStore) SCUMDataObservations() SCUMDataObservationRepository {
return &persistentRepository[domain.SCUMDataObservation, domain.SCUMProjectionFilter]{repository: store.MemoryStore.scumDataObservations, persist: store.persist}
}
func (store *FileStore) SCUMPlayerLiveStates() SCUMPlayerLiveStateRepository {
return &persistentRepository[domain.SCUMPlayerLiveState, domain.SCUMProjectionFilter]{repository: store.MemoryStore.scumPlayerLiveStates, persist: store.persist}
}
func (store *FileStore) SCUMSquads() SCUMSquadRepository {
return &persistentRepository[domain.SCUMSquad, domain.SCUMProjectionFilter]{repository: store.MemoryStore.scumSquads, persist: store.persist}
}
func (store *FileStore) SCUMSquadMembers() SCUMSquadMemberRepository {
return &persistentRepository[domain.SCUMSquadMember, domain.SCUMProjectionFilter]{repository: store.MemoryStore.scumSquadMembers, persist: store.persist}
}
func (store *FileStore) SCUMVehicles() SCUMVehicleRepository {
return &persistentRepository[domain.SCUMVehicle, domain.SCUMProjectionFilter]{repository: store.MemoryStore.scumVehicles, persist: store.persist}
}
func (store *FileStore) SCUMFlags() SCUMFlagRepository {
return &persistentRepository[domain.SCUMFlag, domain.SCUMProjectionFilter]{repository: store.MemoryStore.scumFlags, persist: store.persist}
}
func (store *FileStore) SCUMCurrentPositions() SCUMCurrentPositionRepository {
return &persistentRepository[domain.SCUMCurrentPosition, domain.SCUMProjectionFilter]{repository: store.MemoryStore.scumCurrentPositions, persist: store.persist}
}
func (store *FileStore) SCUMOperationRequests() SCUMOperationRequestRepository {
return &persistentRepository[domain.SCUMOperationRequest, domain.SCUMOperationRequestFilter]{repository: store.MemoryStore.scumOperationRequests, persist: store.persist}
}
func (store *FileStore) SCUMWorkflowInstances() SCUMWorkflowInstanceRepository {
return &persistentRepository[domain.SCUMWorkflowInstance, domain.SCUMWorkflowInstanceFilter]{repository: store.MemoryStore.scumWorkflowInstances, persist: store.persist}
}
func (store *FileStore) SCUMWorkflowSteps() SCUMWorkflowStepRepository {
return &persistentRepository[domain.SCUMWorkflowStep, domain.SCUMWorkflowStepFilter]{repository: store.MemoryStore.scumWorkflowSteps, persist: store.persist}
}
func (store *FileStore) load() error { func (store *FileStore) load() error {
data, err := os.ReadFile(store.path) data, err := os.ReadFile(store.path)
@@ -307,7 +347,7 @@ func (store *FileStore) snapshot() StoreSnapshot {
GameClientBridgeCommands: snapshotRepository(store.MemoryStore.bridgeCommands.memoryRepository), GameClientBridgeCommands: snapshotRepository(store.MemoryStore.bridgeCommands.memoryRepository),
GameClientBridgeSnapshots: snapshotRepository(store.MemoryStore.bridgeSnapshots.memoryRepository), GameClientBridgeSnapshots: snapshotRepository(store.MemoryStore.bridgeSnapshots.memoryRepository),
GameClientBridgeStreams: snapshotRepository(store.MemoryStore.bridgeStreams), GameClientBridgeStreams: snapshotRepository(store.MemoryStore.bridgeStreams),
GamePlayers: snapshotRepository(store.MemoryStore.gamePlayers), GamePlayerAliases: snapshotRepository(store.MemoryStore.gamePlayerAliases), GamePlayerSessions: snapshotRepository(store.MemoryStore.gamePlayerSessions), GameAccessAttempts: snapshotRepository(store.MemoryStore.gameAccessAttempts), GameSecuritySignals: snapshotRepository(store.MemoryStore.gameSecuritySignals), GamePlayerStatePatches: snapshotRepository(store.MemoryStore.gamePlayerStatePatches), GameMapTrackPoints: snapshotRepository(store.MemoryStore.gameMapTrackPoints), GamePlayerVehicleSegments: snapshotRepository(store.MemoryStore.gamePlayerVehicleSegments), GameGiftCatalogs: snapshotRepository(store.MemoryStore.gameGiftCatalogs), GameGiftRevisions: snapshotRepository(store.MemoryStore.gameGiftRevisions), GameGiftGrants: snapshotRepository(store.MemoryStore.gameGiftGrants), GamePlayers: snapshotRepository(store.MemoryStore.gamePlayers), GamePlayerAliases: snapshotRepository(store.MemoryStore.gamePlayerAliases), GamePlayerSessions: snapshotRepository(store.MemoryStore.gamePlayerSessions), GameAccessAttempts: snapshotRepository(store.MemoryStore.gameAccessAttempts), GameSecuritySignals: snapshotRepository(store.MemoryStore.gameSecuritySignals), GamePlayerStatePatches: snapshotRepository(store.MemoryStore.gamePlayerStatePatches), GameMapTrackPoints: snapshotRepository(store.MemoryStore.gameMapTrackPoints), GamePlayerVehicleSegments: snapshotRepository(store.MemoryStore.gamePlayerVehicleSegments), GameGiftCatalogs: snapshotRepository(store.MemoryStore.gameGiftCatalogs), GameGiftRevisions: snapshotRepository(store.MemoryStore.gameGiftRevisions), GameGiftGrants: snapshotRepository(store.MemoryStore.gameGiftGrants), SCUMDataObservations: snapshotRepository(store.MemoryStore.scumDataObservations), SCUMPlayerLiveStates: snapshotRepository(store.MemoryStore.scumPlayerLiveStates), SCUMSquads: snapshotRepository(store.MemoryStore.scumSquads), SCUMSquadMembers: snapshotRepository(store.MemoryStore.scumSquadMembers), SCUMVehicles: snapshotRepository(store.MemoryStore.scumVehicles), SCUMFlags: snapshotRepository(store.MemoryStore.scumFlags), SCUMCurrentPositions: snapshotRepository(store.MemoryStore.scumCurrentPositions), SCUMOperationRequests: snapshotRepository(store.MemoryStore.scumOperationRequests), SCUMWorkflowInstances: snapshotRepository(store.MemoryStore.scumWorkflowInstances), SCUMWorkflowSteps: snapshotRepository(store.MemoryStore.scumWorkflowSteps),
} }
} }
@@ -352,6 +392,16 @@ func (store *FileStore) loadSnapshot(snapshot StoreSnapshot) {
loadRepository(store.MemoryStore.gameGiftCatalogs, snapshot.GameGiftCatalogs) loadRepository(store.MemoryStore.gameGiftCatalogs, snapshot.GameGiftCatalogs)
loadRepository(store.MemoryStore.gameGiftRevisions, snapshot.GameGiftRevisions) loadRepository(store.MemoryStore.gameGiftRevisions, snapshot.GameGiftRevisions)
loadRepository(store.MemoryStore.gameGiftGrants, snapshot.GameGiftGrants) loadRepository(store.MemoryStore.gameGiftGrants, snapshot.GameGiftGrants)
loadRepository(store.MemoryStore.scumDataObservations, snapshot.SCUMDataObservations)
loadRepository(store.MemoryStore.scumPlayerLiveStates, snapshot.SCUMPlayerLiveStates)
loadRepository(store.MemoryStore.scumSquads, snapshot.SCUMSquads)
loadRepository(store.MemoryStore.scumSquadMembers, snapshot.SCUMSquadMembers)
loadRepository(store.MemoryStore.scumVehicles, snapshot.SCUMVehicles)
loadRepository(store.MemoryStore.scumFlags, snapshot.SCUMFlags)
loadRepository(store.MemoryStore.scumCurrentPositions, snapshot.SCUMCurrentPositions)
loadRepository(store.MemoryStore.scumOperationRequests, snapshot.SCUMOperationRequests)
loadRepository(store.MemoryStore.scumWorkflowInstances, snapshot.SCUMWorkflowInstances)
loadRepository(store.MemoryStore.scumWorkflowSteps, snapshot.SCUMWorkflowSteps)
} }
type mutableRepository[T any, F any] interface { type mutableRepository[T any, F any] interface {
+41 -1
View File
@@ -204,6 +204,36 @@ func (store *MySQLStore) GameGiftRevisions() GameGiftRevisionRepository {
func (store *MySQLStore) GameGiftGrants() GameGiftGrantRepository { func (store *MySQLStore) GameGiftGrants() GameGiftGrantRepository {
return &persistentRepository[domain.GameGiftGrant, domain.GameGiftGrantFilter]{repository: store.MemoryStore.gameGiftGrants, persist: store.persist} return &persistentRepository[domain.GameGiftGrant, domain.GameGiftGrantFilter]{repository: store.MemoryStore.gameGiftGrants, persist: store.persist}
} }
func (store *MySQLStore) SCUMDataObservations() SCUMDataObservationRepository {
return &persistentRepository[domain.SCUMDataObservation, domain.SCUMProjectionFilter]{repository: store.MemoryStore.scumDataObservations, persist: store.persist}
}
func (store *MySQLStore) SCUMPlayerLiveStates() SCUMPlayerLiveStateRepository {
return &persistentRepository[domain.SCUMPlayerLiveState, domain.SCUMProjectionFilter]{repository: store.MemoryStore.scumPlayerLiveStates, persist: store.persist}
}
func (store *MySQLStore) SCUMSquads() SCUMSquadRepository {
return &persistentRepository[domain.SCUMSquad, domain.SCUMProjectionFilter]{repository: store.MemoryStore.scumSquads, persist: store.persist}
}
func (store *MySQLStore) SCUMSquadMembers() SCUMSquadMemberRepository {
return &persistentRepository[domain.SCUMSquadMember, domain.SCUMProjectionFilter]{repository: store.MemoryStore.scumSquadMembers, persist: store.persist}
}
func (store *MySQLStore) SCUMVehicles() SCUMVehicleRepository {
return &persistentRepository[domain.SCUMVehicle, domain.SCUMProjectionFilter]{repository: store.MemoryStore.scumVehicles, persist: store.persist}
}
func (store *MySQLStore) SCUMFlags() SCUMFlagRepository {
return &persistentRepository[domain.SCUMFlag, domain.SCUMProjectionFilter]{repository: store.MemoryStore.scumFlags, persist: store.persist}
}
func (store *MySQLStore) SCUMCurrentPositions() SCUMCurrentPositionRepository {
return &persistentRepository[domain.SCUMCurrentPosition, domain.SCUMProjectionFilter]{repository: store.MemoryStore.scumCurrentPositions, persist: store.persist}
}
func (store *MySQLStore) SCUMOperationRequests() SCUMOperationRequestRepository {
return &persistentRepository[domain.SCUMOperationRequest, domain.SCUMOperationRequestFilter]{repository: store.MemoryStore.scumOperationRequests, persist: store.persist}
}
func (store *MySQLStore) SCUMWorkflowInstances() SCUMWorkflowInstanceRepository {
return &persistentRepository[domain.SCUMWorkflowInstance, domain.SCUMWorkflowInstanceFilter]{repository: store.MemoryStore.scumWorkflowInstances, persist: store.persist}
}
func (store *MySQLStore) SCUMWorkflowSteps() SCUMWorkflowStepRepository {
return &persistentRepository[domain.SCUMWorkflowStep, domain.SCUMWorkflowStepFilter]{repository: store.MemoryStore.scumWorkflowSteps, persist: store.persist}
}
func (store *MySQLStore) initialize() error { func (store *MySQLStore) initialize() error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
@@ -294,7 +324,7 @@ func (store *MySQLStore) snapshot() StoreSnapshot {
GameClientBridgeCommands: snapshotRepository(store.MemoryStore.bridgeCommands.memoryRepository), GameClientBridgeCommands: snapshotRepository(store.MemoryStore.bridgeCommands.memoryRepository),
GameClientBridgeSnapshots: snapshotRepository(store.MemoryStore.bridgeSnapshots.memoryRepository), GameClientBridgeSnapshots: snapshotRepository(store.MemoryStore.bridgeSnapshots.memoryRepository),
GameClientBridgeStreams: snapshotRepository(store.MemoryStore.bridgeStreams), GameClientBridgeStreams: snapshotRepository(store.MemoryStore.bridgeStreams),
GamePlayers: snapshotRepository(store.MemoryStore.gamePlayers), GamePlayerAliases: snapshotRepository(store.MemoryStore.gamePlayerAliases), GamePlayerSessions: snapshotRepository(store.MemoryStore.gamePlayerSessions), GameAccessAttempts: snapshotRepository(store.MemoryStore.gameAccessAttempts), GameSecuritySignals: snapshotRepository(store.MemoryStore.gameSecuritySignals), GamePlayerStatePatches: snapshotRepository(store.MemoryStore.gamePlayerStatePatches), GameMapTrackPoints: snapshotRepository(store.MemoryStore.gameMapTrackPoints), GamePlayerVehicleSegments: snapshotRepository(store.MemoryStore.gamePlayerVehicleSegments), GameGiftCatalogs: snapshotRepository(store.MemoryStore.gameGiftCatalogs), GameGiftRevisions: snapshotRepository(store.MemoryStore.gameGiftRevisions), GameGiftGrants: snapshotRepository(store.MemoryStore.gameGiftGrants), GamePlayers: snapshotRepository(store.MemoryStore.gamePlayers), GamePlayerAliases: snapshotRepository(store.MemoryStore.gamePlayerAliases), GamePlayerSessions: snapshotRepository(store.MemoryStore.gamePlayerSessions), GameAccessAttempts: snapshotRepository(store.MemoryStore.gameAccessAttempts), GameSecuritySignals: snapshotRepository(store.MemoryStore.gameSecuritySignals), GamePlayerStatePatches: snapshotRepository(store.MemoryStore.gamePlayerStatePatches), GameMapTrackPoints: snapshotRepository(store.MemoryStore.gameMapTrackPoints), GamePlayerVehicleSegments: snapshotRepository(store.MemoryStore.gamePlayerVehicleSegments), GameGiftCatalogs: snapshotRepository(store.MemoryStore.gameGiftCatalogs), GameGiftRevisions: snapshotRepository(store.MemoryStore.gameGiftRevisions), GameGiftGrants: snapshotRepository(store.MemoryStore.gameGiftGrants), SCUMDataObservations: snapshotRepository(store.MemoryStore.scumDataObservations), SCUMPlayerLiveStates: snapshotRepository(store.MemoryStore.scumPlayerLiveStates), SCUMSquads: snapshotRepository(store.MemoryStore.scumSquads), SCUMSquadMembers: snapshotRepository(store.MemoryStore.scumSquadMembers), SCUMVehicles: snapshotRepository(store.MemoryStore.scumVehicles), SCUMFlags: snapshotRepository(store.MemoryStore.scumFlags), SCUMCurrentPositions: snapshotRepository(store.MemoryStore.scumCurrentPositions), SCUMOperationRequests: snapshotRepository(store.MemoryStore.scumOperationRequests), SCUMWorkflowInstances: snapshotRepository(store.MemoryStore.scumWorkflowInstances), SCUMWorkflowSteps: snapshotRepository(store.MemoryStore.scumWorkflowSteps),
} }
} }
@@ -339,4 +369,14 @@ func (store *MySQLStore) loadSnapshot(snapshot StoreSnapshot) {
loadRepository(store.MemoryStore.gameGiftCatalogs, snapshot.GameGiftCatalogs) loadRepository(store.MemoryStore.gameGiftCatalogs, snapshot.GameGiftCatalogs)
loadRepository(store.MemoryStore.gameGiftRevisions, snapshot.GameGiftRevisions) loadRepository(store.MemoryStore.gameGiftRevisions, snapshot.GameGiftRevisions)
loadRepository(store.MemoryStore.gameGiftGrants, snapshot.GameGiftGrants) loadRepository(store.MemoryStore.gameGiftGrants, snapshot.GameGiftGrants)
loadRepository(store.MemoryStore.scumDataObservations, snapshot.SCUMDataObservations)
loadRepository(store.MemoryStore.scumPlayerLiveStates, snapshot.SCUMPlayerLiveStates)
loadRepository(store.MemoryStore.scumSquads, snapshot.SCUMSquads)
loadRepository(store.MemoryStore.scumSquadMembers, snapshot.SCUMSquadMembers)
loadRepository(store.MemoryStore.scumVehicles, snapshot.SCUMVehicles)
loadRepository(store.MemoryStore.scumFlags, snapshot.SCUMFlags)
loadRepository(store.MemoryStore.scumCurrentPositions, snapshot.SCUMCurrentPositions)
loadRepository(store.MemoryStore.scumOperationRequests, snapshot.SCUMOperationRequests)
loadRepository(store.MemoryStore.scumWorkflowInstances, snapshot.SCUMWorkflowInstances)
loadRepository(store.MemoryStore.scumWorkflowSteps, snapshot.SCUMWorkflowSteps)
} }
+211
View File
@@ -295,6 +295,66 @@ type GameGiftGrantRepository interface {
List(domain.GameGiftGrantFilter) ([]domain.GameGiftGrant, error) List(domain.GameGiftGrantFilter) ([]domain.GameGiftGrant, error)
Update(domain.GameGiftGrant) error Update(domain.GameGiftGrant) error
} }
type SCUMDataObservationRepository interface {
Create(domain.SCUMDataObservation) error
Get(string) (domain.SCUMDataObservation, error)
List(domain.SCUMProjectionFilter) ([]domain.SCUMDataObservation, error)
Update(domain.SCUMDataObservation) error
}
type SCUMPlayerLiveStateRepository interface {
Create(domain.SCUMPlayerLiveState) error
Get(string) (domain.SCUMPlayerLiveState, error)
List(domain.SCUMProjectionFilter) ([]domain.SCUMPlayerLiveState, error)
Update(domain.SCUMPlayerLiveState) error
}
type SCUMSquadRepository interface {
Create(domain.SCUMSquad) error
Get(string) (domain.SCUMSquad, error)
List(domain.SCUMProjectionFilter) ([]domain.SCUMSquad, error)
Update(domain.SCUMSquad) error
}
type SCUMSquadMemberRepository interface {
Create(domain.SCUMSquadMember) error
Get(string) (domain.SCUMSquadMember, error)
List(domain.SCUMProjectionFilter) ([]domain.SCUMSquadMember, error)
Update(domain.SCUMSquadMember) error
}
type SCUMVehicleRepository interface {
Create(domain.SCUMVehicle) error
Get(string) (domain.SCUMVehicle, error)
List(domain.SCUMProjectionFilter) ([]domain.SCUMVehicle, error)
Update(domain.SCUMVehicle) error
}
type SCUMFlagRepository interface {
Create(domain.SCUMFlag) error
Get(string) (domain.SCUMFlag, error)
List(domain.SCUMProjectionFilter) ([]domain.SCUMFlag, error)
Update(domain.SCUMFlag) error
}
type SCUMCurrentPositionRepository interface {
Create(domain.SCUMCurrentPosition) error
Get(string) (domain.SCUMCurrentPosition, error)
List(domain.SCUMProjectionFilter) ([]domain.SCUMCurrentPosition, error)
Update(domain.SCUMCurrentPosition) error
}
type SCUMOperationRequestRepository interface {
Create(domain.SCUMOperationRequest) error
Get(string) (domain.SCUMOperationRequest, error)
List(domain.SCUMOperationRequestFilter) ([]domain.SCUMOperationRequest, error)
Update(domain.SCUMOperationRequest) error
}
type SCUMWorkflowInstanceRepository interface {
Create(domain.SCUMWorkflowInstance) error
Get(string) (domain.SCUMWorkflowInstance, error)
List(domain.SCUMWorkflowInstanceFilter) ([]domain.SCUMWorkflowInstance, error)
Update(domain.SCUMWorkflowInstance) error
}
type SCUMWorkflowStepRepository interface {
Create(domain.SCUMWorkflowStep) error
Get(string) (domain.SCUMWorkflowStep, error)
List(domain.SCUMWorkflowStepFilter) ([]domain.SCUMWorkflowStep, error)
Update(domain.SCUMWorkflowStep) error
}
type Store interface { type Store interface {
Users() UserRepository Users() UserRepository
@@ -337,6 +397,16 @@ type Store interface {
GameGiftCatalogs() GameGiftCatalogRepository GameGiftCatalogs() GameGiftCatalogRepository
GameGiftRevisions() GameGiftRevisionRepository GameGiftRevisions() GameGiftRevisionRepository
GameGiftGrants() GameGiftGrantRepository GameGiftGrants() GameGiftGrantRepository
SCUMDataObservations() SCUMDataObservationRepository
SCUMPlayerLiveStates() SCUMPlayerLiveStateRepository
SCUMSquads() SCUMSquadRepository
SCUMSquadMembers() SCUMSquadMemberRepository
SCUMVehicles() SCUMVehicleRepository
SCUMFlags() SCUMFlagRepository
SCUMCurrentPositions() SCUMCurrentPositionRepository
SCUMOperationRequests() SCUMOperationRequestRepository
SCUMWorkflowInstances() SCUMWorkflowInstanceRepository
SCUMWorkflowSteps() SCUMWorkflowStepRepository
} }
type MemoryStore struct { type MemoryStore struct {
@@ -380,6 +450,16 @@ type MemoryStore struct {
gameGiftCatalogs *memoryRepository[domain.GameGiftCatalog, domain.GameGiftCatalogFilter] gameGiftCatalogs *memoryRepository[domain.GameGiftCatalog, domain.GameGiftCatalogFilter]
gameGiftRevisions *memoryRepository[domain.GameGiftRevision, domain.GameGiftRevisionFilter] gameGiftRevisions *memoryRepository[domain.GameGiftRevision, domain.GameGiftRevisionFilter]
gameGiftGrants *memoryRepository[domain.GameGiftGrant, domain.GameGiftGrantFilter] gameGiftGrants *memoryRepository[domain.GameGiftGrant, domain.GameGiftGrantFilter]
scumDataObservations *memoryRepository[domain.SCUMDataObservation, domain.SCUMProjectionFilter]
scumPlayerLiveStates *memoryRepository[domain.SCUMPlayerLiveState, domain.SCUMProjectionFilter]
scumSquads *memoryRepository[domain.SCUMSquad, domain.SCUMProjectionFilter]
scumSquadMembers *memoryRepository[domain.SCUMSquadMember, domain.SCUMProjectionFilter]
scumVehicles *memoryRepository[domain.SCUMVehicle, domain.SCUMProjectionFilter]
scumFlags *memoryRepository[domain.SCUMFlag, domain.SCUMProjectionFilter]
scumCurrentPositions *memoryRepository[domain.SCUMCurrentPosition, domain.SCUMProjectionFilter]
scumOperationRequests *memoryRepository[domain.SCUMOperationRequest, domain.SCUMOperationRequestFilter]
scumWorkflowInstances *memoryRepository[domain.SCUMWorkflowInstance, domain.SCUMWorkflowInstanceFilter]
scumWorkflowSteps *memoryRepository[domain.SCUMWorkflowStep, domain.SCUMWorkflowStepFilter]
} }
func NewMemoryStore() *MemoryStore { func NewMemoryStore() *MemoryStore {
@@ -528,6 +608,16 @@ func NewMemoryStore() *MemoryStore {
gameGiftCatalogs: newMemoryRepository(func(v domain.GameGiftCatalog) string { return v.ID }, domain.CopyGameGiftCatalog, matchGameGiftCatalog), gameGiftCatalogs: newMemoryRepository(func(v domain.GameGiftCatalog) string { return v.ID }, domain.CopyGameGiftCatalog, matchGameGiftCatalog),
gameGiftRevisions: newMemoryRepository(func(v domain.GameGiftRevision) string { return v.ID }, domain.CopyGameGiftRevision, matchGameGiftRevision), gameGiftRevisions: newMemoryRepository(func(v domain.GameGiftRevision) string { return v.ID }, domain.CopyGameGiftRevision, matchGameGiftRevision),
gameGiftGrants: newMemoryRepository(func(v domain.GameGiftGrant) string { return v.ID }, domain.CopyGameGiftGrant, matchGameGiftGrant), gameGiftGrants: newMemoryRepository(func(v domain.GameGiftGrant) string { return v.ID }, domain.CopyGameGiftGrant, matchGameGiftGrant),
scumDataObservations: newMemoryRepository(func(v domain.SCUMDataObservation) string { return v.ID }, domain.CopySCUMDataObservation, matchSCUMDataObservation),
scumPlayerLiveStates: newMemoryRepository(func(v domain.SCUMPlayerLiveState) string { return v.ID }, domain.CopySCUMPlayerLiveState, matchSCUMPlayerLiveState),
scumSquads: newMemoryRepository(func(v domain.SCUMSquad) string { return v.ID }, domain.CopySCUMSquad, matchSCUMSquad),
scumSquadMembers: newMemoryRepository(func(v domain.SCUMSquadMember) string { return v.ID }, domain.CopySCUMSquadMember, matchSCUMSquadMember),
scumVehicles: newMemoryRepository(func(v domain.SCUMVehicle) string { return v.ID }, domain.CopySCUMVehicle, matchSCUMVehicle),
scumFlags: newMemoryRepository(func(v domain.SCUMFlag) string { return v.ID }, domain.CopySCUMFlag, matchSCUMFlag),
scumCurrentPositions: newMemoryRepository(func(v domain.SCUMCurrentPosition) string { return v.ID }, domain.CopySCUMCurrentPosition, matchSCUMCurrentPosition),
scumOperationRequests: newMemoryRepository(func(v domain.SCUMOperationRequest) string { return v.ID }, domain.CopySCUMOperationRequest, matchSCUMOperationRequest),
scumWorkflowInstances: newMemoryRepository(func(v domain.SCUMWorkflowInstance) string { return v.ID }, domain.CopySCUMWorkflowInstance, matchSCUMWorkflowInstance),
scumWorkflowSteps: newMemoryRepository(func(v domain.SCUMWorkflowStep) string { return v.ID }, domain.CopySCUMWorkflowStep, matchSCUMWorkflowStep),
} }
} }
@@ -607,6 +697,30 @@ func (store *MemoryStore) GameGiftRevisions() GameGiftRevisionRepository {
return store.gameGiftRevisions return store.gameGiftRevisions
} }
func (store *MemoryStore) GameGiftGrants() GameGiftGrantRepository { return store.gameGiftGrants } func (store *MemoryStore) GameGiftGrants() GameGiftGrantRepository { return store.gameGiftGrants }
func (store *MemoryStore) SCUMDataObservations() SCUMDataObservationRepository {
return store.scumDataObservations
}
func (store *MemoryStore) SCUMPlayerLiveStates() SCUMPlayerLiveStateRepository {
return store.scumPlayerLiveStates
}
func (store *MemoryStore) SCUMSquads() SCUMSquadRepository { return store.scumSquads }
func (store *MemoryStore) SCUMSquadMembers() SCUMSquadMemberRepository {
return store.scumSquadMembers
}
func (store *MemoryStore) SCUMVehicles() SCUMVehicleRepository { return store.scumVehicles }
func (store *MemoryStore) SCUMFlags() SCUMFlagRepository { return store.scumFlags }
func (store *MemoryStore) SCUMCurrentPositions() SCUMCurrentPositionRepository {
return store.scumCurrentPositions
}
func (store *MemoryStore) SCUMOperationRequests() SCUMOperationRequestRepository {
return store.scumOperationRequests
}
func (store *MemoryStore) SCUMWorkflowInstances() SCUMWorkflowInstanceRepository {
return store.scumWorkflowInstances
}
func (store *MemoryStore) SCUMWorkflowSteps() SCUMWorkflowStepRepository {
return store.scumWorkflowSteps
}
type memoryRepository[T any, F any] struct { type memoryRepository[T any, F any] struct {
mu sync.RWMutex mu sync.RWMutex
@@ -941,3 +1055,100 @@ func matchGameGiftRevision(v domain.GameGiftRevision, f domain.GameGiftRevisionF
func matchGameGiftGrant(v domain.GameGiftGrant, f domain.GameGiftGrantFilter) bool { func matchGameGiftGrant(v domain.GameGiftGrant, f domain.GameGiftGrantFilter) bool {
return (f.ServerInstanceID == "" || v.ServerInstanceID == f.ServerInstanceID) && (f.GamePlayerRecordID == "" || v.GamePlayerRecordID == f.GamePlayerRecordID) && (f.IdempotencyKey == "" || v.IdempotencyKey == f.IdempotencyKey) return (f.ServerInstanceID == "" || v.ServerInstanceID == f.ServerInstanceID) && (f.GamePlayerRecordID == "" || v.GamePlayerRecordID == f.GamePlayerRecordID) && (f.IdempotencyKey == "" || v.IdempotencyKey == f.IdempotencyKey)
} }
func matchSCUMDataObservation(v domain.SCUMDataObservation, f domain.SCUMProjectionFilter) bool {
return (f.ServerInstanceID == "" || v.ServerInstanceID == f.ServerInstanceID) &&
(f.SubjectType == "" || v.SubjectType == string(f.SubjectType)) &&
(f.GamePlayerRecordID == "" || v.SubjectID == f.GamePlayerRecordID) &&
(f.QueryKey == "" || v.QueryKey == f.QueryKey) &&
(f.Freshness == "" || domain.SCUMProjectionFreshness(v.Status) == f.Freshness)
}
func matchSCUMPlayerLiveState(v domain.SCUMPlayerLiveState, f domain.SCUMProjectionFilter) bool {
search := strings.ToLower(strings.TrimSpace(f.Search))
return (f.ServerInstanceID == "" || v.ServerInstanceID == f.ServerInstanceID) &&
(f.GamePlayerID == "" || v.GamePlayerID == f.GamePlayerID) &&
(f.GamePlayerRecordID == "" || v.GamePlayerRecordID == f.GamePlayerRecordID) &&
(f.UserProfileID == "" || v.UserProfileID == f.UserProfileID) &&
(f.SteamID == "" || v.SteamID == f.SteamID) &&
(f.SquadID == "" || v.SquadID == f.SquadID) &&
(f.Freshness == "" || v.Freshness.Status == f.Freshness) &&
(search == "" || strings.Contains(strings.ToLower(v.DisplayName), search) || strings.Contains(strings.ToLower(v.GamePlayerID), search) || strings.Contains(strings.ToLower(v.UserProfileID), search) || strings.Contains(strings.ToLower(v.SteamID), search))
}
func matchSCUMSquad(v domain.SCUMSquad, f domain.SCUMProjectionFilter) bool {
search := strings.ToLower(strings.TrimSpace(f.Search))
return (f.ServerInstanceID == "" || v.ServerInstanceID == f.ServerInstanceID) &&
(f.SquadID == "" || v.SquadID == f.SquadID) &&
(f.UserProfileID == "" || v.LeaderProfileID == f.UserProfileID) &&
(f.Freshness == "" || v.Freshness.Status == f.Freshness) &&
(search == "" || strings.Contains(strings.ToLower(v.Name), search) || strings.Contains(strings.ToLower(v.SquadID), search))
}
func matchSCUMSquadMember(v domain.SCUMSquadMember, f domain.SCUMProjectionFilter) bool {
search := strings.ToLower(strings.TrimSpace(f.Search))
return (f.ServerInstanceID == "" || v.ServerInstanceID == f.ServerInstanceID) &&
(f.SquadID == "" || v.SquadID == f.SquadID) &&
(f.GamePlayerID == "" || v.GamePlayerID == f.GamePlayerID) &&
(f.GamePlayerRecordID == "" || v.GamePlayerRecordID == f.GamePlayerRecordID) &&
(f.UserProfileID == "" || v.UserProfileID == f.UserProfileID) &&
(f.SteamID == "" || v.SteamID == f.SteamID) &&
(f.Freshness == "" || v.Freshness.Status == f.Freshness) &&
(search == "" || strings.Contains(strings.ToLower(v.DisplayName), search) || strings.Contains(strings.ToLower(v.GamePlayerID), search) || strings.Contains(strings.ToLower(v.UserProfileID), search))
}
func matchSCUMVehicle(v domain.SCUMVehicle, f domain.SCUMProjectionFilter) bool {
search := strings.ToLower(strings.TrimSpace(f.Search))
return (f.ServerInstanceID == "" || v.ServerInstanceID == f.ServerInstanceID) &&
(f.VehicleID == "" || v.VehicleID == f.VehicleID) &&
(f.UserProfileID == "" || v.OwnerProfileID == f.UserProfileID) &&
(f.GamePlayerID == "" || v.OwnerPlayerID == f.GamePlayerID) &&
(f.SquadID == "" || v.SquadID == f.SquadID) &&
(f.Freshness == "" || v.Freshness.Status == f.Freshness) &&
(search == "" || strings.Contains(strings.ToLower(v.Label), search) || strings.Contains(strings.ToLower(v.ClassName), search) || strings.Contains(strings.ToLower(v.VehicleID), search))
}
func matchSCUMFlag(v domain.SCUMFlag, f domain.SCUMProjectionFilter) bool {
return (f.ServerInstanceID == "" || v.ServerInstanceID == f.ServerInstanceID) &&
(f.FlagID == "" || v.FlagID == f.FlagID) &&
(f.UserProfileID == "" || v.OwnerProfileID == f.UserProfileID) &&
(f.GamePlayerID == "" || v.OwnerPlayerID == f.GamePlayerID) &&
(f.SquadID == "" || v.OwnerSquadID == f.SquadID) &&
(f.Freshness == "" || v.Freshness.Status == f.Freshness)
}
func matchSCUMCurrentPosition(v domain.SCUMCurrentPosition, f domain.SCUMProjectionFilter) bool {
return (f.ServerInstanceID == "" || v.ServerInstanceID == f.ServerInstanceID) &&
(f.SubjectType == "" || v.SubjectType == f.SubjectType) &&
(f.GamePlayerID == "" || v.GamePlayerID == f.GamePlayerID) &&
(f.GamePlayerRecordID == "" || v.GamePlayerRecordID == f.GamePlayerRecordID) &&
(f.VehicleID == "" || v.VehicleID == f.VehicleID) &&
(f.Freshness == "" || v.Freshness.Status == f.Freshness)
}
func matchSCUMOperationRequest(v domain.SCUMOperationRequest, f domain.SCUMOperationRequestFilter) bool {
return (f.ServerInstanceID == "" || v.ServerInstanceID == f.ServerInstanceID) &&
(f.PluginID == "" || v.PluginID == f.PluginID) &&
(f.TemplateKey == "" || v.TemplateKey == f.TemplateKey) &&
(f.PlayerID == "" || v.PlayerID == f.PlayerID) &&
(f.RequesterID == "" || v.RequesterID == f.RequesterID) &&
(f.Status == "" || v.Status == f.Status) &&
(f.IdempotencyKey == "" || v.IdempotencyKey == f.IdempotencyKey)
}
func matchSCUMWorkflowInstance(v domain.SCUMWorkflowInstance, f domain.SCUMWorkflowInstanceFilter) bool {
return (f.ServerInstanceID == "" || v.ServerInstanceID == f.ServerInstanceID) &&
(f.PluginID == "" || v.PluginID == f.PluginID) &&
(f.TemplateKey == "" || v.TemplateKey == f.TemplateKey) &&
(f.RequestedBy == "" || v.RequestedBy == f.RequestedBy) &&
(f.Status == "" || v.Status == f.Status) &&
(f.IdempotencyKey == "" || v.IdempotencyKey == f.IdempotencyKey)
}
func matchSCUMWorkflowStep(v domain.SCUMWorkflowStep, f domain.SCUMWorkflowStepFilter) bool {
return (f.WorkflowID == "" || v.WorkflowID == f.WorkflowID) &&
(f.ServerInstanceID == "" || v.ServerInstanceID == f.ServerInstanceID) &&
(f.StepKey == "" || v.StepKey == f.StepKey) &&
(f.Status == "" || v.Status == f.Status) &&
(f.MutatesState == nil || v.MutatesState == *f.MutatesState)
}
+67
View File
@@ -0,0 +1,67 @@
package repo
import (
"path/filepath"
"testing"
"time"
"browser.local/platform/domain"
)
func TestSCUMProjectionRepositoriesCopyFilterAndPersist(t *testing.T) {
path := filepath.Join(t.TempDir(), "metadata.json")
store, err := NewFileStore(path)
if err != nil {
t.Fatalf("create file store: %v", err)
}
stamp := time.Date(2026, 8, 10, 9, 0, 0, 0, time.UTC)
freshness := domain.SCUMProjectionFreshnessState{Status: domain.SCUMProjectionFresh, ObservationID: "obs-1", Source: "run", QueryKey: "scum.player.profile", Sequence: 7, Checksum: "sha256:projection", ObservedAt: stamp, ReceivedAt: stamp.Add(time.Second)}
state := domain.SCUMPlayerLiveState{ID: "state-1", ServerInstanceID: "server-1", GamePlayerRecordID: "game-player-1", GamePlayerID: "steam-1", UserProfileID: "profile-1", SteamID: "steam-1", DisplayName: "Moon", SquadID: "squad-1", UnknownFields: map[string]any{"schemaField": "kept"}, Freshness: freshness, CreatedAt: stamp, UpdatedAt: stamp}
if err := store.SCUMPlayerLiveStates().Create(state); err != nil {
t.Fatalf("create state: %v", err)
}
got, err := store.SCUMPlayerLiveStates().Get(state.ID)
if err != nil {
t.Fatalf("get state: %v", err)
}
got.UnknownFields["schemaField"] = "mutated"
again, err := store.SCUMPlayerLiveStates().Get(state.ID)
if err != nil {
t.Fatalf("get state again: %v", err)
}
if again.UnknownFields["schemaField"] != "kept" {
t.Fatalf("state was not copy-isolated: %+v", again.UnknownFields)
}
filtered, err := store.SCUMPlayerLiveStates().List(domain.SCUMProjectionFilter{ServerInstanceID: "server-1", UserProfileID: "profile-1", Search: "moon"})
if err != nil || len(filtered) != 1 {
t.Fatalf("filter states=%+v err=%v", filtered, err)
}
if err := store.SCUMSquads().Create(domain.SCUMSquad{ID: "squad-1", ServerInstanceID: "server-1", SquadID: "squad-1", Name: "Crystal", Freshness: freshness}); err != nil {
t.Fatalf("create squad: %v", err)
}
if err := store.SCUMVehicles().Create(domain.SCUMVehicle{ID: "vehicle-1", ServerInstanceID: "server-1", VehicleID: "veh-1", Label: "Unknown vehicle", Freshness: freshness}); err != nil {
t.Fatalf("create vehicle: %v", err)
}
if err := store.SCUMFlags().Create(domain.SCUMFlag{ID: "flag-1", ServerInstanceID: "server-1", FlagID: "flag-1", OwnerSquadID: "squad-1", Freshness: freshness}); err != nil {
t.Fatalf("create flag: %v", err)
}
if err := store.SCUMCurrentPositions().Create(domain.SCUMCurrentPosition{ID: "position-1", ServerInstanceID: "server-1", SubjectType: domain.SCUMProjectionSubjectPlayer, SubjectID: "steam-1", GamePlayerRecordID: "game-player-1", X: 1, Y: 2, HasCoordinates: true, Freshness: freshness}); err != nil {
t.Fatalf("create position: %v", err)
}
reloaded, err := NewFileStore(path)
if err != nil {
t.Fatalf("reload file store: %v", err)
}
reloadedStates, err := reloaded.SCUMPlayerLiveStates().List(domain.SCUMProjectionFilter{ServerInstanceID: "server-1", SquadID: "squad-1"})
if err != nil || len(reloadedStates) != 1 || reloadedStates[0].Freshness.QueryKey != "scum.player.profile" {
t.Fatalf("unexpected reloaded states=%+v err=%v", reloadedStates, err)
}
vehicles, err := reloaded.SCUMVehicles().List(domain.SCUMProjectionFilter{ServerInstanceID: "server-1", Search: "unknown"})
if err != nil || len(vehicles) != 1 {
t.Fatalf("unexpected reloaded vehicles=%+v err=%v", vehicles, err)
}
positions, err := reloaded.SCUMCurrentPositions().List(domain.SCUMProjectionFilter{ServerInstanceID: "server-1", SubjectType: domain.SCUMProjectionSubjectPlayer})
if err != nil || len(positions) != 1 || !positions[0].HasCoordinates {
t.Fatalf("unexpected reloaded positions=%+v err=%v", positions, err)
}
}
+6
View File
@@ -125,6 +125,9 @@ func (svc *CoreService) projectGamePlayerEvent(batch domain.LogBatchIngest, entr
if eventType == "scum.login" { if eventType == "scum.login" {
outcome := strings.TrimSpace(fields["outcome"]) outcome := strings.TrimSpace(fields["outcome"])
if outcome == "accepted" { if outcome == "accepted" {
if err := svc.projectSCUMLoginLiveState(player, batch, entry, occurred, true, ""); err != nil {
return err
}
if err := svc.recordSuccessfulGameAccess(player, batch, entry, occurred, strings.TrimSpace(fields["networkFingerprint"])); err != nil { if err := svc.recordSuccessfulGameAccess(player, batch, entry, occurred, strings.TrimSpace(fields["networkFingerprint"])); err != nil {
return err return err
} }
@@ -132,6 +135,9 @@ func (svc *CoreService) projectGamePlayerEvent(batch domain.LogBatchIngest, entr
} }
return svc.recordFailedGameAccess(player, batch, entry, occurred, strings.TrimSpace(fields["networkFingerprint"])) return svc.recordFailedGameAccess(player, batch, entry, occurred, strings.TrimSpace(fields["networkFingerprint"]))
} }
if err := svc.projectSCUMLoginLiveState(player, batch, entry, occurred, false, strings.TrimSpace(fields["reason"])); err != nil {
return err
}
return svc.closeGamePlayerSession(player, sourceSession, occurred, strings.TrimSpace(fields["reason"])) return svc.closeGamePlayerSession(player, sourceSession, occurred, strings.TrimSpace(fields["reason"]))
} }
+14
View File
@@ -225,6 +225,20 @@ type Core interface {
RequestGameGiftGrantForSession(string, string, domain.GameGiftGrantRequest) (domain.GameGiftGrant, error) RequestGameGiftGrantForSession(string, string, domain.GameGiftGrantRequest) (domain.GameGiftGrant, error)
ApproveGameGiftGrantForSession(string, string) (domain.GameGiftGrant, error) ApproveGameGiftGrantForSession(string, string) (domain.GameGiftGrant, error)
ListGameGiftGrantsForSession(string, string) ([]domain.GameGiftGrant, error) ListGameGiftGrantsForSession(string, string) ([]domain.GameGiftGrant, error)
ListSCUMPlayerLiveStatesForSession(string, domain.SCUMProjectionFilter) ([]domain.SCUMPlayerLiveState, error)
ListSCUMSquadsForSession(string, domain.SCUMProjectionFilter) ([]domain.SCUMSquad, error)
ListSCUMSquadMembersForSession(string, domain.SCUMProjectionFilter) ([]domain.SCUMSquadMember, error)
ListSCUMVehiclesForSession(string, domain.SCUMProjectionFilter) ([]domain.SCUMVehicle, error)
ListSCUMFlagsForSession(string, domain.SCUMProjectionFilter) ([]domain.SCUMFlag, error)
ListSCUMCurrentPositionsForSession(string, domain.SCUMProjectionFilter) ([]domain.SCUMCurrentPosition, error)
RequestSCUMOperationForSession(string, string, domain.SCUMOperationRequest) (domain.SCUMOperationRequest, error)
ListSCUMOperationsForSession(string, domain.SCUMOperationRequestFilter) ([]domain.SCUMOperationRequest, error)
ApproveSCUMOperationForSession(string, string) (domain.SCUMOperationRequest, error)
ReconcileSCUMOperation(string) (domain.SCUMOperationRequest, error)
ConfirmSCUMOperation(string, domain.SCUMOperationConfirmation) (domain.SCUMOperationRequest, error)
CreateSCUMWorkflowForSession(string, string, domain.SCUMWorkflowInstance) (domain.SCUMWorkflowInstance, error)
ListSCUMWorkflowsForSession(string, domain.SCUMWorkflowInstanceFilter) ([]domain.SCUMWorkflowInstance, error)
ListSCUMWorkflowStepsForSession(string, domain.SCUMWorkflowStepFilter) ([]domain.SCUMWorkflowStep, error)
CreateAuditEvent(domain.AuditEvent) (domain.AuditEvent, error) CreateAuditEvent(domain.AuditEvent) (domain.AuditEvent, error)
GetAuditEvent(string) (domain.AuditEvent, error) GetAuditEvent(string) (domain.AuditEvent, error)
ListAuditEvents(domain.AuditEventFilter) ([]domain.AuditEvent, error) ListAuditEvents(domain.AuditEventFilter) ([]domain.AuditEvent, error)
+770
View File
@@ -0,0 +1,770 @@
package service
import (
"encoding/json"
"fmt"
"strconv"
"strings"
"browser.local/platform/domain"
"browser.local/platform/repo"
)
type scumSQLiteMutationJobResult struct {
Outcome string `json:"outcome"`
AffectedRows int `json:"affectedRows"`
MutationChecksum string `json:"mutationChecksum"`
ConfirmationRows []map[string]any `json:"confirmationRows"`
SafeMessage string `json:"safeMessage"`
}
func (svc *CoreService) RequestSCUMOperationForSession(sessionID, serverID string, request domain.SCUMOperationRequest) (domain.SCUMOperationRequest, error) {
request = domain.CopySCUMOperationRequest(request)
user, err := svc.GetCurrentUser(sessionID)
if err != nil {
return domain.SCUMOperationRequest{}, err
}
if err := svc.authorizeServerLifecycle(sessionID, serverID); err != nil {
return domain.SCUMOperationRequest{}, err
}
instance, err := svc.store.ServerInstances().Get(serverID)
if err != nil {
return domain.SCUMOperationRequest{}, err
}
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
if err != nil {
return domain.SCUMOperationRequest{}, err
}
template, ok := scumOperationTemplate(plugin, request.TemplateKey)
if !ok {
return domain.SCUMOperationRequest{}, validationError("SCUM operation template is not declared")
}
if !containsString(plugin.DeclaredPermissions, template.Permission) {
return domain.SCUMOperationRequest{}, validationError("SCUM operation permission is not declared")
}
if strings.TrimSpace(request.IdempotencyKey) == "" || len(request.IdempotencyKey) > 120 {
return domain.SCUMOperationRequest{}, validationError("operation idempotency key is required")
}
existing, err := svc.store.SCUMOperationRequests().List(domain.SCUMOperationRequestFilter{ServerInstanceID: serverID, IdempotencyKey: request.IdempotencyKey})
if err != nil {
return domain.SCUMOperationRequest{}, err
}
if len(existing) > 0 {
return domain.CopySCUMOperationRequest(existing[0]), nil
}
playerID := coalesceString(request.PlayerID, firstString(request.Payload, "playerId", "steamId"))
if playerID == "" && request.TemplateKey != "server.reward.command.deliver" {
return domain.SCUMOperationRequest{}, validationError("operation playerId is required")
}
summary := operationSafeSummary(request.TemplateKey, playerID, request.Payload)
switch template.Kind {
case domain.GameClientBridgeOperationKindRCON:
if err := validateSCUMRCONOperationPayload(request.TemplateKey, playerID, request.Payload); err != nil {
return domain.SCUMOperationRequest{}, err
}
case domain.GameClientBridgeOperationKindSQLiteMutation:
guard, payload, err := normalizeSCUMSQLiteMutationRequest(template, playerID, request.Payload, request.Guard)
if err != nil {
return domain.SCUMOperationRequest{}, err
}
request.Guard = guard
request.Payload = payload
summary = scumSQLiteMutationSafeSummary(request.TemplateKey, playerID, guard)
default:
return domain.SCUMOperationRequest{}, validationError("SCUM operation kind is unsupported")
}
stamp := svc.now()
operation := domain.SCUMOperationRequest{ID: "scum-operation-" + fingerprintID(serverID, request.IdempotencyKey), ServerInstanceID: serverID, PluginID: instance.PluginID, TemplateKey: request.TemplateKey, PlayerID: playerID, RequesterID: user.ID, ApprovalLevel: template.ApprovalLevel, Payload: domain.CopyGameClientBridgePayload(request.Payload), Guard: request.Guard, Status: domain.SCUMWorkflowStepWaiting, Reason: bounded(request.Reason, 240), IdempotencyKey: request.IdempotencyKey, SafeSummary: summary, CreatedAt: stamp, UpdatedAt: stamp}
if err := svc.store.SCUMOperationRequests().Create(operation); err != nil {
return domain.SCUMOperationRequest{}, err
}
_, err = svc.recordAuditEventWithID(user.ID, "scum.operation.request", "scum-operation", operation.ID, domain.AuditResultQueued, "typed SCUM operation awaiting approval")
return domain.CopySCUMOperationRequest(operation), err
}
func (svc *CoreService) ListSCUMOperationsForSession(sessionID string, filter domain.SCUMOperationRequestFilter) ([]domain.SCUMOperationRequest, error) {
if err := svc.authorizeServerLifecycle(sessionID, filter.ServerInstanceID); err != nil {
return nil, err
}
values, err := svc.store.SCUMOperationRequests().List(filter)
if err != nil {
return nil, err
}
limitSCUMProjectionSlice(&values, filter.Limit)
return values, nil
}
func (svc *CoreService) ApproveSCUMOperationForSession(sessionID, operationID string) (domain.SCUMOperationRequest, error) {
operation, err := svc.store.SCUMOperationRequests().Get(operationID)
if err != nil {
return domain.SCUMOperationRequest{}, err
}
user, err := svc.GetCurrentUser(sessionID)
if err != nil {
return domain.SCUMOperationRequest{}, err
}
if err := svc.authorizeServerLifecycle(sessionID, operation.ServerInstanceID); err != nil {
return domain.SCUMOperationRequest{}, err
}
if operation.ApprovalLevel == domain.GameClientBridgeApprovalLevelPlatformAdmin && !isPlatformAdmin(user) {
return domain.SCUMOperationRequest{}, ErrForbidden
}
if operation.Status != domain.SCUMWorkflowStepWaiting {
return domain.SCUMOperationRequest{}, validationError("SCUM operation is not awaiting approval")
}
instance, err := svc.store.ServerInstances().Get(operation.ServerInstanceID)
if err != nil {
return domain.SCUMOperationRequest{}, err
}
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
if err != nil {
return domain.SCUMOperationRequest{}, err
}
template, ok := scumOperationTemplate(plugin, operation.TemplateKey)
if !ok {
return domain.SCUMOperationRequest{}, validationError("SCUM operation template is not declared")
}
var jobID string
var auditSummary string
switch template.Kind {
case domain.GameClientBridgeOperationKindRCON:
request, err := svc.sourceRCONRequestForSCUMOperation(operation)
if err != nil {
return domain.SCUMOperationRequest{}, err
}
dispatch, err := svc.DispatchSourceRCONCommandForSession(sessionID, request)
if err != nil {
return domain.SCUMOperationRequest{}, err
}
jobID = dispatch.JobID
auditSummary = "typed SCUM operation dispatched through transient RCON input"
case domain.GameClientBridgeOperationKindSQLiteMutation:
gated, ready, err := svc.applySCUMSQLiteMutationApprovalGate(operation, template)
if err != nil || !ready {
return gated, err
}
operation = gated
job, err := svc.dispatchSCUMSQLiteMutationOperation(operation, template)
if err != nil {
return domain.SCUMOperationRequest{}, err
}
jobID = job.ID
auditSummary = "typed SCUM DB mutation dispatched through template-bound Run job"
default:
return domain.SCUMOperationRequest{}, validationError("SCUM operation kind is unsupported")
}
stamp := svc.now()
operation.ApproverID = user.ID
operation.ApprovedAt = stamp
operation.Status = domain.SCUMWorkflowStepQueued
operation.RunJobID = jobID
operation.UpdatedAt = stamp
operation.AuditReferences = append(operation.AuditReferences, "job:"+jobID)
if err := svc.store.SCUMOperationRequests().Update(operation); err != nil {
return domain.SCUMOperationRequest{}, err
}
_, err = svc.recordAuditEventWithID(user.ID, "scum.operation.approve", "scum-operation", operation.ID, domain.AuditResultQueued, auditSummary)
return domain.CopySCUMOperationRequest(operation), err
}
func (svc *CoreService) ReconcileSCUMOperation(operationID string) (domain.SCUMOperationRequest, error) {
operation, err := svc.store.SCUMOperationRequests().Get(operationID)
if err != nil {
return domain.SCUMOperationRequest{}, err
}
if strings.TrimSpace(operation.RunJobID) == "" {
return domain.CopySCUMOperationRequest(operation), nil
}
job, err := svc.store.Jobs().Get(operation.RunJobID)
if err != nil {
return domain.SCUMOperationRequest{}, err
}
instance, err := svc.store.ServerInstances().Get(operation.ServerInstanceID)
if err != nil {
return domain.SCUMOperationRequest{}, err
}
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
if err != nil {
return domain.SCUMOperationRequest{}, err
}
template, _ := scumOperationTemplate(plugin, operation.TemplateKey)
stamp := svc.now()
switch job.State {
case domain.JobStateSucceeded:
if template.Kind == domain.GameClientBridgeOperationKindSQLiteMutation {
if updated, terminal := reconcileSCUMSQLiteMutationJobResult(operation, template, job); terminal {
operation = updated
} else {
operation = updated
operation.Status = domain.SCUMWorkflowStepConfirming
}
} else if operation.Confirmation.Status == "confirmed" {
operation.Status = domain.SCUMWorkflowStepConfirmed
} else {
operation.Status = domain.SCUMWorkflowStepConfirming
}
case domain.JobStateFailed:
if strings.Contains(strings.ToLower(job.ExecutionResult.Kind), "unknown") || strings.Contains(strings.ToLower(job.ExecutionResult.AuditSummary), "unknown") {
operation.Status = domain.SCUMWorkflowStepUnknown
} else {
operation.Status = domain.SCUMWorkflowStepFailed
}
operation.CompletedAt = stamp
case domain.JobStateCancelled:
operation.Status = domain.SCUMWorkflowStepUnknown
operation.CompletedAt = stamp
}
operation.UpdatedAt = stamp
if (operation.Status == domain.SCUMWorkflowStepConfirmed || operation.Status == domain.SCUMWorkflowStepFailed || operation.Status == domain.SCUMWorkflowStepUnknown) && operation.CompletedAt.IsZero() {
operation.CompletedAt = stamp
}
if err := svc.store.SCUMOperationRequests().Update(operation); err != nil {
return domain.SCUMOperationRequest{}, err
}
return domain.CopySCUMOperationRequest(operation), nil
}
func (svc *CoreService) ConfirmSCUMOperation(operationID string, confirmation domain.SCUMOperationConfirmation) (domain.SCUMOperationRequest, error) {
operation, err := svc.store.SCUMOperationRequests().Get(operationID)
if err != nil {
return domain.SCUMOperationRequest{}, err
}
instance, err := svc.store.ServerInstances().Get(operation.ServerInstanceID)
if err != nil {
return domain.SCUMOperationRequest{}, err
}
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
if err != nil {
return domain.SCUMOperationRequest{}, err
}
template, _ := scumOperationTemplate(plugin, operation.TemplateKey)
confirmation = domain.CopySCUMOperationConfirmation(confirmation)
stamp := svc.now()
if confirmation.Status != "confirmed" {
operation.Status = domain.SCUMWorkflowStepFailed
operation.Confirmation = confirmation
operation.CompletedAt = stamp
operation.UpdatedAt = stamp
if err := svc.store.SCUMOperationRequests().Update(operation); err != nil {
return domain.SCUMOperationRequest{}, err
}
return domain.CopySCUMOperationRequest(operation), nil
}
if template.Kind == domain.GameClientBridgeOperationKindSQLiteMutation && !scumSQLiteMutationConfirmationMatches(operation, confirmation.ConfirmedFields) {
confirmation.Status = "failed"
confirmation.SafeSummary = domain.SCUMSafeSummary{Title: "DB mutation confirmation mismatch", Message: "Run readback did not prove the requested SCUM player field value."}
operation.Status = domain.SCUMWorkflowStepFailed
operation.Confirmation = confirmation
operation.CompletedAt = stamp
operation.UpdatedAt = stamp
if err := svc.store.SCUMOperationRequests().Update(operation); err != nil {
return domain.SCUMOperationRequest{}, err
}
return domain.CopySCUMOperationRequest(operation), nil
}
operation.Confirmation = confirmation
operation.Status = domain.SCUMWorkflowStepConfirmed
operation.CompletedAt = stamp
operation.UpdatedAt = stamp
if err := svc.store.SCUMOperationRequests().Update(operation); err != nil {
return domain.SCUMOperationRequest{}, err
}
return domain.CopySCUMOperationRequest(operation), nil
}
func (svc *CoreService) sourceRCONRequestForSCUMOperation(operation domain.SCUMOperationRequest) (domain.SourceRCONCommandRequest, error) {
command, chat, err := scumRCONCommandForOperation(operation)
if err != nil {
return domain.SourceRCONCommandRequest{}, err
}
request := domain.SourceRCONCommandRequest{ServerInstanceID: operation.ServerInstanceID, IdempotencyKey: "scum-operation-" + operation.IdempotencyKey}
if chat != "" {
request.Kind = domain.SourceRCONCommandKindChat
request.ChatType = 4
request.TargetSteamID = operation.PlayerID
request.Message = chat
return request, nil
}
request.Kind = domain.SourceRCONCommandKindCommand
request.Command = command
return request, nil
}
func scumRCONCommandForOperation(operation domain.SCUMOperationRequest) (command string, chat string, err error) {
playerID := operation.PlayerID
switch operation.TemplateKey {
case "player.fame.set":
amount, ok := operationInteger(operation.Payload, "fame", "amount", "value")
if !ok {
return "", "", validationError("fame amount is required")
}
return fmt.Sprintf("#SetFamePoints %d %q", amount, playerID), "", nil
case "player.currency.normal.set":
amount, ok := operationInteger(operation.Payload, "amount", "balance", "normalBalance")
if !ok {
return "", "", validationError("normal currency amount is required")
}
return fmt.Sprintf("#SetCurrencyBalance Normal %d %q", amount, playerID), "", nil
case "player.currency.gold.set":
amount, ok := operationInteger(operation.Payload, "amount", "balance", "goldBalance")
if !ok {
return "", "", validationError("gold currency amount is required")
}
return fmt.Sprintf("#SetCurrencyBalance Gold %d %q", amount, playerID), "", nil
case "player.notify":
message := strings.TrimSpace(firstString(operation.Payload, "message", "notice"))
if message == "" || len(message) > 200 {
return "", "", validationError("notification message is required")
}
return "", message, nil
default:
return "", "", validationError("unsupported SCUM RCON operation template")
}
}
func validateSCUMRCONOperationPayload(templateKey, playerID string, payload map[string]any) error {
operation := domain.SCUMOperationRequest{TemplateKey: templateKey, PlayerID: playerID, Payload: payload}
command, chat, err := scumRCONCommandForOperation(operation)
if err != nil {
return err
}
if strings.ContainsAny(command, "\r\n") || strings.ContainsAny(chat, "\r\n") {
return validationError("operation payload contains invalid control characters")
}
return nil
}
func normalizeSCUMSQLiteMutationRequest(template domain.GameClientBridgeOperationTemplateDeclaration, playerID string, payload map[string]any, guard domain.SCUMMutationGuard) (domain.SCUMMutationGuard, map[string]any, error) {
lowerKey := strings.ToLower(template.Key)
if strings.Contains(lowerKey, "fame") || strings.Contains(lowerKey, "currency") {
return domain.SCUMMutationGuard{}, nil, validationError("SCUM fame and currency edits must use RCON operation templates")
}
if template.ApprovalLevel != domain.GameClientBridgeApprovalLevelPlatformAdmin {
return domain.SCUMMutationGuard{}, nil, validationError("SCUM DB mutation requires platform-admin approval")
}
if template.Mutation.FieldKey == "" || template.Mutation.ConfirmationQueryKey == "" || template.Mutation.TableKey == "" || template.Mutation.IdentityKey == "" || template.Mutation.ValueKey == "" {
return domain.SCUMMutationGuard{}, nil, validationError("SCUM DB mutation metadata is incomplete")
}
if template.MaxRowsAffected < 1 {
return domain.SCUMMutationGuard{}, nil, validationError("SCUM DB mutation row bound is required")
}
payload = domain.CopyGameClientBridgePayload(payload)
if guard.FieldKey == "" {
guard.FieldKey = coalesceString(firstString(payload, "fieldKey"), template.Mutation.FieldKey)
}
if guard.Before == nil {
guard.Before = payload["before"]
}
if guard.After == nil {
guard.After = payload["after"]
if guard.After == nil {
guard.After = payload["value"]
}
}
if guard.MaxRowsAffected == 0 {
guard.MaxRowsAffected = template.MaxRowsAffected
}
guard.SafetyWindow = coalesceString(guard.SafetyWindow, firstString(payload, "safetyWindow", "maintenanceWindow"))
guard.BackupRef = coalesceString(guard.BackupRef, firstString(payload, "backupRef", "snapshotRef"))
guard.RequiresOfflinePlayer = template.Safety.RequiresOfflinePlayer
guard.RequiresMaintenance = template.Safety.RequiresMaintenanceWindow
guard.RequiresBackup = template.Safety.BackupRequired
if playerID == "" {
return domain.SCUMMutationGuard{}, nil, validationError("SCUM DB mutation playerId is required")
}
if guard.FieldKey != template.Mutation.FieldKey {
return domain.SCUMMutationGuard{}, nil, validationError("SCUM DB mutation field key does not match template")
}
if guard.Before == nil || guard.After == nil {
return domain.SCUMMutationGuard{}, nil, validationError("SCUM DB mutation before and after values are required")
}
if guard.MaxRowsAffected < 1 || guard.MaxRowsAffected > template.MaxRowsAffected {
return domain.SCUMMutationGuard{}, nil, validationError("SCUM DB mutation maxRowsAffected exceeds template bound")
}
if err := validateSCUMMutationValue(template, guard.Before, "before"); err != nil {
return domain.SCUMMutationGuard{}, nil, err
}
if err := validateSCUMMutationValue(template, guard.After, "after"); err != nil {
return domain.SCUMMutationGuard{}, nil, err
}
for key, value := range map[string]any{"playerId": playerID, "fieldKey": guard.FieldKey, "before": guard.Before, "after": guard.After, "safetyWindow": guard.SafetyWindow, "backupRef": guard.BackupRef} {
if value != nil && value != "" {
payload[key] = value
}
}
return guard, payload, nil
}
func validateSCUMMutationValue(template domain.GameClientBridgeOperationTemplateDeclaration, value any, label string) error {
switch template.Mutation.AllowedValueType {
case "integer":
parsed, ok := anyInt64(value)
if !ok {
return validationError("SCUM DB mutation " + label + " value must be an integer")
}
if template.Mutation.MinValue != 0 && float64(parsed) < template.Mutation.MinValue || template.Mutation.MaxValue != 0 && float64(parsed) > template.Mutation.MaxValue {
return validationError("SCUM DB mutation " + label + " value is outside the template range")
}
case "number":
parsed, ok := anyFloat64(value)
if !ok {
return validationError("SCUM DB mutation " + label + " value must be numeric")
}
if template.Mutation.MinValue != 0 && parsed < template.Mutation.MinValue || template.Mutation.MaxValue != 0 && parsed > template.Mutation.MaxValue {
return validationError("SCUM DB mutation " + label + " value is outside the template range")
}
case "string":
if strings.TrimSpace(fmt.Sprint(value)) == "" || strings.ContainsAny(fmt.Sprint(value), "\r\n") {
return validationError("SCUM DB mutation " + label + " value is invalid")
}
case "boolean":
if _, ok := value.(bool); !ok {
return validationError("SCUM DB mutation " + label + " value must be boolean")
}
default:
return validationError("SCUM DB mutation value type is unsupported")
}
return nil
}
func (svc *CoreService) applySCUMSQLiteMutationApprovalGate(operation domain.SCUMOperationRequest, template domain.GameClientBridgeOperationTemplateDeclaration) (domain.SCUMOperationRequest, bool, error) {
state, err := svc.latestSCUMPlayerLiveState(operation.ServerInstanceID, operation.PlayerID)
if err != nil {
if err == repo.ErrNotFound {
return svc.updateSCUMOperationGate(operation, domain.SCUMWorkflowStepWaiting, "等待真实玩家投影", "需要先从当前服务的登录日志或 SCUM.db 读取玩家数据。")
}
return domain.SCUMOperationRequest{}, false, err
}
if state.Freshness.Status != domain.SCUMProjectionFresh {
return svc.updateSCUMOperationGate(operation, domain.SCUMWorkflowStepWaiting, "等待新鲜投影", "玩家投影不是 fresh,需先刷新 SCUM.db/readback。")
}
if template.Safety.RequiresOfflinePlayer && state.Online {
return svc.updateSCUMOperationGate(operation, domain.SCUMWorkflowStepWaiting, "等待玩家离线", "DB-only 玩家字段修改必须等玩家离线或进入维护窗口。")
}
if template.Safety.RequiresMaintenanceWindow && strings.TrimSpace(operation.Guard.SafetyWindow) == "" {
return svc.updateSCUMOperationGate(operation, domain.SCUMWorkflowStepWaiting, "缺少维护窗口", "DB mutation 需要记录维护窗口/离线安全证据。")
}
if template.Safety.BackupRequired && strings.TrimSpace(operation.Guard.BackupRef) == "" {
return svc.updateSCUMOperationGate(operation, domain.SCUMWorkflowStepWaiting, "缺少备份快照", "DB mutation 需要 run 或管理员提供 backup/snapshot evidence。")
}
current, ok := scumCurrentMutationFieldValue(state, operation.Guard.FieldKey)
if !ok {
return svc.updateSCUMOperationGate(operation, domain.SCUMWorkflowStepWaiting, "等待字段读回", "当前投影没有该 DB-only 字段,需先执行确认查询。")
}
if !scumScalarEqual(current, operation.Guard.Before) {
return svc.updateSCUMOperationGate(operation, domain.SCUMWorkflowStepBlocked, "before value 已过期", "当前投影值与审批时 before guard 不一致,已阻止写入。")
}
return domain.CopySCUMOperationRequest(operation), true, nil
}
func (svc *CoreService) updateSCUMOperationGate(operation domain.SCUMOperationRequest, status domain.SCUMWorkflowStepStatus, title string, message string) (domain.SCUMOperationRequest, bool, error) {
operation.Status = status
operation.SafeSummary = domain.SCUMSafeSummary{Title: title, Message: message, Details: map[string]string{"template": operation.TemplateKey, "playerId": operation.PlayerID}}
operation.UpdatedAt = svc.now()
if err := svc.store.SCUMOperationRequests().Update(operation); err != nil {
return domain.SCUMOperationRequest{}, false, err
}
return domain.CopySCUMOperationRequest(operation), false, nil
}
func (svc *CoreService) latestSCUMPlayerLiveState(serverID, playerID string) (domain.SCUMPlayerLiveState, error) {
states, err := svc.store.SCUMPlayerLiveStates().List(domain.SCUMProjectionFilter{ServerInstanceID: serverID, GamePlayerID: playerID})
if err != nil {
return domain.SCUMPlayerLiveState{}, err
}
if len(states) == 0 {
states, err = svc.store.SCUMPlayerLiveStates().List(domain.SCUMProjectionFilter{ServerInstanceID: serverID, SteamID: playerID})
if err != nil {
return domain.SCUMPlayerLiveState{}, err
}
}
if len(states) == 0 {
return domain.SCUMPlayerLiveState{}, repo.ErrNotFound
}
best := states[0]
for _, state := range states[1:] {
if state.Freshness.ObservedAt.After(best.Freshness.ObservedAt) || state.UpdatedAt.After(best.UpdatedAt) {
best = state
}
}
return domain.CopySCUMPlayerLiveState(best), nil
}
func scumCurrentMutationFieldValue(state domain.SCUMPlayerLiveState, fieldKey string) (any, bool) {
if state.UnknownFields != nil {
for _, key := range []string{fieldKey, "field" + fieldKey, "attribute" + fieldKey, "attribute_" + fieldKey, "stat" + fieldKey, "stat_" + fieldKey} {
if value, ok := state.UnknownFields[key]; ok {
return value, true
}
}
}
return nil, false
}
func (svc *CoreService) dispatchSCUMSQLiteMutationOperation(operation domain.SCUMOperationRequest, template domain.GameClientBridgeOperationTemplateDeclaration) (domain.Job, error) {
instance, err := svc.store.ServerInstances().Get(operation.ServerInstanceID)
if err != nil {
return domain.Job{}, err
}
jobID := jobIDFromParts("job-scum-sqlite-mutation", instance.ID, operation.IdempotencyKey)
job := domain.Job{ID: jobID, ServerInstanceID: instance.ID, RunEndpointID: instance.RunEndpointID, Capability: domain.JobCapabilityRemoteRunProtectedSQL, TargetKey: template.TargetKey, InputRef: "input://scum-operation/" + operation.ID, IdempotencyKey: "scum-sqlite-mutation:" + operation.IdempotencyKey, Progress: domain.JobProgress{Percent: 0, Message: "typed SCUM DB mutation queued"}, RetryPolicy: domain.JobRetryPolicy{MaxAttempts: 1, InitialBackoffSeconds: 1, MaxBackoffSeconds: 1}, ExecutionInput: domain.JobExecutionInput{WorkspaceScope: svc.runtimeProfileScope(instance.ID), RemoteAdapterKey: template.TransportKey, RemoteAdapterKind: "protected-sql", TimeoutSeconds: template.TimeoutSeconds, PluginID: operation.PluginID, Inputs: scumSQLiteMutationJobInputs(operation, template)}}
created, err := svc.CreateJob(job)
if err != nil {
return domain.Job{}, err
}
if created.ID != jobID || created.Capability != domain.JobCapabilityRemoteRunProtectedSQL || created.TargetKey != template.TargetKey || created.ExecutionInput.RemoteAdapterKey != template.TransportKey {
return domain.Job{}, validationError("SCUM DB mutation idempotency key is already bound")
}
return created, nil
}
func scumSQLiteMutationJobInputs(operation domain.SCUMOperationRequest, template domain.GameClientBridgeOperationTemplateDeclaration) map[string]string {
return map[string]string{
"operationId": operation.ID,
"templateKey": operation.TemplateKey,
"playerId": operation.PlayerID,
"fieldKey": operation.Guard.FieldKey,
"tableKey": template.Mutation.TableKey,
"identityKey": template.Mutation.IdentityKey,
"valueKey": template.Mutation.ValueKey,
"before": scumScalarString(operation.Guard.Before),
"after": scumScalarString(operation.Guard.After),
"maxRowsAffected": strconv.Itoa(operation.Guard.MaxRowsAffected),
"confirmationQueryKey": template.Mutation.ConfirmationQueryKey,
"safetyWindow": operation.Guard.SafetyWindow,
"backupRef": operation.Guard.BackupRef,
}
}
func reconcileSCUMSQLiteMutationJobResult(operation domain.SCUMOperationRequest, template domain.GameClientBridgeOperationTemplateDeclaration, job domain.Job) (domain.SCUMOperationRequest, bool) {
result, ok := parseSCUMSQLiteMutationJobResult(job.ExecutionResult.Content)
if !ok || result.Outcome == "unknown" || strings.Contains(strings.ToLower(job.ExecutionResult.Kind), "unknown") {
operation.Status = domain.SCUMWorkflowStepUnknown
operation.Confirmation = domain.SCUMOperationConfirmation{Status: "unknown", SafeSummary: domain.SCUMSafeSummary{Title: "DB mutation state unknown", Message: "Run did not return a valid bounded mutation result."}}
return operation, true
}
operation.Confirmation.AffectedRows = result.AffectedRows
operation.Confirmation.MutationChecksum = result.MutationChecksum
operation.Confirmation.Checksum = coalesceString(operation.Confirmation.Checksum, coalesceString(result.MutationChecksum, job.ExecutionResult.Checksum))
if result.Outcome == "stale-before" {
operation.Status = domain.SCUMWorkflowStepFailed
operation.Confirmation.Status = "failed"
operation.SafeSummary = domain.SCUMSafeSummary{Title: "before value 已过期", Message: "Run 在写入前发现当前 DB 值与 approved before guard 不一致。"}
return operation, true
}
if result.Outcome != "succeeded" || result.AffectedRows < 1 {
operation.Status = domain.SCUMWorkflowStepFailed
operation.Confirmation.Status = "failed"
operation.SafeSummary = domain.SCUMSafeSummary{Title: "DB mutation failed", Message: bounded(coalesceString(result.SafeMessage, "Run reported the mutation did not succeed."), 240)}
return operation, true
}
if result.AffectedRows > template.MaxRowsAffected || result.AffectedRows > operation.Guard.MaxRowsAffected || strings.TrimSpace(result.MutationChecksum) == "" {
operation.Status = domain.SCUMWorkflowStepUnknown
operation.Confirmation.Status = "unknown"
operation.SafeSummary = domain.SCUMSafeSummary{Title: "DB mutation row bound unknown", Message: "Run result exceeded declared row bounds or omitted mutation checksum."}
return operation, true
}
if len(result.ConfirmationRows) > 0 {
for _, row := range result.ConfirmationRows {
if scumSQLiteMutationConfirmationMatches(operation, row) {
operation.Status = domain.SCUMWorkflowStepConfirmed
operation.Confirmation.Status = "confirmed"
operation.Confirmation.ConfirmedFields = domain.CopyGameClientBridgePayload(row)
return operation, true
}
}
operation.Status = domain.SCUMWorkflowStepFailed
operation.Confirmation.Status = "failed"
operation.SafeSummary = domain.SCUMSafeSummary{Title: "DB mutation confirmation mismatch", Message: "Run confirmation rows did not match the requested after value."}
return operation, true
}
operation.Confirmation.Status = "executed"
return operation, false
}
func parseSCUMSQLiteMutationJobResult(content string) (scumSQLiteMutationJobResult, bool) {
if strings.TrimSpace(content) == "" {
return scumSQLiteMutationJobResult{}, false
}
var result scumSQLiteMutationJobResult
if err := json.Unmarshal([]byte(content), &result); err != nil {
return scumSQLiteMutationJobResult{}, false
}
result.Outcome = strings.TrimSpace(result.Outcome)
return result, result.Outcome != ""
}
func scumSQLiteMutationConfirmationMatches(operation domain.SCUMOperationRequest, row map[string]any) bool {
if row == nil {
return false
}
rowPlayerID := firstString(row, "playerId", "gamePlayerId", "steamId", "steam_id")
if rowPlayerID != "" && rowPlayerID != operation.PlayerID {
return false
}
if field := firstString(row, "fieldKey", "field", "attributeKey"); field != "" && field != operation.Guard.FieldKey {
return false
}
for _, key := range []string{"value", "after", operation.Guard.FieldKey, "field" + operation.Guard.FieldKey, "attribute" + operation.Guard.FieldKey, "attribute_" + operation.Guard.FieldKey} {
if value, ok := row[key]; ok && scumScalarEqual(value, operation.Guard.After) {
return true
}
}
return false
}
func scumSQLiteMutationSafeSummary(templateKey, playerID string, guard domain.SCUMMutationGuard) domain.SCUMSafeSummary {
details := map[string]string{"template": templateKey, "fieldKey": guard.FieldKey, "maxRowsAffected": strconv.Itoa(guard.MaxRowsAffected)}
if playerID != "" {
details["playerId"] = playerID
}
if guard.SafetyWindow != "" {
details["safetyWindow"] = guard.SafetyWindow
}
if guard.BackupRef != "" {
details["backupRef"] = guard.BackupRef
}
return domain.SCUMSafeSummary{Title: "Typed SCUM DB mutation", Message: "Run executes this through a declared mutation template with before-value and row-bound guards; raw SQL is not stored.", Details: details}
}
func operationInteger(payload map[string]any, keys ...string) (int64, bool) {
for _, key := range keys {
value, exists := payload[key]
if !exists {
continue
}
switch typed := value.(type) {
case int:
return int64(typed), true
case int64:
return typed, true
case uint64:
if typed > uint64(^uint64(0)>>1) {
return 0, false
}
return int64(typed), true
case float64:
if typed == float64(int64(typed)) {
return int64(typed), true
}
case string:
parsed, err := strconv.ParseInt(strings.TrimSpace(typed), 10, 64)
if err == nil {
return parsed, true
}
}
}
return 0, false
}
func anyInt64(value any) (int64, bool) {
switch typed := value.(type) {
case int:
return int64(typed), true
case int8:
return int64(typed), true
case int16:
return int64(typed), true
case int32:
return int64(typed), true
case int64:
return typed, true
case uint:
return int64(typed), true
case uint8:
return int64(typed), true
case uint16:
return int64(typed), true
case uint32:
return int64(typed), true
case uint64:
if typed > uint64(^uint64(0)>>1) {
return 0, false
}
return int64(typed), true
case float64:
if typed == float64(int64(typed)) {
return int64(typed), true
}
case float32:
if typed == float32(int64(typed)) {
return int64(typed), true
}
case json.Number:
parsed, err := typed.Int64()
return parsed, err == nil
case string:
parsed, err := strconv.ParseInt(strings.TrimSpace(typed), 10, 64)
return parsed, err == nil
}
return 0, false
}
func anyFloat64(value any) (float64, bool) {
switch typed := value.(type) {
case int:
return float64(typed), true
case int64:
return float64(typed), true
case uint64:
return float64(typed), true
case float64:
return typed, true
case float32:
return float64(typed), true
case json.Number:
parsed, err := typed.Float64()
return parsed, err == nil
case string:
parsed, err := strconv.ParseFloat(strings.TrimSpace(typed), 64)
return parsed, err == nil
}
return 0, false
}
func scumScalarEqual(left any, right any) bool {
if leftInt, ok := anyInt64(left); ok {
if rightInt, rightOK := anyInt64(right); rightOK {
return leftInt == rightInt
}
}
if leftFloat, ok := anyFloat64(left); ok {
if rightFloat, rightOK := anyFloat64(right); rightOK {
return leftFloat == rightFloat
}
}
return strings.TrimSpace(fmt.Sprint(left)) == strings.TrimSpace(fmt.Sprint(right))
}
func scumScalarString(value any) string {
if parsed, ok := anyInt64(value); ok {
return strconv.FormatInt(parsed, 10)
}
if parsed, ok := anyFloat64(value); ok {
return strconv.FormatFloat(parsed, 'f', -1, 64)
}
if typed, ok := value.(bool); ok {
return strconv.FormatBool(typed)
}
return bounded(strings.TrimSpace(fmt.Sprint(value)), 512)
}
func operationSafeSummary(templateKey, playerID string, payload map[string]any) domain.SCUMSafeSummary {
details := map[string]string{"template": templateKey}
if playerID != "" {
details["playerId"] = playerID
}
if amount, ok := operationInteger(payload, "fame", "amount", "balance", "value", "normalBalance", "goldBalance"); ok {
details["value"] = fmt.Sprintf("%d", amount)
}
return domain.SCUMSafeSummary{Title: "Typed SCUM operation", Message: "RCON text is generated server-side and is not stored in the operation record.", Details: details}
}
func scumOperationTemplate(plugin domain.GamePlugin, key string) (domain.GameClientBridgeOperationTemplateDeclaration, bool) {
for _, template := range plugin.GameClientBridge.OperationTemplates {
if template.Key == key {
return template, true
}
}
return domain.GameClientBridgeOperationTemplateDeclaration{}, false
}
+273
View File
@@ -0,0 +1,273 @@
package service
import (
"encoding/json"
"strings"
"testing"
"time"
"browser.local/platform/domain"
)
func TestSCUMRCONOperationApprovalDispatchesTransientCommandAndConfirms(t *testing.T) {
svc, session, runSession, instance := newSourceRCONFixture(t)
seedSCUMOperationTemplates(t, svc, instance.PluginID)
request := domain.SCUMOperationRequest{TemplateKey: "player.fame.set", PlayerID: "76561198000000001", Payload: map[string]any{"fame": 123}, Reason: "restore fame", IdempotencyKey: "fame-restore-1"}
operation, err := svc.RequestSCUMOperationForSession(session, instance.ID, request)
if err != nil || operation.Status != domain.SCUMWorkflowStepWaiting {
t.Fatalf("request operation=%+v err=%v", operation, err)
}
duplicate, err := svc.RequestSCUMOperationForSession(session, instance.ID, request)
if err != nil || duplicate.ID != operation.ID {
t.Fatalf("duplicate should return original operation: duplicate=%+v err=%v", duplicate, err)
}
approved, err := svc.ApproveSCUMOperationForSession(session, operation.ID)
if err != nil || approved.Status != domain.SCUMWorkflowStepQueued || approved.RunJobID == "" {
t.Fatalf("approve operation=%+v err=%v", approved, err)
}
job, err := svc.store.Jobs().Get(approved.RunJobID)
if err != nil {
t.Fatalf("get operation job: %v", err)
}
serializedOperation, _ := json.Marshal(approved)
serializedJob, _ := json.Marshal(job)
for _, forbidden := range []string{"#SetFamePoints", "SetCurrencyBalance", "password="} {
if strings.Contains(string(serializedOperation), forbidden) || strings.Contains(string(serializedJob), forbidden) {
t.Fatalf("operation/job persisted raw RCON text %q: operation=%s job=%s", forbidden, serializedOperation, serializedJob)
}
}
claim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: "run-local", SessionToken: runSession, Capabilities: []string{domain.JobCapabilityRemoteRunRCONCommand}, Capacity: domain.RunCapacity{MaxJobs: 1}})
if err != nil || !claim.HasJob || claim.Job == nil || claim.Job.JobID != approved.RunJobID {
t.Fatalf("claim operation RCON job: claim=%+v err=%v", claim, err)
}
ack, err := svc.AckRunJob(domain.RunJobAck{RunEndpointID: "run-local", SessionToken: runSession, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt, Message: "accepted"})
if err != nil || !ack.Accepted {
t.Fatalf("ack operation RCON job: ack=%+v err=%v", ack, err)
}
input, err := svc.GetSourceRCONExecutionInput(domain.SourceRCONExecutionInputRequest{RunEndpointID: "run-local", SessionToken: runSession, JobID: claim.Job.JobID, LeaseToken: ack.Job.LeaseToken, Attempt: ack.Job.Attempt})
if err != nil {
t.Fatalf("read transient operation command: %v", err)
}
if input.Command != "#SetFamePoints 123 \"76561198000000001\"" {
t.Fatalf("unexpected generated RCON command: %q", input.Command)
}
if _, err := svc.CompleteRunJob(domain.RunJobResult{RunEndpointID: "run-local", SessionToken: runSession, JobID: claim.Job.JobID, LeaseToken: ack.Job.LeaseToken, Attempt: ack.Job.Attempt, State: domain.JobStateSucceeded, Progress: domain.RunJobProgressReport{Percent: 100}, ExecutionResult: domain.JobExecutionResult{Kind: "source-rcon.succeeded", AuditSummary: "typed RCON delivered"}}); err != nil {
t.Fatalf("complete operation job: %v", err)
}
reconciled, err := svc.ReconcileSCUMOperation(approved.ID)
if err != nil || reconciled.Status != domain.SCUMWorkflowStepConfirming {
t.Fatalf("expected confirming after delivery before readback: %+v err=%v", reconciled, err)
}
confirmed, err := svc.ConfirmSCUMOperation(approved.ID, domain.SCUMOperationConfirmation{Status: "confirmed", ConfirmedFields: map[string]any{"fame": 123}, ObservedAt: fixedTime.Add(time.Minute)})
if err != nil || confirmed.Status != domain.SCUMWorkflowStepConfirmed || confirmed.CompletedAt.IsZero() {
t.Fatalf("confirm operation=%+v err=%v", confirmed, err)
}
}
func TestSCUMRCONOperationPermissionUnknownAndConfirmationFailure(t *testing.T) {
svc, session, runSession, instance := newSourceRCONFixture(t)
seedSCUMOperationTemplates(t, svc, instance.PluginID)
adminOnly, err := svc.RequestSCUMOperationForSession(session, instance.ID, domain.SCUMOperationRequest{TemplateKey: "player.currency.gold.set", PlayerID: "76561198000000002", Payload: map[string]any{"amount": 9}, Reason: "admin-only", IdempotencyKey: "gold-admin-only"})
if err != nil {
t.Fatalf("request admin-only operation: %v", err)
}
if _, err := svc.ApproveSCUMOperationForSession(session, adminOnly.ID); err != ErrForbidden {
t.Fatalf("expected platform-admin approval denial, got %v", err)
}
operation, err := svc.RequestSCUMOperationForSession(session, instance.ID, domain.SCUMOperationRequest{TemplateKey: "player.currency.normal.set", PlayerID: "76561198000000002", Payload: map[string]any{"amount": 500}, Reason: "repair balance", IdempotencyKey: "normal-unknown"})
if err != nil {
t.Fatalf("request normal currency operation: %v", err)
}
approved, err := svc.ApproveSCUMOperationForSession(session, operation.ID)
if err != nil {
t.Fatalf("approve normal currency operation: %v", err)
}
claim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: "run-local", SessionToken: runSession, Capabilities: []string{domain.JobCapabilityRemoteRunRCONCommand}, Capacity: domain.RunCapacity{MaxJobs: 1}})
if err != nil || !claim.HasJob || claim.Job == nil || claim.Job.JobID != approved.RunJobID {
t.Fatalf("claim normal currency job: claim=%+v err=%v", claim, err)
}
ack, err := svc.AckRunJob(domain.RunJobAck{RunEndpointID: "run-local", SessionToken: runSession, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt})
if err != nil {
t.Fatalf("ack normal currency job: %v", err)
}
if _, err := svc.GetSourceRCONExecutionInput(domain.SourceRCONExecutionInputRequest{RunEndpointID: "run-local", SessionToken: runSession, JobID: claim.Job.JobID, LeaseToken: ack.Job.LeaseToken, Attempt: ack.Job.Attempt}); err != nil {
t.Fatalf("consume normal currency command: %v", err)
}
if _, err := svc.CompleteRunJob(domain.RunJobResult{RunEndpointID: "run-local", SessionToken: runSession, JobID: claim.Job.JobID, LeaseToken: ack.Job.LeaseToken, Attempt: ack.Job.Attempt, State: domain.JobStateFailed, Progress: domain.RunJobProgressReport{Percent: 100}, ExecutionResult: domain.JobExecutionResult{Kind: "source-rcon.unknown", AuditSummary: "unknown command state"}}); err != nil {
t.Fatalf("complete unknown operation job: %v", err)
}
unknown, err := svc.ReconcileSCUMOperation(approved.ID)
if err != nil || unknown.Status != domain.SCUMWorkflowStepUnknown {
t.Fatalf("expected unknown terminal state: %+v err=%v", unknown, err)
}
failure, err := svc.ConfirmSCUMOperation(operation.ID, domain.SCUMOperationConfirmation{Status: "failed", SafeSummary: domain.SCUMSafeSummary{Title: "Readback mismatch", Message: "Projection did not match expected currency."}, ObservedAt: time.Date(2026, 8, 10, 12, 0, 0, 0, time.UTC)})
if err != nil || failure.Status != domain.SCUMWorkflowStepFailed {
t.Fatalf("expected confirmation failure: %+v err=%v", failure, err)
}
}
func TestSCUMSQLiteMutationOperationSafetyGatesAndDispatchesTypedJob(t *testing.T) {
svc, session, runSession, instance := newSourceRCONFixture(t)
seedSCUMOperationTemplates(t, svc, instance.PluginID)
adminSession := enableSCUMSQLiteMutationOperationSupport(t, svc, instance)
if _, err := svc.ApplySCUMObservationResult(domain.SCUMObservationResult{ServerInstanceID: instance.ID, PluginID: instance.PluginID, Source: "run.sqlite.read", QueryKey: "scum.player.profile", Sequence: 1, Checksum: "sha256:profile-online", ObservedAt: fixedTime, Rows: []map[string]any{{"gamePlayerId": "76561198000000855", "displayName": "Attribute Tester", "online": true, "855": 100}}}); err != nil {
t.Fatalf("seed online projection: %v", err)
}
operation, err := svc.RequestSCUMOperationForSession(session, instance.ID, domain.SCUMOperationRequest{TemplateKey: "player.attribute.855.set", PlayerID: "76561198000000855", Payload: map[string]any{"fieldKey": "855", "before": 100, "after": 150, "safetyWindow": "maintenance-2026-08-10", "backupRef": "snapshot://scum/server-rcon/20260810"}, Reason: "repair attribute 855", IdempotencyKey: "attribute-855-1"})
if err != nil || operation.Status != domain.SCUMWorkflowStepWaiting {
t.Fatalf("request sqlite mutation=%+v err=%v", operation, err)
}
waiting, err := svc.ApproveSCUMOperationForSession(adminSession, operation.ID)
if err != nil || waiting.Status != domain.SCUMWorkflowStepWaiting || waiting.RunJobID != "" || !strings.Contains(waiting.SafeSummary.Title, "离线") {
t.Fatalf("online player should block dispatch: %+v err=%v", waiting, err)
}
if _, err := svc.ApplySCUMObservationResult(domain.SCUMObservationResult{ServerInstanceID: instance.ID, PluginID: instance.PluginID, Source: "run.sqlite.read", QueryKey: "scum.player.profile", Sequence: 2, Checksum: "sha256:profile-offline", ObservedAt: fixedTime.Add(time.Minute), Rows: []map[string]any{{"gamePlayerId": "76561198000000855", "displayName": "Attribute Tester", "online": false, "855": 100}}}); err != nil {
t.Fatalf("seed offline projection: %v", err)
}
approved, err := svc.ApproveSCUMOperationForSession(adminSession, operation.ID)
if err != nil || approved.Status != domain.SCUMWorkflowStepQueued || approved.RunJobID == "" {
t.Fatalf("approve sqlite mutation=%+v err=%v", approved, err)
}
job, err := svc.store.Jobs().Get(approved.RunJobID)
if err != nil {
t.Fatalf("get sqlite mutation job: %v", err)
}
if job.Capability != domain.JobCapabilityRemoteRunProtectedSQL || job.ExecutionInput.Inputs["fieldKey"] != "855" || job.ExecutionInput.Inputs["before"] != "100" || job.ExecutionInput.Inputs["after"] != "150" || job.ExecutionInput.Inputs["maxRowsAffected"] != "1" {
t.Fatalf("unexpected typed mutation job: %+v", job)
}
serializedOperation, _ := json.Marshal(approved)
serializedJob, _ := json.Marshal(job)
for _, forbidden := range []string{"UPDATE ", "DELETE ", "INSERT ", "SELECT ", "SCUM.db", "/Saved/", "requestText"} {
if strings.Contains(strings.ToUpper(string(serializedOperation)), strings.ToUpper(forbidden)) || strings.Contains(strings.ToUpper(string(serializedJob)), strings.ToUpper(forbidden)) {
t.Fatalf("operation/job persisted raw DB material %q: operation=%s job=%s", forbidden, serializedOperation, serializedJob)
}
}
claim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: "run-local", SessionToken: runSession, Capabilities: []string{domain.JobCapabilityRemoteRunProtectedSQL}, Capacity: domain.RunCapacity{MaxJobs: 1}})
if err != nil || !claim.HasJob || claim.Job == nil || claim.Job.JobID != approved.RunJobID {
t.Fatalf("claim sqlite mutation job: claim=%+v err=%v", claim, err)
}
ack, err := svc.AckRunJob(domain.RunJobAck{RunEndpointID: "run-local", SessionToken: runSession, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt, Message: "accepted"})
if err != nil || !ack.Accepted {
t.Fatalf("ack sqlite mutation job: ack=%+v err=%v", ack, err)
}
mutationChecksum := "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
content := mustJSON(t, map[string]any{"outcome": "succeeded", "affectedRows": 1, "mutationChecksum": mutationChecksum, "confirmationRows": []map[string]any{{"playerId": "76561198000000855", "fieldKey": "855", "value": 150}}})
if _, err := svc.CompleteRunJob(domain.RunJobResult{RunEndpointID: "run-local", SessionToken: runSession, JobID: claim.Job.JobID, LeaseToken: ack.Job.LeaseToken, Attempt: ack.Job.Attempt, State: domain.JobStateSucceeded, Progress: domain.RunJobProgressReport{Percent: 100}, ExecutionResult: domain.JobExecutionResult{Kind: "scum.sqlite-mutation.succeeded", Checksum: mutationChecksum, AuditSummary: "typed SCUM DB mutation result", Content: content}}); err != nil {
t.Fatalf("complete sqlite mutation job: %v", err)
}
confirmed, err := svc.ReconcileSCUMOperation(approved.ID)
if err != nil || confirmed.Status != domain.SCUMWorkflowStepConfirmed || confirmed.Confirmation.AffectedRows != 1 || confirmed.Confirmation.MutationChecksum != mutationChecksum {
t.Fatalf("expected confirmed sqlite mutation: %+v err=%v", confirmed, err)
}
}
func TestSCUMSQLiteMutationBlocksMissingSafetyAndStaleBefore(t *testing.T) {
svc, session, _, instance := newSourceRCONFixture(t)
seedSCUMOperationTemplates(t, svc, instance.PluginID)
adminSession := enableSCUMSQLiteMutationOperationSupport(t, svc, instance)
if _, err := svc.ApplySCUMObservationResult(domain.SCUMObservationResult{ServerInstanceID: instance.ID, PluginID: instance.PluginID, Source: "run.sqlite.read", QueryKey: "scum.player.profile", Sequence: 1, Checksum: "sha256:profile-855", ObservedAt: fixedTime, Rows: []map[string]any{{"gamePlayerId": "steam-855", "displayName": "Guarded", "online": false, "855": 100}}}); err != nil {
t.Fatalf("seed projection: %v", err)
}
missingSafety, err := svc.RequestSCUMOperationForSession(session, instance.ID, domain.SCUMOperationRequest{TemplateKey: "player.attribute.855.set", PlayerID: "steam-855", Payload: map[string]any{"fieldKey": "855", "before": 100, "after": 101}, Reason: "missing maintenance", IdempotencyKey: "attribute-855-missing-safety"})
if err != nil {
t.Fatalf("request missing safety mutation: %v", err)
}
waiting, err := svc.ApproveSCUMOperationForSession(adminSession, missingSafety.ID)
if err != nil || waiting.Status != domain.SCUMWorkflowStepWaiting || waiting.RunJobID != "" || !strings.Contains(waiting.SafeSummary.Title, "维护") {
t.Fatalf("expected missing maintenance/backup wait: %+v err=%v", waiting, err)
}
stale, err := svc.RequestSCUMOperationForSession(session, instance.ID, domain.SCUMOperationRequest{TemplateKey: "player.attribute.855.set", PlayerID: "steam-855", Payload: map[string]any{"fieldKey": "855", "before": 99, "after": 101, "safetyWindow": "maintenance-2026-08-10", "backupRef": "snapshot://scum/server-rcon/stale"}, Reason: "stale before", IdempotencyKey: "attribute-855-stale-before"})
if err != nil {
t.Fatalf("request stale mutation: %v", err)
}
blocked, err := svc.ApproveSCUMOperationForSession(adminSession, stale.ID)
if err != nil || blocked.Status != domain.SCUMWorkflowStepBlocked || blocked.RunJobID != "" || !strings.Contains(blocked.SafeSummary.Title, "before") {
t.Fatalf("expected stale before block: %+v err=%v", blocked, err)
}
}
func TestSCUMSQLiteMutationResultValidationRejectsOverBoundRows(t *testing.T) {
svc, session, runSession, instance := newSourceRCONFixture(t)
seedSCUMOperationTemplates(t, svc, instance.PluginID)
adminSession := enableSCUMSQLiteMutationOperationSupport(t, svc, instance)
if _, err := svc.ApplySCUMObservationResult(domain.SCUMObservationResult{ServerInstanceID: instance.ID, PluginID: instance.PluginID, Source: "run.sqlite.read", QueryKey: "scum.player.profile", Sequence: 1, Checksum: "sha256:profile-overbound", ObservedAt: fixedTime, Rows: []map[string]any{{"gamePlayerId": "steam-overbound", "online": false, "855": 10}}}); err != nil {
t.Fatalf("seed projection: %v", err)
}
operation, err := svc.RequestSCUMOperationForSession(session, instance.ID, domain.SCUMOperationRequest{TemplateKey: "player.attribute.855.set", PlayerID: "steam-overbound", Payload: map[string]any{"fieldKey": "855", "before": 10, "after": 11, "safetyWindow": "maintenance-2026-08-10", "backupRef": "snapshot://scum/server-rcon/overbound"}, Reason: "overbound test", IdempotencyKey: "attribute-855-overbound"})
if err != nil {
t.Fatalf("request overbound mutation: %v", err)
}
approved, err := svc.ApproveSCUMOperationForSession(adminSession, operation.ID)
if err != nil || approved.RunJobID == "" {
t.Fatalf("approve overbound mutation=%+v err=%v", approved, err)
}
claim, err := svc.ClaimRunJob(domain.RunJobClaim{RunEndpointID: "run-local", SessionToken: runSession, Capabilities: []string{domain.JobCapabilityRemoteRunProtectedSQL}, Capacity: domain.RunCapacity{MaxJobs: 1}})
if err != nil || !claim.HasJob || claim.Job == nil {
t.Fatalf("claim overbound job: claim=%+v err=%v", claim, err)
}
ack, err := svc.AckRunJob(domain.RunJobAck{RunEndpointID: "run-local", SessionToken: runSession, JobID: claim.Job.JobID, LeaseToken: claim.Job.LeaseToken, Attempt: claim.Job.Attempt})
if err != nil {
t.Fatalf("ack overbound job: %v", err)
}
content := mustJSON(t, map[string]any{"outcome": "succeeded", "affectedRows": 2, "mutationChecksum": "sha256:mutation-overbound"})
if _, err := svc.CompleteRunJob(domain.RunJobResult{RunEndpointID: "run-local", SessionToken: runSession, JobID: claim.Job.JobID, LeaseToken: ack.Job.LeaseToken, Attempt: ack.Job.Attempt, State: domain.JobStateSucceeded, Progress: domain.RunJobProgressReport{Percent: 100}, ExecutionResult: domain.JobExecutionResult{Kind: "scum.sqlite-mutation.succeeded", Content: content, AuditSummary: "typed SCUM DB mutation result"}}); err != nil {
t.Fatalf("complete overbound job: %v", err)
}
unknown, err := svc.ReconcileSCUMOperation(approved.ID)
if err != nil || unknown.Status != domain.SCUMWorkflowStepUnknown {
t.Fatalf("expected over-bound rows to become unknown: %+v err=%v", unknown, err)
}
}
func seedSCUMOperationTemplates(t *testing.T, svc *CoreService, pluginID string) {
t.Helper()
plugin, err := svc.store.GamePlugins().Get(pluginID)
if err != nil {
t.Fatal(err)
}
plugin.DeclaredPermissions = append(plugin.DeclaredPermissions, "server.game-client.command")
plugin.GameClientBridge.OperationTemplates = []domain.GameClientBridgeOperationTemplateDeclaration{
{Key: "player.fame.set", Title: "Set player fame", Permission: "server.game-client.command", ApprovalLevel: domain.GameClientBridgeApprovalLevelOperator, Kind: domain.GameClientBridgeOperationKindRCON, TransportKey: "rcon", TargetKey: "rcon", PayloadSchemaRef: "schemas/bridge/player-fame-set.payload.schema.json", TimeoutSeconds: 60, MaxPayloadBytes: 2048, Safety: domain.GameClientBridgeOperationSafety{RequiresApproval: true, RequiresConfirmation: true}},
{Key: "player.currency.normal.set", Title: "Set player normal currency", Permission: "server.game-client.command", ApprovalLevel: domain.GameClientBridgeApprovalLevelOperator, Kind: domain.GameClientBridgeOperationKindRCON, TransportKey: "rcon", TargetKey: "rcon", PayloadSchemaRef: "schemas/bridge/player-currency-set.payload.schema.json", TimeoutSeconds: 60, MaxPayloadBytes: 2048, Safety: domain.GameClientBridgeOperationSafety{RequiresApproval: true, RequiresConfirmation: true}},
{Key: "player.currency.gold.set", Title: "Set player gold currency", Permission: "server.game-client.command", ApprovalLevel: domain.GameClientBridgeApprovalLevelPlatformAdmin, Kind: domain.GameClientBridgeOperationKindRCON, TransportKey: "rcon", TargetKey: "rcon", PayloadSchemaRef: "schemas/bridge/player-currency-set.payload.schema.json", TimeoutSeconds: 60, MaxPayloadBytes: 2048, Safety: domain.GameClientBridgeOperationSafety{RequiresApproval: true, RequiresConfirmation: true}},
}
if err := svc.store.GamePlugins().Update(plugin); err != nil {
t.Fatalf("update plugin operation templates: %v", err)
}
}
func enableSCUMSQLiteMutationOperationSupport(t *testing.T, svc *CoreService, instance domain.ServerInstance) string {
t.Helper()
adminSession := createServiceUserAndLogin(t, svc, domain.User{ID: "platform-admin-scum", DisplayName: "SCUM Admin", Email: "scum-admin@example.test", Roles: []string{"platform-admin"}, PasswordHash: "secret-password"})
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
if err != nil {
t.Fatal(err)
}
plugin.DeclaredPermissions = append(plugin.DeclaredPermissions, "server.game-client.maintenance")
plugin.RequiredRunCapabilities = append(plugin.RequiredRunCapabilities, domain.JobCapabilityRemoteRunProtectedSQL)
plugin.RemoteAccess.RunCapabilities = append(plugin.RemoteAccess.RunCapabilities, domain.JobCapabilityRemoteRunProtectedSQL)
plugin.RemoteAccess.DatabaseEngines = append(plugin.RemoteAccess.DatabaseEngines, "sqlite")
plugin.RuntimeProfiles.TransportProfiles = append(plugin.RuntimeProfiles.TransportProfiles, domain.RuntimeTransportProfile{Key: "scum-database", Kind: "sqlite", TargetKey: "scum-database", Capabilities: []string{domain.JobCapabilityRemoteRunProtectedSQL}})
plugin.GameClientBridge.OperationTemplates = append(plugin.GameClientBridge.OperationTemplates, domain.GameClientBridgeOperationTemplateDeclaration{Key: "player.attribute.855.set", Title: "Set player attribute 855", Permission: "server.game-client.maintenance", ApprovalLevel: domain.GameClientBridgeApprovalLevelPlatformAdmin, Kind: domain.GameClientBridgeOperationKindSQLiteMutation, TransportKey: "scum-database", TargetKey: "scum-database", PayloadSchemaRef: "schemas/bridge/player-attribute-855-set.payload.schema.json", ResultSchemaRef: "schemas/bridge/player-attribute-855-set.result.schema.json", ConfirmationSchemaRef: "schemas/bridge/player-attribute-855-set.confirmation.schema.json", TimeoutSeconds: 120, MaxPayloadBytes: 4096, MaxRowsAffected: 1, Mutation: domain.GameClientBridgeOperationMutationDeclaration{FieldKey: "855", TableKey: "prisoner", IdentityKey: "user_profile_id", ValueKey: "value", ConfirmationQueryKey: "scum.player.profile", AllowedValueType: "integer", MinValue: 0, MaxValue: 100000}, Safety: domain.GameClientBridgeOperationSafety{RequiresApproval: true, RequiresOfflinePlayer: true, RequiresMaintenanceWindow: true, RequiresBeforeValue: true, RequiresConfirmation: true, BackupRequired: true}})
if err := svc.store.GamePlugins().Update(plugin); err != nil {
t.Fatalf("update SCUM DB mutation plugin: %v", err)
}
endpoint, err := svc.store.RunEndpoints().Get(instance.RunEndpointID)
if err != nil {
t.Fatal(err)
}
endpoint.Capabilities = append(endpoint.Capabilities, domain.JobCapabilityRemoteRunProtectedSQL)
if err := svc.store.RunEndpoints().Update(endpoint); err != nil {
t.Fatalf("update SCUM DB mutation endpoint: %v", err)
}
return adminSession
}
func mustJSON(t *testing.T, value any) string {
t.Helper()
encoded, err := json.Marshal(value)
if err != nil {
t.Fatalf("marshal test JSON: %v", err)
}
return string(encoded)
}
+803
View File
@@ -0,0 +1,803 @@
package service
import (
"fmt"
"math"
"strconv"
"strings"
"time"
"browser.local/platform/domain"
"browser.local/platform/repo"
"browser.local/platform/validator"
)
func (svc *CoreService) ApplySCUMObservationResult(result domain.SCUMObservationResult) (domain.SCUMDataObservation, error) {
result = domain.CopySCUMObservationResult(result)
if strings.TrimSpace(result.ServerInstanceID) == "" {
return domain.SCUMDataObservation{}, validationError("serverInstanceId is required")
}
instance, err := svc.store.ServerInstances().Get(result.ServerInstanceID)
if err != nil {
return domain.SCUMDataObservation{}, err
}
if strings.TrimSpace(result.PluginID) == "" {
result.PluginID = instance.PluginID
}
if result.PluginID != instance.PluginID {
return domain.SCUMDataObservation{}, validationError("pluginId must match server instance")
}
if strings.TrimSpace(result.QueryKey) == "" {
return domain.SCUMDataObservation{}, validationError("queryKey is required")
}
if result.ReceivedAt.IsZero() {
result.ReceivedAt = svc.now()
}
if result.ObservedAt.IsZero() {
result.ObservedAt = result.ReceivedAt
}
if result.Status == "" {
result.Status = domain.SCUMObservationAccepted
}
latest, err := svc.latestSCUMObservation(result.ServerInstanceID, result.PluginID, result.QueryKey)
if err != nil {
return domain.SCUMDataObservation{}, err
}
if result.Status == domain.SCUMObservationAccepted && !latest.ObservedAt.IsZero() && scumObservationOlder(result, latest) {
result.Status = domain.SCUMObservationStale
result.ErrorCode = "older_observation"
result.SafeSummary = domain.SCUMSafeSummary{Title: "旧观察已忽略", Message: "Run 返回的 SCUM.db 观察早于当前本地投影,未覆盖 last-known-good 数据。"}
}
observation := domain.SCUMDataObservation{ID: scumObservationID(result), ServerInstanceID: result.ServerInstanceID, PluginID: result.PluginID, Source: result.Source, QueryKey: result.QueryKey, Sequence: result.Sequence, Checksum: result.Checksum, Status: result.Status, ErrorCode: result.ErrorCode, SafeSummary: result.SafeSummary, ObservedAt: result.ObservedAt, ReceivedAt: result.ReceivedAt}
if err := svc.upsertSCUMObservation(observation); err != nil {
return domain.SCUMDataObservation{}, err
}
if result.Status != domain.SCUMObservationAccepted {
if result.Status == domain.SCUMObservationFailed {
return observation, svc.markSCUMQueryStale(result, "observation_failed")
}
return observation, nil
}
freshness := domain.SCUMProjectionFreshnessState{Status: domain.SCUMProjectionFresh, ObservationID: observation.ID, Source: observation.Source, QueryKey: observation.QueryKey, Sequence: observation.Sequence, Checksum: observation.Checksum, ObservedAt: observation.ObservedAt, ReceivedAt: observation.ReceivedAt}
if err := svc.applySCUMRows(result.QueryKey, result.ServerInstanceID, result.Rows, freshness); err != nil {
return domain.SCUMDataObservation{}, err
}
return observation, nil
}
func (svc *CoreService) ListSCUMPlayerLiveStatesForSession(sessionID string, filter domain.SCUMProjectionFilter) ([]domain.SCUMPlayerLiveState, error) {
if err := svc.authorizeServerLifecycle(sessionID, filter.ServerInstanceID); err != nil {
return nil, err
}
values, err := svc.store.SCUMPlayerLiveStates().List(filter)
if err != nil {
return nil, err
}
limitSCUMProjectionSlice(&values, filter.Limit)
return values, nil
}
func (svc *CoreService) ListSCUMSquadsForSession(sessionID string, filter domain.SCUMProjectionFilter) ([]domain.SCUMSquad, error) {
if err := svc.authorizeServerLifecycle(sessionID, filter.ServerInstanceID); err != nil {
return nil, err
}
values, err := svc.store.SCUMSquads().List(filter)
if err != nil {
return nil, err
}
limitSCUMProjectionSlice(&values, filter.Limit)
return values, nil
}
func (svc *CoreService) ListSCUMSquadMembersForSession(sessionID string, filter domain.SCUMProjectionFilter) ([]domain.SCUMSquadMember, error) {
if err := svc.authorizeServerLifecycle(sessionID, filter.ServerInstanceID); err != nil {
return nil, err
}
values, err := svc.store.SCUMSquadMembers().List(filter)
if err != nil {
return nil, err
}
limitSCUMProjectionSlice(&values, filter.Limit)
return values, nil
}
func (svc *CoreService) ListSCUMVehiclesForSession(sessionID string, filter domain.SCUMProjectionFilter) ([]domain.SCUMVehicle, error) {
if err := svc.authorizeServerLifecycle(sessionID, filter.ServerInstanceID); err != nil {
return nil, err
}
values, err := svc.store.SCUMVehicles().List(filter)
if err != nil {
return nil, err
}
limitSCUMProjectionSlice(&values, filter.Limit)
return values, nil
}
func (svc *CoreService) ListSCUMFlagsForSession(sessionID string, filter domain.SCUMProjectionFilter) ([]domain.SCUMFlag, error) {
if err := svc.authorizeServerLifecycle(sessionID, filter.ServerInstanceID); err != nil {
return nil, err
}
values, err := svc.store.SCUMFlags().List(filter)
if err != nil {
return nil, err
}
limitSCUMProjectionSlice(&values, filter.Limit)
return values, nil
}
func (svc *CoreService) ListSCUMCurrentPositionsForSession(sessionID string, filter domain.SCUMProjectionFilter) ([]domain.SCUMCurrentPosition, error) {
if err := svc.authorizeServerLifecycle(sessionID, filter.ServerInstanceID); err != nil {
return nil, err
}
values, err := svc.store.SCUMCurrentPositions().List(filter)
if err != nil {
return nil, err
}
limitSCUMProjectionSlice(&values, filter.Limit)
return values, nil
}
func (svc *CoreService) latestSCUMObservation(serverID, pluginID, queryKey string) (domain.SCUMDataObservation, error) {
observations, err := svc.store.SCUMDataObservations().List(domain.SCUMProjectionFilter{ServerInstanceID: serverID, QueryKey: queryKey})
if err != nil {
return domain.SCUMDataObservation{}, err
}
var latest domain.SCUMDataObservation
for _, observation := range observations {
if pluginID != "" && observation.PluginID != pluginID {
continue
}
if latest.ObservedAt.IsZero() || observation.Sequence > latest.Sequence || (observation.Sequence == latest.Sequence && observation.ObservedAt.After(latest.ObservedAt)) {
latest = observation
}
}
return latest, nil
}
func scumObservationOlder(next domain.SCUMObservationResult, latest domain.SCUMDataObservation) bool {
if next.Sequence > 0 && latest.Sequence > 0 && next.Sequence <= latest.Sequence {
return true
}
return !next.ObservedAt.IsZero() && !latest.ObservedAt.IsZero() && next.ObservedAt.Before(latest.ObservedAt)
}
func (svc *CoreService) upsertSCUMObservation(observation domain.SCUMDataObservation) error {
if existing, err := svc.store.SCUMDataObservations().Get(observation.ID); err == nil {
existing.Status = observation.Status
existing.ErrorCode = observation.ErrorCode
existing.SafeSummary = observation.SafeSummary
existing.ReceivedAt = observation.ReceivedAt
return svc.store.SCUMDataObservations().Update(existing)
} else if err != repo.ErrNotFound {
return err
}
return svc.store.SCUMDataObservations().Create(observation)
}
func (svc *CoreService) applySCUMRows(queryKey, serverID string, rows []map[string]any, freshness domain.SCUMProjectionFreshnessState) error {
lower := strings.ToLower(queryKey)
if strings.Contains(lower, "player") || strings.Contains(lower, "profile") || strings.Contains(lower, "economy") {
for _, row := range rows {
if err := svc.applySCUMPlayerRow(serverID, row, freshness); err != nil {
return err
}
}
}
if strings.Contains(lower, "squad-member") || strings.Contains(lower, "squad.member") || strings.Contains(lower, "member") {
for _, row := range rows {
if err := svc.applySCUMSquadMemberRow(serverID, row, freshness); err != nil {
return err
}
}
} else if strings.Contains(lower, "squad") {
for _, row := range rows {
if err := svc.applySCUMSquadRow(serverID, row, freshness); err != nil {
return err
}
}
}
if strings.Contains(lower, "vehicle") {
for _, row := range rows {
if err := svc.applySCUMVehicleRow(serverID, row, freshness); err != nil {
return err
}
}
}
if strings.Contains(lower, "flag") {
for _, row := range rows {
if err := svc.applySCUMFlagRow(serverID, row, freshness); err != nil {
return err
}
}
}
if strings.Contains(lower, "position") || strings.Contains(lower, "coordinate") {
for _, row := range rows {
if err := svc.applySCUMPositionRow(serverID, row, freshness); err != nil {
return err
}
}
}
return nil
}
func (svc *CoreService) applySCUMPlayerRow(serverID string, row map[string]any, freshness domain.SCUMProjectionFreshnessState) error {
gamePlayerID := firstString(row, "gamePlayerId", "playerId", "steamId", "steam_id")
profileID := firstString(row, "userProfileId", "user_profile_id", "profileId")
steamID := firstString(row, "steamId", "steam_id")
name := firstString(row, "displayName", "name", "playerName")
if gamePlayerID == "" && steamID != "" {
gamePlayerID = steamID
}
if gamePlayerID == "" && profileID == "" {
return nil
}
playerRecordID := ""
if gamePlayerID != "" {
playerRecordID = gamePlayerRecordID(serverID, gamePlayerID)
if err := svc.upsertSCUMGamePlayer(serverID, playerRecordID, gamePlayerID, name, freshness.ObservedAt); err != nil {
return err
}
}
idSource := gamePlayerID
if idSource == "" {
idSource = "profile-" + profileID
}
id := scumProjectionID("player-live", serverID, idSource)
state, err := svc.store.SCUMPlayerLiveStates().Get(id)
if err == repo.ErrNotFound {
state = domain.SCUMPlayerLiveState{ID: id, ServerInstanceID: serverID, GamePlayerRecordID: playerRecordID, GamePlayerID: gamePlayerID, UserProfileID: profileID, SteamID: steamID, DisplayName: name, Freshness: domain.SCUMProjectionStateUnknown(), CreatedAt: svc.now()}
} else if err != nil {
return err
}
if isProjectionOlder(freshness, state.Freshness) {
return nil
}
state.GamePlayerRecordID = coalesceString(playerRecordID, state.GamePlayerRecordID)
state.GamePlayerID = coalesceString(gamePlayerID, state.GamePlayerID)
state.UserProfileID = coalesceString(profileID, state.UserProfileID)
state.SteamID = coalesceString(steamID, state.SteamID)
state.DisplayName = coalesceString(name, state.DisplayName)
state.SquadID = coalesceString(firstString(row, "squadId", "squad_id"), state.SquadID)
state.SquadName = coalesceString(firstString(row, "squadName", "squad_name"), state.SquadName)
if value, ok := firstFloat(row, "famePoints", "fame_points", "fame"); ok {
state.FamePoints = value
}
if value, ok := firstFloat(row, "normalBalance", "currencyNormal", "money", "normal_balance"); ok {
state.NormalBalance = value
}
if value, ok := firstFloat(row, "goldBalance", "currencyGold", "gold", "gold_balance"); ok {
state.GoldBalance = value
}
if value, ok := firstBool(row, "online", "isOnline"); ok {
state.Online = value
}
state.LastLoginAt = coalesceTime(firstTime(row, "lastLoginAt", "last_login_at"), state.LastLoginAt)
state.LastLogoutAt = coalesceTime(firstTime(row, "lastLogoutAt", "last_logout_at"), state.LastLogoutAt)
state.LastSaveTime = coalesceTime(firstTime(row, "lastSaveTime", "last_save_time"), state.LastSaveTime)
if position, ok := scumPositionFromRow(serverID, domain.SCUMProjectionSubjectPlayer, gamePlayerID, row, freshness); ok {
position.GamePlayerRecordID = playerRecordID
position.GamePlayerID = gamePlayerID
state.Position = position
if err := svc.upsertSCUMPosition(position); err != nil {
return err
}
}
state.UnknownFields = unknownRowFields(row, "gamePlayerId", "playerId", "steamId", "steam_id", "userProfileId", "user_profile_id", "profileId", "displayName", "name", "playerName", "squadId", "squad_id", "squadName", "squad_name", "famePoints", "fame_points", "fame", "normalBalance", "currencyNormal", "money", "normal_balance", "goldBalance", "currencyGold", "gold", "gold_balance", "online", "isOnline", "lastLoginAt", "last_login_at", "lastLogoutAt", "last_logout_at", "lastSaveTime", "last_save_time", "x", "y", "z", "worldX", "worldY", "worldZ", "mapId", "mapVersion")
state.Freshness = freshness
state.UpdatedAt = svc.now()
if err == repo.ErrNotFound {
return svc.store.SCUMPlayerLiveStates().Create(state)
}
return svc.store.SCUMPlayerLiveStates().Update(state)
}
func (svc *CoreService) applySCUMSquadRow(serverID string, row map[string]any, freshness domain.SCUMProjectionFreshnessState) error {
squadID := firstString(row, "squadId", "squad_id", "id")
if squadID == "" {
return nil
}
id := scumProjectionID("squad", serverID, squadID)
value, err := svc.store.SCUMSquads().Get(id)
if err == repo.ErrNotFound {
value = domain.SCUMSquad{ID: id, ServerInstanceID: serverID, SquadID: squadID, Freshness: domain.SCUMProjectionStateUnknown(), CreatedAt: svc.now()}
} else if err != nil {
return err
}
if isProjectionOlder(freshness, value.Freshness) {
return nil
}
value.Name = coalesceString(firstString(row, "name", "squadName", "squad_name"), value.Name)
value.LeaderProfileID = coalesceString(firstString(row, "leaderProfileId", "leader_profile_id"), value.LeaderProfileID)
value.LeaderPlayerID = coalesceString(firstString(row, "leaderPlayerId", "leader_player_id", "leaderSteamId"), value.LeaderPlayerID)
if memberCount, ok := firstInt(row, "memberCount", "member_count"); ok {
value.MemberCount = memberCount
}
if score, ok := firstFloat(row, "score", "fame", "points"); ok {
value.Score = score
}
value.UnknownFields = unknownRowFields(row, "squadId", "squad_id", "id", "name", "squadName", "squad_name", "leaderProfileId", "leader_profile_id", "leaderPlayerId", "leader_player_id", "leaderSteamId", "memberCount", "member_count", "score", "fame", "points")
value.Freshness = freshness
value.UpdatedAt = svc.now()
if err == repo.ErrNotFound {
return svc.store.SCUMSquads().Create(value)
}
return svc.store.SCUMSquads().Update(value)
}
func (svc *CoreService) applySCUMSquadMemberRow(serverID string, row map[string]any, freshness domain.SCUMProjectionFreshnessState) error {
squadID := firstString(row, "squadId", "squad_id")
profileID := firstString(row, "userProfileId", "user_profile_id", "profileId")
gamePlayerID := firstString(row, "gamePlayerId", "playerId", "steamId", "steam_id")
if squadID == "" || (profileID == "" && gamePlayerID == "") {
return nil
}
playerRecordID := ""
if gamePlayerID != "" {
playerRecordID = gamePlayerRecordID(serverID, gamePlayerID)
if err := svc.upsertSCUMGamePlayer(serverID, playerRecordID, gamePlayerID, firstString(row, "displayName", "name", "playerName"), freshness.ObservedAt); err != nil {
return err
}
}
id := scumProjectionID("squad-member", serverID, squadID+"/"+coalesceString(profileID, gamePlayerID))
value, err := svc.store.SCUMSquadMembers().Get(id)
if err == repo.ErrNotFound {
value = domain.SCUMSquadMember{ID: id, ServerInstanceID: serverID, SquadID: squadID, UserProfileID: profileID, GamePlayerRecordID: playerRecordID, GamePlayerID: gamePlayerID, Freshness: domain.SCUMProjectionStateUnknown(), CreatedAt: svc.now()}
} else if err != nil {
return err
}
if isProjectionOlder(freshness, value.Freshness) {
return nil
}
value.UserProfileID = coalesceString(profileID, value.UserProfileID)
value.GamePlayerRecordID = coalesceString(playerRecordID, value.GamePlayerRecordID)
value.GamePlayerID = coalesceString(gamePlayerID, value.GamePlayerID)
value.SteamID = coalesceString(firstString(row, "steamId", "steam_id"), value.SteamID)
value.DisplayName = coalesceString(firstString(row, "displayName", "name", "playerName"), value.DisplayName)
value.Rank = coalesceString(firstString(row, "rank", "role"), value.Rank)
if isLeader, ok := firstBool(row, "isLeader", "leader"); ok {
value.IsLeader = isLeader
}
value.JoinedAt = coalesceTime(firstTime(row, "joinedAt", "joined_at"), value.JoinedAt)
value.UnknownFields = unknownRowFields(row, "squadId", "squad_id", "userProfileId", "user_profile_id", "profileId", "gamePlayerId", "playerId", "steamId", "steam_id", "displayName", "name", "playerName", "rank", "role", "isLeader", "leader", "joinedAt", "joined_at")
value.Freshness = freshness
value.UpdatedAt = svc.now()
if err == repo.ErrNotFound {
return svc.store.SCUMSquadMembers().Create(value)
}
return svc.store.SCUMSquadMembers().Update(value)
}
func (svc *CoreService) applySCUMVehicleRow(serverID string, row map[string]any, freshness domain.SCUMProjectionFreshnessState) error {
vehicleID := firstString(row, "vehicleId", "vehicle_id", "id")
entityID := firstString(row, "entityId", "entity_id")
if vehicleID == "" && entityID != "" {
vehicleID = entityID
}
if vehicleID == "" {
return nil
}
id := scumProjectionID("vehicle", serverID, vehicleID)
value, err := svc.store.SCUMVehicles().Get(id)
if err == repo.ErrNotFound {
value = domain.SCUMVehicle{ID: id, ServerInstanceID: serverID, VehicleID: vehicleID, Freshness: domain.SCUMProjectionStateUnknown(), CreatedAt: svc.now()}
} else if err != nil {
return err
}
if isProjectionOlder(freshness, value.Freshness) {
return nil
}
value.EntityID = coalesceString(entityID, value.EntityID)
value.ClassName = coalesceString(firstString(row, "className", "class", "type"), value.ClassName)
value.Label = coalesceString(firstString(row, "label", "vehicleName", "name"), value.Label)
if value.Label == "" {
value.Label = coalesceString(value.ClassName, "Unknown vehicle")
}
value.OwnerProfileID = coalesceString(firstString(row, "ownerProfileId", "owner_profile_id", "userProfileId", "user_profile_id"), value.OwnerProfileID)
value.OwnerPlayerID = coalesceString(firstString(row, "ownerPlayerId", "owner_player_id", "steamId", "steam_id"), value.OwnerPlayerID)
value.SquadID = coalesceString(firstString(row, "squadId", "squad_id"), value.SquadID)
if position, ok := scumPositionFromRow(serverID, domain.SCUMProjectionSubjectVehicle, vehicleID, row, freshness); ok {
position.VehicleID = vehicleID
position.EntityID = entityID
value.Position = position
if err := svc.upsertSCUMPosition(position); err != nil {
return err
}
}
value.UnknownFields = unknownRowFields(row, "vehicleId", "vehicle_id", "id", "entityId", "entity_id", "className", "class", "type", "label", "vehicleName", "name", "ownerProfileId", "owner_profile_id", "userProfileId", "user_profile_id", "ownerPlayerId", "owner_player_id", "steamId", "steam_id", "squadId", "squad_id", "x", "y", "z", "worldX", "worldY", "worldZ", "mapId", "mapVersion")
value.Freshness = freshness
value.UpdatedAt = svc.now()
if err == repo.ErrNotFound {
return svc.store.SCUMVehicles().Create(value)
}
return svc.store.SCUMVehicles().Update(value)
}
func (svc *CoreService) applySCUMFlagRow(serverID string, row map[string]any, freshness domain.SCUMProjectionFreshnessState) error {
flagID := firstString(row, "flagId", "flag_id", "baseElementId", "base_element_id", "id")
entityID := firstString(row, "entityId", "entity_id")
if flagID == "" && entityID != "" {
flagID = entityID
}
if flagID == "" {
return nil
}
id := scumProjectionID("flag", serverID, flagID)
value, err := svc.store.SCUMFlags().Get(id)
if err == repo.ErrNotFound {
value = domain.SCUMFlag{ID: id, ServerInstanceID: serverID, FlagID: flagID, Freshness: domain.SCUMProjectionStateUnknown(), CreatedAt: svc.now()}
} else if err != nil {
return err
}
if isProjectionOlder(freshness, value.Freshness) {
return nil
}
value.EntityID = coalesceString(entityID, value.EntityID)
value.OwnerProfileID = coalesceString(firstString(row, "ownerProfileId", "owner_profile_id", "userProfileId", "user_profile_id"), value.OwnerProfileID)
value.OwnerPlayerID = coalesceString(firstString(row, "ownerPlayerId", "owner_player_id", "steamId", "steam_id"), value.OwnerPlayerID)
value.OwnerSquadID = coalesceString(firstString(row, "ownerSquadId", "owner_squad_id", "squadId", "squad_id"), value.OwnerSquadID)
value.OwnerSquadName = coalesceString(firstString(row, "ownerSquadName", "owner_squad_name", "squadName", "squad_name"), value.OwnerSquadName)
value.OwnershipConfidence = coalesceString(firstString(row, "ownershipConfidence", "ownership_confidence"), value.OwnershipConfidence)
if value.OwnershipConfidence == "" {
value.OwnershipConfidence = "unknown"
}
if position, ok := scumPositionFromRow(serverID, domain.SCUMProjectionSubjectFlag, flagID, row, freshness); ok {
position.EntityID = entityID
value.Position = position
if err := svc.upsertSCUMPosition(position); err != nil {
return err
}
}
value.UnknownFields = unknownRowFields(row, "flagId", "flag_id", "baseElementId", "base_element_id", "id", "entityId", "entity_id", "ownerProfileId", "owner_profile_id", "userProfileId", "user_profile_id", "ownerPlayerId", "owner_player_id", "steamId", "steam_id", "ownerSquadId", "owner_squad_id", "squadId", "squad_id", "ownerSquadName", "owner_squad_name", "squadName", "squad_name", "ownershipConfidence", "ownership_confidence", "x", "y", "z", "worldX", "worldY", "worldZ", "mapId", "mapVersion")
value.Freshness = freshness
value.UpdatedAt = svc.now()
if err == repo.ErrNotFound {
return svc.store.SCUMFlags().Create(value)
}
return svc.store.SCUMFlags().Update(value)
}
func (svc *CoreService) applySCUMPositionRow(serverID string, row map[string]any, freshness domain.SCUMProjectionFreshnessState) error {
subjectType := domain.SCUMProjectionSubject(firstString(row, "subjectType", "subject_type"))
if subjectType == "" {
if firstString(row, "vehicleId", "vehicle_id") != "" {
subjectType = domain.SCUMProjectionSubjectVehicle
} else {
subjectType = domain.SCUMProjectionSubjectPlayer
}
}
subjectID := firstString(row, "subjectId", "subject_id", "gamePlayerId", "playerId", "vehicleId", "flagId", "entityId", "id")
position, ok := scumPositionFromRow(serverID, subjectType, subjectID, row, freshness)
if !ok {
return nil
}
position.GamePlayerID = firstString(row, "gamePlayerId", "playerId", "steamId", "steam_id")
if position.GamePlayerID != "" {
position.GamePlayerRecordID = gamePlayerRecordID(serverID, position.GamePlayerID)
}
position.VehicleID = firstString(row, "vehicleId", "vehicle_id")
position.EntityID = firstString(row, "entityId", "entity_id")
return svc.upsertSCUMPosition(position)
}
func (svc *CoreService) upsertSCUMGamePlayer(serverID, recordID, gamePlayerID, displayName string, observedAt time.Time) error {
if gamePlayerID == "" || recordID == "" {
return nil
}
if observedAt.IsZero() {
observedAt = svc.now()
}
player, err := svc.store.GamePlayers().Get(recordID)
if err == repo.ErrNotFound {
return svc.store.GamePlayers().Create(domain.GamePlayer{ID: recordID, ServerInstanceID: serverID, GamePlayerID: gamePlayerID, DisplayName: displayName, FirstSeenAt: observedAt, LastSeenAt: observedAt, LastEventAt: observedAt, CreatedAt: svc.now(), UpdatedAt: svc.now()})
}
if err != nil {
return err
}
if observedAt.Before(player.LastEventAt) {
return nil
}
player.DisplayName = coalesceString(displayName, player.DisplayName)
player.LastSeenAt = maxTime(player.LastSeenAt, observedAt)
player.LastEventAt = observedAt
player.UpdatedAt = svc.now()
return svc.store.GamePlayers().Update(player)
}
func (svc *CoreService) projectSCUMLoginLiveState(player domain.GamePlayer, batch domain.LogBatchIngest, entry domain.LogEntry, observedAt time.Time, online bool, reason string) error {
if player.ID == "" || player.GamePlayerID == "" {
return nil
}
freshness := domain.SCUMProjectionFreshnessState{Status: domain.SCUMProjectionFresh, ObservationID: entryID(batch.LogStreamID, entry.Seq), Source: "login-log", QueryKey: strings.TrimSpace(entry.Fields["eventType"]), Sequence: entry.Seq, Checksum: validator.LogLineChecksum(entry.Line), ObservedAt: observedAt, ReceivedAt: svc.now()}
id := scumProjectionID("player-live", player.ServerInstanceID, player.GamePlayerID)
state, err := svc.store.SCUMPlayerLiveStates().Get(id)
if err == repo.ErrNotFound {
state = domain.SCUMPlayerLiveState{ID: id, ServerInstanceID: player.ServerInstanceID, GamePlayerRecordID: player.ID, GamePlayerID: player.GamePlayerID, DisplayName: player.DisplayName, Freshness: domain.SCUMProjectionStateUnknown(), CreatedAt: svc.now()}
} else if err != nil {
return err
}
if isProjectionOlder(freshness, state.Freshness) {
return nil
}
state.GamePlayerRecordID = player.ID
state.GamePlayerID = player.GamePlayerID
state.DisplayName = player.DisplayName
state.Online = online
if online {
state.LastLoginAt = observedAt
} else {
state.LastLogoutAt = observedAt
}
state.Freshness = freshness
if reason != "" {
state.UnknownFields = domain.CopyGameClientBridgePayload(map[string]any{"lastLogoutReason": bounded(reason, 80)})
}
state.UpdatedAt = svc.now()
if err == repo.ErrNotFound {
return svc.store.SCUMPlayerLiveStates().Create(state)
}
return svc.store.SCUMPlayerLiveStates().Update(state)
}
func (svc *CoreService) upsertSCUMPosition(position domain.SCUMCurrentPosition) error {
existing, err := svc.store.SCUMCurrentPositions().Get(position.ID)
if err == repo.ErrNotFound {
position.CreatedAt = svc.now()
position.UpdatedAt = svc.now()
return svc.store.SCUMCurrentPositions().Create(position)
}
if err != nil {
return err
}
if isProjectionOlder(position.Freshness, existing.Freshness) {
return nil
}
position.CreatedAt = existing.CreatedAt
position.UpdatedAt = svc.now()
return svc.store.SCUMCurrentPositions().Update(position)
}
func (svc *CoreService) markSCUMQueryStale(result domain.SCUMObservationResult, reason string) error {
freshness := domain.SCUMProjectionFreshnessState{Status: domain.SCUMProjectionStale, ObservationID: scumObservationID(result), Source: result.Source, QueryKey: result.QueryKey, Sequence: result.Sequence, Checksum: result.Checksum, StaleReason: reason, ObservedAt: result.ObservedAt, ReceivedAt: result.ReceivedAt}
lower := strings.ToLower(result.QueryKey)
if strings.Contains(lower, "player") || strings.Contains(lower, "profile") || strings.Contains(lower, "economy") {
values, err := svc.store.SCUMPlayerLiveStates().List(domain.SCUMProjectionFilter{ServerInstanceID: result.ServerInstanceID})
if err != nil {
return err
}
for _, value := range values {
if !isProjectionOlder(freshness, value.Freshness) {
value.Freshness = freshness
value.UpdatedAt = svc.now()
if err := svc.store.SCUMPlayerLiveStates().Update(value); err != nil {
return err
}
}
}
}
if strings.Contains(lower, "squad") {
values, err := svc.store.SCUMSquads().List(domain.SCUMProjectionFilter{ServerInstanceID: result.ServerInstanceID})
if err != nil {
return err
}
for _, value := range values {
if !isProjectionOlder(freshness, value.Freshness) {
value.Freshness = freshness
value.UpdatedAt = svc.now()
if err := svc.store.SCUMSquads().Update(value); err != nil {
return err
}
}
}
}
if strings.Contains(lower, "vehicle") {
values, err := svc.store.SCUMVehicles().List(domain.SCUMProjectionFilter{ServerInstanceID: result.ServerInstanceID})
if err != nil {
return err
}
for _, value := range values {
if !isProjectionOlder(freshness, value.Freshness) {
value.Freshness = freshness
value.UpdatedAt = svc.now()
if err := svc.store.SCUMVehicles().Update(value); err != nil {
return err
}
}
}
}
if strings.Contains(lower, "flag") {
values, err := svc.store.SCUMFlags().List(domain.SCUMProjectionFilter{ServerInstanceID: result.ServerInstanceID})
if err != nil {
return err
}
for _, value := range values {
if !isProjectionOlder(freshness, value.Freshness) {
value.Freshness = freshness
value.UpdatedAt = svc.now()
if err := svc.store.SCUMFlags().Update(value); err != nil {
return err
}
}
}
}
return nil
}
func scumPositionFromRow(serverID string, subjectType domain.SCUMProjectionSubject, subjectID string, row map[string]any, freshness domain.SCUMProjectionFreshnessState) (domain.SCUMCurrentPosition, bool) {
x, hasX := firstFloat(row, "x", "worldX", "world_x", "locationX")
y, hasY := firstFloat(row, "y", "worldY", "world_y", "locationY")
z, hasZ := firstFloat(row, "z", "worldZ", "world_z", "locationZ")
if !hasX || !hasY {
return domain.SCUMCurrentPosition{}, false
}
if subjectID == "" {
return domain.SCUMCurrentPosition{}, false
}
position := domain.SCUMCurrentPosition{ID: scumProjectionID("position-"+string(subjectType), serverID, subjectID), ServerInstanceID: serverID, SubjectType: subjectType, SubjectID: subjectID, MapID: coalesceString(firstString(row, "mapId", "map_id"), domain.SCUMMapTrajectoryMapID), MapVersion: coalesceString(firstString(row, "mapVersion", "map_version"), "0.9"), X: x, Y: y, HasCoordinates: true, LastSaveTime: firstTime(row, "lastSaveTime", "last_save_time"), Freshness: freshness}
if hasZ && !math.IsNaN(z) {
position.Z = z
}
return position, true
}
func isProjectionOlder(next, current domain.SCUMProjectionFreshnessState) bool {
if current.Status == "" || current.Status == domain.SCUMProjectionUnknown {
return false
}
if next.Source == current.Source && next.QueryKey == current.QueryKey && next.Sequence > 0 && current.Sequence > 0 && next.Sequence < current.Sequence {
return true
}
return !next.ObservedAt.IsZero() && !current.ObservedAt.IsZero() && next.ObservedAt.Before(current.ObservedAt)
}
func scumObservationID(result domain.SCUMObservationResult) string {
seed := fmt.Sprintf("%s/%s/%s/%d/%s", result.ServerInstanceID, result.PluginID, result.QueryKey, result.Sequence, result.Checksum)
if result.Checksum == "" {
seed = fmt.Sprintf("%s/%s/%s/%d/%s", result.ServerInstanceID, result.PluginID, result.QueryKey, result.Sequence, result.ObservedAt.Format(time.RFC3339Nano))
}
return "scum-observation-" + fingerprintID(result.ServerInstanceID, seed)
}
func scumProjectionID(kind, serverID, subject string) string {
return "scum-" + kind + "-" + fingerprintID(serverID, subject)
}
func firstString(row map[string]any, keys ...string) string {
for _, key := range keys {
if value, ok := row[key]; ok {
switch typed := value.(type) {
case string:
if trimmed := strings.TrimSpace(typed); trimmed != "" {
return trimmed
}
case fmt.Stringer:
if trimmed := strings.TrimSpace(typed.String()); trimmed != "" {
return trimmed
}
case int, int64, uint64, float64:
return fmt.Sprint(typed)
}
}
}
return ""
}
func firstFloat(row map[string]any, keys ...string) (float64, bool) {
for _, key := range keys {
if value, ok := row[key]; ok {
switch typed := value.(type) {
case float64:
return typed, true
case float32:
return float64(typed), true
case int:
return float64(typed), true
case int64:
return float64(typed), true
case uint64:
return float64(typed), true
case string:
parsed, err := strconv.ParseFloat(strings.TrimSpace(typed), 64)
if err == nil {
return parsed, true
}
}
}
}
return 0, false
}
func firstInt(row map[string]any, keys ...string) (int, bool) {
value, ok := firstFloat(row, keys...)
if !ok {
return 0, false
}
return int(value), true
}
func firstBool(row map[string]any, keys ...string) (bool, bool) {
for _, key := range keys {
if value, ok := row[key]; ok {
switch typed := value.(type) {
case bool:
return typed, true
case string:
parsed, err := strconv.ParseBool(strings.TrimSpace(typed))
if err == nil {
return parsed, true
}
case int:
return typed != 0, true
case int64:
return typed != 0, true
case float64:
return typed != 0, true
}
}
}
return false, false
}
func firstTime(row map[string]any, keys ...string) time.Time {
for _, key := range keys {
if value, ok := row[key]; ok {
switch typed := value.(type) {
case time.Time:
return typed
case string:
trimmed := strings.TrimSpace(typed)
if trimmed == "" {
continue
}
if parsed, err := time.Parse(time.RFC3339Nano, trimmed); err == nil {
return parsed
}
if parsed, err := time.Parse("2006-01-02 15:04:05", trimmed); err == nil {
return parsed.UTC()
}
case int64:
return time.Unix(typed, 0).UTC()
case float64:
return time.Unix(int64(typed), 0).UTC()
}
}
}
return time.Time{}
}
func unknownRowFields(row map[string]any, known ...string) map[string]any {
knownSet := map[string]struct{}{}
for _, key := range known {
knownSet[key] = struct{}{}
}
unknown := map[string]any{}
for key, value := range row {
if _, ok := knownSet[key]; ok {
continue
}
unknown[key] = value
}
if len(unknown) == 0 {
return nil
}
return domain.CopyGameClientBridgePayload(unknown)
}
func coalesceString(next, current string) string {
if strings.TrimSpace(next) != "" {
return strings.TrimSpace(next)
}
return current
}
func coalesceTime(next, current time.Time) time.Time {
if !next.IsZero() {
return next
}
return current
}
func limitSCUMProjectionSlice[T any](values *[]T, limit int) {
if limit > 0 && len(*values) > limit {
*values = (*values)[:limit]
}
}
+110
View File
@@ -0,0 +1,110 @@
package service
import (
"testing"
"time"
"browser.local/platform/domain"
)
func TestSCUMObservationProjectsRealRowsAndSeparatesProfileFromSteamID(t *testing.T) {
svc, _ := newRegisteredLogIngestService(t)
observed := time.Date(2026, 8, 10, 9, 0, 0, 0, time.UTC)
observation, err := svc.ApplySCUMObservationResult(domain.SCUMObservationResult{
ServerInstanceID: "server-1",
PluginID: "server.scum",
Source: "run.sqlite.read",
QueryKey: "scum.player.profile",
Sequence: 10,
Checksum: "sha256:profile-10",
ObservedAt: observed,
Rows: []map[string]any{{
"gamePlayerId": "steam-1",
"userProfileId": "profile-99",
"steamId": "steam-1",
"displayName": "Moon",
"squadId": "squad-1",
"famePoints": 42,
"normalBalance": 500.0,
"goldBalance": 7.0,
"x": 100,
"y": 200,
"z": 30,
"lastSaveTime": observed.Add(-time.Minute).Format(time.RFC3339),
"future_column": "preserved",
}},
})
if err != nil || observation.Status != domain.SCUMObservationAccepted {
t.Fatalf("apply observation=%+v err=%v", observation, err)
}
player, err := svc.store.GamePlayers().Get(gamePlayerRecordID("server-1", "steam-1"))
if err != nil || player.DisplayName != "Moon" {
t.Fatalf("expected game player from real row: player=%+v err=%v", player, err)
}
states, err := svc.store.SCUMPlayerLiveStates().List(domain.SCUMProjectionFilter{ServerInstanceID: "server-1", UserProfileID: "profile-99"})
if err != nil || len(states) != 1 {
t.Fatalf("states=%+v err=%v", states, err)
}
state := states[0]
if state.GamePlayerID != "steam-1" || state.UserProfileID != "profile-99" || state.SteamID != "steam-1" || state.NormalBalance != 500 || state.Online {
t.Fatalf("identity/economy projection mixed IDs or inferred online incorrectly: %+v", state)
}
if !state.Position.HasCoordinates || state.Position.X != 100 || state.Position.Y != 200 || state.UnknownFields["future_column"] != "preserved" {
t.Fatalf("position/unknown fields not projected safely: %+v", state)
}
stale, err := svc.ApplySCUMObservationResult(domain.SCUMObservationResult{ServerInstanceID: "server-1", PluginID: "server.scum", Source: "run.sqlite.read", QueryKey: "scum.player.profile", Sequence: 9, Checksum: "sha256:profile-9", ObservedAt: observed.Add(-time.Hour), Rows: []map[string]any{{"gamePlayerId": "steam-1", "userProfileId": "profile-99", "displayName": "Old", "normalBalance": 9999}}})
if err != nil || stale.Status != domain.SCUMObservationStale || stale.ErrorCode != "older_observation" {
t.Fatalf("expected older observation stale, got %+v err=%v", stale, err)
}
again, err := svc.store.SCUMPlayerLiveStates().Get(state.ID)
if err != nil || again.DisplayName != "Moon" || again.NormalBalance != 500 {
t.Fatalf("older observation overwrote last-known-good: %+v err=%v", again, err)
}
}
func TestSCUMFailedObservationMarksStaleWithoutOverwritingProjection(t *testing.T) {
svc, _ := newRegisteredLogIngestService(t)
observed := time.Date(2026, 8, 10, 10, 0, 0, 0, time.UTC)
if _, err := svc.ApplySCUMObservationResult(domain.SCUMObservationResult{ServerInstanceID: "server-1", PluginID: "server.scum", Source: "run.sqlite.read", QueryKey: "scum.player.profile", Sequence: 1, Checksum: "sha256:ok", ObservedAt: observed, Rows: []map[string]any{{"gamePlayerId": "steam-2", "userProfileId": "profile-2", "displayName": "Nova", "normalBalance": 125}}}); err != nil {
t.Fatalf("apply initial observation: %v", err)
}
failed, err := svc.ApplySCUMObservationResult(domain.SCUMObservationResult{ServerInstanceID: "server-1", PluginID: "server.scum", Source: "run.sqlite.read", QueryKey: "scum.player.profile", Sequence: 2, Checksum: "sha256:failed", Status: domain.SCUMObservationFailed, ErrorCode: "sqlite_busy", ObservedAt: observed.Add(time.Minute)})
if err != nil || failed.Status != domain.SCUMObservationFailed {
t.Fatalf("failed observation=%+v err=%v", failed, err)
}
states, err := svc.store.SCUMPlayerLiveStates().List(domain.SCUMProjectionFilter{ServerInstanceID: "server-1", GamePlayerID: "steam-2"})
if err != nil || len(states) != 1 {
t.Fatalf("states=%+v err=%v", states, err)
}
if states[0].NormalBalance != 125 || states[0].Freshness.Status != domain.SCUMProjectionStale || states[0].Freshness.StaleReason != "observation_failed" {
t.Fatalf("failed query did not preserve values and mark stale: %+v", states[0])
}
}
func TestSCUMLoginLogsProjectLiveStateAndDatabaseSaveTimeDoesNotProveOnline(t *testing.T) {
svc, token := newRegisteredLogIngestService(t)
createLogStreamFixture(t, svc)
base := time.Date(2026, 8, 10, 11, 0, 0, 0, time.UTC)
login := gamePlayerBatch(t, token, 1, []domain.LogEntry{{Seq: 1, Timestamp: base, Line: "login accepted", Fields: map[string]string{"eventType": "scum.login", "playerId": "steam-3", "playerName": "Comet", "sessionId": "session-3", "outcome": "accepted"}}})
if _, err := svc.IngestLogBatch(login); err != nil {
t.Fatalf("ingest login: %v", err)
}
states, err := svc.store.SCUMPlayerLiveStates().List(domain.SCUMProjectionFilter{ServerInstanceID: "server-1", GamePlayerID: "steam-3"})
if err != nil || len(states) != 1 || !states[0].Online {
t.Fatalf("login did not mark live state online: states=%+v err=%v", states, err)
}
logout := gamePlayerBatch(t, token, 2, []domain.LogEntry{{Seq: 2, Timestamp: base.Add(time.Minute), Line: "logout", Fields: map[string]string{"eventType": "scum.logout", "playerId": "steam-3", "playerName": "Comet", "sessionId": "session-3", "reason": "disconnect"}}})
if _, err := svc.IngestLogBatch(logout); err != nil {
t.Fatalf("ingest logout: %v", err)
}
if _, err := svc.ApplySCUMObservationResult(domain.SCUMObservationResult{ServerInstanceID: "server-1", PluginID: "server.scum", Source: "run.sqlite.read", QueryKey: "scum.player.profile", Sequence: 3, Checksum: "sha256:save-time", ObservedAt: base.Add(2 * time.Minute), Rows: []map[string]any{{"gamePlayerId": "steam-3", "userProfileId": "profile-3", "displayName": "Comet", "lastSaveTime": base.Add(90 * time.Second).Format(time.RFC3339)}}}); err != nil {
t.Fatalf("apply save-time observation: %v", err)
}
states, err = svc.store.SCUMPlayerLiveStates().List(domain.SCUMProjectionFilter{ServerInstanceID: "server-1", GamePlayerID: "steam-3"})
if err != nil || len(states) != 1 {
t.Fatalf("states=%+v err=%v", states, err)
}
if states[0].Online || states[0].LastSaveTime.IsZero() {
t.Fatalf("last_save_time was incorrectly treated as online proof: %+v", states[0])
}
}
+393
View File
@@ -0,0 +1,393 @@
package service
import (
"fmt"
"sort"
"strings"
"time"
"browser.local/platform/domain"
"browser.local/platform/repo"
)
type scumWorkflowTemplateDefinition struct {
Key string
Title string
Steps []scumWorkflowStepDefinition
}
type scumWorkflowStepDefinition struct {
Key string
DependsOn []string
OperationKey string
QueryTemplateKey string
Capability string
TargetKey string
MutatesState bool
MaxAttempts int
Summary string
}
func (svc *CoreService) CreateSCUMWorkflowForSession(sessionID, serverID string, request domain.SCUMWorkflowInstance) (domain.SCUMWorkflowInstance, error) {
request = domain.CopySCUMWorkflowInstance(request)
user, err := svc.GetCurrentUser(sessionID)
if err != nil {
return domain.SCUMWorkflowInstance{}, err
}
if err := svc.authorizeServerLifecycle(sessionID, serverID); err != nil {
return domain.SCUMWorkflowInstance{}, err
}
instance, err := svc.store.ServerInstances().Get(serverID)
if err != nil {
return domain.SCUMWorkflowInstance{}, err
}
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
if err != nil {
return domain.SCUMWorkflowInstance{}, err
}
template, ok := scumWorkflowTemplates()[request.TemplateKey]
if !ok {
return domain.SCUMWorkflowInstance{}, validationError("SCUM workflow template is not declared")
}
if strings.TrimSpace(request.IdempotencyKey) == "" || len(request.IdempotencyKey) > 120 {
return domain.SCUMWorkflowInstance{}, validationError("workflow idempotency key is required")
}
if existing, err := svc.store.SCUMWorkflowInstances().List(domain.SCUMWorkflowInstanceFilter{ServerInstanceID: serverID, IdempotencyKey: request.IdempotencyKey}); err == nil && len(existing) > 0 {
return domain.CopySCUMWorkflowInstance(existing[0]), nil
} else if err != nil {
return domain.SCUMWorkflowInstance{}, err
}
stamp := svc.now()
workflow := domain.SCUMWorkflowInstance{ID: "scum-workflow-" + fingerprintID(serverID, request.IdempotencyKey), ServerInstanceID: serverID, PluginID: plugin.ID, TemplateKey: template.Key, RequestedBy: user.ID, IdempotencyKey: request.IdempotencyKey, Status: domain.SCUMWorkflowQueued, Input: domain.CopyGameClientBridgePayload(request.Input), SafeSummary: domain.SCUMSafeSummary{Title: template.Title, Message: "SCUM workflow queued with typed steps and safe summaries."}, CreatedAt: stamp, UpdatedAt: stamp}
if err := svc.store.SCUMWorkflowInstances().Create(workflow); err != nil {
return domain.SCUMWorkflowInstance{}, err
}
for index, step := range template.Steps {
maxAttempts := step.MaxAttempts
if maxAttempts == 0 {
maxAttempts = 1
}
record := domain.SCUMWorkflowStep{ID: fmt.Sprintf("%s.step.%02d.%s", workflow.ID, index+1, step.Key), WorkflowID: workflow.ID, ServerInstanceID: serverID, StepKey: step.Key, DependsOn: domain.CopyStringSlice(step.DependsOn), Status: domain.SCUMWorkflowStepQueued, OperationKey: step.OperationKey, QueryTemplateKey: step.QueryTemplateKey, Capability: step.Capability, TargetKey: step.TargetKey, MaxAttempts: maxAttempts, MutatesState: step.MutatesState, SafeSummary: domain.SCUMSafeSummary{Title: step.Key, Message: step.Summary}, CreatedAt: stamp, UpdatedAt: stamp}
if err := svc.store.SCUMWorkflowSteps().Create(record); err != nil {
return domain.SCUMWorkflowInstance{}, err
}
}
_, err = svc.recordAuditEventWithID(user.ID, "scum.workflow.create", "scum-workflow", workflow.ID, domain.AuditResultQueued, "typed SCUM workflow queued")
return domain.CopySCUMWorkflowInstance(workflow), err
}
func (svc *CoreService) ListSCUMWorkflowsForSession(sessionID string, filter domain.SCUMWorkflowInstanceFilter) ([]domain.SCUMWorkflowInstance, error) {
if err := svc.authorizeServerLifecycle(sessionID, filter.ServerInstanceID); err != nil {
return nil, err
}
values, err := svc.store.SCUMWorkflowInstances().List(filter)
if err != nil {
return nil, err
}
limitSCUMProjectionSlice(&values, filter.Limit)
return values, nil
}
func (svc *CoreService) ListSCUMWorkflowStepsForSession(sessionID string, filter domain.SCUMWorkflowStepFilter) ([]domain.SCUMWorkflowStep, error) {
if err := svc.authorizeServerLifecycle(sessionID, filter.ServerInstanceID); err != nil {
return nil, err
}
values, err := svc.store.SCUMWorkflowSteps().List(filter)
if err != nil {
return nil, err
}
limitSCUMProjectionSlice(&values, filter.Limit)
return values, nil
}
func (svc *CoreService) DispatchNextSCUMWorkflowSteps(serverID string, limit int) ([]domain.SCUMWorkflowStep, error) {
if limit <= 0 {
limit = 1
}
workflows, err := svc.store.SCUMWorkflowInstances().List(domain.SCUMWorkflowInstanceFilter{ServerInstanceID: serverID})
if err != nil {
return nil, err
}
sort.SliceStable(workflows, func(i, j int) bool {
if workflows[i].CreatedAt.Equal(workflows[j].CreatedAt) {
return workflows[i].IdempotencyKey < workflows[j].IdempotencyKey
}
return workflows[i].CreatedAt.Before(workflows[j].CreatedAt)
})
dispatched := []domain.SCUMWorkflowStep{}
activeMutating, err := svc.hasActiveSCUMMutatingStep(serverID)
if err != nil {
return nil, err
}
for _, workflow := range workflows {
if !scumWorkflowRunnable(workflow.Status) || len(dispatched) >= limit {
continue
}
steps, err := svc.sortedSCUMWorkflowSteps(workflow.ID)
if err != nil {
return nil, err
}
for _, step := range steps {
if len(dispatched) >= limit || !scumWorkflowStepRunnable(step.Status) || !scumWorkflowDependenciesConfirmed(step, steps) {
continue
}
if step.MutatesState && activeMutating {
return dispatched, nil
}
if blocked, err := svc.blockSCUMStepIfRunUnavailable(workflow, step); err != nil || blocked.ID != "" {
if err != nil {
return nil, err
}
dispatched = append(dispatched, blocked)
return dispatched, nil
}
step.Status = domain.SCUMWorkflowStepRunning
step.Attempt++
step.UpdatedAt = svc.now()
if err := svc.store.SCUMWorkflowSteps().Update(step); err != nil {
return nil, err
}
workflow.Status = domain.SCUMWorkflowRunning
workflow.CurrentStepKey = step.StepKey
workflow.UpdatedAt = step.UpdatedAt
if err := svc.store.SCUMWorkflowInstances().Update(workflow); err != nil {
return nil, err
}
dispatched = append(dispatched, domain.CopySCUMWorkflowStep(step))
if step.MutatesState {
activeMutating = true
return dispatched, nil
}
}
}
return dispatched, nil
}
func (svc *CoreService) CompleteSCUMWorkflowStep(stepID string, status domain.SCUMWorkflowStepStatus, confirmation domain.SCUMOperationConfirmation) (domain.SCUMWorkflowInstance, error) {
step, err := svc.store.SCUMWorkflowSteps().Get(stepID)
if err != nil {
return domain.SCUMWorkflowInstance{}, err
}
workflow, err := svc.store.SCUMWorkflowInstances().Get(step.WorkflowID)
if err != nil {
return domain.SCUMWorkflowInstance{}, err
}
if !scumWorkflowStepTerminal(status) {
return domain.SCUMWorkflowInstance{}, validationError("SCUM workflow step completion status must be terminal")
}
stamp := svc.now()
step.Status = status
step.Confirmation = domain.CopySCUMOperationConfirmation(confirmation)
step.CompletedAt = stamp
step.UpdatedAt = stamp
if err := svc.store.SCUMWorkflowSteps().Update(step); err != nil {
return domain.SCUMWorkflowInstance{}, err
}
return svc.refreshSCUMWorkflowStatus(workflow)
}
func (svc *CoreService) RetrySCUMWorkflowStep(stepID string) (domain.SCUMWorkflowStep, error) {
step, err := svc.store.SCUMWorkflowSteps().Get(stepID)
if err != nil {
return domain.SCUMWorkflowStep{}, err
}
workflow, err := svc.store.SCUMWorkflowInstances().Get(step.WorkflowID)
if err != nil {
return domain.SCUMWorkflowStep{}, err
}
if step.Attempt >= step.MaxAttempts {
return domain.SCUMWorkflowStep{}, validationError("SCUM workflow step retry limit reached")
}
if step.MutatesState && step.Status == domain.SCUMWorkflowStepUnknown && step.Confirmation.Status != "confirmed" {
step.SafeSummary = domain.SCUMSafeSummary{Title: "确认后才能重试", Message: "State-changing SCUM step is unknown; workflow must run confirmation/readback before retry to avoid duplicate effects."}
step.UpdatedAt = svc.now()
if err := svc.store.SCUMWorkflowSteps().Update(step); err != nil {
return domain.SCUMWorkflowStep{}, err
}
return domain.CopySCUMWorkflowStep(step), nil
}
step.Status = domain.SCUMWorkflowStepQueued
step.Confirmation = domain.SCUMOperationConfirmation{}
step.CompletedAt = time.Time{}
step.UpdatedAt = svc.now()
if err := svc.store.SCUMWorkflowSteps().Update(step); err != nil {
return domain.SCUMWorkflowStep{}, err
}
workflow.Status = domain.SCUMWorkflowQueued
workflow.BlockerReason = ""
workflow.UpdatedAt = step.UpdatedAt
if err := svc.store.SCUMWorkflowInstances().Update(workflow); err != nil {
return domain.SCUMWorkflowStep{}, err
}
return domain.CopySCUMWorkflowStep(step), nil
}
func (svc *CoreService) sortedSCUMWorkflowSteps(workflowID string) ([]domain.SCUMWorkflowStep, error) {
steps, err := svc.store.SCUMWorkflowSteps().List(domain.SCUMWorkflowStepFilter{WorkflowID: workflowID})
if err != nil {
return nil, err
}
sort.SliceStable(steps, func(i, j int) bool {
if steps[i].CreatedAt.Equal(steps[j].CreatedAt) {
return steps[i].ID < steps[j].ID
}
return steps[i].CreatedAt.Before(steps[j].CreatedAt)
})
return steps, nil
}
func (svc *CoreService) hasActiveSCUMMutatingStep(serverID string) (bool, error) {
mutates := true
for _, status := range []domain.SCUMWorkflowStepStatus{domain.SCUMWorkflowStepRunning, domain.SCUMWorkflowStepConfirming} {
steps, err := svc.store.SCUMWorkflowSteps().List(domain.SCUMWorkflowStepFilter{ServerInstanceID: serverID, Status: status, MutatesState: &mutates})
if err != nil {
return false, err
}
if len(steps) > 0 {
return true, nil
}
}
return false, nil
}
func (svc *CoreService) blockSCUMStepIfRunUnavailable(workflow domain.SCUMWorkflowInstance, step domain.SCUMWorkflowStep) (domain.SCUMWorkflowStep, error) {
if strings.TrimSpace(step.Capability) == "" {
return domain.SCUMWorkflowStep{}, nil
}
instance, err := svc.store.ServerInstances().Get(workflow.ServerInstanceID)
if err != nil {
return domain.SCUMWorkflowStep{}, err
}
endpoint, err := svc.store.RunEndpoints().Get(instance.RunEndpointID)
if err != nil {
if err == repo.ErrNotFound {
return svc.blockSCUMWorkflowStep(workflow, step, "Run unavailable", "No bound run endpoint is available for this typed SCUM workflow step.")
}
return domain.SCUMWorkflowStep{}, err
}
if err := svc.validateRunnableEndpoint(endpoint, step.Capability); err != nil {
return svc.blockSCUMWorkflowStep(workflow, step, "Run unavailable", "Bound run cannot currently claim the declared workflow capability.")
}
return domain.SCUMWorkflowStep{}, nil
}
func (svc *CoreService) blockSCUMWorkflowStep(workflow domain.SCUMWorkflowInstance, step domain.SCUMWorkflowStep, title string, message string) (domain.SCUMWorkflowStep, error) {
stamp := svc.now()
step.Status = domain.SCUMWorkflowStepBlocked
step.SafeSummary = domain.SCUMSafeSummary{Title: title, Message: message, Details: map[string]string{"stepKey": step.StepKey, "capability": step.Capability}}
step.UpdatedAt = stamp
workflow.Status = domain.SCUMWorkflowBlocked
workflow.CurrentStepKey = step.StepKey
workflow.BlockerReason = title
workflow.SafeSummary = step.SafeSummary
workflow.UpdatedAt = stamp
if err := svc.store.SCUMWorkflowSteps().Update(step); err != nil {
return domain.SCUMWorkflowStep{}, err
}
if err := svc.store.SCUMWorkflowInstances().Update(workflow); err != nil {
return domain.SCUMWorkflowStep{}, err
}
return domain.CopySCUMWorkflowStep(step), nil
}
func (svc *CoreService) refreshSCUMWorkflowStatus(workflow domain.SCUMWorkflowInstance) (domain.SCUMWorkflowInstance, error) {
steps, err := svc.sortedSCUMWorkflowSteps(workflow.ID)
if err != nil {
return domain.SCUMWorkflowInstance{}, err
}
allConfirmed := len(steps) > 0
stamp := svc.now()
for _, step := range steps {
switch step.Status {
case domain.SCUMWorkflowStepFailed:
workflow.Status = domain.SCUMWorkflowFailed
case domain.SCUMWorkflowStepUnknown:
workflow.Status = domain.SCUMWorkflowUnknown
case domain.SCUMWorkflowStepCancelled:
workflow.Status = domain.SCUMWorkflowCancelled
case domain.SCUMWorkflowStepConfirmed:
default:
allConfirmed = false
}
if workflow.Status == domain.SCUMWorkflowFailed || workflow.Status == domain.SCUMWorkflowUnknown || workflow.Status == domain.SCUMWorkflowCancelled {
workflow.CurrentStepKey = step.StepKey
workflow.CompletedAt = stamp
workflow.UpdatedAt = stamp
return domain.CopySCUMWorkflowInstance(workflow), svc.store.SCUMWorkflowInstances().Update(workflow)
}
}
if allConfirmed {
workflow.Status = domain.SCUMWorkflowConfirmed
workflow.CurrentStepKey = ""
workflow.CompletedAt = stamp
} else {
workflow.Status = domain.SCUMWorkflowQueued
workflow.CurrentStepKey = ""
}
workflow.UpdatedAt = stamp
if err := svc.store.SCUMWorkflowInstances().Update(workflow); err != nil {
return domain.SCUMWorkflowInstance{}, err
}
return domain.CopySCUMWorkflowInstance(workflow), nil
}
func scumWorkflowDependenciesConfirmed(step domain.SCUMWorkflowStep, steps []domain.SCUMWorkflowStep) bool {
if len(step.DependsOn) == 0 {
return true
}
statuses := map[string]domain.SCUMWorkflowStepStatus{}
for _, candidate := range steps {
statuses[candidate.StepKey] = candidate.Status
}
for _, dependency := range step.DependsOn {
if statuses[dependency] != domain.SCUMWorkflowStepConfirmed {
return false
}
}
return true
}
func scumWorkflowRunnable(status domain.SCUMWorkflowStatus) bool {
switch status {
case domain.SCUMWorkflowQueued, domain.SCUMWorkflowRunning, domain.SCUMWorkflowWaiting:
return true
default:
return false
}
}
func scumWorkflowStepRunnable(status domain.SCUMWorkflowStepStatus) bool {
switch status {
case domain.SCUMWorkflowStepQueued, domain.SCUMWorkflowStepWaiting:
return true
default:
return false
}
}
func scumWorkflowStepTerminal(status domain.SCUMWorkflowStepStatus) bool {
switch status {
case domain.SCUMWorkflowStepConfirmed, domain.SCUMWorkflowStepFailed, domain.SCUMWorkflowStepUnknown, domain.SCUMWorkflowStepCancelled:
return true
default:
return false
}
}
func scumWorkflowTemplates() map[string]scumWorkflowTemplateDefinition {
read := domain.JobCapabilityRemoteRunDBSQLiteQuery
logs := domain.JobCapabilityRemoteRunLogsTransfer
protectedSQL := domain.JobCapabilityRemoteRunProtectedSQL
rcon := domain.JobCapabilityRemoteRunRCONCommand
return map[string]scumWorkflowTemplateDefinition{
"scum.bootstrap-real-data": {Key: "scum.bootstrap-real-data", Title: "Bootstrap SCUM real data", Steps: []scumWorkflowStepDefinition{{Key: "verify-run-binding", Capability: read, TargetKey: "scum-database", Summary: "Verify run binding and SCUM.db query capability."}, {Key: "schema-probe", DependsOn: []string{"verify-run-binding"}, Capability: read, TargetKey: "scum-database", QueryTemplateKey: "scum.schema.probe", Summary: "Probe SCUM.db schema before projection refresh."}, {Key: "login-cursor", DependsOn: []string{"schema-probe"}, Capability: logs, TargetKey: "scum-login", Summary: "Initialize login log observation cursor."}}},
"scum.player-refresh": {Key: "scum.player-refresh", Title: "Refresh SCUM player", Steps: []scumWorkflowStepDefinition{{Key: "login-evidence", Capability: logs, TargetKey: "scum-login", Summary: "Sync login/logout evidence."}, {Key: "player-profile", DependsOn: []string{"login-evidence"}, Capability: read, TargetKey: "scum-database", QueryTemplateKey: "scum.player.profile", Summary: "Read player profile/economy facts."}, {Key: "position-read", DependsOn: []string{"player-profile"}, Capability: read, TargetKey: "scum-database", QueryTemplateKey: "scum.positions", Summary: "Read current player coordinates."}}},
"scum.world-refresh": {Key: "scum.world-refresh", Title: "Refresh SCUM world", Steps: []scumWorkflowStepDefinition{{Key: "squad-read", Capability: read, TargetKey: "scum-database", QueryTemplateKey: "scum.squads", MaxAttempts: 2, Summary: "Refresh squads."}, {Key: "vehicle-read", Capability: read, TargetKey: "scum-database", QueryTemplateKey: "scum.vehicles", MaxAttempts: 2, Summary: "Refresh vehicles."}, {Key: "flag-read", Capability: read, TargetKey: "scum-database", QueryTemplateKey: "scum.flags", MaxAttempts: 2, Summary: "Refresh flags."}, {Key: "position-read", Capability: read, TargetKey: "scum-database", QueryTemplateKey: "scum.positions", MaxAttempts: 2, Summary: "Refresh map positions."}}},
"scum.player-correction": {Key: "scum.player-correction", Title: "SCUM player correction", Steps: []scumWorkflowStepDefinition{{Key: "safety-check", Capability: read, TargetKey: "scum-database", QueryTemplateKey: "scum.player.profile", Summary: "Verify current projection, before value, offline state, and backup evidence."}, {Key: "apply-operation", DependsOn: []string{"safety-check"}, Capability: protectedSQL, TargetKey: "scum-database", OperationKey: "player.attribute.855.set", MutatesState: true, Summary: "Apply the approved typed operation through Run."}, {Key: "confirmation-read", DependsOn: []string{"apply-operation"}, Capability: read, TargetKey: "scum-database", QueryTemplateKey: "scum.player.profile", Summary: "Confirm the requested value by readback."}}},
"scum.gift-delivery": {Key: "scum.gift-delivery", Title: "SCUM gift delivery", Steps: []scumWorkflowStepDefinition{{Key: "eligibility-check", Summary: "Evaluate gift eligibility and idempotency."}, {Key: "deliver-reward", DependsOn: []string{"eligibility-check"}, Capability: rcon, TargetKey: "scum-management", OperationKey: "reward.deliver", MutatesState: true, MaxAttempts: 2, Summary: "Deliver approved reward through typed operation."}, {Key: "notify-player", DependsOn: []string{"deliver-reward"}, Capability: rcon, TargetKey: "scum-management", OperationKey: "player.notify", MutatesState: true, Summary: "Notify the player after delivery."}, {Key: "confirmation-read", DependsOn: []string{"notify-player"}, Capability: read, TargetKey: "scum-database", QueryTemplateKey: "scum.player.profile", Summary: "Confirm grant state/readback before marking delivered."}}},
"scum.territory-audit": {Key: "scum.territory-audit", Title: "SCUM territory audit", Steps: []scumWorkflowStepDefinition{{Key: "squad-roster", Capability: read, TargetKey: "scum-database", QueryTemplateKey: "scum.squad-members", Summary: "Refresh squad rosters."}, {Key: "flag-ownership", Capability: read, TargetKey: "scum-database", QueryTemplateKey: "scum.flags", Summary: "Refresh flag ownership."}, {Key: "risk-signal", DependsOn: []string{"squad-roster", "flag-ownership"}, Summary: "Project stale owner/member risk signals."}}},
"scum.vehicle-audit": {Key: "scum.vehicle-audit", Title: "SCUM vehicle audit", Steps: []scumWorkflowStepDefinition{{Key: "vehicle-read", Capability: read, TargetKey: "scum-database", QueryTemplateKey: "scum.vehicles", Summary: "Refresh vehicle inventory."}, {Key: "vehicle-map", DependsOn: []string{"vehicle-read"}, Capability: read, TargetKey: "scum-database", QueryTemplateKey: "scum.positions", Summary: "Refresh vehicle map overlays."}}},
"scum.ai-assist": {Key: "scum.ai-assist", Title: "SCUM AI assist", Steps: []scumWorkflowStepDefinition{{Key: "collect-allowed-fields", Summary: "Collect plugin-declared config fields and workflow inputs."}, {Key: "draft-review", DependsOn: []string{"collect-allowed-fields"}, Summary: "Create a reviewable typed diff or workflow draft."}, {Key: "approved-dispatch", DependsOn: []string{"draft-review"}, MutatesState: true, Summary: "Dispatch only after human approval through typed paths."}}},
"scum.product-cleanup": {Key: "scum.product-cleanup", Title: "SCUM product cleanup", Steps: []scumWorkflowStepDefinition{{Key: "remove-raw-routes", Summary: "Remove raw logs, terminal, config, and operation-history product routes."}, {Key: "publish-safe-status", DependsOn: []string{"remove-raw-routes"}, Summary: "Route users to safe workflow/status surfaces."}}},
}
}
+132
View File
@@ -0,0 +1,132 @@
package service
import (
"strings"
"testing"
"time"
"browser.local/platform/domain"
"browser.local/platform/repo"
)
func TestSCUMWorkflowDispatchesReadStepsWithBoundedConcurrencyAndIdempotency(t *testing.T) {
svc, session, instance := newSCUMWorkflowFixture(t, true)
workflow, err := svc.CreateSCUMWorkflowForSession(session, instance.ID, domain.SCUMWorkflowInstance{TemplateKey: "scum.world-refresh", IdempotencyKey: "world-refresh-1", Input: map[string]any{"scope": "world"}})
if err != nil || workflow.Status != domain.SCUMWorkflowQueued {
t.Fatalf("create world workflow=%+v err=%v", workflow, err)
}
duplicate, err := svc.CreateSCUMWorkflowForSession(session, instance.ID, domain.SCUMWorkflowInstance{TemplateKey: "scum.world-refresh", IdempotencyKey: "world-refresh-1"})
if err != nil || duplicate.ID != workflow.ID {
t.Fatalf("expected idempotent workflow create: duplicate=%+v err=%v", duplicate, err)
}
dispatched, err := svc.DispatchNextSCUMWorkflowSteps(instance.ID, 3)
if err != nil || len(dispatched) != 3 {
t.Fatalf("expected three bounded read steps dispatched: steps=%+v err=%v", dispatched, err)
}
for _, step := range dispatched {
if step.MutatesState || step.Status != domain.SCUMWorkflowStepRunning || step.Attempt != 1 {
t.Fatalf("unexpected read step dispatch: %+v", step)
}
}
}
func TestSCUMWorkflowSerializesMutatingStepsPerServer(t *testing.T) {
svc, session, instance := newSCUMWorkflowFixture(t, true)
first, err := svc.CreateSCUMWorkflowForSession(session, instance.ID, domain.SCUMWorkflowInstance{TemplateKey: "scum.gift-delivery", IdempotencyKey: "gift-1"})
if err != nil {
t.Fatalf("create first gift workflow: %v", err)
}
if _, err := svc.CreateSCUMWorkflowForSession(session, instance.ID, domain.SCUMWorkflowInstance{TemplateKey: "scum.gift-delivery", IdempotencyKey: "gift-2"}); err != nil {
t.Fatalf("create second gift workflow: %v", err)
}
steps, err := svc.DispatchNextSCUMWorkflowSteps(instance.ID, 1)
if err != nil || len(steps) != 1 || steps[0].StepKey != "eligibility-check" {
t.Fatalf("expected first eligibility step: steps=%+v err=%v", steps, err)
}
if _, err := svc.CompleteSCUMWorkflowStep(steps[0].ID, domain.SCUMWorkflowStepConfirmed, domain.SCUMOperationConfirmation{Status: "confirmed"}); err != nil {
t.Fatalf("complete eligibility: %v", err)
}
steps, err = svc.DispatchNextSCUMWorkflowSteps(instance.ID, 1)
if err != nil || len(steps) != 1 || steps[0].StepKey != "deliver-reward" || !steps[0].MutatesState {
t.Fatalf("expected first mutating reward step: steps=%+v err=%v", steps, err)
}
if steps[0].WorkflowID != first.ID {
t.Fatalf("expected first workflow to keep the mutation slot: step=%+v first=%+v", steps[0], first)
}
blockedByActiveMutation, err := svc.DispatchNextSCUMWorkflowSteps(instance.ID, 1)
if err != nil {
t.Fatalf("dispatch while mutation active: %v", err)
}
for _, step := range blockedByActiveMutation {
if step.MutatesState {
t.Fatalf("second state-changing step should wait for first terminal state: steps=%+v", blockedByActiveMutation)
}
}
}
func TestSCUMWorkflowBlocksWhenRunUnavailable(t *testing.T) {
svc, session, instance := newSCUMWorkflowFixture(t, false)
workflow, err := svc.CreateSCUMWorkflowForSession(session, instance.ID, domain.SCUMWorkflowInstance{TemplateKey: "scum.player-refresh", IdempotencyKey: "player-refresh-blocked"})
if err != nil {
t.Fatalf("create player refresh workflow: %v", err)
}
steps, err := svc.DispatchNextSCUMWorkflowSteps(instance.ID, 1)
if err != nil || len(steps) != 1 || steps[0].Status != domain.SCUMWorkflowStepBlocked {
t.Fatalf("expected blocked run step: steps=%+v err=%v", steps, err)
}
updated, err := svc.store.SCUMWorkflowInstances().Get(workflow.ID)
if err != nil || updated.Status != domain.SCUMWorkflowBlocked || strings.Contains(updated.SafeSummary.Message, "/") || strings.Contains(strings.ToLower(updated.SafeSummary.Message), "token") {
t.Fatalf("workflow blocker should be safe: workflow=%+v err=%v", updated, err)
}
}
func TestSCUMWorkflowRetryRequiresConfirmationAfterUnknownMutation(t *testing.T) {
svc, session, instance := newSCUMWorkflowFixture(t, true)
if _, err := svc.CreateSCUMWorkflowForSession(session, instance.ID, domain.SCUMWorkflowInstance{TemplateKey: "scum.gift-delivery", IdempotencyKey: "gift-unknown"}); err != nil {
t.Fatalf("create gift workflow: %v", err)
}
steps, err := svc.DispatchNextSCUMWorkflowSteps(instance.ID, 1)
if err != nil || len(steps) != 1 {
t.Fatalf("dispatch eligibility: steps=%+v err=%v", steps, err)
}
if _, err := svc.CompleteSCUMWorkflowStep(steps[0].ID, domain.SCUMWorkflowStepConfirmed, domain.SCUMOperationConfirmation{Status: "confirmed"}); err != nil {
t.Fatalf("complete eligibility: %v", err)
}
steps, err = svc.DispatchNextSCUMWorkflowSteps(instance.ID, 1)
if err != nil || len(steps) != 1 || !steps[0].MutatesState {
t.Fatalf("dispatch mutating reward: steps=%+v err=%v", steps, err)
}
if _, err := svc.CompleteSCUMWorkflowStep(steps[0].ID, domain.SCUMWorkflowStepUnknown, domain.SCUMOperationConfirmation{Status: "unknown"}); err != nil {
t.Fatalf("complete unknown mutation: %v", err)
}
retry, err := svc.RetrySCUMWorkflowStep(steps[0].ID)
if err != nil || retry.Status != domain.SCUMWorkflowStepUnknown || !strings.Contains(retry.SafeSummary.Title, "确认") {
t.Fatalf("unknown mutating retry should require confirmation: step=%+v err=%v", retry, err)
}
}
func newSCUMWorkflowFixture(t *testing.T, runAvailable bool) (*CoreService, string, domain.ServerInstance) {
t.Helper()
svc := newCoreService(repo.NewMemoryStore(), func() time.Time { return fixedTime })
capabilities := []string{domain.JobCapabilityRemoteRunDBSQLiteQuery, domain.JobCapabilityRemoteRunLogsTransfer, domain.JobCapabilityRemoteRunProtectedSQL, domain.JobCapabilityRemoteRunRCONCommand}
plugin, err := svc.CreateGamePlugin(domain.GamePlugin{ID: "server.scum", Name: "SCUM", Version: "1.0.0", ServerType: "scum", ManifestRef: "artifact://manifests/server.scum/1.0.0", CreateFormSchemaRef: "artifact://schemas/server.scum/create-form/1.0.0", RequiredRunCapabilities: capabilities, DeclaredPermissions: []string{"server.game-client.read", "server.game-client.command", "server.game-client.maintenance"}, Permissions: domain.PluginPermissions{Jobs: true, RemoteAccess: true}, RemoteAccess: domain.GamePluginRemoteAccess{Methods: []string{"run"}, RunCapabilities: capabilities, DatabaseEngines: []string{"sqlite"}, RCON: true, LogTransfer: true}, LifecycleActions: domain.PluginLifecycleActions{Start: "actions/start.json"}, RuntimeProfiles: domain.GamePluginRuntimeProfiles{TransportProfiles: []domain.RuntimeTransportProfile{{Key: "scum-database", Kind: "sqlite", TargetKey: "scum-database", Capabilities: []string{domain.JobCapabilityRemoteRunDBSQLiteQuery, domain.JobCapabilityRemoteRunProtectedSQL}}, {Key: "scum-management", Kind: "rcon", TargetKey: "scum-management", Capabilities: []string{domain.JobCapabilityRemoteRunRCONCommand}}}}})
if err != nil {
t.Fatalf("create workflow plugin: %v", err)
}
endpoint, err := svc.CreateRunEndpoint(domain.RunEndpoint{ID: "run-local", DisplayName: "Local Run", Version: "0.1.0", Platform: "windows", Architecture: "amd64", Status: domain.RunEndpointStatusOnline, Capabilities: capabilities, Capacity: domain.RunCapacity{MaxJobs: 4}, LastHeartbeatAt: fixedTime})
if err != nil {
t.Fatalf("create workflow endpoint: %v", err)
}
session := createServiceUserAndLogin(t, svc, domain.User{ID: "workflow-owner", DisplayName: "Workflow Owner", Email: "workflow-owner@example.test", Roles: []string{"server-owner"}, PasswordHash: "secret-password"})
instance, err := svc.CreateServerInstanceForSession(session, domain.ServerInstance{ID: "server-workflow", PluginID: plugin.ID, RunEndpointID: endpoint.ID, Name: "Workflow Server", State: domain.ServerInstanceStateRunning})
if err != nil {
t.Fatalf("create workflow server: %v", err)
}
if !runAvailable {
endpoint.Status = domain.RunEndpointStatusOffline
if err := svc.store.RunEndpoints().Update(endpoint); err != nil {
t.Fatalf("mark workflow endpoint offline: %v", err)
}
}
return svc, session, instance
}
+102 -1
View File
@@ -439,7 +439,7 @@ func ValidatePluginCreateInputs(fields []domain.PluginCreateField, inputs map[st
func validateGameClientBridgeManifest(field string, bridge domain.GameClientBridgeManifest, permissions []string, pages []domain.GamePluginPage, runtimeProfiles domain.GamePluginRuntimeProfiles) []string { func validateGameClientBridgeManifest(field string, bridge domain.GameClientBridgeManifest, permissions []string, pages []domain.GamePluginPage, runtimeProfiles domain.GamePluginRuntimeProfiles) []string {
companionPresent := bridge.Companion != (domain.GameClientBridgeCompanionDeclaration{}) companionPresent := bridge.Companion != (domain.GameClientBridgeCompanionDeclaration{})
if len(bridge.Commands) == 0 && len(bridge.Snapshots) == 0 && len(bridge.QueryTemplates) == 0 && len(bridge.Pages) == 0 && len(bridge.Features) == 0 && bridge.Retention.KeepForSeconds == 0 && bridge.Retention.MaxRecords == 0 && !companionPresent { if len(bridge.Commands) == 0 && len(bridge.Snapshots) == 0 && len(bridge.QueryTemplates) == 0 && len(bridge.OperationTemplates) == 0 && len(bridge.Pages) == 0 && len(bridge.Features) == 0 && bridge.Retention.KeepForSeconds == 0 && bridge.Retention.MaxRecords == 0 && !companionPresent {
return nil return nil
} }
var violations []string var violations []string
@@ -590,6 +590,72 @@ func validateGameClientBridgeManifest(field string, bridge domain.GameClientBrid
violations = append(violations, prefix+" transport must be sqlite with remote.run.db.sqlite.query capability") violations = append(violations, prefix+" transport must be sqlite with remote.run.db.sqlite.query capability")
} }
} }
operationTemplates := map[string]domain.GameClientBridgeOperationTemplateDeclaration{}
for index, template := range bridge.OperationTemplates {
prefix := fmt.Sprintf("%s.operationTemplates[%d]", field, index)
if !clientManagerIdentifierPattern.MatchString(template.Key) || unsafeGameClientBridgeCommandType(template.Key) {
violations = append(violations, prefix+".key is invalid or unsafe")
}
if _, exists := operationTemplates[template.Key]; exists {
violations = append(violations, prefix+".key is duplicated")
}
operationTemplates[template.Key] = template
if strings.TrimSpace(template.Title) == "" || len([]rune(template.Title)) > 80 {
violations = append(violations, prefix+".title is invalid")
}
if !containsString(permissions, template.Permission) {
violations = append(violations, prefix+".permission must be declared by the plugin")
}
if template.ApprovalLevel != domain.GameClientBridgeApprovalLevelOperator && template.ApprovalLevel != domain.GameClientBridgeApprovalLevelPlatformAdmin {
violations = append(violations, prefix+".approvalLevel must require operator or platform-admin approval")
}
if template.Kind != domain.GameClientBridgeOperationKindRCON && template.Kind != domain.GameClientBridgeOperationKindSQLiteMutation {
violations = append(violations, prefix+".kind is invalid")
}
if !safeRelativeJSONRef(template.PayloadSchemaRef) || template.ResultSchemaRef != "" && !safeRelativeJSONRef(template.ResultSchemaRef) || template.ConfirmationSchemaRef != "" && !safeRelativeJSONRef(template.ConfirmationSchemaRef) {
violations = append(violations, prefix+" schema references must be safe relative JSON references")
}
if template.TimeoutSeconds < 1 || template.TimeoutSeconds > 3600 {
violations = append(violations, prefix+".timeoutSeconds is invalid")
}
if template.MaxPayloadBytes < 1 || template.MaxPayloadBytes > maxGameClientBridgePayloadSize {
violations = append(violations, prefix+".maxPayloadBytes is invalid")
}
transport, exists := transports[template.TransportKey]
if !exists {
violations = append(violations, prefix+".transportKey must reference a declared runtime transport profile")
continue
}
if transport.TargetKey != template.TargetKey || strings.TrimSpace(template.TargetKey) == "" {
violations = append(violations, prefix+".targetKey must match the declared runtime transport profile")
}
switch template.Kind {
case domain.GameClientBridgeOperationKindRCON:
if transport.Kind != "rcon" || !containsString(transport.Capabilities, domain.JobCapabilityRemoteRunProtectedRCON) {
violations = append(violations, prefix+" transport must be rcon with remote.run.protected.rcon capability")
}
if template.MaxRowsAffected != 0 {
violations = append(violations, prefix+".maxRowsAffected is only valid for sqlite-mutation")
}
if !emptyGameClientBridgeOperationMutation(template.Mutation) {
violations = append(violations, prefix+".mutation is only valid for sqlite-mutation")
}
case domain.GameClientBridgeOperationKindSQLiteMutation:
if transport.Kind != "sqlite" || !containsString(transport.Capabilities, domain.JobCapabilityRemoteRunProtectedSQL) {
violations = append(violations, prefix+" transport must be sqlite with remote.run.protected.sql capability")
}
if template.ApprovalLevel != domain.GameClientBridgeApprovalLevelPlatformAdmin {
violations = append(violations, prefix+".approvalLevel must require platform-admin approval for sqlite-mutation")
}
if template.MaxRowsAffected < 1 || template.MaxRowsAffected > 10 {
violations = append(violations, prefix+".maxRowsAffected is invalid")
}
if !template.Safety.RequiresBeforeValue || !template.Safety.RequiresConfirmation || (!template.Safety.RequiresOfflinePlayer && !template.Safety.RequiresMaintenanceWindow) {
violations = append(violations, prefix+".safety must require before value, confirmation, and offline or maintenance protection")
}
violations = append(violations, validateGameClientBridgeOperationMutation(prefix+".mutation", template.Mutation, queryTemplates)...)
}
}
pageDeclarations := map[string]domain.GamePluginPage{} pageDeclarations := map[string]domain.GamePluginPage{}
for _, page := range pages { for _, page := range pages {
pageDeclarations[page.Key] = page pageDeclarations[page.Key] = page
@@ -674,6 +740,16 @@ func validateGameClientBridgeManifest(field string, bridge domain.GameClientBrid
violations = append(violations, prefix+" must declare remote.access.request for query templates") violations = append(violations, prefix+" must declare remote.access.request for query templates")
} }
} }
for _, operationKey := range page.OperationKeys {
operation, exists := operationTemplates[operationKey]
if !exists {
violations = append(violations, prefix+" references undeclared operation template "+operationKey)
continue
}
if !containsString(pageDeclaration.Permissions, operation.Permission) {
violations = append(violations, prefix+" must declare operation template permission "+operation.Permission)
}
}
for _, featureKey := range page.FeatureKeys { for _, featureKey := range page.FeatureKeys {
feature, exists := features[featureKey] feature, exists := features[featureKey]
if !exists { if !exists {
@@ -772,6 +848,31 @@ func validateGameClientBridgeProtectedRequest(prefix string, request *domain.Gam
return violations return violations
} }
func emptyGameClientBridgeOperationMutation(value domain.GameClientBridgeOperationMutationDeclaration) bool {
return value.FieldKey == "" && value.TableKey == "" && value.IdentityKey == "" && value.ValueKey == "" && value.ConfirmationQueryKey == "" && value.AllowedValueType == "" && value.MinValue == 0 && value.MaxValue == 0
}
func validateGameClientBridgeOperationMutation(prefix string, value domain.GameClientBridgeOperationMutationDeclaration, queryTemplates map[string]domain.GameClientBridgeQueryTemplateDeclaration) []string {
var violations []string
for field, item := range map[string]string{"fieldKey": value.FieldKey, "tableKey": value.TableKey, "identityKey": value.IdentityKey, "valueKey": value.ValueKey, "confirmationQueryKey": value.ConfirmationQueryKey} {
if !validDistributionLogicalKey(item) || unsafeGameClientBridgePayloadKey(item) {
violations = append(violations, prefix+"."+field+" must be a safe logical key")
}
}
if !oneOf(value.AllowedValueType, "integer", "number", "string", "boolean") {
violations = append(violations, prefix+".allowedValueType is invalid")
}
if value.MaxValue != 0 && value.MinValue > value.MaxValue {
violations = append(violations, prefix+".minValue must not exceed maxValue")
}
if value.ConfirmationQueryKey != "" {
if _, exists := queryTemplates[value.ConfirmationQueryKey]; !exists {
violations = append(violations, prefix+".confirmationQueryKey must reference a declared query template")
}
}
return violations
}
func ValidatePluginBridgeAuthorizeRequest(request domain.PluginBridgeAuthorizeRequest) error { func ValidatePluginBridgeAuthorizeRequest(request domain.PluginBridgeAuthorizeRequest) error {
var violations []string var violations []string
violations = appendRequired(violations, "pluginId", request.PluginID) violations = appendRequired(violations, "pluginId", request.PluginID)
+62 -5
View File
@@ -129,17 +129,25 @@ func TestValidateGamePluginManifestRegistrationValidatesRuntimeProfiles(t *testi
func TestValidateGamePluginManifestRegistrationValidatesGameClientBridgeCatalog(t *testing.T) { func TestValidateGamePluginManifestRegistrationValidatesGameClientBridgeCatalog(t *testing.T) {
registration := validGamePluginManifestRegistration() registration := validGamePluginManifestRegistration()
registration.Manifest.Permissions = append(registration.Manifest.Permissions, "server.game-client.read", "server.game-client.command", "server.remote.access") registration.Manifest.Permissions = append(registration.Manifest.Permissions, "server.game-client.read", "server.game-client.command", "server.game-client.maintenance", "server.remote.access")
registration.Manifest.Capabilities = append(registration.Manifest.Capabilities, domain.JobCapabilityRemoteRunDBSQLiteQuery) registration.Manifest.Capabilities = append(registration.Manifest.Capabilities, domain.JobCapabilityRemoteRunDBSQLiteQuery, domain.JobCapabilityRemoteRunProtectedRCON, domain.JobCapabilityRemoteRunProtectedSQL)
registration.Manifest.Pages[0].Permissions = append(registration.Manifest.Pages[0].Permissions, "server.game-client.read", "server.remote.access") registration.Manifest.Pages[0].Permissions = append(registration.Manifest.Pages[0].Permissions, "server.game-client.read", "server.game-client.command", "server.game-client.maintenance", "server.remote.access")
registration.Manifest.Pages[0].BridgeActions = append(registration.Manifest.Pages[0].BridgeActions, string(domain.PluginBridgeActionRemoteAccessRequest)) registration.Manifest.Pages[0].BridgeActions = append(registration.Manifest.Pages[0].BridgeActions, string(domain.PluginBridgeActionRemoteAccessRequest))
registration.Manifest.RuntimeProfiles.TransportProfiles = []domain.RuntimeTransportProfile{{Key: "sqlite-db", Kind: "sqlite", TargetKey: "db/sqlite", Capabilities: []string{domain.JobCapabilityRemoteRunDBSQLiteQuery}}} registration.Manifest.RuntimeProfiles.TransportProfiles = []domain.RuntimeTransportProfile{
{Key: "sqlite-db", Kind: "sqlite", TargetKey: "db/sqlite", Capabilities: []string{domain.JobCapabilityRemoteRunDBSQLiteQuery}},
{Key: "scum-rcon", Kind: "rcon", TargetKey: "scum-rcon", Capabilities: []string{domain.JobCapabilityRemoteRunProtectedRCON}},
{Key: "scum-mutation-db", Kind: "sqlite", TargetKey: "scum-mutation-db", Capabilities: []string{domain.JobCapabilityRemoteRunProtectedSQL}},
}
registration.Manifest.GameClientBridge = domain.GameClientBridgeManifest{ registration.Manifest.GameClientBridge = domain.GameClientBridgeManifest{
Commands: []domain.GameClientBridgeCommandDeclaration{{Type: "announcement.send", Title: "Send announcement", Permission: "server.game-client.command", ApprovalLevel: domain.GameClientBridgeApprovalLevelOperator, PayloadSchemaRef: "schemas/bridge/announcement.schema.json", ResultSchemaRef: "schemas/bridge/announcement-result.schema.json", TimeoutSeconds: 60, MaxPayloadBytes: 4096}}, Commands: []domain.GameClientBridgeCommandDeclaration{{Type: "announcement.send", Title: "Send announcement", Permission: "server.game-client.command", ApprovalLevel: domain.GameClientBridgeApprovalLevelOperator, PayloadSchemaRef: "schemas/bridge/announcement.schema.json", ResultSchemaRef: "schemas/bridge/announcement-result.schema.json", TimeoutSeconds: 60, MaxPayloadBytes: 4096}},
Snapshots: []domain.GameClientBridgeSnapshotDeclaration{{Type: "players", SchemaVersion: "1", SchemaRef: "schemas/bridge/players.schema.json", Retention: domain.GameClientBridgeRetention{KeepForSeconds: 3600, MaxRecords: 100}}}, Snapshots: []domain.GameClientBridgeSnapshotDeclaration{{Type: "players", SchemaVersion: "1", SchemaRef: "schemas/bridge/players.schema.json", Retention: domain.GameClientBridgeRetention{KeepForSeconds: 3600, MaxRecords: 100}}},
QueryTemplates: []domain.GameClientBridgeQueryTemplateDeclaration{{Key: "player.lookup", Title: "Player lookup", Permission: "server.game-client.read", Engine: "sqlite", TransportKey: "sqlite-db", TargetKey: "db/sqlite", ParameterSchemaRef: "schemas/bridge/query/player-lookup.parameters.schema.json", ResultSchemaRef: "schemas/bridge/query/player-lookup.result.schema.json", MaxRows: 50, TimeoutSeconds: 10}}, QueryTemplates: []domain.GameClientBridgeQueryTemplateDeclaration{{Key: "player.lookup", Title: "Player lookup", Permission: "server.game-client.read", Engine: "sqlite", TransportKey: "sqlite-db", TargetKey: "db/sqlite", ParameterSchemaRef: "schemas/bridge/query/player-lookup.parameters.schema.json", ResultSchemaRef: "schemas/bridge/query/player-lookup.result.schema.json", MaxRows: 50, TimeoutSeconds: 10}},
OperationTemplates: []domain.GameClientBridgeOperationTemplateDeclaration{
{Key: "player.fame.set", Title: "Set player fame", Permission: "server.game-client.command", ApprovalLevel: domain.GameClientBridgeApprovalLevelOperator, Kind: domain.GameClientBridgeOperationKindRCON, TransportKey: "scum-rcon", TargetKey: "scum-rcon", PayloadSchemaRef: "schemas/bridge/operations/player-fame-set.payload.schema.json", ResultSchemaRef: "schemas/bridge/operations/player-fame-set.result.schema.json", ConfirmationSchemaRef: "schemas/bridge/operations/player-fame-set.confirmation.schema.json", TimeoutSeconds: 60, MaxPayloadBytes: 2048, Safety: domain.GameClientBridgeOperationSafety{RequiresApproval: true, RequiresConfirmation: true}},
{Key: "player.attribute.855.set", Title: "Set player attribute 855", Permission: "server.game-client.maintenance", ApprovalLevel: domain.GameClientBridgeApprovalLevelPlatformAdmin, Kind: domain.GameClientBridgeOperationKindSQLiteMutation, TransportKey: "scum-mutation-db", TargetKey: "scum-mutation-db", PayloadSchemaRef: "schemas/bridge/operations/player-attribute-855-set.payload.schema.json", ResultSchemaRef: "schemas/bridge/operations/player-attribute-855-set.result.schema.json", ConfirmationSchemaRef: "schemas/bridge/operations/player-attribute-855-set.confirmation.schema.json", TimeoutSeconds: 120, MaxPayloadBytes: 4096, MaxRowsAffected: 1, Mutation: domain.GameClientBridgeOperationMutationDeclaration{FieldKey: "855", TableKey: "prisoner", IdentityKey: "user_profile_id", ValueKey: "value", ConfirmationQueryKey: "player.lookup", AllowedValueType: "integer", MinValue: 0, MaxValue: 100000}, Safety: domain.GameClientBridgeOperationSafety{RequiresApproval: true, RequiresOfflinePlayer: true, RequiresBeforeValue: true, RequiresConfirmation: true, BackupRequired: true}},
},
Retention: domain.GameClientBridgeRetention{KeepForSeconds: 86400, MaxRecords: 1000}, Retention: domain.GameClientBridgeRetention{KeepForSeconds: 86400, MaxRecords: 1000},
Pages: []domain.GameClientBridgePageContract{{PageKey: "logs", CommandTypes: []string{"announcement.send"}, SnapshotTypes: []string{"players"}, QueryTemplateKeys: []string{"player.lookup"}}}, Pages: []domain.GameClientBridgePageContract{{PageKey: "logs", CommandTypes: []string{"announcement.send"}, SnapshotTypes: []string{"players"}, QueryTemplateKeys: []string{"player.lookup"}, OperationKeys: []string{"player.fame.set", "player.attribute.855.set"}}},
} }
if err := ValidateGamePluginManifestRegistration(registration); err != nil { if err := ValidateGamePluginManifestRegistration(registration); err != nil {
t.Fatalf("expected bridge catalog to validate, got %v", err) t.Fatalf("expected bridge catalog to validate, got %v", err)
@@ -225,6 +233,55 @@ func TestValidateGamePluginManifestRegistrationValidatesGameClientBridgeCatalog(
} }
}) })
} }
operationTemplateTests := []struct {
name string
expected string
mutate func(*domain.GamePluginManifestRegistration)
}{
{name: "duplicate key", expected: "key is duplicated", mutate: func(value *domain.GamePluginManifestRegistration) {
value.Manifest.GameClientBridge.OperationTemplates = append(value.Manifest.GameClientBridge.OperationTemplates, value.Manifest.GameClientBridge.OperationTemplates[0])
}},
{name: "unsafe key", expected: "key is invalid or unsafe", mutate: func(value *domain.GamePluginManifestRegistration) {
value.Manifest.GameClientBridge.OperationTemplates[0].Key = "raw.sql.execute"
}},
{name: "missing approval", expected: "approvalLevel must require", mutate: func(value *domain.GamePluginManifestRegistration) {
value.Manifest.GameClientBridge.OperationTemplates[0].ApprovalLevel = domain.GameClientBridgeApprovalLevelNone
}},
{name: "unsafe schema", expected: "schema references", mutate: func(value *domain.GamePluginManifestRegistration) {
value.Manifest.GameClientBridge.OperationTemplates[0].PayloadSchemaRef = "/etc/operation.json"
}},
{name: "rcon wrong transport", expected: "transport must be rcon", mutate: func(value *domain.GamePluginManifestRegistration) {
value.Manifest.GameClientBridge.OperationTemplates[0].TransportKey = "sqlite-db"
value.Manifest.GameClientBridge.OperationTemplates[0].TargetKey = "db/sqlite"
}},
{name: "mutation wrong transport", expected: "transport must be sqlite", mutate: func(value *domain.GamePluginManifestRegistration) {
value.Manifest.GameClientBridge.OperationTemplates[1].TransportKey = "scum-rcon"
value.Manifest.GameClientBridge.OperationTemplates[1].TargetKey = "scum-rcon"
}},
{name: "mutation row bound", expected: "maxRowsAffected is invalid", mutate: func(value *domain.GamePluginManifestRegistration) {
value.Manifest.GameClientBridge.OperationTemplates[1].MaxRowsAffected = 0
}},
{name: "mutation missing safety", expected: "safety must require", mutate: func(value *domain.GamePluginManifestRegistration) {
value.Manifest.GameClientBridge.OperationTemplates[1].Safety.RequiresBeforeValue = false
}},
{name: "undeclared page operation", expected: "undeclared operation template", mutate: func(value *domain.GamePluginManifestRegistration) {
value.Manifest.GameClientBridge.Pages[0].OperationKeys = []string{"missing.operation"}
}},
{name: "page missing operation permission", expected: "must declare operation template permission", mutate: func(value *domain.GamePluginManifestRegistration) {
value.Manifest.Pages[0].Permissions = []string{"server.game-client.read", "server.remote.access"}
}},
}
for _, test := range operationTemplateTests {
t.Run("operation template "+test.name, func(t *testing.T) {
invalid := domain.CopyGamePluginManifestRegistration(registration)
test.mutate(&invalid)
err := ValidateGamePluginManifestRegistration(invalid)
if err == nil || !strings.Contains(err.Error(), test.expected) {
t.Fatalf("expected %q rejection, got %v", test.expected, err)
}
})
}
} }
func TestValidateGamePluginManifestRegistrationRejectsUnsafeCapabilitiesAndPermissions(t *testing.T) { func TestValidateGamePluginManifestRegistrationRejectsUnsafeCapabilitiesAndPermissions(t *testing.T) {
+7 -11
View File
@@ -178,14 +178,10 @@ async function main() {
markers: [ markers: [
server.name, server.name,
`${server.id} · 插件 ${server.pluginId}@${server.pluginVersion} · 节点 ${server.runEndpointId}`, `${server.id} · 插件 ${server.pluginId}@${server.pluginVersion} · 节点 ${server.runEndpointId}`,
"概览",
"启动", "启动",
"停止", "停止",
"日志", "管理",
"管理终端", "AI 助手"
"配置",
"AI 助手",
"操作历史"
] ]
} }
]; ];
@@ -818,8 +814,8 @@ async function verifyLifecycleOperation(headers, server, chrome) {
const job = await waitForJob(headers, currentServer.id, result.job.id); const job = await waitForJob(headers, currentServer.id, result.job.id);
await chrome.navigate(`${webUrl}/#/servers/${encodeURIComponent(currentServer.id)}`); await chrome.navigate(`${webUrl}/#/servers/${encodeURIComponent(currentServer.id)}`);
await chrome.waitForText([currentServer.name, "操作历史"], "server detail after lifecycle operation"); await chrome.waitForText([currentServer.name, "管理", "AI 助手"], "server detail after lifecycle operation");
const historyState = await clickAndVerify(chrome, "操作历史", ["操作历史", "平台任务记录", "server-lifecycle", "process."]); const manageState = await clickAndVerify(chrome, "管理", ["部署定义", "基本信息", "管理成员"]);
return { return {
action, action,
@@ -832,10 +828,10 @@ async function verifyLifecycleOperation(headers, server, chrome) {
}, },
acceptedJob: pick(result.job, ["id", "serverInstanceId", "runEndpointId", "capability", "state", "resultRef"]), acceptedJob: pick(result.job, ["id", "serverInstanceId", "runEndpointId", "capability", "state", "resultRef"]),
job: pick(job, ["id", "serverInstanceId", "runEndpointId", "capability", "state", "resultRef"]), job: pick(job, ["id", "serverInstanceId", "runEndpointId", "capability", "state", "resultRef"]),
proof: `platform API accepted ${expectedCapability} and platform-owned jobs endpoint returned the same job; browser verified operation history entry point without direct run access`, proof: `platform API accepted ${expectedCapability} and platform-owned jobs endpoint returned the same job; browser verified safe management entry point without direct run access`,
browserEvidence: { browserEvidence: {
...historyState, ...manageState,
proofMode: "operation-history-entry-point" proofMode: "safe-management-entry-point"
} }
}; };
} }
+29 -86
View File
@@ -244,61 +244,18 @@ describe("PlatformApiClient AI providers", () => {
count: 1 count: 1
}); });
} }
if (url.endsWith("/api/v1/server-instances/server-1/config")) { if (url.endsWith("/api/v1/server-instances/server-1/scum/players")) return jsonResponse({ items: [{ id: "scum-player-1", gamePlayerId: "steam-1", displayName: "Prisoner One", online: true }], count: 1 });
return jsonResponse({ if (url.endsWith("/api/v1/server-instances/server-1/scum/squads")) return jsonResponse({ items: [{ id: "squad-1", squadId: "squad-1", name: "Alpha" }], count: 1 });
serverInstanceId: server.id, if (url.endsWith("/api/v1/server-instances/server-1/scum/squad-members")) return jsonResponse({ items: [{ id: "member-1", squadId: "squad-1", gamePlayerId: "steam-1" }], count: 1 });
configVersion: 1, if (url.endsWith("/api/v1/server-instances/server-1/scum/vehicles")) return jsonResponse({ items: [{ id: "vehicle-1", vehicleId: "vehicle-1", label: "SUV" }], count: 1 });
format: "properties", 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 });
key: "server.properties", 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 });
content: "server.name=Example Survival #1\n", if (url.endsWith("/api/v1/server-instances/server-1/scum/operations") && (!init?.method || init.method === "GET")) return jsonResponse({ items: [], count: 0 });
source: "platform-derived", 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" });
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/config/diff") && init?.method === "POST") { 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 });
expect(JSON.parse(String(init.body))).toEqual({
expectedConfigVersion: 1,
key: "server.properties",
proposedContent: "server.name=Example Survival #2\n"
});
return jsonResponse({
serverInstanceId: server.id,
configVersion: 1,
key: "server.properties",
currentContent: "server.name=Example Survival #1\n",
proposedContent: "server.name=Example Survival #2\n",
diff: [
{ kind: "removed", oldNumber: 1, content: "server.name=Example Survival #1" },
{ kind: "added", newNumber: 1, content: "server.name=Example Survival #2" }
],
hasChanges: true,
source: "platform-review",
reviewedAt: "2026-07-03T00:00:00Z"
});
}
if (url.endsWith("/api/v1/server-instances/server-1/config/approve") && init?.method === "POST") {
expect(JSON.parse(String(init.body))).toEqual({
expectedConfigVersion: 1,
key: "server.properties",
proposedContent: "server.name=Example Survival #2\n",
idempotencyKey: "idem-config"
});
return jsonResponse({
status: "queued",
preview: {
serverInstanceId: server.id,
configVersion: 1,
key: "server.properties",
currentContent: "server.name=Example Survival #1\n",
proposedContent: "server.name=Example Survival #2\n",
diff: [{ kind: "added", newNumber: 1, content: "server.name=Example Survival #2" }],
hasChanges: true,
source: "platform-review",
reviewedAt: "2026-07-03T00:00:00Z"
},
job: { ...job, id: "job-config-write", capability: "config.write", targetKey: "server.properties", inputRef: "input://server-config/server-1/server.properties/v1" }
});
}
if (url.endsWith("/api/v1/file-operations/dispatch") && init?.method === "POST") { if (url.endsWith("/api/v1/file-operations/dispatch") && init?.method === "POST") {
expect(JSON.parse(String(init.body))).toEqual({ expect(JSON.parse(String(init.body))).toEqual({
serverInstanceId: server.id, serverInstanceId: server.id,
@@ -536,13 +493,6 @@ describe("PlatformApiClient AI providers", () => {
expect(JSON.parse(String(init.body))).toEqual({ probeKey: "java-21", installPlanKey: "install-java-linux", planDigest: runtimeDigest, idempotencyKey: "idem-dep-install" }); expect(JSON.parse(String(init.body))).toEqual({ probeKey: "java-21", installPlanKey: "install-java-linux", planDigest: runtimeDigest, idempotencyKey: "idem-dep-install" });
return jsonResponse({ ...job, id: "job-dep-install", capability: "dependencies.install", targetKey: "dependencies/install/install-java-linux" }); return jsonResponse({ ...job, id: "job-dep-install", capability: "dependencies.install", targetKey: "dependencies/install/install-java-linux" });
} }
if (url.endsWith("/api/v1/server-instances/server-1/logs/live")) {
return jsonResponse({ items: [], count: 0 });
}
if (url.endsWith("/api/v1/server-instances/server-1/logs/backfill") && init?.method === "POST") {
expect(JSON.parse(String(init.body))).toEqual({ sourceKey: "latest-log", checkpointRef: "artifact://logs/checkpoint/1", limit: 200, idempotencyKey: "idem-log-backfill" });
return jsonResponse({ ...job, id: "job-log-backfill", capability: "logs.backfill", targetKey: "logs/latest-log", inputRef: "artifact://logs/checkpoint/1" });
}
if (url.endsWith("/api/v1/plugin-bridge/authorize") && init?.method === "POST") { if (url.endsWith("/api/v1/plugin-bridge/authorize") && init?.method === "POST") {
return jsonResponse({ return jsonResponse({
pluginId: plugin.id, pluginId: plugin.id,
@@ -604,13 +554,18 @@ describe("PlatformApiClient AI providers", () => {
await expect(client.deleteServerInstance(server.id, { password: "secret-password", force: true, confirmation: "FORCE DELETE" })).resolves.toBeUndefined(); await expect(client.deleteServerInstance(server.id, { password: "secret-password", force: true, confirmation: "FORCE DELETE" })).resolves.toBeUndefined();
await expect(client.getPlatformResourceUsage()).resolves.toMatchObject({ source: "platform-derived", cpuPercent: 28 }); await expect(client.getPlatformResourceUsage()).resolves.toMatchObject({ source: "platform-derived", cpuPercent: 28 });
await expect(client.listServerMetrics()).resolves.toMatchObject({ count: 1, items: [{ serverInstanceId: server.id, online: true }] }); await expect(client.listServerMetrics()).resolves.toMatchObject({ count: 1, items: [{ serverInstanceId: server.id, online: true }] });
await expect(client.getServerConfig(server.id)).resolves.toMatchObject({ content: "server.name=Example Survival #1\n" }); await expect(client.listSCUMPlayers(server.id)).resolves.toMatchObject({ count: 1, items: [{ gamePlayerId: "steam-1" }] });
await expect( await expect(client.listSCUMSquads(server.id)).resolves.toMatchObject({ count: 1 });
client.previewServerConfigDiff(server.id, { expectedConfigVersion: 1, key: "server.properties", proposedContent: "server.name=Example Survival #2\n" }) await expect(client.listSCUMSquadMembers(server.id)).resolves.toMatchObject({ count: 1 });
).resolves.toMatchObject({ hasChanges: true, source: "platform-review" }); await expect(client.listSCUMVehicles(server.id)).resolves.toMatchObject({ count: 1 });
await expect( await expect(client.listSCUMFlags(server.id)).resolves.toMatchObject({ count: 1 });
client.approveServerConfigWrite(server.id, { expectedConfigVersion: 1, key: "server.properties", proposedContent: "server.name=Example Survival #2\n", idempotencyKey: "idem-config" }) await expect(client.listSCUMPositions(server.id)).resolves.toMatchObject({ count: 1 });
).resolves.toMatchObject({ status: "queued", job: { capability: "config.write", targetKey: "server.properties" } }); 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({ await expect(client.dispatchFileOperation({ serverInstanceId: server.id, operation: "read", key: "logs/latest.log", idempotencyKey: "idem-file" })).resolves.toMatchObject({
status: "queued", status: "queued",
job: { capability: "files.read", targetKey: "logs/latest.log" } job: { capability: "files.read", targetKey: "logs/latest.log" }
@@ -665,21 +620,11 @@ describe("PlatformApiClient AI providers", () => {
await expect(client.installDependencies(server.id, { probeKey: "java-21", installPlanKey: "install-java-linux", planDigest: runtimeDigest, idempotencyKey: "idem-dep-install" })).resolves.toMatchObject({ await expect(client.installDependencies(server.id, { probeKey: "java-21", installPlanKey: "install-java-linux", planDigest: runtimeDigest, idempotencyKey: "idem-dep-install" })).resolves.toMatchObject({
capability: "dependencies.install" capability: "dependencies.install"
}); });
await expect(client.listServerLiveLogs(server.id)).resolves.toMatchObject({ count: 0 });
await expect(client.requestLogBackfill(server.id, { sourceKey: "latest-log", checkpointRef: "artifact://logs/checkpoint/1", limit: 200, idempotencyKey: "idem-log-backfill" })).resolves.toMatchObject({
capability: "logs.backfill"
});
await expect(client.authorizePluginBridge({ pluginId: plugin.id, routeKey: "logs", action: "logs.query" })).resolves.toMatchObject({
allowed: true
});
await expect(
client.executePluginBridge({ requestId: "req-bridge", pluginId: plugin.id, routeKey: "logs", serverInstanceId: server.id, action: "logs.query", payload: { logStreamId: "log-1" } })
).resolves.toMatchObject({ status: "ok", result: { entryCount: "0" } });
await expect( await expect(
client.invokeAI({ requestId: "ai-1", serverInstanceId: server.id, purpose: "config.suggest", prompt: "Tune PVP safely", currentConfig: "server.name=Example Survival #1\n" }) 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" } }); ).resolves.toMatchObject({ status: "ok", usage: { mocked: true }, configRecommendation: { diffSummary: "review required" } });
expect(fetchMock).toHaveBeenCalledTimes(43); expect(fetchMock).toHaveBeenCalledTimes(48);
}); });
it("calls plugin marketplace endpoints with filter and state contracts", async () => { it("calls plugin marketplace endpoints with filter and state contracts", async () => {
@@ -708,11 +653,11 @@ describe("PlatformApiClient AI providers", () => {
expect(fetchMock).toHaveBeenCalledTimes(3); expect(fetchMock).toHaveBeenCalledTimes(3);
}); });
it("surfaces config diff preview failures from the platform", async () => { it("surfaces SCUM typed operation failures from the platform", async () => {
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input); const url = String(input);
if (url.endsWith("/api/v1/server-instances/server-1/config/diff") && init?.method === "POST") { if (url.endsWith("/api/v1/server-instances/server-1/scum/operations") && init?.method === "POST") {
return new Response(JSON.stringify({ code: "validation", message: "expectedConfigVersion must match server instance" }), { return new Response(JSON.stringify({ code: "validation", message: "SCUM operation template is not declared" }), {
status: 400, status: 400,
headers: { "Content-Type": "application/json" } headers: { "Content-Type": "application/json" }
}); });
@@ -723,9 +668,7 @@ describe("PlatformApiClient AI providers", () => {
const client = new PlatformApiClient(); const client = new PlatformApiClient();
await expect(client.previewServerConfigDiff(server.id, { expectedConfigVersion: 0, key: "server.properties", proposedContent: "changed=true\n" })).rejects.toThrow( await expect(client.createSCUMOperation(server.id, { templateKey: "raw.sql", reason: "unsafe", idempotencyKey: "bad-scum-op" })).rejects.toThrow("SCUM operation template is not declared");
"expectedConfigVersion must match server instance"
);
}); });
it("keeps raw key and base URL fields out of provider responses", () => { it("keeps raw key and base URL fields out of provider responses", () => {
+51 -51
View File
@@ -36,7 +36,6 @@ import type {
CurrentUserResponse, CurrentUserResponse,
DependencyCatalogResponse, DependencyCatalogResponse,
DependencyJobRequest, DependencyJobRequest,
DeclaredFileReadSnapshotResponse,
FileOperationDispatchRequest, FileOperationDispatchRequest,
FileOperationDispatchResponse, FileOperationDispatchResponse,
GameClientBridgeCancelRequest, GameClientBridgeCancelRequest,
@@ -55,8 +54,6 @@ import type {
JobResponse, JobResponse,
LlmConfigSuggestionRequest, LlmConfigSuggestionRequest,
LlmConfigSuggestionResponse, LlmConfigSuggestionResponse,
LogBackfillRequest,
LogStreamEventOptions,
LogStreamCursorRequest, LogStreamCursorRequest,
LogStreamCursorResponse, LogStreamCursorResponse,
LogStreamListResponse, LogStreamListResponse,
@@ -84,19 +81,12 @@ import type {
RunUpdateRequest, RunUpdateRequest,
RuntimeBindingResponse, RuntimeBindingResponse,
RuntimeBindingUpdateRequest, RuntimeBindingUpdateRequest,
ServerConfigResponse,
ServerConfigDiffPreviewRequest,
ServerConfigDiffPreviewResponse,
ServerLifecycleCommandRequest, ServerLifecycleCommandRequest,
ServerLifecycleCreateRequest, ServerLifecycleCreateRequest,
ServerLifecycleResponse, ServerLifecycleResponse,
ServerDeploymentRequest, ServerDeploymentRequest,
ServerDeploymentRevealResponse, ServerDeploymentRevealResponse,
ServerDeploymentResponse, ServerDeploymentResponse,
ServerConfigWriteApprovalRequest,
ServerConfigWriteDispatchResponse,
SourceRCONCommandRequest,
SourceRCONCommandResponse,
ServerInstanceListResponse, ServerInstanceListResponse,
ServerDeletionRequest, ServerDeletionRequest,
ServerInstanceUpdateRequest, ServerInstanceUpdateRequest,
@@ -110,6 +100,14 @@ import type {
RemoteAdapterDeclarationListResponse, RemoteAdapterDeclarationListResponse,
RemoteAdapterRequest, RemoteAdapterRequest,
RemoteAdapterResponse, RemoteAdapterResponse,
SCUMListResponse,
SCUMOperationListResponse,
SCUMOperationRequest,
SCUMOperationResponse,
SCUMWorkflowCreateRequest,
SCUMWorkflowListResponse,
SCUMWorkflowResponse,
SCUMWorkflowStepListResponse,
ServerRuntimeActionsResponse, ServerRuntimeActionsResponse,
UserCreateRequest, UserCreateRequest,
UserListResponse, UserListResponse,
@@ -444,28 +442,6 @@ export class PlatformApiClient {
}); });
} }
async listServerLiveLogs(id: string): Promise<LogStreamListResponse> {
return this.request<LogStreamListResponse>(`/server-instances/${encodeURIComponent(id)}/logs/live`);
}
openServerLogEvents(id: string, options: LogStreamEventOptions = {}): EventSource {
return new EventSource(this.serverLogEventsUrl(id, options), { withCredentials: true });
}
serverLogEventsUrl(id: string, options: LogStreamEventOptions = {}): string {
const params = new URLSearchParams();
if (options.historyLimit !== undefined) params.set("historyLimit", String(options.historyLimit));
const query = params.toString();
return `${this.baseUrl}/server-instances/${encodeURIComponent(id)}/logs/events${query ? `?${query}` : ""}`;
}
async requestLogBackfill(id: string, request: LogBackfillRequest): Promise<JobResponse> {
return this.request<JobResponse>(`/server-instances/${encodeURIComponent(id)}/logs/backfill`, {
method: "POST",
body: request
});
}
async createJob(request: JobCreateRequest): Promise<JobResponse> { async createJob(request: JobCreateRequest): Promise<JobResponse> {
return this.request<JobResponse>("/jobs", { method: "POST", body: request }); return this.request<JobResponse>("/jobs", { method: "POST", body: request });
} }
@@ -599,26 +575,55 @@ export class PlatformApiClient {
return this.request<RemoteAdapterResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/remote-adapters`, { method: "POST", body: request }); return this.request<RemoteAdapterResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/remote-adapters`, { method: "POST", body: request });
} }
async sendSourceRCONCommand(serverInstanceId: string, request: SourceRCONCommandRequest): Promise<SourceRCONCommandResponse> { async listSCUMPlayers(serverInstanceId: string): Promise<SCUMListResponse> {
return this.request<SourceRCONCommandResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/rcon/commands`, { method: "POST", body: request }); return this.request<SCUMListResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/players`);
} }
async getServerConfig(id: string): Promise<ServerConfigResponse> { async listSCUMSquads(serverInstanceId: string): Promise<SCUMListResponse> {
return this.request<ServerConfigResponse>(`/server-instances/${encodeURIComponent(id)}/config`); return this.request<SCUMListResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/squads`);
} }
async previewServerConfigDiff(id: string, request: ServerConfigDiffPreviewRequest): Promise<ServerConfigDiffPreviewResponse> { async listSCUMSquadMembers(serverInstanceId: string): Promise<SCUMListResponse> {
return this.request<ServerConfigDiffPreviewResponse>(`/server-instances/${encodeURIComponent(id)}/config/diff`, { return this.request<SCUMListResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/squad-members`);
method: "POST",
body: request
});
} }
async approveServerConfigWrite(id: string, request: ServerConfigWriteApprovalRequest): Promise<ServerConfigWriteDispatchResponse> { async listSCUMVehicles(serverInstanceId: string): Promise<SCUMListResponse> {
return this.request<ServerConfigWriteDispatchResponse>(`/server-instances/${encodeURIComponent(id)}/config/approve`, { return this.request<SCUMListResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/vehicles`);
method: "POST", }
body: request
}); async listSCUMFlags(serverInstanceId: string): Promise<SCUMListResponse> {
return this.request<SCUMListResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/flags`);
}
async listSCUMPositions(serverInstanceId: string): Promise<SCUMListResponse> {
return this.request<SCUMListResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/positions`);
}
async listSCUMOperations(serverInstanceId: string): Promise<SCUMOperationListResponse> {
return this.request<SCUMOperationListResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/operations`);
}
async createSCUMOperation(serverInstanceId: string, request: SCUMOperationRequest): Promise<SCUMOperationResponse> {
return this.request<SCUMOperationResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/operations`, { method: "POST", body: request });
}
async approveSCUMOperation(serverInstanceId: string, operationId: string): Promise<SCUMOperationResponse> {
return this.request<SCUMOperationResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/operations/${encodeURIComponent(operationId)}/approve`, { method: "POST", body: {} });
}
async listSCUMWorkflows(serverInstanceId: string): Promise<SCUMWorkflowListResponse> {
return this.request<SCUMWorkflowListResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/workflows`);
}
async createSCUMWorkflow(serverInstanceId: string, request: SCUMWorkflowCreateRequest): Promise<SCUMWorkflowResponse> {
return this.request<SCUMWorkflowResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/workflows`, { method: "POST", body: request });
}
async listSCUMWorkflowSteps(serverInstanceId: string, workflowId?: string): Promise<SCUMWorkflowStepListResponse> {
const params = new URLSearchParams();
if (workflowId) params.set("workflowId", workflowId);
const query = params.toString();
return this.request<SCUMWorkflowStepListResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/workflow-steps${query ? `?${query}` : ""}`);
} }
async dispatchFileOperation(request: FileOperationDispatchRequest): Promise<FileOperationDispatchResponse> { async dispatchFileOperation(request: FileOperationDispatchRequest): Promise<FileOperationDispatchResponse> {
@@ -628,11 +633,6 @@ export class PlatformApiClient {
}); });
} }
async getDeclaredFileReadSnapshot(serverInstanceId: string, fileKey: string): Promise<DeclaredFileReadSnapshotResponse> {
const query = new URLSearchParams({ key: fileKey });
return this.request<DeclaredFileReadSnapshotResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/files/read-snapshot?${query.toString()}`);
}
async listLogStreams(): Promise<LogStreamListResponse> { async listLogStreams(): Promise<LogStreamListResponse> {
return this.request<LogStreamListResponse>("/log-streams"); return this.request<LogStreamListResponse>("/log-streams");
} }
+6 -7
View File
@@ -6,12 +6,12 @@ API clients and DTO types live here, not inside page components.
- `users`: user and role APIs. - `users`: user and role APIs.
- `serverPlugins`: plugin marketplace and installed plugin APIs. - `serverPlugins`: plugin marketplace and installed plugin APIs.
- `serverInstances`: create server, lifecycle, config read, config diff/approval, scoped files, logs, and detail APIs. - `serverInstances`: create server, lifecycle, deployment/member/detail APIs, and SCUM typed projection/workflow APIs.
- `aiProviders`: provider CRUD, test, and model APIs. - `aiProviders`: provider CRUD, test, and model APIs.
- `jobs`: job status and operation APIs. - `jobs`: job status and operation APIs.
- `runEndpoints`: run endpoint status, lifecycle capabilities, and capacity APIs. - `runEndpoints`: run endpoint status, lifecycle capabilities, and capacity APIs.
- `artifacts`: artifact upload/download APIs. - `artifacts`: artifact upload/download APIs.
- `logs`: historical query and tail APIs. - `logs`: internal historical cursor APIs used for maintenance/debug views, not server-detail raw log products.
- `pluginPageBridge`: safe bridge APIs for hosted plugin page. - `pluginPageBridge`: safe bridge APIs for hosted plugin page.
Every API client must use named request and response types. Every API client must use named request and response types.
@@ -26,8 +26,8 @@ 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. - `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. - `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. - `listServerAdministratorCandidates`, `addServerAdministrator`, and `removeServerAdministrator` call server membership endpoints so server owners can invite or remove active non-platform-admin server administrators.
- `getServerConfig`, `previewServerConfigDiff`, and `approveServerConfigWrite` call platform-mediated config routes. ServerDetailPage must preview the platform diff first, keep the explicit confirmation step, and dispatch writes only through the approval API. - 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.
- `dispatchFileOperation` posts `FileOperationDispatchRequest` to `/file-operations/dispatch` using logical file keys and scoped refs rather than raw host paths. - `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. - `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. - `authorizePluginBridge` posts `PluginBridgeAuthorizeRequest` to `/plugin-bridge/authorize` for preflight decisions.
- `executePluginBridge` posts `PluginBridgeExecuteRequest` to `/plugin-bridge/execute` from host-owned bridge dispatch utilities only. Plugin pages receive typed `PluginBridgeExecuteResponse` envelopes and never receive the platform API client, bearer token, raw provider key, run socket, host path, or storage credential. - `executePluginBridge` posts `PluginBridgeExecuteRequest` to `/plugin-bridge/execute` from host-owned bridge dispatch utilities only. Plugin pages receive typed `PluginBridgeExecuteResponse` envelopes and never receive the platform API client, bearer token, raw provider key, run socket, host path, or storage credential.
@@ -52,15 +52,14 @@ Existing platform APIs already cover server lifecycle, jobs, log stream metadata
- `PUT /api/v1/users/current/theme` (`UserThemePreferenceRequest`/`UserThemePreferenceResponse`): implemented per-user theme preferences, including selected palette IDs such as `mecha-black` or `magical-girl`, uploaded background reference or safe persisted data URL metadata, and readable overlay preference. - `PUT /api/v1/users/current/theme` (`UserThemePreferenceRequest`/`UserThemePreferenceResponse`): implemented per-user theme preferences, including selected palette IDs such as `mecha-black` or `magical-girl`, uploaded background reference or safe persisted data URL metadata, and readable overlay preference.
- `GET /api/v1/metrics/platform` (`PlatformResourceUsageResponse`): implemented platform-level CPU/memory/disk usage and LLM connectivity summary for the overview first screen. - `GET /api/v1/metrics/platform` (`PlatformResourceUsageResponse`): implemented platform-level CPU/memory/disk usage and LLM connectivity summary for the overview first screen.
- `GET /api/v1/metrics/server-instances` (`ServerMetricsListResponse`): implemented per-server online state, player count, TPS, latency, CPU/memory/disk for server cards and the server detail header. - `GET /api/v1/metrics/server-instances` (`ServerMetricsListResponse`): implemented per-server online state, player count, TPS, latency, CPU/memory/disk for server cards and the server detail header.
- `GET /api/v1/server-instances/{id}/config` (`ServerConfigResponse`): implemented readable configuration content for diff-based editing. - Server-scoped raw config routes (`GET /api/v1/server-instances/{id}/config`, `POST .../config/diff`, `POST .../config/approve`) are removed from the product API. AI configuration assistance uses `/api/v1/ai/invocations` plus reviewable AI config-diff approval APIs; plugin pages do not receive raw config text.
- `POST /api/v1/server-instances/{id}/config/diff` (`ServerConfigDiffPreviewRequest`/`ServerConfigDiffPreviewResponse`) and `POST /api/v1/server-instances/{id}/config/approve` (`ServerConfigWriteApprovalRequest`/`ServerConfigWriteDispatchResponse`): implemented platform-mediated config write review and approval. Manual config edits and AI suggestion applies must not create generic `config.write` jobs through `POST /api/v1/jobs`.
- `POST /api/v1/file-operations/dispatch` (`FileOperationDispatchRequest`/`FileOperationDispatchResponse`): implemented scoped file operation dispatch using logical keys and refs only. - `POST /api/v1/file-operations/dispatch` (`FileOperationDispatchRequest`/`FileOperationDispatchResponse`): implemented scoped file operation dispatch using logical keys and refs only.
- `POST /api/v1/ai/config-suggestions` (`LlmConfigSuggestionRequest`/`LlmConfigSuggestionResponse`) and `POST /api/v1/ai/invocations` (`AIInvocationRequest`/`AIInvocationResponse`): platform-mediated AI recommendation or diff scoped to one server. Provider keys stay in `platform/`; responses carry only recommendation text, usage metadata, and reviewable suggestions, never keys or provider secrets. - `POST /api/v1/ai/config-suggestions` (`LlmConfigSuggestionRequest`/`LlmConfigSuggestionResponse`) and `POST /api/v1/ai/invocations` (`AIInvocationRequest`/`AIInvocationResponse`): platform-mediated AI recommendation or diff scoped to one server. Provider keys stay in `platform/`; responses carry only recommendation text, usage metadata, and reviewable suggestions, never keys or provider secrets.
- Per-server plugin controls are rendered from installed plugin manifests (`bridgeActions`, `lifecycleActions`, `pages`, `declaredPermissions`); a richer declared-control schema remains a future plugin contract. Hosted bridge execution uses `POST /api/v1/plugin-bridge/execute` for server context, scoped file, log, job, artifact reference, and AI action envelopes instead of direct plugin fetches to platform internals. - Per-server plugin controls are rendered from installed plugin manifests (`bridgeActions`, `lifecycleActions`, `pages`, `declaredPermissions`); a richer declared-control schema remains a future plugin contract. Hosted bridge execution uses `POST /api/v1/plugin-bridge/execute` for server context, scoped file, log, job, artifact reference, and AI action envelopes instead of direct plugin fetches to platform internals.
- Operation/job traceability reuses `GET /api/v1/jobs`, `GET /api/v1/jobs/{id}`, `POST /api/v1/jobs/{id}/cancel`, and `GET /api/v1/audit-events`; the frontend wraps these in one visible operation lifecycle per user intent. - Operation/job traceability reuses `GET /api/v1/jobs`, `GET /api/v1/jobs/{id}`, `POST /api/v1/jobs/{id}/cancel`, and `GET /api/v1/audit-events`; the frontend wraps these in one visible operation lifecycle per user intent.
Browser Job contracts explicitly exclude raw or hashed lease tokens, Run session tokens/generations, secret refs, host paths, sockets, and credentials. The safe schema rejects those keys, and existing API client 401/403 behavior remains authoritative for expired sessions and cross-owner access. Browser Job contracts explicitly exclude raw or hashed lease tokens, Run session tokens/generations, secret refs, host paths, sockets, and credentials. The safe schema rejects those keys, and existing API client 401/403 behavior remains authoritative for expired sessions and cross-owner access.
- Live log and management-terminal output uses `GET /api/v1/server-instances/{id}/logs/events` as a single `EventSource`/SSE stream with bounded initial history. `POST /api/v1/log-streams/query` remains available for explicit historical cursor reads and reconnect repair, not periodic browser polling. - Server-scoped raw log routes (`GET /api/v1/server-instances/{id}/logs/live`, `GET .../logs/events`, `POST .../logs/backfill`) and management-terminal/RCON input routes are removed from product clients. Internal log ingest and cursor query remain available to platform services and maintenance/debug flows.
# Client Manager API projection # Client Manager API projection
`PlatformApiClient` exposes list/detail and typed deploy, control, update, retry, revoke-session, and uninstall methods. `schemas/clientManagerLifecycle.ts` validates status/action/job/health fields and rejects forbidden machine or credential fields before rendering. Lifecycle commands carry profile, distribution, expected deployment generation, approval/confirmation, and idempotency only. Artifact bytes remain in the platform-owned artifact transfer client. `PlatformApiClient` exposes list/detail and typed deploy, control, update, retry, revoke-session, and uninstall methods. `schemas/clientManagerLifecycle.ts` validates status/action/job/health fields and rejects forbidden machine or credential fields before rendering. Lifecycle commands carry profile, distribution, expected deployment generation, approval/confirmation, and idempotency only. Artifact bytes remain in the platform-owned artifact transfer client.
-44
View File
@@ -1,44 +0,0 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { PlatformApiClient } from "./client";
import type { SourceRCONCommandRequest } from "./types";
describe("PlatformApiClient Source RCON command dispatch", () => {
afterEach(() => vi.unstubAllGlobals());
it("posts the typed server-scoped request and projects only safe queue state", async () => {
const request: SourceRCONCommandRequest = {
kind: "chat",
chatType: 4,
message: "Maintenance complete",
targetSteamId: "76561198000000001",
idempotencyKey: "web:source-rcon:chat:server-1:12"
};
const calls: Array<{ url: string; method: string; body?: unknown; authorization: string | null }> = [];
vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
calls.push({
url: String(input),
method: init?.method ?? "GET",
body: init?.body ? JSON.parse(String(init.body)) : undefined,
authorization: new Headers(init?.headers).get("Authorization")
});
return new Response(JSON.stringify({ jobId: "job-source-rcon", serverInstanceId: "server-1", status: "queued", message: "SCUM RCON command queued" }), {
status: 202,
headers: { "Content-Type": "application/json" }
});
}));
const response = await new PlatformApiClient("/api/v1", () => "operator-session").sendSourceRCONCommand("server/1", request);
expect(calls).toEqual([{
url: "/api/v1/server-instances/server%2F1/rcon/commands",
method: "POST",
body: request,
authorization: "Bearer operator-session"
}]);
expect(response).toEqual({ jobId: "job-source-rcon", serverInstanceId: "server-1", status: "queued", message: "SCUM RCON command queued" });
for (const forbidden of ["command", "password", "host", "configRef", "response"]) {
expect(Object.keys(response)).not.toContain(forbidden);
}
});
});
+11
View File
@@ -1373,6 +1373,17 @@ export interface RemoteAdapterResponse {
completedAt?: string; completedAt?: string;
} }
export type SCUMJsonRecord = Record<string, unknown>;
export interface SCUMListResponse<T = SCUMJsonRecord> { items: T[]; count: number; }
export interface SCUMWorkflowCreateRequest { templateKey: string; idempotencyKey: string; input?: SCUMJsonRecord; }
export interface SCUMOperationRequest { templateKey: string; playerId?: string; payload?: SCUMJsonRecord; guard?: SCUMJsonRecord; reason: string; idempotencyKey: string; }
export interface SCUMWorkflowResponse { id: string; serverInstanceId: string; pluginId: string; templateKey: string; requestedBy?: string; idempotencyKey?: string; status: string; currentStepKey?: string; input?: SCUMJsonRecord; safeSummary?: SCUMJsonRecord; blockerReason?: string; auditReferences?: string[]; createdAt: string; updatedAt: string; completedAt?: string; }
export interface SCUMWorkflowStepResponse { id: string; workflowId: string; serverInstanceId: string; stepKey: string; dependsOn?: string[]; status: string; operationKey?: string; queryTemplateKey?: string; capability?: string; targetKey?: string; jobId?: string; attempt?: number; maxAttempts?: number; mutatesState?: boolean; confirmation?: SCUMJsonRecord; safeSummary?: SCUMJsonRecord; blockerReason?: string; auditReferences?: string[]; createdAt: string; updatedAt: string; completedAt?: string; }
export interface SCUMOperationResponse { id: string; serverInstanceId: string; pluginId: string; templateKey: string; playerId?: string; requesterId?: string; approverId?: string; approvalLevel: string; payload?: SCUMJsonRecord; guard?: SCUMJsonRecord; confirmation?: SCUMJsonRecord; status: string; reason?: string; runJobId?: string; safeSummary?: SCUMJsonRecord; auditReferences?: string[]; createdAt: string; approvedAt?: string; completedAt?: string; updatedAt: string; }
export type SCUMWorkflowListResponse = SCUMListResponse<SCUMWorkflowResponse>;
export type SCUMWorkflowStepListResponse = SCUMListResponse<SCUMWorkflowStepResponse>;
export type SCUMOperationListResponse = SCUMListResponse<SCUMOperationResponse>;
export interface ServerConfigResponse { export interface ServerConfigResponse {
serverInstanceId: string; serverInstanceId: string;
configVersion: number; configVersion: number;
@@ -1,368 +0,0 @@
import { ListChecks, Send, Sparkles, Terminal, Trash2, X } from "lucide-react";
import { type FormEvent, type KeyboardEvent as ReactKeyboardEvent, type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { platformApiClient } from "../api/client";
import type { GameClientBridgeCommandResponse, LogEntryBody, LogStreamResponse } from "../api/types";
import { scumManagementRCONCommandRequest } from "../schemas/scumManagementRcon";
import { cx } from "../utils/classes";
import { mergeLogStreams, parseLogStreamEvent, parseServerLogEvent, streamFromServerLogEvent } from "../utils/logEvents";
import { EmptyState, ErrorState, LoadingState, ResultBadge } from "./StateViews";
type LoadState<T> = { status: "loading" } | { status: "error"; reason: string } | { status: "ready"; data: T };
type TerminalLine = { id: string; tone: "input" | "info" | "success" | "warn" | "error"; text: string; at: string; sortKey: number; streamKey?: string; level?: string; seq?: number };
type TerminalQuickCommand = { label: string; command: string; hint: string };
const terminalBridgeResultPollMs = 1000;
const terminalBridgeResultPollAttempts = 30;
const terminalInitialHistoryWindow = 500;
const maxTerminalLines = 10000;
const terminalQuickCommandCatalog: Record<string, TerminalQuickCommand[]> = {
"game.scum": [
{ label: "查询玩家", command: "#ListPlayers", hint: "在线玩家列表" },
{ label: "查询队伍", command: "#ListSquads 1", hint: "服务器队伍列表" },
{ label: "查询车辆", command: "#ListSpawnedVehicles true", hint: "已生成车辆列表" },
{ label: "设为中午", command: "#SetTime 12", hint: "设置游戏时间" }
]
};
interface LiveOperationDrawerProps {
open: boolean;
title: string;
description?: string;
onClose: () => void;
children: ReactNode;
backdropClassName?: string;
panelClassName?: string;
bodyClassName?: string;
hideHeader?: boolean;
}
function LiveOperationDrawer({ open, title, description, onClose, children, backdropClassName, panelClassName, bodyClassName, hideHeader }: LiveOperationDrawerProps) {
useEffect(() => {
if (!open) return undefined;
const previous = document.body.style.overflow;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") onClose();
};
document.body.style.overflow = "hidden";
document.addEventListener("keydown", handleKeyDown);
return () => {
document.body.style.overflow = previous;
document.removeEventListener("keydown", handleKeyDown);
};
}, [onClose, open]);
if (!open) return null;
return (
<div className={cx("drawer-backdrop", backdropClassName)} role="presentation" onClick={onClose}>
<aside className={cx("drawer-panel live-operation-drawer", panelClassName)} role="dialog" aria-modal="true" aria-label={title} onClick={(event) => event.stopPropagation()}>
{!hideHeader && (
<div className="panel-header">
<div>
<h2>{title}</h2>
{description && <p className="page-status">{description}</p>}
</div>
<button type="button" className="theme-upload drawer-close" aria-label={`关闭${title}`} onClick={onClose}><X size={14} /><span></span></button>
</div>
)}
<div className={cx("live-operation-content", bodyClassName)}>{children}</div>
</aside>
</div>
);
}
interface ServerManagementTerminalDrawerProps {
open: boolean;
serverId: string;
serverName: string;
pluginId: string;
canManage: boolean;
onClose: () => void;
}
export function ServerManagementTerminalDrawer({ open, serverId, serverName, pluginId, canManage, onClose }: ServerManagementTerminalDrawerProps) {
const [command, setCommand] = useState("");
const [pending, setPending] = useState(false);
const [lines, setLines] = useState<TerminalLine[]>([]);
const [streams, setStreams] = useState<LoadState<LogStreamResponse[]>>({ status: "loading" });
const [commandHistory, setCommandHistory] = useState<string[]>([]);
const [historyIndex, setHistoryIndex] = useState<number | null>(null);
const [result, setResult] = useState<{ status: "pending" | "succeeded" | "failed"; label: string } | null>(null);
const [followLatest, setFollowLatest] = useState(true);
const outputRef = useRef<HTMLDivElement>(null);
const followLatestRef = useRef(true);
const initialHistoryPendingRef = useRef(false);
const quickCommands = useMemo(() => terminalQuickCommandsForPlugin(pluginId), [pluginId]);
const supportsCommands = quickCommands.length > 0;
const appendLines = useCallback((incoming: TerminalLine[]) => {
if (incoming.length === 0) return;
setLines((current) => mergeTerminalLines(current, incoming));
}, []);
const lockTerminalFollow = useCallback(() => {
followLatestRef.current = true;
setFollowLatest(true);
window.requestAnimationFrame(() => {
const output = outputRef.current;
if (output) output.scrollTop = output.scrollHeight;
window.requestAnimationFrame(() => {
const output = outputRef.current;
if (output) output.scrollTop = output.scrollHeight;
initialHistoryPendingRef.current = false;
});
});
}, []);
useEffect(() => {
if (!open) return;
setStreams({ status: "loading" });
setCommand("");
setPending(false);
setResult(null);
setHistoryIndex(null);
initialHistoryPendingRef.current = true;
followLatestRef.current = true;
setFollowLatest(true);
setLines([terminalSystemLine("info", supportsCommands ? "连接平台日志推送,先补最近历史再实时追加。" : "该插件暂未声明可用的管理终端命令通道。", "SYSTEM")]);
}, [open, supportsCommands]);
useEffect(() => {
if (!open || initialHistoryPendingRef.current || !followLatestRef.current) return undefined;
const frame = window.requestAnimationFrame(() => {
const output = outputRef.current;
if (output) output.scrollTop = output.scrollHeight;
});
return () => window.cancelAnimationFrame(frame);
}, [lines, open]);
useEffect(() => {
if (!open) return undefined;
let ready = false;
const events = platformApiClient.openServerLogEvents(serverId, { historyLimit: terminalInitialHistoryWindow });
events.addEventListener("stream", (event) => {
const stream = parseLogStreamEvent(event);
if (!stream) return;
ready = true;
setStreams((current) => ({ status: "ready", data: mergeLogStreams(current.status === "ready" ? current.data : [], stream) }));
});
events.addEventListener("ready", () => {
ready = true;
setStreams((current) => current.status === "ready" ? current : { status: "ready", data: [] });
lockTerminalFollow();
});
events.addEventListener("log", (event) => {
const payload = parseServerLogEvent(event);
if (!payload) return;
ready = true;
const stream = streamFromServerLogEvent(payload);
setStreams((current) => ({ status: "ready", data: mergeLogStreams(current.status === "ready" ? current.data : [], stream) }));
appendLines([terminalLineFromLog(stream, payload.entry)]);
});
events.onerror = () => {
if (!ready) setStreams({ status: "error", reason: "实时日志推送连接失败" });
};
return () => events.close();
}, [appendLines, lockTerminalFollow, open, serverId]);
function selectQuickCommand(item: TerminalQuickCommand) {
setCommand(item.command);
setHistoryIndex(null);
}
function handleCommandKeyDown(event: ReactKeyboardEvent<HTMLInputElement>) {
if (event.key !== "ArrowUp" && event.key !== "ArrowDown") return;
if (commandHistory.length === 0) return;
event.preventDefault();
const lastIndex = commandHistory.length - 1;
if (event.key === "ArrowUp") {
const nextIndex = historyIndex === null ? lastIndex : Math.max(0, historyIndex - 1);
setHistoryIndex(nextIndex);
setCommand(commandHistory[nextIndex] ?? "");
return;
}
if (historyIndex === null) return;
const nextIndex = historyIndex + 1;
if (nextIndex > lastIndex) {
setHistoryIndex(null);
setCommand("");
return;
}
setHistoryIndex(nextIndex);
setCommand(commandHistory[nextIndex] ?? "");
}
function clearTerminalBuffer() {
setLines([]);
}
function handleTerminalScroll() {
const output = outputRef.current;
if (!output || initialHistoryPendingRef.current) return;
const nextFollowLatest = output.scrollHeight - output.clientHeight - output.scrollTop <= 24;
followLatestRef.current = nextFollowLatest;
setFollowLatest(nextFollowLatest);
}
async function submitCommand(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!supportsCommands || !canManage || pending || !command.trim()) return;
const submitted = command.trim();
setCommand("");
setHistoryIndex(null);
setCommandHistory((current) => [...current.filter((item) => item !== submitted), submitted].slice(-50));
setPending(true);
setResult({ status: "pending", label: "正在提交命令" });
appendLines([terminalSystemLine("input", `> ${submitted}`, "COMMAND")]);
try {
const response = await platformApiClient.queueGameClientBridgeCommand(serverId, scumManagementRCONCommandRequest(serverId, submitted));
const label = bridgeCommandDispatchLabel(response.state, response.id);
setResult({ status: "pending", label: `${label} · 等待 Run 返回结果` });
appendLines([terminalSystemLine("success", `${label} · protected RCON`, "PLATFORM", `ok-${response.id}`)]);
const finalCommand = await waitForBridgeCommandTerminal(response.id);
if (finalCommand) {
const outcome = terminalLineFromBridgeCommand(finalCommand);
setResult({ status: outcome.tone === "success" ? "succeeded" : "failed", label: outcome.text });
appendLines([outcome]);
} else {
const timeoutLine = terminalSystemLine("warn", `桥接命令 ${response.id} 已排队,但尚未返回终态;继续观察实时日志。`, "PLATFORM", `pending-${response.id}`);
setResult({ status: "pending", label: "等待 Run 返回结果" });
appendLines([timeoutLine]);
}
} catch (error) {
const label = error instanceof Error ? error.message : "命令提交失败";
setResult({ status: "failed", label });
appendLines([terminalSystemLine("error", label, "ERROR")]);
} finally {
setPending(false);
}
}
async function waitForBridgeCommandTerminal(commandId: string): Promise<GameClientBridgeCommandResponse | null> {
for (let attempt = 0; attempt < terminalBridgeResultPollAttempts; attempt += 1) {
const current = await platformApiClient.getGameClientBridgeCommand(serverId, commandId);
if (isTerminalBridgeCommandState(current.state)) return current;
await delay(terminalBridgeResultPollMs);
}
return null;
}
return (
<LiveOperationDrawer open={open} title="管理终端" onClose={onClose} backdropClassName="terminal-drawer-backdrop" panelClassName="management-terminal-drawer" bodyClassName="management-terminal-body" hideHeader>
<section className="terminal-output-panel" aria-label="terminal output">
<div className="terminal-output-topbar">
<div>
<strong>{serverName}</strong>
<span> Run · + SSE · {streams.status === "ready" ? "等待当前输出" : streams.status === "loading" ? "连接日志流" : "日志流异常"} · {followLatest ? "自动置底" : "已解锁滚动"}</span>
</div>
<div>
<button type="button" className="terminal-output-action" onClick={clearTerminalBuffer}><Trash2 size={14} /><span></span></button>
<button type="button" className="terminal-output-action" aria-label="关闭管理终端" onClick={onClose}><X size={14} /><span></span></button>
</div>
</div>
<div ref={outputRef} className="terminal-output" role="log" aria-live="polite" onScroll={handleTerminalScroll}>
{streams.status === "error" && <div className="terminal-line terminal-line-error"><time>{new Date().toLocaleTimeString()}</time><span className="terminal-stream">LOGS</span><span className="terminal-text">{streams.reason}</span></div>}
{streams.status === "ready" && streams.data.length === 0 && <div className="terminal-line terminal-line-warn"><time>{new Date().toLocaleTimeString()}</time><span className="terminal-stream">LOGS</span><span className="terminal-text">Run </span></div>}
{lines.map((line) => <div key={line.id} className={`terminal-line terminal-line-${line.tone}`}><time>{line.at}</time><span className="terminal-stream">{line.streamKey || line.level || "LOG"}</span><span className="terminal-text">{line.text}</span></div>)}
</div>
</section>
<section className="terminal-command-dock" aria-label="terminal command controls">
<div className="terminal-command-dock-header">
<span><ListChecks size={14} />{quickCommands.length > 0 ? "插件快捷指令" : "命令输入"}</span>
{result && <ResultBadge status={result.status} label={result.label} />}
</div>
{quickCommands.length > 0 && <div className="terminal-quick-command-list">
{quickCommands.map((item) => <button key={item.command} type="button" className="terminal-quick-command" disabled={!canManage || pending} onClick={() => selectQuickCommand(item)}><strong>{item.label}</strong><span>{item.command}</span><small>{item.hint}</small></button>)}
</div>}
{!supportsCommands && <EmptyState icon={<Terminal size={24} />} title="终端不可用" description="当前插件没有声明平台可调度的即时命令能力,因此不会开放浏览器直连 shell。" />}
{supportsCommands && (
<form className="terminal-command-form" onSubmit={(event) => void submitCommand(event)}>
<label>
<span>SCUM </span>
<small>Enter / Run </small>
<input value={command} disabled={!canManage || pending} placeholder={canManage ? "输入单行命令" : "当前账号没有运行操作权限"} onKeyDown={handleCommandKeyDown} onChange={(event) => { setCommand(event.target.value); setHistoryIndex(null); }} />
</label>
<button type="submit" className="primary-command" disabled={!canManage || pending || !command.trim()}>{pending ? <Sparkles size={15} /> : <Send size={15} />}<span>{pending ? "提交中…" : "发送"}</span></button>
</form>
)}
</section>
</LiveOperationDrawer>
);
}
function terminalQuickCommandsForPlugin(pluginId: string): TerminalQuickCommand[] {
return terminalQuickCommandCatalog[pluginId] ?? [];
}
function bridgeCommandDispatchLabel(state: string, commandId: string): string {
return `${state === "pending" ? "排队" : "提交"} · 桥接命令 ${commandId}`;
}
function terminalLineFromLog(stream: LogStreamResponse, entry: LogEntryBody): TerminalLine {
return {
id: `log-${stream.id}-${entry.seq}`,
tone: terminalTone(entry),
text: entry.line,
at: new Date(entry.timestamp).toLocaleTimeString(),
sortKey: Date.parse(entry.timestamp) || Date.now(),
streamKey: stream.streamKey || stream.source,
level: entry.level,
seq: entry.seq
};
}
function terminalSystemLine(tone: TerminalLine["tone"], text: string, streamKey: string, id = `${streamKey.toLowerCase()}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`): TerminalLine {
const now = Date.now();
return { id, tone, text, at: new Date(now).toLocaleTimeString(), sortKey: now, streamKey };
}
function isTerminalBridgeCommandState(state: GameClientBridgeCommandResponse["state"]): boolean {
return state === "succeeded" || state === "failed" || state === "cancelled" || state === "expired" || state === "unknown";
}
function terminalLineFromBridgeCommand(command: GameClientBridgeCommandResponse): TerminalLine {
const summary = command.result?.summary || command.resultSummary || command.cancellation?.reason || bridgeCommandStateLabel(command.state);
const completed = command.completedAt || command.result?.completedAt || command.cancellation?.cancelledAt || command.updatedAt;
const sortKey = Date.parse(completed) || Date.now();
const tone: TerminalLine["tone"] = command.state === "succeeded" ? "success" : command.state === "failed" ? "error" : "warn";
return { id: `bridge-${command.id}-${command.state}`, tone, text: `桥接命令 ${command.id} · ${bridgeCommandStateLabel(command.state)} · ${summary}`, at: new Date(sortKey).toLocaleTimeString(), sortKey, streamKey: "BRIDGE" };
}
function bridgeCommandStateLabel(state: GameClientBridgeCommandResponse["state"]): string {
switch (state) {
case "succeeded": return "已成功";
case "failed": return "已失败";
case "cancelled": return "已取消";
case "expired": return "已过期";
case "unknown": return "状态未知";
case "claimed": return "Run 已领取";
case "pending": return "已排队";
}
}
function delay(ms: number): Promise<void> {
return new Promise((resolve) => window.setTimeout(resolve, ms));
}
function terminalTone(entry: LogEntryBody): TerminalLine["tone"] {
const value = `${entry.level ?? ""} ${entry.line}`.toLowerCase();
if (/\b(error|fatal|panic|exception|failed|failure)\b/.test(value)) return "error";
if (/\b(warn|warning|timeout|retry)\b/.test(value)) return "warn";
if (/\b(success|succeeded|ready|started|online|listening|accepted)\b/.test(value)) return "success";
return "info";
}
function mergeTerminalLines(current: TerminalLine[], incoming: TerminalLine[]): TerminalLine[] {
const seen = new Set(current.map((line) => line.id));
const merged = [...current];
for (const line of incoming) {
if (seen.has(line.id)) continue;
seen.add(line.id);
merged.push(line);
}
return merged.sort(compareTerminalLines).slice(-maxTerminalLines);
}
function compareTerminalLines(a: TerminalLine, b: TerminalLine): number {
return a.sortKey - b.sortKey || (a.seq ?? 0) - (b.seq ?? 0) || a.id.localeCompare(b.id);
}
@@ -1,18 +0,0 @@
import { describe, expect, it } from "vitest";
import sourceRCONCommandPanelSource from "./SourceRCONCommandPanel.tsx?raw";
describe("SourceRCONCommandPanel", () => {
it("uses protected bridge dispatch without confirmation, transcript, or connection fields", () => {
expect(sourceRCONCommandPanelSource).toContain("queueGameClientBridgeCommand");
expect(sourceRCONCommandPanelSource).toContain("scumManagementRCONCommandRequest");
expect(sourceRCONCommandPanelSource).toContain("scumAnnouncementCommand");
expect(sourceRCONCommandPanelSource).not.toContain("sendSourceRCONCommand");
expect(sourceRCONCommandPanelSource).not.toContain("ConfirmDialog");
expect(sourceRCONCommandPanelSource).not.toContain("operations.");
expect(sourceRCONCommandPanelSource).not.toContain("transcript");
expect(sourceRCONCommandPanelSource).not.toContain("history");
expect(sourceRCONCommandPanelSource).not.toContain("password");
expect(sourceRCONCommandPanelSource).not.toContain("host");
});
});
@@ -1,93 +0,0 @@
import { type FormEvent, useState } from "react";
import { platformApiClient } from "../api/client";
import { scumAnnouncementCommand, scumManagementRCONCommandRequest } from "../schemas/scumManagementRcon";
import { ResultBadge } from "./StateViews";
interface SourceRCONCommandPanelProps {
serverId: string;
pluginId: string;
}
type DispatchState = { status: "pending" | "succeeded" | "failed"; label: string } | null;
export function SourceRCONCommandPanel({ serverId, pluginId }: SourceRCONCommandPanelProps) {
const [announcement, setAnnouncement] = useState("");
const [rawCommand, setRawCommand] = useState("");
const [pending, setPending] = useState<"announcement" | "command" | null>(null);
const [dispatch, setDispatch] = useState<DispatchState>(null);
if (pluginId !== "game.scum") {
return null;
}
async function sendAnnouncement(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setPending("announcement");
setDispatch({ status: "pending", label: "正在提交服务器公告" });
try {
const submitted = await platformApiClient.queueGameClientBridgeCommand(serverId, scumManagementRCONCommandRequest(serverId, scumAnnouncementCommand(announcement)));
setAnnouncement("");
setDispatch({ status: "succeeded", label: protectedRCONDispatchLabel(submitted.id, submitted.state) });
} catch (error) {
setDispatch({ status: "failed", label: error instanceof Error ? error.message : "服务器公告提交失败" });
} finally {
setPending(null);
}
}
async function sendRawCommand(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setPending("command");
setDispatch({ status: "pending", label: "正在提交原始管理员指令" });
try {
const submitted = await platformApiClient.queueGameClientBridgeCommand(serverId, scumManagementRCONCommandRequest(serverId, rawCommand));
setRawCommand("");
setDispatch({ status: "succeeded", label: protectedRCONDispatchLabel(submitted.id, submitted.state) });
} catch (error) {
setDispatch({ status: "failed", label: error instanceof Error ? error.message : "原始管理员指令提交失败" });
} finally {
setPending(null);
}
}
return (
<article className="console-panel" aria-label="SCUM protected RCON controls">
<div className="panel-header">
<div>
<h2>SCUM </h2>
<p className="page-status"> protected RCON </p>
</div>
{dispatch && <ResultBadge status={dispatch.status} label={dispatch.label} />}
</div>
<div className="operations-command-grid">
<section className="console-module" aria-label="SCUM announcement command">
<div className="panel-header"><h2></h2></div>
<form className="provider-form" onSubmit={(event) => void sendAnnouncement(event)}>
<label>
<textarea value={announcement} maxLength={1024} rows={3} onChange={(event) => setAnnouncement(event.target.value)} disabled={pending !== null} placeholder="输入单行公告内容" />
</label>
<p className="page-status"> SCUM #Announce </p>
<div className="action-strip"><button type="submit" className="primary-command" disabled={pending !== null || !announcement.trim()}>{pending === "announcement" ? "提交中…" : "发送公告"}</button></div>
</form>
</section>
<section className="console-module" aria-label="SCUM raw administrator command">
<div className="panel-header"><h2></h2></div>
<form className="provider-form" onSubmit={(event) => void sendRawCommand(event)}>
<label>
<textarea value={rawCommand} maxLength={8192} rows={5} onChange={(event) => setRawCommand(event.target.value)} disabled={pending !== null} placeholder="例如 #ListPlayers 或 #SetTime 12" />
</label>
<p className="page-status"> SCUM Run </p>
<div className="action-strip"><button type="submit" className="icon-command" disabled={pending !== null || !rawCommand.trim()}>{pending === "command" ? "提交中…" : "发送指令"}</button></div>
</form>
</section>
</div>
</article>
);
}
function protectedRCONDispatchLabel(commandId: string, state: string): string {
return `${state === "pending" ? "排队" : "提交"} · 桥接命令 ${commandId}`;
}
+2 -2
View File
@@ -2,7 +2,7 @@
## Shared Visual Contract ## 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, logs, diffs, plugin-declared pages, and operation history 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 workflow/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. - 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. - 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, confirmed start/stop lifecycle actions, and direct live-log/management-terminal entry points. Plugin-declared pages render as first-class server tabs before platform sections, so each game owns its menu surface. Built-in sections: 日志 (level/keyword/time/source filters + log detail drawer with diagnostics), 管理终端 (platform-mediated plugin command surface), 配置 (deployment status, metadata, administrators, and config edit with reviewable diff before any write job), AI 助手 (LLM suggestions produce recommendation/diff; write jobs require explicit diff confirmation; no raw AI keys reach the frontend), 操作历史 (operation/job IDs, status, timestamps, target, requester, error reasons). Generic 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 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.
## 插件市场 ## 插件市场
+12 -24
View File
@@ -1,26 +1,14 @@
export interface PluginPageFileRequestResult {
status: string;
message: string;
jobId?: string;
}
export interface PluginPageFileReadSnapshot {
serverInstanceId: string;
pluginId: string;
key: string;
state: "ready" | "pending" | "not-read" | "unavailable" | string;
content?: string;
version?: number;
checksum?: string;
sizeBytes?: number;
jobId?: string;
readAt?: string;
reason?: string;
}
export interface PluginPageWorkspaceActions { export interface PluginPageWorkspaceActions {
refreshWorkspace?: () => Promise<unknown>; listSCUMPlayers?: () => Promise<unknown>;
requestFile?: (fileKey: string) => Promise<PluginPageFileRequestResult>; listSCUMSquads?: () => Promise<unknown>;
getFileSnapshot?: (fileKey: string) => Promise<PluginPageFileReadSnapshot>; listSCUMSquadMembers?: () => Promise<unknown>;
writeFile?: (fileKey: string, content: string, options?: { expectedChecksum?: string }) => Promise<PluginPageFileRequestResult>; listSCUMVehicles?: () => Promise<unknown>;
listSCUMFlags?: () => Promise<unknown>;
listSCUMPositions?: () => Promise<unknown>;
listSCUMOperations?: () => Promise<unknown>;
createSCUMOperation?: (request: unknown) => Promise<unknown>;
approveSCUMOperation?: (operationId: string) => Promise<unknown>;
listSCUMWorkflows?: () => Promise<unknown>;
createSCUMWorkflow?: (request: unknown) => Promise<unknown>;
listSCUMWorkflowSteps?: (workflowId?: string) => Promise<unknown>;
} }
+4 -7
View File
@@ -144,14 +144,11 @@ export interface PlatformOverviewSignal {
at: string; at: string;
} }
export type ServerDetailSection = "logs" | "terminal" | "config" | "llm" | "history" | `plugin:${string}`; export type ServerDetailSection = "manage" | "llm" | `plugin:${string}`;
export const serverDetailSections: Array<{ id: ServerDetailSection; label: string }> = [ export const serverDetailSections: Array<{ id: ServerDetailSection; label: string }> = [
{ id: "logs", label: "日志" }, { id: "manage", label: "管理" },
{ id: "terminal", label: "管理终端" }, { id: "llm", label: "AI 助手" }
{ id: "config", label: "配置" },
{ id: "llm", label: "AI 助手" },
{ id: "history", label: "操作历史" }
]; ];
export interface DiffLine { export interface DiffLine {
@@ -177,5 +174,5 @@ export interface LlmSuggestionView {
recommendation: string; recommendation: string;
diffId?: string; diffId?: string;
expiresAt?: string; expiresAt?: string;
diff?: ConfigDiffView; diffSummary?: string;
} }
-14
View File
@@ -5,20 +5,6 @@ server {
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.html; index index.html;
location ~ ^/api/v1/server-instances/[^/]+/logs/events$ {
proxy_pass http://platform:8080;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 1h;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
add_header X-Accel-Buffering no;
}
location /api/v1/ { location /api/v1/ {
proxy_pass http://platform:8080/api/v1/; proxy_pass http://platform:8080/api/v1/;
proxy_http_version 1.1; proxy_http_version 1.1;
+28 -43
View File
@@ -10,7 +10,6 @@ import { ServersPage } from "./ServersPage";
import { UsersPage } from "./UsersPage"; import { UsersPage } from "./UsersPage";
import runtimeTaskProgressSource from "../components/RuntimeTaskProgress.tsx?raw"; import runtimeTaskProgressSource from "../components/RuntimeTaskProgress.tsx?raw";
import serverDeploymentWorkflowSource from "../components/ServerDeploymentWorkflow.tsx?raw"; import serverDeploymentWorkflowSource from "../components/ServerDeploymentWorkflow.tsx?raw";
import serverLiveOperationsSource from "../components/ServerLiveOperations.tsx?raw";
import serverCreateSchemaSource from "../schemas/serverManagement.ts?raw"; import serverCreateSchemaSource from "../schemas/serverManagement.ts?raw";
import serversPageSource from "./ServersPage.tsx?raw"; import serversPageSource from "./ServersPage.tsx?raw";
import serverDetailPageSource from "./ServerDetailPage.tsx?raw"; import serverDetailPageSource from "./ServerDetailPage.tsx?raw";
@@ -214,17 +213,25 @@ describe("first-party console pages", () => {
} }
}); });
it("keeps live logs and management terminal on server detail instead of the server list", () => { it("removes raw log and management terminal entry points from server pages", () => {
expect(serversPageSource).not.toContain("ServerLiveLogDrawer"); expect(serversPageSource).not.toContain("ServerLiveLogDrawer");
expect(serversPageSource).not.toContain("ServerManagementTerminalDrawer"); expect(serversPageSource).not.toContain("ServerManagementTerminalDrawer");
expect(serversPageSource).not.toContain("live-logs"); expect(serversPageSource).not.toContain("live-logs");
expect(serversPageSource).not.toContain("historical-logs");
expect(serversPageSource).not.toContain("requestLogBackfill");
expect(serversPageSource).not.toContain("管理终端"); expect(serversPageSource).not.toContain("管理终端");
expect(serverDetailPageSource).toContain("ServerManagementTerminalDrawer"); expect(serverDetailPageSource).not.toContain("ServerManagementTerminalDrawer");
expect(serverDetailPageSource).not.toContain("SourceRCONCommandPanel");
expect(serverDetailPageSource).not.toContain("ServerLiveLogDrawer"); expect(serverDetailPageSource).not.toContain("ServerLiveLogDrawer");
expect(serverDetailPageSource).not.toContain('<span>实时日志</span>'); expect(serverDetailPageSource).not.toContain('<span>实时日志</span>');
expect(serverDetailPageSource).not.toContain("openServerLogEvents");
expect(serverDetailPageSource).not.toContain("previewServerConfigDiff");
expect(serverDetailPageSource).not.toContain("approveServerConfigWrite");
expect(serverDetailPageSource).not.toContain("操作历史");
expect(serverDetailPageSource).toContain("runtimeObservationFreshness"); expect(serverDetailPageSource).toContain("runtimeObservationFreshness");
expect(serverDetailPageSource).toContain("Run 未验证"); expect(serverDetailPageSource).toContain("Run 未验证");
expect(serverDetailPageSource).toContain("管理终端"); expect(serverDetailPageSource).toContain("PluginPageSection");
expect(serverDetailPageSource).toContain("AI 配置助手");
}); });
it("keeps create target and profile controls out while preserving deployment steps", () => { it("keeps create target and profile controls out while preserving deployment steps", () => {
@@ -311,52 +318,30 @@ describe("first-party console pages", () => {
expect(html).toContain("返回列表"); expect(html).toContain("返回列表");
expect(html).not.toContain("概览"); expect(html).not.toContain("概览");
expect(html).toContain("日志"); expect(html).toContain("管理");
expect(html).toContain("管理终端"); expect(html).not.toContain("日志");
expect(html).toContain("配置"); expect(html).not.toContain("管理终端");
expect(html).not.toContain("配置");
expect(html).not.toContain("运行操作"); expect(html).not.toContain("运行操作");
expect(html).not.toContain("插件控制"); expect(html).not.toContain("插件控制");
expect(html).toContain("AI 助手"); expect(html).toContain("AI 助手");
expect(html).toContain("操作历史"); expect(html).not.toContain("操作历史");
}); });
it("renders management terminal as a large command console with quick commands", () => { it("routes SCUM operations through typed plugin workflow surfaces", () => {
expect(serverLiveOperationsSource).toContain("management-terminal-drawer"); expect(serverDetailPageSource).toContain("PluginPageHostPage");
expect(serverLiveOperationsSource).toContain("terminal-output-topbar"); expect(serverDetailPageSource).toContain("plugin:${page.key}");
expect(serverLiveOperationsSource).toContain("terminal-command-dock"); expect(serverDetailPageSource).toContain("section === \"llm\"");
expect(serverLiveOperationsSource).toContain("terminalQuickCommandCatalog"); expect(serverDetailPageSource).not.toContain("terminalQuickCommandCatalog");
expect(serverLiveOperationsSource).toContain("terminalQuickCommandsForPlugin"); expect(serverDetailPageSource).not.toContain("scumManagementRCONCommandRequest");
expect(serverLiveOperationsSource).toContain("queueGameClientBridgeCommand"); expect(serverDetailPageSource).not.toContain("queueGameClientBridgeCommand");
expect(serverLiveOperationsSource).toContain("scumManagementRCONCommandRequest"); expect(serverDetailPageSource).not.toContain("commandHistory");
expect(serverLiveOperationsSource).toContain("#ListSquads");
expect(serverLiveOperationsSource).toContain("#ListSpawnedVehicles");
expect(serverLiveOperationsSource).toContain("selectQuickCommand(item)");
expect(serverLiveOperationsSource).toContain("hideHeader");
expect(serverLiveOperationsSource).toContain("handleCommandKeyDown");
expect(serverLiveOperationsSource).toContain("commandHistory");
expect(serverLiveOperationsSource).toContain("ArrowUp");
expect(serverLiveOperationsSource).not.toContain("listServerLiveLogs");
expect(serverLiveOperationsSource).toContain("openServerLogEvents");
expect(serverLiveOperationsSource).toContain("getGameClientBridgeCommand");
expect(serverLiveOperationsSource).toContain("terminalLineFromBridgeCommand");
expect(serverLiveOperationsSource).not.toContain("terminalRelevantStreams");
expect(serverLiveOperationsSource).toContain("SSE 实时推送");
expect(serverLiveOperationsSource).toContain("mergeTerminalLines");
expect(serverLiveOperationsSource).toContain("terminalInitialHistoryWindow = 500");
expect(serverLiveOperationsSource).toContain("maxTerminalLines = 10000");
expect(serverLiveOperationsSource).toContain("followLatestRef");
expect(serverLiveOperationsSource).toContain("initialHistoryPendingRef");
expect(serverLiveOperationsSource).toContain("lockTerminalFollow");
expect(serverLiveOperationsSource).toContain("handleTerminalScroll");
expect(serverLiveOperationsSource).toContain("scrollHeight - output.clientHeight - output.scrollTop <= 24");
expect(serverLiveOperationsSource).not.toContain("terminalLogPollMs = 1000");
expect(serverLiveOperationsSource).not.toContain("logStreamPollMs = 5000");
expect(serverLiveOperationsSource).not.toContain("queryLogStream(");
expect(serverLiveOperationsSource).not.toContain("SaveWorld");
}); });
it("uses declared SCUM log source keys for log backfill defaults", () => { it("does not expose SCUM log backfill defaults as product actions", () => {
expect(serversPageSource).toContain("scum-server-events"); expect(serversPageSource).not.toContain("scum-server-events");
expect(serversPageSource).not.toContain("requestLogBackfill");
expect(serversPageSource).not.toContain("historical-logs");
expect(serversPageSource).not.toContain("server-log"); expect(serversPageSource).not.toContain("server-log");
expect(serverDetailPageSource).not.toContain("server-log"); expect(serverDetailPageSource).not.toContain("server-log");
}); });
+21 -15
View File
@@ -26,19 +26,19 @@ const plugin: GamePluginResponse = {
manifestRef: "artifact://manifests/game.scum/1.0.0", manifestRef: "artifact://manifests/game.scum/1.0.0",
createFormSchemaRef: "schemas/create-form.schema.json", createFormSchemaRef: "schemas/create-form.schema.json",
requiredRunCapabilities: [], requiredRunCapabilities: [],
declaredPermissions: ["server.read", "server.logs.read", "server.game-client.read", "server.game-client.command"], declaredPermissions: ["server.read", "server.game-client.read", "server.game-client.command"],
permissions: { ai: false, logs: true, files: false, jobs: true, artifacts: false, remoteAccess: false }, permissions: { ai: true, logs: false, files: false, jobs: true, artifacts: false, remoteAccess: false },
lifecycleActions: {}, lifecycleActions: {},
bridgeActions: ["server.instances.read", "logs.query"], bridgeActions: ["server.instances.read"],
pages: [{ pages: [{
key: "files-config", key: "players",
title: "文件与配置", title: "用户管理",
path: "/files-config", path: "/players",
bundleKey: "scum-server-plugin", bundleKey: "scum-server-plugin",
bundleVersion: "1.0.1", bundleVersion: "1.0.1",
bundleIntegritySha256: "sha256:8de5ec67248be72a6fa47df5e6f8c98e10092ade99e6fc066ceeba678b119c64", bundleIntegritySha256: "sha256:3488b316d909e597024f8f31c7bc96ab8019f643d74a528dc442d3df0dc3d54e",
permissions: ["server.game-client.read", "server.game-client.command", "server.logs.read"], permissions: ["server.game-client.read", "server.game-client.command"],
bridgeActions: ["server.instances.read", "logs.query"] bridgeActions: ["server.instances.read"]
}], }],
tags: ["scum"], tags: ["scum"],
aiPurposes: [], aiPurposes: [],
@@ -48,7 +48,7 @@ const plugin: GamePluginResponse = {
snapshots: [{ type: "companion.health", schemaVersion: "1", schemaRef: "schemas/bridge/health.json", keepForSeconds: 3600, maxRecords: 24 }], snapshots: [{ type: "companion.health", schemaVersion: "1", schemaRef: "schemas/bridge/health.json", keepForSeconds: 3600, maxRecords: 24 }],
commandRetentionSeconds: 86400, commandRetentionSeconds: 86400,
maxCommands: 1000, maxCommands: 1000,
pages: [{ pageKey: "files-config", commandTypes: ["announcement.send"], snapshotTypes: ["companion.health"] }] pages: [{ pageKey: "players", commandTypes: ["announcement.send"], snapshotTypes: ["companion.health"] }]
}, },
status: "installed" status: "installed"
}; };
@@ -65,7 +65,7 @@ function props(serverId = "server-1"): PageComponentProps {
}; };
return { return {
session, session,
params: { pluginId: "game.scum", routeKey: "files-config", serverId }, params: { pluginId: "game.scum", routeKey: "players", serverId },
operations, operations,
onNavigate: () => undefined, onNavigate: () => undefined,
onLogout: async () => undefined, onLogout: async () => undefined,
@@ -77,7 +77,7 @@ function props(serverId = "server-1"): PageComponentProps {
describe("PluginPageHostPage", () => { describe("PluginPageHostPage", () => {
it("renders a generic manifest-owned bundle declaration", () => { it("renders a generic manifest-owned bundle declaration", () => {
const html = renderToStaticMarkup(<PluginPageHostPage {...props()} initialPlugin={plugin} />); const html = renderToStaticMarkup(<PluginPageHostPage {...props()} initialPlugin={plugin} />);
expect(html).toContain("文件与配置"); expect(html).toContain("用户管理");
expect(html).toContain("平台托管上下文"); expect(html).toContain("平台托管上下文");
expect(html).toContain("scum-server-plugin@1.0.1"); expect(html).toContain("scum-server-plugin@1.0.1");
expect(html).toContain("完整性"); expect(html).toContain("完整性");
@@ -96,11 +96,17 @@ describe("PluginPageHostPage", () => {
expect(hostSource).not.toMatch(/ScumFileConfigWorkbench|GamePlayerIntelligencePanel|GameGiftCatalogPanel|ScumMapTrajectoryPanel|game\.scum/); expect(hostSource).not.toMatch(/ScumFileConfigWorkbench|GamePlayerIntelligencePanel|GameGiftCatalogPanel|ScumMapTrajectoryPanel|game\.scum/);
}); });
it("keeps file workspace callbacks stable across parent operational refreshes", () => { it("keeps typed SCUM workspace callbacks stable across parent operational refreshes", () => {
expect(hostSource).toContain("readyPluginRef.current = readyPlugin"); expect(hostSource).toContain("readyPluginRef.current = readyPlugin");
expect(hostSource).toContain("hostContextRef.current = hostContext"); expect(hostSource).toContain("hostContextRef.current = hostContext");
expect(hostSource).toContain("refreshWorkspace: async ()"); expect(hostSource).toContain("listSCUMPlayers: () => platformApiClient.listSCUMPlayers(serverId)");
expect(hostSource).toContain("platformApiClient.listGamePlugins()"); expect(hostSource).toContain("createSCUMWorkflow: (request) => platformApiClient.createSCUMWorkflow(serverId, request as never)");
expect(hostSource).toContain("createSCUMOperation: (request) => platformApiClient.createSCUMOperation(serverId, request as never)");
expect(hostSource).toContain("listSCUMWorkflowSteps: (workflowId) => platformApiClient.listSCUMWorkflowSteps(serverId, workflowId)");
expect(hostSource).not.toContain("refreshWorkspace");
expect(hostSource).not.toContain("requestFile");
expect(hostSource).not.toContain("writeFile");
expect(hostSource).not.toContain("getDeclaredFileReadSnapshot");
expect(hostSource).toContain("}, [pluginId, serverId]);"); expect(hostSource).toContain("}, [pluginId, serverId]);");
expect(hostSource).not.toContain("}, [hostContext, readyPlugin, serverId]);"); expect(hostSource).not.toContain("}, [hostContext, readyPlugin, serverId]);");
}); });
+16 -95
View File
@@ -9,7 +9,7 @@ import { EmptyState, ErrorState, LoadingState } from "../components/StateViews";
import type { PageComponentProps } from "../contracts/page"; import type { PageComponentProps } from "../contracts/page";
import { pluginBridgeManifestContractFromResponse } from "../contracts/pluginBridge"; import { pluginBridgeManifestContractFromResponse } from "../contracts/pluginBridge";
import type { PluginPageWorkspaceActions } from "../contracts/pluginPageHost"; import type { PluginPageWorkspaceActions } from "../contracts/pluginPageHost";
import { createPluginBridgeDispatcher, createPluginBridgeHostContext } from "../utils/pluginBridgeHost"; import { createPluginBridgeHostContext } from "../utils/pluginBridgeHost";
import { loadPluginPageBundle, type PluginPageAvailability } from "../utils/pluginPageBundles"; import { loadPluginPageBundle, type PluginPageAvailability } from "../utils/pluginPageBundles";
type PluginPageState = type PluginPageState =
@@ -27,7 +27,7 @@ export function PluginPageHostPage({ params, onNavigate, initialPlugin, embedded
const routeKey = params.routeKey ?? ""; const routeKey = params.routeKey ?? "";
const serverId = params.serverId ?? ""; const serverId = params.serverId ?? "";
const [state, setState] = useState<PluginPageState>(() => initialPlugin ? { status: "ready", plugin: initialPlugin } : { status: "loading" }); const [state, setState] = useState<PluginPageState>(() => initialPlugin ? { status: "ready", plugin: initialPlugin } : { status: "loading" });
const [bundle, setBundle] = useState<ComponentType<{ context: ReturnType<typeof createPluginBridgeHostContext>; workspace?: unknown; workspaceActions?: PluginPageWorkspaceActions; availability: PluginPageAvailability }> | null>(null); const [bundle, setBundle] = useState<ComponentType<{ context: ReturnType<typeof createPluginBridgeHostContext>; workspaceActions?: PluginPageWorkspaceActions; availability: PluginPageAvailability }> | null>(null);
const [bundleError, setBundleError] = useState(""); const [bundleError, setBundleError] = useState("");
const [availability, setAvailability] = useState<PluginPageAvailability>({ available: false, reason: "正在验证 Companion 可用性。" }); const [availability, setAvailability] = useState<PluginPageAvailability>({ available: false, reason: "正在验证 Companion 可用性。" });
const readyPluginRef = useRef<GamePluginResponse | undefined>(undefined); const readyPluginRef = useRef<GamePluginResponse | undefined>(undefined);
@@ -69,97 +69,18 @@ export function PluginPageHostPage({ params, onNavigate, initialPlugin, embedded
const workspaceActions = useMemo<PluginPageWorkspaceActions | undefined>(() => { const workspaceActions = useMemo<PluginPageWorkspaceActions | undefined>(() => {
if (!pluginId) return undefined; if (!pluginId) return undefined;
return { return {
refreshWorkspace: async () => { listSCUMPlayers: () => platformApiClient.listSCUMPlayers(serverId),
const response = await platformApiClient.listGamePlugins(); listSCUMSquads: () => platformApiClient.listSCUMSquads(serverId),
const plugin = response.items.find((candidate) => candidate.id === pluginId); listSCUMSquadMembers: () => platformApiClient.listSCUMSquadMembers(serverId),
if (!plugin) { listSCUMVehicles: () => platformApiClient.listSCUMVehicles(serverId),
throw new Error("未找到已注册的插件声明。"); listSCUMFlags: () => platformApiClient.listSCUMFlags(serverId),
} listSCUMPositions: () => platformApiClient.listSCUMPositions(serverId),
readyPluginRef.current = plugin; listSCUMOperations: () => platformApiClient.listSCUMOperations(serverId),
setState({ status: "ready", plugin }); createSCUMOperation: (request) => platformApiClient.createSCUMOperation(serverId, request as never),
return plugin.fileWorkspace; approveSCUMOperation: (operationId) => platformApiClient.approveSCUMOperation(serverId, operationId),
}, listSCUMWorkflows: () => platformApiClient.listSCUMWorkflows(serverId),
requestFile: async (fileKey) => { createSCUMWorkflow: (request) => platformApiClient.createSCUMWorkflow(serverId, request as never),
const plugin = readyPluginRef.current; listSCUMWorkflowSteps: (workflowId) => platformApiClient.listSCUMWorkflowSteps(serverId, workflowId)
const context = hostContextRef.current;
if (!serverId) {
return { status: "denied", message: "插件页面没有绑定服务器,无法读取文件。" };
}
if (!plugin || !context) {
return { status: "denied", message: "插件页面上下文尚未就绪。" };
}
if (!plugin.fileWorkspace?.files.some((file) => file.key === fileKey)) {
return { status: "denied", message: "该文件不在当前插件声明的工作区内。" };
}
const dispatch = createPluginBridgeDispatcher(context, platformApiClient);
const response = await dispatch({
requestId: `web:plugin-file-read:${serverId}:${fileKey}:${Date.now()}`,
action: "files.request",
payload: { operation: "read", key: fileKey }
});
const jobId = response.result?.jobId;
if (response.status === "queued" || response.status === "ok") {
return { status: response.status, jobId, message: jobId ? `读取任务 ${jobId} 已提交。` : "已提交文件读取请求。" };
}
return { status: response.status, message: response.error?.message ?? "文件读取请求未能提交。" };
},
getFileSnapshot: async (fileKey) => {
const plugin = readyPluginRef.current;
if (!serverId) {
return { serverInstanceId: "", pluginId, key: fileKey, state: "unavailable", reason: "插件页面没有绑定服务器,无法读取文件快照。" };
}
if (!plugin) {
return { serverInstanceId: serverId, pluginId, key: fileKey, state: "unavailable", reason: "插件页面上下文尚未就绪。" };
}
if (!plugin.fileWorkspace?.files.some((file) => file.key === fileKey)) {
return { serverInstanceId: serverId, pluginId: plugin.id, key: fileKey, state: "unavailable", reason: "该文件不在当前插件声明的工作区内。" };
}
try {
return await platformApiClient.getDeclaredFileReadSnapshot(serverId, fileKey);
} catch (error) {
return {
serverInstanceId: serverId,
pluginId: plugin.id,
key: fileKey,
state: "unavailable",
reason: error instanceof Error ? error.message : "无法读取文件快照。"
};
}
},
writeFile: async (fileKey, content, options) => {
const plugin = readyPluginRef.current;
const context = hostContextRef.current;
if (!serverId) {
return { status: "denied", message: "插件页面没有绑定服务器,无法写入文件。" };
}
if (!plugin || !context) {
return { status: "denied", message: "插件页面上下文尚未就绪。" };
}
const file = plugin.fileWorkspace?.files.find((candidate) => candidate.key === fileKey);
if (!file) {
return { status: "denied", message: "该文件不在当前插件声明的工作区内。" };
}
if (file.kind !== "config" || !file.editable) {
return { status: "denied", message: "该声明文件不允许通过配置工作台写入。" };
}
if (!context.permissions.includes("server.files.write")) {
return { status: "denied", message: "当前页面没有声明文件写入权限。" };
}
try {
const response = await platformApiClient.dispatchFileOperation({
serverInstanceId: serverId,
pluginId: plugin.id,
operation: "write",
key: fileKey,
content,
expectedChecksum: options?.expectedChecksum,
idempotencyKey: `web:plugin-file-write:${serverId}:${fileKey}:${Date.now()}`
});
return { status: response.status, jobId: response.job.id, message: `写入任务 ${response.job.id} 已提交。` };
} catch (error) {
return { status: "error", message: error instanceof Error ? error.message : "文件写入请求未能提交。" };
}
}
}; };
}, [pluginId, serverId]); }, [pluginId, serverId]);
const bundleLoadKey = declaredBundlePage ? [declaredBundlePage.bundleKey, declaredBundlePage.bundleVersion, declaredBundlePage.bundleIntegritySha256, declaredBundlePage.path].join(":") : ""; const bundleLoadKey = declaredBundlePage ? [declaredBundlePage.bundleKey, declaredBundlePage.bundleVersion, declaredBundlePage.bundleIntegritySha256, declaredBundlePage.path].join(":") : "";
@@ -196,7 +117,7 @@ export function PluginPageHostPage({ params, onNavigate, initialPlugin, embedded
<> <>
{bundleError && <ErrorState title="插件页面不可用" reason={bundleError} />} {bundleError && <ErrorState title="插件页面不可用" reason={bundleError} />}
{!bundle && !bundleError && <LoadingState label="正在校验并加载插件页面 bundle…" compact />} {!bundle && !bundleError && <LoadingState label="正在校验并加载插件页面 bundle…" compact />}
{bundle && React.createElement(bundle, { context: hostContext, workspace: state.plugin.fileWorkspace, workspaceActions, availability })} {bundle && React.createElement(bundle, { context: hostContext, workspaceActions, availability })}
</> </>
); );
} }
@@ -226,7 +147,7 @@ export function PluginPageHostPage({ params, onNavigate, initialPlugin, embedded
</section> </section>
{bundleError && <ErrorState title="插件页面不可用" reason={bundleError} />} {bundleError && <ErrorState title="插件页面不可用" reason={bundleError} />}
{!bundle && !bundleError && <LoadingState label="正在校验并加载插件页面 bundle…" />} {!bundle && !bundleError && <LoadingState label="正在校验并加载插件页面 bundle…" />}
{bundle && React.createElement(bundle, { context: hostContext, workspace: state.plugin.fileWorkspace, workspaceActions, availability })} {bundle && React.createElement(bundle, { context: hostContext, workspaceActions, availability })}
</div> </div>
); );
} }
+19 -27
View File
@@ -2,8 +2,6 @@ import { describe, expect, it } from "vitest";
import { configDiffViewFromPreview } from "./ServerDetailPage"; import { configDiffViewFromPreview } from "./ServerDetailPage";
import serverDetailPageSource from "./ServerDetailPage.tsx?raw"; import serverDetailPageSource from "./ServerDetailPage.tsx?raw";
import sourceRCONCommandPanelSource from "../components/SourceRCONCommandPanel.tsx?raw";
import artifactTransferSource from "../utils/artifactTransfer.ts?raw";
import type { ServerConfigDiffPreviewResponse } from "../api/types"; import type { ServerConfigDiffPreviewResponse } from "../api/types";
const preview: ServerConfigDiffPreviewResponse = { const preview: ServerConfigDiffPreviewResponse = {
@@ -41,9 +39,11 @@ describe("ServerDetailPage config write approval", () => {
]); ]);
}); });
it("uses config preview and approval APIs instead of generic config.write job creation", () => { it("uses AI config diff approval without exposing raw config workbench APIs", () => {
expect(serverDetailPageSource).toContain("previewServerConfigDiff"); expect(serverDetailPageSource).toContain("approveAIConfigDiff");
expect(serverDetailPageSource).toContain("approveServerConfigWrite"); expect(serverDetailPageSource).toContain("AI 配置助手");
expect(serverDetailPageSource).not.toContain("previewServerConfigDiff");
expect(serverDetailPageSource).not.toContain("approveServerConfigWrite");
expect(serverDetailPageSource).not.toContain('capability: "config.write"'); expect(serverDetailPageSource).not.toContain('capability: "config.write"');
}); });
@@ -62,10 +62,10 @@ describe("ServerDetailPage config write approval", () => {
expect(serverDetailPageSource).not.toContain("content: diff.nextContent"); expect(serverDetailPageSource).not.toContain("content: diff.nextContent");
}); });
it("uses platform-mediated artifact download and bridge references without backend internals", () => { it("keeps artifact transfer and backend internals out of server detail", () => {
expect(serverDetailPageSource).toContain("openArtifactDownload"); expect(serverDetailPageSource).not.toContain("openArtifactDownload");
expect(serverDetailPageSource).toContain("readArtifactContent"); expect(serverDetailPageSource).not.toContain("readArtifactContent");
expect(serverDetailPageSource).toContain("浏览器制品传输"); expect(serverDetailPageSource).not.toContain("浏览器制品传输");
expect(serverDetailPageSource).not.toContain("storage://"); expect(serverDetailPageSource).not.toContain("storage://");
expect(serverDetailPageSource).not.toContain("unix://"); expect(serverDetailPageSource).not.toContain("unix://");
expect(serverDetailPageSource).not.toContain("Bearer "); expect(serverDetailPageSource).not.toContain("Bearer ");
@@ -79,7 +79,7 @@ describe("ServerDetailPage config write approval", () => {
expect(serverDetailPageSource).not.toContain("pushRunUpdate"); expect(serverDetailPageSource).not.toContain("pushRunUpdate");
expect(serverDetailPageSource).not.toContain("generateClientManager"); expect(serverDetailPageSource).not.toContain("generateClientManager");
expect(serverDetailPageSource).not.toContain("ClientManagerLifecyclePanel"); expect(serverDetailPageSource).not.toContain("ClientManagerLifecyclePanel");
expect(serverDetailPageSource).toContain("openServerLogEvents"); expect(serverDetailPageSource).not.toContain("openServerLogEvents");
expect(serverDetailPageSource).not.toContain("authKey"); expect(serverDetailPageSource).not.toContain("authKey");
expect(serverDetailPageSource).not.toContain("password="); expect(serverDetailPageSource).not.toContain("password=");
expect(serverDetailPageSource).not.toContain("unix://"); expect(serverDetailPageSource).not.toContain("unix://");
@@ -95,17 +95,12 @@ describe("ServerDetailPage config write approval", () => {
expect(serverDetailPageSource).not.toContain("RuntimeDLLExtensionsPanel"); expect(serverDetailPageSource).not.toContain("RuntimeDLLExtensionsPanel");
}); });
it("adds SCUM announcements and raw commands through protected RCON bridge jobs", () => { it("does not expose raw SCUM RCON command panels", () => {
expect(serverDetailPageSource).toContain("SourceRCONCommandPanel"); expect(serverDetailPageSource).not.toContain("SourceRCONCommandPanel");
expect(sourceRCONCommandPanelSource).toContain("queueGameClientBridgeCommand"); expect(serverDetailPageSource).not.toContain("queueGameClientBridgeCommand");
expect(sourceRCONCommandPanelSource).toContain("scumManagementRCONCommandRequest"); expect(serverDetailPageSource).not.toContain("scumManagementRCONCommandRequest");
expect(sourceRCONCommandPanelSource).toContain("不保留指令原文"); expect(serverDetailPageSource).not.toContain("管理终端");
expect(sourceRCONCommandPanelSource).toContain("执行结果以 Run 日志为准"); expect(serverDetailPageSource).toContain("PluginPageHostPage");
expect(sourceRCONCommandPanelSource).not.toContain("ConfirmDialog");
expect(sourceRCONCommandPanelSource).not.toContain("operations.");
for (const forbidden of ["password", "host", "transcript", "history"]) {
expect(sourceRCONCommandPanelSource).not.toContain(forbidden);
}
}); });
it("removes generic plugin controls from server detail", () => { it("removes generic plugin controls from server detail", () => {
@@ -136,12 +131,9 @@ describe("ServerDetailPage config write approval", () => {
expect(serverDetailPageSource).not.toContain("ScumFileManagementSection"); expect(serverDetailPageSource).not.toContain("ScumFileManagementSection");
}); });
it("keeps plugin lifecycle and bridge-visible output on platform-owned logical references", () => { it("keeps plugin lifecycle output out of raw bridge-visible detail surfaces", () => {
expect(serverDetailPageSource).toContain("platformApiClient.openArtifactDownload(artifact.id)"); expect(serverDetailPageSource).not.toContain("platformApiClient.openArtifactDownload(artifact.id)");
expect(serverDetailPageSource).toContain("downloadArtifactReference(reference"); expect(serverDetailPageSource).not.toContain("downloadArtifactReference(reference");
expect(artifactTransferSource).toContain("readContent(reference.artifactId");
expect(artifactTransferSource).toContain("replace(/Bearer\\s+[^\\s]+/gi, \"[token]\")");
expect(artifactTransferSource).toContain("replace(/sk-[A-Za-z0-9_-]+/g, \"[secret]\")");
expect(serverDetailPageSource).not.toContain("storage://bucket"); expect(serverDetailPageSource).not.toContain("storage://bucket");
expect(serverDetailPageSource).not.toContain("runSocket"); expect(serverDetailPageSource).not.toContain("runSocket");
expect(serverDetailPageSource).not.toContain("rawApiKey"); expect(serverDetailPageSource).not.toContain("rawApiKey");
+19 -621
View File
@@ -1,36 +1,25 @@
import { Download, MoonStar, PackageOpen, Pencil, ShieldCheck, Sparkles, Square, Terminal, UserRoundMinus, UserRoundPlus, WandSparkles } from "lucide-react"; import { MoonStar, PackageOpen, Pencil, ShieldCheck, Sparkles, Square, UserRoundMinus, UserRoundPlus, WandSparkles } from "lucide-react";
import { type FormEvent, useCallback, useEffect, useMemo, useState } from "react"; import { type FormEvent, useCallback, useEffect, useMemo, useState } from "react";
import { platformApiClient } from "../api/client"; import { platformApiClient } from "../api/client";
import type { import type {
ConfigDiffLineResponse, ConfigDiffLineResponse,
ArtifactResponse,
BackupResponse,
GamePluginResponse, GamePluginResponse,
JobResponse, JobResponse,
LogStreamResponse,
ServerConfigDiffPreviewResponse,
ServerConfigResponse,
ServerInstanceResponse, ServerInstanceResponse,
ServerMemberResponse, ServerMemberResponse,
ServerMetricsResponse, ServerMetricsResponse,
ServerDeploymentResponse, ServerDeploymentResponse,
MetricSampleResponse, ServerConfigDiffPreviewResponse,
RemoteAdapterDeclarationResponse,
RunEndpointResponse RunEndpointResponse
} from "../api/types"; } from "../api/types";
import { ConfirmDialog, DiffView, UsageMeter } from "../components/OperationControls"; import { ConfirmDialog, UsageMeter } from "../components/OperationControls";
import { ProductionGovernancePanel } from "../components/ProductionGovernancePanel"; import { EmptyState, ErrorState, LoadingState, ResultBadge } from "../components/StateViews";
import { SourceRCONCommandPanel } from "../components/SourceRCONCommandPanel";
import { DiagnosticSummary, EmptyState, ErrorState, LoadingState, ResultBadge } from "../components/StateViews";
import type { PageComponentProps } from "../contracts/page"; import type { PageComponentProps } from "../contracts/page";
import { jobCapabilityLabel } from "../contracts/jobPresentation";
import { canStartServer, canStopServer, runtimeObservationFreshness, serverMetadataFormFromInstance, type ServerMetadataFormState } from "../contracts/serverManagement"; import { canStartServer, canStopServer, runtimeObservationFreshness, serverMetadataFormFromInstance, type ServerMetadataFormState } from "../contracts/serverManagement";
import { ServerManagementTerminalDrawer } from "../components/ServerLiveOperations";
import { import {
serverDetailSections, serverDetailSections,
serverIsOnline, serverIsOnline,
isPlatformAdmin,
type ConfigDiffView, type ConfigDiffView,
type LlmSuggestionView, type LlmSuggestionView,
type ServerDetailSection type ServerDetailSection
@@ -39,34 +28,25 @@ import {
serverLifecycleCommandRequest, serverLifecycleCommandRequest,
serverMetadataUpdateRequestFromForm serverMetadataUpdateRequestFromForm
} from "../schemas/serverManagement"; } from "../schemas/serverManagement";
import { diffHasChanges } from "../utils/diff";
import { downloadArtifactReference, safeArtifactError, safeArtifactFilename } from "../utils/artifactTransfer";
import { cx } from "../utils/classes"; import { cx } from "../utils/classes";
import { stateLabel, statusClass } from "./ServersPage"; import { stateLabel, statusClass } from "./ServersPage";
import { PluginPageHostPage } from "./PluginPageHostPage"; import { PluginPageHostPage } from "./PluginPageHostPage";
import { appendLiveLogEntries, entryFromServerLogEvent, mergeLogStreams, parseLogStreamEvent, parseServerLogEvent, streamFromServerLogEvent, type LiveLogEntry } from "../utils/logEvents";
type LoadState<T> = { status: "loading" } | { status: "error"; reason: string } | { status: "ready"; data: T }; type LoadState<T> = { status: "loading" } | { status: "error"; reason: string } | { status: "ready"; data: T };
const defaultConfigKey = "server.properties";
const serverDetailRefreshMs = 5000; const serverDetailRefreshMs = 5000;
const serverMetricFreshMs = 30000; const serverMetricFreshMs = 30000;
export function ServerDetailPage(props: PageComponentProps) { export function ServerDetailPage(props: PageComponentProps) {
const { session, params, operations, onNavigate } = props; const { session, params, operations, onNavigate } = props;
const serverId = params.serverId ?? ""; const serverId = params.serverId ?? "";
const [section, setSection] = useState<ServerDetailSection>("logs"); const [section, setSection] = useState<ServerDetailSection>("manage");
const [instance, setInstance] = useState<LoadState<ServerInstanceResponse>>({ status: "loading" }); const [instance, setInstance] = useState<LoadState<ServerInstanceResponse>>({ status: "loading" });
const [metrics, setMetrics] = useState<ServerMetricsResponse | null>(null); const [metrics, setMetrics] = useState<ServerMetricsResponse | null>(null);
const [plugins, setPlugins] = useState<GamePluginResponse[]>([]); const [plugins, setPlugins] = useState<GamePluginResponse[]>([]);
const [jobs, setJobs] = useState<JobResponse[]>([]); const [jobs, setJobs] = useState<JobResponse[]>([]);
const [artifacts, setArtifacts] = useState<ArtifactResponse[]>([]);
const [metricHistory, setMetricHistory] = useState<MetricSampleResponse[]>([]);
const [backups, setBackups] = useState<BackupResponse[]>([]);
const [remoteAdapters, setRemoteAdapters] = useState<RemoteAdapterDeclarationResponse[]>([]);
const [runEndpoint, setRunEndpoint] = useState<RunEndpointResponse | undefined>(); const [runEndpoint, setRunEndpoint] = useState<RunEndpointResponse | undefined>();
const [deployment, setDeployment] = useState<LoadState<ServerDeploymentResponse>>({ status: "loading" }); const [deployment, setDeployment] = useState<LoadState<ServerDeploymentResponse>>({ status: "loading" });
const [terminalOpen, setTerminalOpen] = useState(false);
const [confirm, setConfirm] = useState<null | { title: string; description: string; danger?: boolean; run: () => Promise<void> }>(null); const [confirm, setConfirm] = useState<null | { title: string; description: string; danger?: boolean; run: () => Promise<void> }>(null);
const [confirmBusy, setConfirmBusy] = useState(false); const [confirmBusy, setConfirmBusy] = useState(false);
@@ -77,7 +57,7 @@ export function ServerDetailPage(props: PageComponentProps) {
} }
setInstance({ status: "loading" }); setInstance({ status: "loading" });
try { try {
const [detail, pluginResponse, jobResponse, deploymentResponse, metricHistoryResponse, backupResponse, adapterResponse, endpointResponse] = await Promise.all([ const [detail, pluginResponse, jobResponse, deploymentResponse, endpointResponse] = await Promise.all([
platformApiClient.getServerInstance(serverId), platformApiClient.getServerInstance(serverId),
platformApiClient.listGamePlugins(), platformApiClient.listGamePlugins(),
platformApiClient.listJobs(serverId), platformApiClient.listJobs(serverId),
@@ -85,36 +65,17 @@ export function ServerDetailPage(props: PageComponentProps) {
.getServerDeployment(serverId) .getServerDeployment(serverId)
.then((data): LoadState<ServerDeploymentResponse> => ({ status: "ready", data })) .then((data): LoadState<ServerDeploymentResponse> => ({ status: "ready", data }))
.catch((error): LoadState<ServerDeploymentResponse> => ({ status: "error", reason: error instanceof Error ? error.message : "部署定义加载失败" })), .catch((error): LoadState<ServerDeploymentResponse> => ({ status: "error", reason: error instanceof Error ? error.message : "部署定义加载失败" })),
platformApiClient.listMetricHistory(serverId).catch(() => ({ items: [], count: 0 })),
platformApiClient.listBackups(serverId).catch(() => ({ items: [], count: 0 })),
platformApiClient.listRemoteAdapters(serverId).catch(() => ({ items: [], count: 0 })),
platformApiClient.listRunEndpoints().catch(() => ({ items: [], count: 0 })) platformApiClient.listRunEndpoints().catch(() => ({ items: [], count: 0 }))
]); ]);
setInstance({ status: "ready", data: detail }); setInstance({ status: "ready", data: detail });
setPlugins(pluginResponse.items); setPlugins(pluginResponse.items);
setJobs(jobResponse.items); setJobs(jobResponse.items);
setDeployment(deploymentResponse); setDeployment(deploymentResponse);
setMetricHistory(metricHistoryResponse.items);
setBackups(backupResponse.items);
setRemoteAdapters(adapterResponse.items);
setRunEndpoint(endpointResponse.items.find((endpoint) => endpoint.id === detail.runEndpointId)); setRunEndpoint(endpointResponse.items.find((endpoint) => endpoint.id === detail.runEndpointId));
const artifactLists = await Promise.all(
jobResponse.items.slice(0, 20).map((job) =>
platformApiClient
.listArtifacts({ ownerKind: "job", ownerId: job.id, state: "available" })
.then((response) => response.items)
.catch(() => [] as ArtifactResponse[])
)
);
setArtifacts(uniqueArtifacts(artifactLists.flat()));
} catch (error) { } catch (error) {
setInstance({ status: "error", reason: error instanceof Error ? error.message : "加载失败" }); setInstance({ status: "error", reason: error instanceof Error ? error.message : "加载失败" });
setArtifacts([]);
setRunEndpoint(undefined); setRunEndpoint(undefined);
setDeployment({ status: "error", reason: "部署定义加载失败" }); setDeployment({ status: "error", reason: "部署定义加载失败" });
setMetricHistory([]);
setBackups([]);
setRemoteAdapters([]);
} }
try { try {
const metricsResponse = await platformApiClient.listServerMetrics(); const metricsResponse = await platformApiClient.listServerMetrics();
@@ -151,10 +112,6 @@ export function ServerDetailPage(props: PageComponentProps) {
return () => window.clearInterval(timer); return () => window.clearInterval(timer);
}, [refreshOperationalState]); }, [refreshOperationalState]);
const serverOperations = useMemo(
() => operations.operations.filter((operation) => operation.targetId === serverId || operation.targetId.startsWith(`${serverId}:`)),
[operations.operations, serverId]
);
const canManageServers = session.capabilities.includes("servers.manage"); const canManageServers = session.capabilities.includes("servers.manage");
const readyPlugin = instance.status === "ready" ? plugins.find((plugin) => plugin.id === instance.data.pluginId) : undefined; const readyPlugin = instance.status === "ready" ? plugins.find((plugin) => plugin.id === instance.data.pluginId) : undefined;
const detailFreshness = instance.status === "ready" ? runtimeObservationFreshness(instance.data, runEndpoint) : "unverified"; const detailFreshness = instance.status === "ready" ? runtimeObservationFreshness(instance.data, runEndpoint) : "unverified";
@@ -168,7 +125,7 @@ export function ServerDetailPage(props: PageComponentProps) {
useEffect(() => { useEffect(() => {
if (detailSections.some((entry) => entry.id === section)) return; if (detailSections.some((entry) => entry.id === section)) return;
setSection(detailSections[0]?.id ?? "logs"); setSection(detailSections[0]?.id ?? "manage");
}, [detailSections, section]); }, [detailSections, section]);
function requestLifecycle(current: ServerInstanceResponse, action: "start" | "stop") { function requestLifecycle(current: ServerInstanceResponse, action: "start" | "stop") {
@@ -270,7 +227,6 @@ export function ServerDetailPage(props: PageComponentProps) {
<Square size={15} /> <Square size={15} />
<span></span> <span></span>
</button> </button>
<button type="button" className="icon-command" disabled={!canManageServers} title={canManageServers ? "管理终端" : "当前账号没有管理权限"} onClick={() => setTerminalOpen(true)}><Terminal size={15} /><span></span></button>
</div> </div>
</div> </div>
<div className="server-detail-stat-strip"> <div className="server-detail-stat-strip">
@@ -301,11 +257,9 @@ export function ServerDetailPage(props: PageComponentProps) {
))} ))}
</nav> </nav>
{section === "logs" && <LogsSection serverId={serverId} />}
{section === "terminal" && <SourceRCONCommandPanel serverId={instance.data.id} pluginId={instance.data.pluginId} />}
{pluginPageKeyFromSection(section) && readyPlugin && <PluginPageSection pageProps={props} serverId={serverId} plugin={readyPlugin} routeKey={pluginPageKeyFromSection(section) ?? ""} />} {pluginPageKeyFromSection(section) && readyPlugin && <PluginPageSection pageProps={props} serverId={serverId} plugin={readyPlugin} routeKey={pluginPageKeyFromSection(section) ?? ""} />}
{section === "config" && <ServerDeploymentSection instance={instance.data} deployment={deployment} />} {section === "manage" && <ServerDeploymentSection instance={instance.data} deployment={deployment} />}
{section === "config" && ( {section === "manage" && (
<ServerMetadataSection <ServerMetadataSection
instance={instance.data} instance={instance.data}
session={session} session={session}
@@ -313,11 +267,8 @@ export function ServerDetailPage(props: PageComponentProps) {
onChanged={(next) => setInstance({ status: "ready", data: next })} onChanged={(next) => setInstance({ status: "ready", data: next })}
/> />
)} )}
{section === "config" && <ServerAdministratorsSection instance={instance.data} session={session} onChanged={(next) => setInstance({ status: "ready", data: next })} />} {section === "manage" && <ServerAdministratorsSection instance={instance.data} session={session} onChanged={(next) => setInstance({ status: "ready", data: next })} />}
{section === "config" && <ConfigSection serverId={serverId} instance={instance.data} session={session} operations={operations} />}
{section === "llm" && <LlmSection serverId={serverId} instance={instance.data} session={session} operations={operations} />} {section === "llm" && <LlmSection serverId={serverId} instance={instance.data} session={session} operations={operations} />}
{section === "history" && <HistorySection serverId={serverId} serverOperations={serverOperations} jobs={jobs} artifacts={artifacts} metricHistory={metricHistory} backups={backups} remoteAdapters={remoteAdapters} />}
<ServerManagementTerminalDrawer open={terminalOpen} serverId={instance.data.id} serverName={instance.data.name} pluginId={instance.data.pluginId} canManage={canManageServers} onClose={() => setTerminalOpen(false)} />
</> </>
)} )}
@@ -344,14 +295,6 @@ export function ServerDetailPage(props: PageComponentProps) {
); );
} }
function uniqueArtifacts(artifacts: ArtifactResponse[]): ArtifactResponse[] {
const byID = new Map<string, ArtifactResponse>();
for (const artifact of artifacts) {
byID.set(artifact.id, artifact);
}
return [...byID.values()];
}
function serverDetailSectionEntries(plugin?: GamePluginResponse): Array<{ id: ServerDetailSection; label: string }> { function serverDetailSectionEntries(plugin?: GamePluginResponse): Array<{ id: ServerDetailSection; label: string }> {
const pluginPages = (plugin?.pages ?? []).map((page) => ({ id: `plugin:${page.key}` as ServerDetailSection, label: page.title })); const pluginPages = (plugin?.pages ?? []).map((page) => ({ id: `plugin:${page.key}` as ServerDetailSection, label: page.title }));
return [...pluginPages, ...serverDetailSections]; return [...pluginPages, ...serverDetailSections];
@@ -619,343 +562,6 @@ function metricFreshnessLabel(metrics: ServerMetricsResponse | null): string {
return new Date(metrics.collectedAt).toLocaleTimeString(); return new Date(metrics.collectedAt).toLocaleTimeString();
} }
interface LogsSectionProps {
serverId: string;
}
interface LogFilterState {
level: string;
keyword: string;
source: string;
sinceMinutes: string;
}
function LogsSection({ serverId }: LogsSectionProps) {
const [streams, setStreams] = useState<LoadState<LogStreamResponse[]>>({ status: "loading" });
const [entries, setEntries] = useState<LiveLogEntry[]>([]);
const [filter, setFilter] = useState<LogFilterState>({ level: "all", keyword: "", source: "all", sinceMinutes: "all" });
const [selected, setSelected] = useState<LiveLogEntry | null>(null);
const [eventSourceKey, setEventSourceKey] = useState(0);
const refresh = useCallback(() => setEventSourceKey((current) => current + 1), []);
useEffect(() => {
setStreams({ status: "loading" });
setEntries([]);
setSelected(null);
let ready = false;
const events = platformApiClient.openServerLogEvents(serverId, { historyLimit: 200 });
events.addEventListener("stream", (event) => {
const stream = parseLogStreamEvent(event);
if (!stream) return;
ready = true;
setStreams((current) => ({ status: "ready", data: mergeLogStreams(current.status === "ready" ? current.data : [], stream) }));
});
events.addEventListener("ready", () => {
ready = true;
setStreams((current) => current.status === "ready" ? current : { status: "ready", data: [] });
});
events.addEventListener("log", (event) => {
const payload = parseServerLogEvent(event);
if (!payload) return;
ready = true;
setStreams((current) => ({ status: "ready", data: mergeLogStreams(current.status === "ready" ? current.data : [], streamFromServerLogEvent(payload)) }));
setEntries((current) => appendLiveLogEntries(current, [entryFromServerLogEvent(payload)], 1000));
});
events.onerror = () => {
if (!ready) setStreams({ status: "error", reason: "实时日志推送连接失败" });
};
return () => events.close();
}, [eventSourceKey, serverId]);
const sources = useMemo(() => [...new Set(entries.map((entry) => entry.source))], [entries]);
const visible = useMemo(() => {
const keyword = filter.keyword.trim().toLowerCase();
const sinceMs = filter.sinceMinutes === "all" ? null : Date.now() - Number(filter.sinceMinutes) * 60_000;
return entries.filter((entry) => {
if (filter.level !== "all" && (entry.level ?? "info").toLowerCase() !== filter.level) {
return false;
}
if (filter.source !== "all" && entry.source !== filter.source) {
return false;
}
if (keyword && !entry.line.toLowerCase().includes(keyword)) {
return false;
}
if (sinceMs !== null && new Date(entry.timestamp).getTime() < sinceMs) {
return false;
}
return true;
}).sort(compareLogEntriesDesc);
}, [entries, filter]);
return (
<article className="console-panel" aria-label="server logs">
<div className="panel-header">
<h2></h2>
<button type="button" className="icon-command" onClick={refresh}>
<Sparkles size={14} />
<span></span>
</button>
</div>
<div className="log-filter-bar">
<select value={filter.level} aria-label="按级别过滤" onChange={(event) => setFilter((current) => ({ ...current, level: event.target.value }))}>
<option value="all"></option>
<option value="error">error</option>
<option value="warn">warn</option>
<option value="info">info</option>
<option value="debug">debug</option>
</select>
<select value={filter.source} aria-label="按来源过滤" onChange={(event) => setFilter((current) => ({ ...current, source: event.target.value }))}>
<option value="all"></option>
{sources.map((source) => (
<option key={source} value={source}>
{source}
</option>
))}
</select>
<select
value={filter.sinceMinutes}
aria-label="按时间过滤"
onChange={(event) => setFilter((current) => ({ ...current, sinceMinutes: event.target.value }))}
>
<option value="all"></option>
<option value="15"> 15 </option>
<option value="60"> 1 </option>
<option value="1440"> 24 </option>
</select>
<input
type="search"
value={filter.keyword}
placeholder="关键字"
aria-label="按关键字过滤"
onChange={(event) => setFilter((current) => ({ ...current, keyword: event.target.value }))}
/>
</div>
{streams.status === "loading" && <LoadingState label="正在加载日志…" compact />}
{streams.status === "error" && <ErrorState title="日志加载失败" reason={streams.reason} diagnosticId={`logs:${serverId}`} onRetry={refresh} compact />}
{streams.status === "ready" && entries.length === 0 && (
<EmptyState title="暂无日志" description="该服务器还没有已入库的日志流,或运行端尚未上报日志。" actionLabel="重连" onAction={refresh} />
)}
{streams.status === "ready" && entries.length > 0 && visible.length === 0 && (
<EmptyState title="没有匹配的日志" description="调整级别、来源、时间范围或关键字后再试。" />
)}
{visible.length > 0 && (
<div className="log-list" role="list">
{visible.map((entry) => (
<button key={`${entry.streamId}-${entry.seq}`} type="button" className="log-line" role="listitem" onClick={() => setSelected(entry)}>
<time>{new Date(entry.timestamp).toLocaleTimeString()}</time>
<span className={cx("log-level", levelClass(entry.level))}>{(entry.level ?? "info").toUpperCase()}</span>
<span>{entry.line}</span>
</button>
))}
</div>
)}
{selected && (
<div className="drawer-backdrop" role="presentation" onClick={() => setSelected(null)}>
<div className="drawer-panel" role="dialog" aria-modal="true" aria-label="日志详情" onClick={(event) => event.stopPropagation()}>
<div className="panel-header">
<h2></h2>
<button type="button" className="drawer-close" onClick={() => setSelected(null)}>
</button>
</div>
<dl className="detail-list">
<div>
<dt></dt>
<dd>{new Date(selected.timestamp).toLocaleString()}</dd>
</div>
<div>
<dt></dt>
<dd>{(selected.level ?? "info").toUpperCase()}</dd>
</div>
<div>
<dt></dt>
<dd>{selected.source}</dd>
</div>
<div>
<dt></dt>
<dd>{selected.seq}</dd>
</div>
<div>
<dt></dt>
<dd>{selected.line}</dd>
</div>
{selected.fields && Object.keys(selected.fields).length > 0 && (
<div>
<dt></dt>
<dd>
{Object.entries(selected.fields)
.map(([key, value]) => `${key}=${value}`)
.join(" ")}
</dd>
</div>
)}
</dl>
<DiagnosticSummary diagnosticId={`log:${serverId}:${selected.source}:${selected.seq}`} detail={selected.line} />
</div>
</div>
)}
</article>
);
}
function levelClass(level?: string): string {
const normalized = (level ?? "info").toLowerCase();
if (normalized === "error" || normalized === "fatal") {
return "log-level-error";
}
if (normalized === "warn" || normalized === "warning") {
return "log-level-warn";
}
return "log-level-info";
}
function compareLogEntriesDesc(a: LiveLogEntry, b: LiveLogEntry): number {
const time = (Date.parse(b.timestamp) || 0) - (Date.parse(a.timestamp) || 0);
if (time !== 0) return time;
return b.seq - a.seq;
}
interface ConfigSectionProps {
serverId: string;
instance: ServerInstanceResponse;
session: PageComponentProps["session"];
operations: PageComponentProps["operations"];
}
function ConfigSection({ serverId, instance, session, operations }: ConfigSectionProps) {
const [config, setConfig] = useState<LoadState<{ content: string; source: "api" | "local" }>>({ status: "loading" });
const [draft, setDraft] = useState("");
const [diff, setDiff] = useState<ConfigDiffView | null>(null);
const [previewBusy, setPreviewBusy] = useState(false);
const [previewError, setPreviewError] = useState<string | null>(null);
const refresh = useCallback(async () => {
setConfig({ status: "loading" });
try {
const response: ServerConfigResponse = await platformApiClient.getServerConfig(serverId);
setConfig({ status: "ready", data: { content: response.content, source: "api" } });
setDraft(response.content);
} catch (error) {
setConfig({ status: "error", reason: error instanceof Error ? error.message : "配置读取接口不可用" });
setDraft("");
}
}, [serverId]);
useEffect(() => {
void refresh();
}, [refresh]);
async function prepareDiff(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (config.status !== "ready") {
return;
}
setPreviewBusy(true);
setPreviewError(null);
try {
const preview = await platformApiClient.previewServerConfigDiff(serverId, {
expectedConfigVersion: instance.configVersion,
expectedChecksum: instance.configChecksum,
key: defaultConfigKey,
proposedContent: draft
});
setDiff(configDiffViewFromPreview(preview));
} catch (error) {
setPreviewError(error instanceof Error ? error.message : "配置差异预览失败");
} finally {
setPreviewBusy(false);
}
}
async function submitDiff() {
if (!diff || config.status !== "ready") {
return;
}
const operationId = operations.begin({ intent: "写入配置", targetKind: "config", targetId: serverId, requester: session.displayName });
try {
const dispatch = await platformApiClient.approveServerConfigWrite(serverId, {
expectedConfigVersion: diff.configVersion ?? instance.configVersion,
expectedChecksum: diff.checksum ?? instance.configChecksum,
key: diff.key ?? defaultConfigKey,
proposedContent: diff.nextContent,
proposedContentInputRef: diff.proposedContentInputRef,
idempotencyKey: `web:config.write:${serverId}:${Date.now()}`
});
const job = dispatch.job;
operations.succeed(operationId, `配置写入任务 ${job.id} 已派发`, job);
setDiff(null);
} catch (error) {
operations.fail(operationId, error instanceof Error ? error.message : "配置写入任务派发失败", operationId);
}
}
const writeOperation = operations.operations.find((operation) => operation.intent === "写入配置" && operation.targetId === serverId);
return (
<article className="console-panel" aria-label="server configuration">
<div className="panel-header">
<h2></h2>
{config.status === "ready" && <span className="page-status"> v{instance.configVersion}{instance.configChecksum ? ` · ${instance.configChecksum.slice(0, 18)}` : ""}</span>}
</div>
{writeOperation && (
<div style={{ marginBottom: 10 }}>
<ResultBadge
status={writeOperation.status}
label={
writeOperation.status === "pending"
? "配置写入中…"
: writeOperation.status === "succeeded"
? (writeOperation.message ?? "写入任务已派发")
: `写入失败:${writeOperation.errorReason}(诊断 ${writeOperation.diagnosticId}`
}
/>
</div>
)}
{config.status === "loading" && <LoadingState label="正在加载配置…" compact />}
{config.status === "error" && <ErrorState title="配置读取不可用" reason={config.reason} diagnosticId={`server-config:${serverId}`} onRetry={() => void refresh()} compact />}
{previewError && <ErrorState title="配置差异预览失败" reason={previewError} compact />}
{config.status === "ready" && (
<form className="provider-form" style={{ border: 0, padding: 0 }} onSubmit={(event) => void prepareDiff(event)}>
<label>
<textarea value={draft} onChange={(event) => setDraft(event.target.value)} rows={10} aria-label="配置编辑器" />
</label>
<button type="submit" className="primary-command" disabled={previewBusy || draft === config.data.content}>
{previewBusy ? "预览中…" : "预览变更"}
</button>
</form>
)}
{diff && (
<div className="drawer-backdrop" role="presentation" onClick={() => setDiff(null)}>
<div className="drawer-panel" role="dialog" aria-modal="true" aria-label="配置变更确认" onClick={(event) => event.stopPropagation()}>
<div className="panel-header">
<h2></h2>
<span className="page-status">{diff.summary}</span>
</div>
<p style={{ margin: 0, color: "var(--ink-soft)", fontSize: 14 }}>
<strong>{instance.name}</strong>{serverId}
</p>
<DiffView lines={diff.lines} />
<div className="confirm-actions">
<button type="button" onClick={() => setDiff(null)}>
</button>
<button type="button" className="confirm-primary" disabled={!diffHasChanges(diff)} onClick={() => void submitDiff()}>
</button>
</div>
</div>
</div>
)}
</article>
);
}
interface LlmSectionProps { interface LlmSectionProps {
serverId: string; serverId: string;
instance: ServerInstanceResponse; instance: ServerInstanceResponse;
@@ -985,21 +591,13 @@ function LlmSection({ serverId, instance, session, operations }: LlmSectionProps
throw new Error(response.error?.message ?? "AI 提供商未返回可用建议"); throw new Error(response.error?.message ?? "AI 提供商未返回可用建议");
} }
const recommendation = response.configRecommendation; const recommendation = response.configRecommendation;
const preview = recommendation?.suggestedConfig
? await platformApiClient.previewServerConfigDiff(serverId, {
expectedConfigVersion: instance.configVersion,
expectedChecksum: instance.configChecksum,
key: recommendation.key,
proposedContent: recommendation.suggestedConfig
})
: undefined;
setSuggestion({ setSuggestion({
serverInstanceId: serverId, serverInstanceId: serverId,
source: "api", source: "api",
recommendation: response.recommendation ?? "Platform 已返回配置建议。", recommendation: response.recommendation ?? "Platform 已返回配置建议。",
diffId: recommendation?.diffId, diffId: recommendation?.diffId,
expiresAt: recommendation?.expiresAt, expiresAt: recommendation?.expiresAt,
diff: preview ? configDiffViewFromPreview(preview) : undefined diffSummary: recommendation?.diffSummary
}); });
} catch (caught) { } catch (caught) {
setSuggestionError(caught instanceof Error ? caught.message : "AI 建议请求失败"); setSuggestionError(caught instanceof Error ? caught.message : "AI 建议请求失败");
@@ -1009,7 +607,7 @@ function LlmSection({ serverId, instance, session, operations }: LlmSectionProps
} }
async function applySuggestion() { async function applySuggestion() {
if (!suggestion?.diff || !suggestion.diffId || approvalBusy) { if (!suggestion?.diffId || approvalBusy) {
return; return;
} }
const operationId = operations.begin({ intent: "应用 AI 配置建议", targetKind: "llm", targetId: serverId, requester: session.displayName }); const operationId = operations.begin({ intent: "应用 AI 配置建议", targetKind: "llm", targetId: serverId, requester: session.displayName });
@@ -1078,15 +676,19 @@ function LlmSection({ serverId, instance, session, operations }: LlmSectionProps
<span className="page-status"> AI Provider</span> <span className="page-status"> AI Provider</span>
</div> </div>
<p style={{ margin: 0, color: "var(--ink-soft)", fontSize: 14 }}>{suggestion.recommendation}</p> <p style={{ margin: 0, color: "var(--ink-soft)", fontSize: 14 }}>{suggestion.recommendation}</p>
{suggestion.diff ? ( {suggestion.diffId ? (
<> <>
<DiffView lines={suggestion.diff.lines} /> <div className="console-record">
<div className="console-record-head"><strong>Reviewable AI diff</strong><span className="status-pill status-active">pending</span></div>
<div className="console-record-meta"><span>Diff {suggestion.diffId}</span>{suggestion.expiresAt && <span> {new Date(suggestion.expiresAt).toLocaleString()}</span>}</div>
<span className="provider-id">{suggestion.diffSummary ?? "平台已保存可审查配置差异;批准后才会派发写入任务。"}</span>
</div>
<div className="confirm-actions"> <div className="confirm-actions">
<button type="button" onClick={() => setSuggestion(null)}> <button type="button" onClick={() => setSuggestion(null)}>
</button> </button>
<button type="button" className="confirm-primary" onClick={() => setConfirming(true)}> <button type="button" className="confirm-primary" onClick={() => setConfirming(true)}>
AI
</button> </button>
</div> </div>
</> </>
@@ -1132,207 +734,3 @@ function configDiffLineFromPreviewLine(line: ConfigDiffLineResponse): ConfigDiff
text: line.content text: line.content
}; };
} }
interface HistorySectionProps {
serverId: string;
serverOperations: PageComponentProps["operations"]["operations"];
jobs: JobResponse[];
artifacts: ArtifactResponse[];
metricHistory: MetricSampleResponse[];
backups: BackupResponse[];
remoteAdapters: RemoteAdapterDeclarationResponse[];
}
function HistorySection({ serverId, serverOperations, jobs, artifacts, metricHistory, backups, remoteAdapters }: HistorySectionProps) {
return (
<>
<ProductionGovernancePanel compact title={`服务器 ${serverId} 的容量与告警`} />
<div className="overview-two-col" aria-label="operation history">
<article className="console-panel">
<div className="panel-header">
<h2></h2>
</div>
{serverOperations.length === 0 ? (
<EmptyState title="暂无操作记录" description="在本页发起启动、停止、插件页面操作或配置写入后,这里会显示完整的操作生命周期。" />
) : (
<div className="console-record-list">
{serverOperations.map((operation) => (
<div key={operation.id} className="console-record">
<div className="console-record-head">
<strong>{operation.intent}</strong>
<ResultBadge
status={operation.status}
label={operation.status === "pending" ? "进行中" : operation.status === "succeeded" ? "成功" : "失败"}
/>
</div>
<div className="console-record-meta">
<span>
<code>{operation.id}</code>
</span>
{operation.jobId && (
<span>
<code>{operation.jobId}</code>
{operation.jobState ? `${operation.jobState}` : ""}
</span>
)}
<span> {operation.targetId}</span>
<span> {operation.requester}</span>
<span>{new Date(operation.updatedAt).toLocaleString()}</span>
</div>
{operation.message && <span className="provider-id">{operation.message}</span>}
{operation.status === "failed" && operation.errorReason && (
<span className="provider-id">
{operation.errorReason}
{operation.diagnosticId ? `(诊断 ${operation.diagnosticId}` : ""}
</span>
)}
</div>
))}
</div>
)}
</article>
<article className="console-panel">
<div className="panel-header">
<h2></h2>
</div>
{jobs.length === 0 ? (
<EmptyState title="暂无任务" description="该服务器还没有平台侧任务记录。" />
) : (
<div className="console-record-list">
{jobs.slice(0, 20).map((job) => (
<div key={job.id} className="console-record">
<div className="console-record-head">
<strong>{jobCapabilityLabel(job.capability)}</strong>
<span className={cx("status-pill", job.state === "succeeded" ? "status-active" : job.state === "failed" ? "status-error" : "status-disabled")}>
{job.state}
</span>
</div>
<div className="console-record-meta">
<span>
<code>{job.id}</code>
</span>
<span> {job.progress.percent}%</span>
{job.progress.phase && <span>{deploymentProgressLabel(job.progress)}</span>}
<span>
{job.attempt}/{job.retryPolicy.maxAttempts}
</span>
{job.nextAttemptAt && <span> {new Date(job.nextAttemptAt).toLocaleString()}</span>}
{job.lastReconciledAt && <span> {new Date(job.lastReconciledAt).toLocaleString()}</span>}
<span>{new Date(job.updatedAt).toLocaleString()}</span>
</div>
{job.progress.message && <span className="provider-id">{job.progress.message}</span>}
{job.cancelReason && <span className="provider-id">{job.cancelReason}</span>}
{job.reconcileOutcome && <span className="provider-id">{job.reconcileOutcome}</span>}
{job.executionResult && (job.executionResult.processState || job.executionResult.checksum || job.executionResult.version !== undefined) && (
<span className="provider-id">
{job.executionResult.processState ?? job.executionResult.kind ?? "已记录"}
{job.executionResult.version !== undefined ? ` · v${job.executionResult.version}` : ""}
{job.executionResult.checksum ? ` · ${job.executionResult.checksum.slice(0, 18)}` : ""}
{job.executionResult.sizeBytes !== undefined ? ` · ${job.executionResult.sizeBytes} B` : ""}
</span>
)}
</div>
))}
</div>
)}
</article>
<ArtifactDownloadPanel serverId={serverId} artifacts={artifacts} />
<article className="console-panel" aria-label="durable observability">
<div className="panel-header">
<h2></h2>
</div>
<div className="console-record-list">
<div className="console-record">
<div className="console-record-head"><strong></strong><span className="status-pill status-active">{metricHistory.length} </span></div>
<div className="console-record-meta"><span> {metricHistory.length > 0 ? new Date(metricHistory[metricHistory.length - 1].collectedAt).toLocaleString() : "暂无"}</span></div>
</div>
<div className="console-record">
<div className="console-record-head"><strong></strong><span className="status-pill status-active">{backups.length} </span></div>
<div className="console-record-meta">{backups.slice(0, 4).map((backup) => <span key={backup.id}>{backup.id} · {backup.state} · {backup.checksum.slice(0, 18)}</span>)}</div>
</div>
<div className="console-record">
<div className="console-record-head"><strong></strong><span className="status-pill status-active">{remoteAdapters.length} </span></div>
<div className="console-record-meta">{remoteAdapters.slice(0, 4).map((adapter) => <span key={adapter.key}>{adapter.key} · {adapter.kind} · {adapter.targetKeys.join(", ")}</span>)}</div>
</div>
</div>
</article>
</div>
</>
);
}
interface ArtifactDownloadPanelProps {
serverId: string;
artifacts: ArtifactResponse[];
}
function ArtifactDownloadPanel({ serverId, artifacts }: ArtifactDownloadPanelProps) {
const [activeId, setActiveId] = useState<string | null>(null);
const [result, setResult] = useState<Record<string, { status: "pending" | "succeeded" | "failed"; label: string; progress?: number }>>({});
async function downloadArtifact(artifact: ArtifactResponse) {
setActiveId(artifact.id);
setResult((current) => ({ ...current, [artifact.id]: { status: "pending", label: "正在打开制品", progress: 0 } }));
try {
const reference = await platformApiClient.openArtifactDownload(artifact.id);
await downloadArtifactReference(reference, (artifactId, offset, limit) => platformApiClient.readArtifactContent(artifactId, offset, limit), (progress) => {
setResult((current) => ({ ...current, [artifact.id]: { status: "pending", label: `传输 ${progress}%`, progress } }));
});
setResult((current) => ({ ...current, [artifact.id]: { status: "succeeded", label: `已打开 ${safeArtifactFilename(reference.filename)}`, progress: 100 } }));
} catch (error) {
setResult((current) => ({ ...current, [artifact.id]: { status: "failed", label: safeArtifactError(error) } }));
} finally {
setActiveId(null);
}
}
return (
<article className="console-panel">
<div className="panel-header">
<h2>
<PackageOpen size={16} />
</h2>
</div>
{artifacts.length === 0 ? (
<EmptyState title="暂无可下载制品" description="该服务器当前没有已完成的可用制品。" />
) : (
<div className="console-record-list">
{artifacts.slice(0, 12).map((artifact) => {
const itemResult = result[artifact.id];
return (
<div key={artifact.id} className="console-record">
<div className="console-record-head">
<strong>{artifact.id}</strong>
<ResultBadge status={itemResult?.status ?? "pending"} label={itemResult?.label ?? artifact.state} />
</div>
<div className="console-record-meta">
<span> {serverId}</span>
<span>{formatBytes(artifact.sizeBytes)}</span>
<span>{artifact.checksum}</span>
</div>
{itemResult?.progress !== undefined && <UsageMeter label="传输" percent={itemResult.progress} />}
<div className="action-strip">
<button type="button" className="icon-command" disabled={activeId !== null} onClick={() => void downloadArtifact(artifact)}>
<Download size={14} />
<span>{activeId === artifact.id ? "传输中" : "打开"}</span>
</button>
</div>
</div>
);
})}
</div>
)}
</article>
);
}
function formatBytes(value: number): string {
if (value < 1024) {
return `${value} B`;
}
if (value < 1024 * 1024) {
return `${(value / 1024).toFixed(1)} KiB`;
}
return `${(value / (1024 * 1024)).toFixed(1)} MiB`;
}
+5 -12
View File
@@ -11,7 +11,6 @@ import {
runtimeDependencyStages, runtimeDependencyStages,
runtimeDownloadStages, runtimeDownloadStages,
runtimeKeyResetStages, runtimeKeyResetStages,
runtimeLogStages,
runtimeRunBuildStages, runtimeRunBuildStages,
runtimeUpdateStages, runtimeUpdateStages,
useRuntimeTaskController useRuntimeTaskController
@@ -33,7 +32,6 @@ import { filterServerCards, serverIsOnline, type ServerCardView, type ServerStat
import { import {
clientManagerBuildRequest, clientManagerBuildRequest,
dependencyJobRequest, dependencyJobRequest,
logBackfillRequest,
runDistributionGenerateRequest, runDistributionGenerateRequest,
runUpdateRequest, runUpdateRequest,
serverCreateRequestFromForm, serverCreateRequestFromForm,
@@ -367,8 +365,7 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr
const job = await platformApiClient.installDependencies(instance.id, dependencyJobRequest(instance.id, probe.key, plan.key, plan.digest)); const job = await platformApiClient.installDependencies(instance.id, dependencyJobRequest(instance.id, probe.key, plan.key, plan.digest));
return `依赖安装任务已排队,job ${job.id}`; return `依赖安装任务已排队,job ${job.id}`;
} }
const job = await platformApiClient.requestLogBackfill(instance.id, logBackfillRequest(instance.id, defaults.logSourceKey)); throw new Error("该运行操作已下线");
return `历史日志回填任务已排队,job ${job.id}`;
} }
}); });
} }
@@ -677,8 +674,7 @@ type ServerQuickRuntimeAction =
| "reset-run-key" | "reset-run-key"
| "generate-client-manager" | "generate-client-manager"
| "dependencies-check" | "dependencies-check"
| "dependencies-install" | "dependencies-install";
| "historical-logs";
async function requireQuickRuntimeActionAvailable(serverInstanceId: string, action: ServerQuickRuntimeAction): Promise<void> { async function requireQuickRuntimeActionAvailable(serverInstanceId: string, action: ServerQuickRuntimeAction): Promise<void> {
const runtimeActions = await platformApiClient.getServerRuntimeActions(serverInstanceId); const runtimeActions = await platformApiClient.getServerRuntimeActions(serverInstanceId);
@@ -917,7 +913,7 @@ const serverQuickActionGroups: Array<{ label: string; actions: ServerQuickRuntim
}, },
{ {
label: "诊断维护", label: "诊断维护",
actions: ["dependencies-check", "dependencies-install", "historical-logs"] actions: ["dependencies-check", "dependencies-install"]
} }
]; ];
@@ -937,8 +933,6 @@ function quickRuntimeActionLabel(action: ServerQuickRuntimeAction): string {
return "依赖检查"; return "依赖检查";
case "dependencies-install": case "dependencies-install":
return "依赖安装"; return "依赖安装";
case "historical-logs":
return "历史日志";
} }
} }
@@ -961,7 +955,7 @@ function quickRuntimeStages(action: ServerQuickRuntimeAction) {
if (action === "dependencies-check" || action === "dependencies-install") { if (action === "dependencies-check" || action === "dependencies-install") {
return runtimeDependencyStages; return runtimeDependencyStages;
} }
return runtimeLogStages; return runtimeDependencyStages;
} }
function quickRuntimeExecuteStageIndex(action: ServerQuickRuntimeAction): number { function quickRuntimeExecuteStageIndex(action: ServerQuickRuntimeAction): number {
@@ -990,8 +984,7 @@ function quickRuntimeDefaultsForPlugin(pluginId: string) {
clientProfileKey: isScum ? "scum-client-manager" : "client-manager", clientProfileKey: isScum ? "scum-client-manager" : "client-manager",
repositoryUrl: isScum ? "https://github.com/F88888/scum_client.git" : "https://github.com/example/client-manager.git", repositoryUrl: isScum ? "https://github.com/F88888/scum_client.git" : "https://github.com/example/client-manager.git",
probeKey: isScum ? "steamcmd" : "java-21", probeKey: isScum ? "steamcmd" : "java-21",
installPlanKey: isScum ? "install-steamcmd-linux" : "install-java-linux", installPlanKey: isScum ? "install-steamcmd-linux" : "install-java-linux"
logSourceKey: isScum ? "scum-server-events" : "latest-log"
}; };
} }
+1 -1
View File
@@ -6,7 +6,7 @@ First-party routes must be declared here before page implementation.
- `/`: 平台概览(平台管理员默认落地页). - `/`: 平台概览(平台管理员默认落地页).
- `/servers`: 服务器管理(服主/服务器管理员默认落地页). - `/servers`: 服务器管理(服主/服务器管理员默认落地页).
- `/servers/:serverId`: 服务器详情 route(日常运维工作台:插件声明页面、日志、管理终端、配置、AI 助手、操作历史). - `/servers/:serverId`: 服务器详情 route(日常运维工作台:插件声明页面、管理、AI 助手;不暴露 raw 日志、管理终端、raw 配置或操作历史.
- `/plugins`: 插件市场. - `/plugins`: 插件市场.
- `/users`: 用户管理. - `/users`: 用户管理.
- `/ai-providers`: AI 提供商管理. - `/ai-providers`: AI 提供商管理.
-25
View File
@@ -1,25 +0,0 @@
import { describe, expect, it } from "vitest";
import { sourceRCONChatRequest, sourceRCONRawCommandRequest } from "./sourceRcon";
describe("Source RCON browser request schemas", () => {
it("builds a bounded typed chat request without connection material", () => {
expect(sourceRCONChatRequest("server-1", { chatType: 4, message: "hello", targetSteamId: "76561198000000001" }, 12)).toEqual({
kind: "chat",
chatType: 4,
message: "hello",
targetSteamId: "76561198000000001",
idempotencyKey: "web:source-rcon:chat:server-1:12"
});
});
it("rejects framed text and formats raw commands without chat fields", () => {
expect(() => sourceRCONChatRequest("server-1", { chatType: 2, message: "line one\nline two" }, 13)).toThrow("受限的单行文本");
expect(() => sourceRCONChatRequest("server-1", { chatType: 8, message: "hello" }, 13)).toThrow("聊天类型");
expect(sourceRCONRawCommandRequest("server-1", " SetTime 12 ", 14)).toEqual({
kind: "command",
command: "SetTime 12",
idempotencyKey: "web:source-rcon:command:server-1:14"
});
});
});
-49
View File
@@ -1,49 +0,0 @@
import type { SourceRCONCommandRequest } from "../api/types";
const maxChatBytes = 1024;
const maxCommandBytes = 4000;
const steamID64 = /^[0-9]{17}$/;
export interface SourceRCONChatDraft {
chatType: number;
message: string;
targetSteamId?: string;
}
export function sourceRCONChatRequest(serverInstanceId: string, draft: SourceRCONChatDraft, sequence = Date.now()): SourceRCONCommandRequest {
const message = validateSourceRCONText(draft.message, maxChatBytes, "聊天内容");
if (!Number.isInteger(draft.chatType) || draft.chatType < 0 || draft.chatType > 7) {
throw new Error("聊天类型必须在 0 到 7 之间。");
}
const targetSteamId = draft.targetSteamId?.trim() ?? "";
if (targetSteamId && !steamID64.test(targetSteamId)) {
throw new Error("目标 SteamID64 必须为 17 位数字。");
}
return {
kind: "chat",
chatType: draft.chatType,
message,
targetSteamId: targetSteamId || undefined,
idempotencyKey: sourceRCONIdempotencyKey("chat", serverInstanceId, sequence)
};
}
export function sourceRCONRawCommandRequest(serverInstanceId: string, command: string, sequence = Date.now()): SourceRCONCommandRequest {
return {
kind: "command",
command: validateSourceRCONText(command, maxCommandBytes, "原始指令"),
idempotencyKey: sourceRCONIdempotencyKey("command", serverInstanceId, sequence)
};
}
function validateSourceRCONText(value: string, maxBytes: number, label: string): string {
const normalized = value.trim();
if (!normalized || new TextEncoder().encode(normalized).byteLength > maxBytes || /[\u0000\r\n]/.test(normalized)) {
throw new Error(`${label}必须是受限的单行文本。`);
}
return normalized;
}
function sourceRCONIdempotencyKey(kind: "chat" | "command", serverInstanceId: string, sequence: number): string {
return `web:source-rcon:${kind}:${serverInstanceId.trim()}:${Math.max(0, Math.floor(sequence))}`;
}
+1
View File
@@ -756,6 +756,7 @@ to{transform:translate(-50%,-50%) rotate(calc(var(--construct-drift) + 360deg))}
.console-stat-strip>div,.operations-pulse-strip>div{display:grid;gap:3px;min-width:0;padding:9px 10px;border:1px solid color-mix(in srgb,var(--line) 78%,transparent);border-radius:6px;background:color-mix(in srgb,var(--surface-solid) 78%,var(--accent-soft))} .console-stat-strip>div,.operations-pulse-strip>div{display:grid;gap:3px;min-width:0;padding:9px 10px;border:1px solid color-mix(in srgb,var(--line) 78%,transparent);border-radius:6px;background:color-mix(in srgb,var(--surface-solid) 78%,var(--accent-soft))}
.console-stat-strip dt,.operations-pulse-strip dt{color:var(--ink-faint);font-size:11px} .console-stat-strip dt,.operations-pulse-strip dt{color:var(--ink-faint);font-size:11px}
.console-stat-strip dd,.operations-pulse-strip dd{margin:0;color:var(--ink);font-size:18px;font-weight:850} .console-stat-strip dd,.operations-pulse-strip dd{margin:0;color:var(--ink);font-size:18px;font-weight:850}
.map-projection-board{position:relative;min-height:320px;border:1px solid color-mix(in srgb,var(--line) 76%,transparent);border-radius:14px;overflow:hidden;background:radial-gradient(circle at 50% 50%,color-mix(in srgb,var(--accent-soft) 42%,transparent),transparent 58%),linear-gradient(135deg,color-mix(in srgb,var(--surface-solid) 78%,#000),#05070d)}.map-projection-dot{position:absolute;width:9px;height:9px;border-radius:999px;background:var(--accent);box-shadow:0 0 16px color-mix(in srgb,var(--accent) 80%,transparent);transform:translate(-50%,-50%)}
.console-row-list,.operations-endpoint-list,.operations-job-list{display:grid;gap:6px;margin-top:10px} .console-row-list,.operations-endpoint-list,.operations-job-list{display:grid;gap:6px;margin-top:10px}
.console-row,.operations-endpoint-row,.operations-job-row{display:grid;grid-template-columns:minmax(0,1fr) auto auto;align-items:center;gap:10px;min-width:0;padding:8px 10px;border:1px solid var(--line);border-radius:6px;background:var(--control-surface);color:var(--ink-soft);text-align:left} .console-row,.operations-endpoint-row,.operations-job-row{display:grid;grid-template-columns:minmax(0,1fr) auto auto;align-items:center;gap:10px;min-width:0;padding:8px 10px;border:1px solid var(--line);border-radius:6px;background:var(--control-surface);color:var(--ink-soft);text-align:left}
.console-row-button,.operations-job-row{width:100%;cursor:pointer} .console-row-button,.operations-job-row{width:100%;cursor:pointer}
@@ -1,26 +1,11 @@
import type { SCUMConfigField, SCUMFeatureWorkspace, SCUMLogicalDirectory, SCUMLogicalFile } from "./contracts.js";
type StateSetter<T> = (next: T | ((previous: T) => T)) => void; type StateSetter<T> = (next: T | ((previous: T) => T)) => void;
export type ReactLike = { export type ReactLike = {
createElement: (...args: any[]) => any; createElement: (...args: any[]) => any;
useEffect?: (effect: () => void | (() => void), deps: readonly unknown[]) => void; useEffect?: (effect: () => void | (() => void), deps: readonly unknown[]) => void;
useState?: <T>(initialState: T | (() => T)) => [T, StateSetter<T>]; useState?: <T>(initialState: T | (() => T)) => [T, StateSetter<T>];
}; };
export type SCUMFileReadSnapshot = {
serverInstanceId: string;
pluginId: string;
key: string;
state: "ready" | "pending" | "not-read" | "unavailable" | string;
content?: string;
version?: number;
checksum?: string;
sizeBytes?: number;
jobId?: string;
readAt?: string;
reason?: string;
};
export type SCUMPageContext = { export type SCUMPageContext = {
pageKey?: string; pageKey?: string;
pageTitle?: string; pageTitle?: string;
@@ -28,499 +13,186 @@ export type SCUMPageContext = {
permissions: string[]; permissions: string[];
availability: { available: boolean; reason?: string }; availability: { available: boolean; reason?: string };
featureAvailability?: Array<{ key: string; available: boolean; reason?: string }>; featureAvailability?: Array<{ key: string; available: boolean; reason?: string }>;
workspace?: SCUMFeatureWorkspace; workspaceActions?: SCUMWorkspaceActions;
workspaceActions?: {
refreshWorkspace?: () => Promise<SCUMFeatureWorkspace | undefined>;
requestFile?: (fileKey: string) => Promise<{ status: string; message: string; jobId?: string }>;
getFileSnapshot?: (fileKey: string) => Promise<SCUMFileReadSnapshot>;
writeFile?: (fileKey: string, content: string, options?: { expectedChecksum?: string }) => Promise<{ status: string; message: string; jobId?: string }>;
};
}; };
type NormalizedWorkspace = { defaultDirectoryKey: string; directories: readonly SCUMLogicalDirectory[]; files: readonly SCUMLogicalFile[]; configFields: readonly SCUMConfigField[] }; type SCUMWorkspaceActions = {
type ConfigMode = "fields" | "pairs" | "source"; listSCUMPlayers?: () => Promise<unknown>;
type RawEncoding = "utf-8" | "utf-16le"; listSCUMSquads?: () => Promise<unknown>;
type FileRequestState = { fileKey: string; status: string; message: string; jobId?: string } | null; listSCUMSquadMembers?: () => Promise<unknown>;
type WorkspaceRefreshState = { status: string; message: string } | null; listSCUMVehicles?: () => Promise<unknown>;
type PreviewState = { fileKey: string; mode: ConfigMode; summary: string; proposedContent: string; lines: readonly DiffLine[] } | null; listSCUMFlags?: () => Promise<unknown>;
type DiffLine = { kind: "same" | "added" | "removed"; text: string }; listSCUMPositions?: () => Promise<unknown>;
listSCUMOperations?: () => Promise<unknown>;
createSCUMOperation?: (request: unknown) => Promise<unknown>;
listSCUMWorkflows?: () => Promise<unknown>;
createSCUMWorkflow?: (request: unknown) => Promise<unknown>;
listSCUMWorkflowSteps?: (workflowId?: string) => Promise<unknown>;
};
type RecordMap = Record<string, unknown>;
type DataState = { status: "loading" } | { status: "error"; reason: string } | { status: "ready"; data: SCUMSurfaceData };
type ActionState = { status: "idle" | "pending" | "ok" | "error"; message?: string };
type SCUMSurfaceData = { players: RecordMap[]; squads: RecordMap[]; members: RecordMap[]; vehicles: RecordMap[]; flags: RecordMap[]; positions: RecordMap[]; operations: RecordMap[]; workflows: RecordMap[]; steps: RecordMap[] };
const emptyData: SCUMSurfaceData = { players: [], squads: [], members: [], vehicles: [], flags: [], positions: [], operations: [], workflows: [], steps: [] };
export function renderSCUMFeaturePage(react: ReactLike, input: SCUMPageContext) { export function renderSCUMFeaturePage(react: ReactLike, input: SCUMPageContext) {
const e = react.createElement; const e = react.createElement;
if (input.pageKey && input.pageKey !== "files-config") return renderSCUMFeatureSurface(e, input); const [state, setState] = usePluginState<DataState>(react, { status: "loading" });
const workspace = normalizeWorkspace(input.workspace); const [action, setAction] = usePluginState<ActionState>(react, { status: "idle" });
const [selectedDirectoryKey, setSelectedDirectoryKey] = usePluginState(react, workspace.defaultDirectoryKey); const pageKey = input.pageKey ?? "players";
const effectiveDirectoryKey = workspace.directories.some((directory) => directory.key === selectedDirectoryKey) ? selectedDirectoryKey : workspace.defaultDirectoryKey;
const directoryFiles = workspace.files.filter((file) => file.directoryKey === effectiveDirectoryKey);
const defaultFileKey = initialFileKey(workspace, effectiveDirectoryKey);
const [selectedFileKey, setSelectedFileKey] = usePluginState(react, defaultFileKey);
const selectedFile = directoryFiles.find((file) => file.key === selectedFileKey) ?? directoryFiles[0] ?? workspace.files[0];
const selectedFields = selectedFile ? workspace.configFields.filter((field) => field.fileKey === selectedFile.key) : [];
const [configMode, setConfigMode] = usePluginState<ConfigMode>(react, "fields");
const [fieldDraft, setFieldDraft] = usePluginState<Record<string, string>>(react, {});
const [pairDraft, setPairDraft] = usePluginState<Record<string, string>>(react, {});
const [rawDraft, setRawDraft] = usePluginState<Record<string, string>>(react, {});
const [rawEncoding, setRawEncoding] = usePluginState<RawEncoding>(react, "utf-8");
const [snapshot, setSnapshot] = usePluginState<SCUMFileReadSnapshot | null>(react, null);
const [requestState, setRequestState] = usePluginState<FileRequestState>(react, null);
const [workspaceRefreshState, setWorkspaceRefreshState] = usePluginState<WorkspaceRefreshState>(react, null);
const [writeState, setWriteState] = usePluginState<FileRequestState>(react, null);
const [preview, setPreview] = usePluginState<PreviewState>(react, null);
const scoped = Boolean(input.serverInstanceId);
const canFilesRead = scoped && input.permissions.includes("server.files.read");
const canFilesWrite = scoped && input.permissions.includes("server.files.write");
if (react.useEffect) { const refresh = () => {
react.useEffect(() => { const actions = input.workspaceActions;
let active = true; if (!input.serverInstanceId || !actions) {
setPreview(null); setState({ status: "error", reason: "插件页面没有绑定服务器,无法读取 SCUM 投影。" });
setWriteState(null); return;
if (!selectedFile || !input.workspaceActions?.getFileSnapshot) {
setSnapshot(null);
return () => { active = false; };
}
void input.workspaceActions.getFileSnapshot(selectedFile.key).then((next) => {
if (active) setSnapshot(next);
}).catch((error) => {
if (active) setSnapshot({ serverInstanceId: input.serverInstanceId ?? "", pluginId: "game.scum", key: selectedFile.key, state: "unavailable", reason: error instanceof Error ? error.message : "无法读取文件快照。" });
});
return () => { active = false; };
}, [input.serverInstanceId, input.workspaceActions?.getFileSnapshot, selectedFile?.key]);
} }
setState({ status: "loading" });
void Promise.all([
safeList(actions.listSCUMPlayers), safeList(actions.listSCUMSquads), safeList(actions.listSCUMSquadMembers), safeList(actions.listSCUMVehicles),
safeList(actions.listSCUMFlags), safeList(actions.listSCUMPositions), safeList(actions.listSCUMOperations), safeList(actions.listSCUMWorkflows), safeList(actions.listSCUMWorkflowSteps)
]).then(([players, squads, members, vehicles, flags, positions, operations, workflows, steps]) => setState({ status: "ready", data: { players, squads, members, vehicles, flags, positions, operations, workflows, steps } }))
.catch((error) => setState({ status: "error", reason: error instanceof Error ? error.message : "SCUM 投影读取失败。" }));
};
function refreshWorkspaceCatalog() { if (react.useEffect) react.useEffect(() => { refresh(); return undefined; }, [input.serverInstanceId, pageKey, input.workspaceActions]);
if (!input.workspaceActions?.refreshWorkspace) return;
setWorkspaceRefreshState({ status: "pending", message: "正在从平台刷新声明文件列表..." });
void input.workspaceActions.refreshWorkspace().then((next) => {
const count = next?.files?.length ?? 0;
setWorkspaceRefreshState({ status: "ok", message: `文件列表已刷新:${count} 个声明文件。` });
}).catch((error) => {
setWorkspaceRefreshState({ status: "error", message: error instanceof Error ? error.message : "文件列表刷新失败。" });
});
}
function loadFileSnapshot(fileKey: string, poll = false, attempt = 0) { const data = state.status === "ready" ? state.data : emptyData;
if (!input.workspaceActions?.getFileSnapshot) return; return e("section", { className: "console-panel", "aria-label": input.pageTitle ?? surfaceTitle(pageKey) },
void input.workspaceActions.getFileSnapshot(fileKey).then((next) => {
setSnapshot(next);
if (poll && next.state !== "ready" && attempt < 8) {
globalThis.setTimeout(() => loadFileSnapshot(fileKey, true, attempt + 1), Math.min(3200, 600 + attempt * 350));
}
}).catch((error) => {
setSnapshot({ serverInstanceId: input.serverInstanceId ?? "", pluginId: "game.scum", key: fileKey, state: "unavailable", reason: error instanceof Error ? error.message : "无法读取文件快照。" });
});
}
function selectDirectory(directoryKey: string) {
const nextFiles = workspace.files.filter((file) => file.directoryKey === directoryKey);
const nextFile = nextFiles.find((file) => file.kind === "config") ?? nextFiles[0];
setSelectedDirectoryKey(directoryKey);
if (nextFile) selectFile(nextFile, directoryKey);
}
function selectFile(file: SCUMLogicalFile, directoryKey = file.directoryKey) {
setSelectedDirectoryKey(directoryKey);
setSelectedFileKey(file.key);
setConfigMode(file.kind === "config" && workspace.configFields.some((field) => field.fileKey === file.key) ? "fields" : "source");
setRequestState(null);
setWriteState(null);
setPreview(null);
setRawEncoding("utf-8");
}
function requestSelectedFile() {
if (!selectedFile || !canFilesRead || !input.workspaceActions?.requestFile) return;
setRequestState({ fileKey: selectedFile.key, status: "pending", message: "正在提交文件读取请求..." });
setPreview(null);
void input.workspaceActions.requestFile(selectedFile.key).then((result) => {
setRequestState({ fileKey: selectedFile.key, status: result.status, message: result.message, jobId: result.jobId });
if (result.status === "queued" || result.status === "ok") {
setSnapshot({ serverInstanceId: input.serverInstanceId ?? "", pluginId: "game.scum", key: selectedFile.key, state: "pending", jobId: result.jobId, reason: "等待运行端完成文件读取。" });
loadFileSnapshot(selectedFile.key, true);
}
}).catch((error) => setRequestState({ fileKey: selectedFile.key, status: "error", message: error instanceof Error ? error.message : "文件读取请求失败。" }));
}
function refreshSelectedSnapshot() {
if (!selectedFile || !input.workspaceActions?.getFileSnapshot) return;
setPreview(null);
loadFileSnapshot(selectedFile.key);
}
function previewModeChange(mode: ConfigMode, content: string, current: string) {
if (!selectedFile) return;
const diff = buildSimpleDiff(current, content);
setPreview({ fileKey: selectedFile.key, mode, proposedContent: content, summary: diff.summary, lines: diff.lines });
}
function writePreviewedContent() {
if (!selectedFile || !preview || preview.fileKey !== selectedFile.key || !input.workspaceActions?.writeFile) return;
setWriteState({ fileKey: selectedFile.key, status: "pending", message: "正在提交声明文件写入..." });
void input.workspaceActions.writeFile(selectedFile.key, preview.proposedContent, { expectedChecksum: snapshot?.checksum }).then((result) => {
setWriteState({ fileKey: selectedFile.key, status: result.status, message: result.message, jobId: result.jobId });
setPreview(null);
}).catch((error) => setWriteState({ fileKey: selectedFile.key, status: "error", message: error instanceof Error ? error.message : "文件写入请求失败。" }));
}
return e("section", { className: "console-panel", "aria-label": "SCUM 文件管理" },
e("div", { className: "panel-header" }, e("div", { className: "panel-header" },
e("div", null, e("h2", null, "文件管理"), e("p", { className: "provider-id" }, "第一级选择目录,第二级选择目录内文件;配置默认表单,日志只读原文。")), e("div", null, e("h2", null, input.pageTitle ?? surfaceTitle(pageKey)), e("p", { className: "provider-id" }, surfaceSummary(pageKey))),
e("span", { className: "page-status" }, scoped ? "声明文件工作区" : "插件页面未绑定服务器")
),
e("div", { className: "file-workbench" },
navigationPane(e, workspace, effectiveDirectoryKey, directoryFiles, selectedFile?.key, workspaceRefreshState, Boolean(input.workspaceActions?.refreshWorkspace), selectDirectory, selectFile, refreshWorkspaceCatalog),
selectedFile
? fileDetail(e, {
file: selectedFile,
fields: selectedFields,
mode: selectedFile.kind === "log" ? "source" : configMode,
setMode: setConfigMode,
fieldDraft,
setFieldDraft,
pairDraft,
setPairDraft,
rawDraft,
setRawDraft,
rawEncoding,
setRawEncoding,
snapshot: snapshot?.key === selectedFile.key ? snapshot : null,
requestState: requestState?.fileKey === selectedFile.key ? requestState : null,
writeState: writeState?.fileKey === selectedFile.key ? writeState : null,
preview: preview?.fileKey === selectedFile.key ? preview : null,
canFilesRead,
canFilesWrite,
canRequestFile: Boolean(input.workspaceActions?.requestFile),
canRefreshSnapshot: Boolean(input.workspaceActions?.getFileSnapshot),
canWriteFile: Boolean(input.workspaceActions?.writeFile),
onRequestFile: requestSelectedFile,
onRefreshSnapshot: refreshSelectedSnapshot,
onPreview: previewModeChange,
onWrite: writePreviewedContent
})
: e("div", { className: "file-workbench-detail" }, e("p", { className: "page-status" }, "当前插件没有可展示的声明文件。"))
)
);
}
function renderSCUMFeatureSurface(e: ReactLike["createElement"], input: SCUMPageContext) {
const meta = scumSurfaceMeta(input.pageKey ?? "");
const featureRows = (input.featureAvailability ?? [])
.filter((feature) => meta.features.includes(feature.key))
.map((feature) => e("div", { key: feature.key, className: "console-row" }, e("span", null, feature.key), e("strong", null, feature.available ? "可用" : feature.reason ?? "等待 Companion")));
return e("section", { className: "console-panel", "aria-label": input.pageTitle ?? meta.title },
e("div", { className: "panel-header" },
e("div", null, e("h2", null, input.pageTitle ?? meta.title), e("p", { className: "provider-id" }, meta.summary)),
e("span", { className: "page-status" }, input.availability.available ? "Companion 在线" : input.availability.reason ?? "等待 Companion")
),
e("div", { className: "console-row-list" },
e("div", { className: "console-row" }, e("span", null, "服务器"), e("strong", null, input.serverInstanceId ?? "未绑定")),
e("div", { className: "console-row" }, e("span", null, "权限"), e("strong", null, input.permissions.join(" / ") || "未声明")),
featureRows.length ? featureRows : e("div", { className: "console-row" }, e("span", null, "插件能力"), e("strong", null, meta.features.join(" / ") || "由插件声明"))
)
);
}
function scumSurfaceMeta(pageKey: string): { title: string; summary: string; features: string[] } {
switch (pageKey) {
case "players": return { title: "用户管理", summary: "玩家查询、在线状态、维护窗口和状态修正由 SCUM 插件 Companion 提供。", features: ["player.intelligence", "state.patch"] };
case "squads": return { title: "队伍管理", summary: "队伍列表、成员关系和风险上下文来自插件声明的 squads 快照。", features: ["player.intelligence"] };
case "live-map": return { title: "实时地图", summary: "玩家、载具和轨迹采样由插件事件流驱动。", features: ["trajectory.collect", "vehicle.spawn"] };
case "gifts": return { title: "礼包管理", summary: "礼包目录、发放和玩家通知通过受保护插件命令执行。", features: ["reward.delivery"] };
default: return { title: "SCUM 插件页面", summary: "该页面由 SCUM 插件声明。", features: [] };
}
}
function navigationPane(e: ReactLike["createElement"], workspace: NormalizedWorkspace, directoryKey: string, directoryFiles: readonly SCUMLogicalFile[], selectedFileKey: string | undefined, refreshState: WorkspaceRefreshState, canRefreshWorkspace: boolean, onDirectoryChange: (directoryKey: string) => void, onFileSelect: (file: SCUMLogicalFile) => void, onRefreshWorkspace: () => void) {
const activeDirectory = workspace.directories.find((directory) => directory.key === directoryKey);
const selectedFile = directoryFiles.find((file) => file.key === selectedFileKey) ?? directoryFiles[0];
return e("aside", { className: "file-workbench-nav", "aria-label": "SCUM 文件两级菜单" },
e("div", { className: "console-row-actions file-workbench-nav-actions" },
e("button", { type: "button", className: "icon-command", disabled: !canRefreshWorkspace, onClick: onRefreshWorkspace }, "刷新文件列表")
),
refreshState ? e("p", { className: "page-status", "data-state": refreshState.status }, refreshState.message) : null,
e("label", { className: "file-workbench-picker" },
e("span", null, "目录"),
e("select", { value: directoryKey, onChange: (event: { target: { value: string } }) => onDirectoryChange(event.target.value), "aria-label": "选择目录" },
workspace.directories.length ? workspace.directories.map((directory) => e("option", { key: directory.key, value: directory.key }, `${directory.label} · ${directoryScopeLabel(directory.scope)}`)) : e("option", { value: "" }, "没有已声明目录")
)
),
e("div", { className: "file-workbench-directory-heading" },
e("strong", null, activeDirectory?.label ?? "插件声明目录"),
e("span", null, activeDirectory ? `${activeDirectory.key} · ${directoryScopeLabel(activeDirectory.scope)}` : "等待平台声明"),
e("small", null, `${directoryFiles.length} 个声明文件`)
),
e("div", { className: "file-workbench-directory", role: "listbox", "aria-label": "声明文件列表" },
directoryFiles.length
? directoryFiles.map((file) => e("button", { key: file.key, type: "button", className: "file-workbench-file", role: "option", "aria-selected": file.key === selectedFile?.key, "aria-current": file.key === selectedFile?.key ? "page" : undefined, onClick: () => onFileSelect(file) },
e("strong", null, file.label),
e("span", null, file.kind === "config" ? file.editable === false ? "配置只读" : "配置可写" : `日志${file.streamKey ? ` · ${file.streamKey}` : ""}`)
))
: e("p", { className: "page-status" }, "当前目录没有平台声明文件。")
)
);
}
function fileDetail(e: ReactLike["createElement"], props: {
file: SCUMLogicalFile; fields: readonly SCUMConfigField[]; mode: ConfigMode; setMode: StateSetter<ConfigMode>; fieldDraft: Record<string, string>; setFieldDraft: StateSetter<Record<string, string>>; pairDraft: Record<string, string>; setPairDraft: StateSetter<Record<string, string>>;
rawDraft: Record<string, string>; setRawDraft: StateSetter<Record<string, string>>; rawEncoding: RawEncoding; setRawEncoding: StateSetter<RawEncoding>; snapshot: SCUMFileReadSnapshot | null;
requestState: FileRequestState; writeState: FileRequestState; preview: PreviewState; canFilesRead: boolean; canFilesWrite: boolean; canRequestFile: boolean; canRefreshSnapshot: boolean; canWriteFile: boolean;
onRequestFile: () => void; onRefreshSnapshot: () => void; onPreview: (mode: ConfigMode, content: string, current: string) => void; onWrite: () => void;
}) {
const canShowFields = props.file.kind === "config" && props.fields.length > 0;
const currentContent = props.snapshot?.state === "ready" ? decodeRawContent(props.snapshot.content ?? "", props.rawEncoding) : "";
const snapshotValues = props.snapshot?.state === "ready" ? parseIniAssignments(currentContent) : {};
return e("article", { className: "file-workbench-detail", "aria-label": `文件 ${props.file.label}` },
e("div", { className: "panel-header" },
e("div", null, e("h2", null, props.file.label), e("p", { className: "provider-id" }, `${props.file.directoryKey} · ${props.file.kind === "config" ? "配置文件" : "日志文件"}${props.file.streamKey ? ` · ${props.file.streamKey}` : ""}`)),
e("div", { className: "console-row-actions" }, e("div", { className: "console-row-actions" },
e("button", { type: "button", className: "icon-command", disabled: !props.canFilesRead || !props.canRequestFile, onClick: props.onRequestFile }, "读取文件"), e("span", { className: "page-status" }, input.availability.available ? "投影/Companion 可用" : input.availability.reason ?? "等待 Run/Companion"),
e("button", { type: "button", className: "icon-command", disabled: !props.canRefreshSnapshot, onClick: props.onRefreshSnapshot }, "刷新结果") e("button", { type: "button", className: "icon-command", onClick: refresh }, "刷新投影"),
workflowButton(e, input, setAction, refresh, pageWorkflow(pageKey))
) )
), ),
props.requestState ? e("p", { className: "page-status", "data-state": props.requestState.status }, props.requestState.message) : null, action.status !== "idle" ? e("p", { className: "page-status", "data-state": action.status }, action.message) : null,
props.writeState ? e("p", { className: "page-status", "data-state": props.writeState.status }, props.writeState.message) : null, state.status === "loading" ? e("p", { className: "page-status" }, "正在读取平台本地 SCUM 投影…") : null,
props.file.kind === "log" || props.snapshot?.state === "ready" ? encodingSwitcher(e, props.rawEncoding, props.setRawEncoding) : null, state.status === "error" ? e("p", { className: "page-status", "data-state": "error" }, state.reason) : null,
canShowFields state.status === "ready" ? renderSurfaceBody(e, pageKey, data, input, setAction, refresh) : null
? e("div", { className: "file-workbench-mode", role: "tablist", "aria-label": "表单配置与原文模式" }, );
e("span", null, "编辑视图"), }
e("button", { type: "button", role: "tab", "aria-selected": props.mode === "fields", className: props.mode === "fields" ? "file-workbench-mode-active" : undefined, onClick: () => props.setMode("fields") }, "配置表单"),
e("button", { type: "button", role: "tab", "aria-selected": props.mode === "pairs", className: props.mode === "pairs" ? "file-workbench-mode-active" : undefined, onClick: () => props.setMode("pairs") }, "键值视图"), function renderSurfaceBody(e: ReactLike["createElement"], pageKey: string, data: SCUMSurfaceData, input: SCUMPageContext, setAction: StateSetter<ActionState>, refresh: () => void) {
e("button", { type: "button", role: "tab", "aria-selected": props.mode === "source", className: props.mode === "source" ? "file-workbench-mode-active" : undefined, onClick: () => props.setMode("source") }, "原文模式") switch (pageKey) {
case "players": return playersSurface(e, data, input, setAction, refresh);
case "squads": return squadsSurface(e, data);
case "live-map": return mapSurface(e, data);
case "gifts": return giftsSurface(e, data, input, setAction, refresh);
case "workflows": return workflowsSurface(e, data);
default: return playersSurface(e, data, input, setAction, refresh);
}
}
function playersSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, input: SCUMPageContext, setAction: StateSetter<ActionState>, refresh: () => void) {
return e("div", { className: "console-record-list" },
statsStrip(e, [["玩家投影", data.players.length], ["在线", data.players.filter((p) => boolField(p, "Online", "online")).length], ["坐标", data.positions.length], ["待审操作", data.operations.filter((op) => field(op, "Status", "status") === "waiting").length]]),
data.players.length ? data.players.slice(0, 80).map((player) => e("article", { key: idOf(player), className: "console-record" },
e("div", { className: "console-record-head" }, e("strong", null, textField(player, "DisplayName", "displayName") || textField(player, "GamePlayerID", "gamePlayerId") || "未知玩家"), e("span", { className: `status-pill ${boolField(player, "Online", "online") ? "status-active" : "status-disabled"}` }, boolField(player, "Online", "online") ? "在线" : "离线/未知")),
e("div", { className: "console-record-meta" }, e("span", null, `Steam ${textField(player, "SteamID", "steamId") || "unknown"}`), e("span", null, `Profile ${textField(player, "UserProfileID", "userProfileId") || "unknown"}`), e("span", null, `队伍 ${textField(player, "SquadName", "squadName") || textField(player, "SquadID", "squadId") || "unknown"}`), e("span", null, freshness(player))),
e("span", { className: "provider-id" }, `Fame ${numField(player, "FamePoints", "famePoints")} · Cash ${numField(player, "NormalBalance", "normalBalance")} · Gold ${numField(player, "GoldBalance", "goldBalance")} · ${coords(field(player, "Position", "position") as RecordMap | undefined)}`),
e("div", { className: "console-row-actions" },
operationButton(e, input, setAction, refresh, player, "player.fame.set", "fame", "Fame +100", 100),
operationButton(e, input, setAction, refresh, player, "player.currency.normal.set", "amount", "现金 +1000", 1000),
operationButton(e, input, setAction, refresh, player, "player.attribute.855.set", "after", "855 审批", Number(numField(player, "855", "855")) || 1, true)
) )
: null, )) : e("p", { className: "page-status" }, "暂无玩家投影。先运行 player/world refresh workflow;不会显示假玩家。")
canShowFields && props.mode === "fields"
? modeledConfigurationFields(e, props.file, props.fields, props.fieldDraft, props.setFieldDraft, snapshotValues, currentContent, props.snapshot?.state === "ready", props.canFilesWrite, props.canWriteFile, props.preview, props.onPreview, props.onWrite)
: canShowFields && props.mode === "pairs"
? modeledKeyValueEditor(e, props.file, props.fields, props.pairDraft, props.setPairDraft, snapshotValues, currentContent, props.snapshot?.state === "ready", props.canFilesWrite, props.canWriteFile, props.preview, props.onPreview, props.onWrite)
: rawFileView(e, props.file, currentContent, props.rawDraft, props.setRawDraft, props.snapshot, props.canFilesWrite, props.canWriteFile, props.preview, props.onPreview, props.onWrite)
); );
} }
function encodingSwitcher(e: ReactLike["createElement"], encoding: RawEncoding, setEncoding: StateSetter<RawEncoding>) { function squadsSurface(e: ReactLike["createElement"], data: SCUMSurfaceData) {
return e("div", { className: "file-workbench-mode file-workbench-encoding", role: "tablist", "aria-label": "原文编码" }, return e("div", { className: "overview-two-col" },
e("span", null, "文本编码"), tablePanel(e, "队伍", data.squads, (squad) => [textField(squad, "Name", "name") || textField(squad, "SquadID", "squadId"), `成员 ${numField(squad, "MemberCount", "memberCount")}`, `队长 ${textField(squad, "LeaderProfileID", "leaderProfileId") || "unknown"}`, freshness(squad)]),
e("button", { type: "button", role: "tab", "aria-selected": encoding === "utf-8", className: encoding === "utf-8" ? "file-workbench-mode-active" : undefined, onClick: () => setEncoding("utf-8") }, "UTF-8"), tablePanel(e, "成员 / 旗帜", [...data.members.slice(0, 40), ...data.flags.slice(0, 40)], (item) => [textField(item, "DisplayName", "displayName") || textField(item, "FlagID", "flagId") || "unknown", textField(item, "Rank", "rank") || textField(item, "OwnershipConfidence", "ownershipConfidence") || "unknown", textField(item, "SquadID", "squadId") || textField(item, "OwnerSquadID", "ownerSquadId") || "unknown", freshness(item)])
e("button", { type: "button", role: "tab", "aria-selected": encoding === "utf-16le", className: encoding === "utf-16le" ? "file-workbench-mode-active" : undefined, onClick: () => setEncoding("utf-16le") }, "UTF-16 LE")
); );
} }
function modeledConfigurationFields(e: ReactLike["createElement"], file: SCUMLogicalFile, fields: readonly SCUMConfigField[], draft: Record<string, string>, setDraft: StateSetter<Record<string, string>>, values: Record<string, string>, currentContent: string, hasReadSnapshot: boolean, canFilesWrite: boolean, canWriteFile: boolean, preview: PreviewState, onPreview: (mode: ConfigMode, content: string, current: string) => void, onWrite: () => void) { function mapSurface(e: ReactLike["createElement"], data: SCUMSurfaceData) {
const editable = hasReadSnapshot && canFilesWrite && file.editable !== false; const overlays = [...data.positions, ...data.vehicles.map((v) => field(v, "Position", "position") as RecordMap).filter(Boolean), ...data.flags.map((f) => field(f, "Position", "position") as RecordMap).filter(Boolean)];
const fieldKeys = new Set(fields.map((field) => field.key)); return e("div", { className: "console-record-list" },
const selectedDraft = Object.fromEntries(Object.entries(draft).filter(([key]) => fieldKeys.has(key))); statsStrip(e, [["玩家", data.players.length], ["载具", data.vehicles.length], ["旗帜", data.flags.length], ["坐标点", overlays.length]]),
const hasChanges = Object.keys(selectedDraft).length > 0; e("div", { className: "map-projection-board" }, overlays.slice(0, 120).map((point, index) => e("span", { key: `${idOf(point)}:${index}`, className: "map-projection-dot", title: `${textField(point, "SubjectType", "subjectType") || "point"} ${coords(point)}`, style: dotStyle(point) }, ""))),
const unknown = unknownIniAssignments(currentContent, fields); tablePanel(e, "地图覆盖物", overlays, (point) => [textField(point, "SubjectType", "subjectType") || "unknown", textField(point, "SubjectID", "subjectId") || textField(point, "GamePlayerID", "gamePlayerId") || textField(point, "VehicleID", "vehicleId") || "unknown", coords(point), freshness(point)])
return e("div", { className: "file-workbench-fields" },
fields.map((field) => {
const value = draft[field.key] ?? values[field.configKey] ?? field.defaultValue;
return e("label", { className: "file-workbench-field", key: field.key },
e("span", null, e("strong", null, field.label), e("small", null, `${field.configKey} · ${field.description}`)),
fieldControl(e, field, value, editable, (next) => setDraft((current) => ({ ...current, [field.key]: next }))),
e("small", null, `${controlLabel(field)} · ${field.restartImpact === "restart-required" ? "修改后需要受控重启" : "可在安全窗口内生效"}`)
);
}),
unknown.length ? e("section", { className: "file-workbench-unknown", "aria-label": "未建模配置项" }, e("h3", null, "未建模配置项"), unknown.map((item) => e("div", { className: "console-row", key: `${item.key}:${item.index}` }, e("span", null, item.key), e("strong", null, item.value || "空值")))) : null,
e("div", { className: "console-row-actions file-workbench-actions" },
e("button", { type: "button", className: "icon-command", disabled: !editable || !hasChanges, onClick: () => onPreview("fields", composeIniContent(currentContent, fields, selectedDraft), currentContent) }, "预览改动"),
e("button", { type: "button", className: "primary-command", disabled: !editable || !canWriteFile || !preview || preview.mode !== "fields", onClick: onWrite }, "提交写入")
),
preview && preview.mode === "fields" ? diffPreview(e, preview) : null,
e("p", { className: "page-status" }, hasReadSnapshot ? editable ? "配置表单会保留原文中的未知行;提交前先预览差异。" : "当前账号仅有读取权限,配置项为只读。" : "先读取文件后才显示服务器当前值;未读取时不会把默认值伪装成原文。")
); );
} }
function modeledKeyValueEditor(e: ReactLike["createElement"], file: SCUMLogicalFile, fields: readonly SCUMConfigField[], draft: Record<string, string>, setDraft: StateSetter<Record<string, string>>, values: Record<string, string>, currentContent: string, hasReadSnapshot: boolean, canFilesWrite: boolean, canWriteFile: boolean, preview: PreviewState, onPreview: (mode: ConfigMode, content: string, current: string) => void, onWrite: () => void) { function giftsSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, input: SCUMPageContext, setAction: StateSetter<ActionState>, refresh: () => void) {
const editable = hasReadSnapshot && canFilesWrite && file.editable !== false; return e("div", { className: "console-record-list" },
const currentPairs = modeledPairText(fields, values); statsStrip(e, [["可选玩家", data.players.length], ["发放操作", data.operations.filter((op) => textField(op, "TemplateKey", "templateKey") === "reward.deliver").length], ["未知态", data.operations.filter((op) => field(op, "Status", "status") === "unknown").length]]),
const rawValue = draft[file.key] ?? currentPairs; e("p", { className: "page-status" }, "礼包只创建 typed delivery workflow;确认结果未知时不会重复发放。"),
const parsed = parseModeledPairDraft(rawValue, fields); data.players.slice(0, 40).map((player) => e("article", { key: idOf(player), className: "console-record" },
const proposedContent = composeIniContent(currentContent, fields, parsed.changes); e("div", { className: "console-record-head" }, e("strong", null, textField(player, "DisplayName", "displayName") || idOf(player)), e("span", { className: "status-pill status-disabled" }, freshness(player))),
const hasChanges = proposedContent !== currentContent && Object.keys(parsed.changes).length > 0; e("div", { className: "console-row-actions" }, operationButton(e, input, setAction, refresh, player, "reward.deliver", "rewardKey", "创建礼包发放", "starter-pack"), operationButton(e, input, setAction, refresh, player, "player.notify", "message", "发送通知", "你的礼包正在审核发放。"))
return e("div", { className: "file-workbench-raw file-workbench-pairs" }, ))
e("textarea", { className: "file-workbench-raw-editor file-workbench-keyvalue-editor", value: rawValue, readOnly: !editable, spellCheck: false, "aria-label": `${file.label} 键值视图编辑`, onChange: (event: { target: { value: string } }) => setDraft((current) => ({ ...current, [file.key]: event.target.value })) }),
parsed.unknown.length ? e("section", { className: "file-workbench-unknown", "aria-label": "不可写键值" }, e("h3", null, "不可写键值"), parsed.unknown.map((key) => e("div", { className: "console-row", key }, e("span", null, key), e("strong", null, "未在声明字段中")))) : null,
e("div", { className: "console-row-actions file-workbench-actions" },
e("button", { type: "button", className: "icon-command", disabled: !editable || parsed.unknown.length > 0 || !hasChanges, onClick: () => onPreview("pairs", proposedContent, currentContent) }, "预览改动"),
e("button", { type: "button", className: "primary-command", disabled: !editable || !canWriteFile || !preview || preview.mode !== "pairs", onClick: onWrite }, "提交写入")
),
preview && preview.mode === "pairs" ? diffPreview(e, preview) : null,
e("p", { className: "page-status" }, hasReadSnapshot ? "键值视图适合批量调整已声明字段;未知键只展示,不会被作为安全写入目标。" : "先读取文件后才启用键值编辑。")
); );
} }
function fieldControl(e: ReactLike["createElement"], field: SCUMConfigField, value: string, editable: boolean, onChange: (next: string) => void) { function workflowsSurface(e: ReactLike["createElement"], data: SCUMSurfaceData) {
if (field.control === "boolean") { return e("div", { className: "console-record-list" },
return e("select", { value: normalizeBoolean(value) ? "true" : "false", disabled: !editable, "aria-label": field.label, onChange: (event: { target: { value: string } }) => onChange(event.target.value) }, data.workflows.length ? data.workflows.map((wf) => e("article", { key: idOf(wf), className: "console-record" },
e("option", { value: "true" }, "是"), e("div", { className: "console-record-head" }, e("strong", null, textField(wf, "TemplateKey", "templateKey") || idOf(wf)), e("span", { className: "status-pill status-active" }, textField(wf, "Status", "status") || "queued")),
e("option", { value: "false" }, "否") e("div", { className: "console-record-meta" }, e("span", null, `当前步骤 ${textField(wf, "CurrentStepKey", "currentStepKey") || "等待调度"}`), e("span", null, `创建 ${dateField(wf, "CreatedAt", "createdAt")}`)),
); e("span", { className: "provider-id" }, summaryText(wf))
} )) : e("p", { className: "page-status" }, "暂无 workflow。可以从各页面发起 refresh/audit/correction/gift workflow。"),
if (field.control === "number" || field.control === "port") { tablePanel(e, "步骤", data.steps, (step) => [textField(step, "StepKey", "stepKey"), textField(step, "Status", "status"), textField(step, "Capability", "capability") || textField(step, "QueryTemplateKey", "queryTemplateKey") || textField(step, "OperationKey", "operationKey"), summaryText(step)])
return e("div", { className: "file-workbench-number-control" },
e("input", { type: "range", value, min: field.minimum, max: field.maximum, disabled: !editable, "aria-label": `${field.label}滑动输入`, onChange: (event: { target: { value: string } }) => onChange(event.target.value) }),
e("input", { type: "number", value, min: field.minimum, max: field.maximum, readOnly: !editable, "aria-label": field.label, onChange: (event: { target: { value: string } }) => onChange(event.target.value) })
);
}
return e("input", { type: "text", value, readOnly: !editable, "aria-label": field.label, onChange: (event: { target: { value: string } }) => onChange(event.target.value) });
}
function rawFileView(e: ReactLike["createElement"], file: SCUMLogicalFile, currentContent: string, rawDraft: Record<string, string>, setRawDraft: StateSetter<Record<string, string>>, snapshot: SCUMFileReadSnapshot | null, canFilesWrite: boolean, canWriteFile: boolean, preview: PreviewState, onPreview: (mode: ConfigMode, content: string, current: string) => void, onWrite: () => void) {
if (snapshot?.state !== "ready") {
const message = snapshot?.reason ?? (file.kind === "config" ? "尚未读取此配置文件的受控原文。" : "尚未读取此日志文件的受控内容。");
return e("div", { className: "file-workbench-raw" }, e("p", { className: "page-status" }, message), snapshot?.jobId ? e("small", null, `读取任务 ${snapshot.jobId}`) : null);
}
const rawValue = rawDraft[file.key] ?? currentContent;
const editable = file.kind === "config" && canFilesWrite && file.editable !== false;
if (file.kind === "log") {
return e("div", { className: "file-workbench-raw" }, e("pre", { className: "runtime-task-log" }, rawValue), e("p", { className: "page-status" }, `日志原文只读 · ${contentLineCount(rawValue)} 行 · ${snapshot.sizeBytes ?? 0} B`));
}
return e("div", { className: "file-workbench-raw" },
e("textarea", { className: "file-workbench-raw-editor", value: rawValue, readOnly: !editable, spellCheck: false, "aria-label": `${file.label} 原文模式编辑`, onChange: (event: { target: { value: string } }) => setRawDraft((current) => ({ ...current, [file.key]: event.target.value })) }),
e("div", { className: "console-row-actions file-workbench-actions" },
e("button", { type: "button", className: "icon-command", disabled: !editable || rawValue === currentContent, onClick: () => onPreview("source", rawValue, currentContent) }, "预览改动"),
e("button", { type: "button", className: "primary-command", disabled: !editable || !canWriteFile || !preview || preview.mode !== "source", onClick: onWrite }, "提交写入")
),
preview && preview.mode === "source" ? diffPreview(e, preview) : null,
e("p", { className: "page-status" }, editable ? "原文模式会整文件写入声明 file key;提交前请先预览差异。" : "原文配置当前只读。")
); );
} }
function diffPreview(e: ReactLike["createElement"], preview: Exclude<PreviewState, null>) { function workflowButton(e: ReactLike["createElement"], input: SCUMPageContext, setAction: StateSetter<ActionState>, refresh: () => void, templateKey: string) {
return e("section", { className: "file-workbench-diff", "aria-label": "文件改动预览" }, if (!templateKey) return null;
e("div", { className: "console-row" }, e("span", null, "差异预览"), e("strong", null, preview.summary)), return e("button", { type: "button", className: "primary-command", disabled: !input.workspaceActions?.createSCUMWorkflow, onClick: () => createWorkflow(input, setAction, refresh, templateKey) }, workflowLabel(templateKey));
e("pre", { className: "runtime-task-log" }, preview.lines.map((line) => `${line.kind === "added" ? "+" : line.kind === "removed" ? "-" : " "} ${line.text}`).join("\n"))
);
} }
function usePluginState<T>(react: ReactLike, initialState: T): [T, StateSetter<T>] { function operationButton(e: ReactLike["createElement"], input: SCUMPageContext, setAction: StateSetter<ActionState>, refresh: () => void, player: RecordMap, templateKey: string, valueKey: string, label: string, value: unknown, guarded = false) {
return react.useState ? react.useState(initialState) : [initialState, () => undefined]; return e("button", { type: "button", className: "icon-command", disabled: !input.workspaceActions?.createSCUMOperation, onClick: () => createOperation(input, setAction, refresh, player, templateKey, valueKey, value, guarded) }, label);
} }
function normalizeWorkspace(workspace?: SCUMFeatureWorkspace): NormalizedWorkspace { function createWorkflow(input: SCUMPageContext, setAction: StateSetter<ActionState>, refresh: () => void, templateKey: string) {
const directories = workspace?.directories ?? []; setAction({ status: "pending", message: `正在创建 ${templateKey} workflow…` });
const directoryKeys = new Set(directories.map((directory) => directory.key)); void input.workspaceActions?.createSCUMWorkflow?.({ templateKey, idempotencyKey: `plugin:${templateKey}:${input.serverInstanceId}:${Date.now()}` }).then((result) => {
const files = (workspace?.files ?? []).filter((file) => directoryKeys.has(file.directoryKey)); setAction({ status: "ok", message: `Workflow 已创建:${textField(result as RecordMap, "id") || templateKey}` }); refresh();
const fileKeys = new Set(files.map((file) => file.key)); }).catch((error) => setAction({ status: "error", message: error instanceof Error ? error.message : "Workflow 创建失败。" }));
const configFields = (workspace?.configFields ?? []).filter((field) => fileKeys.has(field.fileKey));
return { defaultDirectoryKey: workspace?.defaultDirectoryKey && directories.some((directory) => directory.key === workspace.defaultDirectoryKey) ? workspace.defaultDirectoryKey : directories[0]?.key ?? "", directories, files, configFields };
} }
function initialFileKey(workspace: NormalizedWorkspace, directoryKey: string): string { function createOperation(input: SCUMPageContext, setAction: StateSetter<ActionState>, refresh: () => void, player: RecordMap, templateKey: string, valueKey: string, value: unknown, guarded: boolean) {
return workspace.files.find((file) => file.directoryKey === directoryKey && file.kind === "config")?.key ?? workspace.files.find((file) => file.directoryKey === directoryKey)?.key ?? workspace.files[0]?.key ?? ""; const playerId = textField(player, "GamePlayerID", "gamePlayerId") || textField(player, "SteamID", "steamId");
const before = guarded ? field(field(player, "UnknownFields", "unknownFields") as RecordMap | undefined, "855") ?? 0 : undefined;
const payload: RecordMap = guarded ? { fieldKey: "855", before, after: value, safetyWindow: `plugin-maintenance-${Date.now()}`, backupRef: `backup-required:${Date.now()}` } : { [valueKey]: value };
setAction({ status: "pending", message: `正在创建 ${templateKey} typed operation…` });
void input.workspaceActions?.createSCUMOperation?.({ templateKey, playerId, payload, reason: "SCUM plugin projection surface request", idempotencyKey: `plugin:${templateKey}:${playerId}:${Date.now()}` }).then((result) => {
setAction({ status: "ok", message: `操作已进入审批/确认队列:${textField(result as RecordMap, "id") || templateKey}` }); refresh();
}).catch((error) => setAction({ status: "error", message: error instanceof Error ? error.message : "操作创建失败。" }));
} }
function directoryScopeLabel(scope: SCUMLogicalDirectory["scope"]): string { function pageWorkflow(pageKey: string): string {
return scope === "logs" ? "日志声明" : "配置声明"; switch (pageKey) {
case "players": return "scum.player-refresh";
case "squads": return "scum.territory-audit";
case "live-map": return "scum.world-refresh";
case "gifts": return "scum.gift-delivery";
case "workflows": return "scum.product-cleanup";
default: return "scum.bootstrap-real-data";
}
} }
function modeledPairText(fields: readonly SCUMConfigField[], values: Record<string, string>): string { function surfaceTitle(pageKey: string): string { return pageKey === "squads" ? "队伍/旗帜管理" : pageKey === "live-map" ? "实时地图" : pageKey === "gifts" ? "礼包管理" : pageKey === "workflows" ? "Workflow 状态" : "用户管理"; }
return fields.map((field) => `${field.configKey}=${values[field.configKey] ?? field.defaultValue}`).join("\n"); function surfaceSummary(pageKey: string): string { return pageKey === "live-map" ? "玩家、载具、旗帜坐标来自平台本地投影;缺失时显示 stale/unknown。" : pageKey === "gifts" ? "礼包发放、通知和确认都通过 typed workflow,不直接改投影。" : pageKey === "squads" ? "队伍、成员、旗帜所有权来自 SCUM.db typed observations。" : "玩家列表由登录日志和 SCUM.db typed observations 创建,不显示样例数据。"; }
} function workflowLabel(templateKey: string): string { return templateKey.includes("audit") ? "发起审计" : templateKey.includes("gift") ? "创建发放 workflow" : templateKey.includes("world") ? "刷新世界投影" : templateKey.includes("cleanup") ? "清理旧入口" : "刷新真实数据"; }
function parseModeledPairDraft(content: string, fields: readonly SCUMConfigField[]): { changes: Record<string, string>; unknown: string[] } { function statsStrip(e: ReactLike["createElement"], items: Array<[string, number]>) { return e("div", { className: "console-stat-strip" }, items.map(([label, value]) => e("span", { key: label, className: "server-card-stat" }, e("span", null, label), e("strong", null, String(value))))); }
const byConfigKey = new Map(fields.map((field) => [field.configKey, field])); function tablePanel(e: ReactLike["createElement"], title: string, rows: RecordMap[], render: (row: RecordMap) => unknown[]) { return e("article", { className: "console-module" }, e("div", { className: "panel-header" }, e("h2", null, title), e("span", { className: "page-status" }, `${rows.length}`)), e("div", { className: "console-row-list" }, rows.length ? rows.slice(0, 100).map((row) => e("div", { key: idOf(row), className: "console-row" }, render(row).map((part, i) => i === 0 ? e("span", { key: i }, String(part ?? "unknown")) : e("strong", { key: i }, String(part ?? "unknown"))))) : e("p", { className: "page-status" }, "暂无真实投影数据。"))); }
const changes: Record<string, string> = {}; function dotStyle(point: RecordMap): Record<string, string> { const x = Number(field(point, "X", "x") ?? 0); const y = Number(field(point, "Y", "y") ?? 0); return { left: `${Math.max(2, Math.min(98, 50 + x / 10000))}%`, top: `${Math.max(2, Math.min(98, 50 - y / 10000))}%` }; }
const unknown: string[] = []; function safeList(fn?: () => Promise<unknown>): Promise<RecordMap[]> { return fn ? fn().then((value) => Array.isArray((value as RecordMap)?.items) ? (value as { items: RecordMap[] }).items : []) : Promise.resolve([]); }
for (const line of content.split("\n")) { function usePluginState<T>(react: ReactLike, initial: T): [T, StateSetter<T>] { return react.useState ? react.useState<T>(initial) : [initial, () => undefined]; }
const parsed = parseIniAssignment(line); function field(row: RecordMap | undefined, ...keys: string[]): unknown { if (!row) return undefined; for (const key of keys) if (row[key] !== undefined) return row[key]; return undefined; }
if (!parsed) continue; function textField(row: RecordMap | unknown, ...keys: string[]): string { const value = field(row as RecordMap, ...keys); return value === undefined || value === null ? "" : String(value); }
const field = byConfigKey.get(parsed.key); function boolField(row: RecordMap, ...keys: string[]): boolean { const value = field(row, ...keys); return value === true || value === "true"; }
if (!field) { function numField(row: RecordMap, ...keys: string[]): string { const value = field(row, ...keys); return value === undefined || value === null || value === "" ? "--" : String(value); }
unknown.push(parsed.key); function idOf(row: RecordMap): string { return textField(row, "ID", "id", "GamePlayerID", "gamePlayerId", "SquadID", "squadId", "VehicleID", "vehicleId", "FlagID", "flagId", "StepKey", "stepKey") || Math.random().toString(36).slice(2); }
continue; function freshness(row: RecordMap): string { const fresh = field(row, "Freshness", "freshness") as RecordMap | undefined; return textField(fresh, "Status", "status") || "unknown"; }
} function coords(row?: RecordMap): string { if (!row) return "坐标 unknown"; const ok = field(row, "HasCoordinates", "hasCoordinates"); return ok === false ? "坐标 unknown" : `X ${numField(row, "X", "x")} / Y ${numField(row, "Y", "y")} / Z ${numField(row, "Z", "z")}`; }
changes[field.key] = parsed.value; function summaryText(row: RecordMap): string { const summary = field(row, "SafeSummary", "safeSummary") as RecordMap | undefined; return textField(summary, "Message", "message") || textField(row, "BlockerReason", "blockerReason") || "safe summary pending"; }
} function dateField(row: RecordMap, ...keys: string[]): string { const value = textField(row, ...keys); return value ? new Date(value).toLocaleString() : "unknown"; }
return { changes, unknown: [...new Set(unknown)] };
}
function controlLabel(field: SCUMConfigField): string {
const range = field.minimum !== undefined || field.maximum !== undefined ? `范围 ${field.minimum ?? "不限"}-${field.maximum ?? "不限"}` : "";
const label = field.control === "boolean" ? "是/否选择" : field.control === "number" || field.control === "port" ? "滑动输入" : "文本填空";
return [label, range].filter(Boolean).join(" · ");
}
function normalizeBoolean(value: string): boolean {
return /^(true|1|yes|on)$/i.test(String(value).trim());
}
function parseIniAssignments(content: string): Record<string, string> {
const values: Record<string, string> = {};
for (const line of content.split("\n")) {
const parsed = parseIniAssignment(line);
if (parsed) values[parsed.key] = parsed.value;
}
return values;
}
function unknownIniAssignments(content: string, fields: readonly SCUMConfigField[]): Array<{ key: string; value: string; index: number }> {
const known = new Set(fields.map((field) => field.configKey));
const unknown: Array<{ key: string; value: string; index: number }> = [];
content.split("\n").forEach((line, index) => {
const parsed = parseIniAssignment(line);
if (parsed && !known.has(parsed.key)) unknown.push({ ...parsed, index });
});
return unknown;
}
function parseIniAssignment(line: string): { key: string; value: string } | null {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#") || trimmed.startsWith(";") || trimmed.startsWith("[")) return null;
const separator = line.indexOf("=");
if (separator < 1) return null;
return { key: line.slice(0, separator).trim(), value: line.slice(separator + 1).trim() };
}
function composeIniContent(content: string, fields: readonly SCUMConfigField[], draft: Record<string, string>): string {
const byField = new Map(fields.map((field) => [field.key, field]));
const changes = new Map<string, string>();
for (const [fieldKey, value] of Object.entries(draft)) {
const field = byField.get(fieldKey);
if (field) changes.set(field.configKey, value);
}
if (changes.size === 0) return content;
const applied = new Set<string>();
const lines = content.split("\n").map((line) => {
const parsed = parseIniAssignment(line);
if (!parsed || !changes.has(parsed.key)) return line;
applied.add(parsed.key);
return `${line.slice(0, line.indexOf("=") + 1)}${changes.get(parsed.key) ?? ""}`;
});
for (const [key, value] of changes) {
if (!applied.has(key)) lines.push(`${key}=${value}`);
}
return content.endsWith("\n") ? lines.join("\n") : lines.join("\n").replace(/\n$/, "");
}
function decodeRawContent(content: string, encoding: RawEncoding): string {
if (encoding === "utf-8") return content;
const bytes = Uint8Array.from(Array.from(content), (char) => char.charCodeAt(0) & 0xff);
if (typeof TextDecoder !== "undefined") return new TextDecoder("utf-16le").decode(bytes);
let decoded = "";
for (let index = 0; index < bytes.length; index += 2) decoded += String.fromCharCode(bytes[index] | ((bytes[index + 1] ?? 0) << 8));
return decoded;
}
function buildSimpleDiff(current: string, proposed: string): { summary: string; lines: readonly DiffLine[] } {
const currentLines = current.split("\n");
const proposedLines = proposed.split("\n");
const max = Math.max(currentLines.length, proposedLines.length);
const lines: DiffLine[] = [];
let added = 0;
let removed = 0;
for (let index = 0; index < max; index += 1) {
const before = currentLines[index];
const after = proposedLines[index];
if (before === after) {
if (before !== undefined) lines.push({ kind: "same", text: before });
continue;
}
if (before !== undefined) { removed += 1; lines.push({ kind: "removed", text: before }); }
if (after !== undefined) { added += 1; lines.push({ kind: "added", text: after }); }
}
return { summary: `+${added} / -${removed} 行变更`, lines };
}
function contentLineCount(content: string): number {
return content ? content.split("\n").length : 0;
}
+596 -94
View File
@@ -21,10 +21,37 @@
], ],
"createFormSchema": "schemas/create-form.schema.json", "createFormSchema": "schemas/create-form.schema.json",
"createFields": [ "createFields": [
{ "key": "serverName", "label": "SCUM 服务器名称", "type": "text", "required": true, "configKey": "serverName" }, {
{ "key": "gamePort", "label": "游戏端口", "type": "port", "required": true, "defaultValue": "7779", "configKey": "gamePort" }, "key": "serverName",
{ "key": "queryPort", "label": "查询端口", "type": "port", "required": true, "defaultValue": "27015", "configKey": "queryPort" }, "label": "SCUM 服务器名称",
{ "key": "maxPlayers", "label": "最大玩家数", "type": "number", "required": true, "defaultValue": "128", "configKey": "maxPlayers" } "type": "text",
"required": true,
"configKey": "serverName"
},
{
"key": "gamePort",
"label": "游戏端口",
"type": "port",
"required": true,
"defaultValue": "7779",
"configKey": "gamePort"
},
{
"key": "queryPort",
"label": "查询端口",
"type": "port",
"required": true,
"defaultValue": "27015",
"configKey": "queryPort"
},
{
"key": "maxPlayers",
"label": "最大玩家数",
"type": "number",
"required": true,
"defaultValue": "128",
"configKey": "maxPlayers"
}
] ]
}, },
"capabilities": [ "capabilities": [
@@ -43,6 +70,7 @@
"remote.rsync.write", "remote.rsync.write",
"remote.run.files.read", "remote.run.files.read",
"remote.run.files.write", "remote.run.files.write",
"remote.run.db.sqlite.query",
"remote.run.process.start", "remote.run.process.start",
"remote.run.process.stop", "remote.run.process.stop",
"remote.run.logs.transfer", "remote.run.logs.transfer",
@@ -67,6 +95,7 @@
"runCapabilities": [ "runCapabilities": [
"remote.run.files.read", "remote.run.files.read",
"remote.run.files.write", "remote.run.files.write",
"remote.run.db.sqlite.query",
"remote.run.process.start", "remote.run.process.start",
"remote.run.process.stop", "remote.run.process.stop",
"remote.run.logs.transfer", "remote.run.logs.transfer",
@@ -74,28 +103,26 @@
"remote.run.protected.rcon", "remote.run.protected.rcon",
"remote.run.program.command" "remote.run.program.command"
], ],
"databaseEngines": [
"sqlite"
],
"logTransfer": true "logTransfer": true
}, },
"bridge": { "bridge": {
"actions": [ "actions": [
"server.instances.read", "server.instances.read",
"jobs.dispatch", "jobs.dispatch",
"logs.query",
"artifacts.open", "artifacts.open",
"files.request",
"remote.access.request", "remote.access.request",
"ai.invoke", "ai.invoke",
"run.distribution.request", "run.distribution.request",
"dependencies.request", "dependencies.request",
"logs.backfill.request",
"client-manager.request", "client-manager.request",
"plugin-lifecycle.request" "plugin-lifecycle.request"
] ]
}, },
"gameClientBridge": { "gameClientBridge": {
"commands": [ "commands": [
{ "type": "config.read", "title": "Read SCUM configuration", "permission": "server.game-client.read", "approvalLevel": "none", "payloadSchemaRef": "schemas/bridge/config-read.payload.schema.json", "resultSchemaRef": "schemas/bridge/config-read.result.schema.json", "timeoutSeconds": 30, "maxPayloadBytes": 1024 },
{ "type": "config.patch", "title": "Patch SCUM configuration", "permission": "server.game-client.maintenance", "approvalLevel": "platform-admin", "payloadSchemaRef": "schemas/bridge/config-patch.payload.schema.json", "resultSchemaRef": "schemas/bridge/config-patch.result.schema.json", "timeoutSeconds": 60, "maxPayloadBytes": 4096 },
{ {
"type": "announcement.send", "type": "announcement.send",
"title": "Send SCUM announcement", "title": "Send SCUM announcement",
@@ -195,39 +222,6 @@
"resultSchemaRef": "schemas/bridge/game-state-patch.result.schema.json", "resultSchemaRef": "schemas/bridge/game-state-patch.result.schema.json",
"timeoutSeconds": 120, "timeoutSeconds": 120,
"maxPayloadBytes": 4096 "maxPayloadBytes": 4096
},
{
"type": "database.request",
"title": "Execute approved SCUM database request",
"permission": "server.game-client.maintenance",
"approvalLevel": "platform-admin",
"payloadSchemaRef": "schemas/bridge/protected-request.payload.schema.json",
"resultSchemaRef": "schemas/bridge/protected-request.result.schema.json",
"timeoutSeconds": 120,
"maxPayloadBytes": 16384,
"protectedRequest": { "kind": "sql", "transportKey": "scum-database", "targetKey": "scum-database", "textField": "requestText", "maxTextBytes": 16384 }
},
{
"type": "management.rcon.request",
"title": "Execute approved SCUM management command",
"permission": "server.game-client.command",
"approvalLevel": "operator",
"payloadSchemaRef": "schemas/bridge/protected-request.payload.schema.json",
"resultSchemaRef": "schemas/bridge/protected-request.result.schema.json",
"timeoutSeconds": 120,
"maxPayloadBytes": 8192,
"protectedRequest": { "kind": "rcon", "transportKey": "scum-management", "targetKey": "scum-management", "textField": "requestText", "maxTextBytes": 8192 }
},
{
"type": "management.program.request",
"title": "Execute approved SCUM management program request",
"permission": "server.game-client.maintenance",
"approvalLevel": "platform-admin",
"payloadSchemaRef": "schemas/bridge/protected-request.payload.schema.json",
"resultSchemaRef": "schemas/bridge/protected-request.result.schema.json",
"timeoutSeconds": 120,
"maxPayloadBytes": 8192,
"protectedRequest": { "kind": "program", "transportKey": "scum-program", "targetKey": "scum-program", "textField": "requestText", "maxTextBytes": 8192 }
} }
], ],
"snapshots": [ "snapshots": [
@@ -288,34 +282,346 @@
"maxRecords": 1000 "maxRecords": 1000
} }
], ],
"queryTemplates": [
{
"key": "scum.player.profile",
"title": "Read SCUM player profile, economy, squad, and position facts",
"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": 500,
"timeoutSeconds": 15
},
{
"key": "scum.squads",
"title": "Read SCUM squad records",
"permission": "server.game-client.read",
"engine": "sqlite",
"transportKey": "scum-database",
"targetKey": "scum-database",
"parameterSchemaRef": "schemas/bridge/queries/scum-squads.parameters.schema.json",
"resultSchemaRef": "schemas/bridge/queries/scum-squads.result.schema.json",
"maxRows": 500,
"timeoutSeconds": 15
},
{
"key": "scum.squad-members",
"title": "Read SCUM squad membership records",
"permission": "server.game-client.read",
"engine": "sqlite",
"transportKey": "scum-database",
"targetKey": "scum-database",
"parameterSchemaRef": "schemas/bridge/queries/scum-squad-members.parameters.schema.json",
"resultSchemaRef": "schemas/bridge/queries/scum-squad-members.result.schema.json",
"maxRows": 500,
"timeoutSeconds": 15
},
{
"key": "scum.vehicles",
"title": "Read SCUM vehicle records and coordinates",
"permission": "server.game-client.read",
"engine": "sqlite",
"transportKey": "scum-database",
"targetKey": "scum-database",
"parameterSchemaRef": "schemas/bridge/queries/scum-vehicles.parameters.schema.json",
"resultSchemaRef": "schemas/bridge/queries/scum-vehicles.result.schema.json",
"maxRows": 500,
"timeoutSeconds": 15
},
{
"key": "scum.flags",
"title": "Read SCUM flag and ownership records",
"permission": "server.game-client.read",
"engine": "sqlite",
"transportKey": "scum-database",
"targetKey": "scum-database",
"parameterSchemaRef": "schemas/bridge/queries/scum-flags.parameters.schema.json",
"resultSchemaRef": "schemas/bridge/queries/scum-flags.result.schema.json",
"maxRows": 500,
"timeoutSeconds": 15
},
{
"key": "scum.positions",
"title": "Read SCUM current player, vehicle, and flag coordinates",
"permission": "server.game-client.read",
"engine": "sqlite",
"transportKey": "scum-database",
"targetKey": "scum-database",
"parameterSchemaRef": "schemas/bridge/queries/scum-positions.parameters.schema.json",
"resultSchemaRef": "schemas/bridge/queries/scum-positions.result.schema.json",
"maxRows": 500,
"timeoutSeconds": 15
}
],
"operationTemplates": [
{
"key": "player.fame.set",
"title": "Set SCUM player fame through RCON",
"permission": "server.game-client.command",
"approvalLevel": "operator",
"kind": "rcon",
"transportKey": "scum-management",
"targetKey": "scum-management",
"payloadSchemaRef": "schemas/bridge/player-fame-set.payload.schema.json",
"resultSchemaRef": "schemas/bridge/player-rcon-set.result.schema.json",
"confirmationSchemaRef": "schemas/bridge/player-fame-set.confirmation.schema.json",
"timeoutSeconds": 60,
"maxPayloadBytes": 2048,
"safety": {
"requiresApproval": true,
"requiresConfirmation": true
}
},
{
"key": "player.currency.normal.set",
"title": "Set SCUM normal currency through RCON",
"permission": "server.game-client.command",
"approvalLevel": "operator",
"kind": "rcon",
"transportKey": "scum-management",
"targetKey": "scum-management",
"payloadSchemaRef": "schemas/bridge/player-currency-set.payload.schema.json",
"resultSchemaRef": "schemas/bridge/player-rcon-set.result.schema.json",
"confirmationSchemaRef": "schemas/bridge/player-currency-set.confirmation.schema.json",
"timeoutSeconds": 60,
"maxPayloadBytes": 2048,
"safety": {
"requiresApproval": true,
"requiresConfirmation": true
}
},
{
"key": "player.currency.gold.set",
"title": "Set SCUM gold currency through RCON",
"permission": "server.game-client.command",
"approvalLevel": "operator",
"kind": "rcon",
"transportKey": "scum-management",
"targetKey": "scum-management",
"payloadSchemaRef": "schemas/bridge/player-currency-set.payload.schema.json",
"resultSchemaRef": "schemas/bridge/player-rcon-set.result.schema.json",
"confirmationSchemaRef": "schemas/bridge/player-currency-set.confirmation.schema.json",
"timeoutSeconds": 60,
"maxPayloadBytes": 2048,
"safety": {
"requiresApproval": true,
"requiresConfirmation": true
}
},
{
"key": "player.notify",
"title": "Notify SCUM player through RCON chat",
"permission": "server.game-client.command",
"approvalLevel": "operator",
"kind": "rcon",
"transportKey": "scum-management",
"targetKey": "scum-management",
"payloadSchemaRef": "schemas/bridge/player-notify.payload.schema.json",
"resultSchemaRef": "schemas/bridge/player-notify.result.schema.json",
"confirmationSchemaRef": "schemas/bridge/player-notify.confirmation.schema.json",
"timeoutSeconds": 60,
"maxPayloadBytes": 2048,
"safety": {
"requiresApproval": true,
"requiresConfirmation": true
}
},
{
"key": "reward.deliver",
"title": "Deliver approved SCUM reward through typed command workflow",
"permission": "server.game-client.command",
"approvalLevel": "operator",
"kind": "rcon",
"transportKey": "scum-management",
"targetKey": "scum-management",
"payloadSchemaRef": "schemas/bridge/reward-deliver.payload.schema.json",
"resultSchemaRef": "schemas/bridge/reward-deliver.result.schema.json",
"confirmationSchemaRef": "schemas/bridge/reward-deliver.confirmation.schema.json",
"timeoutSeconds": 60,
"maxPayloadBytes": 4096,
"safety": {
"requiresApproval": true,
"requiresConfirmation": true
}
},
{
"key": "player.attribute.855.set",
"title": "Set SCUM DB-only player attribute 855",
"permission": "server.game-client.maintenance",
"approvalLevel": "platform-admin",
"kind": "sqlite-mutation",
"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": {
"fieldKey": "855",
"tableKey": "prisoner",
"identityKey": "user_profile_id",
"valueKey": "value",
"confirmationQueryKey": "scum.player.profile",
"allowedValueType": "integer",
"minValue": 0,
"maxValue": 100000
},
"safety": {
"requiresApproval": true,
"requiresOfflinePlayer": true,
"requiresMaintenanceWindow": true,
"requiresBeforeValue": true,
"requiresConfirmation": true,
"backupRequired": true
}
}
],
"commandRetentionSeconds": 604800, "commandRetentionSeconds": 604800,
"maxCommands": 1000, "maxCommands": 1000,
"features": [ "features": [
{ "key": "config.manage", "title": "SCUM configuration", "permission": "server.game-client.read", "requiredHandlers": ["config.read", "config.patch"] }, {
{ "key": "player.intelligence", "title": "SCUM player intelligence", "permission": "server.game-client.read", "requiredHandlers": ["player.lookup"], "requiredEventProducers": ["semantic.events"] }, "key": "player.intelligence",
{ "key": "reward.delivery", "title": "SCUM reward delivery", "permission": "server.game-client.command", "requiredHandlers": ["reward.deliver", "player.notify"] }, "title": "SCUM player intelligence",
{ "key": "state.patch", "title": "SCUM player state patch", "permission": "server.game-client.maintenance", "requiredHandlers": ["game-state.patch"] }, "permission": "server.game-client.read",
{ "key": "vehicle.spawn", "title": "SCUM catalogued vehicle spawn", "permission": "server.game-client.command", "requiredHandlers": ["vehicle.spawn"] }, "requiredHandlers": [
{ "key": "trajectory.collect", "title": "SCUM trajectories", "permission": "server.game-client.read", "requiredEventProducers": ["semantic.events"] } "player.lookup"
],
"requiredEventProducers": [
"semantic.events"
]
},
{
"key": "reward.delivery",
"title": "SCUM reward delivery",
"permission": "server.game-client.command",
"requiredHandlers": [
"reward.deliver",
"player.notify"
]
},
{
"key": "state.patch",
"title": "SCUM player state patch",
"permission": "server.game-client.maintenance",
"requiredHandlers": [
"game-state.patch"
]
},
{
"key": "vehicle.spawn",
"title": "SCUM catalogued vehicle spawn",
"permission": "server.game-client.command",
"requiredHandlers": [
"vehicle.spawn"
]
},
{
"key": "trajectory.collect",
"title": "SCUM trajectories",
"permission": "server.game-client.read",
"requiredEventProducers": [
"semantic.events"
]
}
], ],
"pages": [ "pages": [
{ {
"pageKey": "files-config", "pageKey": "players",
"commandTypes": [ "snapshotTypes": [
"announcement.send", "players",
"companion.diagnostics", "online.sessions"
"player.lookup",
"reward.deliver",
"vehicle.spawn",
"event.start",
"restart.prepare",
"maintenance.prepare",
"database.request",
"management.rcon.request",
"management.program.request"
], ],
"snapshotTypes": ["companion.health", "online.sessions", "players", "squads", "vehicles", "flags"], "queryTemplateKeys": [
"featureKeys": ["config.manage", "player.intelligence", "reward.delivery", "state.patch", "vehicle.spawn", "trajectory.collect"] "scum.player.profile",
"scum.positions"
],
"operationKeys": [
"player.fame.set",
"player.currency.normal.set",
"player.currency.gold.set",
"player.notify",
"player.attribute.855.set"
],
"featureKeys": [
"player.intelligence",
"state.patch"
]
},
{
"pageKey": "squads",
"snapshotTypes": [
"squads",
"flags"
],
"queryTemplateKeys": [
"scum.squads",
"scum.squad-members",
"scum.flags"
],
"featureKeys": [
"player.intelligence"
]
},
{
"pageKey": "live-map",
"snapshotTypes": [
"players",
"vehicles",
"flags"
],
"queryTemplateKeys": [
"scum.player.profile",
"scum.vehicles",
"scum.flags",
"scum.positions"
],
"featureKeys": [
"trajectory.collect"
]
},
{
"pageKey": "gifts",
"snapshotTypes": [
"players"
],
"operationKeys": [
"reward.deliver",
"player.notify"
],
"featureKeys": [
"reward.delivery"
]
},
{
"pageKey": "workflows",
"queryTemplateKeys": [
"scum.player.profile",
"scum.squads",
"scum.squad-members",
"scum.vehicles",
"scum.flags",
"scum.positions"
],
"operationKeys": [
"player.fame.set",
"player.currency.normal.set",
"player.currency.gold.set",
"player.notify",
"reward.deliver",
"player.attribute.855.set"
],
"featureKeys": [
"player.intelligence",
"reward.delivery",
"state.patch",
"vehicle.spawn",
"trajectory.collect"
]
} }
], ],
"companion": { "companion": {
@@ -360,41 +666,179 @@
"status": "actions/status.json" "status": "actions/status.json"
}, },
"assetFiles": [ "assetFiles": [
{ "path": "actions/install.json", "mode": 384 }, {
{ "path": "actions/start.json", "mode": 384 }, "path": "actions/install.json",
{ "path": "actions/stop.json", "mode": 384 }, "mode": 384
{ "path": "actions/restart.json", "mode": 384 }, },
{ "path": "actions/status.json", "mode": 384 }, {
{ "path": "bin/scum-install-update.cmd", "mode": 448 }, "path": "actions/start.json",
{ "path": "bin/scum-start.cmd", "mode": 448 } "mode": 384
},
{
"path": "actions/stop.json",
"mode": 384
},
{
"path": "actions/restart.json",
"mode": 384
},
{
"path": "actions/status.json",
"mode": 384
},
{
"path": "bin/scum-install-update.cmd",
"mode": 448
},
{
"path": "bin/scum-start.cmd",
"mode": 448
}
], ],
"productionLifecycle": { "productionLifecycle": {
"operations": ["install", "enable", "disable", "upgrade", "rollback", "retire", "dependency-check"], "operations": [
"install",
"enable",
"disable",
"upgrade",
"rollback",
"retire",
"dependency-check"
],
"dependencyPolicy": "required", "dependencyPolicy": "required",
"approvalRequired": ["disable", "rollback", "retire"] "approvalRequired": [
"disable",
"rollback",
"retire"
]
}, },
"pages": [ "pages": [
{ "key": "files-config", "title": "文件管理", "path": "/files-config", "bundleKey": "scum-server-plugin", "bundleVersion": "1.0.3", "bundleIntegritySha256": "sha256:797c4e303c102f0316e71e4e5bda50a6ca96506a7cb368a42ecf858b236609b2", "permissions": ["server.read", "server.files.read", "server.files.write", "server.logs.read", "server.game-client.read", "server.game-client.command", "server.game-client.maintenance"], "bridgeActions": ["server.instances.read", "files.request", "logs.query"], "featureKeys": ["config.manage"] }, {
{ "key": "players", "title": "用户管理", "path": "/players", "bundleKey": "scum-server-plugin", "bundleVersion": "1.0.3", "bundleIntegritySha256": "sha256:797c4e303c102f0316e71e4e5bda50a6ca96506a7cb368a42ecf858b236609b2", "permissions": ["server.read", "server.game-client.read", "server.game-client.command", "server.game-client.maintenance"], "bridgeActions": ["server.instances.read"], "featureKeys": ["player.intelligence", "state.patch"] }, "key": "players",
{ "key": "squads", "title": "队伍管理", "path": "/squads", "bundleKey": "scum-server-plugin", "bundleVersion": "1.0.3", "bundleIntegritySha256": "sha256:797c4e303c102f0316e71e4e5bda50a6ca96506a7cb368a42ecf858b236609b2", "permissions": ["server.read", "server.game-client.read"], "bridgeActions": ["server.instances.read"], "featureKeys": ["player.intelligence"] }, "title": "用户管理",
{ "key": "live-map", "title": "实时地图", "path": "/live-map", "bundleKey": "scum-server-plugin", "bundleVersion": "1.0.3", "bundleIntegritySha256": "sha256:797c4e303c102f0316e71e4e5bda50a6ca96506a7cb368a42ecf858b236609b2", "permissions": ["server.read", "server.game-client.read"], "bridgeActions": ["server.instances.read"], "featureKeys": ["trajectory.collect"] }, "path": "/players",
{ "key": "gifts", "title": "礼包管理", "path": "/gifts", "bundleKey": "scum-server-plugin", "bundleVersion": "1.0.3", "bundleIntegritySha256": "sha256:797c4e303c102f0316e71e4e5bda50a6ca96506a7cb368a42ecf858b236609b2", "permissions": ["server.read", "server.game-client.read", "server.game-client.command"], "bridgeActions": ["server.instances.read"], "featureKeys": ["reward.delivery"] } "bundleKey": "scum-server-plugin",
"bundleVersion": "1.0.3",
"bundleIntegritySha256": "sha256:3488b316d909e597024f8f31c7bc96ab8019f643d74a528dc442d3df0dc3d54e",
"permissions": [
"server.read",
"server.game-client.read",
"server.game-client.command",
"server.game-client.maintenance"
], ],
"fileWorkspace": { "bridgeActions": [
"defaultDirectoryKey": "scum-config", "server.instances.read",
"directories": [{ "key": "scum-config", "label": "服务器配置", "scope": "config" }, { "key": "scum-logs", "label": "日志文件", "scope": "logs" }], "remote.access.request"
"files": [{ "key": "scum-server-settings", "directoryKey": "scum-config", "label": "ServerSettings.ini", "kind": "config", "editable": true }, { "key": "scum-game-config", "directoryKey": "scum-config", "label": "Game.ini", "kind": "config" }, { "key": "scum-engine-config", "directoryKey": "scum-config", "label": "Engine.ini", "kind": "config" }, { "key": "scum-game-user-settings", "directoryKey": "scum-config", "label": "GameUserSettings.ini", "kind": "config" }, { "key": "scum-admin-log", "directoryKey": "scum-logs", "label": "Admin.log", "kind": "log", "streamKey": "scum.admin" }, { "key": "scum-chat-log", "directoryKey": "scum-logs", "label": "Chat.log", "kind": "log", "streamKey": "scum.chat" }, { "key": "scum-kill-log", "directoryKey": "scum-logs", "label": "Kill.log", "kind": "log", "streamKey": "scum.kill" }, { "key": "scum-login-log", "directoryKey": "scum-logs", "label": "Login.log", "kind": "log", "streamKey": "scum.login" }, { "key": "scum-server-log", "directoryKey": "scum-logs", "label": "Server.log", "kind": "log", "streamKey": "scum.server" }], ],
"configFields": [{ "key": "server-name", "fileKey": "scum-server-settings", "configKey": "ServerName", "label": "服务器名称", "description": "显示在服务器浏览器与玩家连接界面。", "control": "text", "defaultValue": "SCUM Server", "restartImpact": "restart-required" }, { "key": "game-port", "fileKey": "scum-server-settings", "configKey": "GamePort", "label": "游戏端口", "description": "玩家连接所使用的游戏端口。", "control": "port", "minimum": 1, "maximum": 65535, "defaultValue": "7779", "restartImpact": "restart-required" }, { "key": "query-port", "fileKey": "scum-server-settings", "configKey": "QueryPort", "label": "查询端口", "description": "服务器查询和状态发现所使用的端口。", "control": "port", "minimum": 1, "maximum": 65535, "defaultValue": "27015", "restartImpact": "restart-required" }, { "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" }] "featureKeys": [
"player.intelligence",
"state.patch"
]
}, },
{
"key": "squads",
"title": "队伍管理",
"path": "/squads",
"bundleKey": "scum-server-plugin",
"bundleVersion": "1.0.3",
"bundleIntegritySha256": "sha256:3488b316d909e597024f8f31c7bc96ab8019f643d74a528dc442d3df0dc3d54e",
"permissions": [
"server.read",
"server.game-client.read"
],
"bridgeActions": [
"server.instances.read",
"remote.access.request"
],
"featureKeys": [
"player.intelligence"
]
},
{
"key": "live-map",
"title": "实时地图",
"path": "/live-map",
"bundleKey": "scum-server-plugin",
"bundleVersion": "1.0.3",
"bundleIntegritySha256": "sha256:3488b316d909e597024f8f31c7bc96ab8019f643d74a528dc442d3df0dc3d54e",
"permissions": [
"server.read",
"server.game-client.read"
],
"bridgeActions": [
"server.instances.read",
"remote.access.request"
],
"featureKeys": [
"trajectory.collect"
]
},
{
"key": "gifts",
"title": "礼包管理",
"path": "/gifts",
"bundleKey": "scum-server-plugin",
"bundleVersion": "1.0.3",
"bundleIntegritySha256": "sha256:3488b316d909e597024f8f31c7bc96ab8019f643d74a528dc442d3df0dc3d54e",
"permissions": [
"server.read",
"server.game-client.read",
"server.game-client.command"
],
"bridgeActions": [
"server.instances.read"
],
"featureKeys": [
"reward.delivery"
]
},
{
"key": "workflows",
"title": "Workflow 状态",
"path": "/workflows",
"bundleKey": "scum-server-plugin",
"bundleVersion": "1.0.3",
"bundleIntegritySha256": "sha256:3488b316d909e597024f8f31c7bc96ab8019f643d74a528dc442d3df0dc3d54e",
"permissions": [
"server.read",
"server.game-client.read",
"server.game-client.command",
"server.game-client.maintenance"
],
"bridgeActions": [
"server.instances.read",
"remote.access.request"
],
"featureKeys": [
"player.intelligence",
"reward.delivery",
"state.patch",
"vehicle.spawn",
"trajectory.collect"
]
}
],
"ai": { "ai": {
"purposes": [ "purposes": [
"config.suggest", "config.suggest"
"logs.diagnose"
], ],
"mediation": "platform", "mediation": "platform",
"configWritePolicy": "review-required" "configWritePolicy": "review-required"
}, },
"mapTrajectories": { "mapId": "scum-island", "mapVersion": "0.9", "worldMinX": -500000, "worldMinY": -500000, "worldMaxX": 500000, "worldMaxY": 500000, "imageWidth": 2048, "imageHeight": 2048, "precision": 1, "sampleDistance": 4, "sampleIntervalSeconds": 20, "retentionSeconds": 604800 }, "mapTrajectories": {
"mapId": "scum-island",
"mapVersion": "0.9",
"worldMinX": -500000,
"worldMinY": -500000,
"worldMaxX": 500000,
"worldMaxY": 500000,
"imageWidth": 2048,
"imageHeight": 2048,
"precision": 1,
"sampleDistance": 4,
"sampleIntervalSeconds": 20,
"retentionSeconds": 604800
},
"runtimeProfiles": { "runtimeProfiles": {
"discovery": [ "discovery": [
{ {
@@ -591,10 +1035,46 @@
} }
], ],
"logEvents": [ "logEvents": [
{ "key": "scum-player-position", "title": "SCUM player position", "sourceKey": "scum-client-events", "eventType": "player.position", "permission": "server.logs.read", "schemaRef": "schemas/log-events/player-position.event.schema.json", "retentionDays": 7, "severity": "info" }, {
{ "key": "scum-vehicle-position", "title": "SCUM vehicle position", "sourceKey": "scum-client-events", "eventType": "vehicle.position", "permission": "server.logs.read", "schemaRef": "schemas/log-events/vehicle-position.event.schema.json", "retentionDays": 7, "severity": "info" }, "key": "scum-player-position",
{ "key": "scum-player-vehicle-enter", "title": "SCUM player vehicle enter", "sourceKey": "scum-client-events", "eventType": "player.vehicle.enter", "permission": "server.logs.read", "schemaRef": "schemas/log-events/player-vehicle-enter.event.schema.json", "retentionDays": 7, "severity": "info" }, "title": "SCUM player position",
{ "key": "scum-player-vehicle-leave", "title": "SCUM player vehicle leave", "sourceKey": "scum-client-events", "eventType": "player.vehicle.leave", "permission": "server.logs.read", "schemaRef": "schemas/log-events/player-vehicle-leave.event.schema.json", "retentionDays": 7, "severity": "info" }, "sourceKey": "scum-client-events",
"eventType": "player.position",
"permission": "server.logs.read",
"schemaRef": "schemas/log-events/player-position.event.schema.json",
"retentionDays": 7,
"severity": "info"
},
{
"key": "scum-vehicle-position",
"title": "SCUM vehicle position",
"sourceKey": "scum-client-events",
"eventType": "vehicle.position",
"permission": "server.logs.read",
"schemaRef": "schemas/log-events/vehicle-position.event.schema.json",
"retentionDays": 7,
"severity": "info"
},
{
"key": "scum-player-vehicle-enter",
"title": "SCUM player vehicle enter",
"sourceKey": "scum-client-events",
"eventType": "player.vehicle.enter",
"permission": "server.logs.read",
"schemaRef": "schemas/log-events/player-vehicle-enter.event.schema.json",
"retentionDays": 7,
"severity": "info"
},
{
"key": "scum-player-vehicle-leave",
"title": "SCUM player vehicle leave",
"sourceKey": "scum-client-events",
"eventType": "player.vehicle.leave",
"permission": "server.logs.read",
"schemaRef": "schemas/log-events/player-vehicle-leave.event.schema.json",
"retentionDays": 7,
"severity": "info"
},
{ {
"key": "scum-chat", "key": "scum-chat",
"title": "SCUM chat message", "title": "SCUM chat message",
@@ -718,19 +1198,26 @@
"key": "scum-database", "key": "scum-database",
"kind": "sqlite", "kind": "sqlite",
"targetKey": "scum-database", "targetKey": "scum-database",
"capabilities": ["remote.run.protected.sql"] "capabilities": [
"remote.run.db.sqlite.query",
"remote.run.protected.sql"
]
}, },
{ {
"key": "scum-management", "key": "scum-management",
"kind": "rcon", "kind": "rcon",
"targetKey": "scum-management", "targetKey": "scum-management",
"capabilities": ["remote.run.protected.rcon"] "capabilities": [
"remote.run.protected.rcon"
]
}, },
{ {
"key": "scum-program", "key": "scum-program",
"kind": "program", "kind": "program",
"targetKey": "scum-program", "targetKey": "scum-program",
"capabilities": ["remote.run.program.command"] "capabilities": [
"remote.run.program.command"
]
} }
], ],
"clientManagers": [ "clientManagers": [
@@ -776,7 +1263,15 @@
] ]
}, },
"lifecycle": { "lifecycle": {
"actions": ["start", "stop", "restart", "status", "update", "rollback", "uninstall"], "actions": [
"start",
"stop",
"restart",
"status",
"update",
"rollback",
"uninstall"
],
"startupTimeoutSeconds": 60, "startupTimeoutSeconds": 60,
"stopTimeoutSeconds": 30 "stopTimeoutSeconds": 30
}, },
@@ -785,7 +1280,14 @@
"intervalSeconds": 30, "intervalSeconds": 30,
"degradedAfterSeconds": 90, "degradedAfterSeconds": 90,
"offlineAfterSeconds": 120, "offlineAfterSeconds": 120,
"requiredCapabilities": ["component.register", "component.heartbeat", "component.health", "component.control", "game-client.bridge", "logs.stream"] "requiredCapabilities": [
"component.register",
"component.heartbeat",
"component.health",
"component.control",
"game-client.bridge",
"logs.stream"
]
}, },
"updatePolicy": { "updatePolicy": {
"strategy": "manual-staged", "strategy": "manual-staged",
@@ -1,6 +1,5 @@
import { renderSCUMFeaturePage } from "../features/page.js"; import { renderSCUMFeaturePage } from "../features/page.js";
import type { SCUMFeatureWorkspace } from "../features/contracts.js";
export const pluginPageBundle = { key: "scum-server-plugin", version: "1.0.3", integritySha256: "sha256:797c4e303c102f0316e71e4e5bda50a6ca96506a7cb368a42ecf858b236609b2" }; export const pluginPageBundle = { key: "scum-server-plugin", version: "1.0.3", integritySha256: "sha256:3488b316d909e597024f8f31c7bc96ab8019f643d74a528dc442d3df0dc3d54e" };
export function renderPluginPage(react: any, input: any) { return renderSCUMFeaturePage(react, { pageKey: input.page?.key ?? "files-config", pageTitle: input.page?.title ?? "文件管理", serverInstanceId: input.context.serverInstanceId, permissions: input.context.permissions, availability: input.availability, featureAvailability: input.availability.features, workspace: input.workspace as SCUMFeatureWorkspace | undefined, workspaceActions: input.workspaceActions }); } export function renderPluginPage(react: any, input: any) { return renderSCUMFeaturePage(react, { pageKey: input.page?.key ?? "players", pageTitle: input.page?.title ?? "用户管理", serverInstanceId: input.context.serverInstanceId, permissions: input.context.permissions, availability: input.availability, featureAvailability: input.availability.features, workspaceActions: input.workspaceActions }); }
@@ -0,0 +1,13 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"additionalProperties": false,
"required": ["playerId", "fieldKey", "value", "observedAt", "checksum"],
"properties": {
"playerId": { "type": "string", "minLength": 1, "maxLength": 96 },
"fieldKey": { "const": "855" },
"value": { "type": "integer", "minimum": 0, "maximum": 100000 },
"observedAt": { "type": "string", "format": "date-time" },
"checksum": { "type": "string", "minLength": 1, "maxLength": 160 }
}
}
@@ -0,0 +1,15 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"additionalProperties": false,
"required": ["playerId", "fieldKey", "before", "after", "safetyWindow", "backupRef"],
"properties": {
"playerId": { "type": "string", "minLength": 1, "maxLength": 96 },
"fieldKey": { "const": "855" },
"before": { "type": "integer", "minimum": 0, "maximum": 100000 },
"after": { "type": "integer", "minimum": 0, "maximum": 100000 },
"safetyWindow": { "type": "string", "minLength": 1, "maxLength": 120 },
"backupRef": { "type": "string", "minLength": 1, "maxLength": 180 },
"reason": { "type": "string", "maxLength": 240 }
}
}
@@ -0,0 +1,12 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"additionalProperties": false,
"required": ["outcome", "affectedRows", "mutationChecksum"],
"properties": {
"outcome": { "enum": ["succeeded", "failed", "unknown", "stale-before"] },
"affectedRows": { "type": "integer", "minimum": 0, "maximum": 1 },
"mutationChecksum": { "type": "string", "minLength": 1, "maxLength": 160 },
"safeMessage": { "type": "string", "maxLength": 240 }
}
}
@@ -0,0 +1,14 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "SCUMPlayerCurrencySetConfirmation",
"type": "object",
"additionalProperties": false,
"required": ["playerId", "amount", "observedAt"],
"properties": {
"playerId": { "type": "string", "minLength": 17, "maxLength": 17, "pattern": "^[0-9]{17}$" },
"amount": { "type": "integer", "minimum": 0, "maximum": 2147483647 },
"currency": { "enum": ["normal", "gold"] },
"observationId": { "type": "string", "minLength": 1, "maxLength": 160 },
"observedAt": { "type": "string", "format": "date-time" }
}
}
@@ -0,0 +1,12 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "SCUMPlayerCurrencySetPayload",
"type": "object",
"additionalProperties": false,
"required": ["playerId", "amount"],
"properties": {
"playerId": { "type": "string", "minLength": 17, "maxLength": 17, "pattern": "^[0-9]{17}$" },
"amount": { "type": "integer", "minimum": 0, "maximum": 2147483647 },
"reason": { "type": "string", "minLength": 1, "maxLength": 240 }
}
}
@@ -0,0 +1,13 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "SCUMPlayerFameSetConfirmation",
"type": "object",
"additionalProperties": false,
"required": ["playerId", "fame", "observedAt"],
"properties": {
"playerId": { "type": "string", "minLength": 17, "maxLength": 17, "pattern": "^[0-9]{17}$" },
"fame": { "type": "integer", "minimum": -2147483648, "maximum": 2147483647 },
"observationId": { "type": "string", "minLength": 1, "maxLength": 160 },
"observedAt": { "type": "string", "format": "date-time" }
}
}
@@ -0,0 +1,12 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "SCUMPlayerFameSetPayload",
"type": "object",
"additionalProperties": false,
"required": ["playerId", "fame"],
"properties": {
"playerId": { "type": "string", "minLength": 17, "maxLength": 17, "pattern": "^[0-9]{17}$" },
"fame": { "type": "integer", "minimum": -2147483648, "maximum": 2147483647 },
"reason": { "type": "string", "minLength": 1, "maxLength": 240 }
}
}
@@ -0,0 +1,12 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "SCUMPlayerNotifyConfirmation",
"type": "object",
"additionalProperties": false,
"required": ["playerId", "outcome", "observedAt"],
"properties": {
"playerId": { "type": "string", "minLength": 17, "maxLength": 17, "pattern": "^[0-9]{17}$" },
"outcome": { "enum": ["sent", "failed", "unknown"] },
"observedAt": { "type": "string", "format": "date-time" }
}
}
@@ -0,0 +1,12 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "SCUMPlayerRCONSetResult",
"type": "object",
"additionalProperties": false,
"required": ["outcome"],
"properties": {
"outcome": { "enum": ["queued", "succeeded", "failed", "unknown"] },
"runJobId": { "type": "string", "minLength": 1, "maxLength": 160 },
"summary": { "type": "string", "maxLength": 240 }
}
}
@@ -0,0 +1,14 @@
# SCUM.db Query Contract
These query template keys are browser-safe declarations. They intentionally do not carry SQL text, host paths, DSNs, sockets, or credentials. The bound run/agent beside the current SCUM service owns the actual SQLite read implementation and must return rows matching the referenced result schemas.
| Template key | SCUM.db source tables | Projection target |
| --- | --- | --- |
| `scum.player.profile` | `user_profile`, `prisoner`, `prisoner_entity`, `entity`, `bank_account_registry`, `bank_account_registry_currencies`, optional `squad_member` / `squad` joins | Player identity, economy, squad summary, and current position |
| `scum.squads` | `squad`, optional `squad_member`, optional `user_profile` leader joins | Squad records and leader/member counts |
| `scum.squad-members` | `squad_member`, `user_profile`, optional `squad` joins | Squad roster and member identity mapping |
| `scum.vehicles` | `vehicle_spawner`, `entity`, optional owner/squad joins when present | Vehicle inventory and coordinates; unknown class labels remain unknown |
| `scum.flags` | `base_element`, `entity`, `user_profile`, `squad_member`, `squad` where available | Flag ownership, ownership confidence, and coordinates |
| `scum.positions` | `prisoner_entity`, `vehicle_spawner`, `base_element`, `entity` | Current player, vehicle, and flag coordinates |
`last_save_time` is freshness evidence only. It must not be treated as proof that a player is online; online state comes from login/logoff evidence or an explicit typed online field.
@@ -0,0 +1,12 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "SCUMFlagsParameters",
"type": "object",
"additionalProperties": false,
"properties": {
"flagId": { "type": "string", "minLength": 1, "maxLength": 96 },
"ownerProfileId": { "type": "string", "minLength": 1, "maxLength": 96 },
"squadId": { "type": "string", "minLength": 1, "maxLength": 96 },
"limit": { "type": "integer", "minimum": 1, "maximum": 500 }
}
}
@@ -0,0 +1,31 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "SCUMFlagsResult",
"type": "object",
"additionalProperties": false,
"required": ["rows"],
"properties": {
"rows": {
"type": "array",
"maxItems": 500,
"items": {
"type": "object",
"additionalProperties": false,
"required": ["flagId"],
"properties": {
"flagId": { "type": "string", "minLength": 1, "maxLength": 96 },
"entityId": { "type": "string", "minLength": 1, "maxLength": 96 },
"ownerProfileId": { "type": "string", "minLength": 1, "maxLength": 96 },
"ownerPlayerId": { "type": "string", "minLength": 1, "maxLength": 96 },
"ownerSquadId": { "type": "string", "minLength": 1, "maxLength": 96 },
"ownerSquadName": { "type": "string", "minLength": 1, "maxLength": 80 },
"ownershipConfidence": { "enum": ["direct", "member", "squad", "unknown"] },
"x": { "type": "number" },
"y": { "type": "number" },
"z": { "type": "number" }
}
}
},
"truncated": { "type": "boolean" }
}
}
@@ -0,0 +1,13 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "SCUMPlayerProfileParameters",
"type": "object",
"additionalProperties": false,
"properties": {
"gamePlayerId": { "type": "string", "minLength": 1, "maxLength": 96, "pattern": "^[A-Za-z0-9_.:-]{1,96}$" },
"userProfileId": { "type": "string", "minLength": 1, "maxLength": 96, "pattern": "^[A-Za-z0-9_.:-]{1,96}$" },
"steamId": { "type": "string", "minLength": 1, "maxLength": 96, "pattern": "^[A-Za-z0-9_.:-]{1,96}$" },
"search": { "type": "string", "minLength": 1, "maxLength": 80 },
"limit": { "type": "integer", "minimum": 1, "maximum": 500 }
}
}
@@ -0,0 +1,34 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "SCUMPlayerProfileResult",
"type": "object",
"additionalProperties": false,
"required": ["rows"],
"properties": {
"rows": {
"type": "array",
"maxItems": 500,
"items": {
"type": "object",
"additionalProperties": false,
"required": ["userProfileId"],
"properties": {
"gamePlayerId": { "type": "string", "minLength": 1, "maxLength": 96 },
"userProfileId": { "type": "string", "minLength": 1, "maxLength": 96 },
"steamId": { "type": "string", "minLength": 1, "maxLength": 96 },
"displayName": { "type": "string", "minLength": 1, "maxLength": 80 },
"squadId": { "type": "string", "minLength": 1, "maxLength": 96 },
"squadName": { "type": "string", "minLength": 1, "maxLength": 80 },
"famePoints": { "type": "number" },
"normalBalance": { "type": "number" },
"goldBalance": { "type": "number" },
"x": { "type": "number" },
"y": { "type": "number" },
"z": { "type": "number" },
"lastSaveTime": { "type": "string", "format": "date-time" }
}
}
},
"truncated": { "type": "boolean" }
}
}
@@ -0,0 +1,11 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "SCUMPositionsParameters",
"type": "object",
"additionalProperties": false,
"properties": {
"subjectType": { "enum": ["player", "vehicle", "flag"] },
"subjectId": { "type": "string", "minLength": 1, "maxLength": 96 },
"limit": { "type": "integer", "minimum": 1, "maximum": 500 }
}
}
@@ -0,0 +1,30 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "SCUMPositionsResult",
"type": "object",
"additionalProperties": false,
"required": ["rows"],
"properties": {
"rows": {
"type": "array",
"maxItems": 500,
"items": {
"type": "object",
"additionalProperties": false,
"required": ["subjectType", "subjectId", "x", "y"],
"properties": {
"subjectType": { "enum": ["player", "vehicle", "flag"] },
"subjectId": { "type": "string", "minLength": 1, "maxLength": 96 },
"gamePlayerId": { "type": "string", "minLength": 1, "maxLength": 96 },
"vehicleId": { "type": "string", "minLength": 1, "maxLength": 96 },
"entityId": { "type": "string", "minLength": 1, "maxLength": 96 },
"x": { "type": "number" },
"y": { "type": "number" },
"z": { "type": "number" },
"lastSaveTime": { "type": "string", "format": "date-time" }
}
}
},
"truncated": { "type": "boolean" }
}
}
@@ -0,0 +1,11 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "SCUMSquadMembersParameters",
"type": "object",
"additionalProperties": false,
"properties": {
"squadId": { "type": "string", "minLength": 1, "maxLength": 96 },
"userProfileId": { "type": "string", "minLength": 1, "maxLength": 96 },
"limit": { "type": "integer", "minimum": 1, "maximum": 500 }
}
}
@@ -0,0 +1,29 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "SCUMSquadMembersResult",
"type": "object",
"additionalProperties": false,
"required": ["rows"],
"properties": {
"rows": {
"type": "array",
"maxItems": 500,
"items": {
"type": "object",
"additionalProperties": false,
"required": ["squadId", "userProfileId"],
"properties": {
"squadId": { "type": "string", "minLength": 1, "maxLength": 96 },
"userProfileId": { "type": "string", "minLength": 1, "maxLength": 96 },
"gamePlayerId": { "type": "string", "minLength": 1, "maxLength": 96 },
"steamId": { "type": "string", "minLength": 1, "maxLength": 96 },
"displayName": { "type": "string", "minLength": 1, "maxLength": 80 },
"rank": { "type": "string", "minLength": 1, "maxLength": 32 },
"isLeader": { "type": "boolean" },
"joinedAt": { "type": "string", "format": "date-time" }
}
}
},
"truncated": { "type": "boolean" }
}
}
@@ -0,0 +1,11 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "SCUMSquadsParameters",
"type": "object",
"additionalProperties": false,
"properties": {
"squadId": { "type": "string", "minLength": 1, "maxLength": 96 },
"search": { "type": "string", "minLength": 1, "maxLength": 80 },
"limit": { "type": "integer", "minimum": 1, "maximum": 500 }
}
}
@@ -0,0 +1,27 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "SCUMSquadsResult",
"type": "object",
"additionalProperties": false,
"required": ["rows"],
"properties": {
"rows": {
"type": "array",
"maxItems": 500,
"items": {
"type": "object",
"additionalProperties": false,
"required": ["squadId"],
"properties": {
"squadId": { "type": "string", "minLength": 1, "maxLength": 96 },
"name": { "type": "string", "minLength": 1, "maxLength": 80 },
"leaderProfileId": { "type": "string", "minLength": 1, "maxLength": 96 },
"leaderPlayerId": { "type": "string", "minLength": 1, "maxLength": 96 },
"memberCount": { "type": "integer", "minimum": 0, "maximum": 1000 },
"score": { "type": "number" }
}
}
},
"truncated": { "type": "boolean" }
}
}
@@ -0,0 +1,12 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "SCUMVehiclesParameters",
"type": "object",
"additionalProperties": false,
"properties": {
"vehicleId": { "type": "string", "minLength": 1, "maxLength": 96 },
"ownerProfileId": { "type": "string", "minLength": 1, "maxLength": 96 },
"squadId": { "type": "string", "minLength": 1, "maxLength": 96 },
"limit": { "type": "integer", "minimum": 1, "maximum": 500 }
}
}
@@ -0,0 +1,31 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "SCUMVehiclesResult",
"type": "object",
"additionalProperties": false,
"required": ["rows"],
"properties": {
"rows": {
"type": "array",
"maxItems": 500,
"items": {
"type": "object",
"additionalProperties": false,
"required": ["vehicleId"],
"properties": {
"vehicleId": { "type": "string", "minLength": 1, "maxLength": 96 },
"entityId": { "type": "string", "minLength": 1, "maxLength": 96 },
"className": { "type": "string", "minLength": 1, "maxLength": 120 },
"label": { "type": "string", "minLength": 1, "maxLength": 120 },
"ownerProfileId": { "type": "string", "minLength": 1, "maxLength": 96 },
"ownerPlayerId": { "type": "string", "minLength": 1, "maxLength": 96 },
"squadId": { "type": "string", "minLength": 1, "maxLength": 96 },
"x": { "type": "number" },
"y": { "type": "number" },
"z": { "type": "number" }
}
}
},
"truncated": { "type": "boolean" }
}
}
@@ -0,0 +1,13 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "SCUMRewardDeliverConfirmation",
"type": "object",
"additionalProperties": false,
"required": ["grantId", "outcome", "observedAt"],
"properties": {
"grantId": { "type": "string", "minLength": 1, "maxLength": 160 },
"outcome": { "enum": ["delivered", "failed", "unknown"] },
"observationId": { "type": "string", "minLength": 1, "maxLength": 160 },
"observedAt": { "type": "string", "format": "date-time" }
}
}
@@ -271,6 +271,11 @@
"items": { "$ref": "#/$defs/gameClientBridgeQueryTemplate" }, "items": { "$ref": "#/$defs/gameClientBridgeQueryTemplate" },
"maxItems": 128 "maxItems": 128
}, },
"operationTemplates": {
"type": "array",
"items": { "$ref": "#/$defs/gameClientBridgeOperationTemplate" },
"maxItems": 128
},
"commandRetentionSeconds": { "type": "integer", "minimum": 1, "maximum": 31536000 }, "commandRetentionSeconds": { "type": "integer", "minimum": 1, "maximum": 31536000 },
"maxCommands": { "type": "integer", "minimum": 1, "maximum": 100000 }, "maxCommands": { "type": "integer", "minimum": 1, "maximum": 100000 },
"pages": { "pages": {
@@ -359,6 +364,55 @@
"timeoutSeconds": { "type": "integer", "minimum": 1, "maximum": 60 } "timeoutSeconds": { "type": "integer", "minimum": 1, "maximum": 60 }
} }
}, },
"gameClientBridgeOperationSafety": {
"type": "object",
"additionalProperties": false,
"properties": {
"requiresApproval": { "type": "boolean" },
"requiresOfflinePlayer": { "type": "boolean" },
"requiresMaintenanceWindow": { "type": "boolean" },
"requiresBeforeValue": { "type": "boolean" },
"requiresConfirmation": { "type": "boolean" },
"backupRequired": { "type": "boolean" }
}
},
"gameClientBridgeOperationMutation": {
"type": "object",
"required": ["fieldKey", "tableKey", "identityKey", "valueKey", "confirmationQueryKey", "allowedValueType"],
"additionalProperties": false,
"properties": {
"fieldKey": { "$ref": "#/$defs/logicalKey" },
"tableKey": { "$ref": "#/$defs/logicalKey" },
"identityKey": { "$ref": "#/$defs/logicalKey" },
"valueKey": { "$ref": "#/$defs/logicalKey" },
"confirmationQueryKey": { "$ref": "#/$defs/logicalKey" },
"allowedValueType": { "enum": ["integer", "number", "string", "boolean"] },
"minValue": { "type": "number" },
"maxValue": { "type": "number" }
}
},
"gameClientBridgeOperationTemplate": {
"type": "object",
"required": ["key", "title", "permission", "approvalLevel", "kind", "transportKey", "targetKey", "payloadSchemaRef", "timeoutSeconds", "maxPayloadBytes"],
"additionalProperties": false,
"properties": {
"key": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,159}$" },
"title": { "type": "string", "minLength": 1, "maxLength": 80 },
"permission": { "$ref": "#/$defs/pluginPermission" },
"approvalLevel": { "enum": ["operator", "platform-admin"] },
"kind": { "enum": ["rcon", "sqlite-mutation"] },
"transportKey": { "$ref": "#/$defs/logicalKey" },
"targetKey": { "$ref": "#/$defs/logicalKey" },
"payloadSchemaRef": { "$ref": "#/$defs/relativeJsonRef" },
"resultSchemaRef": { "$ref": "#/$defs/relativeJsonRef" },
"confirmationSchemaRef": { "$ref": "#/$defs/relativeJsonRef" },
"timeoutSeconds": { "type": "integer", "minimum": 1, "maximum": 3600 },
"maxPayloadBytes": { "type": "integer", "minimum": 1, "maximum": 65536 },
"maxRowsAffected": { "type": "integer", "minimum": 1, "maximum": 10 },
"mutation": { "$ref": "#/$defs/gameClientBridgeOperationMutation" },
"safety": { "$ref": "#/$defs/gameClientBridgeOperationSafety" }
}
},
"gameClientBridgePageContract": { "gameClientBridgePageContract": {
"type": "object", "type": "object",
"required": ["pageKey"], "required": ["pageKey"],
@@ -368,6 +422,7 @@
"commandTypes": { "type": "array", "items": { "type": "string" }, "uniqueItems": true }, "commandTypes": { "type": "array", "items": { "type": "string" }, "uniqueItems": true },
"snapshotTypes": { "type": "array", "items": { "type": "string" }, "uniqueItems": true }, "snapshotTypes": { "type": "array", "items": { "type": "string" }, "uniqueItems": true },
"queryTemplateKeys": { "type": "array", "items": { "type": "string" }, "uniqueItems": true }, "queryTemplateKeys": { "type": "array", "items": { "type": "string" }, "uniqueItems": true },
"operationKeys": { "type": "array", "items": { "type": "string" }, "uniqueItems": true },
"featureKeys": { "type": "array", "items": { "type": "string" }, "uniqueItems": true } "featureKeys": { "type": "array", "items": { "type": "string" }, "uniqueItems": true }
} }
}, },
+137 -3
View File
@@ -167,6 +167,12 @@ function unsafeGameClientBridgeCommandTypeReason(value: string): string | undefi
return undefined; return undefined;
} }
function unsafeGameClientBridgePayloadKey(value: string): boolean {
const tokens = identifierTokens(value);
const compact = tokens.join("");
return ["sql", "rawsql", "sqltext", "sqlstatement", "dsn", "hostpath", "socket", "credential", "accesstoken"].includes(compact);
}
function unsafeBridgeSchemaFieldReason(fieldName: string): string | undefined { function unsafeBridgeSchemaFieldReason(fieldName: string): string | undefined {
const tokens = identifierTokens(fieldName); const tokens = identifierTokens(fieldName);
const compact = tokens.join(""); const compact = tokens.join("");
@@ -669,7 +675,25 @@ export function validateGameClientBridgeCatalog(manifest: unknown): string[] {
maxRows?: number; maxRows?: number;
timeoutSeconds?: number; timeoutSeconds?: number;
}; };
type BridgePage = { pageKey?: string; commandTypes?: string[]; snapshotTypes?: string[]; queryTemplateKeys?: string[] }; type BridgeOperationSafety = { requiresApproval?: boolean; requiresOfflinePlayer?: boolean; requiresMaintenanceWindow?: boolean; requiresBeforeValue?: boolean; requiresConfirmation?: boolean; backupRequired?: boolean };
type BridgeOperationMutation = { fieldKey?: string; tableKey?: string; identityKey?: string; valueKey?: string; confirmationQueryKey?: string; allowedValueType?: string; minValue?: number; maxValue?: number };
type BridgeOperationTemplate = {
key?: string;
permission?: string;
approvalLevel?: string;
kind?: string;
transportKey?: string;
targetKey?: string;
payloadSchemaRef?: string;
resultSchemaRef?: string;
confirmationSchemaRef?: string;
timeoutSeconds?: number;
maxPayloadBytes?: number;
maxRowsAffected?: number;
mutation?: BridgeOperationMutation;
safety?: BridgeOperationSafety;
};
type BridgePage = { pageKey?: string; commandTypes?: string[]; snapshotTypes?: string[]; queryTemplateKeys?: string[]; operationKeys?: string[] };
type BridgeCompanion = { type BridgeCompanion = {
profileKey?: string; profileKey?: string;
configTemplateKey?: string; configTemplateKey?: string;
@@ -692,7 +716,7 @@ export function validateGameClientBridgeCatalog(manifest: unknown): string[] {
remoteAccess?: { runCapabilities?: string[]; databaseEngines?: string[] }; remoteAccess?: { runCapabilities?: string[]; databaseEngines?: string[] };
pages?: PluginPage[]; pages?: PluginPage[];
runtimeProfiles?: { transportProfiles?: RuntimeTransportProfile[]; clientManagers?: RuntimeClientManager[] }; runtimeProfiles?: { transportProfiles?: RuntimeTransportProfile[]; clientManagers?: RuntimeClientManager[] };
gameClientBridge?: { commands?: BridgeCommand[]; snapshots?: Array<{ type?: string }>; queryTemplates?: BridgeQueryTemplate[]; pages?: BridgePage[]; companion?: BridgeCompanion }; gameClientBridge?: { commands?: BridgeCommand[]; snapshots?: Array<{ type?: string }>; queryTemplates?: BridgeQueryTemplate[]; operationTemplates?: BridgeOperationTemplate[]; pages?: BridgePage[]; companion?: BridgeCompanion };
}; };
const bridge = declaration.gameClientBridge; const bridge = declaration.gameClientBridge;
if (!bridge) { if (!bridge) {
@@ -702,6 +726,7 @@ export function validateGameClientBridgeCatalog(manifest: unknown): string[] {
const commands = new Set<string>(); const commands = new Set<string>();
const snapshots = new Set((bridge.snapshots ?? []).map((snapshot) => snapshot.type ?? "")); const snapshots = new Set((bridge.snapshots ?? []).map((snapshot) => snapshot.type ?? ""));
const queryTemplates = new Map<string, BridgeQueryTemplate>(); const queryTemplates = new Map<string, BridgeQueryTemplate>();
const operationTemplates = new Map<string, BridgeOperationTemplate>();
const declaredPermissions = new Set(declaration.permissions ?? []); const declaredPermissions = new Set(declaration.permissions ?? []);
const declaredCapabilities = new Set(declaration.capabilities ?? []); const declaredCapabilities = new Set(declaration.capabilities ?? []);
const remoteCapabilities = new Set(declaration.remoteAccess?.runCapabilities ?? []); const remoteCapabilities = new Set(declaration.remoteAccess?.runCapabilities ?? []);
@@ -839,6 +864,92 @@ export function validateGameClientBridgeCatalog(manifest: unknown): string[] {
errors.push(`${location}: sqlite query templates require the plugin and remote-access sqlite query capability`); errors.push(`${location}: sqlite query templates require the plugin and remote-access sqlite query capability`);
} }
} }
for (const [index, operationTemplate] of (bridge.operationTemplates ?? []).entries()) {
const location = `manifest.gameClientBridge.operationTemplates[${index}]`;
const key = operationTemplate.key ?? "";
const unsafeReason = unsafeGameClientBridgeCommandTypeReason(key);
if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,159}$/.test(key) || unsafeReason) {
errors.push(`${location}.key: ${unsafeReason ?? "operation template key is unsafe"}`);
}
if (operationTemplates.has(key)) {
errors.push(`${location}.key: duplicate operation template ${key}`);
}
operationTemplates.set(key, operationTemplate);
if (!operationTemplate.permission || !declaredPermissions.has(operationTemplate.permission)) {
errors.push(`${location}.permission: permission must be declared by the plugin manifest`);
}
if (!new Set(["operator", "platform-admin"]).has(operationTemplate.approvalLevel ?? "")) {
errors.push(`${location}.approvalLevel: must require operator or platform-admin approval`);
}
if (!new Set(["rcon", "sqlite-mutation"]).has(operationTemplate.kind ?? "")) {
errors.push(`${location}.kind: must be rcon or sqlite-mutation`);
}
for (const [field, ref] of [["payloadSchemaRef", operationTemplate.payloadSchemaRef], ["resultSchemaRef", operationTemplate.resultSchemaRef], ["confirmationSchemaRef", operationTemplate.confirmationSchemaRef]] as const) {
if ((field === "payloadSchemaRef" && !ref) || (ref && !isSafeRelativeJsonRef(ref))) {
errors.push(`${location}.${field}: raw host paths and unsafe schema references are not allowed`);
}
}
if (!Number.isInteger(operationTemplate.timeoutSeconds) || (operationTemplate.timeoutSeconds ?? 0) < 1 || (operationTemplate.timeoutSeconds ?? 0) > 3600) {
errors.push(`${location}.timeoutSeconds: must be an integer between 1 and 3600`);
}
if (!Number.isInteger(operationTemplate.maxPayloadBytes) || (operationTemplate.maxPayloadBytes ?? 0) < 1 || (operationTemplate.maxPayloadBytes ?? 0) > 65536) {
errors.push(`${location}.maxPayloadBytes: must be an integer between 1 and 65536`);
}
const transport = transportProfiles.find((profile) => profile.key === operationTemplate.transportKey);
if (!transport) {
errors.push(`${location}.transportKey: undeclared transport profile ${operationTemplate.transportKey ?? ""}`);
continue;
}
if (!operationTemplate.targetKey || transport.targetKey !== operationTemplate.targetKey) {
errors.push(`${location}.targetKey: must match the declared runtime transport target`);
}
if (operationTemplate.kind === "rcon") {
if (transport.kind !== "rcon" || !transport.capabilities?.includes("remote.run.protected.rcon")) {
errors.push(`${location}.transportKey: rcon operations require remote.run.protected.rcon transport`);
}
if (operationTemplate.maxRowsAffected !== undefined) {
errors.push(`${location}.maxRowsAffected: only sqlite-mutation operations may declare affected row bounds`);
}
if (operationTemplate.mutation !== undefined) {
errors.push(`${location}.mutation: only sqlite-mutation operations may declare mutation metadata`);
}
}
if (operationTemplate.kind === "sqlite-mutation") {
if (transport.kind !== "sqlite" || !transport.capabilities?.includes("remote.run.protected.sql")) {
errors.push(`${location}.transportKey: sqlite-mutation operations require sqlite remote.run.protected.sql transport`);
}
if (operationTemplate.approvalLevel !== "platform-admin") {
errors.push(`${location}.approvalLevel: sqlite-mutation operations require platform-admin approval`);
}
if (!Number.isInteger(operationTemplate.maxRowsAffected) || (operationTemplate.maxRowsAffected ?? 0) < 1 || (operationTemplate.maxRowsAffected ?? 0) > 10) {
errors.push(`${location}.maxRowsAffected: must be an integer between 1 and 10`);
}
const safety = operationTemplate.safety;
if (!safety?.requiresBeforeValue || !safety.requiresConfirmation || (!safety.requiresOfflinePlayer && !safety.requiresMaintenanceWindow)) {
errors.push(`${location}.safety: sqlite-mutation operations require before value, confirmation, and offline or maintenance protection`);
}
const mutation = operationTemplate.mutation;
if (!mutation) {
errors.push(`${location}.mutation: sqlite-mutation operations require field/table/identity metadata`);
} else {
for (const field of ["fieldKey", "tableKey", "identityKey", "valueKey", "confirmationQueryKey"] as const) {
const value = mutation[field] ?? "";
if (!/^[A-Za-z0-9][A-Za-z0-9._:/-]{0,159}$/.test(value) || unsafeGameClientBridgePayloadKey(value)) {
errors.push(`${location}.mutation.${field}: must be a safe logical key`);
}
}
if (!new Set(["integer", "number", "string", "boolean"]).has(mutation.allowedValueType ?? "")) {
errors.push(`${location}.mutation.allowedValueType: must be integer, number, string, or boolean`);
}
if (mutation.minValue !== undefined && mutation.maxValue !== undefined && mutation.minValue > mutation.maxValue) {
errors.push(`${location}.mutation: minValue must not exceed maxValue`);
}
if (mutation.confirmationQueryKey && !queryTemplates.has(mutation.confirmationQueryKey)) {
errors.push(`${location}.mutation.confirmationQueryKey: must reference a declared query template`);
}
}
}
}
for (const [index, page] of (bridge.pages ?? []).entries()) { for (const [index, page] of (bridge.pages ?? []).entries()) {
for (const commandType of page.commandTypes ?? []) { for (const commandType of page.commandTypes ?? []) {
if (!commands.has(commandType)) { if (!commands.has(commandType)) {
@@ -864,6 +975,17 @@ export function validateGameClientBridgeCatalog(manifest: unknown): string[] {
errors.push(`manifest.gameClientBridge.pages[${index}].queryTemplateKeys: page must declare remote.access.request`); errors.push(`manifest.gameClientBridge.pages[${index}].queryTemplateKeys: page must declare remote.access.request`);
} }
} }
for (const operationKey of page.operationKeys ?? []) {
const operationTemplate = operationTemplates.get(operationKey);
if (!operationTemplate) {
errors.push(`manifest.gameClientBridge.pages[${index}].operationKeys: undeclared operation template ${operationKey}`);
continue;
}
const pluginPage = declaration.pages?.find((candidate) => candidate.key === page.pageKey);
if (!pluginPage?.permissions?.includes(operationTemplate.permission ?? "")) {
errors.push(`manifest.gameClientBridge.pages[${index}].operationKeys: page must declare operation template permission ${operationTemplate.permission ?? ""}`);
}
}
} }
return errors; return errors;
} }
@@ -944,7 +1066,8 @@ function referencedGameClientBridgeSchemas(manifest: unknown): GameClientBridgeS
type BridgeCommand = { payloadSchemaRef?: string; resultSchemaRef?: string }; type BridgeCommand = { payloadSchemaRef?: string; resultSchemaRef?: string };
type BridgeSnapshot = { schemaRef?: string }; type BridgeSnapshot = { schemaRef?: string };
type BridgeQueryTemplate = { parameterSchemaRef?: string; resultSchemaRef?: string }; type BridgeQueryTemplate = { parameterSchemaRef?: string; resultSchemaRef?: string };
const bridge = (manifest as { gameClientBridge?: { commands?: BridgeCommand[]; snapshots?: BridgeSnapshot[]; queryTemplates?: BridgeQueryTemplate[] } }).gameClientBridge; type BridgeOperationTemplate = { payloadSchemaRef?: string; resultSchemaRef?: string; confirmationSchemaRef?: string };
const bridge = (manifest as { gameClientBridge?: { commands?: BridgeCommand[]; snapshots?: BridgeSnapshot[]; queryTemplates?: BridgeQueryTemplate[]; operationTemplates?: BridgeOperationTemplate[] } }).gameClientBridge;
if (!bridge) { if (!bridge) {
return []; return [];
} }
@@ -970,6 +1093,17 @@ function referencedGameClientBridgeSchemas(manifest: unknown): GameClientBridgeS
refs.push({ location: `manifest.gameClientBridge.queryTemplates[${index}].resultSchemaRef`, ref: queryTemplate.resultSchemaRef }); refs.push({ location: `manifest.gameClientBridge.queryTemplates[${index}].resultSchemaRef`, ref: queryTemplate.resultSchemaRef });
} }
} }
for (const [index, operationTemplate] of (bridge.operationTemplates ?? []).entries()) {
if (operationTemplate.payloadSchemaRef) {
refs.push({ location: `manifest.gameClientBridge.operationTemplates[${index}].payloadSchemaRef`, ref: operationTemplate.payloadSchemaRef });
}
if (operationTemplate.resultSchemaRef) {
refs.push({ location: `manifest.gameClientBridge.operationTemplates[${index}].resultSchemaRef`, ref: operationTemplate.resultSchemaRef });
}
if (operationTemplate.confirmationSchemaRef) {
refs.push({ location: `manifest.gameClientBridge.operationTemplates[${index}].confirmationSchemaRef`, ref: operationTemplate.confirmationSchemaRef });
}
}
return refs; return refs;
} }
+42
View File
@@ -266,11 +266,52 @@ export interface GameClientBridgeQueryTemplateDeclaration {
timeoutSeconds: number; timeoutSeconds: number;
} }
export type GameClientBridgeOperationKind = "rcon" | "sqlite-mutation";
export interface GameClientBridgeOperationSafety {
requiresApproval?: boolean;
requiresOfflinePlayer?: boolean;
requiresMaintenanceWindow?: boolean;
requiresBeforeValue?: boolean;
requiresConfirmation?: boolean;
backupRequired?: boolean;
}
export interface GameClientBridgeOperationMutationDeclaration {
fieldKey: string;
tableKey: string;
identityKey: string;
valueKey: string;
confirmationQueryKey: string;
allowedValueType: "integer" | "number" | "string" | "boolean";
minValue?: number;
maxValue?: number;
}
export interface GameClientBridgeOperationTemplateDeclaration {
key: string;
title: string;
permission: PluginPermission;
approvalLevel: Exclude<GameClientBridgeApprovalLevel, "none">;
kind: GameClientBridgeOperationKind;
transportKey: string;
targetKey: string;
payloadSchemaRef: string;
resultSchemaRef?: string;
confirmationSchemaRef?: string;
timeoutSeconds: number;
maxPayloadBytes: number;
maxRowsAffected?: number;
mutation?: GameClientBridgeOperationMutationDeclaration;
safety?: GameClientBridgeOperationSafety;
}
export interface GameClientBridgePageContract { export interface GameClientBridgePageContract {
pageKey: string; pageKey: string;
commandTypes?: string[]; commandTypes?: string[];
snapshotTypes?: string[]; snapshotTypes?: string[];
queryTemplateKeys?: string[]; queryTemplateKeys?: string[];
operationKeys?: string[];
featureKeys?: string[]; featureKeys?: string[];
} }
@@ -296,6 +337,7 @@ export interface GameClientBridgeManifest {
commands: GameClientBridgeCommandDeclaration[]; commands: GameClientBridgeCommandDeclaration[];
snapshots: GameClientBridgeSnapshotDeclaration[]; snapshots: GameClientBridgeSnapshotDeclaration[];
queryTemplates?: GameClientBridgeQueryTemplateDeclaration[]; queryTemplates?: GameClientBridgeQueryTemplateDeclaration[];
operationTemplates?: GameClientBridgeOperationTemplateDeclaration[];
commandRetentionSeconds: number; commandRetentionSeconds: number;
maxCommands: number; maxCommands: number;
pages?: GameClientBridgePageContract[]; pages?: GameClientBridgePageContract[];
+170 -44
View File
@@ -26,6 +26,7 @@ import {
parseBridgeExecutionResponse, parseBridgeExecutionResponse,
parseAIInvocationResponse, parseAIInvocationResponse,
type GameClientBridgeQueryTemplateDeclaration, type GameClientBridgeQueryTemplateDeclaration,
type GameClientBridgeOperationTemplateDeclaration,
type GameClientBridgeProtectedRequestDeclaration, type GameClientBridgeProtectedRequestDeclaration,
type GameClientBridgeCompanionDeclaration, type GameClientBridgeCompanionDeclaration,
type GamePluginManifest, type GamePluginManifest,
@@ -54,6 +55,7 @@ type MutableBridgeManifest = {
commands: Array<Record<string, unknown>>; commands: Array<Record<string, unknown>>;
snapshots: Array<Record<string, unknown>>; snapshots: Array<Record<string, unknown>>;
queryTemplates?: Array<Record<string, unknown>>; queryTemplates?: Array<Record<string, unknown>>;
operationTemplates?: Array<Record<string, unknown>>;
commandRetentionSeconds: number; commandRetentionSeconds: number;
maxCommands: number; maxCommands: number;
pages: Array<Record<string, unknown>>; pages: Array<Record<string, unknown>>;
@@ -92,30 +94,44 @@ function validateTemporaryBridgeManifest(mutate?: (manifest: MutableBridgeManife
fs.cpSync(path.join(pluginsRoot, "examples/dev-game-plugin"), fixtureDir, { recursive: true }); fs.cpSync(path.join(pluginsRoot, "examples/dev-game-plugin"), fixtureDir, { recursive: true });
const manifestPath = path.join(fixtureDir, "manifest.json"); const manifestPath = path.join(fixtureDir, "manifest.json");
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")) as MutableBridgeManifest; const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")) as MutableBridgeManifest;
manifest.capabilities = [...manifest.capabilities, "remote.run.db.sqlite.query"]; manifest.capabilities = [...manifest.capabilities, "remote.run.db.sqlite.query", "remote.run.protected.rcon", "remote.run.protected.sql"];
manifest.permissions = [...manifest.permissions, "server.game-client.command", "server.game-client.read"]; manifest.permissions = [...manifest.permissions, "server.game-client.command", "server.game-client.read", "server.game-client.maintenance"];
manifest.remoteAccess = { methods: ["run"], runCapabilities: ["remote.run.db.sqlite.query"], databaseEngines: ["sqlite"] }; manifest.remoteAccess = { methods: ["run"], runCapabilities: ["remote.run.db.sqlite.query", "remote.run.protected.rcon", "remote.run.protected.sql"], databaseEngines: ["sqlite"] };
manifest.runtimeProfiles = { manifest.runtimeProfiles = {
transportProfiles: [{ key: "sqlite-db", kind: "sqlite", targetKey: "db/sqlite", capabilities: ["remote.run.db.sqlite.query"] }] transportProfiles: [
{ key: "sqlite-db", kind: "sqlite", targetKey: "db/sqlite", capabilities: ["remote.run.db.sqlite.query"] },
{ key: "scum-rcon", kind: "rcon", targetKey: "scum-rcon", capabilities: ["remote.run.protected.rcon"] },
{ key: "scum-mutation-db", kind: "sqlite", targetKey: "scum-mutation-db", capabilities: ["remote.run.protected.sql"] }
]
}; };
const overviewPage = manifest.pages?.find((page) => page.key === "overview"); const overviewPage = manifest.pages?.find((page) => page.key === "overview");
if (overviewPage) { if (overviewPage) {
overviewPage.permissions = [...(overviewPage.permissions ?? []), "server.game-client.read", "server.remote.access"]; overviewPage.permissions = [...(overviewPage.permissions ?? []), "server.game-client.read", "server.game-client.command", "server.game-client.maintenance", "server.remote.access"];
overviewPage.bridgeActions = [...(overviewPage.bridgeActions ?? []), "remote.access.request"]; overviewPage.bridgeActions = [...(overviewPage.bridgeActions ?? []), "remote.access.request"];
} }
manifest.gameClientBridge = { manifest.gameClientBridge = {
commands: [{ type: "announcement.send", title: "Send announcement", permission: "server.game-client.command", approvalLevel: "operator", payloadSchemaRef: "schemas/bridge/announcement.schema.json", resultSchemaRef: "schemas/bridge/announcement-result.schema.json", timeoutSeconds: 60, maxPayloadBytes: 4096 }], commands: [{ type: "announcement.send", title: "Send announcement", permission: "server.game-client.command", approvalLevel: "operator", payloadSchemaRef: "schemas/bridge/announcement.schema.json", resultSchemaRef: "schemas/bridge/announcement-result.schema.json", timeoutSeconds: 60, maxPayloadBytes: 4096 }],
snapshots: [{ type: "players", schemaVersion: "1", schemaRef: "schemas/bridge/players.schema.json", keepForSeconds: 3600, maxRecords: 100 }], snapshots: [{ type: "players", schemaVersion: "1", schemaRef: "schemas/bridge/players.schema.json", keepForSeconds: 3600, maxRecords: 100 }],
queryTemplates: [{ key: "player.by-id", title: "Find player by ID", permission: "server.game-client.read", engine: "sqlite", transportKey: "sqlite-db", targetKey: "db/sqlite", parameterSchemaRef: "schemas/bridge/player-by-id.parameters.schema.json", resultSchemaRef: "schemas/bridge/player-by-id.result.schema.json", maxRows: 1, timeoutSeconds: 10 }], queryTemplates: [{ key: "player.by-id", title: "Find player by ID", permission: "server.game-client.read", engine: "sqlite", transportKey: "sqlite-db", targetKey: "db/sqlite", parameterSchemaRef: "schemas/bridge/player-by-id.parameters.schema.json", resultSchemaRef: "schemas/bridge/player-by-id.result.schema.json", maxRows: 1, timeoutSeconds: 10 }],
operationTemplates: [
{ key: "player.fame.set", title: "Set player fame", permission: "server.game-client.command", approvalLevel: "operator", kind: "rcon", transportKey: "scum-rcon", targetKey: "scum-rcon", payloadSchemaRef: "schemas/bridge/player-fame-set.payload.schema.json", resultSchemaRef: "schemas/bridge/player-fame-set.result.schema.json", confirmationSchemaRef: "schemas/bridge/player-fame-set.confirmation.schema.json", timeoutSeconds: 60, maxPayloadBytes: 2048, safety: { requiresApproval: true, requiresConfirmation: true } },
{ key: "player.attribute.855.set", title: "Set player attribute 855", permission: "server.game-client.maintenance", approvalLevel: "platform-admin", kind: "sqlite-mutation", transportKey: "scum-mutation-db", targetKey: "scum-mutation-db", payloadSchemaRef: "schemas/bridge/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: { fieldKey: "855", tableKey: "prisoner", identityKey: "user_profile_id", valueKey: "value", confirmationQueryKey: "player.by-id", allowedValueType: "integer", minValue: 0, maxValue: 100000 }, safety: { requiresApproval: true, requiresOfflinePlayer: true, requiresBeforeValue: true, requiresConfirmation: true, backupRequired: true } }
],
commandRetentionSeconds: 86400, commandRetentionSeconds: 86400,
maxCommands: 1000, maxCommands: 1000,
pages: [{ pageKey: "overview", commandTypes: ["announcement.send"], snapshotTypes: ["players"], queryTemplateKeys: ["player.by-id"] }] pages: [{ pageKey: "overview", commandTypes: ["announcement.send"], snapshotTypes: ["players"], queryTemplateKeys: ["player.by-id"], operationKeys: ["player.fame.set", "player.attribute.855.set"] }]
}; };
writeFixtureJSON(fixtureDir, "schemas/bridge/announcement.schema.json", bridgeObjectSchema({ message: { type: "string", minLength: 1, maxLength: 200 } }, ["message"])); writeFixtureJSON(fixtureDir, "schemas/bridge/announcement.schema.json", bridgeObjectSchema({ message: { type: "string", minLength: 1, maxLength: 200 } }, ["message"]));
writeFixtureJSON(fixtureDir, "schemas/bridge/announcement-result.schema.json", bridgeObjectSchema({ accepted: { type: "boolean" } }, ["accepted"])); writeFixtureJSON(fixtureDir, "schemas/bridge/announcement-result.schema.json", bridgeObjectSchema({ accepted: { type: "boolean" } }, ["accepted"]));
writeFixtureJSON(fixtureDir, "schemas/bridge/players.schema.json", bridgeObjectSchema({ players: { type: "array", maxItems: 100, items: bridgeObjectSchema({ id: { type: "string", minLength: 1, maxLength: 80 } }, ["id"]) } }, ["players"])); writeFixtureJSON(fixtureDir, "schemas/bridge/players.schema.json", bridgeObjectSchema({ players: { type: "array", maxItems: 100, items: bridgeObjectSchema({ id: { type: "string", minLength: 1, maxLength: 80 } }, ["id"]) } }, ["players"]));
writeFixtureJSON(fixtureDir, "schemas/bridge/player-by-id.parameters.schema.json", bridgeObjectSchema({ playerId: { type: "string", minLength: 1, maxLength: 96 } }, ["playerId"])); writeFixtureJSON(fixtureDir, "schemas/bridge/player-by-id.parameters.schema.json", bridgeObjectSchema({ playerId: { type: "string", minLength: 1, maxLength: 96 } }, ["playerId"]));
writeFixtureJSON(fixtureDir, "schemas/bridge/player-by-id.result.schema.json", bridgeObjectSchema({ players: { type: "array", maxItems: 1, items: bridgeObjectSchema({ playerId: { type: "string", minLength: 1, maxLength: 96 } }, ["playerId"]) } }, ["players"])); writeFixtureJSON(fixtureDir, "schemas/bridge/player-by-id.result.schema.json", bridgeObjectSchema({ players: { type: "array", maxItems: 1, items: bridgeObjectSchema({ playerId: { type: "string", minLength: 1, maxLength: 96 } }, ["playerId"]) } }, ["players"]));
writeFixtureJSON(fixtureDir, "schemas/bridge/player-fame-set.payload.schema.json", bridgeObjectSchema({ playerId: { type: "string", minLength: 1, maxLength: 96 }, fame: { type: "integer", minimum: 0, maximum: 2147483647 } }, ["playerId", "fame"]));
writeFixtureJSON(fixtureDir, "schemas/bridge/player-fame-set.result.schema.json", bridgeObjectSchema({ outcome: { enum: ["queued", "succeeded", "failed", "unknown"] } }, ["outcome"]));
writeFixtureJSON(fixtureDir, "schemas/bridge/player-fame-set.confirmation.schema.json", bridgeObjectSchema({ playerId: { type: "string" }, fame: { type: "integer" } }, ["playerId", "fame"]));
writeFixtureJSON(fixtureDir, "schemas/bridge/player-attribute-855-set.payload.schema.json", bridgeObjectSchema({ playerId: { type: "string", minLength: 1, maxLength: 96 }, before: { type: "number" }, after: { type: "number" }, safetyWindow: { type: "string", minLength: 1, maxLength: 96 } }, ["playerId", "before", "after", "safetyWindow"]));
writeFixtureJSON(fixtureDir, "schemas/bridge/player-attribute-855-set.result.schema.json", bridgeObjectSchema({ outcome: { enum: ["succeeded", "failed", "unknown"] }, rowsAffected: { type: "integer", minimum: 0, maximum: 1 } }, ["outcome", "rowsAffected"]));
writeFixtureJSON(fixtureDir, "schemas/bridge/player-attribute-855-set.confirmation.schema.json", bridgeObjectSchema({ playerId: { type: "string" }, value: { type: "number" } }, ["playerId", "value"]));
mutate?.(manifest, fixtureDir); mutate?.(manifest, fixtureDir);
writeFixtureJSON(fixtureDir, "manifest.json", manifest); writeFixtureJSON(fixtureDir, "manifest.json", manifest);
return validateManifestFile(manifestPath); return validateManifestFile(manifestPath);
@@ -174,19 +190,16 @@ describe("plugin manifest validation", () => {
expect(validateManifestFile("examples/scum-server-plugin/manifest.json")).toEqual([]); expect(validateManifestFile("examples/scum-server-plugin/manifest.json")).toEqual([]);
}); });
it("declares bounded protected SQL and management request surfaces", () => { it("removes raw protected SQL and management request command surfaces", () => {
const pluginDir = path.join(pluginsRoot, "examples/scum-server-plugin"); const pluginDir = path.join(pluginsRoot, "examples/scum-server-plugin");
const manifest = JSON.parse(fs.readFileSync(path.join(pluginDir, "manifest.json"), "utf8")) as { gameClientBridge: { commands: Array<{ type: string; payloadSchemaRef: string; resultSchemaRef?: string; protectedRequest?: { kind: string; textField: string; transportKey: string; targetKey: string } }> } }; const manifest = JSON.parse(fs.readFileSync(path.join(pluginDir, "manifest.json"), "utf8")) as { gameClientBridge: { commands: Array<{ type: string; protectedRequest?: { kind: string } }>; queryTemplates: Array<{ key: string }>; operationTemplates: Array<{ key: string; kind: string }> } };
const commands = manifest.gameClientBridge.commands.filter((candidate) => candidate.protectedRequest); const commands = manifest.gameClientBridge.commands.filter((candidate) => candidate.protectedRequest);
expect(commands.map((command) => command.protectedRequest?.kind)).toEqual(expect.arrayContaining(["sql", "rcon", "program"])); expect(commands).toEqual([]);
for (const command of commands) { expect(manifest.gameClientBridge.commands.map((command) => command.type)).not.toEqual(expect.arrayContaining(["config.read", "config.patch", "database.request", "management.rcon.request", "management.program.request"]));
expect(command.protectedRequest?.textField).toBe("requestText"); expect(manifest.gameClientBridge.queryTemplates.map((query) => query.key)).toEqual(expect.arrayContaining(["scum.player.profile", "scum.squads", "scum.squad-members", "scum.vehicles", "scum.flags", "scum.positions"]));
expect(command.protectedRequest?.transportKey).toBe(command.protectedRequest?.targetKey); expect(manifest.gameClientBridge.operationTemplates.map((operation) => operation.key)).toEqual(expect.arrayContaining(["player.fame.set", "player.currency.normal.set", "player.currency.gold.set", "player.notify", "reward.deliver", "player.attribute.855.set"]));
const payload = JSON.parse(fs.readFileSync(path.join(pluginDir, command.payloadSchemaRef), "utf8")); expect(manifest.gameClientBridge.operationTemplates.find((operation) => operation.key === "player.attribute.855.set")?.kind).toBe("sqlite-mutation");
const result = JSON.parse(fs.readFileSync(path.join(pluginDir, command.resultSchemaRef!), "utf8")); expect(fs.existsSync(path.join(pluginDir, "schemas/bridge/queries/SCUM_DB_CONTRACT.md"))).toBe(true);
expect(payload).toMatchObject({ additionalProperties: false, required: ["requestText"] });
expect(result).toMatchObject({ additionalProperties: false, properties: { outcome: { enum: ["succeeded", "failed", "unknown"] } } });
}
}); });
it("declares SCUM install/update and start lifecycle through plugin assets", () => { it("declares SCUM install/update and start lifecycle through plugin assets", () => {
@@ -257,7 +270,7 @@ describe("plugin manifest validation", () => {
expect(local?.capabilities).not.toContain("remote.run.rcon.command"); expect(local?.capabilities).not.toContain("remote.run.rcon.command");
expect(local?.transportKeys).not.toContain("rcon"); expect(local?.transportKeys).not.toContain("rcon");
expect(manifest.runtimeProfiles?.transportProfiles).toEqual(expect.arrayContaining([ expect(manifest.runtimeProfiles?.transportProfiles).toEqual(expect.arrayContaining([
expect.objectContaining({ key: "scum-database", kind: "sqlite", capabilities: ["remote.run.protected.sql"] }), expect.objectContaining({ key: "scum-database", kind: "sqlite", capabilities: expect.arrayContaining(["remote.run.db.sqlite.query", "remote.run.protected.sql"]) }),
expect.objectContaining({ key: "scum-management", kind: "rcon", capabilities: ["remote.run.protected.rcon"] }), expect.objectContaining({ key: "scum-management", kind: "rcon", capabilities: ["remote.run.protected.rcon"] }),
expect.objectContaining({ key: "scum-program", kind: "program", capabilities: ["remote.run.program.command"] }) expect.objectContaining({ key: "scum-program", kind: "program", capabilities: ["remote.run.program.command"] })
])); ]));
@@ -439,7 +452,7 @@ describe("plugin manifest validation", () => {
maxPayloadBytes: number; maxPayloadBytes: number;
}>; }>;
snapshots: Array<{ type: string; schemaVersion: string; schemaRef: string }>; snapshots: Array<{ type: string; schemaVersion: string; schemaRef: string }>;
pages: Array<{ pageKey: string; commandTypes?: string[]; snapshotTypes?: string[] }>; pages: Array<{ pageKey: string; commandTypes?: string[]; snapshotTypes?: string[]; queryTemplateKeys?: string[]; operationKeys?: string[] }>;
}; };
pages: Array<{ key: string; permissions?: string[] }>; pages: Array<{ key: string; permissions?: string[] }>;
fileWorkspace?: { fileWorkspace?: {
@@ -478,25 +491,22 @@ describe("plugin manifest validation", () => {
"maintenance.prepare" "maintenance.prepare"
])); ]));
expect(manifest.gameClientBridge.snapshots.map((snapshot) => snapshot.type)).toEqual(expect.arrayContaining(["companion.health", "online.sessions", "players", "squads", "vehicles", "flags"])); expect(manifest.gameClientBridge.snapshots.map((snapshot) => snapshot.type)).toEqual(expect.arrayContaining(["companion.health", "online.sessions", "players", "squads", "vehicles", "flags"]));
expect(manifest.gameClientBridge.pages.map((page) => page.pageKey)).toContain("files-config"); expect(manifest.gameClientBridge.pages.map((page) => page.pageKey)).toEqual(expect.arrayContaining(["players", "squads", "live-map", "gifts", "workflows"]));
expect(manifest.gameClientBridge.pages.find((page) => page.pageKey === "files-config")?.commandTypes).toEqual(expect.arrayContaining([ expect(manifest.gameClientBridge.pages.map((page) => page.pageKey)).not.toContain("files-config");
"announcement.send", expect(manifest.gameClientBridge.pages.find((page) => page.pageKey === "players")?.operationKeys).toEqual(expect.arrayContaining([
"companion.diagnostics", "player.fame.set",
"player.lookup", "player.currency.normal.set",
"reward.deliver", "player.currency.gold.set",
"event.start", "player.notify",
"restart.prepare", "player.attribute.855.set"
"maintenance.prepare"
])); ]));
expect(manifest.pages.map((page) => page.key)).toEqual(expect.arrayContaining(["files-config", "players", "squads", "live-map", "gifts"])); expect(manifest.gameClientBridge.pages.find((page) => page.pageKey === "workflows")?.queryTemplateKeys).toEqual(expect.arrayContaining(["scum.player.profile", "scum.squads", "scum.vehicles", "scum.flags", "scum.positions"]));
expect(manifest.pages.find((page) => page.key === "files-config")?.permissions).toEqual(expect.arrayContaining(["server.game-client.read", "server.game-client.command", "server.game-client.maintenance"])); expect(manifest.pages.map((page) => page.key)).toEqual(expect.arrayContaining(["players", "squads", "live-map", "gifts", "workflows"]));
expect(manifest.pages.map((page) => page.key)).not.toContain("files-config");
expect(manifest.pages.find((page) => page.key === "players")?.permissions).toEqual(expect.arrayContaining(["server.game-client.read", "server.game-client.command", "server.game-client.maintenance"])); expect(manifest.pages.find((page) => page.key === "players")?.permissions).toEqual(expect.arrayContaining(["server.game-client.read", "server.game-client.command", "server.game-client.maintenance"]));
expect(manifest.fileWorkspace?.defaultDirectoryKey).toBe("scum-config"); expect(manifest.fileWorkspace).toBeUndefined();
expect(manifest.fileWorkspace?.directories.map((directory) => directory.key)).toEqual(["scum-config", "scum-logs"]);
expect(manifest.fileWorkspace?.files.map((file) => file.key)).toEqual(expect.arrayContaining(["scum-server-settings", "scum-game-config", "scum-engine-config", "scum-game-user-settings", "scum-admin-log", "scum-chat-log", "scum-kill-log", "scum-login-log", "scum-server-log"]));
expect(manifest.fileWorkspace?.configFields.every((field) => field.fileKey === "scum-server-settings")).toBe(true);
expect(manifest.runtimeProfiles?.lifecycleProfiles?.find((profile) => profile.key === "scum-client")?.capabilities).not.toContain("remote.run.rcon.command"); expect(manifest.runtimeProfiles?.lifecycleProfiles?.find((profile) => profile.key === "scum-client")?.capabilities).not.toContain("remote.run.rcon.command");
expect(manifest.runtimeProfiles?.logSources?.map((source) => source.key)).toEqual(expect.arrayContaining(["scum-chat-events", "scum-server-events", "scum-client-events"])); expect(manifest.runtimeProfiles?.logSources?.map((source) => source.key)).toEqual(expect.arrayContaining(["scum-chat-events", "scum-server-events", "scum-login-events", "scum-client-events"]));
}); });
it("declares bounded and permissioned SCUM bridge commands", () => { it("declares bounded and permissioned SCUM bridge commands", () => {
@@ -601,17 +611,22 @@ describe("plugin manifest validation", () => {
} }
} }
const operationsPage = manifest.gameClientBridge.pages.find((page) => page.pageKey === "files-config"); const pageSnapshotTypes = manifest.gameClientBridge.pages.flatMap((page) => page.snapshotTypes ?? []);
expect(operationsPage?.snapshotTypes).toEqual(expect.arrayContaining(expectedTypes)); expect(pageSnapshotTypes).toEqual(expect.arrayContaining(["online.sessions", "players", "squads", "vehicles", "flags"]));
expect(manifest.gameClientBridge.pages.find((page) => page.pageKey === "players")?.snapshotTypes).toEqual(expect.arrayContaining(["players", "online.sessions"]));
expect(manifest.gameClientBridge.pages.find((page) => page.pageKey === "live-map")?.snapshotTypes).toEqual(expect.arrayContaining(["players", "vehicles", "flags"]));
}); });
it("does not declare direct database query templates", () => { it("declares typed SCUM.db query templates without browser-visible SQL", () => {
const pluginDir = path.join(pluginsRoot, "examples/scum-server-plugin"); const pluginDir = path.join(pluginsRoot, "examples/scum-server-plugin");
const manifest = JSON.parse(fs.readFileSync(path.join(pluginDir, "manifest.json"), "utf8")) as { const manifest = JSON.parse(fs.readFileSync(path.join(pluginDir, "manifest.json"), "utf8")) as {
permissions: string[]; permissions: string[];
capabilities: string[];
remoteAccess?: { runCapabilities?: string[]; databaseEngines?: string[] };
gameClientBridge: { gameClientBridge: {
queryTemplates: Array<{ queryTemplates: Array<{
key: string; key: string;
title?: string;
permission: string; permission: string;
engine: string; engine: string;
transportKey: string; transportKey: string;
@@ -626,12 +641,72 @@ describe("plugin manifest validation", () => {
pages: Array<{ key: string; permissions?: string[]; bridgeActions?: string[] }>; pages: Array<{ key: string; permissions?: string[]; bridgeActions?: string[] }>;
runtimeProfiles?: { transportProfiles?: Array<{ key: string; kind: string; targetKey?: string; capabilities: string[] }> }; runtimeProfiles?: { transportProfiles?: Array<{ key: string; kind: string; targetKey?: string; capabilities: string[] }> };
}; };
const operationsPage = manifest.gameClientBridge.pages.find((page) => page.pageKey === "files-config"); const expectedKeys = ["scum.player.profile", "scum.squads", "scum.squad-members", "scum.vehicles", "scum.flags", "scum.positions"];
const operationsPluginPage = manifest.pages.find((page) => page.key === "files-config"); const templatesByKey = new Map(manifest.gameClientBridge.queryTemplates.map((template) => [template.key, template]));
expect(manifest.gameClientBridge.queryTemplates ?? []).toEqual([]); expect([...templatesByKey.keys()]).toEqual(expect.arrayContaining(expectedKeys));
expect(operationsPage?.queryTemplateKeys ?? []).toEqual([]); expect(manifest.capabilities).toContain("remote.run.db.sqlite.query");
expect(operationsPluginPage?.permissions).not.toContain("server.remote.access"); expect(manifest.remoteAccess?.runCapabilities).toContain("remote.run.db.sqlite.query");
expect(operationsPluginPage?.bridgeActions).not.toContain("remote.access.request"); expect(manifest.remoteAccess?.databaseEngines).toContain("sqlite");
const sqliteTransport = manifest.runtimeProfiles?.transportProfiles?.find((profile) => profile.key === "scum-database");
expect(sqliteTransport).toMatchObject({ kind: "sqlite", targetKey: "scum-database" });
expect(sqliteTransport?.capabilities).toEqual(expect.arrayContaining(["remote.run.db.sqlite.query"]));
for (const key of expectedKeys) {
const template = templatesByKey.get(key)!;
expect(template.engine).toBe("sqlite");
expect(template.transportKey).toBe("scum-database");
expect(template.targetKey).toBe("scum-database");
expect(JSON.stringify(template).toLowerCase()).not.toMatch(/select\s|from\s|sqlite:|scum\.db|databasepath|hostpath|dsn/);
const parameters = JSON.parse(fs.readFileSync(path.join(pluginDir, template.parameterSchemaRef), "utf8"));
const result = JSON.parse(fs.readFileSync(path.join(pluginDir, template.resultSchemaRef), "utf8"));
expect(parameters).toMatchObject({ type: "object", additionalProperties: false });
expect(result).toMatchObject({ type: "object", additionalProperties: false, required: ["rows"] });
expect(result.properties.rows.maxItems).toBeLessThanOrEqual(template.maxRows);
}
const playersPage = manifest.gameClientBridge.pages.find((page) => page.pageKey === "players");
const squadsPage = manifest.gameClientBridge.pages.find((page) => page.pageKey === "squads");
const mapPage = manifest.gameClientBridge.pages.find((page) => page.pageKey === "live-map");
expect(playersPage?.queryTemplateKeys).toEqual(expect.arrayContaining(["scum.player.profile", "scum.positions"]));
expect(squadsPage?.queryTemplateKeys).toEqual(expect.arrayContaining(["scum.squads", "scum.squad-members", "scum.flags"]));
expect(mapPage?.queryTemplateKeys).toEqual(expect.arrayContaining(["scum.vehicles", "scum.flags", "scum.positions"]));
for (const pageKey of ["players", "squads", "live-map"]) {
const pluginPage = manifest.pages.find((page) => page.key === pageKey);
expect(pluginPage?.permissions).toContain("server.game-client.read");
expect(pluginPage?.bridgeActions).toContain("remote.access.request");
}
});
it("declares typed SCUM RCON operations without arbitrary command inputs", () => {
const pluginDir = path.join(pluginsRoot, "examples/scum-server-plugin");
const manifest = JSON.parse(fs.readFileSync(path.join(pluginDir, "manifest.json"), "utf8")) as {
gameClientBridge: {
operationTemplates: Array<{ key: string; kind: string; permission: string; approvalLevel: string; payloadSchemaRef: string; resultSchemaRef?: string; confirmationSchemaRef?: string; safety?: Record<string, boolean> }>;
pages: Array<{ pageKey: string; operationKeys?: string[] }>;
};
pages: Array<{ key: string; permissions?: string[] }>;
};
const expectedKeys = ["player.fame.set", "player.currency.normal.set", "player.currency.gold.set", "player.notify", "reward.deliver"];
const operationsByKey = new Map(manifest.gameClientBridge.operationTemplates.map((operation) => [operation.key, operation]));
expect([...operationsByKey.keys()]).toEqual(expect.arrayContaining(expectedKeys));
for (const key of expectedKeys) {
const operation = operationsByKey.get(key)!;
expect(operation.kind).toBe("rcon");
expect(operation.permission).toBe("server.game-client.command");
expect(operation.approvalLevel).toBe("operator");
expect(operation.safety).toMatchObject({ requiresApproval: true, requiresConfirmation: true });
const payload = JSON.parse(fs.readFileSync(path.join(pluginDir, operation.payloadSchemaRef), "utf8"));
const result = JSON.parse(fs.readFileSync(path.join(pluginDir, operation.resultSchemaRef!), "utf8"));
const confirmation = JSON.parse(fs.readFileSync(path.join(pluginDir, operation.confirmationSchemaRef!), "utf8"));
expect(payload).toMatchObject({ type: "object", additionalProperties: false });
expect(result).toMatchObject({ type: "object", additionalProperties: false });
expect(confirmation).toMatchObject({ type: "object", additionalProperties: false });
expect(JSON.stringify(payload).toLowerCase()).not.toMatch(/rcon|commandtext|requesttext|sql|dsn|hostpath/);
}
const playersPage = manifest.gameClientBridge.pages.find((page) => page.pageKey === "players");
const giftsPage = manifest.gameClientBridge.pages.find((page) => page.pageKey === "gifts");
expect(playersPage?.operationKeys).toEqual(expect.arrayContaining(["player.fame.set", "player.currency.normal.set", "player.currency.gold.set", "player.notify"]));
expect(giftsPage?.operationKeys).toEqual(expect.arrayContaining(["reward.deliver", "player.notify"]));
expect(manifest.pages.find((page) => page.key === "players")?.permissions).toContain("server.game-client.command");
expect(manifest.pages.find((page) => page.key === "gifts")?.permissions).toContain("server.game-client.command");
}); });
it("declares typed SCUM semantic log events with bounded schemas", () => { it("declares typed SCUM semantic log events with bounded schemas", () => {
@@ -856,6 +931,35 @@ describe("plugin manifest validation", () => {
expect(actionErrors.some((error) => error.includes("page must declare remote.access.request"))).toBe(true); expect(actionErrors.some((error) => error.includes("page must declare remote.access.request"))).toBe(true);
}); });
it("validates typed operation templates and page operation bindings", () => {
expect(validateTemporaryBridgeManifest()).toEqual([]);
const unsafeKeyErrors = validateTemporaryBridgeManifest((manifest) => {
manifest.gameClientBridge.operationTemplates![0].key = "raw.sql.execute";
});
expect(unsafeKeyErrors.some((error) => error.includes("operationTemplates") && error.includes("arbitrary SQL"))).toBe(true);
const approvalErrors = validateTemporaryBridgeManifest((manifest) => {
manifest.gameClientBridge.operationTemplates![0].approvalLevel = "none";
});
expect(approvalErrors.some((error) => error.includes("approvalLevel") && error.includes("operator"))).toBe(true);
const rconTransportErrors = validateTemporaryBridgeManifest((manifest) => {
Object.assign(manifest.gameClientBridge.operationTemplates![0], { transportKey: "sqlite-db", targetKey: "db/sqlite" });
});
expect(rconTransportErrors.some((error) => error.includes("rcon operations require"))).toBe(true);
const mutationSafetyErrors = validateTemporaryBridgeManifest((manifest) => {
manifest.gameClientBridge.operationTemplates![1].safety = { requiresConfirmation: true };
});
expect(mutationSafetyErrors.some((error) => error.includes("sqlite-mutation operations require before value"))).toBe(true);
const pageErrors = validateTemporaryBridgeManifest((manifest) => {
manifest.gameClientBridge.pages[0].operationKeys = ["missing.operation"];
});
expect(pageErrors.some((error) => error.includes("undeclared operation template missing.operation"))).toBe(true);
});
it.each(["sqlText", "dsn", "hostPath", "shellCommand", "socketAddress", "accessToken", "credential"])("rejects unsafe query parameter schema field %s", (fieldName) => { it.each(["sqlText", "dsn", "hostPath", "shellCommand", "socketAddress", "accessToken", "credential"])("rejects unsafe query parameter schema field %s", (fieldName) => {
const errors = validateTemporaryBridgeManifest((_manifest, fixtureDir) => { const errors = validateTemporaryBridgeManifest((_manifest, fixtureDir) => {
writeFixtureJSON(fixtureDir, "schemas/bridge/player-by-id.parameters.schema.json", bridgeObjectSchema({ [fieldName]: { type: "string", minLength: 1, maxLength: 120 } }, [fieldName])); writeFixtureJSON(fixtureDir, "schemas/bridge/player-by-id.parameters.schema.json", bridgeObjectSchema({ [fieldName]: { type: "string", minLength: 1, maxLength: 120 } }, [fieldName]));
@@ -990,6 +1094,28 @@ describe("plugin SDK", () => {
expect(JSON.stringify(declaration).toLowerCase()).not.toMatch(/sqltext|dsn|hostpath|socket|credential/); expect(JSON.stringify(declaration).toLowerCase()).not.toMatch(/sqltext|dsn|hostpath|socket|credential/);
}); });
it("types controlled operation template declarations", () => {
const declaration: GameClientBridgeOperationTemplateDeclaration = {
key: "player.attribute.855.set",
title: "Set player attribute 855",
permission: "server.game-client.maintenance",
approvalLevel: "platform-admin",
kind: "sqlite-mutation",
transportKey: "scum-mutation-db",
targetKey: "scum-mutation-db",
payloadSchemaRef: "schemas/bridge/operations/player-attribute-855-set.payload.schema.json",
resultSchemaRef: "schemas/bridge/operations/player-attribute-855-set.result.schema.json",
confirmationSchemaRef: "schemas/bridge/operations/player-attribute-855-set.confirmation.schema.json",
timeoutSeconds: 120,
maxPayloadBytes: 4096,
maxRowsAffected: 1,
mutation: { fieldKey: "855", tableKey: "prisoner", identityKey: "user_profile_id", valueKey: "value", confirmationQueryKey: "player.lookup", allowedValueType: "integer", minValue: 0, maxValue: 100000 },
safety: { requiresApproval: true, requiresOfflinePlayer: true, requiresBeforeValue: true, requiresConfirmation: true, backupRequired: true }
};
expect(declaration).toMatchObject({ kind: "sqlite-mutation", approvalLevel: "platform-admin", maxRowsAffected: 1 });
expect(JSON.stringify(declaration).toLowerCase()).not.toMatch(/sqltext|dsn|hostpath|socket|credential|password/);
});
it("builds safe game-client bridge requests without component transport material", () => { it("builds safe game-client bridge requests without component transport material", () => {
const request = createGameClientBridgeQueueRequest({ const request = createGameClientBridgeQueueRequest({
profileKey: "scum-client", profileKey: "scum-client",
+82 -66
View File
@@ -9,18 +9,16 @@ import { configurationCatalog, validateConfigPatch, validateStatePatch, validate
import { scumMigrationParityFixtures } from "./fixtures/scum-migration-parity.js"; import { scumMigrationParityFixtures } from "./fixtures/scum-migration-parity.js";
const pageSource = readFileSync(resolve(dirname(fileURLToPath(import.meta.url)), "../examples/scum-server-plugin/features/page.ts"), "utf8"); const pageSource = readFileSync(resolve(dirname(fileURLToPath(import.meta.url)), "../examples/scum-server-plugin/features/page.ts"), "utf8");
const declaredWorkspace = { const projectionData = {
defaultDirectoryKey: "scum-config", players: [{ gamePlayerId: "steam-1", steamId: "76561198000000001", userProfileId: "profile-1", displayName: "Mira", squadName: "Wolves", online: true, famePoints: 42, normalBalance: 1000, goldBalance: 3, position: { x: 10, y: 20, z: 3, hasCoordinates: true }, freshness: { status: "fresh" }, unknownFields: { "855": 100 } }],
directories: [ squads: [{ squadId: "squad-1", name: "Wolves", memberCount: 3, leaderProfileId: "profile-1", freshness: { status: "fresh" } }],
{ key: "scum-config", label: "服务器配置", scope: "config" }, members: [{ gamePlayerId: "steam-1", displayName: "Mira", squadId: "squad-1", rank: "Leader", freshness: { status: "fresh" } }],
{ key: "scum-logs", label: "日志文件", scope: "logs" } vehicles: [{ vehicleId: "veh-1", label: "Laika", position: { subjectType: "vehicle", subjectId: "veh-1", x: 400, y: 200, z: 0, hasCoordinates: true }, freshness: { status: "fresh" } }],
], flags: [{ flagId: "flag-1", ownerSquadId: "squad-1", ownershipConfidence: "verified", position: { subjectType: "flag", subjectId: "flag-1", x: 100, y: 80, z: 0, hasCoordinates: true }, freshness: { status: "fresh" } }],
files: [ positions: [{ subjectType: "player", subjectId: "steam-1", gamePlayerId: "steam-1", x: 10, y: 20, z: 3, hasCoordinates: true, freshness: { status: "fresh" } }],
{ key: "scum-server-settings", directoryKey: "scum-config", label: "ServerSettings.ini", kind: "config", editable: true }, operations: [{ id: "op-1", templateKey: "player.fame.set", status: "waiting", safeSummary: { message: "awaiting approval" } }],
{ key: "scum-game-config", directoryKey: "scum-config", label: "Game.ini", kind: "config" }, workflows: [{ id: "wf-1", templateKey: "scum.world-refresh", status: "queued", currentStepKey: "read-positions", createdAt: "2026-08-10T00:00:00Z", safeSummary: { message: "world refresh queued" } }],
{ key: "scum-admin-log", directoryKey: "scum-logs", label: "Admin.log", kind: "log", streamKey: "scum.admin" } steps: [{ stepKey: "read-positions", status: "queued", capability: "remote.run.db.sqlite.query", safeSummary: { message: "queued safely" } }]
],
configFields: configurationCatalog
}; };
describe("SCUM plugin feature module", () => { describe("SCUM plugin feature module", () => {
@@ -64,64 +62,77 @@ describe("SCUM plugin feature module", () => {
expect(migrationStatus([...flags, flags[0]], "server-1", "configuration")).toMatchObject({ authority: "transitional-read-only", pluginWritesEnabled: false }); expect(migrationStatus([...flags, flags[0]], "server-1", "configuration")).toMatchObject({ authority: "transitional-read-only", pluginWritesEnabled: false });
}); });
it("renders the compact two-level file management workbench without legacy stacked panels", () => { it("renders projection-backed user management without raw file/config panels", () => {
const view = renderAndCollect(); const view = renderAndCollect();
expect(view.nodes).toContain("section:SCUM 文件管理"); expect(view.nodes).toContain("section:用户管理");
expect(view.nodes).toContain("aside:SCUM 文件两级菜单"); expect(view.texts.join("\n")).toContain("登录日志和 SCUM.db typed observations");
expect(view.nodes).toContain("article:文件 ServerSettings.ini"); expect(view.texts).toContain("投影/Companion 可用");
expect(view.texts.join("\n")).toContain("scum-config · 配置声明"); expect(view.texts).toContain("刷新投影");
expect(view.texts).toContain("刷新文件列表"); expect(view.texts).toContain("刷新真实数据");
expect(view.texts).toContain("ServerSettings.ini"); expect(view.texts).toContain("Mira");
expect(view.texts).toContain("Game.ini"); expect(view.texts.join("\n")).toContain("Steam 76561198000000001");
expect(view.texts).toContain("配置表单"); expect(view.texts.join("\n")).toContain("Profile profile-1");
expect(view.texts).toContain("键值视图"); expect(view.texts.join("\n")).toContain("Fame 42");
expect(view.texts).toContain("原文模式"); expect(view.buttons.find((button) => button.label === "Fame +100")?.disabled).toBe(false);
expect(view.buttons.find((button) => button.label === "刷新文件列表")?.disabled).toBe(false); expect(view.buttons.find((button) => button.label === "现金 +1000")?.disabled).toBe(false);
expect(view.buttons.find((button) => button.label === "读取文件")?.disabled).toBe(false); expect(view.buttons.find((button) => button.label === "855 审批")?.disabled).toBe(false);
expect(view.buttons.find((button) => button.label === "刷新结果")?.disabled).toBe(false); for (const removedText of ["ServerSettings.ini", "Game.ini", "配置表单", "键值视图", "原文模式", "读取文件", "提交写入"]) expect(view.texts.join("\n")).not.toContain(removedText);
for (const legacyText of ["玩家档案", "礼物", "受控状态", "载具", "地图轨迹", "查询玩家"]) expect(view.texts.join("\n")).not.toContain(legacyText);
}); });
it("does not invent a hardcoded SCUM file list when the platform workspace is missing", () => { it("does not invent fake players when projections are empty", () => {
const view = renderAndCollect({ workspace: {} }); const view = renderAndCollect({ data: { ...projectionData, players: [], positions: [] } });
expect(view.texts.join("\n")).toContain("当前插件没有可展示的声明文件。"); expect(view.texts.join("\n")).toContain("暂无玩家投影");
expect(view.texts).not.toContain("ServerSettings.ini"); expect(view.texts.join("\n")).toContain("不会显示假玩家");
expect(view.texts).not.toContain("Game.ini"); expect(view.texts).not.toContain("Mira");
expect(pageSource).not.toContain("fallbackFiles"); expect(pageSource).not.toContain("fallbackFiles");
expect(pageSource).not.toContain("samplePlayers");
}); });
it("keeps declared log files in read-only raw view with encoding controls", () => { it("renders squad and flag governance from projections", () => {
const view = renderAndCollect({ directoryKey: "scum-logs", fileKey: "scum-admin-log" }); const view = renderAndCollect({ pageKey: "squads", pageTitle: "队伍管理" });
expect(view.nodes).toContain("article:文件 Admin.log"); expect(view.nodes).toContain("section:队伍管理");
expect(view.texts).toContain("UTF-8"); expect(view.texts).toContain("队伍");
expect(view.texts).toContain("UTF-16 LE"); expect(view.texts).toContain("成员 / 旗帜");
expect(view.texts.join("\n")).toContain("尚未读取此日志文件的受控内容。"); expect(view.texts).toContain("Wolves");
expect(view.nodes.some((node) => node.startsWith("textarea:"))).toBe(false); expect(view.texts.join("\n")).toContain("成员 3");
expect(view.texts).not.toContain("配置表单"); expect(view.texts.join("\n")).toContain("verified");
expect(view.texts).not.toContain("提交写入");
}); });
it("renders current config values, unknown fields, encoding switch, and guarded write actions after a read", () => { it("renders realtime map overlays without sample coordinates", () => {
const view = renderAndCollect({ snapshot: { serverInstanceId: "server-1", pluginId: "game.scum", key: "scum-server-settings", state: "ready", content: "ServerName=Qinghuo\nMaxPlayers=96\nCustomKey=keep\n", version: 3, checksum: "sha256:cfg", sizeBytes: 48 } }); const view = renderAndCollect({ pageKey: "live-map", pageTitle: "实时地图" });
expect(view.texts).toContain("UTF-8"); expect(view.nodes).toContain("section:实时地图");
expect(view.texts).toContain("UTF-16 LE"); expect(view.texts).toContain("地图覆盖物");
expect(view.texts).toContain("未建模配置项"); expect(view.texts.join("\n")).toContain("坐标点");
expect(view.texts).toContain("CustomKey"); expect(view.texts.join("\n")).toContain("X 10 / Y 20 / Z 3");
expect(pageSource).toContain('e("option", { value: "true" }, "是")'); expect(pageSource).toContain("map-projection-board");
expect(pageSource).toContain('type: "range"'); expect(pageSource).not.toContain("sampleCoordinates");
expect(view.buttons.find((button) => button.label === "预览改动")?.disabled).toBe(true);
expect(view.buttons.find((button) => button.label === "提交写入")?.disabled).toBe(true);
}); });
it("loads snapshots on selection or manual refresh and bounded post-request refresh", () => { it("renders gift and workflow typed status surfaces", () => {
expect(pageSource).toContain("getFileSnapshot(selectedFile.key)"); const gifts = renderAndCollect({ pageKey: "gifts", pageTitle: "礼包管理" });
expect(pageSource).toContain("刷新结果"); expect(gifts.nodes).toContain("section:礼包管理");
expect(pageSource).toContain("loadFileSnapshot(selectedFile.key, true)"); expect(gifts.texts.join("\n")).toContain("typed delivery workflow");
expect(gifts.buttons.find((button) => button.label === "创建礼包发放")?.disabled).toBe(false);
expect(gifts.buttons.find((button) => button.label === "发送通知")?.disabled).toBe(false);
const workflows = renderAndCollect({ pageKey: "workflows", pageTitle: "Workflow 状态" });
expect(workflows.nodes).toContain("section:Workflow 状态");
expect(workflows.texts.join("\n")).toContain("scum.world-refresh");
expect(workflows.texts.join("\n")).toContain("read-positions");
});
it("loads projections through typed workspace actions instead of file snapshots", () => {
expect(pageSource).toContain("listSCUMPlayers");
expect(pageSource).toContain("createSCUMOperation");
expect(pageSource).toContain("createSCUMWorkflow");
expect(pageSource).not.toContain("getFileSnapshot");
expect(pageSource).not.toContain("requestFile");
expect(pageSource).not.toContain("writeFile");
expect(pageSource).not.toContain("setInterval"); expect(pageSource).not.toContain("setInterval");
}); });
}); });
function renderAndCollect(options: { snapshot?: Record<string, unknown>; permissions?: string[]; directoryKey?: string; fileKey?: string; workspace?: Record<string, unknown> } = {}) { function renderAndCollect(options: { data?: typeof projectionData; permissions?: string[]; pageKey?: string; pageTitle?: string } = {}) {
const nodes: string[] = []; const nodes: string[] = [];
const texts: string[] = []; const texts: string[] = [];
const buttons: Array<{ label: string; disabled: boolean }> = []; const buttons: Array<{ label: string; disabled: boolean }> = [];
@@ -137,21 +148,26 @@ function renderAndCollect(options: { snapshot?: Record<string, unknown>; permiss
useEffect: () => undefined, useEffect: () => undefined,
useState: <T,>(initial: T | (() => T)): [T, (next: T | ((previous: T) => T)) => void] => { useState: <T,>(initial: T | (() => T)): [T, (next: T | ((previous: T) => T)) => void] => {
stateCall += 1; stateCall += 1;
if (stateCall === 1 && options.directoryKey) return [options.directoryKey as T, () => undefined]; if (stateCall === 1) return [{ status: "ready", data: options.data ?? projectionData } as T, () => undefined];
if (stateCall === 2 && options.fileKey) return [options.fileKey as T, () => undefined];
if (stateCall === 8 && options.snapshot) return [options.snapshot as T, () => undefined];
return [typeof initial === "function" ? (initial as () => T)() : initial, () => undefined]; return [typeof initial === "function" ? (initial as () => T)() : initial, () => undefined];
} }
}; };
renderPluginPage(react, { renderPluginPage(react, {
context: { serverInstanceId: "server-1", permissions: options.permissions ?? ["server.files.read", "server.files.write", "server.logs.read"] }, page: { key: options.pageKey ?? "players", title: options.pageTitle ?? "用户管理" },
availability: { available: true, features: [{ key: "config.manage", available: true }] }, context: { serverInstanceId: "server-1", permissions: options.permissions ?? ["server.game-client.read", "server.game-client.command", "server.game-client.maintenance"] },
workspace: options.workspace ?? declaredWorkspace, availability: { available: true, features: [{ key: "player.intelligence", available: true }] },
workspaceActions: { workspaceActions: {
refreshWorkspace: async () => declaredWorkspace, listSCUMPlayers: async () => ({ items: projectionData.players, count: projectionData.players.length }),
requestFile: async (fileKey: string) => ({ status: "queued", message: fileKey }), listSCUMSquads: async () => ({ items: projectionData.squads, count: projectionData.squads.length }),
getFileSnapshot: async (fileKey: string) => ({ serverInstanceId: "server-1", pluginId: "game.scum", key: fileKey, state: "not-read" }), listSCUMSquadMembers: async () => ({ items: projectionData.members, count: projectionData.members.length }),
writeFile: async (fileKey: string) => ({ status: "queued", message: fileKey }) listSCUMVehicles: async () => ({ items: projectionData.vehicles, count: projectionData.vehicles.length }),
listSCUMFlags: async () => ({ items: projectionData.flags, count: projectionData.flags.length }),
listSCUMPositions: async () => ({ items: projectionData.positions, count: projectionData.positions.length }),
listSCUMOperations: async () => ({ items: projectionData.operations, count: projectionData.operations.length }),
listSCUMWorkflows: async () => ({ items: projectionData.workflows, count: projectionData.workflows.length }),
listSCUMWorkflowSteps: async () => ({ items: projectionData.steps, count: projectionData.steps.length }),
createSCUMOperation: async () => ({ id: "op-new", status: "waiting" }),
createSCUMWorkflow: async () => ({ id: "wf-new", status: "queued" })
} }
}); });
return { nodes, texts, buttons }; return { nodes, texts, buttons };