feat(scum): gate live data on schema evidence
This commit is contained in:
@@ -633,6 +633,7 @@ type GamePluginManifest struct {
|
||||
RemoteAccess GamePluginRemoteAccess
|
||||
RuntimeProfiles GamePluginRuntimeProfiles
|
||||
GameClientBridge GameClientBridgeManifest
|
||||
SCUMLiveData SCUMLiveDataManifest
|
||||
MapTrajectories *GameMapTrajectoryDeclaration
|
||||
}
|
||||
|
||||
@@ -673,6 +674,7 @@ type GamePlugin struct {
|
||||
RemoteAccess GamePluginRemoteAccess
|
||||
RuntimeProfiles GamePluginRuntimeProfiles
|
||||
GameClientBridge GameClientBridgeManifest
|
||||
SCUMLiveData SCUMLiveDataManifest
|
||||
MapTrajectories *GameMapTrajectoryDeclaration
|
||||
ValidationViolations []string
|
||||
Status GamePluginStatus
|
||||
@@ -1078,6 +1080,7 @@ const (
|
||||
JobCapabilityRemoteRunProcessStart = "remote.run.process.start"
|
||||
JobCapabilityRemoteRunProcessStop = "remote.run.process.stop"
|
||||
JobCapabilityRemoteRunDBMySQLQuery = "remote.run.db.mysql.query"
|
||||
JobCapabilityRemoteRunDBSQLiteProbe = "remote.run.db.sqlite.probe"
|
||||
JobCapabilityRemoteRunDBSQLiteQuery = "remote.run.db.sqlite.query"
|
||||
JobCapabilityRemoteRunLogsTransfer = "remote.run.logs.transfer"
|
||||
JobCapabilityRemoteRunRCONCommand = "remote.run.rcon.command"
|
||||
@@ -1694,6 +1697,7 @@ func CopyGamePlugin(plugin GamePlugin) GamePlugin {
|
||||
plugin.RemoteAccess = CopyGamePluginRemoteAccess(plugin.RemoteAccess)
|
||||
plugin.RuntimeProfiles = CopyGamePluginRuntimeProfiles(plugin.RuntimeProfiles)
|
||||
plugin.GameClientBridge = CopyGameClientBridgeManifest(plugin.GameClientBridge)
|
||||
plugin.SCUMLiveData = CopySCUMLiveDataManifest(plugin.SCUMLiveData)
|
||||
if plugin.MapTrajectories != nil {
|
||||
value := CopyGameMapTrajectoryDeclaration(*plugin.MapTrajectories)
|
||||
plugin.MapTrajectories = &value
|
||||
@@ -1764,6 +1768,7 @@ func CopyGamePluginManifest(manifest GamePluginManifest) GamePluginManifest {
|
||||
manifest.RemoteAccess = CopyGamePluginRemoteAccess(manifest.RemoteAccess)
|
||||
manifest.RuntimeProfiles = CopyGamePluginRuntimeProfiles(manifest.RuntimeProfiles)
|
||||
manifest.GameClientBridge = CopyGameClientBridgeManifest(manifest.GameClientBridge)
|
||||
manifest.SCUMLiveData = CopySCUMLiveDataManifest(manifest.SCUMLiveData)
|
||||
if manifest.MapTrajectories != nil {
|
||||
value := CopyGameMapTrajectoryDeclaration(*manifest.MapTrajectories)
|
||||
manifest.MapTrajectories = &value
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
package domain
|
||||
|
||||
import "time"
|
||||
|
||||
type SCUMDataCapability string
|
||||
|
||||
const (
|
||||
SCUMDataCapabilitySchemaProbe SCUMDataCapability = "schema-probe"
|
||||
SCUMDataCapabilityPlayerRead SCUMDataCapability = "players.read"
|
||||
SCUMDataCapabilityPlayerDetailRead SCUMDataCapability = "player-details.read"
|
||||
SCUMDataCapabilitySquadRead SCUMDataCapability = "squads.read"
|
||||
SCUMDataCapabilitySquadMemberRead SCUMDataCapability = "squad-members.read"
|
||||
SCUMDataCapabilityVehicleRead SCUMDataCapability = "vehicles.read"
|
||||
SCUMDataCapabilityFlagRead SCUMDataCapability = "flags.read"
|
||||
SCUMDataCapabilityPositionRead SCUMDataCapability = "positions.read"
|
||||
SCUMDataCapabilityProfileXMLWrite SCUMDataCapability = "profile-xml.write"
|
||||
SCUMDataCapabilityEconomyCommand SCUMDataCapability = "economy-command.write"
|
||||
SCUMDataCapabilityGiftCommand SCUMDataCapability = "gift-command.write"
|
||||
)
|
||||
|
||||
type SCUMCapabilityEvidenceStatus string
|
||||
|
||||
const (
|
||||
SCUMCapabilityEvidenceMissing SCUMCapabilityEvidenceStatus = "missing"
|
||||
SCUMCapabilityEvidenceCompatible SCUMCapabilityEvidenceStatus = "compatible"
|
||||
SCUMCapabilityEvidenceIncompatible SCUMCapabilityEvidenceStatus = "incompatible"
|
||||
SCUMCapabilityEvidenceFailed SCUMCapabilityEvidenceStatus = "failed"
|
||||
)
|
||||
|
||||
type SCUMCapabilityGateState string
|
||||
|
||||
const (
|
||||
SCUMCapabilityGateEnabled SCUMCapabilityGateState = "enabled"
|
||||
SCUMCapabilityGateDisabled SCUMCapabilityGateState = "disabled"
|
||||
)
|
||||
|
||||
type SCUMSafeErrorCode string
|
||||
|
||||
const (
|
||||
SCUMSafeErrorNone SCUMSafeErrorCode = "none"
|
||||
SCUMSafeErrorProbeExecutorAbsent SCUMSafeErrorCode = "probe_executor_absent"
|
||||
SCUMSafeErrorProbeMissing SCUMSafeErrorCode = "probe_missing"
|
||||
SCUMSafeErrorProbeFailed SCUMSafeErrorCode = "probe_failed"
|
||||
SCUMSafeErrorSchemaIncompatible SCUMSafeErrorCode = "schema_incompatible"
|
||||
SCUMSafeErrorBindingMismatch SCUMSafeErrorCode = "binding_mismatch"
|
||||
SCUMSafeErrorAdapterMismatch SCUMSafeErrorCode = "adapter_mismatch"
|
||||
SCUMSafeErrorFingerprintMismatch SCUMSafeErrorCode = "fingerprint_mismatch"
|
||||
SCUMSafeErrorDigestMismatch SCUMSafeErrorCode = "digest_mismatch"
|
||||
SCUMSafeErrorEvidenceExpired SCUMSafeErrorCode = "evidence_expired"
|
||||
SCUMSafeErrorInvalidProbePayload SCUMSafeErrorCode = "invalid_probe_payload"
|
||||
)
|
||||
|
||||
type SCUMSafeError struct {
|
||||
Code SCUMSafeErrorCode
|
||||
Message string
|
||||
Retryable bool
|
||||
}
|
||||
|
||||
type SCUMBindingIdentity struct {
|
||||
ServerInstanceID string
|
||||
RunBindingID string
|
||||
RunEndpointID string
|
||||
PluginID string
|
||||
PluginVersion string
|
||||
AdapterVersion string
|
||||
GameVersion string
|
||||
DatabaseIdentity string
|
||||
}
|
||||
|
||||
type SCUMSchemaProbeBounds struct {
|
||||
MaxObjects int
|
||||
MaxColumnsPerObject int
|
||||
MaxIndexesPerObject int
|
||||
MaxForeignKeys int
|
||||
MaxCardinalityReads int
|
||||
MaxSampleRows int
|
||||
TimeoutMS int
|
||||
MaxResultBytes int
|
||||
}
|
||||
|
||||
func DefaultSCUMSchemaProbeBounds() SCUMSchemaProbeBounds {
|
||||
return SCUMSchemaProbeBounds{MaxObjects: 256, MaxColumnsPerObject: 128, MaxIndexesPerObject: 64, MaxForeignKeys: 64, MaxCardinalityReads: 64, MaxSampleRows: 3, TimeoutMS: 5000, MaxResultBytes: 512 * 1024}
|
||||
}
|
||||
|
||||
type SCUMSchemaProbeRequest struct {
|
||||
RequestID string
|
||||
JobID string
|
||||
Binding SCUMBindingIdentity
|
||||
Bounds SCUMSchemaProbeBounds
|
||||
RequestedAt time.Time
|
||||
}
|
||||
|
||||
type SCUMSchemaProbeDeclaration struct {
|
||||
Capability string
|
||||
TargetKey string
|
||||
Bounds SCUMSchemaProbeBounds
|
||||
}
|
||||
|
||||
type SCUMLiveDataCapabilityGateDeclaration struct {
|
||||
Capability SCUMDataCapability
|
||||
Gate SCUMCapabilityGateState
|
||||
AdapterVersion string
|
||||
RequiredSchemaFingerprint string
|
||||
RequiredAssetDigests []string
|
||||
EvidenceStatus SCUMCapabilityEvidenceStatus
|
||||
SafeReason string
|
||||
}
|
||||
|
||||
type SCUMLiveDataManifest struct {
|
||||
SchemaVersion string
|
||||
Probe SCUMSchemaProbeDeclaration
|
||||
CapabilityGates []SCUMLiveDataCapabilityGateDeclaration
|
||||
}
|
||||
|
||||
type SCUMSchemaColumnEvidence struct {
|
||||
NameFingerprint string
|
||||
DeclaredType string
|
||||
Nullable *bool
|
||||
PrimaryKey bool
|
||||
Ordinal int
|
||||
}
|
||||
|
||||
type SCUMSchemaIndexEvidence struct {
|
||||
NameFingerprint string
|
||||
Unique bool
|
||||
ColumnHashes []string
|
||||
}
|
||||
|
||||
type SCUMSchemaForeignKeyEvidence struct {
|
||||
FromColumnHash string
|
||||
ToObjectHash string
|
||||
ToColumnHash string
|
||||
}
|
||||
|
||||
type SCUMSchemaObjectEvidence struct {
|
||||
ObjectHash string
|
||||
Kind string
|
||||
NameFingerprint string
|
||||
DeclaredColumns []SCUMSchemaColumnEvidence
|
||||
Indexes []SCUMSchemaIndexEvidence
|
||||
ForeignKeys []SCUMSchemaForeignKeyEvidence
|
||||
ApproximateRows *int64
|
||||
SampleFingerprints []string
|
||||
}
|
||||
|
||||
type SCUMSchemaProbeResult struct {
|
||||
RequestID string
|
||||
JobID string
|
||||
Binding SCUMBindingIdentity
|
||||
Status SCUMCapabilityEvidenceStatus
|
||||
SchemaFingerprint string
|
||||
ObservedAt time.Time
|
||||
ResultDigest string
|
||||
Objects []SCUMSchemaObjectEvidence
|
||||
SafeError SCUMSafeError
|
||||
Limits SCUMSchemaProbeBounds
|
||||
}
|
||||
|
||||
type SCUMCapabilityRequirement struct {
|
||||
Capability SCUMDataCapability
|
||||
AdapterVersion string
|
||||
SchemaFingerprint string
|
||||
AssetDigests []string
|
||||
}
|
||||
|
||||
type SCUMCapabilityEvidence struct {
|
||||
Capability SCUMDataCapability
|
||||
Status SCUMCapabilityEvidenceStatus
|
||||
Binding SCUMBindingIdentity
|
||||
AdapterVersion string
|
||||
SchemaFingerprint string
|
||||
ProbeResultDigest string
|
||||
AssetDigests []string
|
||||
ObservedAt time.Time
|
||||
ExpiresAt time.Time
|
||||
SafeError SCUMSafeError
|
||||
}
|
||||
|
||||
type SCUMCapabilityGate struct {
|
||||
Capability SCUMDataCapability
|
||||
State SCUMCapabilityGateState
|
||||
Enabled bool
|
||||
ReasonCode SCUMSafeErrorCode
|
||||
Reason string
|
||||
Evidence SCUMCapabilityEvidence
|
||||
}
|
||||
|
||||
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 {
|
||||
gate.ReasonCode = SCUMSafeErrorProbeExecutorAbsent
|
||||
gate.Reason = "bound Run does not expose the generic SQLite schema-probe executor"
|
||||
return gate
|
||||
}
|
||||
if evidence.Status == SCUMCapabilityEvidenceMissing || evidence.Capability == "" {
|
||||
return gate
|
||||
}
|
||||
gate.Evidence = CopySCUMCapabilityEvidence(evidence)
|
||||
if evidence.Status == SCUMCapabilityEvidenceFailed {
|
||||
gate.ReasonCode = SCUMSafeErrorProbeFailed
|
||||
gate.Reason = safeReason(evidence.SafeError.Message, "last schema probe failed")
|
||||
return gate
|
||||
}
|
||||
if evidence.Status == SCUMCapabilityEvidenceIncompatible {
|
||||
gate.ReasonCode = SCUMSafeErrorSchemaIncompatible
|
||||
gate.Reason = safeReason(evidence.SafeError.Message, "current schema is incompatible with the plugin adapter")
|
||||
return gate
|
||||
}
|
||||
if evidence.Capability != requirement.Capability {
|
||||
gate.ReasonCode = SCUMSafeErrorSchemaIncompatible
|
||||
gate.Reason = "capability evidence does not match the requested SCUM capability"
|
||||
return gate
|
||||
}
|
||||
if !sameSCUMBinding(evidence.Binding, active) {
|
||||
gate.ReasonCode = SCUMSafeErrorBindingMismatch
|
||||
gate.Reason = "evidence belongs to a different server, Run binding, plugin, adapter, game, or database identity"
|
||||
return gate
|
||||
}
|
||||
if evidence.AdapterVersion != requirement.AdapterVersion {
|
||||
gate.ReasonCode = SCUMSafeErrorAdapterMismatch
|
||||
gate.Reason = "evidence adapter version does not match the plugin requirement"
|
||||
return gate
|
||||
}
|
||||
if evidence.SchemaFingerprint == "" || evidence.SchemaFingerprint != requirement.SchemaFingerprint {
|
||||
gate.ReasonCode = SCUMSafeErrorFingerprintMismatch
|
||||
gate.Reason = "schema fingerprint does not match the plugin requirement"
|
||||
return gate
|
||||
}
|
||||
if !containsAllStrings(evidence.AssetDigests, requirement.AssetDigests) {
|
||||
gate.ReasonCode = SCUMSafeErrorDigestMismatch
|
||||
gate.Reason = "packaged asset digest does not match the compatible evidence"
|
||||
return gate
|
||||
}
|
||||
if !evidence.ExpiresAt.IsZero() && !now.IsZero() && !now.Before(evidence.ExpiresAt) {
|
||||
gate.ReasonCode = SCUMSafeErrorEvidenceExpired
|
||||
gate.Reason = "current-service evidence has expired and must be probed again"
|
||||
return gate
|
||||
}
|
||||
gate.State = SCUMCapabilityGateEnabled
|
||||
gate.Enabled = true
|
||||
gate.ReasonCode = SCUMSafeErrorNone
|
||||
gate.Reason = "current-service evidence matches the versioned plugin adapter"
|
||||
return gate
|
||||
}
|
||||
|
||||
func CopySCUMCapabilityEvidence(value SCUMCapabilityEvidence) SCUMCapabilityEvidence {
|
||||
value.AssetDigests = append([]string(nil), value.AssetDigests...)
|
||||
return value
|
||||
}
|
||||
|
||||
func CopySCUMSchemaProbeResult(value SCUMSchemaProbeResult) SCUMSchemaProbeResult {
|
||||
value.Objects = append([]SCUMSchemaObjectEvidence(nil), value.Objects...)
|
||||
for index := range value.Objects {
|
||||
value.Objects[index].DeclaredColumns = append([]SCUMSchemaColumnEvidence(nil), value.Objects[index].DeclaredColumns...)
|
||||
value.Objects[index].Indexes = append([]SCUMSchemaIndexEvidence(nil), value.Objects[index].Indexes...)
|
||||
value.Objects[index].ForeignKeys = append([]SCUMSchemaForeignKeyEvidence(nil), value.Objects[index].ForeignKeys...)
|
||||
value.Objects[index].SampleFingerprints = append([]string(nil), value.Objects[index].SampleFingerprints...)
|
||||
for idx := range value.Objects[index].Indexes {
|
||||
value.Objects[index].Indexes[idx].ColumnHashes = append([]string(nil), value.Objects[index].Indexes[idx].ColumnHashes...)
|
||||
}
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func CopySCUMLiveDataManifest(value SCUMLiveDataManifest) SCUMLiveDataManifest {
|
||||
value.CapabilityGates = append([]SCUMLiveDataCapabilityGateDeclaration(nil), value.CapabilityGates...)
|
||||
for index := range value.CapabilityGates {
|
||||
value.CapabilityGates[index].RequiredAssetDigests = append([]string(nil), value.CapabilityGates[index].RequiredAssetDigests...)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func sameSCUMBinding(a SCUMBindingIdentity, b SCUMBindingIdentity) bool {
|
||||
return a.ServerInstanceID == b.ServerInstanceID && a.RunBindingID == b.RunBindingID && a.RunEndpointID == b.RunEndpointID && a.PluginID == b.PluginID && a.PluginVersion == b.PluginVersion && a.AdapterVersion == b.AdapterVersion && a.GameVersion == b.GameVersion && a.DatabaseIdentity == b.DatabaseIdentity
|
||||
}
|
||||
|
||||
func containsAllStrings(values []string, required []string) bool {
|
||||
set := map[string]struct{}{}
|
||||
for _, value := range values {
|
||||
set[value] = struct{}{}
|
||||
}
|
||||
for _, value := range required {
|
||||
if _, ok := set[value]; !ok {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func safeReason(value string, fallback string) string {
|
||||
if value == "" {
|
||||
return fallback
|
||||
}
|
||||
return value
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSCUMCapabilityGateDefaultsClosedWithoutProbeEvidence(t *testing.T) {
|
||||
active := scumGateBinding()
|
||||
requirement := SCUMCapabilityRequirement{Capability: SCUMDataCapabilityPlayerRead, AdapterVersion: "adapter-1", SchemaFingerprint: "schema-1", AssetDigests: []string{"sha256:query"}}
|
||||
|
||||
gate := EvaluateSCUMCapabilityGate(requirement, SCUMCapabilityEvidence{}, active, true, time.Now())
|
||||
|
||||
if gate.Enabled || gate.State != SCUMCapabilityGateDisabled || gate.ReasonCode != SCUMSafeErrorProbeMissing {
|
||||
t.Fatalf("expected closed gate without evidence, got %#v", gate)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSCUMCapabilityGateDefaultsClosedWhenProbeExecutorUnavailable(t *testing.T) {
|
||||
active := scumGateBinding()
|
||||
requirement := SCUMCapabilityRequirement{Capability: SCUMDataCapabilityPlayerRead, AdapterVersion: "adapter-1", SchemaFingerprint: "schema-1"}
|
||||
evidence := SCUMCapabilityEvidence{Capability: SCUMDataCapabilityPlayerRead, Status: SCUMCapabilityEvidenceCompatible, Binding: active, AdapterVersion: "adapter-1", SchemaFingerprint: "schema-1"}
|
||||
|
||||
gate := EvaluateSCUMCapabilityGate(requirement, evidence, active, false, time.Now())
|
||||
|
||||
if gate.Enabled || gate.ReasonCode != SCUMSafeErrorProbeExecutorAbsent {
|
||||
t.Fatalf("expected executor gate failure, got %#v", gate)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSCUMCapabilityGateEnablesOnlyMatchingEvidence(t *testing.T) {
|
||||
now := time.Date(2026, 8, 11, 12, 0, 0, 0, time.UTC)
|
||||
active := scumGateBinding()
|
||||
requirement := SCUMCapabilityRequirement{Capability: SCUMDataCapabilityVehicleRead, AdapterVersion: "adapter-1", SchemaFingerprint: "schema-1", AssetDigests: []string{"sha256:vehicle-query"}}
|
||||
evidence := SCUMCapabilityEvidence{Capability: SCUMDataCapabilityVehicleRead, Status: SCUMCapabilityEvidenceCompatible, Binding: active, AdapterVersion: "adapter-1", SchemaFingerprint: "schema-1", AssetDigests: []string{"sha256:vehicle-query", "sha256:result-schema"}, ExpiresAt: now.Add(time.Hour)}
|
||||
|
||||
gate := EvaluateSCUMCapabilityGate(requirement, evidence, active, true, now)
|
||||
|
||||
if !gate.Enabled || gate.State != SCUMCapabilityGateEnabled || gate.ReasonCode != SCUMSafeErrorNone {
|
||||
t.Fatalf("expected enabled gate, got %#v", gate)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSCUMCapabilityGateRejectsMismatchedCurrentServiceEvidence(t *testing.T) {
|
||||
now := time.Date(2026, 8, 11, 12, 0, 0, 0, time.UTC)
|
||||
active := scumGateBinding()
|
||||
requirement := SCUMCapabilityRequirement{Capability: SCUMDataCapabilityPositionRead, AdapterVersion: "adapter-1", SchemaFingerprint: "schema-1", AssetDigests: []string{"sha256:positions"}}
|
||||
evidence := SCUMCapabilityEvidence{Capability: SCUMDataCapabilityPositionRead, Status: SCUMCapabilityEvidenceCompatible, Binding: active, AdapterVersion: "adapter-1", SchemaFingerprint: "schema-1", AssetDigests: []string{"sha256:positions"}, ExpiresAt: now.Add(time.Hour)}
|
||||
|
||||
changedBinding := evidence
|
||||
changedBinding.Binding.DatabaseIdentity = "db-other"
|
||||
if gate := EvaluateSCUMCapabilityGate(requirement, changedBinding, active, true, now); gate.Enabled || gate.ReasonCode != SCUMSafeErrorBindingMismatch {
|
||||
t.Fatalf("expected binding mismatch, got %#v", gate)
|
||||
}
|
||||
|
||||
changedFingerprint := evidence
|
||||
changedFingerprint.SchemaFingerprint = "schema-other"
|
||||
if gate := EvaluateSCUMCapabilityGate(requirement, changedFingerprint, active, true, now); gate.Enabled || gate.ReasonCode != SCUMSafeErrorFingerprintMismatch {
|
||||
t.Fatalf("expected fingerprint mismatch, got %#v", gate)
|
||||
}
|
||||
|
||||
changedDigest := evidence
|
||||
changedDigest.AssetDigests = []string{"sha256:different"}
|
||||
if gate := EvaluateSCUMCapabilityGate(requirement, changedDigest, active, true, now); gate.Enabled || gate.ReasonCode != SCUMSafeErrorDigestMismatch {
|
||||
t.Fatalf("expected digest mismatch, got %#v", gate)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultSCUMSchemaProbeBoundsAreBoundedAndDiagnosticOnly(t *testing.T) {
|
||||
bounds := DefaultSCUMSchemaProbeBounds()
|
||||
if bounds.MaxObjects <= 0 || bounds.MaxSampleRows > 3 || bounds.TimeoutMS > 5000 || bounds.MaxResultBytes > 512*1024 {
|
||||
t.Fatalf("unexpected unsafe default probe bounds: %#v", bounds)
|
||||
}
|
||||
}
|
||||
|
||||
func scumGateBinding() SCUMBindingIdentity {
|
||||
return SCUMBindingIdentity{ServerInstanceID: "server-1", RunBindingID: "binding-1", RunEndpointID: "run-1", PluginID: "game.scum", PluginVersion: "0.1.6", AdapterVersion: "adapter-1", GameVersion: "scum-1", DatabaseIdentity: "db-current"}
|
||||
}
|
||||
Reference in New Issue
Block a user