Store SCUM facts in typed platform tables

Run pushes typed SCUM facts through POST /api/v1/run/scum/facts, but the
production router rejected that path before the signature middleware because
runServiceRequest listed individual Run channel prefixes, and MySQL kept SCUM
rows inside the whole metadata snapshot instead of platform tables.

- /api/v1/run/ is now the signed machine channel space while
  /api/v1/run/endpoints keeps normal bearer/admin authorization.
- MySQL gets real scum_user, scum_user_trajectory, scum_vehicle,
  scum_vehicle_trajectory and scum_vehicle_lock tables with parameterized
  per-row repositories instead of full snapshot rewrites. Snapshot-shaped
  tables from the unreleased interim build are replaced, and SCUM rows still
  inside a metadata snapshot are migrated once.
- Facts ingest verifies the target server plugin type and converges stale
  online users to offline after SCUMUserOfflineAfter.
- The plugin page and browser read one bounded /scum/surface response instead
  of five list calls per refresh.

Call-count budget for one server: per 5s facts batch, one SELECT plus one
INSERT/UPDATE per reported user and vehicle, one INSERT per moved trajectory
sample or new lock row, one bounded stale-user SELECT, and a trajectory
retention DELETE at most once per hour. One browser refresh issues one surface
request every 15s instead of five list requests.
This commit is contained in:
npc0-hue
2026-09-15 12:12:11 +08:00
parent 63e2340db7
commit a822e9250a
20 changed files with 1288 additions and 72 deletions
+9 -6
View File
@@ -40,12 +40,15 @@ func publicAPIRequest(r *http.Request) bool {
}
func runServiceRequest(r *http.Request) bool {
return strings.HasPrefix(r.URL.Path, "/api/v1/run/control/") ||
strings.HasPrefix(r.URL.Path, "/api/v1/run/lifecycle/") ||
strings.HasPrefix(r.URL.Path, "/api/v1/run/jobs/") ||
strings.HasPrefix(r.URL.Path, "/api/v1/run/logs/") ||
strings.HasPrefix(r.URL.Path, "/api/v1/run/artifacts/") ||
strings.HasPrefix(r.URL.Path, "/api/v1/run/metrics/")
// Run owns the authenticated machine channels under /run/. Keep the
// browser's endpoint-management API on the normal bearer/admin path, but do
// not maintain a list of individual Run channel prefixes here. New typed
// plugin channels (for example /run/scum/facts) must reach their own
// signature/session middleware instead of being rejected by this router.
path := strings.TrimSuffix(r.URL.Path, "/")
return strings.HasPrefix(path, "/api/v1/run/") &&
path != "/api/v1/run/endpoints" &&
!strings.HasPrefix(path, "/api/v1/run/endpoints/")
}
func platformAdminRequest(r *http.Request) bool {
+44
View File
@@ -263,6 +263,50 @@ func TestAuthorizedRouterAllowsRunLifecycleReportWithoutBearer(t *testing.T) {
}
}
func TestAuthorizedRouterAcceptsSignedRunSCUMFactsWithoutBearer(t *testing.T) {
store := repo.NewMemoryStore()
core := service.NewCoreService(store)
if _, err := core.CreateGamePlugin(validGamePluginRequest().ToDomain()); err != nil {
t.Fatalf("create plugin: %v", err)
}
if _, err := core.CreateRunEndpoint(validRunEndpointRequest().ToDomain()); err != nil {
t.Fatalf("create endpoint: %v", err)
}
if _, err := core.CreateServerInstance(domain.ServerInstance{ID: "server-scum-facts", PluginID: "server.scum", RunEndpointID: "run-local", Name: "SCUM Facts", State: domain.ServerInstanceStateReady, ConfigVersion: 1}); err != nil {
t.Fatalf("create server: %v", err)
}
router := NewAuthorizedRouterWithCore(core)
// The browser endpoint-management API under /run/ keeps the normal bearer path.
assertErrorResponse(t, performRaw(t, router, http.MethodGet, "/api/v1/run/endpoints", ""), http.StatusUnauthorized, errorCodeUnauthorized)
hello := decodeBody[dto.RunControlHelloResponse](t, performRunControlHello(t, router, validRunControlHelloRequest()))
observedAt := time.Now().UTC()
body, err := json.Marshal(dto.SCUMFactIngestRequest{
RunEndpointID: "run-local", SessionToken: hello.SessionToken, ServerInstanceID: "server-scum-facts",
Users: []dto.SCUMUserFactBody{{SteamID: "76561198000000009", DisplayName: "Signed Run", Online: true, Login: true, ObservedAt: observedAt, LoginObservedAt: observedAt, Position: &dto.SCUMPositionBody{X: 1, Y: 2, Z: 3}}},
})
if err != nil {
t.Fatalf("marshal scum facts: %v", err)
}
signed := signedRunRequest(t, router, "/api/v1/run/scum/facts", body, hello.SessionToken, "nonce-api-scum-facts", time.Now().UTC())
assertStatus(t, signed, http.StatusAccepted)
users, err := store.SCUMUsers().List(domain.SCUMUserFilter{ServerInstanceID: "server-scum-facts"})
if err != nil {
t.Fatalf("list scum users: %v", err)
}
if len(users) != 1 || users[0].SteamID != "76561198000000009" || !users[0].Online {
t.Fatalf("signed Run SCUM facts did not reach the platform tables: %+v", users)
}
tracks, err := store.SCUMUserTrajectories().List(domain.SCUMUserTrajectoryFilter{ServerInstanceID: "server-scum-facts"})
if err != nil {
t.Fatalf("list scum trajectories: %v", err)
}
if len(tracks) != 1 {
t.Fatalf("expected one platform trajectory for the reported position, got %+v", tracks)
}
}
func signedRunRequest(t *testing.T, router http.Handler, path string, body []byte, token string, nonce string, stamp time.Time) *httptest.ResponseRecorder {
t.Helper()
timestamp := strconv.FormatInt(stamp.Unix(), 10)
+1
View File
@@ -84,6 +84,7 @@ func (h *coreHandlers) register(mux *http.ServeMux) {
mux.HandleFunc("/api/v1/server-instances/{id}/scum/vehicles", h.serverSCUMVehicles)
mux.HandleFunc("/api/v1/server-instances/{id}/scum/vehicle-trajectories", h.serverSCUMVehicleTrajectories)
mux.HandleFunc("/api/v1/server-instances/{id}/scum/vehicle-locks", h.serverSCUMVehicleLocks)
mux.HandleFunc("/api/v1/server-instances/{id}/scum/surface", h.serverSCUMSurface)
mux.HandleFunc("/api/v1/server-instances/{id}/plugin-data/{collection}", h.serverPluginDataCollection)
mux.HandleFunc("/api/v1/server-instances/{id}/plugin-data/{collection}/transaction", h.serverPluginDataTransaction)
mux.HandleFunc("/api/v1/server-instances/{id}/dependencies/check", h.serverDependenciesCheck)
+1 -1
View File
@@ -154,7 +154,7 @@ Server-scoped terminal log streaming (`GET /api/v1/server-instances/{id}/logs/ev
Runtime distribution 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 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, 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 platform-owned `scum_user`, trajectory, vehicle, and lock table records plus typed operation/workflow requests, approval status, confirmation status, blocker reasons, and bounded summaries. They never expose direct game database SQL text, DB paths, DSNs, RCON command text, raw request payloads, run sockets, host paths, or credentials.
SCUM product APIs expose platform-owned `scum_user`, trajectory, vehicle, and lock table records plus typed operation/workflow requests, approval status, confirmation status, blocker reasons, and bounded summaries. The browser reads one bounded server surface (`GET /api/v1/server-instances/{id}/scum/surface`) instead of issuing one list call per table. Signed Run facts arrive through `POST /api/v1/run/scum/facts`; browser Run endpoint management under `/api/v1/run/endpoints` keeps the normal bearer/admin authorization while the remaining `/api/v1/run/` paths belong to the signed machine channels. These APIs never expose direct game database SQL text, DB paths, DSNs, RCON command text, raw 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.
+13
View File
@@ -94,6 +94,19 @@ func (h *coreHandlers) serverSCUMVehicleLocks(w http.ResponseWriter, r *http.Req
writeJSON(w, http.StatusOK, dto.SCUMVehicleLockListFromDomain(items))
}
func (h *coreHandlers) serverSCUMSurface(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeMethodNotAllowed(w, http.MethodGet)
return
}
value, err := h.core.GetSCUMSurfaceForSession(bearerToken(r), r.PathValue("id"))
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, dto.SCUMSurfaceFromDomain(value))
}
func (h *coreHandlers) runSCUMFacts(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeMethodNotAllowed(w, http.MethodPost)