# 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-` 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-` 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-` 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: ```json { "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. ## Storage `User Service` is split between two backends per [`../ARCHITECTURE.md §Persistence Backends`](../ARCHITECTURE.md): - PostgreSQL is the source of truth for table-shaped business state. The `user` schema (provisioned externally) holds `accounts`, `blocked_emails`, `entitlement_records`, `entitlement_snapshots`, `sanction_records`, `sanction_active`, `limit_records`, `limit_active`. Embedded migrations in [`internal/adapters/postgres/migrations`](internal/adapters/postgres/migrations) apply at process start; a non-zero exit is fatal. - Redis hosts the two stream publishers — the auxiliary domain-events stream and the trusted user-lifecycle stream described below. No durable user state lives on Redis after Stage 3 of `PG_PLAN.md`. Schema decisions and the reasoning behind keeping `entitlement_snapshots` denormalised, expressing eligibility flags as SQL predicates instead of materialised columns, and sharing one `*redis.Client` between the two publishers are recorded in [`docs/postgres-migration.md`](docs/postgres-migration.md). ### Configuration PostgreSQL knobs (consumed via `pkg/postgres`): - `USERSERVICE_POSTGRES_PRIMARY_DSN` (required) - `USERSERVICE_POSTGRES_REPLICA_DSNS` (optional; comma-separated) - `USERSERVICE_POSTGRES_OPERATION_TIMEOUT` (default `1s`) - `USERSERVICE_POSTGRES_MAX_OPEN_CONNS` (default `25`) - `USERSERVICE_POSTGRES_MAX_IDLE_CONNS` (default `5`) - `USERSERVICE_POSTGRES_CONN_MAX_LIFETIME` (default `30m`) Redis knobs (consumed via `pkg/redisconn`): - `USERSERVICE_REDIS_MASTER_ADDR` (required) - `USERSERVICE_REDIS_REPLICA_ADDRS` (optional; comma-separated) - `USERSERVICE_REDIS_PASSWORD` (required; mandatory by architectural rule) - `USERSERVICE_REDIS_DB` (default `0`) - `USERSERVICE_REDIS_OPERATION_TIMEOUT` (default `250ms`) Stream-shape knobs: - `USERSERVICE_REDIS_DOMAIN_EVENTS_STREAM` (default `user:domain_events`) - `USERSERVICE_REDIS_DOMAIN_EVENTS_STREAM_MAX_LEN` (default `1024`) - `USERSERVICE_REDIS_LIFECYCLE_EVENTS_STREAM` (default `user:lifecycle_events`) - `USERSERVICE_REDIS_LIFECYCLE_EVENTS_STREAM_MAX_LEN` (default `1024`) The deprecated variables `USERSERVICE_REDIS_ADDR`, `USERSERVICE_REDIS_USERNAME`, `USERSERVICE_REDIS_TLS_ENABLED`, and `USERSERVICE_REDIS_KEYSPACE_PREFIX` are retired; setting any of them now fails service start with a clear error message pointing back to `ARCHITECTURE.md §Persistence Backends`. ## References - [Internal REST contract](openapi.yaml) - [Service docs index](docs/README.md) - [PostgreSQL migration decisions](docs/postgres-migration.md) - [Stage 21 decisions](docs/stage21-user-name-display-name.md) - [Stage 22 decisions](docs/stage22-permanent-block-delete-user.md) - [System architecture](../ARCHITECTURE.md)