// Package userservice provides the HTTP adapter for the // ports.UserService eligibility port. It wraps the trusted // User Service internal endpoint // `GET /api/v1/internal/users/{user_id}/eligibility` and decodes the // response into the lobby-side ports.Eligibility shape. package userservice import ( "context" "encoding/json" "errors" "fmt" "net/http" "net/url" "strings" "time" "galaxy/lobby/internal/ports" "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" ) // permanentBlockSanctionCode mirrors policy.SanctionCodePermanentBlock in // galaxy/user. The lobby adapter inspects the active_sanctions array for // this string to populate Eligibility.PermanentBlocked without taking a // build-time dependency on the user module. const permanentBlockSanctionCode = "permanent_block" // maxRegisteredRaceNamesLimitCode mirrors // policy.LimitCodeMaxRegisteredRaceNames in galaxy/user. A snapshot value // of 0 denotes unlimited per the lifetime tariff. const maxRegisteredRaceNamesLimitCode = "max_registered_race_names" // Client implements ports.UserService against the trusted internal HTTP // surface of User Service. type Client struct { baseURL string httpClient *http.Client } // Config groups the construction parameters of Client. type Config struct { // BaseURL is the absolute root URL of User Service (no trailing slash // required). The eligibility path is appended on every call. BaseURL string // Timeout bounds one round trip including TLS handshake. It must be // positive. Timeout time.Duration } // Validate reports whether cfg stores a usable Client configuration. func (cfg Config) Validate() error { switch { case strings.TrimSpace(cfg.BaseURL) == "": return errors.New("user service base url must not be empty") case cfg.Timeout <= 0: return errors.New("user service timeout must be positive") default: return nil } } // NewClient constructs a Client from cfg. Transport is wrapped with // otelhttp.NewTransport so traces propagate to User Service. func NewClient(cfg Config) (*Client, error) { if err := cfg.Validate(); err != nil { return nil, fmt.Errorf("new user service client: %w", err) } httpClient := &http.Client{ Timeout: cfg.Timeout, Transport: otelhttp.NewTransport(http.DefaultTransport), } return &Client{ baseURL: strings.TrimRight(cfg.BaseURL, "/"), httpClient: httpClient, }, nil } // rawEligibility mirrors the lobby-relevant subset of // lobbyeligibility.GetUserEligibilityResult. Unknown JSON fields are // ignored intentionally so future user-side additions do not break the // lobby decoder; see the decision record for context. type rawEligibility struct { Exists bool `json:"exists"` Markers rawMarkers `json:"markers"` ActiveSanctions []rawSanction `json:"active_sanctions"` EffectiveLimits []rawLimit `json:"effective_limits"` } type rawMarkers struct { CanLogin bool `json:"can_login"` CanCreatePrivateGame bool `json:"can_create_private_game"` CanManagePrivateGame bool `json:"can_manage_private_game"` CanJoinGame bool `json:"can_join_game"` CanUpdateProfile bool `json:"can_update_profile"` } type rawSanction struct { SanctionCode string `json:"sanction_code"` } type rawLimit struct { LimitCode string `json:"limit_code"` Value int `json:"value"` } // GetEligibility issues GET /api/v1/internal/users/{user_id}/eligibility // and decodes the response into a ports.Eligibility value. HTTP 404 is // treated as a present-but-missing user (Exists=false). Transport errors, // timeouts, and unexpected statuses surface as ports.ErrUserServiceUnavailable. func (client *Client) GetEligibility(ctx context.Context, userID string) (ports.Eligibility, error) { if client == nil || client.httpClient == nil { return ports.Eligibility{}, errors.New("get eligibility: nil client") } if ctx == nil { return ports.Eligibility{}, errors.New("get eligibility: nil context") } trimmed := strings.TrimSpace(userID) if trimmed == "" { return ports.Eligibility{}, errors.New("get eligibility: user id must not be empty") } endpoint := client.baseURL + "/api/v1/internal/users/" + url.PathEscape(trimmed) + "/eligibility" req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) if err != nil { return ports.Eligibility{}, fmt.Errorf("get eligibility: %w", err) } req.Header.Set("Accept", "application/json") resp, err := client.httpClient.Do(req) if err != nil { return ports.Eligibility{}, fmt.Errorf("get eligibility: %w", errors.Join(ports.ErrUserServiceUnavailable, err)) } defer resp.Body.Close() switch { case resp.StatusCode == http.StatusNotFound: return ports.Eligibility{Exists: false}, nil case resp.StatusCode < 200 || resp.StatusCode >= 300: return ports.Eligibility{}, fmt.Errorf( "get eligibility: unexpected status %d: %w", resp.StatusCode, ports.ErrUserServiceUnavailable, ) } var raw rawEligibility if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil { return ports.Eligibility{}, fmt.Errorf("get eligibility: decode body: %w", err) } return ports.Eligibility{ Exists: raw.Exists, CanLogin: raw.Markers.CanLogin, CanCreatePrivateGame: raw.Markers.CanCreatePrivateGame, CanManagePrivateGame: raw.Markers.CanManagePrivateGame, CanJoinGame: raw.Markers.CanJoinGame, CanUpdateProfile: raw.Markers.CanUpdateProfile, PermanentBlocked: containsSanction(raw.ActiveSanctions, permanentBlockSanctionCode), MaxRegisteredRaceNames: lookupLimit(raw.EffectiveLimits, maxRegisteredRaceNamesLimitCode), }, nil } func containsSanction(records []rawSanction, code string) bool { for _, record := range records { if record.SanctionCode == code { return true } } return false } func lookupLimit(records []rawLimit, code string) int { for _, record := range records { if record.LimitCode == code { return record.Value } } return 0 } // Compile-time interface assertion. var _ ports.UserService = (*Client)(nil)