packages feed

plexus-protocol 0.5.0.0 → 0.5.2.0

raw patch · 7 files changed

+684/−48 lines, 7 filesdep +crypton-x509-storedep +crypton-x509-systemdep +tlsdep ~networkPVP: major bump suggested

API removals or changes: PVP suggests a major version bump

Dependencies added: crypton-x509-store, crypton-x509-system, tls

Dependency ranges changed: network

API changes (from Hackage documentation)

- Plexus: StreamError :: Text -> Provenance -> Text -> Bool -> PlexusStreamItem
- Plexus.Types: StreamError :: Text -> Provenance -> Text -> Bool -> PlexusStreamItem
+ Plexus: StreamErrorWith :: Text -> Provenance -> Text -> Bool -> Maybe Refusal -> PlexusStreamItem
+ Plexus: TlsOptions :: [FilePath] -> TlsOptions
+ Plexus: [itemErrorRefusal] :: PlexusStreamItem -> Maybe Refusal
+ Plexus: [methodExamples] :: MethodSchema -> [MethodExample]
+ Plexus: [methodGrant] :: MethodSchema -> Maybe Text
+ Plexus: [methodGroup] :: MethodSchema -> Maybe Text
+ Plexus: [methodHeldOn] :: MethodSchema -> Maybe [Text]
+ Plexus: [methodLongDescription] :: MethodSchema -> Maybe Text
+ Plexus: [methodPublic] :: MethodSchema -> Bool
+ Plexus: [methodTarget] :: MethodSchema -> Maybe Text
+ Plexus: [psRoles] :: PluginSchema -> Maybe (Map Text [Text])
+ Plexus: [psView] :: PluginSchema -> Maybe View
+ Plexus: [substrateTls] :: SubstrateConfig -> Maybe TlsOptions
+ Plexus: [tlsCaFiles] :: TlsOptions -> [FilePath]
+ Plexus: data TlsOptions
+ Plexus: defaultTlsOptions :: TlsOptions
+ Plexus: pattern StreamError :: Text -> Provenance -> Text -> Bool -> PlexusStreamItem
+ Plexus.Auth: Anonymous :: View
+ Plexus.Auth: AuthInfo :: [Text] -> Maybe Text -> AuthInfo
+ Plexus.Auth: BackendInfo :: Text -> Maybe View -> Maybe Text -> Maybe AuthInfo -> BackendInfo
+ Plexus.Auth: Caller :: View
+ Plexus.Auth: Refusal :: Maybe Text -> Maybe Text -> [Text] -> [Text] -> [Text] -> Text -> Refusal
+ Plexus.Auth: [authMethods] :: AuthInfo -> [Text]
+ Plexus.Auth: [authResourceMetadata] :: AuthInfo -> Maybe Text
+ Plexus.Auth: [infoAuth] :: BackendInfo -> Maybe AuthInfo
+ Plexus.Auth: [infoBackend] :: BackendInfo -> Text
+ Plexus.Auth: [infoViewHash] :: BackendInfo -> Maybe Text
+ Plexus.Auth: [infoView] :: BackendInfo -> Maybe View
+ Plexus.Auth: [refusalCeiling] :: Refusal -> [Text]
+ Plexus.Auth: [refusalGrant] :: Refusal -> Maybe Text
+ Plexus.Auth: [refusalHeldOn] :: Refusal -> [Text]
+ Plexus.Auth: [refusalMessage] :: Refusal -> Text
+ Plexus.Auth: [refusalRoles] :: Refusal -> [Text]
+ Plexus.Auth: [refusalScope] :: Refusal -> Maybe Text
+ Plexus.Auth: data AuthInfo
+ Plexus.Auth: data BackendInfo
+ Plexus.Auth: data Refusal
+ Plexus.Auth: data View
+ Plexus.Auth: instance Data.Aeson.Types.FromJSON.FromJSON Plexus.Auth.AuthInfo
+ Plexus.Auth: instance Data.Aeson.Types.FromJSON.FromJSON Plexus.Auth.BackendInfo
+ Plexus.Auth: instance Data.Aeson.Types.FromJSON.FromJSON Plexus.Auth.Refusal
+ Plexus.Auth: instance Data.Aeson.Types.FromJSON.FromJSON Plexus.Auth.View
+ Plexus.Auth: instance Data.Aeson.Types.ToJSON.ToJSON Plexus.Auth.AuthInfo
+ Plexus.Auth: instance Data.Aeson.Types.ToJSON.ToJSON Plexus.Auth.BackendInfo
+ Plexus.Auth: instance Data.Aeson.Types.ToJSON.ToJSON Plexus.Auth.Refusal
+ Plexus.Auth: instance Data.Aeson.Types.ToJSON.ToJSON Plexus.Auth.View
+ Plexus.Auth: instance GHC.Classes.Eq Plexus.Auth.AuthInfo
+ Plexus.Auth: instance GHC.Classes.Eq Plexus.Auth.BackendInfo
+ Plexus.Auth: instance GHC.Classes.Eq Plexus.Auth.Refusal
+ Plexus.Auth: instance GHC.Classes.Eq Plexus.Auth.View
+ Plexus.Auth: instance GHC.Classes.Ord Plexus.Auth.View
+ Plexus.Auth: instance GHC.Generics.Generic Plexus.Auth.AuthInfo
+ Plexus.Auth: instance GHC.Generics.Generic Plexus.Auth.BackendInfo
+ Plexus.Auth: instance GHC.Generics.Generic Plexus.Auth.Refusal
+ Plexus.Auth: instance GHC.Generics.Generic Plexus.Auth.View
+ Plexus.Auth: instance GHC.Show.Show Plexus.Auth.AuthInfo
+ Plexus.Auth: instance GHC.Show.Show Plexus.Auth.BackendInfo
+ Plexus.Auth: instance GHC.Show.Show Plexus.Auth.Refusal
+ Plexus.Auth: instance GHC.Show.Show Plexus.Auth.View
+ Plexus.Auth: parseBackendInfo :: Value -> Maybe BackendInfo
+ Plexus.Auth: parseRefusal :: Value -> Maybe Refusal
+ Plexus.Auth: refusalFromObject :: Object -> Maybe Refusal
+ Plexus.Auth: viewText :: View -> Text
+ Plexus.Client: Keepalive :: Double -> Double -> Keepalive
+ Plexus.Client: LostConnection :: Text -> Bool -> LostConnection
+ Plexus.Client: TlsOptions :: [FilePath] -> TlsOptions
+ Plexus.Client: [kaIdle] :: Keepalive -> Double
+ Plexus.Client: [kaTimeout] :: Keepalive -> Double
+ Plexus.Client: [lostReason] :: LostConnection -> Text
+ Plexus.Client: [lostSent] :: LostConnection -> Bool
+ Plexus.Client: [substrateTls] :: SubstrateConfig -> Maybe TlsOptions
+ Plexus.Client: [tlsCaFiles] :: TlsOptions -> [FilePath]
+ Plexus.Client: connectionLost :: SubstrateConnection -> IO (Maybe Text)
+ Plexus.Client: data Keepalive
+ Plexus.Client: data LostConnection
+ Plexus.Client: data TlsOptions
+ Plexus.Client: defaultTlsOptions :: TlsOptions
+ Plexus.Client: hostHeader :: SubstrateConfig -> String
+ Plexus.Client: instance GHC.Classes.Eq Plexus.Client.Keepalive
+ Plexus.Client: instance GHC.Classes.Eq Plexus.Client.TlsOptions
+ Plexus.Client: instance GHC.Classes.Ord Plexus.Client.TlsOptions
+ Plexus.Client: instance GHC.Exception.Type.Exception Plexus.Client.LostConnection
+ Plexus.Client: instance GHC.Show.Show Plexus.Client.Keepalive
+ Plexus.Client: instance GHC.Show.Show Plexus.Client.LostConnection
+ Plexus.Client: instance GHC.Show.Show Plexus.Client.TlsOptions
+ Plexus.Client: keepaliveFromEnv :: IO (Maybe Keepalive)
+ Plexus.Client: substrateRpcWith :: SubstrateConnection -> IO () -> Text -> Value -> Stream (Of PlexusStreamItem) IO ()
+ Plexus.Schema.Recursive: Anonymous :: View
+ Plexus.Schema.Recursive: Caller :: View
+ Plexus.Schema.Recursive: MethodExample :: Value -> Maybe Text -> MethodExample
+ Plexus.Schema.Recursive: [exampleNote] :: MethodExample -> Maybe Text
+ Plexus.Schema.Recursive: [exampleParams] :: MethodExample -> Value
+ Plexus.Schema.Recursive: [methodExamples] :: MethodSchema -> [MethodExample]
+ Plexus.Schema.Recursive: [methodGrant] :: MethodSchema -> Maybe Text
+ Plexus.Schema.Recursive: [methodGroup] :: MethodSchema -> Maybe Text
+ Plexus.Schema.Recursive: [methodHeldOn] :: MethodSchema -> Maybe [Text]
+ Plexus.Schema.Recursive: [methodLongDescription] :: MethodSchema -> Maybe Text
+ Plexus.Schema.Recursive: [methodPublic] :: MethodSchema -> Bool
+ Plexus.Schema.Recursive: [methodTarget] :: MethodSchema -> Maybe Text
+ Plexus.Schema.Recursive: [psRoles] :: PluginSchema -> Maybe (Map Text [Text])
+ Plexus.Schema.Recursive: [psView] :: PluginSchema -> Maybe View
+ Plexus.Schema.Recursive: data MethodExample
+ Plexus.Schema.Recursive: data View
+ Plexus.Schema.Recursive: hasAuthColumns :: PluginSchema -> Bool
+ Plexus.Schema.Recursive: instance Data.Aeson.Types.FromJSON.FromJSON Plexus.Schema.Recursive.MethodExample
+ Plexus.Schema.Recursive: instance Data.Aeson.Types.ToJSON.ToJSON Plexus.Schema.Recursive.MethodExample
+ Plexus.Schema.Recursive: instance GHC.Classes.Eq Plexus.Schema.Recursive.MethodExample
+ Plexus.Schema.Recursive: instance GHC.Generics.Generic Plexus.Schema.Recursive.MethodExample
+ Plexus.Schema.Recursive: instance GHC.Show.Show Plexus.Schema.Recursive.MethodExample
+ Plexus.Schema.Recursive: paramValues :: Value -> Maybe [Text]
+ Plexus.Schema.Recursive: xPlexusValues :: Text
+ Plexus.Transport: invokeMethodStreamingWith :: SubstrateConfig -> IO () -> [Text] -> Text -> Value -> (PlexusStreamItem -> IO ()) -> IO (Either TransportError ())
+ Plexus.Types: ConnectionLost :: Text -> Bool -> TransportError
+ Plexus.Types: Refusal :: Maybe Text -> Maybe Text -> [Text] -> [Text] -> [Text] -> Text -> Refusal
+ Plexus.Types: StreamErrorWith :: Text -> Provenance -> Text -> Bool -> Maybe Refusal -> PlexusStreamItem
+ Plexus.Types: [itemErrorRefusal] :: PlexusStreamItem -> Maybe Refusal
+ Plexus.Types: [refusalCeiling] :: Refusal -> [Text]
+ Plexus.Types: [refusalGrant] :: Refusal -> Maybe Text
+ Plexus.Types: [refusalHeldOn] :: Refusal -> [Text]
+ Plexus.Types: [refusalMessage] :: Refusal -> Text
+ Plexus.Types: [refusalRoles] :: Refusal -> [Text]
+ Plexus.Types: [refusalScope] :: Refusal -> Maybe Text
+ Plexus.Types: [transportSent] :: TransportError -> Bool
+ Plexus.Types: data Refusal
+ Plexus.Types: itemRefusal :: PlexusStreamItem -> Maybe Refusal
+ Plexus.Types: pattern StreamError :: Text -> Provenance -> Text -> Bool -> PlexusStreamItem
- Plexus: MethodSchema :: Text -> Text -> PluginHash -> Maybe Value -> Maybe Value -> Bool -> Bool -> Maybe Value -> Maybe Value -> Maybe DeprecationInfo -> Maybe [ParamSchema] -> MethodRole -> MethodSchema
+ Plexus: MethodSchema :: Text -> Text -> PluginHash -> Maybe Value -> Maybe Value -> Bool -> Bool -> Maybe Value -> Maybe Value -> Maybe DeprecationInfo -> Maybe [ParamSchema] -> MethodRole -> Maybe Text -> Maybe Text -> Maybe Text -> Maybe [Text] -> Bool -> Maybe Text -> [MethodExample] -> MethodSchema
- Plexus: PluginSchema :: Text -> Text -> Text -> Maybe Text -> PluginHash -> [MethodSchema] -> Maybe [ChildSummary] -> Maybe DeprecationInfo -> PluginSchema
+ Plexus: PluginSchema :: Text -> Text -> Text -> Maybe Text -> PluginHash -> [MethodSchema] -> Maybe [ChildSummary] -> Maybe DeprecationInfo -> Maybe (Map Text [Text]) -> Maybe View -> PluginSchema
- Plexus: SubstrateConfig :: String -> Int -> String -> Text -> Headers -> SubstrateConfig
+ Plexus: SubstrateConfig :: String -> Int -> String -> Text -> Headers -> Maybe TlsOptions -> SubstrateConfig
- Plexus.Client: SubstrateConfig :: String -> Int -> String -> Text -> Headers -> SubstrateConfig
+ Plexus.Client: SubstrateConfig :: String -> Int -> String -> Text -> Headers -> Maybe TlsOptions -> SubstrateConfig
- Plexus.Schema.Recursive: MethodSchema :: Text -> Text -> PluginHash -> Maybe Value -> Maybe Value -> Bool -> Bool -> Maybe Value -> Maybe Value -> Maybe DeprecationInfo -> Maybe [ParamSchema] -> MethodRole -> MethodSchema
+ Plexus.Schema.Recursive: MethodSchema :: Text -> Text -> PluginHash -> Maybe Value -> Maybe Value -> Bool -> Bool -> Maybe Value -> Maybe Value -> Maybe DeprecationInfo -> Maybe [ParamSchema] -> MethodRole -> Maybe Text -> Maybe Text -> Maybe Text -> Maybe [Text] -> Bool -> Maybe Text -> [MethodExample] -> MethodSchema
- Plexus.Schema.Recursive: PluginSchema :: Text -> Text -> Text -> Maybe Text -> PluginHash -> [MethodSchema] -> Maybe [ChildSummary] -> Maybe DeprecationInfo -> PluginSchema
+ Plexus.Schema.Recursive: PluginSchema :: Text -> Text -> Text -> Maybe Text -> PluginHash -> [MethodSchema] -> Maybe [ChildSummary] -> Maybe DeprecationInfo -> Maybe (Map Text [Text]) -> Maybe View -> PluginSchema

