Skip to main content

vta_cli_common/commands/
rooms.rs

1//! `pnm rooms …` — a member's surface on a data room.
2//!
3//! # Two parties, two transports, and why that is the whole design
4//!
5//! Every command here talks to **two** services, and never confuses them:
6//!
7//! - the operator's **VTA**, over the client's existing session, to mint a
8//!   presentation ([`VtaClient::room_present`]) and to open a sealed record
9//!   ([`VtaClient::room_open`]);
10//! - the room's **host**, unauthenticated, carrying that presentation.
11//!
12//! The credentials the presentation is derived from, and the group key that
13//! opens a record, stay inside the VTA. This CLI holds neither at any point,
14//! which is what makes losing the laptop a smaller event than losing the agent.
15//!
16//! # Why a fresh presentation per call
17//!
18//! A presentation names *what may be done*, not who is doing it, and is bound
19//! to the party that signed the request it rides. Caching one across commands
20//! would mean either re-binding it (impossible without the VTA) or sending it
21//! unbound (a bearer token). It is one extra local round-trip and it removes a
22//! whole class of mistake, so every command mints its own.
23//!
24//! # What this surface deliberately does not do
25//!
26//! **Issue credentials.** Minting a VIC, VMC or VAC needs the *room's* signing
27//! key, which is the owner's, not a member's — a different party with different
28//! custody. It belongs in an owner surface and is deliberately absent here
29//! rather than half-present.
30
31use serde_json::Value;
32use vta_sdk::prelude::*;
33use vtc_client::VtcClient;
34use vtc_client::rooms::{CleartextContent, RoomSession, Visibility};
35
36/// Everything a room command needs to reach both parties.
37///
38/// The host DID is optional because an operator may not know it, and the cost
39/// of not knowing is stated rather than hidden: without it the minted
40/// presentation carries no audience, so it is bearer-shaped against that room
41/// for its four-hour life. With it, a captured presentation is worthless to
42/// anyone else.
43pub struct RoomTarget<'a> {
44    pub host_url: &'a str,
45    pub host_did: Option<&'a str>,
46    pub room_id: &'a str,
47}
48
49impl RoomTarget<'_> {
50    fn client(&self) -> VtcClient {
51        // The room surface carries no token — a room operation is authorized by
52        // the presentation, never by a session with the host — so an anonymous
53        // client is the correct one even when the host is a VTC the operator
54        // also has an account on.
55        VtcClient::anonymous(self.host_url, self.host_did.unwrap_or("did:key:zHost"))
56    }
57}
58
59/// Mint a presentation for one action, and warn when it will be unbound.
60async fn present(
61    client: &VtaClient,
62    target: &RoomTarget<'_>,
63    action: &str,
64) -> Result<RoomSession, Box<dyn std::error::Error>> {
65    if target.host_did.is_none() {
66        eprintln!(
67            "note: no --host-did given, so this presentation is not bound to a host. \
68             Anyone who observes it can use it against this room until it expires."
69        );
70    }
71
72    let minted = client
73        .room_present(target.room_id, action, target.host_did, None)
74        .await?;
75    session_from_minted(target.room_id, &minted)
76}
77
78/// Rebuild the session from what the VTA minted.
79///
80/// The VTA answers with the presentation the host expects, so it is read back
81/// rather than reassembled from parts — a second assembly is a second chance to
82/// get the chain order wrong, and the order is load-bearing (leaf first).
83///
84/// Every missing member is an error rather than a default. A presentation with
85/// no chain is not an empty presentation; it is a reply this client does not
86/// understand, and proceeding would send the host something it will refuse for
87/// reasons that read as a credential problem.
88pub fn session_from_minted(
89    room_id: &str,
90    minted: &Value,
91) -> Result<RoomSession, Box<dyn std::error::Error>> {
92    let presentation = minted
93        .get("presentation")
94        .ok_or_else(|| format!("the VTA's reply carried no presentation: {minted}"))?;
95    let membership = presentation
96        .get("membership")
97        .and_then(Value::as_str)
98        .ok_or("the minted presentation carried no membership credential")?;
99    let authority_values = presentation
100        .get("authority")
101        .and_then(Value::as_array)
102        .ok_or("the minted presentation carried no authority chain")?;
103    let authority: Vec<String> = authority_values
104        .iter()
105        .filter_map(|v| v.as_str().map(str::to_string))
106        .collect();
107    // A chain that lost links to a type mismatch is not a shorter chain — it is
108    // a different grant, and a shorter one always confers less than the VTA
109    // minted. Refuse rather than present it.
110    if authority.len() != authority_values.len() {
111        return Err("the minted authority chain contained a non-string link".into());
112    }
113
114    let mut session = RoomSession::new(room_id, membership, authority)?;
115    if let Some(binding) = presentation.get("subjectBinding").and_then(Value::as_str) {
116        session = session.with_subject_binding(binding);
117    }
118    Ok(session)
119}
120
121/// Resolve `--pin` / `--unpin` into the wire value.
122///
123/// Three states from two flags: pinned, unpinned, and **unchanged**. Absence has
124/// to stay distinguishable from `false`, or a curation meaning to change only
125/// the status would silently unpin the record on its way past.
126pub fn pinned_from_flags(pin: bool, unpin: bool) -> Option<bool> {
127    if pin {
128        Some(true)
129    } else if unpin {
130        Some(false)
131    } else {
132        None
133    }
134}
135
136/// The signer for the room-host leg: the operator's own DID and key.
137///
138/// A room request is signed by the party the presentation was minted *for*, and
139/// the VTA minted it for the DID the CLI authenticates as. Signing with any
140/// other key produces a presentation bound to somebody else, which the host
141/// refuses — correctly, and confusingly, so the two are taken from one place.
142pub struct RoomSigner<'a> {
143    pub did: &'a str,
144    pub key_multibase: &'a str,
145}
146
147/// `rooms list` — the room's records, metadata only.
148pub async fn cmd_rooms_list(
149    client: &VtaClient,
150    target: RoomTarget<'_>,
151    signer: RoomSigner<'_>,
152    prefix: Option<&str>,
153    since_version: Option<u64>,
154    limit: Option<usize>,
155) -> Result<(), Box<dyn std::error::Error>> {
156    let session = present(client, &target, "read").await?;
157    let listing = target
158        .client()
159        .list_records(
160            &session,
161            prefix,
162            since_version,
163            signer.did,
164            signer.key_multibase,
165        )
166        .await?;
167
168    if crate::render::is_json_output() {
169        crate::render::print_json(&listing.records)?;
170        return Ok(());
171    }
172    if listing.records.is_empty() {
173        println!(
174            "No records{}.",
175            prefix.map(|p| format!(" under `{p}`")).unwrap_or_default()
176        );
177        return Ok(());
178    }
179
180    println!("{} record(s):", listing.records.len());
181    for r in listing.records.iter().take(limit.unwrap_or(usize::MAX)) {
182        let key = r.get("key").and_then(Value::as_str).unwrap_or("?");
183        let version = r.get("version").and_then(Value::as_u64).unwrap_or(0);
184        let status = r.get("status").and_then(Value::as_str).unwrap_or("active");
185        // On a sealed tier there is no title to show — that is the tier working,
186        // not a gap, so say so rather than printing an empty column.
187        let title = r
188            .get("cleartext")
189            .and_then(|c| c.get("title"))
190            .and_then(Value::as_str)
191            .unwrap_or("(sealed)");
192        println!("  {key}  v{version}  {status:<10} {title}");
193    }
194    Ok(())
195}
196
197/// `rooms get` — one record, opened through the VTA when it is sealed.
198pub async fn cmd_rooms_get(
199    client: &VtaClient,
200    target: RoomTarget<'_>,
201    signer: RoomSigner<'_>,
202    key: &str,
203) -> Result<(), Box<dyn std::error::Error>> {
204    let session = present(client, &target, "read").await?;
205    let record = target
206        .client()
207        .get_record(&session, key, signer.did, signer.key_multibase)
208        .await?;
209
210    // An `open`-tier record arrives readable. A sealed one arrives as
211    // ciphertext this process cannot decrypt, and must not try to: the group
212    // key is the VTA's.
213    if let Some(cleartext) = record.get("cleartext") {
214        if crate::render::is_json_output() {
215            crate::render::print_json(cleartext)?;
216        } else {
217            if let Some(title) = cleartext.get("title").and_then(Value::as_str) {
218                println!("{title}\n");
219            }
220            println!(
221                "{}",
222                cleartext.get("body").and_then(Value::as_str).unwrap_or("")
223            );
224        }
225        return Ok(());
226    }
227
228    let sealed = record.get("sealed").and_then(Value::as_str).ok_or(
229        "the record carried neither cleartext nor sealed content — is this a room this \
230         host serves?",
231    )?;
232    let nonce = record
233        .get("nonce")
234        .and_then(Value::as_str)
235        .ok_or("a sealed record with no nonce cannot be opened")?;
236    let epoch = record.get("epoch").and_then(Value::as_u64).unwrap_or(0) as u32;
237    let version = record.get("version").and_then(Value::as_u64).unwrap_or(0);
238
239    let opened = client
240        .room_open(target.room_id, key, version, sealed, nonce, epoch)
241        .await
242        .map_err(|e| {
243            // The failure operators will actually hit, named where they will
244            // read it. A record sealed under a later epoch is a missed commit,
245            // and it reads like corruption if nobody says otherwise.
246            format!(
247                "{e}\n\nIf this mentions an epoch, your VTA has not been given the room's \
248                 latest commit — ask the room's owner to deliver it, then retry."
249            )
250        })?;
251
252    let plaintext = opened
253        .get("plaintext")
254        .and_then(Value::as_str)
255        .ok_or("the VTA opened the record but returned no plaintext")?;
256    let bytes =
257        base64::Engine::decode(&base64::engine::general_purpose::URL_SAFE_NO_PAD, plaintext)?;
258    println!("{}", String::from_utf8_lossy(&bytes));
259    Ok(())
260}
261
262/// `rooms put` — write a record to an `open` room.
263///
264/// Sealed tiers are refused rather than half-served: sealing needs the room's
265/// MLS group, which lives in the VTA, and there is no task that seals on a
266/// caller's behalf. Writing cleartext into a room whose other records are
267/// encrypted would be worse than refusing.
268pub async fn cmd_rooms_put(
269    client: &VtaClient,
270    target: RoomTarget<'_>,
271    signer: RoomSigner<'_>,
272    key: &str,
273    title: Option<String>,
274    body: String,
275    expected_version: Option<u64>,
276) -> Result<(), Box<dyn std::error::Error>> {
277    let session = present(client, &target, "write").await?;
278    let put = target
279        .client()
280        .put_record(
281            &session,
282            key,
283            None,
284            Some(CleartextContent {
285                title,
286                body,
287                ..Default::default()
288            }),
289            expected_version,
290            signer.did,
291            signer.key_multibase,
292        )
293        .await
294        .map_err(|e| {
295            format!(
296                "{e}\n\nA sealed room (`attributed` / `private`) refuses cleartext: sealing \
297                 needs the room's group key, which lives in your VTA, and no task seals on a \
298                 caller's behalf yet."
299            )
300        })?;
301    println!("Wrote {} at version {}", put.key, put.version);
302    Ok(())
303}
304
305/// `rooms curate` — change a record's standing.
306pub async fn cmd_rooms_curate(
307    client: &VtaClient,
308    target: RoomTarget<'_>,
309    signer: RoomSigner<'_>,
310    key: &str,
311    status: Option<String>,
312    pinned: Option<bool>,
313    reason: Option<String>,
314) -> Result<(), Box<dyn std::error::Error>> {
315    if status.is_none() && pinned.is_none() {
316        return Err("nothing to change — pass --status and/or --pin/--unpin".into());
317    }
318    // `curate` is its own grant, deliberately not implied by `write`: deciding
319    // what a room's shared knowledge is worth is a different act from adding to
320    // it. So the presentation is minted for `curate`, and a member who only
321    // writes is refused here rather than at the host.
322    let session = present(client, &target, "curate").await?;
323
324    let out = target
325        .client()
326        .curate_record(
327            &session,
328            key,
329            status,
330            pinned,
331            reason,
332            signer.did,
333            signer.key_multibase,
334        )
335        .await?;
336    println!(
337        "{} is now {} at version {}{}",
338        out.key,
339        serde_json::to_value(out.status)
340            .ok()
341            .and_then(|v| v.as_str().map(str::to_string))
342            .unwrap_or_else(|| "?".into()),
343        out.version,
344        if out.pinned { " (pinned)" } else { "" }
345    );
346    Ok(())
347}
348
349/// `rooms renew` — mint the next epoch, which is what keeps a room live.
350///
351/// Needs `admin`. It is the same act as ordinary use, and it is the whole
352/// defence against a hostile succession claim: an owner who renews is
353/// structurally safe without thinking about it.
354pub async fn cmd_rooms_renew(
355    client: &VtaClient,
356    target: RoomTarget<'_>,
357    signer: RoomSigner<'_>,
358    epoch: u32,
359    reason: Option<String>,
360) -> Result<(), Box<dyn std::error::Error>> {
361    let session = present(client, &target, "admin").await?;
362    let minted = target
363        .client()
364        .mint_epoch(
365            &session,
366            epoch,
367            reason.as_deref(),
368            signer.did,
369            signer.key_multibase,
370        )
371        .await?;
372    println!("Room {} is at epoch {}", minted.room_id, minted.epoch);
373    Ok(())
374}
375
376/// `rooms create` — register a room with a host.
377///
378/// The only command here that needs no presentation: the room has issued
379/// nothing yet, so there is no chain to present. The host checks the request's
380/// own proof instead, which is why this must be signed as the owner it names.
381pub async fn cmd_rooms_create(
382    target: RoomTarget<'_>,
383    signer: RoomSigner<'_>,
384    visibility: &str,
385    retention_days: Option<u32>,
386) -> Result<(), Box<dyn std::error::Error>> {
387    let visibility: Visibility = serde_json::from_value(Value::String(visibility.to_string()))
388        .map_err(|_| "visibility must be one of: open, attributed, private")?;
389
390    target
391        .client()
392        .create_room(
393            target.room_id,
394            signer.did,
395            visibility,
396            retention_days,
397            signer.did,
398            signer.key_multibase,
399        )
400        .await
401        .map_err(|e| {
402            format!(
403                "{e}\n\nA community host decides whose rooms it stores. If this says \
404                 `not-a-member`, you are not a member there; if it says \
405                 `private-tier-not-enabled`, that community has not turned the tier on."
406            )
407        })?;
408    println!("Registered {} as {}", target.room_id, signer.did);
409    println!(
410        "  Next: the room must issue you a membership and an authority credential before \
411         you can act in it."
412    );
413    Ok(())
414}
415
416#[cfg(test)]
417mod tests {
418    use super::*;
419    use serde_json::json;
420
421    fn minted(authority: Value) -> Value {
422        json!({
423            "presentation": {
424                "membership": "vmc-blob",
425                "authority": authority,
426            },
427            "expiresAt": "2026-09-07T16:00:00Z",
428        })
429    }
430
431    #[test]
432    fn a_minted_presentation_becomes_a_session() {
433        let session = session_from_minted("did:webvh:room", &minted(json!(["leaf", "root"])))
434            .expect("a well-formed reply rebuilds");
435        assert_eq!(session.room_id(), "did:webvh:room");
436        assert_eq!(session.chain_depth(), 2, "leaf first, root last");
437    }
438
439    /// A private room's presentation carries the same-subject proof, and losing
440    /// it on the way through would turn a valid call into a refusal the
441    /// operator cannot explain.
442    #[test]
443    fn a_subject_binding_survives_the_rebuild() {
444        let mut m = minted(json!(["leaf"]));
445        m["presentation"]["subjectBinding"] = json!("zk-proof-blob");
446        let session =
447            session_from_minted("did:webvh:room", &m).expect("a private presentation rebuilds");
448        assert_eq!(session.chain_depth(), 1);
449    }
450
451    /// Each missing member is refused rather than defaulted. A reply this
452    /// client does not understand must not become a request the host refuses
453    /// for reasons that read as a credential problem.
454    #[test]
455    fn an_unreadable_reply_is_refused_rather_than_guessed() {
456        assert!(
457            session_from_minted("r", &json!({})).is_err(),
458            "no presentation"
459        );
460        assert!(
461            session_from_minted("r", &json!({ "presentation": { "authority": ["a"] } })).is_err(),
462            "no membership"
463        );
464        assert!(
465            session_from_minted("r", &json!({ "presentation": { "membership": "m" } })).is_err(),
466            "no chain"
467        );
468        assert!(
469            session_from_minted("r", &minted(json!([]))).is_err(),
470            "an empty chain authorizes nothing and must not be sent"
471        );
472    }
473
474    /// A link that is not a string would otherwise be filtered out, silently
475    /// shortening the chain — and a shorter chain confers less than the VTA
476    /// minted, so the call would fail somewhere far from the cause.
477    #[test]
478    fn a_malformed_link_does_not_silently_shorten_the_chain() {
479        let err = session_from_minted("r", &minted(json!(["leaf", 7])))
480            .expect_err("a non-string link is refused");
481        assert!(
482            err.to_string().contains("non-string"),
483            "the error must name the cause: {err}"
484        );
485    }
486
487    #[test]
488    fn pin_flags_keep_unchanged_distinct_from_unpinned() {
489        assert_eq!(pinned_from_flags(true, false), Some(true));
490        assert_eq!(pinned_from_flags(false, true), Some(false));
491        assert_eq!(
492            pinned_from_flags(false, false),
493            None,
494            "neither flag must leave the pin alone, not clear it"
495        );
496    }
497}