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, 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}