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
+34 -4
View File
@@ -23,6 +23,8 @@ Optional integrations:
- `GATEWAY_ADMIN_HTTP_ADDR` enables the private `/metrics` listener;
- `GATEWAY_AUTH_SERVICE_BASE_URL` enables real public auth handling through
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`.
Operational caveats:
@@ -118,6 +120,10 @@ The public auth JSON contract uses a challenge-token flow:
key for the device session being created.
`time_zone` is the client-selected IANA time zone name forwarded unchanged to
`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
`AuthServiceClient`.
@@ -322,10 +328,24 @@ The authenticated transport uses a split contract:
- signatures are computed over canonical envelope fields and a hash of raw
FlatBuffers bytes.
The gateway treats authenticated request `payload_bytes` as opaque business
data.
It verifies integrity and forwards verified bytes downstream without rewriting
them.
The gateway verifies authenticated payload bytes before any downstream call.
Most downstream routes may still treat those bytes as opaque, but the gateway
is also allowed to transcode verified FlatBuffers payloads into trusted
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`.
`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
`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
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
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
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
Upon user's device/session registration flow, `preferred_language` value
must be obtained via existing [geoip](../pkg/geoip) package by returned Country.
When geoip feils to return country by ip, fallback is `en` language.
must be obtained via existing [geoip](../pkg/geoip) package by returned
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/config"
"galaxy/gateway/internal/downstream"
"galaxy/gateway/internal/downstream/userservice"
"galaxy/gateway/internal/events"
"galaxy/gateway/internal/grpcapi"
"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 {
return errors.Join(
fallbackSessionCache.Close(),
replayStore.Close(),
sessionSubscriber.Close(),
clientEventSubscriber.Close(),
closeUserServiceRoutes(),
)
}
@@ -227,7 +243,7 @@ func newAuthenticatedGRPCDependencies(ctx context.Context, cfg config.Config, lo
return grpcapi.ServerDependencies{
Service: grpcapi.NewFanOutPushStreamService(pushHub, responseSigner, nil, logger),
Router: downstream.NewStaticRouter(nil),
Router: downstream.NewStaticRouter(userRoutes),
ResponseSigner: responseSigner,
SessionCache: sessionCache,
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:
```json
+18
View File
@@ -52,6 +52,24 @@ sequenceDiagram
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
```mermaid
+4 -2
View File
@@ -55,5 +55,7 @@ Notes:
- The admin listener is optional and serves only Prometheus text metrics.
- Public auth routing stays available without an upstream adapter, but returns
`503 service_unavailable`.
- Authenticated gRPC starts with an empty static router; `ExecuteCommand`
remains `UNIMPLEMENTED` until downstream routes are injected.
- The default runtime reserves direct `user.*` authenticated self-service
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/go/protovalidate v1.1.3
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/google/flatbuffers v25.12.19+incompatible
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.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/otel v1.42.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.42.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.42.0
go.opentelemetry.io/otel/exporters/prometheus v0.64.0
go.opentelemetry.io/otel/metric v1.42.0
go.opentelemetry.io/otel/sdk v1.42.0
go.opentelemetry.io/otel/sdk/metric v1.42.0
go.opentelemetry.io/otel/trace v1.42.0
go.opentelemetry.io/otel 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/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
go.uber.org/zap v1.27.1
golang.org/x/time v0.15.0
google.golang.org/grpc v1.80.0
@@ -32,24 +32,24 @@ require (
cel.dev/expr v0.25.1 // indirect
github.com/antlr4-go/antlr/v4 v4.13.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/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/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.0 // 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.1 // indirect
github.com/goccy/go-json v0.10.5 // 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/cel-go v0.27.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/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/oasdiff/yaml v0.0.0-20260313112342-a3ea61cb4d4c // indirect
github.com/oasdiff/yaml3 v0.0.0-20260224194419-61cd415a242b // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // 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.19.2 // 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
@@ -81,12 +81,12 @@ require (
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.42.0 // 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.uber.org/multierr v1.10.0 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect
golang.org/x/arch v0.24.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/exp v0.0.0-20250813145105-42675adae3e6 // 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/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.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM=
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.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/bytedance/sonic/loader v0.5.1 h1:Ygpfa9zwRCCKSlrp5bBP/b/Xzc3VxsAW+5NIYXrOOpI=
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=
@@ -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/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.134.0 h1:/L5+1+kfe6dXh8Ot/wqiTgUkjOIEJiC0bbYVziHB8rU=
github.com/getkin/kin-openapi v0.134.0/go.mod h1:wK6ZLG/VgoETO9pcLJ/VmAtIcl/DNlMayNTb716EUxE=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/getkin/kin-openapi v0.135.0 h1:751SjYfbiwqukYuVjwYEIKNfrSwS5YpA7DZnKSwQgtg=
github.com/gin-contrib/sse v1.1.1 h1:uGYpNwTacv5R68bSGMapo62iLTRa9l5zxGCps4hK6ko=
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=
@@ -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/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.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
github.com/go-playground/validator/v10 v10.30.2 h1:JiFIMtSSHb2/XBUbWM4i/MpeQm9ZK2xqPNk8vgvu5JQ=
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.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
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=
@@ -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/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.0-20260313112342-a3ea61cb4d4c h1:7ACFcSaQsrWtrH4WHHfUqE1C+f8r2uv8KGaW0jTNjus=
github.com/oasdiff/yaml v0.0.0-20260313112342-a3ea61cb4d4c/go.mod h1:JKox4Gszkxt57kj27u7rvi7IFoIULvCZHUsBTUmQM/s=
github.com/oasdiff/yaml3 v0.0.0-20260224194419-61cd415a242b h1:vivRhVUAa9t1q0Db4ZmezBP8pWQWnXHFokZj0AOea2g=
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/oasdiff/yaml v0.0.9 h1:zQOvd2UKoozsSsAknnWoDJlSK4lC0mpmjfDsfqNwX48=
github.com/oasdiff/yaml3 v0.0.9 h1:rWPrKccrdUm8J0F3sGuU+fuh9+1K/RdJlWF7O/9yw2g=
github.com/pelletier/go-toml/v2 v2.3.0 h1:k59bC/lIZREW0/iVaQR8nDHxVq8OVlIzYCOJf421CaM=
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=
@@ -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/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.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc=
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=
@@ -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.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.67.0 h1:E7DmskpIO7ZR6QI6zKSEKIDNUYoKw9oHXP23gzbCdU0=
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/github.com/gin-gonic/gin/otelgin v0.68.0 h1:5FXSL2s6afUC1bzNzl1iedZZ8yqR7GOhbCoEXtyeK6Q=
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/propagators/b3 v1.42.0 h1:B2Pew5ufEtgkjLF+tSkXjgYZXQr9m7aCm1wLKB0URbU=
go.opentelemetry.io/contrib/propagators/b3 v1.42.0/go.mod h1:iPgUcSEF5DORW6+yNbdw/YevUy+QqJ508ncjhrRSCjc=
go.opentelemetry.io/otel v1.42.0 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho=
go.opentelemetry.io/otel v1.42.0/go.mod h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOXWkdqCc=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.42.0 h1:THuZiwpQZuHPul65w4WcwEnkX2QIuMT+UFoOrygtoJw=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.42.0/go.mod h1:J2pvYM5NGHofZ2/Ru6zw/TNWnEQp5crgyDeSrYpXkAw=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.42.0 h1:zWWrB1U6nqhS/k6zYB74CjRpuiitRtLLi68VcgmOEto=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.42.0/go.mod h1:2qXPNBX1OVRC0IwOnfo1ljoid+RD0QK3443EaqVlsOU=
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/prometheus v0.64.0 h1:g0LRDXMX/G1SEZtK8zl8Chm4K6GBwRkjPKE36LxiTYs=
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/contrib/propagators/b3 v1.43.0 h1:CETqV3QLLPTy5yNrqyMr41VnAOOD4lsRved7n4QG00A=
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 h1:88Y4s2C8oTui1LGM6bTWkw0ICGcOLCAI5l6zsD1j20k=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0 h1:RAE+JPfvEmvy+0LzyUA25/SGawPwIUbZ6u0Wug54sLc=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 h1:3iZJKlCZufyRzPzlQhUIWVmfltrXuGyfjREgGP3UUjc=
go.opentelemetry.io/otel/exporters/prometheus v0.65.0 h1:jOveH/b4lU9HT7y+Gfamf18BqlOuz2PWEvs8yM7Q6XE=
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0 h1:mS47AX77OtFfKG4vtp+84kuGSFZHTyxtXIN269vChY0=
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
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/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/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
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.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
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.24.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/arch v0.25.0 h1:qnk6Ksugpi5Bz32947rkUgDt9/s5qvqDPl/gBKdMJLE=
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/go.mod h1:4QTo5u+SEIbbKW1RacMZq1YEfOBqeXa19JeshGi+zc4=
+42
View File
@@ -45,6 +45,11 @@ const (
// public-auth delegation.
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
// private admin HTTP listener address. When it is empty, the admin listener
// remains disabled.
@@ -479,6 +484,15 @@ type AuthServiceConfig struct {
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
// metrics exposure. The listener remains disabled when Addr is empty.
type AdminHTTPConfig struct {
@@ -610,6 +624,10 @@ type Config struct {
// Session Service.
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
// exposure.
AdminHTTP AdminHTTPConfig
@@ -791,6 +809,13 @@ func DefaultAuthServiceConfig() 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
// omitted settings, and validates the resulting values.
func LoadFromEnv() (Config, error) {
@@ -799,6 +824,7 @@ func LoadFromEnv() (Config, error) {
Logging: DefaultLoggingConfig(),
PublicHTTP: DefaultPublicHTTPConfig(),
AuthService: DefaultAuthServiceConfig(),
UserService: DefaultUserServiceConfig(),
AdminHTTP: DefaultAdminHTTPConfig(),
AuthenticatedGRPC: DefaultAuthenticatedGRPCConfig(),
SessionCacheRedis: DefaultSessionCacheRedisConfig(),
@@ -856,6 +882,11 @@ func LoadFromEnv() (Config, error) {
cfg.AuthService.BaseURL = rawAuthServiceBaseURL
}
rawUserServiceBaseURL, ok := os.LookupEnv(userServiceBaseURLEnvVar)
if ok {
cfg.UserService.BaseURL = rawUserServiceBaseURL
}
rawAdminHTTPAddr, ok := os.LookupEnv(adminHTTPAddrEnvVar)
if ok {
cfg.AdminHTTP.Addr = rawAdminHTTPAddr
@@ -1124,6 +1155,17 @@ func LoadFromEnv() (Config, error) {
}
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 != "" {
cfg.AdminHTTP.Addr = addr
}
+115 -1
View File
@@ -7,6 +7,7 @@ import (
"encoding/pem"
"os"
"path/filepath"
"sync"
"testing"
"time"
@@ -14,6 +15,8 @@ import (
"github.com/stretchr/testify/require"
)
var configEnvMu sync.Mutex
func TestLoadFromEnv(t *testing.T) {
customResponseSignerPrivateKeyPEMPath := new(string)
*customResponseSignerPrivateKeyPEMPath = writeTestResponseSignerPEMFile(t)
@@ -27,6 +30,9 @@ func TestLoadFromEnv(t *testing.T) {
customAuthServiceBaseURL := new(string)
*customAuthServiceBaseURL = " http://127.0.0.1:8082/ "
customUserServiceBaseURL := new(string)
*customUserServiceBaseURL = " http://127.0.0.1:8083/ "
customAuthenticatedGRPCAddr := new(string)
*customAuthenticatedGRPCAddr = "127.0.0.1:9191"
@@ -80,6 +86,7 @@ func TestLoadFromEnv(t *testing.T) {
shutdownTimeout *string
publicHTTPAddr *string
authServiceBaseURL *string
userServiceBaseURL *string
authenticatedGRPCAddr *string
authenticatedGRPCFreshnessWindow *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",
authenticatedGRPCAddr: customAuthenticatedGRPCAddr,
@@ -368,6 +409,7 @@ func TestLoadFromEnv(t *testing.T) {
shutdownTimeoutEnvVar,
publicHTTPAddrEnvVar,
authServiceBaseURLEnvVar,
userServiceBaseURLEnvVar,
authenticatedGRPCAddrEnvVar,
authenticatedGRPCFreshnessWindowEnvVar,
sessionCacheRedisAddrEnvVar,
@@ -379,6 +421,7 @@ func TestLoadFromEnv(t *testing.T) {
setEnvValue(t, shutdownTimeoutEnvVar, tt.shutdownTimeout)
setEnvValue(t, publicHTTPAddrEnvVar, tt.publicHTTPAddr)
setEnvValue(t, authServiceBaseURLEnvVar, tt.authServiceBaseURL)
setEnvValue(t, userServiceBaseURLEnvVar, tt.userServiceBaseURL)
setEnvValue(t, authenticatedGRPCAddrEnvVar, tt.authenticatedGRPCAddr)
setEnvValue(t, authenticatedGRPCFreshnessWindowEnvVar, tt.authenticatedGRPCFreshnessWindow)
setEnvValue(t, sessionCacheRedisAddrEnvVar, tt.sessionCacheRedisAddr)
@@ -492,7 +535,7 @@ func TestLoadFromEnvOperationalSettings(t *testing.T) {
restoreEnvs(t, append(
append(
append(
append(operationalEnvVars(), sessionCacheRedisEnvVars()...),
append(append(operationalEnvVars(), authServiceBaseURLEnvVar, userServiceBaseURLEnvVar), sessionCacheRedisEnvVars()...),
sessionEventsRedisEnvVars()...,
),
clientEventsRedisEnvVars()...,
@@ -563,6 +606,8 @@ func TestLoadFromEnvAuthService(t *testing.T) {
restoreEnvs(t,
authServiceBaseURLEnvVar,
userServiceBaseURLEnvVar,
logLevelEnvVar,
sessionCacheRedisAddrEnvVar,
sessionEventsRedisStreamEnvVar,
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) {
customSessionCacheRedisAddr := new(string)
*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) {
t.Helper()
configEnvMu.Lock()
t.Cleanup(configEnvMu.Unlock)
for _, envVar := range envVars {
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{}