vtc_client/rooms/mod.rs
1//! Client surface for the `rooms/*` Trust Tasks.
2//!
3//! # What is different about these calls
4//!
5//! Every other method on [`crate::VtcClient`] carries an operator token: the community
6//! knows who you are, and its ACL decides what you may do. **A room call carries no token
7//! at all.** It carries a presentation — a membership credential and the authority chain
8//! the room itself issued — and the host decides from that alone.
9//!
10//! That is not a convenience. It is what makes a room portable: a host that consulted its
11//! own records to authorize a room operation would become part of that room's membership,
12//! and the room could no longer move to a different host without reissuing credentials.
13//! So [`RoomSession`] deliberately holds no session, and none of these methods reads
14//! `self.token`.
15//!
16//! # Holding the chain
17//!
18//! A [`RoomSession`] carries the whole authority chain, **leaf first**, and sends all of it
19//! on every call. The host never fetches a link it was not given — resolving one over the
20//! network would make verification depend on availability, turn an identifier into a
21//! request the host can be induced to make against an address the *caller* chooses, and
22//! signal credential use to whoever hosts that identifier.
23//!
24//! # Agents hold less than their humans
25//!
26//! The case the design exists for: a member holds `read`/`write`, and equips their agent
27//! with a chain one link longer whose leaf confers only `read`, expires in hours, and is
28//! bound to the agent. The agent's `RoomSession` is built exactly like the member's — the
29//! difference is entirely in the credentials it was handed, which is the point.
30
31// The group-key and sealing layers live in `vti-rooms` — a VTA needs both to open a
32// record on an agent's behalf, and a VTA must not depend on a VTC client. Re-exported
33// under their old paths so a caller that had `vtc-client` with `mls` is unaffected.
34#[cfg(feature = "mls")]
35pub use vti_rooms::{mls, sealed};
36
37use crate::{VtcClient, VtcError};
38
39// The wire types and their Type URIs, from the crate that also stores and serves them.
40//
41// These were defined a second time here until the group-key layer moved out and the
42// duplication became a compile error — two structs of one wire form, identical and with
43// nothing checking they stayed that way, which is the drift the schema-conformance suite in
44// `vti-rooms` exists to catch and could not see from over here.
45pub use vti_rooms::Visibility;
46pub use vti_rooms::authz::MAX_CHAIN_DEPTH;
47pub use vti_rooms::wire::{
48 AuthorityPresentation, ChainResponse, CleartextContent, CurateRecordResponse, EpochLink,
49 ListRecordsResponse, MintEpochResponse, OwnerResponse, PutRecordResponse, ROOMS_CREATE_TYPE,
50 ROOMS_EPOCH_CHAIN_TYPE, ROOMS_EPOCH_MINT_TYPE, ROOMS_OWNER_CLAIM_TYPE,
51 ROOMS_OWNER_TRANSFER_TYPE, ROOMS_RECORDS_CURATE_TYPE, ROOMS_RECORDS_GET_TYPE,
52 ROOMS_RECORDS_LIST_TYPE, ROOMS_RECORDS_PUT_TYPE, SealedContent,
53};
54
55/// A caller's standing in one room.
56///
57/// Holds the credentials, not a session. Build one per room per identity — a member's and
58/// their agent's are different sessions against the same room, differing only in the chain
59/// they carry.
60#[derive(Debug, Clone)]
61pub struct RoomSession {
62 room_id: String,
63 presentation: AuthorityPresentation,
64}
65
66impl RoomSession {
67 /// Build a session from a membership credential and an authority chain.
68 ///
69 /// `authority` is **leaf first**: the credential being relied on comes first, and the
70 /// one the room issued comes last. Rejected here if it is empty or deeper than
71 /// [`MAX_CHAIN_DEPTH`], so a caller learns locally rather than from a rejected request.
72 pub fn new(
73 room_id: impl Into<String>,
74 membership: impl Into<String>,
75 authority: Vec<String>,
76 ) -> Result<Self, VtcError> {
77 if authority.is_empty() {
78 return Err(VtcError::Url(
79 "an authority chain is required: a room operation is authorized by the chain, \
80 never by a session"
81 .into(),
82 ));
83 }
84 if authority.len() > MAX_CHAIN_DEPTH {
85 return Err(VtcError::Url(format!(
86 "authority chain is {} deep, exceeding the maximum of {MAX_CHAIN_DEPTH}",
87 authority.len()
88 )));
89 }
90 Ok(Self {
91 room_id: room_id.into(),
92 presentation: AuthorityPresentation {
93 membership: membership.into(),
94 authority,
95 subject_binding: None,
96 },
97 })
98 }
99
100 /// Attach the same-subject proof a `private` room requires.
101 ///
102 /// Without it a private-room call is refused: two parties could otherwise pool
103 /// credentials — one contributing membership, the other authority — and present as a
104 /// single party holding both.
105 pub fn with_subject_binding(mut self, binding: impl Into<String>) -> Self {
106 self.presentation.subject_binding = Some(binding.into());
107 self
108 }
109
110 /// The room this session acts on.
111 pub fn room_id(&self) -> &str {
112 &self.room_id
113 }
114
115 /// How many links the chain carries. Depth 1 is a grant straight from the room;
116 /// depth 2 is typically a member's agent.
117 pub fn chain_depth(&self) -> usize {
118 self.presentation.authority.len()
119 }
120}
121
122impl VtcClient {
123 /// Register a room with this host.
124 ///
125 /// The caller brings `room_id`: a room identified by something its host chose could not
126 /// move to another host without changing identity.
127 pub async fn create_room(
128 &self,
129 room_id: &str,
130 owner_did: &str,
131 visibility: Visibility,
132 retention_days: Option<u32>,
133 signer_did: &str,
134 private_key_multibase: &str,
135 ) -> Result<serde_json::Value, VtcError> {
136 let payload = serde_json::json!({
137 "roomId": room_id,
138 "ownerDid": owner_did,
139 "visibility": visibility,
140 "retentionDays": retention_days,
141 });
142 self.room_task(
143 ROOMS_CREATE_TYPE,
144 payload,
145 signer_did,
146 private_key_multibase,
147 )
148 .await
149 }
150
151 /// Write a record.
152 ///
153 /// Exactly one of `sealed` / `cleartext` — the host refuses the other shape for the
154 /// room's tier, so passing both or neither is a request it cannot honour.
155 ///
156 /// `expected_version` is an optional precondition: `Some(0)` means create-only, and
157 /// `Some(n)` requires the stored record to be at version `n`. A mismatch comes back
158 /// carrying the current version, so a caller does not have to re-read to learn what it
159 /// lost to.
160 #[allow(clippy::too_many_arguments)]
161 pub async fn put_record(
162 &self,
163 session: &RoomSession,
164 key: &str,
165 sealed: Option<SealedContent>,
166 cleartext: Option<CleartextContent>,
167 expected_version: Option<u64>,
168 signer_did: &str,
169 private_key_multibase: &str,
170 ) -> Result<PutRecordResponse, VtcError> {
171 let mut payload = serde_json::json!({
172 "roomId": session.room_id,
173 "key": key,
174 "presentation": session.presentation,
175 });
176 if let Some(s) = sealed {
177 payload["sealed"] =
178 serde_json::to_value(s).map_err(|e| VtcError::Url(e.to_string()))?;
179 }
180 if let Some(c) = cleartext {
181 payload["cleartext"] =
182 serde_json::to_value(c).map_err(|e| VtcError::Url(e.to_string()))?;
183 }
184 if let Some(v) = expected_version {
185 payload["expectedVersion"] = serde_json::json!(v);
186 }
187 let value = self
188 .room_task(
189 ROOMS_RECORDS_PUT_TYPE,
190 payload,
191 signer_did,
192 private_key_multibase,
193 )
194 .await?;
195 serde_json::from_value(value).map_err(|e| VtcError::Http {
196 status: 200,
197 body: format!("put response is not a PutRecordResponse: {e}"),
198 })
199 }
200
201 /// Read one record.
202 ///
203 /// Presents exactly as a write does, and needs no session — which is the point on a
204 /// sealed room: authorizing reads by session would hand the host a member identifier on
205 /// every access, and a period of those reconstructs the membership the tier withholds.
206 pub async fn get_record(
207 &self,
208 session: &RoomSession,
209 key: &str,
210 signer_did: &str,
211 private_key_multibase: &str,
212 ) -> Result<serde_json::Value, VtcError> {
213 let payload = serde_json::json!({
214 "roomId": session.room_id,
215 "key": key,
216 "presentation": session.presentation,
217 });
218 self.room_task(
219 ROOMS_RECORDS_GET_TYPE,
220 payload,
221 signer_did,
222 private_key_multibase,
223 )
224 .await
225 }
226
227 /// List record metadata.
228 ///
229 /// Never returns bodies — fetch the handful that matter with [`VtcClient::get_record`].
230 /// `since_version` is the incremental-sync watermark, and the response **includes
231 /// tombstones**: a caller that never saw a retraction would resurrect the record on its
232 /// next full rebuild.
233 ///
234 /// `cursor` continues a previous page. **A listing is not complete until the response's
235 /// `cursor` is absent** — a page shorter than the one asked for says nothing, which is
236 /// why this takes the token rather than leaving callers to guess from a length.
237 pub async fn list_records(
238 &self,
239 session: &RoomSession,
240 prefix: Option<&str>,
241 since_version: Option<u64>,
242 cursor: Option<&str>,
243 signer_did: &str,
244 private_key_multibase: &str,
245 ) -> Result<ListRecordsResponse, VtcError> {
246 let mut payload = serde_json::json!({
247 "roomId": session.room_id,
248 "presentation": session.presentation,
249 });
250 if let Some(p) = prefix {
251 payload["prefix"] = serde_json::json!(p);
252 }
253 if let Some(v) = since_version {
254 payload["sinceVersion"] = serde_json::json!(v);
255 }
256 if let Some(c) = cursor {
257 payload["cursor"] = serde_json::json!(c);
258 }
259 let value = self
260 .room_task(
261 ROOMS_RECORDS_LIST_TYPE,
262 payload,
263 signer_did,
264 private_key_multibase,
265 )
266 .await?;
267 serde_json::from_value(value).map_err(|e| VtcError::Http {
268 status: 200,
269 body: format!("list response is not a ListRecordsResponse: {e}"),
270 })
271 }
272
273 /// Advance the room's key epoch — how a member is removed.
274 ///
275 /// Requires a chain conferring `admin`. `epoch` must be exactly one greater than the
276 /// current one. The host records the number and never learns the key: distributing it
277 /// to the remaining members happens out of its sight.
278 pub async fn mint_epoch(
279 &self,
280 session: &RoomSession,
281 epoch: u32,
282 reason: Option<&str>,
283 signer_did: &str,
284 private_key_multibase: &str,
285 ) -> Result<MintEpochResponse, VtcError> {
286 self.mint_epoch_with_link(
287 session,
288 epoch,
289 None,
290 reason,
291 signer_did,
292 private_key_multibase,
293 )
294 .await
295 }
296
297 /// Mint an epoch and hand the host the rung that keeps the room's past readable.
298 ///
299 /// The rung is the outgoing epoch's storage key sealed under the incoming one, and
300 /// minting is the only moment one party holds both — so this is the only call that can
301 /// carry it. A room that advances without one keeps working and silently loses the
302 /// ability to read everything written before, for every member including the writer.
303 ///
304 /// [`Self::mint_epoch`] is this with `None`, which is the right call only for a room
305 /// that has deliberately chosen not to keep its history.
306 pub async fn mint_epoch_with_link(
307 &self,
308 session: &RoomSession,
309 epoch: u32,
310 link: Option<&EpochLink>,
311 reason: Option<&str>,
312 signer_did: &str,
313 private_key_multibase: &str,
314 ) -> Result<MintEpochResponse, VtcError> {
315 let mut payload = serde_json::json!({
316 "roomId": session.room_id,
317 "epoch": epoch,
318 "presentation": session.presentation,
319 });
320 if let Some(r) = reason {
321 payload["reason"] = serde_json::json!(r);
322 }
323 if let Some(l) = link {
324 payload["link"] = serde_json::to_value(l).map_err(|e| VtcError::Http {
325 status: 0,
326 body: format!("serialise the epoch link: {e}"),
327 })?;
328 }
329 let value = self
330 .room_task(
331 ROOMS_EPOCH_MINT_TYPE,
332 payload,
333 signer_did,
334 private_key_multibase,
335 )
336 .await?;
337 serde_json::from_value(value).map_err(|e| VtcError::Http {
338 status: 200,
339 body: format!("mint response is not a MintEpochResponse: {e}"),
340 })
341 }
342
343 /// Fetch the room's epoch key chain, highest epoch first.
344 ///
345 /// What a member calls after joining, or after restoring their group state, so that
346 /// records sealed before then still open. Feed the result to
347 /// `SealedRoom::add_links`.
348 ///
349 /// Gated on `read` at the host: reading the room and reading the parts written earlier
350 /// are the same act. The rungs are ciphertext — a caller holding no epoch key learns
351 /// nothing from them but how many epochs the room has had.
352 ///
353 /// `from_epoch` returns only rungs at or below that epoch, which is how a member who
354 /// already holds the top of the chain asks for the rest.
355 pub async fn epoch_chain(
356 &self,
357 session: &RoomSession,
358 from_epoch: Option<u32>,
359 limit: Option<u32>,
360 signer_did: &str,
361 private_key_multibase: &str,
362 ) -> Result<ChainResponse, VtcError> {
363 let mut payload = serde_json::json!({
364 "roomId": session.room_id,
365 "presentation": session.presentation,
366 });
367 if let Some(f) = from_epoch {
368 payload["fromEpoch"] = serde_json::json!(f);
369 }
370 if let Some(l) = limit {
371 payload["limit"] = serde_json::json!(l);
372 }
373 let value = self
374 .room_task(
375 ROOMS_EPOCH_CHAIN_TYPE,
376 payload,
377 signer_did,
378 private_key_multibase,
379 )
380 .await?;
381 serde_json::from_value(value).map_err(|e| VtcError::Http {
382 status: 200,
383 body: format!("chain response is not a ChainResponse: {e}"),
384 })
385 }
386
387 /// Change a record's **standing** — its status, whether it is pinned, or both.
388 ///
389 /// Needs a chain conferring `curate`, which is deliberately *not* implied by `write`:
390 /// deciding what a room's shared knowledge is worth is a different grant from being
391 /// able to add to it.
392 ///
393 /// Separate from [`Self::put_record`] because standing is not content. On a sealed tier
394 /// a host cannot read what it stores, so "same body, now deprecated" through `put` would
395 /// make a member re-seal and re-upload bytes the host already holds, to say something
396 /// that is not about the bytes.
397 ///
398 /// `status` and `pinned` are independent: omit either to leave it unchanged, and passing
399 /// neither changes nothing. The curation assigns a **new version**, because a change
400 /// others must converge on is a change like any other — one that left the version alone
401 /// would be invisible to every `sinceVersion` watermark in the room.
402 #[allow(clippy::too_many_arguments)]
403 pub async fn curate_record(
404 &self,
405 session: &RoomSession,
406 key: &str,
407 status: Option<String>,
408 pinned: Option<bool>,
409 reason: Option<String>,
410 signer_did: &str,
411 private_key_multibase: &str,
412 ) -> Result<CurateRecordResponse, VtcError> {
413 let mut payload = serde_json::json!({
414 "roomId": session.room_id,
415 "key": key,
416 "presentation": session.presentation,
417 });
418 // Each member is omitted when absent rather than sent as null: the
419 // published schema types them, and "leave unchanged" is absence.
420 if let Some(s) = status {
421 payload["status"] = serde_json::json!(s);
422 }
423 if let Some(p) = pinned {
424 payload["pinned"] = serde_json::json!(p);
425 }
426 if let Some(r) = reason {
427 payload["reason"] = serde_json::json!(r);
428 }
429 let value = self
430 .room_task(
431 ROOMS_RECORDS_CURATE_TYPE,
432 payload,
433 signer_did,
434 private_key_multibase,
435 )
436 .await?;
437 serde_json::from_value(value).map_err(|e| VtcError::Http {
438 status: 200,
439 body: format!("curate response is not a CurateRecordResponse: {e}"),
440 })
441 }
442
443 /// Hand the room to another member, deliberately and while still present.
444 ///
445 /// Requires a chain conferring `admin` — the same grant that mints epochs, since
446 /// transferring is the more consequential of the two.
447 ///
448 /// **The host does not check that `new_owner_did` is a member**, and cannot: it holds no
449 /// roster and no MLS group state. That obligation is the outgoing owner's, who can see
450 /// the group. Give the room to someone who cannot commit and they inherit a room they
451 /// cannot renew.
452 pub async fn transfer_owner(
453 &self,
454 session: &RoomSession,
455 new_owner_did: &str,
456 reason: Option<&str>,
457 signer_did: &str,
458 private_key_multibase: &str,
459 ) -> Result<OwnerResponse, VtcError> {
460 let mut payload = serde_json::json!({
461 "roomId": session.room_id,
462 "newOwnerDid": new_owner_did,
463 "presentation": session.presentation,
464 });
465 if let Some(r) = reason {
466 payload["reason"] = serde_json::json!(r);
467 }
468 self.owner_task(
469 ROOMS_OWNER_TRANSFER_TYPE,
470 payload,
471 signer_did,
472 private_key_multibase,
473 )
474 .await
475 }
476
477 /// Claim a room whose owner has stopped renewing it.
478 ///
479 /// `nomination` is the succession credential the room issued to this claimant in
480 /// advance. All three of the host's conditions must hold together: a valid nomination,
481 /// a room that has been **dormant** past its grace window — not merely lapsed — and the
482 /// claimant's own membership, which is what `session` carries.
483 ///
484 /// A claim does not renew the room. It hands over a dormant room, and the new owner's
485 /// first act should be the epoch mint that proves they can perform it.
486 pub async fn claim_owner(
487 &self,
488 session: &RoomSession,
489 nomination: &str,
490 reason: Option<&str>,
491 signer_did: &str,
492 private_key_multibase: &str,
493 ) -> Result<OwnerResponse, VtcError> {
494 let mut payload = serde_json::json!({
495 "roomId": session.room_id,
496 "nomination": nomination,
497 "presentation": session.presentation,
498 });
499 if let Some(r) = reason {
500 payload["reason"] = serde_json::json!(r);
501 }
502 self.owner_task(
503 ROOMS_OWNER_CLAIM_TYPE,
504 payload,
505 signer_did,
506 private_key_multibase,
507 )
508 .await
509 }
510
511 /// The shared tail of the two succession verbs, which answer with one shape.
512 async fn owner_task(
513 &self,
514 type_uri: &str,
515 payload: serde_json::Value,
516 signer_did: &str,
517 private_key_multibase: &str,
518 ) -> Result<OwnerResponse, VtcError> {
519 let value = self
520 .room_task(type_uri, payload, signer_did, private_key_multibase)
521 .await?;
522 serde_json::from_value(value).map_err(|e| VtcError::Http {
523 status: 200,
524 body: format!("{type_uri} response is not an OwnerResponse: {e}"),
525 })
526 }
527
528 /// Send one `rooms/*` document and return its response payload.
529 ///
530 /// The one place a room call is made, so the no-token property is visible in a single
531 /// function rather than repeated across five: this builds a signed document and posts
532 /// it, and never touches `self.token`.
533 async fn room_task(
534 &self,
535 type_uri: &str,
536 payload: serde_json::Value,
537 signer_did: &str,
538 private_key_multibase: &str,
539 ) -> Result<serde_json::Value, VtcError> {
540 let doc = vta_sdk::trust_task_sign::build_signed(
541 type_uri,
542 payload,
543 signer_did,
544 private_key_multibase,
545 &self.vtc_did,
546 )
547 .await
548 .map_err(|e| VtcError::Signing(e.to_string()))?;
549
550 let resp = self
551 .http
552 .post(format!("{}/trust-tasks", self.base_url))
553 .header("content-type", "application/json")
554 .body(doc)
555 .send()
556 .await?;
557 if !resp.status().is_success() {
558 let status = resp.status().as_u16();
559 let body = resp.text().await.unwrap_or_default();
560 return Err(VtcError::Http { status, body });
561 }
562
563 let text = resp.text().await?;
564 let response_doc: trust_tasks_rs::TrustTask<serde_json::Value> =
565 serde_json::from_str(&text).map_err(|e| VtcError::Http {
566 status: 200,
567 body: format!("unexpected room response (not a Trust Task document): {e}: {text}"),
568 })?;
569 Ok(response_doc.payload)
570 }
571}
572
573#[cfg(test)]
574mod tests {
575 use super::*;
576
577 #[test]
578 fn a_session_requires_a_chain() {
579 let err = RoomSession::new("did:key:zRoom", "vmc", vec![]).unwrap_err();
580 assert!(
581 format!("{err}").contains("authorized by the chain"),
582 "a room session without a chain has nothing to present: {err}"
583 );
584 }
585
586 #[test]
587 fn a_session_refuses_a_chain_past_the_ceiling() {
588 let chain: Vec<String> = (0..=MAX_CHAIN_DEPTH).map(|i| format!("vac-{i}")).collect();
589 let err = RoomSession::new("did:key:zRoom", "vmc", chain).unwrap_err();
590 assert!(format!("{err}").contains("exceeding the maximum"), "{err}");
591 }
592
593 /// The agent case, at the level the client models it: same construction, one more link,
594 /// and the narrowing lives in the credentials rather than in any flag here.
595 #[test]
596 fn a_members_session_and_their_agents_differ_only_in_the_chain() {
597 let member = RoomSession::new("did:key:zRoom", "vmc", vec!["vac-member".into()])
598 .expect("member session");
599 let agent = RoomSession::new(
600 "did:key:zRoom",
601 "vmc",
602 vec!["vac-agent".into(), "vac-member".into()],
603 )
604 .expect("agent session");
605
606 assert_eq!(member.room_id(), agent.room_id());
607 assert_eq!(member.chain_depth(), 1, "a grant straight from the room");
608 assert_eq!(agent.chain_depth(), 2, "one attenuation deeper");
609 }
610
611 #[test]
612 fn a_subject_binding_is_attached_only_when_asked_for() {
613 let s = RoomSession::new("did:key:zRoom", "vmc", vec!["vac".into()]).unwrap();
614 assert!(s.presentation.subject_binding.is_none());
615 let s = s.with_subject_binding("proof");
616 assert_eq!(s.presentation.subject_binding.as_deref(), Some("proof"));
617 }
618
619 /// The wire shape a host reads. `camelCase`, and the binding omitted rather than null
620 /// when absent — a host distinguishes absent from present-but-empty.
621 #[test]
622 fn a_presentation_serialises_camel_case_and_omits_an_absent_binding() {
623 let s = RoomSession::new("did:key:zRoom", "vmc", vec!["a".into(), "b".into()]).unwrap();
624 let v = serde_json::to_value(&s.presentation).unwrap();
625 assert_eq!(v["membership"], "vmc");
626 assert_eq!(v["authority"][0], "a", "leaf first");
627 assert!(v.get("subjectBinding").is_none());
628
629 let s = s.with_subject_binding("bind");
630 let v = serde_json::to_value(&s.presentation).unwrap();
631 assert_eq!(v["subjectBinding"], "bind");
632 }
633
634 #[test]
635 fn visibility_serialises_lowercase_as_the_host_expects() {
636 assert_eq!(
637 serde_json::to_value(Visibility::Attributed).unwrap(),
638 serde_json::json!("attributed")
639 );
640 }
641}