use serde::{Deserialize, Serialize};
use crate::room_state::direct_messages::PurgeToken;
use crate::room_state::member::MemberId;
pub type RoomKey = [u8; 32];
pub const OUTBOUND_DMS_STORAGE_KEY: &[u8] = b"outbound_dms";
#[derive(Debug, Default, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct OutboundDmStore {
#[serde(default)]
pub entries: Vec<OutboundDmEntry>,
#[serde(default)]
pub hidden_threads: Vec<HiddenDmThreadEntry>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct HiddenDmThreadEntry {
pub room_owner_vk: [u8; 32],
pub peer: MemberId,
pub hidden_at_ts: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct OutboundDmEntry {
pub room_owner_vk: [u8; 32],
pub sender: MemberId,
pub recipient: MemberId,
pub purge_token: PurgeToken,
pub timestamp: u64,
pub plaintext: String,
}
pub type RequestId = u64;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum ChatDelegateRequestMsg {
StoreRequest {
key: ChatDelegateKey,
value: Vec<u8>,
},
GetRequest {
key: ChatDelegateKey,
},
DeleteRequest {
key: ChatDelegateKey,
},
ListRequest,
StoreSigningKey {
room_key: RoomKey,
signing_key_bytes: [u8; 32],
},
GetPublicKey {
room_key: RoomKey,
},
SignMessage {
room_key: RoomKey,
request_id: RequestId,
message_bytes: Vec<u8>,
},
SignMember {
room_key: RoomKey,
request_id: RequestId,
member_bytes: Vec<u8>,
},
SignBan {
room_key: RoomKey,
request_id: RequestId,
ban_bytes: Vec<u8>,
},
SignConfig {
room_key: RoomKey,
request_id: RequestId,
config_bytes: Vec<u8>,
},
SignMemberInfo {
room_key: RoomKey,
request_id: RequestId,
member_info_bytes: Vec<u8>,
},
SignSecretVersion {
room_key: RoomKey,
request_id: RequestId,
record_bytes: Vec<u8>,
},
SignEncryptedSecret {
room_key: RoomKey,
request_id: RequestId,
secret_bytes: Vec<u8>,
},
SignUpgrade {
room_key: RoomKey,
request_id: RequestId,
upgrade_bytes: Vec<u8>,
},
EnsureRoomSubscription {
room_owner_vk: RoomKey,
contract_id: [u8; 32],
},
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
pub struct ChatDelegateKey(pub Vec<u8>);
impl ChatDelegateKey {
pub fn new(key: Vec<u8>) -> Self {
Self(key)
}
pub fn as_bytes(&self) -> &[u8] {
&self.0
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum ChatDelegateResponseMsg {
GetResponse {
key: ChatDelegateKey,
value: Option<Vec<u8>>,
},
ListResponse {
keys: Vec<ChatDelegateKey>,
},
StoreResponse {
key: ChatDelegateKey,
value_size: usize,
result: Result<(), String>,
},
DeleteResponse {
key: ChatDelegateKey,
result: Result<(), String>,
},
StoreSigningKeyResponse {
room_key: RoomKey,
result: Result<(), String>,
},
GetPublicKeyResponse {
room_key: RoomKey,
public_key: Option<[u8; 32]>,
},
SignResponse {
room_key: RoomKey,
request_id: RequestId,
signature: Result<Vec<u8>, String>,
},
EnsureRoomSubscriptionResponse {
room_owner_vk: RoomKey,
result: Result<(), String>,
},
}
pub fn is_thread_hidden(
hidden_threads: &[HiddenDmThreadEntry],
room_owner_vk: &[u8; 32],
peer: MemberId,
max_message_ts: u64,
) -> bool {
hidden_threads
.iter()
.find(|h| &h.room_owner_vk == room_owner_vk && h.peer == peer)
.is_some_and(|h| max_message_ts <= h.hidden_at_ts)
}
#[cfg(test)]
mod tests {
use super::*;
use freenet_scaffold::util::FastHash;
fn sample_entry() -> OutboundDmEntry {
OutboundDmEntry {
room_owner_vk: [9u8; 32],
sender: MemberId(FastHash(0xdead_beef)),
recipient: MemberId(FastHash(0x1234_5678)),
purge_token: crate::room_state::direct_messages::PurgeToken([0xab; 16]),
timestamp: 1_700_000_000,
plaintext: "hello, world".to_string(),
}
}
fn sample_hidden() -> HiddenDmThreadEntry {
HiddenDmThreadEntry {
room_owner_vk: [9u8; 32],
peer: MemberId(FastHash(0x1234_5678)),
hidden_at_ts: 1_700_000_000,
}
}
#[test]
fn outbound_dm_store_json_round_trips() {
let store = OutboundDmStore {
entries: vec![sample_entry()],
hidden_threads: vec![],
};
let json = serde_json::to_string(&store).expect("serialize JSON");
let parsed: OutboundDmStore = serde_json::from_str(&json).expect("parse JSON");
assert_eq!(parsed, store);
}
#[test]
fn outbound_dm_store_cbor_round_trips() {
let store = OutboundDmStore {
entries: vec![sample_entry(), sample_entry()],
hidden_threads: vec![],
};
let mut buf = Vec::new();
ciborium::ser::into_writer(&store, &mut buf).expect("serialize CBOR");
let parsed: OutboundDmStore =
ciborium::de::from_reader(buf.as_slice()).expect("parse CBOR");
assert_eq!(parsed, store);
}
#[test]
fn empty_outbound_dm_store_json_round_trips() {
let store = OutboundDmStore::default();
let json = serde_json::to_string(&store).expect("serialize JSON");
let parsed: OutboundDmStore = serde_json::from_str(&json).expect("parse JSON");
assert_eq!(parsed, store);
}
#[test]
fn outbound_dm_store_with_hidden_threads_json_round_trips() {
let store = OutboundDmStore {
entries: vec![sample_entry()],
hidden_threads: vec![sample_hidden()],
};
let json = serde_json::to_string(&store).expect("serialize JSON");
let parsed: OutboundDmStore = serde_json::from_str(&json).expect("parse JSON");
assert_eq!(parsed, store);
}
#[test]
fn outbound_dm_store_with_hidden_threads_cbor_round_trips() {
let store = OutboundDmStore {
entries: vec![],
hidden_threads: vec![sample_hidden(), sample_hidden()],
};
let mut buf = Vec::new();
ciborium::ser::into_writer(&store, &mut buf).expect("serialize CBOR");
let parsed: OutboundDmStore =
ciborium::de::from_reader(buf.as_slice()).expect("parse CBOR");
assert_eq!(parsed, store);
}
#[test]
fn outbound_dm_store_decodes_legacy_json_without_hidden_threads() {
let legacy_json = r#"{"entries":[]}"#;
let parsed: OutboundDmStore =
serde_json::from_str(legacy_json).expect("legacy JSON must decode");
assert!(parsed.entries.is_empty());
assert!(parsed.hidden_threads.is_empty());
}
#[test]
fn outbound_dm_store_decodes_legacy_cbor_without_hidden_threads() {
#[derive(Serialize)]
struct LegacyStore {
entries: Vec<OutboundDmEntry>,
}
let legacy = LegacyStore {
entries: vec![sample_entry()],
};
let mut buf = Vec::new();
ciborium::ser::into_writer(&legacy, &mut buf).expect("serialize legacy CBOR");
let parsed: OutboundDmStore =
ciborium::de::from_reader(buf.as_slice()).expect("legacy CBOR must decode");
assert_eq!(parsed.entries.len(), 1);
assert!(parsed.hidden_threads.is_empty());
}
#[test]
fn is_thread_hidden_returns_false_for_empty_list() {
let peer = MemberId(FastHash(0x42));
assert!(!is_thread_hidden(&[], &[0u8; 32], peer, 0));
assert!(!is_thread_hidden(&[], &[0u8; 32], peer, 1_000));
}
#[test]
fn is_thread_hidden_equal_timestamp_stays_hidden() {
let peer = MemberId(FastHash(0x42));
let hidden = vec![HiddenDmThreadEntry {
room_owner_vk: [9u8; 32],
peer,
hidden_at_ts: 1_000,
}];
assert!(is_thread_hidden(&hidden, &[9u8; 32], peer, 1_000));
}
#[test]
fn is_thread_hidden_strictly_later_message_revives() {
let peer = MemberId(FastHash(0x42));
let hidden = vec![HiddenDmThreadEntry {
room_owner_vk: [9u8; 32],
peer,
hidden_at_ts: 1_000,
}];
assert!(!is_thread_hidden(&hidden, &[9u8; 32], peer, 1_001));
}
#[test]
fn is_thread_hidden_is_scoped_per_room() {
let peer = MemberId(FastHash(0x42));
let hidden = vec![HiddenDmThreadEntry {
room_owner_vk: [9u8; 32],
peer,
hidden_at_ts: 1_000,
}];
assert!(!is_thread_hidden(&hidden, &[7u8; 32], peer, 500));
}
#[test]
fn is_thread_hidden_is_scoped_per_peer() {
let peer_a = MemberId(FastHash(0x42));
let peer_b = MemberId(FastHash(0x99));
let hidden = vec![HiddenDmThreadEntry {
room_owner_vk: [9u8; 32],
peer: peer_a,
hidden_at_ts: 1_000,
}];
assert!(!is_thread_hidden(&hidden, &[9u8; 32], peer_b, 500));
}
#[test]
fn is_thread_hidden_zero_max_zero_hidden_stays_hidden() {
let peer = MemberId(FastHash(0x42));
let hidden = vec![HiddenDmThreadEntry {
room_owner_vk: [9u8; 32],
peer,
hidden_at_ts: 0,
}];
assert!(is_thread_hidden(&hidden, &[9u8; 32], peer, 0));
}
}