Skip to main content

ping_core/
client.rs

1//! `MessagingClient` — top-level handle. Owns the OpenMLS provider, identity, local device,
2//! and the set of open conversations.
3//!
4//! All operations are `async`. The intent is that the FFI generators emit Swift `async`,
5//! Kotlin `suspend`, and the WASM glue exposes Promises.
6
7use openmls::framing::MlsMessageOut;
8use openmls::prelude::{
9    tls_codec::Serialize as TlsSerialize, BasicCredential, Ciphersuite, CredentialWithKey,
10    KeyPackageBuilder,
11};
12use openmls_basic_credential::SignatureKeyPair;
13use openmls_traits::OpenMlsProvider;
14use parking_lot::RwLock;
15use ping_mls_store::{PersistentMlsProvider, StorageBackend};
16use std::collections::HashMap;
17use std::sync::Arc;
18use zeroize::Zeroizing;
19
20use crate::{
21    codec,
22    conversation::{Conversation, ConversationId, ConversationMeta, MemberInfo},
23    device::{
24        CatchupAppEventEntry, CatchupConversationEntry, CatchupSnapshot, DeviceId, DeviceInfo,
25        LinkingTicket, LocalDevice, CATCHUP_SNAPSHOT_VERSION,
26    },
27    error::{Error, Result},
28    identity::{Identity, UserId},
29    message::{IncomingMessage, MessageEnvelope, MessageKind},
30    storage::Storage,
31    sync::SyncCursor,
32    transport::Transport,
33};
34
35const DEFAULT_CIPHERSUITE: Ciphersuite = Ciphersuite::MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519;
36
37/// Whether a transport send failure is a DEFINITE server rejection (the server
38/// returned an HTTP error response) rather than an ambiguous network failure
39/// where the server may actually have applied the message.
40///
41/// This decides whether a staged Commit can be safely rolled back: only a
42/// definite rejection guarantees the server did NOT apply it. The host transport
43/// embeds the HTTP status in the error string (e.g. "network: http 409"); 4xx +
44/// known server error codes are definite, while timeouts / "fetch failed" / 5xx
45/// are ambiguous (could be a masked success). When unsure we treat it as
46/// ambiguous (return false) — merging on a masked success is recoverable, but
47/// rolling back a Commit the server DID apply strands us a step behind forever.
48fn is_definite_rejection(err: &Error) -> bool {
49    let Error::Transport(s) = err else {
50        return false;
51    };
52    let s = s.to_ascii_lowercase();
53    s.contains("http 4")
54        || s.contains("epoch_advanced")
55        || s.contains("invalid_request")
56        || s.contains("not_found")
57        || s.contains("conflict")
58        || s.contains("forbidden")
59        || s.contains("unauthorized")
60}
61
62/// Per-chat result reported by [`MessagingClient::admit_device_to_chats`].
63#[derive(Debug, Clone)]
64pub struct AdmitChatOutcome {
65    pub conversation_id: ConversationId,
66    pub status: AdmitChatStatus,
67}
68
69#[derive(Debug, Clone)]
70pub enum AdmitChatStatus {
71    /// The new device is now an MLS leaf in this chat. Both the Commit
72    /// and the addressed Welcome have been sent.
73    Admitted,
74    /// We chose not to admit (e.g. the conversation is a DeviceGroup,
75    /// which was already handled at linking-ticket build time).
76    Skipped { reason: String },
77    /// MLS or transport rejected the admission. `error` is the underlying
78    /// message — typically a `transport error: ...` or an OpenMLS error.
79    Failed { error: String },
80}
81
82#[derive(Debug)]
83pub struct ClientConfig {
84    pub identity: Identity,
85    pub device_label: String,
86    pub storage: Arc<dyn Storage>,
87    pub transport: Arc<dyn Transport>,
88    /// Wall clock in ms. Pulled from the host so we can use a synthetic clock in tests.
89    pub now_ms: u64,
90    /// [CR-4] OpenMLS-provider backend. Defaults to in-memory; iOS NSE and web SW
91    /// cold-start paths MUST pass `StorageBackend::Sqlite { path, encryption_key }`
92    /// (native) or `StorageBackend::IndexedDb { db_name }` (WASM, when that lands).
93    /// See `docs/design/CR4_CR7_PERSISTENCE.md`.
94    pub storage_backend: StorageBackend,
95    /// Optional 32-byte Ed25519 secret key the SDK should use as the
96    /// device signing key. When set AND no `LocalDevice` is yet
97    /// persisted in `storage`, the SDK constructs its first
98    /// `LocalDevice` from this key instead of generating a fresh
99    /// random one — so `device_id = SHA-256(public_key_of(secret))`
100    /// is fully determined by what the host provided.
101    ///
102    /// Use case: align the SDK's `device_id` (which it stamps into
103    /// every envelope's `sender_device` field) with an externally-
104    /// computed device id — typically `SHA-256(device_signing_pubkey)`
105    /// in the host's auth layer, where the JWT carries that same
106    /// value as its `device_id` claim. Without this alignment, a
107    /// server that validates `envelope.sender_device ==
108    /// jwt.device_id` would reject every send.
109    ///
110    /// Ignored on re-init (when storage already has a persisted
111    /// `LocalDevice`) so the device identity remains stable across
112    /// restarts.
113    pub device_signing_secret_key: Option<[u8; 32]>,
114}
115
116impl ClientConfig {
117    /// Construct a config with `StorageBackend::Memory` — convenient for tests and
118    /// the existing v0.1 in-memory flow.
119    pub fn new_in_memory(
120        identity: Identity,
121        device_label: String,
122        storage: Arc<dyn Storage>,
123        transport: Arc<dyn Transport>,
124        now_ms: u64,
125    ) -> Self {
126        Self {
127            identity,
128            device_label,
129            storage,
130            transport,
131            now_ms,
132            storage_backend: StorageBackend::Memory,
133            device_signing_secret_key: None,
134        }
135    }
136}
137
138pub struct MessagingClient {
139    pub(crate) identity: Identity,
140    pub(crate) local_device: LocalDevice,
141    pub(crate) crypto: Arc<PersistentMlsProvider>,
142    pub(crate) signing: Arc<SignatureKeyPair>,
143    pub(crate) storage: Arc<dyn Storage>,
144    pub(crate) transport: Arc<dyn Transport>,
145    conversations: RwLock<HashMap<ConversationId, Conversation>>,
146    /// Conversations detected as STRANDED during catch-up: a full page of
147    /// events was fetched but nothing could be applied (every envelope failed
148    /// to decrypt / was wrong-epoch), meaning a Commit was missed and the group
149    /// can no longer advance from local state. The host polls
150    /// [`MessagingClient::stranded_conversations`] after a sync and recovers
151    /// each (re-Welcome / same-user state snapshot). Cleared automatically once
152    /// the conversation makes progress again.
153    stranded: RwLock<std::collections::HashSet<ConversationId>>,
154}
155
156impl std::fmt::Debug for MessagingClient {
157    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
158        f.debug_struct("MessagingClient")
159            .field("user_id", &self.identity.user_id().as_hex())
160            .field("device_id", &self.local_device.device_id.as_hex())
161            .field("conversation_count", &self.conversations.read().len())
162            .finish()
163    }
164}
165
166impl MessagingClient {
167    /// Initialise. Creates a new local device if none is recorded in storage; otherwise rehydrates.
168    pub async fn init(cfg: ClientConfig) -> Result<Arc<Self>> {
169        // [CR-4] OpenMLS provider is now pluggable. For `StorageBackend::Memory` this
170        // behaves like the old `OpenMlsRustCrypto::default()`. For `Sqlite`, the
171        // working set is hydrated from the on-disk blob; subsequent `checkpoint` calls
172        // flush it back. iOS NSE / web SW cold-start lives here.
173        //
174        // Use `open_async` so the WASM `StorageBackend::IndexedDb` variant can read
175        // its snapshot blob through the host-supplied `AsyncBlobStore` before
176        // returning — without this, the provider's `MemoryStorage` would be empty
177        // and `MlsGroup::load` would silently return `None` for every group on
178        // cold restart, breaking chat persistence across reloads. Native targets
179        // (Memory + Sqlite) delegate to the sync path under the hood, so the
180        // `.await` is free there.
181        let crypto = PersistentMlsProvider::open_async(cfg.storage_backend.clone())
182            .await
183            .map_err(|e| Error::Storage(format!("provider open: {e}")))?;
184        let local_device = match cfg.storage.get("device", "local").await? {
185            Some(bytes) => decode_local_device(&bytes, cfg.identity.user_id().clone())?,
186            None => {
187                // First-init path. If the host supplied a signing secret
188                // (typically to align the device_id with their auth
189                // layer), use it; otherwise mint a fresh random key.
190                // Either way, the constructed `LocalDevice` is
191                // immediately persisted so future inits load from
192                // storage without consulting the override again.
193                let dev = match cfg.device_signing_secret_key.as_ref() {
194                    Some(secret) => LocalDevice::from_signing_secret(
195                        cfg.identity.user_id().clone(),
196                        cfg.device_label,
197                        cfg.now_ms,
198                        secret,
199                    ),
200                    None => LocalDevice::generate(
201                        cfg.identity.user_id().clone(),
202                        cfg.device_label,
203                        cfg.now_ms,
204                    ),
205                };
206                let bytes = encode_local_device(&dev)?;
207                cfg.storage.put("device", "local", bytes).await?;
208                dev
209            }
210        };
211
212        // [CR-4] MLS signing keypair MUST be stable across cold restarts — otherwise the
213        // leaf-key stored on disk no longer matches the per-client key on re-init, and any
214        // send-after-restart silently misroutes. We derive deterministically from the
215        // already-persistent `LocalDevice::signing` (Ed25519, 32 raw bytes), and the
216        // ciphersuite's signature scheme is Ed25519 too — so the device signing key and the
217        // MLS leaf signing key are the same bytes. The MLS storage provider also receives
218        // a copy via `store()` so OpenMLS-internal lookups (process_message, etc.) succeed.
219        let signing = {
220            let sk_bytes = local_device.signing.to_bytes().to_vec();
221            let pk_bytes = local_device.signing.verifying_key().to_bytes().to_vec();
222            let kp = SignatureKeyPair::from_raw(
223                DEFAULT_CIPHERSUITE.signature_algorithm(),
224                sk_bytes,
225                pk_bytes,
226            );
227            kp.store(crypto.storage()).map_err(Error::mls)?;
228            Arc::new(kp)
229        };
230
231        let client = Arc::new(Self {
232            identity: cfg.identity,
233            local_device,
234            crypto,
235            signing,
236            storage: cfg.storage,
237            transport: cfg.transport,
238            conversations: RwLock::new(HashMap::new()),
239            stranded: RwLock::new(std::collections::HashSet::new()),
240        });
241
242        client.rehydrate_conversations(cfg.now_ms).await?;
243
244        // [CR-10] Ensure the DeviceGroup exists at init, not lazily inside
245        // build_linking_ticket. Single-device users need somewhere to write
246        // personal events (drafts, read pointers, notes, vault wrapper)
247        // even before they pair a second device. Lazy creation in
248        // build_linking_ticket left them with no DG → no place for
249        // personal state to land.
250        //
251        // Idempotent — re-init after a cold restart finds the DG via
252        // rehydrate_conversations and this becomes a no-op.
253        client.ensure_device_group(cfg.now_ms).await?;
254
255        Ok(client)
256    }
257
258    /// [CR-10] Idempotently ensures this user's DeviceGroup exists in
259    /// `self.conversations`. Called from `init` (so single-device users
260    /// have a DG immediately) and from `build_linking_ticket` (the legacy
261    /// lazy path; still safe to call when the DG already exists, since
262    /// rehydrate_conversations would have re-attached it before init
263    /// returned).
264    ///
265    /// The DeviceGroup is a one-leaf MLS group at creation time —
266    /// `add_members` (called by `build_linking_ticket` when a second
267    /// device pairs in) is what grows it. We persist the snapshot so a
268    /// cold restart picks it up before this function runs again.
269    pub(crate) async fn ensure_device_group(self: &Arc<Self>, now_ms: u64) -> Result<()> {
270        let dg_id = device_group_id_for(self.identity.user_id());
271        if self.conversations.read().contains_key(&dg_id) {
272            return Ok(());
273        }
274        let mut new_dg = Conversation::create(
275            dg_id,
276            Some("device-group".into()),
277            self.local_device.device_id.clone(),
278            self.identity.user_id(),
279            self.crypto.clone(),
280            self.signing.clone(),
281            self.storage.clone(),
282            now_ms,
283        )?;
284        new_dg.meta.is_device_group = true;
285        new_dg.snapshot_to_storage().await?;
286        self.conversations.write().insert(dg_id, new_dg);
287        Ok(())
288    }
289
290    pub fn user_id(&self) -> UserId {
291        self.identity.user_id().clone()
292    }
293    /// Export this client's account identity (the Ed25519 seed, CBOR-wrapped —
294    /// same format `Identity::import` / `MessagingClient::init(identity_export)`
295    /// accept). SECRET. Hosts use this to TRANSFER the account identity to a
296    /// newly-linked device over the sealed linking channel, so every linked
297    /// device shares ONE `user_id` and `IncomingMessage.sender_user_id` equals
298    /// the local `user_id()` for any of the account's own devices — the basis
299    /// for cross-device self-attribution. Never log or persist in cleartext.
300    pub fn export_identity(&self) -> Zeroizing<Vec<u8>> {
301        self.identity.export()
302    }
303    pub fn device_id(&self) -> DeviceId {
304        self.local_device.device_id.clone()
305    }
306    pub fn device_info(&self, now_ms: u64) -> DeviceInfo {
307        self.local_device.info(now_ms)
308    }
309
310    /// Generate a fresh KeyPackage to publish to the directory. Hosts call this when registering
311    /// a device or topping up the directory.
312    ///
313    /// `build()` writes the private init + encryption keys into the storage
314    /// provider's working set, but ON ITS OWN that write is NOT durable: on the
315    /// WASM/AsyncBlob backend the working set only reaches IndexedDB at the next
316    /// `checkpoint_async`, so a page reload before the next state-changing op
317    /// loses the private keys while the PUBLIC KeyPackage has already been
318    /// published. Any Welcome later bound to that KeyPackage then fails with
319    /// "No matching key package was found in the key store" (breaking calls and
320    /// every invite to this device). So we checkpoint HERE, before returning the
321    /// bytes the host will publish — the published KeyPackage is durable the
322    /// instant it leaves this function. Hence `async`.
323    pub async fn fresh_key_package(&self) -> Result<Vec<u8>> {
324        self.build_key_package(false).await
325    }
326
327    /// Generate a fresh LAST-RESORT KeyPackage.
328    ///
329    /// A normal KeyPackage is single-use: once a Welcome consumes it, the
330    /// private init key is deleted and the directory entry is burned. When a
331    /// device's published pool runs dry, every invite to that device hard-fails
332    /// ("user unavailable") until it comes online and tops up — the classic
333    /// "I added them but they never got it" complaint.
334    ///
335    /// A last-resort KeyPackage (RFC 9420 §10) carries the `LastResort`
336    /// extension, signalling the server it may serve this KeyPackage MORE THAN
337    /// ONCE when no single-use KeyPackages remain. The host publishes exactly
338    /// one per device; the server keeps it as the always-available fallback so
339    /// an invite never fails purely because the pool emptied. Forward secrecy
340    /// for the joining epoch is slightly weaker (the init key is reused until
341    /// replenishment), which is the accepted RFC trade-off for availability.
342    pub async fn fresh_last_resort_key_package(&self) -> Result<Vec<u8>> {
343        self.build_key_package(true).await
344    }
345
346    async fn build_key_package(&self, last_resort: bool) -> Result<Vec<u8>> {
347        let credential_with_key = CredentialWithKey {
348            credential: BasicCredential::new(self.identity.user_id().0.clone()).into(),
349            signature_key: self.signing.public().to_vec().into(),
350        };
351        let mut builder = KeyPackageBuilder::new()
352            // Advertise the group-name extension capability so a later
353            // `set_name` (rename / avatar change via GroupContextExtensions)
354            // passes openmls' per-member capability check on every group this
355            // device joins. For a last-resort KeyPackage the leaf must also
356            // advertise the LastResort extension it carries, or openmls
357            // validation rejects the KeyPackage at add-member time. See
358            // `conversation::ping_leaf_capabilities_for`.
359            .leaf_node_capabilities(crate::conversation::ping_leaf_capabilities_for(last_resort));
360        if last_resort {
361            builder = builder.mark_as_last_resort();
362        }
363        let bundle = builder
364            .build(
365                DEFAULT_CIPHERSUITE,
366                self.crypto.as_ref(),
367                self.signing.as_ref(),
368                credential_with_key,
369            )
370            .map_err(Error::mls)?;
371        // Durably persist the freshly-generated private keys BEFORE the public
372        // KeyPackage is handed to the host to publish (see doc comment).
373        self.crypto
374            .checkpoint_async()
375            .await
376            .map_err(|e| Error::Storage(format!("key package checkpoint: {e}")))?;
377        // KeyPackages are serialized as MlsMessage(KeyPackage) per the MLS framing spec.
378        let msg: MlsMessageOut = bundle.key_package().clone().into();
379        msg.tls_serialize_detached().map_err(Error::mls)
380    }
381
382    /// Create a new conversation owned by this client (and seeded with a single member: this device).
383    pub async fn create_conversation(
384        self: &Arc<Self>,
385        name: Option<String>,
386        now_ms: u64,
387    ) -> Result<ConversationId> {
388        let id = ConversationId::new();
389        let convo = Conversation::create(
390            id,
391            name,
392            self.local_device.device_id.clone(),
393            self.identity.user_id(),
394            self.crypto.clone(),
395            self.signing.clone(),
396            self.storage.clone(),
397            now_ms,
398        )?;
399        convo.snapshot_to_storage().await?;
400        self.conversations.write().insert(id, convo);
401        Ok(id)
402    }
403
404    /// Join via a Welcome bundled in a [`MessageEnvelope`] of kind `Welcome`.
405    pub async fn join_conversation(
406        self: &Arc<Self>,
407        welcome_envelope: &MessageEnvelope,
408        now_ms: u64,
409    ) -> Result<ConversationId> {
410        if welcome_envelope.kind != MessageKind::Welcome {
411            return Err(Error::Invalid("expected Welcome envelope".into()));
412        }
413        let convo = Conversation::join(
414            &welcome_envelope.payload,
415            self.local_device.device_id.clone(),
416            self.crypto.clone(),
417            self.signing.clone(),
418            self.storage.clone(),
419            now_ms,
420        )?;
421        let id = convo.id();
422        convo.snapshot_to_storage().await?;
423        self.conversations.write().insert(id, convo);
424        // Joining (re-Welcome) recovers a previously-stranded conversation.
425        self.stranded.write().remove(&id);
426        Ok(id)
427    }
428
429    /// Conversations detected as STRANDED during catch-up — a Commit was missed
430    /// and the group can no longer advance from local state. The host should
431    /// recover each (re-Welcome from a peer, or a same-user state-snapshot
432    /// import) so messages start delivering again. The set self-clears as a
433    /// conversation makes progress or is re-joined.
434    pub fn stranded_conversations(&self) -> Vec<ConversationId> {
435        let mut ids: Vec<ConversationId> = self.stranded.read().iter().copied().collect();
436        ids.sort_by_key(|a| a.0);
437        ids
438    }
439
440    pub fn list_conversations(&self) -> Vec<ConversationMeta> {
441        self.conversations
442            .read()
443            .values()
444            .map(|c| c.meta.clone())
445            .collect()
446    }
447
448    /// Member roster for a conversation, recovered locally from the MLS
449    /// group's leaf credentials. Empty if the conversation is unknown to
450    /// this client. Lets any device (including one that just joined via a
451    /// linking Welcome) resolve a 1:1 peer's `UserId` without the
452    /// out-of-band `ping.profile` re-send.
453    pub fn members(&self, conv_id: ConversationId) -> Vec<MemberInfo> {
454        self.conversations
455            .read()
456            .get(&conv_id)
457            .map(|c| c.members())
458            .unwrap_or_default()
459    }
460
461    /// Send an application message. Returns once the envelope has been handed to the transport.
462    pub async fn send(
463        &self,
464        conv_id: ConversationId,
465        plaintext: Vec<u8>,
466        now_ms: u64,
467    ) -> Result<MessageEnvelope> {
468        let envelope = {
469            let mut guard = self.conversations.write();
470            let convo = guard
471                .get_mut(&conv_id)
472                .ok_or_else(|| Error::UnknownConversation(conv_id.as_hex()))?;
473            convo.send_application(&plaintext, now_ms)?
474        };
475        self.transport.send(envelope.clone()).await?;
476        // The OpenMLS sender ratchet advances on every Application message — `seq` + `hlc`
477        // are bumped on the conversation, and the underlying group keystore stores new
478        // generation keys. Without a checkpoint here, a reload rolls back to the pre-send
479        // state and the next send re-uses an already-consumed generation that receivers
480        // silently drop. Mirrors the snapshot calls after every Commit/Welcome op.
481        //
482        // Capture the snapshot inputs UNDER the read guard, then DROP the
483        // guard (end of the `let` statement) before the async flush — never
484        // hold a `parking_lot` guard across `.await` (see
485        // `Conversation::snapshot_inputs`).
486        let snap = self
487            .conversations
488            .read()
489            .get(&conv_id)
490            .map(|c| c.snapshot_inputs())
491            .transpose()?;
492        if let Some(snap) = snap {
493            snap.flush().await?;
494        }
495        Ok(envelope)
496    }
497
498    /// Add members. The Commit goes on the wire; the Welcome should be delivered to the new
499    /// devices' inboxes (the host transport implements that — typically as a separate addressed
500    /// envelope).
501    ///
502    /// [CR-2] Each entry is `(DeviceId, KeyPackage_bytes)`. The host typically gets the
503    /// device_id from the directory at the same time it gets the KeyPackage; we use it to
504    /// record a per-conversation `device_id → leaf_index` map so [`Self::revoke_device`]
505    /// can later locate the leaf without a fresh directory lookup. The SDK does not
506    /// cryptographically verify the host's device-id claim — that's a directory policy
507    /// concern.
508    //
509    // The `conversations` lock is taken only for the SYNCHRONOUS MLS work
510    // (the add commit) and the synchronous snapshot capture, then dropped
511    // BEFORE every `.await`. We must never hold a `parking_lot` guard
512    // across an await — see `Conversation::snapshot_inputs` for why (the
513    // single-threaded wasm worker would panic in `parking_lot`'s parker
514    // stub). `parking_lot/send_guard` is still set so any guard that DOES
515    // briefly cross a yield-free boundary stays `Send`.
516    pub async fn add_members(
517        &self,
518        conv_id: ConversationId,
519        entries: Vec<(DeviceId, Vec<u8>)>,
520        now_ms: u64,
521    ) -> Result<()> {
522        // Phase 1 — stage the Commit WITHOUT merging (local epoch unchanged).
523        let staged = {
524            let mut guard = self.conversations.write();
525            let convo = guard
526                .get_mut(&conv_id)
527                .ok_or_else(|| Error::UnknownConversation(conv_id.as_hex()))?;
528            convo.stage_add_members(entries, now_ms)?
529        };
530
531        // Phase 2 — send the Commit FIRST, then merge only if the server accepts
532        // it (send-then-merge). A Commit the server REJECTS is rolled back, so the
533        // local epoch can never run ahead of the server — the desync that
534        // permanently bricks a group (every later Commit 409s; peers can't decrypt
535        // our epoch). A network failure with NO response is ambiguous (the server
536        // may have applied it), so there we merge to match a possible masked
537        // success rather than strand ourselves a step behind.
538        if let Err(send_err) = self.transport.send(staged.commit.clone()).await {
539            let merged = {
540                let mut guard = self.conversations.write();
541                match guard.get_mut(&conv_id) {
542                    Some(convo) if is_definite_rejection(&send_err) => {
543                        let _ = convo.abort_staged();
544                        false
545                    }
546                    Some(convo) => {
547                        convo.confirm_staged(&staged, now_ms)?;
548                        true
549                    }
550                    None => false,
551                }
552            };
553            if merged {
554                self.flush_conversation(&conv_id).await?;
555            }
556            return Err(send_err);
557        }
558
559        // Phase 3 — Commit accepted: merge locally + persist (so the advanced
560        // epoch survives a crash even if the Welcome below fails).
561        {
562            let mut guard = self.conversations.write();
563            let convo = guard
564                .get_mut(&conv_id)
565                .ok_or_else(|| Error::UnknownConversation(conv_id.as_hex()))?;
566            convo.confirm_staged(&staged, now_ms)?;
567        }
568        self.flush_conversation(&conv_id).await?;
569
570        // Phase 4 — deliver the Welcome to the new members. Best-effort: they are
571        // in the group server-side now; a failed Welcome is recoverable
572        // (re-invite) and must NOT roll back the merged Commit.
573        if let Some(welcome) = staged.welcome {
574            self.transport.send(welcome).await?;
575        }
576        Ok(())
577    }
578
579    /// Change a conversation's `name` (carried in the GroupContext) and broadcast
580    /// the change to every member as an MLS GroupContextExtensions Commit. Unlike
581    /// a hydration broadcast, the new name rides MLS group STATE, so every member
582    /// — and every future joiner via the GroupInfo — converges on it. Hosts use
583    /// this to make a rename or an embedded avatar-media-id change bulletproof
584    /// (the `name` carries the `ping:meta:v1:` blob).
585    ///
586    /// No Welcome (membership is unchanged). Uses the same send-then-merge
587    /// rollback discipline as [`Self::add_members`] so a server-rejected Commit
588    /// never desyncs the local epoch. All members must have re-linked since the
589    /// group-name capability shipped (see `conversation::ping_leaf_capabilities`),
590    /// else openmls rejects the Commit.
591    pub async fn set_conversation_name(
592        &self,
593        conv_id: ConversationId,
594        name: Option<String>,
595        now_ms: u64,
596    ) -> Result<()> {
597        // Phase 1 — stage the Commit WITHOUT merging (local epoch unchanged).
598        let staged = {
599            let mut guard = self.conversations.write();
600            let convo = guard
601                .get_mut(&conv_id)
602                .ok_or_else(|| Error::UnknownConversation(conv_id.as_hex()))?;
603            convo.stage_set_name(name, now_ms)?
604        };
605
606        // Phase 2 — send-then-merge (see `add_members` for the rollback rationale).
607        if let Err(send_err) = self.transport.send(staged.commit.clone()).await {
608            let merged = {
609                let mut guard = self.conversations.write();
610                match guard.get_mut(&conv_id) {
611                    Some(convo) if is_definite_rejection(&send_err) => {
612                        let _ = convo.abort_staged();
613                        false
614                    }
615                    Some(convo) => {
616                        convo.confirm_staged(&staged, now_ms)?;
617                        true
618                    }
619                    None => false,
620                }
621            };
622            if merged {
623                self.flush_conversation(&conv_id).await?;
624            }
625            return Err(send_err);
626        }
627
628        // Phase 3 — Commit accepted: merge locally + persist.
629        {
630            let mut guard = self.conversations.write();
631            let convo = guard
632                .get_mut(&conv_id)
633                .ok_or_else(|| Error::UnknownConversation(conv_id.as_hex()))?;
634            convo.confirm_staged(&staged, now_ms)?;
635        }
636        self.flush_conversation(&conv_id).await?;
637        Ok(())
638    }
639
640    /// Snapshot + flush a conversation's persistable state. Captures the snapshot
641    /// synchronously under the read guard, drops the guard, then awaits the flush
642    /// (never hold a `parking_lot` guard across an await — wasm parker panics).
643    async fn flush_conversation(&self, conv_id: &ConversationId) -> Result<()> {
644        let snap = self
645            .conversations
646            .read()
647            .get(conv_id)
648            .map(|c| c.snapshot_inputs())
649            .transpose()?;
650        if let Some(snap) = snap {
651            snap.flush().await?;
652        }
653        Ok(())
654    }
655
656    /// Admits `new_device_id` to every conversation in `kps_per_chat` via
657    /// the standard MLS `add_members` flow — one Commit + one Welcome per
658    /// chat. This is the SDK-side replacement for the host's previous
659    /// per-chat reconciler loop after device linking; centralising it
660    /// here means iOS/Android/web hosts all share the orchestration and
661    /// the transport's Welcome-recipient priming is automatic.
662    ///
663    /// Inputs:
664    /// - `new_device_id`: the device being admitted (matches the
665    ///   `device_binding_sig` recipient in the linking ticket).
666    /// - `kps_per_chat`: one freshly-claimed KeyPackage per chat. The
667    ///   host claims these via the auth-layer's per-account KP pool
668    ///   (`GET /v1/devices/{accountId}`) AFTER the new device's
669    ///   bootstrap has uploaded its KP batch.
670    /// - `now_ms`: wall-clock used to stamp HLCs on the emitted
671    ///   envelopes.
672    ///
673    /// Per-chat failures (unknown conversation, MLS error, transport
674    /// error, etc.) are CAPTURED in the returned vec rather than
675    /// short-circuiting the whole call — losing one chat shouldn't
676    /// strand the new device on every other chat. The caller decides
677    /// whether to retry the failed entries (e.g. with a fresh KP).
678    pub async fn admit_device_to_chats(
679        &self,
680        new_device_id: DeviceId,
681        kps_per_chat: Vec<(ConversationId, Vec<u8>)>,
682        now_ms: u64,
683    ) -> Result<Vec<AdmitChatOutcome>> {
684        let mut outcomes = Vec::with_capacity(kps_per_chat.len());
685        for (conv_id, kp_bytes) in kps_per_chat {
686            // Belt-and-braces: skip the DeviceGroup. The DG was already
687            // welcomed via the linking ticket — re-adding the new
688            // device there would produce a duplicate-add Commit that
689            // BE de-dups, but the noise is avoidable.
690            let is_dg = self
691                .conversations
692                .read()
693                .get(&conv_id)
694                .map(|c| c.meta().is_device_group)
695                .unwrap_or(false);
696            if is_dg {
697                outcomes.push(AdmitChatOutcome {
698                    conversation_id: conv_id,
699                    status: AdmitChatStatus::Skipped {
700                        reason: "device_group".to_string(),
701                    },
702                });
703                continue;
704            }
705
706            // Prime the host transport with the welcome recipient BEFORE
707            // we mutate MLS state. If priming fails (non-web hosts use
708            // the default no-op), continue — the host's transport will
709            // either route some other way or surface a 4xx on the
710            // welcome send and we'll catch it below.
711            let _ = self
712                .transport
713                .set_next_welcome_recipients(conv_id, vec![new_device_id.clone()])
714                .await;
715
716            let entry = (new_device_id.clone(), kp_bytes);
717            let outcome_result = {
718                let mut guard = self.conversations.write();
719                match guard.get_mut(&conv_id) {
720                    Some(convo) => convo.add_members(vec![entry], now_ms),
721                    None => Err(Error::UnknownConversation(conv_id.as_hex())),
722                }
723            };
724
725            let outcome = match outcome_result {
726                Ok(o) => o,
727                Err(e) => {
728                    outcomes.push(AdmitChatOutcome {
729                        conversation_id: conv_id,
730                        status: AdmitChatStatus::Failed {
731                            error: e.to_string(),
732                        },
733                    });
734                    continue;
735                }
736            };
737
738            if let Err(e) = self.transport.send(outcome.commit).await {
739                outcomes.push(AdmitChatOutcome {
740                    conversation_id: conv_id,
741                    status: AdmitChatStatus::Failed {
742                        error: format!("commit send: {e}"),
743                    },
744                });
745                continue;
746            }
747            if let Err(e) = self.transport.send(outcome.welcome).await {
748                outcomes.push(AdmitChatOutcome {
749                    conversation_id: conv_id,
750                    status: AdmitChatStatus::Failed {
751                        error: format!("welcome send: {e}"),
752                    },
753                });
754                continue;
755            }
756
757            // Capture the snapshot under the read guard, drop it, then
758            // flush async (never hold the lock across `.await`).
759            let snap_result = self
760                .conversations
761                .read()
762                .get(&conv_id)
763                .map(|c| c.snapshot_inputs())
764                .transpose();
765            let flush_result = match snap_result {
766                Ok(Some(snap)) => snap.flush().await,
767                Ok(None) => Ok(()),
768                Err(e) => Err(e),
769            };
770            if let Err(e) = flush_result {
771                // Snapshot failure is non-fatal for the join — the MLS adds
772                // already shipped — but record it so the host can decide
773                // whether to retry. The next successful send/process will
774                // re-snapshot anyway.
775                outcomes.push(AdmitChatOutcome {
776                    conversation_id: conv_id,
777                    status: AdmitChatStatus::Failed {
778                        error: format!("snapshot: {e}"),
779                    },
780                });
781                continue;
782            }
783
784            outcomes.push(AdmitChatOutcome {
785                conversation_id: conv_id,
786                status: AdmitChatStatus::Admitted,
787            });
788        }
789        Ok(outcomes)
790    }
791
792    pub async fn remove_members(
793        &self,
794        conv_id: ConversationId,
795        leaf_indexes: Vec<u32>,
796        now_ms: u64,
797    ) -> Result<()> {
798        // Send-then-merge — see `add_members` for the full rationale.
799        let staged = {
800            let mut guard = self.conversations.write();
801            let convo = guard
802                .get_mut(&conv_id)
803                .ok_or_else(|| Error::UnknownConversation(conv_id.as_hex()))?;
804            convo.stage_remove_members(leaf_indexes, now_ms)?
805        };
806
807        if let Err(send_err) = self.transport.send(staged.commit.clone()).await {
808            let merged = {
809                let mut guard = self.conversations.write();
810                match guard.get_mut(&conv_id) {
811                    Some(convo) if is_definite_rejection(&send_err) => {
812                        let _ = convo.abort_staged();
813                        false
814                    }
815                    Some(convo) => {
816                        convo.confirm_staged(&staged, now_ms)?;
817                        true
818                    }
819                    None => false,
820                }
821            };
822            if merged {
823                self.flush_conversation(&conv_id).await?;
824            }
825            return Err(send_err);
826        }
827
828        {
829            let mut guard = self.conversations.write();
830            let convo = guard
831                .get_mut(&conv_id)
832                .ok_or_else(|| Error::UnknownConversation(conv_id.as_hex()))?;
833            convo.confirm_staged(&staged, now_ms)?;
834        }
835        self.flush_conversation(&conv_id).await?;
836        Ok(())
837    }
838
839    /// Leave a conversation. Broadcasts a self-Remove PROPOSAL (MLS doesn't
840    /// allow committing your own removal — a remaining member commits it via
841    /// [`Self::commit_pending_proposals`]). After this returns, the host should
842    /// delete the conversation locally; the leaver remains a cryptographic
843    /// member only until a peer commits the proposal, at which point the server
844    /// stops delivering to this device.
845    pub async fn leave_conversation(&self, conv_id: ConversationId, now_ms: u64) -> Result<()> {
846        let proposal = {
847            let mut guard = self.conversations.write();
848            let convo = guard
849                .get_mut(&conv_id)
850                .ok_or_else(|| Error::UnknownConversation(conv_id.as_hex()))?;
851            convo.leave_group(now_ms)?
852        };
853        // A proposal doesn't change the epoch, so there's nothing to roll back
854        // on a send failure — surface the error and let the host retry.
855        self.transport.send(proposal).await?;
856        self.flush_conversation(&conv_id).await?;
857        Ok(())
858    }
859
860    /// Drop a conversation's ENTIRE local state with **no** network side effect.
861    ///
862    /// Unlike [`Self::leave_conversation`] (which broadcasts a self-Remove
863    /// proposal so a remaining member evicts you), this is a purely LOCAL
864    /// teardown for a group the server does not back — e.g. an invite that minted
865    /// the MLS group locally but never completed server-side, so the backend
866    /// 404s `fetchSince` / 403s the member roster for it. The host detects that
867    /// authoritative "not a member" verdict and calls this so the dead group
868    /// stops rehydrating on every restart (and re-materialising as a ghost
869    /// conversation). No envelope is sent — there is no live group to send to.
870    ///
871    /// Deletes the OpenMLS group state AND the host-side snapshot rows
872    /// (`groups/{id}/…`, `cursors/{id}`, `device_leaves/{id}`) that
873    /// [`Self::rehydrate_conversations`] would otherwise reload, then drops the
874    /// in-memory handle + `stranded` marker. Idempotent: an unknown id still
875    /// best-effort purges any orphan storage rows, and calling it twice is safe.
876    pub async fn drop_conversation_local(&self, conv_id: ConversationId) -> Result<()> {
877        // Take the handle OUT of the map first, then delete its OpenMLS state via
878        // the owned value — mirrors the self-removal teardown in
879        // `process_envelope` and keeps no lock across the awaits below.
880        let existing = self.conversations.write().remove(&conv_id);
881        if let Some(mut convo) = existing {
882            if let Err(e) = convo.delete_group_state() {
883                // Best-effort: dropping the in-memory handle + purging the
884                // snapshot rows below already makes the group non-rehydratable.
885                tracing::warn!(error = %e, "drop_conversation_local: delete group state failed");
886            }
887        }
888        self.stranded.write().remove(&conv_id);
889
890        // Purge the host-side snapshot rows so a restart's
891        // `rehydrate_conversations` (which walks `groups/{id}/meta`) can't bring
892        // the group back. Best-effort — deleting an absent key is a no-op on
893        // every backend. Sweep the whole `groups/{id}/` prefix so any future
894        // sub-key is covered, then the known `cursors` + `device_leaves` rows.
895        let hex = conv_id.as_hex();
896        if let Ok(keys) = self.storage.list_keys("groups", &format!("{hex}/")).await {
897            for k in keys {
898                let _ = self.storage.delete("groups", &k).await;
899            }
900        }
901        let _ = self.storage.delete("cursors", &hex).await;
902        let _ = self.storage.delete("device_leaves", &hex).await;
903        Ok(())
904    }
905
906    /// Conversations with buffered pending proposals (e.g. a peer's leave
907    /// proposal awaiting a Commit). The host polls this after a sync and, if it
908    /// is the designated committer, calls [`Self::commit_pending_proposals`] to
909    /// evict the leaver. Sorted for determinism.
910    pub fn conversations_with_pending_proposals(&self) -> Vec<ConversationId> {
911        let guard = self.conversations.read();
912        let mut ids: Vec<ConversationId> = guard
913            .iter()
914            .filter(|(_, c)| c.has_pending_proposals())
915            .map(|(id, _)| *id)
916            .collect();
917        ids.sort_by_key(|a| a.0);
918        ids
919    }
920
921    /// Commit all buffered pending proposals for a conversation (evicts a peer
922    /// who left). No-op (Ok) when nothing is pending. Send-then-merge with
923    /// rollback like add/remove so a server-rejected Commit doesn't desync the
924    /// epoch — on an `epoch_advanced` rejection the host should re-sync (another
925    /// member already committed) and the pending proposal will have cleared.
926    pub async fn commit_pending_proposals(
927        &self,
928        conv_id: ConversationId,
929        now_ms: u64,
930    ) -> Result<()> {
931        let staged = {
932            let mut guard = self.conversations.write();
933            let convo = guard
934                .get_mut(&conv_id)
935                .ok_or_else(|| Error::UnknownConversation(conv_id.as_hex()))?;
936            match convo.stage_commit_pending_proposals(now_ms)? {
937                Some(s) => s,
938                None => return Ok(()),
939            }
940        };
941
942        if let Err(send_err) = self.transport.send(staged.commit.clone()).await {
943            let merged = {
944                let mut guard = self.conversations.write();
945                match guard.get_mut(&conv_id) {
946                    Some(convo) if is_definite_rejection(&send_err) => {
947                        let _ = convo.abort_staged();
948                        false
949                    }
950                    Some(convo) => {
951                        convo.confirm_staged(&staged, now_ms)?;
952                        true
953                    }
954                    None => false,
955                }
956            };
957            if merged {
958                self.flush_conversation(&conv_id).await?;
959            }
960            return Err(send_err);
961        }
962
963        {
964            let mut guard = self.conversations.write();
965            let convo = guard
966                .get_mut(&conv_id)
967                .ok_or_else(|| Error::UnknownConversation(conv_id.as_hex()))?;
968            convo.confirm_staged(&staged, now_ms)?;
969        }
970        self.flush_conversation(&conv_id).await?;
971        Ok(())
972    }
973
974    /// Process an inbound envelope coming from the transport's subscribe callback or a sync pull.
975    /// Returns `Some` for application traffic, `None` for handshake messages (already merged).
976    pub async fn process_envelope(
977        &self,
978        env: &MessageEnvelope,
979        now_ms: u64,
980    ) -> Result<Option<IncomingMessage>> {
981        // Welcome envelopes for unknown conversations are routed to `join_conversation` by the
982        // caller. Here we only handle traffic for already-open groups.
983        //
984        // Do the MLS processing AND capture the snapshot synchronously
985        // under the write guard, then DROP the guard before the async
986        // flush. Previously the write guard was held across
987        // `snapshot_to_storage().await`; on the single-threaded wasm
988        // worker a concurrent `list_conversations()` (or any reader) that
989        // landed while a writer was waiting made `parking_lot` park →
990        // panic "Parking not supported". This is the method the crash
991        // stack pointed at (sync / Welcome ingestion).
992        let (out, snap) = {
993            let mut guard = self.conversations.write();
994            let convo = match guard.get_mut(&env.conversation_id) {
995                Some(c) => c,
996                None => return Err(Error::UnknownConversation(env.conversation_id.as_hex())),
997            };
998            let out = convo.process(env, now_ms)?;
999            // SELF-REMOVAL: if this Commit removed our own leaf the group is now
1000            // Inactive. Tear it down — delete the OpenMLS state + drop the handle
1001            // — so the conversation becomes UNKNOWN to this client. A later
1002            // re-invite Welcome for the same group_id is then routed to
1003            // `join_conversation` (the host only joins UNKNOWN conversations) and
1004            // re-joins from clean storage, instead of being suppressed as an
1005            // "already-joined duplicate" (the cause of the stuck-un-joined re-invite).
1006            if !convo.is_active() {
1007                let conv_id = env.conversation_id;
1008                // Best-effort storage cleanup; even if it fails, dropping the
1009                // in-memory handle already makes the conversation re-joinable.
1010                if let Err(e) = convo.delete_group_state() {
1011                    tracing::warn!(error = %e, "process_envelope: delete removed group state failed");
1012                }
1013                guard.remove(&conv_id);
1014                self.stranded.write().remove(&conv_id);
1015                return Ok(out);
1016            }
1017            // Cheap snapshot — only mutates KV the size of the cursor.
1018            let snap = convo.snapshot_inputs()?;
1019            (out, snap)
1020        };
1021        snap.flush().await?;
1022        Ok(out)
1023    }
1024
1025    /// Catch-up sync: pull missing events for every open conversation since its cursor.
1026    /// Returns the list of newly-decrypted application messages, in apply order.
1027    pub async fn sync_conversations(&self, now_ms: u64) -> Result<Vec<IncomingMessage>> {
1028        // Snapshot the conversation IDs ONLY — not their cursors. The cursor is
1029        // re-read fresh per fetch below. Two bugs this avoids:
1030        //   1. Stale-cursor pagination: `process_envelope` advances the
1031        //      conversation's cursor as it applies each page, but the OLD code
1032        //      kept fetching from the cursor captured up-front — so a chat with
1033        //      more than one page (256+) of backlog re-fetched the SAME first
1034        //      page forever and never paged past it (catch-up silently truncated
1035        //      a freshly-linked device's history, incl. group-avatar/name
1036        //      hydration that lands after the first page).
1037        //   2. Join-during-sync: reading IDs fresh here (and re-reading the map
1038        //      each iteration) means a conversation the host joins via a live
1039        //      Welcome right before/around this call is still covered.
1040        // Iterate a sorted, de-duplicated ID set for deterministic order.
1041        let conversation_ids: Vec<ConversationId> = {
1042            let guard = self.conversations.read();
1043            let mut ids: Vec<ConversationId> = guard.keys().copied().collect();
1044            ids.sort_by_key(|a| a.0);
1045            ids
1046        };
1047
1048        let mut delivered = Vec::new();
1049        for conv_id in conversation_ids {
1050            loop {
1051                // Re-read the LIVE cursor each iteration so pagination advances
1052                // as `process_envelope` consumes pages. If the conversation was
1053                // removed mid-sync (e.g. a wipe), stop cleanly.
1054                let cursor = match self.conversations.read().get(&conv_id) {
1055                    Some(c) => c.cursor.clone(),
1056                    None => break,
1057                };
1058                // PER-CONVERSATION ISOLATION: a transport error on ONE
1059                // conversation (a transient 5xx, a non-404 fetch failure) must
1060                // not abort catch-up for ALL the others. Previously a single
1061                // erroring conversation propagated `?` and failed the entire
1062                // `sync_conversations` — so on a freshly-linked device, one bad
1063                // conversation (e.g. the device group, or a group mid-churn)
1064                // blocked every chat from syncing, and group name/avatar
1065                // hydration broadcasts never arrived. Log + skip this
1066                // conversation instead.
1067                let batch = match self.transport.fetch_since(conv_id, cursor, 256).await {
1068                    Ok(b) => b,
1069                    Err(e) => {
1070                        tracing::warn!(error = %e, "sync_conversations: fetch_since failed; skipping conversation");
1071                        break;
1072                    }
1073                };
1074                if batch.is_empty() {
1075                    break;
1076                }
1077                let mut advanced = false;
1078                for env in &batch {
1079                    // PER-ENVELOPE ISOLATION: a single undecryptable / malformed
1080                    // / wrong-epoch envelope must not drop the rest of the page
1081                    // (or fail the sync). Skip it; the live stream / a later
1082                    // epoch advance can still deliver retriable ones.
1083                    match self.process_envelope(env, now_ms).await {
1084                        Ok(Some(msg)) => {
1085                            advanced = true;
1086                            delivered.push(msg);
1087                        }
1088                        // Commits / handshakes advance the cursor too (no app
1089                        // message surfaced) — treat them as progress so we keep
1090                        // paging instead of re-fetching the same page.
1091                        Ok(None) => advanced = true,
1092                        Err(e) => {
1093                            tracing::warn!(error = %e, "sync_conversations: process_envelope failed; skipping envelope");
1094                        }
1095                    }
1096                }
1097                if advanced {
1098                    // Progress was made — this conversation is not stranded
1099                    // (clear any prior stranded mark).
1100                    self.stranded.write().remove(&conv_id);
1101                }
1102                if batch.len() < 256 {
1103                    break; // partial page → caught up
1104                }
1105                if !advanced {
1106                    // Full page but nothing advanced the cursor (every envelope
1107                    // errored / was wrong-epoch). The group can no longer advance
1108                    // from local state — a Commit was missed. Mark it STRANDED so
1109                    // the host can recover it (re-Welcome / snapshot import) via
1110                    // `stranded_conversations()`, and bail to avoid an infinite
1111                    // re-fetch loop.
1112                    tracing::warn!(
1113                        "sync_conversations: full page made no progress; marking conversation stranded"
1114                    );
1115                    self.stranded.write().insert(conv_id);
1116                    break;
1117                }
1118            }
1119        }
1120        Ok(delivered)
1121    }
1122
1123    /// Rehydrate conversations from storage on startup ([CR-4]).
1124    ///
1125    /// Walks the host-side `groups` namespace for meta records, pairs each with its
1126    /// cursor + device→leaf map, and asks `Conversation::load` to re-attach to the
1127    /// underlying OpenMLS group state. The MLS state itself was persisted by the
1128    /// SQLite-backed `PersistentMlsProvider` on the previous run; this method
1129    /// reconciles the SDK-side caches with what's on disk.
1130    async fn rehydrate_conversations(self: &Arc<Self>, now_ms: u64) -> Result<()> {
1131        let metas = self.storage.list_keys("groups", "").await?;
1132        for path in metas {
1133            // path looks like "{convId}/meta"
1134            let Some((id_hex, suffix)) = path.split_once('/') else {
1135                continue;
1136            };
1137            if suffix != "meta" {
1138                continue;
1139            }
1140            let Some(meta_bytes) = self.storage.get("groups", &path).await? else {
1141                continue;
1142            };
1143            let meta: ConversationMeta = match codec::decode(&meta_bytes) {
1144                Ok(m) => m,
1145                Err(_) => continue,
1146            };
1147            let cursor_bytes = self
1148                .storage
1149                .get("cursors", id_hex)
1150                .await?
1151                .unwrap_or_default();
1152            let cursor = if cursor_bytes.is_empty() {
1153                SyncCursor::default()
1154            } else {
1155                SyncCursor::decode(&cursor_bytes).unwrap_or_default()
1156            };
1157
1158            // [CR-2] device→leaf map was persisted alongside meta + cursor.
1159            let device_leaves_bytes = self
1160                .storage
1161                .get("device_leaves", id_hex)
1162                .await?
1163                .unwrap_or_default();
1164            let device_leaves: std::collections::BTreeMap<DeviceId, u32> =
1165                if device_leaves_bytes.is_empty() {
1166                    std::collections::BTreeMap::new()
1167                } else {
1168                    let pairs: Vec<(DeviceId, u32)> =
1169                        codec::decode(&device_leaves_bytes).unwrap_or_default();
1170                    pairs.into_iter().collect()
1171                };
1172
1173            match Conversation::load(
1174                meta.id,
1175                meta.clone(),
1176                cursor,
1177                device_leaves,
1178                self.local_device.device_id.clone(),
1179                self.crypto.clone(),
1180                self.signing.clone(),
1181                self.storage.clone(),
1182                now_ms,
1183            ) {
1184                Ok(Some(convo)) => {
1185                    tracing::debug!(
1186                        target: "ping_core::client",
1187                        convo = %id_hex,
1188                        epoch = meta.epoch,
1189                        "rehydrated conversation from disk"
1190                    );
1191                    self.conversations.write().insert(meta.id, convo);
1192                }
1193                Ok(None) => {
1194                    tracing::warn!(
1195                        target: "ping_core::client",
1196                        convo = %id_hex,
1197                        "host-side meta present but OpenMLS state missing — skipping"
1198                    );
1199                }
1200                Err(e) => {
1201                    tracing::warn!(
1202                        target: "ping_core::client",
1203                        convo = %id_hex,
1204                        error = %e,
1205                        "Conversation::load failed — skipping"
1206                    );
1207                }
1208            }
1209        }
1210        Ok(())
1211    }
1212
1213    // ------------------- Multi-device API -------------------
1214
1215    /// Build a [`LinkingTicket`] for a new device. The caller obtains `new_device_kp` from the
1216    /// new device (e.g., via QR-encoded handshake) and is responsible for sealing the returned
1217    /// ticket against the new device's ephemeral X25519 pubkey before transmission via
1218    /// [`ping_link::seal_ticket`].
1219    ///
1220    /// [CR-13] `last_app_events` is a host-supplied list of `(conversation_id, app_event_bytes)`
1221    /// for the new device's "what you missed" UI. The SDK adds its own metas + (currently-
1222    /// empty) per-conversation MLS state and bundles everything into
1223    /// [`device::CatchupSnapshot`], CBOR-encoded into the ticket's `catchup_snapshot` field.
1224    /// Pass an empty `Vec` to suppress catchup data (the new device sees an empty
1225    /// conversation list until normal sync runs).
1226    pub async fn build_linking_ticket(
1227        self: &Arc<Self>,
1228        new_device_id: DeviceId,
1229        new_device_kp: Vec<u8>,
1230        last_app_events: Vec<(ConversationId, Vec<u8>)>,
1231        now_ms: u64,
1232    ) -> Result<LinkingTicket> {
1233        let device_binding_sig = self.identity.sign_device_binding(&new_device_id.0);
1234        let dg_id = device_group_id_for(self.identity.user_id());
1235
1236        // [CR-10] DG is eagerly created at init now, but call ensure here too so
1237        // hosts that bypass `MessagingClient::init` (mocked tests, legacy upgrade
1238        // paths) keep working.
1239        self.ensure_device_group(now_ms).await?;
1240
1241        // Admit the new device to the DeviceGroup.
1242        let outcome = {
1243            let mut conversations = self.conversations.write();
1244            // `ensure_device_group` above creates it, but return a typed error
1245            // instead of panicking (which would unwind across FFI/wasm) on the
1246            // pathological case where it's still missing.
1247            let dg = conversations.get_mut(&dg_id).ok_or_else(|| {
1248                Error::Invalid("device group missing after ensure_device_group".into())
1249            })?;
1250            // [CR-2] Record the new device's leaf in the DG so future `revoke_device`
1251            // can find it. The new_device_id we got as a parameter is the inviter's
1252            // own assertion — same trust model as the rest of `add_members`.
1253            dg.add_members(vec![(new_device_id.clone(), new_device_kp)], now_ms)?
1254        };
1255
1256        // [CR-13] Assemble the catchup snapshot: SDK-known conversation metadata + host-
1257        // supplied last-known plaintext per conversation. [CR-7] now populates
1258        // `group_state_bytes` with each group's MLS state so the new device can decrypt
1259        // historical traffic without re-Welcoming. An empty `group_state_bytes` would
1260        // mean either a group with no exportable state (shouldn't happen) or an
1261        // encoder failure (we let those propagate as errors below).
1262        let catchup_snapshot = if last_app_events.is_empty() && self.conversations.read().is_empty()
1263        {
1264            // Cheap path: nothing to snapshot, skip the encode round-trip.
1265            Vec::new()
1266        } else {
1267            let conversation_metas: Vec<CatchupConversationEntry> = self
1268                .conversations
1269                .read()
1270                .values()
1271                .map(|c| -> Result<CatchupConversationEntry> {
1272                    // CR-7: per-group state. We deliberately keep the export bytes
1273                    // inside the (HPKE-sealed-by-CR-3) LinkingTicket; the receiver
1274                    // calls `import_state_snapshot` with these bytes after `consume_linking_ticket`.
1275                    let group_bytes = c.export_state_snapshot(now_ms)?.to_vec();
1276                    Ok(CatchupConversationEntry {
1277                        conversation_id: c.id(),
1278                        meta: c.meta().clone(),
1279                        group_state_bytes: group_bytes,
1280                    })
1281                })
1282                .collect::<Result<_>>()?;
1283            let last_app_events_per_conv: Vec<CatchupAppEventEntry> = last_app_events
1284                .into_iter()
1285                .map(|(conversation_id, app_event_bytes)| CatchupAppEventEntry {
1286                    conversation_id,
1287                    app_event_bytes,
1288                })
1289                .collect();
1290            CatchupSnapshot {
1291                v: CATCHUP_SNAPSHOT_VERSION,
1292                conversation_metas,
1293                last_app_events_per_conv,
1294            }
1295            .encode()?
1296        };
1297
1298        Ok(LinkingTicket {
1299            v: 1,
1300            user_id: self.identity.user_id().clone(),
1301            user_pubkey: self.identity.public_key().to_bytes().to_vec(),
1302            new_device_id,
1303            device_binding_sig,
1304            device_group_welcome: outcome.welcome.payload,
1305            catchup_snapshot,
1306        })
1307    }
1308
1309    /// Apply a received linking ticket. Joins the user's DeviceGroup; the catch-up snapshot
1310    /// (if any) is decrypted by the host using the standard per-conversation channel afterwards.
1311    pub async fn consume_linking_ticket(
1312        self: &Arc<Self>,
1313        ticket: &LinkingTicket,
1314        now_ms: u64,
1315    ) -> Result<()> {
1316        // Verify the binding the existing device made for us. (Ed25519 public keys are 32 bytes.)
1317        let pk_bytes: [u8; 32] = ticket
1318            .user_pubkey
1319            .as_slice()
1320            .try_into()
1321            .map_err(|_| Error::Identity("user_pubkey must be 32 bytes".into()))?;
1322        let user_pk = ed25519_dalek::VerifyingKey::from_bytes(&pk_bytes)
1323            .map_err(|e| Error::Identity(format!("bad user pubkey: {e}")))?;
1324        Identity::verify_device_binding(
1325            &user_pk,
1326            &ticket.user_id,
1327            &ticket.new_device_id.0,
1328            &ticket.device_binding_sig,
1329        )?;
1330        if ticket.new_device_id != self.local_device.device_id {
1331            return Err(Error::Invalid(
1332                "ticket addressed to a different device".into(),
1333            ));
1334        }
1335
1336        let dummy_env = MessageEnvelope::new(
1337            ConversationId(device_group_id_for(&ticket.user_id).0),
1338            0,
1339            MessageKind::Welcome,
1340            self.local_device.device_id.clone(),
1341            0,
1342            crate::clock::Hlc::ZERO,
1343            ticket.device_group_welcome.clone(),
1344        );
1345        self.join_conversation(&dummy_env, now_ms).await?;
1346        Ok(())
1347    }
1348
1349    /// [CR-7] Export the MLS state snapshot for one open conversation.
1350    ///
1351    /// Thin pass-through to [`Conversation::export_state_snapshot`]. Returned bytes
1352    /// are wrapped in `Zeroizing` because they contain past epoch secrets.
1353    pub fn export_conversation_state_snapshot(
1354        &self,
1355        conv_id: ConversationId,
1356        now_ms: u64,
1357    ) -> Result<zeroize::Zeroizing<Vec<u8>>> {
1358        let guard = self.conversations.read();
1359        let convo = guard
1360            .get(&conv_id)
1361            .ok_or_else(|| Error::UnknownConversation(conv_id.as_hex()))?;
1362        convo.export_state_snapshot(now_ms)
1363    }
1364
1365    /// [CR-7] Import a `GroupStateSnapshot` produced by another device's
1366    /// [`Conversation::export_state_snapshot`].
1367    ///
1368    /// Replays the snapshot's entries into this client's OpenMLS provider, then
1369    /// reconstructs the `Conversation` handle via `MlsGroup::load`. After return,
1370    /// the conversation is in `list_conversations()` and `send`/`process_envelope`
1371    /// work against it normally.
1372    ///
1373    /// **Scope.** This is for the *same-user* hand-off (linking, recovery). The
1374    /// snapshot exposes the exporter's view of past epoch secrets for the target
1375    /// group; only call this when the receiving device has been authenticated to
1376    /// the same user identity (mnemonic, QR-handshake). Cross-user history transfer
1377    /// uses HPKE-sealed AppEvent re-shares (umbrella §15.6), not this method.
1378    ///
1379    /// **Sanity.** Refuses snapshots whose `group_id` doesn't match the bytes the
1380    /// receiver intends to claim — guards against host bugs that shuffle snapshots
1381    /// between groups. Refuses mismatched OpenMLS storage versions outright; no
1382    /// silent forward/back compatibility.
1383    pub async fn import_state_snapshot(
1384        self: &Arc<Self>,
1385        snapshot_bytes: &[u8],
1386        now_ms: u64,
1387    ) -> Result<ConversationId> {
1388        use crate::device::GroupStateSnapshot;
1389        let snap = GroupStateSnapshot::decode(snapshot_bytes)
1390            .map_err(|e| Error::Invalid(format!("snapshot decode: {e}")))?;
1391
1392        if snap.openmls_storage_version != openmls_traits::storage::CURRENT_VERSION {
1393            return Err(Error::Invalid(format!(
1394                "snapshot openmls_storage_version={} not supported (this SDK supports v={})",
1395                snap.openmls_storage_version,
1396                openmls_traits::storage::CURRENT_VERSION
1397            )));
1398        }
1399
1400        let conv_id = snap.group_id;
1401
1402        // Refuse if we already have an active handle for this conv — the host should
1403        // close it first, otherwise import silently overwrites in-memory state and
1404        // the existing handle becomes stale.
1405        if self.conversations.read().contains_key(&conv_id) {
1406            return Err(Error::Invalid(format!(
1407                "conversation {} already open; close before importing snapshot",
1408                conv_id.as_hex()
1409            )));
1410        }
1411
1412        // Replay raw KV pairs into the provider's working set.
1413        let entries: Vec<(Vec<u8>, Vec<u8>)> =
1414            snap.entries.into_iter().map(|e| (e.key, e.value)).collect();
1415        self.crypto
1416            .import_entries(entries)
1417            .map_err(|e| Error::Storage(format!("import entries: {e}")))?;
1418
1419        // Reconstruct the Conversation handle. `Conversation::load` will return
1420        // `Ok(None)` if OpenMLS still can't find the group — i.e. our snapshot was
1421        // incomplete or for a different storage version.
1422        let meta = ConversationMeta {
1423            id: conv_id,
1424            name: None,
1425            epoch: 0, // will be overwritten from the loaded group state in process()
1426            member_count: 0,
1427            is_device_group: false, // host can flip this via meta update if needed
1428            created_at_ms: now_ms,
1429        };
1430        let convo = Conversation::load(
1431            conv_id,
1432            meta,
1433            SyncCursor::default(),
1434            std::collections::BTreeMap::new(),
1435            self.local_device.device_id.clone(),
1436            self.crypto.clone(),
1437            self.signing.clone(),
1438            self.storage.clone(),
1439            now_ms,
1440        )?
1441        .ok_or_else(|| {
1442            Error::Invalid(
1443                "snapshot imported but OpenMLS could not load the group — snapshot may be incomplete or storage version mismatched"
1444                    .into(),
1445            )
1446        })?;
1447
1448        // Pull the live epoch + member count from the loaded group so the meta we
1449        // just stubbed is consistent with what we'll observe on subsequent process_envelope.
1450        let live_epoch = convo.epoch();
1451        let live_members = convo.group.members().count() as u32;
1452        let live_name = convo.name_from_group_state();
1453        let mut convo = convo;
1454        convo.meta.epoch = live_epoch;
1455        convo.meta.member_count = live_members;
1456        // Recover the name from the loaded GroupContext state (a snapshot import
1457        // is join-equivalent; the stubbed `name: None` would otherwise stick).
1458        convo.meta.name = live_name;
1459        convo.snapshot_to_storage().await?;
1460
1461        self.conversations.write().insert(conv_id, convo);
1462        Ok(conv_id)
1463    }
1464
1465    /// Export a derived secret from one conversation's MLS exporter ([CR-8]).
1466    ///
1467    /// Thin pass-through to [`Conversation::export_secret`]. See that method's doc comment
1468    /// for the contract on `label`, `context`, length validation, and zeroization. The
1469    /// returned `Zeroizing<Vec<u8>>` is automatically wiped when dropped.
1470    pub fn export_conversation_secret(
1471        &self,
1472        conv_id: ConversationId,
1473        label: &str,
1474        context: &[u8],
1475        length: usize,
1476    ) -> Result<Zeroizing<Vec<u8>>> {
1477        let guard = self.conversations.read();
1478        let convo = guard
1479            .get(&conv_id)
1480            .ok_or_else(|| Error::UnknownConversation(conv_id.as_hex()))?;
1481        convo.export_secret(label, context, length)
1482    }
1483
1484    /// Revoke a device by removing its leaf from every conversation where we know its
1485    /// position ([CR-2]).
1486    ///
1487    /// Returns one Commit envelope per conversation the device was a leaf in. The host
1488    /// broadcasts each envelope to the affected conversation; the SDK has also already
1489    /// handed them to the transport via `transport.send` (idempotent broadcast is the
1490    /// host's call).
1491    ///
1492    /// **Scope.** The SDK can only resolve leaves it recorded itself — either when it
1493    /// admitted the device via [`Self::add_members`] or when this device joined as the
1494    /// target via Welcome. For peer-admitted devices the leaf index isn't locally known;
1495    /// those conversations are silently skipped. The host can fall back to
1496    /// `remove_members(leaf_index)` directly using a transport-side directory lookup if
1497    /// it needs to revoke from those conversations too. See
1498    /// `docs/architecture/multi-device.md §Device removal` for the broader flow.
1499    ///
1500    /// Conversations with no entry for `device_id` produce no envelope; an empty `Vec`
1501    /// return is a valid outcome (e.g. the device was already revoked, or was never
1502    /// added by this client).
1503    #[allow(clippy::await_holding_lock)] // see add_members for rationale
1504    pub async fn revoke_device(
1505        &self,
1506        device_id: DeviceId,
1507        now_ms: u64,
1508    ) -> Result<Vec<MessageEnvelope>> {
1509        // 1. Walk every open conversation and gather (conv_id, leaf_index) pairs where
1510        //    we know `device_id` controls a leaf. Done under a read lock so we don't hold
1511        //    the write lock across the per-conversation remove path.
1512        let targets: Vec<(ConversationId, u32)> = self
1513            .conversations
1514            .read()
1515            .iter()
1516            .filter_map(|(id, c)| c.leaf_index_of(&device_id).map(|leaf| (*id, leaf)))
1517            .collect();
1518
1519        // 2. For each target, emit a remove_members commit. We do this sequentially: each
1520        //    one is a separate MLS epoch advance on its own group, and they don't share
1521        //    state, so parallel issuance is safe but adds complexity we don't need for v1.
1522        let mut envelopes = Vec::with_capacity(targets.len());
1523        for (conv_id, leaf_index) in targets {
1524            let envelope = {
1525                let mut guard = self.conversations.write();
1526                let convo = guard
1527                    .get_mut(&conv_id)
1528                    .ok_or_else(|| Error::UnknownConversation(conv_id.as_hex()))?;
1529                convo.remove_members(vec![leaf_index], now_ms)?
1530            };
1531            self.transport.send(envelope.clone()).await?;
1532            if let Some(c) = self.conversations.read().get(&conv_id) {
1533                c.snapshot_to_storage().await?;
1534            }
1535            envelopes.push(envelope);
1536        }
1537
1538        // 3. Notify the auth-layer server so it can invalidate the
1539        //    revoked device's KeyPackage pool, mark `auth.devices.revoked_at`,
1540        //    and refuse any future envelope signed by the revoked device's
1541        //    JWT. Done AFTER the MLS Commits so peers learn via MLS first
1542        //    (the canonical path) and the auth layer is the eventual-
1543        //    consistency cleanup. Transport failures bubble up so callers
1544        //    can retry — but the MLS-side work has already shipped, so
1545        //    the device is functionally revoked in every group; only the
1546        //    auth-layer KeyPackage purge is pending.
1547        self.transport.revoke_device_remote(device_id).await?;
1548        Ok(envelopes)
1549    }
1550}
1551
1552fn device_group_id_for(user_id: &UserId) -> ConversationId {
1553    // Deterministic 16-byte ID derived from the user's id, prefixed so it cannot collide with
1554    // a randomly-generated ULID in normal use (ULIDs start with a millisecond timestamp).
1555    let mut bytes = [0u8; 16];
1556    bytes[0] = 0xFF;
1557    bytes[1] = 0xDC; // "DeviCe" group sentinel
1558    let h = codec::sha256(&user_id.0);
1559    bytes[2..].copy_from_slice(&h[..14]);
1560    ConversationId(bytes)
1561}
1562
1563fn encode_local_device(d: &LocalDevice) -> Result<Vec<u8>> {
1564    use serde::Serialize;
1565    #[derive(Serialize)]
1566    struct Persisted<'a> {
1567        device_id: &'a DeviceId,
1568        label: &'a str,
1569        created_at_ms: u64,
1570        #[serde(with = "serde_bytes")]
1571        signing_seed: &'a [u8],
1572    }
1573    codec::encode(&Persisted {
1574        device_id: &d.device_id,
1575        label: &d.label,
1576        created_at_ms: d.created_at_ms,
1577        signing_seed: d.signing.as_bytes(),
1578    })
1579}
1580
1581fn decode_local_device(bytes: &[u8], user_id: UserId) -> Result<LocalDevice> {
1582    use serde::Deserialize;
1583    #[derive(Deserialize)]
1584    struct Persisted {
1585        device_id: DeviceId,
1586        label: String,
1587        created_at_ms: u64,
1588        #[serde(with = "serde_bytes")]
1589        signing_seed: Vec<u8>,
1590    }
1591    let p: Persisted = codec::decode(bytes)?;
1592    let seed: [u8; 32] = p
1593        .signing_seed
1594        .as_slice()
1595        .try_into()
1596        .map_err(|_| Error::Invalid("device signing seed must be 32 bytes".into()))?;
1597    let signing = ed25519_dalek::SigningKey::from_bytes(&seed);
1598    Ok(LocalDevice {
1599        device_id: p.device_id,
1600        user_id,
1601        label: p.label,
1602        signing,
1603        created_at_ms: p.created_at_ms,
1604    })
1605}