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 EnsureRoomSubscription {
187 room_owner_vk: RoomKey,
188 contract_id: [u8; 32],
189 },
190}
191
192#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
193pub struct ChatDelegateKey(pub Vec<u8>);
194
195impl ChatDelegateKey {
196 pub fn new(key: Vec<u8>) -> Self {
197 Self(key)
198 }
199
200 pub fn as_bytes(&self) -> &[u8] {
201 &self.0
202 }
203}
204
205/// Responses sent from the Chat Delegate to the App
206#[derive(Debug, Clone, Serialize, Deserialize)]
207pub enum ChatDelegateResponseMsg {
208 // Key-value storage responses
209 GetResponse {
210 key: ChatDelegateKey,
211 value: Option<Vec<u8>>,
212 },
213 ListResponse {
214 keys: Vec<ChatDelegateKey>,
215 },
216 StoreResponse {
217 key: ChatDelegateKey,
218 value_size: usize,
219 result: Result<(), String>,
220 },
221 DeleteResponse {
222 key: ChatDelegateKey,
223 result: Result<(), String>,
224 },
225
226 // Signing key management responses
227 /// Response to StoreSigningKey
228 StoreSigningKeyResponse {
229 room_key: RoomKey,
230 result: Result<(), String>,
231 },
232 /// Response to GetPublicKey
233 GetPublicKeyResponse {
234 room_key: RoomKey,
235 /// The public key bytes if the signing key exists
236 public_key: Option<[u8; 32]>,
237 },
238
239 // Signing response (used for all signing operations)
240 /// Response to any signing operation
241 SignResponse {
242 room_key: RoomKey,
243 /// The request ID for correlation
244 request_id: RequestId,
245 /// The signature bytes (64 bytes for Ed25519, as Vec for serde compatibility)
246 signature: Result<Vec<u8>, String>,
247 },
248
249 /// Response to [`ChatDelegateRequestMsg::EnsureRoomSubscription`].
250 ///
251 /// `Ok(())` means the delegate emitted a `SubscribeContractRequest` to the
252 /// runtime; the actual subscription confirmation flows back to the
253 /// delegate as `InboundDelegateMsg::SubscribeContractResponse` and is not
254 /// surfaced to the UI.
255 EnsureRoomSubscriptionResponse {
256 room_owner_vk: RoomKey,
257 result: Result<(), String>,
258 },
259}
260
261/// Pure helper: should a DM thread for `(room, peer)` currently be
262/// hidden from the left rail?
263///
264/// Returns `true` iff the user has a `HiddenDmThreadEntry` for the
265/// thread AND no message in the thread has `timestamp > hidden_at_ts`.
266/// The strict `>` (not `>=`) on `max_message_ts` ensures that the
267/// message used to populate `hidden_at_ts` does not itself revive the
268/// thread. Any newer DM (inbound or outbound) crosses the threshold
269/// and revives.
270///
271/// `hidden_threads` is the full slice as loaded from the delegate;
272/// the lookup is linear because the list is tiny (bounded by the
273/// number of distinct DM pairs the user has actually hidden, which
274/// in practice is well under a hundred). Issue freenet/river#261.
275pub fn is_thread_hidden(
276 hidden_threads: &[HiddenDmThreadEntry],
277 room_owner_vk: &[u8; 32],
278 peer: MemberId,
279 max_message_ts: u64,
280) -> bool {
281 hidden_threads
282 .iter()
283 .find(|h| &h.room_owner_vk == room_owner_vk && h.peer == peer)
284 .is_some_and(|h| max_message_ts <= h.hidden_at_ts)
285}
286
287#[cfg(test)]
288mod tests {
289 use super::*;
290 use freenet_scaffold::util::FastHash;
291
292 fn sample_entry() -> OutboundDmEntry {
293 OutboundDmEntry {
294 room_owner_vk: [9u8; 32],
295 sender: MemberId(FastHash(0xdead_beef)),
296 recipient: MemberId(FastHash(0x1234_5678)),
297 purge_token: crate::room_state::direct_messages::PurgeToken([0xab; 16]),
298 timestamp: 1_700_000_000,
299 plaintext: "hello, world".to_string(),
300 }
301 }
302
303 fn sample_hidden() -> HiddenDmThreadEntry {
304 HiddenDmThreadEntry {
305 room_owner_vk: [9u8; 32],
306 peer: MemberId(FastHash(0x1234_5678)),
307 hidden_at_ts: 1_700_000_000,
308 }
309 }
310
311 /// Per the "Non-string map keys in JSON-serialized API types" rule
312 /// in `freenet/.claude/rules/bug-prevention-patterns.md`, any
313 /// wire-boundary type stored in the delegate that may eventually be
314 /// JSON-encoded (e.g. by a future diagnostic upload) MUST have a
315 /// JSON round-trip test. `OutboundDmStore` uses a `Vec` precisely
316 /// for this reason; this test pins that choice.
317 #[test]
318 fn outbound_dm_store_json_round_trips() {
319 let store = OutboundDmStore {
320 entries: vec![sample_entry()],
321 hidden_threads: vec![],
322 };
323 let json = serde_json::to_string(&store).expect("serialize JSON");
324 let parsed: OutboundDmStore = serde_json::from_str(&json).expect("parse JSON");
325 assert_eq!(parsed, store);
326 }
327
328 /// CBOR is the on-the-wire encoding used by the chat delegate, so
329 /// it also has to round-trip.
330 #[test]
331 fn outbound_dm_store_cbor_round_trips() {
332 let store = OutboundDmStore {
333 entries: vec![sample_entry(), sample_entry()],
334 hidden_threads: vec![],
335 };
336 let mut buf = Vec::new();
337 ciborium::ser::into_writer(&store, &mut buf).expect("serialize CBOR");
338 let parsed: OutboundDmStore =
339 ciborium::de::from_reader(buf.as_slice()).expect("parse CBOR");
340 assert_eq!(parsed, store);
341 }
342
343 /// An empty store must serialize to a stable, parseable shape so a
344 /// fresh delegate can persist a zero-entry store the first time
345 /// any caller asks for one.
346 #[test]
347 fn empty_outbound_dm_store_json_round_trips() {
348 let store = OutboundDmStore::default();
349 let json = serde_json::to_string(&store).expect("serialize JSON");
350 let parsed: OutboundDmStore = serde_json::from_str(&json).expect("parse JSON");
351 assert_eq!(parsed, store);
352 }
353
354 /// Issue freenet/river#261 — `hidden_threads` is now part of the
355 /// stored blob. JSON round-trip pins the load-bearing wire shape
356 /// (Vec of struct, not HashMap) per the "non-string map keys"
357 /// bug-prevention pattern.
358 #[test]
359 fn outbound_dm_store_with_hidden_threads_json_round_trips() {
360 let store = OutboundDmStore {
361 entries: vec![sample_entry()],
362 hidden_threads: vec![sample_hidden()],
363 };
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 /// CBOR is the on-the-wire encoding used by the chat delegate, so
370 /// `hidden_threads` must also CBOR round-trip.
371 #[test]
372 fn outbound_dm_store_with_hidden_threads_cbor_round_trips() {
373 let store = OutboundDmStore {
374 entries: vec![],
375 hidden_threads: vec![sample_hidden(), sample_hidden()],
376 };
377 let mut buf = Vec::new();
378 ciborium::ser::into_writer(&store, &mut buf).expect("serialize CBOR");
379 let parsed: OutboundDmStore =
380 ciborium::de::from_reader(buf.as_slice()).expect("parse CBOR");
381 assert_eq!(parsed, store);
382 }
383
384 /// Issue freenet/river#261 BACKWARDS COMPAT: pre-#261 delegate
385 /// blobs serialized BEFORE `hidden_threads` existed must still
386 /// decode into an `OutboundDmStore` with an empty `hidden_threads`
387 /// (via `#[serde(default)]`). Without this, the first reload
388 /// after upgrading River would fail to hydrate the outbound-DM
389 /// cache for every user whose delegate already has the #256 blob.
390 ///
391 /// We pin both JSON and CBOR: JSON via a hand-written legacy
392 /// payload (the shape `serde_json::to_string` would have produced
393 /// before this PR), and CBOR by serializing a synthetic
394 /// "legacy" store that contains only the `entries` field via the
395 /// same path the delegate writes.
396 #[test]
397 fn outbound_dm_store_decodes_legacy_json_without_hidden_threads() {
398 let legacy_json = r#"{"entries":[]}"#;
399 let parsed: OutboundDmStore =
400 serde_json::from_str(legacy_json).expect("legacy JSON must decode");
401 assert!(parsed.entries.is_empty());
402 assert!(parsed.hidden_threads.is_empty());
403 }
404
405 #[test]
406 fn outbound_dm_store_decodes_legacy_cbor_without_hidden_threads() {
407 // Simulate a pre-#261 OutboundDmStore wire shape by hand-rolling
408 // a CBOR map with only the `entries` key. `ciborium` writes
409 // structs as definite-length maps keyed by field name, so we
410 // reproduce that here:
411 // { "entries": [ <one OutboundDmEntry> ] }
412 #[derive(Serialize)]
413 struct LegacyStore {
414 entries: Vec<OutboundDmEntry>,
415 }
416 let legacy = LegacyStore {
417 entries: vec![sample_entry()],
418 };
419 let mut buf = Vec::new();
420 ciborium::ser::into_writer(&legacy, &mut buf).expect("serialize legacy CBOR");
421
422 let parsed: OutboundDmStore =
423 ciborium::de::from_reader(buf.as_slice()).expect("legacy CBOR must decode");
424 assert_eq!(parsed.entries.len(), 1);
425 assert!(parsed.hidden_threads.is_empty());
426 }
427
428 /// `is_thread_hidden` returns false on an empty hidden list. This
429 /// is the common-case fast-path for users who have never hidden a
430 /// thread.
431 #[test]
432 fn is_thread_hidden_returns_false_for_empty_list() {
433 let peer = MemberId(FastHash(0x42));
434 assert!(!is_thread_hidden(&[], &[0u8; 32], peer, 0));
435 assert!(!is_thread_hidden(&[], &[0u8; 32], peer, 1_000));
436 }
437
438 /// `is_thread_hidden` returns true when the only message in the
439 /// thread is the one whose timestamp was captured as
440 /// `hidden_at_ts`. The strict `>` rule means equal-timestamp does
441 /// NOT revive — otherwise hiding a thread whose most-recent message
442 /// is exactly `now()` would instantly fail to hide.
443 #[test]
444 fn is_thread_hidden_equal_timestamp_stays_hidden() {
445 let peer = MemberId(FastHash(0x42));
446 let hidden = vec![HiddenDmThreadEntry {
447 room_owner_vk: [9u8; 32],
448 peer,
449 hidden_at_ts: 1_000,
450 }];
451 assert!(is_thread_hidden(&hidden, &[9u8; 32], peer, 1_000));
452 }
453
454 /// Any message strictly later than `hidden_at_ts` must revive the
455 /// thread.
456 #[test]
457 fn is_thread_hidden_strictly_later_message_revives() {
458 let peer = MemberId(FastHash(0x42));
459 let hidden = vec![HiddenDmThreadEntry {
460 room_owner_vk: [9u8; 32],
461 peer,
462 hidden_at_ts: 1_000,
463 }];
464 assert!(!is_thread_hidden(&hidden, &[9u8; 32], peer, 1_001));
465 }
466
467 /// A `HiddenDmThreadEntry` for the same peer in a DIFFERENT room
468 /// must NOT hide the thread in the current room. The lookup is
469 /// `(room, peer)`, not just `peer`.
470 #[test]
471 fn is_thread_hidden_is_scoped_per_room() {
472 let peer = MemberId(FastHash(0x42));
473 let hidden = vec![HiddenDmThreadEntry {
474 room_owner_vk: [9u8; 32],
475 peer,
476 hidden_at_ts: 1_000,
477 }];
478 // Different room — must be visible.
479 assert!(!is_thread_hidden(&hidden, &[7u8; 32], peer, 500));
480 }
481
482 /// A `HiddenDmThreadEntry` for a DIFFERENT peer in the same room
483 /// must NOT hide the thread.
484 #[test]
485 fn is_thread_hidden_is_scoped_per_peer() {
486 let peer_a = MemberId(FastHash(0x42));
487 let peer_b = MemberId(FastHash(0x99));
488 let hidden = vec![HiddenDmThreadEntry {
489 room_owner_vk: [9u8; 32],
490 peer: peer_a,
491 hidden_at_ts: 1_000,
492 }];
493 assert!(!is_thread_hidden(&hidden, &[9u8; 32], peer_b, 500));
494 }
495
496 /// Thread with no messages at all (max_message_ts = 0) and a
497 /// `hidden_at_ts` of 0 stays hidden — the strict `<=` rule still
498 /// applies. This matches the design intent: a freshly hidden
499 /// empty thread should stay hidden until either party sends a
500 /// (necessarily later, since unix ts > 0) message.
501 #[test]
502 fn is_thread_hidden_zero_max_zero_hidden_stays_hidden() {
503 let peer = MemberId(FastHash(0x42));
504 let hidden = vec![HiddenDmThreadEntry {
505 room_owner_vk: [9u8; 32],
506 peer,
507 hidden_at_ts: 0,
508 }];
509 assert!(is_thread_hidden(&hidden, &[9u8; 32], peer, 0));
510 }
511}