feat: game lobby service
This commit is contained in:
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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`.
|
||||
Reference in New Issue
Block a user