Align runtime profiles with plugin-owned records
This commit is contained in:
@@ -4,29 +4,19 @@ import "testing"
|
|||||||
|
|
||||||
func TestCopyGameClientBridgeDeclarationsCopiesQueryTemplateSlices(t *testing.T) {
|
func TestCopyGameClientBridgeDeclarationsCopiesQueryTemplateSlices(t *testing.T) {
|
||||||
manifest := GameClientBridgeManifest{
|
manifest := GameClientBridgeManifest{
|
||||||
QueryTemplates: []GameClientBridgeQueryTemplateDeclaration{{Key: "player.lookup", PollIntervalSeconds: 3, SQLRef: "sql/player-lookup.sql", Projections: []GameClientBridgeQueryProjectionDeclaration{{Collection: "users", RowPath: "rows", MatchField: "kind", MatchValue: "player", UpsertKeys: []string{"steamId"}, FieldMappings: map[string]string{"steamId": "steamId"}, FixedValues: map[string]string{"source": "sqlite"}, ObservedAtField: "sampledAt", MergeExisting: true}}}},
|
QueryTemplates: []GameClientBridgeQueryTemplateDeclaration{{Key: "player.lookup", PollIntervalSeconds: 3, SQLRef: "sql/player-lookup.sql", Projections: []GameClientBridgeQueryProjectionDeclaration{{Collection: "users", RowPath: "rows", MatchField: "kind", MatchValue: "player", UpsertKeys: []string{"steamId"}, FieldMappings: map[string]string{"steamId": "steamId"}, FixedValues: map[string]string{"source": "sqlite"}, ObservedAtField: "sampledAt", MergeExisting: true}}}},
|
||||||
LogProjections: []GameClientBridgeLogProjectionDeclaration{{
|
|
||||||
Key: "player.login", StreamKeys: []string{"process.stdout"}, Steps: []GameClientBridgeLogProjectionStepDeclaration{{Pattern: `Player (?<slot>\d+)`}}, CorrelationFields: []string{"slot"}, MaxInterveningLines: 8,
|
|
||||||
Target: GameClientBridgeLogProjectionTargetDeclaration{Collection: "users", UpsertKeys: []string{"steamId"}, CaptureMappings: map[string]string{"steamId": "steamId"}, HashMappings: map[string]string{"networkCorrelation": "ip"}, FixedValues: map[string]string{"source": "stdout"}, ObservedAtField: "lastLoginAt"},
|
|
||||||
Presence: &GameClientBridgeLogProjectionPresenceDeclaration{TimestampField: "lastLoginAt", ActiveWindowSeconds: 600, ActivityTarget: &GameClientBridgeLogProjectionTargetDeclaration{Collection: "activity", UpsertKeys: []string{"steamId"}, CaptureMappings: map[string]string{"steamId": "steamId"}}},
|
|
||||||
}},
|
|
||||||
LifecycleProjections: []GameClientBridgeLifecycleProjectionDeclaration{{Key: "server.stop", Capabilities: []string{"process.stop"}, ProcessStates: []string{"stopped"}, Target: GameClientBridgeBulkProjectionTargetDeclaration{Collection: "users", MatchField: "online", MatchValue: "true", FixedValues: map[string]string{"online": "false"}, ActivityTarget: &GameClientBridgeBulkActivityTargetDeclaration{Collection: "activity", UpsertKeys: []string{"steamId"}, RowMappings: map[string]string{"steamId": "steamId"}, FixedValues: map[string]string{"eventType": "logout"}}}}},
|
LifecycleProjections: []GameClientBridgeLifecycleProjectionDeclaration{{Key: "server.stop", Capabilities: []string{"process.stop"}, ProcessStates: []string{"stopped"}, Target: GameClientBridgeBulkProjectionTargetDeclaration{Collection: "users", MatchField: "online", MatchValue: "true", FixedValues: map[string]string{"online": "false"}, ActivityTarget: &GameClientBridgeBulkActivityTargetDeclaration{Collection: "activity", UpsertKeys: []string{"steamId"}, RowMappings: map[string]string{"steamId": "steamId"}, FixedValues: map[string]string{"eventType": "logout"}}}}},
|
||||||
DataPacks: []GameClientBridgeDataPackDeclaration{{Key: "db-v1", LogParserRefs: []string{"logs.json"}, ConfigMapRefs: []string{"config.json"}, DataRefs: []string{"data.json"}}},
|
DataPacks: []GameClientBridgeDataPackDeclaration{{Key: "db-v1", ConfigMapRefs: []string{"config.json"}, DataRefs: []string{"data.json"}}},
|
||||||
Pages: []GameClientBridgePageContract{{PageKey: "players", QueryTemplateKeys: []string{"player.lookup"}}},
|
Pages: []GameClientBridgePageContract{{PageKey: "players", QueryTemplateKeys: []string{"player.lookup"}}},
|
||||||
}
|
}
|
||||||
manifestCopy := CopyGameClientBridgeManifest(manifest)
|
manifestCopy := CopyGameClientBridgeManifest(manifest)
|
||||||
manifestCopy.QueryTemplates[0].Key = "mutated"
|
manifestCopy.QueryTemplates[0].Key = "mutated"
|
||||||
manifestCopy.QueryTemplates[0].Projections[0].FieldMappings["steamId"] = "mutated"
|
manifestCopy.QueryTemplates[0].Projections[0].FieldMappings["steamId"] = "mutated"
|
||||||
manifestCopy.LogProjections[0].StreamKeys[0] = "mutated"
|
|
||||||
manifestCopy.LogProjections[0].Target.CaptureMappings["steamId"] = "mutated"
|
|
||||||
manifestCopy.LogProjections[0].Target.HashMappings["networkCorrelation"] = "mutated"
|
|
||||||
manifestCopy.LogProjections[0].Presence.ActivityTarget.CaptureMappings["steamId"] = "mutated"
|
|
||||||
manifestCopy.LifecycleProjections[0].Capabilities[0] = "mutated"
|
manifestCopy.LifecycleProjections[0].Capabilities[0] = "mutated"
|
||||||
manifestCopy.LifecycleProjections[0].Target.ActivityTarget.RowMappings["steamId"] = "mutated"
|
manifestCopy.LifecycleProjections[0].Target.ActivityTarget.RowMappings["steamId"] = "mutated"
|
||||||
manifestCopy.DataPacks[0].LogParserRefs[0] = "mutated"
|
|
||||||
manifestCopy.DataPacks[0].DataRefs[0] = "mutated"
|
manifestCopy.DataPacks[0].DataRefs[0] = "mutated"
|
||||||
manifestCopy.Pages[0].QueryTemplateKeys[0] = "mutated"
|
manifestCopy.Pages[0].QueryTemplateKeys[0] = "mutated"
|
||||||
if manifest.QueryTemplates[0].Key != "player.lookup" || manifest.QueryTemplates[0].SQLRef != "sql/player-lookup.sql" || manifest.QueryTemplates[0].Projections[0].FieldMappings["steamId"] != "steamId" || !manifest.QueryTemplates[0].Projections[0].MergeExisting || manifest.LogProjections[0].StreamKeys[0] != "process.stdout" || manifest.LogProjections[0].Target.CaptureMappings["steamId"] != "steamId" || manifest.LogProjections[0].Target.HashMappings["networkCorrelation"] != "ip" || manifest.LogProjections[0].Presence.ActivityTarget.CaptureMappings["steamId"] != "steamId" || manifest.LifecycleProjections[0].Capabilities[0] != "process.stop" || manifest.LifecycleProjections[0].Target.ActivityTarget.RowMappings["steamId"] != "steamId" || manifest.DataPacks[0].LogParserRefs[0] != "logs.json" || manifest.DataPacks[0].DataRefs[0] != "data.json" || manifest.Pages[0].QueryTemplateKeys[0] != "player.lookup" {
|
if manifest.QueryTemplates[0].Key != "player.lookup" || manifest.QueryTemplates[0].SQLRef != "sql/player-lookup.sql" || manifest.QueryTemplates[0].Projections[0].FieldMappings["steamId"] != "steamId" || !manifest.QueryTemplates[0].Projections[0].MergeExisting || manifest.LifecycleProjections[0].Capabilities[0] != "process.stop" || manifest.LifecycleProjections[0].Target.ActivityTarget.RowMappings["steamId"] != "steamId" || manifest.DataPacks[0].DataRefs[0] != "data.json" || manifest.Pages[0].QueryTemplateKeys[0] != "player.lookup" {
|
||||||
t.Fatalf("manifest copy aliases query template declarations: source=%#v copy=%#v", manifest, manifestCopy)
|
t.Fatalf("manifest copy aliases query template declarations: source=%#v copy=%#v", manifest, manifestCopy)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -442,26 +442,6 @@ type RuntimeLogSource struct {
|
|||||||
RetentionDays int
|
RetentionDays int
|
||||||
}
|
}
|
||||||
|
|
||||||
type RuntimeLogEventSeverity string
|
|
||||||
|
|
||||||
const (
|
|
||||||
RuntimeLogEventSeverityInfo RuntimeLogEventSeverity = "info"
|
|
||||||
RuntimeLogEventSeverityNotice RuntimeLogEventSeverity = "notice"
|
|
||||||
RuntimeLogEventSeverityWarning RuntimeLogEventSeverity = "warning"
|
|
||||||
RuntimeLogEventSeverityCritical RuntimeLogEventSeverity = "critical"
|
|
||||||
)
|
|
||||||
|
|
||||||
type RuntimeLogEvent struct {
|
|
||||||
Key string
|
|
||||||
Title string
|
|
||||||
SourceKey string
|
|
||||||
EventType string
|
|
||||||
Permission string
|
|
||||||
SchemaRef string
|
|
||||||
RetentionDays int
|
|
||||||
Severity RuntimeLogEventSeverity
|
|
||||||
}
|
|
||||||
|
|
||||||
type RuntimeTransportProfile struct {
|
type RuntimeTransportProfile struct {
|
||||||
Key string
|
Key string
|
||||||
Kind string
|
Kind string
|
||||||
@@ -469,6 +449,20 @@ type RuntimeTransportProfile struct {
|
|||||||
Capabilities []string
|
Capabilities []string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RuntimeDataTarget declares a bounded plugin-owned local data snapshot that
|
||||||
|
// Run may materialize before a typed query. It never carries a host path.
|
||||||
|
type RuntimeDataTarget struct {
|
||||||
|
Key string
|
||||||
|
Kind string
|
||||||
|
TransportKey string
|
||||||
|
SourceRootKey string
|
||||||
|
SourcePath string
|
||||||
|
WorkspaceKey string
|
||||||
|
RefreshPolicy string
|
||||||
|
MaxBytes int64
|
||||||
|
Platforms []string
|
||||||
|
}
|
||||||
|
|
||||||
type RuntimeClientManagerProfile struct {
|
type RuntimeClientManagerProfile struct {
|
||||||
Key string
|
Key string
|
||||||
DisplayName string
|
DisplayName string
|
||||||
@@ -597,8 +591,8 @@ type GamePluginRuntimeProfiles struct {
|
|||||||
InstallPlans []RuntimeInstallPlan
|
InstallPlans []RuntimeInstallPlan
|
||||||
ServerDeployments []RuntimeServerDeploymentProfile
|
ServerDeployments []RuntimeServerDeploymentProfile
|
||||||
LogSources []RuntimeLogSource
|
LogSources []RuntimeLogSource
|
||||||
LogEvents []RuntimeLogEvent
|
|
||||||
TransportProfiles []RuntimeTransportProfile
|
TransportProfiles []RuntimeTransportProfile
|
||||||
|
DataTargets []RuntimeDataTarget
|
||||||
ClientManagers []RuntimeClientManagerProfile
|
ClientManagers []RuntimeClientManagerProfile
|
||||||
DLLExtensions []RuntimeDLLExtensionProfile
|
DLLExtensions []RuntimeDLLExtensionProfile
|
||||||
}
|
}
|
||||||
@@ -1926,11 +1920,14 @@ func CopyGamePluginRuntimeProfiles(profiles GamePluginRuntimeProfiles) GamePlugi
|
|||||||
profiles.ServerDeployments[i] = CopyRuntimeServerDeploymentProfile(profiles.ServerDeployments[i])
|
profiles.ServerDeployments[i] = CopyRuntimeServerDeploymentProfile(profiles.ServerDeployments[i])
|
||||||
}
|
}
|
||||||
profiles.LogSources = append([]RuntimeLogSource(nil), profiles.LogSources...)
|
profiles.LogSources = append([]RuntimeLogSource(nil), profiles.LogSources...)
|
||||||
profiles.LogEvents = append([]RuntimeLogEvent(nil), profiles.LogEvents...)
|
|
||||||
profiles.TransportProfiles = append([]RuntimeTransportProfile(nil), profiles.TransportProfiles...)
|
profiles.TransportProfiles = append([]RuntimeTransportProfile(nil), profiles.TransportProfiles...)
|
||||||
for i := range profiles.TransportProfiles {
|
for i := range profiles.TransportProfiles {
|
||||||
profiles.TransportProfiles[i].Capabilities = CopyStringSlice(profiles.TransportProfiles[i].Capabilities)
|
profiles.TransportProfiles[i].Capabilities = CopyStringSlice(profiles.TransportProfiles[i].Capabilities)
|
||||||
}
|
}
|
||||||
|
profiles.DataTargets = append([]RuntimeDataTarget(nil), profiles.DataTargets...)
|
||||||
|
for i := range profiles.DataTargets {
|
||||||
|
profiles.DataTargets[i].Platforms = CopyStringSlice(profiles.DataTargets[i].Platforms)
|
||||||
|
}
|
||||||
profiles.ClientManagers = append([]RuntimeClientManagerProfile(nil), profiles.ClientManagers...)
|
profiles.ClientManagers = append([]RuntimeClientManagerProfile(nil), profiles.ClientManagers...)
|
||||||
for i := range profiles.ClientManagers {
|
for i := range profiles.ClientManagers {
|
||||||
profiles.ClientManagers[i].SupportedTargets = append([]RuntimeTarget(nil), profiles.ClientManagers[i].SupportedTargets...)
|
profiles.ClientManagers[i].SupportedTargets = append([]RuntimeTarget(nil), profiles.ClientManagers[i].SupportedTargets...)
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
package domain
|
|
||||||
|
|
||||||
import "testing"
|
|
||||||
|
|
||||||
func TestCopyGamePluginRuntimeProfilesCopiesLogEvents(t *testing.T) {
|
|
||||||
profiles := GamePluginRuntimeProfiles{
|
|
||||||
LogEvents: []RuntimeLogEvent{{
|
|
||||||
Key: "chat-message", Title: "Chat message", SourceKey: "chat-log", EventType: "chat.message",
|
|
||||||
Permission: "server.logs.read", SchemaRef: "schemas/log-events/chat-message.schema.json", RetentionDays: 30, Severity: "info",
|
|
||||||
}},
|
|
||||||
}
|
|
||||||
|
|
||||||
copied := CopyGamePluginRuntimeProfiles(profiles)
|
|
||||||
copied.LogEvents[0].Title = "Mutated"
|
|
||||||
|
|
||||||
if profiles.LogEvents[0].Title != "Chat message" {
|
|
||||||
t.Fatalf("expected log event declarations to be copied, got %+v", profiles.LogEvents)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -139,20 +139,15 @@ func TestGameClientBridgeQueryTemplateDeclarationRoundTripIsSafe(t *testing.T) {
|
|||||||
ParameterSchemaRef: "schemas/bridge/query/player-lookup.parameters.schema.json", ResultSchemaRef: "schemas/bridge/query/player-lookup.result.schema.json", SQLRef: "sql/player-lookup.sql", MaxRows: 50, TimeoutSeconds: 10, PollIntervalSeconds: 3,
|
ParameterSchemaRef: "schemas/bridge/query/player-lookup.parameters.schema.json", ResultSchemaRef: "schemas/bridge/query/player-lookup.result.schema.json", SQLRef: "sql/player-lookup.sql", MaxRows: 50, TimeoutSeconds: 10, PollIntervalSeconds: 3,
|
||||||
Projections: []GameClientBridgeQueryProjectionDeclarationBody{{Collection: "users", RowPath: "rows", MatchField: "kind", MatchValue: "player", UpsertKeys: []string{"steamId"}, FieldMappings: map[string]string{"steamId": "steamId"}, FixedValues: map[string]string{"source": "sqlite"}, ObservedAtField: "sampledAt", MergeExisting: true}},
|
Projections: []GameClientBridgeQueryProjectionDeclarationBody{{Collection: "users", RowPath: "rows", MatchField: "kind", MatchValue: "player", UpsertKeys: []string{"steamId"}, FieldMappings: map[string]string{"steamId": "steamId"}, FixedValues: map[string]string{"source": "sqlite"}, ObservedAtField: "sampledAt", MergeExisting: true}},
|
||||||
}},
|
}},
|
||||||
LogProjections: []GameClientBridgeLogProjectionDeclarationBody{{
|
|
||||||
Key: "player.login", StreamKeys: []string{"process.stdout"}, Steps: []GameClientBridgeLogProjectionStepDeclarationBody{{Pattern: `Player (?<slot>\d+)`}}, CorrelationFields: []string{"slot"}, MaxInterveningLines: 8,
|
|
||||||
Target: GameClientBridgeLogProjectionTargetDeclarationBody{Collection: "users", UpsertKeys: []string{"steamId"}, CaptureMappings: map[string]string{"steamId": "steamId"}, HashMappings: map[string]string{"networkCorrelation": "ip"}, FixedValues: map[string]string{"source": "stdout"}, ObservedAtField: "lastLoginAt"},
|
|
||||||
Presence: &GameClientBridgeLogProjectionPresenceDeclarationBody{TimestampField: "lastLoginAt", ActiveWindowSeconds: 600, ActivityTarget: &GameClientBridgeLogProjectionTargetDeclarationBody{Collection: "activity", UpsertKeys: []string{"steamId"}, CaptureMappings: map[string]string{"steamId": "steamId"}}},
|
|
||||||
}},
|
|
||||||
LifecycleProjections: []GameClientBridgeLifecycleProjectionDeclarationBody{{Key: "server.stop", Capabilities: []string{"process.stop"}, Target: GameClientBridgeBulkProjectionTargetBody{Collection: "users", MatchField: "online", MatchValue: "true", FixedValues: map[string]string{"online": "false"}, ObservedAtField: "lastLogoutAt", ActivityTarget: &GameClientBridgeBulkActivityTargetBody{Collection: "activity", UpsertKeys: []string{"steamId", "observedAt"}, RowMappings: map[string]string{"steamId": "steamId"}, FixedValues: map[string]string{"eventType": "logout"}, ObservedAtField: "observedAt"}}}},
|
LifecycleProjections: []GameClientBridgeLifecycleProjectionDeclarationBody{{Key: "server.stop", Capabilities: []string{"process.stop"}, Target: GameClientBridgeBulkProjectionTargetBody{Collection: "users", MatchField: "online", MatchValue: "true", FixedValues: map[string]string{"online": "false"}, ObservedAtField: "lastLogoutAt", ActivityTarget: &GameClientBridgeBulkActivityTargetBody{Collection: "activity", UpsertKeys: []string{"steamId", "observedAt"}, RowMappings: map[string]string{"steamId": "steamId"}, FixedValues: map[string]string{"eventType": "logout"}, ObservedAtField: "observedAt"}}}},
|
||||||
DataPacks: []GameClientBridgeDataPackDeclarationBody{{Key: "db-v1", DatabaseUserVersion: 1, LogParserRefs: []string{"data/logs.json"}, ConfigMapRefs: []string{"data/config.json"}, DataRefs: []string{"data/items.json"}}},
|
DataPacks: []GameClientBridgeDataPackDeclarationBody{{Key: "db-v1", DatabaseUserVersion: 1, ConfigMapRefs: []string{"data/config.json"}, DataRefs: []string{"data/items.json"}}},
|
||||||
CommandRetentionSeconds: 86400,
|
CommandRetentionSeconds: 86400,
|
||||||
MaxCommands: 1000,
|
MaxCommands: 1000,
|
||||||
Pages: []GameClientBridgePageContractBody{{PageKey: "players", QueryTemplateKeys: []string{"player.lookup"}}},
|
Pages: []GameClientBridgePageContractBody{{PageKey: "players", QueryTemplateKeys: []string{"player.lookup"}}},
|
||||||
}
|
}
|
||||||
|
|
||||||
domainManifest := body.ToDomain()
|
domainManifest := body.ToDomain()
|
||||||
if len(domainManifest.QueryTemplates) != 1 || domainManifest.QueryTemplates[0].SQLRef != "sql/player-lookup.sql" || domainManifest.QueryTemplates[0].PollIntervalSeconds != 3 || len(domainManifest.QueryTemplates[0].Projections) != 1 || domainManifest.QueryTemplates[0].Projections[0].MatchValue != "player" || !domainManifest.QueryTemplates[0].Projections[0].MergeExisting || len(domainManifest.LogProjections) != 1 || domainManifest.LogProjections[0].Presence.ActiveWindowSeconds != 600 || domainManifest.LogProjections[0].Target.HashMappings["networkCorrelation"] != "ip" || len(domainManifest.LifecycleProjections) != 1 || domainManifest.LifecycleProjections[0].Target.ActivityTarget.FixedValues["eventType"] != "logout" || len(domainManifest.DataPacks) != 1 || domainManifest.DataPacks[0].DataRefs[0] != "data/items.json" || domainManifest.Pages[0].QueryTemplateKeys[0] != "player.lookup" {
|
if len(domainManifest.QueryTemplates) != 1 || domainManifest.QueryTemplates[0].SQLRef != "sql/player-lookup.sql" || domainManifest.QueryTemplates[0].PollIntervalSeconds != 3 || len(domainManifest.QueryTemplates[0].Projections) != 1 || domainManifest.QueryTemplates[0].Projections[0].MatchValue != "player" || !domainManifest.QueryTemplates[0].Projections[0].MergeExisting || len(domainManifest.LifecycleProjections) != 1 || domainManifest.LifecycleProjections[0].Target.ActivityTarget.FixedValues["eventType"] != "logout" || len(domainManifest.DataPacks) != 1 || domainManifest.DataPacks[0].DataRefs[0] != "data/items.json" || domainManifest.Pages[0].QueryTemplateKeys[0] != "player.lookup" {
|
||||||
t.Fatalf("query template conversion lost declaration fields: %#v", domainManifest)
|
t.Fatalf("query template conversion lost declaration fields: %#v", domainManifest)
|
||||||
}
|
}
|
||||||
domainManifest.QueryTemplates[0].Projections[0].FieldMappings["steamId"] = "mutated"
|
domainManifest.QueryTemplates[0].Projections[0].FieldMappings["steamId"] = "mutated"
|
||||||
@@ -160,11 +155,6 @@ func TestGameClientBridgeQueryTemplateDeclarationRoundTripIsSafe(t *testing.T) {
|
|||||||
t.Fatal("query projection target aliases request DTO data")
|
t.Fatal("query projection target aliases request DTO data")
|
||||||
}
|
}
|
||||||
domainManifest.QueryTemplates[0].Projections[0].FieldMappings["steamId"] = "steamId"
|
domainManifest.QueryTemplates[0].Projections[0].FieldMappings["steamId"] = "steamId"
|
||||||
domainManifest.LogProjections[0].Target.CaptureMappings["steamId"] = "mutated"
|
|
||||||
if body.LogProjections[0].Target.CaptureMappings["steamId"] != "steamId" {
|
|
||||||
t.Fatal("log projection target aliases request DTO data")
|
|
||||||
}
|
|
||||||
domainManifest.LogProjections[0].Target.CaptureMappings["steamId"] = "steamId"
|
|
||||||
domainManifest.LifecycleProjections[0].Target.ActivityTarget.RowMappings["steamId"] = "mutated"
|
domainManifest.LifecycleProjections[0].Target.ActivityTarget.RowMappings["steamId"] = "mutated"
|
||||||
if body.LifecycleProjections[0].Target.ActivityTarget.RowMappings["steamId"] != "steamId" {
|
if body.LifecycleProjections[0].Target.ActivityTarget.RowMappings["steamId"] != "steamId" {
|
||||||
t.Fatal("lifecycle projection target aliases request DTO data")
|
t.Fatal("lifecycle projection target aliases request DTO data")
|
||||||
@@ -190,10 +180,6 @@ func TestGameClientBridgeQueryTemplateDeclarationRoundTripIsSafe(t *testing.T) {
|
|||||||
t.Fatal("query projection target aliases domain data")
|
t.Fatal("query projection target aliases domain data")
|
||||||
}
|
}
|
||||||
response.QueryTemplates[0].Projections[0].FixedValues["source"] = "sqlite"
|
response.QueryTemplates[0].Projections[0].FixedValues["source"] = "sqlite"
|
||||||
response.LogProjections[0].Target.FixedValues["source"] = "mutated"
|
|
||||||
if domainManifest.LogProjections[0].Target.FixedValues["source"] != "stdout" {
|
|
||||||
t.Fatal("log projection target aliases domain data")
|
|
||||||
}
|
|
||||||
response.LifecycleProjections[0].Target.FixedValues["online"] = "mutated"
|
response.LifecycleProjections[0].Target.FixedValues["online"] = "mutated"
|
||||||
if domainManifest.LifecycleProjections[0].Target.FixedValues["online"] != "false" {
|
if domainManifest.LifecycleProjections[0].Target.FixedValues["online"] != "false" {
|
||||||
t.Fatal("lifecycle projection target aliases domain data")
|
t.Fatal("lifecycle projection target aliases domain data")
|
||||||
|
|||||||
@@ -1,45 +0,0 @@
|
|||||||
package dto
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"browser.local/platform/domain"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestRuntimeLogEventDeclarationRoundTripUsesSafeProjection(t *testing.T) {
|
|
||||||
body := GamePluginRuntimeProfilesBody{
|
|
||||||
LogEvents: []RuntimeLogEventBody{{
|
|
||||||
Key: "chat-message", Title: "Chat message", SourceKey: "chat-log", EventType: "chat.message",
|
|
||||||
Permission: "server.logs.read", SchemaRef: "schemas/log-events/chat-message.schema.json", RetentionDays: 30, Severity: "info",
|
|
||||||
}},
|
|
||||||
}
|
|
||||||
|
|
||||||
profiles := body.ToDomain()
|
|
||||||
if len(profiles.LogEvents) != 1 || profiles.LogEvents[0].EventType != "chat.message" || profiles.LogEvents[0].Severity != "info" {
|
|
||||||
t.Fatalf("log event conversion lost declaration fields: %+v", profiles.LogEvents)
|
|
||||||
}
|
|
||||||
profiles.LogEvents[0].Title = "Mutated"
|
|
||||||
if body.LogEvents[0].Title != "Chat message" {
|
|
||||||
t.Fatal("log event domain conversion aliases request DTO data")
|
|
||||||
}
|
|
||||||
|
|
||||||
projection := runtimeProfilesFromDomain(domain.GamePluginRuntimeProfiles{LogEvents: profiles.LogEvents})
|
|
||||||
encoded, err := json.Marshal(projection.LogEvents[0])
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("marshal log event projection: %v", err)
|
|
||||||
}
|
|
||||||
var fields map[string]any
|
|
||||||
if err := json.Unmarshal(encoded, &fields); err != nil {
|
|
||||||
t.Fatalf("decode log event projection: %v", err)
|
|
||||||
}
|
|
||||||
expected := []string{"key", "title", "sourceKey", "eventType", "permission", "schemaRef", "retentionDays", "severity"}
|
|
||||||
if len(fields) != len(expected) {
|
|
||||||
t.Fatalf("log event projection contains unexpected fields: %s", encoded)
|
|
||||||
}
|
|
||||||
for _, field := range expected {
|
|
||||||
if _, exists := fields[field]; !exists {
|
|
||||||
t.Fatalf("log event projection is missing %q: %s", field, encoded)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -24,3 +24,28 @@ func applyPluginCreateDefaults(plugin domain.GamePlugin, definition domain.Serve
|
|||||||
func deploymentNeedsCompleteRuntimeBinding(_ domain.GamePlugin, definition domain.ServerDeploymentDefinition) bool {
|
func deploymentNeedsCompleteRuntimeBinding(_ domain.GamePlugin, definition domain.ServerDeploymentDefinition) bool {
|
||||||
return definition.Mode == ""
|
return definition.Mode == ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// lifecycleDefaultProfileKey selects a plugin-declared lifecycle profile when
|
||||||
|
// an older server record has no explicit deployment profile. Runtime bindings
|
||||||
|
// remain optional logical transport overrides; they must not be required for
|
||||||
|
// standard Run lifecycle, file, or log workflows.
|
||||||
|
func lifecycleDefaultProfileKey(_ domain.ServerInstance, plugin domain.GamePlugin, profileKey string) string {
|
||||||
|
if profileKey != "" {
|
||||||
|
if profile, ok := runtimeLifecycleProfileForKey(plugin.RuntimeProfiles, profileKey); ok {
|
||||||
|
return profile.Key
|
||||||
|
}
|
||||||
|
return profileKey
|
||||||
|
}
|
||||||
|
return firstRuntimeLifecycleProfileKey(plugin)
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultRunDistributionProfileKey(plugin domain.GamePlugin, profileKey string) string {
|
||||||
|
return lifecycleDefaultProfileKey(domain.ServerInstance{}, plugin, profileKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstRuntimeLifecycleProfileKey(plugin domain.GamePlugin) string {
|
||||||
|
if len(plugin.RuntimeProfiles.LifecycleProfiles) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return plugin.RuntimeProfiles.LifecycleProfiles[0].Key
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ package service
|
|||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -231,16 +233,24 @@ func readLogSegment(path string) (domain.LogBatchRecord, error) {
|
|||||||
defer file.Close()
|
defer file.Close()
|
||||||
|
|
||||||
entries := []domain.LogEntry{}
|
entries := []domain.LogEntry{}
|
||||||
scanner := bufio.NewScanner(file)
|
reader := bufio.NewReader(file)
|
||||||
for scanner.Scan() {
|
for {
|
||||||
|
line, readErr := reader.ReadBytes('\n')
|
||||||
|
if len(line) == 0 && errors.Is(readErr, io.EOF) {
|
||||||
|
break
|
||||||
|
}
|
||||||
var entry domain.LogEntry
|
var entry domain.LogEntry
|
||||||
if err := json.Unmarshal(scanner.Bytes(), &entry); err != nil {
|
if err := json.Unmarshal(line, &entry); err != nil {
|
||||||
return domain.LogBatchRecord{}, fmt.Errorf("decode log segment %s: %w", filepath.Base(path), err)
|
return domain.LogBatchRecord{}, fmt.Errorf("decode log segment %s: %w", filepath.Base(path), err)
|
||||||
}
|
}
|
||||||
entries = append(entries, domain.CopyLogEntry(entry))
|
entries = append(entries, domain.CopyLogEntry(entry))
|
||||||
}
|
if readErr == nil {
|
||||||
if err := scanner.Err(); err != nil {
|
continue
|
||||||
return domain.LogBatchRecord{}, fmt.Errorf("read log segment %s: %w", filepath.Base(path), err)
|
}
|
||||||
|
if errors.Is(readErr, io.EOF) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
return domain.LogBatchRecord{}, fmt.Errorf("read log segment %s: %w", filepath.Base(path), readErr)
|
||||||
}
|
}
|
||||||
sort.SliceStable(entries, func(i, j int) bool { return entries[i].Seq < entries[j].Seq })
|
sort.SliceStable(entries, func(i, j int) bool { return entries[i].Seq < entries[j].Seq })
|
||||||
if len(entries) == 0 {
|
if len(entries) == 0 {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
"browser.local/platform/domain"
|
"browser.local/platform/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestSanitizeLogNetworkFieldsIsGameAgnostic(t *testing.T) {
|
func TestLogIngestPreservesOpaqueFields(t *testing.T) {
|
||||||
batch := domain.LogBatchIngest{Entries: []domain.LogEntry{
|
batch := domain.LogBatchIngest{Entries: []domain.LogEntry{
|
||||||
{Fields: map[string]string{
|
{Fields: map[string]string{
|
||||||
"eventType": "game.session.opened",
|
"eventType": "game.session.opened",
|
||||||
@@ -17,15 +17,10 @@ func TestSanitizeLogNetworkFieldsIsGameAgnostic(t *testing.T) {
|
|||||||
}},
|
}},
|
||||||
}}
|
}}
|
||||||
|
|
||||||
sanitizeLogNetworkFields(&batch)
|
fields := storedLogEntries(batch.Entries)[0].Fields
|
||||||
|
for key, expected := range map[string]string{"networkFingerprint": "fingerprint", "ip": "192.0.2.1", "ipAddress": "2001:db8::1", "playerId": "player-1"} {
|
||||||
fields := batch.Entries[0].Fields
|
if fields[key] != expected {
|
||||||
for _, key := range []string{"networkFingerprint", "ip", "ipAddress"} {
|
t.Fatalf("expected opaque field %s to be preserved, got %q", key, fields[key])
|
||||||
if _, exists := fields[key]; exists {
|
|
||||||
t.Fatalf("expected %s to be removed", key)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if fields["playerId"] != "player-1" {
|
|
||||||
t.Fatal("expected unrelated fields to be preserved")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,245 +0,0 @@
|
|||||||
package service
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/hex"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"regexp"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"browser.local/platform/domain"
|
|
||||||
"browser.local/platform/repo"
|
|
||||||
)
|
|
||||||
|
|
||||||
type pluginLogSequenceState struct {
|
|
||||||
StepIndex int
|
|
||||||
Captures map[string]string
|
|
||||||
LastSeq uint64
|
|
||||||
}
|
|
||||||
|
|
||||||
func (svc *CoreService) projectPluginLogBatch(stream domain.LogStream, entries []domain.LogEntry) error {
|
|
||||||
if len(entries) == 0 || (stream.Source != domain.LogStreamSourceProcess && stream.Source != domain.LogStreamSourceFile && stream.Source != domain.LogStreamSourceManagementProgram) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
instance, err := svc.store.ServerInstances().Get(stream.ServerInstanceID)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
for _, projection := range plugin.GameClientBridge.LogProjections {
|
|
||||||
if !containsString(projection.StreamKeys, stream.StreamKey) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for _, entry := range entries {
|
|
||||||
captures, complete, matchErr := svc.advancePluginLogProjection(stream, projection, entry)
|
|
||||||
if matchErr != nil {
|
|
||||||
return matchErr
|
|
||||||
}
|
|
||||||
if complete {
|
|
||||||
observedAt := entry.Timestamp
|
|
||||||
if observedAt.IsZero() {
|
|
||||||
observedAt = svc.now()
|
|
||||||
}
|
|
||||||
if err := svc.applyPluginLogProjection(instance, plugin, projection, captures, observedAt); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (svc *CoreService) advancePluginLogProjection(stream domain.LogStream, projection domain.GameClientBridgeLogProjectionDeclaration, entry domain.LogEntry) (map[string]string, bool, error) {
|
|
||||||
if len(projection.Steps) == 0 {
|
|
||||||
return nil, false, nil
|
|
||||||
}
|
|
||||||
stateKey := strings.Join([]string{stream.ServerInstanceID, stream.ID, stream.LogSessionID, projection.Key}, "\x00")
|
|
||||||
svc.logProjectionMu.Lock()
|
|
||||||
defer svc.logProjectionMu.Unlock()
|
|
||||||
states := svc.logProjectionStates[stateKey]
|
|
||||||
if states == nil {
|
|
||||||
states = map[string]pluginLogSequenceState{}
|
|
||||||
svc.logProjectionStates[stateKey] = states
|
|
||||||
}
|
|
||||||
nextStates := make(map[string]pluginLogSequenceState, len(states)+1)
|
|
||||||
var completed map[string]string
|
|
||||||
for correlationKey, state := range states {
|
|
||||||
if state.StepIndex < 1 || state.StepIndex >= len(projection.Steps) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if projection.MaxInterveningLines >= 0 && state.LastSeq > 0 && entry.Seq > state.LastSeq+uint64(projection.MaxInterveningLines)+1 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
match, err := matchLogProjectionStep(projection.Steps[state.StepIndex].Pattern, entry.Line)
|
|
||||||
if err != nil {
|
|
||||||
return nil, false, err
|
|
||||||
}
|
|
||||||
if match == nil {
|
|
||||||
nextStates[correlationKey] = state
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
merged, ok := mergeLogCaptures(state.Captures, match)
|
|
||||||
if !ok || !correlationCapturesAgree(state.Captures, match, projection.CorrelationFields) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if state.StepIndex+1 == len(projection.Steps) {
|
|
||||||
completed = merged
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
nextKey := logCorrelationKey(merged, projection.CorrelationFields)
|
|
||||||
nextStates[nextKey] = pluginLogSequenceState{StepIndex: state.StepIndex + 1, Captures: merged, LastSeq: entry.Seq}
|
|
||||||
}
|
|
||||||
first, err := matchLogProjectionStep(projection.Steps[0].Pattern, entry.Line)
|
|
||||||
if err != nil {
|
|
||||||
return nil, false, err
|
|
||||||
}
|
|
||||||
if first != nil {
|
|
||||||
if len(projection.Steps) == 1 {
|
|
||||||
completed = first
|
|
||||||
} else {
|
|
||||||
key := logCorrelationKey(first, projection.CorrelationFields)
|
|
||||||
nextStates[key] = pluginLogSequenceState{StepIndex: 1, Captures: first, LastSeq: entry.Seq}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
svc.logProjectionStates[stateKey] = nextStates
|
|
||||||
return completed, completed != nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func matchLogProjectionStep(pattern, line string) (map[string]string, error) {
|
|
||||||
expression, err := regexp.Compile(pattern)
|
|
||||||
if err != nil {
|
|
||||||
return nil, validationError("declared log projection pattern is invalid")
|
|
||||||
}
|
|
||||||
values := expression.FindStringSubmatch(line)
|
|
||||||
if values == nil {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
result := make(map[string]string)
|
|
||||||
for index, name := range expression.SubexpNames() {
|
|
||||||
if index > 0 && name != "" && index < len(values) {
|
|
||||||
result[name] = values[index]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func mergeLogCaptures(existing, incoming map[string]string) (map[string]string, bool) {
|
|
||||||
merged := make(map[string]string, len(existing)+len(incoming))
|
|
||||||
for key, value := range existing {
|
|
||||||
merged[key] = value
|
|
||||||
}
|
|
||||||
for key, value := range incoming {
|
|
||||||
if previous, exists := merged[key]; exists && previous != value {
|
|
||||||
return nil, false
|
|
||||||
}
|
|
||||||
merged[key] = value
|
|
||||||
}
|
|
||||||
return merged, true
|
|
||||||
}
|
|
||||||
|
|
||||||
func correlationCapturesAgree(existing, incoming map[string]string, fields []string) bool {
|
|
||||||
for _, field := range fields {
|
|
||||||
left, leftExists := existing[field]
|
|
||||||
right, rightExists := incoming[field]
|
|
||||||
if leftExists && rightExists && left != right {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
func logCorrelationKey(captures map[string]string, fields []string) string {
|
|
||||||
parts := make([]string, len(fields))
|
|
||||||
for index, field := range fields {
|
|
||||||
parts[index] = captures[field]
|
|
||||||
}
|
|
||||||
return strings.Join(parts, "\x1f")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (svc *CoreService) applyPluginLogProjection(instance domain.ServerInstance, plugin domain.GamePlugin, projection domain.GameClientBridgeLogProjectionDeclaration, captures map[string]string, observedAt time.Time) error {
|
|
||||||
value := pluginLogProjectionValue(instance.ID, projection.Target, captures, observedAt)
|
|
||||||
key, err := pluginDataRowKey(value, projection.Target.UpsertKeys)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
existing, getErr := svc.store.PluginDataRecords().Get(pluginDataID(instance.ID, plugin.ID, projection.Target.Collection, key))
|
|
||||||
isNew := errors.Is(getErr, repo.ErrNotFound)
|
|
||||||
if getErr != nil && !isNew {
|
|
||||||
return getErr
|
|
||||||
}
|
|
||||||
insideWindow := false
|
|
||||||
sameObservation := false
|
|
||||||
if projection.Presence != nil && !isNew {
|
|
||||||
if previous, ok := pluginDataTimestamp(existing.Value[projection.Presence.TimestampField]); ok {
|
|
||||||
if observedAt.Before(previous) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
sameObservation = observedAt.Equal(previous)
|
|
||||||
insideWindow = observedAt.Sub(previous) < time.Duration(projection.Presence.ActiveWindowSeconds)*time.Second
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if insideWindow && !sameObservation {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if !isNew {
|
|
||||||
value = mergePluginDataValues(existing.Value, value)
|
|
||||||
}
|
|
||||||
if _, err := svc.applyPluginDataTransaction(domain.PluginDataTransaction{PluginID: plugin.ID, ServerInstanceID: instance.ID, Collection: projection.Target.Collection, Mutations: []domain.PluginDataMutation{{Operation: domain.PluginDataMutationPut, Key: key, Value: value}}}); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if projection.Presence != nil && projection.Presence.ActivityTarget != nil {
|
|
||||||
activity := pluginLogProjectionValue(instance.ID, *projection.Presence.ActivityTarget, captures, observedAt)
|
|
||||||
activityKey, keyErr := pluginDataRowKey(activity, projection.Presence.ActivityTarget.UpsertKeys)
|
|
||||||
if keyErr != nil {
|
|
||||||
return keyErr
|
|
||||||
}
|
|
||||||
if _, applyErr := svc.applyPluginDataTransaction(domain.PluginDataTransaction{PluginID: plugin.ID, ServerInstanceID: instance.ID, Collection: projection.Presence.ActivityTarget.Collection, Mutations: []domain.PluginDataMutation{{Operation: domain.PluginDataMutationPut, Key: activityKey, Value: activity}}}); applyErr != nil {
|
|
||||||
return applyErr
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func pluginLogProjectionValue(serverID string, target domain.GameClientBridgeLogProjectionTargetDeclaration, captures map[string]string, observedAt time.Time) map[string]any {
|
|
||||||
value := make(map[string]any, len(target.CaptureMappings)+len(target.HashMappings)+len(target.FixedValues)+1)
|
|
||||||
for destination, capture := range target.CaptureMappings {
|
|
||||||
value[destination] = captures[capture]
|
|
||||||
}
|
|
||||||
for destination, capture := range target.HashMappings {
|
|
||||||
value[destination] = logProjectionCorrelationHash(serverID, captures[capture])
|
|
||||||
}
|
|
||||||
for key, fixed := range target.FixedValues {
|
|
||||||
value[key] = renderLogProjectionTemplate(fixed, captures)
|
|
||||||
}
|
|
||||||
if target.ObservedAtField != "" {
|
|
||||||
value[target.ObservedAtField] = observedAt.UTC().Format(time.RFC3339Nano)
|
|
||||||
}
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
|
|
||||||
func logProjectionCorrelationHash(serverID, value string) string {
|
|
||||||
digest := sha256.Sum256([]byte(serverID + "\x00" + value))
|
|
||||||
return hex.EncodeToString(digest[:])
|
|
||||||
}
|
|
||||||
|
|
||||||
func renderLogProjectionTemplate(template string, captures map[string]string) string {
|
|
||||||
result := template
|
|
||||||
for key, value := range captures {
|
|
||||||
result = strings.ReplaceAll(result, "{{"+key+"}}", value)
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
func pluginDataTimestamp(value any) (time.Time, bool) {
|
|
||||||
text := strings.TrimSpace(fmt.Sprint(value))
|
|
||||||
if text == "" || text == "<nil>" {
|
|
||||||
return time.Time{}, false
|
|
||||||
}
|
|
||||||
parsed, err := time.Parse(time.RFC3339Nano, text)
|
|
||||||
return parsed, err == nil
|
|
||||||
}
|
|
||||||
@@ -1,237 +0,0 @@
|
|||||||
package service
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"browser.local/platform/domain"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestDurableStdoutProjectionCreatesUsersAndSuppressesRapidDuplicates(t *testing.T) {
|
|
||||||
svc := newTestCoreService()
|
|
||||||
plugin, endpoint := createPluginAndRunEndpoint(t, svc)
|
|
||||||
plugin.RuntimeProfiles.ClientManagers = append(plugin.RuntimeProfiles.ClientManagers, domain.RuntimeClientManagerProfile{Key: "scum-client", Health: domain.RuntimeClientManagerHealth{RequiredCapabilities: []string{"game-client.bridge"}}})
|
|
||||||
plugin.GameClientBridge.LogProjections = []domain.GameClientBridgeLogProjectionDeclaration{{
|
|
||||||
Key: "player.login", StreamKeys: []string{"stdout"}, CorrelationFields: []string{"playerSlot"}, MaxInterveningLines: 4,
|
|
||||||
Steps: []domain.GameClientBridgeLogProjectionStepDeclaration{
|
|
||||||
{Pattern: `Player "(?P<displayName>[^"]+)" reported as player (?P<playerSlot>[0-9]+)`},
|
|
||||||
{Pattern: `Player (?P<playerSlot>[0-9]+) SteamID \(assumed\): (?P<steamId>[0-9]+)`},
|
|
||||||
},
|
|
||||||
Target: domain.GameClientBridgeLogProjectionTargetDeclaration{
|
|
||||||
Collection: "scum_users", UpsertKeys: []string{"steamId"},
|
|
||||||
CaptureMappings: map[string]string{"steamId": "steamId", "displayName": "displayName", "playerSlot": "playerSlot"},
|
|
||||||
FixedValues: map[string]string{"online": "true", "source": "supervised-stdout"}, ObservedAtField: "lastLoginAt",
|
|
||||||
},
|
|
||||||
Presence: &domain.GameClientBridgeLogProjectionPresenceDeclaration{
|
|
||||||
TimestampField: "lastLoginAt", ActiveWindowSeconds: 600,
|
|
||||||
ActivityTarget: &domain.GameClientBridgeLogProjectionTargetDeclaration{
|
|
||||||
Collection: "scum_activity_events", UpsertKeys: []string{"steamId", "observedAt"},
|
|
||||||
CaptureMappings: map[string]string{"steamId": "steamId", "displayName": "displayName"}, FixedValues: map[string]string{"eventType": "login"}, ObservedAtField: "observedAt",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
if err := svc.store.GamePlugins().Update(plugin); err != nil {
|
|
||||||
t.Fatalf("update plugin projection: %v", err)
|
|
||||||
}
|
|
||||||
instance, err := svc.CreateServerInstance(domain.ServerInstance{ID: "server-log-projection", PluginID: plugin.ID, RunEndpointID: endpoint.ID, Name: "SCUM projection", State: domain.ServerInstanceStateRunning})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("create server: %v", err)
|
|
||||||
}
|
|
||||||
helloRequest := validRunControlHello()
|
|
||||||
helloRequest.CapabilityReport.Fingerprint = "cap-log-projection"
|
|
||||||
hello, err := svc.RegisterRunHello(helloRequest)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("register Run: %v", err)
|
|
||||||
}
|
|
||||||
stream, err := svc.CreateLogStream(domain.LogStream{ID: "log-projection", ServerInstanceID: instance.ID, Source: domain.LogStreamSourceProcess, StreamKey: "stdout", StorageBackend: domain.LogStorageBackendLocalSegments, RetentionPolicy: "default"})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("create stdout stream: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
base := time.Date(2026, 8, 18, 23, 25, 12, 0, time.UTC)
|
|
||||||
ingestProjectionLines(t, svc, hello.SessionToken, endpoint.ID, instance.ID, stream.ID, 1, base, []string{
|
|
||||||
`LogBattlEye: Display: Player "love_fitting" reported as player 0`,
|
|
||||||
`LogBattlEye: Display: Player #0 love_fitting (redacted) connected`,
|
|
||||||
})
|
|
||||||
ingestProjectionLines(t, svc, hello.SessionToken, endpoint.ID, instance.ID, stream.ID, 3, base.Add(2*time.Second), []string{
|
|
||||||
`LogBattlEye: Display: Player 0 SteamID (assumed): 76561199510658111`,
|
|
||||||
})
|
|
||||||
assertPresenceProjectionCounts(t, svc, plugin.ID, instance.ID, 1, 1, 0)
|
|
||||||
|
|
||||||
ingestProjectionLines(t, svc, hello.SessionToken, endpoint.ID, instance.ID, stream.ID, 4, base.Add(5*time.Minute), []string{
|
|
||||||
`LogBattlEye: Display: Player "love_fitting" reported as player 0`,
|
|
||||||
`LogBattlEye: Display: Player 0 SteamID (assumed): 76561199510658111`,
|
|
||||||
})
|
|
||||||
assertPresenceProjectionCounts(t, svc, plugin.ID, instance.ID, 1, 1, 0)
|
|
||||||
|
|
||||||
ingestProjectionLines(t, svc, hello.SessionToken, endpoint.ID, instance.ID, stream.ID, 6, base.Add(11*time.Minute), []string{
|
|
||||||
`LogBattlEye: Display: Player "love_fitting" reported as player 0`,
|
|
||||||
`LogBattlEye: Display: Player 0 SteamID (assumed): 76561199510658111`,
|
|
||||||
})
|
|
||||||
assertPresenceProjectionCounts(t, svc, plugin.ID, instance.ID, 1, 2, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTradeLogProjectionsCreateCatalogAndTradeEvents(t *testing.T) {
|
|
||||||
svc := newTestCoreService()
|
|
||||||
plugin, endpoint := createPluginAndRunEndpoint(t, svc)
|
|
||||||
pattern := `^\d{4}\.\d{2}\.\d{2}-\d{2}\.\d{2}\.\d{2}: \[Trade\] Tradeable \((?P<itemCode>[A-Za-z0-9_.-]{1,128}) \(x(?P<quantity>\d{1,9})\)\) (?P<tradeVerb>purchased|sold) by .*?\((?P<steamId>\d{1,50})\) for (?P<price>-?\d{1,12})$`
|
|
||||||
plugin.GameClientBridge.LogProjections = []domain.GameClientBridgeLogProjectionDeclaration{{
|
|
||||||
Key: "scum.trade.catalog", StreamKeys: []string{"scum.trade"}, Steps: []domain.GameClientBridgeLogProjectionStepDeclaration{{Pattern: pattern}}, CorrelationFields: []string{"itemCode"}, MaxInterveningLines: 0,
|
|
||||||
Target: domain.GameClientBridgeLogProjectionTargetDeclaration{Collection: "scum_trade_goods", UpsertKeys: []string{"code"}, CaptureMappings: map[string]string{"code": "itemCode"}, FixedValues: map[string]string{"catalogType": "item", "source": "scum.trade"}, ObservedAtField: "lastSeenAt"},
|
|
||||||
}, {
|
|
||||||
Key: "scum.trade.events", StreamKeys: []string{"scum.trade"}, Steps: []domain.GameClientBridgeLogProjectionStepDeclaration{{Pattern: pattern}}, CorrelationFields: []string{"steamId", "itemCode", "tradeVerb"}, MaxInterveningLines: 0,
|
|
||||||
Target: domain.GameClientBridgeLogProjectionTargetDeclaration{Collection: "scum_trade_events", UpsertKeys: []string{"steamId", "itemCode", "tradeVerb", "quantity", "price", "observedAt"}, CaptureMappings: map[string]string{"steamId": "steamId", "itemCode": "itemCode", "tradeVerb": "tradeVerb", "quantity": "quantity", "price": "price"}, FixedValues: map[string]string{"eventType": "trade", "source": "scum.trade"}, ObservedAtField: "observedAt"},
|
|
||||||
}}
|
|
||||||
if err := svc.store.GamePlugins().Update(plugin); err != nil {
|
|
||||||
t.Fatalf("update trade projections: %v", err)
|
|
||||||
}
|
|
||||||
instance, err := svc.CreateServerInstance(domain.ServerInstance{ID: "server-trade-projection", PluginID: plugin.ID, RunEndpointID: endpoint.ID, Name: "SCUM trade projection", State: domain.ServerInstanceStateRunning})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("create server: %v", err)
|
|
||||||
}
|
|
||||||
helloRequest := validRunControlHello()
|
|
||||||
helloRequest.CapabilityReport.Fingerprint = "cap-trade-log-projection"
|
|
||||||
hello, err := svc.RegisterRunHello(helloRequest)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("register Run: %v", err)
|
|
||||||
}
|
|
||||||
stream, err := svc.CreateLogStream(domain.LogStream{ID: "trade-log-projection", ServerInstanceID: instance.ID, Source: domain.LogStreamSourceFile, StreamKey: "scum.trade", StorageBackend: domain.LogStorageBackendLocalSegments, RetentionPolicy: "default"})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("create trade stream: %v", err)
|
|
||||||
}
|
|
||||||
base := time.Date(2026, 8, 27, 12, 34, 56, 0, time.UTC)
|
|
||||||
ingestTradeProjectionLines(t, svc, hello.SessionToken, endpoint.ID, instance.ID, stream.ID, 1, base, []string{`2026.08.27-12.34.56: [Trade] Tradeable (BPC_Apple (x2)) purchased by Mira(76561198000000001) for 120`})
|
|
||||||
goods, err := svc.store.PluginDataRecords().List(domain.PluginDataFilter{PluginID: plugin.ID, ServerInstanceID: instance.ID, Collection: "scum_trade_goods"})
|
|
||||||
if err != nil || len(goods) != 1 || goods[0].Key != "BPC_Apple" || goods[0].Value["catalogType"] != "item" || goods[0].Value["lastSeenAt"] == nil {
|
|
||||||
t.Fatalf("trade item catalog was not projected: %+v err=%v", goods, err)
|
|
||||||
}
|
|
||||||
trades, err := svc.store.PluginDataRecords().List(domain.PluginDataFilter{PluginID: plugin.ID, ServerInstanceID: instance.ID, Collection: "scum_trade_events"})
|
|
||||||
if err != nil || len(trades) != 1 || trades[0].Value["itemCode"] != "BPC_Apple" || trades[0].Value["quantity"] != "2" || trades[0].Value["tradeVerb"] != "purchased" || trades[0].Value["steamId"] != "76561198000000001" || trades[0].Value["price"] != "120" {
|
|
||||||
t.Fatalf("trade event was not projected: %+v err=%v", trades, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLifecycleProjectionMarksOnlineUsersOffline(t *testing.T) {
|
|
||||||
svc := newTestCoreService()
|
|
||||||
plugin, endpoint := createPluginAndRunEndpoint(t, svc)
|
|
||||||
plugin.GameClientBridge.LifecycleProjections = []domain.GameClientBridgeLifecycleProjectionDeclaration{{
|
|
||||||
Key: "server.stop", Capabilities: []string{domain.LifecycleCapabilityStop}, ProcessStates: []string{"stopped"},
|
|
||||||
Target: domain.GameClientBridgeBulkProjectionTargetDeclaration{Collection: "scum_users", MatchField: "online", MatchValue: "true", FixedValues: map[string]string{"online": "false", "status": "offline", "logoutReason": "server-stop"}, ObservedAtField: "lastLogoutAt", ActivityTarget: &domain.GameClientBridgeBulkActivityTargetDeclaration{Collection: "scum_activity_events", UpsertKeys: []string{"steamId", "observedAt", "eventType"}, RowMappings: map[string]string{"steamId": "steamId", "displayName": "displayName"}, FixedValues: map[string]string{"eventType": "logout", "reason": "server-stop"}, ObservedAtField: "observedAt"}},
|
|
||||||
}}
|
|
||||||
if err := svc.store.GamePlugins().Update(plugin); err != nil {
|
|
||||||
t.Fatalf("update lifecycle projection plugin: %v", err)
|
|
||||||
}
|
|
||||||
instance, err := svc.CreateServerInstance(domain.ServerInstance{ID: "server-lifecycle-projection", PluginID: plugin.ID, RunEndpointID: endpoint.ID, Name: "SCUM lifecycle", State: domain.ServerInstanceStateRunning})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("create server: %v", err)
|
|
||||||
}
|
|
||||||
if _, err := svc.applyPluginDataTransaction(domain.PluginDataTransaction{PluginID: plugin.ID, ServerInstanceID: instance.ID, Collection: "scum_users", Mutations: []domain.PluginDataMutation{
|
|
||||||
{Operation: domain.PluginDataMutationPut, Key: "steam-1", Value: map[string]any{"steamId": "steam-1", "displayName": "Ada", "online": "true"}},
|
|
||||||
{Operation: domain.PluginDataMutationPut, Key: "steam-2", Value: map[string]any{"steamId": "steam-2", "displayName": "Lin", "online": "false"}},
|
|
||||||
}}); err != nil {
|
|
||||||
t.Fatalf("seed plugin users: %v", err)
|
|
||||||
}
|
|
||||||
helloRequest := validRunControlHello()
|
|
||||||
helloRequest.CapabilityReport.Capabilities = append(helloRequest.CapabilityReport.Capabilities, domain.LifecycleCapabilityStop)
|
|
||||||
helloRequest.CapabilityReport.Fingerprint = "cap-lifecycle-projection"
|
|
||||||
hello, err := svc.RegisterRunHello(helloRequest)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("register Run: %v", err)
|
|
||||||
}
|
|
||||||
_, err = svc.ReportRunLifecycle(domain.RunLifecycleReport{RunEndpointID: endpoint.ID, SessionToken: hello.SessionToken, ServerInstanceID: instance.ID, Capability: domain.LifecycleCapabilityStop, State: domain.JobStateSucceeded, Progress: domain.RunJobProgressReport{Percent: 100}, ExecutionResult: domain.JobExecutionResult{Kind: "process", ProcessState: "stopped", ExitClassification: "requested-stop"}})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("report lifecycle stop: %v", err)
|
|
||||||
}
|
|
||||||
users, err := svc.store.PluginDataRecords().List(domain.PluginDataFilter{PluginID: plugin.ID, ServerInstanceID: instance.ID, Collection: "scum_users"})
|
|
||||||
if err != nil || len(users) != 2 {
|
|
||||||
t.Fatalf("list lifecycle users=%+v err=%v", users, err)
|
|
||||||
}
|
|
||||||
for _, user := range users {
|
|
||||||
if user.Key == "steam-1" && (user.Value["online"] != "false" || user.Value["logoutReason"] != "server-stop" || user.Value["lastLogoutAt"] == nil) {
|
|
||||||
t.Fatalf("online user was not logged out: %+v", user)
|
|
||||||
}
|
|
||||||
if user.Key == "steam-2" && user.Value["logoutReason"] != nil {
|
|
||||||
t.Fatalf("offline user should not receive duplicate logout: %+v", user)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
activity, err := svc.store.PluginDataRecords().List(domain.PluginDataFilter{PluginID: plugin.ID, ServerInstanceID: instance.ID, Collection: "scum_activity_events"})
|
|
||||||
if err != nil || len(activity) != 1 || activity[0].Value["steamId"] != "steam-1" || activity[0].Value["eventType"] != "logout" {
|
|
||||||
t.Fatalf("lifecycle logout activity not projected: %+v err=%v", activity, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLifecycleRestartReportMarksOnlineUsersOffline(t *testing.T) {
|
|
||||||
svc := newTestCoreService()
|
|
||||||
plugin, endpoint := createPluginAndRunEndpoint(t, svc)
|
|
||||||
plugin.GameClientBridge.LifecycleProjections = []domain.GameClientBridgeLifecycleProjectionDeclaration{{
|
|
||||||
Key: "server.restart", Capabilities: []string{"process.restart"},
|
|
||||||
Target: domain.GameClientBridgeBulkProjectionTargetDeclaration{Collection: "scum_users", MatchField: "online", MatchValue: "true", FixedValues: map[string]string{"online": "false", "status": "offline", "logoutReason": "server-stop"}, ObservedAtField: "lastLogoutAt"},
|
|
||||||
}}
|
|
||||||
if err := svc.store.GamePlugins().Update(plugin); err != nil {
|
|
||||||
t.Fatalf("update restart projection plugin: %v", err)
|
|
||||||
}
|
|
||||||
instance, err := svc.CreateServerInstance(domain.ServerInstance{ID: "server-restart-projection", PluginID: plugin.ID, RunEndpointID: endpoint.ID, Name: "SCUM restart", State: domain.ServerInstanceStateRunning})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("create server: %v", err)
|
|
||||||
}
|
|
||||||
if _, err := svc.applyPluginDataTransaction(domain.PluginDataTransaction{PluginID: plugin.ID, ServerInstanceID: instance.ID, Collection: "scum_users", Mutations: []domain.PluginDataMutation{{Operation: domain.PluginDataMutationPut, Key: "steam-1", Value: map[string]any{"steamId": "steam-1", "displayName": "Ada", "online": true}}}}); err != nil {
|
|
||||||
t.Fatalf("seed plugin users: %v", err)
|
|
||||||
}
|
|
||||||
helloRequest := validRunControlHello()
|
|
||||||
helloRequest.CapabilityReport.Capabilities = append(helloRequest.CapabilityReport.Capabilities, "process.restart")
|
|
||||||
helloRequest.CapabilityReport.Fingerprint = "cap-restart-lifecycle-projection"
|
|
||||||
hello, err := svc.RegisterRunHello(helloRequest)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("register Run: %v", err)
|
|
||||||
}
|
|
||||||
report, err := svc.ReportRunLifecycle(domain.RunLifecycleReport{RunEndpointID: endpoint.ID, SessionToken: hello.SessionToken, ServerInstanceID: instance.ID, Capability: "process.restart", State: domain.JobStateSucceeded, Progress: domain.RunJobProgressReport{Percent: 100}, ExecutionResult: domain.JobExecutionResult{Kind: "process", ProcessState: "running"}})
|
|
||||||
if err != nil || report.ProjectedState != domain.ServerInstanceStateRunning {
|
|
||||||
t.Fatalf("report lifecycle restart: report=%+v err=%v", report, err)
|
|
||||||
}
|
|
||||||
users, err := svc.store.PluginDataRecords().List(domain.PluginDataFilter{PluginID: plugin.ID, ServerInstanceID: instance.ID, Collection: "scum_users"})
|
|
||||||
if err != nil || len(users) != 1 || users[0].Value["online"] != "false" || users[0].Value["logoutReason"] != "server-stop" || users[0].Value["lastLogoutAt"] == nil {
|
|
||||||
t.Fatalf("restart did not log out online users: %+v err=%v", users, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func ingestProjectionLines(t *testing.T, svc *CoreService, sessionToken, endpointID, serverID, streamID string, firstSeq uint64, observedAt time.Time, lines []string) {
|
|
||||||
t.Helper()
|
|
||||||
entries := make([]domain.LogEntry, len(lines))
|
|
||||||
for index, line := range lines {
|
|
||||||
entries[index] = domain.LogEntry{Seq: firstSeq + uint64(index), Timestamp: observedAt.Add(time.Duration(index) * time.Second), Level: "display", Line: line}
|
|
||||||
}
|
|
||||||
lastSeq := firstSeq + uint64(len(entries)) - 1
|
|
||||||
batch := domain.LogBatchIngest{RunEndpointID: endpointID, SessionToken: sessionToken, LogStreamID: streamID, ServerInstanceID: serverID, StreamKey: "stdout", Source: domain.LogStreamSourceProcess, FirstSeq: firstSeq, LastSeq: lastSeq, Compression: "none", Checksum: checksumForEntries(t, entries), Entries: entries}
|
|
||||||
if result, err := svc.IngestLogBatch(batch); err != nil || !result.Accepted {
|
|
||||||
t.Fatalf("ingest projection lines result=%+v err=%v", result, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func ingestTradeProjectionLines(t *testing.T, svc *CoreService, sessionToken, endpointID, serverID, streamID string, firstSeq uint64, observedAt time.Time, lines []string) {
|
|
||||||
t.Helper()
|
|
||||||
entries := make([]domain.LogEntry, len(lines))
|
|
||||||
for index, line := range lines {
|
|
||||||
entries[index] = domain.LogEntry{Seq: firstSeq + uint64(index), Timestamp: observedAt.Add(time.Duration(index) * time.Second), Level: "info", Line: line}
|
|
||||||
}
|
|
||||||
lastSeq := firstSeq + uint64(len(entries)) - 1
|
|
||||||
batch := domain.LogBatchIngest{RunEndpointID: endpointID, SessionToken: sessionToken, LogStreamID: streamID, ServerInstanceID: serverID, StreamKey: "scum.trade", Source: domain.LogStreamSourceFile, FirstSeq: firstSeq, LastSeq: lastSeq, Compression: "none", Checksum: checksumForEntries(t, entries), Entries: entries}
|
|
||||||
if result, err := svc.IngestLogBatch(batch); err != nil || !result.Accepted {
|
|
||||||
t.Fatalf("ingest trade projection lines result=%+v err=%v", result, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func assertPresenceProjectionCounts(t *testing.T, svc *CoreService, pluginID, serverID string, users, activities, commands int) {
|
|
||||||
t.Helper()
|
|
||||||
userRows, err := svc.store.PluginDataRecords().List(domain.PluginDataFilter{PluginID: pluginID, ServerInstanceID: serverID, Collection: "scum_users"})
|
|
||||||
if err != nil || len(userRows) != users {
|
|
||||||
t.Fatalf("projected users=%+v err=%v", userRows, err)
|
|
||||||
}
|
|
||||||
activityRows, err := svc.store.PluginDataRecords().List(domain.PluginDataFilter{PluginID: pluginID, ServerInstanceID: serverID, Collection: "scum_activity_events"})
|
|
||||||
if err != nil || len(activityRows) != activities {
|
|
||||||
t.Fatalf("projected activities=%+v err=%v", activityRows, err)
|
|
||||||
}
|
|
||||||
queued, err := svc.store.GameClientBridgeCommands().List(domain.GameClientBridgeCommandFilter{ServerInstanceID: serverID, PluginID: pluginID})
|
|
||||||
if err != nil || len(queued) != commands {
|
|
||||||
t.Fatalf("presence bridge commands=%+v err=%v", queued, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1006,6 +1006,32 @@ func TestServerFileListReportsFailedRuntimeRefresh(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestServerFileManagerUsesDefaultProfileWithoutRuntimeBinding(t *testing.T) {
|
||||||
|
svc := newTestCoreService()
|
||||||
|
plugin, endpoint := createPluginAndRunEndpoint(t, svc)
|
||||||
|
endpoint.Capabilities = append(endpoint.Capabilities, domain.JobCapabilityFilesList)
|
||||||
|
if err := svc.store.RunEndpoints().Update(endpoint); err != nil {
|
||||||
|
t.Fatalf("update file list capability: %v", err)
|
||||||
|
}
|
||||||
|
ownerSession := createServiceUserAndLogin(t, svc, domain.User{ID: "user-file-default-profile", DisplayName: "File Default Profile", Email: "file-default-profile@example.test", Roles: []string{"server-owner"}, PasswordHash: "secret-password"})
|
||||||
|
instance, err := svc.CreateServerInstanceForSession(ownerSession, domain.ServerInstance{ID: "server-file-default-profile", PluginID: plugin.ID, RunEndpointID: endpoint.ID, Name: "File Default Profile Server", State: domain.ServerInstanceStateRunning, Deployment: domain.ServerDeploymentDefinition{Mode: domain.ServerDeploymentModeGuided, ServerRoot: `C:\scumserver`}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create server: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := svc.runtimeBindingForServer(instance.ID); !errors.Is(err, repo.ErrNotFound) {
|
||||||
|
t.Fatalf("file manager must not require a runtime binding: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
list, err := svc.RefreshServerFileListForSession(ownerSession, domain.ServerFileListRequest{ServerInstanceID: instance.ID, DirectoryKey: "server-root", IdempotencyKey: "file-default-profile-list"})
|
||||||
|
if err != nil || list.State != "pending" || list.Job.ExecutionInput.WorkspaceScope != "local" {
|
||||||
|
t.Fatalf("expected default-profile file list job, result=%+v err=%v", list, err)
|
||||||
|
}
|
||||||
|
read, err := svc.DispatchFileOperationForSession(ownerSession, domain.FileOperationDispatchRequest{ServerInstanceID: instance.ID, PluginID: plugin.ID, Operation: domain.FileOperationRead, Key: "logs/latest.log", IdempotencyKey: "file-default-profile-read"})
|
||||||
|
if err != nil || read.Job.ExecutionInput.WorkspaceScope != "local" {
|
||||||
|
t.Fatalf("expected default-profile file read job, result=%+v err=%v", read, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestServerFileBrowseWaitsForFreshRunResultWithoutCachedList(t *testing.T) {
|
func TestServerFileBrowseWaitsForFreshRunResultWithoutCachedList(t *testing.T) {
|
||||||
svc := newTestCoreService()
|
svc := newTestCoreService()
|
||||||
plugin, endpoint := createPluginAndRunEndpoint(t, svc)
|
plugin, endpoint := createPluginAndRunEndpoint(t, svc)
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ func TestRuntimeBindingValidationAndLifecycleGating(t *testing.T) {
|
|||||||
t.Fatalf("unexpected unconfigured view: view=%+v err=%v", view, err)
|
t.Fatalf("unexpected unconfigured view: view=%+v err=%v", view, err)
|
||||||
}
|
}
|
||||||
withoutBinding, err := svc.StartServerInstanceForSession(ownerSession, domain.ServerLifecycleCommand{ServerInstanceID: instance.ID, ExpectedConfigVersion: instance.ConfigVersion, IdempotencyKey: "start-without-binding"})
|
withoutBinding, err := svc.StartServerInstanceForSession(ownerSession, domain.ServerLifecycleCommand{ServerInstanceID: instance.ID, ExpectedConfigVersion: instance.ConfigVersion, IdempotencyKey: "start-without-binding"})
|
||||||
if err != nil || withoutBinding.Job.TargetKey != "actions/start.json" || withoutBinding.Job.ExecutionInput.WorkspaceScope != "" {
|
if err != nil || withoutBinding.Job.TargetKey != "actions/start.json" || withoutBinding.Job.ExecutionInput.WorkspaceScope != "local" {
|
||||||
t.Fatalf("expected plugin lifecycle start without manual runtime binding, result=%+v err=%v", withoutBinding, err)
|
t.Fatalf("expected plugin lifecycle start without manual runtime binding, result=%+v err=%v", withoutBinding, err)
|
||||||
}
|
}
|
||||||
if _, err := svc.UpdateServerRuntimeBindingForSession(otherSession, instance.ID, domain.RuntimeBindingUpdate{ProfileKey: "local"}); err != ErrForbidden {
|
if _, err := svc.UpdateServerRuntimeBindingForSession(otherSession, instance.ID, domain.RuntimeBindingUpdate{ProfileKey: "local"}); err != ErrForbidden {
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ type runFileListEntry struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (svc *CoreService) GetServerFileWorkspaceForSession(sessionID string, serverInstanceID string) (domain.ServerFileWorkspaceView, error) {
|
func (svc *CoreService) GetServerFileWorkspaceForSession(sessionID string, serverInstanceID string) (domain.ServerFileWorkspaceView, error) {
|
||||||
ctx, err := svc.serverFileContextForSession(sessionID, serverInstanceID, "", false, false)
|
ctx, err := svc.serverFileContextForSession(sessionID, serverInstanceID, "", false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return domain.ServerFileWorkspaceView{}, err
|
return domain.ServerFileWorkspaceView{}, err
|
||||||
}
|
}
|
||||||
@@ -88,7 +88,7 @@ func (svc *CoreService) ListServerFilesForSession(sessionID string, request doma
|
|||||||
if err := validator.ValidateServerFileListRequest(request); err != nil {
|
if err := validator.ValidateServerFileListRequest(request); err != nil {
|
||||||
return domain.ServerFileListResult{}, err
|
return domain.ServerFileListResult{}, err
|
||||||
}
|
}
|
||||||
ctx, err := svc.serverFileContextForSession(sessionID, request.ServerInstanceID, request.DirectoryKey, false, false)
|
ctx, err := svc.serverFileContextForSession(sessionID, request.ServerInstanceID, request.DirectoryKey, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return domain.ServerFileListResult{}, err
|
return domain.ServerFileListResult{}, err
|
||||||
}
|
}
|
||||||
@@ -148,7 +148,7 @@ func (svc *CoreService) RefreshServerFileListForSession(sessionID string, reques
|
|||||||
if err := validator.ValidateServerFileListRequest(request); err != nil {
|
if err := validator.ValidateServerFileListRequest(request); err != nil {
|
||||||
return domain.ServerFileListResult{}, err
|
return domain.ServerFileListResult{}, err
|
||||||
}
|
}
|
||||||
ctx, err := svc.serverFileContextForSession(sessionID, request.ServerInstanceID, request.DirectoryKey, false, true)
|
ctx, err := svc.serverFileContextForSession(sessionID, request.ServerInstanceID, request.DirectoryKey, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return domain.ServerFileListResult{}, err
|
return domain.ServerFileListResult{}, err
|
||||||
}
|
}
|
||||||
@@ -288,7 +288,7 @@ func (svc *CoreService) UploadServerFileForSession(sessionID string, request dom
|
|||||||
if err := validator.ValidateServerFileUploadRequest(request); err != nil {
|
if err := validator.ValidateServerFileUploadRequest(request); err != nil {
|
||||||
return domain.ServerFileUploadDispatch{}, err
|
return domain.ServerFileUploadDispatch{}, err
|
||||||
}
|
}
|
||||||
ctx, err := svc.serverFileContextForSession(sessionID, request.ServerInstanceID, request.DirectoryKey, true, true)
|
ctx, err := svc.serverFileContextForSession(sessionID, request.ServerInstanceID, request.DirectoryKey, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return domain.ServerFileUploadDispatch{}, err
|
return domain.ServerFileUploadDispatch{}, err
|
||||||
}
|
}
|
||||||
@@ -326,7 +326,7 @@ func (svc *CoreService) PrepareServerFileDownloadForSession(sessionID string, re
|
|||||||
if err := validator.ValidateServerFileDownloadRequest(request); err != nil {
|
if err := validator.ValidateServerFileDownloadRequest(request); err != nil {
|
||||||
return domain.ServerFileDownloadResult{}, err
|
return domain.ServerFileDownloadResult{}, err
|
||||||
}
|
}
|
||||||
ctx, err := svc.serverFileContextForSession(sessionID, request.ServerInstanceID, "", false, true)
|
ctx, err := svc.serverFileContextForSession(sessionID, request.ServerInstanceID, "", false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return domain.ServerFileDownloadResult{}, err
|
return domain.ServerFileDownloadResult{}, err
|
||||||
}
|
}
|
||||||
@@ -412,7 +412,7 @@ func (svc *CoreService) ReadRunFileInputChunk(request domain.RunFileInputChunkRe
|
|||||||
return domain.CopyRunFileInputChunk(chunk), nil
|
return domain.CopyRunFileInputChunk(chunk), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (svc *CoreService) serverFileContextForSession(sessionID string, serverInstanceID string, directoryKey string, requireWrite bool, requireRuntime bool) (serverFileContext, error) {
|
func (svc *CoreService) serverFileContextForSession(sessionID string, serverInstanceID string, directoryKey string, requireWrite bool) (serverFileContext, error) {
|
||||||
instance, err := svc.GetServerInstanceForSession(sessionID, serverInstanceID)
|
instance, err := svc.GetServerInstanceForSession(sessionID, serverInstanceID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return serverFileContext{}, err
|
return serverFileContext{}, err
|
||||||
@@ -421,11 +421,6 @@ func (svc *CoreService) serverFileContextForSession(sessionID string, serverInst
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return serverFileContext{}, err
|
return serverFileContext{}, err
|
||||||
}
|
}
|
||||||
if requireRuntime {
|
|
||||||
if err := svc.requireCompleteRuntimeBindings(user.ID, instance.ID, "file.manager.denied"); err != nil {
|
|
||||||
return serverFileContext{}, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
|
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return serverFileContext{}, err
|
return serverFileContext{}, err
|
||||||
|
|||||||
@@ -1,61 +0,0 @@
|
|||||||
package validator
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"browser.local/platform/domain"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestValidateGameClientBridgeLogProjectionDeclaration(t *testing.T) {
|
|
||||||
bridge := domain.GameClientBridgeManifest{
|
|
||||||
LogProjections: []domain.GameClientBridgeLogProjectionDeclaration{{
|
|
||||||
Key: "player.login", StreamKeys: []string{"process.stdout"}, CorrelationFields: []string{"slot"}, MaxInterveningLines: 16,
|
|
||||||
Steps: []domain.GameClientBridgeLogProjectionStepDeclaration{
|
|
||||||
{Pattern: `Player "(?<name>[^"]+)" reported as player (?<slot>\d+)`},
|
|
||||||
{Pattern: `Player (?<slot>\d+) SteamID \(assumed\): (?<steamId>\d+)`},
|
|
||||||
},
|
|
||||||
Target: domain.GameClientBridgeLogProjectionTargetDeclaration{Collection: "scum_users", UpsertKeys: []string{"steamId"}, CaptureMappings: map[string]string{"steamId": "steamId", "name": "name"}, FixedValues: map[string]string{"source": "stdout"}, ObservedAtField: "lastLoginAt"},
|
|
||||||
Presence: &domain.GameClientBridgeLogProjectionPresenceDeclaration{
|
|
||||||
TimestampField: "lastLoginAt", ActiveWindowSeconds: 600,
|
|
||||||
ActivityTarget: &domain.GameClientBridgeLogProjectionTargetDeclaration{Collection: "scum_activity", UpsertKeys: []string{"steamId"}, CaptureMappings: map[string]string{"steamId": "steamId"}, ObservedAtField: "observedAt"},
|
|
||||||
},
|
|
||||||
}},
|
|
||||||
Retention: domain.GameClientBridgeRetention{KeepForSeconds: 86400, MaxRecords: 1000},
|
|
||||||
}
|
|
||||||
profiles := domain.GamePluginRuntimeProfiles{ClientManagers: []domain.RuntimeClientManagerProfile{{Key: "scum-client", Health: domain.RuntimeClientManagerHealth{RequiredCapabilities: []string{"game-client.bridge"}}}}}
|
|
||||||
if violations := validateGameClientBridgeManifest("gameClientBridge", bridge, []string{"server.game-client.command"}, nil, nil, profiles); len(violations) != 0 {
|
|
||||||
t.Fatalf("expected repeated named captures across steps to validate, got %v", violations)
|
|
||||||
}
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
expected string
|
|
||||||
mutate func(*domain.GameClientBridgeManifest, *domain.GamePluginRuntimeProfiles)
|
|
||||||
}{
|
|
||||||
{name: "invalid regex", expected: "valid regular expression", mutate: func(value *domain.GameClientBridgeManifest, _ *domain.GamePluginRuntimeProfiles) {
|
|
||||||
value.LogProjections[0].Steps[0].Pattern = "("
|
|
||||||
}},
|
|
||||||
{name: "missing capture", expected: "references undeclared capture missing", mutate: func(value *domain.GameClientBridgeManifest, _ *domain.GamePluginRuntimeProfiles) {
|
|
||||||
value.LogProjections[0].Target.CaptureMappings["steamId"] = "missing"
|
|
||||||
}},
|
|
||||||
{name: "invalid timestamp field", expected: "presence.timestampField must reference", mutate: func(value *domain.GameClientBridgeManifest, _ *domain.GamePluginRuntimeProfiles) {
|
|
||||||
value.LogProjections[0].Presence.TimestampField = "missingAt"
|
|
||||||
}},
|
|
||||||
{name: "invalid activity target", expected: "presence.activityTarget.upsertKeys field missing is not projected", mutate: func(value *domain.GameClientBridgeManifest, _ *domain.GamePluginRuntimeProfiles) {
|
|
||||||
value.LogProjections[0].Presence.ActivityTarget.UpsertKeys = []string{"missing"}
|
|
||||||
}},
|
|
||||||
}
|
|
||||||
for _, test := range tests {
|
|
||||||
t.Run(test.name, func(t *testing.T) {
|
|
||||||
candidate := domain.CopyGameClientBridgeManifest(bridge)
|
|
||||||
candidateProfiles := profiles
|
|
||||||
candidateProfiles.ClientManagers = append([]domain.RuntimeClientManagerProfile(nil), profiles.ClientManagers...)
|
|
||||||
test.mutate(&candidate, &candidateProfiles)
|
|
||||||
violations := validateGameClientBridgeManifest("gameClientBridge", candidate, []string{"server.game-client.command"}, nil, nil, candidateProfiles)
|
|
||||||
if !strings.Contains(strings.Join(violations, "; "), test.expected) {
|
|
||||||
t.Fatalf("expected %q violation, got %v", test.expected, violations)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -12,7 +12,6 @@ import (
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
MaxLogBatchEntries = 512
|
MaxLogBatchEntries = 512
|
||||||
MaxLogLineLength = 8192
|
|
||||||
MaxLogQueryLimit = 10000
|
MaxLogQueryLimit = 10000
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -63,12 +62,6 @@ func ValidateLogBatchIngest(batch domain.LogBatchIngest) error {
|
|||||||
if entry.Seq != batch.FirstSeq+uint64(i) {
|
if entry.Seq != batch.FirstSeq+uint64(i) {
|
||||||
violations = append(violations, fmt.Sprintf("entries[%d].seq must be contiguous", i))
|
violations = append(violations, fmt.Sprintf("entries[%d].seq must be contiguous", i))
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(entry.Line) == "" {
|
|
||||||
violations = append(violations, fmt.Sprintf("entries[%d].line is required", i))
|
|
||||||
}
|
|
||||||
if len(entry.Line) > MaxLogLineLength {
|
|
||||||
violations = append(violations, fmt.Sprintf("entries[%d].line is too long", i))
|
|
||||||
}
|
|
||||||
for key := range entry.Fields {
|
for key := range entry.Fields {
|
||||||
if strings.TrimSpace(key) == "" {
|
if strings.TrimSpace(key) == "" {
|
||||||
violations = append(violations, fmt.Sprintf("entries[%d].fields key is required", i))
|
violations = append(violations, fmt.Sprintf("entries[%d].fields key is required", i))
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package validator
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"browser.local/platform/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestValidateLogBatchIngestAcceptsVerbatimBlankAndLongLines(t *testing.T) {
|
||||||
|
entries := []domain.LogEntry{
|
||||||
|
{Seq: 1, Timestamp: time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC), Line: ""},
|
||||||
|
{Seq: 2, Timestamp: time.Date(2026, 9, 1, 0, 0, 1, 0, time.UTC), Line: strings.Repeat("x", 32*1024)},
|
||||||
|
}
|
||||||
|
checksum, err := LogEntriesChecksum(entries)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("checksum: %v", err)
|
||||||
|
}
|
||||||
|
batch := domain.LogBatchIngest{RunEndpointID: "run-1", SessionToken: "session-1", LogStreamID: "run.run-1.server-1.stdout", ServerInstanceID: "server-1", StreamKey: "stdout", Source: domain.LogStreamSourceProcess, FirstSeq: 1, LastSeq: 2, Compression: "none", Checksum: checksum, Entries: entries}
|
||||||
|
if err := ValidateLogBatchIngest(batch); err != nil {
|
||||||
|
t.Fatalf("verbatim log batch was rejected: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -166,7 +166,6 @@ func ValidateGamePlugin(plugin domain.GamePlugin) error {
|
|||||||
violations = append(violations, err.Error())
|
violations = append(violations, err.Error())
|
||||||
}
|
}
|
||||||
violations = append(violations, validateRuntimeProfileCapabilityDeclarations(plugin.RuntimeProfiles, plugin.RequiredRunCapabilities)...)
|
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.RequiredRunCapabilities, plugin.Pages, plugin.RuntimeProfiles)...)
|
violations = append(violations, validateGameClientBridgeManifest("gameClientBridge", plugin.GameClientBridge, plugin.DeclaredPermissions, plugin.RequiredRunCapabilities, plugin.Pages, plugin.RuntimeProfiles)...)
|
||||||
violations = append(violations, validatePluginCreateFields("createFields", plugin.CreateFields)...)
|
violations = append(violations, validatePluginCreateFields("createFields", plugin.CreateFields)...)
|
||||||
violations = append(violations, validatePluginAssetFiles("lifecycleAssets", plugin.LifecycleAssets)...)
|
violations = append(violations, validatePluginAssetFiles("lifecycleAssets", plugin.LifecycleAssets)...)
|
||||||
@@ -237,7 +236,6 @@ func ValidateGamePluginManifestRegistration(registration domain.GamePluginManife
|
|||||||
violations = append(violations, err.Error())
|
violations = append(violations, err.Error())
|
||||||
}
|
}
|
||||||
violations = append(violations, validateRuntimeProfileCapabilityDeclarations(manifest.RuntimeProfiles, manifest.Capabilities)...)
|
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.Capabilities, manifest.Pages, manifest.RuntimeProfiles)...)
|
violations = append(violations, validateGameClientBridgeManifest("manifest.gameClientBridge", manifest.GameClientBridge, manifest.Permissions, manifest.Capabilities, manifest.Pages, manifest.RuntimeProfiles)...)
|
||||||
violations = append(violations, validatePluginAssetFileDeclarations("manifest.assetFiles", manifest.AssetFiles)...)
|
violations = append(violations, validatePluginAssetFileDeclarations("manifest.assetFiles", manifest.AssetFiles)...)
|
||||||
violations = append(violations, validatePluginAssetFiles("assetFiles", registration.AssetFiles)...)
|
violations = append(violations, validatePluginAssetFiles("assetFiles", registration.AssetFiles)...)
|
||||||
@@ -469,7 +467,7 @@ func ValidatePluginCreateInputs(fields []domain.PluginCreateField, inputs map[st
|
|||||||
|
|
||||||
func validateGameClientBridgeManifest(field string, bridge domain.GameClientBridgeManifest, permissions []string, runCapabilities []string, pages []domain.GamePluginPage, runtimeProfiles domain.GamePluginRuntimeProfiles) []string {
|
func validateGameClientBridgeManifest(field string, bridge domain.GameClientBridgeManifest, permissions []string, runCapabilities []string, pages []domain.GamePluginPage, runtimeProfiles domain.GamePluginRuntimeProfiles) []string {
|
||||||
companionPresent := bridge.Companion != (domain.GameClientBridgeCompanionDeclaration{})
|
companionPresent := bridge.Companion != (domain.GameClientBridgeCompanionDeclaration{})
|
||||||
if len(bridge.Commands) == 0 && len(bridge.Snapshots) == 0 && len(bridge.QueryTemplates) == 0 && len(bridge.LogProjections) == 0 && len(bridge.LifecycleProjections) == 0 && len(bridge.DataPacks) == 0 && len(bridge.Pages) == 0 && len(bridge.Features) == 0 && bridge.Retention.KeepForSeconds == 0 && bridge.Retention.MaxRecords == 0 && !companionPresent {
|
if len(bridge.Commands) == 0 && len(bridge.Snapshots) == 0 && len(bridge.QueryTemplates) == 0 && len(bridge.LifecycleProjections) == 0 && len(bridge.DataPacks) == 0 && len(bridge.Pages) == 0 && len(bridge.Features) == 0 && bridge.Retention.KeepForSeconds == 0 && bridge.Retention.MaxRecords == 0 && !companionPresent {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
var violations []string
|
var violations []string
|
||||||
@@ -628,18 +626,6 @@ func validateGameClientBridgeManifest(field string, bridge domain.GameClientBrid
|
|||||||
violations = append(violations, prefix+" transport must be sqlite with remote.run.db.sqlite.query capability")
|
violations = append(violations, prefix+" transport must be sqlite with remote.run.db.sqlite.query capability")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
logProjectionKeys := map[string]struct{}{}
|
|
||||||
for index, projection := range bridge.LogProjections {
|
|
||||||
prefix := fmt.Sprintf("%s.logProjections[%d]", field, index)
|
|
||||||
if !clientManagerIdentifierPattern.MatchString(projection.Key) {
|
|
||||||
violations = append(violations, prefix+".key is invalid")
|
|
||||||
}
|
|
||||||
if _, exists := logProjectionKeys[projection.Key]; exists {
|
|
||||||
violations = append(violations, prefix+".key is duplicated")
|
|
||||||
}
|
|
||||||
logProjectionKeys[projection.Key] = struct{}{}
|
|
||||||
violations = append(violations, validateGameClientBridgeLogProjection(prefix, projection)...)
|
|
||||||
}
|
|
||||||
lifecycleProjectionKeys := map[string]struct{}{}
|
lifecycleProjectionKeys := map[string]struct{}{}
|
||||||
for index, projection := range bridge.LifecycleProjections {
|
for index, projection := range bridge.LifecycleProjections {
|
||||||
prefix := fmt.Sprintf("%s.lifecycleProjections[%d]", field, index)
|
prefix := fmt.Sprintf("%s.lifecycleProjections[%d]", field, index)
|
||||||
@@ -662,10 +648,10 @@ func validateGameClientBridgeManifest(field string, bridge domain.GameClientBrid
|
|||||||
violations = append(violations, prefix+".key is duplicated")
|
violations = append(violations, prefix+".key is duplicated")
|
||||||
}
|
}
|
||||||
dataPackKeys[dataPack.Key] = struct{}{}
|
dataPackKeys[dataPack.Key] = struct{}{}
|
||||||
if dataPack.DatabaseUserVersion < 1 || len(dataPack.LogParserRefs) == 0 || len(dataPack.ConfigMapRefs) == 0 {
|
if dataPack.DatabaseUserVersion < 1 || len(dataPack.ConfigMapRefs) == 0 {
|
||||||
violations = append(violations, prefix+" must declare a database version and parser/config assets")
|
violations = append(violations, prefix+" must declare a database version and config assets")
|
||||||
}
|
}
|
||||||
refs := append(domain.CopyStringSlice(dataPack.LogParserRefs), dataPack.ConfigMapRefs...)
|
refs := domain.CopyStringSlice(dataPack.ConfigMapRefs)
|
||||||
refs = append(refs, dataPack.DataRefs...)
|
refs = append(refs, dataPack.DataRefs...)
|
||||||
for _, ref := range refs {
|
for _, ref := range refs {
|
||||||
if !safeRelativeJSONRef(ref) {
|
if !safeRelativeJSONRef(ref) {
|
||||||
@@ -939,154 +925,6 @@ func validateGameClientBridgeBulkActivityTarget(prefix string, target domain.Gam
|
|||||||
return violations
|
return violations
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateGameClientBridgeLogProjection(prefix string, projection domain.GameClientBridgeLogProjectionDeclaration) []string {
|
|
||||||
var violations []string
|
|
||||||
if len(projection.StreamKeys) < 1 || len(projection.StreamKeys) > 64 {
|
|
||||||
violations = append(violations, prefix+".streamKeys must contain between 1 and 64 streams")
|
|
||||||
}
|
|
||||||
for _, streamKey := range projection.StreamKeys {
|
|
||||||
if !clientManagerIdentifierPattern.MatchString(streamKey) {
|
|
||||||
violations = append(violations, prefix+".streamKeys contains an invalid stream key")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
violations = append(violations, duplicateViolations(prefix+".streamKeys", projection.StreamKeys)...)
|
|
||||||
|
|
||||||
captures := map[string]struct{}{}
|
|
||||||
if len(projection.Steps) < 1 || len(projection.Steps) > 64 {
|
|
||||||
violations = append(violations, prefix+".steps must contain between 1 and 64 patterns")
|
|
||||||
}
|
|
||||||
for index, step := range projection.Steps {
|
|
||||||
stepPrefix := fmt.Sprintf("%s.steps[%d].pattern", prefix, index)
|
|
||||||
if strings.TrimSpace(step.Pattern) == "" || len([]rune(step.Pattern)) > 16384 {
|
|
||||||
violations = append(violations, stepPrefix+" is empty or too large")
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
compiled, err := regexp.Compile(step.Pattern)
|
|
||||||
if err != nil {
|
|
||||||
violations = append(violations, stepPrefix+" must be a valid regular expression")
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for _, capture := range compiled.SubexpNames() {
|
|
||||||
if capture != "" {
|
|
||||||
captures[capture] = struct{}{}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(projection.CorrelationFields) < 1 || len(projection.CorrelationFields) > 64 {
|
|
||||||
violations = append(violations, prefix+".correlationFields must contain between 1 and 64 captures")
|
|
||||||
}
|
|
||||||
for _, field := range projection.CorrelationFields {
|
|
||||||
if !gameClientBridgeCaptureNamePattern.MatchString(field) {
|
|
||||||
violations = append(violations, prefix+".correlationFields contains an invalid capture name")
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if _, exists := captures[field]; !exists {
|
|
||||||
violations = append(violations, prefix+".correlationFields references undeclared capture "+field)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
violations = append(violations, duplicateViolations(prefix+".correlationFields", projection.CorrelationFields)...)
|
|
||||||
if projection.MaxInterveningLines < 0 || projection.MaxInterveningLines > 100000 {
|
|
||||||
violations = append(violations, prefix+".maxInterveningLines is invalid")
|
|
||||||
}
|
|
||||||
violations = append(violations, validateGameClientBridgeLogProjectionTarget(prefix+".target", projection.Target, captures)...)
|
|
||||||
|
|
||||||
if projection.Presence == nil {
|
|
||||||
return violations
|
|
||||||
}
|
|
||||||
presence := projection.Presence
|
|
||||||
if !gameClientBridgeFieldPattern.MatchString(presence.TimestampField) || !gameClientBridgeLogProjectionTargetDeclaresField(projection.Target, presence.TimestampField) {
|
|
||||||
violations = append(violations, prefix+".presence.timestampField must reference a projected target field")
|
|
||||||
}
|
|
||||||
if presence.ActiveWindowSeconds < 1 || presence.ActiveWindowSeconds > 31536000 {
|
|
||||||
violations = append(violations, prefix+".presence.activeWindowSeconds is invalid")
|
|
||||||
}
|
|
||||||
if presence.ActivityTarget != nil {
|
|
||||||
violations = append(violations, validateGameClientBridgeLogProjectionTarget(prefix+".presence.activityTarget", *presence.ActivityTarget, captures)...)
|
|
||||||
}
|
|
||||||
return violations
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateGameClientBridgeLogProjectionTarget(prefix string, target domain.GameClientBridgeLogProjectionTargetDeclaration, captures map[string]struct{}) []string {
|
|
||||||
var violations []string
|
|
||||||
if !gameClientBridgeCollectionPattern.MatchString(target.Collection) {
|
|
||||||
violations = append(violations, prefix+".collection is invalid")
|
|
||||||
}
|
|
||||||
if len(target.UpsertKeys) < 1 || len(target.UpsertKeys) > 8 {
|
|
||||||
violations = append(violations, prefix+".upsertKeys must contain between 1 and 8 fields")
|
|
||||||
}
|
|
||||||
for _, key := range target.UpsertKeys {
|
|
||||||
if !gameClientBridgeFieldPattern.MatchString(key) {
|
|
||||||
violations = append(violations, prefix+".upsertKeys contains an invalid field")
|
|
||||||
}
|
|
||||||
if !gameClientBridgeLogProjectionTargetDeclaresField(target, key) {
|
|
||||||
violations = append(violations, prefix+".upsertKeys field "+key+" is not projected")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
violations = append(violations, duplicateViolations(prefix+".upsertKeys", target.UpsertKeys)...)
|
|
||||||
|
|
||||||
if len(target.CaptureMappings) < 1 || len(target.CaptureMappings) > 64 {
|
|
||||||
violations = append(violations, prefix+".captureMappings must contain between 1 and 64 mappings")
|
|
||||||
}
|
|
||||||
projectedFields := map[string]struct{}{}
|
|
||||||
for destination, capture := range target.CaptureMappings {
|
|
||||||
if !gameClientBridgeFieldPattern.MatchString(destination) || !gameClientBridgeCaptureNamePattern.MatchString(capture) {
|
|
||||||
violations = append(violations, prefix+".captureMappings contains an invalid field or capture")
|
|
||||||
}
|
|
||||||
if _, exists := captures[capture]; !exists {
|
|
||||||
violations = append(violations, prefix+".captureMappings references undeclared capture "+capture)
|
|
||||||
}
|
|
||||||
projectedFields[destination] = struct{}{}
|
|
||||||
}
|
|
||||||
if len(target.HashMappings) > 64 {
|
|
||||||
violations = append(violations, prefix+".hashMappings contains too many fields")
|
|
||||||
}
|
|
||||||
for destination, capture := range target.HashMappings {
|
|
||||||
if !gameClientBridgeFieldPattern.MatchString(destination) || !gameClientBridgeCaptureNamePattern.MatchString(capture) {
|
|
||||||
violations = append(violations, prefix+".hashMappings contains an invalid field or capture")
|
|
||||||
}
|
|
||||||
if _, exists := captures[capture]; !exists {
|
|
||||||
violations = append(violations, prefix+".hashMappings references undeclared capture "+capture)
|
|
||||||
}
|
|
||||||
if _, exists := projectedFields[destination]; exists {
|
|
||||||
violations = append(violations, prefix+" declares field "+destination+" more than once")
|
|
||||||
}
|
|
||||||
projectedFields[destination] = struct{}{}
|
|
||||||
}
|
|
||||||
if len(target.FixedValues) > 64 {
|
|
||||||
violations = append(violations, prefix+".fixedValues contains too many fields")
|
|
||||||
}
|
|
||||||
for destination, value := range target.FixedValues {
|
|
||||||
if !gameClientBridgeFieldPattern.MatchString(destination) || len([]rune(value)) > 4096 {
|
|
||||||
violations = append(violations, prefix+".fixedValues contains an invalid field or oversized value")
|
|
||||||
}
|
|
||||||
if _, exists := projectedFields[destination]; exists {
|
|
||||||
violations = append(violations, prefix+" declares field "+destination+" more than once")
|
|
||||||
}
|
|
||||||
projectedFields[destination] = struct{}{}
|
|
||||||
}
|
|
||||||
if target.ObservedAtField != "" {
|
|
||||||
if !gameClientBridgeFieldPattern.MatchString(target.ObservedAtField) {
|
|
||||||
violations = append(violations, prefix+".observedAtField is invalid")
|
|
||||||
}
|
|
||||||
if _, exists := projectedFields[target.ObservedAtField]; exists {
|
|
||||||
violations = append(violations, prefix+" declares field "+target.ObservedAtField+" more than once")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return violations
|
|
||||||
}
|
|
||||||
|
|
||||||
func gameClientBridgeLogProjectionTargetDeclaresField(target domain.GameClientBridgeLogProjectionTargetDeclaration, field string) bool {
|
|
||||||
if target.ObservedAtField == field {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
if _, exists := target.CaptureMappings[field]; exists {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
_, exists := target.FixedValues[field]
|
|
||||||
return exists
|
|
||||||
}
|
|
||||||
|
|
||||||
func validCompanionProofEnvironment(value string) bool {
|
func validCompanionProofEnvironment(value string) bool {
|
||||||
if len(value) < 3 || len(value) > 64 || value[0] < 'A' || value[0] > 'Z' {
|
if len(value) < 3 || len(value) > 64 || value[0] < 'A' || value[0] > 'Z' {
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package validator
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"browser.local/platform/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestValidateGamePluginRuntimeProfilesAllowsSafeCasePreservingDataTargetPath(t *testing.T) {
|
||||||
|
profiles := validRuntimeDataTargetProfiles("SCUM/Saved/SaveFiles/SCUM.db")
|
||||||
|
if err := ValidateGamePluginRuntimeProfiles(profiles); err != nil {
|
||||||
|
t.Fatalf("expected safe cross-platform data target path to validate: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateGamePluginRuntimeProfilesRejectsUnsafeDataTargetPath(t *testing.T) {
|
||||||
|
for _, sourcePath := range []string{"../SCUM.db", "/SCUM.db", `SCUM\\Saved\\SCUM.db`, "C:/SCUM.db", "https://example.test/SCUM.db"} {
|
||||||
|
t.Run(sourcePath, func(t *testing.T) {
|
||||||
|
err := ValidateGamePluginRuntimeProfiles(validRuntimeDataTargetProfiles(sourcePath))
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "sourcePath must be a safe relative path") {
|
||||||
|
t.Fatalf("expected unsafe source path rejection, got %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func validRuntimeDataTargetProfiles(sourcePath string) domain.GamePluginRuntimeProfiles {
|
||||||
|
return domain.GamePluginRuntimeProfiles{
|
||||||
|
TransportProfiles: []domain.RuntimeTransportProfile{{
|
||||||
|
Key: "scum-database",
|
||||||
|
Kind: "sqlite",
|
||||||
|
TargetKey: "scum-database",
|
||||||
|
Capabilities: []string{domain.JobCapabilityRemoteRunDBSQLiteQuery},
|
||||||
|
}},
|
||||||
|
DataTargets: []domain.RuntimeDataTarget{{
|
||||||
|
Key: "scum-database",
|
||||||
|
Kind: "sqlite.snapshot",
|
||||||
|
TransportKey: "scum-database",
|
||||||
|
SourceRootKey: "server-root",
|
||||||
|
SourcePath: sourcePath,
|
||||||
|
WorkspaceKey: "databases/scum-database",
|
||||||
|
RefreshPolicy: "on-demand-snapshot",
|
||||||
|
MaxBytes: 1024 * 1024,
|
||||||
|
Platforms: []string{"windows"},
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
package validator
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"browser.local/platform/domain"
|
|
||||||
)
|
|
||||||
|
|
||||||
func validRuntimeLogEventProfiles() domain.GamePluginRuntimeProfiles {
|
|
||||||
return domain.GamePluginRuntimeProfiles{
|
|
||||||
LogSources: []domain.RuntimeLogSource{{Key: "chat-log", Kind: "file.tail", StreamKey: "chat", CursorKind: "offset", RetentionDays: 30}},
|
|
||||||
LogEvents: []domain.RuntimeLogEvent{{
|
|
||||||
Key: "chat-message", Title: "Chat message", SourceKey: "chat-log", EventType: "chat.message",
|
|
||||||
Permission: "server.logs.read", SchemaRef: "schemas/log-events/chat-message.schema.json", RetentionDays: 30, Severity: "info",
|
|
||||||
}},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestValidateGamePluginRuntimeProfilesValidatesLogEvents(t *testing.T) {
|
|
||||||
if err := ValidateGamePluginRuntimeProfiles(validRuntimeLogEventProfiles()); err != nil {
|
|
||||||
t.Fatalf("expected valid runtime log event declaration, got %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
expected string
|
|
||||||
mutate func(*domain.GamePluginRuntimeProfiles)
|
|
||||||
}{
|
|
||||||
{name: "undeclared source", expected: "sourceKey must reference", mutate: func(profiles *domain.GamePluginRuntimeProfiles) { profiles.LogEvents[0].SourceKey = "missing" }},
|
|
||||||
{name: "invalid event type", expected: "eventType is invalid", mutate: func(profiles *domain.GamePluginRuntimeProfiles) { profiles.LogEvents[0].EventType = "chat message" }},
|
|
||||||
{name: "invalid permission", expected: "permission is not allowed", mutate: func(profiles *domain.GamePluginRuntimeProfiles) { profiles.LogEvents[0].Permission = "server.admin" }},
|
|
||||||
{name: "unsafe schema", expected: "schemaRef must be a bounded safe relative JSON reference", mutate: func(profiles *domain.GamePluginRuntimeProfiles) {
|
|
||||||
profiles.LogEvents[0].SchemaRef = "schemas/log events/chat.json"
|
|
||||||
}},
|
|
||||||
{name: "retention bound", expected: "retentionDays is invalid", mutate: func(profiles *domain.GamePluginRuntimeProfiles) { profiles.LogEvents[0].RetentionDays = 366 }},
|
|
||||||
{name: "retention exceeds source", expected: "retentionDays must not exceed the source retention", mutate: func(profiles *domain.GamePluginRuntimeProfiles) { profiles.LogEvents[0].RetentionDays = 31 }},
|
|
||||||
{name: "invalid severity", expected: "severity is invalid", mutate: func(profiles *domain.GamePluginRuntimeProfiles) { profiles.LogEvents[0].Severity = "emergency" }},
|
|
||||||
{name: "plugin error severity", expected: "severity is invalid", mutate: func(profiles *domain.GamePluginRuntimeProfiles) { profiles.LogEvents[0].Severity = "error" }},
|
|
||||||
{name: "duplicate key", expected: "key is duplicated", mutate: func(profiles *domain.GamePluginRuntimeProfiles) {
|
|
||||||
profiles.LogEvents = append(profiles.LogEvents, profiles.LogEvents[0])
|
|
||||||
}},
|
|
||||||
{name: "duplicate event type", expected: "eventType is duplicated", mutate: func(profiles *domain.GamePluginRuntimeProfiles) {
|
|
||||||
duplicate := profiles.LogEvents[0]
|
|
||||||
duplicate.Key = "chat-message-copy"
|
|
||||||
profiles.LogEvents = append(profiles.LogEvents, duplicate)
|
|
||||||
}},
|
|
||||||
}
|
|
||||||
for _, test := range tests {
|
|
||||||
t.Run(test.name, func(t *testing.T) {
|
|
||||||
profiles := domain.CopyGamePluginRuntimeProfiles(validRuntimeLogEventProfiles())
|
|
||||||
test.mutate(&profiles)
|
|
||||||
err := ValidateGamePluginRuntimeProfiles(profiles)
|
|
||||||
if err == nil || !strings.Contains(err.Error(), test.expected) {
|
|
||||||
t.Fatalf("expected %q validation error, got %v", test.expected, err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestValidateGamePluginRuntimeProfilesAcceptsDeclaredLogEventSeverities(t *testing.T) {
|
|
||||||
for _, severity := range []domain.RuntimeLogEventSeverity{
|
|
||||||
domain.RuntimeLogEventSeverityInfo,
|
|
||||||
domain.RuntimeLogEventSeverityNotice,
|
|
||||||
domain.RuntimeLogEventSeverityWarning,
|
|
||||||
domain.RuntimeLogEventSeverityCritical,
|
|
||||||
} {
|
|
||||||
t.Run(string(severity), func(t *testing.T) {
|
|
||||||
profiles := validRuntimeLogEventProfiles()
|
|
||||||
profiles.LogEvents[0].Severity = severity
|
|
||||||
if err := ValidateGamePluginRuntimeProfiles(profiles); err != nil {
|
|
||||||
t.Fatalf("expected severity %q to validate, got %v", severity, err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestValidateGamePluginRuntimeProfilesAllowsEventRetentionWhenSourceUsesDefault(t *testing.T) {
|
|
||||||
profiles := validRuntimeLogEventProfiles()
|
|
||||||
profiles.LogSources[0].RetentionDays = 0
|
|
||||||
if err := ValidateGamePluginRuntimeProfiles(profiles); err != nil {
|
|
||||||
t.Fatalf("expected source default retention to allow bounded event retention, got %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestValidateGamePluginRuntimeProfilesRejectsUnsafeLogEventSemantics(t *testing.T) {
|
|
||||||
unsafeEventTypes := []string{
|
|
||||||
"ops.shell.execute",
|
|
||||||
"ops.execute",
|
|
||||||
"ops.sql.query",
|
|
||||||
"ops.raw-host-path",
|
|
||||||
"run.socket.open",
|
|
||||||
"auth.credential.exposed",
|
|
||||||
"auth.api-key.exposed",
|
|
||||||
}
|
|
||||||
for _, eventType := range unsafeEventTypes {
|
|
||||||
t.Run(eventType, func(t *testing.T) {
|
|
||||||
profiles := validRuntimeLogEventProfiles()
|
|
||||||
profiles.LogEvents[0].EventType = eventType
|
|
||||||
err := ValidateGamePluginRuntimeProfiles(profiles)
|
|
||||||
if err == nil || !strings.Contains(err.Error(), "eventType contains unsafe operation semantics") {
|
|
||||||
t.Fatalf("expected unsafe event type %q to be rejected, got %v", eventType, err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestValidateGamePluginManifestRegistrationRequiresDeclaredLogEventPermission(t *testing.T) {
|
|
||||||
registration := validGamePluginManifestRegistration()
|
|
||||||
registration.Manifest.RuntimeProfiles = validRuntimeLogEventProfiles()
|
|
||||||
registration.Manifest.RuntimeProfiles.LogEvents[0].Permission = "server.game-client.read"
|
|
||||||
|
|
||||||
err := ValidateGamePluginManifestRegistration(registration)
|
|
||||||
if err == nil || !strings.Contains(err.Error(), "manifest.runtimeProfiles.logEvents[0].permission must be declared by the plugin") {
|
|
||||||
t.Fatalf("expected undeclared log event permission rejection, got %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
registration.Manifest.Permissions = append(registration.Manifest.Permissions, "server.game-client.read")
|
|
||||||
if err := ValidateGamePluginManifestRegistration(registration); err != nil {
|
|
||||||
t.Fatalf("expected declared log event permission to validate, got %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -12,10 +12,8 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
runtimeLogEventSchemaRefPattern = regexp.MustCompile(`^[A-Za-z0-9_./-]+\.json$`)
|
runtimeDLLModKeyPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{0,79}$`)
|
||||||
runtimeLogEventTypePattern = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,119}$`)
|
runtimeDLLABIPattern = regexp.MustCompile(`^[A-Za-z0-9._-]{1,80}$`)
|
||||||
runtimeDLLModKeyPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{0,79}$`)
|
|
||||||
runtimeDLLABIPattern = regexp.MustCompile(`^[A-Za-z0-9._-]{1,80}$`)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func ValidateGamePluginRuntimeProfiles(profiles domain.GamePluginRuntimeProfiles) error {
|
func ValidateGamePluginRuntimeProfiles(profiles domain.GamePluginRuntimeProfiles) error {
|
||||||
@@ -23,6 +21,9 @@ func ValidateGamePluginRuntimeProfiles(profiles domain.GamePluginRuntimeProfiles
|
|||||||
var violations []string
|
var violations []string
|
||||||
lifecycleKeys := map[string]struct{}{}
|
lifecycleKeys := map[string]struct{}{}
|
||||||
transportKeys := map[string]struct{}{}
|
transportKeys := map[string]struct{}{}
|
||||||
|
transportProfiles := map[string]domain.RuntimeTransportProfile{}
|
||||||
|
dataTargetKeys := map[string]struct{}{}
|
||||||
|
dataTargetWorkspaces := map[string]struct{}{}
|
||||||
managerKeys := map[string]struct{}{}
|
managerKeys := map[string]struct{}{}
|
||||||
dllExtensionKeys := map[string]struct{}{}
|
dllExtensionKeys := map[string]struct{}{}
|
||||||
dllExtensionStates := map[string]string{}
|
dllExtensionStates := map[string]string{}
|
||||||
@@ -31,9 +32,6 @@ func ValidateGamePluginRuntimeProfiles(profiles domain.GamePluginRuntimeProfiles
|
|||||||
installPlanKeys := map[string]struct{}{}
|
installPlanKeys := map[string]struct{}{}
|
||||||
serverDeploymentKeys := map[string]struct{}{}
|
serverDeploymentKeys := map[string]struct{}{}
|
||||||
logSourceKeys := map[string]struct{}{}
|
logSourceKeys := map[string]struct{}{}
|
||||||
logSourceRetentions := map[string]int{}
|
|
||||||
logEventKeys := map[string]struct{}{}
|
|
||||||
logEventTypes := map[string]struct{}{}
|
|
||||||
|
|
||||||
for i, probe := range profiles.Discovery {
|
for i, probe := range profiles.Discovery {
|
||||||
prefix := fmt.Sprintf("runtimeProfiles.discovery[%d]", i)
|
prefix := fmt.Sprintf("runtimeProfiles.discovery[%d]", i)
|
||||||
@@ -237,9 +235,6 @@ func ValidateGamePluginRuntimeProfiles(profiles domain.GamePluginRuntimeProfiles
|
|||||||
prefix := fmt.Sprintf("runtimeProfiles.logSources[%d]", i)
|
prefix := fmt.Sprintf("runtimeProfiles.logSources[%d]", i)
|
||||||
violations = append(violations, validateProfileKey(prefix+".key", source.Key)...)
|
violations = append(violations, validateProfileKey(prefix+".key", source.Key)...)
|
||||||
violations = append(violations, recordRuntimeProfileKey(logSourceKeys, prefix+".key", source.Key)...)
|
violations = append(violations, recordRuntimeProfileKey(logSourceKeys, prefix+".key", source.Key)...)
|
||||||
if source.Key != "" {
|
|
||||||
logSourceRetentions[source.Key] = source.RetentionDays
|
|
||||||
}
|
|
||||||
if !oneOf(source.Kind, "process.stdout", "process.stderr", "file.tail", "ftp.poll", "sql.query", "client-manager") {
|
if !oneOf(source.Kind, "process.stdout", "process.stderr", "file.tail", "ftp.poll", "sql.query", "client-manager") {
|
||||||
violations = append(violations, prefix+".kind is invalid")
|
violations = append(violations, prefix+".kind is invalid")
|
||||||
}
|
}
|
||||||
@@ -254,44 +249,6 @@ func ValidateGamePluginRuntimeProfiles(profiles domain.GamePluginRuntimeProfiles
|
|||||||
violations = append(violations, prefix+".retentionDays is invalid")
|
violations = append(violations, prefix+".retentionDays is invalid")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(profiles.LogEvents) > 128 {
|
|
||||||
violations = append(violations, "runtimeProfiles.logEvents must not exceed 128")
|
|
||||||
}
|
|
||||||
for i, event := range profiles.LogEvents {
|
|
||||||
prefix := fmt.Sprintf("runtimeProfiles.logEvents[%d]", i)
|
|
||||||
violations = append(violations, validateProfileKey(prefix+".key", event.Key)...)
|
|
||||||
violations = append(violations, recordRuntimeProfileKey(logEventKeys, prefix+".key", event.Key)...)
|
|
||||||
if strings.TrimSpace(event.Title) == "" || len([]rune(event.Title)) > 80 {
|
|
||||||
violations = append(violations, prefix+".title is invalid")
|
|
||||||
}
|
|
||||||
violations = append(violations, validateSafeRuntimeValue(prefix+".title", event.Title)...)
|
|
||||||
violations = append(violations, validateProfileKey(prefix+".sourceKey", event.SourceKey)...)
|
|
||||||
if _, exists := logSourceKeys[event.SourceKey]; !exists {
|
|
||||||
violations = append(violations, prefix+".sourceKey must reference a declared runtime log source")
|
|
||||||
}
|
|
||||||
if !runtimeLogEventTypePattern.MatchString(event.EventType) {
|
|
||||||
violations = append(violations, prefix+".eventType is invalid")
|
|
||||||
}
|
|
||||||
violations = append(violations, recordRuntimeProfileKey(logEventTypes, prefix+".eventType", event.EventType)...)
|
|
||||||
if hasUnsafeRuntimeLogEventSemantics(event.EventType) {
|
|
||||||
violations = append(violations, prefix+".eventType contains unsafe operation semantics")
|
|
||||||
}
|
|
||||||
if !validPluginPermission(event.Permission) {
|
|
||||||
violations = append(violations, prefix+".permission is not allowed")
|
|
||||||
}
|
|
||||||
if len(event.SchemaRef) > 240 || !runtimeLogEventSchemaRefPattern.MatchString(event.SchemaRef) || !safeRelativeJSONRef(event.SchemaRef) {
|
|
||||||
violations = append(violations, prefix+".schemaRef must be a bounded safe relative JSON reference")
|
|
||||||
}
|
|
||||||
if event.RetentionDays < 1 || event.RetentionDays > 365 {
|
|
||||||
violations = append(violations, prefix+".retentionDays is invalid")
|
|
||||||
}
|
|
||||||
if sourceRetention, exists := logSourceRetentions[event.SourceKey]; exists && sourceRetention > 0 && event.RetentionDays > sourceRetention {
|
|
||||||
violations = append(violations, prefix+".retentionDays must not exceed the source retention")
|
|
||||||
}
|
|
||||||
if !oneOf(string(event.Severity), string(domain.RuntimeLogEventSeverityInfo), string(domain.RuntimeLogEventSeverityNotice), string(domain.RuntimeLogEventSeverityWarning), string(domain.RuntimeLogEventSeverityCritical)) {
|
|
||||||
violations = append(violations, prefix+".severity is invalid")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for i, transport := range profiles.TransportProfiles {
|
for i, transport := range profiles.TransportProfiles {
|
||||||
prefix := fmt.Sprintf("runtimeProfiles.transportProfiles[%d]", i)
|
prefix := fmt.Sprintf("runtimeProfiles.transportProfiles[%d]", i)
|
||||||
violations = append(violations, validateProfileKey(prefix+".key", transport.Key)...)
|
violations = append(violations, validateProfileKey(prefix+".key", transport.Key)...)
|
||||||
@@ -311,6 +268,36 @@ func ValidateGamePluginRuntimeProfiles(profiles domain.GamePluginRuntimeProfiles
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
violations = append(violations, duplicateViolations(prefix+".capabilities", transport.Capabilities)...)
|
violations = append(violations, duplicateViolations(prefix+".capabilities", transport.Capabilities)...)
|
||||||
|
if transport.Key != "" {
|
||||||
|
transportProfiles[transport.Key] = transport
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(profiles.DataTargets) > 16 {
|
||||||
|
violations = append(violations, "runtimeProfiles.dataTargets must not exceed 16")
|
||||||
|
}
|
||||||
|
for i, target := range profiles.DataTargets {
|
||||||
|
prefix := fmt.Sprintf("runtimeProfiles.dataTargets[%d]", i)
|
||||||
|
violations = append(violations, validateProfileKey(prefix+".key", target.Key)...)
|
||||||
|
violations = append(violations, recordRuntimeProfileKey(dataTargetKeys, prefix+".key", target.Key)...)
|
||||||
|
violations = append(violations, validateProfileKey(prefix+".transportKey", target.TransportKey)...)
|
||||||
|
violations = append(violations, validateProfileKey(prefix+".sourceRootKey", target.SourceRootKey)...)
|
||||||
|
violations = append(violations, validateSafeRelativeRuntimePath(prefix+".sourcePath", target.SourcePath)...)
|
||||||
|
violations = append(violations, validateProfileKey(prefix+".workspaceKey", target.WorkspaceKey)...)
|
||||||
|
if target.Kind != "sqlite.snapshot" || target.RefreshPolicy != "on-demand-snapshot" || !strings.HasPrefix(target.WorkspaceKey, "databases/") {
|
||||||
|
violations = append(violations, prefix+" must declare an on-demand sqlite snapshot workspace")
|
||||||
|
}
|
||||||
|
if target.MaxBytes < 1 || target.MaxBytes > 1024*1024*1024 {
|
||||||
|
violations = append(violations, prefix+".maxBytes is invalid")
|
||||||
|
}
|
||||||
|
violations = append(violations, validateRuntimePlatforms(prefix+".platforms", target.Platforms)...)
|
||||||
|
if _, exists := dataTargetWorkspaces[target.WorkspaceKey]; exists {
|
||||||
|
violations = append(violations, prefix+".workspaceKey is duplicated")
|
||||||
|
}
|
||||||
|
dataTargetWorkspaces[target.WorkspaceKey] = struct{}{}
|
||||||
|
transport, exists := transportProfiles[target.TransportKey]
|
||||||
|
if !exists || transport.Kind != "sqlite" || transport.TargetKey != target.TransportKey || !containsString(transport.Capabilities, domain.JobCapabilityRemoteRunDBSQLiteQuery) {
|
||||||
|
violations = append(violations, prefix+".transportKey must reference a declared SQLite query transport")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
for i, manager := range profiles.ClientManagers {
|
for i, manager := range profiles.ClientManagers {
|
||||||
prefix := fmt.Sprintf("runtimeProfiles.clientManagers[%d]", i)
|
prefix := fmt.Sprintf("runtimeProfiles.clientManagers[%d]", i)
|
||||||
@@ -609,42 +596,6 @@ func validateSafeRuntimeValue(field, value string) []string {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func hasUnsafeRuntimeLogEventSemantics(eventType string) bool {
|
|
||||||
lowered := strings.ToLower(strings.TrimSpace(eventType))
|
|
||||||
tokens := strings.FieldsFunc(lowered, func(char rune) bool {
|
|
||||||
return char == '.' || char == '_' || char == '-' || char == '/'
|
|
||||||
})
|
|
||||||
unsafeTokens := map[string]struct{}{
|
|
||||||
"apikey": {}, "credential": {}, "credentials": {}, "eval": {}, "exec": {},
|
|
||||||
"execute": {}, "password": {}, "powershell": {}, "script": {}, "secret": {},
|
|
||||||
"shell": {}, "socket": {}, "terminal": {}, "token": {},
|
|
||||||
}
|
|
||||||
for _, token := range tokens {
|
|
||||||
if _, unsafe := unsafeTokens[token]; unsafe {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for index := 0; index+1 < len(tokens); index++ {
|
|
||||||
pair := tokens[index] + "." + tokens[index+1]
|
|
||||||
switch pair {
|
|
||||||
case "absolute.path", "access.key", "api.key", "component.key", "database.query", "direct.socket", "file.path", "host.path", "private.key", "raw.path", "run.direct", "run.socket", "unix.socket":
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
tokenSet := make(map[string]struct{}, len(tokens))
|
|
||||||
for _, token := range tokens {
|
|
||||||
tokenSet[token] = struct{}{}
|
|
||||||
}
|
|
||||||
if _, hasSQL := tokenSet["sql"]; hasSQL {
|
|
||||||
for _, token := range []string{"query", "statement", "raw"} {
|
|
||||||
if _, unsafe := tokenSet[token]; unsafe {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func recordRuntimeProfileKey(seen map[string]struct{}, field, key string) []string {
|
func recordRuntimeProfileKey(seen map[string]struct{}, field, key string) []string {
|
||||||
if key == "" {
|
if key == "" {
|
||||||
return nil
|
return nil
|
||||||
@@ -681,20 +632,6 @@ func validateRuntimeProfileCapabilityDeclarations(profiles domain.GamePluginRunt
|
|||||||
return violations
|
return violations
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateRuntimeLogEventPermissionDeclarations(field string, profiles domain.GamePluginRuntimeProfiles, declared []string) []string {
|
|
||||||
declaredSet := make(map[string]struct{}, len(declared))
|
|
||||||
for _, permission := range declared {
|
|
||||||
declaredSet[permission] = struct{}{}
|
|
||||||
}
|
|
||||||
var violations []string
|
|
||||||
for i, event := range profiles.LogEvents {
|
|
||||||
if _, exists := declaredSet[event.Permission]; !exists {
|
|
||||||
violations = append(violations, fmt.Sprintf("%s[%d].permission must be declared by the plugin", field, i))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return violations
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateLifecycleActionsOptional(actions domain.PluginLifecycleActions) []string {
|
func validateLifecycleActionsOptional(actions domain.PluginLifecycleActions) []string {
|
||||||
var violations []string
|
var violations []string
|
||||||
for field, value := range map[string]string{"install": actions.Install, "start": actions.Start, "stop": actions.Stop, "restart": actions.Restart, "status": actions.Status} {
|
for field, value := range map[string]string{"install": actions.Install, "start": actions.Start, "stop": actions.Stop, "restart": actions.Restart, "status": actions.Status} {
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ export interface GameClientBridgeQueryProjectionDeclarationResponse {
|
|||||||
mergeExisting?: boolean;
|
mergeExisting?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GameClientBridgeDataPackDeclarationResponse { key: string; databaseUserVersion: number; logParserRefs: string[]; configMapRefs: string[]; }
|
export interface GameClientBridgeDataPackDeclarationResponse { key: string; databaseUserVersion: number; configMapRefs: string[]; dataRefs?: string[]; }
|
||||||
|
|
||||||
export interface GameClientBridgePageContractResponse {
|
export interface GameClientBridgePageContractResponse {
|
||||||
pageKey: string;
|
pageKey: string;
|
||||||
@@ -311,16 +311,6 @@ export interface PluginLogicalFileResponse { key: string; directoryKey: string;
|
|||||||
export interface PluginConfigFieldResponse { key: string; fileKey: string; configKey: string; label: string; description: string; control: "text" | "number" | "boolean" | "port"; minimum?: number; maximum?: number; defaultValue?: string; restartImpact: "none" | "restart-required"; }
|
export interface PluginConfigFieldResponse { key: string; fileKey: string; configKey: string; label: string; description: string; control: "text" | "number" | "boolean" | "port"; minimum?: number; maximum?: number; defaultValue?: string; restartImpact: "none" | "restart-required"; }
|
||||||
export interface PluginFileWorkspaceResponse { defaultDirectoryKey: string; directories: PluginLogicalDirectoryResponse[]; files: PluginLogicalFileResponse[]; configFields: PluginConfigFieldResponse[]; }
|
export interface PluginFileWorkspaceResponse { defaultDirectoryKey: string; directories: PluginLogicalDirectoryResponse[]; files: PluginLogicalFileResponse[]; configFields: PluginConfigFieldResponse[]; }
|
||||||
|
|
||||||
export interface RuntimeLogEventResponse {
|
|
||||||
key: string;
|
|
||||||
title: string;
|
|
||||||
sourceKey: string;
|
|
||||||
eventType: string;
|
|
||||||
permission: string;
|
|
||||||
schemaRef: string;
|
|
||||||
retentionDays: number;
|
|
||||||
severity: "info" | "notice" | "warning" | "critical";
|
|
||||||
}
|
|
||||||
export interface RuntimeTransportProfileResponse {
|
export interface RuntimeTransportProfileResponse {
|
||||||
key: string;
|
key: string;
|
||||||
kind: string;
|
kind: string;
|
||||||
@@ -369,7 +359,6 @@ export interface GamePluginRuntimeProfilesResponse {
|
|||||||
installPlans?: RuntimeInstallPlanResponse[];
|
installPlans?: RuntimeInstallPlanResponse[];
|
||||||
serverDeployments?: RuntimeServerDeploymentProfileResponse[];
|
serverDeployments?: RuntimeServerDeploymentProfileResponse[];
|
||||||
logSources?: RuntimeLogSourceResponse[];
|
logSources?: RuntimeLogSourceResponse[];
|
||||||
logEvents?: RuntimeLogEventResponse[];
|
|
||||||
transportProfiles?: RuntimeTransportProfileResponse[];
|
transportProfiles?: RuntimeTransportProfileResponse[];
|
||||||
clientManagers?: RuntimeClientManagerProfileResponse[];
|
clientManagers?: RuntimeClientManagerProfileResponse[];
|
||||||
dllExtensions?: RuntimeDLLExtensionProfileResponse[];
|
dllExtensions?: RuntimeDLLExtensionProfileResponse[];
|
||||||
|
|||||||
@@ -1,40 +0,0 @@
|
|||||||
package companion
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestConsoleSemanticEventProducerParsesOnlyBoundedKnownOutput(t *testing.T) {
|
|
||||||
availability := VerifiedSemanticEventProducer()
|
|
||||||
if !availability.Available || availability.Reason == "" {
|
|
||||||
t.Fatalf("console event producer should be available: %+v", availability)
|
|
||||||
}
|
|
||||||
batch := ParseConsoleRecords("server-1", []ConsoleRecord{{ServerID: "server-1", Stream: "stdout", Sequence: 1, OccurredAt: time.Now(), Text: "SCUM LOGIN 76561198000000001 10.0.0.1"}, {ServerID: "server-1", Stream: "stderr", Sequence: 2, OccurredAt: time.Now(), Text: "unrecognised output"}}, "fixture-secret")
|
|
||||||
if len(batch.Events) != 1 || batch.Events[0].Type != "scum.login" || batch.Events[0].NetworkCorrelation == "" || len(batch.Diagnostics) != 1 || batch.Diagnostics[0].Code != "unknown-console-format" {
|
|
||||||
t.Fatalf("unsafe console parsing result: %+v", batch)
|
|
||||||
}
|
|
||||||
if batch.Events[0].NetworkCorrelation == "10.0.0.1" {
|
|
||||||
t.Fatal("raw network value leaked")
|
|
||||||
}
|
|
||||||
if len(batch.Events[0].NetworkCorrelation) != 64 {
|
|
||||||
t.Fatalf("network correlation must be full sha256 hex, got %q", batch.Events[0].NetworkCorrelation)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestConsoleSemanticEventProducerParsesScumLoginLog(t *testing.T) {
|
|
||||||
observedAt := time.Date(2026, 8, 27, 12, 0, 0, 0, time.UTC)
|
|
||||||
batch := ParseConsoleRecords("server-1", []ConsoleRecord{
|
|
||||||
{ServerID: "server-1", Stream: "stdout", Sequence: 1, OccurredAt: observedAt, Text: "2026.08.27-12.00.00: '10.0.0.2 76561198000000002:Ada(42)' logged in at: X=1 Y=2 Z=3"},
|
|
||||||
{ServerID: "server-1", Stream: "stdout", Sequence: 2, OccurredAt: observedAt.Add(time.Second), Text: "2026.08.27-12.00.01: '10.0.0.2 76561198000000002:Ada(42)' logged out"},
|
|
||||||
}, "fixture-secret")
|
|
||||||
if len(batch.Events) != 2 || batch.Events[0].Type != "scum.login" || batch.Events[1].Type != "scum.logout" {
|
|
||||||
t.Fatalf("login log events not parsed: %+v", batch)
|
|
||||||
}
|
|
||||||
if batch.Events[0].PlayerID != "76561198000000002" || batch.Events[0].DisplayName != "Ada" || len(batch.Events[0].NetworkCorrelation) != 64 {
|
|
||||||
t.Fatalf("login event fields are incomplete: %+v", batch.Events[0])
|
|
||||||
}
|
|
||||||
if batch.Events[0].NetworkCorrelation == "10.0.0.2" {
|
|
||||||
t.Fatal("raw login log IP leaked")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
{
|
|
||||||
"version": 2,
|
|
||||||
"encoding": "utf-16le",
|
|
||||||
"lineEnding": "lf",
|
|
||||||
"continuationPolicy": "append-to-previous-timestamped-record",
|
|
||||||
"timestampFormat": "yyyy.MM.dd-HH.mm.ss",
|
|
||||||
"defaultPattern": "^([0-9]{4}\\.[0-9]{2}\\.[0-9]{2}-[0-9]{2}\\.[0-9]{2}\\.[0-9]{2}):?\\s*(.*)$",
|
|
||||||
"defaultFields": ["occurredAt", "payload"],
|
|
||||||
"parsers": [
|
|
||||||
{ "key": "login", "filePattern": "^login_[0-9]{14}\\.log$", "eventType": "scum.login" },
|
|
||||||
{ "key": "chat", "filePattern": "^chat_[0-9]{14}\\.log$", "eventType": "scum.chat" },
|
|
||||||
{ "key": "admin", "filePattern": "^admin_[0-9]{14}\\.log$", "eventType": "scum.admin" },
|
|
||||||
{ "key": "kill", "filePattern": "^kill_[0-9]{14}\\.log$", "eventType": "scum.kill" },
|
|
||||||
{ "key": "event-kill", "filePattern": "^event_kill_[0-9]{14}\\.log$", "eventType": "scum.event.kill" },
|
|
||||||
{ "key": "quests", "filePattern": "^quests_[0-9]{14}\\.log$", "eventType": "scum.quest" },
|
|
||||||
{ "key": "famepoints", "filePattern": "^famepoints_[0-9]{14}\\.log$", "eventType": "scum.famepoints" },
|
|
||||||
{ "key": "economy", "filePattern": "^economy_[0-9]{14}\\.log$", "eventType": "scum.economy" },
|
|
||||||
{ "key": "gameplay", "filePattern": "^gameplay_[0-9]{14}\\.log$", "eventType": "scum.gameplay" },
|
|
||||||
{ "key": "vehicle-destruction", "filePattern": "^vehicle_destruction_[0-9]{14}\\.log$", "eventType": "scum.vehicle.destruction" },
|
|
||||||
{ "key": "raid-protection", "filePattern": "^raid_protection_[0-9]{14}\\.log$", "eventType": "scum.raid.protection" },
|
|
||||||
{ "key": "base-building-destruction", "filePattern": "^base_building_destruction_[0-9]{14}\\.log$", "eventType": "scum.base.destruction" },
|
|
||||||
{ "key": "chest-ownership", "filePattern": "^chest_ownership_[0-9]{14}\\.log$", "eventType": "scum.chest.ownership" },
|
|
||||||
{ "key": "loot", "filePattern": "^loot_[0-9]{14}\\.log$", "eventType": "scum.loot" },
|
|
||||||
{ "key": "violations", "filePattern": "^violations_[0-9]{14}\\.log$", "eventType": "scum.violation" },
|
|
||||||
{ "key": "sentry", "filePattern": "^sentry_[0-9]{14}\\.log$", "eventType": "scum.sentry" },
|
|
||||||
{ "key": "server-notifications", "filePattern": "^server_notifications_[0-9]{14}\\.log$", "eventType": "scum.server.notification" },
|
|
||||||
{ "key": "armor-absorption", "filePattern": "^armor_absorption_[0-9]{14}\\.log$", "eventType": "scum.armor.absorption" },
|
|
||||||
{ "key": "network-objects", "filePattern": "^network_objects_[0-9]{14}\\.log$", "eventType": "scum.network.object" }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
-1
@@ -1 +0,0 @@
|
|||||||
{"type":"object","additionalProperties":false,"required":["events"],"properties":{"events":{"type":"array","minItems":1,"maxItems":100,"items":{"type":"object","additionalProperties":false,"required":["type","occurredAt"],"properties":{"type":{"type":"string","enum":["scum.login","scum.logout"],"maxLength":16},"occurredAt":{"type":"string","minLength":20,"maxLength":40},"playerId":{"type":"string","minLength":1,"maxLength":96},"displayName":{"type":"string","minLength":1,"maxLength":80},"networkCorrelation":{"type":"string","pattern":"^[a-f0-9]{64}$","maxLength":64}}}}}}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"required": ["occurredAt", "adminActorId", "actionCategory", "approved"],
|
|
||||||
"properties": {
|
|
||||||
"occurredAt": { "type": "string", "format": "date-time", "minLength": 1, "maxLength": 40 },
|
|
||||||
"adminActorId": { "type": "string", "minLength": 1, "maxLength": 96 },
|
|
||||||
"actionCategory": { "type": "string", "enum": ["announcement", "teleport", "spawn", "kick", "ban", "unban", "restart", "config-review", "other"] },
|
|
||||||
"targetPlayerId": { "type": "string", "minLength": 1, "maxLength": 96 },
|
|
||||||
"reason": { "type": "string", "minLength": 1, "maxLength": 256 },
|
|
||||||
"approved": { "type": "boolean" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"required": ["occurredAt", "playerId", "playerName", "channel", "message"],
|
|
||||||
"properties": {
|
|
||||||
"occurredAt": { "type": "string", "format": "date-time", "minLength": 1, "maxLength": 40 },
|
|
||||||
"playerId": { "type": "string", "minLength": 1, "maxLength": 96 },
|
|
||||||
"playerName": { "type": "string", "minLength": 1, "maxLength": 80 },
|
|
||||||
"channel": { "type": "string", "enum": ["local", "global", "squad", "admin", "unknown"] },
|
|
||||||
"message": { "type": "string", "minLength": 1, "maxLength": 512 }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"required": ["occurredAt", "victimPlayerId", "victimName", "weaponClass", "distanceMeters"],
|
|
||||||
"properties": {
|
|
||||||
"occurredAt": { "type": "string", "format": "date-time", "minLength": 1, "maxLength": 40 },
|
|
||||||
"killerPlayerId": { "type": "string", "minLength": 1, "maxLength": 96 },
|
|
||||||
"killerName": { "type": "string", "minLength": 1, "maxLength": 80 },
|
|
||||||
"victimPlayerId": { "type": "string", "minLength": 1, "maxLength": 96 },
|
|
||||||
"victimName": { "type": "string", "minLength": 1, "maxLength": 80 },
|
|
||||||
"weaponClass": { "type": "string", "minLength": 1, "maxLength": 80 },
|
|
||||||
"distanceMeters": { "type": "number", "minimum": 0, "maximum": 5000 },
|
|
||||||
"suicide": { "type": "boolean" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"required": ["occurredAt", "playerId", "playerName", "sessionId", "outcome"],
|
|
||||||
"properties": {
|
|
||||||
"occurredAt": { "type": "string", "format": "date-time", "minLength": 1, "maxLength": 40 },
|
|
||||||
"playerId": { "type": "string", "minLength": 1, "maxLength": 96 },
|
|
||||||
"playerName": { "type": "string", "minLength": 1, "maxLength": 80 },
|
|
||||||
"sessionId": { "type": "string", "minLength": 1, "maxLength": 96 },
|
|
||||||
"outcome": { "type": "string", "enum": ["accepted", "rejected"] },
|
|
||||||
"networkFingerprint": { "type": "string", "minLength": 1, "maxLength": 128, "writeOnly": true, "description": "Transient source material for server-local irreversible correlation only; Platform never persists or returns this value." }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"required": ["occurredAt", "playerId", "playerName", "sessionId", "reason"],
|
|
||||||
"properties": {
|
|
||||||
"occurredAt": { "type": "string", "format": "date-time", "minLength": 1, "maxLength": 40 },
|
|
||||||
"playerId": { "type": "string", "minLength": 1, "maxLength": 96 },
|
|
||||||
"playerName": { "type": "string", "minLength": 1, "maxLength": 80 },
|
|
||||||
"sessionId": { "type": "string", "minLength": 1, "maxLength": 96 },
|
|
||||||
"reason": { "type": "string", "enum": ["disconnect", "timeout", "kicked", "server-stop", "unknown"] }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"required": ["occurredAt", "playerId", "action", "mineClass", "zone", "suspicious"],
|
|
||||||
"properties": {
|
|
||||||
"occurredAt": { "type": "string", "format": "date-time", "minLength": 1, "maxLength": 40 },
|
|
||||||
"playerId": { "type": "string", "minLength": 1, "maxLength": 96 },
|
|
||||||
"action": { "type": "string", "enum": ["placed", "triggered", "detonated", "disarmed", "removed"] },
|
|
||||||
"mineClass": { "type": "string", "minLength": 1, "maxLength": 80 },
|
|
||||||
"zone": { "type": "string", "minLength": 1, "maxLength": 32 },
|
|
||||||
"suspicious": { "type": "boolean" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"required": ["occurredAt", "serverFps", "frameTimeMs", "onlinePlayers", "entityCount"],
|
|
||||||
"properties": {
|
|
||||||
"occurredAt": { "type": "string", "format": "date-time", "minLength": 1, "maxLength": 40 },
|
|
||||||
"serverFps": { "type": "number", "minimum": 0, "maximum": 1000 },
|
|
||||||
"frameTimeMs": { "type": "number", "minimum": 0, "maximum": 1000 },
|
|
||||||
"onlinePlayers": { "type": "integer", "minimum": 0, "maximum": 1000 },
|
|
||||||
"entityCount": { "type": "integer", "minimum": 0, "maximum": 10000000 }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"required": ["occurredAt", "playerId", "tradeKind", "itemCount", "currencyDelta", "suspicious"],
|
|
||||||
"properties": {
|
|
||||||
"occurredAt": { "type": "string", "format": "date-time", "minLength": 1, "maxLength": 40 },
|
|
||||||
"source": { "type": "string", "enum": ["companion", "log-projection", "scum.trade"] },
|
|
||||||
"playerId": { "type": "string", "minLength": 1, "maxLength": 96 },
|
|
||||||
"counterpartyPlayerId": { "type": "string", "minLength": 1, "maxLength": 96 },
|
|
||||||
"itemCode": { "type": "string", "pattern": "^[A-Za-z0-9_.-]{1,128}$", "minLength": 1, "maxLength": 128 },
|
|
||||||
"tradeVerb": { "type": "string", "enum": ["purchased", "sold"] },
|
|
||||||
"tradeKind": { "type": "string", "enum": ["purchase", "sale", "transfer", "unknown"] },
|
|
||||||
"quantity": { "type": "integer", "minimum": 0, "maximum": 1000000000 },
|
|
||||||
"itemCount": { "type": "integer", "minimum": 0, "maximum": 1000 },
|
|
||||||
"price": { "type": "integer", "minimum": -1000000000, "maximum": 1000000000 },
|
|
||||||
"currencyDelta": { "type": "integer", "minimum": -1000000000, "maximum": 1000000000 },
|
|
||||||
"suspicious": { "type": "boolean" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"required": ["occurredAt", "playerId", "targetKind", "targetId", "outcome", "suspicious"],
|
|
||||||
"properties": {
|
|
||||||
"occurredAt": { "type": "string", "format": "date-time", "minLength": 1, "maxLength": 40 },
|
|
||||||
"playerId": { "type": "string", "minLength": 1, "maxLength": 96 },
|
|
||||||
"targetKind": { "type": "string", "enum": ["door", "container", "vehicle", "base", "unknown"] },
|
|
||||||
"targetId": { "type": "string", "minLength": 1, "maxLength": 128 },
|
|
||||||
"outcome": { "type": "string", "enum": ["success", "failed", "cancelled"] },
|
|
||||||
"suspicious": { "type": "boolean" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -17,19 +17,6 @@ function formatErrors(prefix: string, errors: ErrorObject[] | null | undefined):
|
|||||||
return (errors ?? []).map((error) => `${prefix}${error.instancePath}: ${error.message}`);
|
return (errors ?? []).map((error) => `${prefix}${error.instancePath}: ${error.message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractNamedCaptureNames(pattern: string): string[] {
|
|
||||||
const captures: string[] = [];
|
|
||||||
const capturePattern = /\(\?(?:P)?<([A-Za-z][A-Za-z0-9_]*)>/g;
|
|
||||||
for (const match of pattern.matchAll(capturePattern)) {
|
|
||||||
captures.push(match[1]);
|
|
||||||
}
|
|
||||||
return captures;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeNamedCaptureSyntax(pattern: string): string {
|
|
||||||
return pattern.replace(/\(\?P<([A-Za-z][A-Za-z0-9_]*)>/g, "(?<$1>");
|
|
||||||
}
|
|
||||||
|
|
||||||
function unsafeFieldReason(fieldName: string): string | undefined {
|
function unsafeFieldReason(fieldName: string): string | undefined {
|
||||||
const compact = fieldName.toLowerCase().replace(/[^a-z0-9]/g, "");
|
const compact = fieldName.toLowerCase().replace(/[^a-z0-9]/g, "");
|
||||||
if (compact.includes("rawapikey") || compact.includes("apikey") || compact.includes("providerkey")) {
|
if (compact.includes("rawapikey") || compact.includes("apikey") || compact.includes("providerkey")) {
|
||||||
@@ -283,57 +270,6 @@ function validateBoundedBridgeSchema(value: unknown, location: string): string[]
|
|||||||
return errors;
|
return errors;
|
||||||
}
|
}
|
||||||
|
|
||||||
function unsafeSemanticLogEventTypeReason(value: string): string | undefined {
|
|
||||||
const tokens = identifierTokens(value);
|
|
||||||
const tokenSet = new Set(tokens);
|
|
||||||
if (
|
|
||||||
tokens.some((token) => ["shell", "powershell", "script", "terminal", "execute", "exec", "eval"].includes(token)) ||
|
|
||||||
tokens.some((token) => ["credential", "password", "secret", "socket"].includes(token)) ||
|
|
||||||
(tokenSet.has("run") && (tokenSet.has("direct") || tokenSet.has("socket"))) ||
|
|
||||||
(tokenSet.has("path") && (tokenSet.has("host") || tokenSet.has("raw"))) ||
|
|
||||||
(tokenSet.has("sql") && tokens.some((token) => ["query", "statement", "raw", "execute", "exec"].includes(token)))
|
|
||||||
) {
|
|
||||||
return "unsafe SQL, shell, path, credential, or socket event types are not allowed";
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function validateBoundedSemanticLogSchema(value: unknown, location: string): string[] {
|
|
||||||
const errors = validateBoundedBridgeSchema(value, location);
|
|
||||||
const visit = (node: unknown, nodeLocation: string): void => {
|
|
||||||
if (Array.isArray(node)) {
|
|
||||||
node.forEach((item, index) => visit(item, `${nodeLocation}[${index}]`));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (typeof node !== "object" || node === null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const record = node as Record<string, unknown>;
|
|
||||||
if (record.type === "array") {
|
|
||||||
if (!Number.isInteger(record.maxItems) || (record.maxItems as number) < 1 || (record.maxItems as number) > 1000) {
|
|
||||||
errors.push(`${nodeLocation}.maxItems: bounded event arrays must set maxItems between 1 and 1000`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (record.type === "string" && !Object.hasOwn(record, "enum") && !Object.hasOwn(record, "const")) {
|
|
||||||
if (!Number.isInteger(record.maxLength) || (record.maxLength as number) < 1 || (record.maxLength as number) > 4096) {
|
|
||||||
errors.push(`${nodeLocation}.maxLength: bounded event strings must set maxLength between 1 and 4096`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (record.type === "integer" || record.type === "number") {
|
|
||||||
if (typeof record.minimum !== "number" || !Number.isFinite(record.minimum) || typeof record.maximum !== "number" || !Number.isFinite(record.maximum)) {
|
|
||||||
errors.push(`${nodeLocation}: bounded event numbers must set finite minimum and maximum values`);
|
|
||||||
} else if (record.minimum > record.maximum) {
|
|
||||||
errors.push(`${nodeLocation}: event number minimum must not exceed maximum`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (const [key, child] of Object.entries(record)) {
|
|
||||||
visit(child, `${nodeLocation}.${key}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
visit(value, location);
|
|
||||||
return errors;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function validateLifecycleActionFile(actionPath: string, expectedAction?: string): string[] {
|
export function validateLifecycleActionFile(actionPath: string, expectedAction?: string): string[] {
|
||||||
const action = readJson(path.resolve(rootDir, actionPath));
|
const action = readJson(path.resolve(rootDir, actionPath));
|
||||||
const ajv = new Ajv2020({ allErrors: true });
|
const ajv = new Ajv2020({ allErrors: true });
|
||||||
@@ -689,20 +625,6 @@ export function validateGameClientBridgeCatalog(manifest: unknown): string[] {
|
|||||||
timeoutSeconds?: number;
|
timeoutSeconds?: number;
|
||||||
pollIntervalSeconds?: number;
|
pollIntervalSeconds?: number;
|
||||||
};
|
};
|
||||||
type BridgeLogProjectionTarget = { collection?: string; upsertKeys?: string[]; captureMappings?: Record<string, string>; fixedValues?: Record<string, string>; observedAtField?: string };
|
|
||||||
type BridgeLogProjection = {
|
|
||||||
key?: string;
|
|
||||||
streamKeys?: string[];
|
|
||||||
steps?: Array<{ pattern?: string }>;
|
|
||||||
correlationFields?: string[];
|
|
||||||
maxInterveningLines?: number;
|
|
||||||
target?: BridgeLogProjectionTarget;
|
|
||||||
presence?: {
|
|
||||||
timestampField?: string;
|
|
||||||
activeWindowSeconds?: number;
|
|
||||||
activityTarget?: BridgeLogProjectionTarget;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
type BridgePage = { pageKey?: string; commandTypes?: string[]; snapshotTypes?: string[]; queryTemplateKeys?: string[] };
|
type BridgePage = { pageKey?: string; commandTypes?: string[]; snapshotTypes?: string[]; queryTemplateKeys?: string[] };
|
||||||
type BridgeCompanion = {
|
type BridgeCompanion = {
|
||||||
profileKey?: string;
|
profileKey?: string;
|
||||||
@@ -726,7 +648,7 @@ export function validateGameClientBridgeCatalog(manifest: unknown): string[] {
|
|||||||
remoteAccess?: { runCapabilities?: string[]; databaseEngines?: string[] };
|
remoteAccess?: { runCapabilities?: string[]; databaseEngines?: string[] };
|
||||||
pages?: PluginPage[];
|
pages?: PluginPage[];
|
||||||
runtimeProfiles?: { transportProfiles?: RuntimeTransportProfile[]; clientManagers?: RuntimeClientManager[] };
|
runtimeProfiles?: { transportProfiles?: RuntimeTransportProfile[]; clientManagers?: RuntimeClientManager[] };
|
||||||
gameClientBridge?: { commands?: BridgeCommand[]; snapshots?: Array<{ type?: string }>; queryTemplates?: BridgeQueryTemplate[]; logProjections?: BridgeLogProjection[]; pages?: BridgePage[]; companion?: BridgeCompanion };
|
gameClientBridge?: { commands?: BridgeCommand[]; snapshots?: Array<{ type?: string }>; queryTemplates?: BridgeQueryTemplate[]; pages?: BridgePage[]; companion?: BridgeCompanion };
|
||||||
};
|
};
|
||||||
const bridge = declaration.gameClientBridge;
|
const bridge = declaration.gameClientBridge;
|
||||||
if (!bridge) {
|
if (!bridge) {
|
||||||
@@ -736,7 +658,6 @@ export function validateGameClientBridgeCatalog(manifest: unknown): string[] {
|
|||||||
const commands = new Set<string>();
|
const commands = new Set<string>();
|
||||||
const snapshots = new Set((bridge.snapshots ?? []).map((snapshot) => snapshot.type ?? ""));
|
const snapshots = new Set((bridge.snapshots ?? []).map((snapshot) => snapshot.type ?? ""));
|
||||||
const queryTemplates = new Map<string, BridgeQueryTemplate>();
|
const queryTemplates = new Map<string, BridgeQueryTemplate>();
|
||||||
const logProjections = new Set<string>();
|
|
||||||
const declaredPermissions = new Set(declaration.permissions ?? []);
|
const declaredPermissions = new Set(declaration.permissions ?? []);
|
||||||
const declaredCapabilities = new Set(declaration.capabilities ?? []);
|
const declaredCapabilities = new Set(declaration.capabilities ?? []);
|
||||||
const remoteCapabilities = new Set(declaration.remoteAccess?.runCapabilities ?? []);
|
const remoteCapabilities = new Set(declaration.remoteAccess?.runCapabilities ?? []);
|
||||||
@@ -844,63 +765,6 @@ export function validateGameClientBridgeCatalog(manifest: unknown): string[] {
|
|||||||
errors.push(`${location}: sqlite query templates require the plugin and remote-access sqlite query capability`);
|
errors.push(`${location}: sqlite query templates require the plugin and remote-access sqlite query capability`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const captureNamePattern = /^[A-Za-z][A-Za-z0-9_]{0,79}$/;
|
|
||||||
const fieldNamePattern = /^[A-Za-z][A-Za-z0-9._-]{0,79}$/;
|
|
||||||
const collectionPattern = /^[A-Za-z][A-Za-z0-9._-]{0,119}$/;
|
|
||||||
const validateProjectionTarget = (location: string, target: BridgeLogProjectionTarget | undefined, captures: Set<string>): string[] => {
|
|
||||||
const targetErrors: string[] = [];
|
|
||||||
if (!target || !collectionPattern.test(target.collection ?? "")) targetErrors.push(`${location}.collection: must be a safe collection`);
|
|
||||||
if (!Array.isArray(target?.upsertKeys) || target.upsertKeys.length < 1 || target.upsertKeys.length > 8 || !target.upsertKeys.every((key) => fieldNamePattern.test(key))) targetErrors.push(`${location}.upsertKeys: must contain 1 to 8 safe fields`);
|
|
||||||
const mappings = target?.captureMappings;
|
|
||||||
if (!mappings || Array.isArray(mappings) || Object.keys(mappings).length < 1 || Object.keys(mappings).length > 64) {
|
|
||||||
targetErrors.push(`${location}.captureMappings: must contain 1 to 64 mappings`);
|
|
||||||
} else {
|
|
||||||
for (const [destination, capture] of Object.entries(mappings)) {
|
|
||||||
if (!fieldNamePattern.test(destination) || typeof capture !== "string" || !captureNamePattern.test(capture)) targetErrors.push(`${location}.captureMappings: contains an invalid field or capture`);
|
|
||||||
if (!captures.has(capture)) targetErrors.push(`${location}.captureMappings: references undeclared capture ${capture}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const fixedValues = target?.fixedValues ?? {};
|
|
||||||
if (Array.isArray(fixedValues) || Object.keys(fixedValues).length > 64 || !Object.entries(fixedValues).every(([destination, value]) => fieldNamePattern.test(destination) && typeof value === "string" && value.length <= 4096)) targetErrors.push(`${location}.fixedValues: contains an invalid field or value`);
|
|
||||||
const declaredFields = new Set([...Object.keys(mappings ?? {}), ...Object.keys(fixedValues)]);
|
|
||||||
if (Object.keys(mappings ?? {}).some((field) => Object.prototype.hasOwnProperty.call(fixedValues, field))) targetErrors.push(`${location}: a field cannot be declared by both captureMappings and fixedValues`);
|
|
||||||
if (target?.observedAtField && (!fieldNamePattern.test(target.observedAtField) || declaredFields.has(target.observedAtField))) targetErrors.push(`${location}.observedAtField: must be a safe unique field`);
|
|
||||||
if (target?.observedAtField) declaredFields.add(target.observedAtField);
|
|
||||||
if (Array.isArray(target?.upsertKeys) && !target.upsertKeys.every((key) => declaredFields.has(key))) targetErrors.push(`${location}.upsertKeys: every key must be projected`);
|
|
||||||
return targetErrors;
|
|
||||||
};
|
|
||||||
for (const [index, projection] of (bridge.logProjections ?? []).entries()) {
|
|
||||||
const location = `manifest.gameClientBridge.logProjections[${index}]`;
|
|
||||||
const key = projection.key ?? "";
|
|
||||||
if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,159}$/.test(key)) errors.push(`${location}.key: log projection key is unsafe`);
|
|
||||||
if (logProjections.has(key)) errors.push(`${location}.key: duplicate log projection ${key}`);
|
|
||||||
logProjections.add(key);
|
|
||||||
if (!Array.isArray(projection.streamKeys) || projection.streamKeys.length < 1 || projection.streamKeys.length > 64 || new Set(projection.streamKeys).size !== projection.streamKeys.length || !projection.streamKeys.every((streamKey) => /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,159}$/.test(streamKey))) errors.push(`${location}.streamKeys: must contain 1 to 64 unique safe streams`);
|
|
||||||
const captures = new Set<string>();
|
|
||||||
if (!Array.isArray(projection.steps) || projection.steps.length < 1 || projection.steps.length > 64) {
|
|
||||||
errors.push(`${location}.steps: must contain 1 to 64 regular expressions`);
|
|
||||||
} else {
|
|
||||||
for (const [stepIndex, step] of projection.steps.entries()) {
|
|
||||||
try {
|
|
||||||
if (!step.pattern || step.pattern.length > 16384) throw new Error("invalid");
|
|
||||||
new RegExp(normalizeNamedCaptureSyntax(step.pattern));
|
|
||||||
for (const capture of extractNamedCaptureNames(step.pattern)) captures.add(capture);
|
|
||||||
} catch {
|
|
||||||
errors.push(`${location}.steps[${stepIndex}].pattern: must be a valid bounded regular expression`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!Array.isArray(projection.correlationFields) || projection.correlationFields.length < 1 || projection.correlationFields.length > 64 || new Set(projection.correlationFields).size !== projection.correlationFields.length || !projection.correlationFields.every((field) => captureNamePattern.test(field) && captures.has(field))) errors.push(`${location}.correlationFields: must reference unique named captures`);
|
|
||||||
if (!Number.isInteger(projection.maxInterveningLines) || (projection.maxInterveningLines ?? -1) < 0 || (projection.maxInterveningLines ?? 0) > 100000) errors.push(`${location}.maxInterveningLines: must be between 0 and 100000`);
|
|
||||||
errors.push(...validateProjectionTarget(`${location}.target`, projection.target, captures));
|
|
||||||
const presence = projection.presence;
|
|
||||||
if (!presence) continue;
|
|
||||||
const target = projection.target;
|
|
||||||
const targetFields = new Set([...Object.keys(target?.captureMappings ?? {}), ...Object.keys(target?.fixedValues ?? {}), ...(target?.observedAtField ? [target.observedAtField] : [])]);
|
|
||||||
if (!fieldNamePattern.test(presence.timestampField ?? "") || !targetFields.has(presence.timestampField ?? "")) errors.push(`${location}.presence.timestampField: must reference a projected target field`);
|
|
||||||
if (!Number.isInteger(presence.activeWindowSeconds) || (presence.activeWindowSeconds ?? 0) < 1 || (presence.activeWindowSeconds ?? 0) > 31536000) errors.push(`${location}.presence.activeWindowSeconds: must be between 1 and 31536000`);
|
|
||||||
if (presence.activityTarget) errors.push(...validateProjectionTarget(`${location}.presence.activityTarget`, presence.activityTarget, captures));
|
|
||||||
}
|
|
||||||
for (const [index, page] of (bridge.pages ?? []).entries()) {
|
for (const [index, page] of (bridge.pages ?? []).entries()) {
|
||||||
for (const commandType of page.commandTypes ?? []) {
|
for (const commandType of page.commandTypes ?? []) {
|
||||||
if (!commands.has(commandType)) {
|
if (!commands.has(commandType)) {
|
||||||
@@ -932,7 +796,7 @@ export function validateGameClientBridgeCatalog(manifest: unknown): string[] {
|
|||||||
|
|
||||||
function validateGameClientBridgeDataPacks(manifest: unknown, manifestDir: string, declaredAssets: Set<string>): string[] {
|
function validateGameClientBridgeDataPacks(manifest: unknown, manifestDir: string, declaredAssets: Set<string>): string[] {
|
||||||
if (typeof manifest !== "object" || manifest === null) return [];
|
if (typeof manifest !== "object" || manifest === null) return [];
|
||||||
const dataPacks = (manifest as { gameClientBridge?: { dataPacks?: Array<{ key?: string; databaseUserVersion?: number; logParserRefs?: string[]; configMapRefs?: string[]; dataRefs?: string[] }> } }).gameClientBridge?.dataPacks ?? [];
|
const dataPacks = (manifest as { gameClientBridge?: { dataPacks?: Array<{ key?: string; databaseUserVersion?: number; configMapRefs?: string[]; dataRefs?: string[] }> } }).gameClientBridge?.dataPacks ?? [];
|
||||||
const errors: string[] = [];
|
const errors: string[] = [];
|
||||||
const keys = new Set<string>();
|
const keys = new Set<string>();
|
||||||
for (const [index, dataPack] of dataPacks.entries()) {
|
for (const [index, dataPack] of dataPacks.entries()) {
|
||||||
@@ -940,7 +804,7 @@ function validateGameClientBridgeDataPacks(manifest: unknown, manifestDir: strin
|
|||||||
if (!/^[A-Za-z][A-Za-z0-9._-]{0,79}$/.test(dataPack.key ?? "") || keys.has(dataPack.key ?? "")) errors.push(`${location}.key: must be a unique data-pack key`);
|
if (!/^[A-Za-z][A-Za-z0-9._-]{0,79}$/.test(dataPack.key ?? "") || keys.has(dataPack.key ?? "")) errors.push(`${location}.key: must be a unique data-pack key`);
|
||||||
keys.add(dataPack.key ?? "");
|
keys.add(dataPack.key ?? "");
|
||||||
if (!Number.isInteger(dataPack.databaseUserVersion) || (dataPack.databaseUserVersion ?? 0) < 1) errors.push(`${location}.databaseUserVersion: must be a positive SQLite user_version`);
|
if (!Number.isInteger(dataPack.databaseUserVersion) || (dataPack.databaseUserVersion ?? 0) < 1) errors.push(`${location}.databaseUserVersion: must be a positive SQLite user_version`);
|
||||||
for (const field of ["logParserRefs", "configMapRefs", "dataRefs"] as const) {
|
for (const field of ["configMapRefs", "dataRefs"] as const) {
|
||||||
const refs = dataPack[field] ?? [];
|
const refs = dataPack[field] ?? [];
|
||||||
if (field !== "dataRefs" && refs.length === 0) errors.push(`${location}.${field}: must declare at least one package asset`);
|
if (field !== "dataRefs" && refs.length === 0) errors.push(`${location}.${field}: must declare at least one package asset`);
|
||||||
for (const ref of refs) {
|
for (const ref of refs) {
|
||||||
@@ -980,70 +844,6 @@ function validateGameClientBridgeSQLAssets(manifest: unknown, manifestDir: strin
|
|||||||
return errors;
|
return errors;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function validateRuntimeLogEventCatalog(manifest: unknown): string[] {
|
|
||||||
if (typeof manifest !== "object" || manifest === null) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
type RuntimeLogSource = { key?: string; retentionDays?: number };
|
|
||||||
type RuntimeLogEvent = {
|
|
||||||
key?: string;
|
|
||||||
sourceKey?: string;
|
|
||||||
eventType?: string;
|
|
||||||
permission?: string;
|
|
||||||
schemaRef?: string;
|
|
||||||
retentionDays?: number;
|
|
||||||
severity?: string;
|
|
||||||
};
|
|
||||||
const declaration = manifest as {
|
|
||||||
permissions?: string[];
|
|
||||||
runtimeProfiles?: { logSources?: RuntimeLogSource[]; logEvents?: RuntimeLogEvent[] };
|
|
||||||
};
|
|
||||||
const logEvents = declaration.runtimeProfiles?.logEvents ?? [];
|
|
||||||
const logSources = new Map((declaration.runtimeProfiles?.logSources ?? []).map((source) => [source.key ?? "", source]));
|
|
||||||
const permissions = new Set(declaration.permissions ?? []);
|
|
||||||
const keys = new Set<string>();
|
|
||||||
const eventTypes = new Set<string>();
|
|
||||||
const errors: string[] = [];
|
|
||||||
|
|
||||||
for (const [index, event] of logEvents.entries()) {
|
|
||||||
const location = `manifest.runtimeProfiles.logEvents[${index}]`;
|
|
||||||
const key = event.key ?? "";
|
|
||||||
const eventType = event.eventType ?? "";
|
|
||||||
if (keys.has(key)) {
|
|
||||||
errors.push(`${location}.key: duplicate semantic log event key ${key}`);
|
|
||||||
}
|
|
||||||
keys.add(key);
|
|
||||||
if (eventTypes.has(eventType)) {
|
|
||||||
errors.push(`${location}.eventType: duplicate semantic log event type ${eventType}`);
|
|
||||||
}
|
|
||||||
eventTypes.add(eventType);
|
|
||||||
const unsafeTypeReason = unsafeSemanticLogEventTypeReason(eventType);
|
|
||||||
if (unsafeTypeReason) {
|
|
||||||
errors.push(`${location}.eventType: ${unsafeTypeReason}`);
|
|
||||||
}
|
|
||||||
const source = logSources.get(event.sourceKey ?? "");
|
|
||||||
if (!source) {
|
|
||||||
errors.push(`${location}.sourceKey: undeclared log source ${event.sourceKey ?? ""}`);
|
|
||||||
}
|
|
||||||
if (!event.permission || !permissions.has(event.permission)) {
|
|
||||||
errors.push(`${location}.permission: permission must be declared by the plugin manifest`);
|
|
||||||
}
|
|
||||||
if (!event.schemaRef || !isSafeRelativeJsonRef(event.schemaRef)) {
|
|
||||||
errors.push(`${location}.schemaRef: raw host paths and unsafe schema references are not allowed`);
|
|
||||||
}
|
|
||||||
if (!Number.isInteger(event.retentionDays) || (event.retentionDays ?? 0) < 1 || (event.retentionDays ?? 0) > 365) {
|
|
||||||
errors.push(`${location}.retentionDays: must be an integer between 1 and 365`);
|
|
||||||
}
|
|
||||||
if (source?.retentionDays && (event.retentionDays ?? 0) > source.retentionDays) {
|
|
||||||
errors.push(`${location}.retentionDays: must not exceed source retentionDays`);
|
|
||||||
}
|
|
||||||
if (!event.severity || !["info", "notice", "warning", "critical"].includes(event.severity)) {
|
|
||||||
errors.push(`${location}.severity: must be info, notice, warning, or critical`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return errors;
|
|
||||||
}
|
|
||||||
|
|
||||||
type GameClientBridgeSchemaReference = {
|
type GameClientBridgeSchemaReference = {
|
||||||
location: string;
|
location: string;
|
||||||
ref: string;
|
ref: string;
|
||||||
@@ -1127,54 +927,6 @@ function validateGameClientBridgeSchemaFiles(manifest: unknown, manifestDir: str
|
|||||||
return errors;
|
return errors;
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateRuntimeLogEventSchemaFiles(manifest: unknown, manifestDir: string): string[] {
|
|
||||||
if (typeof manifest !== "object" || manifest === null) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
const logEvents = (manifest as { runtimeProfiles?: { logEvents?: Array<{ schemaRef?: string }> } }).runtimeProfiles?.logEvents ?? [];
|
|
||||||
const errors: string[] = [];
|
|
||||||
for (const [index, event] of logEvents.entries()) {
|
|
||||||
const location = `manifest.runtimeProfiles.logEvents[${index}].schemaRef`;
|
|
||||||
const ref = event.schemaRef;
|
|
||||||
if (!ref || !isSafeRelativeJsonRef(ref)) {
|
|
||||||
errors.push(`${location}: raw host paths and unsafe schema references are not allowed`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const schemaPath = path.resolve(manifestDir, ref);
|
|
||||||
if (!fs.existsSync(schemaPath) || !fs.statSync(schemaPath).isFile()) {
|
|
||||||
errors.push(`${location}: missing semantic log event schema file ${ref}`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const relativeRealPath = path.relative(fs.realpathSync(manifestDir), fs.realpathSync(schemaPath));
|
|
||||||
if (relativeRealPath === ".." || relativeRealPath.startsWith(`..${path.sep}`) || path.isAbsolute(relativeRealPath)) {
|
|
||||||
errors.push(`${location}: semantic log event schema must remain inside the plugin manifest directory`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let schema: unknown;
|
|
||||||
try {
|
|
||||||
schema = readJson(schemaPath);
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : "invalid JSON";
|
|
||||||
errors.push(`${location}: semantic log event schema is not valid JSON: ${message}`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const schemaAjv = new Ajv2020({ allErrors: true, strict: false, validateFormats: false });
|
|
||||||
if (!schemaAjv.validateSchema(schema as AnySchema)) {
|
|
||||||
errors.push(...formatErrors(`${location}.schema`, schemaAjv.errors));
|
|
||||||
} else {
|
|
||||||
schemaAjv.compile(schema as AnySchema);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : "invalid JSON Schema";
|
|
||||||
errors.push(`${location}: semantic log event schema is invalid: ${message}`);
|
|
||||||
}
|
|
||||||
errors.push(...scanUnsafeBridgeSchema(schema, `${location}.schema`));
|
|
||||||
errors.push(...validateBoundedSemanticLogSchema(schema, `${location}.schema`));
|
|
||||||
}
|
|
||||||
return errors;
|
|
||||||
}
|
|
||||||
|
|
||||||
type CompanionConfigDeclaration = {
|
type CompanionConfigDeclaration = {
|
||||||
profileKey?: string;
|
profileKey?: string;
|
||||||
configSchemaRef?: string;
|
configSchemaRef?: string;
|
||||||
@@ -1352,8 +1104,6 @@ export function validateManifestFile(manifestPath: string): string[] {
|
|||||||
errors.push(...validateGameClientBridgeCatalog(manifest));
|
errors.push(...validateGameClientBridgeCatalog(manifest));
|
||||||
errors.push(...validateGameClientBridgeSchemaFiles(manifest, manifestDir));
|
errors.push(...validateGameClientBridgeSchemaFiles(manifest, manifestDir));
|
||||||
errors.push(...validateGameClientBridgeCompanionConfig(manifest, manifestDir));
|
errors.push(...validateGameClientBridgeCompanionConfig(manifest, manifestDir));
|
||||||
errors.push(...validateRuntimeLogEventCatalog(manifest));
|
|
||||||
errors.push(...validateRuntimeLogEventSchemaFiles(manifest, manifestDir));
|
|
||||||
const assetValidation = validateManifestAssetFiles(manifest, manifestDir);
|
const assetValidation = validateManifestAssetFiles(manifest, manifestDir);
|
||||||
errors.push(...assetValidation.errors);
|
errors.push(...assetValidation.errors);
|
||||||
errors.push(...validateGameClientBridgeSQLAssets(manifest, manifestDir, assetValidation.declared));
|
errors.push(...validateGameClientBridgeSQLAssets(manifest, manifestDir, assetValidation.declared));
|
||||||
|
|||||||
+13
-44
@@ -266,38 +266,9 @@ export interface GameClientBridgeQueryProjectionDeclaration {
|
|||||||
mergeExisting?: boolean;
|
mergeExisting?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GameClientBridgeLogProjectionStepDeclaration {
|
|
||||||
pattern: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GameClientBridgeLogProjectionTargetDeclaration {
|
|
||||||
collection: string;
|
|
||||||
upsertKeys: string[];
|
|
||||||
captureMappings: Record<string, string>;
|
|
||||||
fixedValues?: Record<string, string>;
|
|
||||||
observedAtField?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GameClientBridgeLogProjectionPresenceDeclaration {
|
|
||||||
timestampField: string;
|
|
||||||
activeWindowSeconds: number;
|
|
||||||
activityTarget?: GameClientBridgeLogProjectionTargetDeclaration;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GameClientBridgeLogProjectionDeclaration {
|
|
||||||
key: string;
|
|
||||||
streamKeys: string[];
|
|
||||||
steps: GameClientBridgeLogProjectionStepDeclaration[];
|
|
||||||
correlationFields: string[];
|
|
||||||
maxInterveningLines: number;
|
|
||||||
target: GameClientBridgeLogProjectionTargetDeclaration;
|
|
||||||
presence?: GameClientBridgeLogProjectionPresenceDeclaration;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GameClientBridgeDataPackDeclaration {
|
export interface GameClientBridgeDataPackDeclaration {
|
||||||
key: string;
|
key: string;
|
||||||
databaseUserVersion: number;
|
databaseUserVersion: number;
|
||||||
logParserRefs: string[];
|
|
||||||
configMapRefs: string[];
|
configMapRefs: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -331,7 +302,6 @@ export interface GameClientBridgeManifest {
|
|||||||
commands: GameClientBridgeCommandDeclaration[];
|
commands: GameClientBridgeCommandDeclaration[];
|
||||||
snapshots: GameClientBridgeSnapshotDeclaration[];
|
snapshots: GameClientBridgeSnapshotDeclaration[];
|
||||||
queryTemplates?: GameClientBridgeQueryTemplateDeclaration[];
|
queryTemplates?: GameClientBridgeQueryTemplateDeclaration[];
|
||||||
logProjections?: GameClientBridgeLogProjectionDeclaration[];
|
|
||||||
dataPacks?: GameClientBridgeDataPackDeclaration[];
|
dataPacks?: GameClientBridgeDataPackDeclaration[];
|
||||||
commandRetentionSeconds: number;
|
commandRetentionSeconds: number;
|
||||||
maxCommands: number;
|
maxCommands: number;
|
||||||
@@ -491,19 +461,6 @@ export interface RuntimeLogSource {
|
|||||||
retentionDays?: number;
|
retentionDays?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type RuntimeLogEventSeverity = "info" | "notice" | "warning" | "critical";
|
|
||||||
|
|
||||||
export interface RuntimeLogEventDeclaration {
|
|
||||||
key: string;
|
|
||||||
title: string;
|
|
||||||
sourceKey: string;
|
|
||||||
eventType: string;
|
|
||||||
permission: PluginPermission;
|
|
||||||
schemaRef: string;
|
|
||||||
retentionDays: number;
|
|
||||||
severity: RuntimeLogEventSeverity;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RuntimeTransportProfile {
|
export interface RuntimeTransportProfile {
|
||||||
key: string;
|
key: string;
|
||||||
kind: "file" | "ftp" | "rsync" | "mysql" | "sqlite" | "rcon";
|
kind: "file" | "ftp" | "rsync" | "mysql" | "sqlite" | "rcon";
|
||||||
@@ -511,6 +468,18 @@ export interface RuntimeTransportProfile {
|
|||||||
capabilities: RunCapability[];
|
capabilities: RunCapability[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RuntimeDataTarget {
|
||||||
|
key: string;
|
||||||
|
kind: "sqlite.snapshot";
|
||||||
|
transportKey: string;
|
||||||
|
sourceRootKey: string;
|
||||||
|
sourcePath: string;
|
||||||
|
workspaceKey: string;
|
||||||
|
refreshPolicy: "on-demand-snapshot";
|
||||||
|
maxBytes: number;
|
||||||
|
platforms?: RuntimePlatform[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface RuntimeClientManagerProfile {
|
export interface RuntimeClientManagerProfile {
|
||||||
key: string;
|
key: string;
|
||||||
displayName?: string;
|
displayName?: string;
|
||||||
@@ -588,8 +557,8 @@ export interface GamePluginRuntimeProfiles {
|
|||||||
dependencyProbes?: RuntimeDependencyProbe[];
|
dependencyProbes?: RuntimeDependencyProbe[];
|
||||||
installPlans?: RuntimeInstallPlan[];
|
installPlans?: RuntimeInstallPlan[];
|
||||||
logSources?: RuntimeLogSource[];
|
logSources?: RuntimeLogSource[];
|
||||||
logEvents?: RuntimeLogEventDeclaration[];
|
|
||||||
transportProfiles?: RuntimeTransportProfile[];
|
transportProfiles?: RuntimeTransportProfile[];
|
||||||
|
dataTargets?: RuntimeDataTarget[];
|
||||||
clientManagers?: RuntimeClientManagerProfile[];
|
clientManagers?: RuntimeClientManagerProfile[];
|
||||||
dllExtensions?: RuntimeDLLExtensionProfile[];
|
dllExtensions?: RuntimeDLLExtensionProfile[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1084,6 +1084,8 @@ const localRunCapabilities = [
|
|||||||
"process.install",
|
"process.install",
|
||||||
"process.start",
|
"process.start",
|
||||||
"process.stop",
|
"process.stop",
|
||||||
|
"process.restart",
|
||||||
|
"process.status",
|
||||||
"logs.read",
|
"logs.read",
|
||||||
"run.self-update",
|
"run.self-update",
|
||||||
"dependencies.check",
|
"dependencies.check",
|
||||||
@@ -1104,6 +1106,7 @@ const localRuntimeProfiles = {
|
|||||||
}],
|
}],
|
||||||
logSources: source.runtimeProfiles?.logSources ?? [],
|
logSources: source.runtimeProfiles?.logSources ?? [],
|
||||||
transportProfiles: source.runtimeProfiles?.transportProfiles ?? [],
|
transportProfiles: source.runtimeProfiles?.transportProfiles ?? [],
|
||||||
|
dataTargets: source.runtimeProfiles?.dataTargets ?? [],
|
||||||
clientManagers: []
|
clientManagers: []
|
||||||
};
|
};
|
||||||
const localGameClientBridge = JSON.parse(JSON.stringify(source.gameClientBridge ?? {}));
|
const localGameClientBridge = JSON.parse(JSON.stringify(source.gameClientBridge ?? {}));
|
||||||
|
|||||||
Reference in New Issue
Block a user