1#[cfg(feature = "mls")]
35pub use vti_rooms::{mls, sealed};
36
37use crate::{VtcClient, VtcError};
38
39pub use vti_rooms::Visibility;
46pub use vti_rooms::authz::MAX_CHAIN_DEPTH;
47pub use vti_rooms::wire::{
48 AuthorityPresentation, CleartextContent, ListRecordsResponse, MintEpochResponse, OwnerResponse,
49 PutRecordResponse, ROOMS_CREATE_TYPE, ROOMS_EPOCH_MINT_TYPE, ROOMS_OWNER_CLAIM_TYPE,
50 ROOMS_OWNER_TRANSFER_TYPE, ROOMS_RECORDS_GET_TYPE, ROOMS_RECORDS_LIST_TYPE,
51 ROOMS_RECORDS_PUT_TYPE, SealedContent,
52};
53
54#[derive(Debug, Clone)]
60pub struct RoomSession {
61 room_id: String,
62 presentation: AuthorityPresentation,
63}
64
65impl RoomSession {
66 pub fn new(
72 room_id: impl Into<String>,
73 membership: impl Into<String>,
74 authority: Vec<String>,
75 ) -> Result<Self, VtcError> {
76 if authority.is_empty() {
77 return Err(VtcError::Url(
78 "an authority chain is required: a room operation is authorized by the chain, \
79 never by a session"
80 .into(),
81 ));
82 }
83 if authority.len() > MAX_CHAIN_DEPTH {
84 return Err(VtcError::Url(format!(
85 "authority chain is {} deep, exceeding the maximum of {MAX_CHAIN_DEPTH}",
86 authority.len()
87 )));
88 }
89 Ok(Self {
90 room_id: room_id.into(),
91 presentation: AuthorityPresentation {
92 membership: membership.into(),
93 authority,
94 subject_binding: None,
95 },
96 })
97 }
98
99 pub fn with_subject_binding(mut self, binding: impl Into<String>) -> Self {
105 self.presentation.subject_binding = Some(binding.into());
106 self
107 }
108
109 pub fn room_id(&self) -> &str {
111 &self.room_id
112 }
113
114 pub fn chain_depth(&self) -> usize {
117 self.presentation.authority.len()
118 }
119}
120
121impl VtcClient {
122 pub async fn create_room(
127 &self,
128 room_id: &str,
129 owner_did: &str,
130 visibility: Visibility,
131 retention_days: Option<u32>,
132 signer_did: &str,
133 private_key_multibase: &str,
134 ) -> Result<serde_json::Value, VtcError> {
135 let payload = serde_json::json!({
136 "roomId": room_id,
137 "ownerDid": owner_did,
138 "visibility": visibility,
139 "retentionDays": retention_days,
140 });
141 self.room_task(
142 ROOMS_CREATE_TYPE,
143 payload,
144 signer_did,
145 private_key_multibase,
146 )
147 .await
148 }
149
150 #[allow(clippy::too_many_arguments)]
160 pub async fn put_record(
161 &self,
162 session: &RoomSession,
163 key: &str,
164 sealed: Option<SealedContent>,
165 cleartext: Option<CleartextContent>,
166 expected_version: Option<u64>,
167 signer_did: &str,
168 private_key_multibase: &str,
169 ) -> Result<PutRecordResponse, VtcError> {
170 let mut payload = serde_json::json!({
171 "roomId": session.room_id,
172 "key": key,
173 "presentation": session.presentation,
174 });
175 if let Some(s) = sealed {
176 payload["sealed"] =
177 serde_json::to_value(s).map_err(|e| VtcError::Url(e.to_string()))?;
178 }
179 if let Some(c) = cleartext {
180 payload["cleartext"] =
181 serde_json::to_value(c).map_err(|e| VtcError::Url(e.to_string()))?;
182 }
183 if let Some(v) = expected_version {
184 payload["expectedVersion"] = serde_json::json!(v);
185 }
186 let value = self
187 .room_task(
188 ROOMS_RECORDS_PUT_TYPE,
189 payload,
190 signer_did,
191 private_key_multibase,
192 )
193 .await?;
194 serde_json::from_value(value).map_err(|e| VtcError::Http {
195 status: 200,
196 body: format!("put response is not a PutRecordResponse: {e}"),
197 })
198 }
199
200 pub async fn get_record(
206 &self,
207 session: &RoomSession,
208 key: &str,
209 signer_did: &str,
210 private_key_multibase: &str,
211 ) -> Result<serde_json::Value, VtcError> {
212 let payload = serde_json::json!({
213 "roomId": session.room_id,
214 "key": key,
215 "presentation": session.presentation,
216 });
217 self.room_task(
218 ROOMS_RECORDS_GET_TYPE,
219 payload,
220 signer_did,
221 private_key_multibase,
222 )
223 .await
224 }
225
226 pub async fn list_records(
233 &self,
234 session: &RoomSession,
235 prefix: Option<&str>,
236 since_version: Option<u64>,
237 signer_did: &str,
238 private_key_multibase: &str,
239 ) -> Result<ListRecordsResponse, VtcError> {
240 let mut payload = serde_json::json!({
241 "roomId": session.room_id,
242 "presentation": session.presentation,
243 });
244 if let Some(p) = prefix {
245 payload["prefix"] = serde_json::json!(p);
246 }
247 if let Some(v) = since_version {
248 payload["sinceVersion"] = serde_json::json!(v);
249 }
250 let value = self
251 .room_task(
252 ROOMS_RECORDS_LIST_TYPE,
253 payload,
254 signer_did,
255 private_key_multibase,
256 )
257 .await?;
258 serde_json::from_value(value).map_err(|e| VtcError::Http {
259 status: 200,
260 body: format!("list response is not a ListRecordsResponse: {e}"),
261 })
262 }
263
264 pub async fn mint_epoch(
270 &self,
271 session: &RoomSession,
272 epoch: u32,
273 reason: Option<&str>,
274 signer_did: &str,
275 private_key_multibase: &str,
276 ) -> Result<MintEpochResponse, VtcError> {
277 let mut payload = serde_json::json!({
278 "roomId": session.room_id,
279 "epoch": epoch,
280 "presentation": session.presentation,
281 });
282 if let Some(r) = reason {
283 payload["reason"] = serde_json::json!(r);
284 }
285 let value = self
286 .room_task(
287 ROOMS_EPOCH_MINT_TYPE,
288 payload,
289 signer_did,
290 private_key_multibase,
291 )
292 .await?;
293 serde_json::from_value(value).map_err(|e| VtcError::Http {
294 status: 200,
295 body: format!("mint response is not a MintEpochResponse: {e}"),
296 })
297 }
298
299 pub async fn transfer_owner(
309 &self,
310 session: &RoomSession,
311 new_owner_did: &str,
312 reason: Option<&str>,
313 signer_did: &str,
314 private_key_multibase: &str,
315 ) -> Result<OwnerResponse, VtcError> {
316 let mut payload = serde_json::json!({
317 "roomId": session.room_id,
318 "newOwnerDid": new_owner_did,
319 "presentation": session.presentation,
320 });
321 if let Some(r) = reason {
322 payload["reason"] = serde_json::json!(r);
323 }
324 self.owner_task(
325 ROOMS_OWNER_TRANSFER_TYPE,
326 payload,
327 signer_did,
328 private_key_multibase,
329 )
330 .await
331 }
332
333 pub async fn claim_owner(
343 &self,
344 session: &RoomSession,
345 nomination: &str,
346 reason: Option<&str>,
347 signer_did: &str,
348 private_key_multibase: &str,
349 ) -> Result<OwnerResponse, VtcError> {
350 let mut payload = serde_json::json!({
351 "roomId": session.room_id,
352 "nomination": nomination,
353 "presentation": session.presentation,
354 });
355 if let Some(r) = reason {
356 payload["reason"] = serde_json::json!(r);
357 }
358 self.owner_task(
359 ROOMS_OWNER_CLAIM_TYPE,
360 payload,
361 signer_did,
362 private_key_multibase,
363 )
364 .await
365 }
366
367 async fn owner_task(
369 &self,
370 type_uri: &str,
371 payload: serde_json::Value,
372 signer_did: &str,
373 private_key_multibase: &str,
374 ) -> Result<OwnerResponse, VtcError> {
375 let value = self
376 .room_task(type_uri, payload, signer_did, private_key_multibase)
377 .await?;
378 serde_json::from_value(value).map_err(|e| VtcError::Http {
379 status: 200,
380 body: format!("{type_uri} response is not an OwnerResponse: {e}"),
381 })
382 }
383
384 async fn room_task(
390 &self,
391 type_uri: &str,
392 payload: serde_json::Value,
393 signer_did: &str,
394 private_key_multibase: &str,
395 ) -> Result<serde_json::Value, VtcError> {
396 let doc = vta_sdk::trust_task_sign::build_signed(
397 type_uri,
398 payload,
399 signer_did,
400 private_key_multibase,
401 &self.vtc_did,
402 )
403 .await
404 .map_err(|e| VtcError::Signing(e.to_string()))?;
405
406 let resp = self
407 .http
408 .post(format!("{}/trust-tasks", self.base_url))
409 .header("content-type", "application/json")
410 .body(doc)
411 .send()
412 .await?;
413 if !resp.status().is_success() {
414 let status = resp.status().as_u16();
415 let body = resp.text().await.unwrap_or_default();
416 return Err(VtcError::Http { status, body });
417 }
418
419 let text = resp.text().await?;
420 let response_doc: trust_tasks_rs::TrustTask<serde_json::Value> =
421 serde_json::from_str(&text).map_err(|e| VtcError::Http {
422 status: 200,
423 body: format!("unexpected room response (not a Trust Task document): {e}: {text}"),
424 })?;
425 Ok(response_doc.payload)
426 }
427}
428
429#[cfg(test)]
430mod tests {
431 use super::*;
432
433 #[test]
434 fn a_session_requires_a_chain() {
435 let err = RoomSession::new("did:key:zRoom", "vmc", vec![]).unwrap_err();
436 assert!(
437 format!("{err}").contains("authorized by the chain"),
438 "a room session without a chain has nothing to present: {err}"
439 );
440 }
441
442 #[test]
443 fn a_session_refuses_a_chain_past_the_ceiling() {
444 let chain: Vec<String> = (0..=MAX_CHAIN_DEPTH).map(|i| format!("vac-{i}")).collect();
445 let err = RoomSession::new("did:key:zRoom", "vmc", chain).unwrap_err();
446 assert!(format!("{err}").contains("exceeding the maximum"), "{err}");
447 }
448
449 #[test]
452 fn a_members_session_and_their_agents_differ_only_in_the_chain() {
453 let member = RoomSession::new("did:key:zRoom", "vmc", vec!["vac-member".into()])
454 .expect("member session");
455 let agent = RoomSession::new(
456 "did:key:zRoom",
457 "vmc",
458 vec!["vac-agent".into(), "vac-member".into()],
459 )
460 .expect("agent session");
461
462 assert_eq!(member.room_id(), agent.room_id());
463 assert_eq!(member.chain_depth(), 1, "a grant straight from the room");
464 assert_eq!(agent.chain_depth(), 2, "one attenuation deeper");
465 }
466
467 #[test]
468 fn a_subject_binding_is_attached_only_when_asked_for() {
469 let s = RoomSession::new("did:key:zRoom", "vmc", vec!["vac".into()]).unwrap();
470 assert!(s.presentation.subject_binding.is_none());
471 let s = s.with_subject_binding("proof");
472 assert_eq!(s.presentation.subject_binding.as_deref(), Some("proof"));
473 }
474
475 #[test]
478 fn a_presentation_serialises_camel_case_and_omits_an_absent_binding() {
479 let s = RoomSession::new("did:key:zRoom", "vmc", vec!["a".into(), "b".into()]).unwrap();
480 let v = serde_json::to_value(&s.presentation).unwrap();
481 assert_eq!(v["membership"], "vmc");
482 assert_eq!(v["authority"][0], "a", "leaf first");
483 assert!(v.get("subjectBinding").is_none());
484
485 let s = s.with_subject_binding("bind");
486 let v = serde_json::to_value(&s.presentation).unwrap();
487 assert_eq!(v["subjectBinding"], "bind");
488 }
489
490 #[test]
491 fn visibility_serialises_lowercase_as_the_host_expects() {
492 assert_eq!(
493 serde_json::to_value(Visibility::Attributed).unwrap(),
494 serde_json::json!("attributed")
495 );
496 }
497}