feat: user service

This commit is contained in:
Ilia Denisov
2026-04-10 19:05:02 +02:00
committed by GitHub
parent 710bad712e
commit 23ffcb7535
140 changed files with 33418 additions and 952 deletions
+19
View File
@@ -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
+206
View File
@@ -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"
}
}
```
+163
View File
@@ -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`
+106
View File
@@ -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.
+151
View File
@@ -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.