mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-04-25 20:42:13 +00:00
439 lines
18 KiB
Haskell
439 lines
18 KiB
Haskell
{-# LANGUAGE DataKinds #-}
|
|
{-# LANGUAGE DuplicateRecordFields #-}
|
|
{-# LANGUAGE FlexibleInstances #-}
|
|
{-# LANGUAGE GADTs #-}
|
|
{-# LANGUAGE KindSignatures #-}
|
|
{-# LANGUAGE LambdaCase #-}
|
|
{-# LANGUAGE MultiWayIf #-}
|
|
{-# LANGUAGE NamedFieldPuns #-}
|
|
{-# LANGUAGE OverloadedLists #-}
|
|
{-# LANGUAGE OverloadedStrings #-}
|
|
{-# LANGUAGE ScopedTypeVariables #-}
|
|
{-# LANGUAGE StandaloneDeriving #-}
|
|
{-# LANGUAGE TemplateHaskell #-}
|
|
{-# LANGUAGE TupleSections #-}
|
|
{-# LANGUAGE TypeApplications #-}
|
|
{-# OPTIONS_GHC -fno-warn-ambiguous-fields #-}
|
|
|
|
module Simplex.Chat.Operators where
|
|
|
|
import Control.Applicative ((<|>))
|
|
import Data.Aeson (FromJSON (..), ToJSON (..))
|
|
import qualified Data.Aeson as J
|
|
import qualified Data.Aeson.Encoding as JE
|
|
import qualified Data.Aeson.TH as JQ
|
|
import Data.FileEmbed
|
|
import Data.Foldable (foldMap')
|
|
import Data.IORef
|
|
import Data.Int (Int64)
|
|
import Data.List (find, foldl')
|
|
import Data.List.NonEmpty (NonEmpty)
|
|
import qualified Data.List.NonEmpty as L
|
|
import Data.Map.Strict (Map)
|
|
import qualified Data.Map.Strict as M
|
|
import Data.Maybe (fromMaybe, isNothing, mapMaybe)
|
|
import Data.Scientific (floatingOrInteger)
|
|
import Data.Set (Set)
|
|
import qualified Data.Set as S
|
|
import Data.Text (Text)
|
|
import qualified Data.Text as T
|
|
import Data.Time (addUTCTime)
|
|
import Data.Time.Clock (UTCTime, nominalDay)
|
|
import Database.SQLite.Simple.FromField (FromField (..))
|
|
import Database.SQLite.Simple.ToField (ToField (..))
|
|
import Language.Haskell.TH.Syntax (lift)
|
|
import Simplex.Chat.Operators.Conditions
|
|
import Simplex.Chat.Types.Util (textParseJSON)
|
|
import Simplex.Messaging.Agent.Env.SQLite (ServerCfg (..), ServerRoles (..), allRoles)
|
|
import Simplex.Messaging.Encoding.String
|
|
import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, fromTextField_, sumTypeJSON)
|
|
import Simplex.Messaging.Protocol (AProtoServerWithAuth (..), AProtocolType (..), ProtoServerWithAuth (..), ProtocolServer (..), ProtocolType (..), ProtocolTypeI, SProtocolType (..), UserProtocol)
|
|
import Simplex.Messaging.Transport.Client (TransportHost (..))
|
|
import Simplex.Messaging.Util (atomicModifyIORef'_, safeDecodeUtf8)
|
|
|
|
usageConditionsCommit :: Text
|
|
usageConditionsCommit = "a5061f3147165a05979d6ace33960aced2d6ac03"
|
|
|
|
previousConditionsCommit :: Text
|
|
previousConditionsCommit = "11a44dc1fd461a93079f897048b46998db55da5c"
|
|
|
|
usageConditionsText :: Text
|
|
usageConditionsText =
|
|
$( let s = $(embedFile =<< makeRelativeToProject "PRIVACY.md")
|
|
in [|stripFrontMatter $(lift (safeDecodeUtf8 s))|]
|
|
)
|
|
|
|
data DBStored = DBStored | DBNew
|
|
|
|
data SDBStored (s :: DBStored) where
|
|
SDBStored :: SDBStored 'DBStored
|
|
SDBNew :: SDBStored 'DBNew
|
|
|
|
deriving instance Show (SDBStored s)
|
|
|
|
class DBStoredI s where sdbStored :: SDBStored s
|
|
|
|
instance DBStoredI 'DBStored where sdbStored = SDBStored
|
|
|
|
instance DBStoredI 'DBNew where sdbStored = SDBNew
|
|
|
|
data DBEntityId' (s :: DBStored) where
|
|
DBEntityId :: Int64 -> DBEntityId' 'DBStored
|
|
DBNewEntity :: DBEntityId' 'DBNew
|
|
|
|
deriving instance Show (DBEntityId' s)
|
|
|
|
type DBEntityId = DBEntityId' 'DBStored
|
|
|
|
type DBNewEntity = DBEntityId' 'DBNew
|
|
|
|
data OperatorTag = OTSimplex | OTFlux
|
|
deriving (Eq, Ord, Show)
|
|
|
|
instance FromField OperatorTag where fromField = fromTextField_ textDecode
|
|
|
|
instance ToField OperatorTag where toField = toField . textEncode
|
|
|
|
instance FromJSON OperatorTag where
|
|
parseJSON = textParseJSON "OperatorTag"
|
|
|
|
instance ToJSON OperatorTag where
|
|
toJSON = J.String . textEncode
|
|
toEncoding = JE.text . textEncode
|
|
|
|
instance TextEncoding OperatorTag where
|
|
textDecode = \case
|
|
"simplex" -> Just OTSimplex
|
|
"flux" -> Just OTFlux
|
|
_ -> Nothing
|
|
textEncode = \case
|
|
OTSimplex -> "simplex"
|
|
OTFlux -> "flux"
|
|
|
|
-- this and other types only define instances of serialization for known DB IDs only,
|
|
-- entities without IDs cannot be serialized to JSON
|
|
instance FromField DBEntityId where fromField f = DBEntityId <$> fromField f
|
|
|
|
instance ToField DBEntityId where toField (DBEntityId i) = toField i
|
|
|
|
data UsageConditions = UsageConditions
|
|
{ conditionsId :: Int64,
|
|
conditionsCommit :: Text,
|
|
notifiedAt :: Maybe UTCTime,
|
|
createdAt :: UTCTime
|
|
}
|
|
deriving (Show)
|
|
|
|
data UsageConditionsAction
|
|
= UCAReview {operators :: [ServerOperator], deadline :: Maybe UTCTime, showNotice :: Bool}
|
|
| UCAAccepted {operators :: [ServerOperator]}
|
|
deriving (Show)
|
|
|
|
usageConditionsAction :: [ServerOperator] -> UsageConditions -> UTCTime -> Maybe UsageConditionsAction
|
|
usageConditionsAction operators UsageConditions {createdAt, notifiedAt} now = do
|
|
let enabledOperators = filter (\ServerOperator {enabled} -> enabled) operators
|
|
if
|
|
| null enabledOperators -> Nothing
|
|
| all conditionsAccepted enabledOperators ->
|
|
let acceptedForOperators = filter conditionsAccepted operators
|
|
in Just $ UCAAccepted acceptedForOperators
|
|
| otherwise ->
|
|
let acceptForOperators = filter (not . conditionsAccepted) enabledOperators
|
|
deadline = conditionsRequiredOrDeadline createdAt (fromMaybe now notifiedAt)
|
|
showNotice = isNothing notifiedAt
|
|
in Just $ UCAReview acceptForOperators deadline showNotice
|
|
|
|
conditionsRequiredOrDeadline :: UTCTime -> UTCTime -> Maybe UTCTime
|
|
conditionsRequiredOrDeadline createdAt notifiedAtOrNow =
|
|
if notifiedAtOrNow < addUTCTime (14 * nominalDay) createdAt
|
|
then Just $ conditionsDeadline notifiedAtOrNow
|
|
else Nothing -- required
|
|
where
|
|
conditionsDeadline :: UTCTime -> UTCTime
|
|
conditionsDeadline = addUTCTime (31 * nominalDay)
|
|
|
|
data ConditionsAcceptance
|
|
= CAAccepted {acceptedAt :: Maybe UTCTime}
|
|
| CARequired {deadline :: Maybe UTCTime}
|
|
deriving (Show)
|
|
|
|
type ServerOperator = ServerOperator' 'DBStored
|
|
|
|
type NewServerOperator = ServerOperator' 'DBNew
|
|
|
|
data AServerOperator = forall s. ASO (SDBStored s) (ServerOperator' s)
|
|
|
|
deriving instance Show AServerOperator
|
|
|
|
data ServerOperator' s = ServerOperator
|
|
{ operatorId :: DBEntityId' s,
|
|
operatorTag :: Maybe OperatorTag,
|
|
tradeName :: Text,
|
|
legalName :: Maybe Text,
|
|
serverDomains :: [Text],
|
|
conditionsAcceptance :: ConditionsAcceptance,
|
|
enabled :: Bool,
|
|
roles :: ServerRoles
|
|
}
|
|
deriving (Show)
|
|
|
|
conditionsAccepted :: ServerOperator -> Bool
|
|
conditionsAccepted ServerOperator {conditionsAcceptance} = case conditionsAcceptance of
|
|
CAAccepted {} -> True
|
|
_ -> False
|
|
|
|
data UserOperatorServers = UserOperatorServers
|
|
{ operator :: Maybe ServerOperator,
|
|
smpServers :: [UserServer 'PSMP],
|
|
xftpServers :: [UserServer 'PXFTP]
|
|
}
|
|
deriving (Show)
|
|
|
|
data UpdatedUserOperatorServers = UpdatedUserOperatorServers
|
|
{ operator :: Maybe ServerOperator,
|
|
smpServers :: [AUserServer 'PSMP],
|
|
xftpServers :: [AUserServer 'PXFTP]
|
|
}
|
|
deriving (Show)
|
|
|
|
updatedServers :: UserProtocol p => UpdatedUserOperatorServers -> SProtocolType p -> [AUserServer p]
|
|
updatedServers UpdatedUserOperatorServers {smpServers, xftpServers} = \case
|
|
SPSMP -> smpServers
|
|
SPXFTP -> xftpServers
|
|
|
|
type UserServer p = UserServer' 'DBStored p
|
|
|
|
type NewUserServer p = UserServer' 'DBNew p
|
|
|
|
data AUserServer p = forall s. AUS (SDBStored s) (UserServer' s p)
|
|
|
|
deriving instance Show (AUserServer p)
|
|
|
|
data UserServer' s p = UserServer
|
|
{ serverId :: DBEntityId' s,
|
|
server :: ProtoServerWithAuth p,
|
|
preset :: Bool,
|
|
tested :: Maybe Bool,
|
|
enabled :: Bool,
|
|
deleted :: Bool
|
|
}
|
|
deriving (Show)
|
|
|
|
data PresetOperator = PresetOperator
|
|
{ operator :: Maybe NewServerOperator,
|
|
smp :: [NewUserServer 'PSMP],
|
|
useSMP :: Int,
|
|
xftp :: [NewUserServer 'PXFTP],
|
|
useXFTP :: Int
|
|
}
|
|
|
|
operatorServers :: UserProtocol p => SProtocolType p -> PresetOperator -> [NewUserServer p]
|
|
operatorServers p PresetOperator {smp, xftp} = case p of
|
|
SPSMP -> smp
|
|
SPXFTP -> xftp
|
|
|
|
operatorServersToUse :: UserProtocol p => SProtocolType p -> PresetOperator -> Int
|
|
operatorServersToUse p PresetOperator {useSMP, useXFTP} = case p of
|
|
SPSMP -> useSMP
|
|
SPXFTP -> useXFTP
|
|
|
|
presetServer :: Bool -> ProtoServerWithAuth p -> NewUserServer p
|
|
presetServer = newUserServer_ True
|
|
|
|
newUserServer :: ProtoServerWithAuth p -> NewUserServer p
|
|
newUserServer = newUserServer_ False True
|
|
|
|
newUserServer_ :: Bool -> Bool -> ProtoServerWithAuth p -> NewUserServer p
|
|
newUserServer_ preset enabled server =
|
|
UserServer {serverId = DBNewEntity, server, preset, tested = Nothing, enabled, deleted = False}
|
|
|
|
-- This function should be used inside DB transaction to update conditions in the database
|
|
-- it evaluates to (conditions to mark as accepted to SimpleX operator, current conditions, and conditions to add)
|
|
usageConditionsToAdd :: Bool -> UTCTime -> [UsageConditions] -> (Maybe UsageConditions, UsageConditions, [UsageConditions])
|
|
usageConditionsToAdd = usageConditionsToAdd' previousConditionsCommit usageConditionsCommit
|
|
|
|
-- This function is used in unit tests
|
|
usageConditionsToAdd' :: Text -> Text -> Bool -> UTCTime -> [UsageConditions] -> (Maybe UsageConditions, UsageConditions, [UsageConditions])
|
|
usageConditionsToAdd' prevCommit sourceCommit newUser createdAt = \case
|
|
[]
|
|
| newUser -> (Just sourceCond, sourceCond, [sourceCond])
|
|
| otherwise -> (Just prevCond, sourceCond, [prevCond, sourceCond])
|
|
where
|
|
prevCond = conditions 1 prevCommit
|
|
sourceCond = conditions 2 sourceCommit
|
|
conds
|
|
| hasSourceCond -> (Nothing, last conds, [])
|
|
| otherwise -> (Nothing, sourceCond, [sourceCond])
|
|
where
|
|
hasSourceCond = any ((sourceCommit ==) . conditionsCommit) conds
|
|
sourceCond = conditions cId sourceCommit
|
|
cId = maximum (map conditionsId conds) + 1
|
|
where
|
|
conditions cId commit = UsageConditions {conditionsId = cId, conditionsCommit = commit, notifiedAt = Nothing, createdAt}
|
|
|
|
-- This function should be used inside DB transaction to update operators.
|
|
-- It allows to add/remove/update preset operators in the database preserving enabled and roles settings,
|
|
-- and preserves custom operators without tags for forward compatibility.
|
|
updatedServerOperators :: NonEmpty PresetOperator -> [ServerOperator] -> [AServerOperator]
|
|
updatedServerOperators presetOps storedOps =
|
|
foldr addPreset [] presetOps
|
|
<> map (ASO SDBStored) (filter (isNothing . operatorTag) storedOps)
|
|
where
|
|
-- TODO remove domains of preset operators from custom
|
|
addPreset PresetOperator {operator} = case operator of
|
|
Nothing -> id
|
|
Just presetOp -> (storedOp' :)
|
|
where
|
|
storedOp' = case find ((operatorTag presetOp ==) . operatorTag) storedOps of
|
|
Just ServerOperator {operatorId, conditionsAcceptance, enabled, roles} ->
|
|
ASO SDBStored presetOp {operatorId, conditionsAcceptance, enabled, roles}
|
|
Nothing -> ASO SDBNew presetOp
|
|
|
|
-- This function should be used inside DB transaction to update servers.
|
|
updatedUserServers :: forall p. UserProtocol p => SProtocolType p -> NonEmpty PresetOperator -> NonEmpty (NewUserServer p) -> [UserServer p] -> NonEmpty (AUserServer p)
|
|
updatedUserServers _ _ randomSrvs [] = L.map (AUS SDBNew) randomSrvs
|
|
updatedUserServers p presetOps randomSrvs srvs =
|
|
fromMaybe (L.map (AUS SDBNew) randomSrvs) (L.nonEmpty updatedSrvs)
|
|
where
|
|
updatedSrvs = map userServer presetSrvs <> map (AUS SDBStored) (filter customServer srvs)
|
|
storedSrvs :: Map (ProtoServerWithAuth p) (UserServer p)
|
|
storedSrvs = foldl' (\ss srv@UserServer {server} -> M.insert server srv ss) M.empty srvs
|
|
customServer :: UserServer p -> Bool
|
|
customServer srv = not (preset srv) && all (`S.notMember` presetHosts) (srvHost srv)
|
|
presetSrvs :: [NewUserServer p]
|
|
presetSrvs = concatMap (operatorServers p) presetOps
|
|
presetHosts :: Set TransportHost
|
|
presetHosts = foldMap' (S.fromList . L.toList . srvHost) presetSrvs
|
|
userServer :: NewUserServer p -> AUserServer p
|
|
userServer srv@UserServer {server} = maybe (AUS SDBNew srv) (AUS SDBStored) (M.lookup server storedSrvs)
|
|
|
|
srvHost :: UserServer' s p -> NonEmpty TransportHost
|
|
srvHost UserServer {server = ProtoServerWithAuth srv _} = host srv
|
|
|
|
agentServerCfgs :: [(Text, ServerOperator)] -> NonEmpty (NewUserServer p) -> [UserServer' s p] -> NonEmpty (ServerCfg p)
|
|
agentServerCfgs opDomains randomSrvs =
|
|
fromMaybe fallbackSrvs . L.nonEmpty . mapMaybe enabledOpAgentServer
|
|
where
|
|
fallbackSrvs = L.map (snd . agentServer) randomSrvs
|
|
enabledOpAgentServer srv =
|
|
let (opEnabled, srvCfg) = agentServer srv
|
|
in if opEnabled then Just srvCfg else Nothing
|
|
agentServer :: UserServer' s p -> (Bool, ServerCfg p)
|
|
agentServer srv@UserServer {server, enabled} =
|
|
case find (\(d, _) -> any (matchingHost d) (srvHost srv)) opDomains of
|
|
Just (_, ServerOperator {operatorId = DBEntityId opId, enabled = opEnabled, roles}) ->
|
|
(opEnabled, ServerCfg {server, enabled, operator = Just opId, roles})
|
|
Nothing ->
|
|
(True, ServerCfg {server, enabled, operator = Nothing, roles = allRoles})
|
|
|
|
matchingHost :: Text -> TransportHost -> Bool
|
|
matchingHost d = \case
|
|
THDomainName h -> d `T.isSuffixOf` T.pack h
|
|
_ -> False
|
|
|
|
operatorDomains :: [ServerOperator] -> [(Text, ServerOperator)]
|
|
operatorDomains = foldr (\op ds -> foldr (\d -> ((d, op) :)) ds (serverDomains op)) []
|
|
|
|
groupByOperator :: ([ServerOperator], [UserServer 'PSMP], [UserServer 'PXFTP]) -> IO [UserOperatorServers]
|
|
groupByOperator (ops, smpSrvs, xftpSrvs) = do
|
|
ss <- mapM (\op -> (serverDomains op,) <$> newIORef (UserOperatorServers (Just op) [] [])) ops
|
|
custom <- newIORef $ UserOperatorServers Nothing [] []
|
|
mapM_ (addServer ss custom addSMP) (reverse smpSrvs)
|
|
mapM_ (addServer ss custom addXFTP) (reverse xftpSrvs)
|
|
opSrvs <- mapM (readIORef . snd) ss
|
|
customSrvs <- readIORef custom
|
|
pure $ opSrvs <> [customSrvs]
|
|
where
|
|
addServer :: [([Text], IORef UserOperatorServers)] -> IORef UserOperatorServers -> (UserServer p -> UserOperatorServers -> UserOperatorServers) -> UserServer p -> IO ()
|
|
addServer ss custom add srv =
|
|
let v = maybe custom snd $ find (\(ds, _) -> any (\d -> any (matchingHost d) (srvHost srv)) ds) ss
|
|
in atomicModifyIORef'_ v $ add srv
|
|
addSMP srv s@UserOperatorServers {smpServers} = (s :: UserOperatorServers) {smpServers = srv : smpServers}
|
|
addXFTP srv s@UserOperatorServers {xftpServers} = (s :: UserOperatorServers) {xftpServers = srv : xftpServers}
|
|
|
|
data UserServersError
|
|
= USEStorageMissing {protocol :: AProtocolType}
|
|
| USEProxyMissing {protocol :: AProtocolType}
|
|
| USEDuplicateServer {protocol :: AProtocolType, duplicateServer :: AProtoServerWithAuth, duplicateHost :: TransportHost}
|
|
deriving (Show)
|
|
|
|
validateUserServers :: NonEmpty UpdatedUserOperatorServers -> [UserServersError]
|
|
validateUserServers uss =
|
|
missingRolesErr SPSMP storage USEStorageMissing
|
|
<> missingRolesErr SPSMP proxy USEProxyMissing
|
|
<> missingRolesErr SPXFTP storage USEStorageMissing
|
|
<> duplicatServerErrs SPSMP
|
|
<> duplicatServerErrs SPXFTP
|
|
where
|
|
missingRolesErr :: (ProtocolTypeI p, UserProtocol p) => SProtocolType p -> (ServerRoles -> Bool) -> (AProtocolType -> UserServersError) -> [UserServersError]
|
|
missingRolesErr p roleSel err = [err (AProtocolType p) | not hasRole]
|
|
where
|
|
hasRole =
|
|
any (\(AUS _ UserServer {deleted, enabled}) -> enabled && not deleted) $
|
|
concatMap (`updatedServers` p) $ filter roleEnabled (L.toList uss)
|
|
roleEnabled UpdatedUserOperatorServers {operator} =
|
|
maybe True (\ServerOperator {enabled, roles} -> enabled && roleSel roles) operator
|
|
duplicatServerErrs :: (ProtocolTypeI p, UserProtocol p) => SProtocolType p -> [UserServersError]
|
|
duplicatServerErrs p = mapMaybe duplicateErr_ srvs
|
|
where
|
|
srvs =
|
|
filter (\(AUS _ UserServer {deleted}) -> not deleted) $
|
|
concatMap (`updatedServers` p) (L.toList uss)
|
|
duplicateErr_ (AUS _ srv@UserServer {server}) =
|
|
USEDuplicateServer (AProtocolType p) (AProtoServerWithAuth p server)
|
|
<$> find (`S.member` duplicateHosts) (srvHost srv)
|
|
duplicateHosts = snd $ foldl' addHost (S.empty, S.empty) allHosts
|
|
allHosts = concatMap (\(AUS _ srv) -> L.toList $ srvHost srv) srvs
|
|
addHost (hs, dups) h
|
|
| h `S.member` hs = (hs, S.insert h dups)
|
|
| otherwise = (S.insert h hs, dups)
|
|
|
|
instance ToJSON (DBEntityId' s) where
|
|
toEncoding = \case
|
|
DBEntityId i -> toEncoding i
|
|
DBNewEntity -> JE.null_
|
|
toJSON = \case
|
|
DBEntityId i -> toJSON i
|
|
DBNewEntity -> J.Null
|
|
|
|
instance DBStoredI s => FromJSON (DBEntityId' s) where
|
|
parseJSON v = case (v, sdbStored @s) of
|
|
(J.Null, SDBNew) -> pure DBNewEntity
|
|
(J.Number n, SDBStored) -> case floatingOrInteger n of
|
|
Left (_ :: Double) -> fail "bad DBEntityId"
|
|
Right i -> pure $ DBEntityId (fromInteger i)
|
|
_ -> fail "bad DBEntityId"
|
|
omittedField = case sdbStored @s of
|
|
SDBStored -> Nothing
|
|
SDBNew -> Just DBNewEntity
|
|
|
|
$(JQ.deriveJSON defaultJSON ''UsageConditions)
|
|
|
|
$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "CA") ''ConditionsAcceptance)
|
|
|
|
instance ToJSON (ServerOperator' s) where
|
|
toEncoding = $(JQ.mkToEncoding defaultJSON ''ServerOperator')
|
|
toJSON = $(JQ.mkToJSON defaultJSON ''ServerOperator')
|
|
|
|
instance DBStoredI s => FromJSON (ServerOperator' s) where
|
|
parseJSON = $(JQ.mkParseJSON defaultJSON ''ServerOperator')
|
|
|
|
$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "UCA") ''UsageConditionsAction)
|
|
|
|
instance ProtocolTypeI p => ToJSON (UserServer' s p) where
|
|
toEncoding = $(JQ.mkToEncoding defaultJSON ''UserServer')
|
|
toJSON = $(JQ.mkToJSON defaultJSON ''UserServer')
|
|
|
|
instance (DBStoredI s, ProtocolTypeI p) => FromJSON (UserServer' s p) where
|
|
parseJSON = $(JQ.mkParseJSON defaultJSON ''UserServer')
|
|
|
|
instance ProtocolTypeI p => FromJSON (AUserServer p) where
|
|
parseJSON v = (AUS SDBStored <$> parseJSON v) <|> (AUS SDBNew <$> parseJSON v)
|
|
|
|
$(JQ.deriveJSON defaultJSON ''UserOperatorServers)
|
|
|
|
instance FromJSON UpdatedUserOperatorServers where
|
|
parseJSON = $(JQ.mkParseJSON defaultJSON ''UpdatedUserOperatorServers)
|
|
|
|
$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "USE") ''UserServersError)
|