diff --git a/apps/simplex-store-service/README.md b/apps/simplex-store-service/README.md index cb4709d843..01f3971f5d 100644 --- a/apps/simplex-store-service/README.md +++ b/apps/simplex-store-service/README.md @@ -120,7 +120,11 @@ Other options: section (omitting it disables card payments), an optional `[group]` section (omitting it turns off the group's commands, though a group created earlier still has its code messages updated), the poll cadence, and optional `[names]` and `[chain]` sections for the names store (omitting both -turns it off), whose `[chain] service_key` is [the gas key](#the-gas-key). +turns it off), whose `[chain] service_key` is [the gas key](#the-gas-key). `[names]` requires +`terms_version` and `terms_url`, and an optional `[community]` section lists the community names +(see [Terms and community names](#terms-and-community-names)). Write every setting as +`key = value`: the parser also reads `key: value`, but drops the first character of its value. A +key written twice in one section, or a section header written twice, stops the service at startup. `store_service.ini.example` is the committed template; `store_service.ini` itself is gitignored, since a real one holds API keys and webhook secrets. @@ -493,6 +497,36 @@ docker compose exec store-service bash -c ': /443' adds for that user or network, then the chain, in `iptables` and `ip6tables`. Running it again changes nothing, and `--check` then reports the chain absent. +### Terms and community names + +`[names]` needs two settings, with no default: + +- `terms_version`: the version of the name terms buyers agree to, 1 to 32 letters, digits, dots, + underscores or hyphens, such as `2026-10-23`. Change it whenever the terms change. +- `terms_url`: the `https://` page of those terms, which the order's checkbox links to. + +**Upgrading:** an ini written before these settings existed stops the service at startup until both +are added under `[names]`. + +`[community]` lists the community names, one line per name: the label, as it is searched, then `=` +and what the name is for: + +``` +[community] +bakers = bakers and bakeries in Portugal +``` + +Search answers and the registration view carry a listed name's text, which the page shows with the +name as a community name. Each label must be a valid name in lower case and listed once; each text is +1 to 200 characters with no control, format or line separator characters, and should not end with a +full stop, since the page adds one. The service refuses to start, naming the setting, on any other +entry, on `[community]` without `[names]`, on a key written twice in a section (only the first would +be read) and on a section header written twice in the file (only the last would be read). It logs +how many community names it loaded. + +Both the terms and the list are read only at startup, so an edit applies after a restart. After a +restart, search each listed name on the names site and check that it shows as a community name. + ### The gas key `[chain] service_key` is the registrar key: an Ethereum account (EOA) that sends every names @@ -503,10 +537,10 @@ names. The commands run from `scripts/store-service/` on the service host, with Foundry's `cast` ([getfoundry.sh](https://getfoundry.sh), checked with 1.5.1) and `jq`. They read the ini through this -shell function, which takes a key's value without surrounding spaces, tabs or a CR: +shell function, which takes a `[chain]` key's value without surrounding spaces, tabs or a CR: ``` -ini() { sudo sed -n "s/^$1[[:space:]]*=[[:space:]]*\([^[:space:]]*\).*/\1/p" "${2:-store_service.ini}"; } +ini() { sudo sed -n "/^\[chain\]/,/^\[/s/^$1[[:space:]]*=[[:space:]]*\([^[:space:]]*\).*/\1/p" "${2:-store_service.ini}"; } ``` Keys and the RPC URL are read through `ini`, so they stay out of the shell history. A key is in @@ -940,6 +974,8 @@ payment against this service in Chromium, as they run against the webapp's mock. age of 5 seconds; 2. builds the names site (`vite build`) into the run's folder; 3. writes an ini for that deployment with `[names]` and `[chain]` and no `[btcpay]` or `[stripe]`, + with the terms version `2026-10-23` and the one community name `bakers`, which the webapp's + community case searches for, serving the names site all in one on the host `127.0.0.1`, and signing with the test suite's public issuer secret (`issuerSecret` in `tests/Bots/StoreService/ConfigTests.hs`), trusted through `[issuer] dev_keys`. That secret is public: never use it outside this script; diff --git a/apps/simplex-store-service/src/StoreService/Config.hs b/apps/simplex-store-service/src/StoreService/Config.hs index fecd832f82..3b91e80a5f 100644 --- a/apps/simplex-store-service/src/StoreService/Config.hs +++ b/apps/simplex-store-service/src/StoreService/Config.hs @@ -13,6 +13,7 @@ module StoreService.Config BadgeIssuerKey (..), GroupConfig (..), NamesConfig (..), + TermsConfig (..), ChainConfig (..), ServiceConfig (..), defaultExpiryMinutes, @@ -26,14 +27,21 @@ module StoreService.Config where import qualified Control.Exception as E -import StoreService.Log (logWarn) -import Control.Monad (mfilter, unless, when) +import StoreService.Log (logInfo, logWarn) +import Control.Applicative (many) +import Control.Monad (forM_, mfilter, unless, when) +import Data.Attoparsec.Combinator (lookAhead) import Data.Attoparsec.Text (Parser, endOfInput, isEndOfLine, parseOnly, satisfy, skipMany, skipSpace, skipWhile) +import Data.Bifunctor (first) import qualified Data.ByteArray as BA import Data.ByteArray.Encoding (Base (Base16), convertFromBase) +import Data.ByteString (ByteString) import qualified Data.ByteString.Char8 as B -import Data.Ini (Ini, iniGlobals, iniParser, keys, lookupValue, sections) -import Data.List (nub) +import Data.Char (GeneralCategory (..), generalCategory, isAsciiLower, isAsciiUpper, isControl, isDigit, isSpace) +import Data.Ini (Ini, iniGlobals, iniParser, keyValueParser, keys, lookupValue, sectionParser, sections) +import Data.List (nub, (\\)) +import Data.Map.Strict (Map) +import qualified Data.Map.Strict as M import Data.Maybe (fromMaybe, isJust, isNothing) import Data.Text (Text) import qualified Data.Text as T @@ -42,12 +50,15 @@ import qualified Data.Text.IO as TIO import Data.Time.Clock (UTCTime) import Data.Time.Format.ISO8601 (iso8601ParseM) import Data.Word (Word64) +import Simplex.Chat.Badges.Code (maxNameLength, minNameLength) import Simplex.Chat.Library.Commands (mkValidName) import Simplex.Messaging.Crypto.BBS (BBSSecretKey) import Simplex.Messaging.Crypto.Secp256k1 (Secp256k1PrivateKey, mkPrivateKey) import Simplex.Messaging.Encoding.String (strDecode) import Simplex.Messaging.Eth.Address (Address) -import Simplex.Messaging.SimplexName (SimplexTLD (..)) +import Simplex.Messaging.SimplexName (LabelHash (..), SimplexTLD (..), labelHash) +import Simplex.Messaging.Util (tshow) +import StoreService.Names.Label (LabelError (..), validLabel) import System.IO.Error (ioeGetErrorString) import Text.Read (readMaybe) @@ -132,10 +143,17 @@ data NamesConfig = NamesConfig nTermSlackSeconds :: Int, nRevealMarginSeconds :: Int, nMaxOrderMoves :: Int, + nTerms :: TermsConfig, + -- | The operator's community names, keyed by the label hash's bytes, as search receives a name. + nCommunity :: Map ByteString Text, nChain :: ChainConfig } deriving (Eq, Show) +-- | The name terms buyers agree to, and the page the order's checkbox links to. +data TermsConfig = TermsConfig {tVersion :: Text, tUrl :: Text} + deriving (Eq, Show) + data ChainConfig = ChainConfig { cRpcUrl :: String, cChainId :: Word64, @@ -188,6 +206,12 @@ requireHttps u | "https://" `T.isPrefixOf` u = Right u | otherwise = Left "btcpay.host must be an absolute https URL" +maxTermsVersionLength :: Int +maxTermsVersionLength = 32 + +maxCommunityTextLength :: Int +maxCommunityTextLength = 200 + -- | The controller refuses a registration under 730 days. minNameYears :: Int minNameYears = 2 @@ -215,11 +239,28 @@ readServiceConfig path = E.try (TIO.readFile path) >>= \case Left (e :: E.IOException) -> pure (Left (ioeGetErrorString e)) -- Data.Ini stops at the first unparseable line and keeps what it has, so a missing `=` silently drops every section below. - Right text -> case parseOnly (iniParser <* trailingNoise <* endOfInput) text of + Right text -> case parseOnly ((,) <$> lookAhead (many keyValueParser *> many sectionParser) <*> iniParser <* trailingNoise <* endOfInput) text of Left _ -> pure (Left "could not be read as an ini file: a line is malformed") - Right ini -> do - mapM_ (\k -> logWarn (T.pack path <> ": nothing reads " <> k <> ", so it was ignored")) (unknownKeys ini) - pure (parseConfig ini) + Right (written, ini) -> case repeatedSetting written of + Just e -> pure (Left e) + Nothing -> do + mapM_ (\k -> logWarn (T.pack path <> ": nothing reads " <> k <> ", so it was ignored")) (unknownKeys ini) + let cfg = parseConfig ini + forM_ (either (const Nothing) names cfg) $ \NamesConfig {nCommunity} -> + logInfo ("names: " <> tshow (M.size nCommunity) <> " community names loaded") + pure cfg + +-- | Data.Ini keeps only the last section of a name and the first value of a key in a section, +-- so the sections are read in order to refuse a repeated one. +repeatedSetting :: [(Text, [(Text, Text)])] -> Maybe String +repeatedSetting written = case repeated (map fst written) of + h : _ -> Just ("[" <> T.unpack h <> "] is written twice, and only the last of them would be read") + [] -> case [(s, k) | (s, kvs) <- written, k <- repeated (map fst kvs)] of + (s, k) : _ -> Just (T.unpack s <> "." <> T.unpack k <> " is written twice, and only the first of them would be read") + [] -> Nothing + +repeated :: Eq a => [a] -> [a] +repeated xs = nub (xs \\ nub xs) -- | iniParser stops before a trailing comment, so endOfInput alone would reject a valid file. trailingNoise :: Parser () @@ -246,7 +287,7 @@ knownSettings = ("poll", ["waiting_seconds", "idle_seconds"]), ("group", ["display_name", "description"]), ("issuer", ["index", "private_key", "dev_keys"]), - ("names", ["tld", "years", "term_start", "term_slack_seconds", "reveal_margin_seconds", "max_order_moves"]), + ("names", ["tld", "terms_version", "terms_url", "years", "term_start", "term_slack_seconds", "reveal_margin_seconds", "max_order_moves"]), ("chain", ["rpc_url", "chain_id", "controller", "sponsor", "base_registrar", "resolver", "service_key", "confirmations", "gas_limit_cap", "max_fee_per_gas"]) ] @@ -254,7 +295,8 @@ unknownKeys :: Ini -> [Text] unknownKeys ini = beforeAnySection <> unknownSections <> settings where beforeAnySection = [k <> ", written above the first section header" | (k, _) <- iniGlobals ini] - unknownSections = ["[" <> s <> "]" | s <- sections ini, T.strip s `notElem` map fst knownSettings] + -- [community] keys are labels, which parseConfig checks. + unknownSections = ["[" <> s <> "]" | s <- sections ini, T.strip s `notElem` ("community" : map fst knownSettings)] settings = [ section <> "." <> key | (section, known) <- knownSettings, @@ -403,7 +445,9 @@ parseConfig ini = do let sHost = defaultStripeHost pure (Just StripeConfig {sSecretKey, sPublishableKey, sWebhookSecret, sSessionMinutes, sHost}) namesSection - | not (hasSection "names") = if hasSection "chain" then Left "[chain] is set but [names] is absent, so nothing would use it" else Right Nothing + | not (hasSection "names") = case filter hasSection ["chain", "community"] of + s : _ -> Left ("[" <> T.unpack s <> "] is set but [names] is absent, so nothing would use it") + [] -> Right Nothing | not (hasSection "chain") = Left "[names] needs a [chain] section" | otherwise = do nTLD <- tldSetting @@ -413,8 +457,10 @@ parseConfig ini = do nTermSlackSeconds <- atLeast 0 "names" "term_slack_seconds" 3600 nRevealMarginSeconds <- atLeast 0 "names" "reveal_margin_seconds" 900 nMaxOrderMoves <- atLeast 0 "names" "max_order_moves" 3 + nTerms <- termsSetting + nCommunity <- communityList nTLD nChain <- chainSection - pure (Just NamesConfig {nTLD, nYears, nTermStart, nTermSlackSeconds, nRevealMarginSeconds, nMaxOrderMoves, nChain}) + pure (Just NamesConfig {nTLD, nYears, nTermStart, nTermSlackSeconds, nRevealMarginSeconds, nMaxOrderMoves, nTerms, nCommunity, nChain}) tldSetting = required "names" "tld" >>= \case "simplex" -> Right TLDSimplex @@ -423,6 +469,30 @@ parseConfig ini = do termStart = maybe (Left ("names.term_start must be an ISO 8601 UTC time such as " <> defaultTermStart)) Right $ iso8601ParseM (maybe defaultTermStart T.unpack (present "names" "term_start")) + termsSetting = do + tVersion <- required "names" "terms_version" + unless (T.length tVersion <= maxTermsVersionLength && T.all versionChar tVersion) $ + Left ("names.terms_version must be 1 to " <> show maxTermsVersionLength <> " letters, digits, dots, underscores or hyphens") + tUrl <- required "names" "terms_url" + unless (httpsUrl tUrl) $ Left "names.terms_url must be an https URL" + pure TermsConfig {tVersion, tUrl} + versionChar c = isAsciiUpper c || isAsciiLower c || isDigit c || c `elem` ("._-" :: String) + httpsUrl u = case T.stripPrefix "https://" u of + Just rest -> not (T.null rest) && T.all (\c -> not (isSpace c || isControl c)) rest + Nothing -> False + communityList tld = M.fromList <$> mapM (communityEntry tld) (either (const []) id (keys "community" ini)) + communityEntry tld label = do + let k = "community." <> T.unpack label + text = fromMaybe "" (look "community" label) + valid <- first (labelRefusal k) (validLabel tld label) + when (T.null text || T.length text > maxCommunityTextLength) $ Left (k <> " must be 1 to " <> show maxCommunityTextLength <> " characters") + when (T.any invisible text) $ Left (k <> " has a control, format or line separator character") + let LabelHash h = labelHash valid + pure (h, text) + invisible c = isControl c || generalCategory c `elem` [Format, LineSeparator, ParagraphSeparator] + labelRefusal k = \case + LELength -> k <> " must be " <> show minNameLength <> " to " <> show maxNameLength <> " characters" + LEChars -> k <> " must be lower-case letters and digits, with single hyphens between them" atLeast lowest s k d = do v <- num s k d if v >= lowest then Right v else Left (T.unpack s <> "." <> T.unpack k <> " must be at least " <> show lowest) diff --git a/apps/simplex-store-service/src/StoreService/Names/Community.hs b/apps/simplex-store-service/src/StoreService/Names/Community.hs new file mode 100644 index 0000000000..bde20b99fa --- /dev/null +++ b/apps/simplex-store-service/src/StoreService/Names/Community.hs @@ -0,0 +1,33 @@ +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} + +module StoreService.Names.Community + ( communityFor, + termsFor, + communityJSON, + termsJSON, + ) +where + +import Data.Aeson ((.=)) +import qualified Data.Aeson as J +import qualified Data.Map.Strict as M +import Data.Text (Text) +import Simplex.Messaging.SimplexName (LabelHash (..)) +import StoreService.Config (NamesConfig (..), TermsConfig (..)) + +communityFor :: NamesConfig -> LabelHash -> Maybe Text +communityFor NamesConfig {nCommunity} (LabelHash h) = M.lookup h nCommunity + +-- | The suffix records that the buyer agreed to the community wording, under the same version. +termsFor :: NamesConfig -> LabelHash -> Text +termsFor cfg@NamesConfig {nTerms = TermsConfig {tVersion}} lh = maybe tVersion (const $ tVersion <> communityTermsSuffix) (communityFor cfg lh) + +communityTermsSuffix :: Text +communityTermsSuffix = "/community" + +communityJSON :: NamesConfig -> LabelHash -> J.Value +communityJSON cfg lh = maybe J.Null (\t -> J.object ["for" .= t]) (communityFor cfg lh) + +termsJSON :: NamesConfig -> J.Value +termsJSON NamesConfig {nTerms = TermsConfig {tVersion, tUrl}} = J.object ["version" .= tVersion, "url" .= tUrl] diff --git a/apps/simplex-store-service/src/StoreService/Names/View.hs b/apps/simplex-store-service/src/StoreService/Names/View.hs index eef633d2a9..26baf77d3b 100644 --- a/apps/simplex-store-service/src/StoreService/Names/View.hs +++ b/apps/simplex-store-service/src/StoreService/Names/View.hs @@ -34,6 +34,7 @@ import Simplex.Messaging.Util (safeDecodeUtf8) import StoreService.Config (ChainConfig (..), NamesConfig (..)) import StoreService.Names.Abi (hex) import StoreService.Names.Chain (TxHash (..)) +import StoreService.Names.Community (communityJSON) import StoreService.Names.Contracts (Contracts, sponsorNonce) import StoreService.Names.Phases (RegistrationId (..)) import StoreService.Names.Store (Balance (..), BalanceHold (..), LateWait (..), NamePhase (..), NameRegistration (..), NameReveal (..), RegistrationPayment (..), getBalance, getRegistration, heldBy, lateWait) @@ -67,7 +68,7 @@ readViewChain ct NameRegistration {nrPhase, nrOwner} = case nrPhase of none = ViewReads {vrIntentNonce = Nothing, vrUnread = []} viewJSON :: NamesConfig -> NameRegistration -> Maybe Balance -> Maybe LateWait -> ViewReads -> J.Value -viewJSON NamesConfig {nTLD, nChain = ChainConfig {cChainId, cResolver, cSponsor}} r balance late ViewReads {vrIntentNonce, vrUnread} = +viewJSON cfg@NamesConfig {nTLD, nChain = ChainConfig {cChainId, cResolver, cSponsor}} r balance late ViewReads {vrIntentNonce, vrUnread} = J.object [ "id" .= rid, "version" .= nrVersion, @@ -96,7 +97,8 @@ viewJSON NamesConfig {nTLD, nChain = ChainConfig {cChainId, cResolver, cSponsor} "intentNonce" .= vrIntentNonce, "unread" .= vrUnread, "expiresAt" .= nrExpiresAt, - "error" .= nrError + "error" .= nrError, + "community" .= communityJSON cfg (nrLabelHash r) ] where NameRegistration {nrId = RegistrationId rid, nrVersion, nrMoves, nrLinksRefusedAt, nrPhase, nrLabelHash = LabelHash lh, nrReveal, nrOwner, nrCommitment, nrSealed} = r diff --git a/apps/simplex-store-service/src/StoreService/Web/Names/Search.hs b/apps/simplex-store-service/src/StoreService/Web/Names/Search.hs index 4ab8d095c5..88216bb695 100644 --- a/apps/simplex-store-service/src/StoreService/Web/Names/Search.hs +++ b/apps/simplex-store-service/src/StoreService/Web/Names/Search.hs @@ -26,6 +26,7 @@ import Simplex.Messaging.Util (tshow) import StoreService.Config (ChainConfig (..), NamesConfig (..)) import StoreService.Log (logWarn) import StoreService.Names.Abi (bytes32FromHex) +import StoreService.Names.Community (communityJSON, termsJSON) import StoreService.Names.Contracts import StoreService.Names.Links (LinkKind (..), linkKindOf) import StoreService.Names.Profiles (Entity, Profiles, linkEntity, linkPreview) @@ -51,7 +52,7 @@ searchHandler cfg@NamesConfig {nYears} st ct ps hashText req respond = case Labe Just len -> do registering <- withTransaction st (`isLabelRegistering` lh) timeout (ctDeadline ct) (runExceptT $ found lh len registering) >>= \case - Just (Right f) -> respond =<< searchAnswer cfg ps f + Just (Right f) -> respond =<< searchAnswer cfg ps lh f Just (Left e) -> unavailable $ tshow e Nothing -> unavailable "timed out" where @@ -76,13 +77,14 @@ queryLength req = case join $ lookup "length" $ queryString req of | n >= toInteger minNameLength && n <= toInteger maxNameLength = Just $ fromInteger n | otherwise = Nothing -searchAnswer :: NamesConfig -> Profiles -> Found -> IO Response -searchAnswer NamesConfig {nTLD, nYears, nTermStart, nChain = ChainConfig {cChainId, cResolver, cSponsor}} ps = \case +searchAnswer :: NamesConfig -> Profiles -> LabelHash -> Found -> IO Response +searchAnswer cfg@NamesConfig {nTLD, nYears, nTermStart, nChain = ChainConfig {cChainId, cResolver, cSponsor}} ps lh = \case FAvailable price -> ok $ ["status" .= ("available" :: Text), "price" .= price, "currency" .= ("usd" :: Text), "years" .= nYears, "termStart" .= termStart] <> ["resolver" .= addressText cResolver, "sponsor" .= addressText cSponsor] <> chain + <> ["terms" .= termsJSON cfg] FRegistering -> ok ["status" .= ("registered" :: Text), "entities" .= ([] :: [Entity]), "tld" .= tldText nTLD] FReserved -> ok ["status" .= ("reserved" :: Text)] FRegistered RegisteredName {rnOwner, rnResolver, rnExpiresAt, rnGracePeriod} records_ -> do @@ -94,7 +96,7 @@ searchAnswer NamesConfig {nTLD, nYears, nTermStart, nChain = ChainConfig {cChain <> chain where ok :: [JT.Pair] -> IO Response - ok = pure . jsonResponse status200 . J.object + ok fields = pure $ jsonResponse status200 $ J.object $ fields <> ["community" .= communityJSON cfg lh] chain = ["chainId" .= cChainId, "tld" .= tldText nTLD] termStart = floor (utcTimeToPOSIXSeconds nTermStart) :: Integer recordsJSON NameRecords {rdContact, rdChannel} = J.object ["contact" .= rdContact, "channel" .= rdChannel] diff --git a/apps/simplex-store-service/store_service.ini.example b/apps/simplex-store-service/store_service.ini.example index 80b8ff8478..e52d7b2ea9 100644 --- a/apps/simplex-store-service/store_service.ini.example +++ b/apps/simplex-store-service/store_service.ini.example @@ -70,6 +70,10 @@ idle_seconds = 60 ;[names] ; testing until the .simplex TLD is deployed ;tld = testing +; the version of the name terms buyers agree to (1 to 32 letters, digits, dots, underscores or +; hyphens), and the page the order's checkbox links to; both required, read only at startup +;terms_version = 2026-10-23 +;terms_url = https://simplex.chat/ ; the paid years of a name; at least 2, the controller's minimum ;years = 2 ; a name's term starts here; the days until then are free @@ -81,6 +85,12 @@ idle_seconds = 60 ; how many times one order may move to another name ;max_order_moves = 3 ; +; community names, with [names] only: one line per name, `