Add SCUM capability negotiation gate
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user