{-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE TupleSections #-} module StoreService.Service ( ServiceState (..), newServiceState, configureStoreVerifier, welcomeGetOpts, trustDevKeys, checkDevChain, requireDevKeys, checkIssuerKey, storeService, storeServiceCLI, storeServiceResponse, badgeErrorRetryAfter, shownServiceRequest, survive, IssueCodeOpts (..), issueBadgeCode, ) where import StoreService.Catalog (StoreProduct (..), defaultCatalog, storeProduct) import StoreService.Codes (issueFailedText, issueOneCode, singleUse) import StoreService.Config (AppleStoreConfig (..), BadgeIssuerKey (..), ChainConfig (..), NamesConfig (..), PlayStoreConfig (..), ServiceConfig (..), localChainId, minNameYears, readServiceConfig) import StoreService.Group (GroupEvent, ensureManagedGroup, fmtTime, groupEvent, hasTracker, refreshTracker, revokeWithTracker, runGroupLane) import StoreService.Group.Command (badgeTypeP, codeP, maxMonths, maxNameYears, nameCodeP, textTokenP) import StoreService.Names.Balances (RefundRecord (..), RefundRefusal (..), recordRefund) import StoreService.Names.Chain (newChainClient, nodeChainId) import StoreService.Names.Codes (issueNameCode, revokeNameCode) import StoreService.Names.Env (NamesEnv (..), newNamesEnv) import StoreService.Names.Orders (ordersEnv) import StoreService.Names.Phases (RegistrationId (..)) import StoreService.Names.Records (runRecordEdits) import StoreService.Names.Sender (runSender) import StoreService.Names.Store (Balance (..), BalanceHold (..), BalanceLink (..), LateCredit (..), RegistrationPayment (..), balanceByReference, balanceChain, methodText) import StoreService.Names.Turns (Turns, newTurns) import StoreService.Names.Worker (newWorker, runCodeSweep, runWorkers) import StoreService.Options import StoreService.Poller (expiryGrace, newPollerEnv, newReadHints, runPoller) import StoreService.Providers (Provider) import StoreService.Providers.BTCPay (btcpayProvider) import StoreService.Providers.Stripe (stripeProvider) import StoreService.Store import StoreService.Store.Invoices (seedCatalog, truncateToSecond) import StoreService.Store.Migrate (runStoreServiceMigrations) import StoreService.StoreReceipts import StoreService.StoreReceipts.Apple (readAppleRoot, verifyAppleTransaction) import StoreService.StoreReceipts.Google (playStoreVerifier) import StoreService.StoreReceipts.Mock (mockStoreVerifier) import StoreService.Waiters (Waiters, newWaiters) import StoreService.Web.Server (checkSiteDirs, exportWebapp, newWebEnv, runWebListeners) import Control.Applicative (optional) import Control.Concurrent.STM import StoreService.Log (logError, logInfo, logWarn) import Control.Monad import Control.Monad.IO.Class (liftIO) import qualified Data.Aeson as J import qualified Data.Aeson.KeyMap as KM import qualified Data.Attoparsec.ByteString.Char8 as A import Data.Bifunctor (first) import Data.ByteString.Char8 (ByteString) import Data.Char (isSpace) import Data.Either (fromRight, isRight) import Data.Functor (($>), (<&>)) import Data.Int (Int64) import qualified Data.Map.Strict as M import Data.Maybe (fromMaybe, isJust, maybeToList) import qualified Data.Text as T import Data.Time.Clock (NominalDiffTime, UTCTime, getCurrentTime) import Data.Word (Word32) import Simplex.Chat.Badges import Simplex.Chat.Badges.Code import Simplex.Chat.Badges.Ledger import Simplex.Chat.StoreService import Simplex.Chat.Badges.Types (BadgeCodePaymentStatus (..)) import Simplex.Chat.Bot (initializeBotAddress') import Simplex.Chat.Bot.Store (withDB, withDB') import Simplex.Chat.Controller import Simplex.Chat.Core (sendChatCmd, simplexChatCore) import Simplex.Chat.Options (printDbOpts) import Simplex.Chat.PaymentService (ServicePayment (..)) import Simplex.Chat.PaymentService.Types (InvoiceId (..), StoreTransactionRef (..)) import Simplex.Chat.Terminal (terminalChatConfig) import Simplex.Chat.Terminal.Main (simplexChatCLI') import Simplex.Chat.Types (AgentInvId (..), User (..)) import Simplex.Messaging.Agent.Store.Common (DBStore) import qualified Simplex.Messaging.Agent.Store.DB as DB import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.BBS (bbsPublicKey) import Simplex.Messaging.Encoding.String (strEncode) import Simplex.Messaging.Util (catchOwn, catchOwn', raceAny_, safeDecodeUtf8, tshow) import Simplex.Messaging.Version (isCompatible) import System.Directory (getAppUserDataDirectory) import System.Exit (exitFailure) data ServiceState = ServiceState { serviceCC :: TMVar ChatController, serviceRequestQ :: TQueue (User, AgentInvId, Maybe C.PublicKeyEd25519, J.Object), groupEventQ :: TQueue GroupEvent, storeVerifier :: StoreVerifier } -- | With no store verifier, which store_service.ini configures, every store receipt not already credited -- is answered provider_not_configured. newServiceState :: IO ServiceState newServiceState = do serviceCC <- newEmptyTMVarIO serviceRequestQ <- newTQueueIO groupEventQ <- newTQueueIO pure ServiceState {serviceCC, serviceRequestQ, groupEventQ, storeVerifier = noStoreVerifier} welcomeGetOpts :: IO StoreServiceOpts welcomeGetOpts = do appDir <- getAppUserDataDirectory "simplex" opts@StoreServiceOpts {coreOptions, testing, serviceName} <- getStoreServiceOpts appDir "simplex_badge_service" unless testing $ do putStrLn $ "SimpleX Store Service v" ++ versionNumber printDbOpts coreOptions putStrLn $ "Service name: " ++ T.unpack serviceName pure opts -- | The ini's dev_keys, trusted beside the clients' keys; the config accepts them only on the local chain. trustDevKeys :: Maybe ServiceConfig -> ChatConfig -> IO (Either String ChatConfig) trustDevKeys serviceCfg cfg@ChatConfig {badgePublicKeys} = do r <- fmap trusted . sequence <$> mapM publicKey devKeys when (isRight r && not (null devKeys)) $ logWarn $ "trusting issuer dev_keys at indexes " <> T.intercalate ", " (map (\BadgeIssuerKey {keyIdx} -> tshow keyIdx) devKeys) <> "; no client trusts them, never set them in production" pure r where devKeys = maybe [] devIssuerKeys serviceCfg trusted ks = cfg {badgePublicKeys = M.union badgePublicKeys (M.fromList ks)} publicKey BadgeIssuerKey {keyIdx, secretKey} | M.member keyIdx badgePublicKeys = pure $ Left $ "issuer.dev_keys index " <> show keyIdx <> " is the index of a key clients trust" | otherwise = fmap (keyIdx,) . first (("issuer.dev_keys secret at index " <> show keyIdx <> " is not a valid key: ") <>) <$> bbsPublicKey secretKey -- | With dev keys, the node itself must be the local chain, not only the ini's chain_id. checkDevChain :: Maybe ServiceConfig -> IO (Either String ()) checkDevChain = \case Just ServiceConfig {devIssuerKeys = _ : _, names = Just NamesConfig {nChain}} -> do chain <- C.newRandom >>= (`newChainClient` nChain) nodeChainId chain <&> \case Right chainId | chainId == localChainId -> Right () | otherwise -> Left $ refused <> "is " <> show chainId <> ", not " <> show localChainId Left _ -> Left $ refused <> "could not be read" _ -> pure $ Right () where refused = "issuer.dev_keys is refused: the node's chain id " checkIssuerKey :: StoreServiceOpts -> Maybe ServiceConfig -> ChatConfig -> IO (Either String BadgeIssuerKey) checkIssuerKey StoreServiceOpts {issuerKey} serviceCfg cfg = case issuerKey of Left e -> pure (Left e) Right (Just k) -> checkOne cfg k Right Nothing -> case serviceCfg >>= issuer of Nothing -> pure $ Left "an issuer key is required - pass --issuer-key-idx and --issuer-secret, or add an [issuer] section to store_service.ini (see `simplex-chat badge keygen`)" Just k -> checkOne cfg k checkOne :: ChatConfig -> BadgeIssuerKey -> IO (Either String BadgeIssuerKey) checkOne ChatConfig {badgePublicKeys} k@BadgeIssuerKey {keyIdx, secretKey} = bbsPublicKey secretKey >>= \case Left e -> pure $ Left $ "issuer secret at index " <> show keyIdx <> " is not a valid key: " <> e Right pk -> pure $ case M.lookup keyIdx badgePublicKeys of Just pk' | pk' == pk -> Right k Just _ -> Left $ "issuer secret does not match the configured key at index " <> show keyIdx <> ", its public key is " <> T.unpack (safeDecodeUtf8 $ strEncode pk) Nothing -> Left $ "no configured badge key at index " <> show keyIdx <> ", clients could not verify what this service signs" requireIssuerKey :: StoreServiceOpts -> Maybe ServiceConfig -> ChatConfig -> IO BadgeIssuerKey requireIssuerKey opts serviceCfg cfg = checkIssuerKey opts serviceCfg cfg >>= orExit requireDevKeys :: Maybe ServiceConfig -> ChatConfig -> IO ChatConfig requireDevKeys serviceCfg cfg = do checkDevChain serviceCfg >>= orExit trustDevKeys serviceCfg cfg >>= orExit orExit :: Either String a -> IO a orExit = either (\e -> putStrLn ("Error: " <> e) >> exitFailure) pure readConfigOrExit :: FilePath -> IO ServiceConfig readConfigOrExit path = readServiceConfig path >>= \case Left e -> putStrLn (path <> ": " <> e) >> exitFailure Right sc -> pure sc -- | The config refuses the dev flag beside a real verifier, so only one of them applies. configureStoreVerifier :: Maybe ServiceConfig -> ServiceState -> IO ServiceState configureStoreVerifier serviceCfg env@ServiceState {storeVerifier = v} = case serviceCfg of Just ServiceConfig {devAcceptUnverifiedStoreReceipts = True} -> do logWarn "[dev] accept_unverified_store_receipts is on: any store receipt is accepted without verification" pure env {storeVerifier = mockStoreVerifier} Just ServiceConfig {appleStore, playStore} -> do apple <- forM appleStore $ \AppleStoreConfig {aBundleId, aRootCertificates} -> (\roots -> verifyAppleTransaction roots aBundleId) <$> mapM ((>>= orExit) . readAppleRoot) aRootCertificates google <- forM playStore $ (>>= orExit) . playStoreVerifier forM_ appleStore $ \AppleStoreConfig {aBundleId} -> logInfo $ "verifying App Store receipts for " <> aBundleId forM_ playStore $ \PlayStoreConfig {gPackageName} -> logInfo $ "verifying Play purchases for " <> gPackageName pure env { storeVerifier = v { verifyApple = maybe (verifyApple v) Just apple, verifyGoogle = maybe (verifyGoogle v) (Just . fst) google, acknowledgeGoogle = maybe (acknowledgeGoogle v) (Just . snd) google } } Nothing -> pure env storeService :: StoreServiceOpts -> ChatConfig -> ServiceState -> IO () storeService opts@StoreServiceOpts {serviceConfigFile} baseCfg env = do serviceCfg <- traverse readConfigOrExit serviceConfigFile forM_ serviceCfg $ \sc -> checkSiteDirs (listener sc) >>= orExit cfg <- requireDevKeys serviceCfg baseCfg key <- requireIssuerKey opts serviceCfg cfg waiters <- newWaiters -- The hooks' //revoke takes a prepaid code's turn, so the turns exist before the chat starts. turns <- newTurns requestEnv <- configureStoreVerifier serviceCfg env let groupCfg = serviceCfg >>= \ServiceConfig {group} -> group trackerQ_ = groupEventQ env <$ groupCfg readEvents cc = forever $ atomically (readTBQueue $ outputQ cc) >>= \case (_, Right (CEvtServiceRequest u reqId sigKey reqData)) -> atomically $ writeTQueue (serviceRequestQ env) (u, reqId, sigKey, reqData) (_, Right ev) | isJust groupCfg -> forM_ (groupEvent ev) $ atomically . writeTQueue (groupEventQ env) _ -> pure () startLanes cc = do lanes <- maybe (pure []) (serviceLanes waiters turns cc) serviceCfg -- The group is resolved before the other lanes start, so a failed lookup exits at once rather than waiting for them to end. groupLane_ <- forM groupCfg $ \gc -> runGroupLane cc (groupEventQ env) <$> ensureManagedGroup cc gc raceAny_ $ processQueuedRequests key trackerQ_ requestEnv : maybeToList groupLane_ <> lanes chatHooks = defaultChatHooks { preStartHook = Just $ badgePreStartHook opts, postStartHook = Just $ badgePostStartHook opts env, preCmdHook = Just $ badgeCmdHook turns } -- The reader runs from the start and must not block, since outputQ carries every chat event and a full queue stalls the core. simplexChatCore cfg {chatHooks} (mkChatOpts opts) $ \_ cc -> raceAny_ [readEvents cc, startLanes cc] where serviceLanes :: Waiters -> Turns -> ChatController -> ServiceConfig -> IO [IO ()] serviceLanes ws turns cc@ChatController {chatStore} sc = do -- Seed before the listener accepts anything, since every checkout is priced from these. seedServiceCatalog chatStore btc <- maybe (pure []) (fmap (: []) . btcpayProvider) (btcpay sc) str <- maybe (pure []) (fmap (: []) . stripeProvider) (stripe sc) let providers = btc <> str hints <- newReadHints namesEnv <- forM (names sc) (startNames cc turns ws providers) webEnv <- newWebEnv chatStore sc ws hints providers namesEnv exportWebapp (listener sc) (stripe sc) pollerEnv <- newPollerEnv chatStore ws (nePhases <$> namesEnv) hints providers (poll sc) nameLanes <- maybe (pure []) namesLanes namesEnv pure $ [runWebListeners webEnv, runPoller pollerEnv] <> nameLanes startNames :: ChatController -> Turns -> Waiters -> [Provider] -> NamesConfig -> IO NamesEnv startNames cc@ChatController {chatStore} turns waiters providers nc@NamesConfig {nTLD, nChain = ChainConfig {cChainId}} = readTVarIO (currentUser cc) >>= \case Nothing -> putStrLn "No current user" >> exitFailure Just User {userId} -> do logInfo $ "names: on, TLD " <> safeDecodeUtf8 (strEncode nTLD) <> ", chain " <> tshow cChainId newNamesEnv cc userId nc chatStore turns waiters providers -- | The workers resume every registration in their phases, and the code sweep runs at the start and then daily. namesLanes :: NamesEnv -> IO [IO ()] namesLanes ne@NamesEnv {neChain, neSender, neRecords} = do worker <- newWorker (ordersEnv ne) neChain neSender pure [runSender neSender, runRecordEdits neRecords, runWorkers worker, runCodeSweep worker] seedServiceCatalog :: DBStore -> IO () seedServiceCatalog st = do now <- truncateToSecond <$> getCurrentTime let (prices, offers) = defaultCatalog now (seededPrices, seededOffers) <- seedCatalog st prices offers logInfo $ "badge catalog: " <> tshow (length prices) <> " prices and " <> tshow (length offers) <> " offers compiled in, " <> tshow seededPrices <> " prices and " <> tshow seededOffers <> " offers inserted" storeServiceCLI :: StoreServiceOpts -> IO () storeServiceCLI opts@StoreServiceOpts {serviceConfigFile} = do serviceCfg <- traverse readConfigOrExit serviceConfigFile cfg <- requireDevKeys serviceCfg terminalChatConfig key <- requireIssuerKey opts serviceCfg cfg env <- newServiceState >>= configureStoreVerifier serviceCfg -- This mode runs no names lanes, so only its own //revoke takes these turns. turns <- newTurns let eventHook _cc = \case Right (CEvtServiceRequest u reqId sigKey reqData) -> do atomically $ writeTQueue (serviceRequestQ env) (u, reqId, sigKey, reqData) pure $ Right $ CEvtServiceRequest u reqId sigKey (shownServiceRequest reqData) ev -> pure ev chatHooks = defaultChatHooks { preStartHook = Just $ badgePreStartHook opts, postStartHook = Just $ badgePostStartHook opts env, preCmdHook = Just $ badgeCmdHook turns, eventHook = Just eventHook } raceAny_ [ simplexChatCLI' cfg {chatHooks} (mkChatOpts opts) Nothing, processQueuedRequests key Nothing env ] -- | The terminal prints each request, and requests carry codes and store receipts, which are bearer secrets. shownServiceRequest :: J.Object -> J.Object shownServiceRequest reqData = case KM.lookup "request" reqData of Just (J.Object cmd) | Just t <- KM.lookup "type" cmd -> KM.singleton "request" $ J.object ["type" J..= t] _ -> KM.empty badgeCmdHook :: Turns -> ChatController -> ChatCommand -> IO (Either (Either ChatError ChatResponse) ChatCommand) badgeCmdHook turns cc = \case CustomChatCommand cmd -> Left <$> runBadgeCmd turns cc cmd cmd -> pure $ Right cmd runBadgeCmd :: Turns -> ChatController -> ByteString -> IO (Either ChatError ChatResponse) runBadgeCmd turns cc cmd | Right issueOpts <- A.parseOnly issueCmdP cmd = issuedReply formatBadgeCode <$> issueBadgeCode cc issueOpts | Right (len, years, paymentStatus) <- A.parseOnly issueNameCmdP cmd = issuedReply (formatNameCode . NCPrepaid) <$> issueNameCode cc len years paymentStatus | Right code <- A.parseOnly revokeCmdP cmd = textReply <$> revokeWithTracker cc code | Right code <- A.parseOnly revokeNameCmdP cmd = textReply <$> revokeNameCode turns cc code | Right (ref, cents_) <- A.parseOnly refundCmdP cmd = textReply <$> maybe (showRefund cc ref) (refundBalanceCmd turns cc ref) cents_ | otherwise = pure $ chatCmdError $ "Usage: //issue supporter|legend|investor [months 1-" <> show maxMonths <> "] [paid|unpaid|free], //issue name show minNameLength <> "-" <> show maxNameLength <> "> [years " <> show minNameYears <> "-" <> show maxNameYears <> "] [paid|unpaid|free], //revoke , or //refund []" where issuedReply :: (c -> T.Text) -> Either String c -> Either ChatError ChatResponse issuedReply format = \case Right code -> Right CRCustomChatResponse {user_ = Nothing, response = "Code: " <> format code} Left _ -> chatCmdError (T.unpack issueFailedText) textReply = \case Right response -> Right CRCustomChatResponse {user_ = Nothing, response} Left e -> chatCmdError (T.unpack e) revokeCmdP :: A.Parser BadgeCode revokeCmdP = "revoke " *> codeP <* (A.skipSpace *> A.endOfInput) revokeNameCmdP :: A.Parser NamePrepaidCode revokeNameCmdP = "revoke " *> prepaidCodeP <* (A.skipSpace *> A.endOfInput) where prepaidCodeP = parseNameCode . safeDecodeUtf8 <$> A.takeWhile1 (not . isSpace) >>= \case Just (NCPrepaid code) -> pure code _ -> fail "not a prepaid name code" -- | The reference, and the cents of the balance to refund; without them the balance is shown. refundCmdP :: A.Parser (T.Text, Maybe Int) refundCmdP = do ref <- "refund " *> (safeDecodeUtf8 <$> A.takeWhile1 (not . isSpace)) cents_ <- optional (A.space *> (A.decimal :: A.Parser Integer)) A.skipSpace A.endOfInput (ref,) <$> mapM checkCents cents_ where -- Integer, as in issueCmdP, so a figure past Int is refused rather than wrapped. checkCents n | n >= 1 && n <= toInteger (maxBound :: Int) = pure (fromInteger n) | otherwise = fail "cents out of range" -- | Left is a refusal or a failure; both sides are the text to show the operator. showRefund :: ChatController -> T.Text -> IO (Either T.Text T.Text) showRefund cc ref = do now <- getCurrentTime withDB' "showRefund" cc (\db -> balanceByReference db ref now >>= mapM (withChain db)) <&> \case Right Nothing -> Left $ "No order has the reference " <> ref <> "." Right (Just (RegistrationId rid, Nothing)) -> Left $ "Order " <> rid <> " has no balance." Right (Just (RegistrationId rid, Just (balance@Balance {blAmount, blCredited, blRefunded}, chain))) -> Right $ "Order " <> rid <> ": balance " <> dollars blAmount <> " (credited " <> dollars blCredited <> ", refunded " <> dollars blRefunded <> "). Paid by: " <> chainText chain <> ". " <> nextStep balance Left _ -> Left "The balance could not be read." where withChain db (rid, balance_) = (rid,) <$> mapM (\balance -> (balance,) <$> balanceChain db rid) balance_ nextStep Balance {blAmount, blHold} = case blHold of Just hold -> "It cannot be refunded now: " <> heldText hold <> "." Nothing | blAmount > 0 -> "To record its refund: //refund " <> ref <> " " <> tshow blAmount | otherwise -> "Nothing to refund." -- | The refund is recorded before support pays it, so the balance cannot be spent meanwhile. refundBalanceCmd :: Turns -> ChatController -> T.Text -> Int -> IO (Either T.Text T.Text) refundBalanceCmd turns ChatController {chatStore} ref cents = do now <- getCurrentTime ((Right <$> recordRefund turns chatStore ref cents now) `catchOwn` (pure . Left)) >>= \case Right (Right RefundRecord {rdRegistration = RegistrationId rid, rdCents, rdChain}) -> do -- The registration id, not the reference, since an invoice id is kept out of the logs. logInfo $ "names: recorded a refund of " <> tshow rdCents <> " cents for order " <> rid pure $ Right $ "Recorded a refund of " <> dollars rdCents <> " for order " <> rid <> ". Refund it now, by: " <> chainText rdChain <> "." Right (Left refusal) -> pure $ Left $ "Nothing recorded: " <> refusalText refusal <> "." Left e -> Left "The refund could not be recorded." <$ logError ("names: recording a refund failed: " <> tshow e) where refusalText = \case RefundNoOrder -> "no order has the reference " <> ref RefundNoBalance -> "the order of " <> ref <> " has no balance" RefundAmount amount -> "the balance is " <> dollars amount <> " now, not " <> dollars cents RefundHeld hold -> heldText hold heldText :: BalanceHold -> T.Text heldText BalanceHold {bhHolder = RegistrationId holder, bhUntil, bhOverdue, bhFunded} | bhOverdue = heldBy <> "its invoice expired at " <> fmtTime bhUntil <> "; start the service so the poller closes that order, " <> tshow graceMinutes <> " minutes after the expiry at the earliest, then stop it and run //refund again" | bhFunded = heldBy <> "whose invoice holds money; it holds until that payment settles or the invoice closes; run the command again then" | otherwise = heldBy <> "its invoice open until " <> fmtTime bhUntil <> "; run the command again then" where heldBy = "held by order " <> holder <> ", " graceMinutes = round (expiryGrace / minute) :: Int minute = 60 :: NominalDiffTime -- | From the balance's own order back to the first; a part paid from a balance names the next order, whose balance paid it. chainText :: [BalanceLink] -> T.Text chainText chain = T.intercalate "; " $ zipWith linkText chain (map (Just . lkRegistration) (drop 1 chain) <> [Nothing]) where linkText BalanceLink {lkRegistration = RegistrationId rid, lkPayment, lkPaid, lkFromBalance, lkLateCredits} source_ = "order " <> rid <> ": " <> case paid lkPayment lkPaid lkLateCredits <> fromBalance lkFromBalance source_ of [] -> "no payment recorded" parts -> T.intercalate ", " parts paid payment cents lateCredits = case payment of RPInvoice method (InvoiceId invId) -> let by = " by " <> methodText method <> " on invoice " <> invId late = sum $ map lcCents lateCredits in [dollars cents <> by | cents > 0] <> [dollars late <> " late" <> by | late > 0] RPCode _ -> ["a prepaid code"] RPBalance -> [] fromBalance cents = \case Just (RegistrationId source) | cents > 0 -> [dollars cents <> " from the balance of order " <> source] _ -> [] dollars :: Int -> T.Text dollars cents = "$" <> tshow whole <> "." <> T.justifyRight 2 '0' (tshow part) where (whole, part) = cents `quotRem` centsPerDollar centsPerDollar = 100 issueNameCmdP :: A.Parser (Int, Int, BadgeCodePaymentStatus) issueNameCmdP = do (len, years) <- "issue " *> nameCodeP paymentStatus <- fromMaybe CPSFree <$> optional (A.space *> textTokenP) A.skipSpace A.endOfInput pure (len, years, paymentStatus) issueCmdP :: A.Parser IssueCodeOpts issueCmdP = "issue " *> do badgeType <- badgeTypeP months_ <- optional (A.space *> (A.decimal :: A.Parser Integer)) -- Kept outside optional, which would otherwise backtrack past a bad count. months <- maybe (pure 1) checkMonths months_ paymentStatus <- fromMaybe CPSFree <$> optional (A.space *> textTokenP) A.skipSpace A.endOfInput pure IssueCodeOpts {badgeType, months, paymentStatus} where -- Integer, because attoparsec's decimal wraps silently at Int, so the guard would check a truncated count. checkMonths n | n >= 1 && n <= fromIntegral maxMonths = pure (fromInteger n) | otherwise = fail $ "months must be between 1 and " <> show maxMonths data IssueCodeOpts = IssueCodeOpts { badgeType :: BadgeType, months :: Int, paymentStatus :: BadgeCodePaymentStatus } issueBadgeCode :: ChatController -> IssueCodeOpts -> IO (Either String BadgeCode) issueBadgeCode cc IssueCodeOpts {badgeType, months, paymentStatus} = fmap fst <$> issueOneCode cc badgeType months paymentStatus singleUse processQueuedRequests :: BadgeIssuerKey -> Maybe (TQueue GroupEvent) -> ServiceState -> IO () processQueuedRequests key trackerQ_ env = do cc <- atomically $ readTMVar $ serviceCC env -- a Play hand-over can wait two verifyTimeouts on Play, so it waits on a lane of its own playQ <- newTQueueIO let answer (u, reqId, sigKey, reqData) = handleServiceRequest key (storeVerifier env) cc trackerQ_ u reqId sigKey reqData route req@(_, _, _, reqData) | presentsPlayReceipt reqData = atomically $ writeTQueue playQ req | otherwise = answer req lane q act = forever $ do req@(_, reqId, _, _) <- atomically $ readTQueue q survive ("request " <> safeDecodeUtf8 (strEncode reqId)) $ act req raceAny_ [lane (serviceRequestQ env) route, lane playQ answer] -- | Only what creates a purchase leaves the main lane: an append to an existing ledger is guarded by nothing but that lane being serial. presentsPlayReceipt :: J.Object -> Bool presentsPlayReceipt reqData = case J.fromJSON (J.Object reqData) of J.Success StoreServiceRequest {request = STCPurchaseBadge {payment = SPGoogle {}, upgrade = Nothing}} -> True _ -> False -- | Cancellation is rethrown, since that is how the race stops this thread. The -- exception itself is not logged: it can quote the request, which carries codes and store receipts. survive :: T.Text -> IO () -> IO () survive what action = action `catchOwn'` \_ -> logError $ "store service: " <> what <> " failed; the next one will be handled" badgePreStartHook :: StoreServiceOpts -> ChatController -> IO () badgePreStartHook opts ChatController {config, chatStore} = runStoreServiceMigrations opts config chatStore badgePostStartHook :: StoreServiceOpts -> ServiceState -> ChatController -> IO () badgePostStartHook StoreServiceOpts {noAddress, testing} env cc = do -- Core starts this False and gates service request delivery on it, so the hook must set it. atomically $ writeTVar (processServiceRequests cc) True readTVarIO (currentUser cc) >>= \case Nothing -> putStrLn "No current user" >> exitFailure Just _ -> do -- The address carries service RPC only, so contact requests are never auto-accepted. unless noAddress $ initializeBotAddress' (not testing) (Just True) False cc void $ atomically $ tryPutTMVar (serviceCC env) cc handleServiceRequest :: BadgeIssuerKey -> StoreVerifier -> ChatController -> Maybe (TQueue GroupEvent) -> User -> AgentInvId -> Maybe C.PublicKeyEd25519 -> J.Object -> IO () handleServiceRequest key verifier cc trackerQ_ User {userId} reqId sigKey reqData = do let reqIdT = safeDecodeUtf8 (strEncode reqId) logInfo $ "store service request " <> reqIdT resp <- storeServiceResponse key verifier cc trackerQ_ sigKey reqData `catchOwn'` \_ -> logError ("store service request " <> reqIdT <> " failed") $> errorResponse STEInternal sendChatCmd cc (APISendServiceResponse userId reqId (responseObject resp)) >>= \case Right _ -> pure () Left e -> logError $ "store service response failed for " <> reqIdT <> ": " <> tshow e responseObject :: StoreServiceResponse -> J.Object responseObject r = case J.toJSON r of J.Object o -> o _ -> KM.fromList [("type", J.String "error"), ("code", J.toJSON STEInternal)] errorResponse :: StoreServiceErrorCode -> StoreServiceResponse errorResponse code = STPError {code, message = Nothing, retryAfter = badgeErrorRetryAfter code} badgeErrorRetryAfter :: StoreServiceErrorCode -> Maybe Word32 badgeErrorRetryAfter = \case STEPaymentPending -> Just 300 STEProviderUnavailable -> Just 300 STERateLimited -> Just 60 _ -> Nothing -- | The agent verified the signature, so sigKey is a key the sender holds; a differing purchaseKey would let a client claim a purchase it cannot sign for. storeServiceResponse :: BadgeIssuerKey -> StoreVerifier -> ChatController -> Maybe (TQueue GroupEvent) -> Maybe C.PublicKeyEd25519 -> J.Object -> IO StoreServiceResponse storeServiceResponse key verifier cc trackerQ_ sigKey reqData = case J.fromJSON (J.Object reqData) of J.Error _ -> pure $ errorResponse STEBadRequest J.Success StoreServiceRequest {version, purchaseKey, request} | not (version `isCompatible` supportedStoreServiceVRange) -> pure $ errorResponse STEUnsupportedVersion | purchaseKey /= sigKey -> pure $ errorResponse STEBadRequest | otherwise -> case request of STCRedeemBadgeCode {masterKey, code} -> case purchaseKey of Just k -> redeemCode key cc trackerQ_ k masterKey code Nothing -> pure $ errorResponse STEBadRequest STCPurchaseBadge {masterKey, payment, upgrade} | Just receipt_ <- toStoreReceipt verifier payment -> case (purchaseKey, upgrade) of (Just k, Nothing) -> either storeRefusalResponse (purchaseWithReceipt key cc k masterKey) receipt_ -- store upgrades are not built, and ignoring one would credit its months at the discounted price (Just _, Just _) -> pure $ errorResponse STEBadRequest (Nothing, _) -> pure $ errorResponse STEBadRequest STCIssueBadge {balance} -> case purchaseKey of Just k -> issueBadgeCmd key cc k balance Nothing -> pure $ errorResponse STEBadRequest -- Every command but redeemBadgeCode and purchaseBadge from a store needs a key the service already knows; those two create the purchase, so their key is unknown on a first purchase. _ -> case purchaseKey of Nothing -> pure $ errorResponse STEUnsupportedVersion Just k -> withDB' "purchaseKeyExists" cc (`purchaseKeyExists` k) >>= \case Right True -> pure $ errorResponse STEUnsupportedVersion Right False -> pure $ errorResponse STEUnknownPurchaseKey Left _ -> pure $ errorResponse STEInternal badgeNow :: ChatController -> IO UTCTime badgeNow ChatController {config = ChatConfig {badgeCurrentTime}} = badgeCurrentTime randomId :: ChatController -> IO T.Text randomId cc = safeDecodeUtf8 . strEncode <$> atomically (C.randomBytes 16 (random cc)) -- | Neither the badge type nor the expiry comes from the caller; both derive from the entry. credentialForEntry :: BadgeIssuerKey -> BadgeMasterKey -> StatementEntry -> IO (Either String (StatementEntry, BadgeCredential)) credentialForEntry BadgeIssuerKey {keyIdx, secretKey} masterKey e@StatementEntry {balanceStartTs = periodEnd, balanceBadgeType} = do let badgeInfo = BadgeInfo {badgeType = balanceBadgeType, badgeExpiry = endOfMondayAfter periodEnd, badgeExtra = ""} fmap (e,) <$> issueBadge keyIdx secretKey (VerifiedBadgeRequest BadgeRequest {masterKey, badgeInfo}) issuanceAfter :: StatementEntry -> (StatementEntry, BadgeCredential) -> (StatementEntry, StatementEntry, BadgeCredential) issuanceAfter previous (issued, credential) = (previous, issued, credential) credentialResponse :: Maybe BadgeCredential -> Maybe T.Text -> [StatementEntry] -> StoreServiceResponse credentialResponse credential previousEntryId entries = STPBadgeCredential {credential, receipt = Nothing, statement = BadgeStatement {entries, previousEntryId}} -- | Nothing is written until the credential is signed, so a signing failure leaves the code unspent. redeemCode :: BadgeIssuerKey -> ChatController -> Maybe (TQueue GroupEvent) -> C.PublicKeyEd25519 -> BadgeMasterKey -> T.Text -> IO StoreServiceResponse redeemCode key cc trackerQ_ purchaseKey masterKey codeText = case parseBadgeCode codeText of Nothing -> pure $ errorResponse STECodeInvalid Just code -> do now <- badgeNow cc withDB "getBadgeCode" cc (readCode now code) >>= \case Left _ -> pure $ errorResponse STEInternal Right (Left resp) -> pure resp Right (Right IssuedCode {badgeCodeId, badgeType, months, redeemLimit}) -> signFirstMonth key cc masterKey badgeType months SCCode now >>= \case Left resp -> pure resp Right firstMonth -> do -- If the code was revoked or used up while signing, the claim fails. Read the code again to tell the client why. r <- withDB "writeCodeRedemption" cc $ \db -> liftIO (createCodePurchase db NewCodePurchase {badgeCodeId, purchaseKey, masterKey, badgeType} now) >>= \case Nothing -> readCode now code db >>= \case Left resp -> pure (resp, False) Right _ -> logError "store service: redeeming a code failed, but the code has uses left and is not revoked" $> (errorResponse STEInternal, False) Just purchaseId -> liftIO $ (,True) <$> firstMonthResponse db purchaseId Nothing firstMonth let (resp, claimed) = fromRight (errorResponse STEInternal, False) r when (claimed && hasTracker redeemLimit) $ refreshTracker cc trackerQ_ badgeCodeId code pure resp where readCode now code db = liftIO $ getBadgeCode db (badgeCodeHash code) >>= \case Nothing -> pure $ Left $ errorResponse STECodeInvalid Just c@IssuedCode {badgeCodeId, revokedAt, paymentStatus, expiresAt, redeemLimit, redeemCount} -> getCodePurchaseForKey db badgeCodeId purchaseKey >>= \case -- A key that already redeemed gets its credential back without a use, even if the code has since -- expired or been revoked: a client whose reply was lost retries, and would otherwise lose the badge. KeyRedeemed KeyPurchase {badgePurchaseId, credential} -> maybe (Left $ errorResponse STEInternal) (Left . credentialResponse (Just credential) Nothing) <$> getLedgerEntries db badgePurchaseId 0 -- code_used would make the client drop its keys, so the holder could never get the badge back. KeyRedeemedUnreadable -> logError "store service: a redeemed code's credential is missing or unreadable" $> Left (errorResponse STEInternal) KeyUnredeemed -- Revoked is checked first, so it answers as if the code never existed. | Just _ <- revokedAt -> pure $ Left $ errorResponse STECodeInvalid -- Redeeming an unpaid code would issue a free badge, so unpaid is refused. | CPSUnpaid <- paymentStatus -> pure $ Left $ errorResponse STEPaymentPending | redeemCount >= redeemLimit -> pure $ Left $ errorResponse STECodeUsed | maybe False (now >=) expiresAt -> pure $ Left $ errorResponse STECodeExpired | otherwise -> pure $ Right c -- | Every refusal is answered before anything is written, so it leaves the receipt unclaimed; and -- nothing is written until the credential is signed. purchaseWithReceipt :: BadgeIssuerKey -> ChatController -> C.PublicKeyEd25519 -> BadgeMasterKey -> StoreReceipt -> IO StoreServiceResponse purchaseWithReceipt key cc purchaseKey masterKey StoreReceipt {txRef = StoreTransactionRef {provider, transactionRef = providerRef}, verifyReceipt, acknowledgeReceipt} = withDB' "getStorePayment" cc (\db -> getStorePaymentCredit db provider providerRef) >>= \case Left _ -> pure $ errorResponse STEInternal Right credit -- only the key it credited can ask, and it is told only what it was given, so the store is not asked | creditedBy credit -> answerCredit credit | otherwise -> verifyReceipt >>= \case Left refusal -> storeRefusalResponse refusal -- the claim was read from the evidence before it was verified, so it must name the transaction the store vouched for Right VerifiedStoreTransaction {transactionRef} | transactionRef /= providerRef -> storeRefusalResponse $ SRVerifierFailed "verified a transaction other than the one claimed" Right VerifiedStoreTransaction {quantity} | quantity /= 1 -> storeRefusalResponse $ SRVerifierFailed $ "verified a quantity of " <> tshow quantity Right VerifiedStoreTransaction {testPurchase = True} -> storeRefusalResponse SRTestPurchase -- another key's credit is told only once the store vouched for the receipt, or it would reveal which transactions were credited Right tx -> case credit of Uncredited -> newPurchase tx _ -> answerCredit credit where creditedBy = \case Credited CreditedPurchase {purchaseKey = k} -> k == purchaseKey _ -> False answerCredit credit = withDB' "answerStoreCredit" cc (\db -> creditedResponse db purchaseKey credit) <&> \case Right (Left resp) -> resp _ -> errorResponse STEInternal -- the product is read only for a receipt not yet credited, so retiring it leaves its replays answered newPurchase tx@VerifiedStoreTransaction {productId, paid} = case storeProduct provider productId of Nothing -> pure $ errorResponse STEProductUnavailable Just StoreProduct {badgeType, months} -> maybe (pure $ Right ()) ($ tx) acknowledgeReceipt >>= \case Left refusal -> storeRefusalResponse refusal Right () -> do now <- badgeNow cc signFirstMonth key cc masterKey badgeType months (SCPayment Nothing) now >>= \case Left resp -> pure resp Right firstMonth -> do paymentId <- randomId cc r <- withDB "writeStorePurchase" cc $ \db -> liftIO (createStorePurchase db NewStorePurchase {paymentId, provider, providerRef, paid, purchaseKey, masterKey, badgeType} now) >>= \case -- Credited to another key, or to this one by a request that ran alongside it, while signing. Nothing -> liftIO (getStorePaymentCredit db provider providerRef >>= creditedResponse db purchaseKey) >>= \case Left resp -> pure resp Right () -> logError ("store service: a payment for " <> providerRef <> " exists but funds no purchase") $> errorResponse STEInternal Just purchaseId -> liftIO $ firstMonthResponse db purchaseId (Just paymentId) firstMonth pure $ fromRight (errorResponse STEInternal) r -- | A forged receipt and a refunded one are both receipt_invalid, so the client learns nothing a -- forger could iterate on. A test purchase is named, since the device that made it already knows. storeRefusalResponse :: StoreRefusal -> IO StoreServiceResponse storeRefusalResponse = \case SRInvalid reason -> logInfo ("store receipt refused: " <> reason) $> errorResponse STEReceiptInvalid SRTestPurchase -> logInfo "store receipt refused: test purchase" $> errorResponse STETestPurchase SRPending -> pure $ errorResponse STEPaymentPending SRUnreachable reason -> logWarn ("store unreachable: " <> reason) $> errorResponse STEProviderUnavailable SRVerifierFailed reason -> logError ("store receipt not verified: " <> reason) $> errorResponse STEInternal SRNotConfigured -> logWarn "store receipt refused: no verifier for this store is configured" $> errorResponse STEProviderNotConfigured creditedResponse :: DB.Connection -> C.PublicKeyEd25519 -> FundingCredit -> IO (Either StoreServiceResponse ()) creditedResponse db purchaseKey = \case Uncredited -> pure $ Right () CreditedUnreadable -> pure $ Left $ errorResponse STEInternal Credited CreditedPurchase {purchaseKey = k, badgePurchaseId, credential} | k /= purchaseKey -> pure $ Left $ errorResponse STEReceiptUsed | otherwise -> maybe (Left $ errorResponse STEInternal) (Left . credentialResponse (Just credential) Nothing) <$> getLedgerEntries db badgePurchaseId 0 signFirstMonth :: BadgeIssuerKey -> ChatController -> BadgeMasterKey -> BadgeType -> Int -> StatementCreditType -> UTCTime -> IO (Either StoreServiceResponse (StatementEntry, (StatementEntry, BadgeCredential))) signFirstMonth key cc masterKey badgeType months credit now = do (grantUuid, issueUuid) <- (,) <$> randomId cc <*> randomId cc -- TODO [badges] a top-up grants onto an existing ledger, and must lapse before it or the -- months it adds are counted from a start already in the past let granted = grantEntry now grantUuid months credit $ emptyEntry now badgeType -- A grant of at least one month starting now always has a month to issue. case issueEntry now issueUuid granted of Nothing -> pure $ Left $ errorResponse STEInternal Just issued -> credentialForEntry key masterKey issued >>= \case Left e -> logError ("store service signing failed: " <> T.pack e) $> Left (errorResponse STEInternal) Right signed -> pure $ Right (granted, signed) firstMonthResponse :: DB.Connection -> Int64 -> Maybe T.Text -> (StatementEntry, (StatementEntry, BadgeCredential)) -> IO StoreServiceResponse firstMonthResponse db purchaseId creditPaymentId_ (granted, signed) = do appendLedgerPlan db purchaseId creditPaymentId_ [granted] $ Just $ issuanceAfter granted signed entries_ <- getLedgerEntries db purchaseId 0 pure $ maybe (errorResponse STEInternal) (credentialResponse (Just $ snd signed) Nothing) entries_ -- | The purchase is reached through the verified signer key and no other way. issueBadgeCmd :: BadgeIssuerKey -> ChatController -> C.PublicKeyEd25519 -> BadgeBalance -> IO StoreServiceResponse issueBadgeCmd key cc purchaseKey BadgeBalance {lastEntry} = do now <- badgeNow cc purchase_ <- withDB' "getBadgePurchase" cc $ \db -> do p_ <- getPurchaseByKey db purchaseKey forM p_ $ \p@ServicePurchase {badgePurchaseId} -> (p,) <$> getLedgerTip db badgePurchaseId case purchase_ of Left _ -> pure $ errorResponse STEInternal Right Nothing -> pure $ errorResponse STEUnknownPurchaseKey Right (Just (ServicePurchase {badgePurchaseId, masterKey, badgeType}, tip)) -> do (lapseUuid, issueUuid) <- (,) <$> randomId cc <*> randomId cc let tipEntry = fromMaybe (emptyEntry now badgeType) tip lapsed = lapseEntry now lapseUuid tipEntry current = fromMaybe tipEntry lapsed case issueEntry now issueUuid current of Nothing -> writeIssued badgePurchaseId tip (maybeToList lapsed) now Nothing Just e -> credentialForEntry key masterKey e >>= \case Left err -> logError ("store service signing failed: " <> T.pack err) $> errorResponse STEInternal Right signed -> writeIssued badgePurchaseId tip (maybeToList lapsed) now $ Just $ issuanceAfter current signed where -- Write only if the tip has not moved, since another request may have advanced it. writeIssued purchaseId tip rows t issuance_ = do r <- withDB "issueBadge" cc $ \db -> liftIO $ do tip' <- getLedgerTip db purchaseId when (fmap entryId tip' == fmap entryId tip) $ appendLedgerPlan db purchaseId Nothing rows issuance_ issueResponse db purchaseId t pure $ fromRight (errorResponse STEInternal) r -- Only the asserted entry's identity is read, never the months it claims. issueResponse db purchaseId t = do let StatementEntry {entryId = assertedUuid} = lastEntry assertedId <- getLedgerEntryId db purchaseId assertedUuid -- TODO [badges] when the assertion does not resolve, heal the ledger and restate it as a -- single opening credit, rather than resending the whole history entries_ <- getLedgerEntries db purchaseId (fromMaybe 0 assertedId) credential_ <- getCurrentIssuance db purchaseId t pure $ maybe (errorResponse STEInternal) (credentialResponse credential_ (assertedUuid <$ assertedId)) entries_