From a027ca70eb12787b292f52fc86b5f6eea937d62e Mon Sep 17 00:00:00 2001 From: npc0-hue Date: Wed, 2 Sep 2026 10:22:14 +0800 Subject: [PATCH] Align runtime profiles with plugin-owned records --- platform/domain/game_client_bridge_test.go | 16 +- platform/domain/resources.go | 41 ++- platform/domain/runtime_log_events_test.go | 19 -- platform/dto/resources_test.go | 18 +- platform/dto/runtime_log_events_test.go | 45 --- platform/service/lifecycle_defaults.go | 25 ++ platform/service/log_body_store.go | 22 +- platform/service/log_ingest_privacy_test.go | 15 +- platform/service/plugin_log_projection.go | 245 ----------------- .../service/plugin_log_projection_test.go | 237 ---------------- platform/service/resources_test.go | 26 ++ platform/service/runtime_bindings_test.go | 2 +- platform/service/server_files.go | 17 +- .../game_client_bridge_log_projection_test.go | 61 ----- platform/validator/log_ingest.go | 7 - platform/validator/log_ingest_test.go | 24 ++ platform/validator/resources.go | 170 +----------- .../validator/runtime_data_targets_test.go | 48 ++++ platform/validator/runtime_log_events_test.go | 122 --------- platform/validator/runtime_profiles.go | 133 +++------ platform_web/api/types.ts | 13 +- .../companion/events_test.go | 40 --- .../data-packs/scum-db-v57/log-parsers.json | 30 -- .../semantic-events.snapshot.schema.json | 1 - .../log-events/admin.event.schema.json | 14 - .../schemas/log-events/chat.event.schema.json | 13 - .../schemas/log-events/kill.event.schema.json | 16 -- .../log-events/login.event.schema.json | 14 - .../log-events/logout.event.schema.json | 13 - .../schemas/log-events/mine.event.schema.json | 14 - .../log-events/performance.event.schema.json | 13 - .../log-events/trade.event.schema.json | 20 -- .../log-events/unlock.event.schema.json | 14 - plugins/scripts/validate-manifest.ts | 256 +----------------- plugins/sdk/index.ts | 57 +--- scripts/local-debug/smoke.sh | 3 + 36 files changed, 234 insertions(+), 1590 deletions(-) delete mode 100644 platform/domain/runtime_log_events_test.go delete mode 100644 platform/dto/runtime_log_events_test.go delete mode 100644 platform/service/plugin_log_projection.go delete mode 100644 platform/service/plugin_log_projection_test.go delete mode 100644 platform/validator/game_client_bridge_log_projection_test.go create mode 100644 platform/validator/log_ingest_test.go create mode 100644 platform/validator/runtime_data_targets_test.go delete mode 100644 platform/validator/runtime_log_events_test.go delete mode 100644 plugins/examples/scum-server-plugin/companion/events_test.go delete mode 100644 plugins/examples/scum-server-plugin/data-packs/scum-db-v57/log-parsers.json delete mode 100644 plugins/examples/scum-server-plugin/schemas/bridge/semantic-events.snapshot.schema.json delete mode 100644 plugins/examples/scum-server-plugin/schemas/log-events/admin.event.schema.json delete mode 100644 plugins/examples/scum-server-plugin/schemas/log-events/chat.event.schema.json delete mode 100644 plugins/examples/scum-server-plugin/schemas/log-events/kill.event.schema.json delete mode 100644 plugins/examples/scum-server-plugin/schemas/log-events/login.event.schema.json delete mode 100644 plugins/examples/scum-server-plugin/schemas/log-events/logout.event.schema.json delete mode 100644 plugins/examples/scum-server-plugin/schemas/log-events/mine.event.schema.json delete mode 100644 plugins/examples/scum-server-plugin/schemas/log-events/performance.event.schema.json delete mode 100644 plugins/examples/scum-server-plugin/schemas/log-events/trade.event.schema.json delete mode 100644 plugins/examples/scum-server-plugin/schemas/log-events/unlock.event.schema.json diff --git a/platform/domain/game_client_bridge_test.go b/platform/domain/game_client_bridge_test.go index 67b64dc..0655b2a 100644 --- a/platform/domain/game_client_bridge_test.go +++ b/platform/domain/game_client_bridge_test.go @@ -4,29 +4,19 @@ import "testing" func TestCopyGameClientBridgeDeclarationsCopiesQueryTemplateSlices(t *testing.T) { 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}}}}, - LogProjections: []GameClientBridgeLogProjectionDeclaration{{ - Key: "player.login", StreamKeys: []string{"process.stdout"}, Steps: []GameClientBridgeLogProjectionStepDeclaration{{Pattern: `Player (?\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"}}}, - }}, + 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}}}}, 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"}}}, } manifestCopy := CopyGameClientBridgeManifest(manifest) manifestCopy.QueryTemplates[0].Key = "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].Target.ActivityTarget.RowMappings["steamId"] = "mutated" - manifestCopy.DataPacks[0].LogParserRefs[0] = "mutated" manifestCopy.DataPacks[0].DataRefs[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) } diff --git a/platform/domain/resources.go b/platform/domain/resources.go index 1a2b8d3..04142f9 100644 --- a/platform/domain/resources.go +++ b/platform/domain/resources.go @@ -442,26 +442,6 @@ type RuntimeLogSource struct { 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 { Key string Kind string @@ -469,6 +449,20 @@ type RuntimeTransportProfile struct { 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 { Key string DisplayName string @@ -597,8 +591,8 @@ type GamePluginRuntimeProfiles struct { InstallPlans []RuntimeInstallPlan ServerDeployments []RuntimeServerDeploymentProfile LogSources []RuntimeLogSource - LogEvents []RuntimeLogEvent TransportProfiles []RuntimeTransportProfile + DataTargets []RuntimeDataTarget ClientManagers []RuntimeClientManagerProfile DLLExtensions []RuntimeDLLExtensionProfile } @@ -1926,11 +1920,14 @@ func CopyGamePluginRuntimeProfiles(profiles GamePluginRuntimeProfiles) GamePlugi profiles.ServerDeployments[i] = CopyRuntimeServerDeploymentProfile(profiles.ServerDeployments[i]) } profiles.LogSources = append([]RuntimeLogSource(nil), profiles.LogSources...) - profiles.LogEvents = append([]RuntimeLogEvent(nil), profiles.LogEvents...) profiles.TransportProfiles = append([]RuntimeTransportProfile(nil), profiles.TransportProfiles...) for i := range profiles.TransportProfiles { 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...) for i := range profiles.ClientManagers { profiles.ClientManagers[i].SupportedTargets = append([]RuntimeTarget(nil), profiles.ClientManagers[i].SupportedTargets...) diff --git a/platform/domain/runtime_log_events_test.go b/platform/domain/runtime_log_events_test.go deleted file mode 100644 index e018001..0000000 --- a/platform/domain/runtime_log_events_test.go +++ /dev/null @@ -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) - } -} diff --git a/platform/dto/resources_test.go b/platform/dto/resources_test.go index f512fdd..403f306 100644 --- a/platform/dto/resources_test.go +++ b/platform/dto/resources_test.go @@ -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, 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 (?\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"}}}}, - 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, MaxCommands: 1000, Pages: []GameClientBridgePageContractBody{{PageKey: "players", QueryTemplateKeys: []string{"player.lookup"}}}, } 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) } 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") } 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" if body.LifecycleProjections[0].Target.ActivityTarget.RowMappings["steamId"] != "steamId" { 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") } 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" if domainManifest.LifecycleProjections[0].Target.FixedValues["online"] != "false" { t.Fatal("lifecycle projection target aliases domain data") diff --git a/platform/dto/runtime_log_events_test.go b/platform/dto/runtime_log_events_test.go deleted file mode 100644 index 9ffe49e..0000000 --- a/platform/dto/runtime_log_events_test.go +++ /dev/null @@ -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) - } - } -} diff --git a/platform/service/lifecycle_defaults.go b/platform/service/lifecycle_defaults.go index d939f6f..755e98c 100644 --- a/platform/service/lifecycle_defaults.go +++ b/platform/service/lifecycle_defaults.go @@ -24,3 +24,28 @@ func applyPluginCreateDefaults(plugin domain.GamePlugin, definition domain.Serve func deploymentNeedsCompleteRuntimeBinding(_ domain.GamePlugin, definition domain.ServerDeploymentDefinition) bool { 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 +} diff --git a/platform/service/log_body_store.go b/platform/service/log_body_store.go index a183ee9..3a7d66b 100644 --- a/platform/service/log_body_store.go +++ b/platform/service/log_body_store.go @@ -3,7 +3,9 @@ package service import ( "bufio" "encoding/json" + "errors" "fmt" + "io" "net/url" "os" "path/filepath" @@ -231,16 +233,24 @@ func readLogSegment(path string) (domain.LogBatchRecord, error) { defer file.Close() entries := []domain.LogEntry{} - scanner := bufio.NewScanner(file) - for scanner.Scan() { + reader := bufio.NewReader(file) + for { + line, readErr := reader.ReadBytes('\n') + if len(line) == 0 && errors.Is(readErr, io.EOF) { + break + } 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) } entries = append(entries, domain.CopyLogEntry(entry)) - } - if err := scanner.Err(); err != nil { - return domain.LogBatchRecord{}, fmt.Errorf("read log segment %s: %w", filepath.Base(path), err) + if readErr == nil { + continue + } + if errors.Is(readErr, io.EOF) { + break + } + return domain.LogBatchRecord{}, fmt.Errorf("read log segment %s: %w", filepath.Base(path), readErr) } sort.SliceStable(entries, func(i, j int) bool { return entries[i].Seq < entries[j].Seq }) if len(entries) == 0 { diff --git a/platform/service/log_ingest_privacy_test.go b/platform/service/log_ingest_privacy_test.go index 125d6ca..34c1c18 100644 --- a/platform/service/log_ingest_privacy_test.go +++ b/platform/service/log_ingest_privacy_test.go @@ -6,7 +6,7 @@ import ( "browser.local/platform/domain" ) -func TestSanitizeLogNetworkFieldsIsGameAgnostic(t *testing.T) { +func TestLogIngestPreservesOpaqueFields(t *testing.T) { batch := domain.LogBatchIngest{Entries: []domain.LogEntry{ {Fields: map[string]string{ "eventType": "game.session.opened", @@ -17,15 +17,10 @@ func TestSanitizeLogNetworkFieldsIsGameAgnostic(t *testing.T) { }}, }} - sanitizeLogNetworkFields(&batch) - - fields := batch.Entries[0].Fields - for _, key := range []string{"networkFingerprint", "ip", "ipAddress"} { - if _, exists := fields[key]; exists { - t.Fatalf("expected %s to be removed", key) + 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"} { + if fields[key] != expected { + t.Fatalf("expected opaque field %s to be preserved, got %q", key, fields[key]) } } - if fields["playerId"] != "player-1" { - t.Fatal("expected unrelated fields to be preserved") - } } diff --git a/platform/service/plugin_log_projection.go b/platform/service/plugin_log_projection.go deleted file mode 100644 index a7a11d3..0000000 --- a/platform/service/plugin_log_projection.go +++ /dev/null @@ -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 == "" { - return time.Time{}, false - } - parsed, err := time.Parse(time.RFC3339Nano, text) - return parsed, err == nil -} diff --git a/platform/service/plugin_log_projection_test.go b/platform/service/plugin_log_projection_test.go deleted file mode 100644 index ce23a49..0000000 --- a/platform/service/plugin_log_projection_test.go +++ /dev/null @@ -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[^"]+)" reported as player (?P[0-9]+)`}, - {Pattern: `Player (?P[0-9]+) SteamID \(assumed\): (?P[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[A-Za-z0-9_.-]{1,128}) \(x(?P\d{1,9})\)\) (?Ppurchased|sold) by .*?\((?P\d{1,50})\) for (?P-?\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) - } -} diff --git a/platform/service/resources_test.go b/platform/service/resources_test.go index 8e1a9f7..e190843 100644 --- a/platform/service/resources_test.go +++ b/platform/service/resources_test.go @@ -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) { svc := newTestCoreService() plugin, endpoint := createPluginAndRunEndpoint(t, svc) diff --git a/platform/service/runtime_bindings_test.go b/platform/service/runtime_bindings_test.go index 61a67e0..020f281 100644 --- a/platform/service/runtime_bindings_test.go +++ b/platform/service/runtime_bindings_test.go @@ -94,7 +94,7 @@ func TestRuntimeBindingValidationAndLifecycleGating(t *testing.T) { 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"}) - 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) } if _, err := svc.UpdateServerRuntimeBindingForSession(otherSession, instance.ID, domain.RuntimeBindingUpdate{ProfileKey: "local"}); err != ErrForbidden { diff --git a/platform/service/server_files.go b/platform/service/server_files.go index 3ce53ef..113e1b3 100644 --- a/platform/service/server_files.go +++ b/platform/service/server_files.go @@ -54,7 +54,7 @@ type runFileListEntry struct { } 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 { return domain.ServerFileWorkspaceView{}, err } @@ -88,7 +88,7 @@ func (svc *CoreService) ListServerFilesForSession(sessionID string, request doma if err := validator.ValidateServerFileListRequest(request); err != nil { 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 { return domain.ServerFileListResult{}, err } @@ -148,7 +148,7 @@ func (svc *CoreService) RefreshServerFileListForSession(sessionID string, reques if err := validator.ValidateServerFileListRequest(request); err != nil { 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 { return domain.ServerFileListResult{}, err } @@ -288,7 +288,7 @@ func (svc *CoreService) UploadServerFileForSession(sessionID string, request dom if err := validator.ValidateServerFileUploadRequest(request); err != nil { 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 { return domain.ServerFileUploadDispatch{}, err } @@ -326,7 +326,7 @@ func (svc *CoreService) PrepareServerFileDownloadForSession(sessionID string, re if err := validator.ValidateServerFileDownloadRequest(request); err != nil { return domain.ServerFileDownloadResult{}, err } - ctx, err := svc.serverFileContextForSession(sessionID, request.ServerInstanceID, "", false, true) + ctx, err := svc.serverFileContextForSession(sessionID, request.ServerInstanceID, "", false) if err != nil { return domain.ServerFileDownloadResult{}, err } @@ -412,7 +412,7 @@ func (svc *CoreService) ReadRunFileInputChunk(request domain.RunFileInputChunkRe 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) if err != nil { return serverFileContext{}, err @@ -421,11 +421,6 @@ func (svc *CoreService) serverFileContextForSession(sessionID string, serverInst if err != nil { 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) if err != nil { return serverFileContext{}, err diff --git a/platform/validator/game_client_bridge_log_projection_test.go b/platform/validator/game_client_bridge_log_projection_test.go deleted file mode 100644 index 0da47d2..0000000 --- a/platform/validator/game_client_bridge_log_projection_test.go +++ /dev/null @@ -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 "(?[^"]+)" reported as player (?\d+)`}, - {Pattern: `Player (?\d+) SteamID \(assumed\): (?\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) - } - }) - } -} diff --git a/platform/validator/log_ingest.go b/platform/validator/log_ingest.go index 2da4837..cc9f468 100644 --- a/platform/validator/log_ingest.go +++ b/platform/validator/log_ingest.go @@ -12,7 +12,6 @@ import ( const ( MaxLogBatchEntries = 512 - MaxLogLineLength = 8192 MaxLogQueryLimit = 10000 ) @@ -63,12 +62,6 @@ func ValidateLogBatchIngest(batch domain.LogBatchIngest) error { if entry.Seq != batch.FirstSeq+uint64(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 { if strings.TrimSpace(key) == "" { violations = append(violations, fmt.Sprintf("entries[%d].fields key is required", i)) diff --git a/platform/validator/log_ingest_test.go b/platform/validator/log_ingest_test.go new file mode 100644 index 0000000..59d46ca --- /dev/null +++ b/platform/validator/log_ingest_test.go @@ -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) + } +} diff --git a/platform/validator/resources.go b/platform/validator/resources.go index 5899d6e..0c7d806 100644 --- a/platform/validator/resources.go +++ b/platform/validator/resources.go @@ -166,7 +166,6 @@ func ValidateGamePlugin(plugin domain.GamePlugin) error { violations = append(violations, err.Error()) } violations = append(violations, validateRuntimeProfileCapabilityDeclarations(plugin.RuntimeProfiles, plugin.RequiredRunCapabilities)...) - violations = append(violations, validateRuntimeLogEventPermissionDeclarations("runtimeProfiles.logEvents", plugin.RuntimeProfiles, plugin.DeclaredPermissions)...) violations = append(violations, validateGameClientBridgeManifest("gameClientBridge", plugin.GameClientBridge, plugin.DeclaredPermissions, plugin.RequiredRunCapabilities, plugin.Pages, plugin.RuntimeProfiles)...) violations = append(violations, validatePluginCreateFields("createFields", plugin.CreateFields)...) violations = append(violations, validatePluginAssetFiles("lifecycleAssets", plugin.LifecycleAssets)...) @@ -237,7 +236,6 @@ func ValidateGamePluginManifestRegistration(registration domain.GamePluginManife violations = append(violations, err.Error()) } 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, validatePluginAssetFileDeclarations("manifest.assetFiles", manifest.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 { 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 } 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") } } - 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{}{} for index, projection := range bridge.LifecycleProjections { 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") } dataPackKeys[dataPack.Key] = struct{}{} - if dataPack.DatabaseUserVersion < 1 || len(dataPack.LogParserRefs) == 0 || len(dataPack.ConfigMapRefs) == 0 { - violations = append(violations, prefix+" must declare a database version and parser/config assets") + if dataPack.DatabaseUserVersion < 1 || len(dataPack.ConfigMapRefs) == 0 { + 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...) for _, ref := range refs { if !safeRelativeJSONRef(ref) { @@ -939,154 +925,6 @@ func validateGameClientBridgeBulkActivityTarget(prefix string, target domain.Gam 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 { if len(value) < 3 || len(value) > 64 || value[0] < 'A' || value[0] > 'Z' { return false diff --git a/platform/validator/runtime_data_targets_test.go b/platform/validator/runtime_data_targets_test.go new file mode 100644 index 0000000..955cc8c --- /dev/null +++ b/platform/validator/runtime_data_targets_test.go @@ -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"}, + }}, + } +} diff --git a/platform/validator/runtime_log_events_test.go b/platform/validator/runtime_log_events_test.go deleted file mode 100644 index 77781ba..0000000 --- a/platform/validator/runtime_log_events_test.go +++ /dev/null @@ -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) - } -} diff --git a/platform/validator/runtime_profiles.go b/platform/validator/runtime_profiles.go index 5fb7657..807306e 100644 --- a/platform/validator/runtime_profiles.go +++ b/platform/validator/runtime_profiles.go @@ -12,10 +12,8 @@ import ( ) 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}$`) - runtimeDLLABIPattern = regexp.MustCompile(`^[A-Za-z0-9._-]{1,80}$`) + runtimeDLLModKeyPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{0,79}$`) + runtimeDLLABIPattern = regexp.MustCompile(`^[A-Za-z0-9._-]{1,80}$`) ) func ValidateGamePluginRuntimeProfiles(profiles domain.GamePluginRuntimeProfiles) error { @@ -23,6 +21,9 @@ func ValidateGamePluginRuntimeProfiles(profiles domain.GamePluginRuntimeProfiles var violations []string lifecycleKeys := map[string]struct{}{} transportKeys := map[string]struct{}{} + transportProfiles := map[string]domain.RuntimeTransportProfile{} + dataTargetKeys := map[string]struct{}{} + dataTargetWorkspaces := map[string]struct{}{} managerKeys := map[string]struct{}{} dllExtensionKeys := map[string]struct{}{} dllExtensionStates := map[string]string{} @@ -31,9 +32,6 @@ func ValidateGamePluginRuntimeProfiles(profiles domain.GamePluginRuntimeProfiles installPlanKeys := map[string]struct{}{} serverDeploymentKeys := 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 { prefix := fmt.Sprintf("runtimeProfiles.discovery[%d]", i) @@ -237,9 +235,6 @@ func ValidateGamePluginRuntimeProfiles(profiles domain.GamePluginRuntimeProfiles prefix := fmt.Sprintf("runtimeProfiles.logSources[%d]", i) violations = append(violations, validateProfileKey(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") { violations = append(violations, prefix+".kind is invalid") } @@ -254,44 +249,6 @@ func ValidateGamePluginRuntimeProfiles(profiles domain.GamePluginRuntimeProfiles 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 { prefix := fmt.Sprintf("runtimeProfiles.transportProfiles[%d]", i) 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)...) + 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 { prefix := fmt.Sprintf("runtimeProfiles.clientManagers[%d]", i) @@ -609,42 +596,6 @@ func validateSafeRuntimeValue(field, value string) []string { 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 { if key == "" { return nil @@ -681,20 +632,6 @@ func validateRuntimeProfileCapabilityDeclarations(profiles domain.GamePluginRunt 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 { 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} { diff --git a/platform_web/api/types.ts b/platform_web/api/types.ts index 2b6c661..ead3521 100644 --- a/platform_web/api/types.ts +++ b/platform_web/api/types.ts @@ -67,7 +67,7 @@ export interface GameClientBridgeQueryProjectionDeclarationResponse { 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 { 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 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 { key: string; kind: string; @@ -369,7 +359,6 @@ export interface GamePluginRuntimeProfilesResponse { installPlans?: RuntimeInstallPlanResponse[]; serverDeployments?: RuntimeServerDeploymentProfileResponse[]; logSources?: RuntimeLogSourceResponse[]; - logEvents?: RuntimeLogEventResponse[]; transportProfiles?: RuntimeTransportProfileResponse[]; clientManagers?: RuntimeClientManagerProfileResponse[]; dllExtensions?: RuntimeDLLExtensionProfileResponse[]; diff --git a/plugins/examples/scum-server-plugin/companion/events_test.go b/plugins/examples/scum-server-plugin/companion/events_test.go deleted file mode 100644 index c0527cb..0000000 --- a/plugins/examples/scum-server-plugin/companion/events_test.go +++ /dev/null @@ -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") - } -} diff --git a/plugins/examples/scum-server-plugin/data-packs/scum-db-v57/log-parsers.json b/plugins/examples/scum-server-plugin/data-packs/scum-db-v57/log-parsers.json deleted file mode 100644 index 28f494f..0000000 --- a/plugins/examples/scum-server-plugin/data-packs/scum-db-v57/log-parsers.json +++ /dev/null @@ -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" } - ] -} diff --git a/plugins/examples/scum-server-plugin/schemas/bridge/semantic-events.snapshot.schema.json b/plugins/examples/scum-server-plugin/schemas/bridge/semantic-events.snapshot.schema.json deleted file mode 100644 index 34fcb99..0000000 --- a/plugins/examples/scum-server-plugin/schemas/bridge/semantic-events.snapshot.schema.json +++ /dev/null @@ -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}}}}}} diff --git a/plugins/examples/scum-server-plugin/schemas/log-events/admin.event.schema.json b/plugins/examples/scum-server-plugin/schemas/log-events/admin.event.schema.json deleted file mode 100644 index 5831e34..0000000 --- a/plugins/examples/scum-server-plugin/schemas/log-events/admin.event.schema.json +++ /dev/null @@ -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" } - } -} diff --git a/plugins/examples/scum-server-plugin/schemas/log-events/chat.event.schema.json b/plugins/examples/scum-server-plugin/schemas/log-events/chat.event.schema.json deleted file mode 100644 index d87a2d1..0000000 --- a/plugins/examples/scum-server-plugin/schemas/log-events/chat.event.schema.json +++ /dev/null @@ -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 } - } -} diff --git a/plugins/examples/scum-server-plugin/schemas/log-events/kill.event.schema.json b/plugins/examples/scum-server-plugin/schemas/log-events/kill.event.schema.json deleted file mode 100644 index 7d94169..0000000 --- a/plugins/examples/scum-server-plugin/schemas/log-events/kill.event.schema.json +++ /dev/null @@ -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" } - } -} diff --git a/plugins/examples/scum-server-plugin/schemas/log-events/login.event.schema.json b/plugins/examples/scum-server-plugin/schemas/log-events/login.event.schema.json deleted file mode 100644 index 7f8de62..0000000 --- a/plugins/examples/scum-server-plugin/schemas/log-events/login.event.schema.json +++ /dev/null @@ -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." } - } -} diff --git a/plugins/examples/scum-server-plugin/schemas/log-events/logout.event.schema.json b/plugins/examples/scum-server-plugin/schemas/log-events/logout.event.schema.json deleted file mode 100644 index 3bdbaaf..0000000 --- a/plugins/examples/scum-server-plugin/schemas/log-events/logout.event.schema.json +++ /dev/null @@ -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"] } - } -} diff --git a/plugins/examples/scum-server-plugin/schemas/log-events/mine.event.schema.json b/plugins/examples/scum-server-plugin/schemas/log-events/mine.event.schema.json deleted file mode 100644 index 1a0e780..0000000 --- a/plugins/examples/scum-server-plugin/schemas/log-events/mine.event.schema.json +++ /dev/null @@ -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" } - } -} diff --git a/plugins/examples/scum-server-plugin/schemas/log-events/performance.event.schema.json b/plugins/examples/scum-server-plugin/schemas/log-events/performance.event.schema.json deleted file mode 100644 index 009093c..0000000 --- a/plugins/examples/scum-server-plugin/schemas/log-events/performance.event.schema.json +++ /dev/null @@ -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 } - } -} diff --git a/plugins/examples/scum-server-plugin/schemas/log-events/trade.event.schema.json b/plugins/examples/scum-server-plugin/schemas/log-events/trade.event.schema.json deleted file mode 100644 index 1674a36..0000000 --- a/plugins/examples/scum-server-plugin/schemas/log-events/trade.event.schema.json +++ /dev/null @@ -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" } - } -} diff --git a/plugins/examples/scum-server-plugin/schemas/log-events/unlock.event.schema.json b/plugins/examples/scum-server-plugin/schemas/log-events/unlock.event.schema.json deleted file mode 100644 index 70c1d6f..0000000 --- a/plugins/examples/scum-server-plugin/schemas/log-events/unlock.event.schema.json +++ /dev/null @@ -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" } - } -} diff --git a/plugins/scripts/validate-manifest.ts b/plugins/scripts/validate-manifest.ts index 7b56b11..5388847 100644 --- a/plugins/scripts/validate-manifest.ts +++ b/plugins/scripts/validate-manifest.ts @@ -17,19 +17,6 @@ function formatErrors(prefix: string, errors: ErrorObject[] | null | undefined): 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 { const compact = fieldName.toLowerCase().replace(/[^a-z0-9]/g, ""); if (compact.includes("rawapikey") || compact.includes("apikey") || compact.includes("providerkey")) { @@ -283,57 +270,6 @@ function validateBoundedBridgeSchema(value: unknown, location: string): string[] 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; - 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[] { const action = readJson(path.resolve(rootDir, actionPath)); const ajv = new Ajv2020({ allErrors: true }); @@ -689,20 +625,6 @@ export function validateGameClientBridgeCatalog(manifest: unknown): string[] { timeoutSeconds?: number; pollIntervalSeconds?: number; }; - type BridgeLogProjectionTarget = { collection?: string; upsertKeys?: string[]; captureMappings?: Record; fixedValues?: Record; 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 BridgeCompanion = { profileKey?: string; @@ -726,7 +648,7 @@ export function validateGameClientBridgeCatalog(manifest: unknown): string[] { remoteAccess?: { runCapabilities?: string[]; databaseEngines?: string[] }; pages?: PluginPage[]; 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; if (!bridge) { @@ -736,7 +658,6 @@ export function validateGameClientBridgeCatalog(manifest: unknown): string[] { const commands = new Set(); const snapshots = new Set((bridge.snapshots ?? []).map((snapshot) => snapshot.type ?? "")); const queryTemplates = new Map(); - const logProjections = new Set(); const declaredPermissions = new Set(declaration.permissions ?? []); const declaredCapabilities = new Set(declaration.capabilities ?? []); 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`); } } - 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[] => { - 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(); - 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 commandType of page.commandTypes ?? []) { if (!commands.has(commandType)) { @@ -932,7 +796,7 @@ export function validateGameClientBridgeCatalog(manifest: unknown): string[] { function validateGameClientBridgeDataPacks(manifest: unknown, manifestDir: string, declaredAssets: Set): string[] { 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 keys = new Set(); 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`); keys.add(dataPack.key ?? ""); 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] ?? []; if (field !== "dataRefs" && refs.length === 0) errors.push(`${location}.${field}: must declare at least one package asset`); for (const ref of refs) { @@ -980,70 +844,6 @@ function validateGameClientBridgeSQLAssets(manifest: unknown, manifestDir: strin 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(); - const eventTypes = new Set(); - 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 = { location: string; ref: string; @@ -1127,54 +927,6 @@ function validateGameClientBridgeSchemaFiles(manifest: unknown, manifestDir: str 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 = { profileKey?: string; configSchemaRef?: string; @@ -1352,8 +1104,6 @@ export function validateManifestFile(manifestPath: string): string[] { errors.push(...validateGameClientBridgeCatalog(manifest)); errors.push(...validateGameClientBridgeSchemaFiles(manifest, manifestDir)); errors.push(...validateGameClientBridgeCompanionConfig(manifest, manifestDir)); - errors.push(...validateRuntimeLogEventCatalog(manifest)); - errors.push(...validateRuntimeLogEventSchemaFiles(manifest, manifestDir)); const assetValidation = validateManifestAssetFiles(manifest, manifestDir); errors.push(...assetValidation.errors); errors.push(...validateGameClientBridgeSQLAssets(manifest, manifestDir, assetValidation.declared)); diff --git a/plugins/sdk/index.ts b/plugins/sdk/index.ts index 163c39e..b6b172e 100644 --- a/plugins/sdk/index.ts +++ b/plugins/sdk/index.ts @@ -266,38 +266,9 @@ export interface GameClientBridgeQueryProjectionDeclaration { mergeExisting?: boolean; } -export interface GameClientBridgeLogProjectionStepDeclaration { - pattern: string; -} - -export interface GameClientBridgeLogProjectionTargetDeclaration { - collection: string; - upsertKeys: string[]; - captureMappings: Record; - fixedValues?: Record; - 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 { key: string; databaseUserVersion: number; - logParserRefs: string[]; configMapRefs: string[]; } @@ -331,7 +302,6 @@ export interface GameClientBridgeManifest { commands: GameClientBridgeCommandDeclaration[]; snapshots: GameClientBridgeSnapshotDeclaration[]; queryTemplates?: GameClientBridgeQueryTemplateDeclaration[]; - logProjections?: GameClientBridgeLogProjectionDeclaration[]; dataPacks?: GameClientBridgeDataPackDeclaration[]; commandRetentionSeconds: number; maxCommands: number; @@ -491,19 +461,6 @@ export interface RuntimeLogSource { 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 { key: string; kind: "file" | "ftp" | "rsync" | "mysql" | "sqlite" | "rcon"; @@ -511,6 +468,18 @@ export interface RuntimeTransportProfile { 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 { key: string; displayName?: string; @@ -588,8 +557,8 @@ export interface GamePluginRuntimeProfiles { dependencyProbes?: RuntimeDependencyProbe[]; installPlans?: RuntimeInstallPlan[]; logSources?: RuntimeLogSource[]; - logEvents?: RuntimeLogEventDeclaration[]; transportProfiles?: RuntimeTransportProfile[]; + dataTargets?: RuntimeDataTarget[]; clientManagers?: RuntimeClientManagerProfile[]; dllExtensions?: RuntimeDLLExtensionProfile[]; } diff --git a/scripts/local-debug/smoke.sh b/scripts/local-debug/smoke.sh index 240cc67..7fa349b 100755 --- a/scripts/local-debug/smoke.sh +++ b/scripts/local-debug/smoke.sh @@ -1084,6 +1084,8 @@ const localRunCapabilities = [ "process.install", "process.start", "process.stop", + "process.restart", + "process.status", "logs.read", "run.self-update", "dependencies.check", @@ -1104,6 +1106,7 @@ const localRuntimeProfiles = { }], logSources: source.runtimeProfiles?.logSources ?? [], transportProfiles: source.runtimeProfiles?.transportProfiles ?? [], + dataTargets: source.runtimeProfiles?.dataTargets ?? [], clientManagers: [] }; const localGameClientBridge = JSON.parse(JSON.stringify(source.gameClientBridge ?? {}));