2.2 Sessions & Device Registration
Section 2 — Games
2.2 Sessions & Device Registration
Both the session ledger and device tracking live entirely inside Game/Sessions/.
Player login appends a row to the session ledger and creates/updates mutable session state for runtime freshness checks. Devices are registered at login time as a side effect of the auth flow.
Session Event Types
| Event | Trigger |
|---|---|
Login | Successful authentication via any provider |
Logout | Player explicitly logs out |
SessionExpired | Service-level timeout event type; not currently written by match validation |
ForceLogout | Admin action; also used for security-triggered evictions |
TokenRefresh | Defined in code, but the current refresh endpoint does not record this event |
GamePlatform Enum
The platform field on sessions and stored device rows uses the GamePlatform enum.
Unknown exists as a fallback/default value.
Nintendo
| Value | Display Name |
|---|---|
NintendoSwitch | Nintendo Switch |
NintendoSwitchLite | Nintendo Switch Lite |
NintendoSwitchOLED | Nintendo Switch OLED |
PlayStation
| Value | Display Name |
|---|---|
PlayStation4 | PlayStation 4 |
PlayStation4Pro | PlayStation 4 Pro |
PlayStation5 | PlayStation 5 |
PlayStation5Pro | PlayStation 5 Pro |
PlayStationVR | PlayStation VR |
PlayStationVR2 | PlayStation VR2 |
Xbox
| Value | Display Name |
|---|---|
XboxOne | Xbox One |
XboxOneS | Xbox One S |
XboxOneX | Xbox One X |
XboxSeriesS | Xbox Series S |
XboxSeriesX | Xbox Series X |
PC
| Value | Display Name |
|---|---|
PC_Windows | PC (Windows) |
PC_Mac | PC (Mac) |
PC_Linux | PC (Linux) |
PC_SteamDeck | Steam Deck |
Mobile
| Value | Display Name |
|---|---|
Mobile_iOS | iOS |
Mobile_Android | Android |
VR
| Value | Display Name |
|---|---|
MetaQuest2 | Meta Quest 2 |
MetaQuest3 | Meta Quest 3 |
MetaQuestPro | Meta Quest Pro |
ValveIndex | Valve Index |
HTCVive | HTC Vive |
Cloud / Other
| Value | Display Name |
|---|---|
Cloud_GeForceNow | GeForce NOW |
Cloud_XboxCloud | Xbox Cloud Gaming |
Cloud_Luna | Amazon Luna |
Other | Other |
Unknown | Unknown |
2.2.1 Device Registration
What Device Registration Is
When a player logs into a game, the game client can optionally submit a DeviceInfoRequest
alongside its authentication payload. The server uses this to maintain a record of which
physical devices have accessed the game under each player account.
Registration is upsert-based: if a device with the same fingerprint already exists for the player, it is updated. If not, a new record is created. The player never calls a registration endpoint directly — it happens as a side effect of login.
When It Runs
Triggered during every login flow where deviceInfo is included. Runs after
authentication succeeds and before the player JWT is issued.
Login flows that support device registration:
POST /api/player-auth/login(Steam, Epic, Sequence, Mock)POST /api/player-auth/email/signupPOST /api/player-auth/email/tenant-loginPOST /api/player-auth/otc/exchangePOST /api/player-auth/wallet/verify(EVM wallet)
Providing deviceInfo is optional. If omitted, login proceeds normally and the
session row is created with device_id = null.
Blocked Device Check
Before registration completes, the service checks whether the fingerprint matches a device
with is_blocked = true. If so, the login is rejected — the player cannot authenticate
from a blocked device regardless of auth provider.
ClientInfoRequest
| Field | Required | Max Length | Notes |
|---|---|---|---|
platform | Yes | — | GamePlatform enum value |
clientVersion | No | 32 chars | Game client version |
clientBuild | No | 64 chars | Build number or hash |
metadata | No | — | Arbitrary key-value pairs |
DeviceInfoRequest
| Field | Required | Max Length | Notes |
|---|---|---|---|
deviceFingerprint | Yes | 16–256 chars | Hash of hardware identifiers; unique per physical device+install |
hardwareModel | No | 128 chars | Device model name (e.g. "PlayStation 5 Digital Edition") |
osVersion | No | 64 chars | OS version string |
metadata | No | — | Arbitrary key-value pairs |
DeviceName is not part of registration. Players assign a display name later via
PATCH /api/player/devices/{deviceId}.
Upsert Behaviour
| Condition | Result |
|---|---|
| Fingerprint not seen before | New game_player_devices row created |
| Fingerprint matches existing device | last_seen_at updated, login_count incremented, hardware fields updated if changed |
| Fingerprint matches a blocked device | Login rejected — 403 |
first_seen_at is set only on creation and never updated.
What Gets Stored
Table: game_player_devices
| Column | Source |
|---|---|
player_id | Authenticated player |
device_fingerprint | DeviceInfoRequest.DeviceFingerprint |
platform | ClientInfoRequest.Platform, or Unknown if no client info is provided |
hardware_model | DeviceInfoRequest.HardwareModel |
os_version | DeviceInfoRequest.OsVersion |
is_trusted | false on creation; player sets via management endpoints |
is_blocked | false on creation; player sets via management endpoints |
first_seen_at | Set at creation |
last_seen_at | Updated on every login |
login_count | Incremented on every login |
metadata | DeviceInfoRequest.Metadata |
Once registered, the device id is linked to the session row in
game_app_player_login_sessions.device_id.
Device Data Model — Full Schema
Table: game_player_devices (mutable)
| Column | Type | Notes |
|---|---|---|
id | uuid | PK |
player_id | uuid | FK → game_player_profiles.id |
device_fingerprint | text | 16–256 chars; uniqueness enforced per player |
platform | text | GamePlatform enum value |
hardware_model | text? | Device model |
os_version | text? | OS version string |
device_name | text? | Player-assigned display name |
is_trusted | bool | Default false |
is_blocked | bool | Default false; blocks device from logging in |
first_seen_at | timestamptz | Set on first registration |
last_seen_at | timestamptz | Updated on every login from this device |
login_count | int | Incremented on every login from this device |
metadata | jsonb | Arbitrary metadata |
created_at_gl | timestamptz | |
updated_at_gl | timestamptz | |
received_at_db | timestamptz |
Code References
- Service interface:
GamersLabRestAPI/Game/Sessions/Services/IPlayerDeviceService.csGetOrCreateDeviceAsync— registration entry pointIsDeviceBlockedAsync— blocked check at loginRecordDeviceLoginAsync— updates last seen / login count
- Service implementation:
GamersLabRestAPI/Game/Sessions/Services/PlayerDeviceService.cs - Repository:
GamersLabRestAPI/Game/Sessions/Repositories/PlayerDeviceRepository.cs - Device model:
GamersLabRestAPI/Game/Sessions/Models/PlayerDevice.cs - Device management DTOs:
GamersLabRestAPI/Game/Sessions/DTOs/PlayerDeviceDTOs.cs - Login telemetry DTOs:
GamersLabRestAPI.Contracts/Game/Sessions/DTOs/DeviceInfoRequest.cs - Platform enum:
GamersLabRestAPI.Contracts/Game/Sessions/Models/GamePlatform.cs
2.2.2 Session Ledger
What the Session Ledger Is
Current implemented session ledger rows are login and logout-style events. Token refresh has
a service method and enum value, but POST /api/player-auth/refresh does not currently call
it. Nothing in the ledger is ever updated or deleted. It is the authoritative historical
record of persisted player auth activity.
The ledger is separate from the mutable session state (game_player_login_session_state),
which tracks only current status. The ledger is for history and audit; the state table is for
runtime checks.
Two Ledger Tables
Session events are split across two tables by direction:
| Table | Records | Mutable? |
|---|---|---|
game_app_player_login_sessions | Login events | No — append-only |
game_app_player_logout_sessions | Logout, expiry, force-logout events | No — append-only |
Both carry the same session_id — the link connecting a login row to its logout row. A single
logical session can produce a login row and, if explicitly closed, a logout row.
Session ID
session_id is the logical identifier for one player auth session, shared across all ledger
rows that belong to it:
Login event → game_app_player_login_sessions (session_id = X)
Logout event → game_app_player_logout_sessions (session_id = X)session_id is also the PK of the session state row in game_player_login_session_state.
All three tables are joined by it.
Event Types and Which Table They Write To
| Event type | Table | Trigger |
|---|---|---|
Login | game_app_player_login_sessions | Successful authentication via any provider |
Logout | game_app_player_logout_sessions | POST /api/player-auth/logout |
SessionExpired | game_app_player_logout_sessions | Service method exists; not currently called by match validation |
ForceLogout | game_app_player_logout_sessions | Service method exists for admin/security eviction |
Login Ledger — game_app_player_login_sessions
Append-only. Composite PK: (tenant_id, id).
| Column | Type | Notes |
|---|---|---|
tenant_id | uuid | Composite PK |
id | uuid | Composite PK — unique per row |
session_id | uuid | Links this row to the session state and logout rows |
player_id | uuid | FK → game_player_profiles.id |
auth_method_id | uuid? | FK → the auth method used; null if not tracked |
auth_provider | text | Denormalised provider name (steam, epic, email, evm_wallet, etc.) |
device_id | uuid? | FK → game_player_devices.id; null if no device submitted |
platform | text | GamePlatform enum value |
client_version | text? | Game client version string |
client_build | text? | Build identifier |
ip_address | inet? | Caller IP at the time of the event |
occurred_at | timestamptz | Domain time — when the event happened |
created_at_gl | timestamptz | Server time — when the platform received it |
received_at_db | timestamptz | Database time — DEFAULT now() |
metadata | jsonb | Arbitrary key-value pairs; immutable |
row_hash | text | Application-computed integrity hash |
auth_provider is stored denormalised so that queries by provider name do not require a join.
Logout Ledger — game_app_player_logout_sessions
Append-only. Composite PK: (tenant_id, id).
| Column | Type | Notes |
|---|---|---|
tenant_id | uuid | Composite PK |
id | uuid | Composite PK |
session_id | uuid | Links to the originating login event |
player_id | uuid | FK → game_player_profiles.id |
reason | text | logout_reason enum value |
message | text? | Optional free-text context |
occurred_at | timestamptz | Domain time |
created_at_gl | timestamptz | Server receive time |
received_at_db | timestamptz | Database receive time (DEFAULT now()) |
metadata | jsonb | Arbitrary metadata captured at logout time |
Logout Reasons
| Value | Meaning |
|---|---|
user_logout | Player called POST /api/player-auth/logout |
timeout | Session expired (lazily detected) |
token_expired | JWT expired without refresh |
kicked | Admin force-logout |
device_blocked | Login blocked by device block flag |
account_suspended | Player account blocked platform-wide |
network_error | Client-reported network failure |
client_crash | Client-reported crash |
app_close | Client-reported graceful close |
server_shutdown | Server-initiated termination |
unknown | Fallback |
Three Timestamps
Both ledger tables carry three timestamps following the platform's ledger conventions:
| Timestamp | Meaning | Set by |
|---|---|---|
occurred_at | When the event happened in the game world | Application (domain time) |
created_at_gl | When the platform server received and processed it | Application |
received_at_db | When the database row was inserted | Database (DEFAULT now()) |
All three are immutable once written. received_at_db is used as the cursor in paginated
history queries.
Row Hash
row_hash is an application-computed hash of the row's content, written at insert time.
Provides a deduplication fingerprint and an integrity check.
Query Surfaces
Player Session History — cursor-paginated log of login events for a specific player.
SessionHistoryQuery:
| Parameter | Default | Notes |
|---|---|---|
tenantId | — | Optional — omit for cross-tenant history |
cursorTimestamp | — | received_at_db of last item on previous page |
cursorId | — | id of last item on previous page |
pageSize | 50 | Items per page |
SessionHistoryResponse:
sessions[]
id Guid
sessionId Guid
tenantId Guid
authProvider string
platform string
platformDisplayName string
deviceId Guid?
clientVersion string?
clientBuild string?
ipAddress string?
occurredAt DateTime
totalCount int
pageSize int
hasMore bool
nextCursorTimestamp DateTime?
nextCursorId Guid?Tenant Session Analytics — aggregate view across all sessions for a tenant.
SessionAnalyticsResponse:
totalLogins int
totalLogouts int
uniquePlatforms int
uniquePlayers int
loginsByPlatform Dictionary<string, int>
loginsByProvider Dictionary<string, int>Player Session Summary — high-level lifetime summary for a single player across all tenants.
PlayerSessionSummaryResponse:
totalLogins int
totalGamesPlayed int — distinct tenant count
platformsUsed string[]
firstLoginAt DateTime?
lastLoginAt DateTime?
recentSessions[]
tenantId Guid
platform string
platformDisplayName string
clientVersion string?
loginAt DateTimeRelationship to Session State
| Ledger | Session State (game_player_login_session_state) | |
|---|---|---|
| Mutable | No | Yes |
| Purpose | Permanent history and audit | Runtime checks (is this session fresh/active?) |
| Rows per session | Many (one per event) | One (upserted on each event) |
| Used for | History queries, analytics | Match validation and freshness TTL check |
| Deleted | Never | No — but ended_at is set on close |
A login writes a new ledger row and creates the state row. A logout writes a logout ledger row
and sets ended_at on the state row. Token refresh currently rotates tokens only; it does not
write a ledger row or update last_seen_at.
Code References
- Login session model:
GamersLabRestAPI/Game/Sessions/Models/PlayerLoginSession.cs - Logout session model:
GamersLabRestAPI/Game/Sessions/Models/PlayerLogoutSession.cs - Session state model:
GamersLabRestAPI/Game/Sessions/Models/PlayerLoginSessionState.cs - Session event types:
GamersLabRestAPI/Game/Sessions/Models/SessionEventType.cs - Session service:
GamersLabRestAPI/Game/Sessions/Services/PlayerSessionService.csLogLoginAsync,LogLogoutAsync,LogTokenRefreshAsync,LogSessionExpiredAsync,LogForceLogoutAsync- Current refresh endpoint does not call
LogTokenRefreshAsync GetPlayerSessionHistoryAsync,GetTenantSessionHistoryAsync,GetTenantAnalyticsAsync,GetPlayerSessionSummaryAsync
- Session DTOs:
GamersLabRestAPI/Game/Sessions/DTOs/PlayerSessionDTOs.cs - Session repository:
GamersLabRestAPI/Game/Sessions/Repositories/NpgsqlPlayerSessionRepository.cs
2.2.3 Session Heartbeat
What a Player Session Is
When a player authenticates, two things are created:
- An append-only ledger row (
game_app_player_login_sessions) recording the login event permanently. - A mutable session state row (
game_player_login_session_state) tracking whether the session is currently active and when it was last seen.
Logout and force-expiry service methods operate on the state row. The ledger row is never modified. The current refresh endpoint does not update the session state row.
Session Lifetime
A player session has an inactivity window of 2 hours.
The clock is measured from last_seen_at, not from started_at. A session active for 8 hours
is still valid as long as last_seen_at is within the last 2 hours.
Session is considered expired when either:
ended_atis set (explicit logout or server-terminated), orlast_seen_at < now − 2 hours(inactivity timeout)
Note: is_active on the session state model is defined as ended_at == null. A session can
satisfy is_active = true and still be expired by the 2-hour TTL check.
Heartbeat — How Sessions Stay Alive
There is no dedicated heartbeat endpoint. Although LogTokenRefreshAsync can update
last_seen_at, the current refresh endpoint does not call it.
| Action | Updates last_seen_at |
|---|---|
POST /api/player-auth/refresh | No — rotates tokens only in the current implementation |
POST /api/player-auth/logout | Yes — set to logout time, then ended_at is set |
| Match create / join (session check) | No — validated but not extended |
| Event writes | No |
If the cached session state is older than 2 hours, the player must re-authenticate before session-sensitive writes can succeed.
Expiry Detection — Lazy, Not Scheduled
There is no background service that auto-expires sessions. Expiry is detected lazily the next time a session-sensitive write is attempted.
When expiry is detected:
- Create-match returns per-player
410 Gonevalidation errors. - Join currently maps the same expired-session validation to
409 Conflict. - The current match validation path does not append a
SessionExpiredlogout row or setended_at.
Until that lazy check triggers, an expired session row sits with ended_at = null and a
stale last_seen_at.
What Checks Session Validity
Not all write operations care whether a player session is active.
Player JWT operations — verify the JWT signature and expiry. The JWT has a 2-hour TTL.
Game write operations (X-Game-Key + bearer token) — session validity only checked where semantically meaningful:
| Operation | Session validity checked? | Notes |
|---|---|---|
| Match create | Yes | Each player's last_seen_at must be within 2 hours |
| Match join | Yes | Same check — currently returned as 409 if session expired |
| Match leave | No | Self-only write; no per-player session re-validation |
| Match end | No | Bearer auth required; match can end after players' sessions expire |
| Match results | No | Self-only write; written after the match concludes |
| Event records (match-scoped) | No | login_session_id stored but nullable; not re-validated |
| Player event records | No | Same |
A player must have an active session to be accepted into a match. Once in, the match can progress and conclude — and all associated events and results can be written — even if that player's session later expires mid-game. A network disruption or client crash should not invalidate in-progress match data.
Session State Data Model
Table: game_player_login_session_state (mutable)
| Column | Type | Notes |
|---|---|---|
tenant_id | uuid | Composite PK |
session_id | uuid | Composite PK |
player_id | uuid | FK → game_player_profiles.id |
started_at | timestamptz | Set at login; never updated |
ended_at | timestamptz? | Set on logout or by force-expiry service methods; current match validation does not set it |
last_seen_at | timestamptz | Set at login; drives the 2-hour TTL check |
auth_provider | text | Provider used at login |
device_id | uuid? | Device registered at login, if provided |
platform | text | GamePlatform enum value |
client_version | text? | Game client version at login |
last_ip_address | inet? | Set/updated by session-state writes; not updated by the current refresh endpoint |
updated_at_db | timestamptz | DB row update timestamp |
Known Limitations
Active session count / concurrent session policy (TODO): There is currently no enforcement of a maximum concurrent session count per player. A player can log in from multiple devices and maintain multiple active sessions simultaneously. A future per-tenant policy could enforce single-session mode or a maximum count.
Proactive expiry (TODO): Expired sessions are not closed automatically. No background
service scans for sessions where last_seen_at < now - 2 hours and sets ended_at. Current
match validation also does not close the session on expiry. This means:
- Session analytics may count sessions as "active" past their actual expiry time
ended_atis null for sessions that expired without explicit logout
Code References
- Session state model:
GamersLabRestAPI/Game/Sessions/Models/PlayerLoginSessionState.cs - Session service:
GamersLabRestAPI/Game/Sessions/Services/PlayerSessionService.csLogTokenRefreshAsync— can updatelast_seen_at, but is not called by the refresh endpointLogSessionExpiredAsync— can write a timeout logout event, but is not called by match validationLogLogoutAsync— writes user_logout event
- Session repository:
GamersLabRestAPI/Game/Sessions/Repositories/NpgsqlPlayerSessionRepository.csUpdateLastSeenAsync— session-state freshness update helperEndSessionAsync— setsended_at
- Match session validation:
GamersLabRestAPI/Game/Matches/Services/MatchService.csLoginSessionTtlconstant —TimeSpan.FromHours(2)ValidateMatchPlayer— applies session cutoff check; returns 410 if expired
- Player login/refresh controller:
GamersLabRestAPI/PlayerAuth/Controllers/PlayerLoginController.cs