feat(scum): gate live data on schema evidence

This commit is contained in:
npc0-hue
2026-08-11 13:28:36 +08:00
parent e5d26e5420
commit a58da47c60
18 changed files with 1265 additions and 3 deletions
@@ -7,8 +7,8 @@
## 2. Minimal Run Probe and Current-Service Evidence Gate
- [ ] 2.1 Add a release gate that keeps every database-backed SCUM read and write capability disabled until capability-specific current-service evidence matches a versioned plugin adapter; do not add production SQL or mutation assets before this group is complete.
- [ ] 2.2 Define the minimal generic schema-probe request/result contract, safe error model, binding identity, bounds, and redacted evidence DTO needed by Platform and the plugin without embedding SCUM table names or host paths in Run-facing generic code.
- [x] 2.1 Add a release gate that keeps every database-backed SCUM read and write capability disabled until capability-specific current-service evidence matches a versioned plugin adapter; do not add production SQL or mutation assets before this group is complete.
- [x] 2.2 Define the minimal generic schema-probe request/result contract, safe error model, binding identity, bounds, and redacted evidence DTO needed by Platform and the plugin without embedding SCUM table names or host paths in Run-facing generic code.
- [ ] 2.3 If the active binding lacks the minimal bounded query-only probe executor, create/hand off a separately authorized task rooted in the independent Run repository, wait for its tests/commit/deployment evidence, and record that evidence here; do not edit or vendor Run source from this change.
- [ ] 2.4 Use the personal server-management MCP (`list_devices`, `test_connection`, then `ssh_exec` only when needed) for device inventory, connectivity checks, and bounded diagnostics. Execute the actual schema probe only as a Platform durable job through the active authenticated Run binding; never run SCUM SQL directly over SSH or bypass the Run channel.
- [ ] 2.5 Capture `sqlite_master`, applicable read-only PRAGMA metadata, indexes, foreign keys, declared types, cardinalities, and small redacted samples for candidate player, profile/entity, squad/member, vehicle, flag/base, economy, coordinate, and character-profile payload sources.
+5
View File
@@ -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
+295
View File
@@ -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
}
+78
View File
@@ -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"}
}
+53
View File
@@ -399,6 +399,28 @@ type GameMapTrajectoryDeclarationBody struct {
RetentionSeconds int `json:"retentionSeconds"`
}
type SCUMSchemaProbeDeclarationBody struct {
Capability string `json:"capability"`
TargetKey string `json:"targetKey"`
Bounds SCUMSchemaProbeBoundsDTO `json:"bounds"`
}
type SCUMLiveDataCapabilityGateBody struct {
Capability string `json:"capability"`
Gate string `json:"gate"`
AdapterVersion string `json:"adapterVersion"`
RequiredSchemaFingerprint string `json:"requiredSchemaFingerprint,omitempty"`
RequiredAssetDigests []string `json:"requiredAssetDigests,omitempty"`
EvidenceStatus string `json:"evidenceStatus"`
SafeReason string `json:"safeReason"`
}
type SCUMLiveDataManifestBody struct {
SchemaVersion string `json:"schemaVersion"`
Probe SCUMSchemaProbeDeclarationBody `json:"probe"`
CapabilityGates []SCUMLiveDataCapabilityGateBody `json:"capabilityGates"`
}
type GamePluginManifestBody struct {
ID string `json:"id"`
Name string `json:"name"`
@@ -419,6 +441,7 @@ type GamePluginManifestBody struct {
RemoteAccess GamePluginRemoteAccessBody `json:"remoteAccess,omitempty"`
RuntimeProfiles GamePluginRuntimeProfilesBody `json:"runtimeProfiles,omitempty"`
GameClientBridge GameClientBridgeManifestBody `json:"gameClientBridge,omitempty"`
SCUMLiveData SCUMLiveDataManifestBody `json:"scumLiveData,omitempty"`
MapTrajectories *GameMapTrajectoryDeclarationBody `json:"mapTrajectories,omitempty"`
}
@@ -458,6 +481,7 @@ type GamePluginCreateRequest struct {
RemoteAccess GamePluginRemoteAccessBody `json:"remoteAccess,omitempty"`
RuntimeProfiles GamePluginRuntimeProfilesBody `json:"runtimeProfiles,omitempty"`
GameClientBridge GameClientBridgeManifestBody `json:"gameClientBridge,omitempty"`
SCUMLiveData SCUMLiveDataManifestBody `json:"scumLiveData,omitempty"`
MapTrajectories *GameMapTrajectoryDeclarationBody `json:"mapTrajectories,omitempty"`
ValidationViolations []string `json:"validationViolations,omitempty"`
}
@@ -486,6 +510,7 @@ type GamePluginResponse struct {
RemoteAccess GamePluginRemoteAccessBody `json:"remoteAccess,omitempty"`
RuntimeProfiles GamePluginRuntimeProfilesResponseBody `json:"runtimeProfiles,omitempty"`
GameClientBridge GameClientBridgeManifestBody `json:"gameClientBridge,omitempty"`
SCUMLiveData *SCUMLiveDataManifestBody `json:"scumLiveData,omitempty"`
MapTrajectories *GameMapTrajectoryDeclarationBody `json:"mapTrajectories,omitempty"`
ValidationViolations []string `json:"validationViolations,omitempty"`
Status domain.GamePluginStatus `json:"status"`
@@ -1082,11 +1107,20 @@ func (request GamePluginManifestRegistrationRequest) ToDomain() domain.GamePlugi
RemoteAccess: request.Manifest.RemoteAccess.ToDomain(),
RuntimeProfiles: request.Manifest.RuntimeProfiles.ToDomain(),
GameClientBridge: request.Manifest.GameClientBridge.ToDomain(),
SCUMLiveData: request.Manifest.SCUMLiveData.ToDomain(),
MapTrajectories: mapTrajectoryDeclarationToDomain(request.Manifest.MapTrajectories),
},
}
}
func (body SCUMLiveDataManifestBody) ToDomain() domain.SCUMLiveDataManifest {
gates := make([]domain.SCUMLiveDataCapabilityGateDeclaration, len(body.CapabilityGates))
for index, gate := range body.CapabilityGates {
gates[index] = domain.SCUMLiveDataCapabilityGateDeclaration{Capability: domain.SCUMDataCapability(gate.Capability), Gate: domain.SCUMCapabilityGateState(gate.Gate), AdapterVersion: gate.AdapterVersion, RequiredSchemaFingerprint: gate.RequiredSchemaFingerprint, RequiredAssetDigests: domain.CopyStringSlice(gate.RequiredAssetDigests), EvidenceStatus: domain.SCUMCapabilityEvidenceStatus(gate.EvidenceStatus), SafeReason: gate.SafeReason}
}
return domain.SCUMLiveDataManifest{SchemaVersion: body.SchemaVersion, Probe: domain.SCUMSchemaProbeDeclaration{Capability: body.Probe.Capability, TargetKey: body.Probe.TargetKey, Bounds: scumProbeBoundsToDomain(body.Probe.Bounds)}, CapabilityGates: gates}
}
func pluginAssetFilesToDomain(files []PluginAssetFileBody) []domain.PluginAssetFile {
if files == nil {
return nil
@@ -1268,6 +1302,7 @@ func (request GamePluginCreateRequest) ToDomain() domain.GamePlugin {
RemoteAccess: request.RemoteAccess.ToDomain(),
RuntimeProfiles: request.RuntimeProfiles.ToDomain(),
GameClientBridge: request.GameClientBridge.ToDomain(),
SCUMLiveData: request.SCUMLiveData.ToDomain(),
MapTrajectories: mapTrajectoryDeclarationToDomain(request.MapTrajectories),
ValidationViolations: domain.CopyStringSlice(request.ValidationViolations),
}
@@ -1521,6 +1556,7 @@ func GamePluginFromDomain(plugin domain.GamePlugin) GamePluginResponse {
RemoteAccess: remoteAccessFromDomain(plugin.RemoteAccess),
RuntimeProfiles: runtimeProfilesFromDomain(plugin.RuntimeProfiles),
GameClientBridge: gameClientBridgeManifestFromDomain(plugin.GameClientBridge),
SCUMLiveData: scumLiveDataManifestPtrFromDomain(plugin.SCUMLiveData),
MapTrajectories: mapTrajectoryDeclarationFromDomain(plugin.MapTrajectories),
ValidationViolations: plugin.ValidationViolations,
Status: plugin.Status,
@@ -1625,6 +1661,23 @@ func productionLifecycleFromDomain(lifecycle domain.GamePluginProductionLifecycl
return GamePluginProductionLifecycleBody{Operations: lifecycle.Operations, DependencyPolicy: lifecycle.DependencyPolicy, ApprovalRequired: lifecycle.ApprovalRequired}
}
func scumLiveDataManifestFromDomain(value domain.SCUMLiveDataManifest) SCUMLiveDataManifestBody {
value = domain.CopySCUMLiveDataManifest(value)
gates := make([]SCUMLiveDataCapabilityGateBody, len(value.CapabilityGates))
for index, gate := range value.CapabilityGates {
gates[index] = SCUMLiveDataCapabilityGateBody{Capability: string(gate.Capability), Gate: string(gate.Gate), AdapterVersion: gate.AdapterVersion, RequiredSchemaFingerprint: gate.RequiredSchemaFingerprint, RequiredAssetDigests: domain.CopyStringSlice(gate.RequiredAssetDigests), EvidenceStatus: string(gate.EvidenceStatus), SafeReason: gate.SafeReason}
}
return SCUMLiveDataManifestBody{SchemaVersion: value.SchemaVersion, Probe: SCUMSchemaProbeDeclarationBody{Capability: value.Probe.Capability, TargetKey: value.Probe.TargetKey, Bounds: scumProbeBoundsFromDomain(value.Probe.Bounds)}, CapabilityGates: gates}
}
func scumLiveDataManifestPtrFromDomain(value domain.SCUMLiveDataManifest) *SCUMLiveDataManifestBody {
if value.SchemaVersion == "" && value.Probe.Capability == "" && len(value.CapabilityGates) == 0 {
return nil
}
body := scumLiveDataManifestFromDomain(value)
return &body
}
func gameClientBridgeManifestFromDomain(value domain.GameClientBridgeManifest) GameClientBridgeManifestBody {
value = domain.CopyGameClientBridgeManifest(value)
commands := make([]GameClientBridgeCommandDeclarationBody, len(value.Commands))
+140
View File
@@ -0,0 +1,140 @@
package dto
import (
"time"
"browser.local/platform/domain"
)
type SCUMBindingIdentityDTO struct {
ServerInstanceID string `json:"serverInstanceId"`
RunBindingID string `json:"runBindingId"`
RunEndpointID string `json:"runEndpointId"`
PluginID string `json:"pluginId"`
PluginVersion string `json:"pluginVersion"`
AdapterVersion string `json:"adapterVersion"`
GameVersion string `json:"gameVersion"`
DatabaseIdentity string `json:"databaseIdentity"`
}
type SCUMSchemaProbeBoundsDTO struct {
MaxObjects int `json:"maxObjects"`
MaxColumnsPerObject int `json:"maxColumnsPerObject"`
MaxIndexesPerObject int `json:"maxIndexesPerObject"`
MaxForeignKeys int `json:"maxForeignKeys"`
MaxCardinalityReads int `json:"maxCardinalityReads"`
MaxSampleRows int `json:"maxSampleRows"`
TimeoutMS int `json:"timeoutMs"`
MaxResultBytes int `json:"maxResultBytes"`
}
type SCUMSchemaProbeRequestDTO struct {
RequestID string `json:"requestId"`
JobID string `json:"jobId"`
Binding SCUMBindingIdentityDTO `json:"binding"`
Bounds SCUMSchemaProbeBoundsDTO `json:"bounds"`
RequestedAt time.Time `json:"requestedAt"`
}
type SCUMSafeErrorDTO struct {
Code string `json:"code"`
Message string `json:"message,omitempty"`
Retryable bool `json:"retryable"`
}
type SCUMSchemaColumnEvidenceDTO struct {
NameFingerprint string `json:"nameFingerprint"`
DeclaredType string `json:"declaredType"`
Nullable *bool `json:"nullable,omitempty"`
PrimaryKey bool `json:"primaryKey"`
Ordinal int `json:"ordinal"`
}
type SCUMSchemaIndexEvidenceDTO struct {
NameFingerprint string `json:"nameFingerprint"`
Unique bool `json:"unique"`
ColumnHashes []string `json:"columnHashes"`
}
type SCUMSchemaForeignKeyEvidenceDTO struct {
FromColumnHash string `json:"fromColumnHash"`
ToObjectHash string `json:"toObjectHash"`
ToColumnHash string `json:"toColumnHash"`
}
type SCUMSchemaObjectEvidenceDTO struct {
ObjectHash string `json:"objectHash"`
Kind string `json:"kind"`
NameFingerprint string `json:"nameFingerprint"`
DeclaredColumns []SCUMSchemaColumnEvidenceDTO `json:"declaredColumns"`
Indexes []SCUMSchemaIndexEvidenceDTO `json:"indexes"`
ForeignKeys []SCUMSchemaForeignKeyEvidenceDTO `json:"foreignKeys"`
ApproximateRows *int64 `json:"approximateRows,omitempty"`
SampleFingerprints []string `json:"sampleFingerprints,omitempty"`
}
type SCUMSchemaProbeResultDTO struct {
RequestID string `json:"requestId"`
JobID string `json:"jobId"`
Binding SCUMBindingIdentityDTO `json:"binding"`
Status string `json:"status"`
SchemaFingerprint string `json:"schemaFingerprint,omitempty"`
ObservedAt time.Time `json:"observedAt"`
ResultDigest string `json:"resultDigest,omitempty"`
Objects []SCUMSchemaObjectEvidenceDTO `json:"objects,omitempty"`
SafeError SCUMSafeErrorDTO `json:"safeError,omitempty"`
Limits SCUMSchemaProbeBoundsDTO `json:"limits"`
}
type SCUMCapabilityGateDTO struct {
Capability string `json:"capability"`
State string `json:"state"`
Enabled bool `json:"enabled"`
ReasonCode string `json:"reasonCode"`
Reason string `json:"reason"`
}
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}
}
func SCUMSchemaProbeResultFromDomain(value domain.SCUMSchemaProbeResult) SCUMSchemaProbeResultDTO {
value = domain.CopySCUMSchemaProbeResult(value)
objects := make([]SCUMSchemaObjectEvidenceDTO, len(value.Objects))
for index, object := range value.Objects {
columns := make([]SCUMSchemaColumnEvidenceDTO, len(object.DeclaredColumns))
for i, column := range object.DeclaredColumns {
columns[i] = SCUMSchemaColumnEvidenceDTO{NameFingerprint: column.NameFingerprint, DeclaredType: column.DeclaredType, Nullable: column.Nullable, PrimaryKey: column.PrimaryKey, Ordinal: column.Ordinal}
}
indexes := make([]SCUMSchemaIndexEvidenceDTO, len(object.Indexes))
for i, item := range object.Indexes {
indexes[i] = SCUMSchemaIndexEvidenceDTO{NameFingerprint: item.NameFingerprint, Unique: item.Unique, ColumnHashes: append([]string(nil), item.ColumnHashes...)}
}
foreignKeys := make([]SCUMSchemaForeignKeyEvidenceDTO, len(object.ForeignKeys))
for i, item := range object.ForeignKeys {
foreignKeys[i] = SCUMSchemaForeignKeyEvidenceDTO{FromColumnHash: item.FromColumnHash, ToObjectHash: item.ToObjectHash, ToColumnHash: item.ToColumnHash}
}
objects[index] = SCUMSchemaObjectEvidenceDTO{ObjectHash: object.ObjectHash, Kind: object.Kind, NameFingerprint: object.NameFingerprint, DeclaredColumns: columns, Indexes: indexes, ForeignKeys: foreignKeys, ApproximateRows: object.ApproximateRows, SampleFingerprints: append([]string(nil), object.SampleFingerprints...)}
}
return SCUMSchemaProbeResultDTO{RequestID: value.RequestID, JobID: value.JobID, Binding: scumBindingIdentityFromDomain(value.Binding), Status: string(value.Status), SchemaFingerprint: value.SchemaFingerprint, ObservedAt: value.ObservedAt, ResultDigest: value.ResultDigest, Objects: objects, SafeError: SCUMSafeErrorDTO{Code: string(value.SafeError.Code), Message: value.SafeError.Message, Retryable: value.SafeError.Retryable}, Limits: scumProbeBoundsFromDomain(value.Limits)}
}
func SCUMCapabilityGateFromDomain(value domain.SCUMCapabilityGate) SCUMCapabilityGateDTO {
return SCUMCapabilityGateDTO{Capability: string(value.Capability), State: string(value.State), Enabled: value.Enabled, ReasonCode: string(value.ReasonCode), Reason: value.Reason}
}
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}
}
func scumBindingIdentityFromDomain(value domain.SCUMBindingIdentity) SCUMBindingIdentityDTO {
return SCUMBindingIdentityDTO{ServerInstanceID: value.ServerInstanceID, RunBindingID: value.RunBindingID, RunEndpointID: value.RunEndpointID, PluginID: value.PluginID, PluginVersion: value.PluginVersion, AdapterVersion: value.AdapterVersion, GameVersion: value.GameVersion, DatabaseIdentity: value.DatabaseIdentity}
}
func scumProbeBoundsToDomain(value SCUMSchemaProbeBoundsDTO) domain.SCUMSchemaProbeBounds {
return domain.SCUMSchemaProbeBounds{MaxObjects: value.MaxObjects, MaxColumnsPerObject: value.MaxColumnsPerObject, MaxIndexesPerObject: value.MaxIndexesPerObject, MaxForeignKeys: value.MaxForeignKeys, MaxCardinalityReads: value.MaxCardinalityReads, MaxSampleRows: value.MaxSampleRows, TimeoutMS: value.TimeoutMS, MaxResultBytes: value.MaxResultBytes}
}
func scumProbeBoundsFromDomain(value domain.SCUMSchemaProbeBounds) SCUMSchemaProbeBoundsDTO {
return SCUMSchemaProbeBoundsDTO{MaxObjects: value.MaxObjects, MaxColumnsPerObject: value.MaxColumnsPerObject, MaxIndexesPerObject: value.MaxIndexesPerObject, MaxForeignKeys: value.MaxForeignKeys, MaxCardinalityReads: value.MaxCardinalityReads, MaxSampleRows: value.MaxSampleRows, TimeoutMS: value.TimeoutMS, MaxResultBytes: value.MaxResultBytes}
}
@@ -0,0 +1,46 @@
# SCUM Live Data Contracts
This contract replaces SCUM projection/Workflow-facing reads with evidence-gated local management data. It is intentionally generic at the Run boundary: Platform and plugins may name SCUM capabilities, but Run receives only packaged generic SQLite probe/template/mutation work and never hardcodes SCUM table names, command keys, host paths, sockets, credentials, or browser-supplied SQL.
## Capability gate
Every database-backed SCUM read/write capability is disabled until all of the following are true for the active server binding:
- the bound Run advertises `remote.run.db.sqlite.probe`;
- Platform has a current `SCUMCapabilityEvidence` row for the exact server instance, Run binding, Run endpoint, plugin id/version, adapter version, game version, and database identity;
- evidence status is `compatible` for the requested capability;
- the evidence schema fingerprint equals the versioned adapter requirement;
- every required packaged asset digest is present in the evidence;
- evidence has not expired or been invalidated by rebinding, database identity change, plugin version change, adapter version change, or schema fingerprint change.
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.
## Probe request
`SCUMSchemaProbeRequest` is a Platform durable-job payload addressed through the active authenticated Run binding.
Required fields:
- `requestId`, `jobId`;
- `binding`: `serverInstanceId`, `runBindingId`, `runEndpointId`, `pluginId`, `pluginVersion`, `adapterVersion`, `gameVersion`, `databaseIdentity`;
- `bounds`: `maxObjects`, `maxColumnsPerObject`, `maxIndexesPerObject`, `maxForeignKeys`, `maxCardinalityReads`, `maxSampleRows`, `timeoutMs`, `maxResultBytes`;
- `requestedAt`.
The payload must not include a host database path, DSN, socket, credential, raw SQL text, raw rows, or SCUM-specific table names. Target resolution happens inside the active Run package from logical bindings only.
## Probe result
`SCUMSchemaProbeResult` returns only redacted schema evidence:
- request/job/binding identity;
- status: `missing`, `compatible`, `incompatible`, or `failed`;
- schema fingerprint and result digest;
- bounded object metadata with object/name/column/index/fk fingerprints, declared types, nullable/primary-key flags, approximate row counts, and sample fingerprints;
- safe error code/message when failed;
- limits actually applied.
Samples are hashes/fingerprints only. Raw row content, XML payloads, SQL, paths, DSNs, sockets, credentials, host names, IPs, and RCON text are never returned to Platform Web, plugin pages, AI prompts, or safe diagnostic fields.
## Release behavior
The first-party SCUM plugin declares `scumLiveData` with `remote.run.db.sqlite.probe` and per-capability gates. Until current-service evidence exists, all gates remain `disabled` with `evidenceStatus: missing`. Query assets, RCON templates, XML mutations, map transforms, and gift transports may be added only after current-service probe evidence proves their adapter requirements; unsupported or ambiguous capabilities stay disabled independently.
+1
View File
@@ -826,6 +826,7 @@ func gamePluginFromManifestRegistration(registration domain.GamePluginManifestRe
RemoteAccess: manifest.RemoteAccess,
RuntimeProfiles: manifest.RuntimeProfiles,
GameClientBridge: manifest.GameClientBridge,
SCUMLiveData: manifest.SCUMLiveData,
MapTrajectories: manifest.MapTrajectories,
Status: domain.GamePluginStatusInstalled,
}
+4 -1
View File
@@ -157,6 +157,7 @@ func ValidateGamePlugin(plugin domain.GamePlugin) error {
violations = append(violations, validateRuntimeProfileCapabilityDeclarations(plugin.RuntimeProfiles, plugin.RequiredRunCapabilities)...)
violations = append(violations, validateRuntimeLogEventPermissionDeclarations("runtimeProfiles.logEvents", plugin.RuntimeProfiles, plugin.DeclaredPermissions)...)
violations = append(violations, validateGameClientBridgeManifest("gameClientBridge", plugin.GameClientBridge, plugin.DeclaredPermissions, plugin.Pages, plugin.RuntimeProfiles)...)
violations = append(violations, validateSCUMLiveDataManifest("scumLiveData", plugin.SCUMLiveData, plugin.RequiredRunCapabilities, plugin.RemoteAccess, plugin.RuntimeProfiles)...)
violations = append(violations, validateMapTrajectoryDeclaration("mapTrajectories", plugin.MapTrajectories)...)
violations = append(violations, validatePluginCreateFields("createFields", plugin.CreateFields)...)
violations = append(violations, validatePluginAssetFiles("lifecycleAssets", plugin.LifecycleAssets)...)
@@ -229,6 +230,7 @@ func ValidateGamePluginManifestRegistration(registration domain.GamePluginManife
violations = append(violations, validateRuntimeProfileCapabilityDeclarations(manifest.RuntimeProfiles, manifest.Capabilities)...)
violations = append(violations, validateRuntimeLogEventPermissionDeclarations("manifest.runtimeProfiles.logEvents", manifest.RuntimeProfiles, manifest.Permissions)...)
violations = append(violations, validateGameClientBridgeManifest("manifest.gameClientBridge", manifest.GameClientBridge, manifest.Permissions, manifest.Pages, manifest.RuntimeProfiles)...)
violations = append(violations, validateSCUMLiveDataManifest("manifest.scumLiveData", manifest.SCUMLiveData, manifest.Capabilities, manifest.RemoteAccess, manifest.RuntimeProfiles)...)
violations = append(violations, validateMapTrajectoryDeclaration("manifest.mapTrajectories", manifest.MapTrajectories)...)
violations = append(violations, validatePluginAssetFileDeclarations("manifest.assetFiles", manifest.AssetFiles)...)
violations = append(violations, validatePluginAssetFiles("assetFiles", registration.AssetFiles)...)
@@ -2199,7 +2201,7 @@ func validPluginRunCapability(capability string) bool {
domain.JobCapabilityRemoteRsyncRead, domain.JobCapabilityRemoteRsyncWrite,
domain.JobCapabilityRemoteRunFilesRead, domain.JobCapabilityRemoteRunFilesWrite,
domain.JobCapabilityRemoteRunProcessStart, domain.JobCapabilityRemoteRunProcessStop,
domain.JobCapabilityRemoteRunDBMySQLQuery, domain.JobCapabilityRemoteRunDBSQLiteQuery,
domain.JobCapabilityRemoteRunDBMySQLQuery, domain.JobCapabilityRemoteRunDBSQLiteProbe, domain.JobCapabilityRemoteRunDBSQLiteQuery,
domain.JobCapabilityRemoteRunLogsTransfer, domain.JobCapabilityRemoteRunRCONCommand,
domain.JobCapabilityRemoteRunProtectedSQL, domain.JobCapabilityRemoteRunProtectedRCON, domain.JobCapabilityRemoteRunProgram,
domain.JobCapabilityRunSelfUpdate, domain.JobCapabilityDependenciesCheck, domain.JobCapabilityDependenciesInstall,
@@ -2233,6 +2235,7 @@ func remoteCapabilityRequiresInputRef(capability string) bool {
domain.JobCapabilityRemoteRsyncWrite,
domain.JobCapabilityRemoteRunFilesWrite,
domain.JobCapabilityRemoteRunDBMySQLQuery,
domain.JobCapabilityRemoteRunDBSQLiteProbe,
domain.JobCapabilityRemoteRunDBSQLiteQuery,
domain.JobCapabilityRemoteRunRCONCommand, domain.JobCapabilityRemoteRunProtectedSQL,
domain.JobCapabilityRemoteRunProtectedRCON, domain.JobCapabilityRemoteRunProgram:
+19
View File
@@ -295,6 +295,25 @@ func TestValidateGamePluginManifestRegistrationRejectsUnsafeCapabilitiesAndPermi
}
}
func TestValidateGamePluginManifestRegistrationValidatesSCUMLiveDataGate(t *testing.T) {
registration := validGamePluginManifestRegistration()
registration.Manifest.ID = "game.scum"
registration.Manifest.Capabilities = append(registration.Manifest.Capabilities, domain.JobCapabilityRemoteRunDBSQLiteProbe)
registration.Manifest.RemoteAccess = domain.GamePluginRemoteAccess{Methods: []string{"run"}, RunCapabilities: []string{domain.JobCapabilityRemoteRunDBSQLiteProbe}}
registration.Manifest.RuntimeProfiles.TransportProfiles = []domain.RuntimeTransportProfile{{Key: "scum-database", Kind: "sqlite", TargetKey: "scum-database", Capabilities: []string{domain.JobCapabilityRemoteRunDBSQLiteProbe}}}
registration.Manifest.SCUMLiveData = domain.SCUMLiveDataManifest{SchemaVersion: "1", Probe: domain.SCUMSchemaProbeDeclaration{Capability: domain.JobCapabilityRemoteRunDBSQLiteProbe, TargetKey: "scum-database", Bounds: domain.DefaultSCUMSchemaProbeBounds()}, CapabilityGates: []domain.SCUMLiveDataCapabilityGateDeclaration{{Capability: domain.SCUMDataCapabilityPlayerRead, Gate: domain.SCUMCapabilityGateDisabled, AdapterVersion: "scum-live-data-v0", EvidenceStatus: domain.SCUMCapabilityEvidenceMissing, SafeReason: "等待当前服务证据。"}}}
if err := ValidateGamePluginManifestRegistration(registration); err != nil {
t.Fatalf("expected disabled live-data gate to validate, got %v", err)
}
registration.Manifest.SCUMLiveData.CapabilityGates[0].Gate = domain.SCUMCapabilityGateEnabled
err := ValidateGamePluginManifestRegistration(registration)
if err == nil || !strings.Contains(err.Error(), "evidenceStatus must be compatible") || !strings.Contains(err.Error(), "requiredSchemaFingerprint") {
t.Fatalf("expected enabled gate evidence violations, got %v", err)
}
}
func TestValidateServerInstanceDependencies(t *testing.T) {
instance := domain.ServerInstance{
ID: "server-1",
+309
View File
@@ -0,0 +1,309 @@
package validator
import (
"fmt"
"regexp"
"strings"
"browser.local/platform/domain"
)
const (
maxSCUMProbeObjects = 512
maxSCUMProbeColumnsPerObject = 256
maxSCUMProbeIndexesPerObject = 128
maxSCUMProbeForeignKeys = 128
maxSCUMProbeSamples = 3
maxSCUMProbeTimeoutMS = 10000
maxSCUMProbeResultBytes = 1024 * 1024
)
var scumHashPattern = regexp.MustCompile(`^sha256:[a-fA-F0-9]{64}$|^[a-fA-F0-9]{16,128}$`)
func ValidateSCUMSchemaProbeRequest(request domain.SCUMSchemaProbeRequest) error {
var violations []string
violations = appendRequired(violations, "requestId", request.RequestID)
violations = appendRequired(violations, "jobId", request.JobID)
violations = append(violations, validateSCUMBindingIdentity("binding", request.Binding)...)
violations = append(violations, validateSCUMSchemaProbeBounds("bounds", request.Bounds)...)
return finish(violations)
}
func ValidateSCUMSchemaProbeResult(result domain.SCUMSchemaProbeResult) error {
var violations []string
violations = appendRequired(violations, "requestId", result.RequestID)
violations = appendRequired(violations, "jobId", result.JobID)
violations = append(violations, validateSCUMBindingIdentity("binding", result.Binding)...)
if !validSCUMCapabilityEvidenceStatus(result.Status) {
violations = append(violations, "status is invalid")
}
if result.SchemaFingerprint != "" && !validSCUMFingerprint(result.SchemaFingerprint) {
violations = append(violations, "schemaFingerprint must be a digest/fingerprint")
}
if result.ResultDigest != "" && !validSCUMFingerprint(result.ResultDigest) {
violations = append(violations, "resultDigest must be a digest/fingerprint")
}
violations = append(violations, validateSCUMSafeError("safeError", result.SafeError)...)
violations = append(violations, validateSCUMSchemaProbeBounds("limits", result.Limits)...)
if len(result.Objects) > result.Limits.MaxObjects && result.Limits.MaxObjects > 0 {
violations = append(violations, "objects exceeds declared limit")
}
for i, object := range result.Objects {
field := fmt.Sprintf("objects[%d]", i)
violations = append(violations, validateSCUMSchemaObjectEvidence(field, object)...)
}
return finish(violations)
}
func ValidateSCUMCapabilityEvidence(evidence domain.SCUMCapabilityEvidence) error {
var violations []string
if !validSCUMDataCapability(evidence.Capability) {
violations = append(violations, "capability is invalid")
}
if !validSCUMCapabilityEvidenceStatus(evidence.Status) {
violations = append(violations, "status is invalid")
}
violations = append(violations, validateSCUMBindingIdentity("binding", evidence.Binding)...)
violations = appendRequired(violations, "adapterVersion", evidence.AdapterVersion)
if evidence.SchemaFingerprint != "" && !validSCUMFingerprint(evidence.SchemaFingerprint) {
violations = append(violations, "schemaFingerprint must be a digest/fingerprint")
}
if evidence.ProbeResultDigest != "" && !validSCUMFingerprint(evidence.ProbeResultDigest) {
violations = append(violations, "probeResultDigest must be a digest/fingerprint")
}
for i, digest := range evidence.AssetDigests {
if !validSCUMDigest(digest) {
violations = append(violations, fmt.Sprintf("assetDigests[%d] must be sha256 digest", i))
}
}
violations = append(violations, validateSCUMSafeError("safeError", evidence.SafeError)...)
return finish(violations)
}
func validateSCUMLiveDataManifest(prefix string, value domain.SCUMLiveDataManifest, capabilities []string, remoteAccess domain.GamePluginRemoteAccess, runtimeProfiles domain.GamePluginRuntimeProfiles) []string {
if value.SchemaVersion == "" && value.Probe.Capability == "" && len(value.CapabilityGates) == 0 {
return nil
}
var violations []string
if value.SchemaVersion != "1" {
violations = append(violations, prefix+".schemaVersion must be 1")
}
if value.Probe.Capability != domain.JobCapabilityRemoteRunDBSQLiteProbe {
violations = append(violations, prefix+".probe.capability must be "+domain.JobCapabilityRemoteRunDBSQLiteProbe)
}
if !containsString(capabilities, domain.JobCapabilityRemoteRunDBSQLiteProbe) || !containsString(remoteAccess.RunCapabilities, domain.JobCapabilityRemoteRunDBSQLiteProbe) {
violations = append(violations, prefix+".probe requires declared remote.run.db.sqlite.probe capability")
}
probeTransportFound := false
for _, transport := range runtimeProfiles.TransportProfiles {
if transport.Key != value.Probe.TargetKey && transport.TargetKey != value.Probe.TargetKey {
continue
}
probeTransportFound = true
if transport.Kind != "sqlite" || !containsString(transport.Capabilities, domain.JobCapabilityRemoteRunDBSQLiteProbe) {
violations = append(violations, prefix+".probe.targetKey must reference sqlite transport with remote.run.db.sqlite.probe")
}
}
if !probeTransportFound {
violations = append(violations, prefix+".probe.targetKey must reference a declared transport")
}
violations = append(violations, validateSCUMSchemaProbeBounds(prefix+".probe.bounds", value.Probe.Bounds)...)
seen := map[domain.SCUMDataCapability]struct{}{}
for i, gate := range value.CapabilityGates {
field := fmt.Sprintf("%s.capabilityGates[%d]", prefix, i)
if !validSCUMDataCapability(gate.Capability) {
violations = append(violations, field+".capability is invalid")
}
if _, exists := seen[gate.Capability]; exists {
violations = append(violations, field+".capability is duplicated")
}
seen[gate.Capability] = struct{}{}
if gate.Gate != domain.SCUMCapabilityGateDisabled && gate.Gate != domain.SCUMCapabilityGateEnabled {
violations = append(violations, field+".gate is invalid")
}
violations = appendRequired(violations, field+".adapterVersion", gate.AdapterVersion)
if !validSCUMCapabilityEvidenceStatus(gate.EvidenceStatus) {
violations = append(violations, field+".evidenceStatus is invalid")
}
if containsSCUMProtectedMaterial(gate.SafeReason) || len(gate.SafeReason) > 240 || strings.TrimSpace(gate.SafeReason) == "" {
violations = append(violations, field+".safeReason is unsafe")
}
if gate.Gate == domain.SCUMCapabilityGateEnabled {
if gate.EvidenceStatus != domain.SCUMCapabilityEvidenceCompatible {
violations = append(violations, field+".evidenceStatus must be compatible when enabled")
}
if !validSCUMFingerprint(gate.RequiredSchemaFingerprint) {
violations = append(violations, field+".requiredSchemaFingerprint is required when enabled")
}
if len(gate.RequiredAssetDigests) == 0 {
violations = append(violations, field+".requiredAssetDigests is required when enabled")
}
}
if gate.Gate == domain.SCUMCapabilityGateDisabled && gate.EvidenceStatus == domain.SCUMCapabilityEvidenceCompatible {
violations = append(violations, field+".evidenceStatus must not claim compatibility while disabled")
}
for digestIndex, digest := range gate.RequiredAssetDigests {
if !validSCUMDigest(digest) {
violations = append(violations, fmt.Sprintf("%s.requiredAssetDigests[%d] must be sha256 digest", field, digestIndex))
}
}
}
return violations
}
func validateSCUMBindingIdentity(prefix string, value domain.SCUMBindingIdentity) []string {
var violations []string
for _, item := range []struct{ name, value string }{{"serverInstanceId", value.ServerInstanceID}, {"runBindingId", value.RunBindingID}, {"runEndpointId", value.RunEndpointID}, {"pluginId", value.PluginID}, {"pluginVersion", value.PluginVersion}, {"adapterVersion", value.AdapterVersion}, {"databaseIdentity", value.DatabaseIdentity}} {
violations = appendRequired(violations, prefix+"."+item.name, item.value)
if containsSCUMProtectedMaterial(item.value) {
violations = append(violations, prefix+"."+item.name+" contains protected connection material")
}
}
if value.GameVersion != "" && containsSCUMProtectedMaterial(value.GameVersion) {
violations = append(violations, prefix+".gameVersion contains protected connection material")
}
return violations
}
func validateSCUMSchemaProbeBounds(prefix string, value domain.SCUMSchemaProbeBounds) []string {
var violations []string
if value.MaxObjects < 1 || value.MaxObjects > maxSCUMProbeObjects {
violations = append(violations, prefix+".maxObjects is out of bounds")
}
if value.MaxColumnsPerObject < 1 || value.MaxColumnsPerObject > maxSCUMProbeColumnsPerObject {
violations = append(violations, prefix+".maxColumnsPerObject is out of bounds")
}
if value.MaxIndexesPerObject < 0 || value.MaxIndexesPerObject > maxSCUMProbeIndexesPerObject {
violations = append(violations, prefix+".maxIndexesPerObject is out of bounds")
}
if value.MaxForeignKeys < 0 || value.MaxForeignKeys > maxSCUMProbeForeignKeys {
violations = append(violations, prefix+".maxForeignKeys is out of bounds")
}
if value.MaxCardinalityReads < 0 || value.MaxCardinalityReads > maxSCUMProbeObjects {
violations = append(violations, prefix+".maxCardinalityReads is out of bounds")
}
if value.MaxSampleRows < 0 || value.MaxSampleRows > maxSCUMProbeSamples {
violations = append(violations, prefix+".maxSampleRows is out of bounds")
}
if value.TimeoutMS < 1 || value.TimeoutMS > maxSCUMProbeTimeoutMS {
violations = append(violations, prefix+".timeoutMs is out of bounds")
}
if value.MaxResultBytes < 1 || value.MaxResultBytes > maxSCUMProbeResultBytes {
violations = append(violations, prefix+".maxResultBytes is out of bounds")
}
return violations
}
func validateSCUMSchemaObjectEvidence(prefix string, value domain.SCUMSchemaObjectEvidence) []string {
var violations []string
if !validSCUMFingerprint(value.ObjectHash) {
violations = append(violations, prefix+".objectHash must be a digest/fingerprint")
}
if value.Kind != "table" && value.Kind != "view" && value.Kind != "index" && value.Kind != "trigger" {
violations = append(violations, prefix+".kind is invalid")
}
if !validSCUMFingerprint(value.NameFingerprint) || containsSCUMProtectedMaterial(value.NameFingerprint) {
violations = append(violations, prefix+".nameFingerprint must be redacted")
}
for i, column := range value.DeclaredColumns {
field := fmt.Sprintf("%s.declaredColumns[%d]", prefix, i)
if !validSCUMFingerprint(column.NameFingerprint) || containsSCUMProtectedMaterial(column.NameFingerprint) {
violations = append(violations, field+".nameFingerprint must be redacted")
}
if containsSCUMProtectedMaterial(column.DeclaredType) {
violations = append(violations, field+".declaredType contains protected material")
}
}
for i, index := range value.Indexes {
field := fmt.Sprintf("%s.indexes[%d]", prefix, i)
if !validSCUMFingerprint(index.NameFingerprint) || containsSCUMProtectedMaterial(index.NameFingerprint) {
violations = append(violations, field+".nameFingerprint must be redacted")
}
for columnIndex, hash := range index.ColumnHashes {
if !validSCUMFingerprint(hash) {
violations = append(violations, fmt.Sprintf("%s.columnHashes[%d] must be a digest/fingerprint", field, columnIndex))
}
}
}
for i, fk := range value.ForeignKeys {
field := fmt.Sprintf("%s.foreignKeys[%d]", prefix, i)
for _, item := range []struct{ name, value string }{{"fromColumnHash", fk.FromColumnHash}, {"toObjectHash", fk.ToObjectHash}, {"toColumnHash", fk.ToColumnHash}} {
if !validSCUMFingerprint(item.value) {
violations = append(violations, field+"."+item.name+" must be a digest/fingerprint")
}
}
}
for i, sample := range value.SampleFingerprints {
if !validSCUMFingerprint(sample) || containsSCUMProtectedMaterial(sample) {
violations = append(violations, fmt.Sprintf("%s.sampleFingerprints[%d] must be a redacted fingerprint", prefix, i))
}
}
return violations
}
func validateSCUMSafeError(prefix string, value domain.SCUMSafeError) []string {
var violations []string
if !validSCUMSafeErrorCode(value.Code) {
violations = append(violations, prefix+".code is invalid")
}
if containsSCUMProtectedMaterial(value.Message) {
violations = append(violations, prefix+".message contains protected material")
}
if len(value.Message) > 320 {
violations = append(violations, prefix+".message is too long")
}
return violations
}
func validSCUMDataCapability(value domain.SCUMDataCapability) bool {
switch value {
case domain.SCUMDataCapabilitySchemaProbe, domain.SCUMDataCapabilityPlayerRead, domain.SCUMDataCapabilityPlayerDetailRead, domain.SCUMDataCapabilitySquadRead, domain.SCUMDataCapabilitySquadMemberRead, domain.SCUMDataCapabilityVehicleRead, domain.SCUMDataCapabilityFlagRead, domain.SCUMDataCapabilityPositionRead, domain.SCUMDataCapabilityProfileXMLWrite, domain.SCUMDataCapabilityEconomyCommand, domain.SCUMDataCapabilityGiftCommand:
return true
default:
return false
}
}
func validSCUMCapabilityEvidenceStatus(value domain.SCUMCapabilityEvidenceStatus) bool {
switch value {
case domain.SCUMCapabilityEvidenceMissing, domain.SCUMCapabilityEvidenceCompatible, domain.SCUMCapabilityEvidenceIncompatible, domain.SCUMCapabilityEvidenceFailed:
return true
default:
return false
}
}
func validSCUMSafeErrorCode(value domain.SCUMSafeErrorCode) bool {
switch value {
case "", domain.SCUMSafeErrorNone, domain.SCUMSafeErrorProbeExecutorAbsent, domain.SCUMSafeErrorProbeMissing, domain.SCUMSafeErrorProbeFailed, domain.SCUMSafeErrorSchemaIncompatible, domain.SCUMSafeErrorBindingMismatch, domain.SCUMSafeErrorAdapterMismatch, domain.SCUMSafeErrorFingerprintMismatch, domain.SCUMSafeErrorDigestMismatch, domain.SCUMSafeErrorEvidenceExpired, domain.SCUMSafeErrorInvalidProbePayload:
return true
default:
return false
}
}
func validSCUMFingerprint(value string) bool { return scumHashPattern.MatchString(value) }
func validSCUMDigest(value string) bool {
return regexp.MustCompile(`^sha256:[a-fA-F0-9]{64}$`).MatchString(value)
}
func containsSCUMProtectedMaterial(value string) bool {
trimmed := strings.TrimSpace(value)
lowered := strings.ToLower(trimmed)
if trimmed == "" {
return false
}
if strings.Contains(lowered, "select ") || strings.Contains(lowered, "insert into") || strings.Contains(lowered, "update ") || strings.Contains(lowered, "delete from") || strings.Contains(lowered, "pragma ") || strings.Contains(lowered, "attach database") {
return true
}
if strings.Contains(lowered, "dsn") || strings.Contains(lowered, "password") || strings.Contains(lowered, "credential") || strings.Contains(lowered, "token") || strings.Contains(lowered, "socket") || strings.Contains(lowered, "rcon") {
return true
}
if strings.HasPrefix(lowered, "sqlite://") || strings.HasPrefix(lowered, "mysql://") || strings.HasPrefix(lowered, "file://") || strings.HasPrefix(lowered, "tcp://") || strings.HasPrefix(lowered, "unix://") {
return true
}
if strings.HasPrefix(trimmed, "/") || strings.HasPrefix(trimmed, "\\\\") || regexp.MustCompile(`^[A-Za-z]:[\\/]`).MatchString(trimmed) {
return true
}
return false
}
+71
View File
@@ -0,0 +1,71 @@
package validator
import (
"strings"
"testing"
"time"
"browser.local/platform/domain"
)
const scumProbeHash = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
func TestValidateSCUMSchemaProbeRequestAllowsBoundedGenericProbe(t *testing.T) {
request := domain.SCUMSchemaProbeRequest{RequestID: "probe-1", JobID: "job-1", Binding: validatorSCUMBinding(), Bounds: domain.DefaultSCUMSchemaProbeBounds(), RequestedAt: time.Now()}
if err := ValidateSCUMSchemaProbeRequest(request); err != nil {
t.Fatalf("expected valid probe request, got %v", err)
}
}
func TestValidateSCUMSchemaProbeRequestRejectsHostPathsAndLooseBounds(t *testing.T) {
request := domain.SCUMSchemaProbeRequest{RequestID: "probe-1", JobID: "job-1", Binding: validatorSCUMBinding(), Bounds: domain.DefaultSCUMSchemaProbeBounds()}
request.Binding.DatabaseIdentity = `C:\SCUM\Saved\SaveFiles\SCUM.db`
request.Bounds.MaxSampleRows = 25
err := ValidateSCUMSchemaProbeRequest(request)
if err == nil || !strings.Contains(err.Error(), "protected connection material") || !strings.Contains(err.Error(), "maxSampleRows") {
t.Fatalf("expected protected material and bound violations, got %v", err)
}
}
func TestValidateSCUMSchemaProbeResultRejectsRawSQLAndRows(t *testing.T) {
result := domain.SCUMSchemaProbeResult{
RequestID: "probe-1",
JobID: "job-1",
Binding: validatorSCUMBinding(),
Status: domain.SCUMCapabilityEvidenceCompatible,
SchemaFingerprint: scumProbeHash,
ObservedAt: time.Now(),
ResultDigest: scumProbeHash,
Limits: domain.DefaultSCUMSchemaProbeBounds(),
Objects: []domain.SCUMSchemaObjectEvidence{{
ObjectHash: scumProbeHash,
Kind: "table",
NameFingerprint: "select * from players",
DeclaredColumns: []domain.SCUMSchemaColumnEvidence{{NameFingerprint: scumProbeHash, DeclaredType: "TEXT", Ordinal: 1}},
SampleFingerprints: []string{"{\"raw\":\"row\"}"},
}},
}
err := ValidateSCUMSchemaProbeResult(result)
if err == nil || !strings.Contains(err.Error(), "nameFingerprint must be redacted") || !strings.Contains(err.Error(), "sampleFingerprints") {
t.Fatalf("expected redaction violations, got %v", err)
}
}
func TestValidateSCUMCapabilityEvidenceRequiresSafeCurrentServiceIdentity(t *testing.T) {
evidence := domain.SCUMCapabilityEvidence{Capability: domain.SCUMDataCapabilityPlayerRead, Status: domain.SCUMCapabilityEvidenceCompatible, Binding: validatorSCUMBinding(), AdapterVersion: "adapter-1", SchemaFingerprint: scumProbeHash, ProbeResultDigest: scumProbeHash, AssetDigests: []string{scumProbeHash}, SafeError: domain.SCUMSafeError{Code: domain.SCUMSafeErrorNone}}
if err := ValidateSCUMCapabilityEvidence(evidence); err != nil {
t.Fatalf("expected valid evidence, got %v", err)
}
evidence.SafeError = domain.SCUMSafeError{Code: domain.SCUMSafeErrorProbeFailed, Message: "sqlite:///private/tmp/SCUM.db locked"}
if err := ValidateSCUMCapabilityEvidence(evidence); err == nil || !strings.Contains(err.Error(), "protected material") {
t.Fatalf("expected safe error redaction violation, got %v", err)
}
}
func validatorSCUMBinding() domain.SCUMBindingIdentity {
return domain.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-fingerprint-1"}
}
@@ -70,6 +70,7 @@
"remote.rsync.write",
"remote.run.files.read",
"remote.run.files.write",
"remote.run.db.sqlite.probe",
"remote.run.db.sqlite.query",
"remote.run.process.start",
"remote.run.process.stop",
@@ -95,6 +96,7 @@
"runCapabilities": [
"remote.run.files.read",
"remote.run.files.write",
"remote.run.db.sqlite.probe",
"remote.run.db.sqlite.query",
"remote.run.process.start",
"remote.run.process.stop",
@@ -556,6 +558,36 @@
"requestTimeoutSeconds": 15
}
},
"scumLiveData": {
"schemaVersion": "1",
"probe": {
"capability": "remote.run.db.sqlite.probe",
"targetKey": "scum-database",
"bounds": {
"maxObjects": 256,
"maxColumnsPerObject": 128,
"maxIndexesPerObject": 64,
"maxForeignKeys": 64,
"maxCardinalityReads": 64,
"maxSampleRows": 3,
"timeoutMs": 5000,
"maxResultBytes": 524288
}
},
"capabilityGates": [
{ "capability": "schema-probe", "gate": "disabled", "adapterVersion": "scum-live-data-v0", "evidenceStatus": "missing", "safeReason": "等待当前绑定的 Run 证明通用只读 schema probe 可用。" },
{ "capability": "players.read", "gate": "disabled", "adapterVersion": "scum-live-data-v0", "evidenceStatus": "missing", "safeReason": "等待当前服务 schema evidence 与版本化 adapter 匹配。" },
{ "capability": "player-details.read", "gate": "disabled", "adapterVersion": "scum-live-data-v0", "evidenceStatus": "missing", "safeReason": "等待当前服务玩家详情 join 与字段含义验证。" },
{ "capability": "squads.read", "gate": "disabled", "adapterVersion": "scum-live-data-v0", "evidenceStatus": "missing", "safeReason": "等待当前服务队伍表与 rank/leader 含义验证。" },
{ "capability": "squad-members.read", "gate": "disabled", "adapterVersion": "scum-live-data-v0", "evidenceStatus": "missing", "safeReason": "等待当前服务队伍成员 join 唯一性验证。" },
{ "capability": "vehicles.read", "gate": "disabled", "adapterVersion": "scum-live-data-v0", "evidenceStatus": "missing", "safeReason": "等待当前服务载具 identity、ownership 和坐标验证。" },
{ "capability": "flags.read", "gate": "disabled", "adapterVersion": "scum-live-data-v0", "evidenceStatus": "missing", "safeReason": "等待当前服务旗子 ownership 与 territory 关系验证。" },
{ "capability": "positions.read", "gate": "disabled", "adapterVersion": "scum-live-data-v0", "evidenceStatus": "missing", "safeReason": "等待当前服务坐标范围、cadence 和 map transform 验证。" },
{ "capability": "profile-xml.write", "gate": "disabled", "adapterVersion": "scum-live-data-v0", "evidenceStatus": "missing", "safeReason": "等待真实 XML 来源、字段名、backup 和离线安全证据验证。" },
{ "capability": "economy-command.write", "gate": "disabled", "adapterVersion": "scum-live-data-v0", "evidenceStatus": "missing", "safeReason": "等待 Fame/currency command 与 readback confirmation 验证。" },
{ "capability": "gift-command.write", "gate": "disabled", "adapterVersion": "scum-live-data-v0", "evidenceStatus": "missing", "safeReason": "等待 gift item catalog、transport 和 conclusive receipt 验证。" }
]
},
"permissions": [
"server.create",
"server.read",
@@ -1069,6 +1101,7 @@
"kind": "sqlite",
"targetKey": "scum-database",
"capabilities": [
"remote.run.db.sqlite.probe",
"remote.run.db.sqlite.query",
"remote.run.protected.sql"
]
@@ -50,6 +50,7 @@
"gameClientBridge": {
"$ref": "#/$defs/gameClientBridgeManifest"
},
"scumLiveData": { "$ref": "#/$defs/scumLiveDataManifest" },
"mapTrajectories": { "$ref": "#/$defs/mapTrajectoryDeclaration" },
"capabilities": {
"type": "array",
@@ -287,6 +288,48 @@
"companion": { "$ref": "#/$defs/gameClientBridgeCompanion" }
}
},
"scumLiveDataManifest": {
"type": "object",
"required": ["schemaVersion", "probe", "capabilityGates"],
"additionalProperties": false,
"properties": {
"schemaVersion": { "const": "1" },
"probe": { "$ref": "#/$defs/scumSchemaProbeDeclaration" },
"capabilityGates": { "type": "array", "items": { "$ref": "#/$defs/scumLiveDataCapabilityGate" }, "uniqueItems": true, "minItems": 1, "maxItems": 32 }
}
},
"scumSchemaProbeDeclaration": {
"type": "object",
"required": ["capability", "targetKey", "bounds"],
"additionalProperties": false,
"properties": {
"capability": { "const": "remote.run.db.sqlite.probe" },
"targetKey": { "$ref": "#/$defs/logicalKey" },
"bounds": { "$ref": "#/$defs/scumSchemaProbeBounds" }
}
},
"scumSchemaProbeBounds": {
"type": "object",
"required": ["maxObjects", "maxColumnsPerObject", "maxIndexesPerObject", "maxForeignKeys", "maxCardinalityReads", "maxSampleRows", "timeoutMs", "maxResultBytes"],
"additionalProperties": false,
"properties": {
"maxObjects": { "type": "integer", "minimum": 1, "maximum": 512 }, "maxColumnsPerObject": { "type": "integer", "minimum": 1, "maximum": 256 }, "maxIndexesPerObject": { "type": "integer", "minimum": 0, "maximum": 128 }, "maxForeignKeys": { "type": "integer", "minimum": 0, "maximum": 128 }, "maxCardinalityReads": { "type": "integer", "minimum": 0, "maximum": 512 }, "maxSampleRows": { "type": "integer", "minimum": 0, "maximum": 3 }, "timeoutMs": { "type": "integer", "minimum": 1, "maximum": 10000 }, "maxResultBytes": { "type": "integer", "minimum": 1, "maximum": 1048576 }
}
},
"scumLiveDataCapabilityGate": {
"type": "object",
"required": ["capability", "gate", "adapterVersion", "evidenceStatus", "safeReason"],
"additionalProperties": false,
"properties": {
"capability": { "enum": ["schema-probe", "players.read", "player-details.read", "squads.read", "squad-members.read", "vehicles.read", "flags.read", "positions.read", "profile-xml.write", "economy-command.write", "gift-command.write"] },
"gate": { "enum": ["disabled", "enabled"] },
"adapterVersion": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,79}$" },
"requiredSchemaFingerprint": { "type": "string", "pattern": "^(sha256:)?[a-fA-F0-9]{16,128}$" },
"requiredAssetDigests": { "type": "array", "items": { "type": "string", "pattern": "^sha256:[a-fA-F0-9]{64}$" }, "uniqueItems": true, "maxItems": 16 },
"evidenceStatus": { "enum": ["missing", "compatible", "incompatible", "failed"] },
"safeReason": { "type": "string", "minLength": 1, "maxLength": 240 }
}
},
"gameClientBridgeCompanion": {
"type": "object",
"required": ["profileKey", "configTemplateKey", "configSchemaRef", "configFormat", "platformBaseUrlSource", "registrationProof", "proofMaterialSource", "proofMaterialEnv", "sessionMode", "tlsPolicy", "heartbeatIntervalSeconds", "commandPollIntervalSeconds", "requestTimeoutSeconds"],
@@ -463,6 +506,7 @@
"remote.run.process.start",
"remote.run.process.stop",
"remote.run.db.mysql.query",
"remote.run.db.sqlite.probe",
"remote.run.db.sqlite.query",
"remote.run.logs.transfer",
"remote.run.rcon.command",
@@ -0,0 +1,46 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://browser.local/schemas/scum-live-data.schema.json",
"title": "SCUMLiveDataManifestDeclaration",
"type": "object",
"required": ["schemaVersion", "probe", "capabilityGates"],
"additionalProperties": false,
"properties": {
"schemaVersion": { "const": "1" },
"probe": {
"type": "object",
"required": ["capability", "targetKey", "bounds"],
"additionalProperties": false,
"properties": {
"capability": { "const": "remote.run.db.sqlite.probe" },
"targetKey": { "type": "string", "pattern": "^[a-z0-9][a-z0-9._/-]*$", "maxLength": 120 },
"bounds": { "$ref": "#/$defs/probeBounds" }
}
},
"capabilityGates": { "type": "array", "items": { "$ref": "#/$defs/capabilityGate" }, "minItems": 1, "maxItems": 32 }
},
"$defs": {
"probeBounds": {
"type": "object",
"required": ["maxObjects", "maxColumnsPerObject", "maxIndexesPerObject", "maxForeignKeys", "maxCardinalityReads", "maxSampleRows", "timeoutMs", "maxResultBytes"],
"additionalProperties": false,
"properties": {
"maxObjects": { "type": "integer", "minimum": 1, "maximum": 512 }, "maxColumnsPerObject": { "type": "integer", "minimum": 1, "maximum": 256 }, "maxIndexesPerObject": { "type": "integer", "minimum": 0, "maximum": 128 }, "maxForeignKeys": { "type": "integer", "minimum": 0, "maximum": 128 }, "maxCardinalityReads": { "type": "integer", "minimum": 0, "maximum": 512 }, "maxSampleRows": { "type": "integer", "minimum": 0, "maximum": 3 }, "timeoutMs": { "type": "integer", "minimum": 1, "maximum": 10000 }, "maxResultBytes": { "type": "integer", "minimum": 1, "maximum": 1048576 }
}
},
"capabilityGate": {
"type": "object",
"required": ["capability", "gate", "adapterVersion", "evidenceStatus", "safeReason"],
"additionalProperties": false,
"properties": {
"capability": { "enum": ["schema-probe", "players.read", "player-details.read", "squads.read", "squad-members.read", "vehicles.read", "flags.read", "positions.read", "profile-xml.write", "economy-command.write", "gift-command.write"] },
"gate": { "enum": ["disabled", "enabled"] },
"adapterVersion": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,79}$" },
"requiredSchemaFingerprint": { "type": "string", "pattern": "^(sha256:)?[a-fA-F0-9]{16,128}$" },
"requiredAssetDigests": { "type": "array", "items": { "type": "string", "pattern": "^sha256:[a-fA-F0-9]{64}$" }, "uniqueItems": true, "maxItems": 16 },
"evidenceStatus": { "enum": ["missing", "compatible", "incompatible", "failed"] },
"safeReason": { "type": "string", "minLength": 1, "maxLength": 240 }
}
}
}
}
+48
View File
@@ -1054,6 +1054,53 @@ export function validateRuntimeLogEventCatalog(manifest: unknown): string[] {
return errors;
}
export function validateSCUMLiveDataManifest(manifest: unknown): string[] {
if (typeof manifest !== "object" || manifest === null) return [];
type Gate = { capability?: string; gate?: string; adapterVersion?: string; requiredSchemaFingerprint?: string; requiredAssetDigests?: string[]; evidenceStatus?: string; safeReason?: string };
type Probe = { capability?: string; targetKey?: string; bounds?: { maxSampleRows?: number; timeoutMs?: number; maxResultBytes?: number } };
type Transport = { key?: string; kind?: string; targetKey?: string; capabilities?: string[] };
const declaration = manifest as { id?: string; capabilities?: string[]; remoteAccess?: { runCapabilities?: string[] }; runtimeProfiles?: { transportProfiles?: Transport[] }; scumLiveData?: { schemaVersion?: string; probe?: Probe; capabilityGates?: Gate[] } };
const liveData = declaration.scumLiveData;
if (!liveData) return [];
const errors: string[] = [];
const declaredCapabilities = new Set(declaration.capabilities ?? []);
const remoteCapabilities = new Set(declaration.remoteAccess?.runCapabilities ?? []);
const probe = liveData.probe;
const location = "manifest.scumLiveData";
if (liveData.schemaVersion !== "1") errors.push(`${location}.schemaVersion: must be 1`);
if (probe?.capability !== "remote.run.db.sqlite.probe") errors.push(`${location}.probe.capability: must be remote.run.db.sqlite.probe`);
if (!declaredCapabilities.has("remote.run.db.sqlite.probe") || !remoteCapabilities.has("remote.run.db.sqlite.probe")) errors.push(`${location}.probe: plugin and remoteAccess must declare remote.run.db.sqlite.probe`);
const transport = (declaration.runtimeProfiles?.transportProfiles ?? []).find((candidate) => candidate.targetKey === probe?.targetKey || candidate.key === probe?.targetKey);
if (!transport) {
errors.push(`${location}.probe.targetKey: must reference a declared runtime transport profile or target`);
} else {
if (transport.kind !== "sqlite") errors.push(`${location}.probe.targetKey: schema probe requires a sqlite transport profile`);
if (!transport.capabilities?.includes("remote.run.db.sqlite.probe")) errors.push(`${location}.probe.targetKey: sqlite transport must declare remote.run.db.sqlite.probe`);
}
if ((probe?.bounds?.maxSampleRows ?? 0) > 3) errors.push(`${location}.probe.bounds.maxSampleRows: redacted samples are limited to 3`);
if ((probe?.bounds?.timeoutMs ?? 0) > 10000) errors.push(`${location}.probe.bounds.timeoutMs: must be bounded to 10 seconds or less`);
if ((probe?.bounds?.maxResultBytes ?? 0) > 1048576) errors.push(`${location}.probe.bounds.maxResultBytes: must be bounded to 1 MiB or less`);
const gates = liveData.capabilityGates ?? [];
const seen = new Set<string>();
for (const [index, gate] of gates.entries()) {
const gateLocation = `${location}.capabilityGates[${index}]`;
const capability = gate.capability ?? "";
if (seen.has(capability)) errors.push(`${gateLocation}.capability: duplicate gate ${capability}`);
seen.add(capability);
const reasonErrors = unsafeStringReasons(gate.safeReason ?? "");
errors.push(...reasonErrors.map((reason) => `${gateLocation}.safeReason: ${reason}`));
if (gate.gate === "enabled") {
if (gate.evidenceStatus !== "compatible") errors.push(`${gateLocation}.evidenceStatus: enabled gates require compatible evidence`);
if (!/^(sha256:)?[a-fA-F0-9]{16,128}$/.test(gate.requiredSchemaFingerprint ?? "")) errors.push(`${gateLocation}.requiredSchemaFingerprint: enabled gates require a schema fingerprint`);
if (!Array.isArray(gate.requiredAssetDigests) || gate.requiredAssetDigests.length === 0) errors.push(`${gateLocation}.requiredAssetDigests: enabled gates require immutable asset digests`);
}
if (gate.gate === "disabled" && gate.evidenceStatus === "compatible") errors.push(`${gateLocation}.evidenceStatus: disabled gates must not claim compatible evidence`);
}
return errors;
}
type GameClientBridgeSchemaReference = {
location: string;
ref: string;
@@ -1372,6 +1419,7 @@ export function validateManifestFile(manifestPath: string): string[] {
errors.push(...validateClientManagerProfiles(manifest));
errors.push(...validateDLLExtensionProfiles(manifest));
errors.push(...validateGameClientBridgeCatalog(manifest));
errors.push(...validateSCUMLiveDataManifest(manifest));
errors.push(...validateGameClientBridgeSchemaFiles(manifest, manifestDir));
errors.push(...validateGameClientBridgeCompanionConfig(manifest, manifestDir));
errors.push(...validateRuntimeLogEventCatalog(manifest));
+51
View File
@@ -43,6 +43,7 @@ export type RunCapability =
| "remote.run.process.start"
| "remote.run.process.stop"
| "remote.run.db.mysql.query"
| "remote.run.db.sqlite.probe"
| "remote.run.db.sqlite.query"
| "remote.run.logs.transfer"
| "remote.run.rcon.command"
@@ -268,6 +269,55 @@ export interface GameClientBridgeQueryTemplateDeclaration {
export type GameClientBridgeOperationKind = "rcon" | "sqlite-mutation";
export type SCUMLiveDataCapability =
| "schema-probe"
| "players.read"
| "player-details.read"
| "squads.read"
| "squad-members.read"
| "vehicles.read"
| "flags.read"
| "positions.read"
| "profile-xml.write"
| "economy-command.write"
| "gift-command.write";
export type SCUMLiveDataGateState = "disabled" | "enabled";
export type SCUMLiveDataEvidenceStatus = "missing" | "compatible" | "incompatible" | "failed";
export interface SCUMLiveDataCapabilityGateDeclaration {
capability: SCUMLiveDataCapability;
gate: SCUMLiveDataGateState;
adapterVersion: string;
requiredSchemaFingerprint?: string;
requiredAssetDigests?: `sha256:${string}`[];
evidenceStatus: SCUMLiveDataEvidenceStatus;
safeReason: string;
}
export interface SCUMSchemaProbeBoundsDeclaration {
maxObjects: number;
maxColumnsPerObject: number;
maxIndexesPerObject: number;
maxForeignKeys: number;
maxCardinalityReads: number;
maxSampleRows: number;
timeoutMs: number;
maxResultBytes: number;
}
export interface SCUMSchemaProbeDeclaration {
capability: Extract<RunCapability, "remote.run.db.sqlite.probe">;
targetKey: string;
bounds: SCUMSchemaProbeBoundsDeclaration;
}
export interface SCUMLiveDataManifestDeclaration {
schemaVersion: "1";
probe: SCUMSchemaProbeDeclaration;
capabilityGates: SCUMLiveDataCapabilityGateDeclaration[];
}
export interface GameClientBridgeOperationSafety {
requiresApproval?: boolean;
requiresOfflinePlayer?: boolean;
@@ -722,6 +772,7 @@ export interface GamePluginManifest {
remoteAccess?: GamePluginRemoteAccess;
runtimeProfiles?: GamePluginRuntimeProfiles;
gameClientBridge?: GameClientBridgeManifest;
scumLiveData?: SCUMLiveDataManifestDeclaration;
actions?: GamePluginActions;
assetFiles?: PluginAssetFile[];
productionLifecycle: {
+20
View File
@@ -30,6 +30,7 @@ import {
type GameClientBridgeProtectedRequestDeclaration,
type GameClientBridgeCompanionDeclaration,
type GamePluginManifest,
type SCUMLiveDataManifestDeclaration,
type RuntimeLogEventDeclaration,
type RuntimeClientManagerProfile,
type PluginLifecycleActionDeclaration,
@@ -202,6 +203,25 @@ describe("plugin manifest validation", () => {
expect(fs.existsSync(path.join(pluginDir, "schemas/bridge/queries/SCUM_DB_CONTRACT.md"))).toBe(true);
});
it("keeps SCUM database-backed live-data gates disabled until current-service evidence exists", () => {
const manifest = JSON.parse(fs.readFileSync(path.join(pluginsRoot, "examples/scum-server-plugin/manifest.json"), "utf8")) as GamePluginManifest & { scumLiveData: SCUMLiveDataManifestDeclaration; remoteAccess: { runCapabilities: string[] } };
expect(manifest.capabilities).toContain("remote.run.db.sqlite.probe");
expect(manifest.remoteAccess.runCapabilities).toContain("remote.run.db.sqlite.probe");
expect(manifest.scumLiveData.probe).toMatchObject({ capability: "remote.run.db.sqlite.probe", targetKey: "scum-database" });
expect(manifest.scumLiveData.capabilityGates.map((gate) => gate.capability)).toEqual(expect.arrayContaining(["players.read", "squads.read", "vehicles.read", "flags.read", "positions.read", "profile-xml.write", "economy-command.write", "gift-command.write"]));
expect(manifest.scumLiveData.capabilityGates.every((gate) => gate.gate === "disabled" && gate.evidenceStatus === "missing")).toBe(true);
expect(JSON.stringify(manifest.scumLiveData).toLowerCase()).not.toMatch(/select\s+.+from|sqlite:\/\/|mysql:\/\/|password|credential|socket|hostpath/);
});
it("rejects enabling SCUM live-data gates without compatible evidence and immutable digests", () => {
const errors = validateTemporaryScumCompanionManifest((manifest) => {
manifest.scumLiveData.capabilityGates[1] = { ...manifest.scumLiveData.capabilityGates[1], gate: "enabled", evidenceStatus: "missing" };
});
expect(errors.some((error) => error.includes("enabled gates require compatible evidence"))).toBe(true);
expect(errors.some((error) => error.includes("enabled gates require a schema fingerprint"))).toBe(true);
expect(errors.some((error) => error.includes("enabled gates require immutable asset digests"))).toBe(true);
});
it("declares SCUM install/update and start lifecycle through plugin assets", () => {
const pluginDir = path.join(pluginsRoot, "examples/scum-server-plugin");
const manifest = JSON.parse(fs.readFileSync(path.join(pluginDir, "manifest.json"), "utf8")) as any;