feat: game lobby service

This commit is contained in:
Ilia Denisov
2026-04-25 23:20:55 +02:00
committed by GitHub
parent 32dc29359a
commit 48b0056b49
336 changed files with 57074 additions and 1418 deletions
+7 -4
View File
@@ -48,7 +48,8 @@ Blocked response:
"account": {
"user_id": "user-123",
"email": "pilot@example.com",
"race_name": "Pilot Nova",
"user_name": "player-abcdefgh",
"display_name": "PilotNova",
"preferred_language": "en",
"time_zone": "Europe/Kaliningrad",
"declared_country": "DE",
@@ -78,7 +79,7 @@ Request:
```json
{
"race_name": "Nova Prime"
"display_name": "NovaPrime"
}
```
@@ -89,7 +90,8 @@ Success:
"account": {
"user_id": "user-123",
"email": "pilot@example.com",
"race_name": "Nova Prime",
"user_name": "player-abcdefgh",
"display_name": "NovaPrime",
"preferred_language": "en",
"time_zone": "Europe/Kaliningrad",
"entitlement": {
@@ -151,7 +153,8 @@ Success:
"user": {
"user_id": "user-123",
"email": "pilot@example.com",
"race_name": "Pilot Nova",
"user_name": "player-abcdefgh",
"display_name": "PilotNova",
"preferred_language": "en",
"time_zone": "Europe/Kaliningrad",
"entitlement": {
+6 -5
View File
@@ -66,15 +66,16 @@ Rules:
### Profile update
`UpdateMyProfile` changes only `race_name`.
`UpdateMyProfile` changes only `display_name`.
Rules:
- preserve stored casing on success
- enforce canonical reservation uniqueness
- reject conflicts as `409 conflict`
- validate the submitted value through `pkg/util/string.go:ValidateTypeName`
- an empty value is accepted and resets the stored display name
- uniqueness is not enforced; multiple users may share the same value
- `user_name` is immutable and cannot be updated through this operation
- reject writes while `profile_update_block` is active
- return current aggregate on no-op rename
- return the current aggregate on no-op updates
### Settings update
+6 -3
View File
@@ -58,16 +58,19 @@ Checks:
- `preferred_language` is a valid BCP 47 tag
- `time_zone` is a valid IANA time-zone name
### race_name conflict
### profile update rejected
Symptoms:
- profile update returns `409 conflict`
- profile update returns `400 invalid_request` or `409 conflict`
Checks:
- desired race name is not already reserved under canonical uniqueness rules
- submitted `display_name` passes `pkg/util/string.go:ValidateTypeName`; empty
values are accepted and reset the stored display name
- user is not currently blocked by `profile_update_block`
- `user_name` is immutable; any attempt to mutate it surfaces as
`409 conflict`
### declared-country sync rejected
+111
View File
@@ -0,0 +1,111 @@
# Stage 21 — `user_name` + `display_name` refactor
## Context
The Game Lobby plan moved every in-game `race_name` value into the Lobby Race
Name Directory. User Service stopped owning any in-game naming concept. The
legacy single-valued `RaceName` field on `UserAccount`, the canonical
race-name reservation store, and the `RaceNamePolicy` port were deleted. Two
stable fields replace them:
- `user_name` — immutable auto-generated `player-<suffix>` handle, unique
platform-wide, assigned once at account creation;
- `display_name` — mutable optional free-text label validated by
`pkg/util/string.go:ValidateTypeName`, empty by default, not unique.
## Key decisions
### Crockford Base32 lowercase alphabet for the suffix
`user_name` ends with eight characters drawn from the alphabet
`0123456789abcdefghjkmnpqrstvwxyz` (Crockford Base32 lowercase, `i`, `l`,
`o`, `u` excluded). Each `player-` identifier therefore has 40 bits of
entropy and is free of visually ambiguous pairs.
The generator lives in
`user/internal/adapters/local/id_generator.go:randomSuffix`; implementation
reads five random bytes via `crypto/rand` and walks the 5-bit groups through
the alphabet without using `encoding/base32` so the alphabet swap stays
self-contained.
### Retry limit bumped to 10
`authdirectory.Ensurer.ensureCreateRetryLimit` moved from `8` to `10`.
Collisions on the 40-bit suffix are expected to be extremely rare; the extra
two attempts give a comfortable margin before the service falls back to
`503 service_unavailable`.
### Canonical reservation removed
`RaceNameReservation`, `RaceNameCanonicalKey`, `ErrRaceNameConflict`, and
the `reservation:race-name:*` Redis keys are gone. Uniqueness now comes from
the single `lookup:user-name:<encoded>` index; no canonical form is
persisted in User Service. Any existing Redis dataset must be re-initialized
(the codebase has no production deployment, so no migration script ships).
### Confusable policy moved to Lobby
`user/internal/ports/race_name_policy.go` and
`user/internal/adapters/local/race_name_policy.go` were deleted. The
Unicode case-fold + digit-to-letter anti-fraud map + TR39 confusable
skeleton landed under `lobby/internal/domain/racename/` so Stage 09R can
wire it into the new Race Name Directory. Golden fixtures (`Pilot Nova` vs
`P1lot N0va`, unicode `paypal` vs Cyrillic lookalike) moved with the policy.
### Admin surface
- `/api/v1/internal/user-lookups/by-race-name` is replaced by
`/api/v1/internal/user-lookups/by-user-name` (exact 1:1 lookup).
- Admin listing `GET /api/v1/internal/users` gains `user_name`,
`display_name`, and `display_name_match` query parameters. `display_name`
supports `exact` (default) and `prefix` matching.
### Empty `display_name`
An empty or whitespace-only input value on `POST /profile` is accepted and
stored as an empty `display_name`. Non-empty values are validated by
`pkg/util/string.go:ValidateTypeName`; internal whitespace, leading/trailing
special characters, and unsupported characters are rejected.
### Eligibility snapshot gains `max_registered_race_names`
`lobbyeligibility.limitCatalog` now covers every tariff explicitly. The
defaults for `max_registered_race_names` are `free=1`, `paid_monthly=2`,
`paid_yearly=6`, `paid_lifetime=0` (unlimited marker). A
`LimitCodeMaxRegisteredRaceNames` user-specific override, when active,
replaces the tariff default. This feeds Stage 17A's registration quota
enforcement and Stage 22's `permanent_block` cascading.
### Domain event payload
`ports.ProfileChangedEvent.RaceName` was replaced by `UserName` and
`DisplayName`. The Redis stream publisher emits `user_name` on every event
and `display_name` only when non-empty, keeping the event type
`user.profile.changed` stable.
### Telemetry rename
`Runtime.RecordRaceNameReservationConflict` is now
`Runtime.RecordUserNameConflict`; the metric name changed from
`user.race_name.reservation_conflicts` to `user.user_name.conflicts`.
### Gateway boundary
The cross-module FlatBuffers boundary also moved:
`pkg/schema/fbs/user.fbs` swaps `AccountView.race_name` for
`user_name` + `display_name`, and `UpdateMyProfileRequest.race_name` for
`display_name`. `pkg/transcoder/user.go`, `pkg/model/user`, gateway
downstream tests, and the integration harness mirror the change.
## Files of interest
- Domain: `user/internal/domain/common/types.go`,
`user/internal/domain/account/model.go`,
`user/internal/domain/policy/model.go`
- Storage: `user/internal/adapters/redisstate/keyspace.go`,
`user/internal/adapters/redis/userstore/store.go`
- Services: `user/internal/service/{authdirectory,selfservice,adminusers,accountview,lobbyeligibility,shared}/`
- HTTP + OpenAPI: `user/internal/api/internalhttp/`, `user/openapi.yaml`
- Lobby seed: `lobby/internal/domain/racename/{policy,types,policy_test}.go`
- Gateway boundary: `pkg/schema/fbs/user.fbs`, `pkg/transcoder/user.go`,
`pkg/model/user/user.go`
@@ -0,0 +1,141 @@
# Stage 22 — `permanent_block` Sanction and `DeleteUser` Soft-Delete
Stage 22 lands in `galaxy/user` the terminal-state sanction
`permanent_block`, the soft-delete command `DeleteUser`, and the dedicated
Redis Stream `user:lifecycle_events` that feeds the Stage 23 `Game Lobby`
Race Name Directory cascade release.
## Outcomes
- `policy.SanctionCodePermanentBlock` joins the supported sanction
catalogue. The sanction collapses every `can_*` eligibility marker to
`false`, surfaces in the lobby-facing eligibility snapshot, and blocks
every self-service read and write with `409 conflict`. Admin reads still
return the record so operators can observe the state.
- `LimitCodeMaxRegisteredRaceNames` — already introduced by Stage 21 — is
now wired through the admin list index and the lifecycle write catalogue
has no further gap (tracked here so future stages do not re-open task
22.2).
- `UserAccount.DeletedAt` (`*time.Time`) represents the soft-delete state
of a regular-user record. When set, every external read path returns
`404 subject_not_found` for the `user_id`.
- `POST /api/v1/internal/users/{user_id}/delete` is the trusted command
used by `Admin Service` to soft-delete a regular user. The command is
idempotent per `user_id`: a second call after soft-delete returns
`404 subject_not_found` and does not re-emit the lifecycle event.
- `ports.UserLifecyclePublisher` plus `adapters/redis/lifecycleevents`
publish exactly one `user.lifecycle.permanent_blocked` event on a
successful permanent-block apply and exactly one `user.lifecycle.deleted`
event on a successful `DeleteUser`. Both events carry
`{event_type, user_id, occurred_at_ms, source, actor_type, actor_id?,
reason_code, trace_id?}`.
## Decisions
### 1. Dedicated Redis Stream
**Decision.** Lifecycle events live on their own stream (default
`user:lifecycle_events`) rather than extending the shared
`user:domain_events` stream.
**Why.** The consumer model is different: `Game Lobby` treats lifecycle
events as source-of-truth triggers for RND cascade release and wants a
narrow, at-least-once stream it can pin an offset on. Co-locating the
events with high-volume domain events (profile, settings) would force the
consumer to filter a much larger firehose and would couple retention
policies. A dedicated stream keeps the contract small.
### 2. Soft-Delete Preserves the Record
**Decision.** `DeleteUser` sets `UserAccount.DeletedAt` but preserves the
account record, the email/user-name lookup keys, and the admin indexes.
**Why.** Audit. Compliance and support workflows need to resolve a
`user_id` back to its last known `email`, `user_name`, and tariff state
after the user is gone. Hard-delete would break support. External reads
still surface the account as `subject_not_found` so the live contract is
clean.
### 3. `DeleteUser` Second-Call Semantics — `404`, Not `200`
**Decision.** A second `DeleteUser` call for the same `user_id` returns
`404 subject_not_found` rather than a cosmetic `200 OK` echoing the
existing `deleted_at`.
**Why.** This is the exit criterion in `lobby/PLAN.md` §Stage 22: "a second
call after soft-delete returns `subject_not_found`". It keeps the
`user_id` subject semantics uniform across every external surface (auth,
self-service, admin-read, lobby-eligibility, `DeleteUser` itself) — every
post-delete access converges on the same error code. It also avoids the
footgun of a "delete" that appears to succeed after the account is
already gone.
### 4. Soft-Deleted Email Returns `blocked`, Not `existing`
**Decision.** `Store.ResolveByEmail` and `Store.EnsureByEmail` return the
`blocked` outcome with `reason_code=account_deleted` when the email lookup
resolves to a soft-deleted account. They do not try to free the email
lookup or reassign the `user_id` to a new account.
**Why.** The alternative — reclaiming the email on soft-delete so
ensure-by-email can mint a fresh `user_id` — requires coordinated mutation
of multiple lookup keys across the delete path. The simpler rule "deleted
emails stay blocked" mirrors common platform practice, guarantees stable
audit trails (the old `user_id` remains resolvable by id), and sidesteps
any ambiguity about which account an authenticator should re-bind to. If
a compliance event demands the email be released, an explicit
`unblock-by-email` command can be added later without changing the Stage
22 contract.
### 5. Removing `permanent_block` Does Not Emit a Lifecycle Event
**Decision.** The `RemoveSanction` path does not publish a
`user.lifecycle.permanent_blocked` event or any lifecycle event when it
clears a `permanent_block` record.
**Why.** The spec phrasing in `lobby/PLAN.md` §22.4 is "emitted when
`SanctionCodePermanentBlock` becomes active on a user". The inverse
transition (admin un-blocks) is administratively supported, but Stage 23
does not currently need a signal to "un-cascade" RND state — that decision
is deferred. Emitting a complementary `permanent_block_removed` event now
would lock us into a consumer-facing shape before its consumer exists.
### 6. Publishing Is Post-Commit, Best-Effort
**Decision.** Both apply-permanent-block and delete publish the lifecycle
event after the persistence commit succeeds. Failure to publish logs and
increments `user.event_publication_failures` but does not roll back the
commit or fail the HTTP request.
**Why.** Matches the existing sanction/limit publisher shape
(`policysvc.publishSanctionChanged` et al.) and the global rule in
`galaxy/AGENTS.md`: never publish after a rollback; always publish after
commit. Rolling back on a publisher failure would leak partial state
through subsequent reads and invite inconsistent retries.
### 7. Admin Listing Excludes Soft-Deleted Accounts by Default
**Decision.** `adminusers.Lister` silently skips candidates whose
aggregate load returns `subject_not_found` (the effect of
`Loader.Load` for soft-deleted accounts). The OpenAPI schema exposes
`deleted_at` on `AccountView` for cases where a caller already holds the
record.
**Why.** Stage 22 needs the default behaviour to converge with the
exit-criterion "external admin-read of a deleted user returns
`subject_not_found`". A dedicated `deleted` filter (for admin workflows
that explicitly want the audit trail) is out of scope — it can be added
as a separate task without changing the core contract.
## Cross-References
- `galaxy/lobby/PLAN.md` §Stage 22 drives the exit criteria; §Stage 23 is
the downstream lobby consumer of `user:lifecycle_events`.
- `galaxy/ARCHITECTURE.md` §3 (User Service) and §7 (Race Name Directory)
describe the external-facing contract realised by this stage.
- Related module files: `internal/domain/policy/model.go`,
`internal/domain/account/model.go`,
`internal/service/accountdeletion/service.go`,
`internal/service/policysvc/service.go`,
`internal/adapters/redis/lifecycleevents/publisher.go`,
`internal/api/internalhttp/handler.go`, and `openapi.yaml`.