16 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 inplayer-<suffix>formdisplay_name— mutable free-text user label validated bypkg/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 (includingmax_registered_race_namesoverrides) - 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_namevalues 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
- synchronize current effective
AdminUsers- lookups by
user_id, exact-after-trimemail, exactuser_name, and exact or prefixdisplay_name - deterministic filtered listing
- explicit entitlement, sanction, and limit commands
DeleteUsersoft-delete command
- lookups by
The public authenticated gateway boundary currently exposes exactly three self-service message types:
user.account.getuser.profile.update— payload carriesdisplay_nameonly; the priorrace_namepayload field is removed and rejected if presentuser.settings.update
Externally these commands use authenticated gRPC plus FlatBuffers payloads. Internally gateway calls:
GET /api/v1/internal/users/{user_id}/accountPOST /api/v1/internal/users/{user_id}/profilePOST /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 byAdmin Service. Idempotent peruser_id; a second call after soft-delete returns404 subject_not_foundfor 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 Serviceand are the only identifier permitted as a foreign key from other models. - Every new user receives an auto-generated
user_nameinplayer-<suffix>form. The suffix is 8 characters drawn from a confusable-free alphanumeric alphabet.user_nameis immutable after creation; collisions are resolved by retry during create (limit 10 attempts). display_namestarts empty for new accounts. Self-service may change it viaUpdateMyProfile; validation delegates topkg/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_namelookup is exact by stored value;display_namesupports exact and prefix lookups.
Auth-Facing Contract
Auth / Session Service depends on the following synchronous user-owned
decisions:
resolve-by-email- returns
creatable,existing, orblocked
- returns
ensure-by-email- returns
created,existing, orblocked
- returns
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_contextis required.- Its frozen shape is:
preferred_languagetime_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 Serviceforwards the preferred-language candidate derived from publicAccept-Language- unsupported or missing public language input falls back to
en Auth / Session Serviceforwards the public confirmtime_zone- the create-only registration context remains unchanged for existing users
Auth-facing blocking semantics:
blockedmeans the auth flow must not create or return a usable session for that subject.send-email-codemay still remain success-shaped at the auth edge, butUser Serviceremains 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_languagetime_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_namechange- direct
declared_countrychange - 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
- changes only
UpdateMySettings- changes only
preferred_languageandtime_zone - rejects unsupported or unknown fields
- changes only
- active
profile_update_blocksanction blocks both profile and settings writes with409 conflict - active
permanent_blocksanction blocks every self-service read and write with409 conflictand surfaces in admin reads
Validation Rules
- 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_blockprivate_game_create_blockprivate_game_manage_blockgame_join_blockprofile_update_blockpermanent_block— terminal state; collapses everycan_*eligibility marker tofalse; triggers RND cascade release inGame Lobbythroughuser:lifecycle_events
Supported user-specific limit codes:
max_owned_private_gamesmax_pending_public_applicationsmax_active_game_membershipsmax_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=falserather than404 - 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_logincan_create_private_gamecan_manage_private_gamecan_join_gamecan_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-specificmax_registered_race_nameslimit override, when active, replaces the tariff value.
declared_country Ownership Split
Ownership is intentionally split:
User Service- stores only the current effective
declared_countryvalue
- stores only the current effective
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_tokenis 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_nameexactdisplay_nameexact or prefixdeletedflag (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.changeduser.settings.changeduser.entitlement.changeduser.sanction.changeduser.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 whenSanctionCodePermanentBlockbecomes active on a useruser.lifecycle.deleted— emitted whenDeleteUsersucceeds
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
initializedfor auth-driven creation andupdatedfor later self-service writes - entitlement events use:
initializedgrantedextendedrevokedexpired_repaired
- sanction events use:
appliedremoved
- limit events use:
setremoved
Error Model
The trusted internal REST contract uses strict JSON error envelopes:
{
"error": {
"code": "invalid_request",
"message": "request is invalid"
}
}
Stable error codes:
invalid_requestconflictsubject_not_foundinternal_errorservice_unavailable
Gateway mirrors these business errors on the authenticated user.* boundary
as:
- gateway
result_code - FlatBuffers error payload carrying the same
codeandmessage
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:
- PostgreSQL is the source of truth for table-shaped business state. The
userschema (provisioned externally) holdsaccounts,blocked_emails,entitlement_records,entitlement_snapshots,sanction_records,sanction_active,limit_records,limit_active. Embedded migrations ininternal/adapters/postgres/migrationsapply 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.
Configuration
PostgreSQL knobs (consumed via pkg/postgres):
USERSERVICE_POSTGRES_PRIMARY_DSN(required)USERSERVICE_POSTGRES_REPLICA_DSNS(optional; comma-separated)USERSERVICE_POSTGRES_OPERATION_TIMEOUT(default1s)USERSERVICE_POSTGRES_MAX_OPEN_CONNS(default25)USERSERVICE_POSTGRES_MAX_IDLE_CONNS(default5)USERSERVICE_POSTGRES_CONN_MAX_LIFETIME(default30m)
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(default0)USERSERVICE_REDIS_OPERATION_TIMEOUT(default250ms)
Stream-shape knobs:
USERSERVICE_REDIS_DOMAIN_EVENTS_STREAM(defaultuser:domain_events)USERSERVICE_REDIS_DOMAIN_EVENTS_STREAM_MAX_LEN(default1024)USERSERVICE_REDIS_LIFECYCLE_EVENTS_STREAM(defaultuser:lifecycle_events)USERSERVICE_REDIS_LIFECYCLE_EVENTS_STREAM_MAX_LEN(default1024)
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.