packages feed

plexus-synapse-3.15.0: src/Synapse/Self/Types.hs

{-# 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)