feat: user service
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
# User Service Docs
|
||||
|
||||
This directory keeps service-local documentation that is more operational or
|
||||
more example-heavy than [`../README.md`](../README.md).
|
||||
|
||||
Sections:
|
||||
|
||||
- [Runtime and components](runtime.md)
|
||||
- [Main flows and boundaries](flows.md)
|
||||
- [Operator runbook](runbook.md)
|
||||
- [Contract examples](examples.md)
|
||||
|
||||
Primary references:
|
||||
|
||||
- [`../README.md`](../README.md) for stable service scope and business rules
|
||||
- [`../openapi.yaml`](../openapi.yaml) for the trusted internal REST contract
|
||||
- [`../../ARCHITECTURE.md`](../../ARCHITECTURE.md) for system-level transport
|
||||
and ownership rules
|
||||
- [`../../TESTING.md`](../../TESTING.md) for the cross-service testing matrix
|
||||
@@ -0,0 +1,206 @@
|
||||
# Contract Examples
|
||||
|
||||
## ensure-by-email
|
||||
|
||||
Request:
|
||||
|
||||
```json
|
||||
{
|
||||
"email": "pilot@example.com",
|
||||
"registration_context": {
|
||||
"preferred_language": "en",
|
||||
"time_zone": "Europe/Kaliningrad"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Created response:
|
||||
|
||||
```json
|
||||
{
|
||||
"outcome": "created",
|
||||
"user_id": "user-123"
|
||||
}
|
||||
```
|
||||
|
||||
Existing response:
|
||||
|
||||
```json
|
||||
{
|
||||
"outcome": "existing",
|
||||
"user_id": "user-123"
|
||||
}
|
||||
```
|
||||
|
||||
Blocked response:
|
||||
|
||||
```json
|
||||
{
|
||||
"outcome": "blocked",
|
||||
"block_reason_code": "policy_blocked"
|
||||
}
|
||||
```
|
||||
|
||||
## account aggregate
|
||||
|
||||
```json
|
||||
{
|
||||
"account": {
|
||||
"user_id": "user-123",
|
||||
"email": "pilot@example.com",
|
||||
"race_name": "Pilot Nova",
|
||||
"preferred_language": "en",
|
||||
"time_zone": "Europe/Kaliningrad",
|
||||
"declared_country": "DE",
|
||||
"entitlement": {
|
||||
"plan_code": "free",
|
||||
"is_paid": false,
|
||||
"source": "auth_registration",
|
||||
"actor": {
|
||||
"type": "service",
|
||||
"id": "user-service"
|
||||
},
|
||||
"reason_code": "initial_free_entitlement",
|
||||
"starts_at": "2026-04-09T10:00:00Z",
|
||||
"updated_at": "2026-04-09T10:00:00Z"
|
||||
},
|
||||
"active_sanctions": [],
|
||||
"active_limits": [],
|
||||
"created_at": "2026-04-09T10:00:00Z",
|
||||
"updated_at": "2026-04-09T10:00:00Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## update profile
|
||||
|
||||
Request:
|
||||
|
||||
```json
|
||||
{
|
||||
"race_name": "Nova Prime"
|
||||
}
|
||||
```
|
||||
|
||||
Success:
|
||||
|
||||
```json
|
||||
{
|
||||
"account": {
|
||||
"user_id": "user-123",
|
||||
"email": "pilot@example.com",
|
||||
"race_name": "Nova Prime",
|
||||
"preferred_language": "en",
|
||||
"time_zone": "Europe/Kaliningrad",
|
||||
"entitlement": {
|
||||
"plan_code": "free",
|
||||
"is_paid": false,
|
||||
"source": "auth_registration",
|
||||
"actor": {
|
||||
"type": "service",
|
||||
"id": "user-service"
|
||||
},
|
||||
"reason_code": "initial_free_entitlement",
|
||||
"starts_at": "2026-04-09T10:00:00Z",
|
||||
"updated_at": "2026-04-09T10:00:00Z"
|
||||
},
|
||||
"active_sanctions": [],
|
||||
"active_limits": [],
|
||||
"created_at": "2026-04-09T10:00:00Z",
|
||||
"updated_at": "2026-04-09T10:05:00Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Conflict:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"code": "conflict",
|
||||
"message": "request conflicts with current state"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## update settings
|
||||
|
||||
Request:
|
||||
|
||||
```json
|
||||
{
|
||||
"preferred_language": "fr-FR",
|
||||
"time_zone": "Europe/Paris"
|
||||
}
|
||||
```
|
||||
|
||||
## admin lookup by e-mail
|
||||
|
||||
Request:
|
||||
|
||||
```json
|
||||
{
|
||||
"email": "pilot@example.com"
|
||||
}
|
||||
```
|
||||
|
||||
Success:
|
||||
|
||||
```json
|
||||
{
|
||||
"user": {
|
||||
"user_id": "user-123",
|
||||
"email": "pilot@example.com",
|
||||
"race_name": "Pilot Nova",
|
||||
"preferred_language": "en",
|
||||
"time_zone": "Europe/Kaliningrad",
|
||||
"entitlement": {
|
||||
"plan_code": "free",
|
||||
"is_paid": false,
|
||||
"source": "auth_registration",
|
||||
"actor": {
|
||||
"type": "service",
|
||||
"id": "user-service"
|
||||
},
|
||||
"reason_code": "initial_free_entitlement",
|
||||
"starts_at": "2026-04-09T10:00:00Z",
|
||||
"updated_at": "2026-04-09T10:00:00Z"
|
||||
},
|
||||
"active_sanctions": [],
|
||||
"active_limits": [],
|
||||
"created_at": "2026-04-09T10:00:00Z",
|
||||
"updated_at": "2026-04-09T10:00:00Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## declared-country sync
|
||||
|
||||
Request:
|
||||
|
||||
```json
|
||||
{
|
||||
"declared_country": "DE"
|
||||
}
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"user_id": "user-123",
|
||||
"declared_country": "DE",
|
||||
"updated_at": "2026-04-09T10:10:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
## shared error envelope
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"code": "invalid_request",
|
||||
"message": "request is invalid"
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,163 @@
|
||||
# Main Flows and Boundaries
|
||||
|
||||
## Auth / Session -> User
|
||||
|
||||
`Auth / Session Service` uses synchronous REST calls for user ownership
|
||||
decisions during public auth.
|
||||
|
||||
### Resolve by e-mail
|
||||
|
||||
`POST /api/v1/internal/user-resolutions/by-email`
|
||||
|
||||
Outcome vocabulary:
|
||||
|
||||
- `creatable`
|
||||
- `existing`
|
||||
- `blocked`
|
||||
|
||||
The decision is based on exact-after-trim e-mail matching plus the current
|
||||
block state for that subject.
|
||||
|
||||
### Ensure by e-mail
|
||||
|
||||
`POST /api/v1/internal/users/ensure-by-email`
|
||||
|
||||
Rules:
|
||||
|
||||
- `registration_context` is required
|
||||
- `registration_context` is create-only
|
||||
- existing users ignore the supplied registration context
|
||||
- blocked subjects return `blocked` rather than creating a user
|
||||
- the current rollout sends temporary `preferred_language="en"` from
|
||||
authsession and forwards the public confirm `time_zone`
|
||||
|
||||
Create side effects:
|
||||
|
||||
- generate opaque `user_id`
|
||||
- generate default `player-*` race name
|
||||
- store initial preferred language and time zone
|
||||
- materialize the initial free entitlement snapshot
|
||||
- publish initialization-style profile, settings, and entitlement events
|
||||
|
||||
## Gateway -> User
|
||||
|
||||
Gateway owns the external authenticated gRPC contract and transcodes to this
|
||||
service's internal REST API.
|
||||
|
||||
External authenticated message types:
|
||||
|
||||
- `user.account.get`
|
||||
- `user.profile.update`
|
||||
- `user.settings.update`
|
||||
|
||||
Internal REST routes:
|
||||
|
||||
- `GET /api/v1/internal/users/{user_id}/account`
|
||||
- `POST /api/v1/internal/users/{user_id}/profile`
|
||||
- `POST /api/v1/internal/users/{user_id}/settings`
|
||||
|
||||
Rules:
|
||||
|
||||
- gateway derives `user_id` from authenticated session context only
|
||||
- success returns the shared account aggregate
|
||||
- business errors return stable `code` and `message`
|
||||
- timeout or upstream `503` stay transport-level unavailable at gateway
|
||||
|
||||
### Profile update
|
||||
|
||||
`UpdateMyProfile` changes only `race_name`.
|
||||
|
||||
Rules:
|
||||
|
||||
- preserve stored casing on success
|
||||
- enforce canonical reservation uniqueness
|
||||
- reject conflicts as `409 conflict`
|
||||
- reject writes while `profile_update_block` is active
|
||||
- return current aggregate on no-op rename
|
||||
|
||||
### Settings update
|
||||
|
||||
`UpdateMySettings` changes only:
|
||||
|
||||
- `preferred_language`
|
||||
- `time_zone`
|
||||
|
||||
Rules:
|
||||
|
||||
- validate BCP 47 and IANA semantics
|
||||
- reject writes while `profile_update_block` is active
|
||||
- return the refreshed account aggregate
|
||||
|
||||
## Lobby -> User
|
||||
|
||||
`Game Lobby Service` reads one synchronous eligibility snapshot through:
|
||||
|
||||
- `GET /api/v1/internal/users/{user_id}/eligibility`
|
||||
|
||||
Rules:
|
||||
|
||||
- unknown users return `exists=false`
|
||||
- current entitlement is expiry-repaired lazily
|
||||
- active sanctions are filtered to the lobby-relevant set
|
||||
- effective limits combine default catalog values plus active overrides
|
||||
- markers are derived from sanctions, entitlement, and limits
|
||||
|
||||
## Geo -> User
|
||||
|
||||
`Geo Profile Service` synchronizes the latest approved effective declared
|
||||
country through:
|
||||
|
||||
- `POST /api/v1/internal/users/{user_id}/declared-country/sync`
|
||||
|
||||
Rules:
|
||||
|
||||
- input must be uppercase ISO 3166-1 alpha-2
|
||||
- syncing the stored value is a no-op
|
||||
- `User Service` stores only the current effective value
|
||||
- geo owns review workflow and history
|
||||
- successful updates publish `user.declared_country.changed`
|
||||
|
||||
## Admin Reads And Commands
|
||||
|
||||
Trusted admin callers use:
|
||||
|
||||
- exact reads by `user_id`, e-mail, and race name
|
||||
- deterministic filtered listing
|
||||
- explicit entitlement commands
|
||||
- explicit sanction commands
|
||||
- explicit limit commands
|
||||
|
||||
Listing rules:
|
||||
|
||||
- order by `created_at desc`, then `user_id desc`
|
||||
- combine filters with `AND`
|
||||
- `page_token` is opaque and filter-bound
|
||||
|
||||
## Domain Events
|
||||
|
||||
The shared auxiliary event stream contains post-commit state propagation for:
|
||||
|
||||
- `user.profile.changed`
|
||||
- `user.settings.changed`
|
||||
- `user.entitlement.changed`
|
||||
- `user.sanction.changed`
|
||||
- `user.limit.changed`
|
||||
- `user.declared_country.changed`
|
||||
|
||||
Operation vocabularies:
|
||||
|
||||
- profile and settings:
|
||||
- `initialized`
|
||||
- `updated`
|
||||
- entitlement:
|
||||
- `initialized`
|
||||
- `granted`
|
||||
- `extended`
|
||||
- `revoked`
|
||||
- `expired_repaired`
|
||||
- sanction:
|
||||
- `applied`
|
||||
- `removed`
|
||||
- limit:
|
||||
- `set`
|
||||
- `removed`
|
||||
@@ -0,0 +1,106 @@
|
||||
# Runbook
|
||||
|
||||
## Startup Checklist
|
||||
|
||||
Before starting `userservice`, verify:
|
||||
|
||||
- `USERSERVICE_REDIS_ADDR` points to the intended Redis instance
|
||||
- internal HTTP bind address is free
|
||||
- optional admin metrics listener does not collide with another process
|
||||
- domain-events stream settings match the environment that consumes them
|
||||
|
||||
Expected startup behavior:
|
||||
|
||||
- configuration is loaded and validated first
|
||||
- Redis-backed stores and publishers are constructed
|
||||
- startup fails fast on Redis misconfiguration or connectivity failure
|
||||
|
||||
## Health And Readiness
|
||||
|
||||
`userservice` does not expose public health endpoints.
|
||||
|
||||
Operational readiness is typically checked through one trusted internal route,
|
||||
for example:
|
||||
|
||||
- `GET /api/v1/internal/users/{user_id}/exists`
|
||||
|
||||
with a guaranteed-missing `user_id`. A healthy process returns `200` with
|
||||
`{"exists":false}`.
|
||||
|
||||
If admin metrics are enabled, `/metrics` on the admin listener is the
|
||||
additional process-level operational endpoint.
|
||||
|
||||
## Common Failure Modes
|
||||
|
||||
### Redis unavailable
|
||||
|
||||
Symptoms:
|
||||
|
||||
- process fails during startup
|
||||
- internal API returns `503 service_unavailable`
|
||||
- domain events stop being published
|
||||
|
||||
Checks:
|
||||
|
||||
- connectivity to `USERSERVICE_REDIS_ADDR`
|
||||
- Redis ACL credentials
|
||||
- Redis DB number
|
||||
- TLS setting mismatch
|
||||
|
||||
### Invalid registration context
|
||||
|
||||
Symptoms:
|
||||
|
||||
- `ensure-by-email` returns `400 invalid_request`
|
||||
|
||||
Checks:
|
||||
|
||||
- `preferred_language` is a valid BCP 47 tag
|
||||
- `time_zone` is a valid IANA time-zone name
|
||||
|
||||
### race_name conflict
|
||||
|
||||
Symptoms:
|
||||
|
||||
- profile update returns `409 conflict`
|
||||
|
||||
Checks:
|
||||
|
||||
- desired race name is not already reserved under canonical uniqueness rules
|
||||
- user is not currently blocked by `profile_update_block`
|
||||
|
||||
### declared-country sync rejected
|
||||
|
||||
Symptoms:
|
||||
|
||||
- geo sync returns `400 invalid_request`
|
||||
|
||||
Checks:
|
||||
|
||||
- country code is uppercase ISO 3166-1 alpha-2
|
||||
- trusted caller is using the intended internal route
|
||||
|
||||
## Safe Rollout Notes
|
||||
|
||||
- Keep `Auth / Session Service` and `User Service` aligned on the current
|
||||
`registration_context` shape.
|
||||
- During the current rollout, treat authsession-provided
|
||||
`preferred_language="en"` as the active create-path contract.
|
||||
- Gateway direct `user.*` self-service routing depends on the internal REST
|
||||
routes staying stable.
|
||||
- Do not roll out billing-driven entitlement mutations assuming another
|
||||
service owns current entitlement state. `User Service` remains the source of
|
||||
truth for current entitlement.
|
||||
|
||||
## Debugging Data Mismatches
|
||||
|
||||
When a caller reports mismatched user state:
|
||||
|
||||
1. Read the current account aggregate through the trusted internal route.
|
||||
2. Confirm whether the discrepancy is in source-of-truth state or in a
|
||||
downstream projection.
|
||||
3. If the issue concerns declared-country workflow history, switch to `Geo
|
||||
Profile Service`; `User Service` stores only the current effective value.
|
||||
4. If the issue concerns authenticated edge transport, verify the same user
|
||||
through gateway `user.account.get` to distinguish transport problems from
|
||||
source-of-truth problems.
|
||||
@@ -0,0 +1,151 @@
|
||||
# Runtime and Components
|
||||
|
||||
The diagram below focuses on the deployed `galaxy/user` process and its
|
||||
runtime dependencies.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph Callers
|
||||
Auth["Auth / Session Service"]
|
||||
Gateway["Edge Gateway"]
|
||||
Lobby["Game Lobby Service"]
|
||||
Geo["Geo Profile Service"]
|
||||
Admin["Trusted admin callers"]
|
||||
end
|
||||
|
||||
subgraph User["User Service process"]
|
||||
InternalHTTP["Trusted internal HTTP listener\n/api/v1/internal/*"]
|
||||
AdminHTTP["Optional admin HTTP listener\n/metrics"]
|
||||
Services["Application services"]
|
||||
Telemetry["Logs, traces, metrics"]
|
||||
end
|
||||
|
||||
Redis["Redis\nkeyspace + domain-events stream"]
|
||||
|
||||
Auth --> InternalHTTP
|
||||
Gateway --> InternalHTTP
|
||||
Lobby --> InternalHTTP
|
||||
Geo --> InternalHTTP
|
||||
Admin --> InternalHTTP
|
||||
InternalHTTP --> Services
|
||||
Services --> Redis
|
||||
InternalHTTP --> Telemetry
|
||||
AdminHTTP --> Telemetry
|
||||
```
|
||||
|
||||
## Listeners
|
||||
|
||||
`userservice` exposes two HTTP listeners:
|
||||
|
||||
| Listener | Default addr | Purpose |
|
||||
| --- | --- | --- |
|
||||
| Internal HTTP | `:8091` | Trusted business API under `/api/v1/internal/*` |
|
||||
| Admin HTTP | disabled | Optional Prometheus metrics on `/metrics` |
|
||||
|
||||
Shared listener defaults:
|
||||
|
||||
- read-header timeout: `2s`
|
||||
- read timeout: `10s`
|
||||
- idle timeout: `1m`
|
||||
|
||||
The internal application timeout is configured separately through
|
||||
`USERSERVICE_INTERNAL_HTTP_REQUEST_TIMEOUT`.
|
||||
|
||||
Intentional omissions:
|
||||
|
||||
- no public listener
|
||||
- no authenticated edge gRPC listener
|
||||
- no built-in `/healthz`
|
||||
- no built-in `/readyz`
|
||||
|
||||
## Startup Wiring
|
||||
|
||||
`cmd/userservice` loads config, constructs logging and telemetry, and then
|
||||
creates the runtime through `internal/app.NewRuntime`.
|
||||
|
||||
The runtime wires:
|
||||
|
||||
- Redis-backed stores for accounts, entitlement snapshots, sanctions, limits,
|
||||
and listing indexes
|
||||
- the trusted internal HTTP router
|
||||
- the optional admin metrics listener
|
||||
- the optional Redis-backed domain-event publishers
|
||||
- service-local helpers for clock, IDs, and validation/policy adapters
|
||||
|
||||
Startup fails fast when Redis connectivity is unavailable or configuration is
|
||||
invalid.
|
||||
|
||||
## Redis Namespaces
|
||||
|
||||
The service uses one Redis keyspace prefix plus one auxiliary domain-events
|
||||
stream.
|
||||
|
||||
Configuration:
|
||||
|
||||
- `USERSERVICE_REDIS_KEYSPACE_PREFIX`
|
||||
- `USERSERVICE_REDIS_DOMAIN_EVENTS_STREAM`
|
||||
- `USERSERVICE_REDIS_DOMAIN_EVENTS_STREAM_MAX_LEN`
|
||||
|
||||
The keyspace stores source-of-truth business state. The stream carries
|
||||
post-commit auxiliary domain events and must not be treated as the source of
|
||||
truth.
|
||||
|
||||
## Configuration Groups
|
||||
|
||||
Required for all process starts:
|
||||
|
||||
- `USERSERVICE_REDIS_ADDR`
|
||||
|
||||
Core process config:
|
||||
|
||||
- `USERSERVICE_SHUTDOWN_TIMEOUT`
|
||||
- `USERSERVICE_LOG_LEVEL`
|
||||
|
||||
Internal HTTP config:
|
||||
|
||||
- `USERSERVICE_INTERNAL_HTTP_ADDR`
|
||||
- `USERSERVICE_INTERNAL_HTTP_READ_HEADER_TIMEOUT`
|
||||
- `USERSERVICE_INTERNAL_HTTP_READ_TIMEOUT`
|
||||
- `USERSERVICE_INTERNAL_HTTP_IDLE_TIMEOUT`
|
||||
- `USERSERVICE_INTERNAL_HTTP_REQUEST_TIMEOUT`
|
||||
|
||||
Admin HTTP config:
|
||||
|
||||
- `USERSERVICE_ADMIN_HTTP_ADDR`
|
||||
- `USERSERVICE_ADMIN_HTTP_READ_HEADER_TIMEOUT`
|
||||
- `USERSERVICE_ADMIN_HTTP_READ_TIMEOUT`
|
||||
- `USERSERVICE_ADMIN_HTTP_IDLE_TIMEOUT`
|
||||
|
||||
Redis connectivity and namespace config:
|
||||
|
||||
- `USERSERVICE_REDIS_USERNAME`
|
||||
- `USERSERVICE_REDIS_PASSWORD`
|
||||
- `USERSERVICE_REDIS_DB`
|
||||
- `USERSERVICE_REDIS_TLS_ENABLED`
|
||||
- `USERSERVICE_REDIS_OPERATION_TIMEOUT`
|
||||
- `USERSERVICE_REDIS_KEYSPACE_PREFIX`
|
||||
- `USERSERVICE_REDIS_DOMAIN_EVENTS_STREAM`
|
||||
- `USERSERVICE_REDIS_DOMAIN_EVENTS_STREAM_MAX_LEN`
|
||||
|
||||
Telemetry:
|
||||
|
||||
- `OTEL_SERVICE_NAME`
|
||||
- `OTEL_TRACES_EXPORTER`
|
||||
- `OTEL_METRICS_EXPORTER`
|
||||
- `OTEL_EXPORTER_OTLP_PROTOCOL`
|
||||
- `OTEL_EXPORTER_OTLP_TRACES_PROTOCOL`
|
||||
- `OTEL_EXPORTER_OTLP_METRICS_PROTOCOL`
|
||||
- `USERSERVICE_OTEL_STDOUT_TRACES_ENABLED`
|
||||
- `USERSERVICE_OTEL_STDOUT_METRICS_ENABLED`
|
||||
|
||||
## Runtime Notes
|
||||
|
||||
- The service remains internal REST only; gateway owns external authenticated
|
||||
gRPC and FlatBuffers.
|
||||
- Gateway self-service traffic reaches this service over REST/JSON after
|
||||
gateway-side authentication and FlatBuffers transcoding.
|
||||
- Current direct synchronous callers are `Auth / Session Service`,
|
||||
`Edge Gateway`, `Game Lobby Service`, `Geo Profile Service`, and trusted
|
||||
admin callers.
|
||||
- Domain-event publication is auxiliary. A failed auxiliary consumer must not
|
||||
become the source of truth for current account state.
|
||||
Reference in New Issue
Block a user