Files
galaxy-game/user/README.md
T
2026-04-25 23:20:55 +02:00

14 KiB

User Service

galaxy/user owns regular-user platform identity and account state.

The service is internal-only. Its source-of-truth transport is trusted REST/JSON. Edge Gateway exposes selected self-service operations externally through authenticated gRPC with FlatBuffers payloads and transcodes those requests to this service's internal REST API.

Scope

User Service is the source of truth for:

  • opaque regular-user identifiers in user-* form
  • exact-after-trim login e-mail addresses
  • user_name — immutable auto-generated unique platform handle in player-<suffix> form
  • display_name — mutable free-text user label validated by pkg/util/string.go:ValidateTypeName, not required to be unique, empty by default
  • editable self-service settings (preferred_language, time_zone)
  • current entitlement snapshot including max_registered_race_names
  • active sanctions (including permanent_block) and active user-specific limits (including max_registered_race_names overrides)
  • current effective declared_country
  • soft-delete state via DeleteUser

User Service is not the source of truth for:

  • system-administrator identity
  • device sessions, challenges, or client public keys
  • in-game race_name values or their uniqueness — those live in the Game Lobby Race Name Directory
  • declared-country review workflow or history
  • edge authentication, request signing, or replay protection

Administrative reads and writes against regular-user state do not make this service the owner of administrator identity. Admin identity belongs to the future Admin Service.

Trusted Surfaces

The internal REST surface is split into five stable groups:

  • AuthIntegration
    • resolve-by-email
    • exists-by-user-id
    • ensure-by-email
    • block-by-user-id
    • block-by-email
  • MyAccount
    • get account aggregate
    • update profile
    • update settings
  • LobbyIntegration
    • read synchronous eligibility snapshot
  • GeoIntegration
    • synchronize current effective declared_country
  • AdminUsers
    • lookups by user_id, exact-after-trim email, exact user_name, and exact or prefix display_name
    • deterministic filtered listing
    • explicit entitlement, sanction, and limit commands
    • DeleteUser soft-delete command

The public authenticated gateway boundary currently exposes exactly three self-service message types:

  • user.account.get
  • user.profile.update — payload carries display_name only; the prior race_name payload field is removed and rejected if present
  • user.settings.update

Externally these commands use authenticated gRPC plus FlatBuffers payloads. Internally gateway calls:

  • GET /api/v1/internal/users/{user_id}/account
  • POST /api/v1/internal/users/{user_id}/profile
  • POST /api/v1/internal/users/{user_id}/settings

Additional trusted internal operations:

  • POST /api/v1/internal/users/{user_id}/delete — soft-delete (DeleteUser); intended to be called only by Admin Service. Idempotent per user_id; a second call after soft-delete returns 404 subject_not_found for external reads but keeps the deleted record for audit.

Gateway must derive user_id from authenticated session context only. The client payload never carries user identity for this boundary.

Identity And Lookup Rules

  • User IDs are opaque stable identifiers generated by User Service and are the only identifier permitted as a foreign key from other models.
  • Every new user receives an auto-generated user_name in player-<suffix> form. The suffix is 8 characters drawn from a confusable-free alphanumeric alphabet. user_name is immutable after creation; collisions are resolved by retry during create (limit 10 attempts).
  • display_name starts empty for new accounts. Self-service may change it via UpdateMyProfile; validation delegates to pkg/util/string.go:ValidateTypeName; uniqueness is not enforced.
  • E-mail semantics are exact-after-trim.
    • The service trims surrounding whitespace.
    • The service does not lowercase, canonicalize, or alias-normalize e-mail values.
    • Exact lookup by e-mail uses the trimmed stored value.
  • user_name lookup is exact by stored value; display_name supports exact and prefix lookups.

Auth-Facing Contract

Auth / Session Service depends on the following synchronous user-owned decisions:

  • resolve-by-email
    • returns creatable, existing, or blocked
  • ensure-by-email
    • returns created, existing, or blocked
  • exists-by-user-id
    • supports trusted session revoke and block flows
  • block operations
    • support trusted auth-driven user or e-mail blocking flows

