Gamers Lab Docs

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

CallerCredentialWhere it comes from
Player (self)Authorization: Bearer <player_jwt>Issued by any player auth login flow
Game serverX-Game-Key: <key>Issued per tenant, write-capable, tenant-scoped
Dashboard / third-party appX-API-Key: <key>Issued per tenant, read-only, must have allow_data_api flag
AnonymousNonePublic 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.

ValueMeaning
privateOpted 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.
limitedDefault. Display name and avatar are visible to external callers and public profile pages.
fullPublic 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.

CallerAllowedNotes
Player JWTYesMust be a scope=player JWT. Returns own profile only.
Game KeyNo401
API KeyNo401
AnonymousNo401

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 visibilityResponseShape
private200PlayerProfileMinimalDTOid, profileVisibility only
limited200PlayerProfileSummaryDTOid, displayName, avatarUrl, profileVisibility
full200PlayerProfileSummaryDTOid, displayName, avatarUrl, profileVisibility
Not in tenant404Player not found in this tenant (regardless of visibility)
Player does not exist404

Why private still returns id for game keys: the game server enrolled this player through its own login flow. It already knows the player exists. Returning id lets 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 visibilityResponseShape
private404Player has opted out — existence is not revealed
limited200PlayerProfileSummaryDTOid, displayName, avatarUrl, profileVisibility
full200PlayerProfileSummaryDTOid, displayName, avatarUrl, profileVisibility
Not in API key tenant404Player not found for this tenant (regardless of visibility)
Hidden from tenant discovery404Player is hidden from tenant-scoped discovery for this tenant
Player does not exist404
API key missing allow_data_api403Key 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 visibilityResponseShape
private404Profile is not found; existence is not revealed
limited200PublicPlayerProfileDTOid, displayName, avatarUrl, profileVisibility, empty games[]
full200PublicPlayerProfileDTO — basic fields plus visible games[]
Player does not exist404

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      int

Hidden-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.

CallerAllowed
AnonymousYes

Response:

authUrl     string
expiresAt   DateTime

POST /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 string

Response 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.

CallerAllowed
Player JWTYes — own profile only
Any otherNo — 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.

CallerAllowed
Player JWTYes — own profile only
Any otherNo — 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.

CallerAllowed
Player JWTYes — own profile only
Any otherNo — 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.

CallerAllowed
Player JWTYes — own records only
Any otherNo — 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.

CallerAllowed
Player JWTYes
Any otherNo — 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.

CallerAllowed
Player JWTYes — own auth methods only
Any otherNo — 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.

CallerAllowed
Player JWTYes — own auth methods only
Any otherNo — 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.

CallerAllowed
Player JWTYes
Any otherNo — 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

TenantScopedPlayerProfilePublicDTO — legacy full visibility game-key shape

id                  Guid
displayName         string?
avatarUrl           string?
profileVisibility   string
tenantAccess[]
  tenantId          Guid
  tenantRole        string
  firstSeenAt       DateTime
  lastSeenAt        DateTime
  loginCount        int

Current 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 only

Current 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        int

PlayerProfileSummaryDTO — limited/full visibility, keyed external callers

id                  Guid
displayName         string?
avatarUrl           string?
profileVisibility   string

PlayerProfileMinimalDTO — private + game key only

id                  Guid
profileVisibility   string

Fields 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 consent
  • authMethods — linked provider accounts and provider user IDs
  • platformRole — internal platform classification
  • createdAt — account creation timestamp
  • isActive — merge/active status
  • mergedIntoId / 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

CodeMeaning
400Invalid request payload, invalid visibility value, link/merge failure, auth method not found, or unlink blocked
401Missing or invalid credential (JWT, game key, or API key)
403API key exists but lacks allow_data_api permission
404Player 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=true first 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

On this page