Skip to main content

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, 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/// A caller's standing in one room.
55///
56/// Holds the credentials, not a session. Build one per room per identity — a member's and
57/// their agent's are different sessions against the same room, differing only in the chain
58/// they carry.
59#[derive(Debug, Clone)]
60pub struct RoomSession {
61    room_id: String,
62    presentation: AuthorityPresentation,
63}
64
65impl RoomSession {
66    /// Build a session from a membership credential and an authority chain.
67    ///
68    /// `authority` is **leaf first**: the credential being relied on comes first, and the
69    /// one the room issued comes last. Rejected here if it is empty or deeper than
70    /// [`MAX_CHAIN_DEPTH`], so a caller learns locally rather than from a rejected request.
71    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    /// Attach the same-subject proof a `private` room requires.
100    ///
101    /// Without it a private-room call is refused: two parties could otherwise pool
102    /// credentials — one contributing membership, the other authority — and present as a
103    /// single party holding both.
104    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    /// The room this session acts on.
110    pub fn room_id(&self) -> &str {
111        &self.room_id
112    }
113
114    /// How many links the chain carries. Depth 1 is a grant straight from the room;
115    /// depth 2 is typically a member's agent.
116    pub fn chain_depth(&self) -> usize {
117        self.presentation.authority.len()
118    }
119}
120
121impl VtcClient {
122    /// Register a room with this host.
123    ///
124    /// The caller brings `room_id`: a room identified by something its host chose could not
125    /// move to another host without changing identity.
126    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    /// Write a record.
151    ///
152    /// Exactly one of `sealed` / `cleartext` — the host refuses the other shape for the
153    /// room's tier, so passing both or neither is a request it cannot honour.
154    ///
155    /// `expected_version` is an optional precondition: `Some(0)` means create-only, and
156    /// `Some(n)` requires the stored record to be at version `n`. A mismatch comes back
157    /// carrying the current version, so a caller does not have to re-read to learn what it
158    /// lost to.
159    #[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    /// Read one record.
201    ///
202    /// Presents exactly as a write does, and needs no session — which is the point on a
203    /// sealed room: authorizing reads by session would hand the host a member identifier on
204    /// every access, and a period of those reconstructs the membership the tier withholds.
205    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    /// List record metadata.
227    ///
228    /// Never returns bodies — fetch the handful that matter with [`VtcClient::get_record`].
229    /// `since_version` is the incremental-sync watermark, and the response **includes
230    /// tombstones**: a caller that never saw a retraction would resurrect the record on its
231    /// next full rebuild.
232    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    /// Advance the room's key epoch — how a member is removed.
265    ///
266    /// Requires a chain conferring `admin`. `epoch` must be exactly one greater than the
267    /// current one. The host records the number and never learns the key: distributing it
268    /// to the remaining members happens out of its sight.
269    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    /// Hand the room to another member, deliberately and while still present.
300    ///
301    /// Requires a chain conferring `admin` — the same grant that mints epochs, since
302    /// transferring is the more consequential of the two.
303    ///
304    /// **The host does not check that `new_owner_did` is a member**, and cannot: it holds no
305    /// roster and no MLS group state. That obligation is the outgoing owner's, who can see
306    /// the group. Give the room to someone who cannot commit and they inherit a room they
307    /// cannot renew.
308    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    /// Claim a room whose owner has stopped renewing it.
334    ///
335    /// `nomination` is the succession credential the room issued to this claimant in
336    /// advance. All three of the host's conditions must hold together: a valid nomination,
337    /// a room that has been **dormant** past its grace window — not merely lapsed — and the
338    /// claimant's own membership, which is what `session` carries.
339    ///
340    /// A claim does not renew the room. It hands over a dormant room, and the new owner's
341    /// first act should be the epoch mint that proves they can perform it.
342    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    /// The shared tail of the two succession verbs, which answer with one shape.
368    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    /// Send one `rooms/*` document and return its response payload.
385    ///
386    /// The one place a room call is made, so the no-token property is visible in a single
387    /// function rather than repeated across five: this builds a signed document and posts
388    /// it, and never touches `self.token`.
389    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    /// The agent case, at the level the client models it: same construction, one more link,
450    /// and the narrowing lives in the credentials rather than in any flag here.
451    #[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    /// The wire shape a host reads. `camelCase`, and the binding omitted rather than null
476    /// when absent — a host distinguishes absent from present-but-empty.
477    #[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}