plexus-synapse 3.13.0 → 3.15.0
raw patch · 55 files changed
+13872/−1266 lines, 55 filesdep +aeson-qqdep +base64-bytestringdep +case-insensitivedep ~aesondep ~plexus-protocoldep ~timePVP ok
version bump matches the API change (PVP)
Dependencies added: aeson-qq, base64-bytestring, case-insensitive, crypton, ram, temporary, tls, unix, uuid
Dependency ranges changed: aeson, plexus-protocol, time, vector
API changes (from Hackage documentation)
- Synapse.Monad: [seHost] :: SynapseEnv -> !Text
- Synapse.Monad: [sePort] :: SynapseEnv -> !Int
+ Synapse.Algebra.Render: Listing :: Text -> Maybe Text -> Listing
+ Synapse.Algebra.Render: [lsCaller] :: Listing -> Maybe Text
+ Synapse.Algebra.Render: [lsInvoke] :: Listing -> Text
+ Synapse.Algebra.Render: [rsWidth] :: RenderStyle -> !Int
+ Synapse.Algebra.Render: data Listing
+ Synapse.Algebra.Render: fitText :: Int -> Text -> Text
+ Synapse.Algebra.Render: instance GHC.Classes.Eq Synapse.Algebra.Render.Listing
+ Synapse.Algebra.Render: instance GHC.Show.Show Synapse.Algebra.Render.Listing
+ Synapse.Algebra.Render: quoteMask :: String -> [Bool]
+ Synapse.Algebra.Render: renderListing :: RenderStyle -> Listing -> PluginSchema -> Text
+ Synapse.Algebra.Render: renderSchemaFor :: RenderStyle -> Maybe Text -> PluginSchema -> Text
+ Synapse.Algebra.Render: summarize :: Text -> Text
+ Synapse.Auth: AnonPage :: Text -> Maybe Text -> [(Text, Text)] -> Text -> AnonPage
+ Synapse.Auth: SessionGrant :: Text -> Text -> Maybe Text -> Maybe Text -> Maybe Integer -> [Text] -> Maybe Whoami -> SessionGrant
+ Synapse.Auth: Whoami :: Text -> Maybe Text -> Maybe Text -> [(Text, Text)] -> [Text] -> Whoami
+ Synapse.Auth: [apBackend] :: AnonPage -> Text
+ Synapse.Auth: [apInvoke] :: AnonPage -> Text
+ Synapse.Auth: [apMethods] :: AnonPage -> [(Text, Text)]
+ Synapse.Auth: [apVersion] :: AnonPage -> Maybe Text
+ Synapse.Auth: [sgAgent] :: SessionGrant -> Maybe Text
+ Synapse.Auth: [sgCeiling] :: SessionGrant -> [Text]
+ Synapse.Auth: [sgExpiresAt] :: SessionGrant -> Maybe Integer
+ Synapse.Auth: [sgName] :: SessionGrant -> Maybe Text
+ Synapse.Auth: [sgPrincipal] :: SessionGrant -> Text
+ Synapse.Auth: [sgToken] :: SessionGrant -> Text
+ Synapse.Auth: [sgWhoami] :: SessionGrant -> Maybe Whoami
+ Synapse.Auth: [whoAgent] :: Whoami -> Maybe Text
+ Synapse.Auth: [whoCeiling] :: Whoami -> [Text]
+ Synapse.Auth: [whoMemberships] :: Whoami -> [(Text, Text)]
+ Synapse.Auth: [whoName] :: Whoami -> Maybe Text
+ Synapse.Auth: [whoPrincipal] :: Whoami -> Text
+ Synapse.Auth: abbreviatePrincipal :: Text -> Text
+ Synapse.Auth: anonymousMethods :: Maybe AuthInfo -> Maybe PluginSchema -> [(Text, Text)]
+ Synapse.Auth: data AnonPage
+ Synapse.Auth: data SessionGrant
+ Synapse.Auth: data Whoami
+ Synapse.Auth: instance GHC.Classes.Eq Synapse.Auth.AnonPage
+ Synapse.Auth: instance GHC.Classes.Eq Synapse.Auth.SessionGrant
+ Synapse.Auth: instance GHC.Classes.Eq Synapse.Auth.Whoami
+ Synapse.Auth: instance GHC.Show.Show Synapse.Auth.AnonPage
+ Synapse.Auth: instance GHC.Show.Show Synapse.Auth.SessionGrant
+ Synapse.Auth: instance GHC.Show.Show Synapse.Auth.Whoami
+ Synapse.Auth: isMissingAuth :: Text -> Bool
+ Synapse.Auth: parseSessionGrant :: Value -> Maybe SessionGrant
+ Synapse.Auth: parseWhoami :: Value -> Maybe Whoami
+ Synapse.Auth: renderAnonymousPage :: AnonPage -> Text
+ Synapse.Auth: renderCallerHeader :: Whoami -> Text
+ Synapse.Auth: renderRefusal :: [Text] -> Refusal -> Text
+ Synapse.Auth: scopeOf :: Text -> Text
+ Synapse.Auth.Loopback: CallbackCode :: Text -> CallbackResult
+ Synapse.Auth.Loopback: CallbackIgnored :: CallbackResult
+ Synapse.Auth.Loopback: CallbackRefused :: Text -> CallbackResult
+ Synapse.Auth.Loopback: awaitCallback :: Loopback -> Text -> Int -> IO (Either Text Text)
+ Synapse.Auth.Loopback: callbackPage :: Bool -> Text -> ByteString
+ Synapse.Auth.Loopback: checkCallback :: Text -> (Text, Text, [(Text, Text)]) -> CallbackResult
+ Synapse.Auth.Loopback: data CallbackResult
+ Synapse.Auth.Loopback: data Loopback
+ Synapse.Auth.Loopback: durationText :: Int -> Text
+ Synapse.Auth.Loopback: instance GHC.Classes.Eq Synapse.Auth.Loopback.CallbackResult
+ Synapse.Auth.Loopback: instance GHC.Show.Show Synapse.Auth.Loopback.CallbackResult
+ Synapse.Auth.Loopback: loopbackPort :: Loopback -> Int
+ Synapse.Auth.Loopback: loopbackRedirect :: Loopback -> Text
+ Synapse.Auth.Loopback: openBrowser :: Text -> IO Bool
+ Synapse.Auth.Loopback: parseRequestLine :: ByteString -> Maybe (Text, Text, [(Text, Text)])
+ Synapse.Auth.Loopback: percentDecode :: Text -> Text
+ Synapse.Auth.Loopback: withLoopback :: (Loopback -> IO a) -> IO a
+ Synapse.Backend.Discovery: defaultRegistryHost :: Text
+ Synapse.Backend.Discovery: defaultRegistryPort :: Int
+ Synapse.Backend.Discovery: endpointFor :: Endpoint -> Backend -> Endpoint
+ Synapse.Backend.Discovery: getBackendAtEp :: Headers -> Endpoint -> IO (Maybe Text)
+ Synapse.Backend.Discovery: getBackendAtWith :: Headers -> Text -> Int -> IO (Maybe Text)
+ Synapse.Backend.Discovery: pingBackendsVia :: Endpoint -> [Backend] -> IO [Backend]
+ Synapse.Backend.Discovery: probeBackendAtEp :: Headers -> Endpoint -> IO (Either Text Text)
+ Synapse.Backend.Discovery: probeBackendAtWith :: Headers -> Text -> Int -> IO (Either Text Text)
+ Synapse.Backend.Discovery: probeFailureAt :: Endpoint -> IO (Maybe Text)
+ Synapse.Backend.Discovery: probedInfo :: Text -> Int -> IO (Maybe BackendInfo)
+ Synapse.Backend.Discovery: probedInfoAt :: Endpoint -> IO (Maybe BackendInfo)
+ Synapse.Backend.Discovery: registryDiscoveryAt :: Endpoint -> BackendDiscovery
+ Synapse.Backend.Discovery: registryEndpointFromEnv :: IO (Text, Int)
+ Synapse.Backend.Discovery: registryUrlEnvVar :: String
+ Synapse.CLI.Help: MethodPage :: Text -> Text -> Maybe MethodSchema -> Bool -> MethodPage
+ Synapse.CLI.Help: [mpCaller] :: MethodPage -> Bool
+ Synapse.CLI.Help: [mpProgram] :: MethodPage -> Text
+ Synapse.CLI.Help: [mpSchema] :: MethodPage -> Maybe MethodSchema
+ Synapse.CLI.Help: [mpTarget] :: MethodPage -> Text
+ Synapse.CLI.Help: data MethodPage
+ Synapse.CLI.Help: flagLabel :: IR -> ParamDef -> Text
+ Synapse.CLI.Help: flagType :: IR -> TypeRef -> Text
+ Synapse.CLI.Help: instance GHC.Classes.Eq Synapse.CLI.Help.MethodPage
+ Synapse.CLI.Help: instance GHC.Show.Show Synapse.CLI.Help.MethodPage
+ Synapse.CLI.Help: methodPage :: MethodDef -> MethodPage
+ Synapse.CLI.Help: paramValuesOf :: IR -> ParamDef -> Maybe [Text]
+ Synapse.CLI.Help: renderExample :: Text -> Text -> MethodDef -> Value -> Text
+ Synapse.CLI.Help: renderMethodPage :: MethodPage -> IR -> MethodDef -> Text
+ Synapse.CLI.Help: serverSourced :: ParamDef -> Bool
+ Synapse.CLI.Help: shellQuote :: Text -> Text
+ Synapse.CLI.Help: synthesiseExample :: IR -> MethodDef -> Maybe Value -> Value
+ Synapse.CLI.Help: wrapWords :: Int -> Text -> [Text]
+ Synapse.CLI.Parse: MalformedListValue :: Text -> Text -> Text -> ParseError
+ Synapse.CLI.Parse: UnexpectedArgument :: Text -> ParseError
+ Synapse.CLI.Parse: isScalarElement :: IR -> TypeRef -> Bool
+ Synapse.CLI.Parse: pairFlags :: IR -> MethodDef -> [Text] -> Either [ParseError] [(Text, Text)]
+ Synapse.CLI.Parse: parsePathAndParams :: [Text] -> ([Text], [(Text, Text)], [Text], Bool)
+ Synapse.CLI.Parse: splitListValue :: Text -> Either Text [Text]
+ Synapse.CLI.Source: SourceEnv :: FilePath -> Maybe FilePath -> IO ByteString -> IORef (Maybe Text) -> SourceEnv
+ Synapse.CLI.Source: SourceUnreadable :: Text -> Text -> Text -> SourceError
+ Synapse.CLI.Source: [seCache] :: SourceEnv -> IORef (Maybe Text)
+ Synapse.CLI.Source: [seCwd] :: SourceEnv -> FilePath
+ Synapse.CLI.Source: [seHome] :: SourceEnv -> Maybe FilePath
+ Synapse.CLI.Source: [seKey] :: SourceError -> Text
+ Synapse.CLI.Source: [seSource] :: SourceError -> Text
+ Synapse.CLI.Source: [seStdin] :: SourceEnv -> IO ByteString
+ Synapse.CLI.Source: [seWhy] :: SourceError -> Text
+ Synapse.CLI.Source: data SourceEnv
+ Synapse.CLI.Source: data SourceError
+ Synapse.CLI.Source: expandSources :: SourceEnv -> [(Text, Text)] -> IO (Either SourceError [(Text, Text)])
+ Synapse.CLI.Source: expandValue :: SourceEnv -> Text -> Text -> IO (Either SourceError Text)
+ Synapse.CLI.Source: instance GHC.Classes.Eq Synapse.CLI.Source.SourceError
+ Synapse.CLI.Source: instance GHC.Show.Show Synapse.CLI.Source.SourceError
+ Synapse.CLI.Source: mkSourceEnv :: FilePath -> Maybe FilePath -> IO SourceEnv
+ Synapse.CLI.Source: renderSourceError :: SourceError -> Text
+ Synapse.CLI.Source: stdinWasRead :: SourceEnv -> IO Bool
+ Synapse.Endpoint: Endpoint :: !Text -> !Int -> !Bool -> !Text -> ![FilePath] -> Endpoint
+ Synapse.Endpoint: [epCaFiles] :: Endpoint -> ![FilePath]
+ Synapse.Endpoint: [epHost] :: Endpoint -> !Text
+ Synapse.Endpoint: [epPath] :: Endpoint -> !Text
+ Synapse.Endpoint: [epPort] :: Endpoint -> !Int
+ Synapse.Endpoint: [epTls] :: Endpoint -> !Bool
+ Synapse.Endpoint: addressKey :: Endpoint -> Text
+ Synapse.Endpoint: audience :: Endpoint -> Text
+ Synapse.Endpoint: cliFlags :: Text -> Int -> Endpoint -> Text
+ Synapse.Endpoint: cliOptions :: Endpoint -> Text
+ Synapse.Endpoint: data Endpoint
+ Synapse.Endpoint: defaultHost :: Text
+ Synapse.Endpoint: defaultPort :: Int
+ Synapse.Endpoint: dirName :: Endpoint -> FilePath
+ Synapse.Endpoint: instance GHC.Classes.Eq Synapse.Endpoint.Endpoint
+ Synapse.Endpoint: instance GHC.Classes.Ord Synapse.Endpoint.Endpoint
+ Synapse.Endpoint: instance GHC.Show.Show Synapse.Endpoint.Endpoint
+ Synapse.Endpoint: isPlain :: Endpoint -> Bool
+ Synapse.Endpoint: normalisePath :: Text -> Text
+ Synapse.Endpoint: parseUrl :: Text -> Either Text Endpoint
+ Synapse.Endpoint: plainAt :: Text -> Int -> Endpoint
+ Synapse.Endpoint: renderUrl :: Endpoint -> Text
+ Synapse.Endpoint: shortUrl :: Endpoint -> Text
+ Synapse.Endpoint: substrateConfig :: Endpoint -> Text -> Headers -> SubstrateConfig
+ Synapse.Find: Answer :: Bool -> Text -> [Value] -> Text -> Answer
+ Synapse.Find: Entry :: EntryKind -> MethodDef -> Maybe ParamDef -> Entry
+ Synapse.Find: Exact :: MatchKind
+ Synapse.Find: FindOpts :: Text -> [Text] -> Int -> Bool -> Bool -> FindOpts
+ Synapse.Find: FindRender :: Text -> Text -> [Text] -> [Text] -> Int -> Bool -> IR -> FindRender
+ Synapse.Find: Hit :: Entry -> [(Text, Where, WordMatch)] -> Double -> Hit
+ Synapse.Find: InDescription :: MatchKind
+ Synapse.Find: Mentioned :: Group
+ Synapse.Find: MethodEntry :: EntryKind
+ Synapse.Find: Methods :: Group
+ Synapse.Find: OnMethodDescription :: Where
+ Synapse.Find: OnMethodName :: Where
+ Synapse.Find: OnParamDescription :: Where
+ Synapse.Find: OnParamName :: Where
+ Synapse.Find: OnlyAll :: Only
+ Synapse.Find: OnlyBoth :: Only
+ Synapse.Find: OnlyMethods :: Only
+ Synapse.Find: OnlyParams :: Only
+ Synapse.Find: ParamEntry :: EntryKind
+ Synapse.Find: Parameters :: Group
+ Synapse.Find: Prefix :: MatchKind
+ Synapse.Find: SegmentExact :: MatchKind
+ Synapse.Find: SegmentPrefix :: MatchKind
+ Synapse.Find: Subsequence :: MatchKind
+ Synapse.Find: Typo :: Int -> MatchKind
+ Synapse.Find: WordMatch :: MatchKind -> Text -> [(Int, Int)] -> WordMatch
+ Synapse.Find: [anJson] :: Answer -> [Value]
+ Synapse.Find: [anMatched] :: Answer -> Bool
+ Synapse.Find: [anScope] :: Answer -> Text
+ Synapse.Find: [anText] :: Answer -> Text
+ Synapse.Find: [enKind] :: Entry -> EntryKind
+ Synapse.Find: [enMethod] :: Entry -> MethodDef
+ Synapse.Find: [enParam] :: Entry -> Maybe ParamDef
+ Synapse.Find: [foBackend] :: FindOpts -> Text
+ Synapse.Find: [foLimit] :: FindOpts -> Int
+ Synapse.Find: [foMethods] :: FindOpts -> Bool
+ Synapse.Find: [foParams] :: FindOpts -> Bool
+ Synapse.Find: [foTerms] :: FindOpts -> [Text]
+ Synapse.Find: [frBackend] :: FindRender -> Text
+ Synapse.Find: [frBold] :: FindRender -> Bool
+ Synapse.Find: [frIR] :: FindRender -> IR
+ Synapse.Find: [frLimit] :: FindRender -> Int
+ Synapse.Find: [frPath] :: FindRender -> [Text]
+ Synapse.Find: [frProgram] :: FindRender -> Text
+ Synapse.Find: [frQuery] :: FindRender -> [Text]
+ Synapse.Find: [hitEntry] :: Hit -> Entry
+ Synapse.Find: [hitScore] :: Hit -> Double
+ Synapse.Find: [hitWords] :: Hit -> [(Text, Where, WordMatch)]
+ Synapse.Find: [wmKind] :: WordMatch -> MatchKind
+ Synapse.Find: [wmSpans] :: WordMatch -> [(Int, Int)]
+ Synapse.Find: [wmTaken] :: WordMatch -> Text
+ Synapse.Find: answer :: Text -> Text -> Bool -> FindOpts -> IR -> Answer
+ Synapse.Find: catalogEntries :: [Text] -> IR -> [Entry]
+ Synapse.Find: data Answer
+ Synapse.Find: data Entry
+ Synapse.Find: data EntryKind
+ Synapse.Find: data FindOpts
+ Synapse.Find: data FindRender
+ Synapse.Find: data Group
+ Synapse.Find: data Hit
+ Synapse.Find: data MatchKind
+ Synapse.Find: data Only
+ Synapse.Find: data Where
+ Synapse.Find: data WordMatch
+ Synapse.Find: defaultLimit :: Int
+ Synapse.Find: entryLocation :: Entry -> Text
+ Synapse.Find: findInfo :: ParserInfo FindOpts
+ Synapse.Find: hitGroup :: Hit -> Group
+ Synapse.Find: hitJson :: IR -> Hit -> Value
+ Synapse.Find: instance GHC.Classes.Eq Synapse.Find.Entry
+ Synapse.Find: instance GHC.Classes.Eq Synapse.Find.EntryKind
+ Synapse.Find: instance GHC.Classes.Eq Synapse.Find.FindOpts
+ Synapse.Find: instance GHC.Classes.Eq Synapse.Find.Group
+ Synapse.Find: instance GHC.Classes.Eq Synapse.Find.Hit
+ Synapse.Find: instance GHC.Classes.Eq Synapse.Find.MatchKind
+ Synapse.Find: instance GHC.Classes.Eq Synapse.Find.Only
+ Synapse.Find: instance GHC.Classes.Eq Synapse.Find.Where
+ Synapse.Find: instance GHC.Classes.Eq Synapse.Find.WordMatch
+ Synapse.Find: instance GHC.Classes.Ord Synapse.Find.EntryKind
+ Synapse.Find: instance GHC.Classes.Ord Synapse.Find.Group
+ Synapse.Find: instance GHC.Enum.Bounded Synapse.Find.Group
+ Synapse.Find: instance GHC.Enum.Enum Synapse.Find.Group
+ Synapse.Find: instance GHC.Show.Show Synapse.Find.Entry
+ Synapse.Find: instance GHC.Show.Show Synapse.Find.EntryKind
+ Synapse.Find: instance GHC.Show.Show Synapse.Find.FindOpts
+ Synapse.Find: instance GHC.Show.Show Synapse.Find.Group
+ Synapse.Find: instance GHC.Show.Show Synapse.Find.Hit
+ Synapse.Find: instance GHC.Show.Show Synapse.Find.MatchKind
+ Synapse.Find: instance GHC.Show.Show Synapse.Find.Only
+ Synapse.Find: instance GHC.Show.Show Synapse.Find.Where
+ Synapse.Find: instance GHC.Show.Show Synapse.Find.WordMatch
+ Synapse.Find: kindScore :: MatchKind -> Double
+ Synapse.Find: loadCatalog :: Maybe IRCache -> SynapseM IR
+ Synapse.Find: matchDescription :: Text -> Text -> Maybe WordMatch
+ Synapse.Find: matchName :: Text -> Text -> Maybe WordMatch
+ Synapse.Find: narrowPath :: IR -> [Text] -> ([Text], [Text])
+ Synapse.Find: nearest :: [Entry] -> [Text] -> [Entry]
+ Synapse.Find: renderFind :: FindRender -> [Hit] -> Text
+ Synapse.Find: renderNoMatch :: FindRender -> [Entry] -> Text
+ Synapse.Find: search :: Only -> [Entry] -> [Text] -> [Hit]
+ Synapse.Find: takenAs :: [Hit] -> Maybe Text
+ Synapse.Find: typoLimit :: Int -> Int
+ Synapse.IR.Cache: irCacheKey :: Maybe Text -> Text -> Text
+ Synapse.IR.Types: [irPluginRequests] :: IR -> Maybe (Map Text Value)
+ Synapse.IR.Types: [irPluginRoles] :: IR -> Maybe (Map Text (Map Text [Text]))
+ Synapse.IR.Types: [mdGrant] :: MethodDef -> Maybe Text
+ Synapse.IR.Types: [mdLongDescription] :: MethodDef -> Maybe Text
+ Synapse.IR.Types: [mdTarget] :: MethodDef -> Maybe Text
+ Synapse.IR.Types: [pdSource] :: ParamDef -> Maybe Value
+ Synapse.IR.Types: [pdValues] :: ParamDef -> Maybe [Text]
+ Synapse.Identity.Command: KeyList :: KeyCommand
+ Synapse.Identity.Command: KeyNew :: Text -> StorePref -> KeyCommand
+ Synapse.Identity.Command: KeyShow :: Text -> KeyCommand
+ Synapse.Identity.Command: LoginOpts :: Text -> Maybe Text -> Maybe Text -> [Text] -> Maybe Integer -> LoginOpts
+ Synapse.Identity.Command: LogoutOpts :: Text -> Maybe Text -> LogoutOpts
+ Synapse.Identity.Command: [lgAs] :: LogoutOpts -> Maybe Text
+ Synapse.Identity.Command: [lgBackend] :: LogoutOpts -> Text
+ Synapse.Identity.Command: [loAs] :: LoginOpts -> Maybe Text
+ Synapse.Identity.Command: [loBackend] :: LoginOpts -> Text
+ Synapse.Identity.Command: [loKey] :: LoginOpts -> Maybe Text
+ Synapse.Identity.Command: [loScope] :: LoginOpts -> [Text]
+ Synapse.Identity.Command: [loTtl] :: LoginOpts -> Maybe Integer
+ Synapse.Identity.Command: data KeyCommand
+ Synapse.Identity.Command: data LoginOpts
+ Synapse.Identity.Command: data LogoutOpts
+ Synapse.Identity.Command: instance GHC.Classes.Eq Synapse.Identity.Command.KeyCommand
+ Synapse.Identity.Command: instance GHC.Classes.Eq Synapse.Identity.Command.LoginOpts
+ Synapse.Identity.Command: instance GHC.Classes.Eq Synapse.Identity.Command.LogoutOpts
+ Synapse.Identity.Command: instance GHC.Show.Show Synapse.Identity.Command.KeyCommand
+ Synapse.Identity.Command: instance GHC.Show.Show Synapse.Identity.Command.LoginOpts
+ Synapse.Identity.Command: instance GHC.Show.Show Synapse.Identity.Command.LogoutOpts
+ Synapse.Identity.Command: keyCommandInfo :: ParserInfo KeyCommand
+ Synapse.Identity.Command: loginInfo :: ParserInfo LoginOpts
+ Synapse.Identity.Command: logoutInfo :: ParserInfo LogoutOpts
+ Synapse.Identity.Command: runKeyCommand :: StoreEnv -> KeyCommand -> IO ExitCode
+ Synapse.Identity.Command: splitScopes :: [Text] -> [Text]
+ Synapse.Identity.Proof: b64urlNoPad :: ByteString -> Text
+ Synapse.Identity.Proof: buildProof :: Seed -> Text -> Text -> ByteString -> Integer -> Text
+ Synapse.Identity.Proof: canonicalMessage :: Text -> Text -> Text -> Integer -> Text
+ Synapse.Identity.Proof: data Seed
+ Synapse.Identity.Proof: decodeSeedText :: Text -> Either Text Seed
+ Synapse.Identity.Proof: encodeSeedText :: Seed -> Text
+ Synapse.Identity.Proof: generateSeed :: IO Seed
+ Synapse.Identity.Proof: hexText :: ByteString -> Text
+ Synapse.Identity.Proof: instance GHC.Classes.Eq Synapse.Identity.Proof.Seed
+ Synapse.Identity.Proof: instance GHC.Show.Show Synapse.Identity.Proof.Seed
+ Synapse.Identity.Proof: newNonce :: IO ByteString
+ Synapse.Identity.Proof: principalOf :: Seed -> Text
+ Synapse.Identity.Proof: publicKeyHex :: Seed -> Text
+ Synapse.Identity.Proof: seedBytes :: Seed -> ByteString
+ Synapse.Identity.Proof: seedFromBytes :: ByteString -> Either Text Seed
+ Synapse.Identity.Proof: validAgent :: Text -> Bool
+ Synapse.Identity.Proof: validKeyName :: Text -> Bool
+ Synapse.Identity.Proof: wsAudience :: Text -> Int -> Text
+ Synapse.Identity.Session: ActiveSession :: Endpoint -> Text -> Maybe SessionRec -> ActiveSession
+ Synapse.Identity.Session: LoginResult :: Text -> Text -> Text -> Maybe Integer -> [Text] -> LoginResult
+ Synapse.Identity.Session: [asEndpoint] :: ActiveSession -> Endpoint
+ Synapse.Identity.Session: [asRec] :: ActiveSession -> Maybe SessionRec
+ Synapse.Identity.Session: [asToken] :: ActiveSession -> Text
+ Synapse.Identity.Session: [lrAgent] :: LoginResult -> Text
+ Synapse.Identity.Session: [lrExpiresAt] :: LoginResult -> Maybe Integer
+ Synapse.Identity.Session: [lrPrincipal] :: LoginResult -> Text
+ Synapse.Identity.Session: [lrScope] :: LoginResult -> [Text]
+ Synapse.Identity.Session: [lrToken] :: LoginResult -> Text
+ Synapse.Identity.Session: bearer :: Text -> (Text, Text)
+ Synapse.Identity.Session: canReattest :: Endpoint -> IO Bool
+ Synapse.Identity.Session: currentCeiling :: IO [Text]
+ Synapse.Identity.Session: data ActiveSession
+ Synapse.Identity.Session: data LoginResult
+ Synapse.Identity.Session: instance GHC.Classes.Eq Synapse.Identity.Session.ActiveSession
+ Synapse.Identity.Session: instance GHC.Classes.Eq Synapse.Identity.Session.LoginResult
+ Synapse.Identity.Session: instance GHC.Show.Show Synapse.Identity.Session.ActiveSession
+ Synapse.Identity.Session: instance GHC.Show.Show Synapse.Identity.Session.LoginResult
+ Synapse.Identity.Session: isSessionEndedItem :: HubStreamItem -> Bool
+ Synapse.Identity.Session: isSessionEndedText :: Text -> Bool
+ Synapse.Identity.Session: isUpgradeRefused :: Text -> Bool
+ Synapse.Identity.Session: loginPerson :: Logger -> Endpoint -> Text -> Int -> (Text -> IO ()) -> IO (Either Text SessionGrant)
+ Synapse.Identity.Session: loginWithKey :: Logger -> Endpoint -> Text -> Seed -> Text -> [Text] -> Maybe Integer -> IO (Either Text LoginResult)
+ Synapse.Identity.Session: logoutWithToken :: Logger -> Endpoint -> Text -> Text -> IO (Either Text ())
+ Synapse.Identity.Session: markSessionEnded :: Text -> IO ()
+ Synapse.Identity.Session: notePrinted :: IO ()
+ Synapse.Identity.Session: noteRefused :: IO ()
+ Synapse.Identity.Session: printedAny :: IO Bool
+ Synapse.Identity.Session: reattest :: Logger -> StoreEnv -> Endpoint -> IO (Maybe ActiveSession)
+ Synapse.Identity.Session: reattestAgain :: Logger -> StoreEnv -> Endpoint -> IO (Maybe ActiveSession)
+ Synapse.Identity.Session: sessionFor :: Logger -> StoreEnv -> Maybe Text -> Endpoint -> IO (Maybe ActiveSession)
+ Synapse.Identity.Session: takeSessionEnded :: IO (Maybe Text)
+ Synapse.Identity.Session: wasRefused :: IO Bool
+ Synapse.Identity.Session: whoamiM :: SynapseM (Maybe Whoami)
+ Synapse.Identity.Session: whoamiWith :: Logger -> Endpoint -> Text -> Text -> IO (Maybe Whoami)
+ Synapse.Identity.Store: KcAbsent :: KcResult
+ Synapse.Identity.Store: KcFound :: Text -> KcResult
+ Synapse.Identity.Store: KcUnavailable :: Text -> KcResult
+ Synapse.Identity.Store: KeyFromCredentials :: FilePath -> KeySource
+ Synapse.Identity.Store: KeyFromEnv :: KeySource
+ Synapse.Identity.Store: KeyFromFile :: FilePath -> KeySource
+ Synapse.Identity.Store: KeyFromKeychain :: KeySource
+ Synapse.Identity.Store: Keychain :: Text -> (Text -> [(Text, Text)] -> IO KcResult) -> (Text -> Text -> [(Text, Text)] -> Text -> IO (Either Text ())) -> (Text -> [(Text, Text)] -> IO ()) -> (Text -> Text -> IO [Text]) -> Keychain
+ Synapse.Identity.Store: SessionFromEnv :: SessionSource
+ Synapse.Identity.Store: SessionFromFile :: FilePath -> SessionSource
+ Synapse.Identity.Store: SessionFromKeychain :: SessionSource
+ Synapse.Identity.Store: SessionRec :: Text -> Text -> Text -> Maybe Integer -> Text -> [Text] -> Maybe Integer -> Text -> SessionRec
+ Synapse.Identity.Store: StoreAuto :: StorePref
+ Synapse.Identity.Store: StoreEnv :: (String -> IO (Maybe String)) -> FilePath -> Maybe Keychain -> StoreEnv
+ Synapse.Identity.Store: StoreFile :: StorePref
+ Synapse.Identity.Store: StoreKeychain :: StorePref
+ Synapse.Identity.Store: [kcClear] :: Keychain -> Text -> [(Text, Text)] -> IO ()
+ Synapse.Identity.Store: [kcLabel] :: Keychain -> Text
+ Synapse.Identity.Store: [kcLookup] :: Keychain -> Text -> [(Text, Text)] -> IO KcResult
+ Synapse.Identity.Store: [kcNames] :: Keychain -> Text -> Text -> IO [Text]
+ Synapse.Identity.Store: [kcStore] :: Keychain -> Text -> Text -> [(Text, Text)] -> Text -> IO (Either Text ())
+ Synapse.Identity.Store: [srAgent] :: SessionRec -> Text
+ Synapse.Identity.Store: [srBackend] :: SessionRec -> Text
+ Synapse.Identity.Store: [srExpiresAt] :: SessionRec -> Maybe Integer
+ Synapse.Identity.Store: [srKey] :: SessionRec -> Text
+ Synapse.Identity.Store: [srPrincipal] :: SessionRec -> Text
+ Synapse.Identity.Store: [srScope] :: SessionRec -> [Text]
+ Synapse.Identity.Store: [srToken] :: SessionRec -> Text
+ Synapse.Identity.Store: [srTtl] :: SessionRec -> Maybe Integer
+ Synapse.Identity.Store: [stEnv] :: StoreEnv -> String -> IO (Maybe String)
+ Synapse.Identity.Store: [stKeychain] :: StoreEnv -> Maybe Keychain
+ Synapse.Identity.Store: [stPlexus] :: StoreEnv -> FilePath
+ Synapse.Identity.Store: addressKey :: Endpoint -> Text
+ Synapse.Identity.Store: clearDefaultAgent :: StoreEnv -> Endpoint -> Text -> IO ()
+ Synapse.Identity.Store: data KcResult
+ Synapse.Identity.Store: data KeySource
+ Synapse.Identity.Store: data Keychain
+ Synapse.Identity.Store: data SessionRec
+ Synapse.Identity.Store: data SessionSource
+ Synapse.Identity.Store: data StoreEnv
+ Synapse.Identity.Store: data StorePref
+ Synapse.Identity.Store: instance Data.Aeson.Types.FromJSON.FromJSON Synapse.Identity.Store.SessionRec
+ Synapse.Identity.Store: instance Data.Aeson.Types.ToJSON.ToJSON Synapse.Identity.Store.SessionRec
+ Synapse.Identity.Store: instance GHC.Classes.Eq Synapse.Identity.Store.KcResult
+ Synapse.Identity.Store: instance GHC.Classes.Eq Synapse.Identity.Store.KeySource
+ Synapse.Identity.Store: instance GHC.Classes.Eq Synapse.Identity.Store.SessionRec
+ Synapse.Identity.Store: instance GHC.Classes.Eq Synapse.Identity.Store.SessionSource
+ Synapse.Identity.Store: instance GHC.Classes.Eq Synapse.Identity.Store.StorePref
+ Synapse.Identity.Store: instance GHC.Show.Show Synapse.Identity.Store.KcResult
+ Synapse.Identity.Store: instance GHC.Show.Show Synapse.Identity.Store.KeySource
+ Synapse.Identity.Store: instance GHC.Show.Show Synapse.Identity.Store.SessionRec
+ Synapse.Identity.Store: instance GHC.Show.Show Synapse.Identity.Store.SessionSource
+ Synapse.Identity.Store: instance GHC.Show.Show Synapse.Identity.Store.StorePref
+ Synapse.Identity.Store: keyExists :: StoreEnv -> Text -> IO (Maybe KeySource)
+ Synapse.Identity.Store: keyFilePath :: StoreEnv -> Text -> FilePath
+ Synapse.Identity.Store: listKeys :: StoreEnv -> IO [(Text, KeySource)]
+ Synapse.Identity.Store: lookupKey :: StoreEnv -> Text -> IO (Either Text (Maybe (Seed, KeySource)))
+ Synapse.Identity.Store: lookupSession :: StoreEnv -> Endpoint -> Text -> IO (Maybe (Either Text SessionRec, SessionSource))
+ Synapse.Identity.Store: memoryKeychain :: IO (Keychain, IORef (Map (Text, [(Text, Text)]) Text))
+ Synapse.Identity.Store: readDefaultAgent :: StoreEnv -> Endpoint -> IO (Maybe Text)
+ Synapse.Identity.Store: removeSession :: StoreEnv -> Endpoint -> Text -> IO ()
+ Synapse.Identity.Store: renderKeySource :: KeySource -> Text
+ Synapse.Identity.Store: sessionFilePath :: StoreEnv -> Endpoint -> Text -> FilePath
+ Synapse.Identity.Store: systemKeychain :: IO (Maybe Keychain)
+ Synapse.Identity.Store: systemStoreEnv :: IO StoreEnv
+ Synapse.Identity.Store: withSessionLock :: StoreEnv -> Endpoint -> Text -> IO a -> IO a
+ Synapse.Identity.Store: writeDefaultAgent :: StoreEnv -> Endpoint -> Text -> IO ()
+ Synapse.Identity.Store: writeKey :: StoreEnv -> StorePref -> Text -> Seed -> IO (Either Text KeySource)
+ Synapse.Identity.Store: writePrivateFile :: FilePath -> ByteString -> IO ()
+ Synapse.Identity.Store: writeSession :: StoreEnv -> Endpoint -> SessionRec -> IO SessionSource
+ Synapse.Monad: [seCookies] :: SynapseEnv -> ![(Text, Text)]
+ Synapse.Monad: [seEndpoint] :: SynapseEnv -> !Endpoint
+ Synapse.Monad: [seHeaders] :: SynapseEnv -> ![(Text, Text)]
+ Synapse.Monad: [seViewKey] :: SynapseEnv -> !Maybe Text
+ Synapse.Monad: initEnvAt :: Endpoint -> Text -> Logger -> Maybe Text -> IO SynapseEnv
+ Synapse.Monad: seHost :: SynapseEnv -> Text
+ Synapse.Monad: sePort :: SynapseEnv -> Int
+ Synapse.Monad: viewQualified :: Maybe Text -> Text -> Text
+ Synapse.Monad: withRequestContext :: [(Text, Text)] -> [(Text, Text)] -> SynapseEnv -> SynapseEnv
+ Synapse.Monad: withViewKey :: Maybe Text -> SynapseEnv -> SynapseEnv
+ Synapse.Reconnect: CallShape :: Bool -> Bool -> Bool -> CallShape
+ Synapse.Reconnect: Failed :: TransportError -> Ending
+ Synapse.Reconnect: Finished :: Ending
+ Synapse.Reconnect: GaveUp :: Double -> Text -> Ending
+ Synapse.Reconnect: Resumed :: Double -> Int -> Maybe Integer -> Resumed
+ Synapse.Reconnect: WriteUnknown :: Text -> Ending
+ Synapse.Reconnect: [csRead] :: CallShape -> Bool
+ Synapse.Reconnect: [csSince] :: CallShape -> Bool
+ Synapse.Reconnect: [csStream] :: CallShape -> Bool
+ Synapse.Reconnect: [rsAttempts] :: Resumed -> Int
+ Synapse.Reconnect: [rsOutageSecs] :: Resumed -> Double
+ Synapse.Reconnect: [rsSince] :: Resumed -> Maybe Integer
+ Synapse.Reconnect: awaitBackend :: (Text -> Bool) -> Text -> IO (Either Text a) -> IO (Either Text a)
+ Synapse.Reconnect: callShape :: MethodSchema -> CallShape
+ Synapse.Reconnect: data CallShape
+ Synapse.Reconnect: data Ending
+ Synapse.Reconnect: data Resumed
+ Synapse.Reconnect: defaultWindow :: Double
+ Synapse.Reconnect: exitUnreachable :: Int
+ Synapse.Reconnect: exitWriteUnknown :: Int
+ Synapse.Reconnect: giveUp :: Text -> Double -> Text -> IO a
+ Synapse.Reconnect: instance GHC.Classes.Eq Synapse.Reconnect.CallShape
+ Synapse.Reconnect: instance GHC.Classes.Eq Synapse.Reconnect.Ending
+ Synapse.Reconnect: instance GHC.Classes.Eq Synapse.Reconnect.Resumed
+ Synapse.Reconnect: instance GHC.Show.Show Synapse.Reconnect.CallShape
+ Synapse.Reconnect: instance GHC.Show.Show Synapse.Reconnect.Ending
+ Synapse.Reconnect: instance GHC.Show.Show Synapse.Reconnect.Resumed
+ Synapse.Reconnect: isOutage :: TransportError -> Bool
+ Synapse.Reconnect: isOutageText :: Text -> Bool
+ Synapse.Reconnect: parseDuration :: Text -> Maybe Double
+ Synapse.Reconnect: readNames :: [Text]
+ Synapse.Reconnect: reconnectWindow :: IO Double
+ Synapse.Reconnect: renderDuration :: Double -> Text
+ Synapse.Reconnect: reportEnding :: Text -> Ending -> IO (Either TransportError ())
+ Synapse.Reconnect: resilientCall :: Text -> CallShape -> (Value -> IO () -> (PlexusStreamItem -> IO ()) -> IO (Either TransportError ())) -> IO Bool -> Value -> (Resumed -> IO ()) -> (PlexusStreamItem -> IO ()) -> IO Ending
+ Synapse.Reconnect: retrying :: Text -> IO (Either TransportError a) -> IO (Either TransportError a)
+ Synapse.Reconnect: setReconnectWindow :: Double -> IO ()
+ Synapse.Schema.Types: [methodExamples] :: MethodSchema -> [MethodExample]
+ Synapse.Schema.Types: [methodGrant] :: MethodSchema -> Maybe Text
+ Synapse.Schema.Types: [methodGroup] :: MethodSchema -> Maybe Text
+ Synapse.Schema.Types: [methodHeldOn] :: MethodSchema -> Maybe [Text]
+ Synapse.Schema.Types: [methodLongDescription] :: MethodSchema -> Maybe Text
+ Synapse.Schema.Types: [methodPublic] :: MethodSchema -> Bool
+ Synapse.Schema.Types: [methodTarget] :: MethodSchema -> Maybe Text
+ Synapse.Schema.Types: [psRoles] :: PluginSchema -> Maybe (Map Text [Text])
+ Synapse.Schema.Types: [psView] :: PluginSchema -> Maybe View
+ Synapse.Self: CredentialRef :: Text -> CredentialRef
+ Synapse.Self: HierarchicalBody :: Text -> Text -> [(Text, Text)] -> ParsedUriBody
+ Synapse.Self: NamedResolver :: Text -> ResolveFn -> NamedResolver
+ Synapse.Self: OpaqueBody :: Text -> ParsedUriBody
+ Synapse.Self: ParsedUri :: Text -> ParsedUriBody -> ParsedUri
+ Synapse.Self: ResolveBackendError :: CredentialRef -> Text -> ResolveError
+ Synapse.Self: ResolveNotFound :: CredentialRef -> ResolveError
+ Synapse.Self: ResolveParseError :: Text -> ResolveError
+ Synapse.Self: ResolveUnknownScheme :: Text -> ResolveError
+ Synapse.Self: ResolvedDefaults :: !Cookies -> !Headers -> ResolvedDefaults
+ Synapse.Self: ResolverRegistry :: Map Text ResolveFn -> ResolverRegistry
+ Synapse.Self: ScopedDefaults :: Map Text CredentialRef -> Map Text CredentialRef -> ScopedDefaults
+ Synapse.Self: StoredDefaults :: Int -> Map Text CredentialRef -> Map Text CredentialRef -> Map Text ScopedDefaults -> StoredDefaults
+ Synapse.Self: [nrResolve] :: NamedResolver -> ResolveFn
+ Synapse.Self: [nrScheme] :: NamedResolver -> Text
+ Synapse.Self: [puAuthority] :: ParsedUriBody -> Text
+ Synapse.Self: [puBody] :: ParsedUri -> ParsedUriBody
+ Synapse.Self: [puPath] :: ParsedUriBody -> Text
+ Synapse.Self: [puQuery] :: ParsedUriBody -> [(Text, Text)]
+ Synapse.Self: [puScheme] :: ParsedUri -> Text
+ Synapse.Self: [rdCookies] :: ResolvedDefaults -> !Cookies
+ Synapse.Self: [rdHeaders] :: ResolvedDefaults -> !Headers
+ Synapse.Self: [schemes] :: ResolverRegistry -> Map Text ResolveFn
+ Synapse.Self: [sdCookies] :: StoredDefaults -> Map Text CredentialRef
+ Synapse.Self: [sdHeaders] :: StoredDefaults -> Map Text CredentialRef
+ Synapse.Self: [sdScopes] :: StoredDefaults -> Map Text ScopedDefaults
+ Synapse.Self: [sdVersion] :: StoredDefaults -> Int
+ Synapse.Self: [sdsCookies] :: ScopedDefaults -> Map Text CredentialRef
+ Synapse.Self: [sdsHeaders] :: ScopedDefaults -> Map Text CredentialRef
+ Synapse.Self: [unCredentialRef] :: CredentialRef -> Text
+ Synapse.Self: class Resolver r
+ Synapse.Self: currentVersion :: Int
+ Synapse.Self: data NamedResolver
+ Synapse.Self: data ParsedUri
+ Synapse.Self: data ParsedUriBody
+ Synapse.Self: data ResolveError
+ Synapse.Self: data ResolvedDefaults
+ Synapse.Self: data ScopedDefaults
+ Synapse.Self: data StoredDefaults
+ Synapse.Self: decodeDefaults :: ByteString -> Either Text StoredDefaults
+ Synapse.Self: defaultRegistry :: ResolverRegistry
+ Synapse.Self: defaultsPath :: Text -> FilePath
+ Synapse.Self: deleteFromKeychain :: Text -> Text -> IO (Either Text ())
+ Synapse.Self: emptyRegistry :: ResolverRegistry
+ Synapse.Self: emptyResolvedDefaults :: ResolvedDefaults
+ Synapse.Self: emptyStoredDefaults :: StoredDefaults
+ Synapse.Self: encodeDefaults :: StoredDefaults -> ByteString
+ Synapse.Self: envResolver :: ResolveFn
+ Synapse.Self: fileResolver :: ResolveFn
+ Synapse.Self: isKeychainPlatform :: Bool
+ Synapse.Self: keychainResolver :: ResolveFn
+ Synapse.Self: literalResolver :: ResolveFn
+ Synapse.Self: loadDefaults :: Text -> IO StoredDefaults
+ Synapse.Self: lookupResolver :: Text -> ResolverRegistry -> Maybe ResolveFn
+ Synapse.Self: merge :: ResolvedDefaults -> Cookies -> Headers -> (Cookies, Headers)
+ Synapse.Self: newtype CredentialRef
+ Synapse.Self: newtype ResolverRegistry
+ Synapse.Self: parseUri :: CredentialRef -> Either Text ParsedUri
+ Synapse.Self: registerNamedResolver :: NamedResolver -> ResolverRegistry -> ResolverRegistry
+ Synapse.Self: registerResolver :: Text -> ResolveFn -> ResolverRegistry -> ResolverRegistry
+ Synapse.Self: resolve :: Resolver r => r -> ResolveFn
+ Synapse.Self: resolveAll :: ResolverRegistry -> StoredDefaults -> MethodPath -> IO (Either ResolveError ResolvedDefaults)
+ Synapse.Self: resolveRef :: ResolverRegistry -> CredentialRef -> IO (Either ResolveError Text)
+ Synapse.Self: resolveToken :: Text -> IO (Maybe Text)
+ Synapse.Self: scheme :: Resolver r => Proxy r -> Text
+ Synapse.Self: storeInKeychain :: Text -> Text -> Text -> IO (Either Text ())
+ Synapse.Self: type Cookies = Map Text Text
+ Synapse.Self: type Headers = Map Text Text
+ Synapse.Self: type MethodPath = [Text]
+ Synapse.Self: type ResolveFn = ParsedUri -> IO Either ResolveError Text
+ Synapse.Self: writeDefaults :: Text -> StoredDefaults -> IO ()
+ Synapse.Self.Command: CmdClear :: !Text -> !Bool -> SelfCommand
+ Synapse.Self.Command: CmdImportToken :: !Text -> !Text -> !Bool -> SelfCommand
+ Synapse.Self.Command: CmdResolve :: !Text -> !SelfKind -> !Text -> SelfCommand
+ Synapse.Self.Command: CmdSet :: !Text -> !SelfKind -> !Text -> !Text -> SelfCommand
+ Synapse.Self.Command: CmdSetFromStdin :: !Text -> !SelfKind -> !Text -> SelfCommand
+ Synapse.Self.Command: CmdSetSecret :: !Text -> !SelfKind -> !Text -> SelfCommand
+ Synapse.Self.Command: CmdShow :: !Text -> !Bool -> SelfCommand
+ Synapse.Self.Command: CmdUnset :: !Text -> !SelfKind -> !Text -> !Bool -> !Bool -> SelfCommand
+ Synapse.Self.Command: CmdUpgrade :: !Text -> !Maybe SelfKind -> !Maybe Text -> SelfCommand
+ Synapse.Self.Command: Cookie :: SelfKind
+ Synapse.Self.Command: Header :: SelfKind
+ Synapse.Self.Command: JwtClaim :: !Text -> !Text -> JwtClaim
+ Synapse.Self.Command: JwtSummary :: !Maybe Text -> !Maybe Text -> !Maybe Text -> !Maybe Text -> !Maybe Text -> !Maybe UTCTime -> !Maybe UTCTime -> !Maybe Text -> !Maybe Text -> ![JwtClaim] -> JwtSummary
+ Synapse.Self.Command: [jcName] :: JwtClaim -> !Text
+ Synapse.Self.Command: [jcValue] :: JwtClaim -> !Text
+ Synapse.Self.Command: [jsAlg] :: JwtSummary -> !Maybe Text
+ Synapse.Self.Command: [jsAud] :: JwtSummary -> !Maybe Text
+ Synapse.Self.Command: [jsEmail] :: JwtSummary -> !Maybe Text
+ Synapse.Self.Command: [jsExp] :: JwtSummary -> !Maybe UTCTime
+ Synapse.Self.Command: [jsExtraClaims] :: JwtSummary -> ![JwtClaim]
+ Synapse.Self.Command: [jsIat] :: JwtSummary -> !Maybe UTCTime
+ Synapse.Self.Command: [jsIss] :: JwtSummary -> !Maybe Text
+ Synapse.Self.Command: [jsKid] :: JwtSummary -> !Maybe Text
+ Synapse.Self.Command: [jsSub] :: JwtSummary -> !Maybe Text
+ Synapse.Self.Command: [jsUsername] :: JwtSummary -> !Maybe Text
+ Synapse.Self.Command: data JwtClaim
+ Synapse.Self.Command: data JwtSummary
+ Synapse.Self.Command: data SelfCommand
+ Synapse.Self.Command: data SelfKind
+ Synapse.Self.Command: decodeJwt :: Text -> Maybe JwtSummary
+ Synapse.Self.Command: instance GHC.Classes.Eq Synapse.Self.Command.JwtClaim
+ Synapse.Self.Command: instance GHC.Classes.Eq Synapse.Self.Command.JwtSummary
+ Synapse.Self.Command: instance GHC.Classes.Eq Synapse.Self.Command.SelfCommand
+ Synapse.Self.Command: instance GHC.Classes.Eq Synapse.Self.Command.SelfKind
+ Synapse.Self.Command: instance GHC.Show.Show Synapse.Self.Command.JwtClaim
+ Synapse.Self.Command: instance GHC.Show.Show Synapse.Self.Command.JwtSummary
+ Synapse.Self.Command: instance GHC.Show.Show Synapse.Self.Command.SelfCommand
+ Synapse.Self.Command: instance GHC.Show.Show Synapse.Self.Command.SelfKind
+ Synapse.Self.Command: kindLabel :: SelfKind -> Text
+ Synapse.Self.Command: knownSchemes :: [Text]
+ Synapse.Self.Command: parseSelfCommand :: Parser SelfCommand
+ Synapse.Self.Command: renderJwtSummary :: UTCTime -> JwtSummary -> [Text]
+ Synapse.Self.Command: renderKind :: SelfKind -> Text
+ Synapse.Self.Command: renderRelativeExpiry :: UTCTime -> UTCTime -> Text
+ Synapse.Self.Command: runSelfCommand :: SelfCommand -> IO ExitCode
+ Synapse.Self.Command: selfCommandInfo :: ParserInfo SelfCommand
+ Synapse.Self.Command: wrapAsLiteralIfNeeded :: Text -> Text
+ Synapse.Self.IO: ResolvedDefaults :: !Cookies -> !Headers -> ResolvedDefaults
+ Synapse.Self.IO: [rdCookies] :: ResolvedDefaults -> !Cookies
+ Synapse.Self.IO: [rdHeaders] :: ResolvedDefaults -> !Headers
+ Synapse.Self.IO: absDefaultsPath :: Text -> IO FilePath
+ Synapse.Self.IO: data ResolvedDefaults
+ Synapse.Self.IO: emptyResolvedDefaults :: ResolvedDefaults
+ Synapse.Self.IO: instance GHC.Classes.Eq Synapse.Self.IO.ResolvedDefaults
+ Synapse.Self.IO: instance GHC.Show.Show Synapse.Self.IO.ResolvedDefaults
+ Synapse.Self.IO: loadDefaults :: Text -> IO StoredDefaults
+ Synapse.Self.IO: merge :: ResolvedDefaults -> Cookies -> Headers -> (Cookies, Headers)
+ Synapse.Self.IO: resolveAll :: ResolverRegistry -> StoredDefaults -> MethodPath -> IO (Either ResolveError ResolvedDefaults)
+ Synapse.Self.IO: type Cookies = Map Text Text
+ Synapse.Self.IO: type Headers = Map Text Text
+ Synapse.Self.IO: type MethodPath = [Text]
+ Synapse.Self.IO: writeDefaults :: Text -> StoredDefaults -> IO ()
+ Synapse.Self.Resolve: HierarchicalBody :: Text -> Text -> [(Text, Text)] -> ParsedUriBody
+ Synapse.Self.Resolve: NamedResolver :: Text -> ResolveFn -> NamedResolver
+ Synapse.Self.Resolve: OpaqueBody :: Text -> ParsedUriBody
+ Synapse.Self.Resolve: ParsedUri :: Text -> ParsedUriBody -> ParsedUri
+ Synapse.Self.Resolve: ResolveBackendError :: CredentialRef -> Text -> ResolveError
+ Synapse.Self.Resolve: ResolveNotFound :: CredentialRef -> ResolveError
+ Synapse.Self.Resolve: ResolveParseError :: Text -> ResolveError
+ Synapse.Self.Resolve: ResolveUnknownScheme :: Text -> ResolveError
+ Synapse.Self.Resolve: ResolverRegistry :: Map Text ResolveFn -> ResolverRegistry
+ Synapse.Self.Resolve: [nrResolve] :: NamedResolver -> ResolveFn
+ Synapse.Self.Resolve: [nrScheme] :: NamedResolver -> Text
+ Synapse.Self.Resolve: [puAuthority] :: ParsedUriBody -> Text
+ Synapse.Self.Resolve: [puBody] :: ParsedUri -> ParsedUriBody
+ Synapse.Self.Resolve: [puPath] :: ParsedUriBody -> Text
+ Synapse.Self.Resolve: [puQuery] :: ParsedUriBody -> [(Text, Text)]
+ Synapse.Self.Resolve: [puScheme] :: ParsedUri -> Text
+ Synapse.Self.Resolve: [schemes] :: ResolverRegistry -> Map Text ResolveFn
+ Synapse.Self.Resolve: class Resolver r
+ Synapse.Self.Resolve: data NamedResolver
+ Synapse.Self.Resolve: data ParsedUri
+ Synapse.Self.Resolve: data ParsedUriBody
+ Synapse.Self.Resolve: data ResolveError
+ Synapse.Self.Resolve: emptyRegistry :: ResolverRegistry
+ Synapse.Self.Resolve: instance GHC.Base.Monoid Synapse.Self.Resolve.ResolverRegistry
+ Synapse.Self.Resolve: instance GHC.Base.Semigroup Synapse.Self.Resolve.ResolverRegistry
+ Synapse.Self.Resolve: instance GHC.Classes.Eq Synapse.Self.Resolve.ParsedUri
+ Synapse.Self.Resolve: instance GHC.Classes.Eq Synapse.Self.Resolve.ParsedUriBody
+ Synapse.Self.Resolve: instance GHC.Classes.Eq Synapse.Self.Resolve.ResolveError
+ Synapse.Self.Resolve: instance GHC.Show.Show Synapse.Self.Resolve.ParsedUri
+ Synapse.Self.Resolve: instance GHC.Show.Show Synapse.Self.Resolve.ParsedUriBody
+ Synapse.Self.Resolve: instance GHC.Show.Show Synapse.Self.Resolve.ResolveError
+ Synapse.Self.Resolve: lookupResolver :: Text -> ResolverRegistry -> Maybe ResolveFn
+ Synapse.Self.Resolve: newtype ResolverRegistry
+ Synapse.Self.Resolve: parseUri :: CredentialRef -> Either Text ParsedUri
+ Synapse.Self.Resolve: registerNamedResolver :: NamedResolver -> ResolverRegistry -> ResolverRegistry
+ Synapse.Self.Resolve: registerResolver :: Text -> ResolveFn -> ResolverRegistry -> ResolverRegistry
+ Synapse.Self.Resolve: resolve :: Resolver r => r -> ResolveFn
+ Synapse.Self.Resolve: resolveRef :: ResolverRegistry -> CredentialRef -> IO (Either ResolveError Text)
+ Synapse.Self.Resolve: scheme :: Resolver r => Proxy r -> Text
+ Synapse.Self.Resolve: type ResolveFn = ParsedUri -> IO Either ResolveError Text
+ Synapse.Self.Resolve.Default: defaultRegistry :: ResolverRegistry
+ Synapse.Self.Resolve.Env: envResolver :: ResolveFn
+ Synapse.Self.Resolve.File: fileResolver :: ResolveFn
+ Synapse.Self.Resolve.Keychain: deleteFromKeychain :: Text -> Text -> IO (Either Text ())
+ Synapse.Self.Resolve.Keychain: isKeychainPlatform :: Bool
+ Synapse.Self.Resolve.Keychain: keychainResolver :: ResolveFn
+ Synapse.Self.Resolve.Keychain: platformName :: Text
+ Synapse.Self.Resolve.Keychain: storeInKeychain :: Text -> Text -> Text -> IO (Either Text ())
+ Synapse.Self.Resolve.Literal: literalResolver :: ResolveFn
+ Synapse.Self.Types: CredentialRef :: Text -> CredentialRef
+ Synapse.Self.Types: ScopedDefaults :: Map Text CredentialRef -> Map Text CredentialRef -> ScopedDefaults
+ Synapse.Self.Types: StoredDefaults :: Int -> Map Text CredentialRef -> Map Text CredentialRef -> Map Text ScopedDefaults -> StoredDefaults
+ Synapse.Self.Types: [sdCookies] :: StoredDefaults -> Map Text CredentialRef
+ Synapse.Self.Types: [sdHeaders] :: StoredDefaults -> Map Text CredentialRef
+ Synapse.Self.Types: [sdScopes] :: StoredDefaults -> Map Text ScopedDefaults
+ Synapse.Self.Types: [sdVersion] :: StoredDefaults -> Int
+ Synapse.Self.Types: [sdsCookies] :: ScopedDefaults -> Map Text CredentialRef
+ Synapse.Self.Types: [sdsHeaders] :: ScopedDefaults -> Map Text CredentialRef
+ Synapse.Self.Types: [unCredentialRef] :: CredentialRef -> Text
+ Synapse.Self.Types: currentVersion :: Int
+ Synapse.Self.Types: data ScopedDefaults
+ Synapse.Self.Types: data StoredDefaults
+ Synapse.Self.Types: decodeDefaults :: ByteString -> Either Text StoredDefaults
+ Synapse.Self.Types: emptyStoredDefaults :: StoredDefaults
+ Synapse.Self.Types: encodeDefaults :: StoredDefaults -> ByteString
+ Synapse.Self.Types: instance Data.Aeson.Types.FromJSON.FromJSON Synapse.Self.Types.CredentialRef
+ Synapse.Self.Types: instance Data.Aeson.Types.FromJSON.FromJSON Synapse.Self.Types.ScopedDefaults
+ Synapse.Self.Types: instance Data.Aeson.Types.FromJSON.FromJSON Synapse.Self.Types.StoredDefaults
+ Synapse.Self.Types: instance Data.Aeson.Types.ToJSON.ToJSON Synapse.Self.Types.CredentialRef
+ Synapse.Self.Types: instance Data.Aeson.Types.ToJSON.ToJSON Synapse.Self.Types.ScopedDefaults
+ Synapse.Self.Types: instance Data.Aeson.Types.ToJSON.ToJSON Synapse.Self.Types.StoredDefaults
+ Synapse.Self.Types: instance GHC.Classes.Eq Synapse.Self.Types.CredentialRef
+ Synapse.Self.Types: instance GHC.Classes.Eq Synapse.Self.Types.ScopedDefaults
+ Synapse.Self.Types: instance GHC.Classes.Eq Synapse.Self.Types.StoredDefaults
+ Synapse.Self.Types: instance GHC.Classes.Ord Synapse.Self.Types.CredentialRef
+ Synapse.Self.Types: instance GHC.Show.Show Synapse.Self.Types.CredentialRef
+ Synapse.Self.Types: instance GHC.Show.Show Synapse.Self.Types.ScopedDefaults
+ Synapse.Self.Types: instance GHC.Show.Show Synapse.Self.Types.StoredDefaults
+ Synapse.Self.Types: newtype CredentialRef
+ Synapse.Transport: mergeUpgradeHeaders :: Maybe Text -> [(Text, Text)] -> [(Text, Text)] -> Headers
- Synapse.Algebra.Render: RenderStyle :: !Int -> !Bool -> !Bool -> !Bool -> RenderStyle
+ Synapse.Algebra.Render: RenderStyle :: !Int -> !Bool -> !Bool -> !Bool -> !Int -> RenderStyle
- Synapse.IR.Types: IR :: Text -> Text -> Maybe Text -> Maybe GenerationMetadata -> Map Text TypeDef -> Map Text MethodDef -> Map Text [Text] -> Maybe (Map Text PluginHashInfo) -> IR
+ Synapse.IR.Types: IR :: Text -> Text -> Maybe Text -> Maybe GenerationMetadata -> Map Text TypeDef -> Map Text MethodDef -> Map Text [Text] -> Maybe (Map Text PluginHashInfo) -> Maybe (Map Text Value) -> Maybe (Map Text (Map Text [Text])) -> IR
- Synapse.IR.Types: MethodDef :: Text -> Text -> Text -> Maybe Text -> Bool -> [ParamDef] -> TypeRef -> Maybe TypeRef -> Maybe TypeRef -> Maybe Value -> MethodRole -> MethodDef
+ Synapse.IR.Types: MethodDef :: Text -> Text -> Text -> Maybe Text -> Bool -> [ParamDef] -> TypeRef -> Maybe TypeRef -> Maybe TypeRef -> Maybe Value -> MethodRole -> Maybe Text -> Maybe Text -> Maybe Text -> MethodDef
- Synapse.IR.Types: ParamDef :: Text -> TypeRef -> Maybe Text -> Bool -> Maybe Value -> Maybe DeprecationInfo -> ParamDef
+ Synapse.IR.Types: ParamDef :: Text -> TypeRef -> Maybe Text -> Bool -> Maybe Value -> Maybe DeprecationInfo -> Maybe Value -> Maybe [Text] -> ParamDef
- Synapse.Monad: SynapseEnv :: !Text -> !Int -> !Text -> !IORef (HashMap PluginHash PluginSchema) -> !IORef (HashSet PluginHash) -> !Logger -> !Maybe Text -> SynapseEnv
+ Synapse.Monad: SynapseEnv :: !Endpoint -> !Text -> !IORef (HashMap PluginHash PluginSchema) -> !IORef (HashSet PluginHash) -> !Logger -> !Maybe Text -> ![(Text, Text)] -> ![(Text, Text)] -> !Maybe Text -> SynapseEnv
- Synapse.Schema.Types: MethodSchema :: Text -> Text -> PluginHash -> Maybe Value -> Maybe Value -> Bool -> Bool -> Maybe Value -> Maybe Value -> Maybe DeprecationInfo -> Maybe [ParamSchema] -> MethodRole -> MethodSchema
+ Synapse.Schema.Types: 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
- Synapse.Schema.Types: PluginSchema :: Text -> Text -> Text -> Maybe Text -> PluginHash -> [MethodSchema] -> Maybe [ChildSummary] -> Maybe DeprecationInfo -> PluginSchema
+ Synapse.Schema.Types: PluginSchema :: Text -> Text -> Text -> Maybe Text -> PluginHash -> [MethodSchema] -> Maybe [ChildSummary] -> Maybe DeprecationInfo -> Maybe (Map Text [Text]) -> Maybe View -> PluginSchema
- Synapse.Transport: invokeStreamingWithBidir :: Path -> Text -> Value -> (HubStreamItem -> IO ()) -> (Text -> Request Value -> IO (Maybe (Response Value))) -> SynapseM ()
+ Synapse.Transport: invokeStreamingWithBidir :: CallShape -> IO (Maybe Headers) -> (Resumed -> IO ()) -> Path -> Text -> Value -> (HubStreamItem -> IO ()) -> (Text -> Request Value -> IO (Maybe (Response Value))) -> SynapseM ()
Files
- app/Main.hs +1770/−980
- plexus-synapse.cabal +316/−3
- src/Synapse/Algebra/Render.hs +270/−88
- src/Synapse/Auth.hs +330/−0
- src/Synapse/Auth/Loopback.hs +199/−0
- src/Synapse/Backend/Discovery.hs +199/−72
- src/Synapse/CLI/Help.hs +356/−25
- src/Synapse/CLI/Parse.hs +241/−7
- src/Synapse/CLI/Source.hs +130/−0
- src/Synapse/CLI/Transform.hs +16/−7
- src/Synapse/Endpoint.hs +204/−0
- src/Synapse/Find.hs +657/−0
- src/Synapse/IR/Builder.hs +34/−1
- src/Synapse/IR/Cache.hs +14/−1
- src/Synapse/IR/Types.hs +65/−3
- src/Synapse/Identity/Command.hs +157/−0
- src/Synapse/Identity/Proof.hs +165/−0
- src/Synapse/Identity/Session.hs +521/−0
- src/Synapse/Identity/Store.hs +552/−0
- src/Synapse/Monad.hs +58/−8
- src/Synapse/Reconnect.hs +418/−0
- src/Synapse/Self.hs +139/−0
- src/Synapse/Self/Command.hs +1020/−0
- src/Synapse/Self/Commands.hs +3/−10
- src/Synapse/Self/IO.hs +445/−0
- src/Synapse/Self/Protocol/TestRunner.hs +3/−0
- src/Synapse/Self/Resolve.hs +216/−0
- src/Synapse/Self/Resolve/Default.hs +43/−0
- src/Synapse/Self/Resolve/Env.hs +53/−0
- src/Synapse/Self/Resolve/File.hs +86/−0
- src/Synapse/Self/Resolve/Keychain.hs +256/−0
- src/Synapse/Self/Resolve/Literal.hs +40/−0
- src/Synapse/Self/Types.hs +176/−0
- src/Synapse/Transport.hs +115/−17
- test/AuthSpec.hs +369/−0
- test/DeprecationSpec.hs +5/−1
- test/EndpointSpec.hs +107/−0
- test/FindSpec.hs +249/−0
- test/IR12MethodRoleSpec.hs +46/−1
- test/IRSpec.hs +3/−2
- test/IdentitySpec.hs +296/−0
- test/KeychainSpec.hs +147/−0
- test/MockHub.hs +320/−0
- test/ParseSpec.hs +354/−0
- test/PathNormalizationSpec.hs +13/−40
- test/ReconnectSpec.hs +284/−0
- test/RenderSpec.hs +318/−0
- test/Req8RenderSpec.hs +137/−0
- test/SchemaDecodeSpec.hs +190/−0
- test/SelfCommandSpec.hs +278/−0
- test/SelfMigrationSpec.hs +260/−0
- test/SelfSafeWriteSpec.hs +301/−0
- test/SelfSpec.hs +629/−0
- test/SourceSpec.hs +94/−0
- test/TlsSpec.hs +205/−0
app/Main.hs view
@@ -15,983 +15,1773 @@ -- is passed through for method invocation. module Main where -import Control.Monad (when)-import Control.Monad.IO.Class (liftIO)-import Control.Monad.Reader (asks)-import Data.Aeson-import qualified Data.Aeson.Key as K-import qualified Data.Aeson.KeyMap as KM-import qualified Data.ByteString.Lazy.Char8 as LBS-import Data.Maybe (isJust)-import Data.Text (Text)-import qualified Data.Text as T-import qualified Data.Text.IO as TIO-import qualified Data.Text.Encoding as TE-import Options.Applicative-import Data.Version (showVersion)-import qualified Paths_plexus_synapse as Meta-import System.Directory (doesFileExist, getHomeDirectory)-import System.Exit (exitFailure, exitSuccess)-import System.FilePath ((</>))-import System.IO (hPutStrLn, stderr, hFlush, stdout)---import Synapse.Schema.Types-import Synapse.Monad (SynapseM, SynapseEnv(..), SynapseError(..), BackendErrorType(..), TransportContext(..), TransportErrorCategory(..), initEnv, runSynapseM, throwNav, throwTransport, throwParse, throwBackend)-import qualified Synapse.Log as Log-import qualified Katip-import Synapse.Algebra.Navigate-import Synapse.Algebra.Render (renderSchema)-import Synapse.CLI.Help (renderMethodHelp)-import Synapse.CLI.Parse (parseParams)-import qualified Synapse.CLI.Parse as Parse-import Synapse.IR.Types (IR, irMethods, MethodDef)-import qualified Data.Map.Strict as Map-import qualified Synapse.CLI.Template as TemplateIR-import Synapse.Deprecation (emitActivationWarning, emitMethodWarning)-import Synapse.Transport-import Synapse.Bidir (BidirMode(..), parseBidirMode, detectBidirMode, handleBidirRequest)-import Plexus.Types (Response(..))-import Synapse.CLI.Transform (mkTransformEnv, transformParams, defaultTransformers, injectBooleanDefaults, injectSmartDefaults)-import Synapse.IR.Builder (buildIR)-import Synapse.Renderer (RendererConfig, defaultRendererConfig, renderItem, prettyValue, withMethodPath)-import System.Directory (createDirectoryIfMissing, getHomeDirectory)-import System.FilePath ((</>))-import qualified Synapse.Self.Commands as Self-import Synapse.Backend.Discovery (Backend(..), BackendDiscovery(..), registryDiscovery, pingBackends, getBackendAt, registerWithRegistry)---- ============================================================================--- Types--- ============================================================================---- | Synapse-level options (must appear before backend)--- Controls connection settings and output format-data SynapseOpts = SynapseOpts- { soHost :: Text -- ^ Registry host for discovery- , soPort :: Int -- ^ Registry port for discovery- , soJson :: Bool -- ^ Output raw JSON stream items- , soRaw :: Bool -- ^ Output raw content (no templates)- , soDryRun :: Bool -- ^ Show request without sending- , soSchema :: Bool -- ^ Fetch raw schema JSON- , soGenerate :: Bool -- ^ Generate templates from IR- , soEmitIR :: Bool -- ^ Emit IR for code generation- , soParams :: Maybe Text -- ^ JSON params via -p- , soRpc :: Maybe Text -- ^ Raw JSON-RPC passthrough- , soGeneratorInfo :: [Text] -- ^ Generator tool info (tool:version pairs)- , soBidirMode :: Maybe Text -- ^ Bidirectional mode override- , soBidirCmd :: Maybe Text -- ^ Bidirectional subprocess command (--bidir-cmd)- , soNoCache :: Bool -- ^ Disable IR caching- , soLogLevel :: Maybe Text -- ^ Log level: info, debug, trace- , soLogSubsystems :: [Text] -- ^ Filter logs by subsystems (empty = all)- , soToken :: Maybe Text -- ^ JWT sent as Cookie: access_token=<jwt>- , soTokenFile :: Maybe Text -- ^ Path to token file (overridden by --token)- , soNoDeprecationWarnings :: Bool -- ^ Suppress invocation-time deprecation warnings (IR-15)- }- deriving Show---- | Full CLI arguments after two-phase parsing-data Args = Args- { argOpts :: SynapseOpts -- ^ Synapse-level options- , argBackend :: Maybe Text -- ^ Backend name (first positional)- , argPath :: [Text] -- ^ Path segments and --key value params (raw)- }- deriving Show----- ============================================================================--- Constants--- ============================================================================--defaultHost :: Text-defaultHost = "127.0.0.1"--defaultPort :: Int-defaultPort = 4444---- ============================================================================--- Logging Helper--- ============================================================================---- | Create a logger from command-line options--- Default: ErrorS (only show errors)-makeLoggerFromOpts :: SynapseOpts -> IO Log.Logger-makeLoggerFromOpts opts = do- let level = case soLogLevel opts of- Nothing -> Katip.ErrorS -- Default: only errors- Just levelStr -> parseLogLevel levelStr- Log.makeLogger level- where- parseLogLevel "error" = Katip.ErrorS- parseLogLevel "warn" = Katip.WarningS- parseLogLevel "info" = Katip.InfoS- parseLogLevel "debug" = Katip.DebugS- parseLogLevel _ = Katip.ErrorS -- Default to error---- ============================================================================--- Token Resolution--- ============================================================================---- | Resolve the auth token to use for this invocation.------ Priority:--- 1. --token <jwt> (explicit, highest priority)--- 2. --token-file <path> (explicit file)--- 3. ~/.plexus/tokens/<backend> (per-backend default)------ Token files contain just the raw JWT, optionally with a trailing newline.-resolveToken :: SynapseOpts -> Text -> IO (Maybe Text)-resolveToken opts backend =- case soToken opts of- Just tok -> pure (Just tok)- Nothing -> do- mPath <- case soTokenFile opts of- Just path -> pure (Just (T.unpack path))- Nothing -> do- home <- getHomeDirectory- let defaultPath = home </> ".plexus" </> "tokens" </> T.unpack backend- exists <- doesFileExist defaultPath- pure $ if exists then Just defaultPath else Nothing- case mPath of- Nothing -> pure Nothing- Just path -> do- contents <- TIO.readFile path- let tok = T.strip contents- pure $ if T.null tok then Nothing else Just tok---- ============================================================================--- Argument Splitting--- ============================================================================--- Main--- ============================================================================--main :: IO ()-main = do- args <- execParser argsInfo- let opts = argOpts args-- -- Initialize logger (default: ErrorS, only show errors)- logger <- makeLoggerFromOpts opts-- Log.logDebug logger Log.SubsystemCLI "synapse starting..."-- -- Use specified host/port for registry discovery- let discovery = registryDiscovery (soHost opts) (soPort opts)-- -- Get the backend at the connection point (host:port)- -- If it has a registry plugin, that's used for discovering other backends- Log.logDebug logger Log.SubsystemDiscovery $- "Checking backend at " <> soHost opts <> ":" <> T.pack (show (soPort opts))-- maybeBackend <- getBackendAt (soHost opts) (soPort opts)-- Log.logDebug logger Log.SubsystemDiscovery $- "Backend check complete: " <> T.pack (show maybeBackend)- let hostBackend = maybe "plexus" id maybeBackend-- -- Auto-register: if we're connecting to a non-default port, push the- -- discovered backend to the registry at the default port (if reachable)- let targetPort = soPort opts- targetHost = soHost opts- when (targetPort /= defaultPort) $ do- case maybeBackend of- Just name -> do- -- Check if the registry is at the default port- maybeRegistry <- getBackendAt targetHost defaultPort- case maybeRegistry of- Just registryName ->- registerWithRegistry targetHost defaultPort registryName- name targetHost targetPort- Nothing -> pure ()- Nothing -> pure ()-- case argBackend args of- Nothing- -- Suppress banner for data output modes- | soEmitIR opts || soSchema opts || soJson opts || soRaw opts ->- runWithDiscovery discovery hostBackend args- | otherwise -> do- -- No backend specified, show available backends- TIO.putStr cliHeader- backends <- discoverBackends discovery- -- Ping backends to check if they're reachable- backendsWithStatus <- pingBackends backends- TIO.putStrLn "\nAvailable backends:"- mapM_ printBackend backendsWithStatus- TIO.putStrLn "\nUsage: synapse <backend> [command...]"- Just backend- -- Handle --help/-h if it somehow ends up as the backend- | backend `elem` ["--help", "-h"] -> TIO.putStr cliHeader- -- Handle _self meta-commands (use discovered primary backend)- | backend == "_self" -> runWithDiscovery discovery hostBackend args- | otherwise -> runWithDiscovery discovery backend args---- | Print a backend in the list-printBackend :: Backend -> IO ()-printBackend b = do- let nameField = T.justifyLeft 15 ' ' (backendName b)- let hostPort = backendHost b <> ":" <> T.pack (show (backendPort b))- let status = case backendReachable b of- Just True -> " [OK]"- Just False -> " [UNREACHABLE]"- Nothing -> ""- let desc = if T.null (backendDescription b)- then ""- else " - " <> backendDescription b- TIO.putStrLn $ " " <> nameField <> hostPort <> status <> desc---- | Run a Hub command with backend discovery-runWithDiscovery :: BackendDiscovery -> Text -> Args -> IO ()-runWithDiscovery discovery backendName args = do- let opts = argOpts args- -- Try to discover backend info- maybeBackend <- getBackendInfo discovery backendName-- case maybeBackend of- Just backend -> do- -- Verify the resolved backend actually identifies as the requested name.- -- This catches cases where -P points to a different backend than expected,- -- or the registry has stale entries.- actualName <- getBackendAt (backendHost backend) (backendPort backend)- case actualName of- Just name | name /= backendName -> do- hPutStrLn stderr $ "Backend mismatch: requested '" <> T.unpack backendName- <> "' but " <> T.unpack (backendHost backend) <> ":" <> show (backendPort backend)- <> " identifies as '" <> T.unpack name <> "'"- exitFailure- Nothing -> do- -- Protocol handshake failed when verifying backend- logger <- makeLoggerFromOpts opts- token <- resolveToken opts backendName- env <- initEnv (soHost opts) (soPort opts) backendName logger token- result <- runSynapseM env (throwBackend (ProtocolHandshakeFailed (backendHost backend) (backendPort backend)) [])- case result of- Left err -> do- hPutStrLn stderr $ renderError err- exitFailure- Right () -> exitSuccess- Just _ ->- -- Match confirmed- run backendName (backendHost backend) (backendPort backend) args- Nothing -> do- -- Backend not found in registry - check if this was a direct connection attempt- -- where the protocol handshake failed- maybeDirectBackend <- getBackendAt (soHost opts) (soPort opts)- logger <- makeLoggerFromOpts opts- token <- resolveToken opts backendName- env <- initEnv (soHost opts) (soPort opts) backendName logger token- case maybeDirectBackend of- Nothing -> do- -- Protocol handshake failed at the specified host:port- result <- runSynapseM env (throwBackend (ProtocolHandshakeFailed (soHost opts) (soPort opts)) [])- case result of- Left err -> do- hPutStrLn stderr $ renderError err- exitFailure- Right () -> exitSuccess- Just discoveredName | discoveredName /= backendName -> do- -- Backend exists but has a different name- backends <- discoverBackends discovery- backendsWithStatus <- pingBackends backends- result <- runSynapseM env (throwBackend (BackendNotFound backendName) backendsWithStatus)- case result of- Left err -> do- hPutStrLn stderr $ renderError err- exitFailure- Right () -> exitSuccess- Just _ -> do- -- Backend discovered successfully - should not reach here, but proceed- run backendName (soHost opts) (soPort opts) args---- | Run a Hub command with specified backend and connection details-run :: Text -> Text -> Int -> Args -> IO ()-run backend host port args = do- let opts = argOpts args- logger <- makeLoggerFromOpts opts- Log.logInfo logger Log.SubsystemCLI $ "Synapse starting: backend=" <> backend <> ", host=" <> host <> ", port=" <> T.pack (show port)- token <- resolveToken opts backend- env <- initEnv host port backend logger token- rendererCfg <- defaultRendererConfig- Log.logDebug logger Log.SubsystemCLI "Running dispatch..."- result <- runSynapseM env (dispatch args rendererCfg)- case result of- Left err -> do- hPutStrLn stderr $ renderError err- exitFailure- Right () -> exitSuccess---- | Dispatch based on navigation result-dispatch :: Args -> RendererConfig -> SynapseM ()-dispatch Args{argOpts = SynapseOpts{..}, argBackend, argPath} rendererCfg = do- -- Check for _self commands first (before anything else)- case argBackend of- Just "_self" -> do- -- _self meta-command: parse subcommand and rest from argPath- let (pathSegs, rawParams, _) = parsePathAndParams argPath- case pathSegs of- (subcommand : rest) -> do- Self.dispatch subcommand rest rawParams- return ()- [] -> do- -- No subcommand provided, show help- Self.showHelp- return ()-- -- Normal dispatch- _ -> do- -- Mode 1: Raw JSON-RPC passthrough- case soRpc of- Just rpcJson -> do- case eitherDecode (LBS.fromStrict $ TE.encodeUtf8 rpcJson) of- Left err -> throwParse $ T.pack err- Right rpcReq -> do- items <- invokeRawRpc rpcReq- liftIO $ mapM_ (printResult soJson soRaw rendererCfg) items- return ()-- Nothing -> do- -- Parse path and inline params (--key value pairs)- let (pathSegs, rawParams, helpRequested) = parsePathAndParams argPath-- -- Normal Plexus RPC routing- -- Apply parameter transformations (path expansion, env vars)- transformEnv <- liftIO mkTransformEnv- inlineParams <- liftIO $ transformParams transformEnv defaultTransformers rawParams-- -- Mode 1.5: Respond subcommand (agent sends a response to a pending bidir request)- if not (null pathSegs) && head pathSegs == "respond"- then handleRespondCommand rawParams-- -- Mode 2: Schema request- else if soSchema- then do- -- Try to navigate and determine if last segment is a method- schemaResult <- fetchSchemaForPath pathSegs- case schemaResult of- Left err -> throwNav $ FetchError err pathSegs- Right val -> liftIO $ LBS.putStrLn $ encode val-- -- Mode 3: Generate templates (IR-driven approach)- else if soGenerate- then do- homeDir <- liftIO getHomeDirectory- let baseDir = homeDir </> ".config" </> "synapse" </> "templates"- writeAndLog gt = do- writeGeneratedTemplateIR baseDir gt- TIO.putStrLn $ " " <> T.pack (TemplateIR.gtPath gt)- liftIO $ TIO.putStrLn $ "Generating templates in " <> T.pack baseDir <> "..."- count <- TemplateIR.generateAllTemplatesWithCallback writeAndLog pathSegs- liftIO $ TIO.putStrLn $ "Generated " <> T.pack (show count) <> " templates"-- -- Mode 4: Emit IR for code generation- else if soEmitIR- then do- ir <- buildIR soGeneratorInfo pathSegs- liftIO $ LBS.putStrLn $ encode ir-- else do- -- Mode 5: Normal navigation- if null pathSegs- then do- rootSchema <- navigate []- case rootSchema of- ViewPlugin schema _ -> liftIO $ do- TIO.putStr cliHeader- TIO.putStr "\n\n"- TIO.putStr $ renderSchema schema- _ -> pure ()- else do- -- Navigate to target- view <- navigate pathSegs- case view of- -- Landed on a plugin: show help- ViewPlugin schema _ ->- liftIO $ TIO.putStr $ renderSchema schema-- -- Landed on a method: invoke or show help- ViewMethod method path -> do- let fullPath = T.intercalate "." path-- -- IR-15: emit stderr deprecation warnings right before any- -- real invocation. Dedupe is per-process-lifetime, so this- -- action is idempotent on repeat calls. We bind it as a- -- local SynapseM action and invoke it at both invocation- -- sites below (fast path and slow path). --help, --dry-run,- -- --schema, and --emit-ir do not invoke, and intentionally- -- do not fire these warnings.- let fireDeprecationWarnings :: SynapseM ()- fireDeprecationWarnings = do- liftIO $ emitMethodWarning- soNoDeprecationWarnings- fullPath- (methodDeprecation method)- let namespacePath = init path- when (not (null namespacePath)) $ do- let activationNs = T.intercalate "." namespacePath- parentPlugin <- fetchSchemaAt namespacePath- liftIO $ emitActivationWarning- soNoDeprecationWarnings- activationNs- (psDeprecation parentPlugin)-- -- Optimization: Skip IR construction for parameter-less methods- -- IR is only needed for:- -- 1. Help rendering (--help flag)- -- 2. Parameter parsing (methods with params)- let needsIR = helpRequested- || isJust (methodParams method)- || isJust soParams- || not (null inlineParams)-- -- Fast path: parameter-less method with no help requested- if not needsIR- then fireDeprecationWarnings >> invokeMethod path (object [])- else do- -- Build IR for this method's namespace- ir <- buildIR soGeneratorInfo (init path)-- -- If --help was explicitly requested, show help and exit- if helpRequested- then do- case Map.lookup fullPath (irMethods ir) of- Just methodDef ->- liftIO $ TIO.putStr $ renderMethodHelp ir methodDef- Nothing ->- liftIO $ TIO.putStrLn $ T.intercalate "." path <> " - " <> methodDescription method- else do- -- Build params: use IR-driven parsing for inline params- let schemaDefaults = extractSchemaDefaults method- userParams <- case soParams of- -- -p JSON: parse as raw JSON (bypass IR parsing)- Just jsonStr ->- case eitherDecode (LBS.fromStrict $ TE.encodeUtf8 jsonStr) of- Left err -> throwParse $ T.pack err- Right p -> pure p- Nothing- | not (null inlineParams) ->- -- Use IR-driven parsing for inline params- case Map.lookup fullPath (irMethods ir) of- Just methodDef -> do- -- Inject boolean defaults for flags without values- let paramsWithBools = injectBooleanDefaults ir methodDef inlineParams- -- Inject smart defaults for missing required params (e.g., --path defaults to cwd)- paramsWithDefaults <- liftIO $ injectSmartDefaults transformEnv ir methodDef paramsWithBools- case parseParams ir methodDef paramsWithDefaults of- Right p -> pure p- Left errs -> do- liftIO $ mapM_ (hPutStrLn stderr . renderParseError) errs- throwParse "Parameter parsing failed"- Nothing ->- -- Fallback to flat object if method not in IR- pure $ buildParamsObject inlineParams- | otherwise -> pure $ object []- -- Merge: user params override schema defaults- let params = mergeParams schemaDefaults userParams-- if soDryRun- then do- backend <- asks seBackend- liftIO $ LBS.putStrLn $ encodeDryRun backend (init path) (last path) params- -- Show help when: method has required params AND user provided no params- else if hasRequiredParams method && userParams == object [] && null inlineParams- then do- -- Render help from IR- case Map.lookup fullPath (irMethods ir) of- Just methodDef ->- liftIO $ TIO.putStr $ renderMethodHelp ir methodDef- Nothing ->- -- Fallback: method not in IR, use basic info- liftIO $ TIO.putStrLn $ T.intercalate "." path <> " - " <> methodDescription method- -- IR-15: fire stderr deprecation warnings before real invocation.- else fireDeprecationWarnings >> invokeMethod path params- where- handleRespondCommand :: [(Text, Text)] -> SynapseM ()- handleRespondCommand params = do- let mRequestId = lookup "request_id" params- mResponseStr = lookup "response" params- case (mRequestId, mResponseStr) of- (Nothing, _) -> throwParse "Missing --request-id for respond subcommand"- (_, Nothing) -> throwParse "Missing --response for respond subcommand"- (Just requestId, Just responseStr) ->- case eitherDecode (LBS.fromStrict $ TE.encodeUtf8 responseStr) of- Left err -> throwParse $ "Invalid --response JSON: " <> T.pack err- Right (resp :: Response Value) -> sendResponse requestId resp-- invokeMethod path params = do- let namespacePath = init path -- path without method name- let methodName' = last path- -- Set method path hint for template resolution- let rendererCfg' = withMethodPath rendererCfg path- -- Determine bidirectional mode (--bidir-cmd takes precedence)- bidirMode <- liftIO $ case soBidirCmd of- Just cmd -> pure $ BidirCmd cmd- Nothing -> case soBidirMode of- Just modeStr -> case parseBidirMode modeStr of- Just mode -> pure mode- Nothing -> do- TIO.hPutStrLn stderr $ "[synapse] Unknown --bidir-mode: " <> modeStr <> ", using auto-detect"- detectBidirMode- Nothing -> detectBidirMode- -- Use bidirectional streaming handler- invokeStreamingWithBidir- namespacePath- methodName'- params- (printResult soJson soRaw rendererCfg')- (handleBidirRequest bidirMode Nothing)-- -- Extract default values from JSON Schema properties- -- Schema format: {"properties": {"key": {"default": value, ...}, ...}, ...}- extractSchemaDefaults :: MethodSchema -> Value- extractSchemaDefaults m = case methodParams m of- Nothing -> object []- Just (Object o) -> case KM.lookup "properties" o of- Just (Object props) -> object- [ (k, defaultVal)- | (k, propSchema) <- KM.toList props- , Object propObj <- [propSchema]- , Just defaultVal <- [KM.lookup "default" propObj]- ]- _ -> object []- Just _ -> object []-- -- Merge two JSON objects: right takes precedence over left- mergeParams :: Value -> Value -> Value- mergeParams (Object defaults) (Object user) =- Object (KM.union user defaults) -- union prefers first arg on conflict- mergeParams _ user = user -- if defaults aren't an object, just use user params-- -- Check if method has required parameters- hasRequiredParams :: MethodSchema -> Bool- hasRequiredParams m = case methodParams m of- Nothing -> False- Just (Object o) -> case KM.lookup "required" o of- Just (Array arr) -> not (null arr)- _ -> False- Just _ -> False-- -- Fetch schema for a path, detecting if last segment is a method- -- Uses navigate to determine what type of schema to fetch- fetchSchemaForPath :: [Text] -> SynapseM (Either Text Value)- fetchSchemaForPath segs = do- view <- navigate segs- case view of- ViewPlugin schema _ -> pure $ Right $ toJSON schema- ViewMethod method path -> do- -- Use method-specific schema query for detailed method info- let parentPath = init path- methodName' = last path- detailedMethod <- fetchMethodSchema parentPath methodName'- pure $ Right $ toJSON detailedMethod-- -- Invoke raw JSON-RPC request- invokeRawRpc :: Value -> SynapseM [HubStreamItem]- invokeRawRpc rpcReq = do- case rpcReq of- Object o -> case (KM.lookup "method" o, KM.lookup "params" o) of- (Just (String method), Just params) -> invokeRaw method params- (Just (String method), Nothing) -> invokeRaw method (object [])- _ -> throwParse "JSON-RPC must have 'method' field"- _ -> throwParse "JSON-RPC must be an object"---- | Parse path segments and --key value params--- Returns (path segments, [(key, value)] params, help requested)--- Example: ["echo", "once", "--message", "hello", "--count", "3"]--- -> (["echo", "once"], [("message", "hello"), ("count", "3")], False)--- Example: ["echo", "once", "--help"]--- -> (["echo", "once"], [], True)-parsePathAndParams :: [Text] -> ([Text], [(Text, Text)], Bool)-parsePathAndParams = go [] [] False- where- go path params helpReq [] = (reverse path, reverse params, helpReq)- go path params helpReq (x:xs)- -- --help flag: mark help requested, don't add as param- | x == "--help" || x == "-h" =- go path params True xs- -- --key value pair (value must not start with --)- | Just key <- T.stripPrefix "--" x- , not (T.null key)- , (val:rest) <- xs- , not (T.isPrefixOf "--" val) =- let normalizedKey = T.replace "-" "_" key -- Normalize kebab-case to snake_case- in go path ((normalizedKey, val) : params) helpReq rest- -- --key with no value or next arg is another flag (boolean flag)- | Just key <- T.stripPrefix "--" x- , not (T.null key) =- let normalizedKey = T.replace "-" "_" key -- Normalize kebab-case to snake_case- in go path ((normalizedKey, "") : params) helpReq xs- -- Regular path segment - split on dots to support Plexus RPC path syntax (e.g., cone.chat)- | otherwise =- let segments = map (T.replace "-" "_") $ filter (not . T.null) $ T.splitOn "." x- in go (reverse segments ++ path) params helpReq xs---- | Build JSON object from key-value pairs-buildParamsObject :: [(Text, Text)] -> Value-buildParamsObject pairs = object- [ (K.fromText k, inferValue v) | (k, v) <- pairs ]- where- -- Try to infer the JSON type from the string value- inferValue :: Text -> Value- inferValue t- | t == "true" = Bool True- | t == "false" = Bool False- | Just n <- readMaybe (T.unpack t) :: Maybe Integer = Number (fromInteger n)- | Just n <- readMaybe (T.unpack t) :: Maybe Double = Number (realToFrac n)- | otherwise = String t--readMaybe :: Read a => String -> Maybe a-readMaybe s = case reads s of- [(x, "")] -> Just x- _ -> Nothing---- | ASCII splash logo-splash :: Text-splash = T.unlines- [ ""- , "███████╗██╗ ██╗███╗ ██╗ █████╗ ██████╗ ███████╗███████╗"- , "██╔════╝╚██╗ ██╔╝████╗ ██║██╔══██╗██╔══██╗██╔════╝██╔════╝"- , "███████╗ ╚████╔╝ ██╔██╗ ██║███████║██████╔╝███████╗█████╗ "- , "╚════██║ ╚██╔╝ ██║╚██╗██║██╔══██║██╔═══╝ ╚════██║██╔══╝ "- , "███████║ ██║ ██║ ╚████║██║ ██║██║ ███████║███████╗"- , "╚══════╝ ╚═╝ ╚═╝ ╚═══╝╚═╝ ╚═╝╚═╝ ╚══════╝╚══════╝"- , ""- ]---- | Get CLI help text from optparse-applicative-cliHeader :: Text-cliHeader = splash <> T.pack (fst $ renderFailure failure "synapse") <> selfHelp- where- failure = parserFailure defaultPrefs argsInfo (ShowHelpText Nothing) mempty- selfHelp = T.unlines- [ ""- , "Meta-commands (local, no RPC):"- , ""- , " synapse _self template"- , " Manage Mustache templates (CRUD operations)"- , ""- , " Subcommands:"- , " list [pattern] - List existing templates"- , " show <method> - Display template content"- , " generate [pattern] - Generate new templates from IR"- , " delete <pattern> - Delete templates"- , " reload - Clear template cache"- , ""- , " Examples:"- , " synapse _self template list"- , " synapse _self template show cone.chat"- , " synapse _self template generate 'plexus.cone.*'"- , ""- ]---- | Render an error for display-renderError :: SynapseError -> String-renderError = \case- NavError (NotFound seg path maybeSchema) ->- let baseMsg = "Command not found: '" <> T.unpack seg <> "' at " <> showPath path- in case maybeSchema of- Nothing -> baseMsg- Just schema ->- let methods = psMethods schema- children = pluginChildren schema- suggestions = if null methods && null children- then ""- else "\n\nAvailable commands:"- <> renderMethods methods- <> renderChildren children- in baseMsg <> suggestions- NavError (MethodNotTerminal seg path) ->- "Method '" <> T.unpack seg <> "' cannot have subcommands at " <> showPath path- NavError (Cycle hash path) ->- "Cycle detected: hash " <> T.unpack hash <> " at " <> showPath path- NavError (FetchError msg path) ->- "Fetch error at " <> showPath path <> ": " <> T.unpack msg- TransportError msg ->- "Transport error: " <> T.unpack msg- TransportErrorContext ctx ->- renderTransportError ctx- ParseError msg ->- "Parse error: " <> T.unpack msg- ValidationError msg ->- "Validation error: " <> T.unpack msg- BackendError errorType backends ->- case errorType of- BackendNotFound name ->- "Backend not found: '" <> T.unpack name <> "'"- <> renderBackendList backends- BackendUnreachable name ->- "Backend unreachable: '" <> T.unpack name <> "'"- <> renderBackendList backends- NoBackendsAvailable ->- "No backends available"- <> renderBackendList backends- ProtocolHandshakeFailed host port ->- "Protocol handshake failed at " <> T.unpack host <> ":" <> show port <> "\n"- <> "The _info request did not complete successfully within 2 seconds.\n"- <> "This typically means:\n"- <> " - The server is not responding to Plexus RPC protocol messages\n"- <> " - The server is not sending required StreamDone messages\n"- <> " - The connection is timing out"- where- showPath [] = "root"- showPath p = T.unpack $ T.intercalate "." p-- renderMethods [] = ""- renderMethods methods =- let header = "\n\n Methods:"- methodLines = map renderMethod methods- in header <> concat methodLines-- renderMethod method =- let name = T.unpack $ methodName method- desc = T.unpack $ methodDescription method- padding = 20- namePadded = name <> replicate (max 1 (padding - length name)) ' '- in "\n " <> namePadded <> desc-- renderChildren [] = ""- renderChildren children =- let header = "\n\n Child plugins:"- childLines = map renderChild children- in header <> concat childLines-- renderChild child =- let name = T.unpack $ csNamespace child- desc = T.unpack $ csDescription child- padding = 20- namePadded = name <> replicate (max 1 (padding - length name)) ' '- in "\n " <> namePadded <> desc-- renderBackendList [] = "\n\nNo backends available."- renderBackendList backends =- let header = "\n\nAvailable backends:"- backendLines = map renderBackendLine backends- usageHint = "\n\nUsage: synapse <backend> [command...]"- in header <> concat backendLines <> usageHint-- renderBackendLine backend =- let name = T.unpack $ backendName backend- nameField = name <> replicate (max 1 (15 - length name)) ' '- hostPort = T.unpack $ backendHost backend <> ":" <> T.pack (show (backendPort backend))- status = case backendReachable backend of- Just True -> " [OK]"- Just False -> " [UNREACHABLE]"- Nothing -> ""- desc = if T.null (backendDescription backend)- then ""- else " - " <> T.unpack (backendDescription backend)- in "\n " <> nameField <> hostPort <> status <> desc---- | Render transport error with context-renderTransportError :: TransportContext -> String-renderTransportError TransportContext{..} = case tcCategory of- ConnectionRefused ->- "Connection refused\n\n" <>- "Backend: " <> T.unpack tcBackend <> "\n" <>- "Address: " <> T.unpack tcHost <> ":" <> show tcPort <> "\n" <>- "Path: " <> showPath tcPath <> "\n\n" <>- "Troubleshooting:\n" <>- " - Check if the backend is running\n" <>- " - Verify host (-H) and port (-P) settings\n" <>- " - Run 'synapse' (no args) to list backends"-- ConnectionTimeout ->- "Connection timeout\n\n" <>- "Backend: " <> T.unpack tcBackend <> " @ " <>- T.unpack tcHost <> ":" <> show tcPort <> "\n\n" <>- "The server may be overloaded or network latency is high\n" <>- "Error: " <> T.unpack tcMessage-- ProtocolError ->- "Protocol error\n\n" <>- "Backend: " <> T.unpack tcBackend <> "\n" <>- "This may indicate a version mismatch\n\n" <>- "Error: " <> T.unpack tcMessage-- UnknownTransportError ->- "Transport error: " <> T.unpack tcMessage <> "\n\n" <>- "Backend: " <> T.unpack tcBackend <> " @ " <>- T.unpack tcHost <> ":" <> show tcPort- where- showPath [] = "root"- showPath p = T.unpack $ T.intercalate "." p---- | Render a parse error for display-renderParseError :: Parse.ParseError -> String-renderParseError = \case- Parse.UnknownParam name suggestions ->- "Unknown parameter: --" <> T.unpack name <>- case suggestions of- [] -> ""- [s] -> "\n\nDid you mean: --" <> T.unpack s <> "?"- ss -> "\n\nDid you mean one of:\n" <>- unlines [" --" <> T.unpack s | s <- ss]- Parse.MissingRequired name ->- "Missing required parameter: --" <> T.unpack name- Parse.InvalidValue name reason ->- "Invalid value for --" <> T.unpack name <> ": " <> T.unpack reason- Parse.AmbiguousVariant name variants ->- "Ambiguous variant for --" <> T.unpack name <> ": could be one of " <> show (map T.unpack variants)- Parse.MissingDiscriminator param field ->- "Missing discriminator for --" <> T.unpack param <> ": need --" <> T.unpack param <> "." <> T.unpack field- Parse.UnknownVariant param value valid suggestions ->- "Unknown variant '" <> T.unpack value <> "' for --" <> T.unpack param <>- "\n\nValid variants: " <> T.unpack (T.intercalate ", " valid) <>- case suggestions of- [] -> ""- [s] -> "\n\nDid you mean: " <> T.unpack s <> "?"- ss -> "\n\nDid you mean one of: " <> T.unpack (T.intercalate ", " ss) <> "?"- Parse.TypeNotFound name ->- "Type not found in IR: " <> T.unpack name----- | Write a generated template to disk (no logging)-writeGeneratedTemplateIR :: FilePath -> TemplateIR.GeneratedTemplate -> IO ()-writeGeneratedTemplateIR baseDir gt = do- let fullPath = baseDir </> TemplateIR.gtPath gt- createDirectoryIfMissing True (baseDir </> T.unpack (TemplateIR.gtNamespace gt))- TIO.writeFile fullPath (TemplateIR.gtTemplate gt)---- | Encode a dry-run request-encodeDryRun :: Text -> [Text] -> Text -> Value -> LBS.ByteString-encodeDryRun backend namespacePath method params =- let fullPath = if null namespacePath then [backend] else namespacePath- dotPath = T.intercalate "." (fullPath ++ [method])- in encode $ object- [ "jsonrpc" .= ("2.0" :: Text)- , "id" .= (1 :: Int)- , "method" .= (backend <> ".call")- , "params" .= object- [ "method" .= dotPath- , "params" .= params- ]- ]---- | Print a stream result--- soJson: output raw JSON stream items--- soRaw: skip template rendering, just output content JSON--- otherwise: try template rendering, fall back to content JSON-printResult :: Bool -> Bool -> RendererConfig -> HubStreamItem -> IO ()-printResult True _ _ item = LBS.putStrLn $ encode item-printResult _ True _ item = case item of- -- Raw mode: just output the content- HubData _ _ _ dat -> do- LBS.putStrLn $ encode dat- hFlush stdout- HubProgress _ _ msg _ -> do- TIO.putStr msg- TIO.putStr "\r"- hFlush stdout- HubError _ _ err _ ->- hPutStrLn stderr $ "Error: " <> T.unpack err- _ -> pure ()-printResult _ _ cfg item = do- -- Template mode: try to render with template- mRendered <- renderItem cfg item- case mRendered of- Just text- | T.null (T.strip text) -> pure ()- | otherwise -> do- TIO.putStr text- hFlush stdout- Nothing -> case item of- -- Fallback to pretty-printed content- HubData _ _ _ dat -> do- TIO.putStrLn $ prettyValue dat- hFlush stdout- HubProgress _ _ msg _ -> do- TIO.putStr msg- TIO.putStr "\r"- hFlush stdout- HubError _ _ err _ ->- hPutStrLn stderr $ "Error: " <> T.unpack err- _ -> pure ()---- ============================================================================--- Argument Parsing (Synapse Options Only)--- ============================================================================---- | Parser for synapse-level options only--- Backend and path are handled separately after arg splitting-argsInfo :: ParserInfo Args-argsInfo = info (argsParser <**> versionOption <**> helper)- ( fullDesc- <> header "synapse - Algebraic CLI for Hub"- <> progDesc "synapse [OPTIONS] <backend> <path...> [--param value ...]"- <> noIntersperse -- Stop option parsing at first positional arg- )- where- versionOption = infoOption (showVersion Meta.version)- ( long "version" <> short 'V' <> help "Show version" )--argsParser :: Parser Args-argsParser = Args <$> optsParser <*> backendParser <*> restParser- where- backendParser = optional $ T.pack <$> argument str- ( metavar "BACKEND"- <> help "Backend name (e.g., plexus, registry-hub)" )-- restParser = many $ T.pack <$> argument str- ( metavar "PATH... [--param value ...]"- <> help "Path and method parameters (passed through)" )--optsParser :: Parser SynapseOpts-optsParser = do- soHost <- T.pack <$> strOption- ( long "host" <> short 'H' <> metavar "HOST"- <> value (T.unpack defaultHost)- <> help "Registry/discovery host (default: 127.0.0.1)" )- soPort <- option auto- ( long "port" <> short 'P' <> metavar "PORT"- <> value defaultPort- <> help "Registry/discovery port (default: 4444)" )- soJson <- switch- ( long "json" <> short 'j'- <> help "Output raw JSON stream items" )- soRaw <- switch- ( long "raw"- <> help "Output raw content JSON (skip templates)" )- soDryRun <- switch- ( long "dry-run" <> short 'n'- <> help "Show JSON-RPC request without sending" )- soSchema <- switch- ( long "schema" <> short 's'- <> help "Fetch raw schema JSON for path" )- soGenerate <- switch- ( long "generate-templates" <> short 'g'- <> help "Generate mustache templates from IR" )- soEmitIR <- switch- ( long "emit-ir" <> short 'i'- <> help "Emit IR for code generation (JSON)" )- soParams <- optional $ T.pack <$> strOption- ( long "params" <> short 'p' <> metavar "JSON"- <> help "Method parameters as JSON object" )- soRpc <- optional $ T.pack <$> strOption- ( long "rpc" <> short 'r' <> metavar "JSON"- <> help "Raw JSON-RPC request (bypass navigation)" )- soGeneratorInfo <- many $ T.pack <$> strOption- ( long "generator-info" <> metavar "TOOL:VERSION"- <> help "Generator tool version info (can be specified multiple times)" )- soBidirMode <- optional $ T.pack <$> strOption- ( long "bidir-mode" <> short 'b' <> metavar "MODE"- <> help "Bidirectional mode: interactive (TTY), json (pipe protocol), auto-cancel, defaults, respond" )- soBidirCmd <- optional $ T.pack <$> strOption- ( long "bidir-cmd" <> metavar "CMD"- <> help "Shell command to handle bidir requests (stdin=JSON request, stdout=JSON response)" )- soNoCache <- switch- ( long "no-cache"- <> help "Disable IR caching (force rebuild from schema)" )- soLogLevel <- optional $ T.pack <$> strOption- ( long "log-level" <> metavar "LEVEL"- <> help "Log level: info, debug, trace (default: disabled)" )- soLogSubsystems <- many $ T.pack <$> strOption- ( long "log-subsystem" <> metavar "SUBSYSTEM"- <> help "Filter logs by subsystem: discovery, transport, rpc, schema, cache, navigation (can specify multiple, default: all)" )- soToken <- optional $ T.pack <$> strOption- ( long "token" <> short 't' <> metavar "JWT"- <> help "JWT sent as Cookie: access_token=<jwt> on WebSocket upgrade" )- soTokenFile <- optional $ T.pack <$> strOption- ( long "token-file" <> metavar "PATH"- <> help "Path to token file (default lookup: ~/.plexus/tokens/<backend>)" )- soNoDeprecationWarnings <- switch- ( long "no-deprecation-warnings"- <> help "Suppress invocation-time deprecation warnings on stderr (IR-15)" )- pure SynapseOpts{..}+import Control.Monad (filterM, when)+import Control.Monad.IO.Class (liftIO)+import Control.Monad.Reader (asks)+import Data.Aeson+import qualified Data.Aeson.Key as K+import qualified Data.Aeson.KeyMap as KM+import qualified Data.ByteString.Lazy.Char8 as LBS+import Data.List (stripPrefix)+import Data.Maybe (isJust)+import Data.Text (Text)+import qualified Data.Text as T+import qualified Data.Text.IO as TIO+import qualified Data.Text.Encoding as TE+import Options.Applicative+import qualified Options.Applicative.Help.Pretty as Pretty+import System.Environment (getEnvironment)+import Data.Version (showVersion)+import qualified Paths_plexus_synapse as Meta+import System.Directory (getHomeDirectory)+import System.Exit (exitFailure, exitSuccess)+import System.FilePath ((</>))+import System.IO (hPutStrLn, stderr, hFlush, stdout, hIsTerminalDevice)+++import Synapse.Schema.Types+import Synapse.Monad (SynapseM, SynapseEnv(..), SynapseError(..), BackendErrorType(..), TransportContext(..), TransportErrorCategory(..), initEnvAt, runSynapseM, throwNav, throwTransport, throwParse, throwBackend, withRequestContext, withViewKey)+import qualified Synapse.Self as Self+ ( CredentialRef(..)+ , ResolveError(..)+ , ResolvedDefaults(..)+ , defaultRegistry+ , loadDefaults+ , merge+ , resolveAll+ )+import qualified Control.Exception as E+import qualified Synapse.Log as Log+import qualified Katip+import Synapse.Algebra.Navigate+import Synapse.Algebra.Render (Listing(..), renderListing, defaultStyle)+import Synapse.Auth (AnonPage(..), anonymousMethods, renderAnonymousPage, renderCallerHeader, renderRefusal, SessionGrant(..))+import Plexus.Auth (BackendInfo(..), View(..), viewText)+import Plexus.Types (itemRefusal)+import Control.Monad.Except (catchError, throwError)+import Synapse.CLI.Help (MethodPage(..), renderMethodPage)+import Synapse.CLI.Parse (parseParams, parsePathAndParams, pairFlags)+import qualified Synapse.CLI.Parse as Parse+import Synapse.IR.Types (IR, irMethods, MethodDef)+import qualified Data.Map.Strict as Map+import qualified Synapse.CLI.Template as TemplateIR+import Synapse.Deprecation (emitActivationWarning, emitMethodWarning)+import Synapse.Transport+import Synapse.Bidir (BidirMode(..), parseBidirMode, detectBidirMode, handleBidirRequest)+import Plexus.Types (Response(..))+import Synapse.CLI.Transform (TransformEnv(..), mkTransformEnv, transformParams, defaultTransformers, injectBooleanDefaults, injectSmartDefaults)+import Synapse.CLI.Source (SourceEnv, mkSourceEnv, expandSources, expandValue, stdinWasRead, renderSourceError)+import Synapse.IR.Builder (buildIR)+import Synapse.Renderer (RendererConfig, defaultRendererConfig, renderItem, prettyValue, withMethodPath)+import System.Directory (createDirectoryIfMissing, doesFileExist, getHomeDirectory)+import System.FilePath ((</>))+import qualified Synapse.Self.Commands as Self+import qualified Synapse.Self.Command as SelfCmd+import Synapse.Backend.Discovery (Backend(..), BackendDiscovery(..), registryDiscoveryAt, endpointFor, pingBackendsVia, getBackendAt, getBackendAtEp, probeBackendAtEp, registerWithRegistry, probedInfoAt, probeFailureAt)+import Synapse.Endpoint (Endpoint(..), plainAt, isPlain, parseUrl, normalisePath, cliOptions)+import qualified Synapse.Endpoint as E+import Control.Applicative ((<|>))+import System.Environment (lookupEnv)+import Data.Time.Clock.POSIX (posixSecondsToUTCTime)+import Data.Time.Format (formatTime, defaultTimeLocale)+import qualified Synapse.Identity.Command as IdCmd+import qualified Synapse.Identity.Proof as Proof+import qualified Synapse.Identity.Session as Session+import qualified Synapse.Reconnect as Reconnect+import qualified Synapse.Identity.Store as Store+import System.Exit (ExitCode(..), exitWith)+import qualified Synapse.Find as Find+import Synapse.IR.Cache (newIRCache, defaultCacheConfig)++-- ============================================================================+-- Types+-- ============================================================================++-- | Synapse-level options (must appear before backend)+-- Controls connection settings and output format+data SynapseOpts = SynapseOpts+ { soHost :: Text -- ^ Registry host for discovery+ , soPort :: Int -- ^ Registry port for discovery+ , soHostFlag :: Maybe Text -- ^ SCAFFOLD-21: -H as given+ , soPortFlag :: Maybe Int -- ^ SCAFFOLD-21: -P as given+ , soUrl :: Maybe Text -- ^ SCAFFOLD-21: --url ws[s]://HOST[:PORT][/PATH]+ , soTls :: Bool -- ^ SCAFFOLD-21: --tls — wss to -H / -P+ , soPath :: Maybe Text -- ^ SCAFFOLD-21: --path — the socket's path+ , soCaCerts :: [Text] -- ^ SCAFFOLD-21: --cacert FILE — trusted beside the system's+ , soEndpoint :: Endpoint -- ^ SCAFFOLD-21: the connection point, resolved from the above+ , soJson :: Bool -- ^ Output raw JSON stream items+ , soRaw :: Bool -- ^ Output raw content (no templates)+ , soDryRun :: Bool -- ^ Show request without sending+ , soSchema :: Bool -- ^ Fetch raw schema JSON+ , soGenerate :: Bool -- ^ Generate templates from IR+ , soEmitIR :: Bool -- ^ Emit IR for code generation+ , soParams :: Maybe Text -- ^ JSON params via -p+ , soRpc :: Maybe Text -- ^ Raw JSON-RPC passthrough+ , soGeneratorInfo :: [Text] -- ^ Generator tool info (tool:version pairs)+ , soBidirMode :: Maybe Text -- ^ Bidirectional mode override+ , soBidirCmd :: Maybe Text -- ^ Bidirectional subprocess command (--bidir-cmd)+ , soNoCache :: Bool -- ^ Disable IR caching+ , soLogLevel :: Maybe Text -- ^ Log level: info, debug, trace+ , soLogSubsystems :: [Text] -- ^ Filter logs by subsystems (empty = all)+ , soToken :: Maybe Text -- ^ JWT sent as Cookie: access_token=<jwt>+ , soTokenFile :: Maybe Text -- ^ Path to token file (overridden by --token)+ , soCookies :: [(Text, Text)] -- ^ SAFE-S04/REQ-5: extra --cookie KEY=VALUE entries+ , soHeaders :: [(Text, Text)] -- ^ SAFE-S04/REQ-5: extra --header KEY=VALUE entries+ , soNoDeprecationWarnings :: Bool -- ^ Suppress invocation-time deprecation warnings (IR-15)+ , soAs :: Maybe Text -- ^ IDENTITY-43: which stored agent session to carry+ , soReconnectFor :: Maybe Text -- ^ SYNAPSE-20: how long to ride out an outage (10m; 0 = never)+ }+ deriving Show++-- | Full CLI arguments after two-phase parsing+data Args = Args+ { argOpts :: SynapseOpts -- ^ Synapse-level options+ , argBackend :: Maybe Text -- ^ Backend name (first positional)+ , argPath :: [Text] -- ^ Path segments and --key value params (raw)+ , argFind :: Maybe Find.FindOpts -- ^ SYNAPSE-17: a search (_find), not a call+ }+ deriving Show+++-- ============================================================================+-- Constants+-- ============================================================================++defaultHost :: Text+defaultHost = "127.0.0.1"++defaultPort :: Int+defaultPort = 4444++-- ============================================================================+-- Logging Helper+-- ============================================================================++-- | Create a logger from command-line options+-- Default: ErrorS (only show errors)+makeLoggerFromOpts :: SynapseOpts -> IO Log.Logger+makeLoggerFromOpts opts = do+ let level = case soLogLevel opts of+ Nothing -> Katip.ErrorS -- Default: only errors+ Just levelStr -> parseLogLevel levelStr+ Log.makeLogger level+ where+ parseLogLevel "error" = Katip.ErrorS+ parseLogLevel "warn" = Katip.WarningS+ parseLogLevel "info" = Katip.InfoS+ parseLogLevel "debug" = Katip.DebugS+ parseLogLevel _ = Katip.ErrorS -- Default to error++-- ============================================================================+-- Token Resolution+-- ============================================================================++-- | Build the request-context (extra cookies/headers) for the WS upgrade,+-- combining --cookie/--header flags with SYNAPSE_COOKIE_*/SYNAPSE_HEADER_*+-- env vars. Env keys are lowercased.+collectRequestContext :: SynapseOpts -> IO ([(Text, Text)], [(Text, Text)])+collectRequestContext opts = do+ envVars <- getEnvironment+ let envCookies = [ (T.toLower (T.pack k), T.pack v)+ | (k0, v) <- envVars+ , Just k <- [stripPrefix "SYNAPSE_COOKIE_" k0]+ ]+ envHeaders = [ (T.toLower (T.pack k), T.pack v)+ | (k0, v) <- envVars+ , Just k <- [stripPrefix "SYNAPSE_HEADER_" k0]+ ]+ pure (soCookies opts ++ envCookies, soHeaders opts ++ envHeaders)++-- | Resolve the auth token to use for this invocation from CLI-only+-- sources.+--+-- Priority:+-- 1. --token <jwt> (explicit, highest priority)+-- 2. --token-file <path> (explicit file)+--+-- Any persistent per-backend default (including the former+-- @~\/.plexus\/tokens\/\<backend\>@, now auto-migrated by+-- 'Synapse.Self.loadDefaults' on first read) lives in+-- @~\/.plexus\/\<backend\>\/defaults.json@ as a+-- @cookies.access_token@ ref and is loaded through the SELF-2 pipeline+-- in 'buildEnv'. This function therefore only consults+-- invocation-scoped flags; returning 'Nothing' means "no CLI override",+-- not "no token available".+--+-- Token files contain just the raw JWT, optionally with a trailing+-- newline.+resolveToken :: SynapseOpts -> Text -> IO (Maybe Text)+resolveToken opts _backend =+ case soToken opts of+ Just tok -> pure (Just tok)+ Nothing -> case soTokenFile opts of+ Nothing -> pure Nothing+ Just path -> do+ contents <- TIO.readFile (T.unpack path)+ let tok = T.strip contents+ pure $ if T.null tok then Nothing else Just tok++-- | IDENTITY-16: the upgrade headers a discovery probe carries — the token+-- (as the access_token cookie), --cookie and --header, as the command line+-- and its env forms give them. Stored per-backend defaults are not read+-- here: the probe runs before the backend is named.+--+-- (No signature: the result is the websockets package's header list, which+-- this executable does not depend on by name.)+probeHeaders opts ep = do+ mToken <- resolveToken opts ""+ (cookies, headers) <- collectRequestContext opts+ sess <- sessionHeaders opts ep (isJust mToken || hasCredential cookies headers)+ pure (mergeUpgradeHeaders mToken cookies (headers ++ sess))++-- | Does this cookie/header set already carry a credential (an+-- access_token cookie or an Authorization header)? Then no stored session+-- is added to it.+hasCredential :: [(Text, Text)] -> [(Text, Text)] -> Bool+hasCredential cookies headers =+ any ((== "access_token") . T.toLower . fst) cookies+ || any ((== "authorization") . T.toLower . fst) headers++-- | IDENTITY-43: the agent asked for — @--as@, else @SYNAPSE_AS@.+askedAgent :: SynapseOpts -> IO (Maybe Text)+askedAgent opts = do+ env <- lookupEnv "SYNAPSE_AS"+ pure (soAs opts <|> (T.pack <$> mfilter' (not . null) env))+ where mfilter' f m = m >>= \x -> if f x then Just x else Nothing++-- | IDENTITY-43: the stored session's @Authorization: Bearer@ for a call to+-- the address — none when the call already carries a credential (-t,+-- --token-file, an access_token cookie, an Authorization header).+sessionHeaders :: SynapseOpts -> Endpoint -> Bool -> IO [(Text, Text)]+sessionHeaders opts ep explicit+ | explicit = pure []+ | otherwise = do+ st <- Store.systemStoreEnv+ asked <- askedAgent opts+ logger <- makeLoggerFromOpts opts+ maybe [] (\s -> [Session.bearer (Session.asToken s)]) <$> Session.sessionFor logger st asked ep++-- | IDENTITY-43: the _info probe, logging in again from the key once when+-- the upgrade is refused (401) while a stored session is carried.+probeWithSession :: SynapseOpts -> Endpoint -> IO (Maybe Text)+probeWithSession = probeWithSession' False++-- | SYNAPSE-20: with @waits@, a probe that fails as an outage does is+-- tried again for the reconnect window.+probeWithSession' :: Bool -> SynapseOpts -> Endpoint -> IO (Maybe Text)+probeWithSession' waits opts ep = do+ probe <- probeHeaders opts ep+ let once = probeBackendAtEp probe ep+ whereTo = Store.addressKey ep+ r <- if waits+ then Reconnect.awaitBackend+ (\why -> Reconnect.isOutageText why && not (Session.isUpgradeRefused why))+ whereTo once+ else once+ case r of+ Right name -> pure (Just name)+ Left why+ | Session.isUpgradeRefused why -> do+ can <- Session.canReattest ep+ if not can then pure Nothing else do+ st <- Store.systemStoreEnv+ logger <- makeLoggerFromOpts opts+ new <- Session.reattest logger st ep+ case new of+ Nothing -> pure Nothing+ Just _ -> do+ probe' <- probeHeaders opts ep+ getBackendAtEp probe' ep+ | otherwise -> pure Nothing++-- ============================================================================+-- Env Construction (SELF-2 read path)+-- ============================================================================++-- | Render a 'Self.ResolveError' as a human-readable message naming the+-- failing URI and the underlying reason.+renderResolveError :: Self.ResolveError -> String+renderResolveError = \case+ Self.ResolveUnknownScheme scheme ->+ "unknown credential scheme: " <> T.unpack scheme+ <> " (no resolver registered; known schemes: literal, env, file)"+ Self.ResolveNotFound (Self.CredentialRef uri) ->+ "credential not found: " <> T.unpack uri+ Self.ResolveBackendError (Self.CredentialRef uri) msg ->+ "credential backend error for " <> T.unpack uri <> ": " <> T.unpack msg+ Self.ResolveParseError msg ->+ "malformed credential reference: " <> T.unpack msg++-- | Build a fully-wired 'SynapseEnv' for a backend invocation.+--+-- SELF-2 read path:+--+-- 1. CLI token resolution (--token / --token-file; persistent defaults+-- live in ~/.plexus/<b>/defaults.json and are loaded in step 3)+-- 2. Collect CLI --cookie / --header flags and SYNAPSE_COOKIE_*/HEADER_* env+-- 3. Load stored defaults from @~\/.plexus\/\<backend\>\/defaults.json@+-- 4. Resolve every 'CredentialRef' through the default registry+-- (literal / env / file)+-- 5. Wrap the legacy token (if any) as a CLI @access_token@ cookie so it+-- flows through the same 'Self.merge' and wins over any stored default+-- — preserving the pre-SELF-2 precedence of the --token flag.+-- 6. Merge CLI overrides onto resolved stored defaults (CLI wins per key).+--+-- On resolve failure: print a clear message and 'exitFailure'. We never+-- build a half-authenticated 'SynapseEnv'.+buildEnv :: Endpoint -> Text -> Log.Logger -> SynapseOpts -> IO SynapseEnv+buildEnv ep backend logger opts = do+ -- Legacy token path (unchanged semantically; SELF-3 consolidates).+ mToken <- resolveToken opts backend+ (cliCookieList, cliHeaderList) <- collectRequestContext opts++ -- SELF-2: load + resolve + merge.+ stored <- E.catch (Self.loadDefaults backend) $ \e -> do+ hPutStrLn stderr $ "Error: " <> show (e :: E.IOException)+ exitFailure+ resolved <- Self.resolveAll Self.defaultRegistry stored []+ resolvedDefaults <- case resolved of+ Left err -> do+ hPutStrLn stderr $ "Error resolving defaults for backend '"+ <> T.unpack backend <> "': " <> renderResolveError err+ exitFailure+ Right rd -> pure rd++ -- Fold CLI flags (and the legacy --token / SYNAPSE_TOKEN / token file)+ -- into the CLI-side maps so the unified 'Self.merge' produces the final+ -- cookie + header set.+ let cliCookiesBase = Map.fromList cliCookieList+ cliCookies = case mToken of+ Just tok -> Map.insert "access_token" tok cliCookiesBase+ Nothing -> cliCookiesBase+ cliHeaders = Map.fromList cliHeaderList+ (mergedCookies, mergedHeaders) =+ Self.merge resolvedDefaults cliCookies cliHeaders++ -- IDENTITY-43: no credential given or stored for this backend — carry+ -- the agent's stored session, if there is one.+ sess <- sessionHeaders opts ep+ (hasCredential (Map.toList mergedCookies) (Map.toList mergedHeaders))++ -- 'seToken = Nothing' here: any access_token cookie already sits in the+ -- merged cookie map. Transport's mergeUpgradeHeaders joins them into a+ -- single Cookie header exactly as before.+ env0 <- initEnvAt ep backend logger Nothing+ pure $ withRequestContext (Map.toList mergedCookies)+ (Map.toList mergedHeaders ++ sess)+ env0++-- ============================================================================+-- Argument Splitting+-- ============================================================================+-- Main+-- ============================================================================++main :: IO ()+main = do+ args0 <- execParser argsInfo+ -- SCAFFOLD-21: where the connection point is — --url, or -H / -P with+ -- --tls / --path — resolved once, before anything dials+ opts <- either (\e -> hPutStrLn stderr ("synapse: " <> T.unpack e) >> exitWith (ExitFailure 2)) pure+ =<< resolveConnection (argOpts args0)+ let args = args0 { argOpts = opts }+ ep = soEndpoint opts++ -- SYNAPSE-20: the reconnect window, before anything dials+ envWindow <- lookupEnv "SYNAPSE_RECONNECT_FOR"+ case soReconnectFor opts <|> fmap T.pack envWindow of+ Nothing -> pure ()+ Just w -> case Reconnect.parseDuration w of+ Just secs -> Reconnect.setReconnectWindow secs+ Nothing -> do+ hPutStrLn stderr $ "synapse: --reconnect-for takes a duration (10m, 90s, 1h, 0), not '" <> T.unpack w <> "'"+ exitWith (ExitFailure 2)++ -- IDENTITY-43: keys are local; _login / _logout dial the backend+ -- themselves (a proof opens exactly one connection — no probe first).+ when (argBackend args == Just "_key") $ runKey (argPath args)+ when (argBackend args == Just "_login") $ runLogin opts (argPath args)+ when (argBackend args == Just "_logout") $ runLogout opts (argPath args)++ -- Initialize logger (default: ErrorS, only show errors)+ logger <- makeLoggerFromOpts opts++ Log.logDebug logger Log.SubsystemCLI "synapse starting..."++ -- Use specified host/port for registry discovery+ let discovery = registryDiscoveryAt ep++ -- Get the backend at the connection point (host:port)+ -- If it has a registry plugin, that's used for discovering other backends+ Log.logDebug logger Log.SubsystemDiscovery $+ "Checking backend at " <> Store.addressKey ep++ -- IDENTITY-16: the probe carries what the command line supplied (-t,+ -- --token-file, --cookie, --header and their env forms), so a backend that+ -- refuses anonymous upgrades still answers it.+ -- IDENTITY-43: and the agent's stored session, made again from its key+ -- if the upgrade refuses it.+ -- SYNAPSE-20: a call to a named backend waits out an outage at start+ -- too (a promote's restart); the registry listing and _self never wait.+ let waits = case argBackend args of+ Just b -> b == "_find" || not ("_" `T.isPrefixOf` b || "-" `T.isPrefixOf` b)+ Nothing -> False+ maybeBackend <- probeWithSession' waits opts ep++ Log.logDebug logger Log.SubsystemDiscovery $+ "Backend check complete: " <> T.pack (show maybeBackend)+ let hostBackend = maybe "plexus" id maybeBackend++ -- Auto-register: if we're connecting to a non-default port, push the+ -- discovered backend to the registry at the default port (if reachable)+ let targetPort = soPort opts+ targetHost = soHost opts+ -- SCAFFOLD-21: a registry records ws://HOST:PORT; an address behind TLS+ -- or on a path is not registered+ when (targetPort /= defaultPort && isPlain ep) $ do+ case maybeBackend of+ Just name -> do+ -- Check if the registry is at the default port+ maybeRegistry <- getBackendAt targetHost defaultPort+ case maybeRegistry of+ Just registryName ->+ registerWithRegistry targetHost defaultPort registryName+ name targetHost targetPort+ Nothing -> pure ()+ Nothing -> pure ()++ case argBackend args of+ Nothing+ -- Suppress banner for data output modes+ | soEmitIR opts || soSchema opts || soJson opts || soRaw opts ->+ runWithDiscovery discovery hostBackend args+ | otherwise -> do+ -- No backend specified, show available backends+ TIO.putStr cliHeader+ backends <- discoverBackends discovery+ -- Ping backends to check if they're reachable+ backendsWithStatus <- pingBackendsVia ep backends+ TIO.putStrLn "\nAvailable backends:"+ mapM_ (printBackend ep) backendsWithStatus+ TIO.putStrLn "\nUsage: synapse <backend> [command...]"+ Just backend+ -- Handle --help/-h if it somehow ends up as the backend+ | backend `elem` ["--help", "-h"] -> TIO.putStr cliHeader+ -- SYNAPSE-17: _find BACKEND [activation…] WORDS… — a search over the+ -- backend's schema, reached as a call to that backend is+ | backend == "_find" -> do+ fo <- parseBuiltin "_find" Find.findInfo (argPath args)+ runWithDiscovery discovery (Find.foBackend fo)+ args { argBackend = Just (Find.foBackend fo), argPath = [], argFind = Just fo }+ -- Handle _self meta-commands+ | backend == "_self" ->+ -- SELF-4 verbs (synapse _self <backend> <verb>) dispatch through+ -- a dedicated parser on the raw argPath. Legacy _self+ -- subcommands (template / scan / debug / validate / test) keep+ -- the original path through runWithDiscovery.+ if isSelfV4Invocation (argPath args)+ then runSelfV4 (argPath args)+ else runWithDiscovery discovery hostBackend args+ | otherwise -> runWithDiscovery discovery backend args++-- | Legacy @_self@ subcommands that predate SELF-4. These keep their+-- original dispatch path through 'Synapse.Self.Commands.dispatch'.+--+-- Anything else under @_self@ is treated as SELF-4 (@_self \<backend\>+-- ...@) and routes through 'Synapse.Self.Command.runSelfCommand'.+legacySelfSubcommands :: [Text]+legacySelfSubcommands =+ [ "template", "scan", "debug", "validate", "test"+ , "--help", "-h" -- Legacy help path is also handled by the old dispatcher+ ]++-- | True when @_self@ was invoked in the SELF-4 shape — anything other+-- than a legacy subcommand as the first positional. The SELF-4 parser+-- handles its own help / error messaging from there, so even+-- @_self \<backend\>@ (no verb) routes here: optparse emits a+-- "Missing: COMMAND" message listing the SELF-4 verbs.+isSelfV4Invocation :: [Text] -> Bool+isSelfV4Invocation [] = False -- No args at all: legacy help.+isSelfV4Invocation (first : _) = first `notElem` legacySelfSubcommands++-- | Re-parse the post-@_self@ token stream through the SELF-4+-- optparse-applicative parser, then run the resulting command.+runSelfV4 :: [Text] -> IO ()+runSelfV4 rest = do+ let argv = map T.unpack rest+ parserInfo = SelfCmd.selfCommandInfo+ pr = execParserPure defaultPrefs parserInfo argv+ cmd <- handleParseResult pr+ code <- SelfCmd.runSelfCommand cmd+ case code of+ ExitSuccess -> pure ()+ ExitFailure _ -> exitWith code++-- | A builtin's options, its usage said as @synapse NAME …@.+parseBuiltin :: String -> ParserInfo a -> [Text] -> IO a+parseBuiltin name pinfo rest =+ case execParserPure defaultPrefs pinfo (map T.unpack rest) of+ Failure f -> do+ let (msg, code) = renderFailure f ("synapse " <> name)+ case code of+ ExitSuccess -> putStrLn msg >> exitSuccess+ _ -> hPutStrLn stderr msg >> exitWith code+ r -> handleParseResult r++-- | IDENTITY-43: @synapse _key new|show|list@ — local.+runKey :: [Text] -> IO ()+runKey rest = do+ cmd <- handleParseResult (execParserPure defaultPrefs IdCmd.keyCommandInfo (map T.unpack rest))+ st <- Store.systemStoreEnv+ IdCmd.runKeyCommand st cmd >>= exitWith++-- | IDENTITY-43: where @_login@ / @_logout@ dial — the connection point+-- named with @-P@, else what the registry says of the backend, else the+-- connection point.+loginAddress :: SynapseOpts -> Text -> IO Endpoint+loginAddress opts backendName+ | soPort opts /= defaultPort || not (isPlain ep) = pure ep+ | otherwise = do+ registered <- getBackendInfo (registryDiscoveryAt ep) backendName+ pure $ maybe ep (endpointFor ep) registered+ where ep = soEndpoint opts++dieWith :: Text -> IO a+dieWith msg = TIO.hPutStrLn stderr ("synapse: " <> msg) >> exitFailure++-- | IDENTITY-43: @synapse -P PORT _login BACKEND --key NAME --as AGENT+-- [--scope role\@SCOPE …] [--ttl SECS]@.+runLogin :: SynapseOpts -> [Text] -> IO ()+runLogin opts rest = do+ lo <- handleParseResult (execParserPure defaultPrefs IdCmd.loginInfo (map T.unpack rest))+ asked <- askedAgent opts+ -- AUTH-8: no key and no agent named — a person, through the browser+ when (IdCmd.loKey lo == Nothing && IdCmd.loAs lo == Nothing && asked == Nothing) $+ runLoginPerson opts lo+ agent <- maybe (dieWith "name the agent to log in as: --as AGENT (or SYNAPSE_AS)") pure (IdCmd.loAs lo <|> asked)+ let keyName = maybe "default" id (IdCmd.loKey lo)+ st <- Store.systemStoreEnv+ seed <- Store.lookupKey st keyName >>= \case+ Left e -> dieWith e+ Right Nothing -> dieWith ("no key " <> keyName <> " (synapse _key new " <> keyName <> ")")+ Right (Just (s, _)) -> pure s+ ep <- loginAddress opts (IdCmd.loBackend lo)+ logger <- makeLoggerFromOpts opts+ r <- Session.loginWithKey logger ep (IdCmd.loBackend lo) seed agent (IdCmd.loScope lo) (IdCmd.loTtl lo)+ lr <- either (\e -> dieWith ("login to " <> IdCmd.loBackend lo <> " at " <> Store.addressKey ep <> " failed: " <> e)) pure r+ let rec = Store.SessionRec+ { Store.srToken = Session.lrToken lr+ , Store.srPrincipal = if T.null (Session.lrPrincipal lr) then Proof.principalOf seed else Session.lrPrincipal lr+ , Store.srAgent = Session.lrAgent lr+ , Store.srExpiresAt = Session.lrExpiresAt lr+ , Store.srKey = keyName+ , Store.srScope = IdCmd.loScope lo+ , Store.srTtl = IdCmd.loTtl lo+ , Store.srBackend = IdCmd.loBackend lo+ }+ src <- Store.writeSession st ep rec+ Store.writeDefaultAgent st ep (Store.srAgent rec)+ let until' = case Store.srExpiresAt rec of+ Just t -> T.pack (formatTime defaultTimeLocale "%Y-%m-%d %H:%M:%S UTC" (posixSecondsToUTCTime (fromIntegral t)))+ Nothing -> "(the server named no end)"+ scope = if null (Session.lrScope lr) then "the key's own grants" else T.intercalate ", " (Session.lrScope lr)+ kept = case src of+ Store.SessionFromKeychain -> "keychain"+ Store.SessionFromFile p -> T.pack p+ Store.SessionFromEnv -> "PLEXUS_SESSION"+ TIO.putStrLn $ "signed in to " <> IdCmd.loBackend lo <> " at " <> Store.addressKey ep <> " as " <> Store.srAgent rec+ mapM_ (\(k, v) -> TIO.putStrLn (" " <> T.justifyLeft 10 ' ' k <> v))+ [ ("principal", Store.srPrincipal rec)+ , ("scope", scope)+ , ("until", until')+ , ("kept in", kept)+ ]+ exitSuccess++-- | AUTH-8: the session slot a person's sign-in is kept under.+personAgent :: Text+personAgent = "me"++-- | AUTH-8: @synapse -P PORT _login BACKEND@ — a person signs in through+-- the browser (the loopback flow), and the session is kept as an agent's+-- is (keychain, else file), under the name @me@, with no key to make it+-- again from: when it ends, synapse says to sign in again.+runLoginPerson :: SynapseOpts -> IdCmd.LoginOpts -> IO ()+runLoginPerson opts lo = do+ let backend = IdCmd.loBackend lo+ ep <- loginAddress opts backend+ logger <- makeLoggerFromOpts opts+ r <- Session.loginPerson logger ep backend 300 (TIO.hPutStrLn stderr)+ g <- either (\e -> dieWith ("sign-in to " <> backend <> " at " <> Store.addressKey ep <> " failed: " <> e)) pure r+ who <- case sgWhoami g of+ Just w -> pure (Just w)+ Nothing -> Session.whoamiWith logger ep backend (sgToken g)+ let rec = Store.SessionRec+ { Store.srToken = sgToken g+ , Store.srPrincipal = sgPrincipal g+ , Store.srAgent = personAgent+ , Store.srExpiresAt = sgExpiresAt g+ , Store.srKey = "" -- a person's: nothing to sign in again from+ , Store.srScope = sgCeiling g+ , Store.srTtl = Nothing+ , Store.srBackend = backend+ }+ st <- Store.systemStoreEnv+ src <- Store.writeSession st ep rec+ Store.writeDefaultAgent st ep personAgent+ let named = case who of+ Just w -> maybe "" id (T.stripPrefix "you are " (renderCallerHeader w))+ Nothing -> case sgName g of+ Just n -> n <> " (" <> sgPrincipal g <> ")"+ Nothing -> sgPrincipal g+ until' = case sgExpiresAt g of+ Just t -> T.pack (formatTime defaultTimeLocale "%Y-%m-%d %H:%M:%S UTC" (posixSecondsToUTCTime (fromIntegral t)))+ Nothing -> "(the server named no end)"+ kept = case src of+ Store.SessionFromKeychain -> "keychain"+ Store.SessionFromFile p -> T.pack p+ Store.SessionFromEnv -> "PLEXUS_SESSION"+ TIO.putStrLn $ "signed in to " <> backend <> " as " <> named+ mapM_ (\(k, v) -> TIO.putStrLn (" " <> T.justifyLeft 10 ' ' k <> v))+ [ ("until", until')+ , ("kept in", kept <> " (as " <> personAgent <> ")")+ ]+ exitSuccess++-- | IDENTITY-43: @synapse -P PORT _logout BACKEND [--as AGENT]@.+runLogout :: SynapseOpts -> [Text] -> IO ()+runLogout opts rest = do+ lo <- handleParseResult (execParserPure defaultPrefs IdCmd.logoutInfo (map T.unpack rest))+ st <- Store.systemStoreEnv+ ep <- loginAddress opts (IdCmd.lgBackend lo)+ asked <- askedAgent opts+ dflt <- Store.readDefaultAgent st ep+ agent <- maybe (dieWith ("no agent is signed in to " <> Store.addressKey ep <> " (name one: --as AGENT)")) pure+ (IdCmd.lgAs lo <|> asked <|> dflt)+ found <- Store.lookupSession st ep agent+ rec <- case found of+ Just (Right r, src) | src /= Store.SessionFromEnv -> pure r+ _ -> dieWith ("no stored session for " <> agent <> " at " <> Store.addressKey ep)+ logger <- makeLoggerFromOpts opts+ r <- Session.logoutWithToken logger ep (IdCmd.lgBackend lo) (Store.srToken rec)+ Store.removeSession st ep agent+ Store.clearDefaultAgent st ep agent+ case r of+ Right () -> TIO.putStrLn ("signed out " <> agent <> " at " <> Store.addressKey ep)+ Left e -> TIO.putStrLn ("forgot " <> agent <> "'s session at " <> Store.addressKey ep+ <> " (the server said: " <> e <> ")")+ exitSuccess++-- | Print a backend in the list+-- (SCAFFOLD-21: the connection point by the URL it was named with, when+-- it is not plain ws://HOST:PORT/)+printBackend :: Endpoint -> Backend -> IO ()+printBackend ep b = do+ let nameField = T.justifyLeft 15 ' ' (backendName b)+ let hostPort = Store.addressKey (endpointFor ep b)+ let status = case backendReachable b of+ Just True -> " [OK]"+ Just False -> " [UNREACHABLE]"+ Nothing -> ""+ let desc = if T.null (backendDescription b)+ then ""+ else " - " <> backendDescription b+ TIO.putStrLn $ " " <> nameField <> hostPort <> status <> desc++-- | Run a Hub command with backend discovery+runWithDiscovery :: BackendDiscovery -> Text -> Args -> IO ()+runWithDiscovery discovery backendName args = do+ let opts = argOpts args+ ep = soEndpoint opts+ -- Try to discover backend info+ maybeBackend <- getBackendInfo discovery backendName++ case maybeBackend of+ Just backend -> do+ -- Verify the resolved backend actually identifies as the requested name.+ -- This catches cases where -P points to a different backend than expected,+ -- or the registry has stale entries.+ -- SYNAPSE-20: a named backend's probe waits out an outage (or the+ -- server's 429 under many parallel calls) as the first one did+ -- SCAFFOLD-21: at the connection point, reached as it was named+ let at = endpointFor ep backend+ actualName <- probeWithSession' True opts at+ case actualName of+ Just name | name /= backendName -> do+ hPutStrLn stderr $ "Backend mismatch: requested '" <> T.unpack backendName+ <> "' but " <> T.unpack (Store.addressKey at)+ <> " identifies as '" <> T.unpack name <> "'"+ exitFailure+ Nothing -> do+ -- Protocol handshake failed when verifying backend+ when (epTls at) $ tlsUnreachable at+ logger <- makeLoggerFromOpts opts+ env <- buildEnv ep backendName logger opts+ result <- runSynapseM env (throwBackend (ProtocolHandshakeFailed (backendHost backend) (backendPort backend)) [])+ case result of+ Left err -> do+ hPutStrLn stderr $ renderError err+ exitFailure+ Right () -> exitSuccess+ Just _ ->+ -- Match confirmed+ run backendName at args+ Nothing -> do+ -- Backend not found in registry - check if this was a direct connection attempt+ -- where the protocol handshake failed+ maybeDirectBackend <- probeWithSession' True opts ep+ logger <- makeLoggerFromOpts opts+ env <- buildEnv ep backendName logger opts+ case maybeDirectBackend of+ Nothing -> do+ -- Protocol handshake failed at the specified host:port+ when (epTls ep) $ tlsUnreachable ep+ result <- runSynapseM env (throwBackend (ProtocolHandshakeFailed (soHost opts) (soPort opts)) [])+ case result of+ Left err -> do+ hPutStrLn stderr $ renderError err+ exitFailure+ Right () -> exitSuccess+ Just discoveredName | discoveredName /= backendName -> do+ -- Backend exists but has a different name+ backends <- discoverBackends discovery+ backendsWithStatus <- pingBackendsVia ep backends+ result <- runSynapseM env (throwBackend (BackendNotFound backendName) backendsWithStatus)+ case result of+ Left err -> do+ hPutStrLn stderr $ renderError err+ exitFailure+ Right () -> exitSuccess+ Just _ -> do+ -- Backend discovered successfully - should not reach here, but proceed+ run backendName ep args++-- | SCAFFOLD-21: a wss address whose probe failed — why, from the probe+-- (a certificate the trust store refused, a name it does not cover, a+-- proxy that does not upgrade), and what to do about a local CA. Exits 1.+tlsUnreachable :: Endpoint -> IO ()+tlsUnreachable at = do+ why <- maybe "no answer" id <$> probeFailureAt at+ let shown = T.replace "\\\"" "\"" (fst (T.breakOn "\\nCallStack" why))+ detail = case T.breakOn "Error_Protocol \"" shown of+ (_, r) | not (T.null r) -> T.takeWhile (/= '"') (T.drop (T.length "Error_Protocol \"") r)+ _ -> shown+ has = any (`T.isInfixOf` shown)+ if has ["HandshakeFailed"]+ then do+ TIO.hPutStrLn stderr $ "synapse: the TLS handshake with " <> E.shortUrl at <> " failed: " <> detail+ TIO.hPutStrLn stderr $+ if has ["UnknownCa", "unknown CA", "ertificate", "NameMismatch", "Expired", "SelfSigned"]+ then " its certificate is checked against the system's trust store and each --cacert, and its name\n"+ <> " against " <> epHost at <> "; for a local CA, add --cacert ROOT.pem"+ else " the server refused it — does it serve a certificate for " <> epHost at <> "?"+ else case T.breakOn "responseCode = " shown of+ (_, r) | not (T.null r) -> do+ let code = T.takeWhile (`elem` ['0' .. '9']) (T.drop (T.length "responseCode = ") r)+ TIO.hPutStrLn stderr $ "synapse: " <> E.shortUrl at <> " answered the upgrade with HTTP " <> code+ <> ", not a WebSocket"+ TIO.hPutStrLn stderr $ if code `elem` ["401", "403"]+ then " it refused the credential: sign in with synapse " <> cliOptions at <> " _login BACKEND"+ else " is " <> epPath at <> " the socket's path? (a hosted app's is /ws)"+ _ -> TIO.hPutStrLn stderr $ "synapse: " <> E.shortUrl at <> " did not answer the _info probe: " <> detail+ exitFailure++-- | Run a Hub command with specified backend and connection details+run :: Text -> Endpoint -> Args -> IO ()+run backend ep args = do+ let opts = argOpts args+ logger <- makeLoggerFromOpts opts+ Log.logInfo logger Log.SubsystemCLI $ "Synapse starting: backend=" <> backend <> ", at " <> Store.addressKey ep+ env <- withView ep =<< buildEnv ep backend logger opts+ rendererCfg <- defaultRendererConfig+ Log.logDebug logger Log.SubsystemCLI "Running dispatch..."+ result <- runSynapseM env (dispatch args rendererCfg)+ -- IDENTITY-43: the server refused the stored session (the upgrade's 401,+ -- or the in-band "you have signed out" held back before anything was+ -- shown): log in again from the key, and run the call once more.+ ended <- Session.takeSessionEnded+ let refused = case result of+ Left (TransportErrorContext ctx) -> Session.isUpgradeRefused (tcMessage ctx)+ Left (NavError (FetchError m _)) -> Session.isUpgradeRefused m+ _ -> False+ result' <- if not (isJust ended || refused) then pure result else do+ st <- Store.systemStoreEnv+ new <- Session.reattest logger st ep+ case new of+ Just _ -> do+ env' <- withView ep =<< buildEnv ep backend logger opts+ runSynapseM env' (dispatch args rendererCfg)+ Nothing -> do+ mapM_ (\m -> hPutStrLn stderr ("Error: " <> T.unpack m)) ended+ pure result+ case result' of+ Left err -> do+ hPutStrLn stderr $ renderError err+ exitFailure+ Right () -> do+ -- AUTH-8: a refusal was shown — the call did not happen+ refused' <- Session.wasRefused+ if refused' then exitWith (ExitFailure refusedExit) else exitSuccess++-- | AUTH-8: the exit status of a call the hub refused (sysexits' EX_NOPERM).+refusedExit :: Int+refusedExit = 77++-- | AUTH-8: the env's cache keys qualified by the caller's view, as the+-- _info probe at this address answered it (the view's hash, else its kind).+withView :: Endpoint -> SynapseEnv -> IO SynapseEnv+withView ep env = do+ info <- probedInfoAt ep+ let key = info >>= \i -> infoViewHash i <|> (viewText <$> infoView i)+ pure (withViewKey key env)++-- | Dispatch based on navigation result+dispatch :: Args -> RendererConfig -> SynapseM ()+dispatch Args{argOpts = soOpts@SynapseOpts{..}, argBackend, argPath, argFind} rendererCfg = do+ -- Check for _self commands first (before anything else)+ case argBackend of+ _ | Just fo <- argFind -> runFind fo+ Just "_self" -> do+ -- _self meta-command: parse subcommand and rest from argPath+ let (pathSegs, rawParams, _, _) = parsePathAndParams argPath+ case pathSegs of+ (subcommand : rest) -> do+ Self.dispatch subcommand rest rawParams+ return ()+ [] -> do+ -- No subcommand provided, show help+ Self.showHelp+ return ()++ -- Normal dispatch+ _ -> do+ -- Mode 1: Raw JSON-RPC passthrough+ case soRpc of+ Just rpcJson -> do+ case eitherDecode (LBS.fromStrict $ TE.encodeUtf8 rpcJson) of+ Left err -> throwParse $ T.pack err+ Right rpcReq -> do+ items <- invokeRawRpc rpcReq+ liftIO $ mapM_ (printResult soJson soRaw rendererCfg) items+ return ()++ Nothing -> do+ -- Parse path and inline params (--key value pairs)+ let (pathSegs, rawParams, flagArgs, helpRequested) = parsePathAndParams argPath++ -- Normal Plexus RPC routing+ -- Apply parameter transformations (path expansion, env vars)+ transformEnv <- liftIO mkTransformEnv+ -- SYNAPSE-10: a value that names its source (@file, -) is read+ -- first, so no other step ever sees the literal "@file"; stdin is+ -- read once and kept for the second pass below+ sourceEnv <- liftIO $ mkSourceEnv (teCwd transformEnv) (teHome transformEnv)+ let fromSources pairs = liftIO (expandSources sourceEnv pairs) >>= either (throwParse . renderSourceError) pure+ sourcedParams <- if helpRequested then pure rawParams else fromSources rawParams+ inlineParams <- liftIO $ transformParams transformEnv defaultTransformers sourcedParams++ -- Mode 1.5: Respond subcommand (agent sends a response to a pending bidir request)+ if not (null pathSegs) && head pathSegs == "respond"+ then handleRespondCommand sourcedParams++ -- Mode 2: Schema request+ else if soSchema+ then do+ -- Try to navigate and determine if last segment is a method+ schemaResult <- fetchSchemaForPath pathSegs+ case schemaResult of+ Left err -> throwNav $ FetchError err pathSegs+ Right val -> liftIO $ LBS.putStrLn $ encode val++ -- Mode 3: Generate templates (IR-driven approach)+ else if soGenerate+ then do+ homeDir <- liftIO getHomeDirectory+ let baseDir = homeDir </> ".config" </> "synapse" </> "templates"+ writeAndLog gt = do+ writeGeneratedTemplateIR baseDir gt+ TIO.putStrLn $ " " <> T.pack (TemplateIR.gtPath gt)+ liftIO $ TIO.putStrLn $ "Generating templates in " <> T.pack baseDir <> "..."+ count <- TemplateIR.generateAllTemplatesWithCallback writeAndLog pathSegs+ liftIO $ TIO.putStrLn $ "Generated " <> T.pack (show count) <> " templates"++ -- Mode 4: Emit IR for code generation+ else if soEmitIR+ then do+ ir <- buildIR soGeneratorInfo pathSegs+ liftIO $ LBS.putStrLn $ encode ir++ else do+ -- Mode 5: Normal navigation+ info <- currentInfo+ let anonymous = (info >>= infoView) == Just Anonymous+ if null pathSegs+ then do+ -- SYNAPSE-12: a backend named, its help is the+ -- backend's alone — no banner, options or meta-commands.+ -- AUTH-8: to an anonymous caller, only the door.+ rootSchema <- if anonymous+ then (Just <$> navigate []) `catchError` \_ -> pure Nothing+ else Just <$> navigate []+ case rootSchema of+ _ | anonymous -> showAnonymous info (rootSchema >>= viewSchema)+ Just (ViewPlugin schema _) -> showPluginHelp info [] schema+ _ -> pure ()+ else do+ -- Navigate to target+ -- AUTH-8: an anonymous caller asking for anything the+ -- door does not show is shown the door+ view <- navigate pathSegs `catchError` \e -> case e of+ NavError (NotFound {}) | anonymous -> do+ root <- (Just <$> navigate []) `catchError` \_ -> pure Nothing+ showAnonymous info (root >>= viewSchema)+ liftIO $ do+ TIO.hPutStrLn stderr $ "synapse: sign in to reach " <> T.intercalate " " pathSegs+ exitWith (ExitFailure refusedExit)+ _ -> throwError e+ case view of+ -- Landed on a plugin: show help+ ViewPlugin schema _ -> showPluginHelp info pathSegs schema++ -- Landed on a method: invoke or show help+ ViewMethod method path -> do+ let fullPath = T.intercalate "." path++ -- IR-15: emit stderr deprecation warnings right before any+ -- real invocation. Dedupe is per-process-lifetime, so this+ -- action is idempotent on repeat calls. We bind it as a+ -- local SynapseM action and invoke it at both invocation+ -- sites below (fast path and slow path). --help, --dry-run,+ -- --schema, and --emit-ir do not invoke, and intentionally+ -- do not fire these warnings.+ let fireDeprecationWarnings :: SynapseM ()+ fireDeprecationWarnings = do+ liftIO $ emitMethodWarning+ soNoDeprecationWarnings+ fullPath+ (methodDeprecation method)+ let namespacePath = init path+ when (not (null namespacePath)) $ do+ let activationNs = T.intercalate "." namespacePath+ parentPlugin <- fetchSchemaAt namespacePath+ liftIO $ emitActivationWarning+ soNoDeprecationWarnings+ activationNs+ (psDeprecation parentPlugin)++ -- Optimization: Skip IR construction for parameter-less methods+ -- IR is only needed for:+ -- 1. Help rendering (--help flag)+ -- 2. Parameter parsing (methods with params)+ let needsIR = helpRequested+ || isJust (methodParams method)+ || isJust soParams+ || not (null inlineParams)++ -- Fast path: parameter-less method with no help requested+ if not needsIR+ then fireDeprecationWarnings >> invokeMethod sourceEnv method path (object [])+ else do+ -- Build IR for this method's namespace+ ir <- buildIR soGeneratorInfo (init path)++ -- If --help was explicitly requested, show help and exit+ if helpRequested+ then do+ case Map.lookup fullPath (irMethods ir) of+ Just methodDef -> do+ page <- pageFor info pathSegs method+ liftIO $ do+ TIO.putStr $ renderMethodPage page ir methodDef+ backendHelpFooter+ Nothing ->+ liftIO $ TIO.putStrLn $ T.intercalate "." path <> " - " <> methodDescription method+ else do+ -- Build params: use IR-driven parsing for inline params+ let schemaDefaults = extractSchemaDefaults method+ userParams <- case soParams of+ -- -p JSON: parse as raw JSON (bypass IR parsing);+ -- SYNAPSE-10: -p @params.json and -p - name a source+ Just given -> do+ jsonStr <- liftIO (expandValue sourceEnv "" given) >>= either (throwParse . renderSourceError) pure+ case eitherDecode (LBS.fromStrict $ TE.encodeUtf8 jsonStr) of+ Left err -> throwParse $ T.pack err+ Right p -> pure p+ Nothing+ | not (null inlineParams) ->+ -- Use IR-driven parsing for inline params+ case Map.lookup fullPath (irMethods ir) of+ Just methodDef -> do+ -- Pair the flags again by parameter type, now that the+ -- method is known (a value may begin with dashes)+ methodParams <- case pairFlags ir methodDef flagArgs of+ Right pairs -> fromSources pairs >>= liftIO . transformParams transformEnv defaultTransformers+ Left errs -> do+ liftIO $ mapM_ (hPutStrLn stderr . renderParseError) errs+ throwParse "Parameter parsing failed"+ -- Inject boolean defaults for flags without values+ let paramsWithBools = injectBooleanDefaults ir methodDef methodParams+ -- Inject smart defaults for missing required params (e.g., --path defaults to cwd)+ paramsWithDefaults <- liftIO $ injectSmartDefaults transformEnv ir methodDef paramsWithBools+ case parseParams ir methodDef paramsWithDefaults of+ Right p -> pure p+ Left errs -> do+ liftIO $ mapM_ (hPutStrLn stderr . renderParseError) errs+ throwParse "Parameter parsing failed"+ Nothing ->+ -- Fallback to flat object if method not in IR+ pure $ buildParamsObject inlineParams+ | otherwise -> pure $ object []+ -- Merge: user params override schema defaults+ let params = mergeParams schemaDefaults userParams++ if soDryRun+ then do+ backend <- asks seBackend+ liftIO $ LBS.putStrLn $ encodeDryRun backend (init path) (last path) params+ -- Show help when: method has required params AND user provided no params+ else if hasRequiredParams method && userParams == object [] && null inlineParams+ then do+ -- Render help from IR+ case Map.lookup fullPath (irMethods ir) of+ Just methodDef -> do+ page <- pageFor info pathSegs method+ liftIO $ do+ TIO.putStr $ renderMethodPage page ir methodDef+ backendHelpFooter+ Nothing ->+ -- Fallback: method not in IR, use basic info+ liftIO $ TIO.putStrLn $ T.intercalate "." path <> " - " <> methodDescription method+ -- IR-15: fire stderr deprecation warnings before real invocation.+ else fireDeprecationWarnings >> invokeMethod sourceEnv method path params+ where+ -- AUTH-8: what the _info probe at this address answered (view, auth)+ currentInfo :: SynapseM (Maybe BackendInfo)+ currentInfo = do+ ep <- asks seEndpoint+ liftIO (probedInfoAt ep)++ viewSchema = \case+ ViewPlugin schema _ -> Just schema+ _ -> Nothing++ -- A plugin's help: as SYNAPSE-12 laid it out; in a caller's view (AUTH-4)+ -- with who the caller is under the description, and each method's+ -- grant and where it is held when the schema says them.+ --+ -- SYNAPSE-13: in gh's shape — a usage line, one line per method in its+ -- group, and how to see a method's options.+ showPluginHelp :: Maybe BackendInfo -> [Text] -> PluginSchema -> SynapseM ()+ showPluginHelp info segs schema = do+ backend <- asks seBackend+ let invoke = T.unwords ([invokeProgram, backend] ++ segs)+ who <- case info >>= infoView of+ Just Caller -> Session.whoamiM+ _ -> pure Nothing+ let caller = case info >>= infoView of+ Just Caller -> Just (maybe "you are signed in" renderCallerHeader who)+ _ -> Nothing+ liftIO $ do+ TIO.putStr $ renderListing defaultStyle (Listing invoke caller) schema+ synapseHelpLine++ -- SYNAPSE-13: synapse and the global options that reach this backend,+ -- as a command line shown in help (@synapse -P 4455@).+ invokeProgram :: Text+ invokeProgram = T.unwords (filter (not . T.null) ["synapse", cliOptions soEndpoint])++ -- SYNAPSE-13: what a method's page needs beside the IR — how it is+ -- reached, its schema (examples, values, grant) and the caller's view.+ pageFor :: Maybe BackendInfo -> [Text] -> MethodSchema -> SynapseM MethodPage+ pageFor info segs method = do+ backend <- asks seBackend+ pure MethodPage+ { mpProgram = invokeProgram+ , mpTarget = T.unwords (backend : segs)+ , mpSchema = Just method+ , mpCaller = (info >>= infoView) == Just Caller+ }++ -- SYNAPSE-17: search the backend's methods and parameters (the+ -- caller's view of its schema, from the IR cache when its hash is+ -- current) and answer in help's shape; -j: one JSON object per hit.+ runFind :: Find.FindOpts -> SynapseM ()+ runFind fo = do+ backend <- asks seBackend+ cache <- liftIO $ if soNoCache then pure Nothing else+ (Just <$> (newIRCache . defaultCacheConfig =<< getHomeDirectory))+ `E.catch` \(_ :: E.SomeException) -> pure Nothing+ ir <- Find.loadCatalog cache+ tty <- liftIO $ hIsTerminalDevice stdout+ noColor <- liftIO $ lookupEnv "NO_COLOR"+ let ans = Find.answer invokeProgram backend (tty && maybe True null noColor) fo ir+ liftIO $ if not (Find.anMatched ans)+ then do+ if soJson+ then TIO.hPutStrLn stderr ("No match for '" <> T.unwords (Find.foTerms fo) <> "' in " <> Find.anScope ans <> ".")+ else TIO.putStr (Find.anText ans)+ exitWith (ExitFailure 1)+ else if soJson+ then mapM_ (LBS.putStrLn . encode) (Find.anJson ans)+ else TIO.putStr (Find.anText ans)++ -- AUTH-8: the page an anonymous caller sees: the door, and how to sign in.+ showAnonymous :: Maybe BackendInfo -> Maybe PluginSchema -> SynapseM ()+ showAnonymous info root = do+ backend <- asks seBackend+ authSchema <- (Just <$> fetchSchemaAt ["auth"]) `catchError` \_ -> pure Nothing+ let invokePrefix = invokeProgram+ page = AnonPage+ { apBackend = backend+ , apVersion = psVersion <$> root+ , apMethods = anonymousMethods (info >>= infoAuth) authSchema+ , apInvoke = invokePrefix+ }+ liftIO $ TIO.putStr (renderAnonymousPage page)++ handleRespondCommand :: [(Text, Text)] -> SynapseM ()+ handleRespondCommand params = do+ let mRequestId = lookup "request_id" params+ mResponseStr = lookup "response" params+ case (mRequestId, mResponseStr) of+ (Nothing, _) -> throwParse "Missing --request-id for respond subcommand"+ (_, Nothing) -> throwParse "Missing --response for respond subcommand"+ (Just requestId, Just responseStr) ->+ case eitherDecode (LBS.fromStrict $ TE.encodeUtf8 responseStr) of+ Left err -> throwParse $ "Invalid --response JSON: " <> T.pack err+ Right (resp :: Response Value) -> sendResponse requestId resp++ invokeMethod srcEnv method path params = do+ let namespacePath = init path -- path without method name+ let methodName' = last path+ -- Set method path hint for template resolution+ let rendererCfg' = withMethodPath rendererCfg path+ -- Determine bidirectional mode (--bidir-cmd takes precedence)+ bidirMode <- liftIO $ case soBidirCmd of+ Just cmd -> pure $ BidirCmd cmd+ Nothing -> case soBidirMode of+ Just modeStr -> case parseBidirMode modeStr of+ Just mode -> pure mode+ Nothing -> do+ TIO.hPutStrLn stderr $ "[synapse] Unknown --bidir-mode: " <> modeStr <> ", using auto-detect"+ detectBidirMode+ Nothing -> detectBidirMode+ -- SYNAPSE-10: a value read from stdin (-) has taken it; the JSON+ -- bidirectional mode reads stdin too, and the two cannot share+ stdinTaken <- liftIO $ stdinWasRead srcEnv+ case bidirMode of+ BidirJson | stdinTaken -> throwParse "a value was read from stdin (-), so --bidir-mode json cannot read it too; answer the backend's requests with --bidir-cmd, or use a file (@FILE) for the value"+ _ -> pure ()+ -- IDENTITY-43: while the stored session could still be made again,+ -- a "you have signed out" answered before anything else is held back+ -- for 'run' to log in again and retry.+ ep <- asks seEndpoint+ armed <- liftIO $ Session.canReattest ep+ let printer item = do+ shown <- Session.printedAny+ if armed && not shown && Session.isSessionEndedItem item+ then Session.markSessionEnded (endedMessage item)+ else do+ case item of+ HubData{} -> Session.notePrinted+ _ -> pure ()+ printResult soJson soRaw rendererCfg' item+ -- SYNAPSE-20: a stream that resumed after a gap says so — on -j as a+ -- line of its own (stdout, in the stream), else on stderr+ let resumed r+ | soJson = do+ LBS.putStrLn $ encode $ object $+ [ "type" .= ("reconnected" :: Text)+ , "outage_secs" .= (fromIntegral (round (Reconnect.rsOutageSecs r) :: Int) :: Int)+ , "attempts" .= Reconnect.rsAttempts r+ ] ++ maybe [] (\s -> ["since" .= s]) (Reconnect.rsSince r)+ hFlush stdout+ | otherwise = TIO.hPutStrLn stderr $+ "synapse: the stream resumed"+ <> maybe "; anything sent during the gap may be missing" (\s -> " from " <> T.pack (show s) <> " (since)") (Reconnect.rsSince r)+ -- SYNAPSE-20: a stream outliving a restart finds its session gone+ -- (menger keeps them in memory): sign in again from the key, and+ -- reconnect with the new one+ backendName' <- asks seBackend+ logger' <- asks seLogger+ let refreshAuth = do+ st <- Store.systemStoreEnv+ new <- Session.reattestAgain logger' st ep+ case new of+ Nothing -> pure Nothing+ Just _ -> do+ env' <- buildEnv ep backendName' logger' soOpts+ pure (Just (mergeUpgradeHeaders (seToken env') (seCookies env') (seHeaders env')))+ -- Use bidirectional streaming handler+ invokeStreamingWithBidir+ (Reconnect.callShape method)+ refreshAuth+ resumed+ namespacePath+ methodName'+ params+ printer+ (handleBidirRequest bidirMode Nothing)++ endedMessage item = case item of+ HubData _ _ _ (Object o) | Just (String m) <- KM.lookup "message" o -> m+ HubError _ _ e _ -> e+ _ -> "your session has ended"++ -- Extract default values from JSON Schema properties+ -- Schema format: {"properties": {"key": {"default": value, ...}, ...}, ...}+ extractSchemaDefaults :: MethodSchema -> Value+ extractSchemaDefaults m = case methodParams m of+ Nothing -> object []+ Just (Object o) -> case KM.lookup "properties" o of+ Just (Object props) -> object+ [ (k, defaultVal)+ | (k, propSchema) <- KM.toList props+ , Object propObj <- [propSchema]+ , Just defaultVal <- [KM.lookup "default" propObj]+ ]+ _ -> object []+ Just _ -> object []++ -- Merge two JSON objects: right takes precedence over left+ mergeParams :: Value -> Value -> Value+ mergeParams (Object defaults) (Object user) =+ Object (KM.union user defaults) -- union prefers first arg on conflict+ mergeParams _ user = user -- if defaults aren't an object, just use user params++ -- Check if method has required parameters+ hasRequiredParams :: MethodSchema -> Bool+ hasRequiredParams m = case methodParams m of+ Nothing -> False+ Just (Object o) -> case KM.lookup "required" o of+ Just (Array arr) -> not (null arr)+ _ -> False+ Just _ -> False++ -- Fetch schema for a path, detecting if last segment is a method+ -- Uses navigate to determine what type of schema to fetch+ fetchSchemaForPath :: [Text] -> SynapseM (Either Text Value)+ fetchSchemaForPath segs = do+ view <- navigate segs+ case view of+ ViewPlugin schema _ -> pure $ Right $ toJSON schema+ ViewMethod method path -> do+ -- Use method-specific schema query for detailed method info+ let parentPath = init path+ methodName' = last path+ detailedMethod <- fetchMethodSchema parentPath methodName'+ pure $ Right $ toJSON detailedMethod++ -- Invoke raw JSON-RPC request+ invokeRawRpc :: Value -> SynapseM [HubStreamItem]+ invokeRawRpc rpcReq = do+ case rpcReq of+ Object o -> case (KM.lookup "method" o, KM.lookup "params" o) of+ (Just (String method), Just params) -> invokeRaw method params+ (Just (String method), Nothing) -> invokeRaw method (object [])+ _ -> throwParse "JSON-RPC must have 'method' field"+ _ -> throwParse "JSON-RPC must be an object"++-- | Build JSON object from key-value pairs+buildParamsObject :: [(Text, Text)] -> Value+buildParamsObject pairs = object+ [ (K.fromText k, inferValue v) | (k, v) <- pairs ]+ where+ -- Try to infer the JSON type from the string value+ inferValue :: Text -> Value+ inferValue t+ | t == "true" = Bool True+ | t == "false" = Bool False+ | Just n <- readMaybe (T.unpack t) :: Maybe Integer = Number (fromInteger n)+ | Just n <- readMaybe (T.unpack t) :: Maybe Double = Number (realToFrac n)+ | otherwise = String t++readMaybe :: Read a => String -> Maybe a+readMaybe s = case reads s of+ [(x, "")] -> Just x+ _ -> Nothing++-- | SYNAPSE-12: the one line under a backend's help that points at+-- synapse's own (dim on a terminal).+backendHelpFooter :: IO ()+backendHelpFooter = TIO.putStrLn "" >> synapseHelpLine++-- | SYNAPSE-13: the pointer to synapse's own help, dimmed on a terminal —+-- under a listing's own "Run '…'" lines, with no blank between.+synapseHelpLine :: IO ()+synapseHelpLine = do+ tty <- hIsTerminalDevice stdout+ let line = "Run 'synapse --help' for synapse's own options."+ TIO.putStrLn (if tty then "\ESC[2m" <> line <> "\ESC[0m" else line)++-- | ASCII splash logo+splash :: Text+splash = T.unlines+ [ ""+ , "███████╗██╗ ██╗███╗ ██╗ █████╗ ██████╗ ███████╗███████╗"+ , "██╔════╝╚██╗ ██╔╝████╗ ██║██╔══██╗██╔══██╗██╔════╝██╔════╝"+ , "███████╗ ╚████╔╝ ██╔██╗ ██║███████║██████╔╝███████╗█████╗ "+ , "╚════██║ ╚██╔╝ ██║╚██╗██║██╔══██║██╔═══╝ ╚════██║██╔══╝ "+ , "███████║ ██║ ██║ ╚████║██║ ██║██║ ███████║███████╗"+ , "╚══════╝ ╚═╝ ╚═╝ ╚═══╝╚═╝ ╚═╝╚═╝ ╚══════╝╚══════╝"+ , ""+ ]++-- | Get CLI help text from optparse-applicative+cliHeader :: Text+cliHeader = splash <> T.pack (fst $ renderFailure failure "synapse") <> "\n"+ where+ failure = parserFailure defaultPrefs argsInfo (ShowHelpText Nothing) mempty++-- | The meta-commands, under synapse's own options: in @synapse --help@ and+-- on the page bare @synapse@ shows.+metaCommands :: [Text]+metaCommands =+ [ ""+ , "Values (after the backend and the method):"+ , " --flag @FILE the value is that file's contents; --flag - reads stdin"+ , " (once, however many values ask); @@text sends a literal"+ , " @text. -p @params.json and -p - take the whole object the"+ , " same way. No argument then carries the value, so a body"+ , " can be as large as the backend accepts"+ , ""+ , "Where the hub is (before the backend):"+ , " -P PORT / -H HOST plain ws://HOST:PORT/ (default 127.0.0.1:4444, the registry)"+ , " --url wss://HOST/ws a hub behind TLS (every hosted app), or ws://HOST:PORT/PATH;"+ , " the same as -H HOST --tls --path /ws (443 unless -P). The"+ , " certificate is checked against the system's trust store;"+ , " --cacert FILE trusts a local CA too. Sessions are kept per"+ , " URL, so _login and every call take the same options"+ , ""+ , "Meta-commands:"+ , ""+ , " synapse _key new|show|list [NAME]"+ , " Ed25519 keys an agent proves who it is with (keychain, else"+ , " ~/.plexus/keys/NAME.key; PLEXUS_KEY and $CREDENTIALS_DIRECTORY read first)"+ , ""+ , " synapse -P PORT _login BACKEND"+ , " synapse -P PORT _login BACKEND --key NAME --as AGENT [--scope ROLE@SCOPE] [--ttl SECS]"+ , " synapse -P PORT _logout BACKEND [--as AGENT]"+ , " A person signs in through the browser (a loopback on 127.0.0.1; kept as"+ , " 'me'); an agent logs in from a key. Later calls to that backend carry the"+ , " session by themselves (--as AGENT / SYNAPSE_AS picks one; else the last"+ , " logged in), and an agent's logs in again from the key when it ends"+ , ""+ , " synapse -P PORT _find BACKEND [ACTIVATION...] WORDS... [--limit N] [--methods|--params]"+ , " Search the backend's methods and parameters: names and descriptions,"+ , " typos and abbreviations too (setpar finds set_parent), best first, in"+ , " help's shape (METHODS, PARAMETERS, MENTIONED IN); -j one JSON object per hit"+ , ""+ , " synapse _self template"+ , " Manage Mustache templates (CRUD operations)"+ , ""+ , " Subcommands:"+ , " list [pattern] - List existing templates"+ , " show <method> - Display template content"+ , " generate [pattern] - Generate new templates from IR"+ , " delete <pattern> - Delete templates"+ , " reload - Clear template cache"+ , ""+ , " Examples:"+ , " synapse _self template list"+ , " synapse _self template show cone.chat"+ , " synapse _self template generate 'plexus.cone.*'"+ , ""+ ]++-- | Render an error for display+renderError :: SynapseError -> String+renderError = \case+ NavError (NotFound seg path maybeSchema) ->+ let baseMsg = "Command not found: '" <> T.unpack seg <> "' at " <> showPath path+ in case maybeSchema of+ Nothing -> baseMsg+ Just schema ->+ let methods = psMethods schema+ children = pluginChildren schema+ suggestions = if null methods && null children+ then ""+ else "\n\nAvailable commands:"+ <> renderMethods methods+ <> renderChildren children+ in baseMsg <> suggestions+ NavError (MethodNotTerminal seg path) ->+ "Method '" <> T.unpack seg <> "' cannot have subcommands at " <> showPath path+ NavError (Cycle hash path) ->+ "Cycle detected: hash " <> T.unpack hash <> " at " <> showPath path+ NavError (FetchError msg path) ->+ "Fetch error at " <> showPath path <> ": " <> T.unpack msg+ TransportError msg ->+ "Transport error: " <> T.unpack msg+ TransportErrorContext ctx ->+ renderTransportError ctx+ ParseError msg ->+ "Parse error: " <> T.unpack msg+ ValidationError msg ->+ "Validation error: " <> T.unpack msg+ BackendError errorType backends ->+ case errorType of+ BackendNotFound name ->+ "Backend not found: '" <> T.unpack name <> "'"+ <> renderBackendList backends+ BackendUnreachable name ->+ "Backend unreachable: '" <> T.unpack name <> "'"+ <> renderBackendList backends+ NoBackendsAvailable ->+ "No backends available"+ <> renderBackendList backends+ ProtocolHandshakeFailed host port ->+ "Protocol handshake failed at " <> T.unpack host <> ":" <> show port <> "\n"+ <> "The _info request did not complete successfully within 2 seconds.\n"+ <> "This typically means:\n"+ <> " - The server is not responding to Plexus RPC protocol messages\n"+ <> " - The server is not sending required StreamDone messages\n"+ <> " - The connection is timing out"+ where+ showPath [] = "root"+ showPath p = T.unpack $ T.intercalate "." p++ renderMethods [] = ""+ renderMethods methods =+ let header = "\n\n Methods:"+ methodLines = map renderMethod methods+ in header <> concat methodLines++ renderMethod method =+ let name = T.unpack $ methodName method+ desc = T.unpack $ methodDescription method+ padding = 20+ namePadded = name <> replicate (max 1 (padding - length name)) ' '+ in "\n " <> namePadded <> desc++ renderChildren [] = ""+ renderChildren children =+ let header = "\n\n Child plugins:"+ childLines = map renderChild children+ in header <> concat childLines++ renderChild child =+ let name = T.unpack $ csNamespace child+ desc = T.unpack $ csDescription child+ padding = 20+ namePadded = name <> replicate (max 1 (padding - length name)) ' '+ in "\n " <> namePadded <> desc++ renderBackendList [] = "\n\nNo backends available."+ renderBackendList backends =+ let header = "\n\nAvailable backends:"+ backendLines = map renderBackendLine backends+ usageHint = "\n\nUsage: synapse <backend> [command...]"+ in header <> concat backendLines <> usageHint++ renderBackendLine backend =+ let name = T.unpack $ backendName backend+ nameField = name <> replicate (max 1 (15 - length name)) ' '+ hostPort = T.unpack $ backendHost backend <> ":" <> T.pack (show (backendPort backend))+ status = case backendReachable backend of+ Just True -> " [OK]"+ Just False -> " [UNREACHABLE]"+ Nothing -> ""+ desc = if T.null (backendDescription backend)+ then ""+ else " - " <> T.unpack (backendDescription backend)+ in "\n " <> nameField <> hostPort <> status <> desc++-- | Render transport error with context+renderTransportError :: TransportContext -> String+renderTransportError TransportContext{..} = case tcCategory of+ ConnectionRefused ->+ "Connection refused\n\n" <>+ "Backend: " <> T.unpack tcBackend <> "\n" <>+ "Address: " <> T.unpack tcHost <> ":" <> show tcPort <> "\n" <>+ "Path: " <> showPath tcPath <> "\n\n" <>+ "Troubleshooting:\n" <>+ " - Check if the backend is running\n" <>+ " - Verify host (-H) and port (-P) settings\n" <>+ " - Run 'synapse' (no args) to list backends"++ ConnectionTimeout ->+ "Connection timeout\n\n" <>+ "Backend: " <> T.unpack tcBackend <> " @ " <>+ T.unpack tcHost <> ":" <> show tcPort <> "\n\n" <>+ "The server may be overloaded or network latency is high\n" <>+ "Error: " <> T.unpack tcMessage++ ProtocolError ->+ "Protocol error\n\n" <>+ "Backend: " <> T.unpack tcBackend <> "\n" <>+ "This may indicate a version mismatch\n\n" <>+ "Error: " <> T.unpack tcMessage++ UnknownTransportError ->+ "Transport error: " <> T.unpack tcMessage <> "\n\n" <>+ "Backend: " <> T.unpack tcBackend <> " @ " <>+ T.unpack tcHost <> ":" <> show tcPort+ where+ showPath [] = "root"+ showPath p = T.unpack $ T.intercalate "." p++-- | Render a parse error for display+renderParseError :: Parse.ParseError -> String+renderParseError = \case+ Parse.UnknownParam name suggestions ->+ "Unknown parameter: --" <> T.unpack name <>+ case suggestions of+ [] -> ""+ [s] -> "\n\nDid you mean: --" <> T.unpack s <> "?"+ ss -> "\n\nDid you mean one of:\n" <>+ unlines [" --" <> T.unpack s | s <- ss]+ Parse.MissingRequired name ->+ "Missing required parameter: --" <> T.unpack name+ Parse.InvalidValue name reason ->+ "Invalid value for --" <> T.unpack name <> ": " <> T.unpack reason+ Parse.AmbiguousVariant name variants ->+ "Ambiguous variant for --" <> T.unpack name <> ": could be one of " <> show (map T.unpack variants)+ Parse.MissingDiscriminator param field ->+ "Missing discriminator for --" <> T.unpack param <> ": need --" <> T.unpack param <> "." <> T.unpack field+ Parse.UnknownVariant param value valid suggestions ->+ "Unknown variant '" <> T.unpack value <> "' for --" <> T.unpack param <>+ "\n\nValid variants: " <> T.unpack (T.intercalate ", " valid) <>+ case suggestions of+ [] -> ""+ [s] -> "\n\nDid you mean: " <> T.unpack s <> "?"+ ss -> "\n\nDid you mean one of: " <> T.unpack (T.intercalate ", " ss) <> "?"+ Parse.TypeNotFound name ->+ "Type not found in IR: " <> T.unpack name+ Parse.MalformedListValue name value reason ->+ "Malformed list value for --" <> T.unpack name <> ": " <> T.unpack value <>+ " (" <> T.unpack reason <> ")"+ Parse.UnexpectedArgument arg ->+ "Unexpected argument: " <> T.unpack arg <>+ "\n\nA value follows its flag (--key VALUE or --key=VALUE); a boolean flag takes none unless it is true or false."+++-- | Write a generated template to disk (no logging)+writeGeneratedTemplateIR :: FilePath -> TemplateIR.GeneratedTemplate -> IO ()+writeGeneratedTemplateIR baseDir gt = do+ let fullPath = baseDir </> TemplateIR.gtPath gt+ createDirectoryIfMissing True (baseDir </> T.unpack (TemplateIR.gtNamespace gt))+ TIO.writeFile fullPath (TemplateIR.gtTemplate gt)++-- | Encode a dry-run request+encodeDryRun :: Text -> [Text] -> Text -> Value -> LBS.ByteString+encodeDryRun backend namespacePath method params =+ let fullPath = if null namespacePath then [backend] else namespacePath+ dotPath = T.intercalate "." (fullPath ++ [method])+ in encode $ object+ [ "jsonrpc" .= ("2.0" :: Text)+ , "id" .= (1 :: Int)+ , "method" .= (backend <> ".call")+ , "params" .= object+ [ "method" .= dotPath+ , "params" .= params+ ]+ ]++-- | Print a stream result+-- soJson: output raw JSON stream items+-- soRaw: skip template rendering, just output content JSON+-- otherwise: try template rendering, fall back to content JSON+printResult :: Bool -> Bool -> RendererConfig -> HubStreamItem -> IO ()+printResult json raw cfg item = case itemRefusal item of+ -- AUTH-8: a refusal says what it needed (and the call exits non-zero)+ Just r -> do+ Session.noteRefused+ if json then LBS.putStrLn (encode item) else do+ ceiling' <- Session.currentCeiling+ TIO.hPutStr stderr (renderRefusal ceiling' r)+ Nothing -> printResult' json raw cfg item++printResult' :: Bool -> Bool -> RendererConfig -> HubStreamItem -> IO ()+printResult' True _ _ item = LBS.putStrLn $ encode item+printResult' _ True _ item = case item of+ -- Raw mode: just output the content+ HubData _ _ _ dat -> do+ LBS.putStrLn $ encode dat+ hFlush stdout+ HubProgress _ _ msg _ -> do+ TIO.putStr msg+ TIO.putStr "\r"+ hFlush stdout+ HubError _ _ err _ ->+ hPutStrLn stderr $ "Error: " <> T.unpack err+ _ -> pure ()+printResult' _ _ cfg item = do+ -- Template mode: try to render with template+ mRendered <- renderItem cfg item+ case mRendered of+ Just text+ | T.null (T.strip text) -> pure ()+ | otherwise -> do+ TIO.putStr text+ hFlush stdout+ Nothing -> case item of+ -- Fallback to pretty-printed content+ HubData _ _ _ dat -> do+ TIO.putStrLn $ prettyValue dat+ hFlush stdout+ HubProgress _ _ msg _ -> do+ TIO.putStr msg+ TIO.putStr "\r"+ hFlush stdout+ HubError _ _ err _ ->+ hPutStrLn stderr $ "Error: " <> T.unpack err+ _ -> pure ()++-- ============================================================================+-- Argument Parsing (Synapse Options Only)+-- ============================================================================++-- | Parser for synapse-level options only+-- Backend and path are handled separately after arg splitting+argsInfo :: ParserInfo Args+argsInfo = info (argsParser <**> versionOption <**> helper)+ ( fullDesc+ <> header "synapse - Algebraic CLI for Hub"+ <> progDesc "synapse [OPTIONS] <backend> <path...> [--param value ...]"+ <> noIntersperse -- Stop option parsing at first positional arg+ <> footerDoc (Just (Pretty.vsep (map (Pretty.pretty . T.unpack) (drop 1 metaCommands))))+ )+ where+ versionOption = infoOption (showVersion Meta.version)+ ( long "version" <> short 'V' <> help "Show version" )++argsParser :: Parser Args+argsParser = Args <$> optsParser <*> backendParser <*> restParser <*> pure Nothing+ where+ backendParser = optional $ T.pack <$> argument str+ ( metavar "BACKEND"+ <> help "Backend name (e.g., plexus, registry-hub)" )++ restParser = many $ T.pack <$> argument str+ ( metavar "PATH... [--param value ...]"+ <> help "Path and method parameters (passed through)" )++optsParser :: Parser SynapseOpts+optsParser = do+ soHostFlag <- optional $ T.pack <$> strOption+ ( long "host" <> short 'H' <> metavar "HOST"+ <> help "Registry/discovery host (default: 127.0.0.1)" )+ soPortFlag <- optional $ option auto+ ( long "port" <> short 'P' <> metavar "PORT"+ <> help "Registry/discovery port (default: 4444; 443 with --tls)" )+ soUrl <- optional $ T.pack <$> strOption+ ( long "url" <> metavar "URL"+ <> help "The hub's socket as a URL: ws://HOST[:PORT][/PATH] or wss://HOST[:PORT][/PATH] (e.g. wss://app.menger.sh/ws); in place of -H/-P/--tls/--path" )+ soTls <- switch+ ( long "tls"+ <> help "Dial wss:// (TLS; the certificate checked against the system's trust store) at -H/-P" )+ soPath <- optional $ T.pack <$> strOption+ ( long "path" <> metavar "PATH"+ <> help "The socket's path at -H/-P (default: /), e.g. /ws" )+ soCaCerts <- many $ T.pack <$> strOption+ ( long "cacert" <> metavar "FILE"+ <> help "With wss: also trust the CA certificate(s) in this PEM file (repeatable; a local CA for a test)" )+ soJson <- switch+ ( long "json" <> short 'j'+ <> help "Output raw JSON stream items" )+ soRaw <- switch+ ( long "raw"+ <> help "Output raw content JSON (skip templates)" )+ soDryRun <- switch+ ( long "dry-run" <> short 'n'+ <> help "Show JSON-RPC request without sending" )+ soSchema <- switch+ ( long "schema" <> short 's'+ <> help "Fetch raw schema JSON for path" )+ soGenerate <- switch+ ( long "generate-templates" <> short 'g'+ <> help "Generate mustache templates from IR" )+ soEmitIR <- switch+ ( long "emit-ir" <> short 'i'+ <> help "Emit IR for code generation (JSON)" )+ soParams <- optional $ T.pack <$> strOption+ ( long "params" <> short 'p' <> metavar "JSON"+ <> help "Method parameters as a JSON object, sent as written; @FILE reads it from a file, - from stdin" )+ soRpc <- optional $ T.pack <$> strOption+ ( long "rpc" <> short 'r' <> metavar "JSON"+ <> help "Raw JSON-RPC request (bypass navigation)" )+ soGeneratorInfo <- many $ T.pack <$> strOption+ ( long "generator-info" <> metavar "TOOL:VERSION"+ <> help "Generator tool version info (can be specified multiple times)" )+ soBidirMode <- optional $ T.pack <$> strOption+ ( long "bidir-mode" <> short 'b' <> metavar "MODE"+ <> help "Bidirectional mode: interactive (TTY), json (pipe protocol), auto-cancel, defaults, respond" )+ soBidirCmd <- optional $ T.pack <$> strOption+ ( long "bidir-cmd" <> metavar "CMD"+ <> help "Shell command to handle bidir requests (stdin=JSON request, stdout=JSON response)" )+ soNoCache <- switch+ ( long "no-cache"+ <> help "Disable IR caching (force rebuild from schema)" )+ soLogLevel <- optional $ T.pack <$> strOption+ ( long "log-level" <> metavar "LEVEL"+ <> help "Log level: info, debug, trace (default: disabled)" )+ soLogSubsystems <- many $ T.pack <$> strOption+ ( long "log-subsystem" <> metavar "SUBSYSTEM"+ <> help "Filter logs by subsystem: discovery, transport, rpc, schema, cache, navigation (can specify multiple, default: all)" )+ soToken <- optional $ T.pack <$> strOption+ ( long "token" <> short 't' <> metavar "JWT"+ <> help "JWT sent as Cookie: access_token=<jwt> on WebSocket upgrade (overrides any access_token in ~/.plexus/<backend>/defaults.json)" )+ soTokenFile <- optional $ T.pack <$> strOption+ ( long "token-file" <> metavar "PATH"+ <> help "Path to file containing a raw JWT (overrides any access_token in ~/.plexus/<backend>/defaults.json)" )+ soCookies <- many (parseKv <$> strOption+ ( long "cookie" <> metavar "KEY=VALUE"+ <> help "Extra cookie attached to WS upgrade (repeatable; SAFE-S04/REQ-5)" ))+ soHeaders <- many (parseKv <$> strOption+ ( long "header" <> metavar "KEY=VALUE"+ <> help "Extra HTTP header attached to WS upgrade (repeatable; SAFE-S04/REQ-5)" ))+ soNoDeprecationWarnings <- switch+ ( long "no-deprecation-warnings"+ <> help "Suppress invocation-time deprecation warnings on stderr (IR-15)" )+ soAs <- optional $ T.pack <$> strOption+ ( long "as" <> metavar "AGENT"+ <> help "Carry this agent's stored session (see _login; else SYNAPSE_AS, else the backend's default agent)" )+ soReconnectFor <- optional $ T.pack <$> strOption+ ( long "reconnect-for" <> metavar "DURATION"+ <> help "Ride out an outage this long: a lost connection is retried, a read sent again, a stream resumed (10m, 90s, 1h; 0 = never; else SYNAPSE_RECONNECT_FOR, else 10m)" )+ pure SynapseOpts+ { soHost = maybe defaultHost id soHostFlag+ , soPort = maybe defaultPort id soPortFlag+ , soEndpoint = plainAt (maybe defaultHost id soHostFlag) (maybe defaultPort id soPortFlag)+ , ..+ }++-- | SCAFFOLD-21: the connection point from the options — @--url@, or @-H@+-- / @-P@ with @--tls@ (port 443 unless @-P@) and @--path@; plain+-- @ws://127.0.0.1:4444/@ when none is given. @soHost@ / @soPort@ follow it.+-- A @--cacert@ that cannot be read is refused here, not at the handshake.+resolveConnection :: SynapseOpts -> IO (Either Text SynapseOpts)+resolveConnection o = case pick of+ Left e -> pure (Left e)+ Right ep0 -> do+ let ep = ep0 { epCaFiles = map T.unpack (soCaCerts o) }+ missing <- filterM (fmap not . doesFileExist . T.unpack) (soCaCerts o)+ pure $ case () of+ _ | not (null (soCaCerts o)) && not (epTls ep) ->+ Left "--cacert is for a wss:// address (--url wss://… or --tls)"+ | (f : _) <- missing -> Left ("--cacert " <> f <> ": no such file")+ | otherwise -> Right o { soHost = epHost ep, soPort = epPort ep, soEndpoint = ep }+ where+ pick = case soUrl o of+ Just u+ | isJust (soHostFlag o) || isJust (soPortFlag o) || soTls o || isJust (soPath o) ->+ Left "--url names the whole address: give it alone, or -H / -P with --tls / --path"+ | otherwise -> parseUrl u+ Nothing -> Right Endpoint+ { epHost = maybe defaultHost id (soHostFlag o)+ , epPort = maybe (if soTls o then 443 else defaultPort) id (soPortFlag o)+ , epTls = soTls o+ , epPath = maybe "/" normalisePath (soPath o)+ , epCaFiles = []+ }++-- | Parse a KEY=VALUE pair (used by --cookie/--header). Treats the first '='+-- as the separator; everything before is the key, everything after is the value.+parseKv :: String -> (Text, Text)+parseKv s =+ let (k, rest) = break (== '=') s+ v = case rest of+ '=':vv -> vv+ _ -> ""+ in (T.pack k, T.pack v)
plexus-synapse.cabal view
@@ -1,6 +1,6 @@ cabal-version: 3.0 name: plexus-synapse-version: 3.13.0+version: 3.15.0 synopsis: Schema-driven CLI for Plexus RPC servers license: MIT author:@@ -12,10 +12,19 @@ default: False manual: True +flag build-keychain-tests+ description: Build and run SELF-8 keychain tests. These shell out to+ `security` on macOS and touch the real keychain; they're+ opt-in so default `cabal test` runs stay quiet.+ Enable with: cabal test -f build-keychain-tests+ default: False+ manual: True+ library exposed-modules: Synapse.Schema.Types Synapse.Schema.Functor+ Synapse.Endpoint Synapse.Monad Synapse.Log Synapse.Algebra.Navigate@@ -23,6 +32,7 @@ Synapse.Algebra.Walk Synapse.Algebra.Recursion Synapse.Transport+ Synapse.Reconnect Synapse.Cache Synapse.Deprecation Synapse.Renderer@@ -34,9 +44,21 @@ Synapse.CLI.Parse Synapse.CLI.Support Synapse.CLI.Template+ Synapse.CLI.Source Synapse.CLI.Transform Synapse.CLI.Similarity+ Synapse.Find Synapse.Backend.Discovery+ Synapse.Self+ Synapse.Self.Types+ Synapse.Self.Resolve+ Synapse.Self.IO+ Synapse.Self.Resolve.Literal+ Synapse.Self.Resolve.Env+ Synapse.Self.Resolve.File+ Synapse.Self.Resolve.Keychain+ Synapse.Self.Resolve.Default+ Synapse.Self.Command Synapse.Self.Commands Synapse.Self.Template Synapse.Self.Pattern@@ -46,13 +68,21 @@ Synapse.Self.Protocol.StreamTracker Synapse.Self.Protocol.Reporter Synapse.Self.Protocol.TestRunner+ Synapse.Identity.Proof+ Synapse.Identity.Store+ Synapse.Identity.Session+ Synapse.Identity.Command+ Synapse.Auth+ Synapse.Auth.Loopback build-depends: base >= 4.17 && < 5,- plexus-protocol,+ -- SCAFFOLD-21: substrateTls (wss)+ plexus-protocol >= 0.5.2, aeson >= 2.0 && < 2.3, aeson-pretty >= 0.8 && < 0.9, text >= 2.0 && < 2.2, bytestring >= 0.11 && < 0.13,+ case-insensitive >= 1.2 && < 1.3, websockets >= 0.13 && < 0.14, network >= 3.1 && < 3.3, async >= 2.2 && < 2.3,@@ -75,7 +105,13 @@ array >= 0.5 && < 0.6, process >= 1.6 && < 1.7, fast-logger >= 3.0 && < 3.3,- katip >= 0.8 && < 0.9+ katip >= 0.8 && < 0.9,+ base64-bytestring >= 1.2 && < 1.3,+ optparse-applicative >= 0.18 && < 0.19,+ unix >= 2.7 && < 2.9,+ -- IDENTITY-43: Ed25519 keys and their randomness+ crypton >= 1.0 && < 1.2,+ ram >= 0.20 && < 0.23 hs-source-dirs: src default-language: GHC2021 default-extensions:@@ -203,6 +239,62 @@ RecordWildCards ghc-options: -threaded +test-suite schema-decode-test+ type: exitcode-stdio-1.0+ main-is: SchemaDecodeSpec.hs+ build-depends:+ base >= 4.17 && < 5,+ plexus-protocol,+ aeson >= 2.2 && < 2.3,+ aeson-qq >= 0.8 && < 0.9,+ bytestring >= 0.11 && < 0.13,+ vector >= 0.13 && < 0.14+ hs-source-dirs: test+ default-language: GHC2021+ default-extensions:+ OverloadedStrings+ ghc-options: -threaded++test-suite req8-render-test+ type: exitcode-stdio-1.0+ main-is: Req8RenderSpec.hs+ build-depends:+ base >= 4.17 && < 5,+ plexus-protocol,+ plexus-synapse,+ aeson >= 2.2 && < 2.3,+ aeson-qq >= 0.8 && < 0.9,+ containers >= 0.6 && < 0.8,+ text >= 2.0 && < 2.2+ hs-source-dirs: test+ default-language: GHC2021+ default-extensions:+ OverloadedStrings+ ghc-options: -threaded++test-suite render-test+ -- SYNAPSE-12: a backend's help laid out (blank lines, columns, wrapping,+ -- required before optional, types as the schema says them).+ -- SYNAPSE-13: in the gh / cargo shape — listings of one-line summaries in+ -- their groups; a method's page with its usage line, aligned options,+ -- values and examples; an older hub's schema still rendered.+ type: exitcode-stdio-1.0+ main-is: RenderSpec.hs+ build-depends:+ base >= 4.17 && < 5,+ plexus-protocol,+ plexus-synapse,+ aeson >= 2.2 && < 2.3,+ aeson-qq >= 0.8 && < 0.9,+ containers >= 0.6 && < 0.8,+ text >= 2.0 && < 2.2,+ hspec >= 2.10 && < 2.12+ hs-source-dirs: test+ default-language: GHC2021+ default-extensions:+ OverloadedStrings+ ghc-options: -threaded+ test-suite typeref-json type: exitcode-stdio-1.0 main-is: TypeRefJson.hs@@ -236,11 +328,30 @@ OverloadedStrings ghc-options: -threaded +-- SYNAPSE-10: a parameter's value from a file or stdin+test-suite source-test+ type: exitcode-stdio-1.0+ main-is: SourceSpec.hs+ build-depends:+ base >= 4.17 && < 5,+ plexus-synapse,+ text >= 2.0 && < 2.2,+ bytestring >= 0.11 && < 0.13,+ directory >= 1.3 && < 1.4,+ filepath >= 1.4 && < 1.6,+ hspec >= 2.10 && < 2.12+ hs-source-dirs: test+ default-language: GHC2021+ default-extensions:+ OverloadedStrings+ ghc-options: -threaded+ test-suite path-normalization-test type: exitcode-stdio-1.0 main-is: PathNormalizationSpec.hs build-depends: base >= 4.17 && < 5,+ plexus-synapse, text >= 2.0 && < 2.2, hspec >= 2.10 && < 2.12 hs-source-dirs: test@@ -333,15 +444,217 @@ OverloadedStrings ghc-options: -threaded +test-suite self-test+ type: exitcode-stdio-1.0+ main-is: SelfSpec.hs+ other-modules:+ SelfCommandSpec+ SelfMigrationSpec+ SelfSafeWriteSpec+ build-depends:+ base >= 4.17 && < 5,+ plexus-synapse,+ text >= 2.0 && < 2.2,+ bytestring >= 0.11 && < 0.13,+ containers >= 0.6 && < 0.8,+ aeson >= 2.2 && < 2.3,+ directory >= 1.3 && < 1.4,+ filepath >= 1.4 && < 1.6,+ hspec >= 2.10 && < 2.12,+ time >= 1.12 && < 1.15,+ temporary >= 1.3 && < 1.4,+ base64-bytestring >= 1.2 && < 1.3,+ unix >= 2.7 && < 2.9+ hs-source-dirs: test+ default-language: GHC2021+ default-extensions:+ OverloadedStrings+ ghc-options: -threaded++test-suite identity-test+ -- IDENTITY-43: the canonical message, the fixed proof vector the Rust+ -- side cross-checks, and the key and session stores' order (env, a+ -- mocked keychain, files in a directory of the test's own).+ type: exitcode-stdio-1.0+ main-is: IdentitySpec.hs+ build-depends:+ base >= 4.17 && < 5,+ async >= 2.2 && < 2.3,+ plexus-synapse,+ text >= 2.0 && < 2.2,+ bytestring >= 0.11 && < 0.13,+ base64-bytestring >= 1.2 && < 1.3,+ aeson >= 2.2 && < 2.3,+ containers >= 0.6 && < 0.8,+ directory >= 1.3 && < 1.4,+ filepath >= 1.4 && < 1.6,+ hspec >= 2.10 && < 2.12,+ temporary >= 1.3 && < 1.4,+ unix >= 2.7 && < 2.9+ hs-source-dirs: test+ default-language: GHC2021+ default-extensions:+ OverloadedStrings+ ghc-options: -threaded+ test-suite ir12-method-role-test type: exitcode-stdio-1.0 main-is: IR12MethodRoleSpec.hs build-depends: base >= 4.17 && < 5,+ containers >= 0.6 && < 0.8, plexus-protocol, plexus-synapse, aeson >= 2.2 && < 2.3, bytestring >= 0.11 && < 0.13,+ hspec >= 2.10 && < 2.12+ hs-source-dirs: test+ default-language: GHC2021+ default-extensions:+ OverloadedStrings+ ghc-options: -threaded++test-suite keychain-test+ -- SELF-8 keychain tests. Opt-in via `-f build-keychain-tests` because+ -- they shell out to macOS `security` and touch the real keychain.+ type: exitcode-stdio-1.0+ main-is: KeychainSpec.hs+ build-depends:+ base >= 4.17 && < 5,+ plexus-synapse,+ text >= 2.0 && < 2.2,+ hspec >= 2.10 && < 2.12,+ uuid >= 1.3 && < 1.4+ hs-source-dirs: test+ default-language: GHC2021+ default-extensions:+ OverloadedStrings+ ghc-options: -threaded+ if !flag(build-keychain-tests)+ buildable: False++test-suite auth-test+ -- AUTH-8: the auth shapes plexus-protocol parses (and their absence),+ -- the anonymous page, the caller's header and grant columns, the refusal+ -- block, the loopback's state check, and the flows end to end against a+ -- hub in miniature (test/MockHub.hs) on ports of their own.+ type: exitcode-stdio-1.0+ main-is: AuthSpec.hs+ other-modules: MockHub+ build-depends:+ base >= 4.17 && < 5,+ plexus-protocol,+ plexus-synapse,+ aeson >= 2.2 && < 2.3,+ aeson-qq >= 0.8 && < 0.9,+ bytestring >= 0.11 && < 0.13,+ containers >= 0.6 && < 0.8,+ text >= 2.0 && < 2.2,+ katip >= 0.8 && < 0.9,+ network >= 3.1 && < 3.3,+ websockets >= 0.13 && < 0.14,+ vector >= 0.12 && < 0.14,+ hspec >= 2.10 && < 2.12+ hs-source-dirs: test+ default-language: GHC2021+ default-extensions:+ OverloadedStrings+ ghc-options: -threaded++test-suite find-test+ -- SYNAPSE-17: _find's matcher (exact, prefix, typo at both thresholds,+ -- snake_case subsequence, every word, ranking) and its renders against+ -- the mock hub's catalog (menger's schema as it was, test/fixtures), on a+ -- terminal and piped; a caller's view; the IR cache's hash check.+ type: exitcode-stdio-1.0+ main-is: FindSpec.hs+ other-modules: MockHub+ build-depends:+ base >= 4.17 && < 5,+ plexus-synapse,+ aeson >= 2.2 && < 2.3,+ bytestring >= 0.11 && < 0.13,+ containers >= 0.6 && < 0.8,+ directory >= 1.3 && < 1.4,+ filepath >= 1.4 && < 1.6,+ text >= 2.0 && < 2.2,+ katip >= 0.8 && < 0.9,+ network >= 3.1 && < 3.3,+ websockets >= 0.13 && < 0.14,+ vector >= 0.12 && < 0.14,+ hspec >= 2.10 && < 2.12+ hs-source-dirs: test+ default-language: GHC2021+ default-extensions:+ OverloadedStrings+ ghc-options: -threaded++test-suite reconnect-test+ -- SYNAPSE-20: a lost connection is noticed (a close, a drop before any+ -- answer, a silent peer by its keepalive) and synapse rides out the+ -- outage — a read sent again, a stream resumed with since, a write that+ -- went out never repeated, one that never left sent when the backend is+ -- back, the window run out — against a flaky hub on ports of its own.+ type: exitcode-stdio-1.0+ main-is: ReconnectSpec.hs+ build-depends:+ base >= 4.17 && < 5,+ plexus-protocol,+ plexus-synapse,+ aeson >= 2.2 && < 2.3,+ text >= 2.0 && < 2.2,+ network >= 3.1 && < 3.3,+ websockets >= 0.13 && < 0.14,+ hspec >= 2.10 && < 2.12+ hs-source-dirs: test+ default-language: GHC2021+ default-extensions:+ OverloadedStrings+ ghc-options: -threaded++test-suite endpoint-test+ -- SCAFFOLD-21: --url and -H/-P with --tls/--path read into one address,+ -- and every key derived from it — the session store's (a plain address+ -- keyed exactly as before), the proof's audience, the options messages+ -- tell the reader to type, the plexus-protocol config.+ type: exitcode-stdio-1.0+ main-is: EndpointSpec.hs+ build-depends:+ base >= 4.17 && < 5,+ plexus-protocol,+ plexus-synapse,+ text >= 2.0 && < 2.2,+ hspec >= 2.10 && < 2.12+ hs-source-dirs: test+ default-language: GHC2021+ default-extensions:+ OverloadedStrings++test-suite tls-test+ -- SCAFFOLD-21: synapse speaks wss — the probe, a call and a key login+ -- through a TLS terminator of the test's own (a CA openssl makes for the+ -- run, in front of the mock hub), at a path; an unknown CA and a wrong+ -- name refused at the handshake. Pending without openssl.+ type: exitcode-stdio-1.0+ main-is: TlsSpec.hs+ other-modules: MockHub+ build-depends:+ base >= 4.17 && < 5,+ plexus-protocol,+ plexus-synapse,+ aeson >= 2.2 && < 2.3,+ base64-bytestring >= 1.2 && < 1.3,+ bytestring >= 0.11 && < 0.13,+ directory >= 1.3 && < 1.4,+ filepath >= 1.4 && < 1.6,+ katip >= 0.8 && < 0.9,+ network >= 3.1 && < 3.3,+ process >= 1.6 && < 1.7,+ temporary >= 1.3 && < 1.4,+ text >= 2.0 && < 2.2,+ tls >= 2.1 && < 2.5,+ vector >= 0.12 && < 0.14,+ websockets >= 0.13 && < 0.14, hspec >= 2.10 && < 2.12 hs-source-dirs: test default-language: GHC2021
src/Synapse/Algebra/Render.hs view
@@ -3,6 +3,12 @@ ( -- * Rendering Functions renderSchema , renderSchemaWith+ , renderSchemaFor+ , renderListing+ , Listing(..)+ , summarize+ , fitText+ , quoteMask , renderMethod , renderMethodFull , renderChild@@ -21,11 +27,14 @@ import Data.Text (Text) import qualified Data.Text as T import Data.Aeson (Value(..))+import qualified Data.Aeson as Aeson import qualified Data.Aeson.Key as K import qualified Data.Aeson.KeyMap as KM-import Data.List (intersperse, sortOn)-import Prettyprinter-import Prettyprinter.Render.Text (renderStrict)+import qualified Data.Vector as V+import Control.Applicative ((<|>))+import Data.Char (isAlphaNum, toLower)+import Data.List (sortOn)+import Data.Maybe (fromMaybe, isJust, isNothing) import Synapse.Schema.Types import Plexus.Schema.Recursive (DeprecationInfo(..), ParamSchema(..))@@ -36,6 +45,7 @@ , rsShowHash :: !Bool -- ^ Show plugin/method hashes , rsShowTypes :: !Bool -- ^ Show parameter types , rsCompact :: !Bool -- ^ Compact single-line format+ , rsWidth :: !Int -- ^ Columns to wrap descriptions at (SYNAPSE-12) } deriving stock (Show, Eq) @@ -46,6 +56,7 @@ , rsShowHash = False , rsShowTypes = True , rsCompact = False+ , rsWidth = 80 } -- | Compact style: single-line descriptions@@ -58,102 +69,247 @@ -- | Render with custom style renderSchemaWith :: RenderStyle -> PluginSchema -> Text-renderSchemaWith _style PluginSchema{..}- | null psMethods && maybe True null psChildren = deprecationBanner <> headerText- | otherwise = renderStrict $ layoutPretty layoutOpts doc+renderSchemaWith style = renderSchemaFor style Nothing++-- | AUTH-8: render for a caller — the header line saying who it is+-- (@you are … · editor on MENGER@) under the description, when given.+-- SYNAPSE-13: the listing as 'renderListing' lays it out, reached by+-- @synapse <namespace>@.+renderSchemaFor :: RenderStyle -> Maybe Text -> PluginSchema -> Text+renderSchemaFor style callerLine ps =+ renderListing style (Listing ("synapse " <> psNamespace ps) callerLine) ps++-- | SYNAPSE-13: what a listing needs beside the schema.+data Listing = Listing+ { lsInvoke :: Text -- ^ the command line that reaches this plugin (@synapse -P 4455 menger tickets@)+ , lsCaller :: Maybe Text -- ^ AUTH-8: who the caller is, in a caller's view+ }+ deriving stock (Show, Eq)++-- | SYNAPSE-13: a plugin's listing in gh's shape — the name, version and+-- description; a usage line; then one line per activation and per method+-- (its name and summary, the first sentence of its description), the+-- methods in their groups (the schema's @group@, in the order they first+-- appear; alphabetical within each; one METHODS section when there are+-- none), and how to see a method's options. In a caller's view (AUTH-8)+-- who the caller is sits under the description, and each method's line+-- ends with its grant and where it is held, in two aligned columns.+renderListing :: RenderStyle -> Listing -> PluginSchema -> Text+renderListing style Listing{..} PluginSchema{..} = T.unlines (dropTrailingBlanks body) where- layoutOpts = LayoutOptions (AvailablePerLine 80 1.0)+ width = rsWidth style+ authCols = isJust lsCaller || any (isJust . methodHeldOn) psMethods - -- Activation-level deprecation banner (plain-text, rendered ahead- -- of the compact header variant used for trivial schemas).- deprecationBanner = case psDeprecation of- Just di -> deprecationMarker <> " " <> formatDeprecationLine di <> "\n"- Nothing -> ""+ body = concat+ [ [heading]+ , deprecationNotice+ , map (" " <>) (wrap (width - 2) psDescription)+ , maybe [] (\l -> [" " <> l]) lsCaller+ , section "USAGE" (map (" " <>) usage)+ , section "ACTIVATIONS" (map childRow children)+ , concat [ section title (concatMap methodRows ms) | (title, ms) <- methodSections ]+ , if null footer then [] else "" : footer+ ] - headerText = psNamespace <> " v" <> psVersion <> "\n" <> psDescription <> "\n"+ heading = case psDeprecation of+ Just _ -> deprecationMarker <> " " <> psNamespace <> " v" <> psVersion+ Nothing -> psNamespace <> " v" <> psVersion - -- Name line; decorated with the warning marker when the activation- -- carries Just DeprecationInfo.- namespaceHeading = case psDeprecation of- Just _ -> pretty (deprecationMarker <> " " <> psNamespace)- <+> pretty ("v" <> psVersion)- Nothing -> pretty psNamespace <+> pretty ("v" <> psVersion)+ deprecationNotice = case psDeprecation of+ Just di -> [" " <> formatDeprecationLine di]+ Nothing -> [] - -- Full deprecation notice rendered immediately below the heading- -- when relevant, before the description block.- deprecationNoticeDoc = case psDeprecation of- Just di -> vsep- [ emptyDoc- , indent 2 $ pretty (formatDeprecationLine di)- ]- Nothing -> emptyDoc+ section _ [] = []+ section title rows = "" : title : rows - doc :: Doc ann- doc = vsep- [ namespaceHeading- , deprecationNoticeDoc- , emptyDoc- , indent 2 $ align $ fillSep $ map pretty $ T.words psDescription- , emptyDoc- , childrenDoc- , methodsDoc+ children = maybe [] (sortOn csNamespace) psChildren++ usage = concat+ [ [lsInvoke <> " <activation> <method> [flags]" | not (null children)]+ , [lsInvoke <> " <method> [flags]" | not (null psMethods)] ] - childrenDoc = case psChildren of- Nothing -> emptyDoc- Just [] -> emptyDoc- Just children -> vsep- [ pretty ("activations" :: Text)- , emptyDoc- , indent 2 $ vsep $ map renderChildDoc (sortOn csNamespace children)- , emptyDoc- ]+ footer = concat+ [ ["Run '" <> lsInvoke <> " <activation>' for its methods." | not (null children)]+ , ["Run '" <> lsInvoke <> " <method> --help' for its options." | not (null psMethods)]+ ] - methodsDoc- | null psMethods = emptyDoc- | otherwise = vsep- [ pretty ("methods" :: Text)- , emptyDoc- , indent 2 $ vsep $ intersperse emptyDoc $ map renderMethodDoc (sortOn methodName psMethods)- ]+ -- The groups in the order they first appear, each alphabetical; the+ -- ungrouped last (as OTHER), or all of them as METHODS when no method+ -- says a group.+ methodSections =+ let groups = dedupe [g | Just g <- map methodGroup psMethods]+ inGroup g = sortOn methodName [m | m <- psMethods, methodGroup m == Just g]+ ungrouped = sortOn methodName [m | m <- psMethods, isNothing (methodGroup m)]+ in if null groups+ then [("METHODS", ungrouped) | not (null ungrouped)]+ else [(T.toUpper g, inGroup g) | g <- groups]+ ++ [("OTHER", ungrouped) | not (null ungrouped)]+ dedupe = foldl (\acc x -> if x `elem` acc then acc else acc ++ [x]) [] - renderChildDoc child = fillBreak 12 (pretty $ csNamespace child)- <+> align (fillSep $ map pretty $ T.words $ csDescription child)+ decoratedName m = case methodDeprecation m of+ Just _ -> deprecationMarker <> " " <> methodName m+ Nothing -> methodName m - renderMethodDoc method =- let nameText = case methodDeprecation method of- Just _ -> deprecationMarker <> " " <> methodName method- Nothing -> methodName method- nameLine = fillBreak 12 (pretty nameText)- <+> align (fillSep $ map pretty $ T.words $ methodDescription method)- depLines = case methodDeprecation method of- Just di -> [indent 12 $ pretty (formatDeprecationLine di)]- Nothing -> []- in vsep $ [nameLine] ++ depLines ++ paramsDocs (methodParams method)+ -- The name column: wide enough for the longest name (capped), shared by+ -- every section so the summaries start in one column.+ nameW = min 24 $ maximum $ 8 : map T.length (map csNamespace children ++ map decoratedName psMethods)+ descCol = 2 + nameW + 2 - paramsDocs Nothing = []- paramsDocs (Just (Object o)) = case KM.lookup "properties" o of- Just (Object props) ->- let reqList = case KM.lookup "required" o of- Just (Array arr) -> [t | String t <- foldr (:) [] arr]- _ -> []- propList = KM.toList props- sorted = sortOn (\(k, _) -> (K.toText k `notElem` reqList, K.toText k)) propList- in [indent 12 $ vsep $ map (renderParamDoc reqList) sorted]- _ -> []- paramsDocs _ = []+ -- AUTH-8: the grant and where it is held, after the summary.+ grantOf m = fromMaybe "" (methodGrant m)+ heldOf m = maybe "" (T.intercalate ", ") (methodHeldOn m)+ grantW = maximum (0 : map (T.length . grantOf) psMethods)+ heldW = maximum (0 : map (T.length . heldOf) psMethods)+ summaryW+ | authCols = max 20 (width - descCol - 2 - grantW - 2 - heldW)+ | otherwise = width - descCol - renderParamDoc :: [Text] -> (K.Key, Value) -> Doc ann- renderParamDoc required (name, propSchema) =- let nameText = K.toText name- isReq = nameText `elem` required- (typ, desc) = extractTypeDesc propSchema- flag = "--" <> T.replace "_" "-" nameText- typStr = " <" <> typ <> ">" <> if isReq then "" else "?"- descWords = if T.null desc then [] else map pretty (T.words desc)- in fillBreak 20 (pretty flag <> pretty typStr)- <+> align (fillSep descWords)+ row name summary =+ T.stripEnd (" " <> T.justifyLeft nameW ' ' name <> " " <> summary) + childRow c = row (csNamespace c) (fitText (width - descCol) (summarize (csDescription c)))++ methodRows m =+ let s = fitText summaryW (summarize (methodDescription m))+ line+ | authCols = row (decoratedName m)+ (T.justifyLeft summaryW ' ' s <> " " <> T.justifyLeft grantW ' ' (grantOf m) <> " " <> heldOf m)+ | otherwise = row (decoratedName m) s+ in line : maybe [] (\di -> [T.replicate descCol " " <> formatDeprecationLine di]) (methodDeprecation m)++-- | SYNAPSE-13: a description's summary — its first sentence: up to the+-- first @. @, @; @ or @: @ outside brackets and quotes (an @e.g.@ or+-- @i.e.@ is not an end), without the stop.+summarize :: Text -> Text+summarize desc = T.strip (go (0 :: Int) "" (zip str (quoteMask str)))+ where+ str = T.unpack (T.strip desc)+ go _ acc [] = T.pack (reverse acc)+ go d acc ((c, q) : rest)+ | q = go d (c : acc) rest+ | c `elem` ("([{" :: String) = go (d + 1) (c : acc) rest+ | c `elem` (")]}" :: String) = go (max 0 (d - 1)) (c : acc) rest+ | d == 0, c `elem` (".;:" :: String), startsSpace rest, not (abbrev c acc) = T.pack (reverse acc)+ | otherwise = go d (c : acc) rest+ startsSpace ((' ', _) : _) = True+ startsSpace _ = False+ abbrev '.' acc = map toLower (reverse (takeWhile (/= ' ') acc)) `elem` ["e.g", "i.e", "etc", "vs", "cf"]+ abbrev _ _ = False++-- | Which characters are inside a quoted span — @'…'@ or @`…`@, the quotes+-- included. A @'@ opens a quote after a non-letter and before a non-space+-- (so @ticket's@ is an apostrophe), and closes before a non-letter; a quote+-- never closed is no quote.+quoteMask :: String -> [Bool]+quoteMask str = case openAt of+ Just j -> take j marks ++ map (const False) (drop j marks)+ Nothing -> marks+ where+ (marks, openAt) = go Nothing Nothing (0 :: Int) str+ alnum = maybe False isAlphaNum+ go open _ _ [] = ([], fmap snd open)+ go open prev i (c : rest) =+ let next = case rest of { (x : _) -> Just x; [] -> Nothing }+ (m, open') = case open of+ Nothing+ | c == '`' -> (True, Just ('`', i))+ | c == '\'', not (alnum prev), maybe False (/= ' ') next -> (True, Just ('\'', i))+ | otherwise -> (False, Nothing)+ Just (q, _)+ | c == q, q == '`' || not (alnum next) -> (True, Nothing)+ | otherwise -> (True, open)+ (ms, o) = go open' (Just c) (i + 1) rest+ in (m : ms, o)++-- | SYNAPSE-13: a line's text in at most @w@ columns: whole when it fits;+-- else without its parentheticals; else up to the last @ — @ that fits;+-- else cut after a comma that keeps most of it, or at a word, ending in @…@.+-- SYNAPSE-17: a quoted span (@'…'@, @`…`@) is never cut or changed inside:+-- its brackets stay, and a cut ends before it (or keeps it whole when it+-- leads the line).+fitText :: Int -> Text -> Text+fitText w t+ | T.length t <= w = t+ | T.length bare <= w = bare+ | Just c <- lastFitting 0 " \x2014 " = c+ | Just c <- lastFitting 1 ", ", T.length c * 10 >= w * 6 = c <> "\x2026"+ | otherwise = wordCut atoms+ where+ chars = T.unpack t+ (bareCs, bareMask) = unzip (tidy (dropParens (zip chars (quoteMask chars))))+ bare = T.pack bareCs+ -- the longest prefix ending before @sep@ (outside a quote) that fits+ -- (with @room@ for an …)+ lastFitting room sep =+ case [ p | (p, _) <- T.breakOnAll sep bare, T.length p + room <= w+ , not (T.null (T.strip p)), not (quotedAt (T.length p)) ] of+ [] -> Nothing+ ps -> Just (T.stripEnd (last ps))+ quotedAt i = i < length bareMask && bareMask !! i+ -- words, a quoted span with its spaces one of them+ atoms = map (T.pack . map fst) (splitAtoms (zip bareCs bareMask))+ splitAtoms cs = case dropWhile (\(c, q) -> c == ' ' && not q) cs of+ [] -> []+ cs' -> let (a, rest) = break (\(c, q) -> c == ' ' && not q) cs' in a : splitAtoms rest+ quotedAtom x = T.any (`elem` ("'`" :: String)) x+ wordCut ws = go "" ws+ where+ go acc [] = acc+ go acc (x : xs)+ | T.length (join acc x) + 1 <= w = go (join acc x) xs+ | T.null acc = if quotedAtom x then x else T.take (max 1 (w - 1)) x <> "\x2026"+ | otherwise = T.dropWhileEnd (`elem` (",;:" :: String)) acc <> "\x2026"+ join acc x = if T.null acc then x else acc <> " " <> x+ -- whitespace runs outside quotes made one space, none left before a+ -- comma or a stop, none at the ends+ tidy = trimEnds . noSpaceBefore . collapse+ where+ collapse [] = []+ collapse ((c, False) : rest)+ | isSpaceC c = (' ', False) : collapse (dropWhile (\(x, q) -> isSpaceC x && not q) rest)+ collapse (x : rest) = x : collapse rest+ noSpaceBefore ((' ', False) : x@(c, False) : rest)+ | c `elem` (",.;:" :: String) = noSpaceBefore (x : rest)+ noSpaceBefore (x : rest) = x : noSpaceBefore rest+ noSpaceBefore [] = []+ trimEnds = reverse . dropWhile blank . reverse . dropWhile blank+ blank (c, q) = isSpaceC c && not q+ isSpaceC x = x == ' ' || x == '\n' || x == '\t'+ -- parentheticals outside quotes dropped (a quote inside one goes with it)+ dropParens = strip (0 :: Int)+ where+ strip _ [] = []+ strip d (x@(c, q) : rest)+ | not q, c == '(' = strip (d + 1) rest+ | not q, c == ')', d > 0 = strip (d - 1) rest+ | d > 0 = strip d rest+ | otherwise = x : strip d rest++-- | Words wrapped greedily to a width (at least 20); a word longer than the+-- width stands on its own line.+wrap :: Int -> Text -> [Text]+wrap w0 txt = go (T.words txt)+ where+ w = max 20 w0+ go [] = []+ go (x : xs) = line x xs+ line acc [] = [acc]+ line acc (y : ys)+ | T.length acc + 1 + T.length y <= w = line (acc <> " " <> y) ys+ | otherwise = acc : line y ys++-- | A lead and lines that start in column @col@: the first beside the lead+-- when it fits (two spaces between), else all of them under it.+hang :: Int -> Text -> [Text] -> [Text]+hang col lead ds = case ds of+ (d : rest) | T.length lead <= col - 2 -> (T.justifyLeft col ' ' lead <> d) : map (pad <>) rest+ _ -> lead : map (pad <>) ds+ where pad = T.replicate col " "++dropTrailingBlanks :: [Text] -> [Text]+dropTrailingBlanks = reverse . dropWhile (T.null . T.strip) . reverse+ -- | Render a method (short form) renderMethod :: MethodSchema -> Text renderMethod = renderMethodWith defaultStyle@@ -258,13 +414,39 @@ in " " <> decoratedName <> " <" <> typ <> ">" <> reqText <> "\n " <> desc <> depLine --- | Extract type and description from property schema+-- | Extract type and description from property schema.+--+-- SYNAPSE-12: the type a flag takes, as the schema says it — a nullable+-- type is its non-null part (@["string", "null"]@ → @string@), an array+-- its element's (@string[]@), a @$ref@ the type it names, a union its+-- alternatives (@a|b@); @any@ only when the schema says nothing. extractTypeDesc :: Value -> (Text, Text)-extractTypeDesc (Object po) =- ( case KM.lookup "type" po of { Just (String t) -> t; _ -> "any" }+extractTypeDesc v@(Object po) =+ ( schemaTypeName v , case KM.lookup "description" po of { Just (String d) -> d; _ -> "" } ) extractTypeDesc _ = ("any", "")++schemaTypeName :: Value -> Text+schemaTypeName (Object po)+ | Just (String ref) <- KM.lookup "$ref" po = last (T.splitOn "/" ref)+ | Just (String "array") <- KM.lookup "type" po = itemsName <> "[]"+ | Just (String t) <- KM.lookup "type" po = t+ | Just (Array ts) <- KM.lookup "type" po =+ case [t | String t <- V.toList ts, t /= "null"] of+ ["array"] -> itemsName <> "[]"+ [] -> "any"+ ts' -> T.intercalate "|" ts'+ | Just (Array alts) <- KM.lookup "anyOf" po <|> KM.lookup "oneOf" po =+ case filter (/= "null") (map schemaTypeName (V.toList alts)) of+ [] -> "any"+ ns -> T.intercalate "|" (dedupe ns)+ | Just (Array _) <- KM.lookup "enum" po = "string"+ | otherwise = "any"+ where+ itemsName = maybe "any" schemaTypeName (KM.lookup "items" po)+ dedupe = foldr (\x acc -> if x `elem` acc then acc else x : acc) []+schemaTypeName _ = "any" -- | Pad text to a minimum width padRight :: Int -> Text -> Text
+ src/Synapse/Auth.hs view
@@ -0,0 +1,330 @@+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE LambdaCase #-}++-- | AUTH-8: what synapse shows of a hub's auth — pure, so every render is+-- tested from fixtures.+--+-- * The anonymous page: a hub whose @_info@ says @view: anonymous@ shows+-- only its door (login, signup, providers) and how to sign in from here.+-- * The caller's header: @you are NAME (PRINCIPAL) · ROLE on SCOPES, within+-- CEILING@, from @auth.whoami@ (or, on a hub without it, @session.whoami@).+-- * A refusal: @refused: needs GRANT on SCOPE@ and what would allow it.+--+-- Every wire field read here is optional (AUTH-1, "Login off stays open"):+-- a hub without a gate answers none of it, and nothing here is shown.+module Synapse.Auth+ ( -- * Who the caller is+ Whoami(..)+ , parseWhoami+ , renderCallerHeader+ , abbreviatePrincipal+ , scopeOf++ -- * The anonymous page+ , AnonPage(..)+ , anonymousMethods+ , renderAnonymousPage++ -- * Refusals+ , renderRefusal++ -- * The session auth.login answers+ , SessionGrant(..)+ , parseSessionGrant+ , isMissingAuth+ ) where++import Control.Applicative ((<|>))+import Data.Aeson (Value(..))+import qualified Data.Aeson.Key as K+import qualified Data.Aeson.KeyMap as KM+import Data.List (nub, sortOn)+import Data.Maybe (fromMaybe, mapMaybe)+import Data.Scientific (toBoundedInteger)+import Data.Text (Text)+import qualified Data.Text as T+import qualified Data.Vector as V++import Plexus.Auth (AuthInfo(..), Refusal(..))+import Plexus.Schema.Recursive (MethodSchema(..), PluginSchema(..))++-- ============================================================================+-- Who the caller is+-- ============================================================================++-- | What @auth.whoami@ (or @session.whoami@'s @identity@) says.+data Whoami = Whoami+ { whoPrincipal :: Text+ , whoName :: Maybe Text+ , whoAgent :: Maybe Text+ , whoMemberships :: [(Text, Text)] -- ^ (role, scope); scope "" when the hub names none+ , whoCeiling :: [Text] -- ^ as the hub says it: @editor\@MENGER-12@ or a scope+ } deriving (Eq, Show)++-- | A whoami from a data item's content. Shapes read (all fields but the+-- principal optional):+--+-- @+-- {"type":"whoami","principal":"key:ac79…","name":"worker-1","agent":"worker-1",+-- "memberships":[{"role":"editor","scope":"MENGER"}],"ceiling":["editor\@MENGER-12"]}+-- {"type":"identity","principal":"idp:…","name":"ada","roles":["admin"]} -- session.whoami+-- @+--+-- @memberships@ (or @roles@) may also be strings, @role\@SCOPE@ or a bare+-- role; @ceiling@ strings or @{role|grant, scope}@ objects. An anonymous+-- answer (@type: anonymous@, or no principal) is Nothing.+parseWhoami :: Value -> Maybe Whoami+parseWhoami = \case+ Object o+ | Just (String "anonymous") <- KM.lookup "type" o -> Nothing+ | Just (String p) <- KM.lookup "principal" o, not (T.null p) -> Just Whoami+ { whoPrincipal = p+ , whoName = nonEmpty (str "name" o)+ , whoAgent = nonEmpty (str "agent" o)+ , whoMemberships = memberships o+ , whoCeiling = ceilingOf o+ }+ -- a whoami nested one level (e.g. {"type":"whoami","identity":{…}})+ | Just inner@(Object _) <- KM.lookup "identity" o -> parseWhoami inner+ _ -> Nothing+ where+ memberships o = case KM.lookup "memberships" o of+ Just v -> roleScopes v+ Nothing -> maybe [] roleScopes (KM.lookup "roles" o)+ roleScopes = \case+ Array xs -> mapMaybe one (V.toList xs)+ _ -> []+ one = \case+ String s -> Just (splitRoleScope s)+ Object e -> case (str "role" e, str "scope" e) of+ ("", _) -> Nothing+ (r, s) -> Just (r, s)+ _ -> Nothing++ceilingOf :: KM.KeyMap Value -> [Text]+ceilingOf o = case KM.lookup "ceiling" o <|> KM.lookup "scope" o of+ Just (Array xs) -> mapMaybe one (V.toList xs)+ Just (String s) | not (T.null s) -> [s]+ _ -> []+ where+ one = \case+ String s -> Just s+ Object e ->+ let what = case (str "role" e, str "grant" e) of+ (r, _) | not (T.null r) -> r+ (_, g) -> g+ sc = str "scope" e+ in if T.null what && T.null sc then Nothing+ else Just (if T.null what then sc else if T.null sc then what else what <> "@" <> sc)+ _ -> Nothing++-- | @editor\@MENGER@ → (editor, MENGER); @admin@ → (admin, "").+splitRoleScope :: Text -> (Text, Text)+splitRoleScope s = case T.breakOn "@" s of+ (r, rest) | not (T.null rest) -> (r, T.drop 1 rest)+ _ -> (s, "")++-- | The scope of a ceiling entry: @editor\@MENGER-12@ → @MENGER-12@; an+-- entry with no @\@@ is its own scope.+scopeOf :: Text -> Text+scopeOf s = case T.breakOn "@" s of+ (_, rest) | not (T.null rest) -> T.drop 1 rest+ _ -> s++-- | @you are worker-1 (key:ac79…) · editor on MENGER, within MENGER-12@.+--+-- Several roles: each @ROLE on SCOPES@, joined with @; @. A membership the+-- hub named no scope for is the role alone. No memberships: the name and+-- principal alone.+renderCallerHeader :: Whoami -> Text+renderCallerHeader w = "you are " <> who <> held <> within+ where+ shown = abbreviatePrincipal (whoPrincipal w)+ who = case whoName w <|> whoAgent w of+ Just n | n /= whoPrincipal w -> n <> " (" <> shown <> ")"+ _ -> shown+ roles = nub (map fst (whoMemberships w))+ onScopes r = case filter (not . T.null) [s | (r', s) <- whoMemberships w, r' == r] of+ [] -> r+ ss -> r <> " on " <> T.intercalate ", " (nub ss)+ held = if null roles then "" else " \x00B7 " <> T.intercalate "; " (map onScopes roles)+ within = case nub (map scopeOf (whoCeiling w)) of+ [] -> ""+ cs -> ", within " <> T.intercalate ", " cs++-- | A principal shortened for a one-line header: the part after its last+-- @:@ or @/@ kept to its leading word-and-underscore and four more+-- characters — @key:ac79e0…@ → @key:ac79…@, @oidc:workos/user_01M4QX…@ →+-- @oidc:workos/user_01M4…@. A short one is shown whole.+abbreviatePrincipal :: Text -> Text+abbreviatePrincipal p+ | T.length ident <= 12 = p+ | otherwise = prefix <> T.take keep ident <> "\x2026"+ where+ (prefix, ident) = splitIdent p+ lead = case T.breakOn "_" ident of+ (w, rest) | not (T.null rest), T.length w <= 8 -> T.length w + 1+ _ -> 0+ keep = lead + 4+ splitIdent t =+ let i = maximum (0 : [n + 1 | (n, c) <- zip [0 ..] (T.unpack t), c == ':' || c == '/'])+ in T.splitAt i t++-- ============================================================================+-- The anonymous page+-- ============================================================================++data AnonPage = AnonPage+ { apBackend :: Text -- ^ the hub's name+ , apVersion :: Maybe Text -- ^ the root schema's version, when the view shows it+ , apMethods :: [(Text, Text)] -- ^ (method, description) under @auth@+ , apInvoke :: Text -- ^ how this synapse was pointed: @synapse -P 4455@+ } deriving (Eq, Show)++-- | The door's methods, each with its description: the ones @_info@'s @auth@+-- block names (@auth.login@ → @login@), else the auth schema's public+-- ones, else login, signup and providers; described by the auth schema+-- when it says, else in synapse's words. login, signup, providers first,+-- in that order; any others after, by name.+anonymousMethods :: Maybe AuthInfo -> Maybe PluginSchema -> [(Text, Text)]+anonymousMethods info authSchema = sortOn (rank . fst) [ (m, describe m) | m <- names ]+ where+ fromInfo = maybe [] (map (\m -> fromMaybe m (T.stripPrefix "auth." m)) . authMethods) info+ fromSchema = maybe [] (map methodName . filter methodPublic . psMethods) authSchema+ names = nub $ case (fromInfo, fromSchema) of+ ([], []) -> ["login", "signup", "providers"]+ ([], s) -> s+ (i, _) -> i+ described = maybe [] (\s -> [(methodName m, methodDescription m) | m <- psMethods s]) authSchema+ describe m = case lookup m described of+ Just d | not (T.null d) -> d+ _ -> fromMaybe "" (lookup m ownWords)+ ownWords =+ [ ("login", "Sign in \x2014 a person through the browser, an agent with its key")+ , ("signup", "Create an account, or ask for access")+ , ("providers", "The ways in this server offers")+ ]+ rank m = (fromMaybe 3 (lookup m (zip ["login", "signup", "providers"] [0 :: Int ..])), m)++-- | The page an anonymous caller sees for a hub (AUTH-8):+--+-- @+-- menger v1.0.0+-- Sign in to see what you can do here.+--+-- auth+-- login Sign in — a person through the browser, an agent with its key+-- signup Create an account, or ask for access+-- providers The ways in this server offers+--+-- synapse -P 4455 _login menger # a person+-- synapse -P 4455 _login menger --key agent-7 --as w-1 # an agent+-- @+renderAnonymousPage :: AnonPage -> Text+renderAnonymousPage AnonPage{..} = T.unlines $+ [ apBackend <> maybe "" (\v -> if T.null v then "" else " v" <> v) apVersion+ , " Sign in to see what you can do here."+ , ""+ , "auth"+ ]+ <> [ " " <> T.justifyLeft nameW ' ' m <> (if T.null d then "" else " " <> d) | (m, d) <- apMethods ]+ <> [ "" ]+ <> [ " " <> T.justifyLeft exW ' ' ex <> " # " <> who | (ex, who) <- examples ]+ where+ nameW = maximum (0 : map (T.length . fst) apMethods)+ examples =+ [ (apInvoke <> " _login " <> apBackend, "a person")+ , (apInvoke <> " _login " <> apBackend <> " --key agent-7 --as w-1", "an agent")+ ]+ exW = maximum (map (T.length . fst) examples)++-- ============================================================================+-- Refusals+-- ============================================================================++-- | A refusal, as a block for stderr:+--+-- @+-- refused: needs tickets.set_status on IDENTITY+-- roles with it editor, admin+-- you hold it on MENGER (within MENGER-12)+-- @+--+-- The ceiling is the refusal's own when the hub names one, else the one+-- passed in (the stored session's, as asked at login). A line with+-- nothing to say is left out; a refusal naming no grant is its message.+renderRefusal :: [Text] -> Refusal -> Text+renderRefusal fallbackCeiling r = T.unlines (headline : details)+ where+ headline = case (refusalGrant r, refusalScope r) of+ (Just g, Just s) -> "refused: needs " <> g <> " on " <> s+ (Just g, Nothing) -> "refused: needs " <> g+ _ -> "refused: " <> refusalMessage r+ ceilingScopes = nub $ map scopeOf $+ if null (refusalCeiling r) then fallbackCeiling else refusalCeiling r+ within = if null ceilingScopes then "" else " (within " <> T.intercalate ", " ceilingScopes <> ")"+ details =+ [ " roles with it " <> T.intercalate ", " (refusalRoles r) | not (null (refusalRoles r)) ]+ <> [ " you hold it on " <> heldText ]+ heldText = case refusalHeldOn r of+ [] -> "nowhere" <> within+ hs -> T.intercalate ", " hs <> within++-- ============================================================================+-- The session auth.login answers+-- ============================================================================++-- | @{type: session, token, principal, name?, agent?, expires_at?, ceiling?}@+-- (AUTH-5), or IDENTITY-42's @{type: login, …, scope}@ from session.login.+data SessionGrant = SessionGrant+ { sgToken :: Text+ , sgPrincipal :: Text+ , sgName :: Maybe Text+ , sgAgent :: Maybe Text+ , sgExpiresAt :: Maybe Integer+ , sgCeiling :: [Text]+ , sgWhoami :: Maybe Whoami -- ^ when the answer carries memberships+ } deriving (Eq, Show)++-- | The session in a data item's content, when it is one.+parseSessionGrant :: Value -> Maybe SessionGrant+parseSessionGrant = \case+ Object o+ | Just (String t) <- KM.lookup "type" o, t `elem` ["session", "login"]+ , Just (String tok) <- KM.lookup "token" o, not (T.null tok) -> Just SessionGrant+ { sgToken = tok+ , sgPrincipal = str "principal" o+ , sgName = nonEmpty (str "name" o)+ , sgAgent = nonEmpty (str "agent" o)+ , sgExpiresAt = KM.lookup "expires_at" o >>= int+ , sgCeiling = ceilingOf o+ , sgWhoami = case parseWhoami (Object o) of+ Just w | not (null (whoMemberships w)) -> Just w+ _ -> Nothing+ }+ _ -> Nothing++-- | Is this error the hub saying it has no @auth@ activation (or no such+-- method on it)? Then synapse falls back to IDENTITY-42's @session.*@.+isMissingAuth :: Text -> Bool+isMissingAuth m = any (`T.isInfixOf` T.toLower m)+ [ "activation not found", "method not found", "unknown method", "no such method"+ , "unknown activation", "not found: auth", "-32601", "activationnotfound", "methodnotfound" ]++-- ============================================================================+-- Helpers+-- ============================================================================++str :: Text -> KM.KeyMap Value -> Text+str k o = case KM.lookup (K.fromText k) o of+ Just (String s) -> s+ _ -> ""++nonEmpty :: Text -> Maybe Text+nonEmpty t = if T.null t then Nothing else Just t++int :: Value -> Maybe Integer+int = \case+ Number n -> fromIntegral <$> (toBoundedInteger n :: Maybe Int)+ String s | [(i, "")] <- reads (T.unpack s) -> Just i+ _ -> Nothing
+ src/Synapse/Auth/Loopback.hs view
@@ -0,0 +1,199 @@+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE LambdaCase #-}+{-# LANGUAGE ScopedTypeVariables #-}++-- | AUTH-8: a person signing in from synapse — the loopback flow (AUTH-1,+-- "Settled to start", Q3).+--+-- synapse listens on @127.0.0.1:<a port the OS picks>@, asks the hub for a+-- sign-in URL that returns there (@auth.login {flow: {browser: {redirect}}}@),+-- opens it, and waits — at most five minutes — for the browser to come+-- back with @GET /cb?code=…&state=…@. The state must be the one the hub+-- handed out with the URL; anything else is refused and ends the wait.+-- The code then goes back to the hub (@auth.login {flow: {code: …}}@).+--+-- The parsing and the state check are pure ('parseRequestLine',+-- 'checkCallback') and tested; the socket is only 127.0.0.1, accepts one+-- answer and closes.+module Synapse.Auth.Loopback+ ( -- * The listener+ Loopback+ , loopbackPort+ , loopbackRedirect+ , withLoopback+ , awaitCallback++ -- * Pure parts+ , parseRequestLine+ , checkCallback+ , CallbackResult(..)+ , percentDecode+ , callbackPage+ , durationText++ -- * The browser+ , openBrowser+ ) where++import Control.Exception (SomeException, bracket, try)+import Control.Monad (void)+import qualified Data.ByteString as BS+import qualified Data.ByteString.Char8 as BS8+import Data.Char (chr, digitToInt, isHexDigit)+import Data.Text (Text)+import qualified Data.Text as T+import qualified Data.Text.Encoding as TE+import qualified Data.Text.Encoding.Error as TEE+import qualified Network.Socket as N+import qualified Network.Socket.ByteString as NB+import System.Environment (lookupEnv)+import System.Info (os)+import System.Process (createProcess, proc, std_err, std_out, std_in, StdStream(..))+import System.Timeout (timeout)++-- | A listening socket on 127.0.0.1 and the port the OS gave it.+data Loopback = Loopback+ { lbSocket :: N.Socket+ , lbPort :: Int+ }++loopbackPort :: Loopback -> Int+loopbackPort = lbPort++-- | Where the hub sends the browser back: @http://127.0.0.1:<port>/cb@.+loopbackRedirect :: Loopback -> Text+loopbackRedirect lb = "http://127.0.0.1:" <> T.pack (show (lbPort lb)) <> "/cb"++-- | Listen on 127.0.0.1 (never another interface), port 0, for the+-- duration of the action.+withLoopback :: (Loopback -> IO a) -> IO a+withLoopback act = bracket open (N.close . lbSocket) act+ where+ open = do+ sock <- N.socket N.AF_INET N.Stream N.defaultProtocol+ N.setSocketOption sock N.ReuseAddr 1+ N.bind sock (N.SockAddrInet 0 (N.tupleToHostAddress (127, 0, 0, 1)))+ N.listen sock 4+ port <- N.socketPort sock+ pure (Loopback sock (fromIntegral port))++-- | What one request to the loopback came to.+data CallbackResult+ = CallbackCode Text -- ^ @/cb@ with the state expected: the code+ | CallbackRefused Text -- ^ @/cb@ that cannot be accepted: why (ends the wait)+ | CallbackIgnored -- ^ anything else (@/favicon.ico@): answered 404, keep waiting+ deriving (Eq, Show)++-- | The method, path and query of an HTTP request line:+-- @GET /cb?code=c_1&state=s_2 HTTP/1.1@ → @("GET", "/cb", [("code","c_1"),("state","s_2")])@.+parseRequestLine :: BS.ByteString -> Maybe (Text, Text, [(Text, Text)])+parseRequestLine raw = case BS8.words (BS8.takeWhile (\c -> c /= '\r' && c /= '\n') raw) of+ (method : target : _) ->+ let (path, query) = BS8.break (== '?') target+ params = [ (dec k, dec (BS.drop 1 v))+ | kv <- BS8.split '&' (BS.drop 1 query), not (BS.null kv)+ , let (k, v) = BS8.break (== '=') kv ]+ in Just (utf8 method, utf8 path, params)+ _ -> Nothing+ where+ dec = percentDecode . utf8+ utf8 = TE.decodeUtf8With TEE.lenientDecode++-- | Decide a request: @GET /cb@ carrying the expected state and a code is+-- the code; @/cb@ with a provider's @error@, a missing code, or another+-- state is refused (a mismatched state is never a code); any other path+-- is ignored.+checkCallback :: Text -> (Text, Text, [(Text, Text)]) -> CallbackResult+checkCallback expected (_method, path, params)+ | path /= "/cb" = CallbackIgnored+ | Just e <- lookup "error" params =+ CallbackRefused ("the sign-in was not completed: " <> e+ <> maybe "" (" \x2014 " <>) (lookup "error_description" params))+ | lookup "state" params /= Just expected =+ CallbackRefused "the browser came back with a state this sign-in did not ask for; refused"+ | otherwise = case lookup "code" params of+ Just c | not (T.null c) -> CallbackCode c+ _ -> CallbackRefused "the browser came back without a code"++-- | @%2F@ → @/@, @+@ → space.+percentDecode :: Text -> Text+percentDecode = T.pack . go . T.unpack+ where+ go ('%' : a : b : rest) | isHexDigit a && isHexDigit b = chr (digitToInt a * 16 + digitToInt b) : go rest+ go ('+' : rest) = ' ' : go rest+ go (c : rest) = c : go rest+ go [] = []++-- | @300@ → @5 minutes@; @90@ → @90 seconds@.+durationText :: Int -> Text+durationText secs+ | secs >= 60 && secs `mod` 60 == 0 = T.pack (show (secs `div` 60)) <> " minutes"+ | secs == 1 = "1 second"+ | otherwise = T.pack (show secs) <> " seconds"++-- | The small page the browser shows once synapse has the answer.+callbackPage :: Bool -> Text -> BS.ByteString+callbackPage ok msg = response+ where+ status = if ok then "200 OK" else "400 Bad Request"+ title = if ok then "Signed in" else "Not signed in"+ body = TE.encodeUtf8 $ T.concat+ [ "<!doctype html><meta charset=utf-8><title>", title, "</title>"+ , "<body style=\"font:16px system-ui,sans-serif;margin:3em\"><h1>", title, "</h1><p>"+ , escape msg, "</p></body>" ]+ response = BS.concat+ [ "HTTP/1.1 ", status, "\r\nContent-Type: text/html; charset=utf-8\r\n"+ , "Content-Length: ", BS8.pack (show (BS.length body)), "\r\nConnection: close\r\n"+ , "Cache-Control: no-store\r\n\r\n", body ]+ escape = T.concatMap (\case '<' -> "<"; '>' -> ">"; '&' -> "&"; '"' -> """; c -> T.singleton c)++-- | Wait (up to @secs@) for the browser to come back with the state+-- expected. The code, or why there is none.+awaitCallback :: Loopback -> Text -> Int -> IO (Either Text Text)+awaitCallback lb expected secs = do+ r <- timeout (secs * 1000000) loop+ pure $ case r of+ Nothing -> Left ("no answer from the browser within " <> durationText secs)+ Just x -> x+ where+ loop = do+ (conn, _) <- N.accept (lbSocket lb)+ req <- readHead conn+ let decided = maybe CallbackIgnored (checkCallback expected) (parseRequestLine req)+ case decided of+ CallbackIgnored -> do+ send conn "HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"+ N.close conn+ loop+ CallbackRefused why -> do+ send conn (callbackPage False why)+ N.close conn+ pure (Left why)+ CallbackCode code -> do+ send conn (callbackPage True "You are signed in to synapse. You can close this tab.")+ N.close conn+ pure (Right code)+ send conn bytes = void (try (NB.sendAll conn bytes) :: IO (Either SomeException ()))+ -- the request line is all that is read; enough of the head to hold it+ readHead conn = go BS.empty+ where+ go acc+ | "\r\n" `BS.isInfixOf` acc || BS.length acc > 16384 = pure acc+ | otherwise = do+ chunk <- try (NB.recv conn 4096) :: IO (Either SomeException BS.ByteString)+ case chunk of+ Right c | not (BS.null c) -> go (acc <> c)+ _ -> pure acc++-- | Open the URL in the person's browser (@xdg-open@; @open@ on macOS),+-- detached and silent. @SYNAPSE_NO_BROWSER=1@ opens nothing (the URL is+-- printed either way). True when a browser was asked.+openBrowser :: Text -> IO Bool+openBrowser url = do+ off <- lookupEnv "SYNAPSE_NO_BROWSER"+ if maybe False (`elem` ["1", "true", "yes"]) off then pure False else do+ let opener = if os == "darwin" then "open" else "xdg-open"+ r <- try (void (createProcess (proc opener [T.unpack url])+ { std_in = NoStream, std_out = NoStream, std_err = NoStream }))+ :: IO (Either SomeException ())+ pure (either (const False) (const True) r)
src/Synapse/Backend/Discovery.hs view
@@ -44,16 +44,34 @@ -- * Implementations , stubDiscovery , registryDiscovery+ , registryDiscoveryAt + -- * SCAFFOLD-21: an address that is not plain ws://HOST:PORT/+ , endpointFor+ -- * Backend Discovery , getBackendAt+ , getBackendAtWith+ , getBackendAtEp+ , probeBackendAtWith+ , probeBackendAtEp+ , probedInfo+ , probedInfoAt+ , probeFailureAt -- * Health Checks , pingBackend , pingBackends+ , pingBackendsVia -- * Registry Registration , registerWithRegistry++ -- * Registry Endpoint+ , registryUrlEnvVar+ , defaultRegistryHost+ , defaultRegistryPort+ , registryEndpointFromEnv ) where import Data.Aeson (ToJSON, FromJSON, (.:), (.:?), withObject)@@ -66,13 +84,19 @@ import Data.Either (isRight) import Control.Concurrent.Async (mapConcurrently, race, async, wait) import Control.Concurrent (threadDelay)-import Data.IORef (newIORef, writeIORef, readIORef)+import Data.IORef (IORef, newIORef, writeIORef, readIORef, modifyIORef')+import qualified Data.Map.Strict as Map+import System.IO.Unsafe (unsafePerformIO)+import Plexus.Auth (BackendInfo, parseBackendInfo) import Control.Monad (void)-import Plexus.Client (SubstrateConfig(..))+import Synapse.Endpoint (Endpoint(..), plainAt, substrateConfig, addressKey)+import qualified Network.WebSockets as WS import qualified Plexus.Transport as ST import Plexus.Types (PlexusStreamItem(..), TransportError(..)) import qualified Synapse.Log as Log import qualified System.IO as IO+import qualified Data.Text.Read as TR+import System.Environment (lookupEnv) -- ============================================================================ -- Backend Type@@ -178,10 +202,17 @@ -- Connects to the registry service and queries for registered backends. -- Falls back to stubDiscovery if registry is unavailable. registryDiscovery :: Text -> Int -> BackendDiscovery-registryDiscovery registryHost registryPort = BackendDiscovery+registryDiscovery registryHost registryPort = registryDiscoveryAt (plainAt registryHost registryPort)++-- | SCAFFOLD-21: 'registryDiscovery' through a connection point at any+-- address (a hub behind TLS, on a path). The backends a registry lists+-- are plain @ws://HOST:PORT/@, as it records them; the connection point+-- itself is reached as it was named ('endpointFor').+registryDiscoveryAt :: Endpoint -> BackendDiscovery+registryDiscoveryAt ep = BackendDiscovery { discoverBackends = do -- First, discover the backend name using _info- maybeBackendName <- discoverBackendName registryHost registryPort+ maybeBackendName <- either (const Nothing) Just <$> probeBackendAtEp [] ep case maybeBackendName of Just discoveredName -> do -- Create a Backend entry for the discovered backend@@ -195,7 +226,7 @@ } -- Try to get additional backends from registry (if it exists)- registryBackends <- queryRegistry registryHost registryPort discoveredName+ registryBackends <- queryRegistry ep discoveredName -- Only include discovered backend if not already in registry -- (prefer registry entry since it has more details)@@ -211,67 +242,119 @@ Nothing -> -- Connection failed completely discoverBackends stubDiscovery- , getBackendInfo = queryBackend registryHost registryPort+ , getBackendInfo = queryBackend ep }+ where+ registryHost = epHost ep+ registryPort = epPort ep +-- | SCAFFOLD-21: how to reach a backend a discovery answered — the+-- connection point as it was named (its scheme, path, CAs) when the+-- backend is at its host and port, else plain @ws://HOST:PORT/@.+endpointFor :: Endpoint -> Backend -> Endpoint+endpointFor ep b+ | backendHost b == epHost ep && backendPort b == epPort ep = ep+ | otherwise = plainAt (backendHost b) (backendPort b)+ -- | Get the backend name at a given host/port via _info -- Returns whatever backend is running there (e.g., "registry-hub", "plexus") -- If that backend has a registry plugin, it can be used to discover other backends getBackendAt :: Text -> Int -> IO (Maybe Text)-getBackendAt = discoverBackendName+getBackendAt host port = getBackendAtEp [] (plainAt host port) --- | Discover the backend name by calling _info (with 2000ms timeout)-discoverBackendName :: Text -> Int -> IO (Maybe Text)-discoverBackendName host port = do- let cfg = SubstrateConfig- { substrateHost = T.unpack host- , substratePort = port- , substratePath = "/"- , substrateBackend = "" -- Not used for _info- , substrateHeaders = []- }+-- | IDENTITY-16: 'getBackendAt', its _info probe carrying the caller's+-- upgrade headers (the token's cookie, --cookie, --header; see+-- 'Synapse.Transport.mergeUpgradeHeaders'). A backend that refuses+-- anonymous upgrades answers the probe only with them.+getBackendAtWith :: WS.Headers -> Text -> Int -> IO (Maybe Text)+getBackendAtWith headers host port = getBackendAtEp headers (plainAt host port)++-- | SCAFFOLD-21: 'getBackendAtWith' at any address.+getBackendAtEp :: WS.Headers -> Endpoint -> IO (Maybe Text)+getBackendAtEp headers ep = either (const Nothing) Just <$> probeBackendAtEp headers ep++-- | IDENTITY-43: the _info probe, saying why when it fails — so a caller+-- can tell the upgrade's 401 (its session ended) from a backend that is+-- down.+probeBackendAtWith :: WS.Headers -> Text -> Int -> IO (Either Text Text)+probeBackendAtWith headers host port = probeBackendAtEp headers (plainAt host port)++-- | SCAFFOLD-21: 'probeBackendAtWith' at any address. Behind TLS the+-- probe waits longer (a handshake across the internet, not loopback).+probeBackendAtEp :: WS.Headers -> Endpoint -> IO (Either Text Text)+probeBackendAtEp headers ep = do+ let cfg = substrateConfig ep "" headers -- the backend is not used for _info+ secs = probeSecs ep timeout = do- threadDelay 2000000 -- 2 second timeout- pure (Left $ ConnectionTimeout host port)+ threadDelay (secs * 1000000)+ pure (Left $ ConnectionTimeout (epHost ep) (epPort ep)) rpcCall = do ST.rpcCallWith cfg "_info" Aeson.Null- `catch` \(_e :: SomeException) ->- pure (Left $ NetworkError "Connection failed")+ `catch` \(e :: SomeException) ->+ pure (Left $ NetworkError ("Connection failed: " <> T.pack (show e))) - -- Race the RPC call against a 2 second timeout+ -- Race the RPC call against the timeout raceResult <- race timeout rpcCall + let failed why = do+ -- SCAFFOLD-21: kept, so a caller can say why (a certificate refused)+ modifyIORef' failMemo (Map.insert (addressKey ep) why)+ pure (Left why) case raceResult of Left _err ->- pure Nothing- Right (Left _err) ->- pure Nothing+ failed "the _info probe timed out"+ Right (Left err) ->+ failed (T.pack (show err)) Right (Right items) -> do -- Extract the backend name from subscription response case [Aeson.fromJSON content | StreamData _ _ _ content <- items] of- (Aeson.Success (Aeson.Object obj):_) | Just (Aeson.String name) <- KM.lookup "backend" obj ->- pure (Just name)+ (Aeson.Success v@(Aeson.Object obj):_) | Just (Aeson.String name) <- KM.lookup "backend" obj -> do+ -- AUTH-8: kept for the rest of the process — the view and the+ -- auth block this connection's credential was answered with+ mapM_ (\i -> modifyIORef' infoMemo (Map.insert (addressKey ep) i)) (parseBackendInfo v)+ pure (Right name) _ ->- pure Nothing+ failed "the _info probe answered no backend name" +-- | How long the _info probe waits: 2 s on a plain socket (loopback, as+-- ever), 10 s behind TLS.+probeSecs :: Endpoint -> Int+probeSecs ep = if epTls ep then 10 else 2++-- | AUTH-8: what the last successful _info probe at HOST:PORT answered in+-- this process (its view and auth block), when it was probed.+probedInfo :: Text -> Int -> IO (Maybe BackendInfo)+probedInfo host port = probedInfoAt (plainAt host port)++-- | SCAFFOLD-21: 'probedInfo' at any address.+probedInfoAt :: Endpoint -> IO (Maybe BackendInfo)+probedInfoAt ep = Map.lookup (addressKey ep) <$> readIORef infoMemo++-- | SCAFFOLD-21: why the last _info probe at this address failed, in this+-- process (a TLS handshake's refusal, say), if it did.+probeFailureAt :: Endpoint -> IO (Maybe Text)+probeFailureAt ep = Map.lookup (addressKey ep) <$> readIORef failMemo++{-# NOINLINE failMemo #-}+failMemo :: IORef (Map.Map Text Text)+failMemo = unsafePerformIO (newIORef Map.empty)++{-# NOINLINE infoMemo #-}+infoMemo :: IORef (Map.Map Text BackendInfo)+infoMemo = unsafePerformIO (newIORef Map.empty)+ -- | Query the registry for all backends-queryRegistry :: Text -> Int -> Text -> IO [Backend]-queryRegistry host port backendName = do+queryRegistry :: Endpoint -> Text -> IO [Backend]+queryRegistry ep backendName = do -- Try registry query first- queryRegistryImpl host port backendName `catch` \(_e :: SomeException) ->+ queryRegistryImpl ep backendName `catch` \(_e :: SomeException) -> -- Return empty list on error - caller will handle fallback pure [] -- | Implementation of registry query-queryRegistryImpl :: Text -> Int -> Text -> IO [Backend]-queryRegistryImpl host port backendName = do- let cfg = SubstrateConfig- { substrateHost = T.unpack host- , substratePort = port- , substratePath = "/"- , substrateBackend = backendName- , substrateHeaders = []- }+queryRegistryImpl :: Endpoint -> Text -> IO [Backend]+queryRegistryImpl ep backendName = do+ let cfg = substrateConfig ep backendName [] -- Call registry.list through the backend result <- ST.invokeMethod cfg ["registry"] "list" (Aeson.object []) case result of@@ -284,10 +367,10 @@ pure $ map convertBackend backendInfos -- | Query for a specific backend by name-queryBackend :: Text -> Int -> Text -> IO (Maybe Backend)-queryBackend host port name = do+queryBackend :: Endpoint -> Text -> IO (Maybe Backend)+queryBackend ep name = do -- First discover the backend name- maybeBackendName <- discoverBackendName host port+ maybeBackendName <- getBackendAtEp [] ep case maybeBackendName of Just discoveredName -> do -- Check if the requested name matches the discovered backend@@ -295,29 +378,23 @@ then pure $ Just Backend { backendName = discoveredName , backendDescription = "Backend discovered via _info"- , backendHost = host- , backendPort = port+ , backendHost = epHost ep+ , backendPort = epPort ep , backendVersion = Nothing , backendReachable = Nothing } else do -- Try to find it in the registry result <- catch- (queryBackendImpl host port discoveredName name)+ (queryBackendImpl ep discoveredName name) (\(_e :: SomeException) -> pure Nothing) pure result Nothing -> getBackendInfo stubDiscovery name -- | Implementation of backend get query-queryBackendImpl :: Text -> Int -> Text -> Text -> IO (Maybe Backend)-queryBackendImpl host port backendName name = do- let cfg = SubstrateConfig- { substrateHost = T.unpack host- , substratePort = port- , substratePath = "/"- , substrateBackend = backendName- , substrateHeaders = []- }+queryBackendImpl :: Endpoint -> Text -> Text -> IO (Maybe Backend)+queryBackendImpl ep backendName name = do+ let cfg = substrateConfig ep backendName [] params = Aeson.object ["name" Aeson..= name] result <- ST.invokeMethod cfg ["registry"] "get" params case result of@@ -345,19 +422,21 @@ -- 300ms allows for WebSocket connection establishment overhead (~150-200ms) -- plus actual RPC round-trip time pingBackend :: Backend -> IO Backend-pingBackend backend = do- let cfg = SubstrateConfig- { substrateHost = T.unpack (backendHost backend)- , substratePort = backendPort backend- , substratePath = "/"- , substrateBackend = "" -- _info has no namespace prefix- , substrateHeaders = []- }- timeout = threadDelay 300000 >> pure (Left $ ConnectionTimeout (backendHost backend) (backendPort backend))+pingBackend = pingBackendAt Nothing++-- | SCAFFOLD-21: ping at the backend's own address — the connection point+-- as it was named when the backend is there ('endpointFor'), which behind+-- TLS is given 5 s rather than 300 ms.+pingBackendAt :: Maybe Endpoint -> Backend -> IO Backend+pingBackendAt via backend = do+ let ep = maybe (plainAt (backendHost backend) (backendPort backend)) (`endpointFor` backend) via+ cfg = substrateConfig ep "" [] -- _info has no namespace prefix+ micros = if epTls ep then 5000000 else 300000+ timeout = threadDelay micros >> pure (Left $ ConnectionTimeout (backendHost backend) (backendPort backend)) rpcCall = ST.rpcCallWith cfg "_info" Aeson.Null `catch` \(_e :: SomeException) -> pure (Left $ NetworkError "Connection failed") - -- Race the RPC call against a 300ms timeout+ -- Race the RPC call against the timeout result <- race timeout rpcCall let reachable = case result of Left _ -> False -- Timeout@@ -370,6 +449,10 @@ pingBackends :: [Backend] -> IO [Backend] pingBackends backends = mapConcurrently pingBackend backends +-- | SCAFFOLD-21: 'pingBackends', the connection point reached as named.+pingBackendsVia :: Endpoint -> [Backend] -> IO [Backend]+pingBackendsVia ep = mapConcurrently (pingBackendAt (Just ep))+ -- ============================================================================ -- Registry Registration -- ============================================================================@@ -387,13 +470,7 @@ -> Int -- ^ Backend port -> IO () registerWithRegistry registryHost registryPort registryBackend name host port = do- let cfg = SubstrateConfig- { substrateHost = T.unpack registryHost- , substratePort = registryPort- , substratePath = "/"- , substrateBackend = registryBackend- , substrateHeaders = []- }+ let cfg = substrateConfig (plainAt registryHost registryPort) registryBackend [] params = Aeson.object [ "name" Aeson..= name , "host" Aeson..= host@@ -405,3 +482,53 @@ _ <- ST.invokeMethod cfg ["registry"] "register" params `catch` \(_e :: SomeException) -> pure (Left $ NetworkError "Registry unavailable") pure ()++-- ============================================================================+-- Registry Endpoint+-- ============================================================================+--+-- SYNAPSE-7: the endpoint helpers synapse-cc 0.3.4 imports+-- (src/SynapseCC/RegistryResolve.hs). They were written with Z2H-6 on+-- the feature/Z2H-6-registry branch (e176373), which was never merged to+-- main, so synapse-cc's main did not build against synapse's. These are+-- that commit's definitions, unchanged and additive: nothing in synapse+-- calls them yet, so no behavior here moves.++-- | Environment variable naming the registry endpoint, shared with+-- plexus-transport's boot-time self-registration.+registryUrlEnvVar :: String+registryUrlEnvVar = "PLEXUS_REGISTRY_URL"++defaultRegistryHost :: Text+defaultRegistryHost = "127.0.0.1"++defaultRegistryPort :: Int+defaultRegistryPort = 4444++-- | Resolve the registry endpoint from @PLEXUS_REGISTRY_URL@+-- (e.g. @ws:\/\/127.0.0.1:4444@ or @host:port@), falling back to+-- @127.0.0.1:4444@. Malformed values fall back to the default rather+-- than failing the invocation.+registryEndpointFromEnv :: IO (Text, Int)+registryEndpointFromEnv = do+ mUrl <- lookupEnv registryUrlEnvVar+ pure $ case mUrl of+ Nothing -> (defaultRegistryHost, defaultRegistryPort)+ Just raw -> parseRegistryUrl (T.strip (T.pack raw))++-- | Parse @[ws://]host[:port]@.+parseRegistryUrl :: Text -> (Text, Int)+parseRegistryUrl raw+ | T.null raw = (defaultRegistryHost, defaultRegistryPort)+ | otherwise =+ let noScheme = case T.breakOn "://" raw of+ (_, rest) | not (T.null rest) -> T.drop 3 rest+ _ -> raw+ noPath = T.takeWhile (/= '/') noScheme+ (host, portTxt) = case T.breakOnEnd ":" noPath of+ (h, p) | not (T.null h) -> (T.dropEnd 1 h, p)+ _ -> (noPath, "")+ in case TR.decimal portTxt of+ Right (p, "") | not (T.null host) -> (host, p)+ _ | T.null noPath -> (defaultRegistryHost, defaultRegistryPort)+ | otherwise -> (noPath, defaultRegistryPort)
src/Synapse/CLI/Help.hs view
@@ -43,6 +43,19 @@ renderMethodHelp , renderMethodHelpWith + -- * The method page (SYNAPSE-13)+ , MethodPage(..)+ , methodPage+ , renderMethodPage+ , renderExample+ , synthesiseExample+ , shellQuote+ , flagLabel+ , flagType+ , paramValuesOf+ , serverSourced+ , wrapWords+ -- * Parameter Help , renderParamHelp , renderParamHelpWith@@ -56,15 +69,26 @@ , defaultHelpStyle ) where -import Data.List (intersperse)-import Data.Map.Strict (Map)+import Control.Applicative ((<|>))+import Data.Aeson (Value(..))+import qualified Data.Aeson as Aeson+import qualified Data.Aeson.Key as K+import qualified Data.Aeson.KeyMap as KM+import qualified Data.ByteString.Lazy as LBS+import Data.Char (isAlphaNum)+import Data.List (sortOn) import qualified Data.Map.Strict as Map-import Data.Maybe (fromMaybe, maybeToList)+import Data.Maybe (fromMaybe, isJust, mapMaybe)+import Data.Scientific (floatingOrInteger) import Data.Text (Text) import qualified Data.Text as T+import qualified Data.Text.Encoding as TE+import qualified Data.Vector as V +import Plexus.Schema.Recursive (MethodExample(..), MethodSchema(..), paramValues) import Synapse.IR.Types-import Synapse.Algebra.Render (deprecationMarker, formatDeprecationLine)+import Synapse.Algebra.Render (deprecationMarker, formatDeprecationLine, fitText)+import Synapse.CLI.Parse (isScalarElement) -- ============================================================================ -- Configuration@@ -92,33 +116,316 @@ -- Method Help -- ============================================================================ --- | Render complete help for a method+-- | Render complete help for a method: its page ('renderMethodPage'),+-- reached by @synapse <namespace> <method>@, from the IR alone. renderMethodHelp :: IR -> MethodDef -> Text-renderMethodHelp = renderMethodHelpWith defaultHelpStyle+renderMethodHelp ir md = renderMethodPage (methodPage md) ir md --- | Render method help with custom style+-- | Render method help with custom style (the style no longer changes the+-- page; kept for callers of the earlier layout). renderMethodHelpWith :: HelpStyle -> IR -> MethodDef -> Text-renderMethodHelpWith style ir method = T.unlines $- [ header- , ""- ] ++ descSection ++ paramSection+renderMethodHelpWith _ = renderMethodHelp++-- ============================================================================+-- The method page (SYNAPSE-13)+-- ============================================================================++-- | What a method's page needs beside the IR.+data MethodPage = MethodPage+ { mpProgram :: Text -- ^ synapse and its global options (@synapse -P 4455@)+ , mpTarget :: Text -- ^ the backend and path to the method (@menger tickets set_status@)+ , mpSchema :: Maybe MethodSchema -- ^ the method's schema: long description, examples, values, grant+ , mpCaller :: Bool -- ^ AUTH-8: in a caller's view (say what it requires)+ }+ deriving stock (Show, Eq)++-- | A page for a method known only from the IR: reached as+-- @synapse <namespace> <method>@.+methodPage :: MethodDef -> MethodPage+methodPage md = MethodPage+ { mpProgram = "synapse"+ , mpTarget = T.replace "." " " (mdFullPath md)+ , mpSchema = Nothing+ , mpCaller = False+ }++-- | SYNAPSE-13: a method's page in cargo's shape — the description; a+-- blank line and the long description; @Usage:@ with the required flags+-- spelled out; @Options:@ aligned, required first then optional in+-- [brackets], each with its type, description and allowed values;+-- @Example:@ — the schema's examples as command lines, each under its+-- note, else one synthesised from the required options; and, in a caller's+-- view, what it requires and where the caller holds it.+renderMethodPage :: MethodPage -> IR -> MethodDef -> Text+renderMethodPage MethodPage{..} ir md = T.unlines (dropTrailing body) where- header = mdFullPath method <> streamingIndicator+ width = 80+ invoke = mpProgram <> " " <> mpTarget - streamingIndicator- | mdStreaming method = " [streaming]"- | otherwise = ""+ body = concat+ [ descLines+ , longLines+ , [""]+ , usageLines+ , optionsBlock+ , exampleBlock+ , requiresBlock+ ] - descSection = case mdDescription method of- Just desc -> [desc, ""]+ descLines = case mdDescription md of+ Just d | not (T.null (T.strip d)) -> wrapWords width d+ _ -> [mdName md]++ longText = (mpSchema >>= methodLongDescription) <|> mdLongDescription md+ longLines = case longText of+ Just l | not (T.null (T.strip l)) ->+ "" : concatMap (\p -> if T.null p then [""] else wrapWords width p) (paragraphs l)+ _ -> []++ -- What a caller passes (a server-supplied param, REQ-8, is not a flag).+ (callerParams, serverParams) =+ ( [p | p <- params, not (serverSourced p)]+ , [p | p <- params, serverSourced p] )+ params = sortOn (not . pdRequired) (mdParams md)+ required = [p | p <- callerParams, pdRequired p]+ optional = [p | p <- callerParams, not (pdRequired p)]++ usageLines =+ [ "Usage: " <> T.unwords ([invoke] ++ map usageFlag required ++ ["[OPTIONS]" | not (null optional)]) ]+ usageFlag p = flagOf p <> " <" <> T.toUpper (pdName p) <> ">"++ optionsBlock+ | null callerParams && null serverParams = []+ | otherwise = ["", "Options:"] ++ concatMap optionRows callerParams ++ concatMap serverRows serverParams++ flagCol = 2 + min 28 (maximum (0 : map (T.length . flagText) callerParams ++ map (T.length . pdName) serverParams)) + 2++ flagText = flagLabel ir++ optionRows p = hangAt flagCol (" " <> flagText p) (descOf p ++ extras p)+ serverRows p = hangAt flagCol (" " <> pdName p) ["\x2190 " <> sourceLabel p]++ descW = max 20 (width - flagCol)+ descOf p = maybe [] (wrapWords descW) (pdDescription p)+ extras p = concat+ [ maybe [] (\di -> [formatDeprecationLine di]) (pdDeprecation p)+ , listLines p+ , maybe [] (\vs -> wrapWords descW ("[values: " <> T.intercalate ", " vs <> "]")) (valuesOf p)+ , if hsExpandEnums defaultHelpStyle then expandType ir (pdName p) (pdType p) else []+ ]++ listLines p = case listElement (pdType p) of+ Just inner+ | isScalarElement ir inner -> ["Repeat the flag or use commas: " <> flagOf p <> " a,b"]+ | otherwise -> ["Repeat the flag for each element"] Nothing -> []+ listElement = \case+ RefArray inner -> Just inner+ RefOptional inner -> listElement inner+ _ -> Nothing - paramSection- | null (mdParams method) = ["(no parameters)"]- | otherwise =- ["Parameters:"] ++- concatMap (renderParamBlock style ir) (mdParams method)+ -- The schema's hint (x-plexus-values), else the type's own literals.+ valuesOf p = pdValues p <|> schemaValues (pdName p) <|> stringEnumValues ir (pdType p)+ schemaValues name = do+ ms <- mpSchema+ Object o <- methodParams ms+ Object props <- KM.lookup "properties" o+ prop <- KM.lookup (K.fromText name) props+ paramValues prop + -- Examples: the schema's, else one from the required options.+ examples = case maybe [] methodExamples mpSchema of+ [] -> [MethodExample (synthesiseExample ir md (fmap methodParams mpSchema >>= id)) Nothing]+ es -> es+ exampleBlock =+ -- SYNAPSE-10: where a value may come from, on every method page+ [ ""+ , "A value may be read from a file (--flag @FILE) or stdin (--flag -);"+ , "@@text sends a literal @text."+ , "", if length examples == 1 then "Example:" else "Examples:"]+ ++ concat (zipWith exampleLines [0 :: Int ..] examples)+ exampleLines i ex =+ [ "" | i > 0 ]+ ++ maybe [] (\n -> [" # " <> n]) (exampleNote ex)+ ++ [" " <> renderExample mpProgram mpTarget md (exampleParams ex)]++ -- AUTH-8: in a caller's view, the grant and where it is held.+ requiresBlock = case mpSchema of+ Just ms | mpCaller -> maybe [] (\l -> ["", l]) (requiresLine ms)+ _ -> []+ requiresLine ms+ | methodPublic ms = Just "Requires: nothing (public)"+ | otherwise = do+ grant <- methodGrant ms+ let held = case methodHeldOn ms of+ Just hs@(_ : _) -> "you hold it on " <> T.intercalate ", " hs+ _ -> "you hold it nowhere"+ pure $ case methodTarget ms of+ Just t -> "Requires: " <> grant <> " on the scope of --" <> kebab t <> " (" <> held <> ")"+ Nothing -> "Requires: " <> grant <> ", answered from where it is held (" <> held <> ")"++ paragraphs = map (T.unwords . T.words) . splitBlank . T.lines+ splitBlank ls = case break (T.null . T.strip) ls of+ (p, []) -> [T.unlines p]+ (p, _ : rest) -> T.unlines p : "" : splitBlank rest++ dropTrailing = reverse . dropWhile (T.null . T.strip) . reverse++-- | A lead and lines that start in column @col@: the first beside the lead+-- when it fits (two spaces between), else all of them under it.+hangAt :: Int -> Text -> [Text] -> [Text]+hangAt col lead ds = case ds of+ [] -> [lead]+ (d : rest) | T.length lead <= col - 2 -> (T.justifyLeft col ' ' lead <> d) : map (pad <>) rest+ _ -> lead : map (pad <>) ds+ where pad = T.replicate col " "++flagOf :: ParamDef -> Text+flagOf p = "--" <> kebab (pdName p)++-- | SYNAPSE-13: a parameter as an option line shows it — its flag and the+-- type it takes, in [brackets] when optional, ⚠ before a deprecated one+-- (@--id <string>@, @[--snoozed <boolean>]@). SYNAPSE-17's search shows a+-- parameter the same way.+flagLabel :: IR -> ParamDef -> Text+flagLabel ir p =+ let decorated = case pdDeprecation p of+ Just _ -> deprecationMarker <> " " <> flagOf p+ Nothing -> flagOf p+ f = decorated <> " <" <> flagType ir (pdType p) <> ">"+ in if pdRequired p then f else "[" <> f <> "]"++-- | A type as a flag takes it: an optional one as its inner type (the+-- brackets say it is optional), a named one by its own name.+flagType :: IR -> TypeRef -> Text+flagType ir = \case+ RefOptional inner -> flagType ir inner+ RefArray inner -> flagType ir inner <> "[]"+ t@(RefNamed qn) ->+ let full = renderTypeRef ir t+ in if full == qualifiedNameFull qn then qnLocalName qn else full+ t -> renderTypeRef ir t++-- | The values a parameter takes: its schema's hint (@x-plexus-values@),+-- else its type's literals when it is a string enum.+paramValuesOf :: IR -> ParamDef -> Maybe [Text]+paramValuesOf ir p = pdValues p <|> stringEnumValues ir (pdType p)++kebab :: Text -> Text+kebab = T.replace "_" "-"++-- | REQ-8: a param the server supplies (@x-plexus-source@ other than rpc).+serverSourced :: ParamDef -> Bool+serverSourced p = isJust (pdSource p) && sourceLabel p /= "rpc"++sourceLabel :: ParamDef -> Text+sourceLabel p = case pdSource p of+ Just (Object src) ->+ let str k = case KM.lookup k src of { Just (String t) -> Just t; _ -> Nothing }+ in case str "from" of+ Just "auth" -> maybe "auth" ("auth: " <>) (str "resolver")+ Just "cookie" -> "cookie" <> maybe "" (" " <>) (str "key")+ Just "header" -> "header" <> maybe "" (" " <>) (str "key")+ Just "query" -> "query" <> maybe "" (" " <>) (str "key")+ Just "derived" -> "server-derived"+ Just other -> other+ Nothing -> "server"+ _ -> "rpc"++-- | The literals a string-enum type takes, when it is one.+stringEnumValues :: IR -> TypeRef -> Maybe [Text]+stringEnumValues ir = \case+ RefOptional inner -> stringEnumValues ir inner+ RefNamed qn -> case Map.lookup (qualifiedNameFull qn) (irTypes ir) of+ Just TypeDef{tdKind = KindStringEnum vs} | not (null vs) -> Just vs+ _ -> Nothing+ _ -> Nothing++-- | SYNAPSE-13: one example from a method's required options: each the+-- value its description gives after @e.g.@, else the first of its values,+-- else a stand-in by type (@<ID>@ for a string).+synthesiseExample :: IR -> MethodDef -> Maybe Value -> Value+synthesiseExample ir md schemaParams = Object $ KM.fromList+ [ (K.fromText (pdName p), sample p) | p <- mdParams md, pdRequired p, not (serverSourced p) ]+ where+ sample p = fromMaybe (byType p (pdType p)) (fromDesc p <|> fromValues p)+ fromDesc p = do+ d <- pdDescription p+ let (_, after) = T.breakOn "e.g. " d+ guard' (not (T.null after))+ let tok = T.takeWhile (\c -> c `notElem` (" ,;)" :: String)) (T.drop 5 after)+ bare = T.dropAround (`elem` ("'\"`[]" :: String)) tok+ guard' (not (T.null bare))+ pure (String bare)+ fromValues p = do+ Object o <- schemaParams+ Object props <- KM.lookup "properties" o+ prop <- KM.lookup (K.fromText (pdName p)) props+ v : _ <- paramValues prop+ pure (String v)+ byType p = \case+ RefPrimitive "integer" _ -> Number 1+ RefPrimitive "number" _ -> Number 1+ RefPrimitive "boolean" _ -> Bool True+ RefArray inner -> Array (V.singleton (byType p inner))+ RefOptional inner -> byType p inner+ RefNamed qn -> case Map.lookup (qualifiedNameFull qn) (irTypes ir) of+ Just TypeDef{tdKind = KindStringEnum (v : _)} -> String v+ Just TypeDef{tdKind = KindAlias t} -> byType p t+ Just TypeDef{tdKind = KindPrimitive t _} -> byType p (RefPrimitive t Nothing)+ _ -> String ("<" <> T.toUpper (pdName p) <> ">")+ _ -> String ("<" <> T.toUpper (pdName p) <> ">")+ guard' b = if b then Just () else Nothing++-- | SYNAPSE-13: an example's params as a synapse command line: each param+-- its flag (required first, in the method's order), a list its values joined+-- by commas (an element holding one double-quoted, SYNAPSE-2), an object its dotted fields+-- (@--value.type choice@); params a flag cannot carry (a list inside an+-- object, SYNAPSE-9) go whole as @-p '{…}'@, before the backend.+renderExample :: Text -> Text -> MethodDef -> Value -> Text+renderExample program target md params = case params of+ Object o -> case mapM flagsOf (ordered o) of+ Just groups -> T.unwords ([program, target] ++ concat groups)+ Nothing -> whole+ _ -> whole+ where+ whole = T.unwords [program, "-p", shellQuote (TE.decodeUtf8 (LBS.toStrict (Aeson.encode params))), target]+ -- required first, then optional, each in the method's order+ order = zip (map pdName (sortOn (not . pdRequired) (mdParams md))) [0 :: Int ..]+ ordered o = sortOn (\(k, _) -> (fromMaybe maxBound (lookup (K.toText k) order), K.toText k)) (KM.toList o)+ flagsOf (k, v) = flags True ("--" <> kebab (K.toText k)) v+ flags _ _ Null = Just []+ -- an object's fields, its discriminator (@type@) first+ flags _ f (Object o) =+ concat <$> mapM (\(k, v) -> flags False (f <> "." <> kebab (K.toText k)) v)+ (sortOn (\(k, _) -> (K.toText k /= "type", K.toText k)) (KM.toList o))+ flags top f (Array xs)+ | not top = Nothing -- a list inside an object: not as flags (SYNAPSE-9)+ | V.null xs = Just []+ | otherwise = do+ scalars <- mapM scalar (V.toList xs)+ -- an element holding a comma is double-quoted inside the value+ let element x = if T.isInfixOf "," x || T.isPrefixOf "\"" x || T.null x || T.strip x /= x+ then "\"" <> T.replace "\"" "\"\"" x <> "\""+ else x+ pure [f, shellQuote (T.intercalate "," (map element scalars))]+ flags _ f v = (\x -> [f, shellQuote x]) <$> scalar v+ scalar = \case+ String t -> Just t+ Number n -> Just (either (T.pack . show) (T.pack . show) (floatingOrInteger n :: Either Double Integer))+ Bool b -> Just (if b then "true" else "false")+ _ -> Nothing++-- | A word as the shell reads it: bare when it is plain, else in single+-- quotes.+shellQuote :: Text -> Text+shellQuote t+ | not (T.null t), T.all plain t = t+ | Just inner <- T.stripPrefix "<" t >>= T.stripSuffix ">", not (T.null inner), T.all plain inner = t+ | T.any (== '\'') t, not (T.any (`elem` ("\"$`\\!" :: String)) t) = "\"" <> t <> "\""+ | otherwise = "'" <> T.replace "'" "'\\''" t <> "'"+ where plain c = isAlphaNum c || c `elem` ("_@%+=:,./-" :: String)+ -- | Render a parameter as a block (may be multi-line for complex types). -- -- IR-14: when @pdDeprecation param@ is @Just DeprecationInfo@ the@@ -142,9 +449,9 @@ reqText = if pdRequired param then "(required)" else "(optional)" headerLine = indent' <> flagName <> " <" <> typeStr <> "> " <> reqText - -- Description line+ -- Description, wrapped in its column descLines = case pdDescription param of- Just desc -> [indent2 <> desc]+ Just desc -> map (indent2 <>) (wrapWords (80 - T.length indent2) desc) Nothing -> [] -- Deprecation detail line (IR-14). Indented one level beyond the@@ -153,12 +460,36 @@ Just di -> [indent2 <> formatDeprecationLine di] Nothing -> [] + -- How to pass several elements of a list (SYNAPSE-2)+ listLines = case listElement (pdType param) of+ Just inner+ | isScalarElement ir inner ->+ [indent2 <> "Repeat the flag or use commas: " <> rawFlag <> " a,b"]+ | otherwise -> [indent2 <> "Repeat the flag for each element"]+ Nothing -> []+ listElement = \case+ RefArray inner -> Just inner+ RefOptional inner -> listElement inner+ _ -> Nothing+ -- Type expansion (for enums/unions) expansionLines = if hsExpandEnums style then map (indent2 <>) (expandType ir (pdName param) (pdType param)) else [] - in [""] ++ [headerLine] ++ descLines ++ deprecationLines ++ expansionLines+ in [""] ++ [headerLine] ++ descLines ++ deprecationLines ++ listLines ++ expansionLines++-- | Words wrapped greedily to a width (at least 20).+wrapWords :: Int -> Text -> [Text]+wrapWords w0 = go . T.words+ where+ w = max 20 w0+ go [] = []+ go (x : xs) = line x xs+ line acc [] = [acc]+ line acc (y : ys)+ | T.length acc + 1 + T.length y <= w = line (acc <> " " <> y) ys+ | otherwise = acc : line y ys -- ============================================================================ -- Parameter Help
src/Synapse/CLI/Parse.hs view
@@ -42,19 +42,50 @@ -- -> [("tags", [("", "backend"), ("", "critical"), ("", "urgent")])] -- -> {"tags": ["backend", "critical", "urgent"]} -- @+--+-- For a list of scalars, each flag value is also a comma-separated record+-- (see 'splitListValue'):+--+-- @+-- --tags backend,critical --tags '"a,b"'+-- -> {"tags": ["backend", "critical", "a,b"]}+-- @+--+-- = Command line+--+-- The pairs come from the command line in two passes. 'parsePathAndParams'+-- runs before the method is known and finds the path; 'pairFlags' runs once+-- it is, and uses each parameter's type to decide whether a flag takes the+-- argument after it, so a value may begin with dashes:+--+-- @+-- --summary --body "--- id: X" --limit=3 (summary a boolean)+-- -> [("summary", "true"), ("body", "--- id: X"), ("limit", "3")]+-- @ module Synapse.CLI.Parse- ( -- * Parsing- parseParams+ ( -- * Command line+ parsePathAndParams+ , pairFlags++ -- * Parsing+ , parseParams , ParseError(..) + -- * List values+ , isScalarElement+ , splitListValue+ -- * Helpers (exposed for testing) , groupByPrefix , buildParamValue ) where +import Control.Monad (foldM) import Data.Aeson (Value(..), object) import qualified Data.Aeson.Key as K+import Data.Either (partitionEithers) import Data.List (find)+import Data.Maybe (fromMaybe) import Data.Map.Strict (Map) import qualified Data.Map.Strict as Map import Data.Text (Text)@@ -86,9 +117,144 @@ -- ^ Unknown variant name (param name, provided value, valid variants, suggestions) | TypeNotFound Text -- ^ Referenced type not found in IR+ | MalformedListValue Text Text Text+ -- ^ List flag value with broken quoting (param name, flag value, reason)+ | UnexpectedArgument Text+ -- ^ Argument that no flag takes as its value deriving (Show, Eq) -- ============================================================================+-- Command Line+-- ============================================================================++-- | Whether a flag written without @=@ takes the argument after it+data FlagArity+ = TakesNext -- ^ Always, whatever it begins with: the parameter takes a value+ | TakesBoolean -- ^ Only when it reads as a boolean: the parameter is a boolean+ | TakesUnlessFlag -- ^ Unless it begins with @--@ (and is not @--@): no parameter is known++-- | Split the arguments after the backend into path segments, (key, value)+-- pairs, the flag arguments the pairs came from, and whether help was asked+-- for.+--+-- This runs before the method is known, so a flag takes the next argument+-- unless that begins with @--@ and is not exactly @--@. That is enough to find+-- the path. Once the method is known, 'pairFlags' pairs the flag arguments+-- again by type; the pairs returned here serve only where there is no method+-- schema (@_self@, @respond@, a method missing from the IR).+--+-- Example: ["echo", "once", "--message", "hello", "--count", "3"]+-- -> (["echo", "once"], [("message", "hello"), ("count", "3")],+-- ["--message", "hello", "--count", "3"], False)+-- Example: ["echo", "once", "--help"]+-- -> (["echo", "once"], [], [], True)+parsePathAndParams :: [Text] -> ([Text], [(Text, Text)], [Text], Bool)+parsePathAndParams = go [] [] [] False+ where+ go path params flags helpReq [] = (reverse path, reverse params, reverse flags, helpReq)+ go path params flags helpReq (x:xs)+ -- --help flag: mark help requested, don't add as param+ | x == "--help" || x == "-h" =+ go path params flags True xs+ -- --key value, --key=value, or a bare --key+ | Just flag <- T.stripPrefix "--" x+ , not (T.null flag) =+ let (key, val, n) = takeFlag (const TakesUnlessFlag) flag xs+ (taken, rest) = splitAt n xs+ in go path ((normalizeKey key, fromMaybe "" val) : params)+ (reverse (x : taken) ++ flags) helpReq rest+ -- Regular path segment - split on dots to support Plexus RPC path syntax (e.g., cone.chat)+ | otherwise =+ let segments = map (T.replace "-" "_") $ filter (not . T.null) $ T.splitOn "." x+ in go (reverse segments ++ path) params flags helpReq xs++-- | Pair a method's flag arguments (from 'parsePathAndParams') by the types+-- of its parameters:+--+-- * @--key=value@: everything after the first @=@, empty or not+-- * a boolean: @true@ when bare; it takes the next argument only when that+-- reads as a boolean (@true@, @false@, @1@, @0@), so @--summary --limit 3@+-- is two flags+-- * any other parameter: the next argument, whatever it begins with+-- (@---@, @--force@, @-5@, @--@)+-- * a flag that names no parameter: as in 'parsePathAndParams'; its key+-- stays as typed, so 'UnknownParam' names it so+--+-- Keys are normalized (kebab-case to snake_case); values never are. An+-- argument that no flag takes is an 'UnexpectedArgument'.+pairFlags :: IR -> MethodDef -> [Text] -> Either [ParseError] [(Text, Text)]+pairFlags ir method args = case partitionEithers (go args) of+ ([], pairs) -> Right pairs+ (errs, _) -> Left errs+ where+ go [] = []+ go (x:xs) = case T.stripPrefix "--" x of+ Just flag | not (T.null flag) ->+ let (typed, val, n) = takeFlag arity flag xs+ key = normalizeKey typed+ pair = case arity key of+ TakesNext -> (key, fromMaybe "" val)+ TakesBoolean -> (key, fromMaybe "true" val)+ TakesUnlessFlag -> (typed, fromMaybe "" val)+ in Right pair : go (drop n xs)+ _ -> Left (UnexpectedArgument x) : go xs++ arity = flagArity ir method++-- | Read one flag (the text after @--@) against the arguments after it:+-- its key as typed, its value ('Nothing' when bare), and how many of those+-- arguments it took (0 or 1).+takeFlag :: (Text -> FlagArity) -> Text -> [Text] -> (Text, Maybe Text, Int)+takeFlag arity flag next+ | (key, eqValue) <- T.breakOn "=" flag, not (T.null eqValue) =+ (key, Just (T.drop 1 eqValue), 0)+ | otherwise = case (arity (normalizeKey flag), next) of+ (TakesNext, val : _) -> (flag, Just val, 1)+ (TakesBoolean, val : _) | isBooleanWord val -> (flag, Just val, 1)+ (TakesUnlessFlag, val : _) | val == "--" || not ("--" `T.isPrefixOf` val) -> (flag, Just val, 1)+ _ -> (flag, Nothing, 0)+ where+ -- The spellings 'inferTypedValue' reads as a boolean+ isBooleanWord val = T.toLower val `elem` ["true", "false", "1", "0"]++-- | How the flag with this (normalized) key takes its value: by the type of+-- the parameter it names, or of the field a dotted key names+flagArity :: IR -> MethodDef -> Text -> FlagArity+flagArity ir method key = case T.splitOn "." key of+ name : fields+ | Just param <- find ((== name) . pdName) (mdParams method) ->+ case foldM (fieldType ir) (pdType param) fields of+ Just ty | isBooleanRef ir ty -> TakesBoolean+ _ -> TakesNext+ _ -> TakesUnlessFlag++-- | The type of a field of a struct, or of any variant of an enum+fieldType :: IR -> TypeRef -> Text -> Maybe TypeRef+fieldType ir ref name = case ref of+ RefOptional inner -> fieldType ir inner name+ RefNamed qn -> case tdKind <$> Map.lookup (qualifiedNameFull qn) (irTypes ir) of+ Just (KindStruct fields) -> fdType <$> find ((== name) . fdName) fields+ Just (KindEnum _ variants) -> fdType <$> find ((== name) . fdName) (concatMap vdFields variants)+ Just (KindAlias target) -> fieldType ir target name+ _ -> Nothing+ _ -> Nothing++-- | Is this a boolean (optional, or a named type that is one)?+isBooleanRef :: IR -> TypeRef -> Bool+isBooleanRef ir = \case+ RefPrimitive typ _ -> typ == "boolean"+ RefOptional inner -> isBooleanRef ir inner+ RefNamed qn -> case tdKind <$> Map.lookup (qualifiedNameFull qn) (irTypes ir) of+ Just (KindPrimitive typ _) -> typ == "boolean"+ Just (KindAlias target) -> isBooleanRef ir target+ _ -> False+ _ -> False++-- | Normalize a flag key from kebab-case to snake_case+normalizeKey :: Text -> Text+normalizeKey = T.replace "-" "_"++-- ============================================================================ -- Main Parsing Function -- ============================================================================ @@ -203,11 +369,18 @@ else buildParamValue ir param{pdType = inner} kvs RefArray innerType ->- -- Collect all repeated --key value entries (no comma splitting - use shell separation)- let values = [val | ("", val) <- kvs]- in if null values+ -- Collect all repeated --key value entries; for a list of scalars each+ -- entry is itself a comma-separated record (objects/lists/any stay whole)+ let rawValues = [val | ("", val) <- kvs]+ elements val+ | isScalarElement ir innerType =+ either (Left . MalformedListValue (pdName param) val) Right (splitListValue val)+ | otherwise = Right [val]+ in if null rawValues then Left $ InvalidValue (pdName param) "array requires at least one value"- else Right $ Array $ V.fromList $ map (buildTypedValue ir innerType) values+ else do+ values <- concat <$> traverse elements rawValues+ Right $ Array $ V.fromList $ map (buildTypedValue ir innerType) values RefAny -> -- Dynamic type, try to parse as JSON or use as string@@ -242,7 +415,7 @@ KindAlias target -> -- Follow the alias- buildParamValue ir ParamDef{pdName = paramName, pdType = target, pdDescription = Nothing, pdRequired = True, pdDefault = Nothing, pdDeprecation = Nothing} kvs+ buildParamValue ir ParamDef{pdName = paramName, pdType = target, pdDescription = Nothing, pdRequired = True, pdDefault = Nothing, pdDeprecation = Nothing, pdSource = Nothing, pdValues = Nothing} kvs KindStringEnum allowedValues -> -- Expect single string value from allowed set@@ -305,6 +478,67 @@ RefArray _ -> inferValue val -- Best effort for arrays RefAny -> inferValue val RefUnknown -> String val++-- ============================================================================+-- List Values+-- ============================================================================++-- | Is this list element type a scalar (string, integer, number, boolean, or+-- a named type that is one of those)? Only lists of scalars split their flag+-- values on commas; objects, lists and untyped values are passed whole.+isScalarElement :: IR -> TypeRef -> Bool+isScalarElement ir = \case+ RefPrimitive typ _ -> typ `elem` scalarTypes+ RefNamed qn -> case Map.lookup (qualifiedNameFull qn) (irTypes ir) of+ Just TypeDef{tdKind = KindPrimitive typ _} -> typ `elem` scalarTypes+ Just TypeDef{tdKind = KindStringEnum _} -> True+ Just TypeDef{tdKind = KindAlias target} -> isScalarElement ir target+ _ -> False+ RefOptional inner -> isScalarElement ir inner+ RefArray _ -> False+ RefAny -> False+ RefUnknown -> False+ where+ scalarTypes = ["string", "integer", "number", "boolean"]++-- | Split one flag value of a scalar list into its elements.+--+-- The value is one comma-separated record. Unquoted fields are trimmed and+-- dropped when empty. A field starting with @"@ runs to its closing quote,+-- keeps its commas and spaces, and reads @""@ inside as one quote.+--+-- @+-- a,b,c -> ["a", "b", "c"]+-- a, b ,c -> ["a", "b", "c"]+-- a,,b, -> ["a", "b"]+-- "a,b","c" -> ["a,b", "c"]+-- "say ""hi""",b -> ["say \"hi\"", "b"]+-- "",a -> ["", "a"]+-- "a,b or "a"x,b -> Left reason+-- @+splitListValue :: Text -> Either Text [Text]+splitListValue = field+ where+ field t = case T.uncons (T.stripStart t) of+ Just ('"', rest) -> quoted "" rest+ _ ->+ let (raw, rest) = T.breakOn "," t+ val = T.strip raw+ in ([val | not (T.null val)] ++) <$> afterComma rest++ -- Inside quotes: a doubled quote is one quote, a single one closes the field+ quoted acc t = case T.breakOn "\"" t of+ (_, "") -> Left "a quoted element has no closing quote"+ (chunk, close) -> case T.uncons (T.drop 1 close) of+ Just ('"', more) -> quoted (acc <> chunk <> "\"") more+ _ -> case T.uncons (T.stripStart (T.drop 1 close)) of+ Nothing -> Right [acc <> chunk]+ Just (',', more) -> ((acc <> chunk) :) <$> field more+ Just _ -> Left "a closing quote must be followed by a comma or the end"++ afterComma rest = case T.uncons rest of+ Nothing -> Right []+ Just (_, more) -> field more -- drop the comma -- ============================================================================ -- Value Inference
+ src/Synapse/CLI/Source.hs view
@@ -0,0 +1,130 @@+{-# LANGUAGE RecordWildCards #-}+-- | SYNAPSE-10: a parameter's value from a file or stdin.+--+-- A flag's value is one shell argument, and Linux caps an argument at 128 KB+-- (@MAX_ARG_STRLEN@), so @--body "$(cat body.md)"@ fails before synapse+-- starts once the body is large. Here a value names its source instead and+-- synapse reads it after it has started, so the only limit left is the+-- backend's own:+--+-- @+-- --body @body.md the file's contents (UTF-8), as they are+-- --body - standard input, read once however many values ask+-- --body @@literal a value that really begins with @ (one @ is dropped)+-- -p @params.json the whole params object from a file; -p - from stdin+-- @+--+-- The rule applies to every flag value of every backend, after the flags are+-- paired and before any other transformation, so @--body=\@body.md@ works+-- too and a path parameter read from a file is still expanded afterwards.+-- A file that cannot be read is a refusal naming the flag, the path and the+-- escape — never a value sent as the literal text @\@body.md@.+module Synapse.CLI.Source+ ( SourceEnv(..)+ , SourceError(..)+ , mkSourceEnv+ , expandSources+ , expandValue+ , stdinWasRead+ , renderSourceError+ ) where++import Control.Exception (IOException, try)+import System.IO.Error (ioeGetErrorString)+import qualified Data.ByteString as BS+import Data.IORef (IORef, newIORef, readIORef, writeIORef)+import Data.Text (Text)+import qualified Data.Text as T+import qualified Data.Text.Encoding as TE+import qualified Data.Text.Encoding.Error as TE+import System.FilePath (isRelative, (</>))+import System.IO (Handle, stdin)++-- | What a source needs: where relative paths resolve, what @~/@ means, how+-- stdin is read (a test hands in its own), and stdin's contents once read.+data SourceEnv = SourceEnv+ { seCwd :: FilePath+ , seHome :: Maybe FilePath+ , seStdin :: IO BS.ByteString+ , seCache :: IORef (Maybe Text)+ }++-- | A source that could not be read: the flag it was for (empty for @-p@),+-- the path as written, and the system's reason.+data SourceError = SourceUnreadable+ { seKey :: Text+ , seSource :: Text+ , seWhy :: Text+ }+ deriving (Show, Eq)++mkSourceEnv :: FilePath -> Maybe FilePath -> IO SourceEnv+mkSourceEnv cwd home = do+ cache <- newIORef Nothing+ pure SourceEnv { seCwd = cwd, seHome = home, seStdin = readToEnd stdin, seCache = cache }++-- | All of a handle, without closing it: 'BS.getContents' semi-closes+-- stdin, and the bidirectional mode's terminal check later throws on a+-- closed handle.+readToEnd :: Handle -> IO BS.ByteString+readToEnd h = go []+ where+ go acc = do+ chunk <- BS.hGetSome h 65536+ if BS.null chunk then pure (BS.concat (reverse acc)) else go (chunk : acc)++-- | Whether some value has taken stdin: the bidirectional JSON mode reads it+-- too, and the two cannot share.+stdinWasRead :: SourceEnv -> IO Bool+stdinWasRead env = maybe False (const True) <$> readIORef (seCache env)++-- | Every flag value through 'expandValue'; the first failure wins.+expandSources :: SourceEnv -> [(Text, Text)] -> IO (Either SourceError [(Text, Text)])+expandSources env = go []+ where+ go acc [] = pure (Right (reverse acc))+ go acc ((k, v) : rest) = do+ r <- expandValue env k v+ case r of+ Left e -> pure (Left e)+ Right v' -> go ((k, v') : acc) rest++-- | One value: @-@ is stdin, @\@\@x@ is the literal @\@x@, @\@path@ is that+-- file, anything else is itself.+expandValue :: SourceEnv -> Text -> Text -> IO (Either SourceError Text)+expandValue env@SourceEnv{..} key val+ | val == "-" = Right <$> readStdinOnce env+ | "@@" `T.isPrefixOf` val = pure (Right (T.drop 1 val))+ | Just path <- T.stripPrefix "@" val, not (T.null path) = do+ r <- try (BS.readFile (resolve path)) :: IO (Either IOException BS.ByteString)+ pure $ case r of+ Left e -> Left SourceUnreadable { seKey = key, seSource = val, seWhy = T.pack (ioeGetErrorString e) }+ Right bytes -> Right (decode bytes)+ | otherwise = pure (Right val)+ where+ resolve p = case (T.unpack p, seHome) of+ ('~' : '/' : rest, Just home) -> home </> rest+ (fp, _) | isRelative fp -> seCwd </> fp+ | otherwise -> fp++readStdinOnce :: SourceEnv -> IO Text+readStdinOnce SourceEnv{..} = do+ cached <- readIORef seCache+ case cached of+ Just t -> pure t+ Nothing -> do+ t <- decode <$> seStdin+ writeIORef seCache (Just t)+ pure t++decode :: BS.ByteString -> Text+decode = TE.decodeUtf8With TE.lenientDecode++-- | The refusal, with the escape for a value that really begins with @.+renderSourceError :: SourceError -> Text+renderSourceError SourceUnreadable{..} =+ flag <> seSource <> ": cannot read " <> T.drop 1 seSource <> " (" <> seWhy <> "). "+ <> "A value that begins with @ is read from that file; write @@" <> T.drop 1 seSource+ <> " to send the text " <> seSource <> " itself."+ where+ flag = if T.null seKey then "-p " else "--" <> T.replace "_" "-" seKey <> " "
src/Synapse/CLI/Transform.hs view
@@ -11,8 +11,10 @@ -- @ -- Command line args -- ↓--- parsePathAndParams (extract --key value pairs)+-- parsePathAndParams (find the path and the flag arguments) -- ↓+-- pairFlags (pair --key value / --key=value by parameter type)+-- ↓ -- [(Text, Text)] raw params -- ↓ -- transformParams (THIS MODULE - middleware chain)@@ -190,17 +192,24 @@ -- Environment Variable Expansion -- ============================================================================ --- | Expand environment variables in parameter values+-- | Expand environment variables in path parameter values -- -- Handles:--- - @--value $VAR@ → @--value \<env_value\>@--- - @--message \"Hello $USER\"@ → @--message \"Hello alice\"@+-- - @--path $PROJECT/src@ → @--path \/home\/user\/proj\/src@ --+-- Path parameters only (SYNAPSE-6), as the README has always said. Any+-- other value is sent exactly as it arrived: a shell has already expanded+-- every @$VAR@ the caller left unquoted or double-quoted, so the only+-- @$@ that reaches synapse is one the caller protected on purpose —+-- single quotes, a value read from a file (@--body "$(cat body.md)"@).+-- Expanding it again rewrote text the caller meant literally: a ticket+-- body mentioning @$HOME@ was sent as @\/home\/alice@.+-- -- Simple implementation: only expands $VARNAME format (not ${VAR}). -- Fail-safe: Returns original value if variable not found. envExpansion :: Transformer envExpansion TransformEnv{..} (key, val)- | T.any (== '$') val = do+ | isPathParam key && T.any (== '$') val = do let expanded = expandEnvVars teEnv val pure (key, expanded) | otherwise = pure (key, val)@@ -282,8 +291,8 @@ -- | Inject "true" for boolean flags that were present but had no value ----- When a user writes @--force@ without a value, parsePathAndParams marks it--- as @("force", "")@. This function looks at the method definition to determine+-- A bare @--force@ is already @("force", "true")@ from @pairFlags@; @--force=@+-- arrives as @("force", "")@. This function looks at the method definition to determine -- which empty-value params are actually booleans, and fills them with "true". -- -- Non-boolean params with empty values are left as-is and will fail validation
+ src/Synapse/Endpoint.hs view
@@ -0,0 +1,204 @@+-- | SCAFFOLD-21: where a hub is reached — a host and port, and, for a hub+-- behind TLS (every hosted app: @wss://<app>.menger.sh/ws@), the scheme+-- and the path its socket answers at.+--+-- One value, from the command line (@--url@, or @-H@ / @-P@ with @--tls@+-- and @--path@), carried to every dial: the probe, the call, the stream and+-- its reconnects, the schema, a bidirectional response, @_login@ and the+-- session a call carries. The store keys a session by 'addressKey': a+-- plain @ws://HOST:PORT/@ keeps the @HOST:PORT@ it always had (no stored+-- session moves), anything else is keyed by its whole URL, so a @ws@ and a+-- @wss@ backend on one host, or two paths, never share a session.+module Synapse.Endpoint+ ( Endpoint(..)+ , plainAt+ , isPlain+ , parseUrl+ , normalisePath+ , renderUrl+ , shortUrl+ , addressKey+ , dirName+ , audience+ , cliFlags+ , cliOptions+ , defaultHost+ , defaultPort+ , substrateConfig+ ) where++import qualified Data.ByteString as BS+import Data.Char (isAlphaNum, isDigit, ord, toLower)+import Data.Text (Text)+import qualified Data.Text as T+import qualified Data.Text.Encoding as TE+import qualified Network.WebSockets as WS+import Numeric (showHex)++import Plexus.Client (SubstrateConfig(..), TlsOptions(..))++-- | A hub's address.+data Endpoint = Endpoint+ { epHost :: !Text+ , epPort :: !Int+ , epTls :: !Bool -- ^ @wss://@: TLS, the certificate verified+ , epPath :: !Text -- ^ the socket's path, @/@ when none is given+ , epCaFiles :: ![FilePath] -- ^ PEM files trusted beside the system's (@--cacert@)+ }+ deriving stock (Eq, Ord, Show)++-- | @ws://HOST:PORT/@ — what @-H@ / @-P@ have always named.+plainAt :: Text -> Int -> Endpoint+plainAt h p = Endpoint { epHost = h, epPort = p, epTls = False, epPath = "/", epCaFiles = [] }++-- | Is this the plain @ws://HOST:PORT/@ every address was before+-- SCAFFOLD-21? (Then everything keyed by it is keyed as it was.)+isPlain :: Endpoint -> Bool+isPlain ep = not (epTls ep) && epPath ep == "/"++-- | Parse @ws://HOST[:PORT][/PATH]@ or @wss://HOST[:PORT][/PATH]@. The+-- port defaults to the scheme's (80, 443); the path to @/@. An IPv6+-- literal is bracketed (@wss://[::1]:8443/ws@). A query or fragment is+-- refused: a Plexus socket is named by its path alone.+parseUrl :: Text -> Either Text Endpoint+parseUrl raw0 = do+ let raw = T.strip raw0+ (tls, rest) <- case T.breakOn "://" raw of+ (scheme, r) | not (T.null r) -> case T.toLower scheme of+ "ws" -> Right (False, T.drop 3 r)+ "wss" -> Right (True, T.drop 3 r)+ "http" -> Left ("--url takes ws:// or wss://, not http:// (the socket of an http page is ws://" <> T.drop 3 r <> ")")+ "https" -> Left ("--url takes ws:// or wss://, not https:// (the socket of an https page is wss://" <> T.drop 3 r <> ")")+ other -> Left ("--url takes ws:// or wss://, not " <> other <> "://")+ _ -> Left ("--url needs a scheme: ws://" <> raw <> " or wss://" <> raw)+ let (authority, pathPart) = T.breakOn "/" rest+ if T.any (`elem` ['?', '#']) pathPart || T.any (`elem` ['?', '#']) authority+ then Left "--url takes no query or fragment: a socket is named by its path"+ else Right ()+ if T.any (== '@') authority+ then Left "--url takes no user:password@ — a credential goes in --token-file or the stored session"+ else Right ()+ (host, port) <- splitAuthority (if tls then 443 else 80) authority+ pure Endpoint+ { epHost = host+ , epPort = port+ , epTls = tls+ , epPath = normalisePath pathPart+ , epCaFiles = []+ }++-- | @HOST[:PORT]@ or @[V6][:PORT]@.+splitAuthority :: Int -> Text -> Either Text (Text, Int)+splitAuthority dflt a+ | T.null a = Left "--url names no host"+ | Just inner <- T.stripPrefix "[" a =+ case T.breakOn "]" inner of+ (h, r) | not (T.null r), not (T.null h) -> case T.drop 1 r of+ "" -> Right (h, dflt)+ p | Just digits <- T.stripPrefix ":" p -> (,) h <$> port digits+ _ -> Left ("--url: cannot read the host " <> a)+ _ -> Left ("--url: cannot read the host " <> a)+ | otherwise = case T.breakOnEnd ":" a of+ ("", _) -> Right (T.toLower a, dflt)+ (h, p) -> (,) (T.toLower (T.dropEnd 1 h)) <$> port p+ where+ port digits+ | not (T.null digits), T.all isDigit digits, T.length digits <= 5+ , n <- read (T.unpack digits), n >= 1, n <= 65535 = Right n+ | otherwise = Left ("--url: the port '" <> digits <> "' is not a port")++-- | A path as a socket is named by it: a leading @/@, no trailing one+-- (@ws/@ → @/ws@; empty → @/@).+normalisePath :: Text -> Text+normalisePath p =+ let trimmed = T.dropWhileEnd (== '/') (T.dropWhile (== '/') (T.strip p))+ in "/" <> trimmed++-- | The URL, with the port always written (@wss://host:443/ws@) — the form+-- the store keys by.+renderUrl :: Endpoint -> Text+renderUrl ep = scheme ep <> "://" <> hostPart (epHost ep) <> ":" <> T.pack (show (epPort ep)) <> epPath ep++scheme :: Endpoint -> Text+scheme ep = if epTls ep then "wss" else "ws"++hostPart :: Text -> Text+hostPart h = if T.any (== ':') h then "[" <> h <> "]" else h++-- | What a session is kept under: @HOST:PORT@ for a plain address (as+-- every session before SCAFFOLD-21 was), the whole URL otherwise.+addressKey :: Endpoint -> Text+addressKey ep+ | isPlain ep = epHost ep <> ":" <> T.pack (show (epPort ep))+ | otherwise = renderUrl ep++-- | The directory name under @~/.plexus/sessions/@: @HOST_PORT@ for a+-- plain address (unchanged), else @wss_HOST_PORT@ (or @ws_…@) and the path+-- with each @/@ as @_@ — any other byte outside @[A-Za-z0-9.-]@ written+-- @%XX@, so two addresses never share one.+dirName :: Endpoint -> FilePath+dirName ep+ | isPlain ep = T.unpack (epHost ep) <> "_" <> show (epPort ep)+ | otherwise =+ T.unpack (scheme ep) <> "_" <> escape (T.unpack (epHost ep)) <> "_" <> show (epPort ep)+ <> concatMap pathChar (T.unpack (T.dropWhileEnd (== '/') (epPath ep)))+ where+ escape = concatMap safe+ safe c | isAlphaNum c && ord c < 128 || c `elem` ['.', '-'] = [c]+ | otherwise = pct c+ pathChar '/' = "_"+ pathChar c = safe c+ pct c = concatMap (\b -> '%' : hex2 b) (BS.unpack (TE.encodeUtf8 (T.singleton c)))+ hex2 b = let h = map toLower (showHex b "") in if length h < 2 then '0' : h else h++-- | A key proof's audience (IDENTITY-40's @aud@): the server's socket+-- origin, as the server lists the URLs it is dialed at — @ws://HOST:PORT@+-- for a plain address (unchanged), @wss://HOST@ (@:PORT@ when not 443)+-- behind TLS, which is what a hub run with @--public-url https://HOST@+-- accepts (plexus-login's @socket_urls@). The path is not part of it.+audience :: Endpoint -> Text+audience ep+ | epTls ep = "wss://" <> hostPart (epHost ep) <> (if epPort ep == 443 then "" else ":" <> T.pack (show (epPort ep)))+ | otherwise = "ws://" <> epHost ep <> ":" <> T.pack (show (epPort ep))++-- | The global options that reach this address, as synapse's own help and+-- messages write them: @-P 4455@, @-H host -P 4455@, @--url wss://host/ws@+-- (and @--cacert FILE@ for each local CA). Empty for the default address.+cliFlags :: Text -> Int -> Endpoint -> Text+cliFlags defaultHost defaultPort ep+ | isPlain ep = T.unwords $+ (if epHost ep /= defaultHost then ["-H", epHost ep] else [])+ <> (if epPort ep /= defaultPort then ["-P", T.pack (show (epPort ep))] else [])+ | otherwise = T.unwords $ ["--url", shortUrl ep] <> concatMap (\f -> ["--cacert", T.pack f]) (epCaFiles ep)++-- | The URL as a person writes it: the scheme's default port and a bare+-- @/@ left out (@wss://hostdemo.menger.sh/ws@).+shortUrl :: Endpoint -> Text+shortUrl ep = scheme ep <> "://" <> hostPart (epHost ep)+ <> (if epPort ep == dflt then "" else ":" <> T.pack (show (epPort ep)))+ <> (if epPath ep == "/" then "" else epPath ep)+ where dflt = if epTls ep then 443 else 80++-- | The connection point when none is named: the registry's, @ws://127.0.0.1:4444/@.+defaultHost :: Text+defaultHost = "127.0.0.1"++defaultPort :: Int+defaultPort = 4444++-- | 'cliFlags' against the default address — what a message tells the+-- reader to type (@synapse -P 4455 _login …@, @synapse --url wss://h/ws _login …@).+cliOptions :: Endpoint -> Text+cliOptions = cliFlags defaultHost defaultPort++-- | The plexus-protocol config that dials this address for @backend@,+-- carrying @headers@ on the upgrade.+substrateConfig :: Endpoint -> Text -> WS.Headers -> SubstrateConfig+substrateConfig ep backend headers = SubstrateConfig+ { substrateHost = T.unpack (epHost ep)+ , substratePort = epPort ep+ , substratePath = T.unpack (epPath ep)+ , substrateBackend = backend+ , substrateHeaders = headers+ , substrateTls = if epTls ep then Just (TlsOptions (epCaFiles ep)) else Nothing+ }
+ src/Synapse/Find.hs view
@@ -0,0 +1,657 @@+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE RecordWildCards #-}+{-# LANGUAGE LambdaCase #-}++-- | SYNAPSE-17: @synapse _find <backend> [activation…] <words…>@ — a fuzzy+-- search over a backend's methods and parameters, answered in the shape of+-- synapse's help (SYNAPSE-13): grouped, aligned, every line a command.+--+-- * The catalog is the backend's IR (the caller's view of its schema),+-- kept in synapse's IR cache under the root schema's hash: a search+-- costs one schema call — the hash check — when nothing has changed.+-- * A word matches a name exactly, as a prefix (of the name or of one of+-- its @snake_case@ segments), as a subsequence of segment prefixes+-- (@setpar@ → @set_parent@), or with a typo (edit distance ≤ 1 for 4–7+-- letters, ≤ 2 beyond); else as a word (or a word's prefix) of a+-- description. Every word must match.+-- * A hit whose words all match names is a METHOD or a PARAMETER; one that+-- needs a description is MENTIONED IN — unless its method is a named hit+-- already.+module Synapse.Find+ ( -- * The command+ FindOpts(..)+ , findInfo+ , defaultLimit++ -- * The catalog+ , loadCatalog+ , Entry(..)+ , EntryKind(..)+ , catalogEntries+ , narrowPath+ , entryLocation++ -- * Matching+ , MatchKind(..)+ , WordMatch(..)+ , matchName+ , matchDescription+ , typoLimit+ , kindScore++ -- * Search+ , Hit(..)+ , Where(..)+ , Group(..)+ , Only(..)+ , search+ , hitGroup+ , takenAs++ -- * The answer+ , Answer(..)+ , answer++ -- * Rendering+ , FindRender(..)+ , renderFind+ , renderNoMatch+ , nearest+ , hitJson+ ) where++import Control.Exception (SomeException, try)+import Control.Monad.IO.Class (liftIO)+import Control.Monad.Reader (asks)+import Data.Aeson (Value, object, (.=))+import Data.Char (isAlphaNum)+import Data.List (sortOn, foldl')+import qualified Data.Map.Strict as Map+import Data.Maybe (fromMaybe, listToMaybe, mapMaybe)+import qualified Data.Set as Set+import Data.Text (Text)+import qualified Data.Text as T+import Options.Applicative++import Synapse.Algebra.Render (fitText, summarize)+import Synapse.CLI.Help (flagLabel, flagType, paramValuesOf, serverSourced, wrapWords)+import Synapse.CLI.Similarity (levenshteinDistance)+import Synapse.IR.Builder (buildIR)+import Synapse.IR.Cache (IRCache, insertIR, irCacheKey, lookupIR)+import Synapse.IR.Types+import Synapse.Monad (SynapseM, SynapseEnv(..))+import Synapse.Schema.Types (psHash)+import Synapse.Transport (fetchSchemaAt)++-- ============================================================================+-- The command+-- ============================================================================++-- | What @_find@ was asked.+data FindOpts = FindOpts+ { foBackend :: Text -- ^ the backend, e.g. menger+ , foTerms :: [Text] -- ^ an activation path (optional), then the words+ , foLimit :: Int -- ^ at most this many hits+ , foMethods :: Bool -- ^ only METHODS+ , foParams :: Bool -- ^ only PARAMETERS+ }+ deriving stock (Show, Eq)++defaultLimit :: Int+defaultLimit = 20++findInfo :: ParserInfo FindOpts+findInfo = info (p <**> helper)+ ( fullDesc+ <> header "synapse _find - search a backend's methods and parameters"+ <> progDesc "Every method and parameter whose name or description matches all the words, best first, in help's shape: METHODS, PARAMETERS, MENTIONED IN (a match only in a description). A word may be misspelled (one edit for 4-7 letters, two beyond), a prefix, or an abbreviation of a snake_case name (setpar finds set_parent). Activation names before the words narrow the search (_find menger tickets setpar). Global options (-H, -P, -j) go before _find; -j prints one JSON object per hit."+ )+ where+ p = FindOpts+ <$> (T.pack <$> strArgument (metavar "BACKEND" <> help "The hub's name, e.g. menger"))+ <*> some (T.pack <$> strArgument (metavar "WORDS..." <> help "[activation…] then the words to look for"))+ <*> option auto (long "limit" <> metavar "N" <> value defaultLimit <> showDefault <> help "At most N hits")+ <*> switch (long "methods" <> help "Only methods")+ <*> switch (long "params" <> help "Only parameters")++-- ============================================================================+-- The catalog+-- ============================================================================++-- | The backend's IR, as the caller's view of its schema has it: the root+-- schema is fetched (the hash check), and the IR kept under that hash —+-- qualified by the caller's view (AUTH-8) — is used when there is one;+-- else the tree is walked once and the IR kept for next time. A cache that+-- cannot be read or written is no cache.+loadCatalog :: Maybe IRCache -> SynapseM IR+loadCatalog mcache = do+ root <- fetchSchemaAt []+ view <- asks seViewKey+ let key = "find-" <> irCacheKey view (psHash root)+ cached <- case mcache of+ Nothing -> pure Nothing+ Just c -> liftIO (either (const Nothing) id <$> (try (lookupIR c key) :: IO (Either SomeException (Maybe IR))))+ case cached of+ Just ir -> pure ir+ Nothing -> do+ ir <- buildIR [] []+ case mcache of+ Nothing -> pure ()+ Just c -> liftIO (() <$ (try (insertIR c key ir) :: IO (Either SomeException ())))+ pure ir++data EntryKind = MethodEntry | ParamEntry+ deriving stock (Show, Eq, Ord)++-- | One thing a search can find: a method, or one of its parameters.+data Entry = Entry+ { enKind :: EntryKind+ , enMethod :: MethodDef+ , enParam :: Maybe ParamDef+ }+ deriving stock (Show, Eq)++-- | Every method of the IR, and every parameter a caller passes (one the+-- server supplies, REQ-8, is not a flag), under the activation path.+catalogEntries :: [Text] -> IR -> [Entry]+catalogEntries path ir =+ concat+ [ Entry MethodEntry m Nothing+ : [Entry ParamEntry m (Just p) | p <- mdParams m, not (serverSourced p)]+ | m <- Map.elems (irMethods ir)+ , under (mdNamespace m)+ ]+ where+ prefix = T.intercalate "." path+ under ns = null path || ns == prefix || (prefix <> ".") `T.isPrefixOf` ns++-- | The leading terms that name an activation (as help's path does), and+-- the words after them; at least one word is always left.+narrowPath :: IR -> [Text] -> ([Text], [Text])+narrowPath ir = go []+ where+ namespaces = Set.fromList (map mdNamespace (Map.elems (irMethods ir)))+ isPath segs =+ let p = T.intercalate "." segs+ in any (\ns -> ns == p || (p <> ".") `T.isPrefixOf` ns) (Set.toList namespaces)+ go acc (t : rest@(_ : _))+ | isPath (acc ++ [t]) = go (acc ++ [t]) rest+ go acc rest = (acc, rest)++-- | Where an entry is: its activation path and method, as typed+-- (@tickets snooze@).+entryLocation :: Entry -> Text+entryLocation = methodLocation . enMethod++methodLocation :: MethodDef -> Text+methodLocation m = T.unwords (filter (not . T.null) [T.replace "." " " (mdNamespace m), mdName m])++-- ============================================================================+-- Matching+-- ============================================================================++-- | How a word matched, best first.+data MatchKind+ = Exact -- ^ the whole name+ | Prefix -- ^ the start of the name+ | SegmentExact -- ^ one @snake_case@ segment of it+ | SegmentPrefix -- ^ the start of a segment+ | Subsequence -- ^ prefixes of segments, in order (@setpar@)+ | Typo Int -- ^ within this many edits of the name or a segment's start+ | InDescription -- ^ a word of a description, or its start+ deriving stock (Show, Eq)++kindScore :: MatchKind -> Double+kindScore = \case+ Exact -> 1.0+ Prefix -> 0.9+ SegmentExact -> 0.85+ SegmentPrefix -> 0.8+ Subsequence -> 0.6+ Typo d -> 0.55 - 0.1 * fromIntegral (d - 1)+ InDescription -> 0.3++-- | A word's match: how, what it was taken as, and which characters of the+-- text it matched (offset, length).+data WordMatch = WordMatch+ { wmKind :: MatchKind+ , wmTaken :: Text+ , wmSpans :: [(Int, Int)]+ }+ deriving stock (Show, Eq)++-- | The edits a word of this length may be off by: none under 4 letters,+-- one for 4–7, two beyond.+typoLimit :: Int -> Int+typoLimit n+ | n < 4 = 0+ | n <= 7 = 1+ | otherwise = 2++-- | A name's segments and where each starts (@set_parent@ → set@0, parent@4).+segments :: Text -> [(Int, Text)]+segments name = go 0 (T.unpack name)+ where+ go _ [] = []+ go off s =+ let (seg, rest) = break isSep s+ off' = off + length seg+ in case rest of+ [] -> [(off, T.pack seg) | not (null seg)]+ (_ : rest') -> [(off, T.pack seg) | not (null seg)] ++ go (off' + 1) rest'+ isSep c = c `elem` ("_-." :: String)++-- | Match a word (lower case) against a name.+matchName :: Text -> Text -> Maybe WordMatch+matchName w0 name0+ | T.null w = Nothing+ | w == name = Just (WordMatch Exact name0 [(0, T.length w)])+ | w `T.isPrefixOf` name = Just (WordMatch Prefix w [(0, T.length w)])+ | Just (off, _) <- find' (\(_, s) -> s == w) segs = Just (WordMatch SegmentExact w [(off, T.length w)])+ | Just (off, _) <- find' (\(_, s) -> w `T.isPrefixOf` s) segs = Just (WordMatch SegmentPrefix w [(off, T.length w)])+ | Just spans <- subsequence (T.unpack w) segs = Just (WordMatch Subsequence name0 spans)+ | otherwise = typo+ where+ w = normalise w0+ name = T.toLower name0+ segs = segments name+ find' f = listToMaybe . filter f++ -- prefixes of segments, in order, at least two of them+ subsequence ws ss = case pieces ws ss of+ Just sp@(_ : _ : _) -> Just sp+ _ -> Nothing+ pieces [] _ = Just []+ pieces _ [] = Nothing+ pieces ws ((off, s) : rest) =+ let common = length (takeWhile id (zipWith (==) ws (T.unpack s)))+ tryLen k = ((off, k) :) <$> pieces (drop k ws) rest+ viaThis = foldr (\k acc -> maybe acc Just (tryLen k)) Nothing [common, common - 1 .. 1]+ in case viaThis of+ Just sp -> Just sp+ Nothing -> pieces ws rest++ -- within the limit of the whole name or of a segment's start+ typo+ | k == 0 = Nothing+ | otherwise =+ case sortOn rank candidates of+ ((d, _, off, taken) : _) | d <= k -> Just (WordMatch (Typo d) (orig off (T.length taken)) [(off, T.length taken)])+ _ -> Nothing+ where+ n = T.length w+ k = typoLimit n+ candidates =+ [ (levenshteinDistance w p, T.length c - j, off, p)+ | (off, c) <- (0, name) : segs+ , j <- [max 1 (n - k) .. min (T.length c) (n + k)]+ , let p = T.take j c+ ]+ -- fewest edits; then the whole segment; then the longest+ rank (d, short, _, p) = (d, short, negate (T.length p))+ orig off len = T.take len (T.drop off name0)++-- | A word (lower case) as a word of a description, or a word's start:+-- the matched characters of the text. Words under 3 letters are not+-- looked for in descriptions.+matchDescription :: Text -> Text -> Maybe WordMatch+matchDescription w0 text+ | T.length w < 3 = Nothing+ | null spans = Nothing+ | otherwise = Just (WordMatch InDescription w spans)+ where+ w = normalise w0+ spans = descSpans w text++-- | Where a word starts a word of the text (offset, the word's length).+descSpans :: Text -> Text -> [(Int, Int)]+descSpans w text =+ [ (off, T.length w) | (off, tok) <- tokens text, w `T.isPrefixOf` T.toLower tok ]+ where+ -- a query word with an underscore matches across the text's underscores+ tokens t+ | T.any (== '_') w = wordsBy (\c -> isAlphaNum c || c == '_') t+ | otherwise = wordsBy isAlphaNum t++wordsBy :: (Char -> Bool) -> Text -> [(Int, Text)]+wordsBy keep t = go 0 (T.unpack t)+ where+ go _ [] = []+ go off s@(c : rest)+ | keep c = let (tok, rest') = span keep s+ in (off, T.pack tok) : go (off + length tok) rest'+ | otherwise = go (off + 1) rest++normalise :: Text -> Text+normalise = T.replace "-" "_" . T.toLower++-- ============================================================================+-- Search+-- ============================================================================++-- | Which text a word matched.+data Where = OnMethodName | OnParamName | OnMethodDescription | OnParamDescription+ deriving stock (Show, Eq)++data Hit = Hit+ { hitEntry :: Entry+ , hitWords :: [(Text, Where, WordMatch)] -- ^ per query word+ , hitScore :: Double+ }+ deriving stock (Show, Eq)++data Group = Methods | Parameters | Mentioned+ deriving stock (Show, Eq, Ord, Enum, Bounded)++-- | A named hit is a method or a parameter; one that needed a description+-- is mentioned.+hitGroup :: Hit -> Group+hitGroup h+ | not (all named (hitWords h)) = Mentioned+ | enKind (hitEntry h) == MethodEntry = Methods+ | otherwise = Parameters+ where named (_, wh, _) = wh `elem` [OnMethodName, OnParamName]++-- | Which groups to keep.+data Only = OnlyAll | OnlyMethods | OnlyParams | OnlyBoth+ deriving stock (Show, Eq)++-- | Every hit for the words (all must match), best first: by score, then+-- the shorter path, then alphabetically. A mention of a method that is a+-- named hit already (itself or by a parameter) is left out.+search :: Only -> [Entry] -> [Text] -> [Hit]+search only entries ws = filter keep ranked+ where+ hits = mapMaybe (matchEntry (map normalise ws)) entries+ namedMethods = Set.fromList [mdFullPath (enMethod (hitEntry h)) | h <- hits, hitGroup h /= Mentioned]+ keep h = groupWanted (hitGroup h)+ && not (hitGroup h == Mentioned && Set.member (mdFullPath (enMethod (hitEntry h))) namedMethods)+ groupWanted g = case only of+ OnlyAll -> True+ OnlyMethods -> g == Methods+ OnlyParams -> g == Parameters+ OnlyBoth -> g /= Mentioned+ ranked = sortOn (\h -> (negate (hitScore h), T.length (key h), key h)) hits+ key h = entryLocation (hitEntry h) <> maybe "" ((" --" <>) . pdName) (enParam (hitEntry h))++matchEntry :: [Text] -> Entry -> Maybe Hit+matchEntry ws e = do+ matched <- mapM best ws+ let onParam = any (\(_, wh, _) -> wh `elem` [OnParamName, OnParamDescription]) matched+ if enKind e == ParamEntry && not onParam+ then Nothing+ else Just (Hit e matched (sum [kindScore (wmKind m) | (_, _, m) <- matched] / fromIntegral (length matched)))+ where+ m = enMethod e+ methodDescs = filter (not . T.null) [fromMaybe "" (mdDescription m), fromMaybe "" (mdLongDescription m)]+ best w = fmap (\(wh, wm) -> (w, wh, wm)) $ pick $ case enParam e of+ Nothing ->+ [ (OnMethodName, matchName w (mdName m)) ]+ ++ [ (OnMethodDescription, matchDescription w d) | d <- methodDescs ]+ Just p ->+ [ (OnParamName, matchName w (pdName p))+ , (OnMethodName, matchName w (mdName m)) ]+ ++ [ (OnParamDescription, matchDescription w d) | Just d <- [pdDescription p] ]+ -- the best-scoring; on a tie, the first (the parameter's own name)+ pick cs = foldl' better Nothing [(wh, x) | (wh, Just x) <- cs]+ better Nothing c = Just c+ better (Just a@(_, x)) c@(_, y)+ | kindScore (wmKind y) > kindScore (wmKind x) = Just c+ | otherwise = Just a++-- | The query as the top hit took it — a typo or an abbreviation said as+-- the name it matched — when it differs from what was typed.+takenAs :: [Hit] -> Maybe Text+takenAs [] = Nothing+takenAs (h : _)+ | taken == typed = Nothing+ | otherwise = Just (T.unwords taken)+ where+ typed = [w | (w, _, _) <- hitWords h]+ taken = [ case wmKind m of { Typo _ -> wmTaken m; Subsequence -> wmTaken m; _ -> w } | (w, _, m) <- hitWords h ]++-- ============================================================================+-- The answer+-- ============================================================================++-- | What @_find@ says: its page, or one JSON object per hit (@-j@).+data Answer = Answer+ { anMatched :: Bool -- ^ anything found (else the page is the nearest names)+ , anText :: Text -- ^ the page+ , anJson :: [Value] -- ^ the hits, at most the limit, best first+ , anScope :: Text -- ^ where it looked (@menger tickets@)+ }++-- | The answer to a search: given synapse's command line (@synapse -P+-- 4455@), the backend, whether to embolden, and the IR (the caller's view).+answer :: Text -> Text -> Bool -> FindOpts -> IR -> Answer+answer program backend bold FindOpts{..} ir = Answer+ { anMatched = not (null hits)+ , anText = if null hits then renderNoMatch fr (nearest entries ws) else renderFind fr hits+ , anJson = map (hitJson ir) (take foLimit hits)+ , anScope = T.unwords (backend : path)+ }+ where+ (path, ws) = narrowPath ir foTerms+ entries = catalogEntries path ir+ only = case (foMethods, foParams) of+ (True, True) -> OnlyBoth+ (True, False) -> OnlyMethods+ (False, True) -> OnlyParams+ _ -> OnlyAll+ hits = search only entries ws+ fr = FindRender+ { frProgram = program+ , frBackend = backend+ , frPath = path+ , frQuery = ws+ , frLimit = foLimit+ , frBold = bold+ , frIR = ir+ }++-- ============================================================================+-- Rendering+-- ============================================================================++-- | What a rendering needs beside the hits.+data FindRender = FindRender+ { frProgram :: Text -- ^ synapse and its global options (@synapse -P 4455@)+ , frBackend :: Text -- ^ the backend (@menger@)+ , frPath :: [Text] -- ^ the activation path the search was narrowed to+ , frQuery :: [Text] -- ^ the words, as typed+ , frLimit :: Int+ , frBold :: Bool -- ^ on a terminal: the matched characters in bold+ , frIR :: IR+ }++width :: Int+width = 80++-- | A description keeps at least this many columns, past the 80 when the+-- columns before it are wide: a line too short to say anything is no help.+minDesc :: Int+minDesc = 36++-- | SYNAPSE-17: the hits in help's shape — a header (how many, for what,+-- as what), METHODS · PARAMETERS · MENTIONED IN with the locations in one+-- column and the flags in the next, the descriptions fitted to the line,+-- how many more the limit held back, and how to see the top hit's options.+renderFind :: FindRender -> [Hit] -> Text+renderFind FindRender{..} hits = T.unlines (dropTrailingBlanks body)+ where+ total = length hits+ shown = take (max 0 frLimit) hits+ scope = T.unwords (frBackend : frPath)+ query = T.unwords frQuery+ header =+ T.pack (show total) <> (if total == 1 then " match" else " matches")+ <> " for '" <> query <> "'"+ <> maybe "" (\t -> " (as '" <> t <> "')") (takenAs hits)+ <> " in " <> scope++ body = concat+ [ [header]+ , concat [ section g [h | h <- shown, hitGroup h == g] | g <- [minBound .. maxBound] ]+ , [ "" | total > length shown ]+ , [ T.pack (show (total - length shown)) <> " more: '--limit " <> T.pack (show total) <> "' shows them all."+ | total > length shown ]+ , case shown of+ (h : _) -> ["", "Run '" <> frProgram <> " " <> frBackend <> " " <> entryLocation (hitEntry h) <> " --help' for its options."]+ [] -> []+ ]++ section _ [] = []+ section g hs = "" : groupTitle g : concatMap row hs++ locW = maximum (0 : map (T.length . entryLocation . hitEntry) shown)+ col1 = 2 + locW + 4+ paramLabels = [flagLabel frIR p | h <- shown, Just p <- [enParam (hitEntry h)]]+ flagW = maximum (0 : map T.length paramLabels)+ col2 = col1 + flagW + 4++ row h = case enParam (hitEntry h) of+ Nothing ->+ let descW = max minDesc (width - col1)+ in [ T.stripEnd (" " <> locCell h <> descCell h descW (methodText (enMethod (hitEntry h)))) ]+ Just p ->+ let descW = max minDesc (width - col2)+ label = flagLabel frIR p+ flagOff = T.length (fst (T.breakOn "--" label)) + 2+ flagSpans = [ (flagOff + o, l) | (_, OnParamName, m) <- hitWords h, (o, l) <- wmSpans m ]+ flag = styled frBold label flagSpans <> T.replicate (flagW + 4 - T.length label) " "+ values = case paramValuesOf frIR p of+ Just vs@(_ : _) -> map (T.replicate col2 " " <>) (wrapWords descW ("[values: " <> T.intercalate ", " vs <> "]"))+ _ -> []+ in T.stripEnd (" " <> locCell h <> flag <> descCell h descW (fromMaybe "" (pdDescription p))) : values++ -- the location, the method's matched characters in bold+ locCell h =+ let e = hitEntry h+ loc = entryLocation e+ nameOff = T.length loc - T.length (mdName (enMethod e))+ spans = [ (nameOff + o, l) | (_, OnMethodName, m) <- hitWords h, (o, l) <- wmSpans m ]+ in styled frBold loc spans <> T.replicate (locW + 4 - T.length loc) " "++ -- a named hit's summary; a mention's shows where the word is+ descCell h w text =+ let descWords = [ wd | (wd, wh, _) <- hitWords h, wh `elem` [OnMethodDescription, OnParamDescription] ]+ shownText = if hitGroup h == Mentioned then snippet w descWords text (matchSource h text) else fitText w (summarize text)+ spans = concat [ descSpans (normalise wd) shownText | wd <- descWords ]+ in styled frBold shownText spans++ methodText m = case mdDescription m of+ Just d | not (T.null (T.strip d)) -> d+ _ -> fromMaybe "" (mdLongDescription m)++ -- the text a mention was found in: the one shown, else (a method's)+ -- its long description+ matchSource h shown = case (enParam (hitEntry h), mdLongDescription (enMethod (hitEntry h))) of+ (Nothing, Just long) -> shown <> "\n" <> long+ _ -> shown++groupTitle :: Group -> Text+groupTitle = \case+ Methods -> "METHODS"+ Parameters -> "PARAMETERS"+ Mentioned -> "MENTIONED IN"++-- | A description cut to show a word it was found by (in @source@, the+-- description and what else was searched): the summary when the word is in+-- it, else as much of the summary as fits, then @… word …@.+snippet :: Int -> [Text] -> Text -> Text -> Text+snippet w ws text source+ | null ws = fitText w summary+ | any (\wd -> not (null (descSpans (normalise wd) fitted))) ws = fitted+ | otherwise = case firstToken of+ Nothing -> fitted+ Just tok ->+ let tailT = " \x2026 " <> tok <> " \x2026"+ room = w - T.length tailT+ lead = T.dropWhileEnd (== '\x2026') (fitText room summary)+ in if room >= 12 then T.stripEnd lead <> tailT else fitText w ("\x2026 " <> tok <> " \x2026")+ where+ summary = summarize text+ fitted = fitText w summary+ -- the text's word (as written) where the first of the words is found+ firstToken = listToMaybe+ [ T.dropAround (`elem` ("()[]{},.;:'\"`" :: String)) whole+ | wd <- ws+ , (off, _) <- take 1 (descSpans (normalise wd) source)+ , let whole = wordAt off source+ ]+ wordAt off t =+ let before = T.takeWhileEnd (not . isSpace') (T.take off t)+ after = T.takeWhile (not . isSpace') (T.drop off t)+ isSpace' c = c == ' ' || c == '\n'+ in before <> after++-- | Text with the characters at the spans in bold (when asked).+styled :: Bool -> Text -> [(Int, Int)] -> Text+styled False t _ = t+styled True t spans+ | null marks = t+ | otherwise = T.pack (go 0 (T.unpack t))+ where+ marks = Set.fromList [ i | (o, l) <- spans, i <- [o .. o + l - 1], i >= 0, i < T.length t ]+ go _ [] = []+ go i s@(_ : _)+ | Set.member i marks =+ let (run, rest) = splitAt (runLen i) s+ in "\ESC[1m" ++ run ++ "\ESC[22m" ++ go (i + length run) rest+ | otherwise = let (c : rest) = s in c : go (i + 1) rest+ runLen i = length (takeWhile (`Set.member` marks) [i ..])++dropTrailingBlanks :: [Text] -> [Text]+dropTrailingBlanks = reverse . dropWhile (T.null . T.strip) . reverse++-- | The three methods whose names are nearest the query (by edit distance+-- to the name or to one of its segments).+nearest :: [Entry] -> [Text] -> [Entry]+nearest entries ws = take 3 (sortOn rank candidates)+ where+ methods = [e | e <- entries, enKind e == MethodEntry]+ -- the introspection every hub has (schema, hashes) is never what was meant+ candidates = case filter ((`notElem` ["schema", "hashes"]) . mdName . enMethod) methods of+ [] -> methods+ cs -> cs+ q = T.intercalate "_" (map normalise ws)+ dist e =+ let name = T.toLower (mdName (enMethod e))+ in minimum (levenshteinDistance q name : [levenshteinDistance q s | (_, s) <- segments name])+ rank e = (dist e, T.length (entryLocation e), entryLocation e)++-- | Nothing matched: say so, then the nearest names.+renderNoMatch :: FindRender -> [Entry] -> Text+renderNoMatch FindRender{..} near = T.unlines $+ [ "No match for '" <> T.unwords frQuery <> "' in " <> T.unwords (frBackend : frPath) <> "." ]+ ++ if null near then [] else+ [ "", "Nearest:" ]+ ++ [ T.stripEnd (" " <> T.justifyLeft (locW + 4) ' ' (entryLocation e) <> fitText (max minDesc (width - (2 + locW + 4))) (summarize (desc e)))+ | e <- near ]+ where+ locW = maximum (0 : map (T.length . entryLocation) near)+ desc e = fromMaybe "" (mdDescription (enMethod e))++-- | One hit as JSON, for @-j@.+hitJson :: IR -> Hit -> Value+hitJson ir h = object $+ [ "kind" .= (case enKind e of { MethodEntry -> "method"; ParamEntry -> "param" } :: Text)+ , "path" .= mdFullPath m+ ]+ ++ case enParam e of+ Just p ->+ [ "param" .= pdName p+ , "type" .= flagType ir (pdType p)+ , "required" .= pdRequired p+ , "description" .= fromMaybe "" (pdDescription p)+ ] ++ maybe [] (\vs -> ["values" .= vs]) (paramValuesOf ir p)+ Nothing -> [ "description" .= fromMaybe "" (mdDescription m) ]+ +++ [ "group" .= (case hitGroup h of { Methods -> "methods"; Parameters -> "parameters"; Mentioned -> "mentioned" } :: Text)+ , "score" .= (fromIntegral (round (hitScore h * 100) :: Int) / 100 :: Double)+ , "matched" .= T.unwords [ case wmKind wm of { Typo _ -> wmTaken wm; Subsequence -> wmTaken wm; _ -> w } | (w, _, wm) <- hitWords h ]+ ]+ where+ e = hitEntry h+ m = enMethod e
src/Synapse/IR/Builder.hs view
@@ -40,7 +40,7 @@ import Synapse.Monad import Synapse.IR.Types hiding (QualifiedName(..), qualifiedNameFull) import Synapse.IR.Types (QualifiedName(..), qualifiedNameFull, synapseVersion)-import Plexus.Schema.Recursive (DeprecationInfo, ParamSchema(..))+import Plexus.Schema.Recursive (DeprecationInfo, ParamSchema(..), paramValues) -- ============================================================================ -- Building IR@@ -123,6 +123,15 @@ childHashes = fromMaybe Map.empty (irPluginHashes childIR) allHashes = Map.insert namespace hashInfo childHashes + -- REQ-5: capture per-plugin PlexusRequest schema (psRequest from wire schema).+ -- HEADERS-1 stub: plexus-protocol's PluginSchema does not yet expose+ -- psRequest (cross-repo patch pending). Treat as always-Nothing so the+ -- synapse binary builds; REQ-5 rendering becomes a no-op until the field+ -- lands in plexus-protocol.+ let _ = schema -- suppress unused-binding warning when REQ-5 is stubbed+ childRequests = fromMaybe Map.empty (irPluginRequests childIR)+ allRequests = childRequests+ pure $ IR { irVersion = irVersion emptyIR -- Use version from emptyIR , irBackend = irBackend emptyIR -- Will be set by buildIR@@ -132,6 +141,11 @@ , irMethods = Map.union localMethods (irMethods childIR) , irPlugins = Map.insert namespace pluginMethods (irPlugins childIR) , irPluginHashes = Just allHashes -- V2: Store hash info per plugin+ , irPluginRequests = if Map.null allRequests then Nothing else Just allRequests+ -- AUTH-9: this plugin's default roles, beside its children's.+ , irPluginRoles = case (psRoles schema, irPluginRoles childIR) of+ (Nothing, childRoles) -> childRoles+ (Just roles, childRoles) -> Just (Map.insert namespace roles (fromMaybe Map.empty childRoles)) } irAlgebra (MethodF method namespace path) = do@@ -147,6 +161,8 @@ , irMethods = Map.singleton fullPath mdef , irPlugins = Map.singleton namespace [methodName method] , irPluginHashes = Nothing -- Methods don't have plugin-level hashes+ , irPluginRequests = Nothing -- Methods don't carry request schemas+ , irPluginRoles = Nothing -- Methods don't carry roles } -- ============================================================================@@ -389,6 +405,11 @@ -- tags. plexus-protocol defaults this to MethodRoleRpc when -- the upstream schema omits the field, so pre-IR servers -- continue to work with no change in behaviour.+ , mdGrant = methodGrant method+ , mdTarget = methodTarget method+ , mdLongDescription = methodLongDescription method+ -- AUTH-9: what the method requires and its doc comment, lifted+ -- for hub-codegen; Nothing from a pre-AUTH schema. } in (allTypes, mdef) @@ -467,6 +488,16 @@ (ps:_) -> paramDeprecation ps [] -> Nothing + -- REQ-6/REQ-9: extract @x-plexus-source@ from each property's schema.+ -- Carries where the param value comes from (auth, cookie, header,+ -- query, derived, rpc) so downstream renderers and codegen can+ -- emit per-param annotations. Nothing means the schema had no+ -- extension — treated as RPC.+ extractSource :: Value -> Maybe Value+ extractSource propSchema = case propSchema of+ Object po -> KM.lookup "x-plexus-source" po+ _ -> Nothing+ -- Build param defs params = [ ParamDef@@ -476,6 +507,8 @@ , pdRequired = K.toText k `elem` required , pdDefault = extractDefault v , pdDeprecation = lookupDeprecation (K.toText k)+ , pdSource = extractSource v+ , pdValues = paramValues v } | (k, v) <- props ]
src/Synapse/IR/Cache.hs view
@@ -12,6 +12,7 @@ , newIRCache , lookupIR , insertIR+ , irCacheKey , clearCache , getCacheStats , CacheStats(..)@@ -81,7 +82,19 @@ , cacheStats = stats } --- | Look up IR by hash+-- | AUTH-8: the key an IR is kept under — the plugin hash, qualified by+-- the caller's view when the hub has one (@VIEW_HASH@ joined with @-@, a+-- file name). Callers pass this to 'lookupIR' / 'insertIR' so two+-- callers' views of one hub never share an IR on disk.+irCacheKey :: Maybe Text -> Text -> Text+irCacheKey Nothing hash = hash+irCacheKey (Just v) hash+ | T.null v = hash+ | otherwise = T.map safe v <> "-" <> hash+ where+ safe c = if c `elem` ("/\\:" :: String) then '_' else c++-- | Look up IR by hash (see 'irCacheKey' for a view's) lookupIR :: IRCache -> Text -> IO (Maybe IR) lookupIR IRCache{..} hash | not (cacheEnabled cacheConfig) = pure Nothing
src/Synapse/IR/Types.hs view
@@ -62,8 +62,11 @@ import Control.Applicative ((<|>)) import Data.Aeson+import qualified Data.Aeson as Aeson+import qualified Data.Aeson.KeyMap as KM import Data.Map.Strict (Map) import qualified Data.Map.Strict as Map+import Data.Maybe (catMaybes) import Data.Text (Text) import qualified Data.Text as T import GHC.Generics (Generic)@@ -130,10 +133,24 @@ , irMethods :: Map Text MethodDef -- ^ All methods, keyed by full path (e.g., "cone.chat") , irPlugins :: Map Text [Text] -- ^ Plugin -> method names mapping , irPluginHashes :: Maybe (Map Text PluginHashInfo) -- ^ V2: Hash information per plugin+ , irPluginRequests :: Maybe (Map Text Aeson.Value) -- ^ REQ-5: per-plugin PlexusRequest JSON Schema (Nothing = no request struct)+ , irPluginRoles :: Maybe (Map Text (Map Text [Text]))+ -- ^ AUTH-9: per-plugin default roles (AUTH-3's @PluginSchema.roles@),+ -- plugin namespace → role → the grants it lists. Omitted from the JSON+ -- when 'Nothing', so an IR from a pre-AUTH schema is unchanged. } deriving stock (Show, Eq, Generic)- deriving anyclass (ToJSON, FromJSON)+ deriving anyclass (FromJSON) +-- | Generic, except that @irPluginRoles@ is left out when 'Nothing' (AUTH-9):+-- an IR built from a schema without roles serializes exactly as before.+instance ToJSON IR where+ toJSON ir = case genericToJSON defaultOptions ir of+ Object o -> Object $ case irPluginRoles ir of+ Nothing -> KM.delete "irPluginRoles" o+ Just _ -> o+ v -> v+ -- | Empty IR for folding emptyIR :: IR emptyIR = IR@@ -145,6 +162,8 @@ , irMethods = Map.empty , irPlugins = Map.empty , irPluginHashes = Nothing+ , irPluginRequests = Nothing+ , irPluginRoles = Nothing } -- | Merge two IRs (for combining results from tree walk)@@ -162,6 +181,16 @@ (Just ha, Nothing) -> Just ha (Nothing, Just hb) -> Just hb (Nothing, Nothing) -> Nothing+ , irPluginRequests = case (irPluginRequests a, irPluginRequests b) of+ (Just ra, Just rb) -> Just (Map.union ra rb)+ (Just ra, Nothing) -> Just ra+ (Nothing, Just rb) -> Just rb+ (Nothing, Nothing) -> Nothing+ , irPluginRoles = case (irPluginRoles a, irPluginRoles b) of+ (Just ra, Just rb) -> Just (Map.union ra rb)+ (Just ra, Nothing) -> Just ra+ (Nothing, Just rb) -> Just rb+ (Nothing, Nothing) -> Nothing } -- ============================================================================@@ -273,6 +302,14 @@ -- -- Consumed by hub-codegen (Rust field @md_role@, JSON key @mdRole@) -- to emit typed-handle clients for @DynamicChild@ methods.+ , mdGrant :: Maybe Text+ -- ^ AUTH-9: the method's grant (AUTH-3's @MethodSchema.grant@),+ -- @<activation>.<method>@, e.g. @tickets.set_status@.+ , mdTarget :: Maybe Text+ -- ^ AUTH-9: the param that locates what a call acts on (@MethodSchema.target@).+ , mdLongDescription :: Maybe Text+ -- ^ AUTH-9: the doc comment beside the short description+ -- (@MethodSchema.long_description@). } deriving stock (Show, Eq, Generic) @@ -284,7 +321,7 @@ -- names used verbatim as JSON keys; @Maybe@ fields omitted when -- @Nothing@ to match aeson's legacy behaviour). instance ToJSON MethodDef where- toJSON MethodDef{..} = object+ toJSON MethodDef{..} = object $ [ "mdName" .= mdName , "mdFullPath" .= mdFullPath , "mdNamespace" .= mdNamespace@@ -296,6 +333,12 @@ , "mdBidirResponseType" .= mdBidirResponseType , "mdBidirResponseSchema" .= mdBidirResponseSchema , "mdRole" .= mdRole+ ] <> catMaybes+ -- AUTH-9: present only when the schema says them, so a pre-AUTH+ -- schema's IR is unchanged.+ [ ("mdGrant" .=) <$> mdGrant+ , ("mdTarget" .=) <$> mdTarget+ , ("mdLongDescription" .=) <$> mdLongDescription ] instance FromJSON MethodDef where@@ -311,6 +354,9 @@ <*> o .:? "mdBidirResponseType" <*> o .:? "mdBidirResponseSchema" <*> o .:? "mdRole" .!= MethodRoleRpc+ <*> o .:? "mdGrant"+ <*> o .:? "mdTarget"+ <*> o .:? "mdLongDescription" -- | A parameter definition data ParamDef = ParamDef@@ -326,6 +372,18 @@ -- Defaults to 'Nothing' when the schema lacks @param_schemas@, so -- pre-IR-5 producers and non-deprecated parameters are indistinguishable -- at the rendering layer.+ , pdSource :: Maybe Value+ -- ^ REQ-6/REQ-9: @x-plexus-source@ extension annotation, when the wire+ -- schema declared one on this parameter. The value is the raw JSON+ -- object (e.g. @{ "from": "auth", "resolver": "self.db.validate_user" }@+ -- or @{ "from": "cookie", "key": "access_token" }@). Consumers (synapse+ -- renderer, hub-codegen JSDoc) read this to identify where each param+ -- is sourced from. @Nothing@ means the schema lacked the extension —+ -- treat as RPC-sourced.+ , pdValues :: Maybe [Text]+ -- ^ SYNAPSE-17: the values the parameter takes, as its schema hints them+ -- (@x-plexus-values@). Omitted from the JSON when 'Nothing', so an IR+ -- from a schema without the hint serializes exactly as before. } deriving stock (Show, Eq, Generic) @@ -333,14 +391,16 @@ -- deserializing IR JSON that predates IR-14. -- All other fields preserve the derivation defaults. instance ToJSON ParamDef where- toJSON ParamDef{..} = object+ toJSON ParamDef{..} = object $ [ "pdName" .= pdName , "pdType" .= pdType , "pdDescription" .= pdDescription , "pdRequired" .= pdRequired , "pdDefault" .= pdDefault , "pdDeprecation" .= pdDeprecation+ , "pdSource" .= pdSource ]+ ++ maybe [] (\vs -> ["pdValues" .= vs]) pdValues instance FromJSON ParamDef where parseJSON = withObject "ParamDef" $ \o -> ParamDef@@ -350,6 +410,8 @@ <*> o .: "pdRequired" <*> o .:? "pdDefault" <*> o .:? "pdDeprecation"+ <*> o .:? "pdSource"+ <*> o .:? "pdValues" -- ============================================================================ -- Type References
+ src/Synapse/Identity/Command.hs view
@@ -0,0 +1,157 @@+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE LambdaCase #-}++-- | IDENTITY-43: the command lines.+--+-- @+-- synapse _key new [NAME] [--store auto|file|keychain] -- NAME defaults to "default"; never overwrites+-- synapse _key show [NAME] -- key:\<hex\>+-- synapse _key list+-- synapse [-H HOST] -P PORT _login BACKEND -- a person: the browser (AUTH-8)+-- synapse [-H HOST] -P PORT _login BACKEND [--key NAME] --as AGENT [--scope role\@SCOPE …] [--ttl SECS]+-- synapse [-H HOST] -P PORT _logout BACKEND [--as AGENT]+-- @+--+-- @_key@ is local and runs here; @_login@ / @_logout@ are parsed here and+-- run by the executable, which knows how to reach a backend.+module Synapse.Identity.Command+ ( -- * _key+ KeyCommand(..)+ , keyCommandInfo+ , runKeyCommand++ -- * _login / _logout+ , LoginOpts(..)+ , loginInfo+ , LogoutOpts(..)+ , logoutInfo+ , splitScopes+ ) where++import Data.Text (Text)+import qualified Data.Text as T+import qualified Data.Text.IO as TIO+import Options.Applicative+import System.Exit (ExitCode(..))+import System.IO (stderr)++import Synapse.Identity.Proof+import Synapse.Identity.Store++-- ============================================================================+-- _key+-- ============================================================================++data KeyCommand+ = KeyNew Text StorePref+ | KeyShow Text+ | KeyList+ deriving (Eq, Show)++keyCommandInfo :: ParserInfo KeyCommand+keyCommandInfo = info (parser <**> helper)+ ( fullDesc+ <> header "synapse _key - the Ed25519 keys an agent proves who it is with"+ <> progDesc "Kept in the OS keychain when one answers, else ~/.plexus/keys/NAME.key (0600). Read in the order: PLEXUS_KEY (base64 seed), keychain, $CREDENTIALS_DIRECTORY/NAME.key, ~/.plexus/keys/NAME.key."+ )+ where+ parser = hsubparser+ ( command "new" (info newP (progDesc "Make a key (NAME defaults to 'default'); prints key:<hex>. Never overwrites."))+ <> command "show" (info showP (progDesc "Print a key's principal, key:<hex>"))+ <> command "list" (info (pure KeyList) (progDesc "Every key synapse can find, and where"))+ )+ nameArg = T.pack <$> strArgument (metavar "NAME" <> value "default" <> showDefault)+ newP = KeyNew <$> nameArg <*> option storeR+ ( long "store" <> metavar "auto|file|keychain" <> value StoreAuto+ <> help "Where to keep it (default auto: the keychain when it answers, else the file)" )+ showP = KeyShow <$> nameArg+ storeR = eitherReader $ \case+ "auto" -> Right StoreAuto+ "file" -> Right StoreFile+ "keychain" -> Right StoreKeychain+ s -> Left ("--store is auto, file or keychain, not " <> s)++runKeyCommand :: StoreEnv -> KeyCommand -> IO ExitCode+runKeyCommand st = \case+ KeyNew name pref+ | not (validKeyName name) -> bad ("a key name is 1-64 of A-Z a-z 0-9 . _ - (not '" <> name <> "')")+ | otherwise -> keyExists st name >>= \case+ Just src -> bad ("key " <> name <> " already exists (" <> renderKeySource src <> "); synapse never overwrites a key")+ Nothing -> do+ seed <- generateSeed+ writeKey st pref name seed >>= \case+ Left e -> bad e+ Right src -> do+ TIO.putStrLn (principalOf seed)+ TIO.hPutStrLn stderr ("synapse: key " <> name <> " kept in " <> renderKeySource src)+ pure ExitSuccess+ KeyShow name -> lookupKey st name >>= \case+ Left e -> bad e+ Right Nothing -> bad ("no key " <> name <> " (synapse _key new " <> name <> ")")+ Right (Just (seed, _)) -> ExitSuccess <$ TIO.putStrLn (principalOf seed)+ KeyList -> do+ keys <- listKeys st+ rows <- mapM describe keys+ if null rows+ then TIO.hPutStrLn stderr "synapse: no keys (synapse _key new)"+ else mapM_ TIO.putStrLn (align' rows)+ pure ExitSuccess+ where+ bad msg = ExitFailure 1 <$ TIO.hPutStrLn stderr ("synapse: " <> msg)+ describe (name, src) = do+ -- the principal from the store that listed it (PLEXUS_KEY would+ -- answer for every name, so it is not asked here)+ p <- lookupKey st { stEnv = \k -> if k == "PLEXUS_KEY" then pure Nothing else stEnv st k } name+ let principal = case p of+ Right (Just (seed, _)) -> principalOf seed+ Right Nothing -> "?"+ Left e -> "(" <> e <> ")"+ pure (name, principal, renderKeySource src)+ align' rows =+ let w = maximum [T.length n | (n, _, _) <- rows]+ in [T.justifyLeft w ' ' n <> " " <> p <> " " <> s | (n, p, s) <- rows]++-- ============================================================================+-- _login / _logout+-- ============================================================================++data LoginOpts = LoginOpts+ { loBackend :: Text+ , loKey :: Maybe Text -- ^ AUTH-8: none (and no agent named) is a person signing in through the browser+ , loAs :: Maybe Text+ , loScope :: [Text]+ , loTtl :: Maybe Integer+ } deriving (Eq, Show)++loginInfo :: ParserInfo LoginOpts+loginInfo = info (p <**> helper)+ ( fullDesc+ <> header "synapse _login - sign in; later calls to this backend carry the session by themselves"+ <> progDesc "A person (no --key, no --as): opens the browser to sign in and waits on a loopback port (127.0.0.1) for it to come back (auth.login's browser flow; 5 minutes). An agent (--key and/or --as): proves the key (a plexus-key proof at the upgrade) and asks auth.login for a session (session.login on a hub without auth). Either way the session is kept (keychain, else ~/.plexus/sessions/HOST_PORT/AGENT.json; a person's under the name 'me') and becomes this backend's default. Global options (-H, -P) go before _login."+ )+ where+ p = LoginOpts+ <$> (T.pack <$> strArgument (metavar "BACKEND" <> help "The hub's name, e.g. menger"))+ <*> optional (T.pack <$> strOption (long "key" <> short 'k' <> metavar "NAME" <> help "The key to prove (an agent; 'default' when only --as is given)"))+ <*> optional (T.pack <$> strOption (long "as" <> metavar "AGENT" <> help "The agent id to log in as (else SYNAPSE_AS)"))+ <*> (splitScopes <$> many (T.pack <$> strOption (long "scope" <> metavar "ROLE@SCOPE" <> help "The session's ceiling, e.g. editor@MENGER-12 (repeat, or commas); default: the key's own grants")))+ <*> optional (option auto (long "ttl" <> metavar "SECS" <> help "Session lifetime in seconds (the server's default 12 h, at most 24 h)"))++data LogoutOpts = LogoutOpts+ { lgBackend :: Text+ , lgAs :: Maybe Text+ } deriving (Eq, Show)++logoutInfo :: ParserInfo LogoutOpts+logoutInfo = info (p <**> helper)+ ( fullDesc+ <> header "synapse _logout - end a session (session.logout) and forget it"+ )+ where+ p = LogoutOpts+ <$> (T.pack <$> strArgument (metavar "BACKEND" <> help "The hub's name, e.g. menger"))+ <*> optional (T.pack <$> strOption (long "as" <> metavar "AGENT" <> help "Which agent's session (else SYNAPSE_AS, else the backend's default)"))++-- | @--scope a,b --scope c@ → @[a, b, c]@.+splitScopes :: [Text] -> [Text]+splitScopes = filter (not . T.null) . map T.strip . concatMap (T.splitOn ",")
+ src/Synapse/Identity/Proof.hs view
@@ -0,0 +1,165 @@+{-# LANGUAGE OverloadedStrings #-}++-- | IDENTITY-43: an agent's key and the proof it logs in with — IDENTITY-40's+-- "The wire", exactly.+--+-- A key is an Ed25519 seed (32 bytes); its principal is+-- @key:\<64 lowercase hex of the public key\>@. A proof is a bearer value+--+-- @+-- plexus-key.\<base64url-nopad of UTF-8 JSON\>+-- {"v":1,"key":"\<64 hex\>","aud":"\<ws url\>","agent":"\<id\>","nonce":"\<b64url\>","ts":\<unix s\>,"sig":"\<b64url\>"}+-- @+--+-- where @sig@ is Ed25519 over the UTF-8 bytes of exactly+-- @plexus-key-login/v1\\n\<aud\>\\n\<agent\>\\n\<nonce\>\\n\<ts\>@ (no trailing+-- newline). The JSON's fields go in that order, compact, so the same+-- inputs give the same proof on every side (Ed25519 signing is+-- deterministic): see the fixed vector in @test/IdentitySpec.hs@.+module Synapse.Identity.Proof+ ( -- * Keys+ Seed+ , seedFromBytes+ , seedBytes+ , generateSeed+ , publicKeyHex+ , principalOf++ -- * The proof+ , canonicalMessage+ , buildProof+ , newNonce+ , wsAudience+ , validAgent+ , validKeyName++ -- * Encodings+ , b64urlNoPad+ , hexText+ , decodeSeedText+ , encodeSeedText+ ) where++import qualified Crypto.PubKey.Ed25519 as Ed+import Crypto.Error (CryptoFailable(..))+import Crypto.Random (getRandomBytes)+import qualified Data.Aeson.Encoding as AE+import Data.Aeson ((.=))+import qualified Data.ByteArray as BA+import Data.ByteString (ByteString)+import qualified Data.ByteString as BS+import qualified Data.ByteString.Base64 as B64+import qualified Data.ByteString.Base64.URL as B64U+import qualified Data.ByteString.Lazy as LBS+import Data.Char (isAsciiLower, isAsciiUpper, isDigit)+import Data.Text (Text)+import qualified Data.Text as T+import qualified Data.Text.Encoding as TE+import Text.Printf (printf)++-- | An Ed25519 seed: the 32 bytes a key is.+newtype Seed = Seed ByteString+ deriving (Eq)++instance Show Seed where+ show _ = "Seed <redacted>"++-- | A seed from its 32 bytes, or why these are not one.+seedFromBytes :: ByteString -> Either Text Seed+seedFromBytes b+ | BS.length b == 32 = Right (Seed b)+ | otherwise = Left $ "a key is 32 bytes, not " <> T.pack (show (BS.length b))++seedBytes :: Seed -> ByteString+seedBytes (Seed b) = b++-- | A new seed from the system's randomness.+generateSeed :: IO Seed+generateSeed = Seed <$> getRandomBytes 32++secretOf :: Seed -> Ed.SecretKey+secretOf (Seed b) = case Ed.secretKey b of+ CryptoPassed sk -> sk+ CryptoFailed e -> error ("Synapse.Identity.Proof: " <> show e) -- length is checked at construction++publicOf :: Seed -> Ed.PublicKey+publicOf = Ed.toPublic . secretOf++-- | The public key, 64 lowercase hex.+publicKeyHex :: Seed -> Text+publicKeyHex = hexText . BA.convert . publicOf++-- | @key:\<64 hex\>@ — the principal a key is.+principalOf :: Seed -> Text+principalOf s = "key:" <> publicKeyHex s++-- | The bytes the signature covers, as text:+-- @plexus-key-login/v1\\n\<aud\>\\n\<agent\>\\n\<nonce\>\\n\<ts\>@.+canonicalMessage :: Text -> Text -> Text -> Integer -> Text+canonicalMessage aud agent nonce ts =+ T.intercalate "\n" ["plexus-key-login/v1", aud, agent, nonce, T.pack (show ts)]++-- | The proof for this key, audience (the ws URL dialed), agent, nonce (its+-- raw bytes; 32 of them) and time (unix seconds).+buildProof :: Seed -> Text -> Text -> ByteString -> Integer -> Text+buildProof seed aud agent nonceRaw ts =+ let sk = secretOf seed+ pk = Ed.toPublic sk+ nonce = b64urlNoPad nonceRaw+ msg = TE.encodeUtf8 (canonicalMessage aud agent nonce ts)+ sig = Ed.sign sk pk msg+ json = AE.encodingToLazyByteString $ AE.pairs $ mconcat+ [ "v" .= (1 :: Int)+ , "key" .= hexText (BA.convert pk)+ , "aud" .= aud+ , "agent" .= agent+ , "nonce" .= nonce+ , "ts" .= ts+ , "sig" .= b64urlNoPad (BA.convert sig)+ ]+ in "plexus-key." <> b64urlNoPad (LBS.toStrict json)++-- | 32 random bytes for a proof's nonce.+newNonce :: IO ByteString+newNonce = getRandomBytes 32++-- | The URL a proof's @aud@ names: the socket synapse dials,+-- @ws://HOST:PORT@ (SCAFFOLD-21: behind TLS, @wss://HOST@ —+-- 'Synapse.Endpoint.audience' says which for an address).+wsAudience :: Text -> Int -> Text+wsAudience host port = "ws://" <> host <> ":" <> T.pack (show port)++-- | An agent id: 1–64 of @[A-Za-z0-9._-]@.+validAgent :: Text -> Bool+validAgent a = not (T.null a) && T.length a <= 64 && T.all idChar a++-- | A key's name: the same alphabet (it names a file).+validKeyName :: Text -> Bool+validKeyName n = validAgent n && n `notElem` [".", ".."]++idChar :: Char -> Bool+idChar c = isAsciiLower c || isAsciiUpper c || isDigit c || c `elem` ("._-" :: String)++-- | base64url without padding.+b64urlNoPad :: ByteString -> Text+b64urlNoPad = TE.decodeLatin1 . B64U.encodeUnpadded++-- | Lowercase hex.+hexText :: ByteString -> Text+hexText = T.pack . concatMap (printf "%02x") . BS.unpack++-- | A seed as it is kept (base64 of the 32 bytes; @PLEXUS_KEY@'s form).+encodeSeedText :: Seed -> Text+encodeSeedText (Seed b) = TE.decodeLatin1 (B64.encode b)++-- | A kept seed back: standard or url base64, padded or not, surrounding+-- whitespace ignored.+decodeSeedText :: Text -> Either Text Seed+decodeSeedText t =+ let s = TE.encodeUtf8 (T.strip t)+ std = B64.decode s+ url = B64U.decodeUnpadded s+ urlp = B64U.decode s+ in case [b | Right b <- [std, urlp, url]] of+ (b : _) -> seedFromBytes b+ [] -> Left "not base64"
+ src/Synapse/Identity/Session.hs view
@@ -0,0 +1,521 @@+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE LambdaCase #-}+{-# LANGUAGE PatternSynonyms #-}++-- | IDENTITY-43: logging in from a key, and the session every later call+-- carries by itself.+--+-- * 'loginWithKey' opens a connection presenting a fresh proof+-- (@Authorization: Bearer plexus-key.…@) and asks @session.login@ on it.+-- * 'sessionFor' finds the session a call to @HOST:PORT@ goes with — the+-- agent named (@--as@ / @SYNAPSE_AS@) or the backend's default — and, when+-- its @expires_at@ has passed, logs in again from the key it was made+-- from before the call is sent.+-- * 'reattest' does that on demand, when the server refuses the session+-- (a 401 at the upgrade, or the "you have signed out" error): the caller+-- retries once.+--+-- The session found for an address is remembered for the rest of the+-- process, so the discovery probe and the call carry the same token.+module Synapse.Identity.Session+ ( -- * Login / logout+ LoginResult(..)+ , loginWithKey+ , logoutWithToken++ -- * A person (AUTH-8: the loopback flow)+ , loginPerson+ , whoamiWith+ , whoamiM++ -- * The session a call carries+ , ActiveSession(..)+ , sessionFor+ , reattest+ , reattestAgain+ , canReattest+ , bearer++ -- * Recognising an ended session+ , isSessionEndedItem+ , isSessionEndedText+ , isUpgradeRefused++ -- * Within one call+ , markSessionEnded+ , takeSessionEnded+ , notePrinted+ , printedAny+ , noteRefused+ , wasRefused+ , currentCeiling+ ) where++import Control.Exception (SomeException, try)+import Control.Monad (when)+import Data.Aeson (Value(..), object, (.=))+import qualified Data.Aeson.KeyMap as KM+import Data.IORef+import qualified Data.Map.Strict as Map+import Data.Maybe (isJust)+import Data.Scientific (toBoundedInteger)+import Data.Text (Text)+import qualified Data.Text as T+import qualified Data.Text.IO as TIO+import Data.Time.Clock.POSIX (getPOSIXTime)+import qualified Data.Vector as V+import System.IO (stderr)+import System.IO.Unsafe (unsafePerformIO)++import Control.Monad.Except (catchError)+++import Synapse.Auth (SessionGrant(..), Whoami, isMissingAuth, parseSessionGrant, parseWhoami)+import qualified Synapse.Auth.Loopback as Loopback+import Synapse.Identity.Proof+import Synapse.Identity.Store+import Synapse.Endpoint (Endpoint)+import qualified Synapse.Endpoint as E+import qualified Synapse.Log as Log+import Synapse.Monad+import Synapse.Schema.Types (HubStreamItem, pattern HubData, pattern HubError, pattern HubGuidance)+import Synapse.Transport (invoke)++-- ============================================================================+-- Login+-- ============================================================================++-- | What @session.login@ answered.+data LoginResult = LoginResult+ { lrToken :: Text+ , lrPrincipal :: Text+ , lrAgent :: Text+ , lrExpiresAt :: Maybe Integer+ , lrScope :: [Text]+ } deriving (Eq, Show)++nowSecs :: IO Integer+nowSecs = round <$> getPOSIXTime++-- | Log in to the hub @backend@ at @HOST:PORT@ as @agent@, proving the key:+-- a connection opened with the proof, and on it AUTH-5's @auth.login+-- {flow: {session: {ttl_secs?, ceiling?}}}@ — or, on a hub with no @auth@+-- activation (every hub before AUTH), IDENTITY-42's @session.login {ttl_secs?,+-- scope?}@ on a second connection with a fresh proof (a proof works once).+loginWithKey :: Log.Logger -> Endpoint -> Text -> Seed -> Text -> [Text] -> Maybe Integer -> IO (Either Text LoginResult)+loginWithKey logger ep backend seed agent scope ttl+ | not (validAgent agent) = pure (Left ("an agent id is 1-64 of A-Z a-z 0-9 . _ - (not '" <> agent <> "')"))+ | otherwise = do+ viaAuth <- do+ proof <- freshProof+ let flow = object $+ maybe [] (\t -> ["ttl_secs" .= t]) ttl+ <> (if null scope then [] else ["ceiling" .= scope])+ items <- call logger ep backend (Just proof) "auth" "login" (object ["flow" .= object ["session" .= flow]])+ pure (items >>= grantItem "auth.login")+ case viaAuth of+ Left e | isMissingAuth e -> do+ proof <- freshProof+ let params = object $+ maybe [] (\t -> ["ttl_secs" .= t]) ttl+ <> (if null scope then [] else ["scope" .= scope])+ items <- call logger ep backend (Just proof) "session" "login" params+ pure (items >>= loginItem)+ other -> pure other+ where+ freshProof = do+ nonce <- newNonce+ ts <- nowSecs+ pure (buildProof seed (E.audience ep) agent nonce ts)+ grantItem what items =+ case [g | HubData _ _ _ v <- items, Just g <- [parseSessionGrant v]] of+ (g : _) -> Right LoginResult+ { lrToken = sgToken g+ , lrPrincipal = sgPrincipal g+ , lrAgent = maybe agent id (sgAgent g)+ , lrExpiresAt = sgExpiresAt g+ , lrScope = sgCeiling g+ }+ [] -> Left (answerError items (what <> " answered no session"))+ loginItem items =+ case [o | HubData _ _ _ (Object o) <- items, KM.lookup "type" o == Just (String "login")] of+ (o : _) | Just (String tok) <- KM.lookup "token" o -> Right LoginResult+ { lrToken = tok+ , lrPrincipal = str "principal" o+ , lrAgent = let a = str "agent" o in if T.null a then agent else a+ , lrExpiresAt = KM.lookup "expires_at" o >>= int+ , lrScope = case KM.lookup "scope" o of+ Just (Array xs) -> [s | String s <- V.toList xs]+ _ -> []+ }+ _ -> Left (answerError items "session.login answered no login")++-- | AUTH-8: a person signs in through the browser (the loopback flow,+-- AUTH-1 Q3). synapse listens on 127.0.0.1 (a port the OS picks), asks+-- @auth.login {flow: {browser: {redirect: http://127.0.0.1:PORT/cb}}}@ for+-- @{type: login_url, url, state}@, opens the URL (and says it, for a+-- browser elsewhere), waits up to @secs@ for @GET /cb?code=…&state=…@ with+-- that state, and trades the code: @auth.login {flow: {code: {code,+-- state}}}@ → the session. Every call here is anonymous.+loginPerson :: Log.Logger -> Endpoint -> Text -> Int -> (Text -> IO ()) -> IO (Either Text SessionGrant)+loginPerson logger ep backend secs say =+ Loopback.withLoopback $ \lb -> do+ let redirect = Loopback.loopbackRedirect lb+ browserFlow = object ["flow" .= object ["browser" .= object ["redirect" .= redirect]]]+ first' <- call logger ep backend Nothing "auth" "login" browserFlow+ case first' >>= loginUrl of+ Left e+ | isMissingAuth e -> pure (Left (addressKey ep <> " has no auth activation, so no browser sign-in"+ <> " (an agent signs in with its key: _login " <> backend <> " --key NAME --as AGENT)"))+ | otherwise -> pure (Left e)+ Right (url, state) -> do+ opened <- Loopback.openBrowser url+ say $ (if opened then "opening your browser to sign in; if it does not open, visit:"+ else "to sign in, visit:")+ say (" " <> url)+ say ("waiting for the browser on " <> redirect <> " (" <> Loopback.durationText secs <> ") \x2026")+ back <- Loopback.awaitCallback lb state secs+ case back of+ Left why -> pure (Left why)+ Right code -> do+ let codeFlow = object ["flow" .= object ["code" .= object ["code" .= code, "state" .= state]]]+ done <- call logger ep backend Nothing "auth" "login" codeFlow+ pure $ done >>= \items ->+ case [g | HubData _ _ _ v <- items, Just g <- [parseSessionGrant v]] of+ (g : _) -> Right g+ [] -> Left (answerError items "auth.login answered no session for the code")+ where+ loginUrl items =+ case [o | HubData _ _ _ (Object o) <- items, KM.lookup "type" o `elem` [Just (String "login_url"), Just (String "url")]] of+ (o : _) | Just (String u) <- KM.lookup "url" o, not (T.null u) ->+ case KM.lookup "state" o of+ Just (String st) | not (T.null st) -> Right (u, st)+ _ -> Left "auth.login gave a sign-in URL with no state; refused (the state is what binds the browser's answer to this sign-in)"+ _ -> Left (answerError items "auth.login answered no sign-in URL")++-- | AUTH-8: who a token is — @auth.whoami@, else (a hub without the auth+-- activation) @session.whoami@; Nothing when neither says.+whoamiWith :: Log.Logger -> Endpoint -> Text -> Text -> IO (Maybe Whoami)+whoamiWith logger ep backend token = do+ a <- call logger ep backend (Just token) "auth" "whoami" (object [])+ case a >>= firstWhoami of+ Right w -> pure (Just w)+ Left _ -> do+ b <- call logger ep backend (Just token) "session" "whoami" (object [])+ pure (either (const Nothing) Just (b >>= firstWhoami))++-- | 'whoamiWith' on the call's own connection (its credential, whatever it+-- is): @auth.whoami@, else @session.whoami@, else Nothing. Never fails.+whoamiM :: SynapseM (Maybe Whoami)+whoamiM = do+ a <- (Just <$> invoke ["auth"] "whoami" (object [])) `catchError` \_ -> pure Nothing+ case a >>= either (const Nothing) Just . firstWhoami of+ Just w -> pure (Just w)+ Nothing -> do+ b <- (Just <$> invoke ["session"] "whoami" (object [])) `catchError` \_ -> pure Nothing+ pure (b >>= either (const Nothing) Just . firstWhoami)++firstWhoami :: [HubStreamItem] -> Either Text Whoami+firstWhoami items = case [w | HubData _ _ _ v <- items, Just w <- [parseWhoami v]] of+ (w : _) -> Right w+ [] -> Left (answerError items "no whoami")++-- | End the session this token is: @auth.logout@ (AUTH-5), else — a hub+-- with no auth activation — @session.logout@.+logoutWithToken :: Log.Logger -> Endpoint -> Text -> Text -> IO (Either Text ())+logoutWithToken logger ep backend token = do+ a <- call logger ep backend (Just token) "auth" "logout" (object [])+ case a >>= ended "auth.logout" of+ Left e | isMissingAuth e -> do+ items <- call logger ep backend (Just token) "session" "logout" (object [])+ pure (items >>= ended "session.logout")+ other -> pure other+ where+ -- an answer with no error is a logout; one that says so, certainly+ ended what is+ | any isLoggedOut is = Right ()+ | null [() | HubError{} <- is] && null (errorEvents is) && not (null [() | HubData{} <- is]) = Right ()+ | otherwise = Left (answerError is (what <> " answered nothing"))+ isLoggedOut = \case+ HubData _ _ _ (Object o) -> KM.lookup "type" o `elem` [Just (String "logged_out"), Just (String "logout")]+ _ -> False+ errorEvents is = [() | HubData _ _ _ (Object o) <- is, KM.lookup "type" o == Just (String "error")]++-- | One @\<ns\>.\<method\>@ call on a fresh connection presenting @cred@+-- as @Authorization: Bearer@ (none: anonymous).+call :: Log.Logger -> Endpoint -> Text -> Maybe Text -> Text -> Text -> Value -> IO (Either Text [HubStreamItem])+call logger ep backend cred ns method params = do+ env0 <- initEnvAt ep backend logger Nothing+ let env = withRequestContext [] (maybe [] (\c -> [bearer c]) cred) env0+ r <- runSynapseM env (invoke [ns] method params)+ pure $ case r of+ Right items -> Right items+ Left (TransportErrorContext ctx)+ | isUpgradeRefused (tcMessage ctx) -> Left (addressKey ep <> " refused the credential at the upgrade (401)")+ | otherwise -> Left (firstLine (tcMessage ctx))+ Left (TransportError m) -> Left (firstLine m)+ Left e -> Left (T.pack (show e))++-- | SCAFFOLD-21: the global options a sign-in at this address is typed+-- with, and the space after them (none for the default address).+loginOpts :: Endpoint -> Text+loginOpts ep = case E.cliOptions ep of+ "" -> ""+ o -> o <> " "++firstLine :: Text -> Text+firstLine = T.strip . T.takeWhile (/= '\n')++-- | The upgrade header a credential travels in.+bearer :: Text -> (Text, Text)+bearer cred = ("Authorization", "Bearer " <> cred)++answerError :: [HubStreamItem] -> Text -> Text+answerError items fallback =+ case [m | HubData _ _ _ (Object o) <- items, KM.lookup "type" o == Just (String "error"), Just (String m) <- [KM.lookup "message" o]]+ <> [e | HubError _ _ e _ <- items]+ <> [T.pack (show g) | HubGuidance _ _ g _ _ _ <- items] of+ (m : _) -> m+ [] -> fallback++str :: KM.Key -> KM.KeyMap Value -> Text+str k o = case KM.lookup k o of+ Just (String s) -> s+ _ -> ""++int :: Value -> Maybe Integer+int = \case+ Number n -> fromIntegral <$> (toBoundedInteger n :: Maybe Int)+ String s | [(i, "")] <- reads (T.unpack s) -> Just i+ _ -> Nothing++-- ============================================================================+-- The session a call carries+-- ============================================================================++data ActiveSession = ActiveSession+ { asEndpoint :: Endpoint+ , asToken :: Text+ , asRec :: Maybe SessionRec -- ^ Nothing: PLEXUS_SESSION, nothing to log in again from+ } deriving (Eq, Show)++{-# NOINLINE memo #-}+memo :: IORef (Map.Map Endpoint (Maybe ActiveSession))+memo = unsafePerformIO (newIORef Map.empty)++-- | Addresses already logged in again in this process (at most once each).+{-# NOINLINE reattested #-}+reattested :: IORef [Endpoint]+reattested = unsafePerformIO (newIORef [])++-- | The session a call to @HOST:PORT@ carries, if any: @PLEXUS_SESSION@,+-- else the stored one for the agent asked (or the backend's default agent).+-- One past its @expires_at@ is made again from its key first.+sessionFor :: Log.Logger -> StoreEnv -> Maybe Text -> Endpoint -> IO (Maybe ActiveSession)+sessionFor logger st asked ep = do+ known <- Map.lookup ep <$> readIORef memo+ case known of+ Just s -> pure s+ Nothing -> do+ s <- find+ modifyIORef' memo (Map.insert ep s)+ pure s+ where+ find = do+ envTok <- stEnv st "PLEXUS_SESSION"+ case envTok of+ Just t | not (null t) -> pure (Just (ActiveSession ep (T.strip (T.pack t)) Nothing))+ _ -> do+ agent <- maybe (readDefaultAgent st ep) (pure . Just) asked+ case agent of+ Nothing -> pure Nothing+ Just a -> lookupSession st ep a >>= \case+ Just (Right rec, _) -> do+ now <- nowSecs+ let active = ActiveSession ep (srToken rec) (Just rec)+ if maybe False (<= now + 5) (srExpiresAt rec)+ then reattestWith logger st active "had expired"+ else pure (Just active)+ Just (Left tok, _) -> pure (Just (ActiveSession ep tok Nothing))+ Nothing -> do+ when (isJust asked) $ TIO.hPutStrLn stderr $+ "synapse: no session for " <> a <> " at " <> addressKey ep+ <> " (synapse " <> loginOpts ep <> "_login <backend> --key NAME --as " <> a <> ")"+ pure Nothing++-- | Can the session in use at this address be made again from its key?+canReattest :: Endpoint -> IO Bool+canReattest ep = do+ s <- Map.lookup ep <$> readIORef memo+ done <- elem ep <$> readIORef reattested+ pure $ case s of+ -- AUTH-8: a person's session (no key) is made again only by signing in+ Just (Just ActiveSession{asRec = Just r}) -> not done && not (T.null (srKey r))+ _ -> False++-- | The server refused the session in use at this address: log in again+-- from its key (once per process). The new session, or Nothing when there+-- was none to remake or the login failed (both said on stderr).+reattest :: Log.Logger -> StoreEnv -> Endpoint -> IO (Maybe ActiveSession)+reattest logger st ep = do+ ok <- canReattest ep+ s <- Map.lookup ep <$> readIORef memo+ case s of+ Just (Just active) | ok -> do+ r <- reattestWith logger st active "has ended"+ pure $ case r of+ Just new | asToken new /= asToken active -> Just new+ _ -> Nothing+ _ -> pure Nothing++-- | SYNAPSE-20: 'reattest' for a call that outlived its session — a long+-- stream across a server restart (menger keeps sessions in memory). Its+-- reconnect loop asks at most once per outage, so the once-per-process+-- mark is lifted first.+reattestAgain :: Log.Logger -> StoreEnv -> Endpoint -> IO (Maybe ActiveSession)+reattestAgain logger st ep = do+ modifyIORef' reattested (filter (/= ep))+ reattest logger st ep++reattestWith :: Log.Logger -> StoreEnv -> ActiveSession -> Text -> IO (Maybe ActiveSession)+reattestWith logger st active why = case asRec active of+ Nothing -> pure (Just active)+ Just rec | T.null (srKey rec) -> do+ -- AUTH-8: a person's session: there is no key to make it again from+ TIO.hPutStrLn stderr $ "synapse: the session for " <> srAgent rec <> " at "+ <> addressKey (asEndpoint active) <> " " <> why+ <> "; sign in again: synapse " <> loginOpts (asEndpoint active) <> "_login " <> srBackend rec+ pure (Just active)+ Just rec -> do+ let ep = asEndpoint active+ who = srAgent rec <> " at " <> addressKey ep+ modifyIORef' reattested (ep :)+ -- SYNAPSE-15: one process at a time signs this agent in again; one+ -- that waited finds the session another has just made, and takes it+ withSessionLock st ep (srAgent rec) $ do+ fresher <- storedFresher st active rec+ case fresher of+ Just active' -> do+ modifyIORef' memo (Map.insert ep (Just active'))+ pure (Just active')+ Nothing -> mint ep who rec+ where+ mint ep who rec = do+ key <- lookupKey st (srKey rec)+ case key of+ Right (Just (seed, _)) -> do+ r <- loginWithKey logger ep (srBackend rec) seed (srAgent rec) (srScope rec) (srTtl rec)+ case r of+ Right lr -> do+ let rec' = rec { srToken = lrToken lr+ , srPrincipal = if T.null (lrPrincipal lr) then srPrincipal rec else lrPrincipal lr+ , srExpiresAt = lrExpiresAt lr+ }+ active' = active { asToken = lrToken lr, asRec = Just rec' }+ -- SYNAPSE-15: a session that cannot be kept is a warning; the+ -- call goes on with the one it holds+ kept <- try (writeSession st ep rec')+ case kept of+ Right _ -> pure ()+ Left (e :: SomeException) -> TIO.hPutStrLn stderr $+ "synapse: warning: could not keep the new session for " <> who+ <> " (" <> T.pack (show e) <> "); this call goes on with it"+ modifyIORef' memo (Map.insert ep (Just active'))+ TIO.hPutStrLn stderr $ "synapse: the session for " <> who <> " " <> why+ <> "; signed in again from key " <> srKey rec+ pure (Just active')+ Left e -> do+ TIO.hPutStrLn stderr $ "synapse: the session for " <> who <> " " <> why+ <> " and signing in again from key " <> srKey rec <> " failed: " <> e+ pure (Just active)+ Right Nothing -> do+ TIO.hPutStrLn stderr $ "synapse: the session for " <> who <> " " <> why+ <> ", and key " <> srKey rec <> " is in no store to sign in again from"+ pure (Just active)+ Left e -> do+ TIO.hPutStrLn stderr $ "synapse: the session for " <> who <> " " <> why <> "; key " <> srKey rec <> ": " <> e+ pure (Just active)++-- | SYNAPSE-15: the agent's stored session, read again under its lock,+-- when another process has replaced the one this call holds with one that+-- is still live — then this call takes it instead of signing in again.+storedFresher :: StoreEnv -> ActiveSession -> SessionRec -> IO (Maybe ActiveSession)+storedFresher st active rec = do+ stored <- try (lookupSession st (asEndpoint active) (srAgent rec))+ now <- nowSecs+ pure $ case stored of+ Right (Just (Right r, _))+ | srToken r /= asToken active+ , not (T.null (srToken r))+ , maybe True (> now + 5) (srExpiresAt r)+ -> Just active { asToken = srToken r, asRec = Just r }+ Right _ -> Nothing+ Left (_ :: SomeException) -> Nothing++-- ============================================================================+-- Recognising an ended session+-- ============================================================================++-- | The in-band error a hub answers a call whose session has ended with.+isSessionEndedItem :: HubStreamItem -> Bool+isSessionEndedItem = \case+ HubData _ _ _ (Object o)+ | KM.lookup "type" o == Just (String "error")+ , Just (String m) <- KM.lookup "message" o -> isSessionEndedText m+ HubError _ _ e _ -> isSessionEndedText e+ _ -> False++isSessionEndedText :: Text -> Bool+isSessionEndedText m = any (`T.isInfixOf` T.toLower m)+ ["you have signed out", "your sign-in has expired"]++-- | A transport failure that is the upgrade's 401.+isUpgradeRefused :: Text -> Bool+isUpgradeRefused m = any (`T.isInfixOf` m) ["responseCode = 401", " 401 ", "(401)", "Unauthorized"]++-- ============================================================================+-- Within one call: was the session refused before anything was shown?+-- ============================================================================++{-# NOINLINE endedRef #-}+endedRef :: IORef (Maybe Text)+endedRef = unsafePerformIO (newIORef Nothing)++{-# NOINLINE printedRef #-}+printedRef :: IORef Bool+printedRef = unsafePerformIO (newIORef False)++-- | Hold back the server's refusal (its words) for the retry.+markSessionEnded :: Text -> IO ()+markSessionEnded = writeIORef endedRef . Just++-- | Was the session refused, and the refusal held back? Its words; resets.+takeSessionEnded :: IO (Maybe Text)+takeSessionEnded = atomicModifyIORef' endedRef (\b -> (Nothing, b))++notePrinted :: IO ()+notePrinted = writeIORef printedRef True++printedAny :: IO Bool+printedAny = readIORef printedRef++-- | AUTH-8: a refusal was shown (the call exits non-zero).+{-# NOINLINE refusedRef #-}+refusedRef :: IORef Bool+refusedRef = unsafePerformIO (newIORef False)++noteRefused :: IO ()+noteRefused = writeIORef refusedRef True++wasRefused :: IO Bool+wasRefused = readIORef refusedRef++-- | AUTH-8: the ceiling of the session this process carries, as asked at+-- login (@editor\@MENGER-12@ each) — what a refusal that names none is+-- said to be within. Empty when no stored session is carried.+currentCeiling :: IO [Text]+currentCeiling = do+ m <- readIORef memo+ pure $ case [srScope r | Just ActiveSession{asRec = Just r} <- Map.elems m] of+ (c : _) -> c+ [] -> []
+ src/Synapse/Identity/Store.hs view
@@ -0,0 +1,552 @@+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE LambdaCase #-}+{-# LANGUAGE ScopedTypeVariables #-}++-- | IDENTITY-43: where an agent's key and its sessions are kept — IDENTITY-40's+-- "Stores (synapse)", exactly.+--+-- Key @NAME@, read in this order:+--+-- 1. env @PLEXUS_KEY@ (base64 of the 32-byte seed)+-- 2. the OS keychain (@secret-tool@ attributes @service=plexus-key name=NAME@;+-- macOS @security … -s plexus-key -a NAME@)+-- 3. @$CREDENTIALS_DIRECTORY/NAME.key@ (a systemd service's credentials)+-- 4. @~\/.plexus\/keys\/NAME.key@ (0600)+--+-- Session for (backend @HOST:PORT@, agent):+--+-- 1. env @PLEXUS_SESSION@ (the token itself)+-- 2. the keychain (@service=plexus-session backend=HOST:PORT agent=AGENT@)+-- 3. @~\/.plexus\/sessions\/HOST_PORT\/AGENT.json@ (0600)+--+-- SCAFFOLD-21: a backend reached any other way than plain+-- @ws://HOST:PORT/@ is keyed by its URL — @backend=wss://HOST:443/ws@ in the+-- keychain, @~\/.plexus\/sessions\/wss_HOST_443_ws\/AGENT.json@ on disk —+-- so a ws and a wss backend on one host keep sessions apart, and every+-- session stored before is found where it was.+--+-- Writes go to the keychain when it answers, else the file. The keychain+-- is asked through its command-line tool with a 2 s limit, and a host with+-- no session bus (a server) has none — so a headless machine falls through+-- to the files at once. @PLEXUS_KEYCHAIN=off@ skips it outright.+--+-- Everything is reached through a 'StoreEnv', so tests give it a directory,+-- an environment and a keychain of their own.+module Synapse.Identity.Store+ ( -- * The environment+ StoreEnv(..)+ , systemStoreEnv+ , Keychain(..)+ , KcResult(..)+ , systemKeychain+ , memoryKeychain++ -- * Keys+ , KeySource(..)+ , renderKeySource+ , lookupKey+ , StorePref(..)+ , writeKey+ , keyExists+ , listKeys+ , keyFilePath++ -- * Sessions+ , SessionRec(..)+ , SessionSource(..)+ , lookupSession+ , writeSession+ , removeSession+ , sessionFilePath+ , addressKey+ , readDefaultAgent+ , writeDefaultAgent+ , clearDefaultAgent++ , withSessionLock++ -- * Files+ , writePrivateFile+ ) where++import Control.Exception (SomeException, try, catch, finally, onException)+import Control.Monad (unless, when)+import Data.Aeson (FromJSON(..), ToJSON(..), (.:), (.:?), (.=), (.!=), object, withObject)+import qualified Data.Aeson as A+import qualified Data.ByteString as BS+import qualified Data.ByteString.Lazy as LBS+import Data.IORef+import qualified Data.Map.Strict as Map+import Data.List (nub, sort)+import Data.Maybe (isJust)+import Data.Text (Text)+import qualified Data.Text as T+import qualified Data.Text.Encoding as TE+import qualified Data.Text.IO as TIO+import System.Directory+import System.Environment (lookupEnv)+import System.Exit (ExitCode(..))+import System.FilePath ((</>), takeDirectory, takeExtension, dropExtension, takeFileName)+import System.IO (hClose, openBinaryTempFile)+import qualified System.Info as Info+import System.IO.Unsafe (unsafePerformIO)+import System.Posix.Files (setFileMode)+import System.Posix.IO (createFile, closeFd)+import Foreign.C.Types (CInt(..))+import Foreign.C.Error (throwErrnoIfMinus1RetryMayBlock_)+import System.Process (proc, readCreateProcessWithExitCode)+import System.Timeout (timeout)++import Synapse.Identity.Proof+import Synapse.Endpoint (Endpoint)+import qualified Synapse.Endpoint as E++-- ============================================================================+-- Environment+-- ============================================================================++-- | What the stores read and write through.+data StoreEnv = StoreEnv+ { stEnv :: String -> IO (Maybe String) -- ^ the process environment+ , stPlexus :: FilePath -- ^ @~/.plexus@+ , stKeychain :: Maybe Keychain -- ^ the OS keychain, when there is one+ }++-- | The real thing: the process environment, @~/.plexus@ and the system+-- keychain (if it is there).+systemStoreEnv :: IO StoreEnv+systemStoreEnv = do+ home <- getHomeDirectory+ kc <- systemKeychain+ pure StoreEnv { stEnv = lookupEnv, stPlexus = home </> ".plexus", stKeychain = kc }++-- | A keychain item is a service and the attributes that name it.+data KcResult = KcFound Text | KcAbsent | KcUnavailable Text+ deriving (Eq, Show)++data Keychain = Keychain+ { kcLabel :: Text+ , kcLookup :: Text -> [(Text, Text)] -> IO KcResult+ , kcStore :: Text -> Text -> [(Text, Text)] -> Text -> IO (Either Text ()) -- ^ label, service, attrs, secret+ , kcClear :: Text -> [(Text, Text)] -> IO ()+ , kcNames :: Text -> Text -> IO [Text] -- ^ service, attribute → the values items hold+ }++-- | Once the keychain has failed to answer in this process, it is not+-- asked again (each ask may cost the 2 s limit).+{-# NOINLINE keychainDown #-}+keychainDown :: IORef Bool+keychainDown = unsafePerformIO (newIORef False)++-- | The system keychain, or none: none when @PLEXUS_KEYCHAIN@ is off, when+-- its tool is not installed, or (Linux) when there is no session bus to+-- reach a secret service on.+systemKeychain :: IO (Maybe Keychain)+systemKeychain = do+ off <- lookupEnv "PLEXUS_KEYCHAIN"+ if fmap (T.toLower . T.pack) off `elem` map Just ["0", "off", "no", "false", "none", "file"]+ then pure Nothing+ else case Info.os of+ "darwin" -> fmap (const macKeychain) <$> findExecutable "security"+ "linux" -> do+ tool <- findExecutable "secret-tool"+ bus <- sessionBus+ pure $ if isJust tool && bus then Just secretTool else Nothing+ _ -> pure Nothing+ where+ sessionBus = do+ addr <- lookupEnv "DBUS_SESSION_BUS_ADDRESS"+ case addr of+ Just a | not (null a) -> pure True+ _ -> lookupEnv "XDG_RUNTIME_DIR" >>= \case+ Just d -> doesPathExist (d </> "bus")+ Nothing -> pure False++-- | Run a keychain tool with the 2 s limit: its exit, stdout and stderr, or+-- why it did not answer.+runTool :: FilePath -> [String] -> String -> IO (Either Text (ExitCode, String, String))+runTool exe args input = do+ down <- readIORef keychainDown+ if down then pure (Left "the keychain did not answer earlier") else do+ r <- try (timeout 2000000 (readCreateProcessWithExitCode (proc exe args) input))+ case r of+ Left (e :: SomeException) -> markDown ("could not run " <> T.pack exe <> ": " <> T.pack (show e))+ Right Nothing -> markDown (T.pack exe <> " did not answer within 2 s")+ Right (Just out) -> pure (Right out)+ where+ markDown msg = writeIORef keychainDown True >> pure (Left msg)++attrArgs :: Text -> [(Text, Text)] -> [String]+attrArgs service attrs = concat [[T.unpack k, T.unpack v] | (k, v) <- ("service", service) : attrs]++-- | Linux: @secret-tool@ over the Secret Service.+secretTool :: Keychain+secretTool = Keychain+ { kcLabel = "keychain (secret-tool)"+ , kcLookup = \service attrs -> do+ r <- runTool "secret-tool" ("lookup" : attrArgs service attrs) ""+ pure $ case r of+ Left msg -> KcUnavailable msg+ Right (ExitSuccess, out, _) -> KcFound (T.strip (T.pack out))+ -- exit 1 with nothing said: the service answered, no such item+ Right (ExitFailure 1, _, err) | all (`elem` (" \n\t" :: String)) err -> KcAbsent+ Right (_, _, err) -> KcUnavailable (T.strip (T.pack err))+ , kcStore = \label service attrs secret -> do+ r <- runTool "secret-tool" (["store", "--label=" <> T.unpack label] <> attrArgs service attrs) (T.unpack secret)+ pure $ case r of+ Left msg -> Left msg+ Right (ExitSuccess, _, _) -> Right ()+ Right (_, _, err) -> Left (T.strip (T.pack err))+ , kcClear = \service attrs -> () <$ runTool "secret-tool" ("clear" : attrArgs service attrs) ""+ , kcNames = \service attr -> do+ r <- runTool "secret-tool" ["search", "--all", "service", T.unpack service] ""+ pure $ case r of+ Right (ExitSuccess, out, err) ->+ -- secret-tool prints the attributes on stdout or stderr by version+ [ T.strip v+ | l <- T.lines (T.pack out <> "\n" <> T.pack err)+ , Just v <- [T.stripPrefix ("attribute." <> attr <> " = ") (T.strip l)]+ ]+ _ -> []+ }++-- | macOS: @security@ generic passwords; the account is the attribute+-- values joined with @/@ (a key's: its name).+macKeychain :: Keychain+macKeychain = Keychain+ { kcLabel = "keychain (macOS)"+ , kcLookup = \service attrs -> do+ r <- runTool "security" ["find-generic-password", "-s", T.unpack service, "-a", account attrs, "-w"] ""+ pure $ case r of+ Left msg -> KcUnavailable msg+ Right (ExitSuccess, out, _) -> KcFound (T.strip (T.pack out))+ Right (ExitFailure 44, _, _) -> KcAbsent+ Right (_, _, err) -> KcUnavailable (T.strip (T.pack err))+ , kcStore = \label service attrs secret -> do+ r <- runTool "security" ["add-generic-password", "-U", "-l", T.unpack label, "-s", T.unpack service, "-a", account attrs, "-w", T.unpack secret] ""+ pure $ case r of+ Left msg -> Left msg+ Right (ExitSuccess, _, _) -> Right ()+ Right (_, _, err) -> Left (T.strip (T.pack err))+ , kcClear = \service attrs -> () <$ runTool "security" ["delete-generic-password", "-s", T.unpack service, "-a", account attrs] ""+ , kcNames = \_ _ -> pure [] -- `security` has no cheap listing; keys in files are still listed+ }+ where account = T.unpack . T.intercalate "/" . map snd++-- | An in-memory keychain, for tests.+memoryKeychain :: IO (Keychain, IORef (Map.Map (Text, [(Text, Text)]) Text))+memoryKeychain = do+ ref <- newIORef Map.empty+ let kc = Keychain+ { kcLabel = "keychain (memory)"+ , kcLookup = \s a -> maybe KcAbsent KcFound . Map.lookup (s, a) <$> readIORef ref+ , kcStore = \_ s a v -> Right () <$ modifyIORef' ref (Map.insert (s, a) v)+ , kcClear = \s a -> modifyIORef' ref (Map.delete (s, a))+ , kcNames = \s attr -> do+ m <- readIORef ref+ pure [v | ((s', as), _) <- Map.toList m, s' == s, Just v <- [lookup attr as]]+ }+ pure (kc, ref)++-- ============================================================================+-- Keys+-- ============================================================================++data KeySource = KeyFromEnv | KeyFromKeychain | KeyFromCredentials FilePath | KeyFromFile FilePath+ deriving (Eq, Show)++renderKeySource :: KeySource -> Text+renderKeySource = \case+ KeyFromEnv -> "PLEXUS_KEY"+ KeyFromKeychain -> "keychain"+ KeyFromCredentials p -> "credentials " <> T.pack p+ KeyFromFile p -> "file " <> T.pack p++keyAttrs :: Text -> [(Text, Text)]+keyAttrs name = [("name", name)]++keyFilePath :: StoreEnv -> Text -> FilePath+keyFilePath st name = stPlexus st </> "keys" </> (T.unpack name <> ".key")++-- | Key @NAME@ in the spec's order, or why it is not there (Left when a+-- store holds something that is not a key).+lookupKey :: StoreEnv -> Text -> IO (Either Text (Maybe (Seed, KeySource)))+lookupKey st name = do+ envKey <- stEnv st "PLEXUS_KEY"+ case envKey of+ Just v | not (null v) -> pure $ either (Left . ("PLEXUS_KEY: " <>)) (\s -> Right (Just (s, KeyFromEnv))) (decodeSeedText (T.pack v))+ _ -> do+ fromKc <- case stKeychain st of+ Nothing -> pure Nothing+ Just kc -> kcLookup kc "plexus-key" (keyAttrs name) >>= \case+ KcFound v -> pure (Just (decodeSeedText v, KeyFromKeychain))+ _ -> pure Nothing+ case fromKc of+ Just (Right s, src) -> pure (Right (Just (s, src)))+ Just (Left e, _) -> pure (Left ("the keychain's plexus-key " <> name <> ": " <> e))+ Nothing -> do+ creds <- stEnv st "CREDENTIALS_DIRECTORY"+ fromCreds <- case creds of+ Just d | not (null d) -> readKeyFile (d </> (T.unpack name <> ".key")) KeyFromCredentials+ _ -> pure (Right Nothing)+ case fromCreds of+ Right Nothing -> readKeyFile (keyFilePath st name) KeyFromFile+ other -> pure other+ where+ readKeyFile p mk = do+ exists <- doesFileExist p+ if not exists then pure (Right Nothing) else do+ bytes <- BS.readFile p+ -- base64 text, or the 32 raw bytes+ let parsed = case decodeSeedText (TE.decodeLatin1 bytes) of+ Right s -> Right s+ Left _ -> seedFromBytes bytes+ pure $ case parsed of+ Right s -> Right (Just (s, mk p))+ Left e -> Left (T.pack p <> ": " <> e)++-- | Where a new key goes.+data StorePref = StoreAuto | StoreFile | StoreKeychain+ deriving (Eq, Show)++-- | Is there already a key by this name in a store synapse writes (the+-- keychain or the file)?+keyExists :: StoreEnv -> Text -> IO (Maybe KeySource)+keyExists st name = do+ inKc <- case stKeychain st of+ Just kc -> kcLookup kc "plexus-key" (keyAttrs name) >>= \case+ KcFound _ -> pure True+ _ -> pure False+ Nothing -> pure False+ if inKc then pure (Just KeyFromKeychain) else do+ let p = keyFilePath st name+ e <- doesFileExist p+ pure (if e then Just (KeyFromFile p) else Nothing)++-- | Keep a key: the keychain when it answers (and is wanted), else the file.+writeKey :: StoreEnv -> StorePref -> Text -> Seed -> IO (Either Text KeySource)+writeKey st pref name seed = do+ let secret = encodeSeedText seed+ toFile = do+ let p = keyFilePath st name+ writePrivateFile p (TE.encodeUtf8 (secret <> "\n"))+ pure (Right (KeyFromFile p))+ case (pref, stKeychain st) of+ (StoreFile, _) -> toFile+ (StoreKeychain, Nothing) -> pure (Left "no keychain answers here")+ (_, Just kc) -> do+ r <- kcStore kc ("plexus key " <> name) "plexus-key" (keyAttrs name) secret+ case r of+ Right () -> pure (Right KeyFromKeychain)+ Left e | pref == StoreKeychain -> pure (Left ("the keychain refused: " <> e))+ | otherwise -> toFile+ (StoreAuto, Nothing) -> toFile++-- | Every key synapse can see by name: (name, where).+listKeys :: StoreEnv -> IO [(Text, KeySource)]+listKeys st = do+ kcNamesFound <- case stKeychain st of+ Just kc -> kcNames kc "plexus-key" "name"+ Nothing -> pure []+ creds <- stEnv st "CREDENTIALS_DIRECTORY"+ credNames <- case creds of+ Just d | not (null d) -> keyFilesIn d+ _ -> pure []+ fileNames <- keyFilesIn (stPlexus st </> "keys")+ let kcs = [(n, KeyFromKeychain) | n <- nub kcNamesFound]+ cs = [(n, KeyFromCredentials p) | (n, p) <- credNames, n `notElem` map fst kcs]+ fs = [(n, KeyFromFile p) | (n, p) <- fileNames, n `notElem` map fst kcs, n `notElem` map fst cs]+ pure (kcs <> cs <> fs)+ where+ keyFilesIn d = do+ e <- doesDirectoryExist d+ if not e then pure [] else do+ fs <- sort <$> listDirectory d+ pure [(T.pack (dropExtension f), d </> f) | f <- fs, takeExtension f == ".key"]++-- ============================================================================+-- Sessions+-- ============================================================================++-- | A session as synapse keeps it: the token, who it is, until when, and+-- what it was made from (the key's name, the scope and ttl asked) so it+-- can be made again.+data SessionRec = SessionRec+ { srToken :: Text+ , srPrincipal :: Text+ , srAgent :: Text+ , srExpiresAt :: Maybe Integer -- ^ unix seconds+ , srKey :: Text -- ^ the key's name in the key store+ , srScope :: [Text] -- ^ as asked at login (empty: the key's own)+ , srTtl :: Maybe Integer -- ^ as asked at login+ , srBackend :: Text -- ^ the hub's name (its @<name>.call@)+ } deriving (Eq, Show)++instance ToJSON SessionRec where+ toJSON s = object $+ [ "token" .= srToken s+ , "principal" .= srPrincipal s+ , "agent" .= srAgent s+ , "expires_at" .= srExpiresAt s+ , "key" .= srKey s+ , "scope" .= srScope s+ , "backend" .= srBackend s+ ] <> maybe [] (\t -> ["ttl_secs" .= t]) (srTtl s)++instance FromJSON SessionRec where+ parseJSON = withObject "session" $ \o -> SessionRec+ <$> o .: "token"+ <*> o .:? "principal" .!= ""+ <*> o .: "agent"+ <*> o .:? "expires_at"+ <*> o .:? "key" .!= "default"+ <*> o .:? "scope" .!= []+ <*> o .:? "ttl_secs"+ <*> o .:? "backend" .!= ""++data SessionSource = SessionFromEnv | SessionFromKeychain | SessionFromFile FilePath+ deriving (Eq, Show)++-- | @HOST:PORT@, the backend a session belongs to (SCAFFOLD-21: the+-- whole URL for an address that is not plain @ws://HOST:PORT/@ — see+-- 'Synapse.Endpoint.addressKey').+addressKey :: Endpoint -> Text+addressKey = E.addressKey++sessionDir :: StoreEnv -> Endpoint -> FilePath+sessionDir st ep = stPlexus st </> "sessions" </> E.dirName ep++sessionFilePath :: StoreEnv -> Endpoint -> Text -> FilePath+sessionFilePath st ep agent = sessionDir st ep </> (T.unpack agent <> ".json")++sessionAttrs :: Endpoint -> Text -> [(Text, Text)]+sessionAttrs ep agent = [("backend", addressKey ep), ("agent", agent)]++-- | The session for this backend and agent. @PLEXUS_SESSION@ is the token+-- alone: no record, nothing to sign in again from.+lookupSession :: StoreEnv -> Endpoint -> Text -> IO (Maybe (Either Text SessionRec, SessionSource))+lookupSession st ep agent = do+ envTok <- stEnv st "PLEXUS_SESSION"+ case envTok of+ Just t | not (null t) -> pure (Just (Left (T.strip (T.pack t)), SessionFromEnv))+ _ -> do+ fromKc <- case stKeychain st of+ Nothing -> pure Nothing+ Just kc -> kcLookup kc "plexus-session" (sessionAttrs ep agent) >>= \case+ KcFound v -> pure (A.decodeStrict (TE.encodeUtf8 v))+ _ -> pure Nothing+ case fromKc of+ Just r -> pure (Just (Right r, SessionFromKeychain))+ Nothing -> do+ let p = sessionFilePath st ep agent+ e <- doesFileExist p+ if not e then pure Nothing else do+ r <- A.decodeFileStrict p+ pure ((\s -> (Right s, SessionFromFile p)) <$> r)++-- | Keep a session: the keychain when it answers, else the file.+writeSession :: StoreEnv -> Endpoint -> SessionRec -> IO SessionSource+writeSession st ep s = do+ let json = TE.decodeUtf8 (LBS.toStrict (A.encode s))+ p = sessionFilePath st ep (srAgent s)+ toFile = do+ writePrivateFile p (LBS.toStrict (A.encode s))+ pure (SessionFromFile p)+ case stKeychain st of+ Nothing -> toFile+ Just kc -> do+ r <- kcStore kc ("plexus session " <> addressKey ep <> " " <> srAgent s)+ "plexus-session" (sessionAttrs ep (srAgent s)) json+ case r of+ Right () -> do+ -- a stale file would shadow nothing (the keychain is read first)+ -- but would outlive a logout's intent; drop it+ removeIfExists p+ pure SessionFromKeychain+ Left _ -> toFile++-- | Forget a session, wherever it is kept.+removeSession :: StoreEnv -> Endpoint -> Text -> IO ()+removeSession st ep agent = do+ mapM_ (\kc -> kcClear kc "plexus-session" (sessionAttrs ep agent)) (stKeychain st)+ removeIfExists (sessionFilePath st ep agent)++defaultAgentPath :: StoreEnv -> Endpoint -> FilePath+defaultAgentPath st ep = sessionDir st ep </> "default"++-- | The agent a backend's calls go as when none is named.+readDefaultAgent :: StoreEnv -> Endpoint -> IO (Maybe Text)+readDefaultAgent st ep = do+ let p = defaultAgentPath st ep+ e <- doesFileExist p+ if not e then pure Nothing else do+ t <- T.strip <$> TIO.readFile p+ pure (if validAgent t then Just t else Nothing)++writeDefaultAgent :: StoreEnv -> Endpoint -> Text -> IO ()+writeDefaultAgent st ep agent =+ writePrivateFile (defaultAgentPath st ep) (TE.encodeUtf8 (agent <> "\n"))++-- | Drop the default when it names this agent.+clearDefaultAgent :: StoreEnv -> Endpoint -> Text -> IO ()+clearDefaultAgent st ep agent = do+ cur <- readDefaultAgent st ep+ when (cur == Just agent) $ removeIfExists (defaultAgentPath st ep)++-- ============================================================================+-- Files+-- ============================================================================++-- | Write a file only its owner can read: its directory 0700, the file+-- 0600 from the moment it exists, replaced atomically.+--+-- SYNAPSE-15: each writer has a temporary file of its own+-- (@NAME.tmpNNN@, created with O_EXCL and 0600 by 'openBinaryTempFile'),+-- renamed over the target; parallel writers never touch each other's+-- file, and the last rename wins. Before, every writer used @NAME.tmp@,+-- and one removed another's between its write and its rename.+writePrivateFile :: FilePath -> BS.ByteString -> IO ()+writePrivateFile p bytes = do+ let dir = takeDirectory p+ privateDir dir+ (tmp, h) <- openBinaryTempFile dir (takeFileName p <> ".tmp")+ (do setFileMode tmp 0o600+ BS.hPut h bytes+ hClose h+ renameFile tmp p)+ `onException` (hClose h >> removeIfExists tmp)++foreign import ccall safe "sys/file.h flock" c_flock :: CInt -> CInt -> IO CInt++-- | flock's LOCK_EX.+lockEx :: CInt+lockEx = 2++-- | A directory only its owner can enter, made if missing.+privateDir :: FilePath -> IO ()+privateDir dir = do+ existed <- doesDirectoryExist dir+ createDirectoryIfMissing True dir+ unless existed $ setFileMode dir 0o700++-- | SYNAPSE-15: hold the agent's session lock at this backend (an advisory+-- lock on @AGENT.lock@ beside its session) for the action — so of the+-- processes that find the session stale at once, one signs in again and+-- the others, waiting here, find its new session when they read again.+withSessionLock :: StoreEnv -> Endpoint -> Text -> IO a -> IO a+withSessionLock st ep agent act = do+ let lockPath = sessionDir st ep </> (T.unpack agent <> ".lock")+ -- flock on a descriptor of our own (not a GHC handle: GHC refuses a+ -- second writer of one file within a process): processes and threads+ -- alike wait their turn; closing the descriptor lets the next one in+ opened <- try (privateDir (takeDirectory lockPath) >> createFile lockPath 0o600)+ case opened of+ -- a lock that cannot be had (a read-only store) is no reason to fail+ -- the call: it goes on unserialised, as before+ Left (_ :: SomeException) -> act+ Right fd -> flip finally (closeFd fd) $ do+ throwErrnoIfMinus1RetryMayBlock_ "flock" (c_flock (fromIntegral fd) lockEx) (pure ())+ act++removeIfExists :: FilePath -> IO ()+removeIfExists p = removeFile p `catch` \(_ :: SomeException) -> pure ()
src/Synapse/Monad.hs view
@@ -20,8 +20,14 @@ -- * Environment , SynapseEnv(..)+ , seHost+ , sePort , initEnv+ , initEnvAt , defaultEnv+ , withRequestContext+ , withViewKey+ , viewQualified -- * Errors , SynapseError(..)@@ -61,18 +67,21 @@ import Synapse.Schema.Types import Synapse.Backend.Discovery (Backend(..))+import Synapse.Endpoint (Endpoint(..), plainAt) import qualified Synapse.Log as Log import qualified Katip -- | Environment for Synapse operations data SynapseEnv = SynapseEnv- { seHost :: !Text -- ^ Hub host- , sePort :: !Int -- ^ Hub port+ { seEndpoint :: !Endpoint -- ^ Where the hub is reached (SCAFFOLD-21: ws or wss, its path) , seBackend :: !Text -- ^ Backend name (first CLI argument) , seCache :: !(IORef (HashMap PluginHash PluginSchema)) -- ^ Schema cache , seVisited :: !(IORef (HashSet PluginHash)) -- ^ Cycle detection , seLogger :: !Log.Logger -- ^ Structured logger , seToken :: !(Maybe Text) -- ^ JWT sent as Cookie: access_token=<jwt>+ , seCookies :: ![(Text, Text)] -- ^ SAFE-S04/REQ-5: extra cookies for WS upgrade+ , seHeaders :: ![(Text, Text)] -- ^ SAFE-S04/REQ-5: extra headers for WS upgrade+ , seViewKey :: !(Maybe Text) -- ^ AUTH-8: the caller's view (its hash, else its kind); qualifies every cache key } -- | Errors that can occur during Synapse operations@@ -137,21 +146,46 @@ env <- defaultEnv backend logger runSynapseM env action --- | Initialize environment with given host/port/backend/logger/token+-- | The hub's host.+seHost :: SynapseEnv -> Text+seHost = epHost . seEndpoint++-- | The hub's port.+sePort :: SynapseEnv -> Int+sePort = epPort . seEndpoint++-- | Initialize environment with given host/port/backend/logger/token, for a+-- hub at plain @ws://HOST:PORT/@ ('initEnvAt' for any address).+-- Extra cookies/headers default to empty; use 'withRequestContext' to add+-- them after construction (SAFE-S04/REQ-5). initEnv :: Text -> Int -> Text -> Log.Logger -> Maybe Text -> IO SynapseEnv-initEnv host port backend logger token = do+initEnv host port = initEnvAt (plainAt host port)++-- | SCAFFOLD-21: 'initEnv' for a hub at any address (@wss://…/ws@ too).+initEnvAt :: Endpoint -> Text -> Log.Logger -> Maybe Text -> IO SynapseEnv+initEnvAt ep backend logger token = do cache <- newIORef HM.empty visited <- newIORef HS.empty pure SynapseEnv- { seHost = host- , sePort = port+ { seEndpoint = ep , seBackend = backend , seCache = cache , seVisited = visited , seLogger = logger , seToken = token+ , seCookies = []+ , seHeaders = []+ , seViewKey = Nothing } +-- | Augment a SynapseEnv with extra cookies and headers for the WS upgrade.+-- (SAFE-S04/REQ-5: --cookie/--header CLI flags and SYNAPSE_COOKIE_*/SYNAPSE_HEADER_* env)+withRequestContext :: [(Text, Text)] -> [(Text, Text)] -> SynapseEnv -> SynapseEnv+withRequestContext cookies headers env = env+ { seCookies = seCookies env ++ cookies+ , seHeaders = seHeaders env ++ headers+ }+ -- | Default environment (localhost:4444, requires backend and logger) defaultEnv :: Text -> Log.Logger -> IO SynapseEnv defaultEnv backend logger = initEnv "127.0.0.1" 4444 backend logger Nothing@@ -209,14 +243,30 @@ lookupCache :: PluginHash -> SynapseM (Maybe PluginSchema) lookupCache hash = do cacheRef <- asks seCache+ key <- asks (\e -> viewQualified (seViewKey e) hash) cache <- liftIO $ readIORef cacheRef- pure $ HM.lookup hash cache+ pure $ HM.lookup key cache -- | Insert a schema into the cache insertCache :: PluginHash -> PluginSchema -> SynapseM () insertCache hash schema = do cacheRef <- asks seCache- liftIO $ modifyIORef' cacheRef (HM.insert hash schema)+ key <- asks (\e -> viewQualified (seViewKey e) hash)+ liftIO $ modifyIORef' cacheRef (HM.insert key schema)++-- | AUTH-8: a cache key under the caller's view — @VIEW/HASH@, or the hash+-- alone on a hub with no view (as before AUTH). Two callers' views of one+-- hub never share an entry, even where the hub gave a schema one hash in+-- both.+viewQualified :: Maybe Text -> Text -> Text+viewQualified Nothing hash = hash+viewQualified (Just v) hash+ | T.null v = hash+ | otherwise = v <> "/" <> hash++-- | AUTH-8: the view this env's calls see (from @_info@).+withViewKey :: Maybe Text -> SynapseEnv -> SynapseEnv+withViewKey v env = env { seViewKey = v } -- ============================================================================ -- Logging and Config
+ src/Synapse/Reconnect.hs view
@@ -0,0 +1,418 @@+{-# LANGUAGE LambdaCase #-}+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE ScopedTypeVariables #-}++-- | SYNAPSE-20: synapse rides out an outage.+--+-- A lost connection is noticed by the client (plexus-protocol's+-- 'Plexus.Client': a close, a reset, or a keepalive ping unanswered), and+-- reported as 'PT.ConnectionLost'. Here is what synapse does about it:+--+-- * it tries again for the reconnect window (@--reconnect-for@,+-- @SYNAPSE_RECONNECT_FOR@; ten minutes unless told), backing off from+-- 0.5 s, doubling, to 15 s, and saying so on stderr — never stdout;+-- * a read is sent again; a write whose request went out is not — the+-- write may have happened, so synapse says so and exits 'exitWriteUnknown';+-- a write whose request never left is sent when the connection is back;+-- * a stream is subscribed again with the same params — and with+-- @since@ set to when the call began, when the method takes one and the+-- caller gave none (@await_reply@), so nothing said during the outage is+-- lost — and the caller is told it resumed (@-j@ prints a+-- @{"type":"reconnected"}@ line);+-- * when the window runs out, it exits 'exitUnreachable' with the reason.+module Synapse.Reconnect+ ( -- * The window+ setReconnectWindow+ , reconnectWindow+ , defaultWindow+ , parseDuration+ , renderDuration++ -- * What a call is+ , CallShape(..)+ , callShape+ , readNames++ -- * Running a call through an outage+ , Resumed(..)+ , Ending(..)+ , resilientCall+ , retrying+ , awaitBackend+ , isOutage+ , isOutageText+ , giveUp+ , reportEnding++ -- * Exit codes+ , exitUnreachable+ , exitWriteUnknown+ ) where++import Control.Concurrent (threadDelay)+import Data.Aeson (Value(..))+import qualified Data.Aeson.KeyMap as KM+import Data.Char (isDigit)+import Data.IORef+import Data.List (find)+import Data.Text (Text)+import qualified Data.Text as T+import qualified Data.Text.IO as TIO+import Data.Time.Clock.POSIX (getPOSIXTime)+import GHC.Clock (getMonotonicTime)+import System.Exit (ExitCode(..), exitWith)+import System.IO (hFlush, stderr)+import System.IO.Unsafe (unsafePerformIO)++import Plexus.Schema.Recursive (MethodSchema(..))+import qualified Plexus.Types as PT++-- ---------------------------------------------------------------------------+-- The window++-- | Ten minutes (owner, 2026-10-17: "at least 10 minutes of outage").+defaultWindow :: Double+defaultWindow = 600++{-# NOINLINE windowRef #-}+windowRef :: IORef Double+windowRef = unsafePerformIO (newIORef defaultWindow)++-- | Set once, at start, from @--reconnect-for@ / @SYNAPSE_RECONNECT_FOR@.+setReconnectWindow :: Double -> IO ()+setReconnectWindow = writeIORef windowRef . max 0++reconnectWindow :: IO Double+reconnectWindow = readIORef windowRef++-- | @10m@, @90s@, @1h@, @1m30s@, @600@ (seconds), @0@ (never).+parseDuration :: Text -> Maybe Double+parseDuration t0 = case T.unpack (T.toLower (T.strip t0)) of+ "" -> Nothing+ s | all isDigit s -> Just (read s)+ | otherwise -> go 0 s+ where+ go acc [] = Just acc+ go acc s = case span isDigit s of+ ([], _) -> Nothing+ (ds, u : rest) | Just k <- unit u -> go (acc + read ds * k) rest+ _ -> Nothing+ unit 'h' = Just 3600+ unit 'm' = Just 60+ unit 's' = Just 1+ unit _ = Nothing++-- | @2m10s@, @10m@, @45s@, @1h@.+renderDuration :: Double -> Text+renderDuration d =+ let n = max 0 (round d) :: Int+ (h, r) = n `divMod` 3600+ (m, s) = r `divMod` 60+ part v u = if v > 0 then T.pack (show v) <> u else ""+ in if n == 0 then "0s" else part h "h" <> part m "m" <> part s "s"++-- ---------------------------------------------------------------------------+-- What a call is++-- | What decides how a call rides out an outage.+data CallShape = CallShape+ { csStream :: Bool -- ^ the method streams (a watch, await_reply)+ , csRead :: Bool -- ^ sending it again is safe+ , csSince :: Bool -- ^ it takes a @since@ a resumed call can carry+ } deriving (Eq, Show)++-- | A method's shape from its schema: a read when its group says @read@+-- (menger's methods do), else when its name is a read's; anything else —+-- and anything bidirectional — is a write, never sent twice.+callShape :: MethodSchema -> CallShape+callShape m = CallShape+ { csStream = methodStreaming m+ , csRead = not (methodBidirectional m) && isRead+ , csSince = takesSince (methodParams m)+ }+ where+ isRead = case methodGroup m of+ Just "read" -> True+ Just "write" -> False+ _ -> methodName m `elem` readNames+ takesSince (Just (Object o))+ | Just (Object props) <- KM.lookup "properties" o = KM.member "since" props+ takesSince _ = False++-- | Method names that only read, for a schema that groups nothing.+readNames :: [Text]+readNames =+ [ "schema", "hash", "hashes", "health", "get", "list", "find", "search", "show"+ , "history", "watch", "await_reply", "await_message", "queued", "whoami"+ , "providers", "mine", "requests", "counts", "tree", "groups", "extents"+ , "facets", "member", "ready", "waves", "waves_under", "waves_to", "blockers"+ , "messages", "candidates", "environments", "running", "for_ticket"+ , "theme_for", "roles", "requires", "waiting"+ ]++-- ---------------------------------------------------------------------------+-- Outages++-- | A failure that is the backend being away, not a fault in the call.+isOutage :: PT.TransportError -> Bool+isOutage = \case+ PT.ConnectionRefused{} -> True+ PT.ConnectionTimeout{} -> True+ PT.ConnectionLost{} -> True+ PT.NetworkError msg -> isOutageText msg+ PT.ProtocolError msg -> tooMany msg || gatewayDown msg++-- | The server's connection limits (ACCESS-29): a 429 at the upgrade.+tooMany :: Text -> Bool+tooMany msg = any (`T.isInfixOf` T.toLower msg) ["responsecode = 429", "too many requests"]++-- | SCAFFOLD-21: a proxy in front (the edge before a hosted app) answering+-- the upgrade 502, 503 or 504 — it is up, the hub behind it is not (a+-- promote's restart). Nothing was sent; the hub comes back behind it.+gatewayDown :: Text -> Bool+gatewayDown msg = any (`T.isInfixOf` T.toLower msg)+ ["responsecode = 502", "responsecode = 503", "responsecode = 504"]++-- | The same, read from a message (the probe says why as text). A refused+-- upgrade (401, 403, 421) is not an outage.+isOutageText :: Text -> Bool+isOutageText msg = any (`T.isInfixOf` T.toLower msg)+ [ "refused", "timed out", "timeout", "reset by peer", "connectionlost"+ , "connection lost", "broken pipe", "connectionclosed", "end of file"+ , "no route to host", "network is unreachable", "does not exist (connection"+ -- the server's connection limits (ACCESS-29) answer 429 at the upgrade:+ -- nothing was sent, and it passes+ , "responsecode = 429", "too many requests"+ -- SCAFFOLD-21: the proxy in front answers while the hub behind is away+ , "responsecode = 502", "responsecode = 503", "responsecode = 504"+ ]++-- | The upgrade's 401: the credential refused (IDENTITY-43's test).+upgradeRefused :: PT.TransportError -> Bool+upgradeRefused e = any (`T.isInfixOf` reasonOf e) ["responseCode = 401", " 401 ", "(401)", "Unauthorized"]++-- | Whether a lost call's request had gone out.+sentOf :: PT.TransportError -> Bool+sentOf (PT.ConnectionLost _ s) = s+sentOf _ = False++reasonOf :: PT.TransportError -> Text+reasonOf = \case+ PT.ConnectionRefused{} -> "connection refused"+ PT.ConnectionTimeout{} -> "connection timed out"+ PT.ConnectionLost m _ -> m+ PT.NetworkError m -> m+ PT.ProtocolError m -> m++-- | A reason as a person says it (the probe's is a shown error).+plainReason :: Text -> Text+plainReason why+ | "ConnectionRefused" `T.isInfixOf` why = "connection refused"+ | "ConnectionTimeout" `T.isInfixOf` why = "connection timed out"+ | Just code <- find (\c -> ("responseCode = " <> c) `T.isInfixOf` why) ["502", "503", "504"] =+ "the proxy in front answered " <> code <> ": the hub behind it is away"+ | otherwise = why++-- | An outage under way: when it began, how many tries, the next pause.+data Outage = Outage+ { oStart :: Double+ , oAttempts :: Int+ , oDelay :: Double+ }++say :: Text -> IO ()+say t = TIO.hPutStrLn stderr ("synapse: " <> t) >> hFlush stderr++-- | Pause before the next try, or 'Nothing' when the window is spent.+pause :: Text -> Double -> Text -> Maybe Outage -> IO (Maybe Outage)+pause = pauseAs True++-- | 'pause', saying the outage began as a lost connection or (at start) a+-- backend not reached.+pauseAs :: Bool -> Text -> Double -> Text -> Maybe Outage -> IO (Maybe Outage)+pauseAs lost whereTo window why mo = do+ now <- getMonotonicTime+ o <- case mo of+ Just o -> pure o+ Nothing -> do+ say $ (if lost then "connection to " <> whereTo <> " lost (" else "cannot reach " <> whereTo <> " (")+ <> plainReason why <> ") — " <> (if lost then "reconnecting" else "waiting")+ <> " for up to " <> renderDuration window+ pure (Outage now 0 0.5)+ let elapsed = now - oStart o+ left = window - elapsed+ if left <= 0+ then pure Nothing+ else do+ let d = min (oDelay o) left+ say $ "retrying " <> whereTo <> " in " <> renderDuration' d <> " ("+ <> renderDuration elapsed <> " of " <> renderDuration window <> ")"+ threadDelay (round (d * 1000000))+ pure (Just o { oAttempts = oAttempts o + 1, oDelay = min 15 (oDelay o * 2) })+ where+ renderDuration' d | d < 1 = T.pack (show (fromIntegral (round (d * 10) :: Int) / 10 :: Double)) <> "s"+ | otherwise = renderDuration d++-- ---------------------------------------------------------------------------+-- Running a call through an outage++-- | A call that resumed after it had delivered something: how long the+-- outage was, how many tries it took, and the @since@ it carried (if it did).+data Resumed = Resumed+ { rsOutageSecs :: Double+ , rsAttempts :: Int+ , rsSince :: Maybe Integer+ } deriving (Eq, Show)++-- | How a call ended.+data Ending+ = Finished+ | Failed PT.TransportError -- ^ not an outage (or no window): reported as before+ | GaveUp Double Text -- ^ the window ran out: its length, the last reason+ | WriteUnknown Text -- ^ a write went out and its connection went before the answer+ deriving (Eq, Show)++-- | Run a call, one attempt at a time, through any outage within the+-- window. @attempt params onSubscribed onItem@ is one try. @onResume@ is+-- told, as soon as a try after an outage is taken by the server, that the+-- call resumed — for a stream always (what was said in the gap may be+-- missing, unless it carried @since@), for a unary read when it had+-- already delivered part of its answer.+resilientCall+ :: Text+ -> CallShape+ -> (Value -> IO () -> (PT.PlexusStreamItem -> IO ()) -> IO (Either PT.TransportError ()))+ -> IO Bool+ -- ^ after an outage, the server refused the credential at the upgrade+ -- (it lost its sessions in the restart): sign in again, True when a new+ -- credential is in place for the next try+ -> Value+ -> (Resumed -> IO ())+ -> (PT.PlexusStreamItem -> IO ())+ -> IO Ending+resilientCall whereTo shape attempt reauth params0 onResume onItem = do+ window <- reconnectWindow+ began <- (floor <$> getPOSIXTime) :: IO Integer+ everDelivered <- newIORef False+ let sinceFor n+ | n > (0 :: Int), csSince shape, Object o <- params0, unset (KM.lookup "since" o) = Just began+ | otherwise = Nothing+ -- a default the schema filled in (null) is no since of the caller's+ unset Nothing = True+ unset (Just Null) = True+ unset _ = False+ paramsFor n = case (sinceFor n, params0) of+ (Just s, Object o) -> Object (KM.insert "since" (Number (fromInteger s)) o)+ _ -> params0+ go n outage = goAuth n outage False+ goAuth n outage reauthed = do+ taken <- newIORef False+ let subscribed = do+ writeIORef taken True+ case outage of+ Just o -> do+ now <- getMonotonicTime+ say $ "reconnected to " <> whereTo <> " after " <> renderDuration (now - oStart o)+ before <- readIORef everDelivered+ if csStream shape || before+ then onResume (Resumed (now - oStart o) (oAttempts o) (sinceFor n))+ else pure ()+ Nothing -> pure ()+ item i = writeIORef everDelivered True >> onItem i+ r <- attempt (paramsFor n) subscribed item+ t <- readIORef taken+ -- a try the server took had the connection back: a new loss is a+ -- new outage, with the whole window again+ let outage' = if t then Nothing else outage+ case r of+ Right () -> pure Finished+ Left e+ | Just _ <- outage', not reauthed, upgradeRefused e -> do+ -- the server came back without the session: once per+ -- outage, sign in again and try at once+ ok <- reauth+ if ok then goAuth (n + 1) outage' True else pure (Failed e)+ | not (isOutage e) -> pure (Failed e)+ | sentOf e && not (csRead shape) -> pure (WriteUnknown (reasonOf e))+ | window <= 0 -> pure (Failed e)+ | otherwise -> do+ next <- pause whereTo window (reasonOf e) outage'+ case next of+ Nothing -> pure (GaveUp window (reasonOf e))+ Just o -> goAuth (n + 1) (Just o) (reauthed && not t)+ go 0 Nothing++-- | A call that is safe to repeat (a schema fetch), tried through an+-- outage within the window; when the window runs out, synapse exits.+retrying :: Text -> IO (Either PT.TransportError a) -> IO (Either PT.TransportError a)+retrying whereTo act = do+ window <- reconnectWindow+ let go outage = do+ r <- act+ case r of+ Left e | isOutage e, window > 0 -> do+ next <- pause whereTo window (reasonOf e) outage+ case next of+ Nothing -> giveUp whereTo window (reasonOf e)+ Just o -> go (Just o)+ _ -> do+ case (r, outage) of+ (Right _, Just o) -> do+ now <- getMonotonicTime+ say $ "reconnected to " <> whereTo <> " after " <> renderDuration (now - oStart o)+ _ -> pure ()+ pure r+ go Nothing++-- | The probe at start, tried through an outage within the window: while+-- it fails as an outage does (refused, timed out, reset), wait and try+-- again; any other answer is the caller's.+awaitBackend :: (Text -> Bool) -> Text -> IO (Either Text a) -> IO (Either Text a)+awaitBackend outage whereTo act = do+ window <- reconnectWindow+ let go o0 = do+ r <- act+ case r of+ Left why | outage why, window > 0 -> do+ next <- pauseAs False whereTo window why o0+ case next of+ Nothing -> giveUp whereTo window (plainReason why)+ Just o -> go (Just o)+ _ -> do+ case (r, o0) of+ (Right _, Just o) -> do+ now <- getMonotonicTime+ say $ "reconnected to " <> whereTo <> " after " <> renderDuration (now - oStart o)+ _ -> pure ()+ pure r+ go Nothing++-- ---------------------------------------------------------------------------+-- Endings++-- | sysexits' EX_UNAVAILABLE: the backend stayed away for the whole window.+exitUnreachable :: Int+exitUnreachable = 69++-- | sysexits' EX_TEMPFAIL: a write went out and its answer never came; it+-- may have happened.+exitWriteUnknown :: Int+exitWriteUnknown = 75++giveUp :: Text -> Double -> Text -> IO a+giveUp whereTo window why = do+ say $ whereTo <> " unreachable for " <> renderDuration window <> " (" <> why+ <> ") — giving up; --reconnect-for sets how long to wait"+ exitWith (ExitFailure exitUnreachable)++-- | The endings that are synapse's to report: a spent window and a write+-- of unknown outcome exit here; the others go back to the caller.+reportEnding :: Text -> Ending -> IO (Either PT.TransportError ())+reportEnding whereTo = \case+ Finished -> pure (Right ())+ Failed e -> pure (Left e)+ GaveUp window why -> giveUp whereTo window why+ WriteUnknown why -> do+ say $ "connection to " <> whereTo <> " lost before an answer (" <> why+ <> ") — the write may have happened; check before sending it again"+ exitWith (ExitFailure exitWriteUnknown)
+ src/Synapse/Self.hs view
@@ -0,0 +1,139 @@+{-# LANGUAGE OverloadedStrings #-}++-- | Public surface for the per-backend defaults store.+--+-- Defaults live at @~\/.plexus\/\<backend\>\/defaults.json@ (see 'defaultsPath').+-- The file is a manifest of credential *references* — URIs like+-- @keychain:\/\/svc\/account@ or @literal:raw-value@ — that a resolver+-- registry ('Synapse.Self.Resolve') dereferences at dispatch time.+--+-- This ticket (SELF-1) establishes the types, file format, and resolver+-- API. Actual IO wiring lives in SELF-2 (read), SELF-4 (CLI), SELF-5+-- (write), SELF-7 (core resolvers), SELF-8 (keychain).+module Synapse.Self+ ( -- * File path+ defaultsPath++ -- * On-disk types and encoding+ , StoredDefaults(..)+ , CredentialRef(..)+ , ScopedDefaults(..)+ , emptyStoredDefaults+ , currentVersion+ , encodeDefaults+ , decodeDefaults++ -- * Resolver API+ , ParsedUri(..)+ , ParsedUriBody(..)+ , parseUri+ , Resolver(..)+ , ResolveFn+ , NamedResolver(..)+ , ResolverRegistry(..)+ , emptyRegistry+ , registerResolver+ , registerNamedResolver+ , lookupResolver+ , resolveRef+ , ResolveError(..)++ -- * Core resolvers+ , literalResolver+ , envResolver+ , fileResolver+ , keychainResolver+ , storeInKeychain+ , deleteFromKeychain+ , isKeychainPlatform+ , defaultRegistry++ -- * Read path (SELF-2): load, resolve, merge+ , ResolvedDefaults(..)+ , emptyResolvedDefaults+ , MethodPath+ , Cookies+ , Headers+ , loadDefaults+ , resolveAll+ , merge++ -- * Write path (SELF-4 stub; SELF-5 tightens)+ , writeDefaults++ -- * Token sugar (SELF-6)+ , resolveToken+ ) where++import qualified Control.Exception as E+import Data.Text (Text)+import qualified Data.Text as T+import qualified Data.Map.Strict as Map+import System.FilePath ((</>))++import Synapse.Self.IO+ ( Cookies+ , Headers+ , MethodPath+ , ResolvedDefaults(..)+ , emptyResolvedDefaults+ , loadDefaults+ , merge+ , resolveAll+ , writeDefaults+ )+import Synapse.Self.Resolve+import Synapse.Self.Resolve.Default (defaultRegistry)+import Synapse.Self.Resolve.Env (envResolver)+import Synapse.Self.Resolve.File (fileResolver)+import Synapse.Self.Resolve.Keychain+ ( deleteFromKeychain+ , isKeychainPlatform+ , keychainResolver+ , storeInKeychain+ )+import Synapse.Self.Resolve.Literal (literalResolver)+import Synapse.Self.Types++-- | Path to a backend\'s defaults file: @~\/.plexus\/\<backend\>\/defaults.json@.+--+-- The returned path is relative to @~@ only in the sense that it begins+-- with the literal string @~\/@ — callers wanting the absolute path should+-- expand via 'System.Directory.getHomeDirectory'. This split lets pure+-- code compute the path without IO; the disk IO wiring (SELF-2 / SELF-5)+-- performs the expansion.+defaultsPath :: Text -> FilePath+defaultsPath backend =+ "~" </> ".plexus" </> T.unpack backend </> "defaults.json"++-- | Resolve the @cookies.access_token@ entry from a backend's defaults+-- store. Returns 'Nothing' when the file is missing, the cookie is+-- absent, or the ref fails to resolve.+--+-- This is the \"sugar\" seam SELF-6 uses to collapse+-- @SynapseCC.Auth.resolveToken@'s final fallback onto 'Synapse.Self'.+-- It is deliberately conservative:+--+-- * IO errors on 'loadDefaults' (permissions, corrupt JSON, etc.)+-- swallow to 'Nothing'. Callers that care can still use+-- 'loadDefaults' + 'resolveRef' directly for loud behavior.+-- * Resolve errors swallow to 'Nothing' for the same reason.+-- * An empty resolved value is treated as \"not set\" ('Nothing').+--+-- The loud variant (exit on IO / resolve failure) lives in+-- @buildEnv@ in the synapse executable because that path *must* fail+-- an unauthenticated request rather than silently drop the token.+-- Here — the fallback path, invoked only after explicit CLI / env+-- sources have been exhausted — a soft 'Nothing' is the right default.+resolveToken :: Text -> IO (Maybe Text)+resolveToken backend = do+ eStored <- E.try (loadDefaults backend) :: IO (Either E.IOException StoredDefaults)+ case eStored of+ Left _ -> pure Nothing+ Right stored -> case Map.lookup "access_token" (sdCookies stored) of+ Nothing -> pure Nothing+ Just ref -> do+ r <- resolveRef defaultRegistry ref+ case r of+ Right tok | not (T.null tok) -> pure (Just tok)+ _ -> pure Nothing
+ src/Synapse/Self/Command.hs view
@@ -0,0 +1,1020 @@+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE RecordWildCards #-}+{-# LANGUAGE ScopedTypeVariables #-}+{-# LANGUAGE LambdaCase #-}++-- | The @synapse _self \<backend\> \<verb\> ...@ command tree (SELF-4).+--+-- This module is the user-facing surface of the per-backend defaults store+-- introduced in SELF-1 / SELF-2 / SELF-7. It exposes:+--+-- * A 'SelfCommand' ADT covering every verb in the ticket spec.+-- * A single 'runSelfCommand' entry point that returns an 'ExitCode' so+-- callers don't have to know the (stdout / stderr / exit status)+-- contract of each individual verb.+-- * 'parseSelfCommand' — an optparse-applicative parser the executable+-- wires into @app\/Main.hs@.+--+-- Keeping the handlers here (rather than in @app@) lets @synapse-cc@+-- reuse the same code via SELF-6 without duplicating parsing or output+-- logic.+--+-- __Verbs:__+--+-- @+-- show [--json]+-- set cookie|header \<name\> \<value-or-uri\>+-- set-from-stdin cookie|header \<name\>+-- set-secret cookie|header \<name\> -- friendly error until SELF-8+-- unset cookie|header \<name\> [--yes] [--no-delete-from-keychain]+-- resolve cookie|header \<name\>+-- upgrade-to-keychain [cookie|header] [\<name\>] -- friendly error until SELF-8+-- clear [--yes]+-- import-token \<path-or--> [--to-keychain] -- --to-keychain requires SELF-8+-- @+module Synapse.Self.Command+ ( -- * Command ADT+ SelfCommand(..)+ , SelfKind(..)+ , kindLabel+ , renderKind++ -- * Execution+ , runSelfCommand++ -- * Parser (optparse-applicative)+ , parseSelfCommand+ , selfCommandInfo++ -- * Set-value heuristic (exported for testing)+ , knownSchemes+ , wrapAsLiteralIfNeeded++ -- * JWT inspection (exported for testing)+ , JwtSummary(..)+ , JwtClaim(..)+ , decodeJwt+ , renderJwtSummary+ , renderRelativeExpiry+ ) where++import Control.Exception (IOException)+import qualified Control.Exception as E+import Data.Aeson (Value(..), (.=), object, eitherDecodeStrict')+import Data.Aeson.Encode.Pretty (Config(..), Indent(..), NumberFormat(..), encodePretty')+import qualified Data.Aeson.Key as Key+import qualified Data.Aeson.KeyMap as KM+import qualified Data.ByteString as BS+import qualified Data.ByteString.Base64.URL as B64U+import qualified Data.ByteString.Char8 as BS8+import qualified Data.ByteString.Lazy as LBS+import Data.Map.Strict (Map)+import qualified Data.Map.Strict as Map+import Data.Maybe (catMaybes)+import Data.Text (Text)+import qualified Data.Text as T+import qualified Data.Text.Encoding as TE+import qualified Data.Text.IO as TIO+import Data.Time.Clock (UTCTime, diffUTCTime, getCurrentTime)+import Data.Time.Clock.POSIX (posixSecondsToUTCTime)+import Data.Time.Format (defaultTimeLocale, formatTime)+import Options.Applicative+import System.Directory (doesFileExist, removeFile)+import System.Exit (ExitCode(..))+import System.IO (hFlush, isEOF, stderr, stdout)++import Synapse.Self+ ( CredentialRef(..)+ , ParsedUri(..)+ , ParsedUriBody(..)+ , ResolveError(..)+ , ResolverRegistry+ , StoredDefaults(..)+ , defaultRegistry+ , defaultsPath+ , deleteFromKeychain+ , emptyStoredDefaults+ , isKeychainPlatform+ , loadDefaults+ , parseUri+ , resolveRef+ , storeInKeychain+ , writeDefaults+ )+import qualified Synapse.Self.IO as SelfIO++-- | Keychain service name for items created by @_self@ verbs.+-- Constant across backends — the per-backend key lives in the account.+keychainService :: Text+keychainService = "plexus"++-- | Keychain account name for a given (backend, kind, name).+-- Convention: @\<backend\>\/\<kind\>\/\<name\>@.+keychainAccountFor :: Text -> SelfKind -> Text -> Text+keychainAccountFor backend k name =+ backend <> "/" <> renderKind k <> "/" <> name++-- ============================================================================+-- ADT+-- ============================================================================++-- | Cookies vs. headers. The defaults file carries both; every verb+-- except @clear@ / @show@ / @import-token@ / bare @upgrade-to-keychain@+-- requires a kind.+data SelfKind = Cookie | Header+ deriving (Show, Eq)++-- | The 'StoredDefaults' field name for a kind — @"cookies"@ or @"headers"@.+kindLabel :: SelfKind -> Text+kindLabel Cookie = "cookies"+kindLabel Header = "headers"++-- | Singular noun for messages.+renderKind :: SelfKind -> Text+renderKind Cookie = "cookie"+renderKind Header = "header"++-- | Every verb in the @_self@ subcommand tree. Each value fully+-- determines the resulting 'IO' behavior — 'runSelfCommand' is a pure+-- dispatcher over this type.+data SelfCommand+ = CmdShow !Text !Bool+ -- ^ @show [--json]@. Backend, jsonMode.+ | CmdSet !Text !SelfKind !Text !Text+ -- ^ @set cookie|header \<name\> \<value-or-uri\>@.+ -- The value goes through 'wrapAsLiteralIfNeeded' before storage.+ | CmdSetFromStdin !Text !SelfKind !Text+ -- ^ @set-from-stdin cookie|header \<name\>@. Reads full stdin,+ -- stores as @literal:\<stdin\>@ regardless of scheme prefix.+ | CmdSetSecret !Text !SelfKind !Text+ -- ^ @set-secret cookie|header \<name\>@. Requires SELF-8; emits a+ -- friendly error until then.+ | CmdUnset !Text !SelfKind !Text !Bool !Bool+ -- ^ @unset cookie|header \<name\>@. Flags: @--yes@,+ -- @--no-delete-from-keychain@.+ | CmdResolve !Text !SelfKind !Text+ -- ^ @resolve cookie|header \<name\>@. Prints resolved value to+ -- stdout; errors to stderr; exits non-zero on failure.+ | CmdUpgrade !Text !(Maybe SelfKind) !(Maybe Text)+ -- ^ @upgrade-to-keychain [cookie|header] [\<name\>]@. Friendly error+ -- until SELF-8 regardless of arguments.+ | CmdClear !Text !Bool+ -- ^ @clear [--yes]@. Deletes the backend\'s defaults.json (not the+ -- directory).+ | CmdImportToken !Text !Text !Bool+ -- ^ @import-token \<path-or--> [--to-keychain]@. Path @"-"@ = stdin.+ -- @--to-keychain@ requires SELF-8.+ deriving (Show, Eq)++-- ============================================================================+-- Known schemes (static; see SELF-4 ticket)+-- ============================================================================++-- | The set of scheme prefixes a @set\<value-or-uri\>@ argument can+-- carry to skip the literal wrapper. Static on purpose: this must not+-- depend on the runtime registry because SELF-8 (keychain) wires in+-- its resolver dynamically, and SELF-4 predates that.+--+-- Bare strings matching none of these get wrapped as @literal:\<value\>@.+knownSchemes :: [Text]+knownSchemes =+ [ "literal:"+ , "env://"+ , "file://"+ , "keychain://"+ , "vault://"+ , "aws-secrets://"+ , "gcp-secret://"+ ]++-- | Wrap a value as @literal:\<value\>@ unless it begins with one of+-- the 'knownSchemes'. Preserves leading\/trailing whitespace so pasted+-- tokens round-trip byte-for-byte through @set@ / @resolve@.+wrapAsLiteralIfNeeded :: Text -> Text+wrapAsLiteralIfNeeded v+ | any (`T.isPrefixOf` v) knownSchemes = v+ | otherwise = "literal:" <> v++-- ============================================================================+-- Parser+-- ============================================================================++-- | Full @ParserInfo@ for @_self@. The executable attaches this under+-- its top-level @_self@ subcommand; @synapse-cc@ does the same.+selfCommandInfo :: ParserInfo SelfCommand+selfCommandInfo = info (parseSelfCommand <**> helper)+ ( fullDesc+ <> progDesc "Manage per-backend defaults (cookies, headers, credentials)"+ <> header "synapse _self - backend credential management"+ )++-- | The @\<backend\> \<verb\> ...@ parser. Exposed separately so the+-- @_self@ level can be folded under a parent @synapse ...@ parser+-- without double-@helper@.+parseSelfCommand :: Parser SelfCommand+parseSelfCommand =+ mkCmd <$> backendArg <*> verbSub+ where+ mkCmd b f = f b++ backendArg = argument (T.pack <$> str)+ ( metavar "BACKEND"+ <> help "Backend name (directory under ~/.plexus/)"+ )++ verbSub :: Parser (Text -> SelfCommand)+ verbSub = hsubparser+ ( command "show" (info showCmd (progDesc "Show effective defaults with resolved values"))+ <> command "set" (info setCmd (progDesc "Set a cookie or header to a value or URI"))+ <> command "set-from-stdin" (info setFromStdinCmd (progDesc "Set a cookie or header from stdin (always literal:)"))+ <> command "set-secret" (info setSecretCmd (progDesc "Push stdin to keychain and store a keychain:// ref"))+ <> command "unset" (info unsetCmd (progDesc "Remove a cookie or header entry"))+ <> command "resolve" (info resolveCmd (progDesc "Resolve and print a cookie or header value"))+ <> command "upgrade-to-keychain" (info upgradeCmd (progDesc "Move literal: refs into the keychain"))+ <> command "clear" (info clearCmd (progDesc "Delete this backend's defaults.json"))+ <> command "import-token" (info importTokenCmd (progDesc "Import a JWT from file or stdin as access_token"))+ )++ showCmd = (\j b -> CmdShow b j)+ <$> switch (long "json" <> help "Emit JSON (machine-readable)")+ setCmd = (\k n v b -> CmdSet b k n v)+ <$> kindArg+ <*> nameArg+ <*> argument (T.pack <$> str) (metavar "VALUE-OR-URI" <> help "Raw value, or a scheme-prefixed URI (literal:, env://, file://, keychain://, vault://)")+ setFromStdinCmd = (\k n b -> CmdSetFromStdin b k n) <$> kindArg <*> nameArg+ setSecretCmd = (\k n b -> CmdSetSecret b k n) <$> kindArg <*> nameArg+ unsetCmd = (\k n y nk b -> CmdUnset b k n y nk)+ <$> kindArg+ <*> nameArg+ <*> switch (long "yes" <> short 'y' <> help "Skip confirmation prompts")+ <*> switch (long "no-delete-from-keychain" <> help "If ref was keychain://, do not attempt to delete the keychain item")+ resolveCmd = (\k n b -> CmdResolve b k n) <$> kindArg <*> nameArg+ upgradeCmd = (\k n b -> CmdUpgrade b k n)+ <$> optional kindArg+ <*> optional nameArg+ clearCmd = (\y b -> CmdClear b y)+ <$> switch (long "yes" <> short 'y' <> help "Skip confirmation")+ importTokenCmd = (\p kc b -> CmdImportToken b p kc)+ <$> argument (T.pack <$> str) (metavar "PATH-OR--" <> help "Path to a JWT file; \"-\" reads from stdin")+ <*> switch (long "to-keychain" <> help "Push token to keychain and store a keychain:// ref (requires SELF-8)")++ kindArg = argument kindReader (metavar "KIND" <> help "cookie | header")+ nameArg = argument (T.pack <$> str) (metavar "NAME" <> help "Cookie or header name")++ kindReader = eitherReader $ \case+ "cookie" -> Right Cookie+ "cookies" -> Right Cookie+ "header" -> Right Header+ "headers" -> Right Header+ s -> Left ("expected 'cookie' or 'header', got: " <> s)++-- ============================================================================+-- Execution+-- ============================================================================++-- | Run a parsed 'SelfCommand'. Returns an 'ExitCode' rather than+-- calling 'exitWith' so callers can compose.+runSelfCommand :: SelfCommand -> IO ExitCode+runSelfCommand = \case+ CmdShow b jsonMode -> runShow b jsonMode+ CmdSet b k n v -> runSet b k n (wrapAsLiteralIfNeeded v)+ CmdSetFromStdin b k n -> runSetFromStdin b k n+ CmdSetSecret b k n+ | isKeychainPlatform -> runSetSecret b k n+ | otherwise -> keychainUnavailable "set-secret"+ CmdUnset b k n yes noKeychain -> runUnset b k n yes noKeychain+ CmdResolve b k n -> runResolve b k n+ CmdUpgrade b mk mn+ | isKeychainPlatform -> runUpgrade b mk mn+ | otherwise -> keychainUnavailable "upgrade-to-keychain"+ CmdClear b yes -> runClear b yes+ CmdImportToken b path toKc -> runImportToken b path toKc++-- ---------------------------------------------------------------------------+-- Helpers shared by verbs+-- ---------------------------------------------------------------------------++keychainUnavailable :: Text -> IO ExitCode+keychainUnavailable verb = do+ TIO.hPutStrLn stderr $ T.concat+ [ "Error: `", verb, "` requires the keychain resolver, which is not "+ , "available in this build.\n"+ , "\n"+ , "The keychain integration lands with SELF-8. In the meantime:\n"+ , " - Use `set` with an explicit scheme (env://, file://, literal:) to avoid keychain.\n"+ , " - Or store the value in your OS keychain by hand and reference it\n"+ , " directly with `keychain://...` once SELF-8 ships.\n"+ ]+ pure (ExitFailure 2)++-- | Pretty-print a 'ResolveError' for stderr, reusing the CLI's style+-- but slightly more verbose (names the failing URI for every error+-- variant).+renderResolveErrorText :: ResolveError -> Text+renderResolveErrorText = \case+ ResolveUnknownScheme s ->+ "unknown credential scheme `" <> s+ <> "` (no resolver registered; built-in schemes: literal, env, file)"+ ResolveNotFound (CredentialRef uri) ->+ "credential not found: " <> uri+ ResolveBackendError (CredentialRef uri) msg ->+ "credential backend error for " <> uri <> ": " <> msg+ ResolveParseError msg ->+ "malformed credential reference: " <> msg++-- | Loud load: either return the 'StoredDefaults', or print the IO+-- exception and exit. Used by every verb that reads the file.+loadOrExit :: Text -> IO StoredDefaults+loadOrExit backend = do+ r <- E.try (loadDefaults backend) :: IO (Either E.IOException StoredDefaults)+ case r of+ Right sd -> pure sd+ Left e -> do+ TIO.hPutStrLn stderr $+ "Error: failed to load defaults for backend `" <> backend <> "`: "+ <> T.pack (show e)+ _ <- E.throwIO e+ pure emptyStoredDefaults -- unreachable; throwIO terminates++-- | Pull the map a kind refers to out of a 'StoredDefaults'.+kindMap :: SelfKind -> StoredDefaults -> Map Text CredentialRef+kindMap Cookie = sdCookies+kindMap Header = sdHeaders++-- | Replace a kind's map inside a 'StoredDefaults'.+setKindMap :: SelfKind -> Map Text CredentialRef -> StoredDefaults -> StoredDefaults+setKindMap Cookie m sd = sd { sdCookies = m }+setKindMap Header m sd = sd { sdHeaders = m }++-- ---------------------------------------------------------------------------+-- show+-- ---------------------------------------------------------------------------++-- | Entry for `_self <backend> show [--json]`. Loads, resolves each+-- entry against 'defaultRegistry', then emits either a human-readable+-- table or a JSON blob.+runShow :: Text -> Bool -> IO ExitCode+runShow backend jsonMode = do+ sd <- loadOrExit backend+ now <- getCurrentTime+ entries <- buildShowEntries defaultRegistry sd+ if jsonMode+ then do+ LBS.putStr (encodePrettyJson (entriesToJson backend entries))+ BS8.putStrLn ""+ pure ExitSuccess+ else do+ renderShowText backend sd now entries+ pure ExitSuccess++-- | An entry in the `show` output — one cookie or header.+data ShowEntry = ShowEntry+ { seKind :: !SelfKind+ , seName :: !Text+ , seRef :: !CredentialRef+ , seResolver :: !Text+ , seResult :: !(Either ResolveError Text)+ }++-- | Best-effort resolver name for a URI. Falls back to the raw scheme+-- string on unparseable URIs (which will also surface as a resolve+-- error later; this only feeds the per-line resolver label).+resolverName :: CredentialRef -> Text+resolverName ref = case parseUri ref of+ Left _ -> "?"+ Right p -> puScheme p++buildShowEntries :: ResolverRegistry -> StoredDefaults -> IO [ShowEntry]+buildShowEntries reg sd = do+ let pull k = [ (kn, cref) | (kn, cref) <- Map.toAscList (kindMap k sd) ]+ let allRefs = [(Cookie, n, r) | (n, r) <- pull Cookie]+ ++ [(Header, n, r) | (n, r) <- pull Header]+ mapM (\(k, n, r) -> do+ res <- resolveRef reg r+ pure ShowEntry+ { seKind = k+ , seName = n+ , seRef = r+ , seResolver = resolverName r+ , seResult = res+ }) allRefs++renderShowText :: Text -> StoredDefaults -> UTCTime -> [ShowEntry] -> IO ()+renderShowText backend _sd now entries = do+ let path = defaultsPath backend+ TIO.putStrLn $ "Backend: " <> backend+ TIO.putStrLn $ "Path: " <> T.pack path+ let (cookies, headers) = partitionKinds entries+ renderSection "Cookies" cookies now+ renderSection "Headers" headers now+ case (null cookies, null headers) of+ (True, True) -> TIO.putStrLn "\n(no defaults configured for this backend)"+ _ -> pure ()++partitionKinds :: [ShowEntry] -> ([ShowEntry], [ShowEntry])+partitionKinds = foldr step ([], [])+ where+ step e (cs, hs) = case seKind e of+ Cookie -> (e : cs, hs)+ Header -> (cs, e : hs)++renderSection :: Text -> [ShowEntry] -> UTCTime -> IO ()+renderSection _ [] _ = pure ()+renderSection title es now = do+ TIO.putStrLn ""+ TIO.putStrLn $ title <> ":"+ mapM_ (renderShowEntry now) es++renderShowEntry :: UTCTime -> ShowEntry -> IO ()+renderShowEntry now ShowEntry{..} = do+ let CredentialRef uri = seRef+ TIO.putStrLn $ " " <> seName <> " -> " <> uri <> " [" <> seResolver <> "]"+ case seResult of+ Left err -> do+ TIO.putStrLn $ " x " <> renderResolveErrorText err+ Right val -> do+ TIO.putStrLn " ok resolved"+ case decodeJwt val of+ Nothing -> pure () -- Not a JWT; don't print the value (might be a secret).+ Just summary -> mapM_ (TIO.putStrLn . (" " <>)) (renderJwtSummary now summary)++-- | Machine-readable JSON form of the full show output.+entriesToJson :: Text -> [ShowEntry] -> Value+entriesToJson backend entries =+ let (cookies, headers) = partitionKinds entries+ in object+ [ "backend" .= backend+ , "path" .= defaultsPath backend+ , "cookies" .= fmap entryToJson cookies+ , "headers" .= fmap entryToJson headers+ ]++entryToJson :: ShowEntry -> Value+entryToJson ShowEntry{..} =+ let CredentialRef uri = seRef+ base =+ [ "name" .= seName+ , "uri" .= uri+ , "resolver" .= seResolver+ ]+ outcome = case seResult of+ Left err ->+ [ "resolved" .= False+ , "error" .= renderResolveErrorText err+ ]+ Right val ->+ let jwt = decodeJwt val+ jwtPairs = case jwt of+ Nothing -> []+ Just s -> [ "jwt" .= jwtToJson s ]+ in [ "resolved" .= True+ -- NEVER include the resolved value in JSON either — JWT+ -- claims go through jwtToJson (sans signature) and other+ -- secrets stay on disk.+ ] <> jwtPairs+ in object (base <> outcome)++encodePrettyJson :: Value -> LBS.ByteString+encodePrettyJson = encodePretty' Config+ { confIndent = Spaces 2+ , confCompare = compare+ , confNumFormat = Generic+ , confTrailingNewline = False+ }++-- ---------------------------------------------------------------------------+-- set / set-from-stdin+-- ---------------------------------------------------------------------------++-- | Implement @set@ (after the heuristic has already been applied by+-- 'runSelfCommand').+runSet :: Text -> SelfKind -> Text -> Text -> IO ExitCode+runSet backend k name rawRef = do+ sd <- loadOrExit backend+ let m = kindMap k sd+ m' = Map.insert name (CredentialRef rawRef) m+ writeDefaults backend (setKindMap k m' sd)+ TIO.putStrLn $ "Stored " <> renderKind k <> " `" <> name+ <> "` -> " <> rawRef <> " for backend `" <> backend <> "`."+ pure ExitSuccess++-- | Implement @set-from-stdin@: read entire stdin, wrap as @literal:@+-- regardless of content, then delegate to 'runSet'.+--+-- A single trailing newline is stripped — users invoking this via+-- @echo value | ...@ almost always mean "the value is @value@, not+-- @value\\n@." Mirrors the file resolver's newline handling+-- ('Synapse.Self.Resolve.File') for consistency.+runSetFromStdin :: Text -> SelfKind -> Text -> IO ExitCode+runSetFromStdin backend k name = do+ value <- T.pack <$> getContents+ let trimmed = stripTrailingNewline value+ runSet backend k name ("literal:" <> trimmed)++stripTrailingNewline :: Text -> Text+stripTrailingNewline t = case T.unsnoc t of+ Just (pfx, '\n') -> pfx+ _ -> t++-- ---------------------------------------------------------------------------+-- unset+-- ---------------------------------------------------------------------------++-- | Implement @unset@. If the removed ref had a @keychain://@ scheme+-- and the user doesn't opt out, prompt to "also delete from keychain"+-- — but the actual deletion is a no-op until SELF-8 lands.+runUnset :: Text -> SelfKind -> Text -> Bool -> Bool -> IO ExitCode+runUnset backend k name yes noKeychain = do+ sd <- loadOrExit backend+ let m = kindMap k sd+ case Map.lookup name m of+ Nothing -> do+ TIO.hPutStrLn stderr $+ "No " <> renderKind k <> " named `" <> name+ <> "` in backend `" <> backend <> "`."+ pure (ExitFailure 1)+ Just ref@(CredentialRef uri) -> do+ let m' = Map.delete name m+ writeDefaults backend (setKindMap k m' sd)+ TIO.putStrLn $ "Removed " <> renderKind k <> " `" <> name+ <> "` (was " <> uri <> ")."+ -- Keychain cleanup prompt (no-op until SELF-8; prompt still fires+ -- so users see consistent behavior across versions).+ let isKeychainRef = case parseUri ref of+ Right (ParsedUri "keychain" _) -> True+ _ -> False+ if isKeychainRef && not noKeychain+ then do+ shouldDelete <-+ if yes+ then pure True+ else promptYesNo "Also delete keychain item? [Y/n] " True+ if shouldDelete+ then case parseUri ref of+ Right (ParsedUri "keychain"+ (HierarchicalBody svc path _)) ->+ case T.stripPrefix "/" path of+ Just account | not (T.null account) -> do+ r <- deleteFromKeychain svc account+ case r of+ Right () -> TIO.putStrLn "Deleted keychain item."+ Left err -> TIO.hPutStrLn stderr $+ "Warning: keychain delete failed: " <> err+ _ -> TIO.hPutStrLn stderr+ "Warning: malformed keychain URI — cannot delete item."+ _ -> TIO.hPutStrLn stderr+ "Warning: could not parse keychain URI — skipped delete."+ else TIO.putStrLn "(kept the keychain entry on disk)"+ else pure ()+ pure ExitSuccess++-- | Prompt yes/no. Default used when the user hits Enter.+promptYesNo :: Text -> Bool -> IO Bool+promptYesNo prompt defaultYes = do+ TIO.putStr prompt+ hFlush stdout+ done <- isEOF+ if done+ then pure defaultYes+ else do+ line <- TIO.getLine+ let trimmed = T.toLower (T.strip line)+ pure $ case trimmed of+ "" -> defaultYes+ "y" -> True+ "yes" -> True+ "n" -> False+ "no" -> False+ _ -> defaultYes++-- ---------------------------------------------------------------------------+-- resolve+-- ---------------------------------------------------------------------------++-- | Implement @resolve@. Prints the URI to stdout (first line) and the+-- resolved value (second line) on success. On failure, the URI still+-- goes to stdout (useful for diagnostics) and the error goes to+-- stderr, with a non-zero exit.+--+-- Unlike @show@, @resolve@ *does* print the resolved value — this is+-- the command users run when they explicitly want to see the secret+-- (e.g. for comparison, or to pipe into another tool).+runResolve :: Text -> SelfKind -> Text -> IO ExitCode+runResolve backend k name = do+ sd <- loadOrExit backend+ let m = kindMap k sd+ case Map.lookup name m of+ Nothing -> do+ TIO.hPutStrLn stderr $+ "No " <> renderKind k <> " named `" <> name+ <> "` in backend `" <> backend <> "`."+ pure (ExitFailure 1)+ Just ref@(CredentialRef uri) -> do+ TIO.putStrLn uri+ r <- resolveRef defaultRegistry ref+ case r of+ Right val -> do+ TIO.putStrLn val+ pure ExitSuccess+ Left err -> do+ TIO.hPutStrLn stderr $ "Error: " <> renderResolveErrorText err+ pure (ExitFailure 1)++-- ---------------------------------------------------------------------------+-- clear+-- ---------------------------------------------------------------------------++-- | Implement @clear@. Prompts unless @--yes@. Deletes the defaults+-- file only; never removes the parent @~\/.plexus\/\<backend\>@+-- directory (might hold unrelated files).+runClear :: Text -> Bool -> IO ExitCode+runClear backend yes = do+ path <- SelfIO.absDefaultsPath backend+ exists <- doesFileExist path+ if not exists+ then do+ TIO.putStrLn $ "No defaults file to clear for backend `" <> backend+ <> "` (path: " <> T.pack path <> ")."+ pure ExitSuccess+ else do+ proceed <-+ if yes+ then pure True+ else promptYesNo+ ("Delete " <> T.pack path <> "? [y/N] ") False+ if proceed+ then do+ r <- E.try (removeFile path) :: IO (Either IOException ())+ case r of+ Right () -> do+ TIO.putStrLn $ "Cleared defaults for backend `" <> backend <> "`."+ pure ExitSuccess+ Left e -> do+ TIO.hPutStrLn stderr $ "Error: " <> T.pack (show e)+ pure (ExitFailure 1)+ else do+ TIO.putStrLn "Aborted."+ pure (ExitFailure 1)++-- ---------------------------------------------------------------------------+-- import-token+-- ---------------------------------------------------------------------------++-- | Implement @import-token@. Reads a file (or stdin if @"-"@). By+-- default stores the content as @literal:\<contents\>@ under+-- @cookies.access_token@. With @--to-keychain@ on a supported platform,+-- pushes the token into the OS keychain and stores a @keychain://@+-- reference instead.+runImportToken :: Text -> Text -> Bool -> IO ExitCode+runImportToken backend path toKeychain+ | toKeychain, not isKeychainPlatform =+ keychainUnavailable "import-token --to-keychain"+ | otherwise = do+ token <- readTokenSource path+ let trimmed = T.strip token+ if T.null trimmed+ then do+ TIO.hPutStrLn stderr "Error: token source is empty."+ pure (ExitFailure 1)+ else if toKeychain+ then do+ let account = keychainAccountFor backend Cookie "access_token"+ r <- storeInKeychain keychainService account trimmed+ case r of+ Left err -> do+ TIO.hPutStrLn stderr $ "Error: " <> err+ pure (ExitFailure 1)+ Right () -> do+ sd <- loadOrExit backend+ let refUri = "keychain://" <> keychainService+ <> "/" <> account+ m = sdCookies sd+ m' = Map.insert "access_token"+ (CredentialRef refUri) m+ writeDefaults backend (sd { sdCookies = m' })+ TIO.putStrLn $+ "Imported access_token for backend `" <> backend+ <> "` (stored in keychain as " <> refUri <> ")."+ pure ExitSuccess+ else do+ sd <- loadOrExit backend+ let m = sdCookies sd+ m' = Map.insert "access_token"+ (CredentialRef ("literal:" <> trimmed)) m+ writeDefaults backend (sd { sdCookies = m' })+ TIO.putStrLn $ "Imported access_token for backend `" <> backend+ <> "` (stored as literal:). "+ <> "Consider `_self " <> backend+ <> " upgrade-to-keychain` for a more secure posture."+ pure ExitSuccess++-- ---------------------------------------------------------------------------+-- set-secret+-- ---------------------------------------------------------------------------++-- | Implement @set-secret@. Reads stdin, pushes the value into the OS+-- keychain under @service=plexus@ and+-- @account=\<backend\>\/\<kind\>\/\<name\>@, then stores a+-- @keychain://@ reference in @defaults.json@.+--+-- Called only when 'isKeychainPlatform' is True; the dispatch in+-- 'runSelfCommand' routes non-macOS to 'keychainUnavailable'.+runSetSecret :: Text -> SelfKind -> Text -> IO ExitCode+runSetSecret backend k name = do+ raw <- T.pack <$> getContents+ let value = T.strip raw+ if T.null value+ then do+ TIO.hPutStrLn stderr "Error: stdin is empty; nothing to store."+ pure (ExitFailure 1)+ else do+ let account = keychainAccountFor backend k name+ r <- storeInKeychain keychainService account value+ case r of+ Left err -> do+ TIO.hPutStrLn stderr $ "Error: " <> err+ pure (ExitFailure 1)+ Right () -> do+ sd <- loadOrExit backend+ let refUri = "keychain://" <> keychainService <> "/" <> account+ m = kindMap k sd+ m' = Map.insert name (CredentialRef refUri) m+ writeDefaults backend (setKindMap k m' sd)+ TIO.putStrLn $ "Stored " <> renderKind k <> " `" <> name+ <> "` as " <> refUri+ pure ExitSuccess++-- ---------------------------------------------------------------------------+-- upgrade-to-keychain+-- ---------------------------------------------------------------------------++-- | Implement @upgrade-to-keychain@. For each matching @literal:@ ref,+-- push the value into the OS keychain and rewrite the stored ref as+-- @keychain://@. Non-literal refs are left alone (they're already+-- indirect). Scope:+--+-- * @upgrade-to-keychain@ — every literal: entry, both kinds+-- * @upgrade-to-keychain cookie@ — every literal: cookie+-- * @upgrade-to-keychain cookie NAME@ — that single entry+--+-- Called only when 'isKeychainPlatform' is True; dispatch routes+-- non-macOS to 'keychainUnavailable'.+runUpgrade :: Text -> Maybe SelfKind -> Maybe Text -> IO ExitCode+runUpgrade backend mKind mName = do+ sd <- loadOrExit backend+ let kindsToWalk = case mKind of+ Just k -> [k]+ Nothing -> [Cookie, Header]+ (sd', upgraded, skipped, errs) <-+ foldlIOM (sd, 0 :: Int, 0 :: Int, [] :: [Text]) kindsToWalk $+ \(accSd, u, s, es) k -> do+ let m = kindMap k accSd+ entries = case mName of+ Just n -> [(kn, v) | (kn, v) <- Map.toAscList m, kn == n]+ Nothing -> Map.toAscList m+ foldlIOM (accSd, u, s, es) entries $ \(iterSd, iu, isk, ies) (kn, ref) ->+ case stripLiteralPrefix ref of+ Nothing -> pure (iterSd, iu, isk + 1, ies)+ Just value -> do+ let account = keychainAccountFor backend k kn+ r <- storeInKeychain keychainService account value+ case r of+ Left err ->+ pure (iterSd, iu, isk, ("`" <> kn <> "`: " <> err) : ies)+ Right () -> do+ let refUri = "keychain://" <> keychainService+ <> "/" <> account+ m' = Map.insert kn (CredentialRef refUri)+ (kindMap k iterSd)+ iterSd' = setKindMap k m' iterSd+ pure (iterSd', iu + 1, isk, ies)+ -- Only rewrite the file if anything changed.+ if upgraded > 0+ then writeDefaults backend sd'+ else pure ()+ case mName of+ Just n | upgraded == 0 && null errs ->+ TIO.hPutStrLn stderr $+ "No literal: entry named `" <> n <> "` to upgrade."+ _ -> pure ()+ TIO.putStrLn $ T.concat+ [ "Upgraded ", T.pack (show upgraded), " ref(s) to keychain; skipped "+ , T.pack (show skipped), " non-literal entries."+ ]+ case errs of+ [] -> pure ()+ _ -> do+ TIO.hPutStrLn stderr "Some entries failed:"+ mapM_ (\e -> TIO.hPutStrLn stderr $ " - " <> e) (reverse errs)+ pure (if null errs then ExitSuccess else ExitFailure 1)++-- | Return @Just value@ if a 'CredentialRef' is @literal:\<value\>@,+-- else 'Nothing'.+stripLiteralPrefix :: CredentialRef -> Maybe Text+stripLiteralPrefix (CredentialRef uri) = T.stripPrefix "literal:" uri++-- | Monadic left fold for IO, no external library. Accumulator threads+-- through each step; returns the final accumulator.+foldlIOM :: acc -> [a] -> (acc -> a -> IO acc) -> IO acc+foldlIOM acc [] _ = pure acc+foldlIOM acc (x:xs) f = do+ acc' <- f acc x+ foldlIOM acc' xs f++-- | Read a token from a path. @"-"@ means stdin; all other strings are+-- paths on disk. A trailing newline is preserved here — the caller+-- trims before storage.+readTokenSource :: Text -> IO Text+readTokenSource "-" = T.pack <$> getContents+readTokenSource p = TIO.readFile (T.unpack p)++-- ============================================================================+-- JWT inspection+-- ============================================================================++-- | Structured summary of a JWT. We only record fields the CLI prints.+-- Signatures are never stored.+data JwtSummary = JwtSummary+ { jsAlg :: !(Maybe Text)+ , jsKid :: !(Maybe Text)+ , jsIss :: !(Maybe Text)+ , jsAud :: !(Maybe Text) -- ^ Rendered: first aud entry if array.+ , jsSub :: !(Maybe Text)+ , jsExp :: !(Maybe UTCTime)+ , jsIat :: !(Maybe UTCTime)+ , jsUsername :: !(Maybe Text) -- ^ @preferred_username@+ , jsEmail :: !(Maybe Text)+ , jsExtraClaims :: ![JwtClaim] -- ^ Retained for future; unused in v1.+ }+ deriving (Show, Eq)++-- | A single JWT claim, name + rendered value. Reserved for future+-- extension (e.g. scope, roles). Currently unused — the named fields+-- on 'JwtSummary' cover everything we print.+data JwtClaim = JwtClaim+ { jcName :: !Text+ , jcValue :: !Text+ }+ deriving (Show, Eq)++-- | Attempt to decode a string as a JWT. Returns 'Nothing' on anything+-- that isn't JWT-shaped. A "JWT-shaped" value has exactly two dots,+-- each of the three segments base64-decodes cleanly (URL-safe, with or+-- without padding), and the header JSON has an @alg@ field.+--+-- Decoding never fails loudly: a value that looks JWT-shaped but has+-- a non-JSON payload yields 'Nothing'.+decodeJwt :: Text -> Maybe JwtSummary+decodeJwt t = case T.splitOn "." t of+ [h, p, _sig]+ | not (T.null h), not (T.null p) -> do+ headerJson <- decodeSegment h+ payloadJson <- decodeSegment p+ -- Header must have an `alg` field to count as a JWT.+ case headerJson of+ Object o | KM.member "alg" o -> Just (buildSummary headerJson payloadJson)+ _ -> Nothing+ _ -> Nothing++decodeSegment :: Text -> Maybe Value+decodeSegment seg =+ let bs = TE.encodeUtf8 seg+ -- JWT uses URL-safe base64 with padding stripped.+ padded = padBase64 bs+ in case B64U.decode padded of+ Left _ -> Nothing+ Right raw -> case eitherDecodeStrict' raw :: Either String Value of+ Left _ -> Nothing+ Right v -> Just v++-- | Re-pad a URL-safe base64 string to a multiple of 4. @base64-bytestring@+-- requires padding for decoding; JWTs strip it.+padBase64 :: BS.ByteString -> BS.ByteString+padBase64 bs =+ let n = BS.length bs+ r = n `mod` 4+ pad = case r of+ 0 -> 0+ 2 -> 2+ 3 -> 1+ _ -> 0 -- 1 is invalid; let decode fail on it+ in bs <> BS8.replicate pad '='++buildSummary :: Value -> Value -> JwtSummary+buildSummary header payload = JwtSummary+ { jsAlg = stringClaim header "alg"+ , jsKid = stringClaim header "kid"+ , jsIss = stringClaim payload "iss"+ , jsAud = audClaim payload+ , jsSub = stringClaim payload "sub"+ , jsExp = timeClaim payload "exp"+ , jsIat = timeClaim payload "iat"+ , jsUsername = stringClaim payload "preferred_username"+ , jsEmail = stringClaim payload "email"+ , jsExtraClaims = []+ }++-- | Pull a string claim out of a JSON object. Returns 'Nothing' for+-- non-object values, missing fields, or fields that aren't strings.+stringClaim :: Value -> Text -> Maybe Text+stringClaim (Object o) k = case KM.lookup (Key.fromText k) o of+ Just (String s) -> Just s+ _ -> Nothing+stringClaim _ _ = Nothing++-- | @aud@ can be either a string or an array of strings. We render the+-- first element in either case.+audClaim :: Value -> Maybe Text+audClaim (Object o) = case KM.lookup "aud" o of+ Just (String s) -> Just s+ Just (Array arr) -> case foldMap stringOf arr of+ [] -> Nothing+ (x:_) -> Just x+ _ -> Nothing+ where+ stringOf (String s) = [s]+ stringOf _ = []+audClaim _ = Nothing++-- | Integer seconds-since-epoch as a 'UTCTime'.+timeClaim :: Value -> Text -> Maybe UTCTime+timeClaim (Object o) k = case KM.lookup (Key.fromText k) o of+ Just (Number n) -> Just (posixSecondsToUTCTime (realToFrac n))+ _ -> Nothing+timeClaim _ _ = Nothing++-- | Render a JWT summary as a list of aligned label/value lines.+-- Never emits the signature; never emits raw claim bodies.+renderJwtSummary :: UTCTime -> JwtSummary -> [Text]+renderJwtSummary now JwtSummary{..} = catMaybes+ [ fmap (labelled "alg") jsAlg+ , fmap (labelled "kid" . truncateKid) jsKid+ , fmap (labelled "iss") jsIss+ , fmap (labelled "aud") jsAud+ , fmap (labelled "sub") jsSub+ , fmap (\t ->+ labelled "exp"+ (T.pack (formatISO8601 t) <> " (" <> renderRelativeExpiry now t <> ")"))+ jsExp+ , fmap (\t -> labelled "iat" (T.pack (formatISO8601 t))) jsIat+ , case (jsUsername, jsEmail) of+ (Just u, Just e) -> Just $ labelled "user" (u <> " (" <> e <> ")")+ (Just u, Nothing) -> Just $ labelled "user" u+ (Nothing, Just e) -> Just $ labelled "user" e+ (Nothing, Nothing) -> Nothing+ ]+ where+ labelled label val = label <> padTo 5 label <> val+ padTo n lab = T.replicate (max 1 (n - T.length lab)) " "++-- | Long `kid` values are typically random key-IDs that add visual+-- noise without aiding identification. Keep the first 6 chars and+-- elide the tail.+truncateKid :: Text -> Text+truncateKid t+ | T.length t > 16 = T.take 6 t <> "..." <> T.takeEnd 4 t+ | otherwise = t++formatISO8601 :: UTCTime -> String+formatISO8601 = formatTime defaultTimeLocale "%Y-%m-%dT%H:%M:%SZ"++-- | Render @exp - now@ as a human-readable relative duration:+-- "expired 16d 4h ago" for past, "valid for 4m 32s" for future.+--+-- Uses the two largest non-zero units (d/h, h/m, m/s, or just s for+-- sub-minute), which matches the operator-facing prose the ticket asks+-- for: glance-readable, enough precision to act on.+renderRelativeExpiry :: UTCTime -> UTCTime -> Text+renderRelativeExpiry now exp_ =+ let delta = diffUTCTime exp_ now+ secs :: Integer+ secs = floor (realToFrac delta :: Double)+ absSecs = abs secs+ (d, r1) = absSecs `divMod` 86400+ (h, r2) = r1 `divMod` 3600+ (m, s) = r2 `divMod` 60+ unitPair = case (d, h, m, s) of+ _ | d > 0 && h > 0 -> T.pack (show d) <> "d " <> T.pack (show h) <> "h"+ _ | d > 0 -> T.pack (show d) <> "d"+ _ | h > 0 && m > 0 -> T.pack (show h) <> "h " <> T.pack (show m) <> "m"+ _ | h > 0 -> T.pack (show h) <> "h"+ _ | m > 0 && s > 0 -> T.pack (show m) <> "m " <> T.pack (show s) <> "s"+ _ | m > 0 -> T.pack (show m) <> "m"+ _ -> T.pack (show s) <> "s"+ in if secs < 0+ then "expired " <> unitPair <> " ago"+ else "valid for " <> unitPair++-- | Minimal JSON render of a 'JwtSummary' for the @--json@ output path.+jwtToJson :: JwtSummary -> Value+jwtToJson JwtSummary{..} = object $ catMaybes+ [ fmap ("alg" .=) jsAlg+ , fmap ("kid" .=) jsKid+ , fmap ("iss" .=) jsIss+ , fmap ("aud" .=) jsAud+ , fmap ("sub" .=) jsSub+ , fmap (\t -> "exp" .= formatISO8601 t) jsExp+ , fmap (\t -> "iat" .= formatISO8601 t) jsIat+ , fmap ("preferred_username" .=) jsUsername+ , fmap ("email" .=) jsEmail+ ]
src/Synapse/Self/Commands.hs view
@@ -21,7 +21,7 @@ import Synapse.Backend.Discovery (getBackendAt) import qualified Synapse.Self.Template as Template import qualified Synapse.Self.Debugger as Debugger-import Plexus.Client (SubstrateConfig(..))+import Synapse.Endpoint (substrateConfig) -- | Dispatch to a specific _self subcommand dispatch :: Text -> [Text] -> [(Text, Text)] -> SynapseM ()@@ -48,20 +48,13 @@ let methodParams = filter (\(k, _) -> k /= "allow_unknown" && k /= "raw") params -- Get connection settings from environment (set by -H/-P flags)- host <- asks seHost- port <- asks sePort+ ep <- asks seEndpoint backend <- asks seBackend case rest of methodParts | not (null methodParts) -> do let method = T.intercalate "." methodParts- let config = SubstrateConfig- { substrateHost = T.unpack host- , substratePort = port- , substratePath = "/"- , substrateBackend = backend- , substrateHeaders = []- }+ let config = substrateConfig ep backend [] liftIO $ Debugger.testMethod config method methodParams allowUnknown rawJson _ -> throwParse "Usage: synapse [options] _self test [--allow-unknown] [--raw <json>] <method> [--param value ...]\n Note: Use -H/--host and -P/--port flags to set connection (default: 127.0.0.1:4444)"
+ src/Synapse/Self/IO.hs view
@@ -0,0 +1,445 @@+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE RecordWildCards #-}+{-# LANGUAGE ScopedTypeVariables #-}++-- | IO glue for the per-backend defaults store (SELF-2).+--+-- Three pieces live here:+--+-- * 'loadDefaults' — read @~\/.plexus\/\<backend\>\/defaults.json@ from disk.+-- * 'resolveAll' — dispatch every 'CredentialRef' in a 'StoredDefaults'+-- through a 'ResolverRegistry', yielding a+-- 'ResolvedDefaults' with concrete values.+-- * 'merge' — pure left-biased merge of CLI-provided cookies\/headers+-- over the resolved stored defaults. CLI wins per key.+--+-- The three compose into the read path that synapse\'s request builder uses+-- on every invocation: load → resolve → merge → hand to the upgrade.+--+-- == Platform notes+--+-- POSIX mode semantics (@chmod 0600@ on sensitive files, @0700@ on the+-- parent dir, permission-sniffing on read) apply only on Unix. On+-- Windows ('System.Info.os' == @"mingw32"@) those steps are skipped —+-- the atomic temp-file + rename dance still happens, but permission+-- enforcement is a platform gap tracked by the SELF-5 ticket's \"Out of+-- scope\" list (no ACL story yet).+module Synapse.Self.IO+ ( -- * Resolved defaults (concrete values, not refs)+ ResolvedDefaults(..)+ , emptyResolvedDefaults++ -- * Aliases used by the pipeline+ , MethodPath+ , Cookies+ , Headers++ -- * Load / resolve / merge+ , loadDefaults+ , resolveAll+ , merge++ -- * Write path (atomic rename + mode-aware chmod; SELF-5)+ , writeDefaults+ , absDefaultsPath+ ) where++import Control.Exception (IOException, bracketOnError, throwIO, try)+import qualified Control.Exception as E+import qualified Data.ByteString as BS+import Data.Map.Strict (Map)+import qualified Data.Map.Strict as Map+import Data.Text (Text)+import qualified Data.Text as T+import qualified Data.Text.Encoding as TE+import System.Directory+ ( createDirectoryIfMissing+ , doesDirectoryExist+ , doesFileExist+ , getHomeDirectory+ , removeFile+ , renameFile+ )+import System.FilePath ((</>), takeDirectory)+import System.IO (hPutStrLn, stderr)+import System.IO.Error (ioeGetErrorString, isDoesNotExistError, userError)+import qualified System.Info+import System.Posix.Files (FileStatus, fileMode, getFileStatus, setFileMode)+import System.Posix.Types (FileMode)+import Data.Bits ((.&.))++import Synapse.Self.Resolve+ ( ResolveError(..)+ , ResolverRegistry+ , resolveRef+ )+import Synapse.Self.Types+ ( CredentialRef(..)+ , StoredDefaults(..)+ , decodeDefaults+ , encodeDefaults+ , emptyStoredDefaults+ )++-- ============================================================================+-- Types used by the request-building pipeline+-- ============================================================================++-- | A method path. v1 ignores the contents entirely — it exists so SELF-7++-- can introduce scope matching without a signature change.+type MethodPath = [Text]++-- | Cookies attached to the WebSocket upgrade, keyed by name.+type Cookies = Map Text Text++-- | Extra HTTP headers attached to the upgrade, keyed by name.+type Headers = Map Text Text++-- | A 'StoredDefaults' after every 'CredentialRef' has been dereferenced+-- through a 'ResolverRegistry'. Parallels the on-disk shape but carries+-- concrete 'Text' values instead of refs.+data ResolvedDefaults = ResolvedDefaults+ { rdCookies :: !Cookies+ , rdHeaders :: !Headers+ }+ deriving stock (Show, Eq)++-- | Empty resolved defaults — what 'resolveAll' returns for+-- 'emptyStoredDefaults'.+emptyResolvedDefaults :: ResolvedDefaults+emptyResolvedDefaults = ResolvedDefaults+ { rdCookies = Map.empty+ , rdHeaders = Map.empty+ }++-- ============================================================================+-- loadDefaults+-- ============================================================================++-- | Compute the absolute path to a backend's defaults file, expanding @~@+-- via 'getHomeDirectory'. 'Synapse.Self.defaultsPath' returns the+-- tilde-prefixed form for pure code; this is the IO-side expansion.+absDefaultsPath :: Text -> IO FilePath+absDefaultsPath backend = do+ home <- getHomeDirectory+ pure (home </> ".plexus" </> T.unpack backend </> "defaults.json")++-- | Read a backend's defaults file.+--+-- * Missing file → 'emptyStoredDefaults' (NOT an error — absence means+-- "no defaults configured for this backend").+-- * Parse failure → 'IOException' naming the file path and the decoder's+-- message.+-- * Other IO errors (permission denied, …) → propagate as-is so the caller+-- gets a clear platform error.+--+-- __SELF-5:__ after a successful decode, inspect the file contents and+-- its Unix mode. If the encoded bytes contain a @literal:@ substring+-- (i.e. a raw credential value lives in the file) AND the mode has any+-- group- or world-accessible bits set (@mode .&. 0o077 \/= 0@), emit a+-- stderr WARN line recommending @chmod 600 \<path\>@. The read still+-- succeeds — the warning is a nudge, not a failure.+--+-- Files that contain only @keychain:\/\/@ \/ @env:\/\/@ \/ @file:\/\/@+-- refs are strictly a manifest and are safe at any mode; no warning is+-- emitted for those regardless of permissions. On Windows the mode+-- check is skipped entirely (no POSIX mode semantics).+loadDefaults :: Text -> IO StoredDefaults+loadDefaults backend = do+ -- SELF-3: on entry, migrate the legacy ~/.plexus/tokens/<backend> file+ -- to the new defaults.json (or delete it if it's stale / empty). After+ -- migration completes (or is a no-op), the body below reads the new+ -- file naturally — no double-work for the caller.+ migrateIfNeeded backend+ path <- absDefaultsPath backend+ exists <- doesFileExist path+ if not exists+ then pure emptyStoredDefaults+ else do+ result <- try (BS.readFile path) :: IO (Either IOException BS.ByteString)+ case result of+ Left ioErr+ | isDoesNotExistError ioErr -> pure emptyStoredDefaults+ | otherwise ->+ throwIO $ userError $+ "loadDefaults: failed to read " <> path+ <> ": " <> ioeGetErrorString ioErr+ Right bs -> case decodeDefaults bs of+ Left err ->+ throwIO $ userError $+ "loadDefaults: failed to parse " <> path+ <> ": " <> T.unpack err+ Right sd -> do+ warnIfInsecureMode path bs+ pure sd++-- ============================================================================+-- migrateIfNeeded (SELF-3: legacy ~/.plexus/tokens/<backend> → defaults.json)+-- ============================================================================++-- | Compute the absolute path to the legacy per-backend token file+-- (@~\/.plexus\/tokens\/\<backend\>@).+--+-- This is the /only/ call site in synapse that references+-- @~\/.plexus\/tokens\/@. Every other read-path has been routed through+-- 'loadDefaults'; the legacy path lives here solely so first-boot+-- migration can drain it. Grep enforcement: after SELF-3,+-- @git grep -n 'plexus\/tokens'@ should return zero hits outside this+-- function (and the ticket's own plans\/ docs).+legacyTokenPath :: Text -> IO FilePath+legacyTokenPath backend = do+ home <- getHomeDirectory+ pure (home </> ".plexus" </> "tokens" </> T.unpack backend)++-- | First-boot migration of the legacy plaintext token file into a+-- @defaults.json@ manifest with a @literal:\<jwt\>@ ref.+--+-- Runs at the top of every 'loadDefaults' invocation but is effectively+-- a no-op in steady state (fast path: the legacy file does not exist).+-- Three cases:+--+-- 1. New file absent, legacy absent → nothing to do.+-- 2. New file present, legacy present → legacy is stale; delete it+-- unread and leave the new file untouched. Log INFO.+-- 3. New file absent, legacy present → migrate:+-- * Read the legacy file; trim leading\/trailing whitespace.+-- * If the trimmed contents are empty: delete legacy silently and+-- return (the existing 'loadDefaults' body then sees no file and+-- returns 'emptyStoredDefaults').+-- * Otherwise: construct a 'StoredDefaults' with+-- @cookies.access_token = CredentialRef "literal:\<jwt\>"@,+-- write it via 'writeDefaults' (SELF-5 atomic + 0600 chmod),+-- delete the legacy file, and log INFO suggesting+-- @synapse _self \<backend\> upgrade-to-keychain@.+--+-- Security note: migration target is deliberately @literal:@, not+-- @keychain:\/\/@. A silent permission escalation would surprise users+-- and could trigger an OS keychain prompt. Opt-in upgrade is the+-- @_self \<backend\> upgrade-to-keychain@ verb.+migrateIfNeeded :: Text -> IO ()+migrateIfNeeded backend = do+ newPath <- absDefaultsPath backend+ legacyPath <- legacyTokenPath backend+ newExists <- doesFileExist newPath+ legacyExists <- doesFileExist legacyPath+ case (newExists, legacyExists) of+ (_, False) -> pure () -- no legacy → nothing to do+ (True, True) -> do+ -- Stale legacy file: new defaults.json wins. Delete the legacy+ -- file unread so it doesn't masquerade as authoritative state.+ E.handle (\(_ :: IOException) -> pure ()) (removeFile legacyPath)+ hPutStrLn stderr $+ "[INFO] removed stale legacy token file " <> legacyPath+ <> " (superseded by defaults.json)"+ (False, True) -> do+ -- Real migration. Read, trim, decide empty vs non-empty.+ readResult <- try (BS.readFile legacyPath)+ :: IO (Either IOException BS.ByteString)+ case readResult of+ Left _ -> pure () -- unreadable legacy file: leave it; next run retries+ Right raw -> do+ let trimmed = T.strip (TE.decodeUtf8 raw)+ if T.null trimmed+ then+ -- Empty / whitespace-only legacy: drop it silently. No+ -- new file is created — the existing 'loadDefaults' body+ -- returns 'emptyStoredDefaults'.+ E.handle (\(_ :: IOException) -> pure ()) (removeFile legacyPath)+ else do+ let sd = emptyStoredDefaults+ { sdCookies = Map.singleton "access_token"+ (CredentialRef ("literal:" <> trimmed))+ }+ writeDefaults backend sd+ E.handle (\(_ :: IOException) -> pure ()) (removeFile legacyPath)+ hPutStrLn stderr $+ "[INFO] migrated legacy token file " <> legacyPath+ <> " to " <> newPath+ <> " (stored as literal:). Consider: synapse _self "+ <> T.unpack backend <> " upgrade-to-keychain"++-- | Emit a stderr WARN when the file contains a @literal:@ value AND+-- its mode is group-\/world-accessible. Silent on Windows (mode is+-- meaningless) and on non-literal-only manifests (safe at any mode).+--+-- A failure to stat the file (e.g. race with deletion) is swallowed —+-- the caller has already read the bytes, so there is no state to+-- recover from here.+warnIfInsecureMode :: FilePath -> BS.ByteString -> IO ()+warnIfInsecureMode path bs+ | System.Info.os == "mingw32" = pure ()+ | not (BS.isInfixOf "literal:" bs) = pure ()+ | otherwise = do+ mSt <- try (getFileStatus path) :: IO (Either IOException FileStatus)+ case mSt of+ Left _ -> pure ()+ Right st -> do+ let m = fileMode st+ if m .&. (0o077 :: FileMode) /= 0+ then hPutStrLn stderr $+ "[WARN] " <> path <> " contains literal: credentials "+ <> "and is group/world accessible; recommend `chmod 600 "+ <> path <> "`."+ else pure ()++-- ============================================================================+-- resolveAll+-- ============================================================================++-- | Resolve every 'CredentialRef' in a 'StoredDefaults' through the+-- registry. Dispatch is sequential: the first failure short-circuits and+-- the partial map is discarded — we never send a half-authenticated+-- request.+--+-- The 'MethodPath' is threaded unchanged for forward compatibility with+-- scope matching. v1 ignores it; backend-level defaults are the only ones+-- consulted.+--+-- Returns 'Right' with a fully-populated 'ResolvedDefaults' on success,+-- 'Left' with the first 'ResolveError' encountered on failure.+resolveAll+ :: ResolverRegistry+ -> StoredDefaults+ -> MethodPath+ -> IO (Either ResolveError ResolvedDefaults)+resolveAll reg StoredDefaults{..} _methodPath = do+ eCookies <- resolveMap reg sdCookies+ case eCookies of+ Left err -> pure (Left err)+ Right cookies -> do+ eHeaders <- resolveMap reg sdHeaders+ case eHeaders of+ Left err -> pure (Left err)+ Right headers -> pure $ Right ResolvedDefaults+ { rdCookies = cookies+ , rdHeaders = headers+ }++-- | Resolve every value in a @Map Text CredentialRef@, short-circuiting+-- on the first failure. Preserves input keys.+resolveMap+ :: ResolverRegistry+ -> Map Text CredentialRef+ -> IO (Either ResolveError (Map Text Text))+resolveMap reg = go . Map.toAscList+ where+ go [] = pure (Right Map.empty)+ go ((k, ref) : rest) = do+ r <- resolveRef reg ref+ case r of+ Left err -> pure (Left err)+ Right v -> do+ rest' <- go rest+ case rest' of+ Left err -> pure (Left err)+ Right m -> pure $ Right (Map.insert k v m)++-- ============================================================================+-- merge+-- ============================================================================++-- | Pure left-biased merge of CLI cookies\/headers over the resolved+-- stored defaults. CLI wins per key.+--+-- The caller is responsible for wrapping each CLI @--cookie KEY=VALUE@ and+-- @--header KEY=VALUE@ into the 'Cookies'\/'Headers' maps before calling+-- 'merge'; there is no special casing for the source of a value inside+-- this function.+merge+ :: ResolvedDefaults+ -> Cookies -- ^ from CLI flags (wins on conflict)+ -> Headers -- ^ from CLI flags (wins on conflict)+ -> (Cookies, Headers)+merge ResolvedDefaults{..} cliCookies cliHeaders =+ ( Map.union cliCookies rdCookies+ , Map.union cliHeaders rdHeaders+ )++-- ============================================================================+-- writeDefaults (SELF-5: atomic rename + mode-aware chmod)+-- ============================================================================++-- | Write a 'StoredDefaults' to disk for @backend@.+--+-- The write is atomic and mode-aware:+--+-- 1. Ensure the parent directory exists. If it was freshly created by+-- this call, chmod it to @0700@. If it already existed with+-- group-\/world-accessible bits set, emit a stderr WARN but leave it+-- alone (the user may have structure we shouldn\'t disturb).+-- 2. Encode the 'StoredDefaults' to JSON.+-- 3. Write the encoded bytes to a sibling temp file+-- @\<dir\>\/.defaults.json.tmp@ (hidden dotfile so visually+-- distinct if ever stuck).+-- 4. If the encoded content contains the substring @"literal:"@ anywhere,+-- chmod the temp file to @0600@ before rename. Otherwise @0644@ is+-- acceptable (manifest-only, no secrets in-file).+-- 5. Atomically 'renameFile' the temp into place — this wraps the+-- platform @rename(2)@ syscall and is the only step that makes the+-- write visible to other readers.+-- 6. On any failure at steps 3-5, remove the temp file (if it exists)+-- and propagate the original exception. No partial @defaults.json@+-- is ever left behind.+--+-- On Windows (@System.Info.os == "mingw32"@) the chmod steps are+-- skipped; the atomic rename still happens.+writeDefaults :: Text -> StoredDefaults -> IO ()+writeDefaults backend sd = do+ path <- absDefaultsPath backend+ let dir = takeDirectory path+ tmpPath = dir </> ".defaults.json.tmp"+ bytes = encodeDefaults sd+ sensitive = BS.isInfixOf "literal:" bytes++ -- Step 1: parent dir. Track whether we created it so we only chmod+ -- freshly-created dirs (existing dirs with wide mode get a WARN).+ dirPreexisted <- doesDirectoryExist dir+ createDirectoryIfMissing True dir+ if not dirPreexisted+ then setFileModeUnix dir 0o700+ else warnIfWideDir dir++ -- Steps 2-5: atomic temp write + chmod + rename. `bracketOnError`+ -- removes the temp file on any exception from the write or rename;+ -- on success no cleanup runs.+ bracketOnError+ (pure tmpPath)+ (\p -> E.handle (\(_ :: IOException) -> pure ()) (removeFileIfExists p))+ (\p -> do+ BS.writeFile p bytes+ setFileModeUnix p (if sensitive then 0o600 else 0o644)+ renameFile p path)++-- | Remove a file, ignoring "does not exist" errors but propagating+-- anything else. Used by the 'bracketOnError' cleanup — we don\'t want+-- cleanup-of-nonexistent-tmp to hide the real failure.+removeFileIfExists :: FilePath -> IO ()+removeFileIfExists p = do+ exists <- doesFileExist p+ if exists then removeFile p else pure ()++-- | 'setFileMode' on Unix, no-op on Windows. Matches the platform gating+-- in 'warnIfInsecureMode' / 'warnIfWideDir'.+setFileModeUnix :: FilePath -> FileMode -> IO ()+setFileModeUnix p m+ | System.Info.os == "mingw32" = pure ()+ | otherwise = setFileMode p m++-- | WARN when a pre-existing parent dir has group-\/world-accessible+-- bits. We never tighten an existing dir\'s mode — users may have+-- structure under @~\/.plexus@ we shouldn\'t perturb. Silent on+-- Windows and on already-tight dirs.+warnIfWideDir :: FilePath -> IO ()+warnIfWideDir dir+ | System.Info.os == "mingw32" = pure ()+ | otherwise = do+ mSt <- try (getFileStatus dir) :: IO (Either IOException FileStatus)+ case mSt of+ Left _ -> pure ()+ Right st -> do+ let m = fileMode st+ if m .&. (0o077 :: FileMode) /= 0+ then hPutStrLn stderr $+ "[WARN] " <> dir <> " has group/world access bits set; "+ <> "recommend `chmod 700 " <> dir <> "` to tighten."+ else pure ()
src/Synapse/Self/Protocol/TestRunner.hs view
@@ -73,6 +73,7 @@ , substratePath = "/" , substrateBackend = backend , substrateHeaders = []+ , substrateTls = Nothing } -- Run all test suites@@ -357,6 +358,8 @@ "Protocol error: " <> msg formatTransportError (PT.NetworkError msg) = "Network error: " <> msg+formatTransportError (PT.ConnectionLost msg _) =+ "Connection lost: " <> msg -- | Simple timeout implementation -- Returns Nothing if timeout occurs, Just result otherwise
+ src/Synapse/Self/Resolve.hs view
@@ -0,0 +1,216 @@+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE RecordWildCards #-}++-- | URI parsing and resolver registry for credential references.+--+-- A 'CredentialRef' on disk is one of:+--+-- * Opaque: @literal:\<rawValue\>@ — value taken verbatim after the first+-- colon. No URL-encoding so JWTs / base64 fit cleanly.+-- * Hierarchical: @scheme:\/\/authority\/path[?query]@ — interpretation+-- is scheme-specific.+--+-- Parsing is shape-only: 'parseUri' accepts any well-formed URI. Scheme+-- validity is enforced at *resolve* time via the registry, so the file+-- parses even when a scheme\'s backend (e.g. @vault@) is not wired up.+--+-- The registry is an open map; concrete resolvers (e.g. @keychain@,+-- @env@, @file@) are registered by their owning packages via simple+-- 'Map' insertion. An empty registry (@mempty@) returns+-- 'ResolveUnknownScheme' for every lookup.+module Synapse.Self.Resolve+ ( -- * Parsed URI shape+ ParsedUri(..)+ , ParsedUriBody(..)+ , parseUri++ -- * Resolver interface+ , Resolver(..)+ , ResolveFn+ , NamedResolver(..)++ -- * Registry+ , ResolverRegistry(..)+ , emptyRegistry+ , registerResolver+ , registerNamedResolver+ , lookupResolver+ , resolveRef++ -- * Errors+ , ResolveError(..)+ ) where++import Data.Map.Strict (Map)+import qualified Data.Map.Strict as Map+import Data.Proxy (Proxy)+import Data.Text (Text)+import qualified Data.Text as T++import Synapse.Self.Types (CredentialRef(..))++-- ============================================================================+-- Parsed URI+-- ============================================================================++-- | Body of a parsed URI.+--+-- We keep this deliberately simple — this is not RFC 3986. We only need+-- to separate the opaque escape hatch from the @scheme://authority/path@+-- shape, plus a light query split so @env:\/\/VAR?fallback=x@ style+-- extensions are possible later without another parser pass.+data ParsedUriBody+ = OpaqueBody Text+ -- ^ Everything after the first @:@ for non-hierarchical URIs.+ | HierarchicalBody+ { puAuthority :: Text+ , puPath :: Text+ , puQuery :: [(Text, Text)]+ }+ deriving stock (Show, Eq)++-- | A parsed credential URI.+data ParsedUri = ParsedUri+ { puScheme :: Text+ , puBody :: ParsedUriBody+ }+ deriving stock (Show, Eq)++-- | Parse a 'CredentialRef' URI.+--+-- Splits on the first @:@ to extract the scheme. If the remainder starts+-- with @\/\/@ we treat it as hierarchical (authority + path + optional+-- query); otherwise the remainder is taken verbatim as the opaque body.+--+-- Rejected shapes:+--+-- * bare strings with no scheme (e.g. @"abc"@)+-- * empty scheme (e.g. @":foo"@)+parseUri :: CredentialRef -> Either Text ParsedUri+parseUri (CredentialRef raw) =+ case T.breakOn ":" raw of+ (_, "") -> Left ("missing scheme in credential reference: " <> raw)+ (scheme, colonRest)+ | T.null scheme -> Left ("empty scheme in credential reference: " <> raw)+ | otherwise ->+ let rest = T.drop 1 colonRest+ in case T.stripPrefix "//" rest of+ Just hier -> Right (ParsedUri scheme (parseHierarchical hier))+ Nothing -> Right (ParsedUri scheme (OpaqueBody rest))++-- | Split @authority/path?query@. Path and authority are empty when absent.+parseHierarchical :: Text -> ParsedUriBody+parseHierarchical s =+ let (beforeQ, afterQ) = T.breakOn "?" s+ query = if T.null afterQ then [] else parseQuery (T.drop 1 afterQ)+ (authority, path) = case T.breakOn "/" beforeQ of+ (a, "") -> (a, "")+ (a, slash) -> (a, slash)+ in HierarchicalBody+ { puAuthority = authority+ , puPath = path+ , puQuery = query+ }++-- | Parse @k1=v1&k2=v2@. No URL-decoding — keys and values are taken+-- verbatim. Missing @=@ yields an empty value.+parseQuery :: Text -> [(Text, Text)]+parseQuery s = map splitPair (T.splitOn "&" s)+ where+ splitPair pair = case T.breakOn "=" pair of+ (k, "") -> (k, "")+ (k, eqRest) -> (k, T.drop 1 eqRest)++-- ============================================================================+-- Resolver interface+-- ============================================================================++-- | The shape resolvers implement. @IO@ because most backends+-- (keychain, filesystem, vault) are inherently effectful.+type ResolveFn = ParsedUri -> IO (Either ResolveError Text)++-- | A 'Resolver' knows its own scheme and how to dereference a URI.+--+-- Kept as a typeclass for ergonomic instance declarations, but the+-- registry stores the bare 'ResolveFn' so resolvers defined outside+-- this package (e.g. a future @plexus-vault@) compose without any+-- typeclass gymnastics.+class Resolver r where+ scheme :: Proxy r -> Text+ resolve :: r -> ResolveFn++-- | A named resolver for debugging / diagnostics — pair the scheme+-- string with its 'ResolveFn' without going through the typeclass.+data NamedResolver = NamedResolver+ { nrScheme :: Text+ , nrResolve :: ResolveFn+ }++-- ============================================================================+-- Registry+-- ============================================================================++-- | Map from scheme to resolver function. Construction via 'mempty'+-- yields an empty registry that always returns 'ResolveUnknownScheme'.+newtype ResolverRegistry = ResolverRegistry+ { schemes :: Map Text ResolveFn+ }++instance Semigroup ResolverRegistry where+ -- Right-biased union matches 'Map.union'\'s left-bias when read as+ -- @a <> b == a@ wins: we flip it so later registrations override.+ ResolverRegistry a <> ResolverRegistry b = ResolverRegistry (Map.union b a)++instance Monoid ResolverRegistry where+ mempty = ResolverRegistry Map.empty++-- | An empty registry. Equivalent to 'mempty'; provided for discoverability.+emptyRegistry :: ResolverRegistry+emptyRegistry = mempty++-- | Register a resolver function under a scheme. Last write wins.+registerResolver :: Text -> ResolveFn -> ResolverRegistry -> ResolverRegistry+registerResolver s fn (ResolverRegistry m) =+ ResolverRegistry (Map.insert s fn m)++-- | Register a 'NamedResolver'. Convenience for the typeclass-free path.+registerNamedResolver :: NamedResolver -> ResolverRegistry -> ResolverRegistry+registerNamedResolver NamedResolver{..} = registerResolver nrScheme nrResolve++-- | Look up the resolver for a scheme.+lookupResolver :: Text -> ResolverRegistry -> Maybe ResolveFn+lookupResolver s (ResolverRegistry m) = Map.lookup s m++-- | Resolve a 'CredentialRef' end-to-end: parse, dispatch, and report+-- either the resolved value or a structured 'ResolveError'. Parse+-- failures surface as 'ResolveParseError' so callers get a single error+-- channel.+resolveRef :: ResolverRegistry -> CredentialRef -> IO (Either ResolveError Text)+resolveRef reg ref =+ case parseUri ref of+ Left err -> pure (Left (ResolveParseError err))+ Right parsed ->+ case lookupResolver (puScheme parsed) reg of+ Nothing -> pure (Left (ResolveUnknownScheme (puScheme parsed)))+ Just fn -> fn parsed++-- ============================================================================+-- Errors+-- ============================================================================++-- | Operator-facing failure modes for credential reference resolution.+--+-- These are what @_self resolve@ (SELF-4) surfaces — structured so UIs+-- can render @"keychain item 'uscis/access_token' not found"@ instead of+-- @"error 127"@.+data ResolveError+ = ResolveUnknownScheme Text+ -- ^ No resolver registered for this scheme.+ | ResolveNotFound CredentialRef+ -- ^ Scheme resolved but the underlying item is absent+ -- (keychain miss, env var unset, file doesn\'t exist).+ | ResolveBackendError CredentialRef Text+ -- ^ Scheme resolver hit an unexpected error from its backend.+ | ResolveParseError Text+ -- ^ The reference URI itself was malformed.+ deriving stock (Show, Eq)
+ src/Synapse/Self/Resolve/Default.hs view
@@ -0,0 +1,43 @@+{-# LANGUAGE OverloadedStrings #-}++-- | The default 'ResolverRegistry' for the baseline deployment shapes.+--+-- Registers the four resolvers every deployment needs:+--+-- * @literal:\<value\>@ — opaque escape hatch (see 'literalResolver').+-- * @env:\/\/\<VAR\>@ — environment variable (see 'envResolver').+-- * @file:\/\/\<path\>@ — file contents (see 'fileResolver').+-- * @keychain:\/\/\<svc\>\/\<acct\>@ — OS keychain (see 'keychainResolver';+-- full support on macOS, clear error on other platforms).+--+-- Registering @keychain@ unconditionally (including on platforms where+-- it's a stub) lets @_self show@ list @keychain:\/\/@ refs with a+-- specific "not implemented on <platform>" error instead of the less+-- helpful "unknown scheme" error.+--+-- Callers extend via 'registerResolver':+--+-- > let reg = registerResolver "vault" myVaultResolver defaultRegistry+module Synapse.Self.Resolve.Default+ ( defaultRegistry+ ) where++import Synapse.Self.Resolve+ ( ResolverRegistry+ , emptyRegistry+ , registerResolver+ )+import Synapse.Self.Resolve.Env (envResolver)+import Synapse.Self.Resolve.File (fileResolver)+import Synapse.Self.Resolve.Keychain (keychainResolver)+import Synapse.Self.Resolve.Literal (literalResolver)++-- | A registry pre-populated with the four baseline resolvers:+-- @literal@, @env@, @file@, @keychain@.+defaultRegistry :: ResolverRegistry+defaultRegistry =+ registerResolver "literal" literalResolver+ $ registerResolver "env" envResolver+ $ registerResolver "file" fileResolver+ $ registerResolver "keychain" keychainResolver+ $ emptyRegistry
+ src/Synapse/Self/Resolve/Env.hs view
@@ -0,0 +1,53 @@+{-# LANGUAGE OverloadedStrings #-}++-- | The @env:\/\/\<VAR\>@ resolver — read credentials from environment+-- variables.+--+-- The authority slot carries the variable name; path and query are+-- ignored. @env:\/\/USCIS_JWT@ looks up @USCIS_JWT@ via+-- 'System.Environment.lookupEnv':+--+-- * set → 'Right' value+-- * unset → 'ResolveNotFound'+--+-- Using the authority slot for the variable name is a slight RFC 3986+-- stretch (authority is supposed to be host:port) but it reads right.+-- Alternatives like @env:VAR@ (opaque) or @env:\/\/\/VAR@ (path) were+-- uglier for no gain.+module Synapse.Self.Resolve.Env+ ( envResolver+ ) where++import qualified Data.Text as T+import System.Environment (lookupEnv)++import Synapse.Self.Resolve+ ( ParsedUri(..)+ , ParsedUriBody(..)+ , ResolveError(..)+ , ResolveFn+ )+import Synapse.Self.Types (CredentialRef(..))++-- | Resolver for @env:\/\/\<VAR\>@.+--+-- Dispatches only on 'HierarchicalBody'. An opaque @env:VAR@ form is a+-- shape error surfaced as 'ResolveBackendError'.+envResolver :: ResolveFn+envResolver ParsedUri{puScheme, puBody} =+ case puBody of+ HierarchicalBody{puAuthority = varName}+ | T.null varName ->+ pure $ Left $ ResolveBackendError+ (CredentialRef (puScheme <> "://"))+ "env: missing variable name (expected env://VAR)"+ | otherwise -> do+ mVal <- lookupEnv (T.unpack varName)+ case mVal of+ Just v -> pure (Right (T.pack v))+ Nothing -> pure $ Left $+ ResolveNotFound (CredentialRef (puScheme <> "://" <> varName))+ OpaqueBody body ->+ pure $ Left $ ResolveBackendError+ (CredentialRef (puScheme <> ":" <> body))+ "env: scheme must be hierarchical (env://VAR), not opaque (env:VAR)"
+ src/Synapse/Self/Resolve/File.hs view
@@ -0,0 +1,86 @@+{-# LANGUAGE OverloadedStrings #-}++-- | The @file:\/\/\<path\>@ resolver — read credentials from a file.+--+-- Generalization of the existing @--token-file@ idiom. The path is+-- reconstructed from the parsed URI (authority ++ path), supports @~@+-- expansion, and a single trailing newline is stripped so cleanly+-- concatenated @printf \'%s\\n\' \> token@ files Just Work. Interior+-- whitespace is preserved.+--+-- Error mapping:+--+-- * Missing file → 'ResolveNotFound'+-- * Other IO errors (permission denied, is-a-directory, …) →+-- 'ResolveBackendError' with the IOError message.+module Synapse.Self.Resolve.File+ ( fileResolver+ ) where++import Control.Exception (IOException, try)+import qualified Data.Text as T+import qualified Data.Text.IO as TIO+import System.Directory (getHomeDirectory)+import System.IO.Error (isDoesNotExistError)++import Synapse.Self.Resolve+ ( ParsedUri(..)+ , ParsedUriBody(..)+ , ResolveError(..)+ , ResolveFn+ )+import Synapse.Self.Types (CredentialRef(..))++-- | Resolver for @file:\/\/\<path\>@.+--+-- Dispatches on 'HierarchicalBody'. Reconstructs the path as+-- @authority ++ path@, expands a leading @~@ to the user\'s home+-- directory, reads the file as UTF-8, and strips a single trailing+-- newline.+fileResolver :: ResolveFn+fileResolver ParsedUri{puScheme, puBody} =+ case puBody of+ HierarchicalBody{puAuthority = auth, puPath = p} -> do+ let raw = auth <> p+ canonical = CredentialRef (puScheme <> "://" <> raw)+ if T.null raw+ then pure $ Left $ ResolveBackendError canonical+ "file: missing path (expected file:///abs or file://~/rel)"+ else do+ expanded <- expandHome (T.unpack raw)+ readWithStrip canonical expanded+ OpaqueBody body ->+ pure $ Left $ ResolveBackendError+ (CredentialRef (puScheme <> ":" <> body))+ "file: scheme must be hierarchical (file://path), not opaque"++-- | Expand a leading @~@ or @~\/@ to the user\'s home directory. Any+-- other @~@ (e.g. @~user@) is left alone — that form would need+-- platform-specific getpwnam logic we don\'t want to pull in here.+expandHome :: FilePath -> IO FilePath+expandHome path = case path of+ "~" -> getHomeDirectory+ '~':'/':rest -> do+ home <- getHomeDirectory+ pure (home <> "/" <> rest)+ _ -> pure path++-- | Read @path@ and strip a single trailing @\\n@ if present. All other+-- bytes (including interior newlines and leading\/trailing spaces) are+-- preserved verbatim.+readWithStrip :: CredentialRef -> FilePath -> IO (Either ResolveError T.Text)+readWithStrip ref path = do+ result <- try (TIO.readFile path) :: IO (Either IOException T.Text)+ case result of+ Right contents -> pure $ Right (stripTrailingNewline contents)+ Left e+ | isDoesNotExistError e -> pure $ Left $ ResolveNotFound ref+ | otherwise ->+ pure $ Left $ ResolveBackendError ref (T.pack (show e))++-- | Strip a single trailing @\\n@. No-op if the file has no trailing+-- newline, and only one is removed even if the file ends in @\\n\\n@.+stripTrailingNewline :: T.Text -> T.Text+stripTrailingNewline t = case T.unsnoc t of+ Just (prefix, '\n') -> prefix+ _ -> t
+ src/Synapse/Self/Resolve/Keychain.hs view
@@ -0,0 +1,256 @@+{-# LANGUAGE OverloadedStrings #-}++-- | The @keychain:\/\/\<service\>\/\<account\>@ resolver — OS-managed+-- credential storage.+--+-- Elevates the credentials store out of plaintext into OS-managed+-- credential storage. With keychain refs, @defaults.json@ is a manifest+-- of references and contains no secrets — safe to commit to personal+-- dotfiles.+--+-- __Platforms:__+--+-- * macOS — full support via the @security@ CLI (shell-out, no FFI).+-- * Linux — stub. Returns 'ResolveBackendError' with a clear+-- "not implemented; PR welcome" message.+-- * Windows — stub. Same.+--+-- Platform dispatch is runtime via 'System.Info.os' so the module+-- compiles identically on every platform.+--+-- __URI form:__+--+-- > keychain://<service>/<account>+--+-- The authority is the keychain service; everything after the leading+-- @\/@ in the path is the account. Slashes in the account are preserved+-- (keychain accepts them); e.g. @keychain:\/\/plexus\/uscis\/cookie\/access_token@+-- resolves with @service = "plexus"@ and @account = "uscis\/cookie\/access_token"@.+--+-- __macOS implementation:__+--+-- Shells out to:+--+-- @+-- security find-generic-password -s \<service\> -a \<account\> -w+-- security add-generic-password -s \<service\> -a \<account\> -w \<value\> -U+-- security delete-generic-password -s \<service\> -a \<account\>+-- @+--+-- Exit code 44 from @security@ means "item not found" and surfaces as+-- 'ResolveNotFound'. Other non-zero exits surface as 'ResolveBackendError'+-- with the captured stderr. The @security -w@ command appends a single+-- trailing newline to stdout — stripped exactly once.+module Synapse.Self.Resolve.Keychain+ ( -- * Resolver+ keychainResolver++ -- * Mutations (for SELF-4 verbs)+ , storeInKeychain+ , deleteFromKeychain++ -- * Platform probe+ , isKeychainPlatform+ , platformName+ ) where++import Data.Text (Text)+import qualified Data.Text as T+import System.Exit (ExitCode(..))+import qualified System.Info as Info+import System.Process (readProcessWithExitCode)++import Synapse.Self.Resolve+ ( ParsedUri(..)+ , ParsedUriBody(..)+ , ResolveError(..)+ , ResolveFn+ )+import Synapse.Self.Types (CredentialRef(..))++-- ============================================================================+-- Platform detection+-- ============================================================================++-- | True iff the current platform has a working keychain backend.+-- Only macOS returns True today.+isKeychainPlatform :: Bool+isKeychainPlatform = Info.os == "darwin"++-- | Human-readable platform name, for error messages.+platformName :: Text+platformName = case Info.os of+ "darwin" -> "macOS"+ "linux" -> "Linux"+ "mingw32" -> "Windows"+ "mingw64" -> "Windows"+ "cygwin32" -> "Windows"+ other -> T.pack other++-- ============================================================================+-- Resolver+-- ============================================================================++-- | Resolver for @keychain:\/\/\<service\>\/\<account\>@.+keychainResolver :: ResolveFn+keychainResolver ParsedUri{puScheme, puBody} = case puBody of+ OpaqueBody body ->+ pure $ Left $ ResolveBackendError+ (CredentialRef (puScheme <> ":" <> body))+ "keychain: scheme must be hierarchical (keychain://service/account), not opaque"+ HierarchicalBody{puAuthority = service, puPath = rawPath} ->+ case parseServiceAccount service rawPath of+ Left err -> pure (Left err)+ Right (s, a)+ | isKeychainPlatform -> resolveMacOS s a+ | otherwise -> pure (Left (stubError (canonicalRef s a)))++-- | Split the ParsedUri pieces into @(service, account)@ or a structured+-- 'ResolveParseError' naming what's missing.+parseServiceAccount+ :: Text -- ^ authority+ -> Text -- ^ raw path (leading @\/@ expected)+ -> Either ResolveError (Text, Text)+parseServiceAccount service rawPath+ | T.null service =+ Left $ ResolveParseError+ "service missing in keychain://<service>/<account>"+ | otherwise =+ case T.stripPrefix "/" rawPath of+ Nothing ->+ Left $ ResolveParseError+ "account missing in keychain://<service>/<account>"+ Just account+ | T.null account ->+ Left $ ResolveParseError+ "account missing in keychain://<service>/<account>"+ | otherwise -> Right (service, account)++-- | The canonical URI we echo back in 'ResolveError' payloads.+canonicalRef :: Text -> Text -> CredentialRef+canonicalRef service account =+ CredentialRef ("keychain://" <> service <> "/" <> account)++-- | Error returned on platforms without a keychain backend.+stubError :: CredentialRef -> ResolveError+stubError ref =+ ResolveBackendError ref $ T.concat+ [ "keychain backend not implemented on "+ , platformName+ , "; PR welcome."+ ]++-- ============================================================================+-- macOS: shell out to `security`+-- ============================================================================++-- | macOS exit code returned by @security@ when an item is not found.+-- Documented under @errSecItemNotFound@ in the @security(1)@ manpage.+securityNotFoundExitCode :: Int+securityNotFoundExitCode = 44++-- | Resolve via @security find-generic-password -s \<service\> -a \<account\> -w@.+resolveMacOS :: Text -> Text -> IO (Either ResolveError Text)+resolveMacOS service account = do+ let args =+ [ "find-generic-password"+ , "-s", T.unpack service+ , "-a", T.unpack account+ , "-w"+ ]+ ref = canonicalRef service account+ (code, out, err) <- readProcessWithExitCode "security" args ""+ case code of+ ExitSuccess ->+ pure $ Right (stripOneTrailingNewline (T.pack out))+ ExitFailure c+ | c == securityNotFoundExitCode ->+ pure $ Left (ResolveNotFound ref)+ | otherwise ->+ pure $ Left $ ResolveBackendError ref $ T.concat+ [ "security exited with code "+ , T.pack (show c)+ , renderStderr err+ ]++-- | Strip exactly one trailing newline if present.+-- @security -w@ appends one newline; interior bytes are preserved.+stripOneTrailingNewline :: Text -> Text+stripOneTrailingNewline t = case T.unsnoc t of+ Just (pfx, '\n') -> pfx+ _ -> t++-- | Append stderr to an error message if non-empty.+renderStderr :: String -> Text+renderStderr "" = ""+renderStderr s = ": " <> T.strip (T.pack s)++-- ============================================================================+-- Mutations (exposed for SELF-4)+-- ============================================================================++-- | Store @value@ under @service@/@account@ in the OS keychain.+--+-- Uses @security add-generic-password -U@ so an existing item is updated+-- in place. Returns @Right ()@ on success. On non-macOS platforms,+-- returns @Left@ with a "not implemented" message.+storeInKeychain+ :: Text -- ^ service+ -> Text -- ^ account+ -> Text -- ^ value to store+ -> IO (Either Text ())+storeInKeychain service account value+ | not isKeychainPlatform = pure $ Left $ T.concat+ [ "keychain backend not implemented on "+ , platformName+ , "; cannot store secret. PR welcome."+ ]+ | otherwise = do+ let args =+ [ "add-generic-password"+ , "-s", T.unpack service+ , "-a", T.unpack account+ , "-w", T.unpack value+ , "-U"+ ]+ (code, _out, err) <- readProcessWithExitCode "security" args ""+ case code of+ ExitSuccess -> pure (Right ())+ ExitFailure c ->+ pure $ Left $ T.concat+ [ "security add-generic-password exited with code "+ , T.pack (show c)+ , renderStderr err+ ]++-- | Delete @service@/@account@ from the OS keychain.+--+-- Exit code 44 (not found) is treated as success — the intent ("no such+-- item remains") is already true.+deleteFromKeychain+ :: Text -- ^ service+ -> Text -- ^ account+ -> IO (Either Text ())+deleteFromKeychain service account+ | not isKeychainPlatform = pure $ Left $ T.concat+ [ "keychain backend not implemented on "+ , platformName+ , "; cannot delete secret. PR welcome."+ ]+ | otherwise = do+ let args =+ [ "delete-generic-password"+ , "-s", T.unpack service+ , "-a", T.unpack account+ ]+ (code, _out, err) <- readProcessWithExitCode "security" args ""+ case code of+ ExitSuccess -> pure (Right ())+ ExitFailure c+ | c == securityNotFoundExitCode -> pure (Right ())+ | otherwise ->+ pure $ Left $ T.concat+ [ "security delete-generic-password exited with code "+ , T.pack (show c)+ , renderStderr err+ ]
+ src/Synapse/Self/Resolve/Literal.hs view
@@ -0,0 +1,40 @@+{-# LANGUAGE OverloadedStrings #-}++-- | The @literal:\<value\>@ resolver — the opaque escape hatch.+--+-- Everything after @literal:@ is returned verbatim. No URL-decoding, no+-- trimming: @literal: foo @ yields @" foo "@ (leading\/trailing spaces+-- preserved). @literal:@ (empty body) yields @""@.+--+-- This is the dumbest possible resolver on purpose: the common case is+-- "paste a JWT, don\'t make me think about encoding."+module Synapse.Self.Resolve.Literal+ ( literalResolver+ ) where++import qualified Data.Text as T++import Synapse.Self.Resolve+ ( ParsedUri(..)+ , ParsedUriBody(..)+ , ResolveError(..)+ , ResolveFn+ )+import Synapse.Self.Types (CredentialRef(..))++-- | Resolver for @literal:\<value\>@.+--+-- Only dispatches on 'OpaqueBody' — a hierarchical @literal:\/\/x@ shape+-- is a shape error, reported as 'ResolveBackendError' (the scheme is+-- registered; the caller just used the wrong form).+literalResolver :: ResolveFn+literalResolver ParsedUri{puScheme, puBody} =+ case puBody of+ OpaqueBody body -> pure (Right body)+ HierarchicalBody{} ->+ pure $ Left $ ResolveBackendError+ (CredentialRef (puScheme <> ":<hierarchical>"))+ (T.concat+ [ "literal: scheme must be opaque (literal:<value>), "+ , "not hierarchical (literal://...)"+ ])
+ src/Synapse/Self/Types.hs view
@@ -0,0 +1,176 @@+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE RecordWildCards #-}++-- | On-disk types and file format for per-backend defaults.+--+-- The defaults store is a manifest of credential *references* (URIs),+-- not plaintext credentials. A resolver registry ('Synapse.Self.Resolve')+-- dereferences the URIs at dispatch time.+--+-- This module defines:+--+-- * 'StoredDefaults' — in-memory shape of the on-disk file.+-- * 'CredentialRef' — an opaque URI string carried verbatim.+-- * 'ScopedDefaults' — reserved for per-namespace / per-method overrides;+-- parsed but unused in v1.+-- * Pure 'encodeDefaults' / 'decodeDefaults' functions with deterministic+-- key ordering and two-space indent.+--+-- The file format is versioned; unknown top-level fields are tolerated for+-- forward compatibility, but unknown versions are rejected loudly.+module Synapse.Self.Types+ ( -- * On-disk shape+ StoredDefaults(..)+ , CredentialRef(..)+ , ScopedDefaults(..)+ , emptyStoredDefaults+ , currentVersion++ -- * Encoding+ , encodeDefaults+ , decodeDefaults+ ) where++import Data.Aeson (FromJSON(..), ToJSON(..), Value(..), (.=), (.:), (.:?), (.!=), object, withObject, withText)+import qualified Data.Aeson as Aeson+import Data.Aeson.Encode.Pretty (Config(..), Indent(..), NumberFormat(..), encodePretty')+import qualified Data.Aeson.Key as Key+import qualified Data.Aeson.KeyMap as KM+import Data.ByteString (ByteString)+import qualified Data.ByteString.Lazy as LBS+import Data.Map.Strict (Map)+import qualified Data.Map.Strict as Map+import Data.Text (Text)+import qualified Data.Text as T++-- | Current on-disk format version.+--+-- Incremented whenever the file shape changes in a non-backward-compatible+-- way. Decoders reject anything that is not exactly this version with a+-- structured error; see 'decodeDefaults'.+currentVersion :: Int+currentVersion = 1++-- | A raw credential reference URI, carried verbatim.+--+-- The wrapper exists so we never accidentally confuse a URI with a resolved+-- secret value. Parsing into scheme + body is deferred to 'Synapse.Self.Resolve.parseUri'+-- because the file parses as long as the shape is right — invalid schemes+-- should only surface when we try to actually resolve them.+newtype CredentialRef = CredentialRef { unCredentialRef :: Text }+ deriving stock (Show, Eq, Ord)++instance ToJSON CredentialRef where+ toJSON = String . unCredentialRef++instance FromJSON CredentialRef where+ parseJSON = withText "CredentialRef" (pure . CredentialRef)++-- | Per-scope overrides. v1 carries the shape through verbatim but does+-- not consume it — resolution ignores scopes for now. Defined here so the+-- file format is stable when scope semantics land.+data ScopedDefaults = ScopedDefaults+ { sdsCookies :: Map Text CredentialRef+ , sdsHeaders :: Map Text CredentialRef+ }+ deriving stock (Show, Eq)++instance ToJSON ScopedDefaults where+ toJSON ScopedDefaults{..} = object+ [ "cookies" .= sdsCookies+ , "headers" .= sdsHeaders+ ]++instance FromJSON ScopedDefaults where+ parseJSON = withObject "ScopedDefaults" $ \o -> do+ cookies <- o .:? "cookies" .!= Map.empty+ headers <- o .:? "headers" .!= Map.empty+ pure ScopedDefaults+ { sdsCookies = cookies+ , sdsHeaders = headers+ }++-- | The on-disk defaults store for a single backend.+--+-- Values in 'sdCookies' / 'sdHeaders' are 'CredentialRef's — URIs like+-- @keychain:\/\/svc\/account@ or @literal:raw-value@. The resolver registry+-- dereferences them at dispatch time.+data StoredDefaults = StoredDefaults+ { sdVersion :: Int+ , sdCookies :: Map Text CredentialRef+ , sdHeaders :: Map Text CredentialRef+ , sdScopes :: Map Text ScopedDefaults+ }+ deriving stock (Show, Eq)++-- | An empty defaults file at the current version.+emptyStoredDefaults :: StoredDefaults+emptyStoredDefaults = StoredDefaults+ { sdVersion = currentVersion+ , sdCookies = Map.empty+ , sdHeaders = Map.empty+ , sdScopes = Map.empty+ }++instance ToJSON StoredDefaults where+ toJSON StoredDefaults{..} = object+ [ "version" .= sdVersion+ , "defaults" .= object+ [ "cookies" .= sdCookies+ , "headers" .= sdHeaders+ ]+ , "scopes" .= sdScopes+ ]++instance FromJSON StoredDefaults where+ parseJSON = withObject "StoredDefaults" $ \o -> do+ version <- o .: "version"+ defaultsObj <- o .:? "defaults" .!= object []+ (cookies, headers) <- flip (withObject "defaults") defaultsObj $ \d -> do+ c <- d .:? "cookies" .!= Map.empty+ h <- d .:? "headers" .!= Map.empty+ pure (c, h)+ scopes <- o .:? "scopes" .!= Map.empty+ pure StoredDefaults+ { sdVersion = version+ , sdCookies = cookies+ , sdHeaders = headers+ , sdScopes = scopes+ }++-- | Serialize 'StoredDefaults' with deterministic key order and two-space+-- indent. Callers can hash the result for change detection and expect+-- byte-stable output across runs.+encodeDefaults :: StoredDefaults -> ByteString+encodeDefaults sd = LBS.toStrict (encodePretty' config (toJSON sd))+ where+ config = Config+ { confIndent = Spaces 2+ , confCompare = compare+ , confNumFormat = Generic+ , confTrailingNewline = False+ }++-- | Parse a defaults file. Tolerates unknown top-level keys for forward+-- compatibility, treats a missing @defaults@ object as empty, and rejects+-- unknown versions with a structured message.+decodeDefaults :: ByteString -> Either Text StoredDefaults+decodeDefaults bs =+ case Aeson.eitherDecodeStrict' bs of+ Left err -> Left (T.pack err)+ Right value -> case value of+ Object o -> do+ version <- lookupInt "version" o+ if version /= currentVersion+ then Left ("unsupported version: " <> T.pack (show version)+ <> ", expected " <> T.pack (show currentVersion))+ else case Aeson.fromJSON value of+ Aeson.Success sd -> Right sd+ Aeson.Error e -> Left (T.pack e)+ _ -> Left "expected top-level JSON object"+ where+ lookupInt name o = case KM.lookup (Key.fromText name) o of+ Nothing -> Left ("missing required field: " <> name)+ Just v -> case Aeson.fromJSON v of+ Aeson.Success n -> Right n+ Aeson.Error e -> Left ("field " <> name <> ": " <> T.pack e)
src/Synapse/Transport.hs view
@@ -1,3 +1,4 @@+{-# LANGUAGE LambdaCase #-} {-# LANGUAGE ScopedTypeVariables #-} -- | Transport layer for Synapse@@ -19,21 +20,31 @@ -- * Bidirectional Response , sendResponse++ -- * Upgrade headers (IDENTITY-16: discovery probes carry them too)+ , mergeUpgradeHeaders ) where import Control.Monad.IO.Class (liftIO)+import Data.IORef (newIORef, readIORef, modifyIORef') import Control.Monad.Reader (asks) import Data.Aeson (Value)+import qualified Data.ByteString.Char8 as BS8+import qualified Data.CaseInsensitive as CI+import qualified Data.List as List import Data.Text (Text) import qualified Data.Text as T+import qualified Network.WebSockets as WS -import Plexus.Client (SubstrateConfig(..), cookieHeader)+import Plexus.Client (SubstrateConfig(..), cookieHeader, hostHeader) import qualified Plexus.Transport as ST import qualified Plexus.Types as PT import Synapse.Schema.Types import Synapse.Monad+import Synapse.Endpoint (substrateConfig) import qualified Synapse.Log as Log+import qualified Synapse.Reconnect as R -- | Fetch the root schema fetchSchema :: SynapseM PluginSchema@@ -42,21 +53,71 @@ Log.logInfo logger Log.SubsystemSchema "Fetching root schema" fetchSchemaAt [] --- | Convert plexus-protocol TransportError to error message+-- | Convert plexus-protocol TransportError to error message.+--+-- SAFE-5 + REQ-5: detect embedded JSON-RPC semantic error codes in the+-- protocol-error message and rewrite them with meaningful prefixes.+-- The wire format wraps RpcError as @"Subscription error: RpcErrorObj+-- {errCode = N, errMessage = \\"...\\", errData = ...}"@; we parse N+-- to render semantic prefixes for the four documented codes:+--+-- * @-32001@ Authentication required (REQ-5 hint added downstream)+-- * @-32602@ Invalid parameters+-- * @-32601@ Method not found+-- * @-32000@ Execution error transportErrorToText :: PT.TransportError -> Text transportErrorToText (PT.ConnectionRefused host port) = "Connection refused to " <> host <> ":" <> T.pack (show port) transportErrorToText (PT.ConnectionTimeout host port) = "Connection timeout to " <> host <> ":" <> T.pack (show port)-transportErrorToText (PT.ProtocolError msg) = "Protocol error: " <> msg+transportErrorToText (PT.ProtocolError msg) = renderProtocolError msg transportErrorToText (PT.NetworkError msg) = "Network error: " <> msg+transportErrorToText (PT.ConnectionLost msg _) = "Connection lost: " <> msg +-- | Inspect a protocol-error message for an embedded JSON-RPC error code+-- and rewrite with a semantic prefix. Falls back to the original message+-- when no recognized code is found.+renderProtocolError :: Text -> Text+renderProtocolError msg =+ case extractErrCode msg of+ Just (-32001) -> "Authentication required: " <> extractErrMessage msg+ <> "\nHint: pass --token <jwt>, set SYNAPSE_TOKEN, or store an access_token ref in ~/.plexus/<backend>/defaults.json (see `synapse _self`)"+ Just (-32602) -> "Invalid parameters: " <> extractErrMessage msg+ <> "\nHint: run with --help for the method to see expected params"+ Just (-32601) -> "Method not found: " <> extractErrMessage msg+ <> "\nHint: run 'synapse <backend>' to list available methods"+ Just (-32000) -> "Execution error: " <> extractErrMessage msg+ _ -> "Protocol error: " <> msg++-- | Pull the @errCode = N@ integer out of a 'show'-ed RpcErrorObj.+extractErrCode :: Text -> Maybe Int+extractErrCode msg =+ case T.breakOn "errCode = " msg of+ (_, rest) | not (T.null rest) ->+ let after = T.drop (T.length "errCode = ") rest+ digits = T.takeWhile (\c -> c == '-' || (c >= '0' && c <= '9')) after+ in case T.unpack digits of+ "" -> Nothing+ d -> Just (read d)+ _ -> Nothing++-- | Pull the @errMessage = "..."@ string out of a 'show'-ed RpcErrorObj.+-- Returns the empty string if no message can be parsed.+extractErrMessage :: Text -> Text+extractErrMessage msg =+ case T.breakOn "errMessage = \"" msg of+ (_, rest) | not (T.null rest) ->+ let after = T.drop (T.length "errMessage = \"") rest+ in T.takeWhile (/= '"') after+ _ -> ""+ -- | Convert plexus-protocol TransportError to TransportCategory transportErrorToCategory :: PT.TransportError -> TransportErrorCategory transportErrorToCategory (PT.ConnectionRefused _ _) = ConnectionRefused transportErrorToCategory (PT.ConnectionTimeout _ _) = ConnectionTimeout transportErrorToCategory (PT.ProtocolError _) = ProtocolError transportErrorToCategory (PT.NetworkError _) = UnknownTransportError+transportErrorToCategory (PT.ConnectionLost _ _) = UnknownTransportError -- | Fetch schema at a specific path fetchSchemaAt :: Path -> SynapseM PluginSchema@@ -65,7 +126,7 @@ cfg <- getConfig Log.logDebug logger Log.SubsystemSchema $ "Fetching schema at path: " <> T.pack (show path)- result <- liftIO $ ST.fetchSchemaAt cfg path+ result <- liftIO $ R.retrying (whereOf cfg) $ ST.fetchSchemaAt cfg path case result of Left err -> do Log.logInfo logger Log.SubsystemSchema $@@ -81,7 +142,7 @@ fetchMethodSchema :: Path -> Text -> SynapseM MethodSchema fetchMethodSchema path methodName = do cfg <- getConfig- result <- liftIO $ ST.fetchMethodSchemaAt cfg path methodName+ result <- liftIO $ R.retrying (whereOf cfg) $ ST.fetchMethodSchemaAt cfg path methodName case result of Left err -> throwNav $ FetchError (transportErrorToText err) path Right schema -> pure schema@@ -186,14 +247,18 @@ -- the response is sent back via {backend}.respond (if the handler returns Just). -- If the handler returns Nothing (BidirRespond mode), no response is sent immediately. invokeStreamingWithBidir- :: Path+ :: R.CallShape -- ^ SYNAPSE-20: how it rides out an outage+ -> IO (Maybe WS.Headers) -- ^ SYNAPSE-20: sign in again; the upgrade's new headers+ -> (R.Resumed -> IO ()) -- ^ told when a stream resumed after a gap+ -> Path -> Text -> Value -> (HubStreamItem -> IO ()) -- ^ Handler for non-request items -> (Text -> PT.Request Value -> IO (Maybe (PT.Response Value))) -- ^ Handler for bidirectional requests -> SynapseM ()-invokeStreamingWithBidir namespacePath method params onItem onBidirRequest = do+invokeStreamingWithBidir shape refreshAuth onResume namespacePath method params onItem onBidirRequest = do cfg <- getConfig+ cfgRef <- liftIO $ newIORef cfg let wrappedHandler item = case item of PT.StreamRequest _ _ reqId reqData _timeout -> do -- Handle the bidirectional request@@ -205,7 +270,17 @@ pure () Nothing -> pure () -- agent will respond via separate call _ -> onItem item- result <- liftIO $ ST.invokeMethodStreaming cfg namespacePath method params wrappedHandler+ attempt ps subscribed h = do+ c <- readIORef cfgRef+ ST.invokeMethodStreamingWith c subscribed namespacePath method ps h+ reauth = refreshAuth >>= \case+ Just hs -> True <$ modifyIORef' cfgRef (\c -> c { substrateHeaders = hs })+ Nothing -> pure False+ -- SYNAPSE-20: through an outage — a read sent again, a stream resumed+ -- (with since when it takes one), a write that went out never repeated+ result <- liftIO $+ R.resilientCall (whereOf cfg) shape attempt reauth params onResume wrappedHandler+ >>= R.reportEnding (whereOf cfg) case result of Left transportErr -> do backend <- asks seBackend@@ -228,6 +303,13 @@ getPort (PT.ConnectionTimeout _ p) _ = p getPort _ c = substratePort c +-- | SYNAPSE-20: @host:port@, for what synapse says about an outage+-- (SCAFFOLD-21: the URL, for an address behind TLS or on a path).+whereOf :: SubstrateConfig -> Text+whereOf c = case (substrateTls c, substratePath c) of+ (Nothing, "/") -> T.pack (substrateHost c) <> ":" <> T.pack (show (substratePort c))+ (tls, path) -> maybe "ws" (const "wss") tls <> "://" <> T.pack (hostHeader c) <> T.pack path+ -- | Send a response for a bidirectional request sendResponse :: Text -> PT.Response Value -> SynapseM () sendResponse requestId response = do@@ -250,14 +332,30 @@ -- | Get SubstrateConfig from environment getConfig :: SynapseM SubstrateConfig getConfig = do- host <- asks seHost- port <- asks sePort+ ep <- asks seEndpoint backend <- asks seBackend mToken <- asks seToken- pure $ SubstrateConfig- { substrateHost = T.unpack host- , substratePort = port- , substratePath = "/"- , substrateBackend = backend- , substrateHeaders = maybe [] cookieHeader mToken- }+ cks <- asks seCookies+ hdrs <- asks seHeaders+ -- SAFE-S04: merge token Cookie + arbitrary --cookie/--header upgrade context.+ -- SCAFFOLD-21: the endpoint's scheme (ws / wss) and path come along.+ pure $ substrateConfig ep backend (mergeUpgradeHeaders mToken cks hdrs)++-- | Build the WS upgrade Headers list from optional token + extra cookies + extra headers.+-- Cookies are concatenated into a single Cookie header as @key=value; key=value@.+-- Token (when present) becomes a @access_token=...@ cookie entry merged with the rest.+mergeUpgradeHeaders :: Maybe Text -> [(Text, Text)] -> [(Text, Text)] -> WS.Headers+mergeUpgradeHeaders mToken extraCookies extraHeaders =+ let allCookies = case mToken of+ Just tok -> ("access_token", tok) : extraCookies+ Nothing -> extraCookies+ cookieHdr = if null allCookies+ then []+ else+ let kv (k, v) = T.unpack k <> "=" <> T.unpack v+ joined = BS8.pack $ List.intercalate "; " (map kv allCookies)+ in [(CI.mk "Cookie", joined)]+ otherHdrs = [ (CI.mk (BS8.pack (T.unpack k)), BS8.pack (T.unpack v))+ | (k, v) <- extraHeaders+ ]+ in cookieHdr ++ otherHdrs
+ test/AuthSpec.hs view
@@ -0,0 +1,369 @@+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE QuasiQuotes #-}+{-# LANGUAGE LambdaCase #-}++-- | AUTH-8: synapse shows each caller its own server.+--+-- * plexus-protocol parses AUTH-2..5's new shapes — and their absence:+-- every field is optional, today's hubs answer none of it;+-- * the renders: the anonymous page, the caller's header and the grant+-- columns, the refusal block — each pinned to the ticket's transcript;+-- * the loopback's request parsing and state check, and the listener+-- itself on 127.0.0.1;+-- * the flows end to end against 'MockHub' (a hub in miniature on a port+-- of its own): a person's browser sign-in, an agent's auth.login and its+-- fallback to session.login on a hub without auth, whoami.+--+-- @SYNAPSE_MOCK_HUB=today|anonymous|caller@ runs the mock hub instead of+-- the tests (it prints its port), for transcripts by hand.+module Main (main) where++import Control.Concurrent (forkIO, threadDelay)+import Control.Monad (void)+import Data.Aeson (Value, fromJSON, Result(..), toJSON, decode)+import qualified Data.Aeson as A+import Data.Aeson.QQ.Simple (aesonQQ)+import qualified Data.ByteString.Char8 as BS8+import qualified Data.Map.Strict as Map+import Data.Maybe (isJust)+import Data.Text (Text)+import qualified Data.Text as T+import qualified Katip+import qualified Network.Socket as N+import qualified Network.Socket.ByteString as NB+import System.Environment (lookupEnv, setEnv)+import Test.Hspec++import Plexus.Auth+import Plexus.Schema.Recursive (ChildSummary(..), MethodSchema(..), PluginSchema(..), MethodRole(..), hasAuthColumns)+import Plexus.Types (PlexusStreamItem(..), itemRefusal)+import Synapse.Endpoint (plainAt)+import Synapse.Algebra.Render (renderSchema, renderSchemaFor, defaultStyle)+import Synapse.Auth+import Synapse.Auth.Loopback+import Synapse.Identity.Proof (generateSeed)+import qualified Synapse.Identity.Session as Session+import qualified Synapse.Log as Log++import MockHub++main :: IO ()+main = do+ serve <- lookupEnv "SYNAPSE_MOCK_HUB"+ case serve of+ Just "today" -> serveMockHub todaysHub+ Just "anonymous" -> serveMockHub (Scenario AnonymousView True False mockState False)+ Just "caller" -> serveMockHub (Scenario CallerView True False mockState False)+ _ -> do+ setEnv "SYNAPSE_NO_BROWSER" "1"+ hspec spec++-- ============================================================================+-- Fixtures+-- ============================================================================++parse :: A.FromJSON a => Value -> a+parse v = case fromJSON v of+ Success x -> x+ Error e -> error e++-- AUTH-3/4: a caller's tickets schema+callerTickets :: PluginSchema+callerTickets = parse [aesonQQ|{+ "namespace": "tickets", "version": "0.5.0", "description": "Plans as data", "hash": "v-9a77",+ "view": "caller",+ "roles": {"viewer": ["tickets.get", "tickets.list"], "editor": ["tickets.get", "tickets.list", "tickets.set_status"]},+ "methods": [+ {"name": "get", "description": "One ticket by id", "hash": "g", "grant": "tickets.get", "target": "id", "held_on": ["MENGER"]},+ {"name": "list", "description": "Tickets, filtered and paged", "hash": "l", "grant": "tickets.list", "held_on": ["MENGER"]},+ {"name": "set_status", "description": "Change a ticket's status", "hash": "s",+ "long_description": "Moves it through Pending → Ready → Coding → In Review → Complete; a doc has no lifecycle and is refused.",+ "grant": "tickets.set_status", "target": "id", "held_on": ["MENGER-12"]}+ ]}|]++-- today's: no grant, no view+todaysTickets :: PluginSchema+todaysTickets = parse [aesonQQ|{+ "namespace": "tickets", "version": "0.5.0", "description": "Plans as data", "hash": "t0",+ "methods": [+ {"name": "get", "description": "One ticket by id", "hash": "g"},+ {"name": "set_status", "description": "Change a ticket's status", "hash": "s"}+ ]}|]++methodNamed :: Text -> PluginSchema -> MethodSchema+methodNamed n ps = head [m | m <- psMethods ps, methodName m == n]++worker :: Whoami+worker = Whoami+ { whoPrincipal = "key:ac79e0f3b1c24d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f708192a3b4c5d6"+ , whoName = Just "worker-1", whoAgent = Just "worker-1"+ , whoMemberships = [("editor", "MENGER")], whoCeiling = ["editor@MENGER-12"] }++-- AUTH-2's refusal, as the subscription notification's result carries it+refusalItem :: Value+refusalItem = [aesonQQ|{"type":"error","error_kind":"forbidden",+ "grant":"tickets.set_status","scope":"IDENTITY",+ "roles":["editor","admin"],"held_on":["MENGER"],+ "message":"needs tickets.set_status on IDENTITY",+ "metadata":{"provenance":["menger"],"plexus_hash":"h","timestamp":0}}|]++logger :: IO Log.Logger+logger = Log.makeLogger Katip.ErrorS++-- ============================================================================+-- The spec+-- ============================================================================++spec :: Spec+spec = do+ describe "plexus-protocol: the method schema (AUTH-3/4)" $ do+ it "reads grant, target, held_on and long_description" $ do+ let m = methodNamed "set_status" callerTickets+ methodGrant m `shouldBe` Just "tickets.set_status"+ methodTarget m `shouldBe` Just "id"+ methodHeldOn m `shouldBe` Just ["MENGER-12"]+ methodLongDescription m `shouldSatisfy` maybe False ("Moves it through" `T.isPrefixOf`)+ methodTarget (methodNamed "list" callerTickets) `shouldBe` Nothing+ it "reads their absence as nothing (today's schemas)" $ do+ let m = methodNamed "set_status" todaysTickets+ (methodGrant m, methodTarget m, methodHeldOn m, methodLongDescription m, methodPublic m)+ `shouldBe` (Nothing, Nothing, Nothing, Nothing, False)+ (psRoles todaysTickets, psView todaysTickets) `shouldBe` (Nothing, Nothing)+ hasAuthColumns todaysTickets `shouldBe` False+ it "reads roles and the view on a caller's plugin schema" $ do+ psView callerTickets `shouldBe` Just Caller+ fmap (Map.lookup "viewer") (psRoles callerTickets) `shouldBe` Just (Just ["tickets.get", "tickets.list"])+ hasAuthColumns callerTickets `shouldBe` True+ it "reads an anonymous view's bare schema (a method named and public, children without hashes)" $ do+ let root = parse [aesonQQ|{"namespace":"menger","hash":"v-anon-3c1f",+ "children":[{"namespace":"auth","methods":[{"name":"providers","public":true},{"name":"login","public":true}]}]}|] :: PluginSchema+ map csNamespace (maybe [] id (psChildren root)) `shouldBe` ["auth"]+ psVersion root `shouldBe` ""+ let m = parse [aesonQQ|{"name":"login","public":true}|] :: MethodSchema+ (methodName m, methodPublic m, methodDescription m, methodRole m) `shouldBe` ("login", True, "", MethodRoleRpc)+ it "writes the new fields back only when present" $ do+ let v = toJSON (methodNamed "set_status" todaysTickets)+ (decode (A.encode v) :: Maybe MethodSchema) `shouldBe` Just (methodNamed "set_status" todaysTickets)+ show v `shouldNotContain` "grant"+ (fromJSON (toJSON callerTickets) :: Result PluginSchema) `shouldBe` Success callerTickets++ describe "plexus-protocol: _info's view and auth block (AUTH-4)" $ do+ it "today's _info: no view, no auth" $ do+ let i = parseBackendInfo [aesonQQ|{"backend":"menger","auth_capabilities":{"mechanisms":[{"kind":"anonymous"}]}}|]+ i `shouldBe` Just (BackendInfo "menger" Nothing Nothing Nothing)+ it "an anonymous _info: the door's methods and the resource metadata" $ do+ let i = parseBackendInfo [aesonQQ|{"backend":"menger","view":"anonymous",+ "auth":{"methods":["auth.providers","auth.login","auth.signup"],+ "resource_metadata":"http://127.0.0.1:4456/.well-known/oauth-protected-resource"}}|]+ fmap infoView i `shouldBe` Just (Just Anonymous)+ fmap (fmap authMethods . infoAuth) i `shouldBe` Just (Just ["auth.providers", "auth.login", "auth.signup"])+ fmap (fmap authResourceMetadata . infoAuth) i `shouldBe` Just (Just (Just "http://127.0.0.1:4456/.well-known/oauth-protected-resource"))+ it "a caller's _info, its view's hash as view_hash, hash, or inside a view object" $ do+ fmap infoViewHash (parseBackendInfo [aesonQQ|{"backend":"m","view":"caller","view_hash":"v-9a77"}|]) `shouldBe` Just (Just "v-9a77")+ fmap infoViewHash (parseBackendInfo [aesonQQ|{"backend":"m","view":"caller","hash":"v-1"}|]) `shouldBe` Just (Just "v-1")+ let o = parseBackendInfo [aesonQQ|{"backend":"m","view":{"kind":"caller","hash":"v-2"}}|]+ fmap (\i -> (infoView i, infoViewHash i)) o `shouldBe` Just (Just Caller, Just "v-2")+ it "a view it cannot read is no view, never a failed _info" $+ fmap infoView (parseBackendInfo [aesonQQ|{"backend":"m","view":"sideways","auth":7}|]) `shouldBe` Just Nothing++ describe "plexus-protocol: the refusal item (AUTH-2)" $ do+ it "an error item with error_kind forbidden carries its grant, scope, roles and held_on" $ do+ let item = parse refusalItem :: PlexusStreamItem+ itemRefusal item `shouldBe` Just Refusal+ { refusalGrant = Just "tickets.set_status", refusalScope = Just "IDENTITY"+ , refusalRoles = ["editor", "admin"], refusalHeldOn = ["MENGER"], refusalCeiling = []+ , refusalMessage = "needs tickets.set_status on IDENTITY" }+ case item of+ StreamError _ _ msg _ -> msg `shouldBe` "needs tickets.set_status on IDENTITY"+ _ -> expectationFailure "a refusal is still an error item"+ it "parses as written in the ticket, without metadata" $ do+ let bare = parse [aesonQQ|{"type":"error","error_kind":"forbidden","grant":"tickets.set_status",+ "scope":"IDENTITY","roles":["editor","admin"],"held_on":["MENGER"],+ "message":"needs tickets.set_status on IDENTITY"}|] :: PlexusStreamItem+ fmap refusalGrant (itemRefusal bare) `shouldBe` Just (Just "tickets.set_status")+ it "any other error has no refusal, and builds as before" $ do+ let plain = parse [aesonQQ|{"type":"error","message":"Activation not found: auth",+ "metadata":{"provenance":["menger"],"plexus_hash":"h","timestamp":0},"recoverable":false}|]+ itemRefusal plain `shouldBe` Nothing+ itemRefusal (StreamError "h" (parse [aesonQQ|["m"]|]) "boom" False) `shouldBe` Nothing+ it "keeps a refusal's fields when written back" $ do+ let item = parse refusalItem :: PlexusStreamItem+ fmap itemRefusal (decode (A.encode item)) `shouldBe` Just (itemRefusal item)+ it "a data item whose content is a refusal is one too; one missing every field is still read" $ do+ let d = parse [aesonQQ|{"type":"data","content_type":"tickets.set_status",+ "metadata":{"provenance":["menger"],"plexus_hash":"h","timestamp":0},+ "content":{"type":"error","error_kind":"forbidden","grant":"tickets.create","scope":"MENGER"}}|]+ fmap refusalMessage (itemRefusal d) `shouldBe` Just "needs tickets.create on MENGER"+ parseRefusal [aesonQQ|{"error_kind":"forbidden"}|] `shouldBe` Just (Refusal Nothing Nothing [] [] [] "forbidden")+ parseRefusal [aesonQQ|{"error_kind":"invalid_params","message":"x"}|] `shouldBe` Nothing++ describe "the anonymous page" $ do+ it "renders the ticket's transcript" $ do+ let page = AnonPage "menger" (Just "1.0.0")+ (anonymousMethods (Just (AuthInfo ["auth.providers", "auth.login", "auth.signup"] Nothing)) Nothing)+ "synapse -P 4455"+ renderAnonymousPage page `shouldBe` T.unlines+ [ "menger v1.0.0"+ , " Sign in to see what you can do here."+ , ""+ , "auth"+ , " login Sign in \x2014 a person through the browser, an agent with its key"+ , " signup Create an account, or ask for access"+ , " providers The ways in this server offers"+ , ""+ , " synapse -P 4455 _login menger # a person"+ , " synapse -P 4455 _login menger --key agent-7 --as w-1 # an agent"+ ]+ it "takes the door's methods from _info, else the auth schema's public ones, else its own three" $ do+ let authS = parse [aesonQQ|{"namespace":"auth","methods":[+ {"name":"login","description":"Sign in here","public":true},+ {"name":"whoami","description":"Who","public":false},+ {"name":"magic","description":"A link by mail","public":true}]}|]+ anonymousMethods Nothing (Just authS) `shouldBe` [("login", "Sign in here"), ("magic", "A link by mail")]+ map fst (anonymousMethods (Just (AuthInfo ["auth.signup"] Nothing)) (Just authS)) `shouldBe` ["signup"]+ map fst (anonymousMethods Nothing Nothing) `shouldBe` ["login", "signup", "providers"]+ it "names no version when the view shows none" $+ head (T.lines (renderAnonymousPage (AnonPage "menger" Nothing [] "synapse"))) `shouldBe` "menger"++ describe "the caller's header" $ do+ it "says who, what role where, and within what" $+ renderCallerHeader worker `shouldBe` "you are worker-1 (key:ac79\x2026) \x00B7 editor on MENGER, within MENGER-12"+ it "parses auth.whoami's shape" $+ parseWhoami [aesonQQ|{"type":"whoami","principal":"key:ac79e0f3b1c24d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f708192a3b4c5d6",+ "name":"worker-1","agent":"worker-1",+ "memberships":[{"role":"editor","scope":"MENGER"}],"ceiling":["editor@MENGER-12"]}|]+ `shouldBe` Just worker+ it "reads memberships as role@SCOPE strings and a ceiling as objects" $ do+ let w = parseWhoami [aesonQQ|{"principal":"oidc:workos/user_01M4QXZ7ABCDEF","name":"Ada Lovelace",+ "memberships":["admin@*","viewer@IDENTITY","viewer@AUTH"],+ "ceiling":[{"role":"viewer","scope":"AUTH"}]}|]+ fmap renderCallerHeader w `shouldBe`+ Just "you are Ada Lovelace (oidc:workos/user_01M4\x2026) \x00B7 admin on *; viewer on IDENTITY, AUTH, within AUTH"+ it "falls back to session.whoami's identity (roles with no scope)" $+ fmap renderCallerHeader (parseWhoami [aesonQQ|{"type":"identity","principal":"idp:1234","name":"ada","roles":["admin"],"tenant":null}|])+ `shouldBe` Just "you are ada (idp:1234) \x00B7 admin"+ it "an anonymous whoami is no one" $ do+ parseWhoami [aesonQQ|{"type":"anonymous"}|] `shouldBe` Nothing+ parseWhoami [aesonQQ|{"type":"identity","principal":""}|] `shouldBe` Nothing+ it "shortens a long principal and leaves a short one" $ do+ abbreviatePrincipal "key:ac79e0f3b1c24d5e6f708192a3b4c5d6" `shouldBe` "key:ac79\x2026"+ abbreviatePrincipal "oidc:workos/user_01M4QXZ7ABCDEF" `shouldBe` "oidc:workos/user_01M4\x2026"+ abbreviatePrincipal "idp:1234" `shouldBe` "idp:1234"++ describe "help in a caller's view" $ do+ let out = renderSchemaFor defaultStyle (Just (renderCallerHeader worker)) callerTickets+ ls = T.lines out+ lineOf n = head [l | l <- ls, (" " <> n <> " ") `T.isPrefixOf` l]+ colOf needle l = T.length (fst (T.breakOn needle l))+ it "puts who the caller is under the description" $+ take 3 ls `shouldBe`+ [ "tickets v0.5.0", " Plans as data"+ , " you are worker-1 (key:ac79\x2026) \x00B7 editor on MENGER, within MENGER-12" ]+ it "shows each method's grant and where it is held, in aligned columns" $ do+ lineOf "get" `shouldSatisfy` T.isInfixOf "tickets.get"+ T.stripEnd (lineOf "set_status") `shouldSatisfy` T.isSuffixOf "tickets.set_status MENGER-12"+ let grantCols = [colOf "tickets." (lineOf n) | n <- ["get", "list", "set_status"]]+ heldCols = [colOf "MENGER" (T.drop (colOf "tickets." (lineOf n)) (lineOf n)) + colOf "tickets." (lineOf n) | n <- ["get", "list", "set_status"]]+ length (filter (== head grantCols) grantCols) `shouldBe` 3+ length (filter (== head heldCols) heldCols) `shouldBe` 3+ it "renders a schema with no grants exactly as before AUTH, header or not" $ do+ renderSchemaFor defaultStyle Nothing todaysTickets `shouldBe` renderSchema todaysTickets+ T.lines (renderSchemaFor defaultStyle Nothing callerTickets) `shouldNotContain`+ [" you are worker-1 (key:ac79\x2026) \x00B7 editor on MENGER, within MENGER-12"]++ describe "the refusal block" $ do+ let r = Refusal (Just "tickets.set_status") (Just "IDENTITY") ["editor", "admin"] ["MENGER"] [] "needs tickets.set_status on IDENTITY"+ it "renders the ticket's transcript, the ceiling from the session" $+ renderRefusal ["editor@MENGER-12"] r `shouldBe` T.unlines+ [ "refused: needs tickets.set_status on IDENTITY"+ , " roles with it editor, admin"+ , " you hold it on MENGER (within MENGER-12)" ]+ it "prefers the refusal's own ceiling, and says so without one" $ do+ T.lines (renderRefusal ["editor@X"] r { refusalCeiling = ["editor@MENGER-7"] }) !! 2+ `shouldBe` " you hold it on MENGER (within MENGER-7)"+ T.lines (renderRefusal [] r) !! 2 `shouldBe` " you hold it on MENGER"+ it "says nowhere when the caller holds it nowhere, and leaves out roles it was not told" $+ renderRefusal [] (Refusal (Just "auth.role_set") (Just "*") [] [] [] "x") `shouldBe` T.unlines+ [ "refused: needs auth.role_set on *", " you hold it on nowhere" ]++ describe "the loopback: a request and its state" $ do+ let req s = parseRequestLine (BS8.pack s)+ it "reads the request line's path and query, percent-decoded" $+ req "GET /cb?code=c_19ab&state=s%2F7f2c HTTP/1.1\r\nHost: x\r\n\r\n"+ `shouldBe` Just ("GET", "/cb", [("code", "c_19ab"), ("state", "s/7f2c")])+ it "takes the code only with the state it handed out" $ do+ fmap (checkCallback "s_7f2c") (req "GET /cb?code=c_1&state=s_7f2c HTTP/1.1") `shouldBe` Just (CallbackCode "c_1")+ fmap (checkCallback "s_7f2c") (req "GET /cb?code=c_1&state=s_evil HTTP/1.1") `shouldSatisfy` refused+ fmap (checkCallback "s_7f2c") (req "GET /cb?code=c_1 HTTP/1.1") `shouldSatisfy` refused+ fmap (checkCallback "s_7f2c") (req "GET /cb?state=s_7f2c HTTP/1.1") `shouldSatisfy` refused+ fmap (checkCallback "s_7f2c") (req "GET /cb?error=access_denied&state=s_7f2c HTTP/1.1") `shouldSatisfy` refused+ it "ignores anything but /cb" $+ fmap (checkCallback "s") (req "GET /favicon.ico HTTP/1.1") `shouldBe` Just CallbackIgnored+ it "listens on 127.0.0.1 and answers the browser" $ withLoopback $ \lb -> do+ loopbackRedirect lb `shouldSatisfy` T.isPrefixOf "http://127.0.0.1:"+ void $ forkIO $ do+ threadDelay 100000+ _ <- httpGet (loopbackPort lb) "/favicon.ico"+ reply <- httpGet (loopbackPort lb) "/cb?code=c_1&state=s_ok"+ reply `shouldSatisfy` BS8.isPrefixOf "HTTP/1.1 200"+ awaitCallback lb "s_ok" 5 `shouldReturn` Right "c_1"+ it "refuses a browser back with another state, and ends the wait" $ withLoopback $ \lb -> do+ void $ forkIO $ threadDelay 100000 >> void (httpGet (loopbackPort lb) "/cb?code=c_1&state=s_other")+ r <- awaitCallback lb "s_ok" 5+ r `shouldSatisfy` either ("state" `T.isInfixOf`) (const False)+ it "gives up after its time" $ withLoopback $ \lb ->+ awaitCallback lb "s_ok" 1 `shouldReturn` Left "no answer from the browser within 1 second"++ describe "against a hub (MockHub)" $ do+ it "a person signs in through the browser: login_url, the loopback, the code, a session" $+ withMockHub (Scenario AnonymousView True False mockState False) $ \port -> do+ lg <- logger+ r <- Session.loginPerson lg (plainAt "127.0.0.1" port) "menger" 10 (const (pure ()))+ fmap (\g -> (sgToken g, sgName g, sgPrincipal g)) r+ `shouldBe` Right ("mgs_person", Just "Ada Lovelace", "oidc:workos/user_01M4QXZ7ABCDEF")+ fmap (fmap renderCallerHeader . sgWhoami) r+ `shouldBe` Right (Just "you are Ada Lovelace (oidc:workos/user_01M4\x2026) \x00B7 admin on *")+ it "a browser that comes back with another state signs no one in" $+ withMockHub (Scenario AnonymousView True False "s_forged" False) $ \port -> do+ lg <- logger+ r <- Session.loginPerson lg (plainAt "127.0.0.1" port) "menger" 10 (const (pure ()))+ either ("state" `T.isInfixOf`) (const False) r `shouldBe` True+ it "a hub with no auth activation says so for a person" $+ withMockHub todaysHub $ \port -> do+ lg <- logger+ r <- Session.loginPerson lg (plainAt "127.0.0.1" port) "menger" 10 (const (pure ()))+ either ("no auth activation" `T.isInfixOf`) (const False) r `shouldBe` True+ it "an agent asks auth.login for a session where the hub has auth" $+ withMockHub (Scenario CallerView True False mockState False) $ \port -> do+ lg <- logger+ seed <- generateSeed+ r <- Session.loginWithKey lg (plainAt "127.0.0.1" port) "menger" seed "worker-1" ["editor@MENGER-12"] (Just 3600)+ fmap (\l -> (Session.lrToken l, Session.lrScope l)) r `shouldBe` Right ("mgs_agent_auth", ["editor@MENGER-12"])+ it "an agent falls back to session.login on today's hub" $+ withMockHub todaysHub $ \port -> do+ lg <- logger+ seed <- generateSeed+ r <- Session.loginWithKey lg (plainAt "127.0.0.1" port) "menger" seed "worker-1" ["editor@MENGER-12"] Nothing+ fmap (\l -> (Session.lrToken l, Session.lrScope l)) r `shouldBe` Right ("mgs_agent_session", ["editor@MENGER-12"])+ it "whoami: auth.whoami where there is one, nothing where neither answers" $ do+ withMockHub (Scenario CallerView True False mockState False) $ \port -> do+ lg <- logger+ Session.whoamiWith lg (plainAt "127.0.0.1" port) "menger" "mgs_x" `shouldReturn` Just worker+ withMockHub todaysHub $ \port -> do+ lg <- logger+ w <- Session.whoamiWith lg (plainAt "127.0.0.1" port) "menger" "mgs_x"+ isJust w `shouldBe` False+ where+ refused = \case+ Just (CallbackRefused _) -> True+ _ -> False++-- | A GET to the loopback, as a browser sends it; the reply's first bytes.+httpGet :: Int -> String -> IO BS8.ByteString+httpGet port path = do+ sock <- N.socket N.AF_INET N.Stream N.defaultProtocol+ N.connect sock (N.SockAddrInet (fromIntegral port) (N.tupleToHostAddress (127, 0, 0, 1)))+ NB.sendAll sock (BS8.pack ("GET " <> path <> " HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n"))+ r <- NB.recv sock 4096+ N.close sock+ pure r
test/DeprecationSpec.hs view
@@ -67,6 +67,7 @@ , methodDeprecation = Nothing , methodParamSchemas = Nothing , methodRole = MethodRoleRpc+ , methodLongDescription = Nothing, methodGrant = Nothing, methodTarget = Nothing, methodHeldOn = Nothing, methodPublic = False, methodGroup = Nothing, methodExamples = [] } deprecatedMethod :: MethodSchema@@ -91,6 +92,7 @@ , psMethods = [deprecatedMethod, plainMethod] , psChildren = Nothing , psDeprecation = Nothing+ , psRoles = Nothing, psView = Nothing } deprecatedPlugin :: PluginSchema@@ -309,7 +311,9 @@ describe "renderMethodHelp (IR-14 per-parameter deprecation in Help.hs)" $ do it "prepends the warning marker on the deprecated parameter's flag line" $ do let (_, rendered) = helpFixture- ls = T.lines rendered+ -- SYNAPSE-13: the options, not the usage line or an example's+ -- command line, which name the flag as it is typed+ ls = takeWhile (not . T.isPrefixOf "Example") (dropWhile (/= "Options:") (T.lines rendered)) pathLines = filter (T.isInfixOf "--path") ls pathLines `shouldSatisfy` (not . null) all (T.isInfixOf deprecationMarker) pathLines `shouldBe` True
+ test/EndpointSpec.hs view
@@ -0,0 +1,107 @@+{-# LANGUAGE OverloadedStrings #-}++-- | SCAFFOLD-21: where a hub is reached — @--url@ and @-H@ / @-P@ with+-- @--tls@ / @--path@ read into one 'Endpoint', and every key derived from+-- it: the session store's (a plain address keyed exactly as before), the+-- key proof's audience, the options a message tells the reader to type.+module Main (main) where++import Data.Either (isLeft)+import qualified Data.Text as T+import Test.Hspec++import Plexus.Client (SubstrateConfig(..), TlsOptions(..), hostHeader)+import Synapse.Endpoint++main :: IO ()+main = hspec $ do+ describe "parseUrl" $ do+ it "reads a hosted app's socket: wss, the default port, the path" $+ parseUrl "wss://hostdemo.menger.sh/ws" `shouldBe`+ Right (Endpoint "hostdemo.menger.sh" 443 True "/ws" [])+ it "reads ws with a port and no path as /" $+ parseUrl "ws://127.0.0.1:4455" `shouldBe` Right (plainAt "127.0.0.1" 4455)+ it "defaults ws to 80 and keeps an explicit wss port" $ do+ fmap epPort (parseUrl "ws://example.org/ws") `shouldBe` Right 80+ fmap epPort (parseUrl "wss://example.org:8443/ws") `shouldBe` Right 8443+ it "lowercases the host, trims a trailing slash, takes WSS in any case" $+ parseUrl "WSS://App.Example/ws/" `shouldBe` Right (Endpoint "app.example" 443 True "/ws" [])+ it "reads a bracketed IPv6 literal" $+ parseUrl "wss://[::1]:8443/ws" `shouldBe` Right (Endpoint "::1" 8443 True "/ws" [])+ it "refuses http(s), a missing scheme, a query, a credential, a bad port" $ do+ parseUrl "https://hostdemo.menger.sh/ws" `shouldSatisfy` isLeft+ parseUrl "http://hostdemo.menger.sh" `shouldSatisfy` isLeft+ parseUrl "hostdemo.menger.sh/ws" `shouldSatisfy` isLeft+ parseUrl "wss://h/ws?x=1" `shouldSatisfy` isLeft+ parseUrl "wss://u:p@h/ws" `shouldSatisfy` isLeft+ parseUrl "wss://h:99999/ws" `shouldSatisfy` isLeft+ parseUrl "wss://h:/ws" `shouldSatisfy` isLeft+ parseUrl "wss:///ws" `shouldSatisfy` isLeft+ it "says what https:// would have meant" $+ parseUrl "https://app.example/ws" `shouldBe`+ Left "--url takes ws:// or wss://, not https:// (the socket of an https page is wss://app.example/ws)"++ describe "normalisePath" $+ it "makes /PATH of any spelling" $ do+ map normalisePath ["", "/", "ws", "/ws", "/ws/", "//a/b//"] `shouldBe` ["/", "/", "/ws", "/ws", "/ws", "/a/b"]++ describe "the session store's keys" $ do+ it "keeps a plain address's key and directory exactly as before (no stored session moves)" $ do+ addressKey (plainAt "127.0.0.1" 4455) `shouldBe` "127.0.0.1:4455"+ dirName (plainAt "127.0.0.1" 4455) `shouldBe` "127.0.0.1_4455"+ it "keys a wss address by its URL, port and path included" $ do+ let ep = Endpoint "hostdemo.menger.sh" 443 True "/ws" []+ addressKey ep `shouldBe` "wss://hostdemo.menger.sh:443/ws"+ dirName ep `shouldBe` "wss_hostdemo.menger.sh_443_ws"+ it "never gives a ws and a wss backend on one host, or two paths, one key" $ do+ let eps =+ [ plainAt "h" 443+ , Endpoint "h" 443 True "/" []+ , Endpoint "h" 443 True "/ws" []+ , Endpoint "h" 443 True "/ws/a" []+ , Endpoint "h" 443 False "/ws" []+ , Endpoint "h" 443 True "/ws_a" []+ ]+ distinct xs = length xs == length (foldr (\x acc -> if x `elem` acc then acc else x : acc) [] xs)+ map addressKey eps `shouldSatisfy` distinct+ map dirName eps `shouldSatisfy` distinct+ it "writes anything outside [A-Za-z0-9.-] in a path as %XX, never a separator" $ do+ dirName (Endpoint "h" 443 True "/a b/ü" []) `shouldBe` "wss_h_443_a%20b_%c3%bc"+ dirName (Endpoint "h" 443 True "/../x" []) `shouldBe` "wss_h_443_.._x"+ dirName (Endpoint "h" 443 True "/../x" []) `shouldNotSatisfy` ('/' `elem`)+ it "ignores the CA files (they say how to trust the server, not which it is)" $+ addressKey (Endpoint "h" 443 True "/ws" ["/tmp/ca.pem"]) `shouldBe` addressKey (Endpoint "h" 443 True "/ws" [])++ describe "audience (a key proof's aud)" $ do+ it "is ws://HOST:PORT for a plain address, as before" $+ audience (plainAt "127.0.0.1" 4455) `shouldBe` "ws://127.0.0.1:4455"+ it "is the wss origin a hub run with --public-url https://HOST accepts — no path, no :443" $ do+ audience (Endpoint "hostdemo.menger.sh" 443 True "/ws" []) `shouldBe` "wss://hostdemo.menger.sh"+ audience (Endpoint "localhost" 8443 True "/ws" []) `shouldBe` "wss://localhost:8443"++ describe "cliOptions (what a message tells the reader to type)" $ do+ it "says nothing for the default address, -P / -H for a plain one" $ do+ cliOptions (plainAt "127.0.0.1" 4444) `shouldBe` ""+ cliOptions (plainAt "127.0.0.1" 4455) `shouldBe` "-P 4455"+ cliOptions (plainAt "box" 4455) `shouldBe` "-H box -P 4455"+ it "says --url, in its short form, for the rest (and each --cacert)" $ do+ cliOptions (Endpoint "hostdemo.menger.sh" 443 True "/ws" []) `shouldBe` "--url wss://hostdemo.menger.sh/ws"+ cliOptions (Endpoint "localhost" 8443 True "/ws" ["ca.pem"]) `shouldBe` "--url wss://localhost:8443/ws --cacert ca.pem"+ cliOptions (Endpoint "h" 8080 False "/ws" []) `shouldBe` "--url ws://h:8080/ws"+ it "round-trips through parseUrl" $+ mapM_ (\ep -> case T.words (cliOptions ep) of+ ("--url" : u : _) -> parseUrl u `shouldBe` Right ep+ other -> expectationFailure ("no --url in " <> show other))+ [ Endpoint "hostdemo.menger.sh" 443 True "/ws" []+ , Endpoint "localhost" 8443 True "/" []+ , Endpoint "h" 8080 False "/ws" [] ]++ describe "substrateConfig" $ do+ it "dials plain ws with no TLS for a plain address" $ do+ let c = substrateConfig (plainAt "127.0.0.1" 4455) "menger" []+ (substrateHost c, substratePort c, substratePath c, substrateTls c) `shouldBe` ("127.0.0.1", 4455, "/", Nothing)+ it "dials wss at the path, the CA files carried, the Host header without :443" $ do+ let c = substrateConfig (Endpoint "hostdemo.menger.sh" 443 True "/ws" ["ca.pem"]) "hostdemo" []+ (substratePath c, substrateTls c) `shouldBe` ("/ws", Just (TlsOptions ["ca.pem"]))+ hostHeader c `shouldBe` "hostdemo.menger.sh"+ hostHeader c { substratePort = 8443 } `shouldBe` "hostdemo.menger.sh:8443"
+ test/FindSpec.hs view
@@ -0,0 +1,249 @@+{-# LANGUAGE OverloadedStrings #-}++-- | SYNAPSE-17: @synapse _find@.+--+-- * the matcher: exact, prefix, a typo at both thresholds, a snake_case+-- subsequence, every word required, and the ranking;+-- * the renders, against the mock hub's catalog (menger's tickets and+-- deployments as its schema had them on 2026-10-15, test/fixtures):+-- each of the ticket's examples piped (no colour codes) and on a+-- terminal (the matched characters bold);+-- * a caller's view: a method outside it is never a hit;+-- * the IR cache: a second search costs one schema call, the hash check.+--+-- @SYNAPSE_MOCK_HUB=catalog|catalog-caller@ serves the catalog hub instead+-- of running the tests (it prints its port), for transcripts by hand.+module Main (main) where++import Data.Aeson (encode)+import qualified Data.ByteString.Lazy.Char8 as LBS+import Data.IORef (readIORef)+import qualified Data.Map.Strict as Map+import Data.Text (Text)+import qualified Data.Text as T+import qualified Data.Text.IO as TIO+import qualified Katip+import System.Directory (getTemporaryDirectory, createDirectoryIfMissing, removeDirectoryRecursive, doesDirectoryExist)+import System.Environment (lookupEnv)+import System.FilePath ((</>))+import Test.Hspec++import Synapse.Algebra.Render (fitText, summarize)+import Synapse.Find+import Synapse.IR.Cache (CacheConfig(..), newIRCache)+import Synapse.IR.Types+import qualified Synapse.Log as Log+import Synapse.Monad (initEnv, runSynapseM, withViewKey)++import MockHub++main :: IO ()+main = do+ serve <- lookupEnv "SYNAPSE_MOCK_HUB"+ dump <- lookupEnv "SYNAPSE_FIND_DUMP"+ case (serve, dump) of+ (Just "catalog", _) -> serveMockHub (catalogHub NoView)+ (Just "catalog-caller", _) -> serveMockHub (catalogHub CallerView)+ (_, Just _) -> dumpAll+ _ -> hspec spec++-- ============================================================================+-- Fixtures+-- ============================================================================++-- | The catalog as a view of the mock hub has it (no cache).+catalog :: ViewMode -> IO IR+catalog v = withMockHub (catalogHub v) $ \port -> do+ logger <- Log.makeLogger Katip.ErrorS+ env0 <- initEnv "127.0.0.1" port "menger" logger Nothing+ let env = withViewKey (if v == CallerView then Just "v-9a77" else Nothing) env0+ r <- runSynapseM env (loadCatalog Nothing)+ either (\e -> fail (show e)) pure r++opts :: [Text] -> FindOpts+opts ws = FindOpts "menger" ws defaultLimit False False++-- | _find menger WORDS, as synapse -P 4455 says it, piped or on a terminal.+page :: IR -> Bool -> FindOpts -> Text+page ir bold fo = anText (answer "synapse -P 4455" "menger" bold fo ir)++stripEsc :: Text -> Text+stripEsc = T.replace "\ESC[1m" "" . T.replace "\ESC[22m" ""++-- | The ticket's examples: how each is typed, its golden file, the opts,+-- and a stretch of its terminal page that must be bold.+examples :: [(String, FilePath, FindOpts, Text)]+examples =+ [ ("snooze", "snooze", opts ["snooze"], "tickets \ESC[1msnooze\ESC[22m")+ , ("comit", "comit", opts ["comit"], "link_\ESC[1mcommit\ESC[22ms")+ , ("queue urgency", "queue-urgency", opts ["queue", "urgency"], "--\ESC[1murgency\ESC[22m <string>")+ , ("tickets setpar", "tickets-setpar", opts ["tickets", "setpar"], "\ESC[1mset\ESC[22m_\ESC[1mpar\ESC[22ment")+ , ("--params filter", "params-filter", (opts ["filter"]) { foParams = True }, "[--\ESC[1mfilter\ESC[22m <string>]")+ , ("reschedule", "reschedule", opts ["reschedule"], "")+ -- a quoted example is never cut inside: 'category in (active, review)'+ -- does not fit, so the line ends before it+ , ("watch filter", "watch-filter", opts ["watch", "filter"], "--\ESC[1mfilter\ESC[22m <string>")+ ]++golden :: FilePath -> FilePath+golden name = "test/fixtures/find-golden" </> name <> ".txt"++-- | Write the goldens again (SYNAPSE_FIND_DUMP=1), from the pages as they+-- render now: read them before committing.+dumpAll :: IO ()+dumpAll = do+ ir <- catalog NoView+ createDirectoryIfMissing True "test/fixtures/find-golden"+ mapM_ (\(_, file, fo, _) -> TIO.writeFile (golden file) (page ir False fo)) examples+ LBS.writeFile (golden "rationale-json") (LBS.unlines (map encode (anJson (answer "synapse -P 4455" "menger" False (opts ["rationale"]) ir))))++-- | A method as the IR has it, for the ranking's synthetic catalog.+method :: Text -> Text -> Text -> MethodDef+method ns name desc = MethodDef+ { mdName = name, mdFullPath = ns <> "." <> name, mdNamespace = ns+ , mdDescription = Just desc, mdStreaming = False, mdParams = []+ , mdReturns = RefUnknown, mdBidirType = Nothing, mdBidirResponseType = Nothing+ , mdBidirResponseSchema = Nothing, mdRole = MethodRoleRpc+ , mdGrant = Nothing, mdTarget = Nothing, mdLongDescription = Nothing+ }++synthetic :: IR+synthetic = emptyIR { irMethods = Map.fromList [ (mdFullPath m, m) | m <- ms ] }+ where+ ms =+ [ method "tickets" "find" "One by id"+ , method "x" "find" "One by id, elsewhere"+ , method "tickets" "find_all" "Every one"+ , method "tickets" "fi_nder" "A finder"+ , method "tickets" "fine" "A fine"+ , method "tickets" "lookup" "Find things by name"+ , method "tickets" "other" "Nothing to see"+ ]++kinds :: Text -> Text -> Maybe MatchKind+kinds w n = wmKind <$> matchName w n++-- ============================================================================+-- The spec+-- ============================================================================++spec :: Spec+spec = do+ describe "the matcher" $ do+ it "an exact name" $+ kinds "snooze" "snooze" `shouldBe` Just Exact+ it "a prefix of the name, a segment, a segment's start" $ do+ kinds "snooze" "snoozed" `shouldBe` Just Prefix+ kinds "commits" "link_commits" `shouldBe` Just SegmentExact+ kinds "commit" "link_commits" `shouldBe` Just SegmentPrefix+ it "a typo of 4-7 letters: one edit, said as the name it meant" $ do+ fmap (\m -> (wmKind m, wmTaken m)) (matchName "comit" "link_commits") `shouldBe` Just (Typo 1, "commit")+ kinds "snoze" "snooze" `shouldBe` Just (Typo 1)+ kinds "unlnik" "unlink_commits" `shouldBe` Nothing -- two edits at 6 letters+ it "a typo beyond 7 letters: two edits" $ do+ kinds "ratoinale" "rationale" `shouldBe` Just (Typo 2)+ kinds "rationle" "rationale" `shouldBe` Just (Typo 1)+ kinds "rtoinael" "rationale" `shouldBe` Nothing+ it "no typo under 4 letters" $ do+ typoLimit 3 `shouldBe` 0+ (typoLimit 4, typoLimit 7, typoLimit 8) `shouldBe` (1, 1, 2)+ kinds "snz" "snooze" `shouldBe` Nothing+ it "a snake_case subsequence: prefixes of its segments, in order" $ do+ matchName "setpar" "set_parent" `shouldBe` Just (WordMatch Subsequence "set_parent" [(0, 3), (4, 3)])+ kinds "lc" "link_commits" `shouldBe` Just Subsequence+ kinds "parset" "set_parent" `shouldBe` Nothing+ it "a word of a description, or its start" $ do+ fmap wmSpans (matchDescription "snooze" "Include the snoozed ones") `shouldBe` Just [(12, 6)]+ matchDescription "zz" "zz top" `shouldBe` Nothing+ it "every word must match" $ do+ ir <- catalog NoView+ let hits ws = search OnlyAll (catalogEntries [] ir) ws+ where' h = (entryLocation (hitEntry h), pdName <$> enParam (hitEntry h))+ map where' (hits ["queue", "urgency"]) `shouldBe` [("tickets queue", Just "urgency")]+ hits ["queue", "zzzz"] `shouldBe` []+ it "ranks exact > prefix > subsequence / typo > description; ties by the shorter path, then alphabetically" $ do+ let ranked = map (entryLocation . hitEntry) (search OnlyAll (catalogEntries [] synthetic) ["find"])+ ranked `shouldBe` ["x find", "tickets find", "tickets find_all", "tickets fi_nder", "tickets fine", "tickets lookup"]++ describe "a description cut to the line (fitText): never inside a quote" $ do+ let watchFilter = "A filter, e.g. 'category in (active, review)' (optional): adds Entered / Left for each ticket a write moves into or out of it, or Recount past 256"+ it "ends before a quote that does not fit, its brackets untouched" $ do+ fitText 36 (summarize watchFilter) `shouldBe` "A filter, e.g.\x2026"+ fitText 50 (summarize watchFilter) `shouldBe` "A filter, e.g. 'category in (active, review)'"+ it "a quote that fits stays whole" $+ fitText 40 "A filter, e.g. 'not category done' (optional)" `shouldBe` "A filter, e.g. 'not category done'"+ it "a backtick span is a quote too; an apostrophe is not" $ do+ fitText 26 "Run `set_status (x, y)` with the ticket's id (optional)" `shouldBe` "Run `set_status (x, y)`\x2026"+ fitText 30 "The ticket's own words (optional) and more words" `shouldBe` "The ticket's own words and\x2026"+ it "summarize does not stop inside a quote" $+ summarize "Run `a. b` then stop. More" `shouldBe` "Run `a. b` then stop"++ describe "the pages, piped and on a terminal (the mock hub's catalog)" $ do+ mapM_ (\(typed, file, fo, boldBit) -> do+ it ("_find menger " <> typed <> ", piped: as pinned, no colour codes") $ do+ ir <- catalog NoView+ want <- TIO.readFile (golden file)+ let got = page ir False fo+ got `shouldBe` want+ T.any (== '\ESC') got `shouldBe` False+ it ("_find menger " <> typed <> ", on a terminal: the same, the matched characters bold") $ do+ ir <- catalog NoView+ want <- TIO.readFile (golden file)+ let got = page ir True fo+ stripEsc got `shouldBe` want+ if T.null boldBit then pure () else T.isInfixOf boldBit got `shouldBe` True+ ) examples++ it "-j: one JSON object per hit, best first" $ do+ ir <- catalog NoView+ want <- LBS.readFile (golden "rationale-json")+ LBS.unlines (map encode (anJson (answer "synapse -P 4455" "menger" False (opts ["rationale"]) ir))) `shouldBe` want++ it "--limit: at most N, and the footer says how many more" $ do+ ir <- catalog NoView+ let t = page ir False ((opts ["filter"]) { foParams = True, foLimit = 3 })+ T.lines t `shouldContain` ["6 more: '--limit 9' shows them all."]+ length (filter (" tickets " `T.isPrefixOf`) (T.lines t)) `shouldBe` 3++ it "--methods: only methods" $ do+ ir <- catalog NoView+ let t = page ir False ((opts ["commit"]) { foMethods = True })+ T.lines t `shouldContain` ["METHODS"]+ T.lines t `shouldNotContain` ["PARAMETERS"]++ it "a description's match is MENTIONED IN, the word shown" $ do+ ir <- catalog NoView+ let t = page ir False (opts ["rationale"])+ T.lines t `shouldContain` ["MENTIONED IN"]+ T.isInfixOf "rationale" (T.unlines (dropWhile (/= "MENTIONED IN") (T.lines t))) `shouldBe` True++ describe "under login (a caller's view)" $ do+ it "a method outside the caller's view is never a hit" $ do+ outside <- catalog NoView+ inside <- catalog CallerView+ let paths ir = [ mdFullPath (enMethod (hitEntry h)) | h <- search OnlyAll (catalogEntries [] ir) ["delete"] ]+ paths outside `shouldContain` ["tickets.delete"]+ paths inside `shouldNotContain` ["tickets.delete"]+ anJson (answer "synapse" "menger" False (opts ["delete"]) inside) `shouldSatisfy` all (not . T.isInfixOf "tickets.delete" . T.pack . LBS.unpack . encode)++ describe "the schema, from the IR cache when its hash is current" $ do+ it "a second search costs one schema call: the hash check" $ do+ tmp <- getTemporaryDirectory+ let dir = tmp </> "synapse-find-test-cache"+ exists <- doesDirectoryExist dir+ if exists then removeDirectoryRecursive dir else pure ()+ cache <- newIRCache (CacheConfig True 100 True dir)+ withMockHub (catalogHub NoView) $ \port -> do+ logger <- Log.makeLogger Katip.ErrorS+ env <- initEnv "127.0.0.1" port "menger" logger Nothing+ before <- readIORef schemaCalls+ Right first <- runSynapseM env (loadCatalog (Just cache))+ middle <- readIORef schemaCalls+ -- a fresh cache on the same directory: from disk, as the next process reads it+ cache' <- newIRCache (CacheConfig True 100 True dir)+ Right second <- runSynapseM env (loadCatalog (Just cache'))+ after <- readIORef schemaCalls+ (middle - before) `shouldSatisfy` (>= 3)+ (after - middle) `shouldBe` 1+ Map.keys (irMethods second) `shouldBe` Map.keys (irMethods first)+ page second False (opts ["comit"]) `shouldBe` page first False (opts ["comit"])
test/IR12MethodRoleSpec.hs view
@@ -30,8 +30,12 @@ , MethodSchema(..) ) +import qualified Data.Map.Strict as Map+import Synapse.IR.Builder (extractMethodDef) import Synapse.IR.Types- ( MethodDef(..)+ ( IR(..)+ , emptyIR+ , MethodDef(..) , TypeRef(..) ) @@ -49,6 +53,9 @@ , mdBidirResponseType = Nothing , mdBidirResponseSchema = Nothing , mdRole = MethodRoleRpc+ , mdGrant = Nothing+ , mdTarget = Nothing+ , mdLongDescription = Nothing } -- | Build a JSON @MethodSchema@ with an optional @role@ key. Passing@@ -168,3 +175,41 @@ case eitherDecode (encode preIrMethodDefJson) :: Either String MethodDef of Left e -> expectationFailure $ "decode failed: " <> e Right md -> mdRole md `shouldBe` MethodRoleRpc++ -- AUTH-9: what a method requires reaches hub-codegen through the IR.+ describe "AUTH-9: grant, target, long_description and roles in the IR" $ do+ let schemaWith extra = object $+ [ "name" .= ("set_status" :: String)+ , "description" .= ("Change a ticket's status" :: String)+ , "hash" .= ("h" :: String)+ ] <> extra+ lifted j = case Aeson.fromJSON j :: Aeson.Result MethodSchema of+ Aeson.Error e -> error e+ Aeson.Success ms -> snd (extractMethodDef "tickets" "tickets" ms)+ keysOf v = case v of+ Object o -> o+ _ -> KM.empty++ it "lifts the schema's grant, target and long_description onto the method" $ do+ let md = lifted $ schemaWith+ [ "grant" .= ("tickets.set_status" :: String)+ , "target" .= ("id" :: String)+ , "long_description" .= ("Moves it through Pending → Ready." :: String)+ ]+ o = keysOf (Aeson.toJSON md)+ (mdGrant md, mdTarget md) `shouldBe` (Just "tickets.set_status", Just "id")+ KM.lookup "mdGrant" o `shouldBe` Just (String "tickets.set_status")+ KM.lookup "mdTarget" o `shouldBe` Just (String "id")+ KM.lookup "mdLongDescription" o `shouldBe` Just (String "Moves it through Pending → Ready.")+ fmap mdTarget (eitherDecode (encode md)) `shouldBe` Right (Just "id")++ it "a pre-AUTH schema's method carries none of the keys" $ do+ let o = keysOf (Aeson.toJSON (lifted (schemaWith [])))+ map (`KM.member` o) ["mdGrant", "mdTarget", "mdLongDescription"] `shouldBe` [False, False, False]++ it "irPluginRoles is emitted only when some plugin has roles" $ do+ KM.member "irPluginRoles" (keysOf (Aeson.toJSON emptyIR)) `shouldBe` False+ let roles = Map.singleton "tickets" (Map.singleton "editor" ["tickets.set_status"])+ withRoles = emptyIR { irPluginRoles = Just roles }+ KM.lookup "irPluginRoles" (keysOf (Aeson.toJSON withRoles)) `shouldBe` Just (Aeson.toJSON roles)+ fmap irPluginRoles (eitherDecode (encode withRoles)) `shouldBe` Right (Just roles)
test/IRSpec.hs view
@@ -106,8 +106,9 @@ forM_ (Map.elems $ irMethods ir) $ \method -> do let helpText = renderMethodHelp ir method T.length helpText `shouldSatisfy` (> 0)- -- Help should contain method name- helpText `shouldSatisfy` T.isInfixOf (mdFullPath method)+ -- Help should contain method name (SYNAPSE-13: in its usage line,+ -- as it is typed: synapse <namespace> <method>)+ helpText `shouldSatisfy` T.isInfixOf (mdName method) it "all type refs resolve" $ forM_ (Map.elems $ irMethods ir) $ \method -> do
+ test/IdentitySpec.hs view
@@ -0,0 +1,296 @@+{-# LANGUAGE OverloadedStrings #-}++-- | IDENTITY-43: an agent's key, its proof, and where synapse keeps both.+--+-- * the canonical message the signature covers, byte for byte;+-- * a FIXED proof vector (seed 0x01..0x20, aud ws://127.0.0.1:4460, agent+-- worker-3, nonce 32 × 0x07, ts 1791600000) — printed, and pinned, so the+-- Rust side (IDENTITY-41/42) can check it computes the same string;+-- * the stores' order — PLEXUS_KEY, the keychain (an in-memory one here),+-- $CREDENTIALS_DIRECTORY, ~/.plexus/keys — and the session store's, all in+-- a directory of the test's own: nothing touches ~/.plexus, the real+-- keychain or a socket.+module Main (main) where++import qualified Data.Aeson as A+import qualified Data.Aeson.KeyMap as KM+import qualified Data.ByteString as BS+import qualified Data.ByteString.Base64.URL as B64U+import Control.Concurrent (threadDelay)+import Control.Concurrent.Async (mapConcurrently)+import Control.Exception (SomeException, try)+import Data.IORef (readIORef, newIORef, atomicModifyIORef')+import qualified Data.Map.Strict as Map+import Data.Text (Text)+import qualified Data.Text as T+import qualified Data.Text.Encoding as TE+import qualified Data.Text.IO as TIO+import System.Directory (createDirectoryIfMissing, doesFileExist, listDirectory)+import System.FilePath ((</>))+import System.IO.Temp (withSystemTempDirectory)+import System.Posix.Files (fileMode, getFileStatus, intersectFileModes)+import Test.Hspec++import Synapse.Endpoint (plainAt)+import Synapse.Identity.Command (splitScopes)+import Synapse.Identity.Proof+import Synapse.Identity.Store++-- ============================================================================+-- The fixed vector+-- ============================================================================++vectorSeed :: Seed+vectorSeed = either (error . T.unpack) id (seedFromBytes (BS.pack [1 .. 32]))++vectorAud, vectorAgent :: Text+vectorAud = "ws://127.0.0.1:4460"+vectorAgent = "worker-3"++vectorNonce :: BS.ByteString+vectorNonce = BS.replicate 32 7++vectorTs :: Integer+vectorTs = 1791600000++vectorProof :: Text+vectorProof = buildProof vectorSeed vectorAud vectorAgent vectorNonce vectorTs++-- | The proof the vector gives, pinned. Ed25519 signing is deterministic,+-- so this never changes unless the wire does.+expectedProof :: Text+expectedProof =+ "plexus-key.eyJ2IjoxLCJrZXkiOiI3OWI1NTYyZThmZTY1NGY5NDA3OGIxMTJlOGE5OGJhNzkwMWY4NTNhZTY5NWJlZDdlMGUzOTEwYmFkMDQ5NjY0IiwiYXVkIjoid3M6Ly8xMjcuMC4wLjE6NDQ2MCIsImFnZW50Ijoid29ya2VyLTMiLCJub25jZSI6IkJ3Y0hCd2NIQndjSEJ3Y0hCd2NIQndjSEJ3Y0hCd2NIQndjSEJ3Y0hCd2MiLCJ0cyI6MTc5MTYwMDAwMCwic2lnIjoiSlJxSGx6VEd2WGZubm8zQWlsMWhudU9wZ2J6SzlyNFZPa0l6OHNRbmpkVzZwWlk4S1h5YlR4QlBBcmdaclFkeFR1M3NvaWtwSkxRMURSaDN3SVFLQVEifQ"++-- | Its JSON, for reading.+--+-- > {"v":1,"key":"79b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664","aud":"ws://127.0.0.1:4460","agent":"worker-3","nonce":"BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc","ts":1791600000,"sig":"JRqHlzTGvXfnno3Ail1hnuOpgbzK9r4VOkIz8sQnjdW6pZY8KXybTxBPArgZrQdxTu3soikpJLQ1DRh3wIQKAQ"}++-- ============================================================================+-- Stores in a sandbox+-- ============================================================================++sandbox :: [(String, String)] -> Maybe Keychain -> FilePath -> StoreEnv+sandbox env kc dir = StoreEnv+ { stEnv = \k -> pure (lookup k env)+ , stPlexus = dir </> ".plexus"+ , stKeychain = kc+ }++seedOf :: Int -> Seed+seedOf n = either (error . T.unpack) id (seedFromBytes (BS.replicate 32 (fromIntegral n)))++mode :: FilePath -> IO Int+mode p = fromIntegral . intersectFileModes 0o777 . fileMode <$> getFileStatus p++main :: IO ()+main = do+ TIO.putStrLn ""+ TIO.putStrLn "IDENTITY-43 fixed vector:"+ TIO.putStrLn (" seed 0x01..0x20 -> " <> principalOf vectorSeed)+ TIO.putStrLn (" aud " <> vectorAud)+ TIO.putStrLn (" agent " <> vectorAgent)+ TIO.putStrLn (" nonce 32 x 0x07 -> " <> b64urlNoPad vectorNonce)+ TIO.putStrLn (" ts " <> T.pack (show vectorTs))+ TIO.putStrLn (" message " <> T.pack (show (canonicalMessage vectorAud vectorAgent (b64urlNoPad vectorNonce) vectorTs)))+ TIO.putStrLn (" proof " <> vectorProof)+ TIO.putStrLn ""+ hspec spec++spec :: Spec+spec = do+ describe "keys" $ do+ it "derives the RFC 8032 public key (test 1) from its seed" $ do+ let Right s = seedFromBytes (hexBytes "9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60")+ publicKeyHex s `shouldBe` "d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a"+ principalOf s `shouldBe` "key:d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a"++ it "refuses a seed that is not 32 bytes" $ do+ seedFromBytes (BS.replicate 31 0) `shouldSatisfy` either (const True) (const False)++ it "keeps a seed as base64 and reads it back (and url base64, unpadded)" $ do+ decodeSeedText (encodeSeedText vectorSeed) `shouldBe` Right vectorSeed+ decodeSeedText (b64urlNoPad (seedBytes vectorSeed)) `shouldBe` Right vectorSeed+ decodeSeedText (" " <> encodeSeedText vectorSeed <> "\n") `shouldBe` Right vectorSeed++ it "admits agent ids of 1-64 of [A-Za-z0-9._-]" $ do+ map validAgent ["worker-3", "a", "A.b_c-9", T.replicate 64 "x"] `shouldBe` [True, True, True, True]+ map validAgent ["", T.replicate 65 "x", "a b", "a/b", "ä", "a:b"] `shouldBe` replicate 6 False++ describe "the canonical message" $ do+ it "is plexus-key-login/v1, aud, agent, nonce, ts — newline-joined, no trailing newline" $+ canonicalMessage "ws://127.0.0.1:4455" "worker-3" "AAAA" 1791600000+ `shouldBe` "plexus-key-login/v1\nws://127.0.0.1:4455\nworker-3\nAAAA\n1791600000"++ it "names the socket dialed as the audience" $+ wsAudience "127.0.0.1" 4455 `shouldBe` "ws://127.0.0.1:4455"++ describe "the proof" $ do+ it "is plexus-key. + base64url-nopad of the JSON, fields in the wire's order" $ do+ let Just body = T.stripPrefix "plexus-key." vectorProof+ Right json = B64U.decodeUnpadded (TE.encodeUtf8 body)+ keys = map (T.takeWhile (/= '"')) (T.splitOn ",\"" (T.drop 2 (TE.decodeUtf8 json)))+ T.any (== '=') body `shouldBe` False+ keys `shouldBe` ["v", "key", "aud", "agent", "nonce", "ts", "sig"]+ let Just (A.Object o) = A.decodeStrict json+ KM.lookup "v" o `shouldBe` Just (A.Number 1)+ KM.lookup "key" o `shouldBe` Just (A.String (publicKeyHex vectorSeed))+ KM.lookup "aud" o `shouldBe` Just (A.String vectorAud)+ KM.lookup "agent" o `shouldBe` Just (A.String vectorAgent)+ KM.lookup "nonce" o `shouldBe` Just (A.String (b64urlNoPad vectorNonce))+ KM.lookup "ts" o `shouldBe` Just (A.Number 1791600000)+ case KM.lookup "sig" o of+ Just (A.String sig) -> fmap BS.length (B64U.decodeUnpadded (TE.encodeUtf8 sig)) `shouldBe` Right 64+ other -> expectationFailure ("sig: " <> show other)++ it "is the pinned string for the fixed vector" $+ vectorProof `shouldBe` expectedProof++ it "changes with every input" $ do+ let other = [ buildProof (seedOf 9) vectorAud vectorAgent vectorNonce vectorTs+ , buildProof vectorSeed "ws://127.0.0.1:4455" vectorAgent vectorNonce vectorTs+ , buildProof vectorSeed vectorAud "worker-4" vectorNonce vectorTs+ , buildProof vectorSeed vectorAud vectorAgent (BS.replicate 32 8) vectorTs+ , buildProof vectorSeed vectorAud vectorAgent vectorNonce (vectorTs + 1)+ ]+ filter (== vectorProof) other `shouldBe` []++ describe "the key store" $ do+ it "reads PLEXUS_KEY before the keychain, the credentials directory and the file" $+ withSystemTempDirectory "identity" $ \dir -> do+ (kc, _) <- memoryKeychain+ let st0 = sandbox [("CREDENTIALS_DIRECTORY", dir </> "creds")] (Just kc) dir+ Right _ <- writeKey st0 StoreFile "a" (seedOf 4)+ createDirectoryIfMissing True (dir </> "creds")+ BS.writeFile (dir </> "creds" </> "a.key") (TE.encodeUtf8 (encodeSeedText (seedOf 3)))+ Right KeyFromKeychain <- writeKey st0 StoreKeychain "a" (seedOf 2)+ let withEnv = sandbox [("PLEXUS_KEY", T.unpack (encodeSeedText (seedOf 1))), ("CREDENTIALS_DIRECTORY", dir </> "creds")] (Just kc) dir+ fmap (fmap fst) <$> lookupKey withEnv "a" `shouldReturn` Right (Just (seedOf 1))+ fmap (fmap snd) <$> lookupKey withEnv "a" `shouldReturn` Right (Just KeyFromEnv)+ -- no env: the keychain+ fmap (fmap fst) <$> lookupKey st0 "a" `shouldReturn` Right (Just (seedOf 2))+ -- no keychain: the credentials directory+ let noKc = sandbox [("CREDENTIALS_DIRECTORY", dir </> "creds")] Nothing dir+ fmap (fmap fst) <$> lookupKey noKc "a" `shouldReturn` Right (Just (seedOf 3))+ -- none of those: the file+ let fileOnly = sandbox [] Nothing dir+ fmap (fmap fst) <$> lookupKey fileOnly "a" `shouldReturn` Right (Just (seedOf 4))+ fmap (fmap snd) <$> lookupKey fileOnly "a" `shouldReturn` Right (Just (KeyFromFile (dir </> ".plexus" </> "keys" </> "a.key")))+ lookupKey fileOnly "b" `shouldReturn` Right Nothing++ it "writes to the keychain when it answers, else a 0600 file in a 0700 directory" $+ withSystemTempDirectory "identity" $ \dir -> do+ (kc, ref) <- memoryKeychain+ writeKey (sandbox [] (Just kc) dir) StoreAuto "k" (seedOf 5) `shouldReturn` Right KeyFromKeychain+ Map.keys <$> readIORef ref `shouldReturn` [("plexus-key", [("name", "k")])]+ let p = dir </> ".plexus" </> "keys" </> "f.key"+ writeKey (sandbox [] Nothing dir) StoreAuto "f" (seedOf 6) `shouldReturn` Right (KeyFromFile p)+ mode p `shouldReturn` 0o600+ mode (dir </> ".plexus" </> "keys") `shouldReturn` 0o700+ writeKey (sandbox [] Nothing dir) StoreKeychain "g" (seedOf 6) >>= (`shouldSatisfy` either (const True) (const False))++ it "falls back to the file when the keychain refuses a write" $+ withSystemTempDirectory "identity" $ \dir -> do+ (kc, _) <- memoryKeychain+ let refusing = kc { kcStore = \_ _ _ _ -> pure (Left "locked") }+ writeKey (sandbox [] (Just refusing) dir) StoreAuto "k" (seedOf 5)+ `shouldReturn` Right (KeyFromFile (dir </> ".plexus" </> "keys" </> "k.key"))++ it "says when a name is taken, and lists keys with where they are" $+ withSystemTempDirectory "identity" $ \dir -> do+ (kc, _) <- memoryKeychain+ let st = sandbox [] (Just kc) dir+ keyExists st "a" `shouldReturn` Nothing+ _ <- writeKey st StoreKeychain "a" (seedOf 1)+ _ <- writeKey st StoreFile "b" (seedOf 2)+ keyExists st "a" `shouldReturn` Just KeyFromKeychain+ keyExists st "b" `shouldReturn` Just (KeyFromFile (dir </> ".plexus" </> "keys" </> "b.key"))+ map fst <$> listKeys st `shouldReturn` ["a", "b"]++ describe "the session store" $ do+ let rec = SessionRec+ { srToken = "mgs_abc", srPrincipal = "key:00", srAgent = "worker-3"+ , srExpiresAt = Just 1791643200, srKey = "agent-7", srScope = ["editor@MENGER-12"]+ , srTtl = Nothing, srBackend = "menger" }++ it "reads PLEXUS_SESSION first, then the keychain, then the file" $+ withSystemTempDirectory "identity" $ \dir -> do+ (kc, _) <- memoryKeychain+ let fileSt = sandbox [] Nothing dir+ p = dir </> ".plexus" </> "sessions" </> "127.0.0.1_4455" </> "worker-3.json"+ writeSession fileSt (plainAt "127.0.0.1" 4455) rec `shouldReturn` SessionFromFile p+ mode p `shouldReturn` 0o600+ lookupSession fileSt (plainAt "127.0.0.1" 4455) "worker-3" `shouldReturn` Just (Right rec, SessionFromFile p)+ let kcSt = sandbox [] (Just kc) dir+ rec2 = rec { srToken = "mgs_def" }+ writeSession kcSt (plainAt "127.0.0.1" 4455) rec2 `shouldReturn` SessionFromKeychain+ lookupSession kcSt (plainAt "127.0.0.1" 4455) "worker-3" `shouldReturn` Just (Right rec2, SessionFromKeychain)+ -- a keychain write drops the stale file+ doesFileExist p `shouldReturn` False+ let envSt = sandbox [("PLEXUS_SESSION", "mgs_env\n")] (Just kc) dir+ lookupSession envSt (plainAt "127.0.0.1" 4455) "worker-3" `shouldReturn` Just (Left "mgs_env", SessionFromEnv)+ lookupSession kcSt (plainAt "127.0.0.1" 4455) "worker-4" `shouldReturn` Nothing+ lookupSession kcSt (plainAt "127.0.0.1" 4456) "worker-3" `shouldReturn` Nothing++ it "keeps the key name, scope and backend a re-login needs" $+ withSystemTempDirectory "identity" $ \dir -> do+ let st = sandbox [] Nothing dir+ _ <- writeSession st (plainAt "127.0.0.1" 4455) rec+ raw <- BS.readFile (dir </> ".plexus" </> "sessions" </> "127.0.0.1_4455" </> "worker-3.json")+ let Just (A.Object o) = A.decodeStrict raw+ map (`KM.member` o) ["token", "principal", "agent", "expires_at", "key", "scope", "backend"]+ `shouldBe` replicate 7 True++ it "forgets a session from both stores, and the default agent with it" $+ withSystemTempDirectory "identity" $ \dir -> do+ (kc, ref) <- memoryKeychain+ let st = sandbox [] (Just kc) dir+ _ <- writeSession st (plainAt "127.0.0.1" 4455) rec+ writeDefaultAgent st (plainAt "127.0.0.1" 4455) "worker-3"+ readDefaultAgent st (plainAt "127.0.0.1" 4455) `shouldReturn` Just "worker-3"+ removeSession st (plainAt "127.0.0.1" 4455) "worker-3"+ clearDefaultAgent st (plainAt "127.0.0.1" 4455) "worker-3"+ Map.null <$> readIORef ref `shouldReturn` True+ lookupSession st (plainAt "127.0.0.1" 4455) "worker-3" `shouldReturn` Nothing+ readDefaultAgent st (plainAt "127.0.0.1" 4455) `shouldReturn` Nothing++ describe "parallel writers (SYNAPSE-15)" $ do+ let rec = SessionRec+ { srToken = "mgs_0", srPrincipal = "key:00", srAgent = "worker-3"+ , srExpiresAt = Just 1791643200, srKey = "agent-7", srScope = []+ , srTtl = Nothing, srBackend = "menger" }++ it "32 writers of one session at once all succeed, and leave one session, 0600, and no temporary file" $+ withSystemTempDirectory "identity" $ \dir -> do+ let st = sandbox [] Nothing dir+ sdir = dir </> ".plexus" </> "sessions" </> "127.0.0.1_4455"+ rs <- mapConcurrently (\i -> try (writeSession st (plainAt "127.0.0.1" 4455) rec { srToken = "mgs_" <> T.pack (show i) }))+ [1 .. 32 :: Int]+ [e | Left (e :: SomeException) <- rs] `shouldSatisfy` null+ Just (Right r, _) <- lookupSession st (plainAt "127.0.0.1" 4455) "worker-3"+ srToken r `shouldSatisfy` (`elem` ["mgs_" <> T.pack (show i) | i <- [1 .. 32 :: Int]])+ mode (sdir </> "worker-3.json") `shouldReturn` 0o600+ listDirectory sdir `shouldReturn` ["worker-3.json"]++ it "the session lock lets one holder in at a time" $+ withSystemTempDirectory "identity" $ \dir -> do+ let st = sandbox [] Nothing dir+ inside <- newIORef (0 :: Int)+ most <- newIORef (0 :: Int)+ _ <- mapConcurrently (\_ -> withSessionLock st (plainAt "127.0.0.1" 4455) "worker-3" $ do+ n <- atomicModifyIORef' inside (\k -> (k + 1, k + 1))+ atomicModifyIORef' most (\m -> (max m n, ()))+ threadDelay 20000+ atomicModifyIORef' inside (\k -> (k - 1, ())))+ [1 .. 8 :: Int]+ readIORef most `shouldReturn` 1++ describe "_login's --scope" $+ it "takes the flag repeated or comma-separated" $+ splitScopes ["editor@MENGER-12,viewer@PLEXUS", " admin@* "] `shouldBe` ["editor@MENGER-12", "viewer@PLEXUS", "admin@*"]++hexBytes :: Text -> BS.ByteString+hexBytes = BS.pack . go . T.unpack+ where+ go (a : b : rest) = fromIntegral (read ['0', 'x', a, b] :: Int) : go rest+ go _ = []
+ test/KeychainSpec.hs view
@@ -0,0 +1,147 @@+{-# LANGUAGE OverloadedStrings #-}++-- | SELF-8 opt-in keychain tests.+--+-- This test-suite shells out to the macOS @security@ CLI and touches the+-- real keychain. It's gated behind the @build-keychain-tests@ cabal flag:+--+-- > cabal test plexus-synapse:keychain-test -f build-keychain-tests+--+-- Default @cabal test@ runs do NOT include this suite.+--+-- Tests use a unique per-run service name (@plexus-selftest-<uuid>@) so+-- they don't collide with real synapse/synapse-cc keychain items. All+-- created items are cleaned up in 'finally' blocks regardless of test+-- outcome.+module Main (main) where++import Control.Exception (finally)+import qualified Data.Text as T+import Data.UUID (toText)+import Data.UUID.V4 (nextRandom)+import System.Info (os)++import Synapse.Self+ ( CredentialRef(..)+ , ResolveError(..)+ , deleteFromKeychain+ , isKeychainPlatform+ , resolveRef+ , storeInKeychain+ )+import Synapse.Self.Resolve (registerResolver, emptyRegistry)+import Synapse.Self.Resolve.Keychain (keychainResolver)++import Test.Hspec++main :: IO ()+main = hspec spec++spec :: Spec+spec = describe "Synapse.Self.Resolve.Keychain (SELF-8)" $ do+ if not isKeychainPlatform+ then it ("skipped: keychain backend not implemented on " ++ os) pending+ else keychainMacOSSpec++keychainMacOSSpec :: Spec+keychainMacOSSpec = do+ describe "platform probe" $ do+ it "reports macOS as a keychain platform" $+ isKeychainPlatform `shouldBe` True++ describe "resolveRef via defaultRegistry entry" $ do+ it "returns ResolveNotFound for a keychain item that doesn't exist" $ do+ run <- runId+ let service = "plexus-selftest-" <> run+ reg = registerResolver "keychain" keychainResolver emptyRegistry+ ref = CredentialRef $+ "keychain://" <> service <> "/no-such-account"+ r <- resolveRef reg ref+ r `shouldBe` Left (ResolveNotFound ref)++ describe "store -> resolve -> delete round trip" $ do+ it "stores a value, reads it back, then deletes" $ do+ run <- runId+ let service = "plexus-selftest-" <> run+ account = "roundtrip/access_token"+ value = "eyJhbGciOiJIUzI1NiJ9.fake"+ ref = CredentialRef $+ "keychain://" <> service <> "/" <> account+ reg = registerResolver "keychain" keychainResolver emptyRegistry+ (do+ rs <- storeInKeychain service account value+ rs `shouldBe` Right ()++ rr <- resolveRef reg ref+ rr `shouldBe` Right value++ rd <- deleteFromKeychain service account+ rd `shouldBe` Right ()++ rr2 <- resolveRef reg ref+ rr2 `shouldBe` Left (ResolveNotFound ref)+ )+ `finally`+ -- Belt-and-suspenders cleanup (no-op if delete already ran).+ deleteFromKeychain service account++ describe "update-in-place (-U)" $ do+ it "overwrites an existing item when stored again" $ do+ run <- runId+ let service = "plexus-selftest-" <> run+ account = "update/access_token"+ v1 = "first"+ v2 = "second"+ ref = CredentialRef $+ "keychain://" <> service <> "/" <> account+ reg = registerResolver "keychain" keychainResolver emptyRegistry+ (do+ _ <- storeInKeychain service account v1+ _ <- storeInKeychain service account v2+ r <- resolveRef reg ref+ r `shouldBe` Right v2+ )+ `finally`+ deleteFromKeychain service account++ describe "delete idempotency" $ do+ it "deleting a non-existent item returns Right ()" $ do+ run <- runId+ let service = "plexus-selftest-" <> run+ account = "never-created"+ r <- deleteFromKeychain service account+ r `shouldBe` Right ()++ describe "URI shape errors" $ do+ let reg = registerResolver "keychain" keychainResolver emptyRegistry++ it "rejects opaque keychain:foo" $ do+ r <- resolveRef reg (CredentialRef "keychain:foo")+ case r of+ Left (ResolveBackendError _ msg) ->+ ("must be hierarchical" `T.isInfixOf` msg) `shouldBe` True+ other -> expectationFailure ("expected ResolveBackendError, got "+ <> show other)++ it "rejects empty service" $ do+ r <- resolveRef reg (CredentialRef "keychain:///account")+ case r of+ Left (ResolveParseError msg) ->+ ("service missing" `T.isInfixOf` msg) `shouldBe` True+ other -> expectationFailure ("expected ResolveParseError, got "+ <> show other)++ it "rejects empty account" $ do+ r <- resolveRef reg (CredentialRef "keychain://svc")+ case r of+ Left (ResolveParseError msg) ->+ ("account missing" `T.isInfixOf` msg) `shouldBe` True+ other -> expectationFailure ("expected ResolveParseError, got "+ <> show other)++-- | Short stable id for each test's service name. Using a UUID fragment+-- keeps tests isolated across parallel runs and across dev/CI machines.+runId :: IO T.Text+runId = do+ u <- nextRandom+ pure $ T.take 8 (toText u)
+ test/MockHub.hs view
@@ -0,0 +1,320 @@+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE LambdaCase #-}+{-# LANGUAGE ScopedTypeVariables #-}++-- | AUTH-8: a Plexus hub in miniature, speaking the wire synapse speaks+-- (JSON-RPC over a WebSocket; @_info@, @<backend>.call@, subscription+-- notifications ending in @done@), answering AUTH-2..5's shapes as the+-- tickets write them. It listens on 127.0.0.1, a port the OS picks.+--+-- A 'Scenario' says which hub it is: today's (no view, no auth activation),+-- an anonymous caller's, or a signed-in caller's. The person flow's+-- browser is played by the hub itself: answering @auth.login {flow:+-- {browser: {redirect}}}@ it also, a moment later, sends the GET the+-- provider's redirect would — to @redirect?code=…&state=…@.+module MockHub+ ( Scenario(..)+ , ViewMode(..)+ , todaysHub+ , withMockHub+ , serveMockHub+ , mockCode+ , mockState+ , catalogHub+ , schemaCalls+ ) where++import Control.Concurrent (forkIO, threadDelay)+import Control.Concurrent.MVar (MVar, newEmptyMVar, takeMVar, tryPutMVar, tryTakeMVar)+import System.Timeout (timeout)+import Control.Exception (SomeException, bracket, try)+import Control.Monad (forever, void)+import Data.Aeson+import qualified Data.Aeson.Key as K+import qualified Data.Aeson.KeyMap as KM+import qualified Data.ByteString.Char8 as BS8+import Data.IORef+import Data.Text (Text)+import qualified Data.Text as T+import qualified Data.Text.Encoding as TE+import qualified Network.Socket as N+import qualified Network.Socket.ByteString as NB+import qualified Network.WebSockets as WS+import qualified Data.Vector as V+import System.IO (hFlush, stdout)+import System.IO.Unsafe (unsafePerformIO)++data ViewMode = NoView | AnonymousView | CallerView+ deriving (Eq, Show)++data Scenario = Scenario+ { scView :: ViewMode+ , scAuth :: Bool -- ^ has the auth activation (auth.login, auth.whoami, …)+ , scSessionLogin :: Bool -- ^ has IDENTITY-42's session.login (today's menger)+ , scBrowserState :: Text -- ^ the state the browser comes back with (mockState: the right one)+ , scCatalog :: Bool -- ^ SYNAPSE-17: serve menger's tickets and deployments as they were (test/fixtures)+ } deriving (Eq, Show)++todaysHub :: Scenario+todaysHub = Scenario NoView False True mockState False++-- | SYNAPSE-17: a hub with a catalog to search — menger's tickets (17 of+-- its methods) and deployments (3), as its schema had them on 2026-10-15;+-- in a caller's view, tickets.delete (admin only) is not in it.+catalogHub :: ViewMode -> Scenario+catalogHub v = Scenario v False False mockState True++-- | How many schema calls the mock hubs have answered (this process).+{-# NOINLINE schemaCalls #-}+schemaCalls :: IORef Int+schemaCalls = unsafePerformIO (newIORef 0)++mockCode, mockState :: Text+mockCode = "c_19ab"+mockState = "s_7f2c"++-- | Run an action against a hub on a fresh port; the port is passed in.+withMockHub :: Scenario -> (Int -> IO a) -> IO a+withMockHub sc act = bracket open (N.close . fst) (\(_, port) -> act port)+ where+ open = do+ sock <- N.socket N.AF_INET N.Stream N.defaultProtocol+ N.setSocketOption sock N.ReuseAddr 1+ N.bind sock (N.SockAddrInet 0 (N.tupleToHostAddress (127, 0, 0, 1)))+ N.listen sock 16+ port <- fromIntegral <$> N.socketPort sock+ void $ forkIO $ acceptLoop sc sock+ pure (sock, port)++-- | Serve until killed, printing the port (for transcripts by hand).+serveMockHub :: Scenario -> IO ()+serveMockHub sc = withMockHub sc $ \port -> do+ putStrLn (show port)+ hFlush stdout+ forever (threadDelay 1000000)++acceptLoop :: Scenario -> N.Socket -> IO ()+acceptLoop sc sock = do+ r <- try (N.accept sock)+ case r of+ Left (_ :: SomeException) -> pure () -- the hub was closed+ Right (conn, _) -> do+ void $ forkIO $ do+ _ <- try (session conn) :: IO (Either SomeException ())+ N.close conn+ acceptLoop sc sock+ where+ session conn = do+ pending <- WS.makePendingConnection conn WS.defaultConnectionOptions+ -- the credential the upgrade carried, if any+ let bearer = lookup "Authorization" (WS.requestHeaders (WS.pendingRequest pending))+ c <- WS.acceptRequest pending+ next <- newIORef (100 :: Int)+ forever $ do+ msg <- WS.receiveData c+ case decode msg of+ Just (Object o) -> handle sc c next (isBearer bearer) o+ _ -> pure ()+ isBearer = maybe False (BS8.isPrefixOf "Bearer ")++handle :: Scenario -> WS.Connection -> IORef Int -> Bool -> Object -> IO ()+handle sc c next signedIn o = do+ let rid = KM.lookup "id" o+ method = str "method" o+ params = case KM.lookup "params" o of { Just (Object p) -> p; _ -> KM.empty }+ sub <- atomicModifyIORef' next (\n -> (n + 1, n))+ send (object ["jsonrpc" .= ("2.0" :: Text), "id" .= rid, "result" .= sub])+ let emit item = send (object+ [ "jsonrpc" .= ("2.0" :: Text), "method" .= ("subscription" :: Text)+ , "params" .= object ["subscription" .= sub, "result" .= item] ])+ meta = object ["provenance" .= (["menger"] :: [Text]), "plexus_hash" .= ("h0" :: Text), "timestamp" .= (0 :: Int)]+ dat :: Text -> Value -> IO ()+ dat ct v = emit (object ["type" .= ("data" :: Text), "metadata" .= meta, "content_type" .= ct, "content" .= v])+ err :: Text -> [(K.Key, Value)] -> IO ()+ err m extra = emit (Object (KM.fromList (+ [("type", "error"), ("metadata", meta), ("message", String m), ("recoverable", Bool False)] <> extra)))+ done = emit (object ["type" .= ("done" :: Text), "metadata" .= meta])+ case method of+ "_info" -> do+ dat "_info" (infoFor sc)+ done+ "menger.call" -> do+ let inner = str "method" params+ args = case KM.lookup "params" params of { Just (Object a) -> a; _ -> KM.empty }+ if inner == "tickets.ask"+ then askBack emit meta dat err+ else callMethod sc signedIn inner args dat err+ done+ -- SCAFFOLD-21: a bidirectional answer, on a connection of its own+ "menger.respond" -> do+ void (tryPutMVar responses (KM.lookup "response_data" params))+ done+ _ -> err ("Invalid method format: " <> method) [] >> done+ where+ send :: Value -> IO ()+ send = WS.sendTextData c . encode++-- | SCAFFOLD-21: the answers to 'askBack's requests (@menger.respond@'s+-- @response_data@), as they arrive on any connection.+{-# NOINLINE responses #-}+responses :: MVar (Maybe Value)+responses = unsafePerformIO newEmptyMVar++-- | A bidirectional method (@tickets.ask@): it asks the caller to confirm+-- (a @request@ item), waits up to 5 s for @menger.respond@, and answers+-- with what came back.+askBack :: (Value -> IO ()) -> Value -> (Text -> Value -> IO ()) -> (Text -> [(K.Key, Value)] -> IO ()) -> IO ()+askBack emit meta dat err = do+ _ <- tryTakeMVar responses+ emit (object+ [ "type" .= ("request" :: Text), "metadata" .= meta, "request_id" .= ("req-1" :: Text)+ , "request" .= object ["type" .= ("confirm" :: Text), "message" .= ("go on?" :: Text)] ])+ got <- timeout 5000000 (takeMVar responses)+ case got of+ Just (Just r) -> dat "tickets.ask" (object ["type" .= ("answered" :: Text), "response" .= r])+ _ -> err "no response to req-1" []++infoFor :: Scenario -> Value+infoFor sc = case scView sc of+ NoView -> object ["backend" .= ("menger" :: Text), "auth_capabilities" .= object ["mechanisms" .= [object ["kind" .= ("anonymous" :: Text)]]]]+ AnonymousView -> object+ [ "backend" .= ("menger" :: Text), "view" .= ("anonymous" :: Text)+ , "auth" .= object+ [ "methods" .= (["auth.providers", "auth.login", "auth.signup"] :: [Text])+ , "resource_metadata" .= ("http://127.0.0.1:4456/.well-known/oauth-protected-resource" :: Text) ] ]+ CallerView -> object ["backend" .= ("menger" :: Text), "view" .= ("caller" :: Text), "view_hash" .= ("v-9a77" :: Text)]++callMethod :: Scenario -> Bool -> Text -> Object+ -> (Text -> Value -> IO ()) -> (Text -> [(K.Key, Value)] -> IO ()) -> IO ()+callMethod sc _signedIn inner args dat err = do+ if ".schema" `T.isSuffixOf` inner then atomicModifyIORef' schemaCalls (\n -> (n + 1, ())) else pure ()+ case inner of+ "menger.schema" -> dat "menger.schema" (rootSchema sc)+ "tickets.schema" | scCatalog sc -> do+ Just (Object o) <- decodeFileStrict "test/fixtures/find-tickets.json"+ dat "tickets.schema" (Object (catalogView sc o))+ "deployments.schema" | scCatalog sc -> do+ Just v <- decodeFileStrict "test/fixtures/find-deployments.json"+ dat "deployments.schema" (v :: Value)+ "auth.schema" | scAuth sc -> dat "auth.schema" authSchema+ "tickets.schema" | scView sc /= AnonymousView -> dat "tickets.schema" (ticketsSchema sc)+ "auth.whoami" | scAuth sc -> dat "auth.whoami" (object+ [ "type" .= ("whoami" :: Text), "principal" .= ("key:ac79e0f3b1c24d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f708192a3b4c5d6" :: Text)+ , "name" .= ("worker-1" :: Text), "agent" .= ("worker-1" :: Text)+ , "memberships" .= [object ["role" .= ("editor" :: Text), "scope" .= ("MENGER" :: Text)]]+ , "ceiling" .= (["editor@MENGER-12"] :: [Text]) ])+ "tickets.get" -> dat "tickets.get" (object ["type" .= ("ticket" :: Text), "id" .= ("MENGER-12" :: Text)])+ "tickets.set_status"+ | scView sc == CallerView -> err "needs tickets.set_status on IDENTITY"+ [ ("error_kind", "forbidden"), ("grant", "tickets.set_status"), ("scope", "IDENTITY")+ , ("roles", toJSON (["editor", "admin"] :: [Text])), ("held_on", toJSON (["MENGER"] :: [Text])) ]+ | otherwise -> dat "tickets.set_status" (object ["type" .= ("status_changed" :: Text)])+ "auth.login" | scAuth sc -> case KM.lookup "flow" args of+ Just (Object f)+ | Just (Object b) <- KM.lookup "browser" f -> do+ let redirect = str "redirect" b+ dat "auth.login" (object+ [ "type" .= ("login_url" :: Text)+ , "url" .= ("http://127.0.0.1:4456/login?cli=1&state=" <> mockState)+ , "state" .= mockState ])+ -- the provider's redirect, a moment later+ void $ forkIO $ threadDelay 300000 >> browserReturns redirect (scBrowserState sc)+ | Just (Object cd) <- KM.lookup "code" f ->+ if str "code" cd == mockCode && str "state" cd == mockState+ then dat "auth.login" (object+ [ "type" .= ("session" :: Text), "token" .= ("mgs_person" :: Text)+ , "principal" .= ("oidc:workos/user_01M4QXZ7ABCDEF" :: Text), "name" .= ("Ada Lovelace" :: Text)+ , "expires_at" .= (1791663600 :: Int)+ , "memberships" .= [object ["role" .= ("admin" :: Text), "scope" .= ("*" :: Text)]] ])+ else err "the code is not good for this state" []+ | Just (Object s) <- KM.lookup "session" f -> dat "auth.login" (object+ [ "type" .= ("session" :: Text), "token" .= ("mgs_agent_auth" :: Text)+ , "principal" .= ("key:79b5" :: Text), "agent" .= ("worker-1" :: Text)+ , "expires_at" .= (1791663600 :: Int)+ , "ceiling" .= maybe (toJSON ([] :: [Text])) id (KM.lookup "ceiling" s) ])+ _ -> err "auth.login: no flow" []+ "session.login" | scSessionLogin sc -> dat "session.login" (object+ [ "type" .= ("login" :: Text), "token" .= ("mgs_agent_session" :: Text)+ , "principal" .= ("key:79b5" :: Text), "agent" .= ("worker-1" :: Text)+ , "expires_at" .= (1791663600 :: Int)+ , "scope" .= maybe (toJSON ([] :: [Text])) id (KM.lookup "scope" args) ])+ other -> err ("Activation not found: " <> T.takeWhile (/= '.') other) []++-- | The browser coming back to synapse's loopback, as the provider's+-- redirect sends it.+browserReturns :: Text -> Text -> IO ()+browserReturns redirect state = do+ let rest = maybe redirect id (T.stripPrefix "http://127.0.0.1:" redirect)+ (portT, path) = T.breakOn "/" rest+ port = read (T.unpack portT) :: Int+ sock <- N.socket N.AF_INET N.Stream N.defaultProtocol+ N.connect sock (N.SockAddrInet (fromIntegral port) (N.tupleToHostAddress (127, 0, 0, 1)))+ -- a favicon first, as browsers do: the loopback must not take it for an answer+ NB.sendAll sock "GET /favicon.ico HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n"+ _ <- NB.recv sock 4096+ N.close sock+ sock' <- N.socket N.AF_INET N.Stream N.defaultProtocol+ N.connect sock' (N.SockAddrInet (fromIntegral port) (N.tupleToHostAddress (127, 0, 0, 1)))+ NB.sendAll sock' $ TE.encodeUtf8 $+ "GET " <> path <> "?code=" <> mockCode <> "&state=" <> state <> " HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n"+ _ <- NB.recv sock' 4096+ N.close sock'++rootSchema :: Scenario -> Value+rootSchema sc = object $+ [ "namespace" .= ("menger" :: Text), "version" .= ("1.0.0" :: Text)+ , "description" .= ("Central routing and introspection" :: Text)+ , "hash" .= (case scView sc of { AnonymousView -> "v-anon-3c1f"; CallerView | scCatalog sc -> "rc-v9a77"; _ | scCatalog sc -> "rc0"; _ -> "r0" } :: Text)+ , "children" .= children ]+ where+ children = case scView sc of+ _ | scCatalog sc ->+ [ object ["namespace" .= ("tickets" :: Text), "description" .= ("Plans as data" :: Text), "hash" .= ("t0" :: Text)]+ , object ["namespace" .= ("deployments" :: Text), "description" .= ("Deployments" :: Text), "hash" .= ("d0" :: Text)] ]+ AnonymousView -> [object ["namespace" .= ("auth" :: Text), "methods" .= [+ object ["name" .= ("providers" :: Text), "public" .= True],+ object ["name" .= ("login" :: Text), "public" .= True],+ object ["name" .= ("signup" :: Text), "public" .= True]]]]+ _ -> [ object ["namespace" .= ("tickets" :: Text), "description" .= ("Plans as data" :: Text), "hash" .= ("t0" :: Text)] ]+ <> [ object ["namespace" .= ("auth" :: Text), "description" .= ("Sign in, who am I, what does this need" :: Text), "hash" .= ("a0" :: Text)] | scAuth sc ]++authSchema :: Value+authSchema = object+ [ "namespace" .= ("auth" :: Text), "version" .= ("0.1.0" :: Text), "hash" .= ("a0" :: Text)+ , "description" .= ("Sign in, who am I, what does this need" :: Text)+ , "methods" .= [ m "providers" "The ways in this server offers"+ , m "login" "Sign in \x2014 a person through the browser, an agent with its key"+ , m "signup" "Create an account, or ask for access" ] ]+ where m n d = object ["name" .= (n :: Text), "description" .= (d :: Text), "hash" .= (n <> "0"), "public" .= True]++ticketsSchema :: Scenario -> Value+ticketsSchema sc = object $+ [ "namespace" .= ("tickets" :: Text), "version" .= ("0.5.0" :: Text), "hash" .= ("t0" :: Text)+ , "description" .= ("Plans as data" :: Text)+ , "methods" .= map method+ [ ("get", "One ticket by id", ["MENGER"], Just "id")+ , ("list", "Tickets, filtered and paged", ["MENGER"], Nothing)+ , ("set_status", "Change a ticket's status", ["MENGER-12"], Just "id") ] ]+ <> [ "view" .= ("caller" :: Text) | caller ]+ where+ caller = scView sc == CallerView+ method (n, d, held, target) = object $+ [ "name" .= (n :: Text), "description" .= (d :: Text), "hash" .= (n <> "0")+ , "params" .= object ["type" .= ("object" :: Text), "required" .= (["id"] :: [Text]),+ "properties" .= object ["id" .= object ["type" .= ("string" :: Text), "description" .= ("Ticket id, e.g. MNG-2" :: Text)]]] ]+ <> (if caller then [ "grant" .= ("tickets." <> n), "held_on" .= (held :: [Text]) ] <> maybe [] (\t -> ["target" .= (t :: Text)]) target else [])++-- | The tickets schema as the caller's view shows it: without what only an+-- admin holds (tickets.delete).+catalogView :: Scenario -> Object -> Object+catalogView sc o+ | scView sc /= CallerView = o+ | otherwise = KM.insert "view" (String "caller") (maybe o (\ms -> KM.insert "methods" (dropDelete ms) o) (KM.lookup "methods" o))+ where+ dropDelete (Array ms) = Array (V.filter (\case { Object m -> KM.lookup "name" m /= Just (String "delete"); _ -> True }) ms)+ dropDelete v = v++str :: Text -> Object -> Text+str k o = case KM.lookup (K.fromText k) o of+ Just (String s) -> s+ _ -> ""
test/ParseSpec.hs view
@@ -11,11 +11,32 @@ import qualified Data.Vector as V import Test.Hspec +import Synapse.CLI.Help (renderParamHelp) import Synapse.CLI.Parse+import Synapse.CLI.Transform (TransformEnv(..), envExpansion, injectBooleanDefaults) import Synapse.IR.Types main :: IO () main = hspec $ do+ describe "envExpansion (SYNAPSE-6): $VAR is substituted in path parameters only" $ do+ let env = TransformEnv+ { teCwd = "/work", teHome = Just "/home/alice"+ , teEnv = [("HOME", "/home/alice"), ("PROJECT", "/home/alice/proj")] }+ expand k v = envExpansion env (k, v)++ it "a ticket body keeps $HOME literally" $+ expand "body" "costs $HOME and $PROJECT" `shouldReturn` ("body", "costs $HOME and $PROJECT")++ it "a title, a message, any non-path value keeps it too" $ do+ expand "title" "$HOME" `shouldReturn` ("title", "$HOME")+ expand "message" "Hello $PROJECT" `shouldReturn` ("message", "Hello $PROJECT")++ it "a path parameter still expands" $+ expand "path" "$PROJECT/src" `shouldReturn` ("path", "/home/alice/proj/src")++ it "an unset variable in a path parameter is left as written" $+ expand "working_dir" "$NOPE/x" `shouldReturn` ("working_dir", "$NOPE/x")+ describe "groupByPrefix" $ do it "groups dotted keys by prefix" $ do let input = [("identifier.type", "by_name"), ("identifier.name", "foo"), ("prompt", "hi")]@@ -79,6 +100,339 @@ let kvs = [] buildParamValue emptyIR param kvs `shouldSatisfy` isLeft + -- SYNAPSE-2: each flag value of a list of scalars is a comma-separated+ -- record with double-quote quoting. One test per row of the ticket's table.+ describe "buildParamValue with comma-separated list values" $ do+ let strings = listParam "tags" (RefArray (RefPrimitive "string" Nothing))+ ints = listParam "ids" (RefArray (RefPrimitive "integer" Nothing))+ one p val = buildParamValue listIR p [("", val)]++ describe "string list" $ do+ it "a,b,c,e -> a b c e" $+ one strings "a,b,c,e" `shouldBe` strs ["a", "b", "c", "e"]+ it "a -> a (unchanged)" $+ one strings "a" `shouldBe` strs ["a"]+ it "\"a,b,c,d\",\"e,f,g\" -> two elements (a quoted field keeps its commas)" $+ one strings "\"a,b,c,d\",\"e,f,g\"" `shouldBe` strs ["a,b,c,d", "e,f,g"]+ it "\"say \"\"hi\"\"\",b -> say \"hi\" and b (a doubled quote is one quote)" $+ one strings "\"say \"\"hi\"\"\",b" `shouldBe` strs ["say \"hi\"", "b"]+ it "a, b ,c -> a b c (space around an unquoted field is dropped)" $+ one strings "a, b ,c" `shouldBe` strs ["a", "b", "c"]+ it "a,,b, -> a b (an empty unquoted field is dropped)" $+ one strings "a,,b," `shouldBe` strs ["a", "b"]+ it "\"\",a -> the empty string and a" $+ one strings "\"\",a" `shouldBe` strs ["", "a"]+ it "\"a,b is an error naming the parameter and the value" $+ one strings "\"a,b" `shouldBe`+ Left (MalformedListValue "tags" "\"a,b" "a quoted element has no closing quote")+ it "\"a\"x,b is an error naming the parameter and the value" $+ one strings "\"a\"x,b" `shouldBe`+ Left (MalformedListValue "tags" "\"a\"x,b" "a closing quote must be followed by a comma or the end")++ describe "integer list (elements typed as before)" $ do+ it "1,2,3 -> 1 2 3" $+ one ints "1,2,3" `shouldBe` nums [1, 2, 3]+ it "1 -> 1 (unchanged)" $+ one ints "1" `shouldBe` nums [1]+ it "\"1,2\",\"3\" -> two elements (a quoted field keeps its commas)" $+ one ints "\"1,2\",\"3\"" `shouldBe` arr [String "1,2", Number 3]+ it "\"4\"\"2\",5 -> 4\"2 and 5 (a doubled quote is one quote)" $+ one ints "\"4\"\"2\",5" `shouldBe` arr [String "4\"2", Number 5]+ it "1, 2 ,3 -> 1 2 3 (space around an unquoted field is dropped)" $+ one ints "1, 2 ,3" `shouldBe` nums [1, 2, 3]+ it "1,,2, -> 1 2 (an empty unquoted field is dropped)" $+ one ints "1,,2," `shouldBe` nums [1, 2]+ it "\"\",1 -> the empty string and 1" $+ one ints "\"\",1" `shouldBe` arr [String "", Number 1]+ it "\"1,2 is an error naming the parameter and the value" $+ one ints "\"1,2" `shouldBe`+ Left (MalformedListValue "ids" "\"1,2" "a quoted element has no closing quote")+ it "\"1\"x,2 is an error naming the parameter and the value" $+ one ints "\"1\"x,2" `shouldBe`+ Left (MalformedListValue "ids" "\"1\"x,2" "a closing quote must be followed by a comma or the end")++ describe "which lists split" $ do+ it "an optional list splits like a list" $ do+ let blockedBy = listParam "blocked_by" (RefOptional (RefArray (RefPrimitive "string" Nothing)))+ one blockedBy "MNG-1,MNG-2" `shouldBe` strs ["MNG-1", "MNG-2"]+ one blockedBy "\"a,b\",\"c\"" `shouldBe` strs ["a,b", "c"]++ it "a list of a named scalar type splits" $+ one (listParam "refs" (RefArray (named "TicketId"))) "MNG-1,MNG-2"+ `shouldBe` strs ["MNG-1", "MNG-2"]++ it "a list of a string enum splits" $+ one (listParam "statuses" (RefArray (named "Status"))) "open,closed"+ `shouldBe` strs ["open", "closed"]++ it "a list of objects is not split: each flag value is one element" $+ one (listParam "links" (RefArray (named "Link"))) "{\"id\":\"a\",\"kind\":\"b\"}"+ `shouldBe` arr [String "{\"id\":\"a\",\"kind\":\"b\"}"]++ it "a list of untyped values is not split" $+ one (listParam "items" (RefArray RefAny)) "a,b" `shouldBe` strs ["a,b"]++ it "a list of lists is not split" $+ one (listParam "rows" (RefArray (RefArray (RefPrimitive "string" Nothing)))) "a,b"+ `shouldBe` strs ["a,b"]++ it "a non-list parameter keeps its commas" $+ buildParamValue listIR (listParam "title" (RefPrimitive "string" Nothing)) [("", "a, b")]+ `shouldBe` Right (String "a, b")++ describe "repeated flags" $ do+ it "--x a,b --x c -> a b c, in order" $+ buildParamValue listIR strings [("", "a,b"), ("", "c")] `shouldBe` strs ["a", "b", "c"]++ it "mix plain, comma-separated and quoted values, in order" $+ buildParamValue listIR strings [("", "a"), ("", "b, c"), ("", "\"d,e\",f")]+ `shouldBe` strs ["a", "b", "c", "d,e", "f"]++ it "one malformed value fails the whole parameter" $+ buildParamValue listIR strings [("", "a"), ("", "\"b")]+ `shouldBe` Left (MalformedListValue "tags" "\"b" "a quoted element has no closing quote")++ describe "parseParams" $ do+ let method = methodWith+ [ listParam "blocked_by" (RefOptional (RefArray (RefPrimitive "string" Nothing)))+ , listParam "title" (RefPrimitive "string" Nothing)+ ]+ it "splits the list and leaves other parameters alone" $+ parseParams listIR method [("blocked_by", "MNG-1,MNG-2"), ("title", "t, u")]+ `shouldBe` Right (object+ [ (K.fromText "blocked_by", Array (V.fromList [String "MNG-1", String "MNG-2"]))+ , (K.fromText "title", String "t, u")+ ])+ it "a malformed list value fails the parse, so nothing is sent" $+ parseParams listIR method [("blocked_by", "\"a,b"), ("title", "t")]+ `shouldBe` Left [MalformedListValue "blocked_by" "\"a,b" "a quoted element has no closing quote"]++ describe "help for list parameters" $ do+ it "a list of scalars names the comma form" $+ renderParamHelp listIR (listParam "blocked_by" (RefOptional (RefArray (RefPrimitive "string" Nothing))))+ `shouldSatisfy` elem " Repeat the flag or use commas: --blocked-by a,b"+ it "a list of objects only repeats" $+ renderParamHelp listIR (listParam "links" (RefArray (named "Link")))+ `shouldSatisfy` elem " Repeat the flag for each element"++ -- SYNAPSE-4: a flag that takes a value takes the next argument whatever it+ -- begins with, and --key=value is a flag and its value. One group per row+ -- of the ticket's table, run through both passes and parseParams.+ describe "flag values that begin with dashes" $ do+ describe "a parameter that takes a value takes the next argument, whatever it begins with" $ do+ it "--body '---\\nid: X\\n---' (frontmatter)" $+ invoke ["--body", frontmatter] `shouldBe` sends [("body", String frontmatter)]+ it "--body --force" $+ invoke ["--body", "--force"] `shouldBe` sends [("body", "--force")]+ it "--offset -5" $+ invoke ["--offset", "-5"] `shouldBe` sends [("offset", Number (-5))]+ it "--body -- (exactly two dashes)" $+ invoke ["--body", "--"] `shouldBe` sends [("body", "--")]+ it "--body '' (empty)" $+ invoke ["--body", ""] `shouldBe` sends [("body", "")]+ it "the flag after such a value is still a flag" $+ invoke ["--body", "---", "--id", "MNG-1"] `shouldBe` sends [("body", "---"), ("id", "MNG-1")]++ describe "--key=value" $ do+ it "--body='---\\nid: X\\n---'" $+ invoke ["--body=" <> frontmatter] `shouldBe` sends [("body", String frontmatter)]+ it "--body= (empty)" $+ invoke ["--body="] `shouldBe` sends [("body", "")]+ it "--body=a=b (the value keeps every = after the first)" $+ invoke ["--body=a=b"] `shouldBe` sends [("body", "a=b")]+ it "--body=--force" $+ invoke ["--body=--force"] `shouldBe` sends [("body", "--force")]+ it "--id=MNG-1 --offset=-5" $+ invoke ["--id=MNG-1", "--offset=-5"] `shouldBe` sends [("id", "MNG-1"), ("offset", Number (-5))]++ describe "a boolean, bare" $ do+ it "--summary --limit 3 -> summary true, limit 3" $+ invoke ["--summary", "--limit", "3"] `shouldBe` sends [("summary", Bool True), ("limit", Number 3)]+ it "--limit 3 --summary (last) -> summary true" $+ invoke ["--limit", "3", "--summary"] `shouldBe` sends [("summary", Bool True), ("limit", Number 3)]++ describe "a boolean with an explicit value" $ do+ it "--summary true --limit 3" $+ invoke ["--summary", "true", "--limit", "3"] `shouldBe` sends [("summary", Bool True), ("limit", Number 3)]+ it "--summary false" $+ invoke ["--summary", "false"] `shouldBe` sends [("summary", Bool False)]+ it "--summary=false" $+ invoke ["--summary=false"] `shouldBe` sends [("summary", Bool False)]+ it "--summary 0 (a spelling already read as a boolean)" $+ invoke ["--summary", "0"] `shouldBe` sends [("summary", Bool False)]+ it "--summary yes: yes is not its value, and no flag takes it" $+ invoke ["--summary", "yes"] `shouldBe` Left [UnexpectedArgument "yes"]++ describe "a list parameter (SYNAPSE-2, unchanged)" $ do+ it "--commits a,b --commits c" $+ invoke ["--commits", "a,b", "--commits", "c"] `shouldBe` sends [("commits", strings ["a", "b", "c"])]+ it "--commits=a,b" $+ invoke ["--commits=a,b"] `shouldBe` sends [("commits", strings ["a", "b"])]+ it "--blocked-by=MNG-1,MNG-2 --blocked-by MNG-3" $+ invoke ["--blocked-by=MNG-1,MNG-2", "--blocked-by", "MNG-3"]+ `shouldBe` sends [("blocked_by", strings ["MNG-1", "MNG-2", "MNG-3"])]++ describe "a dotted key" $ do+ it "--identifier.type by_name --identifier.name foo (unchanged)" $+ invoke ["--identifier.type", "by_name", "--identifier.name", "foo"]+ `shouldBe` sends [("identifier", object [("type", "by_name"), ("name", "foo")])]+ it "--identifier.name takes a value that begins with dashes" $+ invoke ["--identifier.type", "by_name", "--identifier.name", "--x"]+ `shouldBe` sends [("identifier", object [("type", "by_name"), ("name", "--x")])]+ it "a bare boolean field does not swallow the next flag" $+ invoke ["--options.verbose", "--limit", "3"]+ `shouldBe` sends [("options", object [("verbose", Bool True), ("label", Null)]), ("limit", Number 3)]++ describe "a flag that names no parameter" $ do+ it "--no-such x: Unknown parameter, named as typed" $+ invoke ["--no-such", "x"] `shouldBe` Left [UnknownParam "no-such" []]+ it "--no-such=---x: named without its value" $+ invoke ["--no-such=---x"] `shouldBe` Left [UnknownParam "no-such" []]+ it "--bodyy x: still suggests the parameter" $+ invoke ["--bodyy", "x"] `shouldBe` Left [UnknownParam "bodyy" ["body"]]+ it "takes the next argument unless it begins with --, as before" $+ invoke ["--no-such", "--body", "b"] `shouldBe` Left [UnknownParam "no-such" []]++ describe "keys are normalized, values never" $ do+ it "--blocked-by MNG-1 -> blocked_by" $+ invoke ["--blocked-by", "MNG-1"] `shouldBe` sends [("blocked_by", strings ["MNG-1"])]+ it "--title --a-b-c keeps its dashes" $+ invoke ["--title", "--a-b-c"] `shouldBe` sends [("title", "--a-b-c")]+ it "--title=--a-b-c keeps its dashes" $+ invoke ["--title=--a-b-c"] `shouldBe` sends [("title", "--a-b-c")]++ describe "parsePathAndParams (before the method is known)" $ do+ it "finds the path when a value begins with dashes" $ do+ let (path, _, flags, helpReq) = parsePathAndParams ["tickets", "amend", "--body", frontmatter, "--id", "MNG-1"]+ (path, flags, helpReq) `shouldBe` (["tickets", "amend"], ["--body", frontmatter, "--id", "MNG-1"], False)+ it "a value of exactly -- is not a path segment" $+ parsePathAndParams ["tickets", "amend", "--body", "--"]+ `shouldBe` (["tickets", "amend"], [("body", "--")], ["--body", "--"], False)+ it "--key=value takes nothing after it" $+ parsePathAndParams ["--id=MNG-1", "tickets", "get"]+ `shouldBe` (["tickets", "get"], [("id", "MNG-1")], ["--id=MNG-1"], False)+ it "--help is still help, even after a flag" $+ parsePathAndParams ["tickets", "amend", "--body", "--help"]+ `shouldBe` (["tickets", "amend"], [("body", "")], ["--body"], True)+ isLeft :: Either a b -> Bool isLeft (Left _) = True isLeft (Right _) = False++-- ============================================================================+-- Fixtures for list values+-- ============================================================================++-- | A required parameter of the given type+listParam :: Text -> TypeRef -> ParamDef+listParam name ty = ParamDef+ { pdName = name+ , pdType = ty+ , pdDescription = Nothing+ , pdRequired = True+ , pdDefault = Nothing+ , pdDeprecation = Nothing+ , pdSource = Nothing+ , pdValues = Nothing+ }++-- | A method with the given parameters+methodWith :: [ParamDef] -> MethodDef+methodWith params = MethodDef+ { mdName = "create"+ , mdFullPath = "t.create"+ , mdNamespace = "t"+ , mdDescription = Nothing+ , mdStreaming = False+ , mdParams = params+ , mdReturns = RefAny+ , mdBidirType = Nothing+ , mdBidirResponseType = Nothing+ , mdBidirResponseSchema = Nothing+ , mdRole = MethodRoleRpc+ , mdGrant = Nothing+ , mdTarget = Nothing+ , mdLongDescription = Nothing+ }++-- | A reference to a type in 'listIR'+named :: Text -> TypeRef+named = RefNamed . QualifiedName "t"++-- | An IR with a named scalar, a string enum and an object type+listIR :: IR+listIR = emptyIR+ { irTypes = Map.fromList+ [ ("t.TicketId", TypeDef "TicketId" "t" Nothing (KindPrimitive "string" Nothing))+ , ("t.Status", TypeDef "Status" "t" Nothing (KindStringEnum ["open", "closed"]))+ , ("t.Link", TypeDef "Link" "t" Nothing (KindStruct+ [ FieldDef "id" (RefPrimitive "string" Nothing) Nothing True Nothing+ , FieldDef "kind" (RefPrimitive "string" Nothing) Nothing True Nothing+ ]))+ ]+ }++arr :: [Value] -> Either ParseError Value+arr = Right . Array . V.fromList++strs :: [Text] -> Either ParseError Value+strs = arr . map String++nums :: [Integer] -> Either ParseError Value+nums = arr . map (Number . fromInteger)++-- ============================================================================+-- Fixtures for flag values+-- ============================================================================++-- | A method's flag arguments as synapse reads them once the method is known:+-- both passes, then 'parseParams' (no backend needed)+invoke :: [Text] -> Either [ParseError] Value+invoke args =+ let (_, _, flags, _) = parsePathAndParams ("tickets" : "amend" : args)+ in pairFlags dashIR dashMethod flags+ >>= parseParams dashIR dashMethod . injectBooleanDefaults dashIR dashMethod++-- | The object 'parseParams' builds from these fields+sends :: [(Text, Value)] -> Either [ParseError] Value+sends fields = Right $ object [(K.fromText k, v) | (k, v) <- fields]++strings :: [Text] -> Value+strings = Array . V.fromList . map String++frontmatter :: Text+frontmatter = "---\nid: X\n---\nbody"++-- | An optional parameter of the given type+optParam :: Text -> TypeRef -> ParamDef+optParam name ty = (listParam name ty) { pdRequired = False }++-- | menger's amend/list/link_commits parameters, plus a dotted enum and struct+dashMethod :: MethodDef+dashMethod = methodWith+ [ optParam "body" (RefOptional (RefPrimitive "string" Nothing))+ , optParam "id" (RefPrimitive "string" Nothing)+ , optParam "title" (RefOptional (RefPrimitive "string" Nothing))+ , optParam "summary" (RefOptional (RefPrimitive "boolean" Nothing))+ , optParam "limit" (RefOptional (RefPrimitive "integer" (Just "uint32")))+ , optParam "offset" (RefPrimitive "integer" Nothing)+ , optParam "commits" (RefArray (RefPrimitive "string" Nothing))+ , optParam "blocked_by" (RefOptional (RefArray (RefPrimitive "string" Nothing)))+ , optParam "identifier" (named "Identifier")+ , optParam "options" (named "Options")+ ]++-- | An IR with a discriminated enum and a struct with a boolean field+dashIR :: IR+dashIR = emptyIR+ { irTypes = Map.fromList+ [ ("t.Identifier", TypeDef "Identifier" "t" Nothing (KindEnum "type"+ [ VariantDef "by_name" Nothing [FieldDef "name" (RefPrimitive "string" Nothing) Nothing True Nothing]+ , VariantDef "by_id" Nothing [FieldDef "id" (RefPrimitive "string" Nothing) Nothing True Nothing]+ ]))+ , ("t.Options", TypeDef "Options" "t" Nothing (KindStruct+ [ FieldDef "verbose" (RefPrimitive "boolean" Nothing) Nothing False Nothing+ , FieldDef "label" (RefPrimitive "string" Nothing) Nothing False Nothing+ ]))+ ]+ }
test/PathNormalizationSpec.hs view
@@ -3,36 +3,9 @@ -- | Unit tests for path and parameter normalization module Main where -import Data.Text (Text)-import qualified Data.Text as T import Test.Hspec --- Copy of parsePathAndParams for testing--- We can't import from Main, so we replicate the logic-parsePathAndParams :: [Text] -> ([Text], [(Text, Text)], Bool)-parsePathAndParams = go [] [] False- where- go path params helpReq [] = (reverse path, reverse params, helpReq)- go path params helpReq (x:xs)- -- --help flag: mark help requested, don't add as param- | x == "--help" || x == "-h" =- go path params True xs- -- --key value pair (value must not start with --)- | Just key <- T.stripPrefix "--" x- , not (T.null key)- , (val:rest) <- xs- , not (T.isPrefixOf "--" val) =- let normalizedKey = T.replace "-" "_" key -- Normalize kebab-case to snake_case- in go path ((normalizedKey, val) : params) helpReq rest- -- --key with no value or next arg is another flag (boolean flag)- | Just key <- T.stripPrefix "--" x- , not (T.null key) =- let normalizedKey = T.replace "-" "_" key -- Normalize kebab-case to snake_case- in go path ((normalizedKey, "") : params) helpReq xs- -- Regular path segment - split on dots to support plexus.cone.chat syntax- | otherwise =- let segments = map (T.replace "-" "_") $ filter (not . T.null) $ T.splitOn "." x- in go (reverse segments ++ path) params helpReq xs+import Synapse.CLI.Parse (parsePathAndParams) main :: IO () main = hspec $ do@@ -40,49 +13,49 @@ it "normalizes hyphens in simple path segments" $ do let input = ["changelog", "queue-add", "--help"]- let (path, params, helpReq) = parsePathAndParams input+ let (path, params, _, helpReq) = parsePathAndParams input path `shouldBe` ["changelog", "queue_add"] params `shouldBe` [] helpReq `shouldBe` True it "normalizes hyphens in dot-notation paths" $ do let input = ["plexus.cone.queue-add", "--name", "test"]- let (path, params, helpReq) = parsePathAndParams input+ let (path, params, _, helpReq) = parsePathAndParams input path `shouldBe` ["plexus", "cone", "queue_add"] params `shouldBe` [("name", "test")] helpReq `shouldBe` False it "normalizes hyphens in parameter names" $ do let input = ["cone", "create", "--working-dir", "/tmp", "--dry-run"]- let (path, params, helpReq) = parsePathAndParams input+ let (path, params, _, helpReq) = parsePathAndParams input path `shouldBe` ["cone", "create"] params `shouldBe` [("working_dir", "/tmp"), ("dry_run", "")] helpReq `shouldBe` False it "handles mixed hyphens in path and params" $ do let input = ["cone", "queue-list", "--max-count", "10", "--sort-by", "date"]- let (path, params, helpReq) = parsePathAndParams input+ let (path, params, _, helpReq) = parsePathAndParams input path `shouldBe` ["cone", "queue_list"] params `shouldBe` [("max_count", "10"), ("sort_by", "date")] helpReq `shouldBe` False it "normalizes complex dot-notation with hyphens" $ do let input = ["plexus.arbor.tree-create", "--tree-id", "abc-123"]- let (path, params, helpReq) = parsePathAndParams input+ let (path, params, _, helpReq) = parsePathAndParams input path `shouldBe` ["plexus", "arbor", "tree_create"] params `shouldBe` [("tree_id", "abc-123")] -- Value NOT normalized, only key helpReq `shouldBe` False it "handles underscore paths unchanged" $ do let input = ["changelog", "queue_add", "--description", "test"]- let (path, params, helpReq) = parsePathAndParams input+ let (path, params, _, helpReq) = parsePathAndParams input path `shouldBe` ["changelog", "queue_add"] params `shouldBe` [("description", "test")] helpReq `shouldBe` False it "normalizes nested parameter paths" $ do let input = ["cone", "chat", "--identifier.type", "by-name", "--identifier.name", "test"]- let (path, params, helpReq) = parsePathAndParams input+ let (path, params, _, helpReq) = parsePathAndParams input path `shouldBe` ["cone", "chat"] params `shouldBe` [("identifier.type", "by-name"), ("identifier.name", "test")] -- Note: parameter suffixes (after .) are NOT normalized in parsePathAndParams@@ -91,7 +64,7 @@ it "normalizes _self template commands" $ do let input = ["_self", "template", "show", "cone.queue-add"]- let (path, params, helpReq) = parsePathAndParams input+ let (path, params, _, helpReq) = parsePathAndParams input -- Note: "cone.queue-add" splits on dot and normalizes hyphens path `shouldBe` ["_self", "template", "show", "cone", "queue_add"] params `shouldBe` []@@ -99,7 +72,7 @@ it "handles repeated array parameters with hyphens" $ do let input = ["queue-add", "--tags", "backend", "--tags", "critical", "--max-items", "5"]- let (path, params, helpReq) = parsePathAndParams input+ let (path, params, _, helpReq) = parsePathAndParams input path `shouldBe` ["queue_add"] params `shouldBe` [("tags", "backend"), ("tags", "critical"), ("max_items", "5")] helpReq `shouldBe` False@@ -108,21 +81,21 @@ it "handles empty input" $ do let input = []- let (path, params, helpReq) = parsePathAndParams input+ let (path, params, _, helpReq) = parsePathAndParams input path `shouldBe` [] params `shouldBe` [] helpReq `shouldBe` False it "preserves hyphens in parameter values" $ do let input = ["cone", "create", "--name", "my-test-cone"]- let (path, params, helpReq) = parsePathAndParams input+ let (path, params, _, helpReq) = parsePathAndParams input path `shouldBe` ["cone", "create"] params `shouldBe` [("name", "my-test-cone")] -- Value preserved helpReq `shouldBe` False it "handles boolean flags with hyphens" $ do let input = ["test", "--verbose", "--dry-run", "--force"]- let (path, params, helpReq) = parsePathAndParams input+ let (path, params, _, helpReq) = parsePathAndParams input path `shouldBe` ["test"] params `shouldBe` [("verbose", ""), ("dry_run", ""), ("force", "")] helpReq `shouldBe` False
+ test/ReconnectSpec.hs view
@@ -0,0 +1,284 @@+{-# LANGUAGE LambdaCase #-}+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE ScopedTypeVariables #-}++-- | SYNAPSE-20: a lost connection is noticed, and synapse rides out the+-- outage. Against a flaky hub in miniature (each call answered as the test+-- says: answered, dropped mid-stream, dropped before any answer, or taken+-- and then silent), on ports of its own:+--+-- * the client fails a call whose connection closes or goes silent —+-- 'PT.ConnectionLost' — instead of waiting forever (APP-41's hang);+-- * a read is sent again, a stream resumes (with @since@ when it takes+-- one), a write that went out is never repeated, one that never left is;+-- * the window, its spelling, and a method's shape from its schema.+module Main (main) where++import Control.Concurrent (forkIO, threadDelay)+import Control.Exception (SomeException, bracket, try)+import Control.Monad (forever, void)+import Data.Aeson+import qualified Data.Aeson.KeyMap as KM+import Data.IORef+import Data.Text (Text)+import qualified Network.Socket as N+import qualified Network.WebSockets as WS+import System.Environment (setEnv, unsetEnv)+import System.Timeout (timeout)+import Test.Hspec++import Plexus.Client (SubstrateConfig(..))+import Plexus.Schema.Recursive (MethodSchema(..), MethodRole(..))+import qualified Plexus.Transport as ST+import qualified Plexus.Types as PT+import Synapse.Reconnect++-- ---------------------------------------------------------------------------+-- The flaky hub++-- | How the hub answers one call.+data Mode+ = Answer -- ^ confirmed, one item echoing the params, done+ | DropMid -- ^ confirmed, one item, then the connection closed+ | DropNow -- ^ the connection closed before any answer+ | Silent -- ^ confirmed, then nothing — not even a pong++data Hub = Hub+ { hubPort :: Int+ , hubModes :: IORef [Mode] -- ^ one per call, the last repeated+ , hubCalls :: IORef [Value] -- ^ the params of every call, in order+ }++withHub :: [Mode] -> (Hub -> IO a) -> IO a+withHub modes act = do+ ms <- newIORef modes+ calls <- newIORef []+ bracket (open ms calls) (N.close . fst) (\(_, h) -> act h)+ where+ open ms calls = do+ sock <- N.socket N.AF_INET N.Stream N.defaultProtocol+ N.setSocketOption sock N.ReuseAddr 1+ N.bind sock (N.SockAddrInet 0 (N.tupleToHostAddress (127, 0, 0, 1)))+ N.listen sock 16+ port <- fromIntegral <$> N.socketPort sock+ void $ forkIO $ acceptLoop sock ms calls+ pure (sock, Hub port ms calls)++acceptLoop :: N.Socket -> IORef [Mode] -> IORef [Value] -> IO ()+acceptLoop sock ms calls = do+ r <- try (N.accept sock)+ case r of+ Left (_ :: SomeException) -> pure ()+ Right (conn, _) -> do+ void $ forkIO $ do+ _ <- try (session conn) :: IO (Either SomeException ())+ N.close conn+ acceptLoop sock ms calls+ where+ session conn = do+ pending <- WS.makePendingConnection conn WS.defaultConnectionOptions+ c <- WS.acceptRequest pending+ forever $ do+ msg <- WS.receiveData c+ case decode msg of+ Just (Object o) -> handle c o+ _ -> pure ()+ handle c o = do+ let rid = KM.lookup "id" o+ params = case KM.lookup "params" o of { Just (Object p) -> p; _ -> KM.empty }+ inner = maybe Null id (KM.lookup "params" params)+ send = WS.sendTextData c . encode+ confirm = send (object ["jsonrpc" .= ("2.0" :: Text), "id" .= rid, "result" .= (7 :: Int)])+ emit item = send (object+ [ "jsonrpc" .= ("2.0" :: Text), "method" .= ("subscription" :: Text)+ , "params" .= object ["subscription" .= (7 :: Int), "result" .= item] ])+ meta = object ["provenance" .= (["hub"] :: [Text]), "plexus_hash" .= ("h0" :: Text), "timestamp" .= (0 :: Int)]+ dat = emit (object ["type" .= ("data" :: Text), "metadata" .= meta, "content_type" .= ("t.echo" :: Text), "content" .= inner])+ done = emit (object ["type" .= ("done" :: Text), "metadata" .= meta])+ modifyIORef' calls (++ [inner])+ mode <- atomicModifyIORef' ms $ \case+ [m] -> ([m], m)+ (m : rest) -> (rest, m)+ [] -> ([], Answer)+ case mode of+ Answer -> confirm >> dat >> done+ DropMid -> confirm >> dat >> threadDelay 50000 >> ioError (userError "dropped")+ DropNow -> ioError (userError "dropped")+ Silent -> confirm >> forever (threadDelay 1000000)++cfgFor :: Int -> SubstrateConfig+cfgFor port = SubstrateConfig "127.0.0.1" port "/" "t" [] Nothing++-- | One try, as synapse makes it.+attemptAt :: Int -> Value -> IO () -> (PT.PlexusStreamItem -> IO ()) -> IO (Either PT.TransportError ())+attemptAt port ps subscribed onItem =+ ST.invokeMethodStreamingWith (cfgFor port) subscribed ["t"] "m" ps onItem++noReauth :: IO Bool+noReauth = pure False++-- | A port nothing listens on.+deadPort :: IO Int+deadPort = do+ sock <- N.socket N.AF_INET N.Stream N.defaultProtocol+ N.bind sock (N.SockAddrInet 0 (N.tupleToHostAddress (127, 0, 0, 1)))+ port <- fromIntegral <$> N.socketPort sock+ N.close sock+ pure port++isData :: PT.PlexusStreamItem -> Bool+isData PT.StreamData{} = True+isData _ = False++readShape, writeShape, sinceShape :: CallShape+readShape = CallShape False True False+writeShape = CallShape False False False+sinceShape = CallShape True True True++method :: Text -> Maybe Text -> Maybe Value -> MethodSchema+method name grp params = MethodSchema+ { methodName = name, methodDescription = "", methodHash = "h", methodParams = params+ , methodReturns = Nothing, methodStreaming = False, methodBidirectional = False+ , methodRequestType = Nothing, methodResponseType = Nothing, methodDeprecation = Nothing+ , methodParamSchemas = Nothing, methodRole = MethodRoleRpc, methodLongDescription = Nothing+ , methodGrant = Nothing, methodTarget = Nothing, methodHeldOn = Nothing, methodPublic = False+ , methodGroup = grp, methodExamples = [] }++-- | Never hang a test: a call that waits forever fails it.+within :: Int -> IO a -> IO a+within secs act = timeout (secs * 1000000) act >>= maybe (expectationFailure "hung" >> error "hung") pure++main :: IO ()+main = hspec $ do+ describe "the window" $ do+ it "reads its spellings" $ do+ map parseDuration ["10m", "90s", "1h", "1m30s", "600", "0", "", "ten", "5x"]+ `shouldBe` [Just 600, Just 90, Just 3600, Just 90, Just 600, Just 0, Nothing, Nothing, Nothing]+ it "says a duration short" $+ map renderDuration [0, 45, 130, 600, 3600] `shouldBe` ["0s", "45s", "2m10s", "10m", "1h"]++ describe "a method's shape" $ do+ it "is a read when its group says so, a write when it says write" $ do+ csRead (callShape (method "get" (Just "read") Nothing)) `shouldBe` True+ csRead (callShape (method "get" (Just "write") Nothing)) `shouldBe` False+ it "falls back on a read's name, and anything else is a write" $ do+ csRead (callShape (method "list" (Just "inputs") Nothing)) `shouldBe` True+ csRead (callShape (method "answer" (Just "inputs") Nothing)) `shouldBe` False+ csRead (callShape (method "post" Nothing Nothing)) `shouldBe` False+ it "is never a read when bidirectional" $+ csRead (callShape ((method "get" (Just "read") Nothing) { methodBidirectional = True })) `shouldBe` False+ it "takes since when its params have one" $ do+ let ps = object ["type" .= ("object" :: Text), "properties" .= object ["since" .= object [], "id" .= object []]]+ csSince (callShape (method "await_reply" (Just "read") (Just ps))) `shouldBe` True+ csSince (callShape (method "get" (Just "read") Nothing)) `shouldBe` False++ describe "what an outage is" $ do+ let upgrade code = PT.ProtocolError ("Connection failed: MalformedResponse (ResponseHead {responseCode = "+ <> code <> ", responseMessage = \"x\", responseHeaders = []}) \"Wrong response status or message.\"")+ it "is a proxy in front answering 502 / 503 / 504 while the hub behind it is away (SCAFFOLD-21)" $ do+ map (isOutage . upgrade) ["502", "503", "504"] `shouldBe` [True, True, True]+ isOutageText "responseCode = 502, responseMessage = \"Bad Gateway\"" `shouldBe` True+ it "is not a refused credential, a wrong path, a misdirected request" $+ map (isOutage . upgrade) ["401", "403", "404", "421"] `shouldBe` [False, False, False, False]+ it "is the server's own 429, as before" $+ isOutage (upgrade "429") `shouldBe` True++ describe "the client notices a lost connection" $ do+ it "fails a stream whose connection closes, instead of waiting forever" $+ withHub [DropMid] $ \h -> do+ r <- within 10 $ attemptAt (hubPort h) (object []) (pure ()) (\_ -> pure ())+ r `shouldSatisfy` \case+ Left (PT.ConnectionLost _ True) -> True+ _ -> False+ it "fails a call dropped before any answer, as sent" $+ withHub [DropNow] $ \h -> do+ r <- within 10 $ attemptAt (hubPort h) (object []) (pure ()) (\_ -> pure ())+ r `shouldSatisfy` \case+ Left (PT.ConnectionLost _ True) -> True+ _ -> False+ it "notices a silent peer by its keepalive" $+ withHub [Silent] $ \h -> do+ setEnv "PLEXUS_KEEPALIVE" "1,1"+ r <- within 10 $ attemptAt (hubPort h) (object []) (pure ()) (\_ -> pure ())+ unsetEnv "PLEXUS_KEEPALIVE"+ case r of+ Left (PT.ConnectionLost why True) -> why `shouldBe` "no answer to a keepalive ping in 1 s"+ other -> expectationFailure (show other)++ describe "synapse rides out an outage" $ do+ it "sends a read again, and says the call resumed when part had arrived" $+ withHub [DropMid, Answer] $ \h -> do+ setReconnectWindow 10+ items <- newIORef (0 :: Int)+ resumed <- newIORef []+ e <- within 20 $ resilientCall "hub" readShape (attemptAt (hubPort h)) noReauth (object ["id" .= ("A-1" :: Text)])+ (\r -> modifyIORef' resumed (r :)) (\i -> if isData i then modifyIORef' items (+ 1) else pure ())+ e `shouldBe` Finished+ readIORef items `shouldReturn` 2+ length <$> readIORef resumed `shouldReturn` 1+ length <$> readIORef (hubCalls h) `shouldReturn` 2+ it "resumes a stream with since set to when it began" $+ withHub [DropMid, Answer] $ \h -> do+ setReconnectWindow 10+ resumed <- newIORef []+ e <- within 20 $ resilientCall "hub" sinceShape (attemptAt (hubPort h)) noReauth (object ["id" .= ("A-1" :: Text), "since" .= Null])+ (\r -> modifyIORef' resumed (r :)) (\_ -> pure ())+ e `shouldBe` Finished+ [first, second] <- readIORef (hubCalls h)+ first `shouldBe` object ["id" .= ("A-1" :: Text), "since" .= Null]+ case second of+ Object o -> KM.lookup "since" o `shouldSatisfy` \case { Just (Number _) -> True; _ -> False }+ _ -> expectationFailure "params"+ map rsSince <$> readIORef resumed >>= \ss -> ss `shouldSatisfy` all (/= Nothing)+ it "keeps a since the caller gave" $+ withHub [DropMid, Answer] $ \h -> do+ setReconnectWindow 10+ _ <- within 20 $ resilientCall "hub" sinceShape (attemptAt (hubPort h)) noReauth (object ["since" .= (42 :: Int)])+ (\_ -> pure ()) (\_ -> pure ())+ readIORef (hubCalls h) `shouldReturn` [object ["since" .= (42 :: Int)], object ["since" .= (42 :: Int)]]+ it "never sends a write again once it went out" $+ withHub [DropNow, Answer] $ \h -> do+ setReconnectWindow 10+ e <- within 20 $ resilientCall "hub" writeShape (attemptAt (hubPort h)) noReauth (object []) (\_ -> pure ()) (\_ -> pure ())+ e `shouldSatisfy` \case { WriteUnknown _ -> True; _ -> False }+ length <$> readIORef (hubCalls h) `shouldReturn` 1+ it "sends a write that never left once the backend is back" $ do+ port <- deadPort+ setReconnectWindow 20+ -- the backend comes up on that port 2 s later+ r <- newEmptyIORef+ void $ forkIO $ do+ threadDelay 2000000+ withHubAt port [Answer] $ \h -> do+ threadDelay 8000000+ readIORef (hubCalls h) >>= writeIORef r . Just . length+ e <- within 20 $ resilientCall "hub" writeShape (attemptAt port) noReauth (object []) (\_ -> pure ()) (\_ -> pure ())+ e `shouldBe` Finished+ it "gives up when the window runs out" $ do+ port <- deadPort+ setReconnectWindow 2+ e <- within 20 $ resilientCall "hub" readShape (attemptAt port) noReauth (object []) (\_ -> pure ()) (\_ -> pure ())+ e `shouldSatisfy` \case { GaveUp 2 _ -> True; _ -> False }+ it "with no window, reports the loss as before" $+ withHub [DropMid] $ \h -> do+ setReconnectWindow 0+ e <- within 20 $ resilientCall "hub" readShape (attemptAt (hubPort h)) noReauth (object []) (\_ -> pure ()) (\_ -> pure ())+ e `shouldSatisfy` \case { Failed (PT.ConnectionLost _ True) -> True; _ -> False }+ where+ newEmptyIORef :: IO (IORef (Maybe Int))+ newEmptyIORef = newIORef Nothing++-- | A hub on a port already chosen.+withHubAt :: Int -> [Mode] -> (Hub -> IO a) -> IO a+withHubAt port modes act = do+ ms <- newIORef modes+ calls <- newIORef []+ bracket (open ms calls) N.close (\_ -> act (Hub port ms calls))+ where+ open ms calls = do+ sock <- N.socket N.AF_INET N.Stream N.defaultProtocol+ N.setSocketOption sock N.ReuseAddr 1+ N.bind sock (N.SockAddrInet (fromIntegral port) (N.tupleToHostAddress (127, 0, 0, 1)))+ N.listen sock 16+ void $ forkIO $ acceptLoop sock ms calls+ pure sock
+ test/RenderSpec.hs view
@@ -0,0 +1,318 @@+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE QuasiQuotes #-}++-- | SYNAPSE-13: a backend's help in the gh / cargo shape.+--+-- A listing (@synapse … menger tickets@) is a usage line, then one line per+-- activation and per method — its name and summary, no flags — the methods+-- in their groups, and how to see a method's options. A method's page+-- (@… tickets set_status --help@) is the description, the long description,+-- a usage line naming the required flags, aligned options with their types+-- and allowed values, examples as command lines (the schema's, else one+-- synthesised), and, in a caller's view, what it requires. A schema from a+-- hub that says none of the new fields still renders sensibly.+module Main (main) where++import Data.Aeson (Value)+import Data.Aeson.QQ.Simple (aesonQQ)+import qualified Data.Map.Strict as Map+import Data.Maybe (fromJust)+import Data.Text (Text)+import qualified Data.Text as T+import qualified Data.Text.IO as TIO+import Test.Hspec++import Plexus.Schema.Recursive (ChildSummary(..), MethodExample(..), MethodRole(..), MethodSchema(..), PluginSchema(..))+import Synapse.Algebra.Render (Listing(..), renderListing, renderSchema, defaultStyle, summarize, fitText)+import Synapse.CLI.Help (MethodPage(..), renderMethodPage, renderExample, shellQuote)+import Synapse.IR.Builder (extractMethodDef)+import Synapse.IR.Types++method :: Text -> Text -> Maybe Value -> MethodSchema+method name desc params = MethodSchema+ { methodName = name, methodDescription = desc, methodHash = "h", methodParams = params+ , methodReturns = Nothing, methodStreaming = True, methodBidirectional = False+ , methodRequestType = Nothing, methodResponseType = Nothing, methodDeprecation = Nothing+ , methodParamSchemas = Nothing, methodRole = MethodRoleRpc+ , methodLongDescription = Nothing, methodGrant = Nothing, methodTarget = Nothing, methodHeldOn = Nothing, methodPublic = False+ , methodGroup = Nothing, methodExamples = [] }++grouped :: Text -> MethodSchema -> MethodSchema+grouped g m = m { methodGroup = Just g }++-- ---------------------------------------------------------------------------+-- Fixtures+-- ---------------------------------------------------------------------------++-- | A hub with children and ungrouped methods (an older hub's shape).+hub :: PluginSchema+hub = PluginSchema+ { psNamespace = "menger", psVersion = "1.0.0"+ , psDescription = "Central routing and introspection"+ , psLongDescription = Nothing, psHash = "h0"+ , psMethods =+ [ method "hash" "Get plexus configuration hash (from the recursive schema) This hash changes whenever any method or child plugin changes" Nothing+ , method "call" "Route a call to a registered activation" (Just findParams)+ ]+ , psChildren = Just+ [ ChildSummary "tickets" "Plans as data — create, query, and watch the work DAG" "h1"+ , ChildSummary "comments" "Comments on the app and on ticket bodies — add, list (filtered), resolve, reopen, watch" "h2"+ ]+ , psDeprecation = Nothing+ , psRoles = Nothing, psView = Nothing+ }++-- | A leaf whose methods say their groups (declaration order: read, write,+-- inputs), one saying none.+tickets :: PluginSchema+tickets = PluginSchema+ { psNamespace = "tickets", psVersion = "0.5.0"+ , psDescription = "Plans as data — create, query, and watch the work DAG"+ , psLongDescription = Nothing, psHash = "t0"+ , psMethods =+ [ grouped "read" (method "list" "List tickets, optionally filtered by epic, status, parent and/or a filter; paged with after + limit" (Just findParams))+ , grouped "write" (method "set_status" "Set a ticket's status (not a doc's: a doc has no lifecycle)" (Just setStatusParams))+ , grouped "read" (method "get" "Get one ticket by id, or by path address SHORTCODE/path (e.g. PLEXUS/_MENGER/board)" Nothing)+ , grouped "inputs" (method "answer" "Answer an input: record a value (checked against its kind and options) with a rationale" Nothing)+ , grouped "write" (method "create" "Create a ticket; allocates the next EPIC-N id" Nothing)+ , method "schema" "Get plugin or method schema. Pass {\"method\": \"name\"} for a specific method." Nothing+ ]+ , psChildren = Nothing+ , psDeprecation = Nothing+ , psRoles = Nothing, psView = Nothing+ }++findParams, setStatusParams, answerParams :: Value+findParams = [aesonQQ|{+ "type": "object",+ "properties": {+ "after": {"type": ["string", "null"], "description": "Cursor: start strictly after this ticket id, e.g. MNG-9 (optional)"},+ "limit": {"type": ["integer", "null"], "format": "uint64", "description": "At most this many tickets (default 50); the page event carries the next cursor (optional)"},+ "query": {"type": "string", "description": "Substring of the ticket id or title, case-insensitive (ASCII), trimmed; empty matches every ticket"}+ },+ "required": ["query"]+}|]+setStatusParams = [aesonQQ|{+ "type": "object",+ "properties": {+ "id": {"type": "string", "description": "Ticket id, e.g. MNG-2"},+ "status": {"type": "string", "description": "New status (case-insensitive; `active` is Coding)",+ "x-plexus-values": ["Pending", "Ready", "Coding", "In Review", "Blocked", "Complete"]},+ "note": {"type": ["string", "null"], "description": "Why (optional)"}+ },+ "required": ["id", "status"]+}|]+answerParams = [aesonQQ|{+ "type": "object",+ "properties": {+ "ticket": {"type": "string", "description": "Ticket id, e.g. INPUT-9"},+ "value": {"type": "object", "description": "Typed by the input's kind"},+ "commits": {"type": ["array", "null"], "items": {"type": "string"}, "description": "Commits"},+ "rationale": {"type": ["string", "null"], "description": "Why (optional)"}+ },+ "required": ["ticket", "value"]+}|]++-- | set_status as a hub with SYNAPSE-13's fields serves it.+setStatus :: MethodSchema+setStatus = (method "set_status" "Set a ticket's status (not a doc's: a doc has no lifecycle)" (Just setStatusParams))+ { methodLongDescription = Just "Yields exactly one event: `StatusChanged`, `NotFound`, or `Error`.\n\nA doc has no lifecycle."+ , methodGrant = Just "tickets.set_status", methodTarget = Just "id", methodHeldOn = Just ["MENGER"]+ , methodGroup = Just "write"+ , methodExamples =+ [ MethodExample [aesonQQ|{"id": "MENGER-40", "status": "Coding"}|] (Just "start work on MENGER-40")+ , MethodExample [aesonQQ|{"id": "MENGER-40", "status": "In Review"}|] Nothing+ ]+ }++pageOf :: Bool -> MethodSchema -> (MethodDef, Text)+pageOf caller ms =+ let (types, md) = extractMethodDef "tickets" "tickets" ms+ ir = emptyIR { irMethods = Map.singleton (mdFullPath md) md, irTypes = types }+ page = MethodPage+ { mpProgram = "synapse -P 4455", mpTarget = "menger tickets " <> methodName ms+ , mpSchema = Just ms, mpCaller = caller }+ in (md, renderMethodPage page ir md)++-- | An older hub's set_status: no long description, examples, values,+-- grant or group — and only the IR (no schema) at hand.+oldPage :: Text+oldPage =+ let old = (method "set_status" "Set a ticket's status" (Just [aesonQQ|{+ "type": "object",+ "properties": {"id": {"type": "string", "description": "Ticket id, e.g. MNG-2"},+ "status": {"type": "string", "description": "New status"}},+ "required": ["id", "status"]}|]))+ (types, md) = extractMethodDef "tickets" "tickets" old+ ir = emptyIR { irMethods = Map.singleton (mdFullPath md) md, irTypes = types }+ page = MethodPage { mpProgram = "synapse", mpTarget = "menger tickets set_status", mpSchema = Nothing, mpCaller = False }+ in renderMethodPage page ir md++-- ---------------------------------------------------------------------------+-- Helpers+-- ---------------------------------------------------------------------------++linesOf :: Text -> [Text]+linesOf = T.lines++lineWith :: [Text] -> Text -> Text+lineWith ls needle = fromJust (lookupLine needle)+ where lookupLine n = case filter (T.isInfixOf n) ls of { (l : _) -> Just l; [] -> Nothing }++indexOf :: [Text] -> Text -> Int+indexOf ls needle = length (takeWhile (not . T.isInfixOf needle) ls)++col :: Text -> Text -> Int+col needle l = T.length (fst (T.breakOn needle l))++main :: IO ()+main = do+ let listing = renderListing defaultStyle (Listing "synapse -P 4455 menger tickets" Nothing) tickets+ hubListing = renderListing defaultStyle (Listing "synapse -P 4455 menger" Nothing) hub+ (_, page) = pageOf False setStatus+ (_, callerPage) = pageOf True setStatus+ mapM_ TIO.putStrLn [listing, hubListing, page, callerPage, oldPage]+ hspec $ do+ describe "a listing (gh's shape)" $ do+ let ls = linesOf listing+ it "is the name, version and description, a usage line, then its sections" $ do+ take 2 ls `shouldBe` ["tickets v0.5.0", " Plans as data \x2014 create, query, and watch the work DAG"]+ lineWith ls "USAGE" `shouldBe` "USAGE"+ ls !! (indexOf ls "USAGE" + 1) `shouldBe` " synapse -P 4455 menger tickets <method> [flags]"++ it "groups the methods in the order their groups first appear, the ungrouped last" $+ filter (\l -> not (T.null l) && T.toUpper l == l && not (" " `T.isPrefixOf` l)) ls+ `shouldBe` ["USAGE", "READ", "WRITE", "INPUTS", "OTHER"]++ it "lists each group alphabetically" $ do+ let at n = indexOf ls (" " <> n <> " ")+ (at "get" < at "list", at "create" < at "set_status") `shouldBe` (True, True)+ (indexOf ls "READ" < at "get", at "list" < indexOf ls "WRITE") `shouldBe` (True, True)++ it "is one line per method — its summary, no flags" $ do+ lineWith ls " create " `shouldSatisfy` T.isSuffixOf "Create a ticket"+ lineWith ls " answer " `shouldSatisfy` T.isSuffixOf "Answer an input"+ lineWith ls " schema " `shouldSatisfy` T.isSuffixOf "Get plugin or method schema"+ filter (T.isInfixOf "--") (takeWhile (not . T.isPrefixOf "Run ") ls) `shouldBe` []+ length (filter (T.isPrefixOf " ") ls) `shouldBe` 2 + length (psMethods tickets) -- the description, the usage, a line each++ it "starts every summary in one column" $ do+ let cols = [col "Create a ticket" (lineWith ls " create "), col "Answer" (lineWith ls " answer "), col "Set a ticket" (lineWith ls " set_status ")]+ cols `shouldSatisfy` (\cs -> and (zipWith (==) cs (drop 1 cs)))++ it "ends with how to see a method's options" $+ last ls `shouldBe` "Run 'synapse -P 4455 menger tickets <method> --help' for its options."++ it "fits in 80 columns and never leaves two blank lines in a row" $ do+ filter ((> 80) . T.length) ls `shouldBe` []+ ("\n\n\n" `T.isInfixOf` listing) `shouldBe` False++ describe "a listing of a hub with no groups (an older hub)" $ do+ let ls = linesOf hubListing+ it "lists activations, then methods, alphabetically" $ do+ filter (`elem` ["ACTIVATIONS", "METHODS"]) ls `shouldBe` ["ACTIVATIONS", "METHODS"]+ (indexOf ls " comments " < indexOf ls " tickets ", indexOf ls " call " < indexOf ls " hash ") `shouldBe` (True, True)+ it "says how to reach both" $ do+ ls `shouldContain` [" synapse -P 4455 menger <activation> <method> [flags]", " synapse -P 4455 menger <method> [flags]"]+ ls `shouldContain` ["Run 'synapse -P 4455 menger <activation>' for its methods.", "Run 'synapse -P 4455 menger <method> --help' for its options."]+ it "keeps a long summary to its line" $ do+ lineWith ls " comments " `shouldSatisfy` T.isSuffixOf "Comments on the app and on ticket bodies"+ filter ((> 80) . T.length) ls `shouldBe` []++ describe "a listing in a caller's view (AUTH-8)" $ do+ let caller = PluginSchema+ { psNamespace = "tickets", psVersion = "0.5.0", psDescription = "Plans as data"+ , psLongDescription = Nothing, psHash = "c", psChildren = Nothing, psDeprecation = Nothing+ , psRoles = Nothing, psView = Nothing+ , psMethods = [ setStatus, grouped "read" (method "get" "One ticket by id" Nothing) { methodGrant = Just "tickets.get", methodHeldOn = Just ["MENGER", "PLEXUS"] } ] }+ ls = linesOf (renderListing defaultStyle (Listing "synapse menger tickets" (Just "you are worker-1 \x00B7 editor on MENGER")) caller)+ it "keeps who the caller is under the description" $+ take 3 ls `shouldBe` ["tickets v0.5.0", " Plans as data", " you are worker-1 \x00B7 editor on MENGER"]+ it "ends each method's one line with its grant and where it is held, aligned" $ do+ let g = lineWith ls " get "+ s = lineWith ls " set_status "+ T.stripEnd g `shouldSatisfy` T.isSuffixOf "tickets.get MENGER, PLEXUS"+ T.stripEnd s `shouldSatisfy` T.isSuffixOf "tickets.set_status MENGER"+ col "tickets." g `shouldBe` col "tickets." s+ it "shows no grant column outside a caller's view" $+ renderListing defaultStyle (Listing "synapse menger tickets" Nothing) caller { psMethods = [setStatus { methodHeldOn = Nothing }] }+ `shouldSatisfy` (not . T.isInfixOf "tickets.set_status")++ describe "a summary" $ do+ it "is the first sentence, outside brackets, not stopping at e.g." $ do+ summarize "Create a ticket; allocates the next id" `shouldBe` "Create a ticket"+ summarize "Answer an input: record a value" `shouldBe` "Answer an input"+ summarize "Get plugin or method schema. Pass {\"method\": \"name\"}." `shouldBe` "Get plugin or method schema"+ summarize "Set a ticket's status (not a doc's: a doc has no lifecycle)" `shouldBe` "Set a ticket's status (not a doc's: a doc has no lifecycle)"+ summarize "Get one ticket, e.g. MNG-2. More" `shouldBe` "Get one ticket, e.g. MNG-2"+ summarize "Heartbeat \x2014 version, unix time" `shouldBe` "Heartbeat \x2014 version, unix time"+ it "fits its column: without parentheticals, at a dash, else cut with an ellipsis" $ do+ fitText 40 "Get one ticket by id, or by path address SHORTCODE/path (e.g. PLEXUS/_MENGER/board)"+ `shouldBe` "Get one ticket by id, or by path\x2026"+ fitText 60 "Comments on the app (and bodies) \x2014 add, list (filtered), resolve, reopen, watch"+ `shouldBe` "Comments on the app \x2014 add, list, resolve, reopen, watch"+ fitText 30 "Each shortcode's last sequence \x2014 where each one ends" `shouldBe` "Each shortcode's last sequence"+ T.length (fitText 20 "a very long single sentence without any stops in it at all") `shouldSatisfy` (<= 20)++ describe "a method's page (cargo's shape)" $ do+ let ls = linesOf page+ opts = drop (indexOf ls "Options:") ls+ it "is the description, then the long description in its paragraphs" $ do+ head ls `shouldBe` "Set a ticket's status (not a doc's: a doc has no lifecycle)"+ take 4 (drop 1 ls) `shouldBe` ["", "Yields exactly one event: `StatusChanged`, `NotFound`, or `Error`.", "", "A doc has no lifecycle."]+ it "has a usage line naming the required flags, then [OPTIONS]" $+ lineWith ls "Usage:" `shouldBe` "Usage: synapse -P 4455 menger tickets set_status --id <ID> --status <STATUS> [OPTIONS]"+ it "aligns the options, required first, optional in brackets, each with its type" $ do+ let i = indexOf ls "Options:"+ take 3 (map (T.takeWhile (/= '>')) (filter (" -" `T.isPrefixOf`) (drop i ls) ++ filter (" [" `T.isPrefixOf`) (drop i ls)))+ `shouldBe` [" --id <string", " --status <string", " [--note <string"]+ let cols = [col "Ticket id" (lineWith opts "--id <"), col "New status" (lineWith opts "--status <"), col "Why" (lineWith opts "[--note <")]+ cols `shouldSatisfy` (\cs -> and (zipWith (==) cs (drop 1 cs)))+ it "says the values a param takes, under its description" $ do+ let i = indexOf opts "--status <"+ opts !! (i + 1) `shouldSatisfy` T.isInfixOf "[values: Pending, Ready, Coding, In Review, Blocked,"+ T.length (T.takeWhile (== ' ') (opts !! (i + 1))) `shouldBe` col "New status" (opts !! i)+ it "renders the schema's examples as command lines under their notes" $ do+ lineWith ls "Examples:" `shouldBe` "Examples:"+ ls `shouldContain`+ [ " # start work on MENGER-40"+ , " synapse -P 4455 menger tickets set_status --id MENGER-40 --status Coding" ]+ lineWith ls "In Review'" `shouldBe` " synapse -P 4455 menger tickets set_status --id MENGER-40 --status 'In Review'"+ it "says nothing of grants outside a caller's view" $+ page `shouldSatisfy` (not . T.isInfixOf "Requires:")+ it "says what it requires in a caller's view" $+ last (linesOf callerPage) `shouldBe` "Requires: tickets.set_status on the scope of --id (you hold it on MENGER)"+ it "fits in 80 columns, its command lines (usage, examples) aside" $+ filter (\l -> T.length l > 80 && not (" synapse " `T.isPrefixOf` l) && not ("Usage: " `T.isPrefixOf` l)) ls `shouldBe` []++ describe "a method's page from an older hub (none of the new fields)" $ do+ let ls = linesOf oldPage+ it "still has its usage line and options" $ do+ lineWith ls "Usage:" `shouldBe` "Usage: synapse menger tickets set_status --id <ID> --status <STATUS>"+ lineWith (drop (indexOf ls "Options:") ls) "--status <" `shouldSatisfy` T.isInfixOf "New status"+ it "synthesises one example from the required options (an e.g. where it gives one)" $ do+ lineWith ls "Example:" `shouldBe` "Example:"+ lineWith ls " synapse " `shouldBe` " synapse menger tickets set_status --id MNG-2 --status <STATUS>"+ oldPage `shouldSatisfy` (not . T.isInfixOf "values:")++ describe "an example as a command line" $ do+ let (md, _) = pageOf False ((method "answer" "Answer" (Just answerParams)))+ run = renderExample "synapse -P 4455" "menger tickets answer" md+ it "puts required params first, an object's fields dotted with its type first" $+ run [aesonQQ|{"rationale": "holds are real", "value": {"type": "choice", "option": "archive"}, "ticket": "R-13"}|]+ `shouldBe` "synapse -P 4455 menger tickets answer --ticket R-13 --value.type choice --value.option archive --rationale 'holds are real'"+ it "joins a list with commas, an element holding one in double quotes (SYNAPSE-2)" $ do+ run [aesonQQ|{"ticket": "R-13", "commits": ["a1b2c3d", "x@4f7114a"]}|]+ `shouldBe` "synapse -P 4455 menger tickets answer --ticket R-13 --commits a1b2c3d,x@4f7114a"+ run [aesonQQ|{"ticket": "R-13", "commits": ["a,b", "c"]}|]+ `shouldBe` "synapse -P 4455 menger tickets answer --ticket R-13 --commits '\"a,b\",c'"+ it "sends a list inside an object whole, with -p before the backend (SYNAPSE-9)" $+ run [aesonQQ|{"ticket": "R-13", "value": {"type": "multi", "options": ["unit", "wire"]}}|]+ `shouldBe` "synapse -P 4455 -p '{\"ticket\":\"R-13\",\"value\":{\"options\":[\"unit\",\"wire\"],\"type\":\"multi\"}}' menger tickets answer"+ it "quotes what the shell would split" $ do+ map shellQuote ["MENGER-40", "In Review", "it's", "#/ticket/D-2", "<ID>", "$HOME", ""]+ `shouldBe` ["MENGER-40", "'In Review'", "\"it's\"", "'#/ticket/D-2'", "<ID>", "'$HOME'", "''"]++ describe "renderSchema (the listing, reached by its namespace)" $+ it "is the listing with synapse <namespace> as its usage" $+ renderSchema tickets `shouldSatisfy` T.isInfixOf " synapse tickets <method> [flags]"
+ test/Req8RenderSpec.hs view
@@ -0,0 +1,137 @@+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE QuasiQuotes #-}++-- | REQ-8 acceptance: synapse renderer shows per-param x-plexus-source+-- annotations inline when rendering a method's help.+--+-- SYNAPSE-13: a listing no longer shows parameters (one line per method),+-- so the annotations are pinned on the method's page, built from the IR.+module Main where++import Data.Aeson (Value)+import Data.Aeson.QQ.Simple (aesonQQ)+import qualified Data.IORef as IORef+import qualified Data.Text as T+import Data.Text (Text)+import System.Exit (exitFailure, exitSuccess)++import qualified Plexus.Schema.Recursive+import Plexus.Schema.Recursive+ ( PluginSchema(..), MethodSchema(..) )+import qualified Data.Map.Strict as Map+import Synapse.CLI.Help (renderMethodHelp)+import Synapse.IR.Builder (extractMethodDef)+import Synapse.IR.Types (IR(..), MethodDef(..), emptyIR)++-- ─── Fixture ──────────────────────────────────────────────────++-- A plugin with one method that has three params:+-- - user: x-plexus-source.from = auth (resolver)+-- - origin: x-plexus-source.from = derived+-- - search: no annotation (RPC param)+fixture :: PluginSchema+fixture = PluginSchema+ { psNamespace = "svc"+ , psVersion = "1.0.0"+ , psDescription = "Test service"+ , psLongDescription = Nothing+ , psHash = "h1"+ , psMethods = [method1]+ , psChildren = Nothing+ , psDeprecation = Nothing+ , psRoles = Nothing, psView = Nothing+ }+ where+ method1 = MethodSchema+ { methodName = "list"+ , methodDescription = "List things"+ , methodHash = "h2"+ , methodParams = Just paramsSchema+ , methodReturns = Nothing+ , methodStreaming = False+ , methodBidirectional = False+ , methodRequestType = Nothing+ , methodResponseType = Nothing+ , methodDeprecation = Nothing+ , methodParamSchemas = Nothing+ , methodRole = defaultRole+ , methodLongDescription = Nothing, methodGrant = Nothing, methodTarget = Nothing, methodHeldOn = Nothing, methodPublic = False, methodGroup = Nothing, methodExamples = []+ }++-- | Stub role — renderSchema doesn't branch on role for this test.+defaultRole :: Plexus.Schema.Recursive.MethodRole+defaultRole = Plexus.Schema.Recursive.MethodRoleRpc++paramsSchema :: Value+paramsSchema = [aesonQQ|{+ "type": "object",+ "properties": {+ "user": {+ "type": "object",+ "x-plexus-source": {+ "from": "auth",+ "resolver": "self.db.validate_user"+ }+ },+ "origin": {+ "type": "object",+ "x-plexus-source": { "from": "derived" }+ },+ "search": {+ "type": "string"+ }+ },+ "required": ["search"]+}|]++-- ─── Runner ───────────────────────────────────────────────────++data TestResult = Pass String | Fail String String++main :: IO ()+main = do+ failures <- IORef.newIORef (0 :: Int)+ let run (Pass name) = putStrLn $ " [PASS] " <> name+ run (Fail name reason) = do+ putStrLn $ " [FAIL] " <> name <> ": " <> reason+ IORef.modifyIORef' failures (+ 1)++ let (types, md) = extractMethodDef "svc" "svc" (head (psMethods fixture))+ ir = emptyIR { irMethods = Map.singleton (mdFullPath md) md, irTypes = types }+ out = renderMethodHelp ir md++ putStrLn "=== REQ-8: per-param source annotations in renderer ==="+ putStrLn "(rendered output)"+ putStrLn "---"+ putStrLn $ T.unpack out+ putStrLn "---"++ run $ assertContains out "list" "method name appears"+ run $ assertContains out "user" "auth-sourced param name appears"+ run $ assertContains out "origin" "derived-sourced param name appears"+ run $ assertContains out "--search" "RPC param emitted as --flag"+ run $ assertContains out "auth: self.db.validate_user"+ "auth resolver expression appears on the auth-sourced param"+ run $ assertContains out "server-derived"+ "derived source is labeled 'server-derived'"+ run $ assertNotContains out "--user"+ "auth-sourced param is NOT emitted as --flag (server-extracted)"+ run $ assertNotContains out "--origin"+ "derived-sourced param is NOT emitted as --flag"++ fc <- IORef.readIORef failures+ if fc == 0+ then putStrLn "\nREQ-8: ALL TESTS PASSED" >> exitSuccess+ else putStrLn ("\nREQ-8: " <> show fc <> " test(s) failed") >> exitFailure++assertContains :: Text -> Text -> String -> TestResult+assertContains hay needle name =+ if needle `T.isInfixOf` hay+ then Pass name+ else Fail name ("expected output to contain: " <> T.unpack needle)++assertNotContains :: Text -> Text -> String -> TestResult+assertNotContains hay needle name =+ if needle `T.isInfixOf` hay+ then Fail name ("unexpected substring present: " <> T.unpack needle)+ else Pass name
+ test/SchemaDecodeSpec.hs view
@@ -0,0 +1,190 @@+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE QuasiQuotes #-}++-- | REQ-S10 spike: verify Plexus.Schema.Recursive.PluginSchema decodes+-- the new optional psRequest field losslessly, including x-plexus-source+-- extension nodes.+module Main where++import Data.Aeson+import qualified Data.Aeson.Key as Key+import qualified Data.Aeson.KeyMap as KeyMap+import Data.Aeson.QQ.Simple (aesonQQ)+import qualified Data.ByteString.Lazy.Char8 as BL+import Data.Maybe (isJust, isNothing, fromJust)+import qualified Data.Vector as V+import System.Exit (exitFailure, exitSuccess)+import qualified Data.IORef as IORef++import Plexus.Schema.Recursive (PluginSchema(..))++-- ============================================================================+-- Fixtures+-- ============================================================================++fixtureWithRequest :: Value+fixtureWithRequest = [aesonQQ|{+ "namespace": "clients",+ "version": "1.0.0",+ "description": "Client management",+ "hash": "abc123",+ "methods": [],+ "request": {+ "type": "object",+ "properties": {+ "auth_token": {+ "type": "string",+ "description": "JWT from Keycloak",+ "x-plexus-source": { "from": "cookie", "key": "access_token" }+ },+ "origin": {+ "type": "string",+ "x-plexus-source": { "from": "header", "key": "origin" }+ },+ "peer_addr": {+ "type": "string",+ "x-plexus-source": { "from": "derived" }+ }+ },+ "required": ["auth_token"]+ }+}|]++fixtureWithoutRequest :: Value+fixtureWithoutRequest = [aesonQQ|{+ "namespace": "forms",+ "version": "1.0.0",+ "description": "Forms (no auth required)",+ "hash": "def456",+ "methods": []+}|]++fixtureEmptyRequest :: Value+fixtureEmptyRequest = [aesonQQ|{+ "namespace": "edge",+ "version": "1.0.0",+ "description": "Edge case",+ "hash": "ghi789",+ "methods": [],+ "request": {}+}|]++-- ============================================================================+-- Test runner+-- ============================================================================++data TestResult = Pass String | Fail String String++runTest :: String -> Bool -> String -> TestResult+runTest name True _ = Pass name+runTest name False why = Fail name why++main :: IO ()+main = do+ failures <- IORef.newIORef (0 :: Int)+ let run :: TestResult -> IO ()+ run (Pass name) = putStrLn $ " [PASS] " <> name+ run (Fail name reason) = do+ putStrLn $ " [FAIL] " <> name <> ": " <> reason+ IORef.modifyIORef' failures (+ 1)++ putStrLn "=== REQ-S10: PluginSchema psRequest decoding ==="++ -- Pass condition 1: fixtureWithRequest decodes to psRequest = Just _+ -- and x-plexus-source.from on auth_token is "cookie"+ case fromJSON fixtureWithRequest :: Result PluginSchema of+ Error e -> run (Fail "fixture-with-request decodes" e)+ Success schema -> do+ run $ runTest "fixture-with-request: psRequest = Just _"+ (isJust (psRequest schema))+ "expected Just, got Nothing"+ let cookieFrom = do+ req <- psRequest schema+ obj <- valueToObject req+ props <- KeyMap.lookup "properties" obj+ propsObj <- valueToObject props+ authTok <- KeyMap.lookup "auth_token" propsObj+ atObj <- valueToObject authTok+ src <- KeyMap.lookup "x-plexus-source" atObj+ srcObj <- valueToObject src+ from <- KeyMap.lookup "from" srcObj+ valueToString from+ run $ runTest "fixture-with-request: auth_token x-plexus-source.from == 'cookie'"+ (cookieFrom == Just "\"cookie\"")+ ("got: " <> show cookieFrom)++ -- Pass condition 2: fixtureWithoutRequest decodes to psRequest = Nothing+ case fromJSON fixtureWithoutRequest :: Result PluginSchema of+ Error e -> run (Fail "fixture-without-request decodes" e)+ Success schema ->+ run $ runTest "fixture-without-request: psRequest = Nothing"+ (isNothing (psRequest schema))+ ("expected Nothing, got: " <> show (psRequest schema))++ -- Pass condition 3: fixtureEmptyRequest decodes to psRequest = Just (Object empty)+ case fromJSON fixtureEmptyRequest :: Result PluginSchema of+ Error e -> run (Fail "fixture-empty-request decodes" e)+ Success schema -> do+ let isEmptyObject = case psRequest schema of+ Just (Object km) -> KeyMap.null km+ _ -> False+ run $ runTest "fixture-empty-request: psRequest = Just (Object empty)"+ isEmptyObject+ ("got: " <> show (psRequest schema))++ -- Pass condition 4: lossless roundtrip on all three fixtures+ let roundtripCheck name fixture =+ case fromJSON fixture :: Result PluginSchema of+ Error e -> Fail (name <> " decode for roundtrip") e+ Success original ->+ case decode (encode original) :: Maybe PluginSchema of+ Nothing -> Fail (name <> " roundtrip decode") "encode/decode failed"+ Just decoded ->+ runTest (name <> " roundtrip lossless")+ (decoded == original)+ "decoded /= original"++ run $ roundtripCheck "fixture-with-request" fixtureWithRequest+ run $ roundtripCheck "fixture-without-request" fixtureWithoutRequest+ run $ roundtripCheck "fixture-empty-request" fixtureEmptyRequest++ -- Pass condition 5: x-plexus-source survives roundtrip on all three field types+ case fromJSON fixtureWithRequest :: Result PluginSchema of+ Error _ -> run (Fail "x-plexus-source roundtrip" "decode failed")+ Success original -> do+ let reEncoded = encode original+ case decode reEncoded :: Maybe PluginSchema of+ Nothing -> run (Fail "x-plexus-source roundtrip decode" "decode-after-encode failed")+ Just decoded -> do+ let extractFrom propName = do+ req <- psRequest decoded+ obj <- valueToObject req+ props <- KeyMap.lookup "properties" obj >>= valueToObject+ prop <- KeyMap.lookup (Key.fromString propName) props >>= valueToObject+ src <- KeyMap.lookup "x-plexus-source" prop >>= valueToObject+ from <- KeyMap.lookup "from" src+ valueToString from+ run $ runTest "x-plexus-source roundtrip: cookie field"+ (extractFrom "auth_token" == Just "\"cookie\"") (show $ extractFrom "auth_token")+ run $ runTest "x-plexus-source roundtrip: header field"+ (extractFrom "origin" == Just "\"header\"") (show $ extractFrom "origin")+ run $ runTest "x-plexus-source roundtrip: derived field"+ (extractFrom "peer_addr" == Just "\"derived\"") (show $ extractFrom "peer_addr")++ fc <- IORef.readIORef failures+ if fc == 0+ then do+ putStrLn "\nREQ-S10: ALL TESTS PASSED"+ exitSuccess+ else do+ putStrLn $ "\nREQ-S10: " <> show fc <> " test(s) failed"+ exitFailure++-- helpers+valueToObject :: Value -> Maybe Object+valueToObject (Object o) = Just o+valueToObject _ = Nothing++valueToString :: Value -> Maybe String+valueToString (String t) = Just (show t) -- show wraps in quotes; we compare against quoted strings below+valueToString _ = Nothing
+ test/SelfCommandSpec.hs view
@@ -0,0 +1,278 @@+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE ScopedTypeVariables #-}++-- | Tests for 'Synapse.Self.Command' (SELF-4).+--+-- Two layers:+--+-- 1. Pure tests: the set-value heuristic ('wrapAsLiteralIfNeeded'),+-- JWT decode + relative-expiry rendering, and exit-code behavior+-- for keychain-gated verbs.+--+-- 2. Integration tests: exercise 'runSelfCommand' against a tmp HOME+-- so the on-disk @~\/.plexus\/\<backend\>\/defaults.json@ lifecycle+-- is covered end-to-end.+--+-- The integration tests use 'withSystemTempDirectory' + 'withEnv "HOME"'+-- (mirroring the pattern in SelfSpec.hs) so they never touch the+-- developer's real @~\/.plexus@.+module SelfCommandSpec (spec) where++import Control.Exception (bracket_)+import qualified Data.ByteString as BS+import qualified Data.ByteString.Char8 as BS8+import qualified Data.ByteString.Base64.URL as B64U+import qualified Data.Map.Strict as Map+import Data.Text (Text)+import qualified Data.Text as T+import qualified Data.Text.Encoding as TE+import qualified Data.Text.IO as TIO+import Data.Time.Clock (UTCTime, addUTCTime, getCurrentTime)+import Data.Time.Clock.POSIX (posixSecondsToUTCTime, utcTimeToPOSIXSeconds)+import System.Directory (doesFileExist, getHomeDirectory, removeFile)+import System.Environment (lookupEnv, setEnv, unsetEnv)+import System.Exit (ExitCode(..))+import System.FilePath ((</>))+import System.IO.Temp (withSystemTempDirectory)+import Test.Hspec++import Synapse.Self+ ( CredentialRef(..)+ , StoredDefaults(..)+ , decodeDefaults+ , emptyStoredDefaults+ )+import Synapse.Self.Command++spec :: Spec+spec = do+ describe "wrapAsLiteralIfNeeded" $ do+ it "wraps a bare string with no scheme" $+ wrapAsLiteralIfNeeded "abc123" `shouldBe` "literal:abc123"++ it "preserves a literal: prefix" $+ wrapAsLiteralIfNeeded "literal:already" `shouldBe` "literal:already"++ it "preserves env:// refs" $+ wrapAsLiteralIfNeeded "env://FOO" `shouldBe` "env://FOO"++ it "preserves keychain:// refs" $+ wrapAsLiteralIfNeeded "keychain://svc/acct" `shouldBe` "keychain://svc/acct"++ it "preserves file:// refs" $+ wrapAsLiteralIfNeeded "file:///etc/token" `shouldBe` "file:///etc/token"++ it "preserves vault:// refs" $+ wrapAsLiteralIfNeeded "vault://secret" `shouldBe` "vault://secret"++ it "wraps a JWT (bare) as literal:<jwt>" $+ wrapAsLiteralIfNeeded "eyJhbGc.eyJzdWIi.sig"+ `shouldBe` "literal:eyJhbGc.eyJzdWIi.sig"++ it "wraps a string containing a scheme-like substring mid-value" $+ -- The heuristic only matches on *prefix*; this is a bare string.+ wrapAsLiteralIfNeeded "xyz env://FOO"+ `shouldBe` "literal:xyz env://FOO"++ describe "decodeJwt" $ do+ it "returns Nothing for a non-JWT bare string" $+ decodeJwt "hello-world" `shouldBe` Nothing++ it "returns Nothing for a 2-segment value (missing signature slot)" $+ decodeJwt "eyJhbGc.eyJzdWIi" `shouldBe` Nothing++ it "returns Nothing when the header has no alg field" $ do+ let h = encodeSeg "{\"typ\":\"JWT\"}"+ p = encodeSeg "{\"sub\":\"x\"}"+ decodeJwt (h <> "." <> p <> ".sig") `shouldBe` Nothing++ it "decodes a minimal well-formed JWT" $ do+ let h = encodeSeg "{\"alg\":\"HS256\",\"kid\":\"k1\"}"+ p = encodeSeg "{\"sub\":\"jdoe\",\"iss\":\"https://idp.example/realms/r\"}"+ jwt = h <> "." <> p <> ".sig"+ case decodeJwt jwt of+ Nothing -> expectationFailure "expected Just, got Nothing"+ Just s -> do+ jsAlg s `shouldBe` Just "HS256"+ jsKid s `shouldBe` Just "k1"+ jsSub s `shouldBe` Just "jdoe"+ jsIss s `shouldBe` Just "https://idp.example/realms/r"++ it "handles aud as an array (first element wins)" $ do+ let h = encodeSeg "{\"alg\":\"RS256\"}"+ p = encodeSeg "{\"aud\":[\"account\",\"app\"]}"+ case decodeJwt (h <> "." <> p <> ".sig") of+ Just s -> jsAud s `shouldBe` Just "account"+ Nothing -> expectationFailure "expected Just"++ it "extracts preferred_username and email" $ do+ let h = encodeSeg "{\"alg\":\"HS256\"}"+ p = encodeSeg "{\"preferred_username\":\"ben\",\"email\":\"ben@x.dev\"}"+ case decodeJwt (h <> "." <> p <> ".sig") of+ Just s -> do+ jsUsername s `shouldBe` Just "ben"+ jsEmail s `shouldBe` Just "ben@x.dev"+ Nothing -> expectationFailure "expected Just"++ it "decodes exp as a UTCTime" $ do+ let h = encodeSeg "{\"alg\":\"HS256\"}"+ p = encodeSeg "{\"exp\":1700000000}"+ case decodeJwt (h <> "." <> p <> ".sig") of+ Just s -> jsExp s `shouldBe`+ Just (posixSecondsToUTCTime 1700000000)+ Nothing -> expectationFailure "expected Just"++ it "tolerates URL-safe base64 with stripped padding" $ do+ -- Construct a header/payload whose un-padded base64url has length+ -- not divisible by 4.+ let headerBs = "{\"alg\":\"HS256\"}" :: BS.ByteString+ payloadBs = "{\"sub\":\"x\"}" :: BS.ByteString+ h = T.dropWhileEnd (== '=') (TE.decodeUtf8 (B64U.encode headerBs))+ p = T.dropWhileEnd (== '=') (TE.decodeUtf8 (B64U.encode payloadBs))+ case decodeJwt (h <> "." <> p <> ".sig") of+ Just s -> jsSub s `shouldBe` Just "x"+ Nothing -> expectationFailure "expected Just"++ describe "renderRelativeExpiry" $ do+ let now = posixSecondsToUTCTime 1_700_000_000++ it "renders 'expired Nm Ns ago' for a recently-expired token" $ do+ let past = addUTCTime (-(60 * 4 + 32)) now -- 4m 32s ago+ renderRelativeExpiry now past `shouldBe` "expired 4m 32s ago"++ it "renders 'valid for Nm Ns' for a near-future token" $ do+ let future = addUTCTime (60 * 4 + 32) now+ renderRelativeExpiry now future `shouldBe` "valid for 4m 32s"++ it "uses days+hours for old tokens" $ do+ let past = addUTCTime (-(16 * 86400 + 4 * 3600)) now+ renderRelativeExpiry now past `shouldBe` "expired 16d 4h ago"++ it "falls back to seconds-only for sub-minute deltas" $ do+ let future = addUTCTime 5 now+ renderRelativeExpiry now future `shouldBe` "valid for 5s"++ it "hours-only when minutes are zero" $ do+ let future = addUTCTime (3 * 3600) now+ renderRelativeExpiry now future `shouldBe` "valid for 3h"++ -- SELF-8: keychain-touching verbs (set-secret, upgrade-to-keychain,+ -- import-token --to-keychain) live behind an opt-in test-suite+ -- (plexus-synapse:keychain-test, gated on the `build-keychain-tests`+ -- cabal flag). They shell out to the `security` CLI on macOS, which+ -- we don't want firing during a default `cabal test` run. The+ -- keychain-unavailable error paths (non-macOS platforms) are verified+ -- there too, so this block is intentionally empty.+ pure ()++ -- ==========================================================================+ -- Integration: round-trip against a tmp HOME+ -- ==========================================================================++ describe "runSelfCommand: integration round-trip" $ do+ it "set -> show-readable -> resolve -> unset -> clear" $+ withTmpHome $ \tmpHome -> do+ let backend = "itest"+ defaultsFile = tmpHome </> ".plexus" </> T.unpack backend+ </> "defaults.json"++ -- 1. Set two values.+ _ <- runSelfCommand+ (CmdSet backend Cookie "access_token" "literal:abc123")+ _ <- runSelfCommand+ (CmdSet backend Header "X-Trace" "env://NONEXISTENT_ENV_SELF4")++ -- The file now exists and decodes round-trip.+ exists <- doesFileExist defaultsFile+ exists `shouldBe` True+ bytes <- BS.readFile defaultsFile+ case decodeDefaults bytes of+ Left err -> expectationFailure ("decode failed: " <> T.unpack err)+ Right sd -> do+ Map.lookup "access_token" (sdCookies sd)+ `shouldBe` Just (CredentialRef "literal:abc123")+ Map.lookup "X-Trace" (sdHeaders sd)+ `shouldBe` Just (CredentialRef "env://NONEXISTENT_ENV_SELF4")++ -- 2. resolve access_token prints the value.+ code <- runSelfCommand (CmdResolve backend Cookie "access_token")+ code `shouldBe` ExitSuccess++ -- 3. unset removes it.+ _ <- runSelfCommand+ (CmdUnset backend Cookie "access_token" True False)+ bytes' <- BS.readFile defaultsFile+ case decodeDefaults bytes' of+ Right sd -> Map.member "access_token" (sdCookies sd) `shouldBe` False+ Left err -> expectationFailure ("decode failed: " <> T.unpack err)++ -- 4. clear --yes deletes the file.+ _ <- runSelfCommand (CmdClear backend True)+ stillThere <- doesFileExist defaultsFile+ stillThere `shouldBe` False++ it "set wraps a bare value as literal: on disk" $+ withTmpHome $ \tmpHome -> do+ let backend = "itest-wrap"+ path = tmpHome </> ".plexus" </> T.unpack backend </> "defaults.json"+ -- Bare JWT-looking value (three dot-separated segments) should+ -- still be wrapped — the prefix heuristic doesn't look like any+ -- known scheme.+ _ <- runSelfCommand+ (CmdSet backend Cookie "t" "eyJhbGc.eyJzdWIi.sig")+ bytes <- BS.readFile path+ case decodeDefaults bytes of+ Right sd -> Map.lookup "t" (sdCookies sd) `shouldBe`+ Just (CredentialRef "literal:eyJhbGc.eyJzdWIi.sig")+ Left err -> expectationFailure ("decode failed: " <> T.unpack err)++ it "clear on a missing file is a no-op (succeeds)" $+ withTmpHome $ \_tmp -> do+ code <- runSelfCommand (CmdClear "never-touched" True)+ code `shouldBe` ExitSuccess++ it "resolve on a missing entry exits non-zero" $+ withTmpHome $ \_tmp -> do+ code <- runSelfCommand (CmdResolve "ghost" Cookie "nope")+ code `shouldBe` ExitFailure 1++ it "unset on a missing entry exits non-zero" $+ withTmpHome $ \_tmp -> do+ code <- runSelfCommand (CmdUnset "ghost" Cookie "nope" True False)+ code `shouldBe` ExitFailure 1++ it "import-token (from file) stores literal:<jwt>" $+ withTmpHome $ \tmpHome -> do+ let backend = "itest-import"+ tokenFile = tmpHome </> "tok.jwt"+ defaultsFile = tmpHome </> ".plexus" </> T.unpack backend+ </> "defaults.json"+ TIO.writeFile tokenFile "eyJhbGc.eyJzdWIi.sig\n"+ _ <- runSelfCommand+ (CmdImportToken backend (T.pack tokenFile) False)+ bytes <- BS.readFile defaultsFile+ case decodeDefaults bytes of+ Right sd -> Map.lookup "access_token" (sdCookies sd) `shouldBe`+ Just (CredentialRef "literal:eyJhbGc.eyJzdWIi.sig")+ Left err -> expectationFailure ("decode failed: " <> T.unpack err)++-- ============================================================================+-- helpers+-- ============================================================================++-- | Run @action@ with @HOME@ set to a fresh temp directory; restore on exit.+withTmpHome :: (FilePath -> IO a) -> IO a+withTmpHome action =+ withSystemTempDirectory "synapse-self4-test-" $ \tmp -> do+ prior <- lookupEnv "HOME"+ bracket_+ (setEnv "HOME" tmp)+ (case prior of+ Just v -> setEnv "HOME" v+ Nothing -> unsetEnv "HOME")+ (action tmp)++-- | Encode a JSON string as URL-safe base64 without padding (JWT style).+encodeSeg :: BS.ByteString -> Text+encodeSeg bs =+ T.dropWhileEnd (== '=') (TE.decodeUtf8 (B64U.encode bs))
+ test/SelfMigrationSpec.hs view
@@ -0,0 +1,260 @@+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE ScopedTypeVariables #-}++-- | Tests for the SELF-3 legacy token migration in 'Synapse.Self.IO':+--+-- * Legacy @~\/.plexus\/tokens\/\<backend\>@ present, no new+-- @defaults.json@ → on 'loadDefaults': legacy removed, new file written+-- with @cookies.access_token = \"literal:\<jwt\>\"@, returned+-- 'StoredDefaults' carries that ref, file mode is @0600@, INFO logged.+-- * Legacy + new both present → legacy removed unread, new file+-- untouched, returned 'StoredDefaults' matches the new file.+-- * Neither file → 'emptyStoredDefaults', no files created.+-- * Empty legacy file → deleted silently, no new file created.+-- * Whitespace-only legacy → deleted silently, no new file created.+-- * INFO log line matches the expected wording (including the+-- \"Consider: synapse _self\" upgrade hint).+--+-- Every test isolates filesystem state via 'withSystemTempDirectory' ++-- a bracketed @setEnv "HOME" tmp@. No OS keychain, no external process+-- invocation. This spec follows the SELF-5 test pattern.+module SelfMigrationSpec (spec) where++import Control.Exception (bracket, bracket_)+import qualified Control.Exception as E+import qualified Data.ByteString as BS+import qualified Data.ByteString.Char8 as BS8+import Data.Bits ((.&.))+import qualified Data.Map.Strict as Map+import Data.Text (Text)+import qualified Data.Text as T+import qualified Data.Text.Encoding as TE+import System.Directory+ ( createDirectoryIfMissing+ , doesDirectoryExist+ , doesFileExist+ )+import System.Environment (lookupEnv, setEnv, unsetEnv)+import System.FilePath ((</>))+import qualified System.Info+import System.IO (IOMode(..), hClose, hFlush, openFile, stderr)+import GHC.IO.Handle (hDuplicate, hDuplicateTo)+import System.IO.Temp (withSystemTempDirectory)+import System.Posix.Files (fileMode, getFileStatus)+import System.Posix.Types (FileMode)+import Test.Hspec++import Synapse.Self+ ( CredentialRef(..)+ , StoredDefaults(..)+ , emptyStoredDefaults+ , encodeDefaults+ , loadDefaults+ )++-- | True on Unix-style platforms where POSIX modes have meaning.+isUnix :: Bool+isUnix = System.Info.os /= "mingw32"++-- | File mode masked to the permission bits (drop file-type bits).+permBits :: FileMode -> FileMode+permBits m = m .&. 0o777++-- | Run @act@ with @HOME@ pointed at @tmp@, restoring the prior @HOME@+-- (or unsetting it if there was none) after.+withHome :: FilePath -> IO a -> IO a+withHome tmp act = do+ prior <- lookupEnv "HOME"+ bracket_+ (setEnv "HOME" tmp)+ (case prior of+ Just v -> setEnv "HOME" v+ Nothing -> unsetEnv "HOME")+ act++-- | Capture whatever is written to stderr during @act@. Writes to a+-- fresh file under the supplied scratch dir. Restores the original+-- stderr — even if @act@ throws — before reading the captured bytes+-- back.+captureStderrInto :: FilePath -> IO a -> IO (a, String)+captureStderrInto scratchDir act = do+ let capPath = scratchDir </> "captured-stderr.txt"+ hFlush stderr+ origStderr <- hDuplicate stderr+ r <- bracket+ (openFile capPath WriteMode)+ (\h -> do+ hFlush stderr+ hDuplicateTo origStderr stderr+ hClose origStderr+ hClose h)+ (\h -> do+ hDuplicateTo h stderr+ act)+ bs <- BS.readFile capPath+ let captured = T.unpack+ (T.pack (map (toEnum . fromIntegral) (BS.unpack bs)))+ pure (r, captured)++-- | Backend name used across these specs. Unique suffix so we never+-- collide with the developer's real @~\/.plexus\/@ tree even if the+-- HOME redirection ever failed.+testBackend :: Text+testBackend = "self3-test-bk"++-- | Place @bytes@ at @tmp/.plexus/tokens/\<backend\>@, creating the+-- parent directory if needed.+placeLegacy :: FilePath -> Text -> BS.ByteString -> IO ()+placeLegacy tmp backend bytes = do+ let tokensDir = tmp </> ".plexus" </> "tokens"+ createDirectoryIfMissing True tokensDir+ BS.writeFile (tokensDir </> T.unpack backend) bytes++-- | Absolute path to the legacy token file under the sandboxed HOME.+legacyPath :: FilePath -> Text -> FilePath+legacyPath tmp backend = tmp </> ".plexus" </> "tokens" </> T.unpack backend++-- | Absolute path to the new defaults.json under the sandboxed HOME.+newPath :: FilePath -> Text -> FilePath+newPath tmp backend =+ tmp </> ".plexus" </> T.unpack backend </> "defaults.json"++-- | True when @needle@ is a substring of @haystack@.+containsSubstring :: String -> String -> Bool+containsSubstring needle haystack =+ T.isInfixOf (T.pack needle) (T.pack haystack)++-- | A realistic-looking JWT for the migrated value (shape only).+sampleJwt :: Text+sampleJwt = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ0ZXN0In0.sig"++spec :: Spec+spec = do+ describe "SELF-3 legacy token migration" $ do++ it "migrates a legacy token to literal: ref and removes legacy" $+ withSystemTempDirectory "synapse-self3" $ \tmp ->+ withHome tmp $ do+ placeLegacy tmp testBackend (TE.encodeUtf8 sampleJwt)+ (loaded, _err) <- captureStderrInto tmp $ loadDefaults testBackend+ -- Legacy removed.+ doesFileExist (legacyPath tmp testBackend) `shouldReturn` False+ -- New file exists with the expected content.+ doesFileExist (newPath tmp testBackend) `shouldReturn` True+ bs <- BS.readFile (newPath tmp testBackend)+ BS8.unpack bs `shouldSatisfy`+ containsSubstring ("literal:" <> T.unpack sampleJwt)+ -- Returned defaults match.+ sdCookies loaded `shouldBe`+ Map.singleton "access_token"+ (CredentialRef ("literal:" <> sampleJwt))+ sdHeaders loaded `shouldBe` Map.empty++ it "trims surrounding whitespace from the legacy token" $+ withSystemTempDirectory "synapse-self3" $ \tmp ->+ withHome tmp $ do+ placeLegacy tmp testBackend+ (TE.encodeUtf8 (" \n" <> sampleJwt <> "\n "))+ (loaded, _err) <- captureStderrInto tmp $ loadDefaults testBackend+ sdCookies loaded `shouldBe`+ Map.singleton "access_token"+ (CredentialRef ("literal:" <> sampleJwt))++ it "writes the migrated defaults.json with mode 0600 (literal: content)" $+ if not isUnix+ then pendingWith "POSIX-only semantics"+ else withSystemTempDirectory "synapse-self3" $ \tmp ->+ withHome tmp $ do+ placeLegacy tmp testBackend (TE.encodeUtf8 sampleJwt)+ _ <- captureStderrInto tmp $ loadDefaults testBackend+ st <- getFileStatus (newPath tmp testBackend)+ permBits (fileMode st) `shouldBe` 0o600++ it "deletes the legacy file unread when the new file already exists" $+ withSystemTempDirectory "synapse-self3" $ \tmp ->+ withHome tmp $ do+ -- Stage an authoritative new defaults.json first.+ let existing = emptyStoredDefaults+ { sdCookies = Map.fromList+ [ ("session", CredentialRef "literal:from-new-file") ]+ }+ newDir = tmp </> ".plexus" </> T.unpack testBackend+ createDirectoryIfMissing True newDir+ BS.writeFile (newPath tmp testBackend) (encodeDefaults existing)+ -- Stage a legacy file too — must be ignored.+ placeLegacy tmp testBackend (TE.encodeUtf8 "ignored-legacy-jwt")++ (loaded, err) <- captureStderrInto tmp $ loadDefaults testBackend+ -- Legacy removed.+ doesFileExist (legacyPath tmp testBackend) `shouldReturn` False+ -- New file preserved byte-for-byte.+ bsAfter <- BS.readFile (newPath tmp testBackend)+ bsAfter `shouldBe` encodeDefaults existing+ -- Returned defaults = new file contents (NOT the legacy jwt).+ loaded `shouldBe` existing+ -- INFO line names the stale legacy file.+ err `shouldSatisfy` containsSubstring "[INFO]"+ err `shouldSatisfy` containsSubstring "stale legacy token file"+ err `shouldSatisfy`+ containsSubstring (legacyPath tmp testBackend)++ it "returns empty defaults and creates no files when neither exists" $+ withSystemTempDirectory "synapse-self3" $ \tmp ->+ withHome tmp $ do+ (loaded, _err) <- captureStderrInto tmp $ loadDefaults testBackend+ loaded `shouldBe` emptyStoredDefaults+ doesFileExist (legacyPath tmp testBackend) `shouldReturn` False+ doesFileExist (newPath tmp testBackend) `shouldReturn` False+ -- Backend subdir is not created on the empty path.+ doesDirectoryExist (tmp </> ".plexus" </> T.unpack testBackend)+ `shouldReturn` False++ it "silently deletes an empty legacy file without creating a new one" $+ withSystemTempDirectory "synapse-self3" $ \tmp ->+ withHome tmp $ do+ placeLegacy tmp testBackend BS.empty+ (loaded, err) <- captureStderrInto tmp $ loadDefaults testBackend+ loaded `shouldBe` emptyStoredDefaults+ doesFileExist (legacyPath tmp testBackend) `shouldReturn` False+ doesFileExist (newPath tmp testBackend) `shouldReturn` False+ -- Silent: no INFO line about migration on an empty file.+ err `shouldNotSatisfy` containsSubstring "migrated legacy"++ it "silently deletes a whitespace-only legacy file without creating a new one" $+ withSystemTempDirectory "synapse-self3" $ \tmp ->+ withHome tmp $ do+ placeLegacy tmp testBackend (BS8.pack " \n\t\n ")+ (loaded, err) <- captureStderrInto tmp $ loadDefaults testBackend+ loaded `shouldBe` emptyStoredDefaults+ doesFileExist (legacyPath tmp testBackend) `shouldReturn` False+ doesFileExist (newPath tmp testBackend) `shouldReturn` False+ err `shouldNotSatisfy` containsSubstring "migrated legacy"++ it "emits an INFO line with the full migration hint wording" $+ withSystemTempDirectory "synapse-self3" $ \tmp ->+ withHome tmp $ do+ placeLegacy tmp testBackend (TE.encodeUtf8 sampleJwt)+ (_loaded, err) <- captureStderrInto tmp $ loadDefaults testBackend+ err `shouldSatisfy` containsSubstring "[INFO]"+ err `shouldSatisfy` containsSubstring "migrated legacy token file"+ err `shouldSatisfy`+ containsSubstring (legacyPath tmp testBackend)+ err `shouldSatisfy`+ containsSubstring (newPath tmp testBackend)+ err `shouldSatisfy` containsSubstring "(stored as literal:)"+ err `shouldSatisfy` containsSubstring "Consider: synapse _self "+ err `shouldSatisfy` containsSubstring (T.unpack testBackend)+ err `shouldSatisfy` containsSubstring "upgrade-to-keychain"++ it "is idempotent: running loadDefaults twice yields the same result" $+ withSystemTempDirectory "synapse-self3" $ \tmp ->+ withHome tmp $ do+ placeLegacy tmp testBackend (TE.encodeUtf8 sampleJwt)+ (loaded1, _err1) <- captureStderrInto tmp $ loadDefaults testBackend+ -- After the first call, the legacy file is gone. The second+ -- call sees only the new defaults.json and returns the same+ -- StoredDefaults. This exercises the "new file absent, legacy+ -- absent" fast path as a follow-up to migration.+ doesFileExist (legacyPath tmp testBackend) `shouldReturn` False+ (loaded2, _err2) <- captureStderrInto tmp $ loadDefaults testBackend+ loaded2 `shouldBe` loaded1
+ test/SelfSafeWriteSpec.hs view
@@ -0,0 +1,301 @@+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE ScopedTypeVariables #-}++-- | Tests for the SELF-5 safe-write path in 'Synapse.Self.IO':+--+-- * Atomic rename via temp file (@.defaults.json.tmp@).+-- * Content-aware chmod (@0600@ with @literal:@ values, @0644@ without).+-- * Parent directory created mode @0700@ when freshly created.+-- * Pre-existing wide parent directory → WARN, dir untouched.+-- * Simulated mid-write failure → temp file cleaned up, no partial+-- @defaults.json@.+-- * 'loadDefaults' permission sniffing: WARN on world-readable files+-- with @literal:@ content; silent on manifest-only files regardless+-- of mode.+--+-- Every test isolates filesystem state via 'withSystemTempDirectory' ++-- a bracketed @setEnv "HOME" tmp@. No OS keychain, no external process+-- invocation. Mode checks are skipped on Windows (POSIX-only semantics).+module SelfSafeWriteSpec (spec) where++import Control.Exception (bracket, bracket_, try)+import qualified Control.Exception as E+import qualified Data.ByteString as BS+import Data.Bits ((.&.))+import qualified Data.Map.Strict as Map+import Data.Text (Text)+import qualified Data.Text as T+import System.Directory+ ( createDirectoryIfMissing+ , doesFileExist+ )+import System.Environment (lookupEnv, setEnv, unsetEnv)+import System.FilePath ((</>))+import qualified System.Info+import System.IO (IOMode(..), hClose, hFlush, openFile, stderr)+import GHC.IO.Handle (hDuplicate, hDuplicateTo)+import System.IO.Temp (withSystemTempDirectory)+import System.Posix.Files (fileMode, getFileStatus, setFileMode)+import System.Posix.Types (FileMode)+import Test.Hspec++import Synapse.Self+ ( CredentialRef(..)+ , StoredDefaults(..)+ , emptyStoredDefaults+ , loadDefaults+ , writeDefaults+ )++-- | True on Unix-style platforms where POSIX modes have meaning.+isUnix :: Bool+isUnix = System.Info.os /= "mingw32"++-- | Run @act@ with @HOME@ pointed at @tmp@, restoring the prior @HOME@+-- (or unsetting it if there was none) after.+withHome :: FilePath -> IO a -> IO a+withHome tmp act = do+ prior <- lookupEnv "HOME"+ bracket_+ (setEnv "HOME" tmp)+ (case prior of+ Just v -> setEnv "HOME" v+ Nothing -> unsetEnv "HOME")+ act++-- | Capture whatever is written to stderr during @act@. Writes to a+-- fresh file under the supplied scratch dir (caller threads one in so+-- capture output stays inside the test\'s temp-dir sandbox). Restores+-- the original stderr — even if @act@ throws — before reading the+-- captured bytes back.+captureStderrInto :: FilePath -> IO a -> IO (a, String)+captureStderrInto scratchDir act = do+ let capPath = scratchDir </> "captured-stderr.txt"+ hFlush stderr+ origStderr <- hDuplicate stderr+ r <- bracket+ (openFile capPath WriteMode)+ (\h -> do+ hFlush stderr+ hDuplicateTo origStderr stderr+ hClose origStderr+ hClose h)+ (\h -> do+ hDuplicateTo h stderr+ act)+ bs <- BS.readFile capPath+ let captured = T.unpack+ (T.pack (map (toEnum . fromIntegral) (BS.unpack bs)))+ pure (r, captured)++-- | Backend name used across these specs — unique suffix so we never+-- collide with the developer\'s real @~\/.plexus\/@ tree even if the+-- HOME redirection ever failed.+testBackend :: Text+testBackend = "self5-test-bk"++-- | File mode masked to the permission bits (drop file-type bits).+permBits :: FileMode -> FileMode+permBits m = m .&. 0o777++spec :: Spec+spec = do+ describe "SELF-5 safe write" $ do++ -- ========================================================================+ -- writeDefaults: mode-aware chmod+ -- ========================================================================+ describe "chmod policy" $ do+ it "sets 0600 when any value starts with literal:" $+ withSystemTempDirectory "synapse-self5" $ \tmp ->+ withHome tmp $ do+ let sd = emptyStoredDefaults+ { sdCookies = Map.fromList+ [ ("access_token", CredentialRef "literal:secret-token") ]+ }+ writeDefaults testBackend sd+ let path = tmp </> ".plexus" </> T.unpack testBackend </> "defaults.json"+ exists <- doesFileExist path+ exists `shouldBe` True+ if isUnix+ then do+ st <- getFileStatus path+ permBits (fileMode st) `shouldBe` 0o600+ else pure ()++ it "sets 0644 when values are only non-literal refs" $+ withSystemTempDirectory "synapse-self5" $ \tmp ->+ withHome tmp $ do+ let sd = emptyStoredDefaults+ { sdCookies = Map.fromList+ [ ("access_token", CredentialRef "keychain://svc/tok") ]+ , sdHeaders = Map.fromList+ [ ("X-Key", CredentialRef "env://API_KEY") ]+ }+ writeDefaults testBackend sd+ let path = tmp </> ".plexus" </> T.unpack testBackend </> "defaults.json"+ if isUnix+ then do+ st <- getFileStatus path+ permBits (fileMode st) `shouldBe` 0o644+ else pure ()++ it "sets 0600 when any one of several refs is literal" $+ withSystemTempDirectory "synapse-self5" $ \tmp ->+ withHome tmp $ do+ let sd = emptyStoredDefaults+ { sdCookies = Map.fromList+ [ ("session", CredentialRef "keychain://svc/session")+ , ("backup", CredentialRef "literal:emergency-value")+ ]+ }+ writeDefaults testBackend sd+ let path = tmp </> ".plexus" </> T.unpack testBackend </> "defaults.json"+ if isUnix+ then do+ st <- getFileStatus path+ permBits (fileMode st) `shouldBe` 0o600+ else pure ()++ -- ========================================================================+ -- writeDefaults: parent directory mode+ -- ========================================================================+ describe "parent directory" $ do+ it "creates a fresh parent directory with mode 0700" $+ withSystemTempDirectory "synapse-self5" $ \tmp ->+ withHome tmp $ do+ writeDefaults testBackend emptyStoredDefaults+ let dir = tmp </> ".plexus" </> T.unpack testBackend+ if isUnix+ then do+ st <- getFileStatus dir+ permBits (fileMode st) `shouldBe` 0o700+ else pure ()++ it "leaves an existing wide parent directory untouched and WARNs" $+ if not isUnix+ then pendingWith "POSIX-only semantics"+ else withSystemTempDirectory "synapse-self5" $ \tmp ->+ withHome tmp $ do+ let dir = tmp </> ".plexus" </> T.unpack testBackend+ createDirectoryIfMissing True dir+ setFileMode dir 0o755 -- intentionally wide+ (_, err) <- captureStderrInto tmp $ writeDefaults testBackend emptyStoredDefaults+ st <- getFileStatus dir+ -- Mode untouched.+ permBits (fileMode st) `shouldBe` 0o755+ -- WARN emitted.+ err `shouldSatisfy` containsSubstring "[WARN]"+ err `shouldSatisfy` containsSubstring dir++ -- ========================================================================+ -- writeDefaults: atomicity+ -- ========================================================================+ describe "atomicity" $ do+ it "leaves no temp file and no partial defaults.json when the write fails" $+ if not isUnix+ then pendingWith "needs POSIX mode to stage the failure"+ else withSystemTempDirectory "synapse-self5" $ \tmp ->+ withHome tmp $ do+ let dir = tmp </> ".plexus" </> T.unpack testBackend+ tmpFile = dir </> ".defaults.json.tmp"+ path = dir </> "defaults.json"+ createDirectoryIfMissing True dir+ -- Make the parent dir read-only so writeFile to the tmp+ -- path fails. Restore 0700 in the cleanup so the temp+ -- directory teardown itself succeeds.+ bracket_+ (setFileMode dir 0o500)+ (setFileMode dir 0o700)+ $ do+ let sd = emptyStoredDefaults+ { sdCookies = Map.fromList+ [ ("t", CredentialRef "literal:foo") ]+ }+ r <- try (writeDefaults testBackend sd)+ :: IO (Either E.SomeException ())+ case r of+ Left _ -> pure () -- expected: write failed+ Right _ -> expectationFailure+ "writeDefaults should have failed on a read-only parent dir"+ -- Neither artifact should exist after the failure.+ doesFileExist tmpFile `shouldReturn` False+ doesFileExist path `shouldReturn` False++ it "rename is atomic: final file appears only after a complete write" $+ withSystemTempDirectory "synapse-self5" $ \tmp ->+ withHome tmp $ do+ let sd = emptyStoredDefaults+ { sdCookies = Map.fromList+ [ ("t", CredentialRef "literal:value") ]+ }+ dir = tmp </> ".plexus" </> T.unpack testBackend+ tmpFile = dir </> ".defaults.json.tmp"+ path = dir </> "defaults.json"+ writeDefaults testBackend sd+ -- After a successful write, the temp file is gone and the+ -- final file exists.+ doesFileExist tmpFile `shouldReturn` False+ doesFileExist path `shouldReturn` True++ -- ========================================================================+ -- loadDefaults: permission sniffing+ -- ========================================================================+ describe "loadDefaults permission sniffing" $ do+ it "WARNs on world-readable file with literal: content" $+ if not isUnix+ then pendingWith "POSIX-only semantics"+ else withSystemTempDirectory "synapse-self5" $ \tmp ->+ withHome tmp $ do+ let sd = emptyStoredDefaults+ { sdCookies = Map.fromList+ [ ("t", CredentialRef "literal:oops-wide-open") ]+ }+ path = tmp </> ".plexus" </> T.unpack testBackend </> "defaults.json"+ writeDefaults testBackend sd+ -- Intentionally loosen the file to simulate a user who+ -- chmod-ed it by hand.+ setFileMode path 0o644+ (loaded, err) <- captureStderrInto tmp $ loadDefaults testBackend+ loaded `shouldBe` sd+ err `shouldSatisfy` containsSubstring "[WARN]"+ err `shouldSatisfy` containsSubstring path+ err `shouldSatisfy` containsSubstring "chmod 600"++ it "does NOT WARN on world-readable file with only non-literal refs" $+ if not isUnix+ then pendingWith "POSIX-only semantics"+ else withSystemTempDirectory "synapse-self5" $ \tmp ->+ withHome tmp $ do+ let sd = emptyStoredDefaults+ { sdCookies = Map.fromList+ [ ("t", CredentialRef "keychain://svc/token") ]+ , sdHeaders = Map.fromList+ [ ("X", CredentialRef "env://API_KEY") ]+ }+ path = tmp </> ".plexus" </> T.unpack testBackend </> "defaults.json"+ writeDefaults testBackend sd+ setFileMode path 0o644 -- wide, but file is manifest-only+ (loaded, err) <- captureStderrInto tmp $ loadDefaults testBackend+ loaded `shouldBe` sd+ err `shouldNotSatisfy` containsSubstring "[WARN]"++ it "does NOT WARN on a tight (0600) file with literal: content" $+ if not isUnix+ then pendingWith "POSIX-only semantics"+ else withSystemTempDirectory "synapse-self5" $ \tmp ->+ withHome tmp $ do+ let sd = emptyStoredDefaults+ { sdCookies = Map.fromList+ [ ("t", CredentialRef "literal:tight-and-tidy") ]+ }+ writeDefaults testBackend sd+ -- writeDefaults already set 0600 on this file.+ (loaded, err) <- captureStderrInto tmp $ loadDefaults testBackend+ loaded `shouldBe` sd+ err `shouldNotSatisfy` containsSubstring "[WARN]"++-- | True when @needle@ is a substring of @haystack@.+containsSubstring :: String -> String -> Bool+containsSubstring needle haystack =+ T.isInfixOf (T.pack needle) (T.pack haystack)
+ test/SelfSpec.hs view
@@ -0,0 +1,629 @@+{-# LANGUAGE OverloadedStrings #-}++-- | Unit tests for Synapse.Self (SELF-1, SELF-2, SELF-7).+--+-- Covers:+-- - StoredDefaults roundtrip encode/decode with mixed URI schemes+-- - Version mismatch rejection+-- - Unknown top-level field tolerance (forward compatibility)+-- - Missing "defaults" object treated as empty+-- - parseUri shape coverage (literal, env, keychain, file, bare-string reject)+-- - Deterministic key ordering from encodeDefaults+-- - Empty resolver registry returns ResolveUnknownScheme+-- - SELF-7: literalResolver, envResolver, fileResolver, defaultRegistry+-- - SELF-2: loadDefaults / resolveAll / merge+module Main where++import Control.Exception (bracket, bracket_, finally, try)+import qualified Control.Exception as E+import qualified Data.ByteString as BS+import qualified Data.ByteString.Char8 as BS8+import qualified Data.Map.Strict as Map+import Data.Text (Text)+import qualified Data.Text as T+import qualified Data.Text.Encoding as TE+import qualified Data.Text.IO as TIO+import System.Directory+ ( createDirectoryIfMissing+ , getHomeDirectory+ , getTemporaryDirectory+ , removeFile+ , removePathForcibly+ )+import System.Environment (lookupEnv, setEnv, unsetEnv)+import System.FilePath ((</>))+import System.IO (hClose, openTempFile)+import qualified System.IO as IO+import Test.Hspec++import Synapse.Self+import qualified SelfCommandSpec+import qualified SelfMigrationSpec+import qualified SelfSafeWriteSpec++main :: IO ()+main = hspec $ do+ describe "Synapse.Self.Command (SELF-4)" SelfCommandSpec.spec+ SelfSafeWriteSpec.spec+ SelfMigrationSpec.spec++ describe "defaultsPath" $ do+ it "returns ~/.plexus/<backend>/defaults.json" $ do+ defaultsPath "substrate" `shouldBe` "~/.plexus/substrate/defaults.json"++ it "handles backends with dashes" $ do+ defaultsPath "my-backend" `shouldBe` "~/.plexus/my-backend/defaults.json"++ describe "parseUri" $ do+ it "parses literal: as opaque body" $ do+ parseUri (CredentialRef "literal:abc123") `shouldBe`+ Right (ParsedUri "literal" (OpaqueBody "abc123"))++ it "preserves colons in opaque bodies (e.g. JWTs)" $ do+ parseUri (CredentialRef "literal:eyJhbGciOi.JJWT.sig") `shouldBe`+ Right (ParsedUri "literal" (OpaqueBody "eyJhbGciOi.JJWT.sig"))++ it "parses env://VAR" $ do+ parseUri (CredentialRef "env://USCIS_API_KEY") `shouldBe`+ Right (ParsedUri "env" (HierarchicalBody "USCIS_API_KEY" "" []))++ it "parses keychain://service/account" $ do+ parseUri (CredentialRef "keychain://uscis/access_token") `shouldBe`+ Right (ParsedUri "keychain" (HierarchicalBody "uscis" "/access_token" []))++ it "parses file:///abs/path" $ do+ parseUri (CredentialRef "file:///etc/plexus/token") `shouldBe`+ Right (ParsedUri "file" (HierarchicalBody "" "/etc/plexus/token" []))++ it "parses hierarchical URIs with query strings" $ do+ parseUri (CredentialRef "env://FOO?fallback=bar&retry=3") `shouldBe`+ Right (ParsedUri "env"+ (HierarchicalBody "FOO" "" [("fallback", "bar"), ("retry", "3")]))++ it "rejects bare strings with no scheme" $ do+ case parseUri (CredentialRef "abc123") of+ Left _ -> pure ()+ Right p -> expectationFailure ("expected Left, got Right " <> show p)++ it "rejects empty scheme" $ do+ case parseUri (CredentialRef ":foo") of+ Left _ -> pure ()+ Right p -> expectationFailure ("expected Left, got Right " <> show p)++ describe "encodeDefaults / decodeDefaults" $ do+ let mixed = StoredDefaults+ { sdVersion = 1+ , sdCookies = Map.fromList+ [ ("access_token", CredentialRef "keychain://uscis/access_token")+ , ("session", CredentialRef "literal:raw-session-cookie")+ ]+ , sdHeaders = Map.fromList+ [ ("X-Trace-Id", CredentialRef "literal:abc123")+ , ("X-API-Key", CredentialRef "env://USCIS_API_KEY")+ , ("X-Token", CredentialRef "file:///etc/plexus/token")+ ]+ , sdScopes = Map.empty+ }++ it "roundtrips a file with mixed URI schemes" $ do+ let encoded = encodeDefaults mixed+ decodeDefaults encoded `shouldBe` Right mixed++ it "produces deterministic output across calls" $ do+ encodeDefaults mixed `shouldBe` encodeDefaults mixed++ it "uses two-space indentation" $ do+ let encoded = TE.decodeUtf8 (encodeDefaults mixed)+ -- Nested fields should be indented with exactly 2 spaces per level.+ -- We check that no tab characters appear and that indented lines+ -- begin with 2-space multiples.+ T.any (== '\t') encoded `shouldBe` False+ let firstIndent = dropWhile (/= '\n') (T.unpack encoded)+ take 3 (drop 1 firstIndent) `shouldBe` " \""++ it "orders keys deterministically (ascending)" $ do+ let encoded = TE.decodeUtf8 (encodeDefaults mixed)+ -- "cookies" should appear before "headers", and within cookies+ -- "access_token" before "session".+ let cookiesIx = T.breakOn "cookies" encoded+ headersIx = T.breakOn "headers" encoded+ accessIx = T.breakOn "access_token" encoded+ sessionIx = T.breakOn "session" encoded+ T.length (fst cookiesIx) `shouldSatisfy` (< T.length (fst headersIx))+ T.length (fst accessIx) `shouldSatisfy` (< T.length (fst sessionIx))++ it "treats missing defaults object as empty" $ do+ let raw = BS8.pack "{\"version\": 1}"+ case decodeDefaults raw of+ Right sd -> do+ sdVersion sd `shouldBe` 1+ sdCookies sd `shouldBe` Map.empty+ sdHeaders sd `shouldBe` Map.empty+ sdScopes sd `shouldBe` Map.empty+ Left err -> expectationFailure ("unexpected error: " <> T.unpack err)++ it "tolerates unknown top-level fields (forward compat)" $ do+ let raw = BS8.pack+ "{\"version\": 1, \"future_feature\": {\"some\": \"thing\"}, \+ \\"defaults\": {\"cookies\": {\"c\": \"literal:v\"}}}"+ case decodeDefaults raw of+ Right sd ->+ Map.lookup "c" (sdCookies sd) `shouldBe` Just (CredentialRef "literal:v")+ Left err -> expectationFailure ("unexpected error: " <> T.unpack err)++ it "tolerates unknown fields inside the defaults object" $ do+ let raw = BS8.pack+ "{\"version\": 1, \"defaults\": \+ \{\"cookies\": {}, \"headers\": {}, \"bonus\": {}}}"+ decodeDefaults raw `shouldSatisfy` isRight++ it "rejects unknown versions with structured message" $ do+ let raw = BS8.pack "{\"version\": 2, \"defaults\": {}}"+ decodeDefaults raw `shouldBe` Left "unsupported version: 2, expected 1"++ it "rejects version 0 with structured message" $ do+ let raw = BS8.pack "{\"version\": 0}"+ decodeDefaults raw `shouldBe` Left "unsupported version: 0, expected 1"++ it "decodes scopes even though v1 ignores them semantically" $ do+ let raw = BS8.pack+ "{\"version\": 1, \"scopes\": {\"cone\": {\"cookies\": {\"x\": \"literal:v\"}}}}"+ case decodeDefaults raw of+ Right sd -> do+ case Map.lookup "cone" (sdScopes sd) of+ Just scoped ->+ Map.lookup "x" (sdsCookies scoped) `shouldBe` Just (CredentialRef "literal:v")+ Nothing -> expectationFailure "expected cone scope"+ Left err -> expectationFailure ("unexpected error: " <> T.unpack err)++ describe "ResolverRegistry" $ do+ it "mempty returns ResolveUnknownScheme for any scheme" $ do+ let reg = mempty :: ResolverRegistry+ result <- resolveRef reg (CredentialRef "literal:foo")+ result `shouldBe` Left (ResolveUnknownScheme "literal")++ it "mempty reports the correct scheme name on lookup failure" $ do+ let reg = mempty :: ResolverRegistry+ result <- resolveRef reg (CredentialRef "keychain://svc/account")+ result `shouldBe` Left (ResolveUnknownScheme "keychain")++ it "surfaces parse errors via ResolveParseError" $ do+ let reg = mempty :: ResolverRegistry+ result <- resolveRef reg (CredentialRef "no-scheme-here")+ case result of+ Left (ResolveParseError _) -> pure ()+ other -> expectationFailure ("expected ResolveParseError, got " <> show other)++ it "dispatches to a registered resolver" $ do+ let reg = registerResolver "literal"+ (\p -> pure $ Right $ case puBody p of+ OpaqueBody v -> v+ _ -> "unexpected-body")+ mempty+ result <- resolveRef reg (CredentialRef "literal:hello")+ result `shouldBe` Right "hello"++ it "later registrations override earlier ones" $ do+ let reg = registerResolver "s" (\_ -> pure (Right "second"))+ $ registerResolver "s" (\_ -> pure (Right "first"))+ mempty+ result <- resolveRef reg (CredentialRef "s://anything")+ result `shouldBe` Right "second"++ -- ==========================================================================+ -- SELF-7: core resolvers+ -- ==========================================================================++ describe "literalResolver" $ do+ let reg = registerResolver "literal" literalResolver mempty++ it "returns the body verbatim for literal:foo" $ do+ result <- resolveRef reg (CredentialRef "literal:foo")+ result `shouldBe` Right "foo"++ it "returns empty string for literal: (empty body)" $ do+ result <- resolveRef reg (CredentialRef "literal:")+ result `shouldBe` Right ""++ it "preserves leading/trailing spaces (literal: foo )" $ do+ result <- resolveRef reg (CredentialRef "literal: foo ")+ result `shouldBe` Right " foo "++ it "preserves interior colons (JWT-style)" $ do+ result <- resolveRef reg (CredentialRef "literal:eyJhbGciOi.JJWT.sig")+ result `shouldBe` Right "eyJhbGciOi.JJWT.sig"++ it "rejects hierarchical literal:// as backend error" $ do+ result <- resolveRef reg (CredentialRef "literal://foo")+ case result of+ Left (ResolveBackendError _ _) -> pure ()+ other -> expectationFailure+ ("expected ResolveBackendError, got " <> show other)++ describe "envResolver" $ do+ let reg = registerResolver "env" envResolver mempty+ testVar = "SYNAPSE_SELF_TEST_VAR_7F3A"++ it "returns the variable value when set" $+ withEnv testVar "hello-env" $ do+ result <- resolveRef reg (CredentialRef ("env://" <> T.pack testVar))+ result `shouldBe` Right "hello-env"++ it "preserves whitespace inside variable values" $+ withEnv testVar " spaced " $ do+ result <- resolveRef reg (CredentialRef ("env://" <> T.pack testVar))+ result `shouldBe` Right " spaced "++ it "reports ResolveNotFound when the variable is unset" $+ withoutEnv testVar $ do+ result <- resolveRef reg (CredentialRef ("env://" <> T.pack testVar))+ result `shouldBe` Left+ (ResolveNotFound (CredentialRef ("env://" <> T.pack testVar)))++ it "reports ResolveBackendError for env:// with no variable name" $ do+ result <- resolveRef reg (CredentialRef "env://")+ case result of+ Left (ResolveBackendError _ _) -> pure ()+ other -> expectationFailure+ ("expected ResolveBackendError, got " <> show other)++ it "reports ResolveBackendError for opaque env:VAR" $ do+ result <- resolveRef reg (CredentialRef "env:FOO")+ case result of+ Left (ResolveBackendError _ _) -> pure ()+ other -> expectationFailure+ ("expected ResolveBackendError, got " <> show other)++ describe "fileResolver" $ do+ let reg = registerResolver "file" fileResolver mempty++ it "reads file contents and strips a single trailing newline" $+ withTempFileContents "hello\n" $ \path -> do+ result <- resolveRef reg+ (CredentialRef ("file://" <> T.pack path))+ result `shouldBe` Right "hello"++ it "preserves interior newlines and whitespace" $+ withTempFileContents "a\n b \nc\n" $ \path -> do+ result <- resolveRef reg+ (CredentialRef ("file://" <> T.pack path))+ result `shouldBe` Right "a\n b \nc"++ it "returns contents verbatim when there is no trailing newline" $+ withTempFileContents "no-newline" $ \path -> do+ result <- resolveRef reg+ (CredentialRef ("file://" <> T.pack path))+ result `shouldBe` Right "no-newline"++ it "strips only one trailing newline (leaves the rest)" $+ withTempFileContents "two\n\n" $ \path -> do+ result <- resolveRef reg+ (CredentialRef ("file://" <> T.pack path))+ result `shouldBe` Right "two\n"++ it "returns ResolveNotFound for a missing file" $ do+ tmp <- getTemporaryDirectory+ let path = tmp </> "synapse-self-does-not-exist-7f3a"+ ref = CredentialRef ("file://" <> T.pack path)+ result <- resolveRef reg ref+ result `shouldBe` Left (ResolveNotFound ref)++ it "returns ResolveBackendError when the path is a directory" $ do+ tmp <- getTemporaryDirectory+ result <- resolveRef reg+ (CredentialRef ("file://" <> T.pack tmp))+ case result of+ Left (ResolveBackendError _ _) -> pure ()+ other -> expectationFailure+ ("expected ResolveBackendError, got " <> show other)++ it "expands ~ to the user's home directory" $ do+ home <- getHomeDirectory+ -- Write a tmp file directly under $HOME so the ~ expansion test+ -- doesn't depend on any specific subdir layout.+ let name = ".synapse-self-test-7f3a"+ path = home </> name+ bracket_+ (TIO.writeFile path "tilde-ok\n")+ (removePathForcibly path)+ $ do+ result <- resolveRef reg+ (CredentialRef (T.pack ("file://~/" <> name)))+ result `shouldBe` Right "tilde-ok"++ it "reports ResolveBackendError for opaque file:path" $ do+ result <- resolveRef reg (CredentialRef "file:/tmp/x")+ case result of+ Left (ResolveBackendError _ _) -> pure ()+ other -> expectationFailure+ ("expected ResolveBackendError, got " <> show other)++ describe "defaultRegistry" $ do+ it "dispatches literal: via the default registry" $ do+ result <- resolveRef defaultRegistry (CredentialRef "literal:abc")+ result `shouldBe` Right "abc"++ it "dispatches env:// via the default registry" $+ withEnv "SYNAPSE_SELF_TEST_DEFAULT_REG" "reg-ok" $ do+ result <- resolveRef defaultRegistry+ (CredentialRef "env://SYNAPSE_SELF_TEST_DEFAULT_REG")+ result `shouldBe` Right "reg-ok"++ it "dispatches file:// via the default registry" $+ withTempFileContents "file-ok\n" $ \path -> do+ result <- resolveRef defaultRegistry+ (CredentialRef ("file://" <> T.pack path))+ result `shouldBe` Right "file-ok"++ it "returns ResolveUnknownScheme for unregistered schemes" $ do+ result <- resolveRef defaultRegistry+ (CredentialRef "vault://svc/account")+ result `shouldBe` Left (ResolveUnknownScheme "vault")++ it "end-to-end: resolves a StoredDefaults with one entry per scheme" $+ withEnv "SYNAPSE_SELF_TEST_E2E_ENV" "from-env" $+ withTempFileContents "from-file\n" $ \path -> do+ let sd = StoredDefaults+ { sdVersion = 1+ , sdCookies = Map.empty+ , sdHeaders = Map.fromList+ [ ("X-Literal", CredentialRef "literal:from-literal")+ , ("X-Env", CredentialRef "env://SYNAPSE_SELF_TEST_E2E_ENV")+ , ("X-File", CredentialRef ("file://" <> T.pack path))+ ]+ , sdScopes = Map.empty+ }+ -- Resolve every header ref end-to-end via defaultRegistry.+ resolved <- traverse (resolveRef defaultRegistry) (sdHeaders sd)+ Map.lookup "X-Literal" resolved `shouldBe` Just (Right "from-literal")+ Map.lookup "X-Env" resolved `shouldBe` Just (Right "from-env")+ Map.lookup "X-File" resolved `shouldBe` Just (Right "from-file")++ -- ==========================================================================+ -- SELF-2: loadDefaults / resolveAll / merge+ -- ==========================================================================++ describe "loadDefaults" $ do+ it "returns emptyStoredDefaults when the file is absent" $+ withBackendDefaults "synapse-self-test-missing" Nothing $ \_ -> do+ sd <- loadDefaults "synapse-self-test-missing"+ sd `shouldBe` emptyStoredDefaults++ it "reads and decodes a well-formed defaults.json" $ do+ let sd = StoredDefaults+ { sdVersion = 1+ , sdCookies = Map.fromList+ [ ("access_token", CredentialRef "literal:jwt-abc") ]+ , sdHeaders = Map.fromList+ [ ("X-Trace-Id", CredentialRef "literal:trace-123") ]+ , sdScopes = Map.empty+ }+ withBackendDefaults "synapse-self-test-ok" (Just (encodeDefaults sd))+ $ \_ -> do+ loaded <- loadDefaults "synapse-self-test-ok"+ loaded `shouldBe` sd++ it "raises an IOError naming the file path on malformed JSON" $ do+ withBackendDefaults "synapse-self-test-bad" (Just "not json at all")+ $ \path -> do+ result <- try (loadDefaults "synapse-self-test-bad")+ :: IO (Either IOError StoredDefaults)+ case result of+ Left ioErr -> do+ let msg = show ioErr+ msg `shouldSatisfy` containsSubstring path+ -- A decoder message should appear somewhere — either the+ -- aeson error text or our own "failed to parse" prefix.+ msg `shouldSatisfy` containsSubstring "parse"+ Right sd ->+ expectationFailure+ ("expected IOError, got successful parse: " <> show sd)++ it "raises an IOError on unknown version" $ do+ let badVersion = BS8.pack "{\"version\": 99, \"defaults\": {}}"+ withBackendDefaults "synapse-self-test-ver" (Just badVersion) $ \_ -> do+ result <- try (loadDefaults "synapse-self-test-ver")+ :: IO (Either IOError StoredDefaults)+ case result of+ Left ioErr ->+ show ioErr `shouldSatisfy` containsSubstring "unsupported version"+ Right sd ->+ expectationFailure+ ("expected IOError, got: " <> show sd)++ describe "resolveAll" $ do+ let testMethod = ["foo", "bar"] :: MethodPath++ it "returns empty resolved defaults for empty stored defaults" $ do+ r <- resolveAll defaultRegistry emptyStoredDefaults testMethod+ r `shouldBe` Right emptyResolvedDefaults++ it "resolves literal-only cookies and headers end-to-end" $ do+ let sd = StoredDefaults+ { sdVersion = 1+ , sdCookies = Map.fromList+ [ ("access_token", CredentialRef "literal:abc")+ , ("session", CredentialRef "literal:xyz")+ ]+ , sdHeaders = Map.fromList+ [ ("X-Trace", CredentialRef "literal:t-1") ]+ , sdScopes = Map.empty+ }+ r <- resolveAll defaultRegistry sd testMethod+ case r of+ Right rd -> do+ rdCookies rd `shouldBe` Map.fromList+ [("access_token", "abc"), ("session", "xyz")]+ rdHeaders rd `shouldBe` Map.fromList [("X-Trace", "t-1")]+ Left err -> expectationFailure ("expected Right, got " <> show err)++ it "short-circuits on the first unknown scheme" $ do+ -- Use 'vault' — not registered in defaultRegistry (reserved for+ -- a future resolver). The test asserts that resolveAll bails on+ -- the first unknown scheme and does not attempt subsequent refs.+ let sd = StoredDefaults+ { sdVersion = 1+ , sdCookies = Map.fromList+ [ ("access_token", CredentialRef "vault://svc/token")+ , ("session", CredentialRef "literal:should-not-leak")+ ]+ , sdHeaders = Map.empty+ , sdScopes = Map.empty+ }+ r <- resolveAll defaultRegistry sd testMethod+ r `shouldBe` Left (ResolveUnknownScheme "vault")++ it "reports ResolveNotFound for env:// with an unset variable" $+ withoutEnv "SYNAPSE_SELF_TEST_MISSING_VAR_2B" $ do+ let ref = CredentialRef "env://SYNAPSE_SELF_TEST_MISSING_VAR_2B"+ sd = StoredDefaults+ { sdVersion = 1+ , sdCookies = Map.empty+ , sdHeaders = Map.fromList [("X-K", ref)]+ , sdScopes = Map.empty+ }+ r <- resolveAll defaultRegistry sd testMethod+ case r of+ Left (ResolveNotFound (CredentialRef uri)) ->+ uri `shouldSatisfy` T.isInfixOf "SYNAPSE_SELF_TEST_MISSING_VAR_2B"+ other ->+ expectationFailure ("expected ResolveNotFound, got " <> show other)++ it "does not partial-resolve: failure in cookies discards header refs" $+ withoutEnv "SYNAPSE_SELF_TEST_MISSING_VAR_2C" $ do+ let sd = StoredDefaults+ { sdVersion = 1+ , sdCookies = Map.fromList+ [ ("c", CredentialRef "env://SYNAPSE_SELF_TEST_MISSING_VAR_2C") ]+ , sdHeaders = Map.fromList+ [ ("H", CredentialRef "literal:header-value") ]+ , sdScopes = Map.empty+ }+ r <- resolveAll defaultRegistry sd testMethod+ case r of+ Left _ -> pure ()+ Right v -> expectationFailure+ ("expected Left, got Right " <> show v)++ it "threads MethodPath (v1 ignores it; any path resolves equivalently)" $ do+ let sd = StoredDefaults+ { sdVersion = 1+ , sdCookies = Map.fromList [("k", CredentialRef "literal:v")]+ , sdHeaders = Map.empty+ , sdScopes = Map.empty+ }+ r1 <- resolveAll defaultRegistry sd []+ r2 <- resolveAll defaultRegistry sd ["some", "method"]+ r1 `shouldBe` r2++ describe "merge" $ do+ it "is identity when CLI maps are empty" $ do+ let rd = ResolvedDefaults+ { rdCookies = Map.fromList [("a", "1"), ("b", "2")]+ , rdHeaders = Map.fromList [("X", "x")]+ }+ merge rd Map.empty Map.empty `shouldBe`+ (rdCookies rd, rdHeaders rd)++ it "passes CLI values through when defaults are empty" $ do+ let cliC = Map.fromList [("c", "v")]+ cliH = Map.fromList [("H", "hv")]+ merge emptyResolvedDefaults cliC cliH `shouldBe` (cliC, cliH)++ it "gives CLI priority over stored defaults on per-key conflict" $ do+ let rd = ResolvedDefaults+ { rdCookies = Map.fromList+ [ ("access_token", "from-stored")+ , ("other", "stored-only")+ ]+ , rdHeaders = Map.fromList [("X-Trace", "stored-trace")]+ }+ cliC = Map.fromList [("access_token", "from-cli")]+ cliH = Map.fromList [("X-Trace", "cli-trace")]+ (mergedC, mergedH) = merge rd cliC cliH+ Map.lookup "access_token" mergedC `shouldBe` Just "from-cli"+ Map.lookup "other" mergedC `shouldBe` Just "stored-only"+ Map.lookup "X-Trace" mergedH `shouldBe` Just "cli-trace"++ it "unions disjoint key sets without loss" $ do+ let rd = ResolvedDefaults+ { rdCookies = Map.fromList [("a", "1")]+ , rdHeaders = Map.fromList [("X", "x")]+ }+ cliC = Map.fromList [("b", "2")]+ cliH = Map.fromList [("Y", "y")]+ (mergedC, mergedH) = merge rd cliC cliH+ mergedC `shouldBe` Map.fromList [("a", "1"), ("b", "2")]+ mergedH `shouldBe` Map.fromList [("X", "x"), ("Y", "y")]++-- | Set an env var for the duration of an action, restoring any prior+-- value (or unsetting if it was unset).+withEnv :: String -> String -> IO a -> IO a+withEnv name value action = do+ prior <- lookupEnv name+ bracket_+ (setEnv name value)+ (case prior of+ Just v -> setEnv name v+ Nothing -> unsetEnv name)+ action++-- | Ensure an env var is unset for the duration of an action, restoring+-- it afterwards.+withoutEnv :: String -> IO a -> IO a+withoutEnv name action = do+ prior <- lookupEnv name+ bracket_+ (unsetEnv name)+ (case prior of+ Just v -> setEnv name v+ Nothing -> pure ())+ action++-- | Write @contents@ to a fresh temp file, pass its path to @action@,+-- and unlink the file on the way out.+withTempFileContents :: Text -> (FilePath -> IO a) -> IO a+withTempFileContents contents action = do+ tmpDir <- getTemporaryDirectory+ bracket+ (openTempFile tmpDir "synapse-self-test.txt")+ (\(path, h) -> hClose h `finally` removeFile path)+ (\(path, h) -> do+ IO.hSetEncoding h IO.utf8+ TIO.hPutStr h contents+ hClose h+ action path)++isRight :: Either a b -> Bool+isRight (Right _) = True+isRight _ = False++-- | True when the 'show'n error message contains the given substring.+containsSubstring :: String -> String -> Bool+containsSubstring needle = T.isInfixOf (T.pack needle) . T.pack++-- | Stage @~\/.plexus\/\<backend\>\/defaults.json@ with the given bytes+-- (or clear it) for the duration of the action, then restore.+--+-- We write a unique-per-test backend subdir so parallel tests don't+-- collide and so we never perturb a real backend's defaults. The action+-- receives the absolute file path so it can assert against it.+withBackendDefaults :: String -> Maybe BS.ByteString -> (String -> IO a) -> IO a+withBackendDefaults backend mBytes action = do+ home <- getHomeDirectory+ let dir = home </> ".plexus" </> backend+ path = dir </> "defaults.json"+ createDirectoryIfMissing True dir+ -- Snapshot prior state so we can restore it.+ priorExists <- E.try (BS.readFile path) :: IO (Either E.IOException BS.ByteString)+ -- Clear any existing file first, then install the test bytes (if any).+ E.handle (\(_ :: E.IOException) -> pure ()) (removeFile path)+ case mBytes of+ Nothing -> pure ()+ Just bytes -> BS.writeFile path bytes+ finally+ (action path)+ (do E.handle (\(_ :: E.IOException) -> pure ()) (removeFile path)+ case priorExists of+ Right bs -> BS.writeFile path bs+ Left _ -> pure ())
+ test/SourceSpec.hs view
@@ -0,0 +1,94 @@+{-# LANGUAGE OverloadedStrings #-}+-- | SYNAPSE-10: a parameter's value from a file or stdin.+module Main (main) where++import Data.IORef (newIORef, readIORef, writeIORef)+import qualified Data.ByteString as BS+import qualified Data.Text as T+import qualified Data.Text.Encoding as TE+import System.Directory (getTemporaryDirectory, removeDirectoryRecursive, createDirectoryIfMissing)+import System.FilePath ((</>))+import Test.Hspec++import Synapse.CLI.Source++-- | An environment over a scratch directory, with stdin handed in and+-- counted, so a test can say what stdin holds and how often it was read.+withEnv :: BS.ByteString -> (SourceEnv -> FilePath -> IO Int -> IO a) -> IO a+withEnv stdinBytes body = do+ tmp <- getTemporaryDirectory+ let dir = tmp </> "synapse-source-spec"+ createDirectoryIfMissing True dir+ reads' <- newIORef (0 :: Int)+ cache <- newIORef Nothing+ let env = SourceEnv+ { seCwd = dir+ , seHome = Just (dir </> "home")+ , seStdin = do n <- readIORef reads'; writeIORef reads' (n + 1); pure stdinBytes+ , seCache = cache+ }+ createDirectoryIfMissing True (dir </> "home")+ r <- body env dir (readIORef reads')+ removeDirectoryRecursive dir+ pure r++main :: IO ()+main = hspec $ describe "a value from a file or stdin (SYNAPSE-10)" $ do+ it "reads @file as the file's contents, as they are" $ withEnv "" $ \env dir _ -> do+ BS.writeFile (dir </> "body.md") "---\ntitle: x\n---\n\ncosts $HOME, keeps the final newline\n"+ r <- expandSources env [("body", "@body.md"), ("title", "plain")]+ r `shouldBe` Right [("body", "---\ntitle: x\n---\n\ncosts $HOME, keeps the final newline\n"), ("title", "plain")]++ it "round-trips a 1 MB body" $ withEnv "" $ \env dir _ -> do+ let big = T.replicate (1024 * 1024 `div` 16) "0123456789abcdef"+ BS.writeFile (dir </> "big.md") (TE.encodeUtf8 big)+ r <- expandValue env "body" "@big.md"+ fmap T.length r `shouldBe` Right (1024 * 1024)+ r `shouldBe` Right big++ it "resolves ~/ against home and an absolute path as itself" $ withEnv "" $ \env dir _ -> do+ BS.writeFile (dir </> "home" </> "h.txt") "from home"+ BS.writeFile (dir </> "abs.txt") "absolute"+ expandValue env "a" "@~/h.txt" >>= (`shouldBe` Right "from home")+ expandValue env "b" (T.pack ("@" <> dir </> "abs.txt")) >>= (`shouldBe` Right "absolute")++ it "reads - from stdin, once, however many values ask" $ withEnv "piped in" $ \env _ reads' -> do+ r <- expandSources env [("body", "-"), ("note", "-")]+ r `shouldBe` Right [("body", "piped in"), ("note", "piped in")]+ reads' >>= (`shouldBe` 1)+ stdinWasRead env >>= (`shouldBe` True)++ it "has not read stdin when no value asked" $ withEnv "piped in" $ \env _ reads' -> do+ _ <- expandSources env [("body", "text")]+ reads' >>= (`shouldBe` 0)+ stdinWasRead env >>= (`shouldBe` False)++ it "sends @@x as the literal @x" $ withEnv "" $ \env _ _ -> do+ expandValue env "handle" "@@alice" >>= (`shouldBe` Right "@alice")+ expandValue env "handle" "@@@" >>= (`shouldBe` Right "@@")++ it "leaves a lone @ and every other value alone" $ withEnv "" $ \env _ _ -> do+ expandValue env "x" "@" >>= (`shouldBe` Right "@")+ expandValue env "x" "a@b" >>= (`shouldBe` Right "a@b")+ expandValue env "x" "--dashes" >>= (`shouldBe` Right "--dashes")+ expandValue env "x" "" >>= (`shouldBe` Right "")++ it "refuses a file it cannot read, naming the flag, the path and the escape" $ withEnv "" $ \env _ _ -> do+ r <- expandValue env "blocked_by" "@nope.md"+ case r of+ Left e -> do+ seKey e `shouldBe` "blocked_by"+ seSource e `shouldBe` "@nope.md"+ let msg = renderSourceError e+ msg `shouldSatisfy` T.isPrefixOf "--blocked-by @nope.md: cannot read nope.md"+ msg `shouldSatisfy` T.isInfixOf "write @@nope.md"+ Right v -> expectationFailure ("read " <> show v)++ it "names -p when the params object is the source" $ withEnv "" $ \env _ _ -> do+ r <- expandValue env "" "@params.json"+ fmap renderSourceError (either Just (const Nothing) r) `shouldSatisfy` maybe False (T.isPrefixOf "-p @params.json: cannot read params.json")++ it "stops at the first unreadable source" $ withEnv "" $ \env dir _ -> do+ BS.writeFile (dir </> "ok.md") "ok"+ r <- expandSources env [("a", "@ok.md"), ("b", "@missing"), ("c", "@missing2")]+ either seSource (const "read everything") r `shouldBe` "@missing"
+ test/TlsSpec.hs view
@@ -0,0 +1,205 @@+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE ScopedTypeVariables #-}+{-# LANGUAGE PatternSynonyms #-}+{-# LANGUAGE TypeApplications #-}++-- | SCAFFOLD-21: synapse speaks wss. A TLS terminator of the test's own —+-- a CA and a certificate for @localhost@ made with openssl for this run,+-- in front of the mock hub, as the edge sits in front of a hosted app —+-- and against it: the probe, a call and a key login over wss at a path,+-- with the request's path and Host as the edge would see them; a server+-- the trust store does not vouch for, and a certificate for another name,+-- refused at the handshake before a byte of the request is sent.+module Main (main) where++import Control.Concurrent (forkIO, killThread)+import Control.Exception (SomeException, bracket, finally, try)+import Control.Monad (forever, void)+import Data.Aeson (Value(..), decodeStrict, object)+import qualified Data.Aeson.KeyMap as KM+import qualified Data.ByteString as BS+import qualified Data.ByteString.Base64.URL as B64U+import qualified Data.ByteString.Char8 as BS8+import qualified Data.ByteString.Lazy as LBS+import Data.IORef+import qualified Data.Text as T+import qualified Network.Socket as N+import qualified Network.Socket.ByteString as NB+import qualified Network.TLS as TLS+import System.Directory (findExecutable)+import System.Exit (ExitCode(..))+import System.FilePath ((</>))+import System.IO.Temp (withSystemTempDirectory)+import System.Process (readProcessWithExitCode)+import Test.Hspec++import qualified Katip+import MockHub (todaysHub, withMockHub)+import Synapse.Backend.Discovery (probeBackendAtEp)+import Synapse.Endpoint (Endpoint(..))+import qualified Synapse.Identity.Proof as Proof+import qualified Synapse.Identity.Session as Session+import qualified Synapse.Log as Log+import Synapse.Monad (initEnvAt, runSynapseM)+import Synapse.Schema.Types (pattern HubData)+import Synapse.Transport (invoke, invokeStreamingWithBidir)+import Synapse.Reconnect (CallShape(..))+import Plexus.Types (Response(..))++main :: IO ()+main = do+ openssl <- findExecutable "openssl"+ hspec $ case openssl of+ Nothing -> it "needs openssl to make a CA for the run" $ pendingWith "no openssl on PATH"+ Just _ -> around withPki specs++-- | The run's PKI: a CA, and a certificate for @localhost@ it signed.+data Pki = Pki { pkiCa :: FilePath, pkiCert :: FilePath, pkiKey :: FilePath, pkiOtherCa :: FilePath }++withPki :: (Pki -> IO ()) -> IO ()+withPki act = withSystemTempDirectory "synapse-tls" $ \dir -> do+ let f = (dir </>)+ run args = do+ (code, _, err) <- readProcessWithExitCode "openssl" args ""+ case code of+ ExitSuccess -> pure ()+ _ -> fail ("openssl " <> unwords args <> ": " <> err)+ ca name = run ["req", "-x509", "-newkey", "rsa:2048", "-nodes", "-days", "1"+ , "-keyout", f (name <> ".key"), "-out", f (name <> ".pem")+ , "-subj", "/CN=" <> name+ , "-addext", "basicConstraints=critical,CA:TRUE"+ , "-addext", "keyUsage=critical,keyCertSign,cRLSign" ]+ ca "synapse-test-ca"+ ca "another-ca"+ writeFile (f "ext.cnf") "subjectAltName=DNS:localhost\nbasicConstraints=CA:FALSE\nextendedKeyUsage=serverAuth\nkeyUsage=digitalSignature,keyEncipherment\n"+ run ["req", "-newkey", "rsa:2048", "-nodes", "-keyout", f "srv.key", "-out", f "srv.csr", "-subj", "/CN=localhost"]+ run ["x509", "-req", "-in", f "srv.csr", "-CA", f "synapse-test-ca.pem", "-CAkey", f "synapse-test-ca.key"+ , "-CAcreateserial", "-days", "1", "-out", f "srv.pem", "-extfile", f "ext.cnf"]+ act (Pki (f "synapse-test-ca.pem") (f "srv.pem") (f "srv.key") (f "another-ca.pem"))++-- | What the terminator saw: each connection's request head (the bytes+-- before the first blank line), once its handshake completed.+type Heads = IORef [BS.ByteString]++-- | A TLS terminator on 127.0.0.1 (a port the OS picks) forwarding each+-- connection to the plain hub at @backend@, as the edge does.+withTerminator :: Pki -> Int -> (Int -> Heads -> IO a) -> IO a+withTerminator pki backend act = do+ cred <- TLS.credentialLoadX509 (pkiCert pki) (pkiKey pki) >>= either fail pure+ heads <- newIORef []+ let params = TLS.defaultParamsServer+ { TLS.serverShared = (TLS.serverShared TLS.defaultParamsServer)+ { TLS.sharedCredentials = TLS.Credentials [cred] } }+ open = do+ sock <- N.socket N.AF_INET N.Stream N.defaultProtocol+ N.setSocketOption sock N.ReuseAddr 1+ N.bind sock (N.SockAddrInet 0 (N.tupleToHostAddress (127, 0, 0, 1)))+ N.listen sock 16+ port <- fromIntegral <$> N.socketPort sock+ tid <- forkIO (forever (N.accept sock >>= void . forkIO . serve params heads . fst))+ pure (sock, tid, port)+ close (sock, tid, _) = killThread tid >> N.close sock+ bracket open close (\(_, _, port) -> act port heads)+ where+ serve params heads conn = flip finally (N.close conn) $ void $ try @SomeException $ do+ ctx <- TLS.contextNew conn params+ TLS.handshake ctx+ up <- N.socket N.AF_INET N.Stream N.defaultProtocol+ flip finally (N.close up) $ do+ N.connect up (N.SockAddrInet (fromIntegral backend) (N.tupleToHostAddress (127, 0, 0, 1)))+ seen <- newIORef False+ let down = forever $ do+ b <- NB.recv up 65536+ if BS.null b then fail "hub closed" else TLS.sendData ctx (LBS.fromStrict b)+ t <- forkIO (void (try @SomeException down) >> TLS.bye ctx)+ flip finally (killThread t) $ forever $ do+ b <- TLS.recvData ctx+ if BS.null b then fail "client closed" else do+ first <- atomicModifyIORef' seen (\s -> (True, not s))+ if first then modifyIORef' heads (++ [fst (BS.breakSubstring "\r\n\r\n" b)]) else pure ()+ NB.sendAll up b++logger :: IO Log.Logger+logger = Log.makeLogger Katip.ErrorS++specs :: SpecWith Pki+specs = do+ describe "wss through a TLS terminator, the CA given with --cacert" $ do+ it "answers the _info probe, dialing the path with the Host the certificate names" $ \pki ->+ withMockHub todaysHub $ \hub -> withTerminator pki hub $ \port heads -> do+ let ep = Endpoint "localhost" port True "/ws" [pkiCa pki]+ probeBackendAtEp [] ep `shouldReturn` Right "menger"+ hs <- readIORef heads+ h <- case hs of+ (h : _) -> pure h+ [] -> expectationFailure "the terminator saw no request" >> pure ""+ BS8.unpack h `shouldStartWith` "GET /ws HTTP/1.1"+ BS8.lines h `shouldSatisfy` any (== BS8.pack ("Host: localhost:" <> show port <> "\r"))++ it "carries a call and its answer" $ \pki ->+ withMockHub todaysHub $ \hub -> withTerminator pki hub $ \port _ -> do+ lg <- logger+ env <- initEnvAt (Endpoint "localhost" port True "/ws" [pkiCa pki]) "menger" lg Nothing+ r <- runSynapseM env (invoke ["tickets"] "get" (object [("id", String "MENGER-12")]))+ case r of+ Right items -> [v | HubData _ _ _ (Object v) <- items, KM.lookup "id" v == Just (String "MENGER-12")]+ `shouldSatisfy` (not . null)+ Left e -> expectationFailure (show e)++ it "logs in from a key, the proof's audience the server's wss origin" $ \pki ->+ withMockHub todaysHub $ \hub -> withTerminator pki hub $ \port heads -> do+ lg <- logger+ let ep = Endpoint "localhost" port True "/ws" [pkiCa pki]+ seed = either (error . T.unpack) id (Proof.seedFromBytes (BS.pack [1 .. 32]))+ r <- Session.loginWithKey lg ep "menger" seed "worker-1" [] Nothing+ fmap Session.lrToken r `shouldBe` Right "mgs_agent_session"+ hs <- readIORef heads+ let proofs = [ p | h <- hs, l <- BS8.lines h+ , Just rest <- [BS.stripPrefix "Authorization: Bearer plexus-key." l]+ , Just p <- [decodeProof (BS8.takeWhile (/= '\r') rest)] ]+ proofs `shouldSatisfy` (not . null)+ mapM_ (\p -> KM.lookup "aud" p `shouldBe` Just (String ("wss://localhost:" <> T.pack (show port)))) proofs++ it "carries a bidirectional request and its answer (respond goes over wss too)" $ \pki ->+ withMockHub todaysHub $ \hub -> withTerminator pki hub $ \port heads -> do+ lg <- logger+ env <- initEnvAt (Endpoint "localhost" port True "/ws" [pkiCa pki]) "menger" lg Nothing+ seen <- newIORef []+ asked <- newIORef []+ r <- runSynapseM env $ invokeStreamingWithBidir+ (CallShape { csStream = False, csRead = False, csSince = False })+ (pure Nothing) (const (pure ())) ["tickets"] "ask" (object [])+ (\i -> modifyIORef' seen (++ [i]))+ (\rid _ -> modifyIORef' asked (++ [rid]) >> pure (Just (Confirmed True :: Response Value)))+ r `shouldSatisfy` either (const False) (const True)+ readIORef asked `shouldReturn` ["req-1"]+ items <- readIORef seen+ [v | HubData _ _ _ (Object v) <- items, KM.lookup "type" v == Just (String "answered")]+ `shouldBe` [KM.fromList [("type", String "answered"), ("response", object [("type", String "confirmed"), ("value", Bool True)])]]+ -- the call and the respond: two upgrades, both through TLS at the path+ hs <- readIORef heads+ length (filter (BS.isPrefixOf "GET /ws HTTP/1.1") hs) `shouldSatisfy` (>= 2)++ describe "refusing what it cannot verify" $ do+ it "refuses a certificate the trust store does not vouch for — nothing is sent" $ \pki ->+ withMockHub todaysHub $ \hub -> withTerminator pki hub $ \port heads -> do+ r <- probeBackendAtEp [] (Endpoint "localhost" port True "/ws" [pkiOtherCa pki])+ r `shouldSatisfy` either (const True) (const False)+ readIORef heads `shouldReturn` []++ it "refuses a certificate for another name (127.0.0.1 is not localhost)" $ \pki ->+ withMockHub todaysHub $ \hub -> withTerminator pki hub $ \port heads -> do+ r <- probeBackendAtEp [] (Endpoint "127.0.0.1" port True "/ws" [pkiCa pki])+ r `shouldSatisfy` either (const True) (const False)+ readIORef heads `shouldReturn` []++ it "refuses a CA file that holds no certificate" $ \pki ->+ withMockHub todaysHub $ \hub -> withTerminator pki hub $ \port _ -> do+ r <- probeBackendAtEp [] (Endpoint "localhost" port True "/ws" [pkiKey pki])+ either T.unpack (const "") r `shouldContain` "holds no PEM certificate"++-- | A key proof's JSON (@plexus-key.<base64url>@ without its prefix).+decodeProof :: BS.ByteString -> Maybe (KM.KeyMap Value)+decodeProof b64 = case B64U.decodeUnpadded b64 of+ Right json | Just (Object o) <- decodeStrict json -> Just o+ _ -> Nothing