From 73947edceeacf8130c190dd4d15ff07799f6b089 Mon Sep 17 00:00:00 2001 From: npc0-hue Date: Tue, 28 Jul 2026 15:23:09 +0800 Subject: [PATCH] feat(scum): add local game player intelligence --- .../.openspec.yaml | 2 + .../design.md | 57 +++ .../proposal.md | 23 ++ .../scum-game-player-intelligence/spec.md | 51 +++ .../tasks.md | 21 ++ platform/api/game_player_handlers.go | 57 +++ platform/api/resource_handlers.go | 2 + platform/domain/game_players.go | 111 ++++++ platform/dto/game_players.go | 82 +++++ platform/model/game_players.go | 72 ++++ platform/repo/file_store.go | 26 ++ platform/repo/mysql_store.go | 21 ++ platform/repo/resources.go | 138 +++++-- platform/service/game_players.go | 337 ++++++++++++++++++ platform/service/game_players_test.go | 119 +++++++ platform/service/log_ingest.go | 26 +- platform/service/resources.go | 70 ++-- platform/service/secret_envelope.go | 1 + platform_web/api/client.ts | 11 + platform_web/api/types.ts | 7 + .../GamePlayerIntelligencePanel.test.tsx | 11 + .../GamePlayerIntelligencePanel.tsx | 20 ++ platform_web/pages/PluginPageHostPage.tsx | 2 + .../examples/scum-server-plugin/manifest.json | 2 +- .../log-events/login.event.schema.json | 2 +- plugins/tests/manifest-validation.test.ts | 3 + 26 files changed, 1209 insertions(+), 65 deletions(-) create mode 100644 openspec/changes/add-scum-game-player-intelligence/.openspec.yaml create mode 100644 openspec/changes/add-scum-game-player-intelligence/design.md create mode 100644 openspec/changes/add-scum-game-player-intelligence/proposal.md create mode 100644 openspec/changes/add-scum-game-player-intelligence/specs/scum-game-player-intelligence/spec.md create mode 100644 openspec/changes/add-scum-game-player-intelligence/tasks.md create mode 100644 platform/api/game_player_handlers.go create mode 100644 platform/domain/game_players.go create mode 100644 platform/dto/game_players.go create mode 100644 platform/model/game_players.go create mode 100644 platform/service/game_players.go create mode 100644 platform/service/game_players_test.go create mode 100644 platform_web/components/GamePlayerIntelligencePanel.test.tsx create mode 100644 platform_web/components/GamePlayerIntelligencePanel.tsx diff --git a/openspec/changes/add-scum-game-player-intelligence/.openspec.yaml b/openspec/changes/add-scum-game-player-intelligence/.openspec.yaml new file mode 100644 index 0000000..e8209ff --- /dev/null +++ b/openspec/changes/add-scum-game-player-intelligence/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-28 diff --git a/openspec/changes/add-scum-game-player-intelligence/design.md b/openspec/changes/add-scum-game-player-intelligence/design.md new file mode 100644 index 0000000..87694f7 --- /dev/null +++ b/openspec/changes/add-scum-game-player-intelligence/design.md @@ -0,0 +1,57 @@ +## Context + +Platform already receives sequenced, durable runtime log batches and has a `game.scum` manifest that declares `scum.login` and `scum.logout` schemas. Those entries are queryable as logs but are not durable player-domain records. The new projection must remain local to a server instance, be safe to invoke as part of log ingestion, and not make raw network data browser-visible or persistent. + +## Goals / Non-Goals + +**Goals:** + +- Persist independent game-player records keyed by server instance and SCUM player ID, with aliases, bounded sessions, access attempts, and review-only security signals. +- Project success login/logout semantics atomically and idempotently from accepted log entries, with sequence/event keys for duplicates and timestamp ordering for stale updates. +- Derive server-isolated correlation identifiers using an HMAC secret and a normalized source value, while retaining only the derived value. +- Offer permission-scoped, server-authorized management APIs and console records that never return IP values, raw source identifiers, secrets, raw log lines, or automatic enforcement controls. +- Retain access attempts and signals for a configurable bounded period and preserve player identity/session summaries after evidence expiry. + +**Non-Goals:** + +- Platform-user accounts, credentials, game database reads/writes, skill/attribute editing, gifts, map trails, or any automatic ban/kick/punishment. +- Changes to Run channels, direct host/socket/database access, or browser access to raw semantic log bodies. + +## Decisions + +1. **Use server-local game identity.** `game_player` is unique on `(server_instance_id, game_player_id)`; it has no foreign key to `user`. This prevents platform-console authentication from being mistaken for SCUM identity. An alias table records renamed display names with first/last-seen times. + + Alternative considered: use display name as the identifier. It cannot safely handle player renames or duplicate names. + +2. **Project only accepted SCUM semantic events in the ingestion transaction.** The log sequence (`log_stream_id`, `seq`) is the projection idempotency key. Login/open-session changes only apply when their event time is not older than the player's latest projected event; a logout closes the latest matching open session no later than its event time. Duplicate batches perform no second projection. + + Alternative considered: asynchronous parsing of raw log lines. It would duplicate declared parsing rules, complicate order guarantees, and retain unnecessary sensitive content. + +3. **Store only an HMAC-derived, server-scoped network correlation key.** A server-specific HMAC domain separator plus a process secret produces the correlation key. The source is never saved in models, logs, DTOs, or signal evidence. A digest is useful only within the same server and cannot be compared across servers. + + Alternative considered: hash the raw IP directly. Unsalted hashes are reversible for common address spaces and correlatable across servers. + +4. **Treat failures and anomalies as review evidence.** Failed login/connection event payloads create bounded `game_access_attempt` records. A threshold (five failures for the same fingerprint within fifteen minutes) creates or refreshes an `excessive-failed-access` signal; multiple player IDs sharing one server-local fingerprint create a `possible-alt-account` signal. Signals include text status and evidence counts, and have no action API. + + Alternative considered: automatic moderation. It is excluded because heuristic evidence requires an operator review. + +5. **Use existing session authorization and console primitives.** New endpoints use existing server read authorization. The frontend fetches named API contracts and renders an ordinary full-width shared table/record list with explicit status labels, never raw logs or a new page-local visual system. + +## Risks / Trade-offs + +- [A plugin payload omits a usable player ID] → Ignore the event for projection and retain normal log ingestion; validate only bounded known fields. +- [Late logout arrives after another login] → Close only a compatible open session whose start is no later than logout; never regress `lastSeenAt`. +- [Projection failure follows log body append] → Return the ingest error and retry the acknowledged range safely; event identity prevents duplicate records. +- [HMAC secret rotates] → Derived keys intentionally cease correlating across rotations; raw data is never recoverable. Deployment config keeps the secret stable during its intended retention window. +- [Evidence tables grow] → Perform retention pruning during projection/query and cap query limits. + +## Migration Plan + +1. Add model-first tables and repositories; existing installations begin with no projected players. +2. Deploy manifest schemas and Platform projection; only newly ingested declared events create records. Historical log backfill remains an explicit later operation. +3. Deploy APIs and console page after backend authorization is available. +4. Rollback by disabling the page and projection. Existing records contain no raw network data and can expire through normal retention cleanup. + +## Open Questions + +None. Initial thresholds and retention are conservative constants covered by tests and can become declared policy in a later change. diff --git a/openspec/changes/add-scum-game-player-intelligence/proposal.md b/openspec/changes/add-scum-game-player-intelligence/proposal.md new file mode 100644 index 0000000..661c4a5 --- /dev/null +++ b/openspec/changes/add-scum-game-player-intelligence/proposal.md @@ -0,0 +1,23 @@ +## Why + +SCUM login semantics are currently retained only as operational logs, so operators cannot safely review a local player's identity history, sessions, or suspicious access behavior. A server-scoped game-player projection turns declared SCUM login/logout events into reviewable evidence without conflating game identities with platform users or exposing network data. + +## What Changes + +- Add a server-scoped local game-player domain with aliases, sessions, access attempts, and review-only security signals. +- Project declared `scum.login` and `scum.logout` semantic log events idempotently, including duplicate, renamed-player, out-of-order, and session-boundary handling. +- Derive a server-isolated irreversible network correlation fingerprint; do not persist raw IP addresses or raw fingerprints. +- Add authenticated player-profile, aliases, access-trajectory, and security-signal APIs plus SCUM console contracts and records. +- Define bounded retention and manual-review semantics for access evidence and risk signals. + +## Capabilities + +### New Capabilities + +- `scum-game-player-intelligence`: Server-local SCUM player identities, event projection, privacy-preserving access evidence, and review-only risk signals. + +## Impact + +- Affects Platform domain/model/repository/service/API/validation layers and durable metadata storage. +- Affects the SCUM plugin semantic-event schemas and page declaration, plus Platform Web API contracts, routing, and shared-console records. +- Adds no Platform account authentication, direct game-database access, raw network data, automatic moderation, Run protocol, or host/executor data exposure. diff --git a/openspec/changes/add-scum-game-player-intelligence/specs/scum-game-player-intelligence/spec.md b/openspec/changes/add-scum-game-player-intelligence/specs/scum-game-player-intelligence/spec.md new file mode 100644 index 0000000..1eb56f3 --- /dev/null +++ b/openspec/changes/add-scum-game-player-intelligence/specs/scum-game-player-intelligence/spec.md @@ -0,0 +1,51 @@ +## ADDED Requirements + +### Requirement: Server-local game-player identity +The system SHALL maintain a game-player identity independently of platform users, uniquely scoped by `serverInstanceId` and immutable game-player ID, with bounded display-name alias history. + +#### Scenario: A known game player logs in under a new name +- **WHEN** a successful declared SCUM login contains an existing player ID and a different valid display name +- **THEN** the system SHALL update the player's current display name and retain the previous and new names as server-local aliases + +### Requirement: Idempotent semantic login/logout projection +The system SHALL project accepted `scum.login` and `scum.logout` semantic events using a durable event identity and SHALL tolerate duplicate and out-of-order delivery without creating duplicate access evidence or regressing player state. + +#### Scenario: A duplicate login event is received +- **WHEN** the same accepted log stream sequence is ingested again +- **THEN** the system SHALL return the normal duplicate acknowledgement and SHALL not create an additional session or alias record + +#### Scenario: A stale logout arrives after a later login +- **WHEN** a logout event is older than the player's latest successful login +- **THEN** the system SHALL only close an eligible earlier open session and SHALL not overwrite the newer player last-seen state + +### Requirement: Privacy-preserving access evidence +The system SHALL persist no raw IP address, raw network fingerprint, host path, credential, or raw sensitive log content in game-player access records or browser-visible responses. Network correlation SHALL be derived as an irreversible value scoped to one server instance. + +#### Scenario: An access event carries a source address +- **WHEN** a SCUM event contains a source address or network identifier +- **THEN** the system SHALL use it only to derive a server-isolated correlation value and SHALL omit the source value from stored models and API responses + +### Requirement: Review-only risk signals +The system SHALL create bounded security signals for thresholded failed access and possible shared-fingerprint identities, and SHALL expose them as manual-review evidence only. + +#### Scenario: Repeated failed access crosses the threshold +- **WHEN** five failed attempts with one server-local correlation value occur within fifteen minutes +- **THEN** the system SHALL create or refresh an excessive-failed-access signal with an explicit textual review status and evidence count + +#### Scenario: An operator reviews a signal +- **WHEN** an authorized operator reads a player security signal +- **THEN** the response SHALL contain no automated enforcement command, raw network identifier, or raw log line + +### Requirement: Authorized player intelligence console +The system SHALL provide server-authorized player profile, aliases, sessions, access-attempt, and security-signal responses through named contracts, and the SCUM console SHALL render labels and status text rather than relying only on color. + +#### Scenario: An unauthorized user requests server player data +- **WHEN** a platform session lacks read access to the requested server instance +- **THEN** the player intelligence API SHALL deny access without revealing whether a game-player record exists + +### Requirement: Bounded evidence retention +The system SHALL retain access attempts and active security evidence for a bounded period, prune expired evidence during normal service operations, and keep independent player identity history intact. + +#### Scenario: Evidence is older than the retention limit +- **WHEN** a query or projection runs after an access attempt exceeds the retention period +- **THEN** the system SHALL remove the expired attempt and any expired non-active signal evidence without deleting the game-player identity diff --git a/openspec/changes/add-scum-game-player-intelligence/tasks.md b/openspec/changes/add-scum-game-player-intelligence/tasks.md new file mode 100644 index 0000000..95b3c6d --- /dev/null +++ b/openspec/changes/add-scum-game-player-intelligence/tasks.md @@ -0,0 +1,21 @@ +## 1. Domain and persistence + +- [x] 1.1 Add server-local game-player, alias, session, access-attempt, and security-signal domain/models, repositories, MySQL migration, memory/file stores, validation, and retention helpers. +- [x] 1.2 Add idempotent SCUM login/logout and failed-access projection from accepted semantic log entries, including rename, duplicate, stale event, session boundary, isolated HMAC correlation, and review-only signal thresholds. +- [x] 1.3 Add focused domain/service/repository tests for privacy, ordering, idempotency, access control, retention, aliases, sessions, and risk signals. + +## 2. APIs and plugin contract + +- [x] 2.1 Add named request/response DTOs, authorized handlers/routes, safe projections, and API tests for player profiles, lists, aliases, sessions, attempts, and signals. +- [x] 2.2 Extend the SCUM manifest semantic schemas/page contract for bounded player intelligence and add plugin validation fixtures/tests. + +## 3. SCUM console + +- [x] 3.1 Add Platform Web API/client contracts and route resolution for the SCUM player intelligence work surface. +- [x] 3.2 Render full-width shared-console player records, access trajectories, and manual-review signals with explicit text status and no raw network/log exposure. +- [x] 3.3 Add frontend contract/component tests for safe rendering and status readability. + +## 4. Verification + +- [x] 4.1 Run strict OpenSpec validation and focused backend, plugin, and frontend test suites. +- [ ] 4.2 Run `scripts/check-structure.sh`, stage only task files, commit on `main`, and push the configured remote. diff --git a/platform/api/game_player_handlers.go b/platform/api/game_player_handlers.go new file mode 100644 index 0000000..90e737c --- /dev/null +++ b/platform/api/game_player_handlers.go @@ -0,0 +1,57 @@ +package api + +import ( + "browser.local/platform/domain" + "browser.local/platform/dto" + "browser.local/platform/repo" + "net/http" + "strconv" +) + +// serverGamePlayers godoc +// @Summary List server-local game players +// @Description Returns privacy-safe SCUM game player records visible to the current server operator. +// @Tags game-players +// @Produce json +// @Success 200 {object} dto.GamePlayerListResponse +// @Failure 401 {object} dto.ErrorResponse +// @Failure 403 {object} dto.ErrorResponse +// @Router /api/v1/server-instances/{id}/game-players [get] +func (h *coreHandlers) serverGamePlayers(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeMethodNotAllowed(w, http.MethodGet) + return + } + limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) + values, err := h.core.ListGamePlayersForSession(bearerToken(r), domain.GamePlayerFilter{ServerInstanceID: r.PathValue("id"), Search: r.URL.Query().Get("search"), Limit: limit}) + if err != nil { + writeServiceError(w, err) + return + } + writeJSON(w, http.StatusOK, dto.GamePlayerListFromDomain(values)) +} + +// serverGamePlayerDetail godoc +// @Summary Get server-local game player profile +// @Description Returns aliases, session trajectory, access attempts, and manual-review signals without network identifiers. +// @Tags game-players +// @Produce json +// @Success 200 {object} dto.GamePlayerProfileResponse +// @Failure 404 {object} dto.ErrorResponse +// @Router /api/v1/server-instances/{id}/game-players/{playerId} [get] +func (h *coreHandlers) serverGamePlayerDetail(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeMethodNotAllowed(w, http.MethodGet) + return + } + value, err := h.core.GetGamePlayerProfileForSession(bearerToken(r), r.PathValue("playerId")) + if err != nil { + writeServiceError(w, err) + return + } + if value.Player.ServerInstanceID != r.PathValue("id") { + writeServiceError(w, repo.ErrNotFound) + return + } + writeJSON(w, http.StatusOK, dto.GamePlayerProfileFromDomain(value)) +} diff --git a/platform/api/resource_handlers.go b/platform/api/resource_handlers.go index 83f6a24..98acca2 100644 --- a/platform/api/resource_handlers.go +++ b/platform/api/resource_handlers.go @@ -93,6 +93,8 @@ func (h *coreHandlers) register(mux *http.ServeMux) { mux.HandleFunc("/api/v1/server-instances/{id}/game-client-bridge/commands/{commandId}/cancel", h.serverGameClientBridgeCommandCancel) mux.HandleFunc("/api/v1/server-instances/{id}/game-client-bridge/commands/{commandId}", h.serverGameClientBridgeCommandDetail) mux.HandleFunc("/api/v1/server-instances/{id}/game-client-bridge/snapshots", h.serverGameClientBridgeSnapshots) + mux.HandleFunc("/api/v1/server-instances/{id}/game-players", h.serverGamePlayers) + mux.HandleFunc("/api/v1/server-instances/{id}/game-players/{playerId}", h.serverGamePlayerDetail) mux.HandleFunc("/api/v1/server-instances/{id}/dependencies/check", h.serverDependenciesCheck) mux.HandleFunc("/api/v1/server-instances/{id}/dependencies/install", h.serverDependenciesInstall) mux.HandleFunc("/api/v1/server-instances/{id}/dependencies", h.serverDependencies) diff --git a/platform/domain/game_players.go b/platform/domain/game_players.go new file mode 100644 index 0000000..591a407 --- /dev/null +++ b/platform/domain/game_players.go @@ -0,0 +1,111 @@ +package domain + +import "time" + +// GamePlayer is a local game identity and is never a platform console user. +type GamePlayer struct { + ID string + ServerInstanceID string + GamePlayerID string + DisplayName string + FirstSeenAt time.Time + LastSeenAt time.Time + LastEventAt time.Time + CreatedAt time.Time + UpdatedAt time.Time +} +type GamePlayerFilter struct { + ServerInstanceID string + Search string + Limit int +} + +// GamePlayerAlias preserves a bounded name history for one local game player. +type GamePlayerAlias struct { + ID string + GamePlayerRecordID string + ServerInstanceID string + Alias string + FirstSeenAt time.Time + LastSeenAt time.Time +} +type GamePlayerAliasFilter struct { + GamePlayerRecordID string + ServerInstanceID string + Limit int +} + +// GamePlayerSession records a successful, bounded SCUM session. No raw network data is present. +type GamePlayerSession struct { + ID string + GamePlayerRecordID string + ServerInstanceID string + SourceSessionID string + StartedAt time.Time + EndedAt time.Time + EndReason string + LastEventAt time.Time +} +type GamePlayerSessionFilter struct { + GamePlayerRecordID string + ServerInstanceID string + OpenOnly bool + Limit int +} + +// GameAccessAttempt is review evidence. NetworkCorrelationKey is an irreversible server-local HMAC output. +type GameAccessAttempt struct { + ID string + ServerInstanceID string + GamePlayerRecordID string + EventID string + OccurredAt time.Time + Outcome string + Reason string + NetworkCorrelationKey string + ExpiresAt time.Time +} +type GameAccessAttemptFilter struct { + ServerInstanceID string + GamePlayerRecordID string + Limit int +} +type GameSecuritySignalStatus string + +const ( + GameSecuritySignalOpen GameSecuritySignalStatus = "open" + GameSecuritySignalReviewRequired GameSecuritySignalStatus = "review-required" + GameSecuritySignalExpired GameSecuritySignalStatus = "expired" +) + +// GameSecuritySignal is evidence for manual review; it carries no enforcement action. +type GameSecuritySignal struct { + ID string + ServerInstanceID string + GamePlayerRecordID string + RuleKey string + Status GameSecuritySignalStatus + EvidenceCount int + Summary string + FirstObservedAt time.Time + LastObservedAt time.Time + ExpiresAt time.Time +} +type GameSecuritySignalFilter struct { + ServerInstanceID string + GamePlayerRecordID string + Limit int +} +type GamePlayerProfile struct { + Player GamePlayer + Aliases []GamePlayerAlias + Sessions []GamePlayerSession + AccessAttempts []GameAccessAttempt + SecuritySignals []GameSecuritySignal +} + +func CopyGamePlayer(v GamePlayer) GamePlayer { return v } +func CopyGamePlayerAlias(v GamePlayerAlias) GamePlayerAlias { return v } +func CopyGamePlayerSession(v GamePlayerSession) GamePlayerSession { return v } +func CopyGameAccessAttempt(v GameAccessAttempt) GameAccessAttempt { return v } +func CopyGameSecuritySignal(v GameSecuritySignal) GameSecuritySignal { return v } diff --git a/platform/dto/game_players.go b/platform/dto/game_players.go new file mode 100644 index 0000000..a0d4efe --- /dev/null +++ b/platform/dto/game_players.go @@ -0,0 +1,82 @@ +package dto + +import ( + "browser.local/platform/domain" + "time" +) + +type GamePlayerResponse struct { + ID string `json:"id"` + ServerInstanceID string `json:"serverInstanceId"` + GamePlayerID string `json:"gamePlayerId"` + DisplayName string `json:"displayName"` + FirstSeenAt time.Time `json:"firstSeenAt"` + LastSeenAt time.Time `json:"lastSeenAt"` +} +type GamePlayerAliasResponse struct { + Alias string `json:"alias"` + FirstSeenAt time.Time `json:"firstSeenAt"` + LastSeenAt time.Time `json:"lastSeenAt"` +} +type GamePlayerSessionResponse struct { + ID string `json:"id"` + SourceSessionID string `json:"sourceSessionId"` + StartedAt time.Time `json:"startedAt"` + EndedAt time.Time `json:"endedAt,omitempty"` + EndReason string `json:"endReason,omitempty"` +} +type GameAccessAttemptResponse struct { + ID string `json:"id"` + OccurredAt time.Time `json:"occurredAt"` + Outcome string `json:"outcome"` + Reason string `json:"reason"` +} +type GameSecuritySignalResponse struct { + ID string `json:"id"` + RuleKey string `json:"ruleKey"` + Status string `json:"status"` + EvidenceCount int `json:"evidenceCount"` + Summary string `json:"summary"` + FirstObservedAt time.Time `json:"firstObservedAt"` + LastObservedAt time.Time `json:"lastObservedAt"` +} +type GamePlayerProfileResponse struct { + Player GamePlayerResponse `json:"player"` + Aliases []GamePlayerAliasResponse `json:"aliases"` + Sessions []GamePlayerSessionResponse `json:"sessions"` + AccessAttempts []GameAccessAttemptResponse `json:"accessAttempts"` + SecuritySignals []GameSecuritySignalResponse `json:"securitySignals"` +} +type GamePlayerListResponse struct { + Items []GamePlayerResponse `json:"items"` +} + +func GamePlayersFromDomain(values []domain.GamePlayer) []GamePlayerResponse { + out := make([]GamePlayerResponse, len(values)) + for i, v := range values { + out[i] = gamePlayerFromDomain(v) + } + return out +} +func GamePlayerListFromDomain(values []domain.GamePlayer) GamePlayerListResponse { + return GamePlayerListResponse{Items: GamePlayersFromDomain(values)} +} +func GamePlayerProfileFromDomain(v domain.GamePlayerProfile) GamePlayerProfileResponse { + out := GamePlayerProfileResponse{Player: gamePlayerFromDomain(v.Player), Aliases: make([]GamePlayerAliasResponse, len(v.Aliases)), Sessions: make([]GamePlayerSessionResponse, len(v.Sessions)), AccessAttempts: make([]GameAccessAttemptResponse, len(v.AccessAttempts)), SecuritySignals: make([]GameSecuritySignalResponse, len(v.SecuritySignals))} + for i, x := range v.Aliases { + out.Aliases[i] = GamePlayerAliasResponse{Alias: x.Alias, FirstSeenAt: x.FirstSeenAt, LastSeenAt: x.LastSeenAt} + } + for i, x := range v.Sessions { + out.Sessions[i] = GamePlayerSessionResponse{ID: x.ID, SourceSessionID: x.SourceSessionID, StartedAt: x.StartedAt, EndedAt: x.EndedAt, EndReason: x.EndReason} + } + for i, x := range v.AccessAttempts { + out.AccessAttempts[i] = GameAccessAttemptResponse{ID: x.ID, OccurredAt: x.OccurredAt, Outcome: x.Outcome, Reason: x.Reason} + } + for i, x := range v.SecuritySignals { + out.SecuritySignals[i] = GameSecuritySignalResponse{ID: x.ID, RuleKey: x.RuleKey, Status: string(x.Status), EvidenceCount: x.EvidenceCount, Summary: x.Summary, FirstObservedAt: x.FirstObservedAt, LastObservedAt: x.LastObservedAt} + } + return out +} +func gamePlayerFromDomain(v domain.GamePlayer) GamePlayerResponse { + return GamePlayerResponse{ID: v.ID, ServerInstanceID: v.ServerInstanceID, GamePlayerID: v.GamePlayerID, DisplayName: v.DisplayName, FirstSeenAt: v.FirstSeenAt, LastSeenAt: v.LastSeenAt} +} diff --git a/platform/model/game_players.go b/platform/model/game_players.go new file mode 100644 index 0000000..a29a05b --- /dev/null +++ b/platform/model/game_players.go @@ -0,0 +1,72 @@ +package model + +import "time" + +// GamePlayer is the model-first table shape for an independent server-local game identity. +type GamePlayer struct { + ID string `json:"id" db:"id"` + ServerInstanceID string `json:"serverInstanceId" db:"server_instance_id"` + GamePlayerID string `json:"gamePlayerId" db:"game_player_id"` + DisplayName string `json:"displayName" db:"display_name"` + FirstSeenAt time.Time `json:"firstSeenAt" db:"first_seen_at"` + LastSeenAt time.Time `json:"lastSeenAt" db:"last_seen_at"` + LastEventAt time.Time `json:"lastEventAt" db:"last_event_at"` + CreatedAt time.Time `json:"createdAt" db:"created_at"` + UpdatedAt time.Time `json:"updatedAt" db:"updated_at"` +} + +func (GamePlayer) TableName() string { return "game_players" } + +type GamePlayerAlias struct { + ID string `json:"id" db:"id"` + GamePlayerRecordID string `json:"gamePlayerRecordId" db:"game_player_record_id"` + ServerInstanceID string `json:"serverInstanceId" db:"server_instance_id"` + Alias string `json:"alias" db:"alias"` + FirstSeenAt time.Time `json:"firstSeenAt" db:"first_seen_at"` + LastSeenAt time.Time `json:"lastSeenAt" db:"last_seen_at"` +} + +func (GamePlayerAlias) TableName() string { return "game_player_aliases" } + +type GamePlayerSession struct { + ID string `json:"id" db:"id"` + GamePlayerRecordID string `json:"gamePlayerRecordId" db:"game_player_record_id"` + ServerInstanceID string `json:"serverInstanceId" db:"server_instance_id"` + SourceSessionID string `json:"sourceSessionId" db:"source_session_id"` + StartedAt time.Time `json:"startedAt" db:"started_at"` + EndedAt time.Time `json:"endedAt,omitempty" db:"ended_at"` + EndReason string `json:"endReason,omitempty" db:"end_reason"` + LastEventAt time.Time `json:"lastEventAt" db:"last_event_at"` +} + +func (GamePlayerSession) TableName() string { return "game_player_sessions" } + +// GameAccessAttempt intentionally has no raw address field; correlation is irreversible and server-scoped. +type GameAccessAttempt struct { + ID string `json:"id" db:"id"` + ServerInstanceID string `json:"serverInstanceId" db:"server_instance_id"` + GamePlayerRecordID string `json:"gamePlayerRecordId" db:"game_player_record_id"` + EventID string `json:"eventId" db:"event_id"` + OccurredAt time.Time `json:"occurredAt" db:"occurred_at"` + Outcome string `json:"outcome" db:"outcome"` + Reason string `json:"reason" db:"reason"` + NetworkCorrelationKey string `json:"-" db:"network_correlation_key"` + ExpiresAt time.Time `json:"expiresAt" db:"expires_at"` +} + +func (GameAccessAttempt) TableName() string { return "game_access_attempts" } + +type GameSecuritySignal struct { + ID string `json:"id" db:"id"` + ServerInstanceID string `json:"serverInstanceId" db:"server_instance_id"` + GamePlayerRecordID string `json:"gamePlayerRecordId" db:"game_player_record_id"` + RuleKey string `json:"ruleKey" db:"rule_key"` + Status string `json:"status" db:"status"` + EvidenceCount int `json:"evidenceCount" db:"evidence_count"` + Summary string `json:"summary" db:"summary"` + FirstObservedAt time.Time `json:"firstObservedAt" db:"first_observed_at"` + LastObservedAt time.Time `json:"lastObservedAt" db:"last_observed_at"` + ExpiresAt time.Time `json:"expiresAt" db:"expires_at"` +} + +func (GameSecuritySignal) TableName() string { return "game_security_signals" } diff --git a/platform/repo/file_store.go b/platform/repo/file_store.go index 80247e2..c614b84 100644 --- a/platform/repo/file_store.go +++ b/platform/repo/file_store.go @@ -42,6 +42,11 @@ type StoreSnapshot struct { GameClientBridgeCommands []domain.GameClientBridgeCommand `json:"gameClientBridgeCommands"` GameClientBridgeSnapshots []domain.GameClientBridgeSnapshot `json:"gameClientBridgeSnapshots"` GameClientBridgeStreams []domain.GameClientBridgeSnapshotStream `json:"gameClientBridgeStreams"` + GamePlayers []domain.GamePlayer `json:"gamePlayers"` + GamePlayerAliases []domain.GamePlayerAlias `json:"gamePlayerAliases"` + GamePlayerSessions []domain.GamePlayerSession `json:"gamePlayerSessions"` + GameAccessAttempts []domain.GameAccessAttempt `json:"gameAccessAttempts"` + GameSecuritySignals []domain.GameSecuritySignal `json:"gameSecuritySignals"` } type FileStore struct { @@ -190,6 +195,21 @@ func (store *FileStore) GameClientBridgeSnapshots() GameClientBridgeSnapshotRepo func (store *FileStore) GameClientBridgeSnapshotStreams() GameClientBridgeSnapshotStreamRepository { return &persistentRepository[domain.GameClientBridgeSnapshotStream, domain.GameClientBridgeSnapshotStreamFilter]{repository: store.MemoryStore.bridgeStreams, persist: store.persist} } +func (store *FileStore) GamePlayers() GamePlayerRepository { + return &persistentRepository[domain.GamePlayer, domain.GamePlayerFilter]{repository: store.MemoryStore.gamePlayers, persist: store.persist} +} +func (store *FileStore) GamePlayerAliases() GamePlayerAliasRepository { + return &persistentRepository[domain.GamePlayerAlias, domain.GamePlayerAliasFilter]{repository: store.MemoryStore.gamePlayerAliases, persist: store.persist} +} +func (store *FileStore) GamePlayerSessions() GamePlayerSessionRepository { + return &persistentRepository[domain.GamePlayerSession, domain.GamePlayerSessionFilter]{repository: store.MemoryStore.gamePlayerSessions, persist: store.persist} +} +func (store *FileStore) GameAccessAttempts() GameAccessAttemptRepository { + return &persistentRepository[domain.GameAccessAttempt, domain.GameAccessAttemptFilter]{repository: store.MemoryStore.gameAccessAttempts, persist: store.persist} +} +func (store *FileStore) GameSecuritySignals() GameSecuritySignalRepository { + return &persistentRepository[domain.GameSecuritySignal, domain.GameSecuritySignalFilter]{repository: store.MemoryStore.gameSecuritySignals, persist: store.persist} +} func (store *FileStore) load() error { data, err := os.ReadFile(store.path) @@ -263,6 +283,7 @@ func (store *FileStore) snapshot() StoreSnapshot { GameClientBridgeCommands: snapshotRepository(store.MemoryStore.bridgeCommands.memoryRepository), GameClientBridgeSnapshots: snapshotRepository(store.MemoryStore.bridgeSnapshots.memoryRepository), GameClientBridgeStreams: snapshotRepository(store.MemoryStore.bridgeStreams), + GamePlayers: snapshotRepository(store.MemoryStore.gamePlayers), GamePlayerAliases: snapshotRepository(store.MemoryStore.gamePlayerAliases), GamePlayerSessions: snapshotRepository(store.MemoryStore.gamePlayerSessions), GameAccessAttempts: snapshotRepository(store.MemoryStore.gameAccessAttempts), GameSecuritySignals: snapshotRepository(store.MemoryStore.gameSecuritySignals), } } @@ -296,6 +317,11 @@ func (store *FileStore) loadSnapshot(snapshot StoreSnapshot) { loadRepository(store.MemoryStore.bridgeCommands.memoryRepository, snapshot.GameClientBridgeCommands) loadRepository(store.MemoryStore.bridgeSnapshots.memoryRepository, snapshot.GameClientBridgeSnapshots) loadRepository(store.MemoryStore.bridgeStreams, snapshot.GameClientBridgeStreams) + loadRepository(store.MemoryStore.gamePlayers, snapshot.GamePlayers) + loadRepository(store.MemoryStore.gamePlayerAliases, snapshot.GamePlayerAliases) + loadRepository(store.MemoryStore.gamePlayerSessions, snapshot.GamePlayerSessions) + loadRepository(store.MemoryStore.gameAccessAttempts, snapshot.GameAccessAttempts) + loadRepository(store.MemoryStore.gameSecuritySignals, snapshot.GameSecuritySignals) } type mutableRepository[T any, F any] interface { diff --git a/platform/repo/mysql_store.go b/platform/repo/mysql_store.go index 57d81e1..de67551 100644 --- a/platform/repo/mysql_store.go +++ b/platform/repo/mysql_store.go @@ -171,6 +171,21 @@ func (store *MySQLStore) GameClientBridgeSnapshots() GameClientBridgeSnapshotRep func (store *MySQLStore) GameClientBridgeSnapshotStreams() GameClientBridgeSnapshotStreamRepository { return &persistentRepository[domain.GameClientBridgeSnapshotStream, domain.GameClientBridgeSnapshotStreamFilter]{repository: store.MemoryStore.bridgeStreams, persist: store.persist} } +func (store *MySQLStore) GamePlayers() GamePlayerRepository { + return &persistentRepository[domain.GamePlayer, domain.GamePlayerFilter]{repository: store.MemoryStore.gamePlayers, persist: store.persist} +} +func (store *MySQLStore) GamePlayerAliases() GamePlayerAliasRepository { + return &persistentRepository[domain.GamePlayerAlias, domain.GamePlayerAliasFilter]{repository: store.MemoryStore.gamePlayerAliases, persist: store.persist} +} +func (store *MySQLStore) GamePlayerSessions() GamePlayerSessionRepository { + return &persistentRepository[domain.GamePlayerSession, domain.GamePlayerSessionFilter]{repository: store.MemoryStore.gamePlayerSessions, persist: store.persist} +} +func (store *MySQLStore) GameAccessAttempts() GameAccessAttemptRepository { + return &persistentRepository[domain.GameAccessAttempt, domain.GameAccessAttemptFilter]{repository: store.MemoryStore.gameAccessAttempts, persist: store.persist} +} +func (store *MySQLStore) GameSecuritySignals() GameSecuritySignalRepository { + return &persistentRepository[domain.GameSecuritySignal, domain.GameSecuritySignalFilter]{repository: store.MemoryStore.gameSecuritySignals, persist: store.persist} +} func (store *MySQLStore) initialize() error { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) @@ -261,6 +276,7 @@ func (store *MySQLStore) snapshot() StoreSnapshot { GameClientBridgeCommands: snapshotRepository(store.MemoryStore.bridgeCommands.memoryRepository), GameClientBridgeSnapshots: snapshotRepository(store.MemoryStore.bridgeSnapshots.memoryRepository), GameClientBridgeStreams: snapshotRepository(store.MemoryStore.bridgeStreams), + GamePlayers: snapshotRepository(store.MemoryStore.gamePlayers), GamePlayerAliases: snapshotRepository(store.MemoryStore.gamePlayerAliases), GamePlayerSessions: snapshotRepository(store.MemoryStore.gamePlayerSessions), GameAccessAttempts: snapshotRepository(store.MemoryStore.gameAccessAttempts), GameSecuritySignals: snapshotRepository(store.MemoryStore.gameSecuritySignals), } } @@ -294,4 +310,9 @@ func (store *MySQLStore) loadSnapshot(snapshot StoreSnapshot) { loadRepository(store.MemoryStore.bridgeCommands.memoryRepository, snapshot.GameClientBridgeCommands) loadRepository(store.MemoryStore.bridgeSnapshots.memoryRepository, snapshot.GameClientBridgeSnapshots) loadRepository(store.MemoryStore.bridgeStreams, snapshot.GameClientBridgeStreams) + loadRepository(store.MemoryStore.gamePlayers, snapshot.GamePlayers) + loadRepository(store.MemoryStore.gamePlayerAliases, snapshot.GamePlayerAliases) + loadRepository(store.MemoryStore.gamePlayerSessions, snapshot.GamePlayerSessions) + loadRepository(store.MemoryStore.gameAccessAttempts, snapshot.GameAccessAttempts) + loadRepository(store.MemoryStore.gameSecuritySignals, snapshot.GameSecuritySignals) } diff --git a/platform/repo/resources.go b/platform/repo/resources.go index 0acc354..d817b1e 100644 --- a/platform/repo/resources.go +++ b/platform/repo/resources.go @@ -3,6 +3,7 @@ package repo import ( "errors" "sort" + "strings" "sync" "browser.local/platform/domain" @@ -224,6 +225,41 @@ type GameClientBridgeSnapshotStreamRepository interface { Delete(id string) error } +type GamePlayerRepository interface { + Create(domain.GamePlayer) error + Get(string) (domain.GamePlayer, error) + List(domain.GamePlayerFilter) ([]domain.GamePlayer, error) + Update(domain.GamePlayer) error +} +type GamePlayerAliasRepository interface { + Create(domain.GamePlayerAlias) error + Get(string) (domain.GamePlayerAlias, error) + List(domain.GamePlayerAliasFilter) ([]domain.GamePlayerAlias, error) + Update(domain.GamePlayerAlias) error + Delete(string) error +} +type GamePlayerSessionRepository interface { + Create(domain.GamePlayerSession) error + Get(string) (domain.GamePlayerSession, error) + List(domain.GamePlayerSessionFilter) ([]domain.GamePlayerSession, error) + Update(domain.GamePlayerSession) error + Delete(string) error +} +type GameAccessAttemptRepository interface { + Create(domain.GameAccessAttempt) error + Get(string) (domain.GameAccessAttempt, error) + List(domain.GameAccessAttemptFilter) ([]domain.GameAccessAttempt, error) + Update(domain.GameAccessAttempt) error + Delete(string) error +} +type GameSecuritySignalRepository interface { + Create(domain.GameSecuritySignal) error + Get(string) (domain.GameSecuritySignal, error) + List(domain.GameSecuritySignalFilter) ([]domain.GameSecuritySignal, error) + Update(domain.GameSecuritySignal) error + Delete(string) error +} + type Store interface { Users() UserRepository AuthSessions() AuthSessionRepository @@ -254,38 +290,48 @@ type Store interface { GameClientBridgeCommands() GameClientBridgeCommandRepository GameClientBridgeSnapshots() GameClientBridgeSnapshotRepository GameClientBridgeSnapshotStreams() GameClientBridgeSnapshotStreamRepository + GamePlayers() GamePlayerRepository + GamePlayerAliases() GamePlayerAliasRepository + GamePlayerSessions() GamePlayerSessionRepository + GameAccessAttempts() GameAccessAttemptRepository + GameSecuritySignals() GameSecuritySignalRepository } type MemoryStore struct { - users *memoryRepository[domain.User, domain.UserFilter] - authSessions *memoryRepository[domain.AuthSessionRecord, domain.AuthSessionFilter] - runSessions *memoryRepository[domain.RunControlSession, struct{}] - aiProviders *memoryRepository[domain.AIProvider, domain.AIProviderFilter] - gamePlugins *memoryRepository[domain.GamePlugin, domain.GamePluginFilter] - serverInstances *memoryRepository[domain.ServerInstance, domain.ServerInstanceFilter] - runEndpoints *memoryRepository[domain.RunEndpoint, domain.RunEndpointFilter] - jobs *memoryJobRepository - artifacts *memoryRepository[domain.Artifact, domain.ArtifactFilter] - runtimeBindings *memoryRepository[domain.RuntimeBinding, domain.RuntimeBindingFilter] - componentKeys *memoryRepository[domain.EncryptedComponentKey, domain.EncryptedComponentKeyFilter] - runDists *memoryRepository[domain.RunDistribution, domain.RunDistributionFilter] - clientDists *memoryRepository[domain.ClientManagerDistribution, domain.ClientManagerDistributionFilter] - clientInstalls *memoryRepository[domain.ClientManagerInstallation, domain.ClientManagerInstallationFilter] - clientSessions *memoryRepository[domain.ClientManagerSession, domain.ClientManagerSessionFilter] - clientNonces *memoryRepository[domain.ClientManagerRegistrationNonce, domain.ClientManagerNonceFilter] - dependencies *memoryRepository[domain.DependencyStatus, domain.DependencyStatusFilter] - buildJobs *memoryRepository[domain.ClientManagerBuildJob, domain.ClientManagerBuildJobFilter] - updateJobs *memoryRepository[domain.RunUpdateJob, domain.RunUpdateJobFilter] - logStreams *memoryRepository[domain.LogStream, domain.LogStreamFilter] - auditEvents *memoryRepository[domain.AuditEvent, domain.AuditEventFilter] - metricSamples *memoryRepository[domain.MetricSample, domain.MetricSampleFilter] - backups *memoryRepository[domain.BackupRecord, domain.BackupFilter] - alerts *memoryRepository[domain.AlertRecord, domain.AlertFilter] - pluginLifecycle *memoryRepository[domain.PluginLifecycleInstallation, domain.PluginLifecycleFilter] - aiConfigDiffs *memoryRepository[domain.AIConfigDiffPreview, domain.AIConfigDiffFilter] - bridgeCommands *memoryGameClientBridgeCommandRepository - bridgeSnapshots *memoryGameClientBridgeSnapshotRepository - bridgeStreams *memoryRepository[domain.GameClientBridgeSnapshotStream, domain.GameClientBridgeSnapshotStreamFilter] + users *memoryRepository[domain.User, domain.UserFilter] + authSessions *memoryRepository[domain.AuthSessionRecord, domain.AuthSessionFilter] + runSessions *memoryRepository[domain.RunControlSession, struct{}] + aiProviders *memoryRepository[domain.AIProvider, domain.AIProviderFilter] + gamePlugins *memoryRepository[domain.GamePlugin, domain.GamePluginFilter] + serverInstances *memoryRepository[domain.ServerInstance, domain.ServerInstanceFilter] + runEndpoints *memoryRepository[domain.RunEndpoint, domain.RunEndpointFilter] + jobs *memoryJobRepository + artifacts *memoryRepository[domain.Artifact, domain.ArtifactFilter] + runtimeBindings *memoryRepository[domain.RuntimeBinding, domain.RuntimeBindingFilter] + componentKeys *memoryRepository[domain.EncryptedComponentKey, domain.EncryptedComponentKeyFilter] + runDists *memoryRepository[domain.RunDistribution, domain.RunDistributionFilter] + clientDists *memoryRepository[domain.ClientManagerDistribution, domain.ClientManagerDistributionFilter] + clientInstalls *memoryRepository[domain.ClientManagerInstallation, domain.ClientManagerInstallationFilter] + clientSessions *memoryRepository[domain.ClientManagerSession, domain.ClientManagerSessionFilter] + clientNonces *memoryRepository[domain.ClientManagerRegistrationNonce, domain.ClientManagerNonceFilter] + dependencies *memoryRepository[domain.DependencyStatus, domain.DependencyStatusFilter] + buildJobs *memoryRepository[domain.ClientManagerBuildJob, domain.ClientManagerBuildJobFilter] + updateJobs *memoryRepository[domain.RunUpdateJob, domain.RunUpdateJobFilter] + logStreams *memoryRepository[domain.LogStream, domain.LogStreamFilter] + auditEvents *memoryRepository[domain.AuditEvent, domain.AuditEventFilter] + metricSamples *memoryRepository[domain.MetricSample, domain.MetricSampleFilter] + backups *memoryRepository[domain.BackupRecord, domain.BackupFilter] + alerts *memoryRepository[domain.AlertRecord, domain.AlertFilter] + pluginLifecycle *memoryRepository[domain.PluginLifecycleInstallation, domain.PluginLifecycleFilter] + aiConfigDiffs *memoryRepository[domain.AIConfigDiffPreview, domain.AIConfigDiffFilter] + bridgeCommands *memoryGameClientBridgeCommandRepository + bridgeSnapshots *memoryGameClientBridgeSnapshotRepository + bridgeStreams *memoryRepository[domain.GameClientBridgeSnapshotStream, domain.GameClientBridgeSnapshotStreamFilter] + gamePlayers *memoryRepository[domain.GamePlayer, domain.GamePlayerFilter] + gamePlayerAliases *memoryRepository[domain.GamePlayerAlias, domain.GamePlayerAliasFilter] + gamePlayerSessions *memoryRepository[domain.GamePlayerSession, domain.GamePlayerSessionFilter] + gameAccessAttempts *memoryRepository[domain.GameAccessAttempt, domain.GameAccessAttemptFilter] + gameSecuritySignals *memoryRepository[domain.GameSecuritySignal, domain.GameSecuritySignalFilter] } func NewMemoryStore() *MemoryStore { @@ -423,6 +469,11 @@ func NewMemoryStore() *MemoryStore { domain.CopyGameClientBridgeSnapshotStream, matchGameClientBridgeSnapshotStream, ), + gamePlayers: newMemoryRepository(func(v domain.GamePlayer) string { return v.ID }, domain.CopyGamePlayer, matchGamePlayer), + gamePlayerAliases: newMemoryRepository(func(v domain.GamePlayerAlias) string { return v.ID }, domain.CopyGamePlayerAlias, matchGamePlayerAlias), + gamePlayerSessions: newMemoryRepository(func(v domain.GamePlayerSession) string { return v.ID }, domain.CopyGamePlayerSession, matchGamePlayerSession), + gameAccessAttempts: newMemoryRepository(func(v domain.GameAccessAttempt) string { return v.ID }, domain.CopyGameAccessAttempt, matchGameAccessAttempt), + gameSecuritySignals: newMemoryRepository(func(v domain.GameSecuritySignal) string { return v.ID }, domain.CopyGameSecuritySignal, matchGameSecuritySignal), } } @@ -475,6 +526,19 @@ func (store *MemoryStore) GameClientBridgeSnapshots() GameClientBridgeSnapshotRe func (store *MemoryStore) GameClientBridgeSnapshotStreams() GameClientBridgeSnapshotStreamRepository { return store.bridgeStreams } +func (store *MemoryStore) GamePlayers() GamePlayerRepository { return store.gamePlayers } +func (store *MemoryStore) GamePlayerAliases() GamePlayerAliasRepository { + return store.gamePlayerAliases +} +func (store *MemoryStore) GamePlayerSessions() GamePlayerSessionRepository { + return store.gamePlayerSessions +} +func (store *MemoryStore) GameAccessAttempts() GameAccessAttemptRepository { + return store.gameAccessAttempts +} +func (store *MemoryStore) GameSecuritySignals() GameSecuritySignalRepository { + return store.gameSecuritySignals +} type memoryRepository[T any, F any] struct { mu sync.RWMutex @@ -775,3 +839,19 @@ func matchGameClientBridgeSnapshotStream(stream domain.GameClientBridgeSnapshotS (filter.Type == "" || stream.Type == filter.Type) && (filter.StreamKey == "" || stream.StreamKey == filter.StreamKey) } + +func matchGamePlayer(v domain.GamePlayer, f domain.GamePlayerFilter) bool { + return (f.ServerInstanceID == "" || v.ServerInstanceID == f.ServerInstanceID) && (f.Search == "" || strings.Contains(strings.ToLower(v.DisplayName), strings.ToLower(f.Search)) || strings.Contains(strings.ToLower(v.GamePlayerID), strings.ToLower(f.Search))) +} +func matchGamePlayerAlias(v domain.GamePlayerAlias, f domain.GamePlayerAliasFilter) bool { + return (f.GamePlayerRecordID == "" || v.GamePlayerRecordID == f.GamePlayerRecordID) && (f.ServerInstanceID == "" || v.ServerInstanceID == f.ServerInstanceID) +} +func matchGamePlayerSession(v domain.GamePlayerSession, f domain.GamePlayerSessionFilter) bool { + return (f.GamePlayerRecordID == "" || v.GamePlayerRecordID == f.GamePlayerRecordID) && (f.ServerInstanceID == "" || v.ServerInstanceID == f.ServerInstanceID) && (!f.OpenOnly || v.EndedAt.IsZero()) +} +func matchGameAccessAttempt(v domain.GameAccessAttempt, f domain.GameAccessAttemptFilter) bool { + return (f.GamePlayerRecordID == "" || v.GamePlayerRecordID == f.GamePlayerRecordID) && (f.ServerInstanceID == "" || v.ServerInstanceID == f.ServerInstanceID) +} +func matchGameSecuritySignal(v domain.GameSecuritySignal, f domain.GameSecuritySignalFilter) bool { + return (f.GamePlayerRecordID == "" || v.GamePlayerRecordID == f.GamePlayerRecordID) && (f.ServerInstanceID == "" || v.ServerInstanceID == f.ServerInstanceID) +} diff --git a/platform/service/game_players.go b/platform/service/game_players.go new file mode 100644 index 0000000..7dd2053 --- /dev/null +++ b/platform/service/game_players.go @@ -0,0 +1,337 @@ +package service + +import ( + "browser.local/platform/domain" + "browser.local/platform/repo" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "sort" + "strconv" + "strings" + "time" +) + +const gameAccessRetention = 30 * 24 * time.Hour +const failedAccessWindow = 15 * time.Minute +const failedAccessThreshold = 5 + +func (svc *CoreService) ListGamePlayersForSession(sessionID string, filter domain.GamePlayerFilter) ([]domain.GamePlayer, error) { + if err := svc.authorizeServerLifecycle(sessionID, filter.ServerInstanceID); err != nil { + return nil, err + } + if err := svc.pruneGamePlayerEvidence(filter.ServerInstanceID); err != nil { + return nil, err + } + values, err := svc.store.GamePlayers().List(filter) + if err != nil { + return nil, err + } + sort.Slice(values, func(i, j int) bool { return values[i].LastSeenAt.After(values[j].LastSeenAt) }) + if filter.Limit > 0 && len(values) > filter.Limit { + values = values[:filter.Limit] + } + return values, nil +} + +func (svc *CoreService) GetGamePlayerProfileForSession(sessionID, playerRecordID string) (domain.GamePlayerProfile, error) { + player, err := svc.store.GamePlayers().Get(playerRecordID) + if err != nil { + return domain.GamePlayerProfile{}, err + } + if err := svc.authorizeServerLifecycle(sessionID, player.ServerInstanceID); err != nil { + return domain.GamePlayerProfile{}, err + } + if err := svc.pruneGamePlayerEvidence(player.ServerInstanceID); err != nil { + return domain.GamePlayerProfile{}, err + } + aliases, err := svc.store.GamePlayerAliases().List(domain.GamePlayerAliasFilter{GamePlayerRecordID: player.ID}) + if err != nil { + return domain.GamePlayerProfile{}, err + } + sessions, err := svc.store.GamePlayerSessions().List(domain.GamePlayerSessionFilter{GamePlayerRecordID: player.ID}) + if err != nil { + return domain.GamePlayerProfile{}, err + } + attempts, err := svc.store.GameAccessAttempts().List(domain.GameAccessAttemptFilter{GamePlayerRecordID: player.ID}) + if err != nil { + return domain.GamePlayerProfile{}, err + } + signals, err := svc.store.GameSecuritySignals().List(domain.GameSecuritySignalFilter{GamePlayerRecordID: player.ID}) + if err != nil { + return domain.GamePlayerProfile{}, err + } + return domain.GamePlayerProfile{Player: player, Aliases: aliases, Sessions: sessions, AccessAttempts: attempts, SecuritySignals: signals}, nil +} + +func (svc *CoreService) projectGamePlayerEvents(batch domain.LogBatchIngest) error { + for _, entry := range batch.Entries { + if err := svc.projectGamePlayerEvent(batch, entry); err != nil { + return err + } + } + return svc.pruneGamePlayerEvidence(batch.ServerInstanceID) +} +func (svc *CoreService) projectGamePlayerEvent(batch domain.LogBatchIngest, entry domain.LogEntry) error { + fields := entry.Fields + if fields == nil { + return nil + } + eventType := strings.TrimSpace(fields["eventType"]) + if eventType != "scum.login" && eventType != "scum.logout" { + return nil + } + gameID, name := strings.TrimSpace(fields["playerId"]), strings.TrimSpace(fields["playerName"]) + if gameID == "" || name == "" { + return nil + } + occurred := entry.Timestamp + if raw := strings.TrimSpace(fields["occurredAt"]); raw != "" { + if parsed, err := time.Parse(time.RFC3339, raw); err == nil { + occurred = parsed + } + } + if occurred.IsZero() { + occurred = svc.now() + } + recordID := gamePlayerRecordID(batch.ServerInstanceID, gameID) + player, err := svc.store.GamePlayers().Get(recordID) + if err == repo.ErrNotFound { + player = domain.GamePlayer{ID: recordID, ServerInstanceID: batch.ServerInstanceID, GamePlayerID: gameID, DisplayName: name, FirstSeenAt: occurred, LastSeenAt: occurred, LastEventAt: occurred, CreatedAt: svc.now(), UpdatedAt: svc.now()} + if err = svc.store.GamePlayers().Create(player); err != nil { + return err + } + } else if err != nil { + return err + } + if occurred.After(player.LastEventAt) || occurred.Equal(player.LastEventAt) { + if name != player.DisplayName { + player.DisplayName = name + } + player.LastSeenAt = maxTime(player.LastSeenAt, occurred) + player.LastEventAt = occurred + player.UpdatedAt = svc.now() + if err := svc.store.GamePlayers().Update(player); err != nil { + return err + } + } + if err := svc.upsertGamePlayerAlias(player, name, occurred); err != nil { + return err + } + sourceSession := strings.TrimSpace(fields["sessionId"]) + if sourceSession == "" { + sourceSession = "event-" + entryID(batch.LogStreamID, entry.Seq) + } + if eventType == "scum.login" { + outcome := strings.TrimSpace(fields["outcome"]) + if outcome == "accepted" { + if err := svc.recordSuccessfulGameAccess(player, batch, entry, occurred, strings.TrimSpace(fields["networkFingerprint"])); err != nil { + return err + } + return svc.openGamePlayerSession(player, sourceSession, occurred) + } + return svc.recordFailedGameAccess(player, batch, entry, occurred, strings.TrimSpace(fields["networkFingerprint"])) + } + return svc.closeGamePlayerSession(player, sourceSession, occurred, strings.TrimSpace(fields["reason"])) +} + +func (svc *CoreService) recordSuccessfulGameAccess(player domain.GamePlayer, batch domain.LogBatchIngest, entry domain.LogEntry, at time.Time, raw string) error { + id := "attempt-" + entryID(batch.LogStreamID, entry.Seq) + if _, err := svc.store.GameAccessAttempts().Get(id); err == nil { + return nil + } else if err != repo.ErrNotFound { + return err + } + key := svc.networkCorrelation(batch.ServerInstanceID, raw) + if err := svc.store.GameAccessAttempts().Create(domain.GameAccessAttempt{ID: id, ServerInstanceID: batch.ServerInstanceID, GamePlayerRecordID: player.ID, EventID: entryID(batch.LogStreamID, entry.Seq), OccurredAt: at, Outcome: "accepted", Reason: "login-accepted", NetworkCorrelationKey: key, ExpiresAt: at.Add(gameAccessRetention)}); err != nil { + return err + } + if key != "" { + return svc.refreshPossibleAltSignal(player, key, at) + } + return nil +} + +func (svc *CoreService) refreshPossibleAltSignal(player domain.GamePlayer, key string, at time.Time) error { + attempts, err := svc.store.GameAccessAttempts().List(domain.GameAccessAttemptFilter{ServerInstanceID: player.ServerInstanceID}) + if err != nil { + return err + } + players := map[string]struct{}{} + for _, attempt := range attempts { + if attempt.Outcome == "accepted" && attempt.NetworkCorrelationKey == key && !attempt.OccurredAt.Before(at.Add(-gameAccessRetention)) { + players[attempt.GamePlayerRecordID] = struct{}{} + } + } + if len(players) < 2 { + return nil + } + id := "signal-alt-" + fingerprintID(player.ServerInstanceID, key) + signal, err := svc.store.GameSecuritySignals().Get(id) + if err == repo.ErrNotFound { + return svc.store.GameSecuritySignals().Create(domain.GameSecuritySignal{ID: id, ServerInstanceID: player.ServerInstanceID, GamePlayerRecordID: player.ID, RuleKey: "possible-alt-account", Status: domain.GameSecuritySignalReviewRequired, EvidenceCount: len(players), Summary: "Multiple game identities share server-local access evidence; manual review required", FirstObservedAt: at, LastObservedAt: at, ExpiresAt: at.Add(gameAccessRetention)}) + } + if err != nil { + return err + } + signal.EvidenceCount = len(players) + signal.LastObservedAt = at + signal.ExpiresAt = at.Add(gameAccessRetention) + return svc.store.GameSecuritySignals().Update(signal) +} +func (svc *CoreService) upsertGamePlayerAlias(player domain.GamePlayer, name string, at time.Time) error { + id := gamePlayerAliasID(player.ID, name) + item, err := svc.store.GamePlayerAliases().Get(id) + if err == repo.ErrNotFound { + return svc.store.GamePlayerAliases().Create(domain.GamePlayerAlias{ID: id, GamePlayerRecordID: player.ID, ServerInstanceID: player.ServerInstanceID, Alias: name, FirstSeenAt: at, LastSeenAt: at}) + } + if err != nil { + return err + } + if at.After(item.LastSeenAt) { + item.LastSeenAt = at + return svc.store.GamePlayerAliases().Update(item) + } + return nil +} +func (svc *CoreService) openGamePlayerSession(player domain.GamePlayer, source string, at time.Time) error { + id := gamePlayerSessionID(player.ID, source) + item, err := svc.store.GamePlayerSessions().Get(id) + if err == repo.ErrNotFound { + return svc.store.GamePlayerSessions().Create(domain.GamePlayerSession{ID: id, GamePlayerRecordID: player.ID, ServerInstanceID: player.ServerInstanceID, SourceSessionID: source, StartedAt: at, LastEventAt: at}) + } + if err != nil { + return err + } + if at.After(item.LastEventAt) { + item.LastEventAt = at + return svc.store.GamePlayerSessions().Update(item) + } + return nil +} +func (svc *CoreService) closeGamePlayerSession(player domain.GamePlayer, source string, at time.Time, reason string) error { + id := gamePlayerSessionID(player.ID, source) + item, err := svc.store.GamePlayerSessions().Get(id) + if err == repo.ErrNotFound { + return nil + } + if err != nil { + return err + } + if item.StartedAt.After(at) || (!item.EndedAt.IsZero() && !at.After(item.EndedAt)) { + return nil + } + item.EndedAt = at + item.EndReason = bounded(reason, 40) + item.LastEventAt = maxTime(item.LastEventAt, at) + return svc.store.GamePlayerSessions().Update(item) +} +func (svc *CoreService) recordFailedGameAccess(player domain.GamePlayer, batch domain.LogBatchIngest, entry domain.LogEntry, at time.Time, raw string) error { + id := "attempt-" + entryID(batch.LogStreamID, entry.Seq) + if _, err := svc.store.GameAccessAttempts().Get(id); err == nil { + return nil + } else if err != repo.ErrNotFound { + return err + } + key := svc.networkCorrelation(batch.ServerInstanceID, raw) + attempt := domain.GameAccessAttempt{ID: id, ServerInstanceID: batch.ServerInstanceID, GamePlayerRecordID: player.ID, EventID: entryID(batch.LogStreamID, entry.Seq), OccurredAt: at, Outcome: "rejected", Reason: "login-rejected", NetworkCorrelationKey: key, ExpiresAt: at.Add(gameAccessRetention)} + if err := svc.store.GameAccessAttempts().Create(attempt); err != nil { + return err + } + if key != "" { + return svc.refreshFailedAccessSignal(player, key, at) + } + return nil +} +func (svc *CoreService) refreshFailedAccessSignal(player domain.GamePlayer, key string, at time.Time) error { + attempts, err := svc.store.GameAccessAttempts().List(domain.GameAccessAttemptFilter{ServerInstanceID: player.ServerInstanceID}) + if err != nil { + return err + } + count := 0 + for _, a := range attempts { + if a.NetworkCorrelationKey == key && !a.OccurredAt.Before(at.Add(-failedAccessWindow)) { + count++ + } + } + if count < failedAccessThreshold { + return nil + } + id := "signal-failed-" + fingerprintID(player.ServerInstanceID, key) + s, err := svc.store.GameSecuritySignals().Get(id) + if err == repo.ErrNotFound { + return svc.store.GameSecuritySignals().Create(domain.GameSecuritySignal{ID: id, ServerInstanceID: player.ServerInstanceID, GamePlayerRecordID: player.ID, RuleKey: "excessive-failed-access", Status: domain.GameSecuritySignalReviewRequired, EvidenceCount: count, Summary: "Repeated failed access requires manual review", FirstObservedAt: at, LastObservedAt: at, ExpiresAt: at.Add(gameAccessRetention)}) + } + if err != nil { + return err + } + s.EvidenceCount = count + s.LastObservedAt = at + s.ExpiresAt = at.Add(gameAccessRetention) + return svc.store.GameSecuritySignals().Update(s) +} +func (svc *CoreService) networkCorrelation(serverID, raw string) string { + raw = strings.TrimSpace(raw) + if raw == "" { + return "" + } + mac := hmac.New(sha256.New, svc.networkFingerprintKey) + mac.Write([]byte(serverID)) + mac.Write([]byte{0}) + mac.Write([]byte(raw)) + return hex.EncodeToString(mac.Sum(nil)) +} +func (svc *CoreService) pruneGamePlayerEvidence(serverID string) error { + now := svc.now() + attempts, err := svc.store.GameAccessAttempts().List(domain.GameAccessAttemptFilter{ServerInstanceID: serverID}) + if err != nil { + return err + } + for _, a := range attempts { + if !a.ExpiresAt.After(now) { + if err := svc.store.GameAccessAttempts().Delete(a.ID); err != nil { + return err + } + } + } + signals, err := svc.store.GameSecuritySignals().List(domain.GameSecuritySignalFilter{ServerInstanceID: serverID}) + if err != nil { + return err + } + for _, s := range signals { + if !s.ExpiresAt.After(now) { + if err := svc.store.GameSecuritySignals().Delete(s.ID); err != nil { + return err + } + } + } + return nil +} +func gamePlayerRecordID(server, id string) string { return "game-player-" + fingerprintID(server, id) } +func gamePlayerAliasID(player, name string) string { + return "game-player-alias-" + fingerprintID(player, name) +} +func gamePlayerSessionID(player, session string) string { + return "game-player-session-" + fingerprintID(player, session) +} +func entryID(stream string, seq uint64) string { return stream + "-" + itoa(seq) } +func fingerprintID(a, b string) string { + sum := sha256.Sum256([]byte(a + "\x00" + b)) + return hex.EncodeToString(sum[:])[:24] +} +func itoa(v uint64) string { + return strconv.FormatUint(v, 10) +} +func maxTime(a, b time.Time) time.Time { + if b.After(a) { + return b + } + return a +} +func bounded(v string, n int) string { + v = strings.TrimSpace(v) + if len(v) > n { + return v[:n] + } + return v +} diff --git a/platform/service/game_players_test.go b/platform/service/game_players_test.go new file mode 100644 index 0000000..6e82273 --- /dev/null +++ b/platform/service/game_players_test.go @@ -0,0 +1,119 @@ +package service + +import ( + "browser.local/platform/domain" + "strings" + "testing" + "time" +) + +func TestSCUMGamePlayerProjectionIsIdempotentAndRedactsNetworkMaterial(t *testing.T) { + svc, token := newRegisteredLogIngestService(t) + createLogStreamFixture(t, svc) + base := time.Date(2026, 7, 28, 12, 0, 0, 0, time.UTC) + login := gamePlayerBatch(t, token, 1, []domain.LogEntry{{Seq: 1, Timestamp: base, Line: "login", Fields: map[string]string{"eventType": "scum.login", "playerId": "steam-1", "playerName": "Moon", "sessionId": "session-1", "outcome": "accepted", "networkFingerprint": "203.0.113.9"}}}) + if _, err := svc.IngestLogBatch(login); err != nil { + t.Fatalf("login projection: %v", err) + } + if _, err := svc.IngestLogBatch(login); err != nil { + t.Fatalf("duplicate projection: %v", err) + } + players, err := svc.store.GamePlayers().List(domain.GamePlayerFilter{ServerInstanceID: "server-1"}) + if err != nil || len(players) != 1 { + t.Fatalf("players=%+v err=%v", players, err) + } + sessions, err := svc.store.GamePlayerSessions().List(domain.GamePlayerSessionFilter{GamePlayerRecordID: players[0].ID}) + if err != nil || len(sessions) != 1 { + t.Fatalf("sessions=%+v err=%v", sessions, err) + } + raw, err := svc.QueryLogStream(domain.LogStreamCursorQuery{LogStreamID: "log-1", Limit: 10}) + if err != nil { + t.Fatal(err) + } + if len(raw.Entries) != 1 || strings.Contains(strings.Join(mapValues(raw.Entries[0].Fields), " "), "203.0.113.9") { + t.Fatalf("raw network material leaked into log storage: %+v", raw.Entries) + } + logout := gamePlayerBatch(t, token, 2, []domain.LogEntry{{Seq: 2, Timestamp: base.Add(time.Minute), Line: "logout", Fields: map[string]string{"eventType": "scum.logout", "playerId": "steam-1", "playerName": "Moon Renamed", "sessionId": "session-1", "reason": "disconnect"}}}) + if _, err := svc.IngestLogBatch(logout); err != nil { + t.Fatalf("logout projection: %v", err) + } + aliases, _ := svc.store.GamePlayerAliases().List(domain.GamePlayerAliasFilter{GamePlayerRecordID: players[0].ID}) + sessions, _ = svc.store.GamePlayerSessions().List(domain.GamePlayerSessionFilter{GamePlayerRecordID: players[0].ID}) + if len(aliases) != 2 || len(sessions) != 1 || sessions[0].EndedAt.IsZero() { + t.Fatalf("expected alias history and closed session: aliases=%+v sessions=%+v", aliases, sessions) + } +} + +func TestSCUMRejectedAccessCreatesReviewOnlySignal(t *testing.T) { + svc, token := newRegisteredLogIngestService(t) + createLogStreamFixture(t, svc) + entries := make([]domain.LogEntry, 0, 5) + at := time.Date(2026, 7, 28, 12, 0, 0, 0, time.UTC) + for seq := uint64(1); seq <= 5; seq++ { + entries = append(entries, domain.LogEntry{Seq: seq, Timestamp: at.Add(time.Duration(seq) * time.Minute), Line: "rejected", Fields: map[string]string{"eventType": "scum.login", "playerId": "steam-rejected", "playerName": "Rejected", "sessionId": "failed", "outcome": "rejected", "networkFingerprint": "198.51.100.8"}}) + } + if _, err := svc.IngestLogBatch(gamePlayerBatch(t, token, 1, entries)); err != nil { + t.Fatal(err) + } + signals, err := svc.store.GameSecuritySignals().List(domain.GameSecuritySignalFilter{ServerInstanceID: "server-1"}) + if err != nil || len(signals) != 1 { + t.Fatalf("signals=%+v err=%v", signals, err) + } + if signals[0].Status != domain.GameSecuritySignalReviewRequired || signals[0].EvidenceCount != 5 || strings.Contains(signals[0].Summary, "198.51.100.8") { + t.Fatalf("unsafe signal=%+v", signals[0]) + } +} + +func TestSCUMSharedServerLocalFingerprintSignalsPossibleAltAccount(t *testing.T) { + svc, token := newRegisteredLogIngestService(t) + createLogStreamFixture(t, svc) + at := time.Date(2026, 7, 28, 12, 0, 0, 0, time.UTC) + entries := []domain.LogEntry{{Seq: 1, Timestamp: at, Line: "login", Fields: map[string]string{"eventType": "scum.login", "playerId": "steam-a", "playerName": "A", "sessionId": "a", "outcome": "accepted", "networkFingerprint": "198.51.100.9"}}, {Seq: 2, Timestamp: at.Add(time.Minute), Line: "login", Fields: map[string]string{"eventType": "scum.login", "playerId": "steam-b", "playerName": "B", "sessionId": "b", "outcome": "accepted", "networkFingerprint": "198.51.100.9"}}} + if _, err := svc.IngestLogBatch(gamePlayerBatch(t, token, 1, entries)); err != nil { + t.Fatal(err) + } + signals, err := svc.store.GameSecuritySignals().List(domain.GameSecuritySignalFilter{ServerInstanceID: "server-1"}) + if err != nil || len(signals) != 1 || signals[0].RuleKey != "possible-alt-account" || signals[0].EvidenceCount != 2 { + t.Fatalf("signals=%+v err=%v", signals, err) + } +} + +func TestSCUMGamePlayerProjectionIgnoresOutOfOrderLogoutAndPrunesExpiredEvidence(t *testing.T) { + svc, token := newRegisteredLogIngestService(t) + createLogStreamFixture(t, svc) + now := time.Date(2026, 7, 28, 12, 0, 0, 0, time.UTC) + login := domain.LogEntry{Seq: 1, Timestamp: now, Line: "login", Fields: map[string]string{"eventType": "scum.login", "playerId": "steam-order", "playerName": "Order", "sessionId": "order", "outcome": "accepted"}} + logout := domain.LogEntry{Seq: 2, Timestamp: now.Add(-time.Minute), Line: "logout", Fields: map[string]string{"eventType": "scum.logout", "playerId": "steam-order", "playerName": "Order", "sessionId": "order", "reason": "disconnect"}} + if _, err := svc.IngestLogBatch(gamePlayerBatch(t, token, 1, []domain.LogEntry{login, logout})); err != nil { + t.Fatal(err) + } + players, _ := svc.store.GamePlayers().List(domain.GamePlayerFilter{ServerInstanceID: "server-1"}) + sessions, _ := svc.store.GamePlayerSessions().List(domain.GamePlayerSessionFilter{GamePlayerRecordID: players[0].ID}) + if len(sessions) != 1 || !sessions[0].EndedAt.IsZero() { + t.Fatalf("stale logout closed active session: %+v", sessions) + } + if _, err := svc.ListGamePlayersForSession("", domain.GamePlayerFilter{ServerInstanceID: "server-1"}); err != ErrUnauthorized { + t.Fatalf("expected unauthorized player query, got %v", err) + } + if err := svc.store.GameAccessAttempts().Create(domain.GameAccessAttempt{ID: "expired", ServerInstanceID: "server-1", GamePlayerRecordID: players[0].ID, ExpiresAt: now.Add(-time.Hour)}); err != nil { + t.Fatal(err) + } + svc.now = func() time.Time { return now } + if err := svc.pruneGamePlayerEvidence("server-1"); err != nil { + t.Fatal(err) + } + if _, err := svc.store.GameAccessAttempts().Get("expired"); err == nil { + t.Fatal("expired access evidence was retained") + } +} +func gamePlayerBatch(t *testing.T, token string, first uint64, entries []domain.LogEntry) domain.LogBatchIngest { + t.Helper() + return domain.LogBatchIngest{RunEndpointID: "run-local", SessionToken: token, LogStreamID: "log-1", ServerInstanceID: "server-1", StreamKey: "stdout", Source: domain.LogStreamSourceProcess, FirstSeq: first, LastSeq: entries[len(entries)-1].Seq, Compression: "none", Checksum: checksumForEntries(t, entries), Entries: entries} +} +func mapValues(values map[string]string) []string { + out := make([]string, 0, len(values)) + for _, value := range values { + out = append(out, value) + } + return out +} diff --git a/platform/service/log_ingest.go b/platform/service/log_ingest.go index 2826d5f..08807e7 100644 --- a/platform/service/log_ingest.go +++ b/platform/service/log_ingest.go @@ -9,6 +9,7 @@ const defaultLogQueryLimit = 100 func (svc *CoreService) IngestLogBatch(batch domain.LogBatchIngest) (domain.LogBatchIngestResult, error) { batch = domain.CopyLogBatchIngest(batch) + projectionBatch := domain.CopyLogBatchIngest(batch) if err := validator.ValidateLogBatchIngest(batch); err != nil { return domain.LogBatchIngestResult{}, err } @@ -31,6 +32,9 @@ func (svc *CoreService) IngestLogBatch(batch domain.LogBatchIngest) (domain.LogB return domain.LogBatchIngestResult{}, err } if exists && record.LastSeq == batch.LastSeq && record.Checksum == batch.Checksum { + if err := svc.projectGamePlayerEvents(projectionBatch); err != nil { + return domain.LogBatchIngestResult{}, err + } return domain.LogBatchIngestResult{ Accepted: true, LogStreamID: batch.LogStreamID, @@ -47,11 +51,13 @@ func (svc *CoreService) IngestLogBatch(batch domain.LogBatchIngest) (domain.LogB return domain.LogBatchIngestResult{}, validationError("log batch firstSeq must follow latest acknowledged sequence") } + storedBatch := domain.CopyLogBatchIngest(batch) + sanitizeGamePlayerNetworkFields(&storedBatch) record := domain.CopyLogBatchRecord(domain.LogBatchRecord{ Checksum: batch.Checksum, FirstSeq: batch.FirstSeq, LastSeq: batch.LastSeq, - Entries: batch.Entries, + Entries: storedBatch.Entries, }) if err := svc.logStore.AppendBatch(batch.LogStreamID, record); err != nil { return domain.LogBatchIngestResult{}, err @@ -61,6 +67,9 @@ func (svc *CoreService) IngestLogBatch(batch domain.LogBatchIngest) (domain.LogB if err := svc.store.LogStreams().Update(stream); err != nil { return domain.LogBatchIngestResult{}, err } + if err := svc.projectGamePlayerEvents(projectionBatch); err != nil { + return domain.LogBatchIngestResult{}, err + } return domain.LogBatchIngestResult{ Accepted: true, LogStreamID: batch.LogStreamID, @@ -71,6 +80,21 @@ func (svc *CoreService) IngestLogBatch(batch domain.LogBatchIngest) (domain.LogB }, nil } +// sanitizeGamePlayerNetworkFields removes raw network material before the durable log body is written. +func sanitizeGamePlayerNetworkFields(batch *domain.LogBatchIngest) { + for index := range batch.Entries { + fields := batch.Entries[index].Fields + if fields == nil { + continue + } + if fields["eventType"] == "scum.login" { + delete(fields, "networkFingerprint") + delete(fields, "ip") + delete(fields, "ipAddress") + } + } +} + func (svc *CoreService) QueryLogStream(query domain.LogStreamCursorQuery) (domain.LogStreamCursorResult, error) { if err := validator.ValidateLogStreamCursorQuery(query); err != nil { return domain.LogStreamCursorResult{}, err diff --git a/platform/service/resources.go b/platform/service/resources.go index 5ef2bbb..597ca12 100644 --- a/platform/service/resources.go +++ b/platform/service/resources.go @@ -203,6 +203,8 @@ type Core interface { QueryLogStreamForSession(string, domain.LogStreamCursorQuery) (domain.LogStreamCursorResult, error) IngestLogBatch(domain.LogBatchIngest) (domain.LogBatchIngestResult, error) QueryLogStream(domain.LogStreamCursorQuery) (domain.LogStreamCursorResult, error) + ListGamePlayersForSession(string, domain.GamePlayerFilter) ([]domain.GamePlayer, error) + GetGamePlayerProfileForSession(string, string) (domain.GamePlayerProfile, error) CreateAuditEvent(domain.AuditEvent) (domain.AuditEvent, error) GetAuditEvent(string) (domain.AuditEvent, error) ListAuditEvents(domain.AuditEventFilter) ([]domain.AuditEvent, error) @@ -210,28 +212,29 @@ type Core interface { } type CoreService struct { - store repo.Store - now func() time.Time - authMu sync.Mutex - authSessions map[string]string - controlMu sync.Mutex - runSessions map[string]domain.RunControlSession - runSessionSeq uint64 - jobMu sync.Mutex - bridgeMu sync.Mutex - bridgeSeq uint64 - logStore LogBodyStore - artifactStore ArtifactBodyStore - artifactMu sync.Mutex - artifactTransfers map[string]domain.ArtifactTransferSession - artifactPayloads map[string][]byte - artifactTransferSeq uint64 - auditMu sync.Mutex - auditSeq uint64 - productionMu sync.Mutex - sourceRCONCommands *sourceRCONCommandBroker - aiProviderClient AIProviderClient - secretEnvelope SecretEnvelope + store repo.Store + now func() time.Time + authMu sync.Mutex + authSessions map[string]string + controlMu sync.Mutex + runSessions map[string]domain.RunControlSession + runSessionSeq uint64 + jobMu sync.Mutex + bridgeMu sync.Mutex + bridgeSeq uint64 + logStore LogBodyStore + artifactStore ArtifactBodyStore + artifactMu sync.Mutex + artifactTransfers map[string]domain.ArtifactTransferSession + artifactPayloads map[string][]byte + artifactTransferSeq uint64 + auditMu sync.Mutex + auditSeq uint64 + productionMu sync.Mutex + sourceRCONCommands *sourceRCONCommandBroker + aiProviderClient AIProviderClient + secretEnvelope SecretEnvelope + networkFingerprintKey []byte } var _ Core = (*CoreService)(nil) @@ -254,17 +257,18 @@ func newCoreServiceWithLogStore(store repo.Store, logStore LogBodyStore, now fun } artifactStore := NewMemoryArtifactBodyStore() service := &CoreService{ - store: store, - now: now, - authSessions: map[string]string{}, - runSessions: map[string]domain.RunControlSession{}, - logStore: logStore, - artifactStore: artifactStore, - artifactTransfers: map[string]domain.ArtifactTransferSession{}, - artifactPayloads: map[string][]byte{}, - sourceRCONCommands: newSourceRCONCommandBroker(now), - aiProviderClient: MockAIProviderClient{}, - secretEnvelope: newSecretEnvelope(developmentSecretEnvelopeKey), + store: store, + now: now, + authSessions: map[string]string{}, + runSessions: map[string]domain.RunControlSession{}, + logStore: logStore, + artifactStore: artifactStore, + artifactTransfers: map[string]domain.ArtifactTransferSession{}, + artifactPayloads: map[string][]byte{}, + sourceRCONCommands: newSourceRCONCommandBroker(now), + aiProviderClient: MockAIProviderClient{}, + secretEnvelope: newSecretEnvelope(developmentSecretEnvelopeKey), + networkFingerprintKey: []byte(developmentSecretEnvelopeKey), } return service } diff --git a/platform/service/secret_envelope.go b/platform/service/secret_envelope.go index eb88d2a..a5d55f5 100644 --- a/platform/service/secret_envelope.go +++ b/platform/service/secret_envelope.go @@ -78,6 +78,7 @@ func (svc *CoreService) ConfigureSecretEnvelopeKey(secret string) error { return validationError("PLATFORM_SECRET_ENVELOPE_KEY must be at least 32 characters") } svc.secretEnvelope = newSecretEnvelope(secret) + svc.networkFingerprintKey = []byte(secret) return nil } diff --git a/platform_web/api/client.ts b/platform_web/api/client.ts index 83a1620..90a2eeb 100644 --- a/platform_web/api/client.ts +++ b/platform_web/api/client.ts @@ -48,6 +48,8 @@ import type { GameClientBridgeSnapshotQuery, GameClientBridgeStatusResponse, GamePluginListResponse, + GamePlayerListResponse, + GamePlayerProfileResponse, HealthResponse, JobCreateRequest, JobListResponse, @@ -574,6 +576,15 @@ export class PlatformApiClient { return this.request(`/backups?serverInstanceId=${encodeURIComponent(serverInstanceId)}`); } + async listGamePlayers(serverInstanceId: string, search = ""): Promise { + const query = search ? `?search=${encodeURIComponent(search)}` : ""; + return this.request(`/server-instances/${encodeURIComponent(serverInstanceId)}/game-players${query}`); + } + + async getGamePlayerProfile(serverInstanceId: string, playerId: string): Promise { + return this.request(`/server-instances/${encodeURIComponent(serverInstanceId)}/game-players/${encodeURIComponent(playerId)}`); + } + async getBackup(id: string): Promise { return this.request(`/backups/${encodeURIComponent(id)}`); } diff --git a/platform_web/api/types.ts b/platform_web/api/types.ts index 74b7144..c616a5d 100644 --- a/platform_web/api/types.ts +++ b/platform_web/api/types.ts @@ -297,6 +297,13 @@ export interface RuntimeLogEventResponse { retentionDays: number; severity: "info" | "notice" | "warning" | "critical"; } +export interface GamePlayerResponse { id: string; serverInstanceId: string; gamePlayerId: string; displayName: string; firstSeenAt: string; lastSeenAt: string; } +export interface GamePlayerAliasResponse { alias: string; firstSeenAt: string; lastSeenAt: string; } +export interface GamePlayerSessionResponse { id: string; sourceSessionId: string; startedAt: string; endedAt?: string; endReason?: string; } +export interface GameAccessAttemptResponse { id: string; occurredAt: string; outcome: string; reason: string; } +export interface GameSecuritySignalResponse { id: string; ruleKey: string; status: "open" | "review-required" | "expired"; evidenceCount: number; summary: string; firstObservedAt: string; lastObservedAt: string; } +export interface GamePlayerProfileResponse { player: GamePlayerResponse; aliases: GamePlayerAliasResponse[]; sessions: GamePlayerSessionResponse[]; accessAttempts: GameAccessAttemptResponse[]; securitySignals: GameSecuritySignalResponse[]; } +export interface GamePlayerListResponse { items: GamePlayerResponse[]; } export interface RuntimeTransportProfileResponse { key: string; diff --git a/platform_web/components/GamePlayerIntelligencePanel.test.tsx b/platform_web/components/GamePlayerIntelligencePanel.test.tsx new file mode 100644 index 0000000..88e70f5 --- /dev/null +++ b/platform_web/components/GamePlayerIntelligencePanel.test.tsx @@ -0,0 +1,11 @@ +import { describe, expect, it } from "vitest"; +import source from "./GamePlayerIntelligencePanel.tsx?raw"; + +describe("GamePlayerIntelligencePanel", () => { + it("renders reviewable labels without raw network material or enforcement controls", () => { + expect(source).toContain("需要人工审核"); + expect(source).toContain("失败尝试"); + expect(source).not.toContain("networkFingerprint"); + expect(source).not.toMatch(/\bban\b|\bkick\b/i); + }); +}); diff --git a/platform_web/components/GamePlayerIntelligencePanel.tsx b/platform_web/components/GamePlayerIntelligencePanel.tsx new file mode 100644 index 0000000..f5aaa8b --- /dev/null +++ b/platform_web/components/GamePlayerIntelligencePanel.tsx @@ -0,0 +1,20 @@ +import { RefreshCw, ShieldAlert, UsersRound } from "lucide-react"; +import { useCallback, useEffect, useState } from "react"; + +import { platformApiClient } from "../api/client"; +import type { GamePlayerProfileResponse, GamePlayerResponse } from "../api/types"; +import { ErrorState, LoadingState } from "./StateViews"; + +type State = { status: "loading" } | { status: "error"; reason: string } | { status: "ready"; players: GamePlayerResponse[]; selected?: GamePlayerProfileResponse }; + +export function GamePlayerIntelligencePanel({ serverInstanceId }: { serverInstanceId: string }) { + const [state, setState] = useState({ status: "loading" }); + const load = useCallback(async () => { setState({ status: "loading" }); try { const result = await platformApiClient.listGamePlayers(serverInstanceId); setState({ status: "ready", players: result.items }); } catch (error) { setState({ status: "error", reason: error instanceof Error ? error.message : "玩家档案读取失败。" }); } }, [serverInstanceId]); + useEffect(() => { void load(); }, [load]); + async function select(player: GamePlayerResponse) { try { const selected = await platformApiClient.getGamePlayerProfile(serverInstanceId, player.id); setState((current) => current.status === "ready" ? { ...current, selected } : current); } catch (error) { setState({ status: "error", reason: error instanceof Error ? error.message : "玩家档案读取失败。" }); } } + if (state.status === "loading") return ; + if (state.status === "error") return void load()} />; + const profile = state.selected; + return

