feat(scum): add local game player intelligence
This commit is contained in:
@@ -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.
|
||||||
+51
@@ -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.
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 }
|
||||||
@@ -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}
|
||||||
|
}
|
||||||
@@ -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" }
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user