Files

plexus-protocol.cabal view
@@ -1,6 +1,6 @@ cabal-version:      3.0 name:               plexus-protocol-version:            0.5.0.0+version:            0.5.2.0 synopsis:           Plexus RPC protocol types and client license:            MIT author:@@ -14,6 +14,7 @@     Plexus.Transport     Plexus     Plexus.Types+    Plexus.Auth     Plexus.Schema     Plexus.Schema.Cache     Plexus.Schema.Recursive@@ -26,7 +27,11 @@     case-insensitive >= 1.2,     containers >= 0.6,     websockets >= 0.13,-    network >= 3.1,+    network >= 3.1.2,+    -- SCAFFOLD-21: wss — TLS, the certificate checked against the system store+    tls >= 2.1 && < 2.5,+    crypton-x509-store >= 1.6 && < 1.10,+    crypton-x509-system >= 1.6 && < 1.10,     async >= 2.2,     stm >= 2.5,     streaming >= 0.2,
src/Plexus.hs view
@@ -34,6 +34,8 @@   ( -- * Connection (re-exported from Plexus.Client)     SubstrateConnection   , SubstrateConfig(..)+  , TlsOptions(..)+  , defaultTlsOptions   , defaultConfig   , connect   , disconnect
+ src/Plexus/Auth.hs view
@@ -0,0 +1,202 @@+-- | AUTH-4 / AUTH-2 / AUTH-5: what a client parses of a hub's auth.+--+-- Every field is optional on the wire: a hub without a gate (every hub+-- before AUTH, and any hub that leaves login off) answers none of it, and+-- everything here parses its absence as 'Nothing' or empty.+--+-- * '_info' gains @view@ (@anonymous@ | @caller@), the view's @hash@, and,+--   to an anonymous caller, an @auth@ block naming the methods it may call+--   and the RFC 9728 resource metadata URL ('BackendInfo').+-- * A refusal is an error item with @error_kind: "forbidden"@ naming the+--   grant it needed, the scope it was asked on, the roles that list the+--   grant and where the caller holds it ('Refusal').+module Plexus.Auth+  ( -- * The view (AUTH-4)+    View(..)+  , viewText+  , BackendInfo(..)+  , AuthInfo(..)+  , parseBackendInfo++    -- * Refusals (AUTH-2)+  , Refusal(..)+  , parseRefusal+  , refusalFromObject+  ) where++import Control.Applicative ((<|>))+import Data.Aeson+import qualified Data.Aeson.KeyMap as KM+import Data.Aeson.Types (Parser, parseMaybe)+import Data.Text (Text)+import qualified Data.Text as T+import GHC.Generics (Generic)++-- ============================================================================+-- The view+-- ============================================================================++-- | Which part of a hub the caller sees: an anonymous caller only how to+-- sign in; a signed-in caller what it can use.+data View = Anonymous | Caller+  deriving stock (Show, Eq, Ord, Generic)++viewText :: View -> Text+viewText Anonymous = "anonymous"+viewText Caller = "caller"++-- | @"anonymous"@ / @"caller"@, or an object naming it as @kind@ (a+-- tolerance: a hub may carry the view's hash beside it).+instance FromJSON View where+  parseJSON = \case+    String s -> fromName s+    Object o -> (o .: "kind" <|> o .: "view") >>= fromName+    v -> fail ("View: expected a string, got " <> show v)+    where+      fromName s = case T.toLower s of+        "anonymous" -> pure Anonymous+        "caller" -> pure Caller+        other -> fail ("View: unknown view " <> T.unpack other)++instance ToJSON View where+  toJSON = String . viewText++-- | The anonymous view's door: the methods it may call and where an HTTP+-- client reads how to get a token.+data AuthInfo = AuthInfo+  { authMethods          :: [Text]      -- ^ e.g. @["auth.providers","auth.login","auth.signup"]@+  , authResourceMetadata :: Maybe Text  -- ^ RFC 9728 @/.well-known/oauth-protected-resource@+  }+  deriving stock (Show, Eq, Generic)++instance FromJSON AuthInfo where+  parseJSON = withObject "AuthInfo" $ \o -> AuthInfo+    <$> o .:? "methods" .!= []+    <*> o .:? "resource_metadata"++instance ToJSON AuthInfo where+  toJSON AuthInfo{..} = object $+    ["methods" .= authMethods]+    <> maybe [] (\u -> ["resource_metadata" .= u]) authResourceMetadata++-- | What @_info@ answers. Only @backend@ is required.+data BackendInfo = BackendInfo+  { infoBackend  :: Text+  , infoView     :: Maybe View   -- ^ Nothing: a hub with no gate (today's)+  , infoViewHash :: Maybe Text   -- ^ the view's own hash (@view_hash@, else @hash@, else the view object's @hash@)+  , infoAuth     :: Maybe AuthInfo+  }+  deriving stock (Show, Eq, Generic)++instance FromJSON BackendInfo where+  parseJSON = withObject "BackendInfo" $ \o -> do+    backend <- o .: "backend"+    -- a view that does not parse is no view, never a failed _info+    view <- optionalLenient (o .:? "view")+    viewObjHash <- case KM.lookup "view" o of+      Just (Object vo) -> optionalLenient (vo .:? "hash")+      _ -> pure Nothing+    vhash <- optionalLenient (o .:? "view_hash")+    hash <- optionalLenient (o .:? "hash")+    auth <- optionalLenient (o .:? "auth")+    pure BackendInfo+      { infoBackend = backend+      , infoView = view+      , infoViewHash = vhash <|> viewObjHash <|> hash+      , infoAuth = auth+      }++instance ToJSON BackendInfo where+  toJSON BackendInfo{..} = object $+    ["backend" .= infoBackend]+    <> maybe [] (\v -> ["view" .= v]) infoView+    <> maybe [] (\h -> ["view_hash" .= h]) infoViewHash+    <> maybe [] (\a -> ["auth" .= a]) infoAuth++-- | Parse @_info@'s content.+parseBackendInfo :: Value -> Maybe BackendInfo+parseBackendInfo = parseMaybe parseJSON++-- | A field that may be present but malformed: Nothing rather than failing.+optionalLenient :: Parser (Maybe a) -> Parser (Maybe a)+optionalLenient p = p <|> pure Nothing++-- ============================================================================+-- Refusals+-- ============================================================================++-- | A typed refusal: @error_kind: "forbidden"@. Every field but the+-- message is optional; lists absent are empty.+data Refusal = Refusal+  { refusalGrant   :: Maybe Text   -- ^ @tickets.set_status@+  , refusalScope   :: Maybe Text   -- ^ where it was asked: @IDENTITY@+  , refusalRoles   :: [Text]       -- ^ the roles that list the grant+  , refusalHeldOn  :: [Text]       -- ^ where the caller holds it+  , refusalCeiling :: [Text]       -- ^ the session's ceiling, when the hub says it+  , refusalMessage :: Text+  }+  deriving stock (Show, Eq, Generic)++instance FromJSON Refusal where+  parseJSON = withObject "Refusal" $ \o -> do+    kind <- o .:? "error_kind"+    case kind :: Maybe Text of+      Just k | T.toLower k == "forbidden" -> refusalFields o+      _ -> fail "Refusal: error_kind is not forbidden"++instance ToJSON Refusal where+  toJSON Refusal{..} = object $+    [ "error_kind" .= ("forbidden" :: Text)+    , "message" .= refusalMessage+    , "roles" .= refusalRoles+    , "held_on" .= refusalHeldOn+    ]+    <> maybe [] (\g -> ["grant" .= g]) refusalGrant+    <> maybe [] (\s -> ["scope" .= s]) refusalScope+    <> (if null refusalCeiling then [] else ["ceiling" .= refusalCeiling])++refusalFields :: Object -> Parser Refusal+refusalFields o = do+  grant <- optionalLenient (o .:? "grant")+  scope <- optionalLenient (o .:? "scope")+  roles <- textList o "roles"+  held <- textList o "held_on"+  ceiling <- textList o "ceiling"+  msg <- optionalLenient (o .:? "message")+  pure Refusal+    { refusalGrant = grant+    , refusalScope = scope+    , refusalRoles = roles+    , refusalHeldOn = held+    , refusalCeiling = ceiling+    , refusalMessage = maybe (defaultMessage grant scope) id msg+    }+  where+    defaultMessage g s = case (g, s) of+      (Just g', Just s') -> "needs " <> g' <> " on " <> s'+      (Just g', Nothing) -> "needs " <> g'+      _ -> "forbidden"++-- | A list of strings, a single string, or a list whose elements are+-- objects naming a @scope@ / @role@ / @name@ — anything else is empty.+textList :: Object -> Key -> Parser [Text]+textList o k = pure $ case KM.lookup k o of+  Just (Array xs) -> concatMap one (foldr (:) [] xs)+  Just (String s) -> [s]+  _ -> []+  where+    one = \case+      String s -> [s]+      Object e+        | Just (String s) <- KM.lookup "scope" e -> [s]+        | Just (String s) <- KM.lookup "role" e -> [s]+        | Just (String s) <- KM.lookup "name" e -> [s]+      _ -> []++-- | A refusal from an error item's (or an in-band error event's) fields.+refusalFromObject :: Object -> Maybe Refusal+refusalFromObject = parseMaybe parseJSON . Object++-- | A refusal from any JSON value: an object with @error_kind: forbidden@.+parseRefusal :: Value -> Maybe Refusal+parseRefusal = parseMaybe parseJSON
src/Plexus/Client.hs view
@@ -12,22 +12,37 @@     SubstrateConnection   , connect   , disconnect+  , connectionLost +    -- * A lost connection (SYNAPSE-20)+  , LostConnection(..)+  , Keepalive(..)+  , keepaliveFromEnv+     -- * Core RPC primitive   , substrateRpc+  , substrateRpcWith      -- * Configuration   , SubstrateConfig(..)+  , TlsOptions(..)+  , defaultTlsOptions   , defaultConfig   , cookieHeader+  , hostHeader   ) where -import Control.Concurrent (forkIO)+import Control.Concurrent (forkIO, killThread, threadDelay)+import Control.Concurrent.MVar (MVar, modifyMVar, newMVar)+import System.IO.Unsafe (unsafePerformIO) import Control.Concurrent.Async (Async, async, cancel, wait) import Control.Concurrent.STM-import Control.Exception (SomeException, catch)-import Control.Monad (forever, void)+import Control.Exception (Exception, SomeException, bracketOnError, catch, fromException, throwIO, finally, try)+import Control.Monad (forever, void, when, unless)+import GHC.Clock (getMonotonicTime)+import System.Environment (lookupEnv) import System.Timeout (timeout)+import Text.Read (readMaybe) import Data.Aeson import qualified Data.Aeson.KeyMap as KM import qualified Data.ByteString.Lazy.Char8 as LBS@@ -38,8 +53,15 @@ import qualified Data.Text as T import Network.WebSockets (Connection) import qualified Network.WebSockets as WS+import qualified Network.WebSockets.Stream as WSS+import qualified Data.ByteString as BS import qualified Data.ByteString.Char8 as BS8 import qualified Data.CaseInsensitive as CI+import Data.X509.CertificateStore (CertificateStore, makeCertificateStore)+import Data.X509.File (readSignedObject)+import qualified Network.Socket as S+import qualified Network.TLS as TLS+import System.X509 (getSystemCertificateStore) import Streaming import qualified Streaming.Prelude as Str @@ -52,9 +74,29 @@   , substratePath    :: String   , substrateBackend :: Text    -- ^ Backend name (e.g., "plexus")   , substrateHeaders :: WS.Headers  -- ^ Extra HTTP upgrade headers (e.g., Cookie)+  , substrateTls     :: Maybe TlsOptions+    -- ^ SCAFFOLD-21: 'Nothing' dials @ws://@; 'Just' dials @wss://@ — TLS+    -- with the server's certificate verified against the system's trust+    -- store (plus 'tlsCaFiles') and its name ('substrateHost'), sent as SNI.   }   deriving stock (Show, Eq, Ord) +-- | SCAFFOLD-21: how a @wss://@ connection is made. The certificate is+-- always verified — the chain against the system's trust store (the+-- distribution's bundle; @SYSTEM_CERTIFICATE_PATH@ overrides where it is+-- read from) and every PEM file in 'tlsCaFiles' (a local CA, for a test+-- behind a proxy of one's own), the name against 'substrateHost'. There is+-- no switch that skips the check.+data TlsOptions = TlsOptions+  { tlsCaFiles :: [FilePath]+    -- ^ PEM files of certificates trusted beside the system's+  }+  deriving stock (Show, Eq, Ord)++-- | Verify against the system's trust store alone.+defaultTlsOptions :: TlsOptions+defaultTlsOptions = TlsOptions { tlsCaFiles = [] }+ -- | Default configuration for local development (requires backend) defaultConfig :: Text -> SubstrateConfig defaultConfig backend = SubstrateConfig@@ -63,8 +105,20 @@   , substratePath    = "/"   , substrateBackend = backend   , substrateHeaders = []+  , substrateTls     = Nothing   } +-- | SCAFFOLD-21: the upgrade's @Host@ — the host alone on the scheme's+-- default port (443 for wss, 80 for ws), else @host:port@.+hostHeader :: SubstrateConfig -> String+hostHeader SubstrateConfig{..}+  | substratePort == defaultPort = substrateHost+  | otherwise = bracketed <> ":" <> show substratePort+  where+    defaultPort = maybe 80 (const 443) substrateTls+    bracketed | ':' `elem` substrateHost = "[" <> substrateHost <> "]"  -- an IPv6 literal+              | otherwise = substrateHost+ -- | Build a Cookie header from a JWT token cookieHeader :: Text -> WS.Headers cookieHeader tok = [(CI.mk "Cookie", "access_token=" <> BS8.pack (T.unpack tok))]@@ -82,8 +136,66 @@   , scSubscriptions :: TVar (Map SubscriptionId (TQueue Value))   , scPendingReqs   :: TVar (Map RequestId PendingRequest)   , scReaderThread  :: Async ()+  , scClosed        :: TMVar Text+    -- ^ SYNAPSE-20: filled, once, with why the connection is gone — the+    -- reader ended (a close, a reset) or a keepalive ping went unanswered.+    -- Every call waiting on this connection waits on it too, so none is+    -- left waiting on a dead socket.+  , scKeepalive     :: Maybe (Async ())   } +-- | SYNAPSE-20: a call's connection was lost. 'lostSent' says whether the+-- call's request had gone out before it was.+data LostConnection = LostConnection+  { lostReason :: Text+  , lostSent   :: Bool+  }+  deriving stock (Show)++instance Exception LostConnection++-- | SYNAPSE-20: how a silent drop (no close, no reset: a peer that stopped,+-- a network that went away) is noticed. After 'kaIdle' seconds with+-- nothing heard, a ping is sent; when nothing (a pong or any message) is+-- heard 'kaTimeout' seconds after it, the connection is lost.+data Keepalive = Keepalive+  { kaIdle    :: Double+  , kaTimeout :: Double+  }+  deriving stock (Show, Eq)++-- | The keepalive: @PLEXUS_KEEPALIVE=IDLE[,TIMEOUT]@ in seconds, @0@ or+-- @off@ for none; by default a ping after 15 s of silence, lost 10 s later.+keepaliveFromEnv :: IO (Maybe Keepalive)+keepaliveFromEnv = do+  v <- lookupEnv "PLEXUS_KEEPALIVE"+  pure $ case fmap (T.splitOn "," . T.strip . T.pack) v of+    Nothing -> Just def+    Just [t] | t `elem` ["0", "off", "none"] -> Nothing+    Just [i] | Just i' <- num i, i' > 0 -> Just def { kaIdle = i' }+    Just [i, t] | Just i' <- num i, Just t' <- num t, i' > 0, t' > 0 -> Just (Keepalive i' t')+    Just _ -> Just def+  where+    def = Keepalive 15 10+    num :: Text -> Maybe Double+    num t = case readMaybe (T.unpack (T.strip t)) :: Maybe Double of+      Just d -> Just d+      Nothing -> fromIntegral <$> (readMaybe (T.unpack (T.strip t)) :: Maybe Int)++-- | SYNAPSE-20: why the connection is gone, if it is.+connectionLost :: SubstrateConnection -> IO (Maybe Text)+connectionLost c = atomically (tryReadTMVar (scClosed c))++-- | Say why the connection ended, from what ended the reader.+describeEnd :: SomeException -> Text+describeEnd e = case fromException e of+  Just (WS.CloseRequest code msg) ->+    "the server closed the connection (" <> T.pack (show code)+      <> (if LBS.null msg then "" else ": " <> T.pack (LBS.unpack msg)) <> ")"+  Just WS.ConnectionClosed -> "the connection was closed"+  Just other -> "the connection failed: " <> T.pack (show other)+  Nothing -> "the connection failed: " <> T.pack (show e)+ -- | Connect to substrate -- Throws an exception if connection fails connect :: SubstrateConfig -> IO SubstrateConnection@@ -92,51 +204,169 @@   nextId <- newIORef 1   subs   <- newTVarIO Map.empty   pendingReqs <- newTVarIO Map.empty+  closed <- newEmptyTMVarIO   resultVar <- newEmptyTMVarIO  -- Either error message or (conn, reader)+  ka <- keepaliveFromEnv+  -- SYNAPSE-20: when anything was last heard (a message or a pong)+  heard <- newIORef =<< getMonotonicTime+  let touch = getMonotonicTime >>= writeIORef heard+      lose why = atomically (void (tryPutTMVar closed why))+      opts = WS.defaultConnectionOptions { WS.connectionOnPong = touch }    -- Run WebSocket client in a background thread   -- Catch exceptions inside the thread to prevent them from being printed-  void $ forkIO $-    (WS.runClientWith substrateHost substratePort substratePath-        WS.defaultConnectionOptions substrateHeaders $ \conn -> do-      reader <- async $ readerLoop conn subs pendingReqs+  -- SCAFFOLD-21: over TLS when the config says wss+  let dial :: (Connection -> IO ()) -> IO ()+      dial = case substrateTls of+        Nothing -> WS.runClientWith substrateHost substratePort substratePath+                     opts substrateHeaders+        Just tls -> \app -> withTlsStream substrateHost substratePort tls $ \stream ->+          WS.runClientWithStream stream (hostHeader SubstrateConfig{..}) substratePath+            opts substrateHeaders app+  tid <- forkIO $+    (dial $ \conn -> do+      reader <- async $+        readerLoop conn touch subs pendingReqs+          `catch` (\(e :: SomeException) -> lose (describeEnd e))       -- Signal success       atomically $ putTMVar resultVar (Right (conn, reader))       -- Keep alive until reader exits       void (wait reader) `catch` \(_ :: SomeException) -> pure ()-    ) `catch` \(e :: SomeException) ->+    ) `catch` (\(e :: SomeException) ->       -- Signal failure (don't print, just capture)-      atomically $ putTMVar resultVar (Left $ show e)+      atomically $ void $ tryPutTMVar resultVar (Left $ show e))+      `finally` lose "the connection was closed"    -- Wait for connection result (with 5s timeout instead of hardcoded 200ms sleep)   mResult <- timeout 5000000 $ atomically $ takeTMVar resultVar    case mResult of-    Nothing -> error "Connection timeout (5s)"+    Nothing -> killThread tid >> error "Connection timeout (5s)"     Just (Left err) -> error $ "Connection failed: " <> err-    Just (Right (conn, reader)) ->+    Just (Right (conn, reader)) -> do+      kat <- traverse (\k -> async (keepaliveLoop k conn heard closed reader)) ka       pure SubstrateConnection         { scConnection    = conn         , scNextId        = nextId         , scSubscriptions = subs         , scPendingReqs   = pendingReqs         , scReaderThread  = reader+        , scClosed        = closed+        , scKeepalive     = kat         } +-- | SCAFFOLD-21: a TLS stream to @host:port@ for the action — the TCP+-- connection to the first address that answers, the handshake with the+-- name sent as SNI, the certificate chain verified against the system's+-- trust store and 'tlsCaFiles', and the name against the certificate. A+-- failed check fails the handshake (an exception, as a refused dial is).+withTlsStream :: String -> Int -> TlsOptions -> (WSS.Stream -> IO a) -> IO a+withTlsStream host port TlsOptions{..} act = do+  store <- trustStore tlsCaFiles+  let base = TLS.defaultParamsClient host (BS8.pack (show port))+      params = base+        { TLS.clientShared = (TLS.clientShared base) { TLS.sharedCAStore = store } }+  sock <- openTcp host port+  flip finally (S.close sock) $ do+    ctx <- TLS.contextNew sock params+    TLS.handshake ctx+    let recv = do+          chunk <- TLS.recvData ctx+          pure (if BS.null chunk then Nothing else Just chunk)+        send = maybe (TLS.bye ctx `catch` \(_ :: SomeException) -> pure ())+                     (TLS.sendData ctx)+    stream <- WSS.makeStream recv send+    act stream `finally` WSS.close stream++-- | The certificates a @wss://@ server's chain may end in: the system's+-- and those in each PEM file named. A file that holds none is an error+-- (a mistyped path would otherwise only show as a failed handshake).+trustStore :: [FilePath] -> IO CertificateStore+trustStore files = do+  system <- systemStore+  extra <- mapM readCa files+  pure (system <> makeCertificateStore (concat extra))+  where+    readCa f = do+      r <- try (readSignedObject f)+      case r of+        Left (e :: SomeException) -> ioError (userError ("CA file " <> f <> ": " <> show e))+        Right [] -> ioError (userError ("CA file " <> f <> " holds no PEM certificate"))+        Right certs -> pure certs++-- | The system's trust store, read once per process (every connection of+-- a pool would otherwise read the bundle again).+systemStore :: IO CertificateStore+systemStore = modifyMVar systemStoreMemo $ \case+  Just st -> pure (Just st, st)+  Nothing -> do+    st <- getSystemCertificateStore+    pure (Just st, st)++{-# NOINLINE systemStoreMemo #-}+systemStoreMemo :: MVar (Maybe CertificateStore)+systemStoreMemo = unsafePerformIO (newMVar Nothing)++-- | A TCP connection to the first of @host@'s addresses that takes it.+openTcp :: String -> Int -> IO S.Socket+openTcp host port = do+  let hints = S.defaultHints { S.addrSocketType = S.Stream }+  addrs <- S.getAddrInfo (Just hints) (Just host) (Just (show port))+  let attempt [] = ioError (userError ("no address of " <> host <> " answered"))+      attempt (a : rest) = do+        r <- try $ bracketOnError (S.openSocket a) S.close $ \s -> do+          S.setSocketOption s S.NoDelay 1+          S.connect s (S.addrAddress a)+          pure s+        case r of+          Right s -> pure s+          Left (e :: SomeException)+            | null rest -> throwIO e+            | otherwise -> attempt rest+  attempt addrs++-- | SYNAPSE-20: ping after 'kaIdle' seconds of silence; when nothing is+-- heard 'kaTimeout' seconds after the ping, the connection is lost — the+-- waiting calls are told and the reader is stopped, which closes the socket.+keepaliveLoop :: Keepalive -> Connection -> IORef Double -> TMVar Text -> Async () -> IO ()+keepaliveLoop Keepalive{..} conn heard closed reader = go Nothing+  where+    tick = min 1 (kaIdle / 2)+    go pinged = do+      threadDelay (round (tick * 1000000))+      gone <- atomically (not <$> isEmptyTMVar closed)+      unless gone $ do+        now <- getMonotonicTime+        h <- readIORef heard+        case pinged of+          Just at | h < at && now - at >= kaTimeout -> do+            atomically $ void $ tryPutTMVar closed $+              "no answer to a keepalive ping in " <> T.pack (show (round kaTimeout :: Int)) <> " s"+            cancel reader+          Just at | h < at -> go pinged+          _ | now - h >= kaIdle -> do+                WS.sendPing conn ("synapse" :: Text)+                  `catch` \(_ :: SomeException) -> pure ()+                go (Just now)+            | otherwise -> go Nothing+ -- | Disconnect from substrate disconnect :: SubstrateConnection -> IO () disconnect SubstrateConnection{..} = do+  mapM_ cancel scKeepalive   cancel scReaderThread   WS.sendClose scConnection ("bye" :: Text) `catch` \(_ :: SomeException) -> pure ()  -- | Reader loop - dispatches incoming messages to the right handler readerLoop   :: Connection+  -> IO ()   -> TVar (Map SubscriptionId (TQueue Value))   -> TVar (Map RequestId PendingRequest)   -> IO ()-readerLoop conn subs pendingReqs = forever $ do+readerLoop conn touch subs pendingReqs = forever $ do   msg <- WS.receiveData conn+  touch   case eitherDecode msg of     Left err -> putStrLn $ "Failed to decode message: " <> err     Right val -> dispatch val@@ -212,7 +442,24 @@   -> Text              -- ^ Method name (e.g., "bash_execute")   -> Value             -- ^ Parameters   -> Stream (Of PlexusStreamItem) IO ()-substrateRpc conn method params = do+substrateRpc conn = substrateRpcWith conn (pure ())++-- | SYNAPSE-20: 'substrateRpc', telling @onSubscribed@ when the server has+-- taken the call (its subscription confirmed) — before any item.+substrateRpcWith+  :: SubstrateConnection+  -> IO ()+  -> Text+  -> Value+  -> Stream (Of PlexusStreamItem) IO ()+substrateRpcWith conn onSubscribed method params = do+  -- SYNAPSE-20: a connection already gone (it died idle in a pool) takes+  -- no request; nothing was sent, so the caller may always try again+  gone <- liftIO $ connectionLost conn+  case gone of+    Just why -> liftIO $ throwIO (LostConnection why False)+    Nothing -> pure ()+   -- Get next request ID   rid <- liftIO $ atomicModifyIORef' (scNextId conn) $ \n -> (n + 1, RequestId n) @@ -224,27 +471,34 @@   liftIO $ atomically $ modifyTVar' (scPendingReqs conn) $     Map.insert rid (PendingRequest queue respVar) -  -- Send subscription request+  -- Send subscription request (a send that fails sent nothing)   let req = mkSubscribeRequest rid method params   liftIO $ WS.sendTextData (scConnection conn) (encode req)+    `catch` \(e :: SomeException) -> do+      why <- maybe (describeEnd e) id <$> connectionLost conn+      throwIO (LostConnection why False) -  -- Wait for subscription confirmation-  resp <- liftIO $ atomically $ takeTMVar respVar+  -- Wait for subscription confirmation — or for the connection to go+  resp <- liftIO $ atomically $+    (Right <$> takeTMVar respVar) `orElse` (Left <$> readTMVar (scClosed conn))    -- Clean up pending request   liftIO $ atomically $ modifyTVar' (scPendingReqs conn) $ Map.delete rid    case resp of-    RpcError _ err -> do+    Left why -> liftIO $ throwIO (LostConnection why True)++    Right (RpcError _ err) -> do       -- Emit error as StreamError so caller can handle it properly-      Str.yield $ StreamError+      Str.yield $ StreamErrorWith         { itemPlexusHash = ""  -- No hash available for subscription errors         , itemProvenance = Provenance ["substrate"]         , itemError = T.pack $ "Subscription error: " <> show err         , itemRecoverable = False+        , itemErrorRefusal = Nothing         } -    RpcSuccess _ result -> do+    Right (RpcSuccess _ result) -> do       -- The reader loop already registered the queue in scSubscriptions       -- when it processed the response, so we just need the subId for cleanup       case fromJSON result of@@ -253,12 +507,22 @@           pure ()          Success subId -> do+          liftIO onSubscribed           -- Stream items until done, cleanup when finished           streamItems queue subId <* liftIO (cleanup subId)   where+    -- SYNAPSE-20: what arrived before the connection went is delivered+    -- first ('orElse' tries the queue first); then the call fails with+    -- 'LostConnection' instead of waiting forever+    next :: TQueue Value -> IO Value+    next queue = do+      r <- atomically $+        (Right <$> readTQueue queue) `orElse` (Left <$> readTMVar (scClosed conn))+      either (\why -> throwIO (LostConnection why True)) pure r+     streamItems :: TQueue Value -> SubscriptionId -> Stream (Of PlexusStreamItem) IO ()     streamItems queue subId = do-      val <- liftIO $ atomically $ readTQueue queue+      val <- liftIO $ next queue       case fromJSON val of         Error err -> do           liftIO $ putStrLn $ "Failed to parse stream item: " <> err
src/Plexus/Schema/Recursive.hs view
@@ -39,6 +39,15 @@   , ParamSchema(..)   , MethodRole(..) +    -- * The caller's view (AUTH-3 / AUTH-4)+  , View(..)+  , hasAuthColumns++    -- * Help metadata (SYNAPSE-13)+  , MethodExample(..)+  , paramValues+  , xPlexusValues+     -- * Queries   , isHubActivation   , isLeafActivation@@ -55,11 +64,16 @@  import Data.Aeson import Data.Aeson.Types (Parser)+import qualified Data.Aeson.Key as K+import qualified Data.Aeson.KeyMap as KM import Data.Maybe (catMaybes, fromMaybe) import Data.Text (Text) import qualified Data.Text as T+import Data.Map.Strict (Map) import GHC.Generics (Generic) +import Plexus.Auth (View(..))+ -- ============================================================================ -- Deprecation Metadata (IR-5) -- ============================================================================@@ -182,8 +196,9 @@ instance FromJSON ChildSummary where   parseJSON = withObject "ChildSummary" $ \o -> ChildSummary     <$> o .: "namespace"-    <*> o .: "description"-    <*> o .: "hash"+    -- AUTH-4: an anonymous view's children may be bare+    <*> o .:? "description" .!= ""+    <*> o .:? "hash" .!= ""  instance ToJSON ChildSummary where   toJSON ChildSummary{..} = object@@ -206,14 +221,22 @@   , methodDeprecation     :: Maybe DeprecationInfo  -- ^ Deprecation info, if any (IR-5)   , methodParamSchemas    :: Maybe [ParamSchema]    -- ^ Optional structured param info with per-field deprecations (IR-5)   , methodRole            :: MethodRole             -- ^ Structural role in the activation graph (IR-2 / IR-3); defaults to 'MethodRoleRpc'+  , methodLongDescription :: Maybe Text             -- ^ AUTH-3: the doc comment, beside the short description+  , methodGrant           :: Maybe Text             -- ^ AUTH-3: @<activation>.<method>@, e.g. @tickets.set_status@+  , methodTarget          :: Maybe Text             -- ^ AUTH-3: the param that locates what a call acts on+  , methodHeldOn          :: Maybe [Text]           -- ^ AUTH-4: where the caller holds the grant (its view only)+  , methodPublic          :: Bool                   -- ^ AUTH-3: callable anonymously (the auth activation's door)+  , methodGroup           :: Maybe Text             -- ^ SYNAPSE-13: the method's group in a listing (OpenAPI's tag)+  , methodExamples        :: [MethodExample]        -- ^ SYNAPSE-13: worked calls, each its params and a note   }   deriving stock (Show, Eq, Generic)  instance FromJSON MethodSchema where   parseJSON = withObject "MethodSchema" $ \o -> MethodSchema     <$> o .: "name"-    <*> o .: "description"-    <*> o .: "hash"+    -- AUTH-4: an anonymous view may name a method and no more+    <*> o .:? "description" .!= ""+    <*> o .:? "hash" .!= ""     <*> o .:? "params"     <*> o .:? "returns"     <*> o .:? "streaming"      .!= False@@ -223,9 +246,17 @@     <*> o .:? "deprecation"     <*> o .:? "param_schemas"     <*> o .:? "role" .!= MethodRoleRpc+    <*> o .:? "long_description"+    <*> o .:? "grant"+    <*> o .:? "target"+    <*> o .:? "held_on"+    <*> o .:? "public" .!= False+    -- SYNAPSE-13: absent on an older hub; an unreadable one is ignored+    <*> (o .:? "group" <|> pure Nothing)+    <*> (o .:? "examples" .!= [] <|> pure [])  instance ToJSON MethodSchema where-  toJSON MethodSchema{..} = object+  toJSON MethodSchema{..} = object $     [ "name"           .= methodName     , "description"    .= methodDescription     , "hash"           .= methodHash@@ -238,8 +269,46 @@     , "deprecation"    .= methodDeprecation     , "param_schemas"  .= methodParamSchemas     , "role"           .= methodRole+    ] <> catMaybes+    [ ("long_description" .=) <$> methodLongDescription+    , ("grant"            .=) <$> methodGrant+    , ("target"           .=) <$> methodTarget+    , ("held_on"          .=) <$> methodHeldOn+    , if methodPublic then Just ("public" .= True) else Nothing+    , ("group"            .=) <$> methodGroup+    , if null methodExamples then Nothing else Just ("examples" .= methodExamples)     ] +-- | SYNAPSE-13: one worked call of a method — the params it sends and a+-- note saying what it does. A client renders it as a command line.+data MethodExample = MethodExample+  { exampleParams :: Value       -- ^ the params object, as the method takes it+  , exampleNote   :: Maybe Text  -- ^ what the call does, in a line+  }+  deriving stock (Show, Eq, Generic)++instance FromJSON MethodExample where+  parseJSON = withObject "MethodExample" $ \o -> MethodExample+    <$> o .:? "params" .!= Object mempty+    <*> o .:? "note"++instance ToJSON MethodExample where+  toJSON MethodExample{..} = object $+    ("params" .= exampleParams) : maybe [] (\n -> ["note" .= n]) exampleNote++-- | SYNAPSE-13: the JSON Schema extension key on a param's property that+-- lists the values it is meant to take — a hint, never enforced.+xPlexusValues :: Text+xPlexusValues = "x-plexus-values"++-- | SYNAPSE-13: the values a param's property says it is meant to take+-- (@x-plexus-values@), when it says them.+paramValues :: Value -> Maybe [Text]+paramValues (Object po) = case KM.lookup (K.fromText xPlexusValues) po of+  Just (Array vs) -> Just [t | String t <- foldr (:) [] vs]+  _ -> Nothing+paramValues _ = Nothing+ -- | Shallow plugin schema (what we receive from {backend}.schema) -- -- Children are summaries only - fetch full schema on-demand when navigating.@@ -253,22 +322,27 @@   , psMethods         :: [MethodSchema]   , psChildren        :: Maybe [ChildSummary]  -- ^ Nothing = leaf, Just = hub activation   , psDeprecation     :: Maybe DeprecationInfo -- ^ Activation-level deprecation (IR-5)+  , psRoles           :: Maybe (Map Text [Text]) -- ^ AUTH-3: the activation's default roles, each its grants+  , psView            :: Maybe View            -- ^ AUTH-4: which view this schema answers from   }   deriving stock (Show, Eq, Generic)  instance FromJSON PluginSchema where   parseJSON = withObject "PluginSchema" $ \o -> PluginSchema     <$> o .: "namespace"-    <*> o .: "version"-    <*> o .: "description"+    -- AUTH-4: an anonymous view's schema may omit what it does not show+    <*> o .:? "version" .!= ""+    <*> o .:? "description" .!= ""     <*> o .:? "long_description"-    <*> o .: "hash"+    <*> o .:? "hash" .!= ""     <*> o .:? "methods" .!= []     <*> o .:? "children"     <*> o .:? "deprecation"+    <*> (o .:? "roles" <|> pure Nothing)+    <*> (o .:? "view" <|> pure Nothing)  instance ToJSON PluginSchema where-  toJSON PluginSchema{..} = object+  toJSON PluginSchema{..} = object $     [ "namespace"        .= psNamespace     , "version"          .= psVersion     , "description"      .= psDescription@@ -277,6 +351,9 @@     , "methods"          .= psMethods     , "children"         .= psChildren     , "deprecation"      .= psDeprecation+    ] <> catMaybes+    [ ("roles" .=) <$> psRoles+    , ("view"  .=) <$> psView     ]  -- | Result of a schema query - can be either a full plugin or just a method@@ -299,6 +376,11 @@ -- ============================================================================ -- Basic Queries -- ============================================================================++-- | AUTH-4: does any method here say its grant or where it is held? Then+-- help shows those columns; otherwise it renders as before AUTH.+hasAuthColumns :: PluginSchema -> Bool+hasAuthColumns ps = any (\m -> maybe False (const True) (methodGrant m) || maybe False (const True) (methodHeldOn m)) (psMethods ps)  -- | Is this a hub activation (has children)? isHubActivation :: PluginSchema -> Bool
src/Plexus/Transport.hs view
@@ -10,6 +10,7 @@     -- * RPC Calls (streaming)   , rpcCallStreaming   , invokeMethodStreaming+  , invokeMethodStreamingWith      -- * Bidirectional Response   , sendBidirectionalResponse@@ -37,7 +38,7 @@ import qualified Network.Socket as NS import System.IO.Unsafe (unsafePerformIO) -import Plexus.Client (SubstrateConfig(..), connect, disconnect, substrateRpc, defaultConfig)+import Plexus.Client (SubstrateConfig(..), LostConnection(..), connect, disconnect, substrateRpc, substrateRpcWith, defaultConfig) import Plexus.Client.Pool (ConnectionPool, createConnectionPool, withPooledConnection, defaultPlexusPoolConfig) import Plexus.Types (PlexusStreamItem(..), TransportError(..), Response(..), StandardResponse) import Plexus.Schema.Recursive (PluginSchema, MethodSchema, SchemaResult(..), parsePluginSchema, parseSchemaResult)@@ -71,6 +72,9 @@ -- | Categorize exceptions into typed TransportError categorizeException :: SubstrateConfig -> SomeException -> IO (Either TransportError a) categorizeException cfg e+  -- SYNAPSE-20: the connection went while the call was on it+  | Just (LostConnection why sent) <- fromException e =+      pure $ Left $ ConnectionLost why sent   -- Check for connection refused (most common)   | Just ioErr <- fromException e :: Maybe IOException = do       let errMsg = show ioErr@@ -87,7 +91,14 @@       let errMsg = show e           host = T.pack $ substrateHost cfg           port = substratePort cfg-      in pure $ Left $ if "protocol" `isInfixOf` errMsg || "parse" `isInfixOf` errMsg+      in pure $ Left $+        -- SYNAPSE-20: 'connect' reports a refused or timed-out dial as an+        -- error call; say which, so a caller can tell an outage from a fault+        if "refused" `isInfixOf` errMsg || "ECONNREFUSED" `isInfixOf` errMsg+          then ConnectionRefused host port+        else if "Connection timeout" `isInfixOf` errMsg+          then ConnectionTimeout host port+        else if "protocol" `isInfixOf` errMsg || "parse" `isInfixOf` errMsg         then ProtocolError (T.pack errMsg)         else NetworkError (T.pack errMsg) @@ -96,11 +107,19 @@     on f g x y = f (g x) (g y)  doCallInner :: SubstrateConfig -> Text -> Value -> IO [PlexusStreamItem]-doCallInner cfg method params = do+doCallInner cfg method params = freshIfUnsent $ do   pool <- getOrCreatePool cfg   withPooledConnection pool $ \conn ->     S.toList_ $ substrateRpc conn method params +-- | SYNAPSE-20: a pooled connection that died while idle fails the call+-- before anything is sent (and the pool drops it); the call goes once more,+-- on a fresh connection. A call whose request went out is never repeated+-- here — whether that is safe is the caller's to say.+freshIfUnsent :: IO a -> IO a+freshIfUnsent act = act `E.catch` \(e :: LostConnection) ->+  if lostSent e then E.throwIO e else act+ -- | Streaming RPC call - invokes callback for each item as it arrives rpcCallStreaming :: SubstrateConfig -> Text -> Value -> (PlexusStreamItem -> IO ()) -> IO (Either TransportError ()) rpcCallStreaming cfg method params onItem = do@@ -109,19 +128,28 @@   pure result  doCallStreaming :: SubstrateConfig -> Text -> Value -> (PlexusStreamItem -> IO ()) -> IO ()-doCallStreaming cfg method params onItem = do+doCallStreaming cfg = doCallStreamingWith cfg (pure ())++doCallStreamingWith :: SubstrateConfig -> IO () -> Text -> Value -> (PlexusStreamItem -> IO ()) -> IO ()+doCallStreamingWith cfg onSubscribed method params onItem = freshIfUnsent $ do   pool <- getOrCreatePool cfg   withPooledConnection pool $ \conn ->-    S.mapM_ onItem $ substrateRpc conn method params+    S.mapM_ onItem $ substrateRpcWith conn onSubscribed method params  -- | Streaming method invocation invokeMethodStreaming :: SubstrateConfig -> [Text] -> Text -> Value -> (PlexusStreamItem -> IO ()) -> IO (Either TransportError ())-invokeMethodStreaming cfg namespacePath method params onItem = do+invokeMethodStreaming cfg = invokeMethodStreamingWith cfg (pure ())++-- | SYNAPSE-20: 'invokeMethodStreaming', telling @onSubscribed@ when the+-- server has taken the call, before any item.+invokeMethodStreamingWith :: SubstrateConfig -> IO () -> [Text] -> Text -> Value -> (PlexusStreamItem -> IO ()) -> IO (Either TransportError ())+invokeMethodStreamingWith cfg onSubscribed namespacePath method params onItem = do   let backend = substrateBackend cfg   let fullPath = if null namespacePath then [backend] else namespacePath   let dotPath = T.intercalate "." (fullPath ++ [method])   let callParams = object ["method" .= dotPath, "params" .= params]-  rpcCallStreaming cfg (backend <> ".call") callParams onItem+  (Right <$> doCallStreamingWith cfg onSubscribed (backend <> ".call") callParams onItem)+    `catch` categorizeException cfg  -- | Fetch schema at a specific path -- Empty path = root (<backend>.schema)
src/Plexus/Types.hs view
@@ -1,3 +1,5 @@+{-# LANGUAGE PatternSynonyms #-}+ -- | JSON-RPC types for communicating with Plexus RPC servers module Plexus.Types   ( -- * JSON-RPC Protocol@@ -10,7 +12,9 @@   , SubscriptionId(..)      -- * Plexus Stream Types-  , PlexusStreamItem(..)+  , PlexusStreamItem(.., StreamError)+  , itemRefusal+  , Refusal(..)   , Provenance(..)   , StreamMetadata(..)   , GuidanceErrorType(..)@@ -32,7 +36,9 @@   , getPlexusHash   ) where +import Control.Applicative ((<|>)) import Data.Aeson+import qualified Data.Aeson.KeyMap as KM import Data.Aeson.Types (Parser) import qualified Data.ByteString.Lazy as LBS import Data.Maybe (catMaybes)@@ -41,6 +47,8 @@ import qualified Data.Text.Encoding as T import GHC.Generics (Generic) +import Plexus.Auth (Refusal(..), refusalFromObject)+ -- | Request ID for JSON-RPC calls newtype RequestId = RequestId { unRequestId :: Int }   deriving stock (Show, Eq, Ord)@@ -283,6 +291,15 @@   | NetworkError       { transportMessage :: Text       }+  -- | SYNAPSE-20: an open connection was lost (the server closed it, it was+  -- reset, or a keepalive ping went unanswered) while a call was on it.+  -- 'transportSent' says whether the call's request had been sent: when it+  -- had not, repeating the call is always safe; when it had, the server+  -- may have acted on it.+  | ConnectionLost+      { transportMessage :: Text+      , transportSent    :: Bool+      }   deriving stock (Show, Eq, Generic)  instance ToJSON TransportError where@@ -304,6 +321,11 @@     [ "error_kind" .= ("network_error" :: Text)     , "message" .= msg     ]+  toJSON (ConnectionLost msg sent) = object+    [ "error_kind" .= ("connection_lost" :: Text)+    , "message" .= msg+    , "sent" .= sent+    ]  instance FromJSON TransportError where   parseJSON = withObject "TransportError" $ \o -> do@@ -313,6 +335,7 @@       "connection_timeout" -> ConnectionTimeout <$> o .: "host" <*> o .: "port"       "protocol_error" -> ProtocolError <$> o .: "message"       "network_error" -> NetworkError <$> o .: "message"+      "connection_lost" -> ConnectionLost <$> o .: "message" <*> o .: "sent"       _ -> fail $ "Unknown transport error_kind: " <> T.unpack kind  -- ============================================================================@@ -463,11 +486,14 @@       , itemAvailableMethods  :: Maybe [Text]       , itemMethodSchema      :: Maybe Value       }-  | StreamError+  | StreamErrorWith       { itemPlexusHash  :: Text       , itemProvenance  :: Provenance       , itemError       :: Text       , itemRecoverable :: Bool+      , itemErrorRefusal :: Maybe Refusal+        -- ^ AUTH-2: @error_kind: "forbidden"@ and what it names; Nothing+        -- for every other error (and on every hub without a gate)       }   | StreamRequest       { itemPlexusHash  :: Text@@ -482,6 +508,24 @@       }   deriving stock (Show, Eq, Generic) +-- | An error item as it was before AUTH-2: hash, provenance, message,+-- recoverable. Matches every error (a refusal too); builds one with no+-- refusal. 'StreamErrorWith' carries the typed refusal.+pattern StreamError :: Text -> Provenance -> Text -> Bool -> PlexusStreamItem+pattern StreamError hash prov err rec <- StreamErrorWith hash prov err rec _+  where StreamError hash prov err rec = StreamErrorWith hash prov err rec Nothing++{-# COMPLETE StreamProgress, StreamData, StreamGuidance, StreamError, StreamRequest, StreamDone #-}++-- | AUTH-2: the refusal an item carries — an error item with+-- @error_kind: "forbidden"@, or a data item whose content is one (an+-- in-band error event naming the same fields).+itemRefusal :: PlexusStreamItem -> Maybe Refusal+itemRefusal = \case+  StreamErrorWith _ _ _ _ r -> r+  StreamData _ _ _ (Object o) -> refusalFromObject o+  _ -> Nothing+ instance FromJSON PlexusStreamItem where   parseJSON = withObject "PlexusStreamItem" $ \o -> do     typ <- o .: "type" :: Parser Text@@ -509,9 +553,10 @@             <*> o .: "suggestion"             <*> o .:? "available_methods"             <*> o .:? "method_schema"-          "error" -> StreamError hash prov+          "error" -> StreamErrorWith hash prov             <$> o .: "message"             <*> o .:? "recoverable" .!= False+            <*> pure (refusalFromObject o)           "request" -> StreamRequest hash prov             <$> o .: "request_id"             <*> o .: "request"@@ -522,7 +567,9 @@       -- Legacy format: flat structure       parseLegacy :: Text -> Object -> Parser PlexusStreamItem       parseLegacy typ o = do-        hash <- o .: "plexus_hash"+        -- an error item may come bare (AUTH-2's refusal is written so):+        -- no hash or provenance is no reason to lose it+        hash <- if typ == "error" then o .:? "plexus_hash" .!= "" else o .: "plexus_hash"         case typ of           "progress" -> StreamProgress hash             <$> o .: "provenance"@@ -538,10 +585,11 @@             <*> o .: "suggestion"             <*> o .:? "available_methods"             <*> o .:? "method_schema"-          "error" -> StreamError hash-            <$> o .: "provenance"-            <*> o .: "error"-            <*> o .: "recoverable"+          "error" -> StreamErrorWith hash+            <$> o .:? "provenance" .!= Provenance []+            <*> (o .: "error" <|> o .: "message")+            <*> o .:? "recoverable" .!= False+            <*> pure (refusalFromObject o)           "request" -> StreamRequest hash             <$> o .: "provenance"             <*> o .: "request_id"@@ -573,12 +621,17 @@     [ ("available_methods" .=) <$> availMethods     , ("method_schema" .=) <$> methodSchema     ]-  toJSON (StreamError hash prov err rec) = object+  toJSON (StreamErrorWith hash prov err rec refusal) = object $     [ "type" .= ("error" :: Text)     , "metadata" .= StreamMetadata prov hash 0     , "message" .= err     , "recoverable" .= rec-    ]+    ] <> refusalFields+    where+      -- the refusal's own fields, beside the message (as the hub sent them)+      refusalFields = case toJSON <$> refusal of+        Just (Object ro) -> [ (k, v) | (k, v) <- KM.toList ro, k /= "message" ]+        _ -> []   toJSON (StreamRequest hash prov reqId reqData timeout) = object $     [ "type" .= ("request" :: Text)     , "metadata" .= StreamMetadata prov hash 0