diff --git a/apps/simplex-badge-service/Main.hs b/apps/simplex-badge-service/Main.hs index b870ea8671..84635abfe6 100644 --- a/apps/simplex-badge-service/Main.hs +++ b/apps/simplex-badge-service/Main.hs @@ -1,14 +1,27 @@ +{-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} module Main where -import BadgeService.Options (BadgeServiceOpts (..)) +import BadgeService.Admin (runAdminCmd) +import BadgeService.Options (BadgeServiceCommand (..), BadgeServiceOpts (..), getBadgeServiceCommand) import BadgeService.Service import Simplex.Chat.Terminal (terminalChatConfig) +import System.Directory (getAppUserDataDirectory) +-- | 'getBadgeServiceCommand' decides, from the same combined parser that generates --help, +-- whether this is the operator @codes@ subcommand or a plain service run (the default with +-- no arguments -- 'BadgeService.Options.badgeServiceCommand' wraps the @codes@ subparser in +-- 'optional' so this never becomes mandatory). The @codes@ branch runs and exits without ever +-- starting the bot; the plain-run branch falls through to 'welcomeGetOpts', unchanged, which +-- re-parses the same arguments to print the startup banner before starting the service. main :: IO () main = do - opts@BadgeServiceOpts {runCLI} <- welcomeGetOpts - if runCLI - then badgeServiceCLI opts - else badgeService opts terminalChatConfig + appDir <- getAppUserDataDirectory "simplex" + getBadgeServiceCommand appDir "simplex_badge_service" >>= \case + RunAdmin adminOpts -> runAdminCmd adminOpts + RunService _ -> do + opts@BadgeServiceOpts {runCLI} <- welcomeGetOpts + if runCLI + then badgeServiceCLI opts + else badgeService opts terminalChatConfig diff --git a/apps/simplex-badge-service/src/BadgeService/Admin.hs b/apps/simplex-badge-service/src/BadgeService/Admin.hs new file mode 100644 index 0000000000..bb29460660 --- /dev/null +++ b/apps/simplex-badge-service/src/BadgeService/Admin.hs @@ -0,0 +1,267 @@ +{-# LANGUAGE CPP #-} +{-# LANGUAGE LambdaCase #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} + +-- | Operator tooling for redemption codes (decision 3): @simplex-badge-service codes +-- issue|revoke|status@. Runs standalone against the same database and ini as the service +-- ("BadgeService.Options" keeps the plain service run the DEFAULT when no subcommand is +-- given -- see its @optional@-wrapped subparser); this module owns the sub-subcommand +-- parser and the execution behind it. It asserts the schema is current and REFUSES to run a +-- pending migration itself -- 'openCurrentStore' uses 'MCError', never a confirming +-- 'MigrationConfirmation' -- because this may run against the database of a live service, +-- and silently migrating under a running process is how you corrupt one. It calls +-- 'seedCatalog' the same way the service does, does its work in one short transaction per +-- invocation, and exits: it never starts the bot. +-- +-- @issue@ is the one place a redemption code's plaintext exists outside a user's clipboard: +-- 'runIssue' generates it in memory with 'BadgeService.Codes.generateBatchCode', hashes it +-- with 'BadgeService.Codes.codeHash' for the row 'insertCodes' writes, and only prints the +-- plaintext to stdout after that insert has committed. Nothing here ever writes a plaintext +-- code anywhere -- not to the database, not to a log, not to a temp file. +module BadgeService.Admin + ( AdminOpts (..), + AdminCmd (..), + IssueOpts (..), + adminCommandParser, + runAdminCmd, + ) +where + +import BadgeService.Catalog (seedCatalog) +import qualified BadgeService.Codes as Codes +import BadgeService.Config (BadgeServiceConfig (..), CodesConfig (..), readBadgeServiceConfig) +import BadgeService.Store + ( BadgeCode (BadgeCode, badgeType, batch, createdAt, expiresAt, months, redeemedAt, redeemedPurchaseId, revokedAt, unredeemedAt), + NewBadgeCode (NewBadgeCode), + getCodeByHash, + insertCodes, + revokeBatch, + withServiceTransaction, + ) +import Control.Exception (finally) +import Control.Monad (replicateM) +import Data.Text (Text) +import qualified Data.Text as T +import Data.Time.Calendar (Day) +import Data.Time.Clock (UTCTime (..), addUTCTime, getCurrentTime, nominalDay) +import Data.Time.Format (defaultTimeLocale, parseTimeM) +import Data.Word (Word8) +import Options.Applicative +import Simplex.Chat.Badges (BadgeType (..)) +import Simplex.Chat.Options (CoreChatOpts (..)) +import Simplex.Chat.Options.DB +import Simplex.Chat.Store (createChatStore) +import Simplex.Messaging.Agent.Store.Common (DBStore) +import Simplex.Messaging.Agent.Store.Interface (closeDBStore, migrateDBSchema) +import Simplex.Messaging.Agent.Store.Shared (MigrationConfig (..), MigrationConfirmation (..), MigrationError, migrationErrorDescription) +import qualified Simplex.Messaging.Crypto as C +import Simplex.Messaging.Encoding.String (textEncode) +import System.Exit (exitFailure) +import Text.Read (readMaybe) + +#if defined(dbPostgres) +import BadgeService.Store.Postgres.Migrations (badgeServiceSchemaMigrations) +#else +import BadgeService.Store.SQLite.Migrations (badgeServiceSchemaMigrations) +#endif + +-- | Everything the @codes@ subcommand needs on top of 'AdminCmd': the same database options +-- and the same ini path as a service run, so an operator points it at the live deployment +-- (docs/protocol/badges-web.md). +data AdminOpts = AdminOpts + { adminCoreOptions :: CoreChatOpts, + adminConfigFile :: FilePath, + adminCmd :: AdminCmd + } + +data AdminCmd + = CmdIssue IssueOpts + | CmdRevoke Text + | CmdStatus Text + +-- | @--months@ is required and at least 1 (@codes.months@ carries @CHECK (months > 0)@, A3); +-- @--type@ accepts only 'BTSupporter'\/'BTLegend' (lifetime codes are out of scope, so +-- @investor@ is rejected); @--expires@ defaults to @[codes] default_expiry_days@ from the +-- ini when absent (filled in by 'runIssue') and is stored exactly as given otherwise, with no +-- validation beyond the date format -- a past date is accepted. +data IssueOpts = IssueOpts + { issueType :: BadgeType, + issueMonths :: Word8, + issueCount :: Int, + issueBatch :: Text, + issueExpires :: Maybe Day + } + +-- Parsing ----------------------------------------------------------------------- + +-- | The @codes issue|revoke|status@ sub-subcommand parser, nested inside the @codes@ command +-- "BadgeService.Options" installs at the top level. Each leaf 'info' below is deliberately +-- bare (no explicit 'helper'): nested two levels inside +-- 'BadgeService.Options.badgeServiceCommand''s @hsubparser@, optparse-applicative already +-- installs @-h@\/@--help@ for every command an @hsubparser@ dispatches to, and adding a +-- second one here duplicated the line in @--help@ output. +adminCommandParser :: Parser AdminCmd +adminCommandParser = + hsubparser $ + command "issue" (info issueP (progDesc "Mint new redemption codes, printing the plaintext once")) + <> command "revoke" (info revokeP (progDesc "Revoke every unredeemed code in a batch")) + <> command "status" (info statusP (progDesc "Report a redemption code's status")) + where + issueP = + (\issueType issueMonths issueCount issueBatch issueExpires -> CmdIssue IssueOpts {issueType, issueMonths, issueCount, issueBatch, issueExpires}) + <$> typeOption + <*> monthsOption + <*> countOption + <*> batchOption + <*> expiresOption + revokeP = CmdRevoke . T.pack <$> strOption (long "batch" <> metavar "BATCH" <> help "batch name to revoke") + statusP = CmdStatus . T.pack <$> strOption (long "code" <> metavar "CODE" <> help "redemption code to look up") + +typeOption :: Parser BadgeType +typeOption = option (eitherReader badgeTypeReader) (long "type" <> metavar "TYPE" <> help "supporter or legend") + +-- | Deliberately narrower than 'Simplex.Chat.Badges.textDecode': that instance never fails +-- (falling back to 'BTUnknown'), but a code can only ever credit a balance the ledger can +-- represent, so @investor@ (a lifetime badge) and anything else are rejected here. +badgeTypeReader :: String -> Either String BadgeType +badgeTypeReader = \case + "supporter" -> Right BTSupporter + "legend" -> Right BTLegend + s -> Left ("invalid --type '" <> s <> "': expected 'supporter' or 'legend'") + +monthsOption :: Parser Word8 +monthsOption = option (eitherReader monthsReader) (long "months" <> metavar "N" <> help "months credited per code (at least 1)") + +monthsReader :: String -> Either String Word8 +monthsReader s = case readMaybe s :: Maybe Int of + Just n + | n < 1 -> Left ("--months must be at least 1, got " <> show n) + | n > fromIntegral (maxBound :: Word8) -> Left ("--months is too large, got " <> show n) + | otherwise -> Right (fromIntegral n) + Nothing -> Left ("--months must be an integer, got '" <> s <> "'") + +countOption :: Parser Int +countOption = option (eitherReader countReader) (long "count" <> metavar "N" <> help "number of codes to issue") + +countReader :: String -> Either String Int +countReader s = case readMaybe s :: Maybe Int of + Just n | n >= 1 -> Right n + Just n -> Left ("--count must be at least 1, got " <> show n) + Nothing -> Left ("--count must be an integer, got '" <> s <> "'") + +batchOption :: Parser Text +batchOption = T.pack <$> strOption (long "batch" <> metavar "BATCH" <> help "batch label stored on every issued code") + +expiresOption :: Parser (Maybe Day) +expiresOption = + optional $ + option + (eitherReader dayReader) + (long "expires" <> metavar "YYYY-MM-DD" <> help "expiry date (default: [codes] default_expiry_days from the ini)") + +dayReader :: String -> Either String Day +dayReader s = + maybe (Left ("--expires must be YYYY-MM-DD, got '" <> s <> "'")) Right $ + parseTimeM True defaultTimeLocale "%Y-%m-%d" s + +-- Execution ----------------------------------------------------------------------- + +-- | Opens the store, asserts both the core and the badge service schema are current (failing +-- rather than migrating either), seeds the catalog, dispatches the one subcommand, then +-- closes the store. Never starts the bot. +runAdminCmd :: AdminOpts -> IO () +runAdminCmd AdminOpts {adminCoreOptions, adminConfigFile, adminCmd} = do + bsConfig <- readBadgeServiceConfig adminConfigFile >>= either dieConfig pure + chatStore <- openCurrentStore adminCoreOptions + (seedCatalog chatStore >> dispatch chatStore bsConfig) `finally` closeDBStore chatStore + where + dieConfig e = putStrLn e >> exitFailure + dispatch chatStore bsConfig = case adminCmd of + CmdIssue issueOpts -> runIssue chatStore bsConfig issueOpts + CmdRevoke batchName -> runRevoke chatStore batchName + CmdStatus code -> runStatus chatStore code + +-- | Opens the shared chat\/badge-service database and confirms it needs no migration, +-- refusing to run one if it does ('MCError' on both the core chat schema and the badge +-- service schema): safe to point at the database of a live service. +openCurrentStore :: CoreChatOpts -> IO DBStore +openCurrentStore CoreChatOpts {dbOptions} = do + chatStoreResult <- createChatStore (toDBOpts dbOptions chatSuffix False chatDBFunctions) noMigrateConfig + chatStore <- either (dieMigration "chat") pure chatStoreResult + badgeResult <- + migrateDBSchema + chatStore + (toDBOpts dbOptions chatSuffix False []) + (Just "sx_badge_service_migrations") + badgeServiceSchemaMigrations + noMigrateConfig + case badgeResult of + Right () -> pure chatStore + Left e -> closeDBStore chatStore >> dieMigration "badge service" e + where + noMigrateConfig = MigrationConfig {confirm = MCError, backupPath = Nothing} + +dieMigration :: String -> MigrationError -> IO a +dieMigration label e = do + putStrLn $ "codes: " <> label <> " schema is not current (" <> migrationErrorDescription False e <> ")" + putStrLn "codes: refusing to migrate automatically; start the service once (or migrate it) first" + exitFailure + +-- | Generates 'issueCount' fresh, unrelated codes ('BadgeService.Codes.generateBatchCode'), +-- hashes each for storage, and inserts every row in one transaction. Only after that +-- transaction commits are the plaintext codes printed to stdout, exactly once each, in the +-- same order they were generated -- the only place any of them is ever written down. +runIssue :: DBStore -> BadgeServiceConfig -> IssueOpts -> IO () +runIssue chatStore BadgeServiceConfig {codes = CodesConfig {codesDefaultExpiryDays}} IssueOpts {issueType, issueMonths, issueCount, issueBatch, issueExpires} = do + now <- getCurrentTime + let expiresAt = maybe (defaultExpiry now) (\day -> UTCTime day 0) issueExpires + drg <- C.newRandom + plainCodes <- replicateM issueCount (Codes.generateBatchCode drg) + -- Positional (not record) construction: 'NewBadgeCode' and 'BadgeCode' share field names + -- ('badgeType', 'months', 'batch', 'expiresAt'), and both are in scope here (the latter for + -- 'describeCode'), which DuplicateRecordFields cannot disambiguate in record syntax across + -- two different constructors -- only 'NewBadgeCode' the bare constructor is imported. + let newCode c = NewBadgeCode (Codes.codeHash (Codes.normalizeCode c)) issueType issueMonths issueBatch expiresAt + newCodes = map newCode plainCodes + withServiceTransaction chatStore (\db -> insertCodes db newCodes now) >>= \case + Left err -> putStrLn ("codes issue: " <> show err) >> exitFailure + Right () -> mapM_ (putStrLn . T.unpack) plainCodes + where + defaultExpiry now = addUTCTime (fromIntegral codesDefaultExpiryDays * nominalDay) now + +-- | Sets @revoked_at@ on every unrevoked code in the batch, in one transaction; a batch name +-- matching nothing is not an error, just zero codes revoked ('BadgeService.Store.revokeBatch'). +runRevoke :: DBStore -> Text -> IO () +runRevoke chatStore batchName = do + now <- getCurrentTime + withServiceTransaction chatStore (\db -> revokeBatch db batchName now) >>= \case + Left err -> putStrLn ("codes revoke: " <> show err) >> exitFailure + Right n -> putStrLn (show n <> " code(s) revoked in batch " <> T.unpack batchName) + +-- | Normalizes and hashes the presented code the same way redemption does, then reports what +-- 'getCodeByHash' finds: unredeemed, redeemed (by which purchase), revoked, or not found. +runStatus :: DBStore -> Text -> IO () +runStatus chatStore presented = + withServiceTransaction chatStore (\db -> getCodeByHash db (Codes.codeHash (Codes.normalizeCode presented))) >>= \case + Left err -> putStrLn ("codes status: " <> show err) >> exitFailure + Right Nothing -> putStrLn "not found" + Right (Just (code, _redeemerKey)) -> putStrLn (describeCode code) + +describeCode :: BadgeCode -> String +describeCode BadgeCode {badgeType, months, batch, expiresAt, redeemedAt, redeemedPurchaseId, revokedAt, unredeemedAt, createdAt} = + unwords + [ "type=" <> T.unpack (textEncode badgeType), + "months=" <> show months, + "batch=" <> T.unpack batch, + "expires=" <> show expiresAt, + "created=" <> show createdAt, + "status=" <> redemptionLabel + ] + where + redemptionLabel + | Just _ <- revokedAt = "revoked" + | Just pid <- redeemedPurchaseId, Just at <- redeemedAt = "redeemed (purchase=" <> show pid <> " at=" <> show at <> ")" + | Just pid <- redeemedPurchaseId = "redeemed (purchase=" <> show pid <> ")" + | Just at <- unredeemedAt = "unredeemed (previously redeemed, unredeemed at " <> show at <> ")" + | otherwise = "unredeemed" diff --git a/apps/simplex-badge-service/src/BadgeService/Options.hs b/apps/simplex-badge-service/src/BadgeService/Options.hs index ccad579f1b..430e5847a7 100644 --- a/apps/simplex-badge-service/src/BadgeService/Options.hs +++ b/apps/simplex-badge-service/src/BadgeService/Options.hs @@ -1,16 +1,20 @@ {-# LANGUAGE ApplicativeDo #-} {-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} module BadgeService.Options ( BadgeServiceOpts (..), + BadgeServiceCommand (..), getBadgeServiceOpts, + getBadgeServiceCommand, badgeServiceOpts, mkChatOpts, ) where +import BadgeService.Admin (AdminOpts (..), adminCommandParser) import qualified Data.Text as T import Options.Applicative import Simplex.Chat.Controller (updateStr, versionNumber, versionString) @@ -27,6 +31,16 @@ data BadgeServiceOpts = BadgeServiceOpts configFile :: FilePath } +-- | Either the plain service run -- the DEFAULT when no subcommand is given -- or the +-- operator @codes@ subcommand ("BadgeService.Admin"). 'hsubparser' alone makes a subcommand +-- mandatory, which would break every existing way of starting the service, so +-- 'badgeServiceCommand' wraps it in 'optional' (decision 3): both branches of the parser +-- always run, and only the VALUE produced (not the parser structure) depends on whether +-- @codes@ was given. +data BadgeServiceCommand + = RunService BadgeServiceOpts + | RunAdmin AdminOpts + badgeServiceOpts :: FilePath -> FilePath -> Parser BadgeServiceOpts badgeServiceOpts appDir defaultDbName = do coreOptions <- coreChatOptsP appDir defaultDbName @@ -82,6 +96,35 @@ getBadgeServiceOpts appDir defaultDbName = versionOption = infoOption versionAndUpdate (long "version" <> short 'v' <> help "Show version") versionAndUpdate = versionStr <> "\n" <> updateStr +-- | Parses either the plain service run or the @codes@ subcommand (see 'BadgeServiceCommand'). +-- Plain 'Applicative' combinators, not @do@\/'ApplicativeDo': 'Parser' has no 'Monad' +-- instance, and GHC's 'ApplicativeDo' desugaring does not always find an applicative-only +-- reading of a @do@ block ending in a 'case' over an earlier bind, as this one does. +-- @--config@ and the core database options are parsed unconditionally by 'badgeServiceOpts', +-- so they apply the same way to both branches: the subcommand loads the same ini as a +-- service run. +badgeServiceCommand :: FilePath -> FilePath -> Parser BadgeServiceCommand +badgeServiceCommand appDir defaultDbName = + toCommand <$> badgeServiceOpts appDir defaultDbName <*> optional codesSubparser + where + codesSubparser = + hsubparser $ + command "codes" (info adminCommandParser (progDesc "Operator commands for redemption codes")) + toCommand opts@BadgeServiceOpts {coreOptions, configFile} = \case + Nothing -> RunService opts + Just adminCmd -> RunAdmin AdminOpts {adminCoreOptions = coreOptions, adminConfigFile = configFile, adminCmd} + +getBadgeServiceCommand :: FilePath -> FilePath -> IO BadgeServiceCommand +getBadgeServiceCommand appDir defaultDbName = + execParser $ + info + (helper <*> versionOption <*> badgeServiceCommand appDir defaultDbName) + (header versionStr <> fullDesc <> progDesc "Start SimpleX Badge Service, or run an operator subcommand") + where + versionStr = versionString versionNumber + versionOption = infoOption versionAndUpdate (long "version" <> short 'v' <> help "Show version") + versionAndUpdate = versionStr <> "\n" <> updateStr + mkChatOpts :: BadgeServiceOpts -> ChatOpts mkChatOpts BadgeServiceOpts {coreOptions, serviceName, clientService} = ChatOpts diff --git a/plans/2026-08-21-badges-web-checkout.md b/plans/2026-08-21-badges-web-checkout.md index 979cfae434..693f57e548 100644 --- a/plans/2026-08-21-badges-web-checkout.md +++ b/plans/2026-08-21-badges-web-checkout.md @@ -140,7 +140,7 @@ The two `-m` filters are needed because the badge tests live under two hspec pat | B5 | RPC dispatcher: envelope, version, signer, throttle | A2, A6, B1 | ☑ | | B6 | `getBadgeCatalog` | A4, B1, B2, B5 | ☐ | | B7 | `purchaseBadge{code}` and `issueBadge` | B1, B2, B3, B4, B5 | ☐ | -| B8 | `codes` operator subcommand | A4, B1, B3 | ☐ | +| B8 | `codes` operator subcommand | A4, B1, B3 | ☑ | | B9 | Service address publication | A6, B5 | ☐ | | B10 | Service integration tests | B7, B8 | ☐ | | C1 | `Store/Badges.hs`: client badge store | A1, A2 | ☐ | diff --git a/simplex-chat.cabal b/simplex-chat.cabal index fb4c081aaa..6711aa15fa 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -417,6 +417,7 @@ executable simplex-badge-service default-extensions: StrictData other-modules: + BadgeService.Admin BadgeService.Catalog BadgeService.Codes BadgeService.Config @@ -700,6 +701,7 @@ test-suite simplex-chat-test API.Docs.Syntax.Types API.Docs.Types API.TypeInfo + BadgeService.Admin BadgeService.Catalog BadgeService.Codes BadgeService.Config