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

455 lines
14 KiB
Markdown

# 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:
```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.
## References
- [Internal REST contract](openapi.yaml)
- [Service docs index](docs/README.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)