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 it names the
39/// **recipient** of the document — nothing more. It used to be passed as the presentation's
40/// `audience` as well, on the reading that this bound the presentation to one host. It never
41/// could: see [`present`]. A presentation is bound to whoever will sign the request, always,
42/// so there is no unbound case for an operator to be warned about.
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.
60///
61/// # There is no party to name here, and there used to be two
62///
63/// This passed `target.host_did` as the presentation's `audience` until that was found
64/// never to work: `audience` named the party that had to *present* the credential, not the
65/// one it was addressed to, so filling it with a host's DID named somebody no presenter can
66/// ever be, and every request was refused. Omitting `--host-did` "worked" only because an
67/// absent audience skipped the check — so the flag's two states were *broken* and
68/// *unprotected*, with nothing in between.
69///
70/// Neither the field nor this parameter survives. A presentation is granted to the caller
71/// the VTA authenticated and a host refuses a chain granting to anyone else, so who may
72/// present is established rather than declared; and it is bound to a *room* rather than a
73/// host, so there is no destination to name either. `--host-did` keeps its real job:
74/// naming the document's `recipient`.
75///
76/// See `rooms/keys/present/0.2`, which removed both members, and
77/// trustoverip/dtgwg-trust-tasks-tf#414.
78async fn present(
79    client: &VtaClient,
80    target: &RoomTarget<'_>,
81    action: &str,
82) -> Result<RoomSession, Box<dyn std::error::Error>> {
83    let minted = client.room_present(target.room_id, action).await?;
84    session_from_minted(target.room_id, &minted)
85}
86
87/// Rebuild the session from what the VTA minted.
88///
89/// The VTA answers with the presentation the host expects, so it is read back
90/// rather than reassembled from parts — a second assembly is a second chance to
91/// get the chain order wrong, and the order is load-bearing (leaf first).
92///
93/// Every missing member is an error rather than a default. A presentation with
94/// no chain is not an empty presentation; it is a reply this client does not
95/// understand, and proceeding would send the host something it will refuse for
96/// reasons that read as a credential problem.
97pub fn session_from_minted(
98    room_id: &str,
99    minted: &Value,
100) -> Result<RoomSession, Box<dyn std::error::Error>> {
101    let presentation = minted
102        .get("presentation")
103        .ok_or_else(|| format!("the VTA's reply carried no presentation: {minted}"))?;
104    let membership = presentation
105        .get("membership")
106        .and_then(Value::as_str)
107        .ok_or("the minted presentation carried no membership credential")?;
108    let authority_values = presentation
109        .get("authority")
110        .and_then(Value::as_array)
111        .ok_or("the minted presentation carried no authority chain")?;
112    let authority: Vec<String> = authority_values
113        .iter()
114        .filter_map(|v| v.as_str().map(str::to_string))
115        .collect();
116    // A chain that lost links to a type mismatch is not a shorter chain — it is
117    // a different grant, and a shorter one always confers less than the VTA
118    // minted. Refuse rather than present it.
119    if authority.len() != authority_values.len() {
120        return Err("the minted authority chain contained a non-string link".into());
121    }
122
123    let mut session = RoomSession::new(room_id, membership, authority)?;
124    if let Some(binding) = presentation.get("subjectBinding").and_then(Value::as_str) {
125        session = session.with_subject_binding(binding);
126    }
127    Ok(session)
128}
129
130/// Resolve `--pin` / `--unpin` into the wire value.
131///
132/// Three states from two flags: pinned, unpinned, and **unchanged**. Absence has
133/// to stay distinguishable from `false`, or a curation meaning to change only
134/// the status would silently unpin the record on its way past.
135pub fn pinned_from_flags(pin: bool, unpin: bool) -> Option<bool> {
136    if pin {
137        Some(true)
138    } else if unpin {
139        Some(false)
140    } else {
141        None
142    }
143}
144
145/// The signer for the room-host leg: the operator's own DID and key.
146///
147/// A room request is signed by the party the presentation was minted *for*, and
148/// the VTA minted it for the DID the CLI authenticates as. Signing with any
149/// other key produces a presentation bound to somebody else, which the host
150/// refuses — correctly, and confusingly, so the two are taken from one place.
151pub struct RoomSigner<'a> {
152    pub did: &'a str,
153    pub key_multibase: &'a str,
154}
155
156/// `rooms list` — the room's records, metadata only.
157pub async fn cmd_rooms_list(
158    client: &VtaClient,
159    target: RoomTarget<'_>,
160    signer: RoomSigner<'_>,
161    prefix: Option<&str>,
162    since_version: Option<u64>,
163    limit: Option<usize>,
164) -> Result<(), Box<dyn std::error::Error>> {
165    let session = present(client, &target, "read").await?;
166
167    // Read the listing to its END, then let `limit` decide how much to PRINT.
168    // A host pages, and absence of the cursor is the only end-of-listing signal
169    // — taking the first page would print "3 record(s)" over a room holding
170    // three hundred, which is the failure this whole member exists to prevent.
171    let mut listing = target
172        .client()
173        .list_records(
174            &session,
175            prefix,
176            since_version,
177            None,
178            signer.did,
179            signer.key_multibase,
180        )
181        .await?;
182    let mut cursor = listing.cursor.clone();
183    while let Some(c) = cursor {
184        let next = target
185            .client()
186            .list_records(
187                &session,
188                prefix,
189                since_version,
190                Some(&c),
191                signer.did,
192                signer.key_multibase,
193            )
194            .await?;
195        cursor = next.cursor.clone();
196        listing.records.extend(next.records);
197    }
198
199    if crate::render::is_json_output() {
200        crate::render::print_json(&listing.records)?;
201        return Ok(());
202    }
203    if listing.records.is_empty() {
204        println!(
205            "No records{}.",
206            prefix.map(|p| format!(" under `{p}`")).unwrap_or_default()
207        );
208        return Ok(());
209    }
210
211    let shown = limit.unwrap_or(usize::MAX).min(listing.records.len());
212    if shown < listing.records.len() {
213        println!(
214            "{} record(s), showing {shown} — pass a larger --limit for the rest:",
215            listing.records.len()
216        );
217    } else {
218        println!("{} record(s):", listing.records.len());
219    }
220    for r in listing.records.iter().take(shown) {
221        let key = r.get("key").and_then(Value::as_str).unwrap_or("?");
222        let version = r.get("version").and_then(Value::as_u64).unwrap_or(0);
223        let status = r.get("status").and_then(Value::as_str).unwrap_or("active");
224        // On a sealed tier there is no title to show — that is the tier working,
225        // not a gap, so say so rather than printing an empty column.
226        let title = r
227            .get("cleartext")
228            .and_then(|c| c.get("title"))
229            .and_then(Value::as_str)
230            .unwrap_or("(sealed)");
231        println!("  {key}  v{version}  {status:<10} {title}");
232    }
233    Ok(())
234}
235
236/// `rooms get` — one record, opened through the VTA when it is sealed.
237pub async fn cmd_rooms_get(
238    client: &VtaClient,
239    target: RoomTarget<'_>,
240    signer: RoomSigner<'_>,
241    key: &str,
242) -> Result<(), Box<dyn std::error::Error>> {
243    let session = present(client, &target, "read").await?;
244    let record = target
245        .client()
246        .get_record(&session, key, signer.did, signer.key_multibase)
247        .await?;
248
249    // An `open`-tier record arrives readable. A sealed one arrives as
250    // ciphertext this process cannot decrypt, and must not try to: the group
251    // key is the VTA's.
252    if let Some(cleartext) = record.get("cleartext") {
253        if crate::render::is_json_output() {
254            crate::render::print_json(cleartext)?;
255        } else {
256            if let Some(title) = cleartext.get("title").and_then(Value::as_str) {
257                println!("{title}\n");
258            }
259            println!(
260                "{}",
261                cleartext.get("body").and_then(Value::as_str).unwrap_or("")
262            );
263        }
264        return Ok(());
265    }
266
267    let sealed = record.get("sealed").and_then(Value::as_str).ok_or(
268        "the record carried neither cleartext nor sealed content — is this a room this \
269         host serves?",
270    )?;
271    let nonce = record
272        .get("nonce")
273        .and_then(Value::as_str)
274        .ok_or("a sealed record with no nonce cannot be opened")?;
275    let epoch = record.get("epoch").and_then(Value::as_u64).unwrap_or(0) as u32;
276    let version = record.get("version").and_then(Value::as_u64).unwrap_or(0);
277
278    let opened = client
279        .room_open(target.room_id, key, version, sealed, nonce, epoch)
280        .await
281        .map_err(|e| {
282            // The failure operators will actually hit, named where they will
283            // read it. A record sealed under a later epoch is a missed commit,
284            // and it reads like corruption if nobody says otherwise.
285            format!(
286                "{e}\n\nIf this mentions an epoch, your VTA has not been given the room's \
287                 latest commit — ask the room's owner to deliver it, then retry."
288            )
289        })?;
290
291    let plaintext = opened
292        .get("plaintext")
293        .and_then(Value::as_str)
294        .ok_or("the VTA opened the record but returned no plaintext")?;
295    let bytes =
296        base64::Engine::decode(&base64::engine::general_purpose::URL_SAFE_NO_PAD, plaintext)?;
297    println!("{}", String::from_utf8_lossy(&bytes));
298    Ok(())
299}
300
301/// `rooms put` — write a record to an `open` room.
302///
303/// Sealed tiers are refused rather than half-served: sealing needs the room's
304/// MLS group, which lives in the VTA, and there is no task that seals on a
305/// caller's behalf. Writing cleartext into a room whose other records are
306/// encrypted would be worse than refusing.
307pub async fn cmd_rooms_put(
308    client: &VtaClient,
309    target: RoomTarget<'_>,
310    signer: RoomSigner<'_>,
311    key: &str,
312    title: Option<String>,
313    body: String,
314    expected_version: Option<u64>,
315) -> Result<(), Box<dyn std::error::Error>> {
316    let session = present(client, &target, "write").await?;
317    let put = target
318        .client()
319        .put_record(
320            &session,
321            key,
322            None,
323            Some(CleartextContent {
324                title,
325                body,
326                ..Default::default()
327            }),
328            expected_version,
329            signer.did,
330            signer.key_multibase,
331        )
332        .await
333        .map_err(|e| {
334            format!(
335                "{e}\n\nA sealed room (`attributed` / `private`) refuses cleartext: sealing \
336                 needs the room's group key, which lives in your VTA, and no task seals on a \
337                 caller's behalf yet."
338            )
339        })?;
340    println!("Wrote {} at version {}", put.key, put.version);
341    Ok(())
342}
343
344/// `rooms curate` — change a record's standing.
345pub async fn cmd_rooms_curate(
346    client: &VtaClient,
347    target: RoomTarget<'_>,
348    signer: RoomSigner<'_>,
349    key: &str,
350    status: Option<String>,
351    pinned: Option<bool>,
352    reason: Option<String>,
353) -> Result<(), Box<dyn std::error::Error>> {
354    if status.is_none() && pinned.is_none() {
355        return Err("nothing to change — pass --status and/or --pin/--unpin".into());
356    }
357    // `curate` is its own grant, deliberately not implied by `write`: deciding
358    // what a room's shared knowledge is worth is a different act from adding to
359    // it. So the presentation is minted for `curate`, and a member who only
360    // writes is refused here rather than at the host.
361    let session = present(client, &target, "curate").await?;
362
363    let out = target
364        .client()
365        .curate_record(
366            &session,
367            key,
368            status,
369            pinned,
370            reason,
371            signer.did,
372            signer.key_multibase,
373        )
374        .await?;
375    println!(
376        "{} is now {} at version {}{}",
377        out.key,
378        serde_json::to_value(out.status)
379            .ok()
380            .and_then(|v| v.as_str().map(str::to_string))
381            .unwrap_or_else(|| "?".into()),
382        out.version,
383        if out.pinned { " (pinned)" } else { "" }
384    );
385    Ok(())
386}
387
388/// `rooms renew` — mint the next epoch, which is what keeps a room live.
389///
390/// Needs `admin`. It is the same act as ordinary use, and it is the whole
391/// defence against a hostile succession claim: an owner who renews is
392/// structurally safe without thinking about it.
393pub async fn cmd_rooms_renew(
394    client: &VtaClient,
395    target: RoomTarget<'_>,
396    signer: RoomSigner<'_>,
397    epoch: u32,
398    reason: Option<String>,
399) -> Result<(), Box<dyn std::error::Error>> {
400    let session = present(client, &target, "admin").await?;
401    let minted = target
402        .client()
403        .mint_epoch(
404            &session,
405            epoch,
406            reason.as_deref(),
407            signer.did,
408            signer.key_multibase,
409        )
410        .await?;
411    println!("Room {} is at epoch {}", minted.room_id, minted.epoch);
412    Ok(())
413}
414
415/// `rooms create` — register a room with a host.
416///
417/// The only command here that needs no presentation: the room has issued
418/// nothing yet, so there is no chain to present. The host checks the request's
419/// own proof instead, which is why this must be signed as the owner it names.
420pub async fn cmd_rooms_create(
421    target: RoomTarget<'_>,
422    signer: RoomSigner<'_>,
423    visibility: &str,
424    retention_days: Option<u32>,
425) -> Result<(), Box<dyn std::error::Error>> {
426    let visibility: Visibility = serde_json::from_value(Value::String(visibility.to_string()))
427        .map_err(|_| "visibility must be one of: open, attributed, private")?;
428
429    target
430        .client()
431        .create_room(
432            target.room_id,
433            signer.did,
434            visibility,
435            retention_days,
436            signer.did,
437            signer.key_multibase,
438        )
439        .await
440        .map_err(|e| {
441            format!(
442                "{e}\n\nA community host decides whose rooms it stores. If this says \
443                 `not-a-member`, you are not a member there; if it says \
444                 `private-tier-not-enabled`, that community has not turned the tier on."
445            )
446        })?;
447    println!("Registered {} as {}", target.room_id, signer.did);
448    println!(
449        "  Next: the room must issue you a membership and an authority credential before \
450         you can act in it."
451    );
452    Ok(())
453}
454
455#[cfg(test)]
456mod tests {
457    use super::*;
458    use serde_json::json;
459
460    fn minted(authority: Value) -> Value {
461        json!({
462            "presentation": {
463                "membership": "vmc-blob",
464                "authority": authority,
465            },
466            "expiresAt": "2026-09-07T16:00:00Z",
467        })
468    }
469
470    #[test]
471    fn a_minted_presentation_becomes_a_session() {
472        let session = session_from_minted("did:webvh:room", &minted(json!(["leaf", "root"])))
473            .expect("a well-formed reply rebuilds");
474        assert_eq!(session.room_id(), "did:webvh:room");
475        assert_eq!(session.chain_depth(), 2, "leaf first, root last");
476    }
477
478    /// A private room's presentation carries the same-subject proof, and losing
479    /// it on the way through would turn a valid call into a refusal the
480    /// operator cannot explain.
481    #[test]
482    fn a_subject_binding_survives_the_rebuild() {
483        let mut m = minted(json!(["leaf"]));
484        m["presentation"]["subjectBinding"] = json!("zk-proof-blob");
485        let session =
486            session_from_minted("did:webvh:room", &m).expect("a private presentation rebuilds");
487        assert_eq!(session.chain_depth(), 1);
488    }
489
490    /// Each missing member is refused rather than defaulted. A reply this
491    /// client does not understand must not become a request the host refuses
492    /// for reasons that read as a credential problem.
493    #[test]
494    fn an_unreadable_reply_is_refused_rather_than_guessed() {
495        assert!(
496            session_from_minted("r", &json!({})).is_err(),
497            "no presentation"
498        );
499        assert!(
500            session_from_minted("r", &json!({ "presentation": { "authority": ["a"] } })).is_err(),
501            "no membership"
502        );
503        assert!(
504            session_from_minted("r", &json!({ "presentation": { "membership": "m" } })).is_err(),
505            "no chain"
506        );
507        assert!(
508            session_from_minted("r", &minted(json!([]))).is_err(),
509            "an empty chain authorizes nothing and must not be sent"
510        );
511    }
512
513    /// A link that is not a string would otherwise be filtered out, silently
514    /// shortening the chain — and a shorter chain confers less than the VTA
515    /// minted, so the call would fail somewhere far from the cause.
516    #[test]
517    fn a_malformed_link_does_not_silently_shorten_the_chain() {
518        let err = session_from_minted("r", &minted(json!(["leaf", 7])))
519            .expect_err("a non-string link is refused");
520        assert!(
521            err.to_string().contains("non-string"),
522            "the error must name the cause: {err}"
523        );
524    }
525
526    #[test]
527    fn pin_flags_keep_unchanged_distinct_from_unpinned() {
528        assert_eq!(pinned_from_flags(true, false), Some(true));
529        assert_eq!(pinned_from_flags(false, true), Some(false));
530        assert_eq!(
531            pinned_from_flags(false, false),
532            None,
533            "neither flag must leave the pin alone, not clear it"
534        );
535    }
536}