core: types for channel invitations

This commit is contained in:
Evgeny @ SimpleX Chat
2026-04-12 13:17:43 +00:00
parent 317bb5388b
commit e8dc556a73
6 changed files with 492 additions and 6 deletions
@@ -0,0 +1,444 @@
# Public Group Invitations & Directory Listing
## Goal
Enable public group (channel) subscribers to invite new subscribers via DM (sharing group link + profile), with owners able to prove ownership via a signed invitation. This unblocks directory service support for public groups alongside regular groups.
## Context
### Current state
- Public groups have `PublicGroupProfile {groupType = GTChannel, groupLink, publicGroupId}` and `useRelays = True`
- Public group invitations are explicitly blocked: `Commands.hs:2475` and `Subscriber.hs:2376`
- Users join public groups via link → `APIPrepareGroup``APIConnectPreparedGroup`
- Group invitations (`XGrpInv`) carry `connRequest :: ConnReqInvitation` — public groups don't use this
- Directory bot registers groups via group invitation (owner invites bot as admin) — public groups need a different flow
### Owner keys in public group links
- `FixedLinkData.rootKey :: PublicKeyEd25519` — genesis root key
- `UserContactData.owners :: [OwnerAuth]` — chain of authorized owner keys, each signed by root or previous owner
- Public group creator stores `GroupKeys {groupRootKey = GRKPrivate rootPrivKey, memberPrivKey}`
- `memberPrivKey`'s public key = `ownerKey` in the `OwnerAuth` entry (created via `newOwnerAuth` in ShortLink.hs:80)
- `publicGroupId = sha256(rootPubKey)` — immutable group identity
- Verification chain: `validateLinkOwners` (Protocol.hs:1821) validates each `OwnerAuth` signature against root or previous owner
### DR connection shared secret
- Each direct connection has `rcAD` (Associated Data) derived from X3DH key exchange — both sides can independently compute it
- `getConnectionRatchetAdHash` returns `sha256(rcAD)` — available via agent API
- This is the binding for replay protection in owner signatures
### How group invitations work (for comparison)
1. `APIAddMember` (Commands.hs:2470): creates agent connection, creates member record, calls `sendGrpInvitation`
2. `sendGrpInvitation` (Commands.hs:3769): builds `GroupInvitation`, sends `XGrpInv`, creates `CISndGroupInvitation` chat item
3. Receiver `processGroupInvitation` (Subscriber.hs:2374): calls `createGroupInvitation` (Store/Groups.hs:453) which **creates a local group stub** — THEN creates `CIRcvGroupInvitation` referencing the new `groupId`
4. `APIJoinGroup` (Commands.hs:2501): retrieves stored `connRequest`, joins via agent connection
5. `updateCIGroupInvitationStatus` (Commands.hs:4077): finds chat item via `getChatItemByGroupId`, updates status
## Design
### New protocol message: `XGrpInvPub`
```haskell
data PublicGroupInvitation = PublicGroupInvitation
{ groupProfile :: GroupProfile, -- contains PublicGroupProfile {groupLink, publicGroupId}
groupOwner :: Maybe GroupOwnerSig, -- present when sender is group owner
groupSize :: Maybe Int -- current subscriber count
}
data GroupOwnerSig = GroupOwnerSig
{ memberId :: MemberId, -- owner's memberId (= ownerId in OwnerAuth list)
bindingData :: ByteString, -- ratchetAdHash (direct) or smpEncode (publicGroupId, memberId) (public group)
ownerSig :: C.Signature 'C.Ed25519 -- sig(smpEncode chatBinding <> bindingData <> invitationBody)
-- chatBinding tag is NOT sent — determined from context by both sides
}
```
Group link and `publicGroupId` come from `groupProfile.publicGroup`.
#### What is signed
Uses the same `MsgSigning`/`ChatBinding` mechanism as relay group message signatures (Protocol.hs:407), extended to support direct chats and non-public groups.
Current binding: `CBGroup` with `bindingData = smpEncode (publicGroupId, memberId)`.
For owner signatures on invitations, the binding depends on where the invitation is sent:
- **Direct chat**: new `CBDirect` with `bindingData = ratchetAdHash` (`sha256(rcAD)` from `getConnectionRatchetAdHash`). Both sides can independently compute it. Prevents replay: Bob can't reuse Alice's signed proof with the bot because `ratchetAdHash` differs per connection.
- **Public group (relay group)**: existing `CBGroup` with `bindingData = smpEncode (publicGroupId, memberId)` — the sender's identity in the group where the invitation is posted.
- **Group without public identity** (groups without `publicGroupId`): no strong binding available. MemberIds are admin-assigned, so an admin could forge a matching memberId in another group and replay a signed invitation. Owner signatures treated as failed verification (`OSSFailed "no group identity for signature verification"`). Invitations can still be shared unsigned.
The signed content is: `smpEncode chatBinding <> bindingData <> invitationBody` signed with `memberPrivKey`.
#### Verification
The verifier (in `LDATA` continuation — link already resolved):
1. From `LDATA` response: `FixedLinkData {rootKey, linkEntityId}` and `UserContactData {owners}`
2. Cross-validates `publicGroupId` from invitation profile matches `linkEntityId` from resolved link → `OSSFailed "group identity mismatch"` if not
3. Determines `chatBinding` from context (direct → `CBDirect`, public group → `CBGroup`, group without identity → `OSSFailed`)
4. Computes expected binding data and compares against `bindingData` from message:
- Direct: expected = `getConnectionRatchetAdHash` for the contact connection
- Public group: expected = `smpEncode (publicGroupId, memberId)`
- Mismatch → `OSSFailed "binding data mismatch"` (possible MitM in direct, or wrong group context)
4. Finds `OwnerAuth` entry where `ownerId == groupOwner.memberId``OSSFailed "unknown member ID"` if not found
5. Reconstructs signed content: `smpEncode chatBinding <> bindingData <> invitationBody`
6. Verifies `ownerSig` against that entry's `ownerKey``OSSFailed "signature verification failed"` if invalid
Only the owner key matching `memberId` is accepted — not root key, not other owner keys.
**Signing** always uses `memberPrivKey` (whose public key matches one `ownerKey` entry).
#### Signature verification status
Separate type — `MsgSigStatus` doesn't apply (needs pending state, different context):
```haskell
data OwnerSigStatus
= OSSPending
| OSSVerified
| OSSFailed {reason :: Text} -- e.g. "unknown member ID", "signature failed"
```
Used as `Maybe OwnerSigStatus` on `CIGroupInvitation`:
- `Nothing` — not signed (regular group invitation or unsigned public group invitation)
- `Just OSSPending` — signed, async link resolution in progress
- `Just OSSVerified` — signed and verified, confirmed owner
- `Just OSSFailed {reason}` — signed but verification failed, reason for debugging
#### Async verification flow
Subscriber is single-threaded — cannot block on link resolution.
1. Receive `XGrpInvPub` with `groupOwner = Just sig`
2. Create invisible prepared group + `CIRcvGroupInvitation` with `ownerSigStatus = Just OSSPending`
3. Fire `CEvtNewChatItems`
4. Call `getAgentConnShortLinkAsync` with a new `CommandFunction` (e.g. `CFGetPublicGroupLinkData`) to start async link resolution
5. Agent resolves short link, returns `LDATA` through subscriber
6. `LDATA` handler matches `CFGetPublicGroupLinkData` in `withCompletedCommand` continuation:
- Gets `FixedLinkData {rootKey, linkEntityId}` and `UserContactData {owners}`
- Cross-validates `publicGroupId` from profile matches `linkEntityId`
- Gets `ratchetAdHash` via `getConnectionRatchetAdHash`
- Finds `OwnerAuth` entry where `ownerId == sig.memberId`
- Verifies signature → `OSSVerified` or `OSSFailed {reason}`
- Updates group data from resolved link data (via store)
- Updates chat item `ownerSigStatus` (via store + `toView`)
This follows the same pattern as `CFGetRelayDataJoin` / `CFGetRelayDataAccept` in Subscriber.hs:1124-1171.
UI presentation based on `isChannel`/group type:
- `Nothing` → neutral invitation display
- `Just OSSPending` → invitation with pending verification indicator
- `Just OSSVerified` → "Invited by channel owner" with verification indicator
- `Just OSSFailed _` → warning indicator
### Protocol wire format
```
event: "x.grp.inv.pub"
params: { "groupInvitation": PublicGroupInvitation }
```
Added to `ChatMsgEvent` GADT, `CMEventTag`, parser (`appJsonToCM`), encoder (`chatToAppMessage`). Same pattern as `XGrpRelayInv`.
### Chat item types: reuse `CIGroupInvitation` with invisible prepared group
On receiving a public group invitation, create an **invisible prepared group** immediately. This gives us a `groupId` so `CIGroupInvitation` works as-is. The group is not visible in the chat list until the user taps the invitation and confirms.
**Extended `CIGroupInvitation`:**
```haskell
data CIGroupInvitation = CIGroupInvitation
{ groupId :: GroupId,
groupMemberId :: GroupMemberId,
localDisplayName :: GroupName,
groupProfile :: GroupProfile,
status :: CIGroupInvitationStatus,
ownerSigStatus :: Maybe OwnerSigStatus -- NEW: Nothing for regular group invitations and unsigned public group invitations
}
```
Existing `CIRcvGroupInvitation` / `CISndGroupInvitation` constructors are reused — no new CIContent cases needed.
Only `CIGISPending` and `CIGISAccepted` statuses are used for public group invitations.
**All platforms** (Swift, Kotlin) add optional `ownerSigStatus` field to `CIGroupInvitation` — backward compatible (`null`/`nil` for regular group invitations).
#### Invisible prepared group lifecycle
**On receive:**
1. Create invisible prepared group from invitation profile data (similar to `APIPrepareGroup` but without network calls)
2. Store group link in the group record
3. Create `CIRcvGroupInvitation` referencing the new `groupId`, with `ownerSigStatus`
4. Group is NOT visible in chat list
**On tap:**
1. Make the group visible (set `hidden = 0`)
2. If link was already resolved (LDATA continuation ran): group data is up-to-date, navigate to group
3. If link was NOT resolved (unsigned invitation, or LDATA failed): resolve link now, update group data
4. User sees group preview, taps Join → `APIConnectPreparedGroup` → update status to `CIGISAccepted`
**Cleanup:**
- Cleanup manager periodically picks up stale invisible groups that were never opened
- If invitation deleted and then a new one arrives for the same group (same `publicGroupId`), reuse the existing invisible group if cleanup hasn't picked it up yet
- On deletion of last referencing invitation item, group cleaned up (unless it was opened and became visible)
**Status tracking:**
- `groups.chat_item_id` points to the latest invitation item — updated on each new invitation
- `updateCIGroupInvitationStatus` / `getChatItemByGroupId` work because we have a local group record
- Only the latest invitation item is updated to `CIGISAccepted` on join; older items stay `CIGISPending`
### Sending flow: `APISharePublicGroup`
New command — `APIAddMember` stays for regular groups only.
```haskell
APISharePublicGroup :: GroupId -> ContactId -> ChatCommand
```
Only subscribers/owners of the public group can call it.
`sendPublicGroupInvitation`:
1. Load `GroupInfo`, verify `useRelays' gInfo` and user is a current member
2. Get `PublicGroupProfile {groupLink, publicGroupId}` from `groupProfile gInfo`
3. If user has `GroupKeys {memberPrivKey}` (is owner):
- Get `ratchetAdHash` via `getConnectionRatchetAdHash` for the contact's connection (same as `getConnectionCode` at Commands.hs:3340)
- Build invitation body (JSON-encoded `PublicGroupInvitation` without `groupOwner`)
- Sign `smpEncode CBDirect <> ratchetAdHash <> invitationBody` with `memberPrivKey`
- Build `GroupOwnerSig {memberId = membership.memberId, bindingData = ratchetAdHash, ownerSig}`
4. Build `PublicGroupInvitation {groupProfile, groupOwner, groupSize}`
5. Send via `sendDirectContactMessage` as `XGrpInvPub`
6. Create `CISndGroupInvitation` chat item (sender has the group, so `groupId` is available)
7. Return `CRSentPublicGroupInvitation user gInfo contact`
Duplicate invitations to the same contact are allowed — it's just a message.
### Receiving flow
**Handler in Subscriber.hs:**
```haskell
XGrpInvPub inv -> processPublicGroupInvitation ct inv msg msgMeta
```
`processPublicGroupInvitation`:
1. Parse `PublicGroupInvitation {groupProfile, groupOwner, groupSize}`
2. Verify `groupProfile.publicGroup` is present with `groupType == GTChannel`
3. Create invisible prepared group from profile data
4. Create `CIRcvGroupInvitation` chat item with `ownerSigStatus`:
- `groupOwner = Nothing``Nothing`
- `groupOwner = Just sig``Just OSSPending`
5. Fire `CEvtNewChatItems` event
6. If signed: `getAgentConnShortLinkAsync user CFGetPublicGroupLinkData ...` — continuation handles verification and updates (see "Async verification flow" above)
### UI rendering
`CIGroupInvitationView` (both platforms) checks `groupProfile.publicGroup` for group type and `ownerSigStatus` for owner verification:
**Icon:** Based on `groupProfile.publicGroup != nil` — antenna icon for public groups, group icon for regular. Same logic as `chatIconName` / `isChannel`.
**Invitation text** based on `ownerSigStatus`:
- `Nothing` → "You are invited to group/channel" (neutral)
- `Just OSSPending` → "You are invited to group/channel" with pending verification indicator
- `Just OSSVerified` → "Invited by channel owner" (text based on group type) with verification indicator
- `Just OSSFailed _` → warning indicator for failed owner claim
**Tap handler:** Branches on `groupProfile.publicGroup` (NOT `ownerSigStatus` — unsigned public group invitations also need the link-based flow):
- Regular group (`publicGroup == nil`): existing `joinGroup(groupId)``APIJoinGroup`
- Public group (`publicGroup != nil`): make invisible group visible, navigate to group, user taps Join → `APIConnectPreparedGroup`
## Directory bot changes
### Public group registration flow
The bot receives `CEvtNewChatItems` with `CIRcvGroupInvitation` that has `ownerSigStatus`. In `crDirectoryEvent_`:
- Check `ownerSigStatus == Just OSSVerified` — reject if not verified owner
- Check `groupProfile.publicGroup` is present (it's a public group)
- Extract group link from `groupProfile.publicGroup.groupLink`
- Check for duplicate groups (by `publicGroupId` or display name)
- Create `GroupReg` (no separate listing category — profile is the source of truth)
- Join public group as subscriber via the link
- Set status `GRSPendingApproval` (skip `GRSPendingUpdate`)
- Notify admin for approval
**Simplified status flow for public groups:**
- `GRSPendingConfirmation` → (if duplicate name) → confirm
- `GRSProposed` → bot joins group
- `GRSPendingApproval` → admin approves
- `GRSActive` → listed
- No `GRSPendingUpdate` step (no welcome message link check)
- No `GRSSuspendedBadRoles` (bot is subscriber, no role to check)
**Profile monitoring:** Bot as subscriber receives `XGrpInfo` instantly when owner updates. On profile change: compare received profile against what the link actually contains (re-resolve link). If they differ (possible modified client attack), do not approve. Periodic re-verification of link data for listed public groups.
**Commands not applicable to public groups:** `/filter`, `/role`, `/link` — bot is subscriber, cannot manage members.
### Search and listing
**Search results:** Include public groups in same query as regular groups. Mark public groups in result text based on `groupProfile.publicGroup` (group type).
**JSON listing:** No changes to `DirectoryEntryType``DETGroup` works for both. Consumers distinguish by checking `groupProfile.publicGroup` in the `DirectoryEntry`.
## Adversarial review: coding decisions
### 1. CIGroupInvitation JSON backward compatibility
`$(JQ.deriveJSON defaultJSON ''CIGroupInvitation)` at CIContent.hs:719 — TH-derived `defaultJSON` requires ALL fields present. Adding `ownerSigStatus` breaks parsing of old JSON without this field.
**Decision:** Use `omitNothingField` / `omittedField` pattern so `ownerSigStatus` defaults to `Nothing` when absent. Or use custom JSON instance. Same applies to `DBJSONCIContent` DB serialization.
Swift (`Decodable`): optional fields decode as `nil` when absent — naturally backward compatible.
Kotlin (`@Serializable`): add `val ownerSigStatus: OwnerSigStatus? = null` — default value handles missing field.
### 2. CIGISExpired is dead code
`CIGISExpired` is defined but never used anywhere. `CIGISRejected` is used when deleting/leaving a group (`Commands.hs:1210`). For public group invitations, only `CIGISPending` and `CIGISAccepted` are needed.
### 3. Invisible prepared group: createPreparedGroup vs createGroupInvitation
Two existing patterns:
- `createGroupInvitation` (Groups.hs:415): creates group + host member + user member, stores `inv_queue_info` (connRequest). Used for `XGrpInv`.
- `createPreparedGroup` (Groups.hs ~line 607): creates group with `PreparedGroup` metadata, user member with `GSMemUnknown`, optional host member. Used for `APIPrepareGroup`.
For invisible public group invitations, `createPreparedGroup` is closer — it creates a group from link data without a connRequest. But we need:
- A new flag/state to mark the group as invisible (not shown in chat list)
- No chat banner items on creation (those make it visible)
- The group link stored for later use when tapping
**Decision:** Chat list query filters on `creating_in_progress = 0` (Messages.hs:918). Prepared groups are NOT hidden — they appear as soon as `creating_in_progress = 0`. Options:
- Keep `creating_in_progress = 1` for invisible groups, flip to `0` on tap. Risk: other code may treat `creating_in_progress = 1` as "delete on failure" (see `deleteInProgressGroup`).
- Add new column (e.g. `hidden INTEGER NOT NULL DEFAULT 0`) to groups table, add `AND g.hidden = 0` to chat list query. Cleaner but requires migration.
- Use existing `PreparedGroup` state with `connLinkPreparedConnection = 0` and `connLinkStartedConnection = 0` and filter on these. But these are not currently in the chat list query.
Recommended: new `hidden` column — cleanest separation, no risk of conflating with `creating_in_progress` semantics.
### 4. Tap action: joinGroup vs connect flow
Current `joinGroup` calls `apiJoinGroup(groupId)``APIJoinGroup` (Commands.hs:2501), which uses stored `connRequest` from `inv_queue_info`. This does NOT work for public groups — there's no `connRequest`.
**Decision:** Tapping a public group invitation must:
1. Make the invisible group visible
2. Navigate to the group (now visible in chat list as prepared)
3. User sees the group preview and taps "Join" → `APIConnectPreparedGroup`
This is a different UI flow from regular group invitations (which join inline). The `CIGroupInvitationView` tap handler must branch based on `groupProfile.publicGroup` (public group vs regular group) — NOT on `ownerSigStatus`, since unsigned public group invitations also need the link-based flow.
### 5. Icon in CIGroupInvitationView
Currently hardcoded: iOS `"person.2.circle.fill"`, Kotlin `MR.images.ic_supervised_user_circle_filled`.
**Decision:** Check `groupProfile.publicGroup != nil` to select public group icon (antenna: `"antenna.radiowaves.left.and.right.circle.fill"` / equivalent Kotlin resource). Same logic as `chatIconName` in ChatTypes.swift.
### 6. Directory bot: event detection
Bot detects group invitations via `CEvtReceivedGroupInvitation` (Events.hs:78), NOT through `CEvtNewChatItems`. For public group invitations, there is no `CEvtReceivedGroupInvitation` — the chat item arrives through `CEvtNewChatItems`.
Currently `CEvtNewChatItems` in the bot only handles `CIRcvMsgContent (MCText t)` from direct chats (Events.hs:96). Detecting `CIRcvGroupInvitation` with `ownerSigStatus` requires a new pattern match case.
**Decision:** Add a new case in `crDirectoryEvent_` matching `CEvtNewChatItems` with `CIRcvGroupInvitation` content that has `ownerSigStatus /= Nothing`. Map to a new `DEPublicGroupSubmission` event.
Or: add a new `CEvt` event type (`CEvtReceivedPublicGroupInvitation`) emitted alongside `CEvtNewChatItems` in the subscriber, similar to how `CEvtReceivedGroupInvitation` is emitted for regular invitations.
### 7. ChatBinding extension
`ChatBinding` (Protocol.hs:321) currently has single constructor `CBGroup`. Encoding: `"G"` (single char). Adding `CBDirect` = `"D"`.
**Decision:** Add `CBDirect` constructor. Encoding follows single-char pattern. `ChatBinding` is NOT included in the protocol message (`GroupOwnerSig`) — it's determined from context by both sides. It IS used in the signed content for domain separation.
### 8. GroupOwnerSig JSON encoding
`GroupOwnerSig` has `ByteString` field (`bindingData`). Need JSON encoding for the protocol message.
**Decision:** `bindingData` uses base64url encoding (`B64UrlByteString` pattern). `C.Signature` already has JSON instances. `ChatBinding` is NOT in the JSON — determined from context.
### 9. CommandFunction for async link resolution
Add `CFGetPublicGroupLinkData` to `CommandFunction` enum (Types.hs:1871). Expected response: `LDATA_` (same as `CFGetRelayDataJoin`).
**Decision:** The `LDATA` handler in Subscriber.hs needs a new case matching `CFGetPublicGroupLinkData`. This handler needs access to the chat item ID and contact connection to update the item and verify the binding. The command's associated connection (`conn_` parameter in `getAgentConnShortLinkAsync`) provides this context.
### 10. getAgentConnShortLinkAsync connection parameter
`getAgentConnShortLinkAsync user cmdFunc conn_ shortLink` takes `Maybe Connection`. For relay data, `conn_` is `Just conn` (the relay member connection). For public group invitation verification, what connection do we pass?
**Decision:** Pass the contact's connection (`Just conn` from the direct message handler). The `LDATA` response arrives on this connection's subscriber handler. The `withCompletedCommand` pattern recovers the `CommandData` which can carry additional context (but currently only carries `cmdFunction`).
**Confirmed issue:** `LDATA` is ONLY handled inside `processGroupMessage` (Subscriber.hs:1124). There is NO `LDATA` handler in `processDirectMessage`. For public group invitation verification, the async response arrives on the contact's direct connection → `processDirectMessage`. Must add `LDATA` handling to `processDirectMessage` with `CFGetPublicGroupLinkData` case.
### 11. Sending: GroupOwnerSig construction
`sendPublicGroupInvitation` needs `ratchetAdHash`. `getConnectionRatchetAdHash` is accessed via `withAgent`. The contact's `Connection.connId` maps to the agent `ConnId`.
**Decision:** Extract `agentConnId` from contact's `activeConn`, call `withAgent (\a -> getConnectionRatchetAdHash a agentConnId)` to get the hash. This is the same pattern used in `getConnectionCode` (Commands.hs:3340).
### 12. Sending: chat item creation for CISndGroupInvitation
Sender creates `CISndGroupInvitation` with the group's own `groupId`. The `groupMemberId` refers to the user's membership. `ownerSigStatus` on the sent item: set to `Just OSSVerified` if owner (we know our own signature is valid), or `Nothing` if not owner.
### 13. updateCIGroupInvitationStatus for public groups
`updateCIGroupInvitationStatus` (Commands.hs:4077) finds the chat item via `getChatItemByGroupId` which JOINs `groups.chat_item_id`. This only finds ONE item. For multiple invitations to the same group, only the item linked via `chat_item_id` would be updated.
**Decision:** `setGroupInvitationChatItemId` sets the LATEST invitation's chat_item_id on the group. When the user joins, `updateCIGroupInvitationStatus` updates that one item. Older invitation items for the same group stay as `CIGISPending` — acceptable, they still show the correct profile and link. Alternatively, find all invitation items for this group and update them all, but this adds complexity for a minor UX issue.
### 14. Multiple invitations to same public group
If a user receives multiple invitations to the same public group (same `publicGroupId`), reuse the existing invisible group — just create a new chat item referencing the same `groupId`. Update `chat_item_id` on the group to point to the latest item.
**Decision:** Look up invisible group by `publicGroupId` (`public_group_id` column in `group_profiles` table). If found, reuse its `groupId` for the new chat item and update `chat_item_id`. If not found (or cleaned up), create new invisible group.
## Implementation plan
Conceptual — will be grounded into detailed code-level plan once types are approved.
### Step 1: Protocol types
- `PublicGroupInvitation`, `GroupOwnerSig`, `OwnerSigStatus` types with JSON derivation
- `XGrpInvPub` protocol message, tag `"x.grp.inv.pub"`
- Extend `ChatBinding` with `CBDirect` (`"D"`)
### Step 2: Chat item extension
- Add `ownerSigStatus :: Maybe OwnerSigStatus` to `CIGroupInvitation` (Haskell, Swift, Kotlin)
- Haskell: custom JSON handling or `omittedField` for backward compat (defaultJSON breaks)
- Swift/Kotlin: optional field with default nil/null — naturally backward compatible
### Step 3: Invisible prepared group creation
- New store function: create invisible prepared group from `GroupProfile` + link
- `hidden` flag on groups table (or use PreparedGroup state filtered from chat list)
- Lookup by `publicGroupId` to reuse existing invisible group
- Call `setGroupInvitationChatItemId` to link group ↔ chat item
- Cleanup manager for stale invisible groups
### Step 4: Sending — `APISharePublicGroup`
- New `APISharePublicGroup GroupId ContactId` command
- `sendPublicGroupInvitation`: build invitation, sign if owner (using `getConnectionRatchetAdHash` + `memberPrivKey`), send `XGrpInvPub`
- Create `CISndGroupInvitation` with `ownerSigStatus = Just OSSVerified` if owner, `Nothing` if subscriber
### Step 5: Receiving
- `XGrpInvPub` handler in `processDirectMessage`
- Create invisible group, create `CIRcvGroupInvitation` with `OSSPending` if signed
- `getAgentConnShortLinkAsync user CFGetPublicGroupLinkData (Just conn) groupLink`
- Add `CFGetPublicGroupLinkData` to `CommandFunction`
- Add `LDATA` handling in direct connection context (currently only in group member context)
### Step 6: Joining via invitation
- Tap on public group invitation: make group visible, navigate to group, user taps Join
- Different from regular invitation tap (which calls `apiJoinGroup` inline)
- `APIConnectPreparedGroup` flow, then update status to `CIGISAccepted`
### Step 7: UI (iOS + Kotlin)
- `CIGroupInvitationView`: check `ownerSigStatus` + `groupProfile.publicGroup` for rendering
- Icon: antenna for public groups, group icon for regular
- Tap handler: branch on public group vs regular group
- `OSSPending` state rendering
- `AddGroupMembersView`: `apiSharePublicGroup` for public groups
### Step 8: CLI view
### Step 9: Directory bot — public group registration
- New pattern match in `crDirectoryEvent_` for `CIRcvGroupInvitation` with `ownerSigStatus`
- Or new `CEvt` event emitted from subscriber
### Step 10: Directory bot — search and listing
- Mark public groups in search results based on `groupProfile.publicGroup`
### Step 11: Tests
+1 -1
View File
@@ -3781,7 +3781,7 @@ processChatCommand vr nm = \case
groupSize = Just currentMemCount
}
(msg, _) <- sendDirectContactMessage user ct $ XGrpInv groupInv
let content = CISndGroupInvitation (CIGroupInvitation {groupId, groupMemberId, localDisplayName, groupProfile, status = CIGISPending}) memRole
let content = CISndGroupInvitation (CIGroupInvitation {groupId, groupMemberId, localDisplayName, groupProfile, status = CIGISPending, ownerSigStatus = Nothing}) memRole
timed_ <- contactCITimed ct
ci <- saveSndChatItem' user (CDDirectSnd ct) msg content Nothing Nothing Nothing timed_ False
toView $ CEvtNewChatItems user [AChatItem SCTDirect SMDSnd (DirectChat ct) ci]
+1 -1
View File
@@ -2396,7 +2396,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage =
updateGroupMemberStatus db userId membership GSMemAccepted
toView $ CEvtUserAcceptedGroupSent user gInfo {membership = membership {memberStatus = GSMemAccepted}} (Just ct)
else do
let content = CIRcvGroupInvitation (CIGroupInvitation {groupId, groupMemberId, localDisplayName, groupProfile, status = CIGISPending}) memRole
let content = CIRcvGroupInvitation (CIGroupInvitation {groupId, groupMemberId, localDisplayName, groupProfile, status = CIGISPending, ownerSigStatus = Nothing}) memRole
(ci, cInfo) <- saveRcvChatItemNoParse user (CDDirectRcv ct) msg brokerTs content
withStore' $ \db -> setGroupInvitationChatItemId db user groupId (chatItemId' ci)
toView $ CEvtNewChatItems user [AChatItem SCTDirect SMDRcv cInfo ci]
+10 -1
View File
@@ -256,12 +256,19 @@ instance ToJSON DBMsgErrorType where
toJSON (DBME v) = $(JQ.mkToJSON (singleFieldJSON fstToLower) ''MsgErrorType) v
toEncoding (DBME v) = $(JQ.mkToEncoding (singleFieldJSON fstToLower) ''MsgErrorType) v
data OwnerSigStatus
= OSSPending
| OSSVerified
| OSSFailed {reason :: Text}
deriving (Eq, Show)
data CIGroupInvitation = CIGroupInvitation
{ groupId :: GroupId,
groupMemberId :: GroupMemberId,
localDisplayName :: GroupName,
groupProfile :: GroupProfile,
status :: CIGroupInvitationStatus
status :: CIGroupInvitationStatus,
ownerSigStatus :: Maybe OwnerSigStatus
}
deriving (Eq, Show)
@@ -716,6 +723,8 @@ $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "RME") ''RcvMsgError)
$(JQ.deriveJSON (enumJSON $ dropPrefix "CIGIS") ''CIGroupInvitationStatus)
$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "OSS") ''OwnerSigStatus)
$(JQ.deriveJSON defaultJSON ''CIGroupInvitation)
$(JQ.deriveJSON (enumJSON $ dropPrefix "CISCall") ''CICallStatus)
+5 -2
View File
@@ -318,7 +318,7 @@ data AChatMessage = forall e. MsgEncodingI e => ACMsg (SMsgEncoding e) (ChatMess
data KeyRef = KRMember
deriving (Eq, Show)
data ChatBinding = CBGroup
data ChatBinding = CBGroup | CBDirect
deriving (Eq, Show)
data MsgSignature = MsgSignature KeyRef C.ASignature
@@ -381,10 +381,13 @@ instance Encoding KeyRef where
c -> fail $ "invalid KeyRef tag: " <> show c
instance Encoding ChatBinding where
smpEncode CBGroup = "G"
smpEncode = \case
CBGroup -> "G"
CBDirect -> "D"
smpP =
A.anyChar >>= \case
'G' -> pure CBGroup
'D' -> pure CBDirect
c -> fail $ "invalid ChatBinding: " <> show c
instance ToField ChatBinding where toField = toField . decodeLatin1 . smpEncode
+31 -1
View File
@@ -29,7 +29,7 @@ module Simplex.Chat.Types where
import Control.Applicative ((<|>))
import Crypto.Number.Serialize (os2ip)
import Data.Aeson (FromJSON (..), ToJSON (..))
import Data.Aeson (FromJSON (..), ToJSON (..), (.:), (.=))
import qualified Data.Aeson as J
import qualified Data.Aeson.Encoding as JE
import qualified Data.Aeson.TH as JQ
@@ -877,6 +877,20 @@ data GroupRelayInvitation = GroupRelayInvitation
}
deriving (Eq, Show)
data PublicGroupInvitation = PublicGroupInvitation
{ groupProfile :: GroupProfile,
groupOwner :: Maybe GroupOwnerSig,
groupSize :: Maybe Int
}
deriving (Eq, Show)
data GroupOwnerSig = GroupOwnerSig
{ memberId :: MemberId,
bindingData :: ByteString,
ownerSig :: C.Signature 'C.Ed25519
}
deriving (Eq, Show)
data GroupRejectionReason
= GRRLongName
| GRRBlockedName
@@ -2103,6 +2117,22 @@ $(JQ.deriveJSON defaultJSON ''GroupLinkRejection)
$(JQ.deriveJSON defaultJSON ''GroupRelayInvitation)
instance ToJSON GroupOwnerSig where
toJSON GroupOwnerSig {memberId, bindingData, ownerSig} =
J.object ["memberId" .= memberId, "bindingData" .= strToJSON bindingData, "ownerSig" .= strToJSON (C.signatureBytes ownerSig)]
toEncoding GroupOwnerSig {memberId, bindingData, ownerSig} =
J.pairs ("memberId" .= memberId <> "bindingData" .= strToJSON bindingData <> "ownerSig" .= strToJSON (C.signatureBytes ownerSig))
instance FromJSON GroupOwnerSig where
parseJSON = J.withObject "GroupOwnerSig" $ \o -> do
memberId <- o .: "memberId"
bindingData <- strParseJSON "bindingData" =<< o .: "bindingData"
sigBytes <- strParseJSON "ownerSig" =<< o .: "ownerSig"
ownerSig <- either fail pure $ C.decodeSignature sigBytes
pure GroupOwnerSig {memberId, bindingData, ownerSig}
$(JQ.deriveJSON defaultJSON ''PublicGroupInvitation)
$(JQ.deriveJSON defaultJSON ''IntroInvitation)
$(JQ.deriveJSON defaultJSON ''MemberRestrictions)