package userstore import ( "encoding/base64" "encoding/json" "errors" "fmt" "time" "galaxy/user/internal/domain/common" "galaxy/user/internal/domain/entitlement" "galaxy/user/internal/domain/policy" "galaxy/user/internal/ports" ) // errPageTokenFiltersMismatch reports that a supplied page token was created // for a different normalised filter set. Callers translate it to // ports.ErrInvalidPageToken on the boundary. var errPageTokenFiltersMismatch = errors.New("page token filters do not match current filters") // pageCursor identifies the last (created_at, user_id) tuple visible on the // previous listing page. The cursor is paired with a normalised filter // fingerprint so the token cannot be replayed across a different filter set. type pageCursor struct { CreatedAt time.Time UserID common.UserID } 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 } // userListFilters mirrors ports.UserListFilters but excludes the fields that // only the service layer enforces (display_name match, user_name) so token // replay across a UI re-render that toggles a UI-only filter does not // invalidate the cursor. type userListFilters struct { PaidState entitlement.PaidState PaidExpiresBefore *time.Time PaidExpiresAfter *time.Time DeclaredCountry common.CountryCode SanctionCode policy.SanctionCode LimitCode policy.LimitCode CanLogin *bool CanCreatePrivateGame *bool CanJoinGame *bool } // userListFiltersFromPorts copies the listing-stable subset of port-level // filters into the form embedded into the page token fingerprint. func userListFiltersFromPorts(filters ports.UserListFilters) userListFilters { return 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 (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 + filters into the frozen opaque page token // shape used by the trusted admin listing surface. The encoding is identical // to the previous Redis implementation so existing public clients can keep // using their stored tokens through the migration cut-over. func encodePageToken(cursor pageCursor, filters userListFilters) (string, error) { if err := cursor.Validate(); err != nil { return "", fmt.Errorf("encode page token: %w", err) } fingerprint, err := normaliseFilters(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 parses raw and verifies the embedded fingerprint matches // expected. The token's wire format is preserved across the Redis-to- // PostgreSQL adapter swap. func decodePageToken(raw string, expected userListFilters) (pageCursor, error) { fingerprint, err := normaliseFilters(expected) 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 normalisedFilterFields `json:"filters"` } type normalisedFilterFields 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 normaliseFilters(filters userListFilters) (normalisedFilterFields, error) { if err := filters.Validate(); err != nil { return normalisedFilterFields{}, err } return normalisedFilterFields{ PaidState: string(filters.PaidState), PaidExpiresBeforeUTC: formatOptionalUTC(filters.PaidExpiresBefore), PaidExpiresAfterUTC: formatOptionalUTC(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 formatOptionalUTC(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" }