Player Profile Permissions
Player profile access, visibility, and self-management
Player Profile Permissions
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), keyed external lookup endpoints (/api/player-profiles), and anonymous
public profile endpoints (/api/public/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 | Public profile lookup only |
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 public profile visibility. Public visitors and API keys see 404; game keys see id only when the player is already known in that game. |
limited | Default. Display name and avatar are visible to external callers and public profile pages. |
full | Public profile. Public visitors see display name, avatar, and visible games with last played dates and login counts. |
Visibility has no effect on /api/player-profile/me — players always see their own full profile.
Anonymous public profile pages use GET /api/public/player-profiles/{id}. Keyed profile lookup
uses GET /api/player-profiles/{id} and returns only profile-card identity fields for limited
and full.
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-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 |
limited | 200 | PlayerProfileSummaryDTO — id, displayName, avatarUrl, profileVisibility |
full | 200 | PlayerProfileSummaryDTO — id, displayName, avatarUrl, profileVisibility |
| Not in tenant | 404 | Player not found in this tenant (regardless of visibility) |
| 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, unless tenant-specific opt-out is active for that tenant.
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.
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 | 404 | Player has opted out — existence is not revealed |
limited | 200 | PlayerProfileSummaryDTO — id, displayName, avatarUrl, profileVisibility |
full | 200 | PlayerProfileSummaryDTO — id, displayName, avatarUrl, profileVisibility |
| 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 |
GET /api/public/player-profiles/{id}
Anonymous public profile lookup by player UUID. This is the backing API for public website pages
such as /player/{uuid}.
| Player visibility | Response | Shape |
|---|---|---|
private | 404 | Profile is not found; existence is not revealed |
limited | 200 | PublicPlayerProfileDTO — id, displayName, avatarUrl, profileVisibility, empty games[] |
full | 200 | PublicPlayerProfileDTO — basic fields plus visible games[] |
| Player does not exist | 404 | — |
Public game rows use product-facing language and do not expose email, auth methods, or OTC account details.
gameId Guid
gameName string?
gameSlug string?
lastPlayedAt DateTime
loginCount intHidden-from-discovery game rows are omitted from the public games[] list.
POST /api/player-profiles/bulk
Bulk external lookup for server-side profile enrichment by known UUID.
This endpoint accepts X-API-Key only, requires allow_data_api, and accepts up to 100 player IDs
per request. It is scoped to the API key's tenant: a profile is returned only when the player has a
visible tenant access record for that tenant. Private profiles, missing players, players with no
visible access in the API key's tenant, and players hidden from tenant discovery are returned in
notFound without distinguishing the reason.
Request:
{
"playerIds": [
"11111111-1111-1111-1111-111111111111",
"22222222-2222-2222-2222-222222222222"
]
}Response:
{
"items": [
{
"id": "11111111-1111-1111-1111-111111111111",
"displayName": "Player One",
"avatarUrl": null,
"profileVisibility": "limited",
"tenantAccess": []
}
],
"notFound": [
"22222222-2222-2222-2222-222222222222"
],
"requestedCount": 2,
"processedCount": 2,
"returnedCount": 1
}requestedCount is the raw submitted ID count. processedCount is the effective count after
deduplication and 00000000-0000-0000-0000-000000000000 filtering. items + notFound matches
processedCount, not necessarily requestedCount.
limited and full profiles return basic identity fields. Public game lists are available only
through GET /api/public/player-profiles/{id}.
X-Game-Key is intentionally not supported for bulk lookup.
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.
POST /api/player-portal-auth/steam/start
Create a Steam OpenID URL for global player portal login. The API builds the Steam return_to
from AppSettings:BaseUrl and stores a short-lived state value server-side.
| Caller | Allowed |
|---|---|
| Anonymous | Yes |
Response:
authUrl string
expiresAt DateTimePOST /api/player-portal-auth/steam/exchange
Exchange the Steam OpenID callback URL for a player-scoped portal session. Steam proves ownership of
the Steam account, then the verified Steam ID is used to resolve an existing
game_player_auth_methods row with auth_provider='Steam'. Missing, inactive, or unlinked players
return 401. This flow does not create player profiles.
Request:
callbackUrl stringResponse is the same token shape as player portal OTC exchange: player access token, refresh token, token type, expiry, and player UUID.
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, profileVisibility
Player self-service updates cannot change email. The profile email is tied to the linked
email/OTC sign-in method and is managed through auth/admin flows, not /api/player-profile/me.
Visibility values: private, limited, full
DELETE /api/player-profile/me
Delete and redact the authenticated player's own account.
| Caller | Allowed |
|---|---|
| Player JWT | Yes — own profile only |
| Any other | No — 401 |
Uses the same redaction semantics as platform-admin player delete: clears display name, avatar URL,
and email, sets profile visibility to private, resets platform role to User, marks the profile
inactive, and removes linked auth methods. The operation acts on the authenticated JWT subject's
profile ID directly and does not follow merge chains. The database cleanup is committed in one
transaction and also removes email credentials, invalidates pending email verification, OTC, and
password reset tokens, revokes player refresh tokens, revokes third-party player refresh tokens,
and redacts third-party refresh token email/display-name fields. Managed avatar blobs are deleted
best-effort after the database transaction commits. A player.self_deleted audit record is written
after success; audit write failure is logged and does not roll back or fail a completed deletion.
Existing stateless access tokens can remain usable until their normal expiry. There is also a small
concurrent-login race where a login that already read the profile as active before deletion commits
can finish minting tokens after the delete transaction; closing that completely requires token
issuance to coordinate on the same per-player lock as deletion. The player profile row remains in
the database to preserve referential integrity and audit history.
Platform-admin player delete uses the same cleanup semantics and writes a player.admin_deleted
audit record after success.
POST /api/player-profile/me/avatar
Upload an avatar image for the authenticated player's profile.
| Caller | Allowed |
|---|---|
| Player JWT | Yes — own profile only |
| Any other | No — 401 |
Accepts multipart/form-data with avatarFile. Files must be PNG or JPG and no larger than
2 MB. Uploaded avatars are stored in the configured Blob Storage container under
BlobStorage:PlayerAvatarPrefix, and the stored blob URL is saved to the player's avatarUrl.
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
Includes records from any merged profiles.
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 intTenantScopedPlayerProfilePublicDTO — legacy full visibility game-key shape
id Guid
displayName string?
avatarUrl string?
profileVisibility string
tenantAccess[]
tenantId Guid
tenantRole string
firstSeenAt DateTime
lastSeenAt DateTime
loginCount intCurrent keyed lookup responses no longer use this shape for full; full returns
PlayerProfileSummaryDTO on keyed lookups.
PlayerProfilePublicDTO — legacy full visibility API-key shape
id Guid
displayName string?
avatarUrl string?
profileVisibility string
tenantAccess[] (same shape as above)
— API key: this API key tenant onlyCurrent keyed lookup responses no longer use this shape for full; public game lists are exposed
only through PublicPlayerProfileDTO.
PublicPlayerProfileDTO — anonymous public profile pages
id Guid
displayName string?
avatarUrl string?
profileVisibility string
games[] visible games only when profileVisibility=full
gameId Guid
gameName string?
gameSlug string?
lastPlayedAt DateTime
loginCount intPlayerProfileSummaryDTO — limited/full visibility, keyed 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, or private + API key caller |
TODO — Per-Tenant Player Opt-Out
Currently, a player setting profileVisibility=private still allows game key callers to confirm
their existence within that game's tenant (returns PlayerProfileMinimalDTO with id only).
A future per-tenant opt-out mechanism will allow players to fully remove themselves from a specific game's visible player list. When implemented:
- A player who has opted out of tenant X will return 404 to game key callers for tenant X, regardless of their profile visibility setting.
- This is independent of
profileVisibility, which is a global setting.
TODO — Atomic Merge Operation
Current merge behavior spans multiple independent write operations:
- transfer auth methods
- transfer tenant access
- mark source profile inactive/back-merged
- update target
mergedProfileIds
This is not currently wrapped in one database transaction. A mid-operation failure can leave the merge partially applied. The canonical fix is to execute the full merge inside a single transaction boundary.
TODO — Atomic Primary Auth Method Mutations
Current auth method mutations are also multi-step operations without a single transaction:
- linking with
setAsPrimary=truefirst clears existing primary flags, then inserts the new method - unlinking a primary method deletes first, then promotes a replacement
- setting primary updates multiple rows one-by-one
This can leave a profile with no primary auth method or a transiently inconsistent primary state if a write fails mid-flow. The canonical fix is to make each mutation atomic.
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