ensure-by-email rules:

  • registration_context is required.
  • Its frozen shape is:
    • preferred_language
    • time_zone
  • The registration context is create-only.
    • New users store the supplied values after semantic validation.
    • Existing users ignore the registration context completely.
    • Existing users must not have settings overwritten by a later auth flow.
  • The current rollout source of truth is:
    • Auth / Session Service forwards the preferred-language candidate derived from public Accept-Language
    • unsupported or missing public language input falls back to en
    • Auth / Session Service forwards the public confirm time_zone
    • the create-only registration context remains unchanged for existing users

Auth-facing blocking semantics:

  • blocked means the auth flow must not create or return a usable session for that subject.
  • send-email-code may still remain success-shaped at the auth edge, but User Service remains the source of truth for the blocked decision.

Self-Service Account Contract

Self-service reads and writes operate on one shared account aggregate:

  • immutable:
    • user_name
  • profile:
    • display_name
  • settings:
    • preferred_language
    • time_zone
  • derived current state:
    • entitlement snapshot
    • active sanctions
    • active limits
    • current declared_country

Self-service writes return the refreshed full account aggregate.

Forbidden self-service mutations:

  • e-mail change
  • user_name change
  • direct declared_country change
  • direct entitlement mutation
  • direct sanction mutation
  • direct limit mutation

Current write rules:

  • UpdateMyProfile
    • changes only display_name
    • rejects unsupported or unknown fields
    • returns the current aggregate unchanged when the incoming value equals the stored one
  • UpdateMySettings
    • changes only preferred_language and time_zone
    • rejects unsupported or unknown fields
  • active profile_update_block sanction blocks both profile and settings writes with 409 conflict
  • active permanent_block sanction blocks every self-service read and write with 409 conflict and surfaces in admin reads

Validation Rules

E-mail

  • trim surrounding whitespace
  • validate as structurally valid e-mail
  • keep the trimmed exact value
  • do not lowercase or canonicalize

user_name

  • auto-generated server-side in player-<suffix> form
  • suffix = 8 characters drawn from a confusable-free alphanumeric alphabet
  • uniqueness enforced at store-layer; conflicts resolved by retry during ensure-by-email (limit 10 attempts)
  • immutable after creation; any attempt to mutate is a logic error and returns 500 internal_error

display_name

  • validated through pkg/util/string.go:ValidateTypeName
  • empty value is accepted and rendered as no display name in downstream consumers
  • casing and script preserved as submitted
  • not required to be unique

Note: in-game race_name values are owned by the Game Lobby Race Name Directory and are not validated, stored, or reserved by User Service.

preferred_language

  • validate as BCP 47 language tag
  • store canonical BCP 47 tag form
  • current auth-driven create path temporarily uses "en" from authsession

time_zone

  • validate as IANA time-zone name
  • store trimmed value
  • do not apply additional alias canonicalization

Entitlements

User Service owns the current effective entitlement snapshot.

Rules:

  • every new user starts with the frozen free entitlement baseline
  • explicit admin or later billing commands may:
    • grant
    • extend
    • revoke
  • finite paid entitlements are repaired lazily on read when expiry has passed
  • downstream services read current entitlement from User Service, not from billing or any write-side source

The shared account aggregate and lobby eligibility snapshot always expose the current effective entitlement after lazy expiry repair.

Sanctions And Limits

Sanctions and user-specific limits are explicit command-driven state.

Supported sanction codes:

  • login_block
  • private_game_create_block
  • private_game_manage_block
  • game_join_block
  • profile_update_block
  • permanent_block — terminal state; collapses every can_* eligibility marker to false; triggers RND cascade release in Game Lobby through user:lifecycle_events

Supported user-specific limit codes:

  • max_owned_private_games
  • max_pending_public_applications
  • max_active_game_memberships
  • max_registered_race_names — overrides the tariff default for the RND registered-name quota

