feat(scum): add local game player intelligence

This commit is contained in:
npc0-hue
2026-07-28 15:23:09 +08:00
parent c5806cc6b7
commit 73947edcee
26 changed files with 1209 additions and 65 deletions
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-07-28
@@ -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.
@@ -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.
@@ -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
@@ -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.
+57
View File
@@ -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))
}
+2
View File
@@ -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}/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/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-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/check", h.serverDependenciesCheck)
mux.HandleFunc("/api/v1/server-instances/{id}/dependencies/install", h.serverDependenciesInstall) mux.HandleFunc("/api/v1/server-instances/{id}/dependencies/install", h.serverDependenciesInstall)
mux.HandleFunc("/api/v1/server-instances/{id}/dependencies", h.serverDependencies) mux.HandleFunc("/api/v1/server-instances/{id}/dependencies", h.serverDependencies)
+111
View File
@@ -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 }
+82
View File
@@ -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}
}
+72
View File
@@ -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" }
+26
View File
@@ -42,6 +42,11 @@ type StoreSnapshot struct {
GameClientBridgeCommands []domain.GameClientBridgeCommand `json:"gameClientBridgeCommands"` GameClientBridgeCommands []domain.GameClientBridgeCommand `json:"gameClientBridgeCommands"`
GameClientBridgeSnapshots []domain.GameClientBridgeSnapshot `json:"gameClientBridgeSnapshots"` GameClientBridgeSnapshots []domain.GameClientBridgeSnapshot `json:"gameClientBridgeSnapshots"`
GameClientBridgeStreams []domain.GameClientBridgeSnapshotStream `json:"gameClientBridgeStreams"` 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 { type FileStore struct {
@@ -190,6 +195,21 @@ func (store *FileStore) GameClientBridgeSnapshots() GameClientBridgeSnapshotRepo
func (store *FileStore) GameClientBridgeSnapshotStreams() GameClientBridgeSnapshotStreamRepository { func (store *FileStore) GameClientBridgeSnapshotStreams() GameClientBridgeSnapshotStreamRepository {
return &persistentRepository[domain.GameClientBridgeSnapshotStream, domain.GameClientBridgeSnapshotStreamFilter]{repository: store.MemoryStore.bridgeStreams, persist: store.persist} 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 { func (store *FileStore) load() error {
data, err := os.ReadFile(store.path) data, err := os.ReadFile(store.path)
@@ -263,6 +283,7 @@ func (store *FileStore) snapshot() StoreSnapshot {
GameClientBridgeCommands: snapshotRepository(store.MemoryStore.bridgeCommands.memoryRepository), GameClientBridgeCommands: snapshotRepository(store.MemoryStore.bridgeCommands.memoryRepository),
GameClientBridgeSnapshots: snapshotRepository(store.MemoryStore.bridgeSnapshots.memoryRepository), GameClientBridgeSnapshots: snapshotRepository(store.MemoryStore.bridgeSnapshots.memoryRepository),
GameClientBridgeStreams: snapshotRepository(store.MemoryStore.bridgeStreams), 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.bridgeCommands.memoryRepository, snapshot.GameClientBridgeCommands)
loadRepository(store.MemoryStore.bridgeSnapshots.memoryRepository, snapshot.GameClientBridgeSnapshots) loadRepository(store.MemoryStore.bridgeSnapshots.memoryRepository, snapshot.GameClientBridgeSnapshots)
loadRepository(store.MemoryStore.bridgeStreams, snapshot.GameClientBridgeStreams) 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 { type mutableRepository[T any, F any] interface {
+21
View File
@@ -171,6 +171,21 @@ func (store *MySQLStore) GameClientBridgeSnapshots() GameClientBridgeSnapshotRep
func (store *MySQLStore) GameClientBridgeSnapshotStreams() GameClientBridgeSnapshotStreamRepository { func (store *MySQLStore) GameClientBridgeSnapshotStreams() GameClientBridgeSnapshotStreamRepository {
return &persistentRepository[domain.GameClientBridgeSnapshotStream, domain.GameClientBridgeSnapshotStreamFilter]{repository: store.MemoryStore.bridgeStreams, persist: store.persist} 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 { func (store *MySQLStore) initialize() error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
@@ -261,6 +276,7 @@ func (store *MySQLStore) snapshot() StoreSnapshot {
GameClientBridgeCommands: snapshotRepository(store.MemoryStore.bridgeCommands.memoryRepository), GameClientBridgeCommands: snapshotRepository(store.MemoryStore.bridgeCommands.memoryRepository),
GameClientBridgeSnapshots: snapshotRepository(store.MemoryStore.bridgeSnapshots.memoryRepository), GameClientBridgeSnapshots: snapshotRepository(store.MemoryStore.bridgeSnapshots.memoryRepository),
GameClientBridgeStreams: snapshotRepository(store.MemoryStore.bridgeStreams), 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.bridgeCommands.memoryRepository, snapshot.GameClientBridgeCommands)
loadRepository(store.MemoryStore.bridgeSnapshots.memoryRepository, snapshot.GameClientBridgeSnapshots) loadRepository(store.MemoryStore.bridgeSnapshots.memoryRepository, snapshot.GameClientBridgeSnapshots)
loadRepository(store.MemoryStore.bridgeStreams, snapshot.GameClientBridgeStreams) 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)
} }
+109 -29
View File
@@ -3,6 +3,7 @@ package repo
import ( import (
"errors" "errors"
"sort" "sort"
"strings"
"sync" "sync"
"browser.local/platform/domain" "browser.local/platform/domain"
@@ -224,6 +225,41 @@ type GameClientBridgeSnapshotStreamRepository interface {
Delete(id string) error 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 { type Store interface {
Users() UserRepository Users() UserRepository
AuthSessions() AuthSessionRepository AuthSessions() AuthSessionRepository
@@ -254,38 +290,48 @@ type Store interface {
GameClientBridgeCommands() GameClientBridgeCommandRepository GameClientBridgeCommands() GameClientBridgeCommandRepository
GameClientBridgeSnapshots() GameClientBridgeSnapshotRepository GameClientBridgeSnapshots() GameClientBridgeSnapshotRepository
GameClientBridgeSnapshotStreams() GameClientBridgeSnapshotStreamRepository GameClientBridgeSnapshotStreams() GameClientBridgeSnapshotStreamRepository
GamePlayers() GamePlayerRepository
GamePlayerAliases() GamePlayerAliasRepository
GamePlayerSessions() GamePlayerSessionRepository
GameAccessAttempts() GameAccessAttemptRepository
GameSecuritySignals() GameSecuritySignalRepository
} }
type MemoryStore struct { type MemoryStore struct {
users *memoryRepository[domain.User, domain.UserFilter] users *memoryRepository[domain.User, domain.UserFilter]
authSessions *memoryRepository[domain.AuthSessionRecord, domain.AuthSessionFilter] authSessions *memoryRepository[domain.AuthSessionRecord, domain.AuthSessionFilter]
runSessions *memoryRepository[domain.RunControlSession, struct{}] runSessions *memoryRepository[domain.RunControlSession, struct{}]
aiProviders *memoryRepository[domain.AIProvider, domain.AIProviderFilter] aiProviders *memoryRepository[domain.AIProvider, domain.AIProviderFilter]
gamePlugins *memoryRepository[domain.GamePlugin, domain.GamePluginFilter] gamePlugins *memoryRepository[domain.GamePlugin, domain.GamePluginFilter]
serverInstances *memoryRepository[domain.ServerInstance, domain.ServerInstanceFilter] serverInstances *memoryRepository[domain.ServerInstance, domain.ServerInstanceFilter]
runEndpoints *memoryRepository[domain.RunEndpoint, domain.RunEndpointFilter] runEndpoints *memoryRepository[domain.RunEndpoint, domain.RunEndpointFilter]
jobs *memoryJobRepository jobs *memoryJobRepository
artifacts *memoryRepository[domain.Artifact, domain.ArtifactFilter] artifacts *memoryRepository[domain.Artifact, domain.ArtifactFilter]
runtimeBindings *memoryRepository[domain.RuntimeBinding, domain.RuntimeBindingFilter] runtimeBindings *memoryRepository[domain.RuntimeBinding, domain.RuntimeBindingFilter]
componentKeys *memoryRepository[domain.EncryptedComponentKey, domain.EncryptedComponentKeyFilter] componentKeys *memoryRepository[domain.EncryptedComponentKey, domain.EncryptedComponentKeyFilter]
runDists *memoryRepository[domain.RunDistribution, domain.RunDistributionFilter] runDists *memoryRepository[domain.RunDistribution, domain.RunDistributionFilter]
clientDists *memoryRepository[domain.ClientManagerDistribution, domain.ClientManagerDistributionFilter] clientDists *memoryRepository[domain.ClientManagerDistribution, domain.ClientManagerDistributionFilter]
clientInstalls *memoryRepository[domain.ClientManagerInstallation, domain.ClientManagerInstallationFilter] clientInstalls *memoryRepository[domain.ClientManagerInstallation, domain.ClientManagerInstallationFilter]
clientSessions *memoryRepository[domain.ClientManagerSession, domain.ClientManagerSessionFilter] clientSessions *memoryRepository[domain.ClientManagerSession, domain.ClientManagerSessionFilter]
clientNonces *memoryRepository[domain.ClientManagerRegistrationNonce, domain.ClientManagerNonceFilter] clientNonces *memoryRepository[domain.ClientManagerRegistrationNonce, domain.ClientManagerNonceFilter]
dependencies *memoryRepository[domain.DependencyStatus, domain.DependencyStatusFilter] dependencies *memoryRepository[domain.DependencyStatus, domain.DependencyStatusFilter]
buildJobs *memoryRepository[domain.ClientManagerBuildJob, domain.ClientManagerBuildJobFilter] buildJobs *memoryRepository[domain.ClientManagerBuildJob, domain.ClientManagerBuildJobFilter]
updateJobs *memoryRepository[domain.RunUpdateJob, domain.RunUpdateJobFilter] updateJobs *memoryRepository[domain.RunUpdateJob, domain.RunUpdateJobFilter]
logStreams *memoryRepository[domain.LogStream, domain.LogStreamFilter] logStreams *memoryRepository[domain.LogStream, domain.LogStreamFilter]
auditEvents *memoryRepository[domain.AuditEvent, domain.AuditEventFilter] auditEvents *memoryRepository[domain.AuditEvent, domain.AuditEventFilter]
metricSamples *memoryRepository[domain.MetricSample, domain.MetricSampleFilter] metricSamples *memoryRepository[domain.MetricSample, domain.MetricSampleFilter]
backups *memoryRepository[domain.BackupRecord, domain.BackupFilter] backups *memoryRepository[domain.BackupRecord, domain.BackupFilter]
alerts *memoryRepository[domain.AlertRecord, domain.AlertFilter] alerts *memoryRepository[domain.AlertRecord, domain.AlertFilter]
pluginLifecycle *memoryRepository[domain.PluginLifecycleInstallation, domain.PluginLifecycleFilter] pluginLifecycle *memoryRepository[domain.PluginLifecycleInstallation, domain.PluginLifecycleFilter]
aiConfigDiffs *memoryRepository[domain.AIConfigDiffPreview, domain.AIConfigDiffFilter] aiConfigDiffs *memoryRepository[domain.AIConfigDiffPreview, domain.AIConfigDiffFilter]
bridgeCommands *memoryGameClientBridgeCommandRepository bridgeCommands *memoryGameClientBridgeCommandRepository
bridgeSnapshots *memoryGameClientBridgeSnapshotRepository bridgeSnapshots *memoryGameClientBridgeSnapshotRepository
bridgeStreams *memoryRepository[domain.GameClientBridgeSnapshotStream, domain.GameClientBridgeSnapshotStreamFilter] 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 { func NewMemoryStore() *MemoryStore {
@@ -423,6 +469,11 @@ func NewMemoryStore() *MemoryStore {
domain.CopyGameClientBridgeSnapshotStream, domain.CopyGameClientBridgeSnapshotStream,
matchGameClientBridgeSnapshotStream, 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 { func (store *MemoryStore) GameClientBridgeSnapshotStreams() GameClientBridgeSnapshotStreamRepository {
return store.bridgeStreams 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 { type memoryRepository[T any, F any] struct {
mu sync.RWMutex mu sync.RWMutex
@@ -775,3 +839,19 @@ func matchGameClientBridgeSnapshotStream(stream domain.GameClientBridgeSnapshotS
(filter.Type == "" || stream.Type == filter.Type) && (filter.Type == "" || stream.Type == filter.Type) &&
(filter.StreamKey == "" || stream.StreamKey == filter.StreamKey) (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)
}
+337
View File
@@ -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
}
+119
View File
@@ -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
}
+25 -1
View File
@@ -9,6 +9,7 @@ const defaultLogQueryLimit = 100
func (svc *CoreService) IngestLogBatch(batch domain.LogBatchIngest) (domain.LogBatchIngestResult, error) { func (svc *CoreService) IngestLogBatch(batch domain.LogBatchIngest) (domain.LogBatchIngestResult, error) {
batch = domain.CopyLogBatchIngest(batch) batch = domain.CopyLogBatchIngest(batch)
projectionBatch := domain.CopyLogBatchIngest(batch)
if err := validator.ValidateLogBatchIngest(batch); err != nil { if err := validator.ValidateLogBatchIngest(batch); err != nil {
return domain.LogBatchIngestResult{}, err return domain.LogBatchIngestResult{}, err
} }
@@ -31,6 +32,9 @@ func (svc *CoreService) IngestLogBatch(batch domain.LogBatchIngest) (domain.LogB
return domain.LogBatchIngestResult{}, err return domain.LogBatchIngestResult{}, err
} }
if exists && record.LastSeq == batch.LastSeq && record.Checksum == batch.Checksum { if exists && record.LastSeq == batch.LastSeq && record.Checksum == batch.Checksum {
if err := svc.projectGamePlayerEvents(projectionBatch); err != nil {
return domain.LogBatchIngestResult{}, err
}
return domain.LogBatchIngestResult{ return domain.LogBatchIngestResult{
Accepted: true, Accepted: true,
LogStreamID: batch.LogStreamID, 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") return domain.LogBatchIngestResult{}, validationError("log batch firstSeq must follow latest acknowledged sequence")
} }
storedBatch := domain.CopyLogBatchIngest(batch)
sanitizeGamePlayerNetworkFields(&storedBatch)
record := domain.CopyLogBatchRecord(domain.LogBatchRecord{ record := domain.CopyLogBatchRecord(domain.LogBatchRecord{
Checksum: batch.Checksum, Checksum: batch.Checksum,
FirstSeq: batch.FirstSeq, FirstSeq: batch.FirstSeq,
LastSeq: batch.LastSeq, LastSeq: batch.LastSeq,
Entries: batch.Entries, Entries: storedBatch.Entries,
}) })
if err := svc.logStore.AppendBatch(batch.LogStreamID, record); err != nil { if err := svc.logStore.AppendBatch(batch.LogStreamID, record); err != nil {
return domain.LogBatchIngestResult{}, err 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 { if err := svc.store.LogStreams().Update(stream); err != nil {
return domain.LogBatchIngestResult{}, err return domain.LogBatchIngestResult{}, err
} }
if err := svc.projectGamePlayerEvents(projectionBatch); err != nil {
return domain.LogBatchIngestResult{}, err
}
return domain.LogBatchIngestResult{ return domain.LogBatchIngestResult{
Accepted: true, Accepted: true,
LogStreamID: batch.LogStreamID, LogStreamID: batch.LogStreamID,
@@ -71,6 +80,21 @@ func (svc *CoreService) IngestLogBatch(batch domain.LogBatchIngest) (domain.LogB
}, nil }, 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) { func (svc *CoreService) QueryLogStream(query domain.LogStreamCursorQuery) (domain.LogStreamCursorResult, error) {
if err := validator.ValidateLogStreamCursorQuery(query); err != nil { if err := validator.ValidateLogStreamCursorQuery(query); err != nil {
return domain.LogStreamCursorResult{}, err return domain.LogStreamCursorResult{}, err
+37 -33
View File
@@ -203,6 +203,8 @@ type Core interface {
QueryLogStreamForSession(string, domain.LogStreamCursorQuery) (domain.LogStreamCursorResult, error) QueryLogStreamForSession(string, domain.LogStreamCursorQuery) (domain.LogStreamCursorResult, error)
IngestLogBatch(domain.LogBatchIngest) (domain.LogBatchIngestResult, error) IngestLogBatch(domain.LogBatchIngest) (domain.LogBatchIngestResult, error)
QueryLogStream(domain.LogStreamCursorQuery) (domain.LogStreamCursorResult, 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) CreateAuditEvent(domain.AuditEvent) (domain.AuditEvent, error)
GetAuditEvent(string) (domain.AuditEvent, error) GetAuditEvent(string) (domain.AuditEvent, error)
ListAuditEvents(domain.AuditEventFilter) ([]domain.AuditEvent, error) ListAuditEvents(domain.AuditEventFilter) ([]domain.AuditEvent, error)
@@ -210,28 +212,29 @@ type Core interface {
} }
type CoreService struct { type CoreService struct {
store repo.Store store repo.Store
now func() time.Time now func() time.Time
authMu sync.Mutex authMu sync.Mutex
authSessions map[string]string authSessions map[string]string
controlMu sync.Mutex controlMu sync.Mutex
runSessions map[string]domain.RunControlSession runSessions map[string]domain.RunControlSession
runSessionSeq uint64 runSessionSeq uint64
jobMu sync.Mutex jobMu sync.Mutex
bridgeMu sync.Mutex bridgeMu sync.Mutex
bridgeSeq uint64 bridgeSeq uint64
logStore LogBodyStore logStore LogBodyStore
artifactStore ArtifactBodyStore artifactStore ArtifactBodyStore
artifactMu sync.Mutex artifactMu sync.Mutex
artifactTransfers map[string]domain.ArtifactTransferSession artifactTransfers map[string]domain.ArtifactTransferSession
artifactPayloads map[string][]byte artifactPayloads map[string][]byte
artifactTransferSeq uint64 artifactTransferSeq uint64
auditMu sync.Mutex auditMu sync.Mutex
auditSeq uint64 auditSeq uint64
productionMu sync.Mutex productionMu sync.Mutex
sourceRCONCommands *sourceRCONCommandBroker sourceRCONCommands *sourceRCONCommandBroker
aiProviderClient AIProviderClient aiProviderClient AIProviderClient
secretEnvelope SecretEnvelope secretEnvelope SecretEnvelope
networkFingerprintKey []byte
} }
var _ Core = (*CoreService)(nil) var _ Core = (*CoreService)(nil)
@@ -254,17 +257,18 @@ func newCoreServiceWithLogStore(store repo.Store, logStore LogBodyStore, now fun
} }
artifactStore := NewMemoryArtifactBodyStore() artifactStore := NewMemoryArtifactBodyStore()
service := &CoreService{ service := &CoreService{
store: store, store: store,
now: now, now: now,
authSessions: map[string]string{}, authSessions: map[string]string{},
runSessions: map[string]domain.RunControlSession{}, runSessions: map[string]domain.RunControlSession{},
logStore: logStore, logStore: logStore,
artifactStore: artifactStore, artifactStore: artifactStore,
artifactTransfers: map[string]domain.ArtifactTransferSession{}, artifactTransfers: map[string]domain.ArtifactTransferSession{},
artifactPayloads: map[string][]byte{}, artifactPayloads: map[string][]byte{},
sourceRCONCommands: newSourceRCONCommandBroker(now), sourceRCONCommands: newSourceRCONCommandBroker(now),
aiProviderClient: MockAIProviderClient{}, aiProviderClient: MockAIProviderClient{},
secretEnvelope: newSecretEnvelope(developmentSecretEnvelopeKey), secretEnvelope: newSecretEnvelope(developmentSecretEnvelopeKey),
networkFingerprintKey: []byte(developmentSecretEnvelopeKey),
} }
return service return service
} }
+1
View File
@@ -78,6 +78,7 @@ func (svc *CoreService) ConfigureSecretEnvelopeKey(secret string) error {
return validationError("PLATFORM_SECRET_ENVELOPE_KEY must be at least 32 characters") return validationError("PLATFORM_SECRET_ENVELOPE_KEY must be at least 32 characters")
} }
svc.secretEnvelope = newSecretEnvelope(secret) svc.secretEnvelope = newSecretEnvelope(secret)
svc.networkFingerprintKey = []byte(secret)
return nil return nil
} }
+11
View File
@@ -48,6 +48,8 @@ import type {
GameClientBridgeSnapshotQuery, GameClientBridgeSnapshotQuery,
GameClientBridgeStatusResponse, GameClientBridgeStatusResponse,
GamePluginListResponse, GamePluginListResponse,
GamePlayerListResponse,
GamePlayerProfileResponse,
HealthResponse, HealthResponse,
JobCreateRequest, JobCreateRequest,
JobListResponse, JobListResponse,
@@ -574,6 +576,15 @@ export class PlatformApiClient {
return this.request<BackupListResponse>(`/backups?serverInstanceId=${encodeURIComponent(serverInstanceId)}`); return this.request<BackupListResponse>(`/backups?serverInstanceId=${encodeURIComponent(serverInstanceId)}`);
} }
async listGamePlayers(serverInstanceId: string, search = ""): Promise<GamePlayerListResponse> {
const query = search ? `?search=${encodeURIComponent(search)}` : "";
return this.request<GamePlayerListResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/game-players${query}`);
}
async getGamePlayerProfile(serverInstanceId: string, playerId: string): Promise<GamePlayerProfileResponse> {
return this.request<GamePlayerProfileResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/game-players/${encodeURIComponent(playerId)}`);
}
async getBackup(id: string): Promise<BackupResponse> { async getBackup(id: string): Promise<BackupResponse> {
return this.request<BackupResponse>(`/backups/${encodeURIComponent(id)}`); return this.request<BackupResponse>(`/backups/${encodeURIComponent(id)}`);
} }
+7
View File
@@ -297,6 +297,13 @@ export interface RuntimeLogEventResponse {
retentionDays: number; retentionDays: number;
severity: "info" | "notice" | "warning" | "critical"; 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 { export interface RuntimeTransportProfileResponse {
key: string; key: string;
@@ -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);
});
});
@@ -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<State>({ 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 <LoadingState label="正在加载本地游戏玩家档案…" />;
if (state.status === "error") return <ErrorState title="玩家档案不可用" reason={state.reason} onRetry={() => void load()} />;
const profile = state.selected;
return <section className="console-panel" aria-label="SCUM 本地玩家档案与风险信号"><div className="panel-header"><div><h2><UsersRound size={16} /> </h2><p className="provider-id"></p></div><button type="button" className="icon-command" onClick={() => void load()}><RefreshCw size={14} /><span></span></button></div><div className="console-record-list">{state.players.length ? state.players.map((player) => <button type="button" className="console-record" key={player.id} onClick={() => void select(player)}><strong>{player.displayName}</strong><span> ID{player.gamePlayerId}</span><small>{formatTime(player.lastSeenAt)}</small></button>) : <p className="page-status"></p>}</div>{profile && <div className="console-module"><div className="panel-header"><h3>{profile.player.displayName} </h3><span className="page-status"> {profile.aliases.length} · {profile.sessions.length}</span></div><div className="console-row-list"><div className="console-row"><strong></strong><span>{profile.aliases.map((alias) => alias.alias).join(" / ") || "无"}</span></div>{profile.sessions.slice(0, 10).map((session) => <div className="console-row" key={session.id}><strong></strong><span>{formatTime(session.startedAt)} {session.endedAt ? formatTime(session.endedAt) : "仍在线"} {session.endReason ? `(${session.endReason})` : ""}</span></div>)}</div><div className="console-record-list">{profile.securitySignals.length ? profile.securitySignals.map((signal) => <div className="console-record" key={signal.id}><ShieldAlert size={15} aria-hidden="true" /><strong>{signal.ruleKey}</strong><span>{signal.status === "review-required" ? "需要人工审核" : signal.status}</span><small>{signal.summary} · {signal.evidenceCount} </small></div>) : <p className="page-status"></p>}</div>{profile.accessAttempts.length > 0 && <p className="page-status">{profile.accessAttempts.length} </p>}</div>}</section>;
}
function formatTime(value: string) { const date = new Date(value); return Number.isNaN(date.valueOf()) ? "--" : date.toLocaleString("zh-CN", { hour12: false }); }
@@ -5,6 +5,7 @@ import { platformApiClient } from "../api/client";
import type { GamePluginResponse } from "../api/types"; import type { GamePluginResponse } from "../api/types";
import { PageFrame } from "../components/PageFrame"; import { PageFrame } from "../components/PageFrame";
import { ScumFileConfigWorkbench } from "../components/ScumFileConfigWorkbench"; import { ScumFileConfigWorkbench } from "../components/ScumFileConfigWorkbench";
import { GamePlayerIntelligencePanel } from "../components/GamePlayerIntelligencePanel";
import { EmptyState, ErrorState, LoadingState } from "../components/StateViews"; import { EmptyState, ErrorState, LoadingState } from "../components/StateViews";
import type { PageComponentProps } from "../contracts/page"; import type { PageComponentProps } from "../contracts/page";
import { pluginBridgeManifestContractFromResponse } from "../contracts/pluginBridge"; import { pluginBridgeManifestContractFromResponse } from "../contracts/pluginBridge";
@@ -106,6 +107,7 @@ export function PluginPageHostPage({ params, onNavigate, initialPlugin }: Plugin
)} )}
</section> </section>
{scumResolution?.available && state.plugin.fileWorkspace && <ScumFileConfigWorkbench contract={scumResolution.contract} workspace={state.plugin.fileWorkspace} />} {scumResolution?.available && state.plugin.fileWorkspace && <ScumFileConfigWorkbench contract={scumResolution.contract} workspace={state.plugin.fileWorkspace} />}
{scumResolution?.available && <GamePlayerIntelligencePanel serverInstanceId={serverId} />}
</div> </div>
); );
} }
@@ -347,7 +347,7 @@
"dependencyPolicy": "required", "dependencyPolicy": "required",
"approvalRequired": ["disable", "rollback", "retire"] "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": { "fileWorkspace": {
"defaultDirectoryKey": "scum-config", "defaultDirectoryKey": "scum-config",
"directories": [{ "key": "scum-config", "label": "服务器配置", "scope": "config" }, { "key": "scum-logs", "label": "日志文件", "scope": "logs" }], "directories": [{ "key": "scum-config", "label": "服务器配置", "scope": "config" }, { "key": "scum-logs", "label": "日志文件", "scope": "logs" }],
@@ -9,6 +9,6 @@
"playerName": { "type": "string", "minLength": 1, "maxLength": 80 }, "playerName": { "type": "string", "minLength": 1, "maxLength": 80 },
"sessionId": { "type": "string", "minLength": 1, "maxLength": 96 }, "sessionId": { "type": "string", "minLength": 1, "maxLength": 96 },
"outcome": { "type": "string", "enum": ["accepted", "rejected"] }, "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." }
} }
} }
@@ -725,6 +725,9 @@ describe("plugin manifest validation", () => {
expect(JSON.stringify(schema).toLowerCase()).not.toMatch(/sqltext|shellcommand|hostpath|rawpath|password|credential|runsocket|directsocket/); expect(JSON.stringify(schema).toLowerCase()).not.toMatch(/sqltext|shellcommand|hostpath|rawpath|password|credential|runsocket|directsocket/);
visit(schema); 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<string, Record<string, unknown>> };
expect(loginSchema.properties?.networkFingerprint).toMatchObject({ type: "string", writeOnly: true });
}); });
it("rejects unsafe semantic log declarations and missing references", () => { it("rejects unsafe semantic log declarations and missing references", () => {