From 0e09b38ea6834ae31a0e6af5700a9c869c5bb5d8 Mon Sep 17 00:00:00 2001
From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com>
Date: Mon, 22 Jun 2026 10:15:41 +0000
Subject: [PATCH] core: public groups - roster of privileged members (#7017)
---
apps/ios/SimpleXChat/ChatTypes.swift | 2 +
.../chat/simplex/common/model/ChatModel.kt | 2 +
.../commonMain/resources/MR/base/strings.xml | 1 +
bots/api/TYPES.md | 13 +
bots/src/API/Docs/Types.hs | 2 +
bots/src/API/TypeInfo.hs | 1 +
docs/protocol/channels-overview.md | 3 +-
.../types/typescript/src/types.ts | 8 +
.../src/simplex_chat/types/_types.py | 6 +-
...-05-26-public-groups-via-relays-unified.md | 227 +++++
plans/2026-06-01-roster-members-multipart.md | 220 +++++
simplex-chat.cabal | 2 +
src/Simplex/Chat/Library/Commands.hs | 134 +--
src/Simplex/Chat/Library/Internal.hs | 254 +++++-
src/Simplex/Chat/Library/Subscriber.hs | 583 ++++++++++---
src/Simplex/Chat/Protocol.hs | 106 ++-
src/Simplex/Chat/Store/Connections.hs | 2 +-
src/Simplex/Chat/Store/Files.hs | 114 ++-
src/Simplex/Chat/Store/Groups.hs | 288 ++++++-
src/Simplex/Chat/Store/Messages.hs | 11 +-
src/Simplex/Chat/Store/Postgres/Migrations.hs | 4 +-
.../Migrations/M20260602_group_roster.hs | 64 ++
.../Store/Postgres/Migrations/chat_schema.sql | 75 +-
src/Simplex/Chat/Store/SQLite/Migrations.hs | 4 +-
.../Migrations/M20260602_group_roster.hs | 63 ++
.../SQLite/Migrations/chat_query_plans.txt | 238 +++++-
.../Store/SQLite/Migrations/chat_schema.sql | 40 +-
src/Simplex/Chat/Store/Shared.hs | 8 +-
src/Simplex/Chat/Types.hs | 39 +
src/Simplex/Chat/Types/Shared.hs | 11 +
tests/ChatClient.hs | 2 +-
tests/ChatTests/Groups.hs | 776 ++++++++++++++++--
tests/ProtocolTests.hs | 10 +-
33 files changed, 2902 insertions(+), 411 deletions(-)
create mode 100644 plans/2026-05-26-public-groups-via-relays-unified.md
create mode 100644 plans/2026-06-01-roster-members-multipart.md
create mode 100644 src/Simplex/Chat/Store/Postgres/Migrations/M20260602_group_roster.hs
create mode 100644 src/Simplex/Chat/Store/SQLite/Migrations/M20260602_group_roster.hs
diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift
index 56f92cfc0b..d7478e5c3b 100644
--- a/apps/ios/SimpleXChat/ChatTypes.swift
+++ b/apps/ios/SimpleXChat/ChatTypes.swift
@@ -2741,6 +2741,7 @@ public enum RelayStatus: String, Decodable, Equatable, Hashable {
case new
case invited
case accepted
+ case acknowledgedRoster
case active
case inactive
case rejected
@@ -2816,6 +2817,7 @@ extension RelayStatus {
case .new: "new"
case .invited: "invited"
case .accepted: "accepted"
+ case .acknowledgedRoster: "acknowledged roster"
case .active: "active"
case .inactive: "inactive"
case .rejected: "rejected"
diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt
index fa2051a761..ecfb58fdb0 100644
--- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt
+++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt
@@ -2415,6 +2415,7 @@ enum class RelayStatus {
@SerialName("new") New,
@SerialName("invited") Invited,
@SerialName("accepted") Accepted,
+ @SerialName("acknowledgedRoster") AcknowledgedRoster,
@SerialName("active") Active,
@SerialName("inactive") Inactive,
@SerialName("rejected") Rejected;
@@ -2423,6 +2424,7 @@ enum class RelayStatus {
New -> generalGetString(MR.strings.relay_status_new)
Invited -> generalGetString(MR.strings.relay_status_invited)
Accepted -> generalGetString(MR.strings.relay_status_accepted)
+ AcknowledgedRoster -> generalGetString(MR.strings.relay_status_acknowledged_roster)
Active -> generalGetString(MR.strings.relay_status_active)
Inactive -> generalGetString(MR.strings.relay_status_inactive)
Rejected -> generalGetString(MR.strings.relay_status_rejected)
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 9630c61004..4ea07d4608 100644
--- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml
+++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml
@@ -3022,6 +3022,7 @@
new
invited
accepted
+ acknowledged roster
active
inactive
rejected
diff --git a/bots/api/TYPES.md b/bots/api/TYPES.md
index 4f3df9d365..b38e3f6c6e 100644
--- a/bots/api/TYPES.md
+++ b/bots/api/TYPES.md
@@ -87,6 +87,7 @@ This file is generated automatically.
- [FileProtocol](#fileprotocol)
- [FileStatus](#filestatus)
- [FileTransferMeta](#filetransfermeta)
+- [FileType](#filetype)
- [Format](#format)
- [FormattedText](#formattedtext)
- [FullGroupPreferences](#fullgrouppreferences)
@@ -2100,6 +2101,15 @@ NO_FILE:
- cancelled: bool
+---
+
+## FileType
+
+**Enum type**:
+- "normal"
+- "roster"
+
+
---
## Format
@@ -2316,6 +2326,7 @@ MemberSupport:
- uiThemes: [UIThemeEntityOverrides](#uithemeentityoverrides)?
- customData: JSONObject?
- groupSummary: [GroupSummary](#groupsummary)
+- rosterVersion: int64?
- membersRequireAttention: int
- viaGroupLinkUri: string?
- groupKeys: [GroupKeys](#groupkeys)?
@@ -3317,6 +3328,7 @@ Cancelled:
- xftpRcvFile: [XFTPRcvFile](#xftprcvfile)?
- fileInvitation: [FileInvitation](#fileinvitation)
- fileStatus: [RcvFileStatus](#rcvfilestatus)
+- fileType: [FileType](#filetype)
- rcvFileInline: [InlineFileMode](#inlinefilemode)?
- senderDisplayName: string
- chunkSize: int64
@@ -3441,6 +3453,7 @@ ParseError:
- "new"
- "invited"
- "accepted"
+- "acknowledgedRoster"
- "active"
- "inactive"
- "rejected"
diff --git a/bots/src/API/Docs/Types.hs b/bots/src/API/Docs/Types.hs
index 7b268f4ec5..6313c68838 100644
--- a/bots/src/API/Docs/Types.hs
+++ b/bots/src/API/Docs/Types.hs
@@ -270,6 +270,7 @@ chatTypesDocsData =
(sti @FileProtocol, STEnum' (consLower "FP"), "", [], "", ""),
(sti @FileStatus, STEnum, "FS", [], "", ""),
(sti @FileTransferMeta, STRecord, "", [], "", ""),
+ (sti @FileType, STEnum' (consLower "FT"), "", [], "", ""),
(sti @Format, STUnion, "", ["Unknown"], "", ""),
(sti @FormattedText, STRecord, "", [], "", ""),
(sti @FullGroupPreferences, STRecord, "", [], "", ""),
@@ -489,6 +490,7 @@ deriving instance Generic FileInvitation
deriving instance Generic FileProtocol
deriving instance Generic FileStatus
deriving instance Generic FileTransferMeta
+deriving instance Generic FileType
deriving instance Generic Format
deriving instance Generic FormattedText
deriving instance Generic FullGroupPreferences
diff --git a/bots/src/API/TypeInfo.hs b/bots/src/API/TypeInfo.hs
index 8dfba2bbb0..c5a3b11953 100644
--- a/bots/src/API/TypeInfo.hs
+++ b/bots/src/API/TypeInfo.hs
@@ -170,6 +170,7 @@ toTypeInfo tr =
"DBEntityId'" -> ST TInt64 []
"Integer" -> ST TInt64 []
"Version" -> ST TInt []
+ "VersionRoster" -> ST TInt64 []
"BoolDef" -> ST TBool []
"PQEncryption" -> ST TBool []
"PQSupport" -> ST TBool []
diff --git a/docs/protocol/channels-overview.md b/docs/protocol/channels-overview.md
index d4cd2d2965..7a55f8b6e5 100644
--- a/docs/protocol/channels-overview.md
+++ b/docs/protocol/channels-overview.md
@@ -182,7 +182,7 @@ The low-level protocol supports multiple owners from the initial release. The ap
- **Subscribers** connect to relays and receive content. They cannot send messages by default, but can be given posting rights.
-Additional roles (moderator, admin, member, author) exist in the hierarchy and are inherited from the group protocol.
+Additional roles (moderator, admin, member, author) exist in the hierarchy and are inherited from the group protocol. The owner-signed roster tracks the promoted set - members, moderators, and admins; subscribers are observers until an owner promotes them.
For protocol-level detail - wire formats, message types, signing and verification mechanics, delivery pipeline - see [SimpleX Channels Protocol](./channels-protocol.md).
@@ -242,6 +242,7 @@ This threat model assumes the [SimpleX network threat model](https://github.com/
- Undetectably substitute content - subscribers on honest relays receive the original.
- Alter the channel's authoritative state on the owner's device.
- Substitute the channel profile or impersonate an owner - these require valid signatures.
+- Replay an old roster or role change to re-elevate a removed or demoted member for existing subscribers - they reject anything older than the roster version they applied (a new joiner with no prior roster can still be served an old one, until it syncs from another relay).
- Redirect subscribers to a different channel - the entity ID is validated across link and profile.
- Determine subscriber identity or network address - inherited from SMP transport.
- Correlate subscriber participation across channels - each connection uses independent SMP queues. The subscriber chooses their SMP router independently, so collusion between a relay and the relay's SMP router does not compromise connections through a different router.
diff --git a/packages/simplex-chat-client/types/typescript/src/types.ts b/packages/simplex-chat-client/types/typescript/src/types.ts
index 597f2f5503..323f174f91 100644
--- a/packages/simplex-chat-client/types/typescript/src/types.ts
+++ b/packages/simplex-chat-client/types/typescript/src/types.ts
@@ -2369,6 +2369,11 @@ export interface FileTransferMeta {
cancelled: boolean
}
+export enum FileType {
+ Normal = "normal",
+ Roster = "roster",
+}
+
export type Format =
| Format.Bold
| Format.Italic
@@ -2599,6 +2604,7 @@ export interface GroupInfo {
uiThemes?: UIThemeEntityOverrides
customData?: object
groupSummary: GroupSummary
+ rosterVersion?: number // int64
membersRequireAttention: number // int
viaGroupLinkUri?: string
groupKeys?: GroupKeys
@@ -3635,6 +3641,7 @@ export interface RcvFileTransfer {
xftpRcvFile?: XFTPRcvFile
fileInvitation: FileInvitation
fileStatus: RcvFileStatus
+ fileType: FileType
rcvFileInline?: InlineFileMode
senderDisplayName: string
chunkSize: number // int64
@@ -3806,6 +3813,7 @@ export enum RelayStatus {
New = "new",
Invited = "invited",
Accepted = "accepted",
+ AcknowledgedRoster = "acknowledgedRoster",
Active = "active",
Inactive = "inactive",
Rejected = "rejected",
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 9546bfe5a8..25825d8f98 100644
--- a/packages/simplex-chat-python/src/simplex_chat/types/_types.py
+++ b/packages/simplex-chat-python/src/simplex_chat/types/_types.py
@@ -1662,6 +1662,8 @@ class FileTransferMeta(TypedDict):
chunkSize: int # int64
cancelled: bool
+FileType = Literal["normal", "roster"]
+
class Format_bold(TypedDict):
type: Literal["bold"]
@@ -1822,6 +1824,7 @@ class GroupInfo(TypedDict):
uiThemes: NotRequired["UIThemeEntityOverrides"]
customData: NotRequired[dict[str, object]]
groupSummary: "GroupSummary"
+ rosterVersion: NotRequired[int] # int64
membersRequireAttention: int # int
viaGroupLinkUri: NotRequired[str]
groupKeys: NotRequired["GroupKeys"]
@@ -2550,6 +2553,7 @@ class RcvFileTransfer(TypedDict):
xftpRcvFile: NotRequired["XFTPRcvFile"]
fileInvitation: "FileInvitation"
fileStatus: "RcvFileStatus"
+ fileType: "FileType"
rcvFileInline: NotRequired["InlineFileMode"]
senderDisplayName: str
chunkSize: int # int64
@@ -2667,7 +2671,7 @@ class RelayProfile(TypedDict):
shortDescr: NotRequired[str]
image: NotRequired[str]
-RelayStatus = Literal["new", "invited", "accepted", "active", "inactive", "rejected"]
+RelayStatus = Literal["new", "invited", "accepted", "acknowledgedRoster", "active", "inactive", "rejected"]
ReportReason = Literal["spam", "content", "community", "profile", "other"]
diff --git a/plans/2026-05-26-public-groups-via-relays-unified.md b/plans/2026-05-26-public-groups-via-relays-unified.md
new file mode 100644
index 0000000000..91f7c3a6ce
--- /dev/null
+++ b/plans/2026-05-26-public-groups-via-relays-unified.md
@@ -0,0 +1,227 @@
+# Plan: Public groups via relays (unified)
+
+Date: 2026-05-26
+
+This plan is self-contained. It supersedes `2026-05-08-public-groups-via-relays.md` and folds in the privileged-roster mechanism understood since. Implementers should work from this document alone. File:line anchors are current as of this date — confirm before editing.
+
+## Overview
+
+Channels (shipped) are relay-mediated groups where the relay forwards content from owners only; subscribers are pinned to `GRObserver` and cannot post. Public groups are the second value of the same two-axis design: same wire, same transport, but every member can post, and there are moderators/admins who can act.
+
+| `useRelays` | `groupType` | Name | Posting | Notes |
+|---|---|---|---|---|
+| `false` | (none) | Secret group | all members | today's P2P full-mesh group |
+| `true` | `GTChannel` | Channel | owners only | shipped; subscribers anonymous to each other |
+| `true` | `GTGroup` | **Public group** | every member | **new**; member-to-member DMs deferred |
+| `true` | `GTUnknown _` | (refuse) | — | newer-client link seen by older client → refuse to join |
+
+Three concepts, kept distinct: **transport** = `useRelays` (topology, batch, signatures, delivery); **governance** = `groupType` (who may post, member affordances); **joiner role** = the default role new joiners get, set by the owner on the signed profile.
+
+Two things make public groups work and neither exists today:
+
+1. **The joiner role must come from the owner-signed profile**, not a relay-side global config — otherwise relays disagree on the default role. (Section 2.)
+2. **Members must learn who the moderators/admins are — their identity, signing key, and role — in a way a relay cannot forge.** Today a relay can fabricate a moderator (Section 1, Problem). This is the load-bearing piece and ships first.
+
+`GTGroup`, `PublicGroupProfile`, `useRelays'`, and the relay/signing/forwarding machinery already exist (anchors below). The work is additive.
+
+---
+
+# Section 1 — Privileged roster (`XGrpRoster`)
+
+This is a **general relay-group mechanism**: public groups use it now; channels inherit it for their multi-owner/moderator future. It is the first, self-contained task.
+
+## 1.1 Problem and trust model
+
+Owners are trusted because their keys come from the **link**, never the relay: on join, `createLinkOwnerMember` (`Store/Groups.hs:3072`) writes each owner's `member_pub_key` from the link's `OwnerAuth` chain, validated against `publicGroupId == sha256(rootKey)`. `xGrpMemIntro` even nulls the key when `mRole == GROwner` (`Subscriber.hs:3029`): *"owner key must only come from link data, not from relay intro."*
+
+Non-owner privileged members have no such anchor. Today `xGrpMemIntro` **keeps** the relay-asserted key for `GRModerator`/`GRAdmin`, and `introduceInChannel` (`Internal.hs:1165`) introduces all of `getGroupModerators` (which returns mod+admin+owner, `Store/Groups.hs:1190`). So a malicious relay can assert "X is a moderator, here is X's key," and the subscriber will then trust the relay-chosen key to verify X's signed administrative actions (`XGrpMemDel`, `XGrpMemRestrict`, `XGrpMemRole`). The `when (memRole > GRMember)` gate in `xGrpMemNew` (`Subscriber.hs:2957`) blocks the *dissemination* path but not the *join-time intro* path — the protection is half-applied. Dormant for channels (single-owner broadcast), activated by public groups.
+
+**Conclusion:** a non-owner privileged member's `(memberId, name, key, role)` must be **owner-signed**, exactly like owners are link-signed. That is the roster.
+
+## 1.2 Wire event and signing
+
+New event `XGrpRoster` (add to `ChatMsgEvent`, `Protocol.hs:422`), JSON-encoded, carrying:
+
+- `version :: Word32` — monotonic, from 0.
+- `roster :: [{ memberId, name, key, role }]` — the complete current privileged set, `role ∈ {GRModerator, GRAdmin}`. `name` is a display name only (to avoid ugly "unknown member" records, as `XGrpMsgForward` already carries one). Owners are **not** in the roster.
+
+Add `XGrpRoster_` to `requiresSignature` (`Protocol.hs:1231`) ⇒ `True`. This makes the owner sign it via the existing `groupMsgSigning` (`Internal.hs:1962`, binding `CBGroup <> (publicGroupId, ownerMemberId)`, key `groups.member_priv_key`) and makes recipients require a valid owner signature via `withVerifiedMsg` (`Subscriber.hs:3461`). No new crypto.
+
+**The handler MUST assert the resolved author is an owner** (`memberRole' author == GROwner`). `withVerifiedMsg` verifies the signature against the *author's* key, and the relay chooses `fwdSender` — so without this assertion a relay could route a roster as a member whose key it controls and the signature would verify. Owners exist on recipients only via the link `OwnerAuth` chain, so a relay can neither fabricate an owner nor sign as one. This assertion is the crux of the roster's integrity.
+
+## 1.3 Authoritative model — versioned snapshot, latest-wins, TOFU keys
+
+Each `XGrpRoster` is the complete current privileged set. Recipients treat the highest-version valid roster as authoritative for *who is privileged and their keys*; absence from the newest accepted roster means *not privileged* (reverts to the joiner default unless an accompanying `XGrpMemRole` sets a specific role — see 1.6). This is self-healing: a member who missed one change gets the full current state on the next roster.
+
+**Key handling is trust-on-first-use, pinned per `memberId`** (per entry):
+
+- `memberId` unknown, or known without a key → store the key (first sight, from the owner). Set name/role.
+- `memberId` already has a key:
+ - same key → fine; update name/role.
+ - **different key → error.** Never overwrite; keep the old key; surface a suspicious-roster event.
+
+There is **no in-place key rotation**: a genuine re-key is modeled as a *new member* — the owner removes the old `memberId` (`XGrpMemDel` + roster drop) and adds a new `memberId` with the new key. Consistent with SimpleX's no-mutable-identity stance, and with the `xGrpMemNew` rule in 1.5.
+
+**Anti-replay / rollback.** The relay cannot forge a signed roster but can replay an older one.
+
+- The current roster `version` is anchored in the owner-controlled link mutable data (which already holds `OwnerAuth`, profile, subscriber count). The relay cannot forge it. A roster change that bumps the version also updates link data. **Status:** the write side is implemented; the join-time **read/detect** is deferred — comparing the anchor against the relay-served roster at join is racy (the forwarded roster may not have arrived yet → false positives), so correct staleness detection must be triggered by roster arrival, not at join. The residual new-joiner rollback gap below stands; the hard anti-replay (member + relay) is in place.
+- **Existing members** reject any roster with `version` below the highest already accepted — full anti-replay for them.
+- **New joiners** process the latest version the relay actually serves, even if it lags the link anchor, so honest relay propagation lag never blocks a join. The anchor is used for staleness *detection*, not a hard gate, in v1.
+- **Documented residual gap:** a stale/malicious relay can serve an old-but-valid roster to a brand-new joiner. Documented in `channels-overview.md` with future mitigations: escalate verification to an owner, or have the client compare the relay's version to the link anchor and refuse/retry above a staleness threshold.
+
+## 1.4 Cap on the privileged set
+
+Bound the privileged set so the signed roster always fits one message — never paginate. A hard **cap on moderators + admins** (owners are on the link, not counted), enforced **at promotion time** on the owner: refuse to elevate beyond the cap with a clear error, so the roster is always constructible as one signed message.
+
+Derive the number from the single encoded-message budget (verify the exact constant — the encoded-message-length limit minus signature + JSON overhead) divided by worst-case entry size. With `{memberId, name, key, role}` entries this is comfortably in the tens-to-~100 range; pick the final value from the measured worst-case entry.
+
+## 1.5 Remove the dissemination gates; gate on the roster instead
+
+Because the relay forwards the roster on join **before anything else**, a privileged member's key/role is owner-established before any relay-asserted introduction arrives. So the relay may now disseminate privileged members' full profiles like any other member, and the gates come out:
+
+- **Remove** the `when (memRole > GRMember)` throw in `xGrpMemNew` (`Subscriber.hs:2957`).
+- **Remove** the forward-side `memberRole' s <= GRMember` filter in `sendBodyToMembers` (confirm exact site in `Subscriber.hs`).
+- **Replace** with a roster check in `xGrpMemNew`: for an announcement of a privileged role, require that a member record with that `memberId` already exists **with that privileged role** (roster-established). If found → accept the **profile** update only; **never overwrite `key`, `memberId`, or `role`** (roster-authoritative). If not found → reject (a relay conjuring a privileged member not in the roster).
+
+`introduceInChannel` forwards the cached roster to the new member first, then proceeds; it may still announce the newcomer to moderators and maintain the relations vector. The per-mod `XGrpMemIntro` carrying keys is no longer the trust path for privileged members.
+
+## 1.6 Delivery — what is sent, when, to whom
+
+Two orthogonal axes, and the roster owns only one:
+
+- **Axis A — privileged set + keys** (who is mod/admin, their key/name/role): owned by the roster.
+- **Axis B — group-membership lifecycle** (removed / restricted / left): owned by `XGrpMemDel` / `XGrpMemRestrict` / `XGrpLeave`, unchanged, applies to everyone.
+
+Dispatch by whether an operation touches the {moderator, admin} set (the *roster roles*). Owner is not a roster role — promotion to/from owner uses `XGrpMemRole` (+ link `OwnerAuth`), never the roster.
+
+**`APIMembersRole` (role change, possibly batched):**
+
+- Emit `XGrpMemRole(M, target)` for each affected member exactly as today — this conveys the exact target role for any role, including owner and specific ≤member roles.
+- **Additionally** build and **broadcast** the full signed roster (version++) **iff the {mod, admin} set changed** (any member entered, left, or moved within mod/admin).
+- A mixed batch fires both. Example: target=member over `[moderator M, observer O]` → `XGrpMemRole` for both (exact roles) **and** a roster (M left the set). The promotion case `XGrpMemRole(M, mod)` + roster is mildly redundant on the role field and harmless; the key comes only from the roster.
+
+The broadcast reuses the existing owner-admin-event forwarding (`shouldForward = isUserGrpFwdRelay gInfo && not forwarded`, `Subscriber.hs:3191`). Privileged-set changes are rare administrative events, so this is on the order of an `XGrpInfo`/`XGrpPrefs` broadcast — not per-message. The broadcast roster is the **self-healing** mechanism: a member who missed a prior `XGrpMemRole` is corrected by the snapshot.
+
+**`XGrpMemDel` (removal):** broadcast `XGrpMemDel` as today (it neutralizes the member for existing members). If the removed member was privileged, the owner sends a refreshed roster (version++), which the relay broadcasts like any other version bump (see below).
+
+**Relay broadcast rule — always broadcast on a strict version bump.** A newer-version roster is applied, cached, and broadcast to current members, uniformly — promotion, key/role change, demotion, or privileged removal. We do **not** try to make removal cache-only: a demotion (member stays in the group) is indistinguishable from a deletion at the roster-diff level, so suppressing the broadcast would silently drop the self-healing the spec requires for role changes. The only cost is one redundant roster broadcast alongside `XGrpMemDel` on the rare deletion of a privileged member — and even there the broadcast is not waste, since it self-heals the privileged-set side if the `XGrpMemDel` was lost. (This supersedes an earlier "cache-only on deletion" idea, which could not be implemented without either a wire flag or a fragile demotion-vs-deletion diff.)
+
+**On relay add:** the owner sends the current roster to the new relay so it can serve joiners.
+
+**Joiners:** the relay forwards the cached roster at join (1.5).
+
+**Short offline gaps** are covered by ordinary queued delivery: the role-change roster broadcast sits in the member's SMP queue (FIFO) ahead of any later moderator events, so it is processed first on reconnect.
+
+**Quota-blocked catch-up.** A member offline long enough to fill its queue causes the relay to be quota-blocked — the broadcast may never have been enqueued, so naive queueing would leave the member without the current roster, rejecting moderator events indefinitely. Fix: when the queue drains, the relay **sends the current cached roster ahead of the resumed backlog**, so the member holds the current privileged set before processing the events it couldn't verify.
+
+The hook is confirmed: QCONT is delivered to the **sender** when the recipient drains (`simplexmq Agent.hs:3402`), and the relay receives it per subscriber in the group-member connection handler (`Subscriber.hs:1215` — `continueSending` + `sendPendingGroupMessages user gInfo m conn`, with `gInfo`/`m`/`conn` in scope; a relay→subscriber connection is a group-member connection). Implementation must ensure **roster-first ordering** relative to both the agent-level `continueSending` flush and the re-driven delivery tasks, and gate the extra send on a per-member "delivered roster version" so it fires only when the member is behind.
+
+The roster is **never** delivered through the profile-dissemination prepend. That path (`member_relations_vector` → `XGrpMemNew`) carries **profiles** only; with the gate removed (1.5), a privileged member's profile disseminates through it like any other member's, but only after the roster has established their key/role. Profile via prepend, key/role via roster — orthogonal, no double-prepend.
+
+## 1.7 Relay-side cache (the one new storage pattern)
+
+Relays already forward signed bytes verbatim (`encodeFwdElement` `Batch.hs:106`, `verifiedMsgParts` `Protocol.hs:1445`; `messages.msg_chat_binding` + `msg_signatures`; reconstructed in `toTask` `Delivery.hs:154`). What does **not** exist is "store the latest roster and re-emit to joiners" — `sendHistory` (`Internal.hs:1207`) reconstructs content and does *not* preserve signatures, so it is not a template.
+
+Add a small per-group cache holding the latest signed roster message bytes, plus the roster `version` as a **separate column** alongside them (so the relay compares versions without re-parsing the blob). On receiving `XGrpRoster` from an owner the relay: verifies the owner signature; **checks `version` strictly greater than the cached version** (lower → reject as rollback; equal → idempotent no-op); then updates its own member-role records, overwrites the cache + stored version, and (for a role-change-origin roster) creates a delivery task to all current members. On join, it forwards the cached bytes verbatim.
+
+The relay-side version check protects an **honest** relay's cache from being rolled back by a replayed signed roster — which in turn protects every joiner that relay serves. It does not constrain a **malicious** relay (it controls its own cache); that remains the documented new-joiner residual gap (1.3), bounded by the member-side check (1.3) and the link version anchor.
+
+## 1.8 Races and tests
+
+- **Promotion vs. action ordering.** A newly-promoted mod could act before its roster reaches a recipient ⇒ `RGEMsgBadSignature`. Covered for MVP by causal ordering (the roster is broadcast at promotion, before the mod learns of and acts on it), QCONT catch-up (item 7), and recipient tolerance (a rejected first action is re-sent; the next roster repairs trust). The fuller fix — the **roster-specific prepend** (prepend the cached signed roster ahead of a privileged member's forwarded action for recipients below the current version, reusing the item-7 delivered-version tracker, distinct from the `XGrpMemNew` profile prepend) — touches the hot per-recipient delivery loop that carries every forwarded message, so it is **deferred to a focused, separately-tested pass** rather than shipped untested.
+- **Multi-owner roster signed by an unknown owner** (owner added after the recipient fetched the link): recipient cannot verify ⇒ buffer/refetch link. Cannot occur for single-owner MVP; flag for v7.
+- **Roster vs. profile-update concurrency:** benign (different fields); verify the roster's relations-vector handling does not clobber the profile `MRIntroduced` semantics they share.
+
+Tests: relay-fabricated moderator key is rejected (forgery); promotion delivers a verifiable key; demotion via roster + `XGrpMemRole` reconciles; removed privileged member does not reappear for a new joiner; replayed older roster rejected by existing members; TOFU key-change rejected; batch `APIMembersRole` emits roster + `XGrpMemRole` correctly; self-healing after a dropped role event.
+
+## 1.9 Key anchors for Section 1
+
+`ChatMsgEvent` `Protocol.hs:422`; `requiresSignature` `Protocol.hs:1231`; `groupMsgSigning` `Internal.hs:1962`; `withVerifiedMsg` `Subscriber.hs:3461`; `xGrpMemNew` `Subscriber.hs:2957`; `xGrpMemIntro` `Subscriber.hs:3015`; `introduceInChannel` `Internal.hs:1165`; `getGroupModerators` `Store/Groups.hs:1190`; `memberInfo` `Internal.hs:1187`; `createLinkOwnerMember` `Store/Groups.hs:3072`; `GroupKeys` `Types.hs:462`; `member_relations_vector` machinery in `Types/MemberRelations.hs` (`MemberRelation`, `MRIntroduced`, `IDSubjectIntroduced`, `setNewRelations`); forwarding `Batch.hs:106` / `Protocol.hs:1445` / `Delivery.hs:154`. New columns go in a **new tail migration** (`M20260222_chat_relays` is the *pattern* for relay group columns but is not the tail — never edit an applied migration; `M20260525_member_removed_at` is the current tail).
+
+---
+
+# Section 2 — Joiner role on the signed profile
+
+Today the relay derives a joiner's role from `channelSubscriberRole` (`Controller.hs:161`, default `GRObserver` `Chat.hs:119`), a global config — so relays can disagree and the owner cannot set it per group. Move it onto the owner-signed profile.
+
+## 2.1 Types and helpers
+
+- Add `joinerRole :: Maybe GroupMemberRole` to `PublicGroupProfile` (`Types.hs:798`). No migration: JSON derives via `deriveJSON defaultJSON` with `omitNothingFields = True`, so `Nothing` is omitted on encode and a missing field decodes to `Nothing`.
+- Add a `groupType` accessor and `isChannel` on `GroupInfo`/`GroupProfile`, and a resolver `joinerRoleFor :: GroupInfo -> GroupMemberRole` = `joinerRole` if set, else type-keyed default (`GTChannel → GRObserver`, `GTGroup → GRMember`, `GTUnknown _ → GRObserver`). Reuse the existing `publicGroupEditor`/`memberRole'` (`Types.hs:499`/`506`); do **not** introduce a profile-side `memberRole'` (name collision).
+
+## 2.2 Replace the global config
+
+Switch every `channelSubscriberRole` reader to `joinerRoleFor gInfo` and delete the config: `Controller.hs:161`, `Chat.hs:119`, `Commands.hs:2053`, `Commands.hs:2546`, `Subscriber.hs:3248`, `Subscriber.hs:4019`. Verify no out-of-tree consumer reads it.
+
+## 2.3 Command, preferences, defensive refusal
+
+- `APINewPublicGroup` (`Controller.hs:526`, handler `Commands.hs:2495`) gains `groupType` (default `GTChannel`) and optional `joinerRole` (default `joinerRoleFor` of the type); both written onto the constructed profile (today hardcodes `groupType = GTChannel` at `Commands.hs:2538`).
+- Parameterize the channel-prefs parser by `GroupType`: Channel keeps its override (`support = OFF`); Public group and `GTUnknown` use secret-group defaults (member-to-moderator escalation is expected). Do not duplicate the parser — parameterize it.
+- `directMessages` stays ON by inheritance but is dormant in any relay-mediated group; hide its toggle when `useRelays` and refuse `xGrpDirectInv` defensively when `useRelays'` (`Subscriber.hs:3321`, currently ungated): emit `messageError`, create no contact.
+
+## 2.4 Compatibility
+
+Existing channels: no `joinerRole` ⇒ falls back to `GRObserver` for `GTChannel`. No data migration. Older relays without this change resolve the joiner role from their global config — warn the owner at create time if a selected relay's chat version is below the public-groups version (soft warning, not a block).
+
+---
+
+# Section 3 — Backend tests
+
+Public-group helpers paralleling the channel helpers, plus:
+
+1. Member posts; all members receive it (no "unknown member" lines). 2. Multi-author session: no "unknown member" anywhere. 3. Member edit/delete/react forwarded to all. 4. `xGrpDirectInv` refused under `useRelays` (no contact created); repeat for Channel. 5. Blocked member's messages not forwarded. 6. Multi-relay delivery with cross-relay dedup. 7. History on join. 8. `asGroup=true` from a non-owner rejected. 9. Receipts disabled above the member limit. 10. Older client refuses `groupType = "group"` (needs-newer-version). 11. Incognito member posting attributes the incognito profile. 12. `joinerRole` propagates and defaults correctly (Channel→observer, Public group→member; absent→type default). Plus the roster tests in 1.8.
+
+---
+
+# Section 4 — Clients (iOS, then Kotlin)
+
+## 4.1 Audit `useRelays` vs `isChannel` (structural commit, on its own)
+
+~70–75 sites per platform branch on `useRelays` as a proxy for "is a channel." Split per a mechanical rule and land as a pure structural commit (no behavior change in the same diff):
+
+- **Transport** (keep `useRelays`): link/relay management, owner-can't-leave-own-relay-group, relay-status indicator, incognito flag, typing-state gating, member-DM-affordance suppression.
+- **Governance** (switch to `isChannel`): titles, "subscribers" vs "members" framing, "Channel preferences" labels, channel-style vs group-style member display.
+
+## 4.2 Model and behavior
+
+- Model: add `group` arm to `GroupType` (with serializers); `joinerRole`, `groupType`, `isChannel` accessors. Authoritative role resolution stays in Haskell; clients use it for display.
+- **Narrow the existing refusal:** PR #7009 (merged to `stable`) added `GLPUpdateRequired` for `groupType /= GTChannel` (`Controller.hs:1051`, `Commands.hs` `unsupportedGroupType`). Change it to refuse only `GTUnknown _`; `GTGroup` proceeds to a public-group join.
+- Create flow: one view with a Channel / Public-group segmented control (default Channel) driving the title, link-step label, success screen, and two API params (`groupType`, `joinerRole` = observer for Channel, member for Public group — no role picker in MVP). Hide `directMessages` in create prefs when `useRelays`. Render the threat-model note below the title for Public groups (text in Section 5).
+- Suppress the member-tap "send direct message" affordance in any relay-mediated group.
+- Members view shows the relay-known roster; header "subscribers" (channel) vs "members" (public group). No filtered view in MVP.
+- Strings/icons: ~5–10 `_public_group` string keys mirroring channel forms; reuse `group_members_*` for "members" framing; a distinct Public-group icon (pending design). Kotlin-only: chat-list filter chips place Public groups in the "groups" bucket.
+
+Platforms ship independently (API defaults are backward compatible).
+
+---
+
+# Section 5 — Threat model, docs, release
+
+Fold into `channels-overview.md` (public groups inherit the entire channel threat model; deltas only):
+
+- **A relay can fabricate content as any member** (channels: only as owners). Content (`XMsgNew`/`Update`/`Del`/`React`) is unsigned by design for deniability (`requiresSignature` lists roster/admin events only); broader blast radius in public groups. Detectable via cross-relay consistency. Mitigation is the future opt-in content signing on the channel roadmap; the create-flow note states the trade-off ("a malicious relay could change or fabricate messages from any member — pick relays you trust, or use a secret group for peer-to-peer integrity").
+- **Roster rollback for new joiners** (1.3): documented bounded delta + future mitigations.
+- Unchanged: relay cannot impersonate an owner or substitute the profile (signed events, validated entity ID); `joinerRole` and the privileged roster are owner-signed, so the relay cannot unilaterally change a joiner's default role or fabricate a moderator.
+
+Document `XGrpRoster` (event, signing, versioning, TOFU, delivery) in `channels-protocol.md`. Bump the chat protocol version (the public-groups version that gates `GTGroup` and `joinerRole`). Release notes include the relay-fabrication line.
+
+---
+
+# Sequencing
+
+1. **Section 1 — privileged roster** (backend). The core; gates the rest of the value. Land the gate-removal/roster-check and the event together so no half-applied trust window exists.
+2. **Section 2 — joiner role on profile** (backend). Independent of Section 1.
+3. **Section 3 — backend tests** (alongside 1–2).
+4. **Section 4 — clients**: audit (structural) first, then iOS, then Kotlin.
+5. **Section 5 — docs/version/release** with the backend release.
+
+Backend (1–3) gates the clients. iOS and Kotlin are independent of each other.
+
+---
+
+# Out of scope (deferred)
+
+- **Member-to-member DMs in relay-mediated groups.** Prohibited here (client affordance suppressed, receive-path refusal, relay does not forward `XGrpDirectInv` — so no relay-visible DM graph). A future plan must re-derive the threat model: relay-forwarded DMs would expose (sender, target, time) metadata; relay-blind rendezvous via per-member queues is the privacy-preserving alternative.
+- **`memberAdmission` on relay-mediated join** (hardcoded `GAAccepted` bypasses review/captcha — generic relay-groups gap).
+- **Roster filter/pagination in the members view** for very large groups.
+- **Multi-owner** roster signing/verification and owner promotion via link `OwnerAuth` (v7); **opt-in content signing** (v7 roadmap); **full anti-rollback for new joiners** (link-version hard gate).
diff --git a/plans/2026-06-01-roster-members-multipart.md b/plans/2026-06-01-roster-members-multipart.md
new file mode 100644
index 0000000000..aca98c4698
--- /dev/null
+++ b/plans/2026-06-01-roster-members-multipart.md
@@ -0,0 +1,220 @@
+# Roster: regular members + larger rosters via inline file
+
+Date: 2026-06-01 (revised). Extends Section 1 of `2026-05-26-public-groups-via-relays-unified.md`.
+
+> Anchors below were re-verified against `f/public-groups-members-in-roster` **after** PR #7036 (`core: signed XMember in public group`, commit `0773ccd05`) merged in. Most line numbers shifted; the header-fits check uses `maxEncodedMsgLength = 15602` (now `Protocol.hs:905`). Confirm before editing.
+
+## Reconciliation with PR #7036 (merged into this branch)
+
+PR #7036 landed things this plan predates. Read this section first — it changes the relay flow the plan builds on.
+
+**Renames (the plan's old names no longer exist — grep will miss them):**
+
+| Was | Now | Location |
+|---|---|---|
+| `forwardCachedRoster` | `forwardGroupRoster` | `Internal.hs:1172` |
+| `setCachedGroupRoster` | `setGroupRoster` | `Store/Groups.hs:1415` |
+| `getCachedGroupRoster` | `getGroupRoster` | `Store/Groups.hs:1428` |
+| `setRelayLinkAccepted` | `setRelayKey` (no longer sets relay status) | `Store/Groups.hs:1543` |
+
+"Cached roster" is now "saved roster" throughout; the `roster_msg_*` columns and the `roster_blob` this plan adds are unchanged in intent.
+
+**Roster version baseline is now `Just 0`, not NULL, for relay groups.** `createNewGroup` initializes `roster_version = Just (VersionRoster 0)` for `useRelays` groups (`Store/Groups.hs:365, 427`), and an old channel materializes `0` the first time a relay connects (`Subscriber.hs:905-910`). Consequences: the first promotion bumps `0 -> 1` (not NULL -> 0); the owner and already-onboarded members/relays compare against a real `0`, **but a relay's own `roster_version` is still NULL the first time it receives v0** — applying v0 from NULL is exactly what lets it ack and become publishable (verified: today's `maybe False (newVer <=) (rosterVersion gInfo)` at `Subscriber.hs:3207` applies v0 only because the relay is at `Nothing`; `Just 0` would reject it and the relay would hang `RSInvited`). So the multipart version guards MUST be `Maybe`-comparisons that treat `Nothing` as below `0` (spelled out under *Header handler* and *Completion*); and the **empty roster (v0)** must round-trip through the header+blob path (a 2-byte blob: `Word16` count `0`, one chunk, `chunkSize >= fileSize` -> `RcvChunkFinal` on chunk 1). The empty/small blob is the *common* case for relay onboarding, not an edge case.
+
+**NEW relay roster-ack handshake (`XGrpRosterAck`) — this plan MUST integrate it.** PR #7036 added `XGrpRosterAck :: VersionRoster -> Maybe Text` (`Protocol.hs:499`; tag `x.grp.roster.ack`; NOT in `requiresSignature` — it rides the relay's authenticated connection). Flow:
+
+- On relay connect (`GCInviteeMember` + `isRelay`) the owner **always** sends the current roster via `sendGroupRosterToRelay`, and the relay stays `RSInvited` (**unpublishable**) until it acks (`Subscriber.hs:900-910`).
+- The relay applies the roster in `relayApplyRoster` and, **only while its own status is `RSAccepted`**, sends `XGrpRosterAck author newVer Nothing` (or an error string) — `Subscriber.hs:3210-3221`, `sendRosterAck` at `3276`.
+- The owner's `xGrpRosterAck` handler (`Subscriber.hs:3279-3297`) transitions the relay `RSInvited -> RSAccepted` (and publishes via `setGroupLinkDataAsync`) on a version-matching success ack, or `RSInvited -> RSRejected` on error.
+
+Impact on the multipart design (a REQUIRED change, not just a rename): under this plan the header only *starts* a transfer, so on a relay the **apply, `setGroupRoster`, the ack, AND the broadcast all move to blob completion**, not header receipt. The relay becoming publishable now **gates on the blob transfer completing**: header -> chunks -> verify digest -> `processRoster` + `setGroupRoster` -> (`relayOwnStatus == RSAccepted`) `sendRosterAck` -> broadcast. This is the desired fail-safe (an unpublishable relay can't serve a half-applied roster), but it puts owner->relay blob delivery on the relay-onboarding critical path, not just self-healing. The error branch MUST still ack-with-error (digest mismatch / parse failure -> `sendRosterAck author newVer (Just "...")`) so the owner marks the relay `RSRejected` instead of leaving it hung at `RSInvited`. The current `relayApplyRoster` to fork is `tryAllErrors (setRoster sm)`, where `setRoster` = `processRoster` + `setGroupRoster` (`Subscriber.hs:3205-3228`); the multipart version splits this across header (write `roster_pending_*`) and completion (run `setRoster` + ack + broadcast).
+
+## Goal
+
+Let owners promote channel subscribers to **regular members** who can post, and carry more named members than fit one message.
+
+The JSON roster already exists (event, signing, relay cache, TOFU apply, broadcast, join forward, QCONT). This plan **widens the roster set to include plain members and changes the delivery** to a binary blob over the inline file transfer; the apply logic (`processRoster`) is reused.
+
+The member list moves out of the `XGrpRoster` message into a binary blob sent over the existing inline file transfer. `XGrpRoster` becomes a small signed header (version + the blob's size and digest).
+
+## Roster set: the promoted set {member, mod, admin}
+
+Owners stay on the link, never in the roster. Two edits, then every gate follows.
+
+**1. Widen `isRosterRole`** (`Internal.hs:1237`) to `{GRMember, GRModerator, GRAdmin}`. Every call site wants the promoted set, so this single edit covers:
+
+- `validateGroupRoster` filter (`Internal.hs:1243`) — fixes the bug where member entries are dropped.
+- `buildGroupRoster` filter (`Internal.hs:1255`).
+- promotion gates / cap / trigger / counts (`Commands.hs:2737, 2739, 2746, 2762, 2763, 2768`); update the cap error text at `2740`.
+- owner-remove roster refresh (`Commands.hs:2888`, guarded by `anyPrivilegedRemoved` computed from `isRosterRole` at `2899`) — so removing a plain member, not just a mod/admin, refreshes the roster.
+- receive gates: `xGrpMemNew` (`Subscriber.hs:3011/3026/3045`) and `xGrpMemRole` owner-only (`3185`).
+- **join key-proof gate (NEW in PR #7036, `Subscriber.hs:1620`, `memberJoinRequestViaRelay`)**: a join claiming a `memberId` already roster-established as `isRosterRole` must prove possession of the pinned key (signature + `memberPubKey` match + `viaRelay == this relay's memberId`). Widening to members **extends this proof to promoted members** — a promoted member re-connecting through a relay must sign its `XMember` with the roster-pinned key. This is correct and desirable (it is the receive-side counterpart of the promote-time key invariant in *Known limitations*), and it composes with PR #7036's `acceptGroupJoinRequestAsync existingMem_` path that attaches the connection to the existing roster record. Confirm the promoted member signs its join `XMember` (it does — `encodeXMemberConnInfo`, `Internal.hs`).
+
+**2. Split the role query.** `getGroupRosterMembers` (`Store/Groups.hs:1215`) currently serves two now-diverging needs:
+
+- **Build / revert** wants the promoted set. Redefine `getGroupRosterMembers` to `member_role IN (GRMember, GRModerator, GRAdmin)` (current members). Callers: `bumpAndBroadcastRoster` (`Internal.hs:2175`), `sendGroupRosterToRelay` (`2188`), and the `processRoster` revert set `currentPriv` (`Subscriber.hs:3245`). Build and revert MUST be the same query, or a dropped member is never reverted.
+- **`introduceInChannel`** (`Internal.hs:1188`) wants only the moderation set (mod+admin). Widening it would announce every joiner to every member and introduce every member to every joiner (traffic + anonymity blowup). Reuse the existing `getGroupModerators` (`Store/Groups.hs:1204-1209`, returns mod+admin+owner) rather than adding a function: keep `getGroupOwners` for the owner-first intro, and take mod+admin as `getGroupModerators` minus owners. **Re-apply `filter memberCurrent`** — `getGroupModerators` does NOT filter current members (unlike the old `getGroupRosterMembers`), so without it a removed or left moderator would be introduced to joiners. Members are learned from the roster blob, not introductions.
+
+**Owner-only (confirmed decision).** Only the owner changes any roster role. The alternatives were considered and rejected for v1 — letting a mod/admin set member roles would need either the owner co-signing rosters from a mod/admin (owner round-trip + load) or a separate roster-signing key trusted from mod/admin (broader trust surface) — so owner-only keeps the single owner-key trust anchor.
+
+**Leave and owner-remove differ.** This plan **removes** the `xGrpLeave` roster-bump block (`Subscriber.hs:3439`): since `isRosterRole` is widened, it would otherwise fire `bumpAndBroadcastRoster` on every plain-member leave. So a member **leave** does NOT bump the roster — the leave is the membership axis (`XGrpLeave` neutralizes the member on the relay). An owner **remove** (`APIRemoveMembers`) DOES still bump via `bumpAndBroadcastRoster` (`Commands.hs:2888`, widened to cover plain members). `bumpAndBroadcastRoster` thus stays only for promotion (`APIMembersRole`) and owner-remove.
+
+## Wire: signed header + unsigned blob
+
+**Authoritative metadata is in the signed header.** `version`, blob `fileSize`, and `fileDigest` all live in the owner-signed `XGrpRoster`; the unsigned `BFileChunk`s carry no authoritative metadata. (This is why "total parts in the unsigned part", an earlier review question, is a non-issue here.)
+
+- **Header**: `XGrpRoster { version :: VersionRoster, fileInv :: InlineFileInvitation }`, JSON, signed, forwarded. `InlineFileInvitation { fileSize, fileDigest :: FD.FileDigest }` is a lean `FileInvitation` (no name/connReq/inline/descr; always inline). Tiny; fits `maxEncodedMsgLength` (15602, `Protocol.hs:905`).
+- **Blob**: the binary member list. `RosterMember { memberId, key, role, privileges :: Word16 }` — drop `name`, add `privileges` (reserved: always `0`, parsed and ignored in v1). ~60 B/entry. Members get a placeholder name from `nameFromMemberId`; real profiles arrive on first post.
+- **Serializer/parser**: a binary codec for the blob (a `Word16`-count-prefixed `[RosterMember]`); `RosterMember` becomes binary-only. Full code in *Blob format* below. Owner serializes → digest → chunks; receiver concatenates chunk bytes → verifies the digest → parses.
+- **Cap** `maxGroupRosterSize` → **256** (tunable). Enforce at promotion over the promoted set (`Commands.hs:2739`, via the widened predicate); the receive-side entry-count bound is the parser alone (`rosterBlobP`'s `n > maxGroupRosterSize`); reject a signed `fileSize > cap × max-entry-size` before creating a file. Roster files are exempt from the inline `offer/receiveChunks` ceiling (at 256 ≈ 15 KB the blob is about one `fileChunkSize` chunk; the multipart path handles two if role words push it over).
+
+**Type changes.**
+
+- `GroupRoster` (`Protocol.hs:372-376`): `{version, roster :: [RosterMember]}` → `{version, fileInv :: InlineFileInvitation}`. It stays JSON (the signed header), so `InlineFileInvitation` needs a JSON instance; update its stale doc comment ("Owner-signed snapshot of the privileged (moderator/admin) set").
+- `RosterMember` (`Protocol.hs:378`): drop `name`, add `privileges :: Word16`; remove `deriveJSON` (`Protocol.hs:812`) — binary-only now — and add the `Encoding` below. `buildGroupRoster`'s constructor (`Internal.hs:1255`, currently `name = memberShortenedName m`) drops the `name` field; the consumer side already maps to `nameFromMemberId`.
+- `validateGroupRoster` (`Internal.hs:1241-1242`): was `GroupRoster -> GroupRoster` over `.roster`; now `[RosterMember] -> [RosterMember]`, run on the parsed blob.
+
+### Blob format (serializer / parser)
+
+`RosterMember` is **binary-only** (carried in the blob, never in a JSON message) and gets the `Encoding` below. `MemberKey` (`Types.hs:972`, only `StrEncoding`) and `GroupMemberRole` (`Types/Shared.hs:33`, only `TextEncoding`) lack a binary `Encoding`: `MemberKey` delegates to the underlying `PublicKey` (`Crypto.hs:568`), and the role delegates to its canonical `TextEncoding` (the same `"member"/"moderator"/"admin"` form JSON and the DB use — single source of truth; `GRUnknown` round-trips).
+
+```haskell
+-- MemberKey gains a binary Encoding (it only had StrEncoding); delegate to the Ed25519 key.
+instance Encoding MemberKey where
+ smpEncode (MemberKey k) = smpEncode k
+ smpP = MemberKey <$> smpP
+
+-- General instance (belongs beside GroupMemberRole's TextEncoding in Types/Shared.hs, not here).
+instance Encoding GroupMemberRole where
+ smpEncode = smpEncode . textEncode
+ smpP = maybe (fail "bad GroupMemberRole") pure . textDecode =<< smpP
+
+-- Tuple encoding (Encoding (a,b,c,d), Encoding.hs:192), as GrpMsgForward / FwdSender do.
+instance Encoding RosterMember where
+ smpEncode RosterMember {memberId, key, role, privileges} = smpEncode (memberId, key, role, privileges)
+ smpP = RosterMember <$> smpP <*> smpP <*> smpP <*> smpP
+
+-- Blob = Word16 count (NOT smpEncodeList: its 1-byte count overflows at the 256 cap) followed
+-- by that many entries. This is the byte sequence the digest is computed over and verified
+-- against before parsing.
+encodeRosterBlob :: [RosterMember] -> ByteString
+encodeRosterBlob ms = smpEncode (fromIntegral (length ms) :: Word16) <> B.concat (map smpEncode ms)
+
+rosterBlobP :: Parser [RosterMember]
+rosterBlobP = do
+ n <- fromIntegral <$> smpP @Word16
+ when (n > maxGroupRosterSize) $ fail "roster: too many entries"
+ A.count n smpP
+```
+
+- **Owner**: `encodeRosterBlob` over the promoted set → `FileDigest` (SHA-512, as the file machinery computes it, `LC.sha512Hash`) → chunk; the digest goes in the signed `XGrpRoster` header.
+- **Receiver**: concatenate chunk bytes → verify the digest (S1, over plaintext) → `parseAll rosterBlobP` (consume all input; reject trailing bytes). Parsing runs only after the digest matches, so the bytes are owner-attested; the `n > maxGroupRosterSize` guard and `parseAll` are defensive against a buggy/garbled blob.
+- **Per-entry layout**: `memberId` (1-byte len + id) + `key` (1-byte len + Ed25519 pubkey) + role (1-byte len + role word, e.g. `member` = 7 B) + `privileges` (2 bytes) ≈ ~60 B/entry. The file-transferred blob has no tight size budget, so canonical text is fine.
+- `privileges` is reserved: serialized as `0`, parsed and ignored in v1.
+
+## Delivery: send → header → chunks → completion
+
+### Owner send
+
+`bumpAndBroadcastRoster` and `sendGroupRosterToRelay` build the blob (`buildGroupRoster` over the widened query), compute its `FileDigest`, send the `XGrpRoster` header, then send the blob as `BFileChunk`s against that message's `shared_msg_id`.
+
+`sendFileInline_` reads from a file, so add a send-from-bytes variant (shared with the relay re-serve). The owner's own version bump stays as today (in `bumpAndBroadcastRoster`, `Internal.hs:2176`) — the owner is the source of truth; "bump only at completion" is a receive-side rule.
+
+### Header handler (`xGrpRoster`, member and relay)
+
+The header no longer applies anything — it starts a transfer. It only writes `roster_pending_*`; it never writes `roster_version` or the live `roster_msg_*`.
+
+- **Short-circuit** unless `version > max(roster_version, roster_pending_version)` — strictly greater than both applied and pending — before creating a file. These are **`Maybe` comparisons: `Nothing` (un-materialized version) counts as below `0`** (mirror today's `maybe False (newVer <=) …`, `Subscriber.hs:3207`), so a relay's first receipt at NULL **applies** v0 while a re-receive at `Just 0` short-circuits — the v0 onboarding depends on this.
+ - Why both: the QCONT re-serve is unconditional, so the relay may re-forward a still-cached v5 while a member is mid-receiving v6 (applied 4). Compared only to applied, v5 > 4 would supersede v6, then the arriving v6 chunks fail the v5 digest → stuck.
+ - Why never bump here: a header-time bump makes the genuine blob complete as an equal-version no-op, leaving the receiver at `vN` with `v(N-1)` data.
+- **Create the rcv-file** with `cryptoArgs = Nothing` (see Security), `file_type = roster`, `chat_item_id` NULL, `shared_msg_id` = the header's id. Accept it via `startRcvInlineFT` (chat-item-free), not `acceptRcvInlineFT`, so chunk 1 isn't rejected on `RFSNew`.
+- **One in-flight per group is automatic**: the single `groups` row makes `roster_pending_*` single-valued, and there is one `(group_id, file_type = roster)` file. A duplicate header is idempotent. A version greater than both applied and pending supersedes: `UPDATE roster_pending_*` and delete the existing roster file (cleanup below), then create the new.
+
+### Chunks
+
+The header is enqueued before chunk 1 (per-connection FIFO).
+
+**Reset-on-chunk-1** (decision 4): if chunk 1 arrives with partial chunks, discard and restart so relay restart / re-subscribe / QCONT can re-drive from the start. Discarding MUST (GAP 3):
+
+- delete the `rcv_file_chunks` rows,
+- truncate/remove the on-disk file, and
+- evict its handle from the `rcvFiles` map (`closeFileHandle`).
+
+`appendFileChunk` opens in AppendMode and caches the handle (`Internal.hs:1781`, handle at `1794`), so clearing only the rows would append after the stale bytes and corrupt the blob (digest fails — the stuck state decision 4 avoids).
+
+**Orphaned chunk**: a roster `BFileChunk` matching no in-flight `(group_id, shared_msg_id, file_type = roster)` file is **ACKed and ignored**, never errored (the version is already applied or superseded). This is how an up-to-date member tolerates the unconditional re-serve: the re-served header short-circuits (no file), then its chunks arrive with no transfer in flight. Distinct from reset-on-chunk-1, which fires only when partial chunks exist.
+
+### Completion (on `RcvChunkFinal`)
+
+1. Verify the assembled file's digest against `roster_pending_digest`. On mismatch, discard (delete the file, clear `roster_pending_*`); do not apply or bump.
+2. **Version guard**: apply only if `roster_pending_version > roster_version` (same `Maybe` semantics — a `Nothing` applied version counts as below `0`, so a first v0 completion from NULL applies). A stale/out-of-order completion is rejected, not applied as a downgrade.
+3. Parse → `validateGroupRoster` → `processRoster` (TOFU keys, role updates, revert absent promoted members, role-change items; pass `nameFromMemberId` where it used the entry name).
+
+In **one transaction**: `processRoster` → set `roster_version = roster_pending_version` → set `roster_blob` → clear `roster_pending_*` → delete the file. A **relay** also promotes the pending signed-header columns into the live `roster_msg_*` (this is what `setGroupRoster` writes today at header time — it moves here) and applies to its own records, then **sends the roster ack and broadcasts** (below). So a joiner never sees a live header at `vN` paired with a blob at `vN-1`.
+
+**Relay ack at completion (PR #7036 integration).** The relay's `XGrpRosterAck` (previously sent in `relayApplyRoster` at header receipt) moves to completion, gated exactly as today on `relayOwnStatus gInfo == Just RSAccepted`: on a successful completion send `sendRosterAck author roster_pending_version Nothing`; on digest-mismatch or parse failure send `sendRosterAck author roster_pending_version (Just "...")` so the owner marks the relay `RSRejected` rather than leaving it `RSInvited` forever. A relay therefore becomes publishable only after the full blob arrives and applies — the desired fail-safe, but it makes owner→relay blob delivery part of the relay-onboarding path, so it MUST be reliably driven (see *Owner send* and *Relay re-serve*; for a freshly-connecting relay the owner drives it via `sendGroupRosterToRelay`, including the empty v0 blob). The `relayOwnStatus == Just RSAccepted` gate is ported unchanged but now evaluated at completion rather than header receipt — confirm `relayOwnStatus` cannot change across the header→completion window (it shouldn't: a relay can't reach `RSActive` before acking, since the ack is what publishes it).
+
+The version guard plus the per-version `shared_msg_id` keying are what make the design correct; the short-circuit and one-in-flight-per-group are optimizations.
+
+### Relay re-serve (broadcast / join / QCONT)
+
+Per recipient, forward the signed header (as `forwardGroupRoster` does today, `Internal.hs:1172`) AND re-send the blob as `BFileChunk`s from `groups.roster_blob` (the send-from-bytes variant). An incoming `BFileChunk` returns no delivery task (`Subscriber.hs:1089`), so the blob send is driven here.
+
+**No per-member version gate in v1 (GAP 2).** QCONT/SENT re-forwards the saved roster unconditionally today (`Subscriber.hs:1143`, `1237`), and no per-member delivered-version tracker exists in the tree — this plan adds none. So a re-serve re-sends the whole blob on every drain; at cap 256 that is ~15 KB — one (occasionally two) `BFileChunk`s per drain — acceptable.
+
+It is safe because: an up-to-date member short-circuits the header and ACK-ignores the orphaned chunks; a stale (≤ pending) re-forward mid-transfer is a no-op via the short-circuit; and the completion version guard rejects any stale completion.
+
+If the cap is later raised so the blob spans many chunks, add a per-member `delivered_roster_version` column (read on QCONT/join/broadcast, written on confirmed delivery) and re-serve only when behind — future work.
+
+### Supersede / cancel cleanup
+
+Cleanup spans ALL of these — miss none:
+
+- `files`, `rcv_files`, `rcv_file_chunks`,
+- the on-disk file and its `rcvFiles` handle (`closeFileHandle`),
+- the `roster_pending_*` columns on `groups` (set NULL).
+
+## File-machinery changes (only these)
+
+- **Lookup**: add `files.shared_msg_id`; resolve roster chunks by `(group_id, shared_msg_id, file_type = roster)`. Leave `getGroupFileIdBySharedMsgId` (`Store/Files.hs:310`, chat-item JOIN) for normal files; branch on `file_type` / `chat_item_id IS NULL`.
+- **Fork the three receive sites that call `getChatItemByFileId`** (they throw with no chat item):
+ - `startReceivingFile` (`Internal.hs:827`, reached on chunk 1) — skip the chat item + `CEvtRcvFileStart`.
+ - `receiveFileChunk` `RcvChunkFinal` (`Subscriber.hs:1329`) — replace with the completion path above.
+ - `FileChunkCancel` (`Subscriber.hs:1313`) — delete file + drop in-flight state, no chat item.
+- **Cleanup keyed on `group_id`** (not chat items): `getGroupFileInfo` INNER-JOINs `chat_items`, so group delete (`Commands.hs:1270`) and clear (`1305`) skip roster files; the DB row cascades on group delete but the on-disk file leaks. Add a roster-file cleanup for delete/clear, cancel, and supersede.
+
+## Storage / migration
+
+In-flight state lives on `groups` (mirroring the live saved roster) and `files` (located by `shared_msg_id`) — no join table. These columns go into the in-progress **`M20260602_group_roster`** migration (already part of this work, not yet merged — so it's editable, not an applied migration), SQLite + Postgres; tests regenerate the schema files.
+
+| `groups` column(s) | Holds | Lifecycle |
+|---|---|---|
+| `roster_version` *(kept)* | applied version | bumped at completion |
+| `roster_msg_*` *(kept)* | live signed header (was full JSON) | relay forwards verbatim; promoted from pending at completion |
+| `roster_blob` *(new)* | durable completed blob | written at completion; relay re-serves it |
+| `roster_pending_version`, `roster_pending_digest` *(new)* | in-flight version + digest | set on header receipt; cleared at completion |
+| `roster_pending_msg_*` *(new, relay-only)* | in-flight signed header | set on header receipt; promoted to live at completion (NULL on members) |
+
+The kept `roster_msg_*` columns stay the relay's verbatim-forward source and trust anchor: `forwardGroupRoster` re-forwards them so the joiner verifies the owner signature, and the digest inside authenticates the unsigned blob.
+
+`files` adds `shared_msg_id` and `file_type`. The in-flight transfer is the `files` / `rcv_files` / `rcv_file_chunks` rows with `(group_id, file_type = roster)`.
+
+## Security
+
+- **Owner-signed header**: assert `memberRole' author == GROwner` (`Subscriber.hs:3198`); keep `XGrpRoster_` in `requiresSignature`.
+- **Integrity is entirely the digest** (S1): verify the assembled **plaintext** blob against the owner-signed `fileDigest` at completion. Hence `cryptoArgs = Nothing` — a set cryptoArgs makes `appendFileChunk` re-encrypt the file in place (`Internal.hs:1801`), so the on-disk bytes would be ciphertext and the check would fail. A corrupted chunk fails the digest and the roster is rejected.
+- **TOFU** key pinning per `memberId` unchanged (different key for a known id → keep the trusted key).
+- **Rollback (S2)**: the signature binds `publicGroupId + version` and the digest binds the blob to that header, so cross-group/version substitution stays blocked. But the blob now carries plain members, so a same-group replay of an old `(header, blob)` to a **new joiner** can re-introduce a removed poster or mask a demotion (existing members are protected by the version check). Update `channels-overview.md`.
+
+## Known limitations / out of scope
+
+- A malicious relay can withhold/corrupt chunks → the member stays on its last-applied roster (it can drop any message anyway); new-joiner rollback now covers plain members.
+- A just-promoted member's first posts may show "unknown member" until the file arrives — self-healing.
+- A member who **leaves** lingers in the roster blob until the next bump (this plan drops the leave-triggered refresh). Harmless: they have no relay connection and cannot post, so a new joiner sees only a ghost row; the owner's explicit remove (`APIRemoveMembers`) drops them.
+- Out of scope: granting/enforcing `privileges`; member content signing; joiner-role-on-profile; clients. Do not couple the roster set to the joiner-role mechanism (decision 2) — it is the absolute `{member, mod, admin}`.
+
+## Tests (`tests/ChatTests/Groups.hs`)
+
+The roster tests now live under the `describe "promoted members roster"` block (PR #7036 moved them and added `testChannelAddRelayWithRoster`, which onboards a 2nd relay through the roster-ack handshake). Update those to header+file delivery — `testChannelAddRelayWithRoster` in particular now exercises the v0/empty-roster blob transfer feeding the relay ack, so it must drive the header+chunk(s) to completion before the relay acks.
+
+Then add: digest-mismatch blob rejected (no apply, no version bump) **and the relay acks-with-error → owner marks it `RSRejected`** (PR #7036 path); a relay does **not** ack / become publishable until the blob completes (ack moved to completion); member promotion enters the broadcast roster and can post; reset-on-chunk-1 recovery; superseding version cleans up the in-flight older file; version not bumped on header receipt or on a failed blob; `introduceInChannel` still mod+admin only (no member introductions); on-disk roster file cleaned on group delete/clear mid-transfer; non-owner promotion refused; a promoted member re-connecting through a relay is accepted only with a valid signed `XMember` over the roster-pinned key (the widened `memberJoinRequestViaRelay` gate). Existing mod/admin tests must still pass.
diff --git a/simplex-chat.cabal b/simplex-chat.cabal
index 22b97c83ee..f1effc2e6a 100644
--- a/simplex-chat.cabal
+++ b/simplex-chat.cabal
@@ -143,6 +143,7 @@ library
Simplex.Chat.Store.Postgres.Migrations.M20260530_client_services
Simplex.Chat.Store.Postgres.Migrations.M20260531_member_removed_at
Simplex.Chat.Store.Postgres.Migrations.M20260601_relay_sent_web_domain
+ Simplex.Chat.Store.Postgres.Migrations.M20260602_group_roster
else
exposed-modules:
Simplex.Chat.Archive
@@ -304,6 +305,7 @@ library
Simplex.Chat.Store.SQLite.Migrations.M20260530_client_services
Simplex.Chat.Store.SQLite.Migrations.M20260531_member_removed_at
Simplex.Chat.Store.SQLite.Migrations.M20260601_relay_sent_web_domain
+ Simplex.Chat.Store.SQLite.Migrations.M20260602_group_roster
other-modules:
Paths_simplex_chat
hs-source-dirs:
diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs
index ca4f3e8fcf..004d790844 100644
--- a/src/Simplex/Chat/Library/Commands.hs
+++ b/src/Simplex/Chat/Library/Commands.hs
@@ -1289,6 +1289,8 @@ processChatCommand cxt nm = \case
filesInfo <- withFastStore' $ \db -> getGroupFileInfo db user gInfo
withGroupLock "deleteChat group" chatId $ do
deleteCIFiles user filesInfo
+ -- the roster blob file has no chat item, so it is missed by getGroupFileInfo above
+ cleanupGroupRosterFile user gInfo
(members, recipients) <- getRecipients gInfo
let doSendDel = memberActive membership && isOwner
msgSigned <-
@@ -2050,9 +2052,9 @@ processChatCommand cxt nm = \case
gVar <- asks random
(gInfo, hostMember_) <- withStore $ \db -> createPreparedGroup db gVar cxt user groupProfile True ccLink welcomeSharedMsgId False GRMember Nothing
hostMember <- maybe (throwCmdError "no host member") pure hostMember_
- void $ createChatItem user (CDGroupSnd gInfo Nothing) False CIChatBanner Nothing (Just epochStart)
+ void $ createChatItem user (CDGroupSnd gInfo Nothing) False CIChatBanner Nothing Nothing (Just epochStart)
let cd = CDGroupRcv gInfo Nothing hostMember
- createItem sharedMsgId content = createChatItem user cd True content sharedMsgId Nothing
+ createItem sharedMsgId content = createChatItem user cd True content sharedMsgId Nothing Nothing
cInfo = GroupChat gInfo Nothing
void $ createGroupFeatureItems_ user cd True CIRcvGroupFeature gInfo
aci <- mapM (createItem welcomeSharedMsgId . CIRcvMsgContent) message
@@ -2062,9 +2064,9 @@ processChatCommand cxt nm = \case
pure $ CRNewPreparedChat user $ AChat SCTGroup chat
ACCL _ (CCLink cReq _) -> do
ct <- withStore $ \db -> createPreparedContact db cxt user profile accLink welcomeSharedMsgId
- void $ createChatItem user (CDDirectSnd ct) False CIChatBanner Nothing (Just epochStart)
+ void $ createChatItem user (CDDirectSnd ct) False CIChatBanner Nothing Nothing (Just epochStart)
let cd = CDDirectRcv ct
- createItem sharedMsgId content = createChatItem user cd False content sharedMsgId Nothing
+ createItem sharedMsgId content = createChatItem user cd False content sharedMsgId Nothing Nothing
cInfo = DirectChat ct
void $ createItem Nothing $ CIRcvDirectE2EEInfo $ e2eInfoEncrypted $ connRequestPQEncryption cReq
void $ createFeatureEnabledItems_ user ct
@@ -2081,11 +2083,11 @@ processChatCommand cxt nm = \case
subRole <- if useRelays then asks $ channelSubscriberRole . config else pure GRMember
gVar <- asks random
(gInfo, hostMember_) <- withStore $ \db -> createPreparedGroup db gVar cxt user gp False ccLink welcomeSharedMsgId useRelays subRole publicMemberCount_
- void $ createChatItem user (CDGroupSnd gInfo Nothing) False CIChatBanner Nothing (Just epochStart)
+ void $ createChatItem user (CDGroupSnd gInfo Nothing) False CIChatBanner Nothing Nothing (Just epochStart)
let cd = maybe (CDChannelRcv gInfo Nothing) (CDGroupRcv gInfo Nothing) hostMember_
cInfo = GroupChat gInfo Nothing
void $ createGroupFeatureItems_ user cd True CIRcvGroupFeature gInfo
- aci <- forM description $ \descr -> createChatItem user cd True (CIRcvMsgContent $ MCText descr) welcomeSharedMsgId Nothing
+ aci <- forM description $ \descr -> createChatItem user cd True (CIRcvMsgContent $ MCText descr) welcomeSharedMsgId Nothing Nothing
let chat = case aci of
Just (AChatItem SCTGroup dir _ ci) -> Chat cInfo [CChatItem dir ci] emptyChatStats {unreadCount = 1, minUnreadItemId = chatItemId' ci}
_ -> Chat cInfo [] emptyChatStats
@@ -2153,7 +2155,7 @@ processChatCommand cxt nm = \case
-- create changed feature items (connecting incognito sends default preferences, instead of user preferences)
lift . when incognito $ createContactChangedFeatureItems user ct ct'
forM_ msg_ $ \(sharedMsgId, mc) -> do
- ci <- createChatItem user (CDDirectSnd ct') False (CISndMsgContent mc) (Just sharedMsgId) Nothing
+ ci <- createChatItem user (CDDirectSnd ct') False (CISndMsgContent mc) (Just sharedMsgId) Nothing Nothing
toView $ CEvtNewChatItems user [ci]
pure $ CRStartedConnectionToContact user ct' customUserProfile
CVRConnectedContact ct' -> pure $ CRContactAlreadyExists user ct'
@@ -2246,7 +2248,7 @@ processChatCommand cxt nm = \case
liftIO $ setPreparedGroupStartedConnection db groupId
getGroupInfo db cxt user groupId
forM_ msg_ $ \(sharedMsgId, mc) -> do
- ci <- createChatItem user (CDGroupSnd gInfo' Nothing) False (CISndMsgContent mc) (Just sharedMsgId) Nothing
+ ci <- createChatItem user (CDGroupSnd gInfo' Nothing) False (CISndMsgContent mc) (Just sharedMsgId) Nothing Nothing
toView $ CEvtNewChatItems user [ci]
pure $ CRStartedConnectionToGroup user gInfo' customUserProfile []
CVRConnectedContact _ct -> throwChatError $ CEException "contact already exists when connecting to group"
@@ -2756,34 +2758,45 @@ processChatCommand cxt nm = \case
-- TODO [relays] possible optimization is to read only required members + relays
g@(Group gInfo members) <- withFastStore $ \db -> getGroup db cxt user groupId
when (selfSelected gInfo) $ throwCmdError "can't change role for self"
- let (invitedMems, currentMems, unchangedMems, maxRole, anyAdmin, anyPending) = selectMembers members
+ let (invitedMems, currentMems, unchangedMems, maxRole, anyAdmin, anyPending, anyPrivilegedTarget, finalPrivilegedCount) = selectMembers members
when (length invitedMems + length currentMems + length unchangedMems /= length memberIds) $ throwChatError CEGroupMemberNotFound
when (length memberIds > 1 && (anyAdmin || newRole >= GRAdmin)) $
throwCmdError "can't change role of multiple members when admins selected, or new role is admin"
when anyPending $ throwCmdError "can't change role of members pending approval"
assertUserGroupRole gInfo $ maximum ([GRAdmin, maxRole, newRole] :: [GroupMemberRole])
+ -- in relay groups the roster has a single signer, so only the owner may change moderator/admin roles
+ when (useRelays' gInfo && (isRosterRole newRole || anyPrivilegedTarget) && memberRole' (membership gInfo) /= GROwner) $
+ throwCmdError "only the group owner can change moderator and admin roles"
+ when (useRelays' gInfo && isRosterRole newRole && finalPrivilegedCount > maxGroupRosterSize) $
+ throwCmdError $ "the number of members, moderators and admins would exceed the limit of " <> show maxGroupRosterSize
(errs1, changed1) <- changeRoleInvitedMems user gInfo invitedMems
- (errs2, changed2, acis, msgSigned) <- changeRoleCurrentMems user g currentMems
+ let doBumpRoster = useRelays' gInfo && memberRole' (membership gInfo) == GROwner && (isRosterRole newRole || anyPrivilegedTarget)
+ rosterVer <- if doBumpRoster then Just <$> reserveRosterVersion gInfo else pure Nothing
+ (errs2, changed2, acis, msgSigned) <- changeRoleCurrentMems user g rosterVer currentMems
+ forM_ rosterVer $ \v -> broadcastRoster user gInfo v `catchAllErrors` eToView
unless (null acis) $ toView $ CEvtNewChatItems user acis
let errs = errs1 <> errs2
unless (null errs) $ toView $ CEvtChatErrors errs
pure $ CRMembersRoleUser {user, groupInfo = gInfo, members = changed1 <> changed2, toRole = newRole, msgSigned} -- same order is not guaranteed
where
selfSelected GroupInfo {membership} = elem (groupMemberId' membership) memberIds
- selectMembers :: [GroupMember] -> ([GroupMember], [GroupMember], [GroupMember], GroupMemberRole, Bool, Bool)
- selectMembers = foldr' addMember ([], [], [], GRObserver, False, False)
+ -- anyPrivilegedTarget: a target currently moderator/admin; finalPrivilegedCount:
+ -- moderators + admins after the change (targets take newRole, others keep their role).
+ selectMembers :: [GroupMember] -> ([GroupMember], [GroupMember], [GroupMember], GroupMemberRole, Bool, Bool, Bool, Int)
+ selectMembers = foldr' addMember ([], [], [], GRObserver, False, False, False, 0)
where
- addMember m@GroupMember {groupMemberId, memberStatus, memberRole} (invited, current, unchanged, maxRole, anyAdmin, anyPending)
+ addMember m@GroupMember {groupMemberId, memberStatus, memberRole} (invited, current, unchanged, maxRole, anyAdmin, anyPending, anyPrivTarget, privCount)
| groupMemberId `elem` memberIds =
let maxRole' = max maxRole memberRole
anyAdmin' = anyAdmin || memberRole >= GRAdmin
anyPending' = anyPending || memberPending m
- in
- if
- | memberRole == newRole -> (invited, current, m : unchanged, maxRole', anyAdmin', anyPending')
- | memberStatus == GSMemInvited -> (m : invited, current, unchanged, maxRole', anyAdmin', anyPending')
- | otherwise -> (invited, m : current, unchanged, maxRole', anyAdmin', anyPending')
- | otherwise = (invited, current, unchanged, maxRole, anyAdmin, anyPending)
+ anyPrivTarget' = anyPrivTarget || isRosterRole memberRole
+ privCount' = if isRosterRole newRole then privCount + 1 else privCount
+ in if
+ | memberRole == newRole -> (invited, current, m : unchanged, maxRole', anyAdmin', anyPending', anyPrivTarget', privCount')
+ | memberStatus == GSMemInvited -> (m : invited, current, unchanged, maxRole', anyAdmin', anyPending', anyPrivTarget', privCount')
+ | otherwise -> (invited, m : current, unchanged, maxRole', anyAdmin', anyPending', anyPrivTarget', privCount')
+ | otherwise = (invited, current, unchanged, maxRole, anyAdmin, anyPending, anyPrivTarget, if isRosterRole memberRole then privCount + 1 else privCount)
changeRoleInvitedMems :: User -> GroupInfo -> [GroupMember] -> CM ([ChatError], [GroupMember])
changeRoleInvitedMems user gInfo memsToChange = do
-- not batched, as we need to send different invitations to different connections anyway
@@ -2798,19 +2811,20 @@ processChatCommand cxt nm = \case
withFastStore' $ \db -> updateGroupMemberRole db user m newRole
pure (m :: GroupMember) {memberRole = newRole}
_ -> throwChatError $ CEGroupCantResendInvitation gInfo cName
- changeRoleCurrentMems :: User -> Group -> [GroupMember] -> CM ([ChatError], [GroupMember], [AChatItem], Bool)
- changeRoleCurrentMems user (Group gInfo members) memsToChange = case L.nonEmpty memsToChange of
+ changeRoleCurrentMems :: User -> Group -> Maybe VersionRoster -> [GroupMember] -> CM ([ChatError], [GroupMember], [AChatItem], Bool)
+ changeRoleCurrentMems user (Group gInfo members) rosterVer memsToChange = case L.nonEmpty memsToChange of
Nothing -> pure ([], [], [], False)
Just memsToChange' -> do
- let events = L.map (\GroupMember {memberId} -> XGrpMemRole memberId newRole) memsToChange'
+ let mKey m = if isJust rosterVer then MemberKey <$> memberPubKey m else Nothing
+ events = L.map (\m@GroupMember {memberId} -> XGrpMemRole memberId newRole (mKey m) rosterVer) memsToChange'
recipients = filter memberCurrent members
(msgs_, _gsr) <- sendGroupMessages user gInfo Nothing False recipients events
let signed = any (either (const False) (isJust . signedMsg_)) msgs_
itemsData = zipWith (fmap . sndItemData) memsToChange (L.toList msgs_)
cis_ <- saveSndChatItems user (CDGroupSnd gInfo Nothing) False itemsData Nothing False
when (length cis_ /= length memsToChange) $ logError "changeRoleCurrentMems: memsToChange and cis_ length mismatch"
- (errs, changed) <- lift $ partitionEithers <$> withStoreBatch' (\db -> map (updMember db) memsToChange)
let acis = map (AChatItem SCTGroup SMDSnd (GroupChat gInfo Nothing)) $ rights cis_
+ (errs, changed) <- lift $ partitionEithers <$> withStoreBatch' (\db -> map (updMember db) memsToChange)
pure (errs, changed, acis, signed)
where
sndItemData :: GroupMember -> SndMessage -> NewSndChatItemData c
@@ -2874,20 +2888,25 @@ processChatCommand cxt nm = \case
withGroupLock "removeMembers" groupId $ do
-- TODO [relays] possible optimization is to read only required members + relays
Group gInfo members <- withFastStore $ \db -> getGroup db cxt user groupId
- let (count, invitedMems, pendingApprvMems, pendingRvwMems, currentMems, maxRole, anyAdmin) = selectMembers gmIds members
+ let (count, invitedMems, pendingApprvMems, pendingRvwMems, currentMems, maxRole, anyAdmin, anyPrivilegedRemoved) = selectMembers gmIds members
gmIds = S.fromList $ L.toList groupMemberIds
memCount = length groupMemberIds
when (count /= memCount) $ throwChatError CEGroupMemberNotFound
when (memCount > 1 && anyAdmin) $ throwCmdError "can't remove multiple members when admins selected"
assertUserGroupRole gInfo $ max GRAdmin maxRole
+ when (useRelays' gInfo && anyPrivilegedRemoved && memberRole' (membership gInfo) /= GROwner) $
+ throwCmdError "only the group owner can remove members, moderators and admins"
(errs1, deleted1) <- deleteInvitedMems user invitedMems
let recipients = filter memberCurrent members
- (errs2, deleted2, acis2, signed2) <- deleteMemsSend user gInfo Nothing recipients currentMems
+ let doBumpRoster = useRelays' gInfo && memberRole' (membership gInfo) == GROwner && anyPrivilegedRemoved
+ rosterVer <- if doBumpRoster then Just <$> reserveRosterVersion gInfo else pure Nothing
+ (errs2, deleted2, acis2, signed2) <- deleteMemsSend user gInfo Nothing rosterVer recipients currentMems
(errs3, deleted3, acis3, signed3) <-
foldM (\acc m -> deletePendingMember acc user gInfo [m] m) ([], [], [], False) pendingApprvMems
let moderators = filter (\GroupMember {memberRole} -> memberRole >= GRModerator) members
(errs4, deleted4, acis4, signed4) <-
foldM (\acc m -> deletePendingMember acc user gInfo (m : moderators) m) ([], [], [], False) pendingRvwMems
+ forM_ rosterVer $ \v -> broadcastRoster user gInfo v `catchAllErrors` eToView
let acis = acis2 <> acis3 <> acis4
errs = errs1 <> errs2 <> errs3 <> errs4
deleted = deleted1 <> deleted2 <> deleted3 <> deleted4
@@ -2902,19 +2921,20 @@ processChatCommand cxt nm = \case
unless (null errs) $ toView $ CEvtChatErrors errs
pure $ CRUserDeletedMembers user gInfo' deleted withMessages msgSigned -- same order is not guaranteed
where
- selectMembers :: S.Set GroupMemberId -> [GroupMember] -> (Int, [GroupMember], [GroupMember], [GroupMember], [GroupMember], GroupMemberRole, Bool)
- selectMembers gmIds = foldl' addMember (0, [], [], [], [], GRObserver, False)
+ selectMembers :: S.Set GroupMemberId -> [GroupMember] -> (Int, [GroupMember], [GroupMember], [GroupMember], [GroupMember], GroupMemberRole, Bool, Bool)
+ selectMembers gmIds = foldl' addMember (0, [], [], [], [], GRObserver, False, False)
where
- addMember acc@(n, invited, pendingApprv, pendingRvw, current, maxRole, anyAdmin) m@GroupMember {groupMemberId, memberStatus, memberRole}
+ addMember acc@(n, invited, pendingApprv, pendingRvw, current, maxRole, anyAdmin, anyPrivRemoved) m@GroupMember {groupMemberId, memberStatus, memberRole}
| groupMemberId `S.member` gmIds =
let maxRole' = max maxRole memberRole
anyAdmin' = anyAdmin || memberRole >= GRAdmin
+ anyPrivRemoved' = anyPrivRemoved || isRosterRole memberRole
n' = n + 1
in case memberStatus of
- GSMemInvited -> (n', m : invited, pendingApprv, pendingRvw, current, maxRole', anyAdmin')
- GSMemPendingApproval -> (n', invited, m : pendingApprv, pendingRvw, current, maxRole', anyAdmin')
- GSMemPendingReview -> (n', invited, pendingApprv, m : pendingRvw, current, maxRole', anyAdmin')
- _ -> (n', invited, pendingApprv, pendingRvw, m : current, maxRole', anyAdmin')
+ GSMemInvited -> (n', m : invited, pendingApprv, pendingRvw, current, maxRole', anyAdmin', anyPrivRemoved')
+ GSMemPendingApproval -> (n', invited, m : pendingApprv, pendingRvw, current, maxRole', anyAdmin', anyPrivRemoved')
+ GSMemPendingReview -> (n', invited, pendingApprv, m : pendingRvw, current, maxRole', anyAdmin', anyPrivRemoved')
+ _ -> (n', invited, pendingApprv, pendingRvw, m : current, maxRole', anyAdmin', anyPrivRemoved')
| otherwise = acc
deleteInvitedMems :: User -> [GroupMember] -> CM ([ChatError], [GroupMember])
deleteInvitedMems user memsToDelete = do
@@ -2927,14 +2947,14 @@ processChatCommand cxt nm = \case
deletePendingMember :: ([ChatError], [GroupMember], [AChatItem], Bool) -> User -> GroupInfo -> [GroupMember] -> GroupMember -> CM ([ChatError], [GroupMember], [AChatItem], Bool)
deletePendingMember (accErrs, accDeleted, accACIs, accSigned) user gInfo recipients m = do
(m', scopeInfo) <- mkMemberSupportChatInfo m
- (errs, deleted, acis, signed) <- deleteMemsSend user gInfo (Just scopeInfo) recipients [m']
+ (errs, deleted, acis, signed) <- deleteMemsSend user gInfo (Just scopeInfo) Nothing recipients [m']
pure (errs <> accErrs, deleted <> accDeleted, acis <> accACIs, accSigned || signed)
- deleteMemsSend :: User -> GroupInfo -> Maybe GroupChatScopeInfo -> [GroupMember] -> [GroupMember] -> CM ([ChatError], [GroupMember], [AChatItem], Bool)
- deleteMemsSend user gInfo chatScopeInfo recipients memsToDelete = case L.nonEmpty memsToDelete of
+ deleteMemsSend :: User -> GroupInfo -> Maybe GroupChatScopeInfo -> Maybe VersionRoster -> [GroupMember] -> [GroupMember] -> CM ([ChatError], [GroupMember], [AChatItem], Bool)
+ deleteMemsSend user gInfo chatScopeInfo rosterVer recipients memsToDelete = case L.nonEmpty memsToDelete of
Nothing -> pure ([], [], [], False)
Just memsToDelete' -> do
let chatScope = toChatScope <$> chatScopeInfo
- events = L.map (\GroupMember {memberId} -> XGrpMemDel memberId withMessages) memsToDelete'
+ events = L.map (\GroupMember {memberId} -> XGrpMemDel memberId withMessages rosterVer) memsToDelete'
(msgs_, _gsr) <- sendGroupMessages user gInfo chatScope False recipients events
let signed = any (either (const False) (isJust . signedMsg_)) msgs_
itemsData_ = zipWith (fmap . sndItemData) memsToDelete (L.toList msgs_)
@@ -3134,7 +3154,7 @@ processChatCommand cxt nm = \case
(connId, CCLink cReq _) <- withAgent $ \a -> createConnection a nm (aUserId user) True False SCMInvitation Nothing Nothing IKPQOff subMode
-- [incognito] reuse membership incognito profile
ct <- withFastStore' $ \db -> createMemberContact db user connId cReq g m mConn subMode
- void $ createChatItem user (CDDirectSnd ct) False CIChatBanner Nothing (Just epochStart)
+ void $ createChatItem user (CDDirectSnd ct) False CIChatBanner Nothing Nothing (Just epochStart)
-- TODO not sure it is correct to set connections status here?
pure $ CRNewMemberContact user ct g m
_ -> throwChatError CEGroupMemberNotActive
@@ -3613,13 +3633,18 @@ processChatCommand cxt nm = \case
where
cReqHash1 = contactCReqHash $ CRContactUri crData {crScheme = SSSimplex}
cReqHash2 = contactCReqHash $ CRContactUri crData {crScheme = simplexChat}
+ -- relay-group joins (only via connectToRelay) carry the target relay member in preparedEntity_;
+ -- its memberId binds the join signature so a sibling relay can't replay it
+ relayMemberId_ = case preparedEntity_ of
+ Just (PCEGroup gInfo m) | useRelays' gInfo -> Just (memberId' m)
+ _ -> Nothing
joinPreparedConn' xContactId_ conn@Connection {customUserProfileId} gInfo_ = do
when (incognito /= isJust customUserProfileId) $ throwCmdError "incognito mode is different from prepared connection"
-- TODO [relays] member: refactor joinContact and up avoiding parallel ifs, xContactId is not used
xContactId <- mkXContactId xContactId_
localIncognitoProfile <- forM customUserProfileId $ \pId -> withFastStore $ \db -> getProfileById db userId pId
let incognitoProfile = fromLocalProfile <$> localIncognitoProfile
- conn' <- joinContact user conn cReq incognitoProfile xContactId welcomeSharedMsgId msg_ gInfo_ PQSupportOn
+ conn' <- joinContact user conn cReq incognitoProfile xContactId welcomeSharedMsgId msg_ gInfo_ relayMemberId_ PQSupportOn
pure $ CVRSentInvitation conn' incognitoProfile
connect' groupLinkId xContactId_ gInfo_ = do
let inGroup = isJust groupLinkId
@@ -3634,7 +3659,7 @@ processChatCommand cxt nm = \case
subMode <- chatReadVar subscriptionMode
let sLnk' = serverShortLink <$> sLnk
conn <- withFastStore' $ \db -> createConnReqConnection db userId connId preparedEntity_ cReq cReqHash1 sLnk' xContactId incognitoProfile_ groupLinkId subMode chatV pqSup
- conn' <- joinContact user conn cReq incognitoProfile xContactId welcomeSharedMsgId msg_ gInfo_ pqSup
+ conn' <- joinContact user conn cReq incognitoProfile xContactId welcomeSharedMsgId msg_ gInfo_ relayMemberId_ pqSup
pure $ CVRSentInvitation conn' incognitoProfile
connectContactViaAddress :: User -> IncognitoEnabled -> Contact -> CreatedLinkContact -> CM ChatResponse
connectContactViaAddress user@User {userId} incognito ct@Contact {contactId, activeConn} (CCLink cReq shortLink) =
@@ -3649,7 +3674,7 @@ processChatCommand cxt nm = \case
subMode <- chatReadVar subscriptionMode
let cReqHash = ConnReqUriHash . C.sha256Hash $ strEncode cReq
conn <- withFastStore' $ \db -> createConnReqConnection db userId connId (Just $ PCEContact ct) cReq cReqHash shortLink newXContactId (NewIncognito <$> incognitoProfile) Nothing subMode chatV pqSup
- void $ joinContact user conn cReq incognitoProfile newXContactId Nothing Nothing Nothing pqSup
+ void $ joinContact user conn cReq incognitoProfile newXContactId Nothing Nothing Nothing Nothing pqSup
ct' <- withStore $ \db -> getContact db cxt user contactId
pure $ CRSentInvitationToContact user ct' incognitoProfile
Just conn@Connection {connStatus, xContactId = xContactId_, customUserProfileId} -> case connStatus of
@@ -3658,7 +3683,7 @@ processChatCommand cxt nm = \case
xContactId <- mkXContactId xContactId_
localIncognitoProfile <- forM customUserProfileId $ \pId -> withFastStore $ \db -> getProfileById db userId pId
let incognitoProfile = fromLocalProfile <$> localIncognitoProfile
- void $ joinContact user conn cReq incognitoProfile xContactId Nothing Nothing Nothing PQSupportOn
+ void $ joinContact user conn cReq incognitoProfile xContactId Nothing Nothing Nothing Nothing PQSupportOn
ct' <- withStore $ \db -> getContact db cxt user contactId
pure $ CRSentInvitationToContact user ct' incognitoProfile
_ -> throwCmdError "contact already has connection"
@@ -3670,13 +3695,14 @@ processChatCommand cxt nm = \case
r <- tryAllErrors $ do
(fd@FixedLinkData {rootKey = relayKey, linkEntityId}, cData) <- getShortLinkConnReq nm user relayLink
relayLinkData_ <- liftIO $ decodeLinkUserData cData
- case (relayLinkData_, linkEntityId) of
- (Just RelayShortLinkData {relayProfile = p}, Just entityId) ->
+ relayMemberId <- case (relayLinkData_, linkEntityId) of
+ (Just RelayShortLinkData {relayProfile = p}, Just entityId) -> do
withFastStore $ \db -> updateRelayMemberData db cxt user relayMember (MemberId entityId) (MemberKey relayKey) p
+ pure $ MemberId entityId
_ -> throwChatError $ CEException "relay link: no relay link data or entity id"
let cReq = linkConnReq fd
relayLinkToConnect = CCLink cReq (Just relayLink)
- void $ connectViaContact user (Just $ PCEGroup gInfo relayMember) (incognitoMembership gInfo) relayLinkToConnect Nothing Nothing
+ void $ connectViaContact user (Just $ PCEGroup gInfo (relayMember {memberId = relayMemberId})) (incognitoMembership gInfo) relayLinkToConnect Nothing Nothing
relayMember' <- withFastStore $ \db -> getGroupMember db cxt user (groupId' gInfo) (groupMemberId' relayMember)
pure (relayLink, relayMember', r)
syncSubscriberRelays :: User -> GroupInfo -> [ShortLinkContact] -> CM ()
@@ -3712,8 +3738,8 @@ processChatCommand cxt nm = \case
pure (connId, chatV)
mkXContactId :: Maybe XContactId -> CM XContactId
mkXContactId = maybe (XContactId <$> drgRandomBytes 16) pure
- joinContact :: User -> Connection -> ConnReqContact -> Maybe Profile -> XContactId -> Maybe SharedMsgId -> Maybe (SharedMsgId, MsgContent) -> Maybe (Maybe GroupInfo) -> PQSupport -> CM Connection
- joinContact user conn@Connection {connChatVersion = chatV} cReq incognitoProfile xContactId welcomeSharedMsgId msg_ gInfo_ pqSup = do
+ joinContact :: User -> Connection -> ConnReqContact -> Maybe Profile -> XContactId -> Maybe SharedMsgId -> Maybe (SharedMsgId, MsgContent) -> Maybe (Maybe GroupInfo) -> Maybe MemberId -> PQSupport -> CM Connection
+ joinContact user conn@Connection {connChatVersion = chatV} cReq incognitoProfile xContactId welcomeSharedMsgId msg_ gInfo_ relayMemberId_ pqSup = do
-- gInfo_ is Maybe (Maybe GroupInfo), where Just Nothing means "some unknown group", e.g. when joining via link without profile
profileToSend <-
presentUserBadge user incognitoProfile $ case gInfo_ of
@@ -3721,15 +3747,11 @@ processChatCommand cxt nm = \case
let allowSimplexLinks = maybe True groupUserAllowSimplexLinks gInfo_'
in userProfileInGroup' user allowSimplexLinks incognitoProfile
Nothing -> userProfileDirect user incognitoProfile Nothing True
- chatEvent <- case gInfo_ of
- Just (Just gInfo) | useRelays' gInfo -> do
- let GroupInfo {membership = GroupMember {memberId}} = gInfo
- memberPubKey <- case groupKeys gInfo of
- Just GroupKeys {memberPrivKey} -> pure $ C.publicKey memberPrivKey
- Nothing -> throwChatError $ CEInternalError "no group keys for channel membership"
- pure $ XMember profileToSend memberId (MemberKey memberPubKey)
- _ -> pure $ XContact profileToSend (Just xContactId) welcomeSharedMsgId msg_
- dm <- encodeConnInfoPQ pqSup chatV chatEvent
+ dm <- case gInfo_ of
+ Just (Just gInfo) | useRelays' gInfo -> case relayMemberId_ of
+ Just relayMemberId -> encodeXMemberConnInfo gInfo relayMemberId profileToSend
+ Nothing -> throwChatError $ CEInternalError "relay group join without target relay memberId"
+ _ -> encodeConnInfoPQ pqSup chatV $ XContact profileToSend (Just xContactId) welcomeSharedMsgId msg_
subMode <- chatReadVar subscriptionMode
void $ withAgent $ \a -> joinConnection a nm (aUserId user) (aConnId conn) True cReq dm pqSup subMode
withFastStore' $ \db -> updateConnectionStatusFromTo db conn ConnPrepared ConnJoined
@@ -4914,7 +4936,7 @@ runRelayGroupLinkChecks user = do
then do
-- TODO [relays] emit event to UI when relay own status promoted to RSActive
-- CEvtGroupRelayUpdated requires GroupRelay (owner-side), not available on relay side
- void $ withStore' $ \db -> updateRelayOwnStatusFromTo db gInfo RSAccepted RSActive
+ void $ withStore' $ \db -> updateRelayOwnStatus_ db gInfo RSActive
else void $ withStore' $ \db -> updateRelayOwnStatusFromTo db gInfo RSActive RSInactive
_ -> pure ()
_ -> pure ()
diff --git a/src/Simplex/Chat/Library/Internal.hs b/src/Simplex/Chat/Library/Internal.hs
index c67ad0d514..41f70afda4 100644
--- a/src/Simplex/Chat/Library/Internal.hs
+++ b/src/Simplex/Chat/Library/Internal.hs
@@ -59,7 +59,7 @@ import Simplex.Chat.Controller
import Simplex.Chat.Files
import Simplex.Chat.Markdown
import Simplex.Chat.Messages
-import Simplex.Chat.Messages.Batch (BatchMode (..), MsgBatch (..), batchMessages, encodeBinaryBatch, encodeFwdElement)
+import Simplex.Chat.Messages.Batch (BatchMode (..), MsgBatch (..), batchMessages, encodeBatchElement, encodeBinaryBatch, encodeFwdElement)
import Simplex.Chat.Messages.CIContent
import Simplex.Chat.Messages.CIContent.Events
import Simplex.Chat.Operators
@@ -80,6 +80,7 @@ import Simplex.Chat.Types.Shared
import Simplex.Chat.Util (encryptFile, shuffle)
import Simplex.FileTransfer.Description (FileDescriptionURI (..), ValidFileDescription)
import qualified Simplex.FileTransfer.Description as FD
+import qualified Simplex.Messaging.Crypto.Lazy as LC
import Simplex.FileTransfer.Protocol (FileParty (..), FilePartyI)
import Simplex.FileTransfer.Types (RcvFileId, SndFileId)
import Simplex.Messaging.Agent
@@ -935,9 +936,9 @@ acceptContactRequestAsync
liftIO $ setCommandConnId db user cmdId connId
getContact db cxt user contactId
-acceptGroupJoinRequestAsync :: User -> Int64 -> GroupInfo -> InvitationId -> VersionRangeChat -> Profile -> Maybe XContactId -> Maybe MemberId -> Maybe SharedMsgId -> GroupAcceptance -> GroupMemberRole -> Maybe IncognitoProfile -> Maybe MemberKey -> CM GroupMember
+acceptGroupJoinRequestAsync :: User -> Int64 -> GroupInfo -> InvitationId -> VersionRangeChat -> Profile -> Maybe XContactId -> Maybe MemberId -> Maybe SharedMsgId -> GroupAcceptance -> GroupMemberRole -> Maybe IncognitoProfile -> Maybe MemberKey -> Maybe GroupMember -> CM GroupMember
acceptGroupJoinRequestAsync
- user
+ user@User {userId}
uclId
gInfo@GroupInfo {groupProfile, membership, businessChat}
cReqInvId
@@ -949,12 +950,22 @@ acceptGroupJoinRequestAsync
gAccepted
gLinkMemRole
incognitoProfile
- memberKey_ = do
+ memberKey_
+ existingMem_ = do
gVar <- asks random
let initialStatus = acceptanceToStatus (memberAdmission groupProfile) gAccepted
+ -- a roster-established privileged member attaches a connection to its existing record (keeping
+ -- owner-authoritative role + key); everyone else is created fresh with the group-link role
cxt <- chatStoreCxt
- (groupMemberId, memberId) <- withStore $ \db ->
- createJoiningMember db cxt gVar user gInfo cReqChatVRange cReqProfile cReqXContactId_ cReqMemberId_ welcomeMsgId_ gLinkMemRole initialStatus memberKey_
+ (groupMemberId, memberId) <- case existingMem_ of
+ Just m -> do
+ -- refresh the hash placeholder name from the authenticated join profile; role + key stay roster-authoritative
+ withStore $ \db -> do
+ liftIO $ updateGroupMemberStatus db userId m initialStatus
+ void $ updateMemberProfile db cxt user m cReqProfile
+ pure (groupMemberId' m, memberId' m)
+ Nothing -> withStore $ \db ->
+ createJoiningMember db cxt gVar user gInfo cReqChatVRange cReqProfile cReqXContactId_ cReqMemberId_ welcomeMsgId_ gLinkMemRole initialStatus memberKey_
let currentMemCount = fromIntegral $ currentMembers $ groupSummary gInfo
let Profile {displayName} = userProfileInGroup user gInfo (fromIncognitoProfile <$> incognitoProfile)
GroupMember {memberRole = userRole, memberId = userMemberId} = membership
@@ -1169,21 +1180,47 @@ memberIntroEvt gInfo reMember =
mRestrictions = memberRestrictions reMember
in XGrpMemIntro mInfo mRestrictions
+-- Forward the saved owner-signed roster verbatim (reusing its signed shared_msg_id), then the
+-- blob chunks, so the recipient verifies the owner signature.
+serveRoster :: User -> GroupInfo -> GroupMember -> CM ()
+serveRoster user gInfo member =
+ when (member `supportsVersion` groupRosterVersion) $ do
+ cxt <- chatStoreCxt
+ withStore' (\db -> getGroupRoster db gInfo) >>= \case
+ Just (ownerGMId, brokerTs, sm@SignedMsg {signedBody}, blob_) ->
+ case J.eitherDecodeStrict' signedBody :: Either String (ChatMessage 'Json) of
+ Left e -> logError $ "serveRoster: cannot decode saved roster message: " <> tshow e
+ Right chatMsg@ChatMessage {msgId} ->
+ withStore' (\db -> runExceptT $ getGroupMemberById db cxt user ownerGMId) >>= \case
+ Right owner -> do
+ let fwd = GrpMsgForward {fwdSender = FwdMember (memberId' owner) (memberShortenedName owner), fwdBrokerTs = brokerTs}
+ sendFwdMemberMessage member fwd (VMSigned MSSVerified sm chatMsg)
+ forM_ ((,) <$> msgId <*> blob_) $ \(sid, blob) ->
+ sendInlineBlobChunks user gInfo [member] sid blob
+ Left e -> logError $ "serveRoster: roster owner not found: " <> tshow e
+ Nothing -> pure ()
+
-- Used in groups with relays to introduce moderators and above to a new member,
-- and to announce the new member to moderators and above.
-- This doesn't create introduction records in db, compared to above methods.
introduceInChannel :: StoreCxt -> User -> GroupInfo -> GroupMember -> CM ()
introduceInChannel _ _ _ GroupMember {activeConn = Nothing} = throwChatError $ CEInternalError "member connection not active"
introduceInChannel cxt user gInfo subscriber@GroupMember {activeConn = Just conn, indexInGroup = subscriberIdx} = do
- modMs <- withStore' $ \db -> getGroupModerators db cxt user gInfo
+ (owners, adminsMods) <- withStore' $ \db ->
+ (,) <$> getGroupOwners db cxt user gInfo <*> getGroupAdminsMods db cxt user gInfo
+ let modMs = owners <> adminsMods
void $ sendGroupMessage' user gInfo modMs $ XGrpMemNew (memberInfo gInfo subscriber) Nothing
withStore' $ \db ->
setMemberVectorNewRelations db subscriber [(indexInGroup m, (IDSubjectIntroduced, MRIntroduced)) | m <- modMs]
- let introEvts = map (memberIntroEvt gInfo) modMs
- forM_ (L.nonEmpty introEvts) $ \introEvts' ->
- sendGroupMemberMessages user gInfo conn introEvts'
+ -- owner intros first so the joiner has the owner profile loaded before applying the saved roster (signed by the owner)
+ sendIntros owners
+ serveRoster user gInfo subscriber
+ sendIntros adminsMods
withStore' $ \db ->
setMembersVectorsNewRelation db modMs subscriberIdx IDSubjectIntroduced MRIntroduced
+ where
+ sendIntros ms = forM_ (L.nonEmpty $ map (memberIntroEvt gInfo) ms) $ \evts ->
+ sendGroupMemberMessages user gInfo conn evts
userProfileInGroup :: User -> GroupInfo -> Maybe Profile -> Profile
userProfileInGroup user = userProfileInGroup' user . groupUserAllowSimplexLinks
@@ -1215,6 +1252,29 @@ redactedMemberProfile allowSimplexLinks Profile {displayName, fullName, shortDes
| hasObfuscatedSimplexLink s = Nothing
| otherwise = maybe (Just s) (\fts -> if any ftIsSimplexLink fts then Nothing else Just s) $ parseMaybeMarkdownList s
+-- Roles carried by the roster; owners are on the link, not the roster.
+isRosterRole :: GroupMemberRole -> Bool
+isRosterRole r = r == GRMember || r == GRModerator || r == GRAdmin
+
+-- Drop non-privileged-role entries and de-duplicate by memberId, keeping the first.
+-- Runs on the parsed roster blob.
+validateGroupRoster :: [RosterMember] -> [RosterMember]
+validateGroupRoster entries =
+ dedup S.empty $ filter (\RosterMember {role} -> isRosterRole role) entries
+ where
+ dedup _ [] = []
+ dedup seen (rm@RosterMember {memberId} : rms)
+ | memberId `S.member` seen = dedup seen rms
+ | otherwise = rm : dedup (S.insert memberId seen) rms
+
+-- Privileged members without a known key are skipped (recipients can't verify them).
+buildGroupRoster :: [GroupMember] -> [RosterMember]
+buildGroupRoster mods = take maxGroupRosterSize $ mapMaybe rosterMember mods
+ where
+ rosterMember GroupMember {memberId, memberPubKey, memberRole}
+ | isRosterRole memberRole = (\k -> RosterMember {memberId, key = MemberKey k, role = memberRole, privileges = 0}) <$> memberPubKey
+ | otherwise = Nothing
+
sendHistory :: User -> GroupInfo -> GroupMember -> CM ()
sendHistory _ _ GroupMember {activeConn = Nothing} = throwChatError $ CEInternalError "member connection not active"
sendHistory user gInfo@GroupInfo {membership} m@GroupMember {activeConn = Just conn} =
@@ -1341,7 +1401,7 @@ setGroupLinkData :: NetworkRequestMode -> User -> GroupInfo -> GroupLink -> CM G
setGroupLinkData nm user gInfo gLink = do
cxt <- chatStoreCxt
(conn, groupRelays) <- withFastStore $ \db ->
- (,) <$> getGroupLinkConnection db cxt user gInfo <*> liftIO (getConnectedGroupRelays db gInfo)
+ (,) <$> getGroupLinkConnection db cxt user gInfo <*> liftIO (getPublishableGroupRelays db cxt user gInfo)
let (userLinkData, crClientData) = groupLinkData gInfo gLink groupRelays
linkType = if useRelays' gInfo then CCTChannel else CCTGroup
sLnk <- shortenShortLink' . setShortLinkType_ linkType =<< withAgent (\a -> setConnShortLink a nm (aConnId conn) SCMContact userLinkData (Just crClientData))
@@ -1351,7 +1411,7 @@ setGroupLinkDataAsync :: User -> GroupInfo -> GroupLink -> CM ()
setGroupLinkDataAsync user gInfo gLink = do
cxt <- chatStoreCxt
(conn, groupRelays) <- withStore $ \db ->
- (,) <$> getGroupLinkConnection db cxt user gInfo <*> liftIO (getConnectedGroupRelays db gInfo)
+ (,) <$> getGroupLinkConnection db cxt user gInfo <*> liftIO (getPublishableGroupRelays db cxt user gInfo)
let (userLinkData, crClientData) = groupLinkData gInfo gLink groupRelays
setAgentConnShortLinkAsync user conn userLinkData (Just crClientData)
@@ -1628,13 +1688,16 @@ sendFileInline_ FileTransferMeta {filePath, chunkSize} sharedMsgId sendMsg =
chSize = fromIntegral chunkSize
parseChatMessage :: Connection -> ByteString -> CM (ChatMessage 'Json)
-parseChatMessage conn s = do
+parseChatMessage conn s = snd <$> parseChatMessage' conn s
+{-# INLINE parseChatMessage #-}
+
+parseChatMessage' :: Connection -> ByteString -> CM (Maybe SignedMsg, ChatMessage 'Json)
+parseChatMessage' conn s =
case parseChatMessages s of
- [msg] -> liftEither . first (ChatError . errType) $ (\(APMsg _ (ParsedMsg _ _ m)) -> checkEncoding m) =<< msg
+ [msg] -> liftEither . first (ChatError . errType) $ (\(APMsg _ (ParsedMsg _ sm m)) -> (sm,) <$> checkEncoding m) =<< msg
_ -> throwChatError $ CEException "parseChatMessage: single message is expected"
where
errType = CEInvalidChatMessage conn Nothing (safeDecodeUtf8 s)
-{-# INLINE parseChatMessage #-}
getChatScopeInfo :: StoreCxt -> User -> GroupChatScope -> CM GroupChatScopeInfo
getChatScopeInfo cxt user = \case
@@ -1831,6 +1894,51 @@ closeFileHandle fileId files = do
h_ <- atomically . stateTVar fs $ \m -> (M.lookup fileId m, M.delete fileId m)
liftIO $ mapM_ hClose h_ `catchAll_` pure ()
+-- The roster file has no chat item, so chat-item file enumeration misses it; clean it up by group.
+cleanupGroupRosterFile :: User -> GroupInfo -> CM ()
+cleanupGroupRosterFile User {userId} GroupInfo {groupId} = do
+ infos <- withStore' $ \db -> getGroupRosterFileInfo db userId groupId
+ forM_ infos $ \(fileId, filePath_) -> do
+ lift $ closeFileHandle fileId rcvFiles
+ forM_ filePath_ removeFsFile
+ withStore' $ \db -> do
+ deleteGroupRosterFile db userId groupId
+ deleteGroupRosterTransfers db groupId
+
+-- Supersede/cancel one source relay's in-flight roster transfer: remove its on-disk file + cached
+-- handle first (the cascade only does rows), then the files + transfer rows.
+cleanupRosterTransfer :: GroupInfo -> GroupMemberId -> CM ()
+cleanupRosterTransfer gInfo fromMemberId =
+ withStore' (\db -> getRosterTransferId db gInfo fromMemberId) >>= mapM_ cleanupRosterTransferById
+
+cleanupRosterTransferById :: Int64 -> CM ()
+cleanupRosterTransferById transferId = do
+ file_ <- withStore' $ \db -> getRosterTransferFile db transferId
+ forM_ file_ $ \(fileId, filePath_) -> do
+ lift $ closeFileHandle fileId rcvFiles
+ forM_ filePath_ removeFsFile
+ withStore' $ \db -> do
+ deleteRosterTransferFile db transferId
+ deleteRosterTransfer db transferId
+
+-- MUST evict the cached AppendMode handle before deleting chunks, else re-driven bytes append
+-- after the stale prefix and corrupt the blob.
+resetRosterPartialChunks :: RcvFileTransfer -> CM ()
+resetRosterPartialChunks ft@RcvFileTransfer {fileId, fileStatus} = do
+ lift $ closeFileHandle fileId rcvFiles
+ forM_ (rcvFilePath fileStatus) removeFsFile
+ withStore' $ \db -> deleteRcvFileChunks db ft
+ where
+ rcvFilePath = \case
+ RFSAccepted p -> Just p
+ RFSConnected p -> Just p
+ _ -> Nothing
+
+removeFsFile :: FilePath -> CM ()
+removeFsFile fp = do
+ p <- lift $ toFSFilePath fp
+ removeFile p `catchAllErrors` \_ -> pure ()
+
deleteMembersConnections :: User -> [GroupMember] -> CM ()
deleteMembersConnections user members = deleteMembersConnections' user members False
@@ -2063,6 +2171,26 @@ encodeConnInfoPQ pqSup v chatMsgEvent = do
_ -> pure connInfo
ECMLarge -> throwChatError $ CEException "large info"
+-- conn-info wrapped as a signed element, so the receiver can verify the signature over the body
+encodeSignedConnInfo :: MsgEncodingI e => MsgSigning -> ChatMsgEvent e -> CM ByteString
+encodeSignedConnInfo signing chatMsgEvent = do
+ vr <- chatVersionRange
+ let info = ChatMessage {chatVRange = vr, msgId = Nothing, chatMsgEvent}
+ case encodeChatMessage maxEncodedInfoLength info of
+ ECMEncoded body -> pure $ encodeBatchElement (Just $ signChatMsgBody signing body) body
+ ECMLarge -> throwChatError $ CEException "large signed info"
+
+-- signed XMember for a relay-group join: proves the joiner holds the member key it asserts, and carries
+-- viaRelay = the target relay's memberId inside the signed body so a sibling relay can't accept a replay
+encodeXMemberConnInfo :: GroupInfo -> MemberId -> Profile -> CM ByteString
+encodeXMemberConnInfo GroupInfo {membership = GroupMember {memberId}, groupKeys} relayMemberId profileToSend =
+ case groupKeys of
+ Just GroupKeys {publicGroupId, memberPrivKey} ->
+ let xMemberEvt = XMember profileToSend memberId (MemberKey $ C.publicKey memberPrivKey) (Just relayMemberId)
+ signing = MsgSigning CBGroup (smpEncode (publicGroupId, memberId)) KRMember memberPrivKey
+ in encodeSignedConnInfo signing xMemberEvt
+ Nothing -> throwChatError $ CEInternalError "no group keys for channel membership"
+
deliverMessage :: Connection -> CMEventTag e -> MsgBody -> MessageId -> CM (Int64, PQEncryption)
deliverMessage conn cmEventTag msgBody msgId = do
let msgFlags = MsgFlags {notification = hasNotification cmEventTag}
@@ -2136,6 +2264,52 @@ sendGroupMessage' user gInfo members chatMsgEvent =
((Right msg) :| [], _) -> pure msg
_ -> throwChatError $ CEInternalError "sendGroupMessage': expected 1 message"
+-- TODO [relays] improvement: publish roster_version in link data so the owner can recover the latest version
+-- TODO after restoring from a stale backup (relays accept only strictly-greater versions)
+-- Persist the next roster version before sending the events that carry it (so a recipient never advances
+-- past a version the owner hasn't recorded). The matching blob is broadcast separately, by broadcastRoster,
+-- after the change is applied to the owner's members - so the served roster excludes demoted/removed members.
+reserveRosterVersion :: GroupInfo -> CM VersionRoster
+reserveRosterVersion gInfo = do
+ let rosterVer = maybe (VersionRoster 0) (\(VersionRoster n) -> VersionRoster (n + 1)) (rosterVersion gInfo)
+ withStore' $ \db -> setGroupRosterVersion db gInfo rosterVer
+ pure rosterVer
+
+broadcastRoster :: User -> GroupInfo -> VersionRoster -> CM ()
+broadcastRoster user gInfo rosterVer = do
+ cxt <- chatStoreCxt
+ (relays, rosterMems) <- withStore' $ \db ->
+ (,) <$> getGroupRelayMembers db cxt user gInfo <*> getGroupRosterMembers db cxt user gInfo
+ forM_ (L.nonEmpty relays) $ \relays' ->
+ sendRoster user gInfo (L.toList relays') rosterVer (buildGroupRoster rosterMems)
+
+-- Send the current roster (no version bump) to a newly added relay so it can serve joiners.
+sendGroupRosterToRelay :: User -> GroupInfo -> GroupMember -> CM ()
+sendGroupRosterToRelay user gInfo relayMember =
+ forM_ (rosterVersion gInfo) $ \rosterVer -> do
+ cxt <- chatStoreCxt
+ rosterMems <- withStore' $ \db -> getGroupRosterMembers db cxt user gInfo
+ sendRoster user gInfo [relayMember] rosterVer (buildGroupRoster rosterMems)
+
+-- Row-less send (no files/snd_files rows, so no send-side cleanup); redelivery is the agent's.
+sendRoster :: User -> GroupInfo -> [GroupMember] -> VersionRoster -> [RosterMember] -> CM ()
+sendRoster user gInfo members rosterVer roster = do
+ let blob = encodeRosterBlob roster
+ fileInv = InlineFileInvitation {fileSize = fromIntegral (B.length blob), fileDigest = FD.FileDigest $ LC.sha512Hash $ LB.fromStrict blob}
+ SndMessage {sharedMsgId} <- sendGroupMessage' user gInfo members (XGrpRoster GroupRoster {version = rosterVer, fileInv})
+ sendInlineBlobChunks user gInfo members sharedMsgId blob
+
+-- Send a binary blob as BFileChunks under a shared_msg_id to the given members (chunked by fileChunkSize).
+sendInlineBlobChunks :: User -> GroupInfo -> [GroupMember] -> SharedMsgId -> ByteString -> CM ()
+sendInlineBlobChunks user gInfo members sharedMsgId blob = do
+ chSize <- fromIntegral <$> asks (fileChunkSize . config)
+ go chSize 1 blob
+ where
+ go chSize chunkNo bytes = do
+ let (chunk, rest) = B.splitAt chSize bytes
+ void $ sendGroupMessage' user gInfo members (BFileChunk sharedMsgId (FileChunk chunkNo chunk))
+ unless (B.null rest) $ go chSize (chunkNo + 1) rest
+
-- Relay advertises its current web preview capability to channel owners.
-- Idempotent: sends only when the configured web domain differs from what was last sent, and only to
-- owners whose recorded chat version supports relayWebCapVersion (older apps can't parse XGrpRelayCap).
@@ -2372,10 +2546,14 @@ saveDirectRcvMSG conn@Connection {connId} agentMsgMeta chatMsg@ChatMessage {chat
msg <- withStore $ \db -> createNewMessageAndRcvMsgDelivery db (ConnectionId connId) newMsg sharedMsgId_ rcvMsgDelivery Nothing
pure (conn', msg)
-saveGroupRcvMsg :: MsgEncodingI e => User -> GroupId -> GroupMember -> Connection -> MsgMeta -> VerifiedMsg e -> CM (GroupMember, Connection, RcvMessage)
+saveGroupRcvMsg :: forall e. MsgEncodingI e => User -> GroupId -> GroupMember -> Connection -> MsgMeta -> VerifiedMsg e -> CM (GroupMember, Connection, RcvMessage)
saveGroupRcvMsg user groupId authorMember conn@Connection {connId} agentMsgMeta verifiedMsg = do
let ChatMessage {chatVRange, msgId = sharedMsgId_, chatMsgEvent} = verifiedChatMsg verifiedMsg
- (am'@GroupMember {memberId = amMemId, groupMemberId = amGroupMemId}, conn') <- updateMemberChatVRange authorMember conn chatVRange
+ -- binary messages (file chunks) carry only the initial-version sentinel, not the sender's range;
+ -- applying it would downgrade the member's negotiated version and suppress version-gated delivery
+ (am'@GroupMember {memberId = amMemId, groupMemberId = amGroupMemId}, conn') <- case encoding @e of
+ SBinary -> pure (authorMember, conn)
+ SJson -> updateMemberChatVRange authorMember conn chatVRange
let agentMsgId = fst $ recipient agentMsgMeta
brokerTs = metaBrokerTs agentMsgMeta
newMsg = NewRcvMessage {chatMsgEvent, verifiedMsg, brokerTs}
@@ -2513,11 +2691,11 @@ saveRcvChatItem' user cd msg@RcvMessage {chatMsgEvent, msgSigned, forwardedByMem
_ -> Nothing
-- TODO [mentions] optimize by avoiding unnecessary parsing
-mkChatItem :: (ChatTypeI c, MsgDirectionI d) => ChatDirection c d -> ShowGroupAsSender -> ChatItemId -> CIContent d -> Maybe (CIFile d) -> Maybe (CIQuote c) -> Maybe SharedMsgId -> Maybe CIForwardedFrom -> Maybe CITimed -> Bool -> Bool -> ChatItemTs -> Maybe GroupMemberId -> UTCTime -> ChatItem c d
-mkChatItem cd showGroupAsSender ciId content file quotedItem sharedMsgId itemForwarded itemTimed live userMention itemTs forwardedByMember currentTs =
+mkChatItem :: (ChatTypeI c, MsgDirectionI d) => ChatDirection c d -> ShowGroupAsSender -> ChatItemId -> CIContent d -> Maybe (CIFile d) -> Maybe (CIQuote c) -> Maybe SharedMsgId -> Maybe CIForwardedFrom -> Maybe CITimed -> Bool -> Bool -> ChatItemTs -> Maybe GroupMemberId -> Maybe MsgSigStatus -> UTCTime -> ChatItem c d
+mkChatItem cd showGroupAsSender ciId content file quotedItem sharedMsgId itemForwarded itemTimed live userMention itemTs forwardedByMember msgSigned currentTs =
let ts@(_, ft_) = ciContentTexts content
hasLink_ = ciContentHasLink content ft_
- in mkChatItem_ cd showGroupAsSender ciId content ts file quotedItem sharedMsgId itemForwarded itemTimed live userMention hasLink_ itemTs forwardedByMember Nothing currentTs
+ in mkChatItem_ cd showGroupAsSender ciId content ts file quotedItem sharedMsgId itemForwarded itemTimed live userMention hasLink_ itemTs forwardedByMember msgSigned currentTs
mkChatItem_ :: (ChatTypeI c, MsgDirectionI d) => ChatDirection c d -> ShowGroupAsSender -> ChatItemId -> CIContent d -> (Text, Maybe MarkdownList) -> Maybe (CIFile d) -> Maybe (CIQuote c) -> Maybe SharedMsgId -> Maybe CIForwardedFrom -> Maybe CITimed -> Bool -> Bool -> Bool -> ChatItemTs -> Maybe GroupMemberId -> Maybe MsgSigStatus -> UTCTime -> ChatItem c d
mkChatItem_ cd showGroupAsSender ciId content (itemText, formattedText) file quotedItem sharedMsgId itemForwarded itemTimed live userMention hasLink_ itemTs forwardedByMember msgSigned currentTs =
@@ -2679,7 +2857,7 @@ createFeatureEnabledItems_ :: User -> Contact -> CM [AChatItem]
createFeatureEnabledItems_ user ct@Contact {mergedPreferences} =
forM allChatFeatures $ \(ACF f) -> do
let state = featureState $ getContactUserPreference f mergedPreferences
- createChatItem user (CDDirectRcv ct) False (uncurry (CIRcvChatFeature $ chatFeature f) state) Nothing Nothing
+ createChatItem user (CDDirectRcv ct) False (uncurry (CIRcvChatFeature $ chatFeature f) state) Nothing Nothing Nothing
createFeatureItems ::
MsgDirectionI d =>
@@ -2709,15 +2887,15 @@ createContactsFeatureItems user cts chatDir ciFeature ciOffer getPref = do
unless (null errs) $ toView' $ CEvtChatErrors errs
toView' $ CEvtNewChatItems user acis
where
- contactChangedFeatures :: (Contact, Contact) -> (ChatDirection 'CTDirect d, ShowGroupAsSender, [(CIContent d, Maybe SharedMsgId)])
+ contactChangedFeatures :: (Contact, Contact) -> (ChatDirection 'CTDirect d, ShowGroupAsSender, [(CIContent d, Maybe SharedMsgId, Maybe MsgSigStatus)])
contactChangedFeatures (Contact {mergedPreferences = cups}, ct'@Contact {mergedPreferences = cups'}) = do
let contents = mapMaybe (\(ACF f) -> featureCIContent_ f) allChatFeatures
(chatDir ct', False, contents)
where
- featureCIContent_ :: forall f. FeatureI f => SChatFeature f -> Maybe (CIContent d, Maybe SharedMsgId)
+ featureCIContent_ :: forall f. FeatureI f => SChatFeature f -> Maybe (CIContent d, Maybe SharedMsgId, Maybe MsgSigStatus)
featureCIContent_ f
- | state /= state' = Just (fContent ciFeature state', Nothing)
- | prefState /= prefState' = Just (fContent ciOffer prefState', Nothing)
+ | state /= state' = Just (fContent ciFeature state', Nothing, Nothing)
+ | prefState /= prefState' = Just (fContent ciOffer prefState', Nothing, Nothing)
| otherwise = Nothing
where
fContent :: FeatureContent a d -> (a, Maybe Int) -> CIContent d
@@ -2750,16 +2928,16 @@ createGroupFeatureItems_ user cd showGroupAsSender ciContent GroupInfo {fullGrou
forM allGroupFeatures $ \(AGF f) -> do
let p = getGroupPreference f fullGroupPreferences
(_, param, role) = groupFeatureState p
- createChatItem user cd showGroupAsSender (ciContent (toGroupFeature f) (toGroupPreference p) param role) Nothing Nothing
+ createChatItem user cd showGroupAsSender (ciContent (toGroupFeature f) (toGroupPreference p) param role) Nothing Nothing Nothing
createInternalChatItem :: (ChatTypeI c, MsgDirectionI d) => User -> ChatDirection c d -> CIContent d -> Maybe UTCTime -> CM ()
createInternalChatItem user cd content itemTs_ = do
- ci <- createChatItem user cd False content Nothing itemTs_
+ ci <- createChatItem user cd False content Nothing Nothing itemTs_
toView $ CEvtNewChatItems user [ci]
-createChatItem :: (ChatTypeI c, MsgDirectionI d) => User -> ChatDirection c d -> ShowGroupAsSender -> CIContent d -> Maybe SharedMsgId -> Maybe UTCTime -> CM AChatItem
-createChatItem user cd showGroupAsSender content sharedMsgId itemTs_ =
- lift (createChatItems user itemTs_ [(cd, showGroupAsSender, [(content, sharedMsgId)])]) >>= \case
+createChatItem :: (ChatTypeI c, MsgDirectionI d) => User -> ChatDirection c d -> ShowGroupAsSender -> CIContent d -> Maybe SharedMsgId -> Maybe MsgSigStatus -> Maybe UTCTime -> CM AChatItem
+createChatItem user cd showGroupAsSender content sharedMsgId msgSigned itemTs_ =
+ lift (createChatItems user itemTs_ [(cd, showGroupAsSender, [(content, sharedMsgId, msgSigned)])]) >>= \case
[Right ci] -> pure ci
[Left e] -> throwError e
rs -> throwChatError $ CEInternalError $ "createInternalChatItem: expected 1 result, got " <> show (length rs)
@@ -2771,7 +2949,7 @@ createChatItems ::
(ChatTypeI c, MsgDirectionI d) =>
User ->
Maybe UTCTime ->
- [(ChatDirection c d, ShowGroupAsSender, [(CIContent d, Maybe SharedMsgId)])] ->
+ [(ChatDirection c d, ShowGroupAsSender, [(CIContent d, Maybe SharedMsgId, Maybe MsgSigStatus)])] ->
CM' [Either ChatError AChatItem]
createChatItems user itemTs_ dirsCIContents = do
createdAt <- liftIO getCurrentTime
@@ -2780,24 +2958,24 @@ createChatItems user itemTs_ dirsCIContents = do
void . withStoreBatch' $ \db -> map (updateChat db cxt createdAt) dirsCIContents
withStoreBatch' $ \db -> concatMap (createACIs db itemTs createdAt) dirsCIContents
where
- updateChat :: DB.Connection -> StoreCxt -> UTCTime -> (ChatDirection c d, ShowGroupAsSender, [(CIContent d, Maybe SharedMsgId)]) -> IO ()
+ updateChat :: DB.Connection -> StoreCxt -> UTCTime -> (ChatDirection c d, ShowGroupAsSender, [(CIContent d, Maybe SharedMsgId, Maybe MsgSigStatus)]) -> IO ()
updateChat db cxt createdAt (cd, _, contents)
- | any (ciRequiresAttention . fst) contents || contactChatDeleted cd = void $ updateChatTsStats db cxt user cd createdAt memberChatStats
+ | any (\(content, _, _) -> ciRequiresAttention content) contents || contactChatDeleted cd = void $ updateChatTsStats db cxt user cd createdAt memberChatStats
| otherwise = pure ()
where
memberChatStats :: Maybe (Int, MemberAttention, Int)
memberChatStats = case cd of
CDGroupRcv _g (Just scope) m -> do
- let unread = length $ filter (ciRequiresAttention . fst) contents
+ let unread = length $ filter (\(content, _, _) -> ciRequiresAttention content) contents
in Just (unread, memberAttentionChange unread itemTs_ (Just m) scope, 0)
_ -> Nothing
- createACIs :: DB.Connection -> UTCTime -> UTCTime -> (ChatDirection c d, ShowGroupAsSender, [(CIContent d, Maybe SharedMsgId)]) -> [IO AChatItem]
+ createACIs :: DB.Connection -> UTCTime -> UTCTime -> (ChatDirection c d, ShowGroupAsSender, [(CIContent d, Maybe SharedMsgId, Maybe MsgSigStatus)]) -> [IO AChatItem]
createACIs db itemTs createdAt (cd, showGroupAsSender, contents) = map createACI contents
where
- createACI (content, sharedMsgId) = do
+ createACI (content, sharedMsgId, msgSigned) = do
let hasLink_ = ciContentHasLink content Nothing
- ciId <- createNewChatItemNoMsg db user cd showGroupAsSender content sharedMsgId hasLink_ itemTs createdAt
- let ci = mkChatItem cd showGroupAsSender ciId content Nothing Nothing Nothing Nothing Nothing False False itemTs Nothing createdAt
+ ciId <- createNewChatItemNoMsg db user cd showGroupAsSender content sharedMsgId hasLink_ msgSigned itemTs createdAt
+ let ci = mkChatItem cd showGroupAsSender ciId content Nothing Nothing Nothing Nothing Nothing False False itemTs Nothing msgSigned createdAt
pure $ AChatItem (chatTypeI @c) (msgDirection @d) (toChatInfo cd) ci
-- rcvMem_ Nothing means message from channel - treated same as message from moderator,
diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs
index 2664e217f7..a2ea9870cc 100644
--- a/src/Simplex/Chat/Library/Subscriber.hs
+++ b/src/Simplex/Chat/Library/Subscriber.hs
@@ -24,6 +24,7 @@ import Control.Monad.IO.Unlift
import Control.Monad.Reader
import Data.ByteString.Char8 (ByteString)
import qualified Data.ByteString.Char8 as B
+import qualified Data.ByteString.Lazy.Char8 as LB
import Data.Either (lefts, partitionEithers, rights)
import Data.Foldable (foldr', foldrM)
import Data.Functor (($>))
@@ -40,12 +41,14 @@ import Data.Text (Text)
import qualified Data.Text as T
import Data.Text.Encoding (decodeLatin1)
import Data.Time.Clock (NominalDiffTime, UTCTime, addUTCTime, diffUTCTime, getCurrentTime)
+import Data.Time.Format (defaultTimeLocale, formatTime)
import qualified Data.UUID as UUID
import qualified Data.UUID.V4 as V4
import Data.Word (Word32)
import Simplex.Chat.Call
import Simplex.Chat.Controller
import Simplex.Chat.Delivery
+import Simplex.Chat.Files (getChatTempDirectory)
import Simplex.Chat.Library.Internal
import Simplex.Chat.Web (channelContentChanged, channelProfileUpdated, channelRemoved)
import Simplex.Chat.Messages
@@ -77,7 +80,7 @@ import qualified Simplex.FileTransfer.Transport as XFTP
import Simplex.FileTransfer.Types (FileErrorType (..), RcvFileId, SndFileId)
import Simplex.Messaging.Agent
import Simplex.Messaging.Agent.Client (getAgentWorker, temporaryOrHostError, waitForUserNetwork, waitForWork, waitWhileSuspended, withWorkItems, withWork_)
-import Simplex.Messaging.Agent.Env.SQLite (AgentConfig (..), Worker (..))
+import Simplex.Messaging.Agent.Env.SQLite (Worker (..))
import Simplex.Messaging.Agent.Protocol
import qualified Simplex.Messaging.Agent.Protocol as AP (AgentErrorType (..))
import Simplex.Messaging.Agent.RetryInterval (RetryInterval (..), nextRetryDelay)
@@ -87,8 +90,10 @@ import qualified Simplex.Messaging.Crypto as C
import Simplex.Messaging.Crypto.File (CryptoFile (..))
import Simplex.Messaging.Crypto.Ratchet (PQEncryption (..), PQSupport (..), pattern PQEncOff, pattern PQEncOn, pattern PQSupportOff, pattern PQSupportOn)
import qualified Simplex.Messaging.Crypto.Ratchet as CR
+import qualified Simplex.Messaging.Crypto.Lazy as LC
import Simplex.Messaging.Encoding (smpEncode)
import Simplex.Messaging.Encoding.String
+import Simplex.Messaging.Parsers (parseAll)
import Simplex.Messaging.Protocol (ErrorType (..), MsgFlags (..), ServiceSub (..), ServiceSubError (..), ServiceSubResult (..))
import qualified Simplex.Messaging.Protocol as SMP
import Simplex.Messaging.ServiceScheme (ServiceScheme (..))
@@ -106,6 +111,13 @@ import UnliftIO.STM
smallGroupsRcptsMemLimit :: Int
smallGroupsRcptsMemLimit = 20
+-- Verifies member signatures over CBGroup <> (publicGroupId, memberId) <> signedBody under the given key.
+-- signatures is NonEmpty so the verification can't be vacuously true.
+verifyGroupSig :: C.PublicKeyEd25519 -> B64UrlByteString -> MemberId -> NonEmpty MsgSignature -> ByteString -> Bool
+verifyGroupSig key publicGroupId memberId signatures signedBody =
+ let prefix = smpEncode CBGroup <> smpEncode (publicGroupId, memberId)
+ in all (\case (MsgSignature KRMember sig) -> C.verify (C.APublicVerifyKey C.SEd25519 key) sig (prefix <> signedBody)) signatures
+
processAgentMessage :: ACorrId -> ConnId -> AEvent 'AEConn -> CM ()
processAgentMessage _ _ (DEL_RCVQS delQs) =
toView $ CEvtAgentRcvQueuesDeleted $ L.map rcvQ delQs
@@ -576,7 +588,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
(gInfo, host) <- withStore $ \db -> do
liftIO $ deleteContactCardKeepConn db connId ct
createGroupInvitedViaLink db cxt user conn'' glInv
- void $ createChatItem user (CDGroupSnd gInfo Nothing) False CIChatBanner Nothing (Just epochStart)
+ void $ createChatItem user (CDGroupSnd gInfo Nothing) False CIChatBanner Nothing Nothing (Just epochStart)
-- [incognito] send saved profile
incognitoProfile <- forM customUserProfileId $ \pId -> withStore (\db -> getProfileById db userId pId)
profileToSend <- presentUserBadge user incognitoProfile $ userProfileInGroup user gInfo (fromLocalProfile <$> incognitoProfile)
@@ -901,8 +913,21 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
GCInviteeMember
| isRelay m -> do
withStore' $ \db -> updateGroupMemberStatus db userId m GSMemConnected
- gLink <- withStore $ \db -> getGroupLink db user gInfo
- setGroupLinkDataAsync user gInfo gLink
+ if m `supportsVersion` groupRosterVersion
+ then do
+ -- send the relay a roster (materializing version 0 for old channels with NULL roster_version);
+ -- the relay stays RSInvited (unpublishable) until it acks, so no joiner can impersonate a privileged member
+ gInfo' <- case rosterVersion gInfo of
+ Just _ -> pure gInfo
+ Nothing -> do
+ withStore' $ \db -> setGroupRosterVersion db gInfo (VersionRoster 0)
+ pure gInfo {rosterVersion = Just (VersionRoster 0)}
+ sendGroupRosterToRelay user gInfo' m
+ else do
+ -- a relay below groupRosterVersion can't ack a roster; publish it on connect as before
+ -- the handshake (getPublishableGroupRelays and the LINK handler include/activate it by version)
+ gLink <- withStore $ \db -> getGroupLink db user gInfo
+ setGroupLinkDataAsync user gInfo gLink
| otherwise -> do
(gInfo', mStatus) <-
if not (memberPending m)
@@ -1024,8 +1049,8 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
pure newDeliveryTasks
processEvent :: forall e. MsgEncodingI e => GroupInfo -> GroupMember -> VerifiedMsg e -> CM (Maybe NewMessageDeliveryTask)
processEvent gInfo' m' verifiedMsg = do
- (m'', conn', msg@RcvMessage {msgId, chatMsgEvent = ACME _ event}) <- saveGroupRcvMsg user groupId m' conn msgMeta verifiedMsg
cc <- ask
+ (m'', conn', msg@RcvMessage {msgId, sharedMsgId_, chatMsgEvent = ACME _ event}) <- saveGroupRcvMsg user groupId m' conn msgMeta verifiedMsg
let ctx js = DeliveryTaskContext js False
checkSendAsGroup :: Maybe Bool -> CM (Maybe DeliveryTaskContext) -> CM (Maybe DeliveryTaskContext)
checkSendAsGroup asGroup_ a
@@ -1064,23 +1089,25 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
XGrpMemIntro memInfo memRestrictions_ -> Nothing <$ xGrpMemIntro gInfo' m'' memInfo memRestrictions_
XGrpMemInv memId introInv -> Nothing <$ xGrpMemInv gInfo' m'' memId introInv
XGrpMemFwd memInfo introInv -> Nothing <$ xGrpMemFwd gInfo' m'' memInfo introInv
- XGrpMemRole memId memRole -> fmap ctx <$> xGrpMemRole gInfo' m'' memId memRole msg brokerTs
+ XGrpMemRole memId memRole memberKey rosterVer -> fmap ctx <$> xGrpMemRole gInfo' m'' memId memRole memberKey rosterVer msg brokerTs
XGrpMemRestrict memId memRestrictions -> fmap ctx <$> xGrpMemRestrict gInfo' m'' memId memRestrictions msg brokerTs
XGrpMemCon memId -> Nothing <$ xGrpMemCon gInfo' m'' memId
- XGrpMemDel memId withMessages -> case encoding @e of
- SJson -> fmap ctx <$> xGrpMemDel gInfo' m'' memId withMessages verifiedMsg msg brokerTs False
+ XGrpMemDel memId withMessages rosterVer -> case encoding @e of
+ SJson -> fmap ctx <$> xGrpMemDel gInfo' m'' memId withMessages rosterVer verifiedMsg msg brokerTs False
SBinary -> pure Nothing
XGrpLeave -> fmap ctx <$> xGrpLeave gInfo' m'' msg brokerTs
XGrpDel -> Just (DeliveryTaskContext (DJSGroup {jobSpec = DJRelayRemoved}) False) <$ xGrpDel gInfo' m'' msg brokerTs
XGrpInfo p' -> fmap ctx <$> xGrpInfo gInfo' m'' p' msg brokerTs
XGrpPrefs ps' -> fmap ctx <$> xGrpPrefs gInfo' m'' ps' msg
+ XGrpRoster gr -> fmap ctx <$> xGrpRoster gInfo' m'' m'' gr verifiedMsg sharedMsgId_ brokerTs
+ XGrpRosterAck ackVer ackErr -> Nothing <$ xGrpRosterAck gInfo' m'' ackVer ackErr
-- TODO [knocking] why don't we forward these messages?
XGrpDirectInv connReq mContent_ msgScope -> memberCanSend (Just m'') msgScope $ Nothing <$ xGrpDirectInv gInfo' m'' conn' connReq mContent_ msg brokerTs
XGrpMsgForward fwd msg' -> Nothing <$ xGrpMsgForward gInfo' Nothing m'' fwd (ParsedMsg Nothing Nothing msg') brokerTs
XInfoProbe probe -> Nothing <$ xInfoProbe (COMGroupMember m'') probe
XInfoProbeCheck probeHash -> Nothing <$ xInfoProbeCheck (COMGroupMember m'') probeHash
XInfoProbeOk probe -> Nothing <$ xInfoProbeOk (COMGroupMember m'') probe
- BFileChunk sharedMsgId chunk -> Nothing <$ bFileChunkGroup gInfo' sharedMsgId chunk msgMeta
+ BFileChunk sharedMsgId chunk -> Nothing <$ bFileChunkGroup gInfo' m'' sharedMsgId chunk msgMeta
_ -> Nothing <$ messageError ("unsupported message: " <> tshow event)
forM deliveryTaskContext_ $ \taskContext -> do
let contentChanged :: CM ()
@@ -1143,7 +1170,9 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
sentMsgDeliveryEvent conn msgId
checkSndInlineFTComplete conn msgId
updateGroupItemsStatus gInfo m conn msgId GSSSent (Just $ isJust proxy)
- when continued $ sendPendingGroupMessages user gInfo m conn
+ when continued $ do
+ when (isUserGrpFwdRelay gInfo) $ serveRoster user gInfo m -- roster ahead of the resumed backlog
+ sendPendingGroupMessages user gInfo m conn
SWITCH qd phase cStats -> do
toView $ CEvtGroupMemberSwitch user gInfo m (SwitchProgress qd phase cStats)
(gInfo', m', scopeInfo) <- mkGroupChatScope gInfo m
@@ -1195,9 +1224,10 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
CFGetRelayDataJoin -> do
-- Update relay member with key, memberId and profile from link
relayLinkData_ <- liftIO $ decodeLinkUserData cData
- case (relayLinkData_, linkEntityId) of
- (Just RelayShortLinkData {relayProfile = p}, Just entityId) ->
+ relayMemberId <- case (relayLinkData_, linkEntityId) of
+ (Just RelayShortLinkData {relayProfile = p}, Just entityId) -> do
withStore $ \db -> updateRelayMemberData db cxt user m (MemberId entityId) (MemberKey relayKey) p
+ pure $ MemberId entityId
_ -> throwChatError $ CEException "relay link: no relay link data or entity id"
case cReq of
CRContactUri crData@ConnReqUriData {crClientData} -> do
@@ -1210,13 +1240,9 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
cReqHash = contactCReqHash $ CRContactUri crData {crScheme = SSSimplex}
-- Update connection with data derived from cReq, now available after getConnShortLinkAsync
withStore' $ \db -> updateConnLinkData db user conn cReq cReqHash groupLinkId chatV pqSup
- let GroupMember {memberId = membershipMemId} = membership
- incognitoProfile = incognitoMembershipProfile gInfo
- profileToSend <- presentUserBadge user incognitoProfile $ userProfileInGroup user gInfo (fromLocalProfile <$> incognitoProfile)
- memberPubKey <- case groupKeys gInfo of
- Just GroupKeys {memberPrivKey} -> pure $ C.publicKey memberPrivKey
- Nothing -> throwChatError $ CEInternalError "no group keys for channel membership"
- dm <- encodeConnInfo $ XMember profileToSend membershipMemId (MemberKey memberPubKey)
+ let incognitoProfile = fromLocalProfile <$> incognitoMembershipProfile gInfo
+ profileToSend <- presentUserBadge user incognitoProfile $ userProfileInGroup user gInfo incognitoProfile
+ dm <- encodeXMemberConnInfo gInfo relayMemberId profileToSend
subMode <- chatReadVar subscriptionMode
void $ joinAgentConnectionAsync user (Just conn) True cReq dm subMode
CFGetRelayDataAccept -> do
@@ -1226,7 +1252,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
relayProfile <- liftIO (decodeLinkUserData cData) >>= \case
Just RelayShortLinkData {relayProfile = p} -> pure p
Nothing -> throwChatError $ CEException "relay link: no relay link data"
- (confId, m', relay) <- withStore $ \db -> do
+ (confId, m', relay) <- withStore $ \db -> do
confId <- getRelayConfId db m
liftIO $ updateGroupMemberStatus db userId m GSMemAccepted
(m', relay) <- setRelayLinkAccepted db cxt user m (MemberKey relayKey) relayProfile
@@ -1239,7 +1265,9 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
_ -> throwChatError $ CECommandError "unexpected cmdFunction"
QCONT -> do
continued <- continueSending connEntity conn
- when continued $ sendPendingGroupMessages user gInfo m conn
+ when continued $ do
+ when (isUserGrpFwdRelay gInfo) $ serveRoster user gInfo m -- roster ahead of the resumed backlog
+ sendPendingGroupMessages user gInfo m conn
MWARN msgId err -> do
withStore' $ \db -> updateGroupItemsErrorStatus db msgId (groupMemberId' m) (GSSWarning $ agentSndError err)
processConnMWARN connEntity conn err
@@ -1312,13 +1340,18 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
r n'' = Just (ci, CIRcvDecryptionError mde n'')
mdeUpdatedCI _ _ = Nothing
- receiveFileChunk :: RcvFileTransfer -> Maybe Connection -> MsgMeta -> FileChunk -> CM ()
- receiveFileChunk ft@RcvFileTransfer {fileId, chunkSize} conn_ meta@MsgMeta {recipient = (msgId, _), integrity} = \case
- FileChunkCancel ->
- unless (rcvFileCompleteOrCancelled ft) $ do
- cancelRcvFileTransfer user ft
- ci <- withStore $ \db -> getChatItemByFileId db cxt user fileId
- toView $ CEvtRcvFileSndCancelled user ci ft
+ receiveFileChunk :: Maybe GroupInfo -> RcvFileTransfer -> Maybe Connection -> MsgMeta -> FileChunk -> CM ()
+ receiveFileChunk gInfo_ ft@RcvFileTransfer {fileId, fileType, chunkSize} conn_ MsgMeta {recipient = (msgId, _), integrity} = \case
+ FileChunkCancel -> case fileType of
+ -- cancel only this source's transfer; other relays' in-flight transfers are independent
+ FTRoster -> do
+ t_ <- withStore' $ \db -> getRosterTransfer db fileId
+ forM_ t_ $ \RcvRosterTransfer {rosterTransferId} -> cleanupRosterTransferById rosterTransferId
+ FTNormal ->
+ unless (rcvFileCompleteOrCancelled ft) $ do
+ cancelRcvFileTransfer user ft
+ ci <- withStore $ \db -> getChatItemByFileId db cxt user fileId
+ toView $ CEvtRcvFileSndCancelled user ci ft
FileChunk {chunkNo, chunkBytes = chunk} -> do
case integrity of
MsgOk -> pure ()
@@ -1329,30 +1362,33 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
RcvChunkOk ->
if B.length chunk /= fromInteger chunkSize
then badRcvFileChunk ft "incorrect chunk size"
- else withAckMessage' "file msg" agentConnId meta $ appendFileChunk ft chunkNo chunk False
+ else appendFileChunk ft chunkNo chunk False
RcvChunkFinal ->
if B.length chunk > fromInteger chunkSize
then badRcvFileChunk ft "incorrect chunk size"
else do
appendFileChunk ft chunkNo chunk True
- ci <- withStore $ \db -> do
- liftIO $ do
- updateRcvFileStatus db fileId FSComplete
- updateCIFileStatus db user fileId CIFSRcvComplete
- deleteRcvFileChunks db ft
- getChatItemByFileId db cxt user fileId
- toView $ CEvtRcvFileComplete user ci
- mapM_ (deleteAgentConnectionAsync . aConnId) conn_
- RcvChunkDuplicate -> withAckMessage' "file msg" agentConnId meta $ pure ()
+ case fileType of
+ FTRoster -> forM_ gInfo_ $ \gInfo -> rosterCompletion gInfo ft
+ FTNormal -> do
+ ci <- withStore $ \db -> do
+ liftIO $ do
+ updateRcvFileStatus db fileId FSComplete
+ updateCIFileStatus db user fileId CIFSRcvComplete
+ deleteRcvFileChunks db ft
+ getChatItemByFileId db cxt user fileId
+ toView $ CEvtRcvFileComplete user ci
+ mapM_ (deleteAgentConnectionAsync . aConnId) conn_
+ RcvChunkDuplicate -> pure ()
RcvChunkError -> badRcvFileChunk ft $ "incorrect chunk number " <> show chunkNo
processContactConnMessage :: AEvent e -> ConnectionEntity -> Connection -> UserContact -> CM ()
processContactConnMessage agentMsg connEntity conn UserContact {userContactLinkId = uclId, groupId = ucGroupId_} = case agentMsg of
REQ invId pqSupport _ connInfo -> do
- ChatMessage {chatVRange, chatMsgEvent} <- parseChatMessage conn connInfo
+ (signedMsg_, ChatMessage {chatVRange, chatMsgEvent}) <- parseChatMessage' conn connInfo
case chatMsgEvent of
XContact p xContactId_ welcomeMsgId_ requestMsg_ -> profileContactRequest invId chatVRange p xContactId_ welcomeMsgId_ requestMsg_ pqSupport
- XMember p joiningMemberId joiningMemberKey -> memberJoinRequestViaRelay invId chatVRange p joiningMemberId joiningMemberKey
+ XMember p joiningMemberId joiningMemberKey viaRelay -> memberJoinRequestViaRelay invId chatVRange signedMsg_ p joiningMemberId joiningMemberKey viaRelay
XInfo p -> profileContactRequest invId chatVRange p Nothing Nothing Nothing pqSupport
XGrpRelayInv groupRelayInv -> xGrpRelayInv invId chatVRange groupRelayInv
XGrpRelayTest challenge _ -> xGrpRelayTest invId chatVRange challenge
@@ -1364,13 +1400,13 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
CFSetShortLink ->
case (ucGroupId_, auData) of
(Just groupId, UserContactLinkData UserContactData {relays = relayLinks}) -> do
- (gInfo, gLink, relays, relaysChanged, newlyActiveLinks) <- withStore $ \db -> do
+ (gInfo, gLink, relays, relaysChanged, newlyActiveLinks, newlyActiveGMIds) <- withStore $ \db -> do
gInfo <- getGroupInfo db cxt user groupId
gLink <- getGroupLink db user gInfo
relays <- liftIO $ getGroupRelays db gInfo
- (relays', changed, newlyActive) <- liftIO $ foldrM (updateRelay db) ([], False, []) relays
+ (relays', changed, newlyActiveLinks, newlyActiveGMIds) <- liftIO $ foldrM (updateRelay db) ([], False, [], []) relays
liftIO $ setGroupInProgressDone db gInfo
- pure (gInfo, gLink, relays', changed, newlyActive)
+ pure (gInfo, gLink, relays', changed, newlyActiveLinks, newlyActiveGMIds)
toView $ CEvtGroupLinkDataUpdated user gInfo gLink relays relaysChanged
let GroupSummary {publicMemberCount} = groupSummary gInfo
-- Owner is counted in publicMemberCount; > 1 means at least one subscriber.
@@ -1388,14 +1424,16 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
unless (null recipients) $
void $ sendGroupMessages user gInfo Nothing False recipients events
where
- updateRelay :: DB.Connection -> GroupRelay -> ([GroupRelay], Bool, [ShortLinkContact]) -> IO ([GroupRelay], Bool, [ShortLinkContact])
- updateRelay db relay@GroupRelay {relayLink, relayStatus} (acc, changed, newlyActive) =
+ updateRelay :: DB.Connection -> GroupRelay -> ([GroupRelay], Bool, [ShortLinkContact], [GroupMemberId]) -> IO ([GroupRelay], Bool, [ShortLinkContact], [GroupMemberId])
+ updateRelay db relay@GroupRelay {groupMemberId, relayLink, relayStatus} (acc, changed, newlyActiveLinks, newlyActiveGMIds) =
case relayLink of
Just rLink
- | rLink `elem` relayLinks && relayStatus == RSAccepted -> do
+ -- version is gated upstream at publish (getPublishableGroupRelays): an RSAccepted relay
+ -- whose link is in the published data is necessarily pre-roster, so activate it too
+ | rLink `elem` relayLinks && (relayStatus == RSAcknowledgedRoster || relayStatus == RSAccepted) -> do
relay' <- updateRelayStatus db relay RSActive
- pure (relay' : acc, True, rLink : newlyActive)
- | rLink `elem` relayLinks -> pure (relay : acc, changed, newlyActive)
+ pure (relay' : acc, True, rLink : newlyActiveLinks, groupMemberId : newlyActiveGMIds)
+ | rLink `elem` relayLinks -> pure (relay : acc, changed, newlyActiveLinks, newlyActiveGMIds)
| relayStatus == RSActive -> do
-- Relay link absent from link data — deactivate.
-- RSAccepted relays are not deactivated: their own link data update
@@ -1404,8 +1442,8 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
-- TODO the SMP server, but this owner won't receive a LINK callback for it
-- TODO (LINK only fires in response to own setConnShortLink calls).
relay' <- updateRelayStatus db relay RSInactive
- pure (relay' : acc, True, newlyActive)
- _ -> pure (relay : acc, changed, newlyActive)
+ pure (relay' : acc, True, newlyActiveLinks, newlyActiveGMIds)
+ _ -> pure (relay : acc, changed, newlyActiveLinks, newlyActiveGMIds)
_ -> throwChatError $ CECommandError "LINK event expected for a group link only"
_ -> throwChatError $ CECommandError "unexpected cmdFunction"
MERR _ err -> do
@@ -1446,12 +1484,12 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
-- they will be updated after connection is accepted.
upsertDirectRequestItem cd (requestMsg_, prevSharedMsgId_)
Nothing -> do
- void $ createChatItem user (CDDirectSnd ct) False CIChatBanner Nothing (Just epochStart)
+ void $ createChatItem user (CDDirectSnd ct) False CIChatBanner Nothing Nothing (Just epochStart)
let e2eContent = CIRcvDirectE2EEInfo $ e2eInfoEncrypted $ Just $ CR.pqSupportToEnc $ reqPQSup
- void $ createChatItem user cd False e2eContent Nothing Nothing
+ void $ createChatItem user cd False e2eContent Nothing Nothing Nothing
void $ createFeatureEnabledItems_ user ct
forM_ (autoReply addressSettings) $ \mc -> forM_ welcomeSharedMsgId $ \sharedMsgId ->
- createChatItem user (CDDirectSnd ct) False (CISndMsgContent mc) (Just sharedMsgId) Nothing
+ createChatItem user (CDDirectSnd ct) False (CISndMsgContent mc) (Just sharedMsgId) Nothing Nothing
mapM (createRequestItem cd) requestMsg_
case autoAccept of
Nothing -> do
@@ -1476,13 +1514,13 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
-- they will be updated after connection is accepted.
upsertBusinessRequestItem cd (requestMsg_, prevSharedMsgId_)
Nothing -> do
- void $ createChatItem user (CDGroupSnd gInfo Nothing) False CIChatBanner Nothing (Just epochStart)
+ void $ createChatItem user (CDGroupSnd gInfo Nothing) False CIChatBanner Nothing Nothing (Just epochStart)
-- TODO [short links] possibly, we can just keep them created where they are created on the business side due to auto-accept
-- let e2eContent = CIRcvGroupE2EEInfo $ E2EInfo $ Just False -- no PQ encryption in groups
- -- void $ createChatItem user cd False e2eContent Nothing Nothing
+ -- void $ createChatItem user cd False e2eContent Nothing Nothing Nothing
-- void $ createFeatureEnabledItems_ user ct
forM_ (autoReply addressSettings) $ \arMC -> forM_ welcomeSharedMsgId $ \sharedMsgId ->
- createChatItem user (CDGroupSnd gInfo Nothing) False (CISndMsgContent arMC) (Just sharedMsgId) Nothing
+ createChatItem user (CDGroupSnd gInfo Nothing) False (CISndMsgContent arMC) (Just sharedMsgId) Nothing Nothing
mapM (createRequestItem cd) requestMsg_
toView $ CEvtAcceptingBusinessRequest user gInfo
where
@@ -1546,7 +1584,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
upsertBusinessRequestItem (CDChannelRcv _ _) = const $ pure Nothing
createRequestItem :: ChatTypeI c => ChatDirection c 'MDRcv -> (SharedMsgId, MsgContent) -> CM AChatItem
createRequestItem cd (sharedMsgId, mc) = do
- aci <- createChatItem user cd False (CIRcvMsgContent mc) (Just sharedMsgId) Nothing
+ aci <- createChatItem user cd False (CIRcvMsgContent mc) (Just sharedMsgId) Nothing Nothing
toView $ CEvtNewChatItems user [aci]
pure aci
upsertRequestItem :: ChatTypeI c => ChatDirection c 'MDRcv -> ((SharedMsgId, MsgContent) -> CM (Maybe AChatItem)) -> (SharedMsgId -> CM ()) -> (Maybe (SharedMsgId, MsgContent), Maybe SharedMsgId) -> CM (Maybe AChatItem)
@@ -1574,7 +1612,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
messageError "processContactConnMessage: chat version range incompatible for accepting group join request"
| otherwise -> do
let profileMode = ExistingIncognito <$> incognitoMembershipProfile gInfo
- mem <- acceptGroupJoinRequestAsync user uclId gInfo invId chatVRange p xContactId_ Nothing welcomeMsgId_ acceptance useRole profileMode Nothing
+ mem <- acceptGroupJoinRequestAsync user uclId gInfo invId chatVRange p xContactId_ Nothing welcomeMsgId_ acceptance useRole profileMode Nothing Nothing
(gInfo', mem', scopeInfo) <- mkGroupChatScope gInfo mem
createInternalChatItem user (CDGroupRcv gInfo' scopeInfo mem') (CIRcvGroupEvent RGEInvitedViaGroupLink) Nothing
toView $ CEvtAcceptingGroupJoinRequestMember user gInfo' mem'
@@ -1613,19 +1651,37 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
where
User {userChatRelay} = user
-- TODO [relays] owner, relays: TBC how to communicate member rejection rules from owner to relays
- -- TODO [relays] relay: TBC communicate rejection when memberId already exists (currently checked in createJoiningMember)
- memberJoinRequestViaRelay :: InvitationId -> VersionRangeChat -> Profile -> MemberId -> MemberKey -> CM ()
- memberJoinRequestViaRelay invId chatVRange p joiningMemberId joiningMemberKey = do
+ memberJoinRequestViaRelay :: InvitationId -> VersionRangeChat -> Maybe SignedMsg -> Profile -> MemberId -> MemberKey -> Maybe MemberId -> CM ()
+ memberJoinRequestViaRelay invId chatVRange signedMsg_ p joiningMemberId joiningMemberKey@(MemberKey joiningKey) viaRelay = do
(_ucl, gLinkInfo_) <- withStore $ \db -> getUserContactLinkById db userId uclId
case gLinkInfo_ of
Just GroupLinkInfo {groupId, memberRole = gLinkMemRole} -> do
gInfo <- withStore $ \db -> getGroupInfo db cxt user groupId
- mem <- acceptGroupJoinRequestAsync user uclId gInfo invId chatVRange p Nothing (Just joiningMemberId) Nothing GAAccepted gLinkMemRole Nothing (Just joiningMemberKey)
+ existing_ <- withStore' $ \db -> eitherToMaybe <$> runExceptT (getGroupMemberByMemberId db cxt user gInfo joiningMemberId)
+ case existing_ of
+ Just rosterMem
+ -- a privileged memberId's key is owner-authoritative (the roster); the joiner must prove
+ -- possession of that exact key, otherwise this is an attempt to impersonate it
+ | isRosterRole (memberRole' rosterMem) ->
+ if verifyKey gInfo rosterMem
+ then acceptJoin gInfo (Just rosterMem) (memberRole' rosterMem)
+ else messageError "memberJoinRequestViaRelay: rejected join claiming privileged memberId (key mismatch or invalid signature)"
+ _ -> acceptJoin gInfo Nothing gLinkMemRole
+ Nothing ->
+ messageError "memberJoinRequestViaRelay: no group link info for relay link"
+ where
+ -- replay defense: the viaRelay == own memberId check (viaRelay is in the signed body); without it a sibling relay could replay a privileged member's signed join
+ verifyKey gInfo rosterMem = case (signedMsg_, groupKeys gInfo) of
+ (Just SignedMsg {chatBinding = CBGroup, signatures, signedBody}, Just GroupKeys {publicGroupId}) ->
+ memberPubKey rosterMem == Just joiningKey
+ && verifyGroupSig joiningKey publicGroupId joiningMemberId signatures signedBody
+ && viaRelay == Just (memberId' (membership gInfo))
+ _ -> False
+ acceptJoin gInfo existingMem_ acceptRole = do
+ mem <- acceptGroupJoinRequestAsync user uclId gInfo invId chatVRange p Nothing (Just joiningMemberId) Nothing GAAccepted acceptRole Nothing (Just joiningMemberKey) existingMem_
(gInfo', mem', scopeInfo) <- mkGroupChatScope gInfo mem
createInternalChatItem user (CDGroupRcv gInfo' scopeInfo mem') (CIRcvGroupEvent RGEInvitedViaGroupLink) Nothing
toView $ CEvtAcceptingGroupJoinRequestMember user gInfo' mem'
- Nothing ->
- messageError "memberJoinRequestViaRelay: no group link info for relay link"
muteEventInChannel :: GroupInfo -> GroupMember -> Bool
muteEventInChannel gInfo@GroupInfo {membership} m =
@@ -2157,7 +2213,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
unless (maybe False memberBlocked m') $ autoAcceptFile file_
processFileInv gInfo' m' =
let fileMember_ = if sentAsGroup then Nothing else m'
- in processFileInvitation fInv_ content $ \db -> createRcvGroupFileTransfer db userId gInfo' fileMember_
+ in processFileInvitation fInv_ content $ \db -> createRcvGroupFileTransfer db userId gInfo' fileMember_ FTNormal sharedMsgId_
newChatItem gInfo' m' scopeInfo ciContent ciFile_ timed live = do
let mentions' = if maybe False memberBlocked m' then M.empty else mentions
(ci, cInfo) <- saveRcvCI gInfo' m' scopeInfo ciContent ciFile_ timed live mentions'
@@ -2365,7 +2421,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
ChatConfig {fileChunkSize} <- asks config
fInv'@FileInvitation {fileName, fileSize} <- validateFileInvitation fInv
inline <- receiveInlineMode fInv' Nothing fileChunkSize
- RcvFileTransfer {fileId, xftpRcvFile} <- withStore $ \db -> createRcvGroupFileTransfer db userId gInfo (Just m) fInv' inline fileChunkSize
+ RcvFileTransfer {fileId, xftpRcvFile} <- withStore $ \db -> createRcvGroupFileTransfer db userId gInfo (Just m) FTNormal sharedMsgId_ fInv' inline fileChunkSize
let fileProtocol = if isJust xftpRcvFile then FPXFTP else FPSMP
ciFile = Just $ CIFile {fileId, fileName, fileSize, fileSource = Nothing, fileStatus = CIFSRcvInvitation, fileProtocol}
content = ciContentNoParse $ CIRcvMsgContent $ MCFile ""
@@ -2461,10 +2517,17 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
ft <- withStore $ \db -> getDirectFileIdBySharedMsgId db user ct sharedMsgId >>= getRcvFileTransfer db user
receiveInlineChunk ft chunk meta
- bFileChunkGroup :: GroupInfo -> SharedMsgId -> FileChunk -> MsgMeta -> CM ()
- bFileChunkGroup GroupInfo {groupId} sharedMsgId chunk meta = do
- ft <- withStore $ \db -> getGroupFileIdBySharedMsgId db userId groupId sharedMsgId >>= getRcvFileTransfer db user
- receiveInlineChunk ft chunk meta
+ -- A group BFileChunk is a normal inline file chunk or a roster blob chunk, both located by
+ -- (group_id, shared_msg_id). A chunk matching no in-flight transfer (an orphaned re-served roster
+ -- chunk, or a missing normal file) is ignored; the outer withAckMessage acks it.
+ bFileChunkGroup :: GroupInfo -> GroupMember -> SharedMsgId -> FileChunk -> MsgMeta -> CM ()
+ bFileChunkGroup gInfo@GroupInfo {groupId} fromMember sharedMsgId chunk meta = do
+ fileId_ <- withStore' $ \db -> getGroupRcvFileId db userId groupId (groupMemberId' fromMember) sharedMsgId
+ forM_ fileId_ $ \fileId -> do
+ ft <- withStore $ \db -> getRcvFileTransfer db user fileId
+ case fileType ft of
+ FTRoster -> receiveRosterChunk gInfo ft meta chunk
+ FTNormal -> receiveInlineChunk ft chunk meta
receiveInlineChunk :: RcvFileTransfer -> FileChunk -> MsgMeta -> CM ()
receiveInlineChunk RcvFileTransfer {fileId, fileStatus = RFSNew} FileChunk {chunkNo} _
@@ -2474,7 +2537,18 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
case chunk of
FileChunk {chunkNo} -> when (chunkNo == 1) $ startReceivingFile user fileId
_ -> pure ()
- receiveFileChunk ft Nothing meta chunk
+ receiveFileChunk Nothing ft Nothing meta chunk
+
+ -- A roster re-serve re-sends the blob from chunk 1; discard any partial first, else chunk 1 over a
+ -- partial is out-of-order (RcvChunkError) and appending after the stale prefix corrupts the blob.
+ receiveRosterChunk :: GroupInfo -> RcvFileTransfer -> MsgMeta -> FileChunk -> CM ()
+ receiveRosterChunk gInfo ft meta chunk = do
+ case chunk of
+ FileChunk {chunkNo} | chunkNo == 1 -> do
+ last_ <- withStore' $ \db -> getRcvFileLastChunkNo db ft
+ when (isJust last_) $ resetRosterPartialChunks ft
+ _ -> pure ()
+ receiveFileChunk (Just gInfo) ft Nothing meta chunk
xFileCancelGroup :: GroupInfo -> Maybe GroupMember -> SharedMsgId -> CM (Maybe DeliveryTaskContext)
xFileCancelGroup g@GroupInfo {groupId} m_ sharedMsgId = do
@@ -2532,7 +2606,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
when (fromMemId == memId) $ throwChatError CEGroupDuplicateMemberId
-- [incognito] if direct connection with host is incognito, create membership using the same incognito profile
(gInfo@GroupInfo {groupId, localDisplayName, groupProfile, membership}, hostId) <- withStore $ \db -> createGroupInvitation db cxt user ct inv customUserProfileId
- void $ createChatItem user (CDGroupSnd gInfo Nothing) False CIChatBanner Nothing (Just epochStart)
+ void $ createChatItem user (CDGroupSnd gInfo Nothing) False CIChatBanner Nothing Nothing (Just epochStart)
let GroupMember {groupMemberId, memberId = membershipMemId} = membership
if sameGroupLinkId groupLinkId groupLinkId'
then do
@@ -2996,40 +3070,63 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
_ -> pure (conn', Nothing)
xGrpMemNew :: GroupInfo -> GroupMember -> MemberInfo -> Maybe MsgScope -> RcvMessage -> UTCTime -> CM (Maybe DeliveryJobScope)
- xGrpMemNew gInfo m memInfo@(MemberInfo memId memRole _ _ _) msgScope_ msg brokerTs = do
- if useRelays' gInfo && isRelay m
- then when (memRole > GRMember) $ throwChatError $ CEException "x.grp.mem.new: relay cannot introduce role above member in channel"
- else checkHostRole m memRole
+ xGrpMemNew gInfo m memInfo@(MemberInfo memId memRole _ _ assertedKey_) msgScope_ msg brokerTs = do
+ let fromRelay = useRelays' gInfo && isRelay m
+ unless fromRelay $ checkHostRole m memRole
if sameMemberId memId (membership gInfo)
then pure Nothing
- else do
+ else
withStore' (\db -> runExceptT $ getGroupMemberByMemberId db cxt user gInfo memId) >>= \case
- Right unknownMember@GroupMember {memberStatus = GSMemUnknown} -> do
- (updatedMember, gInfo') <- withStore $ \db -> do
- updatedMember <- updateUnknownMemberAnnounced db cxt user m unknownMember memInfo initialStatus
- gInfo' <-
- if memberPending updatedMember
- then liftIO $ increaseGroupMembersRequireAttention db user gInfo
- else pure gInfo
- pure (updatedMember, gInfo')
- gInfo'' <- updatePublicGroupData user gInfo'
- toView $ CEvtUnknownMemberAnnounced user gInfo'' m unknownMember updatedMember
- memberAnnouncedToView updatedMember gInfo''
- pure $ deliveryJobScope updatedMember
+ Right unknownMember@GroupMember {memberStatus = GSMemUnknown}
+ -- 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
+ -- 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)
+ let assertedKey = (\(MemberKey k) -> k) <$> assertedKey_
+ -- TODO [relays] member: surface relay-key-mismatch as a dedicated event / chat item / relay state
+ when (assertedKey /= memberPubKey unknownMember) $
+ messageWarning $ "x.grp.mem.new: relay asserted key differs from roster-established key, keeping roster key, memberId=" <> safeDecodeUtf8 (strEncode memId)
+ updatedMember <- withStore $ \db -> updateRosterMemberAnnounced db cxt user m unknownMember memInfo initialStatus
+ -- roster members can't be pending, so no members-require-attention update
+ gInfo' <- updatePublicGroupData user gInfo
+ 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 ->
+ messageError "x.grp.mem.new: privileged role not established by roster" $> Nothing
+ | otherwise -> do
+ (updatedMember, gInfo') <- withStore $ \db -> do
+ updatedMember <- updateUnknownMemberAnnounced db cxt user m unknownMember memInfo initialStatus
+ gInfo' <-
+ if memberPending updatedMember
+ then liftIO $ increaseGroupMembersRequireAttention db user gInfo
+ else pure gInfo
+ pure (updatedMember, gInfo')
+ gInfo'' <- updatePublicGroupData user gInfo'
+ toView $ CEvtUnknownMemberAnnounced user gInfo'' m unknownMember updatedMember
+ memberAnnouncedToView updatedMember gInfo''
+ pure $ deliveryJobScope updatedMember
Right _
| 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 _ -> do
- (newMember, gInfo') <- withStore $ \db -> do
- newMember <- createNewGroupMember db cxt user gInfo m memInfo GCPostMember initialStatus
- gInfo' <-
- if memberPending newMember
- then liftIO $ increaseGroupMembersRequireAttention db user gInfo
- else pure gInfo
- pure (newMember, gInfo')
- gInfo'' <- updatePublicGroupData user gInfo'
- memberAnnouncedToView newMember gInfo''
- pure $ deliveryJobScope newMember
+ 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
+ | otherwise -> do
+ (newMember, gInfo') <- withStore $ \db -> do
+ newMember <- createNewGroupMember db cxt user gInfo m memInfo GCPostMember initialStatus
+ gInfo' <-
+ if memberPending newMember
+ then liftIO $ increaseGroupMembersRequireAttention db user gInfo
+ else pure gInfo
+ pure (newMember, gInfo')
+ gInfo'' <- updatePublicGroupData user gInfo'
+ memberAnnouncedToView newMember gInfo''
+ pure $ deliveryJobScope newMember
where
initialStatus = case msgScope_ of
Just (MSMember _) -> GSMemPendingReview
@@ -3068,10 +3165,12 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
messageError "x.grp.mem.intro ignored: member already exists"
Left _
| useRelays' gInfo -> do
- -- owner key must only come from link data, not from relay intro
+ -- role + key are owner-authoritative (roster); an intro establishes neither - a privileged
+ -- claim is created at the channel default with no key until the owner-signed roster confirms it
+ defaultRole <- unknownMemberRole gInfo
let memInfo' = case memInfo of
MemberInfo mId mRole v p _
- | mRole == GROwner -> MemberInfo mId mRole v p Nothing
+ | mRole >= GRMember -> MemberInfo mId defaultRole v p Nothing
_ -> memInfo
void $ withStore $ \db -> createIntroReMember db cxt user gInfo memInfo' memRestrictions
| otherwise -> do
@@ -3141,28 +3240,241 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
chatV = vr cxt `peerConnChatVersion` mcvr
withStore' $ \db -> createIntroToMemberContact db user m toMember chatV mcvr groupConnIds directConnIds customUserProfileId subMode
- xGrpMemRole :: GroupInfo -> GroupMember -> MemberId -> GroupMemberRole -> RcvMessage -> UTCTime -> CM (Maybe DeliveryJobScope)
- xGrpMemRole gInfo@GroupInfo {membership} m@GroupMember {memberRole = senderRole} memId memRole msg@RcvMessage {msgSigned} brokerTs
+ -- rollback defense (channels): apply an owner-signed role/removal only at a version >= the persisted
+ -- roster_version (not the batch-constant gInfo, which a relay can stale by reordering events in one
+ -- batch), then advance it in the same transaction; a strictly lower version is a replay and is ignored.
+ -- Only an owner sender may advance it: a non-owner signed event is rejected by the action that follows,
+ -- but must not bump roster_version first, or every later owner roster at a lower version is dropped.
+ applyAtRosterVersion :: GroupInfo -> GroupMember -> Maybe VersionRoster -> CM (Maybe DeliveryJobScope) -> CM (Maybe DeliveryJobScope)
+ applyAtRosterVersion gInfo sender rosterVer_ action
+ | not (useRelays' gInfo) = action
+ | otherwise = case rosterVer_ of
+ Nothing -> action
+ Just _ | memberRole' sender /= GROwner -> action
+ Just v -> do
+ accept <- withStore' $ \db -> do
+ cur <- getGroupRosterVersion db gInfo
+ let fresh = maybe True (v >=) cur
+ when fresh $ setGroupRosterVersion db gInfo v
+ pure fresh
+ if accept
+ then action
+ else messageWarning "x.grp.mem: roster version not newer than current, ignoring" $> Nothing
+
+ xGrpMemRole :: GroupInfo -> GroupMember -> MemberId -> GroupMemberRole -> Maybe MemberKey -> Maybe VersionRoster -> RcvMessage -> UTCTime -> CM (Maybe DeliveryJobScope)
+ xGrpMemRole gInfo@GroupInfo {membership} m@GroupMember {memberRole = senderRole} memId memRole memberKey_ rosterVer_ msg@RcvMessage {msgSigned} brokerTs
| membershipMemId == memId =
- let gInfo' = gInfo {membership = membership {memberRole = memRole}}
- in changeMemberRole gInfo' membership $ RGEUserRole memRole
- | otherwise =
- withStore' (\db -> runExceptT $ getGroupMemberByMemberId db cxt user gInfo memId) >>= \case
- Right member -> changeMemberRole gInfo member $ RGEMemberRole (groupMemberId' member) (fromLocalProfile $ memberProfile member) memRole
- Left _ -> messageError "x.grp.mem.role with unknown member ID" $> Nothing
+ applyAtRosterVersion gInfo m rosterVer_ $
+ let gInfo' = gInfo {membership = membership {memberRole = memRole}}
+ in changeMemberRole gInfo' membership False (\db -> updateGroupMemberRole db user membership memRole) $ RGEUserRole memRole
+ | otherwise = applyAtRosterVersion gInfo m rosterVer_ $ do
+ defaultRole <- unknownMemberRole gInfo
+ -- an owner-signed event with a key TOFU-creates an unknown member only for a roster role; else a plain lookup
+ let allowCreate = useRelays' gInfo && senderRole == GROwner && isRosterRole memRole && isJust memberKey_
+ withStore' (\db -> runExceptT $ getCreateUnknownGMByMemberId db cxt user gInfo memId (nameFromMemberId memId) defaultRole allowCreate) >>= \case
+ Right (Just (member, created))
+ -- just created (keyless, and allowCreate ensured the event carries its key): pin key + role
+ | created, Just (MemberKey pubKey) <- memberKey_ ->
+ let gEvent = RGEMemberRole (groupMemberId' member) (fromLocalProfile $ memberProfile member) memRole
+ in changeMemberRole gInfo member created (\db -> void $ applyMemberKeyRole db member pubKey memRole) gEvent
+ -- known member: apply the role (its key is established via roster/intro; the event's key is ignored)
+ | otherwise ->
+ let gEvent = RGEMemberRole (groupMemberId' member) (fromLocalProfile $ memberProfile member) memRole
+ in changeMemberRole gInfo member created (\db -> updateGroupMemberRole db user member memRole) gEvent
+ -- in relay groups the roster may deliver role update for previously-unknown privileged members
+ _ | useRelays' gInfo -> pure Nothing
+ | otherwise -> messageError "x.grp.mem.role with unknown member ID" $> Nothing
where
GroupMember {memberId = membershipMemId} = membership
- changeMemberRole gInfo' member@GroupMember {memberRole = fromRole} gEvent
+ -- applyMember writes the change (role, or role + pinned key for a freshly TOFU-created member);
+ -- the delivery scope (relay forwarding) is computed on the pre-change role
+ changeMemberRole gInfo' member@GroupMember {memberRole = fromRole} created applyMember gEvent
| senderRole < maximum ([GRAdmin, fromRole, memRole] :: [GroupMemberRole]) =
messageError "x.grp.mem.role with insufficient member permissions" $> Nothing
+ | useRelays' gInfo && (isRosterRole memRole || isRosterRole fromRole) && senderRole /= GROwner =
+ messageError "x.grp.mem.role: only the owner can change member, moderator and admin roles in relay groups" $> Nothing
+ -- a forwarded role event the roster already applied is a no-op; suppress it.
+ -- a just-created member is keyless here, so fall through to pin its owner-attested key.
+ | useRelays' gInfo && not created && fromRole == memRole = pure $ memberEventDeliveryScope member
| otherwise = do
- withStore' $ \db -> updateGroupMemberRole db user member memRole
+ withStore' applyMember
(gInfo'', m', scopeInfo) <- mkGroupChatScope gInfo' m
(ci, cInfo) <- saveRcvChatItemNoParse user (CDGroupRcv gInfo'' scopeInfo m') msg brokerTs (CIRcvGroupEvent gEvent)
groupMsgToView cInfo ci
toView CEvtMemberRole {user, groupInfo = gInfo'', byMember = m', member = member {memberRole = memRole}, fromRole, toRole = memRole, msgSigned}
pure $ memberEventDeliveryScope member
+ -- The header only starts the transfer; the roster is applied and the version bumped only at
+ -- blob completion, so a withheld or corrupted blob leaves the last good roster intact.
+ -- fromMember is the relay that delivered THIS roster copy (the owner on a relay receiving directly,
+ -- a relay on a member receiving a forward); author is the owner who signed it.
+ xGrpRoster :: GroupInfo -> GroupMember -> GroupMember -> GroupRoster -> VerifiedMsg e -> Maybe SharedMsgId -> UTCTime -> CM (Maybe DeliveryJobScope)
+ xGrpRoster gInfo fromMember author GroupRoster {version = newVer, fileInv = InlineFileInvitation {fileSize, fileDigest}} verifiedMsg sharedMsgId_ brokerTs
+ -- only an owner may sign a roster; otherwise a relay could route it as a member whose key it controls
+ | memberRole' author /= GROwner = messageError "x.grp.roster: not signed by an owner" $> Nothing
+ | fileSize > maxGroupRosterBytes = messageError "x.grp.roster: roster blob size exceeds limit" $> Nothing
+ | otherwise = case verifiedMsg of
+ -- unreachable: XGrpRoster is in requiresSignature, so withVerifiedMsg rejected unsigned
+ VMUnsigned _ -> pure Nothing
+ VMSigned _ sm _ -> case sharedMsgId_ of
+ Nothing -> Nothing <$ messageWarning "x.grp.roster: missing shared message id"
+ Just sharedMsgId -> do
+ -- per-source pending version (THIS relay's own in-flight transfer), not a single group slot
+ pendingVer_ <- withStore' $ \db -> getRosterTransferVersion db gInfo (groupMemberId' fromMember)
+ -- accept a version not below BOTH applied and this source's pending (>=, Nothing below 0): a preceding
+ -- signed event may have already advanced rosterVersion to this blob's version; a lower one is a downgrade.
+ if newVer `notBelowRoster` rosterVersion gInfo && newVer `notBelowRoster` pendingVer_
+ then startRosterTransfer sm sharedMsgId
+ else pure Nothing
+ where
+ startRosterTransfer sm sharedMsgId = do
+ -- supersede THIS source's own in-flight transfer (older version or a restart); other relays' transfers are independent
+ cleanupRosterTransfer gInfo (groupMemberId' fromMember)
+ let relayHdr = if isUserGrpFwdRelay gInfo then Just sm else Nothing
+ chSize <- asks $ fileChunkSize . config
+ let rosterFInv = FileInvitation {fileName = "roster", fileSize, fileDigest = Nothing, fileConnReq = Nothing, fileInline = Just IFMSent, fileDescr = Nothing}
+ -- transfer record + its scratch file in one transaction (file owned by the transfer, keyed per source)
+ rft@RcvFileTransfer {fileId} <- withStore $ \db -> do
+ transferId <- liftIO $ createRosterTransfer db gInfo (groupMemberId' fromMember) newVer fileDigest (groupMemberId' author) brokerTs relayHdr
+ createRosterRcvFile db userId gInfo fromMember transferId sharedMsgId rosterFInv (Just IFMSent) (fromIntegral chSize)
+ -- accept the chat-item-free file before chunk 1 (FIFO before it) so chunk 1 isn't rejected on RFSNew
+ -- transient scratch file (consumed into roster_blob, then deleted): temp folder, not the user's files folder / Downloads
+ tmpDir <- lift getChatTempDirectory
+ rosterTs <- liftIO getCurrentTime
+ let GroupInfo {groupId = gId} = gInfo
+ rosterFile = "roster_" <> show gId <> "_" <> show (groupMemberId' fromMember) <> "_" <> formatTime defaultTimeLocale "%Y%m%d_%H%M%S" rosterTs
+ filePath <- getRcvFilePath fileId (Just tmpDir) rosterFile False
+ withStore' $ \db -> startRcvInlineFT db user rft filePath (Just IFMSent)
+ pure Nothing
+
+ -- Roster version comparison treating Nothing (un-materialized) as below 0. Non-strict (>=) so a relay
+ -- accepts the owner's blob at the version a preceding signed event already advanced rosterVersion to.
+ notBelowRoster :: VersionRoster -> Maybe VersionRoster -> Bool
+ notBelowRoster v = maybe True (v >=)
+
+ -- Blob arrived: verify the owner-attested digest over the plaintext and guard against
+ -- downgrade before applying; on a relay, ack the owner and re-serve to members.
+ rosterCompletion :: GroupInfo -> RcvFileTransfer -> CM ()
+ rosterCompletion gInfo RcvFileTransfer {fileId, fileStatus} =
+ withStore' (\db -> getRosterTransfer db fileId) >>= \case
+ -- defensive: the file always has its transfer (created together, deleted together)
+ Nothing -> lift (closeFileHandle fileId rcvFiles) >> forM_ (rosterFilePath fileStatus) removeFsFile
+ Just RcvRosterTransfer {rosterTransferId = transferId, rosterTransferVersion = pendingVer, rosterTransferDigest = pendingDigest, rosterTransferOwnerGMId = ownerGMId, rosterTransferBrokerTs = rosterBrokerTs, rosterTransferHeader = header_} -> do
+ owner_ <- withStore' $ \db -> eitherToMaybe <$> runExceptT (getGroupMemberById db cxt user ownerGMId)
+ blob <- readAssembledRoster
+ let isRelay = isUserGrpFwdRelay gInfo
+ ackErr err = do
+ cleanupRosterTransferById transferId
+ when isRelay $ forM_ owner_ $ \owner -> sendRosterAck gInfo owner pendingVer (Just err)
+ if FD.FileDigest (LC.sha512Hash (LB.fromStrict blob)) /= pendingDigest
+ then ackErr "relay could not verify the roster blob"
+ else case parseAll rosterBlobP blob of
+ Left _ -> ackErr "relay could not parse the roster blob"
+ Right entries -> case owner_ of
+ Nothing -> cleanupRosterTransferById transferId
+ Just author -> do
+ defaultRole <- unknownMemberRole gInfo
+ -- gate against the persisted roster_version inside the apply transaction: a roster from another
+ -- relay (or a preceding signed event) may already have advanced it past this one; a stale
+ -- completion (e.g. relay1 sent v5 then v6, relay2's v5 completes after v6) is rejected.
+ results_ <- withStore $ \db -> do
+ cur <- liftIO $ getGroupRosterVersion db gInfo
+ if maybe False (pendingVer <) cur
+ then pure Nothing
+ else do
+ res <- processRosterEntries db gInfo defaultRole (validateGroupRoster entries)
+ liftIO $ setGroupLiveRoster db gInfo pendingVer ownerGMId rosterBrokerTs header_ blob
+ pure (Just res)
+ cleanupRosterTransferById transferId
+ forM_ results_ $ \results -> do
+ emitRosterResults gInfo author rosterBrokerTs results
+ -- ack while setting up (own status accepted/acknowledged); a serving (active) relay must not ack broadcasts.
+ when (isRelay && (relayOwnStatus gInfo == Just RSAccepted || relayOwnStatus gInfo == Just RSAcknowledgedRoster)) $ do
+ sendRosterAck gInfo author pendingVer Nothing
+ withStore' $ \db -> void $ updateRelayOwnStatusFromTo db gInfo RSAccepted RSAcknowledgedRoster
+ where
+ rosterFilePath = \case
+ RFSAccepted p -> Just p
+ RFSConnected p -> Just p
+ RFSComplete p -> Just p
+ _ -> Nothing
+ readAssembledRoster = case rosterFilePath fileStatus of
+ Just fp -> readAt fp
+ Nothing -> throwChatError $ CEInternalError "roster file not in progress"
+ readAt fp = lift (toFSFilePath fp) >>= liftIO . B.readFile
+
+ -- TOFU-apply an owner-signed (key, role) to a resolved member: pin the key if absent; for a keyed
+ -- member keep the trusted key (Left = reject a different one), else update the role. Right
+ -- (Just (member-at-new-role, fromRole)) when the role changed, Right Nothing when already current.
+ applyMemberKeyRole :: DB.Connection -> GroupMember -> C.PublicKeyEd25519 -> GroupMemberRole -> IO (Either MemberId (Maybe (GroupMember, GroupMemberRole)))
+ applyMemberKeyRole db m pubKey role = case memberPubKey m of
+ Just k
+ | k /= pubKey -> pure (Left (memberId' m))
+ | memberRole' m == role -> pure (Right Nothing)
+ | otherwise -> updateGroupMemberRole db user m role $> Right (Just (m {memberRole = role}, memberRole' m))
+ Nothing -> setGroupMemberKeyRole db m pubKey role $> Right (Just (m {memberRole = role}, memberRole' m))
+
+ -- TOFU apply: pin each member's key on first use, then update roles.
+ processRosterEntries :: DB.Connection -> GroupInfo -> GroupMemberRole -> [RosterMember] -> ExceptT StoreError IO ([MemberId], [(GroupMember, GroupMemberRole, Bool)])
+ processRosterEntries db gInfo defaultRole entries = do
+ let rosterIds = map (\RosterMember {memberId} -> memberId) entries
+ (cs, as) <- foldrM applyRosterEntry ([], []) entries
+ currentPriv <- liftIO $ getGroupRosterMembers db cxt user gInfo
+ reverted <- liftIO $ fmap catMaybes $ forM currentPriv $ \m ->
+ if memberId' m `notElem` rosterIds
+ then updateGroupMemberRole db user m defaultRole $> Just ((m :: GroupMember) {memberRole = defaultRole}, memberRole' m, False)
+ else pure Nothing
+ pure (cs, as <> reverted)
+ where
+ -- entry-level failure (StoreError or IO exception) is muted; the entry is dropped
+ applyRosterEntry RosterMember {memberId, key = MemberKey pubKey, role} (cs, as) =
+ ( getCreateUnknownGMByMemberId db cxt user gInfo memberId (nameFromMemberId memberId) defaultRole True >>= \case
+ Nothing -> pure (cs, as)
+ Just (m, created) -> liftIO (applyMemberKeyRole db m pubKey role) >>= \case
+ Left mid -> pure (mid : cs, as)
+ Right Nothing -> pure (cs, as)
+ Right (Just (rm, fromR)) -> pure (cs, (rm, fromR, created) : as)
+ )
+ `catchAllErrors` \_ -> pure (cs, as)
+
+ emitRosterResults :: GroupInfo -> GroupMember -> UTCTime -> ([MemberId], [(GroupMember, GroupMemberRole, Bool)]) -> CM ()
+ emitRosterResults gInfo author rosterBrokerTs (conflicts, applied) = do
+ forM_ conflicts $ \mid' ->
+ messageWarning $ "x.grp.roster: member key conflict, keeping trusted key, memberId=" <> safeDecodeUtf8 (strEncode mid')
+ forM_ applied $ \(member, fromRole, created) ->
+ unless created $ createItems member fromRole
+ where
+ createItems member fromRole = do
+ let toRole = memberRole' member
+ gEvent = RGEMemberRole (groupMemberId' member) (fromLocalProfile $ memberProfile member) toRole
+ (gInfo', author', scopeInfo) <- mkGroupChatScope gInfo author
+ ci <- createChatItem user (CDGroupRcv gInfo' scopeInfo author') False (CIRcvGroupEvent gEvent) Nothing (Just MSSVerified) (Just rosterBrokerTs)
+ toView $ CEvtNewChatItems user [ci]
+ toView CEvtMemberRole {user, groupInfo = gInfo', byMember = author', member, fromRole, toRole, msgSigned = Just MSSVerified}
+
+ sendRosterAck :: GroupInfo -> GroupMember -> VersionRoster -> Maybe Text -> CM ()
+ sendRosterAck gInfo owner ackVer err = void $ sendGroupMessage' user gInfo [owner] (XGrpRosterAck ackVer err)
+
+ xGrpRosterAck :: GroupInfo -> GroupMember -> VersionRoster -> Maybe Text -> CM ()
+ xGrpRosterAck gInfo m ackVer err = do
+ relay_ <- withStore' $ \db -> eitherToMaybe <$> runExceptT (getGroupRelayByGMId db (groupMemberId' m))
+ case relay_ of
+ Just relay@GroupRelay {relayStatus = RSAccepted} -> case err of
+ Nothing
+ | rosterVersion gInfo == Just ackVer -> do
+ (relay', gLink) <- withStore $ \db -> do
+ relay' <- liftIO $ updateRelayStatus db relay RSAcknowledgedRoster
+ gLink <- getGroupLink db user gInfo
+ pure (relay', gLink)
+ setGroupLinkDataAsync user gInfo gLink
+ toView $ CEvtGroupRelayUpdated user gInfo m relay'
+ | otherwise -> messageWarning "x.grp.roster.ack: stale version, awaiting ack for the current roster"
+ Just e -> do
+ relay' <- withStore' $ \db -> updateRelayStatusFromTo db relay RSAccepted RSRejected
+ toView $ CEvtGroupRelayUpdated user gInfo m relay'
+ messageError $ "x.grp.roster.ack: relay could not save roster, marked rejected: " <> e
+ _ -> pure ()
+
checkHostRole :: GroupMember -> GroupMemberRole -> CM ()
checkHostRole GroupMember {memberRole, localDisplayName} memRole =
when (memberRole < GRAdmin || memberRole < memRole) $ throwChatError (CEGroupContactRole localDisplayName)
@@ -3207,11 +3519,11 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
withStore $ \db -> setMemberVectorRelationConnected db sendingMem refMem MRSubjectConnected
withStore $ \db -> setMemberVectorRelationConnected db refMem sendingMem MRReferencedConnected
- xGrpMemDel :: GroupInfo -> GroupMember -> MemberId -> Bool -> VerifiedMsg 'Json -> RcvMessage -> UTCTime -> Bool -> CM (Maybe DeliveryJobScope)
- xGrpMemDel gInfo@GroupInfo {membership} m@GroupMember {memberRole = senderRole} memId withMessages verifiedMsg msg@RcvMessage {msgSigned} brokerTs forwarded = do
+ xGrpMemDel :: GroupInfo -> GroupMember -> MemberId -> Bool -> Maybe VersionRoster -> VerifiedMsg 'Json -> RcvMessage -> UTCTime -> Bool -> CM (Maybe DeliveryJobScope)
+ xGrpMemDel gInfo@GroupInfo {membership} m@GroupMember {memberRole = senderRole} memId withMessages rosterVer_ verifiedMsg msg@RcvMessage {msgSigned} brokerTs forwarded = do
let GroupMember {memberId = membershipMemId} = membership
if membershipMemId == memId
- then checkRole membership $ do
+ then applyAtRosterVersion gInfo m rosterVer_ $ checkRole membership $ do
deleteGroupLinkIfExists user gInfo
-- TODO [relays] possible improvement is to immediately delete rcv queues if isUserGrpFwdRelay
unless (isUserGrpFwdRelay gInfo) $ deleteGroupConnections user gInfo False
@@ -3223,7 +3535,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
deleteMemberItem msg gInfo RGEUserDeleted
toView $ CEvtDeletedMemberUser user gInfo {membership = membership'} m withMessages msgSigned
pure $ Just DJSGroup {jobSpec = DJRelayRemoved}
- else
+ else applyAtRosterVersion gInfo m rosterVer_ $
withStore' (\db -> runExceptT $ getGroupMemberByMemberId db cxt user gInfo memId) >>= \case
Left _ -> do
messageError "x.grp.mem.del with unknown member ID"
@@ -3474,7 +3786,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
processForwardedMsg :: VerifiedMsg 'Json -> Maybe GroupMember -> CM ()
processForwardedMsg verifiedMsg author_ = do
rcvMsg_ <- saveGroupFwdRcvMsg user gInfo m author_ verifiedMsg brokerTs
- forM_ rcvMsg_ $ \rcvMsg@RcvMessage {chatMsgEvent = ACME _ event} -> case event of
+ forM_ rcvMsg_ $ \rcvMsg@RcvMessage {sharedMsgId_, chatMsgEvent = ACME _ event} -> case event of
XMsgNew mc ->
void $ memberCanSend author_ scope $ newGroupContentMessage gInfo author_ mc rcvMsg msgTs True
where
@@ -3489,13 +3801,14 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
XInfo p -> withAuthor XInfo_ $ \author -> void $ xInfoMember gInfo author p rcvMsg msgTs
XGrpRelayNew rl -> withAuthor XGrpRelayNew_ $ \author -> void $ xGrpRelayNew gInfo author rl
XGrpMemNew memInfo msgScope -> withAuthor XGrpMemNew_ $ \author -> void $ xGrpMemNew gInfo author memInfo msgScope rcvMsg msgTs
- XGrpMemRole memId memRole -> withAuthor XGrpMemRole_ $ \author -> void $ xGrpMemRole gInfo author memId memRole rcvMsg msgTs
+ XGrpMemRole memId memRole memberKey rosterVer -> withAuthor XGrpMemRole_ $ \author -> void $ xGrpMemRole gInfo author memId memRole memberKey rosterVer rcvMsg msgTs
XGrpMemRestrict memId memRestrictions -> withAuthor XGrpMemRestrict_ $ \author -> void $ xGrpMemRestrict gInfo author memId memRestrictions rcvMsg msgTs
- XGrpMemDel memId withMessages -> withAuthor XGrpMemDel_ $ \author -> void $ xGrpMemDel gInfo author memId withMessages verifiedMsg rcvMsg msgTs True
+ XGrpMemDel memId withMessages rosterVer -> withAuthor XGrpMemDel_ $ \author -> void $ xGrpMemDel gInfo author memId withMessages rosterVer verifiedMsg rcvMsg msgTs True
XGrpLeave -> withAuthor XGrpLeave_ $ \author -> void $ xGrpLeave gInfo author rcvMsg msgTs
XGrpDel -> withAuthor XGrpDel_ $ \author -> void $ xGrpDel gInfo author rcvMsg msgTs
XGrpInfo p' -> withAuthor XGrpInfo_ $ \author -> void $ xGrpInfo gInfo author p' rcvMsg msgTs
XGrpPrefs ps' -> withAuthor XGrpPrefs_ $ \author -> void $ xGrpPrefs gInfo author ps' rcvMsg
+ XGrpRoster gr -> withAuthor XGrpRoster_ $ \author -> void $ xGrpRoster gInfo m author gr verifiedMsg sharedMsgId_ msgTs
_ -> messageError $ "x.grp.msg.forward: unsupported forwarded event " <> T.pack (show $ toCMEventTag event)
where
withAuthor :: CMEventTag e -> (GroupMember -> CM ()) -> CM ()
@@ -3515,12 +3828,12 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
Just sm@SignedMsg {chatBinding, signatures, signedBody}
| GroupMember {memberPubKey = Just pubKey, memberId} <- member ->
case chatBinding of
- CBGroup ->
- let prefix = smpEncode chatBinding <> bindingData
- bindingData = case groupKeys gInfo of
- Just GroupKeys {publicGroupId} -> smpEncode (publicGroupId, memberId)
- Nothing -> smpEncode (memberId, pubKey) -- forward compatibility for verifying signed messages in p2p groups
- in signed MSSVerified <$ guard (all (\(MsgSignature KRMember sig) -> C.verify (C.APublicVerifyKey C.SEd25519 pubKey) sig (prefix <> signedBody)) signatures)
+ CBGroup
+ | Just GroupKeys {publicGroupId} <- groupKeys gInfo ->
+ signed MSSVerified <$ guard (verifyGroupSig pubKey publicGroupId memberId signatures signedBody)
+ | otherwise ->
+ let prefix = smpEncode chatBinding <> smpEncode (memberId, pubKey) -- forward compatibility for verifying signed messages in p2p groups
+ in signed MSSVerified <$ guard (all (\case (MsgSignature KRMember sig) -> C.verify (C.APublicVerifyKey C.SEd25519 pubKey) sig (prefix <> signedBody)) signatures)
_ -> signed MSSSignedNoKey <$ guard signatureOptional
| otherwise -> signed MSSSignedNoKey <$ guard (signatureOptional || unverifiedAllowed membership member tag)
where
@@ -3782,10 +4095,15 @@ runDeliveryJobWorker a deliveryKey Worker {doWork} = do
bucketSize <- asks $ deliveryBucketSize . config
senders <- withStore' $ \db ->
fmap catMaybes . forM senderGMIds $ \sId ->
- fmap eitherToMaybe . runExceptT $ do
+ fmap (join . eitherToMaybe) . runExceptT $ do
sender <- getNonRemovedMemberById db cxt user sId
- vec <- getMemberRelationsVector db sender
- pure (sender, vec)
+ -- owners are already known to every member (group link + owner-intro in introduceInChannel),
+ -- so we never disseminate their profile (redundant, and races with joins re-announcing the owner)
+ if memberRole' sender == GROwner
+ then pure Nothing
+ else do
+ vec <- getMemberRelationsVector db sender
+ pure $ Just (sender, vec)
let missingSenders = length senderGMIds - length senders
when (missingSenders > 0) $
logInfo $ "delivery job " <> tshow jobId <> ": " <> tshow missingSenders <> " senders missing; skipping their profile prepend"
@@ -3795,13 +4113,8 @@ runDeliveryJobWorker a deliveryKey Worker {doWork} = do
if null senders
then pure (body, [], [], [])
else do
- -- Skip role > GRMember (mirrors xGrpMemNew gate).
- -- TODO [relays] public groups: revisit if mods/admins are introduced via this sidecar.
- let (encoderErrs, validLabeled) =
- partitionEithers
- [ (\bs -> (s, bs)) <$> encodeMemberNew (vr cxt) gInfo s
- | (s, _) <- senders, memberRole' s <= GRMember
- ]
+ -- all members' profiles disseminate; privileged key/role come from the roster, not here
+ let (encoderErrs, validLabeled) = partitionEithers [(\bs -> (s, bs)) <$> encodeMemberNew (vr cxt) gInfo s | (s, _) <- senders]
(extBody', inBody, overflowLabeled, large1) = batchProfilesWithBody maxEncodedMsgLength body validLabeled
(overflowBatches', large2) = batchProfiles maxEncodedMsgLength overflowLabeled
packerErrs = [ChatError (CEInternalError $ "oversized profile element for member " <> show (groupMemberId' s)) | s <- large1 <> large2]
diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs
index f3c3e20e47..223fe492a9 100644
--- a/src/Simplex/Chat/Protocol.hs
+++ b/src/Simplex/Chat/Protocol.hs
@@ -48,13 +48,14 @@ import Data.Time.Clock (UTCTime)
import Data.Time.Clock.System (systemToUTCTime, utcToSystemTime)
import Data.Type.Equality
import Data.Typeable (Typeable)
-import Data.Word (Word32)
+import Data.Word (Word16, Word32)
import Simplex.Chat.Badges (LocalBadge)
import Simplex.Chat.Call
import Simplex.Chat.Options.DB (FromField (..), ToField (..))
import Simplex.Chat.Types
import Simplex.Chat.Types.Preferences
import Simplex.Chat.Types.Shared
+import qualified Simplex.FileTransfer.Description as FD
import Simplex.Messaging.Agent.Protocol (VersionSMPA, pqdrSMPAgentVersion)
import Simplex.Messaging.Agent.Store.DB (blobFieldDecoder, fromTextField_)
import Simplex.Messaging.Compression (Compressed, compress1, decompress1, decompressedSize)
@@ -84,12 +85,13 @@ import Simplex.Messaging.Version hiding (version)
-- 16 - support short link data (2025-06-10)
-- 17 - allow host voice messages during member approval regardless of group voice setting (2026-02-10)
-- 18 - relay web capabilities (2026-05-31)
+-- 19 - group roster (2026-06-18)
-- This should not be used directly in code, instead use `maxVersion chatVRange` from ChatConfig.
-- This indirection is needed for backward/forward compatibility testing.
-- Testing with real app versions is still needed, as tests use the current code with different version ranges, not the old code.
currentChatVersion :: VersionChat
-currentChatVersion = VersionChat 18
+currentChatVersion = VersionChat 19
-- This should not be used directly in code, instead use `chatVRange` from ChatConfig (see comment above)
supportedChatVRange :: VersionRangeChat
@@ -160,6 +162,11 @@ memberSupportVoiceVersion = VersionChat 17
relayWebCapVersion :: VersionChat
relayWebCapVersion = VersionChat 18
+-- owner-signed roster (promoted members/moderators/admins) and the relay roster-ack handshake;
+-- a relay below this version is published without the handshake (it can't ack a roster)
+groupRosterVersion :: VersionChat
+groupRosterVersion = VersionChat 19
+
agentToChatVersion :: VersionSMPA -> VersionChat
agentToChatVersion v
| v < pqdrSMPAgentVersion = initialChatVersion
@@ -373,6 +380,36 @@ data GrpMsgForward = GrpMsgForward
}
deriving (Eq, Show)
+-- | Owner-signed roster header for the privileged (moderator/admin/member) set; owners
+-- are not included, their keys come from the link. The member list itself is not
+-- here: it is sent as a binary blob over the inline file transfer, and this header
+-- carries only its inline-file invitation (size + owner-attested digest).
+data GroupRoster = GroupRoster
+ { version :: VersionRoster,
+ fileInv :: InlineFileInvitation
+ }
+ deriving (Eq, Show)
+
+-- | Lean always-inline file invitation for the roster blob, carried in the signed
+-- header. The digest authenticates the unsigned blob; integrity is entirely the digest.
+data InlineFileInvitation = InlineFileInvitation
+ { fileSize :: Integer,
+ fileDigest :: FD.FileDigest
+ }
+ deriving (Eq, Show)
+
+data RosterMember = RosterMember
+ { memberId :: MemberId,
+ key :: MemberKey, -- trust-on-first-use pinned per memberId
+ role :: GroupMemberRole,
+ privileges :: Word16 -- reserved: serialized as 0, parsed and ignored in v1
+ }
+ deriving (Eq, Show)
+
+-- RosterMember is binary-only: it rides in the roster blob, never in a JSON message.
+instance Encoding RosterMember where
+ smpEncode RosterMember {memberId, key, role, privileges} = smpEncode (memberId, key, role, privileges)
+ smpP = RosterMember <$> smpP <*> smpP <*> smpP <*> smpP
instance Encoding FwdSender where
smpEncode = \case
@@ -439,6 +476,11 @@ data MsgSigning = MsgSigning
encodeChatBinding :: ChatBinding -> ByteString -> ByteString
encodeChatBinding cb bindingData = smpEncode cb <> bindingData
+signChatMsgBody :: MsgSigning -> ByteString -> SignedMsg
+signChatMsgBody MsgSigning {bindingTag, bindingData, keyRef, privKey} msgBody =
+ let sig = C.ASignature C.SEd25519 $ C.sign' privKey (encodeChatBinding bindingTag bindingData <> msgBody)
+ in SignedMsg {chatBinding = bindingTag, signatures = MsgSignature keyRef sig L.:| [], signedBody = msgBody}
+
data ChatMsgEvent (e :: MsgEncoding) where
XMsgNew :: MsgContainer -> ChatMsgEvent 'Json
XMsgFileDescr :: {msgId :: SharedMsgId, fileDescr :: FileDescr} -> ChatMsgEvent 'Json
@@ -452,7 +494,7 @@ data ChatMsgEvent (e :: MsgEncoding) where
XFileCancel :: SharedMsgId -> ChatMsgEvent 'Json
XInfo :: Profile -> ChatMsgEvent 'Json
XContact :: {profile :: Profile, contactReqId :: Maybe XContactId, welcomeMsgId :: Maybe SharedMsgId, requestMsg :: Maybe (SharedMsgId, MsgContent)} -> ChatMsgEvent 'Json
- XMember :: {profile :: Profile, newMemberId :: MemberId, newMemberKey :: MemberKey} -> ChatMsgEvent 'Json
+ XMember :: {profile :: Profile, newMemberId :: MemberId, newMemberKey :: MemberKey, viaRelay :: Maybe MemberId} -> ChatMsgEvent 'Json
XDirectDel :: ChatMsgEvent 'Json
XGrpInv :: GroupInvitation -> ChatMsgEvent 'Json
XGrpAcpt :: MemberId -> ChatMsgEvent 'Json
@@ -471,16 +513,18 @@ data ChatMsgEvent (e :: MsgEncoding) where
XGrpMemInv :: MemberId -> IntroInvitation -> ChatMsgEvent 'Json
XGrpMemFwd :: MemberInfo -> IntroInvitation -> ChatMsgEvent 'Json
XGrpMemInfo :: MemberId -> Profile -> ChatMsgEvent 'Json
- XGrpMemRole :: MemberId -> GroupMemberRole -> ChatMsgEvent 'Json
+ XGrpMemRole :: MemberId -> GroupMemberRole -> Maybe MemberKey -> Maybe VersionRoster -> ChatMsgEvent 'Json
XGrpMemRestrict :: MemberId -> MemberRestrictions -> ChatMsgEvent 'Json
XGrpMemCon :: MemberId -> ChatMsgEvent 'Json
XGrpMemConAll :: MemberId -> ChatMsgEvent 'Json -- TODO not implemented
- XGrpMemDel :: MemberId -> Bool -> ChatMsgEvent 'Json
+ XGrpMemDel :: MemberId -> Bool -> Maybe VersionRoster -> ChatMsgEvent 'Json
XGrpLeave :: ChatMsgEvent 'Json
XGrpDel :: ChatMsgEvent 'Json
XGrpInfo :: GroupProfile -> ChatMsgEvent 'Json
XGrpPrefs :: GroupPreferences -> ChatMsgEvent 'Json
XGrpDirectInv :: ConnReqInvitation -> Maybe MsgContent -> Maybe MsgScope -> ChatMsgEvent 'Json
+ XGrpRoster :: GroupRoster -> ChatMsgEvent 'Json
+ XGrpRosterAck :: VersionRoster -> Maybe Text -> ChatMsgEvent 'Json
XGrpMsgForward :: GrpMsgForward -> ChatMessage 'Json -> ChatMsgEvent 'Json
XInfoProbe :: Probe -> ChatMsgEvent 'Json
XInfoProbeCheck :: ProbeHash -> ChatMsgEvent 'Json
@@ -524,6 +568,7 @@ isForwardedGroupMsg ev = case ev of
XGrpDel -> True
XGrpInfo _ -> True
XGrpPrefs _ -> True
+ XGrpRoster _ -> True
_ -> False
data MsgReaction = MREmoji {emoji :: MREmojiChar} | MRUnknown {tag :: Text, json :: J.Object}
@@ -792,6 +837,8 @@ data MsgMention = MsgMention {memberId :: MemberId}
newtype MsgMentions = MsgMentions (Map MemberName MsgMention)
deriving (Eq, Show)
+$(JQ.deriveJSON defaultJSON ''InlineFileInvitation)
+
$(JQ.deriveJSON (taggedObjectJSON $ dropPrefix "MCL") ''MsgChatLink)
$(JQ.deriveJSON defaultJSON ''LinkOwnerSig)
@@ -892,6 +939,28 @@ maxCompressedMsgLength = 13380
maxDecompressedMsgLength :: Int
maxDecompressedMsgLength = 65536
+-- Defensive entry-count bound for the roster blob parser (rosterBlobP) and the
+-- promotion cap over the promoted (member/moderator/admin) set.
+maxGroupRosterSize :: Int
+maxGroupRosterSize = 256
+
+-- Receive-side byte bound: reject an owner-signed header whose claimed fileSize exceeds what
+-- maxGroupRosterSize entries can occupy (128 B/entry is a generous worst case), before a file is created.
+-- 128 B/entry ~ memberId + X.509 Ed25519 key (44 B) + role + privileges + 1-byte length prefixes (~2x the ~65 B typical).
+maxGroupRosterBytes :: Integer
+maxGroupRosterBytes = fromIntegral maxGroupRosterSize * 128
+
+-- The byte sequence the owner-signed digest is computed over and verified against
+-- before parsing. Word16 count (smpEncodeList's 1-byte count is too small for the future cap).
+encodeRosterBlob :: [RosterMember] -> ByteString
+encodeRosterBlob ms = smpEncode (fromIntegral (length ms) :: Word16) <> B.concat (map smpEncode ms)
+
+rosterBlobP :: A.Parser [RosterMember]
+rosterBlobP = do
+ n <- fromIntegral <$> smpP @Word16
+ when (n > maxGroupRosterSize) $ fail "roster: too many entries"
+ A.count n smpP
+
-- maxEncodedMsgLength - delta between MSG and INFO + 100 (returned for forward overhead)
-- delta between MSG and INFO = e2eEncUserMsgLength (no PQ) - e2eEncConnInfoLength (no PQ) = 1008
maxEncodedInfoLength :: Int
@@ -1028,6 +1097,8 @@ data CMEventTag (e :: MsgEncoding) where
XGrpInfo_ :: CMEventTag 'Json
XGrpPrefs_ :: CMEventTag 'Json
XGrpDirectInv_ :: CMEventTag 'Json
+ XGrpRoster_ :: CMEventTag 'Json
+ XGrpRosterAck_ :: CMEventTag 'Json
XGrpMsgForward_ :: CMEventTag 'Json
XInfoProbe_ :: CMEventTag 'Json
XInfoProbeCheck_ :: CMEventTag 'Json
@@ -1088,6 +1159,8 @@ instance MsgEncodingI e => StrEncoding (CMEventTag e) where
XGrpInfo_ -> "x.grp.info"
XGrpPrefs_ -> "x.grp.prefs"
XGrpDirectInv_ -> "x.grp.direct.inv"
+ XGrpRoster_ -> "x.grp.roster"
+ XGrpRosterAck_ -> "x.grp.roster.ack"
XGrpMsgForward_ -> "x.grp.msg.forward"
XInfoProbe_ -> "x.info.probe"
XInfoProbeCheck_ -> "x.info.probe.check"
@@ -1149,6 +1222,8 @@ instance StrEncoding ACMEventTag where
"x.grp.info" -> XGrpInfo_
"x.grp.prefs" -> XGrpPrefs_
"x.grp.direct.inv" -> XGrpDirectInv_
+ "x.grp.roster" -> XGrpRoster_
+ "x.grp.roster.ack" -> XGrpRosterAck_
"x.grp.msg.forward" -> XGrpMsgForward_
"x.info.probe" -> XInfoProbe_
"x.info.probe.check" -> XInfoProbeCheck_
@@ -1196,7 +1271,7 @@ toCMEventTag msg = case msg of
XGrpMemInv _ _ -> XGrpMemInv_
XGrpMemFwd _ _ -> XGrpMemFwd_
XGrpMemInfo _ _ -> XGrpMemInfo_
- XGrpMemRole _ _ -> XGrpMemRole_
+ XGrpMemRole {} -> XGrpMemRole_
XGrpMemRestrict _ _ -> XGrpMemRestrict_
XGrpMemCon _ -> XGrpMemCon_
XGrpMemConAll _ -> XGrpMemConAll_
@@ -1206,6 +1281,8 @@ toCMEventTag msg = case msg of
XGrpInfo _ -> XGrpInfo_
XGrpPrefs _ -> XGrpPrefs_
XGrpDirectInv {} -> XGrpDirectInv_
+ XGrpRoster _ -> XGrpRoster_
+ XGrpRosterAck {} -> XGrpRosterAck_
XGrpMsgForward {} -> XGrpMsgForward_
XInfoProbe _ -> XInfoProbe_
XInfoProbeCheck _ -> XInfoProbeCheck_
@@ -1264,6 +1341,7 @@ requiresSignature = \case
XGrpMemRestrict_ -> True
XGrpLeave_ -> True
XGrpRelayNew_ -> True
+ XGrpRoster_ -> True
XInfo_ -> True
_ -> False
@@ -1332,7 +1410,7 @@ appJsonToCM AppMessageJson {v, msgId, event, params} = do
reqContent <- opt "content"
let requestMsg = (,) <$> reqMsgId <*> reqContent
pure XContact {profile, contactReqId, welcomeMsgId, requestMsg}
- XMember_ -> XMember <$> p "profile" <*> p "newMemberId" <*> p "newMemberKey"
+ XMember_ -> XMember <$> p "profile" <*> p "newMemberId" <*> p "newMemberKey" <*> opt "viaRelay"
XDirectDel_ -> pure XDirectDel
XGrpInv_ -> XGrpInv <$> p "groupInvitation"
XGrpAcpt_ -> XGrpAcpt <$> p "memberId"
@@ -1354,16 +1432,18 @@ appJsonToCM AppMessageJson {v, msgId, event, params} = do
XGrpMemInv_ -> XGrpMemInv <$> p "memberId" <*> p "memberIntro"
XGrpMemFwd_ -> XGrpMemFwd <$> p "memberInfo" <*> p "memberIntro"
XGrpMemInfo_ -> XGrpMemInfo <$> p "memberId" <*> p "profile"
- XGrpMemRole_ -> XGrpMemRole <$> p "memberId" <*> p "role"
+ XGrpMemRole_ -> XGrpMemRole <$> p "memberId" <*> p "role" <*> opt "memberKey" <*> opt "rosterVersion"
XGrpMemRestrict_ -> XGrpMemRestrict <$> p "memberId" <*> p "memberRestrictions"
XGrpMemCon_ -> XGrpMemCon <$> p "memberId"
XGrpMemConAll_ -> XGrpMemConAll <$> p "memberId"
- XGrpMemDel_ -> XGrpMemDel <$> p "memberId" <*> Right (fromRight False $ p "messages")
+ XGrpMemDel_ -> XGrpMemDel <$> p "memberId" <*> Right (fromRight False $ p "messages") <*> opt "rosterVersion"
XGrpLeave_ -> pure XGrpLeave
XGrpDel_ -> pure XGrpDel
XGrpInfo_ -> XGrpInfo <$> p "groupProfile"
XGrpPrefs_ -> XGrpPrefs <$> p "groupPreferences"
XGrpDirectInv_ -> XGrpDirectInv <$> p "connReq" <*> opt "content" <*> opt "scope"
+ XGrpRoster_ -> XGrpRoster <$> (GroupRoster <$> p "version" <*> p "fileInv")
+ XGrpRosterAck_ -> XGrpRosterAck <$> p "version" <*> opt "error"
XGrpMsgForward_ -> do
fwdSender <- opt "memberId" >>= \case
Just memberId -> FwdMember memberId . fromMaybe "" <$> opt "memberName"
@@ -1405,7 +1485,7 @@ chatToAppMessage chatMsg@ChatMessage {chatVRange, msgId, chatMsgEvent} = case en
XFileCancel sharedMsgId -> o ["msgId" .= sharedMsgId]
XInfo profile -> o ["profile" .= profile]
XContact {profile, contactReqId, welcomeMsgId, requestMsg} -> o $ ("contactReqId" .=? contactReqId) $ ("welcomeMsgId" .=? welcomeMsgId) $ ("msgId" .=? (fst <$> requestMsg)) $ ("content" .=? (snd <$> requestMsg)) $ ["profile" .= profile]
- XMember {profile, newMemberId, newMemberKey} -> o ["profile" .= profile, "newMemberId" .= newMemberId, "newMemberKey" .= newMemberKey]
+ XMember {profile, newMemberId, newMemberKey, viaRelay} -> o $ ("viaRelay" .=? viaRelay) ["profile" .= profile, "newMemberId" .= newMemberId, "newMemberKey" .= newMemberKey]
XDirectDel -> JM.empty
XGrpInv groupInv -> o ["groupInvitation" .= groupInv]
XGrpAcpt memId -> o ["memberId" .= memId]
@@ -1426,16 +1506,18 @@ chatToAppMessage chatMsg@ChatMessage {chatVRange, msgId, chatMsgEvent} = case en
XGrpMemInv memId memIntro -> o ["memberId" .= memId, "memberIntro" .= memIntro]
XGrpMemFwd memInfo memIntro -> o ["memberInfo" .= memInfo, "memberIntro" .= memIntro]
XGrpMemInfo memId profile -> o ["memberId" .= memId, "profile" .= profile]
- XGrpMemRole memId role -> o ["memberId" .= memId, "role" .= role]
+ XGrpMemRole memId role memberKey rosterVersion -> o $ ("memberKey" .=? memberKey) $ ("rosterVersion" .=? rosterVersion) ["memberId" .= memId, "role" .= role]
XGrpMemRestrict memId memRestrictions -> o ["memberId" .= memId, "memberRestrictions" .= memRestrictions]
XGrpMemCon memId -> o ["memberId" .= memId]
XGrpMemConAll memId -> o ["memberId" .= memId]
- XGrpMemDel memId messages -> o $ ("messages" .=? if messages then Just True else Nothing) ["memberId" .= memId]
+ XGrpMemDel memId messages rosterVersion -> o $ ("rosterVersion" .=? rosterVersion) $ ("messages" .=? if messages then Just True else Nothing) ["memberId" .= memId]
XGrpLeave -> JM.empty
XGrpDel -> JM.empty
XGrpInfo p -> o ["groupProfile" .= p]
XGrpPrefs p -> o ["groupPreferences" .= p]
XGrpDirectInv connReq content scope -> o $ ("content" .=? content) $ ("scope" .=? scope) ["connReq" .= connReq]
+ XGrpRoster GroupRoster {version, fileInv} -> o ["version" .= version, "fileInv" .= fileInv]
+ XGrpRosterAck version err -> o $ ("error" .=? err) ["version" .= version]
XGrpMsgForward GrpMsgForward {fwdSender, fwdBrokerTs} msg -> o $ encodeFwdSender fwdSender ["msg" .= msg, "msgTs" .= fwdBrokerTs]
where
encodeFwdSender = \case
diff --git a/src/Simplex/Chat/Store/Connections.hs b/src/Simplex/Chat/Store/Connections.hs
index e5ebf8e2bd..2953c1de1f 100644
--- a/src/Simplex/Chat/Store/Connections.hs
+++ b/src/Simplex/Chat/Store/Connections.hs
@@ -149,7 +149,7 @@ getConnectionEntity db cxt user@User {userId, userContactId} agentConnId = do
g.conn_full_link_to_connect, g.conn_short_link_to_connect, g.conn_link_prepared_connection, g.conn_link_started_connection, g.welcome_shared_msg_id, g.request_shared_msg_id,
g.business_chat, g.business_member_id, g.customer_member_id,
g.use_relays, g.relay_own_status,
- g.ui_themes, g.summary_current_members_count, g.public_member_count, g.custom_data, g.chat_item_ttl, g.members_require_attention, g.via_group_link_uri,
+ g.ui_themes, g.summary_current_members_count, g.public_member_count, g.roster_version, g.custom_data, g.chat_item_ttl, g.members_require_attention, g.via_group_link_uri,
g.root_priv_key, g.root_pub_key, g.member_priv_key,
-- GroupInfo {membership}
mu.group_member_id, mu.group_id, mu.index_in_group, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category,
diff --git a/src/Simplex/Chat/Store/Files.hs b/src/Simplex/Chat/Store/Files.hs
index 6cb8e39bbc..dee72731a8 100644
--- a/src/Simplex/Chat/Store/Files.hs
+++ b/src/Simplex/Chat/Store/Files.hs
@@ -31,12 +31,19 @@ module Simplex.Chat.Store.Files
getSharedMsgIdByFileId,
getFileIdBySharedMsgId,
getGroupFileIdBySharedMsgId,
+ getGroupRcvFileId,
+ getGroupRosterFileInfo,
+ deleteGroupRosterFile,
+ getRosterTransferFile,
+ deleteRosterTransferFile,
+ getRcvFileLastChunkNo,
getDirectFileIdBySharedMsgId,
getChatRefByFileId,
lookupChatRefByFileId,
updateSndFileStatus,
createRcvFileTransfer,
createRcvGroupFileTransfer,
+ createRosterRcvFile,
createRcvStandaloneFileTransfer,
appendRcvFD,
getRcvFileDescrByRcvFileId,
@@ -321,6 +328,64 @@ getGroupFileIdBySharedMsgId db userId groupId sharedMsgId =
|]
(userId, groupId, sharedMsgId)
+-- Resolve the in-flight received group inline file for a chunk: read its file_type by shared_msg_id
+-- (LIMIT 1 is safe -- all files sharing a shared_msg_id share a type), then look up by type: a roster
+-- file is scoped to its source relay (every relay re-serves the owner's same shared_msg_id, so the source
+-- disambiguates), a normal file is by shared_msg_id. Nothing => no in-flight transfer (orphaned chunk).
+getGroupRcvFileId :: DB.Connection -> UserId -> Int64 -> GroupMemberId -> SharedMsgId -> IO (Maybe Int64)
+getGroupRcvFileId db userId groupId fromMemberId sharedMsgId = do
+ fileType_ <- getFileType
+ case fileType_ of
+ Just FTRoster ->
+ maybeFirstRow fromOnly $
+ DB.query db (rcvFileIdQ <> " AND r.group_member_id = ?") (userId, groupId, sharedMsgId, FTRoster, fromMemberId)
+ Just FTNormal ->
+ maybeFirstRow fromOnly $
+ DB.query db rcvFileIdQ (userId, groupId, sharedMsgId, FTNormal)
+ Nothing -> pure Nothing
+ where
+ getFileType =
+ maybeFirstRow fromOnly $
+ DB.query db "SELECT file_type FROM files WHERE user_id = ? AND group_id = ? AND shared_msg_id = ? LIMIT 1" (userId, groupId, sharedMsgId)
+ rcvFileIdQ =
+ [sql|
+ SELECT f.file_id FROM files f
+ JOIN rcv_files r ON r.file_id = f.file_id
+ WHERE f.user_id = ? AND f.group_id = ? AND f.shared_msg_id = ? AND f.file_type = ?
+ |]
+
+-- The roster scratch file for a transfer (for fs/handle cleanup before deleting the transfer).
+-- A transfer owns exactly one file (created together in one transaction), so this is single-valued.
+getRosterTransferFile :: DB.Connection -> Int64 -> IO (Maybe (Int64, Maybe FilePath))
+getRosterTransferFile db transferId =
+ maybeFirstRow id $ DB.query db "SELECT file_id, file_path FROM files WHERE roster_transfer_id = ?" (Only transferId)
+
+-- Deletes a transfer's file row; rcv_files and rcv_file_chunks cascade on the FK.
+deleteRosterTransferFile :: DB.Connection -> Int64 -> IO ()
+deleteRosterTransferFile db transferId =
+ DB.execute db "DELETE FROM files WHERE roster_transfer_id = ?" (Only transferId)
+
+-- For roster-file cleanup keyed on the group (not a chat item): every matching file_id and its on-disk
+-- path, so the caller evicts the handle and removes the file for each — delete-all like deleteGroupRosterFile.
+getGroupRosterFileInfo :: DB.Connection -> UserId -> Int64 -> IO [(Int64, Maybe FilePath)]
+getGroupRosterFileInfo db userId groupId =
+ DB.query
+ db
+ "SELECT file_id, file_path FROM files WHERE user_id = ? AND group_id = ? AND file_type = ?"
+ (userId, groupId, FTRoster)
+
+-- Deletes the roster files row; rcv_files and rcv_file_chunks cascade on the FK.
+deleteGroupRosterFile :: DB.Connection -> UserId -> Int64 -> IO ()
+deleteGroupRosterFile db userId groupId =
+ DB.execute db "DELETE FROM files WHERE user_id = ? AND group_id = ? AND file_type = ?" (userId, groupId, FTRoster)
+
+-- The highest stored chunk number, or Nothing if no partial chunks exist (used to decide
+-- whether an arriving chunk 1 is a re-driven transfer that must reset).
+getRcvFileLastChunkNo :: DB.Connection -> RcvFileTransfer -> IO (Maybe Integer)
+getRcvFileLastChunkNo db RcvFileTransfer {fileId} =
+ maybeFirstRow fromOnly $
+ DB.query db "SELECT chunk_number FROM rcv_file_chunks WHERE file_id = ? ORDER BY chunk_number DESC LIMIT 1" (Only fileId)
+
getDirectFileIdBySharedMsgId :: DB.Connection -> User -> Contact -> SharedMsgId -> ExceptT StoreError IO Int64
getDirectFileIdBySharedMsgId db User {userId} Contact {contactId} sharedMsgId =
ExceptT . firstRow fromOnly (SEFileIdNotFoundBySharedMsgId sharedMsgId) $
@@ -379,10 +444,10 @@ createRcvFileTransfer db userId Contact {contactId, localDisplayName = c} f@File
db
"INSERT INTO rcv_files (file_id, file_status, file_queue_info, file_inline, rcv_file_inline, file_descr_id, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?)"
(fileId, FSNew, fileConnReq, fileInline, rcvFileInline, rfdId, currentTs, currentTs)
- pure RcvFileTransfer {fileId, xftpRcvFile, fileInvitation = f, fileStatus = RFSNew, rcvFileInline, senderDisplayName = c, chunkSize, cancelled = False, grpMemberId = Nothing, cryptoArgs = Nothing}
+ pure RcvFileTransfer {fileId, xftpRcvFile, fileInvitation = f, fileStatus = RFSNew, fileType = FTNormal, rcvFileInline, senderDisplayName = c, chunkSize, cancelled = False, grpMemberId = Nothing, cryptoArgs = Nothing}
-createRcvGroupFileTransfer :: DB.Connection -> UserId -> GroupInfo -> Maybe GroupMember -> FileInvitation -> Maybe InlineFileMode -> Integer -> ExceptT StoreError IO RcvFileTransfer
-createRcvGroupFileTransfer db userId GroupInfo {groupId, localDisplayName = gName} m_ f@FileInvitation {fileName, fileSize, fileConnReq, fileInline, fileDescr} rcvFileInline chunkSize = do
+createRcvGroupFileTransfer :: DB.Connection -> UserId -> GroupInfo -> Maybe GroupMember -> FileType -> Maybe SharedMsgId -> FileInvitation -> Maybe InlineFileMode -> Integer -> ExceptT StoreError IO RcvFileTransfer
+createRcvGroupFileTransfer db userId GroupInfo {groupId, localDisplayName = gName} m_ fileType sharedMsgId_ f@FileInvitation {fileName, fileSize, fileConnReq, fileInline, fileDescr} rcvFileInline chunkSize = do
currentTs <- liftIO getCurrentTime
rfd_ <- mapM (createRcvFD_ db userId currentTs) fileDescr
let rfdId = (\RcvFileDescr {fileDescrId} -> fileDescrId) <$> rfd_
@@ -394,15 +459,34 @@ createRcvGroupFileTransfer db userId GroupInfo {groupId, localDisplayName = gNam
fileId <- liftIO $ do
DB.execute
db
- "INSERT INTO files (user_id, group_id, file_name, file_size, chunk_size, file_inline, ci_file_status, protocol, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?)"
- (userId, groupId, fileName, fileSize, chunkSize, fileInline, CIFSRcvInvitation, fileProtocol, currentTs, currentTs)
+ "INSERT INTO files (user_id, group_id, file_name, file_size, chunk_size, file_inline, ci_file_status, protocol, file_type, shared_msg_id, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)"
+ (userId, groupId, fileName, fileSize, chunkSize, fileInline, CIFSRcvInvitation, fileProtocol, fileType, sharedMsgId_, currentTs, currentTs)
insertedRowId db
liftIO $
DB.execute
db
"INSERT INTO rcv_files (file_id, file_status, file_queue_info, file_inline, rcv_file_inline, group_member_id, file_descr_id, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?)"
(fileId, FSNew, fileConnReq, fileInline, rcvFileInline, grpMemberId_, rfdId, currentTs, currentTs)
- pure RcvFileTransfer {fileId, xftpRcvFile, fileInvitation = f, fileStatus = RFSNew, rcvFileInline, senderDisplayName = senderName, chunkSize, cancelled = False, grpMemberId = grpMemberId_, cryptoArgs = Nothing}
+ pure RcvFileTransfer {fileId, xftpRcvFile, fileInvitation = f, fileStatus = RFSNew, fileType, rcvFileInline, senderDisplayName = senderName, chunkSize, cancelled = False, grpMemberId = grpMemberId_, cryptoArgs = Nothing}
+
+-- Roster scratch file owned by a per-source transfer: group_member_id is the delivering relay (so chunk
+-- streams from different relays are distinct files), roster_transfer_id links to the metadata record.
+createRosterRcvFile :: DB.Connection -> UserId -> GroupInfo -> GroupMember -> Int64 -> SharedMsgId -> FileInvitation -> Maybe InlineFileMode -> Integer -> ExceptT StoreError IO RcvFileTransfer
+createRosterRcvFile db userId GroupInfo {groupId} src@GroupMember {localDisplayName = senderName} transferId sharedMsgId f@FileInvitation {fileName, fileSize, fileConnReq, fileInline} rcvFileInline chunkSize = do
+ currentTs <- liftIO getCurrentTime
+ let grpMemberId_ = groupMemberId' src
+ fileId <- liftIO $ do
+ DB.execute
+ db
+ "INSERT INTO files (user_id, group_id, file_name, file_size, chunk_size, file_inline, ci_file_status, protocol, file_type, shared_msg_id, roster_transfer_id, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)"
+ ((userId, groupId, fileName, fileSize, chunkSize, fileInline, CIFSRcvInvitation, FPSMP, FTRoster) :. (sharedMsgId, transferId, currentTs, currentTs))
+ insertedRowId db
+ liftIO $
+ DB.execute
+ db
+ "INSERT INTO rcv_files (file_id, file_status, file_queue_info, file_inline, rcv_file_inline, group_member_id, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?)"
+ (fileId, FSNew, fileConnReq, fileInline, rcvFileInline, grpMemberId_, currentTs, currentTs)
+ pure RcvFileTransfer {fileId, xftpRcvFile = Nothing, fileInvitation = f, fileStatus = RFSNew, fileType = FTRoster, rcvFileInline, senderDisplayName = senderName, chunkSize, cancelled = False, grpMemberId = Just grpMemberId_, cryptoArgs = Nothing}
createRcvStandaloneFileTransfer :: DB.Connection -> UserId -> CryptoFile -> Int64 -> Word32 -> ExceptT StoreError IO Int64
createRcvStandaloneFileTransfer db userId (CryptoFile filePath cfArgs_) fileSize chunkSize = do
@@ -548,7 +632,7 @@ getRcvFileTransfer_ db userId fileId = do
SELECT r.file_status, r.file_queue_info, r.group_member_id, f.file_name,
f.file_size, f.chunk_size, f.cancelled, cs.local_display_name, m.local_display_name,
f.file_path, f.file_crypto_key, f.file_crypto_nonce, r.file_inline, r.rcv_file_inline,
- r.agent_rcv_file_id, r.agent_rcv_file_deleted, r.user_approved_relays, g.local_display_name
+ r.agent_rcv_file_id, r.agent_rcv_file_deleted, r.user_approved_relays, g.local_display_name, f.file_type
FROM rcv_files r
JOIN files f USING (file_id)
LEFT JOIN contacts cs ON cs.contact_id = f.contact_id
@@ -562,9 +646,9 @@ getRcvFileTransfer_ db userId fileId = do
where
rcvFileTransfer ::
Maybe RcvFileDescr ->
- (FileStatus, Maybe ConnReqInvitation, Maybe Int64, String, Integer, Integer, Maybe BoolInt) :. (Maybe ContactName, Maybe ContactName, Maybe FilePath, Maybe C.SbKey, Maybe C.CbNonce, Maybe InlineFileMode, Maybe InlineFileMode, Maybe AgentRcvFileId, BoolInt, BoolInt) :. Only (Maybe ContactName) ->
+ (FileStatus, Maybe ConnReqInvitation, Maybe Int64, String, Integer, Integer, Maybe BoolInt) :. (Maybe ContactName, Maybe ContactName, Maybe FilePath, Maybe C.SbKey, Maybe C.CbNonce, Maybe InlineFileMode, Maybe InlineFileMode, Maybe AgentRcvFileId, BoolInt, BoolInt) :. (Maybe ContactName, FileType) ->
ExceptT StoreError IO RcvFileTransfer
- rcvFileTransfer rfd_ ((fileStatus', fileConnReq, grpMemberId, fileName, fileSize, chunkSize, cancelled_) :. (contactName_, memberName_, filePath_, fileKey, fileNonce, fileInline, rcvFileInline, agentRcvFileId, BI agentRcvFileDeleted, BI userApprovedRelays) :. Only groupName_) =
+ rcvFileTransfer rfd_ ((fileStatus', fileConnReq, grpMemberId, fileName, fileSize, chunkSize, cancelled_) :. (contactName_, memberName_, filePath_, fileKey, fileNonce, fileInline, rcvFileInline, agentRcvFileId, BI agentRcvFileDeleted, BI userApprovedRelays) :. (groupName_, fileType)) =
case contactName_ <|> memberName_ <|> groupName_ <|> standaloneName_ of
Nothing -> throwError $ SERcvFileInvalid fileId
Just name ->
@@ -582,7 +666,7 @@ getRcvFileTransfer_ db userId fileId = do
let fileInvitation = FileInvitation {fileName, fileSize, fileDigest = Nothing, fileConnReq, fileInline, fileDescr = Nothing}
cryptoArgs = CFArgs <$> fileKey <*> fileNonce
xftpRcvFile = (\rfd -> XFTPRcvFile {rcvFileDescription = rfd, agentRcvFileId, agentRcvFileDeleted, userApprovedRelays}) <$> rfd_
- in RcvFileTransfer {fileId, xftpRcvFile, fileInvitation, fileStatus, rcvFileInline, senderDisplayName, chunkSize, cancelled, grpMemberId, cryptoArgs}
+ in RcvFileTransfer {fileId, xftpRcvFile, fileInvitation, fileStatus, fileType, rcvFileInline, senderDisplayName, chunkSize, cancelled, grpMemberId, cryptoArgs}
filePath = case filePath_ of
Nothing -> throwError $ SERcvFileInvalid fileId
Just fp -> pure fp
@@ -678,7 +762,15 @@ createRcvFileChunk db RcvFileTransfer {fileId, fileInvitation = FileInvitation {
currentTs <- getCurrentTime
DB.execute
db
- "INSERT OR REPLACE INTO rcv_file_chunks (file_id, chunk_number, chunk_agent_msg_id, created_at, updated_at) VALUES (?,?,?,?,?)"
+ [sql|
+ INSERT INTO rcv_file_chunks (file_id, chunk_number, chunk_agent_msg_id, created_at, updated_at)
+ VALUES (?,?,?,?,?)
+ ON CONFLICT (file_id, chunk_number) DO UPDATE SET
+ chunk_agent_msg_id = excluded.chunk_agent_msg_id,
+ chunk_stored = 0,
+ created_at = excluded.created_at,
+ updated_at = excluded.updated_at
+ |]
(fileId, chunkNo, msgId, currentTs, currentTs)
pure status
where
diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs
index 2ea3fa9b84..60531cc1dc 100644
--- a/src/Simplex/Chat/Store/Groups.hs
+++ b/src/Simplex/Chat/Store/Groups.hs
@@ -67,6 +67,9 @@ module Simplex.Chat.Store.Groups
getGroupMembersByIndexes,
getSupportScopeMembersByIndexes,
getGroupModerators,
+ getGroupRosterMembers,
+ getGroupAdminsMods,
+ getGroupOnlyMembers,
getGroupOwners,
getGroupRelayMembers,
getGroupMembersForExpiration,
@@ -84,7 +87,19 @@ module Simplex.Chat.Store.Groups
getGroupRelayById,
getGroupRelayByGMId,
getGroupRelays,
- getConnectedGroupRelays,
+ getPublishableGroupRelays,
+ setGroupRosterVersion,
+ getGroupRosterVersion,
+ getGroupRoster,
+ RcvRosterTransfer (..),
+ createRosterTransfer,
+ getRosterTransferVersion,
+ getRosterTransferId,
+ getRosterTransfer,
+ setGroupLiveRoster,
+ deleteRosterTransfer,
+ deleteGroupRosterTransfers,
+ setGroupMemberKeyRole,
createRelayForOwner,
getCreateRelayForMember,
createRelayConnection,
@@ -173,6 +188,7 @@ module Simplex.Chat.Store.Groups
createLinkOwnerMember,
updatePreparedChannelMember,
updateUnknownMemberAnnounced,
+ updateRosterMemberAnnounced,
updateUserMemberProfileSentAt,
setGroupCustomData,
setGroupUIThemes,
@@ -216,6 +232,8 @@ import Simplex.Chat.Types.Shared
import Simplex.Chat.Types.UITheme
import Simplex.Messaging.Agent.Protocol (ConfirmationId, ConnId, CreatedConnLink (..), InvitationId, OwnerAuth (..), UserId)
import Simplex.Messaging.Agent.Store.AgentStore (firstRow, fromOnlyBI, maybeFirstRow)
+import qualified Simplex.FileTransfer.Description as FD
+import Simplex.Messaging.Encoding (smpDecode, smpEncode)
import Simplex.Messaging.Agent.Store.DB (Binary (..), BoolInt (..))
import Simplex.Messaging.Agent.Store.Entity (DBEntityId)
import qualified Simplex.Messaging.Agent.Store.DB as DB
@@ -359,6 +377,7 @@ createNewGroup db cxt user@User {userId} groupProfile incognitoProfile useRelays
Just PublicGroupProfile {groupType, groupLink, publicGroupId} -> (Just groupType, Just groupLink, Just publicGroupId)
Nothing -> (Nothing, Nothing, Nothing)
fullGroupPreferences = mergeGroupPreferences groupPreferences
+ rosterVersion0 = if useRelays then Just (VersionRoster 0) else Nothing
currentTs <- getCurrentTime
customUserProfileId <- mapM (createIncognitoProfile_ db userId currentTs) incognitoProfile
withLocalDisplayName db userId displayName $ \ldn -> runExceptT $ do
@@ -389,11 +408,11 @@ createNewGroup db cxt user@User {userId} groupProfile incognitoProfile useRelays
INSERT INTO groups
(use_relays, creating_in_progress, local_display_name, user_id, group_profile_id, enable_ntfs,
created_at, updated_at, chat_ts, user_member_profile_sent_at,
- root_priv_key, root_pub_key, member_priv_key, public_member_count)
- VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)
+ root_priv_key, root_pub_key, member_priv_key, public_member_count, roster_version)
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|]
( (BI useRelays, BI useRelays, ldn, userId, profileId, BI True, currentTs, currentTs, currentTs, currentTs)
- :. (rootPrivKey_, rootPubKey_, memberPrivKey_, publicMemberCount_)
+ :. (rootPrivKey_, rootPubKey_, memberPrivKey_, publicMemberCount_, rosterVersion0)
)
insertedRowId db
let memberPubKey = C.publicKey . memberPrivKey <$> groupKeys
@@ -420,6 +439,7 @@ createNewGroup db cxt user@User {userId} groupProfile incognitoProfile useRelays
chatItemTTL = Nothing,
uiThemes = Nothing,
groupSummary = GroupSummary {currentMembers = 1, publicMemberCount = publicMemberCount_},
+ rosterVersion = rosterVersion0,
customData = Nothing,
membersRequireAttention = 0,
viaGroupLinkUri = Nothing,
@@ -497,6 +517,7 @@ createGroupInvitation db cxt user@User {userId} contact@Contact {contactId, acti
chatItemTTL = Nothing,
uiThemes = Nothing,
groupSummary = GroupSummary {currentMembers = 2, publicMemberCount = Nothing},
+ rosterVersion = Nothing,
customData = Nothing,
membersRequireAttention = 0,
viaGroupLinkUri = Nothing,
@@ -1215,10 +1236,42 @@ getGroupModerators db cxt user@User {userId, userContactId} GroupInfo {groupId}
(groupMemberQuery <> " WHERE m.user_id = ? AND m.group_id = ? AND (m.contact_id IS NULL OR m.contact_id != ?) AND m.member_role IN (?,?,?)")
(userId, groupId, userContactId, GRModerator, GRAdmin, GROwner)
+-- The full roster set - members, moderators and admins - excluding owners (link-anchored) and
+-- left/removed members. For the privileged subset only use getGroupAdminsMods; for plain members
+-- only use getGroupOnlyMembers.
+getGroupRosterMembers :: DB.Connection -> StoreCxt -> User -> GroupInfo -> IO [GroupMember]
+getGroupRosterMembers db cxt user@User {userId, userContactId} GroupInfo {groupId} = do
+ currentTs <- getCurrentTime
+ filter memberCurrent . map (toContactMember currentTs cxt user)
+ <$> DB.query
+ db
+ (groupMemberQuery <> " WHERE m.user_id = ? AND m.group_id = ? AND (m.contact_id IS NULL OR m.contact_id != ?) AND m.member_role IN (?,?,?)")
+ (userId, groupId, userContactId, GRMember, GRModerator, GRAdmin)
+
+-- Moderators and admins only (excluding owners and plain members) - the set introduced to a
+-- joiner; plain members are learned from the roster blob, not via introductions.
+getGroupAdminsMods :: DB.Connection -> StoreCxt -> User -> GroupInfo -> IO [GroupMember]
+getGroupAdminsMods db cxt user@User {userId, userContactId} GroupInfo {groupId} = do
+ currentTs <- getCurrentTime
+ filter memberCurrent . map (toContactMember currentTs cxt user)
+ <$> DB.query
+ db
+ (groupMemberQuery <> " WHERE m.user_id = ? AND m.group_id = ? AND (m.contact_id IS NULL OR m.contact_id != ?) AND m.member_role IN (?,?)")
+ (userId, groupId, userContactId, GRModerator, GRAdmin)
+
+getGroupOnlyMembers :: DB.Connection -> StoreCxt -> User -> GroupInfo -> IO [GroupMember]
+getGroupOnlyMembers db cxt user@User {userId, userContactId} GroupInfo {groupId} = do
+ currentTs <- getCurrentTime
+ filter memberCurrent . map (toContactMember currentTs cxt user)
+ <$> DB.query
+ db
+ (groupMemberQuery <> " WHERE m.user_id = ? AND m.group_id = ? AND (m.contact_id IS NULL OR m.contact_id != ?) AND m.member_role = ?")
+ (userId, groupId, userContactId, GRMember)
+
getGroupOwners :: DB.Connection -> StoreCxt -> User -> GroupInfo -> IO [GroupMember]
getGroupOwners db cxt user@User {userId, userContactId} GroupInfo {groupId} = do
- ts <- getCurrentTime
- map (toContactMember ts cxt user)
+ currentTs <- getCurrentTime
+ filter memberCurrent . map (toContactMember currentTs cxt user)
<$> DB.query
db
(groupMemberQuery <> " WHERE m.user_id = ? AND m.group_id = ? AND (m.contact_id IS NULL OR m.contact_id != ?) AND m.member_role = ?")
@@ -1373,21 +1426,30 @@ getGroupRelays db GroupInfo {groupId} =
(groupRelayQuery <> " WHERE gr.group_id = ?")
(Only groupId)
-getConnectedGroupRelays :: DB.Connection -> GroupInfo -> IO [GroupRelay]
-getConnectedGroupRelays db GroupInfo {groupId} =
- map toGroupRelay
- <$> DB.query
- db
- ( groupRelayQuery
- <> " "
- <> [sql|
- JOIN group_members m ON m.group_member_id = gr.group_member_id
- WHERE gr.group_id = ?
- AND m.member_status = ?
- AND gr.relay_status IN (?,?)
- |]
- )
- (groupId, GSMemConnected, RSAccepted, RSActive)
+-- Relays whose link is published to subscribers: acked relays (RSAcknowledgedRoster/RSActive) plus
+-- pre-roster relays at RSAccepted (below groupRosterVersion, they can't ack a roster), gated by the
+-- relay's negotiated version read from its member connection.
+getPublishableGroupRelays :: DB.Connection -> StoreCxt -> User -> GroupInfo -> IO [GroupRelay]
+getPublishableGroupRelays db cxt user gInfo@GroupInfo {groupId} = do
+ relays <-
+ map toGroupRelay
+ <$> DB.query
+ db
+ ( groupRelayQuery
+ <> " "
+ <> [sql|
+ JOIN group_members m ON m.group_member_id = gr.group_member_id
+ WHERE gr.group_id = ?
+ AND m.member_status = ?
+ AND gr.relay_status IN (?,?,?)
+ |]
+ )
+ (groupId, GSMemConnected, RSAccepted, RSAcknowledgedRoster, RSActive)
+ members <- getGroupRelayMembers db cxt user gInfo
+ pure [gr | gr@GroupRelay {groupMemberId} <- relays, m <- members, groupMemberId' m == groupMemberId, publishable gr m]
+ where
+ publishable GroupRelay {relayStatus} m =
+ relayStatus /= RSAccepted || not (m `supportsVersion` groupRosterVersion)
groupRelayQuery :: Query
groupRelayQuery =
@@ -1405,6 +1467,149 @@ toGroupRelay ((groupRelayId, groupMemberId, chatRelayId, address, displayName, f
relayCap = RelayCapabilities {webDomain}
in GroupRelay {groupRelayId, groupMemberId, userChatRelay, relayStatus, relayLink, relayCap}
+setGroupRosterVersion :: DB.Connection -> GroupInfo -> VersionRoster -> IO ()
+setGroupRosterVersion db GroupInfo {groupId} v = do
+ currentTs <- getCurrentTime
+ DB.execute db "UPDATE groups SET roster_version = ?, updated_at = ? WHERE group_id = ?" (v, currentTs, groupId)
+
+-- Persisted roster version (the gate baseline; the in-memory gInfo copy is batch-constant and stale on reorder).
+getGroupRosterVersion :: DB.Connection -> GroupInfo -> IO (Maybe VersionRoster)
+getGroupRosterVersion db GroupInfo {groupId} =
+ fmap join . maybeFirstRow fromOnly $
+ DB.query db "SELECT roster_version FROM groups WHERE group_id = ?" (Only groupId)
+
+-- The live roster header a relay re-serves to joiners, with the completed blob served alongside it
+-- (both are written together at completion, so the blob is present whenever the header is).
+getGroupRoster :: DB.Connection -> GroupInfo -> IO (Maybe (GroupMemberId, UTCTime, SignedMsg, Maybe ByteString))
+getGroupRoster db GroupInfo {groupId} =
+ (>>= toRoster)
+ <$> maybeFirstRow
+ id
+ ( DB.query
+ db
+ "SELECT roster_sending_owner_gm_id, roster_broker_ts, roster_msg_chat_binding, roster_msg_signatures, roster_msg_body, roster_blob FROM groups WHERE group_id = ?"
+ (Only groupId)
+ )
+ where
+ toRoster (Just ownerGMId, Just brokerTs, Just cb, Just (Binary sigsBs), Just (Binary body), blob_) =
+ (\sigs -> (ownerGMId, brokerTs, SignedMsg cb sigs body, (\(Binary b) -> b) <$> blob_)) <$> eitherToMaybe (smpDecode sigsBs)
+ toRoster _ = Nothing
+
+-- A per-source in-flight roster transfer, keyed (group_id, from_member_id): replaces the single
+-- roster_pending_* slot, so two relays serving one member can't share a chunk stream. The signed-header
+-- columns are relay-only (NULL on members), promoted to the live roster_msg_* on groups at completion.
+createRosterTransfer :: DB.Connection -> GroupInfo -> GroupMemberId -> VersionRoster -> FD.FileDigest -> GroupMemberId -> UTCTime -> Maybe SignedMsg -> IO Int64
+createRosterTransfer db GroupInfo {groupId} fromMemberId v digest ownerGMId brokerTs sm_ = do
+ -- one in-flight transfer per (group, source): drop any prior row from this source so the INSERT can't hit
+ -- the UNIQUE constraint even if the caller's fs/handle cleanup was skipped (the scratch file would then leak
+ -- until group delete, but the transfer never gets stuck). Normally cleanupRosterTransfer ran first.
+ DB.execute db "DELETE FROM rcv_roster_transfers WHERE group_id = ? AND from_member_id = ?" (groupId, fromMemberId)
+ DB.execute
+ db
+ [sql|
+ INSERT INTO rcv_roster_transfers
+ (group_id, from_member_id, roster_version, roster_digest, sending_owner_gm_id, broker_ts,
+ roster_msg_chat_binding, roster_msg_signatures, roster_msg_body)
+ VALUES (?,?,?,?,?,?,?,?,?)
+ |]
+ ( (groupId, fromMemberId, v, Binary (FD.unFileDigest digest), ownerGMId, brokerTs)
+ :. ((\SignedMsg {chatBinding} -> chatBinding) <$> sm_, (\SignedMsg {signatures} -> Binary (smpEncode signatures)) <$> sm_, (\SignedMsg {signedBody} -> Binary signedBody) <$> sm_)
+ )
+ insertedRowId db
+
+getRosterTransferVersion :: DB.Connection -> GroupInfo -> GroupMemberId -> IO (Maybe VersionRoster)
+getRosterTransferVersion db GroupInfo {groupId} fromMemberId =
+ maybeFirstRow fromOnly $
+ DB.query db "SELECT roster_version FROM rcv_roster_transfers WHERE group_id = ? AND from_member_id = ?" (groupId, fromMemberId)
+
+getRosterTransferId :: DB.Connection -> GroupInfo -> GroupMemberId -> IO (Maybe Int64)
+getRosterTransferId db GroupInfo {groupId} fromMemberId =
+ maybeFirstRow fromOnly $
+ DB.query db "SELECT roster_transfer_id FROM rcv_roster_transfers WHERE group_id = ? AND from_member_id = ?" (groupId, fromMemberId)
+
+-- An in-flight received roster transfer (a rcv_roster_transfers row joined to its scratch file), read at
+-- completion. The header is the relay's re-serve SignedMsg -- present only on a serving relay (NULL on a
+-- member, whose live roster_msg_* stay NULL so it never re-serves).
+data RcvRosterTransfer = RcvRosterTransfer
+ { rosterTransferId :: Int64,
+ rosterTransferVersion :: VersionRoster,
+ rosterTransferDigest :: FD.FileDigest,
+ rosterTransferOwnerGMId :: GroupMemberId,
+ rosterTransferBrokerTs :: UTCTime,
+ rosterTransferHeader :: Maybe SignedMsg
+ }
+ deriving (Show)
+
+-- The in-flight transfer for a received roster file (joined via files.roster_transfer_id), with its
+-- relay-only signed header. Read at completion to apply, promote into the live roster, and ack.
+getRosterTransfer :: DB.Connection -> Int64 -> IO (Maybe RcvRosterTransfer)
+getRosterTransfer db fileId =
+ (>>= toTransfer)
+ <$> maybeFirstRow
+ id
+ ( DB.query
+ db
+ [sql|
+ SELECT t.roster_transfer_id, t.roster_version, t.roster_digest, t.sending_owner_gm_id, t.broker_ts,
+ t.roster_msg_chat_binding, t.roster_msg_signatures, t.roster_msg_body
+ FROM rcv_roster_transfers t
+ JOIN files f ON f.roster_transfer_id = t.roster_transfer_id
+ WHERE f.file_id = ?
+ |]
+ (Only fileId)
+ )
+ where
+ toTransfer (tId, v, Binary d, ownerGMId, brokerTs, cb_, sigs_, body_) =
+ Just
+ RcvRosterTransfer
+ { rosterTransferId = tId,
+ rosterTransferVersion = v,
+ rosterTransferDigest = FD.FileDigest d,
+ rosterTransferOwnerGMId = ownerGMId,
+ rosterTransferBrokerTs = brokerTs,
+ rosterTransferHeader = sm_
+ }
+ where
+ sm_ = case (cb_, sigs_, body_) of
+ (Just cb, Just (Binary sigsBs), Just (Binary body)) ->
+ (\sigs -> SignedMsg cb sigs body) <$> eitherToMaybe (smpDecode sigsBs)
+ _ -> Nothing
+
+-- Write the single live roster on groups from a completed transfer's values (header NULL on a member,
+-- so its live roster_msg_* stay NULL and it never re-serves; only relays re-serve).
+setGroupLiveRoster :: DB.Connection -> GroupInfo -> VersionRoster -> GroupMemberId -> UTCTime -> Maybe SignedMsg -> ByteString -> IO ()
+setGroupLiveRoster db GroupInfo {groupId} v ownerGMId brokerTs sm_ blob = do
+ currentTs <- getCurrentTime
+ DB.execute
+ db
+ [sql|
+ UPDATE groups SET
+ roster_version = ?, roster_blob = ?,
+ roster_sending_owner_gm_id = ?, roster_broker_ts = ?,
+ roster_msg_chat_binding = ?, roster_msg_signatures = ?, roster_msg_body = ?,
+ updated_at = ?
+ WHERE group_id = ?
+ |]
+ ( (v, Binary blob, ownerGMId, brokerTs)
+ :. ((\SignedMsg {chatBinding} -> chatBinding) <$> sm_, (\SignedMsg {signatures} -> Binary (smpEncode signatures)) <$> sm_, (\SignedMsg {signedBody} -> Binary signedBody) <$> sm_, currentTs, groupId)
+ )
+
+-- Delete one in-flight transfer row (its files/rcv_files/rcv_file_chunks are removed separately, with
+-- the on-disk file). Caller removes the fs file + cached handle first.
+deleteRosterTransfer :: DB.Connection -> Int64 -> IO ()
+deleteRosterTransfer db transferId =
+ DB.execute db "DELETE FROM rcv_roster_transfers WHERE roster_transfer_id = ?" (Only transferId)
+
+-- All in-flight transfers for a group (group delete).
+deleteGroupRosterTransfers :: DB.Connection -> Int64 -> IO ()
+deleteGroupRosterTransfers db groupId =
+ DB.execute db "DELETE FROM rcv_roster_transfers WHERE group_id = ?" (Only groupId)
+
+setGroupMemberKeyRole :: DB.Connection -> GroupMember -> C.PublicKeyEd25519 -> GroupMemberRole -> IO ()
+setGroupMemberKeyRole db GroupMember {groupMemberId} pubKey role = do
+ currentTs <- getCurrentTime
+ DB.execute db "UPDATE group_members SET member_pub_key = ?, member_role = ?, updated_at = ? WHERE group_member_id = ?" (pubKey, role, currentTs, groupMemberId)
+
createRelayForOwner :: DB.Connection -> StoreCxt -> TVar ChaChaDRG -> User -> GroupInfo -> UserChatRelay -> ExceptT StoreError IO GroupMember
createRelayForOwner db cxt gVar user@User {userId, userContactId} GroupInfo {groupId, membership} UserChatRelay {relayProfile = RelayProfile {displayName}} = do
currentTs <- liftIO getCurrentTime
@@ -1713,9 +1918,9 @@ getRelayServedGroups db cxt User {userId, userContactId} = do
<$> DB.query
db
( groupInfoQuery
- <> " WHERE g.user_id = ? AND mu.contact_id = ? AND g.relay_own_status IN (?, ?)"
+ <> " WHERE g.user_id = ? AND mu.contact_id = ? AND g.relay_own_status IN (?, ?, ?)"
)
- (userId, userContactId, RSAccepted, RSActive)
+ (userId, userContactId, RSAccepted, RSAcknowledgedRoster, RSActive)
getRelayPublishableGroups :: DB.Connection -> User -> IO [(Int64, B64UrlByteString, Maybe PublicGroupAccess)]
getRelayPublishableGroups db User {userId, userContactId} =
@@ -3182,11 +3387,11 @@ createLinkOwnerMember db cxt user@User {userId, userContactId} GroupInfo {groupI
where
VersionRange minV maxV = vr cxt
--- member_pub_key is not updated here — introduced members are owners
--- whose keys are loaded from link data (trusted out-of-band).
--- Updating from an in-band message would allow a compromised relay to substitute keys.
+-- Intro refreshes only profile / status / peer version. Role and key stay owner-authoritative
+-- (the owner-signed roster for members/moderators/admins, link data for owners), so taking either from
+-- an in-band relayed intro would let a compromised relay substitute them.
updatePreparedChannelMember :: DB.Connection -> StoreCxt -> User -> GroupMember -> MemberInfo -> ExceptT StoreError IO GroupMember
-updatePreparedChannelMember db cxt user@User {userId} member@GroupMember {groupMemberId, memberChatVRange} MemberInfo {memberRole, v, profile} = do
+updatePreparedChannelMember db cxt user@User {userId} member@GroupMember {groupMemberId, memberChatVRange} MemberInfo {v, profile} = do
_ <- updateMemberProfile db cxt user member profile
currentTs <- liftIO getCurrentTime
liftIO $
@@ -3194,14 +3399,13 @@ updatePreparedChannelMember db cxt user@User {userId} member@GroupMember {groupM
db
[sql|
UPDATE group_members
- SET member_role = ?,
- member_status = ?,
+ SET member_status = ?,
peer_chat_min_version = ?,
peer_chat_max_version = ?,
updated_at = ?
WHERE user_id = ? AND group_member_id = ?
|]
- (memberRole, GSMemIntroduced, minV, maxV, currentTs, userId, groupMemberId)
+ (GSMemIntroduced, minV, maxV, currentTs, userId, groupMemberId)
getGroupMemberById db cxt user groupMemberId
where
VersionRange minV maxV = maybe memberChatVRange fromChatVRange v
@@ -3233,6 +3437,30 @@ updateUnknownMemberAnnounced db cxt user@User {userId} invitingMember unknownMem
VersionRange minV maxV = maybe memberChatVRange fromChatVRange v
memberPubKey_ = (\(MemberKey k) -> k) <$> memberKey
+-- Like updateUnknownMemberAnnounced but preserves member_role and member_pub_key
+-- (roster-established for moderators/admins; the dissemination carries only the profile).
+updateRosterMemberAnnounced :: DB.Connection -> StoreCxt -> User -> GroupMember -> GroupMember -> MemberInfo -> GroupMemberStatus -> ExceptT StoreError IO GroupMember
+updateRosterMemberAnnounced db cxt user@User {userId} invitingMember unknownMember@GroupMember {groupMemberId, memberChatVRange} MemberInfo {v, profile} status = do
+ _ <- updateMemberProfile db cxt user unknownMember profile
+ currentTs <- liftIO getCurrentTime
+ liftIO $
+ DB.execute
+ db
+ [sql|
+ UPDATE group_members
+ SET member_category = ?,
+ member_status = ?,
+ invited_by_group_member_id = ?,
+ peer_chat_min_version = ?,
+ peer_chat_max_version = ?,
+ updated_at = ?
+ WHERE user_id = ? AND group_member_id = ?
+ |]
+ ((GCPostMember, status, groupMemberId' invitingMember) :. (minV, maxV, currentTs, userId, groupMemberId))
+ getGroupMemberById db cxt user groupMemberId
+ where
+ VersionRange minV maxV = maybe memberChatVRange fromChatVRange v
+
updateUserMemberProfileSentAt :: DB.Connection -> User -> GroupInfo -> UTCTime -> IO ()
updateUserMemberProfileSentAt db User {userId} GroupInfo {groupId} sentTs =
DB.execute
diff --git a/src/Simplex/Chat/Store/Messages.hs b/src/Simplex/Chat/Store/Messages.hs
index cf12db7ec1..644d73137d 100644
--- a/src/Simplex/Chat/Store/Messages.hs
+++ b/src/Simplex/Chat/Store/Messages.hs
@@ -238,10 +238,7 @@ createNewSndMessage db gVar connOrGroupId chatMsgEvent msgSigning_ encodeMessage
case encodeMessage (SharedMsgId sharedMsgId) of
ECMLarge -> pure $ Left SELargeMsg
ECMEncoded msgBody -> do
- let signedMsg_ = signBody <$> msgSigning_
- signBody MsgSigning {bindingTag, bindingData, keyRef, privKey} =
- let sig = C.ASignature C.SEd25519 $ C.sign' privKey (encodeChatBinding bindingTag bindingData <> msgBody)
- in SignedMsg {chatBinding = bindingTag, signatures = MsgSignature keyRef sig :| [], signedBody = msgBody}
+ let signedMsg_ = (`signChatMsgBody` msgBody) <$> msgSigning_
createdAt <- getCurrentTime
DB.execute
db
@@ -584,9 +581,9 @@ createNewRcvChatItem db user chatDirection RcvMessage {msgId, chatMsgEvent, msgS
CDChannelRcv GroupInfo {membership = GroupMember {memberId = userMemberId}} _ ->
(Just $ Just userMemberId == memberId, memberId)
-createNewChatItemNoMsg :: forall c d. MsgDirectionI d => DB.Connection -> User -> ChatDirection c d -> ShowGroupAsSender -> CIContent d -> Maybe SharedMsgId -> Bool -> UTCTime -> UTCTime -> IO ChatItemId
-createNewChatItemNoMsg db user chatDirection showGroupAsSender ciContent sharedMsgId_ hasLink itemTs =
- createNewChatItem_ db user chatDirection showGroupAsSender Nothing sharedMsgId_ ciContent quoteRow Nothing Nothing False False hasLink itemTs Nothing Nothing
+createNewChatItemNoMsg :: forall c d. MsgDirectionI d => DB.Connection -> User -> ChatDirection c d -> ShowGroupAsSender -> CIContent d -> Maybe SharedMsgId -> Bool -> Maybe MsgSigStatus -> UTCTime -> UTCTime -> IO ChatItemId
+createNewChatItemNoMsg db user chatDirection showGroupAsSender ciContent sharedMsgId_ hasLink msgSigned itemTs =
+ createNewChatItem_ db user chatDirection showGroupAsSender Nothing sharedMsgId_ ciContent quoteRow Nothing Nothing False False hasLink itemTs Nothing msgSigned
where
quoteRow :: NewQuoteRow
quoteRow = (Nothing, Nothing, Nothing, Nothing, Nothing)
diff --git a/src/Simplex/Chat/Store/Postgres/Migrations.hs b/src/Simplex/Chat/Store/Postgres/Migrations.hs
index 20acf0b602..4b814d0434 100644
--- a/src/Simplex/Chat/Store/Postgres/Migrations.hs
+++ b/src/Simplex/Chat/Store/Postgres/Migrations.hs
@@ -37,6 +37,7 @@ import Simplex.Chat.Store.Postgres.Migrations.M20260529_delivery_job_senders
import Simplex.Chat.Store.Postgres.Migrations.M20260530_client_services
import Simplex.Chat.Store.Postgres.Migrations.M20260531_member_removed_at
import Simplex.Chat.Store.Postgres.Migrations.M20260601_relay_sent_web_domain
+import Simplex.Chat.Store.Postgres.Migrations.M20260602_group_roster
import Simplex.Messaging.Agent.Store.Shared (Migration (..))
schemaMigrations :: [(String, Text, Maybe Text)]
@@ -73,7 +74,8 @@ schemaMigrations =
("20260529_delivery_job_senders", m20260529_delivery_job_senders, Just down_m20260529_delivery_job_senders),
("20260530_client_services", m20260530_client_services, Just down_m20260530_client_services),
("20260531_member_removed_at", m20260531_member_removed_at, Just down_m20260531_member_removed_at),
- ("20260601_relay_sent_web_domain", m20260601_relay_sent_web_domain, Just down_m20260601_relay_sent_web_domain)
+ ("20260601_relay_sent_web_domain", m20260601_relay_sent_web_domain, Just down_m20260601_relay_sent_web_domain),
+ ("20260602_group_roster", m20260602_group_roster, Just down_m20260602_group_roster)
]
-- | The list of migrations in ascending order by date
diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/M20260602_group_roster.hs b/src/Simplex/Chat/Store/Postgres/Migrations/M20260602_group_roster.hs
new file mode 100644
index 0000000000..892b2c70da
--- /dev/null
+++ b/src/Simplex/Chat/Store/Postgres/Migrations/M20260602_group_roster.hs
@@ -0,0 +1,64 @@
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE QuasiQuotes #-}
+
+module Simplex.Chat.Store.Postgres.Migrations.M20260602_group_roster where
+
+import Data.Text (Text)
+import Text.RawString.QQ (r)
+
+m20260602_group_roster :: Text
+m20260602_group_roster =
+ [r|
+ALTER TABLE groups ADD COLUMN roster_version BIGINT;
+ALTER TABLE groups ADD COLUMN roster_msg_body BYTEA;
+ALTER TABLE groups ADD COLUMN roster_msg_chat_binding TEXT;
+ALTER TABLE groups ADD COLUMN roster_msg_signatures BYTEA;
+ALTER TABLE groups ADD COLUMN roster_sending_owner_gm_id BIGINT;
+ALTER TABLE groups ADD COLUMN roster_broker_ts TIMESTAMPTZ;
+ALTER TABLE groups ADD COLUMN roster_blob BYTEA;
+
+CREATE TABLE rcv_roster_transfers(
+ roster_transfer_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
+ group_id BIGINT NOT NULL REFERENCES groups ON DELETE CASCADE,
+ from_member_id BIGINT NOT NULL REFERENCES group_members ON DELETE CASCADE,
+ roster_version BIGINT NOT NULL,
+ roster_digest BYTEA NOT NULL,
+ sending_owner_gm_id BIGINT NOT NULL,
+ broker_ts TIMESTAMPTZ NOT NULL,
+ roster_msg_body BYTEA,
+ roster_msg_chat_binding TEXT,
+ roster_msg_signatures BYTEA,
+ created_at TEXT NOT NULL DEFAULT (now()),
+ updated_at TEXT NOT NULL DEFAULT (now())
+);
+CREATE UNIQUE INDEX idx_rcv_roster_transfers_group_id_from_member_id ON rcv_roster_transfers(group_id, from_member_id);
+CREATE INDEX idx_rcv_roster_transfers_from_member_id ON rcv_roster_transfers(from_member_id);
+
+ALTER TABLE files ADD COLUMN shared_msg_id BYTEA;
+ALTER TABLE files ADD COLUMN file_type TEXT NOT NULL DEFAULT 'normal';
+ALTER TABLE files ADD COLUMN roster_transfer_id BIGINT;
+CREATE INDEX idx_files_group_id_shared_msg_id ON files(group_id, shared_msg_id);
+CREATE INDEX idx_files_roster_transfer_id ON files(roster_transfer_id);
+|]
+
+down_m20260602_group_roster :: Text
+down_m20260602_group_roster =
+ [r|
+DROP INDEX idx_files_roster_transfer_id;
+DROP INDEX idx_files_group_id_shared_msg_id;
+ALTER TABLE files DROP COLUMN roster_transfer_id;
+ALTER TABLE files DROP COLUMN file_type;
+ALTER TABLE files DROP COLUMN shared_msg_id;
+
+DROP INDEX idx_rcv_roster_transfers_from_member_id;
+DROP INDEX idx_rcv_roster_transfers_group_id_from_member_id;
+DROP TABLE rcv_roster_transfers;
+
+ALTER TABLE groups DROP COLUMN roster_blob;
+ALTER TABLE groups DROP COLUMN roster_broker_ts;
+ALTER TABLE groups DROP COLUMN roster_sending_owner_gm_id;
+ALTER TABLE groups DROP COLUMN roster_msg_signatures;
+ALTER TABLE groups DROP COLUMN roster_msg_chat_binding;
+ALTER TABLE groups DROP COLUMN roster_msg_body;
+ALTER TABLE groups DROP COLUMN roster_version;
+|]
diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql
index 89cddd48e5..861224ff56 100644
--- a/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql
+++ b/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql
@@ -752,7 +752,10 @@ CREATE TABLE test_chat_schema.files (
file_crypto_key bytea,
file_crypto_nonce bytea,
note_folder_id bigint,
- redirect_file_id bigint
+ redirect_file_id bigint,
+ shared_msg_id bytea,
+ file_type text DEFAULT 'normal'::text NOT NULL,
+ roster_transfer_id bigint
);
@@ -977,9 +980,16 @@ CREATE TABLE test_chat_schema.groups (
public_member_count bigint,
relay_request_retries bigint DEFAULT 0 NOT NULL,
relay_request_delay bigint DEFAULT 0 NOT NULL,
- relay_request_execute_at timestamp with time zone DEFAULT '1970-01-01 01:00:00+01'::timestamp with time zone NOT NULL,
+ relay_request_execute_at timestamp with time zone DEFAULT '1970-01-01 04:00:00+04'::timestamp with time zone NOT NULL,
relay_inactive_at timestamp with time zone,
- relay_sent_web_domain text
+ relay_sent_web_domain text,
+ roster_version bigint,
+ roster_msg_body bytea,
+ roster_msg_chat_binding text,
+ roster_msg_signatures bytea,
+ roster_sending_owner_gm_id bigint,
+ roster_broker_ts timestamp with time zone,
+ roster_blob bytea
);
@@ -1206,6 +1216,34 @@ CREATE TABLE test_chat_schema.rcv_files (
+CREATE TABLE test_chat_schema.rcv_roster_transfers (
+ roster_transfer_id bigint NOT NULL,
+ group_id bigint NOT NULL,
+ from_member_id bigint NOT NULL,
+ roster_version bigint NOT NULL,
+ roster_digest bytea NOT NULL,
+ sending_owner_gm_id bigint NOT NULL,
+ broker_ts timestamp with time zone NOT NULL,
+ roster_msg_body bytea,
+ roster_msg_chat_binding text,
+ roster_msg_signatures bytea,
+ created_at text DEFAULT now() NOT NULL,
+ updated_at text DEFAULT now() NOT NULL
+);
+
+
+
+ALTER TABLE test_chat_schema.rcv_roster_transfers ALTER COLUMN roster_transfer_id ADD GENERATED ALWAYS AS IDENTITY (
+ SEQUENCE NAME test_chat_schema.rcv_roster_transfers_roster_transfer_id_seq
+ START WITH 1
+ INCREMENT BY 1
+ NO MINVALUE
+ NO MAXVALUE
+ CACHE 1
+);
+
+
+
CREATE TABLE test_chat_schema.received_probes (
received_probe_id bigint NOT NULL,
contact_id bigint,
@@ -1739,6 +1777,11 @@ ALTER TABLE ONLY test_chat_schema.rcv_files
+ALTER TABLE ONLY test_chat_schema.rcv_roster_transfers
+ ADD CONSTRAINT rcv_roster_transfers_pkey PRIMARY KEY (roster_transfer_id);
+
+
+
ALTER TABLE ONLY test_chat_schema.received_probes
ADD CONSTRAINT received_probes_pkey PRIMARY KEY (received_probe_id);
@@ -2272,10 +2315,18 @@ CREATE INDEX idx_files_group_id ON test_chat_schema.files USING btree (group_id)
+CREATE INDEX idx_files_group_id_shared_msg_id ON test_chat_schema.files USING btree (group_id, shared_msg_id);
+
+
+
CREATE INDEX idx_files_redirect_file_id ON test_chat_schema.files USING btree (redirect_file_id);
+CREATE INDEX idx_files_roster_transfer_id ON test_chat_schema.files USING btree (roster_transfer_id);
+
+
+
CREATE INDEX idx_files_user_id ON test_chat_schema.files USING btree (user_id);
@@ -2448,6 +2499,14 @@ CREATE INDEX idx_rcv_files_group_member_id ON test_chat_schema.rcv_files USING b
+CREATE INDEX idx_rcv_roster_transfers_from_member_id ON test_chat_schema.rcv_roster_transfers USING btree (from_member_id);
+
+
+
+CREATE UNIQUE INDEX idx_rcv_roster_transfers_group_id_from_member_id ON test_chat_schema.rcv_roster_transfers USING btree (group_id, from_member_id);
+
+
+
CREATE INDEX idx_received_probes_contact_id ON test_chat_schema.received_probes USING btree (contact_id);
@@ -3133,6 +3192,16 @@ ALTER TABLE ONLY test_chat_schema.rcv_files
+ALTER TABLE ONLY test_chat_schema.rcv_roster_transfers
+ ADD CONSTRAINT rcv_roster_transfers_from_member_id_fkey FOREIGN KEY (from_member_id) REFERENCES test_chat_schema.group_members(group_member_id) ON DELETE CASCADE;
+
+
+
+ALTER TABLE ONLY test_chat_schema.rcv_roster_transfers
+ ADD CONSTRAINT rcv_roster_transfers_group_id_fkey FOREIGN KEY (group_id) REFERENCES test_chat_schema.groups(group_id) ON DELETE CASCADE;
+
+
+
ALTER TABLE ONLY test_chat_schema.received_probes
ADD CONSTRAINT received_probes_contact_id_fkey FOREIGN KEY (contact_id) REFERENCES test_chat_schema.contacts(contact_id) ON DELETE CASCADE;
diff --git a/src/Simplex/Chat/Store/SQLite/Migrations.hs b/src/Simplex/Chat/Store/SQLite/Migrations.hs
index 78838c507f..c9dd316aee 100644
--- a/src/Simplex/Chat/Store/SQLite/Migrations.hs
+++ b/src/Simplex/Chat/Store/SQLite/Migrations.hs
@@ -160,6 +160,7 @@ import Simplex.Chat.Store.SQLite.Migrations.M20260529_delivery_job_senders
import Simplex.Chat.Store.SQLite.Migrations.M20260530_client_services
import Simplex.Chat.Store.SQLite.Migrations.M20260531_member_removed_at
import Simplex.Chat.Store.SQLite.Migrations.M20260601_relay_sent_web_domain
+import Simplex.Chat.Store.SQLite.Migrations.M20260602_group_roster
import Simplex.Messaging.Agent.Store.Shared (Migration (..))
schemaMigrations :: [(String, Query, Maybe Query)]
@@ -319,7 +320,8 @@ schemaMigrations =
("20260529_delivery_job_senders", m20260529_delivery_job_senders, Just down_m20260529_delivery_job_senders),
("20260530_client_services", m20260530_client_services, Just down_m20260530_client_services),
("20260531_member_removed_at", m20260531_member_removed_at, Just down_m20260531_member_removed_at),
- ("20260601_relay_sent_web_domain", m20260601_relay_sent_web_domain, Just down_m20260601_relay_sent_web_domain)
+ ("20260601_relay_sent_web_domain", m20260601_relay_sent_web_domain, Just down_m20260601_relay_sent_web_domain),
+ ("20260602_group_roster", m20260602_group_roster, Just down_m20260602_group_roster)
]
-- | The list of migrations in ascending order by date
diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20260602_group_roster.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20260602_group_roster.hs
new file mode 100644
index 0000000000..d68fea3a56
--- /dev/null
+++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20260602_group_roster.hs
@@ -0,0 +1,63 @@
+{-# LANGUAGE QuasiQuotes #-}
+
+module Simplex.Chat.Store.SQLite.Migrations.M20260602_group_roster where
+
+import Database.SQLite.Simple (Query)
+import Database.SQLite.Simple.QQ (sql)
+
+m20260602_group_roster :: Query
+m20260602_group_roster =
+ [sql|
+ALTER TABLE groups ADD COLUMN roster_version INTEGER;
+ALTER TABLE groups ADD COLUMN roster_msg_body BLOB;
+ALTER TABLE groups ADD COLUMN roster_msg_chat_binding TEXT;
+ALTER TABLE groups ADD COLUMN roster_msg_signatures BLOB;
+ALTER TABLE groups ADD COLUMN roster_sending_owner_gm_id INTEGER;
+ALTER TABLE groups ADD COLUMN roster_broker_ts TEXT;
+ALTER TABLE groups ADD COLUMN roster_blob BLOB;
+
+CREATE TABLE rcv_roster_transfers(
+ roster_transfer_id INTEGER PRIMARY KEY,
+ group_id INTEGER NOT NULL REFERENCES groups ON DELETE CASCADE,
+ from_member_id INTEGER NOT NULL REFERENCES group_members ON DELETE CASCADE,
+ roster_version INTEGER NOT NULL,
+ roster_digest BLOB NOT NULL,
+ sending_owner_gm_id INTEGER NOT NULL,
+ broker_ts TEXT NOT NULL,
+ roster_msg_body BLOB,
+ roster_msg_chat_binding TEXT,
+ roster_msg_signatures BLOB,
+ created_at TEXT NOT NULL DEFAULT(datetime('now')),
+ updated_at TEXT NOT NULL DEFAULT(datetime('now'))
+) STRICT;
+CREATE UNIQUE INDEX idx_rcv_roster_transfers_group_id_from_member_id ON rcv_roster_transfers(group_id, from_member_id);
+CREATE INDEX idx_rcv_roster_transfers_from_member_id ON rcv_roster_transfers(from_member_id);
+
+ALTER TABLE files ADD COLUMN shared_msg_id BLOB;
+ALTER TABLE files ADD COLUMN file_type TEXT NOT NULL DEFAULT 'normal';
+ALTER TABLE files ADD COLUMN roster_transfer_id INTEGER;
+CREATE INDEX idx_files_group_id_shared_msg_id ON files(group_id, shared_msg_id);
+CREATE INDEX idx_files_roster_transfer_id ON files(roster_transfer_id);
+|]
+
+down_m20260602_group_roster :: Query
+down_m20260602_group_roster =
+ [sql|
+DROP INDEX idx_files_roster_transfer_id;
+DROP INDEX idx_files_group_id_shared_msg_id;
+ALTER TABLE files DROP COLUMN roster_transfer_id;
+ALTER TABLE files DROP COLUMN file_type;
+ALTER TABLE files DROP COLUMN shared_msg_id;
+
+DROP INDEX idx_rcv_roster_transfers_from_member_id;
+DROP INDEX idx_rcv_roster_transfers_group_id_from_member_id;
+DROP TABLE rcv_roster_transfers;
+
+ALTER TABLE groups DROP COLUMN roster_blob;
+ALTER TABLE groups DROP COLUMN roster_broker_ts;
+ALTER TABLE groups DROP COLUMN roster_sending_owner_gm_id;
+ALTER TABLE groups DROP COLUMN roster_msg_signatures;
+ALTER TABLE groups DROP COLUMN roster_msg_chat_binding;
+ALTER TABLE groups DROP COLUMN roster_msg_body;
+ALTER TABLE groups DROP COLUMN roster_version;
+|]
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 0cac477bd5..fb6166f8c9 100644
--- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt
+++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt
@@ -30,6 +30,7 @@ Query:
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
Plan:
+SEARCH rcv_roster_transfers USING COVERING INDEX idx_rcv_roster_transfers_from_member_id (from_member_id=?)
SEARCH group_relays USING COVERING INDEX idx_group_relays_group_member_id (group_member_id=?)
SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?)
SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?)
@@ -82,6 +83,7 @@ Query:
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
Plan:
+SEARCH rcv_roster_transfers USING COVERING INDEX idx_rcv_roster_transfers_from_member_id (from_member_id=?)
SEARCH group_relays USING COVERING INDEX idx_group_relays_group_member_id (group_member_id=?)
SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?)
SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?)
@@ -148,7 +150,7 @@ Query:
g.conn_full_link_to_connect, g.conn_short_link_to_connect, g.conn_link_prepared_connection, g.conn_link_started_connection, g.welcome_shared_msg_id, g.request_shared_msg_id,
g.business_chat, g.business_member_id, g.customer_member_id,
g.use_relays, g.relay_own_status,
- g.ui_themes, g.summary_current_members_count, g.public_member_count, g.custom_data, g.chat_item_ttl, g.members_require_attention, g.via_group_link_uri,
+ g.ui_themes, g.summary_current_members_count, g.public_member_count, g.roster_version, g.custom_data, g.chat_item_ttl, g.members_require_attention, g.via_group_link_uri,
g.root_priv_key, g.root_pub_key, g.member_priv_key,
-- GroupInfo {membership}
mu.group_member_id, mu.group_id, mu.index_in_group, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category,
@@ -286,6 +288,7 @@ Query:
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)
Plan:
+SEARCH rcv_roster_transfers USING COVERING INDEX idx_rcv_roster_transfers_from_member_id (from_member_id=?)
SEARCH group_relays USING COVERING INDEX idx_group_relays_group_member_id (group_member_id=?)
SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?)
SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?)
@@ -320,6 +323,7 @@ Query:
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
Plan:
+SEARCH rcv_roster_transfers USING COVERING INDEX idx_rcv_roster_transfers_from_member_id (from_member_id=?)
SEARCH group_relays USING COVERING INDEX idx_group_relays_group_member_id (group_member_id=?)
SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?)
SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?)
@@ -354,6 +358,7 @@ Query:
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
Plan:
+SEARCH rcv_roster_transfers USING COVERING INDEX idx_rcv_roster_transfers_from_member_id (from_member_id=?)
SEARCH group_relays USING COVERING INDEX idx_group_relays_group_member_id (group_member_id=?)
SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?)
SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?)
@@ -537,6 +542,7 @@ Query:
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
Plan:
+SEARCH rcv_roster_transfers USING COVERING INDEX idx_rcv_roster_transfers_from_member_id (from_member_id=?)
SEARCH group_relays USING COVERING INDEX idx_group_relays_group_member_id (group_member_id=?)
SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?)
SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?)
@@ -570,6 +576,7 @@ Query:
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)
Plan:
+SEARCH rcv_roster_transfers USING COVERING INDEX idx_rcv_roster_transfers_from_member_id (from_member_id=?)
SEARCH group_relays USING COVERING INDEX idx_group_relays_group_member_id (group_member_id=?)
SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?)
SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?)
@@ -604,6 +611,7 @@ Query:
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)
Plan:
+SEARCH rcv_roster_transfers USING COVERING INDEX idx_rcv_roster_transfers_from_member_id (from_member_id=?)
SEARCH group_relays USING COVERING INDEX idx_group_relays_group_member_id (group_member_id=?)
SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?)
SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?)
@@ -638,6 +646,7 @@ Query:
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
Plan:
+SEARCH rcv_roster_transfers USING COVERING INDEX idx_rcv_roster_transfers_from_member_id (from_member_id=?)
SEARCH group_relays USING COVERING INDEX idx_group_relays_group_member_id (group_member_id=?)
SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?)
SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?)
@@ -1082,6 +1091,17 @@ SEARCH m USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN
SEARCH g USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN
SEARCH h USING INDEX idx_sent_probe_hashes_sent_probe_id (sent_probe_id=?)
+Query:
+ SELECT t.roster_transfer_id, t.roster_version, t.roster_digest, t.sending_owner_gm_id, t.broker_ts,
+ t.roster_msg_chat_binding, t.roster_msg_signatures, t.roster_msg_body
+ FROM rcv_roster_transfers t
+ JOIN files f ON f.roster_transfer_id = t.roster_transfer_id
+ WHERE f.file_id = ?
+
+Plan:
+SEARCH f USING INTEGER PRIMARY KEY (rowid=?)
+SEARCH t USING INTEGER PRIMARY KEY (rowid=?)
+
Query:
UPDATE chat_items
SET user_id = ?, updated_at = ?
@@ -1182,6 +1202,7 @@ Query:
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)
Plan:
+SEARCH rcv_roster_transfers USING COVERING INDEX idx_rcv_roster_transfers_from_member_id (from_member_id=?)
SEARCH group_relays USING COVERING INDEX idx_group_relays_group_member_id (group_member_id=?)
SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?)
SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?)
@@ -1217,6 +1238,7 @@ Query:
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
Plan:
+SEARCH rcv_roster_transfers USING COVERING INDEX idx_rcv_roster_transfers_from_member_id (from_member_id=?)
SEARCH group_relays USING COVERING INDEX idx_group_relays_group_member_id (group_member_id=?)
SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?)
SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?)
@@ -1266,8 +1288,8 @@ Query:
INSERT INTO groups
(use_relays, creating_in_progress, local_display_name, user_id, group_profile_id, enable_ntfs,
created_at, updated_at, chat_ts, user_member_profile_sent_at,
- root_priv_key, root_pub_key, member_priv_key, public_member_count)
- VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)
+ root_priv_key, root_pub_key, member_priv_key, public_member_count, roster_version)
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
Plan:
@@ -1669,7 +1691,7 @@ Query:
SELECT r.file_status, r.file_queue_info, r.group_member_id, f.file_name,
f.file_size, f.chunk_size, f.cancelled, cs.local_display_name, m.local_display_name,
f.file_path, f.file_crypto_key, f.file_crypto_nonce, r.file_inline, r.rcv_file_inline,
- r.agent_rcv_file_id, r.agent_rcv_file_deleted, r.user_approved_relays, g.local_display_name
+ r.agent_rcv_file_id, r.agent_rcv_file_deleted, r.user_approved_relays, g.local_display_name, f.file_type
FROM rcv_files r
JOIN files f USING (file_id)
LEFT JOIN contacts cs ON cs.contact_id = f.contact_id
@@ -1865,6 +1887,7 @@ Query:
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
Plan:
+SEARCH rcv_roster_transfers USING COVERING INDEX idx_rcv_roster_transfers_from_member_id (from_member_id=?)
SEARCH group_relays USING COVERING INDEX idx_group_relays_group_member_id (group_member_id=?)
SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?)
SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?)
@@ -1899,6 +1922,7 @@ Query:
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
Plan:
+SEARCH rcv_roster_transfers USING COVERING INDEX idx_rcv_roster_transfers_from_member_id (from_member_id=?)
SEARCH group_relays USING COVERING INDEX idx_group_relays_group_member_id (group_member_id=?)
SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?)
SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?)
@@ -1946,6 +1970,17 @@ Query:
Plan:
+Query:
+ INSERT INTO rcv_file_chunks (file_id, chunk_number, chunk_agent_msg_id, created_at, updated_at)
+ VALUES (?,?,?,?,?)
+ ON CONFLICT (file_id, chunk_number) DO UPDATE SET
+ chunk_agent_msg_id = excluded.chunk_agent_msg_id,
+ chunk_stored = 0,
+ created_at = excluded.created_at,
+ updated_at = excluded.updated_at
+
+Plan:
+
Query:
INSERT INTO remote_hosts
(host_device_name, store_path, bind_addr, bind_iface, bind_port, ca_key, ca_cert, id_key, host_fingerprint, host_dh_pub)
@@ -3708,6 +3743,15 @@ Plan:
SEARCH i USING COVERING INDEX idx_chat_items_group_shared_msg_id (user_id=? AND group_id=?)
SEARCH f USING COVERING INDEX idx_files_chat_item_id (chat_item_id=?)
+Query:
+ SELECT f.file_id FROM files f
+ JOIN rcv_files r ON r.file_id = f.file_id
+ WHERE f.user_id = ? AND f.group_id = ? AND f.shared_msg_id = ? AND f.file_type = ?
+ AND r.group_member_id = ?
+Plan:
+SEARCH f USING INDEX idx_files_group_id_shared_msg_id (group_id=? AND shared_msg_id=?)
+SEARCH r USING COVERING INDEX idx_rcv_files_group_member_id (group_member_id=? AND rowid=?)
+
Query:
SELECT file_id, contact_id, group_id, note_folder_id
FROM files
@@ -4059,6 +4103,19 @@ Query:
Plan:
SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?)
+Query:
+ UPDATE group_members
+ SET member_category = ?,
+ member_status = ?,
+ invited_by_group_member_id = ?,
+ peer_chat_min_version = ?,
+ peer_chat_max_version = ?,
+ updated_at = ?
+ WHERE user_id = ? AND group_member_id = ?
+
+Plan:
+SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?)
+
Query:
UPDATE group_members
SET member_id = ?, member_pub_key = ?, updated_at = ?
@@ -4077,8 +4134,7 @@ SCAN group_members
Query:
UPDATE group_members
- SET member_role = ?,
- member_status = ?,
+ SET member_status = ?,
peer_chat_min_version = ?,
peer_chat_max_version = ?,
updated_at = ?
@@ -4783,6 +4839,14 @@ Query:
Plan:
+Query:
+ INSERT INTO rcv_roster_transfers
+ (group_id, from_member_id, roster_version, roster_digest, sending_owner_gm_id, broker_ts,
+ roster_msg_chat_binding, roster_msg_signatures, roster_msg_body)
+ VALUES (?,?,?,?,?,?,?,?,?)
+
+Plan:
+
Query:
INSERT INTO remote_controllers
(ctrl_device_name, ca_key, ca_cert, ctrl_fingerprint, id_pub, dh_priv_key, prev_dh_priv_key)
@@ -5259,6 +5323,17 @@ Query:
Plan:
SEARCH groups USING INTEGER PRIMARY KEY (rowid=?)
+Query:
+ UPDATE groups SET
+ roster_version = ?, roster_blob = ?,
+ roster_sending_owner_gm_id = ?, roster_broker_ts = ?,
+ roster_msg_chat_binding = ?, roster_msg_signatures = ?, roster_msg_body = ?,
+ updated_at = ?
+ WHERE group_id = ?
+
+Plan:
+SEARCH groups USING INTEGER PRIMARY KEY (rowid=?)
+
Query:
UPDATE msg_deliveries
SET delivery_status = ?, updated_at = ?
@@ -5276,6 +5351,14 @@ Query:
Plan:
SEARCH protocol_servers USING INTEGER PRIMARY KEY (rowid=?)
+Query:
+ UPDATE rcv_file_chunks
+ SET chunk_stored = 1, updated_at = ?
+ WHERE file_id = ? AND chunk_number = ?
+
+Plan:
+SEARCH rcv_file_chunks USING PRIMARY KEY (file_id=? AND chunk_number=?)
+
Query:
UPDATE rcv_files
SET to_receive = 1, user_approved_relays = ?, updated_at = ?
@@ -5391,7 +5474,7 @@ Query:
g.conn_full_link_to_connect, g.conn_short_link_to_connect, g.conn_link_prepared_connection, g.conn_link_started_connection, g.welcome_shared_msg_id, g.request_shared_msg_id,
g.business_chat, g.business_member_id, g.customer_member_id,
g.use_relays, g.relay_own_status,
- g.ui_themes, g.summary_current_members_count, g.public_member_count, g.custom_data, g.chat_item_ttl, g.members_require_attention, g.via_group_link_uri,
+ g.ui_themes, g.summary_current_members_count, g.public_member_count, g.roster_version, g.custom_data, g.chat_item_ttl, g.members_require_attention, g.via_group_link_uri,
g.root_priv_key, g.root_pub_key, g.member_priv_key,
-- GroupMember - membership
mu.group_member_id, mu.group_id, mu.index_in_group, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category,
@@ -5429,7 +5512,7 @@ Query:
g.conn_full_link_to_connect, g.conn_short_link_to_connect, g.conn_link_prepared_connection, g.conn_link_started_connection, g.welcome_shared_msg_id, g.request_shared_msg_id,
g.business_chat, g.business_member_id, g.customer_member_id,
g.use_relays, g.relay_own_status,
- g.ui_themes, g.summary_current_members_count, g.public_member_count, g.custom_data, g.chat_item_ttl, g.members_require_attention, g.via_group_link_uri,
+ g.ui_themes, g.summary_current_members_count, g.public_member_count, g.roster_version, g.custom_data, g.chat_item_ttl, g.members_require_attention, g.via_group_link_uri,
g.root_priv_key, g.root_pub_key, g.member_priv_key,
-- GroupMember - membership
mu.group_member_id, mu.group_id, mu.index_in_group, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category,
@@ -5460,7 +5543,7 @@ Query:
g.conn_full_link_to_connect, g.conn_short_link_to_connect, g.conn_link_prepared_connection, g.conn_link_started_connection, g.welcome_shared_msg_id, g.request_shared_msg_id,
g.business_chat, g.business_member_id, g.customer_member_id,
g.use_relays, g.relay_own_status,
- g.ui_themes, g.summary_current_members_count, g.public_member_count, g.custom_data, g.chat_item_ttl, g.members_require_attention, g.via_group_link_uri,
+ g.ui_themes, g.summary_current_members_count, g.public_member_count, g.roster_version, g.custom_data, g.chat_item_ttl, g.members_require_attention, g.via_group_link_uri,
g.root_priv_key, g.root_pub_key, g.member_priv_key,
-- GroupMember - membership
mu.group_member_id, mu.group_id, mu.index_in_group, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category,
@@ -5742,6 +5825,26 @@ SEARCH m USING INDEX idx_group_members_group_id (user_id=? AND group_id=?)
SEARCH p USING INTEGER PRIMARY KEY (rowid=?)
SEARCH c USING INDEX idx_connections_group_member_id (group_member_id=?) LEFT-JOIN
+Query:
+ SELECT
+ m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction,
+ m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences,
+ p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx,
+ m.created_at, m.updated_at,
+ m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link,
+ c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.xcontact_id, c.custom_user_profile_id,
+ c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.user_contact_link_id,
+ c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter,
+ c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version
+ FROM group_members m
+ JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id)
+ LEFT JOIN connections c ON c.group_member_id = m.group_member_id
+ WHERE m.user_id = ? AND m.group_id = ? AND (m.contact_id IS NULL OR m.contact_id != ?) AND m.member_role IN (?,?)
+Plan:
+SEARCH m USING INDEX idx_group_members_group_id (user_id=? AND group_id=?)
+SEARCH p USING INTEGER PRIMARY KEY (rowid=?)
+SEARCH c USING INDEX idx_connections_group_member_id (group_member_id=?) LEFT-JOIN
+
Query:
SELECT
m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction,
@@ -5872,11 +5975,11 @@ Query:
FROM group_relays gr
JOIN chat_relays cr ON cr.chat_relay_id = gr.chat_relay_id
- JOIN group_members m ON m.group_member_id = gr.group_member_id
- WHERE gr.group_id = ?
- AND m.member_status = ?
- AND gr.relay_status IN (?,?)
-
+ JOIN group_members m ON m.group_member_id = gr.group_member_id
+ WHERE gr.group_id = ?
+ AND m.member_status = ?
+ AND gr.relay_status IN (?,?,?)
+
Plan:
SEARCH gr USING INDEX idx_group_relays_group_id (group_id=?)
SEARCH cr USING INTEGER PRIMARY KEY (rowid=?)
@@ -6491,6 +6594,14 @@ SEARCH groups USING COVERING INDEX sqlite_autoindex_groups_1 (user_id=? AND loca
SEARCH contacts USING COVERING INDEX sqlite_autoindex_contacts_1 (user_id=? AND local_display_name=?)
SEARCH users USING INTEGER PRIMARY KEY (rowid=?)
+Query: DELETE FROM files WHERE roster_transfer_id = ?
+Plan:
+SEARCH files USING COVERING INDEX idx_files_roster_transfer_id (roster_transfer_id=?)
+SEARCH extra_xftp_file_descriptions USING COVERING INDEX idx_extra_xftp_file_descriptions_file_id (file_id=?)
+SEARCH rcv_files USING INTEGER PRIMARY KEY (rowid=?)
+SEARCH snd_files USING COVERING INDEX idx_snd_files_file_id (file_id=?)
+SEARCH files USING COVERING INDEX idx_files_redirect_file_id (redirect_file_id=?)
+
Query: DELETE FROM files WHERE user_id = ? AND contact_id = ?
Plan:
SEARCH files USING INDEX idx_files_contact_id (contact_id=?)
@@ -6499,9 +6610,18 @@ SEARCH rcv_files USING INTEGER PRIMARY KEY (rowid=?)
SEARCH snd_files USING COVERING INDEX idx_snd_files_file_id (file_id=?)
SEARCH files USING COVERING INDEX idx_files_redirect_file_id (redirect_file_id=?)
+Query: DELETE FROM files WHERE user_id = ? AND group_id = ? AND file_type = ?
+Plan:
+SEARCH files USING INDEX idx_files_group_id (group_id=?)
+SEARCH extra_xftp_file_descriptions USING COVERING INDEX idx_extra_xftp_file_descriptions_file_id (file_id=?)
+SEARCH rcv_files USING INTEGER PRIMARY KEY (rowid=?)
+SEARCH snd_files USING COVERING INDEX idx_snd_files_file_id (file_id=?)
+SEARCH files USING COVERING INDEX idx_files_redirect_file_id (redirect_file_id=?)
+
Query: DELETE FROM group_members WHERE user_id = ? AND group_id = ?
Plan:
SEARCH group_members USING COVERING INDEX idx_group_members_group_id (user_id=? AND group_id=?)
+SEARCH rcv_roster_transfers USING COVERING INDEX idx_rcv_roster_transfers_from_member_id (from_member_id=?)
SEARCH group_relays USING COVERING INDEX idx_group_relays_group_member_id (group_member_id=?)
SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?)
SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?)
@@ -6531,6 +6651,7 @@ SEARCH contacts USING COVERING INDEX idx_contacts_contact_group_member_id (conta
Query: DELETE FROM group_members WHERE user_id = ? AND group_member_id = ?
Plan:
SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?)
+SEARCH rcv_roster_transfers USING COVERING INDEX idx_rcv_roster_transfers_from_member_id (from_member_id=?)
SEARCH group_relays USING COVERING INDEX idx_group_relays_group_member_id (group_member_id=?)
SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?)
SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?)
@@ -6560,6 +6681,7 @@ SEARCH contacts USING COVERING INDEX idx_contacts_contact_group_member_id (conta
Query: DELETE FROM groups WHERE user_id = ? AND group_id = ?
Plan:
SEARCH groups USING INTEGER PRIMARY KEY (rowid=?)
+SEARCH rcv_roster_transfers USING COVERING INDEX idx_rcv_roster_transfers_group_id_from_member_id (group_id=?)
SEARCH group_relays USING COVERING INDEX idx_group_relays_group_id (group_id=?)
SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_group_id (group_id=?)
SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_group_id (group_id=?)
@@ -6632,6 +6754,18 @@ Query: DELETE FROM rcv_file_chunks WHERE file_id = ?
Plan:
SEARCH rcv_file_chunks USING COVERING INDEX idx_rcv_file_chunks_file_id (file_id=?)
+Query: DELETE FROM rcv_roster_transfers WHERE group_id = ?
+Plan:
+SEARCH rcv_roster_transfers USING COVERING INDEX idx_rcv_roster_transfers_group_id_from_member_id (group_id=?)
+
+Query: DELETE FROM rcv_roster_transfers WHERE group_id = ? AND from_member_id = ?
+Plan:
+SEARCH rcv_roster_transfers USING INDEX idx_rcv_roster_transfers_group_id_from_member_id (group_id=? AND from_member_id=?)
+
+Query: DELETE FROM rcv_roster_transfers WHERE roster_transfer_id = ?
+Plan:
+SEARCH rcv_roster_transfers USING INTEGER PRIMARY KEY (rowid=?)
+
Query: DELETE FROM received_probes WHERE created_at <= ?
Plan:
SEARCH received_probes USING COVERING INDEX idx_received_probes_created_at (created_at)
@@ -6755,7 +6889,10 @@ Plan:
Query: INSERT INTO files (user_id, file_name, file_path, file_size, chunk_size, ci_file_status, protocol, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?)
Plan:
-Query: INSERT INTO files (user_id, group_id, file_name, file_size, chunk_size, file_inline, ci_file_status, protocol, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?)
+Query: INSERT INTO files (user_id, group_id, file_name, file_size, chunk_size, file_inline, ci_file_status, protocol, file_type, shared_msg_id, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)
+Plan:
+
+Query: INSERT INTO files (user_id, group_id, file_name, file_size, chunk_size, file_inline, ci_file_status, protocol, file_type, shared_msg_id, roster_transfer_id, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)
Plan:
Query: INSERT INTO group_profiles (display_name, full_name, short_descr, description, image, user_id, preferences, member_admission, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?)
@@ -6776,6 +6913,9 @@ Plan:
Query: INSERT INTO rcv_files (file_id, file_status, file_queue_info, file_inline, rcv_file_inline, file_descr_id, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?)
Plan:
+Query: INSERT INTO rcv_files (file_id, file_status, file_queue_info, file_inline, rcv_file_inline, group_member_id, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?)
+Plan:
+
Query: INSERT INTO rcv_files (file_id, file_status, file_queue_info, file_inline, rcv_file_inline, group_member_id, file_descr_id, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?)
Plan:
@@ -6945,6 +7085,10 @@ Query: SELECT chat_tag_id FROM chat_tags_chats WHERE group_id = ?
Plan:
SEARCH chat_tags_chats USING COVERING INDEX idx_chat_tags_chats_chat_tag_id_group_id (group_id=?)
+Query: SELECT chunk_number FROM rcv_file_chunks WHERE file_id = ? ORDER BY chunk_number DESC LIMIT 1
+Plan:
+SEARCH rcv_file_chunks USING COVERING INDEX idx_rcv_file_chunks_file_id (file_id=?)
+
Query: SELECT conn_req_inv FROM connections WHERE connection_id = ?
Plan:
SEARCH connections USING INTEGER PRIMARY KEY (rowid=?)
@@ -7009,6 +7153,18 @@ Query: SELECT file_id FROM files WHERE user_id = ? AND redirect_file_id = ?
Plan:
SEARCH files USING INDEX idx_files_redirect_file_id (redirect_file_id=?)
+Query: SELECT file_id, file_path FROM files WHERE roster_transfer_id = ?
+Plan:
+SEARCH files USING INDEX idx_files_roster_transfer_id (roster_transfer_id=?)
+
+Query: SELECT file_id, file_path FROM files WHERE user_id = ? AND group_id = ? AND file_type = ?
+Plan:
+SEARCH files USING INDEX idx_files_group_id (group_id=?)
+
+Query: SELECT file_type FROM files WHERE user_id = ? AND group_id = ? AND shared_msg_id = ? LIMIT 1
+Plan:
+SEARCH files USING INDEX idx_files_group_id_shared_msg_id (group_id=? AND shared_msg_id=?)
+
Query: SELECT g.inv_queue_info FROM groups g WHERE g.group_id = ? AND g.user_id = ?
Plan:
SEARCH g USING INTEGER PRIMARY KEY (rowid=?)
@@ -7077,6 +7233,14 @@ Query: SELECT max(active_order) FROM users
Plan:
SEARCH users
+Query: SELECT member_pub_key FROM group_members WHERE local_display_name = ?
+Plan:
+SCAN group_members
+
+Query: SELECT member_pub_key FROM group_members WHERE member_role = 'moderator'
+Plan:
+SCAN group_members
+
Query: SELECT member_relations_vector FROM group_members WHERE group_member_id = ?
Plan:
SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?)
@@ -7085,6 +7249,10 @@ Query: SELECT member_relations_vector FROM group_members WHERE group_member_id =
Plan:
SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?)
+Query: SELECT member_role 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
@@ -7129,6 +7297,30 @@ Query: SELECT relay_status FROM group_relays WHERE group_relay_id = ?
Plan:
SEARCH group_relays USING INTEGER PRIMARY KEY (rowid=?)
+Query: SELECT roster_blob FROM groups WHERE roster_blob IS NOT NULL
+Plan:
+SCAN groups
+
+Query: SELECT roster_sending_owner_gm_id, roster_broker_ts, roster_msg_chat_binding, roster_msg_signatures, roster_msg_body, roster_blob FROM groups WHERE group_id = ?
+Plan:
+SEARCH groups USING INTEGER PRIMARY KEY (rowid=?)
+
+Query: SELECT roster_transfer_id FROM rcv_roster_transfers WHERE group_id = ? AND from_member_id = ?
+Plan:
+SEARCH rcv_roster_transfers USING COVERING INDEX idx_rcv_roster_transfers_group_id_from_member_id (group_id=? AND from_member_id=?)
+
+Query: SELECT roster_version FROM groups
+Plan:
+SCAN groups
+
+Query: SELECT roster_version FROM groups WHERE group_id = ?
+Plan:
+SEARCH groups USING INTEGER PRIMARY KEY (rowid=?)
+
+Query: SELECT roster_version FROM rcv_roster_transfers WHERE group_id = ? AND from_member_id = ?
+Plan:
+SEARCH rcv_roster_transfers USING INDEX idx_rcv_roster_transfers_group_id_from_member_id (group_id=? AND from_member_id=?)
+
Query: SELECT sent_inv_queue_info FROM group_members WHERE group_member_id = ? AND user_id = ?
Plan:
SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?)
@@ -7357,6 +7549,10 @@ Query: UPDATE group_members SET member_profile_id = ?, updated_at = ? WHERE grou
Plan:
SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?)
+Query: UPDATE group_members SET member_pub_key = ?, member_role = ?, updated_at = ? WHERE group_member_id = ?
+Plan:
+SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?)
+
Query: UPDATE group_members SET member_pub_key = ?, updated_at = ? WHERE group_member_id = ?
Plan:
SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?)
@@ -7369,6 +7565,10 @@ Query: UPDATE group_members SET member_role = ? WHERE user_id = ? AND group_memb
Plan:
SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?)
+Query: UPDATE group_members SET member_role = ?, member_pub_key = NULL WHERE local_display_name = ?
+Plan:
+SCAN group_members
+
Query: UPDATE group_members SET support_chat_items_member_attention = ?, updated_at = ? WHERE group_member_id = ?
Plan:
SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?)
@@ -7449,6 +7649,14 @@ Query: UPDATE groups SET root_pub_key = ?, member_priv_key = ?, updated_at = ? W
Plan:
SEARCH groups USING INTEGER PRIMARY KEY (rowid=?)
+Query: UPDATE groups SET roster_blob = ? WHERE roster_blob IS NOT NULL
+Plan:
+SCAN groups
+
+Query: UPDATE groups SET roster_version = ?, updated_at = ? WHERE group_id = ?
+Plan:
+SEARCH groups USING INTEGER PRIMARY KEY (rowid=?)
+
Query: UPDATE groups SET send_rcpts = NULL
Plan:
SCAN groups
diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql
index 06810d6aab..d4d395c1dc 100644
--- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql
+++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql
@@ -192,7 +192,14 @@ CREATE TABLE groups(
relay_request_delay INTEGER NOT NULL DEFAULT 0,
relay_request_execute_at TEXT NOT NULL DEFAULT '1970-01-01 00:00:00',
relay_inactive_at TEXT,
- relay_sent_web_domain TEXT, -- received
+ relay_sent_web_domain TEXT,
+ roster_version INTEGER,
+ roster_msg_body BLOB,
+ roster_msg_chat_binding TEXT,
+ roster_msg_signatures BLOB,
+ roster_sending_owner_gm_id INTEGER,
+ roster_broker_ts TEXT,
+ roster_blob BLOB, -- received
FOREIGN KEY(user_id, local_display_name)
REFERENCES display_names(user_id, local_display_name)
ON DELETE CASCADE
@@ -278,7 +285,10 @@ CREATE TABLE files(
file_crypto_key BLOB,
file_crypto_nonce BLOB,
note_folder_id INTEGER DEFAULT NULL REFERENCES note_folders ON DELETE CASCADE,
- redirect_file_id INTEGER REFERENCES files ON DELETE CASCADE
+ redirect_file_id INTEGER REFERENCES files ON DELETE CASCADE,
+ shared_msg_id BLOB,
+ file_type TEXT NOT NULL DEFAULT 'normal',
+ roster_transfer_id INTEGER
) STRICT;
CREATE TABLE snd_files(
file_id INTEGER NOT NULL REFERENCES files ON DELETE CASCADE,
@@ -798,6 +808,20 @@ CREATE TABLE group_relays(
,
base_web_url TEXT
) STRICT;
+CREATE TABLE rcv_roster_transfers(
+ roster_transfer_id INTEGER PRIMARY KEY,
+ group_id INTEGER NOT NULL REFERENCES groups ON DELETE CASCADE,
+ from_member_id INTEGER NOT NULL REFERENCES group_members ON DELETE CASCADE,
+ roster_version INTEGER NOT NULL,
+ roster_digest BLOB NOT NULL,
+ sending_owner_gm_id INTEGER NOT NULL,
+ broker_ts TEXT NOT NULL,
+ roster_msg_body BLOB,
+ roster_msg_chat_binding TEXT,
+ roster_msg_signatures BLOB,
+ created_at TEXT NOT NULL DEFAULT(datetime('now')),
+ updated_at TEXT NOT NULL DEFAULT(datetime('now'))
+) STRICT;
CREATE INDEX contact_profiles_index ON contact_profiles(
display_name,
full_name
@@ -1317,6 +1341,18 @@ ON groups(
relay_request_group_link
)
WHERE relay_request_group_link IS NOT NULL;
+CREATE UNIQUE INDEX idx_rcv_roster_transfers_group_id_from_member_id ON rcv_roster_transfers(
+ group_id,
+ from_member_id
+);
+CREATE INDEX idx_rcv_roster_transfers_from_member_id ON rcv_roster_transfers(
+ from_member_id
+);
+CREATE INDEX idx_files_group_id_shared_msg_id ON files(
+ group_id,
+ shared_msg_id
+);
+CREATE INDEX idx_files_roster_transfer_id ON files(roster_transfer_id);
CREATE TRIGGER on_group_members_insert_update_summary
AFTER INSERT ON group_members
FOR EACH ROW
diff --git a/src/Simplex/Chat/Store/Shared.hs b/src/Simplex/Chat/Store/Shared.hs
index d00f478d63..9096be3a86 100644
--- a/src/Simplex/Chat/Store/Shared.hs
+++ b/src/Simplex/Chat/Store/Shared.hs
@@ -670,7 +670,7 @@ type BusinessChatInfoRow = (Maybe BusinessChatType, Maybe MemberId, Maybe Member
type GroupKeysRow = (Maybe C.PrivateKeyEd25519, Maybe C.PublicKeyEd25519, Maybe C.PrivateKeyEd25519)
-type GroupInfoRow = (Int64, GroupName, GroupName, Text, Maybe Text, Text, Maybe Text, Maybe ImageData, Maybe GroupType, Maybe ShortLinkContact, Maybe B64UrlByteString) :. PublicGroupAccessRow :. (Maybe MsgFilter, Maybe BoolInt, BoolInt, Maybe GroupPreferences, Maybe GroupMemberAdmission) :. (UTCTime, UTCTime, Maybe UTCTime, Maybe UTCTime) :. PreparedGroupRow :. BusinessChatInfoRow :. (BoolInt, Maybe RelayStatus, Maybe UIThemeEntityOverrides, Int64, Maybe Int64, Maybe CustomData, Maybe Int64, Int, Maybe ConnReqContact) :. GroupKeysRow :. GroupMemberRow
+type GroupInfoRow = (Int64, GroupName, GroupName, Text, Maybe Text, Text, Maybe Text, Maybe ImageData, Maybe GroupType, Maybe ShortLinkContact, Maybe B64UrlByteString) :. PublicGroupAccessRow :. (Maybe MsgFilter, Maybe BoolInt, BoolInt, Maybe GroupPreferences, Maybe GroupMemberAdmission) :. (UTCTime, UTCTime, Maybe UTCTime, Maybe UTCTime) :. PreparedGroupRow :. BusinessChatInfoRow :. (BoolInt, Maybe RelayStatus, Maybe UIThemeEntityOverrides, Int64, Maybe Int64, Maybe VersionRoster, Maybe CustomData, Maybe Int64, Int, Maybe ConnReqContact) :. GroupKeysRow :. GroupMemberRow
type PublicGroupAccessRow = (Maybe Text, Maybe Text, Maybe BoolInt, Maybe BoolInt)
@@ -679,7 +679,7 @@ type GroupMemberRow = (GroupMemberId, GroupId, Int64, MemberId, VersionChat, Ver
type ProfileRow = (ProfileId, ContactName, Text, Maybe Text, Maybe ImageData, Maybe ConnLinkContact, Maybe ChatPeerType, LocalAlias, Maybe Preferences) :. BadgeRow
toGroupInfo :: UTCTime -> StoreCxt -> Int64 -> [ChatTagId] -> GroupInfoRow -> GroupInfo
-toGroupInfo now cxt userContactId chatTags ((groupId, localDisplayName, displayName, fullName, shortDescr, localAlias, description, image, groupType_, groupLink_, publicGroupId_) :. accessRow :. (enableNtfs_, sendRcpts, BI favorite, groupPreferences, memberAdmission) :. (createdAt, updatedAt, chatTs, userMemberProfileSentAt) :. preparedGroupRow :. businessRow :. (BI useRelays, relayOwnStatus, uiThemes, currentMembers, publicMemberCount, customData, chatItemTTL, membersRequireAttention, viaGroupLinkUri) :. groupKeysRow :. userMemberRow) =
+toGroupInfo now cxt userContactId chatTags ((groupId, localDisplayName, displayName, fullName, shortDescr, localAlias, description, image, groupType_, groupLink_, publicGroupId_) :. accessRow :. (enableNtfs_, sendRcpts, BI favorite, groupPreferences, memberAdmission) :. (createdAt, updatedAt, chatTs, userMemberProfileSentAt) :. preparedGroupRow :. businessRow :. (BI useRelays, relayOwnStatus, uiThemes, currentMembers, publicMemberCount, rosterVersion, customData, chatItemTTL, membersRequireAttention, viaGroupLinkUri) :. groupKeysRow :. userMemberRow) =
let membership = (toGroupMember now userContactId userMemberRow) {memberChatVRange = vr cxt}
chatSettings = ChatSettings {enableNtfs = fromMaybe MFAll enableNtfs_, sendRcpts = unBI <$> sendRcpts, favorite}
fullGroupPreferences = mergeGroupPreferences groupPreferences
@@ -689,7 +689,7 @@ toGroupInfo now cxt userContactId chatTags ((groupId, localDisplayName, displayN
businessChat = toBusinessChatInfo businessRow
preparedGroup = toPreparedGroup preparedGroupRow
groupSummary = GroupSummary {currentMembers, publicMemberCount}
- in GroupInfo {groupId, useRelays = BoolDef useRelays, relayOwnStatus, localDisplayName, groupProfile, localAlias, businessChat, fullGroupPreferences, membership, chatSettings, createdAt, updatedAt, chatTs, userMemberProfileSentAt, preparedGroup, chatTags, chatItemTTL, uiThemes, groupSummary, customData, membersRequireAttention, viaGroupLinkUri, groupKeys}
+ in GroupInfo {groupId, useRelays = BoolDef useRelays, relayOwnStatus, localDisplayName, groupProfile, localAlias, businessChat, fullGroupPreferences, membership, chatSettings, createdAt, updatedAt, chatTs, userMemberProfileSentAt, preparedGroup, chatTags, chatItemTTL, uiThemes, groupSummary, rosterVersion, customData, membersRequireAttention, viaGroupLinkUri, groupKeys}
toPreparedGroup :: PreparedGroupRow -> Maybe PreparedGroup
toPreparedGroup = \case
@@ -789,7 +789,7 @@ groupInfoQueryFields =
g.conn_full_link_to_connect, g.conn_short_link_to_connect, g.conn_link_prepared_connection, g.conn_link_started_connection, g.welcome_shared_msg_id, g.request_shared_msg_id,
g.business_chat, g.business_member_id, g.customer_member_id,
g.use_relays, g.relay_own_status,
- g.ui_themes, g.summary_current_members_count, g.public_member_count, g.custom_data, g.chat_item_ttl, g.members_require_attention, g.via_group_link_uri,
+ g.ui_themes, g.summary_current_members_count, g.public_member_count, g.roster_version, g.custom_data, g.chat_item_ttl, g.members_require_attention, g.via_group_link_uri,
g.root_priv_key, g.root_pub_key, g.member_priv_key,
-- GroupMember - membership
mu.group_member_id, mu.group_id, mu.index_in_group, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category,
diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs
index f23ea8a041..538faf8cac 100644
--- a/src/Simplex/Chat/Types.hs
+++ b/src/Simplex/Chat/Types.hs
@@ -490,6 +490,7 @@ data GroupInfo = GroupInfo
uiThemes :: Maybe UIThemeEntityOverrides,
customData :: Maybe CustomData,
groupSummary :: GroupSummary,
+ rosterVersion :: Maybe VersionRoster,
membersRequireAttention :: Int,
viaGroupLinkUri :: Maybe ConnReqContact,
groupKeys :: Maybe GroupKeys
@@ -1021,6 +1022,11 @@ newtype MemberKey = MemberKey C.PublicKeyEd25519
deriving (Eq, Show)
deriving newtype (StrEncoding)
+-- Binary encoding for the roster blob; delegates to the Ed25519 key.
+instance Encoding MemberKey where
+ smpEncode (MemberKey k) = smpEncode k
+ smpP = MemberKey <$> smpP
+
instance FromJSON MemberKey where
parseJSON = strParseJSON "MemberKey"
@@ -1542,11 +1548,38 @@ instance ToJSON InlineFileMode where
toJSON = J.String . textEncode
toEncoding = JE.text . textEncode
+-- Discriminates ordinary chat files from the roster blob file, so the receive
+-- completion / cancel paths branch on the type rather than on chat_item_id (note
+-- folders and redirects also lack a chat item).
+data FileType = FTNormal | FTRoster
+ deriving (Eq, Show)
+
+instance TextEncoding FileType where
+ textEncode = \case
+ FTNormal -> "normal"
+ FTRoster -> "roster"
+ textDecode = \case
+ "normal" -> Just FTNormal
+ "roster" -> Just FTRoster
+ _ -> Nothing
+
+instance FromField FileType where fromField = fromTextField_ textDecode
+
+instance ToField FileType where toField = toField . textEncode
+
+instance FromJSON FileType where
+ parseJSON = textParseJSON "FileType"
+
+instance ToJSON FileType where
+ toJSON = J.String . textEncode
+ toEncoding = JE.text . textEncode
+
data RcvFileTransfer = RcvFileTransfer
{ fileId :: FileTransferId,
xftpRcvFile :: Maybe XFTPRcvFile,
fileInvitation :: FileInvitation,
fileStatus :: RcvFileStatus,
+ fileType :: FileType,
rcvFileInline :: Maybe InlineFileMode,
senderDisplayName :: ContactName,
chunkSize :: Integer,
@@ -2091,6 +2124,12 @@ data StoreCxt = StoreCxt {vr :: VersionRangeChat, badgeKeys :: Map Int BBSPublic
pattern VersionChat :: Word16 -> VersionChat
pattern VersionChat v = Version v
+-- A monotonic per-change counter, not a negotiated protocol version: Int64 rather than the Word16 of
+-- Version, so a long-lived high-churn channel cannot wrap and be permanently rejected by relays (v >= cur).
+newtype VersionRoster = VersionRoster Int64
+ deriving (Eq, Ord, Show)
+ deriving newtype (FromJSON, ToJSON, FromField, ToField)
+
-- this newtype exists to have a concise JSON encoding of version ranges in chat protocol messages in the form of "1-2" or just "1"
newtype ChatVersionRange = ChatVersionRange {fromChatVRange :: VersionRangeChat} deriving (Eq, Show)
diff --git a/src/Simplex/Chat/Types/Shared.hs b/src/Simplex/Chat/Types/Shared.hs
index c71f7ce37a..296268b58f 100644
--- a/src/Simplex/Chat/Types/Shared.hs
+++ b/src/Simplex/Chat/Types/Shared.hs
@@ -11,6 +11,7 @@ import qualified Data.ByteString.Char8 as B
import Data.Text (Text)
import Simplex.Chat.Options.DB (FromField (..), ToField (..))
import Simplex.Messaging.Agent.Store.DB (fromTextField_)
+import Simplex.Messaging.Encoding
import Simplex.Messaging.Encoding.String
import Simplex.Messaging.Parsers (dropPrefix, enumJSON)
import Simplex.Messaging.Util ((<$?>))
@@ -57,6 +58,12 @@ instance ToJSON GroupMemberRole where
toJSON = textToJSON
toEncoding = textToEncoding
+-- Binary encoding for the roster blob; delegates to the canonical TextEncoding
+-- (same member/moderator/admin form JSON and the DB use). GRUnknown round-trips.
+instance Encoding GroupMemberRole where
+ smpEncode = smpEncode . textEncode
+ smpP = maybe (fail "bad GroupMemberRole") pure . textDecode =<< smpP
+
data GroupAcceptance = GAAccepted | GAPendingApproval | GAPendingReview deriving (Eq, Show)
instance StrEncoding GroupAcceptance where
@@ -82,6 +89,7 @@ data RelayStatus
= RSNew -- only for owner
| RSInvited
| RSAccepted
+ | RSAcknowledgedRoster
| RSActive
| RSInactive
| RSRejected
@@ -92,6 +100,7 @@ relayStatusText = \case
RSNew -> "new"
RSInvited -> "invited"
RSAccepted -> "accepted"
+ RSAcknowledgedRoster -> "acknowledged_roster"
RSActive -> "active"
RSInactive -> "inactive"
RSRejected -> "rejected"
@@ -101,6 +110,7 @@ instance TextEncoding RelayStatus where
RSNew -> "new"
RSInvited -> "invited"
RSAccepted -> "accepted"
+ RSAcknowledgedRoster -> "acknowledged_roster"
RSActive -> "active"
RSInactive -> "inactive"
RSRejected -> "rejected"
@@ -108,6 +118,7 @@ instance TextEncoding RelayStatus where
"new" -> Just RSNew
"invited" -> Just RSInvited
"accepted" -> Just RSAccepted
+ "acknowledged_roster" -> Just RSAcknowledgedRoster
"active" -> Just RSActive
"inactive" -> Just RSInactive
"rejected" -> Just RSRejected
diff --git a/tests/ChatClient.hs b/tests/ChatClient.hs
index 442834b244..c955ca1353 100644
--- a/tests/ChatClient.hs
+++ b/tests/ChatClient.hs
@@ -217,7 +217,7 @@ testCfg =
shortLinkPresetServers = ["smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=@localhost:7001"],
testView = True,
tbqSize = 16,
- channelSubscriberRole = GRMember, -- starting role is GRMember to test members sending messages
+ channelSubscriberRole = GRObserver,
confirmMigrations = MCYesUp
}
diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs
index ac9e325ed2..a32aa2d07c 100644
--- a/tests/ChatTests/Groups.hs
+++ b/tests/ChatTests/Groups.hs
@@ -20,13 +20,13 @@ import Control.Monad (forM_, void, when)
import Data.Bifunctor (second)
import Data.ByteString (ByteString)
import qualified Data.ByteString.Char8 as B
-import Data.Maybe (fromMaybe, isJust, listToMaybe, maybeToList)
+import Data.Maybe (fromMaybe, isJust, maybeToList)
import Data.Time (UTCTime)
import Data.Int (Int64)
-import Data.List (intercalate, isInfixOf)
+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 (..), defaultChatHooks)
+import Simplex.Chat.Controller (ChatConfig (..), ChatHooks (..), ChatLogLevel (..), defaultChatHooks)
import Simplex.Chat.Library.Internal (uniqueMsgMentions, updatedMentionNames)
import Simplex.Chat.Markdown (parseMaybeMarkdownList)
import Simplex.Chat.Messages (CIMention (..), CIMentionMember (..), ChatItemId)
@@ -288,6 +288,18 @@ chatGroupTests = do
it "operator allow clears rejection and relay accepts again" testRelayAllowAcceptsAgain
it "rejection on channel A does not affect unrelated channel B" testRelayDoesNotRejectUnrelatedChannel
it "concurrent fresh invitations both rejected" testRelayRejectRaceConcurrentInvitations
+ describe "promoted members roster" $ do
+ it "moderator action verifies via owner-signed roster" testChannelModeratorActionViaRoster
+ 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 "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
+ it "promoted member enters the roster and can post" testChannelPromotedMemberCanPost
+ it "observer cannot post until promoted" testChannelObserverCannotPost
+ it "promoted member re-connecting via a new relay is accepted via the roster-pinned key" testChannelPromotedMemberRejoinViaRelay
+ it "2 relays: multi-chunk roster reassembles per source (no stream interleaving)" testChannelRosterMultiRelayMultipart
describe "channel message operations" $ do
it "should update channel message" testChannelMessageUpdate
it "should delete channel message" testChannelMessageDelete
@@ -8676,6 +8688,20 @@ createChannel1Relay gName owner relay cath dan eve = do
forM_ [cath, dan, eve] $ \member ->
memberJoinChannel gName [relay] [owner] shortLink fullLink member
+-- Promote a fresh channel subscriber (observer default) to member so it can post; the roster bump
+-- re-serves to the other (still-unknown) subscribers, who see the change rendered by member id hash.
+promoteChannelMember :: HasCallStack => String -> TestCC -> TestCC -> TestCC -> [TestCC] -> IO ()
+promoteChannelMember gName owner relay member others = do
+ mName <- userName member
+ oName <- userName owner
+ owner ##> ("/mr #" <> gName <> " " <> mName <> " member")
+ owner <## ("#" <> gName <> ": you changed the role of " <> mName <> " to member (signed)")
+ concurrentlyN_ $
+ [ relay <## ("#" <> gName <> ": " <> oName <> " changed the role of " <> mName <> " from observer to member (signed)"),
+ member <## ("#" <> gName <> ": " <> oName <> " changed your role from observer to member (signed)")
+ ]
+ <> [o <### [EndsWith "from observer to member (signed)"] | o <- others]
+
setupRelay :: TestCC -> TestCC -> IO String
setupRelay owner relay = do
rName <- userName relay
@@ -8710,7 +8736,7 @@ prepareChannel' relayId gName owner relay = do
]
owner ##> ("/show link #" <> gName)
- getGroupLinks owner gName GRMember False
+ getGroupLinks owner gName GRObserver False
createChannel2Relays :: String -> TestCC -> TestCC -> TestCC -> TestCC -> TestCC -> TestCC -> IO ()
createChannel2Relays gName owner relay1 relay2 dan eve frank = do
@@ -8741,7 +8767,7 @@ prepareChannel2Relays gName owner relay1 relay2 = do
owner <## ("#" <> gName <> ": group link relays updated, current relays:")
owner
<### [ EndsWith ": active",
- EndsWith ": accepted"
+ Predicate (\l -> ": invited" `isSuffixOf` l || ": accepted" `isSuffixOf` l || ": acknowledged_roster" `isSuffixOf` l)
]
owner <## "group link:"
void $ getTermLine owner -- consume group link line
@@ -8758,7 +8784,7 @@ prepareChannel2Relays gName owner relay1 relay2 = do
]
owner ##> ("/show link #" <> gName)
- getGroupLinks owner gName GRMember False
+ getGroupLinks owner gName GRObserver False
memberJoinChannel :: String -> [TestCC] -> [TestCC] -> String -> String -> TestCC -> IO ()
memberJoinChannel gName = memberJoinChannel' gName 1 0 0 0
@@ -8887,6 +8913,9 @@ testChannelsSenderDeduplicateOwn ps = do
withNewTestChat ps "eve" eveProfile $ \eve -> do
withNewTestChatCfgOpts ps cfg relayTestOpts "bob" bobProfile $ \bob -> do
createChannel1Relay "team" alice bob cath dan eve
+ -- promote cath and dan while the relay is online, so their buffered posts replay as members
+ promoteChannelMember "team" alice bob cath [dan, eve]
+ promoteChannelMember "team" alice bob dan [cath, eve]
-- chat relay bob is offline
alice #> "#team 1"
@@ -8912,14 +8941,16 @@ testChannelsSenderDeduplicateOwn ps = do
WithTime "#team dan> 6 [>>]"
]
cath
- <### [ "#team: bob introduced dan (Daniel) in the channel",
+ <### [ EndsWith "updated to dan",
+ "#team: bob introduced dan (Daniel) in the channel",
WithTime "#team> 1 [>>]",
WithTime "#team> 2 [>>]",
WithTime "#team> 3 [>>]",
WithTime "#team dan> 6 [>>]"
]
dan
- <### [ "#team: bob introduced cath (Catherine) in the channel",
+ <### [ EndsWith "updated to cath",
+ "#team: bob introduced cath (Catherine) in the channel",
WithTime "#team> 1 [>>]",
WithTime "#team> 2 [>>]",
WithTime "#team> 3 [>>]",
@@ -8927,7 +8958,9 @@ testChannelsSenderDeduplicateOwn ps = do
WithTime "#team cath> 5 [>>]"
]
eve
- <### [ "#team: bob introduced cath (Catherine) in the channel",
+ <### [ EndsWith "updated to cath",
+ EndsWith "updated to dan",
+ "#team: bob introduced cath (Catherine) in the channel",
"#team: bob introduced dan (Daniel) in the channel",
WithTime "#team> 1 [>>]",
WithTime "#team> 2 [>>]",
@@ -8948,11 +8981,13 @@ testChannelLateJoinerReceivesProfile ps =
(shortLink, fullLink) <- prepareChannel1Relay "team" alice bob
memberJoinChannel "team" [bob] [alice] shortLink fullLink cath
memberJoinChannel "team" [bob] [alice] shortLink fullLink dan
+ promoteChannelMember "team" alice bob cath [dan]
- -- first forward: dan learns cath via prepended XGrpMemNew.
+ -- first forward: dan resolves cath (roster-known by id hash) on the prepended XGrpMemNew.
cath #> "#team hi"
bob <# "#team cath> hi"
alice <# "#team cath> hi [>>]"
+ dan <### [EndsWith "updated to cath"]
dan <## "#team: bob introduced cath (Catherine) in the channel"
dan <# "#team cath> hi [>>]"
@@ -8988,12 +9023,23 @@ testChannel2RelaysDeduplicateProfile ps =
memberJoinChannel "team" [bob, cath] [alice] shortLink fullLink dan
memberJoinChannel "team" [bob, cath] [alice] shortLink fullLink eve
+ -- promote dan (observer default) so it can post; eve learns dan via the roster (id hash)
+ alice ##> "/mr #team dan member"
+ alice <## "#team: you changed the role of dan to member (signed)"
+ concurrentlyN_
+ [ bob <## "#team: alice changed the role of dan from observer to member (signed)",
+ cath <## "#team: alice changed the role of dan from observer to member (signed)",
+ dan <## "#team: alice changed your role from observer to member (signed)",
+ eve <### [EndsWith "from observer to member (signed)"]
+ ]
+
-- first forward: both relays prepend XGrpMemNew(dan) for eve;
-- second hits xGrpMemNew's "already created via another relay" branch.
dan #> "#team hi"
bob <# "#team dan> hi"
cath <# "#team dan> hi"
alice <# "#team dan> hi [>>]"
+ eve <### [EndsWith "updated to dan"]
eve .<## " introduced dan (Daniel) in the channel"
eve <# "#team dan> hi [>>]"
@@ -9035,6 +9081,7 @@ testChannelLargeProfileFits ps =
(shortLink, fullLink) <- prepareChannel1Relay "team" alice bob
memberJoinChannel "team" [bob] [alice] shortLink fullLink cath
memberJoinChannel "team" [bob] [alice] shortLink fullLink dan
+ promoteChannelMember "team" alice bob cath [dan]
-- ~14000 chars: profile fits in a singleton batch AND packs
-- inline with the forwarded body (exercises the in-body path).
@@ -9045,6 +9092,7 @@ testChannelLargeProfileFits ps =
cath #> "#team hi"
bob <# "#team cath> hi"
alice <# "#team cath> hi [>>]"
+ dan <### [EndsWith "updated to cath"]
dan <## "#team: bob introduced cath (Catherine) in the channel"
dan <# "#team cath> hi [>>]"
@@ -9058,6 +9106,8 @@ testChannelMultipleLargeProfiles ps =
withNewTestChat ps "dan" danProfile $ \dan -> do
withNewTestChat ps "eve" eveProfile $ \eve -> do
createChannel1Relay "team" alice bob cath dan eve
+ promoteChannelMember "team" alice bob cath [dan, eve]
+ promoteChannelMember "team" alice bob dan [cath, eve]
-- ~14500 chars each: one rides inline with the body,
-- the other spills into a standalone overflow batch.
@@ -9079,15 +9129,19 @@ testChannelMultipleLargeProfiles ps =
WithTime "#team dan> from dan [>>]"
]
cath
- <### [ "#team: bob introduced dan (Daniel) in the channel",
+ <### [ EndsWith "updated to dan",
+ "#team: bob introduced dan (Daniel) in the channel",
WithTime "#team dan> from dan [>>]"
]
dan
- <### [ "#team: bob introduced cath (Catherine) in the channel",
+ <### [ EndsWith "updated to cath",
+ "#team: bob introduced cath (Catherine) in the channel",
WithTime "#team cath> from cath [>>]"
]
eve
- <### [ "#team: bob introduced dan (Daniel) in the channel",
+ <### [ EndsWith "updated to cath",
+ EndsWith "updated to dan",
+ "#team: bob introduced dan (Daniel) in the channel",
"#team: bob introduced cath (Catherine) in the channel",
WithTime "#team cath> from cath [>>]",
WithTime "#team dan> from dan [>>]"
@@ -9109,10 +9163,12 @@ testChannelProfileUpdateNoRePrepend ps =
(shortLink, fullLink) <- prepareChannel1Relay "team" alice bob
memberJoinChannel "team" [bob] [alice] shortLink fullLink cath
memberJoinChannel "team" [bob] [alice] shortLink fullLink dan
+ promoteChannelMember "team" alice bob cath [dan]
cath #> "#team hi"
bob <# "#team cath> hi"
alice <# "#team cath> hi [>>]"
+ dan <### [EndsWith "updated to cath"]
dan <## "#team: bob introduced cath (Catherine) in the channel"
dan <# "#team cath> hi [>>]"
@@ -9138,22 +9194,28 @@ testChannelMultiSendersIndependent ps =
withNewTestChat ps "dan" danProfile $ \dan -> do
withNewTestChat ps "eve" eveProfile $ \eve -> do
createChannel1Relay "team" alice bob cath dan eve
+ promoteChannelMember "team" alice bob cath [dan, eve]
+ promoteChannelMember "team" alice bob dan [cath, eve]
- -- cath posts: dan and eve learn cath via prepended XGrpMemNew
+ -- cath posts: dan and eve resolve cath on the prepended XGrpMemNew
cath #> "#team from cath"
bob <# "#team cath> from cath"
alice <# "#team cath> from cath [>>]"
+ dan <### [EndsWith "updated to cath"]
dan <## "#team: bob introduced cath (Catherine) in the channel"
dan <# "#team cath> from cath [>>]"
+ eve <### [EndsWith "updated to cath"]
eve <## "#team: bob introduced cath (Catherine) in the channel"
eve <# "#team cath> from cath [>>]"
- -- dan posts: cath and eve learn dan independently of cath's vector
+ -- dan posts: cath and eve resolve dan independently of cath's vector
dan #> "#team from dan"
bob <# "#team dan> from dan"
alice <# "#team dan> from dan [>>]"
+ cath <### [EndsWith "updated to dan"]
cath <## "#team: bob introduced dan (Daniel) in the channel"
cath <# "#team dan> from dan [>>]"
+ eve <### [EndsWith "updated to dan"]
eve <## "#team: bob introduced dan (Daniel) in the channel"
eve <# "#team dan> from dan [>>]"
@@ -9174,6 +9236,17 @@ testChannels2RelaysDeliver ps =
withNewTestChat ps "frank" frankProfile $ \frank -> do
createChannel2Relays "team" alice bob cath dan eve frank
+ -- promote dan (observer default) so it can send; eve/frank learn dan via the roster
+ alice ##> "/mr #team dan member"
+ alice <## "#team: you changed the role of dan to member (signed)"
+ concurrentlyN_
+ [ bob <## "#team: alice changed the role of dan from observer to member (signed)",
+ cath <## "#team: alice changed the role of dan from observer to member (signed)",
+ dan <## "#team: alice changed your role from observer to member (signed)",
+ eve <### [EndsWith "from observer to member (signed)"],
+ frank <### [EndsWith "from observer to member (signed)"]
+ ]
+
alice #> "#team hi"
[bob, cath] *<# "#team> hi"
[dan, eve, frank] *<# "#team> hi [>>]"
@@ -9186,18 +9259,15 @@ testChannels2RelaysDeliver ps =
cath <## " + 👍"
alice <# "#team dan> > hi"
alice <## " + 👍"
+ eve .<##. ("#team: unknown member ", " updated to dan")
eve .<## " introduced dan (Daniel) in the channel"
eve <# "#team dan> > hi"
eve <## " + 👍"
+ frank .<##. ("#team: unknown member ", " updated to dan")
frank .<## " introduced dan (Daniel) in the channel"
frank <# "#team dan> > hi"
frank <## " + 👍"
- -- remove below if default role is changed to observer
- dan #> "#team hey"
- [bob, cath] *<# "#team dan> hey"
- [alice, eve, frank] *<# "#team dan> hey [>>]"
-
testChannels2RelaysIncognito :: HasCallStack => TestParams -> IO ()
testChannels2RelaysIncognito ps =
withNewTestChat ps "alice" aliceProfile $ \alice -> do
@@ -9211,6 +9281,17 @@ testChannels2RelaysIncognito ps =
forM_ [eve, frank] $ \member ->
memberJoinChannel "team" [bob, cath] [alice] shortLink fullLink member
+ -- promote dan (observer default) so it can send; eve/frank learn dan via the roster
+ alice ##> ("/mr #team " <> danIncognito <> " member")
+ alice <## ("#team: you changed the role of " <> danIncognito <> " to member (signed)")
+ concurrentlyN_
+ [ bob <## ("#team: alice changed the role of " <> danIncognito <> " from observer to member (signed)"),
+ cath <## ("#team: alice changed the role of " <> danIncognito <> " from observer to member (signed)"),
+ dan <## "#team: alice changed your role from observer to member (signed)",
+ eve <### [EndsWith "from observer to member (signed)"],
+ frank <### [EndsWith "from observer to member (signed)"]
+ ]
+
alice #> "#team hi"
[bob, cath] *<# "#team> hi"
dan ?<# "#team> hi [>>]"
@@ -9224,18 +9305,15 @@ testChannels2RelaysIncognito ps =
cath <## " + 👍"
alice <# ("#team " <> danIncognito <> "> > hi")
alice <## " + 👍"
+ eve .<##. ("#team: unknown member ", (" updated to " <> danIncognito))
eve .<## (" introduced " <> danIncognito <> " in the channel")
eve <# ("#team " <> danIncognito <> "> > hi")
eve <## " + 👍"
+ frank .<##. ("#team: unknown member ", (" updated to " <> danIncognito))
frank .<## (" introduced " <> danIncognito <> " in the channel")
frank <# ("#team " <> danIncognito <> "> > hi")
frank <## " + 👍"
- -- remove below if default role is changed to observer
- dan ?#> "#team hey"
- [bob, cath] *<# ("#team " <> danIncognito <> "> hey")
- [alice, eve, frank] *<# ("#team " <> danIncognito <> "> hey [>>]")
-
testChannelUpdateProfileSigned :: HasCallStack => TestParams -> IO ()
testChannelUpdateProfileSigned ps =
withNewTestChat ps "alice" aliceProfile $ \alice ->
@@ -9293,7 +9371,7 @@ testChannelLinkAfterProfileUpdate ps =
-- late subscriber joins via the same channel link after profile update
threadDelay 100000
alice ##> "/show link #my_team"
- (shortLink', fullLink') <- getGroupLinks alice "my_team" GRMember False
+ (shortLink', fullLink') <- getGroupLinks alice "my_team" GRObserver False
shortLink' `shouldBe` shortLink
fullLink' `shouldBe` fullLink
memberJoinChannel "my_team" [bob] [alice] shortLink' fullLink' dan
@@ -9330,7 +9408,7 @@ testChannelLinkAfterWelcomeUpdate ps =
-- re-fetch updated link, late subscriber joins
threadDelay 100000
alice ##> "/show link #team"
- (shortLink', fullLink') <- getGroupLinks alice "team" GRMember False
+ (shortLink', fullLink') <- getGroupLinks alice "team" GRObserver False
shortLink' `shouldBe` shortLink
fullLink' `shouldBe` fullLink
memberJoinChannel "team" [bob] [alice] shortLink' fullLink' dan
@@ -9367,7 +9445,7 @@ testChannelOwnerKeyAfterLinkUpdate ps =
-- Late subscriber joins via the same channel link after profile update.
alice ##> "/show link #my_team"
- (shortLink', fullLink') <- getGroupLinks alice "my_team" GRMember False
+ (shortLink', fullLink') <- getGroupLinks alice "my_team" GRObserver False
shortLink' `shouldBe` shortLink
fullLink' `shouldBe` fullLink
memberJoinChannel "my_team" [bob] [alice] shortLink' fullLink' dan
@@ -9434,15 +9512,20 @@ testChannelChangeRoleSigned ps =
withNewTestChat ps "eve" eveProfile $ \eve -> do
createChannel1Relay "team" alice bob cath dan eve
+ -- promote cath to member (observer default) so it can post
+ promoteChannelMember "team" alice bob cath [dan, eve]
+
-- other members discover cath
cath #> "#team hello from cath"
bob <# "#team cath> hello from cath"
concurrentlyN_
[ alice <# "#team cath> hello from cath [>>]",
do
+ dan <### [EndsWith "updated to cath"]
dan <## "#team: bob introduced cath (Catherine) in the channel"
dan <# "#team cath> hello from cath [>>]",
do
+ eve <### [EndsWith "updated to cath"]
eve <## "#team: bob introduced cath (Catherine) in the channel"
eve <# "#team cath> hello from cath [>>]"
]
@@ -9463,21 +9546,21 @@ testChannelChangeRoleSigned ps =
dan #$> ("/_get chat #1 count=1", chat, [(0, "changed role of cath to admin (signed)")])
eve #$> ("/_get chat #1 count=1", chat, [(0, "changed role of cath to admin (signed)")])
- -- change role of silent member (other members don't know about member)
+ -- change role of silent member; cath/eve don't know dan via xGrpMemRole, but the
+ -- subsequent roster apply emits the chat item with dan TOFU-created at the new role
threadDelay 1000000
alice ##> "/mr #team dan admin"
alice <## "#team: you changed the role of dan to admin (signed)"
- bob <## "#team: alice changed the role of dan from member to admin (signed)"
concurrentlyN_
- [ dan <## "#team: alice changed your role from member to admin (signed)",
- cath <## "error: x.grp.mem.role with unknown member ID",
- eve <## "error: x.grp.mem.role with unknown member ID"
+ [ bob <## "#team: alice changed the role of dan from observer to admin (signed)",
+ dan <## "#team: alice changed your role from observer to admin (signed)",
+ cath .<##. ("#team: alice changed the role of ", " from observer to admin (signed)"),
+ eve .<##. ("#team: alice changed the role of ", " from observer to admin (signed)")
]
+ -- cath/eve render dan by id hash (unknown to them, roster-TOFU); arrival verified above
alice #$> ("/_get chat #1 count=1", chat, [(1, "changed role of dan to admin (signed)")])
bob #$> ("/_get chat #1 count=1", chat, [(0, "changed role of dan to admin (signed)")])
- cath #$> ("/_get chat #1 count=1", chat, [(0, "changed your role to admin (signed)")]) -- now new chat item
dan #$> ("/_get chat #1 count=1", chat, [(0, "changed your role to admin (signed)")])
- eve #$> ("/_get chat #1 count=1", chat, [(0, "changed role of cath to admin (signed)")]) -- now new chat item
testChannelBlockMemberSigned :: HasCallStack => TestParams -> IO ()
testChannelBlockMemberSigned ps =
@@ -9488,6 +9571,9 @@ testChannelBlockMemberSigned ps =
withNewTestChat ps "eve" eveProfile $ \eve -> do
createChannel1Relay "team" alice bob cath dan eve
+ -- promote cath to member (observer default) so it can post
+ promoteChannelMember "team" alice bob cath [dan, eve]
+
-- other members discover cath
threadDelay 1000000
cath #> "#team hello from cath"
@@ -9495,9 +9581,11 @@ testChannelBlockMemberSigned ps =
concurrentlyN_
[ alice <# "#team cath> hello from cath [>>]",
do
+ dan <### [EndsWith "updated to cath"]
dan <## "#team: bob introduced cath (Catherine) in the channel"
dan <# "#team cath> hello from cath [>>]",
do
+ eve <### [EndsWith "updated to cath"]
eve <## "#team: bob introduced cath (Catherine) in the channel"
eve <# "#team cath> hello from cath [>>]"
]
@@ -9543,6 +9631,224 @@ testChannelBlockMemberSigned ps =
r2 `shouldStartWith` "blocked"
r2 `shouldEndWith` "(signed)"
+checkMemberRow :: HasCallStack => TestCC -> T.Text -> Maybe T.Text -> IO ()
+checkMemberRow cc name expectedRole = do
+ roles <- withCCTransaction cc $ \db ->
+ DB.query db "SELECT member_role FROM group_members WHERE local_display_name = ?" (Only name) :: IO [Only T.Text]
+ map (\(Only r) -> r) roles `shouldBe` maybeToList expectedRole
+
+testChannelModeratorActionViaRoster :: HasCallStack => TestParams -> IO ()
+testChannelModeratorActionViaRoster ps =
+ withNewTestChat ps "alice" aliceProfile $ \alice ->
+ withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob ->
+ withNewTestChat ps "cath" cathProfile $ \cath ->
+ withNewTestChat ps "dan" danProfile $ \dan ->
+ withNewTestChat ps "eve" eveProfile $ \eve ->
+ withNewTestChat ps "frank" frankProfile $ \frank -> do
+ (shortLink, fullLink) <- prepareChannel1Relay "team" alice bob
+ forM_ [cath, dan, eve] $ \member ->
+ memberJoinChannel "team" [bob] [alice] shortLink fullLink member
+
+ -- promote dan (observer default) so it can post; cath and eve then discover dan
+ threadDelay 1000000
+ promoteChannelMember "team" alice bob dan [cath, eve]
+ dan #> "#team hello from dan"
+ bob <# "#team dan> hello from dan"
+ concurrentlyN_
+ [ alice <# "#team dan> hello from dan [>>]",
+ do
+ cath <### [EndsWith "updated to dan"]
+ cath <## "#team: bob introduced dan (Daniel) in the channel"
+ cath <# "#team dan> hello from dan [>>]",
+ do
+ eve <### [EndsWith "updated to dan"]
+ eve <## "#team: bob introduced dan (Daniel) in the channel"
+ eve <# "#team dan> hello from dan [>>]"
+ ]
+
+ -- cath promoted observer -> moderator; dan/eve learn cath via the roster re-serve
+ -- (no name yet -> rendered by member id hash)
+ threadDelay 1000000
+ alice ##> "/mr #team cath moderator"
+ alice <## "#team: you changed the role of cath to moderator (signed)"
+ concurrentlyN_
+ [ bob <## "#team: alice changed the role of cath from observer to moderator (signed)",
+ cath <## "#team: alice changed your role from observer to moderator (signed)",
+ dan <### [EndsWith "to moderator (signed)"],
+ eve <### [EndsWith "to moderator (signed)"]
+ ]
+
+ -- cath (moderator) blocks dan; profile prepend carries cath's full profile to dan/eve
+ threadDelay 1000000
+ cath ##> "/block for all #team dan"
+ cath <## "#team: you blocked dan (signed)"
+ bob <## "#team: cath blocked dan (signed)"
+ alice <## "#team: cath blocked dan (signed)"
+ eve <### [EndsWith "updated to cath"]
+ eve <## "#team: bob introduced cath (Catherine) in the channel"
+ eve <## "#team: cath blocked dan (signed)"
+ dan <### [EndsWith "updated to cath"]
+ dan <## "#team: bob introduced cath (Catherine) in the channel"
+
+ -- frank joins after the roster update; cached roster gives him cath as moderator.
+ -- both alice (owner) and cath (mod) receive XGrpMemNew(frank) via introduceInChannel.
+ -- the roster apply also emits the role-change chat item on frank's side (owner
+ -- profile may not be loaded yet, so the actor renders by memberId hash)
+ threadDelay 1000000
+ memberJoinChannel "team" [bob] [alice, cath] shortLink fullLink frank
+ -- the late joiner learns the roster from the served snapshot (verified below); under the
+ -- no-broadcast model the apply finds no role change to surface, so no item here
+ threadDelay 1000000 -- the served roster arrives async
+ checkMemberRole frank "cath" "moderator"
+ where
+ checkMemberRole :: HasCallStack => TestCC -> T.Text -> T.Text -> IO ()
+ checkMemberRole cc name expectedRole = do
+ roles <- withCCTransaction cc $ \db ->
+ DB.query db "SELECT member_role FROM group_members WHERE local_display_name = ?" (Only name) :: IO [Only T.Text]
+ map (\(Only r) -> r) roles `shouldBe` [expectedRole]
+
+testChannelRemovedModeratorRefreshesRoster :: HasCallStack => TestParams -> IO ()
+testChannelRemovedModeratorRefreshesRoster ps =
+ withNewTestChat ps "alice" aliceProfile $ \alice ->
+ withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob ->
+ withNewTestChat ps "cath" cathProfile $ \cath ->
+ withNewTestChat ps "dan" danProfile $ \dan ->
+ withNewTestChat ps "eve" eveProfile $ \eve ->
+ withNewTestChat ps "frank" frankProfile $ \frank -> do
+ (shortLink, fullLink) <- prepareChannel1Relay "team" alice bob
+ forM_ [cath, dan, eve] $ \member ->
+ memberJoinChannel "team" [bob] [alice] shortLink fullLink member
+ -- cath promoted observer -> moderator; dan/eve learn cath via the roster (id hash)
+ threadDelay 1000000
+ alice ##> "/mr #team cath moderator"
+ alice <## "#team: you changed the role of cath to moderator (signed)"
+ concurrentlyN_
+ [ bob <## "#team: alice changed the role of cath from observer to moderator (signed)",
+ cath <## "#team: alice changed your role from observer to moderator (signed)",
+ dan <### [EndsWith "to moderator (signed)"],
+ eve <### [EndsWith "to moderator (signed)"]
+ ]
+ threadDelay 1000000
+ alice ##> "/rm #team cath"
+ alice <## "#team: you removed cath from the group (signed)"
+ bob <## "#team: alice removed cath from the group (signed)"
+ cath <## "#team: alice removed you from the group (signed)"
+ cath <## "use /d #team to delete the group"
+ dan <### [EndsWith "from the group (signed)"]
+ eve <### [EndsWith "from the group (signed)"]
+
+ -- frank joins after the removal; cached roster has dropped cath
+ threadDelay 1000000
+ memberJoinChannel "team" [bob] [alice] shortLink fullLink frank
+ threadDelay 100000
+ checkMemberRow frank "cath" Nothing
+
+testChannelRoleTransitionsUpdateRoster :: HasCallStack => TestParams -> IO ()
+testChannelRoleTransitionsUpdateRoster ps =
+ withNewTestChat ps "alice" aliceProfile $ \alice ->
+ withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob ->
+ withNewTestChat ps "cath" cathProfile $ \cath ->
+ withNewTestChat ps "dan" danProfile $ \dan ->
+ withNewTestChat ps "eve" eveProfile $ \eve ->
+ withNewTestChat ps "frank" frankProfile $ \frank -> do
+ (shortLink, fullLink) <- prepareChannel1Relay "team" alice bob
+ memberJoinChannel "team" [bob] [alice] shortLink fullLink cath
+ -- observer -> moderator
+ threadDelay 100000
+ alice ##> "/mr #team cath moderator"
+ alice <## "#team: you changed the role of cath to moderator (signed)"
+ concurrentlyN_
+ [ bob <## "#team: alice changed the role of cath from observer to moderator (signed)",
+ cath <## "#team: alice changed your role from observer to moderator (signed)"
+ ]
+ -- dan joins; cached roster has cath as moderator (learned from the served snapshot,
+ -- no separate role-change item under the no-broadcast model)
+ threadDelay 100000
+ memberJoinChannel "team" [bob] [alice, cath] shortLink fullLink dan
+ threadDelay 1000000 -- the served roster arrives async; wait before reading the applied state
+ checkMemberRow dan "cath" (Just "moderator")
+ -- moderator -> admin: dan now knows cath, role event lands cleanly
+ threadDelay 100000
+ alice ##> "/mr #team cath admin"
+ alice <## "#team: you changed the role of cath to admin (signed)"
+ concurrentlyN_
+ [ bob <## "#team: alice changed the role of cath from moderator to admin (signed)",
+ cath <## "#team: alice changed your role from moderator to admin (signed)",
+ dan <## "#team: alice changed the role of cath from moderator to admin (signed)"
+ ]
+ -- eve joins; cached roster has cath as admin (learned from the served snapshot)
+ threadDelay 100000
+ memberJoinChannel "team" [bob] [alice, cath] shortLink fullLink eve
+ threadDelay 1000000 -- the served roster arrives async; wait before reading the applied state
+ checkMemberRow eve "cath" (Just "admin")
+ -- admin -> observer (crossing out of roster, since member is now in-roster): roster drops cath
+ threadDelay 100000
+ alice ##> "/mr #team cath observer"
+ alice <## "#team: you changed the role of cath to observer (signed)"
+ concurrentlyN_
+ [ bob <## "#team: alice changed the role of cath from admin to observer (signed)",
+ cath <## "#team: alice changed your role from admin to observer (signed)",
+ dan <## "#team: alice changed the role of cath from admin to observer (signed)",
+ eve <## "#team: alice changed the role of cath from admin to observer (signed)"
+ ]
+ -- frank joins; cath isn't in the roster, so frank has no record of her
+ threadDelay 100000
+ memberJoinChannel "team" [bob] [alice] shortLink fullLink frank
+ threadDelay 100000
+ checkMemberRow frank "cath" Nothing
+
+testChannelRelayCannotDowngradeRosterMember :: HasCallStack => TestParams -> IO ()
+testChannelRelayCannotDowngradeRosterMember ps =
+ withNewTestChat ps "alice" aliceProfile $ \alice ->
+ withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob ->
+ withNewTestChat ps "cath" cathProfile $ \cath ->
+ withNewTestChatOpts ps (testOpts {coreOptions = testCoreOpts {logLevel = CLLWarning}}) "frank" frankProfile $ \frank -> do
+ (shortLink, fullLink) <- prepareChannel1Relay "team" alice bob
+ memberJoinChannel "team" [bob] [alice] shortLink fullLink cath
+ memberJoinChannel "team" [bob] [alice] shortLink fullLink frank
+ -- promote cath; roster TOFU-creates cath on frank as moderator with the real key
+ threadDelay 1000000
+ alice ##> "/mr #team cath moderator"
+ alice <## "#team: you changed the role of cath to moderator (signed)"
+ concurrentlyN_
+ [ bob <## "#team: alice changed the role of cath from observer to moderator (signed)",
+ cath <## "#team: alice changed your role from observer to moderator (signed)",
+ frank <### [EndsWith "to moderator (signed)"]
+ ]
+ threadDelay 100000
+ realKey <- getMemberPubKey bob "cath"
+ -- malicious relay: corrupt bob's local record of cath so its XGrpMemNew dissemination
+ -- carries a downgraded role + no key
+ withCCTransaction bob $ \db ->
+ DB.execute
+ db
+ "UPDATE group_members SET member_role = ?, member_pub_key = NULL WHERE local_display_name = ?"
+ ("member" :: T.Text, "cath" :: T.Text)
+ -- cath posts; bob prepends XGrpMemNew(cath, member, NULL) to the delivery (frank not yet introduced)
+ threadDelay 100000
+ cath #> "#team hello from cath"
+ bob <# "#team cath> hello from cath"
+ concurrentlyN_
+ [ alice <# "#team cath> hello from cath [>>]",
+ do
+ frank <##. "warning: x.grp.mem.new: relay asserted key differs from roster-established key, keeping roster key, memberId="
+ frank <### [EndsWith "updated to cath"]
+ frank <## "#team: bob introduced cath (Catherine) in the channel"
+ frank <# "#team cath> hello from cath [>>]"
+ ]
+ threadDelay 100000
+ checkMemberRow frank "cath" (Just "moderator")
+ frankKey <- getMemberPubKey frank "cath"
+ frankKey `shouldBe` realKey
+ where
+ getMemberPubKey :: TestCC -> T.Text -> IO (Maybe ByteString)
+ getMemberPubKey cc name = do
+ rows <- withCCTransaction cc $ \db ->
+ DB.query db "SELECT member_pub_key FROM group_members WHERE local_display_name = ?" (Only name) :: IO [Only (Maybe ByteString)]
+ case rows of
+ [Only k] -> pure k
+ _ -> fail $ "expected one row for " <> T.unpack name
+
testChannelRemoveMemberSigned :: HasCallStack => TestParams -> IO ()
testChannelRemoveMemberSigned ps =
withNewTestChat ps "alice" aliceProfile $ \alice ->
@@ -9552,15 +9858,20 @@ testChannelRemoveMemberSigned ps =
withNewTestChat ps "eve" eveProfile $ \eve -> do
createChannel1Relay "team" alice bob cath dan eve
+ -- promote eve to member (observer default) so it can post
+ promoteChannelMember "team" alice bob eve [cath, dan]
+
-- other members discover eve
eve #> "#team hello from eve"
bob <# "#team eve> hello from eve"
concurrentlyN_
[ alice <# "#team eve> hello from eve [>>]",
do
+ dan <### [EndsWith "updated to eve"]
dan <## "#team: bob introduced eve (Eve) in the channel"
dan <# "#team eve> hello from eve [>>]",
do
+ cath <### [EndsWith "updated to eve"]
cath <## "#team: bob introduced eve (Eve) in the channel"
cath <# "#team eve> hello from eve [>>]"
]
@@ -9733,6 +10044,9 @@ testChannelSubscriberLeave ps =
withNewTestChat ps "eve" eveProfile $ \eve -> do
createChannel1Relay "team" alice bob cath dan eve
+ -- promote cath to member (observer default) so it can post
+ promoteChannelMember "team" alice bob cath [dan, eve]
+
-- other members discover cath
threadDelay 1000000
cath #> "#team hello from cath"
@@ -9740,9 +10054,11 @@ testChannelSubscriberLeave ps =
concurrentlyN_
[ alice <# "#team cath> hello from cath [>>]",
do
+ dan <### [EndsWith "updated to cath"]
dan <## "#team: bob introduced cath (Catherine) in the channel"
dan <# "#team cath> hello from cath [>>]",
do
+ eve <### [EndsWith "updated to cath"]
eve <## "#team: bob introduced cath (Catherine) in the channel"
eve <# "#team cath> hello from cath [>>]"
]
@@ -9968,6 +10284,9 @@ testChannelSubscriberProfileUpdate ps =
withNewTestChat ps "eve" eveProfile $ \eve -> do
createChannel1Relay "team" alice bob cath dan eve
+ -- promote dan to member early (observer default) so its role-change item precedes the messages
+ promoteChannelMember "team" alice bob dan [cath, eve]
+
-- enable support and create support chat for cath (but not dan)
threadDelay 1000000
alice ##> "/set support #team on"
@@ -9987,6 +10306,9 @@ testChannelSubscriberProfileUpdate ps =
(dan )
(eve )
+ -- promote cath to member (observer default) so it can post
+ promoteChannelMember "team" alice bob cath [dan, eve]
+
-- other members discover cath
threadDelay 1000000
cath #> "#team hello from cath"
@@ -9994,9 +10316,11 @@ testChannelSubscriberProfileUpdate ps =
concurrentlyN_
[ alice <# "#team cath> hello from cath [>>]",
do
+ dan <### [EndsWith "updated to cath"]
dan <## "#team: bob introduced cath (Catherine) in the channel"
dan <# "#team cath> hello from cath [>>]",
do
+ eve <### [EndsWith "updated to cath"]
eve <## "#team: bob introduced cath (Catherine) in the channel"
eve <# "#team cath> hello from cath [>>]"
]
@@ -10026,9 +10350,8 @@ testChannelSubscriberProfileUpdate ps =
cath #$> ("/_get chat #1 count=2", chat, [(1, "hello from cath"), (1, "hello from kate")])
-- verify profiles are updated correctly
forM_ [alice, bob] $ \cc -> cc `hasContactProfiles` ["alice", "bob", "kate", "dan", "eve"]
- cath `hasContactProfiles` ["alice", "bob", "kate"]
dan `hasContactProfiles` ["alice", "bob", "kate", "dan"]
- eve `hasContactProfiles` ["alice", "bob", "kate", "eve"]
+ -- cath/eve also know dan by id hash now (roster-learned before dan posts); not asserted
-- previously silent subscriber updates profile
-- dan has no support chat -> no profile update item created
@@ -10040,9 +10363,11 @@ testChannelSubscriberProfileUpdate ps =
concurrentlyN_
[ alice <# "#team dave> hello from dave [>>]",
do
+ eve <### [EndsWith "updated to dave"]
eve <## "#team: bob introduced dave in the channel"
eve <# "#team dave> hello from dave [>>]",
do
+ cath <### [EndsWith "updated to dave"]
cath <## "#team: bob introduced dave in the channel"
cath <# "#team dave> hello from dave [>>]"
]
@@ -10128,6 +10453,250 @@ testChannelAddRelay ps =
[bob, cath] *<# "#team> hello"
[dan, eve] *<# "#team> hello [>>]"
+testChannelAddRelayWithRoster :: HasCallStack => TestParams -> IO ()
+testChannelAddRelayWithRoster ps =
+ withNewTestChat ps "alice" aliceProfile $ \alice ->
+ withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob ->
+ withNewTestChatOpts ps relayTestOpts "dan" danProfile $ \dan ->
+ withNewTestChat ps "cath" cathProfile $ \cath ->
+ withNewTestChat ps "eve" eveProfile $ \_eve -> do
+ (shortLink, fullLink) <- prepareChannel1Relay "team" alice bob
+ memberJoinChannel "team" [bob] [alice] shortLink fullLink cath
+
+ -- promote cath observer -> moderator: the roster is created (bob caches it)
+ threadDelay 100000
+ alice ##> "/mr #team cath moderator"
+ alice <## "#team: you changed the role of cath to moderator (signed)"
+ concurrentlyN_
+ [ bob <## "#team: alice changed the role of cath from observer to moderator (signed)",
+ cath <## "#team: alice changed your role from observer to moderator (signed)"
+ ]
+ threadDelay 100000
+
+ -- add dan as a 2nd relay; with a roster present it must cache the roster and ack
+ -- (XGrpRosterAck) before alice publishes it as joinable
+ dan ##> "/ad"
+ (danSLink, _cLink) <- getContactLinks dan True
+ alice ##> ("/relays name=dan " <> danSLink)
+ alice <## "ok"
+ alice ##> "/_add relays #1 2"
+ alice <## "#team: group relays:"
+ alice <## " - relay id 1: active"
+ alice <## " - relay id 2: invited"
+ concurrentlyN_
+ [ do
+ alice <## "#team: group link relays updated, current relays:"
+ alice
+ <### [ " - relay id 1: active",
+ " - relay id 2: active"
+ ]
+ alice <## "group link:"
+ void $ getTermLine alice,
+ dan <## "#team: you joined the group as relay"
+ ]
+
+ -- cath (an existing member) connects to the new relay and is attached to her roster
+ -- record, kept as moderator (the relay learned cath from the cached roster snapshot, so
+ -- it surfaces no role-change item for her)
+ concurrentlyN_
+ [ do
+ cath <## "#team: joining the group (connecting to relay dan)..."
+ cath <## "#team: you joined the group (connected to relay dan)",
+ dan
+ <### [ EndsWith "accepting request to join group #team...",
+ EndsWith "is connected"
+ ]
+ ]
+
+ threadDelay 100000
+ -- the new relay holds the roster (cath is moderator) and learns her name when she connects
+ checkMemberRow dan "cath" (Just "moderator")
+
+testChannelRosterMultipartReassembly :: HasCallStack => TestParams -> IO ()
+testChannelRosterMultipartReassembly ps =
+ withNewTestChatCfgOpts ps cfg testOpts "alice" aliceProfile $ \alice ->
+ withNewTestChatCfgOpts ps cfg relayTestOpts "bob" bobProfile $ \bob ->
+ withNewTestChatCfgOpts ps cfg testOpts "cath" cathProfile $ \cath ->
+ withNewTestChatCfgOpts ps cfg testOpts "dan" danProfile $ \dan -> do
+ (shortLink, fullLink) <- prepareChannel1Relay "team" alice bob
+ memberJoinChannel "team" [bob] [alice] shortLink fullLink cath
+ threadDelay 100000
+ alice ##> "/mr #team cath moderator"
+ alice <## "#team: you changed the role of cath to moderator (signed)"
+ concurrentlyN_
+ [ bob <## "#team: alice changed the role of cath from observer to moderator (signed)",
+ cath <## "#team: alice changed your role from observer to moderator (signed)"
+ ]
+ threadDelay 100000
+ memberJoinChannel "team" [bob] [alice, cath] shortLink fullLink dan
+ -- dan reassembles the multi-chunk roster from the served snapshot (arrives async)
+ threadDelay 1000000
+ checkMemberRow dan "cath" (Just "moderator")
+ where
+ cfg = testCfg {fileChunkSize = 30}
+
+testChannelRosterDigestMismatchRejected :: HasCallStack => TestParams -> IO ()
+testChannelRosterDigestMismatchRejected ps =
+ withNewTestChat ps "alice" aliceProfile $ \alice ->
+ withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob ->
+ withNewTestChat ps "cath" cathProfile $ \cath ->
+ withNewTestChat ps "frank" frankProfile $ \frank -> do
+ (shortLink, fullLink) <- prepareChannel1Relay "team" alice bob
+ memberJoinChannel "team" [bob] [alice] shortLink fullLink cath
+ threadDelay 100000
+ alice ##> "/mr #team cath moderator"
+ alice <## "#team: you changed the role of cath to moderator (signed)"
+ concurrentlyN_
+ [ bob <## "#team: alice changed the role of cath from observer to moderator (signed)",
+ cath <## "#team: alice changed your role from observer to moderator (signed)"
+ ]
+ threadDelay 100000
+ -- corrupt the relay's stored blob (same length, different content) so its digest no
+ -- longer matches the signed header (DB-agnostic: read it, overwrite with zeroed bytes)
+ withCCTransaction bob $ \db -> do
+ rows <- DB.query_ db "SELECT roster_blob FROM groups WHERE roster_blob IS NOT NULL" :: IO [Only (Binary ByteString)]
+ forM_ rows $ \(Only (Binary blob)) ->
+ DB.execute db "UPDATE groups SET roster_blob = ? WHERE roster_blob IS NOT NULL" (Only (Binary (B.replicate (B.length blob) '\NUL')))
+ -- frank joins; bob re-serves the valid header with the corrupted blob, frank rejects it
+ threadDelay 100000
+ memberJoinChannel "team" [bob] [alice, cath] shortLink fullLink frank
+ threadDelay 1000000
+ -- the rejected roster never elevates cath: the intro caps her to the channel default, so she
+ -- stays observer (not moderator), and the version must not advance to the corrupted roster's version 1
+ checkMemberRow frank "cath" (Just "observer")
+ checkRosterNotApplied frank
+ where
+ -- the version is the second guarantee (the role is asserted above): frank holds exactly the team
+ -- group with no roster applied, so roster_version is NULL - it never advanced to the corrupted version 1
+ checkRosterNotApplied :: HasCallStack => TestCC -> IO ()
+ checkRosterNotApplied cc = do
+ vs <- withCCTransaction cc $ \db ->
+ DB.query_ db "SELECT roster_version FROM groups" :: IO [Only (Maybe Int64)]
+ map (\(Only v) -> v) vs `shouldBe` [Nothing]
+
+testChannelPromotedMemberCanPost :: HasCallStack => TestParams -> IO ()
+testChannelPromotedMemberCanPost ps =
+ withNewTestChat ps "alice" aliceProfile $ \alice ->
+ withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob ->
+ withNewTestChat ps "cath" cathProfile $ \cath ->
+ withNewTestChat ps "dan" danProfile $ \dan -> do
+ (shortLink, fullLink) <- prepareChannel1Relay "team" alice bob
+ memberJoinChannel "team" [bob] [alice] shortLink fullLink cath
+ memberJoinChannel "team" [bob] [alice] shortLink fullLink dan
+ -- promote cath to member: cath enters the owner-signed roster (dan learns cath by id hash)
+ promoteChannelMember "team" alice bob cath [dan]
+ -- the promoted member can now post; dan resolves cath on the first forward
+ cath #> "#team hi from cath"
+ bob <# "#team cath> hi from cath"
+ alice <# "#team cath> hi from cath [>>]"
+ dan <### [EndsWith "updated to cath"]
+ dan <## "#team: bob introduced cath (Catherine) in the channel"
+ dan <# "#team cath> hi from cath [>>]"
+ checkMemberRow dan "cath" (Just "member")
+
+testChannelObserverCannotPost :: HasCallStack => TestParams -> IO ()
+testChannelObserverCannotPost ps =
+ withNewTestChat ps "alice" aliceProfile $ \alice ->
+ withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob ->
+ withNewTestChat ps "cath" cathProfile $ \cath ->
+ withNewTestChat ps "dan" danProfile $ \dan -> do
+ (shortLink, fullLink) <- prepareChannel1Relay "team" alice bob
+ memberJoinChannel "team" [bob] [alice] shortLink fullLink cath
+ memberJoinChannel "team" [bob] [alice] shortLink fullLink dan
+ -- cath is an observer (default): its own post is rejected locally and never reaches the relay
+ cath ##> "#team observer attempt"
+ cath <## "#team: you don't have permission to send messages"
+ -- promote cath to member; the post is now accepted and delivered, dan resolves cath
+ promoteChannelMember "team" alice bob cath [dan]
+ cath #> "#team member post"
+ bob <# "#team cath> member post"
+ alice <# "#team cath> member post [>>]"
+ dan <### [EndsWith "updated to cath"]
+ dan <## "#team: bob introduced cath (Catherine) in the channel"
+ dan <# "#team cath> member post [>>]"
+
+testChannelPromotedMemberRejoinViaRelay :: HasCallStack => TestParams -> IO ()
+testChannelPromotedMemberRejoinViaRelay ps =
+ withNewTestChat ps "alice" aliceProfile $ \alice ->
+ withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob ->
+ withNewTestChatOpts ps relayTestOpts "dan" danProfile $ \dan ->
+ withNewTestChat ps "cath" cathProfile $ \cath -> do
+ (shortLink, fullLink) <- prepareChannel1Relay "team" alice bob
+ memberJoinChannel "team" [bob] [alice] shortLink fullLink cath
+ -- promote cath to member: cath enters the owner-signed roster with her pinned key
+ threadDelay 100000
+ promoteChannelMember "team" alice bob cath []
+ threadDelay 100000
+ -- add dan as a 2nd relay; it caches the roster (incl. member cath) before joinable
+ dan ##> "/ad"
+ (danSLink, _cLink) <- getContactLinks dan True
+ alice ##> ("/relays name=dan " <> danSLink)
+ alice <## "ok"
+ alice ##> "/_add relays #1 2"
+ alice <## "#team: group relays:"
+ alice <## " - relay id 1: active"
+ alice <## " - relay id 2: invited"
+ concurrentlyN_
+ [ do
+ alice <## "#team: group link relays updated, current relays:"
+ alice
+ <### [ " - relay id 1: active",
+ " - relay id 2: active"
+ ]
+ alice <## "group link:"
+ void $ getTermLine alice,
+ dan <## "#team: you joined the group as relay"
+ ]
+ -- cath (a promoted member) connects to the new relay; the widened join gate
+ -- (verifyKey over the roster-pinned key) accepts her and keeps her as member
+ concurrentlyN_
+ [ do
+ cath <## "#team: joining the group (connecting to relay dan)..."
+ cath <## "#team: you joined the group (connected to relay dan)",
+ dan
+ <### [ EndsWith "accepting request to join group #team...",
+ EndsWith "is connected"
+ ]
+ ]
+ threadDelay 100000
+ checkMemberRow dan "cath" (Just "member")
+
+testChannelRosterMultiRelayMultipart :: HasCallStack => TestParams -> IO ()
+testChannelRosterMultiRelayMultipart ps =
+ withNewTestChatCfgOpts ps cfg testOpts "alice" aliceProfile $ \alice ->
+ withNewTestChatCfgOpts ps cfg relayTestOpts "bob" bobProfile $ \bob ->
+ withNewTestChatCfgOpts ps cfg relayTestOpts "cath" cathProfile $ \cath ->
+ withNewTestChatCfgOpts ps cfg testOpts "dan" danProfile $ \dan ->
+ withNewTestChatCfgOpts ps cfg testOpts "eve" eveProfile $ \eve ->
+ withNewTestChatCfgOpts ps cfg testOpts "frank" frankProfile $ \frank -> do
+ createChannel2Relays "team" alice bob cath dan eve frank
+
+ -- promote eve to moderator: the owner-signed roster broadcasts through BOTH relays to dan and
+ -- frank (each connected to both). At fileChunkSize=30 the blob spans multiple chunks, so each
+ -- member receives two interleaved multi-chunk streams (one per relay) for the same roster.
+ threadDelay 1000000
+ alice ##> "/mr #team eve moderator"
+ alice <## "#team: you changed the role of eve to moderator (signed)"
+ concurrentlyN_
+ [ bob <## "#team: alice changed the role of eve from observer to moderator (signed)",
+ cath <## "#team: alice changed the role of eve from observer to moderator (signed)",
+ eve <## "#team: alice changed your role from observer to moderator (signed)",
+ dan <### [EndsWith "to moderator (signed)"],
+ frank <### [EndsWith "to moderator (signed)"]
+ ]
+ threadDelay 1000000 -- let both relays' interleaved multipart streams settle
+
+ -- per-source transfers keep the streams independent, so each member reassembles the blob and pins
+ -- eve as the single moderator WITH her owner-attested key (role + key both come from the blob)
+ checkOneModeratorWithKey dan
+ checkOneModeratorWithKey frank
+ where
+ cfg = testCfg {fileChunkSize = 30}
+ checkOneModeratorWithKey cc = do
+ rows <- withCCTransaction cc $ \db ->
+ DB.query_ db "SELECT member_pub_key FROM group_members WHERE member_role = 'moderator'" :: IO [Only (Maybe ByteString)]
+ map (\(Only k) -> isJust k) rows `shouldBe` [True]
+
testChannelRemoveRelay :: HasCallStack => TestParams -> IO ()
testChannelRemoveRelay ps =
withNewTestChat ps "alice" aliceProfile $ \alice ->
@@ -10708,42 +11277,48 @@ testChannelMessageFile ps =
withNewTestChat ps "dan" danProfile $ \dan ->
withNewTestChat ps "eve" eveProfile $ \eve -> withXFTPServer $ do
createChannel1Relay "team" alice bob cath dan eve
-
+ -- the roster arrives as a file before this one; Postgres assigns it a new id and does not
+ -- reuse it on delete (SQLite does), so the received message file is id 2 here, 1 on SQLite.
+#if defined(dbPostgres)
+ let rcvFileId = 2 :: Int
+#else
+ let rcvFileId = 1 :: Int
+#endif
-- owner sends file as channel message
alice #> "/f #team ./tests/fixtures/test.jpg"
alice <## "use /fc 1 to cancel sending"
alice <## "completed uploading file 1 (test.jpg) for #team"
bob <# "#team> sends file test.jpg (136.5 KiB / 139737 bytes)"
- bob <## "use /fr 1 [
/ | ] to receive it"
+ bob <## ("use /fr " <> show rcvFileId <> " [/ | ] to receive it")
concurrentlyN_
[ do
cath <# "#team> sends file test.jpg (136.5 KiB / 139737 bytes) [>>]"
- cath <## "use /fr 1 [/ | ] to receive it [>>]",
+ cath <## ("use /fr " <> show rcvFileId <> " [/ | ] to receive it [>>]"),
do
dan <# "#team> sends file test.jpg (136.5 KiB / 139737 bytes) [>>]"
- dan <## "use /fr 1 [/ | ] to receive it [>>]",
+ dan <## ("use /fr " <> show rcvFileId <> " [/ | ] to receive it [>>]"),
do
eve <# "#team> sends file test.jpg (136.5 KiB / 139737 bytes) [>>]"
- eve <## "use /fr 1 [/ | ] to receive it [>>]"
+ eve <## ("use /fr " <> show rcvFileId <> " [/ | ] to receive it [>>]")
]
-- all members receive the file concurrently
src <- B.readFile "./tests/fixtures/test.jpg"
concurrentlyN_
- [ receiveFile bob "bob" src,
- receiveFile cath "cath" src,
- receiveFile dan "dan" src,
- receiveFile eve "eve" src
+ [ receiveFile bob "bob" rcvFileId src,
+ receiveFile cath "cath" rcvFileId src,
+ receiveFile dan "dan" rcvFileId src,
+ receiveFile eve "eve" rcvFileId src
]
where
- receiveFile cc name src = do
+ receiveFile cc name fileId src = do
let path = "./tests/tmp/test_" <> name <> ".jpg"
- cc ##> ("/fr 1 " <> path)
+ cc ##> ("/fr " <> show fileId <> " " <> path)
cc
- <### [ ConsoleString ("saving file 1 from #team to " <> path),
- "started receiving file 1 (test.jpg) from #team"
+ <### [ ConsoleString ("saving file " <> show fileId <> " from #team to " <> path),
+ ConsoleString ("started receiving file " <> show fileId <> " (test.jpg) from #team")
]
- cc <## "completed receiving file 1 (test.jpg) from #team"
+ cc <## ("completed receiving file " <> show fileId <> " (test.jpg) from #team")
B.readFile path >>= (`shouldBe` src)
testChannelMessageFileCancel :: HasCallStack => TestParams -> IO ()
@@ -10754,33 +11329,37 @@ testChannelMessageFileCancel ps =
withNewTestChat ps "dan" danProfile $ \dan ->
withNewTestChat ps "eve" eveProfile $ \eve -> withXFTPServer $ do
createChannel1Relay "team" alice bob cath dan eve
-
+#if defined(dbPostgres)
+ let rcvFileId = 2 :: Int
+#else
+ let rcvFileId = 1 :: Int
+#endif
-- owner sends file as channel message
alice #> "/f #team ./tests/fixtures/test.jpg"
alice <## "use /fc 1 to cancel sending"
alice <## "completed uploading file 1 (test.jpg) for #team"
bob <# "#team> sends file test.jpg (136.5 KiB / 139737 bytes)"
- bob <## "use /fr 1 [/ | ] to receive it"
+ bob <## ("use /fr " <> show rcvFileId <> " [/ | ] to receive it")
concurrentlyN_
[ do
cath <# "#team> sends file test.jpg (136.5 KiB / 139737 bytes) [>>]"
- cath <## "use /fr 1 [/ | ] to receive it [>>]",
+ cath <## ("use /fr " <> show rcvFileId <> " [/ | ] to receive it [>>]"),
do
dan <# "#team> sends file test.jpg (136.5 KiB / 139737 bytes) [>>]"
- dan <## "use /fr 1 [/ | ] to receive it [>>]",
+ dan <## ("use /fr " <> show rcvFileId <> " [/ | ] to receive it [>>]"),
do
eve <# "#team> sends file test.jpg (136.5 KiB / 139737 bytes) [>>]"
- eve <## "use /fr 1 [/ | ] to receive it [>>]"
+ eve <## ("use /fr " <> show rcvFileId <> " [/ | ] to receive it [>>]")
]
-- owner cancels file
alice ##> "/fc 1"
alice <## "cancelled sending file 1 (test.jpg) to bob"
- bob <## "team cancelled sending file 1 (test.jpg)"
+ bob <## ("team cancelled sending file " <> show rcvFileId <> " (test.jpg)")
concurrentlyN_
- [ cath <## "team cancelled sending file 1 (test.jpg)",
- dan <## "team cancelled sending file 1 (test.jpg)",
- eve <## "team cancelled sending file 1 (test.jpg)"
+ [ cath <## ("team cancelled sending file " <> show rcvFileId <> " (test.jpg)"),
+ dan <## ("team cancelled sending file " <> show rcvFileId <> " (test.jpg)"),
+ eve <## ("team cancelled sending file " <> show rcvFileId <> " (test.jpg)")
]
testChannelMessageQuote :: HasCallStack => TestParams -> IO ()
@@ -10797,6 +11376,9 @@ testChannelMessageQuote ps =
bob <# "#team> hello from channel"
[cath, dan, eve] *<# "#team> hello from channel [>>]"
+ -- promote cath to member (observer default) so it can post
+ promoteChannelMember "team" alice bob cath [dan, eve]
+
-- member quotes channel message
cath `send` "> #team (hello from) replying to channel"
cath <# "#team > hello from channel"
@@ -10808,10 +11390,12 @@ testChannelMessageQuote ps =
alice <# "#team cath> > hello from channel [>>]"
alice <## " replying to channel [>>]",
do
+ dan <### [EndsWith "updated to cath"]
dan <## "#team: bob introduced cath (Catherine) in the channel"
dan <# "#team cath> > hello from channel [>>]"
dan <## " replying to channel [>>]",
do
+ eve <### [EndsWith "updated to cath"]
eve <## "#team: bob introduced cath (Catherine) in the channel"
eve <# "#team cath> > hello from channel [>>]"
eve <## " replying to channel [>>]"
@@ -10925,43 +11509,47 @@ testChannelOwnerFileTransferAsMember ps =
withNewTestChat ps "dan" danProfile $ \dan ->
withNewTestChat ps "eve" eveProfile $ \eve -> withXFTPServer $ do
createChannel1Relay "team" alice bob cath dan eve
-
+#if defined(dbPostgres)
+ let rcvFileId = 2 :: Int
+#else
+ let rcvFileId = 1 :: Int
+#endif
-- owner sends file as member (not as channel)
alice ##> "/_send #1(as_group=off) json [{\"filePath\": \"./tests/fixtures/test.jpg\", \"msgContent\": {\"type\": \"file\", \"text\": \"\"}}]"
alice <# "/f #team ./tests/fixtures/test.jpg"
alice <## "use /fc 1 to cancel sending"
alice <## "completed uploading file 1 (test.jpg) for #team"
bob <# "#team alice> sends file test.jpg (136.5 KiB / 139737 bytes)"
- bob <## "use /fr 1 [/ | ] to receive it"
+ bob <## ("use /fr " <> show rcvFileId <> " [/ | ] to receive it")
concurrentlyN_
[ do
cath <# "#team alice> sends file test.jpg (136.5 KiB / 139737 bytes) [>>]"
- cath <## "use /fr 1 [/ | ] to receive it [>>]",
+ cath <## ("use /fr " <> show rcvFileId <> " [/ | ] to receive it [>>]"),
do
dan <# "#team alice> sends file test.jpg (136.5 KiB / 139737 bytes) [>>]"
- dan <## "use /fr 1 [/ | ] to receive it [>>]",
+ dan <## ("use /fr " <> show rcvFileId <> " [/ | ] to receive it [>>]"),
do
eve <# "#team alice> sends file test.jpg (136.5 KiB / 139737 bytes) [>>]"
- eve <## "use /fr 1 [/ | ] to receive it [>>]"
+ eve <## ("use /fr " <> show rcvFileId <> " [/ | ] to receive it [>>]")
]
-- all members receive the file
src <- B.readFile "./tests/fixtures/test.jpg"
concurrentlyN_
- [ receiveFile bob "bob" src,
- receiveFile cath "cath" src,
- receiveFile dan "dan" src,
- receiveFile eve "eve" src
+ [ receiveFile bob "bob" rcvFileId src,
+ receiveFile cath "cath" rcvFileId src,
+ receiveFile dan "dan" rcvFileId src,
+ receiveFile eve "eve" rcvFileId src
]
where
- receiveFile cc name src = do
+ receiveFile cc name fileId src = do
let path = "./tests/tmp/test_" <> name <> ".jpg"
- cc ##> ("/fr 1 " <> path)
+ cc ##> ("/fr " <> show fileId <> " " <> path)
cc
- <### [ ConsoleString ("saving file 1 from alice to " <> path),
- "started receiving file 1 (test.jpg) from alice"
+ <### [ ConsoleString ("saving file " <> show fileId <> " from alice to " <> path),
+ ConsoleString ("started receiving file " <> show fileId <> " (test.jpg) from alice")
]
- cc <## "completed receiving file 1 (test.jpg) from alice"
+ cc <## ("completed receiving file " <> show fileId <> " (test.jpg) from alice")
B.readFile path >>= (`shouldBe` src)
testChannelOwnerFileCancelAsMember :: HasCallStack => TestParams -> IO ()
@@ -10972,34 +11560,38 @@ testChannelOwnerFileCancelAsMember ps =
withNewTestChat ps "dan" danProfile $ \dan ->
withNewTestChat ps "eve" eveProfile $ \eve -> withXFTPServer $ do
createChannel1Relay "team" alice bob cath dan eve
-
+#if defined(dbPostgres)
+ let rcvFileId = 2 :: Int
+#else
+ let rcvFileId = 1 :: Int
+#endif
-- owner sends file as member (not as channel)
alice ##> "/_send #1(as_group=off) json [{\"filePath\": \"./tests/fixtures/test.jpg\", \"msgContent\": {\"type\": \"file\", \"text\": \"\"}}]"
alice <# "/f #team ./tests/fixtures/test.jpg"
alice <## "use /fc 1 to cancel sending"
alice <## "completed uploading file 1 (test.jpg) for #team"
bob <# "#team alice> sends file test.jpg (136.5 KiB / 139737 bytes)"
- bob <## "use /fr 1 [/ | ] to receive it"
+ bob <## ("use /fr " <> show rcvFileId <> " [/ | ] to receive it")
concurrentlyN_
[ do
cath <# "#team alice> sends file test.jpg (136.5 KiB / 139737 bytes) [>>]"
- cath <## "use /fr 1 [/ | ] to receive it [>>]",
+ cath <## ("use /fr " <> show rcvFileId <> " [/ | ] to receive it [>>]"),
do
dan <# "#team alice> sends file test.jpg (136.5 KiB / 139737 bytes) [>>]"
- dan <## "use /fr 1 [/ | ] to receive it [>>]",
+ dan <## ("use /fr " <> show rcvFileId <> " [/ | ] to receive it [>>]"),
do
eve <# "#team alice> sends file test.jpg (136.5 KiB / 139737 bytes) [>>]"
- eve <## "use /fr 1 [/ | ] to receive it [>>]"
+ eve <## ("use /fr " <> show rcvFileId <> " [/ | ] to receive it [>>]")
]
-- owner cancels file
alice ##> "/fc 1"
alice <## "cancelled sending file 1 (test.jpg) to bob"
- bob <## "alice cancelled sending file 1 (test.jpg)"
+ bob <## ("alice cancelled sending file " <> show rcvFileId <> " (test.jpg)")
concurrentlyN_
- [ cath <## "alice cancelled sending file 1 (test.jpg)",
- dan <## "alice cancelled sending file 1 (test.jpg)",
- eve <## "alice cancelled sending file 1 (test.jpg)"
+ [ cath <## ("alice cancelled sending file " <> show rcvFileId <> " (test.jpg)"),
+ dan <## ("alice cancelled sending file " <> show rcvFileId <> " (test.jpg)"),
+ eve <## ("alice cancelled sending file " <> show rcvFileId <> " (test.jpg)")
]
testChannelReactionAttribution :: HasCallStack => TestParams -> IO ()
@@ -11163,14 +11755,19 @@ testChannelMemberMessageUpdate ps =
withNewTestChat ps "eve" eveProfile $ \eve -> do
createChannel1Relay "team" alice bob cath dan eve
+ -- promote cath to member (observer default) so it can post
+ promoteChannelMember "team" alice bob cath [dan, eve]
+
-- member sends a message
cath #> "#team hello"
bob <# "#team cath> hello"
concurrentlyN_
[ alice <# "#team cath> hello [>>]",
- do dan <## "#team: bob introduced cath (Catherine) in the channel"
+ do dan <### [EndsWith "updated to cath"]
+ dan <## "#team: bob introduced cath (Catherine) in the channel"
dan <# "#team cath> hello [>>]",
- do eve <## "#team: bob introduced cath (Catherine) in the channel"
+ do eve <### [EndsWith "updated to cath"]
+ eve <## "#team: bob introduced cath (Catherine) in the channel"
eve <# "#team cath> hello [>>]"
]
@@ -11194,14 +11791,19 @@ testChannelMemberMessageDelete ps =
withNewTestChat ps "eve" eveProfile $ \eve -> do
createChannel1Relay "team" alice bob cath dan eve
+ -- promote cath to member (observer default) so it can post
+ promoteChannelMember "team" alice bob cath [dan, eve]
+
-- member sends a message
cath #> "#team hello"
bob <# "#team cath> hello"
concurrentlyN_
[ alice <# "#team cath> hello [>>]",
- do dan <## "#team: bob introduced cath (Catherine) in the channel"
+ do dan <### [EndsWith "updated to cath"]
+ dan <## "#team: bob introduced cath (Catherine) in the channel"
dan <# "#team cath> hello [>>]",
- do eve <## "#team: bob introduced cath (Catherine) in the channel"
+ do eve <### [EndsWith "updated to cath"]
+ eve <## "#team: bob introduced cath (Catherine) in the channel"
eve <# "#team cath> hello [>>]"
]
diff --git a/tests/ProtocolTests.hs b/tests/ProtocolTests.hs
index d76c86495f..2592420d01 100644
--- a/tests/ProtocolTests.hs
+++ b/tests/ProtocolTests.hs
@@ -155,7 +155,7 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do
"{\"v\":\"1\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"}}}"
##==## ChatMessage chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew (mcSimple (MCText "hello")))
it "x.msg.new chat message with chat version range" $
- "{\"v\":\"1-18\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"}}}"
+ "{\"v\":\"1-19\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"}}}"
##==## ChatMessage supportedChatVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew (mcSimple (MCText "hello")))
it "x.msg.new quote" $
"{\"v\":\"1\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello to you too\",\"type\":\"text\"},\"quote\":{\"content\":{\"text\":\"hello there!\",\"type\":\"text\"},\"msgRef\":{\"msgId\":\"BQYHCA==\",\"sent\":true,\"sentAt\":\"1970-01-01T00:00:01.000000001Z\"}}}}"
@@ -266,13 +266,13 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do
"{\"v\":\"1\",\"event\":\"x.grp.mem.new\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}"
#==# XGrpMemNew MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Nothing, profile = testProfile, memberKey = Nothing} Nothing
it "x.grp.mem.new with member chat version range" $
- "{\"v\":\"1\",\"event\":\"x.grp.mem.new\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-18\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}"
+ "{\"v\":\"1\",\"event\":\"x.grp.mem.new\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-19\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}"
#==# XGrpMemNew MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Just $ ChatVersionRange supportedChatVRange, profile = testProfile, memberKey = Nothing} Nothing
it "x.grp.mem.intro" $
"{\"v\":\"1\",\"event\":\"x.grp.mem.intro\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}"
#==# XGrpMemIntro MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Nothing, profile = testProfile, memberKey = Nothing} Nothing
it "x.grp.mem.intro with member chat version range" $
- "{\"v\":\"1\",\"event\":\"x.grp.mem.intro\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-18\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}"
+ "{\"v\":\"1\",\"event\":\"x.grp.mem.intro\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-19\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}"
#==# XGrpMemIntro MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Just $ ChatVersionRange supportedChatVRange, profile = testProfile, memberKey = Nothing} Nothing
it "x.grp.mem.intro with member restrictions" $
"{\"v\":\"1\",\"event\":\"x.grp.mem.intro\",\"params\":{\"memberRestrictions\":{\"restriction\":\"blocked\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}"
@@ -287,7 +287,7 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do
"{\"v\":\"1\",\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"directConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}"
#==# XGrpMemFwd MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Nothing, profile = testProfile, memberKey = Nothing} IntroInvitation {groupConnReq = testConnReq, directConnReq = Just testConnReq}
it "x.grp.mem.fwd with member chat version range and w/t directConnReq" $
- "{\"v\":\"1\",\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-18\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}"
+ "{\"v\":\"1\",\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-19\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}"
#==# XGrpMemFwd MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Just $ ChatVersionRange supportedChatVRange, profile = testProfile, memberKey = Nothing} IntroInvitation {groupConnReq = testConnReq, directConnReq = Nothing}
it "x.grp.mem.info" $
"{\"v\":\"1\",\"event\":\"x.grp.mem.info\",\"params\":{\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}"
@@ -300,7 +300,7 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do
#==# XGrpMemConAll (MemberId "\1\2\3\4")
it "x.grp.mem.del" $
"{\"v\":\"1\",\"event\":\"x.grp.mem.del\",\"params\":{\"memberId\":\"AQIDBA==\"}}"
- #==# XGrpMemDel (MemberId "\1\2\3\4") False
+ #==# XGrpMemDel (MemberId "\1\2\3\4") False Nothing
it "x.grp.leave" $
"{\"v\":\"1\",\"event\":\"x.grp.leave\",\"params\":{}}"
==# XGrpLeave