Rules:

  • active views expose only currently supported codes
  • retired legacy limit codes may remain in stored history but are not part of the active read or write contract
  • sanctions and limits are projected into:
    • the self-service account aggregate
    • admin reads
    • lobby eligibility snapshots

Lobby Eligibility Semantics

Game Lobby depends on a synchronous read-optimized eligibility snapshot.

Rules:

  • unknown users return exists=false rather than 404
  • entitlement state is current and expiry-repaired
  • active sanctions are filtered to the lobby-relevant subset
  • effective limits are derived from:
    • the frozen free or paid default catalog
    • plus any active user-specific override

Current markers:

  • can_login
  • can_create_private_game
  • can_manage_private_game
  • can_join_game
  • can_update_profile

Additional materialized fields:

  • max_registered_race_names — tariff-derived quota for the Game Lobby Race Name Directory: free → 1, paid_monthly → 2, paid_yearly → 6, paid_lifetime → 0 (unlimited marker). A user-specific max_registered_race_names limit override, when active, replaces the tariff value.

declared_country Ownership Split

Ownership is intentionally split:

  • User Service
    • stores only the current effective declared_country value
  • Geo Profile Service
    • owns review workflow
    • owns decision history
    • owns version history and retry state

User Service accepts only trusted sync commands from Geo Profile Service for the latest approved effective value.

Sync rules:

  • accepted values are uppercase ISO 3166-1 alpha-2 country codes
  • syncing the already stored value is a no-op
  • a successful change updates the current account record and emits a domain event

Admin Read And List Semantics

Trusted admin reads operate on regular-user state only.

Lookups:

  • by user_id
  • by exact-after-trim email
  • by exact user_name
  • by exact or prefix display_name

Listing rules:

  • deterministic order:
    • created_at desc
    • then user_id desc
  • all supplied filters combine with logical AND
  • page_token is opaque and bound to the normalized filter set that produced it
  • malformed or filter-mismatched tokens return 400 invalid_request

Listing filters include:

  • paid/free state
  • paid expiry bounds
  • current declared_country
  • active sanction code (including permanent_block)
  • active limit code (including max_registered_race_names)
  • derived eligibility markers
  • user_name exact
  • display_name exact or prefix
  • deleted flag (soft-deleted accounts excluded by default)

Domain Events

User Service publishes auxiliary post-commit domain events to the shared Redis stream configured for domain events.

Frozen event types:

  • user.profile.changed
  • user.settings.changed
  • user.entitlement.changed
  • user.sanction.changed
  • user.limit.changed

The current effective declared-country sync remains externally observable as user.declared_country.changed.

User lifecycle stream

Separately from the shared domain-events stream, User Service publishes to a dedicated Redis stream user:lifecycle_events consumed by Game Lobby for Race Name Directory cascade release. Event types:

  • user.lifecycle.permanent_blocked — emitted when SanctionCodePermanentBlock becomes active on a user
  • user.lifecycle.deleted — emitted when DeleteUser succeeds

Event envelopes carry user_id, occurred_at_ms, mutation source, optional reason_code, and actor metadata. Delivery is at-least-once; consumers must be idempotent.

Event rules:

  • events are post-commit only
  • event envelopes carry user_id, mutation source, occurrence timestamp, and optional trace correlation
  • event payloads expose the latest committed state relevant to the operation
  • profile and settings events use initialized for auth-driven creation and updated for later self-service writes
  • entitlement events use:
    • initialized
    • granted
    • extended
    • revoked
    • expired_repaired
  • sanction events use:
    • applied
    • removed
  • limit events use:
    • set
    • removed

Error Model

The trusted internal REST contract uses strict JSON error envelopes:

{
  "error": {
    "code": "invalid_request",
    "message": "request is invalid"
  }
}

Stable error codes:

  • invalid_request
  • conflict
  • subject_not_found
  • internal_error
  • service_unavailable

Gateway mirrors these business errors on the authenticated user.* boundary as:

  • gateway result_code
  • FlatBuffers error payload carrying the same code and message

Transport failures, timeouts, and upstream 503 remain transport-level gateway UNAVAILABLE, not business results.

References