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
+30 -8
View File
@@ -43,7 +43,9 @@ The current v1 platform uses Redis as the main data store and Redis Streams as t
* The platform exposes a single external entry point: **Edge Gateway**. * The platform exposes a single external entry point: **Edge Gateway**.
* Public unauthenticated flows use REST/JSON. * Public unauthenticated flows use REST/JSON.
* Authenticated user traffic uses signed gRPC over HTTP/2 with protobuf control envelopes and FlatBuffers payload bytes. * Authenticated user edge traffic uses signed gRPC over HTTP/2 with protobuf control envelopes and FlatBuffers payload bytes.
* Trusted synchronous inter-service traffic uses REST/JSON unless a service-specific contract states otherwise.
* For the direct `Gateway -> User` self-service boundary, gateway keeps the external authenticated gRPC + FlatBuffers contract and performs REST/JSON transcoding toward `User Service` internally.
* The gateway handles only edge concerns: parsing, authentication, integrity checks, anti-replay, rate limiting, routing, and push delivery. Business authorization and domain rules remain in downstream services. * The gateway handles only edge concerns: parsing, authentication, integrity checks, anti-replay, rate limiting, routing, and push delivery. Business authorization and domain rules remain in downstream services.
* `Auth / Session Service` is the source of truth for `device_session`, but it is not on the hot path of every authenticated request. Gateway authenticates steady-state traffic from session cache and lifecycle updates. * `Auth / Session Service` is the source of truth for `device_session`, but it is not on the hot path of every authenticated request. Gateway authenticates steady-state traffic from session cache and lifecycle updates.
* `Game Lobby` owns platform-level metadata of game sessions. * `Game Lobby` owns platform-level metadata of game sessions.
@@ -65,6 +67,10 @@ The gateway already distinguishes:
* public REST/JSON for unauthenticated traffic such as health checks and public auth; * public REST/JSON for unauthenticated traffic such as health checks and public auth;
* authenticated gRPC over HTTP/2 for verified commands and push delivery. * authenticated gRPC over HTTP/2 for verified commands and push delivery.
For downstream business services, the current default trusted transport is
strict REST/JSON. Gateway may therefore authenticate and verify one external
FlatBuffers command, then transcode it to one trusted downstream REST call.
The public auth contract is: The public auth contract is:
* `send-email-code(email) -> challenge_id` * `send-email-code(email) -> challenge_id`
@@ -230,17 +236,22 @@ Direct integrations:
## 3. [User Service](user/README.md) ## 3. [User Service](user/README.md)
`User Service` owns user identity and profile as platform-level business data. `User Service` owns regular-user identity and profile as platform-level
business data.
It is the source of truth for: It is the source of truth for:
* `user_id`; * `user_id` of regular platform users;
* profile fields and editable user settings; * regular-user profile fields and editable user settings;
* role model, including admin role;
* current tariff/entitlement state; * current tariff/entitlement state;
* user-specific limits and platform sanctions; * user-specific limits and platform sanctions;
* latest effective `declared_country`. * latest effective `declared_country`.
System-administrator identity remains outside this service and belongs to the
later `Admin Service`. Trusted administrative reads and mutations against
regular-user state do not make `User Service` the owner of administrator
identity.
It is directly reachable through gateway for selected user-facing operations such as: It is directly reachable through gateway for selected user-facing operations such as:
* reading and editing allowed profile fields; * reading and editing allowed profile fields;
@@ -253,6 +264,17 @@ Not every profile mutation goes directly here. For example:
* email change must use a code-confirm flow; * email change must use a code-confirm flow;
* `declared_country` change remains under admin approval flow via `Geo Profile Service`. * `declared_country` change remains under admin approval flow via `Geo Profile Service`.
Architectural rules fixed for this service:
* `User Service` owns regular-user identity only; system-admin identity is out
of scope.
* `User Service` stores only the current effective `declared_country`; review
workflow and history belong to `Geo Profile Service`.
* During the current auth-registration rollout, `Auth / Session Service`
passes temporary `preferred_language="en"` plus the confirmed `time_zone`
into `User Service`. Gateway-side geoip language derivation is a later
rollout step and is not part of the current source-of-truth contract.
Future billing does not become a direct dependency of other services. `Billing Service` will feed entitlement/payment outcomes into `User Service`, and the rest of the platform will continue to use `User Service` as the source of truth for current entitlements. Future billing does not become a direct dependency of other services. `Billing Service` will feed entitlement/payment outcomes into `User Service`, and the rest of the platform will continue to use `User Service` as the source of truth for current entitlements.
## 4. Mail Service ## 4. Mail Service
@@ -533,7 +555,7 @@ flowchart TD
N["Notification Service"] N["Notification Service"]
M["Mail Service"] M["Mail Service"]
U -->|"users, roles, tariffs, limits, sanctions, current declared_country"| X1["Platform user identity"] U -->|"regular users, profile/settings, tariffs, limits, sanctions, current declared_country"| X1["Platform user identity"]
A -->|"challenges, device sessions, revoke/block state"| X2["Auth/session state"] A -->|"challenges, device sessions, revoke/block state"| X2["Auth/session state"]
L -->|"game metadata, invites, applications, membership, roster"| X3["Platform game records"] L -->|"game metadata, invites, applications, membership, roster"| X3["Platform game records"]
G -->|"runtime state, current turn, engine health, engine mapping, engine version registry"| X4["Running-game state"] G -->|"runtime state, current turn, engine health, engine mapping, engine version registry"| X4["Running-game state"]
@@ -918,8 +940,8 @@ Recommended order for implementation is:
2. **Auth / Session Service** (implemented) 2. **Auth / Session Service** (implemented)
Public auth flow, `device_session`, revoke/block lifecycle, gateway session projection. Public auth flow, `device_session`, revoke/block lifecycle, gateway session projection.
3. **User Service** (planned) 3. **User Service** (implemented)
Platform user identity, roles, tariffs/entitlements, user limits, settings, sanctions, and current `declared_country`. Regular-user identity, profile/settings, tariffs/entitlements, user limits, sanctions, and current `declared_country`.
4. **Mail Service** 4. **Mail Service**
Internal email delivery for auth codes first, later for platform notifications. Internal email delivery for auth codes first, later for platform notifications.
+8 -7
View File
@@ -368,10 +368,9 @@ The testing plan follows this service order:
* create user * create user
* find by email * find by email
* normalized email uniqueness * exact-after-trim e-mail storage and lookup semantics
* generated default `race_name` for new users * generated default `race_name` for new users
* `race_name` uniqueness and confusable-substitution policy * `race_name` uniqueness and confusable-substitution policy
* role assignment
* tariff/entitlement fields * tariff/entitlement fields
* Profile tests: * Profile tests:
@@ -400,7 +399,7 @@ The testing plan follows this service order:
* resolve existing/creatable/blocked decision for auth * resolve existing/creatable/blocked decision for auth
* `ensure-by-email` create-only `registration_context` semantics * `ensure-by-email` create-only `registration_context` semantics
* current `declared_country` read/write path * current `declared_country` read/write path
* exact lookup by `user_id`, normalized `email`, and `race_name` * exact lookup by `user_id`, exact-after-trim `email`, and exact `race_name`
* paginated filtered listing with deterministic ordering * paginated filtered listing with deterministic ordering
* Storage and API contract tests: * Storage and API contract tests:
@@ -417,13 +416,15 @@ The testing plan follows this service order:
* blocked-by-policy outcome * blocked-by-policy outcome
* `Gateway <-> User` * `Gateway <-> User`
* authenticated profile read * authenticated `user.account.get`
* authenticated allowed profile update * authenticated successful `user.profile.update`
* tariff and settings read paths * authenticated successful `user.settings.update`
* `profile_update_block` conflict projection
* invalid-request projection for malformed self-service payload values
* `Gateway <-> Auth / Session <-> User` * `Gateway <-> Auth / Session <-> User`
* first registration by email * first registration by email
* repeat login by same email * repeat login by same email without overwriting create-only settings
* blocked email/user behavior * blocked email/user behavior
### Regression tests to keep from this stage onward ### Regression tests to keep from this stage onward
+3
View File
@@ -146,6 +146,9 @@ key registered for the created device session.
rollout phase, successful confirms forward create-only user registration rollout phase, successful confirms forward create-only user registration
context to `User Service` as `preferred_language="en"` and the supplied context to `User Service` as `preferred_language="en"` and the supplied
`time_zone` until gateway geoip-based language derivation is deployed. `time_zone` until gateway geoip-based language derivation is deployed.
`User Service` now validates `preferred_language` as BCP 47 and canonicalizes
the stored value on creation, so any future derived language must already be a
valid BCP 47 tag before auth forwards it.
Public boundary rules: Public boundary rules:
+23 -23
View File
@@ -4,45 +4,45 @@ go 1.26.0
require ( require (
github.com/alicebob/miniredis/v2 v2.37.0 github.com/alicebob/miniredis/v2 v2.37.0
github.com/getkin/kin-openapi v0.134.0 github.com/getkin/kin-openapi v0.135.0
github.com/gin-gonic/gin v1.12.0 github.com/gin-gonic/gin v1.12.0
github.com/redis/go-redis/v9 v9.18.0 github.com/redis/go-redis/v9 v9.18.0
github.com/stretchr/testify v1.11.1 github.com/stretchr/testify v1.11.1
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.67.0 go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.68.0
go.opentelemetry.io/otel v1.42.0 go.opentelemetry.io/otel v1.43.0
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.42.0 go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.43.0
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.42.0 go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.42.0 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.42.0 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.42.0 go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.42.0 go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0
go.opentelemetry.io/otel/metric v1.42.0 go.opentelemetry.io/otel/metric v1.43.0
go.opentelemetry.io/otel/sdk v1.42.0 go.opentelemetry.io/otel/sdk v1.43.0
go.opentelemetry.io/otel/sdk/metric v1.42.0 go.opentelemetry.io/otel/sdk/metric v1.43.0
go.opentelemetry.io/otel/trace v1.42.0 go.opentelemetry.io/otel/trace v1.43.0
go.uber.org/zap v1.27.1 go.uber.org/zap v1.27.1
golang.org/x/crypto v0.49.0 golang.org/x/crypto v0.49.0
) )
require ( require (
github.com/bytedance/gopkg v0.1.3 // indirect github.com/bytedance/gopkg v0.1.4 // indirect
github.com/bytedance/sonic v1.15.0 // indirect github.com/bytedance/sonic v1.15.0 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect github.com/bytedance/sonic/loader v0.5.1 // indirect
github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect github.com/cloudwego/base64x v0.1.6 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/gabriel-vasile/mimetype v1.4.13 // indirect github.com/gabriel-vasile/mimetype v1.4.13 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect github.com/gin-contrib/sse v1.1.1 // indirect
github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-openapi/jsonpointer v0.21.0 // indirect github.com/go-openapi/jsonpointer v0.21.0 // indirect
github.com/go-openapi/swag v0.23.0 // indirect github.com/go-openapi/swag v0.23.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.30.1 // indirect github.com/go-playground/validator/v10 v10.30.2 // indirect
github.com/goccy/go-json v0.10.5 // indirect github.com/goccy/go-json v0.10.6 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect github.com/goccy/go-yaml v1.19.2 // indirect
github.com/google/uuid v1.6.0 // indirect github.com/google/uuid v1.6.0 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect
@@ -55,9 +55,9 @@ require (
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
github.com/oasdiff/yaml v0.0.0-20260313112342-a3ea61cb4d4c // indirect github.com/oasdiff/yaml v0.0.9 // indirect
github.com/oasdiff/yaml3 v0.0.0-20260224194419-61cd415a242b // indirect github.com/oasdiff/yaml3 v0.0.9 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pelletier/go-toml/v2 v2.3.0 // indirect
github.com/perimeterx/marshmallow v1.1.5 // indirect github.com/perimeterx/marshmallow v1.1.5 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/quic-go/qpack v0.6.0 // indirect github.com/quic-go/qpack v0.6.0 // indirect
@@ -68,11 +68,11 @@ require (
github.com/yuin/gopher-lua v1.1.1 // indirect github.com/yuin/gopher-lua v1.1.1 // indirect
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.42.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 // indirect
go.opentelemetry.io/proto/otlp v1.10.0 // indirect go.opentelemetry.io/proto/otlp v1.10.0 // indirect
go.uber.org/atomic v1.11.0 // indirect go.uber.org/atomic v1.11.0 // indirect
go.uber.org/multierr v1.10.0 // indirect go.uber.org/multierr v1.10.0 // indirect
golang.org/x/arch v0.24.0 // indirect golang.org/x/arch v0.25.0 // indirect
golang.org/x/net v0.52.0 // indirect golang.org/x/net v0.52.0 // indirect
golang.org/x/sys v0.42.0 // indirect golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.35.0 // indirect golang.org/x/text v0.35.0 // indirect
+24 -48
View File
@@ -4,12 +4,10 @@ github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE= github.com/bytedance/sonic/loader v0.5.1 h1:Ygpfa9zwRCCKSlrp5bBP/b/Xzc3VxsAW+5NIYXrOOpI=
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
@@ -23,10 +21,8 @@ github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/r
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM= github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/getkin/kin-openapi v0.134.0 h1:/L5+1+kfe6dXh8Ot/wqiTgUkjOIEJiC0bbYVziHB8rU= github.com/getkin/kin-openapi v0.135.0 h1:751SjYfbiwqukYuVjwYEIKNfrSwS5YpA7DZnKSwQgtg=
github.com/getkin/kin-openapi v0.134.0/go.mod h1:wK6ZLG/VgoETO9pcLJ/VmAtIcl/DNlMayNTb716EUxE= github.com/gin-contrib/sse v1.1.1 h1:uGYpNwTacv5R68bSGMapo62iLTRa9l5zxGCps4hK6ko=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc= github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
@@ -44,12 +40,10 @@ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/o
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w= github.com/go-playground/validator/v10 v10.30.2 h1:JiFIMtSSHb2/XBUbWM4i/MpeQm9ZK2xqPNk8vgvu5JQ=
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM= github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM=
github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
@@ -84,12 +78,9 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw= github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw=
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8= github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8=
github.com/oasdiff/yaml v0.0.0-20260313112342-a3ea61cb4d4c h1:7ACFcSaQsrWtrH4WHHfUqE1C+f8r2uv8KGaW0jTNjus= github.com/oasdiff/yaml v0.0.9 h1:zQOvd2UKoozsSsAknnWoDJlSK4lC0mpmjfDsfqNwX48=
github.com/oasdiff/yaml v0.0.0-20260313112342-a3ea61cb4d4c/go.mod h1:JKox4Gszkxt57kj27u7rvi7IFoIULvCZHUsBTUmQM/s= github.com/oasdiff/yaml3 v0.0.9 h1:rWPrKccrdUm8J0F3sGuU+fuh9+1K/RdJlWF7O/9yw2g=
github.com/oasdiff/yaml3 v0.0.0-20260224194419-61cd415a242b h1:vivRhVUAa9t1q0Db4ZmezBP8pWQWnXHFokZj0AOea2g= github.com/pelletier/go-toml/v2 v2.3.0 h1:k59bC/lIZREW0/iVaQR8nDHxVq8OVlIzYCOJf421CaM=
github.com/oasdiff/yaml3 v0.0.0-20260224194419-61cd415a242b/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s= github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s=
github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw= github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
@@ -127,34 +118,20 @@ go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.67.0 h1:E7DmskpIO7ZR6QI6zKSEKIDNUYoKw9oHXP23gzbCdU0= go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.68.0 h1:5FXSL2s6afUC1bzNzl1iedZZ8yqR7GOhbCoEXtyeK6Q=
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.67.0/go.mod h1:WB2cS9y+AwqqKhoo9gw6/ZxlSjFBUQGZ8BQOaD3FVXM= go.opentelemetry.io/contrib/propagators/b3 v1.43.0 h1:CETqV3QLLPTy5yNrqyMr41VnAOOD4lsRved7n4QG00A=
go.opentelemetry.io/contrib/propagators/b3 v1.42.0 h1:B2Pew5ufEtgkjLF+tSkXjgYZXQr9m7aCm1wLKB0URbU= go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
go.opentelemetry.io/contrib/propagators/b3 v1.42.0/go.mod h1:iPgUcSEF5DORW6+yNbdw/YevUy+QqJ508ncjhrRSCjc= go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.43.0 h1:8UQVDcZxOJLtX6gxtDt3vY2WTgvZqMQRzjsqiIHQdkc=
go.opentelemetry.io/otel v1.42.0 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho= go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0 h1:w1K+pCJoPpQifuVpsKamUdn9U0zM3xUziVOqsGksUrY=
go.opentelemetry.io/otel v1.42.0/go.mod h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOXWkdqCc= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 h1:88Y4s2C8oTui1LGM6bTWkw0ICGcOLCAI5l6zsD1j20k=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.42.0 h1:MdKucPl/HbzckWWEisiNqMPhRrAOQX8r4jTuGr636gk= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0 h1:RAE+JPfvEmvy+0LzyUA25/SGawPwIUbZ6u0Wug54sLc=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.42.0/go.mod h1:RolT8tWtfHcjajEH5wFIZ4Dgh5jpPdFXYV9pTAk/qjc= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 h1:3iZJKlCZufyRzPzlQhUIWVmfltrXuGyfjREgGP3UUjc=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.42.0 h1:H7O6RlGOMTizyl3R08Kn5pdM06bnH8oscSj7o11tmLA= go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0 h1:TC+BewnDpeiAmcscXbGMfxkO+mwYUwE/VySwvw88PfA=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.42.0/go.mod h1:mBFWu/WOVDkWWsR7Tx7h6EpQB8wsv7P0Yrh0Pb7othc= go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0 h1:mS47AX77OtFfKG4vtp+84kuGSFZHTyxtXIN269vChY0=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.42.0 h1:THuZiwpQZuHPul65w4WcwEnkX2QIuMT+UFoOrygtoJw= go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.42.0/go.mod h1:J2pvYM5NGHofZ2/Ru6zw/TNWnEQp5crgyDeSrYpXkAw= go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.42.0 h1:zWWrB1U6nqhS/k6zYB74CjRpuiitRtLLi68VcgmOEto= go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.42.0/go.mod h1:2qXPNBX1OVRC0IwOnfo1ljoid+RD0QK3443EaqVlsOU= go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.42.0 h1:uLXP+3mghfMf7XmV4PkGfFhFKuNWoCvvx5wP/wOXo0o=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.42.0/go.mod h1:v0Tj04armyT59mnURNUJf7RCKcKzq+lgJs6QSjHjaTc=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.42.0 h1:lSZHgNHfbmQTPfuTmWVkEu8J8qXaQwuV30pjCcAUvP8=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.42.0/go.mod h1:so9ounLcuoRDu033MW/E0AD4hhUjVqswrMF5FoZlBcw=
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.42.0 h1:s/1iRkCKDfhlh1JF26knRneorus8aOwVIDhvYx9WoDw=
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.42.0/go.mod h1:UI3wi0FXg1Pofb8ZBiBLhtMzgoTm1TYkMvn71fAqDzs=
go.opentelemetry.io/otel/metric v1.42.0 h1:2jXG+3oZLNXEPfNmnpxKDeZsFI5o4J+nz6xUlaFdF/4=
go.opentelemetry.io/otel/metric v1.42.0/go.mod h1:RlUN/7vTU7Ao/diDkEpQpnz3/92J9ko05BIwxYa2SSI=
go.opentelemetry.io/otel/sdk v1.42.0 h1:LyC8+jqk6UJwdrI/8VydAq/hvkFKNHZVIWuslJXYsDo=
go.opentelemetry.io/otel/sdk v1.42.0/go.mod h1:rGHCAxd9DAph0joO4W6OPwxjNTYWghRWmkHuGbayMts=
go.opentelemetry.io/otel/sdk/metric v1.42.0 h1:D/1QR46Clz6ajyZ3G8SgNlTJKBdGp84q9RKCAZ3YGuA=
go.opentelemetry.io/otel/sdk/metric v1.42.0/go.mod h1:Ua6AAlDKdZ7tdvaQKfSmnFTdHx37+J4ba8MwVCYM5hc=
go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4LenLmOYY=
go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc=
go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g= go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g=
go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk= go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
@@ -167,8 +144,7 @@ go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
golang.org/x/arch v0.24.0 h1:qlJ3M9upxvFfwRM51tTg3Yl+8CP9vCC1E7vlFpgv99Y= golang.org/x/arch v0.25.0 h1:qnk6Ksugpi5Bz32947rkUgDt9/s5qvqDPl/gBKdMJLE=
golang.org/x/arch v0.24.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
@@ -0,0 +1,273 @@
package authsession
import (
"bytes"
"context"
"fmt"
"io"
"net"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"syscall"
"testing"
"time"
"galaxy/authsession/internal/adapters/userservice"
"galaxy/authsession/internal/domain/common"
"galaxy/authsession/internal/domain/userresolution"
"galaxy/authsession/internal/ports"
"github.com/alicebob/miniredis/v2"
)
func TestUserServiceRESTClientWorksAgainstRealUserServiceRuntime(t *testing.T) {
redisServer := miniredis.RunT(t)
internalAddr := freeTCPAddress(t)
binaryPath := buildUserServiceBinary(t)
process := startUserServiceProcess(t, binaryPath, map[string]string{
"USERSERVICE_INTERNAL_HTTP_ADDR": internalAddr,
"USERSERVICE_REDIS_ADDR": redisServer.Addr(),
})
waitForTCP(t, process, internalAddr)
client, err := userservice.NewRESTClient(userservice.Config{
BaseURL: "http://" + internalAddr,
RequestTimeout: 500 * time.Millisecond,
})
if err != nil {
t.Fatalf("NewRESTClient() error = %v, want nil", err)
}
t.Cleanup(func() {
_ = client.Close()
})
creatableEmail := common.Email("pilot@example.com")
resolution, err := client.ResolveByEmail(context.Background(), creatableEmail)
if err != nil {
t.Fatalf("ResolveByEmail(creatable) error = %v, want nil", err)
}
if got, want := resolution.Kind, userresolution.KindCreatable; got != want {
t.Fatalf("ResolveByEmail(creatable).Kind = %q, want %q", got, want)
}
created, err := client.EnsureUserByEmail(context.Background(), ports.EnsureUserInput{
Email: creatableEmail,
RegistrationContext: &ports.RegistrationContext{
PreferredLanguage: "en",
TimeZone: "Europe/Kaliningrad",
},
})
if err != nil {
t.Fatalf("EnsureUserByEmail(created) error = %v, want nil", err)
}
if got, want := created.Outcome, ports.EnsureUserOutcomeCreated; got != want {
t.Fatalf("EnsureUserByEmail(created).Outcome = %q, want %q", got, want)
}
if created.UserID.IsZero() {
t.Fatalf("EnsureUserByEmail(created).UserID = zero, want non-zero")
}
existing, err := client.ResolveByEmail(context.Background(), creatableEmail)
if err != nil {
t.Fatalf("ResolveByEmail(existing) error = %v, want nil", err)
}
if got, want := existing.Kind, userresolution.KindExisting; got != want {
t.Fatalf("ResolveByEmail(existing).Kind = %q, want %q", got, want)
}
if got, want := existing.UserID, created.UserID; got != want {
t.Fatalf("ResolveByEmail(existing).UserID = %q, want %q", got, want)
}
exists, err := client.ExistsByUserID(context.Background(), created.UserID)
if err != nil {
t.Fatalf("ExistsByUserID(existing) error = %v, want nil", err)
}
if !exists {
t.Fatalf("ExistsByUserID(existing) = false, want true")
}
blocked, err := client.BlockByUserID(context.Background(), ports.BlockUserByIDInput{
UserID: created.UserID,
ReasonCode: userresolution.BlockReasonCode("policy_blocked"),
})
if err != nil {
t.Fatalf("BlockByUserID() error = %v, want nil", err)
}
if got, want := blocked.Outcome, ports.BlockUserOutcomeBlocked; got != want {
t.Fatalf("BlockByUserID().Outcome = %q, want %q", got, want)
}
if got, want := blocked.UserID, created.UserID; got != want {
t.Fatalf("BlockByUserID().UserID = %q, want %q", got, want)
}
repeated, err := client.BlockByEmail(context.Background(), ports.BlockUserByEmailInput{
Email: creatableEmail,
ReasonCode: userresolution.BlockReasonCode("policy_blocked"),
})
if err != nil {
t.Fatalf("BlockByEmail(repeated) error = %v, want nil", err)
}
if got, want := repeated.Outcome, ports.BlockUserOutcomeAlreadyBlocked; got != want {
t.Fatalf("BlockByEmail(repeated).Outcome = %q, want %q", got, want)
}
if got, want := repeated.UserID, created.UserID; got != want {
t.Fatalf("BlockByEmail(repeated).UserID = %q, want %q", got, want)
}
blockedResolution, err := client.ResolveByEmail(context.Background(), creatableEmail)
if err != nil {
t.Fatalf("ResolveByEmail(blocked) error = %v, want nil", err)
}
if got, want := blockedResolution.Kind, userresolution.KindBlocked; got != want {
t.Fatalf("ResolveByEmail(blocked).Kind = %q, want %q", got, want)
}
if got, want := blockedResolution.BlockReasonCode, userresolution.BlockReasonCode("policy_blocked"); got != want {
t.Fatalf("ResolveByEmail(blocked).BlockReasonCode = %q, want %q", got, want)
}
}
type userServiceProcess struct {
cmd *exec.Cmd
doneCh chan struct{}
logs bytes.Buffer
}
func startUserServiceProcess(t *testing.T, binaryPath string, env map[string]string) *userServiceProcess {
t.Helper()
cmd := exec.Command(binaryPath)
cmd.Env = mergeEnvironment(os.Environ(), env)
process := &userServiceProcess{
cmd: cmd,
doneCh: make(chan struct{}),
}
cmd.Stdout = &process.logs
cmd.Stderr = &process.logs
if err := cmd.Start(); err != nil {
t.Fatalf("start user service process: %v", err)
}
go func() {
_ = cmd.Wait()
close(process.doneCh)
}()
t.Cleanup(func() {
stopUserServiceProcess(t, process)
if t.Failed() {
t.Logf("userservice logs:\n%s", process.logs.String())
}
})
return process
}
func stopUserServiceProcess(t *testing.T, process *userServiceProcess) {
t.Helper()
if process == nil || process.cmd == nil || process.cmd.Process == nil {
return
}
select {
case <-process.doneCh:
return
default:
}
_ = process.cmd.Process.Signal(syscall.SIGTERM)
select {
case <-process.doneCh:
case <-time.After(5 * time.Second):
_ = process.cmd.Process.Kill()
<-process.doneCh
}
}
func waitForTCP(t *testing.T, process *userServiceProcess, address string) {
t.Helper()
deadline := time.Now().Add(10 * time.Second)
for time.Now().Before(deadline) {
select {
case <-process.doneCh:
t.Fatalf("userservice exited before %s became reachable\n%s", address, process.logs.String())
default:
}
conn, err := net.DialTimeout("tcp", address, 100*time.Millisecond)
if err == nil {
_ = conn.Close()
return
}
time.Sleep(25 * time.Millisecond)
}
t.Fatalf("userservice did not become reachable at %s\n%s", address, process.logs.String())
}
func freeTCPAddress(t *testing.T) string {
t.Helper()
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("reserve free TCP address: %v", err)
}
defer listener.Close()
return listener.Addr().String()
}
func buildUserServiceBinary(t *testing.T) string {
t.Helper()
outputPath := filepath.Join(t.TempDir(), "userservice")
cmd := exec.Command("go", "build", "-o", outputPath, "./user/cmd/userservice")
cmd.Dir = repositoryRoot(t)
output, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("build userservice binary: %v\n%s", err, output)
}
return outputPath
}
func repositoryRoot(t *testing.T) string {
t.Helper()
_, file, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("resolve repository root: runtime caller unavailable")
}
return filepath.Clean(filepath.Join(filepath.Dir(file), ".."))
}
func mergeEnvironment(base []string, overrides map[string]string) []string {
values := make(map[string]string, len(base)+len(overrides))
for _, entry := range base {
name, value, ok := strings.Cut(entry, "=")
if ok {
values[name] = value
}
}
for name, value := range overrides {
values[name] = value
}
merged := make([]string, 0, len(values))
for name, value := range values {
merged = append(merged, fmt.Sprintf("%s=%s", name, value))
}
return merged
}
var _ io.Writer = (*bytes.Buffer)(nil)
+7 -7
View File
@@ -4,22 +4,22 @@ go 1.26.0
require ( require (
github.com/gin-gonic/gin v1.12.0 github.com/gin-gonic/gin v1.12.0
github.com/go-playground/validator/v10 v10.30.1 github.com/go-playground/validator/v10 v10.30.2
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/stretchr/testify v1.11.1 github.com/stretchr/testify v1.11.1
) )
require ( require (
github.com/bytedance/gopkg v0.1.3 // indirect github.com/bytedance/gopkg v0.1.4 // indirect
github.com/bytedance/sonic v1.15.0 // indirect github.com/bytedance/sonic v1.15.0 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect github.com/bytedance/sonic/loader v0.5.1 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect github.com/cloudwego/base64x v0.1.6 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/gabriel-vasile/mimetype v1.4.13 // indirect github.com/gabriel-vasile/mimetype v1.4.13 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect github.com/gin-contrib/sse v1.1.1 // indirect
github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/goccy/go-json v0.10.5 // indirect github.com/goccy/go-json v0.10.6 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect github.com/goccy/go-yaml v1.19.2 // indirect
github.com/json-iterator/go v1.1.12 // indirect github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect
@@ -27,7 +27,7 @@ require (
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pelletier/go-toml/v2 v2.3.0 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/quic-go/qpack v0.6.0 // indirect github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.59.0 // indirect github.com/quic-go/quic-go v0.59.0 // indirect
@@ -35,7 +35,7 @@ require (
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect github.com/ugorji/go/codec v1.3.1 // indirect
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
golang.org/x/arch v0.24.0 // indirect golang.org/x/arch v0.25.0 // indirect
golang.org/x/crypto v0.49.0 // indirect golang.org/x/crypto v0.49.0 // indirect
golang.org/x/net v0.52.0 // indirect golang.org/x/net v0.52.0 // indirect
golang.org/x/sys v0.42.0 // indirect golang.org/x/sys v0.42.0 // indirect
+7 -14
View File
@@ -1,9 +1,7 @@
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE= github.com/bytedance/sonic/loader v0.5.1 h1:Ygpfa9zwRCCKSlrp5bBP/b/Xzc3VxsAW+5NIYXrOOpI=
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -11,8 +9,7 @@ github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM= github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= github.com/gin-contrib/sse v1.1.1 h1:uGYpNwTacv5R68bSGMapo62iLTRa9l5zxGCps4hK6ko=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
@@ -20,10 +17,8 @@ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/o
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w= github.com/go-playground/validator/v10 v10.30.2 h1:JiFIMtSSHb2/XBUbWM4i/MpeQm9ZK2xqPNk8vgvu5JQ=
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
@@ -48,8 +43,7 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.3.0 h1:k59bC/lIZREW0/iVaQR8nDHxVq8OVlIzYCOJf421CaM=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
@@ -75,8 +69,7 @@ github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2W
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE= go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
golang.org/x/arch v0.24.0 h1:qlJ3M9upxvFfwRM51tTg3Yl+8CP9vCC1E7vlFpgv99Y= golang.org/x/arch v0.25.0 h1:qnk6Ksugpi5Bz32947rkUgDt9/s5qvqDPl/gBKdMJLE=
golang.org/x/arch v0.24.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+34 -4
View File
@@ -23,6 +23,8 @@ Optional integrations:
- `GATEWAY_ADMIN_HTTP_ADDR` enables the private `/metrics` listener; - `GATEWAY_ADMIN_HTTP_ADDR` enables the private `/metrics` listener;
- `GATEWAY_AUTH_SERVICE_BASE_URL` enables real public auth handling through - `GATEWAY_AUTH_SERVICE_BASE_URL` enables real public auth handling through
Auth / Session Service public HTTP; Auth / Session Service public HTTP;
- `GATEWAY_USER_SERVICE_BASE_URL` enables direct authenticated self-service
routing to User Service internal HTTP;
- injected downstream routes are required for successful `ExecuteCommand`. - injected downstream routes are required for successful `ExecuteCommand`.
Operational caveats: Operational caveats:
@@ -118,6 +120,10 @@ The public auth JSON contract uses a challenge-token flow:
key for the device session being created. key for the device session being created.
`time_zone` is the client-selected IANA time zone name forwarded unchanged to `time_zone` is the client-selected IANA time zone name forwarded unchanged to
`Auth / Session Service`. `Auth / Session Service`.
The current create-path source of truth for `preferred_language` is still the
temporary authsession-to-user rollout using `"en"`. Gateway-side language
derivation is a later rollout. The public `confirm-email-code` DTO itself
remains unchanged.
These routes remain unauthenticated and delegate only through an injected These routes remain unauthenticated and delegate only through an injected
`AuthServiceClient`. `AuthServiceClient`.
@@ -322,10 +328,24 @@ The authenticated transport uses a split contract:
- signatures are computed over canonical envelope fields and a hash of raw - signatures are computed over canonical envelope fields and a hash of raw
FlatBuffers bytes. FlatBuffers bytes.
The gateway treats authenticated request `payload_bytes` as opaque business The gateway verifies authenticated payload bytes before any downstream call.
data. Most downstream routes may still treat those bytes as opaque, but the gateway
It verifies integrity and forwards verified bytes downstream without rewriting is also allowed to transcode verified FlatBuffers payloads into trusted
them. downstream REST/JSON calls when the concrete downstream contract requires it.
The current direct `Gateway -> User` self-service boundary uses that pattern:
- external message types:
- `user.account.get`
- `user.profile.update`
- `user.settings.update`
- external payloads and responses:
- FlatBuffers
- internal downstream transport:
- strict REST/JSON to User Service
- business error projection:
- gateway `result_code`
- FlatBuffers error payload mirroring User Service `code` and `message`
The request envelope version literal is `v1`. The request envelope version literal is `v1`.
`payload_hash` is the raw 32-byte SHA-256 digest of `payload_bytes`. `payload_hash` is the raw 32-byte SHA-256 digest of `payload_bytes`.
@@ -965,6 +985,11 @@ failing process startup.
Resolves the target downstream service or adapter by the full exact-match Resolves the target downstream service or adapter by the full exact-match
`message_type` literal. `message_type` literal.
The default `cmd/gateway` wiring keeps the reserved `user.*` self-service
message types mounted even when `GATEWAY_USER_SERVICE_BASE_URL` is unset. In
that configuration they fail closed as dependency-unavailable instead of
falling through to a generic route miss.
### DownstreamClient ### DownstreamClient
Executes a verified authenticated command against a downstream internal service Executes a verified authenticated command against a downstream internal service
@@ -972,6 +997,11 @@ and returns response payload bytes plus a stable opaque result code.
An empty or whitespace-only result code is treated as an internal downstream An empty or whitespace-only result code is treated as an internal downstream
contract violation. contract violation.
Downstream clients may be pure pass-through adapters or gateway-owned
transcoding adapters. The current User Service adapter decodes authenticated
FlatBuffers payloads, calls the trusted internal REST API, and re-encodes the
result into FlatBuffers before the signed gateway response is emitted.
### EventSubscriber ### EventSubscriber
Subscribes to internal pub/sub topics used for: Subscribes to internal pub/sub topics used for:
+5 -2
View File
@@ -3,5 +3,8 @@
## 1. Suggest User's Preferred Language when registering a new User ## 1. Suggest User's Preferred Language when registering a new User
Upon user's device/session registration flow, `preferred_language` value Upon user's device/session registration flow, `preferred_language` value
must be obtained via existing [geoip](../pkg/geoip) package by returned Country. must be obtained via existing [geoip](../pkg/geoip) package by returned
When geoip feils to return country by ip, fallback is `en` language. country.
The derived value must be emitted as a valid BCP 47 language tag because
`User Service` now validates that contract semantically on create.
When geoip fails to return country by IP, fallback is `en`.
+17 -1
View File
@@ -13,6 +13,7 @@ import (
"galaxy/gateway/internal/authn" "galaxy/gateway/internal/authn"
"galaxy/gateway/internal/config" "galaxy/gateway/internal/config"
"galaxy/gateway/internal/downstream" "galaxy/gateway/internal/downstream"
"galaxy/gateway/internal/downstream/userservice"
"galaxy/gateway/internal/events" "galaxy/gateway/internal/events"
"galaxy/gateway/internal/grpcapi" "galaxy/gateway/internal/grpcapi"
"galaxy/gateway/internal/logging" "galaxy/gateway/internal/logging"
@@ -184,12 +185,27 @@ func newAuthenticatedGRPCDependencies(ctx context.Context, cfg config.Config, lo
) )
} }
userRoutes, closeUserServiceRoutes, err := userservice.NewRoutes(cfg.UserService.BaseURL)
if err != nil {
closeErr := errors.Join(
fallbackSessionCache.Close(),
replayStore.Close(),
sessionSubscriber.Close(),
clientEventSubscriber.Close(),
)
return grpcapi.ServerDependencies{}, nil, nil, errors.Join(
fmt.Errorf("build authenticated grpc dependencies: user service routes: %w", err),
closeErr,
)
}
cleanup := func() error { cleanup := func() error {
return errors.Join( return errors.Join(
fallbackSessionCache.Close(), fallbackSessionCache.Close(),
replayStore.Close(), replayStore.Close(),
sessionSubscriber.Close(), sessionSubscriber.Close(),
clientEventSubscriber.Close(), clientEventSubscriber.Close(),
closeUserServiceRoutes(),
) )
} }
@@ -227,7 +243,7 @@ func newAuthenticatedGRPCDependencies(ctx context.Context, cfg config.Config, lo
return grpcapi.ServerDependencies{ return grpcapi.ServerDependencies{
Service: grpcapi.NewFanOutPushStreamService(pushHub, responseSigner, nil, logger), Service: grpcapi.NewFanOutPushStreamService(pushHub, responseSigner, nil, logger),
Router: downstream.NewStaticRouter(nil), Router: downstream.NewStaticRouter(userRoutes),
ResponseSigner: responseSigner, ResponseSigner: responseSigner,
SessionCache: sessionCache, SessionCache: sessionCache,
ReplayStore: replayStore, ReplayStore: replayStore,
+20
View File
@@ -89,6 +89,26 @@ Example `ExecuteCommandResponse`:
} }
``` ```
Example authenticated self-service request metadata:
```json
{
"protocolVersion": "v1",
"deviceSessionId": "device-session-123",
"messageType": "user.account.get",
"timestampMs": "1775121600000",
"requestId": "request-account-123",
"payloadBytes": "RkxBVEJVRkZFUlNfVVNFUl9SRVFVRVNU",
"payloadHash": "5fY6Q8V9mK8x2B7v6v0V0m0i1rQ2QF0rQ8V1Yt1r8Ys=",
"signature": "3o4v8f3h0Y6I0x1bS7zY+8m0bV1Lk4D3yq8J2n8F1rD7yK9v8M1Q0w2s4a6f8d0Q0m3L6y8R1t5w7x9z0a2cA=="
}
```
The external payload remains FlatBuffers. The current `Gateway -> User`
self-service adapter decodes that payload, calls the trusted internal
User Service REST API, then re-encodes the returned account aggregate or error
envelope back into FlatBuffers before signing the response.
Example bootstrap `GatewayEvent` sent after `SubscribeEvents` opens: Example bootstrap `GatewayEvent` sent after `SubscribeEvents` opens:
```json ```json
+18
View File
@@ -52,6 +52,24 @@ sequenceDiagram
Gateway-->>Client: ExecuteCommandResponse + signature Gateway-->>Client: ExecuteCommandResponse + signature
``` ```
## Direct Gateway -> User Self-Service Flow
```mermaid
sequenceDiagram
participant Client
participant Gateway
participant User as User Service
Client->>Gateway: ExecuteCommand(user.account.get | user.profile.update | user.settings.update)
Gateway->>Gateway: verify envelope + session + signature + replay
Gateway->>Gateway: decode FlatBuffers payload
Gateway->>User: trusted REST/JSON internal request
User-->>Gateway: JSON account aggregate or JSON error envelope
Gateway->>Gateway: encode FlatBuffers success or error payload
Gateway->>Gateway: sign response
Gateway-->>Client: ExecuteCommandResponse(result_code, payload_bytes, signature)
```
## SubscribeEvents Lifecycle ## SubscribeEvents Lifecycle
```mermaid ```mermaid
+4 -2
View File
@@ -55,5 +55,7 @@ Notes:
- The admin listener is optional and serves only Prometheus text metrics. - The admin listener is optional and serves only Prometheus text metrics.
- Public auth routing stays available without an upstream adapter, but returns - Public auth routing stays available without an upstream adapter, but returns
`503 service_unavailable`. `503 service_unavailable`.
- Authenticated gRPC starts with an empty static router; `ExecuteCommand` - The default runtime reserves direct `user.*` authenticated self-service
remains `UNIMPLEMENTED` until downstream routes are injected. routes. When `GATEWAY_USER_SERVICE_BASE_URL` is unset those routes stay
mounted but fail closed as dependency-unavailable instead of returning a
route miss.
+22 -22
View File
@@ -6,22 +6,22 @@ require (
buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260209202127-80ab13bee0bf.1 buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260209202127-80ab13bee0bf.1
buf.build/go/protovalidate v1.1.3 buf.build/go/protovalidate v1.1.3
github.com/alicebob/miniredis/v2 v2.37.0 github.com/alicebob/miniredis/v2 v2.37.0
github.com/getkin/kin-openapi v0.134.0 github.com/getkin/kin-openapi v0.135.0
github.com/gin-gonic/gin v1.12.0 github.com/gin-gonic/gin v1.12.0
github.com/google/flatbuffers v25.12.19+incompatible github.com/google/flatbuffers v25.12.19+incompatible
github.com/prometheus/client_golang v1.23.2 github.com/prometheus/client_golang v1.23.2
github.com/redis/go-redis/v9 v9.18.0 github.com/redis/go-redis/v9 v9.18.0
github.com/stretchr/testify v1.11.1 github.com/stretchr/testify v1.11.1
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.67.0 go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.68.0
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0
go.opentelemetry.io/otel v1.42.0 go.opentelemetry.io/otel v1.43.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.42.0 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.42.0 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0
go.opentelemetry.io/otel/exporters/prometheus v0.64.0 go.opentelemetry.io/otel/exporters/prometheus v0.65.0
go.opentelemetry.io/otel/metric v1.42.0 go.opentelemetry.io/otel/metric v1.43.0
go.opentelemetry.io/otel/sdk v1.42.0 go.opentelemetry.io/otel/sdk v1.43.0
go.opentelemetry.io/otel/sdk/metric v1.42.0 go.opentelemetry.io/otel/sdk/metric v1.43.0
go.opentelemetry.io/otel/trace v1.42.0 go.opentelemetry.io/otel/trace v1.43.0
go.uber.org/zap v1.27.1 go.uber.org/zap v1.27.1
golang.org/x/time v0.15.0 golang.org/x/time v0.15.0
google.golang.org/grpc v1.80.0 google.golang.org/grpc v1.80.0
@@ -32,24 +32,24 @@ require (
cel.dev/expr v0.25.1 // indirect cel.dev/expr v0.25.1 // indirect
github.com/antlr4-go/antlr/v4 v4.13.1 // indirect github.com/antlr4-go/antlr/v4 v4.13.1 // indirect
github.com/beorn7/perks v1.0.1 // indirect github.com/beorn7/perks v1.0.1 // indirect
github.com/bytedance/gopkg v0.1.3 // indirect github.com/bytedance/gopkg v0.1.4 // indirect
github.com/bytedance/sonic v1.15.0 // indirect github.com/bytedance/sonic v1.15.0 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect github.com/bytedance/sonic/loader v0.5.1 // indirect
github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect github.com/cloudwego/base64x v0.1.6 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/gabriel-vasile/mimetype v1.4.13 // indirect github.com/gabriel-vasile/mimetype v1.4.13 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect github.com/gin-contrib/sse v1.1.1 // indirect
github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-openapi/jsonpointer v0.21.0 // indirect github.com/go-openapi/jsonpointer v0.21.0 // indirect
github.com/go-openapi/swag v0.23.0 // indirect github.com/go-openapi/swag v0.23.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.30.1 // indirect github.com/go-playground/validator/v10 v10.30.2 // indirect
github.com/goccy/go-json v0.10.5 // indirect github.com/goccy/go-json v0.10.6 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect github.com/goccy/go-yaml v1.19.2 // indirect
github.com/google/cel-go v0.27.0 // indirect github.com/google/cel-go v0.27.0 // indirect
github.com/google/uuid v1.6.0 // indirect github.com/google/uuid v1.6.0 // indirect
@@ -64,15 +64,15 @@ require (
github.com/modern-go/reflect2 v1.0.2 // indirect github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/oasdiff/yaml v0.0.0-20260313112342-a3ea61cb4d4c // indirect github.com/oasdiff/yaml v0.0.9 // indirect
github.com/oasdiff/yaml3 v0.0.0-20260224194419-61cd415a242b // indirect github.com/oasdiff/yaml3 v0.0.9 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pelletier/go-toml/v2 v2.3.0 // indirect
github.com/perimeterx/marshmallow v1.1.5 // indirect github.com/perimeterx/marshmallow v1.1.5 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.67.5 // indirect github.com/prometheus/common v0.67.5 // indirect
github.com/prometheus/otlptranslator v1.0.0 // indirect github.com/prometheus/otlptranslator v1.0.0 // indirect
github.com/prometheus/procfs v0.19.2 // indirect github.com/prometheus/procfs v0.20.1 // indirect
github.com/quic-go/qpack v0.6.0 // indirect github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.59.0 // indirect github.com/quic-go/quic-go v0.59.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
@@ -81,12 +81,12 @@ require (
github.com/yuin/gopher-lua v1.1.1 // indirect github.com/yuin/gopher-lua v1.1.1 // indirect
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.42.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 // indirect
go.opentelemetry.io/proto/otlp v1.10.0 // indirect go.opentelemetry.io/proto/otlp v1.10.0 // indirect
go.uber.org/atomic v1.11.0 // indirect go.uber.org/atomic v1.11.0 // indirect
go.uber.org/multierr v1.10.0 // indirect go.uber.org/multierr v1.10.0 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v2 v2.4.4 // indirect
golang.org/x/arch v0.24.0 // indirect golang.org/x/arch v0.25.0 // indirect
golang.org/x/crypto v0.49.0 // indirect golang.org/x/crypto v0.49.0 // indirect
golang.org/x/exp v0.0.0-20250813145105-42675adae3e6 // indirect golang.org/x/exp v0.0.0-20250813145105-42675adae3e6 // indirect
golang.org/x/net v0.52.0 // indirect golang.org/x/net v0.52.0 // indirect
+24 -48
View File
@@ -16,12 +16,10 @@ github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE= github.com/bytedance/sonic/loader v0.5.1 h1:Ygpfa9zwRCCKSlrp5bBP/b/Xzc3VxsAW+5NIYXrOOpI=
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
@@ -35,10 +33,8 @@ github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/r
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM= github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/getkin/kin-openapi v0.134.0 h1:/L5+1+kfe6dXh8Ot/wqiTgUkjOIEJiC0bbYVziHB8rU= github.com/getkin/kin-openapi v0.135.0 h1:751SjYfbiwqukYuVjwYEIKNfrSwS5YpA7DZnKSwQgtg=
github.com/getkin/kin-openapi v0.134.0/go.mod h1:wK6ZLG/VgoETO9pcLJ/VmAtIcl/DNlMayNTb716EUxE= github.com/gin-contrib/sse v1.1.1 h1:uGYpNwTacv5R68bSGMapo62iLTRa9l5zxGCps4hK6ko=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc= github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
@@ -56,12 +52,10 @@ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/o
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w= github.com/go-playground/validator/v10 v10.30.2 h1:JiFIMtSSHb2/XBUbWM4i/MpeQm9ZK2xqPNk8vgvu5JQ=
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM= github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM=
github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
@@ -106,12 +100,9 @@ github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8= github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/oasdiff/yaml v0.0.0-20260313112342-a3ea61cb4d4c h1:7ACFcSaQsrWtrH4WHHfUqE1C+f8r2uv8KGaW0jTNjus= github.com/oasdiff/yaml v0.0.9 h1:zQOvd2UKoozsSsAknnWoDJlSK4lC0mpmjfDsfqNwX48=
github.com/oasdiff/yaml v0.0.0-20260313112342-a3ea61cb4d4c/go.mod h1:JKox4Gszkxt57kj27u7rvi7IFoIULvCZHUsBTUmQM/s= github.com/oasdiff/yaml3 v0.0.9 h1:rWPrKccrdUm8J0F3sGuU+fuh9+1K/RdJlWF7O/9yw2g=
github.com/oasdiff/yaml3 v0.0.0-20260224194419-61cd415a242b h1:vivRhVUAa9t1q0Db4ZmezBP8pWQWnXHFokZj0AOea2g= github.com/pelletier/go-toml/v2 v2.3.0 h1:k59bC/lIZREW0/iVaQR8nDHxVq8OVlIzYCOJf421CaM=
github.com/oasdiff/yaml3 v0.0.0-20260224194419-61cd415a242b/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s= github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s=
github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw= github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
@@ -124,8 +115,7 @@ github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTU
github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw= github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
github.com/prometheus/otlptranslator v1.0.0 h1:s0LJW/iN9dkIH+EnhiD3BlkkP5QVIUVEoIwkU+A6qos= github.com/prometheus/otlptranslator v1.0.0 h1:s0LJW/iN9dkIH+EnhiD3BlkkP5QVIUVEoIwkU+A6qos=
github.com/prometheus/otlptranslator v1.0.0/go.mod h1:vRYWnXvI6aWGpsdY/mOT/cbeVRBlPWtBNDb7kGR3uKM= github.com/prometheus/otlptranslator v1.0.0/go.mod h1:vRYWnXvI6aWGpsdY/mOT/cbeVRBlPWtBNDb7kGR3uKM=
github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws= github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc=
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw= github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
@@ -161,32 +151,20 @@ go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.67.0 h1:E7DmskpIO7ZR6QI6zKSEKIDNUYoKw9oHXP23gzbCdU0= go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.68.0 h1:5FXSL2s6afUC1bzNzl1iedZZ8yqR7GOhbCoEXtyeK6Q=
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.67.0/go.mod h1:WB2cS9y+AwqqKhoo9gw6/ZxlSjFBUQGZ8BQOaD3FVXM=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 h1:yI1/OhfEPy7J9eoa6Sj051C7n5dvpj0QX8g4sRchg04= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 h1:yI1/OhfEPy7J9eoa6Sj051C7n5dvpj0QX8g4sRchg04=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0/go.mod h1:NoUCKYWK+3ecatC4HjkRktREheMeEtrXoQxrqYFeHSc= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0/go.mod h1:NoUCKYWK+3ecatC4HjkRktREheMeEtrXoQxrqYFeHSc=
go.opentelemetry.io/contrib/propagators/b3 v1.42.0 h1:B2Pew5ufEtgkjLF+tSkXjgYZXQr9m7aCm1wLKB0URbU= go.opentelemetry.io/contrib/propagators/b3 v1.43.0 h1:CETqV3QLLPTy5yNrqyMr41VnAOOD4lsRved7n4QG00A=
go.opentelemetry.io/contrib/propagators/b3 v1.42.0/go.mod h1:iPgUcSEF5DORW6+yNbdw/YevUy+QqJ508ncjhrRSCjc= go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
go.opentelemetry.io/otel v1.42.0 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 h1:88Y4s2C8oTui1LGM6bTWkw0ICGcOLCAI5l6zsD1j20k=
go.opentelemetry.io/otel v1.42.0/go.mod h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOXWkdqCc= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0 h1:RAE+JPfvEmvy+0LzyUA25/SGawPwIUbZ6u0Wug54sLc=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.42.0 h1:THuZiwpQZuHPul65w4WcwEnkX2QIuMT+UFoOrygtoJw= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 h1:3iZJKlCZufyRzPzlQhUIWVmfltrXuGyfjREgGP3UUjc=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.42.0/go.mod h1:J2pvYM5NGHofZ2/Ru6zw/TNWnEQp5crgyDeSrYpXkAw= go.opentelemetry.io/otel/exporters/prometheus v0.65.0 h1:jOveH/b4lU9HT7y+Gfamf18BqlOuz2PWEvs8yM7Q6XE=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.42.0 h1:zWWrB1U6nqhS/k6zYB74CjRpuiitRtLLi68VcgmOEto= go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0 h1:mS47AX77OtFfKG4vtp+84kuGSFZHTyxtXIN269vChY0=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.42.0/go.mod h1:2qXPNBX1OVRC0IwOnfo1ljoid+RD0QK3443EaqVlsOU= go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.42.0 h1:uLXP+3mghfMf7XmV4PkGfFhFKuNWoCvvx5wP/wOXo0o= go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.42.0/go.mod h1:v0Tj04armyT59mnURNUJf7RCKcKzq+lgJs6QSjHjaTc= go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
go.opentelemetry.io/otel/exporters/prometheus v0.64.0 h1:g0LRDXMX/G1SEZtK8zl8Chm4K6GBwRkjPKE36LxiTYs= go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
go.opentelemetry.io/otel/exporters/prometheus v0.64.0/go.mod h1:UrgcjnarfdlBDP3GjDIJWe6HTprwSazNjwsI+Ru6hro=
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.42.0 h1:s/1iRkCKDfhlh1JF26knRneorus8aOwVIDhvYx9WoDw=
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.42.0/go.mod h1:UI3wi0FXg1Pofb8ZBiBLhtMzgoTm1TYkMvn71fAqDzs=
go.opentelemetry.io/otel/metric v1.42.0 h1:2jXG+3oZLNXEPfNmnpxKDeZsFI5o4J+nz6xUlaFdF/4=
go.opentelemetry.io/otel/metric v1.42.0/go.mod h1:RlUN/7vTU7Ao/diDkEpQpnz3/92J9ko05BIwxYa2SSI=
go.opentelemetry.io/otel/sdk v1.42.0 h1:LyC8+jqk6UJwdrI/8VydAq/hvkFKNHZVIWuslJXYsDo=
go.opentelemetry.io/otel/sdk v1.42.0/go.mod h1:rGHCAxd9DAph0joO4W6OPwxjNTYWghRWmkHuGbayMts=
go.opentelemetry.io/otel/sdk/metric v1.42.0 h1:D/1QR46Clz6ajyZ3G8SgNlTJKBdGp84q9RKCAZ3YGuA=
go.opentelemetry.io/otel/sdk/metric v1.42.0/go.mod h1:Ua6AAlDKdZ7tdvaQKfSmnFTdHx37+J4ba8MwVCYM5hc=
go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4LenLmOYY=
go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc=
go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g= go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
@@ -198,12 +176,10 @@ go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/arch v0.24.0 h1:qlJ3M9upxvFfwRM51tTg3Yl+8CP9vCC1E7vlFpgv99Y= golang.org/x/arch v0.25.0 h1:qnk6Ksugpi5Bz32947rkUgDt9/s5qvqDPl/gBKdMJLE=
golang.org/x/arch v0.24.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/exp v0.0.0-20250813145105-42675adae3e6 h1:SbTAbRFnd5kjQXbczszQ0hdk3ctwYf3qBNH9jIsGclE= golang.org/x/exp v0.0.0-20250813145105-42675adae3e6 h1:SbTAbRFnd5kjQXbczszQ0hdk3ctwYf3qBNH9jIsGclE=
golang.org/x/exp v0.0.0-20250813145105-42675adae3e6/go.mod h1:4QTo5u+SEIbbKW1RacMZq1YEfOBqeXa19JeshGi+zc4= golang.org/x/exp v0.0.0-20250813145105-42675adae3e6/go.mod h1:4QTo5u+SEIbbKW1RacMZq1YEfOBqeXa19JeshGi+zc4=
+42
View File
@@ -45,6 +45,11 @@ const (
// public-auth delegation. // public-auth delegation.
authServiceBaseURLEnvVar = "GATEWAY_AUTH_SERVICE_BASE_URL" authServiceBaseURLEnvVar = "GATEWAY_AUTH_SERVICE_BASE_URL"
// userServiceBaseURLEnvVar names the environment variable that configures
// the optional User Service internal HTTP base URL used by authenticated
// gateway self-service delegation.
userServiceBaseURLEnvVar = "GATEWAY_USER_SERVICE_BASE_URL"
// adminHTTPAddrEnvVar names the environment variable that configures the // adminHTTPAddrEnvVar names the environment variable that configures the
// private admin HTTP listener address. When it is empty, the admin listener // private admin HTTP listener address. When it is empty, the admin listener
// remains disabled. // remains disabled.
@@ -479,6 +484,15 @@ type AuthServiceConfig struct {
BaseURL string BaseURL string
} }
// UserServiceConfig describes the optional authenticated self-service upstream
// used by the gateway runtime.
type UserServiceConfig struct {
// BaseURL is the absolute base URL of the User Service internal HTTP API.
// When BaseURL is empty, the gateway keeps using its built-in unavailable
// downstream adapter for the reserved `user.*` routes.
BaseURL string
}
// AdminHTTPConfig describes the private operational HTTP listener used for // AdminHTTPConfig describes the private operational HTTP listener used for
// metrics exposure. The listener remains disabled when Addr is empty. // metrics exposure. The listener remains disabled when Addr is empty.
type AdminHTTPConfig struct { type AdminHTTPConfig struct {
@@ -610,6 +624,10 @@ type Config struct {
// Session Service. // Session Service.
AuthService AuthServiceConfig AuthService AuthServiceConfig
// UserService configures the optional authenticated self-service
// delegation to User Service.
UserService UserServiceConfig
// AdminHTTP configures the optional private admin listener used for metrics // AdminHTTP configures the optional private admin listener used for metrics
// exposure. // exposure.
AdminHTTP AdminHTTPConfig AdminHTTP AdminHTTPConfig
@@ -791,6 +809,13 @@ func DefaultAuthServiceConfig() AuthServiceConfig {
return AuthServiceConfig{} return AuthServiceConfig{}
} }
// DefaultUserServiceConfig returns the default authenticated self-service
// upstream settings. The zero value keeps the built-in unavailable adapter
// active for reserved `user.*` routes.
func DefaultUserServiceConfig() UserServiceConfig {
return UserServiceConfig{}
}
// LoadFromEnv loads Config from the process environment, applies defaults for // LoadFromEnv loads Config from the process environment, applies defaults for
// omitted settings, and validates the resulting values. // omitted settings, and validates the resulting values.
func LoadFromEnv() (Config, error) { func LoadFromEnv() (Config, error) {
@@ -799,6 +824,7 @@ func LoadFromEnv() (Config, error) {
Logging: DefaultLoggingConfig(), Logging: DefaultLoggingConfig(),
PublicHTTP: DefaultPublicHTTPConfig(), PublicHTTP: DefaultPublicHTTPConfig(),
AuthService: DefaultAuthServiceConfig(), AuthService: DefaultAuthServiceConfig(),
UserService: DefaultUserServiceConfig(),
AdminHTTP: DefaultAdminHTTPConfig(), AdminHTTP: DefaultAdminHTTPConfig(),
AuthenticatedGRPC: DefaultAuthenticatedGRPCConfig(), AuthenticatedGRPC: DefaultAuthenticatedGRPCConfig(),
SessionCacheRedis: DefaultSessionCacheRedisConfig(), SessionCacheRedis: DefaultSessionCacheRedisConfig(),
@@ -856,6 +882,11 @@ func LoadFromEnv() (Config, error) {
cfg.AuthService.BaseURL = rawAuthServiceBaseURL cfg.AuthService.BaseURL = rawAuthServiceBaseURL
} }
rawUserServiceBaseURL, ok := os.LookupEnv(userServiceBaseURLEnvVar)
if ok {
cfg.UserService.BaseURL = rawUserServiceBaseURL
}
rawAdminHTTPAddr, ok := os.LookupEnv(adminHTTPAddrEnvVar) rawAdminHTTPAddr, ok := os.LookupEnv(adminHTTPAddrEnvVar)
if ok { if ok {
cfg.AdminHTTP.Addr = rawAdminHTTPAddr cfg.AdminHTTP.Addr = rawAdminHTTPAddr
@@ -1124,6 +1155,17 @@ func LoadFromEnv() (Config, error) {
} }
cfg.AuthService.BaseURL = strings.TrimRight(parsedAuthServiceBaseURL.String(), "/") cfg.AuthService.BaseURL = strings.TrimRight(parsedAuthServiceBaseURL.String(), "/")
} }
cfg.UserService.BaseURL = strings.TrimSpace(cfg.UserService.BaseURL)
if cfg.UserService.BaseURL != "" {
parsedUserServiceBaseURL, err := url.Parse(cfg.UserService.BaseURL)
if err != nil {
return Config{}, fmt.Errorf("load gateway config: parse %s: %w", userServiceBaseURLEnvVar, err)
}
if parsedUserServiceBaseURL.Scheme == "" || parsedUserServiceBaseURL.Host == "" {
return Config{}, fmt.Errorf("load gateway config: %s must be an absolute URL", userServiceBaseURLEnvVar)
}
cfg.UserService.BaseURL = strings.TrimRight(parsedUserServiceBaseURL.String(), "/")
}
if addr := strings.TrimSpace(cfg.AdminHTTP.Addr); addr != "" { if addr := strings.TrimSpace(cfg.AdminHTTP.Addr); addr != "" {
cfg.AdminHTTP.Addr = addr cfg.AdminHTTP.Addr = addr
} }
+115 -1
View File
@@ -7,6 +7,7 @@ import (
"encoding/pem" "encoding/pem"
"os" "os"
"path/filepath" "path/filepath"
"sync"
"testing" "testing"
"time" "time"
@@ -14,6 +15,8 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
var configEnvMu sync.Mutex
func TestLoadFromEnv(t *testing.T) { func TestLoadFromEnv(t *testing.T) {
customResponseSignerPrivateKeyPEMPath := new(string) customResponseSignerPrivateKeyPEMPath := new(string)
*customResponseSignerPrivateKeyPEMPath = writeTestResponseSignerPEMFile(t) *customResponseSignerPrivateKeyPEMPath = writeTestResponseSignerPEMFile(t)
@@ -27,6 +30,9 @@ func TestLoadFromEnv(t *testing.T) {
customAuthServiceBaseURL := new(string) customAuthServiceBaseURL := new(string)
*customAuthServiceBaseURL = " http://127.0.0.1:8082/ " *customAuthServiceBaseURL = " http://127.0.0.1:8082/ "
customUserServiceBaseURL := new(string)
*customUserServiceBaseURL = " http://127.0.0.1:8083/ "
customAuthenticatedGRPCAddr := new(string) customAuthenticatedGRPCAddr := new(string)
*customAuthenticatedGRPCAddr = "127.0.0.1:9191" *customAuthenticatedGRPCAddr = "127.0.0.1:9191"
@@ -80,6 +86,7 @@ func TestLoadFromEnv(t *testing.T) {
shutdownTimeout *string shutdownTimeout *string
publicHTTPAddr *string publicHTTPAddr *string
authServiceBaseURL *string authServiceBaseURL *string
userServiceBaseURL *string
authenticatedGRPCAddr *string authenticatedGRPCAddr *string
authenticatedGRPCFreshnessWindow *string authenticatedGRPCFreshnessWindow *string
sessionCacheRedisAddr *string sessionCacheRedisAddr *string
@@ -217,6 +224,40 @@ func TestLoadFromEnv(t *testing.T) {
}, },
}, },
}, },
{
name: "custom user service base url",
userServiceBaseURL: customUserServiceBaseURL,
sessionCacheRedisAddr: customSessionCacheRedisAddr,
responseSignerPrivateKeyPEMPath: customResponseSignerPrivateKeyPEMPath,
want: Config{
ShutdownTimeout: 5 * time.Second,
Logging: DefaultLoggingConfig(),
PublicHTTP: DefaultPublicHTTPConfig(),
UserService: UserServiceConfig{
BaseURL: "http://127.0.0.1:8083",
},
AdminHTTP: DefaultAdminHTTPConfig(),
AuthenticatedGRPC: DefaultAuthenticatedGRPCConfig(),
SessionCacheRedis: SessionCacheRedisConfig{
Addr: "127.0.0.1:6379",
DB: defaultSessionCacheRedisDB,
KeyPrefix: defaultSessionCacheRedisKeyPrefix,
LookupTimeout: defaultSessionCacheRedisLookupTimeout,
},
ReplayRedis: DefaultReplayRedisConfig(),
SessionEventsRedis: SessionEventsRedisConfig{
Stream: "gateway:session_events",
ReadBlockTimeout: defaultSessionEventsRedisReadBlockTimeout,
},
ClientEventsRedis: ClientEventsRedisConfig{
Stream: "gateway:client_events",
ReadBlockTimeout: defaultClientEventsRedisReadBlockTimeout,
},
ResponseSigner: ResponseSignerConfig{
PrivateKeyPEMPath: *customResponseSignerPrivateKeyPEMPath,
},
},
},
{ {
name: "custom authenticated grpc address", name: "custom authenticated grpc address",
authenticatedGRPCAddr: customAuthenticatedGRPCAddr, authenticatedGRPCAddr: customAuthenticatedGRPCAddr,
@@ -368,6 +409,7 @@ func TestLoadFromEnv(t *testing.T) {
shutdownTimeoutEnvVar, shutdownTimeoutEnvVar,
publicHTTPAddrEnvVar, publicHTTPAddrEnvVar,
authServiceBaseURLEnvVar, authServiceBaseURLEnvVar,
userServiceBaseURLEnvVar,
authenticatedGRPCAddrEnvVar, authenticatedGRPCAddrEnvVar,
authenticatedGRPCFreshnessWindowEnvVar, authenticatedGRPCFreshnessWindowEnvVar,
sessionCacheRedisAddrEnvVar, sessionCacheRedisAddrEnvVar,
@@ -379,6 +421,7 @@ func TestLoadFromEnv(t *testing.T) {
setEnvValue(t, shutdownTimeoutEnvVar, tt.shutdownTimeout) setEnvValue(t, shutdownTimeoutEnvVar, tt.shutdownTimeout)
setEnvValue(t, publicHTTPAddrEnvVar, tt.publicHTTPAddr) setEnvValue(t, publicHTTPAddrEnvVar, tt.publicHTTPAddr)
setEnvValue(t, authServiceBaseURLEnvVar, tt.authServiceBaseURL) setEnvValue(t, authServiceBaseURLEnvVar, tt.authServiceBaseURL)
setEnvValue(t, userServiceBaseURLEnvVar, tt.userServiceBaseURL)
setEnvValue(t, authenticatedGRPCAddrEnvVar, tt.authenticatedGRPCAddr) setEnvValue(t, authenticatedGRPCAddrEnvVar, tt.authenticatedGRPCAddr)
setEnvValue(t, authenticatedGRPCFreshnessWindowEnvVar, tt.authenticatedGRPCFreshnessWindow) setEnvValue(t, authenticatedGRPCFreshnessWindowEnvVar, tt.authenticatedGRPCFreshnessWindow)
setEnvValue(t, sessionCacheRedisAddrEnvVar, tt.sessionCacheRedisAddr) setEnvValue(t, sessionCacheRedisAddrEnvVar, tt.sessionCacheRedisAddr)
@@ -492,7 +535,7 @@ func TestLoadFromEnvOperationalSettings(t *testing.T) {
restoreEnvs(t, append( restoreEnvs(t, append(
append( append(
append( append(
append(operationalEnvVars(), sessionCacheRedisEnvVars()...), append(append(operationalEnvVars(), authServiceBaseURLEnvVar, userServiceBaseURLEnvVar), sessionCacheRedisEnvVars()...),
sessionEventsRedisEnvVars()..., sessionEventsRedisEnvVars()...,
), ),
clientEventsRedisEnvVars()..., clientEventsRedisEnvVars()...,
@@ -563,6 +606,8 @@ func TestLoadFromEnvAuthService(t *testing.T) {
restoreEnvs(t, restoreEnvs(t,
authServiceBaseURLEnvVar, authServiceBaseURLEnvVar,
userServiceBaseURLEnvVar,
logLevelEnvVar,
sessionCacheRedisAddrEnvVar, sessionCacheRedisAddrEnvVar,
sessionEventsRedisStreamEnvVar, sessionEventsRedisStreamEnvVar,
clientEventsRedisStreamEnvVar, clientEventsRedisStreamEnvVar,
@@ -581,6 +626,72 @@ func TestLoadFromEnvAuthService(t *testing.T) {
} }
} }
func TestLoadFromEnvUserService(t *testing.T) {
t.Parallel()
customSessionCacheRedisAddr := new(string)
*customSessionCacheRedisAddr = "127.0.0.1:6379"
customSessionEventsRedisStream := new(string)
*customSessionEventsRedisStream = "gateway:session_events"
customClientEventsRedisStream := new(string)
*customClientEventsRedisStream = "gateway:client_events"
customResponseSignerPrivateKeyPEMPath := new(string)
*customResponseSignerPrivateKeyPEMPath = writeTestResponseSignerPEMFile(t)
invalidRelativeURL := new(string)
*invalidRelativeURL = "/user"
invalidURL := new(string)
*invalidURL = "://bad"
tests := []struct {
name string
value *string
wantErr string
}{
{
name: "relative url rejected",
value: invalidRelativeURL,
wantErr: userServiceBaseURLEnvVar + " must be an absolute URL",
},
{
name: "malformed url rejected",
value: invalidURL,
wantErr: "parse " + userServiceBaseURLEnvVar,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
restoreEnvs(t,
authServiceBaseURLEnvVar,
userServiceBaseURLEnvVar,
logLevelEnvVar,
sessionCacheRedisAddrEnvVar,
sessionEventsRedisStreamEnvVar,
clientEventsRedisStreamEnvVar,
responseSignerPrivateKeyPEMPathEnvVar,
)
setEnvValue(t, userServiceBaseURLEnvVar, tt.value)
setEnvValue(t, sessionCacheRedisAddrEnvVar, customSessionCacheRedisAddr)
setEnvValue(t, sessionEventsRedisStreamEnvVar, customSessionEventsRedisStream)
setEnvValue(t, clientEventsRedisStreamEnvVar, customClientEventsRedisStream)
setEnvValue(t, responseSignerPrivateKeyPEMPathEnvVar, customResponseSignerPrivateKeyPEMPath)
_, err := LoadFromEnv()
require.Error(t, err)
require.ErrorContains(t, err, tt.wantErr)
})
}
}
func TestLoadFromEnvAuthenticatedGRPCAntiAbuse(t *testing.T) { func TestLoadFromEnvAuthenticatedGRPCAntiAbuse(t *testing.T) {
customSessionCacheRedisAddr := new(string) customSessionCacheRedisAddr := new(string)
*customSessionCacheRedisAddr = "127.0.0.1:6379" *customSessionCacheRedisAddr = "127.0.0.1:6379"
@@ -1276,6 +1387,9 @@ func setEnvValue(t *testing.T, envVar string, value *string) {
func restoreEnvs(t *testing.T, envVars ...string) { func restoreEnvs(t *testing.T, envVars ...string) {
t.Helper() t.Helper()
configEnvMu.Lock()
t.Cleanup(configEnvMu.Unlock)
for _, envVar := range envVars { for _, envVar := range envVars {
restoreEnv(t, envVar) restoreEnv(t, envVar)
} }
@@ -0,0 +1,311 @@
// Package userservice implements the authenticated Gateway -> User Service
// self-service downstream adapter.
package userservice
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"galaxy/gateway/internal/downstream"
usermodel "galaxy/model/user"
"galaxy/transcoder"
)
const (
getMyAccountResultCodeOK = "ok"
userServiceAccountPathSuffix = "/account"
userServiceProfilePathSuffix = "/profile"
userServiceSettingsPathSuffix = "/settings"
)
var stableErrorMessages = map[string]string{
"invalid_request": "request is invalid",
"subject_not_found": "subject not found",
"conflict": "request conflicts with current state",
"internal_error": "internal server error",
}
// HTTPClient implements downstream.Client against the trusted internal User
// Service REST API while preserving FlatBuffers at the external authenticated
// gateway boundary.
type HTTPClient struct {
baseURL string
httpClient *http.Client
}
// NewHTTPClient constructs one User Service downstream client backed by the
// trusted internal REST API at baseURL.
func NewHTTPClient(baseURL string) (*HTTPClient, error) {
transport, ok := http.DefaultTransport.(*http.Transport)
if !ok {
return nil, errors.New("new user service HTTP client: default transport is not *http.Transport")
}
return newHTTPClient(baseURL, &http.Client{
Transport: transport.Clone(),
})
}
func newHTTPClient(baseURL string, httpClient *http.Client) (*HTTPClient, error) {
if httpClient == nil {
return nil, errors.New("new user service HTTP client: http client must not be nil")
}
trimmedBaseURL := strings.TrimSpace(baseURL)
if trimmedBaseURL == "" {
return nil, errors.New("new user service HTTP client: base URL must not be empty")
}
parsedBaseURL, err := url.Parse(strings.TrimRight(trimmedBaseURL, "/"))
if err != nil {
return nil, fmt.Errorf("new user service HTTP client: parse base URL: %w", err)
}
if parsedBaseURL.Scheme == "" || parsedBaseURL.Host == "" {
return nil, errors.New("new user service HTTP client: base URL must be absolute")
}
return &HTTPClient{
baseURL: parsedBaseURL.String(),
httpClient: httpClient,
}, nil
}
// Close releases idle HTTP connections owned by the client transport.
func (c *HTTPClient) Close() error {
if c == nil || c.httpClient == nil {
return nil
}
type idleCloser interface {
CloseIdleConnections()
}
if transport, ok := c.httpClient.Transport.(idleCloser); ok {
transport.CloseIdleConnections()
}
return nil
}
// ExecuteCommand routes one authenticated gateway command to the matching
// trusted internal User Service self-service route.
func (c *HTTPClient) ExecuteCommand(ctx context.Context, command downstream.AuthenticatedCommand) (downstream.UnaryResult, error) {
if c == nil || c.httpClient == nil {
return downstream.UnaryResult{}, errors.New("execute user service command: nil client")
}
if ctx == nil {
return downstream.UnaryResult{}, errors.New("execute user service command: nil context")
}
if err := ctx.Err(); err != nil {
return downstream.UnaryResult{}, err
}
if strings.TrimSpace(command.UserID) == "" {
return downstream.UnaryResult{}, errors.New("execute user service command: user_id must not be empty")
}
switch command.MessageType {
case usermodel.MessageTypeGetMyAccount:
if _, err := transcoder.PayloadToGetMyAccountRequest(command.PayloadBytes); err != nil {
return downstream.UnaryResult{}, fmt.Errorf("execute user service command %q: %w", command.MessageType, err)
}
return c.executeGetMyAccount(ctx, command.UserID)
case usermodel.MessageTypeUpdateMyProfile:
request, err := transcoder.PayloadToUpdateMyProfileRequest(command.PayloadBytes)
if err != nil {
return downstream.UnaryResult{}, fmt.Errorf("execute user service command %q: %w", command.MessageType, err)
}
return c.executeUpdateMyProfile(ctx, command.UserID, request)
case usermodel.MessageTypeUpdateMySettings:
request, err := transcoder.PayloadToUpdateMySettingsRequest(command.PayloadBytes)
if err != nil {
return downstream.UnaryResult{}, fmt.Errorf("execute user service command %q: %w", command.MessageType, err)
}
return c.executeUpdateMySettings(ctx, command.UserID, request)
default:
return downstream.UnaryResult{}, fmt.Errorf("execute user service command: unsupported message type %q", command.MessageType)
}
}
func (c *HTTPClient) executeGetMyAccount(ctx context.Context, userID string) (downstream.UnaryResult, error) {
payload, statusCode, err := c.doRequest(ctx, http.MethodGet, c.userPath(userID, userServiceAccountPathSuffix), nil)
if err != nil {
return downstream.UnaryResult{}, fmt.Errorf("execute get my account: %w", err)
}
return projectResponse(statusCode, payload)
}
func (c *HTTPClient) executeUpdateMyProfile(ctx context.Context, userID string, request *usermodel.UpdateMyProfileRequest) (downstream.UnaryResult, error) {
payload, statusCode, err := c.doRequest(ctx, http.MethodPost, c.userPath(userID, userServiceProfilePathSuffix), request)
if err != nil {
return downstream.UnaryResult{}, fmt.Errorf("execute update my profile: %w", err)
}
return projectResponse(statusCode, payload)
}
func (c *HTTPClient) executeUpdateMySettings(ctx context.Context, userID string, request *usermodel.UpdateMySettingsRequest) (downstream.UnaryResult, error) {
payload, statusCode, err := c.doRequest(ctx, http.MethodPost, c.userPath(userID, userServiceSettingsPathSuffix), request)
if err != nil {
return downstream.UnaryResult{}, fmt.Errorf("execute update my settings: %w", err)
}
return projectResponse(statusCode, payload)
}
func (c *HTTPClient) doRequest(ctx context.Context, method string, targetURL string, requestBody any) ([]byte, int, error) {
if c == nil || c.httpClient == nil {
return nil, 0, errors.New("nil client")
}
var bodyReader io.Reader
if requestBody != nil {
payload, err := json.Marshal(requestBody)
if err != nil {
return nil, 0, fmt.Errorf("marshal request body: %w", err)
}
bodyReader = bytes.NewReader(payload)
}
request, err := http.NewRequestWithContext(ctx, method, targetURL, bodyReader)
if err != nil {
return nil, 0, fmt.Errorf("build request: %w", err)
}
if requestBody != nil {
request.Header.Set("Content-Type", "application/json")
}
response, err := c.httpClient.Do(request)
if err != nil {
return nil, 0, err
}
defer response.Body.Close()
payload, err := io.ReadAll(response.Body)
if err != nil {
return nil, 0, fmt.Errorf("read response body: %w", err)
}
return payload, response.StatusCode, nil
}
func (c *HTTPClient) userPath(userID string, suffix string) string {
return c.baseURL + "/api/v1/internal/users/" + url.PathEscape(userID) + suffix
}
func projectResponse(statusCode int, payload []byte) (downstream.UnaryResult, error) {
switch {
case statusCode == http.StatusOK:
var response usermodel.AccountResponse
if err := decodeStrictJSONPayload(payload, &response); err != nil {
return downstream.UnaryResult{}, fmt.Errorf("decode success response: %w", err)
}
payloadBytes, err := transcoder.AccountResponseToPayload(&response)
if err != nil {
return downstream.UnaryResult{}, fmt.Errorf("encode success response payload: %w", err)
}
return downstream.UnaryResult{
ResultCode: getMyAccountResultCodeOK,
PayloadBytes: payloadBytes,
}, nil
case statusCode == http.StatusServiceUnavailable:
return downstream.UnaryResult{}, downstream.ErrDownstreamUnavailable
case statusCode >= 400 && statusCode <= 599:
errorResponse, err := decodeUserServiceError(statusCode, payload)
if err != nil {
return downstream.UnaryResult{}, fmt.Errorf("decode error response: %w", err)
}
payloadBytes, err := transcoder.ErrorResponseToPayload(errorResponse)
if err != nil {
return downstream.UnaryResult{}, fmt.Errorf("encode error response payload: %w", err)
}
return downstream.UnaryResult{
ResultCode: errorResponse.Error.Code,
PayloadBytes: payloadBytes,
}, nil
default:
return downstream.UnaryResult{}, fmt.Errorf("unexpected HTTP status %d", statusCode)
}
}
func decodeUserServiceError(statusCode int, payload []byte) (*usermodel.ErrorResponse, error) {
var response usermodel.ErrorResponse
if err := decodeStrictJSONPayload(payload, &response); err != nil {
return nil, err
}
response.Error.Code = normalizeErrorCode(statusCode, response.Error.Code)
response.Error.Message = normalizeErrorMessage(response.Error.Code, response.Error.Message)
if strings.TrimSpace(response.Error.Code) == "" {
return nil, errors.New("missing error code")
}
if strings.TrimSpace(response.Error.Message) == "" {
return nil, errors.New("missing error message")
}
return &response, nil
}
func normalizeErrorCode(statusCode int, code string) string {
trimmed := strings.TrimSpace(code)
if trimmed != "" {
return trimmed
}
switch statusCode {
case http.StatusBadRequest:
return "invalid_request"
case http.StatusNotFound:
return "subject_not_found"
case http.StatusConflict:
return "conflict"
default:
return "internal_error"
}
}
func normalizeErrorMessage(code string, message string) string {
trimmed := strings.TrimSpace(message)
if trimmed != "" {
return trimmed
}
if stable, ok := stableErrorMessages[code]; ok {
return stable
}
return stableErrorMessages["internal_error"]
}
func decodeStrictJSONPayload(payload []byte, target any) error {
decoder := json.NewDecoder(bytes.NewReader(payload))
decoder.DisallowUnknownFields()
if err := decoder.Decode(target); err != nil {
return err
}
if err := decoder.Decode(&struct{}{}); err != io.EOF {
if err == nil {
return errors.New("unexpected trailing JSON input")
}
return err
}
return nil
}
var _ downstream.Client = (*HTTPClient)(nil)
@@ -0,0 +1,399 @@
package userservice
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
"time"
"galaxy/gateway/internal/downstream"
usermodel "galaxy/model/user"
"galaxy/transcoder"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNewHTTPClient(t *testing.T) {
t.Parallel()
tests := []struct {
name string
baseURL string
wantURL string
wantErr string
}{
{
name: "absolute URL is normalized",
baseURL: " http://127.0.0.1:8081/ ",
wantURL: "http://127.0.0.1:8081",
},
{
name: "empty base URL is rejected",
baseURL: " ",
wantErr: "base URL must not be empty",
},
{
name: "relative base URL is rejected",
baseURL: "/relative",
wantErr: "base URL must be absolute",
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
client, err := NewHTTPClient(tt.baseURL)
if tt.wantErr != "" {
require.Error(t, err)
assert.Contains(t, err.Error(), tt.wantErr)
return
}
require.NoError(t, err)
assert.Equal(t, tt.wantURL, client.baseURL)
})
}
}
func TestHTTPClientExecuteGetMyAccountSuccess(t *testing.T) {
t.Parallel()
wantResponse := sampleAccountResponse()
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
require.Equal(t, http.MethodGet, request.Method)
require.Equal(t, "/api/v1/internal/users/user-123/account", request.URL.Path)
require.NoError(t, json.NewEncoder(writer).Encode(wantResponse))
}))
defer server.Close()
client := newTestHTTPClient(t, server)
payload, err := transcoder.GetMyAccountRequestToPayload(&usermodel.GetMyAccountRequest{})
require.NoError(t, err)
result, err := client.ExecuteCommand(context.Background(), downstream.AuthenticatedCommand{
UserID: "user-123",
MessageType: usermodel.MessageTypeGetMyAccount,
PayloadBytes: payload,
})
require.NoError(t, err)
assert.Equal(t, getMyAccountResultCodeOK, result.ResultCode)
decoded, err := transcoder.PayloadToAccountResponse(result.PayloadBytes)
require.NoError(t, err)
assert.Equal(t, wantResponse, decoded)
}
func TestHTTPClientExecuteUpdateMyProfileProjectsConflict(t *testing.T) {
t.Parallel()
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
require.Equal(t, http.MethodPost, request.Method)
require.Equal(t, "/api/v1/internal/users/user-123/profile", request.URL.Path)
body, err := io.ReadAll(request.Body)
require.NoError(t, err)
require.JSONEq(t, `{"race_name":"Nova Prime"}`, string(body))
writer.WriteHeader(http.StatusConflict)
require.NoError(t, json.NewEncoder(writer).Encode(&usermodel.ErrorResponse{
Error: usermodel.ErrorBody{
Code: "conflict",
Message: "request conflicts with current state",
},
}))
}))
defer server.Close()
client := newTestHTTPClient(t, server)
payload, err := transcoder.UpdateMyProfileRequestToPayload(&usermodel.UpdateMyProfileRequest{RaceName: "Nova Prime"})
require.NoError(t, err)
result, err := client.ExecuteCommand(context.Background(), downstream.AuthenticatedCommand{
UserID: "user-123",
MessageType: usermodel.MessageTypeUpdateMyProfile,
PayloadBytes: payload,
})
require.NoError(t, err)
assert.Equal(t, "conflict", result.ResultCode)
decoded, err := transcoder.PayloadToErrorResponse(result.PayloadBytes)
require.NoError(t, err)
assert.Equal(t, &usermodel.ErrorResponse{
Error: usermodel.ErrorBody{
Code: "conflict",
Message: "request conflicts with current state",
},
}, decoded)
}
func TestHTTPClientExecuteUpdateMySettingsProjectsInvalidRequest(t *testing.T) {
t.Parallel()
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
require.Equal(t, http.MethodPost, request.Method)
require.Equal(t, "/api/v1/internal/users/user-123/settings", request.URL.Path)
body, err := io.ReadAll(request.Body)
require.NoError(t, err)
require.JSONEq(t, `{"preferred_language":"bad","time_zone":"Mars/Base"}`, string(body))
writer.WriteHeader(http.StatusBadRequest)
require.NoError(t, json.NewEncoder(writer).Encode(&usermodel.ErrorResponse{
Error: usermodel.ErrorBody{
Code: "invalid_request",
Message: "request is invalid",
},
}))
}))
defer server.Close()
client := newTestHTTPClient(t, server)
payload, err := transcoder.UpdateMySettingsRequestToPayload(&usermodel.UpdateMySettingsRequest{
PreferredLanguage: "bad",
TimeZone: "Mars/Base",
})
require.NoError(t, err)
result, err := client.ExecuteCommand(context.Background(), downstream.AuthenticatedCommand{
UserID: "user-123",
MessageType: usermodel.MessageTypeUpdateMySettings,
PayloadBytes: payload,
})
require.NoError(t, err)
assert.Equal(t, "invalid_request", result.ResultCode)
decoded, err := transcoder.PayloadToErrorResponse(result.PayloadBytes)
require.NoError(t, err)
assert.Equal(t, "invalid_request", decoded.Error.Code)
}
func TestHTTPClientExecuteCommandProjectsSubjectNotFound(t *testing.T) {
t.Parallel()
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
writer.WriteHeader(http.StatusNotFound)
require.NoError(t, json.NewEncoder(writer).Encode(&usermodel.ErrorResponse{
Error: usermodel.ErrorBody{
Code: "subject_not_found",
Message: "subject not found",
},
}))
}))
defer server.Close()
client := newTestHTTPClient(t, server)
payload, err := transcoder.GetMyAccountRequestToPayload(&usermodel.GetMyAccountRequest{})
require.NoError(t, err)
result, err := client.ExecuteCommand(context.Background(), downstream.AuthenticatedCommand{
UserID: "user-missing",
MessageType: usermodel.MessageTypeGetMyAccount,
PayloadBytes: payload,
})
require.NoError(t, err)
assert.Equal(t, "subject_not_found", result.ResultCode)
}
func TestHTTPClientExecuteCommandMaps503ToUnavailable(t *testing.T) {
t.Parallel()
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
writer.WriteHeader(http.StatusServiceUnavailable)
require.NoError(t, json.NewEncoder(writer).Encode(&usermodel.ErrorResponse{
Error: usermodel.ErrorBody{
Code: "service_unavailable",
Message: "service is unavailable",
},
}))
}))
defer server.Close()
client := newTestHTTPClient(t, server)
payload, err := transcoder.GetMyAccountRequestToPayload(&usermodel.GetMyAccountRequest{})
require.NoError(t, err)
_, err = client.ExecuteCommand(context.Background(), downstream.AuthenticatedCommand{
UserID: "user-123",
MessageType: usermodel.MessageTypeGetMyAccount,
PayloadBytes: payload,
})
require.Error(t, err)
assert.ErrorIs(t, err, downstream.ErrDownstreamUnavailable)
}
func TestHTTPClientExecuteCommandUsesCallerContext(t *testing.T) {
t.Parallel()
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
<-request.Context().Done()
}))
defer server.Close()
client := newTestHTTPClient(t, server)
payload, err := transcoder.GetMyAccountRequestToPayload(&usermodel.GetMyAccountRequest{})
require.NoError(t, err)
ctx, cancel := context.WithTimeout(context.Background(), 25*time.Millisecond)
defer cancel()
_, err = client.ExecuteCommand(ctx, downstream.AuthenticatedCommand{
UserID: "user-123",
MessageType: usermodel.MessageTypeGetMyAccount,
PayloadBytes: payload,
})
require.Error(t, err)
assert.ErrorIs(t, err, context.DeadlineExceeded)
}
func TestHTTPClientExecuteCommandRejectsMalformedSuccessPayload(t *testing.T) {
t.Parallel()
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
_, _ = writer.Write([]byte(`{"account":{"user_id":"user-123","unexpected":true}}`))
}))
defer server.Close()
client := newTestHTTPClient(t, server)
payload, err := transcoder.GetMyAccountRequestToPayload(&usermodel.GetMyAccountRequest{})
require.NoError(t, err)
_, err = client.ExecuteCommand(context.Background(), downstream.AuthenticatedCommand{
UserID: "user-123",
MessageType: usermodel.MessageTypeGetMyAccount,
PayloadBytes: payload,
})
require.Error(t, err)
assert.Contains(t, err.Error(), "decode success response")
}
func TestHTTPClientExecuteCommandRejectsUnsupportedMessageType(t *testing.T) {
t.Parallel()
server := httptest.NewServer(http.NotFoundHandler())
defer server.Close()
client := newTestHTTPClient(t, server)
_, err := client.ExecuteCommand(context.Background(), downstream.AuthenticatedCommand{
UserID: "user-123",
MessageType: "user.unsupported",
PayloadBytes: []byte("payload"),
})
require.Error(t, err)
assert.Contains(t, err.Error(), "unsupported message type")
}
func TestNewRoutesReserveUserMessageTypesWhenUnconfigured(t *testing.T) {
t.Parallel()
routes, closeFn, err := NewRoutes("")
require.NoError(t, err)
require.NoError(t, closeFn())
router := downstream.NewStaticRouter(routes)
for _, messageType := range []string{
usermodel.MessageTypeGetMyAccount,
usermodel.MessageTypeUpdateMyProfile,
usermodel.MessageTypeUpdateMySettings,
} {
client, routeErr := router.Route(messageType)
require.NoError(t, routeErr)
_, execErr := client.ExecuteCommand(context.Background(), downstream.AuthenticatedCommand{
UserID: "user-123",
MessageType: messageType,
})
require.Error(t, execErr)
assert.ErrorIs(t, execErr, downstream.ErrDownstreamUnavailable)
}
}
func TestUnavailableClientReturnsDownstreamUnavailable(t *testing.T) {
t.Parallel()
_, err := unavailableClient{}.ExecuteCommand(context.Background(), downstream.AuthenticatedCommand{})
require.Error(t, err)
assert.ErrorIs(t, err, downstream.ErrDownstreamUnavailable)
}
func newTestHTTPClient(t *testing.T, server *httptest.Server) *HTTPClient {
t.Helper()
client, err := newHTTPClient(server.URL, server.Client())
require.NoError(t, err)
return client
}
func sampleAccountResponse() *usermodel.AccountResponse {
now := time.Date(2026, time.April, 9, 10, 0, 0, 0, time.UTC)
expiresAt := now.Add(30 * 24 * time.Hour)
return &usermodel.AccountResponse{
Account: usermodel.Account{
UserID: "user-123",
Email: "pilot@example.com",
RaceName: "Pilot Nova",
PreferredLanguage: "en",
TimeZone: "Europe/Kaliningrad",
DeclaredCountry: "DE",
Entitlement: usermodel.EntitlementSnapshot{
PlanCode: "free",
IsPaid: false,
Source: "auth_registration",
Actor: usermodel.ActorRef{Type: "service", ID: "user-service"},
ReasonCode: "initial_free_entitlement",
StartsAt: now,
UpdatedAt: now,
},
ActiveSanctions: []usermodel.ActiveSanction{
{
SanctionCode: "profile_update_block",
Scope: "lobby",
ReasonCode: "manual_block",
Actor: usermodel.ActorRef{Type: "admin", ID: "admin-1"},
AppliedAt: now,
ExpiresAt: &expiresAt,
},
},
ActiveLimits: []usermodel.ActiveLimit{
{
LimitCode: "max_owned_private_games",
Value: 3,
ReasonCode: "manual_override",
Actor: usermodel.ActorRef{Type: "admin", ID: "admin-1"},
AppliedAt: now,
},
},
CreatedAt: now,
UpdatedAt: now,
},
}
}
func TestDecodeUserServiceErrorNormalizesBlankFields(t *testing.T) {
t.Parallel()
response, err := decodeUserServiceError(http.StatusBadRequest, []byte(`{"error":{"code":" ","message":" "}}`))
require.NoError(t, err)
assert.Equal(t, "invalid_request", response.Error.Code)
assert.Equal(t, "request is invalid", response.Error.Message)
}
func TestHTTPClientExecuteCommandRejectsNilContext(t *testing.T) {
t.Parallel()
server := httptest.NewServer(http.NotFoundHandler())
defer server.Close()
client := newTestHTTPClient(t, server)
_, err := client.ExecuteCommand(nil, downstream.AuthenticatedCommand{})
require.Error(t, err)
assert.Contains(t, err.Error(), "nil context")
}
@@ -0,0 +1,46 @@
package userservice
import (
"context"
"galaxy/gateway/internal/downstream"
usermodel "galaxy/model/user"
)
var noOpClose = func() error { return nil }
// NewRoutes returns the reserved authenticated gateway routes owned by the
// Gateway -> User self-service boundary.
//
// When baseURL is empty, the returned routes still reserve the stable
// `user.*` message types but resolve them to a dependency-unavailable client
// so callers receive the transport-level unavailable outcome instead of a
// route-miss error.
func NewRoutes(baseURL string) (map[string]downstream.Client, func() error, error) {
client := downstream.Client(unavailableClient{})
closeFn := noOpClose
if baseURL != "" {
httpClient, err := NewHTTPClient(baseURL)
if err != nil {
return nil, nil, err
}
client = httpClient
closeFn = httpClient.Close
}
return map[string]downstream.Client{
usermodel.MessageTypeGetMyAccount: client,
usermodel.MessageTypeUpdateMyProfile: client,
usermodel.MessageTypeUpdateMySettings: client,
}, closeFn, nil
}
type unavailableClient struct{}
func (unavailableClient) ExecuteCommand(context.Context, downstream.AuthenticatedCommand) (downstream.UnaryResult, error) {
return downstream.UnaryResult{}, downstream.ErrDownstreamUnavailable
}
var _ downstream.Client = unavailableClient{}
+3 -13
View File
@@ -12,14 +12,11 @@ github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5P
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI= github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI=
github.com/cpuguy83/go-md2man/v2 v2.0.1/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/cpuguy83/go-md2man/v2 v2.0.1/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/envoyproxy/go-control-plane v0.14.0/go.mod h1:NcS5X47pLl/hfqxU70yPwL9ZMkUlwlKxtAohpi2wBEU= github.com/envoyproxy/go-control-plane v0.14.0/go.mod h1:NcS5X47pLl/hfqxU70yPwL9ZMkUlwlKxtAohpi2wBEU=
github.com/envoyproxy/go-control-plane/envoy v1.36.0/go.mod h1:ty89S1YCCVruQAm9OtKeEkQLTb+Lkz0k8v9W0Oxsv98= github.com/envoyproxy/go-control-plane/envoy v1.36.0/go.mod h1:ty89S1YCCVruQAm9OtKeEkQLTb+Lkz0k8v9W0Oxsv98=
github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4= github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4=
github.com/envoyproxy/protoc-gen-validate v1.3.0/go.mod h1:HvYl7zwPa5mffgyeTUHA9zHIH36nmrm7oCbo4YKoSWA= github.com/envoyproxy/protoc-gen-validate v1.3.0/go.mod h1:HvYl7zwPa5mffgyeTUHA9zHIH36nmrm7oCbo4YKoSWA=
github.com/francoispqt/gojay v1.2.13/go.mod h1:ehT5mTG4ua4581f1++1WLG0vPdaA9HaiDsoyrBGkyDY= github.com/francoispqt/gojay v1.2.13/go.mod h1:ehT5mTG4ua4581f1++1WLG0vPdaA9HaiDsoyrBGkyDY=
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
@@ -44,8 +41,6 @@ github.com/natefinch/atomic v1.0.1/go.mod h1:N/D/ELrljoqDyT3rZrsUmtsuzvHkeB/wWjH
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.19.1/go.mod h1:mP78NwGzrVks5S2H6ab8+ZZGJLZUq1hoULYBAYBw1Ho= github.com/prometheus/client_golang v1.19.1/go.mod h1:mP78NwGzrVks5S2H6ab8+ZZGJLZUq1hoULYBAYBw1Ho=
github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI= github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI=
github.com/prometheus/common v0.48.0/go.mod h1:0/KsvlIEfPQCQ5I2iNSAWKPZziNCvRs5EC6ILDTlAPc= github.com/prometheus/common v0.48.0/go.mod h1:0/KsvlIEfPQCQ5I2iNSAWKPZziNCvRs5EC6ILDTlAPc=
@@ -53,6 +48,7 @@ github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3c
github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs= github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
@@ -67,19 +63,12 @@ github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfS
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.opentelemetry.io/contrib/detectors/gcp v1.39.0/go.mod h1:t/OGqzHBa5v6RHZwrDBJ2OirWc+4q/w2fTbLZwAKjTk= go.opentelemetry.io/contrib/detectors/gcp v1.39.0/go.mod h1:t/OGqzHBa5v6RHZwrDBJ2OirWc+4q/w2fTbLZwAKjTk=
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.42.0 h1:MdKucPl/HbzckWWEisiNqMPhRrAOQX8r4jTuGr636gk=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.42.0/go.mod h1:RolT8tWtfHcjajEH5wFIZ4Dgh5jpPdFXYV9pTAk/qjc=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.42.0 h1:H7O6RlGOMTizyl3R08Kn5pdM06bnH8oscSj7o11tmLA=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.42.0/go.mod h1:mBFWu/WOVDkWWsR7Tx7h6EpQB8wsv7P0Yrh0Pb7othc=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.42.0 h1:lSZHgNHfbmQTPfuTmWVkEu8J8qXaQwuV30pjCcAUvP8=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.42.0/go.mod h1:so9ounLcuoRDu033MW/E0AD4hhUjVqswrMF5FoZlBcw=
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o= go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
@@ -115,6 +104,7 @@ golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -134,6 +124,7 @@ golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/telemetry v0.0.0-20240521205824-bda55230c457/go.mod h1:pRgIJT+bRLFKnoM1ldnzKoxTIn14Yxz928LQRYYgIN0= golang.org/x/telemetry v0.0.0-20240521205824-bda55230c457/go.mod h1:pRgIJT+bRLFKnoM1ldnzKoxTIn14Yxz928LQRYYgIN0=
golang.org/x/telemetry v0.0.0-20260109210033-bd525da824e2/go.mod h1:b7fPSJ0pKZ3ccUh8gnTONJxhn3c/PS6tyzQvyqw4iA8= golang.org/x/telemetry v0.0.0-20260109210033-bd525da824e2/go.mod h1:b7fPSJ0pKZ3ccUh8gnTONJxhn3c/PS6tyzQvyqw4iA8=
golang.org/x/telemetry v0.0.0-20260209163413-e7419c687ee4/go.mod h1:g5NllXBEermZrmR51cJDQxmJUHUOfRAaNyWBM+R+548= golang.org/x/telemetry v0.0.0-20260209163413-e7419c687ee4/go.mod h1:g5NllXBEermZrmR51cJDQxmJUHUOfRAaNyWBM+R+548=
@@ -183,5 +174,4 @@ google.golang.org/grpc v1.79.2/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhH
google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
+26 -3
View File
@@ -8,13 +8,25 @@ Each suite must raise real service processes, speak only over public HTTP/gRPC/R
```text ```text
integration/ integration/
├── README.md ├── README.md
├── go.mod ├── authsessionuser/
│ ├── authsession_user_test.go
│ └── harness_test.go
├── gatewayauthsession/ ├── gatewayauthsession/
│ ├── harness_test.go │ ├── harness_test.go
│ └── gateway_authsession_test.go │ └── gateway_authsession_test.go
├── gatewayauthsessionuser/
│ ├── gateway_authsession_user_test.go
│ └── harness_test.go
├── gatewayuser/
│ ├── gateway_user_test.go
│ └── harness_test.go
├── go.mod
├── go.sum
└── internal/ └── internal/
├── contracts/ ├── contracts/
── gatewayv1/ ── gatewayv1/
│ │ └── contract.go
│ └── userv1/
│ └── contract.go │ └── contract.go
└── harness/ └── harness/
├── binary.go ├── binary.go
@@ -35,8 +47,12 @@ integration/
## Current Boundary Suites ## Current Boundary Suites
- `gatewayauthsession` verifies the integration boundary between real `Edge Gateway` and real `Auth / Session Service`. - `gatewayauthsession` verifies the integration boundary between real `Edge Gateway` and real `Auth / Session Service`.
- `authsessionuser` verifies the integration boundary between real `Auth / Session Service` and real `User Service`.
- `gatewayuser` verifies the direct authenticated self-service boundary between real `Edge Gateway` and real `User Service`.
- `gatewayauthsessionuser` verifies the full public-auth plus authenticated-account chain across real `Edge Gateway`, real `Auth / Session Service`, and real `User Service`.
The current fast suite uses one isolated `miniredis` instance plus external stateful HTTP stubs for mail and user services. The current fast suites use one isolated `miniredis` instance plus either
real downstream processes or external stateful HTTP stubs where appropriate.
## Running ## Running
@@ -45,14 +61,21 @@ Run from the module directory:
```bash ```bash
cd integration cd integration
go test ./gatewayauthsession/... go test ./gatewayauthsession/...
go test ./authsessionuser/...
go test ./gatewayuser/...
go test ./gatewayauthsessionuser/...
``` ```
Useful regression commands after boundary changes: Useful regression commands after boundary changes:
```bash ```bash
go test ./gatewayauthsession/... go test ./gatewayauthsession/...
go test ./authsessionuser/...
go test ./gatewayuser/...
go test ./gatewayauthsessionuser/...
cd ../gateway && go test ./... cd ../gateway && go test ./...
cd ../authsession && go test ./... -run GatewayCompatibility cd ../authsession && go test ./... -run GatewayCompatibility
cd ../user && go test ./...
``` ```
Do not use `go test ./...` from the repository root. The repository is organized through `go.work`, so verification should stay module-scoped. Do not use `go test ./...` from the repository root. The repository is organized through `go.work`, so verification should stay module-scoped.
@@ -0,0 +1,90 @@
package authsessionuser_test
import (
"net/http"
"strings"
"testing"
"github.com/stretchr/testify/require"
)
func TestAuthsessionUserBlackBoxConfirmCreatesUserWithForwardedRegistrationContext(t *testing.T) {
t.Parallel()
h := newAuthsessionUserHarness(t)
email := "created@example.com"
challengeID := h.sendChallenge(t, email)
code := lastMailCodeFor(t, h.mailStub, email)
response := h.confirmCode(t, challengeID, code)
var confirmBody struct {
DeviceSessionID string `json:"device_session_id"`
}
requireJSONStatus(t, response, http.StatusOK, &confirmBody)
require.True(t, strings.HasPrefix(confirmBody.DeviceSessionID, "device-session-"))
lookupResponse, account := lookupUserByEmail(t, h.userServiceURL, email)
require.Equalf(t, http.StatusOK, lookupResponse.StatusCode, formatStatusError(lookupResponse))
require.Equal(t, email, account.User.Email)
require.Equal(t, "en", account.User.PreferredLanguage)
require.Equal(t, testTimeZone, account.User.TimeZone)
require.True(t, strings.HasPrefix(account.User.UserID, "user-"))
require.True(t, strings.HasPrefix(account.User.RaceName, "player-"))
require.Equal(t, "free", account.User.Entitlement.PlanCode)
require.False(t, account.User.Entitlement.IsPaid)
require.Empty(t, account.User.ActiveSanctions)
require.Empty(t, account.User.ActiveLimits)
}
func TestAuthsessionUserBlackBoxConfirmForExistingUserKeepsCreateOnlySettings(t *testing.T) {
t.Parallel()
h := newAuthsessionUserHarness(t)
email := "existing@example.com"
created := postEnsureUser(t, h.userServiceURL, email, "fr-FR", "Europe/Paris")
require.Equal(t, "created", created.Outcome)
sleepForDistinctCreatedAt()
challengeID := h.sendChallenge(t, email)
code := lastMailCodeFor(t, h.mailStub, email)
response := h.confirmCode(t, challengeID, code)
var confirmBody struct {
DeviceSessionID string `json:"device_session_id"`
}
requireJSONStatus(t, response, http.StatusOK, &confirmBody)
require.True(t, strings.HasPrefix(confirmBody.DeviceSessionID, "device-session-"))
lookupResponse, account := lookupUserByEmail(t, h.userServiceURL, email)
require.Equalf(t, http.StatusOK, lookupResponse.StatusCode, formatStatusError(lookupResponse))
require.Equal(t, created.UserID, account.User.UserID)
require.Equal(t, "fr-FR", account.User.PreferredLanguage)
require.Equal(t, "Europe/Paris", account.User.TimeZone)
}
func TestAuthsessionUserBlackBoxBlockedEmailSendIsSuccessShapedAndConfirmIsRejectedWithoutCreatingUser(t *testing.T) {
t.Parallel()
h := newAuthsessionUserHarness(t)
blockedAtSendEmail := "blocked-send@example.com"
postBlockByEmail(t, h.userServiceURL, blockedAtSendEmail)
beforeBlockedSendDeliveries := len(h.mailStub.RecordedDeliveries())
blockedChallengeID := h.sendChallenge(t, blockedAtSendEmail)
require.NotEmpty(t, blockedChallengeID)
require.Len(t, h.mailStub.RecordedDeliveries(), beforeBlockedSendDeliveries)
blockedAtConfirmEmail := "blocked-confirm@example.com"
challengeID := h.sendChallenge(t, blockedAtConfirmEmail)
code := lastMailCodeFor(t, h.mailStub, blockedAtConfirmEmail)
postBlockByEmail(t, h.userServiceURL, blockedAtConfirmEmail)
confirmResponse := h.confirmCode(t, challengeID, code)
requireJSONStatusRaw(t, confirmResponse, http.StatusForbidden, `{"error":{"code":"blocked_by_policy","message":"authentication is blocked by policy"}}`)
lookupResponse, _ := lookupUserByEmail(t, h.userServiceURL, blockedAtConfirmEmail)
requireLookupNotFound(t, lookupResponse)
}
+386
View File
@@ -0,0 +1,386 @@
package authsessionuser_test
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"testing"
"time"
"galaxy/integration/internal/harness"
"github.com/stretchr/testify/require"
)
const (
testClientPublicKey = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8="
testTimeZone = "Europe/Kaliningrad"
)
type authsessionUserHarness struct {
mailStub *harness.MailStub
authsessionPublicURL string
userServiceURL string
authsessionProcess *harness.Process
userServiceProcess *harness.Process
}
func newAuthsessionUserHarness(t *testing.T) *authsessionUserHarness {
t.Helper()
redisServer := harness.StartMiniredis(t)
mailStub := harness.NewMailStub(t)
userServiceAddr := harness.FreeTCPAddress(t)
authsessionPublicAddr := harness.FreeTCPAddress(t)
authsessionInternalAddr := harness.FreeTCPAddress(t)
userServiceBinary := harness.BuildBinary(t, "userservice", "./user/cmd/userservice")
authsessionBinary := harness.BuildBinary(t, "authsession", "./authsession/cmd/authsession")
userServiceEnv := map[string]string{
"USERSERVICE_LOG_LEVEL": "info",
"USERSERVICE_INTERNAL_HTTP_ADDR": userServiceAddr,
"USERSERVICE_REDIS_ADDR": redisServer.Addr(),
"OTEL_TRACES_EXPORTER": "none",
"OTEL_METRICS_EXPORTER": "none",
}
userServiceProcess := harness.StartProcess(t, "userservice", userServiceBinary, userServiceEnv)
waitForUserServiceReady(t, userServiceProcess, "http://"+userServiceAddr)
authsessionEnv := map[string]string{
"AUTHSESSION_LOG_LEVEL": "info",
"AUTHSESSION_PUBLIC_HTTP_ADDR": authsessionPublicAddr,
"AUTHSESSION_INTERNAL_HTTP_ADDR": authsessionInternalAddr,
"AUTHSESSION_REDIS_ADDR": redisServer.Addr(),
"AUTHSESSION_USER_SERVICE_MODE": "rest",
"AUTHSESSION_USER_SERVICE_BASE_URL": "http://" + userServiceAddr,
"AUTHSESSION_MAIL_SERVICE_MODE": "rest",
"AUTHSESSION_MAIL_SERVICE_BASE_URL": mailStub.BaseURL(),
"OTEL_TRACES_EXPORTER": "none",
"OTEL_METRICS_EXPORTER": "none",
"AUTHSESSION_PUBLIC_HTTP_REQUEST_TIMEOUT": time.Second.String(),
"AUTHSESSION_INTERNAL_HTTP_REQUEST_TIMEOUT": time.Second.String(),
}
authsessionProcess := harness.StartProcess(t, "authsession", authsessionBinary, authsessionEnv)
waitForAuthsessionPublicReady(t, authsessionProcess, "http://"+authsessionPublicAddr)
return &authsessionUserHarness{
mailStub: mailStub,
authsessionPublicURL: "http://" + authsessionPublicAddr,
userServiceURL: "http://" + userServiceAddr,
authsessionProcess: authsessionProcess,
userServiceProcess: userServiceProcess,
}
}
func (h *authsessionUserHarness) sendChallenge(t *testing.T, email string) string {
t.Helper()
response := postJSONValue(t, h.authsessionPublicURL+"/api/v1/public/auth/send-email-code", map[string]string{
"email": email,
})
require.Equal(t, http.StatusOK, response.StatusCode)
var body struct {
ChallengeID string `json:"challenge_id"`
}
require.NoError(t, decodeStrictJSONPayload([]byte(response.Body), &body))
require.NotEmpty(t, body.ChallengeID)
return body.ChallengeID
}
func (h *authsessionUserHarness) confirmCode(t *testing.T, challengeID string, code string) httpResponse {
t.Helper()
return postJSONValue(t, h.authsessionPublicURL+"/api/v1/public/auth/confirm-email-code", map[string]string{
"challenge_id": challengeID,
"code": code,
"client_public_key": testClientPublicKey,
"time_zone": testTimeZone,
})
}
type httpResponse struct {
StatusCode int
Body string
Header http.Header
}
func postJSONValue(t *testing.T, targetURL string, body any) httpResponse {
t.Helper()
payload, err := json.Marshal(body)
require.NoError(t, err)
request, err := http.NewRequest(http.MethodPost, targetURL, bytes.NewReader(payload))
require.NoError(t, err)
request.Header.Set("Content-Type", "application/json")
client := &http.Client{
Timeout: 250 * time.Millisecond,
Transport: &http.Transport{
DisableKeepAlives: true,
},
}
t.Cleanup(client.CloseIdleConnections)
response, err := client.Do(request)
require.NoError(t, err)
defer response.Body.Close()
responseBody, err := io.ReadAll(response.Body)
require.NoError(t, err)
return httpResponse{
StatusCode: response.StatusCode,
Body: string(responseBody),
Header: response.Header.Clone(),
}
}
func decodeStrictJSONPayload(payload []byte, target any) error {
decoder := json.NewDecoder(bytes.NewReader(payload))
decoder.DisallowUnknownFields()
if err := decoder.Decode(target); err != nil {
return err
}
if err := decoder.Decode(&struct{}{}); err != io.EOF {
if err == nil {
return errors.New("unexpected trailing JSON input")
}
return err
}
return nil
}
func waitForUserServiceReady(t *testing.T, process *harness.Process, baseURL string) {
t.Helper()
client := &http.Client{Timeout: 250 * time.Millisecond}
deadline := time.Now().Add(10 * time.Second)
for time.Now().Before(deadline) {
request, err := http.NewRequest(http.MethodGet, baseURL+"/api/v1/internal/users/user-missing/exists", nil)
require.NoError(t, err)
response, err := client.Do(request)
if err == nil {
_, _ = io.Copy(io.Discard, response.Body)
response.Body.Close()
if response.StatusCode == http.StatusOK {
return
}
}
time.Sleep(25 * time.Millisecond)
}
t.Fatalf("wait for userservice readiness: timeout\n%s", process.Logs())
}
func waitForAuthsessionPublicReady(t *testing.T, process *harness.Process, baseURL string) {
t.Helper()
client := &http.Client{Timeout: 250 * time.Millisecond}
deadline := time.Now().Add(10 * time.Second)
for time.Now().Before(deadline) {
response, err := postJSONValueMaybe(client, baseURL+"/api/v1/public/auth/send-email-code", map[string]string{
"email": "",
})
if err == nil && response.StatusCode == http.StatusBadRequest {
return
}
time.Sleep(25 * time.Millisecond)
}
t.Fatalf("wait for authsession public readiness: timeout\n%s", process.Logs())
}
func postJSONValueMaybe(client *http.Client, targetURL string, body any) (httpResponse, error) {
payload, err := json.Marshal(body)
if err != nil {
return httpResponse{}, err
}
request, err := http.NewRequest(http.MethodPost, targetURL, bytes.NewReader(payload))
if err != nil {
return httpResponse{}, err
}
request.Header.Set("Content-Type", "application/json")
response, err := client.Do(request)
if err != nil {
return httpResponse{}, err
}
defer response.Body.Close()
responseBody, err := io.ReadAll(response.Body)
if err != nil {
return httpResponse{}, err
}
return httpResponse{
StatusCode: response.StatusCode,
Body: string(responseBody),
Header: response.Header.Clone(),
}, nil
}
func requireJSONStatus(t *testing.T, response httpResponse, wantStatus int, target any) {
t.Helper()
require.Equal(t, wantStatus, response.StatusCode, "response body: %s", response.Body)
require.NoError(t, decodeStrictJSONPayload([]byte(response.Body), target))
}
func requireJSONStatusRaw(t *testing.T, response httpResponse, wantStatus int, wantBody string) {
t.Helper()
require.Equal(t, wantStatus, response.StatusCode, "response body: %s", response.Body)
require.JSONEq(t, wantBody, response.Body)
}
func postEnsureUser(t *testing.T, baseURL string, email string, preferredLanguage string, timeZone string) ensureByEmailResponse {
t.Helper()
response := postJSONValue(t, baseURL+"/api/v1/internal/users/ensure-by-email", map[string]any{
"email": email,
"registration_context": map[string]string{
"preferred_language": preferredLanguage,
"time_zone": timeZone,
},
})
var body ensureByEmailResponse
requireJSONStatus(t, response, http.StatusOK, &body)
return body
}
func postBlockByEmail(t *testing.T, baseURL string, email string) {
t.Helper()
response := postJSONValue(t, baseURL+"/api/v1/internal/user-blocks/by-email", map[string]string{
"email": email,
"reason_code": "policy_blocked",
})
var body blockMutationResponse
requireJSONStatus(t, response, http.StatusOK, &body)
}
func lookupUserByEmail(t *testing.T, baseURL string, email string) (httpResponse, userLookupResponse) {
t.Helper()
response := postJSONValue(t, baseURL+"/api/v1/internal/user-lookups/by-email", map[string]string{
"email": email,
})
if response.StatusCode != http.StatusOK {
return response, userLookupResponse{}
}
var body userLookupResponse
require.NoError(t, decodeStrictJSONPayload([]byte(response.Body), &body))
return response, body
}
type ensureByEmailResponse struct {
Outcome string `json:"outcome"`
UserID string `json:"user_id,omitempty"`
}
type blockMutationResponse struct {
Outcome string `json:"outcome"`
UserID string `json:"user_id,omitempty"`
}
type userLookupResponse struct {
User accountView `json:"user"`
}
type accountView struct {
UserID string `json:"user_id"`
Email string `json:"email"`
RaceName string `json:"race_name"`
PreferredLanguage string `json:"preferred_language"`
TimeZone string `json:"time_zone"`
DeclaredCountry string `json:"declared_country,omitempty"`
Entitlement entitlementSnapshotView `json:"entitlement"`
ActiveSanctions []activeSanctionView `json:"active_sanctions"`
ActiveLimits []activeLimitView `json:"active_limits"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type entitlementSnapshotView struct {
PlanCode string `json:"plan_code"`
IsPaid bool `json:"is_paid"`
Source string `json:"source"`
Actor actorRefView `json:"actor"`
ReasonCode string `json:"reason_code"`
StartsAt time.Time `json:"starts_at"`
EndsAt *time.Time `json:"ends_at,omitempty"`
UpdatedAt time.Time `json:"updated_at"`
}
type activeSanctionView struct {
SanctionCode string `json:"sanction_code"`
Scope string `json:"scope"`
ReasonCode string `json:"reason_code"`
Actor actorRefView `json:"actor"`
AppliedAt time.Time `json:"applied_at"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
}
type activeLimitView struct {
LimitCode string `json:"limit_code"`
Value int `json:"value"`
ReasonCode string `json:"reason_code"`
Actor actorRefView `json:"actor"`
AppliedAt time.Time `json:"applied_at"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
}
type actorRefView struct {
Type string `json:"type"`
ID string `json:"id,omitempty"`
}
func requireLookupNotFound(t *testing.T, response httpResponse) {
t.Helper()
requireJSONStatusRaw(t, response, http.StatusNotFound, `{"error":{"code":"subject_not_found","message":"subject not found"}}`)
}
func lastMailCodeFor(t *testing.T, stub *harness.MailStub, email string) string {
t.Helper()
deliveries := stub.RecordedDeliveries()
for index := len(deliveries) - 1; index >= 0; index-- {
if deliveries[index].Email == email {
return deliveries[index].Code
}
}
t.Fatalf("mail stub did not record delivery for %s", email)
return ""
}
func sleepForDistinctCreatedAt() {
time.Sleep(10 * time.Millisecond)
}
func formatStatusError(response httpResponse) string {
return fmt.Sprintf("status=%d body=%s", response.StatusCode, response.Body)
}
@@ -0,0 +1,86 @@
package gatewayauthsessionuser_test
import (
"net/http"
"strings"
"testing"
"github.com/stretchr/testify/require"
)
func TestGatewayAuthsessionUserFirstRegistrationCreatesUserAndAllowsAccountRead(t *testing.T) {
h := newGatewayAuthsessionUserHarness(t)
const email = "created@example.com"
challengeID := h.sendChallenge(t, email)
code := lastMailCodeFor(t, h.mailStub, email)
clientPrivateKey := newClientPrivateKey("first-registration")
confirmResponse := h.confirmCode(t, challengeID, code, clientPrivateKey)
var confirmBody struct {
DeviceSessionID string `json:"device_session_id"`
}
requireJSONStatus(t, confirmResponse, http.StatusOK, &confirmBody)
require.True(t, strings.HasPrefix(confirmBody.DeviceSessionID, "device-session-"))
sessionRecord := h.waitForGatewaySession(t, confirmBody.DeviceSessionID)
accountResponse := h.executeGetMyAccount(t, confirmBody.DeviceSessionID, "request-first-registration", clientPrivateKey)
require.Equal(t, sessionRecord.UserID, accountResponse.Account.UserID)
require.Equal(t, email, accountResponse.Account.Email)
require.Equal(t, "en", accountResponse.Account.PreferredLanguage)
require.Equal(t, gatewayAuthsessionUserTestTimeZone, accountResponse.Account.TimeZone)
lookupResponse, lookup := h.lookupUserByEmail(t, email)
require.Equalf(t, http.StatusOK, lookupResponse.StatusCode, "status=%d body=%s", lookupResponse.StatusCode, lookupResponse.Body)
require.Equal(t, accountResponse.Account.UserID, lookup.User.UserID)
}
func TestGatewayAuthsessionUserExistingAccountKeepsCreateOnlySettings(t *testing.T) {
h := newGatewayAuthsessionUserHarness(t)
const email = "existing@example.com"
created := h.ensureUser(t, email, "fr-FR", "Europe/Paris")
require.Equal(t, "created", created.Outcome)
challengeID := h.sendChallenge(t, email)
code := lastMailCodeFor(t, h.mailStub, email)
clientPrivateKey := newClientPrivateKey("existing-account")
confirmResponse := h.confirmCode(t, challengeID, code, clientPrivateKey)
var confirmBody struct {
DeviceSessionID string `json:"device_session_id"`
}
requireJSONStatus(t, confirmResponse, http.StatusOK, &confirmBody)
accountResponse := h.executeGetMyAccount(t, confirmBody.DeviceSessionID, "request-existing-account", clientPrivateKey)
require.Equal(t, created.UserID, accountResponse.Account.UserID)
require.Equal(t, "fr-FR", accountResponse.Account.PreferredLanguage)
require.Equal(t, "Europe/Paris", accountResponse.Account.TimeZone)
}
func TestGatewayAuthsessionUserBlockedEmailAndUserBehavior(t *testing.T) {
h := newGatewayAuthsessionUserHarness(t)
blockedAtSendEmail := "blocked-send@example.com"
h.blockByEmail(t, blockedAtSendEmail)
beforeBlockedSendDeliveries := len(h.mailStub.RecordedDeliveries())
blockedChallengeID := h.sendChallenge(t, blockedAtSendEmail)
require.NotEmpty(t, blockedChallengeID)
require.Len(t, h.mailStub.RecordedDeliveries(), beforeBlockedSendDeliveries)
blockedAtConfirmEmail := "blocked-confirm@example.com"
challengeID := h.sendChallenge(t, blockedAtConfirmEmail)
code := lastMailCodeFor(t, h.mailStub, blockedAtConfirmEmail)
h.blockByEmail(t, blockedAtConfirmEmail)
confirmResponse := h.confirmCode(t, challengeID, code, newClientPrivateKey("blocked-confirm"))
require.Equal(t, http.StatusForbidden, confirmResponse.StatusCode)
require.JSONEq(t, `{"error":{"code":"blocked_by_policy","message":"authentication is blocked by policy"}}`, confirmResponse.Body)
lookupResponse, _ := h.lookupUserByEmail(t, blockedAtConfirmEmail)
requireLookupNotFound(t, lookupResponse)
}
@@ -0,0 +1,460 @@
package gatewayauthsessionuser_test
import (
"bytes"
"context"
"crypto/ed25519"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"path/filepath"
"testing"
"time"
gatewayv1 "galaxy/gateway/proto/galaxy/gateway/v1"
contractsgatewayv1 "galaxy/integration/internal/contracts/gatewayv1"
contractsuserv1 "galaxy/integration/internal/contracts/userv1"
"galaxy/integration/internal/harness"
usermodel "galaxy/model/user"
"github.com/redis/go-redis/v9"
"github.com/stretchr/testify/require"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
)
const gatewayAuthsessionUserTestTimeZone = "Europe/Kaliningrad"
type gatewayAuthsessionUserHarness struct {
redis *redis.Client
mailStub *harness.MailStub
authsessionPublicURL string
userServiceURL string
gatewayPublicURL string
gatewayGRPCAddr string
responseSignerPublicKey ed25519.PublicKey
gatewayProcess *harness.Process
authsessionProcess *harness.Process
userServiceProcess *harness.Process
}
func newGatewayAuthsessionUserHarness(t *testing.T) *gatewayAuthsessionUserHarness {
t.Helper()
redisServer := harness.StartMiniredis(t)
redisClient := redis.NewClient(&redis.Options{
Addr: redisServer.Addr(),
Protocol: 2,
DisableIdentity: true,
})
t.Cleanup(func() {
require.NoError(t, redisClient.Close())
})
mailStub := harness.NewMailStub(t)
responseSignerPath, responseSignerPublicKey := harness.WriteResponseSignerPEM(t, t.Name())
userServiceAddr := harness.FreeTCPAddress(t)
authsessionPublicAddr := harness.FreeTCPAddress(t)
authsessionInternalAddr := harness.FreeTCPAddress(t)
gatewayPublicAddr := harness.FreeTCPAddress(t)
gatewayGRPCAddr := harness.FreeTCPAddress(t)
userServiceBinary := harness.BuildBinary(t, "userservice", "./user/cmd/userservice")
authsessionBinary := harness.BuildBinary(t, "authsession", "./authsession/cmd/authsession")
gatewayBinary := harness.BuildBinary(t, "gateway", "./gateway/cmd/gateway")
userServiceEnv := map[string]string{
"USERSERVICE_LOG_LEVEL": "info",
"USERSERVICE_INTERNAL_HTTP_ADDR": userServiceAddr,
"USERSERVICE_REDIS_ADDR": redisServer.Addr(),
"OTEL_TRACES_EXPORTER": "none",
"OTEL_METRICS_EXPORTER": "none",
}
userServiceProcess := harness.StartProcess(t, "userservice", userServiceBinary, userServiceEnv)
harness.WaitForHTTPStatus(t, userServiceProcess, "http://"+userServiceAddr+"/api/v1/internal/users/user-missing/exists", http.StatusOK)
authsessionEnv := map[string]string{
"AUTHSESSION_LOG_LEVEL": "info",
"AUTHSESSION_PUBLIC_HTTP_ADDR": authsessionPublicAddr,
"AUTHSESSION_PUBLIC_HTTP_REQUEST_TIMEOUT": time.Second.String(),
"AUTHSESSION_INTERNAL_HTTP_ADDR": authsessionInternalAddr,
"AUTHSESSION_INTERNAL_HTTP_REQUEST_TIMEOUT": time.Second.String(),
"AUTHSESSION_REDIS_ADDR": redisServer.Addr(),
"AUTHSESSION_USER_SERVICE_MODE": "rest",
"AUTHSESSION_USER_SERVICE_BASE_URL": "http://" + userServiceAddr,
"AUTHSESSION_USER_SERVICE_REQUEST_TIMEOUT": time.Second.String(),
"AUTHSESSION_MAIL_SERVICE_MODE": "rest",
"AUTHSESSION_MAIL_SERVICE_BASE_URL": mailStub.BaseURL(),
"AUTHSESSION_MAIL_SERVICE_REQUEST_TIMEOUT": time.Second.String(),
"AUTHSESSION_REDIS_GATEWAY_SESSION_CACHE_KEY_PREFIX": "gateway:session:",
"AUTHSESSION_REDIS_GATEWAY_SESSION_EVENTS_STREAM": "gateway:session_events",
"OTEL_TRACES_EXPORTER": "none",
"OTEL_METRICS_EXPORTER": "none",
}
authsessionProcess := harness.StartProcess(t, "authsession", authsessionBinary, authsessionEnv)
waitForAuthsessionPublicReady(t, authsessionProcess, "http://"+authsessionPublicAddr)
gatewayEnv := map[string]string{
"GATEWAY_LOG_LEVEL": "info",
"GATEWAY_PUBLIC_HTTP_ADDR": gatewayPublicAddr,
"GATEWAY_AUTHENTICATED_GRPC_ADDR": gatewayGRPCAddr,
"GATEWAY_AUTH_SERVICE_BASE_URL": "http://" + authsessionPublicAddr,
"GATEWAY_USER_SERVICE_BASE_URL": "http://" + userServiceAddr,
"GATEWAY_PUBLIC_AUTH_UPSTREAM_TIMEOUT": (500 * time.Millisecond).String(),
"GATEWAY_SESSION_CACHE_REDIS_ADDR": redisServer.Addr(),
"GATEWAY_SESSION_CACHE_REDIS_KEY_PREFIX": "gateway:session:",
"GATEWAY_SESSION_EVENTS_REDIS_STREAM": "gateway:session_events",
"GATEWAY_CLIENT_EVENTS_REDIS_STREAM": "gateway:client_events",
"GATEWAY_REPLAY_REDIS_KEY_PREFIX": "gateway:replay:",
"GATEWAY_RESPONSE_SIGNER_PRIVATE_KEY_PEM_PATH": filepath.Clean(responseSignerPath),
"GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_PUBLIC_AUTH_RATE_LIMIT_REQUESTS": "100",
"GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_PUBLIC_AUTH_RATE_LIMIT_WINDOW": "1s",
"GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_PUBLIC_AUTH_RATE_LIMIT_BURST": "100",
"GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_SEND_EMAIL_CODE_IDENTITY_RATE_LIMIT_REQUESTS": "100",
"GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_SEND_EMAIL_CODE_IDENTITY_RATE_LIMIT_WINDOW": "1s",
"GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_SEND_EMAIL_CODE_IDENTITY_RATE_LIMIT_BURST": "100",
"GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_CONFIRM_EMAIL_CODE_IDENTITY_RATE_LIMIT_REQUESTS": "100",
"GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_CONFIRM_EMAIL_CODE_IDENTITY_RATE_LIMIT_WINDOW": "1s",
"GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_CONFIRM_EMAIL_CODE_IDENTITY_RATE_LIMIT_BURST": "100",
"OTEL_TRACES_EXPORTER": "none",
"OTEL_METRICS_EXPORTER": "none",
}
gatewayProcess := harness.StartProcess(t, "gateway", gatewayBinary, gatewayEnv)
harness.WaitForHTTPStatus(t, gatewayProcess, "http://"+gatewayPublicAddr+"/healthz", http.StatusOK)
harness.WaitForTCP(t, gatewayProcess, gatewayGRPCAddr)
return &gatewayAuthsessionUserHarness{
redis: redisClient,
mailStub: mailStub,
authsessionPublicURL: "http://" + authsessionPublicAddr,
userServiceURL: "http://" + userServiceAddr,
gatewayPublicURL: "http://" + gatewayPublicAddr,
gatewayGRPCAddr: gatewayGRPCAddr,
responseSignerPublicKey: responseSignerPublicKey,
gatewayProcess: gatewayProcess,
authsessionProcess: authsessionProcess,
userServiceProcess: userServiceProcess,
}
}
func (h *gatewayAuthsessionUserHarness) sendChallenge(t *testing.T, email string) string {
t.Helper()
response := postJSONValue(t, h.gatewayPublicURL+"/api/v1/public/auth/send-email-code", map[string]string{
"email": email,
})
require.Equal(t, http.StatusOK, response.StatusCode)
var body struct {
ChallengeID string `json:"challenge_id"`
}
require.NoError(t, decodeStrictJSONPayload([]byte(response.Body), &body))
return body.ChallengeID
}
func (h *gatewayAuthsessionUserHarness) confirmCode(t *testing.T, challengeID string, code string, clientPrivateKey ed25519.PrivateKey) httpResponse {
t.Helper()
return postJSONValue(t, h.gatewayPublicURL+"/api/v1/public/auth/confirm-email-code", map[string]string{
"challenge_id": challengeID,
"code": code,
"client_public_key": base64.StdEncoding.EncodeToString(clientPrivateKey.Public().(ed25519.PublicKey)),
"time_zone": gatewayAuthsessionUserTestTimeZone,
})
}
func (h *gatewayAuthsessionUserHarness) ensureUser(t *testing.T, email string, preferredLanguage string, timeZone string) ensureByEmailResponse {
t.Helper()
response := postJSONValue(t, h.userServiceURL+"/api/v1/internal/users/ensure-by-email", map[string]any{
"email": email,
"registration_context": map[string]string{
"preferred_language": preferredLanguage,
"time_zone": timeZone,
},
})
var body ensureByEmailResponse
requireJSONStatus(t, response, http.StatusOK, &body)
return body
}
func (h *gatewayAuthsessionUserHarness) lookupUserByEmail(t *testing.T, email string) (httpResponse, userLookupResponse) {
t.Helper()
response := postJSONValue(t, h.userServiceURL+"/api/v1/internal/user-lookups/by-email", map[string]string{
"email": email,
})
if response.StatusCode != http.StatusOK {
return response, userLookupResponse{}
}
var body userLookupResponse
require.NoError(t, decodeStrictJSONPayload([]byte(response.Body), &body))
return response, body
}
func (h *gatewayAuthsessionUserHarness) blockByEmail(t *testing.T, email string) {
t.Helper()
response := postJSONValue(t, h.userServiceURL+"/api/v1/internal/user-blocks/by-email", map[string]string{
"email": email,
"reason_code": "policy_blocked",
})
require.Equal(t, http.StatusOK, response.StatusCode, "response body: %s", response.Body)
}
func (h *gatewayAuthsessionUserHarness) waitForGatewaySession(t *testing.T, deviceSessionID string) gatewaySessionRecord {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
payload, err := h.redis.Get(context.Background(), "gateway:session:"+deviceSessionID).Bytes()
if err == nil {
var record gatewaySessionRecord
require.NoError(t, decodeStrictJSONPayload(payload, &record))
return record
}
time.Sleep(25 * time.Millisecond)
}
t.Fatalf("gateway session projection for %s was not published in time", deviceSessionID)
return gatewaySessionRecord{}
}
func (h *gatewayAuthsessionUserHarness) executeGetMyAccount(t *testing.T, deviceSessionID string, requestID string, clientPrivateKey ed25519.PrivateKey) *usermodel.AccountResponse {
t.Helper()
conn := h.dialGateway(t)
client := gatewayv1.NewEdgeGatewayClient(conn)
payload, err := contractsuserv1.EncodeGetMyAccountRequest()
require.NoError(t, err)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
response, err := client.ExecuteCommand(ctx, newExecuteCommandRequest(deviceSessionID, requestID, contractsuserv1.MessageTypeGetMyAccount, payload, clientPrivateKey))
require.NoError(t, err)
require.Equal(t, contractsuserv1.ResultCodeOK, response.GetResultCode())
assertSignedExecuteCommandResponse(t, response, h.responseSignerPublicKey)
accountResponse, err := contractsuserv1.DecodeAccountResponse(response.GetPayloadBytes())
require.NoError(t, err)
return accountResponse
}
func (h *gatewayAuthsessionUserHarness) dialGateway(t *testing.T) *grpc.ClientConn {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
conn, err := grpc.DialContext(
ctx,
h.gatewayGRPCAddr,
grpc.WithTransportCredentials(insecure.NewCredentials()),
grpc.WithBlock(),
)
require.NoError(t, err)
t.Cleanup(func() {
require.NoError(t, conn.Close())
})
return conn
}
type httpResponse struct {
StatusCode int
Body string
Header http.Header
}
type ensureByEmailResponse struct {
Outcome string `json:"outcome"`
UserID string `json:"user_id,omitempty"`
}
type gatewaySessionRecord struct {
DeviceSessionID string `json:"device_session_id"`
UserID string `json:"user_id"`
ClientPublicKey string `json:"client_public_key"`
Status string `json:"status"`
RevokedAtMS *int64 `json:"revoked_at_ms,omitempty"`
}
type userLookupResponse struct {
User usermodel.Account `json:"user"`
}
func postJSONValue(t *testing.T, targetURL string, body any) httpResponse {
t.Helper()
payload, err := json.Marshal(body)
require.NoError(t, err)
request, err := http.NewRequest(http.MethodPost, targetURL, bytes.NewReader(payload))
require.NoError(t, err)
request.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 5 * time.Second}
response, err := client.Do(request)
require.NoError(t, err)
defer response.Body.Close()
responseBody, err := io.ReadAll(response.Body)
require.NoError(t, err)
return httpResponse{
StatusCode: response.StatusCode,
Body: string(responseBody),
Header: response.Header.Clone(),
}
}
func decodeStrictJSONPayload(payload []byte, target any) error {
decoder := json.NewDecoder(bytes.NewReader(payload))
decoder.DisallowUnknownFields()
if err := decoder.Decode(target); err != nil {
return err
}
if err := decoder.Decode(&struct{}{}); err != io.EOF {
if err == nil {
return fmt.Errorf("unexpected trailing JSON input")
}
return err
}
return nil
}
func requireJSONStatus(t *testing.T, response httpResponse, wantStatus int, target any) {
t.Helper()
require.Equal(t, wantStatus, response.StatusCode, "response body: %s", response.Body)
require.NoError(t, decodeStrictJSONPayload([]byte(response.Body), target))
}
func requireLookupNotFound(t *testing.T, response httpResponse) {
t.Helper()
require.Equal(t, http.StatusNotFound, response.StatusCode, "response body: %s", response.Body)
require.JSONEq(t, `{"error":{"code":"subject_not_found","message":"subject not found"}}`, response.Body)
}
func lastMailCodeFor(t *testing.T, stub *harness.MailStub, email string) string {
t.Helper()
deliveries := stub.RecordedDeliveries()
for index := len(deliveries) - 1; index >= 0; index-- {
if deliveries[index].Email == email {
return deliveries[index].Code
}
}
t.Fatalf("mail stub did not record delivery for %s", email)
return ""
}
func waitForAuthsessionPublicReady(t *testing.T, process *harness.Process, baseURL string) {
t.Helper()
client := &http.Client{Timeout: 250 * time.Millisecond}
deadline := time.Now().Add(10 * time.Second)
for time.Now().Before(deadline) {
response, err := postJSONValueMaybe(client, baseURL+"/api/v1/public/auth/send-email-code", map[string]string{
"email": "",
})
if err == nil && response.StatusCode == http.StatusBadRequest {
return
}
time.Sleep(25 * time.Millisecond)
}
t.Fatalf("wait for authsession public readiness: timeout\n%s", process.Logs())
}
func postJSONValueMaybe(client *http.Client, targetURL string, body any) (httpResponse, error) {
payload, err := json.Marshal(body)
if err != nil {
return httpResponse{}, err
}
request, err := http.NewRequest(http.MethodPost, targetURL, bytes.NewReader(payload))
if err != nil {
return httpResponse{}, err
}
request.Header.Set("Content-Type", "application/json")
response, err := client.Do(request)
if err != nil {
return httpResponse{}, err
}
defer response.Body.Close()
responseBody, err := io.ReadAll(response.Body)
if err != nil {
return httpResponse{}, err
}
return httpResponse{
StatusCode: response.StatusCode,
Body: string(responseBody),
Header: response.Header.Clone(),
}, nil
}
func newClientPrivateKey(label string) ed25519.PrivateKey {
seed := sha256.Sum256([]byte("galaxy-integration-gateway-authsession-user-client-" + label))
return ed25519.NewKeyFromSeed(seed[:])
}
func newExecuteCommandRequest(deviceSessionID string, requestID string, messageType string, payload []byte, clientPrivateKey ed25519.PrivateKey) *gatewayv1.ExecuteCommandRequest {
payloadHash := contractsgatewayv1.ComputePayloadHash(payload)
request := &gatewayv1.ExecuteCommandRequest{
ProtocolVersion: contractsgatewayv1.ProtocolVersionV1,
DeviceSessionId: deviceSessionID,
MessageType: messageType,
TimestampMs: time.Now().UnixMilli(),
RequestId: requestID,
PayloadBytes: payload,
PayloadHash: payloadHash,
TraceId: "trace-" + requestID,
}
request.Signature = contractsgatewayv1.SignRequest(clientPrivateKey, contractsgatewayv1.RequestSigningFields{
ProtocolVersion: request.GetProtocolVersion(),
DeviceSessionID: request.GetDeviceSessionId(),
MessageType: request.GetMessageType(),
TimestampMS: request.GetTimestampMs(),
RequestID: request.GetRequestId(),
PayloadHash: request.GetPayloadHash(),
})
return request
}
func assertSignedExecuteCommandResponse(t *testing.T, response *gatewayv1.ExecuteCommandResponse, publicKey ed25519.PublicKey) {
t.Helper()
require.NoError(t, contractsgatewayv1.VerifyPayloadHash(response.GetPayloadBytes(), response.GetPayloadHash()))
require.NoError(t, contractsgatewayv1.VerifyResponseSignature(publicKey, response.GetSignature(), contractsgatewayv1.ResponseSigningFields{
ProtocolVersion: response.GetProtocolVersion(),
RequestID: response.GetRequestId(),
TimestampMS: response.GetTimestampMs(),
ResultCode: response.GetResultCode(),
PayloadHash: response.GetPayloadHash(),
}))
}
@@ -0,0 +1,147 @@
package gatewayuser_test
import (
"testing"
contractsuserv1 "galaxy/integration/internal/contracts/userv1"
"github.com/stretchr/testify/require"
)
func TestGatewayUserGetMyAccountAuthenticated(t *testing.T) {
h := newGatewayUserHarness(t)
const (
email = "pilot@example.com"
deviceSessionID = "device-session-get-account"
requestID = "request-get-account"
)
created := h.ensureUser(t, email, "en", gatewayUserTestTimeZone)
require.Equal(t, "created", created.Outcome)
clientPrivateKey := newClientPrivateKey("get-account")
h.seedGatewaySession(t, deviceSessionID, created.UserID, clientPrivateKey)
payload, err := contractsuserv1.EncodeGetMyAccountRequest()
require.NoError(t, err)
response := h.executeCommand(t, deviceSessionID, requestID, contractsuserv1.MessageTypeGetMyAccount, payload, clientPrivateKey)
require.Equal(t, contractsuserv1.ResultCodeOK, response.GetResultCode())
accountResponse, err := contractsuserv1.DecodeAccountResponse(response.GetPayloadBytes())
require.NoError(t, err)
require.Equal(t, created.UserID, accountResponse.Account.UserID)
require.Equal(t, email, accountResponse.Account.Email)
require.Equal(t, "en", accountResponse.Account.PreferredLanguage)
require.Equal(t, gatewayUserTestTimeZone, accountResponse.Account.TimeZone)
}
func TestGatewayUserUpdateMyProfileSuccess(t *testing.T) {
h := newGatewayUserHarness(t)
const (
email = "pilot-profile@example.com"
deviceSessionID = "device-session-update-profile"
requestID = "request-update-profile"
)
created := h.ensureUser(t, email, "en", gatewayUserTestTimeZone)
clientPrivateKey := newClientPrivateKey("update-profile")
h.seedGatewaySession(t, deviceSessionID, created.UserID, clientPrivateKey)
payload, err := contractsuserv1.EncodeUpdateMyProfileRequest("Nova Prime")
require.NoError(t, err)
response := h.executeCommand(t, deviceSessionID, requestID, contractsuserv1.MessageTypeUpdateMyProfile, payload, clientPrivateKey)
require.Equal(t, contractsuserv1.ResultCodeOK, response.GetResultCode())
accountResponse, err := contractsuserv1.DecodeAccountResponse(response.GetPayloadBytes())
require.NoError(t, err)
require.Equal(t, "Nova Prime", accountResponse.Account.RaceName)
lookup := h.lookupUserByEmail(t, email)
require.Equal(t, "Nova Prime", lookup.User.RaceName)
}
func TestGatewayUserUpdateMySettingsSuccess(t *testing.T) {
h := newGatewayUserHarness(t)
const (
email = "pilot-settings@example.com"
deviceSessionID = "device-session-update-settings"
requestID = "request-update-settings"
)
created := h.ensureUser(t, email, "en", gatewayUserTestTimeZone)
clientPrivateKey := newClientPrivateKey("update-settings")
h.seedGatewaySession(t, deviceSessionID, created.UserID, clientPrivateKey)
payload, err := contractsuserv1.EncodeUpdateMySettingsRequest("fr-FR", "Europe/Paris")
require.NoError(t, err)
response := h.executeCommand(t, deviceSessionID, requestID, contractsuserv1.MessageTypeUpdateMySettings, payload, clientPrivateKey)
require.Equal(t, contractsuserv1.ResultCodeOK, response.GetResultCode())
accountResponse, err := contractsuserv1.DecodeAccountResponse(response.GetPayloadBytes())
require.NoError(t, err)
require.Equal(t, "fr-FR", accountResponse.Account.PreferredLanguage)
require.Equal(t, "Europe/Paris", accountResponse.Account.TimeZone)
lookup := h.lookupUserByEmail(t, email)
require.Equal(t, "fr-FR", lookup.User.PreferredLanguage)
require.Equal(t, "Europe/Paris", lookup.User.TimeZone)
}
func TestGatewayUserUpdateMyProfileConflict(t *testing.T) {
h := newGatewayUserHarness(t)
const (
email = "pilot-conflict@example.com"
deviceSessionID = "device-session-profile-conflict"
requestID = "request-profile-conflict"
)
created := h.ensureUser(t, email, "en", gatewayUserTestTimeZone)
h.applyProfileUpdateBlock(t, created.UserID)
clientPrivateKey := newClientPrivateKey("profile-conflict")
h.seedGatewaySession(t, deviceSessionID, created.UserID, clientPrivateKey)
payload, err := contractsuserv1.EncodeUpdateMyProfileRequest("Blocked Nova")
require.NoError(t, err)
response := h.executeCommand(t, deviceSessionID, requestID, contractsuserv1.MessageTypeUpdateMyProfile, payload, clientPrivateKey)
require.Equal(t, "conflict", response.GetResultCode())
errorResponse, err := contractsuserv1.DecodeErrorResponse(response.GetPayloadBytes())
require.NoError(t, err)
require.Equal(t, "conflict", errorResponse.Error.Code)
require.Equal(t, "request conflicts with current state", errorResponse.Error.Message)
}
func TestGatewayUserUpdateMySettingsInvalidRequest(t *testing.T) {
h := newGatewayUserHarness(t)
const (
email = "pilot-invalid@example.com"
deviceSessionID = "device-session-settings-invalid"
requestID = "request-settings-invalid"
)
created := h.ensureUser(t, email, "en", gatewayUserTestTimeZone)
clientPrivateKey := newClientPrivateKey("settings-invalid")
h.seedGatewaySession(t, deviceSessionID, created.UserID, clientPrivateKey)
payload, err := contractsuserv1.EncodeUpdateMySettingsRequest("en", "Mars/Base")
require.NoError(t, err)
response := h.executeCommand(t, deviceSessionID, requestID, contractsuserv1.MessageTypeUpdateMySettings, payload, clientPrivateKey)
require.Equal(t, "invalid_request", response.GetResultCode())
errorResponse, err := contractsuserv1.DecodeErrorResponse(response.GetPayloadBytes())
require.NoError(t, err)
require.Equal(t, "invalid_request", errorResponse.Error.Code)
require.NotEmpty(t, errorResponse.Error.Message)
}
+311
View File
@@ -0,0 +1,311 @@
package gatewayuser_test
import (
"bytes"
"context"
"crypto/ed25519"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"path/filepath"
"testing"
"time"
gatewayv1 "galaxy/gateway/proto/galaxy/gateway/v1"
contractsgatewayv1 "galaxy/integration/internal/contracts/gatewayv1"
"galaxy/integration/internal/harness"
usermodel "galaxy/model/user"
"github.com/redis/go-redis/v9"
"github.com/stretchr/testify/require"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
)
const (
gatewayUserDefaultHTTPTimeout = time.Second
gatewayUserTestTimeZone = "Europe/Kaliningrad"
)
type gatewayUserHarness struct {
redis *redis.Client
userServiceURL string
gatewayGRPCAddr string
responseSignerPublicKey ed25519.PublicKey
gatewayProcess *harness.Process
userServiceProcess *harness.Process
}
func newGatewayUserHarness(t *testing.T) *gatewayUserHarness {
t.Helper()
redisServer := harness.StartMiniredis(t)
redisClient := redis.NewClient(&redis.Options{
Addr: redisServer.Addr(),
Protocol: 2,
DisableIdentity: true,
})
t.Cleanup(func() {
require.NoError(t, redisClient.Close())
})
responseSignerPath, responseSignerPublicKey := harness.WriteResponseSignerPEM(t, t.Name())
userServiceAddr := harness.FreeTCPAddress(t)
gatewayPublicAddr := harness.FreeTCPAddress(t)
gatewayGRPCAddr := harness.FreeTCPAddress(t)
userServiceBinary := harness.BuildBinary(t, "userservice", "./user/cmd/userservice")
gatewayBinary := harness.BuildBinary(t, "gateway", "./gateway/cmd/gateway")
userServiceEnv := map[string]string{
"USERSERVICE_LOG_LEVEL": "info",
"USERSERVICE_INTERNAL_HTTP_ADDR": userServiceAddr,
"USERSERVICE_REDIS_ADDR": redisServer.Addr(),
"OTEL_TRACES_EXPORTER": "none",
"OTEL_METRICS_EXPORTER": "none",
}
userServiceProcess := harness.StartProcess(t, "userservice", userServiceBinary, userServiceEnv)
harness.WaitForHTTPStatus(t, userServiceProcess, "http://"+userServiceAddr+"/api/v1/internal/users/user-missing/exists", http.StatusOK)
gatewayEnv := map[string]string{
"GATEWAY_LOG_LEVEL": "info",
"GATEWAY_PUBLIC_HTTP_ADDR": gatewayPublicAddr,
"GATEWAY_AUTHENTICATED_GRPC_ADDR": gatewayGRPCAddr,
"GATEWAY_USER_SERVICE_BASE_URL": "http://" + userServiceAddr,
"GATEWAY_SESSION_CACHE_REDIS_ADDR": redisServer.Addr(),
"GATEWAY_SESSION_CACHE_REDIS_KEY_PREFIX": "gateway:session:",
"GATEWAY_SESSION_EVENTS_REDIS_STREAM": "gateway:session_events",
"GATEWAY_CLIENT_EVENTS_REDIS_STREAM": "gateway:client_events",
"GATEWAY_REPLAY_REDIS_KEY_PREFIX": "gateway:replay:",
"GATEWAY_RESPONSE_SIGNER_PRIVATE_KEY_PEM_PATH": filepath.Clean(responseSignerPath),
"OTEL_TRACES_EXPORTER": "none",
"OTEL_METRICS_EXPORTER": "none",
}
gatewayProcess := harness.StartProcess(t, "gateway", gatewayBinary, gatewayEnv)
harness.WaitForHTTPStatus(t, gatewayProcess, "http://"+gatewayPublicAddr+"/healthz", http.StatusOK)
harness.WaitForTCP(t, gatewayProcess, gatewayGRPCAddr)
return &gatewayUserHarness{
redis: redisClient,
userServiceURL: "http://" + userServiceAddr,
gatewayGRPCAddr: gatewayGRPCAddr,
responseSignerPublicKey: responseSignerPublicKey,
gatewayProcess: gatewayProcess,
userServiceProcess: userServiceProcess,
}
}
func (h *gatewayUserHarness) dialGateway(t *testing.T) *grpc.ClientConn {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
conn, err := grpc.DialContext(
ctx,
h.gatewayGRPCAddr,
grpc.WithTransportCredentials(insecure.NewCredentials()),
grpc.WithBlock(),
)
require.NoError(t, err)
t.Cleanup(func() {
require.NoError(t, conn.Close())
})
return conn
}
func (h *gatewayUserHarness) ensureUser(t *testing.T, email string, preferredLanguage string, timeZone string) ensureByEmailResponse {
t.Helper()
response := postJSONValue(t, h.userServiceURL+"/api/v1/internal/users/ensure-by-email", map[string]any{
"email": email,
"registration_context": map[string]string{
"preferred_language": preferredLanguage,
"time_zone": timeZone,
},
})
var body ensureByEmailResponse
requireJSONStatus(t, response, http.StatusOK, &body)
return body
}
func (h *gatewayUserHarness) lookupUserByEmail(t *testing.T, email string) userLookupResponse {
t.Helper()
response := postJSONValue(t, h.userServiceURL+"/api/v1/internal/user-lookups/by-email", map[string]string{
"email": email,
})
var body userLookupResponse
requireJSONStatus(t, response, http.StatusOK, &body)
return body
}
func (h *gatewayUserHarness) applyProfileUpdateBlock(t *testing.T, userID string) {
t.Helper()
response := postJSONValue(t, h.userServiceURL+"/api/v1/internal/users/"+userID+"/sanctions/apply", map[string]any{
"sanction_code": "profile_update_block",
"scope": "lobby",
"reason_code": "manual_block",
"actor": map[string]string{
"type": "admin",
"id": "admin-1",
},
"applied_at": "2026-04-09T10:00:00Z",
})
require.Equal(t, http.StatusOK, response.StatusCode, "response body: %s", response.Body)
}
func (h *gatewayUserHarness) seedGatewaySession(t *testing.T, deviceSessionID string, userID string, clientPrivateKey ed25519.PrivateKey) {
t.Helper()
record := gatewaySessionRecord{
DeviceSessionID: deviceSessionID,
UserID: userID,
ClientPublicKey: base64.StdEncoding.EncodeToString(clientPrivateKey.Public().(ed25519.PublicKey)),
Status: "active",
}
payload, err := json.Marshal(record)
require.NoError(t, err)
require.NoError(t, h.redis.Set(context.Background(), "gateway:session:"+deviceSessionID, payload, 0).Err())
}
func (h *gatewayUserHarness) executeCommand(t *testing.T, deviceSessionID string, requestID string, messageType string, payload []byte, clientPrivateKey ed25519.PrivateKey) *gatewayv1.ExecuteCommandResponse {
t.Helper()
conn := h.dialGateway(t)
client := gatewayv1.NewEdgeGatewayClient(conn)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
response, err := client.ExecuteCommand(ctx, newExecuteCommandRequest(deviceSessionID, requestID, messageType, payload, clientPrivateKey))
require.NoError(t, err)
assertSignedExecuteCommandResponse(t, response, h.responseSignerPublicKey)
return response
}
type httpResponse struct {
StatusCode int
Body string
Header http.Header
}
type gatewaySessionRecord struct {
DeviceSessionID string `json:"device_session_id"`
UserID string `json:"user_id"`
ClientPublicKey string `json:"client_public_key"`
Status string `json:"status"`
RevokedAtMS *int64 `json:"revoked_at_ms,omitempty"`
}
type ensureByEmailResponse struct {
Outcome string `json:"outcome"`
UserID string `json:"user_id,omitempty"`
}
type userLookupResponse struct {
User usermodel.Account `json:"user"`
}
func postJSONValue(t *testing.T, targetURL string, body any) httpResponse {
t.Helper()
payload, err := json.Marshal(body)
require.NoError(t, err)
request, err := http.NewRequest(http.MethodPost, targetURL, bytes.NewReader(payload))
require.NoError(t, err)
request.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: gatewayUserDefaultHTTPTimeout}
response, err := client.Do(request)
require.NoError(t, err)
defer response.Body.Close()
responseBody, err := io.ReadAll(response.Body)
require.NoError(t, err)
return httpResponse{
StatusCode: response.StatusCode,
Body: string(responseBody),
Header: response.Header.Clone(),
}
}
func requireJSONStatus(t *testing.T, response httpResponse, wantStatus int, target any) {
t.Helper()
require.Equal(t, wantStatus, response.StatusCode, "response body: %s", response.Body)
require.NoError(t, decodeStrictJSONPayload([]byte(response.Body), target))
}
func decodeStrictJSONPayload(payload []byte, target any) error {
decoder := json.NewDecoder(bytes.NewReader(payload))
decoder.DisallowUnknownFields()
if err := decoder.Decode(target); err != nil {
return err
}
if err := decoder.Decode(&struct{}{}); err != io.EOF {
if err == nil {
return fmt.Errorf("unexpected trailing JSON input")
}
return err
}
return nil
}
func newClientPrivateKey(label string) ed25519.PrivateKey {
seed := sha256.Sum256([]byte("galaxy-integration-gateway-user-client-" + label))
return ed25519.NewKeyFromSeed(seed[:])
}
func newExecuteCommandRequest(deviceSessionID string, requestID string, messageType string, payload []byte, clientPrivateKey ed25519.PrivateKey) *gatewayv1.ExecuteCommandRequest {
payloadHash := contractsgatewayv1.ComputePayloadHash(payload)
request := &gatewayv1.ExecuteCommandRequest{
ProtocolVersion: contractsgatewayv1.ProtocolVersionV1,
DeviceSessionId: deviceSessionID,
MessageType: messageType,
TimestampMs: time.Now().UnixMilli(),
RequestId: requestID,
PayloadBytes: payload,
PayloadHash: payloadHash,
TraceId: "trace-" + requestID,
}
request.Signature = contractsgatewayv1.SignRequest(clientPrivateKey, contractsgatewayv1.RequestSigningFields{
ProtocolVersion: request.GetProtocolVersion(),
DeviceSessionID: request.GetDeviceSessionId(),
MessageType: request.GetMessageType(),
TimestampMS: request.GetTimestampMs(),
RequestID: request.GetRequestId(),
PayloadHash: request.GetPayloadHash(),
})
return request
}
func assertSignedExecuteCommandResponse(t *testing.T, response *gatewayv1.ExecuteCommandResponse, publicKey ed25519.PublicKey) {
t.Helper()
require.NoError(t, contractsgatewayv1.VerifyPayloadHash(response.GetPayloadBytes(), response.GetPayloadHash()))
require.NoError(t, contractsgatewayv1.VerifyResponseSignature(publicKey, response.GetSignature(), contractsgatewayv1.ResponseSigningFields{
ProtocolVersion: response.GetProtocolVersion(),
RequestID: response.GetRequestId(),
TimestampMS: response.GetTimestampMs(),
ResultCode: response.GetResultCode(),
PayloadHash: response.GetPayloadHash(),
}))
}
+2 -2
View File
@@ -18,8 +18,8 @@ require (
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/yuin/gopher-lua v1.1.1 // indirect github.com/yuin/gopher-lua v1.1.1 // indirect
go.opentelemetry.io/otel v1.42.0 // indirect go.opentelemetry.io/otel v1.43.0 // indirect
go.opentelemetry.io/otel/sdk/metric v1.42.0 // indirect go.opentelemetry.io/otel/sdk/metric v1.43.0 // indirect
go.uber.org/atomic v1.11.0 // indirect go.uber.org/atomic v1.11.0 // indirect
golang.org/x/net v0.52.0 // indirect golang.org/x/net v0.52.0 // indirect
golang.org/x/sys v0.42.0 // indirect golang.org/x/sys v0.42.0 // indirect
+5 -5
View File
@@ -34,11 +34,11 @@ github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/otel v1.42.0 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho= go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
go.opentelemetry.io/otel/metric v1.42.0 h1:2jXG+3oZLNXEPfNmnpxKDeZsFI5o4J+nz6xUlaFdF/4= go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
go.opentelemetry.io/otel/sdk v1.42.0 h1:LyC8+jqk6UJwdrI/8VydAq/hvkFKNHZVIWuslJXYsDo= go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
go.opentelemetry.io/otel/sdk/metric v1.42.0 h1:D/1QR46Clz6ajyZ3G8SgNlTJKBdGp84q9RKCAZ3YGuA= go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4LenLmOYY= go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
@@ -38,6 +38,11 @@ var (
// ErrInvalidEventSignature reports that one gateway event signature is not // ErrInvalidEventSignature reports that one gateway event signature is not
// a raw Ed25519 signature for the canonical event signing input. // a raw Ed25519 signature for the canonical event signing input.
ErrInvalidEventSignature = errors.New("invalid event signature") ErrInvalidEventSignature = errors.New("invalid event signature")
// ErrInvalidResponseSignature reports that one gateway unary response
// signature is not a raw Ed25519 signature for the canonical response
// signing input.
ErrInvalidResponseSignature = errors.New("invalid response signature")
) )
// RequestSigningFields stores the canonical public request fields bound into // RequestSigningFields stores the canonical public request fields bound into
@@ -85,6 +90,25 @@ type EventSigningFields struct {
PayloadHash []byte PayloadHash []byte
} }
// ResponseSigningFields stores the canonical public unary response fields
// bound into one gateway signature input.
type ResponseSigningFields struct {
// ProtocolVersion identifies the gateway transport envelope version.
ProtocolVersion string
// RequestID is the transport correlation identifier echoed by the gateway.
RequestID string
// TimestampMS carries the gateway response timestamp in milliseconds.
TimestampMS int64
// ResultCode stores the stable opaque gateway result code.
ResultCode string
// PayloadHash stores the raw SHA-256 digest of PayloadBytes.
PayloadHash []byte
}
// ComputePayloadHash returns the canonical raw SHA-256 digest for payloadBytes. // ComputePayloadHash returns the canonical raw SHA-256 digest for payloadBytes.
func ComputePayloadHash(payloadBytes []byte) []byte { func ComputePayloadHash(payloadBytes []byte) []byte {
sum := sha256.Sum256(payloadBytes) sum := sha256.Sum256(payloadBytes)
@@ -154,6 +178,28 @@ func BuildEventSigningInput(fields EventSigningFields) []byte {
return buf return buf
} }
// BuildResponseSigningInput returns the canonical byte sequence the v1
// gateway unary response signature covers.
func BuildResponseSigningInput(fields ResponseSigningFields) []byte {
size := len("galaxy-response-v1") +
len(fields.ProtocolVersion) +
len(fields.RequestID) +
len(fields.ResultCode) +
len(fields.PayloadHash) +
(5 * binary.MaxVarintLen64) +
8
buf := make([]byte, 0, size)
buf = appendLengthPrefixedString(buf, "galaxy-response-v1")
buf = appendLengthPrefixedString(buf, fields.ProtocolVersion)
buf = appendLengthPrefixedString(buf, fields.RequestID)
buf = binary.BigEndian.AppendUint64(buf, uint64(fields.TimestampMS))
buf = appendLengthPrefixedString(buf, fields.ResultCode)
buf = appendLengthPrefixedBytes(buf, fields.PayloadHash)
return buf
}
// SignRequest returns one raw Ed25519 client signature for the canonical v1 // SignRequest returns one raw Ed25519 client signature for the canonical v1
// request signing input. // request signing input.
func SignRequest(privateKey ed25519.PrivateKey, fields RequestSigningFields) []byte { func SignRequest(privateKey ed25519.PrivateKey, fields RequestSigningFields) []byte {
@@ -173,6 +219,19 @@ func VerifyEventSignature(publicKey ed25519.PublicKey, signature []byte, fields
return nil return nil
} }
// VerifyResponseSignature reports whether signature authenticates fields under
// publicKey using the canonical gateway unary-response signing input.
func VerifyResponseSignature(publicKey ed25519.PublicKey, signature []byte, fields ResponseSigningFields) error {
if len(publicKey) != ed25519.PublicKeySize || len(signature) != ed25519.SignatureSize {
return ErrInvalidResponseSignature
}
if !ed25519.Verify(publicKey, BuildResponseSigningInput(fields), signature) {
return ErrInvalidResponseSignature
}
return nil
}
func appendLengthPrefixedString(dst []byte, value string) []byte { func appendLengthPrefixedString(dst []byte, value string) []byte {
return appendLengthPrefixedBytes(dst, []byte(value)) return appendLengthPrefixedBytes(dst, []byte(value))
} }
@@ -0,0 +1,61 @@
// Package userv1contract provides public-contract helpers for the
// authenticated gateway v1 User Service self-service message types.
package userv1contract
import (
usermodel "galaxy/model/user"
"galaxy/transcoder"
)
const (
// MessageTypeGetMyAccount is the authenticated gateway message type used to
// read the current self-service account aggregate.
MessageTypeGetMyAccount = usermodel.MessageTypeGetMyAccount
// MessageTypeUpdateMyProfile is the authenticated gateway message type used
// to mutate self-service profile fields.
MessageTypeUpdateMyProfile = usermodel.MessageTypeUpdateMyProfile
// MessageTypeUpdateMySettings is the authenticated gateway message type used
// to mutate self-service settings fields.
MessageTypeUpdateMySettings = usermodel.MessageTypeUpdateMySettings
// ResultCodeOK is the success result code projected by gateway for all
// successful `user.*` authenticated commands.
ResultCodeOK = "ok"
)
// EncodeGetMyAccountRequest returns the FlatBuffers payload for the public
// empty get-account request.
func EncodeGetMyAccountRequest() ([]byte, error) {
return transcoder.GetMyAccountRequestToPayload(&usermodel.GetMyAccountRequest{})
}
// EncodeUpdateMyProfileRequest returns the FlatBuffers payload for one public
// self-service profile mutation request.
func EncodeUpdateMyProfileRequest(raceName string) ([]byte, error) {
return transcoder.UpdateMyProfileRequestToPayload(&usermodel.UpdateMyProfileRequest{
RaceName: raceName,
})
}
// EncodeUpdateMySettingsRequest returns the FlatBuffers payload for one public
// self-service settings mutation request.
func EncodeUpdateMySettingsRequest(preferredLanguage string, timeZone string) ([]byte, error) {
return transcoder.UpdateMySettingsRequestToPayload(&usermodel.UpdateMySettingsRequest{
PreferredLanguage: preferredLanguage,
TimeZone: timeZone,
})
}
// DecodeAccountResponse decodes the public FlatBuffers success payload shared
// by all authenticated `user.*` commands.
func DecodeAccountResponse(payload []byte) (*usermodel.AccountResponse, error) {
return transcoder.PayloadToAccountResponse(payload)
}
// DecodeErrorResponse decodes the public FlatBuffers error payload shared by
// all authenticated `user.*` commands.
func DecodeErrorResponse(payload []byte) (*usermodel.ErrorResponse, error) {
return transcoder.PayloadToErrorResponse(payload)
}
+188
View File
@@ -0,0 +1,188 @@
// Package user defines the public typed command and response payloads exposed
// at the authenticated Gateway -> User self-service boundary.
package user
import "time"
const (
// MessageTypeGetMyAccount is the authenticated gateway message type used to
// read the current regular-user account aggregate.
MessageTypeGetMyAccount = "user.account.get"
// MessageTypeUpdateMyProfile is the authenticated gateway message type used
// to mutate self-service profile fields.
MessageTypeUpdateMyProfile = "user.profile.update"
// MessageTypeUpdateMySettings is the authenticated gateway message type used
// to mutate self-service settings fields.
MessageTypeUpdateMySettings = "user.settings.update"
)
// GetMyAccountRequest stores the authenticated self-service read request for
// the current regular-user account aggregate.
//
// The request body is intentionally empty because gateway derives user
// identity from the authenticated device session rather than from client
// payload fields.
type GetMyAccountRequest struct{}
// UpdateMyProfileRequest stores the authenticated self-service profile
// mutation request.
type UpdateMyProfileRequest struct {
// RaceName stores the requested exact replacement race name.
RaceName string `json:"race_name"`
}
// UpdateMySettingsRequest stores the authenticated self-service settings
// mutation request.
type UpdateMySettingsRequest struct {
// PreferredLanguage stores the requested BCP 47 language tag.
PreferredLanguage string `json:"preferred_language"`
// TimeZone stores the requested IANA time-zone name.
TimeZone string `json:"time_zone"`
}
// ActorRef stores transport-ready audit actor metadata projected by User
// Service.
type ActorRef struct {
// Type stores the machine-readable actor type.
Type string `json:"type"`
// ID stores the optional stable actor identifier.
ID string `json:"id,omitempty"`
}
// EntitlementSnapshot stores the transport-ready current entitlement snapshot
// of one account.
type EntitlementSnapshot struct {
// PlanCode stores the effective entitlement plan code.
PlanCode string `json:"plan_code"`
// IsPaid reports whether the effective entitlement is currently paid.
IsPaid bool `json:"is_paid"`
// Source stores the machine-readable source that produced the snapshot.
Source string `json:"source"`
// Actor stores the audit actor metadata attached to the current snapshot.
Actor ActorRef `json:"actor"`
// ReasonCode stores the machine-readable reason attached to the snapshot.
ReasonCode string `json:"reason_code"`
// StartsAt stores when the effective state started.
StartsAt time.Time `json:"starts_at"`
// EndsAt stores the optional finite entitlement expiry.
EndsAt *time.Time `json:"ends_at,omitempty"`
// UpdatedAt stores when the snapshot was last recomputed.
UpdatedAt time.Time `json:"updated_at"`
}
// ActiveSanction stores one transport-ready active sanction returned in the
// shared account aggregate.
type ActiveSanction struct {
// SanctionCode stores the active sanction code.
SanctionCode string `json:"sanction_code"`
// Scope stores the machine-readable sanction scope.
Scope string `json:"scope"`
// ReasonCode stores the machine-readable sanction reason.
ReasonCode string `json:"reason_code"`
// Actor stores the audit actor metadata attached to the sanction.
Actor ActorRef `json:"actor"`
// AppliedAt stores when the sanction became active.
AppliedAt time.Time `json:"applied_at"`
// ExpiresAt stores the optional planned sanction expiry.
ExpiresAt *time.Time `json:"expires_at,omitempty"`
}
// ActiveLimit stores one transport-ready active user-specific limit override
// returned in the shared account aggregate.
type ActiveLimit struct {
// LimitCode stores the active limit code.
LimitCode string `json:"limit_code"`
// Value stores the current override value.
Value int `json:"value"`
// ReasonCode stores the machine-readable limit reason.
ReasonCode string `json:"reason_code"`
// Actor stores the audit actor metadata attached to the limit.
Actor ActorRef `json:"actor"`
// AppliedAt stores when the limit became active.
AppliedAt time.Time `json:"applied_at"`
// ExpiresAt stores the optional planned limit expiry.
ExpiresAt *time.Time `json:"expires_at,omitempty"`
}
// Account stores the transport-ready account aggregate shared by User Service
// self-service read and mutation responses.
type Account struct {
// UserID stores the durable regular-user identifier.
UserID string `json:"user_id"`
// Email stores the exact-after-trim login e-mail address.
Email string `json:"email"`
// RaceName stores the current user-facing race name.
RaceName string `json:"race_name"`
// PreferredLanguage stores the current BCP 47 language tag.
PreferredLanguage string `json:"preferred_language"`
// TimeZone stores the current IANA time-zone name.
TimeZone string `json:"time_zone"`
// DeclaredCountry stores the optional current effective declared country.
DeclaredCountry string `json:"declared_country,omitempty"`
// Entitlement stores the current entitlement snapshot.
Entitlement EntitlementSnapshot `json:"entitlement"`
// ActiveSanctions stores the current active sanctions sorted by code.
ActiveSanctions []ActiveSanction `json:"active_sanctions"`
// ActiveLimits stores the current active user-specific limits sorted by
// code.
ActiveLimits []ActiveLimit `json:"active_limits"`
// CreatedAt stores when the account was created.
CreatedAt time.Time `json:"created_at"`
// UpdatedAt stores when the account was last mutated.
UpdatedAt time.Time `json:"updated_at"`
}
// AccountResponse stores the success payload shared by the authenticated
// GetMyAccount, UpdateMyProfile, and UpdateMySettings gateway message types.
type AccountResponse struct {
// Account stores the current account aggregate.
Account Account `json:"account"`
}
// ErrorBody stores the machine-readable and human-readable failure payload
// mirrored from the User Service trusted internal error envelope.
type ErrorBody struct {
// Code stores the stable machine-readable failure code.
Code string `json:"code"`
// Message stores the client-safe failure message.
Message string `json:"message"`
}
// ErrorResponse stores the error payload returned by the authenticated
// Gateway -> User boundary when User Service rejects a request semantically.
type ErrorResponse struct {
// Error stores the mirrored error envelope body.
Error ErrorBody `json:"error"`
}
+78
View File
@@ -0,0 +1,78 @@
// user contains FlatBuffers payloads used by the authenticated gateway
// self-service boundary for User Service.
namespace user;
table GetMyAccountRequest {
}
table UpdateMyProfileRequest {
race_name:string;
}
table UpdateMySettingsRequest {
preferred_language:string;
time_zone:string;
}
table ActorRef {
type:string;
id:string;
}
table EntitlementSnapshot {
plan_code:string;
is_paid:bool;
source:string;
actor:ActorRef;
reason_code:string;
starts_at_ms:int64;
ends_at_ms:int64;
updated_at_ms:int64;
}
table ActiveSanction {
sanction_code:string;
scope:string;
reason_code:string;
actor:ActorRef;
applied_at_ms:int64;
expires_at_ms:int64;
}
table ActiveLimit {
limit_code:string;
value:int64;
reason_code:string;
actor:ActorRef;
applied_at_ms:int64;
expires_at_ms:int64;
}
table AccountView {
user_id:string;
email:string;
race_name:string;
preferred_language:string;
time_zone:string;
declared_country:string;
entitlement:EntitlementSnapshot;
active_sanctions:[ActiveSanction];
active_limits:[ActiveLimit];
created_at_ms:int64;
updated_at_ms:int64;
}
table AccountResponse {
account:AccountView;
}
table ErrorBody {
code:string;
message:string;
}
table ErrorResponse {
error:ErrorBody;
}
root_type AccountResponse;
+65
View File
@@ -0,0 +1,65 @@
// Code generated by the FlatBuffers compiler. DO NOT EDIT.
package user
import (
flatbuffers "github.com/google/flatbuffers/go"
)
type AccountResponse struct {
_tab flatbuffers.Table
}
func GetRootAsAccountResponse(buf []byte, offset flatbuffers.UOffsetT) *AccountResponse {
n := flatbuffers.GetUOffsetT(buf[offset:])
x := &AccountResponse{}
x.Init(buf, n+offset)
return x
}
func FinishAccountResponseBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.Finish(offset)
}
func GetSizePrefixedRootAsAccountResponse(buf []byte, offset flatbuffers.UOffsetT) *AccountResponse {
n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:])
x := &AccountResponse{}
x.Init(buf, n+offset+flatbuffers.SizeUint32)
return x
}
func FinishSizePrefixedAccountResponseBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.FinishSizePrefixed(offset)
}
func (rcv *AccountResponse) Init(buf []byte, i flatbuffers.UOffsetT) {
rcv._tab.Bytes = buf
rcv._tab.Pos = i
}
func (rcv *AccountResponse) Table() flatbuffers.Table {
return rcv._tab
}
func (rcv *AccountResponse) Account(obj *AccountView) *AccountView {
o := flatbuffers.UOffsetT(rcv._tab.Offset(4))
if o != 0 {
x := rcv._tab.Indirect(o + rcv._tab.Pos)
if obj == nil {
obj = new(AccountView)
}
obj.Init(rcv._tab.Bytes, x)
return obj
}
return nil
}
func AccountResponseStart(builder *flatbuffers.Builder) {
builder.StartObject(1)
}
func AccountResponseAddAccount(builder *flatbuffers.Builder, account flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(account), 0)
}
func AccountResponseEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject()
}
+213
View File
@@ -0,0 +1,213 @@
// Code generated by the FlatBuffers compiler. DO NOT EDIT.
package user
import (
flatbuffers "github.com/google/flatbuffers/go"
)
type AccountView struct {
_tab flatbuffers.Table
}
func GetRootAsAccountView(buf []byte, offset flatbuffers.UOffsetT) *AccountView {
n := flatbuffers.GetUOffsetT(buf[offset:])
x := &AccountView{}
x.Init(buf, n+offset)
return x
}
func FinishAccountViewBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.Finish(offset)
}
func GetSizePrefixedRootAsAccountView(buf []byte, offset flatbuffers.UOffsetT) *AccountView {
n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:])
x := &AccountView{}
x.Init(buf, n+offset+flatbuffers.SizeUint32)
return x
}
func FinishSizePrefixedAccountViewBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.FinishSizePrefixed(offset)
}
func (rcv *AccountView) Init(buf []byte, i flatbuffers.UOffsetT) {
rcv._tab.Bytes = buf
rcv._tab.Pos = i
}
func (rcv *AccountView) Table() flatbuffers.Table {
return rcv._tab
}
func (rcv *AccountView) UserId() []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(4))
if o != 0 {
return rcv._tab.ByteVector(o + rcv._tab.Pos)
}
return nil
}
func (rcv *AccountView) Email() []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(6))
if o != 0 {
return rcv._tab.ByteVector(o + rcv._tab.Pos)
}
return nil
}
func (rcv *AccountView) RaceName() []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(8))
if o != 0 {
return rcv._tab.ByteVector(o + rcv._tab.Pos)
}
return nil
}
func (rcv *AccountView) PreferredLanguage() []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(10))
if o != 0 {
return rcv._tab.ByteVector(o + rcv._tab.Pos)
}
return nil
}
func (rcv *AccountView) TimeZone() []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(12))
if o != 0 {
return rcv._tab.ByteVector(o + rcv._tab.Pos)
}
return nil
}
func (rcv *AccountView) DeclaredCountry() []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(14))
if o != 0 {
return rcv._tab.ByteVector(o + rcv._tab.Pos)
}
return nil
}
func (rcv *AccountView) Entitlement(obj *EntitlementSnapshot) *EntitlementSnapshot {
o := flatbuffers.UOffsetT(rcv._tab.Offset(16))
if o != 0 {
x := rcv._tab.Indirect(o + rcv._tab.Pos)
if obj == nil {
obj = new(EntitlementSnapshot)
}
obj.Init(rcv._tab.Bytes, x)
return obj
}
return nil
}
func (rcv *AccountView) ActiveSanctions(obj *ActiveSanction, j int) bool {
o := flatbuffers.UOffsetT(rcv._tab.Offset(18))
if o != 0 {
x := rcv._tab.Vector(o)
x += flatbuffers.UOffsetT(j) * 4
x = rcv._tab.Indirect(x)
obj.Init(rcv._tab.Bytes, x)
return true
}
return false
}
func (rcv *AccountView) ActiveSanctionsLength() int {
o := flatbuffers.UOffsetT(rcv._tab.Offset(18))
if o != 0 {
return rcv._tab.VectorLen(o)
}
return 0
}
func (rcv *AccountView) ActiveLimits(obj *ActiveLimit, j int) bool {
o := flatbuffers.UOffsetT(rcv._tab.Offset(20))
if o != 0 {
x := rcv._tab.Vector(o)
x += flatbuffers.UOffsetT(j) * 4
x = rcv._tab.Indirect(x)
obj.Init(rcv._tab.Bytes, x)
return true
}
return false
}
func (rcv *AccountView) ActiveLimitsLength() int {
o := flatbuffers.UOffsetT(rcv._tab.Offset(20))
if o != 0 {
return rcv._tab.VectorLen(o)
}
return 0
}
func (rcv *AccountView) CreatedAtMs() int64 {
o := flatbuffers.UOffsetT(rcv._tab.Offset(22))
if o != 0 {
return rcv._tab.GetInt64(o + rcv._tab.Pos)
}
return 0
}
func (rcv *AccountView) MutateCreatedAtMs(n int64) bool {
return rcv._tab.MutateInt64Slot(22, n)
}
func (rcv *AccountView) UpdatedAtMs() int64 {
o := flatbuffers.UOffsetT(rcv._tab.Offset(24))
if o != 0 {
return rcv._tab.GetInt64(o + rcv._tab.Pos)
}
return 0
}
func (rcv *AccountView) MutateUpdatedAtMs(n int64) bool {
return rcv._tab.MutateInt64Slot(24, n)
}
func AccountViewStart(builder *flatbuffers.Builder) {
builder.StartObject(11)
}
func AccountViewAddUserId(builder *flatbuffers.Builder, userId flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(userId), 0)
}
func AccountViewAddEmail(builder *flatbuffers.Builder, email flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(email), 0)
}
func AccountViewAddRaceName(builder *flatbuffers.Builder, raceName flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(2, flatbuffers.UOffsetT(raceName), 0)
}
func AccountViewAddPreferredLanguage(builder *flatbuffers.Builder, preferredLanguage flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(3, flatbuffers.UOffsetT(preferredLanguage), 0)
}
func AccountViewAddTimeZone(builder *flatbuffers.Builder, timeZone flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(4, flatbuffers.UOffsetT(timeZone), 0)
}
func AccountViewAddDeclaredCountry(builder *flatbuffers.Builder, declaredCountry flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(5, flatbuffers.UOffsetT(declaredCountry), 0)
}
func AccountViewAddEntitlement(builder *flatbuffers.Builder, entitlement flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(6, flatbuffers.UOffsetT(entitlement), 0)
}
func AccountViewAddActiveSanctions(builder *flatbuffers.Builder, activeSanctions flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(7, flatbuffers.UOffsetT(activeSanctions), 0)
}
func AccountViewStartActiveSanctionsVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT {
return builder.StartVector(4, numElems, 4)
}
func AccountViewAddActiveLimits(builder *flatbuffers.Builder, activeLimits flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(8, flatbuffers.UOffsetT(activeLimits), 0)
}
func AccountViewStartActiveLimitsVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT {
return builder.StartVector(4, numElems, 4)
}
func AccountViewAddCreatedAtMs(builder *flatbuffers.Builder, createdAtMs int64) {
builder.PrependInt64Slot(9, createdAtMs, 0)
}
func AccountViewAddUpdatedAtMs(builder *flatbuffers.Builder, updatedAtMs int64) {
builder.PrependInt64Slot(10, updatedAtMs, 0)
}
func AccountViewEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject()
}
+132
View File
@@ -0,0 +1,132 @@
// Code generated by the FlatBuffers compiler. DO NOT EDIT.
package user
import (
flatbuffers "github.com/google/flatbuffers/go"
)
type ActiveLimit struct {
_tab flatbuffers.Table
}
func GetRootAsActiveLimit(buf []byte, offset flatbuffers.UOffsetT) *ActiveLimit {
n := flatbuffers.GetUOffsetT(buf[offset:])
x := &ActiveLimit{}
x.Init(buf, n+offset)
return x
}
func FinishActiveLimitBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.Finish(offset)
}
func GetSizePrefixedRootAsActiveLimit(buf []byte, offset flatbuffers.UOffsetT) *ActiveLimit {
n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:])
x := &ActiveLimit{}
x.Init(buf, n+offset+flatbuffers.SizeUint32)
return x
}
func FinishSizePrefixedActiveLimitBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.FinishSizePrefixed(offset)
}
func (rcv *ActiveLimit) Init(buf []byte, i flatbuffers.UOffsetT) {
rcv._tab.Bytes = buf
rcv._tab.Pos = i
}
func (rcv *ActiveLimit) Table() flatbuffers.Table {
return rcv._tab
}
func (rcv *ActiveLimit) LimitCode() []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(4))
if o != 0 {
return rcv._tab.ByteVector(o + rcv._tab.Pos)
}
return nil
}
func (rcv *ActiveLimit) Value() int64 {
o := flatbuffers.UOffsetT(rcv._tab.Offset(6))
if o != 0 {
return rcv._tab.GetInt64(o + rcv._tab.Pos)
}
return 0
}
func (rcv *ActiveLimit) MutateValue(n int64) bool {
return rcv._tab.MutateInt64Slot(6, n)
}
func (rcv *ActiveLimit) ReasonCode() []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(8))
if o != 0 {
return rcv._tab.ByteVector(o + rcv._tab.Pos)
}
return nil
}
func (rcv *ActiveLimit) Actor(obj *ActorRef) *ActorRef {
o := flatbuffers.UOffsetT(rcv._tab.Offset(10))
if o != 0 {
x := rcv._tab.Indirect(o + rcv._tab.Pos)
if obj == nil {
obj = new(ActorRef)
}
obj.Init(rcv._tab.Bytes, x)
return obj
}
return nil
}
func (rcv *ActiveLimit) AppliedAtMs() int64 {
o := flatbuffers.UOffsetT(rcv._tab.Offset(12))
if o != 0 {
return rcv._tab.GetInt64(o + rcv._tab.Pos)
}
return 0
}
func (rcv *ActiveLimit) MutateAppliedAtMs(n int64) bool {
return rcv._tab.MutateInt64Slot(12, n)
}
func (rcv *ActiveLimit) ExpiresAtMs() int64 {
o := flatbuffers.UOffsetT(rcv._tab.Offset(14))
if o != 0 {
return rcv._tab.GetInt64(o + rcv._tab.Pos)
}
return 0
}
func (rcv *ActiveLimit) MutateExpiresAtMs(n int64) bool {
return rcv._tab.MutateInt64Slot(14, n)
}
func ActiveLimitStart(builder *flatbuffers.Builder) {
builder.StartObject(6)
}
func ActiveLimitAddLimitCode(builder *flatbuffers.Builder, limitCode flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(limitCode), 0)
}
func ActiveLimitAddValue(builder *flatbuffers.Builder, value int64) {
builder.PrependInt64Slot(1, value, 0)
}
func ActiveLimitAddReasonCode(builder *flatbuffers.Builder, reasonCode flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(2, flatbuffers.UOffsetT(reasonCode), 0)
}
func ActiveLimitAddActor(builder *flatbuffers.Builder, actor flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(3, flatbuffers.UOffsetT(actor), 0)
}
func ActiveLimitAddAppliedAtMs(builder *flatbuffers.Builder, appliedAtMs int64) {
builder.PrependInt64Slot(4, appliedAtMs, 0)
}
func ActiveLimitAddExpiresAtMs(builder *flatbuffers.Builder, expiresAtMs int64) {
builder.PrependInt64Slot(5, expiresAtMs, 0)
}
func ActiveLimitEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject()
}
+128
View File
@@ -0,0 +1,128 @@
// Code generated by the FlatBuffers compiler. DO NOT EDIT.
package user
import (
flatbuffers "github.com/google/flatbuffers/go"
)
type ActiveSanction struct {
_tab flatbuffers.Table
}
func GetRootAsActiveSanction(buf []byte, offset flatbuffers.UOffsetT) *ActiveSanction {
n := flatbuffers.GetUOffsetT(buf[offset:])
x := &ActiveSanction{}
x.Init(buf, n+offset)
return x
}
func FinishActiveSanctionBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.Finish(offset)
}
func GetSizePrefixedRootAsActiveSanction(buf []byte, offset flatbuffers.UOffsetT) *ActiveSanction {
n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:])
x := &ActiveSanction{}
x.Init(buf, n+offset+flatbuffers.SizeUint32)
return x
}
func FinishSizePrefixedActiveSanctionBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.FinishSizePrefixed(offset)
}
func (rcv *ActiveSanction) Init(buf []byte, i flatbuffers.UOffsetT) {
rcv._tab.Bytes = buf
rcv._tab.Pos = i
}
func (rcv *ActiveSanction) Table() flatbuffers.Table {
return rcv._tab
}
func (rcv *ActiveSanction) SanctionCode() []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(4))
if o != 0 {
return rcv._tab.ByteVector(o + rcv._tab.Pos)
}
return nil
}
func (rcv *ActiveSanction) Scope() []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(6))
if o != 0 {
return rcv._tab.ByteVector(o + rcv._tab.Pos)
}
return nil
}
func (rcv *ActiveSanction) ReasonCode() []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(8))
if o != 0 {
return rcv._tab.ByteVector(o + rcv._tab.Pos)
}
return nil
}
func (rcv *ActiveSanction) Actor(obj *ActorRef) *ActorRef {
o := flatbuffers.UOffsetT(rcv._tab.Offset(10))
if o != 0 {
x := rcv._tab.Indirect(o + rcv._tab.Pos)
if obj == nil {
obj = new(ActorRef)
}
obj.Init(rcv._tab.Bytes, x)
return obj
}
return nil
}
func (rcv *ActiveSanction) AppliedAtMs() int64 {
o := flatbuffers.UOffsetT(rcv._tab.Offset(12))
if o != 0 {
return rcv._tab.GetInt64(o + rcv._tab.Pos)
}
return 0
}
func (rcv *ActiveSanction) MutateAppliedAtMs(n int64) bool {
return rcv._tab.MutateInt64Slot(12, n)
}
func (rcv *ActiveSanction) ExpiresAtMs() int64 {
o := flatbuffers.UOffsetT(rcv._tab.Offset(14))
if o != 0 {
return rcv._tab.GetInt64(o + rcv._tab.Pos)
}
return 0
}
func (rcv *ActiveSanction) MutateExpiresAtMs(n int64) bool {
return rcv._tab.MutateInt64Slot(14, n)
}
func ActiveSanctionStart(builder *flatbuffers.Builder) {
builder.StartObject(6)
}
func ActiveSanctionAddSanctionCode(builder *flatbuffers.Builder, sanctionCode flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(sanctionCode), 0)
}
func ActiveSanctionAddScope(builder *flatbuffers.Builder, scope flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(scope), 0)
}
func ActiveSanctionAddReasonCode(builder *flatbuffers.Builder, reasonCode flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(2, flatbuffers.UOffsetT(reasonCode), 0)
}
func ActiveSanctionAddActor(builder *flatbuffers.Builder, actor flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(3, flatbuffers.UOffsetT(actor), 0)
}
func ActiveSanctionAddAppliedAtMs(builder *flatbuffers.Builder, appliedAtMs int64) {
builder.PrependInt64Slot(4, appliedAtMs, 0)
}
func ActiveSanctionAddExpiresAtMs(builder *flatbuffers.Builder, expiresAtMs int64) {
builder.PrependInt64Slot(5, expiresAtMs, 0)
}
func ActiveSanctionEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject()
}
+71
View File
@@ -0,0 +1,71 @@
// Code generated by the FlatBuffers compiler. DO NOT EDIT.
package user
import (
flatbuffers "github.com/google/flatbuffers/go"
)
type ActorRef struct {
_tab flatbuffers.Table
}
func GetRootAsActorRef(buf []byte, offset flatbuffers.UOffsetT) *ActorRef {
n := flatbuffers.GetUOffsetT(buf[offset:])
x := &ActorRef{}
x.Init(buf, n+offset)
return x
}
func FinishActorRefBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.Finish(offset)
}
func GetSizePrefixedRootAsActorRef(buf []byte, offset flatbuffers.UOffsetT) *ActorRef {
n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:])
x := &ActorRef{}
x.Init(buf, n+offset+flatbuffers.SizeUint32)
return x
}
func FinishSizePrefixedActorRefBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.FinishSizePrefixed(offset)
}
func (rcv *ActorRef) Init(buf []byte, i flatbuffers.UOffsetT) {
rcv._tab.Bytes = buf
rcv._tab.Pos = i
}
func (rcv *ActorRef) Table() flatbuffers.Table {
return rcv._tab
}
func (rcv *ActorRef) Type() []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(4))
if o != 0 {
return rcv._tab.ByteVector(o + rcv._tab.Pos)
}
return nil
}
func (rcv *ActorRef) Id() []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(6))
if o != 0 {
return rcv._tab.ByteVector(o + rcv._tab.Pos)
}
return nil
}
func ActorRefStart(builder *flatbuffers.Builder) {
builder.StartObject(2)
}
func ActorRefAddType(builder *flatbuffers.Builder, type_ flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(type_), 0)
}
func ActorRefAddId(builder *flatbuffers.Builder, id flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(id), 0)
}
func ActorRefEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject()
}
+158
View File
@@ -0,0 +1,158 @@
// Code generated by the FlatBuffers compiler. DO NOT EDIT.
package user
import (
flatbuffers "github.com/google/flatbuffers/go"
)
type EntitlementSnapshot struct {
_tab flatbuffers.Table
}
func GetRootAsEntitlementSnapshot(buf []byte, offset flatbuffers.UOffsetT) *EntitlementSnapshot {
n := flatbuffers.GetUOffsetT(buf[offset:])
x := &EntitlementSnapshot{}
x.Init(buf, n+offset)
return x
}
func FinishEntitlementSnapshotBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.Finish(offset)
}
func GetSizePrefixedRootAsEntitlementSnapshot(buf []byte, offset flatbuffers.UOffsetT) *EntitlementSnapshot {
n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:])
x := &EntitlementSnapshot{}
x.Init(buf, n+offset+flatbuffers.SizeUint32)
return x
}
func FinishSizePrefixedEntitlementSnapshotBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.FinishSizePrefixed(offset)
}
func (rcv *EntitlementSnapshot) Init(buf []byte, i flatbuffers.UOffsetT) {
rcv._tab.Bytes = buf
rcv._tab.Pos = i
}
func (rcv *EntitlementSnapshot) Table() flatbuffers.Table {
return rcv._tab
}
func (rcv *EntitlementSnapshot) PlanCode() []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(4))
if o != 0 {
return rcv._tab.ByteVector(o + rcv._tab.Pos)
}
return nil
}
func (rcv *EntitlementSnapshot) IsPaid() bool {
o := flatbuffers.UOffsetT(rcv._tab.Offset(6))
if o != 0 {
return rcv._tab.GetBool(o + rcv._tab.Pos)
}
return false
}
func (rcv *EntitlementSnapshot) MutateIsPaid(n bool) bool {
return rcv._tab.MutateBoolSlot(6, n)
}
func (rcv *EntitlementSnapshot) Source() []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(8))
if o != 0 {
return rcv._tab.ByteVector(o + rcv._tab.Pos)
}
return nil
}
func (rcv *EntitlementSnapshot) Actor(obj *ActorRef) *ActorRef {
o := flatbuffers.UOffsetT(rcv._tab.Offset(10))
if o != 0 {
x := rcv._tab.Indirect(o + rcv._tab.Pos)
if obj == nil {
obj = new(ActorRef)
}
obj.Init(rcv._tab.Bytes, x)
return obj
}
return nil
}
func (rcv *EntitlementSnapshot) ReasonCode() []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(12))
if o != 0 {
return rcv._tab.ByteVector(o + rcv._tab.Pos)
}
return nil
}
func (rcv *EntitlementSnapshot) StartsAtMs() int64 {
o := flatbuffers.UOffsetT(rcv._tab.Offset(14))
if o != 0 {
return rcv._tab.GetInt64(o + rcv._tab.Pos)
}
return 0
}
func (rcv *EntitlementSnapshot) MutateStartsAtMs(n int64) bool {
return rcv._tab.MutateInt64Slot(14, n)
}
func (rcv *EntitlementSnapshot) EndsAtMs() int64 {
o := flatbuffers.UOffsetT(rcv._tab.Offset(16))
if o != 0 {
return rcv._tab.GetInt64(o + rcv._tab.Pos)
}
return 0
}
func (rcv *EntitlementSnapshot) MutateEndsAtMs(n int64) bool {
return rcv._tab.MutateInt64Slot(16, n)
}
func (rcv *EntitlementSnapshot) UpdatedAtMs() int64 {
o := flatbuffers.UOffsetT(rcv._tab.Offset(18))
if o != 0 {
return rcv._tab.GetInt64(o + rcv._tab.Pos)
}
return 0
}
func (rcv *EntitlementSnapshot) MutateUpdatedAtMs(n int64) bool {
return rcv._tab.MutateInt64Slot(18, n)
}
func EntitlementSnapshotStart(builder *flatbuffers.Builder) {
builder.StartObject(8)
}
func EntitlementSnapshotAddPlanCode(builder *flatbuffers.Builder, planCode flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(planCode), 0)
}
func EntitlementSnapshotAddIsPaid(builder *flatbuffers.Builder, isPaid bool) {
builder.PrependBoolSlot(1, isPaid, false)
}
func EntitlementSnapshotAddSource(builder *flatbuffers.Builder, source flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(2, flatbuffers.UOffsetT(source), 0)
}
func EntitlementSnapshotAddActor(builder *flatbuffers.Builder, actor flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(3, flatbuffers.UOffsetT(actor), 0)
}
func EntitlementSnapshotAddReasonCode(builder *flatbuffers.Builder, reasonCode flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(4, flatbuffers.UOffsetT(reasonCode), 0)
}
func EntitlementSnapshotAddStartsAtMs(builder *flatbuffers.Builder, startsAtMs int64) {
builder.PrependInt64Slot(5, startsAtMs, 0)
}
func EntitlementSnapshotAddEndsAtMs(builder *flatbuffers.Builder, endsAtMs int64) {
builder.PrependInt64Slot(6, endsAtMs, 0)
}
func EntitlementSnapshotAddUpdatedAtMs(builder *flatbuffers.Builder, updatedAtMs int64) {
builder.PrependInt64Slot(7, updatedAtMs, 0)
}
func EntitlementSnapshotEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject()
}
+71
View File
@@ -0,0 +1,71 @@
// Code generated by the FlatBuffers compiler. DO NOT EDIT.
package user
import (
flatbuffers "github.com/google/flatbuffers/go"
)
type ErrorBody struct {
_tab flatbuffers.Table
}
func GetRootAsErrorBody(buf []byte, offset flatbuffers.UOffsetT) *ErrorBody {
n := flatbuffers.GetUOffsetT(buf[offset:])
x := &ErrorBody{}
x.Init(buf, n+offset)
return x
}
func FinishErrorBodyBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.Finish(offset)
}
func GetSizePrefixedRootAsErrorBody(buf []byte, offset flatbuffers.UOffsetT) *ErrorBody {
n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:])
x := &ErrorBody{}
x.Init(buf, n+offset+flatbuffers.SizeUint32)
return x
}
func FinishSizePrefixedErrorBodyBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.FinishSizePrefixed(offset)
}
func (rcv *ErrorBody) Init(buf []byte, i flatbuffers.UOffsetT) {
rcv._tab.Bytes = buf
rcv._tab.Pos = i
}
func (rcv *ErrorBody) Table() flatbuffers.Table {
return rcv._tab
}
func (rcv *ErrorBody) Code() []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(4))
if o != 0 {
return rcv._tab.ByteVector(o + rcv._tab.Pos)
}
return nil
}
func (rcv *ErrorBody) Message() []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(6))
if o != 0 {
return rcv._tab.ByteVector(o + rcv._tab.Pos)
}
return nil
}
func ErrorBodyStart(builder *flatbuffers.Builder) {
builder.StartObject(2)
}
func ErrorBodyAddCode(builder *flatbuffers.Builder, code flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(code), 0)
}
func ErrorBodyAddMessage(builder *flatbuffers.Builder, message flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(message), 0)
}
func ErrorBodyEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject()
}
+65
View File
@@ -0,0 +1,65 @@
// Code generated by the FlatBuffers compiler. DO NOT EDIT.
package user
import (
flatbuffers "github.com/google/flatbuffers/go"
)
type ErrorResponse struct {
_tab flatbuffers.Table
}
func GetRootAsErrorResponse(buf []byte, offset flatbuffers.UOffsetT) *ErrorResponse {
n := flatbuffers.GetUOffsetT(buf[offset:])
x := &ErrorResponse{}
x.Init(buf, n+offset)
return x
}
func FinishErrorResponseBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.Finish(offset)
}
func GetSizePrefixedRootAsErrorResponse(buf []byte, offset flatbuffers.UOffsetT) *ErrorResponse {
n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:])
x := &ErrorResponse{}
x.Init(buf, n+offset+flatbuffers.SizeUint32)
return x
}
func FinishSizePrefixedErrorResponseBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.FinishSizePrefixed(offset)
}
func (rcv *ErrorResponse) Init(buf []byte, i flatbuffers.UOffsetT) {
rcv._tab.Bytes = buf
rcv._tab.Pos = i
}
func (rcv *ErrorResponse) Table() flatbuffers.Table {
return rcv._tab
}
func (rcv *ErrorResponse) Error(obj *ErrorBody) *ErrorBody {
o := flatbuffers.UOffsetT(rcv._tab.Offset(4))
if o != 0 {
x := rcv._tab.Indirect(o + rcv._tab.Pos)
if obj == nil {
obj = new(ErrorBody)
}
obj.Init(rcv._tab.Bytes, x)
return obj
}
return nil
}
func ErrorResponseStart(builder *flatbuffers.Builder) {
builder.StartObject(1)
}
func ErrorResponseAddError(builder *flatbuffers.Builder, error flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(error), 0)
}
func ErrorResponseEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject()
}
@@ -0,0 +1,49 @@
// Code generated by the FlatBuffers compiler. DO NOT EDIT.
package user
import (
flatbuffers "github.com/google/flatbuffers/go"
)
type GetMyAccountRequest struct {
_tab flatbuffers.Table
}
func GetRootAsGetMyAccountRequest(buf []byte, offset flatbuffers.UOffsetT) *GetMyAccountRequest {
n := flatbuffers.GetUOffsetT(buf[offset:])
x := &GetMyAccountRequest{}
x.Init(buf, n+offset)
return x
}
func FinishGetMyAccountRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.Finish(offset)
}
func GetSizePrefixedRootAsGetMyAccountRequest(buf []byte, offset flatbuffers.UOffsetT) *GetMyAccountRequest {
n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:])
x := &GetMyAccountRequest{}
x.Init(buf, n+offset+flatbuffers.SizeUint32)
return x
}
func FinishSizePrefixedGetMyAccountRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.FinishSizePrefixed(offset)
}
func (rcv *GetMyAccountRequest) Init(buf []byte, i flatbuffers.UOffsetT) {
rcv._tab.Bytes = buf
rcv._tab.Pos = i
}
func (rcv *GetMyAccountRequest) Table() flatbuffers.Table {
return rcv._tab
}
func GetMyAccountRequestStart(builder *flatbuffers.Builder) {
builder.StartObject(0)
}
func GetMyAccountRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject()
}
@@ -0,0 +1,60 @@
// Code generated by the FlatBuffers compiler. DO NOT EDIT.
package user
import (
flatbuffers "github.com/google/flatbuffers/go"
)
type UpdateMyProfileRequest struct {
_tab flatbuffers.Table
}
func GetRootAsUpdateMyProfileRequest(buf []byte, offset flatbuffers.UOffsetT) *UpdateMyProfileRequest {
n := flatbuffers.GetUOffsetT(buf[offset:])
x := &UpdateMyProfileRequest{}
x.Init(buf, n+offset)
return x
}
func FinishUpdateMyProfileRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.Finish(offset)
}
func GetSizePrefixedRootAsUpdateMyProfileRequest(buf []byte, offset flatbuffers.UOffsetT) *UpdateMyProfileRequest {
n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:])
x := &UpdateMyProfileRequest{}
x.Init(buf, n+offset+flatbuffers.SizeUint32)
return x
}
func FinishSizePrefixedUpdateMyProfileRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.FinishSizePrefixed(offset)
}
func (rcv *UpdateMyProfileRequest) Init(buf []byte, i flatbuffers.UOffsetT) {
rcv._tab.Bytes = buf
rcv._tab.Pos = i
}
func (rcv *UpdateMyProfileRequest) Table() flatbuffers.Table {
return rcv._tab
}
func (rcv *UpdateMyProfileRequest) RaceName() []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(4))
if o != 0 {
return rcv._tab.ByteVector(o + rcv._tab.Pos)
}
return nil
}
func UpdateMyProfileRequestStart(builder *flatbuffers.Builder) {
builder.StartObject(1)
}
func UpdateMyProfileRequestAddRaceName(builder *flatbuffers.Builder, raceName flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(raceName), 0)
}
func UpdateMyProfileRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject()
}
@@ -0,0 +1,71 @@
// Code generated by the FlatBuffers compiler. DO NOT EDIT.
package user
import (
flatbuffers "github.com/google/flatbuffers/go"
)
type UpdateMySettingsRequest struct {
_tab flatbuffers.Table
}
func GetRootAsUpdateMySettingsRequest(buf []byte, offset flatbuffers.UOffsetT) *UpdateMySettingsRequest {
n := flatbuffers.GetUOffsetT(buf[offset:])
x := &UpdateMySettingsRequest{}
x.Init(buf, n+offset)
return x
}
func FinishUpdateMySettingsRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.Finish(offset)
}
func GetSizePrefixedRootAsUpdateMySettingsRequest(buf []byte, offset flatbuffers.UOffsetT) *UpdateMySettingsRequest {
n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:])
x := &UpdateMySettingsRequest{}
x.Init(buf, n+offset+flatbuffers.SizeUint32)
return x
}
func FinishSizePrefixedUpdateMySettingsRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.FinishSizePrefixed(offset)
}
func (rcv *UpdateMySettingsRequest) Init(buf []byte, i flatbuffers.UOffsetT) {
rcv._tab.Bytes = buf
rcv._tab.Pos = i
}
func (rcv *UpdateMySettingsRequest) Table() flatbuffers.Table {
return rcv._tab
}
func (rcv *UpdateMySettingsRequest) PreferredLanguage() []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(4))
if o != 0 {
return rcv._tab.ByteVector(o + rcv._tab.Pos)
}
return nil
}
func (rcv *UpdateMySettingsRequest) TimeZone() []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(6))
if o != 0 {
return rcv._tab.ByteVector(o + rcv._tab.Pos)
}
return nil
}
func UpdateMySettingsRequestStart(builder *flatbuffers.Builder) {
builder.StartObject(2)
}
func UpdateMySettingsRequestAddPreferredLanguage(builder *flatbuffers.Builder, preferredLanguage flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(preferredLanguage), 0)
}
func UpdateMySettingsRequestAddTimeZone(builder *flatbuffers.Builder, timeZone flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(timeZone), 0)
}
func UpdateMySettingsRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject()
}
+504
View File
@@ -0,0 +1,504 @@
package transcoder
import (
"errors"
"fmt"
"time"
usermodel "galaxy/model/user"
userfbs "galaxy/schema/fbs/user"
flatbuffers "github.com/google/flatbuffers/go"
)
// GetMyAccountRequestToPayload converts usermodel.GetMyAccountRequest to
// FlatBuffers bytes suitable for the authenticated gateway transport.
func GetMyAccountRequestToPayload(request *usermodel.GetMyAccountRequest) ([]byte, error) {
if request == nil {
return nil, errors.New("encode get my account request payload: request is nil")
}
builder := flatbuffers.NewBuilder(32)
userfbs.GetMyAccountRequestStart(builder)
offset := userfbs.GetMyAccountRequestEnd(builder)
userfbs.FinishGetMyAccountRequestBuffer(builder, offset)
return builder.FinishedBytes(), nil
}
// PayloadToGetMyAccountRequest converts FlatBuffers payload bytes into
// usermodel.GetMyAccountRequest.
func PayloadToGetMyAccountRequest(data []byte) (result *usermodel.GetMyAccountRequest, err error) {
if len(data) == 0 {
return nil, errors.New("decode get my account request payload: data is empty")
}
defer recoverUserDecodePanic("decode get my account request payload", &result, &err)
_ = userfbs.GetRootAsGetMyAccountRequest(data, 0)
return &usermodel.GetMyAccountRequest{}, nil
}
// UpdateMyProfileRequestToPayload converts usermodel.UpdateMyProfileRequest to
// FlatBuffers bytes suitable for the authenticated gateway transport.
func UpdateMyProfileRequestToPayload(request *usermodel.UpdateMyProfileRequest) ([]byte, error) {
if request == nil {
return nil, errors.New("encode update my profile request payload: request is nil")
}
builder := flatbuffers.NewBuilder(128)
raceName := builder.CreateString(request.RaceName)
userfbs.UpdateMyProfileRequestStart(builder)
userfbs.UpdateMyProfileRequestAddRaceName(builder, raceName)
offset := userfbs.UpdateMyProfileRequestEnd(builder)
userfbs.FinishUpdateMyProfileRequestBuffer(builder, offset)
return builder.FinishedBytes(), nil
}
// PayloadToUpdateMyProfileRequest converts FlatBuffers payload bytes into
// usermodel.UpdateMyProfileRequest.
func PayloadToUpdateMyProfileRequest(data []byte) (result *usermodel.UpdateMyProfileRequest, err error) {
if len(data) == 0 {
return nil, errors.New("decode update my profile request payload: data is empty")
}
defer recoverUserDecodePanic("decode update my profile request payload", &result, &err)
request := userfbs.GetRootAsUpdateMyProfileRequest(data, 0)
return &usermodel.UpdateMyProfileRequest{
RaceName: string(request.RaceName()),
}, nil
}
// UpdateMySettingsRequestToPayload converts
// usermodel.UpdateMySettingsRequest to FlatBuffers bytes suitable for the
// authenticated gateway transport.
func UpdateMySettingsRequestToPayload(request *usermodel.UpdateMySettingsRequest) ([]byte, error) {
if request == nil {
return nil, errors.New("encode update my settings request payload: request is nil")
}
builder := flatbuffers.NewBuilder(128)
preferredLanguage := builder.CreateString(request.PreferredLanguage)
timeZone := builder.CreateString(request.TimeZone)
userfbs.UpdateMySettingsRequestStart(builder)
userfbs.UpdateMySettingsRequestAddPreferredLanguage(builder, preferredLanguage)
userfbs.UpdateMySettingsRequestAddTimeZone(builder, timeZone)
offset := userfbs.UpdateMySettingsRequestEnd(builder)
userfbs.FinishUpdateMySettingsRequestBuffer(builder, offset)
return builder.FinishedBytes(), nil
}
// PayloadToUpdateMySettingsRequest converts FlatBuffers payload bytes into
// usermodel.UpdateMySettingsRequest.
func PayloadToUpdateMySettingsRequest(data []byte) (result *usermodel.UpdateMySettingsRequest, err error) {
if len(data) == 0 {
return nil, errors.New("decode update my settings request payload: data is empty")
}
defer recoverUserDecodePanic("decode update my settings request payload", &result, &err)
request := userfbs.GetRootAsUpdateMySettingsRequest(data, 0)
return &usermodel.UpdateMySettingsRequest{
PreferredLanguage: string(request.PreferredLanguage()),
TimeZone: string(request.TimeZone()),
}, nil
}
// AccountResponseToPayload converts usermodel.AccountResponse to FlatBuffers
// bytes suitable for the authenticated gateway transport.
func AccountResponseToPayload(response *usermodel.AccountResponse) ([]byte, error) {
if response == nil {
return nil, errors.New("encode account response payload: response is nil")
}
builder := flatbuffers.NewBuilder(512)
accountOffset, err := encodeAccount(builder, response.Account)
if err != nil {
return nil, fmt.Errorf("encode account response payload: %w", err)
}
userfbs.AccountResponseStart(builder)
userfbs.AccountResponseAddAccount(builder, accountOffset)
offset := userfbs.AccountResponseEnd(builder)
userfbs.FinishAccountResponseBuffer(builder, offset)
return builder.FinishedBytes(), nil
}
// PayloadToAccountResponse converts FlatBuffers payload bytes into
// usermodel.AccountResponse.
func PayloadToAccountResponse(data []byte) (result *usermodel.AccountResponse, err error) {
if len(data) == 0 {
return nil, errors.New("decode account response payload: data is empty")
}
defer recoverUserDecodePanic("decode account response payload", &result, &err)
response := userfbs.GetRootAsAccountResponse(data, 0)
account := response.Account(nil)
if account == nil {
return nil, errors.New("decode account response payload: account is missing")
}
decodedAccount, err := decodeAccount(account)
if err != nil {
return nil, fmt.Errorf("decode account response payload: %w", err)
}
return &usermodel.AccountResponse{Account: decodedAccount}, nil
}
// ErrorResponseToPayload converts usermodel.ErrorResponse to FlatBuffers bytes
// suitable for the authenticated gateway transport.
func ErrorResponseToPayload(response *usermodel.ErrorResponse) ([]byte, error) {
if response == nil {
return nil, errors.New("encode error response payload: response is nil")
}
builder := flatbuffers.NewBuilder(128)
errorOffset := encodeErrorBody(builder, response.Error)
userfbs.ErrorResponseStart(builder)
userfbs.ErrorResponseAddError(builder, errorOffset)
offset := userfbs.ErrorResponseEnd(builder)
userfbs.FinishErrorResponseBuffer(builder, offset)
return builder.FinishedBytes(), nil
}
// PayloadToErrorResponse converts FlatBuffers payload bytes into
// usermodel.ErrorResponse.
func PayloadToErrorResponse(data []byte) (result *usermodel.ErrorResponse, err error) {
if len(data) == 0 {
return nil, errors.New("decode error response payload: data is empty")
}
defer recoverUserDecodePanic("decode error response payload", &result, &err)
response := userfbs.GetRootAsErrorResponse(data, 0)
errorBody := response.Error(nil)
if errorBody == nil {
return nil, errors.New("decode error response payload: error is missing")
}
return &usermodel.ErrorResponse{
Error: usermodel.ErrorBody{
Code: string(errorBody.Code()),
Message: string(errorBody.Message()),
},
}, nil
}
func encodeAccount(builder *flatbuffers.Builder, account usermodel.Account) (flatbuffers.UOffsetT, error) {
entitlementOffset, err := encodeEntitlementSnapshot(builder, account.Entitlement)
if err != nil {
return 0, fmt.Errorf("encode account: %w", err)
}
activeSanctionOffsets := make([]flatbuffers.UOffsetT, len(account.ActiveSanctions))
for index := range account.ActiveSanctions {
activeSanctionOffsets[index], err = encodeActiveSanction(builder, account.ActiveSanctions[index])
if err != nil {
return 0, fmt.Errorf("encode account active sanction %d: %w", index, err)
}
}
var activeSanctionsVector flatbuffers.UOffsetT
if len(activeSanctionOffsets) > 0 {
userfbs.AccountViewStartActiveSanctionsVector(builder, len(activeSanctionOffsets))
for index := len(activeSanctionOffsets) - 1; index >= 0; index-- {
builder.PrependUOffsetT(activeSanctionOffsets[index])
}
activeSanctionsVector = builder.EndVector(len(activeSanctionOffsets))
}
activeLimitOffsets := make([]flatbuffers.UOffsetT, len(account.ActiveLimits))
for index := range account.ActiveLimits {
activeLimitOffsets[index], err = encodeActiveLimit(builder, account.ActiveLimits[index])
if err != nil {
return 0, fmt.Errorf("encode account active limit %d: %w", index, err)
}
}
var activeLimitsVector flatbuffers.UOffsetT
if len(activeLimitOffsets) > 0 {
userfbs.AccountViewStartActiveLimitsVector(builder, len(activeLimitOffsets))
for index := len(activeLimitOffsets) - 1; index >= 0; index-- {
builder.PrependUOffsetT(activeLimitOffsets[index])
}
activeLimitsVector = builder.EndVector(len(activeLimitOffsets))
}
userID := builder.CreateString(account.UserID)
email := builder.CreateString(account.Email)
raceName := builder.CreateString(account.RaceName)
preferredLanguage := builder.CreateString(account.PreferredLanguage)
timeZone := builder.CreateString(account.TimeZone)
var declaredCountry flatbuffers.UOffsetT
if account.DeclaredCountry != "" {
declaredCountry = builder.CreateString(account.DeclaredCountry)
}
userfbs.AccountViewStart(builder)
userfbs.AccountViewAddUserId(builder, userID)
userfbs.AccountViewAddEmail(builder, email)
userfbs.AccountViewAddRaceName(builder, raceName)
userfbs.AccountViewAddPreferredLanguage(builder, preferredLanguage)
userfbs.AccountViewAddTimeZone(builder, timeZone)
if declaredCountry != 0 {
userfbs.AccountViewAddDeclaredCountry(builder, declaredCountry)
}
userfbs.AccountViewAddEntitlement(builder, entitlementOffset)
if activeSanctionsVector != 0 {
userfbs.AccountViewAddActiveSanctions(builder, activeSanctionsVector)
}
if activeLimitsVector != 0 {
userfbs.AccountViewAddActiveLimits(builder, activeLimitsVector)
}
userfbs.AccountViewAddCreatedAtMs(builder, account.CreatedAt.UTC().UnixMilli())
userfbs.AccountViewAddUpdatedAtMs(builder, account.UpdatedAt.UTC().UnixMilli())
return userfbs.AccountViewEnd(builder), nil
}
func decodeAccount(account *userfbs.AccountView) (usermodel.Account, error) {
entitlement := account.Entitlement(nil)
if entitlement == nil {
return usermodel.Account{}, errors.New("account entitlement is missing")
}
decodedEntitlement, err := decodeEntitlementSnapshot(entitlement)
if err != nil {
return usermodel.Account{}, fmt.Errorf("decode account entitlement: %w", err)
}
createdAt := time.UnixMilli(account.CreatedAtMs()).UTC()
updatedAt := time.UnixMilli(account.UpdatedAtMs()).UTC()
result := usermodel.Account{
UserID: string(account.UserId()),
Email: string(account.Email()),
RaceName: string(account.RaceName()),
PreferredLanguage: string(account.PreferredLanguage()),
TimeZone: string(account.TimeZone()),
DeclaredCountry: string(account.DeclaredCountry()),
Entitlement: decodedEntitlement,
ActiveSanctions: make([]usermodel.ActiveSanction, 0, account.ActiveSanctionsLength()),
ActiveLimits: make([]usermodel.ActiveLimit, 0, account.ActiveLimitsLength()),
CreatedAt: createdAt,
UpdatedAt: updatedAt,
}
activeSanction := new(userfbs.ActiveSanction)
for index := 0; index < account.ActiveSanctionsLength(); index++ {
if !account.ActiveSanctions(activeSanction, index) {
return usermodel.Account{}, fmt.Errorf("account active sanction %d is missing", index)
}
decodedSanction, err := decodeActiveSanction(activeSanction)
if err != nil {
return usermodel.Account{}, fmt.Errorf("decode account active sanction %d: %w", index, err)
}
result.ActiveSanctions = append(result.ActiveSanctions, decodedSanction)
}
activeLimit := new(userfbs.ActiveLimit)
for index := 0; index < account.ActiveLimitsLength(); index++ {
if !account.ActiveLimits(activeLimit, index) {
return usermodel.Account{}, fmt.Errorf("account active limit %d is missing", index)
}
decodedLimit, err := decodeActiveLimit(activeLimit)
if err != nil {
return usermodel.Account{}, fmt.Errorf("decode account active limit %d: %w", index, err)
}
result.ActiveLimits = append(result.ActiveLimits, decodedLimit)
}
return result, nil
}
func encodeEntitlementSnapshot(builder *flatbuffers.Builder, snapshot usermodel.EntitlementSnapshot) (flatbuffers.UOffsetT, error) {
actorOffset := encodeActorRef(builder, snapshot.Actor)
planCode := builder.CreateString(snapshot.PlanCode)
source := builder.CreateString(snapshot.Source)
reasonCode := builder.CreateString(snapshot.ReasonCode)
userfbs.EntitlementSnapshotStart(builder)
userfbs.EntitlementSnapshotAddPlanCode(builder, planCode)
userfbs.EntitlementSnapshotAddIsPaid(builder, snapshot.IsPaid)
userfbs.EntitlementSnapshotAddSource(builder, source)
userfbs.EntitlementSnapshotAddActor(builder, actorOffset)
userfbs.EntitlementSnapshotAddReasonCode(builder, reasonCode)
userfbs.EntitlementSnapshotAddStartsAtMs(builder, snapshot.StartsAt.UTC().UnixMilli())
if snapshot.EndsAt != nil {
userfbs.EntitlementSnapshotAddEndsAtMs(builder, snapshot.EndsAt.UTC().UnixMilli())
}
userfbs.EntitlementSnapshotAddUpdatedAtMs(builder, snapshot.UpdatedAt.UTC().UnixMilli())
return userfbs.EntitlementSnapshotEnd(builder), nil
}
func decodeEntitlementSnapshot(snapshot *userfbs.EntitlementSnapshot) (usermodel.EntitlementSnapshot, error) {
actor := snapshot.Actor(nil)
if actor == nil {
return usermodel.EntitlementSnapshot{}, errors.New("entitlement actor is missing")
}
decodedActor, err := decodeActorRef(actor)
if err != nil {
return usermodel.EntitlementSnapshot{}, fmt.Errorf("decode entitlement actor: %w", err)
}
return usermodel.EntitlementSnapshot{
PlanCode: string(snapshot.PlanCode()),
IsPaid: snapshot.IsPaid(),
Source: string(snapshot.Source()),
Actor: decodedActor,
ReasonCode: string(snapshot.ReasonCode()),
StartsAt: time.UnixMilli(snapshot.StartsAtMs()).UTC(),
EndsAt: optionalUnixMilli(snapshot.EndsAtMs()),
UpdatedAt: time.UnixMilli(snapshot.UpdatedAtMs()).UTC(),
}, nil
}
func encodeActiveSanction(builder *flatbuffers.Builder, sanction usermodel.ActiveSanction) (flatbuffers.UOffsetT, error) {
actorOffset := encodeActorRef(builder, sanction.Actor)
sanctionCode := builder.CreateString(sanction.SanctionCode)
scope := builder.CreateString(sanction.Scope)
reasonCode := builder.CreateString(sanction.ReasonCode)
userfbs.ActiveSanctionStart(builder)
userfbs.ActiveSanctionAddSanctionCode(builder, sanctionCode)
userfbs.ActiveSanctionAddScope(builder, scope)
userfbs.ActiveSanctionAddReasonCode(builder, reasonCode)
userfbs.ActiveSanctionAddActor(builder, actorOffset)
userfbs.ActiveSanctionAddAppliedAtMs(builder, sanction.AppliedAt.UTC().UnixMilli())
if sanction.ExpiresAt != nil {
userfbs.ActiveSanctionAddExpiresAtMs(builder, sanction.ExpiresAt.UTC().UnixMilli())
}
return userfbs.ActiveSanctionEnd(builder), nil
}
func decodeActiveSanction(sanction *userfbs.ActiveSanction) (usermodel.ActiveSanction, error) {
actor := sanction.Actor(nil)
if actor == nil {
return usermodel.ActiveSanction{}, errors.New("sanction actor is missing")
}
decodedActor, err := decodeActorRef(actor)
if err != nil {
return usermodel.ActiveSanction{}, fmt.Errorf("decode sanction actor: %w", err)
}
return usermodel.ActiveSanction{
SanctionCode: string(sanction.SanctionCode()),
Scope: string(sanction.Scope()),
ReasonCode: string(sanction.ReasonCode()),
Actor: decodedActor,
AppliedAt: time.UnixMilli(sanction.AppliedAtMs()).UTC(),
ExpiresAt: optionalUnixMilli(sanction.ExpiresAtMs()),
}, nil
}
func encodeActiveLimit(builder *flatbuffers.Builder, limit usermodel.ActiveLimit) (flatbuffers.UOffsetT, error) {
actorOffset := encodeActorRef(builder, limit.Actor)
limitCode := builder.CreateString(limit.LimitCode)
reasonCode := builder.CreateString(limit.ReasonCode)
userfbs.ActiveLimitStart(builder)
userfbs.ActiveLimitAddLimitCode(builder, limitCode)
userfbs.ActiveLimitAddValue(builder, int64(limit.Value))
userfbs.ActiveLimitAddReasonCode(builder, reasonCode)
userfbs.ActiveLimitAddActor(builder, actorOffset)
userfbs.ActiveLimitAddAppliedAtMs(builder, limit.AppliedAt.UTC().UnixMilli())
if limit.ExpiresAt != nil {
userfbs.ActiveLimitAddExpiresAtMs(builder, limit.ExpiresAt.UTC().UnixMilli())
}
return userfbs.ActiveLimitEnd(builder), nil
}
func decodeActiveLimit(limit *userfbs.ActiveLimit) (usermodel.ActiveLimit, error) {
actor := limit.Actor(nil)
if actor == nil {
return usermodel.ActiveLimit{}, errors.New("limit actor is missing")
}
decodedActor, err := decodeActorRef(actor)
if err != nil {
return usermodel.ActiveLimit{}, fmt.Errorf("decode limit actor: %w", err)
}
value, err := int64ToInt(limit.Value(), "value")
if err != nil {
return usermodel.ActiveLimit{}, err
}
return usermodel.ActiveLimit{
LimitCode: string(limit.LimitCode()),
Value: value,
ReasonCode: string(limit.ReasonCode()),
Actor: decodedActor,
AppliedAt: time.UnixMilli(limit.AppliedAtMs()).UTC(),
ExpiresAt: optionalUnixMilli(limit.ExpiresAtMs()),
}, nil
}
func encodeActorRef(builder *flatbuffers.Builder, actor usermodel.ActorRef) flatbuffers.UOffsetT {
actorType := builder.CreateString(actor.Type)
var actorID flatbuffers.UOffsetT
if actor.ID != "" {
actorID = builder.CreateString(actor.ID)
}
userfbs.ActorRefStart(builder)
userfbs.ActorRefAddType(builder, actorType)
if actorID != 0 {
userfbs.ActorRefAddId(builder, actorID)
}
return userfbs.ActorRefEnd(builder)
}
func decodeActorRef(actor *userfbs.ActorRef) (usermodel.ActorRef, error) {
return usermodel.ActorRef{
Type: string(actor.Type()),
ID: string(actor.Id()),
}, nil
}
func encodeErrorBody(builder *flatbuffers.Builder, errorBody usermodel.ErrorBody) flatbuffers.UOffsetT {
code := builder.CreateString(errorBody.Code)
message := builder.CreateString(errorBody.Message)
userfbs.ErrorBodyStart(builder)
userfbs.ErrorBodyAddCode(builder, code)
userfbs.ErrorBodyAddMessage(builder, message)
return userfbs.ErrorBodyEnd(builder)
}
func optionalUnixMilli(value int64) *time.Time {
if value == 0 {
return nil
}
decoded := time.UnixMilli(value).UTC()
return &decoded
}
func recoverUserDecodePanic[T any](message string, result **T, err *error) {
if recovered := recover(); recovered != nil {
*result = nil
*err = fmt.Errorf("%s: panic recovered: %v", message, recovered)
}
}
+468
View File
@@ -0,0 +1,468 @@
package transcoder
import (
"reflect"
"strconv"
"strings"
"testing"
"time"
usermodel "galaxy/model/user"
userfbs "galaxy/schema/fbs/user"
flatbuffers "github.com/google/flatbuffers/go"
)
func TestUserRequestPayloadRoundTrips(t *testing.T) {
t.Parallel()
getPayload, err := GetMyAccountRequestToPayload(&usermodel.GetMyAccountRequest{})
if err != nil {
t.Fatalf("encode get my account request: %v", err)
}
getDecoded, err := PayloadToGetMyAccountRequest(getPayload)
if err != nil {
t.Fatalf("decode get my account request: %v", err)
}
if !reflect.DeepEqual(&usermodel.GetMyAccountRequest{}, getDecoded) {
t.Fatalf("get my account request mismatch: %#v", getDecoded)
}
profileSource := &usermodel.UpdateMyProfileRequest{RaceName: "Nova Prime"}
profilePayload, err := UpdateMyProfileRequestToPayload(profileSource)
if err != nil {
t.Fatalf("encode update my profile request: %v", err)
}
profileDecoded, err := PayloadToUpdateMyProfileRequest(profilePayload)
if err != nil {
t.Fatalf("decode update my profile request: %v", err)
}
if !reflect.DeepEqual(profileSource, profileDecoded) {
t.Fatalf("update my profile request mismatch\nsource: %#v\ndecoded:%#v", profileSource, profileDecoded)
}
settingsSource := &usermodel.UpdateMySettingsRequest{
PreferredLanguage: "en-US",
TimeZone: "Europe/Kaliningrad",
}
settingsPayload, err := UpdateMySettingsRequestToPayload(settingsSource)
if err != nil {
t.Fatalf("encode update my settings request: %v", err)
}
settingsDecoded, err := PayloadToUpdateMySettingsRequest(settingsPayload)
if err != nil {
t.Fatalf("decode update my settings request: %v", err)
}
if !reflect.DeepEqual(settingsSource, settingsDecoded) {
t.Fatalf("update my settings request mismatch\nsource: %#v\ndecoded:%#v", settingsSource, settingsDecoded)
}
}
func TestAccountResponsePayloadRoundTrip(t *testing.T) {
t.Parallel()
now := time.Date(2026, time.April, 9, 10, 0, 0, 0, time.UTC)
expiresAt := now.Add(30 * 24 * time.Hour)
limitExpiresAt := now.Add(90 * 24 * time.Hour)
source := &usermodel.AccountResponse{
Account: usermodel.Account{
UserID: "user-123",
Email: "pilot@example.com",
RaceName: "Pilot Nova",
PreferredLanguage: "en",
TimeZone: "Europe/Kaliningrad",
DeclaredCountry: "DE",
Entitlement: usermodel.EntitlementSnapshot{
PlanCode: "paid_monthly",
IsPaid: true,
Source: "billing",
Actor: usermodel.ActorRef{Type: "billing", ID: "invoice-1"},
ReasonCode: "renewal",
StartsAt: now,
EndsAt: &expiresAt,
UpdatedAt: now,
},
ActiveSanctions: []usermodel.ActiveSanction{
{
SanctionCode: "profile_update_block",
Scope: "lobby",
ReasonCode: "manual_block",
Actor: usermodel.ActorRef{Type: "admin", ID: "admin-1"},
AppliedAt: now,
ExpiresAt: &expiresAt,
},
},
ActiveLimits: []usermodel.ActiveLimit{
{
LimitCode: "max_owned_private_games",
Value: 3,
ReasonCode: "manual_override",
Actor: usermodel.ActorRef{Type: "admin", ID: "admin-1"},
AppliedAt: now,
ExpiresAt: &limitExpiresAt,
},
},
CreatedAt: now,
UpdatedAt: now.Add(time.Hour),
},
}
payload, err := AccountResponseToPayload(source)
if err != nil {
t.Fatalf("encode account response: %v", err)
}
decoded, err := PayloadToAccountResponse(payload)
if err != nil {
t.Fatalf("decode account response: %v", err)
}
if !reflect.DeepEqual(source, decoded) {
t.Fatalf("account response mismatch\nsource: %#v\ndecoded:%#v", source, decoded)
}
}
func TestErrorResponsePayloadRoundTrip(t *testing.T) {
t.Parallel()
source := &usermodel.ErrorResponse{
Error: usermodel.ErrorBody{
Code: "conflict",
Message: "request conflicts with current state",
},
}
payload, err := ErrorResponseToPayload(source)
if err != nil {
t.Fatalf("encode error response: %v", err)
}
decoded, err := PayloadToErrorResponse(payload)
if err != nil {
t.Fatalf("decode error response: %v", err)
}
if !reflect.DeepEqual(source, decoded) {
t.Fatalf("error response mismatch\nsource: %#v\ndecoded:%#v", source, decoded)
}
}
func TestUserPayloadEncodersRejectNilInputs(t *testing.T) {
t.Parallel()
tests := []struct {
name string
call func() error
}{
{
name: "get my account request",
call: func() error {
_, err := GetMyAccountRequestToPayload(nil)
return err
},
},
{
name: "update my profile request",
call: func() error {
_, err := UpdateMyProfileRequestToPayload(nil)
return err
},
},
{
name: "update my settings request",
call: func() error {
_, err := UpdateMySettingsRequestToPayload(nil)
return err
},
},
{
name: "account response",
call: func() error {
_, err := AccountResponseToPayload(nil)
return err
},
},
{
name: "error response",
call: func() error {
_, err := ErrorResponseToPayload(nil)
return err
},
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if err := tt.call(); err == nil {
t.Fatal("expected error")
}
})
}
}
func TestUserPayloadDecodersRejectEmptyPayloads(t *testing.T) {
t.Parallel()
tests := []struct {
name string
call func() error
}{
{
name: "get my account request",
call: func() error {
_, err := PayloadToGetMyAccountRequest(nil)
return err
},
},
{
name: "update my profile request",
call: func() error {
_, err := PayloadToUpdateMyProfileRequest(nil)
return err
},
},
{
name: "update my settings request",
call: func() error {
_, err := PayloadToUpdateMySettingsRequest(nil)
return err
},
},
{
name: "account response",
call: func() error {
_, err := PayloadToAccountResponse(nil)
return err
},
},
{
name: "error response",
call: func() error {
_, err := PayloadToErrorResponse(nil)
return err
},
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if err := tt.call(); err == nil {
t.Fatal("expected error")
}
})
}
}
func TestUserPayloadDecodersRecoverFromGarbagePayloads(t *testing.T) {
t.Parallel()
tests := []struct {
name string
call func() error
}{
{
name: "get my account request",
call: func() error {
_, err := PayloadToGetMyAccountRequest([]byte{0x01, 0x02, 0x03})
return err
},
},
{
name: "update my profile request",
call: func() error {
_, err := PayloadToUpdateMyProfileRequest([]byte{0x01, 0x02, 0x03})
return err
},
},
{
name: "update my settings request",
call: func() error {
_, err := PayloadToUpdateMySettingsRequest([]byte{0x01, 0x02, 0x03})
return err
},
},
{
name: "account response",
call: func() error {
_, err := PayloadToAccountResponse([]byte{0x01, 0x02, 0x03})
return err
},
},
{
name: "error response",
call: func() error {
_, err := PayloadToErrorResponse([]byte{0x01, 0x02, 0x03})
return err
},
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if err := tt.call(); err == nil {
t.Fatal("expected error")
}
})
}
}
func TestPayloadToAccountResponseRejectsMissingAccount(t *testing.T) {
t.Parallel()
builder := flatbuffers.NewBuilder(64)
userfbs.AccountResponseStart(builder)
offset := userfbs.AccountResponseEnd(builder)
userfbs.FinishAccountResponseBuffer(builder, offset)
_, err := PayloadToAccountResponse(builder.FinishedBytes())
if err == nil {
t.Fatal("expected error for missing account")
}
if !strings.Contains(err.Error(), "account is missing") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestPayloadToAccountResponseRejectsMissingEntitlement(t *testing.T) {
t.Parallel()
payload := buildAccountResponsePayload(func(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
userID := builder.CreateString("user-123")
email := builder.CreateString("pilot@example.com")
raceName := builder.CreateString("Pilot Nova")
preferredLanguage := builder.CreateString("en")
timeZone := builder.CreateString("Europe/Kaliningrad")
userfbs.AccountViewStart(builder)
userfbs.AccountViewAddUserId(builder, userID)
userfbs.AccountViewAddEmail(builder, email)
userfbs.AccountViewAddRaceName(builder, raceName)
userfbs.AccountViewAddPreferredLanguage(builder, preferredLanguage)
userfbs.AccountViewAddTimeZone(builder, timeZone)
userfbs.AccountViewAddCreatedAtMs(builder, 1)
userfbs.AccountViewAddUpdatedAtMs(builder, 2)
return userfbs.AccountViewEnd(builder)
})
_, err := PayloadToAccountResponse(payload)
if err == nil {
t.Fatal("expected error for missing entitlement")
}
if !strings.Contains(err.Error(), "entitlement is missing") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestPayloadToAccountResponseRejectsOverflowLimitValue(t *testing.T) {
t.Parallel()
if strconv.IntSize == 64 {
t.Skip("int overflow from int64 is not possible on 64-bit runtime")
}
maxInt := int64(int(^uint(0) >> 1))
overflow := maxInt + 1
nowMS := int64(1)
payload := buildAccountResponsePayload(func(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
actorType := builder.CreateString("admin")
userfbs.ActorRefStart(builder)
userfbs.ActorRefAddType(builder, actorType)
actorOffset := userfbs.ActorRefEnd(builder)
planCode := builder.CreateString("free")
source := builder.CreateString("auth_registration")
reasonCode := builder.CreateString("initial_free_entitlement")
userfbs.EntitlementSnapshotStart(builder)
userfbs.EntitlementSnapshotAddPlanCode(builder, planCode)
userfbs.EntitlementSnapshotAddSource(builder, source)
userfbs.EntitlementSnapshotAddActor(builder, actorOffset)
userfbs.EntitlementSnapshotAddReasonCode(builder, reasonCode)
userfbs.EntitlementSnapshotAddStartsAtMs(builder, nowMS)
userfbs.EntitlementSnapshotAddUpdatedAtMs(builder, nowMS)
entitlementOffset := userfbs.EntitlementSnapshotEnd(builder)
limitCode := builder.CreateString("max_owned_private_games")
limitReasonCode := builder.CreateString("manual_override")
userfbs.ActiveLimitStart(builder)
userfbs.ActiveLimitAddLimitCode(builder, limitCode)
userfbs.ActiveLimitAddValue(builder, overflow)
userfbs.ActiveLimitAddReasonCode(builder, limitReasonCode)
userfbs.ActiveLimitAddActor(builder, actorOffset)
userfbs.ActiveLimitAddAppliedAtMs(builder, nowMS)
limitOffset := userfbs.ActiveLimitEnd(builder)
userfbs.AccountViewStartActiveLimitsVector(builder, 1)
builder.PrependUOffsetT(limitOffset)
limitsVector := builder.EndVector(1)
userID := builder.CreateString("user-123")
email := builder.CreateString("pilot@example.com")
raceName := builder.CreateString("Pilot Nova")
preferredLanguage := builder.CreateString("en")
timeZone := builder.CreateString("Europe/Kaliningrad")
userfbs.AccountViewStart(builder)
userfbs.AccountViewAddUserId(builder, userID)
userfbs.AccountViewAddEmail(builder, email)
userfbs.AccountViewAddRaceName(builder, raceName)
userfbs.AccountViewAddPreferredLanguage(builder, preferredLanguage)
userfbs.AccountViewAddTimeZone(builder, timeZone)
userfbs.AccountViewAddEntitlement(builder, entitlementOffset)
userfbs.AccountViewAddActiveLimits(builder, limitsVector)
userfbs.AccountViewAddCreatedAtMs(builder, nowMS)
userfbs.AccountViewAddUpdatedAtMs(builder, nowMS)
return userfbs.AccountViewEnd(builder)
})
_, err := PayloadToAccountResponse(payload)
if err == nil {
t.Fatal("expected overflow error")
}
if !strings.Contains(err.Error(), "overflows int") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestPayloadToErrorResponseRejectsMissingError(t *testing.T) {
t.Parallel()
builder := flatbuffers.NewBuilder(64)
userfbs.ErrorResponseStart(builder)
offset := userfbs.ErrorResponseEnd(builder)
userfbs.FinishErrorResponseBuffer(builder, offset)
_, err := PayloadToErrorResponse(builder.FinishedBytes())
if err == nil {
t.Fatal("expected error for missing error body")
}
if !strings.Contains(err.Error(), "error is missing") {
t.Fatalf("unexpected error: %v", err)
}
}
func buildAccountResponsePayload(accountBuilder func(*flatbuffers.Builder) flatbuffers.UOffsetT) []byte {
builder := flatbuffers.NewBuilder(256)
accountOffset := accountBuilder(builder)
userfbs.AccountResponseStart(builder)
userfbs.AccountResponseAddAccount(builder, accountOffset)
responseOffset := userfbs.AccountResponseEnd(builder)
userfbs.FinishAccountResponseBuffer(builder, responseOffset)
return builder.FinishedBytes()
}
+103 -32
View File
@@ -1,5 +1,9 @@
# User Service Implementation Plan # User Service Implementation Plan
This plan has been already implemented and stays here for historical reasons.
It should NOT be threated as source of truth for service functionality.
## Planning Principles ## Planning Principles
This plan is aligned with the current repository architecture and is written This plan is aligned with the current repository architecture and is written
@@ -17,7 +21,9 @@ Execution priorities:
- keep the first version storage-agnostic at the domain boundary even if Redis - keep the first version storage-agnostic at the domain boundary even if Redis
is the initial backend is the initial backend
## Stage 01 — Freeze Vocabulary, Contracts, and Cross-Service Ownership ## ~~Stage 01~~ — Freeze Vocabulary, Contracts, and Cross-Service Ownership
Status: implemented.
### Goal ### Goal
@@ -38,8 +44,10 @@ Remove naming ambiguity and freeze the service boundary before implementation.
- workflow and history in `Geo Profile Service` - workflow and history in `Geo Profile Service`
- Freeze the auth-facing internal REST endpoints already reserved by - Freeze the auth-facing internal REST endpoints already reserved by
`Auth / Session Service`. `Auth / Session Service`.
- Freeze the need for create-only registration context on - Freeze the exact create-only registration context shape on
`EnsureUserByEmail`. `EnsureUserByEmail`:
- `preferred_language`
- `time_zone`
### Deliverables ### Deliverables
@@ -58,7 +66,9 @@ Remove naming ambiguity and freeze the service boundary before implementation.
- none yet beyond documentation review - none yet beyond documentation review
## Stage 02 — Define Domain Entities and Redis-Backed Logical State ## ~~Stage 02~~ — Define Domain Entities and Redis-Backed Logical State
Status: implemented.
### Goal ### Goal
@@ -97,7 +107,9 @@ without revisiting core semantics.
- domain validation tests for required fields - domain validation tests for required fields
- tests for effective-state evaluation of active versus expired records - tests for effective-state evaluation of active versus expired records
## Stage 03 — Implement Auth-Facing Resolution, Ensure, Existence, and E-Mail Blocking ## ~~Stage 03~~ — Implement Auth-Facing Resolution, Ensure, Existence, and E-Mail Blocking
Status: implemented.
### Goal ### Goal
@@ -122,6 +134,12 @@ Provide the minimum trusted API needed by `Auth / Session Service`.
- trusted internal REST handlers for auth-facing endpoints - trusted internal REST handlers for auth-facing endpoints
- domain services for resolution and block behavior - domain services for resolution and block behavior
- Redis-backed storage for user existence and blocked-email subjects - Redis-backed storage for user existence and blocked-email subjects
- runnable `cmd/userservice` process using `Gin` and `go-redis/v9`
- durable create path that already materializes:
- opaque `user_id`
- generated `player-<shortid>` race name
- stored `preferred_language` and `time_zone`
- initial free entitlement snapshot
### Exit Criteria ### Exit Criteria
@@ -137,22 +155,26 @@ Provide the minimum trusted API needed by `Auth / Session Service`.
- block by user id on unknown user returns not found - block by user id on unknown user returns not found
- repeated block calls stay idempotent - repeated block calls stay idempotent
## Stage 04 — Add New-User Creation Context from Auth ## ~~Stage 04~~Implement New-User Creation Context from Auth
Status: implemented.
### Goal ### Goal
Support first-login user creation with initial settings captured at confirm Tighten the already-implemented first-login create path with stricter semantic
time. validation.
### Tasks ### Tasks
- Extend `EnsureUserByEmail` contract with create-only registration context: - Preserve the already-frozen create-only `EnsureUserByEmail`
registration context with:
- `preferred_language` - `preferred_language`
- `time_zone` - `time_zone`
- Validate `preferred_language` as BCP 47. - Tighten `preferred_language` validation to BCP 47 semantics.
- Validate `time_zone` as IANA TZ name. - Tighten `time_zone` validation to IANA TZ semantics.
- Generate initial `race_name` in `player-<shortid>` form during creation. - Preserve generated initial `race_name` in `player-<shortid>` form during
- Initialize the newly created user with: creation.
- Preserve the newly created user initialization with:
- free entitlement - free entitlement
- no active sanctions - no active sanctions
- no custom limits - no custom limits
@@ -161,9 +183,9 @@ time.
### Deliverables ### Deliverables
- extended ensure-by-email request model - create-user domain service using the frozen ensure-by-email request model
- create-user domain service
- generated-race-name helper - generated-race-name helper
- create-path validation for `preferred_language` and `time_zone`
### Exit Criteria ### Exit Criteria
@@ -177,7 +199,9 @@ time.
- existing user ensure ignores create-only registration context - existing user ensure ignores create-only registration context
- invalid BCP 47 or IANA inputs are rejected on create path - invalid BCP 47 or IANA inputs are rejected on create path
## Stage 05 — Implement Self-Service Account Read and Split Profile/Settings Mutations ## ~~Stage 05~~ — Implement Self-Service Account Read and Split Profile/Settings Mutations
Status: implemented.
### Goal ### Goal
@@ -220,7 +244,9 @@ Expose the minimal authenticated account surface routed by `Edge Gateway`.
- `UpdateMySettings` validates BCP 47 and IANA values - `UpdateMySettings` validates BCP 47 and IANA values
- active `profile_update_block` denies both update flows - active `profile_update_block` denies both update flows
## Stage 06 — Implement race_name Uniqueness Policy Behind a Dedicated Interface ## ~~Stage 06~~ — Implement race_name Uniqueness Policy Behind a Dedicated Interface
Status: implemented.
### Goal ### Goal
@@ -256,7 +282,9 @@ Keep `race_name` uniqueness strict and replaceable.
- rename releases the old reservation only after the new one is secured - rename releases the old reservation only after the new one is secured
- failed reservation backend causes mutation to fail closed - failed reservation backend causes mutation to fail closed
## Stage 07 — Implement Entitlement History Plus Materialized Current Snapshot ## ~~Stage 07~~ — Implement Entitlement History Plus Materialized Current Snapshot
Status: implemented.
### Goal ### Goal
@@ -298,7 +326,9 @@ Support both auditability and fast synchronous entitlement reads.
- free default is created for new users - free default is created for new users
- extending or revoking access preserves deterministic current-state behavior - extending or revoking access preserves deterministic current-state behavior
## Stage 08 — Implement Sanctions and Limit Records with Active/Effective Evaluation ## ~~Stage 08~~ — Implement Sanctions and Limit Records with Active/Effective Evaluation
Status: implemented.
### Goal ### Goal
@@ -317,11 +347,23 @@ consumers.
- `profile_update_block` - `profile_update_block`
- Freeze v1 limit catalog: - Freeze v1 limit catalog:
- `max_owned_private_games` - `max_owned_private_games`
- `max_active_private_games`
- `max_pending_public_applications` - `max_pending_public_applications`
- `max_pending_private_join_requests`
- `max_pending_private_invites_sent`
- `max_active_game_memberships` - `max_active_game_memberships`
- Freeze supported v1 limit semantics:
- paid effective defaults:
- `max_owned_private_games=3`
- `max_pending_public_applications=10`
- `max_active_game_memberships=10`
- free effective defaults:
- `max_owned_private_games` is omitted
- `max_pending_public_applications=3`
- `max_active_game_memberships=3`
- `max_active_game_memberships` applies only to public games
- `max_pending_public_applications` is the total public-games budget and is
interpreted by `Game Lobby` together with current active public
memberships
- Keep legacy retired limit codes backward-compatible on reads, but reject
them for new trusted limit commands.
- Implement active/effective evaluation with current time. - Implement active/effective evaluation with current time.
- Implement trusted explicit commands to apply/remove sanctions and set/remove - Implement trusted explicit commands to apply/remove sanctions and set/remove
limits. limits.
@@ -343,9 +385,14 @@ consumers.
- active sanctions appear in account reads - active sanctions appear in account reads
- expired sanctions and limits stop affecting effective state - expired sanctions and limits stop affecting effective state
- retired legacy limit records are ignored during reads and effective
evaluation
- retired legacy limit codes are rejected by trusted limit commands
- applying and removing sanctions/limits is idempotent where appropriate - applying and removing sanctions/limits is idempotent where appropriate
## Stage 09 — Implement Lobby Eligibility Snapshot API ## ~~Stage 09~~ — Implement Lobby Eligibility Snapshot API
Status: implemented.
### Goal ### Goal
@@ -361,6 +408,16 @@ user-level access decisions.
- active lobby-relevant sanctions - active lobby-relevant sanctions
- effective lobby-relevant limits - effective lobby-relevant limits
- derived booleans for lobby decisions - derived booleans for lobby decisions
- Freeze the lobby-facing effective limit catalog:
- paid users receive `max_owned_private_games=3`,
`max_pending_public_applications=10`, and
`max_active_game_memberships=10`
- free users omit `max_owned_private_games` and receive
`max_pending_public_applications=3` and
`max_active_game_memberships=3`
- `max_pending_public_applications` remains the total public-games budget
consumed together with current active public memberships inside
`Game Lobby`
- Keep the response read-optimized so lobby does not need multiple dependent - Keep the response read-optimized so lobby does not need multiple dependent
calls back into `User Service`. calls back into `User Service`.
- Define deterministic not-found behavior. - Define deterministic not-found behavior.
@@ -381,8 +438,12 @@ user-level access decisions.
- lobby eligibility snapshot reflects paid status, sanctions, and limits - lobby eligibility snapshot reflects paid status, sanctions, and limits
- unknown user returns stable not-found behavior - unknown user returns stable not-found behavior
- derived booleans remain consistent with raw effective state - derived booleans remain consistent with raw effective state
- free and paid snapshots materialize the reduced three-code effective limit
catalog correctly
## Stage 10 — Implement Geo declared_country Sync Command ## ~~Stage 10~~ — Implement Geo declared_country Sync Command
Status: implemented.
### Goal ### Goal
@@ -416,7 +477,9 @@ Support the current-country denormalization path owned by `Geo Profile Service`.
- invalid country codes are rejected - invalid country codes are rejected
- country sync emits the correct auxiliary event after commit - country sync emits the correct auxiliary event after commit
## Stage 11 — Implement Admin Lookup, Filtered Listing, and Explicit Trusted Mutations ## ~~Stage 11~~ — Implement Admin Lookup, Filtered Listing, and Explicit Trusted Mutations
Status: implemented.
### Goal ### Goal
@@ -462,7 +525,9 @@ operations.
- exact lookups by `user_id`, email, and `race_name` resolve the correct user - exact lookups by `user_id`, email, and `race_name` resolve the correct user
- every trusted mutation preserves actor and reason metadata - every trusted mutation preserves actor and reason metadata
## Stage 12 — Add Per-Domain-Area Async Events and Observability ## ~~Stage 12~~ — Add Per-Domain-Area Async Events and Observability
Status: implemented.
### Goal ### Goal
@@ -505,7 +570,9 @@ truth.
- event payloads include minimum required metadata - event payloads include minimum required metadata
- observability hooks do not change business behavior - observability hooks do not change business behavior
## Stage 13 — Add Contract Tests Against Auth, Lobby, and Geo Expectations ## ~~Stage 13~~ — Add Contract Tests Against Auth, Lobby, and Geo Expectations
Status: implemented.
### Goal ### Goal
@@ -542,7 +609,9 @@ must satisfy for other services.
- lobby eligibility snapshot reflects paid status, sanctions, and limits - lobby eligibility snapshot reflects paid status, sanctions, and limits
- geo country sync changes only current `declared_country` - geo country sync changes only current `declared_country`
## Stage 14 — Add Rollout Notes for Gateway/Auth/OpenAPI Updates and Shared geoip ## ~~Stage 14~~ — Add Rollout Notes for Gateway/Auth/OpenAPI Updates and Shared geoip
Status: implemented.
### Goal ### Goal
@@ -551,11 +620,13 @@ its intended end-to-end form.
### Tasks ### Tasks
- Document the required `gateway` public `confirm-email-code` addition of - Document the required `gateway` public `confirm-email-code` dependency on
`time_zone`.
- Document the required `authsession` public OpenAPI preservation of the same
`time_zone` requirement.
- Document that the frozen `authsession -> user` ensure contract requires
create-only `registration_context` with `preferred_language` and
`time_zone`. `time_zone`.
- Document the required `authsession` public OpenAPI mirror change.
- Document the required `authsession -> user` ensure contract extension for
create-only registration context.
- Document the required shared `pkg/geoip` package for gateway and geo. - Document the required shared `pkg/geoip` package for gateway and geo.
- Document README follow-up updates needed in `gateway` and `geoprofile`. - Document README follow-up updates needed in `gateway` and `geoprofile`.
- Define rollout order so the cross-service contract changes do not land in an - Define rollout order so the cross-service contract changes do not land in an
+267 -689
View File
File diff suppressed because it is too large Load Diff
+45
View File
@@ -0,0 +1,45 @@
package main
import (
"context"
"fmt"
"os"
"os/signal"
"syscall"
"galaxy/user/internal/app"
"galaxy/user/internal/config"
"galaxy/user/internal/logging"
)
func main() {
if err := run(); err != nil {
_, _ = fmt.Fprintf(os.Stderr, "userservice: %v\n", err)
os.Exit(1)
}
}
func run() error {
cfg, err := config.LoadFromEnv()
if err != nil {
return err
}
logger, err := logging.New(cfg.Logging.Level)
if err != nil {
return err
}
rootCtx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
runtime, err := app.NewRuntime(rootCtx, cfg, logger)
if err != nil {
return err
}
defer func() {
_ = runtime.Close()
}()
return runtime.Run(rootCtx)
}
+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.
+89
View File
@@ -1,3 +1,92 @@
module galaxy/user module galaxy/user
go 1.26.1 go 1.26.1
require (
github.com/alicebob/miniredis/v2 v2.37.0
github.com/disciplinedware/go-confusables v0.1.1
github.com/getkin/kin-openapi v0.135.0
github.com/gin-gonic/gin v1.12.0
github.com/prometheus/client_golang v1.23.2
github.com/redis/go-redis/v9 v9.18.0
github.com/stretchr/testify v1.11.1
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.68.0
go.opentelemetry.io/otel v1.43.0
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.43.0
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0
go.opentelemetry.io/otel/exporters/prometheus v0.65.0
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0
go.opentelemetry.io/otel/metric v1.43.0
go.opentelemetry.io/otel/sdk v1.43.0
go.opentelemetry.io/otel/sdk/metric v1.43.0
go.opentelemetry.io/otel/trace v1.43.0
golang.org/x/text v0.35.0
)
require (
github.com/beorn7/perks v1.0.1 // indirect
github.com/bytedance/gopkg v0.1.4 // indirect
github.com/bytedance/sonic v1.15.0 // indirect
github.com/bytedance/sonic/loader v0.5.1 // indirect
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
github.com/gin-contrib/sse v1.1.1 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-openapi/jsonpointer v0.21.0 // indirect
github.com/go-openapi/swag v0.23.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.30.2 // indirect
github.com/goccy/go-json v0.10.6 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/oasdiff/yaml v0.0.9 // indirect
github.com/oasdiff/yaml3 v0.0.9 // indirect
github.com/pelletier/go-toml/v2 v2.3.0 // indirect
github.com/perimeterx/marshmallow v1.1.5 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.67.5 // indirect
github.com/prometheus/otlptranslator v1.0.0 // indirect
github.com/prometheus/procfs v0.20.1 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.59.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect
github.com/woodsbury/decimal128 v1.3.0 // indirect
github.com/yuin/gopher-lua v1.1.1 // indirect
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 // indirect
go.opentelemetry.io/proto/otlp v1.10.0 // indirect
go.uber.org/atomic v1.11.0 // indirect
go.yaml.in/yaml/v2 v2.4.4 // indirect
golang.org/x/arch v0.25.0 // indirect
golang.org/x/crypto v0.49.0 // indirect
golang.org/x/net v0.52.0 // indirect
golang.org/x/sys v0.42.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect
google.golang.org/grpc v1.80.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+218
View File
@@ -0,0 +1,218 @@
github.com/alicebob/miniredis/v2 v2.37.0 h1:RheObYW32G1aiJIj81XVt78ZHJpHonHLHW7OLIshq68=
github.com/alicebob/miniredis/v2 v2.37.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM=
github.com/bytedance/gopkg v0.1.4/go.mod h1:v1zWfPm21Fb+OsyXN2VAHdL6TBb2L88anLQgdyje6R4=
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
github.com/bytedance/sonic/loader v0.5.1 h1:Ygpfa9zwRCCKSlrp5bBP/b/Xzc3VxsAW+5NIYXrOOpI=
github.com/bytedance/sonic/loader v0.5.1/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/disciplinedware/go-confusables v0.1.1 h1:l/JVOsdrEDHo7nvL+tQfRO1F14UyuuDm1Uvv3Nqmq9Q=
github.com/disciplinedware/go-confusables v0.1.1/go.mod h1:2hAXIAtpSqx+tMKdCzgRNv4J/kmz/oGfSHTBGJjVgfc=
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/getkin/kin-openapi v0.135.0 h1:751SjYfbiwqukYuVjwYEIKNfrSwS5YpA7DZnKSwQgtg=
github.com/getkin/kin-openapi v0.135.0/go.mod h1:6dd5FJl6RdX4usBtFBaQhk9q62Yb2J0Mk5IhUO/QqFI=
github.com/gin-contrib/sse v1.1.1 h1:uGYpNwTacv5R68bSGMapo62iLTRa9l5zxGCps4hK6ko=
github.com/gin-contrib/sse v1.1.1/go.mod h1:QXzuVkA0YO7o/gun03UI1Q+FTI8ZV/n5t03kIQAI89s=
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ=
github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY=
github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.30.2 h1:JiFIMtSSHb2/XBUbWM4i/MpeQm9ZK2xqPNk8vgvu5JQ=
github.com/go-playground/validator/v10 v10.30.2/go.mod h1:mAf2pIOVXjTEBrwUMGKkCWKKPs9NheYGabeB04txQSc=
github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM=
github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw=
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/oasdiff/yaml v0.0.9 h1:zQOvd2UKoozsSsAknnWoDJlSK4lC0mpmjfDsfqNwX48=
github.com/oasdiff/yaml v0.0.9/go.mod h1:8lvhgJG4xiKPj3HN5lDow4jZHPlx1i7dIwzkdAo6oAM=
github.com/oasdiff/yaml3 v0.0.9 h1:rWPrKccrdUm8J0F3sGuU+fuh9+1K/RdJlWF7O/9yw2g=
github.com/oasdiff/yaml3 v0.0.9/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o=
github.com/pelletier/go-toml/v2 v2.3.0 h1:k59bC/lIZREW0/iVaQR8nDHxVq8OVlIzYCOJf421CaM=
github.com/pelletier/go-toml/v2 v2.3.0/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s=
github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=
github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
github.com/prometheus/otlptranslator v1.0.0 h1:s0LJW/iN9dkIH+EnhiD3BlkkP5QVIUVEoIwkU+A6qos=
github.com/prometheus/otlptranslator v1.0.0/go.mod h1:vRYWnXvI6aWGpsdY/mOT/cbeVRBlPWtBNDb7kGR3uKM=
github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc=
github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs=
github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/woodsbury/decimal128 v1.3.0 h1:8pffMNWIlC0O5vbyHWFZAt5yWvWcrHA+3ovIIjVWss0=
github.com/woodsbury/decimal128 v1.3.0/go.mod h1:C5UTmyTjW3JftjUFzOVhC20BEQa2a4ZKOB5I6Zjb+ds=
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.68.0 h1:5FXSL2s6afUC1bzNzl1iedZZ8yqR7GOhbCoEXtyeK6Q=
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.68.0/go.mod h1:MdHW7tLtkeGJnR4TyOrnd5D0zUGZQB1l84uHCe8hRpE=
go.opentelemetry.io/contrib/propagators/b3 v1.43.0 h1:CETqV3QLLPTy5yNrqyMr41VnAOOD4lsRved7n4QG00A=
go.opentelemetry.io/contrib/propagators/b3 v1.43.0/go.mod h1:Q4mCiCdziYzpNR0g+6UqVotAlCDZdzz6L8jwY4knOrw=
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.43.0 h1:8UQVDcZxOJLtX6gxtDt3vY2WTgvZqMQRzjsqiIHQdkc=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.43.0/go.mod h1:2lmweYCiHYpEjQ/lSJBYhj9jP1zvCvQW4BqL9dnT7FQ=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0 h1:w1K+pCJoPpQifuVpsKamUdn9U0zM3xUziVOqsGksUrY=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0/go.mod h1:HBy4BjzgVE8139ieRI75oXm3EcDN+6GhD88JT1Kjvxg=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 h1:88Y4s2C8oTui1LGM6bTWkw0ICGcOLCAI5l6zsD1j20k=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0/go.mod h1:Vl1/iaggsuRlrHf/hfPJPvVag77kKyvrLeD10kpMl+A=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0 h1:RAE+JPfvEmvy+0LzyUA25/SGawPwIUbZ6u0Wug54sLc=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0/go.mod h1:AGmbycVGEsRx9mXMZ75CsOyhSP6MFIcj/6dnG+vhVjk=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 h1:3iZJKlCZufyRzPzlQhUIWVmfltrXuGyfjREgGP3UUjc=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0/go.mod h1:/G+nUPfhq2e+qiXMGxMwumDrP5jtzU+mWN7/sjT2rak=
go.opentelemetry.io/otel/exporters/prometheus v0.65.0 h1:jOveH/b4lU9HT7y+Gfamf18BqlOuz2PWEvs8yM7Q6XE=
go.opentelemetry.io/otel/exporters/prometheus v0.65.0/go.mod h1:i1P8pcumauPtUI4YNopea1dhzEMuEqWP1xoUZDylLHo=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0 h1:TC+BewnDpeiAmcscXbGMfxkO+mwYUwE/VySwvw88PfA=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0/go.mod h1:J/ZyF4vfPwsSr9xJSPyQ4LqtcTPULFR64KwTikGLe+A=
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0 h1:mS47AX77OtFfKG4vtp+84kuGSFZHTyxtXIN269vChY0=
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0/go.mod h1:PJnsC41lAGncJlPUniSwM81gc80GkgWJWr3cu2nKEtU=
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g=
go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
golang.org/x/arch v0.25.0 h1:qnk6Ksugpi5Bz32947rkUgDt9/s5qvqDPl/gBKdMJLE=
golang.org/x/arch v0.25.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA=
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:7QBABkRtR8z+TEnmXTqIqwJLlzrZKVfAUm7tY3yGv0M=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:m8qni9SQFH0tJc1X0vmnpw/0t+AImlSvp30sEupozUg=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM=
google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+13
View File
@@ -0,0 +1,13 @@
// Package local provides small in-process runtime adapters used by the user
// service process.
package local
import "time"
// Clock returns the current wall-clock time.
type Clock struct{}
// Now returns the current time.
func (Clock) Now() time.Time {
return time.Now()
}
@@ -0,0 +1,29 @@
package local
import (
"context"
"fmt"
"galaxy/user/internal/ports"
)
// NoopDeclaredCountryChangedPublisher validates and discards auxiliary
// declared-country change events.
type NoopDeclaredCountryChangedPublisher struct{}
// PublishDeclaredCountryChanged validates event and discards it.
func (NoopDeclaredCountryChangedPublisher) PublishDeclaredCountryChanged(
ctx context.Context,
event ports.DeclaredCountryChangedEvent,
) error {
if ctx == nil {
return fmt.Errorf("publish declared-country changed event: nil context")
}
if err := ctx.Err(); err != nil {
return err
}
return event.Validate()
}
var _ ports.DeclaredCountryChangedPublisher = NoopDeclaredCountryChangedPublisher{}
@@ -0,0 +1,62 @@
package local
import (
"context"
"fmt"
"galaxy/user/internal/ports"
)
// NoopDomainEventPublisher validates and discards auxiliary user-domain
// events.
type NoopDomainEventPublisher struct{}
// PublishProfileChanged validates event and discards it.
func (NoopDomainEventPublisher) PublishProfileChanged(ctx context.Context, event ports.ProfileChangedEvent) error {
return validateNoopPublish(ctx, "publish profile changed event", event.Validate)
}
// PublishSettingsChanged validates event and discards it.
func (NoopDomainEventPublisher) PublishSettingsChanged(ctx context.Context, event ports.SettingsChangedEvent) error {
return validateNoopPublish(ctx, "publish settings changed event", event.Validate)
}
// PublishEntitlementChanged validates event and discards it.
func (NoopDomainEventPublisher) PublishEntitlementChanged(ctx context.Context, event ports.EntitlementChangedEvent) error {
return validateNoopPublish(ctx, "publish entitlement changed event", event.Validate)
}
// PublishSanctionChanged validates event and discards it.
func (NoopDomainEventPublisher) PublishSanctionChanged(ctx context.Context, event ports.SanctionChangedEvent) error {
return validateNoopPublish(ctx, "publish sanction changed event", event.Validate)
}
// PublishLimitChanged validates event and discards it.
func (NoopDomainEventPublisher) PublishLimitChanged(ctx context.Context, event ports.LimitChangedEvent) error {
return validateNoopPublish(ctx, "publish limit changed event", event.Validate)
}
// PublishDeclaredCountryChanged validates event and discards it.
func (NoopDomainEventPublisher) PublishDeclaredCountryChanged(ctx context.Context, event ports.DeclaredCountryChangedEvent) error {
return validateNoopPublish(ctx, "publish declared-country changed event", event.Validate)
}
func validateNoopPublish(ctx context.Context, operation string, validate func() error) error {
if ctx == nil {
return fmt.Errorf("%s: nil context", operation)
}
if err := ctx.Err(); err != nil {
return err
}
return validate()
}
var (
_ ports.ProfileChangedPublisher = NoopDomainEventPublisher{}
_ ports.SettingsChangedPublisher = NoopDomainEventPublisher{}
_ ports.EntitlementChangedPublisher = NoopDomainEventPublisher{}
_ ports.SanctionChangedPublisher = NoopDomainEventPublisher{}
_ ports.LimitChangedPublisher = NoopDomainEventPublisher{}
_ ports.DeclaredCountryChangedPublisher = NoopDomainEventPublisher{}
)
@@ -0,0 +1,105 @@
package local
import (
"crypto/rand"
"encoding/base32"
"fmt"
"strings"
"galaxy/user/internal/domain/common"
"galaxy/user/internal/domain/entitlement"
"galaxy/user/internal/domain/policy"
)
var base32NoPadding = base32.StdEncoding.WithPadding(base32.NoPadding)
// IDGenerator creates opaque stable user identifiers and generated initial
// race names.
type IDGenerator struct{}
// NewUserID returns one newly generated opaque user identifier.
func (IDGenerator) NewUserID() (common.UserID, error) {
token, err := randomToken(10)
if err != nil {
return "", fmt.Errorf("generate user id: %w", err)
}
userID := common.UserID("user-" + token)
if err := userID.Validate(); err != nil {
return "", fmt.Errorf("generate user id: %w", err)
}
return userID, nil
}
// NewInitialRaceName returns one generated race name in the `player-<shortid>`
// form.
func (IDGenerator) NewInitialRaceName() (common.RaceName, error) {
token, err := randomToken(5)
if err != nil {
return "", fmt.Errorf("generate initial race name: %w", err)
}
raceName := common.RaceName("player-" + token)
if err := raceName.Validate(); err != nil {
return "", fmt.Errorf("generate initial race name: %w", err)
}
return raceName, nil
}
// NewEntitlementRecordID returns one generated entitlement history record
// identifier.
func (IDGenerator) NewEntitlementRecordID() (entitlement.EntitlementRecordID, error) {
token, err := randomToken(10)
if err != nil {
return "", fmt.Errorf("generate entitlement record id: %w", err)
}
recordID := entitlement.EntitlementRecordID("entitlement-" + token)
if err := recordID.Validate(); err != nil {
return "", fmt.Errorf("generate entitlement record id: %w", err)
}
return recordID, nil
}
// NewSanctionRecordID returns one generated sanction history record
// identifier.
func (IDGenerator) NewSanctionRecordID() (policy.SanctionRecordID, error) {
token, err := randomToken(10)
if err != nil {
return "", fmt.Errorf("generate sanction record id: %w", err)
}
recordID := policy.SanctionRecordID("sanction-" + token)
if err := recordID.Validate(); err != nil {
return "", fmt.Errorf("generate sanction record id: %w", err)
}
return recordID, nil
}
// NewLimitRecordID returns one generated limit history record identifier.
func (IDGenerator) NewLimitRecordID() (policy.LimitRecordID, error) {
token, err := randomToken(10)
if err != nil {
return "", fmt.Errorf("generate limit record id: %w", err)
}
recordID := policy.LimitRecordID("limit-" + token)
if err := recordID.Validate(); err != nil {
return "", fmt.Errorf("generate limit record id: %w", err)
}
return recordID, nil
}
func randomToken(size int) (string, error) {
buffer := make([]byte, size)
if _, err := rand.Read(buffer); err != nil {
return "", err
}
return strings.ToLower(base32NoPadding.EncodeToString(buffer)), nil
}
@@ -0,0 +1,65 @@
package local
import (
"fmt"
"strings"
"galaxy/user/internal/domain/account"
"galaxy/user/internal/domain/common"
"galaxy/user/internal/ports"
confusables "github.com/disciplinedware/go-confusables"
"golang.org/x/text/cases"
)
type confusableSkeletoner interface {
Skeleton(string) string
}
type raceNamePolicy struct {
caseFolder cases.Caser
skeletoner confusableSkeletoner
}
var raceNameAntiFraudReplacer = strings.NewReplacer(
"1", "i",
"0", "o",
"8", "b",
)
// NewRaceNamePolicy returns the local Stage 06 race-name canonicalization
// policy backed by Unicode case folding, explicit ASCII anti-fraud mappings,
// and a TR39 confusable skeleton.
func NewRaceNamePolicy() (ports.RaceNamePolicy, error) {
policy := &raceNamePolicy{
caseFolder: cases.Fold(),
skeletoner: confusables.Default(),
}
if policy.skeletoner == nil {
return nil, fmt.Errorf("new race-name policy: nil confusable skeletoner")
}
return policy, nil
}
// CanonicalKey returns the stable uniqueness key for raceName.
func (policy *raceNamePolicy) CanonicalKey(raceName common.RaceName) (account.RaceNameCanonicalKey, error) {
switch {
case policy == nil:
return "", fmt.Errorf("canonicalize race name: nil policy")
case policy.skeletoner == nil:
return "", fmt.Errorf("canonicalize race name: nil confusable skeletoner")
}
if err := raceName.Validate(); err != nil {
return "", fmt.Errorf("canonicalize race name: %w", err)
}
folded := policy.caseFolder.String(raceName.String())
antiFraudMapped := raceNameAntiFraudReplacer.Replace(folded)
key := account.RaceNameCanonicalKey(policy.skeletoner.Skeleton(antiFraudMapped))
if err := key.Validate(); err != nil {
return "", fmt.Errorf("canonicalize race name: %w", err)
}
return key, nil
}
@@ -0,0 +1,72 @@
package local
import (
"testing"
"time"
"galaxy/user/internal/domain/account"
"galaxy/user/internal/domain/common"
"galaxy/user/internal/service/shared"
"github.com/stretchr/testify/require"
)
func TestRaceNamePolicyCanonicalKey(t *testing.T) {
t.Parallel()
policy, err := NewRaceNamePolicy()
require.NoError(t, err)
tests := []struct {
name string
left common.RaceName
right common.RaceName
}{
{
name: "case insensitive collision",
left: common.RaceName("Pilot Nova"),
right: common.RaceName("pilot nova"),
},
{
name: "ascii anti fraud collision",
left: common.RaceName("Pilot Nova"),
right: common.RaceName("P1lot N0va"),
},
{
name: "unicode confusable collision",
left: common.RaceName("paypal"),
right: common.RaceName("раураl"),
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
leftKey, err := policy.CanonicalKey(tt.left)
require.NoError(t, err)
rightKey, err := policy.CanonicalKey(tt.right)
require.NoError(t, err)
require.Equal(t, rightKey, leftKey)
})
}
}
func TestBuildRaceNameReservationPreservesOriginalDisplayValue(t *testing.T) {
t.Parallel()
policy, err := NewRaceNamePolicy()
require.NoError(t, err)
record, err := shared.BuildRaceNameReservation(
policy,
common.UserID("user-123"),
common.RaceName("P1lot Nova"),
time.Unix(1_775_240_000, 0).UTC(),
)
require.NoError(t, err)
require.Equal(t, common.RaceName("P1lot Nova"), record.RaceName)
require.NotEqual(t, account.RaceNameCanonicalKey(""), record.CanonicalKey)
}
@@ -0,0 +1,311 @@
// Package domainevents implements Redis Stream-backed auxiliary user-domain
// event publishers.
package domainevents
import (
"context"
"crypto/tls"
"errors"
"fmt"
"strconv"
"strings"
"time"
"galaxy/user/internal/ports"
"github.com/redis/go-redis/v9"
"go.opentelemetry.io/otel/trace"
)
// Config configures one Redis-backed user domain-event publisher.
type Config struct {
// Addr is the Redis network address in host:port form.
Addr string
// Username is the optional Redis ACL username.
Username string
// Password is the optional Redis ACL password.
Password string
// DB is the Redis logical database index.
DB int
// TLSEnabled enables TLS with a conservative minimum protocol version.
TLSEnabled bool
// Stream identifies the Redis Stream key used for domain events.
Stream string
// StreamMaxLen bounds the stream with approximate trimming via
// `XADD MAXLEN ~`.
StreamMaxLen int64
// OperationTimeout bounds each Redis round trip performed by the adapter.
OperationTimeout time.Duration
}
// Publisher publishes auxiliary user-domain events into one Redis Stream.
type Publisher struct {
client *redis.Client
stream string
streamMaxLen int64
operationTimeout time.Duration
}
// New constructs a Redis-backed domain-event publisher from cfg.
func New(cfg Config) (*Publisher, error) {
switch {
case strings.TrimSpace(cfg.Addr) == "":
return nil, errors.New("new redis domain-event publisher: redis addr must not be empty")
case cfg.DB < 0:
return nil, errors.New("new redis domain-event publisher: redis db must not be negative")
case strings.TrimSpace(cfg.Stream) == "":
return nil, errors.New("new redis domain-event publisher: stream must not be empty")
case cfg.StreamMaxLen <= 0:
return nil, errors.New("new redis domain-event publisher: stream max len must be positive")
case cfg.OperationTimeout <= 0:
return nil, errors.New("new redis domain-event publisher: operation timeout must be positive")
}
options := &redis.Options{
Addr: cfg.Addr,
Username: cfg.Username,
Password: cfg.Password,
DB: cfg.DB,
Protocol: 2,
DisableIdentity: true,
}
if cfg.TLSEnabled {
options.TLSConfig = &tls.Config{MinVersion: tls.VersionTLS12}
}
return &Publisher{
client: redis.NewClient(options),
stream: cfg.Stream,
streamMaxLen: cfg.StreamMaxLen,
operationTimeout: cfg.OperationTimeout,
}, nil
}
// Close releases the underlying Redis client resources.
func (publisher *Publisher) Close() error {
if publisher == nil || publisher.client == nil {
return nil
}
return publisher.client.Close()
}
// Ping verifies that the configured Redis backend is reachable within the
// adapter operation timeout budget.
func (publisher *Publisher) Ping(ctx context.Context) error {
operationCtx, cancel, err := publisher.operationContext(ctx, "ping redis domain-event publisher")
if err != nil {
return err
}
defer cancel()
if err := publisher.client.Ping(operationCtx).Err(); err != nil {
return fmt.Errorf("ping redis domain-event publisher: %w", err)
}
return nil
}
// PublishProfileChanged publishes one committed profile-change event.
func (publisher *Publisher) PublishProfileChanged(ctx context.Context, event ports.ProfileChangedEvent) error {
if err := event.Validate(); err != nil {
return fmt.Errorf("publish profile changed event: %w", err)
}
values := buildEnvelope(ports.ProfileChangedEventType, event.UserID.String(), event.OccurredAt, event.Source.String(), traceIDFromContext(ctx, event.TraceID))
values["operation"] = string(event.Operation)
values["race_name"] = event.RaceName.String()
return publisher.publish(ctx, "publish profile changed event", values)
}
// PublishSettingsChanged publishes one committed settings-change event.
func (publisher *Publisher) PublishSettingsChanged(ctx context.Context, event ports.SettingsChangedEvent) error {
if err := event.Validate(); err != nil {
return fmt.Errorf("publish settings changed event: %w", err)
}
values := buildEnvelope(ports.SettingsChangedEventType, event.UserID.String(), event.OccurredAt, event.Source.String(), traceIDFromContext(ctx, event.TraceID))
values["operation"] = string(event.Operation)
values["preferred_language"] = event.PreferredLanguage.String()
values["time_zone"] = event.TimeZone.String()
return publisher.publish(ctx, "publish settings changed event", values)
}
// PublishEntitlementChanged publishes one committed entitlement-change event.
func (publisher *Publisher) PublishEntitlementChanged(ctx context.Context, event ports.EntitlementChangedEvent) error {
if err := event.Validate(); err != nil {
return fmt.Errorf("publish entitlement changed event: %w", err)
}
values := buildEnvelope(ports.EntitlementChangedEventType, event.UserID.String(), event.OccurredAt, event.Source.String(), traceIDFromContext(ctx, event.TraceID))
values["operation"] = string(event.Operation)
values["plan_code"] = string(event.PlanCode)
values["is_paid"] = strconv.FormatBool(event.IsPaid)
values["starts_at_ms"] = strconv.FormatInt(event.StartsAt.UTC().UnixMilli(), 10)
values["reason_code"] = event.ReasonCode.String()
values["actor_type"] = event.Actor.Type.String()
values["updated_at_ms"] = strconv.FormatInt(event.UpdatedAt.UTC().UnixMilli(), 10)
if !event.Actor.ID.IsZero() {
values["actor_id"] = event.Actor.ID.String()
}
if event.EndsAt != nil {
values["ends_at_ms"] = strconv.FormatInt(event.EndsAt.UTC().UnixMilli(), 10)
}
return publisher.publish(ctx, "publish entitlement changed event", values)
}
// PublishSanctionChanged publishes one committed sanction-change event.
func (publisher *Publisher) PublishSanctionChanged(ctx context.Context, event ports.SanctionChangedEvent) error {
if err := event.Validate(); err != nil {
return fmt.Errorf("publish sanction changed event: %w", err)
}
values := buildEnvelope(ports.SanctionChangedEventType, event.UserID.String(), event.OccurredAt, event.Source.String(), traceIDFromContext(ctx, event.TraceID))
values["operation"] = string(event.Operation)
values["sanction_code"] = string(event.SanctionCode)
values["scope"] = event.Scope.String()
values["reason_code"] = event.ReasonCode.String()
values["actor_type"] = event.Actor.Type.String()
values["applied_at_ms"] = strconv.FormatInt(event.AppliedAt.UTC().UnixMilli(), 10)
if !event.Actor.ID.IsZero() {
values["actor_id"] = event.Actor.ID.String()
}
if event.ExpiresAt != nil {
values["expires_at_ms"] = strconv.FormatInt(event.ExpiresAt.UTC().UnixMilli(), 10)
}
if event.RemovedAt != nil {
values["removed_at_ms"] = strconv.FormatInt(event.RemovedAt.UTC().UnixMilli(), 10)
}
return publisher.publish(ctx, "publish sanction changed event", values)
}
// PublishLimitChanged publishes one committed limit-change event.
func (publisher *Publisher) PublishLimitChanged(ctx context.Context, event ports.LimitChangedEvent) error {
if err := event.Validate(); err != nil {
return fmt.Errorf("publish limit changed event: %w", err)
}
values := buildEnvelope(ports.LimitChangedEventType, event.UserID.String(), event.OccurredAt, event.Source.String(), traceIDFromContext(ctx, event.TraceID))
values["operation"] = string(event.Operation)
values["limit_code"] = string(event.LimitCode)
values["reason_code"] = event.ReasonCode.String()
values["actor_type"] = event.Actor.Type.String()
values["applied_at_ms"] = strconv.FormatInt(event.AppliedAt.UTC().UnixMilli(), 10)
if event.Value != nil {
values["value"] = strconv.Itoa(*event.Value)
}
if !event.Actor.ID.IsZero() {
values["actor_id"] = event.Actor.ID.String()
}
if event.ExpiresAt != nil {
values["expires_at_ms"] = strconv.FormatInt(event.ExpiresAt.UTC().UnixMilli(), 10)
}
if event.RemovedAt != nil {
values["removed_at_ms"] = strconv.FormatInt(event.RemovedAt.UTC().UnixMilli(), 10)
}
return publisher.publish(ctx, "publish limit changed event", values)
}
// PublishDeclaredCountryChanged publishes one committed declared-country change
// event.
func (publisher *Publisher) PublishDeclaredCountryChanged(ctx context.Context, event ports.DeclaredCountryChangedEvent) error {
if err := event.Validate(); err != nil {
return fmt.Errorf("publish declared-country changed event: %w", err)
}
values := buildEnvelope(
ports.DeclaredCountryChangedEventType,
event.UserID.String(),
event.UpdatedAt,
event.Source.String(),
traceIDFromContext(ctx, event.TraceID),
)
values["declared_country"] = event.DeclaredCountry.String()
values["updated_at_ms"] = strconv.FormatInt(event.UpdatedAt.UTC().UnixMilli(), 10)
return publisher.publish(ctx, "publish declared-country changed event", values)
}
func (publisher *Publisher) publish(ctx context.Context, operation string, values map[string]any) error {
operationCtx, cancel, err := publisher.operationContext(ctx, operation)
if err != nil {
return err
}
defer cancel()
if err := publisher.client.XAdd(operationCtx, &redis.XAddArgs{
Stream: publisher.stream,
MaxLen: publisher.streamMaxLen,
Approx: true,
Values: values,
}).Err(); err != nil {
return fmt.Errorf("%s: %w", operation, err)
}
return nil
}
func (publisher *Publisher) operationContext(ctx context.Context, operation string) (context.Context, context.CancelFunc, error) {
if publisher == nil || publisher.client == nil {
return nil, nil, fmt.Errorf("%s: nil publisher", operation)
}
if ctx == nil {
return nil, nil, fmt.Errorf("%s: nil context", operation)
}
operationCtx, cancel := context.WithTimeout(ctx, publisher.operationTimeout)
return operationCtx, cancel, nil
}
func buildEnvelope(eventType string, userID string, occurredAt time.Time, source string, traceID string) map[string]any {
values := map[string]any{
"event_type": eventType,
"user_id": userID,
"occurred_at_ms": strconv.FormatInt(occurredAt.UTC().UnixMilli(), 10),
"source": source,
}
if traceID != "" {
values["trace_id"] = traceID
}
return values
}
func traceIDFromContext(ctx context.Context, fallback string) string {
if strings.TrimSpace(fallback) != "" {
return fallback
}
if ctx == nil {
return ""
}
spanContext := trace.SpanContextFromContext(ctx)
if !spanContext.IsValid() {
return ""
}
return spanContext.TraceID().String()
}
var (
_ interface{ Close() error } = (*Publisher)(nil)
_ interface{ Ping(context.Context) error } = (*Publisher)(nil)
_ ports.ProfileChangedPublisher = (*Publisher)(nil)
_ ports.SettingsChangedPublisher = (*Publisher)(nil)
_ ports.EntitlementChangedPublisher = (*Publisher)(nil)
_ ports.SanctionChangedPublisher = (*Publisher)(nil)
_ ports.LimitChangedPublisher = (*Publisher)(nil)
_ ports.DeclaredCountryChangedPublisher = (*Publisher)(nil)
)
@@ -0,0 +1,90 @@
package domainevents
import (
"context"
"strconv"
"testing"
"time"
"galaxy/user/internal/domain/common"
"galaxy/user/internal/ports"
"github.com/alicebob/miniredis/v2"
"github.com/stretchr/testify/require"
)
func TestPublisherPublishesFlatRedisStreamEntry(t *testing.T) {
t.Parallel()
server := miniredis.RunT(t)
publisher, err := New(Config{
Addr: server.Addr(),
Stream: "user:test_events",
StreamMaxLen: 5,
OperationTimeout: time.Second,
})
require.NoError(t, err)
occurredAt := time.Unix(1_775_240_000, 0).UTC()
err = publisher.PublishProfileChanged(context.Background(), ports.ProfileChangedEvent{
UserID: common.UserID("user-123"),
OccurredAt: occurredAt,
Source: common.Source("gateway_self_service"),
TraceID: "4bf92f3577b34da6a3ce929d0e0e4736",
Operation: ports.ProfileChangedOperationUpdated,
RaceName: common.RaceName("Nova Prime"),
})
require.NoError(t, err)
entries, err := publisher.client.XRange(context.Background(), publisher.stream, "-", "+").Result()
require.NoError(t, err)
require.Len(t, entries, 1)
require.Equal(t, ports.ProfileChangedEventType, entries[0].Values["event_type"])
require.Equal(t, "user-123", entries[0].Values["user_id"])
require.Equal(t, strconv.FormatInt(occurredAt.UnixMilli(), 10), entries[0].Values["occurred_at_ms"])
require.Equal(t, "gateway_self_service", entries[0].Values["source"])
require.Equal(t, "4bf92f3577b34da6a3ce929d0e0e4736", entries[0].Values["trace_id"])
require.Equal(t, string(ports.ProfileChangedOperationUpdated), entries[0].Values["operation"])
require.Equal(t, "Nova Prime", entries[0].Values["race_name"])
for index := 0; index < 20; index++ {
err = publisher.PublishSettingsChanged(context.Background(), ports.SettingsChangedEvent{
UserID: common.UserID("user-123"),
OccurredAt: occurredAt.Add(time.Duration(index+1) * time.Second),
Source: common.Source("gateway_self_service"),
Operation: ports.SettingsChangedOperationUpdated,
PreferredLanguage: common.LanguageTag("en-US"),
TimeZone: common.TimeZoneName("UTC"),
})
require.NoError(t, err)
}
length, err := publisher.client.XLen(context.Background(), publisher.stream).Result()
require.NoError(t, err)
require.LessOrEqual(t, length, int64(20))
}
func TestPublisherRejectsInvalidEventBeforeXAdd(t *testing.T) {
t.Parallel()
server := miniredis.RunT(t)
publisher, err := New(Config{
Addr: server.Addr(),
Stream: "user:test_events",
StreamMaxLen: 5,
OperationTimeout: time.Second,
})
require.NoError(t, err)
err = publisher.PublishProfileChanged(context.Background(), ports.ProfileChangedEvent{
UserID: common.UserID("user-123"),
OccurredAt: time.Unix(1_775_240_000, 0).UTC(),
Operation: ports.ProfileChangedOperationUpdated,
RaceName: common.RaceName("Nova Prime"),
})
require.Error(t, err)
length, xLenErr := publisher.client.XLen(context.Background(), publisher.stream).Result()
require.NoError(t, xLenErr)
require.Zero(t, length)
}
@@ -0,0 +1,215 @@
package userstore
import (
"context"
"errors"
"galaxy/user/internal/adapters/redisstate"
"galaxy/user/internal/domain/account"
"galaxy/user/internal/domain/common"
"galaxy/user/internal/domain/entitlement"
"galaxy/user/internal/domain/policy"
"galaxy/user/internal/ports"
"github.com/redis/go-redis/v9"
)
var knownSanctionCodes = []policy.SanctionCode{
policy.SanctionCodeLoginBlock,
policy.SanctionCodePrivateGameCreateBlock,
policy.SanctionCodePrivateGameManageBlock,
policy.SanctionCodeGameJoinBlock,
policy.SanctionCodeProfileUpdateBlock,
}
var knownLimitCodes = []policy.LimitCode{
policy.LimitCodeMaxOwnedPrivateGames,
policy.LimitCodeMaxPendingPublicApplications,
policy.LimitCodeMaxActiveGameMemberships,
}
var knownEligibilityMarkers = []policy.EligibilityMarker{
policy.EligibilityMarkerCanLogin,
policy.EligibilityMarkerCanCreatePrivateGame,
policy.EligibilityMarkerCanManagePrivateGame,
policy.EligibilityMarkerCanJoinGame,
policy.EligibilityMarkerCanUpdateProfile,
}
func (store *Store) addCreatedAtIndex(
pipe redis.Pipeliner,
ctx context.Context,
record account.UserAccount,
) {
pipe.ZAdd(ctx, store.keyspace.CreatedAtIndex(), redis.Z{
Score: redisstate.CreatedAtScore(record.CreatedAt),
Member: record.UserID.String(),
})
}
func (store *Store) syncDeclaredCountryIndex(
pipe redis.Pipeliner,
ctx context.Context,
previous account.UserAccount,
current account.UserAccount,
) {
if !previous.DeclaredCountry.IsZero() {
pipe.SRem(ctx, store.keyspace.DeclaredCountryIndex(previous.DeclaredCountry), current.UserID.String())
}
if !current.DeclaredCountry.IsZero() {
pipe.SAdd(ctx, store.keyspace.DeclaredCountryIndex(current.DeclaredCountry), current.UserID.String())
}
}
func (store *Store) syncEntitlementIndexes(
pipe redis.Pipeliner,
ctx context.Context,
snapshot entitlement.CurrentSnapshot,
) {
pipe.SRem(ctx, store.keyspace.PaidStateIndex(entitlement.PaidStateFree), snapshot.UserID.String())
pipe.SRem(ctx, store.keyspace.PaidStateIndex(entitlement.PaidStatePaid), snapshot.UserID.String())
pipe.SAdd(ctx, store.keyspace.PaidStateIndex(paidStateFromSnapshot(snapshot)), snapshot.UserID.String())
pipe.ZRem(ctx, store.keyspace.FinitePaidExpiryIndex(), snapshot.UserID.String())
if snapshot.HasFiniteExpiry() {
pipe.ZAdd(ctx, store.keyspace.FinitePaidExpiryIndex(), redis.Z{
Score: redisstate.ExpiryScore(*snapshot.EndsAt),
Member: snapshot.UserID.String(),
})
}
}
func (store *Store) syncActiveSanctionCodeIndexes(
pipe redis.Pipeliner,
ctx context.Context,
userID common.UserID,
activeCodes map[policy.SanctionCode]struct{},
) {
for _, code := range knownSanctionCodes {
pipe.SRem(ctx, store.keyspace.ActiveSanctionCodeIndex(code), userID.String())
if _, ok := activeCodes[code]; ok {
pipe.SAdd(ctx, store.keyspace.ActiveSanctionCodeIndex(code), userID.String())
}
}
}
func (store *Store) syncActiveLimitCodeIndexes(
pipe redis.Pipeliner,
ctx context.Context,
userID common.UserID,
activeCodes map[policy.LimitCode]struct{},
) {
for _, code := range knownLimitCodes {
pipe.SRem(ctx, store.keyspace.ActiveLimitCodeIndex(code), userID.String())
if _, ok := activeCodes[code]; ok {
pipe.SAdd(ctx, store.keyspace.ActiveLimitCodeIndex(code), userID.String())
}
}
}
func (store *Store) syncEligibilityMarkerIndexes(
pipe redis.Pipeliner,
ctx context.Context,
userID common.UserID,
isPaid bool,
activeSanctionCodes map[policy.SanctionCode]struct{},
) {
values := deriveEligibilityMarkerValues(isPaid, activeSanctionCodes)
for _, marker := range knownEligibilityMarkers {
pipe.SRem(ctx, store.keyspace.EligibilityMarkerIndex(marker, true), userID.String())
pipe.SRem(ctx, store.keyspace.EligibilityMarkerIndex(marker, false), userID.String())
pipe.SAdd(ctx, store.keyspace.EligibilityMarkerIndex(marker, values[marker]), userID.String())
}
}
func (store *Store) loadActiveSanctionCodeSet(
ctx context.Context,
getter bytesGetter,
userID common.UserID,
) (map[policy.SanctionCode]struct{}, error) {
activeCodes := make(map[policy.SanctionCode]struct{}, len(knownSanctionCodes))
for _, code := range knownSanctionCodes {
_, err := store.loadActiveSanctionRecordID(ctx, getter, store.keyspace.ActiveSanction(userID, code))
switch {
case err == nil:
activeCodes[code] = struct{}{}
case errors.Is(err, ports.ErrNotFound):
continue
default:
return nil, err
}
}
return activeCodes, nil
}
func (store *Store) loadActiveLimitCodeSet(
ctx context.Context,
getter bytesGetter,
userID common.UserID,
) (map[policy.LimitCode]struct{}, error) {
activeCodes := make(map[policy.LimitCode]struct{}, len(knownLimitCodes))
for _, code := range knownLimitCodes {
_, err := store.loadActiveLimitRecordID(ctx, getter, store.keyspace.ActiveLimit(userID, code))
switch {
case err == nil:
activeCodes[code] = struct{}{}
case errors.Is(err, ports.ErrNotFound):
continue
default:
return nil, err
}
}
return activeCodes, nil
}
func (store *Store) activeSanctionWatchKeys(userID common.UserID) []string {
keys := make([]string, 0, len(knownSanctionCodes))
for _, code := range knownSanctionCodes {
keys = append(keys, store.keyspace.ActiveSanction(userID, code))
}
return keys
}
func (store *Store) activeLimitWatchKeys(userID common.UserID) []string {
keys := make([]string, 0, len(knownLimitCodes))
for _, code := range knownLimitCodes {
keys = append(keys, store.keyspace.ActiveLimit(userID, code))
}
return keys
}
func deriveEligibilityMarkerValues(
isPaid bool,
activeSanctionCodes map[policy.SanctionCode]struct{},
) map[policy.EligibilityMarker]bool {
_, loginBlocked := activeSanctionCodes[policy.SanctionCodeLoginBlock]
_, createBlocked := activeSanctionCodes[policy.SanctionCodePrivateGameCreateBlock]
_, manageBlocked := activeSanctionCodes[policy.SanctionCodePrivateGameManageBlock]
_, joinBlocked := activeSanctionCodes[policy.SanctionCodeGameJoinBlock]
_, profileBlocked := activeSanctionCodes[policy.SanctionCodeProfileUpdateBlock]
canLogin := !loginBlocked
return map[policy.EligibilityMarker]bool{
policy.EligibilityMarkerCanLogin: canLogin,
policy.EligibilityMarkerCanCreatePrivateGame: canLogin && isPaid && !createBlocked,
policy.EligibilityMarkerCanManagePrivateGame: canLogin && isPaid && !manageBlocked,
policy.EligibilityMarkerCanJoinGame: canLogin && !joinBlocked,
policy.EligibilityMarkerCanUpdateProfile: canLogin && !profileBlocked,
}
}
func paidStateFromSnapshot(snapshot entitlement.CurrentSnapshot) entitlement.PaidState {
if snapshot.IsPaid {
return entitlement.PaidStatePaid
}
return entitlement.PaidStateFree
}
@@ -0,0 +1,449 @@
package userstore
import (
"context"
"testing"
"time"
"galaxy/user/internal/adapters/redisstate"
"galaxy/user/internal/domain/common"
"galaxy/user/internal/domain/entitlement"
"galaxy/user/internal/domain/policy"
"galaxy/user/internal/ports"
"galaxy/user/internal/service/adminusers"
"galaxy/user/internal/service/entitlementsvc"
"github.com/stretchr/testify/require"
)
func TestListUserIDsCreatedAtPagination(t *testing.T) {
t.Parallel()
store := newTestStore(t)
base := time.Unix(1_775_240_000, 0).UTC()
first := validAccountRecord()
first.UserID = common.UserID("user-100")
first.Email = common.Email("u100@example.com")
first.RaceName = common.RaceName("User 100")
first.CreatedAt = base.Add(-time.Hour)
first.UpdatedAt = first.CreatedAt
second := validAccountRecord()
second.UserID = common.UserID("user-200")
second.Email = common.Email("u200@example.com")
second.RaceName = common.RaceName("User 200")
second.CreatedAt = base
second.UpdatedAt = second.CreatedAt
third := validAccountRecord()
third.UserID = common.UserID("user-300")
third.Email = common.Email("u300@example.com")
third.RaceName = common.RaceName("User 300")
third.CreatedAt = base
third.UpdatedAt = third.CreatedAt
require.NoError(t, store.Create(context.Background(), createAccountInput(first)))
require.NoError(t, store.Create(context.Background(), createAccountInput(second)))
require.NoError(t, store.Create(context.Background(), createAccountInput(third)))
firstPage, err := store.ListUserIDs(context.Background(), ports.ListUsersInput{
PageSize: 2,
Filters: ports.UserListFilters{},
})
require.NoError(t, err)
require.Equal(t, []common.UserID{third.UserID, second.UserID}, firstPage.UserIDs)
require.NotEmpty(t, firstPage.NextPageToken)
secondPage, err := store.ListUserIDs(context.Background(), ports.ListUsersInput{
PageSize: 2,
PageToken: firstPage.NextPageToken,
Filters: ports.UserListFilters{},
})
require.NoError(t, err)
require.Equal(t, []common.UserID{first.UserID}, secondPage.UserIDs)
require.Empty(t, secondPage.NextPageToken)
}
func TestEnsureByEmailInitialAdminIndexes(t *testing.T) {
t.Parallel()
store := newTestStore(t)
now := time.Unix(1_775_240_000, 0).UTC()
record := validAccountRecord()
record.DeclaredCountry = common.CountryCode("DE")
record.CreatedAt = now
record.UpdatedAt = now
result, err := store.EnsureByEmail(context.Background(), ports.EnsureByEmailInput{
Email: record.Email,
Account: record,
Entitlement: validEntitlementSnapshot(record.UserID, now),
EntitlementRecord: validEntitlementRecord(record.UserID, now),
Reservation: raceNameReservation(record.UserID, record.RaceName, now),
})
require.NoError(t, err)
require.Equal(t, ports.EnsureByEmailOutcomeCreated, result.Outcome)
requireSortedSetScore(t, store, store.keyspace.CreatedAtIndex(), record.UserID.String(), redisstate.CreatedAtScore(record.CreatedAt))
requireSetContains(t, store, store.keyspace.PaidStateIndex(entitlement.PaidStateFree), record.UserID.String())
requireSetNotContains(t, store, store.keyspace.PaidStateIndex(entitlement.PaidStatePaid), record.UserID.String())
requireSetContains(t, store, store.keyspace.DeclaredCountryIndex(record.DeclaredCountry), record.UserID.String())
requireSetContains(t, store, store.keyspace.EligibilityMarkerIndex(policy.EligibilityMarkerCanLogin, true), record.UserID.String())
requireSetContains(t, store, store.keyspace.EligibilityMarkerIndex(policy.EligibilityMarkerCanCreatePrivateGame, false), record.UserID.String())
requireSetContains(t, store, store.keyspace.EligibilityMarkerIndex(policy.EligibilityMarkerCanJoinGame, true), record.UserID.String())
}
func TestAccountUpdateSyncsDeclaredCountryIndex(t *testing.T) {
t.Parallel()
store := newTestStore(t)
accountStore := store.Accounts()
record := validAccountRecord()
record.DeclaredCountry = common.CountryCode("DE")
require.NoError(t, accountStore.Create(context.Background(), createAccountInput(record)))
updated := record
updated.DeclaredCountry = common.CountryCode("FR")
updated.UpdatedAt = record.UpdatedAt.Add(time.Minute)
require.NoError(t, accountStore.Update(context.Background(), updated))
requireSetNotContains(t, store, store.keyspace.DeclaredCountryIndex(common.CountryCode("DE")), record.UserID.String())
requireSetContains(t, store, store.keyspace.DeclaredCountryIndex(common.CountryCode("FR")), record.UserID.String())
}
func TestEntitlementLifecycleSyncsAdminIndexes(t *testing.T) {
t.Parallel()
store := newTestStore(t)
now := time.Unix(1_775_240_000, 0).UTC()
record := validAccountRecord()
record.CreatedAt = now
record.UpdatedAt = now
_, err := store.EnsureByEmail(context.Background(), ports.EnsureByEmailInput{
Email: record.Email,
Account: record,
Entitlement: validEntitlementSnapshot(record.UserID, now),
EntitlementRecord: validEntitlementRecord(record.UserID, now),
Reservation: raceNameReservation(record.UserID, record.RaceName, now),
})
require.NoError(t, err)
lifecycleStore := store.EntitlementLifecycle()
freeRecord := validEntitlementRecord(record.UserID, now)
freeSnapshot := validEntitlementSnapshot(record.UserID, now)
grantStartsAt := now.Add(time.Hour)
grantEndsAt := grantStartsAt.Add(30 * 24 * time.Hour)
grantedRecord := paidEntitlementRecord(
entitlement.EntitlementRecordID("entitlement-paid-1"),
record.UserID,
entitlement.PlanCodePaidMonthly,
grantStartsAt,
grantEndsAt,
common.Source("admin"),
common.ReasonCode("manual_grant"),
)
grantedSnapshot := paidEntitlementSnapshot(
record.UserID,
entitlement.PlanCodePaidMonthly,
grantStartsAt,
grantEndsAt,
common.Source("admin"),
common.ReasonCode("manual_grant"),
)
closedFreeRecord := freeRecord
closedFreeRecord.ClosedAt = timePointer(grantStartsAt)
closedFreeRecord.ClosedBy = common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")}
closedFreeRecord.ClosedReasonCode = common.ReasonCode("manual_grant")
require.NoError(t, lifecycleStore.Grant(context.Background(), ports.GrantEntitlementInput{
ExpectedCurrentSnapshot: freeSnapshot,
ExpectedCurrentRecord: freeRecord,
UpdatedCurrentRecord: closedFreeRecord,
NewRecord: grantedRecord,
NewSnapshot: grantedSnapshot,
}))
requireSetContains(t, store, store.keyspace.PaidStateIndex(entitlement.PaidStatePaid), record.UserID.String())
requireSetNotContains(t, store, store.keyspace.PaidStateIndex(entitlement.PaidStateFree), record.UserID.String())
requireSortedSetScore(t, store, store.keyspace.FinitePaidExpiryIndex(), record.UserID.String(), redisstate.ExpiryScore(grantEndsAt))
requireSetContains(t, store, store.keyspace.EligibilityMarkerIndex(policy.EligibilityMarkerCanCreatePrivateGame, true), record.UserID.String())
extendedEndsAt := grantEndsAt.Add(30 * 24 * time.Hour)
extensionRecord := paidEntitlementRecord(
entitlement.EntitlementRecordID("entitlement-paid-2"),
record.UserID,
entitlement.PlanCodePaidMonthly,
grantEndsAt,
extendedEndsAt,
common.Source("admin"),
common.ReasonCode("manual_extend"),
)
extendedSnapshot := paidEntitlementSnapshot(
record.UserID,
entitlement.PlanCodePaidMonthly,
grantStartsAt,
extendedEndsAt,
common.Source("admin"),
common.ReasonCode("manual_extend"),
)
require.NoError(t, lifecycleStore.Extend(context.Background(), ports.ExtendEntitlementInput{
ExpectedCurrentSnapshot: grantedSnapshot,
NewRecord: extensionRecord,
NewSnapshot: extendedSnapshot,
}))
requireSortedSetScore(t, store, store.keyspace.FinitePaidExpiryIndex(), record.UserID.String(), redisstate.ExpiryScore(extendedEndsAt))
revokeAt := grantEndsAt.Add(12 * time.Hour)
revokedCurrentRecord := extensionRecord
revokedCurrentRecord.ClosedAt = timePointer(revokeAt)
revokedCurrentRecord.ClosedBy = common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")}
revokedCurrentRecord.ClosedReasonCode = common.ReasonCode("manual_revoke")
freeAfterRevokeRecord := entitlement.PeriodRecord{
RecordID: entitlement.EntitlementRecordID("entitlement-free-2"),
UserID: record.UserID,
PlanCode: entitlement.PlanCodeFree,
Source: common.Source("admin"),
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
ReasonCode: common.ReasonCode("manual_revoke"),
StartsAt: revokeAt,
CreatedAt: revokeAt,
}
freeAfterRevokeSnapshot := entitlement.CurrentSnapshot{
UserID: record.UserID,
PlanCode: entitlement.PlanCodeFree,
IsPaid: false,
StartsAt: revokeAt,
Source: common.Source("admin"),
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
ReasonCode: common.ReasonCode("manual_revoke"),
UpdatedAt: revokeAt,
}
require.NoError(t, lifecycleStore.Revoke(context.Background(), ports.RevokeEntitlementInput{
ExpectedCurrentSnapshot: extendedSnapshot,
ExpectedCurrentRecord: extensionRecord,
UpdatedCurrentRecord: revokedCurrentRecord,
NewRecord: freeAfterRevokeRecord,
NewSnapshot: freeAfterRevokeSnapshot,
}))
requireSetContains(t, store, store.keyspace.PaidStateIndex(entitlement.PaidStateFree), record.UserID.String())
requireSetNotContains(t, store, store.keyspace.PaidStateIndex(entitlement.PaidStatePaid), record.UserID.String())
requireSortedSetMissing(t, store, store.keyspace.FinitePaidExpiryIndex(), record.UserID.String())
requireSetContains(t, store, store.keyspace.EligibilityMarkerIndex(policy.EligibilityMarkerCanCreatePrivateGame, false), record.UserID.String())
}
func TestPolicyLifecycleSyncsAdminIndexes(t *testing.T) {
t.Parallel()
store := newTestStore(t)
now := time.Unix(1_775_240_000, 0).UTC()
record := validAccountRecord()
record.CreatedAt = now
record.UpdatedAt = now
_, err := store.EnsureByEmail(context.Background(), ports.EnsureByEmailInput{
Email: record.Email,
Account: record,
Entitlement: validEntitlementSnapshot(record.UserID, now),
EntitlementRecord: validEntitlementRecord(record.UserID, now),
Reservation: raceNameReservation(record.UserID, record.RaceName, now),
})
require.NoError(t, err)
lifecycleStore := store.PolicyLifecycle()
sanctionRecord := policy.SanctionRecord{
RecordID: policy.SanctionRecordID("sanction-1"),
UserID: record.UserID,
SanctionCode: policy.SanctionCodeLoginBlock,
Scope: common.Scope("auth"),
ReasonCode: common.ReasonCode("manual_block"),
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
AppliedAt: now,
}
require.NoError(t, lifecycleStore.ApplySanction(context.Background(), ports.ApplySanctionInput{
NewRecord: sanctionRecord,
}))
requireSetContains(t, store, store.keyspace.ActiveSanctionCodeIndex(policy.SanctionCodeLoginBlock), record.UserID.String())
requireSetContains(t, store, store.keyspace.EligibilityMarkerIndex(policy.EligibilityMarkerCanLogin, false), record.UserID.String())
requireSetContains(t, store, store.keyspace.EligibilityMarkerIndex(policy.EligibilityMarkerCanJoinGame, false), record.UserID.String())
removedSanction := sanctionRecord
removedAt := now.Add(time.Minute)
removedSanction.RemovedAt = &removedAt
removedSanction.RemovedBy = common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-2")}
removedSanction.RemovedReasonCode = common.ReasonCode("manual_remove")
require.NoError(t, lifecycleStore.RemoveSanction(context.Background(), ports.RemoveSanctionInput{
ExpectedActiveRecord: sanctionRecord,
UpdatedRecord: removedSanction,
}))
requireSetNotContains(t, store, store.keyspace.ActiveSanctionCodeIndex(policy.SanctionCodeLoginBlock), record.UserID.String())
requireSetContains(t, store, store.keyspace.EligibilityMarkerIndex(policy.EligibilityMarkerCanLogin, true), record.UserID.String())
limitRecord := policy.LimitRecord{
RecordID: policy.LimitRecordID("limit-1"),
UserID: record.UserID,
LimitCode: policy.LimitCodeMaxOwnedPrivateGames,
Value: 5,
ReasonCode: common.ReasonCode("manual_override"),
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
AppliedAt: now.Add(2 * time.Minute),
}
require.NoError(t, lifecycleStore.SetLimit(context.Background(), ports.SetLimitInput{
NewRecord: limitRecord,
}))
requireSetContains(t, store, store.keyspace.ActiveLimitCodeIndex(policy.LimitCodeMaxOwnedPrivateGames), record.UserID.String())
removedLimit := limitRecord
limitRemovedAt := now.Add(3 * time.Minute)
removedLimit.RemovedAt = &limitRemovedAt
removedLimit.RemovedBy = common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-2")}
removedLimit.RemovedReasonCode = common.ReasonCode("manual_remove")
require.NoError(t, lifecycleStore.RemoveLimit(context.Background(), ports.RemoveLimitInput{
ExpectedActiveRecord: limitRecord,
UpdatedRecord: removedLimit,
}))
requireSetNotContains(t, store, store.keyspace.ActiveLimitCodeIndex(policy.LimitCodeMaxOwnedPrivateGames), record.UserID.String())
}
func TestAdminListerReevaluatesExpiredPaidSnapshots(t *testing.T) {
t.Parallel()
store := newTestStore(t)
userID := common.UserID("user-123")
now := time.Unix(1_775_240_000, 0).UTC()
record := validAccountRecord()
record.CreatedAt = now.Add(-2 * time.Hour)
record.UpdatedAt = record.CreatedAt
_, err := store.EnsureByEmail(context.Background(), ports.EnsureByEmailInput{
Email: record.Email,
Account: record,
Entitlement: validEntitlementSnapshot(userID, record.CreatedAt),
EntitlementRecord: validEntitlementRecord(userID, record.CreatedAt),
Reservation: raceNameReservation(userID, record.RaceName, record.CreatedAt),
})
require.NoError(t, err)
grantStartsAt := now.Add(-90 * time.Minute)
grantEndsAt := now.Add(-30 * time.Minute)
freeRecord := validEntitlementRecord(userID, record.CreatedAt)
freeSnapshot := validEntitlementSnapshot(userID, record.CreatedAt)
grantedRecord := paidEntitlementRecord(
entitlement.EntitlementRecordID("entitlement-paid-expired"),
userID,
entitlement.PlanCodePaidMonthly,
grantStartsAt,
grantEndsAt,
common.Source("admin"),
common.ReasonCode("manual_grant"),
)
grantedSnapshot := paidEntitlementSnapshot(
userID,
entitlement.PlanCodePaidMonthly,
grantStartsAt,
grantEndsAt,
common.Source("admin"),
common.ReasonCode("manual_grant"),
)
closedFreeRecord := freeRecord
closedFreeRecord.ClosedAt = timePointer(grantStartsAt)
closedFreeRecord.ClosedBy = common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")}
closedFreeRecord.ClosedReasonCode = common.ReasonCode("manual_grant")
require.NoError(t, store.EntitlementLifecycle().Grant(context.Background(), ports.GrantEntitlementInput{
ExpectedCurrentSnapshot: freeSnapshot,
ExpectedCurrentRecord: freeRecord,
UpdatedCurrentRecord: closedFreeRecord,
NewRecord: grantedRecord,
NewSnapshot: grantedSnapshot,
}))
reader, err := entitlementsvc.NewReader(
store.EntitlementSnapshots(),
store.EntitlementLifecycle(),
adminStoreClock{now: now},
adminStoreIDGenerator{entitlementRecordID: entitlement.EntitlementRecordID("entitlement-free-after-expiry")},
)
require.NoError(t, err)
lister, err := adminusers.NewLister(store.Accounts(), reader, store.Sanctions(), store.Limits(), adminStoreClock{now: now}, store)
require.NoError(t, err)
result, err := lister.Execute(context.Background(), adminusers.ListUsersInput{PaidState: "free"})
require.NoError(t, err)
require.Len(t, result.Items, 1)
require.Equal(t, "user-123", result.Items[0].UserID)
require.Equal(t, "free", result.Items[0].Entitlement.PlanCode)
require.False(t, result.Items[0].Entitlement.IsPaid)
storedSnapshot, err := store.EntitlementSnapshots().GetByUserID(context.Background(), userID)
require.NoError(t, err)
require.Equal(t, entitlement.PlanCodeFree, storedSnapshot.PlanCode)
require.False(t, storedSnapshot.IsPaid)
}
type adminStoreClock struct {
now time.Time
}
func (clock adminStoreClock) Now() time.Time {
return clock.now
}
type adminStoreIDGenerator struct {
entitlementRecordID entitlement.EntitlementRecordID
}
func (generator adminStoreIDGenerator) NewUserID() (common.UserID, error) {
return "", nil
}
func (generator adminStoreIDGenerator) NewInitialRaceName() (common.RaceName, error) {
return "", nil
}
func (generator adminStoreIDGenerator) NewEntitlementRecordID() (entitlement.EntitlementRecordID, error) {
return generator.entitlementRecordID, nil
}
func (generator adminStoreIDGenerator) NewSanctionRecordID() (policy.SanctionRecordID, error) {
return "", nil
}
func (generator adminStoreIDGenerator) NewLimitRecordID() (policy.LimitRecordID, error) {
return "", nil
}
func requireSetContains(t *testing.T, store *Store, key string, member string) {
t.Helper()
exists, err := store.client.SIsMember(context.Background(), key, member).Result()
require.NoError(t, err)
require.True(t, exists, "expected %q to contain %q", key, member)
}
func requireSetNotContains(t *testing.T, store *Store, key string, member string) {
t.Helper()
exists, err := store.client.SIsMember(context.Background(), key, member).Result()
require.NoError(t, err)
require.False(t, exists, "expected %q not to contain %q", key, member)
}
func requireSortedSetScore(t *testing.T, store *Store, key string, member string, want float64) {
t.Helper()
got, err := store.client.ZScore(context.Background(), key, member).Result()
require.NoError(t, err)
require.Equal(t, want, got)
}
func requireSortedSetMissing(t *testing.T, store *Store, key string, member string) {
t.Helper()
_, err := store.client.ZScore(context.Background(), key, member).Result()
require.Error(t, err)
}
@@ -0,0 +1,752 @@
package userstore
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
"galaxy/user/internal/domain/common"
"galaxy/user/internal/domain/entitlement"
"galaxy/user/internal/ports"
"github.com/redis/go-redis/v9"
)
type entitlementPeriodRecord struct {
RecordID string `json:"record_id"`
UserID string `json:"user_id"`
PlanCode string `json:"plan_code"`
Source string `json:"source"`
ActorType string `json:"actor_type"`
ActorID *string `json:"actor_id,omitempty"`
ReasonCode string `json:"reason_code"`
StartsAt string `json:"starts_at"`
EndsAt *string `json:"ends_at,omitempty"`
CreatedAt string `json:"created_at"`
ClosedAt *string `json:"closed_at,omitempty"`
ClosedByType *string `json:"closed_by_type,omitempty"`
ClosedByID *string `json:"closed_by_id,omitempty"`
ClosedReasonCode *string `json:"closed_reason_code,omitempty"`
}
// CreateEntitlementRecord stores one new entitlement history record.
func (store *Store) CreateEntitlementRecord(ctx context.Context, record entitlement.PeriodRecord) error {
if err := record.Validate(); err != nil {
return fmt.Errorf("create entitlement record in redis: %w", err)
}
payload, err := marshalEntitlementPeriodRecord(record)
if err != nil {
return fmt.Errorf("create entitlement record in redis: %w", err)
}
recordKey := store.keyspace.EntitlementRecord(record.RecordID)
historyKey := store.keyspace.EntitlementHistory(record.UserID)
operationCtx, cancel, err := store.operationContext(ctx, "create entitlement record in redis")
if err != nil {
return err
}
defer cancel()
watchErr := store.client.Watch(operationCtx, func(tx *redis.Tx) error {
if err := ensureKeyAbsent(operationCtx, tx, recordKey); err != nil {
return fmt.Errorf("create entitlement record %q in redis: %w", record.RecordID, err)
}
_, err := tx.TxPipelined(operationCtx, func(pipe redis.Pipeliner) error {
pipe.Set(operationCtx, recordKey, payload, 0)
pipe.ZAdd(operationCtx, historyKey, redis.Z{
Score: float64(record.StartsAt.UTC().UnixMicro()),
Member: record.RecordID.String(),
})
return nil
})
if err != nil {
return fmt.Errorf("create entitlement record %q in redis: %w", record.RecordID, err)
}
return nil
}, recordKey, historyKey)
switch {
case errors.Is(watchErr, redis.TxFailedErr):
return fmt.Errorf("create entitlement record %q in redis: %w", record.RecordID, ports.ErrConflict)
case watchErr != nil:
return watchErr
default:
return nil
}
}
// GetEntitlementRecordByRecordID returns the entitlement history record
// identified by recordID.
func (store *Store) GetEntitlementRecordByRecordID(
ctx context.Context,
recordID entitlement.EntitlementRecordID,
) (entitlement.PeriodRecord, error) {
if err := recordID.Validate(); err != nil {
return entitlement.PeriodRecord{}, fmt.Errorf("get entitlement record by record id from redis: %w", err)
}
operationCtx, cancel, err := store.operationContext(ctx, "get entitlement record by record id from redis")
if err != nil {
return entitlement.PeriodRecord{}, err
}
defer cancel()
record, err := store.loadEntitlementRecord(operationCtx, store.client, recordID)
if err != nil {
switch {
case errors.Is(err, ports.ErrNotFound):
return entitlement.PeriodRecord{}, fmt.Errorf("get entitlement record by record id %q from redis: %w", recordID, ports.ErrNotFound)
default:
return entitlement.PeriodRecord{}, fmt.Errorf("get entitlement record by record id %q from redis: %w", recordID, err)
}
}
return record, nil
}
// ListEntitlementRecordsByUserID returns every entitlement history record
// owned by userID.
func (store *Store) ListEntitlementRecordsByUserID(
ctx context.Context,
userID common.UserID,
) ([]entitlement.PeriodRecord, error) {
if err := userID.Validate(); err != nil {
return nil, fmt.Errorf("list entitlement records by user id from redis: %w", err)
}
operationCtx, cancel, err := store.operationContext(ctx, "list entitlement records by user id from redis")
if err != nil {
return nil, err
}
defer cancel()
recordIDs, err := store.client.ZRange(operationCtx, store.keyspace.EntitlementHistory(userID), 0, -1).Result()
if err != nil {
return nil, fmt.Errorf("list entitlement records by user id %q from redis: %w", userID, err)
}
records := make([]entitlement.PeriodRecord, 0, len(recordIDs))
for _, rawRecordID := range recordIDs {
record, err := store.loadEntitlementRecord(operationCtx, store.client, entitlement.EntitlementRecordID(rawRecordID))
if err != nil {
return nil, fmt.Errorf("list entitlement records by user id %q from redis: %w", userID, err)
}
records = append(records, record)
}
return records, nil
}
// UpdateEntitlementRecord replaces one stored entitlement history record.
func (store *Store) UpdateEntitlementRecord(ctx context.Context, record entitlement.PeriodRecord) error {
if err := record.Validate(); err != nil {
return fmt.Errorf("update entitlement record in redis: %w", err)
}
payload, err := marshalEntitlementPeriodRecord(record)
if err != nil {
return fmt.Errorf("update entitlement record in redis: %w", err)
}
recordKey := store.keyspace.EntitlementRecord(record.RecordID)
operationCtx, cancel, err := store.operationContext(ctx, "update entitlement record in redis")
if err != nil {
return err
}
defer cancel()
watchErr := store.client.Watch(operationCtx, func(tx *redis.Tx) error {
if _, err := store.loadEntitlementRecord(operationCtx, tx, record.RecordID); err != nil {
return fmt.Errorf("update entitlement record %q in redis: %w", record.RecordID, err)
}
_, err := tx.TxPipelined(operationCtx, func(pipe redis.Pipeliner) error {
pipe.Set(operationCtx, recordKey, payload, 0)
return nil
})
if err != nil {
return fmt.Errorf("update entitlement record %q in redis: %w", record.RecordID, err)
}
return nil
}, recordKey)
switch {
case errors.Is(watchErr, redis.TxFailedErr):
return fmt.Errorf("update entitlement record %q in redis: %w", record.RecordID, ports.ErrConflict)
case watchErr != nil:
return watchErr
default:
return nil
}
}
// GrantEntitlement atomically closes the current free history record, creates
// one paid history record, and replaces the current snapshot.
func (store *Store) GrantEntitlement(ctx context.Context, input ports.GrantEntitlementInput) error {
if err := input.Validate(); err != nil {
return fmt.Errorf("grant entitlement in redis: %w", err)
}
updatedCurrentRecordPayload, err := marshalEntitlementPeriodRecord(input.UpdatedCurrentRecord)
if err != nil {
return fmt.Errorf("grant entitlement in redis: %w", err)
}
newRecordPayload, err := marshalEntitlementPeriodRecord(input.NewRecord)
if err != nil {
return fmt.Errorf("grant entitlement in redis: %w", err)
}
newSnapshotPayload, err := marshalEntitlementSnapshotRecord(input.NewSnapshot)
if err != nil {
return fmt.Errorf("grant entitlement in redis: %w", err)
}
currentRecordKey := store.keyspace.EntitlementRecord(input.ExpectedCurrentRecord.RecordID)
newRecordKey := store.keyspace.EntitlementRecord(input.NewRecord.RecordID)
historyKey := store.keyspace.EntitlementHistory(input.NewRecord.UserID)
snapshotKey := store.keyspace.EntitlementSnapshot(input.NewSnapshot.UserID)
watchedKeys := append(
[]string{currentRecordKey, newRecordKey, historyKey, snapshotKey},
store.activeSanctionWatchKeys(input.NewSnapshot.UserID)...,
)
operationCtx, cancel, err := store.operationContext(ctx, "grant entitlement in redis")
if err != nil {
return err
}
defer cancel()
watchErr := store.client.Watch(operationCtx, func(tx *redis.Tx) error {
storedSnapshot, err := store.loadEntitlementSnapshot(operationCtx, tx, input.ExpectedCurrentSnapshot.UserID)
if err != nil {
return fmt.Errorf("grant entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, err)
}
if !equalEntitlementSnapshots(storedSnapshot, input.ExpectedCurrentSnapshot) {
return fmt.Errorf("grant entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, ports.ErrConflict)
}
storedCurrentRecord, err := store.loadEntitlementRecord(operationCtx, tx, input.ExpectedCurrentRecord.RecordID)
if err != nil {
return fmt.Errorf("grant entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, err)
}
if !equalEntitlementPeriodRecords(storedCurrentRecord, input.ExpectedCurrentRecord) {
return fmt.Errorf("grant entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, ports.ErrConflict)
}
if err := ensureKeyAbsent(operationCtx, tx, newRecordKey); err != nil {
return fmt.Errorf("grant entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, err)
}
activeSanctionCodes, err := store.loadActiveSanctionCodeSet(operationCtx, tx, input.NewSnapshot.UserID)
if err != nil {
return fmt.Errorf("grant entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, err)
}
_, err = tx.TxPipelined(operationCtx, func(pipe redis.Pipeliner) error {
pipe.Set(operationCtx, currentRecordKey, updatedCurrentRecordPayload, 0)
pipe.Set(operationCtx, newRecordKey, newRecordPayload, 0)
pipe.ZAdd(operationCtx, historyKey, redis.Z{
Score: float64(input.NewRecord.StartsAt.UTC().UnixMicro()),
Member: input.NewRecord.RecordID.String(),
})
pipe.Set(operationCtx, snapshotKey, newSnapshotPayload, 0)
store.syncEntitlementIndexes(pipe, operationCtx, input.NewSnapshot)
store.syncEligibilityMarkerIndexes(pipe, operationCtx, input.NewSnapshot.UserID, input.NewSnapshot.IsPaid, activeSanctionCodes)
return nil
})
if err != nil {
return fmt.Errorf("grant entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, err)
}
return nil
}, watchedKeys...)
switch {
case errors.Is(watchErr, redis.TxFailedErr):
return fmt.Errorf("grant entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, ports.ErrConflict)
case watchErr != nil:
return watchErr
default:
return nil
}
}
// ExtendEntitlement atomically appends one paid history segment and replaces
// the current paid snapshot.
func (store *Store) ExtendEntitlement(ctx context.Context, input ports.ExtendEntitlementInput) error {
if err := input.Validate(); err != nil {
return fmt.Errorf("extend entitlement in redis: %w", err)
}
newRecordPayload, err := marshalEntitlementPeriodRecord(input.NewRecord)
if err != nil {
return fmt.Errorf("extend entitlement in redis: %w", err)
}
newSnapshotPayload, err := marshalEntitlementSnapshotRecord(input.NewSnapshot)
if err != nil {
return fmt.Errorf("extend entitlement in redis: %w", err)
}
newRecordKey := store.keyspace.EntitlementRecord(input.NewRecord.RecordID)
historyKey := store.keyspace.EntitlementHistory(input.NewRecord.UserID)
snapshotKey := store.keyspace.EntitlementSnapshot(input.NewSnapshot.UserID)
watchedKeys := append(
[]string{newRecordKey, historyKey, snapshotKey},
store.activeSanctionWatchKeys(input.NewSnapshot.UserID)...,
)
operationCtx, cancel, err := store.operationContext(ctx, "extend entitlement in redis")
if err != nil {
return err
}
defer cancel()
watchErr := store.client.Watch(operationCtx, func(tx *redis.Tx) error {
storedSnapshot, err := store.loadEntitlementSnapshot(operationCtx, tx, input.ExpectedCurrentSnapshot.UserID)
if err != nil {
return fmt.Errorf("extend entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, err)
}
if !equalEntitlementSnapshots(storedSnapshot, input.ExpectedCurrentSnapshot) {
return fmt.Errorf("extend entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, ports.ErrConflict)
}
if err := ensureKeyAbsent(operationCtx, tx, newRecordKey); err != nil {
return fmt.Errorf("extend entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, err)
}
activeSanctionCodes, err := store.loadActiveSanctionCodeSet(operationCtx, tx, input.NewSnapshot.UserID)
if err != nil {
return fmt.Errorf("extend entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, err)
}
_, err = tx.TxPipelined(operationCtx, func(pipe redis.Pipeliner) error {
pipe.Set(operationCtx, newRecordKey, newRecordPayload, 0)
pipe.ZAdd(operationCtx, historyKey, redis.Z{
Score: float64(input.NewRecord.StartsAt.UTC().UnixMicro()),
Member: input.NewRecord.RecordID.String(),
})
pipe.Set(operationCtx, snapshotKey, newSnapshotPayload, 0)
store.syncEntitlementIndexes(pipe, operationCtx, input.NewSnapshot)
store.syncEligibilityMarkerIndexes(pipe, operationCtx, input.NewSnapshot.UserID, input.NewSnapshot.IsPaid, activeSanctionCodes)
return nil
})
if err != nil {
return fmt.Errorf("extend entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, err)
}
return nil
}, watchedKeys...)
switch {
case errors.Is(watchErr, redis.TxFailedErr):
return fmt.Errorf("extend entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, ports.ErrConflict)
case watchErr != nil:
return watchErr
default:
return nil
}
}
// RevokeEntitlement atomically closes the current paid history record,
// creates one free history record, and replaces the current snapshot.
func (store *Store) RevokeEntitlement(ctx context.Context, input ports.RevokeEntitlementInput) error {
if err := input.Validate(); err != nil {
return fmt.Errorf("revoke entitlement in redis: %w", err)
}
updatedCurrentRecordPayload, err := marshalEntitlementPeriodRecord(input.UpdatedCurrentRecord)
if err != nil {
return fmt.Errorf("revoke entitlement in redis: %w", err)
}
newRecordPayload, err := marshalEntitlementPeriodRecord(input.NewRecord)
if err != nil {
return fmt.Errorf("revoke entitlement in redis: %w", err)
}
newSnapshotPayload, err := marshalEntitlementSnapshotRecord(input.NewSnapshot)
if err != nil {
return fmt.Errorf("revoke entitlement in redis: %w", err)
}
currentRecordKey := store.keyspace.EntitlementRecord(input.ExpectedCurrentRecord.RecordID)
newRecordKey := store.keyspace.EntitlementRecord(input.NewRecord.RecordID)
historyKey := store.keyspace.EntitlementHistory(input.NewRecord.UserID)
snapshotKey := store.keyspace.EntitlementSnapshot(input.NewSnapshot.UserID)
watchedKeys := append(
[]string{currentRecordKey, newRecordKey, historyKey, snapshotKey},
store.activeSanctionWatchKeys(input.NewSnapshot.UserID)...,
)
operationCtx, cancel, err := store.operationContext(ctx, "revoke entitlement in redis")
if err != nil {
return err
}
defer cancel()
watchErr := store.client.Watch(operationCtx, func(tx *redis.Tx) error {
storedSnapshot, err := store.loadEntitlementSnapshot(operationCtx, tx, input.ExpectedCurrentSnapshot.UserID)
if err != nil {
return fmt.Errorf("revoke entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, err)
}
if !equalEntitlementSnapshots(storedSnapshot, input.ExpectedCurrentSnapshot) {
return fmt.Errorf("revoke entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, ports.ErrConflict)
}
storedCurrentRecord, err := store.loadEntitlementRecord(operationCtx, tx, input.ExpectedCurrentRecord.RecordID)
if err != nil {
return fmt.Errorf("revoke entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, err)
}
if !equalEntitlementPeriodRecords(storedCurrentRecord, input.ExpectedCurrentRecord) {
return fmt.Errorf("revoke entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, ports.ErrConflict)
}
if err := ensureKeyAbsent(operationCtx, tx, newRecordKey); err != nil {
return fmt.Errorf("revoke entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, err)
}
activeSanctionCodes, err := store.loadActiveSanctionCodeSet(operationCtx, tx, input.NewSnapshot.UserID)
if err != nil {
return fmt.Errorf("revoke entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, err)
}
_, err = tx.TxPipelined(operationCtx, func(pipe redis.Pipeliner) error {
pipe.Set(operationCtx, currentRecordKey, updatedCurrentRecordPayload, 0)
pipe.Set(operationCtx, newRecordKey, newRecordPayload, 0)
pipe.ZAdd(operationCtx, historyKey, redis.Z{
Score: float64(input.NewRecord.StartsAt.UTC().UnixMicro()),
Member: input.NewRecord.RecordID.String(),
})
pipe.Set(operationCtx, snapshotKey, newSnapshotPayload, 0)
store.syncEntitlementIndexes(pipe, operationCtx, input.NewSnapshot)
store.syncEligibilityMarkerIndexes(pipe, operationCtx, input.NewSnapshot.UserID, input.NewSnapshot.IsPaid, activeSanctionCodes)
return nil
})
if err != nil {
return fmt.Errorf("revoke entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, err)
}
return nil
}, watchedKeys...)
switch {
case errors.Is(watchErr, redis.TxFailedErr):
return fmt.Errorf("revoke entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, ports.ErrConflict)
case watchErr != nil:
return watchErr
default:
return nil
}
}
// RepairExpiredEntitlement atomically replaces one expired finite paid
// snapshot with a materialized free state.
func (store *Store) RepairExpiredEntitlement(ctx context.Context, input ports.RepairExpiredEntitlementInput) error {
if err := input.Validate(); err != nil {
return fmt.Errorf("repair expired entitlement in redis: %w", err)
}
newRecordPayload, err := marshalEntitlementPeriodRecord(input.NewRecord)
if err != nil {
return fmt.Errorf("repair expired entitlement in redis: %w", err)
}
newSnapshotPayload, err := marshalEntitlementSnapshotRecord(input.NewSnapshot)
if err != nil {
return fmt.Errorf("repair expired entitlement in redis: %w", err)
}
newRecordKey := store.keyspace.EntitlementRecord(input.NewRecord.RecordID)
historyKey := store.keyspace.EntitlementHistory(input.NewRecord.UserID)
snapshotKey := store.keyspace.EntitlementSnapshot(input.NewSnapshot.UserID)
watchedKeys := append(
[]string{newRecordKey, historyKey, snapshotKey},
store.activeSanctionWatchKeys(input.NewSnapshot.UserID)...,
)
operationCtx, cancel, err := store.operationContext(ctx, "repair expired entitlement in redis")
if err != nil {
return err
}
defer cancel()
watchErr := store.client.Watch(operationCtx, func(tx *redis.Tx) error {
storedSnapshot, err := store.loadEntitlementSnapshot(operationCtx, tx, input.ExpectedExpiredSnapshot.UserID)
if err != nil {
return fmt.Errorf("repair expired entitlement for user %q in redis: %w", input.ExpectedExpiredSnapshot.UserID, err)
}
if !equalEntitlementSnapshots(storedSnapshot, input.ExpectedExpiredSnapshot) {
return fmt.Errorf("repair expired entitlement for user %q in redis: %w", input.ExpectedExpiredSnapshot.UserID, ports.ErrConflict)
}
if err := ensureKeyAbsent(operationCtx, tx, newRecordKey); err != nil {
return fmt.Errorf("repair expired entitlement for user %q in redis: %w", input.ExpectedExpiredSnapshot.UserID, err)
}
activeSanctionCodes, err := store.loadActiveSanctionCodeSet(operationCtx, tx, input.NewSnapshot.UserID)
if err != nil {
return fmt.Errorf("repair expired entitlement for user %q in redis: %w", input.ExpectedExpiredSnapshot.UserID, err)
}
_, err = tx.TxPipelined(operationCtx, func(pipe redis.Pipeliner) error {
pipe.Set(operationCtx, newRecordKey, newRecordPayload, 0)
pipe.ZAdd(operationCtx, historyKey, redis.Z{
Score: float64(input.NewRecord.StartsAt.UTC().UnixMicro()),
Member: input.NewRecord.RecordID.String(),
})
pipe.Set(operationCtx, snapshotKey, newSnapshotPayload, 0)
store.syncEntitlementIndexes(pipe, operationCtx, input.NewSnapshot)
store.syncEligibilityMarkerIndexes(pipe, operationCtx, input.NewSnapshot.UserID, input.NewSnapshot.IsPaid, activeSanctionCodes)
return nil
})
if err != nil {
return fmt.Errorf("repair expired entitlement for user %q in redis: %w", input.ExpectedExpiredSnapshot.UserID, err)
}
return nil
}, watchedKeys...)
switch {
case errors.Is(watchErr, redis.TxFailedErr):
return fmt.Errorf("repair expired entitlement for user %q in redis: %w", input.ExpectedExpiredSnapshot.UserID, ports.ErrConflict)
case watchErr != nil:
return watchErr
default:
return nil
}
}
func (store *Store) loadEntitlementRecord(
ctx context.Context,
getter bytesGetter,
recordID entitlement.EntitlementRecordID,
) (entitlement.PeriodRecord, error) {
payload, err := getter.Get(ctx, store.keyspace.EntitlementRecord(recordID)).Bytes()
switch {
case errors.Is(err, redis.Nil):
return entitlement.PeriodRecord{}, ports.ErrNotFound
case err != nil:
return entitlement.PeriodRecord{}, err
}
return decodeEntitlementPeriodRecord(payload)
}
func marshalEntitlementPeriodRecord(record entitlement.PeriodRecord) ([]byte, error) {
encoded := entitlementPeriodRecord{
RecordID: record.RecordID.String(),
UserID: record.UserID.String(),
PlanCode: string(record.PlanCode),
Source: record.Source.String(),
ActorType: record.Actor.Type.String(),
ReasonCode: record.ReasonCode.String(),
StartsAt: record.StartsAt.UTC().Format(time.RFC3339Nano),
CreatedAt: record.CreatedAt.UTC().Format(time.RFC3339Nano),
}
if !record.Actor.ID.IsZero() {
value := record.Actor.ID.String()
encoded.ActorID = &value
}
if record.EndsAt != nil {
value := record.EndsAt.UTC().Format(time.RFC3339Nano)
encoded.EndsAt = &value
}
if record.ClosedAt != nil {
value := record.ClosedAt.UTC().Format(time.RFC3339Nano)
encoded.ClosedAt = &value
}
if !record.ClosedBy.Type.IsZero() {
value := record.ClosedBy.Type.String()
encoded.ClosedByType = &value
}
if !record.ClosedBy.ID.IsZero() {
value := record.ClosedBy.ID.String()
encoded.ClosedByID = &value
}
if !record.ClosedReasonCode.IsZero() {
value := record.ClosedReasonCode.String()
encoded.ClosedReasonCode = &value
}
return json.Marshal(encoded)
}
func decodeEntitlementPeriodRecord(payload []byte) (entitlement.PeriodRecord, error) {
var encoded entitlementPeriodRecord
if err := decodeJSONPayload(payload, &encoded); err != nil {
return entitlement.PeriodRecord{}, err
}
startsAt, err := time.Parse(time.RFC3339Nano, encoded.StartsAt)
if err != nil {
return entitlement.PeriodRecord{}, fmt.Errorf("decode entitlement period record starts_at: %w", err)
}
createdAt, err := time.Parse(time.RFC3339Nano, encoded.CreatedAt)
if err != nil {
return entitlement.PeriodRecord{}, fmt.Errorf("decode entitlement period record created_at: %w", err)
}
record := entitlement.PeriodRecord{
RecordID: entitlement.EntitlementRecordID(encoded.RecordID),
UserID: common.UserID(encoded.UserID),
PlanCode: entitlement.PlanCode(encoded.PlanCode),
Source: common.Source(encoded.Source),
Actor: common.ActorRef{Type: common.ActorType(encoded.ActorType)},
ReasonCode: common.ReasonCode(encoded.ReasonCode),
StartsAt: startsAt.UTC(),
CreatedAt: createdAt.UTC(),
}
if encoded.ActorID != nil {
record.Actor.ID = common.ActorID(*encoded.ActorID)
}
if encoded.EndsAt != nil {
value, err := time.Parse(time.RFC3339Nano, *encoded.EndsAt)
if err != nil {
return entitlement.PeriodRecord{}, fmt.Errorf("decode entitlement period record ends_at: %w", err)
}
value = value.UTC()
record.EndsAt = &value
}
if encoded.ClosedAt != nil {
value, err := time.Parse(time.RFC3339Nano, *encoded.ClosedAt)
if err != nil {
return entitlement.PeriodRecord{}, fmt.Errorf("decode entitlement period record closed_at: %w", err)
}
value = value.UTC()
record.ClosedAt = &value
}
if encoded.ClosedByType != nil {
record.ClosedBy.Type = common.ActorType(*encoded.ClosedByType)
}
if encoded.ClosedByID != nil {
record.ClosedBy.ID = common.ActorID(*encoded.ClosedByID)
}
if encoded.ClosedReasonCode != nil {
record.ClosedReasonCode = common.ReasonCode(*encoded.ClosedReasonCode)
}
if err := record.Validate(); err != nil {
return entitlement.PeriodRecord{}, fmt.Errorf("decode entitlement period record: %w", err)
}
return record, nil
}
func equalEntitlementSnapshots(left entitlement.CurrentSnapshot, right entitlement.CurrentSnapshot) bool {
return left.UserID == right.UserID &&
left.PlanCode == right.PlanCode &&
left.IsPaid == right.IsPaid &&
left.StartsAt.Equal(right.StartsAt) &&
equalOptionalTime(left.EndsAt, right.EndsAt) &&
left.Source == right.Source &&
left.Actor == right.Actor &&
left.ReasonCode == right.ReasonCode &&
left.UpdatedAt.Equal(right.UpdatedAt)
}
func equalEntitlementPeriodRecords(left entitlement.PeriodRecord, right entitlement.PeriodRecord) bool {
return left.RecordID == right.RecordID &&
left.UserID == right.UserID &&
left.PlanCode == right.PlanCode &&
left.Source == right.Source &&
left.Actor == right.Actor &&
left.ReasonCode == right.ReasonCode &&
left.StartsAt.Equal(right.StartsAt) &&
equalOptionalTime(left.EndsAt, right.EndsAt) &&
left.CreatedAt.Equal(right.CreatedAt) &&
equalOptionalTime(left.ClosedAt, right.ClosedAt) &&
left.ClosedBy == right.ClosedBy &&
left.ClosedReasonCode == right.ClosedReasonCode
}
func equalOptionalTime(left *time.Time, right *time.Time) bool {
switch {
case left == nil && right == nil:
return true
case left == nil || right == nil:
return false
default:
return left.Equal(*right)
}
}
// EntitlementHistoryStore adapts Store to the existing
// EntitlementHistoryStore port.
type EntitlementHistoryStore struct {
store *Store
}
// EntitlementHistory returns one adapter that exposes the entitlement-history
// store port over Store.
func (store *Store) EntitlementHistory() *EntitlementHistoryStore {
if store == nil {
return nil
}
return &EntitlementHistoryStore{store: store}
}
// Create stores one new entitlement history record.
func (adapter *EntitlementHistoryStore) Create(ctx context.Context, record entitlement.PeriodRecord) error {
return adapter.store.CreateEntitlementRecord(ctx, record)
}
// GetByRecordID returns the entitlement history record identified by recordID.
func (adapter *EntitlementHistoryStore) GetByRecordID(
ctx context.Context,
recordID entitlement.EntitlementRecordID,
) (entitlement.PeriodRecord, error) {
return adapter.store.GetEntitlementRecordByRecordID(ctx, recordID)
}
// ListByUserID returns every entitlement history record owned by userID.
func (adapter *EntitlementHistoryStore) ListByUserID(
ctx context.Context,
userID common.UserID,
) ([]entitlement.PeriodRecord, error) {
return adapter.store.ListEntitlementRecordsByUserID(ctx, userID)
}
// Update replaces one stored entitlement history record.
func (adapter *EntitlementHistoryStore) Update(ctx context.Context, record entitlement.PeriodRecord) error {
return adapter.store.UpdateEntitlementRecord(ctx, record)
}
var _ ports.EntitlementHistoryStore = (*EntitlementHistoryStore)(nil)
// EntitlementLifecycleStore adapts Store to the existing
// EntitlementLifecycleStore port.
type EntitlementLifecycleStore struct {
store *Store
}
// EntitlementLifecycle returns one adapter that exposes the atomic
// entitlement-lifecycle store port over Store.
func (store *Store) EntitlementLifecycle() *EntitlementLifecycleStore {
if store == nil {
return nil
}
return &EntitlementLifecycleStore{store: store}
}
// Grant atomically applies one free-to-paid transition.
func (adapter *EntitlementLifecycleStore) Grant(ctx context.Context, input ports.GrantEntitlementInput) error {
return adapter.store.GrantEntitlement(ctx, input)
}
// Extend atomically appends one paid extension segment and updates the current
// snapshot.
func (adapter *EntitlementLifecycleStore) Extend(ctx context.Context, input ports.ExtendEntitlementInput) error {
return adapter.store.ExtendEntitlement(ctx, input)
}
// Revoke atomically applies one paid-to-free transition.
func (adapter *EntitlementLifecycleStore) Revoke(ctx context.Context, input ports.RevokeEntitlementInput) error {
return adapter.store.RevokeEntitlement(ctx, input)
}
// RepairExpired atomically repairs one expired finite paid snapshot.
func (adapter *EntitlementLifecycleStore) RepairExpired(
ctx context.Context,
input ports.RepairExpiredEntitlementInput,
) error {
return adapter.store.RepairExpiredEntitlement(ctx, input)
}
var _ ports.EntitlementLifecycleStore = (*EntitlementLifecycleStore)(nil)
@@ -0,0 +1,137 @@
package userstore
import (
"context"
"errors"
"fmt"
"time"
"galaxy/user/internal/adapters/redisstate"
"galaxy/user/internal/domain/common"
"galaxy/user/internal/ports"
"github.com/redis/go-redis/v9"
)
// ListUserIDs returns one deterministic page of user identifiers ordered by
// `created_at desc`, then `user_id desc`.
func (store *Store) ListUserIDs(ctx context.Context, input ports.ListUsersInput) (ports.ListUsersResult, error) {
if err := input.Validate(); err != nil {
return ports.ListUsersResult{}, fmt.Errorf("list users in redis: %w", err)
}
operationCtx, cancel, err := store.operationContext(ctx, "list users in redis")
if err != nil {
return ports.ListUsersResult{}, err
}
defer cancel()
startIndex := int64(0)
filters := userListFiltersFromPorts(input.Filters)
if input.PageToken != "" {
cursor, err := redisstate.DecodePageToken(input.PageToken, filters)
if err != nil {
return ports.ListUsersResult{}, fmt.Errorf("list users in redis: %w", ports.ErrInvalidPageToken)
}
score, err := store.client.ZScore(operationCtx, store.keyspace.CreatedAtIndex(), cursor.UserID.String()).Result()
switch {
case errors.Is(err, redis.Nil):
return ports.ListUsersResult{}, fmt.Errorf("list users in redis: %w", ports.ErrInvalidPageToken)
case err != nil:
return ports.ListUsersResult{}, fmt.Errorf("list users in redis: %w", err)
}
if !time.UnixMicro(int64(score)).UTC().Equal(cursor.CreatedAt.UTC()) {
return ports.ListUsersResult{}, fmt.Errorf("list users in redis: %w", ports.ErrInvalidPageToken)
}
rank, err := store.client.ZRevRank(operationCtx, store.keyspace.CreatedAtIndex(), cursor.UserID.String()).Result()
switch {
case errors.Is(err, redis.Nil):
return ports.ListUsersResult{}, fmt.Errorf("list users in redis: %w", ports.ErrInvalidPageToken)
case err != nil:
return ports.ListUsersResult{}, fmt.Errorf("list users in redis: %w", err)
}
startIndex = rank + 1
}
rawPage, err := store.client.ZRevRangeWithScores(
operationCtx,
store.keyspace.CreatedAtIndex(),
startIndex,
startIndex+int64(input.PageSize),
).Result()
if err != nil {
return ports.ListUsersResult{}, fmt.Errorf("list users in redis: %w", err)
}
result := ports.ListUsersResult{
UserIDs: make([]common.UserID, 0, min(len(rawPage), input.PageSize)),
}
visibleCount := min(len(rawPage), input.PageSize)
for index := 0; index < visibleCount; index++ {
userID, err := memberUserID(rawPage[index].Member)
if err != nil {
return ports.ListUsersResult{}, fmt.Errorf("list users in redis: %w", err)
}
result.UserIDs = append(result.UserIDs, userID)
}
if len(rawPage) > input.PageSize {
lastVisible := rawPage[input.PageSize-1]
lastUserID, err := memberUserID(lastVisible.Member)
if err != nil {
return ports.ListUsersResult{}, fmt.Errorf("list users in redis: %w", err)
}
token, err := redisstate.EncodePageToken(redisstate.PageCursor{
CreatedAt: time.UnixMicro(int64(lastVisible.Score)).UTC(),
UserID: lastUserID,
}, filters)
if err != nil {
return ports.ListUsersResult{}, fmt.Errorf("list users in redis: %w", err)
}
result.NextPageToken = token
}
return result, nil
}
func userListFiltersFromPorts(filters ports.UserListFilters) redisstate.UserListFilters {
return redisstate.UserListFilters{
PaidState: filters.PaidState,
PaidExpiresBefore: filters.PaidExpiresBefore,
PaidExpiresAfter: filters.PaidExpiresAfter,
DeclaredCountry: filters.DeclaredCountry,
SanctionCode: filters.SanctionCode,
LimitCode: filters.LimitCode,
CanLogin: filters.CanLogin,
CanCreatePrivateGame: filters.CanCreatePrivateGame,
CanJoinGame: filters.CanJoinGame,
}
}
func memberUserID(member any) (common.UserID, error) {
value, ok := member.(string)
if !ok {
return "", fmt.Errorf("unexpected created-at index member type %T", member)
}
userID := common.UserID(value)
if err := userID.Validate(); err != nil {
return "", fmt.Errorf("created-at index member user id: %w", err)
}
return userID, nil
}
func min(left int, right int) int {
if left < right {
return left
}
return right
}
var _ ports.UserListStore = (*Store)(nil)
@@ -0,0 +1,445 @@
package userstore
import (
"context"
"errors"
"fmt"
"time"
"galaxy/user/internal/domain/policy"
"galaxy/user/internal/ports"
"github.com/redis/go-redis/v9"
)
// ApplySanction atomically creates one new active sanction record.
func (store *Store) ApplySanction(ctx context.Context, input ports.ApplySanctionInput) error {
if err := input.Validate(); err != nil {
return fmt.Errorf("apply sanction in redis: %w", err)
}
recordPayload, err := marshalSanctionRecord(input.NewRecord)
if err != nil {
return fmt.Errorf("apply sanction in redis: %w", err)
}
recordKey := store.keyspace.SanctionRecord(input.NewRecord.RecordID)
historyKey := store.keyspace.SanctionHistory(input.NewRecord.UserID)
activeKey := store.keyspace.ActiveSanction(input.NewRecord.UserID, input.NewRecord.SanctionCode)
snapshotKey := store.keyspace.EntitlementSnapshot(input.NewRecord.UserID)
watchedKeys := append(
[]string{recordKey, historyKey, activeKey, snapshotKey},
store.activeSanctionWatchKeys(input.NewRecord.UserID)...,
)
operationCtx, cancel, err := store.operationContext(ctx, "apply sanction in redis")
if err != nil {
return err
}
defer cancel()
watchErr := store.client.Watch(operationCtx, func(tx *redis.Tx) error {
if err := ensureKeyAbsent(operationCtx, tx, recordKey); err != nil {
return fmt.Errorf("apply sanction for user %q in redis: %w", input.NewRecord.UserID, err)
}
if err := ensureKeyAbsent(operationCtx, tx, activeKey); err != nil {
return fmt.Errorf("apply sanction for user %q in redis: %w", input.NewRecord.UserID, err)
}
snapshot, err := store.loadEntitlementSnapshot(operationCtx, tx, input.NewRecord.UserID)
if err != nil {
return fmt.Errorf("apply sanction for user %q in redis: %w", input.NewRecord.UserID, err)
}
activeSanctionCodes, err := store.loadActiveSanctionCodeSet(operationCtx, tx, input.NewRecord.UserID)
if err != nil {
return fmt.Errorf("apply sanction for user %q in redis: %w", input.NewRecord.UserID, err)
}
activeSanctionCodes[input.NewRecord.SanctionCode] = struct{}{}
_, err = tx.TxPipelined(operationCtx, func(pipe redis.Pipeliner) error {
pipe.Set(operationCtx, recordKey, recordPayload, 0)
pipe.ZAdd(operationCtx, historyKey, redis.Z{
Score: float64(input.NewRecord.AppliedAt.UTC().UnixMicro()),
Member: input.NewRecord.RecordID.String(),
})
setActiveSlot(pipe, operationCtx, activeKey, input.NewRecord.RecordID.String(), input.NewRecord.ExpiresAt)
store.syncActiveSanctionCodeIndexes(pipe, operationCtx, input.NewRecord.UserID, activeSanctionCodes)
store.syncEligibilityMarkerIndexes(pipe, operationCtx, input.NewRecord.UserID, snapshot.IsPaid, activeSanctionCodes)
return nil
})
if err != nil {
return fmt.Errorf("apply sanction for user %q in redis: %w", input.NewRecord.UserID, err)
}
return nil
}, watchedKeys...)
switch {
case errors.Is(watchErr, redis.TxFailedErr):
return fmt.Errorf("apply sanction for user %q in redis: %w", input.NewRecord.UserID, ports.ErrConflict)
case watchErr != nil:
return watchErr
default:
return nil
}
}
// RemoveSanction atomically removes one active sanction record.
func (store *Store) RemoveSanction(ctx context.Context, input ports.RemoveSanctionInput) error {
if err := input.Validate(); err != nil {
return fmt.Errorf("remove sanction in redis: %w", err)
}
updatedPayload, err := marshalSanctionRecord(input.UpdatedRecord)
if err != nil {
return fmt.Errorf("remove sanction in redis: %w", err)
}
recordKey := store.keyspace.SanctionRecord(input.ExpectedActiveRecord.RecordID)
activeKey := store.keyspace.ActiveSanction(input.ExpectedActiveRecord.UserID, input.ExpectedActiveRecord.SanctionCode)
snapshotKey := store.keyspace.EntitlementSnapshot(input.ExpectedActiveRecord.UserID)
watchedKeys := append(
[]string{recordKey, activeKey, snapshotKey},
store.activeSanctionWatchKeys(input.ExpectedActiveRecord.UserID)...,
)
operationCtx, cancel, err := store.operationContext(ctx, "remove sanction in redis")
if err != nil {
return err
}
defer cancel()
watchErr := store.client.Watch(operationCtx, func(tx *redis.Tx) error {
activeRecordID, err := store.loadActiveSanctionRecordID(operationCtx, tx, activeKey)
if err != nil {
return fmt.Errorf("remove sanction for user %q in redis: %w", input.ExpectedActiveRecord.UserID, err)
}
if activeRecordID != input.ExpectedActiveRecord.RecordID {
return fmt.Errorf("remove sanction for user %q in redis: %w", input.ExpectedActiveRecord.UserID, ports.ErrConflict)
}
storedRecord, err := store.loadSanctionRecord(operationCtx, tx, input.ExpectedActiveRecord.RecordID)
if err != nil {
return fmt.Errorf("remove sanction for user %q in redis: %w", input.ExpectedActiveRecord.UserID, err)
}
if !equalSanctionRecords(storedRecord, input.ExpectedActiveRecord) {
return fmt.Errorf("remove sanction for user %q in redis: %w", input.ExpectedActiveRecord.UserID, ports.ErrConflict)
}
snapshot, err := store.loadEntitlementSnapshot(operationCtx, tx, input.ExpectedActiveRecord.UserID)
if err != nil {
return fmt.Errorf("remove sanction for user %q in redis: %w", input.ExpectedActiveRecord.UserID, err)
}
activeSanctionCodes, err := store.loadActiveSanctionCodeSet(operationCtx, tx, input.ExpectedActiveRecord.UserID)
if err != nil {
return fmt.Errorf("remove sanction for user %q in redis: %w", input.ExpectedActiveRecord.UserID, err)
}
delete(activeSanctionCodes, input.ExpectedActiveRecord.SanctionCode)
_, err = tx.TxPipelined(operationCtx, func(pipe redis.Pipeliner) error {
pipe.Set(operationCtx, recordKey, updatedPayload, 0)
pipe.Del(operationCtx, activeKey)
store.syncActiveSanctionCodeIndexes(pipe, operationCtx, input.ExpectedActiveRecord.UserID, activeSanctionCodes)
store.syncEligibilityMarkerIndexes(pipe, operationCtx, input.ExpectedActiveRecord.UserID, snapshot.IsPaid, activeSanctionCodes)
return nil
})
if err != nil {
return fmt.Errorf("remove sanction for user %q in redis: %w", input.ExpectedActiveRecord.UserID, err)
}
return nil
}, watchedKeys...)
switch {
case errors.Is(watchErr, redis.TxFailedErr):
return fmt.Errorf("remove sanction for user %q in redis: %w", input.ExpectedActiveRecord.UserID, ports.ErrConflict)
case watchErr != nil:
return watchErr
default:
return nil
}
}
// SetLimit atomically creates or replaces one active limit record.
func (store *Store) SetLimit(ctx context.Context, input ports.SetLimitInput) error {
if err := input.Validate(); err != nil {
return fmt.Errorf("set limit in redis: %w", err)
}
newRecordPayload, err := marshalLimitRecord(input.NewRecord)
if err != nil {
return fmt.Errorf("set limit in redis: %w", err)
}
newRecordKey := store.keyspace.LimitRecord(input.NewRecord.RecordID)
historyKey := store.keyspace.LimitHistory(input.NewRecord.UserID)
activeKey := store.keyspace.ActiveLimit(input.NewRecord.UserID, input.NewRecord.LimitCode)
watchedKeys := append(
[]string{newRecordKey, historyKey, activeKey},
store.activeLimitWatchKeys(input.NewRecord.UserID)...,
)
operationCtx, cancel, err := store.operationContext(ctx, "set limit in redis")
if err != nil {
return err
}
defer cancel()
if input.ExpectedActiveRecord != nil {
watchedKeys = append(watchedKeys, store.keyspace.LimitRecord(input.ExpectedActiveRecord.RecordID))
}
watchErr := store.client.Watch(operationCtx, func(tx *redis.Tx) error {
if err := ensureKeyAbsent(operationCtx, tx, newRecordKey); err != nil {
return fmt.Errorf("set limit for user %q in redis: %w", input.NewRecord.UserID, err)
}
var updatedPayload []byte
if input.ExpectedActiveRecord == nil {
if err := ensureKeyAbsent(operationCtx, tx, activeKey); err != nil {
return fmt.Errorf("set limit for user %q in redis: %w", input.NewRecord.UserID, err)
}
} else {
activeRecordID, err := store.loadActiveLimitRecordID(operationCtx, tx, activeKey)
if err != nil {
return fmt.Errorf("set limit for user %q in redis: %w", input.NewRecord.UserID, err)
}
if activeRecordID != input.ExpectedActiveRecord.RecordID {
return fmt.Errorf("set limit for user %q in redis: %w", input.NewRecord.UserID, ports.ErrConflict)
}
storedRecord, err := store.loadLimitRecord(operationCtx, tx, input.ExpectedActiveRecord.RecordID)
if err != nil {
return fmt.Errorf("set limit for user %q in redis: %w", input.NewRecord.UserID, err)
}
if !equalLimitRecords(storedRecord, *input.ExpectedActiveRecord) {
return fmt.Errorf("set limit for user %q in redis: %w", input.NewRecord.UserID, ports.ErrConflict)
}
updatedPayload, err = marshalLimitRecord(*input.UpdatedActiveRecord)
if err != nil {
return fmt.Errorf("set limit for user %q in redis: %w", input.NewRecord.UserID, err)
}
}
activeLimitCodes, err := store.loadActiveLimitCodeSet(operationCtx, tx, input.NewRecord.UserID)
if err != nil {
return fmt.Errorf("set limit for user %q in redis: %w", input.NewRecord.UserID, err)
}
activeLimitCodes[input.NewRecord.LimitCode] = struct{}{}
_, err = tx.TxPipelined(operationCtx, func(pipe redis.Pipeliner) error {
if input.ExpectedActiveRecord != nil {
pipe.Set(operationCtx, store.keyspace.LimitRecord(input.ExpectedActiveRecord.RecordID), updatedPayload, 0)
}
pipe.Set(operationCtx, newRecordKey, newRecordPayload, 0)
pipe.ZAdd(operationCtx, historyKey, redis.Z{
Score: float64(input.NewRecord.AppliedAt.UTC().UnixMicro()),
Member: input.NewRecord.RecordID.String(),
})
setActiveSlot(pipe, operationCtx, activeKey, input.NewRecord.RecordID.String(), input.NewRecord.ExpiresAt)
store.syncActiveLimitCodeIndexes(pipe, operationCtx, input.NewRecord.UserID, activeLimitCodes)
return nil
})
if err != nil {
return fmt.Errorf("set limit for user %q in redis: %w", input.NewRecord.UserID, err)
}
return nil
}, watchedKeys...)
switch {
case errors.Is(watchErr, redis.TxFailedErr):
return fmt.Errorf("set limit for user %q in redis: %w", input.NewRecord.UserID, ports.ErrConflict)
case watchErr != nil:
return watchErr
default:
return nil
}
}
// RemoveLimit atomically removes one active limit record.
func (store *Store) RemoveLimit(ctx context.Context, input ports.RemoveLimitInput) error {
if err := input.Validate(); err != nil {
return fmt.Errorf("remove limit in redis: %w", err)
}
updatedPayload, err := marshalLimitRecord(input.UpdatedRecord)
if err != nil {
return fmt.Errorf("remove limit in redis: %w", err)
}
recordKey := store.keyspace.LimitRecord(input.ExpectedActiveRecord.RecordID)
activeKey := store.keyspace.ActiveLimit(input.ExpectedActiveRecord.UserID, input.ExpectedActiveRecord.LimitCode)
watchedKeys := append(
[]string{recordKey, activeKey},
store.activeLimitWatchKeys(input.ExpectedActiveRecord.UserID)...,
)
operationCtx, cancel, err := store.operationContext(ctx, "remove limit in redis")
if err != nil {
return err
}
defer cancel()
watchErr := store.client.Watch(operationCtx, func(tx *redis.Tx) error {
activeRecordID, err := store.loadActiveLimitRecordID(operationCtx, tx, activeKey)
if err != nil {
return fmt.Errorf("remove limit for user %q in redis: %w", input.ExpectedActiveRecord.UserID, err)
}
if activeRecordID != input.ExpectedActiveRecord.RecordID {
return fmt.Errorf("remove limit for user %q in redis: %w", input.ExpectedActiveRecord.UserID, ports.ErrConflict)
}
storedRecord, err := store.loadLimitRecord(operationCtx, tx, input.ExpectedActiveRecord.RecordID)
if err != nil {
return fmt.Errorf("remove limit for user %q in redis: %w", input.ExpectedActiveRecord.UserID, err)
}
if !equalLimitRecords(storedRecord, input.ExpectedActiveRecord) {
return fmt.Errorf("remove limit for user %q in redis: %w", input.ExpectedActiveRecord.UserID, ports.ErrConflict)
}
activeLimitCodes, err := store.loadActiveLimitCodeSet(operationCtx, tx, input.ExpectedActiveRecord.UserID)
if err != nil {
return fmt.Errorf("remove limit for user %q in redis: %w", input.ExpectedActiveRecord.UserID, err)
}
delete(activeLimitCodes, input.ExpectedActiveRecord.LimitCode)
_, err = tx.TxPipelined(operationCtx, func(pipe redis.Pipeliner) error {
pipe.Set(operationCtx, recordKey, updatedPayload, 0)
pipe.Del(operationCtx, activeKey)
store.syncActiveLimitCodeIndexes(pipe, operationCtx, input.ExpectedActiveRecord.UserID, activeLimitCodes)
return nil
})
if err != nil {
return fmt.Errorf("remove limit for user %q in redis: %w", input.ExpectedActiveRecord.UserID, err)
}
return nil
}, watchedKeys...)
switch {
case errors.Is(watchErr, redis.TxFailedErr):
return fmt.Errorf("remove limit for user %q in redis: %w", input.ExpectedActiveRecord.UserID, ports.ErrConflict)
case watchErr != nil:
return watchErr
default:
return nil
}
}
func (store *Store) loadActiveSanctionRecordID(
ctx context.Context,
getter bytesGetter,
key string,
) (policy.SanctionRecordID, error) {
value, err := getter.Get(ctx, key).Result()
switch {
case errors.Is(err, redis.Nil):
return "", ports.ErrNotFound
case err != nil:
return "", err
}
recordID := policy.SanctionRecordID(value)
if err := recordID.Validate(); err != nil {
return "", fmt.Errorf("active sanction record id: %w", err)
}
return recordID, nil
}
func (store *Store) loadActiveLimitRecordID(
ctx context.Context,
getter bytesGetter,
key string,
) (policy.LimitRecordID, error) {
value, err := getter.Get(ctx, key).Result()
switch {
case errors.Is(err, redis.Nil):
return "", ports.ErrNotFound
case err != nil:
return "", err
}
recordID := policy.LimitRecordID(value)
if err := recordID.Validate(); err != nil {
return "", fmt.Errorf("active limit record id: %w", err)
}
return recordID, nil
}
func setActiveSlot(
pipe redis.Pipeliner,
ctx context.Context,
key string,
recordID string,
expiresAt *time.Time,
) {
pipe.Set(ctx, key, recordID, 0)
if expiresAt != nil {
pipe.PExpireAt(ctx, key, expiresAt.UTC())
}
}
func equalSanctionRecords(left policy.SanctionRecord, right policy.SanctionRecord) bool {
return left.RecordID == right.RecordID &&
left.UserID == right.UserID &&
left.SanctionCode == right.SanctionCode &&
left.Scope == right.Scope &&
left.ReasonCode == right.ReasonCode &&
left.Actor == right.Actor &&
left.AppliedAt.Equal(right.AppliedAt) &&
equalOptionalTime(left.ExpiresAt, right.ExpiresAt) &&
equalOptionalTime(left.RemovedAt, right.RemovedAt) &&
left.RemovedBy == right.RemovedBy &&
left.RemovedReasonCode == right.RemovedReasonCode
}
func equalLimitRecords(left policy.LimitRecord, right policy.LimitRecord) bool {
return left.RecordID == right.RecordID &&
left.UserID == right.UserID &&
left.LimitCode == right.LimitCode &&
left.Value == right.Value &&
left.ReasonCode == right.ReasonCode &&
left.Actor == right.Actor &&
left.AppliedAt.Equal(right.AppliedAt) &&
equalOptionalTime(left.ExpiresAt, right.ExpiresAt) &&
equalOptionalTime(left.RemovedAt, right.RemovedAt) &&
left.RemovedBy == right.RemovedBy &&
left.RemovedReasonCode == right.RemovedReasonCode
}
// PolicyLifecycleStore adapts Store to the existing PolicyLifecycleStore
// port.
type PolicyLifecycleStore struct {
store *Store
}
// PolicyLifecycle returns one adapter that exposes the atomic policy-lifecycle
// store port over Store.
func (store *Store) PolicyLifecycle() *PolicyLifecycleStore {
if store == nil {
return nil
}
return &PolicyLifecycleStore{store: store}
}
// ApplySanction atomically creates one new active sanction record.
func (adapter *PolicyLifecycleStore) ApplySanction(ctx context.Context, input ports.ApplySanctionInput) error {
return adapter.store.ApplySanction(ctx, input)
}
// RemoveSanction atomically removes one active sanction record.
func (adapter *PolicyLifecycleStore) RemoveSanction(ctx context.Context, input ports.RemoveSanctionInput) error {
return adapter.store.RemoveSanction(ctx, input)
}
// SetLimit atomically creates or replaces one active limit record.
func (adapter *PolicyLifecycleStore) SetLimit(ctx context.Context, input ports.SetLimitInput) error {
return adapter.store.SetLimit(ctx, input)
}
// RemoveLimit atomically removes one active limit record.
func (adapter *PolicyLifecycleStore) RemoveLimit(ctx context.Context, input ports.RemoveLimitInput) error {
return adapter.store.RemoveLimit(ctx, input)
}
var _ ports.PolicyLifecycleStore = (*PolicyLifecycleStore)(nil)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,930 @@
package userstore
import (
"context"
"strings"
"testing"
"time"
"galaxy/user/internal/domain/account"
"galaxy/user/internal/domain/authblock"
"galaxy/user/internal/domain/common"
"galaxy/user/internal/domain/entitlement"
"galaxy/user/internal/domain/policy"
"galaxy/user/internal/ports"
"github.com/alicebob/miniredis/v2"
"github.com/stretchr/testify/require"
)
func TestAccountStoreCreateAndLookups(t *testing.T) {
t.Parallel()
store := newTestStore(t)
accountStore := store.Accounts()
record := validAccountRecord()
require.NoError(t, accountStore.Create(context.Background(), createAccountInput(record)))
byUserID, err := accountStore.GetByUserID(context.Background(), record.UserID)
require.NoError(t, err)
require.Equal(t, record, byUserID)
byEmail, err := accountStore.GetByEmail(context.Background(), record.Email)
require.NoError(t, err)
require.Equal(t, record, byEmail)
byRaceName, err := accountStore.GetByRaceName(context.Background(), record.RaceName)
require.NoError(t, err)
require.Equal(t, record, byRaceName)
exists, err := accountStore.ExistsByUserID(context.Background(), record.UserID)
require.NoError(t, err)
require.True(t, exists)
reservation, err := store.loadRaceNameReservation(context.Background(), store.client, canonicalKey(record.RaceName))
require.NoError(t, err)
require.Equal(t, record.UserID, reservation.UserID)
require.Equal(t, record.RaceName, reservation.RaceName)
}
func TestBlockedEmailStoreUpsertAndGet(t *testing.T) {
t.Parallel()
store := newTestStore(t)
blockedEmailStore := store.BlockedEmails()
record := authblock.BlockedEmailSubject{
Email: common.Email("blocked@example.com"),
ReasonCode: common.ReasonCode("policy_blocked"),
BlockedAt: time.Unix(1_775_240_100, 0).UTC(),
ResolvedUserID: common.UserID("user-123"),
}
require.NoError(t, blockedEmailStore.Upsert(context.Background(), record))
got, err := blockedEmailStore.GetByEmail(context.Background(), record.Email)
require.NoError(t, err)
require.Equal(t, record, got)
}
func TestEnsureResolveAndBlockFlows(t *testing.T) {
t.Parallel()
store := newTestStore(t)
now := time.Unix(1_775_240_000, 0).UTC()
accountRecord := validAccountRecord()
entitlementSnapshot := validEntitlementSnapshot(accountRecord.UserID, now)
created, err := store.EnsureByEmail(context.Background(), ports.EnsureByEmailInput{
Email: accountRecord.Email,
Account: accountRecord,
Entitlement: entitlementSnapshot,
EntitlementRecord: validEntitlementRecord(accountRecord.UserID, now),
Reservation: raceNameReservation(accountRecord.UserID, accountRecord.RaceName, accountRecord.UpdatedAt),
})
require.NoError(t, err)
require.Equal(t, ports.EnsureByEmailOutcomeCreated, created.Outcome)
reservation, err := store.loadRaceNameReservation(context.Background(), store.client, canonicalKey(accountRecord.RaceName))
require.NoError(t, err)
require.Equal(t, accountRecord.UserID, reservation.UserID)
entitlementHistory, err := store.ListEntitlementRecordsByUserID(context.Background(), accountRecord.UserID)
require.NoError(t, err)
require.Len(t, entitlementHistory, 1)
require.Equal(t, validEntitlementRecord(accountRecord.UserID, now), entitlementHistory[0])
resolved, err := store.ResolveByEmail(context.Background(), accountRecord.Email)
require.NoError(t, err)
require.Equal(t, ports.AuthResolutionKindExisting, resolved.Kind)
blockedByUserID, err := store.BlockByUserID(context.Background(), ports.BlockByUserIDInput{
UserID: accountRecord.UserID,
ReasonCode: common.ReasonCode("policy_blocked"),
BlockedAt: now.Add(time.Minute),
})
require.NoError(t, err)
require.Equal(t, ports.AuthBlockOutcomeBlocked, blockedByUserID.Outcome)
repeatedBlock, err := store.BlockByEmail(context.Background(), ports.BlockByEmailInput{
Email: accountRecord.Email,
ReasonCode: common.ReasonCode("policy_blocked"),
BlockedAt: now.Add(2 * time.Minute),
})
require.NoError(t, err)
require.Equal(t, ports.AuthBlockOutcomeAlreadyBlocked, repeatedBlock.Outcome)
require.Equal(t, accountRecord.UserID, repeatedBlock.UserID)
blockedResolution, err := store.ResolveByEmail(context.Background(), accountRecord.Email)
require.NoError(t, err)
require.Equal(t, ports.AuthResolutionKindBlocked, blockedResolution.Kind)
ensureBlocked, err := store.EnsureByEmail(context.Background(), ports.EnsureByEmailInput{
Email: accountRecord.Email,
Account: accountRecord,
Entitlement: entitlementSnapshot,
EntitlementRecord: validEntitlementRecord(accountRecord.UserID, now),
Reservation: raceNameReservation(accountRecord.UserID, accountRecord.RaceName, accountRecord.UpdatedAt),
})
require.NoError(t, err)
require.Equal(t, ports.EnsureByEmailOutcomeBlocked, ensureBlocked.Outcome)
}
func TestBlockedEmailWithoutUserPreventsEnsureCreate(t *testing.T) {
t.Parallel()
store := newTestStore(t)
now := time.Unix(1_775_240_000, 0).UTC()
accountRecord := validAccountRecord()
entitlementSnapshot := validEntitlementSnapshot(accountRecord.UserID, now)
blocked, err := store.BlockByEmail(context.Background(), ports.BlockByEmailInput{
Email: accountRecord.Email,
ReasonCode: common.ReasonCode("policy_blocked"),
BlockedAt: now,
})
require.NoError(t, err)
require.Equal(t, ports.AuthBlockOutcomeBlocked, blocked.Outcome)
require.True(t, blocked.UserID.IsZero())
resolved, err := store.ResolveByEmail(context.Background(), accountRecord.Email)
require.NoError(t, err)
require.Equal(t, ports.AuthResolutionKindBlocked, resolved.Kind)
ensured, err := store.EnsureByEmail(context.Background(), ports.EnsureByEmailInput{
Email: accountRecord.Email,
Account: accountRecord,
Entitlement: entitlementSnapshot,
EntitlementRecord: validEntitlementRecord(accountRecord.UserID, now),
Reservation: raceNameReservation(accountRecord.UserID, accountRecord.RaceName, accountRecord.UpdatedAt),
})
require.NoError(t, err)
require.Equal(t, ports.EnsureByEmailOutcomeBlocked, ensured.Outcome)
exists, err := store.ExistsByUserID(context.Background(), accountRecord.UserID)
require.NoError(t, err)
require.False(t, exists)
}
func TestEnsureByEmailExistingDoesNotOverwriteStoredSettings(t *testing.T) {
t.Parallel()
store := newTestStore(t)
createdAt := time.Unix(1_775_240_000, 0).UTC()
existingAccount := account.UserAccount{
UserID: common.UserID("user-existing"),
Email: common.Email("pilot@example.com"),
RaceName: common.RaceName("Pilot Nova"),
PreferredLanguage: common.LanguageTag("en"),
TimeZone: common.TimeZoneName("Europe/Kaliningrad"),
CreatedAt: createdAt,
UpdatedAt: createdAt,
}
require.NoError(t, store.Create(context.Background(), createAccountInput(existingAccount)))
result, err := store.EnsureByEmail(context.Background(), ports.EnsureByEmailInput{
Email: existingAccount.Email,
Account: account.UserAccount{
UserID: common.UserID("user-created"),
Email: existingAccount.Email,
RaceName: common.RaceName("player-new123"),
PreferredLanguage: common.LanguageTag("fr-FR"),
TimeZone: common.TimeZoneName("UTC"),
CreatedAt: createdAt.Add(time.Minute),
UpdatedAt: createdAt.Add(time.Minute),
},
Entitlement: validEntitlementSnapshot(common.UserID("user-created"), createdAt.Add(time.Minute)),
EntitlementRecord: validEntitlementRecord(common.UserID("user-created"), createdAt.Add(time.Minute)),
Reservation: raceNameReservation(common.UserID("user-created"), common.RaceName("player-new123"), createdAt.Add(time.Minute)),
})
require.NoError(t, err)
require.Equal(t, ports.EnsureByEmailOutcomeExisting, result.Outcome)
require.Equal(t, existingAccount.UserID, result.UserID)
storedAccount, err := store.GetByEmail(context.Background(), existingAccount.Email)
require.NoError(t, err)
require.Equal(t, existingAccount, storedAccount)
}
func TestAccountStoreRenameRaceNameSwapsLookupAtomically(t *testing.T) {
t.Parallel()
store := newTestStore(t)
accountStore := store.Accounts()
record := validAccountRecord()
require.NoError(t, accountStore.Create(context.Background(), createAccountInput(record)))
updatedAt := record.UpdatedAt.Add(time.Minute)
require.NoError(t, accountStore.RenameRaceName(context.Background(), renameRaceNameInput(record, common.RaceName("Nova Prime"), updatedAt)))
stored, err := accountStore.GetByUserID(context.Background(), record.UserID)
require.NoError(t, err)
require.Equal(t, common.RaceName("Nova Prime"), stored.RaceName)
require.True(t, stored.UpdatedAt.Equal(updatedAt))
_, err = accountStore.GetByRaceName(context.Background(), record.RaceName)
require.ErrorIs(t, err, ports.ErrNotFound)
renamed, err := accountStore.GetByRaceName(context.Background(), common.RaceName("Nova Prime"))
require.NoError(t, err)
require.Equal(t, record.UserID, renamed.UserID)
_, err = store.loadRaceNameReservation(context.Background(), store.client, canonicalKey(record.RaceName))
require.ErrorIs(t, err, ports.ErrNotFound)
reservation, err := store.loadRaceNameReservation(context.Background(), store.client, canonicalKey(common.RaceName("Nova Prime")))
require.NoError(t, err)
require.Equal(t, common.RaceName("Nova Prime"), reservation.RaceName)
}
func TestAccountStoreRenameRaceNameAllowsSameOwnerCanonicalSlot(t *testing.T) {
t.Parallel()
store := newTestStore(t)
accountStore := store.Accounts()
record := validAccountRecord()
record.RaceName = common.RaceName("Pilot Nova")
require.NoError(t, accountStore.Create(context.Background(), createAccountInput(record)))
updatedAt := record.UpdatedAt.Add(time.Minute)
require.NoError(t, accountStore.RenameRaceName(context.Background(), renameRaceNameInput(record, common.RaceName("P1lot Nova"), updatedAt)))
reservation, err := store.loadRaceNameReservation(context.Background(), store.client, canonicalKey(common.RaceName("P1lot Nova")))
require.NoError(t, err)
require.Equal(t, common.RaceName("P1lot Nova"), reservation.RaceName)
}
func TestAccountStoreRenameRaceNameReturnsConflictWhenTargetExists(t *testing.T) {
t.Parallel()
store := newTestStore(t)
accountStore := store.Accounts()
first := validAccountRecord()
second := validAccountRecord()
second.UserID = common.UserID("user-456")
second.Email = common.Email("other@example.com")
second.RaceName = common.RaceName("Taken Name")
require.NoError(t, accountStore.Create(context.Background(), createAccountInput(first)))
require.NoError(t, accountStore.Create(context.Background(), createAccountInput(second)))
err := accountStore.RenameRaceName(context.Background(), renameRaceNameInput(first, second.RaceName, first.UpdatedAt.Add(time.Minute)))
require.ErrorIs(t, err, ports.ErrConflict)
stored, err := accountStore.GetByUserID(context.Background(), first.UserID)
require.NoError(t, err)
require.Equal(t, first.RaceName, stored.RaceName)
}
func TestAccountStoreUpdateDeclaredCountryPreservesLookups(t *testing.T) {
t.Parallel()
store := newTestStore(t)
accountStore := store.Accounts()
record := validAccountRecord()
require.NoError(t, accountStore.Create(context.Background(), createAccountInput(record)))
updated := record
updated.DeclaredCountry = common.CountryCode("FR")
updated.UpdatedAt = record.UpdatedAt.Add(time.Minute)
require.NoError(t, accountStore.Update(context.Background(), updated))
byUserID, err := accountStore.GetByUserID(context.Background(), record.UserID)
require.NoError(t, err)
require.Equal(t, updated, byUserID)
byEmail, err := accountStore.GetByEmail(context.Background(), record.Email)
require.NoError(t, err)
require.Equal(t, updated, byEmail)
byRaceName, err := accountStore.GetByRaceName(context.Background(), record.RaceName)
require.NoError(t, err)
require.Equal(t, updated, byRaceName)
}
func TestAccountStoreCreateReturnsConflictWhenCanonicalReservationExists(t *testing.T) {
t.Parallel()
store := newTestStore(t)
accountStore := store.Accounts()
first := validAccountRecord()
second := validAccountRecord()
second.UserID = common.UserID("user-456")
second.Email = common.Email("other@example.com")
second.RaceName = common.RaceName("P1lot Nova")
require.NoError(t, accountStore.Create(context.Background(), createAccountInput(first)))
err := accountStore.Create(context.Background(), createAccountInput(second))
require.ErrorIs(t, err, ports.ErrConflict)
}
func TestBlockByUserIDRepeatedCallsStayIdempotent(t *testing.T) {
t.Parallel()
store := newTestStore(t)
now := time.Unix(1_775_240_000, 0).UTC()
accountRecord := validAccountRecord()
require.NoError(t, store.Create(context.Background(), createAccountInput(accountRecord)))
first, err := store.BlockByUserID(context.Background(), ports.BlockByUserIDInput{
UserID: accountRecord.UserID,
ReasonCode: common.ReasonCode("policy_blocked"),
BlockedAt: now,
})
require.NoError(t, err)
require.Equal(t, ports.AuthBlockOutcomeBlocked, first.Outcome)
second, err := store.BlockByUserID(context.Background(), ports.BlockByUserIDInput{
UserID: accountRecord.UserID,
ReasonCode: common.ReasonCode("policy_blocked"),
BlockedAt: now.Add(time.Minute),
})
require.NoError(t, err)
require.Equal(t, ports.AuthBlockOutcomeAlreadyBlocked, second.Outcome)
require.Equal(t, accountRecord.UserID, second.UserID)
}
func TestBlockByUserIDUnknownUserReturnsNotFound(t *testing.T) {
t.Parallel()
store := newTestStore(t)
_, err := store.BlockByUserID(context.Background(), ports.BlockByUserIDInput{
UserID: common.UserID("user-missing"),
ReasonCode: common.ReasonCode("policy_blocked"),
BlockedAt: time.Unix(1_775_240_000, 0).UTC(),
})
require.ErrorIs(t, err, ports.ErrNotFound)
}
func TestSanctionAndLimitStoresRoundTrip(t *testing.T) {
t.Parallel()
store := newTestStore(t)
sanctionStore := store.Sanctions()
limitStore := store.Limits()
now := time.Unix(1_775_240_000, 0).UTC()
sanctionRecord := policy.SanctionRecord{
RecordID: policy.SanctionRecordID("sanction-1"),
UserID: common.UserID("user-123"),
SanctionCode: policy.SanctionCodeLoginBlock,
Scope: common.Scope("self_service"),
ReasonCode: common.ReasonCode("policy_enforced"),
Actor: common.ActorRef{Type: common.ActorType("service"), ID: common.ActorID("user-service")},
AppliedAt: now,
}
require.NoError(t, sanctionStore.Create(context.Background(), sanctionRecord))
gotSanction, err := sanctionStore.GetByRecordID(context.Background(), sanctionRecord.RecordID)
require.NoError(t, err)
require.Equal(t, sanctionRecord, gotSanction)
sanctions, err := sanctionStore.ListByUserID(context.Background(), sanctionRecord.UserID)
require.NoError(t, err)
require.Len(t, sanctions, 1)
expiresAt := now.Add(time.Hour)
sanctionRecord.ExpiresAt = &expiresAt
require.NoError(t, sanctionStore.Update(context.Background(), sanctionRecord))
gotSanction, err = sanctionStore.GetByRecordID(context.Background(), sanctionRecord.RecordID)
require.NoError(t, err)
require.Equal(t, sanctionRecord.RecordID, gotSanction.RecordID)
require.Equal(t, sanctionRecord.UserID, gotSanction.UserID)
require.Equal(t, sanctionRecord.SanctionCode, gotSanction.SanctionCode)
require.Equal(t, sanctionRecord.Scope, gotSanction.Scope)
require.Equal(t, sanctionRecord.ReasonCode, gotSanction.ReasonCode)
require.Equal(t, sanctionRecord.Actor, gotSanction.Actor)
require.True(t, gotSanction.AppliedAt.Equal(sanctionRecord.AppliedAt))
require.NotNil(t, gotSanction.ExpiresAt)
require.True(t, gotSanction.ExpiresAt.Equal(*sanctionRecord.ExpiresAt))
limitRecord := policy.LimitRecord{
RecordID: policy.LimitRecordID("limit-1"),
UserID: common.UserID("user-123"),
LimitCode: policy.LimitCodeMaxOwnedPrivateGames,
Value: 3,
ReasonCode: common.ReasonCode("policy_enforced"),
Actor: common.ActorRef{Type: common.ActorType("service"), ID: common.ActorID("user-service")},
AppliedAt: now,
}
require.NoError(t, limitStore.Create(context.Background(), limitRecord))
gotLimit, err := limitStore.GetByRecordID(context.Background(), limitRecord.RecordID)
require.NoError(t, err)
require.Equal(t, limitRecord, gotLimit)
limits, err := limitStore.ListByUserID(context.Background(), limitRecord.UserID)
require.NoError(t, err)
require.Len(t, limits, 1)
limitRecord.Value = 5
require.NoError(t, limitStore.Update(context.Background(), limitRecord))
gotLimit, err = limitStore.GetByRecordID(context.Background(), limitRecord.RecordID)
require.NoError(t, err)
require.Equal(t, limitRecord, gotLimit)
}
func TestPolicyLifecycleApplyAndRemoveSanction(t *testing.T) {
t.Parallel()
store := newTestStore(t)
lifecycleStore := store.PolicyLifecycle()
sanctionStore := store.Sanctions()
snapshotStore := store.EntitlementSnapshots()
now := time.Unix(1_775_240_000, 0).UTC()
userID := common.UserID("user-123")
require.NoError(t, snapshotStore.Put(context.Background(), validEntitlementSnapshot(userID, now)))
record := policy.SanctionRecord{
RecordID: policy.SanctionRecordID("sanction-1"),
UserID: userID,
SanctionCode: policy.SanctionCodeLoginBlock,
Scope: common.Scope("auth"),
ReasonCode: common.ReasonCode("manual_block"),
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
AppliedAt: now,
}
require.NoError(t, lifecycleStore.ApplySanction(context.Background(), ports.ApplySanctionInput{
NewRecord: record,
}))
activeRecordID, err := store.loadActiveSanctionRecordID(
context.Background(),
store.client,
store.keyspace.ActiveSanction(userID, policy.SanctionCodeLoginBlock),
)
require.NoError(t, err)
require.Equal(t, record.RecordID, activeRecordID)
err = lifecycleStore.ApplySanction(context.Background(), ports.ApplySanctionInput{
NewRecord: policy.SanctionRecord{
RecordID: policy.SanctionRecordID("sanction-2"),
UserID: userID,
SanctionCode: policy.SanctionCodeLoginBlock,
Scope: common.Scope("auth"),
ReasonCode: common.ReasonCode("manual_block"),
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-2")},
AppliedAt: now.Add(time.Minute),
},
})
require.ErrorIs(t, err, ports.ErrConflict)
removed := record
removedAt := now.Add(30 * time.Minute)
removed.RemovedAt = &removedAt
removed.RemovedBy = common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-2")}
removed.RemovedReasonCode = common.ReasonCode("manual_remove")
require.NoError(t, lifecycleStore.RemoveSanction(context.Background(), ports.RemoveSanctionInput{
ExpectedActiveRecord: record,
UpdatedRecord: removed,
}))
stored, err := sanctionStore.GetByRecordID(context.Background(), record.RecordID)
require.NoError(t, err)
require.Equal(t, removed, stored)
_, err = store.loadActiveSanctionRecordID(
context.Background(),
store.client,
store.keyspace.ActiveSanction(userID, policy.SanctionCodeLoginBlock),
)
require.ErrorIs(t, err, ports.ErrNotFound)
}
func TestPolicyLifecycleSetAndRemoveLimit(t *testing.T) {
t.Parallel()
store := newTestStore(t)
lifecycleStore := store.PolicyLifecycle()
limitStore := store.Limits()
now := time.Unix(1_775_240_000, 0).UTC()
userID := common.UserID("user-123")
first := policy.LimitRecord{
RecordID: policy.LimitRecordID("limit-1"),
UserID: userID,
LimitCode: policy.LimitCodeMaxOwnedPrivateGames,
Value: 3,
ReasonCode: common.ReasonCode("manual_override"),
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
AppliedAt: now,
}
require.NoError(t, lifecycleStore.SetLimit(context.Background(), ports.SetLimitInput{
NewRecord: first,
}))
activeRecordID, err := store.loadActiveLimitRecordID(
context.Background(),
store.client,
store.keyspace.ActiveLimit(userID, policy.LimitCodeMaxOwnedPrivateGames),
)
require.NoError(t, err)
require.Equal(t, first.RecordID, activeRecordID)
second := policy.LimitRecord{
RecordID: policy.LimitRecordID("limit-2"),
UserID: userID,
LimitCode: policy.LimitCodeMaxOwnedPrivateGames,
Value: 5,
ReasonCode: common.ReasonCode("manual_override"),
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-2")},
AppliedAt: now.Add(time.Hour),
}
updatedFirst := first
removedAt := second.AppliedAt
updatedFirst.RemovedAt = &removedAt
updatedFirst.RemovedBy = second.Actor
updatedFirst.RemovedReasonCode = second.ReasonCode
require.NoError(t, lifecycleStore.SetLimit(context.Background(), ports.SetLimitInput{
ExpectedActiveRecord: &first,
UpdatedActiveRecord: &updatedFirst,
NewRecord: second,
}))
storedFirst, err := limitStore.GetByRecordID(context.Background(), first.RecordID)
require.NoError(t, err)
require.Equal(t, updatedFirst, storedFirst)
activeRecordID, err = store.loadActiveLimitRecordID(
context.Background(),
store.client,
store.keyspace.ActiveLimit(userID, policy.LimitCodeMaxOwnedPrivateGames),
)
require.NoError(t, err)
require.Equal(t, second.RecordID, activeRecordID)
removedSecond := second
removeAt := now.Add(90 * time.Minute)
removedSecond.RemovedAt = &removeAt
removedSecond.RemovedBy = common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-3")}
removedSecond.RemovedReasonCode = common.ReasonCode("manual_remove")
require.NoError(t, lifecycleStore.RemoveLimit(context.Background(), ports.RemoveLimitInput{
ExpectedActiveRecord: second,
UpdatedRecord: removedSecond,
}))
storedSecond, err := limitStore.GetByRecordID(context.Background(), second.RecordID)
require.NoError(t, err)
require.Equal(t, removedSecond, storedSecond)
_, err = store.loadActiveLimitRecordID(
context.Background(),
store.client,
store.keyspace.ActiveLimit(userID, policy.LimitCodeMaxOwnedPrivateGames),
)
require.ErrorIs(t, err, ports.ErrNotFound)
}
func TestEntitlementLifecycleTransitions(t *testing.T) {
t.Parallel()
store := newTestStore(t)
historyStore := store.EntitlementHistory()
snapshotStore := store.EntitlementSnapshots()
lifecycleStore := store.EntitlementLifecycle()
userID := common.UserID("user-123")
startedFreeAt := time.Unix(1_775_240_000, 0).UTC()
freeRecord := validEntitlementRecord(userID, startedFreeAt)
freeSnapshot := validEntitlementSnapshot(userID, startedFreeAt)
require.NoError(t, historyStore.Create(context.Background(), freeRecord))
require.NoError(t, snapshotStore.Put(context.Background(), freeSnapshot))
grantStartsAt := startedFreeAt.Add(24 * time.Hour)
grantEndsAt := grantStartsAt.Add(30 * 24 * time.Hour)
grantedRecord := paidEntitlementRecord(
entitlement.EntitlementRecordID("entitlement-paid-1"),
userID,
entitlement.PlanCodePaidMonthly,
grantStartsAt,
grantEndsAt,
common.Source("admin"),
common.ReasonCode("manual_grant"),
)
grantedSnapshot := paidEntitlementSnapshot(
userID,
entitlement.PlanCodePaidMonthly,
grantStartsAt,
grantEndsAt,
common.Source("admin"),
common.ReasonCode("manual_grant"),
)
closedFreeRecord := freeRecord
closedFreeRecord.ClosedAt = timePointer(grantStartsAt)
closedFreeRecord.ClosedBy = common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")}
closedFreeRecord.ClosedReasonCode = common.ReasonCode("manual_grant")
require.NoError(t, lifecycleStore.Grant(context.Background(), ports.GrantEntitlementInput{
ExpectedCurrentSnapshot: freeSnapshot,
ExpectedCurrentRecord: freeRecord,
UpdatedCurrentRecord: closedFreeRecord,
NewRecord: grantedRecord,
NewSnapshot: grantedSnapshot,
}))
storedSnapshot, err := snapshotStore.GetByUserID(context.Background(), userID)
require.NoError(t, err)
require.Equal(t, grantedSnapshot, storedSnapshot)
storedFreeRecord, err := historyStore.GetByRecordID(context.Background(), freeRecord.RecordID)
require.NoError(t, err)
require.Equal(t, closedFreeRecord, storedFreeRecord)
extendedEndsAt := grantEndsAt.Add(30 * 24 * time.Hour)
extensionRecord := paidEntitlementRecord(
entitlement.EntitlementRecordID("entitlement-paid-2"),
userID,
entitlement.PlanCodePaidMonthly,
grantEndsAt,
extendedEndsAt,
common.Source("admin"),
common.ReasonCode("manual_extend"),
)
extendedSnapshot := paidEntitlementSnapshot(
userID,
entitlement.PlanCodePaidMonthly,
grantStartsAt,
extendedEndsAt,
common.Source("admin"),
common.ReasonCode("manual_extend"),
)
require.NoError(t, lifecycleStore.Extend(context.Background(), ports.ExtendEntitlementInput{
ExpectedCurrentSnapshot: grantedSnapshot,
NewRecord: extensionRecord,
NewSnapshot: extendedSnapshot,
}))
storedSnapshot, err = snapshotStore.GetByUserID(context.Background(), userID)
require.NoError(t, err)
require.Equal(t, extendedSnapshot, storedSnapshot)
revokeAt := grantEndsAt.Add(12 * time.Hour)
revokedCurrentRecord := extensionRecord
revokedCurrentRecord.ClosedAt = timePointer(revokeAt)
revokedCurrentRecord.ClosedBy = common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")}
revokedCurrentRecord.ClosedReasonCode = common.ReasonCode("manual_revoke")
freeAfterRevokeRecord := entitlement.PeriodRecord{
RecordID: entitlement.EntitlementRecordID("entitlement-free-2"),
UserID: userID,
PlanCode: entitlement.PlanCodeFree,
Source: common.Source("admin"),
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
ReasonCode: common.ReasonCode("manual_revoke"),
StartsAt: revokeAt,
CreatedAt: revokeAt,
}
freeAfterRevokeSnapshot := entitlement.CurrentSnapshot{
UserID: userID,
PlanCode: entitlement.PlanCodeFree,
IsPaid: false,
StartsAt: revokeAt,
Source: common.Source("admin"),
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
ReasonCode: common.ReasonCode("manual_revoke"),
UpdatedAt: revokeAt,
}
require.NoError(t, lifecycleStore.Revoke(context.Background(), ports.RevokeEntitlementInput{
ExpectedCurrentSnapshot: extendedSnapshot,
ExpectedCurrentRecord: extensionRecord,
UpdatedCurrentRecord: revokedCurrentRecord,
NewRecord: freeAfterRevokeRecord,
NewSnapshot: freeAfterRevokeSnapshot,
}))
storedSnapshot, err = snapshotStore.GetByUserID(context.Background(), userID)
require.NoError(t, err)
require.Equal(t, freeAfterRevokeSnapshot, storedSnapshot)
historyRecords, err := historyStore.ListByUserID(context.Background(), userID)
require.NoError(t, err)
require.Len(t, historyRecords, 4)
}
func TestRepairExpiredEntitlementMaterializesFreeSnapshot(t *testing.T) {
t.Parallel()
store := newTestStore(t)
historyStore := store.EntitlementHistory()
snapshotStore := store.EntitlementSnapshots()
lifecycleStore := store.EntitlementLifecycle()
userID := common.UserID("user-123")
startsAt := time.Unix(1_775_240_000, 0).UTC()
endsAt := startsAt.Add(24 * time.Hour)
expiredSnapshot := paidEntitlementSnapshot(
userID,
entitlement.PlanCodePaidMonthly,
startsAt,
endsAt,
common.Source("admin"),
common.ReasonCode("manual_grant"),
)
expiredSnapshot.UpdatedAt = endsAt.Add(24 * time.Hour)
expiredRecord := paidEntitlementRecord(
entitlement.EntitlementRecordID("entitlement-paid-1"),
userID,
entitlement.PlanCodePaidMonthly,
startsAt,
endsAt,
common.Source("admin"),
common.ReasonCode("manual_grant"),
)
require.NoError(t, historyStore.Create(context.Background(), expiredRecord))
require.NoError(t, snapshotStore.Put(context.Background(), expiredSnapshot))
repairedAt := endsAt.Add(2 * time.Hour)
freeRecord := entitlement.PeriodRecord{
RecordID: entitlement.EntitlementRecordID("entitlement-free-after-expiry"),
UserID: userID,
PlanCode: entitlement.PlanCodeFree,
Source: common.Source("entitlement_expiry_repair"),
Actor: common.ActorRef{Type: common.ActorType("service"), ID: common.ActorID("user-service")},
ReasonCode: common.ReasonCode("paid_entitlement_expired"),
StartsAt: endsAt,
CreatedAt: repairedAt,
}
freeSnapshot := entitlement.CurrentSnapshot{
UserID: userID,
PlanCode: entitlement.PlanCodeFree,
IsPaid: false,
StartsAt: endsAt,
Source: common.Source("entitlement_expiry_repair"),
Actor: common.ActorRef{Type: common.ActorType("service"), ID: common.ActorID("user-service")},
ReasonCode: common.ReasonCode("paid_entitlement_expired"),
UpdatedAt: repairedAt,
}
require.NoError(t, lifecycleStore.RepairExpired(context.Background(), ports.RepairExpiredEntitlementInput{
ExpectedExpiredSnapshot: expiredSnapshot,
NewRecord: freeRecord,
NewSnapshot: freeSnapshot,
}))
storedSnapshot, err := snapshotStore.GetByUserID(context.Background(), userID)
require.NoError(t, err)
require.Equal(t, freeSnapshot, storedSnapshot)
historyRecords, err := historyStore.ListByUserID(context.Background(), userID)
require.NoError(t, err)
require.Len(t, historyRecords, 2)
require.Equal(t, freeRecord, historyRecords[1])
}
func newTestStore(t *testing.T) *Store {
t.Helper()
server := miniredis.RunT(t)
store, err := New(Config{
Addr: server.Addr(),
DB: 0,
KeyspacePrefix: "user:test:",
OperationTimeout: 250 * time.Millisecond,
})
require.NoError(t, err)
t.Cleanup(func() {
_ = store.Close()
})
return store
}
func validAccountRecord() account.UserAccount {
createdAt := time.Unix(1_775_240_000, 0).UTC()
return account.UserAccount{
UserID: common.UserID("user-123"),
Email: common.Email("pilot@example.com"),
RaceName: common.RaceName("Pilot Nova"),
PreferredLanguage: common.LanguageTag("en"),
TimeZone: common.TimeZoneName("Europe/Kaliningrad"),
CreatedAt: createdAt,
UpdatedAt: createdAt,
}
}
func validEntitlementSnapshot(userID common.UserID, now time.Time) entitlement.CurrentSnapshot {
return entitlement.CurrentSnapshot{
UserID: userID,
PlanCode: entitlement.PlanCodeFree,
IsPaid: false,
StartsAt: now,
Source: common.Source("auth_registration"),
Actor: common.ActorRef{Type: common.ActorType("service"), ID: common.ActorID("user-service")},
ReasonCode: common.ReasonCode("initial_free_entitlement"),
UpdatedAt: now,
}
}
func validEntitlementRecord(userID common.UserID, now time.Time) entitlement.PeriodRecord {
return entitlement.PeriodRecord{
RecordID: entitlement.EntitlementRecordID("entitlement-" + userID.String()),
UserID: userID,
PlanCode: entitlement.PlanCodeFree,
Source: common.Source("auth_registration"),
Actor: common.ActorRef{Type: common.ActorType("service"), ID: common.ActorID("user-service")},
ReasonCode: common.ReasonCode("initial_free_entitlement"),
StartsAt: now,
CreatedAt: now,
}
}
func paidEntitlementRecord(
recordID entitlement.EntitlementRecordID,
userID common.UserID,
planCode entitlement.PlanCode,
startsAt time.Time,
endsAt time.Time,
source common.Source,
reasonCode common.ReasonCode,
) entitlement.PeriodRecord {
return entitlement.PeriodRecord{
RecordID: recordID,
UserID: userID,
PlanCode: planCode,
Source: source,
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
ReasonCode: reasonCode,
StartsAt: startsAt,
EndsAt: timePointer(endsAt),
CreatedAt: startsAt,
}
}
func paidEntitlementSnapshot(
userID common.UserID,
planCode entitlement.PlanCode,
startsAt time.Time,
endsAt time.Time,
source common.Source,
reasonCode common.ReasonCode,
) entitlement.CurrentSnapshot {
return entitlement.CurrentSnapshot{
UserID: userID,
PlanCode: planCode,
IsPaid: true,
StartsAt: startsAt,
EndsAt: timePointer(endsAt),
Source: source,
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
ReasonCode: reasonCode,
UpdatedAt: startsAt,
}
}
func timePointer(value time.Time) *time.Time {
utcValue := value.UTC()
return &utcValue
}
func createAccountInput(record account.UserAccount) ports.CreateAccountInput {
return ports.CreateAccountInput{
Account: record,
Reservation: raceNameReservation(record.UserID, record.RaceName, record.UpdatedAt),
}
}
func renameRaceNameInput(
record account.UserAccount,
newRaceName common.RaceName,
updatedAt time.Time,
) ports.RenameRaceNameInput {
return ports.RenameRaceNameInput{
UserID: record.UserID,
CurrentCanonicalKey: canonicalKey(record.RaceName),
NewRaceName: newRaceName,
NewReservation: raceNameReservation(record.UserID, newRaceName, updatedAt),
UpdatedAt: updatedAt,
}
}
func raceNameReservation(
userID common.UserID,
raceName common.RaceName,
reservedAt time.Time,
) account.RaceNameReservation {
return account.RaceNameReservation{
CanonicalKey: canonicalKey(raceName),
UserID: userID,
RaceName: raceName,
ReservedAt: reservedAt.UTC(),
}
}
func canonicalKey(raceName common.RaceName) account.RaceNameCanonicalKey {
return account.RaceNameCanonicalKey(strings.NewReplacer(
"1", "i",
"0", "o",
"8", "b",
).Replace(strings.ToLower(raceName.String())))
}
@@ -0,0 +1,200 @@
// Package redisstate defines the frozen Redis logical keyspace and pagination
// helpers used by future User Service storage adapters.
package redisstate
import (
"encoding/base64"
"fmt"
"strings"
"time"
"galaxy/user/internal/domain/account"
"galaxy/user/internal/domain/common"
"galaxy/user/internal/domain/entitlement"
"galaxy/user/internal/domain/policy"
)
const defaultPrefix = "user:"
// Keyspace builds the frozen Redis logical keys used by future storage
// adapters. The package intentionally exposes key construction only and does
// not depend on any Redis client.
type Keyspace struct {
// Prefix stores the namespace prefix applied to every key. The zero value
// uses `user:`.
Prefix string
}
// Account returns the primary user-account key for userID.
func (k Keyspace) Account(userID common.UserID) string {
return k.prefix() + "account:" + encodeKeyComponent(userID.String())
}
// EmailLookup returns the exact normalized e-mail lookup key.
func (k Keyspace) EmailLookup(email common.Email) string {
return k.prefix() + "lookup:email:" + encodeKeyComponent(email.String())
}
// RaceNameLookup returns the exact stored race-name lookup key.
func (k Keyspace) RaceNameLookup(raceName common.RaceName) string {
return k.prefix() + "lookup:race-name:" + encodeKeyComponent(raceName.String())
}
// RaceNameReservation returns the replaceable canonical race-name reservation
// key.
func (k Keyspace) RaceNameReservation(key account.RaceNameCanonicalKey) string {
return k.prefix() + "reservation:race-name:" + encodeKeyComponent(key.String())
}
// BlockedEmailSubject returns the dedicated blocked-email-subject key.
func (k Keyspace) BlockedEmailSubject(email common.Email) string {
return k.prefix() + "blocked-email:" + encodeKeyComponent(email.String())
}
// EntitlementRecord returns the primary entitlement history-record key.
func (k Keyspace) EntitlementRecord(recordID entitlement.EntitlementRecordID) string {
return k.prefix() + "entitlement:record:" + encodeKeyComponent(recordID.String())
}
// EntitlementHistory returns the per-user entitlement-history index key.
func (k Keyspace) EntitlementHistory(userID common.UserID) string {
return k.prefix() + "entitlement:history:" + encodeKeyComponent(userID.String())
}
// EntitlementSnapshot returns the current entitlement-snapshot key.
func (k Keyspace) EntitlementSnapshot(userID common.UserID) string {
return k.prefix() + "entitlement:snapshot:" + encodeKeyComponent(userID.String())
}
// SanctionRecord returns the primary sanction history-record key.
func (k Keyspace) SanctionRecord(recordID policy.SanctionRecordID) string {
return k.prefix() + "sanction:record:" + encodeKeyComponent(recordID.String())
}
// SanctionHistory returns the per-user sanction-history index key.
func (k Keyspace) SanctionHistory(userID common.UserID) string {
return k.prefix() + "sanction:history:" + encodeKeyComponent(userID.String())
}
// ActiveSanction returns the per-user active-sanction slot for one sanction
// code. The slot guarantees at most one active sanction per `user_id +
// sanction_code`.
func (k Keyspace) ActiveSanction(userID common.UserID, code policy.SanctionCode) string {
return k.prefix() + "sanction:active:" + encodeKeyComponent(userID.String()) + ":" + encodeKeyComponent(string(code))
}
// LimitRecord returns the primary limit history-record key.
func (k Keyspace) LimitRecord(recordID policy.LimitRecordID) string {
return k.prefix() + "limit:record:" + encodeKeyComponent(recordID.String())
}
// LimitHistory returns the per-user limit-history index key.
func (k Keyspace) LimitHistory(userID common.UserID) string {
return k.prefix() + "limit:history:" + encodeKeyComponent(userID.String())
}
// ActiveLimit returns the per-user active-limit slot for one limit code. The
// slot guarantees at most one active limit per `user_id + limit_code`.
func (k Keyspace) ActiveLimit(userID common.UserID, code policy.LimitCode) string {
return k.prefix() + "limit:active:" + encodeKeyComponent(userID.String()) + ":" + encodeKeyComponent(string(code))
}
// CreatedAtIndex returns the deterministic newest-first user-ordering index.
func (k Keyspace) CreatedAtIndex() string {
return k.prefix() + "index:created-at"
}
// PaidStateIndex returns the coarse free-versus-paid index key.
func (k Keyspace) PaidStateIndex(state entitlement.PaidState) string {
return k.prefix() + "index:paid-state:" + encodeKeyComponent(string(state))
}
// FinitePaidExpiryIndex returns the finite paid-expiry index key. Lifetime
// plans intentionally do not participate in this index.
func (k Keyspace) FinitePaidExpiryIndex() string {
return k.prefix() + "index:paid-expiry:finite"
}
// DeclaredCountryIndex returns the current declared-country reverse-lookup
// index key.
func (k Keyspace) DeclaredCountryIndex(code common.CountryCode) string {
return k.prefix() + "index:declared-country:" + encodeKeyComponent(code.String())
}
// ActiveSanctionCodeIndex returns the reverse-lookup index key for users with
// an active sanction code.
func (k Keyspace) ActiveSanctionCodeIndex(code policy.SanctionCode) string {
return k.prefix() + "index:active-sanction:" + encodeKeyComponent(string(code))
}
// ActiveLimitCodeIndex returns the reverse-lookup index key for users with an
// active limit code.
func (k Keyspace) ActiveLimitCodeIndex(code policy.LimitCode) string {
return k.prefix() + "index:active-limit:" + encodeKeyComponent(string(code))
}
// EligibilityMarkerIndex returns the reverse-lookup index key for one derived
// eligibility marker boolean.
func (k Keyspace) EligibilityMarkerIndex(marker policy.EligibilityMarker, value bool) string {
return fmt.Sprintf("%sindex:eligibility:%s:%t", k.prefix(), encodeKeyComponent(string(marker)), value)
}
// CreatedAtScore returns the frozen ZSET score representation for created-at
// ordering and deterministic pagination.
func CreatedAtScore(createdAt time.Time) float64 {
return float64(createdAt.UTC().UnixMicro())
}
// ExpiryScore returns the frozen ZSET score representation for finite paid
// expiry ordering.
func ExpiryScore(expiresAt time.Time) float64 {
return float64(expiresAt.UTC().UnixMicro())
}
// PageCursor identifies the last seen `(created_at, user_id)` tuple used by
// deterministic newest-first pagination.
type PageCursor struct {
// CreatedAt stores the created-at component of the last seen row.
CreatedAt time.Time
// UserID stores the user-id tiebreaker component of the last seen row.
UserID common.UserID
}
// Validate reports whether PageCursor contains a complete cursor tuple.
func (cursor PageCursor) Validate() error {
if err := common.ValidateTimestamp("page cursor created at", cursor.CreatedAt); err != nil {
return err
}
if err := cursor.UserID.Validate(); err != nil {
return fmt.Errorf("page cursor user id: %w", err)
}
return nil
}
// ComparePageOrder compares two listing positions using the frozen ordering:
// `created_at desc`, then `user_id desc`.
func ComparePageOrder(left PageCursor, right PageCursor) int {
switch {
case left.CreatedAt.After(right.CreatedAt):
return -1
case left.CreatedAt.Before(right.CreatedAt):
return 1
default:
return -strings.Compare(left.UserID.String(), right.UserID.String())
}
}
func (k Keyspace) prefix() string {
prefix := strings.TrimSpace(k.Prefix)
if prefix == "" {
return defaultPrefix
}
return prefix
}
func encodeKeyComponent(value string) string {
return base64.RawURLEncoding.EncodeToString([]byte(value))
}
@@ -0,0 +1,59 @@
package redisstate
import (
"testing"
"time"
"galaxy/user/internal/domain/account"
"galaxy/user/internal/domain/common"
"galaxy/user/internal/domain/entitlement"
"galaxy/user/internal/domain/policy"
"github.com/stretchr/testify/require"
)
func TestKeyspaceBuildsStableKeys(t *testing.T) {
t.Parallel()
keyspace := Keyspace{Prefix: "custom:"}
require.Equal(t, "custom:account:dXNlci0xMjM", keyspace.Account(common.UserID("user-123")))
require.Equal(t, "custom:lookup:email:cGlsb3RAZXhhbXBsZS5jb20", keyspace.EmailLookup(common.Email("pilot@example.com")))
require.Equal(t, "custom:lookup:race-name:UGlsb3QgTm92YQ", keyspace.RaceNameLookup(common.RaceName("Pilot Nova")))
require.Equal(t, "custom:reservation:race-name:cGlsb3Qtbm92YQ", keyspace.RaceNameReservation(account.RaceNameCanonicalKey("pilot-nova")))
require.Equal(t, "custom:blocked-email:cGlsb3RAZXhhbXBsZS5jb20", keyspace.BlockedEmailSubject(common.Email("pilot@example.com")))
require.Equal(t, "custom:entitlement:record:ZW50aXRsZW1lbnQtMTIz", keyspace.EntitlementRecord(entitlement.EntitlementRecordID("entitlement-123")))
require.Equal(t, "custom:sanction:record:c2FuY3Rpb24tMQ", keyspace.SanctionRecord(policy.SanctionRecordID("sanction-1")))
require.Equal(t, "custom:limit:record:bGltaXQtMQ", keyspace.LimitRecord(policy.LimitRecordID("limit-1")))
require.Equal(t, "custom:sanction:active:dXNlci0xMjM:bG9naW5fYmxvY2s", keyspace.ActiveSanction(common.UserID("user-123"), policy.SanctionCodeLoginBlock))
require.Equal(t, "custom:limit:active:dXNlci0xMjM:bWF4X293bmVkX3ByaXZhdGVfZ2FtZXM", keyspace.ActiveLimit(common.UserID("user-123"), policy.LimitCodeMaxOwnedPrivateGames))
require.Equal(t, "custom:index:created-at", keyspace.CreatedAtIndex())
require.Equal(t, "custom:index:paid-state:cGFpZA", keyspace.PaidStateIndex(entitlement.PaidStatePaid))
require.Equal(t, "custom:index:paid-expiry:finite", keyspace.FinitePaidExpiryIndex())
require.Equal(t, "custom:index:declared-country:REU", keyspace.DeclaredCountryIndex(common.CountryCode("DE")))
require.Equal(t, "custom:index:active-sanction:bG9naW5fYmxvY2s", keyspace.ActiveSanctionCodeIndex(policy.SanctionCodeLoginBlock))
require.Equal(t, "custom:index:active-limit:bWF4X293bmVkX3ByaXZhdGVfZ2FtZXM", keyspace.ActiveLimitCodeIndex(policy.LimitCodeMaxOwnedPrivateGames))
require.Equal(t, "custom:index:eligibility:Y2FuX2xvZ2lu:true", keyspace.EligibilityMarkerIndex(policy.EligibilityMarkerCanLogin, true))
}
func TestComparePageOrder(t *testing.T) {
t.Parallel()
newer := PageCursor{CreatedAt: time.Unix(20, 0).UTC(), UserID: common.UserID("user-200")}
older := PageCursor{CreatedAt: time.Unix(10, 0).UTC(), UserID: common.UserID("user-100")}
sameTimeHigherUserID := PageCursor{CreatedAt: time.Unix(20, 0).UTC(), UserID: common.UserID("user-300")}
require.Negative(t, ComparePageOrder(newer, older))
require.Positive(t, ComparePageOrder(older, newer))
require.Negative(t, ComparePageOrder(sameTimeHigherUserID, newer))
}
func TestScoresUseUnixMicro(t *testing.T) {
t.Parallel()
value := time.Unix(1_775_240_000, 123_000).UTC()
want := float64(value.UnixMicro())
require.Equal(t, want, CreatedAtScore(value))
require.Equal(t, want, ExpiryScore(value))
}
@@ -0,0 +1,191 @@
package redisstate
import (
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"time"
"galaxy/user/internal/domain/common"
"galaxy/user/internal/domain/entitlement"
"galaxy/user/internal/domain/policy"
)
var (
// ErrPageTokenFiltersMismatch reports that a supplied page token was created
// for a different normalized filter set.
ErrPageTokenFiltersMismatch = errors.New("page token filters do not match current filters")
)
// UserListFilters stores the frozen admin-listing filter set that becomes part
// of the opaque page token fingerprint.
type UserListFilters struct {
// PaidState stores the coarse free-versus-paid filter.
PaidState entitlement.PaidState
// PaidExpiresBefore stores the optional finite-paid expiry upper bound.
PaidExpiresBefore *time.Time
// PaidExpiresAfter stores the optional finite-paid expiry lower bound.
PaidExpiresAfter *time.Time
// DeclaredCountry stores the optional declared-country filter.
DeclaredCountry common.CountryCode
// SanctionCode stores the optional active-sanction filter.
SanctionCode policy.SanctionCode
// LimitCode stores the optional active-limit filter.
LimitCode policy.LimitCode
// CanLogin stores the optional login-eligibility filter.
CanLogin *bool
// CanCreatePrivateGame stores the optional private-game-create eligibility
// filter.
CanCreatePrivateGame *bool
// CanJoinGame stores the optional join-game eligibility filter.
CanJoinGame *bool
}
// Validate reports whether UserListFilters is structurally valid.
func (filters UserListFilters) Validate() error {
if !filters.PaidState.IsKnown() {
return fmt.Errorf("paid state %q is unsupported", filters.PaidState)
}
if filters.PaidExpiresBefore != nil && filters.PaidExpiresBefore.IsZero() {
return fmt.Errorf("paid expires before must not be zero")
}
if filters.PaidExpiresAfter != nil && filters.PaidExpiresAfter.IsZero() {
return fmt.Errorf("paid expires after must not be zero")
}
if !filters.DeclaredCountry.IsZero() {
if err := filters.DeclaredCountry.Validate(); err != nil {
return fmt.Errorf("declared country: %w", err)
}
}
if filters.SanctionCode != "" && !filters.SanctionCode.IsKnown() {
return fmt.Errorf("sanction code %q is unsupported", filters.SanctionCode)
}
if filters.LimitCode != "" && !filters.LimitCode.IsKnown() {
return fmt.Errorf("limit code %q is unsupported", filters.LimitCode)
}
return nil
}
// EncodePageToken encodes cursor and filters into the frozen opaque page token
// format.
func EncodePageToken(cursor PageCursor, filters UserListFilters) (string, error) {
if err := cursor.Validate(); err != nil {
return "", fmt.Errorf("encode page token: %w", err)
}
fingerprint, err := normalizeFilters(filters)
if err != nil {
return "", fmt.Errorf("encode page token: %w", err)
}
payload, err := json.Marshal(pageTokenPayload{
CreatedAt: cursor.CreatedAt.UTC().Format(time.RFC3339Nano),
UserID: cursor.UserID.String(),
Filters: fingerprint,
})
if err != nil {
return "", fmt.Errorf("encode page token: %w", err)
}
return base64.RawURLEncoding.EncodeToString(payload), nil
}
// DecodePageToken decodes raw into the frozen page cursor and verifies that
// the embedded normalized filter set matches expectedFilters.
func DecodePageToken(raw string, expectedFilters UserListFilters) (PageCursor, error) {
fingerprint, err := normalizeFilters(expectedFilters)
if err != nil {
return PageCursor{}, fmt.Errorf("decode page token: %w", err)
}
payload, err := base64.RawURLEncoding.DecodeString(raw)
if err != nil {
return PageCursor{}, fmt.Errorf("decode page token: %w", err)
}
var token pageTokenPayload
if err := json.Unmarshal(payload, &token); err != nil {
return PageCursor{}, fmt.Errorf("decode page token: %w", err)
}
if token.Filters != fingerprint {
return PageCursor{}, ErrPageTokenFiltersMismatch
}
createdAt, err := time.Parse(time.RFC3339Nano, token.CreatedAt)
if err != nil {
return PageCursor{}, fmt.Errorf("decode page token: parse created_at: %w", err)
}
cursor := PageCursor{
CreatedAt: createdAt.UTC(),
UserID: common.UserID(token.UserID),
}
if err := cursor.Validate(); err != nil {
return PageCursor{}, fmt.Errorf("decode page token: %w", err)
}
return cursor, nil
}
type pageTokenPayload struct {
CreatedAt string `json:"created_at"`
UserID string `json:"user_id"`
Filters normalizedFilterPayload `json:"filters"`
}
type normalizedFilterPayload struct {
PaidState string `json:"paid_state,omitempty"`
PaidExpiresBeforeUTC string `json:"paid_expires_before_utc,omitempty"`
PaidExpiresAfterUTC string `json:"paid_expires_after_utc,omitempty"`
DeclaredCountry string `json:"declared_country,omitempty"`
SanctionCode string `json:"sanction_code,omitempty"`
LimitCode string `json:"limit_code,omitempty"`
CanLogin string `json:"can_login,omitempty"`
CanCreatePrivateGame string `json:"can_create_private_game,omitempty"`
CanJoinGame string `json:"can_join_game,omitempty"`
}
func normalizeFilters(filters UserListFilters) (normalizedFilterPayload, error) {
if err := filters.Validate(); err != nil {
return normalizedFilterPayload{}, err
}
return normalizedFilterPayload{
PaidState: string(filters.PaidState),
PaidExpiresBeforeUTC: formatOptionalTime(filters.PaidExpiresBefore),
PaidExpiresAfterUTC: formatOptionalTime(filters.PaidExpiresAfter),
DeclaredCountry: filters.DeclaredCountry.String(),
SanctionCode: string(filters.SanctionCode),
LimitCode: string(filters.LimitCode),
CanLogin: formatOptionalBool(filters.CanLogin),
CanCreatePrivateGame: formatOptionalBool(filters.CanCreatePrivateGame),
CanJoinGame: formatOptionalBool(filters.CanJoinGame),
}, nil
}
func formatOptionalTime(value *time.Time) string {
if value == nil {
return ""
}
return value.UTC().Format(time.RFC3339Nano)
}
func formatOptionalBool(value *bool) string {
if value == nil {
return ""
}
if *value {
return "true"
}
return "false"
}
@@ -0,0 +1,70 @@
package redisstate
import (
"testing"
"time"
"galaxy/user/internal/domain/common"
"galaxy/user/internal/domain/entitlement"
"galaxy/user/internal/domain/policy"
"github.com/stretchr/testify/require"
)
func TestEncodeDecodePageToken(t *testing.T) {
t.Parallel()
before := time.Unix(1_775_250_000, 0).UTC()
after := time.Unix(1_775_240_000, 0).UTC()
canLogin := true
canCreate := false
canJoin := true
filters := UserListFilters{
PaidState: entitlement.PaidStatePaid,
PaidExpiresBefore: &before,
PaidExpiresAfter: &after,
DeclaredCountry: common.CountryCode("DE"),
SanctionCode: policy.SanctionCodeLoginBlock,
LimitCode: policy.LimitCodeMaxOwnedPrivateGames,
CanLogin: &canLogin,
CanCreatePrivateGame: &canCreate,
CanJoinGame: &canJoin,
}
cursor := PageCursor{
CreatedAt: time.Unix(1_775_240_100, 987_000_000).UTC(),
UserID: common.UserID("user-123"),
}
token, err := EncodePageToken(cursor, filters)
require.NoError(t, err)
decoded, err := DecodePageToken(token, filters)
require.NoError(t, err)
require.Equal(t, cursor, decoded)
}
func TestDecodePageTokenFilterMismatch(t *testing.T) {
t.Parallel()
cursor := PageCursor{
CreatedAt: time.Unix(1_775_240_100, 0).UTC(),
UserID: common.UserID("user-123"),
}
filters := UserListFilters{
PaidState: entitlement.PaidStatePaid,
}
token, err := EncodePageToken(cursor, filters)
require.NoError(t, err)
_, err = DecodePageToken(token, UserListFilters{PaidState: entitlement.PaidStateFree})
require.ErrorIs(t, err, ErrPageTokenFiltersMismatch)
}
func TestDecodePageTokenRejectsInvalidInput(t *testing.T) {
t.Parallel()
_, err := DecodePageToken("%%%not-base64%%%", UserListFilters{})
require.Error(t, err)
}
+133
View File
@@ -0,0 +1,133 @@
// Package adminapi exposes the optional private admin HTTP listener used for
// operational endpoints such as Prometheus metrics.
package adminapi
import (
"context"
"errors"
"fmt"
"log/slog"
"net"
"net/http"
"sync"
"galaxy/user/internal/config"
)
// Server owns the optional admin HTTP listener exposed by the user service.
type Server struct {
cfg config.AdminHTTPConfig
handler http.Handler
logger *slog.Logger
stateMu sync.RWMutex
server *http.Server
listener net.Listener
}
// NewServer constructs an admin HTTP server for cfg and handler.
func NewServer(cfg config.AdminHTTPConfig, handler http.Handler, logger *slog.Logger) *Server {
if handler == nil {
handler = http.NotFoundHandler()
}
if logger == nil {
logger = slog.Default()
}
mux := http.NewServeMux()
mux.Handle("GET /metrics", handler)
return &Server{
cfg: cfg,
handler: mux,
logger: logger.With("component", "admin_http"),
}
}
// Enabled reports whether the admin listener should run.
func (server *Server) Enabled() bool {
return server != nil && server.cfg.Addr != ""
}
// Run binds the configured listener and serves the admin HTTP surface until
// Shutdown closes the server. A disabled admin server returns when ctx is
// canceled.
func (server *Server) Run(ctx context.Context) error {
if ctx == nil {
return errors.New("run admin HTTP server: nil context")
}
if err := ctx.Err(); err != nil {
return err
}
if !server.Enabled() {
<-ctx.Done()
return nil
}
listener, err := net.Listen("tcp", server.cfg.Addr)
if err != nil {
return fmt.Errorf("run admin HTTP server: listen on %q: %w", server.cfg.Addr, err)
}
httpServer := &http.Server{
Handler: server.handler,
ReadHeaderTimeout: server.cfg.ReadHeaderTimeout,
ReadTimeout: server.cfg.ReadTimeout,
IdleTimeout: server.cfg.IdleTimeout,
}
server.stateMu.Lock()
server.server = httpServer
server.listener = listener
server.stateMu.Unlock()
server.logger.Info("admin HTTP server started", "addr", listener.Addr().String())
shutdownDone := make(chan struct{})
go func() {
defer close(shutdownDone)
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), server.cfg.ReadTimeout)
defer cancel()
_ = server.Shutdown(shutdownCtx)
}()
defer func() {
server.stateMu.Lock()
server.server = nil
server.listener = nil
server.stateMu.Unlock()
<-shutdownDone
}()
err = httpServer.Serve(listener)
switch {
case err == nil:
return nil
case errors.Is(err, http.ErrServerClosed):
server.logger.Info("admin HTTP server stopped")
return nil
default:
return fmt.Errorf("run admin HTTP server: serve on %q: %w", server.cfg.Addr, err)
}
}
// Shutdown gracefully stops the admin HTTP server within ctx.
func (server *Server) Shutdown(ctx context.Context) error {
if ctx == nil {
return errors.New("shutdown admin HTTP server: nil context")
}
server.stateMu.RLock()
httpServer := server.server
server.stateMu.RUnlock()
if httpServer == nil {
return nil
}
if err := httpServer.Shutdown(ctx); err != nil && !errors.Is(err, http.ErrServerClosed) {
return fmt.Errorf("shutdown admin HTTP server: %w", err)
}
return nil
}
+98
View File
@@ -0,0 +1,98 @@
package adminapi
import (
"context"
"net/http"
"testing"
"time"
"galaxy/user/internal/config"
"github.com/stretchr/testify/require"
)
func TestServerRunDisabledWaitsForContext(t *testing.T) {
t.Parallel()
server := NewServer(config.AdminHTTPConfig{}, http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
t.Fatal("disabled admin server must not serve requests")
}), nil)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
errCh := make(chan error, 1)
go func() {
errCh <- server.Run(ctx)
}()
cancel()
select {
case err := <-errCh:
require.ErrorIs(t, err, context.Canceled)
case <-time.After(2 * time.Second):
t.Fatal("disabled admin server did not stop after context cancellation")
}
}
func TestServerRunServesMetricsOnly(t *testing.T) {
t.Parallel()
server := NewServer(config.AdminHTTPConfig{
Addr: "127.0.0.1:0",
ReadHeaderTimeout: 2 * time.Second,
ReadTimeout: 10 * time.Second,
IdleTimeout: time.Minute,
}, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte("sample_metric 1\n"))
}), nil)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
errCh := make(chan error, 1)
go func() {
errCh <- server.Run(ctx)
}()
addr := waitForListener(t, server)
metricsResponse, err := http.Get("http://" + addr + "/metrics")
require.NoError(t, err)
t.Cleanup(func() { _ = metricsResponse.Body.Close() })
require.Equal(t, http.StatusOK, metricsResponse.StatusCode)
rootResponse, err := http.Get("http://" + addr + "/")
require.NoError(t, err)
t.Cleanup(func() { _ = rootResponse.Body.Close() })
require.Equal(t, http.StatusNotFound, rootResponse.StatusCode)
cancel()
select {
case err := <-errCh:
require.NoError(t, err)
case <-time.After(2 * time.Second):
t.Fatal("admin server did not stop after context cancellation")
}
}
func waitForListener(t *testing.T, server *Server) string {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
server.stateMu.RLock()
listener := server.listener
server.stateMu.RUnlock()
if listener != nil {
return listener.Addr().String()
}
time.Sleep(10 * time.Millisecond)
}
t.Fatal("admin server listener did not start")
return ""
}
@@ -0,0 +1,205 @@
package internalhttp
import (
"context"
"net/http"
"strconv"
"strings"
"time"
"galaxy/user/internal/service/adminusers"
"galaxy/user/internal/service/shared"
"github.com/gin-gonic/gin"
)
type getUserByEmailRequest struct {
Email string `json:"email"`
}
type getUserByRaceNameRequest struct {
RaceName string `json:"race_name"`
}
func handleGetUserByID(useCase GetUserByIDUseCase, timeout time.Duration) gin.HandlerFunc {
return func(c *gin.Context) {
callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout)
defer cancel()
result, err := useCase.Execute(callCtx, adminusers.GetUserByIDInput{
UserID: c.Param("user_id"),
})
if err != nil {
abortWithProjection(c, shared.ProjectInternalError(err))
return
}
c.JSON(http.StatusOK, result)
}
}
func handleGetUserByEmail(useCase GetUserByEmailUseCase, timeout time.Duration) gin.HandlerFunc {
return func(c *gin.Context) {
var request getUserByEmailRequest
if err := decodeJSONRequest(c.Request, &request); err != nil {
abortWithProjection(c, shared.ProjectInternalError(shared.InvalidRequest(err.Error())))
return
}
callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout)
defer cancel()
result, err := useCase.Execute(callCtx, adminusers.GetUserByEmailInput{
Email: request.Email,
})
if err != nil {
abortWithProjection(c, shared.ProjectInternalError(err))
return
}
c.JSON(http.StatusOK, result)
}
}
func handleGetUserByRaceName(useCase GetUserByRaceNameUseCase, timeout time.Duration) gin.HandlerFunc {
return func(c *gin.Context) {
var request getUserByRaceNameRequest
if err := decodeJSONRequest(c.Request, &request); err != nil {
abortWithProjection(c, shared.ProjectInternalError(shared.InvalidRequest(err.Error())))
return
}
callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout)
defer cancel()
result, err := useCase.Execute(callCtx, adminusers.GetUserByRaceNameInput{
RaceName: request.RaceName,
})
if err != nil {
abortWithProjection(c, shared.ProjectInternalError(err))
return
}
c.JSON(http.StatusOK, result)
}
}
func handleListUsers(useCase ListUsersUseCase, timeout time.Duration) gin.HandlerFunc {
return func(c *gin.Context) {
input, err := buildListUsersInput(c)
if err != nil {
abortWithProjection(c, shared.ProjectInternalError(err))
return
}
callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout)
defer cancel()
result, err := useCase.Execute(callCtx, input)
if err != nil {
abortWithProjection(c, shared.ProjectInternalError(err))
return
}
c.JSON(http.StatusOK, result)
}
}
func buildListUsersInput(c *gin.Context) (adminusers.ListUsersInput, error) {
pageSize, err := parseOptionalPageSize(c, "page_size")
if err != nil {
return adminusers.ListUsersInput{}, err
}
pageToken, err := parseOptionalPageToken(c, "page_token")
if err != nil {
return adminusers.ListUsersInput{}, err
}
paidExpiresBefore, err := parseOptionalRFC3339Query(c, "paid_expires_before")
if err != nil {
return adminusers.ListUsersInput{}, err
}
paidExpiresAfter, err := parseOptionalRFC3339Query(c, "paid_expires_after")
if err != nil {
return adminusers.ListUsersInput{}, err
}
canLogin, err := parseOptionalBoolQuery(c, "can_login")
if err != nil {
return adminusers.ListUsersInput{}, err
}
canCreatePrivateGame, err := parseOptionalBoolQuery(c, "can_create_private_game")
if err != nil {
return adminusers.ListUsersInput{}, err
}
canJoinGame, err := parseOptionalBoolQuery(c, "can_join_game")
if err != nil {
return adminusers.ListUsersInput{}, err
}
return adminusers.ListUsersInput{
PageSize: pageSize,
PageToken: pageToken,
PaidState: c.Query("paid_state"),
PaidExpiresBefore: paidExpiresBefore,
PaidExpiresAfter: paidExpiresAfter,
DeclaredCountry: c.Query("declared_country"),
SanctionCode: c.Query("sanction_code"),
LimitCode: c.Query("limit_code"),
CanLogin: canLogin,
CanCreatePrivateGame: canCreatePrivateGame,
CanJoinGame: canJoinGame,
}, nil
}
func parseOptionalPageSize(c *gin.Context, name string) (int, error) {
raw, present := c.GetQuery(name)
if !present {
return 0, nil
}
value, err := strconv.Atoi(strings.TrimSpace(raw))
if err != nil || value < 1 || value > 200 {
return 0, shared.InvalidRequest("page_size must be between 1 and 200")
}
return value, nil
}
func parseOptionalPageToken(c *gin.Context, name string) (string, error) {
raw, present := c.GetQuery(name)
if !present {
return "", nil
}
if strings.TrimSpace(raw) != raw {
return "", shared.InvalidRequest("page_token must not contain surrounding whitespace")
}
return raw, nil
}
func parseOptionalRFC3339Query(c *gin.Context, name string) (*time.Time, error) {
raw, present := c.GetQuery(name)
if !present {
return nil, nil
}
parsed, err := time.Parse(time.RFC3339, strings.TrimSpace(raw))
if err != nil {
return nil, shared.InvalidRequest(name + " must be a valid RFC 3339 timestamp")
}
return &parsed, nil
}
func parseOptionalBoolQuery(c *gin.Context, name string) (*bool, error) {
raw, present := c.GetQuery(name)
if !present {
return nil, nil
}
parsed, err := strconv.ParseBool(strings.TrimSpace(raw))
if err != nil {
return nil, shared.InvalidRequest(name + " must be a valid boolean")
}
return &parsed, nil
}
@@ -0,0 +1,233 @@
package internalhttp
import (
"bytes"
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"galaxy/user/internal/service/accountview"
"galaxy/user/internal/service/adminusers"
"galaxy/user/internal/service/shared"
"github.com/stretchr/testify/require"
)
func TestAdminReadHandlersSuccessCases(t *testing.T) {
t.Parallel()
handler := mustNewHandler(t, Dependencies{
GetUserByID: getUserByIDFunc(func(_ context.Context, input adminusers.GetUserByIDInput) (adminusers.LookupResult, error) {
require.Equal(t, "user-123", input.UserID)
return adminusers.LookupResult{User: sampleAccountView()}, nil
}),
GetUserByEmail: getUserByEmailFunc(func(_ context.Context, input adminusers.GetUserByEmailInput) (adminusers.LookupResult, error) {
require.Equal(t, "pilot@example.com", input.Email)
return adminusers.LookupResult{User: sampleAccountView()}, nil
}),
GetUserByRaceName: getUserByRaceNameFunc(func(_ context.Context, input adminusers.GetUserByRaceNameInput) (adminusers.LookupResult, error) {
require.Equal(t, "Pilot Nova", input.RaceName)
return adminusers.LookupResult{User: sampleAccountView()}, nil
}),
ListUsers: listUsersFunc(func(_ context.Context, input adminusers.ListUsersInput) (adminusers.ListUsersResult, error) {
require.Equal(t, 2, input.PageSize)
require.Equal(t, "cursor-1", input.PageToken)
require.Equal(t, "paid", input.PaidState)
require.Equal(t, "DE", input.DeclaredCountry)
require.Equal(t, "login_block", input.SanctionCode)
require.Equal(t, "max_owned_private_games", input.LimitCode)
require.NotNil(t, input.PaidExpiresBefore)
require.NotNil(t, input.PaidExpiresAfter)
require.NotNil(t, input.CanLogin)
require.NotNil(t, input.CanCreatePrivateGame)
require.NotNil(t, input.CanJoinGame)
require.False(t, *input.CanLogin)
require.True(t, *input.CanCreatePrivateGame)
require.True(t, *input.CanJoinGame)
require.Equal(t, time.Date(2026, time.April, 10, 12, 0, 0, 0, time.UTC), input.PaidExpiresBefore.UTC())
require.Equal(t, time.Date(2026, time.April, 1, 12, 0, 0, 0, time.UTC), input.PaidExpiresAfter.UTC())
other := sampleAccountView()
other.UserID = "user-234"
other.Email = "second@example.com"
other.RaceName = "Second Pilot"
return adminusers.ListUsersResult{
Items: []accountview.AccountView{sampleAccountView(), other},
NextPageToken: "cursor-2",
}, nil
}),
})
tests := []struct {
name string
method string
path string
body string
wantStatus int
wantBody string
}{
{
name: "get user by id",
method: http.MethodGet,
path: "/api/v1/internal/users/user-123",
wantStatus: http.StatusOK,
wantBody: `{"user":{"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"}}`,
},
{
name: "get user by email",
method: http.MethodPost,
path: "/api/v1/internal/user-lookups/by-email",
body: `{"email":"pilot@example.com"}`,
wantStatus: http.StatusOK,
wantBody: `{"user":{"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"}}`,
},
{
name: "get user by race name",
method: http.MethodPost,
path: "/api/v1/internal/user-lookups/by-race-name",
body: `{"race_name":"Pilot Nova"}`,
wantStatus: http.StatusOK,
wantBody: `{"user":{"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"}}`,
},
{
name: "list users",
method: http.MethodGet,
path: "/api/v1/internal/users?page_size=2&page_token=cursor-1&paid_state=paid&paid_expires_before=2026-04-10T12:00:00Z&paid_expires_after=2026-04-01T12:00:00Z&declared_country=DE&sanction_code=login_block&limit_code=max_owned_private_games&can_login=false&can_create_private_game=true&can_join_game=true",
wantStatus: http.StatusOK,
wantBody: `{"items":[{"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"},{"user_id":"user-234","email":"second@example.com","race_name":"Second Pilot","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"}],"next_page_token":"cursor-2"}`,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
var body *bytes.Buffer
if tt.body != "" {
body = bytes.NewBufferString(tt.body)
} else {
body = &bytes.Buffer{}
}
request := httptest.NewRequest(tt.method, tt.path, body)
if tt.body != "" {
request.Header.Set("Content-Type", "application/json")
}
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, request)
require.Equal(t, tt.wantStatus, recorder.Code)
assertJSONEq(t, recorder.Body.String(), tt.wantBody)
})
}
}
func TestAdminReadHandlersErrorCases(t *testing.T) {
t.Parallel()
handler := mustNewHandler(t, Dependencies{
GetUserByID: getUserByIDFunc(func(context.Context, adminusers.GetUserByIDInput) (adminusers.LookupResult, error) {
return adminusers.LookupResult{}, shared.SubjectNotFound()
}),
GetUserByEmail: getUserByEmailFunc(func(context.Context, adminusers.GetUserByEmailInput) (adminusers.LookupResult, error) {
return adminusers.LookupResult{}, shared.SubjectNotFound()
}),
GetUserByRaceName: getUserByRaceNameFunc(func(context.Context, adminusers.GetUserByRaceNameInput) (adminusers.LookupResult, error) {
return adminusers.LookupResult{}, shared.SubjectNotFound()
}),
ListUsers: listUsersFunc(func(context.Context, adminusers.ListUsersInput) (adminusers.ListUsersResult, error) {
return adminusers.ListUsersResult{}, shared.InvalidRequest("page_token is invalid or does not match current filters")
}),
})
tests := []struct {
name string
method string
path string
body string
wantStatus int
wantBody string
}{
{
name: "get user by id not found",
method: http.MethodGet,
path: "/api/v1/internal/users/user-missing",
wantStatus: http.StatusNotFound,
wantBody: `{"error":{"code":"subject_not_found","message":"subject not found"}}`,
},
{
name: "get user by email malformed json",
method: http.MethodPost,
path: "/api/v1/internal/user-lookups/by-email",
body: `{"email":"pilot@example.com","extra":true}`,
wantStatus: http.StatusBadRequest,
wantBody: `{"error":{"code":"invalid_request","message":"request body contains unknown field \"extra\""}}`,
},
{
name: "get user by race name not found",
method: http.MethodPost,
path: "/api/v1/internal/user-lookups/by-race-name",
body: `{"race_name":"Missing Pilot"}`,
wantStatus: http.StatusNotFound,
wantBody: `{"error":{"code":"subject_not_found","message":"subject not found"}}`,
},
{
name: "list users invalid page size",
method: http.MethodGet,
path: "/api/v1/internal/users?page_size=201",
wantStatus: http.StatusBadRequest,
wantBody: `{"error":{"code":"invalid_request","message":"page_size must be between 1 and 200"}}`,
},
{
name: "list users invalid timestamp",
method: http.MethodGet,
path: "/api/v1/internal/users?paid_expires_before=not-a-time",
wantStatus: http.StatusBadRequest,
wantBody: `{"error":{"code":"invalid_request","message":"paid_expires_before must be a valid RFC 3339 timestamp"}}`,
},
{
name: "list users invalid boolean",
method: http.MethodGet,
path: "/api/v1/internal/users?can_login=maybe",
wantStatus: http.StatusBadRequest,
wantBody: `{"error":{"code":"invalid_request","message":"can_login must be a valid boolean"}}`,
},
{
name: "list users invalid page token",
method: http.MethodGet,
path: "/api/v1/internal/users?page_token=cursor-1",
wantStatus: http.StatusBadRequest,
wantBody: `{"error":{"code":"invalid_request","message":"page_token is invalid or does not match current filters"}}`,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
var body *bytes.Buffer
if tt.body != "" {
body = bytes.NewBufferString(tt.body)
} else {
body = &bytes.Buffer{}
}
request := httptest.NewRequest(tt.method, tt.path, body)
if tt.body != "" {
request.Header.Set("Content-Type", "application/json")
}
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, request)
require.Equal(t, tt.wantStatus, recorder.Code)
assertJSONEq(t, recorder.Body.String(), tt.wantBody)
})
}
}
+841
View File
@@ -0,0 +1,841 @@
package internalhttp
import (
"context"
"fmt"
"log/slog"
"net/http"
"time"
"galaxy/user/internal/logging"
"galaxy/user/internal/service/authdirectory"
"galaxy/user/internal/service/entitlementsvc"
"galaxy/user/internal/service/geosync"
"galaxy/user/internal/service/lobbyeligibility"
"galaxy/user/internal/service/policysvc"
"galaxy/user/internal/service/selfservice"
"galaxy/user/internal/service/shared"
"galaxy/user/internal/telemetry"
"github.com/gin-gonic/gin"
"go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin"
"go.opentelemetry.io/otel/attribute"
)
const internalHTTPServiceName = "galaxy-user-internal"
type errorResponse struct {
Error errorBody `json:"error"`
}
type errorBody struct {
Code string `json:"code"`
Message string `json:"message"`
}
type resolveByEmailRequest struct {
Email string `json:"email"`
}
type resolveByEmailResponse struct {
Kind string `json:"kind"`
UserID string `json:"user_id,omitempty"`
BlockReasonCode string `json:"block_reason_code,omitempty"`
}
type existsByUserIDResponse struct {
Exists bool `json:"exists"`
}
type ensureByEmailRequest struct {
Email string `json:"email"`
RegistrationContext *ensureRegistrationContextDTO `json:"registration_context"`
}
type ensureRegistrationContextDTO struct {
PreferredLanguage string `json:"preferred_language"`
TimeZone string `json:"time_zone"`
}
type ensureByEmailResponse struct {
Outcome string `json:"outcome"`
UserID string `json:"user_id,omitempty"`
BlockReasonCode string `json:"block_reason_code,omitempty"`
}
type blockByUserIDRequest struct {
ReasonCode string `json:"reason_code"`
}
type blockByEmailRequest struct {
Email string `json:"email"`
ReasonCode string `json:"reason_code"`
}
type blockResponse struct {
Outcome string `json:"outcome"`
UserID string `json:"user_id,omitempty"`
}
type getMyAccountResponse struct {
Account selfservice.AccountView `json:"account"`
}
type updateMyProfileRequest struct {
RaceName string `json:"race_name"`
}
type updateMySettingsRequest struct {
PreferredLanguage string `json:"preferred_language"`
TimeZone string `json:"time_zone"`
}
type syncDeclaredCountryRequest struct {
DeclaredCountry string `json:"declared_country"`
}
type syncDeclaredCountryResponse struct {
UserID string `json:"user_id"`
DeclaredCountry string `json:"declared_country"`
UpdatedAt time.Time `json:"updated_at"`
}
type actorDTO struct {
Type string `json:"type"`
ID string `json:"id,omitempty"`
}
type grantEntitlementRequest struct {
PlanCode string `json:"plan_code"`
Source string `json:"source"`
ReasonCode string `json:"reason_code"`
Actor actorDTO `json:"actor"`
StartsAt string `json:"starts_at"`
EndsAt string `json:"ends_at,omitempty"`
}
type extendEntitlementRequest struct {
Source string `json:"source"`
ReasonCode string `json:"reason_code"`
Actor actorDTO `json:"actor"`
EndsAt string `json:"ends_at"`
}
type revokeEntitlementRequest struct {
Source string `json:"source"`
ReasonCode string `json:"reason_code"`
Actor actorDTO `json:"actor"`
}
type applySanctionRequest struct {
SanctionCode string `json:"sanction_code"`
Scope string `json:"scope"`
ReasonCode string `json:"reason_code"`
Actor actorDTO `json:"actor"`
AppliedAt string `json:"applied_at"`
ExpiresAt string `json:"expires_at,omitempty"`
}
type removeSanctionRequest struct {
SanctionCode string `json:"sanction_code"`
ReasonCode string `json:"reason_code"`
Actor actorDTO `json:"actor"`
}
type setLimitRequest struct {
LimitCode string `json:"limit_code"`
Value int `json:"value"`
ReasonCode string `json:"reason_code"`
Actor actorDTO `json:"actor"`
AppliedAt string `json:"applied_at"`
ExpiresAt string `json:"expires_at,omitempty"`
}
type removeLimitRequest struct {
LimitCode string `json:"limit_code"`
ReasonCode string `json:"reason_code"`
Actor actorDTO `json:"actor"`
}
type entitlementSnapshotResponse struct {
PlanCode string `json:"plan_code"`
IsPaid bool `json:"is_paid"`
Source string `json:"source"`
Actor actorDTO `json:"actor"`
ReasonCode string `json:"reason_code"`
StartsAt time.Time `json:"starts_at"`
EndsAt *time.Time `json:"ends_at,omitempty"`
UpdatedAt time.Time `json:"updated_at"`
}
type entitlementCommandResponse struct {
UserID string `json:"user_id"`
Entitlement entitlementSnapshotResponse `json:"entitlement"`
}
func newHandlerWithConfig(cfg Config, deps Dependencies) (http.Handler, error) {
if err := cfg.Validate(); err != nil {
return nil, err
}
normalizedDeps, err := normalizeDependencies(deps)
if err != nil {
return nil, err
}
configureGinModeOnce.Do(func() {
gin.SetMode(gin.ReleaseMode)
})
engine := gin.New()
engine.Use(newOTelMiddleware(normalizedDeps.Telemetry))
engine.Use(withObservability(normalizedDeps.Logger, normalizedDeps.Telemetry))
engine.POST("/api/v1/internal/user-resolutions/by-email", handleResolveByEmail(normalizedDeps.ResolveByEmail, cfg.RequestTimeout))
engine.GET("/api/v1/internal/users/:user_id/exists", handleExistsByUserID(normalizedDeps.ExistsByUserID, cfg.RequestTimeout))
engine.POST("/api/v1/internal/users/ensure-by-email", handleEnsureByEmail(normalizedDeps.EnsureByEmail, cfg.RequestTimeout))
engine.POST("/api/v1/internal/users/:user_id/block", handleBlockByUserID(normalizedDeps.BlockByUserID, cfg.RequestTimeout))
engine.POST("/api/v1/internal/user-blocks/by-email", handleBlockByEmail(normalizedDeps.BlockByEmail, cfg.RequestTimeout))
engine.GET("/api/v1/internal/users/:user_id/account", handleGetMyAccount(normalizedDeps.GetMyAccount, cfg.RequestTimeout))
engine.POST("/api/v1/internal/users/:user_id/profile", handleUpdateMyProfile(normalizedDeps.UpdateMyProfile, cfg.RequestTimeout))
engine.POST("/api/v1/internal/users/:user_id/settings", handleUpdateMySettings(normalizedDeps.UpdateMySettings, cfg.RequestTimeout))
engine.GET("/api/v1/internal/users/:user_id", handleGetUserByID(normalizedDeps.GetUserByID, cfg.RequestTimeout))
engine.POST("/api/v1/internal/user-lookups/by-email", handleGetUserByEmail(normalizedDeps.GetUserByEmail, cfg.RequestTimeout))
engine.POST("/api/v1/internal/user-lookups/by-race-name", handleGetUserByRaceName(normalizedDeps.GetUserByRaceName, cfg.RequestTimeout))
engine.GET("/api/v1/internal/users", handleListUsers(normalizedDeps.ListUsers, cfg.RequestTimeout))
engine.GET("/api/v1/internal/users/:user_id/eligibility", handleGetUserEligibility(normalizedDeps.GetUserEligibility, cfg.RequestTimeout))
engine.POST("/api/v1/internal/users/:user_id/declared-country/sync", handleSyncDeclaredCountry(normalizedDeps.SyncDeclaredCountry, cfg.RequestTimeout))
engine.POST("/api/v1/internal/users/:user_id/entitlements/grant", handleGrantEntitlement(normalizedDeps.GrantEntitlement, cfg.RequestTimeout))
engine.POST("/api/v1/internal/users/:user_id/entitlements/extend", handleExtendEntitlement(normalizedDeps.ExtendEntitlement, cfg.RequestTimeout))
engine.POST("/api/v1/internal/users/:user_id/entitlements/revoke", handleRevokeEntitlement(normalizedDeps.RevokeEntitlement, cfg.RequestTimeout))
engine.POST("/api/v1/internal/users/:user_id/sanctions/apply", handleApplySanction(normalizedDeps.ApplySanction, cfg.RequestTimeout))
engine.POST("/api/v1/internal/users/:user_id/sanctions/remove", handleRemoveSanction(normalizedDeps.RemoveSanction, cfg.RequestTimeout))
engine.POST("/api/v1/internal/users/:user_id/limits/set", handleSetLimit(normalizedDeps.SetLimit, cfg.RequestTimeout))
engine.POST("/api/v1/internal/users/:user_id/limits/remove", handleRemoveLimit(normalizedDeps.RemoveLimit, cfg.RequestTimeout))
return engine, nil
}
func handleResolveByEmail(useCase ResolveByEmailUseCase, timeout time.Duration) gin.HandlerFunc {
return func(c *gin.Context) {
var request resolveByEmailRequest
if err := decodeJSONRequest(c.Request, &request); err != nil {
abortWithProjection(c, shared.ProjectInternalError(shared.InvalidRequest(err.Error())))
return
}
callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout)
defer cancel()
result, err := useCase.Execute(callCtx, authdirectory.ResolveByEmailInput{
Email: request.Email,
})
if err != nil {
abortWithProjection(c, shared.ProjectInternalError(err))
return
}
c.JSON(http.StatusOK, resolveByEmailResponse{
Kind: result.Kind,
UserID: result.UserID,
BlockReasonCode: result.BlockReasonCode,
})
}
}
func handleExistsByUserID(useCase ExistsByUserIDUseCase, timeout time.Duration) gin.HandlerFunc {
return func(c *gin.Context) {
callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout)
defer cancel()
result, err := useCase.Execute(callCtx, authdirectory.ExistsByUserIDInput{
UserID: c.Param("user_id"),
})
if err != nil {
abortWithProjection(c, shared.ProjectInternalError(err))
return
}
c.JSON(http.StatusOK, existsByUserIDResponse{Exists: result.Exists})
}
}
func handleEnsureByEmail(useCase EnsureByEmailUseCase, timeout time.Duration) gin.HandlerFunc {
return func(c *gin.Context) {
var request ensureByEmailRequest
if err := decodeJSONRequest(c.Request, &request); err != nil {
abortWithProjection(c, shared.ProjectInternalError(shared.InvalidRequest(err.Error())))
return
}
if request.RegistrationContext == nil {
abortWithProjection(c, shared.ProjectInternalError(shared.InvalidRequest("registration_context must be present")))
return
}
var registrationContext *authdirectory.RegistrationContext
registrationContext = &authdirectory.RegistrationContext{
PreferredLanguage: request.RegistrationContext.PreferredLanguage,
TimeZone: request.RegistrationContext.TimeZone,
}
callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout)
defer cancel()
result, err := useCase.Execute(callCtx, authdirectory.EnsureByEmailInput{
Email: request.Email,
RegistrationContext: registrationContext,
})
if err != nil {
abortWithProjection(c, shared.ProjectInternalError(err))
return
}
c.JSON(http.StatusOK, ensureByEmailResponse{
Outcome: result.Outcome,
UserID: result.UserID,
BlockReasonCode: result.BlockReasonCode,
})
}
}
func handleBlockByUserID(useCase BlockByUserIDUseCase, timeout time.Duration) gin.HandlerFunc {
return func(c *gin.Context) {
var request blockByUserIDRequest
if err := decodeJSONRequest(c.Request, &request); err != nil {
abortWithProjection(c, shared.ProjectInternalError(shared.InvalidRequest(err.Error())))
return
}
callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout)
defer cancel()
result, err := useCase.Execute(callCtx, authdirectory.BlockByUserIDInput{
UserID: c.Param("user_id"),
ReasonCode: request.ReasonCode,
})
if err != nil {
abortWithProjection(c, shared.ProjectInternalError(err))
return
}
c.JSON(http.StatusOK, blockResponse{
Outcome: result.Outcome,
UserID: result.UserID,
})
}
}
func handleBlockByEmail(useCase BlockByEmailUseCase, timeout time.Duration) gin.HandlerFunc {
return func(c *gin.Context) {
var request blockByEmailRequest
if err := decodeJSONRequest(c.Request, &request); err != nil {
abortWithProjection(c, shared.ProjectInternalError(shared.InvalidRequest(err.Error())))
return
}
callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout)
defer cancel()
result, err := useCase.Execute(callCtx, authdirectory.BlockByEmailInput{
Email: request.Email,
ReasonCode: request.ReasonCode,
})
if err != nil {
abortWithProjection(c, shared.ProjectInternalError(err))
return
}
c.JSON(http.StatusOK, blockResponse{
Outcome: result.Outcome,
UserID: result.UserID,
})
}
}
func handleGetMyAccount(useCase GetMyAccountUseCase, timeout time.Duration) gin.HandlerFunc {
return func(c *gin.Context) {
callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout)
defer cancel()
result, err := useCase.Execute(callCtx, selfservice.GetMyAccountInput{
UserID: c.Param("user_id"),
})
if err != nil {
abortWithProjection(c, shared.ProjectInternalError(err))
return
}
c.JSON(http.StatusOK, getMyAccountResponse{
Account: result.Account,
})
}
}
func handleUpdateMyProfile(useCase UpdateMyProfileUseCase, timeout time.Duration) gin.HandlerFunc {
return func(c *gin.Context) {
var request updateMyProfileRequest
if err := decodeJSONRequest(c.Request, &request); err != nil {
abortWithProjection(c, shared.ProjectInternalError(shared.InvalidRequest(err.Error())))
return
}
callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout)
defer cancel()
result, err := useCase.Execute(callCtx, selfservice.UpdateMyProfileInput{
UserID: c.Param("user_id"),
RaceName: request.RaceName,
})
if err != nil {
abortWithProjection(c, shared.ProjectInternalError(err))
return
}
c.JSON(http.StatusOK, getMyAccountResponse{
Account: result.Account,
})
}
}
func handleUpdateMySettings(useCase UpdateMySettingsUseCase, timeout time.Duration) gin.HandlerFunc {
return func(c *gin.Context) {
var request updateMySettingsRequest
if err := decodeJSONRequest(c.Request, &request); err != nil {
abortWithProjection(c, shared.ProjectInternalError(shared.InvalidRequest(err.Error())))
return
}
callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout)
defer cancel()
result, err := useCase.Execute(callCtx, selfservice.UpdateMySettingsInput{
UserID: c.Param("user_id"),
PreferredLanguage: request.PreferredLanguage,
TimeZone: request.TimeZone,
})
if err != nil {
abortWithProjection(c, shared.ProjectInternalError(err))
return
}
c.JSON(http.StatusOK, getMyAccountResponse{
Account: result.Account,
})
}
}
func handleGetUserEligibility(useCase GetUserEligibilityUseCase, timeout time.Duration) gin.HandlerFunc {
return func(c *gin.Context) {
callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout)
defer cancel()
result, err := useCase.Execute(callCtx, lobbyeligibility.GetUserEligibilityInput{
UserID: c.Param("user_id"),
})
if err != nil {
abortWithProjection(c, shared.ProjectInternalError(err))
return
}
c.JSON(http.StatusOK, result)
}
}
func handleSyncDeclaredCountry(useCase SyncDeclaredCountryUseCase, timeout time.Duration) gin.HandlerFunc {
return func(c *gin.Context) {
var request syncDeclaredCountryRequest
if err := decodeJSONRequest(c.Request, &request); err != nil {
abortWithProjection(c, shared.ProjectInternalError(shared.InvalidRequest(err.Error())))
return
}
callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout)
defer cancel()
result, err := useCase.Execute(callCtx, geosync.SyncDeclaredCountryInput{
UserID: c.Param("user_id"),
DeclaredCountry: request.DeclaredCountry,
})
if err != nil {
abortWithProjection(c, shared.ProjectInternalError(err))
return
}
c.JSON(http.StatusOK, syncDeclaredCountryResponse{
UserID: result.UserID,
DeclaredCountry: result.DeclaredCountry,
UpdatedAt: result.UpdatedAt.UTC(),
})
}
}
func handleGrantEntitlement(useCase GrantEntitlementUseCase, timeout time.Duration) gin.HandlerFunc {
return func(c *gin.Context) {
var request grantEntitlementRequest
if err := decodeJSONRequest(c.Request, &request); err != nil {
abortWithProjection(c, shared.ProjectInternalError(shared.InvalidRequest(err.Error())))
return
}
callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout)
defer cancel()
result, err := useCase.Execute(callCtx, entitlementsvc.GrantInput{
UserID: c.Param("user_id"),
PlanCode: request.PlanCode,
Source: request.Source,
ReasonCode: request.ReasonCode,
Actor: entitlementsvc.ActorInput{
Type: request.Actor.Type,
ID: request.Actor.ID,
},
StartsAt: request.StartsAt,
EndsAt: request.EndsAt,
})
if err != nil {
abortWithProjection(c, shared.ProjectInternalError(err))
return
}
c.JSON(http.StatusOK, entitlementCommandResponseFromResult(result))
}
}
func handleExtendEntitlement(useCase ExtendEntitlementUseCase, timeout time.Duration) gin.HandlerFunc {
return func(c *gin.Context) {
var request extendEntitlementRequest
if err := decodeJSONRequest(c.Request, &request); err != nil {
abortWithProjection(c, shared.ProjectInternalError(shared.InvalidRequest(err.Error())))
return
}
callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout)
defer cancel()
result, err := useCase.Execute(callCtx, entitlementsvc.ExtendInput{
UserID: c.Param("user_id"),
Source: request.Source,
ReasonCode: request.ReasonCode,
Actor: entitlementsvc.ActorInput{
Type: request.Actor.Type,
ID: request.Actor.ID,
},
EndsAt: request.EndsAt,
})
if err != nil {
abortWithProjection(c, shared.ProjectInternalError(err))
return
}
c.JSON(http.StatusOK, entitlementCommandResponseFromResult(result))
}
}
func handleRevokeEntitlement(useCase RevokeEntitlementUseCase, timeout time.Duration) gin.HandlerFunc {
return func(c *gin.Context) {
var request revokeEntitlementRequest
if err := decodeJSONRequest(c.Request, &request); err != nil {
abortWithProjection(c, shared.ProjectInternalError(shared.InvalidRequest(err.Error())))
return
}
callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout)
defer cancel()
result, err := useCase.Execute(callCtx, entitlementsvc.RevokeInput{
UserID: c.Param("user_id"),
Source: request.Source,
ReasonCode: request.ReasonCode,
Actor: entitlementsvc.ActorInput{
Type: request.Actor.Type,
ID: request.Actor.ID,
},
})
if err != nil {
abortWithProjection(c, shared.ProjectInternalError(err))
return
}
c.JSON(http.StatusOK, entitlementCommandResponseFromResult(result))
}
}
func handleApplySanction(useCase ApplySanctionUseCase, timeout time.Duration) gin.HandlerFunc {
return func(c *gin.Context) {
var request applySanctionRequest
if err := decodeJSONRequest(c.Request, &request); err != nil {
abortWithProjection(c, shared.ProjectInternalError(shared.InvalidRequest(err.Error())))
return
}
callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout)
defer cancel()
result, err := useCase.Execute(callCtx, policysvc.ApplySanctionInput{
UserID: c.Param("user_id"),
SanctionCode: request.SanctionCode,
Scope: request.Scope,
ReasonCode: request.ReasonCode,
Actor: policysvc.ActorInput{
Type: request.Actor.Type,
ID: request.Actor.ID,
},
AppliedAt: request.AppliedAt,
ExpiresAt: request.ExpiresAt,
})
if err != nil {
abortWithProjection(c, shared.ProjectInternalError(err))
return
}
c.JSON(http.StatusOK, result)
}
}
func handleRemoveSanction(useCase RemoveSanctionUseCase, timeout time.Duration) gin.HandlerFunc {
return func(c *gin.Context) {
var request removeSanctionRequest
if err := decodeJSONRequest(c.Request, &request); err != nil {
abortWithProjection(c, shared.ProjectInternalError(shared.InvalidRequest(err.Error())))
return
}
callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout)
defer cancel()
result, err := useCase.Execute(callCtx, policysvc.RemoveSanctionInput{
UserID: c.Param("user_id"),
SanctionCode: request.SanctionCode,
ReasonCode: request.ReasonCode,
Actor: policysvc.ActorInput{
Type: request.Actor.Type,
ID: request.Actor.ID,
},
})
if err != nil {
abortWithProjection(c, shared.ProjectInternalError(err))
return
}
c.JSON(http.StatusOK, result)
}
}
func handleSetLimit(useCase SetLimitUseCase, timeout time.Duration) gin.HandlerFunc {
return func(c *gin.Context) {
var request setLimitRequest
if err := decodeJSONRequest(c.Request, &request); err != nil {
abortWithProjection(c, shared.ProjectInternalError(shared.InvalidRequest(err.Error())))
return
}
callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout)
defer cancel()
result, err := useCase.Execute(callCtx, policysvc.SetLimitInput{
UserID: c.Param("user_id"),
LimitCode: request.LimitCode,
Value: request.Value,
ReasonCode: request.ReasonCode,
Actor: policysvc.ActorInput{
Type: request.Actor.Type,
ID: request.Actor.ID,
},
AppliedAt: request.AppliedAt,
ExpiresAt: request.ExpiresAt,
})
if err != nil {
abortWithProjection(c, shared.ProjectInternalError(err))
return
}
c.JSON(http.StatusOK, result)
}
}
func handleRemoveLimit(useCase RemoveLimitUseCase, timeout time.Duration) gin.HandlerFunc {
return func(c *gin.Context) {
var request removeLimitRequest
if err := decodeJSONRequest(c.Request, &request); err != nil {
abortWithProjection(c, shared.ProjectInternalError(shared.InvalidRequest(err.Error())))
return
}
callCtx, cancel := context.WithTimeout(c.Request.Context(), timeout)
defer cancel()
result, err := useCase.Execute(callCtx, policysvc.RemoveLimitInput{
UserID: c.Param("user_id"),
LimitCode: request.LimitCode,
ReasonCode: request.ReasonCode,
Actor: policysvc.ActorInput{
Type: request.Actor.Type,
ID: request.Actor.ID,
},
})
if err != nil {
abortWithProjection(c, shared.ProjectInternalError(err))
return
}
c.JSON(http.StatusOK, result)
}
}
func normalizeDependencies(deps Dependencies) (Dependencies, error) {
switch {
case deps.ResolveByEmail == nil:
return Dependencies{}, fmt.Errorf("resolve-by-email use case must not be nil")
case deps.EnsureByEmail == nil:
return Dependencies{}, fmt.Errorf("ensure-by-email use case must not be nil")
case deps.ExistsByUserID == nil:
return Dependencies{}, fmt.Errorf("exists-by-user-id use case must not be nil")
case deps.BlockByUserID == nil:
return Dependencies{}, fmt.Errorf("block-by-user-id use case must not be nil")
case deps.BlockByEmail == nil:
return Dependencies{}, fmt.Errorf("block-by-email use case must not be nil")
case deps.GetMyAccount == nil:
return Dependencies{}, fmt.Errorf("get-my-account use case must not be nil")
case deps.UpdateMyProfile == nil:
return Dependencies{}, fmt.Errorf("update-my-profile use case must not be nil")
case deps.UpdateMySettings == nil:
return Dependencies{}, fmt.Errorf("update-my-settings use case must not be nil")
case deps.GetUserByID == nil:
return Dependencies{}, fmt.Errorf("get-user-by-id use case must not be nil")
case deps.GetUserByEmail == nil:
return Dependencies{}, fmt.Errorf("get-user-by-email use case must not be nil")
case deps.GetUserByRaceName == nil:
return Dependencies{}, fmt.Errorf("get-user-by-race-name use case must not be nil")
case deps.ListUsers == nil:
return Dependencies{}, fmt.Errorf("list-users use case must not be nil")
case deps.GetUserEligibility == nil:
return Dependencies{}, fmt.Errorf("get-user-eligibility use case must not be nil")
case deps.SyncDeclaredCountry == nil:
return Dependencies{}, fmt.Errorf("sync-declared-country use case must not be nil")
case deps.GrantEntitlement == nil:
return Dependencies{}, fmt.Errorf("grant-entitlement use case must not be nil")
case deps.ExtendEntitlement == nil:
return Dependencies{}, fmt.Errorf("extend-entitlement use case must not be nil")
case deps.RevokeEntitlement == nil:
return Dependencies{}, fmt.Errorf("revoke-entitlement use case must not be nil")
case deps.ApplySanction == nil:
return Dependencies{}, fmt.Errorf("apply-sanction use case must not be nil")
case deps.RemoveSanction == nil:
return Dependencies{}, fmt.Errorf("remove-sanction use case must not be nil")
case deps.SetLimit == nil:
return Dependencies{}, fmt.Errorf("set-limit use case must not be nil")
case deps.RemoveLimit == nil:
return Dependencies{}, fmt.Errorf("remove-limit use case must not be nil")
default:
if deps.Logger == nil {
deps.Logger = slog.Default()
}
return deps, nil
}
}
func entitlementCommandResponseFromResult(result entitlementsvc.CommandResult) entitlementCommandResponse {
response := entitlementCommandResponse{
UserID: result.UserID,
Entitlement: entitlementSnapshotResponse{
PlanCode: string(result.Entitlement.PlanCode),
IsPaid: result.Entitlement.IsPaid,
Source: result.Entitlement.Source.String(),
Actor: actorDTO{Type: result.Entitlement.Actor.Type.String(), ID: result.Entitlement.Actor.ID.String()},
ReasonCode: result.Entitlement.ReasonCode.String(),
StartsAt: result.Entitlement.StartsAt.UTC(),
UpdatedAt: result.Entitlement.UpdatedAt.UTC(),
},
}
if result.Entitlement.EndsAt != nil {
value := result.Entitlement.EndsAt.UTC()
response.Entitlement.EndsAt = &value
}
return response
}
func newOTelMiddleware(runtime *telemetry.Runtime) gin.HandlerFunc {
options := []otelgin.Option{}
if runtime != nil {
options = append(
options,
otelgin.WithTracerProvider(runtime.TracerProvider()),
otelgin.WithMeterProvider(runtime.MeterProvider()),
)
}
return otelgin.Middleware(internalHTTPServiceName, options...)
}
func withObservability(logger *slog.Logger, metrics *telemetry.Runtime) gin.HandlerFunc {
if logger == nil {
logger = slog.Default()
}
return func(c *gin.Context) {
startedAt := time.Now()
c.Next()
statusCode := c.Writer.Status()
route := c.FullPath()
if route == "" {
route = "unmatched"
}
errorCode, _ := c.Get(internalErrorCodeContextKey)
errorCodeValue, _ := errorCode.(string)
outcome := outcomeFromStatusCode(statusCode)
duration := time.Since(startedAt)
attrs := []any{
"transport", "http",
"route", route,
"method", c.Request.Method,
"status_code", statusCode,
"duration_ms", float64(duration.Microseconds()) / 1000,
"edge_outcome", string(outcome),
}
if errorCodeValue != "" {
attrs = append(attrs, "error_code", errorCodeValue)
}
attrs = append(attrs, logging.TraceAttrsFromContext(c.Request.Context())...)
metricAttrs := []attribute.KeyValue{
attribute.String("route", route),
attribute.String("method", c.Request.Method),
attribute.String("edge_outcome", string(outcome)),
}
if errorCodeValue != "" {
metricAttrs = append(metricAttrs, attribute.String("error_code", errorCodeValue))
}
metrics.RecordInternalHTTPRequest(c.Request.Context(), metricAttrs, duration)
switch outcome {
case edgeOutcomeSuccess:
logger.InfoContext(c.Request.Context(), "internal request completed", attrs...)
case edgeOutcomeFailed:
logger.ErrorContext(c.Request.Context(), "internal request failed", attrs...)
default:
logger.WarnContext(c.Request.Context(), "internal request rejected", attrs...)
}
}
}
type edgeOutcome string
const (
edgeOutcomeSuccess edgeOutcome = "success"
edgeOutcomeRejected edgeOutcome = "rejected"
edgeOutcomeFailed edgeOutcome = "failed"
)
func outcomeFromStatusCode(statusCode int) edgeOutcome {
switch {
case statusCode >= 500:
return edgeOutcomeFailed
case statusCode >= 400:
return edgeOutcomeRejected
default:
return edgeOutcomeSuccess
}
}
File diff suppressed because it is too large Load Diff
+88
View File
@@ -0,0 +1,88 @@
package internalhttp
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"galaxy/user/internal/service/shared"
"github.com/gin-gonic/gin"
)
const internalErrorCodeContextKey = "internal_error_code"
type malformedJSONRequestError struct {
message string
}
func (err *malformedJSONRequestError) Error() string {
if err == nil {
return ""
}
return err.message
}
func decodeJSONRequest(request *http.Request, target any) error {
if request == nil || request.Body == nil {
return &malformedJSONRequestError{message: "request body must not be empty"}
}
decoder := json.NewDecoder(request.Body)
decoder.DisallowUnknownFields()
if err := decoder.Decode(target); err != nil {
return describeJSONDecodeError(err)
}
if err := decoder.Decode(&struct{}{}); err != nil {
if errors.Is(err, io.EOF) {
return nil
}
return &malformedJSONRequestError{message: "request body must contain a single JSON object"}
}
return &malformedJSONRequestError{message: "request body must contain a single JSON object"}
}
func describeJSONDecodeError(err error) error {
var syntaxErr *json.SyntaxError
var typeErr *json.UnmarshalTypeError
switch {
case errors.Is(err, io.EOF):
return &malformedJSONRequestError{message: "request body must not be empty"}
case errors.As(err, &syntaxErr):
return &malformedJSONRequestError{message: "request body contains malformed JSON"}
case errors.Is(err, io.ErrUnexpectedEOF):
return &malformedJSONRequestError{message: "request body contains malformed JSON"}
case errors.As(err, &typeErr):
if strings.TrimSpace(typeErr.Field) != "" {
return &malformedJSONRequestError{
message: fmt.Sprintf("request body contains an invalid value for %q", typeErr.Field),
}
}
return &malformedJSONRequestError{message: "request body contains an invalid JSON value"}
case strings.HasPrefix(err.Error(), "json: unknown field "):
return &malformedJSONRequestError{
message: fmt.Sprintf("request body contains unknown field %s", strings.TrimPrefix(err.Error(), "json: unknown field ")),
}
default:
return &malformedJSONRequestError{message: "request body contains invalid JSON"}
}
}
func abortWithProjection(c *gin.Context, projection shared.InternalErrorProjection) {
c.Set(internalErrorCodeContextKey, projection.Code)
c.AbortWithStatusJSON(projection.StatusCode, errorResponse{
Error: errorBody{
Code: projection.Code,
Message: projection.Message,
},
})
}
@@ -0,0 +1,112 @@
package internalhttp
import (
"bytes"
"context"
"log/slog"
"net/http"
"net/http/httptest"
"testing"
"galaxy/user/internal/service/authdirectory"
usertelemetry "galaxy/user/internal/telemetry"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.opentelemetry.io/otel/attribute"
sdkmetric "go.opentelemetry.io/otel/sdk/metric"
"go.opentelemetry.io/otel/sdk/metric/metricdata"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
"go.opentelemetry.io/otel/sdk/trace/tracetest"
)
func TestInternalHandlerEmitsTraceFieldsAndMetrics(t *testing.T) {
t.Parallel()
logger, buffer := newObservedLogger()
telemetryRuntime, reader, recorder := newObservedInternalTelemetryRuntime(t)
handler := mustNewHandler(t, Dependencies{
Logger: logger,
Telemetry: telemetryRuntime,
ExistsByUserID: existsByUserIDFunc(func(context.Context, authdirectory.ExistsByUserIDInput) (authdirectory.ExistsByUserIDResult, error) {
return authdirectory.ExistsByUserIDResult{Exists: true}, nil
}),
})
recorderHTTP := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodGet, "/api/v1/internal/users/user-123/exists", nil)
request.Header.Set("traceparent", "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01")
handler.ServeHTTP(recorderHTTP, request)
require.Equal(t, http.StatusOK, recorderHTTP.Code)
require.NotEmpty(t, recorder.Ended())
assert.Contains(t, buffer.String(), "otel_trace_id")
assert.Contains(t, buffer.String(), "otel_span_id")
assertMetricCount(t, reader, "user.internal_http.requests", map[string]string{
"route": "/api/v1/internal/users/:user_id/exists",
"method": http.MethodGet,
"edge_outcome": "success",
}, 1)
}
func newObservedInternalTelemetryRuntime(t *testing.T) (*usertelemetry.Runtime, *sdkmetric.ManualReader, *tracetest.SpanRecorder) {
t.Helper()
reader := sdkmetric.NewManualReader()
meterProvider := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader))
recorder := tracetest.NewSpanRecorder()
tracerProvider := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(recorder))
runtime, err := usertelemetry.NewWithProviders(meterProvider, tracerProvider)
require.NoError(t, err)
return runtime, reader, recorder
}
func newObservedLogger() (*slog.Logger, *bytes.Buffer) {
buffer := &bytes.Buffer{}
return slog.New(slog.NewJSONHandler(buffer, &slog.HandlerOptions{Level: slog.LevelDebug})), buffer
}
func assertMetricCount(t *testing.T, reader *sdkmetric.ManualReader, metricName string, wantAttrs map[string]string, wantValue int64) {
t.Helper()
var resourceMetrics metricdata.ResourceMetrics
require.NoError(t, reader.Collect(context.Background(), &resourceMetrics))
for _, scopeMetrics := range resourceMetrics.ScopeMetrics {
for _, metric := range scopeMetrics.Metrics {
if metric.Name != metricName {
continue
}
sum, ok := metric.Data.(metricdata.Sum[int64])
require.True(t, ok)
for _, point := range sum.DataPoints {
if hasMetricAttributes(point.Attributes.ToSlice(), wantAttrs) {
assert.Equal(t, wantValue, point.Value)
return
}
}
}
}
require.Failf(t, "test failed", "metric %q with attrs %v not found", metricName, wantAttrs)
}
func hasMetricAttributes(values []attribute.KeyValue, want map[string]string) bool {
if len(values) != len(want) {
return false
}
for _, value := range values {
if want[string(value.Key)] != value.Value.AsString() {
return false
}
}
return true
}
+411
View File
@@ -0,0 +1,411 @@
// Package internalhttp exposes the trusted internal HTTP API used by auth,
// gateway self-service, and internal administrative workflows.
package internalhttp
import (
"context"
"errors"
"fmt"
"log/slog"
"net"
"net/http"
"sync"
"time"
"galaxy/user/internal/service/adminusers"
"galaxy/user/internal/service/authdirectory"
"galaxy/user/internal/service/entitlementsvc"
"galaxy/user/internal/service/geosync"
"galaxy/user/internal/service/lobbyeligibility"
"galaxy/user/internal/service/policysvc"
"galaxy/user/internal/service/selfservice"
"galaxy/user/internal/telemetry"
)
const jsonContentType = "application/json; charset=utf-8"
var configureGinModeOnce sync.Once
// ResolveByEmailUseCase describes the auth-facing resolve-by-email service
// consumed by the HTTP transport layer.
type ResolveByEmailUseCase interface {
// Execute resolves one e-mail subject without creating any account.
Execute(ctx context.Context, input authdirectory.ResolveByEmailInput) (authdirectory.ResolveByEmailResult, error)
}
// EnsureByEmailUseCase describes the auth-facing ensure-by-email service
// consumed by the HTTP transport layer.
type EnsureByEmailUseCase interface {
// Execute returns an existing user, creates a new one, or reports a blocked
// outcome for one e-mail subject.
Execute(ctx context.Context, input authdirectory.EnsureByEmailInput) (authdirectory.EnsureByEmailResult, error)
}
// ExistsByUserIDUseCase describes the auth-facing exists-by-user-id service
// consumed by the HTTP transport layer.
type ExistsByUserIDUseCase interface {
// Execute reports whether one stable user identifier exists.
Execute(ctx context.Context, input authdirectory.ExistsByUserIDInput) (authdirectory.ExistsByUserIDResult, error)
}
// BlockByUserIDUseCase describes the auth-facing block-by-user-id service
// consumed by the HTTP transport layer.
type BlockByUserIDUseCase interface {
// Execute blocks one account addressed by stable user identifier.
Execute(ctx context.Context, input authdirectory.BlockByUserIDInput) (authdirectory.BlockResult, error)
}
// BlockByEmailUseCase describes the auth-facing block-by-email service
// consumed by the HTTP transport layer.
type BlockByEmailUseCase interface {
// Execute blocks one exact normalized e-mail subject.
Execute(ctx context.Context, input authdirectory.BlockByEmailInput) (authdirectory.BlockResult, error)
}
// GetMyAccountUseCase describes the self-service account-read use case
// consumed by the HTTP transport layer.
type GetMyAccountUseCase interface {
// Execute returns the authenticated account aggregate for one user.
Execute(ctx context.Context, input selfservice.GetMyAccountInput) (selfservice.GetMyAccountResult, error)
}
// UpdateMyProfileUseCase describes the self-service profile-mutation use case
// consumed by the HTTP transport layer.
type UpdateMyProfileUseCase interface {
// Execute updates the allowed self-service profile fields for one user.
Execute(ctx context.Context, input selfservice.UpdateMyProfileInput) (selfservice.UpdateMyProfileResult, error)
}
// UpdateMySettingsUseCase describes the self-service settings-mutation use
// case consumed by the HTTP transport layer.
type UpdateMySettingsUseCase interface {
// Execute updates the allowed self-service settings fields for one user.
Execute(ctx context.Context, input selfservice.UpdateMySettingsInput) (selfservice.UpdateMySettingsResult, error)
}
// GetUserByIDUseCase describes the trusted admin exact-read by stable user id
// consumed by the HTTP transport layer.
type GetUserByIDUseCase interface {
// Execute returns the full current account aggregate for one user id.
Execute(ctx context.Context, input adminusers.GetUserByIDInput) (adminusers.LookupResult, error)
}
// GetUserByEmailUseCase describes the trusted admin exact-read by normalized
// e-mail consumed by the HTTP transport layer.
type GetUserByEmailUseCase interface {
// Execute returns the full current account aggregate for one normalized
// e-mail address.
Execute(ctx context.Context, input adminusers.GetUserByEmailInput) (adminusers.LookupResult, error)
}
// GetUserByRaceNameUseCase describes the trusted admin exact-read by exact
// stored race name consumed by the HTTP transport layer.
type GetUserByRaceNameUseCase interface {
// Execute returns the full current account aggregate for one exact race
// name.
Execute(ctx context.Context, input adminusers.GetUserByRaceNameInput) (adminusers.LookupResult, error)
}
// ListUsersUseCase describes the trusted admin paginated listing use case
// consumed by the HTTP transport layer.
type ListUsersUseCase interface {
// Execute returns one deterministic filtered page of full account
// aggregates.
Execute(ctx context.Context, input adminusers.ListUsersInput) (adminusers.ListUsersResult, error)
}
// GetUserEligibilityUseCase describes the trusted lobby-facing eligibility
// snapshot use case consumed by the HTTP transport layer.
type GetUserEligibilityUseCase interface {
// Execute returns one read-optimized lobby eligibility snapshot for one
// user.
Execute(ctx context.Context, input lobbyeligibility.GetUserEligibilityInput) (lobbyeligibility.GetUserEligibilityResult, error)
}
// SyncDeclaredCountryUseCase describes the trusted geo-facing declared-country
// sync use case consumed by the HTTP transport layer.
type SyncDeclaredCountryUseCase interface {
// Execute synchronizes the current effective declared country for one user.
Execute(ctx context.Context, input geosync.SyncDeclaredCountryInput) (geosync.SyncDeclaredCountryResult, error)
}
// GrantEntitlementUseCase describes the trusted entitlement-grant use case
// consumed by the HTTP transport layer.
type GrantEntitlementUseCase interface {
// Execute grants a new current paid entitlement for one user.
Execute(ctx context.Context, input entitlementsvc.GrantInput) (entitlementsvc.CommandResult, error)
}
// ExtendEntitlementUseCase describes the trusted entitlement-extend use case
// consumed by the HTTP transport layer.
type ExtendEntitlementUseCase interface {
// Execute extends the current finite paid entitlement for one user.
Execute(ctx context.Context, input entitlementsvc.ExtendInput) (entitlementsvc.CommandResult, error)
}
// RevokeEntitlementUseCase describes the trusted entitlement-revoke use case
// consumed by the HTTP transport layer.
type RevokeEntitlementUseCase interface {
// Execute revokes the current paid entitlement for one user.
Execute(ctx context.Context, input entitlementsvc.RevokeInput) (entitlementsvc.CommandResult, error)
}
// ApplySanctionUseCase describes the trusted sanction-apply use case consumed
// by the HTTP transport layer.
type ApplySanctionUseCase interface {
// Execute applies one new active sanction record.
Execute(ctx context.Context, input policysvc.ApplySanctionInput) (policysvc.SanctionCommandResult, error)
}
// RemoveSanctionUseCase describes the trusted sanction-remove use case
// consumed by the HTTP transport layer.
type RemoveSanctionUseCase interface {
// Execute removes one current active sanction record by code.
Execute(ctx context.Context, input policysvc.RemoveSanctionInput) (policysvc.SanctionCommandResult, error)
}
// SetLimitUseCase describes the trusted limit-set use case consumed by the
// HTTP transport layer.
type SetLimitUseCase interface {
// Execute creates or replaces one current active limit record.
Execute(ctx context.Context, input policysvc.SetLimitInput) (policysvc.LimitCommandResult, error)
}
// RemoveLimitUseCase describes the trusted limit-remove use case consumed by
// the HTTP transport layer.
type RemoveLimitUseCase interface {
// Execute removes one current active limit record by code.
Execute(ctx context.Context, input policysvc.RemoveLimitInput) (policysvc.LimitCommandResult, error)
}
// Config describes the trusted internal HTTP listener owned by the user
// service.
type Config struct {
// Addr stores the TCP listen address.
Addr string
// ReadHeaderTimeout bounds how long the listener may spend reading request
// headers before rejecting the connection.
ReadHeaderTimeout time.Duration
// ReadTimeout bounds how long the listener may spend reading one request.
ReadTimeout time.Duration
// IdleTimeout bounds how long keep-alive connections stay open.
IdleTimeout time.Duration
// RequestTimeout bounds one application-layer request execution.
RequestTimeout time.Duration
}
// Validate reports whether cfg contains a usable internal HTTP listener
// configuration.
func (cfg Config) Validate() error {
switch {
case cfg.Addr == "":
return errors.New("internal HTTP addr must not be empty")
case cfg.ReadHeaderTimeout <= 0:
return errors.New("internal HTTP read header timeout must be positive")
case cfg.ReadTimeout <= 0:
return errors.New("internal HTTP read timeout must be positive")
case cfg.IdleTimeout <= 0:
return errors.New("internal HTTP idle timeout must be positive")
case cfg.RequestTimeout <= 0:
return errors.New("internal HTTP request timeout must be positive")
default:
return nil
}
}
// Dependencies describes the collaborators used by the trusted internal HTTP
// transport layer.
type Dependencies struct {
// ResolveByEmail executes the auth-facing resolve-by-email use case.
ResolveByEmail ResolveByEmailUseCase
// EnsureByEmail executes the auth-facing ensure-by-email use case.
EnsureByEmail EnsureByEmailUseCase
// ExistsByUserID executes the auth-facing exists-by-user-id use case.
ExistsByUserID ExistsByUserIDUseCase
// BlockByUserID executes the auth-facing block-by-user-id use case.
BlockByUserID BlockByUserIDUseCase
// BlockByEmail executes the auth-facing block-by-email use case.
BlockByEmail BlockByEmailUseCase
// GetMyAccount executes the self-service authenticated account-read use
// case.
GetMyAccount GetMyAccountUseCase
// UpdateMyProfile executes the self-service profile-mutation use case.
UpdateMyProfile UpdateMyProfileUseCase
// UpdateMySettings executes the self-service settings-mutation use case.
UpdateMySettings UpdateMySettingsUseCase
// GetUserByID executes the trusted admin exact-read by stable user id.
GetUserByID GetUserByIDUseCase
// GetUserByEmail executes the trusted admin exact-read by normalized
// e-mail.
GetUserByEmail GetUserByEmailUseCase
// GetUserByRaceName executes the trusted admin exact-read by exact stored
// race name.
GetUserByRaceName GetUserByRaceNameUseCase
// ListUsers executes the trusted admin paginated filtered listing use case.
ListUsers ListUsersUseCase
// GetUserEligibility executes the trusted lobby-facing eligibility snapshot
// read.
GetUserEligibility GetUserEligibilityUseCase
// SyncDeclaredCountry executes the trusted geo-facing declared-country sync
// command.
SyncDeclaredCountry SyncDeclaredCountryUseCase
// GrantEntitlement executes the trusted entitlement-grant use case.
GrantEntitlement GrantEntitlementUseCase
// ExtendEntitlement executes the trusted entitlement-extend use case.
ExtendEntitlement ExtendEntitlementUseCase
// RevokeEntitlement executes the trusted entitlement-revoke use case.
RevokeEntitlement RevokeEntitlementUseCase
// ApplySanction executes the trusted sanction-apply use case.
ApplySanction ApplySanctionUseCase
// RemoveSanction executes the trusted sanction-remove use case.
RemoveSanction RemoveSanctionUseCase
// SetLimit executes the trusted limit-set use case.
SetLimit SetLimitUseCase
// RemoveLimit executes the trusted limit-remove use case.
RemoveLimit RemoveLimitUseCase
// Logger writes structured transport logs. When nil, the default logger is
// used.
Logger *slog.Logger
// Telemetry records OpenTelemetry spans and low-cardinality HTTP metrics.
Telemetry *telemetry.Runtime
}
// Server owns the trusted internal HTTP listener exposed by the user service.
type Server struct {
cfg Config
handler http.Handler
logger *slog.Logger
stateMu sync.RWMutex
server *http.Server
listener net.Listener
}
// NewServer constructs one trusted internal HTTP server for cfg and deps.
func NewServer(cfg Config, deps Dependencies) (*Server, error) {
if err := cfg.Validate(); err != nil {
return nil, fmt.Errorf("new internal HTTP server: %w", err)
}
handler, err := newHandlerWithConfig(cfg, deps)
if err != nil {
return nil, fmt.Errorf("new internal HTTP server: %w", err)
}
logger := deps.Logger
if logger == nil {
logger = slog.Default()
}
return &Server{
cfg: cfg,
handler: handler,
logger: logger,
}, nil
}
// Run binds the configured listener and serves the trusted internal HTTP
// surface until ctx is cancelled or Shutdown closes the server.
func (server *Server) Run(ctx context.Context) error {
if ctx == nil {
return errors.New("run internal HTTP server: nil context")
}
if err := ctx.Err(); err != nil {
return err
}
listener, err := net.Listen("tcp", server.cfg.Addr)
if err != nil {
return fmt.Errorf("run internal HTTP server: listen on %q: %w", server.cfg.Addr, err)
}
httpServer := &http.Server{
Handler: server.handler,
ReadHeaderTimeout: server.cfg.ReadHeaderTimeout,
ReadTimeout: server.cfg.ReadTimeout,
IdleTimeout: server.cfg.IdleTimeout,
}
server.stateMu.Lock()
server.server = httpServer
server.listener = listener
server.stateMu.Unlock()
server.logger.Info("internal HTTP server started", "addr", listener.Addr().String())
shutdownDone := make(chan struct{})
go func() {
defer close(shutdownDone)
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), server.cfg.RequestTimeout)
defer cancel()
_ = server.Shutdown(shutdownCtx)
}()
defer func() {
server.stateMu.Lock()
server.server = nil
server.listener = nil
server.stateMu.Unlock()
<-shutdownDone
}()
err = httpServer.Serve(listener)
switch {
case err == nil:
return nil
case errors.Is(err, http.ErrServerClosed):
server.logger.Info("internal HTTP server stopped")
return nil
default:
return fmt.Errorf("run internal HTTP server: serve on %q: %w", server.cfg.Addr, err)
}
}
// Shutdown gracefully stops the internal HTTP server within ctx.
func (server *Server) Shutdown(ctx context.Context) error {
if ctx == nil {
return errors.New("shutdown internal HTTP server: nil context")
}
server.stateMu.RLock()
httpServer := server.server
server.stateMu.RUnlock()
if httpServer == nil {
return nil
}
if err := httpServer.Shutdown(ctx); err != nil && !errors.Is(err, http.ErrServerClosed) {
return fmt.Errorf("shutdown internal HTTP server: %w", err)
}
return nil
}
+493
View File
@@ -0,0 +1,493 @@
// Package app wires the runnable user-service process.
package app
import (
"context"
"errors"
"fmt"
"log/slog"
"strings"
"sync"
"galaxy/user/internal/adapters/local"
"galaxy/user/internal/adapters/redis/domainevents"
"galaxy/user/internal/adapters/redis/userstore"
"galaxy/user/internal/adminapi"
"galaxy/user/internal/api/internalhttp"
"galaxy/user/internal/config"
"galaxy/user/internal/service/adminusers"
"galaxy/user/internal/service/authdirectory"
"galaxy/user/internal/service/entitlementsvc"
"galaxy/user/internal/service/geosync"
"galaxy/user/internal/service/lobbyeligibility"
"galaxy/user/internal/service/policysvc"
"galaxy/user/internal/service/selfservice"
"galaxy/user/internal/telemetry"
)
type pinger interface {
Ping(context.Context) error
}
type closer interface {
Close() error
}
// Runtime owns the runnable user-service process plus the cleanup functions
// that release runtime resources after shutdown.
type Runtime struct {
cfg config.Config
logger *slog.Logger
// Server owns the internal HTTP listener exposed by the user service.
Server *internalhttp.Server
// AdminServer owns the optional private admin HTTP listener.
AdminServer *adminapi.Server
// Telemetry owns the process-wide OpenTelemetry providers and Prometheus
// handler.
Telemetry *telemetry.Runtime
cleanupFns []func() error
}
// NewRuntime constructs the runnable user-service process from cfg.
func NewRuntime(ctx context.Context, cfg config.Config, logger *slog.Logger) (*Runtime, error) {
if ctx == nil {
return nil, fmt.Errorf("new user-service runtime: nil context")
}
if err := cfg.Validate(); err != nil {
return nil, fmt.Errorf("new user-service runtime: %w", err)
}
if logger == nil {
logger = slog.Default()
}
runtime := &Runtime{
cfg: cfg,
logger: logger,
}
cleanupOnError := func(err error) (*Runtime, error) {
return nil, fmt.Errorf("%w; cleanup: %w", err, runtime.Close())
}
telemetryRuntime, err := telemetry.NewProcess(ctx, telemetry.ProcessConfig{
ServiceName: cfg.Telemetry.ServiceName,
TracesExporter: cfg.Telemetry.TracesExporter,
MetricsExporter: cfg.Telemetry.MetricsExporter,
TracesProtocol: cfg.Telemetry.TracesProtocol,
MetricsProtocol: cfg.Telemetry.MetricsProtocol,
StdoutTracesEnabled: cfg.Telemetry.StdoutTracesEnabled,
StdoutMetricsEnabled: cfg.Telemetry.StdoutMetricsEnabled,
}, logger.With("component", "telemetry"))
if err != nil {
return cleanupOnError(fmt.Errorf("new user-service runtime: telemetry runtime: %w", err))
}
runtime.Telemetry = telemetryRuntime
runtime.cleanupFns = append(runtime.cleanupFns, func() error {
shutdownCtx, cancel := context.WithTimeout(context.Background(), cfg.ShutdownTimeout)
defer cancel()
return telemetryRuntime.Shutdown(shutdownCtx)
})
store, err := userstore.New(userstore.Config{
Addr: cfg.Redis.Addr,
Username: cfg.Redis.Username,
Password: cfg.Redis.Password,
DB: cfg.Redis.DB,
TLSEnabled: cfg.Redis.TLSEnabled,
KeyspacePrefix: cfg.Redis.KeyspacePrefix,
OperationTimeout: cfg.Redis.OperationTimeout,
})
if err != nil {
return cleanupOnError(fmt.Errorf("new user-service runtime: redis user store: %w", err))
}
runtime.cleanupFns = append(runtime.cleanupFns, store.Close)
if err := pingDependency(ctx, "redis user store", store); err != nil {
return cleanupOnError(fmt.Errorf("new user-service runtime: %w", err))
}
domainEventPublisher, err := domainevents.New(domainevents.Config{
Addr: cfg.Redis.Addr,
Username: cfg.Redis.Username,
Password: cfg.Redis.Password,
DB: cfg.Redis.DB,
TLSEnabled: cfg.Redis.TLSEnabled,
Stream: cfg.Redis.DomainEventsStream,
StreamMaxLen: cfg.Redis.DomainEventsStreamMaxLen,
OperationTimeout: cfg.Redis.OperationTimeout,
})
if err != nil {
return cleanupOnError(fmt.Errorf("new user-service runtime: redis domain-event publisher: %w", err))
}
runtime.cleanupFns = append(runtime.cleanupFns, domainEventPublisher.Close)
if err := pingDependency(ctx, "redis domain-event publisher", domainEventPublisher); err != nil {
return cleanupOnError(fmt.Errorf("new user-service runtime: %w", err))
}
clock := local.Clock{}
idGenerator := local.IDGenerator{}
raceNamePolicy, err := local.NewRaceNamePolicy()
if err != nil {
return cleanupOnError(fmt.Errorf("new user-service runtime: race-name policy: %w", err))
}
componentLogger := func(component string) *slog.Logger {
return logger.With("component", component)
}
resolver, err := authdirectory.NewResolverWithObservability(store, componentLogger("authdirectory"), telemetryRuntime)
if err != nil {
return cleanupOnError(fmt.Errorf("new user-service runtime: resolver: %w", err))
}
ensurer, err := authdirectory.NewEnsurerWithObservability(
store,
clock,
idGenerator,
raceNamePolicy,
componentLogger("authdirectory"),
telemetryRuntime,
domainEventPublisher,
domainEventPublisher,
domainEventPublisher,
)
if err != nil {
return cleanupOnError(fmt.Errorf("new user-service runtime: ensurer: %w", err))
}
existenceChecker, err := authdirectory.NewExistenceChecker(store)
if err != nil {
return cleanupOnError(fmt.Errorf("new user-service runtime: existence checker: %w", err))
}
blockByUserID, err := authdirectory.NewBlockByUserIDService(store, clock)
if err != nil {
return cleanupOnError(fmt.Errorf("new user-service runtime: block-by-user-id service: %w", err))
}
blockByEmail, err := authdirectory.NewBlockByEmailService(store, clock)
if err != nil {
return cleanupOnError(fmt.Errorf("new user-service runtime: block-by-email service: %w", err))
}
entitlementReader, err := entitlementsvc.NewReaderWithObservability(
store.EntitlementSnapshots(),
store.EntitlementLifecycle(),
clock,
idGenerator,
componentLogger("entitlementsvc"),
telemetryRuntime,
domainEventPublisher,
)
if err != nil {
return cleanupOnError(fmt.Errorf("new user-service runtime: entitlement reader: %w", err))
}
grantEntitlement, err := entitlementsvc.NewGrantServiceWithObservability(
store.Accounts(),
store.EntitlementHistory(),
entitlementReader,
store.EntitlementLifecycle(),
clock,
idGenerator,
componentLogger("entitlementsvc"),
telemetryRuntime,
domainEventPublisher,
)
if err != nil {
return cleanupOnError(fmt.Errorf("new user-service runtime: grant entitlement service: %w", err))
}
extendEntitlement, err := entitlementsvc.NewExtendServiceWithObservability(
store.Accounts(),
store.EntitlementHistory(),
entitlementReader,
store.EntitlementLifecycle(),
clock,
idGenerator,
componentLogger("entitlementsvc"),
telemetryRuntime,
domainEventPublisher,
)
if err != nil {
return cleanupOnError(fmt.Errorf("new user-service runtime: extend entitlement service: %w", err))
}
revokeEntitlement, err := entitlementsvc.NewRevokeServiceWithObservability(
store.Accounts(),
store.EntitlementHistory(),
entitlementReader,
store.EntitlementLifecycle(),
clock,
idGenerator,
componentLogger("entitlementsvc"),
telemetryRuntime,
domainEventPublisher,
)
if err != nil {
return cleanupOnError(fmt.Errorf("new user-service runtime: revoke entitlement service: %w", err))
}
accountGetter, err := selfservice.NewAccountGetter(store.Accounts(), entitlementReader, store.Sanctions(), store.Limits(), clock)
if err != nil {
return cleanupOnError(fmt.Errorf("new user-service runtime: account getter: %w", err))
}
profileUpdater, err := selfservice.NewProfileUpdaterWithObservability(
store.Accounts(),
entitlementReader,
store.Sanctions(),
store.Limits(),
clock,
raceNamePolicy,
componentLogger("selfservice"),
telemetryRuntime,
domainEventPublisher,
)
if err != nil {
return cleanupOnError(fmt.Errorf("new user-service runtime: profile updater: %w", err))
}
settingsUpdater, err := selfservice.NewSettingsUpdaterWithObservability(
store.Accounts(),
entitlementReader,
store.Sanctions(),
store.Limits(),
clock,
componentLogger("selfservice"),
telemetryRuntime,
domainEventPublisher,
)
if err != nil {
return cleanupOnError(fmt.Errorf("new user-service runtime: settings updater: %w", err))
}
getUserByID, err := adminusers.NewByIDGetter(store.Accounts(), entitlementReader, store.Sanctions(), store.Limits(), clock)
if err != nil {
return cleanupOnError(fmt.Errorf("new user-service runtime: admin get-user-by-id: %w", err))
}
getUserByEmail, err := adminusers.NewByEmailGetter(store.Accounts(), entitlementReader, store.Sanctions(), store.Limits(), clock)
if err != nil {
return cleanupOnError(fmt.Errorf("new user-service runtime: admin get-user-by-email: %w", err))
}
getUserByRaceName, err := adminusers.NewByRaceNameGetter(store.Accounts(), entitlementReader, store.Sanctions(), store.Limits(), clock)
if err != nil {
return cleanupOnError(fmt.Errorf("new user-service runtime: admin get-user-by-race-name: %w", err))
}
listUsers, err := adminusers.NewLister(store.Accounts(), entitlementReader, store.Sanctions(), store.Limits(), clock, store)
if err != nil {
return cleanupOnError(fmt.Errorf("new user-service runtime: admin list-users: %w", err))
}
userEligibility, err := lobbyeligibility.NewSnapshotReader(store.Accounts(), entitlementReader, store.Sanctions(), store.Limits(), clock)
if err != nil {
return cleanupOnError(fmt.Errorf("new user-service runtime: lobby eligibility snapshot reader: %w", err))
}
syncDeclaredCountry, err := geosync.NewSyncServiceWithObservability(
store.Accounts(),
clock,
domainEventPublisher,
componentLogger("geosync"),
telemetryRuntime,
)
if err != nil {
return cleanupOnError(fmt.Errorf("new user-service runtime: geo declared-country sync service: %w", err))
}
applySanction, err := policysvc.NewApplySanctionServiceWithObservability(
store.Accounts(),
store.Sanctions(),
store.Limits(),
store.PolicyLifecycle(),
clock,
idGenerator,
componentLogger("policysvc"),
telemetryRuntime,
domainEventPublisher,
)
if err != nil {
return cleanupOnError(fmt.Errorf("new user-service runtime: apply sanction service: %w", err))
}
removeSanction, err := policysvc.NewRemoveSanctionServiceWithObservability(
store.Accounts(),
store.Sanctions(),
store.Limits(),
store.PolicyLifecycle(),
clock,
idGenerator,
componentLogger("policysvc"),
telemetryRuntime,
domainEventPublisher,
)
if err != nil {
return cleanupOnError(fmt.Errorf("new user-service runtime: remove sanction service: %w", err))
}
setLimit, err := policysvc.NewSetLimitServiceWithObservability(
store.Accounts(),
store.Sanctions(),
store.Limits(),
store.PolicyLifecycle(),
clock,
idGenerator,
componentLogger("policysvc"),
telemetryRuntime,
domainEventPublisher,
)
if err != nil {
return cleanupOnError(fmt.Errorf("new user-service runtime: set limit service: %w", err))
}
removeLimit, err := policysvc.NewRemoveLimitServiceWithObservability(
store.Accounts(),
store.Sanctions(),
store.Limits(),
store.PolicyLifecycle(),
clock,
idGenerator,
componentLogger("policysvc"),
telemetryRuntime,
domainEventPublisher,
)
if err != nil {
return cleanupOnError(fmt.Errorf("new user-service runtime: remove limit service: %w", err))
}
server, err := internalhttp.NewServer(internalhttp.Config{
Addr: cfg.InternalHTTP.Addr,
ReadHeaderTimeout: cfg.InternalHTTP.ReadHeaderTimeout,
ReadTimeout: cfg.InternalHTTP.ReadTimeout,
IdleTimeout: cfg.InternalHTTP.IdleTimeout,
RequestTimeout: cfg.InternalHTTP.RequestTimeout,
}, internalhttp.Dependencies{
ResolveByEmail: resolver,
EnsureByEmail: ensurer,
ExistsByUserID: existenceChecker,
BlockByUserID: blockByUserID,
BlockByEmail: blockByEmail,
GetMyAccount: accountGetter,
UpdateMyProfile: profileUpdater,
UpdateMySettings: settingsUpdater,
GetUserByID: getUserByID,
GetUserByEmail: getUserByEmail,
GetUserByRaceName: getUserByRaceName,
ListUsers: listUsers,
GetUserEligibility: userEligibility,
SyncDeclaredCountry: syncDeclaredCountry,
GrantEntitlement: grantEntitlement,
ExtendEntitlement: extendEntitlement,
RevokeEntitlement: revokeEntitlement,
ApplySanction: applySanction,
RemoveSanction: removeSanction,
SetLimit: setLimit,
RemoveLimit: removeLimit,
Logger: logger.With("component", "internal_http"),
Telemetry: telemetryRuntime,
})
if err != nil {
return cleanupOnError(fmt.Errorf("new user-service runtime: internal HTTP server: %w", err))
}
adminServer := adminapi.NewServer(cfg.AdminHTTP, telemetryRuntime.Handler(), logger)
runtime.Server = server
runtime.AdminServer = adminServer
return runtime, nil
}
// Run serves the internal and admin HTTP listeners until ctx is canceled or a
// listener fails.
func (runtime *Runtime) Run(ctx context.Context) error {
if ctx == nil {
return errors.New("run user-service runtime: nil context")
}
if runtime == nil {
return errors.New("run user-service runtime: nil runtime")
}
if runtime.Server == nil {
return errors.New("run user-service runtime: nil internal HTTP server")
}
if runtime.AdminServer == nil {
return errors.New("run user-service runtime: nil admin HTTP server")
}
runCtx, cancel := context.WithCancel(ctx)
defer cancel()
var (
wg sync.WaitGroup
shutdownMu sync.Mutex
shutdownDone bool
shutdownErr error
)
shutdownServers := func() {
shutdownMu.Lock()
defer shutdownMu.Unlock()
if shutdownDone {
return
}
shutdownDone = true
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), runtime.cfg.ShutdownTimeout)
defer shutdownCancel()
shutdownErr = errors.Join(
runtime.Server.Shutdown(shutdownCtx),
runtime.AdminServer.Shutdown(shutdownCtx),
)
}
errCh := make(chan error, 2)
runServer := func(name string, serve func(context.Context) error) {
wg.Add(1)
go func() {
defer wg.Done()
if err := serve(runCtx); err != nil {
select {
case errCh <- fmt.Errorf("%s: %w", name, err):
default:
}
cancel()
}
}()
}
runServer("internal HTTP server", runtime.Server.Run)
runServer("admin HTTP server", runtime.AdminServer.Run)
done := make(chan struct{})
go func() {
defer close(done)
<-runCtx.Done()
shutdownServers()
wg.Wait()
}()
var runErr error
select {
case runErr = <-errCh:
cancel()
case <-ctx.Done():
cancel()
case <-done:
}
<-done
return errors.Join(runErr, shutdownErr)
}
// Close releases every runtime dependency in reverse construction order.
func (runtime *Runtime) Close() error {
if runtime == nil {
return nil
}
var messages []string
for index := len(runtime.cleanupFns) - 1; index >= 0; index-- {
if err := runtime.cleanupFns[index](); err != nil {
messages = append(messages, err.Error())
}
}
if len(messages) == 0 {
return nil
}
return errors.New(strings.Join(messages, "; "))
}
func pingDependency(ctx context.Context, name string, dependency pinger) error {
if err := dependency.Ping(ctx); err != nil {
return fmt.Errorf("ping %s: %w", name, err)
}
return nil
}
var _ closer = (*userstore.Store)(nil)
+551
View File
@@ -0,0 +1,551 @@
// Package config loads the user-service process configuration from environment
// variables.
package config
import (
"crypto/tls"
"fmt"
"net"
"os"
"strconv"
"strings"
"time"
)
const (
shutdownTimeoutEnvVar = "USERSERVICE_SHUTDOWN_TIMEOUT"
logLevelEnvVar = "USERSERVICE_LOG_LEVEL"
internalHTTPAddrEnvVar = "USERSERVICE_INTERNAL_HTTP_ADDR"
internalHTTPReadHeaderTimeoutEnvVar = "USERSERVICE_INTERNAL_HTTP_READ_HEADER_TIMEOUT"
internalHTTPReadTimeoutEnvVar = "USERSERVICE_INTERNAL_HTTP_READ_TIMEOUT"
internalHTTPIdleTimeoutEnvVar = "USERSERVICE_INTERNAL_HTTP_IDLE_TIMEOUT"
internalHTTPRequestTimeoutEnvVar = "USERSERVICE_INTERNAL_HTTP_REQUEST_TIMEOUT"
adminHTTPAddrEnvVar = "USERSERVICE_ADMIN_HTTP_ADDR"
adminHTTPReadHeaderTimeoutEnvVar = "USERSERVICE_ADMIN_HTTP_READ_HEADER_TIMEOUT"
adminHTTPReadTimeoutEnvVar = "USERSERVICE_ADMIN_HTTP_READ_TIMEOUT"
adminHTTPIdleTimeoutEnvVar = "USERSERVICE_ADMIN_HTTP_IDLE_TIMEOUT"
redisAddrEnvVar = "USERSERVICE_REDIS_ADDR"
redisUsernameEnvVar = "USERSERVICE_REDIS_USERNAME"
redisPasswordEnvVar = "USERSERVICE_REDIS_PASSWORD"
redisDBEnvVar = "USERSERVICE_REDIS_DB"
redisTLSEnabledEnvVar = "USERSERVICE_REDIS_TLS_ENABLED"
redisOperationTimeoutEnvVar = "USERSERVICE_REDIS_OPERATION_TIMEOUT"
redisKeyspacePrefixEnvVar = "USERSERVICE_REDIS_KEYSPACE_PREFIX"
redisDomainEventsStreamEnvVar = "USERSERVICE_REDIS_DOMAIN_EVENTS_STREAM"
redisDomainEventsStreamMaxLenEnvVar = "USERSERVICE_REDIS_DOMAIN_EVENTS_STREAM_MAX_LEN"
otelServiceNameEnvVar = "OTEL_SERVICE_NAME"
otelTracesExporterEnvVar = "OTEL_TRACES_EXPORTER"
otelMetricsExporterEnvVar = "OTEL_METRICS_EXPORTER"
otelExporterOTLPProtocolEnvVar = "OTEL_EXPORTER_OTLP_PROTOCOL"
otelExporterOTLPTracesProtocolEnvVar = "OTEL_EXPORTER_OTLP_TRACES_PROTOCOL"
otelExporterOTLPMetricsProtocolEnvVar = "OTEL_EXPORTER_OTLP_METRICS_PROTOCOL"
otelStdoutTracesEnabledEnvVar = "USERSERVICE_OTEL_STDOUT_TRACES_ENABLED"
otelStdoutMetricsEnabledEnvVar = "USERSERVICE_OTEL_STDOUT_METRICS_ENABLED"
defaultShutdownTimeout = 5 * time.Second
defaultLogLevel = "info"
defaultInternalHTTPAddr = ":8091"
defaultAdminHTTPAddr = ""
defaultReadHeaderTimeout = 2 * time.Second
defaultReadTimeout = 10 * time.Second
defaultIdleTimeout = time.Minute
defaultRequestTimeout = 3 * time.Second
defaultRedisDB = 0
defaultRedisOperationTimeout = 250 * time.Millisecond
defaultRedisKeyspacePrefix = "user:"
defaultDomainEventsStream = "user:domain_events"
defaultDomainEventsStreamMaxLen = 1024
defaultOTelServiceName = "galaxy-user"
otelExporterNone = "none"
otelExporterOTLP = "otlp"
otelProtocolHTTPProtobuf = "http/protobuf"
otelProtocolGRPC = "grpc"
)
// Config stores the full user-service process configuration.
type Config struct {
// ShutdownTimeout bounds graceful shutdown of the long-lived listeners and
// runtime resources.
ShutdownTimeout time.Duration
// Logging configures the process-wide logger.
Logging LoggingConfig
// InternalHTTP configures the trusted internal HTTP listener.
InternalHTTP InternalHTTPConfig
// AdminHTTP configures the optional private admin HTTP listener.
AdminHTTP AdminHTTPConfig
// Redis configures the Redis-backed user store and domain-event publisher.
Redis RedisConfig
// Telemetry configures the process-wide OpenTelemetry runtime.
Telemetry TelemetryConfig
}
// LoggingConfig configures the process-wide logger.
type LoggingConfig struct {
// Level stores the process log level.
Level string
}
// InternalHTTPConfig configures the internal HTTP listener.
type InternalHTTPConfig struct {
// Addr stores the TCP listen address.
Addr string
// ReadHeaderTimeout bounds request-header reading.
ReadHeaderTimeout time.Duration
// ReadTimeout bounds reading one request.
ReadTimeout time.Duration
// IdleTimeout bounds how long keep-alive connections stay open.
IdleTimeout time.Duration
// RequestTimeout bounds one application-layer request execution.
RequestTimeout time.Duration
}
// Validate reports whether cfg stores a usable internal HTTP listener
// configuration.
func (cfg InternalHTTPConfig) Validate() error {
switch {
case strings.TrimSpace(cfg.Addr) == "":
return fmt.Errorf("internal HTTP addr must not be empty")
case cfg.ReadHeaderTimeout <= 0:
return fmt.Errorf("internal HTTP read header timeout must be positive")
case cfg.ReadTimeout <= 0:
return fmt.Errorf("internal HTTP read timeout must be positive")
case cfg.IdleTimeout <= 0:
return fmt.Errorf("internal HTTP idle timeout must be positive")
case cfg.RequestTimeout <= 0:
return fmt.Errorf("internal HTTP request timeout must be positive")
default:
return nil
}
}
// AdminHTTPConfig describes the private operational HTTP listener used for
// Prometheus metrics exposure. The listener remains disabled when Addr is
// empty.
type AdminHTTPConfig struct {
// Addr stores the TCP listen address used by the admin HTTP server.
Addr string
// ReadHeaderTimeout bounds request-header reading.
ReadHeaderTimeout time.Duration
// ReadTimeout bounds reading one request.
ReadTimeout time.Duration
// IdleTimeout bounds how long keep-alive connections stay open.
IdleTimeout time.Duration
}
// Validate reports whether cfg stores a usable optional admin HTTP listener
// configuration.
func (cfg AdminHTTPConfig) Validate() error {
if strings.TrimSpace(cfg.Addr) == "" {
return nil
}
switch {
case cfg.ReadHeaderTimeout <= 0:
return fmt.Errorf("admin HTTP read header timeout must be positive")
case cfg.ReadTimeout <= 0:
return fmt.Errorf("admin HTTP read timeout must be positive")
case cfg.IdleTimeout <= 0:
return fmt.Errorf("admin HTTP idle timeout must be positive")
default:
return nil
}
}
// RedisConfig configures the Redis-backed store and domain-event publisher.
type RedisConfig struct {
// Addr stores the Redis network address.
Addr string
// Username stores the optional Redis ACL username.
Username string
// Password stores the optional Redis ACL password.
Password string
// DB stores the Redis logical database index.
DB int
// TLSEnabled reports whether TLS must be used for Redis connections.
TLSEnabled bool
// OperationTimeout bounds one Redis round trip.
OperationTimeout time.Duration
// KeyspacePrefix stores the root prefix of the service-owned Redis keyspace.
KeyspacePrefix string
// DomainEventsStream stores the Redis Stream key used for auxiliary
// post-commit domain events.
DomainEventsStream string
// DomainEventsStreamMaxLen bounds the domain-events Redis Stream with
// approximate trimming.
DomainEventsStreamMaxLen int64
}
// TLSConfig returns the conservative TLS configuration used by Redis adapters
// when TLSEnabled is true.
func (cfg RedisConfig) TLSConfig() *tls.Config {
if !cfg.TLSEnabled {
return nil
}
return &tls.Config{MinVersion: tls.VersionTLS12}
}
// Validate reports whether cfg stores a usable Redis configuration.
func (cfg RedisConfig) Validate() error {
switch {
case strings.TrimSpace(cfg.Addr) == "":
return fmt.Errorf("redis addr must not be empty")
case cfg.DB < 0:
return fmt.Errorf("redis db must not be negative")
case cfg.OperationTimeout <= 0:
return fmt.Errorf("redis operation timeout must be positive")
case strings.TrimSpace(cfg.KeyspacePrefix) == "":
return fmt.Errorf("redis keyspace prefix must not be empty")
case strings.TrimSpace(cfg.DomainEventsStream) == "":
return fmt.Errorf("redis domain events stream must not be empty")
case cfg.DomainEventsStreamMaxLen <= 0:
return fmt.Errorf("redis domain events stream max len must be positive")
default:
return nil
}
}
// TelemetryConfig configures the user-service OpenTelemetry runtime.
type TelemetryConfig struct {
// ServiceName overrides the default OpenTelemetry service name.
ServiceName string
// TracesExporter selects the external traces exporter. Supported values are
// `none` and `otlp`.
TracesExporter string
// MetricsExporter selects the external metrics exporter. Supported values
// are `none` and `otlp`.
MetricsExporter string
// TracesProtocol selects the OTLP traces protocol when TracesExporter is
// `otlp`.
TracesProtocol string
// MetricsProtocol selects the OTLP metrics protocol when MetricsExporter is
// `otlp`.
MetricsProtocol string
// StdoutTracesEnabled enables the additional stdout trace exporter used for
// local development and debugging.
StdoutTracesEnabled bool
// StdoutMetricsEnabled enables the additional stdout metric exporter used
// for local development and debugging.
StdoutMetricsEnabled bool
}
// Validate reports whether cfg contains a supported OpenTelemetry exporter
// configuration.
func (cfg TelemetryConfig) Validate() error {
switch cfg.TracesExporter {
case otelExporterNone, otelExporterOTLP:
default:
return fmt.Errorf("%s %q is unsupported", otelTracesExporterEnvVar, cfg.TracesExporter)
}
switch cfg.MetricsExporter {
case otelExporterNone, otelExporterOTLP:
default:
return fmt.Errorf("%s %q is unsupported", otelMetricsExporterEnvVar, cfg.MetricsExporter)
}
if cfg.TracesProtocol != "" && cfg.TracesProtocol != otelProtocolHTTPProtobuf && cfg.TracesProtocol != otelProtocolGRPC {
return fmt.Errorf("%s %q is unsupported", otelExporterOTLPTracesProtocolEnvVar, cfg.TracesProtocol)
}
if cfg.MetricsProtocol != "" && cfg.MetricsProtocol != otelProtocolHTTPProtobuf && cfg.MetricsProtocol != otelProtocolGRPC {
return fmt.Errorf("%s %q is unsupported", otelExporterOTLPMetricsProtocolEnvVar, cfg.MetricsProtocol)
}
return nil
}
// DefaultAdminHTTPConfig returns the default settings for the optional private
// admin HTTP listener.
func DefaultAdminHTTPConfig() AdminHTTPConfig {
return AdminHTTPConfig{
Addr: defaultAdminHTTPAddr,
ReadHeaderTimeout: defaultReadHeaderTimeout,
ReadTimeout: defaultReadTimeout,
IdleTimeout: defaultIdleTimeout,
}
}
// DefaultConfig returns the default process configuration with all optional
// values filled.
func DefaultConfig() Config {
return Config{
ShutdownTimeout: defaultShutdownTimeout,
Logging: LoggingConfig{
Level: defaultLogLevel,
},
InternalHTTP: InternalHTTPConfig{
Addr: defaultInternalHTTPAddr,
ReadHeaderTimeout: defaultReadHeaderTimeout,
ReadTimeout: defaultReadTimeout,
IdleTimeout: defaultIdleTimeout,
RequestTimeout: defaultRequestTimeout,
},
AdminHTTP: DefaultAdminHTTPConfig(),
Redis: RedisConfig{
DB: defaultRedisDB,
OperationTimeout: defaultRedisOperationTimeout,
KeyspacePrefix: defaultRedisKeyspacePrefix,
DomainEventsStream: defaultDomainEventsStream,
DomainEventsStreamMaxLen: defaultDomainEventsStreamMaxLen,
},
Telemetry: TelemetryConfig{
ServiceName: defaultOTelServiceName,
TracesExporter: otelExporterNone,
MetricsExporter: otelExporterNone,
},
}
}
// Validate reports whether cfg is process-ready.
func (cfg Config) Validate() error {
switch {
case cfg.ShutdownTimeout <= 0:
return fmt.Errorf("shutdown timeout must be positive")
}
if err := cfg.InternalHTTP.Validate(); err != nil {
return fmt.Errorf("internal HTTP config: %w", err)
}
if err := cfg.AdminHTTP.Validate(); err != nil {
return fmt.Errorf("admin HTTP config: %w", err)
}
if err := cfg.Redis.Validate(); err != nil {
return fmt.Errorf("redis config: %w", err)
}
if _, err := parseLogLevel(cfg.Logging.Level); err != nil {
return fmt.Errorf("logging config: %w", err)
}
if err := cfg.Telemetry.Validate(); err != nil {
return fmt.Errorf("telemetry config: %w", err)
}
return nil
}
// LoadFromEnv loads Config from the process environment.
func LoadFromEnv() (Config, error) {
cfg := DefaultConfig()
var err error
cfg.ShutdownTimeout, err = loadDuration(shutdownTimeoutEnvVar, cfg.ShutdownTimeout)
if err != nil {
return Config{}, err
}
cfg.Logging.Level = loadString(logLevelEnvVar, cfg.Logging.Level)
cfg.InternalHTTP.Addr = loadString(internalHTTPAddrEnvVar, cfg.InternalHTTP.Addr)
cfg.InternalHTTP.ReadHeaderTimeout, err = loadDuration(internalHTTPReadHeaderTimeoutEnvVar, cfg.InternalHTTP.ReadHeaderTimeout)
if err != nil {
return Config{}, err
}
cfg.InternalHTTP.ReadTimeout, err = loadDuration(internalHTTPReadTimeoutEnvVar, cfg.InternalHTTP.ReadTimeout)
if err != nil {
return Config{}, err
}
cfg.InternalHTTP.IdleTimeout, err = loadDuration(internalHTTPIdleTimeoutEnvVar, cfg.InternalHTTP.IdleTimeout)
if err != nil {
return Config{}, err
}
cfg.InternalHTTP.RequestTimeout, err = loadDuration(internalHTTPRequestTimeoutEnvVar, cfg.InternalHTTP.RequestTimeout)
if err != nil {
return Config{}, err
}
cfg.AdminHTTP.Addr = loadString(adminHTTPAddrEnvVar, cfg.AdminHTTP.Addr)
cfg.AdminHTTP.ReadHeaderTimeout, err = loadDuration(adminHTTPReadHeaderTimeoutEnvVar, cfg.AdminHTTP.ReadHeaderTimeout)
if err != nil {
return Config{}, err
}
cfg.AdminHTTP.ReadTimeout, err = loadDuration(adminHTTPReadTimeoutEnvVar, cfg.AdminHTTP.ReadTimeout)
if err != nil {
return Config{}, err
}
cfg.AdminHTTP.IdleTimeout, err = loadDuration(adminHTTPIdleTimeoutEnvVar, cfg.AdminHTTP.IdleTimeout)
if err != nil {
return Config{}, err
}
cfg.Redis.Addr = loadString(redisAddrEnvVar, cfg.Redis.Addr)
cfg.Redis.Username = loadString(redisUsernameEnvVar, cfg.Redis.Username)
cfg.Redis.Password = loadString(redisPasswordEnvVar, cfg.Redis.Password)
cfg.Redis.DB, err = loadInt(redisDBEnvVar, cfg.Redis.DB)
if err != nil {
return Config{}, err
}
cfg.Redis.TLSEnabled, err = loadBool(redisTLSEnabledEnvVar, cfg.Redis.TLSEnabled)
if err != nil {
return Config{}, err
}
cfg.Redis.OperationTimeout, err = loadDuration(redisOperationTimeoutEnvVar, cfg.Redis.OperationTimeout)
if err != nil {
return Config{}, err
}
cfg.Redis.KeyspacePrefix = loadString(redisKeyspacePrefixEnvVar, cfg.Redis.KeyspacePrefix)
cfg.Redis.DomainEventsStream = loadString(redisDomainEventsStreamEnvVar, cfg.Redis.DomainEventsStream)
cfg.Redis.DomainEventsStreamMaxLen, err = loadInt64(redisDomainEventsStreamMaxLenEnvVar, cfg.Redis.DomainEventsStreamMaxLen)
if err != nil {
return Config{}, err
}
cfg.Telemetry.ServiceName = loadString(otelServiceNameEnvVar, cfg.Telemetry.ServiceName)
cfg.Telemetry.TracesExporter = normalizeExporterValue(loadString(otelTracesExporterEnvVar, cfg.Telemetry.TracesExporter))
cfg.Telemetry.MetricsExporter = normalizeExporterValue(loadString(otelMetricsExporterEnvVar, cfg.Telemetry.MetricsExporter))
cfg.Telemetry.TracesProtocol = loadOTLPProtocol(
os.Getenv(otelExporterOTLPTracesProtocolEnvVar),
os.Getenv(otelExporterOTLPProtocolEnvVar),
cfg.Telemetry.TracesExporter,
)
cfg.Telemetry.MetricsProtocol = loadOTLPProtocol(
os.Getenv(otelExporterOTLPMetricsProtocolEnvVar),
os.Getenv(otelExporterOTLPProtocolEnvVar),
cfg.Telemetry.MetricsExporter,
)
cfg.Telemetry.StdoutTracesEnabled, err = loadBool(otelStdoutTracesEnabledEnvVar, cfg.Telemetry.StdoutTracesEnabled)
if err != nil {
return Config{}, err
}
cfg.Telemetry.StdoutMetricsEnabled, err = loadBool(otelStdoutMetricsEnabledEnvVar, cfg.Telemetry.StdoutMetricsEnabled)
if err != nil {
return Config{}, err
}
if err := cfg.Validate(); err != nil {
return Config{}, err
}
return cfg, nil
}
func loadString(envName string, defaultValue string) string {
value, ok := os.LookupEnv(envName)
if !ok {
return defaultValue
}
return strings.TrimSpace(value)
}
func loadDuration(envName string, defaultValue time.Duration) (time.Duration, error) {
value, ok := os.LookupEnv(envName)
if !ok {
return defaultValue, nil
}
duration, err := time.ParseDuration(strings.TrimSpace(value))
if err != nil {
return 0, fmt.Errorf("%s: parse duration: %w", envName, err)
}
return duration, nil
}
func loadInt(envName string, defaultValue int) (int, error) {
value, ok := os.LookupEnv(envName)
if !ok {
return defaultValue, nil
}
parsedValue, err := strconv.Atoi(strings.TrimSpace(value))
if err != nil {
return 0, fmt.Errorf("%s: parse int: %w", envName, err)
}
return parsedValue, nil
}
func loadInt64(envName string, defaultValue int64) (int64, error) {
value, ok := os.LookupEnv(envName)
if !ok {
return defaultValue, nil
}
parsedValue, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64)
if err != nil {
return 0, fmt.Errorf("%s: parse int64: %w", envName, err)
}
return parsedValue, nil
}
func loadBool(envName string, defaultValue bool) (bool, error) {
value, ok := os.LookupEnv(envName)
if !ok {
return defaultValue, nil
}
parsedValue, err := strconv.ParseBool(strings.TrimSpace(value))
if err != nil {
return false, fmt.Errorf("%s: parse bool: %w", envName, err)
}
return parsedValue, nil
}
func parseLogLevel(value string) (string, error) {
switch strings.ToLower(strings.TrimSpace(value)) {
case "debug", "info", "warn", "error":
return value, nil
default:
return "", fmt.Errorf("unsupported log level %q", value)
}
}
func normalizeExporterValue(value string) string {
switch strings.TrimSpace(value) {
case "", otelExporterNone:
return otelExporterNone
default:
return strings.TrimSpace(value)
}
}
func loadOTLPProtocol(primary string, fallback string, exporter string) string {
protocol := strings.TrimSpace(primary)
if protocol == "" {
protocol = strings.TrimSpace(fallback)
}
if protocol == "" && exporter == otelExporterOTLP {
return otelProtocolHTTPProtobuf
}
return protocol
}
// ListenAddress returns the resolved listen address used by tests and process
// startup.
func (cfg InternalHTTPConfig) ListenAddress() string {
if strings.HasPrefix(cfg.Addr, ":") {
return net.JoinHostPort("", strings.TrimPrefix(cfg.Addr, ":"))
}
return cfg.Addr
}
+106
View File
@@ -0,0 +1,106 @@
package config
import (
"testing"
"time"
"github.com/stretchr/testify/require"
)
func TestLoadFromEnvUsesDefaults(t *testing.T) {
t.Setenv(redisAddrEnvVar, "127.0.0.1:6379")
cfg, err := LoadFromEnv()
require.NoError(t, err)
defaults := DefaultConfig()
require.Equal(t, defaults.ShutdownTimeout, cfg.ShutdownTimeout)
require.Equal(t, defaults.Logging.Level, cfg.Logging.Level)
require.Equal(t, defaults.InternalHTTP, cfg.InternalHTTP)
require.Equal(t, defaults.AdminHTTP, cfg.AdminHTTP)
require.Equal(t, "127.0.0.1:6379", cfg.Redis.Addr)
require.Equal(t, defaults.Redis.DB, cfg.Redis.DB)
require.Equal(t, defaults.Redis.DomainEventsStream, cfg.Redis.DomainEventsStream)
require.Equal(t, defaults.Redis.DomainEventsStreamMaxLen, cfg.Redis.DomainEventsStreamMaxLen)
require.Equal(t, defaults.Telemetry, cfg.Telemetry)
}
func TestLoadFromEnvAppliesOverrides(t *testing.T) {
t.Setenv(shutdownTimeoutEnvVar, "9s")
t.Setenv(logLevelEnvVar, "debug")
t.Setenv(internalHTTPAddrEnvVar, "127.0.0.1:18091")
t.Setenv(internalHTTPReadHeaderTimeoutEnvVar, "3s")
t.Setenv(internalHTTPRequestTimeoutEnvVar, "750ms")
t.Setenv(adminHTTPAddrEnvVar, "127.0.0.1:19091")
t.Setenv(adminHTTPIdleTimeoutEnvVar, "90s")
t.Setenv(redisAddrEnvVar, "127.0.0.1:6380")
t.Setenv(redisUsernameEnvVar, "alice")
t.Setenv(redisPasswordEnvVar, "secret")
t.Setenv(redisDBEnvVar, "3")
t.Setenv(redisTLSEnabledEnvVar, "true")
t.Setenv(redisOperationTimeoutEnvVar, "900ms")
t.Setenv(redisKeyspacePrefixEnvVar, "user:custom:")
t.Setenv(redisDomainEventsStreamEnvVar, "user:test_events")
t.Setenv(redisDomainEventsStreamMaxLenEnvVar, "2048")
t.Setenv(otelServiceNameEnvVar, "galaxy-user-stage12")
t.Setenv(otelTracesExporterEnvVar, "otlp")
t.Setenv(otelMetricsExporterEnvVar, "otlp")
t.Setenv(otelExporterOTLPTracesProtocolEnvVar, "grpc")
t.Setenv(otelExporterOTLPMetricsProtocolEnvVar, "http/protobuf")
t.Setenv(otelStdoutTracesEnabledEnvVar, "true")
t.Setenv(otelStdoutMetricsEnabledEnvVar, "true")
cfg, err := LoadFromEnv()
require.NoError(t, err)
require.Equal(t, 9*time.Second, cfg.ShutdownTimeout)
require.Equal(t, "debug", cfg.Logging.Level)
require.Equal(t, "127.0.0.1:18091", cfg.InternalHTTP.Addr)
require.Equal(t, 3*time.Second, cfg.InternalHTTP.ReadHeaderTimeout)
require.Equal(t, 750*time.Millisecond, cfg.InternalHTTP.RequestTimeout)
require.Equal(t, "127.0.0.1:19091", cfg.AdminHTTP.Addr)
require.Equal(t, 90*time.Second, cfg.AdminHTTP.IdleTimeout)
require.Equal(t, "127.0.0.1:6380", cfg.Redis.Addr)
require.Equal(t, "alice", cfg.Redis.Username)
require.Equal(t, "secret", cfg.Redis.Password)
require.Equal(t, 3, cfg.Redis.DB)
require.True(t, cfg.Redis.TLSEnabled)
require.Equal(t, 900*time.Millisecond, cfg.Redis.OperationTimeout)
require.Equal(t, "user:custom:", cfg.Redis.KeyspacePrefix)
require.Equal(t, "user:test_events", cfg.Redis.DomainEventsStream)
require.Equal(t, int64(2048), cfg.Redis.DomainEventsStreamMaxLen)
require.Equal(t, "galaxy-user-stage12", cfg.Telemetry.ServiceName)
require.Equal(t, "otlp", cfg.Telemetry.TracesExporter)
require.Equal(t, "otlp", cfg.Telemetry.MetricsExporter)
require.Equal(t, "grpc", cfg.Telemetry.TracesProtocol)
require.Equal(t, "http/protobuf", cfg.Telemetry.MetricsProtocol)
require.True(t, cfg.Telemetry.StdoutTracesEnabled)
require.True(t, cfg.Telemetry.StdoutMetricsEnabled)
}
func TestLoadFromEnvRejectsInvalidValues(t *testing.T) {
tests := []struct {
name string
envName string
envVal string
}{
{name: "invalid duration", envName: shutdownTimeoutEnvVar, envVal: "later"},
{name: "invalid bool", envName: redisTLSEnabledEnvVar, envVal: "sometimes"},
{name: "invalid log level", envName: logLevelEnvVar, envVal: "verbose"},
{name: "invalid int", envName: redisDBEnvVar, envVal: "db-three"},
{name: "invalid stream max len", envName: redisDomainEventsStreamMaxLenEnvVar, envVal: "many"},
{name: "invalid traces exporter", envName: otelTracesExporterEnvVar, envVal: "zipkin"},
{name: "invalid metrics protocol", envName: otelExporterOTLPMetricsProtocolEnvVar, envVal: "udp"},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Setenv(redisAddrEnvVar, "127.0.0.1:6379")
t.Setenv(tt.envName, tt.envVal)
_, err := LoadFromEnv()
require.Error(t, err)
})
}
}
+136
View File
@@ -0,0 +1,136 @@
// Package account defines the logical user-account entities owned directly by
// User Service.
package account
import (
"fmt"
"strings"
"time"
"galaxy/user/internal/domain/common"
)
// RaceNameCanonicalKey stores the policy-produced reservation key used to
// enforce replaceable race-name uniqueness.
type RaceNameCanonicalKey string
// String returns RaceNameCanonicalKey as its stored canonical string.
func (key RaceNameCanonicalKey) String() string {
return string(key)
}
// IsZero reports whether RaceNameCanonicalKey does not contain a usable value.
func (key RaceNameCanonicalKey) IsZero() bool {
return strings.TrimSpace(string(key)) == ""
}
// Validate reports whether RaceNameCanonicalKey is non-empty and trimmed.
func (key RaceNameCanonicalKey) Validate() error {
switch {
case key.IsZero():
return fmt.Errorf("race name canonical key must not be empty")
case strings.TrimSpace(string(key)) != string(key):
return fmt.Errorf("race name canonical key must not contain surrounding whitespace")
default:
return nil
}
}
// UserAccount stores the current editable account state of one regular user.
type UserAccount struct {
// UserID identifies the durable regular-user account.
UserID common.UserID
// Email stores the normalized login/contact address of the account.
Email common.Email
// RaceName stores the original-casing user-facing race name.
RaceName common.RaceName
// PreferredLanguage stores the current declared language tag.
PreferredLanguage common.LanguageTag
// TimeZone stores the current declared time-zone name.
TimeZone common.TimeZoneName
// DeclaredCountry stores the latest effective declared-country value. The
// zero value means the geo workflow has not synchronized any country yet.
DeclaredCountry common.CountryCode
// CreatedAt stores the account creation timestamp.
CreatedAt time.Time
// UpdatedAt stores the last account mutation timestamp.
UpdatedAt time.Time
}
// Validate reports whether UserAccount satisfies the frozen Stage 02
// structural invariants.
func (record UserAccount) Validate() error {
if err := record.UserID.Validate(); err != nil {
return fmt.Errorf("user account user id: %w", err)
}
if err := record.Email.Validate(); err != nil {
return fmt.Errorf("user account email: %w", err)
}
if err := record.RaceName.Validate(); err != nil {
return fmt.Errorf("user account race name: %w", err)
}
if err := record.PreferredLanguage.Validate(); err != nil {
return fmt.Errorf("user account preferred language: %w", err)
}
if err := record.TimeZone.Validate(); err != nil {
return fmt.Errorf("user account time zone: %w", err)
}
if !record.DeclaredCountry.IsZero() {
if err := record.DeclaredCountry.Validate(); err != nil {
return fmt.Errorf("user account declared country: %w", err)
}
}
if err := common.ValidateTimestamp("user account created at", record.CreatedAt); err != nil {
return err
}
if err := common.ValidateTimestamp("user account updated at", record.UpdatedAt); err != nil {
return err
}
if record.UpdatedAt.Before(record.CreatedAt) {
return fmt.Errorf("user account updated at must not be before created at")
}
return nil
}
// RaceNameReservation stores the current uniqueness reservation for one
// canonicalized race-name key.
type RaceNameReservation struct {
// CanonicalKey stores the policy-produced uniqueness key.
CanonicalKey RaceNameCanonicalKey
// UserID identifies the account that owns the reservation.
UserID common.UserID
// RaceName stores the original-casing name linked to the reservation.
RaceName common.RaceName
// ReservedAt stores when the reservation was acquired.
ReservedAt time.Time
}
// Validate reports whether RaceNameReservation satisfies the frozen Stage 02
// structural invariants.
func (record RaceNameReservation) Validate() error {
if err := record.CanonicalKey.Validate(); err != nil {
return fmt.Errorf("race name reservation canonical key: %w", err)
}
if err := record.UserID.Validate(); err != nil {
return fmt.Errorf("race name reservation user id: %w", err)
}
if err := record.RaceName.Validate(); err != nil {
return fmt.Errorf("race name reservation race name: %w", err)
}
if err := common.ValidateTimestamp("race name reservation reserved at", record.ReservedAt); err != nil {
return err
}
return nil
}
+119
View File
@@ -0,0 +1,119 @@
package account
import (
"testing"
"time"
"galaxy/user/internal/domain/common"
"github.com/stretchr/testify/require"
)
func TestUserAccountValidate(t *testing.T) {
t.Parallel()
createdAt := time.Unix(1_775_240_000, 0).UTC()
updatedAt := createdAt.Add(2 * time.Hour)
tests := []struct {
name string
record UserAccount
wantErr bool
}{
{
name: "valid without declared country",
record: UserAccount{
UserID: common.UserID("user-123"),
Email: common.Email("pilot@example.com"),
RaceName: common.RaceName("Pilot Nova"),
PreferredLanguage: common.LanguageTag("en"),
TimeZone: common.TimeZoneName("Europe/Berlin"),
CreatedAt: createdAt,
UpdatedAt: updatedAt,
},
},
{
name: "valid with declared country",
record: UserAccount{
UserID: common.UserID("user-123"),
Email: common.Email("pilot@example.com"),
RaceName: common.RaceName("Pilot Nova"),
PreferredLanguage: common.LanguageTag("en"),
TimeZone: common.TimeZoneName("Europe/Berlin"),
DeclaredCountry: common.CountryCode("DE"),
CreatedAt: createdAt,
UpdatedAt: updatedAt,
},
},
{
name: "updated before created",
record: UserAccount{
UserID: common.UserID("user-123"),
Email: common.Email("pilot@example.com"),
RaceName: common.RaceName("Pilot Nova"),
PreferredLanguage: common.LanguageTag("en"),
TimeZone: common.TimeZoneName("Europe/Berlin"),
CreatedAt: createdAt,
UpdatedAt: createdAt.Add(-time.Second),
},
wantErr: true,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
err := tt.record.Validate()
if tt.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
})
}
}
func TestRaceNameReservationValidate(t *testing.T) {
t.Parallel()
tests := []struct {
name string
record RaceNameReservation
wantErr bool
}{
{
name: "valid",
record: RaceNameReservation{
CanonicalKey: RaceNameCanonicalKey("pilot-nova"),
UserID: common.UserID("user-123"),
RaceName: common.RaceName("Pilot Nova"),
ReservedAt: time.Unix(1_775_240_100, 0).UTC(),
},
},
{
name: "empty canonical key",
record: RaceNameReservation{
UserID: common.UserID("user-123"),
RaceName: common.RaceName("Pilot Nova"),
ReservedAt: time.Unix(1_775_240_100, 0).UTC(),
},
wantErr: true,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
err := tt.record.Validate()
if tt.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
})
}
}
+56
View File
@@ -0,0 +1,56 @@
// Package authblock defines the dedicated pre-user auth-block entity stored by
// User Service.
package authblock
import (
"fmt"
"time"
"galaxy/user/internal/domain/common"
)
// BlockedEmailSubject stores a blocked e-mail subject that may exist before
// any user account exists.
type BlockedEmailSubject struct {
// Email stores the normalized blocked e-mail subject.
Email common.Email
// ReasonCode stores the machine-readable reason for the block.
ReasonCode common.ReasonCode
// BlockedAt stores when the block became effective.
BlockedAt time.Time
// Actor stores optional audit metadata for the block initiator.
Actor common.ActorRef
// ResolvedUserID stores the linked user when the blocked e-mail already
// belongs to an existing account.
ResolvedUserID common.UserID
}
// Validate reports whether BlockedEmailSubject satisfies the frozen Stage 02
// structural invariants.
func (record BlockedEmailSubject) Validate() error {
if err := record.Email.Validate(); err != nil {
return fmt.Errorf("blocked email subject email: %w", err)
}
if err := record.ReasonCode.Validate(); err != nil {
return fmt.Errorf("blocked email subject reason code: %w", err)
}
if err := common.ValidateTimestamp("blocked email subject blocked at", record.BlockedAt); err != nil {
return err
}
if !record.Actor.IsZero() {
if err := record.Actor.Validate(); err != nil {
return fmt.Errorf("blocked email subject actor: %w", err)
}
}
if !record.ResolvedUserID.IsZero() {
if err := record.ResolvedUserID.Validate(); err != nil {
return fmt.Errorf("blocked email subject resolved user id: %w", err)
}
}
return nil
}
@@ -0,0 +1,61 @@
package authblock
import (
"testing"
"time"
"galaxy/user/internal/domain/common"
"github.com/stretchr/testify/require"
)
func TestBlockedEmailSubjectValidate(t *testing.T) {
t.Parallel()
tests := []struct {
name string
record BlockedEmailSubject
wantErr bool
}{
{
name: "valid without actor or user",
record: BlockedEmailSubject{
Email: common.Email("pilot@example.com"),
ReasonCode: common.ReasonCode("policy_blocked"),
BlockedAt: time.Unix(1_775_240_000, 0).UTC(),
},
},
{
name: "valid with actor and user",
record: BlockedEmailSubject{
Email: common.Email("pilot@example.com"),
ReasonCode: common.ReasonCode("policy_blocked"),
BlockedAt: time.Unix(1_775_240_000, 0).UTC(),
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
ResolvedUserID: common.UserID("user-123"),
},
},
{
name: "missing blocked at",
record: BlockedEmailSubject{
Email: common.Email("pilot@example.com"),
ReasonCode: common.ReasonCode("policy_blocked"),
},
wantErr: true,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
err := tt.record.Validate()
if tt.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
})
}
}
+338
View File
@@ -0,0 +1,338 @@
// Package common defines shared value objects used across the user-service
// domain model.
package common
import (
"errors"
"fmt"
"net/mail"
"strings"
"time"
)
const (
maxRaceNameLength = 64
maxLanguageTagLength = 32
maxTimeZoneNameLength = 128
)
// UserID identifies one regular-platform user owned by User Service.
type UserID string
// String returns UserID as its stored identifier string.
func (id UserID) String() string {
return string(id)
}
// IsZero reports whether UserID does not contain a usable identifier.
func (id UserID) IsZero() bool {
return strings.TrimSpace(string(id)) == ""
}
// Validate reports whether UserID is non-empty, normalized, and uses the
// frozen Stage 02 prefix.
func (id UserID) Validate() error {
return validatePrefixedToken("user id", string(id), "user-")
}
// Email stores one normalized user-login e-mail address.
type Email string
// String returns Email as its stored canonical string.
func (email Email) String() string {
return string(email)
}
// IsZero reports whether Email does not contain a usable address.
func (email Email) IsZero() bool {
return strings.TrimSpace(string(email)) == ""
}
// Validate reports whether Email is non-empty, trimmed, and matches the same
// single-address syntax expected by internal REST contracts.
func (email Email) Validate() error {
raw := string(email)
if err := validateToken("email", raw); err != nil {
return err
}
parsedAddress, err := mail.ParseAddress(raw)
if err != nil || parsedAddress.Name != "" || parsedAddress.Address != raw {
return fmt.Errorf("email %q must be a single valid email address", raw)
}
return nil
}
// RaceName stores one original-casing race name selected for the user
// account.
type RaceName string
// String returns RaceName as its stored value.
func (name RaceName) String() string {
return string(name)
}
// IsZero reports whether RaceName does not contain a usable value.
func (name RaceName) IsZero() bool {
return strings.TrimSpace(string(name)) == ""
}
// Validate reports whether RaceName is non-empty, trimmed, and within the
// frozen OpenAPI length bound.
func (name RaceName) Validate() error {
raw := string(name)
if err := validateToken("race name", raw); err != nil {
return err
}
if len(raw) > maxRaceNameLength {
return fmt.Errorf("race name must be at most %d bytes", maxRaceNameLength)
}
return nil
}
// LanguageTag stores one declared BCP 47 language-tag string.
type LanguageTag string
// String returns LanguageTag as its stored value.
func (tag LanguageTag) String() string {
return string(tag)
}
// IsZero reports whether LanguageTag does not contain a usable value.
func (tag LanguageTag) IsZero() bool {
return strings.TrimSpace(string(tag)) == ""
}
// Validate reports whether LanguageTag is non-empty, trimmed, and within the
// frozen OpenAPI length bound. Stage 02 intentionally freezes the storage
// shape and not the later boundary-level BCP 47 parser choice.
func (tag LanguageTag) Validate() error {
raw := string(tag)
if err := validateToken("language tag", raw); err != nil {
return err
}
if len(raw) > maxLanguageTagLength {
return fmt.Errorf("language tag must be at most %d bytes", maxLanguageTagLength)
}
return nil
}
// TimeZoneName stores one declared IANA time-zone name.
type TimeZoneName string
// String returns TimeZoneName as its stored value.
func (name TimeZoneName) String() string {
return string(name)
}
// IsZero reports whether TimeZoneName does not contain a usable value.
func (name TimeZoneName) IsZero() bool {
return strings.TrimSpace(string(name)) == ""
}
// Validate reports whether TimeZoneName is non-empty, trimmed, and within the
// frozen OpenAPI length bound. Later application stages may tighten
// boundary-level validation further.
func (name TimeZoneName) Validate() error {
raw := string(name)
if err := validateToken("time zone name", raw); err != nil {
return err
}
if len(raw) > maxTimeZoneNameLength {
return fmt.Errorf("time zone name must be at most %d bytes", maxTimeZoneNameLength)
}
return nil
}
// CountryCode stores one ISO 3166-1 alpha-2 code.
type CountryCode string
// String returns CountryCode as its stored value.
func (code CountryCode) String() string {
return string(code)
}
// IsZero reports whether CountryCode does not contain a usable value.
func (code CountryCode) IsZero() bool {
return strings.TrimSpace(string(code)) == ""
}
// Validate reports whether CountryCode is an uppercase ISO 3166-1 alpha-2
// code.
func (code CountryCode) Validate() error {
raw := string(code)
if len(raw) != 2 {
return fmt.Errorf("country code %q must contain exactly two letters", raw)
}
for idx := 0; idx < len(raw); idx++ {
if raw[idx] < 'A' || raw[idx] > 'Z' {
return fmt.Errorf("country code %q must contain only uppercase ASCII letters", raw)
}
}
return nil
}
// ActorType stores one machine-readable actor type for audit metadata.
type ActorType string
// String returns ActorType as its stored value.
func (actorType ActorType) String() string {
return string(actorType)
}
// IsZero reports whether ActorType does not contain a usable value.
func (actorType ActorType) IsZero() bool {
return strings.TrimSpace(string(actorType)) == ""
}
// Validate reports whether ActorType is non-empty and trimmed.
func (actorType ActorType) Validate() error {
return validateToken("actor type", string(actorType))
}
// ActorID stores one optional stable actor identifier.
type ActorID string
// String returns ActorID as its stored value.
func (actorID ActorID) String() string {
return string(actorID)
}
// IsZero reports whether ActorID does not contain a usable value.
func (actorID ActorID) IsZero() bool {
return strings.TrimSpace(string(actorID)) == ""
}
// Validate reports whether ActorID is trimmed when present.
func (actorID ActorID) Validate() error {
if actorID.IsZero() {
return nil
}
return validateToken("actor id", string(actorID))
}
// ActorRef stores actor metadata captured on trusted mutations.
type ActorRef struct {
// Type identifies the machine-readable actor class such as `admin`,
// `service`, or `billing`.
Type ActorType
// ID stores the optional stable actor identifier.
ID ActorID
}
// IsZero reports whether ActorRef does not contain any audit actor metadata.
func (ref ActorRef) IsZero() bool {
return ref.Type.IsZero() && ref.ID.IsZero()
}
// Validate reports whether ActorRef contains a required type and an optional
// trimmed identifier.
func (ref ActorRef) Validate() error {
if err := ref.Type.Validate(); err != nil {
return fmt.Errorf("actor ref type: %w", err)
}
if err := ref.ID.Validate(); err != nil {
return fmt.Errorf("actor ref id: %w", err)
}
return nil
}
// ReasonCode stores one machine-readable reason code.
type ReasonCode string
// String returns ReasonCode as its stored value.
func (code ReasonCode) String() string {
return string(code)
}
// IsZero reports whether ReasonCode does not contain a usable value.
func (code ReasonCode) IsZero() bool {
return strings.TrimSpace(string(code)) == ""
}
// Validate reports whether ReasonCode is non-empty and trimmed.
func (code ReasonCode) Validate() error {
return validateToken("reason code", string(code))
}
// Source stores one machine-readable mutation source.
type Source string
// String returns Source as its stored value.
func (source Source) String() string {
return string(source)
}
// IsZero reports whether Source does not contain a usable value.
func (source Source) IsZero() bool {
return strings.TrimSpace(string(source)) == ""
}
// Validate reports whether Source is non-empty and trimmed.
func (source Source) Validate() error {
return validateToken("source", string(source))
}
// Scope stores one machine-readable sanction scope.
type Scope string
// String returns Scope as its stored value.
func (scope Scope) String() string {
return string(scope)
}
// IsZero reports whether Scope does not contain a usable value.
func (scope Scope) IsZero() bool {
return strings.TrimSpace(string(scope)) == ""
}
// Validate reports whether Scope is non-empty and trimmed.
func (scope Scope) Validate() error {
return validateToken("scope", string(scope))
}
// ValidateTimestamp reports whether value is set.
func ValidateTimestamp(name string, value time.Time) error {
if value.IsZero() {
return fmt.Errorf("%s must not be zero", name)
}
return nil
}
func validateToken(name string, value string) error {
switch {
case strings.TrimSpace(value) == "":
return fmt.Errorf("%s must not be empty", name)
case strings.TrimSpace(value) != value:
return fmt.Errorf("%s must not contain surrounding whitespace", name)
default:
return nil
}
}
func validatePrefixedToken(name string, value string, prefix string) error {
if err := validateToken(name, value); err != nil {
return err
}
if !strings.HasPrefix(value, prefix) {
return fmt.Errorf("%s must start with %q", name, prefix)
}
if len(value) == len(prefix) {
return fmt.Errorf("%s must contain opaque data after %q", name, prefix)
}
return nil
}
// ErrInvertedTimeRange reports that the logical end of a range is not after
// its start.
var ErrInvertedTimeRange = errors.New("time range end must be after start")
+207
View File
@@ -0,0 +1,207 @@
package common
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestUserIDValidate(t *testing.T) {
t.Parallel()
tests := []struct {
name string
value UserID
wantErr bool
}{
{name: "valid", value: UserID("user-abc123")},
{name: "empty", value: UserID(""), wantErr: true},
{name: "surrounding whitespace", value: UserID(" user-abc123 "), wantErr: true},
{name: "wrong prefix", value: UserID("account-abc123"), wantErr: true},
{name: "prefix only", value: UserID("user-"), wantErr: true},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
err := tt.value.Validate()
if tt.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
})
}
}
func TestEmailValidate(t *testing.T) {
t.Parallel()
tests := []struct {
name string
value Email
wantErr bool
}{
{name: "valid", value: Email("pilot@example.com")},
{name: "empty", value: Email(""), wantErr: true},
{name: "display name", value: Email("Pilot <pilot@example.com>"), wantErr: true},
{name: "invalid", value: Email("not-an-email"), wantErr: true},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
err := tt.value.Validate()
if tt.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
})
}
}
func TestRaceNameValidate(t *testing.T) {
t.Parallel()
tests := []struct {
name string
value RaceName
wantErr bool
}{
{name: "valid", value: RaceName("Admiral Nova")},
{name: "empty", value: RaceName(""), wantErr: true},
{name: "too long", value: RaceName("abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijklmn"), wantErr: true},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
err := tt.value.Validate()
if tt.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
})
}
}
func TestLanguageTagValidate(t *testing.T) {
t.Parallel()
tests := []struct {
name string
value LanguageTag
wantErr bool
}{
{name: "valid", value: LanguageTag("en-US")},
{name: "empty", value: LanguageTag(""), wantErr: true},
{name: "surrounding whitespace", value: LanguageTag(" en "), wantErr: true},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
err := tt.value.Validate()
if tt.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
})
}
}
func TestTimeZoneNameValidate(t *testing.T) {
t.Parallel()
tests := []struct {
name string
value TimeZoneName
wantErr bool
}{
{name: "valid", value: TimeZoneName("Europe/Berlin")},
{name: "empty", value: TimeZoneName(""), wantErr: true},
{name: "surrounding whitespace", value: TimeZoneName(" UTC "), wantErr: true},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
err := tt.value.Validate()
if tt.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
})
}
}
func TestCountryCodeValidate(t *testing.T) {
t.Parallel()
tests := []struct {
name string
value CountryCode
wantErr bool
}{
{name: "valid", value: CountryCode("DE")},
{name: "lowercase", value: CountryCode("de"), wantErr: true},
{name: "wrong length", value: CountryCode("DEU"), wantErr: true},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
err := tt.value.Validate()
if tt.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
})
}
}
func TestActorRefValidate(t *testing.T) {
t.Parallel()
tests := []struct {
name string
value ActorRef
wantErr bool
}{
{name: "valid without id", value: ActorRef{Type: ActorType("service")}},
{name: "valid with id", value: ActorRef{Type: ActorType("admin"), ID: ActorID("admin-1")}},
{name: "missing type", value: ActorRef{ID: ActorID("admin-1")}, wantErr: true},
{name: "invalid id whitespace", value: ActorRef{Type: ActorType("admin"), ID: ActorID(" admin-1 ")}, wantErr: true},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
err := tt.value.Validate()
if tt.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
})
}
}
+325
View File
@@ -0,0 +1,325 @@
// Package entitlement defines the logical entitlement entities owned by User
// Service.
package entitlement
import (
"fmt"
"strings"
"time"
"galaxy/user/internal/domain/common"
)
// PlanCode identifies one supported entitlement plan.
type PlanCode string
const (
// PlanCodeFree reports the free default entitlement.
PlanCodeFree PlanCode = "free"
// PlanCodePaidMonthly reports a finite monthly paid entitlement.
PlanCodePaidMonthly PlanCode = "paid_monthly"
// PlanCodePaidYearly reports a finite yearly paid entitlement.
PlanCodePaidYearly PlanCode = "paid_yearly"
// PlanCodePaidLifetime reports a non-expiring paid entitlement.
PlanCodePaidLifetime PlanCode = "paid_lifetime"
)
// IsKnown reports whether PlanCode belongs to the frozen v1 catalog.
func (code PlanCode) IsKnown() bool {
switch code {
case PlanCodeFree, PlanCodePaidMonthly, PlanCodePaidYearly, PlanCodePaidLifetime:
return true
default:
return false
}
}
// IsPaid reports whether PlanCode represents a paid entitlement state.
func (code PlanCode) IsPaid() bool {
switch code {
case PlanCodePaidMonthly, PlanCodePaidYearly, PlanCodePaidLifetime:
return true
default:
return false
}
}
// HasFiniteExpiry reports whether PlanCode requires a bounded `ends_at`
// value in the Stage 07 entitlement timeline model.
func (code PlanCode) HasFiniteExpiry() bool {
switch code {
case PlanCodePaidMonthly, PlanCodePaidYearly:
return true
default:
return false
}
}
// EntitlementRecordID identifies one immutable entitlement history record.
type EntitlementRecordID string
// String returns EntitlementRecordID as its stored identifier string.
func (id EntitlementRecordID) String() string {
return string(id)
}
// IsZero reports whether EntitlementRecordID does not contain a usable value.
func (id EntitlementRecordID) IsZero() bool {
return strings.TrimSpace(string(id)) == ""
}
// Validate reports whether EntitlementRecordID is non-empty, normalized, and
// uses the frozen Stage 02 prefix.
func (id EntitlementRecordID) Validate() error {
switch {
case id.IsZero():
return fmt.Errorf("entitlement record id must not be empty")
case strings.TrimSpace(string(id)) != string(id):
return fmt.Errorf("entitlement record id must not contain surrounding whitespace")
case !strings.HasPrefix(string(id), "entitlement-"):
return fmt.Errorf("entitlement record id must start with %q", "entitlement-")
case len(string(id)) == len("entitlement-"):
return fmt.Errorf("entitlement record id must contain opaque data after %q", "entitlement-")
default:
return nil
}
}
// PeriodRecord stores one entitlement-period history record.
type PeriodRecord struct {
// RecordID identifies the immutable history record.
RecordID EntitlementRecordID
// UserID identifies the account that owns the entitlement record.
UserID common.UserID
// PlanCode stores the effective plan for the recorded period.
PlanCode PlanCode
// Source stores the machine-readable mutation source.
Source common.Source
// Actor stores the audit actor metadata captured for the mutation.
Actor common.ActorRef
// ReasonCode stores the machine-readable reason for the mutation.
ReasonCode common.ReasonCode
// StartsAt stores when the period becomes effective.
StartsAt time.Time
// EndsAt stores the optional planned end of the period.
EndsAt *time.Time
// CreatedAt stores when the history record was created.
CreatedAt time.Time
// ClosedAt stores when the period was later closed early by another trusted
// mutation.
ClosedAt *time.Time
// ClosedBy stores optional audit actor metadata for the close mutation.
ClosedBy common.ActorRef
// ClosedReasonCode stores the reason for closing the period early.
ClosedReasonCode common.ReasonCode
}
// Validate reports whether PeriodRecord satisfies the frozen Stage 02
// structural invariants.
func (record PeriodRecord) Validate() error {
if err := record.RecordID.Validate(); err != nil {
return fmt.Errorf("entitlement period record id: %w", err)
}
if err := record.UserID.Validate(); err != nil {
return fmt.Errorf("entitlement period user id: %w", err)
}
if !record.PlanCode.IsKnown() {
return fmt.Errorf("entitlement period plan code %q is unsupported", record.PlanCode)
}
if err := record.Source.Validate(); err != nil {
return fmt.Errorf("entitlement period source: %w", err)
}
if err := record.Actor.Validate(); err != nil {
return fmt.Errorf("entitlement period actor: %w", err)
}
if err := record.ReasonCode.Validate(); err != nil {
return fmt.Errorf("entitlement period reason code: %w", err)
}
if err := common.ValidateTimestamp("entitlement period starts at", record.StartsAt); err != nil {
return err
}
if err := validatePlanBounds("entitlement period", record.PlanCode, record.StartsAt, record.EndsAt); err != nil {
return err
}
if err := common.ValidateTimestamp("entitlement period created at", record.CreatedAt); err != nil {
return err
}
if record.ClosedAt == nil {
if !record.ClosedBy.IsZero() {
return fmt.Errorf("entitlement period closed by must be empty when closed at is absent")
}
if !record.ClosedReasonCode.IsZero() {
return fmt.Errorf("entitlement period closed reason code must be empty when closed at is absent")
}
return nil
}
if record.ClosedAt.Before(record.StartsAt) {
return fmt.Errorf("entitlement period closed at must not be before starts at")
}
if record.EndsAt != nil && record.ClosedAt.After(*record.EndsAt) {
return fmt.Errorf("entitlement period closed at must not be after ends at")
}
if record.ClosedAt.Before(record.CreatedAt) {
return fmt.Errorf("entitlement period closed at must not be before created at")
}
if err := record.ClosedBy.Validate(); err != nil {
return fmt.Errorf("entitlement period closed by: %w", err)
}
if err := record.ClosedReasonCode.Validate(); err != nil {
return fmt.Errorf("entitlement period closed reason code: %w", err)
}
return nil
}
// IsEffectiveAt reports whether PeriodRecord is the currently effective
// segment at the supplied timestamp.
func (record PeriodRecord) IsEffectiveAt(now time.Time) bool {
if record.ClosedAt != nil {
return false
}
if record.StartsAt.After(now) {
return false
}
if record.EndsAt != nil && !record.EndsAt.After(now) {
return false
}
return true
}
// CurrentSnapshot stores the read-optimized current entitlement state of one
// user account.
type CurrentSnapshot struct {
// UserID identifies the account that owns the current entitlement.
UserID common.UserID
// PlanCode stores the current effective plan code.
PlanCode PlanCode
// IsPaid stores the materialized paid/free state used on hot read paths.
IsPaid bool
// StartsAt stores when the current effective state started.
StartsAt time.Time
// EndsAt stores the optional end of the current finite entitlement.
EndsAt *time.Time
// Source stores the machine-readable source of the current state.
Source common.Source
// Actor stores the actor metadata attached to the last successful mutation.
Actor common.ActorRef
// ReasonCode stores the machine-readable reason attached to the last
// successful mutation.
ReasonCode common.ReasonCode
// UpdatedAt stores when the snapshot was last recomputed.
UpdatedAt time.Time
}
// Validate reports whether CurrentSnapshot satisfies the frozen Stage 02
// structural invariants.
func (record CurrentSnapshot) Validate() error {
if err := record.UserID.Validate(); err != nil {
return fmt.Errorf("entitlement snapshot user id: %w", err)
}
if !record.PlanCode.IsKnown() {
return fmt.Errorf("entitlement snapshot plan code %q is unsupported", record.PlanCode)
}
if record.IsPaid != record.PlanCode.IsPaid() {
return fmt.Errorf("entitlement snapshot paid flag must match plan code %q", record.PlanCode)
}
if err := common.ValidateTimestamp("entitlement snapshot starts at", record.StartsAt); err != nil {
return err
}
if err := validatePlanBounds("entitlement snapshot", record.PlanCode, record.StartsAt, record.EndsAt); err != nil {
return err
}
if err := record.Source.Validate(); err != nil {
return fmt.Errorf("entitlement snapshot source: %w", err)
}
if err := record.Actor.Validate(); err != nil {
return fmt.Errorf("entitlement snapshot actor: %w", err)
}
if err := record.ReasonCode.Validate(); err != nil {
return fmt.Errorf("entitlement snapshot reason code: %w", err)
}
if err := common.ValidateTimestamp("entitlement snapshot updated at", record.UpdatedAt); err != nil {
return err
}
return nil
}
// HasFiniteExpiry reports whether CurrentSnapshot participates in the finite
// paid-expiry index.
func (record CurrentSnapshot) HasFiniteExpiry() bool {
return record.IsPaid && record.EndsAt != nil
}
// IsExpiredAt reports whether CurrentSnapshot represents a finite paid state
// that has already reached its stored expiry.
func (record CurrentSnapshot) IsExpiredAt(now time.Time) bool {
return record.HasFiniteExpiry() && !record.EndsAt.After(now)
}
// PaidState identifies the coarse free-versus-paid filter used by admin
// listing.
type PaidState string
const (
// PaidStateFree filters accounts whose current entitlement is free.
PaidStateFree PaidState = "free"
// PaidStatePaid filters accounts whose current entitlement is paid.
PaidStatePaid PaidState = "paid"
)
// IsKnown reports whether PaidState belongs to the frozen Stage 02 filter
// vocabulary.
func (state PaidState) IsKnown() bool {
switch state {
case "", PaidStateFree, PaidStatePaid:
return true
default:
return false
}
}
func validatePlanBounds(
name string,
planCode PlanCode,
startsAt time.Time,
endsAt *time.Time,
) error {
switch {
case planCode.HasFiniteExpiry():
if endsAt == nil {
return fmt.Errorf("%s ends at must be present for plan code %q", name, planCode)
}
if !endsAt.After(startsAt) {
return common.ErrInvertedTimeRange
}
case endsAt != nil:
return fmt.Errorf("%s ends at must be empty for plan code %q", name, planCode)
}
return nil
}
@@ -0,0 +1,159 @@
package entitlement
import (
"testing"
"time"
"galaxy/user/internal/domain/common"
"github.com/stretchr/testify/require"
)
func TestPeriodRecordValidate(t *testing.T) {
t.Parallel()
startsAt := time.Unix(1_775_240_000, 0).UTC()
endsAt := startsAt.Add(30 * 24 * time.Hour)
createdAt := startsAt.Add(-time.Hour)
closedAt := startsAt.Add(12 * time.Hour)
tests := []struct {
name string
record PeriodRecord
wantErr bool
}{
{
name: "valid open record",
record: PeriodRecord{
RecordID: EntitlementRecordID("entitlement-123"),
UserID: common.UserID("user-123"),
PlanCode: PlanCodePaidMonthly,
Source: common.Source("admin"),
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
ReasonCode: common.ReasonCode("manual_grant"),
StartsAt: startsAt,
EndsAt: &endsAt,
CreatedAt: createdAt,
},
},
{
name: "valid closed record",
record: PeriodRecord{
RecordID: EntitlementRecordID("entitlement-123"),
UserID: common.UserID("user-123"),
PlanCode: PlanCodePaidMonthly,
Source: common.Source("admin"),
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
ReasonCode: common.ReasonCode("manual_grant"),
StartsAt: startsAt,
EndsAt: &endsAt,
CreatedAt: createdAt,
ClosedAt: &closedAt,
ClosedBy: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-2")},
ClosedReasonCode: common.ReasonCode("manual_revoke"),
},
},
{
name: "close metadata without closed at",
record: PeriodRecord{
RecordID: EntitlementRecordID("entitlement-123"),
UserID: common.UserID("user-123"),
PlanCode: PlanCodePaidMonthly,
Source: common.Source("admin"),
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
ReasonCode: common.ReasonCode("manual_grant"),
StartsAt: startsAt,
CreatedAt: createdAt,
ClosedReasonCode: common.ReasonCode("manual_revoke"),
},
wantErr: true,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
err := tt.record.Validate()
if tt.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
})
}
}
func TestCurrentSnapshotValidate(t *testing.T) {
t.Parallel()
startsAt := time.Unix(1_775_240_000, 0).UTC()
endsAt := startsAt.Add(30 * 24 * time.Hour)
updatedAt := startsAt.Add(2 * time.Hour)
tests := []struct {
name string
record CurrentSnapshot
wantErr bool
wantFinite bool
}{
{
name: "valid finite paid snapshot",
record: CurrentSnapshot{
UserID: common.UserID("user-123"),
PlanCode: PlanCodePaidMonthly,
IsPaid: true,
StartsAt: startsAt,
EndsAt: &endsAt,
Source: common.Source("admin"),
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
ReasonCode: common.ReasonCode("manual_grant"),
UpdatedAt: updatedAt,
},
wantFinite: true,
},
{
name: "valid free snapshot",
record: CurrentSnapshot{
UserID: common.UserID("user-123"),
PlanCode: PlanCodeFree,
IsPaid: false,
StartsAt: startsAt,
Source: common.Source("system"),
Actor: common.ActorRef{Type: common.ActorType("service")},
ReasonCode: common.ReasonCode("default_free_plan"),
UpdatedAt: updatedAt,
},
},
{
name: "paid flag mismatch",
record: CurrentSnapshot{
UserID: common.UserID("user-123"),
PlanCode: PlanCodeFree,
IsPaid: true,
StartsAt: startsAt,
Source: common.Source("system"),
Actor: common.ActorRef{Type: common.ActorType("service")},
ReasonCode: common.ReasonCode("default_free_plan"),
UpdatedAt: updatedAt,
},
wantErr: true,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
err := tt.record.Validate()
if tt.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
require.Equal(t, tt.wantFinite, tt.record.HasFiniteExpiry())
})
}
}
+511
View File
@@ -0,0 +1,511 @@
// Package policy defines sanction, limit, and eligibility-domain entities used
// by User Service.
package policy
import (
"fmt"
"slices"
"strings"
"time"
"galaxy/user/internal/domain/common"
)
// SanctionCode identifies one supported sanction in the v1 policy catalog.
type SanctionCode string
const (
// SanctionCodeLoginBlock denies login.
SanctionCodeLoginBlock SanctionCode = "login_block"
// SanctionCodePrivateGameCreateBlock denies private-game creation.
SanctionCodePrivateGameCreateBlock SanctionCode = "private_game_create_block"
// SanctionCodePrivateGameManageBlock denies private-game management.
SanctionCodePrivateGameManageBlock SanctionCode = "private_game_manage_block"
// SanctionCodeGameJoinBlock denies game joining.
SanctionCodeGameJoinBlock SanctionCode = "game_join_block"
// SanctionCodeProfileUpdateBlock denies self-service profile/settings
// mutations.
SanctionCodeProfileUpdateBlock SanctionCode = "profile_update_block"
)
// IsKnown reports whether SanctionCode belongs to the frozen v1 catalog.
func (code SanctionCode) IsKnown() bool {
switch code {
case SanctionCodeLoginBlock,
SanctionCodePrivateGameCreateBlock,
SanctionCodePrivateGameManageBlock,
SanctionCodeGameJoinBlock,
SanctionCodeProfileUpdateBlock:
return true
default:
return false
}
}
// LimitCode identifies one user-specific limit code recognized by User
// Service.
type LimitCode string
const (
// LimitCodeMaxOwnedPrivateGames limits how many private games the user may
// own while the current entitlement is paid.
LimitCodeMaxOwnedPrivateGames LimitCode = "max_owned_private_games"
// LimitCodeMaxPendingPublicApplications stores the total public-games budget
// consumed together with current active public memberships when Game Lobby
// derives remaining pending application headroom.
LimitCodeMaxPendingPublicApplications LimitCode = "max_pending_public_applications"
// LimitCodeMaxActiveGameMemberships limits how many active public-game
// memberships the user may hold at once.
LimitCodeMaxActiveGameMemberships LimitCode = "max_active_game_memberships"
)
const (
// LimitCodeMaxActivePrivateGames is a retired legacy code recognized only
// so old stored records do not break current reads.
LimitCodeMaxActivePrivateGames LimitCode = "max_active_private_games"
// LimitCodeMaxPendingPrivateJoinRequests is a retired legacy code
// recognized only so old stored records do not break current reads.
LimitCodeMaxPendingPrivateJoinRequests LimitCode = "max_pending_private_join_requests"
// LimitCodeMaxPendingPrivateInvitesSent is a retired legacy code
// recognized only so old stored records do not break current reads.
LimitCodeMaxPendingPrivateInvitesSent LimitCode = "max_pending_private_invites_sent"
)
// IsKnown reports whether LimitCode belongs to the current supported write/API
// catalog.
func (code LimitCode) IsKnown() bool {
return code.IsSupported()
}
// IsSupported reports whether LimitCode belongs to the current supported
// write/API catalog.
func (code LimitCode) IsSupported() bool {
switch code {
case LimitCodeMaxOwnedPrivateGames,
LimitCodeMaxPendingPublicApplications,
LimitCodeMaxActiveGameMemberships:
return true
default:
return false
}
}
// IsRetired reports whether LimitCode is a retired legacy code recognized
// only for read compatibility with already stored history records.
func (code LimitCode) IsRetired() bool {
switch code {
case LimitCodeMaxActivePrivateGames,
LimitCodeMaxPendingPrivateJoinRequests,
LimitCodeMaxPendingPrivateInvitesSent:
return true
default:
return false
}
}
// IsRecognized reports whether LimitCode is either currently supported or
// retired-but-recognized for read compatibility.
func (code LimitCode) IsRecognized() bool {
return code.IsSupported() || code.IsRetired()
}
// EligibilityMarker identifies one derived eligibility boolean that may be
// indexed for admin listing.
type EligibilityMarker string
const (
// EligibilityMarkerCanLogin tracks whether the user may currently log in.
EligibilityMarkerCanLogin EligibilityMarker = "can_login"
// EligibilityMarkerCanCreatePrivateGame tracks whether the user may create
// a private game.
EligibilityMarkerCanCreatePrivateGame EligibilityMarker = "can_create_private_game"
// EligibilityMarkerCanManagePrivateGame tracks whether the user may manage
// a private game.
EligibilityMarkerCanManagePrivateGame EligibilityMarker = "can_manage_private_game"
// EligibilityMarkerCanJoinGame tracks whether the user may join a game.
EligibilityMarkerCanJoinGame EligibilityMarker = "can_join_game"
// EligibilityMarkerCanUpdateProfile tracks whether the user may update
// self-service profile/settings fields.
EligibilityMarkerCanUpdateProfile EligibilityMarker = "can_update_profile"
)
// IsKnown reports whether EligibilityMarker belongs to the frozen v1 set.
func (marker EligibilityMarker) IsKnown() bool {
switch marker {
case EligibilityMarkerCanLogin,
EligibilityMarkerCanCreatePrivateGame,
EligibilityMarkerCanManagePrivateGame,
EligibilityMarkerCanJoinGame,
EligibilityMarkerCanUpdateProfile:
return true
default:
return false
}
}
// SanctionRecordID identifies one sanction history record.
type SanctionRecordID string
// String returns SanctionRecordID as its stored identifier string.
func (id SanctionRecordID) String() string {
return string(id)
}
// IsZero reports whether SanctionRecordID does not contain a usable value.
func (id SanctionRecordID) IsZero() bool {
return strings.TrimSpace(string(id)) == ""
}
// Validate reports whether SanctionRecordID is non-empty, normalized, and
// uses the frozen Stage 02 prefix.
func (id SanctionRecordID) Validate() error {
return validatePrefixedRecordID("sanction record id", string(id), "sanction-")
}
// LimitRecordID identifies one limit history record.
type LimitRecordID string
// String returns LimitRecordID as its stored identifier string.
func (id LimitRecordID) String() string {
return string(id)
}
// IsZero reports whether LimitRecordID does not contain a usable value.
func (id LimitRecordID) IsZero() bool {
return strings.TrimSpace(string(id)) == ""
}
// Validate reports whether LimitRecordID is non-empty, normalized, and uses
// the frozen Stage 02 prefix.
func (id LimitRecordID) Validate() error {
return validatePrefixedRecordID("limit record id", string(id), "limit-")
}
// SanctionRecord stores one sanction history record.
type SanctionRecord struct {
// RecordID identifies the sanction history record.
RecordID SanctionRecordID
// UserID identifies the account that owns the sanction.
UserID common.UserID
// SanctionCode stores the sanction applied to the account.
SanctionCode SanctionCode
// Scope stores the machine-readable scope attached to the sanction.
Scope common.Scope
// ReasonCode stores the reason for the sanction mutation.
ReasonCode common.ReasonCode
// Actor stores the audit actor metadata for the apply mutation.
Actor common.ActorRef
// AppliedAt stores when the sanction becomes effective.
AppliedAt time.Time
// ExpiresAt stores the optional planned expiry of the sanction.
ExpiresAt *time.Time
// RemovedAt stores when the sanction was later removed explicitly.
RemovedAt *time.Time
// RemovedBy stores the audit actor metadata for the remove mutation.
RemovedBy common.ActorRef
// RemovedReasonCode stores the reason for the remove mutation.
RemovedReasonCode common.ReasonCode
}
// Validate reports whether SanctionRecord satisfies the frozen structural
// invariants that do not depend on a caller-supplied clock.
func (record SanctionRecord) Validate() error {
if err := record.RecordID.Validate(); err != nil {
return fmt.Errorf("sanction record id: %w", err)
}
if err := record.UserID.Validate(); err != nil {
return fmt.Errorf("sanction user id: %w", err)
}
if !record.SanctionCode.IsKnown() {
return fmt.Errorf("sanction code %q is unsupported", record.SanctionCode)
}
if err := record.Scope.Validate(); err != nil {
return fmt.Errorf("sanction scope: %w", err)
}
if err := record.ReasonCode.Validate(); err != nil {
return fmt.Errorf("sanction reason code: %w", err)
}
if err := record.Actor.Validate(); err != nil {
return fmt.Errorf("sanction actor: %w", err)
}
if err := common.ValidateTimestamp("sanction applied at", record.AppliedAt); err != nil {
return err
}
if record.ExpiresAt != nil && !record.ExpiresAt.After(record.AppliedAt) {
return common.ErrInvertedTimeRange
}
if record.RemovedAt == nil {
if !record.RemovedBy.IsZero() {
return fmt.Errorf("sanction removed by must be empty when removed at is absent")
}
if !record.RemovedReasonCode.IsZero() {
return fmt.Errorf("sanction removed reason code must be empty when removed at is absent")
}
return nil
}
if record.RemovedAt.Before(record.AppliedAt) {
return fmt.Errorf("sanction removed at must not be before applied at")
}
if err := record.RemovedBy.Validate(); err != nil {
return fmt.Errorf("sanction removed by: %w", err)
}
if err := record.RemovedReasonCode.Validate(); err != nil {
return fmt.Errorf("sanction removed reason code: %w", err)
}
return nil
}
// ValidateAt reports whether SanctionRecord also satisfies the current-time
// Stage 02 invariant that `applied_at` must not be in the future.
func (record SanctionRecord) ValidateAt(now time.Time) error {
if err := record.Validate(); err != nil {
return err
}
if now.IsZero() {
return fmt.Errorf("sanction validation time must not be zero")
}
if record.AppliedAt.After(now.UTC()) {
return fmt.Errorf("sanction applied at must not be in the future")
}
return nil
}
// IsActiveAt reports whether SanctionRecord is active at now according to the
// frozen Stage 02 rules.
func (record SanctionRecord) IsActiveAt(now time.Time) bool {
now = now.UTC()
switch {
case now.IsZero():
return false
case record.AppliedAt.After(now):
return false
case record.RemovedAt != nil:
return false
case record.ExpiresAt != nil && !record.ExpiresAt.After(now):
return false
default:
return true
}
}
// LimitRecord stores one user-specific limit history record.
type LimitRecord struct {
// RecordID identifies the limit history record.
RecordID LimitRecordID
// UserID identifies the account that owns the limit.
UserID common.UserID
// LimitCode stores which count-based limit is overridden.
LimitCode LimitCode
// Value stores the override value.
Value int
// ReasonCode stores the reason for the limit mutation.
ReasonCode common.ReasonCode
// Actor stores the audit actor metadata for the set mutation.
Actor common.ActorRef
// AppliedAt stores when the limit becomes effective.
AppliedAt time.Time
// ExpiresAt stores the optional planned expiry of the limit.
ExpiresAt *time.Time
// RemovedAt stores when the limit was later removed explicitly.
RemovedAt *time.Time
// RemovedBy stores the audit actor metadata for the remove mutation.
RemovedBy common.ActorRef
// RemovedReasonCode stores the reason for the remove mutation.
RemovedReasonCode common.ReasonCode
}
// Validate reports whether LimitRecord satisfies the structural invariants
// that do not depend on a caller-supplied clock. Retired legacy limit codes
// remain recognized here so already stored records still decode safely.
func (record LimitRecord) Validate() error {
if err := record.RecordID.Validate(); err != nil {
return fmt.Errorf("limit record id: %w", err)
}
if err := record.UserID.Validate(); err != nil {
return fmt.Errorf("limit user id: %w", err)
}
if !record.LimitCode.IsRecognized() {
return fmt.Errorf("limit code %q is unsupported", record.LimitCode)
}
if record.Value < 0 {
return fmt.Errorf("limit value must not be negative")
}
if err := record.ReasonCode.Validate(); err != nil {
return fmt.Errorf("limit reason code: %w", err)
}
if err := record.Actor.Validate(); err != nil {
return fmt.Errorf("limit actor: %w", err)
}
if err := common.ValidateTimestamp("limit applied at", record.AppliedAt); err != nil {
return err
}
if record.ExpiresAt != nil && !record.ExpiresAt.After(record.AppliedAt) {
return common.ErrInvertedTimeRange
}
if record.RemovedAt == nil {
if !record.RemovedBy.IsZero() {
return fmt.Errorf("limit removed by must be empty when removed at is absent")
}
if !record.RemovedReasonCode.IsZero() {
return fmt.Errorf("limit removed reason code must be empty when removed at is absent")
}
return nil
}
if record.RemovedAt.Before(record.AppliedAt) {
return fmt.Errorf("limit removed at must not be before applied at")
}
if err := record.RemovedBy.Validate(); err != nil {
return fmt.Errorf("limit removed by: %w", err)
}
if err := record.RemovedReasonCode.Validate(); err != nil {
return fmt.Errorf("limit removed reason code: %w", err)
}
return nil
}
// ValidateAt reports whether LimitRecord also satisfies the current-time Stage
// 02 invariant that `applied_at` must not be in the future.
func (record LimitRecord) ValidateAt(now time.Time) error {
if err := record.Validate(); err != nil {
return err
}
if now.IsZero() {
return fmt.Errorf("limit validation time must not be zero")
}
if record.AppliedAt.After(now.UTC()) {
return fmt.Errorf("limit applied at must not be in the future")
}
return nil
}
// IsActiveAt reports whether LimitRecord is active at now according to the
// frozen Stage 02 rules.
func (record LimitRecord) IsActiveAt(now time.Time) bool {
now = now.UTC()
switch {
case now.IsZero():
return false
case record.AppliedAt.After(now):
return false
case record.RemovedAt != nil:
return false
case record.ExpiresAt != nil && !record.ExpiresAt.After(now):
return false
default:
return true
}
}
// ActiveSanctionsAt returns the active sanctions at now, sorted
// deterministically by `sanction_code`. The function returns an error when the
// input contains structurally invalid records or more than one active sanction
// for the same `user_id + sanction_code`.
func ActiveSanctionsAt(records []SanctionRecord, now time.Time) ([]SanctionRecord, error) {
active := make([]SanctionRecord, 0, len(records))
seen := make(map[SanctionCode]struct{}, len(records))
for _, record := range records {
if err := record.ValidateAt(now); err != nil {
return nil, err
}
if !record.IsActiveAt(now) {
continue
}
if _, ok := seen[record.SanctionCode]; ok {
return nil, fmt.Errorf("multiple active sanctions for code %q", record.SanctionCode)
}
seen[record.SanctionCode] = struct{}{}
active = append(active, record)
}
slices.SortFunc(active, func(left SanctionRecord, right SanctionRecord) int {
return strings.Compare(string(left.SanctionCode), string(right.SanctionCode))
})
return active, nil
}
// ActiveLimitsAt returns the active limits at now, sorted deterministically by
// `limit_code`. Retired legacy limit codes are ignored so historical records
// stored under the old catalog do not affect current effective reads. The
// function returns an error when the input contains structurally invalid
// records or more than one active current limit for the same
// `user_id + limit_code`.
func ActiveLimitsAt(records []LimitRecord, now time.Time) ([]LimitRecord, error) {
active := make([]LimitRecord, 0, len(records))
seen := make(map[LimitCode]struct{}, len(records))
for _, record := range records {
if err := record.ValidateAt(now); err != nil {
return nil, err
}
if !record.IsActiveAt(now) {
continue
}
if !record.LimitCode.IsSupported() {
continue
}
if _, ok := seen[record.LimitCode]; ok {
return nil, fmt.Errorf("multiple active limits for code %q", record.LimitCode)
}
seen[record.LimitCode] = struct{}{}
active = append(active, record)
}
slices.SortFunc(active, func(left LimitRecord, right LimitRecord) int {
return strings.Compare(string(left.LimitCode), string(right.LimitCode))
})
return active, nil
}
func validatePrefixedRecordID(name string, value string, prefix string) error {
switch {
case strings.TrimSpace(value) == "":
return fmt.Errorf("%s must not be empty", name)
case strings.TrimSpace(value) != value:
return fmt.Errorf("%s must not contain surrounding whitespace", name)
case !strings.HasPrefix(value, prefix):
return fmt.Errorf("%s must start with %q", name, prefix)
case len(value) == len(prefix):
return fmt.Errorf("%s must contain opaque data after %q", name, prefix)
default:
return nil
}
}
+236
View File
@@ -0,0 +1,236 @@
package policy
import (
"testing"
"time"
"galaxy/user/internal/domain/common"
"github.com/stretchr/testify/require"
)
func TestSanctionRecordValidateAt(t *testing.T) {
t.Parallel()
now := time.Unix(1_775_240_000, 0).UTC()
expiresAt := now.Add(time.Hour)
removedAt := now.Add(30 * time.Minute)
tests := []struct {
name string
record SanctionRecord
wantErr bool
wantActive bool
}{
{
name: "active",
record: SanctionRecord{
RecordID: SanctionRecordID("sanction-1"),
UserID: common.UserID("user-123"),
SanctionCode: SanctionCodeLoginBlock,
Scope: common.Scope("auth"),
ReasonCode: common.ReasonCode("policy_blocked"),
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
AppliedAt: now.Add(-time.Minute),
ExpiresAt: &expiresAt,
},
wantActive: true,
},
{
name: "expired",
record: SanctionRecord{
RecordID: SanctionRecordID("sanction-1"),
UserID: common.UserID("user-123"),
SanctionCode: SanctionCodeLoginBlock,
Scope: common.Scope("auth"),
ReasonCode: common.ReasonCode("policy_blocked"),
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
AppliedAt: now.Add(-2 * time.Hour),
ExpiresAt: ptrTime(now.Add(-time.Minute)),
},
},
{
name: "removed",
record: SanctionRecord{
RecordID: SanctionRecordID("sanction-1"),
UserID: common.UserID("user-123"),
SanctionCode: SanctionCodeLoginBlock,
Scope: common.Scope("auth"),
ReasonCode: common.ReasonCode("policy_blocked"),
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
AppliedAt: now.Add(-time.Hour),
RemovedAt: &removedAt,
RemovedBy: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-2")},
RemovedReasonCode: common.ReasonCode("manual_remove"),
},
},
{
name: "future applied at",
record: SanctionRecord{
RecordID: SanctionRecordID("sanction-1"),
UserID: common.UserID("user-123"),
SanctionCode: SanctionCodeLoginBlock,
Scope: common.Scope("auth"),
ReasonCode: common.ReasonCode("policy_blocked"),
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
AppliedAt: now.Add(time.Minute),
},
wantErr: true,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
err := tt.record.ValidateAt(now)
if tt.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
require.Equal(t, tt.wantActive, tt.record.IsActiveAt(now))
})
}
}
func TestActiveSanctionsAt(t *testing.T) {
t.Parallel()
now := time.Unix(1_775_240_000, 0).UTC()
records := []SanctionRecord{
{
RecordID: SanctionRecordID("sanction-1"),
UserID: common.UserID("user-123"),
SanctionCode: SanctionCodeProfileUpdateBlock,
Scope: common.Scope("profile"),
ReasonCode: common.ReasonCode("moderation"),
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
AppliedAt: now.Add(-time.Hour),
},
{
RecordID: SanctionRecordID("sanction-2"),
UserID: common.UserID("user-123"),
SanctionCode: SanctionCodeLoginBlock,
Scope: common.Scope("auth"),
ReasonCode: common.ReasonCode("policy"),
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-2")},
AppliedAt: now.Add(-2 * time.Hour),
ExpiresAt: ptrTime(now.Add(-time.Minute)),
},
}
active, err := ActiveSanctionsAt(records, now)
require.NoError(t, err)
require.Len(t, active, 1)
require.Equal(t, SanctionCodeProfileUpdateBlock, active[0].SanctionCode)
}
func TestActiveSanctionsAtDuplicateActiveCode(t *testing.T) {
t.Parallel()
now := time.Unix(1_775_240_000, 0).UTC()
_, err := ActiveSanctionsAt([]SanctionRecord{
{
RecordID: SanctionRecordID("sanction-1"),
UserID: common.UserID("user-123"),
SanctionCode: SanctionCodeLoginBlock,
Scope: common.Scope("auth"),
ReasonCode: common.ReasonCode("policy"),
Actor: common.ActorRef{Type: common.ActorType("admin")},
AppliedAt: now.Add(-time.Hour),
},
{
RecordID: SanctionRecordID("sanction-2"),
UserID: common.UserID("user-123"),
SanctionCode: SanctionCodeLoginBlock,
Scope: common.Scope("auth"),
ReasonCode: common.ReasonCode("policy"),
Actor: common.ActorRef{Type: common.ActorType("admin")},
AppliedAt: now.Add(-2 * time.Hour),
},
}, now)
require.Error(t, err)
}
func TestLimitRecordValidateAtAndActiveLimits(t *testing.T) {
t.Parallel()
now := time.Unix(1_775_240_000, 0).UTC()
record := LimitRecord{
RecordID: LimitRecordID("limit-1"),
UserID: common.UserID("user-123"),
LimitCode: LimitCodeMaxOwnedPrivateGames,
Value: 3,
ReasonCode: common.ReasonCode("manual_override"),
Actor: common.ActorRef{Type: common.ActorType("admin"), ID: common.ActorID("admin-1")},
AppliedAt: now.Add(-time.Minute),
}
require.NoError(t, record.ValidateAt(now))
require.True(t, record.IsActiveAt(now))
active, err := ActiveLimitsAt([]LimitRecord{
record,
{
RecordID: LimitRecordID("limit-2"),
UserID: common.UserID("user-123"),
LimitCode: LimitCodeMaxActivePrivateGames,
Value: 7,
ReasonCode: common.ReasonCode("manual_override"),
Actor: common.ActorRef{Type: common.ActorType("admin")},
AppliedAt: now.Add(-time.Hour),
},
}, now)
require.NoError(t, err)
require.Len(t, active, 1)
require.Equal(t, LimitCodeMaxOwnedPrivateGames, active[0].LimitCode)
}
func TestLimitCodeSupportAndRetiredRecognition(t *testing.T) {
t.Parallel()
require.True(t, LimitCodeMaxOwnedPrivateGames.IsSupported())
require.True(t, LimitCodeMaxPendingPublicApplications.IsSupported())
require.True(t, LimitCodeMaxActiveGameMemberships.IsSupported())
require.True(t, LimitCodeMaxActivePrivateGames.IsRetired())
require.True(t, LimitCodeMaxPendingPrivateJoinRequests.IsRetired())
require.True(t, LimitCodeMaxPendingPrivateInvitesSent.IsRetired())
require.True(t, LimitCodeMaxActivePrivateGames.IsRecognized())
require.False(t, LimitCode("unknown_limit").IsRecognized())
require.False(t, LimitCodeMaxActivePrivateGames.IsKnown())
}
func TestActiveLimitsAtDuplicateActiveCode(t *testing.T) {
t.Parallel()
now := time.Unix(1_775_240_000, 0).UTC()
_, err := ActiveLimitsAt([]LimitRecord{
{
RecordID: LimitRecordID("limit-1"),
UserID: common.UserID("user-123"),
LimitCode: LimitCodeMaxOwnedPrivateGames,
Value: 2,
ReasonCode: common.ReasonCode("manual_override"),
Actor: common.ActorRef{Type: common.ActorType("admin")},
AppliedAt: now.Add(-time.Hour),
},
{
RecordID: LimitRecordID("limit-2"),
UserID: common.UserID("user-123"),
LimitCode: LimitCodeMaxOwnedPrivateGames,
Value: 5,
ReasonCode: common.ReasonCode("manual_override"),
Actor: common.ActorRef{Type: common.ActorType("admin")},
AppliedAt: now.Add(-2 * time.Hour),
},
}, now)
require.Error(t, err)
}
func ptrTime(value time.Time) *time.Time {
return &value
}
+43
View File
@@ -0,0 +1,43 @@
// Package logging configures the user-service process logger and provides
// context-aware helpers for attaching OpenTelemetry trace identifiers.
package logging
import (
"context"
"fmt"
"log/slog"
"os"
"strings"
"go.opentelemetry.io/otel/trace"
)
// New constructs the process-wide JSON logger from level.
func New(level string) (*slog.Logger, error) {
var slogLevel slog.Level
if err := slogLevel.UnmarshalText([]byte(strings.TrimSpace(level))); err != nil {
return nil, fmt.Errorf("build logger: %w", err)
}
return slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: slogLevel,
})), nil
}
// TraceAttrsFromContext returns slog key-value pairs for the active
// OpenTelemetry span when ctx carries a valid span context.
func TraceAttrsFromContext(ctx context.Context) []any {
if ctx == nil {
return nil
}
spanContext := trace.SpanContextFromContext(ctx)
if !spanContext.IsValid() {
return nil
}
return []any{
"otel_trace_id", spanContext.TraceID().String(),
"otel_span_id", spanContext.SpanID().String(),
}
}

Some files were not shown because too many files have changed in this diff Show More