本地玩家档案与登录轨迹

游戏身份独立于平台账号;网络数据仅用于服务器内不可逆关联,绝不展示或持久化原始值。

{state.players.length ? state.players.map((player) => ) :

暂无已投影的成功登录记录。

}
{profile &&

{profile.player.displayName} 的审阅记录

别名 {profile.aliases.length} · 会话 {profile.sessions.length}
别名{profile.aliases.map((alias) => alias.alias).join(" / ") || "无"}
{profile.sessions.slice(0, 10).map((session) =>
会话{formatTime(session.startedAt)} → {session.endedAt ? formatTime(session.endedAt) : "仍在线"} {session.endReason ? `(${session.endReason})` : ""}
)}
{profile.securitySignals.length ? profile.securitySignals.map((signal) =>
) :

未发现需要人工审核的风险信号。

}
{profile.accessAttempts.length > 0 &&

失败尝试:{profile.accessAttempts.length} 条(仅显示结果与时间,不显示网络标识)。

}
}
; +} +function formatTime(value: string) { const date = new Date(value); return Number.isNaN(date.valueOf()) ? "--" : date.toLocaleString("zh-CN", { hour12: false }); } diff --git a/platform_web/pages/PluginPageHostPage.tsx b/platform_web/pages/PluginPageHostPage.tsx index 1ae36e8..b3a3e81 100644 --- a/platform_web/pages/PluginPageHostPage.tsx +++ b/platform_web/pages/PluginPageHostPage.tsx @@ -5,6 +5,7 @@ import { platformApiClient } from "../api/client"; import type { GamePluginResponse } from "../api/types"; import { PageFrame } from "../components/PageFrame"; import { ScumFileConfigWorkbench } from "../components/ScumFileConfigWorkbench"; +import { GamePlayerIntelligencePanel } from "../components/GamePlayerIntelligencePanel"; import { EmptyState, ErrorState, LoadingState } from "../components/StateViews"; import type { PageComponentProps } from "../contracts/page"; import { pluginBridgeManifestContractFromResponse } from "../contracts/pluginBridge"; @@ -106,6 +107,7 @@ export function PluginPageHostPage({ params, onNavigate, initialPlugin }: Plugin )} {scumResolution?.available && state.plugin.fileWorkspace && } + {scumResolution?.available && } ); } diff --git a/plugins/examples/scum-server-plugin/manifest.json b/plugins/examples/scum-server-plugin/manifest.json index 7f959df..5e945b6 100644 --- a/plugins/examples/scum-server-plugin/manifest.json +++ b/plugins/examples/scum-server-plugin/manifest.json @@ -347,7 +347,7 @@ "dependencyPolicy": "required", "approvalRequired": ["disable", "rollback", "retire"] }, - "pages": [{ "key": "files-config", "title": "文件与配置", "path": "/files-config", "permissions": ["server.read", "server.files.read", "server.files.write", "server.logs.read", "server.game-client.read", "server.remote.access", "ai.invoke"], "bridgeActions": ["server.instances.read", "files.request", "logs.query", "remote.access.request", "ai.invoke"] }], + "pages": [{ "key": "files-config", "title": "文件、配置与玩家档案", "path": "/files-config", "permissions": ["server.read", "server.files.read", "server.files.write", "server.logs.read", "server.game-client.read", "server.remote.access", "ai.invoke"], "bridgeActions": ["server.instances.read", "files.request", "logs.query", "remote.access.request", "ai.invoke"] }], "fileWorkspace": { "defaultDirectoryKey": "scum-config", "directories": [{ "key": "scum-config", "label": "服务器配置", "scope": "config" }, { "key": "scum-logs", "label": "日志文件", "scope": "logs" }], 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 index 28d8a01..7f8de62 100644 --- 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 @@ -9,6 +9,6 @@ "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 } + "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/tests/manifest-validation.test.ts b/plugins/tests/manifest-validation.test.ts index 66dd0a5..6279fc9 100644 --- a/plugins/tests/manifest-validation.test.ts +++ b/plugins/tests/manifest-validation.test.ts @@ -725,6 +725,9 @@ describe("plugin manifest validation", () => { expect(JSON.stringify(schema).toLowerCase()).not.toMatch(/sqltext|shellcommand|hostpath|rawpath|password|credential|runsocket|directsocket/); visit(schema); } + const login = logEvents.find((event) => event.eventType === "scum.login"); + const loginSchema = JSON.parse(fs.readFileSync(path.join(pluginDir, login?.schemaRef ?? ""), "utf8")) as { properties?: Record> }; + expect(loginSchema.properties?.networkFingerprint).toMatchObject({ type: "string", writeOnly: true }); }); it("rejects unsafe semantic log declarations and missing references", () => {