Align runtime profiles with plugin-owned records

This commit is contained in:
npc0-hue
2026-09-02 10:22:14 +08:00
parent 6018d8f0fc
commit a027ca70eb
36 changed files with 234 additions and 1590 deletions
+2 -12
View File
@@ -5,28 +5,18 @@ 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)
} }
+19 -22
View File
@@ -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)
}
}
+2 -16
View File
@@ -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")
-45
View File
@@ -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)
}
}
}
+25
View File
@@ -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
}
+15 -5
View File
@@ -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 {
continue
} }
if err := scanner.Err(); err != nil { if errors.Is(readErr, io.EOF) {
return domain.LogBatchRecord{}, fmt.Errorf("read log segment %s: %w", filepath.Base(path), err) 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 {
+5 -10
View File
@@ -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")
}
} }
-245
View File
@@ -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)
}
}
+26
View File
@@ -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)
+1 -1
View File
@@ -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 {
+6 -11
View File
@@ -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)
}
})
}
}
-7
View File
@@ -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))
+24
View File
@@ -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)
}
}
+4 -166
View File
@@ -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)
}
}
+33 -96
View File
@@ -12,8 +12,6 @@ import (
) )
var ( var (
runtimeLogEventSchemaRefPattern = regexp.MustCompile(`^[A-Za-z0-9_./-]+\.json$`)
runtimeLogEventTypePattern = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,119}$`)
runtimeDLLModKeyPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{0,79}$`) runtimeDLLModKeyPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{0,79}$`)
runtimeDLLABIPattern = regexp.MustCompile(`^[A-Za-z0-9._-]{1,80}$`) runtimeDLLABIPattern = regexp.MustCompile(`^[A-Za-z0-9._-]{1,80}$`)
) )
@@ -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} {
+1 -12
View File
@@ -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 +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" }
}
}
+3 -253
View File
@@ -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
View File
@@ -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[];
} }
+3
View File
@@ -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 ?? {}));