Add SCUM capability negotiation gate

This commit is contained in:
npc0-hue
2026-08-13 15:10:57 +08:00
parent 4f55c71de5
commit d831e4ade9
12 changed files with 350 additions and 2 deletions
+1
View File
@@ -103,6 +103,7 @@ func (h *coreHandlers) register(mux *http.ServeMux) {
mux.HandleFunc("/api/v1/server-instances/{id}/game-gifts/{catalogId}/revisions", h.serverGameGiftCatalogRevisions)
mux.HandleFunc("/api/v1/server-instances/{id}/game-gift-grants", h.serverGameGiftGrants)
mux.HandleFunc("/api/v1/server-instances/{id}/game-gift-grants/{grantId}/approve", h.serverGameGiftGrantApprove)
mux.HandleFunc("/api/v1/server-instances/{id}/scum/capabilities", h.serverSCUMCapabilities)
mux.HandleFunc("/api/v1/server-instances/{id}/scum/schema-probe", h.serverSCUMSchemaProbe)
mux.HandleFunc("/api/v1/server-instances/{id}/scum/players", h.serverSCUMPlayers)
mux.HandleFunc("/api/v1/server-instances/{id}/scum/squads", h.serverSCUMSquads)
+1 -1
View File
@@ -161,7 +161,7 @@ Server-scoped terminal log streaming (`GET /api/v1/server-instances/{id}/logs/ev
Runtime distribution and client-manager APIs require the current bearer session, server visibility, plugin-declared permissions, complete runtime bindings only for actions that truly depend on external logical bindings, and platform-builder readiness. Run-side lifecycle commands separately require run endpoint capability support and use plugin-declared lifecycle actions without making manual runtime-profile binding a user prerequisite. Responses and audit summaries expose artifact IDs, job IDs, checksums, key generations, fingerprints, status, and redacted `secret://runtime-keys/.../current` refs only. They do not expose raw run keys, client-manager keys, FTP passwords, database DSNs, RCON passwords, host paths, direct sockets, run endpoint private addresses, build workspace paths, or large inline logs.
SCUM product APIs expose only safe local resource rows, capability availability, collected timestamps, and redacted status reasons. Removed legacy SCUM execution routes return `404` and dispatch no job. SCUM APIs never expose SCUM.db SQL text, DB paths, DSNs, RCON command text, raw protected request payloads, run sockets, host paths, or credentials.
SCUM product APIs expose only safe local resource rows, capability availability, collected timestamps, and redacted status reasons. `GET /api/v1/server-instances/{id}/scum/capabilities` returns the Platform-negotiated gate state for the active Run/plugin/adapter binding without dispatching a job. Removed legacy SCUM execution routes return `404` and dispatch no job. SCUM APIs never expose SCUM.db SQL text, DB paths, DSNs, RCON command text, raw protected request payloads, run sockets, host paths, or credentials.
`POST /api/v1/server-instances/workflows/create` requires only the plugin type and server name. A runtime binding may still be maintained internally for advanced logical transports, but browser lifecycle controls must not force operators to choose a runtime profile before start/stop or run-package generation when the plugin deployment/lifecycle declaration is sufficient. Platform builds distributions itself and never needs a registered Run endpoint with `distribution.build` to do so.
+13
View File
@@ -6,6 +6,19 @@ import (
"browser.local/platform/dto"
)
func (h *coreHandlers) serverSCUMCapabilities(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeMethodNotAllowed(w, http.MethodGet)
return
}
negotiation, err := h.core.NegotiateSCUMCapabilitiesForSession(bearerToken(r), r.PathValue("id"))
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, dto.SCUMCapabilityNegotiationFromDomain(negotiation))
}
func (h *coreHandlers) serverSCUMSchemaProbe(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeMethodNotAllowed(w, http.MethodPost)
+11
View File
@@ -60,6 +60,17 @@ func TestSCUMSchemaProbeEndpointQueuesPlatformScheduledDurableJob(t *testing.T)
router := NewAuthorizedRouterWithCore(core)
unauthorized := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/"+instance.ID+"/scum/schema-probe", map[string]string{"idempotencyKey": "probe-api-denied"}, "")
assertStatus(t, unauthorized, http.StatusUnauthorized)
capabilities := requestWithAuth(t, router, http.MethodGet, "/api/v1/server-instances/"+instance.ID+"/scum/capabilities", ``, auth.SessionID)
assertStatus(t, capabilities, http.StatusOK)
capabilityBody := capabilities.Body.String()
if !strings.Contains(capabilityBody, "schema-probe") || !strings.Contains(capabilityBody, "probeExecutorAvailable") {
t.Fatalf("capability negotiation response missing SCUM gates: %s", capabilityBody)
}
for _, forbidden := range []string{"SCUM.db", "sqlite_master", "SELECT", "C:\\", "secret://", "password"} {
if strings.Contains(capabilityBody, forbidden) {
t.Fatalf("capability negotiation response leaked forbidden material %q: %s", forbidden, capabilityBody)
}
}
recorder := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/"+instance.ID+"/scum/schema-probe", map[string]string{"idempotencyKey": "probe-api-current"}, auth.SessionID)
assertStatus(t, recorder, http.StatusAccepted)
body := recorder.Body.String()
+14
View File
@@ -477,6 +477,20 @@ type SCUMCapabilityGate struct {
Evidence SCUMCapabilityEvidence
}
type SCUMCapabilityNegotiation struct {
ServerInstanceID string
RunEndpointID string
RunBindingID string
PluginID string
PluginVersion string
AdapterVersion string
GameVersion string
DatabaseIdentity string
ProbeExecutorAvailable bool
EvaluatedAt time.Time
Gates []SCUMCapabilityGate
}
func EvaluateSCUMCapabilityGate(requirement SCUMCapabilityRequirement, evidence SCUMCapabilityEvidence, active SCUMBindingIdentity, probeExecutorAvailable bool, now time.Time) SCUMCapabilityGate {
gate := SCUMCapabilityGate{Capability: requirement.Capability, State: SCUMCapabilityGateDisabled, ReasonCode: SCUMSafeErrorProbeMissing, Reason: "current-service evidence is required before this SCUM capability can run"}
if !probeExecutorAvailable {
+22
View File
@@ -319,6 +319,20 @@ type SCUMCapabilityGateDTO struct {
Reason string `json:"reason"`
}
type SCUMCapabilityNegotiationDTO struct {
ServerInstanceID string `json:"serverInstanceId"`
RunEndpointID string `json:"runEndpointId"`
RunBindingID string `json:"runBindingId,omitempty"`
PluginID string `json:"pluginId"`
PluginVersion string `json:"pluginVersion"`
AdapterVersion string `json:"adapterVersion"`
GameVersion string `json:"gameVersion,omitempty"`
DatabaseIdentity string `json:"databaseIdentity"`
ProbeExecutorAvailable bool `json:"probeExecutorAvailable"`
EvaluatedAt time.Time `json:"evaluatedAt"`
Gates []SCUMCapabilityGateDTO `json:"gates"`
}
func SCUMSchemaProbeRequestToDomain(value SCUMSchemaProbeRequestDTO) domain.SCUMSchemaProbeRequest {
return domain.SCUMSchemaProbeRequest{RequestID: value.RequestID, JobID: value.JobID, Binding: scumBindingIdentityToDomain(value.Binding), Bounds: scumProbeBoundsToDomain(value.Bounds), RequestedAt: value.RequestedAt}
}
@@ -582,6 +596,14 @@ func SCUMCapabilityGateFromDomain(value domain.SCUMCapabilityGate) SCUMCapabilit
return SCUMCapabilityGateDTO{Capability: string(value.Capability), State: string(value.State), Enabled: value.Enabled, ReasonCode: string(value.ReasonCode), Reason: value.Reason}
}
func SCUMCapabilityNegotiationFromDomain(value domain.SCUMCapabilityNegotiation) SCUMCapabilityNegotiationDTO {
gates := make([]SCUMCapabilityGateDTO, len(value.Gates))
for index, gate := range value.Gates {
gates[index] = SCUMCapabilityGateFromDomain(gate)
}
return SCUMCapabilityNegotiationDTO{ServerInstanceID: value.ServerInstanceID, RunEndpointID: value.RunEndpointID, RunBindingID: value.RunBindingID, PluginID: value.PluginID, PluginVersion: value.PluginVersion, AdapterVersion: value.AdapterVersion, GameVersion: value.GameVersion, DatabaseIdentity: value.DatabaseIdentity, ProbeExecutorAvailable: value.ProbeExecutorAvailable, EvaluatedAt: value.EvaluatedAt, Gates: gates}
}
func scumBindingIdentityToDomain(value SCUMBindingIdentityDTO) domain.SCUMBindingIdentity {
return domain.SCUMBindingIdentity{ServerInstanceID: value.ServerInstanceID, RunBindingID: value.RunBindingID, RunEndpointID: value.RunEndpointID, PluginID: value.PluginID, PluginVersion: value.PluginVersion, AdapterVersion: value.AdapterVersion, GameVersion: value.GameVersion, DatabaseIdentity: value.DatabaseIdentity}
}
@@ -15,6 +15,8 @@ Every database-backed SCUM read/write capability is disabled until all of the fo
If any condition fails, APIs and plugin pages receive a safe disabled state such as `probe_missing`, `probe_executor_absent`, `binding_mismatch`, `fingerprint_mismatch`, `digest_mismatch`, `schema_incompatible`, or `evidence_expired`. Disabled states are ordinary availability results, not projection/audit/workflow work items.
Platform evaluates the full capability set through a read-only negotiation step for the active server instance. The negotiation combines the current server/plugin/Run endpoint/runtime binding, the plugin's per-capability requirements, the bound Run capability list, and the latest accepted typed terminal evidence for the same binding. It returns independent gates for schema probe, read-only SQLite-backed player/squad/vehicle/flag/position reads, typed RCON economy/gift commands, and guarded XML mutations. The negotiation route never dispatches a Run job and never treats another Run binding, plugin version, adapter version, schema fingerprint, database identity, or asset digest as compatible evidence.
## Probe request
`SCUMSchemaProbeRequest` is a Platform durable-job payload addressed through the active authenticated Run binding.
+1
View File
@@ -124,6 +124,7 @@ type Core interface {
ListBackupsForSession(string, domain.BackupFilter) ([]domain.BackupRecord, error)
ListRemoteAdapterDeclarationsForSession(string, string) ([]domain.RemoteAdapterDeclaration, error)
RequestRemoteAdapterForSession(string, domain.RemoteAdapterRequest) (domain.RemoteAdapterResult, error)
NegotiateSCUMCapabilitiesForSession(string, string) (domain.SCUMCapabilityNegotiation, error)
RequestSCUMSchemaProbeForSession(string, string, string) (domain.SCUMSchemaProbeRequest, domain.RemoteAdapterResult, error)
GetServerConfigForSession(string, string) (domain.ServerConfig, error)
GetDeclaredFileReadSnapshotForSession(string, string, string) (domain.DeclaredFileReadSnapshot, error)
+135
View File
@@ -17,6 +17,141 @@ const (
scumParsedLogBatchExecutionKind = "log.parsed-events"
)
func (svc *CoreService) NegotiateSCUMCapabilitiesForSession(sessionID, serverInstanceID string) (domain.SCUMCapabilityNegotiation, error) {
instance, err := svc.GetServerInstanceForSession(sessionID, serverInstanceID)
if err != nil {
return domain.SCUMCapabilityNegotiation{}, err
}
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
if err != nil {
return domain.SCUMCapabilityNegotiation{}, err
}
endpoint, err := svc.store.RunEndpoints().Get(instance.RunEndpointID)
if err != nil {
return domain.SCUMCapabilityNegotiation{}, err
}
adapterVersion := scumSchemaProbeAdapterVersion(plugin.SCUMLiveData)
active := domain.SCUMBindingIdentity{ServerInstanceID: instance.ID, RunEndpointID: instance.RunEndpointID, PluginID: plugin.ID, PluginVersion: plugin.Version, AdapterVersion: adapterVersion, DatabaseIdentity: scumSchemaProbeDatabaseIdentity(plugin.SCUMLiveData.Probe.TargetKey)}
binding, bindingErr := svc.runtimeBindingForServer(instance.ID)
if bindingErr == nil {
binding, bindingErr = normalizeRuntimeBinding(plugin, binding)
}
if bindingErr == nil {
active.RunBindingID = binding.ID
}
probeExecutorAvailable := scumRunCapabilityAvailable(endpoint, domain.SCUMDataCapabilitySchemaProbe)
negotiation := domain.SCUMCapabilityNegotiation{ServerInstanceID: instance.ID, RunEndpointID: instance.RunEndpointID, RunBindingID: active.RunBindingID, PluginID: plugin.ID, PluginVersion: plugin.Version, AdapterVersion: adapterVersion, GameVersion: active.GameVersion, DatabaseIdentity: active.DatabaseIdentity, ProbeExecutorAvailable: probeExecutorAvailable, EvaluatedAt: svc.now()}
evidenceByCapability := svc.latestSCUMCapabilityEvidenceByCapability(instance.ID)
for _, declaration := range plugin.SCUMLiveData.CapabilityGates {
gate := domain.SCUMCapabilityGate{Capability: declaration.Capability, State: domain.SCUMCapabilityGateDisabled, ReasonCode: domain.SCUMSafeErrorProbeMissing, Reason: safeSCUMGateReason(declaration.SafeReason, "current-service evidence is required before this SCUM capability can run")}
if requiredRunCapability := scumRequiredRunCapabilityForDataCapability(declaration.Capability); requiredRunCapability != "" && !containsString(endpoint.Capabilities, requiredRunCapability) {
gate.ReasonCode = domain.SCUMSafeErrorProbeExecutorAbsent
gate.Reason = "bound Run does not expose the generic executor required for this SCUM capability"
negotiation.Gates = append(negotiation.Gates, gate)
continue
}
if bindingErr != nil || binding.Status != domain.RuntimeBindingStatusComplete || binding.PluginVersion != plugin.Version {
gate.ReasonCode = domain.SCUMSafeErrorBindingMismatch
gate.Reason = "active runtime binding is missing, incomplete, or stale for this plugin version"
negotiation.Gates = append(negotiation.Gates, gate)
continue
}
if declaration.Gate != domain.SCUMCapabilityGateEnabled {
gate.ReasonCode = scumReasonCodeForEvidenceStatus(declaration.EvidenceStatus)
negotiation.Gates = append(negotiation.Gates, gate)
continue
}
requirement := domain.SCUMCapabilityRequirement{Capability: declaration.Capability, AdapterVersion: declaration.AdapterVersion, SchemaFingerprint: declaration.RequiredSchemaFingerprint, AssetDigests: domain.CopyStringSlice(declaration.RequiredAssetDigests)}
gate = domain.EvaluateSCUMCapabilityGate(requirement, evidenceByCapability[declaration.Capability], active, probeExecutorAvailable, negotiation.EvaluatedAt)
negotiation.Gates = append(negotiation.Gates, gate)
}
return negotiation, nil
}
func (svc *CoreService) latestSCUMCapabilityEvidenceByCapability(serverInstanceID string) map[domain.SCUMDataCapability]domain.SCUMCapabilityEvidence {
jobs, err := svc.store.Jobs().List(domain.JobFilter{ServerInstanceID: serverInstanceID})
if err != nil {
return map[domain.SCUMDataCapability]domain.SCUMCapabilityEvidence{}
}
evidence := map[domain.SCUMDataCapability]domain.SCUMCapabilityEvidence{}
for _, job := range jobs {
for _, candidate := range scumCapabilityEvidenceFromJob(job) {
current, exists := evidence[candidate.Capability]
if !exists || current.ObservedAt.Before(candidate.ObservedAt) {
evidence[candidate.Capability] = candidate
}
}
}
return evidence
}
func scumCapabilityEvidenceFromJob(job domain.Job) []domain.SCUMCapabilityEvidence {
var evidence []domain.SCUMCapabilityEvidence
if result := job.ExecutionResult.SQLiteSchemaProbe; result != nil {
status := domain.SCUMCapabilityEvidenceFailed
if result.Status == domain.SCUMSchemaProbeStatusSucceeded || result.Status == domain.SCUMCapabilityEvidenceCompatible {
status = domain.SCUMCapabilityEvidenceCompatible
} else if result.Status == domain.SCUMCapabilityEvidenceIncompatible {
status = domain.SCUMCapabilityEvidenceIncompatible
}
evidence = append(evidence, domain.SCUMCapabilityEvidence{Capability: domain.SCUMDataCapabilitySchemaProbe, Status: status, Binding: result.Binding, AdapterVersion: result.Binding.AdapterVersion, SchemaFingerprint: result.SchemaFingerprint, ProbeResultDigest: result.ResultDigest, ObservedAt: result.ObservedAt, SafeError: result.SafeError})
}
if result := job.ExecutionResult.SQLiteTemplate; result != nil {
evidence = append(evidence, domain.SCUMCapabilityEvidence{Capability: result.Capability, Status: scumTerminalEvidenceStatus(result.Status), Binding: result.Binding, AdapterVersion: result.AdapterVersion, SchemaFingerprint: result.SchemaFingerprint, ProbeResultDigest: result.ResultDigest, AssetDigests: []string{result.AssetDigest}, ObservedAt: result.ObservedAt, SafeError: result.SafeError})
}
if result := job.ExecutionResult.RCONTemplate; result != nil {
evidence = append(evidence, domain.SCUMCapabilityEvidence{Capability: result.Capability, Status: scumTerminalEvidenceStatus(result.Status), Binding: result.Binding, AdapterVersion: result.AdapterVersion, SchemaFingerprint: result.SchemaFingerprint, ProbeResultDigest: result.ResultDigest, AssetDigests: []string{result.AssetDigest}, ObservedAt: result.ObservedAt, SafeError: result.SafeError})
}
if result := job.ExecutionResult.GuardedMutation; result != nil {
evidence = append(evidence, domain.SCUMCapabilityEvidence{Capability: result.Capability, Status: scumTerminalEvidenceStatus(result.Status), Binding: result.Binding, AdapterVersion: result.AdapterVersion, SchemaFingerprint: result.SchemaFingerprint, ProbeResultDigest: result.ResultDigest, AssetDigests: []string{result.AssetDigest}, ObservedAt: result.ObservedAt, SafeError: result.SafeError})
}
return evidence
}
func scumTerminalEvidenceStatus(status domain.SCUMTerminalResultStatus) domain.SCUMCapabilityEvidenceStatus {
if status == domain.SCUMTerminalResultSucceeded {
return domain.SCUMCapabilityEvidenceCompatible
}
return domain.SCUMCapabilityEvidenceFailed
}
func scumRunCapabilityAvailable(endpoint domain.RunEndpoint, capability domain.SCUMDataCapability) bool {
return containsString(endpoint.Capabilities, scumRequiredRunCapabilityForDataCapability(capability))
}
func scumRequiredRunCapabilityForDataCapability(capability domain.SCUMDataCapability) string {
switch capability {
case domain.SCUMDataCapabilitySchemaProbe:
return domain.JobCapabilityRemoteRunDBSQLiteProbe
case domain.SCUMDataCapabilityPlayerRead, domain.SCUMDataCapabilityPlayerDetailRead, domain.SCUMDataCapabilitySquadRead, domain.SCUMDataCapabilitySquadMemberRead, domain.SCUMDataCapabilityVehicleRead, domain.SCUMDataCapabilityFlagRead, domain.SCUMDataCapabilityPositionRead:
return domain.JobCapabilityRemoteRunDBSQLiteQuery
case domain.SCUMDataCapabilityEconomyCommand, domain.SCUMDataCapabilityGiftCommand:
return domain.JobCapabilityRemoteRunProtectedRCON
case domain.SCUMDataCapabilityProfileXMLWrite:
return domain.JobCapabilityRemoteRunProtectedSQL
default:
return ""
}
}
func scumReasonCodeForEvidenceStatus(status domain.SCUMCapabilityEvidenceStatus) domain.SCUMSafeErrorCode {
switch status {
case domain.SCUMCapabilityEvidenceFailed:
return domain.SCUMSafeErrorProbeFailed
case domain.SCUMCapabilityEvidenceIncompatible:
return domain.SCUMSafeErrorSchemaIncompatible
default:
return domain.SCUMSafeErrorProbeMissing
}
}
func safeSCUMGateReason(value, fallback string) string {
if strings.TrimSpace(value) == "" {
return fallback
}
return value
}
func (svc *CoreService) RequestSCUMSchemaProbeForSession(sessionID, serverInstanceID, idempotencyKey string) (domain.SCUMSchemaProbeRequest, domain.RemoteAdapterResult, error) {
idempotencyKey = strings.TrimSpace(idempotencyKey)
if idempotencyKey == "" {
@@ -0,0 +1,121 @@
package service
import (
"strings"
"testing"
"time"
"browser.local/platform/domain"
"browser.local/platform/repo"
)
func TestSCUMCapabilityNegotiationEvaluatesActiveBindingEvidenceIndependently(t *testing.T) {
store := repo.NewMemoryStore()
svc := NewCoreService(store)
now := time.Date(2026, 8, 13, 7, 0, 0, 0, time.UTC)
svc.now = func() time.Time { return now }
sessionID := seedSCUMCapabilityNegotiationFixture(t, svc, store)
binding := scumCapabilityNegotiationBinding()
schema := scumNegotiationDigest("a")
playerAsset := scumNegotiationDigest("b")
if err := store.Jobs().Create(domain.Job{ID: "job-scum-probe", ServerInstanceID: binding.ServerInstanceID, RunEndpointID: binding.RunEndpointID, State: domain.JobStateSucceeded, ExecutionResult: domain.JobExecutionResult{Kind: scumSchemaProbeExecutionKind, SQLiteSchemaProbe: &domain.SCUMSchemaProbeResult{RequestID: "job-scum-probe", JobID: "job-scum-probe", Binding: binding, Status: domain.SCUMSchemaProbeStatusSucceeded, SourceFingerprint: scumNegotiationDigest("c"), SchemaFingerprint: schema, ObservedAt: now.Add(-2 * time.Minute), ResultDigest: scumNegotiationDigest("d"), Limits: domain.DefaultSCUMSchemaProbeBounds(), SafeError: domain.SCUMSafeError{Code: domain.SCUMSafeErrorNone}}}}); err != nil {
t.Fatalf("create probe evidence job: %v", err)
}
if err := store.Jobs().Create(domain.Job{ID: "job-scum-player-query", ServerInstanceID: binding.ServerInstanceID, RunEndpointID: binding.RunEndpointID, State: domain.JobStateSucceeded, ExecutionResult: domain.JobExecutionResult{Kind: scumSQLiteTemplateExecutionKind, SQLiteTemplate: &domain.SCUMSQLiteTemplateResult{RequestID: "job-scum-player-query", JobID: "job-scum-player-query", Binding: binding, Status: domain.SCUMTerminalResultSucceeded, Capability: domain.SCUMDataCapabilityPlayerRead, TargetKey: "scum-database", TemplateKey: "players-read", AdapterVersion: binding.AdapterVersion, SchemaFingerprint: schema, AssetDigest: playerAsset, ParameterDigest: scumNegotiationDigest("e"), SourceFingerprint: scumNegotiationDigest("f"), ObservedAt: now.Add(-time.Minute), ResultDigest: scumNegotiationDigest("1"), RowCount: 0, Limits: domain.DefaultSCUMSQLiteTemplateBounds(), SafeError: domain.SCUMSafeError{Code: domain.SCUMSafeErrorNone}}}}); err != nil {
t.Fatalf("create player evidence job: %v", err)
}
negotiation, err := svc.NegotiateSCUMCapabilitiesForSession(sessionID, binding.ServerInstanceID)
if err != nil {
t.Fatalf("negotiate capabilities: %v", err)
}
gates := map[domain.SCUMDataCapability]domain.SCUMCapabilityGate{}
for _, gate := range negotiation.Gates {
gates[gate.Capability] = gate
}
if !negotiation.ProbeExecutorAvailable || negotiation.RunBindingID != binding.RunBindingID || negotiation.DatabaseIdentity != binding.DatabaseIdentity {
t.Fatalf("unexpected negotiation identity: %+v", negotiation)
}
if gate := gates[domain.SCUMDataCapabilitySchemaProbe]; !gate.Enabled || gate.ReasonCode != domain.SCUMSafeErrorNone {
t.Fatalf("schema probe should be enabled from accepted probe evidence, got %+v", gate)
}
if gate := gates[domain.SCUMDataCapabilityPlayerRead]; !gate.Enabled || gate.ReasonCode != domain.SCUMSafeErrorNone {
t.Fatalf("players.read should be enabled from matching template evidence, got %+v", gate)
}
if gate := gates[domain.SCUMDataCapabilitySquadRead]; gate.Enabled || gate.ReasonCode != domain.SCUMSafeErrorProbeMissing {
t.Fatalf("squads.read should remain independently disabled without template evidence, got %+v", gate)
}
if gate := gates[domain.SCUMDataCapabilityEconomyCommand]; gate.Enabled || gate.ReasonCode != domain.SCUMSafeErrorProbeExecutorAbsent {
t.Fatalf("economy-command.write should remain disabled when Run lacks protected RCON, got %+v", gate)
}
}
func TestSCUMCapabilityNegotiationRejectsEvidenceFromAnotherBinding(t *testing.T) {
store := repo.NewMemoryStore()
svc := NewCoreService(store)
now := time.Date(2026, 8, 13, 7, 30, 0, 0, time.UTC)
svc.now = func() time.Time { return now }
sessionID := seedSCUMCapabilityNegotiationFixture(t, svc, store)
binding := scumCapabilityNegotiationBinding()
stale := binding
stale.RunBindingID = "runtime-binding-old"
if err := store.Jobs().Create(domain.Job{ID: "job-stale-player-query", ServerInstanceID: binding.ServerInstanceID, RunEndpointID: binding.RunEndpointID, State: domain.JobStateSucceeded, ExecutionResult: domain.JobExecutionResult{Kind: scumSQLiteTemplateExecutionKind, SQLiteTemplate: &domain.SCUMSQLiteTemplateResult{RequestID: "job-stale-player-query", JobID: "job-stale-player-query", Binding: stale, Status: domain.SCUMTerminalResultSucceeded, Capability: domain.SCUMDataCapabilityPlayerRead, TargetKey: "scum-database", TemplateKey: "players-read", AdapterVersion: binding.AdapterVersion, SchemaFingerprint: scumNegotiationDigest("a"), AssetDigest: scumNegotiationDigest("b"), ParameterDigest: scumNegotiationDigest("e"), SourceFingerprint: scumNegotiationDigest("f"), ObservedAt: now, ResultDigest: scumNegotiationDigest("1"), RowCount: 0, Limits: domain.DefaultSCUMSQLiteTemplateBounds(), SafeError: domain.SCUMSafeError{Code: domain.SCUMSafeErrorNone}}}}); err != nil {
t.Fatalf("create stale evidence job: %v", err)
}
negotiation, err := svc.NegotiateSCUMCapabilitiesForSession(sessionID, binding.ServerInstanceID)
if err != nil {
t.Fatalf("negotiate capabilities: %v", err)
}
for _, gate := range negotiation.Gates {
if gate.Capability == domain.SCUMDataCapabilityPlayerRead {
if gate.Enabled || gate.ReasonCode != domain.SCUMSafeErrorBindingMismatch {
t.Fatalf("players.read should reject stale binding evidence, got %+v", gate)
}
return
}
}
t.Fatal("players.read gate missing")
}
func seedSCUMCapabilityNegotiationFixture(t *testing.T, svc *CoreService, store *repo.MemoryStore) string {
t.Helper()
if _, err := svc.CreateUser(domain.User{ID: "scum-negotiation-owner", DisplayName: "SCUM Negotiation Owner", Email: "scum-negotiation@example.test", Status: domain.UserStatusActive, Roles: []string{"server-owner"}, PasswordHash: "secret-password"}); err != nil {
t.Fatalf("create user: %v", err)
}
auth, err := svc.LoginUser(domain.UserLogin{Account: "scum-negotiation@example.test", Password: "secret-password"})
if err != nil {
t.Fatalf("login: %v", err)
}
plugin := domain.GamePlugin{ID: "game.scum", Version: "1.0.0", Status: domain.GamePluginStatusInstalled, SCUMLiveData: domain.SCUMLiveDataManifest{SchemaVersion: "1", Probe: domain.SCUMSchemaProbeDeclaration{Capability: domain.JobCapabilityRemoteRunDBSQLiteProbe, TargetKey: "scum-database", Bounds: domain.DefaultSCUMSchemaProbeBounds()}, CapabilityGates: []domain.SCUMLiveDataCapabilityGateDeclaration{
{Capability: domain.SCUMDataCapabilitySchemaProbe, Gate: domain.SCUMCapabilityGateEnabled, AdapterVersion: "adapter-1", RequiredSchemaFingerprint: scumNegotiationDigest("a"), EvidenceStatus: domain.SCUMCapabilityEvidenceCompatible, SafeReason: "schema probe evidence is compatible"},
{Capability: domain.SCUMDataCapabilityPlayerRead, Gate: domain.SCUMCapabilityGateEnabled, AdapterVersion: "adapter-1", RequiredSchemaFingerprint: scumNegotiationDigest("a"), RequiredAssetDigests: []string{scumNegotiationDigest("b")}, EvidenceStatus: domain.SCUMCapabilityEvidenceCompatible, SafeReason: "players query evidence is compatible"},
{Capability: domain.SCUMDataCapabilitySquadRead, Gate: domain.SCUMCapabilityGateEnabled, AdapterVersion: "adapter-1", RequiredSchemaFingerprint: scumNegotiationDigest("a"), RequiredAssetDigests: []string{scumNegotiationDigest("2")}, EvidenceStatus: domain.SCUMCapabilityEvidenceCompatible, SafeReason: "squad query evidence is compatible"},
{Capability: domain.SCUMDataCapabilityEconomyCommand, Gate: domain.SCUMCapabilityGateEnabled, AdapterVersion: "adapter-1", RequiredSchemaFingerprint: scumNegotiationDigest("a"), RequiredAssetDigests: []string{scumNegotiationDigest("3")}, EvidenceStatus: domain.SCUMCapabilityEvidenceCompatible, SafeReason: "economy command evidence is compatible"},
{Capability: domain.SCUMDataCapabilityGiftCommand, Gate: domain.SCUMCapabilityGateDisabled, AdapterVersion: "adapter-1", RequiredSchemaFingerprint: scumNegotiationDigest("a"), EvidenceStatus: domain.SCUMCapabilityEvidenceMissing, SafeReason: "gift command evidence is missing"},
{Capability: domain.SCUMDataCapabilityProfileXMLWrite, Gate: domain.SCUMCapabilityGateDisabled, AdapterVersion: "adapter-1", RequiredSchemaFingerprint: scumNegotiationDigest("a"), EvidenceStatus: domain.SCUMCapabilityEvidenceMissing, SafeReason: "XML mutation evidence is missing"},
}}, RuntimeProfiles: domain.GamePluginRuntimeProfiles{LifecycleProfiles: []domain.RuntimeLifecycleProfile{{Key: "local", Mode: "local-process"}}}}
if err := store.GamePlugins().Create(plugin); err != nil {
t.Fatalf("create plugin: %v", err)
}
endpoint := domain.RunEndpoint{ID: "run-scum-negotiation", DisplayName: "Run SCUM Negotiation", Version: "0.1.0", Status: domain.RunEndpointStatusOnline, Capabilities: []string{domain.JobCapabilityRemoteRunDBSQLiteProbe, domain.JobCapabilityRemoteRunDBSQLiteQuery}}
if err := store.RunEndpoints().Create(endpoint); err != nil {
t.Fatalf("create endpoint: %v", err)
}
if err := store.ServerInstances().Create(domain.ServerInstance{ID: "server-scum-negotiation", PluginID: plugin.ID, PluginVersion: plugin.Version, RunEndpointID: endpoint.ID, Name: "SCUM Negotiation", OwnerUserID: "scum-negotiation-owner", State: domain.ServerInstanceStateRunning}); err != nil {
t.Fatalf("create server: %v", err)
}
if _, err := svc.UpdateServerRuntimeBindingForSession(auth.SessionID, "server-scum-negotiation", domain.RuntimeBindingUpdate{ProfileKey: "local", Bindings: map[string]string{}}); err != nil {
t.Fatalf("create runtime binding: %v", err)
}
return auth.SessionID
}
func scumCapabilityNegotiationBinding() domain.SCUMBindingIdentity {
return domain.SCUMBindingIdentity{ServerInstanceID: "server-scum-negotiation", RunBindingID: "runtime-binding-server-scum-negotiation", RunEndpointID: "run-scum-negotiation", PluginID: "game.scum", PluginVersion: "1.0.0", AdapterVersion: "adapter-1", DatabaseIdentity: "scum-database"}
}
func scumNegotiationDigest(char string) string {
return "sha256:" + strings.Repeat(char, 64)
}