1use std::collections::HashMap;
12use std::sync::{Arc, Mutex};
13
14use chrono::Utc;
15use uuid::Uuid;
16
17use crate::error::CollabError;
18use crate::remote::CollabRemote;
19use crate::types::{DekEnvelope, InviteRecord, InviteToken, MemberEntry};
20
21const INVITE_TTL_HOURS: i64 = 48;
23
24#[derive(Debug, Default)]
27struct StubState {
28 members: HashMap<String, Vec<MemberEntry>>,
30 dek_inbox: HashMap<(String, String), DekEnvelope>,
32 share_inbox: HashMap<(String, String), String>,
34 invites: HashMap<String, InviteRecord>,
36}
37
38impl StubState {
39 fn is_member(&self, team_id: &str, pubkey: &str) -> bool {
41 self.members
42 .get(team_id)
43 .map(|ms| ms.iter().any(|m| m.pubkey == pubkey))
44 .unwrap_or(false)
45 }
46
47 fn assert_member(&self, team_id: &str, pubkey: &str) -> Result<(), CollabError> {
49 if self.is_member(team_id, pubkey) {
50 Ok(())
51 } else {
52 Err(CollabError::NotMember {
53 team_id: team_id.to_owned(),
54 })
55 }
56 }
57}
58
59#[derive(Debug, Clone, Default)]
63pub struct StubServer {
64 state: Arc<Mutex<StubState>>,
65}
66
67impl StubServer {
68 pub fn new() -> Self {
70 Self::default()
71 }
72}
73
74impl CollabRemote for StubServer {
75 fn join(&self, team_id: &str, pubkey: &str) -> Result<(), CollabError> {
76 let mut state = self.state.lock().expect("stub state poisoned");
77 let members = state.members.entry(team_id.to_owned()).or_default();
78 if !members.iter().any(|m| m.pubkey == pubkey) {
80 members.push(MemberEntry {
81 pubkey: pubkey.to_owned(),
82 joined_at: Utc::now(),
83 });
84 }
85 Ok(())
86 }
87
88 fn members(&self, team_id: &str) -> Result<Vec<MemberEntry>, CollabError> {
89 let state = self.state.lock().expect("stub state poisoned");
90 Ok(state.members.get(team_id).cloned().unwrap_or_default())
96 }
97
98 fn deliver_dek(
99 &self,
100 team_id: &str,
101 recipient_pubkey: &str,
102 envelope: DekEnvelope,
103 ) -> Result<(), CollabError> {
104 let mut state = self.state.lock().expect("stub state poisoned");
105 state.assert_member(team_id, recipient_pubkey)?;
110 state
111 .dek_inbox
112 .insert((team_id.to_owned(), recipient_pubkey.to_owned()), envelope);
113 Ok(())
114 }
115
116 fn fetch_dek(
117 &self,
118 team_id: &str,
119 recipient_pubkey: &str,
120 ) -> Result<Option<DekEnvelope>, CollabError> {
121 let state = self.state.lock().expect("stub state poisoned");
122 state.assert_member(team_id, recipient_pubkey)?;
124 Ok(state
125 .dek_inbox
126 .get(&(team_id.to_owned(), recipient_pubkey.to_owned()))
127 .cloned())
128 }
129
130 fn create_invite(
131 &self,
132 team_id: &str,
133 invitee_pubkey: &str,
134 ) -> Result<InviteToken, CollabError> {
135 let mut state = self.state.lock().expect("stub state poisoned");
136 let token_str = Uuid::new_v4().to_string();
137 let expires_at = Utc::now() + chrono::Duration::hours(INVITE_TTL_HOURS);
138 let record = InviteRecord {
139 token: token_str.clone(),
140 team_id: team_id.to_owned(),
141 invitee_pubkey: invitee_pubkey.to_owned(),
142 expires_at,
143 };
144 state.invites.insert(token_str.clone(), record);
145 Ok(InviteToken {
146 token: token_str,
147 team_id: team_id.to_owned(),
148 bound_pubkey: invitee_pubkey.to_owned(),
149 })
150 }
151
152 fn confirm_invite(
153 &self,
154 team_id: &str,
155 token: &InviteToken,
156 confirming_pubkey: &str,
157 ) -> Result<(), CollabError> {
158 let mut state = self.state.lock().expect("stub state poisoned");
159
160 let record = state
161 .invites
162 .get(&token.token)
163 .ok_or(CollabError::InviteNotFound)?;
164
165 if record.invitee_pubkey != confirming_pubkey {
168 return Err(CollabError::PubkeyMismatch);
169 }
170
171 if Utc::now() > record.expires_at {
173 return Err(CollabError::InviteNotFound);
174 }
175
176 let confirmed_pubkey = record.invitee_pubkey.clone();
177 let confirmed_team = record.team_id.clone();
178 let token_str = token.token.clone();
179
180 if confirmed_team != team_id {
182 return Err(CollabError::InviteNotFound);
183 }
184
185 state.invites.remove(&token_str);
187
188 let members = state.members.entry(team_id.to_owned()).or_default();
190 if !members.iter().any(|m| m.pubkey == confirmed_pubkey) {
191 members.push(MemberEntry {
192 pubkey: confirmed_pubkey,
193 joined_at: Utc::now(),
194 });
195 }
196
197 Ok(())
198 }
199
200 fn deliver_recovery_share(
201 &self,
202 team_id: &str,
203 custodian_pubkey: &str,
204 share_ciphertext: &str,
205 ) -> Result<(), CollabError> {
206 let mut state = self.state.lock().expect("stub state poisoned");
207 state.assert_member(team_id, custodian_pubkey)?;
208 state.share_inbox.insert(
209 (team_id.to_owned(), custodian_pubkey.to_owned()),
210 share_ciphertext.to_owned(),
211 );
212 Ok(())
213 }
214
215 fn fetch_recovery_share(
216 &self,
217 team_id: &str,
218 custodian_pubkey: &str,
219 ) -> Result<Option<String>, CollabError> {
220 let state = self.state.lock().expect("stub state poisoned");
221 state.assert_member(team_id, custodian_pubkey)?;
222 Ok(state
223 .share_inbox
224 .get(&(team_id.to_owned(), custodian_pubkey.to_owned()))
225 .cloned())
226 }
227}