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 complete each
8//!   host-bound request **as the member** ([`VtaClient::room_act`],
9//!   `rooms/keys/act/0.10`), to seal a record body signed as the member
10//!   ([`VtaClient::room_seal_as_principal`]) and to open a sealed record
11//!   ([`VtaClient::room_open`]);
12//! - the room's **host**, unauthenticated, receiving the completed document unchanged.
13//!
14//! # The member is the persona, never this client
15//!
16//! A room admitted the operator's **persona** — a DID their VTA holds — not this CLI's
17//! session key. So the record verbs (`list`, `get`, `put`, `curate`, the `rooms/info`
18//! lookup and `keys commit --host`) build the request unsigned and have the VTA complete
19//! it as the persona: the persona's own authority narrowed to the one action, the persona
20//! as `issuer` and signer. The host records the persona, and this client's DID reaches it
21//! nowhere. File transfer works the same way (`rooms/blobs/*/0.2`): the begin, the record,
22//! the read and — in direct mode — the commit are completed as the persona, and a proxied
23//! upload's chunks are signed by a single-use upload key minted for that upload. `renew`
24//! and `prune` still present for this client ([`VtaClient::room_present`]) and sign with
25//! its own key; `renew` moves separately, and `prune` cannot move to `rooms/keys/act`,
26//! whose every version leaves `rooms/epoch/prune` out on purpose.
27//!
28//! The credentials the presentation is derived from, and the group key that
29//! opens a record, stay inside the VTA. This CLI holds neither at any point,
30//! which is what makes losing the laptop a smaller event than losing the agent.
31//!
32//! # Why a fresh presentation per call
33//!
34//! A presentation names *what may be done*, not who is doing it, and is bound
35//! to the party that signed the request it rides. Caching one across commands
36//! would mean either re-binding it (impossible without the VTA) or sending it
37//! unbound (a bearer token). It is one extra local round-trip and it removes a
38//! whole class of mistake, so every command mints its own.
39//!
40//! # What this surface deliberately does not do
41//!
42//! **Issue credentials.** Minting a VIC, VMC or VAC needs the *room's* signing
43//! key, which is the owner's, not a member's — a different party with different
44//! custody. The owner's surface is [`super::rooms_custody`], whose verbs are
45//! calls to the VTA holding that key.
46
47use serde_json::Value;
48use vta_sdk::prelude::*;
49use vtc_client::VtcClient;
50pub use vtc_client::rooms::body::RecordBasis;
51use vtc_client::rooms::body::{
52    RecordLocation, SealedRecordBody, VerifiedRecordBody, verify_record_body,
53};
54use vtc_client::rooms::{CleartextContent, RoomSession, SealedContent, Visibility};
55use vti_specs::vault::credentials::get::v0_2 as vault_get;
56
57/// Everything a room command needs to reach both parties.
58///
59/// The host DID is optional because an operator may not know it, and it names the
60/// **recipient** of the document — nothing more. It used to be passed as the presentation's
61/// `audience` as well, on the reading that this bound the presentation to one host. It never
62/// could: see [`present`]. A presentation is bound to whoever will sign the request, always,
63/// so there is no unbound case for an operator to be warned about.
64pub struct RoomTarget<'a> {
65    pub host_url: &'a str,
66    pub host_did: Option<&'a str>,
67    pub room_id: &'a str,
68}
69
70impl RoomTarget<'_> {
71    fn client(&self) -> VtcClient {
72        // The room surface carries no token — a room operation is authorized by
73        // the presentation, never by a session with the host — so an anonymous
74        // client is the correct one even when the host is a VTC the operator
75        // also has an account on.
76        VtcClient::anonymous(self.host_url, self.host_did.unwrap_or("did:key:zHost"))
77    }
78}
79
80/// Mint a presentation for one action.
81///
82/// # There is no party to name here, and there used to be two
83///
84/// This passed `target.host_did` as the presentation's `audience` until that was found
85/// never to work: `audience` named the party that had to *present* the credential, not the
86/// one it was addressed to, so filling it with a host's DID named somebody no presenter can
87/// ever be, and every request was refused. Omitting `--host-did` "worked" only because an
88/// absent audience skipped the check — so the flag's two states were *broken* and
89/// *unprotected*, with nothing in between.
90///
91/// Neither the field nor this parameter survives. A presentation is granted to the caller
92/// the VTA authenticated and a host refuses a chain granting to anyone else, so who may
93/// present is established rather than declared; and it is bound to a *room* rather than a
94/// host, so there is no destination to name either. `--host-did` keeps its real job:
95/// naming the document's `recipient`.
96///
97/// See `rooms/keys/present/0.2`, which removed both members, and
98/// trustoverip/dtgwg-trust-tasks-tf#414.
99async fn present(
100    client: &VtaClient,
101    target: &RoomTarget<'_>,
102    action: &str,
103) -> Result<RoomSession, Box<dyn std::error::Error>> {
104    let minted = client.room_present(target.room_id, action).await?;
105    session_from_minted(target.room_id, &minted)
106}
107
108/// Rebuild the session from what the VTA minted.
109///
110/// The VTA answers with the presentation the host expects, so it is read back
111/// rather than reassembled from parts — a second assembly is a second chance to
112/// get the chain order wrong, and the order is load-bearing (leaf first).
113///
114/// Every missing member is an error rather than a default. A presentation with
115/// no chain is not an empty presentation; it is a reply this client does not
116/// understand, and proceeding would send the host something it will refuse for
117/// reasons that read as a credential problem.
118pub fn session_from_minted(
119    room_id: &str,
120    minted: &Value,
121) -> Result<RoomSession, Box<dyn std::error::Error>> {
122    let presentation = minted
123        .get("presentation")
124        .ok_or_else(|| format!("the VTA's reply carried no presentation: {minted}"))?;
125    let membership = presentation
126        .get("membership")
127        .and_then(Value::as_str)
128        .ok_or("the minted presentation carried no membership credential")?;
129    let authority_values = presentation
130        .get("authority")
131        .and_then(Value::as_array)
132        .ok_or("the minted presentation carried no authority chain")?;
133    let authority: Vec<String> = authority_values
134        .iter()
135        .filter_map(|v| v.as_str().map(str::to_string))
136        .collect();
137    // A chain that lost links to a type mismatch is not a shorter chain — it is
138    // a different grant, and a shorter one always confers less than the VTA
139    // minted. Refuse rather than present it.
140    if authority.len() != authority_values.len() {
141        return Err("the minted authority chain contained a non-string link".into());
142    }
143
144    let mut session = RoomSession::new(room_id, membership, authority)?;
145    if let Some(binding) = presentation.get("subjectBinding").and_then(Value::as_str) {
146        session = session.with_subject_binding(binding);
147    }
148    Ok(session)
149}
150
151/// Send one room request to the host **as the member**: built here without presentation
152/// or proof, completed by the VTA with `rooms/keys/act/0.10`, sent unchanged. Returns the
153/// member the VTA acted as and the host's response payload.
154///
155/// Nothing this client holds goes to the host: not its DID, not its key.
156async fn act_and_send(
157    client: &VtaClient,
158    target: &RoomTarget<'_>,
159    type_uri: &str,
160    payload: Value,
161) -> Result<(String, Value), Box<dyn std::error::Error>> {
162    act_and_send_as(client, target, type_uri, payload, None).await
163}
164
165/// [`act_and_send`], naming the member to act as — required for a document that names
166/// no room (a download chunk, a direct upload's commit or abort), whose authority is
167/// the transfer the member's own request opened.
168async fn act_and_send_as(
169    client: &VtaClient,
170    target: &RoomTarget<'_>,
171    type_uri: &str,
172    payload: Value,
173    issuer: Option<&str>,
174) -> Result<(String, Value), Box<dyn std::error::Error>> {
175    let host_did = target.host_did.unwrap_or("did:key:zHost");
176    let document = vta_sdk::client::unsigned_room_request(type_uri, host_did, issuer, payload)?;
177    let completed = client.room_act(&document).await.map_err(|e| {
178        let text = e.to_string();
179        let hint = if text.contains("principalAmbiguous") {
180            "\n\nYou may act as more than one member of this room: act from the context of \
181             the one you mean."
182        } else if text.contains("notAMember") {
183            "\n\nYour VTA holds no credential this room issued to a persona you may act as. \
184             The owner issues them with `pnm rooms owner issue-membership` and \
185             `issue-authority --store`."
186        } else if text.contains("actionNotHeld") {
187            "\n\nYour own credentials for this room do not confer that action."
188        } else {
189            ""
190        };
191        format!("{e}{hint}")
192    })?;
193    let principal = completed.principal.to_string();
194    let document = serde_json::to_value(&completed.document)?;
195    let reply = target.client().send_signed_room_document(&document).await?;
196    Ok((principal, reply))
197}
198
199/// [`act_and_send`], with the response parsed into its wire type.
200pub(crate) async fn act_and_send_parsed<T: serde::de::DeserializeOwned>(
201    client: &VtaClient,
202    target: &RoomTarget<'_>,
203    type_uri: &str,
204    payload: Value,
205) -> Result<T, Box<dyn std::error::Error>> {
206    let (_, reply) = act_and_send(client, target, type_uri, payload).await?;
207    parse_reply(type_uri, reply)
208}
209
210/// Parse a host's response payload into its wire type, naming the task when it does not fit.
211fn parse_reply<T: serde::de::DeserializeOwned>(
212    what: &str,
213    value: Value,
214) -> Result<T, Box<dyn std::error::Error>> {
215    serde_json::from_value(value)
216        .map_err(|e| format!("the host's {what} response is not one this client reads: {e}").into())
217}
218
219/// Resolve `--pin` / `--unpin` into the wire value.
220///
221/// Three states from two flags: pinned, unpinned, and **unchanged**. Absence has
222/// to stay distinguishable from `false`, or a curation meaning to change only
223/// the status would silently unpin the record on its way past.
224pub fn pinned_from_flags(pin: bool, unpin: bool) -> Option<bool> {
225    if pin {
226        Some(true)
227    } else if unpin {
228        Some(false)
229    } else {
230        None
231    }
232}
233
234/// The signer for the room-host leg: the operator's own DID and key.
235///
236/// A room request is signed by the party the presentation was minted *for*, and
237/// the VTA minted it for the DID the CLI authenticates as. Signing with any
238/// other key produces a presentation bound to somebody else, which the host
239/// refuses — correctly, and confusingly, so the two are taken from one place.
240pub struct RoomSigner<'a> {
241    pub did: &'a str,
242    pub key_multibase: &'a str,
243}
244
245/// `rooms list` — the room's records, metadata only, read as the member.
246pub async fn cmd_rooms_list(
247    client: &VtaClient,
248    target: RoomTarget<'_>,
249    prefix: Option<&str>,
250    since_version: Option<u64>,
251    limit: Option<usize>,
252) -> Result<(), Box<dyn std::error::Error>> {
253    // Read the listing to its END, then let `limit` decide how much to PRINT.
254    // A host pages, and absence of the cursor is the only end-of-listing signal
255    // — taking the first page would print "3 record(s)" over a room holding
256    // three hundred, which is the failure this whole member exists to prevent.
257    let mut listing = list_page(client, &target, prefix, since_version, None).await?;
258    let mut cursor = listing.cursor.clone();
259    while let Some(c) = cursor {
260        let next = list_page(client, &target, prefix, since_version, Some(&c)).await?;
261        cursor = next.cursor.clone();
262        listing.records.extend(next.records);
263    }
264
265    if crate::render::is_json_output() {
266        crate::render::print_json(&listing.records)?;
267        return Ok(());
268    }
269    if listing.records.is_empty() {
270        println!(
271            "No records{}.",
272            prefix.map(|p| format!(" under `{p}`")).unwrap_or_default()
273        );
274        return Ok(());
275    }
276
277    let shown = limit.unwrap_or(usize::MAX).min(listing.records.len());
278    if shown < listing.records.len() {
279        println!(
280            "{} record(s), showing {shown} — pass a larger --limit for the rest:",
281            listing.records.len()
282        );
283    } else {
284        println!("{} record(s):", listing.records.len());
285    }
286    for r in listing.records.iter().take(shown) {
287        let key = r.get("key").and_then(Value::as_str).unwrap_or("?");
288        let version = r.get("version").and_then(Value::as_u64).unwrap_or(0);
289        let status = r.get("status").and_then(Value::as_str).unwrap_or("active");
290        // On a sealed tier there is no title to show — that is the tier working,
291        // not a gap, so say so rather than printing an empty column.
292        let title = r
293            .get("title")
294            .or_else(|| r.get("cleartext").and_then(|c| c.get("title")))
295            .and_then(Value::as_str)
296            .map(inert)
297            .unwrap_or_else(|| "(sealed)".to_string());
298        println!("  {key}  v{version}  {status:<10} {title}");
299    }
300    Ok(())
301}
302
303/// One page of `rooms/records/list`, as the member.
304async fn list_page(
305    client: &VtaClient,
306    target: &RoomTarget<'_>,
307    prefix: Option<&str>,
308    since_version: Option<u64>,
309    cursor: Option<&str>,
310) -> Result<vti_rooms::wire::ListRecordsResponse, Box<dyn std::error::Error>> {
311    let mut payload = serde_json::json!({ "roomId": target.room_id });
312    if let Some(p) = prefix {
313        payload["prefix"] = serde_json::json!(p);
314    }
315    if let Some(v) = since_version {
316        payload["sinceVersion"] = serde_json::json!(v);
317    }
318    if let Some(c) = cursor {
319        payload["cursor"] = serde_json::json!(c);
320    }
321    let (_, mut reply) = act_and_send(
322        client,
323        target,
324        vti_rooms::wire::ROOMS_RECORDS_LIST_V5_TYPE,
325        payload,
326    )
327    .await?;
328    // List 0.5 names the continuation token `nextCursor`; the listing type reads `cursor`.
329    if let Some(obj) = reply.as_object_mut()
330        && let Some(next) = obj.remove("nextCursor")
331    {
332        obj.insert("cursor".into(), next);
333    }
334    parse_reply("rooms/records/list", reply)
335}
336
337/// `rooms get` — one record, read as the member and opened through the VTA when it is
338/// sealed.
339///
340/// A sealed record is shown only after its body checks out: the author's signature, the
341/// location it was signed for, and on `attributed` that the body's signer is the author the
342/// host recorded ([`verify_record_body`]) — the persona, for a record a member's own client
343/// wrote. One that fails is refused, never shown with a warning.
344pub async fn cmd_rooms_get(
345    client: &VtaClient,
346    target: RoomTarget<'_>,
347    key: &str,
348) -> Result<(), Box<dyn std::error::Error>> {
349    let (_, record) = act_and_send(
350        client,
351        &target,
352        vti_rooms::wire::ROOMS_RECORDS_GET_V2_TYPE,
353        serde_json::json!({ "roomId": target.room_id, "key": key }),
354    )
355    .await?;
356
357    // An `open`-tier record arrives readable. A sealed one arrives as
358    // ciphertext this process cannot decrypt, and must not try to: the group
359    // key is the VTA's.
360    if let Some(cleartext) = record.get("cleartext") {
361        if crate::render::is_json_output() {
362            crate::render::print_json(cleartext)?;
363        } else {
364            if let Some(title) = cleartext.get("title").and_then(Value::as_str) {
365                println!("{}\n", inert(title));
366            }
367            println!(
368                "{}",
369                inert(cleartext.get("body").and_then(Value::as_str).unwrap_or(""))
370            );
371            // On this tier provenance is the writer's word as the host stored it, unsigned.
372            if let Some(obj) = cleartext.as_object()
373                && vti_rooms::provenance::carries_any(obj)
374            {
375                println!("\n{} (unsigned on an open room)", provenance_line(obj));
376            }
377        }
378        return Ok(());
379    }
380
381    let sealed: SealedContent =
382        serde_json::from_value(record.get("sealed").cloned().ok_or(
383            "the record carried neither cleartext nor sealed content; it may be retracted",
384        )?)
385        .map_err(|e| format!("the host's sealed record is malformed: {e}"))?;
386    let version = record
387        .get("version")
388        .and_then(Value::as_u64)
389        .ok_or("the host's record carried no version")?;
390    let author = record.get("author").and_then(Value::as_str);
391
392    let body = open_and_verify(client, &target, key, version, &sealed, author).await?;
393    let body = body.body();
394    if crate::render::is_json_output() {
395        crate::render::print_json(body)?;
396        return Ok(());
397    }
398    if let Some(title) = &body.title {
399        println!("{}\n", inert(title));
400    }
401    if let Some(text) = &body.body {
402        println!("{}", inert(text));
403    }
404    if let Some(file) = &body.file {
405        println!(
406            "\nFile: {} ({} bytes). Fetch it with `pnm rooms file get {key}`.",
407            inert(&file.name),
408            file.size
409        );
410    }
411    println!("\nWritten by {} at version {version}.", body.author);
412    if let Ok(Value::Object(obj)) = serde_json::to_value(body) {
413        println!("{}", provenance_line(&obj));
414    }
415    Ok(())
416}
417
418/// `rooms put` — write a record, as the member.
419///
420/// On an `open` room the record goes in the clear. On `attributed` and `private` it is a
421/// [`SealedRecordBody`] naming the member as author, which the VTA signs **as the member**
422/// and seals (`rooms/keys/seal/0.2`, `signAsPrincipal`), so the host never sees it and
423/// every reader can check that the member wrote it.
424pub async fn cmd_rooms_put(
425    client: &VtaClient,
426    target: RoomTarget<'_>,
427    key: &str,
428    title: Option<String>,
429    body: String,
430    expected_version: Option<u64>,
431    provenance: RecordProvenance,
432) -> Result<(), Box<dyn std::error::Error>> {
433    let (sealed_tier, member) = room_tier(client, &target).await?;
434
435    if !sealed_tier {
436        if provenance.is_stated() {
437            return Err(open_tier_provenance_refused(&target).into());
438        }
439        let mut payload = serde_json::json!({
440            "roomId": target.room_id,
441            "key": key,
442            "cleartext": CleartextContent { title, body, ..Default::default() },
443        });
444        if let Some(v) = expected_version {
445            payload["expectedVersion"] = serde_json::json!(v);
446        }
447        let (_, reply) = act_and_send(
448            client,
449            &target,
450            vti_rooms::wire::ROOMS_RECORDS_PUT_V3_TYPE,
451            payload,
452        )
453        .await?;
454        let put: vti_rooms::wire::PutRecordResponse = parse_reply("rooms/records/put", reply)?;
455        println!("Wrote {} at version {} as {member}", put.key, put.version);
456        return Ok(());
457    }
458
459    let mut content = SealedRecordBody::new(
460        RecordLocation {
461            room_id: target.room_id,
462            key,
463            version: 1,
464        },
465        &member,
466    );
467    content.title = title;
468    content.body = Some(body);
469    provenance.apply(&mut content)?;
470    let version = write_sealed(client, &target, content, expected_version).await?;
471    println!("Wrote {key} at version {version}, sealed and signed by {member}");
472    Ok(())
473}
474
475/// A record's provenance and expiry as `rooms put` takes them (`rooms/_shared/0.1`
476/// `SealedRecordBody`, *Provenance* and *Expiry*; trust-tasks-tf #865).
477///
478/// Display and weighting for whoever reads the record, never authority. `basis` `None`
479/// leaves it unstated; `pnm` passes `declared` when the operator names none, which is what
480/// the specification asks of a client relaying what its member typed.
481#[derive(Debug, Clone, Default)]
482pub struct RecordProvenance {
483    /// What kind of claim the record is.
484    pub basis: Option<vtc_client::rooms::body::RecordBasis>,
485    /// Whether `basis` was named by the operator rather than defaulted.
486    pub basis_named: bool,
487    /// The author's own estimate, 0 to 1.
488    pub confidence: Option<f64>,
489    /// Records of this room cited in support: `KEY`, or `KEY@VERSION` to pin what it said then.
490    pub cite_records: Vec<String>,
491    /// Credentials cited in support, by `id` — never fetched.
492    pub cite_credentials: Vec<String>,
493    /// Purposes the record may be used for, each an absolute IRI.
494    pub purposes: Vec<String>,
495    /// The permitted use in words.
496    pub use_note: Option<String>,
497    /// When the author stops standing behind the record, RFC 3339.
498    pub valid_until: Option<String>,
499}
500
501impl RecordProvenance {
502    /// Whether the operator stated anything — a defaulted `basis` is not a statement.
503    pub fn is_stated(&self) -> bool {
504        self.basis_named
505            || self.confidence.is_some()
506            || !self.cite_records.is_empty()
507            || !self.cite_credentials.is_empty()
508            || !self.purposes.is_empty()
509            || self.use_note.is_some()
510            || self.valid_until.is_some()
511    }
512
513    /// Put these members on `body`, checked against the specification's types before the
514    /// VTA is asked to sign anything.
515    pub fn apply(&self, body: &mut SealedRecordBody) -> Result<(), String> {
516        body.basis = self.basis;
517        body.confidence = self.confidence.map(|c| serde_json::json!(c));
518        let mut evidence = Vec::new();
519        for cite in &self.cite_records {
520            let (key, version) = match cite.rsplit_once('@') {
521                Some((k, v)) => (
522                    k,
523                    Some(v.parse::<u64>().map_err(|_| {
524                        format!("--cite-record {cite}: the version after `@` is not a number")
525                    })?),
526                ),
527                None => (cite.as_str(), None),
528            };
529            let mut e = serde_json::json!({ "kind": "record", "key": key });
530            if let Some(v) = version {
531                e["version"] = serde_json::json!(v);
532            }
533            evidence.push(e);
534        }
535        for id in &self.cite_credentials {
536            evidence.push(serde_json::json!({ "kind": "credential", "credentialId": id }));
537        }
538        body.evidence = (!evidence.is_empty()).then_some(Value::Array(evidence));
539        let mut valid_for = serde_json::Map::new();
540        if !self.purposes.is_empty() {
541            valid_for.insert("purposes".into(), serde_json::json!(self.purposes));
542        }
543        if let Some(note) = &self.use_note {
544            valid_for.insert("note".into(), serde_json::json!(note));
545        }
546        body.valid_for = (!valid_for.is_empty()).then_some(Value::Object(valid_for));
547        body.valid_until = self.valid_until.clone();
548        if let Ok(Value::Object(obj)) = serde_json::to_value(&*body) {
549            vti_rooms::provenance::check(&obj)?;
550        }
551        Ok(())
552    }
553}
554
555/// Why `rooms put` will not state provenance on an `open` room, and what to do instead.
556///
557/// An open room carries provenance in `rooms/records/put/0.3`'s cleartext. `rooms/keys/act/0.10`
558/// completes put 0.3, which this client now sends, but it does not yet put the provenance
559/// members into the cleartext; until it does, it refuses rather than drop them.
560fn open_tier_provenance_refused(target: &RoomTarget<'_>) -> String {
561    format!(
562        "room {} is `open`: a record's provenance there rides `rooms/records/put/0.3`'s \
563         cleartext, which pnm does not write yet. Write it without --basis, --confidence, --cite-record, --cite-credential, --purpose, \
564         --use-note and --valid-until, or in a sealed (`attributed` or `private`) room, where \
565         they travel inside the signed body.",
566        target.room_id
567    )
568}
569
570/// What a record says about the kind of claim it is, and whether it has lapsed — shown
571/// beside its author (`SealedRecordBody`, *Provenance*: a reader shows `basis`, or says none
572/// was stated). Never authority.
573fn provenance_line(obj: &serde_json::Map<String, Value>) -> String {
574    let mut line = match obj.get("basis").and_then(Value::as_str) {
575        Some(b) => format!("Basis: {}", inert(b)),
576        None => "Basis: not stated".to_string(),
577    };
578    if let Some(c) = obj.get("confidence").and_then(Value::as_f64) {
579        line.push_str(&format!(" (author's confidence {c})"));
580    }
581    if obj.get("basis").and_then(Value::as_str) == Some("verified") && !obj.contains_key("evidence")
582    {
583        line.push_str(" — names no evidence, so the claim to have checked is unsupported");
584    }
585    if let Some(n) = obj.get("evidence").and_then(Value::as_array).map(Vec::len) {
586        line.push_str(&format!("; cites {n} item(s), not fetched"));
587    }
588    if let Some(use_for) = obj.get("validFor") {
589        let purposes = use_for
590            .get("purposes")
591            .and_then(Value::as_array)
592            .map(|p| {
593                p.iter()
594                    .filter_map(Value::as_str)
595                    .map(inert)
596                    .collect::<Vec<_>>()
597                    .join(", ")
598            })
599            .unwrap_or_default();
600        let note = use_for
601            .get("note")
602            .and_then(Value::as_str)
603            .map(|n| format!(" \"{}\"", inert(n)))
604            .unwrap_or_default();
605        line.push_str(&format!("\nFor use: {purposes}{note}"));
606    }
607    if let Some(until) = obj.get("validUntil").and_then(Value::as_str) {
608        if vti_rooms::provenance::is_expired(obj, chrono::Utc::now()) {
609            line.push_str(&format!(
610                "\nEXPIRED: its author stopped standing behind it at {}.",
611                inert(until)
612            ));
613        } else {
614            line.push_str(&format!("\nValid until {}.", inert(until)));
615        }
616    }
617    line
618}
619
620/// `rooms curate` — change a record's standing.
621pub async fn cmd_rooms_curate(
622    client: &VtaClient,
623    target: RoomTarget<'_>,
624    key: &str,
625    status: Option<String>,
626    pinned: Option<bool>,
627    reason: Option<String>,
628) -> Result<(), Box<dyn std::error::Error>> {
629    if status.is_none() && pinned.is_none() {
630        return Err("nothing to change — pass --status and/or --pin/--unpin".into());
631    }
632    // `curate` is its own grant, deliberately not implied by `write`: deciding
633    // what a room's shared knowledge is worth is a different act from adding to
634    // it. So the request is completed for `curate`, and a member who only
635    // writes is refused by their own VTA rather than at the host.
636    let mut payload = serde_json::json!({ "roomId": target.room_id, "key": key });
637    if let Some(s) = status {
638        payload["status"] = serde_json::json!(s);
639    }
640    if let Some(p) = pinned {
641        payload["pinned"] = serde_json::json!(p);
642    }
643    if let Some(r) = reason {
644        payload["reason"] = serde_json::json!(r);
645    }
646    let (_, reply) = act_and_send(
647        client,
648        &target,
649        vti_rooms::wire::ROOMS_RECORDS_CURATE_TYPE,
650        payload,
651    )
652    .await?;
653    let out: vti_rooms::wire::CurateRecordResponse = parse_reply("rooms/records/curate", reply)?;
654    println!(
655        "{} is now {} at version {}{}",
656        out.key,
657        serde_json::to_value(out.status)
658            .ok()
659            .and_then(|v| v.as_str().map(str::to_string))
660            .unwrap_or_else(|| "?".into()),
661        out.version,
662        if out.pinned { " (pinned)" } else { "" }
663    );
664    Ok(())
665}
666
667/// `rooms renew` — mint the next epoch, which is what keeps an **open** room live.
668///
669/// Needs `admin`. It is the same act as ordinary use, and it is the whole
670/// defence against a hostile succession claim: an owner who renews is
671/// structurally safe without thinking about it.
672///
673/// Refuses an `attributed` or `private` room ([`refuse_sealed_renew`]): there the room's
674/// epoch is its MLS group's plus one, and a mint without a commit moves the host and no
675/// member — every write is then refused as sealed under a superseded epoch.
676pub async fn cmd_rooms_renew(
677    client: &VtaClient,
678    target: RoomTarget<'_>,
679    signer: RoomSigner<'_>,
680    epoch: u32,
681    reason: Option<String>,
682) -> Result<(), Box<dyn std::error::Error>> {
683    // The tier first, under `read`, before anything is minted: rooms/keys/act/0.2
684    // producer item 4 — a client never asks to mint a sealed room's epoch.
685    let read = present(client, &target, "read").await?;
686    let info = target
687        .client()
688        .room_info(&read, signer.did, signer.key_multibase)
689        .await
690        .map_err(|e| format!("could not read the room's tier (rooms/info): {e}"))?;
691    refuse_sealed_renew(&wire_str(&info.visibility), &target)?;
692
693    let session = present(client, &target, "admin").await?;
694    let minted = target
695        .client()
696        .mint_epoch(
697            &session,
698            epoch,
699            reason.as_deref(),
700            signer.did,
701            signer.key_multibase,
702        )
703        .await?;
704    println!("Room {} is at epoch {}", minted.room_id, minted.epoch);
705    Ok(())
706}
707
708/// `pnm rooms renew` is an open room's renewal only. A sealed room (`attributed` or
709/// `private`) renews through the owner's key holder, which advances the group with a commit
710/// and the host with it (`rooms/owner/group/renew/0.2`); a bare mint would brick the room
711/// for writing while looking like success.
712fn refuse_sealed_renew(visibility: &str, target: &RoomTarget<'_>) -> Result<(), String> {
713    if visibility == "open" {
714        return Ok(());
715    }
716    Err(format!(
717        "room {room} is `{visibility}`: its epoch advances only with a commit of its group, \
718         which this client cannot make, and a bare mint would leave every member unable to \
719         write. Renew it from the owner's VTA instead:\n\n  pnm rooms owner group-renew \
720         --room {room} --host-did {host_did}",
721        room = target.room_id,
722        host_did = target.host_did.unwrap_or("<the host's DID>"),
723    ))
724}
725
726/// `rooms prune` — drop the room's epoch key chain below an epoch (`rooms/epoch/prune/0.2`).
727///
728/// Needs `admin`, and moves the way `renew` does: the VTA presents for this client
729/// (`rooms/keys/present`) and this client signs. Not through `rooms/keys/act`, whose every
730/// version leaves the prune out on purpose — destroying a room's ability to read its past
731/// "warrants a task of its own that states its consequences" rather than riding a
732/// signature asked for one operation at a time.
733///
734/// The specification asks a surface offering this to show what becomes unreadable and not
735/// to offer it as an incidental control, so this reads the room first (its current epoch
736/// and retention policy, under `read`), prints [`prune_warning`], and goes ahead only on
737/// `--yes` or a typed `y` at a terminal. Without a terminal and without `--yes` it refuses:
738/// a pipe is not consent.
739pub async fn cmd_rooms_prune(
740    client: &VtaClient,
741    target: RoomTarget<'_>,
742    signer: RoomSigner<'_>,
743    before_epoch: u32,
744    reason: String,
745    assume_yes: bool,
746) -> Result<(), Box<dyn std::error::Error>> {
747    let read = present(client, &target, "read").await?;
748    let info = target
749        .client()
750        .room_info(&read, signer.did, signer.key_multibase)
751        .await
752        .map_err(|e| format!("could not read the room's epoch (rooms/info): {e}"))?;
753    let current = u32::try_from(info.epoch.get()).unwrap_or(u32::MAX);
754    if before_epoch >= current {
755        return Err(not_ahead_message(&target, before_epoch, current).into());
756    }
757    let policy = info.retention_policy.as_ref().map(wire_str);
758
759    eprintln!(
760        "{}",
761        prune_warning(target.room_id, before_epoch, current, policy.as_deref())
762    );
763    match prune_guard(
764        assume_yes,
765        std::io::IsTerminal::is_terminal(&std::io::stdin()),
766    ) {
767        PruneGuard::Proceed => {}
768        PruneGuard::Refuse => {
769            return Err(format!(
770                "refusing to prune without confirmation: there is no terminal to ask. If you \
771                 mean it, run it again with --yes:\n\n  {}",
772                prune_command(&target, before_epoch, &reason)
773            )
774            .into());
775        }
776        PruneGuard::Ask => {
777            if !super::contexts::confirm_destructive("Prune the chain?")? {
778                println!("Not pruned.");
779                return Ok(());
780            }
781        }
782    }
783
784    // `rooms/epoch/prune/0.2`: the host keeps the room's epoch floor and says so, and
785    // members' key holders forget what they hold below it.
786    let session = present(client, &target, "admin").await?;
787    let pruned = target
788        .client()
789        .prune_epoch_v2(
790            &session,
791            before_epoch,
792            Some(&reason),
793            signer.did,
794            signer.key_multibase,
795        )
796        .await
797        .map_err(|e| prune_refused(e, &target, before_epoch, current))?;
798    let floor = pruned.epoch_floor.0.get();
799    let outcome = vtc_client::rooms::PruneResponse {
800        room_id: pruned.room_id.to_string(),
801        pruned: u32::try_from(pruned.pruned).unwrap_or(u32::MAX),
802        earliest_rung: u32::try_from(pruned.earliest_rung.get()).unwrap_or(u32::MAX),
803    };
804    println!(
805        "{} The room's epoch floor is now {floor}: members' VTAs that learn it forget \
806         what they hold below it.",
807        prune_outcome(&outcome)
808    );
809    Ok(())
810}
811
812/// What `prune` does about confirmation: `--yes` always proceeds; without it a terminal is
813/// asked and anything else is refused.
814#[derive(Debug, PartialEq, Eq)]
815enum PruneGuard {
816    Proceed,
817    Ask,
818    Refuse,
819}
820
821fn prune_guard(assume_yes: bool, interactive: bool) -> PruneGuard {
822    match (assume_yes, interactive) {
823        (true, _) => PruneGuard::Proceed,
824        (false, true) => PruneGuard::Ask,
825        (false, false) => PruneGuard::Refuse,
826    }
827}
828
829/// The irreversibility warning (rooms/epoch/prune/0.2 *Consent/purpose*: show what will
830/// become unreadable), in the specification's own terms — the rungs go, not the records.
831fn prune_warning(room_id: &str, before_epoch: u32, current: u32, policy: Option<&str>) -> String {
832    // The off-by-one `rooms/epoch/prune/0.2` states: rung `N = beforeEpoch` survives and
833    // still yields epoch `N − 1`, so the floor is `N − 1` and what becomes unreadable is
834    // everything *below* it.
835    let floor = before_epoch.saturating_sub(1).max(1);
836    let span = if floor <= 1 {
837        "no epoch: rung 2 survives and still yields epoch 1, so nothing becomes unreadable"
838            .to_string()
839    } else if floor == 2 {
840        "epoch 1".to_string()
841    } else {
842        format!("epochs 1 to {}", floor - 1)
843    };
844    let policy_line = match policy {
845        Some("fromJoin") => {
846            "\n  This room's retention policy is `fromJoin`: it keeps no chain, so there may be \
847             nothing to drop."
848        }
849        _ => "",
850    };
851    format!(
852        "WARNING: this cannot be undone.\n\n\
853         Pruning room {room_id} (now at epoch {current}) below epoch {before_epoch} makes \
854         {span} unreadable for good:\n\
855         \n  - The host erases the epoch key chain below epoch {before_epoch}. Nobody keeps a \
856         copy that can bring it back.\n\
857         \n  - The records stay, and the host still serves them. What goes is the ability to \
858         derive the keys they were sealed under: a member who restarts, moves to a new \
859         device, or joins later can never open them again, whatever authority they hold.\n\
860         \n  - It reaches members, as far as they conform: the host keeps the room's epoch \
861         floor ({floor}), and members' VTAs that learn it forget the rungs and keys they \
862         hold below it and never take them back. It is not a recall: anything already \
863         read, decrypted or exported stays where it is, and a VTA that does not conform \
864         keeps what it held.{policy_line}\n\
865         \n  Pruning straight after a membership change tells the host why the change \
866         happened; wait if that matters."
867    )
868}
869
870/// `notAhead`, said before anything is sent, with the floor that would work.
871fn not_ahead_message(target: &RoomTarget<'_>, before_epoch: u32, current: u32) -> String {
872    let mut msg = format!(
873        "--before-epoch {before_epoch} is at or above room {}'s current epoch {current}: \
874         pruning there would drop the whole chain, not shorten it.",
875        target.room_id
876    );
877    if current > 1 {
878        msg.push_str(&format!(
879            " The highest floor that keeps the current epoch readable is {}:\n\n  {}",
880            current - 1,
881            prune_command(target, current - 1, "<why>")
882        ));
883    }
884    msg
885}
886
887/// The corrected command, verbatim, for an operator to run.
888fn prune_command(target: &RoomTarget<'_>, before_epoch: u32, reason: &str) -> String {
889    let host_did = target
890        .host_did
891        .map(|d| format!(" --host-did {d}"))
892        .unwrap_or_default();
893    format!(
894        "pnm rooms prune --room {} --host {}{host_did} --before-epoch {before_epoch} \
895         --reason {} --yes",
896        target.room_id,
897        target.host_url,
898        shell_word(reason)
899    )
900}
901
902/// A host's refusal, with the fix where the code names one.
903fn prune_refused(
904    e: vtc_client::VtcError,
905    target: &RoomTarget<'_>,
906    before_epoch: u32,
907    current: u32,
908) -> String {
909    let code = vtc_client::git_ns::task_error(&e).map(|(code, _)| code);
910    match code.as_deref() {
911        Some(c) if c.ends_with("notAhead") => {
912            // The room advanced between the read and the prune.
913            format!(
914                "{e}\n\n{}",
915                not_ahead_message(target, before_epoch, current)
916            )
917        }
918        Some(c) if c.ends_with("permissionDenied") => format!(
919            "{e}\n\nPruning needs `admin` in the room — not `curate`, which every writer holds. \
920             The room's owner grants it with `pnm rooms owner issue-authority --action admin \
921             --store`."
922        ),
923        _ => e.to_string(),
924    }
925}
926
927/// The host's answer, read the way the specification says to: `pruned: 0` is a success,
928/// and `earliestRung` is how far back the chain reaches, not the floor asked for.
929fn prune_outcome(r: &vtc_client::rooms::PruneResponse) -> String {
930    let dropped = match r.pruned {
931        0 => "nothing to drop — the chain already reached back no further".to_string(),
932        1 => "1 rung dropped".to_string(),
933        n => format!("{n} rungs dropped"),
934    };
935    format!(
936        "Pruned room {}: {dropped}. Its chain now reaches back to epoch {}.",
937        r.room_id, r.earliest_rung
938    )
939}
940
941/// `rooms records prune` — remove the records the room's record retention makes eligible
942/// (`rooms/records/prune/0.2`, trust-tasks-tf #868).
943///
944/// Needs `admin` and moves the way `prune` does: the VTA presents for this client and this
945/// client signs (`rooms/keys/act` completes neither task). The specification asks a surface
946/// to show a dry run first and not to offer a prune beside reversible controls, so this
947/// always asks the host what would go, prints it with what a prune does and does not do, and
948/// goes ahead only on `--yes` or a typed `y` at a terminal.
949#[allow(clippy::too_many_arguments)]
950pub async fn cmd_rooms_records_prune(
951    client: &VtaClient,
952    target: RoomTarget<'_>,
953    signer: RoomSigner<'_>,
954    kinds: Vec<String>,
955    reason: String,
956    dry_run: bool,
957    assume_yes: bool,
958) -> Result<(), Box<dyn std::error::Error>> {
959    let kinds = if kinds.is_empty() {
960        vec!["retracted".to_string(), "aged".to_string()]
961    } else {
962        kinds
963    };
964    if let Some(bad) = kinds.iter().find(|k| *k != "retracted" && *k != "aged") {
965        return Err(format!("--kind {bad}: a kind is `retracted` or `aged`").into());
966    }
967    let kinds: Vec<&str> = kinds.iter().map(String::as_str).collect();
968    let session = present(client, &target, "admin").await?;
969    let host = target.client();
970    let preview = host
971        .prune_records(
972            &session,
973            &kinds,
974            &reason,
975            true,
976            signer.did,
977            signer.key_multibase,
978        )
979        .await
980        .map_err(|e| records_prune_refused(e, &target))?;
981    let preview = serde_json::to_value(&preview)?;
982    eprintln!("{}", records_prune_preview(target.room_id, &preview));
983    if dry_run {
984        if crate::render::is_json_output() {
985            crate::render::print_json(&preview)?;
986        }
987        return Ok(());
988    }
989    if preview["removed"].as_u64() == Some(0) {
990        println!("Nothing is eligible now; nothing pruned.");
991        return Ok(());
992    }
993    match prune_guard(
994        assume_yes,
995        std::io::IsTerminal::is_terminal(&std::io::stdin()),
996    ) {
997        PruneGuard::Proceed => {}
998        PruneGuard::Refuse => {
999            return Err(format!(
1000                "refusing to prune without confirmation: there is no terminal to ask. If you \
1001                 mean it, run it again with --yes:\n\n  {}",
1002                records_prune_command(&target, &kinds, &reason)
1003            )
1004            .into());
1005        }
1006        PruneGuard::Ask => {
1007            if !super::contexts::confirm_destructive("Remove these records for good?")? {
1008                println!("Not pruned.");
1009                return Ok(());
1010            }
1011        }
1012    }
1013    // A fresh presentation: eligibility is decided again, at the moment of the prune.
1014    let session = present(client, &target, "admin").await?;
1015    let done = host
1016        .prune_records(
1017            &session,
1018            &kinds,
1019            &reason,
1020            false,
1021            signer.did,
1022            signer.key_multibase,
1023        )
1024        .await
1025        .map_err(|e| records_prune_refused(e, &target))?;
1026    let done = serde_json::to_value(&done)?;
1027    if crate::render::is_json_output() {
1028        crate::render::print_json(&done)?;
1029        return Ok(());
1030    }
1031    let removed = done["removed"].as_u64().unwrap_or(0);
1032    match done.get("prune") {
1033        Some(mark) => println!(
1034            "Pruned room {}: {removed} record(s) removed at version {}. Members syncing from \
1035             below it rebuild their copy.",
1036            target.room_id, mark["version"]
1037        ),
1038        None => println!(
1039            "Nothing was eligible by the time of the prune; nothing removed in room {}.",
1040            target.room_id
1041        ),
1042    }
1043    Ok(())
1044}
1045
1046/// What a dry run says, and what a prune is — in the specification's terms: records go
1047/// outright, tombstones included; nothing already read is recalled.
1048fn records_prune_preview(room_id: &str, preview: &Value) -> String {
1049    let removed = preview["removed"].as_u64().unwrap_or(0);
1050    let blobs = preview["blobsReleased"].as_u64().unwrap_or(0);
1051    let rule = &preview["recordRetention"];
1052    let mut rule_text = Vec::new();
1053    if let Some(d) = rule["tombstoneDays"].as_u64() {
1054        rule_text.push(format!("tombstones kept {d} days"));
1055    }
1056    if let Some(d) = rule["recordDays"].as_u64() {
1057        rule_text.push(format!("records kept {d} days unless pinned"));
1058    }
1059    format!(
1060        "Room {room_id} keeps its records by this rule: {}.\n\
1061         Under it, a prune now removes {removed} record(s){}.\n\n\
1062         WARNING: a prune cannot be undone. Removed records go outright — tombstones \
1063         included — for every member, and the room keeps only a count and a time. It removes \
1064         them from this host, not from the world: what a member already read, exported or \
1065         cached stays where it is, and a mirror is pruned separately.",
1066        rule_text.join(", "),
1067        if blobs > 0 {
1068            format!(", releasing {blobs} file(s)")
1069        } else {
1070            String::new()
1071        }
1072    )
1073}
1074
1075/// The corrected command, verbatim.
1076fn records_prune_command(target: &RoomTarget<'_>, kinds: &[&str], reason: &str) -> String {
1077    let host_did = target
1078        .host_did
1079        .map(|d| format!(" --host-did {d}"))
1080        .unwrap_or_default();
1081    let kinds: String = kinds.iter().map(|k| format!(" --kind {k}")).collect();
1082    format!(
1083        "pnm rooms records prune --room {} --host {}{host_did}{kinds} --reason {} --yes",
1084        target.room_id,
1085        target.host_url,
1086        shell_word(reason)
1087    )
1088}
1089
1090/// A host's refusal of a records prune, with the fix where the code names one.
1091fn records_prune_refused(e: vtc_client::VtcError, target: &RoomTarget<'_>) -> String {
1092    let code = vtc_client::git_ns::task_error(&e).map(|(code, _)| code);
1093    let host_did = target
1094        .host_did
1095        .map(|d| format!(" --host-did {d}"))
1096        .unwrap_or_default();
1097    match code.as_deref() {
1098        Some(c) if c.ends_with("noRetention") => format!(
1099            "{e}\n\nThe room states no record retention for what you asked, so nothing is \
1100             eligible. Set one first, for example:\n\n  pnm rooms records retention set \
1101             --room {} --host {}{host_did} --tombstone-days 30 --reason <why>",
1102            target.room_id, target.host_url
1103        ),
1104        Some(c) if c.ends_with("roomNotLive") => {
1105            format!("{e}\n\nThe room has lapsed and is read-only until its owner renews it.")
1106        }
1107        Some(c) if c.ends_with("permissionDenied") => format!(
1108            "{e}\n\nPruning records needs `admin` in the room — not `curate`, which every \
1109             writer holds. The room's owner grants it with `pnm rooms owner issue-authority \
1110             --action admin --store`."
1111        ),
1112        _ => e.to_string(),
1113    }
1114}
1115
1116/// `rooms records retention show` — the record retention in force and the latest prune
1117/// (`rooms/info/0.3`).
1118pub async fn cmd_rooms_retention_show(
1119    client: &VtaClient,
1120    target: RoomTarget<'_>,
1121    signer: RoomSigner<'_>,
1122) -> Result<(), Box<dyn std::error::Error>> {
1123    let info = read_info(client, &target, &signer).await?;
1124    if crate::render::is_json_output() {
1125        crate::render::print_json(&serde_json::json!({
1126            "recordRetention": info.get("recordRetention"),
1127            "lastPrune": info.get("lastPrune"),
1128        }))?;
1129        return Ok(());
1130    }
1131    println!("{}", retention_lines(&info));
1132    Ok(())
1133}
1134
1135/// `rooms records retention set` — replace the record retention whole, on the condition
1136/// that the one in force is the one this client just read (`expectedRetention`), so two
1137/// administrators do not overwrite each other unseen.
1138#[allow(clippy::too_many_arguments)]
1139pub async fn cmd_rooms_retention_set(
1140    client: &VtaClient,
1141    target: RoomTarget<'_>,
1142    signer: RoomSigner<'_>,
1143    tombstone_days: Option<u32>,
1144    record_days: Option<u32>,
1145    clear: bool,
1146    reason: String,
1147) -> Result<(), Box<dyn std::error::Error>> {
1148    if !clear && tombstone_days.is_none() && record_days.is_none() {
1149        return Err(
1150            "name --tombstone-days, --record-days, or --clear: the retention is \
1151                    replaced whole, so naming nothing would clear it by accident"
1152                .into(),
1153        );
1154    }
1155    let info = read_info(client, &target, &signer).await?;
1156    let expected = info
1157        .get("recordRetention")
1158        .cloned()
1159        .unwrap_or_else(|| serde_json::json!({}));
1160    let mut retention = serde_json::Map::new();
1161    if let Some(d) = tombstone_days {
1162        retention.insert("tombstoneDays".into(), serde_json::json!(d));
1163    }
1164    if let Some(d) = record_days {
1165        retention.insert("recordDays".into(), serde_json::json!(d));
1166    }
1167    let session = present(client, &target, "admin").await?;
1168    let set = target
1169        .client()
1170        .set_record_retention(
1171            &session,
1172            Value::Object(retention),
1173            Some(expected),
1174            &reason,
1175            signer.did,
1176            signer.key_multibase,
1177        )
1178        .await
1179        .map_err(|e| {
1180            let code = vtc_client::git_ns::task_error(&e).map(|(code, _)| code);
1181            match code.as_deref() {
1182                Some(c) if c.ends_with("retentionConflict") => format!(
1183                    "{e}\n\nAnother administrator changed the retention since this command \
1184                     read it. Read it again (`pnm rooms records retention show --room {} \
1185                     --host {}`) and decide again.",
1186                    target.room_id, target.host_url
1187                ),
1188                _ => e.to_string(),
1189            }
1190        })?;
1191    let set = serde_json::to_value(&set)?;
1192    if crate::render::is_json_output() {
1193        crate::render::print_json(&set)?;
1194        return Ok(());
1195    }
1196    println!(
1197        "Room {} record retention: {} (was {}). Nothing is removed until somebody prunes.",
1198        target.room_id,
1199        retention_text(&set["recordRetention"]),
1200        retention_text(&set["previous"])
1201    );
1202    Ok(())
1203}
1204
1205/// `rooms/info/0.3` as JSON, read under `read`.
1206async fn read_info(
1207    client: &VtaClient,
1208    target: &RoomTarget<'_>,
1209    signer: &RoomSigner<'_>,
1210) -> Result<Value, Box<dyn std::error::Error>> {
1211    let read = present(client, target, "read").await?;
1212    let info = target
1213        .client()
1214        .room_info(&read, signer.did, signer.key_multibase)
1215        .await
1216        .map_err(|e| format!("could not read the room (rooms/info/0.3): {e}"))?;
1217    Ok(serde_json::to_value(&info)?)
1218}
1219
1220/// A record retention in words; `{}` or absent is "none".
1221fn retention_text(rule: &Value) -> String {
1222    let mut parts = Vec::new();
1223    if let Some(d) = rule.get("tombstoneDays").and_then(Value::as_u64) {
1224        parts.push(format!("tombstones {d} days"));
1225    }
1226    if let Some(d) = rule.get("recordDays").and_then(Value::as_u64) {
1227        parts.push(format!("records {d} days (pinned excepted)"));
1228    }
1229    if parts.is_empty() {
1230        "none — no record is eligible by age".to_string()
1231    } else {
1232        parts.join(", ")
1233    }
1234}
1235
1236/// The retention and latest prune `rooms/info/0.3` reports, as lines.
1237fn retention_lines(info: &Value) -> String {
1238    let rule = info.get("recordRetention").cloned().unwrap_or(Value::Null);
1239    let mut out = format!("Record retention: {}", retention_text(&rule));
1240    match info.get("lastPrune") {
1241        Some(p) => out.push_str(&format!(
1242            "\nLast prune: {} record(s) at {}, version {}",
1243            p["removed"],
1244            p["prunedAt"].as_str().unwrap_or("?"),
1245            p["version"]
1246        )),
1247        None => out.push_str("\nLast prune: never"),
1248    }
1249    out
1250}
1251
1252/// `rooms create` — register a room with a host.
1253///
1254/// The only command here that needs no presentation: the room has issued
1255/// nothing yet, so there is no chain to present. The host checks the request's
1256/// own proof instead, which is why this must be signed as the owner it names.
1257pub async fn cmd_rooms_create(
1258    target: RoomTarget<'_>,
1259    signer: RoomSigner<'_>,
1260    visibility: &str,
1261    retention_days: Option<u32>,
1262) -> Result<(), Box<dyn std::error::Error>> {
1263    let visibility: Visibility = serde_json::from_value(Value::String(visibility.to_string()))
1264        .map_err(|_| "visibility must be one of: open, attributed, private")?;
1265
1266    target
1267        .client()
1268        .create_room(
1269            target.room_id,
1270            signer.did,
1271            visibility,
1272            retention_days,
1273            signer.did,
1274            signer.key_multibase,
1275        )
1276        .await
1277        .map_err(|e| {
1278            format!(
1279                "{e}\n\nA community host decides whose rooms it stores. If this says \
1280                 `not-a-member`, you are not a member there; if it says \
1281                 `private-tier-not-enabled`, that community has not turned the tier on."
1282            )
1283        })?;
1284    println!("Registered {} as {}", target.room_id, signer.did);
1285    println!(
1286        "  Next: the room must issue you a membership and an authority credential before \
1287         you can act in it."
1288    );
1289    Ok(())
1290}
1291
1292// ─── Files ───────────────────────────────────────────────────────────────
1293//
1294// Sealing and opening a room file locally, with one file's key from the VTA
1295// (`rooms/keys/file-key/0.2`). The bytes never pass through the VTA; what the
1296// VTA releases opens this one file and nothing else.
1297//
1298// What is written is the upload's whole input and nothing more: the sealed
1299// chunks, the blob manifest a host checks, and the file manifest that belongs
1300// inside the sealed record body. Uploading them is `rooms/blobs/upload/*` and
1301// a record put naming the blob.
1302
1303/// The blob manifest beside the chunks.
1304const BLOB_MANIFEST: &str = "blob.json";
1305/// The file manifest — the sealed record body's `file` member.
1306const FILE_MANIFEST: &str = "file.json";
1307/// The sealed chunks, in index order.
1308const CHUNKS_DIR: &str = "chunks";
1309
1310fn chunk_path(dir: &std::path::Path, index: usize) -> std::path::PathBuf {
1311    dir.join(CHUNKS_DIR).join(format!("{index:06}.bin"))
1312}
1313
1314fn file_key_bytes(
1315    resp: &vti_specs::rooms::keys::file_key::v0_2::Response,
1316) -> Result<vti_rooms::files::FileKey, Box<dyn std::error::Error>> {
1317    use base64::Engine as _;
1318    let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(resp.key.as_str())?;
1319    bytes
1320        .try_into()
1321        .map_err(|_| "the VTA returned a file key that is not 32 bytes".into())
1322}
1323
1324/// `rooms file seal` — seal a local file for a room, ready to upload.
1325pub async fn cmd_rooms_file_seal(
1326    client: &VtaClient,
1327    room_id: &str,
1328    path: &std::path::Path,
1329    out: &std::path::Path,
1330    padme: bool,
1331) -> Result<(), Box<dyn std::error::Error>> {
1332    use vti_rooms::files::{self, FileBinding, Padding};
1333
1334    if out.exists() {
1335        return Err(format!("{} already exists; choose a new directory", out.display()).into());
1336    }
1337    let plaintext = std::fs::read(path)?;
1338    // The name is the uploader's and is sealed: only members see it.
1339    let name = path
1340        .file_name()
1341        .and_then(|n| n.to_str())
1342        .ok_or("the file has no usable name")?;
1343
1344    // A fresh id for every file: the segment nonces are counters, so two files
1345    // under one key would reuse them.
1346    let file_id = files::new_file_id();
1347    let resp = client
1348        .room_file_key(room_id, &files::encode_file_id(&file_id), None)
1349        .await?;
1350    let key = file_key_bytes(&resp)?;
1351    let sealed = files::seal_file(
1352        &key,
1353        FileBinding {
1354            room_id: room_id.to_string(),
1355            file_id,
1356            epoch: resp.epoch.get(),
1357            segment_size: files::DEFAULT_SEGMENT_SIZE,
1358        },
1359        name,
1360        None,
1361        &plaintext,
1362        if padme { Padding::Padme } else { Padding::None },
1363    )?;
1364    std::fs::create_dir_all(out.join(CHUNKS_DIR))?;
1365    for (i, chunk) in sealed.chunks.iter().enumerate() {
1366        std::fs::write(chunk_path(out, i), chunk)?;
1367    }
1368    std::fs::write(
1369        out.join(BLOB_MANIFEST),
1370        serde_json::to_vec_pretty(&sealed.blob)?,
1371    )?;
1372    std::fs::write(
1373        out.join(FILE_MANIFEST),
1374        serde_json::to_vec_pretty(&sealed.file)?,
1375    )?;
1376    println!(
1377        "Sealed {} ({} bytes) under epoch {} into {}\n  blobRef  {}\n  chunks   {} of {} bytes",
1378        name,
1379        sealed.file.size,
1380        sealed.file.epoch,
1381        out.display(),
1382        sealed.file.blob_ref,
1383        sealed.blob.chunks.chunk_count,
1384        sealed.blob.chunks.chunk_size,
1385    );
1386    println!(
1387        "  {FILE_MANIFEST} goes inside the sealed record body; it names the file and is never \
1388         sent to the host in the clear."
1389    );
1390    Ok(())
1391}
1392
1393/// `rooms file open` — open a sealed file directory, refusing one that is not
1394/// the file its author signed.
1395pub async fn cmd_rooms_file_open(
1396    client: &VtaClient,
1397    room_id: &str,
1398    dir: &std::path::Path,
1399    out: &std::path::Path,
1400) -> Result<(), Box<dyn std::error::Error>> {
1401    use vti_rooms::files::{self, BlobManifest, FileManifest};
1402
1403    if out.exists() {
1404        return Err(format!("{} already exists; refusing to overwrite it", out.display()).into());
1405    }
1406    let file: FileManifest = serde_json::from_slice(&std::fs::read(dir.join(FILE_MANIFEST))?)?;
1407    let blob: BlobManifest = serde_json::from_slice(&std::fs::read(dir.join(BLOB_MANIFEST))?)?;
1408    // The file manifest is the author's; the blob manifest is whoever served it.
1409    // Before a byte is trusted: it is the blob the author named, and its chunk size
1410    // and count are the ones the author's segment size and length give.
1411    file.check_blob(&blob)?;
1412    let mut chunks = Vec::with_capacity(blob.chunks.chunk_count as usize);
1413    for (i, expected) in blob.chunks.chunk_digests.iter().enumerate() {
1414        let chunk = std::fs::read(chunk_path(dir, i))?;
1415        if !files::same_digest(&files::digest_multibase(&chunk), expected) {
1416            return Err(format!("chunk {i} does not match the manifest").into());
1417        }
1418        chunks.push(chunk);
1419    }
1420
1421    let resp = client
1422        .room_file_key(room_id, &file.file_id, Some(file.epoch))
1423        .await?;
1424    let key = file_key_bytes(&resp)?;
1425    let plaintext = files::open_file(&key, room_id, &file, &blob, &chunks)?;
1426
1427    // Written beside the target and moved into place, so a failure leaves nothing that
1428    // looks like the file.
1429    let tmp = out.with_extension("partial");
1430    std::fs::write(&tmp, &plaintext)?;
1431    std::fs::rename(&tmp, out)?;
1432    println!(
1433        "Opened {} ({} bytes) into {} — the digest its author signed matches.",
1434        // Untrusted text from another member: escaped, so it cannot drive the terminal.
1435        file.name.escape_debug(),
1436        plaintext.len(),
1437        out.display()
1438    );
1439    Ok(())
1440}
1441
1442// ─── Sealed records and files, end to end ────────────────────────────────
1443
1444/// How many times a sealed write re-seals for the version the host actually assigned before
1445/// giving up. Versions come from a room-wide counter, so each retry loses only to a write
1446/// that landed between this one's seal and its put.
1447const SEALED_WRITE_ATTEMPTS: usize = 4;
1448
1449/// Strip what could drive a terminal from text another member wrote: control characters,
1450/// escape sequences among them, except newline and tab.
1451fn inert(text: &str) -> String {
1452    text.chars()
1453        .filter(|c| !c.is_control() || *c == '\n' || *c == '\t')
1454        .collect()
1455}
1456
1457/// A fresh opaque record key. On a sealed tier a key MUST say nothing about the record.
1458fn opaque_key() -> String {
1459    let bytes: [u8; 16] = rand::random();
1460    base64::Engine::encode(&base64::engine::general_purpose::URL_SAFE_NO_PAD, bytes)
1461}
1462
1463/// The serialized string of a generated newtype (a digest, a transfer id).
1464fn wire_str<T: serde::Serialize>(value: &T) -> String {
1465    serde_json::to_value(value)
1466        .ok()
1467        .and_then(|v| v.as_str().map(str::to_string))
1468        .unwrap_or_default()
1469}
1470
1471/// Whether the room seals its records, from `rooms/info` read as the member — and which
1472/// member the VTA acted as, which is the author a sealed body names.
1473async fn room_tier(
1474    client: &VtaClient,
1475    target: &RoomTarget<'_>,
1476) -> Result<(bool, String), Box<dyn std::error::Error>> {
1477    let (sealed, member, _) = room_tier_and_epoch(client, target).await?;
1478    Ok((sealed, member))
1479}
1480
1481/// [`room_tier`], and the room's current epoch — the one an open room's file is written
1482/// at (`rooms/records/put/0.4` Producer item 7).
1483async fn room_tier_and_epoch(
1484    client: &VtaClient,
1485    target: &RoomTarget<'_>,
1486) -> Result<(bool, String, u64), Box<dyn std::error::Error>> {
1487    use trust_tasks_rs::Payload as _;
1488    let (member, reply) = act_and_send(
1489        client,
1490        target,
1491        vti_specs::rooms::info::v0_3::Payload::TYPE_URI,
1492        serde_json::json!({ "roomId": target.room_id }),
1493    )
1494    .await
1495    .map_err(|e| format!("could not read the room's tier (rooms/info): {e}"))?;
1496    let visibility = reply
1497        .get("visibility")
1498        .and_then(Value::as_str)
1499        .ok_or("the host's rooms/info response names no visibility")?;
1500    let epoch = reply
1501        .get("epoch")
1502        .and_then(Value::as_u64)
1503        .ok_or("the host's rooms/info response names no epoch")?;
1504    Ok((visibility != "open", member, epoch))
1505}
1506
1507/// Have the VTA sign `content` **as the member** and seal it (`rooms/keys/seal/0.2`), and
1508/// put it as the member (`rooms/keys/act/0.10`), re-sealing at the version the host assigned
1509/// whenever that is not the one sealed for. Returns the version written.
1510///
1511/// The record's associated data and its signed body both name the version, and the host
1512/// assigns it from a room-wide counter, so the version is predicted from the room's head and
1513/// a miss is corrected by a rewrite. Until that rewrite lands the record does not open,
1514/// which is why a write that runs out of attempts says so and names the key.
1515async fn write_sealed(
1516    client: &VtaClient,
1517    target: &RoomTarget<'_>,
1518    content: SealedRecordBody,
1519    expected_version: Option<u64>,
1520) -> Result<u64, Box<dyn std::error::Error>> {
1521    write_sealed_naming(client, target, content, expected_version, None).await
1522}
1523
1524/// [`write_sealed`], naming `blobs` committed in the room (`rooms/records/put/0.2`).
1525async fn write_sealed_naming(
1526    client: &VtaClient,
1527    target: &RoomTarget<'_>,
1528    content: SealedRecordBody,
1529    expected_version: Option<u64>,
1530    blobs: Option<Vec<String>>,
1531) -> Result<u64, Box<dyn std::error::Error>> {
1532    let head = list_page(client, target, None, None, None)
1533        .await?
1534        .head_version
1535        .unwrap_or(0);
1536
1537    let key = content.key.clone();
1538    let mut intended = head + 1;
1539    let mut expected = expected_version;
1540    let mut landed = 0;
1541    for _ in 0..SEALED_WRITE_ATTEMPTS {
1542        // Unsigned: the VTA signs it as its author, the member, before sealing.
1543        let unsigned = serde_json::to_vec(&content.clone().at_version(intended))?;
1544        let sealed = client
1545            .room_seal_as_principal(target.room_id, &key, intended, &unsigned)
1546            .await?
1547            .sealed;
1548        let mut payload = serde_json::json!({
1549            "roomId": target.room_id,
1550            "key": key,
1551            "sealed": {
1552                "ciphertext": sealed.ciphertext,
1553                "nonce": sealed.nonce,
1554                "epoch": sealed.epoch.get(),
1555            },
1556        });
1557        if let Some(v) = expected {
1558            payload["expectedVersion"] = serde_json::json!(v);
1559        }
1560        if let Some(b) = &blobs {
1561            payload["blobs"] = serde_json::json!(b);
1562        }
1563        let put_type = vti_rooms::wire::ROOMS_RECORDS_PUT_V3_TYPE;
1564        let (_, reply) = act_and_send(client, target, put_type, payload)
1565            .await
1566            .map_err(|e| put_refused(e, target))?;
1567        let put: vti_rooms::wire::PutRecordResponse = parse_reply("rooms/records/put", reply)?;
1568        landed = put.version;
1569        if landed == intended {
1570            return Ok(landed);
1571        }
1572        // Another write took the predicted version. The record is stored but sealed for the
1573        // wrong one, so it does not open; rewrite it, and only over what just landed.
1574        expected = Some(landed);
1575        intended = landed + 1;
1576    }
1577    Err(format!(
1578        "{key} is stored at version {landed} but sealed for another version, so it does not \
1579         open: the room kept moving under {SEALED_WRITE_ATTEMPTS} attempts. Write it again \
1580         with --expected-version {landed}."
1581    )
1582    .into())
1583}
1584
1585/// The `pnm rooms keys commit` that brings this VTA's group up to the room's epoch,
1586/// with the target's own values. `--host-did` only when the operator gave one: a VTC
1587/// needs it, another host may not have one to give.
1588fn keys_commit_command(target: &RoomTarget<'_>) -> String {
1589    let mut cmd = format!(
1590        "pnm rooms keys commit --room {} --host {}",
1591        target.room_id, target.host_url
1592    );
1593    if let Some(did) = target.host_did {
1594        cmd.push_str(&format!(" --host-did {did}"));
1595    }
1596    cmd
1597}
1598
1599/// `rooms/records/put` refused `e`: when the host says the record was sealed under an
1600/// epoch the room has moved past (the declared `epochMismatch`), say what happened and
1601/// print the command that catches up. Anything else is returned as it was.
1602fn put_refused(
1603    e: Box<dyn std::error::Error>,
1604    target: &RoomTarget<'_>,
1605) -> Box<dyn std::error::Error> {
1606    let refusal = e
1607        .downcast_ref::<vtc_client::VtcError>()
1608        .and_then(vtc_client::git_ns::task_error);
1609    match refusal {
1610        Some((code, message)) => match put_refusal_hint(&code, &message, target) {
1611            Some(hint) => hint.into(),
1612            None => e,
1613        },
1614        None => e,
1615    }
1616}
1617
1618/// The operator-facing text for a `rooms/records/put` refusal `code`, when there is one.
1619fn put_refusal_hint(code: &str, message: &str, target: &RoomTarget<'_>) -> Option<String> {
1620    use vti_specs::rooms::records::put;
1621    let epoch_mismatch = [
1622        put::v0_1::error_codes::EPOCH_MISMATCH.code,
1623        put::v0_2::error_codes::EPOCH_MISMATCH.code,
1624    ];
1625    if !epoch_mismatch.contains(&code) {
1626        return None;
1627    }
1628    Some(format!(
1629        "the host refused the record: {message}\n\n\
1630         The room has moved on to a newer epoch — it was renewed, or its membership changed — \
1631         and this VTA has not applied the commit that moved it, so it sealed the record under \
1632         the old epoch. Nothing was written. Catch up, then write again:\n\n  {}",
1633        keys_commit_command(target)
1634    ))
1635}
1636
1637/// The operator-facing text for a `rooms/keys/open` refusal.
1638fn open_refusal_hint(e: &VtaError, target: &RoomTarget<'_>) -> String {
1639    use vti_specs::rooms::keys::open::v0_2::error_codes::UNKNOWN_EPOCH;
1640    epoch_refusal_hint(e, target, UNKNOWN_EPOCH.code, Sealed::Record)
1641}
1642
1643/// The operator-facing text for a `rooms/keys/file-key` refusal, as `pnm rooms file
1644/// get` and `file open` print it — the same remedies a record's refusal names.
1645fn file_key_refusal_hint(e: &VtaError, target: &RoomTarget<'_>) -> String {
1646    use vti_specs::rooms::keys::file_key::v0_2::error_codes::UNKNOWN_EPOCH;
1647    epoch_refusal_hint(e, target, UNKNOWN_EPOCH.code, Sealed::File)
1648}
1649
1650/// What a refused epoch had sealed, for the wording of [`epoch_refusal_hint`].
1651#[derive(Clone, Copy)]
1652enum Sealed {
1653    Record,
1654    File,
1655}
1656
1657impl Sealed {
1658    fn noun(self) -> &'static str {
1659        match self {
1660            Self::Record => "record",
1661            Self::File => "file",
1662        }
1663    }
1664}
1665
1666/// The remedy for a refused epoch, from the declared `unknownEpoch` (`code`) and its
1667/// `details.reason`.
1668///
1669/// The epoch cases have opposite remedies, so they are told apart by reason —
1670/// `notDelivered` (catch up), `beyondChain` (backfill) and `removed` (no commit will
1671/// help: `rooms/keys/{open,file-key}/0.2` say a caller MUST NOT retry after catching up)
1672/// — never by the message. The text checks are only for a refusal that carries no reason.
1673fn epoch_refusal_hint(e: &VtaError, target: &RoomTarget<'_>, code: &str, sealed: Sealed) -> String {
1674    let noun = sealed.noun();
1675    let text = e.to_string();
1676    let reason = (e.rejection_code() == Some(code))
1677        .then(|| e.rejection_details())
1678        .flatten()
1679        .and_then(|d| d.get("reason"))
1680        .and_then(Value::as_str);
1681    let backfill = || {
1682        format!(
1683            "The {noun} is older than any key this VTA holds — it was written before you \
1684             joined. Fetch the room's history: `pnm rooms keys backfill --room {} --host-did \
1685             {}`.",
1686            target.room_id,
1687            target.host_did.unwrap_or("<host-did>")
1688        )
1689    };
1690    let hint = match reason {
1691        Some("removed") => {
1692            let at = e
1693                .rejection_details()
1694                .and_then(|d| d.get("removedAtEpoch"))
1695                .and_then(Value::as_u64)
1696                .map(|n| format!(" at epoch {n}"))
1697                .unwrap_or_default();
1698            format!(
1699                "You were removed from this room's group{at}. {}s sealed since then will \
1700                 never open for you, and no commit will change that; what was sealed before \
1701                 your removal still opens. Rejoining takes a fresh invitation from the room's \
1702                 owner.",
1703                capitalised(noun)
1704            )
1705        }
1706        Some("notDelivered") => format!(
1707            "The {noun} is newer than this VTA's group: a commit has not been applied here. \
1708             Catch up, then try again:\n\n  {}",
1709            keys_commit_command(target),
1710        ),
1711        Some("beyondChain") => backfill(),
1712        _ if text.contains("cannot reach") => backfill(),
1713        _ if text.contains("has not been delivered") => format!(
1714            "The {noun} is newer than this VTA's group. Catch up with `{}`; if that says you \
1715             were removed, nothing sealed after your removal will open.",
1716            keys_commit_command(target)
1717        ),
1718        _ => format!("The {noun} did not open with any key this VTA holds."),
1719    };
1720    format!("{e}\n\n{hint}")
1721}
1722
1723fn capitalised(word: &str) -> String {
1724    let mut chars = word.chars();
1725    chars
1726        .next()
1727        .map(|c| c.to_uppercase().chain(chars).collect())
1728        .unwrap_or_default()
1729}
1730
1731/// Open a sealed record through the VTA and check its body before anything in it is used.
1732///
1733/// `version` is the record's; the body is opened, and its signed location checked, at the
1734/// version it was **sealed** at — `sealed.version` where the host served one, because a
1735/// curation moves a record past its body. A record curated by a host that predates that
1736/// member carries none, and the VTA searches earlier versions and says which opened.
1737async fn open_and_verify(
1738    client: &VtaClient,
1739    target: &RoomTarget<'_>,
1740    key: &str,
1741    version: u64,
1742    sealed: &SealedContent,
1743    host_author: Option<&str>,
1744) -> Result<VerifiedRecordBody, Box<dyn std::error::Error>> {
1745    let sealed_at = sealed
1746        .sealed_at(version)
1747        .map_err(|why| format!("refusing record {key}: the host says {why}"))?;
1748    let opened = client
1749        .room_open(
1750            target.room_id,
1751            key,
1752            sealed_at,
1753            &sealed.ciphertext,
1754            &sealed.nonce,
1755            sealed.epoch,
1756        )
1757        .await
1758        // The failures operators will actually hit, named where they will read them. Each
1759        // reads like corruption if nobody says otherwise.
1760        .map_err(|e| open_refusal_hint(&e, target))?;
1761    let plaintext = opened
1762        .get("plaintext")
1763        .and_then(Value::as_str)
1764        .ok_or("the VTA opened the record but returned no plaintext")?;
1765    let bytes =
1766        base64::Engine::decode(&base64::engine::general_purpose::URL_SAFE_NO_PAD, plaintext)?;
1767
1768    let resolver = vta_sdk::trust_task_proof::TrustTaskVmResolver::from_optional(
1769        vta_sdk::resolver::shared_did_resolver_from_env().await.ok(),
1770    );
1771    // Where the VTA found it sealed, if not where it was asked to look.
1772    let opened_at = opened
1773        .pointer("/ext/org.openvtc.rooms/sealedAt")
1774        .and_then(Value::as_u64)
1775        .unwrap_or(sealed_at);
1776    if opened_at > sealed_at {
1777        return Err(format!(
1778            "refusing record {key}: the VTA opened it at version {opened_at}, past the {sealed_at} it was asked for"
1779        )
1780        .into());
1781    }
1782    let location = RecordLocation {
1783        room_id: target.room_id,
1784        key,
1785        version: opened_at,
1786    };
1787    Ok(verify_record_body(&bytes, location, host_author, &resolver)
1788        .await
1789        .map_err(|e| format!("refusing record {key}: {e}"))?)
1790}
1791
1792/// The part size a direct upload is cut into: one part up to [`SINGLE_PART_MAX`], else
1793/// parts of this size (storage's multipart floor is 5 MiB).
1794const DIRECT_PART: usize = 8 * 1024 * 1024;
1795/// The largest blob sent as one direct part.
1796const SINGLE_PART_MAX: usize = 16 * 1024 * 1024;
1797/// How many times a direct upload asks again for targets before giving up.
1798const DIRECT_BEGIN_ATTEMPTS: usize = 64;
1799
1800/// The part plan (`directParts`) for `ciphertext`: its parts, and each one's sha2-256.
1801fn direct_plan(ciphertext: &[u8]) -> (usize, Value) {
1802    use sha2::Digest as _;
1803    let part = if ciphertext.len() <= SINGLE_PART_MAX {
1804        ciphertext.len().max(1)
1805    } else {
1806        DIRECT_PART
1807    };
1808    let digests: Vec<String> = ciphertext
1809        .chunks(part)
1810        .map(|c| vti_common::blob_store::sha256_digest_multibase(&sha2::Sha256::digest(c).into()))
1811        .collect();
1812    (
1813        part,
1814        serde_json::json!({ "partSize": part, "partCount": digests.len(), "partDigests": digests }),
1815    )
1816}
1817
1818/// `rooms file put` — seal a local file, upload its ciphertext, and write the record that
1819/// names it, all **as the member** (`rooms/blobs/*/0.2` through `rooms/keys/act`).
1820///
1821/// On a sealed room the file is sealed here under one file's key from the VTA
1822/// (`rooms/keys/file-key`), and its manifest goes inside the sealed body. On an `open` room
1823/// the key is minted here at random and the manifest, key included, goes in the record's
1824/// cleartext (`rooms/records/put/0.4`): the host and every member can open it, and the
1825/// storage holds only ciphertext. Either way its bytes never pass through the VTA. The member's
1826/// VTA signs the begin, the record and — in direct mode — the commit, as the member; the
1827/// bytes go straight to the room's storage over targets the host issued (direct) or to the
1828/// host as chunk documents signed by a single-use upload key minted here for this upload
1829/// alone (proxied). This client's own key signs nothing the host receives.
1830pub async fn cmd_rooms_file_put(
1831    client: &VtaClient,
1832    target: RoomTarget<'_>,
1833    path: &std::path::Path,
1834    title: Option<String>,
1835    description: Option<String>,
1836    tags: Vec<String>,
1837    padme: bool,
1838) -> Result<(), Box<dyn std::error::Error>> {
1839    use trust_tasks_rs::Payload as _;
1840    use vti_rooms::files::{self, FileBinding, Padding};
1841    use vti_specs::rooms::blobs::upload;
1842
1843    let (sealed_tier, member, room_epoch) = room_tier_and_epoch(client, &target).await?;
1844
1845    let plaintext = std::fs::read(path)?;
1846    let name = path
1847        .file_name()
1848        .and_then(|n| n.to_str())
1849        .ok_or("the file has no usable name")?;
1850    // A fresh id for every file: the segment nonces are counters, so two files
1851    // under one key would reuse them.
1852    let file_id = files::new_file_id();
1853    // A sealed room's file key is derived from its storage key, by the VTA. An open room
1854    // has none: the key is minted here for this file alone, and the record carries it in
1855    // the clear (`rooms/records/put/0.4` Producer item 7).
1856    let (file_key, epoch) = if sealed_tier {
1857        let resp = client
1858            .room_file_key(target.room_id, &files::encode_file_id(&file_id), None)
1859            .await?;
1860        (file_key_bytes(&resp)?, resp.epoch.get())
1861    } else {
1862        (files::new_file_key(), room_epoch)
1863    };
1864    let mut sealed = files::seal_file(
1865        &file_key,
1866        FileBinding {
1867            room_id: target.room_id.to_string(),
1868            file_id,
1869            epoch,
1870            segment_size: files::DEFAULT_SEGMENT_SIZE,
1871        },
1872        name,
1873        None,
1874        &plaintext,
1875        if padme { Padding::Padme } else { Padding::None },
1876    )?;
1877    if !sealed_tier {
1878        sealed.file.file_key = Some(files::encode_file_key(&file_key));
1879    }
1880    let ciphertext: Vec<u8> = sealed.chunks.concat();
1881
1882    // The upload key: minted here for this upload alone, from no key held for anything
1883    // else, and dropped when the upload ends.
1884    let (key_did, key_secret) = crate::local_keygen::generate_unbound_admin_did_key();
1885    let key_secret = zeroize::Zeroizing::new(key_secret);
1886    let (part_size, plan) = direct_plan(&ciphertext);
1887    let begin_payload = serde_json::json!({
1888        "roomId": target.room_id,
1889        "manifest": sealed.blob,
1890        "uploadKey": key_did,
1891        "directParts": plan,
1892    });
1893    let begin = || async {
1894        let (_, reply) = act_and_send(
1895            client,
1896            &target,
1897            upload::begin::v0_2::Payload::TYPE_URI,
1898            begin_payload.clone(),
1899        )
1900        .await?;
1901        parse_reply::<upload::begin::v0_2::Response>("rooms/blobs/upload/begin", reply)
1902    };
1903    let mut begun = begin().await?;
1904    let direct = wire_str(&begun.mode) == "direct";
1905    let upload_id = begun.upload_id.to_string();
1906    let host = target.client();
1907
1908    let sent: Result<(), Box<dyn std::error::Error>> = async {
1909        if begun.already_committed == Some(true) {
1910            return Ok(());
1911        }
1912        if direct {
1913            // Each part to its own target, with exactly the headers it names; begin again,
1914            // with the identical manifest and plan, for parts beyond the first batch or
1915            // whose target lapsed.
1916            for _ in 0..DIRECT_BEGIN_ATTEMPTS {
1917                if begun.missing.is_empty() {
1918                    return Ok(());
1919                }
1920                if begun.targets.is_empty() {
1921                    return Err("the host answered direct mode with no targets".into());
1922                }
1923                for t in &begun.targets {
1924                    let i = usize::try_from(t.index)
1925                        .ok()
1926                        .filter(|i| i * part_size < ciphertext.len().max(1))
1927                        .ok_or("the host issued a target for a part that does not exist")?;
1928                    let part =
1929                        &ciphertext[i * part_size..((i + 1) * part_size).min(ciphertext.len())];
1930                    vtc_client::rooms::files::put_part(t, part).await?;
1931                }
1932                begun = begin().await?;
1933            }
1934            return Err("the upload did not finish: the host kept asking for parts".into());
1935        }
1936        for index in &begun.missing {
1937            let i = usize::try_from(index.0)
1938                .ok()
1939                .filter(|i| *i < sealed.chunks.len())
1940                .ok_or_else(|| {
1941                    format!("the host asked for chunk {}, which does not exist", index.0)
1942                })?;
1943            host.upload_chunk_v2(
1944                &upload_id,
1945                i as u64,
1946                &sealed.chunks[i],
1947                &key_did,
1948                &key_secret,
1949            )
1950            .await?;
1951        }
1952        Ok(())
1953    }
1954    .await;
1955
1956    // Commit and abort: the upload key's (proxied), or the member's own (direct).
1957    let finish = |type_uri: &'static str| {
1958        let (upload_id, key_did, key_secret, member) = (
1959            upload_id.clone(),
1960            key_did.clone(),
1961            key_secret.clone(),
1962            member.clone(),
1963        );
1964        let target = &target;
1965        async move {
1966            let payload = serde_json::json!({ "uploadId": upload_id });
1967            if direct {
1968                act_and_send_as(client, target, type_uri, payload, Some(&member))
1969                    .await
1970                    .map(|(_, reply)| reply)
1971            } else {
1972                let doc = vta_sdk::trust_task_sign::build_signed(
1973                    type_uri,
1974                    payload,
1975                    &key_did,
1976                    &key_secret,
1977                    target.host_did.unwrap_or("did:key:zHost"),
1978                )
1979                .await
1980                .map_err(|e| format!("could not sign with the upload key: {e}"))?;
1981                let doc: Value = serde_json::from_str(&doc)?;
1982                Ok(target.client().send_signed_room_document(&doc).await?)
1983            }
1984        }
1985    };
1986    if let Err(e) = sent {
1987        // Release what `begin` reserved rather than leaving it to expire.
1988        let _ = finish(upload::abort::v0_2::Payload::TYPE_URI).await;
1989        return Err(e);
1990    }
1991    let committed: upload::commit::v0_2::Response = parse_reply(
1992        "rooms/blobs/upload/commit",
1993        finish(upload::commit::v0_2::Payload::TYPE_URI).await?,
1994    )?;
1995    let blob_ref = wire_str(&committed.blob_ref);
1996    if !files::same_digest(&blob_ref, &sealed.file.blob_ref) {
1997        return Err(format!(
1998            "the host committed {blob_ref}, not the blob sealed here ({})",
1999            sealed.file.blob_ref
2000        )
2001        .into());
2002    }
2003
2004    let key = opaque_key();
2005    let version = if sealed_tier {
2006        let mut content = SealedRecordBody::new(
2007            RecordLocation {
2008                room_id: target.room_id,
2009                key: &key,
2010                version: 1,
2011            },
2012            &member,
2013        );
2014        content.title = title;
2015        content.description = description;
2016        content.tags = tags;
2017        content.file = Some(sealed.file.clone());
2018        write_sealed_naming(
2019            client,
2020            &target,
2021            content,
2022            Some(0),
2023            Some(vec![blob_ref.clone()]),
2024        )
2025        .await?
2026    } else {
2027        // In the clear, the manifest and its key included: on an open room the host and
2028        // every member read the record, so they open the file too.
2029        let mut cleartext = serde_json::to_value(CleartextContent {
2030            title,
2031            description,
2032            tags,
2033            ..Default::default()
2034        })?;
2035        cleartext["file"] = serde_json::to_value(&sealed.file)?;
2036        let (_, reply) = act_and_send(
2037            client,
2038            &target,
2039            vti_rooms::wire::ROOMS_RECORDS_PUT_V4_TYPE,
2040            serde_json::json!({
2041                "roomId": target.room_id,
2042                "key": key,
2043                "cleartext": cleartext,
2044                "expectedVersion": 0,
2045                "blobs": [blob_ref],
2046            }),
2047        )
2048        .await
2049        .map_err(|e| put_refused(e, &target))?;
2050        parse_reply::<vti_rooms::wire::PutRecordResponse>("rooms/records/put", reply)?.version
2051    };
2052
2053    if crate::render::is_json_output() {
2054        crate::render::print_json(&serde_json::json!({
2055            "key": key,
2056            "version": version,
2057            "blobRef": blob_ref,
2058            "size": sealed.file.size,
2059            "mode": if direct { "direct" } else { "proxied" },
2060        }))?;
2061        return Ok(());
2062    }
2063    println!(
2064        "Put {name} ({} bytes, {}) as record {key} at version {version}, as {member}\n  blobRef  {blob_ref}\n  Members fetch it with `pnm rooms file get {key}`.",
2065        sealed.file.size,
2066        if direct {
2067            "straight to the room's storage"
2068        } else {
2069            "through the host"
2070        }
2071    );
2072    Ok(())
2073}
2074
2075/// A local file name from one another member chose: the last path component, without
2076/// leading dots, never empty.
2077fn local_name(untrusted: &str) -> String {
2078    let base = untrusted
2079        .rsplit(['/', '\\'])
2080        .next()
2081        .unwrap_or_default()
2082        .trim_start_matches('.');
2083    let clean = inert(base).replace(['\n', '\t'], " ");
2084    if clean.trim().is_empty() {
2085        "room-file".to_string()
2086    } else {
2087        clean
2088    }
2089}
2090
2091/// `rooms file get` — fetch a record's file **as the member**, check it against what its
2092/// author signed, and write the plaintext.
2093///
2094/// The record and the blob are asked for through the member's VTA (`rooms/keys/act`); the
2095/// bytes come straight from the room's storage when the host offers it (`direct`, a ranged
2096/// read per chunk), or chunk by chunk through the host, each request signed as the member.
2097/// Nothing is written unless every check holds: the record body's signature and location,
2098/// the blob being the one the author named, every chunk against the manifest, and the
2099/// plaintext against the author's digest.
2100pub async fn cmd_rooms_file_get(
2101    client: &VtaClient,
2102    target: RoomTarget<'_>,
2103    key: &str,
2104    out: Option<&std::path::Path>,
2105) -> Result<(), Box<dyn std::error::Error>> {
2106    use trust_tasks_rs::Payload as _;
2107    use vti_rooms::files::{self, BlobManifest};
2108    use vti_specs::rooms::blobs::{chunk as blob_chunk, get as blob_get};
2109
2110    let (member, record) = act_and_send(
2111        client,
2112        &target,
2113        vti_rooms::wire::ROOMS_RECORDS_GET_V2_TYPE,
2114        serde_json::json!({ "roomId": target.room_id, "key": key }),
2115    )
2116    .await?;
2117    let record: vtc_client::rooms::files::GetRecordV2Response =
2118        parse_reply("rooms/records/get", record)?;
2119    // An open room's record describes its file in the clear, key included
2120    // (`rooms/records/put/0.4`); a sealed room's, inside the body its author signed.
2121    let (file, author, carried) = match record.cleartext.get("file") {
2122        Some(file) if record.sealed.is_none() => {
2123            let file: files::FileManifest = serde_json::from_value(file.clone())
2124                .map_err(|e| format!("the record's file manifest does not read: {e}"))?;
2125            let key = file
2126                .carried_key(true)?
2127                .ok_or("an open room's file manifest carries no key")?;
2128            let author = record
2129                .author
2130                .clone()
2131                .unwrap_or_else(|| "an unnamed member".to_string());
2132            (file, author, Some(key))
2133        }
2134        _ => {
2135            let sealed = record.sealed.as_ref().ok_or(
2136                "the record carries no file: it has neither a sealed body nor a `cleartext.file`, \
2137                 or it has been retracted",
2138            )?;
2139            let sealed = SealedContent {
2140                ciphertext: sealed.ciphertext.clone(),
2141                nonce: sealed.nonce.clone(),
2142                epoch: u32::try_from(sealed.epoch.get())
2143                    .map_err(|_| "the record's epoch is beyond what this client can represent")?,
2144                // The version the body was sealed at, where a curation moved the record past it
2145                // (rooms `_shared/0.1` `SealedRecord.version`): opened and verified there, and a
2146                // `sealed.version` above the record's own refused by `open_and_verify`.
2147                version: sealed.version.map(std::num::NonZeroU64::get),
2148            };
2149            let verified = open_and_verify(
2150                client,
2151                &target,
2152                key,
2153                record.version.get(),
2154                &sealed,
2155                record.author.as_deref(),
2156            )
2157            .await?;
2158            let file = verified
2159                .body()
2160                .file
2161                .clone()
2162                .ok_or("the record carries no file")?;
2163            // A sealed room's key is derived; a manifest carrying one is refused.
2164            file.carried_key(false)?;
2165            (file, verified.body().author.clone(), None)
2166        }
2167    };
2168    let named = record
2169        .blobs
2170        .iter()
2171        .flatten()
2172        .any(|b| files::same_digest(&wire_str(b), &file.blob_ref));
2173    if !named {
2174        return Err(
2175            "the host does not name the file's blob on this record; it may have been \
2176                    released"
2177                .into(),
2178        );
2179    }
2180
2181    let (_, started) = act_and_send(
2182        client,
2183        &target,
2184        blob_get::v0_2::Payload::TYPE_URI,
2185        serde_json::json!({ "roomId": target.room_id, "blobRef": file.blob_ref, "direct": true }),
2186    )
2187    .await?;
2188    let started: blob_get::v0_2::Response = parse_reply("rooms/blobs/get", started)?;
2189    let blob: BlobManifest = serde_json::from_value(serde_json::to_value(&started.manifest)?)?;
2190    // The file manifest is the author's; the blob manifest is the host's. Before a byte is
2191    // trusted: it is the blob the author named, and its shape is the one the author's
2192    // segment size and length give.
2193    file.check_blob(&blob)?;
2194    let size = blob.size;
2195    let chunk_size = blob.chunks.chunk_size;
2196    let mut chunks = Vec::with_capacity(blob.chunks.chunk_digests.len());
2197    for (i, expected) in blob.chunks.chunk_digests.iter().enumerate() {
2198        let chunk = match (&started.target, &started.download_id) {
2199            (Some(t), _) => {
2200                let start = i as u64 * chunk_size;
2201                vtc_client::rooms::files::get_range(t, start..(start + chunk_size).min(size))
2202                    .await?
2203            }
2204            (None, Some(download_id)) => {
2205                let (_, reply) = act_and_send_as(
2206                    client,
2207                    &target,
2208                    blob_chunk::v0_1::Payload::TYPE_URI,
2209                    serde_json::json!({ "downloadId": download_id, "index": i }),
2210                    Some(&member),
2211                )
2212                .await?;
2213                let c: blob_chunk::v0_1::Response = parse_reply("rooms/blobs/chunk", reply)?;
2214                base64::Engine::decode(
2215                    &base64::engine::general_purpose::URL_SAFE_NO_PAD,
2216                    c.data.as_str(),
2217                )?
2218            }
2219            (None, None) => {
2220                return Err("the host answered with neither a target nor a download".into());
2221            }
2222        };
2223        if !files::same_digest(&files::digest_multibase(&chunk), expected) {
2224            return Err(format!("chunk {i} does not match the manifest").into());
2225        }
2226        chunks.push(chunk);
2227    }
2228
2229    let file_key = match carried {
2230        Some(key) => key,
2231        None => {
2232            let resp = client
2233                .room_file_key(target.room_id, &file.file_id, Some(file.epoch))
2234                .await
2235                .map_err(|e| file_key_refusal_hint(&e, &target))?;
2236            file_key_bytes(&resp)?
2237        }
2238    };
2239    let plaintext = files::open_file(&file_key, target.room_id, &file, &blob, &chunks)?;
2240
2241    let out = match out {
2242        Some(p) => p.to_path_buf(),
2243        None => std::path::PathBuf::from(local_name(&file.name)),
2244    };
2245    if out.exists() {
2246        return Err(format!("{} already exists; refusing to overwrite it", out.display()).into());
2247    }
2248    // Written beside the target and moved into place, so a failure leaves nothing that
2249    // looks like the file.
2250    let tmp = out.with_extension("partial");
2251    std::fs::write(&tmp, &plaintext)?;
2252    std::fs::rename(&tmp, &out)?;
2253    println!(
2254        "Fetched {} ({} bytes) into {}, written by {}; it matches the digest {}.",
2255        inert(&file.name).escape_debug(),
2256        plaintext.len(),
2257        out.display(),
2258        inert(&author),
2259        if carried.is_some() {
2260            "its record states (an open room's record is not signed by its author)"
2261        } else {
2262            "they signed"
2263        }
2264    );
2265    Ok(())
2266}
2267
2268/// `rooms owner group-create` — found a sealed room's group in this VTA, with the owner
2269/// as its only member (`rooms/owner/group/create/0.1`).
2270pub async fn cmd_rooms_owner_group_create(
2271    client: &VtaClient,
2272    room_id: &str,
2273    signing_key_id: &str,
2274    member: Option<&str>,
2275) -> Result<(), Box<dyn std::error::Error>> {
2276    let resp = client
2277        .room_group_create(room_id, signing_key_id, member)
2278        .await
2279        .map_err(|e| {
2280            format!(
2281                "{e}\n\n`noSigningKey` means this VTA will not let you name `{signing_key_id}` — \
2282                 check it with `pnm keys list --context <id>`. `alreadyExists` means it already \
2283                 holds this room's group, which is never replaced."
2284            )
2285        })?;
2286    println!("Founded the group of {room_id} at epoch {}.", resp.epoch);
2287    println!(
2288        "  Next: admit members with `pnm rooms owner invite` and `pnm rooms owner group-add`. \
2289         Writing needs your own membership and authority credentials in this VTA \
2290         (`pnm rooms owner issue-membership` / `issue-authority --store`), if not issued yet."
2291    );
2292    Ok(())
2293}
2294
2295/// An earlier removal the VTA was settling when it took this request, and whose revocation
2296/// list the host refused on redelivery, explained for the operator — or `None` when `e` is
2297/// not that refusal.
2298///
2299/// It is `rooms/owner/group/{add,renew,remove}:earlierRemovalRefused` at `add/0.3`,
2300/// `renew/0.2` and `remove/0.4`, with `details` `member`, `code` and `listKept` —
2301/// `rooms/owner/group/remove/0.4` item 8.2. The earlier removal did not take effect, and
2302/// nothing of `this` was done: the fix is to run the command again, then remove the
2303/// earlier member again if that is still wanted.
2304fn earlier_removal_refused(
2305    e: &VtaError,
2306    room_id: &str,
2307    host_did: &str,
2308    signing_key_id: Option<&str>,
2309    this: &str,
2310) -> Option<String> {
2311    let VtaError::Rejected { code, details, .. } = e else {
2312        return None;
2313    };
2314    let task = code.strip_prefix("rooms/owner/group/")?;
2315    if !task.ends_with(":earlierRemovalRefused") {
2316        return None;
2317    }
2318    let d = details.as_ref()?;
2319    let earlier = d["member"].as_str().unwrap_or("an earlier member");
2320    let host_code = d["code"].as_str().unwrap_or("no code given");
2321    let kept = if d["listKept"].as_bool() == Some(true) {
2322        format!(
2323            "Their withdrawal is kept in the room's revocation list and carried by every later \
2324             one; the host enforces it only if it ever accepted that list — deliver it again \
2325             with\n    pnm rooms owner revoke --room {room_id} --signing-key {} --host \
2326             {host_did}",
2327            signing_key_id.unwrap_or("<the room's key>")
2328        )
2329    } else {
2330        "Their withdrawal was discarded: no host accepted it.".to_string()
2331    };
2332    Some(format!(
2333        "Nothing was done: an earlier removal of {earlier} from {room_id} did not take effect \
2334         — {host_did} refused its revocation list when your VTA delivered it again \
2335         ({host_code}). {earlier} is still in the room's group. {kept}\n\nRun this command \
2336         again to {this}; then remove {earlier} again if that is still wanted."
2337    ))
2338}
2339
2340/// `rooms owner group-remove` — remove a member from a room
2341/// (`rooms/owner/group/remove/0.4`, trust-tasks-tf #975).
2342///
2343/// On an `attributed` room the room's key (`signing_key_id`, required there) withdraws the
2344/// member's credentials at the host first, and then the room advances — the removal takes
2345/// effect at the host at once. On a `private` room, where the host cannot be told whom to
2346/// refuse, the same version removes their leaf alone and says so (`withdrawn: false`): their
2347/// credentials stay valid at the host until they expire, and this never reports that as a
2348/// withdrawal.
2349///
2350/// An earlier removal the VTA was still settling, whose list the host refused on
2351/// redelivery, is reported as `earlierRemovalRefused`: that removal did not take effect, and
2352/// nothing of this one was done — this says which member, and what to run.
2353#[allow(clippy::too_many_arguments)]
2354pub async fn cmd_rooms_owner_group_remove(
2355    client: &VtaClient,
2356    room_id: &str,
2357    host_did: &str,
2358    member: &str,
2359    signing_key_id: Option<&str>,
2360    mirrors: &[String],
2361    until: Option<chrono::DateTime<chrono::Utc>>,
2362    reason: Option<&str>,
2363) -> Result<(), Box<dyn std::error::Error>> {
2364    let hint = |e: VtaError| {
2365        let fix = match &e {
2366            VtaError::Rejected { code, .. }
2367                if code == "rooms/owner/group/remove:noSigningKey" && signing_key_id.is_none() =>
2368            {
2369                format!(
2370                    "\n\n{room_id} is not private: removing {member} withdraws their credentials \
2371                     at the host, signed as the room. Run\n    pnm rooms owner group-remove \
2372                     --room {room_id} --host-did {host_did} --member {member} --signing-key \
2373                     <the room's key>"
2374                )
2375            }
2376            _ => String::new(),
2377        };
2378        format!(
2379            "{e}{fix}\n\n`epochConflict` means another admin advanced the room: catch up and \
2380             retry. `hostUnreachable` leaves the outcome unknown; running this again \
2381             settles it with the host before anything else. `ownLeaf` means {member} is \
2382             you: hand the room on with rooms/owner/transfer instead. `noSigningKey` means \
2383             this VTA will not let you name the room's key — check `pnm keys list`."
2384        )
2385    };
2386    // Under one idempotency key, so a retry after a lost reply reports the removal that
2387    // happened instead of `notMember`.
2388    let resp = client
2389        .idempotent(|| {
2390            client.room_group_remove(
2391                room_id,
2392                host_did,
2393                member,
2394                signing_key_id,
2395                mirrors,
2396                until,
2397                reason,
2398            )
2399        })
2400        .await
2401        .map_err(|e| {
2402            // Item 8.2: an earlier removal did not take effect, and this one was not
2403            // started. Say which member, whether their withdrawal survives, and what to run.
2404            earlier_removal_refused(
2405                &e,
2406                room_id,
2407                host_did,
2408                signing_key_id,
2409                &format!("remove {member}"),
2410            )
2411            .unwrap_or_else(|| hint(e))
2412        })?;
2413    if !resp.withdrawn {
2414        // A private room: never represented as a withdrawal (the producer requirement).
2415        println!(
2416            "Removed {member} from {room_id}'s group; the room is now at epoch {}.",
2417            resp.epoch
2418        );
2419        println!(
2420            "  Nothing sealed from now on is readable to them. {room_id} is private, so their \
2421             credentials are NOT withdrawn at the host: they stay valid until they expire. \
2422             To reset every credential the room has issued, run `pnm rooms owner revoke \
2423             --reset` and re-admit those who remain."
2424        );
2425        return Ok(());
2426    }
2427    let r = serde_json::to_value(&resp).unwrap_or_default();
2428    println!(
2429        "Removed {member} from {room_id}: withdrawn at the host from {} (revocation list {}), \
2430         and the room is now at epoch {}.",
2431        r["revokedAt"].as_str().unwrap_or("now"),
2432        r["revocation"]["sequence"],
2433        r["epoch"]
2434    );
2435    if !resp.mirrors_pending.is_empty() {
2436        let pending: Vec<&str> = resp.mirrors_pending.iter().map(|m| m.as_str()).collect();
2437        println!(
2438            "  Not yet withdrawn at: {}. Deliver it with `pnm rooms owner revoke --room \
2439             {room_id} --signing-key {} --host <did>` (no withdrawal).",
2440            pending.join(", "),
2441            signing_key_id.unwrap_or("<the room's key>")
2442        );
2443    }
2444    println!(
2445        "  The host refuses their credentials, and anything they delegated, from now on. \
2446         Nothing they already read can be recalled."
2447    );
2448    Ok(())
2449}
2450
2451/// `rooms owner revoke` — withdraw credentials a room issued (`rooms/owner/revoke/0.1`),
2452/// signed as the room and delivered to every host serving it. With nothing to withdraw,
2453/// re-delivers the list the VTA holds.
2454#[allow(clippy::too_many_arguments)]
2455pub async fn cmd_rooms_owner_revoke(
2456    client: &VtaClient,
2457    room_id: &str,
2458    signing_key_id: &str,
2459    hosts: &[String],
2460    subjects: &[String],
2461    credentials: &[String],
2462    reset: bool,
2463    until: Option<chrono::DateTime<chrono::Utc>>,
2464    reason: Option<&str>,
2465) -> Result<(), Box<dyn std::error::Error>> {
2466    use vti_specs::rooms::owner::revoke::v0_1::Withdrawal;
2467    let until = until.map(|u| u.to_rfc3339_opts(chrono::SecondsFormat::Secs, true));
2468    let mut raw: Vec<serde_json::Value> = Vec::new();
2469    for s in subjects {
2470        raw.push(serde_json::json!({ "kind": "subject", "subject": s }));
2471    }
2472    for c in credentials {
2473        raw.push(serde_json::json!({ "kind": "credential", "credentialId": c }));
2474    }
2475    if reset {
2476        raw.push(serde_json::json!({ "kind": "room" }));
2477    }
2478    if let Some(u) = &until {
2479        for w in &mut raw {
2480            w["until"] = serde_json::json!(u);
2481        }
2482    }
2483    let withdraw: Vec<Withdrawal> = raw
2484        .into_iter()
2485        .map(serde_json::from_value)
2486        .collect::<Result<_, _>>()
2487        .map_err(|e| format!("a withdrawal is not one rooms/owner/revoke accepts: {e}"))?;
2488    let resp = client
2489        .idempotent(|| client.room_owner_revoke(room_id, signing_key_id, hosts, &withdraw, reason))
2490        .await
2491        .map_err(|e| {
2492            format!(
2493                "{e}\n\n`noList` means there is nothing to re-deliver: name a withdrawal. \
2494                 `entryNotPermitted` means the room is private: only --reset applies there. \
2495                 `noSigningKey` means this VTA will not let you name `{signing_key_id}` — check \
2496                 `pnm keys list`. `noAuthority` means it holds no `admin` grant of the room \
2497                 for a persona you act as."
2498            )
2499        })?;
2500    let r = serde_json::to_value(&resp).unwrap_or_default();
2501    println!(
2502        "{room_id}'s revocation list is at sequence {} ({}).",
2503        r["head"]["sequence"],
2504        r["head"]["digest"].as_str().unwrap_or_default()
2505    );
2506    for d in r["deliveries"].as_array().into_iter().flatten() {
2507        let host = d["host"].as_str().unwrap_or_default();
2508        match d["outcome"].as_str().unwrap_or_default() {
2509            "accepted" => println!(
2510                "  {host}: in force from {}",
2511                d["inForceAt"].as_str().unwrap_or_default()
2512            ),
2513            "refused" => {
2514                let code = d["code"].as_str().unwrap_or_default();
2515                println!("  {host}: REFUSED ({code})");
2516                if code == "rooms/revocations/put:stale" {
2517                    println!(
2518                        "    The host holds a later list this VTA did not sign: the room's key \
2519                         may be in more than one party's hands."
2520                    );
2521                }
2522            }
2523            _ => println!("  {host}: no answer — run this again with no withdrawal to deliver it"),
2524        }
2525    }
2526    Ok(())
2527}
2528
2529/// `rooms owner group-renew` — renew a sealed room (`rooms/owner/group/renew/0.2`): advance its group with a commit that changes no membership, and
2530/// the room at its host with that commit. An earlier removal the VTA was settling, refused
2531/// at the host, is explained as `group-remove` explains it: nothing of the renewal was done.
2532pub async fn cmd_rooms_owner_group_renew(
2533    client: &VtaClient,
2534    room_id: &str,
2535    host_did: &str,
2536    reason: Option<&str>,
2537) -> Result<(), Box<dyn std::error::Error>> {
2538    // Under one idempotency key, so a retry after a lost reply reports the renewal that
2539    // happened instead of renewing a second time.
2540    let resp = client
2541        .idempotent(|| client.room_group_renew(room_id, host_did, reason))
2542        .await
2543        .map_err(|e| {
2544            earlier_removal_refused(&e, room_id, host_did, None, &format!("renew {room_id}"))
2545                .unwrap_or_else(|| {
2546                    format!(
2547                        "{e}\n\n`noGroup` means this VTA holds no group for {room_id}: an \
2548                         `open` room renews with `pnm rooms renew`. `epochConflict` means \
2549                         another admin advanced the room: catch up and retry. \
2550                         `hostUnreachable` leaves the outcome unknown; running this again \
2551                         settles it with the host before anything else."
2552                    )
2553                })
2554        })?;
2555    println!(
2556        "Renewed {room_id}; the room is now at epoch {}.",
2557        resp.epoch
2558    );
2559    println!(
2560        "  Nobody joined or left. Every remaining member's VTA must apply the commit before \
2561         it can write again: each runs `pnm rooms keys commit --room {room_id} --host <url> \
2562         --host-did {host_did}`."
2563    );
2564    Ok(())
2565}
2566
2567/// `rooms owner group-add` — admit a member from their KeyPackage
2568/// (`rooms/owner/group/add/0.3`) and write the Welcome
2569/// to `out` for delivery. An earlier removal the VTA was settling, refused at the host, is
2570/// explained as `group-remove` explains it: nothing of the admission was done.
2571pub async fn cmd_rooms_owner_group_add(
2572    client: &VtaClient,
2573    room_id: &str,
2574    host_did: &str,
2575    member: &str,
2576    key_package_src: &str,
2577    reason: Option<&str>,
2578    out: &std::path::Path,
2579) -> Result<(), Box<dyn std::error::Error>> {
2580    if out.exists() {
2581        return Err(format!("{} already exists; refusing to overwrite it", out.display()).into());
2582    }
2583    let key_package = if key_package_src == "-" {
2584        let mut s = String::new();
2585        std::io::Read::read_to_string(&mut std::io::stdin(), &mut s)?;
2586        s
2587    } else {
2588        std::fs::read_to_string(key_package_src)?
2589    };
2590    let key_package = key_package.trim().to_string();
2591
2592    // Under one idempotency key: the answer is the only copy of the Welcome, and a
2593    // retry that re-executed would be refused `alreadyMember` with the Welcome lost.
2594    let resp = client
2595        .idempotent(|| client.room_group_add(room_id, host_did, member, &key_package, reason))
2596        .await
2597        .map_err(|e| {
2598            earlier_removal_refused(&e, room_id, host_did, None, &format!("admit {member}"))
2599                .unwrap_or_else(|| {
2600                    format!(
2601                        "{e}\n\n`epochConflict` means another admin advanced the room: catch \
2602                         up and retry. `hostUnreachable` leaves the outcome unknown; running \
2603                         this again settles it with the host before anything else."
2604                    )
2605                })
2606        })?;
2607
2608    let delivery = serde_json::json!({
2609        "roomId": room_id,
2610        "member": resp.member.as_str(),
2611        "epoch": resp.epoch,
2612        "welcome": resp.welcome.as_str(),
2613        "ratchetTree": resp.ratchet_tree.as_ref().map(|t| t.as_str()),
2614    });
2615    std::fs::write(out, serde_json::to_vec_pretty(&delivery)?)?;
2616    println!(
2617        "Admitted {member} to {room_id}; the room is now at epoch {}.",
2618        resp.epoch
2619    );
2620    println!(
2621        "  Deliver {} to the member's key holder (rooms/keys/welcome) directly — never \
2622         through the host.",
2623        out.display()
2624    );
2625    Ok(())
2626}
2627
2628// ─── A community's offers ────────────────────────────────────────────────
2629//
2630// A community that owns a room offers its members admission
2631// (`vtc/rooms/community/members/add`). The member lists their offers and accepts one with
2632// documents signed as the DID the offer is addressed to — a persona their VTA holds — so
2633// this client builds each request unsigned and has the VTA complete it as that DID with
2634// `rooms/keys/act`, which inserts no presentation for these types. The completed
2635// document goes to the community over its Trust Task HTTPS endpoint, unchanged: a
2636// persona-signed document rides HTTPS, never this client's own DIDComm session.
2637//
2638// The listing is `vtc/rooms/offers/list/0.4`, completed at `rooms/keys/act/0.10`: the
2639// community's offers with their descriptions, and the renewals of the rooms the member is
2640// already in (`renewsAt`). A VTA or a community that serves neither is reported, never
2641// read at an older version.
2642
2643/// The community an offer came from, and the DID it is addressed to.
2644pub struct OfferTarget<'a> {
2645    /// Base URL of the community's Trust Task endpoint (e.g. `https://vtc.example.com/v1`).
2646    pub vtc_url: &'a str,
2647    /// The community's DID — the `recipient` of every document.
2648    pub vtc_did: &'a str,
2649    /// The DID the community addressed its offers to: a persona your VTA holds.
2650    pub member: &'a str,
2651}
2652
2653/// Which party an offer document failed at: the member's VTA, completing it as the member,
2654/// or the community, answering it. A refusal means something different from each —
2655/// `unsupportedType` from the VTA is an agent to upgrade, from the community a community
2656/// that does not serve that version — so each is kept apart until it is reported.
2657enum OfferStepError {
2658    /// The member's VTA did not complete the document (`rooms/keys/act`).
2659    Act(VtaError),
2660    /// The community refused the completed document, or did not answer it.
2661    Community(vtc_client::VtcError),
2662    /// Neither: the document could not be built or serialised.
2663    Local(Box<dyn std::error::Error>),
2664}
2665
2666impl OfferStepError {
2667    /// The error to report, with a hint where the operator's fix is known.
2668    fn report(self, target: &OfferTarget<'_>) -> Box<dyn std::error::Error> {
2669        match self {
2670            Self::Act(e) => {
2671                let text = e.to_string();
2672                let hint = if matches!(e, VtaError::UnsupportedTaskType { .. }) {
2673                    "\n\nYour VTA does not serve rooms/keys/act/0.10, which signs this request \
2674                     as you: upgrade it."
2675                        .to_string()
2676                } else if text.contains("issuerNotPrincipal") {
2677                    format!(
2678                        "\n\nYour VTA holds no `authentication` key of `{}` that you may act as. \
2679                         Pass the persona DID the community knows you by as --member.",
2680                        target.member
2681                    )
2682                } else if text.contains("keyPackageNotHeld") {
2683                    "\n\nThe KeyPackage was not minted by your VTA for this member and room, or \
2684                     it has been used or has expired: run this again without --key-package to \
2685                     mint one."
2686                        .to_string()
2687                } else {
2688                    String::new()
2689                };
2690                format!("{e}{hint}").into()
2691            }
2692            Self::Community(e) => format!("{}: {e}", target.vtc_did).into(),
2693            Self::Local(e) => e,
2694        }
2695    }
2696}
2697
2698/// Have the VTA complete a request to the community as `target.member`
2699/// (`rooms/keys/act/0.10`) and send
2700/// it to the community. Returns the community's response
2701/// payload, or the party that failed.
2702async fn act_and_send_offer(
2703    client: &VtaClient,
2704    target: &OfferTarget<'_>,
2705    type_uri: &str,
2706    payload: Value,
2707) -> Result<Value, OfferStepError> {
2708    let document = vta_sdk::client::unsigned_community_request(
2709        type_uri,
2710        target.vtc_did,
2711        target.member,
2712        payload,
2713    )
2714    .map_err(|e| OfferStepError::Local(e.into()))?;
2715    let completed = client
2716        .room_act_community(&document)
2717        .await
2718        .map_err(OfferStepError::Act)?;
2719    let document =
2720        serde_json::to_value(&completed.document).map_err(|e| OfferStepError::Local(e.into()))?;
2721    VtcClient::anonymous(target.vtc_url, target.vtc_did)
2722        .send_signed_room_document(&document)
2723        .await
2724        .map_err(OfferStepError::Community)
2725}
2726
2727/// One offer as this client shows it: `vtc/rooms/offers/list/0.4`'s, with the room's
2728/// `description` when it has one.
2729type CommunityOffer = vti_specs::vtc::rooms::offers::list::v0_4::OfferV2;
2730
2731/// A renewal of an admission the member holds (`vtc/rooms/offers/list/0.4`).
2732type CommunityRenewal = vti_specs::vtc::rooms::offers::list::v0_4::Renewal;
2733
2734/// What a listing answered: the offers, and the renewals.
2735type OffersListing = (Vec<CommunityOffer>, Vec<CommunityRenewal>);
2736
2737/// One page of the community's `vtc/rooms/offers/list/0.4` answer: its offers, the next
2738/// cursor, and its renewals.
2739#[allow(clippy::type_complexity)]
2740fn offers_page(
2741    reply: Value,
2742) -> Result<(Vec<CommunityOffer>, Option<String>, Vec<CommunityRenewal>), Box<dyn std::error::Error>>
2743{
2744    use vti_specs::vtc::rooms::offers::list::v0_4;
2745    let page: v0_4::Response = parse_reply("vtc/rooms/offers/list/0.4", reply)?;
2746    Ok((
2747        page.items,
2748        page.next_cursor.map(|c| c.to_string()),
2749        page.renewals,
2750    ))
2751}
2752
2753/// Every offer the community has made to the member, and the renewals of the rooms they are
2754/// in: `vtc/rooms/offers/list/0.4`, signed as the member through `rooms/keys/act/0.10`, one
2755/// page at a time. A VTA or a community that serves neither is told so; nothing older is
2756/// asked.
2757async fn list_offers(
2758    client: &VtaClient,
2759    target: &OfferTarget<'_>,
2760) -> Result<OffersListing, Box<dyn std::error::Error>> {
2761    use trust_tasks_rs::Payload as _;
2762    let type_uri = vti_specs::vtc::rooms::offers::list::v0_4::Payload::TYPE_URI;
2763    let mut items = Vec::new();
2764    let mut renewals = Vec::new();
2765    let mut cursor: Option<String> = None;
2766    loop {
2767        let payload = match &cursor {
2768            Some(c) => serde_json::json!({ "cursor": c }),
2769            None => serde_json::json!({}),
2770        };
2771        let reply = act_and_send_offer(client, target, type_uri, payload)
2772            .await
2773            .map_err(|e| e.report(target))?;
2774        let page = offers_page(reply)?;
2775        items.extend(page.0);
2776        renewals.extend(page.2);
2777        match page.1 {
2778            Some(next) => cursor = Some(next),
2779            None => return Ok((items, renewals)),
2780        }
2781    }
2782}
2783
2784/// `pnm rooms offers list` — the rooms the community has offered you, each with what it is
2785/// for when the community says.
2786pub async fn cmd_rooms_offers_list(
2787    client: &VtaClient,
2788    target: OfferTarget<'_>,
2789) -> Result<(), Box<dyn std::error::Error>> {
2790    let (offers, renewals) = list_offers(client, &target).await?;
2791    if offers.is_empty() && renewals.is_empty() {
2792        println!(
2793            "No offers from {} are addressed to {}.",
2794            target.vtc_did, target.member
2795        );
2796        return Ok(());
2797    }
2798    for o in &offers {
2799        print!("{}", offer_lines(o, &target));
2800    }
2801    for r in &renewals {
2802        print!("{}", renewal_lines(r, &target));
2803    }
2804    Ok(())
2805}
2806
2807/// The lines `pnm rooms offers list` prints for one renewal, with the command that collects
2808/// it.
2809fn renewal_lines(r: &CommunityRenewal, target: &OfferTarget<'_>) -> String {
2810    let actions: Vec<String> = r.actions.0.iter().map(ToString::to_string).collect();
2811    format!(
2812        "{}  {}  [{}]  {}  renewal  until {} (renews {})\n  renew: {}\n",
2813        r.room_id.as_str(),
2814        inert(r.label.as_str()),
2815        r.visibility,
2816        actions.join(","),
2817        r.valid_until.to_rfc3339(),
2818        r.renews_at.to_rfc3339(),
2819        renew_command(r.room_id.as_str(), target),
2820    )
2821}
2822
2823/// The `pnm rooms offers renew` line for one renewal, ready to paste.
2824fn renew_command(room_id: &str, target: &OfferTarget<'_>) -> String {
2825    format!(
2826        "pnm rooms offers renew --room {} --vtc {} --vtc-did {} --member {}",
2827        shell_word(room_id),
2828        shell_word(target.vtc_url),
2829        shell_word(target.vtc_did),
2830        shell_word(target.member),
2831    )
2832}
2833
2834/// The vault handle under which the member's VTA holds the `type_tag` credential `room_id`
2835/// issued `member`: `None` when it holds none. More than one is refused — a key holder that
2836/// holds two cannot choose between them, and nor can this client.
2837async fn held_room_credential(
2838    client: &VtaClient,
2839    room_id: &str,
2840    member: &str,
2841    type_tag: &str,
2842) -> Result<Option<String>, Box<dyn std::error::Error>> {
2843    let found = client
2844        .cred_vault_query(serde_json::json!({ "type": type_tag, "issuerDid": room_id }))
2845        .await
2846        .map_err(|e| format!("your VTA's credential vault did not answer a query: {e}"))?;
2847    let mut mine = Vec::new();
2848    for descriptor in found["credentials"].as_array().cloned().unwrap_or_default() {
2849        let Some(id) = descriptor["id"].as_str() else {
2850            continue;
2851        };
2852        let got = client
2853            .cred_vault_get(id)
2854            .await
2855            .map_err(|e| format!("your VTA's credential vault did not return {id}: {e}"))?;
2856        // A room's membership is a Data Integrity credential, so its body is a
2857        // JSON object; a body carried as bytes is some other credential.
2858        let subject = match &got {
2859            vault_get::Response::Variant0 { credential, .. } => credential
2860                .get("credentialSubject")
2861                .and_then(|s| s.get("id"))
2862                .and_then(Value::as_str),
2863            _ => None,
2864        };
2865        if subject == Some(member) {
2866            mine.push(id.to_string());
2867        }
2868    }
2869    match mine.len() {
2870        0 => Ok(None),
2871        1 => Ok(mine.pop()),
2872        n => Err(format!(
2873            "your VTA holds {n} {type_tag}s that {room_id} issued {member}, and acts on the \
2874             room with none of them until one is left: remove the older (`pnm cred-vault \
2875             delete`) and renew again"
2876        )
2877        .into()),
2878    }
2879}
2880
2881/// `pnm rooms offers renew` — collect a renewal of a room you are in: store its membership
2882/// and authority in your VTA in place of the ones they renew (`vtc/rooms/offers/list/0.4`
2883/// surface items 1–3, carried unchanged into 0.4). Storing one already stored is success, so any surface may collect it
2884/// first.
2885pub async fn cmd_rooms_offers_renew(
2886    client: &VtaClient,
2887    target: OfferTarget<'_>,
2888    room_id: &str,
2889) -> Result<(), Box<dyn std::error::Error>> {
2890    let (_, renewals) = list_offers(client, &target).await?;
2891    let renewal = renewals
2892        .into_iter()
2893        .find(|r| r.room_id.as_str() == room_id)
2894        .ok_or_else(|| {
2895            format!(
2896                "{} lists no renewal of {room_id} for {}: `pnm rooms offers list` shows what it \
2897                 has for you",
2898                target.vtc_did, target.member
2899            )
2900        })?;
2901    for (what, type_tag, credential) in [
2902        (
2903            "membership",
2904            "MembershipCredential",
2905            renewal.credentials.membership.as_str(),
2906        ),
2907        (
2908            "authority",
2909            "AuthorityCredential",
2910            renewal.credentials.authority.as_str(),
2911        ),
2912    ] {
2913        let body: Value = serde_json::from_str(credential)
2914            .map_err(|e| format!("the renewal's {what} credential is not JSON: {e}"))?;
2915        let replaces = held_room_credential(client, room_id, target.member, type_tag).await?;
2916        client
2917            .cred_vault_receive(body, replaces.as_deref())
2918            .await
2919            .map_err(|e| {
2920                format!("your VTA's credential vault refused the renewed {what} credential: {e}")
2921            })?;
2922    }
2923    println!(
2924        "Renewed {room_id} for {}: valid until {} (it was until {}).",
2925        target.member,
2926        renewal.valid_until.to_rfc3339(),
2927        renewal.renews_at.to_rfc3339(),
2928    );
2929    Ok(())
2930}
2931
2932/// The lines `pnm rooms offers list` prints for one offer. The label and description are
2933/// the community's text: control characters in them never reach the terminal, and the
2934/// description is kept to one line.
2935fn offer_lines(o: &CommunityOffer, target: &OfferTarget<'_>) -> String {
2936    use vti_specs::vtc::rooms::offers::list::v0_4::OfferV2State;
2937    let actions: Vec<String> = o.actions.0.iter().map(ToString::to_string).collect();
2938    let mut out = format!(
2939        "{}  {}  [{}]  {}  {}  until {}\n",
2940        o.room_id.as_str(),
2941        inert(o.label.as_str()),
2942        o.visibility,
2943        actions.join(","),
2944        o.state,
2945        o.valid_until.to_rfc3339(),
2946    );
2947    if let Some(description) = &o.description {
2948        out.push_str(&format!("  about: {}\n", one_line(description.as_str())));
2949    }
2950    if o.state == OfferV2State::Open {
2951        out.push_str(&format!(
2952            "  accept: {}\n",
2953            accept_command(o.room_id.as_str(), target)
2954        ));
2955    }
2956    out
2957}
2958
2959/// `text` on one line: every control character — line breaks and tabs included — replaced
2960/// with a space, so a description the community wrote cannot move the cursor, start a
2961/// line that looks like this client's own, or ring the terminal.
2962fn one_line(text: &str) -> String {
2963    text.chars()
2964        .map(|c| if c.is_control() { ' ' } else { c })
2965        .collect::<String>()
2966        .trim()
2967        .to_string()
2968}
2969
2970/// The `pnm rooms offers accept` line for one offer, ready to paste: every value filled in,
2971/// so the operator never types a `<placeholder>` a shell reads as a redirect.
2972fn accept_command(room_id: &str, target: &OfferTarget<'_>) -> String {
2973    format!(
2974        "pnm rooms offers accept --room {} --vtc {} --vtc-did {} --member {}",
2975        shell_word(room_id),
2976        shell_word(target.vtc_url),
2977        shell_word(target.vtc_did),
2978        shell_word(target.member),
2979    )
2980}
2981
2982/// `value` as one shell word. The room DID comes from the community, so it is quoted
2983/// unless every character is one no POSIX shell or fish treats specially.
2984fn shell_word(value: &str) -> String {
2985    let plain = !value.is_empty()
2986        && value
2987            .chars()
2988            .all(|c| c.is_ascii_alphanumeric() || ":/._%@+=,-".contains(c));
2989    if plain {
2990        value.to_string()
2991    } else {
2992        format!("'{}'", inert(value).replace('\'', r"'\''"))
2993    }
2994}
2995
2996/// `pnm rooms offers accept` — take up an offer: store its credentials in your VTA, mint a
2997/// KeyPackage there on a sealed tier, accept as the member, and join with the Welcome.
2998///
2999/// `key_package` reuses a KeyPackage your VTA minted for this member and room — for a run
3000/// whose answer was lost after the community admitted you, so the retry is the identical
3001/// acceptance the community (and its VTA) answers with the same Welcome.
3002pub async fn cmd_rooms_offers_accept(
3003    client: &VtaClient,
3004    target: OfferTarget<'_>,
3005    room_id: &str,
3006    key_package: Option<&str>,
3007) -> Result<(), Box<dyn std::error::Error>> {
3008    use trust_tasks_rs::Payload as _;
3009    use vti_specs::vtc::rooms::offers::accept::v0_1 as accept;
3010
3011    let (offers, _) = list_offers(client, &target).await?;
3012    let offer = offers
3013        .into_iter()
3014        .find(|o| o.room_id.as_str() == room_id)
3015        .ok_or_else(|| {
3016            format!(
3017                "{} has no open offer to {room_id} addressed to {}: `pnm rooms offers list` \
3018                 shows what it has offered you",
3019                target.vtc_did, target.member
3020            )
3021        })?;
3022    let sealed = offer.visibility.to_string() != "open";
3023
3024    // The membership and authority credentials the room issued the member, into the VTA's
3025    // vault, where its presentation oracle finds them for every later room request. Stored
3026    // under their own ids, so a second run replaces rather than duplicates.
3027    for (what, credential) in [
3028        ("membership", offer.credentials.membership.as_str()),
3029        ("authority", offer.credentials.authority.as_str()),
3030    ] {
3031        let body: Value = serde_json::from_str(credential)
3032            .map_err(|e| format!("the offer's {what} credential is not JSON: {e}"))?;
3033        client.cred_vault_receive(body, None).await.map_err(|e| {
3034            format!("your VTA's credential vault refused the {what} credential: {e}")
3035        })?;
3036    }
3037    let invitation = offer.credentials.invitation.as_str();
3038
3039    // Joined already — here, or from another surface: nothing to accept, and minting
3040    // again would only make a KeyPackage nobody admits.
3041    if sealed && holds_group(client, room_id, target.member).await? {
3042        println!(
3043            "{room_id} is already joined as {} by your VTA — accepted here or from another \
3044             surface. Nothing to do.",
3045            target.member
3046        );
3047        return Ok(());
3048    }
3049
3050    let key_package = match (sealed, key_package) {
3051        (false, _) => None,
3052        (true, Some(src)) => Some(std::fs::read_to_string(src)?.trim().to_string()),
3053        // From the VTA, never made here: act completes an acceptance only with a KeyPackage
3054        // the same key holder minted for this member and room.
3055        (true, None) => Some(offered_key_package(client, room_id, invitation).await?),
3056    };
3057
3058    let mut payload = serde_json::json!({ "roomId": room_id });
3059    if let Some(kp) = &key_package {
3060        payload["keyPackage"] = Value::String(kp.clone());
3061    }
3062    let reply = act_and_send_offer(client, &target, accept::Payload::TYPE_URI, payload)
3063        .await
3064        .map_err(|e| e.report(&target))
3065        .map_err(|e| match &key_package {
3066            Some(kp) => format!(
3067                "{e}\n\nIf the community may have admitted you, retry the identical acceptance \
3068                 with this KeyPackage (save it to a file and pass --key-package <file>):\n{kp}"
3069            ),
3070            None => e.to_string(),
3071        })?;
3072    let accepted: accept::Response = parse_reply("vtc/rooms/offers/accept", reply)?;
3073
3074    match &accepted.welcome {
3075        Some(welcome) => {
3076            let joined = client
3077                .idempotent(|| {
3078                    client.room_welcome(
3079                        room_id,
3080                        invitation,
3081                        welcome.as_str(),
3082                        accepted.ratchet_tree.as_ref().map(|t| t.as_str()),
3083                    )
3084                })
3085                .await;
3086            match joined {
3087                Ok(joined) => println!(
3088                    "Accepted {room_id} as {} and joined its group at room epoch {}.",
3089                    target.member,
3090                    joined.epoch + 1
3091                ),
3092                // Another surface handed the same Welcome over first: joined all the same.
3093                Err(e) if e.to_string().contains("alreadyJoined") => println!(
3094                    "Accepted {room_id} as {}; your VTA had already joined its group from \
3095                     another surface.",
3096                    target.member
3097                ),
3098                Err(e) => {
3099                    return Err(format!(
3100                        "admitted at room epoch {}, but your VTA did not join from the \
3101                         Welcome: {e}",
3102                        accepted.epoch
3103                    )
3104                    .into());
3105                }
3106            }
3107        }
3108        None if sealed => {
3109            return Err(format!(
3110                "the community admitted you to sealed room {room_id} but sent no Welcome"
3111            )
3112            .into());
3113        }
3114        None => println!(
3115            "Accepted {room_id} as {}; the room is open, at epoch {}.",
3116            target.member, accepted.epoch
3117        ),
3118    }
3119    println!(
3120        "  Read it with `pnm rooms list --room {room_id} --host {} --host-did {}`.",
3121        target.vtc_url,
3122        offer.host.as_str()
3123    );
3124    Ok(())
3125}
3126
3127/// Whether the VTA holds `room`'s group for `member` (`rooms/keys/list`): custody, which
3128/// with the community's record is what every surface shows the member.
3129async fn holds_group(
3130    client: &VtaClient,
3131    room: &str,
3132    member: &str,
3133) -> Result<bool, Box<dyn std::error::Error>> {
3134    let held = client.room_list(false).await?;
3135    Ok(held
3136        .rooms
3137        .iter()
3138        .any(|r| r.room_id.as_str() == room && r.member.as_str() == member))
3139}
3140
3141/// The KeyPackage for an acceptance: the one the VTA already holds for this room, member
3142/// and invitation — which another surface may already have sent — or a new one
3143/// (`rooms/keys/key-package/0.2`).
3144async fn offered_key_package(
3145    client: &VtaClient,
3146    room: &str,
3147    invitation: &str,
3148) -> Result<String, Box<dyn std::error::Error>> {
3149    let kp = client.room_key_package(room, invitation).await?;
3150    if kp.already_minted {
3151        eprintln!(
3152            "Your VTA already held a KeyPackage for this offer — another surface may \
3153             have begun this acceptance; finishing it."
3154        );
3155    }
3156    Ok(kp.key_package.to_string())
3157}
3158
3159/// `vtc/rooms/memberships/list/0.2`, signed as the member through `rooms/keys/act/0.10`:
3160/// the community's whole record of their admissions, with each room's own state (one page at
3161/// a time). A community that does not serve 0.2 is reported, never read at 0.1.
3162async fn list_memberships(
3163    client: &VtaClient,
3164    target: &OfferTarget<'_>,
3165) -> Result<
3166    Vec<vti_specs::vtc::rooms::memberships::list::v0_2::RoomMembershipV2>,
3167    Box<dyn std::error::Error>,
3168> {
3169    use trust_tasks_rs::Payload as _;
3170    use vti_specs::vtc::rooms::memberships::list::v0_2 as list;
3171    let mut items = Vec::new();
3172    let mut cursor: Option<String> = None;
3173    loop {
3174        let payload = match &cursor {
3175            Some(c) => serde_json::json!({ "cursor": c }),
3176            None => serde_json::json!({}),
3177        };
3178        let reply = act_and_send_offer(client, target, list::Payload::TYPE_URI, payload)
3179            .await
3180            .map_err(|e| e.report(target))?;
3181        let page: list::Response = parse_reply("vtc/rooms/memberships/list", reply)?;
3182        items.extend(page.items);
3183        match page.next_cursor {
3184            Some(next) => cursor = Some(next.to_string()),
3185            None => return Ok(items),
3186        }
3187    }
3188}
3189
3190/// What a surface shows for one room — `vtc/rooms/memberships/list/0.2` *One record, every
3191/// surface*: the community's state and the key holder's custody, read the same way by
3192/// every surface.
3193#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3194pub enum RoomStanding {
3195    /// Offered and not yet accepted.
3196    Offered,
3197    /// Admitted, and openable: an open room, or a sealed one whose group the VTA holds.
3198    Joined,
3199    /// Admitted to a sealed room whose group the VTA does not hold, while the community
3200    /// still keeps the Welcome: an acceptance to finish.
3201    Unfinished,
3202    /// Admitted to a sealed room whose group the VTA does not hold, and the Welcome is no
3203    /// longer kept: only an administrator re-admitting the member repairs it.
3204    NeedsReadmission,
3205    /// Admitted, but the room was closed to every member — archived with no access, or
3206    /// scheduled for removal: its host refuses the member's credentials, and the community
3207    /// restores them if the room reopens.
3208    Suspended,
3209    /// Removed by an administrator.
3210    Removed,
3211}
3212
3213impl RoomStanding {
3214    /// The table in `vtc/rooms/memberships/list/0.2`.
3215    pub fn of(state: &str, sealed: bool, held: bool, welcome_kept: bool) -> Self {
3216        match state {
3217            "offered" => Self::Offered,
3218            "removed" => Self::Removed,
3219            "suspended" => Self::Suspended,
3220            _ if !sealed || held => Self::Joined,
3221            _ if welcome_kept => Self::Unfinished,
3222            _ => Self::NeedsReadmission,
3223        }
3224    }
3225
3226    fn label(self) -> &'static str {
3227        match self {
3228            Self::Offered => "offered",
3229            Self::Joined => "joined",
3230            Self::Unfinished => "accepted, not finished",
3231            Self::NeedsReadmission => "admitted, not openable here",
3232            Self::Suspended => "suspended",
3233            Self::Removed => "removed",
3234        }
3235    }
3236}
3237
3238/// What the room's own state means to the member, in a line — `None` for an active room.
3239/// `room_state` and `room_access` are `vtc/rooms/memberships/list/0.2`'s `roomState` and
3240/// `roomAccess`; `purge_at` its `purgeAt`, RFC 3339.
3241pub fn room_condition(
3242    room_state: &str,
3243    room_access: Option<&str>,
3244    purge_at: Option<&str>,
3245) -> Option<String> {
3246    match (room_state, room_access) {
3247        ("active", _) => None,
3248        ("archived", Some("readOnly")) => Some(
3249            "Archived, read-only: you can still read it; nothing can be written, uploaded or \
3250             curated until an administrator unarchives it."
3251                .into(),
3252        ),
3253        ("archived", _) => Some(
3254            "Archived, closed for now: the community withdrew everyone's access and kept the \
3255             content. If it is reopened your access is restored — collect it with \
3256             `pnm rooms offers renew`."
3257                .into(),
3258        ),
3259        ("removalScheduled", _) | ("purging", _) => Some(match purge_at {
3260            Some(at) => format!(
3261                "Being removed: closed to every member, and its content is deleted on {at}. \
3262                 Nothing of it can be read from now on."
3263            ),
3264            None => {
3265                "Being removed: closed to every member, and its content is being deleted.".into()
3266            }
3267        }),
3268        ("removed", _) => Some("Removed: the community deleted the room and its content.".into()),
3269        (other, _) => Some(format!("The room is {other}.")),
3270    }
3271}
3272
3273/// `pnm rooms memberships` — every room the community has offered you and where each
3274/// stands, as every surface you use shows it: the community's record
3275/// (`vtc/rooms/memberships/list`) beside your VTA's custody (`rooms/keys/list`).
3276pub async fn cmd_rooms_memberships(
3277    client: &VtaClient,
3278    target: OfferTarget<'_>,
3279) -> Result<(), Box<dyn std::error::Error>> {
3280    let rooms = list_memberships(client, &target).await?;
3281    if rooms.is_empty() {
3282        println!(
3283            "{} has offered no rooms to {}.",
3284            target.vtc_did, target.member
3285        );
3286        return Ok(());
3287    }
3288    let held = client.room_list(false).await?;
3289    for r in &rooms {
3290        let room_id = r.room_id.as_str();
3291        let holds = held
3292            .rooms
3293            .iter()
3294            .any(|h| h.room_id.as_str() == room_id && h.member.as_str() == target.member);
3295        let standing = RoomStanding::of(
3296            &r.state.to_string(),
3297            r.visibility.to_string() != "open",
3298            holds,
3299            r.repeat_accept_until.is_some(),
3300        );
3301        let actions: Vec<String> = r.actions.0.iter().map(ToString::to_string).collect();
3302        let room_state = r.room_state.to_string();
3303        let room_access = r.room_access.as_ref().map(ToString::to_string);
3304        let purge_at = r
3305            .purge_at
3306            .map(|t| t.to_rfc3339_opts(chrono::SecondsFormat::Secs, true));
3307        let condition = room_condition(&room_state, room_access.as_deref(), purge_at.as_deref());
3308        println!(
3309            "{room_id}  {}  [{}]  {}  {}{}",
3310            inert(r.label.as_str()),
3311            r.visibility,
3312            actions.join(","),
3313            standing.label(),
3314            if condition.is_some() {
3315                format!("  ({room_state})")
3316            } else {
3317                String::new()
3318            },
3319        );
3320        if let Some(c) = &condition {
3321            println!("  {c}");
3322        }
3323        // No acceptance to finish, or re-admission to ask for, in a room that admits no one.
3324        let room_admits = room_state == "active";
3325        match standing {
3326            RoomStanding::Offered | RoomStanding::Unfinished if room_admits => {
3327                println!("  accept: {}", accept_command(room_id, &target));
3328            }
3329            RoomStanding::NeedsReadmission if room_admits => println!(
3330                "  Your VTA never joined this room's group and the community no longer keeps \
3331                 the Welcome: ask an administrator to remove and re-admit you."
3332            ),
3333            _ => {}
3334        }
3335    }
3336    Ok(())
3337}
3338
3339#[cfg(test)]
3340mod tests {
3341    use super::*;
3342    use serde_json::json;
3343
3344    fn prune_target() -> RoomTarget<'static> {
3345        RoomTarget {
3346            host_url: "https://host.example",
3347            host_did: Some("did:web:host.example"),
3348            room_id: "did:webvh:Qm:example.com:rooms:northwind",
3349        }
3350    }
3351
3352    /// rooms/epoch/prune/0.2 *Consent/purpose*: `--yes` always proceeds; without it a
3353    /// terminal is asked and a pipe is refused — never pruned unasked.
3354    #[test]
3355    fn a_prune_without_yes_asks_a_terminal_and_refuses_a_pipe() {
3356        assert_eq!(prune_guard(true, false), PruneGuard::Proceed);
3357        assert_eq!(prune_guard(true, true), PruneGuard::Proceed);
3358        assert_eq!(prune_guard(false, true), PruneGuard::Ask);
3359        assert_eq!(prune_guard(false, false), PruneGuard::Refuse);
3360    }
3361
3362    /// The warning says what becomes unreadable, that it cannot be undone, that the
3363    /// records stay, and that members' VTAs keep what they already derived.
3364    #[test]
3365    fn the_prune_warning_names_what_becomes_unreadable() {
3366        // `beforeEpoch: 5` → floor 4: rung 5 still yields epoch 4, so epochs 1 to 3 go
3367        // (`rooms/epoch/prune/0.2`, *The floor, and the off-by-one*).
3368        let w = prune_warning("did:example:room", 5, 7, Some("chained"));
3369        assert!(w.contains("cannot be undone"), "{w}");
3370        assert!(w.contains("epochs 1 to 3"), "{w}");
3371        assert!(w.contains("epoch floor (4)"), "{w}");
3372        assert!(w.contains("now at epoch 7"), "{w}");
3373        assert!(w.contains("The records stay"), "{w}");
3374        assert!(w.contains("members' VTAs"), "{w}");
3375        assert!(!w.contains("fromJoin"), "{w}");
3376
3377        assert!(prune_warning("r", 3, 4, None).contains("unreadable for good"));
3378        assert!(prune_warning("r", 3, 4, None).contains("epoch 1 "));
3379        assert!(prune_warning("r", 2, 3, None).contains("nothing becomes unreadable"));
3380        assert!(prune_warning("r", 3, 4, Some("fromJoin")).contains("keeps no chain"));
3381    }
3382
3383    /// `notAhead`, refused locally with the highest floor that works, as a command.
3384    #[test]
3385    fn a_floor_at_the_current_epoch_suggests_the_one_below() {
3386        let t = prune_target();
3387        let msg = not_ahead_message(&t, 7, 7);
3388        assert!(msg.contains("current epoch 7"), "{msg}");
3389        assert!(
3390            msg.contains(
3391                "pnm rooms prune --room did:webvh:Qm:example.com:rooms:northwind \
3392                 --host https://host.example --host-did did:web:host.example \
3393                 --before-epoch 6 --reason '<why>' --yes"
3394            ),
3395            "{msg}"
3396        );
3397        // At epoch 1 there is no floor below to suggest.
3398        assert!(!not_ahead_message(&t, 1, 1).contains("pnm rooms prune"));
3399    }
3400
3401    /// The host's declared codes are answered with the fix, not the bare error.
3402    #[test]
3403    fn a_hosts_prune_refusal_is_explained() {
3404        let t = prune_target();
3405        let refusal = |code: &str| vtc_client::VtcError::Http {
3406            status: 400,
3407            body: json!({
3408                "type": "https://trusttasks.org/spec/trust-task-error/0.1",
3409                "payload": { "code": code, "message": "no" },
3410            })
3411            .to_string(),
3412        };
3413        let msg = prune_refused(refusal("rooms/epoch/prune:notAhead"), &t, 7, 7);
3414        assert!(msg.contains("--before-epoch 6"), "{msg}");
3415        let msg = prune_refused(refusal("permissionDenied"), &t, 3, 7);
3416        assert!(msg.contains("needs `admin`"), "{msg}");
3417        assert!(msg.contains("issue-authority --action admin"), "{msg}");
3418        let msg = prune_refused(refusal("rooms/epoch/prune:chainTooDeep"), &t, 3, 7);
3419        assert!(!msg.contains("needs `admin`"), "{msg}");
3420    }
3421
3422    /// `pruned: 0` is a success, and the reach printed is `earliestRung`, not the floor.
3423    #[test]
3424    fn a_prune_outcome_reports_reach_not_the_request() {
3425        let r = |pruned, earliest_rung| vtc_client::rooms::PruneResponse {
3426            room_id: "did:example:room".into(),
3427            pruned,
3428            earliest_rung,
3429        };
3430        let none = prune_outcome(&r(0, 3));
3431        assert!(none.contains("nothing to drop"), "{none}");
3432        assert!(none.contains("reaches back to epoch 3"), "{none}");
3433        assert!(prune_outcome(&r(1, 5)).contains("1 rung dropped"));
3434        assert!(prune_outcome(&r(4, 5)).contains("4 rungs dropped"));
3435    }
3436
3437    fn rejected(code: &str, details: Value) -> VtaError {
3438        VtaError::Rejected {
3439            code: code.into(),
3440            message: "refused".into(),
3441            details: Some(details),
3442        }
3443    }
3444
3445    /// `earlierRemovalRefused` at add 0.3 / renew 0.2 / remove 0.4 is explained alike; a
3446    /// `hostRefused` (`{code, reason}`, or `{stage, code}`) is not.
3447    #[test]
3448    fn an_earlier_removal_is_explained_whichever_version_reported_it() {
3449        let d =
3450            json!({ "member": "did:key:zEarlier", "code": "permissionDenied", "listKept": true });
3451        for code in [
3452            "rooms/owner/group/add:earlierRemovalRefused",
3453            "rooms/owner/group/renew:earlierRemovalRefused",
3454            "rooms/owner/group/remove:earlierRemovalRefused",
3455        ] {
3456            let said = earlier_removal_refused(
3457                &rejected(code, d.clone()),
3458                "did:room",
3459                "did:host",
3460                None,
3461                "admit did:key:zNew",
3462            )
3463            .unwrap_or_else(|| panic!("{code} is an earlier removal"));
3464            assert!(said.starts_with("Nothing was done"), "{said}");
3465            assert!(said.contains("did:key:zEarlier"), "{said}");
3466            assert!(
3467                said.contains("pnm rooms owner revoke --room did:room"),
3468                "{said}"
3469            );
3470            assert!(said.contains("again to admit did:key:zNew"), "{said}");
3471        }
3472        let own = rejected(
3473            "rooms/owner/group/add:hostRefused",
3474            json!({ "code": "permissionDenied", "reason": "not today" }),
3475        );
3476        assert!(earlier_removal_refused(&own, "r", "h", None, "x").is_none());
3477        let kept_not = rejected(
3478            "rooms/owner/group/renew:earlierRemovalRefused",
3479            json!({ "member": "did:key:zEarlier", "code": "rooms/revocations/put:listInvalid",
3480                "listKept": false }),
3481        );
3482        let said = earlier_removal_refused(&kept_not, "r", "h", None, "x").unwrap();
3483        assert!(said.contains("discarded"), "{said}");
3484    }
3485
3486    /// `vtc/rooms/memberships/list/0.1`'s table: the same answers, the same standing.
3487    #[test]
3488    fn a_rooms_condition_says_what_its_state_means() {
3489        assert_eq!(room_condition("active", None, None), None);
3490        assert!(
3491            room_condition("archived", Some("readOnly"), None)
3492                .unwrap()
3493                .contains("read-only")
3494        );
3495        assert!(
3496            room_condition("archived", Some("noAccess"), None)
3497                .unwrap()
3498                .contains("pnm rooms offers renew")
3499        );
3500        assert!(
3501            room_condition(
3502                "removalScheduled",
3503                Some("noAccess"),
3504                Some("2026-12-02T10:00:00Z")
3505            )
3506            .unwrap()
3507            .contains("2026-12-02T10:00:00Z")
3508        );
3509        assert!(
3510            room_condition("removed", None, None)
3511                .unwrap()
3512                .starts_with("Removed")
3513        );
3514    }
3515
3516    #[test]
3517    fn a_rooms_standing_follows_the_one_record() {
3518        use RoomStanding::*;
3519        assert_eq!(RoomStanding::of("offered", true, false, false), Offered);
3520        assert_eq!(RoomStanding::of("admitted", false, false, false), Joined);
3521        assert_eq!(RoomStanding::of("admitted", true, true, true), Joined);
3522        assert_eq!(RoomStanding::of("admitted", true, false, true), Unfinished);
3523        assert_eq!(
3524            RoomStanding::of("admitted", true, false, false),
3525            NeedsReadmission
3526        );
3527        assert_eq!(RoomStanding::of("removed", true, true, false), Removed);
3528        // 0.2: an admission the room's closure withdrew, whatever the custody.
3529        assert_eq!(RoomStanding::of("suspended", true, true, true), Suspended);
3530        assert_eq!(
3531            RoomStanding::of("suspended", false, false, false),
3532            Suspended
3533        );
3534    }
3535
3536    fn minted(authority: Value) -> Value {
3537        json!({
3538            "presentation": {
3539                "membership": "vmc-blob",
3540                "authority": authority,
3541            },
3542            "expiresAt": "2026-09-07T16:00:00Z",
3543        })
3544    }
3545
3546    fn vtc_target() -> RoomTarget<'static> {
3547        RoomTarget {
3548            host_url: "https://community.example/v1",
3549            host_did: Some("did:webvh:community.example"),
3550            room_id: "did:webvh:example.com:rooms:northwind",
3551        }
3552    }
3553
3554    const CATCH_UP: &str = "pnm rooms keys commit --room did:webvh:example.com:rooms:northwind \
3555                            --host https://community.example/v1 --host-did did:webvh:community.example";
3556
3557    /// An offer's accept line carries the offer's own room DID and runs as pasted.
3558    #[test]
3559    fn an_offer_prints_its_accept_command_with_the_room_filled_in() {
3560        let target = OfferTarget {
3561            vtc_url: "https://vtc.example/v1",
3562            vtc_did: "did:webvh:Qm1:vtc.example",
3563            member: "did:webvh:Qm2:alice.example",
3564        };
3565        assert_eq!(
3566            accept_command("did:webvh:Qm3:vtc.example:room-dids:ab12", &target),
3567            "pnm rooms offers accept --room did:webvh:Qm3:vtc.example:room-dids:ab12 \
3568             --vtc https://vtc.example/v1 --vtc-did did:webvh:Qm1:vtc.example \
3569             --member did:webvh:Qm2:alice.example"
3570        );
3571    }
3572
3573    fn offer_v2(description: Option<&str>) -> Value {
3574        let mut o = json!({
3575            "roomId": "did:webvh:Qm3:vtc.example:room-dids:ab12",
3576            "host": "did:webvh:Qm1:vtc.example",
3577            "label": "Board\u{1b}[2J papers",
3578            "visibility": "private",
3579            "actions": ["read", "write"],
3580            "validUntil": "2026-12-01T00:00:00Z",
3581            "offeredAt": "2026-10-01T00:00:00Z",
3582            "state": "open",
3583            "credentials": {
3584                "membership": "{}",
3585                "authority": "{}",
3586                "invitation": "inv"
3587            }
3588        });
3589        if let Some(d) = description {
3590            o["description"] = json!(d);
3591        }
3592        o
3593    }
3594
3595    fn offer_target() -> OfferTarget<'static> {
3596        OfferTarget {
3597            vtc_url: "https://vtc.example/v1",
3598            vtc_did: "did:webvh:Qm1:vtc.example",
3599            member: "did:webvh:Qm2:alice.example",
3600        }
3601    }
3602
3603    /// `vtc/rooms/offers/list/0.2`: an offer shows what its room is for, on one line of its
3604    /// own, with every control character the community wrote replaced — a line break
3605    /// cannot forge a line of this client's, an escape cannot reach the terminal.
3606    #[test]
3607    fn an_offer_shows_its_description_on_one_inert_line() {
3608        let offer: CommunityOffer = serde_json::from_value(offer_v2(Some(
3609            "Papers for the board:\nagenda\tminutes\u{7}\r\n  accept: rm -rf ~",
3610        )))
3611        .unwrap();
3612        let text = offer_lines(&offer, &offer_target());
3613        let lines: Vec<&str> = text.lines().collect();
3614        assert_eq!(lines.len(), 3, "{text}");
3615        assert!(lines[0].contains("Board[2J papers"), "{text}");
3616        assert!(
3617            !text.contains('\u{1b}') && !text.contains('\u{7}'),
3618            "{text}"
3619        );
3620        assert_eq!(
3621            lines[1],
3622            "  about: Papers for the board: agenda minutes     accept: rm -rf ~"
3623        );
3624        assert!(
3625            lines[2].starts_with("  accept: pnm rooms offers accept"),
3626            "{text}"
3627        );
3628
3629        // A room without a description (or an offer read at 0.1) prints no `about` line.
3630        let bare: CommunityOffer = serde_json::from_value(offer_v2(None)).unwrap();
3631        assert!(!offer_lines(&bare, &offer_target()).contains("about:"));
3632    }
3633
3634    /// `vtc/rooms/offers/list/0.4`: the offers, and the renewals of rooms the member is in
3635    /// (`renewsAt`), each printed with the command that collects it.
3636    #[test]
3637    fn an_offers_list_0_4_page_carries_renewals_and_their_renew_command() {
3638        let (items, _, renewals) = offers_page(json!({
3639            "items": [offer_v2(None)],
3640            "renewals": [{
3641                "roomId": "did:webvh:Qm:rooms.example:wg", "host": "did:example:community",
3642                "label": "Working group", "visibility": "attributed", "actions": ["read"],
3643                "validUntil": "2027-11-10T00:00:00Z", "renewsAt": "2026-11-10T00:00:00Z",
3644                "issuedAt": "2026-10-20T09:00:00Z",
3645                "credentials": { "membership": "{}", "authority": "{}" },
3646            }],
3647        }))
3648        .expect("a 0.4 page");
3649        assert_eq!(items.len(), 1);
3650        assert_eq!(renewals.len(), 1);
3651        let lines = renewal_lines(&renewals[0], &offer_target());
3652        assert!(lines.contains("renewal  until 2027-11-10"), "{lines}");
3653        assert!(
3654            lines.contains("pnm rooms offers renew --room did:webvh:Qm:rooms.example:wg"),
3655            "{lines}"
3656        );
3657    }
3658
3659    /// A room DID is the community's text: anything a shell would act on is quoted.
3660    #[test]
3661    fn a_room_id_a_shell_would_read_is_quoted() {
3662        assert_eq!(shell_word("did:web:x"), "did:web:x");
3663        assert_eq!(shell_word("a;rm -rf ~"), "'a;rm -rf ~'");
3664        assert_eq!(shell_word("it's"), r"'it'\''s'");
3665        assert_eq!(shell_word("$(x)"), "'$(x)'");
3666        assert_eq!(shell_word(""), "''");
3667    }
3668
3669    /// A put sealed under a superseded epoch (`epochMismatch`, read off the host's
3670    /// `trust-task-error` document) names the catch-up command with the real values, not
3671    /// the raw HTTP body.
3672    #[test]
3673    fn a_superseded_epoch_put_prints_keys_commit_with_the_real_values() {
3674        let body = json!({
3675            "type": "https://trusttasks.org/spec/rooms/records/put/0.1#error",
3676            "payload": {
3677                "code": "rooms/records/put:epochMismatch",
3678                "message": "record is sealed under epoch 2, room `r` is at 3",
3679            },
3680        });
3681        let err: Box<dyn std::error::Error> = Box::new(vtc_client::VtcError::Http {
3682            status: 400,
3683            body: body.to_string(),
3684        });
3685        let text = put_refused(err, &vtc_target()).to_string();
3686        assert!(text.contains(CATCH_UP), "{text}");
3687        assert!(text.contains("sealed under epoch 2"), "{text}");
3688        assert!(!text.contains("HTTP 400"), "{text}");
3689    }
3690
3691    /// Any other refusal passes through untouched, and no `--host-did` is invented for a
3692    /// host the operator named none for.
3693    #[test]
3694    fn other_put_refusals_pass_through_and_no_host_did_is_invented() {
3695        let t = vtc_target();
3696        assert!(put_refusal_hint("rooms/records/put:recordTooLarge", "", &t).is_none());
3697        let err: Box<dyn std::error::Error> = Box::new(vtc_client::VtcError::Http {
3698            status: 500,
3699            body: "not a document".into(),
3700        });
3701        assert_eq!(
3702            put_refused(err, &t).to_string(),
3703            "VTC returned HTTP 500: not a document"
3704        );
3705
3706        let bare = RoomTarget {
3707            host_did: None,
3708            ..vtc_target()
3709        };
3710        assert_eq!(
3711            keys_commit_command(&bare),
3712            "pnm rooms keys commit --room did:webvh:example.com:rooms:northwind \
3713             --host https://community.example/v1"
3714        );
3715    }
3716
3717    fn open_rejected(reason: &str, extra: Value) -> VtaError {
3718        let mut details = json!({ "reason": reason });
3719        details
3720            .as_object_mut()
3721            .unwrap()
3722            .extend(extra.as_object().unwrap().clone());
3723        VtaError::Rejected {
3724            code: "rooms/keys/open:unknownEpoch".into(),
3725            message: "record is sealed under epoch 4".into(),
3726            details: Some(details),
3727        }
3728    }
3729
3730    /// A member removed from the group is told so, and is not sent to `keys commit`,
3731    /// which can never open what was sealed after the removal.
3732    #[test]
3733    fn a_removed_member_is_told_it_was_removed_not_to_catch_up() {
3734        let text = open_refusal_hint(
3735            &open_rejected("removed", json!({ "removedAtEpoch": 3 })),
3736            &vtc_target(),
3737        );
3738        assert!(
3739            text.contains("You were removed from this room's group at epoch 3"),
3740            "{text}"
3741        );
3742        assert!(!text.contains("keys commit"), "{text}");
3743    }
3744
3745    fn file_key_rejected(reason: &str, message: &str) -> VtaError {
3746        VtaError::Rejected {
3747            code: "rooms/keys/file-key:unknownEpoch".into(),
3748            message: message.into(),
3749            details: Some(json!({ "reason": reason })),
3750        }
3751    }
3752
3753    /// `pnm rooms file get` names the same remedies a record's refusal does. A removed
3754    /// member's file comes as `removed` (`rooms/keys/file-key/0.2`), and the operator is
3755    /// told so and not sent to `keys commit` — the caller MUST NOT retry after catching up.
3756    #[test]
3757    fn a_file_key_refusal_names_the_remedy() {
3758        let removed = VtaError::Rejected {
3759            code: "rooms/keys/file-key:unknownEpoch".into(),
3760            message: "the file is sealed under epoch 4, and this member was removed".into(),
3761            details: Some(json!({ "reason": "removed", "removedAtEpoch": 3 })),
3762        };
3763        let text = file_key_refusal_hint(&removed, &vtc_target());
3764        assert!(
3765            text.contains("You were removed from this room's group at epoch 3"),
3766            "{text}"
3767        );
3768        assert!(text.contains("Files sealed since then"), "{text}");
3769        assert!(!text.contains("keys commit"), "{text}");
3770
3771        // Only behind: catch up.
3772        let behind_commit = file_key_refusal_hint(
3773            &file_key_rejected("notDelivered", "the file is sealed under epoch 4"),
3774            &vtc_target(),
3775        );
3776        assert!(behind_commit.contains(CATCH_UP), "{behind_commit}");
3777
3778        let behind = file_key_refusal_hint(
3779            &file_key_rejected("beyondChain", "sealed under epoch 1"),
3780            &vtc_target(),
3781        );
3782        assert!(behind.contains("pnm rooms keys backfill"), "{behind}");
3783        assert!(behind.contains("The file is older"), "{behind}");
3784
3785        // An `open` code is not a file-key one: the reason is not read across tasks.
3786        let wrong_task = VtaError::Rejected {
3787            code: "rooms/keys/open:unknownEpoch".into(),
3788            message: "x".into(),
3789            details: Some(json!({ "reason": "beyondChain" })),
3790        };
3791        assert!(
3792            file_key_refusal_hint(&wrong_task, &vtc_target()).contains("did not open with any key")
3793        );
3794    }
3795
3796    /// A member who is only behind gets the catch-up command with the real values.
3797    #[test]
3798    fn a_member_behind_is_told_to_catch_up() {
3799        let text = open_refusal_hint(
3800            &open_rejected("notDelivered", json!({ "heldEpoch": 2 })),
3801            &vtc_target(),
3802        );
3803        assert!(text.contains(CATCH_UP), "{text}");
3804    }
3805
3806    /// `pnm rooms renew` refuses a sealed room and names the command that renews it
3807    /// (rooms/keys/act/0.3 producer item 4); an open room renews as before.
3808    #[test]
3809    fn renew_refuses_a_sealed_room_and_names_group_renew() {
3810        let target = RoomTarget {
3811            host_url: "https://community.example/v1",
3812            host_did: Some("did:webvh:community.example"),
3813            room_id: "did:webvh:example.com:rooms:northwind",
3814        };
3815        assert!(refuse_sealed_renew("open", &target).is_ok());
3816        for tier in ["attributed", "private"] {
3817            let e = refuse_sealed_renew(tier, &target).expect_err(tier);
3818            assert!(
3819                e.contains(
3820                    "pnm rooms owner group-renew --room did:webvh:example.com:rooms:northwind \
3821                     --host-did did:webvh:community.example"
3822                ),
3823                "{e}"
3824            );
3825            assert!(e.contains(tier), "{e}");
3826        }
3827        let no_did = RoomTarget {
3828            host_did: None,
3829            ..target
3830        };
3831        let e = refuse_sealed_renew("private", &no_did).unwrap_err();
3832        assert!(e.contains("--host-did <the host's DID>"), "{e}");
3833    }
3834
3835    #[test]
3836    fn a_minted_presentation_becomes_a_session() {
3837        let session = session_from_minted("did:webvh:room", &minted(json!(["leaf", "root"])))
3838            .expect("a well-formed reply rebuilds");
3839        assert_eq!(session.room_id(), "did:webvh:room");
3840        assert_eq!(session.chain_depth(), 2, "leaf first, root last");
3841    }
3842
3843    /// A private room's presentation carries the same-subject proof, and losing
3844    /// it on the way through would turn a valid call into a refusal the
3845    /// operator cannot explain.
3846    #[test]
3847    fn a_subject_binding_survives_the_rebuild() {
3848        let mut m = minted(json!(["leaf"]));
3849        m["presentation"]["subjectBinding"] = json!("zk-proof-blob");
3850        let session =
3851            session_from_minted("did:webvh:room", &m).expect("a private presentation rebuilds");
3852        assert_eq!(session.chain_depth(), 1);
3853    }
3854
3855    /// Each missing member is refused rather than defaulted. A reply this
3856    /// client does not understand must not become a request the host refuses
3857    /// for reasons that read as a credential problem.
3858    #[test]
3859    fn an_unreadable_reply_is_refused_rather_than_guessed() {
3860        assert!(
3861            session_from_minted("r", &json!({})).is_err(),
3862            "no presentation"
3863        );
3864        assert!(
3865            session_from_minted("r", &json!({ "presentation": { "authority": ["a"] } })).is_err(),
3866            "no membership"
3867        );
3868        assert!(
3869            session_from_minted("r", &json!({ "presentation": { "membership": "m" } })).is_err(),
3870            "no chain"
3871        );
3872        assert!(
3873            session_from_minted("r", &minted(json!([]))).is_err(),
3874            "an empty chain authorizes nothing and must not be sent"
3875        );
3876    }
3877
3878    /// A link that is not a string would otherwise be filtered out, silently
3879    /// shortening the chain — and a shorter chain confers less than the VTA
3880    /// minted, so the call would fail somewhere far from the cause.
3881    #[test]
3882    fn a_malformed_link_does_not_silently_shorten_the_chain() {
3883        let err = session_from_minted("r", &minted(json!(["leaf", 7])))
3884            .expect_err("a non-string link is refused");
3885        assert!(
3886            err.to_string().contains("non-string"),
3887            "the error must name the cause: {err}"
3888        );
3889    }
3890
3891    /// Another member's text reaches a terminal: an escape sequence in it must not.
3892    #[test]
3893    fn untrusted_text_loses_its_control_characters_but_keeps_its_lines() {
3894        assert_eq!(inert("a\x1b[2Jb\nc\td\x07"), "a[2Jb\nc\td");
3895    }
3896
3897    /// A file name chosen by another member must not choose where it is written.
3898    #[test]
3899    fn a_file_name_cannot_leave_the_directory_or_hide() {
3900        assert_eq!(local_name("../../etc/passwd"), "passwd");
3901        assert_eq!(local_name("C:\\Users\\x\\evil.exe"), "evil.exe");
3902        assert_eq!(local_name(".bashrc"), "bashrc");
3903        assert_eq!(local_name("term sheet.pdf"), "term sheet.pdf");
3904        assert_eq!(local_name("../"), "room-file");
3905        assert_eq!(local_name("a\nb.txt"), "a b.txt");
3906    }
3907
3908    /// Record keys on a sealed tier say nothing, and do not repeat.
3909    #[test]
3910    fn opaque_keys_are_fresh_and_uninformative() {
3911        let (a, b) = (opaque_key(), opaque_key());
3912        assert_ne!(a, b);
3913        assert_eq!(a.len(), 22, "16 random bytes, base64url");
3914    }
3915
3916    #[test]
3917    fn pin_flags_keep_unchanged_distinct_from_unpinned() {
3918        assert_eq!(pinned_from_flags(true, false), Some(true));
3919        assert_eq!(pinned_from_flags(false, true), Some(false));
3920        assert_eq!(
3921            pinned_from_flags(false, false),
3922            None,
3923            "neither flag must leave the pin alone, not clear it"
3924        );
3925    }
3926}