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    // Signing key management
117    /// Store a signing key for a room (room_key = owner's verifying key bytes)
118    StoreSigningKey {
119        room_key: RoomKey,
120        signing_key_bytes: [u8; 32],
121    },
122    /// Get the public key for a stored signing key
123    GetPublicKey {
124        room_key: RoomKey,
125    },
126
127    // Signing operations - pass serialized data, get signature back
128    // All signing ops include request_id for response correlation
129    /// Sign a message (MessageV1 serialized)
130    SignMessage {
131        room_key: RoomKey,
132        request_id: RequestId,
133        message_bytes: Vec<u8>,
134    },
135    /// Sign a member invitation (Member serialized)
136    SignMember {
137        room_key: RoomKey,
138        request_id: RequestId,
139        member_bytes: Vec<u8>,
140    },
141    /// Sign a ban (BanV1 serialized)
142    SignBan {
143        room_key: RoomKey,
144        request_id: RequestId,
145        ban_bytes: Vec<u8>,
146    },
147    /// Sign a room configuration (Configuration serialized)
148    SignConfig {
149        room_key: RoomKey,
150        request_id: RequestId,
151        config_bytes: Vec<u8>,
152    },
153    /// Sign member info (MemberInfo serialized)
154    SignMemberInfo {
155        room_key: RoomKey,
156        request_id: RequestId,
157        member_info_bytes: Vec<u8>,
158    },
159    /// Sign a secret version record (SecretVersionRecordV1 serialized)
160    SignSecretVersion {
161        room_key: RoomKey,
162        request_id: RequestId,
163        record_bytes: Vec<u8>,
164    },
165    /// Sign an encrypted secret for member (EncryptedSecretForMemberV1 serialized)
166    SignEncryptedSecret {
167        room_key: RoomKey,
168        request_id: RequestId,
169        secret_bytes: Vec<u8>,
170    },
171    /// Sign a room upgrade (RoomUpgrade serialized)
172    SignUpgrade {
173        room_key: RoomKey,
174        request_id: RequestId,
175        upgrade_bytes: Vec<u8>,
176    },
177
178    /// Ask the delegate to subscribe to a room contract so the delegate can
179    /// drive secret rotation when the membership set changes.
180    ///
181    /// `contract_id` is the 32-byte ContractInstanceId for the room contract,
182    /// computed by the UI as `BLAKE3(room_contract_wasm_hash || params)` where
183    /// `params` is the cbor-serialised `ChatRoomParametersV1 { owner: room_owner_vk }`.
184    /// We pass it explicitly rather than recomputing it inside the delegate so
185    /// that the delegate WASM doesn't need to bundle the room-contract WASM.
186    ///
187    /// `request_id` is a per-call unique correlator so the UI's pending-request
188    /// registry can route the matching response back to the awaiting future.
189    /// Without it, the registry was keyed by `room_owner_vk` only, so a second
190    /// `EnsureRoomSubscription` for the same room while a previous one was
191    /// still in flight would collide on the same registry slot — the second
192    /// caller would receive the first call's response (potentially from a
193    /// different session epoch) or have its own response routed to the first
194    /// caller. See PR #276 review feedback for the exact race scenario.
195    EnsureRoomSubscription {
196        room_owner_vk: RoomKey,
197        request_id: RequestId,
198        contract_id: [u8; 32],
199    },
200}
201
202#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
203pub struct ChatDelegateKey(pub Vec<u8>);
204
205impl ChatDelegateKey {
206    pub fn new(key: Vec<u8>) -> Self {
207        Self(key)
208    }
209
210    pub fn as_bytes(&self) -> &[u8] {
211        &self.0
212    }
213}
214
215/// Responses sent from the Chat Delegate to the App
216#[derive(Debug, Clone, Serialize, Deserialize)]
217pub enum ChatDelegateResponseMsg {
218    // Key-value storage responses
219    GetResponse {
220        key: ChatDelegateKey,
221        value: Option<Vec<u8>>,
222    },
223    ListResponse {
224        keys: Vec<ChatDelegateKey>,
225    },
226    StoreResponse {
227        key: ChatDelegateKey,
228        value_size: usize,
229        result: Result<(), String>,
230    },
231    DeleteResponse {
232        key: ChatDelegateKey,
233        result: Result<(), String>,
234    },
235
236    // Signing key management responses
237    /// Response to StoreSigningKey
238    StoreSigningKeyResponse {
239        room_key: RoomKey,
240        result: Result<(), String>,
241    },
242    /// Response to GetPublicKey
243    GetPublicKeyResponse {
244        room_key: RoomKey,
245        /// The public key bytes if the signing key exists
246        public_key: Option<[u8; 32]>,
247    },
248
249    // Signing response (used for all signing operations)
250    /// Response to any signing operation
251    SignResponse {
252        room_key: RoomKey,
253        /// The request ID for correlation
254        request_id: RequestId,
255        /// The signature bytes (64 bytes for Ed25519, as Vec for serde compatibility)
256        signature: Result<Vec<u8>, String>,
257    },
258
259    /// Response to [`ChatDelegateRequestMsg::EnsureRoomSubscription`].
260    ///
261    /// `Ok(())` means the delegate emitted a `SubscribeContractRequest` to the
262    /// runtime; the actual subscription confirmation flows back to the
263    /// delegate as `InboundDelegateMsg::SubscribeContractResponse` and is not
264    /// surfaced to the UI.
265    ///
266    /// `request_id` is echoed back from the request so the UI can route the
267    /// response to the specific awaiting future (see the doc-comment on the
268    /// request variant for why a per-request correlator is required).
269    EnsureRoomSubscriptionResponse {
270        room_owner_vk: RoomKey,
271        request_id: RequestId,
272        result: Result<(), String>,
273    },
274}
275
276/// Pure helper: should a DM thread for `(room, peer)` currently be
277/// hidden from the left rail?
278///
279/// Returns `true` iff the user has a `HiddenDmThreadEntry` for the
280/// thread AND no message in the thread has `timestamp > hidden_at_ts`.
281/// The strict `>` (not `>=`) on `max_message_ts` ensures that the
282/// message used to populate `hidden_at_ts` does not itself revive the
283/// thread. Any newer DM (inbound or outbound) crosses the threshold
284/// and revives.
285///
286/// `hidden_threads` is the full slice as loaded from the delegate;
287/// the lookup is linear because the list is tiny (bounded by the
288/// number of distinct DM pairs the user has actually hidden, which
289/// in practice is well under a hundred). Issue freenet/river#261.
290pub fn is_thread_hidden(
291    hidden_threads: &[HiddenDmThreadEntry],
292    room_owner_vk: &[u8; 32],
293    peer: MemberId,
294    max_message_ts: u64,
295) -> bool {
296    hidden_threads
297        .iter()
298        .find(|h| &h.room_owner_vk == room_owner_vk && h.peer == peer)
299        .is_some_and(|h| max_message_ts <= h.hidden_at_ts)
300}
301
302#[cfg(test)]
303mod tests {
304    use super::*;
305    use freenet_scaffold::util::FastHash;
306
307    fn sample_entry() -> OutboundDmEntry {
308        OutboundDmEntry {
309            room_owner_vk: [9u8; 32],
310            sender: MemberId(FastHash(0xdead_beef)),
311            recipient: MemberId(FastHash(0x1234_5678)),
312            purge_token: crate::room_state::direct_messages::PurgeToken([0xab; 16]),
313            timestamp: 1_700_000_000,
314            plaintext: "hello, world".to_string(),
315        }
316    }
317
318    fn sample_hidden() -> HiddenDmThreadEntry {
319        HiddenDmThreadEntry {
320            room_owner_vk: [9u8; 32],
321            peer: MemberId(FastHash(0x1234_5678)),
322            hidden_at_ts: 1_700_000_000,
323        }
324    }
325
326    /// Per the "Non-string map keys in JSON-serialized API types" rule
327    /// in `freenet/.claude/rules/bug-prevention-patterns.md`, any
328    /// wire-boundary type stored in the delegate that may eventually be
329    /// JSON-encoded (e.g. by a future diagnostic upload) MUST have a
330    /// JSON round-trip test. `OutboundDmStore` uses a `Vec` precisely
331    /// for this reason; this test pins that choice.
332    #[test]
333    fn outbound_dm_store_json_round_trips() {
334        let store = OutboundDmStore {
335            entries: vec![sample_entry()],
336            hidden_threads: vec![],
337        };
338        let json = serde_json::to_string(&store).expect("serialize JSON");
339        let parsed: OutboundDmStore = serde_json::from_str(&json).expect("parse JSON");
340        assert_eq!(parsed, store);
341    }
342
343    /// CBOR is the on-the-wire encoding used by the chat delegate, so
344    /// it also has to round-trip.
345    #[test]
346    fn outbound_dm_store_cbor_round_trips() {
347        let store = OutboundDmStore {
348            entries: vec![sample_entry(), sample_entry()],
349            hidden_threads: vec![],
350        };
351        let mut buf = Vec::new();
352        ciborium::ser::into_writer(&store, &mut buf).expect("serialize CBOR");
353        let parsed: OutboundDmStore =
354            ciborium::de::from_reader(buf.as_slice()).expect("parse CBOR");
355        assert_eq!(parsed, store);
356    }
357
358    /// An empty store must serialize to a stable, parseable shape so a
359    /// fresh delegate can persist a zero-entry store the first time
360    /// any caller asks for one.
361    #[test]
362    fn empty_outbound_dm_store_json_round_trips() {
363        let store = OutboundDmStore::default();
364        let json = serde_json::to_string(&store).expect("serialize JSON");
365        let parsed: OutboundDmStore = serde_json::from_str(&json).expect("parse JSON");
366        assert_eq!(parsed, store);
367    }
368
369    /// Issue freenet/river#261 — `hidden_threads` is now part of the
370    /// stored blob. JSON round-trip pins the load-bearing wire shape
371    /// (Vec of struct, not HashMap) per the "non-string map keys"
372    /// bug-prevention pattern.
373    #[test]
374    fn outbound_dm_store_with_hidden_threads_json_round_trips() {
375        let store = OutboundDmStore {
376            entries: vec![sample_entry()],
377            hidden_threads: vec![sample_hidden()],
378        };
379        let json = serde_json::to_string(&store).expect("serialize JSON");
380        let parsed: OutboundDmStore = serde_json::from_str(&json).expect("parse JSON");
381        assert_eq!(parsed, store);
382    }
383
384    /// CBOR is the on-the-wire encoding used by the chat delegate, so
385    /// `hidden_threads` must also CBOR round-trip.
386    #[test]
387    fn outbound_dm_store_with_hidden_threads_cbor_round_trips() {
388        let store = OutboundDmStore {
389            entries: vec![],
390            hidden_threads: vec![sample_hidden(), sample_hidden()],
391        };
392        let mut buf = Vec::new();
393        ciborium::ser::into_writer(&store, &mut buf).expect("serialize CBOR");
394        let parsed: OutboundDmStore =
395            ciborium::de::from_reader(buf.as_slice()).expect("parse CBOR");
396        assert_eq!(parsed, store);
397    }
398
399    /// Issue freenet/river#261 BACKWARDS COMPAT: pre-#261 delegate
400    /// blobs serialized BEFORE `hidden_threads` existed must still
401    /// decode into an `OutboundDmStore` with an empty `hidden_threads`
402    /// (via `#[serde(default)]`). Without this, the first reload
403    /// after upgrading River would fail to hydrate the outbound-DM
404    /// cache for every user whose delegate already has the #256 blob.
405    ///
406    /// We pin both JSON and CBOR: JSON via a hand-written legacy
407    /// payload (the shape `serde_json::to_string` would have produced
408    /// before this PR), and CBOR by serializing a synthetic
409    /// "legacy" store that contains only the `entries` field via the
410    /// same path the delegate writes.
411    #[test]
412    fn outbound_dm_store_decodes_legacy_json_without_hidden_threads() {
413        let legacy_json = r#"{"entries":[]}"#;
414        let parsed: OutboundDmStore =
415            serde_json::from_str(legacy_json).expect("legacy JSON must decode");
416        assert!(parsed.entries.is_empty());
417        assert!(parsed.hidden_threads.is_empty());
418    }
419
420    #[test]
421    fn outbound_dm_store_decodes_legacy_cbor_without_hidden_threads() {
422        // Simulate a pre-#261 OutboundDmStore wire shape by hand-rolling
423        // a CBOR map with only the `entries` key. `ciborium` writes
424        // structs as definite-length maps keyed by field name, so we
425        // reproduce that here:
426        //   { "entries": [ <one OutboundDmEntry> ] }
427        #[derive(Serialize)]
428        struct LegacyStore {
429            entries: Vec<OutboundDmEntry>,
430        }
431        let legacy = LegacyStore {
432            entries: vec![sample_entry()],
433        };
434        let mut buf = Vec::new();
435        ciborium::ser::into_writer(&legacy, &mut buf).expect("serialize legacy CBOR");
436
437        let parsed: OutboundDmStore =
438            ciborium::de::from_reader(buf.as_slice()).expect("legacy CBOR must decode");
439        assert_eq!(parsed.entries.len(), 1);
440        assert!(parsed.hidden_threads.is_empty());
441    }
442
443    /// `is_thread_hidden` returns false on an empty hidden list. This
444    /// is the common-case fast-path for users who have never hidden a
445    /// thread.
446    #[test]
447    fn is_thread_hidden_returns_false_for_empty_list() {
448        let peer = MemberId(FastHash(0x42));
449        assert!(!is_thread_hidden(&[], &[0u8; 32], peer, 0));
450        assert!(!is_thread_hidden(&[], &[0u8; 32], peer, 1_000));
451    }
452
453    /// `is_thread_hidden` returns true when the only message in the
454    /// thread is the one whose timestamp was captured as
455    /// `hidden_at_ts`. The strict `>` rule means equal-timestamp does
456    /// NOT revive — otherwise hiding a thread whose most-recent message
457    /// is exactly `now()` would instantly fail to hide.
458    #[test]
459    fn is_thread_hidden_equal_timestamp_stays_hidden() {
460        let peer = MemberId(FastHash(0x42));
461        let hidden = vec![HiddenDmThreadEntry {
462            room_owner_vk: [9u8; 32],
463            peer,
464            hidden_at_ts: 1_000,
465        }];
466        assert!(is_thread_hidden(&hidden, &[9u8; 32], peer, 1_000));
467    }
468
469    /// Any message strictly later than `hidden_at_ts` must revive the
470    /// thread.
471    #[test]
472    fn is_thread_hidden_strictly_later_message_revives() {
473        let peer = MemberId(FastHash(0x42));
474        let hidden = vec![HiddenDmThreadEntry {
475            room_owner_vk: [9u8; 32],
476            peer,
477            hidden_at_ts: 1_000,
478        }];
479        assert!(!is_thread_hidden(&hidden, &[9u8; 32], peer, 1_001));
480    }
481
482    /// A `HiddenDmThreadEntry` for the same peer in a DIFFERENT room
483    /// must NOT hide the thread in the current room. The lookup is
484    /// `(room, peer)`, not just `peer`.
485    #[test]
486    fn is_thread_hidden_is_scoped_per_room() {
487        let peer = MemberId(FastHash(0x42));
488        let hidden = vec![HiddenDmThreadEntry {
489            room_owner_vk: [9u8; 32],
490            peer,
491            hidden_at_ts: 1_000,
492        }];
493        // Different room — must be visible.
494        assert!(!is_thread_hidden(&hidden, &[7u8; 32], peer, 500));
495    }
496
497    /// A `HiddenDmThreadEntry` for a DIFFERENT peer in the same room
498    /// must NOT hide the thread.
499    #[test]
500    fn is_thread_hidden_is_scoped_per_peer() {
501        let peer_a = MemberId(FastHash(0x42));
502        let peer_b = MemberId(FastHash(0x99));
503        let hidden = vec![HiddenDmThreadEntry {
504            room_owner_vk: [9u8; 32],
505            peer: peer_a,
506            hidden_at_ts: 1_000,
507        }];
508        assert!(!is_thread_hidden(&hidden, &[9u8; 32], peer_b, 500));
509    }
510
511    /// Thread with no messages at all (max_message_ts = 0) and a
512    /// `hidden_at_ts` of 0 stays hidden — the strict `<=` rule still
513    /// applies. This matches the design intent: a freshly hidden
514    /// empty thread should stay hidden until either party sends a
515    /// (necessarily later, since unix ts > 0) message.
516    #[test]
517    fn is_thread_hidden_zero_max_zero_hidden_stays_hidden() {
518        let peer = MemberId(FastHash(0x42));
519        let hidden = vec![HiddenDmThreadEntry {
520            room_owner_vk: [9u8; 32],
521            peer,
522            hidden_at_ts: 0,
523        }];
524        assert!(is_thread_hidden(&hidden, &[9u8; 32], peer, 0));
525    }
526}