833 lines
30 KiB
Rust
Raw Normal View History

feat: GroupV2 through the threaded client, group roster, registry retry (#167) * feat: expose GroupV2 through the threaded client GroupV2 (de-mls) conversations were reachable only from Core, and every conversation ran the hardcoded millisecond timer profile in group_v2.rs (20-150 ms freeze/consensus windows), which cannot survive real network latency. Make them reachable through ChatClient (as DirectV1 already is), with timing that holds up over a real network. - ChatClient::create_group_conversation(accounts) and add_group_members(convo_id, accounts): resolve each account address to its endorsed signer ids through the client-held directory, the same resolution create_direct_conversation uses, and drive Core::create_group_convo / group_add_member. - GroupV2 timing/policy is injectable: ServiceContext carries a de_mls::ConversationConfig (re-exported as GroupV2Config) defaulting to the de-mls library defaults; Core::set_group_v2_config and the builder's group_v2_config setter override it. The creator's phase durations reach joiners inside the welcome's ConversationSync, so the group runs the creator's phase timing. - GroupV2Convo::add_member validates every member's key package before proposing any add, skips members de-mls would silently not propose (self, already in the group) instead of stranding a pending invite, and flushes opened proposals even on a mid-batch failure, so a failed batch cannot invite members behind the caller's back. - The millisecond test profile moves into the test harnesses (integration_tests_core's TestHarness, crates/client/tests/group_v2.rs). - New client-level tests: three accounts on the in-process transport create a group, a non-creator adds the third member, and messages fan out with directory-verified senders; a batch containing a member with no key package fails without inviting anyone. * fix: class inbound DirectV1 joins as Private, not Group The joiner of a DirectV1 (pairwise) conversation received it classed as Group, because dispatch_to_inbox2 hardcoded ConversationClass::Group for every InboxV2 join. DirectV1 welcomes (InviteType::GroupV1) and GroupV2 welcomes (InviteType::GroupV2) both arrive over InboxV2, so a plain 1:1 invite surfaced to the display layer as a group. ConversationClass is documented as stable across protocol versions of the same conversation shape, and DirectV1 is the pairwise shape, so its joiner must see Private. - InboxV2::handle_frame returns the class alongside the convo: InviteType::GroupV1 (the DirectV1 welcome carrier) yields Private, InviteType::GroupV2 yields Group. - dispatch_to_inbox2 propagates that class instead of hardcoding Group. - direct_v1_by_account_address asserts the joiner sees Private. * feat: expose a group's roster, deduped to one entry per account The display layer needs a group's membership, but nothing exposed it: de-mls holds the authoritative roster (MLS group state) with no public accessor, and members added by other members stay invisible until they send a message. Rebuilding the roster from observed messages would fork state the crypto layer owns and be wrong exactly when a group grows. - GroupConvo::members() returns each member's hex-encoded MLS leaf-credential content, self included. GroupV2Convo delegates to de-mls and guarantees self-inclusion; GroupV1Convo reads its openmls leaves. - Core::group_members(convo_id) mirrors group_add_member's dispatch: a cached group yields its members, a direct conversation is an UnsupportedFunction, otherwise the group is loaded. - ChatClient::group_members returns Vec<GroupMember>, resolving each member's account claim through the directory. A member whose account claim is unconfirmable is listed by device with account None rather than dropped: it is cryptographically in the group, only the account claim is unproven. The credential parsing decode_sender did is factored into parse_credential and shared by both, leaving decode_sender's stricter drop semantics for message senders unchanged. - Because resolve_device_ids fans an account out to every endorsed device, an account whose devices all join surfaced once per device; group_members dedups by account, keeping the first-seen device as the account's representative. Members with no confirmed account stay individual, keyed by their unique device key. - Unit tests cover the tolerant-vs-drop split and the per-account dedup; the three-member group integration test asserts the roster converges after create and after each add, and a solo group lists only its creator. * feat: retry the registry on transient 5xx with backoff and jitter The keypackage/account registry is reliable request-by-request but sheds concurrent bursts with a 5xx, so several instances registering at once each hard-failed on init. HttpRegistry's four calls now retry network errors and 5xx/429 with exponential backoff and full jitter (the jitter decorrelates concurrent publishers so their retries don't re-collide); 4xx and success return immediately. The total retry window is bounded to a few seconds. * fix: mark InboxV2 key package last-resort so members can join multiple groups A key package's init key is one-time-use: openmls deletes it after the first welcome that consumes it. Each installation registers a single key package, so a second group inviting the same member found no matching key package and rejected the welcome with "welcome not addressed to this member", the flaky group add. Mark the InboxV2 key package as last-resort (and advertise the extension in the leaf capabilities, which key-package validation requires) so openmls retains the init key, letting one key package admit an installation to any number of groups. This reuses one init key for every join, trading per-join forward secrecy for membership that just works. A TODO at the publish site tracks the intended one-time key-package pool (the registry pops one per fetch, the client replenishes) with last-resort as the exhaustion fallback (#169). Add regression tests: a member joining two groups (core harness) and two peers invited to several groups over the threaded client. * fix: dedup list_conversations across the store and the in-memory cache A DirectV1 join persists its conversation to the store and also caches it in memory, so list_conversations saw it twice. It deduped with Vec::dedup, which only drops consecutive repeats, over cached_convos' nondeterministic HashMap order, so the duplicate survived whenever another cached conversation fell between the two copies. list_conversations then intermittently returned a conversation twice, and a consumer counting conversations (e.g. checking that a peer joined a group while a direct chat already existed) saw a flaky count. Dedup through a set so a conversation held in both stores is listed once regardless of iteration order. Add a DirectV1-then-GroupV2 regression test, which also covers key-package reuse across conversation types. * fix: dedup the GroupV2 add batch to avoid redundant fetches and duplicate invites Both create_group_convo_v2 and group_add_member funnel through GroupV2Convo::add_member, so a duplicate signer (an account that resolves to the same signer twice, or a repeated account) cost a redundant key-package fetch and a second Add proposal. The existing guard skipped only self and already-committed members, which a within-batch duplicate escapes because add_member opens a proposal the committed roster does not yet reflect, stranding a pending_invite that can later fire a spurious duplicate welcome. Dedup the requested signers before fetching, and guard the add loop with a membership set seeded from the roster and self, hoisting the per-iteration members() call out of the loop. * docs: correct the retry-budget and group-add doc comments The retry-budget comment claimed the ~20s init IPC budget held even at the worst-case sum, but that only holds on the load-shed path where each retry returns fast; a fully unreachable registry costs up to MAX_RETRIES times the reqwest timeout, which no retry budget can rescue. State both. Reword add_group_members to name the proposal, commit, and welcome flow rather than the unexplained "once the add commits". * chore: allow clippy::question_mark in LocalBroadcaster::poll (Rust 1.97 FP) Stable rolled to 1.97, whose clippy question_mark flags poll()'s match on `self.shared.borrow().read(next)`. Its suggested `read(next)?` would drop the RefCell Ref guard and dangle the returned reference, so the lint is a false positive here. CI tracks floating stable (`rustup update stable`), so this is pre-existing code newly flagged; suppress it to keep the branch green.
2026-07-09 20:02:04 +02:00
use std::collections::HashSet;
feat: introduce client event system (#106) * chore(flake): accept extra system attr; add perl for openssl-sys build forAllSystems calls the lambda with {system, pkgs}; strict destructuring requires `..` to ignore the system attribute. `pkgs.perl` is needed because openssl-sys is pulled vendored via libsqlite3-sys / rusqlite / chat-sqlite, and its `perl Configure` step needs FindBin.pm, which Fedora's system perl doesn't ship. * feat: introduce client event system - Core processing yields a `PayloadOutcome` enum — `Empty`, `Convo`, or `Inbox`. `ConvoOutcome` carries a conversation id and an optional decrypted `Content`; `InboxOutcome` adds a `NewConversation` (id + `ConversationClass`) for a peer-initiated conversation. - Client translates `PayloadOutcome` into app-facing `Vec<Event>` (`ConversationStarted`, `MessageReceived`) at the boundary, so the application loop sees discrete events rather than core types. - MLS group welcomes produce a `ConversationStarted` event with no initial content, fixing the silent-group-join case where the inbox layer dropped the observation. - C FFI exposes an `EventList` opaque type with indexed accessors and an `Invalid` sentinel for out-of-bounds / non-applicable reads. - Symmetric `Inbox` / `InboxV2` handlers: both return `Result<InboxOutcome, _>` and own the persistence + ephemeral-key cleanup for the conversations they create. - Updated and simplified `docs/adr/0001-client-event-system.md`. * chore(flake): bump nixpkgs to nixos-unstable-small Temporary. The two crates.io UA fixes (NixOS/nixpkgs#512735 for fetchCargoVendor's python-requests UA, NixOS/nixpkgs#524985 for importCargoLock's curl UA) haven't propagated to nixos-unstable yet. Switch to nixos-unstable-small and force logos-delivery to follow so the smoketest gets the same fix. Revert once nixos-unstable catches up. Refs: - https://github.com/rust-lang/crates.io/issues/13482 - https://github.com/rust-lang/crates.io/issues/13783 - https://crates.io/data-access
2026-05-28 23:51:15 +02:00
use std::sync::Arc;
use std::thread::{self, JoinHandle};
feat: introduce client event system (#106) * chore(flake): accept extra system attr; add perl for openssl-sys build forAllSystems calls the lambda with {system, pkgs}; strict destructuring requires `..` to ignore the system attribute. `pkgs.perl` is needed because openssl-sys is pulled vendored via libsqlite3-sys / rusqlite / chat-sqlite, and its `perl Configure` step needs FindBin.pm, which Fedora's system perl doesn't ship. * feat: introduce client event system - Core processing yields a `PayloadOutcome` enum — `Empty`, `Convo`, or `Inbox`. `ConvoOutcome` carries a conversation id and an optional decrypted `Content`; `InboxOutcome` adds a `NewConversation` (id + `ConversationClass`) for a peer-initiated conversation. - Client translates `PayloadOutcome` into app-facing `Vec<Event>` (`ConversationStarted`, `MessageReceived`) at the boundary, so the application loop sees discrete events rather than core types. - MLS group welcomes produce a `ConversationStarted` event with no initial content, fixing the silent-group-join case where the inbox layer dropped the observation. - C FFI exposes an `EventList` opaque type with indexed accessors and an `Invalid` sentinel for out-of-bounds / non-applicable reads. - Symmetric `Inbox` / `InboxV2` handlers: both return `Result<InboxOutcome, _>` and own the persistence + ephemeral-key cleanup for the conversations they create. - Updated and simplified `docs/adr/0001-client-event-system.md`. * chore(flake): bump nixpkgs to nixos-unstable-small Temporary. The two crates.io UA fixes (NixOS/nixpkgs#512735 for fetchCargoVendor's python-requests UA, NixOS/nixpkgs#524985 for importCargoLock's curl UA) haven't propagated to nixos-unstable yet. Switch to nixos-unstable-small and force logos-delivery to follow so the smoketest gets the same fix. Revert once nixos-unstable catches up. Refs: - https://github.com/rust-lang/crates.io/issues/13482 - https://github.com/rust-lang/crates.io/issues/13783 - https://crates.io/data-access
2026-05-28 23:51:15 +02:00
use components::{ThreadedWakeupService, WakeupEvent};
use crossbeam_channel::{Receiver, Sender, select};
use crypto::Ed25519VerifyingKey;
use libchat::{
feat: GroupV2 through the threaded client, group roster, registry retry (#167) * feat: expose GroupV2 through the threaded client GroupV2 (de-mls) conversations were reachable only from Core, and every conversation ran the hardcoded millisecond timer profile in group_v2.rs (20-150 ms freeze/consensus windows), which cannot survive real network latency. Make them reachable through ChatClient (as DirectV1 already is), with timing that holds up over a real network. - ChatClient::create_group_conversation(accounts) and add_group_members(convo_id, accounts): resolve each account address to its endorsed signer ids through the client-held directory, the same resolution create_direct_conversation uses, and drive Core::create_group_convo / group_add_member. - GroupV2 timing/policy is injectable: ServiceContext carries a de_mls::ConversationConfig (re-exported as GroupV2Config) defaulting to the de-mls library defaults; Core::set_group_v2_config and the builder's group_v2_config setter override it. The creator's phase durations reach joiners inside the welcome's ConversationSync, so the group runs the creator's phase timing. - GroupV2Convo::add_member validates every member's key package before proposing any add, skips members de-mls would silently not propose (self, already in the group) instead of stranding a pending invite, and flushes opened proposals even on a mid-batch failure, so a failed batch cannot invite members behind the caller's back. - The millisecond test profile moves into the test harnesses (integration_tests_core's TestHarness, crates/client/tests/group_v2.rs). - New client-level tests: three accounts on the in-process transport create a group, a non-creator adds the third member, and messages fan out with directory-verified senders; a batch containing a member with no key package fails without inviting anyone. * fix: class inbound DirectV1 joins as Private, not Group The joiner of a DirectV1 (pairwise) conversation received it classed as Group, because dispatch_to_inbox2 hardcoded ConversationClass::Group for every InboxV2 join. DirectV1 welcomes (InviteType::GroupV1) and GroupV2 welcomes (InviteType::GroupV2) both arrive over InboxV2, so a plain 1:1 invite surfaced to the display layer as a group. ConversationClass is documented as stable across protocol versions of the same conversation shape, and DirectV1 is the pairwise shape, so its joiner must see Private. - InboxV2::handle_frame returns the class alongside the convo: InviteType::GroupV1 (the DirectV1 welcome carrier) yields Private, InviteType::GroupV2 yields Group. - dispatch_to_inbox2 propagates that class instead of hardcoding Group. - direct_v1_by_account_address asserts the joiner sees Private. * feat: expose a group's roster, deduped to one entry per account The display layer needs a group's membership, but nothing exposed it: de-mls holds the authoritative roster (MLS group state) with no public accessor, and members added by other members stay invisible until they send a message. Rebuilding the roster from observed messages would fork state the crypto layer owns and be wrong exactly when a group grows. - GroupConvo::members() returns each member's hex-encoded MLS leaf-credential content, self included. GroupV2Convo delegates to de-mls and guarantees self-inclusion; GroupV1Convo reads its openmls leaves. - Core::group_members(convo_id) mirrors group_add_member's dispatch: a cached group yields its members, a direct conversation is an UnsupportedFunction, otherwise the group is loaded. - ChatClient::group_members returns Vec<GroupMember>, resolving each member's account claim through the directory. A member whose account claim is unconfirmable is listed by device with account None rather than dropped: it is cryptographically in the group, only the account claim is unproven. The credential parsing decode_sender did is factored into parse_credential and shared by both, leaving decode_sender's stricter drop semantics for message senders unchanged. - Because resolve_device_ids fans an account out to every endorsed device, an account whose devices all join surfaced once per device; group_members dedups by account, keeping the first-seen device as the account's representative. Members with no confirmed account stay individual, keyed by their unique device key. - Unit tests cover the tolerant-vs-drop split and the per-account dedup; the three-member group integration test asserts the roster converges after create and after each add, and a solo group lists only its creator. * feat: retry the registry on transient 5xx with backoff and jitter The keypackage/account registry is reliable request-by-request but sheds concurrent bursts with a 5xx, so several instances registering at once each hard-failed on init. HttpRegistry's four calls now retry network errors and 5xx/429 with exponential backoff and full jitter (the jitter decorrelates concurrent publishers so their retries don't re-collide); 4xx and success return immediately. The total retry window is bounded to a few seconds. * fix: mark InboxV2 key package last-resort so members can join multiple groups A key package's init key is one-time-use: openmls deletes it after the first welcome that consumes it. Each installation registers a single key package, so a second group inviting the same member found no matching key package and rejected the welcome with "welcome not addressed to this member", the flaky group add. Mark the InboxV2 key package as last-resort (and advertise the extension in the leaf capabilities, which key-package validation requires) so openmls retains the init key, letting one key package admit an installation to any number of groups. This reuses one init key for every join, trading per-join forward secrecy for membership that just works. A TODO at the publish site tracks the intended one-time key-package pool (the registry pops one per fetch, the client replenishes) with last-resort as the exhaustion fallback (#169). Add regression tests: a member joining two groups (core harness) and two peers invited to several groups over the threaded client. * fix: dedup list_conversations across the store and the in-memory cache A DirectV1 join persists its conversation to the store and also caches it in memory, so list_conversations saw it twice. It deduped with Vec::dedup, which only drops consecutive repeats, over cached_convos' nondeterministic HashMap order, so the duplicate survived whenever another cached conversation fell between the two copies. list_conversations then intermittently returned a conversation twice, and a consumer counting conversations (e.g. checking that a peer joined a group while a direct chat already existed) saw a flaky count. Dedup through a set so a conversation held in both stores is listed once regardless of iteration order. Add a DirectV1-then-GroupV2 regression test, which also covers key-package reuse across conversation types. * fix: dedup the GroupV2 add batch to avoid redundant fetches and duplicate invites Both create_group_convo_v2 and group_add_member funnel through GroupV2Convo::add_member, so a duplicate signer (an account that resolves to the same signer twice, or a repeated account) cost a redundant key-package fetch and a second Add proposal. The existing guard skipped only self and already-committed members, which a within-batch duplicate escapes because add_member opens a proposal the committed roster does not yet reflect, stranding a pending_invite that can later fire a spurious duplicate welcome. Dedup the requested signers before fetching, and guard the add loop with a membership set seeded from the roster and self, hoisting the per-iteration members() call out of the loop. * docs: correct the retry-budget and group-add doc comments The retry-budget comment claimed the ~20s init IPC budget held even at the worst-case sum, but that only holds on the load-shed path where each retry returns fast; a fully unreachable registry costs up to MAX_RETRIES times the reqwest timeout, which no retry budget can rescue. State both. Reword add_group_members to name the proposal, commit, and welcome flow rather than the unexplained "once the add commits". * chore: allow clippy::question_mark in LocalBroadcaster::poll (Rust 1.97 FP) Stable rolled to 1.97, whose clippy question_mark flags poll()'s match on `self.shared.borrow().read(next)`. Its suggested `read(next)?` would drop the RefCell Ref guard and dangle the returned reference, so the lint is a false positive here. CI tracks floating stable (`rustup update stable`), so this is pre-existing code newly flagged; suppress it to keep the branch green.
2026-07-09 20:02:04 +02:00
ConversationId, ConvoOutcome, Core, DeliveryService, GroupV2Config, IdentId, IdentIdRef,
InboxOutcome, PayloadOutcome, RegistrationService,
};
use logos_account::{AccountDirectory, resolve_device_ids};
use parking_lot::Mutex;
use storage::ChatStore;
use crate::delegate::{DelegateCredential, DelegateIdentity, DelegateSigner};
use crate::errors::ClientError;
use crate::event::{Event, MessageSender};
type ClientCore<T, R, S> = Core<(DelegateIdentity, T, R, ThreadedWakeupService, S)>;
type AccountAddressRef<'a> = &'a str;
type LocalSignerId = IdentId;
feat: GroupV2 through the threaded client, group roster, registry retry (#167) * feat: expose GroupV2 through the threaded client GroupV2 (de-mls) conversations were reachable only from Core, and every conversation ran the hardcoded millisecond timer profile in group_v2.rs (20-150 ms freeze/consensus windows), which cannot survive real network latency. Make them reachable through ChatClient (as DirectV1 already is), with timing that holds up over a real network. - ChatClient::create_group_conversation(accounts) and add_group_members(convo_id, accounts): resolve each account address to its endorsed signer ids through the client-held directory, the same resolution create_direct_conversation uses, and drive Core::create_group_convo / group_add_member. - GroupV2 timing/policy is injectable: ServiceContext carries a de_mls::ConversationConfig (re-exported as GroupV2Config) defaulting to the de-mls library defaults; Core::set_group_v2_config and the builder's group_v2_config setter override it. The creator's phase durations reach joiners inside the welcome's ConversationSync, so the group runs the creator's phase timing. - GroupV2Convo::add_member validates every member's key package before proposing any add, skips members de-mls would silently not propose (self, already in the group) instead of stranding a pending invite, and flushes opened proposals even on a mid-batch failure, so a failed batch cannot invite members behind the caller's back. - The millisecond test profile moves into the test harnesses (integration_tests_core's TestHarness, crates/client/tests/group_v2.rs). - New client-level tests: three accounts on the in-process transport create a group, a non-creator adds the third member, and messages fan out with directory-verified senders; a batch containing a member with no key package fails without inviting anyone. * fix: class inbound DirectV1 joins as Private, not Group The joiner of a DirectV1 (pairwise) conversation received it classed as Group, because dispatch_to_inbox2 hardcoded ConversationClass::Group for every InboxV2 join. DirectV1 welcomes (InviteType::GroupV1) and GroupV2 welcomes (InviteType::GroupV2) both arrive over InboxV2, so a plain 1:1 invite surfaced to the display layer as a group. ConversationClass is documented as stable across protocol versions of the same conversation shape, and DirectV1 is the pairwise shape, so its joiner must see Private. - InboxV2::handle_frame returns the class alongside the convo: InviteType::GroupV1 (the DirectV1 welcome carrier) yields Private, InviteType::GroupV2 yields Group. - dispatch_to_inbox2 propagates that class instead of hardcoding Group. - direct_v1_by_account_address asserts the joiner sees Private. * feat: expose a group's roster, deduped to one entry per account The display layer needs a group's membership, but nothing exposed it: de-mls holds the authoritative roster (MLS group state) with no public accessor, and members added by other members stay invisible until they send a message. Rebuilding the roster from observed messages would fork state the crypto layer owns and be wrong exactly when a group grows. - GroupConvo::members() returns each member's hex-encoded MLS leaf-credential content, self included. GroupV2Convo delegates to de-mls and guarantees self-inclusion; GroupV1Convo reads its openmls leaves. - Core::group_members(convo_id) mirrors group_add_member's dispatch: a cached group yields its members, a direct conversation is an UnsupportedFunction, otherwise the group is loaded. - ChatClient::group_members returns Vec<GroupMember>, resolving each member's account claim through the directory. A member whose account claim is unconfirmable is listed by device with account None rather than dropped: it is cryptographically in the group, only the account claim is unproven. The credential parsing decode_sender did is factored into parse_credential and shared by both, leaving decode_sender's stricter drop semantics for message senders unchanged. - Because resolve_device_ids fans an account out to every endorsed device, an account whose devices all join surfaced once per device; group_members dedups by account, keeping the first-seen device as the account's representative. Members with no confirmed account stay individual, keyed by their unique device key. - Unit tests cover the tolerant-vs-drop split and the per-account dedup; the three-member group integration test asserts the roster converges after create and after each add, and a solo group lists only its creator. * feat: retry the registry on transient 5xx with backoff and jitter The keypackage/account registry is reliable request-by-request but sheds concurrent bursts with a 5xx, so several instances registering at once each hard-failed on init. HttpRegistry's four calls now retry network errors and 5xx/429 with exponential backoff and full jitter (the jitter decorrelates concurrent publishers so their retries don't re-collide); 4xx and success return immediately. The total retry window is bounded to a few seconds. * fix: mark InboxV2 key package last-resort so members can join multiple groups A key package's init key is one-time-use: openmls deletes it after the first welcome that consumes it. Each installation registers a single key package, so a second group inviting the same member found no matching key package and rejected the welcome with "welcome not addressed to this member", the flaky group add. Mark the InboxV2 key package as last-resort (and advertise the extension in the leaf capabilities, which key-package validation requires) so openmls retains the init key, letting one key package admit an installation to any number of groups. This reuses one init key for every join, trading per-join forward secrecy for membership that just works. A TODO at the publish site tracks the intended one-time key-package pool (the registry pops one per fetch, the client replenishes) with last-resort as the exhaustion fallback (#169). Add regression tests: a member joining two groups (core harness) and two peers invited to several groups over the threaded client. * fix: dedup list_conversations across the store and the in-memory cache A DirectV1 join persists its conversation to the store and also caches it in memory, so list_conversations saw it twice. It deduped with Vec::dedup, which only drops consecutive repeats, over cached_convos' nondeterministic HashMap order, so the duplicate survived whenever another cached conversation fell between the two copies. list_conversations then intermittently returned a conversation twice, and a consumer counting conversations (e.g. checking that a peer joined a group while a direct chat already existed) saw a flaky count. Dedup through a set so a conversation held in both stores is listed once regardless of iteration order. Add a DirectV1-then-GroupV2 regression test, which also covers key-package reuse across conversation types. * fix: dedup the GroupV2 add batch to avoid redundant fetches and duplicate invites Both create_group_convo_v2 and group_add_member funnel through GroupV2Convo::add_member, so a duplicate signer (an account that resolves to the same signer twice, or a repeated account) cost a redundant key-package fetch and a second Add proposal. The existing guard skipped only self and already-committed members, which a within-batch duplicate escapes because add_member opens a proposal the committed roster does not yet reflect, stranding a pending_invite that can later fire a spurious duplicate welcome. Dedup the requested signers before fetching, and guard the add loop with a membership set seeded from the roster and self, hoisting the per-iteration members() call out of the loop. * docs: correct the retry-budget and group-add doc comments The retry-budget comment claimed the ~20s init IPC budget held even at the worst-case sum, but that only holds on the load-shed path where each retry returns fast; a fully unreachable registry costs up to MAX_RETRIES times the reqwest timeout, which no retry budget can rescue. State both. Reword add_group_members to name the proposal, commit, and welcome flow rather than the unexplained "once the add commits". * chore: allow clippy::question_mark in LocalBroadcaster::poll (Rust 1.97 FP) Stable rolled to 1.97, whose clippy question_mark flags poll()'s match on `self.shared.borrow().read(next)`. Its suggested `read(next)?` would drop the RefCell Ref guard and dangle the returned reference, so the lint is a false positive here. CI tracks floating stable (`rustup update stable`), so this is pre-existing code newly flagged; suppress it to keep the branch green.
2026-07-09 20:02:04 +02:00
/// A member of a group conversation's roster.
///
/// Shares [`MessageSender`]'s field semantics: `account` is set only when the
/// member's credential claimed an account *and* the directory confirmed this
/// device belongs to it. Unlike a message sender, an unconfirmable claim does
/// not hide the member: it is cryptographically in the group, so it is listed
/// by `local_identity` (its device) with `account: None`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GroupMember {
pub account: Option<IdentId>,
pub local_identity: IdentId,
}
/// The transport as the client sees it: a [`DeliveryService`] for outbound
/// publishing plus the inbound payload stream the worker drains. One object owns
/// both directions of the boundary.
pub trait Transport: DeliveryService + Send + 'static {
/// Hand over the inbound payload stream. Called once, at client construction,
/// before the [`Core`] takes ownership of the service.
fn inbound(&mut self) -> Receiver<Vec<u8>>;
}
/// High-level chat client.
///
/// Owns the synchronous [`Core`] behind an `Arc<Mutex<…>>` and a background
/// worker that consumes inbound payloads off the transport's channel, drives
/// the core, and forwards observations as [`Event`]s. Construction returns the
/// handle together with the `Receiver<Event>` the application drains on its own
/// schedule.
///
/// Outbound calls (`send_message`, `create_conversation`, …) run on the
/// caller's thread: they briefly lock the core, invoke it, and return — no
/// message-passing round-trip. The `Arc`/`Mutex`/threads live entirely here;
/// the core never mentions threads.
pub struct ChatClient<T, R, S>
where
T: Transport + Send + 'static,
R: RegistrationService + AccountDirectory + Clone + Send + 'static,
S: ChatStore + Send + 'static,
{
/// `parking_lot::Mutex` for its eventual fairness: an inbound burst can't
/// starve caller operations of the lock.
core: Arc<Mutex<ClientCore<T, R, S>>>,
/// The account → device directory. On testnet the registration service
/// doubles as the directory (one deployed registry serves both roles), so
/// the client keeps its own clone of `R`; the core sees key packages only.
directory: R,
/// Dropped on `Drop` to wake the worker's `select!` and shut it down.
shutdown: Option<Sender<()>>,
worker: Option<JoinHandle<()>>,
address: String,
}
// -- GenericChatClient
impl<T, R, S> ChatClient<T, R, S>
where
T: Transport + Send + 'static,
R: RegistrationService + AccountDirectory + Clone + Send + 'static,
S: ChatStore + Send + 'static,
{
pub fn new(
ident: DelegateSigner,
account: String,
mut transport: T,
reg: R,
storage: S,
feat: GroupV2 through the threaded client, group roster, registry retry (#167) * feat: expose GroupV2 through the threaded client GroupV2 (de-mls) conversations were reachable only from Core, and every conversation ran the hardcoded millisecond timer profile in group_v2.rs (20-150 ms freeze/consensus windows), which cannot survive real network latency. Make them reachable through ChatClient (as DirectV1 already is), with timing that holds up over a real network. - ChatClient::create_group_conversation(accounts) and add_group_members(convo_id, accounts): resolve each account address to its endorsed signer ids through the client-held directory, the same resolution create_direct_conversation uses, and drive Core::create_group_convo / group_add_member. - GroupV2 timing/policy is injectable: ServiceContext carries a de_mls::ConversationConfig (re-exported as GroupV2Config) defaulting to the de-mls library defaults; Core::set_group_v2_config and the builder's group_v2_config setter override it. The creator's phase durations reach joiners inside the welcome's ConversationSync, so the group runs the creator's phase timing. - GroupV2Convo::add_member validates every member's key package before proposing any add, skips members de-mls would silently not propose (self, already in the group) instead of stranding a pending invite, and flushes opened proposals even on a mid-batch failure, so a failed batch cannot invite members behind the caller's back. - The millisecond test profile moves into the test harnesses (integration_tests_core's TestHarness, crates/client/tests/group_v2.rs). - New client-level tests: three accounts on the in-process transport create a group, a non-creator adds the third member, and messages fan out with directory-verified senders; a batch containing a member with no key package fails without inviting anyone. * fix: class inbound DirectV1 joins as Private, not Group The joiner of a DirectV1 (pairwise) conversation received it classed as Group, because dispatch_to_inbox2 hardcoded ConversationClass::Group for every InboxV2 join. DirectV1 welcomes (InviteType::GroupV1) and GroupV2 welcomes (InviteType::GroupV2) both arrive over InboxV2, so a plain 1:1 invite surfaced to the display layer as a group. ConversationClass is documented as stable across protocol versions of the same conversation shape, and DirectV1 is the pairwise shape, so its joiner must see Private. - InboxV2::handle_frame returns the class alongside the convo: InviteType::GroupV1 (the DirectV1 welcome carrier) yields Private, InviteType::GroupV2 yields Group. - dispatch_to_inbox2 propagates that class instead of hardcoding Group. - direct_v1_by_account_address asserts the joiner sees Private. * feat: expose a group's roster, deduped to one entry per account The display layer needs a group's membership, but nothing exposed it: de-mls holds the authoritative roster (MLS group state) with no public accessor, and members added by other members stay invisible until they send a message. Rebuilding the roster from observed messages would fork state the crypto layer owns and be wrong exactly when a group grows. - GroupConvo::members() returns each member's hex-encoded MLS leaf-credential content, self included. GroupV2Convo delegates to de-mls and guarantees self-inclusion; GroupV1Convo reads its openmls leaves. - Core::group_members(convo_id) mirrors group_add_member's dispatch: a cached group yields its members, a direct conversation is an UnsupportedFunction, otherwise the group is loaded. - ChatClient::group_members returns Vec<GroupMember>, resolving each member's account claim through the directory. A member whose account claim is unconfirmable is listed by device with account None rather than dropped: it is cryptographically in the group, only the account claim is unproven. The credential parsing decode_sender did is factored into parse_credential and shared by both, leaving decode_sender's stricter drop semantics for message senders unchanged. - Because resolve_device_ids fans an account out to every endorsed device, an account whose devices all join surfaced once per device; group_members dedups by account, keeping the first-seen device as the account's representative. Members with no confirmed account stay individual, keyed by their unique device key. - Unit tests cover the tolerant-vs-drop split and the per-account dedup; the three-member group integration test asserts the roster converges after create and after each add, and a solo group lists only its creator. * feat: retry the registry on transient 5xx with backoff and jitter The keypackage/account registry is reliable request-by-request but sheds concurrent bursts with a 5xx, so several instances registering at once each hard-failed on init. HttpRegistry's four calls now retry network errors and 5xx/429 with exponential backoff and full jitter (the jitter decorrelates concurrent publishers so their retries don't re-collide); 4xx and success return immediately. The total retry window is bounded to a few seconds. * fix: mark InboxV2 key package last-resort so members can join multiple groups A key package's init key is one-time-use: openmls deletes it after the first welcome that consumes it. Each installation registers a single key package, so a second group inviting the same member found no matching key package and rejected the welcome with "welcome not addressed to this member", the flaky group add. Mark the InboxV2 key package as last-resort (and advertise the extension in the leaf capabilities, which key-package validation requires) so openmls retains the init key, letting one key package admit an installation to any number of groups. This reuses one init key for every join, trading per-join forward secrecy for membership that just works. A TODO at the publish site tracks the intended one-time key-package pool (the registry pops one per fetch, the client replenishes) with last-resort as the exhaustion fallback (#169). Add regression tests: a member joining two groups (core harness) and two peers invited to several groups over the threaded client. * fix: dedup list_conversations across the store and the in-memory cache A DirectV1 join persists its conversation to the store and also caches it in memory, so list_conversations saw it twice. It deduped with Vec::dedup, which only drops consecutive repeats, over cached_convos' nondeterministic HashMap order, so the duplicate survived whenever another cached conversation fell between the two copies. list_conversations then intermittently returned a conversation twice, and a consumer counting conversations (e.g. checking that a peer joined a group while a direct chat already existed) saw a flaky count. Dedup through a set so a conversation held in both stores is listed once regardless of iteration order. Add a DirectV1-then-GroupV2 regression test, which also covers key-package reuse across conversation types. * fix: dedup the GroupV2 add batch to avoid redundant fetches and duplicate invites Both create_group_convo_v2 and group_add_member funnel through GroupV2Convo::add_member, so a duplicate signer (an account that resolves to the same signer twice, or a repeated account) cost a redundant key-package fetch and a second Add proposal. The existing guard skipped only self and already-committed members, which a within-batch duplicate escapes because add_member opens a proposal the committed roster does not yet reflect, stranding a pending_invite that can later fire a spurious duplicate welcome. Dedup the requested signers before fetching, and guard the add loop with a membership set seeded from the roster and self, hoisting the per-iteration members() call out of the loop. * docs: correct the retry-budget and group-add doc comments The retry-budget comment claimed the ~20s init IPC budget held even at the worst-case sum, but that only holds on the load-shed path where each retry returns fast; a fully unreachable registry costs up to MAX_RETRIES times the reqwest timeout, which no retry budget can rescue. State both. Reword add_group_members to name the proposal, commit, and welcome flow rather than the unexplained "once the add commits". * chore: allow clippy::question_mark in LocalBroadcaster::poll (Rust 1.97 FP) Stable rolled to 1.97, whose clippy question_mark flags poll()'s match on `self.shared.borrow().read(next)`. Its suggested `read(next)?` would drop the RefCell Ref guard and dangle the returned reference, so the lint is a false positive here. CI tracks floating stable (`rustup update stable`), so this is pre-existing code newly flagged; suppress it to keep the branch green.
2026-07-09 20:02:04 +02:00
group_v2: Option<GroupV2Config>,
) -> Result<(Self, Receiver<Event>), ClientError> {
let inbound = transport.inbound();
let (wakeup_tx, wakeup_rx) = crossbeam_channel::unbounded();
let wakeup_service = ThreadedWakeupService::new(wakeup_tx);
let directory = reg.clone();
let ident = DelegateIdentity::new(ident, &account);
feat: GroupV2 through the threaded client, group roster, registry retry (#167) * feat: expose GroupV2 through the threaded client GroupV2 (de-mls) conversations were reachable only from Core, and every conversation ran the hardcoded millisecond timer profile in group_v2.rs (20-150 ms freeze/consensus windows), which cannot survive real network latency. Make them reachable through ChatClient (as DirectV1 already is), with timing that holds up over a real network. - ChatClient::create_group_conversation(accounts) and add_group_members(convo_id, accounts): resolve each account address to its endorsed signer ids through the client-held directory, the same resolution create_direct_conversation uses, and drive Core::create_group_convo / group_add_member. - GroupV2 timing/policy is injectable: ServiceContext carries a de_mls::ConversationConfig (re-exported as GroupV2Config) defaulting to the de-mls library defaults; Core::set_group_v2_config and the builder's group_v2_config setter override it. The creator's phase durations reach joiners inside the welcome's ConversationSync, so the group runs the creator's phase timing. - GroupV2Convo::add_member validates every member's key package before proposing any add, skips members de-mls would silently not propose (self, already in the group) instead of stranding a pending invite, and flushes opened proposals even on a mid-batch failure, so a failed batch cannot invite members behind the caller's back. - The millisecond test profile moves into the test harnesses (integration_tests_core's TestHarness, crates/client/tests/group_v2.rs). - New client-level tests: three accounts on the in-process transport create a group, a non-creator adds the third member, and messages fan out with directory-verified senders; a batch containing a member with no key package fails without inviting anyone. * fix: class inbound DirectV1 joins as Private, not Group The joiner of a DirectV1 (pairwise) conversation received it classed as Group, because dispatch_to_inbox2 hardcoded ConversationClass::Group for every InboxV2 join. DirectV1 welcomes (InviteType::GroupV1) and GroupV2 welcomes (InviteType::GroupV2) both arrive over InboxV2, so a plain 1:1 invite surfaced to the display layer as a group. ConversationClass is documented as stable across protocol versions of the same conversation shape, and DirectV1 is the pairwise shape, so its joiner must see Private. - InboxV2::handle_frame returns the class alongside the convo: InviteType::GroupV1 (the DirectV1 welcome carrier) yields Private, InviteType::GroupV2 yields Group. - dispatch_to_inbox2 propagates that class instead of hardcoding Group. - direct_v1_by_account_address asserts the joiner sees Private. * feat: expose a group's roster, deduped to one entry per account The display layer needs a group's membership, but nothing exposed it: de-mls holds the authoritative roster (MLS group state) with no public accessor, and members added by other members stay invisible until they send a message. Rebuilding the roster from observed messages would fork state the crypto layer owns and be wrong exactly when a group grows. - GroupConvo::members() returns each member's hex-encoded MLS leaf-credential content, self included. GroupV2Convo delegates to de-mls and guarantees self-inclusion; GroupV1Convo reads its openmls leaves. - Core::group_members(convo_id) mirrors group_add_member's dispatch: a cached group yields its members, a direct conversation is an UnsupportedFunction, otherwise the group is loaded. - ChatClient::group_members returns Vec<GroupMember>, resolving each member's account claim through the directory. A member whose account claim is unconfirmable is listed by device with account None rather than dropped: it is cryptographically in the group, only the account claim is unproven. The credential parsing decode_sender did is factored into parse_credential and shared by both, leaving decode_sender's stricter drop semantics for message senders unchanged. - Because resolve_device_ids fans an account out to every endorsed device, an account whose devices all join surfaced once per device; group_members dedups by account, keeping the first-seen device as the account's representative. Members with no confirmed account stay individual, keyed by their unique device key. - Unit tests cover the tolerant-vs-drop split and the per-account dedup; the three-member group integration test asserts the roster converges after create and after each add, and a solo group lists only its creator. * feat: retry the registry on transient 5xx with backoff and jitter The keypackage/account registry is reliable request-by-request but sheds concurrent bursts with a 5xx, so several instances registering at once each hard-failed on init. HttpRegistry's four calls now retry network errors and 5xx/429 with exponential backoff and full jitter (the jitter decorrelates concurrent publishers so their retries don't re-collide); 4xx and success return immediately. The total retry window is bounded to a few seconds. * fix: mark InboxV2 key package last-resort so members can join multiple groups A key package's init key is one-time-use: openmls deletes it after the first welcome that consumes it. Each installation registers a single key package, so a second group inviting the same member found no matching key package and rejected the welcome with "welcome not addressed to this member", the flaky group add. Mark the InboxV2 key package as last-resort (and advertise the extension in the leaf capabilities, which key-package validation requires) so openmls retains the init key, letting one key package admit an installation to any number of groups. This reuses one init key for every join, trading per-join forward secrecy for membership that just works. A TODO at the publish site tracks the intended one-time key-package pool (the registry pops one per fetch, the client replenishes) with last-resort as the exhaustion fallback (#169). Add regression tests: a member joining two groups (core harness) and two peers invited to several groups over the threaded client. * fix: dedup list_conversations across the store and the in-memory cache A DirectV1 join persists its conversation to the store and also caches it in memory, so list_conversations saw it twice. It deduped with Vec::dedup, which only drops consecutive repeats, over cached_convos' nondeterministic HashMap order, so the duplicate survived whenever another cached conversation fell between the two copies. list_conversations then intermittently returned a conversation twice, and a consumer counting conversations (e.g. checking that a peer joined a group while a direct chat already existed) saw a flaky count. Dedup through a set so a conversation held in both stores is listed once regardless of iteration order. Add a DirectV1-then-GroupV2 regression test, which also covers key-package reuse across conversation types. * fix: dedup the GroupV2 add batch to avoid redundant fetches and duplicate invites Both create_group_convo_v2 and group_add_member funnel through GroupV2Convo::add_member, so a duplicate signer (an account that resolves to the same signer twice, or a repeated account) cost a redundant key-package fetch and a second Add proposal. The existing guard skipped only self and already-committed members, which a within-batch duplicate escapes because add_member opens a proposal the committed roster does not yet reflect, stranding a pending_invite that can later fire a spurious duplicate welcome. Dedup the requested signers before fetching, and guard the add loop with a membership set seeded from the roster and self, hoisting the per-iteration members() call out of the loop. * docs: correct the retry-budget and group-add doc comments The retry-budget comment claimed the ~20s init IPC budget held even at the worst-case sum, but that only holds on the load-shed path where each retry returns fast; a fully unreachable registry costs up to MAX_RETRIES times the reqwest timeout, which no retry budget can rescue. State both. Reword add_group_members to name the proposal, commit, and welcome flow rather than the unexplained "once the add commits". * chore: allow clippy::question_mark in LocalBroadcaster::poll (Rust 1.97 FP) Stable rolled to 1.97, whose clippy question_mark flags poll()'s match on `self.shared.borrow().read(next)`. Its suggested `read(next)?` would drop the RefCell Ref guard and dangle the returned reference, so the lint is a false positive here. CI tracks floating stable (`rustup update stable`), so this is pre-existing code newly flagged; suppress it to keep the branch green.
2026-07-09 20:02:04 +02:00
let mut core = Core::new_with_name(ident, transport, reg, wakeup_service, storage)?;
if let Some(config) = group_v2 {
core.set_group_v2_config(config);
}
Ok(Self::spawn(core, directory, account, inbound, wakeup_rx))
}
fn spawn(
core: ClientCore<T, R, S>,
directory: R,
address: String,
inbound: Receiver<Vec<u8>>,
wakeup_events: Receiver<WakeupEvent>,
) -> (Self, Receiver<Event>) {
let core = Arc::new(Mutex::new(core));
let (event_tx, event_rx) = crossbeam_channel::unbounded();
let (shutdown_tx, shutdown_rx) = crossbeam_channel::bounded::<()>(0);
let worker = thread::spawn({
let core = Arc::clone(&core);
let directory = directory.clone();
move || {
worker_loop(
core,
directory,
inbound,
wakeup_events,
shutdown_rx,
event_tx,
)
}
});
(
Self {
core,
directory,
shutdown: Some(shutdown_tx),
worker: Some(worker),
address,
},
event_rx,
)
}
/// The account address peers use to reach this client.
pub fn addr(&self) -> &str {
&self.address
}
/// Returns the installation name (identity label) of this client.
pub fn installation_name(&self) -> String {
self.core.lock().installation_name().to_string()
}
// Creates a conversation between two Accounts.
pub fn create_direct_conversation(
&mut self,
account: AccountAddressRef,
) -> Result<ConversationId, ClientError> {
let signers = self.signers_from_account(account)?;
let signer_refs: Vec<IdentIdRef> = signers.iter().collect();
self.core
.lock()
.create_direct_convo(&signer_refs)
.map_err(Into::into)
}
feat: GroupV2 through the threaded client, group roster, registry retry (#167) * feat: expose GroupV2 through the threaded client GroupV2 (de-mls) conversations were reachable only from Core, and every conversation ran the hardcoded millisecond timer profile in group_v2.rs (20-150 ms freeze/consensus windows), which cannot survive real network latency. Make them reachable through ChatClient (as DirectV1 already is), with timing that holds up over a real network. - ChatClient::create_group_conversation(accounts) and add_group_members(convo_id, accounts): resolve each account address to its endorsed signer ids through the client-held directory, the same resolution create_direct_conversation uses, and drive Core::create_group_convo / group_add_member. - GroupV2 timing/policy is injectable: ServiceContext carries a de_mls::ConversationConfig (re-exported as GroupV2Config) defaulting to the de-mls library defaults; Core::set_group_v2_config and the builder's group_v2_config setter override it. The creator's phase durations reach joiners inside the welcome's ConversationSync, so the group runs the creator's phase timing. - GroupV2Convo::add_member validates every member's key package before proposing any add, skips members de-mls would silently not propose (self, already in the group) instead of stranding a pending invite, and flushes opened proposals even on a mid-batch failure, so a failed batch cannot invite members behind the caller's back. - The millisecond test profile moves into the test harnesses (integration_tests_core's TestHarness, crates/client/tests/group_v2.rs). - New client-level tests: three accounts on the in-process transport create a group, a non-creator adds the third member, and messages fan out with directory-verified senders; a batch containing a member with no key package fails without inviting anyone. * fix: class inbound DirectV1 joins as Private, not Group The joiner of a DirectV1 (pairwise) conversation received it classed as Group, because dispatch_to_inbox2 hardcoded ConversationClass::Group for every InboxV2 join. DirectV1 welcomes (InviteType::GroupV1) and GroupV2 welcomes (InviteType::GroupV2) both arrive over InboxV2, so a plain 1:1 invite surfaced to the display layer as a group. ConversationClass is documented as stable across protocol versions of the same conversation shape, and DirectV1 is the pairwise shape, so its joiner must see Private. - InboxV2::handle_frame returns the class alongside the convo: InviteType::GroupV1 (the DirectV1 welcome carrier) yields Private, InviteType::GroupV2 yields Group. - dispatch_to_inbox2 propagates that class instead of hardcoding Group. - direct_v1_by_account_address asserts the joiner sees Private. * feat: expose a group's roster, deduped to one entry per account The display layer needs a group's membership, but nothing exposed it: de-mls holds the authoritative roster (MLS group state) with no public accessor, and members added by other members stay invisible until they send a message. Rebuilding the roster from observed messages would fork state the crypto layer owns and be wrong exactly when a group grows. - GroupConvo::members() returns each member's hex-encoded MLS leaf-credential content, self included. GroupV2Convo delegates to de-mls and guarantees self-inclusion; GroupV1Convo reads its openmls leaves. - Core::group_members(convo_id) mirrors group_add_member's dispatch: a cached group yields its members, a direct conversation is an UnsupportedFunction, otherwise the group is loaded. - ChatClient::group_members returns Vec<GroupMember>, resolving each member's account claim through the directory. A member whose account claim is unconfirmable is listed by device with account None rather than dropped: it is cryptographically in the group, only the account claim is unproven. The credential parsing decode_sender did is factored into parse_credential and shared by both, leaving decode_sender's stricter drop semantics for message senders unchanged. - Because resolve_device_ids fans an account out to every endorsed device, an account whose devices all join surfaced once per device; group_members dedups by account, keeping the first-seen device as the account's representative. Members with no confirmed account stay individual, keyed by their unique device key. - Unit tests cover the tolerant-vs-drop split and the per-account dedup; the three-member group integration test asserts the roster converges after create and after each add, and a solo group lists only its creator. * feat: retry the registry on transient 5xx with backoff and jitter The keypackage/account registry is reliable request-by-request but sheds concurrent bursts with a 5xx, so several instances registering at once each hard-failed on init. HttpRegistry's four calls now retry network errors and 5xx/429 with exponential backoff and full jitter (the jitter decorrelates concurrent publishers so their retries don't re-collide); 4xx and success return immediately. The total retry window is bounded to a few seconds. * fix: mark InboxV2 key package last-resort so members can join multiple groups A key package's init key is one-time-use: openmls deletes it after the first welcome that consumes it. Each installation registers a single key package, so a second group inviting the same member found no matching key package and rejected the welcome with "welcome not addressed to this member", the flaky group add. Mark the InboxV2 key package as last-resort (and advertise the extension in the leaf capabilities, which key-package validation requires) so openmls retains the init key, letting one key package admit an installation to any number of groups. This reuses one init key for every join, trading per-join forward secrecy for membership that just works. A TODO at the publish site tracks the intended one-time key-package pool (the registry pops one per fetch, the client replenishes) with last-resort as the exhaustion fallback (#169). Add regression tests: a member joining two groups (core harness) and two peers invited to several groups over the threaded client. * fix: dedup list_conversations across the store and the in-memory cache A DirectV1 join persists its conversation to the store and also caches it in memory, so list_conversations saw it twice. It deduped with Vec::dedup, which only drops consecutive repeats, over cached_convos' nondeterministic HashMap order, so the duplicate survived whenever another cached conversation fell between the two copies. list_conversations then intermittently returned a conversation twice, and a consumer counting conversations (e.g. checking that a peer joined a group while a direct chat already existed) saw a flaky count. Dedup through a set so a conversation held in both stores is listed once regardless of iteration order. Add a DirectV1-then-GroupV2 regression test, which also covers key-package reuse across conversation types. * fix: dedup the GroupV2 add batch to avoid redundant fetches and duplicate invites Both create_group_convo_v2 and group_add_member funnel through GroupV2Convo::add_member, so a duplicate signer (an account that resolves to the same signer twice, or a repeated account) cost a redundant key-package fetch and a second Add proposal. The existing guard skipped only self and already-committed members, which a within-batch duplicate escapes because add_member opens a proposal the committed roster does not yet reflect, stranding a pending_invite that can later fire a spurious duplicate welcome. Dedup the requested signers before fetching, and guard the add loop with a membership set seeded from the roster and self, hoisting the per-iteration members() call out of the loop. * docs: correct the retry-budget and group-add doc comments The retry-budget comment claimed the ~20s init IPC budget held even at the worst-case sum, but that only holds on the load-shed path where each retry returns fast; a fully unreachable registry costs up to MAX_RETRIES times the reqwest timeout, which no retry budget can rescue. State both. Reword add_group_members to name the proposal, commit, and welcome flow rather than the unexplained "once the add commits". * chore: allow clippy::question_mark in LocalBroadcaster::poll (Rust 1.97 FP) Stable rolled to 1.97, whose clippy question_mark flags poll()'s match on `self.shared.borrow().read(next)`. Its suggested `read(next)?` would drop the RefCell Ref guard and dangle the returned reference, so the lint is a false positive here. CI tracks floating stable (`rustup update stable`), so this is pre-existing code newly flagged; suppress it to keep the branch green.
2026-07-09 20:02:04 +02:00
/// Create a GroupV2 conversation with the given accounts' devices. Each
/// account resolves to the signer ids its directory bundle endorses; the
/// group invite goes to every one of them. An empty slice creates a group
/// with only this client, to grow via [`Self::add_group_members`].
pub fn create_group_conversation(
&mut self,
accounts: &[AccountAddressRef],
) -> Result<ConversationId, ClientError> {
let signers = self.signers_from_accounts(accounts)?;
let signer_refs: Vec<IdentIdRef> = signers.iter().collect();
self.core
.lock()
.create_group_convo(&signer_refs)
.map_err(Into::into)
}
/// Add accounts' devices to an existing group conversation. The add is
/// staged as an MLS proposal and merged by the group's next commit (driven
/// asynchronously by the wakeup loop); each joiner's welcome is sent when
/// that commit lands, not when this call returns.
pub fn add_group_members(
&mut self,
convo_id: &str,
accounts: &[AccountAddressRef],
) -> Result<(), ClientError> {
let signers = self.signers_from_accounts(accounts)?;
let signer_refs: Vec<IdentIdRef> = signers.iter().collect();
self.core
.lock()
.group_add_member(convo_id, &signer_refs)
.map_err(Into::into)
}
/// The group's roster, one [`GroupMember`] per account (self included). An
/// account's several devices collapse to a single entry surfacing that
/// account; a member whose account claim the directory can't confirm stays
/// on the roster individually, keyed by its device. Costs one directory
/// lookup per member that claims an account, the same per-member cost a
/// received message's sender check pays.
pub fn group_members(&mut self, convo_id: &str) -> Result<Vec<GroupMember>, ClientError> {
let credentials = self.core.lock().group_members(convo_id)?;
let members = credentials
.iter()
.filter_map(|credential| roster_member(&self.directory, credential));
Ok(dedup_members(members))
}
/// List all conversation IDs known to this client.
refactor(core): replace Rc-based Context with a synchronous, Send-able Core (#123) Make the conversations core Send so the threaded client can own it behind an Arc<Mutex<Core>>: a background worker polls the transport and handles inbound payloads while the application thread issues outbound calls (send, create conversation). Sharing the core across those two threads means moving it into the spawned worker, which is only legal if it is Send. Access stays serialized by the client's Mutex (one thread at a time), so the core needs Send but not Sync and carries no lock of its own. See docs/adr/0001-client-event-system.md for the background-poller design. The Rc<RefCell> service-sharing is what made the core !Send. Context is de-Rc'd and renamed to Core, owning its services outright and driving the inbox and conversation primitives with plain &mut self. - Services (identity, delivery, store, registry, MLS context, causal history) are bundled into a ServiceContext<S> behind an ExternalServices trait, with S = (DS, RS, CS). Constructors live on the (DS, RS, CS) form because S cannot be inferred backwards through S::DS. - Inbox, InboxV2, PrivateV1Convo, and GroupV1Convo become non-generic and receive the ServiceContext bundle as a &mut/& parameter; no Rc or RefCell-as-shared-state remains, so Core is Send whenever its injected services are. - Dispatch branches on ConversationKind in one place: Core rebuilds the target as a Convo<S>/GroupConvo<S> trait object bound to the service bundle, so conversations never escape the orchestrator. - CausalHistoryStore drops its Rc, keeping a plain RefCell.
2026-06-08 21:55:33 +02:00
pub fn list_conversations(&self) -> Result<Vec<ConversationId>, ClientError> {
self.core.lock().list_conversations().map_err(Into::into)
}
/// Encrypt and send `content` to an existing conversation. The core
/// publishes the outbound envelope.
refactor(core): replace Rc-based Context with a synchronous, Send-able Core (#123) Make the conversations core Send so the threaded client can own it behind an Arc<Mutex<Core>>: a background worker polls the transport and handles inbound payloads while the application thread issues outbound calls (send, create conversation). Sharing the core across those two threads means moving it into the spawned worker, which is only legal if it is Send. Access stays serialized by the client's Mutex (one thread at a time), so the core needs Send but not Sync and carries no lock of its own. See docs/adr/0001-client-event-system.md for the background-poller design. The Rc<RefCell> service-sharing is what made the core !Send. Context is de-Rc'd and renamed to Core, owning its services outright and driving the inbox and conversation primitives with plain &mut self. - Services (identity, delivery, store, registry, MLS context, causal history) are bundled into a ServiceContext<S> behind an ExternalServices trait, with S = (DS, RS, CS). Constructors live on the (DS, RS, CS) form because S cannot be inferred backwards through S::DS. - Inbox, InboxV2, PrivateV1Convo, and GroupV1Convo become non-generic and receive the ServiceContext bundle as a &mut/& parameter; no Rc or RefCell-as-shared-state remains, so Core is Send whenever its injected services are. - Dispatch branches on ConversationKind in one place: Core rebuilds the target as a Convo<S>/GroupConvo<S> trait object bound to the service bundle, so conversations never escape the orchestrator. - CausalHistoryStore drops its Rc, keeping a plain RefCell.
2026-06-08 21:55:33 +02:00
pub fn send_message(&mut self, convo_id: &str, content: &[u8]) -> Result<(), ClientError> {
self.core
.lock()
refactor(core): replace Rc-based Context with a synchronous, Send-able Core (#123) Make the conversations core Send so the threaded client can own it behind an Arc<Mutex<Core>>: a background worker polls the transport and handles inbound payloads while the application thread issues outbound calls (send, create conversation). Sharing the core across those two threads means moving it into the spawned worker, which is only legal if it is Send. Access stays serialized by the client's Mutex (one thread at a time), so the core needs Send but not Sync and carries no lock of its own. See docs/adr/0001-client-event-system.md for the background-poller design. The Rc<RefCell> service-sharing is what made the core !Send. Context is de-Rc'd and renamed to Core, owning its services outright and driving the inbox and conversation primitives with plain &mut self. - Services (identity, delivery, store, registry, MLS context, causal history) are bundled into a ServiceContext<S> behind an ExternalServices trait, with S = (DS, RS, CS). Constructors live on the (DS, RS, CS) form because S cannot be inferred backwards through S::DS. - Inbox, InboxV2, PrivateV1Convo, and GroupV1Convo become non-generic and receive the ServiceContext bundle as a &mut/& parameter; no Rc or RefCell-as-shared-state remains, so Core is Send whenever its injected services are. - Dispatch branches on ConversationKind in one place: Core rebuilds the target as a Convo<S>/GroupConvo<S> trait object bound to the service bundle, so conversations never escape the orchestrator. - CausalHistoryStore drops its Rc, keeping a plain RefCell.
2026-06-08 21:55:33 +02:00
.send_content(convo_id, content)
.map_err(Into::into)
}
/// Resolve an account address to the signer (device) ids its published
/// directory bundle endorses. A reachable account has published at least
/// one signer; anything else is an error.
fn signers_from_account(
&self,
account: AccountAddressRef,
) -> Result<Vec<LocalSignerId>, ClientError> {
let account = IdentId::new(account.to_string());
let device_ids = resolve_device_ids(&self.directory, &account)
.map_err(|e| ClientError::AccountResolution(e.to_string()))?;
Ok(device_ids.into_iter().map(IdentId::new).collect())
}
feat: GroupV2 through the threaded client, group roster, registry retry (#167) * feat: expose GroupV2 through the threaded client GroupV2 (de-mls) conversations were reachable only from Core, and every conversation ran the hardcoded millisecond timer profile in group_v2.rs (20-150 ms freeze/consensus windows), which cannot survive real network latency. Make them reachable through ChatClient (as DirectV1 already is), with timing that holds up over a real network. - ChatClient::create_group_conversation(accounts) and add_group_members(convo_id, accounts): resolve each account address to its endorsed signer ids through the client-held directory, the same resolution create_direct_conversation uses, and drive Core::create_group_convo / group_add_member. - GroupV2 timing/policy is injectable: ServiceContext carries a de_mls::ConversationConfig (re-exported as GroupV2Config) defaulting to the de-mls library defaults; Core::set_group_v2_config and the builder's group_v2_config setter override it. The creator's phase durations reach joiners inside the welcome's ConversationSync, so the group runs the creator's phase timing. - GroupV2Convo::add_member validates every member's key package before proposing any add, skips members de-mls would silently not propose (self, already in the group) instead of stranding a pending invite, and flushes opened proposals even on a mid-batch failure, so a failed batch cannot invite members behind the caller's back. - The millisecond test profile moves into the test harnesses (integration_tests_core's TestHarness, crates/client/tests/group_v2.rs). - New client-level tests: three accounts on the in-process transport create a group, a non-creator adds the third member, and messages fan out with directory-verified senders; a batch containing a member with no key package fails without inviting anyone. * fix: class inbound DirectV1 joins as Private, not Group The joiner of a DirectV1 (pairwise) conversation received it classed as Group, because dispatch_to_inbox2 hardcoded ConversationClass::Group for every InboxV2 join. DirectV1 welcomes (InviteType::GroupV1) and GroupV2 welcomes (InviteType::GroupV2) both arrive over InboxV2, so a plain 1:1 invite surfaced to the display layer as a group. ConversationClass is documented as stable across protocol versions of the same conversation shape, and DirectV1 is the pairwise shape, so its joiner must see Private. - InboxV2::handle_frame returns the class alongside the convo: InviteType::GroupV1 (the DirectV1 welcome carrier) yields Private, InviteType::GroupV2 yields Group. - dispatch_to_inbox2 propagates that class instead of hardcoding Group. - direct_v1_by_account_address asserts the joiner sees Private. * feat: expose a group's roster, deduped to one entry per account The display layer needs a group's membership, but nothing exposed it: de-mls holds the authoritative roster (MLS group state) with no public accessor, and members added by other members stay invisible until they send a message. Rebuilding the roster from observed messages would fork state the crypto layer owns and be wrong exactly when a group grows. - GroupConvo::members() returns each member's hex-encoded MLS leaf-credential content, self included. GroupV2Convo delegates to de-mls and guarantees self-inclusion; GroupV1Convo reads its openmls leaves. - Core::group_members(convo_id) mirrors group_add_member's dispatch: a cached group yields its members, a direct conversation is an UnsupportedFunction, otherwise the group is loaded. - ChatClient::group_members returns Vec<GroupMember>, resolving each member's account claim through the directory. A member whose account claim is unconfirmable is listed by device with account None rather than dropped: it is cryptographically in the group, only the account claim is unproven. The credential parsing decode_sender did is factored into parse_credential and shared by both, leaving decode_sender's stricter drop semantics for message senders unchanged. - Because resolve_device_ids fans an account out to every endorsed device, an account whose devices all join surfaced once per device; group_members dedups by account, keeping the first-seen device as the account's representative. Members with no confirmed account stay individual, keyed by their unique device key. - Unit tests cover the tolerant-vs-drop split and the per-account dedup; the three-member group integration test asserts the roster converges after create and after each add, and a solo group lists only its creator. * feat: retry the registry on transient 5xx with backoff and jitter The keypackage/account registry is reliable request-by-request but sheds concurrent bursts with a 5xx, so several instances registering at once each hard-failed on init. HttpRegistry's four calls now retry network errors and 5xx/429 with exponential backoff and full jitter (the jitter decorrelates concurrent publishers so their retries don't re-collide); 4xx and success return immediately. The total retry window is bounded to a few seconds. * fix: mark InboxV2 key package last-resort so members can join multiple groups A key package's init key is one-time-use: openmls deletes it after the first welcome that consumes it. Each installation registers a single key package, so a second group inviting the same member found no matching key package and rejected the welcome with "welcome not addressed to this member", the flaky group add. Mark the InboxV2 key package as last-resort (and advertise the extension in the leaf capabilities, which key-package validation requires) so openmls retains the init key, letting one key package admit an installation to any number of groups. This reuses one init key for every join, trading per-join forward secrecy for membership that just works. A TODO at the publish site tracks the intended one-time key-package pool (the registry pops one per fetch, the client replenishes) with last-resort as the exhaustion fallback (#169). Add regression tests: a member joining two groups (core harness) and two peers invited to several groups over the threaded client. * fix: dedup list_conversations across the store and the in-memory cache A DirectV1 join persists its conversation to the store and also caches it in memory, so list_conversations saw it twice. It deduped with Vec::dedup, which only drops consecutive repeats, over cached_convos' nondeterministic HashMap order, so the duplicate survived whenever another cached conversation fell between the two copies. list_conversations then intermittently returned a conversation twice, and a consumer counting conversations (e.g. checking that a peer joined a group while a direct chat already existed) saw a flaky count. Dedup through a set so a conversation held in both stores is listed once regardless of iteration order. Add a DirectV1-then-GroupV2 regression test, which also covers key-package reuse across conversation types. * fix: dedup the GroupV2 add batch to avoid redundant fetches and duplicate invites Both create_group_convo_v2 and group_add_member funnel through GroupV2Convo::add_member, so a duplicate signer (an account that resolves to the same signer twice, or a repeated account) cost a redundant key-package fetch and a second Add proposal. The existing guard skipped only self and already-committed members, which a within-batch duplicate escapes because add_member opens a proposal the committed roster does not yet reflect, stranding a pending_invite that can later fire a spurious duplicate welcome. Dedup the requested signers before fetching, and guard the add loop with a membership set seeded from the roster and self, hoisting the per-iteration members() call out of the loop. * docs: correct the retry-budget and group-add doc comments The retry-budget comment claimed the ~20s init IPC budget held even at the worst-case sum, but that only holds on the load-shed path where each retry returns fast; a fully unreachable registry costs up to MAX_RETRIES times the reqwest timeout, which no retry budget can rescue. State both. Reword add_group_members to name the proposal, commit, and welcome flow rather than the unexplained "once the add commits". * chore: allow clippy::question_mark in LocalBroadcaster::poll (Rust 1.97 FP) Stable rolled to 1.97, whose clippy question_mark flags poll()'s match on `self.shared.borrow().read(next)`. Its suggested `read(next)?` would drop the RefCell Ref guard and dangle the returned reference, so the lint is a false positive here. CI tracks floating stable (`rustup update stable`), so this is pre-existing code newly flagged; suppress it to keep the branch green.
2026-07-09 20:02:04 +02:00
/// Resolve each account to its signer ids and flatten them, failing on the
/// first unresolvable account.
fn signers_from_accounts(
&self,
accounts: &[AccountAddressRef],
) -> Result<Vec<LocalSignerId>, ClientError> {
let mut signers = Vec::new();
for account in accounts {
signers.extend(self.signers_from_account(account)?);
}
Ok(signers)
}
}
impl<T, R, S> Drop for ChatClient<T, R, S>
where
T: Transport + Send + 'static,
R: RegistrationService + AccountDirectory + Clone + Send + 'static,
S: ChatStore + Send + 'static,
{
fn drop(&mut self) {
// Dropping the sender disconnects the worker's shutdown channel, waking
// its `select!` so it can exit; then we join it.
self.shutdown.take();
if let Some(handle) = self.worker.take() {
let _ = handle.join();
}
}
}
/// Background loop: block until an inbound payload or shutdown arrives, drive
/// the core on each payload, and forward events. No polling — `select!` parks
/// the thread until one of the channels is ready.
fn worker_loop<T, R, S: ChatStore + 'static>(
core: Arc<Mutex<ClientCore<T, R, S>>>,
directory: R,
inbound: Receiver<Vec<u8>>,
wakeup_events: Receiver<WakeupEvent>,
shutdown: Receiver<()>,
event_tx: Sender<Event>,
) where
T: DeliveryService + Send + 'static,
R: RegistrationService + AccountDirectory + Send + 'static,
{
loop {
select! {
recv(inbound) -> msg => {
let Ok(bytes) = msg else {
return; // transport's sender dropped
};
let events = {
let mut core = core.lock();
match core.handle_payload(&bytes) {
Ok(outcome) => events_from_inbound(outcome, &directory),
Err(e) => {
tracing::warn!("inbound handle_payload failed: {e:?}");
vec![Event::InboundError {
message: e.to_string(),
}]
}
}
};
for event in events {
if event_tx.send(event).is_err() {
return; // application dropped the receiver
}
}
}
recv(wakeup_events) -> msg => {
let Ok(WakeupEvent { convo_id }) = msg else {
return; // wakeup service's sender dropped
};
// A wakeup can drive the steward's own commit, so it yields events too.
let events = match core.lock().wakeup(&convo_id) {
Ok(outcome) => events_from_inbound(outcome, &directory),
Err(e) => {
tracing::warn!("wakeup failed: {e:?}");
Vec::new()
}
};
for event in events {
if event_tx.send(event).is_err() {
return; // application dropped the receiver
}
}
}
recv(shutdown) -> _ => return,
}
}
}
feat: introduce client event system (#106) * chore(flake): accept extra system attr; add perl for openssl-sys build forAllSystems calls the lambda with {system, pkgs}; strict destructuring requires `..` to ignore the system attribute. `pkgs.perl` is needed because openssl-sys is pulled vendored via libsqlite3-sys / rusqlite / chat-sqlite, and its `perl Configure` step needs FindBin.pm, which Fedora's system perl doesn't ship. * feat: introduce client event system - Core processing yields a `PayloadOutcome` enum — `Empty`, `Convo`, or `Inbox`. `ConvoOutcome` carries a conversation id and an optional decrypted `Content`; `InboxOutcome` adds a `NewConversation` (id + `ConversationClass`) for a peer-initiated conversation. - Client translates `PayloadOutcome` into app-facing `Vec<Event>` (`ConversationStarted`, `MessageReceived`) at the boundary, so the application loop sees discrete events rather than core types. - MLS group welcomes produce a `ConversationStarted` event with no initial content, fixing the silent-group-join case where the inbox layer dropped the observation. - C FFI exposes an `EventList` opaque type with indexed accessors and an `Invalid` sentinel for out-of-bounds / non-applicable reads. - Symmetric `Inbox` / `InboxV2` handlers: both return `Result<InboxOutcome, _>` and own the persistence + ephemeral-key cleanup for the conversations they create. - Updated and simplified `docs/adr/0001-client-event-system.md`. * chore(flake): bump nixpkgs to nixos-unstable-small Temporary. The two crates.io UA fixes (NixOS/nixpkgs#512735 for fetchCargoVendor's python-requests UA, NixOS/nixpkgs#524985 for importCargoLock's curl UA) haven't propagated to nixos-unstable yet. Switch to nixos-unstable-small and force logos-delivery to follow so the smoketest gets the same fix. Revert once nixos-unstable catches up. Refs: - https://github.com/rust-lang/crates.io/issues/13482 - https://github.com/rust-lang/crates.io/issues/13783 - https://crates.io/data-access
2026-05-28 23:51:15 +02:00
/// Walk a [`PayloadOutcome`] in causal order and emit one `Event` per
feat: introduce client event system (#106) * chore(flake): accept extra system attr; add perl for openssl-sys build forAllSystems calls the lambda with {system, pkgs}; strict destructuring requires `..` to ignore the system attribute. `pkgs.perl` is needed because openssl-sys is pulled vendored via libsqlite3-sys / rusqlite / chat-sqlite, and its `perl Configure` step needs FindBin.pm, which Fedora's system perl doesn't ship. * feat: introduce client event system - Core processing yields a `PayloadOutcome` enum — `Empty`, `Convo`, or `Inbox`. `ConvoOutcome` carries a conversation id and an optional decrypted `Content`; `InboxOutcome` adds a `NewConversation` (id + `ConversationClass`) for a peer-initiated conversation. - Client translates `PayloadOutcome` into app-facing `Vec<Event>` (`ConversationStarted`, `MessageReceived`) at the boundary, so the application loop sees discrete events rather than core types. - MLS group welcomes produce a `ConversationStarted` event with no initial content, fixing the silent-group-join case where the inbox layer dropped the observation. - C FFI exposes an `EventList` opaque type with indexed accessors and an `Invalid` sentinel for out-of-bounds / non-applicable reads. - Symmetric `Inbox` / `InboxV2` handlers: both return `Result<InboxOutcome, _>` and own the persistence + ephemeral-key cleanup for the conversations they create. - Updated and simplified `docs/adr/0001-client-event-system.md`. * chore(flake): bump nixpkgs to nixos-unstable-small Temporary. The two crates.io UA fixes (NixOS/nixpkgs#512735 for fetchCargoVendor's python-requests UA, NixOS/nixpkgs#524985 for importCargoLock's curl UA) haven't propagated to nixos-unstable yet. Switch to nixos-unstable-small and force logos-delivery to follow so the smoketest gets the same fix. Revert once nixos-unstable catches up. Refs: - https://github.com/rust-lang/crates.io/issues/13482 - https://github.com/rust-lang/crates.io/issues/13783 - https://crates.io/data-access
2026-05-28 23:51:15 +02:00
/// observation. For an `Inbox` outcome, [`Event::ConversationStarted`]
/// precedes the message event. The convo id is wrapped into `Arc<str>` once
/// per outcome and shared across the events it produces.
fn events_from_inbound(result: PayloadOutcome, directory: &impl AccountDirectory) -> Vec<Event> {
feat: introduce client event system (#106) * chore(flake): accept extra system attr; add perl for openssl-sys build forAllSystems calls the lambda with {system, pkgs}; strict destructuring requires `..` to ignore the system attribute. `pkgs.perl` is needed because openssl-sys is pulled vendored via libsqlite3-sys / rusqlite / chat-sqlite, and its `perl Configure` step needs FindBin.pm, which Fedora's system perl doesn't ship. * feat: introduce client event system - Core processing yields a `PayloadOutcome` enum — `Empty`, `Convo`, or `Inbox`. `ConvoOutcome` carries a conversation id and an optional decrypted `Content`; `InboxOutcome` adds a `NewConversation` (id + `ConversationClass`) for a peer-initiated conversation. - Client translates `PayloadOutcome` into app-facing `Vec<Event>` (`ConversationStarted`, `MessageReceived`) at the boundary, so the application loop sees discrete events rather than core types. - MLS group welcomes produce a `ConversationStarted` event with no initial content, fixing the silent-group-join case where the inbox layer dropped the observation. - C FFI exposes an `EventList` opaque type with indexed accessors and an `Invalid` sentinel for out-of-bounds / non-applicable reads. - Symmetric `Inbox` / `InboxV2` handlers: both return `Result<InboxOutcome, _>` and own the persistence + ephemeral-key cleanup for the conversations they create. - Updated and simplified `docs/adr/0001-client-event-system.md`. * chore(flake): bump nixpkgs to nixos-unstable-small Temporary. The two crates.io UA fixes (NixOS/nixpkgs#512735 for fetchCargoVendor's python-requests UA, NixOS/nixpkgs#524985 for importCargoLock's curl UA) haven't propagated to nixos-unstable yet. Switch to nixos-unstable-small and force logos-delivery to follow so the smoketest gets the same fix. Revert once nixos-unstable catches up. Refs: - https://github.com/rust-lang/crates.io/issues/13482 - https://github.com/rust-lang/crates.io/issues/13783 - https://crates.io/data-access
2026-05-28 23:51:15 +02:00
match result {
PayloadOutcome::Empty => Vec::new(),
PayloadOutcome::Convo(co) => convo_events(co, directory),
PayloadOutcome::Inbox(io) => inbox_events(io, directory),
feat: introduce client event system (#106) * chore(flake): accept extra system attr; add perl for openssl-sys build forAllSystems calls the lambda with {system, pkgs}; strict destructuring requires `..` to ignore the system attribute. `pkgs.perl` is needed because openssl-sys is pulled vendored via libsqlite3-sys / rusqlite / chat-sqlite, and its `perl Configure` step needs FindBin.pm, which Fedora's system perl doesn't ship. * feat: introduce client event system - Core processing yields a `PayloadOutcome` enum — `Empty`, `Convo`, or `Inbox`. `ConvoOutcome` carries a conversation id and an optional decrypted `Content`; `InboxOutcome` adds a `NewConversation` (id + `ConversationClass`) for a peer-initiated conversation. - Client translates `PayloadOutcome` into app-facing `Vec<Event>` (`ConversationStarted`, `MessageReceived`) at the boundary, so the application loop sees discrete events rather than core types. - MLS group welcomes produce a `ConversationStarted` event with no initial content, fixing the silent-group-join case where the inbox layer dropped the observation. - C FFI exposes an `EventList` opaque type with indexed accessors and an `Invalid` sentinel for out-of-bounds / non-applicable reads. - Symmetric `Inbox` / `InboxV2` handlers: both return `Result<InboxOutcome, _>` and own the persistence + ephemeral-key cleanup for the conversations they create. - Updated and simplified `docs/adr/0001-client-event-system.md`. * chore(flake): bump nixpkgs to nixos-unstable-small Temporary. The two crates.io UA fixes (NixOS/nixpkgs#512735 for fetchCargoVendor's python-requests UA, NixOS/nixpkgs#524985 for importCargoLock's curl UA) haven't propagated to nixos-unstable yet. Switch to nixos-unstable-small and force logos-delivery to follow so the smoketest gets the same fix. Revert once nixos-unstable catches up. Refs: - https://github.com/rust-lang/crates.io/issues/13482 - https://github.com/rust-lang/crates.io/issues/13783 - https://crates.io/data-access
2026-05-28 23:51:15 +02:00
}
}
/// Interpret a hex account address as an Ed25519 account verifying key.
fn account_key_from_hex(addr: &str) -> Option<Ed25519VerifyingKey> {
let bytes: [u8; 32] = hex::decode(addr).ok()?.try_into().ok()?;
Ed25519VerifyingKey::from_bytes(&bytes).ok()
}
/// Why a message's sender could not be accepted, so the message is dropped.
#[derive(Debug, PartialEq, Eq)]
enum SenderError {
/// No credential at all, so no sender can be attributed. Every delivered
/// message must carry an explicit sender.
Missing,
/// Credential bytes were not valid hex.
NotHex,
/// Credential bytes did not decode to a delegate credential.
Malformed,
/// The claimed account address is not an Ed25519 verifying key.
AccountNotAKey,
/// The account → device mapping is wrong or could not be confirmed: the
/// device is not in the account's published set, the account published none,
/// or the directory lookup failed.
Unverified,
}
feat: GroupV2 through the threaded client, group roster, registry retry (#167) * feat: expose GroupV2 through the threaded client GroupV2 (de-mls) conversations were reachable only from Core, and every conversation ran the hardcoded millisecond timer profile in group_v2.rs (20-150 ms freeze/consensus windows), which cannot survive real network latency. Make them reachable through ChatClient (as DirectV1 already is), with timing that holds up over a real network. - ChatClient::create_group_conversation(accounts) and add_group_members(convo_id, accounts): resolve each account address to its endorsed signer ids through the client-held directory, the same resolution create_direct_conversation uses, and drive Core::create_group_convo / group_add_member. - GroupV2 timing/policy is injectable: ServiceContext carries a de_mls::ConversationConfig (re-exported as GroupV2Config) defaulting to the de-mls library defaults; Core::set_group_v2_config and the builder's group_v2_config setter override it. The creator's phase durations reach joiners inside the welcome's ConversationSync, so the group runs the creator's phase timing. - GroupV2Convo::add_member validates every member's key package before proposing any add, skips members de-mls would silently not propose (self, already in the group) instead of stranding a pending invite, and flushes opened proposals even on a mid-batch failure, so a failed batch cannot invite members behind the caller's back. - The millisecond test profile moves into the test harnesses (integration_tests_core's TestHarness, crates/client/tests/group_v2.rs). - New client-level tests: three accounts on the in-process transport create a group, a non-creator adds the third member, and messages fan out with directory-verified senders; a batch containing a member with no key package fails without inviting anyone. * fix: class inbound DirectV1 joins as Private, not Group The joiner of a DirectV1 (pairwise) conversation received it classed as Group, because dispatch_to_inbox2 hardcoded ConversationClass::Group for every InboxV2 join. DirectV1 welcomes (InviteType::GroupV1) and GroupV2 welcomes (InviteType::GroupV2) both arrive over InboxV2, so a plain 1:1 invite surfaced to the display layer as a group. ConversationClass is documented as stable across protocol versions of the same conversation shape, and DirectV1 is the pairwise shape, so its joiner must see Private. - InboxV2::handle_frame returns the class alongside the convo: InviteType::GroupV1 (the DirectV1 welcome carrier) yields Private, InviteType::GroupV2 yields Group. - dispatch_to_inbox2 propagates that class instead of hardcoding Group. - direct_v1_by_account_address asserts the joiner sees Private. * feat: expose a group's roster, deduped to one entry per account The display layer needs a group's membership, but nothing exposed it: de-mls holds the authoritative roster (MLS group state) with no public accessor, and members added by other members stay invisible until they send a message. Rebuilding the roster from observed messages would fork state the crypto layer owns and be wrong exactly when a group grows. - GroupConvo::members() returns each member's hex-encoded MLS leaf-credential content, self included. GroupV2Convo delegates to de-mls and guarantees self-inclusion; GroupV1Convo reads its openmls leaves. - Core::group_members(convo_id) mirrors group_add_member's dispatch: a cached group yields its members, a direct conversation is an UnsupportedFunction, otherwise the group is loaded. - ChatClient::group_members returns Vec<GroupMember>, resolving each member's account claim through the directory. A member whose account claim is unconfirmable is listed by device with account None rather than dropped: it is cryptographically in the group, only the account claim is unproven. The credential parsing decode_sender did is factored into parse_credential and shared by both, leaving decode_sender's stricter drop semantics for message senders unchanged. - Because resolve_device_ids fans an account out to every endorsed device, an account whose devices all join surfaced once per device; group_members dedups by account, keeping the first-seen device as the account's representative. Members with no confirmed account stay individual, keyed by their unique device key. - Unit tests cover the tolerant-vs-drop split and the per-account dedup; the three-member group integration test asserts the roster converges after create and after each add, and a solo group lists only its creator. * feat: retry the registry on transient 5xx with backoff and jitter The keypackage/account registry is reliable request-by-request but sheds concurrent bursts with a 5xx, so several instances registering at once each hard-failed on init. HttpRegistry's four calls now retry network errors and 5xx/429 with exponential backoff and full jitter (the jitter decorrelates concurrent publishers so their retries don't re-collide); 4xx and success return immediately. The total retry window is bounded to a few seconds. * fix: mark InboxV2 key package last-resort so members can join multiple groups A key package's init key is one-time-use: openmls deletes it after the first welcome that consumes it. Each installation registers a single key package, so a second group inviting the same member found no matching key package and rejected the welcome with "welcome not addressed to this member", the flaky group add. Mark the InboxV2 key package as last-resort (and advertise the extension in the leaf capabilities, which key-package validation requires) so openmls retains the init key, letting one key package admit an installation to any number of groups. This reuses one init key for every join, trading per-join forward secrecy for membership that just works. A TODO at the publish site tracks the intended one-time key-package pool (the registry pops one per fetch, the client replenishes) with last-resort as the exhaustion fallback (#169). Add regression tests: a member joining two groups (core harness) and two peers invited to several groups over the threaded client. * fix: dedup list_conversations across the store and the in-memory cache A DirectV1 join persists its conversation to the store and also caches it in memory, so list_conversations saw it twice. It deduped with Vec::dedup, which only drops consecutive repeats, over cached_convos' nondeterministic HashMap order, so the duplicate survived whenever another cached conversation fell between the two copies. list_conversations then intermittently returned a conversation twice, and a consumer counting conversations (e.g. checking that a peer joined a group while a direct chat already existed) saw a flaky count. Dedup through a set so a conversation held in both stores is listed once regardless of iteration order. Add a DirectV1-then-GroupV2 regression test, which also covers key-package reuse across conversation types. * fix: dedup the GroupV2 add batch to avoid redundant fetches and duplicate invites Both create_group_convo_v2 and group_add_member funnel through GroupV2Convo::add_member, so a duplicate signer (an account that resolves to the same signer twice, or a repeated account) cost a redundant key-package fetch and a second Add proposal. The existing guard skipped only self and already-committed members, which a within-batch duplicate escapes because add_member opens a proposal the committed roster does not yet reflect, stranding a pending_invite that can later fire a spurious duplicate welcome. Dedup the requested signers before fetching, and guard the add loop with a membership set seeded from the roster and self, hoisting the per-iteration members() call out of the loop. * docs: correct the retry-budget and group-add doc comments The retry-budget comment claimed the ~20s init IPC budget held even at the worst-case sum, but that only holds on the load-shed path where each retry returns fast; a fully unreachable registry costs up to MAX_RETRIES times the reqwest timeout, which no retry budget can rescue. State both. Reword add_group_members to name the proposal, commit, and welcome flow rather than the unexplained "once the add commits". * chore: allow clippy::question_mark in LocalBroadcaster::poll (Rust 1.97 FP) Stable rolled to 1.97, whose clippy question_mark flags poll()'s match on `self.shared.borrow().read(next)`. Its suggested `read(next)?` would drop the RefCell Ref guard and dangle the returned reference, so the lint is a false positive here. CI tracks floating stable (`rustup update stable`), so this is pre-existing code newly flagged; suppress it to keep the branch green.
2026-07-09 20:02:04 +02:00
/// The resolution of a credential's account claim against the directory.
enum AccountClaim {
/// The credential claimed no account.
None,
/// Confirmed: the directory lists this device under the claimed account.
Verified(IdentId),
/// An account was claimed but could not be confirmed (see [`SenderError`]).
Unverified(SenderError),
}
/// Parse a wire credential into the device it names and the resolution of any
/// account claim, checked against the account → device directory. `Err` only
/// when no device can be attributed at all (missing or unparseable credential).
///
feat: GroupV2 through the threaded client, group roster, registry retry (#167) * feat: expose GroupV2 through the threaded client GroupV2 (de-mls) conversations were reachable only from Core, and every conversation ran the hardcoded millisecond timer profile in group_v2.rs (20-150 ms freeze/consensus windows), which cannot survive real network latency. Make them reachable through ChatClient (as DirectV1 already is), with timing that holds up over a real network. - ChatClient::create_group_conversation(accounts) and add_group_members(convo_id, accounts): resolve each account address to its endorsed signer ids through the client-held directory, the same resolution create_direct_conversation uses, and drive Core::create_group_convo / group_add_member. - GroupV2 timing/policy is injectable: ServiceContext carries a de_mls::ConversationConfig (re-exported as GroupV2Config) defaulting to the de-mls library defaults; Core::set_group_v2_config and the builder's group_v2_config setter override it. The creator's phase durations reach joiners inside the welcome's ConversationSync, so the group runs the creator's phase timing. - GroupV2Convo::add_member validates every member's key package before proposing any add, skips members de-mls would silently not propose (self, already in the group) instead of stranding a pending invite, and flushes opened proposals even on a mid-batch failure, so a failed batch cannot invite members behind the caller's back. - The millisecond test profile moves into the test harnesses (integration_tests_core's TestHarness, crates/client/tests/group_v2.rs). - New client-level tests: three accounts on the in-process transport create a group, a non-creator adds the third member, and messages fan out with directory-verified senders; a batch containing a member with no key package fails without inviting anyone. * fix: class inbound DirectV1 joins as Private, not Group The joiner of a DirectV1 (pairwise) conversation received it classed as Group, because dispatch_to_inbox2 hardcoded ConversationClass::Group for every InboxV2 join. DirectV1 welcomes (InviteType::GroupV1) and GroupV2 welcomes (InviteType::GroupV2) both arrive over InboxV2, so a plain 1:1 invite surfaced to the display layer as a group. ConversationClass is documented as stable across protocol versions of the same conversation shape, and DirectV1 is the pairwise shape, so its joiner must see Private. - InboxV2::handle_frame returns the class alongside the convo: InviteType::GroupV1 (the DirectV1 welcome carrier) yields Private, InviteType::GroupV2 yields Group. - dispatch_to_inbox2 propagates that class instead of hardcoding Group. - direct_v1_by_account_address asserts the joiner sees Private. * feat: expose a group's roster, deduped to one entry per account The display layer needs a group's membership, but nothing exposed it: de-mls holds the authoritative roster (MLS group state) with no public accessor, and members added by other members stay invisible until they send a message. Rebuilding the roster from observed messages would fork state the crypto layer owns and be wrong exactly when a group grows. - GroupConvo::members() returns each member's hex-encoded MLS leaf-credential content, self included. GroupV2Convo delegates to de-mls and guarantees self-inclusion; GroupV1Convo reads its openmls leaves. - Core::group_members(convo_id) mirrors group_add_member's dispatch: a cached group yields its members, a direct conversation is an UnsupportedFunction, otherwise the group is loaded. - ChatClient::group_members returns Vec<GroupMember>, resolving each member's account claim through the directory. A member whose account claim is unconfirmable is listed by device with account None rather than dropped: it is cryptographically in the group, only the account claim is unproven. The credential parsing decode_sender did is factored into parse_credential and shared by both, leaving decode_sender's stricter drop semantics for message senders unchanged. - Because resolve_device_ids fans an account out to every endorsed device, an account whose devices all join surfaced once per device; group_members dedups by account, keeping the first-seen device as the account's representative. Members with no confirmed account stay individual, keyed by their unique device key. - Unit tests cover the tolerant-vs-drop split and the per-account dedup; the three-member group integration test asserts the roster converges after create and after each add, and a solo group lists only its creator. * feat: retry the registry on transient 5xx with backoff and jitter The keypackage/account registry is reliable request-by-request but sheds concurrent bursts with a 5xx, so several instances registering at once each hard-failed on init. HttpRegistry's four calls now retry network errors and 5xx/429 with exponential backoff and full jitter (the jitter decorrelates concurrent publishers so their retries don't re-collide); 4xx and success return immediately. The total retry window is bounded to a few seconds. * fix: mark InboxV2 key package last-resort so members can join multiple groups A key package's init key is one-time-use: openmls deletes it after the first welcome that consumes it. Each installation registers a single key package, so a second group inviting the same member found no matching key package and rejected the welcome with "welcome not addressed to this member", the flaky group add. Mark the InboxV2 key package as last-resort (and advertise the extension in the leaf capabilities, which key-package validation requires) so openmls retains the init key, letting one key package admit an installation to any number of groups. This reuses one init key for every join, trading per-join forward secrecy for membership that just works. A TODO at the publish site tracks the intended one-time key-package pool (the registry pops one per fetch, the client replenishes) with last-resort as the exhaustion fallback (#169). Add regression tests: a member joining two groups (core harness) and two peers invited to several groups over the threaded client. * fix: dedup list_conversations across the store and the in-memory cache A DirectV1 join persists its conversation to the store and also caches it in memory, so list_conversations saw it twice. It deduped with Vec::dedup, which only drops consecutive repeats, over cached_convos' nondeterministic HashMap order, so the duplicate survived whenever another cached conversation fell between the two copies. list_conversations then intermittently returned a conversation twice, and a consumer counting conversations (e.g. checking that a peer joined a group while a direct chat already existed) saw a flaky count. Dedup through a set so a conversation held in both stores is listed once regardless of iteration order. Add a DirectV1-then-GroupV2 regression test, which also covers key-package reuse across conversation types. * fix: dedup the GroupV2 add batch to avoid redundant fetches and duplicate invites Both create_group_convo_v2 and group_add_member funnel through GroupV2Convo::add_member, so a duplicate signer (an account that resolves to the same signer twice, or a repeated account) cost a redundant key-package fetch and a second Add proposal. The existing guard skipped only self and already-committed members, which a within-batch duplicate escapes because add_member opens a proposal the committed roster does not yet reflect, stranding a pending_invite that can later fire a spurious duplicate welcome. Dedup the requested signers before fetching, and guard the add loop with a membership set seeded from the roster and self, hoisting the per-iteration members() call out of the loop. * docs: correct the retry-budget and group-add doc comments The retry-budget comment claimed the ~20s init IPC budget held even at the worst-case sum, but that only holds on the load-shed path where each retry returns fast; a fully unreachable registry costs up to MAX_RETRIES times the reqwest timeout, which no retry budget can rescue. State both. Reword add_group_members to name the proposal, commit, and welcome flow rather than the unexplained "once the add commits". * chore: allow clippy::question_mark in LocalBroadcaster::poll (Rust 1.97 FP) Stable rolled to 1.97, whose clippy question_mark flags poll()'s match on `self.shared.borrow().read(next)`. Its suggested `read(next)?` would drop the RefCell Ref guard and dangle the returned reference, so the lint is a false positive here. CI tracks floating stable (`rustup update stable`), so this is pre-existing code newly flagged; suppress it to keep the branch green.
2026-07-09 20:02:04 +02:00
/// The account-claim policy is left to the caller: a message drops on an
/// unconfirmable claim, a roster entry keeps the device and forgoes the account.
fn parse_credential(
directory: &impl AccountDirectory,
encoded: &[u8],
feat: GroupV2 through the threaded client, group roster, registry retry (#167) * feat: expose GroupV2 through the threaded client GroupV2 (de-mls) conversations were reachable only from Core, and every conversation ran the hardcoded millisecond timer profile in group_v2.rs (20-150 ms freeze/consensus windows), which cannot survive real network latency. Make them reachable through ChatClient (as DirectV1 already is), with timing that holds up over a real network. - ChatClient::create_group_conversation(accounts) and add_group_members(convo_id, accounts): resolve each account address to its endorsed signer ids through the client-held directory, the same resolution create_direct_conversation uses, and drive Core::create_group_convo / group_add_member. - GroupV2 timing/policy is injectable: ServiceContext carries a de_mls::ConversationConfig (re-exported as GroupV2Config) defaulting to the de-mls library defaults; Core::set_group_v2_config and the builder's group_v2_config setter override it. The creator's phase durations reach joiners inside the welcome's ConversationSync, so the group runs the creator's phase timing. - GroupV2Convo::add_member validates every member's key package before proposing any add, skips members de-mls would silently not propose (self, already in the group) instead of stranding a pending invite, and flushes opened proposals even on a mid-batch failure, so a failed batch cannot invite members behind the caller's back. - The millisecond test profile moves into the test harnesses (integration_tests_core's TestHarness, crates/client/tests/group_v2.rs). - New client-level tests: three accounts on the in-process transport create a group, a non-creator adds the third member, and messages fan out with directory-verified senders; a batch containing a member with no key package fails without inviting anyone. * fix: class inbound DirectV1 joins as Private, not Group The joiner of a DirectV1 (pairwise) conversation received it classed as Group, because dispatch_to_inbox2 hardcoded ConversationClass::Group for every InboxV2 join. DirectV1 welcomes (InviteType::GroupV1) and GroupV2 welcomes (InviteType::GroupV2) both arrive over InboxV2, so a plain 1:1 invite surfaced to the display layer as a group. ConversationClass is documented as stable across protocol versions of the same conversation shape, and DirectV1 is the pairwise shape, so its joiner must see Private. - InboxV2::handle_frame returns the class alongside the convo: InviteType::GroupV1 (the DirectV1 welcome carrier) yields Private, InviteType::GroupV2 yields Group. - dispatch_to_inbox2 propagates that class instead of hardcoding Group. - direct_v1_by_account_address asserts the joiner sees Private. * feat: expose a group's roster, deduped to one entry per account The display layer needs a group's membership, but nothing exposed it: de-mls holds the authoritative roster (MLS group state) with no public accessor, and members added by other members stay invisible until they send a message. Rebuilding the roster from observed messages would fork state the crypto layer owns and be wrong exactly when a group grows. - GroupConvo::members() returns each member's hex-encoded MLS leaf-credential content, self included. GroupV2Convo delegates to de-mls and guarantees self-inclusion; GroupV1Convo reads its openmls leaves. - Core::group_members(convo_id) mirrors group_add_member's dispatch: a cached group yields its members, a direct conversation is an UnsupportedFunction, otherwise the group is loaded. - ChatClient::group_members returns Vec<GroupMember>, resolving each member's account claim through the directory. A member whose account claim is unconfirmable is listed by device with account None rather than dropped: it is cryptographically in the group, only the account claim is unproven. The credential parsing decode_sender did is factored into parse_credential and shared by both, leaving decode_sender's stricter drop semantics for message senders unchanged. - Because resolve_device_ids fans an account out to every endorsed device, an account whose devices all join surfaced once per device; group_members dedups by account, keeping the first-seen device as the account's representative. Members with no confirmed account stay individual, keyed by their unique device key. - Unit tests cover the tolerant-vs-drop split and the per-account dedup; the three-member group integration test asserts the roster converges after create and after each add, and a solo group lists only its creator. * feat: retry the registry on transient 5xx with backoff and jitter The keypackage/account registry is reliable request-by-request but sheds concurrent bursts with a 5xx, so several instances registering at once each hard-failed on init. HttpRegistry's four calls now retry network errors and 5xx/429 with exponential backoff and full jitter (the jitter decorrelates concurrent publishers so their retries don't re-collide); 4xx and success return immediately. The total retry window is bounded to a few seconds. * fix: mark InboxV2 key package last-resort so members can join multiple groups A key package's init key is one-time-use: openmls deletes it after the first welcome that consumes it. Each installation registers a single key package, so a second group inviting the same member found no matching key package and rejected the welcome with "welcome not addressed to this member", the flaky group add. Mark the InboxV2 key package as last-resort (and advertise the extension in the leaf capabilities, which key-package validation requires) so openmls retains the init key, letting one key package admit an installation to any number of groups. This reuses one init key for every join, trading per-join forward secrecy for membership that just works. A TODO at the publish site tracks the intended one-time key-package pool (the registry pops one per fetch, the client replenishes) with last-resort as the exhaustion fallback (#169). Add regression tests: a member joining two groups (core harness) and two peers invited to several groups over the threaded client. * fix: dedup list_conversations across the store and the in-memory cache A DirectV1 join persists its conversation to the store and also caches it in memory, so list_conversations saw it twice. It deduped with Vec::dedup, which only drops consecutive repeats, over cached_convos' nondeterministic HashMap order, so the duplicate survived whenever another cached conversation fell between the two copies. list_conversations then intermittently returned a conversation twice, and a consumer counting conversations (e.g. checking that a peer joined a group while a direct chat already existed) saw a flaky count. Dedup through a set so a conversation held in both stores is listed once regardless of iteration order. Add a DirectV1-then-GroupV2 regression test, which also covers key-package reuse across conversation types. * fix: dedup the GroupV2 add batch to avoid redundant fetches and duplicate invites Both create_group_convo_v2 and group_add_member funnel through GroupV2Convo::add_member, so a duplicate signer (an account that resolves to the same signer twice, or a repeated account) cost a redundant key-package fetch and a second Add proposal. The existing guard skipped only self and already-committed members, which a within-batch duplicate escapes because add_member opens a proposal the committed roster does not yet reflect, stranding a pending_invite that can later fire a spurious duplicate welcome. Dedup the requested signers before fetching, and guard the add loop with a membership set seeded from the roster and self, hoisting the per-iteration members() call out of the loop. * docs: correct the retry-budget and group-add doc comments The retry-budget comment claimed the ~20s init IPC budget held even at the worst-case sum, but that only holds on the load-shed path where each retry returns fast; a fully unreachable registry costs up to MAX_RETRIES times the reqwest timeout, which no retry budget can rescue. State both. Reword add_group_members to name the proposal, commit, and welcome flow rather than the unexplained "once the add commits". * chore: allow clippy::question_mark in LocalBroadcaster::poll (Rust 1.97 FP) Stable rolled to 1.97, whose clippy question_mark flags poll()'s match on `self.shared.borrow().read(next)`. Its suggested `read(next)?` would drop the RefCell Ref guard and dangle the returned reference, so the lint is a false positive here. CI tracks floating stable (`rustup update stable`), so this is pre-existing code newly flagged; suppress it to keep the branch green.
2026-07-09 20:02:04 +02:00
) -> Result<(IdentId, AccountClaim), SenderError> {
// No credential at all: there is no device to attribute.
if encoded.is_empty() {
return Err(SenderError::Missing);
}
let Ok(data) = hex::decode(encoded) else {
feat: GroupV2 through the threaded client, group roster, registry retry (#167) * feat: expose GroupV2 through the threaded client GroupV2 (de-mls) conversations were reachable only from Core, and every conversation ran the hardcoded millisecond timer profile in group_v2.rs (20-150 ms freeze/consensus windows), which cannot survive real network latency. Make them reachable through ChatClient (as DirectV1 already is), with timing that holds up over a real network. - ChatClient::create_group_conversation(accounts) and add_group_members(convo_id, accounts): resolve each account address to its endorsed signer ids through the client-held directory, the same resolution create_direct_conversation uses, and drive Core::create_group_convo / group_add_member. - GroupV2 timing/policy is injectable: ServiceContext carries a de_mls::ConversationConfig (re-exported as GroupV2Config) defaulting to the de-mls library defaults; Core::set_group_v2_config and the builder's group_v2_config setter override it. The creator's phase durations reach joiners inside the welcome's ConversationSync, so the group runs the creator's phase timing. - GroupV2Convo::add_member validates every member's key package before proposing any add, skips members de-mls would silently not propose (self, already in the group) instead of stranding a pending invite, and flushes opened proposals even on a mid-batch failure, so a failed batch cannot invite members behind the caller's back. - The millisecond test profile moves into the test harnesses (integration_tests_core's TestHarness, crates/client/tests/group_v2.rs). - New client-level tests: three accounts on the in-process transport create a group, a non-creator adds the third member, and messages fan out with directory-verified senders; a batch containing a member with no key package fails without inviting anyone. * fix: class inbound DirectV1 joins as Private, not Group The joiner of a DirectV1 (pairwise) conversation received it classed as Group, because dispatch_to_inbox2 hardcoded ConversationClass::Group for every InboxV2 join. DirectV1 welcomes (InviteType::GroupV1) and GroupV2 welcomes (InviteType::GroupV2) both arrive over InboxV2, so a plain 1:1 invite surfaced to the display layer as a group. ConversationClass is documented as stable across protocol versions of the same conversation shape, and DirectV1 is the pairwise shape, so its joiner must see Private. - InboxV2::handle_frame returns the class alongside the convo: InviteType::GroupV1 (the DirectV1 welcome carrier) yields Private, InviteType::GroupV2 yields Group. - dispatch_to_inbox2 propagates that class instead of hardcoding Group. - direct_v1_by_account_address asserts the joiner sees Private. * feat: expose a group's roster, deduped to one entry per account The display layer needs a group's membership, but nothing exposed it: de-mls holds the authoritative roster (MLS group state) with no public accessor, and members added by other members stay invisible until they send a message. Rebuilding the roster from observed messages would fork state the crypto layer owns and be wrong exactly when a group grows. - GroupConvo::members() returns each member's hex-encoded MLS leaf-credential content, self included. GroupV2Convo delegates to de-mls and guarantees self-inclusion; GroupV1Convo reads its openmls leaves. - Core::group_members(convo_id) mirrors group_add_member's dispatch: a cached group yields its members, a direct conversation is an UnsupportedFunction, otherwise the group is loaded. - ChatClient::group_members returns Vec<GroupMember>, resolving each member's account claim through the directory. A member whose account claim is unconfirmable is listed by device with account None rather than dropped: it is cryptographically in the group, only the account claim is unproven. The credential parsing decode_sender did is factored into parse_credential and shared by both, leaving decode_sender's stricter drop semantics for message senders unchanged. - Because resolve_device_ids fans an account out to every endorsed device, an account whose devices all join surfaced once per device; group_members dedups by account, keeping the first-seen device as the account's representative. Members with no confirmed account stay individual, keyed by their unique device key. - Unit tests cover the tolerant-vs-drop split and the per-account dedup; the three-member group integration test asserts the roster converges after create and after each add, and a solo group lists only its creator. * feat: retry the registry on transient 5xx with backoff and jitter The keypackage/account registry is reliable request-by-request but sheds concurrent bursts with a 5xx, so several instances registering at once each hard-failed on init. HttpRegistry's four calls now retry network errors and 5xx/429 with exponential backoff and full jitter (the jitter decorrelates concurrent publishers so their retries don't re-collide); 4xx and success return immediately. The total retry window is bounded to a few seconds. * fix: mark InboxV2 key package last-resort so members can join multiple groups A key package's init key is one-time-use: openmls deletes it after the first welcome that consumes it. Each installation registers a single key package, so a second group inviting the same member found no matching key package and rejected the welcome with "welcome not addressed to this member", the flaky group add. Mark the InboxV2 key package as last-resort (and advertise the extension in the leaf capabilities, which key-package validation requires) so openmls retains the init key, letting one key package admit an installation to any number of groups. This reuses one init key for every join, trading per-join forward secrecy for membership that just works. A TODO at the publish site tracks the intended one-time key-package pool (the registry pops one per fetch, the client replenishes) with last-resort as the exhaustion fallback (#169). Add regression tests: a member joining two groups (core harness) and two peers invited to several groups over the threaded client. * fix: dedup list_conversations across the store and the in-memory cache A DirectV1 join persists its conversation to the store and also caches it in memory, so list_conversations saw it twice. It deduped with Vec::dedup, which only drops consecutive repeats, over cached_convos' nondeterministic HashMap order, so the duplicate survived whenever another cached conversation fell between the two copies. list_conversations then intermittently returned a conversation twice, and a consumer counting conversations (e.g. checking that a peer joined a group while a direct chat already existed) saw a flaky count. Dedup through a set so a conversation held in both stores is listed once regardless of iteration order. Add a DirectV1-then-GroupV2 regression test, which also covers key-package reuse across conversation types. * fix: dedup the GroupV2 add batch to avoid redundant fetches and duplicate invites Both create_group_convo_v2 and group_add_member funnel through GroupV2Convo::add_member, so a duplicate signer (an account that resolves to the same signer twice, or a repeated account) cost a redundant key-package fetch and a second Add proposal. The existing guard skipped only self and already-committed members, which a within-batch duplicate escapes because add_member opens a proposal the committed roster does not yet reflect, stranding a pending_invite that can later fire a spurious duplicate welcome. Dedup the requested signers before fetching, and guard the add loop with a membership set seeded from the roster and self, hoisting the per-iteration members() call out of the loop. * docs: correct the retry-budget and group-add doc comments The retry-budget comment claimed the ~20s init IPC budget held even at the worst-case sum, but that only holds on the load-shed path where each retry returns fast; a fully unreachable registry costs up to MAX_RETRIES times the reqwest timeout, which no retry budget can rescue. State both. Reword add_group_members to name the proposal, commit, and welcome flow rather than the unexplained "once the add commits". * chore: allow clippy::question_mark in LocalBroadcaster::poll (Rust 1.97 FP) Stable rolled to 1.97, whose clippy question_mark flags poll()'s match on `self.shared.borrow().read(next)`. Its suggested `read(next)?` would drop the RefCell Ref guard and dangle the returned reference, so the lint is a false positive here. CI tracks floating stable (`rustup update stable`), so this is pre-existing code newly flagged; suppress it to keep the branch green.
2026-07-09 20:02:04 +02:00
tracing::warn!("credential is not valid hex");
return Err(SenderError::NotHex);
};
feat: GroupV2 through the threaded client, group roster, registry retry (#167) * feat: expose GroupV2 through the threaded client GroupV2 (de-mls) conversations were reachable only from Core, and every conversation ran the hardcoded millisecond timer profile in group_v2.rs (20-150 ms freeze/consensus windows), which cannot survive real network latency. Make them reachable through ChatClient (as DirectV1 already is), with timing that holds up over a real network. - ChatClient::create_group_conversation(accounts) and add_group_members(convo_id, accounts): resolve each account address to its endorsed signer ids through the client-held directory, the same resolution create_direct_conversation uses, and drive Core::create_group_convo / group_add_member. - GroupV2 timing/policy is injectable: ServiceContext carries a de_mls::ConversationConfig (re-exported as GroupV2Config) defaulting to the de-mls library defaults; Core::set_group_v2_config and the builder's group_v2_config setter override it. The creator's phase durations reach joiners inside the welcome's ConversationSync, so the group runs the creator's phase timing. - GroupV2Convo::add_member validates every member's key package before proposing any add, skips members de-mls would silently not propose (self, already in the group) instead of stranding a pending invite, and flushes opened proposals even on a mid-batch failure, so a failed batch cannot invite members behind the caller's back. - The millisecond test profile moves into the test harnesses (integration_tests_core's TestHarness, crates/client/tests/group_v2.rs). - New client-level tests: three accounts on the in-process transport create a group, a non-creator adds the third member, and messages fan out with directory-verified senders; a batch containing a member with no key package fails without inviting anyone. * fix: class inbound DirectV1 joins as Private, not Group The joiner of a DirectV1 (pairwise) conversation received it classed as Group, because dispatch_to_inbox2 hardcoded ConversationClass::Group for every InboxV2 join. DirectV1 welcomes (InviteType::GroupV1) and GroupV2 welcomes (InviteType::GroupV2) both arrive over InboxV2, so a plain 1:1 invite surfaced to the display layer as a group. ConversationClass is documented as stable across protocol versions of the same conversation shape, and DirectV1 is the pairwise shape, so its joiner must see Private. - InboxV2::handle_frame returns the class alongside the convo: InviteType::GroupV1 (the DirectV1 welcome carrier) yields Private, InviteType::GroupV2 yields Group. - dispatch_to_inbox2 propagates that class instead of hardcoding Group. - direct_v1_by_account_address asserts the joiner sees Private. * feat: expose a group's roster, deduped to one entry per account The display layer needs a group's membership, but nothing exposed it: de-mls holds the authoritative roster (MLS group state) with no public accessor, and members added by other members stay invisible until they send a message. Rebuilding the roster from observed messages would fork state the crypto layer owns and be wrong exactly when a group grows. - GroupConvo::members() returns each member's hex-encoded MLS leaf-credential content, self included. GroupV2Convo delegates to de-mls and guarantees self-inclusion; GroupV1Convo reads its openmls leaves. - Core::group_members(convo_id) mirrors group_add_member's dispatch: a cached group yields its members, a direct conversation is an UnsupportedFunction, otherwise the group is loaded. - ChatClient::group_members returns Vec<GroupMember>, resolving each member's account claim through the directory. A member whose account claim is unconfirmable is listed by device with account None rather than dropped: it is cryptographically in the group, only the account claim is unproven. The credential parsing decode_sender did is factored into parse_credential and shared by both, leaving decode_sender's stricter drop semantics for message senders unchanged. - Because resolve_device_ids fans an account out to every endorsed device, an account whose devices all join surfaced once per device; group_members dedups by account, keeping the first-seen device as the account's representative. Members with no confirmed account stay individual, keyed by their unique device key. - Unit tests cover the tolerant-vs-drop split and the per-account dedup; the three-member group integration test asserts the roster converges after create and after each add, and a solo group lists only its creator. * feat: retry the registry on transient 5xx with backoff and jitter The keypackage/account registry is reliable request-by-request but sheds concurrent bursts with a 5xx, so several instances registering at once each hard-failed on init. HttpRegistry's four calls now retry network errors and 5xx/429 with exponential backoff and full jitter (the jitter decorrelates concurrent publishers so their retries don't re-collide); 4xx and success return immediately. The total retry window is bounded to a few seconds. * fix: mark InboxV2 key package last-resort so members can join multiple groups A key package's init key is one-time-use: openmls deletes it after the first welcome that consumes it. Each installation registers a single key package, so a second group inviting the same member found no matching key package and rejected the welcome with "welcome not addressed to this member", the flaky group add. Mark the InboxV2 key package as last-resort (and advertise the extension in the leaf capabilities, which key-package validation requires) so openmls retains the init key, letting one key package admit an installation to any number of groups. This reuses one init key for every join, trading per-join forward secrecy for membership that just works. A TODO at the publish site tracks the intended one-time key-package pool (the registry pops one per fetch, the client replenishes) with last-resort as the exhaustion fallback (#169). Add regression tests: a member joining two groups (core harness) and two peers invited to several groups over the threaded client. * fix: dedup list_conversations across the store and the in-memory cache A DirectV1 join persists its conversation to the store and also caches it in memory, so list_conversations saw it twice. It deduped with Vec::dedup, which only drops consecutive repeats, over cached_convos' nondeterministic HashMap order, so the duplicate survived whenever another cached conversation fell between the two copies. list_conversations then intermittently returned a conversation twice, and a consumer counting conversations (e.g. checking that a peer joined a group while a direct chat already existed) saw a flaky count. Dedup through a set so a conversation held in both stores is listed once regardless of iteration order. Add a DirectV1-then-GroupV2 regression test, which also covers key-package reuse across conversation types. * fix: dedup the GroupV2 add batch to avoid redundant fetches and duplicate invites Both create_group_convo_v2 and group_add_member funnel through GroupV2Convo::add_member, so a duplicate signer (an account that resolves to the same signer twice, or a repeated account) cost a redundant key-package fetch and a second Add proposal. The existing guard skipped only self and already-committed members, which a within-batch duplicate escapes because add_member opens a proposal the committed roster does not yet reflect, stranding a pending_invite that can later fire a spurious duplicate welcome. Dedup the requested signers before fetching, and guard the add loop with a membership set seeded from the roster and self, hoisting the per-iteration members() call out of the loop. * docs: correct the retry-budget and group-add doc comments The retry-budget comment claimed the ~20s init IPC budget held even at the worst-case sum, but that only holds on the load-shed path where each retry returns fast; a fully unreachable registry costs up to MAX_RETRIES times the reqwest timeout, which no retry budget can rescue. State both. Reword add_group_members to name the proposal, commit, and welcome flow rather than the unexplained "once the add commits". * chore: allow clippy::question_mark in LocalBroadcaster::poll (Rust 1.97 FP) Stable rolled to 1.97, whose clippy question_mark flags poll()'s match on `self.shared.borrow().read(next)`. Its suggested `read(next)?` would drop the RefCell Ref guard and dangle the returned reference, so the lint is a false positive here. CI tracks floating stable (`rustup update stable`), so this is pre-existing code newly flagged; suppress it to keep the branch green.
2026-07-09 20:02:04 +02:00
let Ok(cred) = DelegateCredential::try_from(data) else {
tracing::warn!("malformed credential");
return Err(SenderError::Malformed);
};
feat: GroupV2 through the threaded client, group roster, registry retry (#167) * feat: expose GroupV2 through the threaded client GroupV2 (de-mls) conversations were reachable only from Core, and every conversation ran the hardcoded millisecond timer profile in group_v2.rs (20-150 ms freeze/consensus windows), which cannot survive real network latency. Make them reachable through ChatClient (as DirectV1 already is), with timing that holds up over a real network. - ChatClient::create_group_conversation(accounts) and add_group_members(convo_id, accounts): resolve each account address to its endorsed signer ids through the client-held directory, the same resolution create_direct_conversation uses, and drive Core::create_group_convo / group_add_member. - GroupV2 timing/policy is injectable: ServiceContext carries a de_mls::ConversationConfig (re-exported as GroupV2Config) defaulting to the de-mls library defaults; Core::set_group_v2_config and the builder's group_v2_config setter override it. The creator's phase durations reach joiners inside the welcome's ConversationSync, so the group runs the creator's phase timing. - GroupV2Convo::add_member validates every member's key package before proposing any add, skips members de-mls would silently not propose (self, already in the group) instead of stranding a pending invite, and flushes opened proposals even on a mid-batch failure, so a failed batch cannot invite members behind the caller's back. - The millisecond test profile moves into the test harnesses (integration_tests_core's TestHarness, crates/client/tests/group_v2.rs). - New client-level tests: three accounts on the in-process transport create a group, a non-creator adds the third member, and messages fan out with directory-verified senders; a batch containing a member with no key package fails without inviting anyone. * fix: class inbound DirectV1 joins as Private, not Group The joiner of a DirectV1 (pairwise) conversation received it classed as Group, because dispatch_to_inbox2 hardcoded ConversationClass::Group for every InboxV2 join. DirectV1 welcomes (InviteType::GroupV1) and GroupV2 welcomes (InviteType::GroupV2) both arrive over InboxV2, so a plain 1:1 invite surfaced to the display layer as a group. ConversationClass is documented as stable across protocol versions of the same conversation shape, and DirectV1 is the pairwise shape, so its joiner must see Private. - InboxV2::handle_frame returns the class alongside the convo: InviteType::GroupV1 (the DirectV1 welcome carrier) yields Private, InviteType::GroupV2 yields Group. - dispatch_to_inbox2 propagates that class instead of hardcoding Group. - direct_v1_by_account_address asserts the joiner sees Private. * feat: expose a group's roster, deduped to one entry per account The display layer needs a group's membership, but nothing exposed it: de-mls holds the authoritative roster (MLS group state) with no public accessor, and members added by other members stay invisible until they send a message. Rebuilding the roster from observed messages would fork state the crypto layer owns and be wrong exactly when a group grows. - GroupConvo::members() returns each member's hex-encoded MLS leaf-credential content, self included. GroupV2Convo delegates to de-mls and guarantees self-inclusion; GroupV1Convo reads its openmls leaves. - Core::group_members(convo_id) mirrors group_add_member's dispatch: a cached group yields its members, a direct conversation is an UnsupportedFunction, otherwise the group is loaded. - ChatClient::group_members returns Vec<GroupMember>, resolving each member's account claim through the directory. A member whose account claim is unconfirmable is listed by device with account None rather than dropped: it is cryptographically in the group, only the account claim is unproven. The credential parsing decode_sender did is factored into parse_credential and shared by both, leaving decode_sender's stricter drop semantics for message senders unchanged. - Because resolve_device_ids fans an account out to every endorsed device, an account whose devices all join surfaced once per device; group_members dedups by account, keeping the first-seen device as the account's representative. Members with no confirmed account stay individual, keyed by their unique device key. - Unit tests cover the tolerant-vs-drop split and the per-account dedup; the three-member group integration test asserts the roster converges after create and after each add, and a solo group lists only its creator. * feat: retry the registry on transient 5xx with backoff and jitter The keypackage/account registry is reliable request-by-request but sheds concurrent bursts with a 5xx, so several instances registering at once each hard-failed on init. HttpRegistry's four calls now retry network errors and 5xx/429 with exponential backoff and full jitter (the jitter decorrelates concurrent publishers so their retries don't re-collide); 4xx and success return immediately. The total retry window is bounded to a few seconds. * fix: mark InboxV2 key package last-resort so members can join multiple groups A key package's init key is one-time-use: openmls deletes it after the first welcome that consumes it. Each installation registers a single key package, so a second group inviting the same member found no matching key package and rejected the welcome with "welcome not addressed to this member", the flaky group add. Mark the InboxV2 key package as last-resort (and advertise the extension in the leaf capabilities, which key-package validation requires) so openmls retains the init key, letting one key package admit an installation to any number of groups. This reuses one init key for every join, trading per-join forward secrecy for membership that just works. A TODO at the publish site tracks the intended one-time key-package pool (the registry pops one per fetch, the client replenishes) with last-resort as the exhaustion fallback (#169). Add regression tests: a member joining two groups (core harness) and two peers invited to several groups over the threaded client. * fix: dedup list_conversations across the store and the in-memory cache A DirectV1 join persists its conversation to the store and also caches it in memory, so list_conversations saw it twice. It deduped with Vec::dedup, which only drops consecutive repeats, over cached_convos' nondeterministic HashMap order, so the duplicate survived whenever another cached conversation fell between the two copies. list_conversations then intermittently returned a conversation twice, and a consumer counting conversations (e.g. checking that a peer joined a group while a direct chat already existed) saw a flaky count. Dedup through a set so a conversation held in both stores is listed once regardless of iteration order. Add a DirectV1-then-GroupV2 regression test, which also covers key-package reuse across conversation types. * fix: dedup the GroupV2 add batch to avoid redundant fetches and duplicate invites Both create_group_convo_v2 and group_add_member funnel through GroupV2Convo::add_member, so a duplicate signer (an account that resolves to the same signer twice, or a repeated account) cost a redundant key-package fetch and a second Add proposal. The existing guard skipped only self and already-committed members, which a within-batch duplicate escapes because add_member opens a proposal the committed roster does not yet reflect, stranding a pending_invite that can later fire a spurious duplicate welcome. Dedup the requested signers before fetching, and guard the add loop with a membership set seeded from the roster and self, hoisting the per-iteration members() call out of the loop. * docs: correct the retry-budget and group-add doc comments The retry-budget comment claimed the ~20s init IPC budget held even at the worst-case sum, but that only holds on the load-shed path where each retry returns fast; a fully unreachable registry costs up to MAX_RETRIES times the reqwest timeout, which no retry budget can rescue. State both. Reword add_group_members to name the proposal, commit, and welcome flow rather than the unexplained "once the add commits". * chore: allow clippy::question_mark in LocalBroadcaster::poll (Rust 1.97 FP) Stable rolled to 1.97, whose clippy question_mark flags poll()'s match on `self.shared.borrow().read(next)`. Its suggested `read(next)?` would drop the RefCell Ref guard and dangle the returned reference, so the lint is a false positive here. CI tracks floating stable (`rustup update stable`), so this is pre-existing code newly flagged; suppress it to keep the branch green.
2026-07-09 20:02:04 +02:00
let device = IdentId::new(hex::encode(cred.delegate_id().as_ref()));
// An unassociated delegate asserts no account → device mapping.
let Some(account_addr) = cred.account_addr() else {
feat: GroupV2 through the threaded client, group roster, registry retry (#167) * feat: expose GroupV2 through the threaded client GroupV2 (de-mls) conversations were reachable only from Core, and every conversation ran the hardcoded millisecond timer profile in group_v2.rs (20-150 ms freeze/consensus windows), which cannot survive real network latency. Make them reachable through ChatClient (as DirectV1 already is), with timing that holds up over a real network. - ChatClient::create_group_conversation(accounts) and add_group_members(convo_id, accounts): resolve each account address to its endorsed signer ids through the client-held directory, the same resolution create_direct_conversation uses, and drive Core::create_group_convo / group_add_member. - GroupV2 timing/policy is injectable: ServiceContext carries a de_mls::ConversationConfig (re-exported as GroupV2Config) defaulting to the de-mls library defaults; Core::set_group_v2_config and the builder's group_v2_config setter override it. The creator's phase durations reach joiners inside the welcome's ConversationSync, so the group runs the creator's phase timing. - GroupV2Convo::add_member validates every member's key package before proposing any add, skips members de-mls would silently not propose (self, already in the group) instead of stranding a pending invite, and flushes opened proposals even on a mid-batch failure, so a failed batch cannot invite members behind the caller's back. - The millisecond test profile moves into the test harnesses (integration_tests_core's TestHarness, crates/client/tests/group_v2.rs). - New client-level tests: three accounts on the in-process transport create a group, a non-creator adds the third member, and messages fan out with directory-verified senders; a batch containing a member with no key package fails without inviting anyone. * fix: class inbound DirectV1 joins as Private, not Group The joiner of a DirectV1 (pairwise) conversation received it classed as Group, because dispatch_to_inbox2 hardcoded ConversationClass::Group for every InboxV2 join. DirectV1 welcomes (InviteType::GroupV1) and GroupV2 welcomes (InviteType::GroupV2) both arrive over InboxV2, so a plain 1:1 invite surfaced to the display layer as a group. ConversationClass is documented as stable across protocol versions of the same conversation shape, and DirectV1 is the pairwise shape, so its joiner must see Private. - InboxV2::handle_frame returns the class alongside the convo: InviteType::GroupV1 (the DirectV1 welcome carrier) yields Private, InviteType::GroupV2 yields Group. - dispatch_to_inbox2 propagates that class instead of hardcoding Group. - direct_v1_by_account_address asserts the joiner sees Private. * feat: expose a group's roster, deduped to one entry per account The display layer needs a group's membership, but nothing exposed it: de-mls holds the authoritative roster (MLS group state) with no public accessor, and members added by other members stay invisible until they send a message. Rebuilding the roster from observed messages would fork state the crypto layer owns and be wrong exactly when a group grows. - GroupConvo::members() returns each member's hex-encoded MLS leaf-credential content, self included. GroupV2Convo delegates to de-mls and guarantees self-inclusion; GroupV1Convo reads its openmls leaves. - Core::group_members(convo_id) mirrors group_add_member's dispatch: a cached group yields its members, a direct conversation is an UnsupportedFunction, otherwise the group is loaded. - ChatClient::group_members returns Vec<GroupMember>, resolving each member's account claim through the directory. A member whose account claim is unconfirmable is listed by device with account None rather than dropped: it is cryptographically in the group, only the account claim is unproven. The credential parsing decode_sender did is factored into parse_credential and shared by both, leaving decode_sender's stricter drop semantics for message senders unchanged. - Because resolve_device_ids fans an account out to every endorsed device, an account whose devices all join surfaced once per device; group_members dedups by account, keeping the first-seen device as the account's representative. Members with no confirmed account stay individual, keyed by their unique device key. - Unit tests cover the tolerant-vs-drop split and the per-account dedup; the three-member group integration test asserts the roster converges after create and after each add, and a solo group lists only its creator. * feat: retry the registry on transient 5xx with backoff and jitter The keypackage/account registry is reliable request-by-request but sheds concurrent bursts with a 5xx, so several instances registering at once each hard-failed on init. HttpRegistry's four calls now retry network errors and 5xx/429 with exponential backoff and full jitter (the jitter decorrelates concurrent publishers so their retries don't re-collide); 4xx and success return immediately. The total retry window is bounded to a few seconds. * fix: mark InboxV2 key package last-resort so members can join multiple groups A key package's init key is one-time-use: openmls deletes it after the first welcome that consumes it. Each installation registers a single key package, so a second group inviting the same member found no matching key package and rejected the welcome with "welcome not addressed to this member", the flaky group add. Mark the InboxV2 key package as last-resort (and advertise the extension in the leaf capabilities, which key-package validation requires) so openmls retains the init key, letting one key package admit an installation to any number of groups. This reuses one init key for every join, trading per-join forward secrecy for membership that just works. A TODO at the publish site tracks the intended one-time key-package pool (the registry pops one per fetch, the client replenishes) with last-resort as the exhaustion fallback (#169). Add regression tests: a member joining two groups (core harness) and two peers invited to several groups over the threaded client. * fix: dedup list_conversations across the store and the in-memory cache A DirectV1 join persists its conversation to the store and also caches it in memory, so list_conversations saw it twice. It deduped with Vec::dedup, which only drops consecutive repeats, over cached_convos' nondeterministic HashMap order, so the duplicate survived whenever another cached conversation fell between the two copies. list_conversations then intermittently returned a conversation twice, and a consumer counting conversations (e.g. checking that a peer joined a group while a direct chat already existed) saw a flaky count. Dedup through a set so a conversation held in both stores is listed once regardless of iteration order. Add a DirectV1-then-GroupV2 regression test, which also covers key-package reuse across conversation types. * fix: dedup the GroupV2 add batch to avoid redundant fetches and duplicate invites Both create_group_convo_v2 and group_add_member funnel through GroupV2Convo::add_member, so a duplicate signer (an account that resolves to the same signer twice, or a repeated account) cost a redundant key-package fetch and a second Add proposal. The existing guard skipped only self and already-committed members, which a within-batch duplicate escapes because add_member opens a proposal the committed roster does not yet reflect, stranding a pending_invite that can later fire a spurious duplicate welcome. Dedup the requested signers before fetching, and guard the add loop with a membership set seeded from the roster and self, hoisting the per-iteration members() call out of the loop. * docs: correct the retry-budget and group-add doc comments The retry-budget comment claimed the ~20s init IPC budget held even at the worst-case sum, but that only holds on the load-shed path where each retry returns fast; a fully unreachable registry costs up to MAX_RETRIES times the reqwest timeout, which no retry budget can rescue. State both. Reword add_group_members to name the proposal, commit, and welcome flow rather than the unexplained "once the add commits". * chore: allow clippy::question_mark in LocalBroadcaster::poll (Rust 1.97 FP) Stable rolled to 1.97, whose clippy question_mark flags poll()'s match on `self.shared.borrow().read(next)`. Its suggested `read(next)?` would drop the RefCell Ref guard and dangle the returned reference, so the lint is a false positive here. CI tracks floating stable (`rustup update stable`), so this is pre-existing code newly flagged; suppress it to keep the branch green.
2026-07-09 20:02:04 +02:00
return Ok((device, AccountClaim::None));
};
let Some(account_key) = account_key_from_hex(account_addr) else {
feat: GroupV2 through the threaded client, group roster, registry retry (#167) * feat: expose GroupV2 through the threaded client GroupV2 (de-mls) conversations were reachable only from Core, and every conversation ran the hardcoded millisecond timer profile in group_v2.rs (20-150 ms freeze/consensus windows), which cannot survive real network latency. Make them reachable through ChatClient (as DirectV1 already is), with timing that holds up over a real network. - ChatClient::create_group_conversation(accounts) and add_group_members(convo_id, accounts): resolve each account address to its endorsed signer ids through the client-held directory, the same resolution create_direct_conversation uses, and drive Core::create_group_convo / group_add_member. - GroupV2 timing/policy is injectable: ServiceContext carries a de_mls::ConversationConfig (re-exported as GroupV2Config) defaulting to the de-mls library defaults; Core::set_group_v2_config and the builder's group_v2_config setter override it. The creator's phase durations reach joiners inside the welcome's ConversationSync, so the group runs the creator's phase timing. - GroupV2Convo::add_member validates every member's key package before proposing any add, skips members de-mls would silently not propose (self, already in the group) instead of stranding a pending invite, and flushes opened proposals even on a mid-batch failure, so a failed batch cannot invite members behind the caller's back. - The millisecond test profile moves into the test harnesses (integration_tests_core's TestHarness, crates/client/tests/group_v2.rs). - New client-level tests: three accounts on the in-process transport create a group, a non-creator adds the third member, and messages fan out with directory-verified senders; a batch containing a member with no key package fails without inviting anyone. * fix: class inbound DirectV1 joins as Private, not Group The joiner of a DirectV1 (pairwise) conversation received it classed as Group, because dispatch_to_inbox2 hardcoded ConversationClass::Group for every InboxV2 join. DirectV1 welcomes (InviteType::GroupV1) and GroupV2 welcomes (InviteType::GroupV2) both arrive over InboxV2, so a plain 1:1 invite surfaced to the display layer as a group. ConversationClass is documented as stable across protocol versions of the same conversation shape, and DirectV1 is the pairwise shape, so its joiner must see Private. - InboxV2::handle_frame returns the class alongside the convo: InviteType::GroupV1 (the DirectV1 welcome carrier) yields Private, InviteType::GroupV2 yields Group. - dispatch_to_inbox2 propagates that class instead of hardcoding Group. - direct_v1_by_account_address asserts the joiner sees Private. * feat: expose a group's roster, deduped to one entry per account The display layer needs a group's membership, but nothing exposed it: de-mls holds the authoritative roster (MLS group state) with no public accessor, and members added by other members stay invisible until they send a message. Rebuilding the roster from observed messages would fork state the crypto layer owns and be wrong exactly when a group grows. - GroupConvo::members() returns each member's hex-encoded MLS leaf-credential content, self included. GroupV2Convo delegates to de-mls and guarantees self-inclusion; GroupV1Convo reads its openmls leaves. - Core::group_members(convo_id) mirrors group_add_member's dispatch: a cached group yields its members, a direct conversation is an UnsupportedFunction, otherwise the group is loaded. - ChatClient::group_members returns Vec<GroupMember>, resolving each member's account claim through the directory. A member whose account claim is unconfirmable is listed by device with account None rather than dropped: it is cryptographically in the group, only the account claim is unproven. The credential parsing decode_sender did is factored into parse_credential and shared by both, leaving decode_sender's stricter drop semantics for message senders unchanged. - Because resolve_device_ids fans an account out to every endorsed device, an account whose devices all join surfaced once per device; group_members dedups by account, keeping the first-seen device as the account's representative. Members with no confirmed account stay individual, keyed by their unique device key. - Unit tests cover the tolerant-vs-drop split and the per-account dedup; the three-member group integration test asserts the roster converges after create and after each add, and a solo group lists only its creator. * feat: retry the registry on transient 5xx with backoff and jitter The keypackage/account registry is reliable request-by-request but sheds concurrent bursts with a 5xx, so several instances registering at once each hard-failed on init. HttpRegistry's four calls now retry network errors and 5xx/429 with exponential backoff and full jitter (the jitter decorrelates concurrent publishers so their retries don't re-collide); 4xx and success return immediately. The total retry window is bounded to a few seconds. * fix: mark InboxV2 key package last-resort so members can join multiple groups A key package's init key is one-time-use: openmls deletes it after the first welcome that consumes it. Each installation registers a single key package, so a second group inviting the same member found no matching key package and rejected the welcome with "welcome not addressed to this member", the flaky group add. Mark the InboxV2 key package as last-resort (and advertise the extension in the leaf capabilities, which key-package validation requires) so openmls retains the init key, letting one key package admit an installation to any number of groups. This reuses one init key for every join, trading per-join forward secrecy for membership that just works. A TODO at the publish site tracks the intended one-time key-package pool (the registry pops one per fetch, the client replenishes) with last-resort as the exhaustion fallback (#169). Add regression tests: a member joining two groups (core harness) and two peers invited to several groups over the threaded client. * fix: dedup list_conversations across the store and the in-memory cache A DirectV1 join persists its conversation to the store and also caches it in memory, so list_conversations saw it twice. It deduped with Vec::dedup, which only drops consecutive repeats, over cached_convos' nondeterministic HashMap order, so the duplicate survived whenever another cached conversation fell between the two copies. list_conversations then intermittently returned a conversation twice, and a consumer counting conversations (e.g. checking that a peer joined a group while a direct chat already existed) saw a flaky count. Dedup through a set so a conversation held in both stores is listed once regardless of iteration order. Add a DirectV1-then-GroupV2 regression test, which also covers key-package reuse across conversation types. * fix: dedup the GroupV2 add batch to avoid redundant fetches and duplicate invites Both create_group_convo_v2 and group_add_member funnel through GroupV2Convo::add_member, so a duplicate signer (an account that resolves to the same signer twice, or a repeated account) cost a redundant key-package fetch and a second Add proposal. The existing guard skipped only self and already-committed members, which a within-batch duplicate escapes because add_member opens a proposal the committed roster does not yet reflect, stranding a pending_invite that can later fire a spurious duplicate welcome. Dedup the requested signers before fetching, and guard the add loop with a membership set seeded from the roster and self, hoisting the per-iteration members() call out of the loop. * docs: correct the retry-budget and group-add doc comments The retry-budget comment claimed the ~20s init IPC budget held even at the worst-case sum, but that only holds on the load-shed path where each retry returns fast; a fully unreachable registry costs up to MAX_RETRIES times the reqwest timeout, which no retry budget can rescue. State both. Reword add_group_members to name the proposal, commit, and welcome flow rather than the unexplained "once the add commits". * chore: allow clippy::question_mark in LocalBroadcaster::poll (Rust 1.97 FP) Stable rolled to 1.97, whose clippy question_mark flags poll()'s match on `self.shared.borrow().read(next)`. Its suggested `read(next)?` would drop the RefCell Ref guard and dangle the returned reference, so the lint is a false positive here. CI tracks floating stable (`rustup update stable`), so this is pre-existing code newly flagged; suppress it to keep the branch green.
2026-07-09 20:02:04 +02:00
tracing::warn!(account_addr, "account address is not a verifying key");
return Ok((
device,
AccountClaim::Unverified(SenderError::AccountNotAKey),
));
};
feat: GroupV2 through the threaded client, group roster, registry retry (#167) * feat: expose GroupV2 through the threaded client GroupV2 (de-mls) conversations were reachable only from Core, and every conversation ran the hardcoded millisecond timer profile in group_v2.rs (20-150 ms freeze/consensus windows), which cannot survive real network latency. Make them reachable through ChatClient (as DirectV1 already is), with timing that holds up over a real network. - ChatClient::create_group_conversation(accounts) and add_group_members(convo_id, accounts): resolve each account address to its endorsed signer ids through the client-held directory, the same resolution create_direct_conversation uses, and drive Core::create_group_convo / group_add_member. - GroupV2 timing/policy is injectable: ServiceContext carries a de_mls::ConversationConfig (re-exported as GroupV2Config) defaulting to the de-mls library defaults; Core::set_group_v2_config and the builder's group_v2_config setter override it. The creator's phase durations reach joiners inside the welcome's ConversationSync, so the group runs the creator's phase timing. - GroupV2Convo::add_member validates every member's key package before proposing any add, skips members de-mls would silently not propose (self, already in the group) instead of stranding a pending invite, and flushes opened proposals even on a mid-batch failure, so a failed batch cannot invite members behind the caller's back. - The millisecond test profile moves into the test harnesses (integration_tests_core's TestHarness, crates/client/tests/group_v2.rs). - New client-level tests: three accounts on the in-process transport create a group, a non-creator adds the third member, and messages fan out with directory-verified senders; a batch containing a member with no key package fails without inviting anyone. * fix: class inbound DirectV1 joins as Private, not Group The joiner of a DirectV1 (pairwise) conversation received it classed as Group, because dispatch_to_inbox2 hardcoded ConversationClass::Group for every InboxV2 join. DirectV1 welcomes (InviteType::GroupV1) and GroupV2 welcomes (InviteType::GroupV2) both arrive over InboxV2, so a plain 1:1 invite surfaced to the display layer as a group. ConversationClass is documented as stable across protocol versions of the same conversation shape, and DirectV1 is the pairwise shape, so its joiner must see Private. - InboxV2::handle_frame returns the class alongside the convo: InviteType::GroupV1 (the DirectV1 welcome carrier) yields Private, InviteType::GroupV2 yields Group. - dispatch_to_inbox2 propagates that class instead of hardcoding Group. - direct_v1_by_account_address asserts the joiner sees Private. * feat: expose a group's roster, deduped to one entry per account The display layer needs a group's membership, but nothing exposed it: de-mls holds the authoritative roster (MLS group state) with no public accessor, and members added by other members stay invisible until they send a message. Rebuilding the roster from observed messages would fork state the crypto layer owns and be wrong exactly when a group grows. - GroupConvo::members() returns each member's hex-encoded MLS leaf-credential content, self included. GroupV2Convo delegates to de-mls and guarantees self-inclusion; GroupV1Convo reads its openmls leaves. - Core::group_members(convo_id) mirrors group_add_member's dispatch: a cached group yields its members, a direct conversation is an UnsupportedFunction, otherwise the group is loaded. - ChatClient::group_members returns Vec<GroupMember>, resolving each member's account claim through the directory. A member whose account claim is unconfirmable is listed by device with account None rather than dropped: it is cryptographically in the group, only the account claim is unproven. The credential parsing decode_sender did is factored into parse_credential and shared by both, leaving decode_sender's stricter drop semantics for message senders unchanged. - Because resolve_device_ids fans an account out to every endorsed device, an account whose devices all join surfaced once per device; group_members dedups by account, keeping the first-seen device as the account's representative. Members with no confirmed account stay individual, keyed by their unique device key. - Unit tests cover the tolerant-vs-drop split and the per-account dedup; the three-member group integration test asserts the roster converges after create and after each add, and a solo group lists only its creator. * feat: retry the registry on transient 5xx with backoff and jitter The keypackage/account registry is reliable request-by-request but sheds concurrent bursts with a 5xx, so several instances registering at once each hard-failed on init. HttpRegistry's four calls now retry network errors and 5xx/429 with exponential backoff and full jitter (the jitter decorrelates concurrent publishers so their retries don't re-collide); 4xx and success return immediately. The total retry window is bounded to a few seconds. * fix: mark InboxV2 key package last-resort so members can join multiple groups A key package's init key is one-time-use: openmls deletes it after the first welcome that consumes it. Each installation registers a single key package, so a second group inviting the same member found no matching key package and rejected the welcome with "welcome not addressed to this member", the flaky group add. Mark the InboxV2 key package as last-resort (and advertise the extension in the leaf capabilities, which key-package validation requires) so openmls retains the init key, letting one key package admit an installation to any number of groups. This reuses one init key for every join, trading per-join forward secrecy for membership that just works. A TODO at the publish site tracks the intended one-time key-package pool (the registry pops one per fetch, the client replenishes) with last-resort as the exhaustion fallback (#169). Add regression tests: a member joining two groups (core harness) and two peers invited to several groups over the threaded client. * fix: dedup list_conversations across the store and the in-memory cache A DirectV1 join persists its conversation to the store and also caches it in memory, so list_conversations saw it twice. It deduped with Vec::dedup, which only drops consecutive repeats, over cached_convos' nondeterministic HashMap order, so the duplicate survived whenever another cached conversation fell between the two copies. list_conversations then intermittently returned a conversation twice, and a consumer counting conversations (e.g. checking that a peer joined a group while a direct chat already existed) saw a flaky count. Dedup through a set so a conversation held in both stores is listed once regardless of iteration order. Add a DirectV1-then-GroupV2 regression test, which also covers key-package reuse across conversation types. * fix: dedup the GroupV2 add batch to avoid redundant fetches and duplicate invites Both create_group_convo_v2 and group_add_member funnel through GroupV2Convo::add_member, so a duplicate signer (an account that resolves to the same signer twice, or a repeated account) cost a redundant key-package fetch and a second Add proposal. The existing guard skipped only self and already-committed members, which a within-batch duplicate escapes because add_member opens a proposal the committed roster does not yet reflect, stranding a pending_invite that can later fire a spurious duplicate welcome. Dedup the requested signers before fetching, and guard the add loop with a membership set seeded from the roster and self, hoisting the per-iteration members() call out of the loop. * docs: correct the retry-budget and group-add doc comments The retry-budget comment claimed the ~20s init IPC budget held even at the worst-case sum, but that only holds on the load-shed path where each retry returns fast; a fully unreachable registry costs up to MAX_RETRIES times the reqwest timeout, which no retry budget can rescue. State both. Reword add_group_members to name the proposal, commit, and welcome flow rather than the unexplained "once the add commits". * chore: allow clippy::question_mark in LocalBroadcaster::poll (Rust 1.97 FP) Stable rolled to 1.97, whose clippy question_mark flags poll()'s match on `self.shared.borrow().read(next)`. Its suggested `read(next)?` would drop the RefCell Ref guard and dangle the returned reference, so the lint is a false positive here. CI tracks floating stable (`rustup update stable`), so this is pre-existing code newly flagged; suppress it to keep the branch green.
2026-07-09 20:02:04 +02:00
let claim = match directory.fetch(&account_key) {
Ok(Some(set)) if set.devices.iter().any(|d| d.as_str() == device.as_str()) => {
AccountClaim::Verified(IdentId::new(account_addr.to_string()))
}
_ => {
feat: GroupV2 through the threaded client, group roster, registry retry (#167) * feat: expose GroupV2 through the threaded client GroupV2 (de-mls) conversations were reachable only from Core, and every conversation ran the hardcoded millisecond timer profile in group_v2.rs (20-150 ms freeze/consensus windows), which cannot survive real network latency. Make them reachable through ChatClient (as DirectV1 already is), with timing that holds up over a real network. - ChatClient::create_group_conversation(accounts) and add_group_members(convo_id, accounts): resolve each account address to its endorsed signer ids through the client-held directory, the same resolution create_direct_conversation uses, and drive Core::create_group_convo / group_add_member. - GroupV2 timing/policy is injectable: ServiceContext carries a de_mls::ConversationConfig (re-exported as GroupV2Config) defaulting to the de-mls library defaults; Core::set_group_v2_config and the builder's group_v2_config setter override it. The creator's phase durations reach joiners inside the welcome's ConversationSync, so the group runs the creator's phase timing. - GroupV2Convo::add_member validates every member's key package before proposing any add, skips members de-mls would silently not propose (self, already in the group) instead of stranding a pending invite, and flushes opened proposals even on a mid-batch failure, so a failed batch cannot invite members behind the caller's back. - The millisecond test profile moves into the test harnesses (integration_tests_core's TestHarness, crates/client/tests/group_v2.rs). - New client-level tests: three accounts on the in-process transport create a group, a non-creator adds the third member, and messages fan out with directory-verified senders; a batch containing a member with no key package fails without inviting anyone. * fix: class inbound DirectV1 joins as Private, not Group The joiner of a DirectV1 (pairwise) conversation received it classed as Group, because dispatch_to_inbox2 hardcoded ConversationClass::Group for every InboxV2 join. DirectV1 welcomes (InviteType::GroupV1) and GroupV2 welcomes (InviteType::GroupV2) both arrive over InboxV2, so a plain 1:1 invite surfaced to the display layer as a group. ConversationClass is documented as stable across protocol versions of the same conversation shape, and DirectV1 is the pairwise shape, so its joiner must see Private. - InboxV2::handle_frame returns the class alongside the convo: InviteType::GroupV1 (the DirectV1 welcome carrier) yields Private, InviteType::GroupV2 yields Group. - dispatch_to_inbox2 propagates that class instead of hardcoding Group. - direct_v1_by_account_address asserts the joiner sees Private. * feat: expose a group's roster, deduped to one entry per account The display layer needs a group's membership, but nothing exposed it: de-mls holds the authoritative roster (MLS group state) with no public accessor, and members added by other members stay invisible until they send a message. Rebuilding the roster from observed messages would fork state the crypto layer owns and be wrong exactly when a group grows. - GroupConvo::members() returns each member's hex-encoded MLS leaf-credential content, self included. GroupV2Convo delegates to de-mls and guarantees self-inclusion; GroupV1Convo reads its openmls leaves. - Core::group_members(convo_id) mirrors group_add_member's dispatch: a cached group yields its members, a direct conversation is an UnsupportedFunction, otherwise the group is loaded. - ChatClient::group_members returns Vec<GroupMember>, resolving each member's account claim through the directory. A member whose account claim is unconfirmable is listed by device with account None rather than dropped: it is cryptographically in the group, only the account claim is unproven. The credential parsing decode_sender did is factored into parse_credential and shared by both, leaving decode_sender's stricter drop semantics for message senders unchanged. - Because resolve_device_ids fans an account out to every endorsed device, an account whose devices all join surfaced once per device; group_members dedups by account, keeping the first-seen device as the account's representative. Members with no confirmed account stay individual, keyed by their unique device key. - Unit tests cover the tolerant-vs-drop split and the per-account dedup; the three-member group integration test asserts the roster converges after create and after each add, and a solo group lists only its creator. * feat: retry the registry on transient 5xx with backoff and jitter The keypackage/account registry is reliable request-by-request but sheds concurrent bursts with a 5xx, so several instances registering at once each hard-failed on init. HttpRegistry's four calls now retry network errors and 5xx/429 with exponential backoff and full jitter (the jitter decorrelates concurrent publishers so their retries don't re-collide); 4xx and success return immediately. The total retry window is bounded to a few seconds. * fix: mark InboxV2 key package last-resort so members can join multiple groups A key package's init key is one-time-use: openmls deletes it after the first welcome that consumes it. Each installation registers a single key package, so a second group inviting the same member found no matching key package and rejected the welcome with "welcome not addressed to this member", the flaky group add. Mark the InboxV2 key package as last-resort (and advertise the extension in the leaf capabilities, which key-package validation requires) so openmls retains the init key, letting one key package admit an installation to any number of groups. This reuses one init key for every join, trading per-join forward secrecy for membership that just works. A TODO at the publish site tracks the intended one-time key-package pool (the registry pops one per fetch, the client replenishes) with last-resort as the exhaustion fallback (#169). Add regression tests: a member joining two groups (core harness) and two peers invited to several groups over the threaded client. * fix: dedup list_conversations across the store and the in-memory cache A DirectV1 join persists its conversation to the store and also caches it in memory, so list_conversations saw it twice. It deduped with Vec::dedup, which only drops consecutive repeats, over cached_convos' nondeterministic HashMap order, so the duplicate survived whenever another cached conversation fell between the two copies. list_conversations then intermittently returned a conversation twice, and a consumer counting conversations (e.g. checking that a peer joined a group while a direct chat already existed) saw a flaky count. Dedup through a set so a conversation held in both stores is listed once regardless of iteration order. Add a DirectV1-then-GroupV2 regression test, which also covers key-package reuse across conversation types. * fix: dedup the GroupV2 add batch to avoid redundant fetches and duplicate invites Both create_group_convo_v2 and group_add_member funnel through GroupV2Convo::add_member, so a duplicate signer (an account that resolves to the same signer twice, or a repeated account) cost a redundant key-package fetch and a second Add proposal. The existing guard skipped only self and already-committed members, which a within-batch duplicate escapes because add_member opens a proposal the committed roster does not yet reflect, stranding a pending_invite that can later fire a spurious duplicate welcome. Dedup the requested signers before fetching, and guard the add loop with a membership set seeded from the roster and self, hoisting the per-iteration members() call out of the loop. * docs: correct the retry-budget and group-add doc comments The retry-budget comment claimed the ~20s init IPC budget held even at the worst-case sum, but that only holds on the load-shed path where each retry returns fast; a fully unreachable registry costs up to MAX_RETRIES times the reqwest timeout, which no retry budget can rescue. State both. Reword add_group_members to name the proposal, commit, and welcome flow rather than the unexplained "once the add commits". * chore: allow clippy::question_mark in LocalBroadcaster::poll (Rust 1.97 FP) Stable rolled to 1.97, whose clippy question_mark flags poll()'s match on `self.shared.borrow().read(next)`. Its suggested `read(next)?` would drop the RefCell Ref guard and dangle the returned reference, so the lint is a false positive here. CI tracks floating stable (`rustup update stable`), so this is pre-existing code newly flagged; suppress it to keep the branch green.
2026-07-09 20:02:04 +02:00
tracing::warn!(account_addr, device = %device.as_str(), "account → device mapping is wrong or unconfirmable");
AccountClaim::Unverified(SenderError::Unverified)
}
feat: GroupV2 through the threaded client, group roster, registry retry (#167) * feat: expose GroupV2 through the threaded client GroupV2 (de-mls) conversations were reachable only from Core, and every conversation ran the hardcoded millisecond timer profile in group_v2.rs (20-150 ms freeze/consensus windows), which cannot survive real network latency. Make them reachable through ChatClient (as DirectV1 already is), with timing that holds up over a real network. - ChatClient::create_group_conversation(accounts) and add_group_members(convo_id, accounts): resolve each account address to its endorsed signer ids through the client-held directory, the same resolution create_direct_conversation uses, and drive Core::create_group_convo / group_add_member. - GroupV2 timing/policy is injectable: ServiceContext carries a de_mls::ConversationConfig (re-exported as GroupV2Config) defaulting to the de-mls library defaults; Core::set_group_v2_config and the builder's group_v2_config setter override it. The creator's phase durations reach joiners inside the welcome's ConversationSync, so the group runs the creator's phase timing. - GroupV2Convo::add_member validates every member's key package before proposing any add, skips members de-mls would silently not propose (self, already in the group) instead of stranding a pending invite, and flushes opened proposals even on a mid-batch failure, so a failed batch cannot invite members behind the caller's back. - The millisecond test profile moves into the test harnesses (integration_tests_core's TestHarness, crates/client/tests/group_v2.rs). - New client-level tests: three accounts on the in-process transport create a group, a non-creator adds the third member, and messages fan out with directory-verified senders; a batch containing a member with no key package fails without inviting anyone. * fix: class inbound DirectV1 joins as Private, not Group The joiner of a DirectV1 (pairwise) conversation received it classed as Group, because dispatch_to_inbox2 hardcoded ConversationClass::Group for every InboxV2 join. DirectV1 welcomes (InviteType::GroupV1) and GroupV2 welcomes (InviteType::GroupV2) both arrive over InboxV2, so a plain 1:1 invite surfaced to the display layer as a group. ConversationClass is documented as stable across protocol versions of the same conversation shape, and DirectV1 is the pairwise shape, so its joiner must see Private. - InboxV2::handle_frame returns the class alongside the convo: InviteType::GroupV1 (the DirectV1 welcome carrier) yields Private, InviteType::GroupV2 yields Group. - dispatch_to_inbox2 propagates that class instead of hardcoding Group. - direct_v1_by_account_address asserts the joiner sees Private. * feat: expose a group's roster, deduped to one entry per account The display layer needs a group's membership, but nothing exposed it: de-mls holds the authoritative roster (MLS group state) with no public accessor, and members added by other members stay invisible until they send a message. Rebuilding the roster from observed messages would fork state the crypto layer owns and be wrong exactly when a group grows. - GroupConvo::members() returns each member's hex-encoded MLS leaf-credential content, self included. GroupV2Convo delegates to de-mls and guarantees self-inclusion; GroupV1Convo reads its openmls leaves. - Core::group_members(convo_id) mirrors group_add_member's dispatch: a cached group yields its members, a direct conversation is an UnsupportedFunction, otherwise the group is loaded. - ChatClient::group_members returns Vec<GroupMember>, resolving each member's account claim through the directory. A member whose account claim is unconfirmable is listed by device with account None rather than dropped: it is cryptographically in the group, only the account claim is unproven. The credential parsing decode_sender did is factored into parse_credential and shared by both, leaving decode_sender's stricter drop semantics for message senders unchanged. - Because resolve_device_ids fans an account out to every endorsed device, an account whose devices all join surfaced once per device; group_members dedups by account, keeping the first-seen device as the account's representative. Members with no confirmed account stay individual, keyed by their unique device key. - Unit tests cover the tolerant-vs-drop split and the per-account dedup; the three-member group integration test asserts the roster converges after create and after each add, and a solo group lists only its creator. * feat: retry the registry on transient 5xx with backoff and jitter The keypackage/account registry is reliable request-by-request but sheds concurrent bursts with a 5xx, so several instances registering at once each hard-failed on init. HttpRegistry's four calls now retry network errors and 5xx/429 with exponential backoff and full jitter (the jitter decorrelates concurrent publishers so their retries don't re-collide); 4xx and success return immediately. The total retry window is bounded to a few seconds. * fix: mark InboxV2 key package last-resort so members can join multiple groups A key package's init key is one-time-use: openmls deletes it after the first welcome that consumes it. Each installation registers a single key package, so a second group inviting the same member found no matching key package and rejected the welcome with "welcome not addressed to this member", the flaky group add. Mark the InboxV2 key package as last-resort (and advertise the extension in the leaf capabilities, which key-package validation requires) so openmls retains the init key, letting one key package admit an installation to any number of groups. This reuses one init key for every join, trading per-join forward secrecy for membership that just works. A TODO at the publish site tracks the intended one-time key-package pool (the registry pops one per fetch, the client replenishes) with last-resort as the exhaustion fallback (#169). Add regression tests: a member joining two groups (core harness) and two peers invited to several groups over the threaded client. * fix: dedup list_conversations across the store and the in-memory cache A DirectV1 join persists its conversation to the store and also caches it in memory, so list_conversations saw it twice. It deduped with Vec::dedup, which only drops consecutive repeats, over cached_convos' nondeterministic HashMap order, so the duplicate survived whenever another cached conversation fell between the two copies. list_conversations then intermittently returned a conversation twice, and a consumer counting conversations (e.g. checking that a peer joined a group while a direct chat already existed) saw a flaky count. Dedup through a set so a conversation held in both stores is listed once regardless of iteration order. Add a DirectV1-then-GroupV2 regression test, which also covers key-package reuse across conversation types. * fix: dedup the GroupV2 add batch to avoid redundant fetches and duplicate invites Both create_group_convo_v2 and group_add_member funnel through GroupV2Convo::add_member, so a duplicate signer (an account that resolves to the same signer twice, or a repeated account) cost a redundant key-package fetch and a second Add proposal. The existing guard skipped only self and already-committed members, which a within-batch duplicate escapes because add_member opens a proposal the committed roster does not yet reflect, stranding a pending_invite that can later fire a spurious duplicate welcome. Dedup the requested signers before fetching, and guard the add loop with a membership set seeded from the roster and self, hoisting the per-iteration members() call out of the loop. * docs: correct the retry-budget and group-add doc comments The retry-budget comment claimed the ~20s init IPC budget held even at the worst-case sum, but that only holds on the load-shed path where each retry returns fast; a fully unreachable registry costs up to MAX_RETRIES times the reqwest timeout, which no retry budget can rescue. State both. Reword add_group_members to name the proposal, commit, and welcome flow rather than the unexplained "once the add commits". * chore: allow clippy::question_mark in LocalBroadcaster::poll (Rust 1.97 FP) Stable rolled to 1.97, whose clippy question_mark flags poll()'s match on `self.shared.borrow().read(next)`. Its suggested `read(next)?` would drop the RefCell Ref guard and dangle the returned reference, so the lint is a false positive here. CI tracks floating stable (`rustup update stable`), so this is pre-existing code newly flagged; suppress it to keep the branch green.
2026-07-09 20:02:04 +02:00
};
Ok((device, claim))
}
/// Decode and verify a message's sender from its credential, checked against the
/// account → device directory (our account store).
///
/// `Ok(sender)` — deliver with the sender; its `account` is set only when the
/// directory confirmed the device, so it is always verified. `Err` — drop the
/// message (including when no credential is present, since every delivered
/// message must carry an explicit sender).
fn decode_sender(
directory: &impl AccountDirectory,
encoded: &[u8],
) -> Result<MessageSender, SenderError> {
let (device, claim) = parse_credential(directory, encoded)?;
match claim {
AccountClaim::None => Ok(MessageSender {
account: None,
local_identity: device,
}),
AccountClaim::Verified(account) => Ok(MessageSender {
account: Some(account),
local_identity: device,
}),
// An unconfirmable account claim drops the message: every delivered
// message must carry a verified sender.
AccountClaim::Unverified(err) => Err(err),
}
}
feat: GroupV2 through the threaded client, group roster, registry retry (#167) * feat: expose GroupV2 through the threaded client GroupV2 (de-mls) conversations were reachable only from Core, and every conversation ran the hardcoded millisecond timer profile in group_v2.rs (20-150 ms freeze/consensus windows), which cannot survive real network latency. Make them reachable through ChatClient (as DirectV1 already is), with timing that holds up over a real network. - ChatClient::create_group_conversation(accounts) and add_group_members(convo_id, accounts): resolve each account address to its endorsed signer ids through the client-held directory, the same resolution create_direct_conversation uses, and drive Core::create_group_convo / group_add_member. - GroupV2 timing/policy is injectable: ServiceContext carries a de_mls::ConversationConfig (re-exported as GroupV2Config) defaulting to the de-mls library defaults; Core::set_group_v2_config and the builder's group_v2_config setter override it. The creator's phase durations reach joiners inside the welcome's ConversationSync, so the group runs the creator's phase timing. - GroupV2Convo::add_member validates every member's key package before proposing any add, skips members de-mls would silently not propose (self, already in the group) instead of stranding a pending invite, and flushes opened proposals even on a mid-batch failure, so a failed batch cannot invite members behind the caller's back. - The millisecond test profile moves into the test harnesses (integration_tests_core's TestHarness, crates/client/tests/group_v2.rs). - New client-level tests: three accounts on the in-process transport create a group, a non-creator adds the third member, and messages fan out with directory-verified senders; a batch containing a member with no key package fails without inviting anyone. * fix: class inbound DirectV1 joins as Private, not Group The joiner of a DirectV1 (pairwise) conversation received it classed as Group, because dispatch_to_inbox2 hardcoded ConversationClass::Group for every InboxV2 join. DirectV1 welcomes (InviteType::GroupV1) and GroupV2 welcomes (InviteType::GroupV2) both arrive over InboxV2, so a plain 1:1 invite surfaced to the display layer as a group. ConversationClass is documented as stable across protocol versions of the same conversation shape, and DirectV1 is the pairwise shape, so its joiner must see Private. - InboxV2::handle_frame returns the class alongside the convo: InviteType::GroupV1 (the DirectV1 welcome carrier) yields Private, InviteType::GroupV2 yields Group. - dispatch_to_inbox2 propagates that class instead of hardcoding Group. - direct_v1_by_account_address asserts the joiner sees Private. * feat: expose a group's roster, deduped to one entry per account The display layer needs a group's membership, but nothing exposed it: de-mls holds the authoritative roster (MLS group state) with no public accessor, and members added by other members stay invisible until they send a message. Rebuilding the roster from observed messages would fork state the crypto layer owns and be wrong exactly when a group grows. - GroupConvo::members() returns each member's hex-encoded MLS leaf-credential content, self included. GroupV2Convo delegates to de-mls and guarantees self-inclusion; GroupV1Convo reads its openmls leaves. - Core::group_members(convo_id) mirrors group_add_member's dispatch: a cached group yields its members, a direct conversation is an UnsupportedFunction, otherwise the group is loaded. - ChatClient::group_members returns Vec<GroupMember>, resolving each member's account claim through the directory. A member whose account claim is unconfirmable is listed by device with account None rather than dropped: it is cryptographically in the group, only the account claim is unproven. The credential parsing decode_sender did is factored into parse_credential and shared by both, leaving decode_sender's stricter drop semantics for message senders unchanged. - Because resolve_device_ids fans an account out to every endorsed device, an account whose devices all join surfaced once per device; group_members dedups by account, keeping the first-seen device as the account's representative. Members with no confirmed account stay individual, keyed by their unique device key. - Unit tests cover the tolerant-vs-drop split and the per-account dedup; the three-member group integration test asserts the roster converges after create and after each add, and a solo group lists only its creator. * feat: retry the registry on transient 5xx with backoff and jitter The keypackage/account registry is reliable request-by-request but sheds concurrent bursts with a 5xx, so several instances registering at once each hard-failed on init. HttpRegistry's four calls now retry network errors and 5xx/429 with exponential backoff and full jitter (the jitter decorrelates concurrent publishers so their retries don't re-collide); 4xx and success return immediately. The total retry window is bounded to a few seconds. * fix: mark InboxV2 key package last-resort so members can join multiple groups A key package's init key is one-time-use: openmls deletes it after the first welcome that consumes it. Each installation registers a single key package, so a second group inviting the same member found no matching key package and rejected the welcome with "welcome not addressed to this member", the flaky group add. Mark the InboxV2 key package as last-resort (and advertise the extension in the leaf capabilities, which key-package validation requires) so openmls retains the init key, letting one key package admit an installation to any number of groups. This reuses one init key for every join, trading per-join forward secrecy for membership that just works. A TODO at the publish site tracks the intended one-time key-package pool (the registry pops one per fetch, the client replenishes) with last-resort as the exhaustion fallback (#169). Add regression tests: a member joining two groups (core harness) and two peers invited to several groups over the threaded client. * fix: dedup list_conversations across the store and the in-memory cache A DirectV1 join persists its conversation to the store and also caches it in memory, so list_conversations saw it twice. It deduped with Vec::dedup, which only drops consecutive repeats, over cached_convos' nondeterministic HashMap order, so the duplicate survived whenever another cached conversation fell between the two copies. list_conversations then intermittently returned a conversation twice, and a consumer counting conversations (e.g. checking that a peer joined a group while a direct chat already existed) saw a flaky count. Dedup through a set so a conversation held in both stores is listed once regardless of iteration order. Add a DirectV1-then-GroupV2 regression test, which also covers key-package reuse across conversation types. * fix: dedup the GroupV2 add batch to avoid redundant fetches and duplicate invites Both create_group_convo_v2 and group_add_member funnel through GroupV2Convo::add_member, so a duplicate signer (an account that resolves to the same signer twice, or a repeated account) cost a redundant key-package fetch and a second Add proposal. The existing guard skipped only self and already-committed members, which a within-batch duplicate escapes because add_member opens a proposal the committed roster does not yet reflect, stranding a pending_invite that can later fire a spurious duplicate welcome. Dedup the requested signers before fetching, and guard the add loop with a membership set seeded from the roster and self, hoisting the per-iteration members() call out of the loop. * docs: correct the retry-budget and group-add doc comments The retry-budget comment claimed the ~20s init IPC budget held even at the worst-case sum, but that only holds on the load-shed path where each retry returns fast; a fully unreachable registry costs up to MAX_RETRIES times the reqwest timeout, which no retry budget can rescue. State both. Reword add_group_members to name the proposal, commit, and welcome flow rather than the unexplained "once the add commits". * chore: allow clippy::question_mark in LocalBroadcaster::poll (Rust 1.97 FP) Stable rolled to 1.97, whose clippy question_mark flags poll()'s match on `self.shared.borrow().read(next)`. Its suggested `read(next)?` would drop the RefCell Ref guard and dangle the returned reference, so the lint is a false positive here. CI tracks floating stable (`rustup update stable`), so this is pre-existing code newly flagged; suppress it to keep the branch green.
2026-07-09 20:02:04 +02:00
/// Map a group member's credential (as reported by MLS, in the same hex-encoded
/// form a message carries as its sender) to a roster entry, tolerating an
/// unconfirmable account claim by listing the device without an account. `None`
/// only when the credential cannot be parsed, which does not happen for a real
/// MLS leaf.
fn roster_member(directory: &impl AccountDirectory, encoded: &[u8]) -> Option<GroupMember> {
let (device, claim) = parse_credential(directory, encoded).ok()?;
let account = match claim {
AccountClaim::Verified(account) => Some(account),
AccountClaim::None | AccountClaim::Unverified(_) => None,
};
Some(GroupMember {
account,
local_identity: device,
})
}
/// The key that decides whether two roster entries are the same member: a
/// verified account, so an account's several devices count once; or, for a
/// member with no confirmed account, its device — unique per MLS leaf, so it
/// never merges with another.
fn member_key(member: &GroupMember) -> &str {
member
.account
.as_ref()
.unwrap_or(&member.local_identity)
.as_str()
}
/// Collapse a roster to one entry per account (keeping the first-seen device as
/// the account's representative) while leaving account-less members individual,
/// order preserved.
fn dedup_members(members: impl IntoIterator<Item = GroupMember>) -> Vec<GroupMember> {
let mut seen = HashSet::new();
members
.into_iter()
.filter(|member| seen.insert(member_key(member).to_owned()))
.collect()
}
fn convo_events(outcome: ConvoOutcome, directory: &impl AccountDirectory) -> Vec<Event> {
let ConvoOutcome {
convo_id,
content,
members_changed,
} = outcome;
let convo_id: Arc<str> = Arc::from(convo_id);
let mut events = Vec::new();
if let Some(c) = content
&& let Ok(sender) = decode_sender(directory, &c.encoded_credential)
{
events.push(Event::MessageReceived {
convo_id: Arc::clone(&convo_id),
content: c.bytes,
sender,
});
}
if members_changed {
events.push(Event::ConversationMembersChanged { convo_id });
}
events
feat: introduce client event system (#106) * chore(flake): accept extra system attr; add perl for openssl-sys build forAllSystems calls the lambda with {system, pkgs}; strict destructuring requires `..` to ignore the system attribute. `pkgs.perl` is needed because openssl-sys is pulled vendored via libsqlite3-sys / rusqlite / chat-sqlite, and its `perl Configure` step needs FindBin.pm, which Fedora's system perl doesn't ship. * feat: introduce client event system - Core processing yields a `PayloadOutcome` enum — `Empty`, `Convo`, or `Inbox`. `ConvoOutcome` carries a conversation id and an optional decrypted `Content`; `InboxOutcome` adds a `NewConversation` (id + `ConversationClass`) for a peer-initiated conversation. - Client translates `PayloadOutcome` into app-facing `Vec<Event>` (`ConversationStarted`, `MessageReceived`) at the boundary, so the application loop sees discrete events rather than core types. - MLS group welcomes produce a `ConversationStarted` event with no initial content, fixing the silent-group-join case where the inbox layer dropped the observation. - C FFI exposes an `EventList` opaque type with indexed accessors and an `Invalid` sentinel for out-of-bounds / non-applicable reads. - Symmetric `Inbox` / `InboxV2` handlers: both return `Result<InboxOutcome, _>` and own the persistence + ephemeral-key cleanup for the conversations they create. - Updated and simplified `docs/adr/0001-client-event-system.md`. * chore(flake): bump nixpkgs to nixos-unstable-small Temporary. The two crates.io UA fixes (NixOS/nixpkgs#512735 for fetchCargoVendor's python-requests UA, NixOS/nixpkgs#524985 for importCargoLock's curl UA) haven't propagated to nixos-unstable yet. Switch to nixos-unstable-small and force logos-delivery to follow so the smoketest gets the same fix. Revert once nixos-unstable catches up. Refs: - https://github.com/rust-lang/crates.io/issues/13482 - https://github.com/rust-lang/crates.io/issues/13783 - https://crates.io/data-access
2026-05-28 23:51:15 +02:00
}
fn inbox_events(outcome: InboxOutcome, directory: &impl AccountDirectory) -> Vec<Event> {
feat: introduce client event system (#106) * chore(flake): accept extra system attr; add perl for openssl-sys build forAllSystems calls the lambda with {system, pkgs}; strict destructuring requires `..` to ignore the system attribute. `pkgs.perl` is needed because openssl-sys is pulled vendored via libsqlite3-sys / rusqlite / chat-sqlite, and its `perl Configure` step needs FindBin.pm, which Fedora's system perl doesn't ship. * feat: introduce client event system - Core processing yields a `PayloadOutcome` enum — `Empty`, `Convo`, or `Inbox`. `ConvoOutcome` carries a conversation id and an optional decrypted `Content`; `InboxOutcome` adds a `NewConversation` (id + `ConversationClass`) for a peer-initiated conversation. - Client translates `PayloadOutcome` into app-facing `Vec<Event>` (`ConversationStarted`, `MessageReceived`) at the boundary, so the application loop sees discrete events rather than core types. - MLS group welcomes produce a `ConversationStarted` event with no initial content, fixing the silent-group-join case where the inbox layer dropped the observation. - C FFI exposes an `EventList` opaque type with indexed accessors and an `Invalid` sentinel for out-of-bounds / non-applicable reads. - Symmetric `Inbox` / `InboxV2` handlers: both return `Result<InboxOutcome, _>` and own the persistence + ephemeral-key cleanup for the conversations they create. - Updated and simplified `docs/adr/0001-client-event-system.md`. * chore(flake): bump nixpkgs to nixos-unstable-small Temporary. The two crates.io UA fixes (NixOS/nixpkgs#512735 for fetchCargoVendor's python-requests UA, NixOS/nixpkgs#524985 for importCargoLock's curl UA) haven't propagated to nixos-unstable yet. Switch to nixos-unstable-small and force logos-delivery to follow so the smoketest gets the same fix. Revert once nixos-unstable catches up. Refs: - https://github.com/rust-lang/crates.io/issues/13482 - https://github.com/rust-lang/crates.io/issues/13783 - https://crates.io/data-access
2026-05-28 23:51:15 +02:00
let InboxOutcome {
new_conversation,
initial,
} = outcome;
let id: Arc<str> = Arc::from(new_conversation.convo_id);
let mut events = Vec::with_capacity(2);
events.push(Event::ConversationStarted {
convo_id: Arc::clone(&id),
class: new_conversation.class,
});
if let Some(c) = initial.and_then(|co| co.content)
&& let Ok(sender) = decode_sender(directory, &c.encoded_credential)
{
feat: introduce client event system (#106) * chore(flake): accept extra system attr; add perl for openssl-sys build forAllSystems calls the lambda with {system, pkgs}; strict destructuring requires `..` to ignore the system attribute. `pkgs.perl` is needed because openssl-sys is pulled vendored via libsqlite3-sys / rusqlite / chat-sqlite, and its `perl Configure` step needs FindBin.pm, which Fedora's system perl doesn't ship. * feat: introduce client event system - Core processing yields a `PayloadOutcome` enum — `Empty`, `Convo`, or `Inbox`. `ConvoOutcome` carries a conversation id and an optional decrypted `Content`; `InboxOutcome` adds a `NewConversation` (id + `ConversationClass`) for a peer-initiated conversation. - Client translates `PayloadOutcome` into app-facing `Vec<Event>` (`ConversationStarted`, `MessageReceived`) at the boundary, so the application loop sees discrete events rather than core types. - MLS group welcomes produce a `ConversationStarted` event with no initial content, fixing the silent-group-join case where the inbox layer dropped the observation. - C FFI exposes an `EventList` opaque type with indexed accessors and an `Invalid` sentinel for out-of-bounds / non-applicable reads. - Symmetric `Inbox` / `InboxV2` handlers: both return `Result<InboxOutcome, _>` and own the persistence + ephemeral-key cleanup for the conversations they create. - Updated and simplified `docs/adr/0001-client-event-system.md`. * chore(flake): bump nixpkgs to nixos-unstable-small Temporary. The two crates.io UA fixes (NixOS/nixpkgs#512735 for fetchCargoVendor's python-requests UA, NixOS/nixpkgs#524985 for importCargoLock's curl UA) haven't propagated to nixos-unstable yet. Switch to nixos-unstable-small and force logos-delivery to follow so the smoketest gets the same fix. Revert once nixos-unstable catches up. Refs: - https://github.com/rust-lang/crates.io/issues/13482 - https://github.com/rust-lang/crates.io/issues/13783 - https://crates.io/data-access
2026-05-28 23:51:15 +02:00
events.push(Event::MessageReceived {
convo_id: Arc::clone(&id),
content: c.bytes,
sender,
feat: introduce client event system (#106) * chore(flake): accept extra system attr; add perl for openssl-sys build forAllSystems calls the lambda with {system, pkgs}; strict destructuring requires `..` to ignore the system attribute. `pkgs.perl` is needed because openssl-sys is pulled vendored via libsqlite3-sys / rusqlite / chat-sqlite, and its `perl Configure` step needs FindBin.pm, which Fedora's system perl doesn't ship. * feat: introduce client event system - Core processing yields a `PayloadOutcome` enum — `Empty`, `Convo`, or `Inbox`. `ConvoOutcome` carries a conversation id and an optional decrypted `Content`; `InboxOutcome` adds a `NewConversation` (id + `ConversationClass`) for a peer-initiated conversation. - Client translates `PayloadOutcome` into app-facing `Vec<Event>` (`ConversationStarted`, `MessageReceived`) at the boundary, so the application loop sees discrete events rather than core types. - MLS group welcomes produce a `ConversationStarted` event with no initial content, fixing the silent-group-join case where the inbox layer dropped the observation. - C FFI exposes an `EventList` opaque type with indexed accessors and an `Invalid` sentinel for out-of-bounds / non-applicable reads. - Symmetric `Inbox` / `InboxV2` handlers: both return `Result<InboxOutcome, _>` and own the persistence + ephemeral-key cleanup for the conversations they create. - Updated and simplified `docs/adr/0001-client-event-system.md`. * chore(flake): bump nixpkgs to nixos-unstable-small Temporary. The two crates.io UA fixes (NixOS/nixpkgs#512735 for fetchCargoVendor's python-requests UA, NixOS/nixpkgs#524985 for importCargoLock's curl UA) haven't propagated to nixos-unstable yet. Switch to nixos-unstable-small and force logos-delivery to follow so the smoketest gets the same fix. Revert once nixos-unstable catches up. Refs: - https://github.com/rust-lang/crates.io/issues/13482 - https://github.com/rust-lang/crates.io/issues/13783 - https://crates.io/data-access
2026-05-28 23:51:15 +02:00
});
}
events
}
#[cfg(test)]
mod sender_check_tests {
use std::collections::HashMap;
use crypto::{Ed25519SigningKey, Ed25519VerifyingKey};
use libchat::IdentId;
use logos_account::{DeviceSet, SignedDeviceBundle};
feat: GroupV2 through the threaded client, group roster, registry retry (#167) * feat: expose GroupV2 through the threaded client GroupV2 (de-mls) conversations were reachable only from Core, and every conversation ran the hardcoded millisecond timer profile in group_v2.rs (20-150 ms freeze/consensus windows), which cannot survive real network latency. Make them reachable through ChatClient (as DirectV1 already is), with timing that holds up over a real network. - ChatClient::create_group_conversation(accounts) and add_group_members(convo_id, accounts): resolve each account address to its endorsed signer ids through the client-held directory, the same resolution create_direct_conversation uses, and drive Core::create_group_convo / group_add_member. - GroupV2 timing/policy is injectable: ServiceContext carries a de_mls::ConversationConfig (re-exported as GroupV2Config) defaulting to the de-mls library defaults; Core::set_group_v2_config and the builder's group_v2_config setter override it. The creator's phase durations reach joiners inside the welcome's ConversationSync, so the group runs the creator's phase timing. - GroupV2Convo::add_member validates every member's key package before proposing any add, skips members de-mls would silently not propose (self, already in the group) instead of stranding a pending invite, and flushes opened proposals even on a mid-batch failure, so a failed batch cannot invite members behind the caller's back. - The millisecond test profile moves into the test harnesses (integration_tests_core's TestHarness, crates/client/tests/group_v2.rs). - New client-level tests: three accounts on the in-process transport create a group, a non-creator adds the third member, and messages fan out with directory-verified senders; a batch containing a member with no key package fails without inviting anyone. * fix: class inbound DirectV1 joins as Private, not Group The joiner of a DirectV1 (pairwise) conversation received it classed as Group, because dispatch_to_inbox2 hardcoded ConversationClass::Group for every InboxV2 join. DirectV1 welcomes (InviteType::GroupV1) and GroupV2 welcomes (InviteType::GroupV2) both arrive over InboxV2, so a plain 1:1 invite surfaced to the display layer as a group. ConversationClass is documented as stable across protocol versions of the same conversation shape, and DirectV1 is the pairwise shape, so its joiner must see Private. - InboxV2::handle_frame returns the class alongside the convo: InviteType::GroupV1 (the DirectV1 welcome carrier) yields Private, InviteType::GroupV2 yields Group. - dispatch_to_inbox2 propagates that class instead of hardcoding Group. - direct_v1_by_account_address asserts the joiner sees Private. * feat: expose a group's roster, deduped to one entry per account The display layer needs a group's membership, but nothing exposed it: de-mls holds the authoritative roster (MLS group state) with no public accessor, and members added by other members stay invisible until they send a message. Rebuilding the roster from observed messages would fork state the crypto layer owns and be wrong exactly when a group grows. - GroupConvo::members() returns each member's hex-encoded MLS leaf-credential content, self included. GroupV2Convo delegates to de-mls and guarantees self-inclusion; GroupV1Convo reads its openmls leaves. - Core::group_members(convo_id) mirrors group_add_member's dispatch: a cached group yields its members, a direct conversation is an UnsupportedFunction, otherwise the group is loaded. - ChatClient::group_members returns Vec<GroupMember>, resolving each member's account claim through the directory. A member whose account claim is unconfirmable is listed by device with account None rather than dropped: it is cryptographically in the group, only the account claim is unproven. The credential parsing decode_sender did is factored into parse_credential and shared by both, leaving decode_sender's stricter drop semantics for message senders unchanged. - Because resolve_device_ids fans an account out to every endorsed device, an account whose devices all join surfaced once per device; group_members dedups by account, keeping the first-seen device as the account's representative. Members with no confirmed account stay individual, keyed by their unique device key. - Unit tests cover the tolerant-vs-drop split and the per-account dedup; the three-member group integration test asserts the roster converges after create and after each add, and a solo group lists only its creator. * feat: retry the registry on transient 5xx with backoff and jitter The keypackage/account registry is reliable request-by-request but sheds concurrent bursts with a 5xx, so several instances registering at once each hard-failed on init. HttpRegistry's four calls now retry network errors and 5xx/429 with exponential backoff and full jitter (the jitter decorrelates concurrent publishers so their retries don't re-collide); 4xx and success return immediately. The total retry window is bounded to a few seconds. * fix: mark InboxV2 key package last-resort so members can join multiple groups A key package's init key is one-time-use: openmls deletes it after the first welcome that consumes it. Each installation registers a single key package, so a second group inviting the same member found no matching key package and rejected the welcome with "welcome not addressed to this member", the flaky group add. Mark the InboxV2 key package as last-resort (and advertise the extension in the leaf capabilities, which key-package validation requires) so openmls retains the init key, letting one key package admit an installation to any number of groups. This reuses one init key for every join, trading per-join forward secrecy for membership that just works. A TODO at the publish site tracks the intended one-time key-package pool (the registry pops one per fetch, the client replenishes) with last-resort as the exhaustion fallback (#169). Add regression tests: a member joining two groups (core harness) and two peers invited to several groups over the threaded client. * fix: dedup list_conversations across the store and the in-memory cache A DirectV1 join persists its conversation to the store and also caches it in memory, so list_conversations saw it twice. It deduped with Vec::dedup, which only drops consecutive repeats, over cached_convos' nondeterministic HashMap order, so the duplicate survived whenever another cached conversation fell between the two copies. list_conversations then intermittently returned a conversation twice, and a consumer counting conversations (e.g. checking that a peer joined a group while a direct chat already existed) saw a flaky count. Dedup through a set so a conversation held in both stores is listed once regardless of iteration order. Add a DirectV1-then-GroupV2 regression test, which also covers key-package reuse across conversation types. * fix: dedup the GroupV2 add batch to avoid redundant fetches and duplicate invites Both create_group_convo_v2 and group_add_member funnel through GroupV2Convo::add_member, so a duplicate signer (an account that resolves to the same signer twice, or a repeated account) cost a redundant key-package fetch and a second Add proposal. The existing guard skipped only self and already-committed members, which a within-batch duplicate escapes because add_member opens a proposal the committed roster does not yet reflect, stranding a pending_invite that can later fire a spurious duplicate welcome. Dedup the requested signers before fetching, and guard the add loop with a membership set seeded from the roster and self, hoisting the per-iteration members() call out of the loop. * docs: correct the retry-budget and group-add doc comments The retry-budget comment claimed the ~20s init IPC budget held even at the worst-case sum, but that only holds on the load-shed path where each retry returns fast; a fully unreachable registry costs up to MAX_RETRIES times the reqwest timeout, which no retry budget can rescue. State both. Reword add_group_members to name the proposal, commit, and welcome flow rather than the unexplained "once the add commits". * chore: allow clippy::question_mark in LocalBroadcaster::poll (Rust 1.97 FP) Stable rolled to 1.97, whose clippy question_mark flags poll()'s match on `self.shared.borrow().read(next)`. Its suggested `read(next)?` would drop the RefCell Ref guard and dangle the returned reference, so the lint is a false positive here. CI tracks floating stable (`rustup update stable`), so this is pre-existing code newly flagged; suppress it to keep the branch green.
2026-07-09 20:02:04 +02:00
use super::{
GroupMember, MessageSender, SenderError, decode_sender, dedup_members, member_key,
roster_member,
};
use crate::delegate::DelegateCredential;
/// In-test account → device directory. Holds device id sets keyed by the hex
/// account key, and can be made to fail to simulate a directory outage.
#[derive(Debug, Default)]
struct FakeDir {
bundles: HashMap<String, Vec<String>>,
fail: bool,
}
impl FakeDir {
/// Publish `devices` (verifying keys) as `account`'s device set.
fn with_devices(account: &Ed25519VerifyingKey, devices: &[&Ed25519VerifyingKey]) -> Self {
let mut bundles = HashMap::new();
bundles.insert(
hex::encode(account.as_ref()),
devices.iter().map(|d| hex::encode(d.as_ref())).collect(),
);
Self {
bundles,
fail: false,
}
}
}
impl logos_account::AccountDirectory for FakeDir {
type Error = &'static str;
fn publish(&mut self, _: &SignedDeviceBundle) -> Result<(), Self::Error> {
Ok(())
}
fn fetch(&self, account: &Ed25519VerifyingKey) -> Result<Option<DeviceSet>, Self::Error> {
if self.fail {
return Err("directory unavailable");
}
Ok(self
.bundles
.get(&hex::encode(account.as_ref()))
.map(|devices| DeviceSet {
lamport: 1,
devices: devices.clone(),
}))
}
}
fn key() -> Ed25519VerifyingKey {
Ed25519SigningKey::generate().verifying_key()
}
/// Encode a credential exactly as it travels on the wire: the hex of the
/// serialized TLV, matching the MLS leaf credential's content bytes.
fn encoded(cred: DelegateCredential) -> Vec<u8> {
hex::encode(cred.serialize()).into_bytes()
}
fn local_id(k: &Ed25519VerifyingKey) -> IdentId {
IdentId::new(hex::encode(k.as_ref()))
}
/// The account published a device set that includes the sending device — the
/// claim checks out, so the message is delivered with a verified account.
#[test]
fn verified_sender_surfaces_account_and_device() {
let account = key();
let device = key();
let dir = FakeDir::with_devices(&account, &[&device]);
let cred = DelegateCredential::associated(&device, &hex::encode(account.as_ref()));
assert_eq!(
decode_sender(&dir, &encoded(cred)),
Ok(MessageSender {
account: Some(local_id(&account)),
local_identity: local_id(&device),
})
);
}
/// The account published a device set that does NOT include the sending
/// device — a spoofed account claim, so the message is dropped.
#[test]
fn contradicted_claim_is_dropped() {
let account = key();
let endorsed = key();
let spoofer = key();
let dir = FakeDir::with_devices(&account, &[&endorsed]);
let cred = DelegateCredential::associated(&spoofer, &hex::encode(account.as_ref()));
assert_eq!(
decode_sender(&dir, &encoded(cred)),
Err(SenderError::Unverified)
);
}
/// A delegate that claims no account surfaces its device but no account.
#[test]
fn unassociated_sender_surfaces_device_only() {
let dir = FakeDir::default();
let device = key();
let cred = DelegateCredential::unassociated(&device);
assert_eq!(
decode_sender(&dir, &encoded(cred)),
Ok(MessageSender {
account: None,
local_identity: local_id(&device),
})
);
}
/// The claimed account has never published a device set — the mapping is
/// missing, so the message is dropped.
#[test]
fn unpublished_account_is_dropped() {
let account = key();
let device = key();
let dir = FakeDir::default(); // nothing published
let cred = DelegateCredential::associated(&device, &hex::encode(account.as_ref()));
assert_eq!(
decode_sender(&dir, &encoded(cred)),
Err(SenderError::Unverified)
);
}
/// A directory outage leaves the mapping unconfirmed, so the message is
/// dropped rather than delivered on an unverified claim.
#[test]
fn directory_error_is_dropped() {
let account = key();
let device = key();
let dir = FakeDir {
fail: true,
..Default::default()
};
let cred = DelegateCredential::associated(&device, &hex::encode(account.as_ref()));
assert_eq!(
decode_sender(&dir, &encoded(cred)),
Err(SenderError::Unverified)
);
}
/// An empty credential leaves no sender to attribute, so the message is dropped.
#[test]
fn empty_credential_is_dropped() {
let dir = FakeDir::default();
assert_eq!(decode_sender(&dir, b""), Err(SenderError::Missing));
}
/// Bytes that aren't a well-formed credential leave the sender's mapping
/// undeterminable, so the message is dropped.
#[test]
fn malformed_credential_is_dropped() {
let dir = FakeDir::default();
assert_eq!(decode_sender(&dir, b"not hex"), Err(SenderError::NotHex));
assert_eq!(
decode_sender(&dir, hex::encode([0u8; 4]).as_bytes()),
Err(SenderError::Malformed)
);
}
/// An account address that isn't a verifying key can't be looked up, so the
/// claim is unconfirmable and the message is dropped.
#[test]
fn non_key_account_address_is_dropped() {
let dir = FakeDir::default();
let cred = DelegateCredential::associated(&key(), "user@example.com");
assert_eq!(
decode_sender(&dir, &encoded(cred)),
Err(SenderError::AccountNotAKey)
);
}
feat: GroupV2 through the threaded client, group roster, registry retry (#167) * feat: expose GroupV2 through the threaded client GroupV2 (de-mls) conversations were reachable only from Core, and every conversation ran the hardcoded millisecond timer profile in group_v2.rs (20-150 ms freeze/consensus windows), which cannot survive real network latency. Make them reachable through ChatClient (as DirectV1 already is), with timing that holds up over a real network. - ChatClient::create_group_conversation(accounts) and add_group_members(convo_id, accounts): resolve each account address to its endorsed signer ids through the client-held directory, the same resolution create_direct_conversation uses, and drive Core::create_group_convo / group_add_member. - GroupV2 timing/policy is injectable: ServiceContext carries a de_mls::ConversationConfig (re-exported as GroupV2Config) defaulting to the de-mls library defaults; Core::set_group_v2_config and the builder's group_v2_config setter override it. The creator's phase durations reach joiners inside the welcome's ConversationSync, so the group runs the creator's phase timing. - GroupV2Convo::add_member validates every member's key package before proposing any add, skips members de-mls would silently not propose (self, already in the group) instead of stranding a pending invite, and flushes opened proposals even on a mid-batch failure, so a failed batch cannot invite members behind the caller's back. - The millisecond test profile moves into the test harnesses (integration_tests_core's TestHarness, crates/client/tests/group_v2.rs). - New client-level tests: three accounts on the in-process transport create a group, a non-creator adds the third member, and messages fan out with directory-verified senders; a batch containing a member with no key package fails without inviting anyone. * fix: class inbound DirectV1 joins as Private, not Group The joiner of a DirectV1 (pairwise) conversation received it classed as Group, because dispatch_to_inbox2 hardcoded ConversationClass::Group for every InboxV2 join. DirectV1 welcomes (InviteType::GroupV1) and GroupV2 welcomes (InviteType::GroupV2) both arrive over InboxV2, so a plain 1:1 invite surfaced to the display layer as a group. ConversationClass is documented as stable across protocol versions of the same conversation shape, and DirectV1 is the pairwise shape, so its joiner must see Private. - InboxV2::handle_frame returns the class alongside the convo: InviteType::GroupV1 (the DirectV1 welcome carrier) yields Private, InviteType::GroupV2 yields Group. - dispatch_to_inbox2 propagates that class instead of hardcoding Group. - direct_v1_by_account_address asserts the joiner sees Private. * feat: expose a group's roster, deduped to one entry per account The display layer needs a group's membership, but nothing exposed it: de-mls holds the authoritative roster (MLS group state) with no public accessor, and members added by other members stay invisible until they send a message. Rebuilding the roster from observed messages would fork state the crypto layer owns and be wrong exactly when a group grows. - GroupConvo::members() returns each member's hex-encoded MLS leaf-credential content, self included. GroupV2Convo delegates to de-mls and guarantees self-inclusion; GroupV1Convo reads its openmls leaves. - Core::group_members(convo_id) mirrors group_add_member's dispatch: a cached group yields its members, a direct conversation is an UnsupportedFunction, otherwise the group is loaded. - ChatClient::group_members returns Vec<GroupMember>, resolving each member's account claim through the directory. A member whose account claim is unconfirmable is listed by device with account None rather than dropped: it is cryptographically in the group, only the account claim is unproven. The credential parsing decode_sender did is factored into parse_credential and shared by both, leaving decode_sender's stricter drop semantics for message senders unchanged. - Because resolve_device_ids fans an account out to every endorsed device, an account whose devices all join surfaced once per device; group_members dedups by account, keeping the first-seen device as the account's representative. Members with no confirmed account stay individual, keyed by their unique device key. - Unit tests cover the tolerant-vs-drop split and the per-account dedup; the three-member group integration test asserts the roster converges after create and after each add, and a solo group lists only its creator. * feat: retry the registry on transient 5xx with backoff and jitter The keypackage/account registry is reliable request-by-request but sheds concurrent bursts with a 5xx, so several instances registering at once each hard-failed on init. HttpRegistry's four calls now retry network errors and 5xx/429 with exponential backoff and full jitter (the jitter decorrelates concurrent publishers so their retries don't re-collide); 4xx and success return immediately. The total retry window is bounded to a few seconds. * fix: mark InboxV2 key package last-resort so members can join multiple groups A key package's init key is one-time-use: openmls deletes it after the first welcome that consumes it. Each installation registers a single key package, so a second group inviting the same member found no matching key package and rejected the welcome with "welcome not addressed to this member", the flaky group add. Mark the InboxV2 key package as last-resort (and advertise the extension in the leaf capabilities, which key-package validation requires) so openmls retains the init key, letting one key package admit an installation to any number of groups. This reuses one init key for every join, trading per-join forward secrecy for membership that just works. A TODO at the publish site tracks the intended one-time key-package pool (the registry pops one per fetch, the client replenishes) with last-resort as the exhaustion fallback (#169). Add regression tests: a member joining two groups (core harness) and two peers invited to several groups over the threaded client. * fix: dedup list_conversations across the store and the in-memory cache A DirectV1 join persists its conversation to the store and also caches it in memory, so list_conversations saw it twice. It deduped with Vec::dedup, which only drops consecutive repeats, over cached_convos' nondeterministic HashMap order, so the duplicate survived whenever another cached conversation fell between the two copies. list_conversations then intermittently returned a conversation twice, and a consumer counting conversations (e.g. checking that a peer joined a group while a direct chat already existed) saw a flaky count. Dedup through a set so a conversation held in both stores is listed once regardless of iteration order. Add a DirectV1-then-GroupV2 regression test, which also covers key-package reuse across conversation types. * fix: dedup the GroupV2 add batch to avoid redundant fetches and duplicate invites Both create_group_convo_v2 and group_add_member funnel through GroupV2Convo::add_member, so a duplicate signer (an account that resolves to the same signer twice, or a repeated account) cost a redundant key-package fetch and a second Add proposal. The existing guard skipped only self and already-committed members, which a within-batch duplicate escapes because add_member opens a proposal the committed roster does not yet reflect, stranding a pending_invite that can later fire a spurious duplicate welcome. Dedup the requested signers before fetching, and guard the add loop with a membership set seeded from the roster and self, hoisting the per-iteration members() call out of the loop. * docs: correct the retry-budget and group-add doc comments The retry-budget comment claimed the ~20s init IPC budget held even at the worst-case sum, but that only holds on the load-shed path where each retry returns fast; a fully unreachable registry costs up to MAX_RETRIES times the reqwest timeout, which no retry budget can rescue. State both. Reword add_group_members to name the proposal, commit, and welcome flow rather than the unexplained "once the add commits". * chore: allow clippy::question_mark in LocalBroadcaster::poll (Rust 1.97 FP) Stable rolled to 1.97, whose clippy question_mark flags poll()'s match on `self.shared.borrow().read(next)`. Its suggested `read(next)?` would drop the RefCell Ref guard and dangle the returned reference, so the lint is a false positive here. CI tracks floating stable (`rustup update stable`), so this is pre-existing code newly flagged; suppress it to keep the branch green.
2026-07-09 20:02:04 +02:00
/// A verified account claim surfaces the member's account and device — the
/// same happy path as a message sender.
#[test]
fn roster_verified_member_surfaces_account() {
let account = key();
let device = key();
let dir = FakeDir::with_devices(&account, &[&device]);
let cred = DelegateCredential::associated(&device, &hex::encode(account.as_ref()));
assert_eq!(
roster_member(&dir, &encoded(cred)),
Some(GroupMember {
account: Some(local_id(&account)),
local_identity: local_id(&device),
})
);
}
/// Unlike a message sender, a spoofed account claim does not hide the
/// member: the device is cryptographically in the group, so it is listed
/// with no account rather than dropped.
#[test]
fn roster_contradicted_claim_lists_device_without_account() {
let account = key();
let endorsed = key();
let spoofer = key();
let dir = FakeDir::with_devices(&account, &[&endorsed]);
let cred = DelegateCredential::associated(&spoofer, &hex::encode(account.as_ref()));
assert_eq!(
roster_member(&dir, &encoded(cred)),
Some(GroupMember {
account: None,
local_identity: local_id(&spoofer),
})
);
}
/// A member whose credential claims no account is listed by device only.
#[test]
fn roster_unassociated_member_lists_device_without_account() {
let dir = FakeDir::default();
let device = key();
let cred = DelegateCredential::unassociated(&device);
assert_eq!(
roster_member(&dir, &encoded(cred)),
Some(GroupMember {
account: None,
local_identity: local_id(&device),
})
);
}
/// A directory outage leaves the account unconfirmed, but the member stays
/// on the roster by device (a message would drop here).
#[test]
fn roster_directory_outage_lists_device_without_account() {
let account = key();
let device = key();
let dir = FakeDir {
fail: true,
..Default::default()
};
let cred = DelegateCredential::associated(&device, &hex::encode(account.as_ref()));
assert_eq!(
roster_member(&dir, &encoded(cred)),
Some(GroupMember {
account: None,
local_identity: local_id(&device),
})
);
}
/// A non-key account address can't be confirmed, so the member is listed by
/// device without an account.
#[test]
fn roster_non_key_account_lists_device_without_account() {
let dir = FakeDir::default();
let device = key();
let cred = DelegateCredential::associated(&device, "user@example.com");
assert_eq!(
roster_member(&dir, &encoded(cred)),
Some(GroupMember {
account: None,
local_identity: local_id(&device),
})
);
}
/// The roster collapses an account's several devices into one entry (keeping
/// the first device seen) while leaving account-less members individual,
/// order preserved.
#[test]
fn dedup_collapses_account_devices_and_keeps_unknowns() {
let with_account = |account: &str, device: &str| GroupMember {
account: Some(IdentId::new(account.to_string())),
local_identity: IdentId::new(device.to_string()),
};
let device_only = |device: &str| GroupMember {
account: None,
local_identity: IdentId::new(device.to_string()),
};
let roster = dedup_members(vec![
with_account("alice", "alice-dev-1"),
with_account("alice", "alice-dev-2"),
device_only("orphan-x"),
with_account("bob", "bob-dev-1"),
device_only("orphan-y"),
]);
let keys: Vec<&str> = roster.iter().map(member_key).collect();
assert_eq!(keys, ["alice", "orphan-x", "bob", "orphan-y"]);
// Alice's collapsed entry keeps her first-seen device.
assert_eq!(roster[0].local_identity.as_str(), "alice-dev-1");
}
}