Skip to main content

river_core/
chat_delegate.rs

1use serde::{Deserialize, Serialize};
2
3use crate::room_state::direct_messages::PurgeToken;
4use crate::room_state::member::MemberId;
5
6/// Room key identifier (owner's verifying key bytes)
7pub type RoomKey = [u8; 32];
8
9/// Delegate storage key for the outbound-DM plaintext cache.
10///
11/// Lets the sender re-render their own DMs as plaintext on reload /
12/// secondary device, since the room contract only carries
13/// ECIES-ciphertext (only the recipient can decrypt). See issue
14/// freenet/river#256.
15pub const OUTBOUND_DMS_STORAGE_KEY: &[u8] = b"outbound_dms";
16
17/// Persistent cache of outbound DM plaintext, keyed by
18/// `(room_owner_vk, recipient, purge_token)` inside each entry.
19///
20/// Stored as a `Vec` rather than `HashMap` so JSON serialization works
21/// — see the "non-string map keys" bug-prevention pattern in
22/// `freenet/.claude/rules/bug-prevention-patterns.md`. Lookups are
23/// linear, which is fine: the store is bounded by per-pair caps
24/// (`MAX_DM_MESSAGES_PER_PAIR`) and pruned on purge tombstones.
25///
26/// Piggybacks the `hidden_threads` list (issue freenet/river#261) — a
27/// purely local "hide this DM thread from my left rail until a fresh
28/// message arrives" view filter. We pack it into the same delegate
29/// blob so a single chat-delegate fetch hydrates both, and so a hide
30/// on device A is visible on device B without a second storage key.
31#[derive(Debug, Default, Clone, Serialize, Deserialize, PartialEq, Eq)]
32pub struct OutboundDmStore {
33    #[serde(default)]
34    pub entries: Vec<OutboundDmEntry>,
35    /// Per-`(room, peer)` "hidden-at" cutoff timestamps. Filter rule:
36    /// a thread is hidden iff `hidden_at_ts >= max(message.timestamp)`
37    /// for messages between the local user and `peer` in that room.
38    /// `#[serde(default)]` so pre-#261 wire bytes (a `Vec<entries>`-only
39    /// `OutboundDmStore`) keep decoding into an empty `hidden_threads`.
40    #[serde(default)]
41    pub hidden_threads: Vec<HiddenDmThreadEntry>,
42}
43
44/// A single user-driven "hide this DM thread until further notice" entry.
45///
46/// `Vec`-of-struct rather than `HashMap` for the same reason as
47/// [`OutboundDmStore::entries`] — JSON object keys must serialize as
48/// strings (see "Non-string map keys in JSON-serialized API types" in
49/// `freenet/.claude/rules/bug-prevention-patterns.md`), and the
50/// `(VerifyingKey, MemberId)` lookup tuple does not. The local UI hot
51/// path materialises this list into a HashMap for O(1) render-time
52/// lookup — see `OutboundDmsCache` in the river-ui crate.
53#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
54pub struct HiddenDmThreadEntry {
55    /// Room owner verifying key — disambiguates the same peer being a
56    /// member of multiple rooms. Raw 32 bytes to match the `RoomKey`
57    /// convention used elsewhere in this module and to keep the type
58    /// JSON-friendly.
59    pub room_owner_vk: [u8; 32],
60    /// Counterparty in the DM thread.
61    pub peer: MemberId,
62    /// Unix seconds at the moment the user clicked "Hide thread".
63    /// Captured from the most-recent message timestamp in the thread at
64    /// that moment (or `now()` if the thread had no messages yet — an
65    /// edge case that can happen if the user composes-and-hides from
66    /// the picker without ever sending) so any subsequent message
67    /// strictly later than this revives the thread.
68    pub hidden_at_ts: u64,
69}
70
71/// A single outbound DM the local user composed and sent.
72///
73/// `purge_token` matches `AuthorizedDirectMessage::purge_token()` for
74/// the ciphertext that was emitted, so the UI/CLI can join the local
75/// plaintext to the contract-state ciphertext entry, and so that
76/// purge tombstones (which list `PurgeToken`s) can prune this store in
77/// lockstep with the contract.
78#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
79pub struct OutboundDmEntry {
80    /// Room owner verifying key — disambiguates the same recipient
81    /// being a member of multiple rooms. Raw 32 bytes to match the
82    /// `RoomKey` convention used elsewhere in this module and to keep
83    /// the type JSON-friendly.
84    pub room_owner_vk: [u8; 32],
85    /// Local user's `MemberId` *at send time*, derived from the room
86    /// signing key. Present so a second device that re-loads under a
87    /// different room identity can tell which of its identities sent
88    /// the DM.
89    pub sender: MemberId,
90    pub recipient: MemberId,
91    pub purge_token: PurgeToken,
92    /// Unix seconds — same value used in the on-wire `DirectMessage`.
93    pub timestamp: u64,
94    pub plaintext: String,
95}
96
97/// Unique identifier for a signing request (for request/response correlation)
98pub type RequestId = u64;
99
100/// Messages sent from the App to the Chat Delegate
101#[derive(Debug, Clone, Serialize, Deserialize)]
102pub enum ChatDelegateRequestMsg {
103    // Key-value storage operations
104    StoreRequest {
105        key: ChatDelegateKey,
106        value: Vec<u8>,
107    },
108    GetRequest {
109        key: ChatDelegateKey,
110    },
111    DeleteRequest {
112        key: ChatDelegateKey,
113    },
114    ListRequest,
115
116    // -----------------------------------------------------------------
117    // Optimistic-concurrency (compare-and-swap) storage operations.
118    //
119    // These exist so that multiple concurrent clients (e.g. two browser
120    // tabs both editing the room list) cannot silently clobber each
121    // other. The plain `StoreRequest` above is a blind last-writer-wins
122    // overwrite; a stale tab's full-snapshot write would destroy a
123    // newer tab's additions (freenet/river#345). The delegate tracks a
124    // per-key generation counter; a `CasStoreRequest` only succeeds when
125    // the caller's `expected_generation` matches the stored generation,
126    // so a stale writer is rejected and forced to re-read + merge.
127    //
128    // Appended to the enum (never reordered): ciborium serializes these
129    // externally-tagged by variant *name*, and only the current delegate
130    // ever receives them, so old delegate WASM is unaffected.
131    // -----------------------------------------------------------------
132    /// Read a value together with its current generation, so the caller
133    /// can subsequently issue a [`CasStoreRequest`] with the matching
134    /// `expected_generation`. A missing key reports generation `0`.
135    GetVersionedRequest {
136        key: ChatDelegateKey,
137    },
138    /// Store `value` only if the key's current generation equals
139    /// `expected_generation` (`0` = expect absent / first write). On a
140    /// match the generation is incremented and the value stored; on a
141    /// mismatch the store is rejected and the current generation + value
142    /// are returned so the caller can merge and retry without an extra
143    /// round-trip.
144    CasStoreRequest {
145        key: ChatDelegateKey,
146        value: Vec<u8>,
147        expected_generation: u64,
148    },
149
150    // Signing key management
151    /// Store a signing key for a room (room_key = owner's verifying key bytes)
152    StoreSigningKey {
153        room_key: RoomKey,
154        signing_key_bytes: [u8; 32],
155    },
156    /// Get the public key for a stored signing key
157    GetPublicKey {
158        room_key: RoomKey,
159    },
160
161    // Signing operations - pass serialized data, get signature back
162    // All signing ops include request_id for response correlation
163    /// Sign a message (MessageV1 serialized)
164    SignMessage {
165        room_key: RoomKey,
166        request_id: RequestId,
167        message_bytes: Vec<u8>,
168    },
169    /// Sign a member invitation (Member serialized)
170    SignMember {
171        room_key: RoomKey,
172        request_id: RequestId,
173        member_bytes: Vec<u8>,
174    },
175    /// Sign a ban (BanV1 serialized)
176    SignBan {
177        room_key: RoomKey,
178        request_id: RequestId,
179        ban_bytes: Vec<u8>,
180    },
181    /// Sign a room configuration (Configuration serialized)
182    SignConfig {
183        room_key: RoomKey,
184        request_id: RequestId,
185        config_bytes: Vec<u8>,
186    },
187    /// Sign member info (MemberInfo serialized)
188    SignMemberInfo {
189        room_key: RoomKey,
190        request_id: RequestId,
191        member_info_bytes: Vec<u8>,
192    },
193    /// Sign a secret version record (SecretVersionRecordV1 serialized)
194    SignSecretVersion {
195        room_key: RoomKey,
196        request_id: RequestId,
197        record_bytes: Vec<u8>,
198    },
199    /// Sign an encrypted secret for member (EncryptedSecretForMemberV1 serialized)
200    SignEncryptedSecret {
201        room_key: RoomKey,
202        request_id: RequestId,
203        secret_bytes: Vec<u8>,
204    },
205    /// Sign a room upgrade (RoomUpgrade serialized)
206    SignUpgrade {
207        room_key: RoomKey,
208        request_id: RequestId,
209        upgrade_bytes: Vec<u8>,
210    },
211
212    /// Ask the delegate to subscribe to a room contract so the delegate can
213    /// drive secret rotation when the membership set changes.
214    ///
215    /// `contract_id` is the 32-byte ContractInstanceId for the room contract,
216    /// computed by the UI as `BLAKE3(room_contract_wasm_hash || params)` where
217    /// `params` is the cbor-serialised `ChatRoomParametersV1 { owner: room_owner_vk }`.
218    /// We pass it explicitly rather than recomputing it inside the delegate so
219    /// that the delegate WASM doesn't need to bundle the room-contract WASM.
220    ///
221    /// `request_id` is a per-call unique correlator so the UI's pending-request
222    /// registry can route the matching response back to the awaiting future.
223    /// Without it, the registry was keyed by `room_owner_vk` only, so a second
224    /// `EnsureRoomSubscription` for the same room while a previous one was
225    /// still in flight would collide on the same registry slot — the second
226    /// caller would receive the first call's response (potentially from a
227    /// different session epoch) or have its own response routed to the first
228    /// caller. See PR #276 review feedback for the exact race scenario.
229    EnsureRoomSubscription {
230        room_owner_vk: RoomKey,
231        request_id: RequestId,
232        contract_id: [u8; 32],
233    },
234}
235
236#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
237pub struct ChatDelegateKey(pub Vec<u8>);
238
239impl ChatDelegateKey {
240    pub fn new(key: Vec<u8>) -> Self {
241        Self(key)
242    }
243
244    pub fn as_bytes(&self) -> &[u8] {
245        &self.0
246    }
247}
248
249/// Responses sent from the Chat Delegate to the App
250#[derive(Debug, Clone, Serialize, Deserialize)]
251pub enum ChatDelegateResponseMsg {
252    // Key-value storage responses
253    GetResponse {
254        key: ChatDelegateKey,
255        value: Option<Vec<u8>>,
256    },
257    ListResponse {
258        keys: Vec<ChatDelegateKey>,
259    },
260    StoreResponse {
261        key: ChatDelegateKey,
262        value_size: usize,
263        result: Result<(), String>,
264    },
265    DeleteResponse {
266        key: ChatDelegateKey,
267        result: Result<(), String>,
268    },
269
270    // Compare-and-swap storage responses (see the request variants).
271    /// Response to [`ChatDelegateRequestMsg::GetVersionedRequest`].
272    GetVersionedResponse {
273        key: ChatDelegateKey,
274        value: Option<Vec<u8>>,
275        /// Current generation of the stored value (`0` if absent).
276        generation: u64,
277    },
278    /// Response to [`ChatDelegateRequestMsg::CasStoreRequest`].
279    CasStoreResponse {
280        key: ChatDelegateKey,
281        result: CasStoreResult,
282    },
283
284    // Signing key management responses
285    /// Response to StoreSigningKey
286    StoreSigningKeyResponse {
287        room_key: RoomKey,
288        result: Result<(), String>,
289    },
290    /// Response to GetPublicKey
291    GetPublicKeyResponse {
292        room_key: RoomKey,
293        /// The public key bytes if the signing key exists
294        public_key: Option<[u8; 32]>,
295    },
296
297    // Signing response (used for all signing operations)
298    /// Response to any signing operation
299    SignResponse {
300        room_key: RoomKey,
301        /// The request ID for correlation
302        request_id: RequestId,
303        /// The signature bytes (64 bytes for Ed25519, as Vec for serde compatibility)
304        signature: Result<Vec<u8>, String>,
305    },
306
307    /// Response to [`ChatDelegateRequestMsg::EnsureRoomSubscription`].
308    ///
309    /// `Ok(())` means the delegate emitted a `SubscribeContractRequest` to the
310    /// runtime; the actual subscription confirmation flows back to the
311    /// delegate as `InboundDelegateMsg::SubscribeContractResponse` and is not
312    /// surfaced to the UI.
313    ///
314    /// `request_id` is echoed back from the request so the UI can route the
315    /// response to the specific awaiting future (see the doc-comment on the
316    /// request variant for why a per-request correlator is required).
317    EnsureRoomSubscriptionResponse {
318        room_owner_vk: RoomKey,
319        request_id: RequestId,
320        result: Result<(), String>,
321    },
322}
323
324/// Outcome of a [`ChatDelegateRequestMsg::CasStoreRequest`].
325#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
326pub enum CasStoreResult {
327    /// The compare-and-swap succeeded; carries the new generation after
328    /// the write (the caller should remember it for its next store).
329    Stored { generation: u64 },
330    /// Generation mismatch — the store was rejected because another
331    /// writer advanced the key first. Carries the current generation and
332    /// value so the caller can merge its pending changes and retry with
333    /// `expected_generation = current_generation`.
334    Conflict {
335        current_generation: u64,
336        current_value: Option<Vec<u8>>,
337    },
338    /// The host-function store failed (e.g. secret storage error).
339    Failed(String),
340}
341
342/// Pure helper: should a DM thread for `(room, peer)` currently be
343/// hidden from the left rail?
344///
345/// Returns `true` iff the user has a `HiddenDmThreadEntry` for the
346/// thread AND no message in the thread has `timestamp > hidden_at_ts`.
347/// The strict `>` (not `>=`) on `max_message_ts` ensures that the
348/// message used to populate `hidden_at_ts` does not itself revive the
349/// thread. Any newer INBOUND DM crosses the threshold and revives;
350/// outbound sends revive via the explicit `unhide_dm_thread` instead,
351/// since freenet/river#526 made the archive clock inbound-only.
352///
353/// `hidden_threads` is the full slice as loaded from the delegate;
354/// the lookup is linear because the list is tiny (bounded by the
355/// number of distinct DM pairs the user has actually hidden, which
356/// in practice is well under a hundred). Issue freenet/river#261.
357pub fn is_thread_hidden(
358    hidden_threads: &[HiddenDmThreadEntry],
359    room_owner_vk: &[u8; 32],
360    peer: MemberId,
361    max_message_ts: u64,
362) -> bool {
363    hidden_threads
364        .iter()
365        .find(|h| &h.room_owner_vk == room_owner_vk && h.peer == peer)
366        .is_some_and(|h| max_message_ts <= h.hidden_at_ts)
367}
368
369#[cfg(test)]
370mod tests {
371    use super::*;
372    use freenet_scaffold::util::FastHash;
373
374    fn sample_entry() -> OutboundDmEntry {
375        OutboundDmEntry {
376            room_owner_vk: [9u8; 32],
377            sender: MemberId(FastHash(0xdead_beef)),
378            recipient: MemberId(FastHash(0x1234_5678)),
379            purge_token: crate::room_state::direct_messages::PurgeToken([0xab; 16]),
380            timestamp: 1_700_000_000,
381            plaintext: "hello, world".to_string(),
382        }
383    }
384
385    fn sample_hidden() -> HiddenDmThreadEntry {
386        HiddenDmThreadEntry {
387            room_owner_vk: [9u8; 32],
388            peer: MemberId(FastHash(0x1234_5678)),
389            hidden_at_ts: 1_700_000_000,
390        }
391    }
392
393    /// Per the "Non-string map keys in JSON-serialized API types" rule
394    /// in `freenet/.claude/rules/bug-prevention-patterns.md`, any
395    /// wire-boundary type stored in the delegate that may eventually be
396    /// JSON-encoded (e.g. by a future diagnostic upload) MUST have a
397    /// JSON round-trip test. `OutboundDmStore` uses a `Vec` precisely
398    /// for this reason; this test pins that choice.
399    #[test]
400    fn outbound_dm_store_json_round_trips() {
401        let store = OutboundDmStore {
402            entries: vec![sample_entry()],
403            hidden_threads: vec![],
404        };
405        let json = serde_json::to_string(&store).expect("serialize JSON");
406        let parsed: OutboundDmStore = serde_json::from_str(&json).expect("parse JSON");
407        assert_eq!(parsed, store);
408    }
409
410    /// CBOR is the on-the-wire encoding used by the chat delegate, so
411    /// it also has to round-trip.
412    #[test]
413    fn outbound_dm_store_cbor_round_trips() {
414        let store = OutboundDmStore {
415            entries: vec![sample_entry(), sample_entry()],
416            hidden_threads: vec![],
417        };
418        let mut buf = Vec::new();
419        ciborium::ser::into_writer(&store, &mut buf).expect("serialize CBOR");
420        let parsed: OutboundDmStore =
421            ciborium::de::from_reader(buf.as_slice()).expect("parse CBOR");
422        assert_eq!(parsed, store);
423    }
424
425    /// An empty store must serialize to a stable, parseable shape so a
426    /// fresh delegate can persist a zero-entry store the first time
427    /// any caller asks for one.
428    #[test]
429    fn empty_outbound_dm_store_json_round_trips() {
430        let store = OutboundDmStore::default();
431        let json = serde_json::to_string(&store).expect("serialize JSON");
432        let parsed: OutboundDmStore = serde_json::from_str(&json).expect("parse JSON");
433        assert_eq!(parsed, store);
434    }
435
436    /// Issue freenet/river#261 — `hidden_threads` is now part of the
437    /// stored blob. JSON round-trip pins the load-bearing wire shape
438    /// (Vec of struct, not HashMap) per the "non-string map keys"
439    /// bug-prevention pattern.
440    #[test]
441    fn outbound_dm_store_with_hidden_threads_json_round_trips() {
442        let store = OutboundDmStore {
443            entries: vec![sample_entry()],
444            hidden_threads: vec![sample_hidden()],
445        };
446        let json = serde_json::to_string(&store).expect("serialize JSON");
447        let parsed: OutboundDmStore = serde_json::from_str(&json).expect("parse JSON");
448        assert_eq!(parsed, store);
449    }
450
451    /// CBOR is the on-the-wire encoding used by the chat delegate, so
452    /// `hidden_threads` must also CBOR round-trip.
453    #[test]
454    fn outbound_dm_store_with_hidden_threads_cbor_round_trips() {
455        let store = OutboundDmStore {
456            entries: vec![],
457            hidden_threads: vec![sample_hidden(), sample_hidden()],
458        };
459        let mut buf = Vec::new();
460        ciborium::ser::into_writer(&store, &mut buf).expect("serialize CBOR");
461        let parsed: OutboundDmStore =
462            ciborium::de::from_reader(buf.as_slice()).expect("parse CBOR");
463        assert_eq!(parsed, store);
464    }
465
466    /// Issue freenet/river#261 BACKWARDS COMPAT: pre-#261 delegate
467    /// blobs serialized BEFORE `hidden_threads` existed must still
468    /// decode into an `OutboundDmStore` with an empty `hidden_threads`
469    /// (via `#[serde(default)]`). Without this, the first reload
470    /// after upgrading River would fail to hydrate the outbound-DM
471    /// cache for every user whose delegate already has the #256 blob.
472    ///
473    /// We pin both JSON and CBOR: JSON via a hand-written legacy
474    /// payload (the shape `serde_json::to_string` would have produced
475    /// before this PR), and CBOR by serializing a synthetic
476    /// "legacy" store that contains only the `entries` field via the
477    /// same path the delegate writes.
478    #[test]
479    fn outbound_dm_store_decodes_legacy_json_without_hidden_threads() {
480        let legacy_json = r#"{"entries":[]}"#;
481        let parsed: OutboundDmStore =
482            serde_json::from_str(legacy_json).expect("legacy JSON must decode");
483        assert!(parsed.entries.is_empty());
484        assert!(parsed.hidden_threads.is_empty());
485    }
486
487    #[test]
488    fn outbound_dm_store_decodes_legacy_cbor_without_hidden_threads() {
489        // Simulate a pre-#261 OutboundDmStore wire shape by hand-rolling
490        // a CBOR map with only the `entries` key. `ciborium` writes
491        // structs as definite-length maps keyed by field name, so we
492        // reproduce that here:
493        //   { "entries": [ <one OutboundDmEntry> ] }
494        #[derive(Serialize)]
495        struct LegacyStore {
496            entries: Vec<OutboundDmEntry>,
497        }
498        let legacy = LegacyStore {
499            entries: vec![sample_entry()],
500        };
501        let mut buf = Vec::new();
502        ciborium::ser::into_writer(&legacy, &mut buf).expect("serialize legacy CBOR");
503
504        let parsed: OutboundDmStore =
505            ciborium::de::from_reader(buf.as_slice()).expect("legacy CBOR must decode");
506        assert_eq!(parsed.entries.len(), 1);
507        assert!(parsed.hidden_threads.is_empty());
508    }
509
510    /// `is_thread_hidden` returns false on an empty hidden list. This
511    /// is the common-case fast-path for users who have never hidden a
512    /// thread.
513    #[test]
514    fn is_thread_hidden_returns_false_for_empty_list() {
515        let peer = MemberId(FastHash(0x42));
516        assert!(!is_thread_hidden(&[], &[0u8; 32], peer, 0));
517        assert!(!is_thread_hidden(&[], &[0u8; 32], peer, 1_000));
518    }
519
520    /// `is_thread_hidden` returns true when the only message in the
521    /// thread is the one whose timestamp was captured as
522    /// `hidden_at_ts`. The strict `>` rule means equal-timestamp does
523    /// NOT revive — otherwise hiding a thread whose most-recent message
524    /// is exactly `now()` would instantly fail to hide.
525    #[test]
526    fn is_thread_hidden_equal_timestamp_stays_hidden() {
527        let peer = MemberId(FastHash(0x42));
528        let hidden = vec![HiddenDmThreadEntry {
529            room_owner_vk: [9u8; 32],
530            peer,
531            hidden_at_ts: 1_000,
532        }];
533        assert!(is_thread_hidden(&hidden, &[9u8; 32], peer, 1_000));
534    }
535
536    /// Any message strictly later than `hidden_at_ts` must revive the
537    /// thread.
538    #[test]
539    fn is_thread_hidden_strictly_later_message_revives() {
540        let peer = MemberId(FastHash(0x42));
541        let hidden = vec![HiddenDmThreadEntry {
542            room_owner_vk: [9u8; 32],
543            peer,
544            hidden_at_ts: 1_000,
545        }];
546        assert!(!is_thread_hidden(&hidden, &[9u8; 32], peer, 1_001));
547    }
548
549    /// A `HiddenDmThreadEntry` for the same peer in a DIFFERENT room
550    /// must NOT hide the thread in the current room. The lookup is
551    /// `(room, peer)`, not just `peer`.
552    #[test]
553    fn is_thread_hidden_is_scoped_per_room() {
554        let peer = MemberId(FastHash(0x42));
555        let hidden = vec![HiddenDmThreadEntry {
556            room_owner_vk: [9u8; 32],
557            peer,
558            hidden_at_ts: 1_000,
559        }];
560        // Different room — must be visible.
561        assert!(!is_thread_hidden(&hidden, &[7u8; 32], peer, 500));
562    }
563
564    /// A `HiddenDmThreadEntry` for a DIFFERENT peer in the same room
565    /// must NOT hide the thread.
566    #[test]
567    fn is_thread_hidden_is_scoped_per_peer() {
568        let peer_a = MemberId(FastHash(0x42));
569        let peer_b = MemberId(FastHash(0x99));
570        let hidden = vec![HiddenDmThreadEntry {
571            room_owner_vk: [9u8; 32],
572            peer: peer_a,
573            hidden_at_ts: 1_000,
574        }];
575        assert!(!is_thread_hidden(&hidden, &[9u8; 32], peer_b, 500));
576    }
577
578    /// Thread with no messages at all (max_message_ts = 0) and a
579    /// `hidden_at_ts` of 0 stays hidden — the strict `<=` rule still
580    /// applies. This matches the design intent: a freshly hidden
581    /// empty thread should stay hidden until either party sends a
582    /// (necessarily later, since unix ts > 0) message.
583    #[test]
584    fn is_thread_hidden_zero_max_zero_hidden_stays_hidden() {
585        let peer = MemberId(FastHash(0x42));
586        let hidden = vec![HiddenDmThreadEntry {
587            room_owner_vk: [9u8; 32],
588            peer,
589            hidden_at_ts: 0,
590        }];
591        assert!(is_thread_hidden(&hidden, &[9u8; 32], peer, 0));
592    }
593
594    // ------------------------------------------------------------------
595    // CAS wire-format round-trips (freenet/river#345).
596    //
597    // The chat-delegate request/response enums had no dedicated wire test
598    // before this — they were only exercised end-to-end. These pin the
599    // new compare-and-swap variants so a future serde/ciborium change
600    // can't silently break the protocol between the UI and the delegate.
601    // ------------------------------------------------------------------
602
603    fn cbor_round_trip_request(msg: &ChatDelegateRequestMsg) -> ChatDelegateRequestMsg {
604        let mut buf = Vec::new();
605        ciborium::ser::into_writer(msg, &mut buf).expect("serialize request");
606        ciborium::from_reader(buf.as_slice()).expect("deserialize request")
607    }
608
609    fn cbor_round_trip_response(msg: &ChatDelegateResponseMsg) -> ChatDelegateResponseMsg {
610        let mut buf = Vec::new();
611        ciborium::ser::into_writer(msg, &mut buf).expect("serialize response");
612        ciborium::from_reader(buf.as_slice()).expect("deserialize response")
613    }
614
615    #[test]
616    fn cas_store_request_cbor_round_trips() {
617        let msg = ChatDelegateRequestMsg::CasStoreRequest {
618            key: ChatDelegateKey(b"rooms_data".to_vec()),
619            value: vec![1, 2, 3, 4, 5],
620            expected_generation: 7,
621        };
622        match cbor_round_trip_request(&msg) {
623            ChatDelegateRequestMsg::CasStoreRequest {
624                key,
625                value,
626                expected_generation,
627            } => {
628                assert_eq!(key.as_bytes(), b"rooms_data");
629                assert_eq!(value, vec![1, 2, 3, 4, 5]);
630                assert_eq!(expected_generation, 7);
631            }
632            other => panic!("wrong variant: {other:?}"),
633        }
634    }
635
636    #[test]
637    fn get_versioned_request_cbor_round_trips() {
638        let msg = ChatDelegateRequestMsg::GetVersionedRequest {
639            key: ChatDelegateKey(b"rooms_data".to_vec()),
640        };
641        assert!(matches!(
642            cbor_round_trip_request(&msg),
643            ChatDelegateRequestMsg::GetVersionedRequest { .. }
644        ));
645    }
646
647    #[test]
648    fn cas_store_result_stored_round_trips() {
649        let msg = ChatDelegateResponseMsg::CasStoreResponse {
650            key: ChatDelegateKey(b"rooms_data".to_vec()),
651            result: CasStoreResult::Stored { generation: 42 },
652        };
653        match cbor_round_trip_response(&msg) {
654            ChatDelegateResponseMsg::CasStoreResponse {
655                result: CasStoreResult::Stored { generation },
656                ..
657            } => assert_eq!(generation, 42),
658            other => panic!("wrong variant: {other:?}"),
659        }
660    }
661
662    #[test]
663    fn cas_store_result_conflict_round_trips() {
664        let msg = ChatDelegateResponseMsg::CasStoreResponse {
665            key: ChatDelegateKey(b"rooms_data".to_vec()),
666            result: CasStoreResult::Conflict {
667                current_generation: 9,
668                current_value: Some(vec![0xaa, 0xbb]),
669            },
670        };
671        match cbor_round_trip_response(&msg) {
672            ChatDelegateResponseMsg::CasStoreResponse {
673                result:
674                    CasStoreResult::Conflict {
675                        current_generation,
676                        current_value,
677                    },
678                ..
679            } => {
680                assert_eq!(current_generation, 9);
681                assert_eq!(current_value, Some(vec![0xaa, 0xbb]));
682            }
683            other => panic!("wrong variant: {other:?}"),
684        }
685    }
686
687    #[test]
688    fn get_versioned_response_round_trips() {
689        let msg = ChatDelegateResponseMsg::GetVersionedResponse {
690            key: ChatDelegateKey(b"rooms_data".to_vec()),
691            value: Some(vec![1, 2, 3]),
692            generation: 5,
693        };
694        match cbor_round_trip_response(&msg) {
695            ChatDelegateResponseMsg::GetVersionedResponse {
696                value, generation, ..
697            } => {
698                assert_eq!(value, Some(vec![1, 2, 3]));
699                assert_eq!(generation, 5);
700            }
701            other => panic!("wrong variant: {other:?}"),
702        }
703    }
704
705    /// Appending the CAS variants must not disturb the existing variants:
706    /// a plain `StoreRequest`/`GetResponse` still round-trips unchanged.
707    /// (ciborium tags externally by name, so this holds by construction —
708    /// the test pins it against an accidental `#[serde(...)]` change.)
709    #[test]
710    fn legacy_variants_still_round_trip_after_appending_cas() {
711        let store = ChatDelegateRequestMsg::StoreRequest {
712            key: ChatDelegateKey(b"outbound_dms".to_vec()),
713            value: vec![9, 9, 9],
714        };
715        assert!(matches!(
716            cbor_round_trip_request(&store),
717            ChatDelegateRequestMsg::StoreRequest { .. }
718        ));
719        let get = ChatDelegateResponseMsg::GetResponse {
720            key: ChatDelegateKey(b"outbound_dms".to_vec()),
721            value: Some(vec![9, 9, 9]),
722        };
723        assert!(matches!(
724            cbor_round_trip_response(&get),
725            ChatDelegateResponseMsg::GetResponse { .. }
726        ));
727    }
728}