diff --git a/apps/ios/Shared/Views/Chat/ChatInfoToolbar.swift b/apps/ios/Shared/Views/Chat/ChatInfoToolbar.swift index 00c8d7070b..f825dbeca7 100644 --- a/apps/ios/Shared/Views/Chat/ChatInfoToolbar.swift +++ b/apps/ios/Shared/Views/Chat/ChatInfoToolbar.swift @@ -131,6 +131,15 @@ public func subscriberCountStr(_ count: Int64) -> String { : String.localizedStringWithFormat(NSLocalizedString("%d subscribers", comment: "channel subscriber count"), count) } +public func ownersContributorsCountStr(_ count: Int, withContributors: Bool) -> String { + if withContributors { + return String.localizedStringWithFormat(NSLocalizedString("%d owners & contributors", comment: "channel members count"), count) + } + return count == 1 + ? String.localizedStringWithFormat(NSLocalizedString("%d owner", comment: "channel owners count"), count) + : String.localizedStringWithFormat(NSLocalizedString("%d owners", comment: "channel owners count"), count) +} + struct ChatInfoToolbar_Previews: PreviewProvider { static var previews: some View { ChatInfoToolbar(chat: Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: [])) diff --git a/apps/ios/Shared/Views/Chat/Group/ChannelMembersView.swift b/apps/ios/Shared/Views/Chat/Group/ChannelMembersView.swift index 44fc302aff..231054fd78 100644 --- a/apps/ios/Shared/Views/Chat/Group/ChannelMembersView.swift +++ b/apps/ios/Shared/Views/Chat/Group/ChannelMembersView.swift @@ -21,22 +21,29 @@ struct ChannelMembersView: View { let s = m.wrapped.memberStatus return s != .memLeft && s != .memRemoved && m.wrapped.memberRole != .relay } + .sorted { $0.wrapped.memberRole > $1.wrapped.memberRole } + let subscriberCount = groupInfo.groupSummary.publicMemberCount ?? Int64(members.count + 1) if groupInfo.isOwner { - let subscriberCount = groupInfo.groupSummary.publicMemberCount ?? Int64(members.count + 1) List { Section(header: Text(subscriberCountStr(subscriberCount)).foregroundColor(theme.colors.secondary)) { memberRow(GMember(groupInfo.membership), user: true, showRole: true) ForEach(members) { member in - memberRow(member, user: false, showRole: member.wrapped.memberRole >= .owner) + memberRow(member, user: false, showRole: member.wrapped.memberRole >= .member) } } } } else { - let owners = members.filter { $0.wrapped.memberRole >= .owner } + let contributors = members.filter { $0.wrapped.memberRole >= .member && $0.wrapped.memberStatus != .memUnknown } + let contributorCount = contributors.count + (groupInfo.membership.memberRole >= .member ? 1 : 0) + let withContributors = contributors.contains { $0.wrapped.memberRole < .owner } + || groupInfo.membership.memberRole >= .member List { - Section(header: Text("Owners").foregroundColor(theme.colors.secondary)) { - ForEach(owners) { member in - memberRow(member, user: false, showRole: false) + Section(header: Text(ownersContributorsCountStr(contributorCount, withContributors: withContributors)).foregroundColor(theme.colors.secondary)) { + if groupInfo.membership.memberRole >= .member { + memberRow(GMember(groupInfo.membership), user: true, showRole: true) + } + ForEach(contributors) { member in + memberRow(member, user: false, showRole: member.wrapped.memberRole >= .moderator) } } } diff --git a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift index 5563d79e61..41e24a6ced 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift @@ -691,7 +691,7 @@ struct GroupChatInfoView: View { } private func channelMembersButton() -> some View { - let label: LocalizedStringKey = groupInfo.isOwner ? "Subscribers" : "Owners" + let label: LocalizedStringKey = groupInfo.isOwner ? "Subscribers" : "Owners & contributors" return NavigationLink { ChannelMembersView(chat: chat, groupInfo: groupInfo) .navigationTitle(label) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt index 6f7c746691..cc9e71354c 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt @@ -1547,6 +1547,11 @@ fun subscriberCountStr(count: Long): String = if (count == 1L) String.format(generalGetString(MR.strings.channel_subscriber_count_singular), count) else String.format(generalGetString(MR.strings.channel_subscriber_count_plural), count) +fun ownersContributorsCountStr(count: Int, withContributors: Boolean): String = + if (withContributors) String.format(generalGetString(MR.strings.channel_owners_contributors_count), count) + else if (count == 1) String.format(generalGetString(MR.strings.channel_owner_count_singular), count) + else String.format(generalGetString(MR.strings.channel_owner_count_plural), count) + @Composable fun ChatInfoToolbarTitle(cInfo: ChatInfo, imageSize: Dp = 40.dp, iconColor: Color = MaterialTheme.colors.secondaryVariant.mixWith(MaterialTheme.colors.onBackground, 0.97f)) { Row( diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/ChannelMembersView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/ChannelMembersView.kt index 9f13cf2b19..ef3d8805f4 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/ChannelMembersView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/ChannelMembersView.kt @@ -14,6 +14,7 @@ import androidx.compose.ui.unit.dp import chat.simplex.common.model.* import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.chat.ownersContributorsCountStr import chat.simplex.common.views.chat.subscriberCountStr import chat.simplex.common.views.helpers.* import chat.simplex.res.MR @@ -33,6 +34,7 @@ fun ChannelMembersView( && m.memberStatus != GroupMemberStatus.MemRemoved && m.memberRole != GroupMemberRole.Relay } + .sortedByDescending { it.memberRole } ColumnWithScrollBar { val title = if (groupInfo.isOwner) { @@ -42,8 +44,8 @@ fun ChannelMembersView( } AppBarTitle(title) + val subscriberCount = groupInfo.groupSummary.publicMemberCount ?: (members.size + 1).toLong() if (groupInfo.isOwner) { - val subscriberCount = groupInfo.groupSummary.publicMemberCount ?: (members.size + 1).toLong() SectionView(title = subscriberCountStr(subscriberCount)) { SectionItemView(minHeight = 54.dp, padding = PaddingValues(horizontal = DEFAULT_PADDING)) { ChannelMemberRow(groupInfo.membership, user = true, showRole = true, isChannel = groupInfo.isChannel) @@ -55,14 +57,23 @@ fun ChannelMembersView( minHeight = 54.dp, padding = PaddingValues(horizontal = DEFAULT_PADDING) ) { - ChannelMemberRow(member, user = false, showRole = member.memberRole >= GroupMemberRole.Owner, isChannel = groupInfo.isChannel) + ChannelMemberRow(member, user = false, showRole = member.memberRole >= GroupMemberRole.Member, isChannel = groupInfo.isChannel) } } } } else { - val owners = members.filter { it.memberRole >= GroupMemberRole.Owner } - SectionView(title = generalGetString(MR.strings.channel_members_section_owners)) { - owners.forEachIndexed { index, member -> + val contributors = members.filter { it.memberRole >= GroupMemberRole.Member && it.memberStatus != GroupMemberStatus.MemUnknown } + val contributorCount = contributors.size + if (groupInfo.membership.memberRole >= GroupMemberRole.Member) 1 else 0 + val withContributors = contributors.any { it.memberRole < GroupMemberRole.Owner } || + groupInfo.membership.memberRole >= GroupMemberRole.Member + SectionView(title = ownersContributorsCountStr(contributorCount, withContributors)) { + if (groupInfo.membership.memberRole >= GroupMemberRole.Member) { + SectionItemView(minHeight = 54.dp, padding = PaddingValues(horizontal = DEFAULT_PADDING)) { + ChannelMemberRow(groupInfo.membership, user = true, showRole = true, isChannel = groupInfo.isChannel) + } + Divider() + } + contributors.forEachIndexed { index, member -> if (index > 0) { Divider() } @@ -71,7 +82,7 @@ fun ChannelMembersView( minHeight = 54.dp, padding = PaddingValues(horizontal = DEFAULT_PADDING) ) { - ChannelMemberRow(member, user = false, showRole = false, isChannel = groupInfo.isChannel) + ChannelMemberRow(member, user = false, showRole = member.memberRole >= GroupMemberRole.Moderator, isChannel = groupInfo.isChannel) } } } diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml index 71c0bf98a4..9262bd9dac 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -2976,9 +2976,12 @@ Subscribers - Owners + Owners & contributors %1$d subscriber %1$d subscribers + %1$d owner + %1$d owners + %1$d owners & contributors you diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 5ff8e8cccb..0a5b9b1f61 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -5,7 +5,7 @@ cabal-version: 1.12 -- see: https://github.com/sol/hpack name: simplex-chat -version: 7.0.0.5 +version: 7.0.0.6 category: Web, System, Services, Cryptography homepage: https://github.com/simplex-chat/simplex-chat#readme author: simplex.chat diff --git a/src/Simplex/Chat/Library/Internal.hs b/src/Simplex/Chat/Library/Internal.hs index 95724c29b5..9c7f91d5ba 100644 --- a/src/Simplex/Chat/Library/Internal.hs +++ b/src/Simplex/Chat/Library/Internal.hs @@ -1256,6 +1256,9 @@ redactedMemberProfile allowSimplexLinks Profile {displayName, fullName, shortDes isRosterRole :: GroupMemberRole -> Bool isRosterRole r = r == GRMember || r == GRModerator || r == GRAdmin +isPrivilegedRole :: GroupMemberRole -> Bool +isPrivilegedRole r = r >= GRMember + -- Drop non-privileged-role entries and de-duplicate by memberId, keeping the first. -- Runs on the parsed roster blob. validateGroupRoster :: [RosterMember] -> [RosterMember] diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index e4908e5677..3118122573 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -3077,8 +3077,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = xGrpMemNew :: GroupInfo -> GroupMember -> MemberInfo -> Maybe MsgScope -> RcvMessage -> UTCTime -> CM (Maybe DeliveryJobScope) xGrpMemNew gInfo m memInfo@(MemberInfo memId memRole _ _ assertedKey_) msgScope_ msg brokerTs = do - let fromRelay = useRelays' gInfo && isRelay m - unless fromRelay $ checkHostRole m memRole + unless (useRelays' gInfo) $ checkHostRole m memRole if sameMemberId memId (membership gInfo) then pure Nothing else @@ -3087,7 +3086,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = -- roster-established privileged member: the relay may update the profile only, -- never the role or key (those are owner-authoritative via the roster, and -- XGrpMemNew is unsigned) - | fromRelay && isRosterRole (memberRole' unknownMember) -> do + | useRelays' gInfo && isPrivilegedRole (memberRole' unknownMember) -> do -- a member's key is immutable per memberId and identical across relays; mismatch -- is unambiguous relay misbehavior (role can legitimately differ across relays -- under multi-relay skew, so we deliberately don't warn on role) @@ -3101,8 +3100,8 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = toView $ CEvtUnknownMemberAnnounced user gInfo' m unknownMember updatedMember memberAnnouncedToView updatedMember gInfo' pure $ deliveryJobScope updatedMember - -- asserted privileged but NOT roster-established: relay conjuring a moderator - | fromRelay && isRosterRole memRole -> + -- asserted privileged but NOT roster-established: relay conjuring a privileged member + | useRelays' gInfo && isPrivilegedRole memRole -> messageError "x.grp.mem.new: privileged role not established by roster" $> Nothing | otherwise -> do (updatedMember, gInfo') <- withStore $ \db -> do @@ -3120,8 +3119,8 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = | useRelays' gInfo -> logInfo "x.grp.mem.new: member already created via another relay" $> Nothing | otherwise -> messageError "x.grp.mem.new error: member already exists" $> Nothing Left _ - -- a privileged member absent from the roster is a relay conjuring a moderator - | fromRelay && isRosterRole memRole -> messageError "x.grp.mem.new: privileged member not established by roster" $> Nothing + -- a privileged member absent from the roster is a relay conjuring one + | useRelays' gInfo && isPrivilegedRole memRole -> messageError "x.grp.mem.new: privileged member not established by roster" $> Nothing | otherwise -> do (newMember, gInfo') <- withStore $ \db -> do newMember <- createNewGroupMember db cxt user gInfo m memInfo GCPostMember initialStatus diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt index fb6166f8c9..eefcb8de57 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -7033,6 +7033,11 @@ Query: SELECT auth_err_counter FROM connections WHERE user_id = ? AND connection Plan: SEARCH connections USING INTEGER PRIMARY KEY (rowid=?) +Query: SELECT c.agent_conn_id FROM connections c JOIN group_members m ON m.group_member_id = c.group_member_id WHERE m.local_display_name = ? +Plan: +SCAN m USING COVERING INDEX idx_group_members_user_id_local_display_name +SEARCH c USING INDEX idx_connections_group_member_id (group_member_id=?) + Query: SELECT chat_item_id FROM chat_items WHERE user_id = ? AND contact_id = ? AND shared_msg_id = ? AND item_sent = ? Plan: SEARCH chat_items USING INDEX idx_chat_items_direct_shared_msg_id (user_id=? AND contact_id=? AND shared_msg_id=?) @@ -7233,6 +7238,10 @@ Query: SELECT max(active_order) FROM users Plan: SEARCH users +Query: SELECT member_id FROM group_members WHERE member_role = ? LIMIT 1 +Plan: +SCAN group_members + Query: SELECT member_pub_key FROM group_members WHERE local_display_name = ? Plan: SCAN group_members @@ -7253,6 +7262,10 @@ Query: SELECT member_role FROM group_members WHERE local_display_name = ? Plan: SCAN group_members +Query: SELECT member_role, member_pub_key FROM group_members WHERE local_display_name = ? +Plan: +SCAN group_members + Query: SELECT member_status FROM group_members WHERE local_display_name = ? Plan: SCAN group_members diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index b667459b6d..51fe8c54aa 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -3,6 +3,7 @@ {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE NumericUnderscores #-} {-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE PatternSynonyms #-} {-# LANGUAGE PostfixOperators #-} {-# LANGUAGE QuasiQuotes #-} {-# LANGUAGE ScopedTypeVariables #-} @@ -16,30 +17,37 @@ import ChatTests.DBUtils import ChatTests.Utils import Control.Concurrent (threadDelay) import Control.Concurrent.Async (concurrently_) +import Control.Concurrent.STM (atomically) import Control.Monad (forM_, void, when) +import Control.Monad.Except (runExceptT) import Data.Bifunctor (second) import Data.ByteString (ByteString) import qualified Data.ByteString.Char8 as B import Data.Maybe (fromMaybe, isJust, maybeToList) -import Data.Time (UTCTime) +import Data.Time (UTCTime, getCurrentTime) import Data.Int (Int64) import Data.List (intercalate, isInfixOf, isSuffixOf) import qualified Data.Map.Strict as M import qualified Data.Text as T -import Simplex.Chat.Controller (ChatConfig (..), ChatHooks (..), ChatLogLevel (..), defaultChatHooks) +import Simplex.Chat.Controller (ChatController (ChatController, smpAgent), ChatConfig (..), ChatHooks (..), ChatLogLevel (..), defaultChatHooks) import Simplex.Chat.Library.Internal (uniqueMsgMentions, updatedMentionNames) import Simplex.Chat.Markdown (parseMaybeMarkdownList) import Simplex.Chat.Messages (CIMention (..), CIMentionMember (..), ChatItemId) +import Simplex.Chat.Messages.Batch (encodeBinaryBatch, encodeFwdElement) import Simplex.Chat.Messages.CIContent (publicGroupNoE2EText) import Simplex.Chat.Options -import Simplex.Chat.Protocol (MsgMention (..), MsgContent (..), msgContentText) +import Simplex.Chat.Protocol (ChatMessage (ChatMessage), ChatMsgEvent (XGrpMemNew), FwdSender (FwdMember), GrpMsgForward (GrpMsgForward), MsgMention (..), MsgContent (..), VerifiedMsg (VMUnsigned), msgContentText) import Simplex.Chat.Types import Simplex.Chat.Types.MemberRelations (MemberRelation (..), getRelation, setRelation) import Simplex.Chat.Types.Shared (GroupMemberRole (..), GroupAcceptance (..)) +import Simplex.Messaging.Agent (sendMessages, vrValue) import Simplex.Messaging.Agent.Env.SQLite import Simplex.Messaging.Agent.RetryInterval import qualified Simplex.Messaging.Agent.Store.DB as DB import Simplex.Messaging.Agent.Store.DB (Binary (..)) +import qualified Simplex.Messaging.Crypto as C +import Simplex.Messaging.Crypto.Ratchet (pattern PQEncOff) +import Simplex.Messaging.Protocol (MsgFlags (..)) import Simplex.Messaging.Server.Env.STM hiding (subscriptions) import Simplex.Messaging.Transport import Simplex.Messaging.Version @@ -295,6 +303,7 @@ chatGroupTests = do it "removed moderator drops from the roster cache" testChannelRemovedModeratorRefreshesRoster it "role transitions update the roster (mod <-> admin, admin -> non-roster)" testChannelRoleTransitionsUpdateRoster it "malicious relay cannot downgrade or re-key a roster-established moderator via XGrpMemNew" testChannelRelayCannotDowngradeRosterMember + it "malicious relay cannot forge a privileged member via XGrpMemNew forwarded as the owner" testChannelRelayCannotForgePrivilegedMember it "should add relay to channel with roster (relay caches roster before joinable)" testChannelAddRelayWithRoster it "roster blob spanning multiple chunks reassembles" testChannelRosterMultipartReassembly it "corrupted roster blob is rejected on digest mismatch" testChannelRosterDigestMismatchRejected @@ -9371,6 +9380,13 @@ testChannels2RelaysIncognito ps = frank <# ("#team " <> danIncognito <> "> > hi") frank <## " + 👍" + alice `hasContactProfiles` ["alice", "bob", "cath", T.pack danIncognito, "eve", "frank"] + bob `hasContactProfiles` ["alice", "bob", T.pack danIncognito, "eve", "frank"] + cath `hasContactProfiles` ["alice", "cath", T.pack danIncognito, "eve", "frank"] + dan `hasContactProfiles` ["alice", "bob", "cath", "dan", T.pack danIncognito] + eve `hasContactProfiles` ["alice", "bob", "cath", T.pack danIncognito, "eve"] + frank `hasContactProfiles` ["alice", "bob", "cath", T.pack danIncognito, "frank"] + testChannelUpdateProfileSigned :: HasCallStack => TestParams -> IO () testChannelUpdateProfileSigned ps = withNewTestChat ps "alice" aliceProfile $ \alice -> @@ -9906,6 +9922,68 @@ testChannelRelayCannotDowngradeRosterMember ps = [Only k] -> pure k _ -> fail $ "expected one row for " <> T.unpack name +testChannelRelayCannotForgePrivilegedMember :: HasCallStack => TestParams -> IO () +testChannelRelayCannotForgePrivilegedMember ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> do + (shortLink, fullLink) <- prepareChannel1Relay "team" alice bob + memberJoinChannel "team" [bob] [alice] shortLink fullLink cath + threadDelay 1000000 + -- the forged attribution only resolves to a privileged author if the victim already holds the + -- owner at GROwner (established via the group link on join) - this documents and guards that premise + checkMemberRow cath "alice" (Just "owner") + ownerMemId <- ownerMemberId bob + connId <- relayConnIdToMember bob "cath" + -- the malicious relay forges the announcement, choosing the new member's role and signing key + g <- C.newRandom + kp <- atomically $ C.generateKeyPair g + ts <- getCurrentTime + let ChatController {smpAgent = bobAgent} = chatController bob + attackerPub = fst kp :: C.PublicKeyEd25519 + forgedMemId = MemberId "forgedadmin1" + forgedProfile = (aliceProfile :: Profile) {displayName = "forgery", fullName = "Forgery"} + memInfo = + MemberInfo + { memberId = forgedMemId, + memberRole = GRAdmin, + v = Nothing, + profile = forgedProfile, + memberKey = Just (MemberKey attackerPub) + } + chatMsg = ChatMessage chatInitialVRange Nothing (XGrpMemNew memInfo Nothing) + fwd = GrpMsgForward (FwdMember ownerMemId "alice") ts + body = encodeBinaryBatch [encodeFwdElement fwd (VMUnsigned chatMsg)] + sent <- runExceptT $ sendMessages bobAgent [(connId, PQEncOff, MsgFlags False, vrValue body)] + either (fail . show) (const $ pure ()) sent + -- secure: the victim rejects the forged privileged announcement instead of storing it + cath <##. "error: x.grp.mem.new: privileged member not established by roster" + forged <- forgedMemberRows cath "forgery" + forged `shouldBe` [] + where + ownerMemberId :: TestCC -> IO MemberId + ownerMemberId cc = do + rows <- withCCTransaction cc $ \db -> + DB.query db "SELECT member_id FROM group_members WHERE member_role = ? LIMIT 1" (Only ("owner" :: T.Text)) :: IO [Only ByteString] + case rows of + [Only mid] -> pure (MemberId mid) + _ -> fail "expected exactly one owner member on the relay" + relayConnIdToMember :: TestCC -> T.Text -> IO ByteString + relayConnIdToMember cc name = do + rows <- withCCTransaction cc $ \db -> + DB.query + db + "SELECT c.agent_conn_id FROM connections c JOIN group_members m ON m.group_member_id = c.group_member_id WHERE m.local_display_name = ?" + (Only name) :: + IO [Only ByteString] + case rows of + (Only connId : _) -> pure connId + _ -> fail $ "no relay connection to member " <> T.unpack name + forgedMemberRows :: TestCC -> T.Text -> IO [(T.Text, Maybe ByteString)] + forgedMemberRows cc name = + withCCTransaction cc $ \db -> + DB.query db "SELECT member_role, member_pub_key FROM group_members WHERE local_display_name = ?" (Only name) + testChannelRemoveMemberSigned :: HasCallStack => TestParams -> IO () testChannelRemoveMemberSigned ps = withNewTestChat ps "alice" aliceProfile $ \alice ->