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;
|
2026-06-11 10:08:07 +02:00
|
|
|
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
|
|
|
|
2026-06-23 12:02:01 -07:00
|
|
|
use components::{ThreadedWakeupService, WakeupEvent};
|
2026-06-11 10:08:07 +02:00
|
|
|
use crossbeam_channel::{Receiver, Sender, select};
|
2026-06-23 13:40:19 +08:00
|
|
|
use crypto::Ed25519VerifyingKey;
|
2026-04-08 23:15:48 +02:00
|
|
|
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, Introduction, PayloadOutcome, RegistrationService,
|
2026-04-08 23:15:48 +02:00
|
|
|
};
|
2026-07-03 23:18:10 +02:00
|
|
|
use logos_account::{AccountDirectory, resolve_device_ids};
|
2026-06-11 10:08:07 +02:00
|
|
|
use parking_lot::Mutex;
|
2026-06-23 12:02:01 -07:00
|
|
|
use storage::ChatStore;
|
2026-05-19 11:54:54 -07:00
|
|
|
|
2026-07-03 23:18:10 +02:00
|
|
|
use crate::delegate::{DelegateCredential, DelegateIdentity, DelegateSigner};
|
2026-05-19 11:54:54 -07:00
|
|
|
use crate::errors::ClientError;
|
2026-06-24 11:48:50 +08:00
|
|
|
use crate::event::{Event, MessageSender};
|
2026-04-08 23:15:48 +02:00
|
|
|
|
2026-07-03 23:18:10 +02:00
|
|
|
type ClientCore<T, R, S> = Core<(DelegateIdentity, T, R, ThreadedWakeupService, S)>;
|
2026-06-20 09:44:27 -07:00
|
|
|
type AccountAddressRef<'a> = &'a str;
|
|
|
|
|
type LocalSignerId = IdentId;
|
2026-06-11 10:08:07 +02:00
|
|
|
|
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,
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-11 10:08:07 +02:00
|
|
|
/// 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.
|
2026-07-03 23:18:10 +02:00
|
|
|
pub struct ChatClient<T, R, S>
|
2026-06-23 12:02:01 -07:00
|
|
|
where
|
|
|
|
|
T: Transport + Send + 'static,
|
2026-07-03 23:18:10 +02:00
|
|
|
R: RegistrationService + AccountDirectory + Clone + Send + 'static,
|
2026-06-23 12:02:01 -07:00
|
|
|
S: ChatStore + Send + 'static,
|
|
|
|
|
{
|
2026-06-11 10:08:07 +02:00
|
|
|
/// `parking_lot::Mutex` for its eventual fairness: an inbound burst can't
|
|
|
|
|
/// starve caller operations of the lock.
|
2026-07-03 23:18:10 +02:00
|
|
|
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,
|
2026-06-11 10:08:07 +02:00
|
|
|
/// Dropped on `Drop` to wake the worker's `select!` and shut it down.
|
|
|
|
|
shutdown: Option<Sender<()>>,
|
|
|
|
|
worker: Option<JoinHandle<()>>,
|
2026-06-23 12:02:01 -07:00
|
|
|
address: String,
|
2026-03-24 18:21:00 -07:00
|
|
|
}
|
|
|
|
|
|
2026-06-23 12:02:01 -07:00
|
|
|
// -- GenericChatClient
|
2026-07-03 23:18:10 +02:00
|
|
|
impl<T, R, S> ChatClient<T, R, S>
|
2026-06-04 10:09:29 +08:00
|
|
|
where
|
2026-06-22 10:38:17 -07:00
|
|
|
T: Transport + Send + 'static,
|
2026-07-03 23:18:10 +02:00
|
|
|
R: RegistrationService + AccountDirectory + Clone + Send + 'static,
|
2026-06-23 12:02:01 -07:00
|
|
|
S: ChatStore + Send + 'static,
|
2026-06-04 10:09:29 +08:00
|
|
|
{
|
2026-06-23 12:02:01 -07:00
|
|
|
pub fn new(
|
2026-07-03 23:18:10 +02:00
|
|
|
ident: DelegateSigner,
|
|
|
|
|
account: String,
|
2026-06-22 10:38:17 -07:00
|
|
|
mut transport: T,
|
|
|
|
|
reg: R,
|
2026-06-23 12:02:01 -07:00
|
|
|
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>,
|
2026-06-22 10:38:17 -07:00
|
|
|
) -> Result<(Self, Receiver<Event>), ClientError> {
|
|
|
|
|
let inbound = transport.inbound();
|
|
|
|
|
|
|
|
|
|
let (wakeup_tx, wakeup_rx) = crossbeam_channel::unbounded();
|
|
|
|
|
let wakeup_service = ThreadedWakeupService::new(wakeup_tx);
|
2026-07-03 23:18:10 +02:00
|
|
|
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);
|
|
|
|
|
}
|
2026-07-03 23:18:10 +02:00
|
|
|
Ok(Self::spawn(core, directory, account, inbound, wakeup_rx))
|
2026-06-22 10:38:17 -07:00
|
|
|
}
|
|
|
|
|
|
2026-06-15 13:15:18 -07:00
|
|
|
fn spawn(
|
2026-07-03 23:18:10 +02:00
|
|
|
core: ClientCore<T, R, S>,
|
|
|
|
|
directory: R,
|
|
|
|
|
address: String,
|
2026-06-15 13:15:18 -07:00
|
|
|
inbound: Receiver<Vec<u8>>,
|
|
|
|
|
wakeup_events: Receiver<WakeupEvent>,
|
|
|
|
|
) -> (Self, Receiver<Event>) {
|
2026-06-11 10:08:07 +02:00
|
|
|
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);
|
2026-07-03 23:18:10 +02:00
|
|
|
let directory = directory.clone();
|
|
|
|
|
move || {
|
|
|
|
|
worker_loop(
|
|
|
|
|
core,
|
|
|
|
|
directory,
|
|
|
|
|
inbound,
|
|
|
|
|
wakeup_events,
|
|
|
|
|
shutdown_rx,
|
|
|
|
|
event_tx,
|
|
|
|
|
)
|
|
|
|
|
}
|
2026-06-11 10:08:07 +02:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
(
|
|
|
|
|
Self {
|
|
|
|
|
core,
|
2026-07-03 23:18:10 +02:00
|
|
|
directory,
|
2026-06-11 10:08:07 +02:00
|
|
|
shutdown: Some(shutdown_tx),
|
|
|
|
|
worker: Some(worker),
|
2026-06-23 12:02:01 -07:00
|
|
|
address,
|
2026-06-11 10:08:07 +02:00
|
|
|
},
|
|
|
|
|
event_rx,
|
|
|
|
|
)
|
2026-06-04 10:09:29 +08:00
|
|
|
}
|
2026-04-08 23:15:48 +02:00
|
|
|
|
2026-07-03 23:18:10 +02:00
|
|
|
/// The account address peers use to reach this client.
|
|
|
|
|
pub fn addr(&self) -> &str {
|
2026-06-23 12:02:01 -07:00
|
|
|
&self.address
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-08 23:15:48 +02:00
|
|
|
/// Returns the installation name (identity label) of this client.
|
2026-06-11 10:08:07 +02:00
|
|
|
pub fn installation_name(&self) -> String {
|
|
|
|
|
self.core.lock().installation_name().to_string()
|
2026-04-08 23:15:48 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Produce a serialised introduction bundle for sharing out-of-band.
|
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 create_intro_bundle(&mut self) -> Result<Vec<u8>, ClientError> {
|
2026-06-11 10:08:07 +02:00
|
|
|
self.core.lock().create_intro_bundle().map_err(Into::into)
|
2026-04-08 23:15:48 +02:00
|
|
|
}
|
|
|
|
|
|
2026-06-20 09:44:27 -07:00
|
|
|
// 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))
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-11 10:08:07 +02:00
|
|
|
/// Parse intro bundle bytes and initiate a private conversation. Outbound
|
|
|
|
|
/// envelopes are published by the core. Returns this side's conversation ID.
|
2026-06-20 09:44:27 -07:00
|
|
|
///
|
|
|
|
|
/// This function will be deprecated in the future. Use `create_direct_conversation`
|
2026-04-08 23:15:48 +02:00
|
|
|
pub fn create_conversation(
|
|
|
|
|
&mut self,
|
|
|
|
|
intro_bundle: &[u8],
|
|
|
|
|
initial_content: &[u8],
|
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
|
|
|
) -> Result<ConversationId, ClientError> {
|
2026-04-08 23:15:48 +02:00
|
|
|
let intro = Introduction::try_from(intro_bundle)?;
|
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
|
|
|
self.core
|
2026-06-11 10:08:07 +02:00
|
|
|
.lock()
|
2026-06-19 12:01:17 -07:00
|
|
|
.create_private_convo_v1(&intro, initial_content)
|
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
|
|
|
.map_err(Into::into)
|
2026-04-08 23:15:48 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// 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> {
|
2026-06-11 10:08:07 +02:00
|
|
|
self.core.lock().list_conversations().map_err(Into::into)
|
2026-04-08 23:15:48 +02:00
|
|
|
}
|
|
|
|
|
|
2026-06-11 10:08:07 +02:00
|
|
|
/// 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
|
2026-06-11 10:08:07 +02:00
|
|
|
.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)
|
2026-04-08 23:15:48 +02:00
|
|
|
}
|
2026-06-20 09:44:27 -07:00
|
|
|
|
2026-07-03 23:18:10 +02:00
|
|
|
/// 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.
|
2026-06-20 09:44:27 -07:00
|
|
|
fn signers_from_account(
|
|
|
|
|
&self,
|
|
|
|
|
account: AccountAddressRef,
|
|
|
|
|
) -> Result<Vec<LocalSignerId>, ClientError> {
|
2026-07-03 23:18:10 +02:00
|
|
|
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())
|
2026-06-20 09:44:27 -07:00
|
|
|
}
|
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)
|
|
|
|
|
}
|
2026-06-11 10:08:07 +02:00
|
|
|
}
|
|
|
|
|
|
2026-07-03 23:18:10 +02:00
|
|
|
impl<T, R, S> Drop for ChatClient<T, R, S>
|
2026-06-23 12:02:01 -07:00
|
|
|
where
|
|
|
|
|
T: Transport + Send + 'static,
|
2026-07-03 23:18:10 +02:00
|
|
|
R: RegistrationService + AccountDirectory + Clone + Send + 'static,
|
2026-06-23 12:02:01 -07:00
|
|
|
S: ChatStore + Send + 'static,
|
|
|
|
|
{
|
2026-06-11 10:08:07 +02:00
|
|
|
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();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-04-08 23:15:48 +02:00
|
|
|
|
2026-06-11 10:08:07 +02:00
|
|
|
/// 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.
|
2026-07-03 23:18:10 +02:00
|
|
|
fn worker_loop<T, R, S: ChatStore + 'static>(
|
|
|
|
|
core: Arc<Mutex<ClientCore<T, R, S>>>,
|
|
|
|
|
directory: R,
|
2026-06-11 10:08:07 +02:00
|
|
|
inbound: Receiver<Vec<u8>>,
|
2026-06-15 13:15:18 -07:00
|
|
|
wakeup_events: Receiver<WakeupEvent>,
|
2026-06-11 10:08:07 +02:00
|
|
|
shutdown: Receiver<()>,
|
|
|
|
|
event_tx: Sender<Event>,
|
|
|
|
|
) where
|
|
|
|
|
T: DeliveryService + Send + 'static,
|
2026-07-03 23:18:10 +02:00
|
|
|
R: RegistrationService + AccountDirectory + Send + 'static,
|
2026-06-11 10:08:07 +02:00
|
|
|
{
|
|
|
|
|
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) {
|
2026-07-03 23:18:10 +02:00
|
|
|
Ok(outcome) => events_from_inbound(outcome, &directory),
|
2026-06-11 10:08:07 +02:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-06-15 13:15:18 -07:00
|
|
|
recv(wakeup_events) -> msg => {
|
|
|
|
|
let Ok(WakeupEvent { convo_id }) = msg else {
|
|
|
|
|
return; // wakeup service's sender dropped
|
|
|
|
|
};
|
feat: surface a roster-changed event when group membership changes (#177)
A group's roster changed silently. An add merges on the steward's commit-inactivity timer, and the members that receive its commit apply it, but neither surfaced any observation: Core::wakeup returned () and the client worker discarded it, and GroupV2Convo mapped only chat messages into a ConvoOutcome, so a commit produced an empty outcome. An app could learn a group grew only by re-selecting the conversation, a manual refresh, or a later message from the new member.
de-mls already reports the change: CommitApplied (adds and removes) and WelcomeReady (adds) both fire, on every member, when a commit merges. Drain them into the observation and emit a new event.
- ConvoOutcome gains members_changed, set by GroupV2Convo when a poll cycle's drained de-mls events include CommitApplied or WelcomeReady. It rides alongside content, the same way a protocol-only frame already yields content: None.
- Convo::wakeup returns a ConvoOutcome instead of (), mirroring handle_frame, so the steward's own timer-driven commit is observable. Kinds with no timers return ConvoOutcome::empty; Core::wakeup and the worker translate it through the same events_from_inbound path inbound payloads use.
- New Event::ConversationMembersChanged { convo_id }: the app re-fetches group_members. Fires on every member a commit reaches, so the inviter sees its own add land and existing members see later joins.
2026-07-15 17:21:23 +02:00
|
|
|
// 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
|
|
|
|
|
}
|
2026-06-15 13:15:18 -07:00
|
|
|
}
|
|
|
|
|
}
|
2026-06-11 10:08:07 +02:00
|
|
|
recv(shutdown) -> _ => return,
|
|
|
|
|
}
|
2026-04-08 23:15:48 +02:00
|
|
|
}
|
2026-03-24 18:21:00 -07:00
|
|
|
}
|
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
|
|
|
|
2026-06-11 10:08:07 +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.
|
2026-06-23 13:40:19 +08:00
|
|
|
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(),
|
2026-06-23 13:40:19 +08:00
|
|
|
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
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-23 13:40:19 +08: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()
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-24 11:48:50 +08:00
|
|
|
/// 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).
|
2026-06-23 13:40:19 +08:00
|
|
|
///
|
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(
|
2026-06-24 11:48:50 +08:00
|
|
|
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.
|
2026-06-23 13:40:19 +08:00
|
|
|
if encoded.is_empty() {
|
2026-06-24 11:48:50 +08:00
|
|
|
return Err(SenderError::Missing);
|
2026-06-23 13:40:19 +08:00
|
|
|
}
|
|
|
|
|
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");
|
2026-06-24 11:48:50 +08:00
|
|
|
return Err(SenderError::NotHex);
|
2026-06-23 13:40:19 +08:00
|
|
|
};
|
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);
|
2026-06-23 13:40:19 +08:00
|
|
|
};
|
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()));
|
2026-06-23 13:40:19 +08:00
|
|
|
// 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));
|
2026-06-23 13:40:19 +08:00
|
|
|
};
|
|
|
|
|
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),
|
|
|
|
|
));
|
2026-06-23 13:40:19 +08:00
|
|
|
};
|
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()))
|
|
|
|
|
}
|
2026-06-23 13:40:19 +08:00
|
|
|
_ => {
|
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)
|
2026-06-23 13:40:19 +08:00
|
|
|
}
|
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),
|
2026-06-22 10:38:17 -07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
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()
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-23 13:40:19 +08:00
|
|
|
fn convo_events(outcome: ConvoOutcome, directory: &impl AccountDirectory) -> Vec<Event> {
|
feat: surface a roster-changed event when group membership changes (#177)
A group's roster changed silently. An add merges on the steward's commit-inactivity timer, and the members that receive its commit apply it, but neither surfaced any observation: Core::wakeup returned () and the client worker discarded it, and GroupV2Convo mapped only chat messages into a ConvoOutcome, so a commit produced an empty outcome. An app could learn a group grew only by re-selecting the conversation, a manual refresh, or a later message from the new member.
de-mls already reports the change: CommitApplied (adds and removes) and WelcomeReady (adds) both fire, on every member, when a commit merges. Drain them into the observation and emit a new event.
- ConvoOutcome gains members_changed, set by GroupV2Convo when a poll cycle's drained de-mls events include CommitApplied or WelcomeReady. It rides alongside content, the same way a protocol-only frame already yields content: None.
- Convo::wakeup returns a ConvoOutcome instead of (), mirroring handle_frame, so the steward's own timer-driven commit is observable. Kinds with no timers return ConvoOutcome::empty; Core::wakeup and the worker translate it through the same events_from_inbound path inbound payloads use.
- New Event::ConversationMembersChanged { convo_id }: the app re-fetches group_members. Fires on every member a commit reaches, so the inviter sees its own add land and existing members see later joins.
2026-07-15 17:21:23 +02:00
|
|
|
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
|
|
|
}
|
|
|
|
|
|
2026-06-23 13:40:19 +08: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,
|
|
|
|
|
});
|
2026-06-23 13:40:19 +08:00
|
|
|
if let Some(c) = initial.and_then(|co| co.content)
|
2026-06-24 11:48:50 +08:00
|
|
|
&& let Ok(sender) = decode_sender(directory, &c.encoded_credential)
|
2026-06-23 13:40:19 +08:00
|
|
|
{
|
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,
|
2026-06-24 11:48:50 +08:00
|
|
|
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
|
|
|
|
|
}
|
2026-06-23 13:40:19 +08:00
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod sender_check_tests {
|
|
|
|
|
use std::collections::HashMap;
|
|
|
|
|
|
|
|
|
|
use crypto::{Ed25519SigningKey, Ed25519VerifyingKey};
|
2026-07-03 23:18:10 +02:00
|
|
|
use libchat::IdentId;
|
|
|
|
|
use logos_account::{DeviceSet, SignedDeviceBundle};
|
2026-06-23 13:40:19 +08:00
|
|
|
|
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,
|
|
|
|
|
};
|
2026-06-23 13:40:19 +08:00
|
|
|
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,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-03 23:18:10 +02:00
|
|
|
impl logos_account::AccountDirectory for FakeDir {
|
2026-06-23 13:40:19 +08:00
|
|
|
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()
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-24 11:48:50 +08:00
|
|
|
fn local_id(k: &Ed25519VerifyingKey) -> IdentId {
|
|
|
|
|
IdentId::new(hex::encode(k.as_ref()))
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-23 13:40:19 +08:00
|
|
|
/// The account published a device set that includes the sending device — the
|
2026-06-24 11:48:50 +08:00
|
|
|
/// claim checks out, so the message is delivered with a verified account.
|
2026-06-23 13:40:19 +08:00
|
|
|
#[test]
|
2026-06-24 11:48:50 +08:00
|
|
|
fn verified_sender_surfaces_account_and_device() {
|
2026-06-23 13:40:19 +08:00
|
|
|
let account = key();
|
|
|
|
|
let device = key();
|
|
|
|
|
let dir = FakeDir::with_devices(&account, &[&device]);
|
|
|
|
|
let cred = DelegateCredential::associated(&device, &hex::encode(account.as_ref()));
|
2026-06-24 11:48:50 +08:00
|
|
|
assert_eq!(
|
|
|
|
|
decode_sender(&dir, &encoded(cred)),
|
|
|
|
|
Ok(MessageSender {
|
|
|
|
|
account: Some(local_id(&account)),
|
|
|
|
|
local_identity: local_id(&device),
|
|
|
|
|
})
|
|
|
|
|
);
|
2026-06-23 13:40:19 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// 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()));
|
2026-06-24 11:48:50 +08:00
|
|
|
assert_eq!(
|
|
|
|
|
decode_sender(&dir, &encoded(cred)),
|
|
|
|
|
Err(SenderError::Unverified)
|
|
|
|
|
);
|
2026-06-23 13:40:19 +08:00
|
|
|
}
|
|
|
|
|
|
2026-06-24 11:48:50 +08:00
|
|
|
/// A delegate that claims no account surfaces its device but no account.
|
2026-06-23 13:40:19 +08:00
|
|
|
#[test]
|
2026-06-24 11:48:50 +08:00
|
|
|
fn unassociated_sender_surfaces_device_only() {
|
2026-06-23 13:40:19 +08:00
|
|
|
let dir = FakeDir::default();
|
2026-06-24 11:48:50 +08:00
|
|
|
let device = key();
|
|
|
|
|
let cred = DelegateCredential::unassociated(&device);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
decode_sender(&dir, &encoded(cred)),
|
|
|
|
|
Ok(MessageSender {
|
|
|
|
|
account: None,
|
|
|
|
|
local_identity: local_id(&device),
|
|
|
|
|
})
|
|
|
|
|
);
|
2026-06-23 13:40:19 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// 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()));
|
2026-06-24 11:48:50 +08:00
|
|
|
assert_eq!(
|
|
|
|
|
decode_sender(&dir, &encoded(cred)),
|
|
|
|
|
Err(SenderError::Unverified)
|
|
|
|
|
);
|
2026-06-23 13:40:19 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// 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()));
|
2026-06-24 11:48:50 +08:00
|
|
|
assert_eq!(
|
|
|
|
|
decode_sender(&dir, &encoded(cred)),
|
|
|
|
|
Err(SenderError::Unverified)
|
|
|
|
|
);
|
2026-06-23 13:40:19 +08:00
|
|
|
}
|
|
|
|
|
|
2026-06-24 11:48:50 +08:00
|
|
|
/// No credential at all (e.g. the PrivateV1 placeholder) leaves no sender to
|
|
|
|
|
/// attribute, so the message is dropped.
|
2026-06-23 13:40:19 +08:00
|
|
|
#[test]
|
2026-06-24 11:48:50 +08:00
|
|
|
fn empty_credential_is_dropped() {
|
2026-06-23 13:40:19 +08:00
|
|
|
let dir = FakeDir::default();
|
2026-06-24 11:48:50 +08:00
|
|
|
assert_eq!(decode_sender(&dir, b""), Err(SenderError::Missing));
|
2026-06-23 13:40:19 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// 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();
|
2026-06-24 11:48:50 +08:00
|
|
|
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)
|
|
|
|
|
);
|
2026-06-23 13:40:19 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// 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");
|
2026-06-24 11:48:50 +08:00
|
|
|
assert_eq!(
|
|
|
|
|
decode_sender(&dir, &encoded(cred)),
|
|
|
|
|
Err(SenderError::AccountNotAKey)
|
|
|
|
|
);
|
2026-06-23 13:40:19 +08:00
|
|
|
}
|
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");
|
|
|
|
|
}
|
2026-06-23 13:40:19 +08:00
|
|
|
}
|