3.2 Player Profiles
Section 3 — Player Management
3.2 Player Profiles
Scope
Defines who can access player profile endpoints, what credential is required, and what data is
returned for each caller type and visibility setting. Covers both self-management endpoints
(/api/player-profile) and external lookup endpoints (/api/player-profiles).
Caller Types
| Caller | Credential | Where it comes from |
|---|---|---|
| Player (self) | Authorization: Bearer <player_jwt> | Issued by any player auth login flow |
| Game server | X-Game-Key: <key> | Issued per tenant, write-capable, tenant-scoped |
| Dashboard / third-party app | X-API-Key: <key> | Issued per tenant, read-only, must have allow_data_api flag |
| Anonymous | None | No access to any profile endpoint |
Game keys and API keys are fundamentally different — see TYPES_OF_KEYS.md for full details.
Profile Visibility Settings
Players control their own visibility via PATCH /api/player-profile/me.
| Value | Meaning |
|---|---|
private | Opted out of global external visibility. Game keys still see id only within the player's tenant unless tenant-specific opt-out is active; API keys see nothing (404). |
limited | Default. Display name and avatar are visible to external callers. |
full | Fully visible. External callers also receive tenant access history. |
Visibility only affects external lookup (/api/player-profiles/\{id\}). It has no effect on
/api/player-profile/me — players always see their own full profile.
Tenant-specific discovery opt-out is a separate rule stored on game_player_tenant_access.
It is narrower than profileVisibility: it hides the player only from tenant-scoped discovery
surfaces for one tenant, without affecting other tenants or the player's own account access.
Tenant Opt Out
Tenant opt-out is a per-tenant discovery rule. It is separate from global
profileVisibility.
What it does
When tenant opt-out is active for a specific (playerId, tenantId) pair, tenant-scoped
discovery surfaces for that tenant should treat the player as not discoverable and return 404.
This is stricter than profileVisibility=private:
profileVisibility=privatestill allows game-key callers in the player's tenant to receivePlayerProfileMinimalDTOwithidonly.- tenant opt-out is intended to suppress even that tenant-scoped existence confirmation.
What it does not affect
Tenant opt-out does not affect:
- other tenants
/api/player-profile/me- internal/admin access
POST /api/player-auth/players/existsfor game-key registration/existence checks- merge resolution
- global non-tenant-scoped visibility rules
How to set it
Players can set or clear tenant opt-out with:
PUT /api/player-profile/me/bus_tenants/\{tenantId\}/opt-out
Request body:
{
"isOptedOut": true
}Use true to hide the player from tenant-scoped discovery for that tenant.
Use false to re-allow tenant-scoped discovery for that tenant.
The player must already have a tenant access record for that tenant. This endpoint does not create tenant membership on its own.
Current implementation note
Tenant opt-out is enforced on tenant-scoped discovery paths. If a player is hidden for a specific tenant, those endpoints should behave as if the player is not discoverable there.
Endpoint Permission Tables
GET /api/player-profile/me
Self-lookup. Always returns FullPlayerProfileDTO.
| Caller | Allowed | Notes |
|---|---|---|
| Player JWT | Yes | Must be a scope=player JWT. Returns own profile only. |
| Game Key | No | 401 |
| API Key | No | 401 |
| Anonymous | No | 401 |
Returns (FullPlayerProfileDTO):
id, displayName, avatarUrl, email, platformRole, profileVisibility,
createdAt, isActive, mergedIntoId, mergedProfileIds,
authMethods[], tenantAccess[]Visibility setting has no effect — this is always the full view.
GET /api/player-profile/me/portal
Aggregated self-service landing-page read for the authenticated player.
| Caller | Allowed | Notes |
|---|---|---|
| Player JWT | Yes | Must be a scope=player JWT. Returns own portal view only. |
| Game Key | No | 401 |
| API Key | No | 401 |
| Anonymous | No | 401 |
Returns (PlayerPortalResponseDTO):
profile
id, displayName, avatarUrl, email, profileVisibility,
platformRole, createdAt, isActive, authMethods[]
visibilityOptions[]
value, label, description
tenants[]
tenantId, tenantName, tenantSlug, tenantRole,
firstSeenAt, lastSeenAt, loginCount, isOptedOut,
banStatus
isCurrentlyBanned
bannedUntil
isPermanent
statusTextPlayer-facing ban safety: this view exposes only current player-safe ban state.
It does not expose bannedByUserId, internal moderation metadata, or raw admin-only ban fields.
Visibility guidance: the response includes backend-driven visibility option labels and
descriptions so client apps can explain private, limited, and full consistently.
This is a separate self-service profile view DTO. It is not the login response from
POST /api/player-portal-auth/otc/exchange, which returns PlayerPortalLoginResponse.
GET /api/player-profiles/{id}
External lookup. Response shape depends on caller type and the player's visibility setting.
Provide exactly one of X-Game-Key or X-API-Key. Requests that send both are rejected with 400.
Game Key path (X-Game-Key)
The game key is tenant-scoped. Only players who have a game_player_tenant_access record for
this key's tenant are reachable. A player in a different game entirely returns 404.
Lookup resolves merged profile IDs to the current active profile before visibility is applied.
If an old merged ID is used, the response id will be the current canonical profile ID, not
necessarily the requested ID.
| Player visibility | Response | Shape |
|---|---|---|
private | 200 | PlayerProfileMinimalDTO — id, profileVisibility only, unless tenant-specific opt-out is active |
limited | 200 | PlayerProfileSummaryDTO — id, displayName, avatarUrl, profileVisibility |
full | 200 | TenantScopedPlayerProfilePublicDTO — summary + this tenant's access record only |
| Not in tenant | 404 | Player not found in this tenant (regardless of visibility) |
| Tenant-specific opt-out active | 404 | Player is hidden from tenant-scoped discovery for this tenant |
| Player does not exist | 404 | — |
Why
privatestill returnsidfor game keys: the game server enrolled this player through its own login flow. It already knows the player exists. Returningidlets servers correlate records without leaking profile data.
Tenant-specific opt-out boundary: when tenant-specific opt-out is active for this tenant, game-key callers should also receive 404. This is stricter than global
profileVisibilityand is intended to remove the player from all tenant-scoped discovery surfaces for that tenant.
Why
fullonly shows this tenant's access record: game servers have context for their own game only. All-tenant access history from other games is not their concern.
Game-key tenant access shape: the tenant access record returned on the game-key path omits
isOptedOut. Tenant opt-out is an internal privacy control, not game-client profile data.
API Key path (X-API-Key, requires allow_data_api)
API keys are tenant-scoped for player profile lookup. They can look up a player by ID only when
that player has a visible game_player_tenant_access record for the API key's tenant. Profile
visibility is also enforced strictly.
Lookup resolves merged profile IDs to the current active profile before visibility is applied.
| Player visibility | Response | Shape |
|---|---|---|
private | 404 | Player has opted out — existence is not revealed |
limited | 200 | PlayerProfileSummaryDTO — id, displayName, avatarUrl, profileVisibility |
full | 200 | PlayerProfilePublicDTO — summary + this API key tenant's access record only |
| Not in API key tenant | 404 | Player not found for this tenant (regardless of visibility) |
| Hidden from tenant discovery | 404 | Player is hidden from tenant-scoped discovery for this tenant |
| Player does not exist | 404 | — |
API key missing allow_data_api | 403 | Key does not permit data access |
POST /api/player-profiles/bulk
Bulk public player profile lookup using an API key.
POST /api/player-profiles/bulk
X-API-Key: <api_key_with_allow_data_api>
Content-Type: application/jsonRequest body:
{
"playerIds": ["<uuid>", "<uuid>"]
}| Caller | Allowed |
|---|---|
API key with allow_data_api | Yes |
| Game key | No — 400 |
| Missing/invalid key | No — 401 |
API key missing allow_data_api | No — 403 |
Accepts up to 100 player IDs. Duplicate IDs are de-duplicated before processing. Requests can return 429 when the profile lookup rate limit is exceeded.
Private profiles and profiles without visible access in the API key tenant are returned in
notFound, intentionally indistinguishable from missing profiles. Limited profiles return
basic identity fields; full profiles include only the API key tenant's visible access record.
Returns (BulkPlayerProfileLookupResponse):
items[] PlayerProfileBulkLookupItemDTO[]
notFound Guid[]
requestedCount int
processedCount int
returnedCount intEach returned item uses the same external shape as API-key single-profile lookup:
id, displayName, avatarUrl, profileVisibility, and tenantAccess[].
GET /api/player-profile/{id} — DISABLED
This endpoint has been commented out. It accepted any valid JWT (player or SaaS) and returned
FullPlayerProfileDTO with no ownership check or visibility gating.
Players looking up their own profile should use /api/player-profile/me.
External lookups should use /api/player-profiles/\{id\} with a game key or API key.
PATCH /api/player-profile/me
Update own profile fields.
| Caller | Allowed |
|---|---|
| Player JWT | Yes — own profile only |
| Any other | No — 401 |
Updatable fields: displayName, avatarUrl, email, profileVisibility
Visibility values: private, limited, full
Separate tenant-scoped opt-out: per-tenant discovery opt-out is not controlled by
profileVisibility. It is tracked separately on game_player_tenant_access and is meant to
hide the player from discovery within one tenant only.
GET /api/player-profile/me/bus_tenants
List all games/tenants the authenticated player has logged into.
| Caller | Allowed |
|---|---|
| Player JWT | Yes — own records only |
| Any other | No — 401 |
Returns: TenantAccessDTO[] — tenantId, tenantRole, firstSeenAt, lastSeenAt, loginCount, isOptedOut
Includes records from any merged profiles.
PUT /api/player-profile/me/bus_tenants/{tenantId}/opt-out
Set or clear tenant discovery opt-out for the authenticated player within one tenant.
| Caller | Allowed |
|---|---|
| Player JWT | Yes — own tenant access only |
| Any other | No — 401 |
Request body: SetTenantOptOutRequest — required isOptedOut
Returns: TenantOptOutStatusDTO — tenantId, isOptedOut
Not Found: returned when the player does not have a tenant access record for that tenant.
POST /api/player-profile/me/auth-methods
Link an additional authentication provider to the player's profile.
| Caller | Allowed |
|---|---|
| Player JWT | Yes |
| Any other | No — 401 |
Validates the provider token live. If the provider user ID is already linked to a different profile, the link is rejected — use the merge flow instead.
DELETE /api/player-profile/me/auth-methods/{authMethodId}
Unlink an authentication provider from the player's profile.
| Caller | Allowed |
|---|---|
| Player JWT | Yes — own auth methods only |
| Any other | No — 401 |
Cannot remove the last remaining auth method. If the removed method was the primary, another method is automatically promoted to primary.
POST /api/player-profile/me/auth-methods/{authMethodId}/set-primary
Set a linked provider as the primary authentication method.
| Caller | Allowed |
|---|---|
| Player JWT | Yes — own auth methods only |
| Any other | No — 401 |
POST /api/player-profile/me/merge
Merge two profiles into one. The authenticated player's profile is the target (survives). The source profile becomes inactive.
| Caller | Allowed |
|---|---|
| Player JWT | Yes |
| Any other | No — 401 |
Requires: sourceProfileId, sourceProvider, sourceAuthToken — a live provider token
proving ownership of the source profile. The token is validated and matched against the source
profile's auth methods before the merge proceeds.
What is transferred from source to target:
- All linked auth methods
- All tenant access records
Source profile after merge: isActive=false, isBackMerged=true, mergedIntoId set to target.
Target profile after merge: mergedProfileIds updated to include source and any profiles
source had previously absorbed.
Response DTO Reference
FullPlayerProfileDTO — player self-view only
id Guid
displayName string?
avatarUrl string?
email string?
platformRole string
profileVisibility string
createdAt DateTime
isActive bool
mergedIntoId Guid?
mergedProfileIds Guid[]
authMethods[]
id Guid
authProvider string
providerUserId string
email string?
username string?
displayName string?
avatarUrl string?
isPrimary bool
linkedAt DateTime
lastUsedAt DateTime?
tenantAccess[]
tenantId Guid
tenantRole string
firstSeenAt DateTime
lastSeenAt DateTime
loginCount int
isOptedOut boolTenantScopedPlayerProfilePublicDTO — full visibility, game key callers
id Guid
displayName string?
avatarUrl string?
profileVisibility string
tenantAccess[]
tenantId Guid
tenantRole string
firstSeenAt DateTime
lastSeenAt DateTime
loginCount intPlayerProfilePublicDTO — full visibility, API key callers
id Guid
displayName string?
avatarUrl string?
profileVisibility string
tenantAccess[] (API key tenant only)
tenantId Guid
tenantRole string
firstSeenAt DateTime
lastSeenAt DateTime
loginCount int
isOptedOut boolPlayerProfileSummaryDTO — limited visibility, external callers
id Guid
displayName string?
avatarUrl string?
profileVisibility stringPlayerProfileMinimalDTO — private + game key only
id Guid
profileVisibility stringFields Never Exposed to External Callers
Regardless of visibility setting, these fields are only returned via /api/player-profile/me:
email— PII; game owners should not have access to player emails without explicit consentauthMethods— linked provider accounts and provider user IDsplatformRole— internal platform classificationcreatedAt— account creation timestampisActive— merge/active statusmergedIntoId/mergedProfileIds— internal merge chain pointers
Note: external callers do not receive merge pointer fields, but a lookup by an old merged profile
ID currently resolves to the active profile and returns that active profile's id.
Error Codes
| Code | Meaning |
|---|---|
| 400 | Invalid request payload, invalid visibility value, link/merge failure, auth method not found, or unlink blocked |
| 401 | Missing or invalid credential (JWT, game key, or API key) |
| 403 | API key exists but lacks allow_data_api permission |
| 404 | Player not found, not in this tenant, tenant-scoped opt-out active, or private + API key caller |
Known Limitations
Atomic Merge Operation (TODO): Current merge behavior spans multiple independent write
operations (transfer auth methods, transfer tenant access, mark source inactive, update target
mergedProfileIds) without a single transaction boundary. A mid-operation failure can leave the
merge partially applied.
Atomic Primary Auth Method Mutations (TODO): Auth method mutations (link with
setAsPrimary=true, unlink primary, set-primary) are multi-step operations without a single
transaction and can leave a profile with no primary or transiently inconsistent primary state.
Code References
- Lookup controller:
GamersLabRestAPI/PlayerAuth/Controllers/PlayerProfileLookupController.cs - Self-management controller:
GamersLabRestAPI/PlayerAuth/Controllers/PlayerProfileController.cs - Profile service:
GamersLabRestAPI/PlayerAuth/Services/PlayerProfileService.cs - Profile DTOs:
GamersLabRestAPI/PlayerAuth/DTOs/PlayerProfileDTOs.cs - Visibility constants:
GamersLabRestAPI/PlayerAuth/Models/PlayerProfile.cs(PlayerProfileVisibilityValues) - Tenant scoped lookup:
IPlayerProfileService.GetTenantScopedProfileAsync