diff --git a/apps/simplex-badge-service/src/BadgeService/Options.hs b/apps/simplex-badge-service/src/BadgeService/Options.hs index 38b352dded..08046a5c36 100644 --- a/apps/simplex-badge-service/src/BadgeService/Options.hs +++ b/apps/simplex-badge-service/src/BadgeService/Options.hs @@ -155,5 +155,8 @@ mkChatOpts BadgeServiceOpts {coreOptions, serviceName, clientService} = markRead = False, createBot = Just CreateBotOpts {botDisplayName = serviceName, allowFiles = False, clientService}, userDisplayName = Nothing, - userImageFile = Nothing + userImageFile = Nothing, + optBadgeServiceAddress = Nothing, + optBadgeWebUrl = Nothing, + optBadgeIssuerKeys = [] } diff --git a/apps/simplex-broadcast-bot/src/Broadcast/Options.hs b/apps/simplex-broadcast-bot/src/Broadcast/Options.hs index 1722bbfdd0..42245330c7 100644 --- a/apps/simplex-broadcast-bot/src/Broadcast/Options.hs +++ b/apps/simplex-broadcast-bot/src/Broadcast/Options.hs @@ -97,5 +97,8 @@ mkChatOpts BroadcastBotOpts {coreOptions, botDisplayName} = markRead = False, createBot = Just CreateBotOpts {botDisplayName, allowFiles = False, clientService = False}, userDisplayName = Nothing, - userImageFile = Nothing + userImageFile = Nothing, + optBadgeServiceAddress = Nothing, + optBadgeWebUrl = Nothing, + optBadgeIssuerKeys = [] } diff --git a/apps/simplex-directory-service/src/Directory/Options.hs b/apps/simplex-directory-service/src/Directory/Options.hs index 89709e66d9..46d2950d93 100644 --- a/apps/simplex-directory-service/src/Directory/Options.hs +++ b/apps/simplex-directory-service/src/Directory/Options.hs @@ -230,5 +230,8 @@ mkChatOpts DirectoryOpts {coreOptions, serviceName, clientService} = markRead = False, createBot = Just CreateBotOpts {botDisplayName = serviceName, allowFiles = False, clientService}, userDisplayName = Nothing, - userImageFile = Nothing + userImageFile = Nothing, + optBadgeServiceAddress = Nothing, + optBadgeWebUrl = Nothing, + optBadgeIssuerKeys = [] } diff --git a/bots/api/TYPES.md b/bots/api/TYPES.md index 665dd26889..aedb158999 100644 --- a/bots/api/TYPES.md +++ b/bots/api/TYPES.md @@ -13,6 +13,7 @@ This file is generated automatically. - [AutoAccept](#autoaccept) - [BadgeInfo](#badgeinfo) - [BadgeProof](#badgeproof) +- [BadgeServiceErrorCode](#badgeserviceerrorcode) - [BadgeStatus](#badgestatus) - [BadgeType](#badgetype) - [BlockingInfo](#blockinginfo) @@ -414,6 +415,32 @@ BadSignature: - badgeInfo: [BadgeInfo](#badgeinfo) +--- + +## BadgeServiceErrorCode + +Badge service error code. Clients must accept unknown codes: the service can be deployed ahead of them. + +**Enum type**: +- "bad_request" +- "unsupported_version" +- "unknown_purchase_key" +- "unknown_offer_id" +- "offer_disabled" +- "offer_mismatch" +- "product_unavailable" +- "payment_not_entitled" +- "payment_pending" +- "provider_unavailable" +- "rate_limited" +- "code_invalid" +- "code_used" +- "code_expired" +- "receipt_invalid" +- "receipt_used" +- "internal" + + --- ## BadgeStatus @@ -1361,6 +1388,12 @@ RelayTestError: - type: "relayTestError" - message: string +BadgeServiceError: +- type: "badgeServiceError" +- badgeError: [BadgeServiceErrorCode](#badgeserviceerrorcode) +- badgeErrorMessage: string? +- retryAfter: word32? + InternalError: - type: "internalError" - message: string diff --git a/bots/src/API/Docs/Commands.hs b/bots/src/API/Docs/Commands.hs index d126ff1844..a17be9a593 100644 --- a/bots/src/API/Docs/Commands.hs +++ b/bots/src/API/Docs/Commands.hs @@ -371,6 +371,8 @@ undocumentedCommands = "APIExportArchive", "APIForwardChatItems", "APIGetAppSettings", + "APIGetBadgeCatalog", + "APIGetBadgeState", "APIGetCallInvitations", "APIGetChat", "APIGetChatContentTypes", @@ -398,6 +400,7 @@ undocumentedCommands = "APIPlanForwardChatItems", "APIPrepareContact", "APIPrepareGroup", + "APIPurchaseBadge", "APIRegisterToken", "APIRejectCall", "APIReorderChatTags", diff --git a/bots/src/API/Docs/Events.hs b/bots/src/API/Docs/Events.hs index 1590ad54d5..a3c3b9ab55 100644 --- a/bots/src/API/Docs/Events.hs +++ b/bots/src/API/Docs/Events.hs @@ -172,6 +172,7 @@ undocumentedEvents = "CEvtAgentConnsDeleted", "CEvtAgentRcvQueuesDeleted", "CEvtAgentUserDeleted", + "CEvtBadgeChanged", "CEvtBusinessRequestAlreadyAccepted", "CEvtCallAnswer", "CEvtCallEnded", diff --git a/bots/src/API/Docs/Responses.hs b/bots/src/API/Docs/Responses.hs index 76f1ddb76b..5b51028966 100644 --- a/bots/src/API/Docs/Responses.hs +++ b/bots/src/API/Docs/Responses.hs @@ -129,6 +129,8 @@ undocumentedResponses = "CRAppSettings", "CRArchiveExported", "CRArchiveImported", + "CRBadgeCatalog", + "CRBadgeState", "CRBroadcastSent", "CRCallInvitations", "CRChatCleared", diff --git a/bots/src/API/Docs/Types.hs b/bots/src/API/Docs/Types.hs index e0af1f3ff5..45b53a0485 100644 --- a/bots/src/API/Docs/Types.hs +++ b/bots/src/API/Docs/Types.hs @@ -35,6 +35,7 @@ import Simplex.Chat.Store.Shared import Simplex.Chat.Operators import Simplex.Messaging.Agent.Store.Entity (DBStored (..)) import Simplex.Chat.Badges +import Simplex.Chat.Badges.Service (BadgeServiceErrorCode (..)) import Simplex.Chat.Names import Simplex.Chat.Types import Simplex.Chat.Types.Preferences @@ -222,6 +223,7 @@ chatTypesDocsData = (sti @ChatDeleteMode, STUnion, "CDM", [], Param "type" <> Choice "self" [("messages", "")] (OnOffParam "notify" "notify" (Just True)), ""), (sti @ChatError, STUnion, "Chat", ["ChatErrorDatabase", "ChatErrorRemoteHost", "ChatErrorRemoteCtrl"], "", ""), (sti @ChatErrorType, STUnion, "CE", ["CEContactNotFound", "CEServerProtocol", "CECallState", "CEInvalidChatMessage"], "", ""), + (sti @BadgeServiceErrorCode, STEnum' (consSep "BSE" '_'), "", ["BSEUnknown"], "", "Badge service error code. Clients must accept unknown codes: the service can be deployed ahead of them."), (sti @BadgeStatus, STEnum, "BS", [], "", ""), (sti @BadgeType, STEnum, "BT", ["BTUnknown"], "", ""), (sti @ChatFeature, STEnum, "CF", [], "", ""), @@ -445,6 +447,7 @@ deriving instance Generic BlockingReason deriving instance Generic BrokerErrorType deriving instance Generic BusinessChatInfo deriving instance Generic BusinessChatType +deriving instance Generic BadgeServiceErrorCode deriving instance Generic BadgeStatus deriving instance Generic BadgeType deriving instance Generic ChatBotCommand diff --git a/packages/simplex-chat-client/types/typescript/src/types.ts b/packages/simplex-chat-client/types/typescript/src/types.ts index cae52090e7..3938c46760 100644 --- a/packages/simplex-chat-client/types/typescript/src/types.ts +++ b/packages/simplex-chat-client/types/typescript/src/types.ts @@ -240,6 +240,27 @@ export interface BadgeProof { proof: string badgeInfo: BadgeInfo } +// Badge service error code. Clients must accept unknown codes: the service can be deployed ahead of them. + +export enum BadgeServiceErrorCode { + Bad_request = "bad_request", + Unsupported_version = "unsupported_version", + Unknown_purchase_key = "unknown_purchase_key", + Unknown_offer_id = "unknown_offer_id", + Offer_disabled = "offer_disabled", + Offer_mismatch = "offer_mismatch", + Product_unavailable = "product_unavailable", + Payment_not_entitled = "payment_not_entitled", + Payment_pending = "payment_pending", + Provider_unavailable = "provider_unavailable", + Rate_limited = "rate_limited", + Code_invalid = "code_invalid", + Code_used = "code_used", + Code_expired = "code_expired", + Receipt_invalid = "receipt_invalid", + Receipt_used = "receipt_used", + Internal = "internal", +} export enum BadgeStatus { Active = "active", @@ -1151,6 +1172,7 @@ export type ChatErrorType = | ChatErrorType.ConnectionUserChangeProhibited | ChatErrorType.PeerChatVRangeIncompatible | ChatErrorType.RelayTestError + | ChatErrorType.BadgeServiceError | ChatErrorType.InternalError | ChatErrorType.Exception @@ -1230,6 +1252,7 @@ export namespace ChatErrorType { | "connectionUserChangeProhibited" | "peerChatVRangeIncompatible" | "relayTestError" + | "badgeServiceError" | "internalError" | "exception" @@ -1595,6 +1618,13 @@ export namespace ChatErrorType { message: string } + export interface BadgeServiceError extends Interface { + type: "badgeServiceError" + badgeError: BadgeServiceErrorCode + badgeErrorMessage?: string + retryAfter?: number // word32 + } + export interface InternalError extends Interface { type: "internalError" message: string diff --git a/packages/simplex-chat-python/src/simplex_chat/types/_types.py b/packages/simplex-chat-python/src/simplex_chat/types/_types.py index 512a7464db..fc82ce17a1 100644 --- a/packages/simplex-chat-python/src/simplex_chat/types/_types.py +++ b/packages/simplex-chat-python/src/simplex_chat/types/_types.py @@ -179,6 +179,10 @@ class BadgeProof(TypedDict): proof: str badgeInfo: "BadgeInfo" +# Badge service error code. Clients must accept unknown codes: the service can be deployed ahead of them. + +BadgeServiceErrorCode = Literal["bad_request", "unsupported_version", "unknown_purchase_key", "unknown_offer_id", "offer_disabled", "offer_mismatch", "product_unavailable", "payment_not_entitled", "payment_pending", "provider_unavailable", "rate_limited", "code_invalid", "code_used", "code_expired", "receipt_invalid", "receipt_used", "internal"] + BadgeStatus = Literal["active", "expired", "expiredOld", "failed", "unknownKey"] BadgeType = Literal["supporter", "legend", "investor"] @@ -1042,6 +1046,12 @@ class ChatErrorType_relayTestError(TypedDict): type: Literal["relayTestError"] message: str +class ChatErrorType_badgeServiceError(TypedDict): + type: Literal["badgeServiceError"] + badgeError: "BadgeServiceErrorCode" + badgeErrorMessage: NotRequired[str] + retryAfter: NotRequired[int] # word32 + class ChatErrorType_internalError(TypedDict): type: Literal["internalError"] message: str @@ -1125,11 +1135,12 @@ ChatErrorType = ( | ChatErrorType_connectionUserChangeProhibited | ChatErrorType_peerChatVRangeIncompatible | ChatErrorType_relayTestError + | ChatErrorType_badgeServiceError | ChatErrorType_internalError | ChatErrorType_exception ) -ChatErrorType_Tag = Literal["noActiveUser", "noConnectionUser", "noSndFileUser", "noRcvFileUser", "userUnknown", "userExists", "chatRelayExists", "differentActiveUser", "cantDeleteActiveUser", "cantDeleteLastUser", "cantHideLastUser", "hiddenUserAlwaysMuted", "emptyUserPassword", "userAlreadyHidden", "userNotHidden", "invalidDisplayName", "chatNotStarted", "chatNotStopped", "chatStoreChanged", "invalidConnReq", "simplexDomainNotReady", "notResolvedLocally", "unsupportedConnReq", "connReqMessageProhibited", "contactNotReady", "contactNotActive", "contactDisabled", "connectionDisabled", "groupUserRole", "groupMemberInitialRole", "contactIncognitoCantInvite", "groupIncognitoCantInvite", "groupContactRole", "groupDuplicateMember", "groupDuplicateMemberId", "groupNotJoined", "groupMemberNotActive", "cantBlockMemberForSelf", "groupMemberUserRemoved", "groupMemberNotFound", "groupCantResendInvitation", "groupInternal", "fileNotFound", "fileSize", "fileAlreadyReceiving", "fileCancelled", "fileCancel", "fileAlreadyExists", "fileWrite", "fileSend", "fileRcvChunk", "fileInternal", "fileImageType", "fileImageSize", "fileNotReceived", "fileNotApproved", "fallbackToSMPProhibited", "inlineFileProhibited", "invalidForward", "invalidChatItemUpdate", "invalidChatItemDelete", "hasCurrentCall", "noCurrentCall", "callContact", "directMessagesProhibited", "agentVersion", "agentNoSubResult", "commandError", "agentCommandError", "invalidFileDescription", "connectionIncognitoChangeProhibited", "connectionUserChangeProhibited", "peerChatVRangeIncompatible", "relayTestError", "internalError", "exception"] +ChatErrorType_Tag = Literal["noActiveUser", "noConnectionUser", "noSndFileUser", "noRcvFileUser", "userUnknown", "userExists", "chatRelayExists", "differentActiveUser", "cantDeleteActiveUser", "cantDeleteLastUser", "cantHideLastUser", "hiddenUserAlwaysMuted", "emptyUserPassword", "userAlreadyHidden", "userNotHidden", "invalidDisplayName", "chatNotStarted", "chatNotStopped", "chatStoreChanged", "invalidConnReq", "simplexDomainNotReady", "notResolvedLocally", "unsupportedConnReq", "connReqMessageProhibited", "contactNotReady", "contactNotActive", "contactDisabled", "connectionDisabled", "groupUserRole", "groupMemberInitialRole", "contactIncognitoCantInvite", "groupIncognitoCantInvite", "groupContactRole", "groupDuplicateMember", "groupDuplicateMemberId", "groupNotJoined", "groupMemberNotActive", "cantBlockMemberForSelf", "groupMemberUserRemoved", "groupMemberNotFound", "groupCantResendInvitation", "groupInternal", "fileNotFound", "fileSize", "fileAlreadyReceiving", "fileCancelled", "fileCancel", "fileAlreadyExists", "fileWrite", "fileSend", "fileRcvChunk", "fileInternal", "fileImageType", "fileImageSize", "fileNotReceived", "fileNotApproved", "fallbackToSMPProhibited", "inlineFileProhibited", "invalidForward", "invalidChatItemUpdate", "invalidChatItemDelete", "hasCurrentCall", "noCurrentCall", "callContact", "directMessagesProhibited", "agentVersion", "agentNoSubResult", "commandError", "agentCommandError", "invalidFileDescription", "connectionIncognitoChangeProhibited", "connectionUserChangeProhibited", "peerChatVRangeIncompatible", "relayTestError", "badgeServiceError", "internalError", "exception"] ChatFeature = Literal["timedMessages", "fullDelete", "reactions", "voice", "files", "calls", "sessions"] diff --git a/plans/badges-codes/2026-08-21-badges-web-checkout.md b/plans/badges-codes/2026-08-21-badges-web-checkout.md index 955dff76c2..3bb964b96c 100644 --- a/plans/badges-codes/2026-08-21-badges-web-checkout.md +++ b/plans/badges-codes/2026-08-21-badges-web-checkout.md @@ -145,7 +145,7 @@ The two `-m` filters are needed because the badge tests live under two hspec pat | B9 | Service address publication | A6, B5 | ☑ | | B10 | Service integration tests | B7, B8 | ☑ | | C1 | `Store/Badges.hs`: client badge store | A1, A2 | ☑ | -| C2 | Commands, responses, events, parsers, View | A2, B2, C1 | ☐ | +| C2 | Commands, responses, events, parsers, View | A2, B2, C1 | ☑ | | C3 | `BadgeManager` worker | C1, C2 | ☐ | | C4 | Redeem path wired end to end | B6, B7, B9, C3 | ☐ | | C5 | Client and service integration tests | B10, C4 | ☐ | @@ -651,7 +651,7 @@ Phase C ends with a chat client that can redeem a code minted by B8 and show the #### C2 — Commands, responses, events, parsers, View -**Files:** `src/Simplex/Chat/Controller.hs`, `src/Simplex/Chat.hs`, `src/Simplex/Chat/Library/Commands.hs`, `src/Simplex/Chat/Store/Badges.hs` (unchanged; C1's readers serve `APIGetBadgeState`), `src/Simplex/Chat/View.hs`, `src/Simplex/Chat/{Options.hs,Mobile.hs,Terminal/Main.hs}`, `apps/simplex-directory-service/src/Directory/Options.hs`, `apps/simplex-broadcast-bot/src/Broadcast/Options.hs`, `apps/simplex-badge-service/src/BadgeService/Options.hs`, `tests/ChatClient.hs`, `bots/src/API/Docs/{Commands.hs,Responses.hs,Events.hs,Types.hs}`, `bots/api/{COMMANDS,EVENTS,TYPES}.md`, `packages/simplex-chat-client/types/typescript/src/{commands,responses,events,types}.ts`, `packages/simplex-chat-python/src/simplex_chat/types/_{commands,responses,events,types}.py` (all regenerated), `tests/BadgeTests.hs`, `tests/APIDocs.hs` (unchanged; the asserting module) +**Files:** `src/Simplex/Chat/Controller.hs`, `src/Simplex/Chat.hs`, `src/Simplex/Chat/Library/Commands.hs`, `src/Simplex/Chat/Store/Badges.hs` (unchanged; C1's readers serve `APIGetBadgeState`), `src/Simplex/Chat/View.hs`, `src/Simplex/Chat/{Options.hs,Mobile.hs,Terminal/Main.hs}`, `apps/simplex-directory-service/src/Directory/Options.hs`, `apps/simplex-broadcast-bot/src/Broadcast/Options.hs`, `apps/simplex-badge-service/src/BadgeService/Options.hs`, `tests/ChatClient.hs`, `src/Simplex/Chat/Badges/Types.hs`, `bots/src/API/Docs/{Commands.hs,Responses.hs,Events.hs,Types.hs}`, `bots/api/TYPES.md`, `packages/simplex-chat-client/types/typescript/src/types.ts`, `packages/simplex-chat-python/src/simplex_chat/types/_types.py` (regenerated), `tests/BadgeTests.hs`, `tests/APIDocs.hs` (unchanged; the asserting module) **Do:** @@ -664,11 +664,11 @@ Phase C ends with a chat client that can redeem a code minted by B8 and show the | APIPurchaseBadge {userId :: UserId, payment :: BadgePurchasePayment} -- /_badge purchase ``` -- Responses `CRBadgeState` and `CRBadgeCatalog`; event `CEvtBadgeChanged`; error `CEBadgeServiceError {badgeError, message, retryAfter}` for the inline redeem errors (UX §2.8). `retryAfter` is populated by B5's `rate_limited`. +- Responses `CRBadgeState` and `CRBadgeCatalog`; event `CEvtBadgeChanged`; error `CEBadgeServiceError {badgeError, badgeErrorMessage, retryAfter}` for the inline redeem errors (UX §2.8). `retryAfter` is `Maybe Word32`, populated by B5's `rate_limited`. The message field is NOT `message`: a record field has one type across a whole data type and `ChatErrorType.message` is already `String` in six constructors (§9). - `CRBadgeState` carries the shown badge's paid-through date, `addMonths balanceMonths balanceStartTs` over the last ledger entry, using B2's `Simplex.Chat.Badges.Months`; the clamping rule has one implementation and the client does not restate it. G6 renders it in place of `stubBillingDate()` and its Swift equivalent. UX §2.11 requires the paid-through date and forbids the phrase "badge valid until", so the credential expiry is never shown. - `CRBadgeState` also carries `badgeWebBaseUrl`, filled by this step's `APIGetBadgeState` handler from `ChatConfig`. `ChatConfig` is not readable from Kotlin or Swift, so the site URL travels in a response rather than through the FFI, and it rides on the badge-state response, which is a local read, so the browser hand-off does not depend on the service being reachable. - Parsers in `chatCommandP`, rendering in `View.hs`. -- `APIGetBadgeState` is handled in this step. `APIPurchaseBadge` and `APIGetBadgeCatalog` get handlers that `throwChatError $ CEBadgeServiceError {badgeError = BSEInternal, message = Just "not implemented", retryAfter = Nothing}`, following the raise convention of every other handler (`Library/Commands.hs:512`), which C4 replaces. This step therefore compiles and its unfiltered `cabal test` passes with no command unhandled. It is the same stub discipline C3 uses for `sendBadgeRequest`. +- `APIGetBadgeState` is handled in this step. `APIPurchaseBadge` and `APIGetBadgeCatalog` get handlers that `throwChatError $ CEBadgeServiceError {badgeError = BSEInternal, badgeErrorMessage = Just "not implemented", retryAfter = Nothing}`, following the raise convention of every other handler (`Library/Commands.hs:512`), which C4 replaces. This step therefore compiles and its unfiltered `cabal test` passes with no command unhandled. It is the same stub discipline C3 uses for `sendBadgeRequest`. - Credential verification needs no new key plumbing: `ChatConfig.badgePublicKeys :: Map Int BBSPublicKey` (`Controller.hs:145`) already holds the issuer keys by index, and `addUserBadge` (`Library/Commands.hs:5127-5145`) already looks the credential's index up there, calls `verifyCredential` (`:5131`), calls `setUserBadge` (`:5134`) and re-presents the profile to every contact (`:5138-5145`). Tests override the map the way the profile tests do, `testCfg {badgePublicKeys = testBadgeKeys pk}` (`tests/ChatTests/Profiles.hs:295`). - `addUserBadge` cannot be called as it stands from C3 or C4. It takes the global `chatLock` (`:5138`) and broadcasts `XInfo` to every contact, and its only current call site is unlocked and top level (`:3544`); C3 and C4 hold a per-user badge lock that already waits on `chatLock`, so calling it there inverts the lock order. It also fails with `throwCmdError` (`:5132`), which is a `CECommandError` (`Controller.hs:1688-1689`), not the `CEBadgeServiceError` C4 must surface. **Split it in this step** into three parts. `verifyUserBadge :: BadgeCredential -> CM (Either Text ()) ` (`:5129-5132`) looks up the key and verifies, and is callable under a lock. It **returns** its failures rather than throwing, because its three callers need three different outcomes: `addUserBadge` maps them back to `throwCmdError`, keeping `AddBadge` unchanged; C4 maps them to `CEBadgeServiceError`; C3 discards the credential and writes nothing. Both current failures, the unknown key index at `:5130` and the failed verification at `:5132`, become `Left`. The middle part, `setUserBadge` and the `currentUser` TVar write (`:5133-5135`), stays inline; it returns the updated `User`. `presentUserBadgeToContacts :: User -> CM ()` (`:5136-5145`) takes that `User`, acquires `chatLock` and broadcasts. `addUserBadge` becomes the three in sequence, so `AddBadge` (`:3544`) is unchanged. - Three `ChatConfig` fields, all overridable in tests: @@ -676,11 +676,11 @@ Phase C ends with a chat client that can redeem a code minted by B8 and show the - `badgeWebBaseUrl :: Text`, the checkout site, used by G1 to build the hand-off URL. A service ini setting cannot reach the app, so the site URL must live here. It defaults to `""` in `defaultChatConfig`, empty meaning unconfigured on the same terms as `badgeServiceAddress`. Release builds set it, and it must then equal the service's `[web] base_url` (A6), because Stripe's `success_url` derives from that side and the hand-off URL from this one; H5 documents keeping the two in step. - `badgeCurrentTime :: IO UTCTime`, defaulting to `getCurrentTime`. C3's worker and C4 read the clock through it, so C5 can advance client time without sleeping. - All three are given their defaults in `defaultChatConfig` (`src/Simplex/Chat.hs:60-62`), the record's only full construction site; `Mobile.hs` and `Terminal.hs` use record update and are unaffected. `--badge-service-address LINK`, `--badge-web-url URL` and `--badge-issuer-key IDX:BASE64URL` are added to `ChatOpts` and `chatOptsP` (`src/Simplex/Chat/Options.hs:40-57,365`) and all three override `cfg` by record update in `simplexChatCLI` (`Terminal/Main.hs:24-28`), which is how §10 and H5 point a client at a locally run service. `--badge-issuer-key` is repeatable and its entries **replace** `badgePublicKeys` outright when any is given, rather than merging: the field already ships eight production keys (`src/Simplex/Chat.hs:69-79`), index 1 among them, so a merge that kept the presets would leave a locally issued credential failing to verify against the production key at the same index. `BBSPublicKey` is 96 bytes and `strEncode`s as unpadded base64url, which is the form `badge keygen` prints. Unlike `ChatConfig`, `ChatOpts` has **six** full construction sites, and every one of them must set the new fields: `chatOptsP` (`Options.hs:485`) from the new parsers, and the other five to their empty values, `mobileChatOpts` (`Mobile.hs:249`), the three bots' `mkChatOpts` (`Directory/Options.hs:217`, `Broadcast/Options.hs:84`, `BadgeService/Options.hs:76`) and `testOpts` (`tests/ChatClient.hs:115`). A missed site is only a `-Wmissing-fields` warning, not a build error, so it fails at runtime in the binary that missed it. -- **Register the new API surface in the bot API docs, or `cabal test` breaks.** `tests/APIDocs.hs:73-110` fails any command, response or event that is in neither the documented nor the exempt list. Add the three commands to `cliCommands`, beside the existing `AddBadge` (`bots/src/API/Docs/Commands.hs:207,213`), so they reach `bots/api/COMMANDS.md` and both generated clients, the two responses to `undocumentedResponses` (`Responses.hs:119`), and `CEvtBadgeChanged` to `undocumentedEvents` (`Events.hs:169`). `CEBadgeServiceError` extends the documented `ChatErrorType` union (`Types.hs:224`), so add `BadgeServiceErrorCode` to the type docs and commit the regenerated `bots/api/*.md` and the TypeScript and Python client files. `describe "Bot API docs"` (`tests/Test.hs:62`) runs on the default CI build and §4's `-m` filters do not select it, so run `cabal test` unfiltered before ticking. + All three are given their defaults in `defaultChatConfig` (`src/Simplex/Chat.hs:60-62`), the record's only full construction site; `Mobile.hs` and `Terminal.hs` use record update and are unaffected. `--badge-service-address LINK`, `--badge-web-url URL` and `--badge-issuer-key IDX:BASE64URL` are added to `ChatOpts` as `optBadgeServiceAddress`, `optBadgeWebUrl` and `optBadgeIssuerKeys` — named apart from the `ChatConfig` fields they override, following the existing `optFilesFolder`/`optTempDirectory` convention, so the record update in `Terminal/Main.hs` is not ambiguous — and are parsed in `chatOptsP` (`src/Simplex/Chat/Options.hs:40-57,365`); all three override `cfg` by record update in `simplexChatCLI` (`Terminal/Main.hs:24-28`), which is how §10 and H5 point a client at a locally run service. `--badge-issuer-key` is repeatable and its entries **replace** `badgePublicKeys` outright when any is given, rather than merging: the field already ships eight production keys (`src/Simplex/Chat.hs:69-79`), index 1 among them, so a merge that kept the presets would leave a locally issued credential failing to verify against the production key at the same index. `BBSPublicKey` is 96 bytes and `strEncode`s as unpadded base64url, which is the form `badge keygen` prints. Unlike `ChatConfig`, `ChatOpts` has **six** full construction sites, and every one of them must set the new fields: `chatOptsP` (`Options.hs:485`) from the new parsers, and the other five to their empty values, `mobileChatOpts` (`Mobile.hs:249`), the three bots' `mkChatOpts` (`Directory/Options.hs:217`, `Broadcast/Options.hs:84`, `BadgeService/Options.hs:76`) and `testOpts` (`tests/ChatClient.hs:115`). A missed site is only a `-Wmissing-fields` warning, not a build error, so it fails at runtime in the binary that missed it. +- **Register the new API surface in the bot API docs, or `cabal test` breaks.** `tests/APIDocs.hs:73-110` fails any command, response or event that is in neither the documented nor the exempt list. Both `cliCommands` and `undocumentedCommands` are EXEMPTION lists, not documentation: `tests/APIDocs.hs:76` unions them with the documented set, and only `chatCommandsDocs` reaches `bots/api/COMMANDS.md` and the generated clients. Add the three commands to `undocumentedCommands` (`bots/src/API/Docs/Commands.hs:335`), which is where the `API*` commands go, the two responses to `undocumentedResponses` (`Responses.hs:119`), and `CEvtBadgeChanged` to `undocumentedEvents` (`Events.hs:169`); none of the four changes a generated file. `CEBadgeServiceError` extends the documented `ChatErrorType` union (`Types.hs:224`), which does, so add `BadgeServiceErrorCode` to the type docs and commit the regenerated `bots/api/TYPES.md`, `types.ts` and `_types.py`. `describe "Bot API docs"` (`tests/Test.hs:62`) runs on the default CI build and §4's `-m` filters do not select it, so run `cabal test` unfiltered before ticking. - `APIAckBadgeAlert`, `CEvtBadgeAlert` and `APISwitchShownBadge` are **not** added; alerts are out of scope and, with only the `paid` slot in use, there is nothing to switch between (§6). -**Verify:** In `tests/BadgeTests.hs`: command parser roundtrip tests. `cabal test` **unfiltered** passes, including `describe "Bot API docs"`, with the regenerated artefacts committed. Manual: `/_badge state 1` on a profile with no badge returns an empty `CRBadgeState` rather than an error; with no options given it reports an empty site URL rather than failing; and `simplex-chat --badge-service-address LINK --badge-web-url URL --badge-issuer-key 1:KEY` starts with `/_badge state 1` reporting the overridden site URL, and `/_badge catalog 1` reaching the service at the overridden address. +**Verify:** In `tests/BadgeTests.hs`: command parser roundtrip tests. `cabal test` **unfiltered** passes, including `describe "Bot API docs"`, with the regenerated artefacts committed. Manual: `/_badge state 1` on a profile with no badge returns an empty `CRBadgeState` rather than an error; with no options given it reports an empty site URL rather than failing; `simplex-chat --badge-service-address LINK --badge-web-url URL --badge-issuer-key 1:KEY` starts with `/_badge state 1` reporting the overridden site URL; a credential signed by that local key verifies through `/badge add` while the same credential fails without the option, which is what proves the issuer keys REPLACE rather than merge; and `/_badge catalog 1` returns the stub `CEBadgeServiceError`. Reaching the service at the overridden address is C4's check, not this step's — this step stubs `APIGetBadgeCatalog` (§9). #### C3 — `BadgeManager` worker @@ -1394,6 +1394,11 @@ Append here when a step contradicts this plan: the step id, what was wrong, and - **C1 — `badge_purchases.user_id` and `purchase_priv_key` are nullable and read as `Maybe`.** `20260731_user_badges` adds both with `ALTER TABLE`, which cannot add a NOT NULL column without a default, so the schema cannot express the invariant that `createPurchase` (the only writer) always sets them. `getShownPurchase` names a NULL as `SEInternalError` rather than returning a purchase whose private key is missing — the key is the whole reason the row is read back. - **C1 — deleting a stale ledger row could in principle violate `badge_issuances.entry_id`'s foreign key.** The REPLACE path deletes `badge_ledger` rows the service no longer holds, and `badge_issuances.entry_id REFERENCES badge_ledger` has no `ON DELETE` clause. Unreachable today, because nothing in this plan writes a non-NULL `entry_id` on the client (`NewBadgeIssuance.ledgerEntryId` exists to mirror the service's record and C3 passes `Nothing`), and it can only arise if the service both drops an already-issued entry and the client had linked an issuance to it. **For C3**, which decides whether to link issuances to local ledger rows at all: if it does, the REPLACE path must null those references before deleting. - **C1 — `payments.provider` is still written as a bare `'code'` literal on both sides.** `PaymentProvider` (`PaymentService/Types.hs:36`) has no `TextEncoding`, so `Store/Badges.hs` repeats `BadgeService/Store.hs`'s `codePaymentProviderText`. Two literals, one spelling, in two different databases that are never compared. **For D0/E2/F1**, as that entry already says: the instance should be added when a second provider needs writing. +- **C2 — `UserBadgeState` is reshaped and the draft `BadgePurchase` is deleted.** C1's entry above left this to C2. `Badges/Types.BadgePurchase` could not be the API shape: it carries `purchasePrivKey` and `masterKey`, and neither may cross the FFI (the private key signs `issueBadge`; the master key is the unlinkability secret), quite apart from the three fields C1 named. `UserBadgeState.badges` is now `[UserBadge]`, a new record holding only what the client can read and the app may see — `badgePurchaseId`, `badgeType`, `status`, `monthsLeft`, `paidThrough`, `createdAt`. `UserBadgeState` keeps `badges` and `shownBadgeId` and loses `payments`, `renewsAt`, `willRenew` and `alert`: nothing in this plan can fill them (alerts, subscriptions and renewals are §6), and a field that is permanently `Nothing`/`[]`/`False` documents a feature that does not exist. `paidThrough` is per badge rather than also at the top level, so the shown badge's paid-through date has ONE source; the app resolves it through `shownBadgeId`. The draft `BadgePurchase` is deleted rather than left unused — its row half is C1's `UserBadgePurchase` and its API half is `UserBadge` — which also removes the `import Simplex.Chat.Badges hiding (BadgePurchase (..))` clash with the unrelated `Simplex.Chat.Badges.BadgePurchase` (the payment-proof sum, which is in use). `badges` holds at most one entry today: `Store/Badges.hs` (unchanged, per the step) exposes only `getShownPurchase`, and with the paid slot the only slot in use the shown purchase IS the profile's badge. +- **C2 — the error's message field is `badgeErrorMessage :: Maybe Text`, and `retryAfter` is `Maybe Word32`.** The step and core §5 both write `CEBadgeServiceError {badgeError, message, retryAfter}`. `message` is not expressible: a Haskell record field has one type across a whole data type, and `ChatErrorType.message` is already `String` in `CECommandError`, `CEGroupInternal`, `CEFileNotFound`, `CEFileAlreadyReceiving`, `CEInternalError` and others. Reusing it as `String` would have meant mapping the wire's absent message to `""`; the field is named apart instead and keeps the wire's `Maybe`. `retryAfter` is `Word32` rather than core §5's `Int` to match `BSPError.retryAfter`, which is its only source, so C4 needs no conversion. Both appear in the regenerated `TYPES.md`, `types.ts` and `_types.py`. +- **C2 — the new commands go in `undocumentedCommands`, not `cliCommands`, and neither list documents anything.** The step said to add them to `cliCommands` "so they reach `bots/api/COMMANDS.md` and both generated clients". Both lists are exemptions from `tests/APIDocs.hs:76`'s completeness check; a command reaches `COMMANDS.md` and the clients only through `chatCommandsDocs`. `cliCommands` holds the terminal-syntax commands and `undocumentedCommands` the `API*` ones, so the three `API*` commands go in the latter. The only regenerated files are therefore `bots/api/TYPES.md`, `types.ts` and `_types.py`, all three changed by `CEBadgeServiceError`/`BadgeServiceErrorCode` alone; `COMMANDS.md` and `EVENTS.md` are unchanged, and the step's Files line is corrected accordingly. Documenting the three commands properly belongs with C4, when they do something. +- **C2 — the step's last manual check contradicts the step.** "`/_badge catalog 1` reaching the service at the overridden address" cannot hold while the same step stubs `APIGetBadgeCatalog` with `not implemented`. What was checked instead: `--badge-service-address` parses a contact link and is rejected when it is not one, `/_badge catalog 1` and `/_badge purchase 1 ` both return the stub `CEBadgeServiceError`, and the issuer-key override is exercised end to end through `/badge add` — a credential signed by a locally generated key at index 1 verifies with `--badge-issuer-key 1:KEY` and fails without it ("does not verify against configured key"), while a credential at index 2 fails with "unknown badge key index" when only index 1 is overridden, which is what proves REPLACE rather than merge. Verify corrected above. **C4 owns the reaching-the-service check.** +- **C2 — `addUserBadge` reads `badgeCurrentTime`, not `getCurrentTime`.** The step introduces the field for C3 and C4 only. Wiring the one existing client-side badge clock read through it too costs nothing (the default is `getCurrentTime`, so `AddBadge` is unchanged) and means the field is load-bearing from the step that adds it rather than from the step that first needs to override it. **It has no behavioural test yet** — nothing in this step can observe a different clock — so C5 is the first place the override is proved to work. ## 10. End-to-end verification diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index cd44b69df6..39ea28aa6b 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -77,6 +77,11 @@ defaultChatConfig = (7, toBBSPublicKey "rl36D5mg2N3NmmEybxE_RBeU9YZ_zeXNPfp7ZMLtUEuf2Mo4OQM_Up1v5rX_IqICD-AIJcuyptEBsELx_PJQzpmiNuG5I4cWO6HkRKtc6fVFvgZMrDJjaascPd1CIyxX"), (8, toBBSPublicKey "joM3Bnt7JPt5JiwQwERHGjro2iVZ0mPD_clUh4hzkhxvbjuFrWuTmfSNA8PWBqGKEGNl13aRi1pMf6yY14E27c5C71JxWm7T-rZaBrGPEUWifhD-qidWuf3PU7KJCCWd") ], + -- the badge service address and the checkout site are set by release builds and by + -- --badge-service-address / --badge-web-url; unset means the feature is unconfigured + badgeServiceAddress = Nothing, + badgeWebBaseUrl = "", + badgeCurrentTime = getCurrentTime, confirmMigrations = MCConsole, -- this property should NOT use operator = Nothing -- non-operator servers can be passed via options diff --git a/src/Simplex/Chat/Badges/Types.hs b/src/Simplex/Chat/Badges/Types.hs index 913007bebc..8842f50864 100644 --- a/src/Simplex/Chat/Badges/Types.hs +++ b/src/Simplex/Chat/Badges/Types.hs @@ -18,10 +18,12 @@ module Simplex.Chat.Badges.Types LedgerDebitType (..), BadgeAlertKind (..), BadgePurchase (..), + BadgePurchasePayment (..), BadgeLedgerEntry (..), BadgeCharge (..), BadgeIssuance (..), BadgeAlert (..), + UserBadge (..), UserBadgeState (..), ) where @@ -34,12 +36,12 @@ import Data.Text (Text) import Data.Time.Clock (UTCTime) import Data.Word (Word8) import Simplex.Chat.Badges hiding (BadgePurchase (..)) -import Simplex.Chat.PaymentService.Types (InvoiceId, StoredPayment) +import Simplex.Chat.PaymentService.Types (InvoiceId) import Simplex.Messaging.Agent.Protocol (UserId) import Simplex.Messaging.Agent.Store.DB (fromTextField_) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Encoding.String -import Simplex.Messaging.Parsers (dropPrefix, taggedObjectJSON) +import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, taggedObjectJSON) #if defined(dbPostgres) import Database.PostgreSQL.Simple.FromField (FromField (..)) import Database.PostgreSQL.Simple.ToField (ToField (..)) @@ -133,6 +135,17 @@ data BadgePurchase = BadgePurchase updatedAt :: UTCTime } +-- | The payment presented with @APIPurchaseBadge@, mapped to the wire +-- 'Simplex.Chat.PaymentService.ServicePayment' when the request is sent. The store cases carry +-- the @payments.payment_id@ of the row the app's purchase flow already created, which is what +-- ties the store transaction to the purchase; a redemption code carries only itself, because a +-- code redemption writes no rows until the service has answered (plan §5, C4). +data BadgePurchasePayment + = BPPApple {paymentId :: Text, jws :: Text} + | BPPGoogle {paymentId :: Text, token :: Text} + | BPPCode {code :: Text} + deriving (Eq, Show) + -- confirmed data BadgeLedgerEntry = BadgeLedgerEntry { entryId :: Int64, @@ -186,17 +199,37 @@ data BadgeAlert = BadgeAlert } deriving (Show) --- unconfirmed draft -data UserBadgeState = UserBadgeState - { badges :: [BadgePurchase], - shownBadgeId :: Maybe Int64, - payments :: [StoredPayment], +-- | One badge of a profile, as the badge surfaces render it (UX 2.2, 2.3, 2.6). +-- +-- This is the API projection of a purchase, NOT its row: the row is +-- 'Simplex.Chat.Store.Badges.UserBadgePurchase', which carries the purchase's PRIVATE KEY and +-- badge master key, and neither may cross the FFI into Kotlin or Swift — the private key signs +-- @issueBadge@ and the master key is the unlinkability secret. Nothing here is a secret, and +-- every field is read from a row the client already holds, so @APIGetBadgeState@ needs no +-- network. +-- +-- @monthsLeft@ and @paidThrough@ come from the purchase's LAST ledger entry: the balance the +-- client believes it holds, and 'Simplex.Chat.Badges.Months.addMonths' applied to it. Both are +-- absent from a purchase with no ledger entry yet. @paidThrough@ is deliberately not the +-- credential's expiry (UX §2.11 forbids presenting one as the other). +data UserBadge = UserBadge + { badgePurchaseId :: Int64, + badgeType :: BadgeType, + status :: BadgePurchaseStatus, monthsLeft :: Int, paidThrough :: Maybe UTCTime, - renewsAt :: Maybe UTCTime, - willRenew :: Bool, - alert :: Maybe BadgeAlert + createdAt :: UTCTime } + deriving (Eq, Show) + +-- | Everything the badge surfaces render for one profile. @shownBadgeId@ names the badge whose +-- credential the profile currently presents (@users.shown_badge_id@), which is the one whose +-- paid-through date the badge screen shows. +data UserBadgeState = UserBadgeState + { badges :: [UserBadge], + shownBadgeId :: Maybe Int64 + } + deriving (Eq, Show) -- BadgeItemStatus crosses both the wire (BadgePrice/BadgeOffer JSON) and the -- badge_prices/badge_offers.status columns (A4's seedCatalog); TextEncoding is the single @@ -255,3 +288,9 @@ instance FromField BadgePurchaseStatus where fromField = fromTextField_ textDeco -- JSON $(JQ.deriveJSON (taggedObjectJSON $ dropPrefix "OD") ''OfferDiscount) + +$(JQ.deriveJSON (taggedObjectJSON $ dropPrefix "BPP") ''BadgePurchasePayment) + +$(JQ.deriveJSON defaultJSON ''UserBadge) + +$(JQ.deriveJSON defaultJSON ''UserBadgeState) diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 54dfb58fc3..427ab7bf0d 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -48,7 +48,7 @@ import Data.Text.Encoding (decodeLatin1) import Data.Time (NominalDiffTime, UTCTime) import Data.Time.Clock.System (SystemTime (..), systemToUTCTime) import Data.Version (showVersion) -import Data.Word (Word16) +import Data.Word (Word16, Word32) import Language.Haskell.TH (Exp, Q, runIO) import Network.Socket (HostName) import Numeric.Natural @@ -84,6 +84,8 @@ import qualified Simplex.Messaging.Agent.Store.DB as DB import Simplex.Messaging.Client (HostMode (..), SMPProxyFallback (..), SMPProxyMode (..), SMPWebPortServers (..), SocksMode (..)) import qualified Simplex.Messaging.Crypto as C import Simplex.Chat.Badges (BadgeCredential) +import Simplex.Chat.Badges.Service (BadgeOffer, BadgePrice, BadgeServiceErrorCode) +import Simplex.Chat.Badges.Types (BadgePurchasePayment, UserBadgeState) import Simplex.Messaging.Crypto.BBS (BBSPublicKey) import Simplex.Messaging.Crypto.File (CryptoFile (..)) import qualified Simplex.Messaging.Crypto.File as CF @@ -143,6 +145,21 @@ data ChatConfig = ChatConfig chatVRange :: VersionRangeChat, -- issuer public keys by index: credentials and proofs name the key that signed them, for rotation badgePublicKeys :: Map Int BBSPublicKey, + -- | The badge service's contact address, as published by the operator's own service run. + -- 'Nothing' means the feature is unconfigured: badge requests fail with + -- 'CEBadgeServiceError' and the clients hide the browser hand-off. This plan carries no + -- address literal because the address is produced by the service run itself. + badgeServiceAddress :: Maybe (ConnectTarget 'CMContact), + -- | The badge checkout site, the base of the URL the app hands off to a browser. It travels + -- to the apps in 'CRBadgeState' rather than through the FFI, because 'ChatConfig' is not + -- readable from Kotlin or Swift. Empty means unconfigured, on the same terms as + -- 'badgeServiceAddress'. In a release build it must equal the service's @[web] base_url@: + -- Stripe's @success_url@ derives from that side and the hand-off URL from this one. + badgeWebBaseUrl :: Text, + -- | The only clock the client's badge code reads, so tests can advance client time without + -- sleeping. Defaults to 'getCurrentTime'; nothing but a test ever replaces it. This is the + -- client twin of @BadgeServiceOpts.serviceClock@. + badgeCurrentTime :: IO UTCTime, confirmMigrations :: MigrationConfirmation, presetServers :: PresetServers, shortLinkPresetServers :: NonEmpty SMPServer, @@ -638,6 +655,9 @@ data ChatCommand | UpdateProfileImage (Maybe ImageData) -- UserId (not used in UI) | UpdateProfileImageFromFile FilePath -- set profile image from a .png/.jpg/.jpeg file | AddBadge BadgeCredential -- attach an issued badge credential (testing; credential from `simplex-chat badge sign`) + | APIGetBadgeState UserId -- the profile's badges, read from the local store, sending nothing + | APIGetBadgeCatalog UserId -- refresh badge prices and offers from the badge service + | APIPurchaseBadge {userId :: UserId, payment :: BadgePurchasePayment} -- present a payment (a redemption code, or store evidence) for a badge | ShowProfileImage | SetUserFeature AChatFeature FeatureAllowed -- UserId (not used in UI) | SetContactFeature AChatFeature ContactName (Maybe FeatureAllowed) @@ -840,6 +860,12 @@ data ChatResponse | CRUserContactLink {user :: User, contactLink :: UserContactLink} | CRUserContactLinkUpdated {user :: User, contactLink :: UserContactLink} | CRContactRequestRejected {user :: User, contactRequest :: UserContactRequest, contact_ :: Maybe Contact} + | -- | The profile's badge state, plus the checkout site the app hands off to a browser. + -- @badgeWebBaseUrl@ is 'ChatConfig'\'s, which Kotlin and Swift cannot read; it rides on this + -- response because a badge state read is local, so the hand-off does not depend on the badge + -- service being reachable. It is empty when the feature is unconfigured. + CRBadgeState {user :: User, badgeState :: UserBadgeState, badgeWebBaseUrl :: Text} + | CRBadgeCatalog {user :: User, prices :: [BadgePrice], offers :: [BadgeOffer]} | CRServiceResponse {user :: User, responseData :: J.Object} | CRServiceReplyAccepted {user :: User, connectionId :: AgentConnId} | CRUserAcceptedGroupSent {user :: User, groupInfo :: GroupInfo, hostContact :: Maybe Contact} @@ -957,6 +983,10 @@ data ChatEvent | CEvtGroupMemberUpdated {user :: User, groupInfo :: GroupInfo, fromMember :: GroupMember, toMember :: GroupMember} | CEvtContactDeletedByContact {user :: User, contact :: Contact} | CEvtReceivedContactRequest {user :: User, contactRequest :: UserContactRequest, chat_ :: Maybe AChat} + | -- | Emitted whenever the badge worker changes a profile's badge state; open badge surfaces + -- re-render from it. It carries no site URL: the URL is configuration, not state, and the + -- app already has it from the 'CRBadgeState' it loaded at start. + CEvtBadgeChanged {user :: User, badgeState :: UserBadgeState} | CEvtServiceRequest {user :: User, requestId :: AgentInvId, signerKey :: Maybe C.PublicKeyEd25519, requestData :: J.Object} | CEvtServiceReplySent {connectionId :: AgentConnId} | CEvtContactRequestRejected {user :: User, contact :: Contact, rejectionReason :: Maybe ContactRejectionReason} @@ -1527,6 +1557,15 @@ data ChatErrorType | CEConnectionUserChangeProhibited | CEPeerChatVRangeIncompatible | CERelayTestError {message :: String} + | -- | A badge operation failed. @badgeError@ is the service's own error code, so the apps can + -- render the inline redeem errors of UX §2.8 from the code rather than from prose; + -- @retryAfter@ is seconds, populated by the service's @rate_limited@. A failure with no + -- service involved (a credential that does not verify) uses 'BSEInternal'. + -- + -- The message field is named apart from the @message :: String@ that the rest of this union + -- carries: a record field has one type across a whole data type, and this one is the wire's + -- optional 'Text'. + CEBadgeServiceError {badgeError :: BadgeServiceErrorCode, badgeErrorMessage :: Maybe Text, retryAfter :: Maybe Word32} | CEInternalError {message :: String} | CEException {message :: String} deriving (Show, Exception) diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 8629c463af..1c6593c506 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -57,6 +57,9 @@ import qualified Data.UUID as UUID import qualified Data.UUID.V4 as V4 import Simplex.Chat.Library.Subscriber import Simplex.Chat.Badges (BadgeCredential (..), LocalBadge (..), maxXFTPFileSize, mkBadgeStatus, verifyCredential) +import Simplex.Chat.Badges.Months (addMonths) +import Simplex.Chat.Badges.Service (BadgeServiceErrorCode (..)) +import Simplex.Chat.Badges.Types (BadgeLedgerEntry (..), UserBadge (..), UserBadgeState (..)) import Simplex.Chat.Names (SimplexDomainProof (..), SimplexDomainClaim (..), claimDomain, mkDomainClaim) import Simplex.Chat.Call import Simplex.Chat.Controller @@ -77,6 +80,7 @@ import Simplex.Chat.Library.Internal import Simplex.Chat.Stats import Simplex.Chat.Store import Simplex.Chat.Store.AppSettings +import Simplex.Chat.Store.Badges (UserBadgePurchase (..), getLastBadgeLedgerEntry, getShownPurchase) import Simplex.Chat.Store.ContactRequest import Simplex.Chat.Store.Connections import Simplex.Chat.Store.Delivery @@ -3552,6 +3556,12 @@ processChatCommand cxt nm = \case pure $ CRFileTransferStatus user fileStatus ShowProfile -> withUser $ \user@User {profile} -> pure $ CRUserProfile user (fromLocalProfile profile) AddBadge cred -> withUser $ \user -> addUserBadge user cred >> ok user + APIGetBadgeState userId -> withUserId userId $ \user -> do + badgeState <- getUserBadgeState user + ChatConfig {badgeWebBaseUrl} <- asks config + pure CRBadgeState {user, badgeState, badgeWebBaseUrl} + APIGetBadgeCatalog _ -> throwChatError badgeNotImplemented + APIPurchaseBadge {} -> throwChatError badgeNotImplemented SetBotCommands commands -> withUser $ \user@User {profile} -> do let LocalProfile {preferences} = profile prefs = Just (fromMaybe emptyChatPrefs preferences :: Preferences) {commands = Just commands} @@ -5133,17 +5143,66 @@ createContactsSndFeatureItems user cts = CUPContact {preference} -> preference CUPUser {preference} -> preference --- attach an issued badge credential to the user's own profile and present it to all current contacts. --- the credential is stored once; every profile send generates a fresh single-use proof (see presentUserBadge). -addUserBadge :: User -> BadgeCredential -> CM () -addUserBadge user cred@(BadgeCredential keyIdx _ _ info) = do +-- | The badge state of one profile, read from the local store: @APIGetBadgeState@ sends nothing. +-- +-- Only the shown purchase is reported today. With the paid slot the only one in use (plan §6), +-- and at most one issued purchase per slot, the shown purchase IS the profile's badge; the list +-- is a list because the investor slot will add a second one. +-- The purchase and its ledger are read in ONE transaction, so the balance reported cannot be +-- from a ledger the worker replaced between the two reads. +getUserBadgeState :: User -> CM UserBadgeState +getUserBadgeState User {userId} = withFastStore $ \db -> + getShownPurchase db userId >>= \case + Nothing -> pure UserBadgeState {badges = [], shownBadgeId = Nothing} + Just UserBadgePurchase {badgePurchaseId, currentBadgeType, status, createdAt} -> do + lastEntry_ <- getLastBadgeLedgerEntry db badgePurchaseId + let badge = + UserBadge + { badgePurchaseId, + badgeType = currentBadgeType, + status, + monthsLeft = maybe 0 (\BadgeLedgerEntry {balanceMonths} -> balanceMonths) lastEntry_, + paidThrough = badgePaidThrough <$> lastEntry_, + createdAt + } + pure UserBadgeState {badges = [badge], shownBadgeId = Just badgePurchaseId} + +-- | The date a purchase is paid through: its balance of whole months added to the instant that +-- balance started running. The clamping rule is 'addMonths'\'s and is not restated here, so the +-- client and the service cannot disagree about "31 January plus one month". +-- +-- This is NOT the credential's expiry, which is an issuance detail and must never be shown as a +-- paid-through date (UX §2.11). A balance of zero yields the instant the balance ran out. +badgePaidThrough :: BadgeLedgerEntry -> UTCTime +badgePaidThrough BadgeLedgerEntry {balanceMonths, balanceStartTs} = addMonths balanceMonths balanceStartTs + +-- | The placeholder failure of the badge commands C4 implements. It is a 'CEBadgeServiceError' +-- rather than a command error because that is the error C4 raises from them, so the stub does +-- not train a client to expect a different shape from the one it will get. +badgeNotImplemented :: ChatErrorType +badgeNotImplemented = CEBadgeServiceError {badgeError = BSEInternal, badgeErrorMessage = Just "not implemented", retryAfter = Nothing} + +-- | Checks that a credential was signed by a configured issuer key. +-- +-- It RETURNS its failures instead of throwing, because its three callers need three different +-- outcomes: @AddBadge@ turns them into a command error, the redeem path (C4) into a +-- 'CEBadgeServiceError', and the worker (C3) discards the credential and writes nothing. It +-- takes no lock, so a caller already holding the per-user badge lock can call it. +verifyUserBadge :: BadgeCredential -> CM (Either Text ()) +verifyUserBadge cred@(BadgeCredential keyIdx _ _ _) = do keys <- asks $ badgePublicKeys . config - key <- maybe (throwCmdError "unknown badge key index") pure $ M.lookup keyIdx keys - verified <- liftIO $ verifyCredential key cred - unless verified $ throwCmdError "badge credential does not verify against configured key" - now <- liftIO getCurrentTime - user' <- withFastStore' $ \db -> setUserBadge db user (Just (OwnBadge cred (mkBadgeStatus now (Just True) info))) - asks currentUser >>= atomically . (`writeTVar` Just user') + case M.lookup keyIdx keys of + Nothing -> pure $ Left "unknown badge key index" + Just key -> do + verified <- liftIO $ verifyCredential key cred + pure $ if verified then Right () else Left "badge credential does not verify against configured key" + +-- | Re-presents the user's profile, with its badge, to every non-incognito contact. +-- +-- It takes 'chatLock', so a caller holding the per-user badge lock (which waits on 'chatLock' +-- first) must RELEASE that lock before calling this, or the two lock orders invert. +presentUserBadgeToContacts :: User -> CM () +presentUserBadgeToContacts user' = do cxt <- asks $ mkStoreCxt . config contacts <- withFastStore' $ \db -> getUserContacts db cxt user' withChatLock "addUserBadge" $ forM_ contacts $ \ct -> @@ -5155,6 +5214,16 @@ addUserBadge user cred@(BadgeCredential keyIdx _ _ info) = do void (sendDirectContactMessage user' ct' (XInfo p)) `catchAllErrors` eToView _ -> pure () +-- attach an issued badge credential to the user's own profile and present it to all current contacts. +-- the credential is stored once; every profile send generates a fresh single-use proof (see presentUserBadge). +addUserBadge :: User -> BadgeCredential -> CM () +addUserBadge user cred@(BadgeCredential _ _ _ info) = do + verifyUserBadge cred >>= either (throwCmdError . T.unpack) pure + now <- liftIO =<< asks (badgeCurrentTime . config) + user' <- withFastStore' $ \db -> setUserBadge db user (Just (OwnBadge cred (mkBadgeStatus now (Just True) info))) + asks currentUser >>= atomically . (`writeTVar` Just user') + presentUserBadgeToContacts user' + assertDirectAllowed :: User -> MsgDirection -> Contact -> CMEventTag e -> CM () assertDirectAllowed user dir ct event = unless (allowedChatEvent || anyDirectOrUsed ct) . unlessM directMessagesAllowed $ @@ -5781,6 +5850,9 @@ chatCommandP = ("/profile " <|> "/p ") *> (uncurry UpdateProfile <$> profileNameDescr), ("/profile" <|> "/p") $> ShowProfile, "/badge add " *> (AddBadge <$> jsonP), + "/_badge state " *> (APIGetBadgeState <$> A.decimal), + "/_badge catalog " *> (APIGetBadgeCatalog <$> A.decimal), + "/_badge purchase " *> (APIPurchaseBadge <$> A.decimal <* A.space <*> jsonP), "/set bot commands " *> (SetBotCommands <$> botCommandsP), "/delete bot commands" $> SetBotCommands [], "/set voice #" *> (SetGroupFeatureRole (AGFR SGFVoice) <$> displayNameP <*> _strP <*> optional memberRole), diff --git a/src/Simplex/Chat/Mobile.hs b/src/Simplex/Chat/Mobile.hs index 09af069ef7..36015ccf0a 100644 --- a/src/Simplex/Chat/Mobile.hs +++ b/src/Simplex/Chat/Mobile.hs @@ -283,7 +283,10 @@ mobileChatOpts dbOptions = markRead = False, createBot = Nothing, userDisplayName = Nothing, - userImageFile = Nothing + userImageFile = Nothing, + optBadgeServiceAddress = Nothing, + optBadgeWebUrl = Nothing, + optBadgeIssuerKeys = [] } defaultMobileConfig :: ChatConfig diff --git a/src/Simplex/Chat/Options.hs b/src/Simplex/Chat/Options.hs index 4e47868fa0..5f5ecafb71 100644 --- a/src/Simplex/Chat/Options.hs +++ b/src/Simplex/Chat/Options.hs @@ -1,4 +1,5 @@ {-# LANGUAGE ApplicativeDo #-} +{-# LANGUAGE DataKinds #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} @@ -29,8 +30,11 @@ import Data.Text.Encoding (encodeUtf8) import Numeric.Natural (Natural) import Options.Applicative import Simplex.Chat.Controller (ChatLogLevel (..), SimpleNetCfg (..), WebPreviewConfig (..), updateStr, versionNumber, versionString) +import Simplex.Chat.Types (ConnectTarget) +import Simplex.Messaging.Agent.Protocol (ConnectionMode (..)) import Simplex.FileTransfer.Description (mb) import Simplex.Messaging.Client (HostMode (..), SMPWebPortServers (..), SocksMode (..), textToHostMode) +import Simplex.Messaging.Crypto.BBS (BBSPublicKey) import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (parseAll) import Simplex.Messaging.Protocol (ProtoServerWithAuth, ProtocolTypeI, SMPServerWithAuth, XFTPServerWithAuth) @@ -53,7 +57,19 @@ data ChatOpts = ChatOpts markRead :: Bool, createBot :: Maybe CreateBotOpts, userDisplayName :: Maybe Text, - userImageFile :: Maybe FilePath + userImageFile :: Maybe FilePath, + -- | @--badge-service-address@: the contact address of a badge service to use instead of the + -- one the build ships (which is none, outside release builds). + optBadgeServiceAddress :: Maybe (ConnectTarget 'CMContact), + -- | @--badge-web-url@: the badge checkout site to hand off to, overriding + -- 'Simplex.Chat.Controller.badgeWebBaseUrl'. + optBadgeWebUrl :: Maybe Text, + -- | @--badge-issuer-key IDX:BASE64URL@, repeatable. When any is given these REPLACE + -- 'Simplex.Chat.Controller.badgePublicKeys' outright rather than merging into it: the + -- shipped map already holds eight production keys, index 1 among them, so a merge that kept + -- the presets would leave a locally issued credential failing to verify against the + -- production key at the same index. + optBadgeIssuerKeys :: [(Int, BBSPublicKey)] } data CoreChatOpts = CoreChatOpts @@ -481,6 +497,29 @@ chatOptsP appDir defaultDbName = do <> metavar "FILE" <> help "Set user profile image from .png/.jpg/.jpeg file when the profile is created (requires --user-display-name); ignored if the user already exists (use \"/set profile image file \" to change it)" ) + optBadgeServiceAddress <- + optional $ + option + strParse + ( long "badge-service-address" + <> metavar "LINK" + <> help "Contact address of the badge service to use for supporter badges" + ) + optBadgeWebUrl <- + optional $ + strOption + ( long "badge-web-url" + <> metavar "URL" + <> help "Base URL of the badge checkout site the app hands off to (must match the service's [web] base_url)" + ) + optBadgeIssuerKeys <- + many $ + option + parseBadgeIssuerKey + ( long "badge-issuer-key" + <> metavar "IDX:BASE64URL" + <> help "Badge issuer public key by index, from `simplex-chat badge keygen` (repeatable; replaces all built-in issuer keys)" + ) pure ChatOpts { coreOptions, @@ -507,7 +546,10 @@ chatOptsP appDir defaultDbName = do userDisplayName, userImageFile = case userImageFile of Just _ | isNothing userDisplayName -> error "--user-image-file option requires --user-display-name option" - _ -> userImageFile + _ -> userImageFile, + optBadgeServiceAddress, + optBadgeWebUrl, + optBadgeIssuerKeys } parseProtocolServers :: ProtocolTypeI p => ReadM [ProtoServerWithAuth p] @@ -516,6 +558,15 @@ parseProtocolServers = eitherReader $ parseAll protocolServersP . B.pack strParse :: StrEncoding a => ReadM a strParse = eitherReader $ parseAll strP . encodeUtf8 . T.pack +-- | @IDX:BASE64URL@ — the key index the credential names, and the key `badge keygen` prints. +-- The index is separated from the key by the first colon, which base64url cannot contain. +parseBadgeIssuerKey :: ReadM (Int, BBSPublicKey) +parseBadgeIssuerKey = eitherReader $ \s -> case break (== ':') s of + (idx, ':' : key) + | [(i, "")] <- reads idx -> (,) i <$> parseAll strP (B.pack key) + | otherwise -> Left "badge issuer key index is not a number" + _ -> Left "badge issuer key must be IDX:BASE64URL" + parseHostMode :: ReadM HostMode parseHostMode = eitherReader $ textToHostMode . T.pack diff --git a/src/Simplex/Chat/Terminal/Main.hs b/src/Simplex/Chat/Terminal/Main.hs index b5bb0a124b..a8be8d0d49 100644 --- a/src/Simplex/Chat/Terminal/Main.hs +++ b/src/Simplex/Chat/Terminal/Main.hs @@ -7,6 +7,7 @@ module Simplex.Chat.Terminal.Main where import Control.Concurrent (forkIO, threadDelay) import Control.Concurrent.STM import Control.Monad +import qualified Data.Map.Strict as M import Data.Maybe (fromMaybe) import Network.Socket import Simplex.Chat.Controller (ChatConfig (..), ChatController (..), ChatError, ChatEvent (..), PresetServers (..), SimpleNetCfg (..), currentRemoteHost, versionNumber, versionString) @@ -25,7 +26,21 @@ simplexChatCLI :: ChatConfig -> Maybe (ServiceName -> ChatConfig -> ChatOpts -> simplexChatCLI cfg server_ = do appDir <- getAppUserDataDirectory "simplex" opts <- getChatOpts appDir "simplex_v1" - simplexChatCLI' cfg opts server_ + simplexChatCLI' (badgeOptsOverride cfg opts) opts server_ + +-- | Points the CLI at a locally run badge service (plan §10, H5). The bots build their own +-- 'ChatOpts' with these unset, so only the CLI is affected. +-- +-- The issuer keys REPLACE the configured map rather than merging into it: 'badgePublicKeys' +-- ships eight production keys, index 1 among them, so a merge that kept the presets would leave +-- a locally issued credential failing to verify against the production key at the same index. +badgeOptsOverride :: ChatConfig -> ChatOpts -> ChatConfig +badgeOptsOverride cfg ChatOpts {optBadgeServiceAddress, optBadgeWebUrl, optBadgeIssuerKeys} = + cfg + { badgeServiceAddress = maybe (badgeServiceAddress cfg) Just optBadgeServiceAddress, + badgeWebBaseUrl = fromMaybe (badgeWebBaseUrl cfg) optBadgeWebUrl, + badgePublicKeys = if null optBadgeIssuerKeys then badgePublicKeys cfg else M.fromList optBadgeIssuerKeys + } simplexChatCLI' :: ChatConfig -> ChatOpts -> Maybe (ServiceName -> ChatConfig -> ChatOpts -> IO ()) -> IO () simplexChatCLI' cfg opts@ChatOpts {chatCmd, chatCmdLog, chatCmdDelay, chatServerPort, coreOptions = CoreChatOpts {headless}} server_ = do diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 6731b62fd6..df3f302cd2 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -44,6 +44,9 @@ import Simplex.Chat.Help import Simplex.Chat.Library.Commands (maxImageSize) import Simplex.Chat.Markdown import Simplex.Chat.Badges (BadgeInfo (..), BadgeStatus (..), BadgeType (..), LocalBadge, localBadgeInfo, localBadgeStatus) +import Simplex.Chat.Badges.Service (BadgeOffer (..), BadgePrice (..)) +import Simplex.Chat.Badges.Types (BadgeOfferId (..), BadgePriceId (..), UserBadge (..), UserBadgeState (..)) +import Simplex.Chat.PaymentService.Types (CurrencyAmount (..)) import Simplex.Chat.Messages hiding (NewChatItem (..)) import Simplex.Chat.Messages.CIContent import Simplex.Chat.Operators @@ -186,6 +189,8 @@ chatResponseToView hu cfg@ChatConfig {logLevel, showReactions, showFullLinks, te CRUserContactLink u UserContactLink {connLinkContact, addressSettings} -> ttyUser u $ connReqContact_ showFullLinks "Your chat address:" connLinkContact <> viewAddressSettings addressSettings CRUserContactLinkUpdated u UserContactLink {addressSettings} -> ttyUser u $ viewAddressSettings addressSettings CRContactRequestRejected u UserContactRequest {localDisplayName = c} _ct_ -> ttyUser u [ttyContact c <> ": contact request rejected"] + CRBadgeState u badgeState url -> ttyUser u $ viewUserBadgeState badgeState <> [viewBadgeWebBaseUrl url] + CRBadgeCatalog u prices offers -> ttyUser u $ viewBadgeCatalog prices offers CRServiceResponse u resp -> ttyUser u ["service response: " <> viewJSON resp] CRServiceReplyAccepted u (AgentConnId cId) -> ttyUser u [plain $ "service reply accepted, connection id: " <> safeDecodeUtf8 (strEncode cId)] CRGroupCreated u g -> ttyUser u $ viewGroupCreated g testView @@ -468,6 +473,7 @@ chatEventToView hu ChatConfig {logLevel, showReactions, showReceipts, testView} CEvtContactUpdated {user = u, fromContact = c, toContact = c'} -> ttyUser u $ viewContactUpdated c c' <> viewContactPrefsUpdated u c c' CEvtGroupMemberUpdated {} -> [] CEvtReceivedContactRequest u UserContactRequest {localDisplayName = c, profile} _chat -> ttyUser u $ viewReceivedContactRequest c (fromLocalProfile profile) + CEvtBadgeChanged u badgeState -> ttyUser u $ viewUserBadgeState badgeState CEvtServiceRequest u reqId sigKey_ req -> ttyUser u $ [plain $ "service request " <> safeDecodeUtf8 (strEncode reqId)] @@ -2806,6 +2812,12 @@ viewChatError isCmd logLevel testView = \case CEConnectionUserChangeProhibited -> ["incognito mode change prohibited for user"] CEPeerChatVRangeIncompatible -> ["peer chat protocol version range incompatible"] CERelayTestError e -> ["relay test error: " <> plain e] + CEBadgeServiceError code msg retryAfter -> + [ "badge service error: " + <> plain (textEncode code :: Text) + <> maybe "" (\m -> ", " <> plain m) msg + <> maybe "" (\s -> ", retry after " <> sShow s <> "s") retryAfter + ] CEInternalError e -> ["internal chat error: " <> plain e] CEException e -> ["exception: " <> plain e] -- e -> ["chat error: " <> sShow e] @@ -2900,6 +2912,37 @@ viewConnectionEntityInactive entity inactive | inactive = ["[" <> connEntityLabel entity <> "] connection is marked as inactive"] | otherwise = ["[" <> connEntityLabel entity <> "] inactive connection is marked as active"] +viewUserBadgeState :: UserBadgeState -> [StyledString] +viewUserBadgeState UserBadgeState {badges, shownBadgeId} = case badges of + [] -> ["no badges"] + _ -> map viewUserBadge badges + where + viewUserBadge UserBadge {badgePurchaseId, badgeType, status, monthsLeft, paidThrough} = + plain (textEncode badgeType :: Text) + <> " badge " + <> sShow badgePurchaseId + <> (if Just badgePurchaseId == shownBadgeId then " (shown)" else "") + <> ": " + <> plain (textEncode status :: Text) + <> ", " + <> sShow monthsLeft + <> " month(s) left" + -- the paid-through date, never the credential expiry (UX 2.11) + <> maybe "" (\t -> ", paid through " <> plain (formatTime defaultTimeLocale "%Y-%m-%d" t)) paidThrough + +viewBadgeWebBaseUrl :: Text -> StyledString +viewBadgeWebBaseUrl url + | T.null url = "badge site: not configured" + | otherwise = "badge site: " <> plain url + +viewBadgeCatalog :: [BadgePrice] -> [BadgeOffer] -> [StyledString] +viewBadgeCatalog prices offers = map viewPrice prices <> map viewOffer offers + where + viewPrice BadgePrice {priceId = BadgePriceId pId, badgeType, monthPrice = CurrencyAmount p, currency, status} = + "price " <> plain pId <> ": " <> plain (textEncode badgeType :: Text) <> " " <> sShow p <> " " <> plain currency <> " per month, " <> plain (textEncode status :: Text) + viewOffer BadgeOffer {offerId = BadgeOfferId oId, months, discount, status, total} = + "offer " <> plain oId <> ": " <> sShow months <> " month(s), " <> viewJSON discount <> maybe "" (\(CurrencyAmount t) -> ", total " <> sShow t) total <> ", " <> plain (textEncode status :: Text) + viewJSON :: J.ToJSON a => a -> StyledString viewJSON = plain . LB.toStrict . J.encode diff --git a/tests/BadgeTests.hs b/tests/BadgeTests.hs index 889afc7004..20ee84c1b4 100644 --- a/tests/BadgeTests.hs +++ b/tests/BadgeTests.hs @@ -2,6 +2,7 @@ {-# LANGUAGE DisambiguateRecordFields #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE GADTs #-} +{-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE ScopedTypeVariables #-} @@ -16,13 +17,16 @@ import qualified Data.ByteString.Lazy.Char8 as LB import Data.Map.Strict (Map) import qualified Data.Map.Strict as M import Data.Text (Text) -import Data.Time.Clock (UTCTime, addUTCTime, getCurrentTime, nominalDay) +import Data.Time.Calendar (fromGregorian) +import Data.Time.Clock (UTCTime (..), addUTCTime, getCurrentTime, nominalDay) import Data.Time.Clock.POSIX (posixSecondsToUTCTime) import qualified Simplex.Messaging.Crypto as C import Simplex.Chat.Badges import qualified Simplex.Chat.Badges as CB import Simplex.Chat.Badges.Service import Simplex.Chat.Badges.Types +import Simplex.Chat.Controller (ChatCommand (..)) +import Simplex.Chat.Library.Commands (badgePaidThrough, parseChatCommand) -- PaymentStatus is hidden: its PSFailed collides with BadgePurchaseStatus's, and the column -- spellings of both are pinned below. The payment statuses are reached through PT instead. import Simplex.Chat.PaymentService hiding (PaymentStatus (..)) @@ -57,6 +61,9 @@ badgeTests = do it "round-trips every BadgeServiceCommand constructor" testBadgeServiceCommandJSON it "round-trips every BadgeServiceResponse constructor" testBadgeServiceResponseJSON it "round-trips BadgeServiceRequest JSON" testBadgeServiceRequestJSON + it "round-trips every BadgePurchasePayment constructor with the documented tags" testBadgePurchasePaymentJSON + it "parses the badge commands, and rejects the shapes that are not commands" testBadgeCommandParsers + it "derives the paid-through date from the last ledger entry, clamping the month" testBadgePaidThrough proofOf :: BadgeProof -> BBSProof proofOf (BadgeProof _ _ p _) = p @@ -423,6 +430,80 @@ testBadgeServiceRequestJSON = do roundtripsBytes (BadgeServiceRequest {version = VersionBadgeService 1, purchaseKey = Just pub, request = BSCPauseBadge}) roundtripsBytes (BadgeServiceRequest {version = VersionBadgeService 1, purchaseKey = Nothing, request = BSCGetBadgeCatalog}) +testBadgePurchasePaymentJSON :: IO () +testBadgePurchasePaymentJSON = do + roundtripsBytes BPPApple {paymentId = "pay-1", jws = "jws"} + roundtripsBytes BPPGoogle {paymentId = "pay-2", token = "token"} + roundtripsBytes BPPCode {code = "CODE"} + -- the tags are core §3's and are what APIPurchaseBadge's argument is written as, so they are + -- asserted literally rather than only through a round trip + J.encode BPPCode {code = "CODE"} `shouldBe` "{\"type\":\"code\",\"code\":\"CODE\"}" + J.encode BPPApple {paymentId = "p", jws = "j"} `shouldBe` "{\"type\":\"apple\",\"paymentId\":\"p\",\"jws\":\"j\"}" + J.encode BPPGoogle {paymentId = "p", token = "t"} `shouldBe` "{\"type\":\"google\",\"paymentId\":\"p\",\"token\":\"t\"}" + +testBadgeCommandParsers :: IO () +testBadgeCommandParsers = do + cred <- jsonCredential + -- `/badge add` is a different command and must not be caught by the `/_badge` parsers + parsesAs (LB.toStrict $ "/badge add " <> J.encode cred) $ \case + AddBadge _ -> True + _ -> False + parsesAs "/_badge state 1" $ \case + APIGetBadgeState 1 -> True + _ -> False + parsesAs "/_badge state 42" $ \case + APIGetBadgeState 42 -> True + _ -> False + parsesAs "/_badge catalog 7" $ \case + APIGetBadgeCatalog 7 -> True + _ -> False + parsesAs "/_badge purchase 3 {\"type\":\"code\",\"code\":\"ABCD-EFGH\"}" $ \case + APIPurchaseBadge {userId = 3, payment = BPPCode {code = "ABCD-EFGH"}} -> True + _ -> False + parsesAs "/_badge purchase 3 {\"type\":\"apple\",\"paymentId\":\"p\",\"jws\":\"j\"}" $ \case + APIPurchaseBadge {userId = 3, payment = BPPApple {paymentId = "p", jws = "j"}} -> True + _ -> False + failsToParse "/_badge state" -- the user id is not optional + failsToParse "/_badge state x" + failsToParse "/_badge purchase 3" -- the payment is not optional + failsToParse "/_badge purchase 3 {\"type\":\"invoice\",\"invoiceId\":\"i\"}" -- not a BadgePurchasePayment + failsToParse "/_badge shown 1 2" -- APISwitchShownBadge is out of scope (plan §6) + failsToParse "/_badge ack 1 renewalApproaching e off" -- APIAckBadgeAlert is out of scope + where + parsesAs s ok = case parseChatCommand s of + Right cmd | ok cmd -> pure () + r -> expectationFailure $ "unexpected parse of " <> show s <> ": " <> show r + failsToParse s = case parseChatCommand s of + Left _ -> pure () + Right cmd -> expectationFailure $ show s <> " should not parse, but gave: " <> show cmd + +testBadgePaidThrough :: IO () +testBadgePaidThrough = do + -- three months from 31 January lands on 30 April: addMonths clamps to the last valid day, and + -- badgePaidThrough must not reimplement that rule + badgePaidThrough (ledgerEntryAt 3 (day 2026 1 31)) `shouldBe` day 2026 4 30 + badgePaidThrough (ledgerEntryAt 1 (day 2026 1 31)) `shouldBe` day 2026 2 28 + -- an exhausted balance is paid through the instant it ran out, not through "no date" + badgePaidThrough (ledgerEntryAt 0 (day 2026 3 15)) `shouldBe` day 2026 3 15 + where + day y m d = UTCTime (fromGregorian y m d) 0 + ledgerEntryAt balanceMonths balanceStartTs = + BadgeLedgerEntry + { entryId = 1, + entryUuid = "e-1", + badgePurchaseId = 1, + -- deliberately NOT balanceMonths: a debit entry's change is -1 while its balance is + -- what is left, and a fixture that made the two equal could not tell them apart + changeMonths = -1, + balanceMonths, + balanceStartTs, + balanceBadgeType = BTSupporter, + wasPausedSince = Nothing, + serviceCreatedAt = balanceStartTs, + createdAt = balanceStartTs, + entryType = LEDebit DTBadge + } + -- Helpers futureTime :: UTCTime diff --git a/tests/ChatClient.hs b/tests/ChatClient.hs index dc6b6b5431..d84f3ca7b4 100644 --- a/tests/ChatClient.hs +++ b/tests/ChatClient.hs @@ -126,7 +126,10 @@ testOpts = markRead = True, createBot = Nothing, userDisplayName = Nothing, - userImageFile = Nothing + userImageFile = Nothing, + optBadgeServiceAddress = Nothing, + optBadgeWebUrl = Nothing, + optBadgeIssuerKeys = [] } testCoreOpts :: CoreChatOpts