455 lines
14 KiB
Markdown
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)
|