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.8`), 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.8`, 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.8`
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    use trust_tasks_rs::Payload as _;
1478    let (member, reply) = act_and_send(
1479        client,
1480        target,
1481        vti_specs::rooms::info::v0_3::Payload::TYPE_URI,
1482        serde_json::json!({ "roomId": target.room_id }),
1483    )
1484    .await
1485    .map_err(|e| format!("could not read the room's tier (rooms/info): {e}"))?;
1486    let visibility = reply
1487        .get("visibility")
1488        .and_then(Value::as_str)
1489        .ok_or("the host's rooms/info response names no visibility")?;
1490    Ok((visibility != "open", member))
1491}
1492
1493/// Have the VTA sign `content` **as the member** and seal it (`rooms/keys/seal/0.2`), and
1494/// put it as the member (`rooms/keys/act/0.8`), re-sealing at the version the host assigned
1495/// whenever that is not the one sealed for. Returns the version written.
1496///
1497/// The record's associated data and its signed body both name the version, and the host
1498/// assigns it from a room-wide counter, so the version is predicted from the room's head and
1499/// a miss is corrected by a rewrite. Until that rewrite lands the record does not open,
1500/// which is why a write that runs out of attempts says so and names the key.
1501async fn write_sealed(
1502    client: &VtaClient,
1503    target: &RoomTarget<'_>,
1504    content: SealedRecordBody,
1505    expected_version: Option<u64>,
1506) -> Result<u64, Box<dyn std::error::Error>> {
1507    write_sealed_naming(client, target, content, expected_version, None).await
1508}
1509
1510/// [`write_sealed`], naming `blobs` committed in the room (`rooms/records/put/0.2`).
1511async fn write_sealed_naming(
1512    client: &VtaClient,
1513    target: &RoomTarget<'_>,
1514    content: SealedRecordBody,
1515    expected_version: Option<u64>,
1516    blobs: Option<Vec<String>>,
1517) -> Result<u64, Box<dyn std::error::Error>> {
1518    let head = list_page(client, target, None, None, None)
1519        .await?
1520        .head_version
1521        .unwrap_or(0);
1522
1523    let key = content.key.clone();
1524    let mut intended = head + 1;
1525    let mut expected = expected_version;
1526    let mut landed = 0;
1527    for _ in 0..SEALED_WRITE_ATTEMPTS {
1528        // Unsigned: the VTA signs it as its author, the member, before sealing.
1529        let unsigned = serde_json::to_vec(&content.clone().at_version(intended))?;
1530        let sealed = client
1531            .room_seal_as_principal(target.room_id, &key, intended, &unsigned)
1532            .await?
1533            .sealed;
1534        let mut payload = serde_json::json!({
1535            "roomId": target.room_id,
1536            "key": key,
1537            "sealed": {
1538                "ciphertext": sealed.ciphertext,
1539                "nonce": sealed.nonce,
1540                "epoch": sealed.epoch.get(),
1541            },
1542        });
1543        if let Some(v) = expected {
1544            payload["expectedVersion"] = serde_json::json!(v);
1545        }
1546        if let Some(b) = &blobs {
1547            payload["blobs"] = serde_json::json!(b);
1548        }
1549        let put_type = vti_rooms::wire::ROOMS_RECORDS_PUT_V3_TYPE;
1550        let (_, reply) = act_and_send(client, target, put_type, payload)
1551            .await
1552            .map_err(|e| put_refused(e, target))?;
1553        let put: vti_rooms::wire::PutRecordResponse = parse_reply("rooms/records/put", reply)?;
1554        landed = put.version;
1555        if landed == intended {
1556            return Ok(landed);
1557        }
1558        // Another write took the predicted version. The record is stored but sealed for the
1559        // wrong one, so it does not open; rewrite it, and only over what just landed.
1560        expected = Some(landed);
1561        intended = landed + 1;
1562    }
1563    Err(format!(
1564        "{key} is stored at version {landed} but sealed for another version, so it does not \
1565         open: the room kept moving under {SEALED_WRITE_ATTEMPTS} attempts. Write it again \
1566         with --expected-version {landed}."
1567    )
1568    .into())
1569}
1570
1571/// The `pnm rooms keys commit` that brings this VTA's group up to the room's epoch,
1572/// with the target's own values. `--host-did` only when the operator gave one: a VTC
1573/// needs it, another host may not have one to give.
1574fn keys_commit_command(target: &RoomTarget<'_>) -> String {
1575    let mut cmd = format!(
1576        "pnm rooms keys commit --room {} --host {}",
1577        target.room_id, target.host_url
1578    );
1579    if let Some(did) = target.host_did {
1580        cmd.push_str(&format!(" --host-did {did}"));
1581    }
1582    cmd
1583}
1584
1585/// `rooms/records/put` refused `e`: when the host says the record was sealed under an
1586/// epoch the room has moved past (the declared `epochMismatch`), say what happened and
1587/// print the command that catches up. Anything else is returned as it was.
1588fn put_refused(
1589    e: Box<dyn std::error::Error>,
1590    target: &RoomTarget<'_>,
1591) -> Box<dyn std::error::Error> {
1592    let refusal = e
1593        .downcast_ref::<vtc_client::VtcError>()
1594        .and_then(vtc_client::git_ns::task_error);
1595    match refusal {
1596        Some((code, message)) => match put_refusal_hint(&code, &message, target) {
1597            Some(hint) => hint.into(),
1598            None => e,
1599        },
1600        None => e,
1601    }
1602}
1603
1604/// The operator-facing text for a `rooms/records/put` refusal `code`, when there is one.
1605fn put_refusal_hint(code: &str, message: &str, target: &RoomTarget<'_>) -> Option<String> {
1606    use vti_specs::rooms::records::put;
1607    let epoch_mismatch = [
1608        put::v0_1::error_codes::EPOCH_MISMATCH.code,
1609        put::v0_2::error_codes::EPOCH_MISMATCH.code,
1610    ];
1611    if !epoch_mismatch.contains(&code) {
1612        return None;
1613    }
1614    Some(format!(
1615        "the host refused the record: {message}\n\n\
1616         The room has moved on to a newer epoch — it was renewed, or its membership changed — \
1617         and this VTA has not applied the commit that moved it, so it sealed the record under \
1618         the old epoch. Nothing was written. Catch up, then write again:\n\n  {}",
1619        keys_commit_command(target)
1620    ))
1621}
1622
1623/// The operator-facing text for a `rooms/keys/open` refusal.
1624fn open_refusal_hint(e: &VtaError, target: &RoomTarget<'_>) -> String {
1625    use vti_specs::rooms::keys::open::v0_2::error_codes::UNKNOWN_EPOCH;
1626    epoch_refusal_hint(e, target, UNKNOWN_EPOCH.code, Sealed::Record)
1627}
1628
1629/// The operator-facing text for a `rooms/keys/file-key` refusal, as `pnm rooms file
1630/// get` and `file open` print it — the same remedies a record's refusal names.
1631fn file_key_refusal_hint(e: &VtaError, target: &RoomTarget<'_>) -> String {
1632    use vti_specs::rooms::keys::file_key::v0_2::error_codes::UNKNOWN_EPOCH;
1633    epoch_refusal_hint(e, target, UNKNOWN_EPOCH.code, Sealed::File)
1634}
1635
1636/// What a refused epoch had sealed, for the wording of [`epoch_refusal_hint`].
1637#[derive(Clone, Copy)]
1638enum Sealed {
1639    Record,
1640    File,
1641}
1642
1643impl Sealed {
1644    fn noun(self) -> &'static str {
1645        match self {
1646            Self::Record => "record",
1647            Self::File => "file",
1648        }
1649    }
1650}
1651
1652/// The remedy for a refused epoch, from the declared `unknownEpoch` (`code`) and its
1653/// `details.reason`.
1654///
1655/// The epoch cases have opposite remedies, so they are told apart by reason —
1656/// `notDelivered` (catch up), `beyondChain` (backfill) and `removed` (no commit will
1657/// help: `rooms/keys/{open,file-key}/0.2` say a caller MUST NOT retry after catching up)
1658/// — never by the message. The text checks are only for a refusal that carries no reason.
1659fn epoch_refusal_hint(e: &VtaError, target: &RoomTarget<'_>, code: &str, sealed: Sealed) -> String {
1660    let noun = sealed.noun();
1661    let text = e.to_string();
1662    let reason = (e.rejection_code() == Some(code))
1663        .then(|| e.rejection_details())
1664        .flatten()
1665        .and_then(|d| d.get("reason"))
1666        .and_then(Value::as_str);
1667    let backfill = || {
1668        format!(
1669            "The {noun} is older than any key this VTA holds — it was written before you \
1670             joined. Fetch the room's history: `pnm rooms keys backfill --room {} --host-did \
1671             {}`.",
1672            target.room_id,
1673            target.host_did.unwrap_or("<host-did>")
1674        )
1675    };
1676    let hint = match reason {
1677        Some("removed") => {
1678            let at = e
1679                .rejection_details()
1680                .and_then(|d| d.get("removedAtEpoch"))
1681                .and_then(Value::as_u64)
1682                .map(|n| format!(" at epoch {n}"))
1683                .unwrap_or_default();
1684            format!(
1685                "You were removed from this room's group{at}. {}s sealed since then will \
1686                 never open for you, and no commit will change that; what was sealed before \
1687                 your removal still opens. Rejoining takes a fresh invitation from the room's \
1688                 owner.",
1689                capitalised(noun)
1690            )
1691        }
1692        Some("notDelivered") => format!(
1693            "The {noun} is newer than this VTA's group: a commit has not been applied here. \
1694             Catch up, then try again:\n\n  {}",
1695            keys_commit_command(target),
1696        ),
1697        Some("beyondChain") => backfill(),
1698        _ if text.contains("cannot reach") => backfill(),
1699        _ if text.contains("has not been delivered") => format!(
1700            "The {noun} is newer than this VTA's group. Catch up with `{}`; if that says you \
1701             were removed, nothing sealed after your removal will open.",
1702            keys_commit_command(target)
1703        ),
1704        _ => format!("The {noun} did not open with any key this VTA holds."),
1705    };
1706    format!("{e}\n\n{hint}")
1707}
1708
1709fn capitalised(word: &str) -> String {
1710    let mut chars = word.chars();
1711    chars
1712        .next()
1713        .map(|c| c.to_uppercase().chain(chars).collect())
1714        .unwrap_or_default()
1715}
1716
1717/// Open a sealed record through the VTA and check its body before anything in it is used.
1718///
1719/// `version` is the record's; the body is opened, and its signed location checked, at the
1720/// version it was **sealed** at — `sealed.version` where the host served one, because a
1721/// curation moves a record past its body. A record curated by a host that predates that
1722/// member carries none, and the VTA searches earlier versions and says which opened.
1723async fn open_and_verify(
1724    client: &VtaClient,
1725    target: &RoomTarget<'_>,
1726    key: &str,
1727    version: u64,
1728    sealed: &SealedContent,
1729    host_author: Option<&str>,
1730) -> Result<VerifiedRecordBody, Box<dyn std::error::Error>> {
1731    let sealed_at = sealed
1732        .sealed_at(version)
1733        .map_err(|why| format!("refusing record {key}: the host says {why}"))?;
1734    let opened = client
1735        .room_open(
1736            target.room_id,
1737            key,
1738            sealed_at,
1739            &sealed.ciphertext,
1740            &sealed.nonce,
1741            sealed.epoch,
1742        )
1743        .await
1744        // The failures operators will actually hit, named where they will read them. Each
1745        // reads like corruption if nobody says otherwise.
1746        .map_err(|e| open_refusal_hint(&e, target))?;
1747    let plaintext = opened
1748        .get("plaintext")
1749        .and_then(Value::as_str)
1750        .ok_or("the VTA opened the record but returned no plaintext")?;
1751    let bytes =
1752        base64::Engine::decode(&base64::engine::general_purpose::URL_SAFE_NO_PAD, plaintext)?;
1753
1754    let resolver = vta_sdk::trust_task_proof::TrustTaskVmResolver::from_optional(
1755        vta_sdk::resolver::shared_did_resolver_from_env().await.ok(),
1756    );
1757    // Where the VTA found it sealed, if not where it was asked to look.
1758    let opened_at = opened
1759        .pointer("/ext/org.openvtc.rooms/sealedAt")
1760        .and_then(Value::as_u64)
1761        .unwrap_or(sealed_at);
1762    if opened_at > sealed_at {
1763        return Err(format!(
1764            "refusing record {key}: the VTA opened it at version {opened_at}, past the {sealed_at} it was asked for"
1765        )
1766        .into());
1767    }
1768    let location = RecordLocation {
1769        room_id: target.room_id,
1770        key,
1771        version: opened_at,
1772    };
1773    Ok(verify_record_body(&bytes, location, host_author, &resolver)
1774        .await
1775        .map_err(|e| format!("refusing record {key}: {e}"))?)
1776}
1777
1778/// The part size a direct upload is cut into: one part up to [`SINGLE_PART_MAX`], else
1779/// parts of this size (storage's multipart floor is 5 MiB).
1780const DIRECT_PART: usize = 8 * 1024 * 1024;
1781/// The largest blob sent as one direct part.
1782const SINGLE_PART_MAX: usize = 16 * 1024 * 1024;
1783/// How many times a direct upload asks again for targets before giving up.
1784const DIRECT_BEGIN_ATTEMPTS: usize = 64;
1785
1786/// The part plan (`directParts`) for `ciphertext`: its parts, and each one's sha2-256.
1787fn direct_plan(ciphertext: &[u8]) -> (usize, Value) {
1788    use sha2::Digest as _;
1789    let part = if ciphertext.len() <= SINGLE_PART_MAX {
1790        ciphertext.len().max(1)
1791    } else {
1792        DIRECT_PART
1793    };
1794    let digests: Vec<String> = ciphertext
1795        .chunks(part)
1796        .map(|c| vti_common::blob_store::sha256_digest_multibase(&sha2::Sha256::digest(c).into()))
1797        .collect();
1798    (
1799        part,
1800        serde_json::json!({ "partSize": part, "partCount": digests.len(), "partDigests": digests }),
1801    )
1802}
1803
1804/// `rooms file put` — seal a local file, upload its ciphertext, and write the record that
1805/// names it, all **as the member** (`rooms/blobs/*/0.2` through `rooms/keys/act`).
1806///
1807/// The file is sealed here under one file's key from the VTA (`rooms/keys/file-key`); its
1808/// bytes never pass through the VTA, and the host receives only ciphertext. The member's
1809/// VTA signs the begin, the record and — in direct mode — the commit, as the member; the
1810/// bytes go straight to the room's storage over targets the host issued (direct) or to the
1811/// host as chunk documents signed by a single-use upload key minted here for this upload
1812/// alone (proxied). This client's own key signs nothing the host receives.
1813pub async fn cmd_rooms_file_put(
1814    client: &VtaClient,
1815    target: RoomTarget<'_>,
1816    path: &std::path::Path,
1817    title: Option<String>,
1818    description: Option<String>,
1819    tags: Vec<String>,
1820    padme: bool,
1821) -> Result<(), Box<dyn std::error::Error>> {
1822    use trust_tasks_rs::Payload as _;
1823    use vti_rooms::files::{self, FileBinding, Padding};
1824    use vti_specs::rooms::blobs::upload;
1825
1826    let (sealed_tier, member) = room_tier(client, &target).await?;
1827    if !sealed_tier {
1828        return Err(
1829            "this room is `open`: its records are cleartext, and a file's manifest \
1830                    belongs inside a sealed body. Files go in `attributed` and `private` rooms."
1831                .into(),
1832        );
1833    }
1834
1835    let plaintext = std::fs::read(path)?;
1836    let name = path
1837        .file_name()
1838        .and_then(|n| n.to_str())
1839        .ok_or("the file has no usable name")?;
1840    // A fresh id for every file: the segment nonces are counters, so two files
1841    // under one key would reuse them.
1842    let file_id = files::new_file_id();
1843    let resp = client
1844        .room_file_key(target.room_id, &files::encode_file_id(&file_id), None)
1845        .await?;
1846    let file_key = file_key_bytes(&resp)?;
1847    let sealed = files::seal_file(
1848        &file_key,
1849        FileBinding {
1850            room_id: target.room_id.to_string(),
1851            file_id,
1852            epoch: resp.epoch.get(),
1853            segment_size: files::DEFAULT_SEGMENT_SIZE,
1854        },
1855        name,
1856        None,
1857        &plaintext,
1858        if padme { Padding::Padme } else { Padding::None },
1859    )?;
1860    let ciphertext: Vec<u8> = sealed.chunks.concat();
1861
1862    // The upload key: minted here for this upload alone, from no key held for anything
1863    // else, and dropped when the upload ends.
1864    let (key_did, key_secret) = crate::local_keygen::generate_unbound_admin_did_key();
1865    let key_secret = zeroize::Zeroizing::new(key_secret);
1866    let (part_size, plan) = direct_plan(&ciphertext);
1867    let begin_payload = serde_json::json!({
1868        "roomId": target.room_id,
1869        "manifest": sealed.blob,
1870        "uploadKey": key_did,
1871        "directParts": plan,
1872    });
1873    let begin = || async {
1874        let (_, reply) = act_and_send(
1875            client,
1876            &target,
1877            upload::begin::v0_2::Payload::TYPE_URI,
1878            begin_payload.clone(),
1879        )
1880        .await?;
1881        parse_reply::<upload::begin::v0_2::Response>("rooms/blobs/upload/begin", reply)
1882    };
1883    let mut begun = begin().await?;
1884    let direct = wire_str(&begun.mode) == "direct";
1885    let upload_id = begun.upload_id.to_string();
1886    let host = target.client();
1887
1888    let sent: Result<(), Box<dyn std::error::Error>> = async {
1889        if begun.already_committed == Some(true) {
1890            return Ok(());
1891        }
1892        if direct {
1893            // Each part to its own target, with exactly the headers it names; begin again,
1894            // with the identical manifest and plan, for parts beyond the first batch or
1895            // whose target lapsed.
1896            for _ in 0..DIRECT_BEGIN_ATTEMPTS {
1897                if begun.missing.is_empty() {
1898                    return Ok(());
1899                }
1900                if begun.targets.is_empty() {
1901                    return Err("the host answered direct mode with no targets".into());
1902                }
1903                for t in &begun.targets {
1904                    let i = usize::try_from(t.index)
1905                        .ok()
1906                        .filter(|i| i * part_size < ciphertext.len().max(1))
1907                        .ok_or("the host issued a target for a part that does not exist")?;
1908                    let part =
1909                        &ciphertext[i * part_size..((i + 1) * part_size).min(ciphertext.len())];
1910                    vtc_client::rooms::files::put_part(t, part).await?;
1911                }
1912                begun = begin().await?;
1913            }
1914            return Err("the upload did not finish: the host kept asking for parts".into());
1915        }
1916        for index in &begun.missing {
1917            let i = usize::try_from(index.0)
1918                .ok()
1919                .filter(|i| *i < sealed.chunks.len())
1920                .ok_or_else(|| {
1921                    format!("the host asked for chunk {}, which does not exist", index.0)
1922                })?;
1923            host.upload_chunk_v2(
1924                &upload_id,
1925                i as u64,
1926                &sealed.chunks[i],
1927                &key_did,
1928                &key_secret,
1929            )
1930            .await?;
1931        }
1932        Ok(())
1933    }
1934    .await;
1935
1936    // Commit and abort: the upload key's (proxied), or the member's own (direct).
1937    let finish = |type_uri: &'static str| {
1938        let (upload_id, key_did, key_secret, member) = (
1939            upload_id.clone(),
1940            key_did.clone(),
1941            key_secret.clone(),
1942            member.clone(),
1943        );
1944        let target = &target;
1945        async move {
1946            let payload = serde_json::json!({ "uploadId": upload_id });
1947            if direct {
1948                act_and_send_as(client, target, type_uri, payload, Some(&member))
1949                    .await
1950                    .map(|(_, reply)| reply)
1951            } else {
1952                let doc = vta_sdk::trust_task_sign::build_signed(
1953                    type_uri,
1954                    payload,
1955                    &key_did,
1956                    &key_secret,
1957                    target.host_did.unwrap_or("did:key:zHost"),
1958                )
1959                .await
1960                .map_err(|e| format!("could not sign with the upload key: {e}"))?;
1961                let doc: Value = serde_json::from_str(&doc)?;
1962                Ok(target.client().send_signed_room_document(&doc).await?)
1963            }
1964        }
1965    };
1966    if let Err(e) = sent {
1967        // Release what `begin` reserved rather than leaving it to expire.
1968        let _ = finish(upload::abort::v0_2::Payload::TYPE_URI).await;
1969        return Err(e);
1970    }
1971    let committed: upload::commit::v0_2::Response = parse_reply(
1972        "rooms/blobs/upload/commit",
1973        finish(upload::commit::v0_2::Payload::TYPE_URI).await?,
1974    )?;
1975    let blob_ref = wire_str(&committed.blob_ref);
1976    if !files::same_digest(&blob_ref, &sealed.file.blob_ref) {
1977        return Err(format!(
1978            "the host committed {blob_ref}, not the blob sealed here ({})",
1979            sealed.file.blob_ref
1980        )
1981        .into());
1982    }
1983
1984    let key = opaque_key();
1985    let mut content = SealedRecordBody::new(
1986        RecordLocation {
1987            room_id: target.room_id,
1988            key: &key,
1989            version: 1,
1990        },
1991        &member,
1992    );
1993    content.title = title;
1994    content.description = description;
1995    content.tags = tags;
1996    content.file = Some(sealed.file.clone());
1997    let version = write_sealed_naming(
1998        client,
1999        &target,
2000        content,
2001        Some(0),
2002        Some(vec![blob_ref.clone()]),
2003    )
2004    .await?;
2005
2006    if crate::render::is_json_output() {
2007        crate::render::print_json(&serde_json::json!({
2008            "key": key,
2009            "version": version,
2010            "blobRef": blob_ref,
2011            "size": sealed.file.size,
2012            "mode": if direct { "direct" } else { "proxied" },
2013        }))?;
2014        return Ok(());
2015    }
2016    println!(
2017        "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}`.",
2018        sealed.file.size,
2019        if direct {
2020            "straight to the room's storage"
2021        } else {
2022            "through the host"
2023        }
2024    );
2025    Ok(())
2026}
2027
2028/// A local file name from one another member chose: the last path component, without
2029/// leading dots, never empty.
2030fn local_name(untrusted: &str) -> String {
2031    let base = untrusted
2032        .rsplit(['/', '\\'])
2033        .next()
2034        .unwrap_or_default()
2035        .trim_start_matches('.');
2036    let clean = inert(base).replace(['\n', '\t'], " ");
2037    if clean.trim().is_empty() {
2038        "room-file".to_string()
2039    } else {
2040        clean
2041    }
2042}
2043
2044/// `rooms file get` — fetch a record's file **as the member**, check it against what its
2045/// author signed, and write the plaintext.
2046///
2047/// The record and the blob are asked for through the member's VTA (`rooms/keys/act`); the
2048/// bytes come straight from the room's storage when the host offers it (`direct`, a ranged
2049/// read per chunk), or chunk by chunk through the host, each request signed as the member.
2050/// Nothing is written unless every check holds: the record body's signature and location,
2051/// the blob being the one the author named, every chunk against the manifest, and the
2052/// plaintext against the author's digest.
2053pub async fn cmd_rooms_file_get(
2054    client: &VtaClient,
2055    target: RoomTarget<'_>,
2056    key: &str,
2057    out: Option<&std::path::Path>,
2058) -> Result<(), Box<dyn std::error::Error>> {
2059    use trust_tasks_rs::Payload as _;
2060    use vti_rooms::files::{self, BlobManifest};
2061    use vti_specs::rooms::blobs::{chunk as blob_chunk, get as blob_get};
2062
2063    let (member, record) = act_and_send(
2064        client,
2065        &target,
2066        vti_rooms::wire::ROOMS_RECORDS_GET_V2_TYPE,
2067        serde_json::json!({ "roomId": target.room_id, "key": key }),
2068    )
2069    .await?;
2070    let record: vtc_client::rooms::files::GetRecordV2Response =
2071        parse_reply("rooms/records/get", record)?;
2072    let sealed = record.sealed.as_ref().ok_or(
2073        "the record has no sealed body: it is on an `open` room, or it has been retracted",
2074    )?;
2075    let sealed = SealedContent {
2076        ciphertext: sealed.ciphertext.clone(),
2077        nonce: sealed.nonce.clone(),
2078        epoch: u32::try_from(sealed.epoch.get())
2079            .map_err(|_| "the record's epoch is beyond what this client can represent")?,
2080        // The version the body was sealed at, where a curation moved the record past it
2081        // (rooms `_shared/0.1` `SealedRecord.version`): opened and verified there, and a
2082        // `sealed.version` above the record's own refused by `open_and_verify`.
2083        version: sealed.version.map(std::num::NonZeroU64::get),
2084    };
2085    let verified = open_and_verify(
2086        client,
2087        &target,
2088        key,
2089        record.version.get(),
2090        &sealed,
2091        record.author.as_deref(),
2092    )
2093    .await?;
2094    let file = verified
2095        .body()
2096        .file
2097        .clone()
2098        .ok_or("the record carries no file")?;
2099    let named = record
2100        .blobs
2101        .iter()
2102        .flatten()
2103        .any(|b| files::same_digest(&wire_str(b), &file.blob_ref));
2104    if !named {
2105        return Err(
2106            "the host does not name the file's blob on this record; it may have been \
2107                    released"
2108                .into(),
2109        );
2110    }
2111
2112    let (_, started) = act_and_send(
2113        client,
2114        &target,
2115        blob_get::v0_2::Payload::TYPE_URI,
2116        serde_json::json!({ "roomId": target.room_id, "blobRef": file.blob_ref, "direct": true }),
2117    )
2118    .await?;
2119    let started: blob_get::v0_2::Response = parse_reply("rooms/blobs/get", started)?;
2120    let blob: BlobManifest = serde_json::from_value(serde_json::to_value(&started.manifest)?)?;
2121    // The file manifest is the author's; the blob manifest is the host's. Before a byte is
2122    // trusted: it is the blob the author named, and its shape is the one the author's
2123    // segment size and length give.
2124    file.check_blob(&blob)?;
2125    let size = blob.size;
2126    let chunk_size = blob.chunks.chunk_size;
2127    let mut chunks = Vec::with_capacity(blob.chunks.chunk_digests.len());
2128    for (i, expected) in blob.chunks.chunk_digests.iter().enumerate() {
2129        let chunk = match (&started.target, &started.download_id) {
2130            (Some(t), _) => {
2131                let start = i as u64 * chunk_size;
2132                vtc_client::rooms::files::get_range(t, start..(start + chunk_size).min(size))
2133                    .await?
2134            }
2135            (None, Some(download_id)) => {
2136                let (_, reply) = act_and_send_as(
2137                    client,
2138                    &target,
2139                    blob_chunk::v0_1::Payload::TYPE_URI,
2140                    serde_json::json!({ "downloadId": download_id, "index": i }),
2141                    Some(&member),
2142                )
2143                .await?;
2144                let c: blob_chunk::v0_1::Response = parse_reply("rooms/blobs/chunk", reply)?;
2145                base64::Engine::decode(
2146                    &base64::engine::general_purpose::URL_SAFE_NO_PAD,
2147                    c.data.as_str(),
2148                )?
2149            }
2150            (None, None) => {
2151                return Err("the host answered with neither a target nor a download".into());
2152            }
2153        };
2154        if !files::same_digest(&files::digest_multibase(&chunk), expected) {
2155            return Err(format!("chunk {i} does not match the manifest").into());
2156        }
2157        chunks.push(chunk);
2158    }
2159
2160    let resp = client
2161        .room_file_key(target.room_id, &file.file_id, Some(file.epoch))
2162        .await
2163        .map_err(|e| file_key_refusal_hint(&e, &target))?;
2164    let file_key = file_key_bytes(&resp)?;
2165    let plaintext = files::open_file(&file_key, target.room_id, &file, &blob, &chunks)?;
2166
2167    let out = match out {
2168        Some(p) => p.to_path_buf(),
2169        None => std::path::PathBuf::from(local_name(&file.name)),
2170    };
2171    if out.exists() {
2172        return Err(format!("{} already exists; refusing to overwrite it", out.display()).into());
2173    }
2174    // Written beside the target and moved into place, so a failure leaves nothing that
2175    // looks like the file.
2176    let tmp = out.with_extension("partial");
2177    std::fs::write(&tmp, &plaintext)?;
2178    std::fs::rename(&tmp, &out)?;
2179    println!(
2180        "Fetched {} ({} bytes) into {}, written by {}; it matches the digest they signed.",
2181        inert(&file.name).escape_debug(),
2182        plaintext.len(),
2183        out.display(),
2184        verified.body().author
2185    );
2186    Ok(())
2187}
2188
2189/// `rooms owner group-create` — found a sealed room's group in this VTA, with the owner
2190/// as its only member (`rooms/owner/group/create/0.1`).
2191pub async fn cmd_rooms_owner_group_create(
2192    client: &VtaClient,
2193    room_id: &str,
2194    signing_key_id: &str,
2195    member: Option<&str>,
2196) -> Result<(), Box<dyn std::error::Error>> {
2197    let resp = client
2198        .room_group_create(room_id, signing_key_id, member)
2199        .await
2200        .map_err(|e| {
2201            format!(
2202                "{e}\n\n`noSigningKey` means this VTA will not let you name `{signing_key_id}` — \
2203                 check it with `pnm keys list --context <id>`. `alreadyExists` means it already \
2204                 holds this room's group, which is never replaced."
2205            )
2206        })?;
2207    println!("Founded the group of {room_id} at epoch {}.", resp.epoch);
2208    println!(
2209        "  Next: admit members with `pnm rooms owner invite` and `pnm rooms owner group-add`. \
2210         Writing needs your own membership and authority credentials in this VTA \
2211         (`pnm rooms owner issue-membership` / `issue-authority --store`), if not issued yet."
2212    );
2213    Ok(())
2214}
2215
2216/// An earlier removal the VTA was settling when it took this request, and whose revocation
2217/// list the host refused on redelivery, explained for the operator — or `None` when `e` is
2218/// not that refusal.
2219///
2220/// It is `rooms/owner/group/{add,renew,remove}:earlierRemovalRefused` at `add/0.3`,
2221/// `renew/0.2` and `remove/0.4`, with `details` `member`, `code` and `listKept` —
2222/// `rooms/owner/group/remove/0.4` item 8.2. The earlier removal did not take effect, and
2223/// nothing of `this` was done: the fix is to run the command again, then remove the
2224/// earlier member again if that is still wanted.
2225fn earlier_removal_refused(
2226    e: &VtaError,
2227    room_id: &str,
2228    host_did: &str,
2229    signing_key_id: Option<&str>,
2230    this: &str,
2231) -> Option<String> {
2232    let VtaError::Rejected { code, details, .. } = e else {
2233        return None;
2234    };
2235    let task = code.strip_prefix("rooms/owner/group/")?;
2236    if !task.ends_with(":earlierRemovalRefused") {
2237        return None;
2238    }
2239    let d = details.as_ref()?;
2240    let earlier = d["member"].as_str().unwrap_or("an earlier member");
2241    let host_code = d["code"].as_str().unwrap_or("no code given");
2242    let kept = if d["listKept"].as_bool() == Some(true) {
2243        format!(
2244            "Their withdrawal is kept in the room's revocation list and carried by every later \
2245             one; the host enforces it only if it ever accepted that list — deliver it again \
2246             with\n    pnm rooms owner revoke --room {room_id} --signing-key {} --host \
2247             {host_did}",
2248            signing_key_id.unwrap_or("<the room's key>")
2249        )
2250    } else {
2251        "Their withdrawal was discarded: no host accepted it.".to_string()
2252    };
2253    Some(format!(
2254        "Nothing was done: an earlier removal of {earlier} from {room_id} did not take effect \
2255         — {host_did} refused its revocation list when your VTA delivered it again \
2256         ({host_code}). {earlier} is still in the room's group. {kept}\n\nRun this command \
2257         again to {this}; then remove {earlier} again if that is still wanted."
2258    ))
2259}
2260
2261/// `rooms owner group-remove` — remove a member from a room
2262/// (`rooms/owner/group/remove/0.4`, trust-tasks-tf #975).
2263///
2264/// On an `attributed` room the room's key (`signing_key_id`, required there) withdraws the
2265/// member's credentials at the host first, and then the room advances — the removal takes
2266/// effect at the host at once. On a `private` room, where the host cannot be told whom to
2267/// refuse, the same version removes their leaf alone and says so (`withdrawn: false`): their
2268/// credentials stay valid at the host until they expire, and this never reports that as a
2269/// withdrawal.
2270///
2271/// An earlier removal the VTA was still settling, whose list the host refused on
2272/// redelivery, is reported as `earlierRemovalRefused`: that removal did not take effect, and
2273/// nothing of this one was done — this says which member, and what to run.
2274#[allow(clippy::too_many_arguments)]
2275pub async fn cmd_rooms_owner_group_remove(
2276    client: &VtaClient,
2277    room_id: &str,
2278    host_did: &str,
2279    member: &str,
2280    signing_key_id: Option<&str>,
2281    mirrors: &[String],
2282    until: Option<chrono::DateTime<chrono::Utc>>,
2283    reason: Option<&str>,
2284) -> Result<(), Box<dyn std::error::Error>> {
2285    let hint = |e: VtaError| {
2286        let fix = match &e {
2287            VtaError::Rejected { code, .. }
2288                if code == "rooms/owner/group/remove:noSigningKey" && signing_key_id.is_none() =>
2289            {
2290                format!(
2291                    "\n\n{room_id} is not private: removing {member} withdraws their credentials \
2292                     at the host, signed as the room. Run\n    pnm rooms owner group-remove \
2293                     --room {room_id} --host-did {host_did} --member {member} --signing-key \
2294                     <the room's key>"
2295                )
2296            }
2297            _ => String::new(),
2298        };
2299        format!(
2300            "{e}{fix}\n\n`epochConflict` means another admin advanced the room: catch up and \
2301             retry. `hostUnreachable` leaves the outcome unknown; running this again \
2302             settles it with the host before anything else. `ownLeaf` means {member} is \
2303             you: hand the room on with rooms/owner/transfer instead. `noSigningKey` means \
2304             this VTA will not let you name the room's key — check `pnm keys list`."
2305        )
2306    };
2307    // Under one idempotency key, so a retry after a lost reply reports the removal that
2308    // happened instead of `notMember`.
2309    let resp = client
2310        .idempotent(|| {
2311            client.room_group_remove(
2312                room_id,
2313                host_did,
2314                member,
2315                signing_key_id,
2316                mirrors,
2317                until,
2318                reason,
2319            )
2320        })
2321        .await
2322        .map_err(|e| {
2323            // Item 8.2: an earlier removal did not take effect, and this one was not
2324            // started. Say which member, whether their withdrawal survives, and what to run.
2325            earlier_removal_refused(
2326                &e,
2327                room_id,
2328                host_did,
2329                signing_key_id,
2330                &format!("remove {member}"),
2331            )
2332            .unwrap_or_else(|| hint(e))
2333        })?;
2334    if !resp.withdrawn {
2335        // A private room: never represented as a withdrawal (the producer requirement).
2336        println!(
2337            "Removed {member} from {room_id}'s group; the room is now at epoch {}.",
2338            resp.epoch
2339        );
2340        println!(
2341            "  Nothing sealed from now on is readable to them. {room_id} is private, so their \
2342             credentials are NOT withdrawn at the host: they stay valid until they expire. \
2343             To reset every credential the room has issued, run `pnm rooms owner revoke \
2344             --reset` and re-admit those who remain."
2345        );
2346        return Ok(());
2347    }
2348    let r = serde_json::to_value(&resp).unwrap_or_default();
2349    println!(
2350        "Removed {member} from {room_id}: withdrawn at the host from {} (revocation list {}), \
2351         and the room is now at epoch {}.",
2352        r["revokedAt"].as_str().unwrap_or("now"),
2353        r["revocation"]["sequence"],
2354        r["epoch"]
2355    );
2356    if !resp.mirrors_pending.is_empty() {
2357        let pending: Vec<&str> = resp.mirrors_pending.iter().map(|m| m.as_str()).collect();
2358        println!(
2359            "  Not yet withdrawn at: {}. Deliver it with `pnm rooms owner revoke --room \
2360             {room_id} --signing-key {} --host <did>` (no withdrawal).",
2361            pending.join(", "),
2362            signing_key_id.unwrap_or("<the room's key>")
2363        );
2364    }
2365    println!(
2366        "  The host refuses their credentials, and anything they delegated, from now on. \
2367         Nothing they already read can be recalled."
2368    );
2369    Ok(())
2370}
2371
2372/// `rooms owner revoke` — withdraw credentials a room issued (`rooms/owner/revoke/0.1`),
2373/// signed as the room and delivered to every host serving it. With nothing to withdraw,
2374/// re-delivers the list the VTA holds.
2375#[allow(clippy::too_many_arguments)]
2376pub async fn cmd_rooms_owner_revoke(
2377    client: &VtaClient,
2378    room_id: &str,
2379    signing_key_id: &str,
2380    hosts: &[String],
2381    subjects: &[String],
2382    credentials: &[String],
2383    reset: bool,
2384    until: Option<chrono::DateTime<chrono::Utc>>,
2385    reason: Option<&str>,
2386) -> Result<(), Box<dyn std::error::Error>> {
2387    use vti_specs::rooms::owner::revoke::v0_1::Withdrawal;
2388    let until = until.map(|u| u.to_rfc3339_opts(chrono::SecondsFormat::Secs, true));
2389    let mut raw: Vec<serde_json::Value> = Vec::new();
2390    for s in subjects {
2391        raw.push(serde_json::json!({ "kind": "subject", "subject": s }));
2392    }
2393    for c in credentials {
2394        raw.push(serde_json::json!({ "kind": "credential", "credentialId": c }));
2395    }
2396    if reset {
2397        raw.push(serde_json::json!({ "kind": "room" }));
2398    }
2399    if let Some(u) = &until {
2400        for w in &mut raw {
2401            w["until"] = serde_json::json!(u);
2402        }
2403    }
2404    let withdraw: Vec<Withdrawal> = raw
2405        .into_iter()
2406        .map(serde_json::from_value)
2407        .collect::<Result<_, _>>()
2408        .map_err(|e| format!("a withdrawal is not one rooms/owner/revoke accepts: {e}"))?;
2409    let resp = client
2410        .idempotent(|| client.room_owner_revoke(room_id, signing_key_id, hosts, &withdraw, reason))
2411        .await
2412        .map_err(|e| {
2413            format!(
2414                "{e}\n\n`noList` means there is nothing to re-deliver: name a withdrawal. \
2415                 `entryNotPermitted` means the room is private: only --reset applies there. \
2416                 `noSigningKey` means this VTA will not let you name `{signing_key_id}` — check \
2417                 `pnm keys list`. `noAuthority` means it holds no `admin` grant of the room \
2418                 for a persona you act as."
2419            )
2420        })?;
2421    let r = serde_json::to_value(&resp).unwrap_or_default();
2422    println!(
2423        "{room_id}'s revocation list is at sequence {} ({}).",
2424        r["head"]["sequence"],
2425        r["head"]["digest"].as_str().unwrap_or_default()
2426    );
2427    for d in r["deliveries"].as_array().into_iter().flatten() {
2428        let host = d["host"].as_str().unwrap_or_default();
2429        match d["outcome"].as_str().unwrap_or_default() {
2430            "accepted" => println!(
2431                "  {host}: in force from {}",
2432                d["inForceAt"].as_str().unwrap_or_default()
2433            ),
2434            "refused" => {
2435                let code = d["code"].as_str().unwrap_or_default();
2436                println!("  {host}: REFUSED ({code})");
2437                if code == "rooms/revocations/put:stale" {
2438                    println!(
2439                        "    The host holds a later list this VTA did not sign: the room's key \
2440                         may be in more than one party's hands."
2441                    );
2442                }
2443            }
2444            _ => println!("  {host}: no answer — run this again with no withdrawal to deliver it"),
2445        }
2446    }
2447    Ok(())
2448}
2449
2450/// `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
2451/// the room at its host with that commit. An earlier removal the VTA was settling, refused
2452/// at the host, is explained as `group-remove` explains it: nothing of the renewal was done.
2453pub async fn cmd_rooms_owner_group_renew(
2454    client: &VtaClient,
2455    room_id: &str,
2456    host_did: &str,
2457    reason: Option<&str>,
2458) -> Result<(), Box<dyn std::error::Error>> {
2459    // Under one idempotency key, so a retry after a lost reply reports the renewal that
2460    // happened instead of renewing a second time.
2461    let resp = client
2462        .idempotent(|| client.room_group_renew(room_id, host_did, reason))
2463        .await
2464        .map_err(|e| {
2465            earlier_removal_refused(&e, room_id, host_did, None, &format!("renew {room_id}"))
2466                .unwrap_or_else(|| {
2467                    format!(
2468                        "{e}\n\n`noGroup` means this VTA holds no group for {room_id}: an \
2469                         `open` room renews with `pnm rooms renew`. `epochConflict` means \
2470                         another admin advanced the room: catch up and retry. \
2471                         `hostUnreachable` leaves the outcome unknown; running this again \
2472                         settles it with the host before anything else."
2473                    )
2474                })
2475        })?;
2476    println!(
2477        "Renewed {room_id}; the room is now at epoch {}.",
2478        resp.epoch
2479    );
2480    println!(
2481        "  Nobody joined or left. Every remaining member's VTA must apply the commit before \
2482         it can write again: each runs `pnm rooms keys commit --room {room_id} --host <url> \
2483         --host-did {host_did}`."
2484    );
2485    Ok(())
2486}
2487
2488/// `rooms owner group-add` — admit a member from their KeyPackage
2489/// (`rooms/owner/group/add/0.3`) and write the Welcome
2490/// to `out` for delivery. An earlier removal the VTA was settling, refused at the host, is
2491/// explained as `group-remove` explains it: nothing of the admission was done.
2492pub async fn cmd_rooms_owner_group_add(
2493    client: &VtaClient,
2494    room_id: &str,
2495    host_did: &str,
2496    member: &str,
2497    key_package_src: &str,
2498    reason: Option<&str>,
2499    out: &std::path::Path,
2500) -> Result<(), Box<dyn std::error::Error>> {
2501    if out.exists() {
2502        return Err(format!("{} already exists; refusing to overwrite it", out.display()).into());
2503    }
2504    let key_package = if key_package_src == "-" {
2505        let mut s = String::new();
2506        std::io::Read::read_to_string(&mut std::io::stdin(), &mut s)?;
2507        s
2508    } else {
2509        std::fs::read_to_string(key_package_src)?
2510    };
2511    let key_package = key_package.trim().to_string();
2512
2513    // Under one idempotency key: the answer is the only copy of the Welcome, and a
2514    // retry that re-executed would be refused `alreadyMember` with the Welcome lost.
2515    let resp = client
2516        .idempotent(|| client.room_group_add(room_id, host_did, member, &key_package, reason))
2517        .await
2518        .map_err(|e| {
2519            earlier_removal_refused(&e, room_id, host_did, None, &format!("admit {member}"))
2520                .unwrap_or_else(|| {
2521                    format!(
2522                        "{e}\n\n`epochConflict` means another admin advanced the room: catch \
2523                         up and retry. `hostUnreachable` leaves the outcome unknown; running \
2524                         this again settles it with the host before anything else."
2525                    )
2526                })
2527        })?;
2528
2529    let delivery = serde_json::json!({
2530        "roomId": room_id,
2531        "member": resp.member.as_str(),
2532        "epoch": resp.epoch,
2533        "welcome": resp.welcome.as_str(),
2534        "ratchetTree": resp.ratchet_tree.as_ref().map(|t| t.as_str()),
2535    });
2536    std::fs::write(out, serde_json::to_vec_pretty(&delivery)?)?;
2537    println!(
2538        "Admitted {member} to {room_id}; the room is now at epoch {}.",
2539        resp.epoch
2540    );
2541    println!(
2542        "  Deliver {} to the member's key holder (rooms/keys/welcome) directly — never \
2543         through the host.",
2544        out.display()
2545    );
2546    Ok(())
2547}
2548
2549// ─── A community's offers ────────────────────────────────────────────────
2550//
2551// A community that owns a room offers its members admission
2552// (`vtc/rooms/community/members/add`). The member lists their offers and accepts one with
2553// documents signed as the DID the offer is addressed to — a persona their VTA holds — so
2554// this client builds each request unsigned and has the VTA complete it as that DID with
2555// `rooms/keys/act`, which inserts no presentation for these types. The completed
2556// document goes to the community over its Trust Task HTTPS endpoint, unchanged: a
2557// persona-signed document rides HTTPS, never this client's own DIDComm session.
2558//
2559// The listing is `vtc/rooms/offers/list/0.4`, completed at `rooms/keys/act/0.8`: the
2560// community's offers with their descriptions, and the renewals of the rooms the member is
2561// already in (`renewsAt`). A VTA or a community that serves neither is reported, never
2562// read at an older version.
2563
2564/// The community an offer came from, and the DID it is addressed to.
2565pub struct OfferTarget<'a> {
2566    /// Base URL of the community's Trust Task endpoint (e.g. `https://vtc.example.com/v1`).
2567    pub vtc_url: &'a str,
2568    /// The community's DID — the `recipient` of every document.
2569    pub vtc_did: &'a str,
2570    /// The DID the community addressed its offers to: a persona your VTA holds.
2571    pub member: &'a str,
2572}
2573
2574/// Which party an offer document failed at: the member's VTA, completing it as the member,
2575/// or the community, answering it. A refusal means something different from each —
2576/// `unsupportedType` from the VTA is an agent to upgrade, from the community a community
2577/// that does not serve that version — so each is kept apart until it is reported.
2578enum OfferStepError {
2579    /// The member's VTA did not complete the document (`rooms/keys/act`).
2580    Act(VtaError),
2581    /// The community refused the completed document, or did not answer it.
2582    Community(vtc_client::VtcError),
2583    /// Neither: the document could not be built or serialised.
2584    Local(Box<dyn std::error::Error>),
2585}
2586
2587impl OfferStepError {
2588    /// The error to report, with a hint where the operator's fix is known.
2589    fn report(self, target: &OfferTarget<'_>) -> Box<dyn std::error::Error> {
2590        match self {
2591            Self::Act(e) => {
2592                let text = e.to_string();
2593                let hint = if matches!(e, VtaError::UnsupportedTaskType { .. }) {
2594                    "\n\nYour VTA does not serve rooms/keys/act/0.8, which signs this request \
2595                     as you: upgrade it."
2596                        .to_string()
2597                } else if text.contains("issuerNotPrincipal") {
2598                    format!(
2599                        "\n\nYour VTA holds no `authentication` key of `{}` that you may act as. \
2600                         Pass the persona DID the community knows you by as --member.",
2601                        target.member
2602                    )
2603                } else if text.contains("keyPackageNotHeld") {
2604                    "\n\nThe KeyPackage was not minted by your VTA for this member and room, or \
2605                     it has been used or has expired: run this again without --key-package to \
2606                     mint one."
2607                        .to_string()
2608                } else {
2609                    String::new()
2610                };
2611                format!("{e}{hint}").into()
2612            }
2613            Self::Community(e) => format!("{}: {e}", target.vtc_did).into(),
2614            Self::Local(e) => e,
2615        }
2616    }
2617}
2618
2619/// Have the VTA complete a request to the community as `target.member`
2620/// (`rooms/keys/act/0.8`) and send
2621/// it to the community. Returns the community's response
2622/// payload, or the party that failed.
2623async fn act_and_send_offer(
2624    client: &VtaClient,
2625    target: &OfferTarget<'_>,
2626    type_uri: &str,
2627    payload: Value,
2628) -> Result<Value, OfferStepError> {
2629    let document = vta_sdk::client::unsigned_community_request(
2630        type_uri,
2631        target.vtc_did,
2632        target.member,
2633        payload,
2634    )
2635    .map_err(|e| OfferStepError::Local(e.into()))?;
2636    let completed = client
2637        .room_act_community(&document)
2638        .await
2639        .map_err(OfferStepError::Act)?;
2640    let document =
2641        serde_json::to_value(&completed.document).map_err(|e| OfferStepError::Local(e.into()))?;
2642    VtcClient::anonymous(target.vtc_url, target.vtc_did)
2643        .send_signed_room_document(&document)
2644        .await
2645        .map_err(OfferStepError::Community)
2646}
2647
2648/// One offer as this client shows it: `vtc/rooms/offers/list/0.4`'s, with the room's
2649/// `description` when it has one.
2650type CommunityOffer = vti_specs::vtc::rooms::offers::list::v0_4::OfferV2;
2651
2652/// A renewal of an admission the member holds (`vtc/rooms/offers/list/0.4`).
2653type CommunityRenewal = vti_specs::vtc::rooms::offers::list::v0_4::Renewal;
2654
2655/// What a listing answered: the offers, and the renewals.
2656type OffersListing = (Vec<CommunityOffer>, Vec<CommunityRenewal>);
2657
2658/// One page of the community's `vtc/rooms/offers/list/0.4` answer: its offers, the next
2659/// cursor, and its renewals.
2660#[allow(clippy::type_complexity)]
2661fn offers_page(
2662    reply: Value,
2663) -> Result<(Vec<CommunityOffer>, Option<String>, Vec<CommunityRenewal>), Box<dyn std::error::Error>>
2664{
2665    use vti_specs::vtc::rooms::offers::list::v0_4;
2666    let page: v0_4::Response = parse_reply("vtc/rooms/offers/list/0.4", reply)?;
2667    Ok((
2668        page.items,
2669        page.next_cursor.map(|c| c.to_string()),
2670        page.renewals,
2671    ))
2672}
2673
2674/// Every offer the community has made to the member, and the renewals of the rooms they are
2675/// in: `vtc/rooms/offers/list/0.4`, signed as the member through `rooms/keys/act/0.8`, one
2676/// page at a time. A VTA or a community that serves neither is told so; nothing older is
2677/// asked.
2678async fn list_offers(
2679    client: &VtaClient,
2680    target: &OfferTarget<'_>,
2681) -> Result<OffersListing, Box<dyn std::error::Error>> {
2682    use trust_tasks_rs::Payload as _;
2683    let type_uri = vti_specs::vtc::rooms::offers::list::v0_4::Payload::TYPE_URI;
2684    let mut items = Vec::new();
2685    let mut renewals = Vec::new();
2686    let mut cursor: Option<String> = None;
2687    loop {
2688        let payload = match &cursor {
2689            Some(c) => serde_json::json!({ "cursor": c }),
2690            None => serde_json::json!({}),
2691        };
2692        let reply = act_and_send_offer(client, target, type_uri, payload)
2693            .await
2694            .map_err(|e| e.report(target))?;
2695        let page = offers_page(reply)?;
2696        items.extend(page.0);
2697        renewals.extend(page.2);
2698        match page.1 {
2699            Some(next) => cursor = Some(next),
2700            None => return Ok((items, renewals)),
2701        }
2702    }
2703}
2704
2705/// `pnm rooms offers list` — the rooms the community has offered you, each with what it is
2706/// for when the community says.
2707pub async fn cmd_rooms_offers_list(
2708    client: &VtaClient,
2709    target: OfferTarget<'_>,
2710) -> Result<(), Box<dyn std::error::Error>> {
2711    let (offers, renewals) = list_offers(client, &target).await?;
2712    if offers.is_empty() && renewals.is_empty() {
2713        println!(
2714            "No offers from {} are addressed to {}.",
2715            target.vtc_did, target.member
2716        );
2717        return Ok(());
2718    }
2719    for o in &offers {
2720        print!("{}", offer_lines(o, &target));
2721    }
2722    for r in &renewals {
2723        print!("{}", renewal_lines(r, &target));
2724    }
2725    Ok(())
2726}
2727
2728/// The lines `pnm rooms offers list` prints for one renewal, with the command that collects
2729/// it.
2730fn renewal_lines(r: &CommunityRenewal, target: &OfferTarget<'_>) -> String {
2731    let actions: Vec<String> = r.actions.0.iter().map(ToString::to_string).collect();
2732    format!(
2733        "{}  {}  [{}]  {}  renewal  until {} (renews {})\n  renew: {}\n",
2734        r.room_id.as_str(),
2735        inert(r.label.as_str()),
2736        r.visibility,
2737        actions.join(","),
2738        r.valid_until.to_rfc3339(),
2739        r.renews_at.to_rfc3339(),
2740        renew_command(r.room_id.as_str(), target),
2741    )
2742}
2743
2744/// The `pnm rooms offers renew` line for one renewal, ready to paste.
2745fn renew_command(room_id: &str, target: &OfferTarget<'_>) -> String {
2746    format!(
2747        "pnm rooms offers renew --room {} --vtc {} --vtc-did {} --member {}",
2748        shell_word(room_id),
2749        shell_word(target.vtc_url),
2750        shell_word(target.vtc_did),
2751        shell_word(target.member),
2752    )
2753}
2754
2755/// The vault handle under which the member's VTA holds the `type_tag` credential `room_id`
2756/// issued `member`: `None` when it holds none. More than one is refused — a key holder that
2757/// holds two cannot choose between them, and nor can this client.
2758async fn held_room_credential(
2759    client: &VtaClient,
2760    room_id: &str,
2761    member: &str,
2762    type_tag: &str,
2763) -> Result<Option<String>, Box<dyn std::error::Error>> {
2764    let found = client
2765        .cred_vault_query(serde_json::json!({ "type": type_tag, "issuerDid": room_id }))
2766        .await
2767        .map_err(|e| format!("your VTA's credential vault did not answer a query: {e}"))?;
2768    let mut mine = Vec::new();
2769    for descriptor in found["credentials"].as_array().cloned().unwrap_or_default() {
2770        let Some(id) = descriptor["id"].as_str() else {
2771            continue;
2772        };
2773        let got = client
2774            .cred_vault_get(id)
2775            .await
2776            .map_err(|e| format!("your VTA's credential vault did not return {id}: {e}"))?;
2777        // A room's membership is a Data Integrity credential, so its body is a
2778        // JSON object; a body carried as bytes is some other credential.
2779        let subject = match &got {
2780            vault_get::Response::Variant0 { credential, .. } => credential
2781                .get("credentialSubject")
2782                .and_then(|s| s.get("id"))
2783                .and_then(Value::as_str),
2784            _ => None,
2785        };
2786        if subject == Some(member) {
2787            mine.push(id.to_string());
2788        }
2789    }
2790    match mine.len() {
2791        0 => Ok(None),
2792        1 => Ok(mine.pop()),
2793        n => Err(format!(
2794            "your VTA holds {n} {type_tag}s that {room_id} issued {member}, and acts on the \
2795             room with none of them until one is left: remove the older (`pnm cred-vault \
2796             delete`) and renew again"
2797        )
2798        .into()),
2799    }
2800}
2801
2802/// `pnm rooms offers renew` — collect a renewal of a room you are in: store its membership
2803/// and authority in your VTA in place of the ones they renew (`vtc/rooms/offers/list/0.4`
2804/// surface items 1–3, carried unchanged into 0.4). Storing one already stored is success, so any surface may collect it
2805/// first.
2806pub async fn cmd_rooms_offers_renew(
2807    client: &VtaClient,
2808    target: OfferTarget<'_>,
2809    room_id: &str,
2810) -> Result<(), Box<dyn std::error::Error>> {
2811    let (_, renewals) = list_offers(client, &target).await?;
2812    let renewal = renewals
2813        .into_iter()
2814        .find(|r| r.room_id.as_str() == room_id)
2815        .ok_or_else(|| {
2816            format!(
2817                "{} lists no renewal of {room_id} for {}: `pnm rooms offers list` shows what it \
2818                 has for you",
2819                target.vtc_did, target.member
2820            )
2821        })?;
2822    for (what, type_tag, credential) in [
2823        (
2824            "membership",
2825            "MembershipCredential",
2826            renewal.credentials.membership.as_str(),
2827        ),
2828        (
2829            "authority",
2830            "AuthorityCredential",
2831            renewal.credentials.authority.as_str(),
2832        ),
2833    ] {
2834        let body: Value = serde_json::from_str(credential)
2835            .map_err(|e| format!("the renewal's {what} credential is not JSON: {e}"))?;
2836        let replaces = held_room_credential(client, room_id, target.member, type_tag).await?;
2837        client
2838            .cred_vault_receive(body, replaces.as_deref())
2839            .await
2840            .map_err(|e| {
2841                format!("your VTA's credential vault refused the renewed {what} credential: {e}")
2842            })?;
2843    }
2844    println!(
2845        "Renewed {room_id} for {}: valid until {} (it was until {}).",
2846        target.member,
2847        renewal.valid_until.to_rfc3339(),
2848        renewal.renews_at.to_rfc3339(),
2849    );
2850    Ok(())
2851}
2852
2853/// The lines `pnm rooms offers list` prints for one offer. The label and description are
2854/// the community's text: control characters in them never reach the terminal, and the
2855/// description is kept to one line.
2856fn offer_lines(o: &CommunityOffer, target: &OfferTarget<'_>) -> String {
2857    use vti_specs::vtc::rooms::offers::list::v0_4::OfferV2State;
2858    let actions: Vec<String> = o.actions.0.iter().map(ToString::to_string).collect();
2859    let mut out = format!(
2860        "{}  {}  [{}]  {}  {}  until {}\n",
2861        o.room_id.as_str(),
2862        inert(o.label.as_str()),
2863        o.visibility,
2864        actions.join(","),
2865        o.state,
2866        o.valid_until.to_rfc3339(),
2867    );
2868    if let Some(description) = &o.description {
2869        out.push_str(&format!("  about: {}\n", one_line(description.as_str())));
2870    }
2871    if o.state == OfferV2State::Open {
2872        out.push_str(&format!(
2873            "  accept: {}\n",
2874            accept_command(o.room_id.as_str(), target)
2875        ));
2876    }
2877    out
2878}
2879
2880/// `text` on one line: every control character — line breaks and tabs included — replaced
2881/// with a space, so a description the community wrote cannot move the cursor, start a
2882/// line that looks like this client's own, or ring the terminal.
2883fn one_line(text: &str) -> String {
2884    text.chars()
2885        .map(|c| if c.is_control() { ' ' } else { c })
2886        .collect::<String>()
2887        .trim()
2888        .to_string()
2889}
2890
2891/// The `pnm rooms offers accept` line for one offer, ready to paste: every value filled in,
2892/// so the operator never types a `<placeholder>` a shell reads as a redirect.
2893fn accept_command(room_id: &str, target: &OfferTarget<'_>) -> String {
2894    format!(
2895        "pnm rooms offers accept --room {} --vtc {} --vtc-did {} --member {}",
2896        shell_word(room_id),
2897        shell_word(target.vtc_url),
2898        shell_word(target.vtc_did),
2899        shell_word(target.member),
2900    )
2901}
2902
2903/// `value` as one shell word. The room DID comes from the community, so it is quoted
2904/// unless every character is one no POSIX shell or fish treats specially.
2905fn shell_word(value: &str) -> String {
2906    let plain = !value.is_empty()
2907        && value
2908            .chars()
2909            .all(|c| c.is_ascii_alphanumeric() || ":/._%@+=,-".contains(c));
2910    if plain {
2911        value.to_string()
2912    } else {
2913        format!("'{}'", inert(value).replace('\'', r"'\''"))
2914    }
2915}
2916
2917/// `pnm rooms offers accept` — take up an offer: store its credentials in your VTA, mint a
2918/// KeyPackage there on a sealed tier, accept as the member, and join with the Welcome.
2919///
2920/// `key_package` reuses a KeyPackage your VTA minted for this member and room — for a run
2921/// whose answer was lost after the community admitted you, so the retry is the identical
2922/// acceptance the community (and its VTA) answers with the same Welcome.
2923pub async fn cmd_rooms_offers_accept(
2924    client: &VtaClient,
2925    target: OfferTarget<'_>,
2926    room_id: &str,
2927    key_package: Option<&str>,
2928) -> Result<(), Box<dyn std::error::Error>> {
2929    use trust_tasks_rs::Payload as _;
2930    use vti_specs::vtc::rooms::offers::accept::v0_1 as accept;
2931
2932    let (offers, _) = list_offers(client, &target).await?;
2933    let offer = offers
2934        .into_iter()
2935        .find(|o| o.room_id.as_str() == room_id)
2936        .ok_or_else(|| {
2937            format!(
2938                "{} has no open offer to {room_id} addressed to {}: `pnm rooms offers list` \
2939                 shows what it has offered you",
2940                target.vtc_did, target.member
2941            )
2942        })?;
2943    let sealed = offer.visibility.to_string() != "open";
2944
2945    // The membership and authority credentials the room issued the member, into the VTA's
2946    // vault, where its presentation oracle finds them for every later room request. Stored
2947    // under their own ids, so a second run replaces rather than duplicates.
2948    for (what, credential) in [
2949        ("membership", offer.credentials.membership.as_str()),
2950        ("authority", offer.credentials.authority.as_str()),
2951    ] {
2952        let body: Value = serde_json::from_str(credential)
2953            .map_err(|e| format!("the offer's {what} credential is not JSON: {e}"))?;
2954        client.cred_vault_receive(body, None).await.map_err(|e| {
2955            format!("your VTA's credential vault refused the {what} credential: {e}")
2956        })?;
2957    }
2958    let invitation = offer.credentials.invitation.as_str();
2959
2960    // Joined already — here, or from another surface: nothing to accept, and minting
2961    // again would only make a KeyPackage nobody admits.
2962    if sealed && holds_group(client, room_id, target.member).await? {
2963        println!(
2964            "{room_id} is already joined as {} by your VTA — accepted here or from another \
2965             surface. Nothing to do.",
2966            target.member
2967        );
2968        return Ok(());
2969    }
2970
2971    let key_package = match (sealed, key_package) {
2972        (false, _) => None,
2973        (true, Some(src)) => Some(std::fs::read_to_string(src)?.trim().to_string()),
2974        // From the VTA, never made here: act completes an acceptance only with a KeyPackage
2975        // the same key holder minted for this member and room.
2976        (true, None) => Some(offered_key_package(client, room_id, invitation).await?),
2977    };
2978
2979    let mut payload = serde_json::json!({ "roomId": room_id });
2980    if let Some(kp) = &key_package {
2981        payload["keyPackage"] = Value::String(kp.clone());
2982    }
2983    let reply = act_and_send_offer(client, &target, accept::Payload::TYPE_URI, payload)
2984        .await
2985        .map_err(|e| e.report(&target))
2986        .map_err(|e| match &key_package {
2987            Some(kp) => format!(
2988                "{e}\n\nIf the community may have admitted you, retry the identical acceptance \
2989                 with this KeyPackage (save it to a file and pass --key-package <file>):\n{kp}"
2990            ),
2991            None => e.to_string(),
2992        })?;
2993    let accepted: accept::Response = parse_reply("vtc/rooms/offers/accept", reply)?;
2994
2995    match &accepted.welcome {
2996        Some(welcome) => {
2997            let joined = client
2998                .idempotent(|| {
2999                    client.room_welcome(
3000                        room_id,
3001                        invitation,
3002                        welcome.as_str(),
3003                        accepted.ratchet_tree.as_ref().map(|t| t.as_str()),
3004                    )
3005                })
3006                .await;
3007            match joined {
3008                Ok(joined) => println!(
3009                    "Accepted {room_id} as {} and joined its group at room epoch {}.",
3010                    target.member,
3011                    joined.epoch + 1
3012                ),
3013                // Another surface handed the same Welcome over first: joined all the same.
3014                Err(e) if e.to_string().contains("alreadyJoined") => println!(
3015                    "Accepted {room_id} as {}; your VTA had already joined its group from \
3016                     another surface.",
3017                    target.member
3018                ),
3019                Err(e) => {
3020                    return Err(format!(
3021                        "admitted at room epoch {}, but your VTA did not join from the \
3022                         Welcome: {e}",
3023                        accepted.epoch
3024                    )
3025                    .into());
3026                }
3027            }
3028        }
3029        None if sealed => {
3030            return Err(format!(
3031                "the community admitted you to sealed room {room_id} but sent no Welcome"
3032            )
3033            .into());
3034        }
3035        None => println!(
3036            "Accepted {room_id} as {}; the room is open, at epoch {}.",
3037            target.member, accepted.epoch
3038        ),
3039    }
3040    println!(
3041        "  Read it with `pnm rooms list --room {room_id} --host {} --host-did {}`.",
3042        target.vtc_url,
3043        offer.host.as_str()
3044    );
3045    Ok(())
3046}
3047
3048/// Whether the VTA holds `room`'s group for `member` (`rooms/keys/list`): custody, which
3049/// with the community's record is what every surface shows the member.
3050async fn holds_group(
3051    client: &VtaClient,
3052    room: &str,
3053    member: &str,
3054) -> Result<bool, Box<dyn std::error::Error>> {
3055    let held = client.room_list(false).await?;
3056    Ok(held
3057        .rooms
3058        .iter()
3059        .any(|r| r.room_id.as_str() == room && r.member.as_str() == member))
3060}
3061
3062/// The KeyPackage for an acceptance: the one the VTA already holds for this room, member
3063/// and invitation — which another surface may already have sent — or a new one
3064/// (`rooms/keys/key-package/0.2`).
3065async fn offered_key_package(
3066    client: &VtaClient,
3067    room: &str,
3068    invitation: &str,
3069) -> Result<String, Box<dyn std::error::Error>> {
3070    let kp = client.room_key_package(room, invitation).await?;
3071    if kp.already_minted {
3072        eprintln!(
3073            "Your VTA already held a KeyPackage for this offer — another surface may \
3074             have begun this acceptance; finishing it."
3075        );
3076    }
3077    Ok(kp.key_package.to_string())
3078}
3079
3080/// `vtc/rooms/memberships/list/0.1`, signed as the member: the community's whole record of
3081/// their admissions (one page at a time).
3082async fn list_memberships(
3083    client: &VtaClient,
3084    target: &OfferTarget<'_>,
3085) -> Result<
3086    Vec<vti_specs::vtc::rooms::memberships::list::v0_1::RoomMembership>,
3087    Box<dyn std::error::Error>,
3088> {
3089    use trust_tasks_rs::Payload as _;
3090    use vti_specs::vtc::rooms::memberships::list::v0_1 as list;
3091    let mut items = Vec::new();
3092    let mut cursor: Option<String> = None;
3093    loop {
3094        let payload = match &cursor {
3095            Some(c) => serde_json::json!({ "cursor": c }),
3096            None => serde_json::json!({}),
3097        };
3098        let reply = act_and_send_offer(client, target, list::Payload::TYPE_URI, payload)
3099            .await
3100            .map_err(|e| e.report(target))?;
3101        let page: list::Response = parse_reply("vtc/rooms/memberships/list", reply)?;
3102        items.extend(page.items);
3103        match page.next_cursor {
3104            Some(next) => cursor = Some(next.to_string()),
3105            None => return Ok(items),
3106        }
3107    }
3108}
3109
3110/// What a surface shows for one room — `vtc/rooms/memberships/list/0.1` *One record, every
3111/// surface*: the community's state and the key holder's custody, read the same way by
3112/// every surface.
3113#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3114pub enum RoomStanding {
3115    /// Offered and not yet accepted.
3116    Offered,
3117    /// Admitted, and openable: an open room, or a sealed one whose group the VTA holds.
3118    Joined,
3119    /// Admitted to a sealed room whose group the VTA does not hold, while the community
3120    /// still keeps the Welcome: an acceptance to finish.
3121    Unfinished,
3122    /// Admitted to a sealed room whose group the VTA does not hold, and the Welcome is no
3123    /// longer kept: only an administrator re-admitting the member repairs it.
3124    NeedsReadmission,
3125    /// Removed by an administrator.
3126    Removed,
3127}
3128
3129impl RoomStanding {
3130    /// The table in `vtc/rooms/memberships/list/0.1`.
3131    pub fn of(state: &str, sealed: bool, held: bool, welcome_kept: bool) -> Self {
3132        match state {
3133            "offered" => Self::Offered,
3134            "removed" => Self::Removed,
3135            _ if !sealed || held => Self::Joined,
3136            _ if welcome_kept => Self::Unfinished,
3137            _ => Self::NeedsReadmission,
3138        }
3139    }
3140
3141    fn label(self) -> &'static str {
3142        match self {
3143            Self::Offered => "offered",
3144            Self::Joined => "joined",
3145            Self::Unfinished => "accepted, not finished",
3146            Self::NeedsReadmission => "admitted, not openable here",
3147            Self::Removed => "removed",
3148        }
3149    }
3150}
3151
3152/// `pnm rooms memberships` — every room the community has offered you and where each
3153/// stands, as every surface you use shows it: the community's record
3154/// (`vtc/rooms/memberships/list`) beside your VTA's custody (`rooms/keys/list`).
3155pub async fn cmd_rooms_memberships(
3156    client: &VtaClient,
3157    target: OfferTarget<'_>,
3158) -> Result<(), Box<dyn std::error::Error>> {
3159    let rooms = list_memberships(client, &target).await?;
3160    if rooms.is_empty() {
3161        println!(
3162            "{} has offered no rooms to {}.",
3163            target.vtc_did, target.member
3164        );
3165        return Ok(());
3166    }
3167    let held = client.room_list(false).await?;
3168    for r in &rooms {
3169        let room_id = r.room_id.as_str();
3170        let holds = held
3171            .rooms
3172            .iter()
3173            .any(|h| h.room_id.as_str() == room_id && h.member.as_str() == target.member);
3174        let standing = RoomStanding::of(
3175            &r.state.to_string(),
3176            r.visibility.to_string() != "open",
3177            holds,
3178            r.repeat_accept_until.is_some(),
3179        );
3180        let actions: Vec<String> = r.actions.0.iter().map(ToString::to_string).collect();
3181        println!(
3182            "{room_id}  {}  [{}]  {}  {}",
3183            inert(r.label.as_str()),
3184            r.visibility,
3185            actions.join(","),
3186            standing.label(),
3187        );
3188        match standing {
3189            RoomStanding::Offered | RoomStanding::Unfinished => {
3190                println!("  accept: {}", accept_command(room_id, &target));
3191            }
3192            RoomStanding::NeedsReadmission => println!(
3193                "  Your VTA never joined this room's group and the community no longer keeps \
3194                 the Welcome: ask an administrator to remove and re-admit you."
3195            ),
3196            RoomStanding::Joined | RoomStanding::Removed => {}
3197        }
3198    }
3199    Ok(())
3200}
3201
3202#[cfg(test)]
3203mod tests {
3204    use super::*;
3205    use serde_json::json;
3206
3207    fn prune_target() -> RoomTarget<'static> {
3208        RoomTarget {
3209            host_url: "https://host.example",
3210            host_did: Some("did:web:host.example"),
3211            room_id: "did:webvh:Qm:example.com:rooms:northwind",
3212        }
3213    }
3214
3215    /// rooms/epoch/prune/0.2 *Consent/purpose*: `--yes` always proceeds; without it a
3216    /// terminal is asked and a pipe is refused — never pruned unasked.
3217    #[test]
3218    fn a_prune_without_yes_asks_a_terminal_and_refuses_a_pipe() {
3219        assert_eq!(prune_guard(true, false), PruneGuard::Proceed);
3220        assert_eq!(prune_guard(true, true), PruneGuard::Proceed);
3221        assert_eq!(prune_guard(false, true), PruneGuard::Ask);
3222        assert_eq!(prune_guard(false, false), PruneGuard::Refuse);
3223    }
3224
3225    /// The warning says what becomes unreadable, that it cannot be undone, that the
3226    /// records stay, and that members' VTAs keep what they already derived.
3227    #[test]
3228    fn the_prune_warning_names_what_becomes_unreadable() {
3229        // `beforeEpoch: 5` → floor 4: rung 5 still yields epoch 4, so epochs 1 to 3 go
3230        // (`rooms/epoch/prune/0.2`, *The floor, and the off-by-one*).
3231        let w = prune_warning("did:example:room", 5, 7, Some("chained"));
3232        assert!(w.contains("cannot be undone"), "{w}");
3233        assert!(w.contains("epochs 1 to 3"), "{w}");
3234        assert!(w.contains("epoch floor (4)"), "{w}");
3235        assert!(w.contains("now at epoch 7"), "{w}");
3236        assert!(w.contains("The records stay"), "{w}");
3237        assert!(w.contains("members' VTAs"), "{w}");
3238        assert!(!w.contains("fromJoin"), "{w}");
3239
3240        assert!(prune_warning("r", 3, 4, None).contains("unreadable for good"));
3241        assert!(prune_warning("r", 3, 4, None).contains("epoch 1 "));
3242        assert!(prune_warning("r", 2, 3, None).contains("nothing becomes unreadable"));
3243        assert!(prune_warning("r", 3, 4, Some("fromJoin")).contains("keeps no chain"));
3244    }
3245
3246    /// `notAhead`, refused locally with the highest floor that works, as a command.
3247    #[test]
3248    fn a_floor_at_the_current_epoch_suggests_the_one_below() {
3249        let t = prune_target();
3250        let msg = not_ahead_message(&t, 7, 7);
3251        assert!(msg.contains("current epoch 7"), "{msg}");
3252        assert!(
3253            msg.contains(
3254                "pnm rooms prune --room did:webvh:Qm:example.com:rooms:northwind \
3255                 --host https://host.example --host-did did:web:host.example \
3256                 --before-epoch 6 --reason '<why>' --yes"
3257            ),
3258            "{msg}"
3259        );
3260        // At epoch 1 there is no floor below to suggest.
3261        assert!(!not_ahead_message(&t, 1, 1).contains("pnm rooms prune"));
3262    }
3263
3264    /// The host's declared codes are answered with the fix, not the bare error.
3265    #[test]
3266    fn a_hosts_prune_refusal_is_explained() {
3267        let t = prune_target();
3268        let refusal = |code: &str| vtc_client::VtcError::Http {
3269            status: 400,
3270            body: json!({
3271                "type": "https://trusttasks.org/spec/trust-task-error/0.1",
3272                "payload": { "code": code, "message": "no" },
3273            })
3274            .to_string(),
3275        };
3276        let msg = prune_refused(refusal("rooms/epoch/prune:notAhead"), &t, 7, 7);
3277        assert!(msg.contains("--before-epoch 6"), "{msg}");
3278        let msg = prune_refused(refusal("permissionDenied"), &t, 3, 7);
3279        assert!(msg.contains("needs `admin`"), "{msg}");
3280        assert!(msg.contains("issue-authority --action admin"), "{msg}");
3281        let msg = prune_refused(refusal("rooms/epoch/prune:chainTooDeep"), &t, 3, 7);
3282        assert!(!msg.contains("needs `admin`"), "{msg}");
3283    }
3284
3285    /// `pruned: 0` is a success, and the reach printed is `earliestRung`, not the floor.
3286    #[test]
3287    fn a_prune_outcome_reports_reach_not_the_request() {
3288        let r = |pruned, earliest_rung| vtc_client::rooms::PruneResponse {
3289            room_id: "did:example:room".into(),
3290            pruned,
3291            earliest_rung,
3292        };
3293        let none = prune_outcome(&r(0, 3));
3294        assert!(none.contains("nothing to drop"), "{none}");
3295        assert!(none.contains("reaches back to epoch 3"), "{none}");
3296        assert!(prune_outcome(&r(1, 5)).contains("1 rung dropped"));
3297        assert!(prune_outcome(&r(4, 5)).contains("4 rungs dropped"));
3298    }
3299
3300    fn rejected(code: &str, details: Value) -> VtaError {
3301        VtaError::Rejected {
3302            code: code.into(),
3303            message: "refused".into(),
3304            details: Some(details),
3305        }
3306    }
3307
3308    /// `earlierRemovalRefused` at add 0.3 / renew 0.2 / remove 0.4 is explained alike; a
3309    /// `hostRefused` (`{code, reason}`, or `{stage, code}`) is not.
3310    #[test]
3311    fn an_earlier_removal_is_explained_whichever_version_reported_it() {
3312        let d =
3313            json!({ "member": "did:key:zEarlier", "code": "permissionDenied", "listKept": true });
3314        for code in [
3315            "rooms/owner/group/add:earlierRemovalRefused",
3316            "rooms/owner/group/renew:earlierRemovalRefused",
3317            "rooms/owner/group/remove:earlierRemovalRefused",
3318        ] {
3319            let said = earlier_removal_refused(
3320                &rejected(code, d.clone()),
3321                "did:room",
3322                "did:host",
3323                None,
3324                "admit did:key:zNew",
3325            )
3326            .unwrap_or_else(|| panic!("{code} is an earlier removal"));
3327            assert!(said.starts_with("Nothing was done"), "{said}");
3328            assert!(said.contains("did:key:zEarlier"), "{said}");
3329            assert!(
3330                said.contains("pnm rooms owner revoke --room did:room"),
3331                "{said}"
3332            );
3333            assert!(said.contains("again to admit did:key:zNew"), "{said}");
3334        }
3335        let own = rejected(
3336            "rooms/owner/group/add:hostRefused",
3337            json!({ "code": "permissionDenied", "reason": "not today" }),
3338        );
3339        assert!(earlier_removal_refused(&own, "r", "h", None, "x").is_none());
3340        let kept_not = rejected(
3341            "rooms/owner/group/renew:earlierRemovalRefused",
3342            json!({ "member": "did:key:zEarlier", "code": "rooms/revocations/put:listInvalid",
3343                "listKept": false }),
3344        );
3345        let said = earlier_removal_refused(&kept_not, "r", "h", None, "x").unwrap();
3346        assert!(said.contains("discarded"), "{said}");
3347    }
3348
3349    /// `vtc/rooms/memberships/list/0.1`'s table: the same answers, the same standing.
3350    #[test]
3351    fn a_rooms_standing_follows_the_one_record() {
3352        use RoomStanding::*;
3353        assert_eq!(RoomStanding::of("offered", true, false, false), Offered);
3354        assert_eq!(RoomStanding::of("admitted", false, false, false), Joined);
3355        assert_eq!(RoomStanding::of("admitted", true, true, true), Joined);
3356        assert_eq!(RoomStanding::of("admitted", true, false, true), Unfinished);
3357        assert_eq!(
3358            RoomStanding::of("admitted", true, false, false),
3359            NeedsReadmission
3360        );
3361        assert_eq!(RoomStanding::of("removed", true, true, false), Removed);
3362    }
3363
3364    fn minted(authority: Value) -> Value {
3365        json!({
3366            "presentation": {
3367                "membership": "vmc-blob",
3368                "authority": authority,
3369            },
3370            "expiresAt": "2026-09-07T16:00:00Z",
3371        })
3372    }
3373
3374    fn vtc_target() -> RoomTarget<'static> {
3375        RoomTarget {
3376            host_url: "https://community.example/v1",
3377            host_did: Some("did:webvh:community.example"),
3378            room_id: "did:webvh:example.com:rooms:northwind",
3379        }
3380    }
3381
3382    const CATCH_UP: &str = "pnm rooms keys commit --room did:webvh:example.com:rooms:northwind \
3383                            --host https://community.example/v1 --host-did did:webvh:community.example";
3384
3385    /// An offer's accept line carries the offer's own room DID and runs as pasted.
3386    #[test]
3387    fn an_offer_prints_its_accept_command_with_the_room_filled_in() {
3388        let target = OfferTarget {
3389            vtc_url: "https://vtc.example/v1",
3390            vtc_did: "did:webvh:Qm1:vtc.example",
3391            member: "did:webvh:Qm2:alice.example",
3392        };
3393        assert_eq!(
3394            accept_command("did:webvh:Qm3:vtc.example:room-dids:ab12", &target),
3395            "pnm rooms offers accept --room did:webvh:Qm3:vtc.example:room-dids:ab12 \
3396             --vtc https://vtc.example/v1 --vtc-did did:webvh:Qm1:vtc.example \
3397             --member did:webvh:Qm2:alice.example"
3398        );
3399    }
3400
3401    fn offer_v2(description: Option<&str>) -> Value {
3402        let mut o = json!({
3403            "roomId": "did:webvh:Qm3:vtc.example:room-dids:ab12",
3404            "host": "did:webvh:Qm1:vtc.example",
3405            "label": "Board\u{1b}[2J papers",
3406            "visibility": "private",
3407            "actions": ["read", "write"],
3408            "validUntil": "2026-12-01T00:00:00Z",
3409            "offeredAt": "2026-10-01T00:00:00Z",
3410            "state": "open",
3411            "credentials": {
3412                "membership": "{}",
3413                "authority": "{}",
3414                "invitation": "inv"
3415            }
3416        });
3417        if let Some(d) = description {
3418            o["description"] = json!(d);
3419        }
3420        o
3421    }
3422
3423    fn offer_target() -> OfferTarget<'static> {
3424        OfferTarget {
3425            vtc_url: "https://vtc.example/v1",
3426            vtc_did: "did:webvh:Qm1:vtc.example",
3427            member: "did:webvh:Qm2:alice.example",
3428        }
3429    }
3430
3431    /// `vtc/rooms/offers/list/0.2`: an offer shows what its room is for, on one line of its
3432    /// own, with every control character the community wrote replaced — a line break
3433    /// cannot forge a line of this client's, an escape cannot reach the terminal.
3434    #[test]
3435    fn an_offer_shows_its_description_on_one_inert_line() {
3436        let offer: CommunityOffer = serde_json::from_value(offer_v2(Some(
3437            "Papers for the board:\nagenda\tminutes\u{7}\r\n  accept: rm -rf ~",
3438        )))
3439        .unwrap();
3440        let text = offer_lines(&offer, &offer_target());
3441        let lines: Vec<&str> = text.lines().collect();
3442        assert_eq!(lines.len(), 3, "{text}");
3443        assert!(lines[0].contains("Board[2J papers"), "{text}");
3444        assert!(
3445            !text.contains('\u{1b}') && !text.contains('\u{7}'),
3446            "{text}"
3447        );
3448        assert_eq!(
3449            lines[1],
3450            "  about: Papers for the board: agenda minutes     accept: rm -rf ~"
3451        );
3452        assert!(
3453            lines[2].starts_with("  accept: pnm rooms offers accept"),
3454            "{text}"
3455        );
3456
3457        // A room without a description (or an offer read at 0.1) prints no `about` line.
3458        let bare: CommunityOffer = serde_json::from_value(offer_v2(None)).unwrap();
3459        assert!(!offer_lines(&bare, &offer_target()).contains("about:"));
3460    }
3461
3462    /// `vtc/rooms/offers/list/0.4`: the offers, and the renewals of rooms the member is in
3463    /// (`renewsAt`), each printed with the command that collects it.
3464    #[test]
3465    fn an_offers_list_0_4_page_carries_renewals_and_their_renew_command() {
3466        let (items, _, renewals) = offers_page(json!({
3467            "items": [offer_v2(None)],
3468            "renewals": [{
3469                "roomId": "did:webvh:Qm:rooms.example:wg", "host": "did:example:community",
3470                "label": "Working group", "visibility": "attributed", "actions": ["read"],
3471                "validUntil": "2027-11-10T00:00:00Z", "renewsAt": "2026-11-10T00:00:00Z",
3472                "issuedAt": "2026-10-20T09:00:00Z",
3473                "credentials": { "membership": "{}", "authority": "{}" },
3474            }],
3475        }))
3476        .expect("a 0.4 page");
3477        assert_eq!(items.len(), 1);
3478        assert_eq!(renewals.len(), 1);
3479        let lines = renewal_lines(&renewals[0], &offer_target());
3480        assert!(lines.contains("renewal  until 2027-11-10"), "{lines}");
3481        assert!(
3482            lines.contains("pnm rooms offers renew --room did:webvh:Qm:rooms.example:wg"),
3483            "{lines}"
3484        );
3485    }
3486
3487    /// A room DID is the community's text: anything a shell would act on is quoted.
3488    #[test]
3489    fn a_room_id_a_shell_would_read_is_quoted() {
3490        assert_eq!(shell_word("did:web:x"), "did:web:x");
3491        assert_eq!(shell_word("a;rm -rf ~"), "'a;rm -rf ~'");
3492        assert_eq!(shell_word("it's"), r"'it'\''s'");
3493        assert_eq!(shell_word("$(x)"), "'$(x)'");
3494        assert_eq!(shell_word(""), "''");
3495    }
3496
3497    /// A put sealed under a superseded epoch (`epochMismatch`, read off the host's
3498    /// `trust-task-error` document) names the catch-up command with the real values, not
3499    /// the raw HTTP body.
3500    #[test]
3501    fn a_superseded_epoch_put_prints_keys_commit_with_the_real_values() {
3502        let body = json!({
3503            "type": "https://trusttasks.org/spec/rooms/records/put/0.1#error",
3504            "payload": {
3505                "code": "rooms/records/put:epochMismatch",
3506                "message": "record is sealed under epoch 2, room `r` is at 3",
3507            },
3508        });
3509        let err: Box<dyn std::error::Error> = Box::new(vtc_client::VtcError::Http {
3510            status: 400,
3511            body: body.to_string(),
3512        });
3513        let text = put_refused(err, &vtc_target()).to_string();
3514        assert!(text.contains(CATCH_UP), "{text}");
3515        assert!(text.contains("sealed under epoch 2"), "{text}");
3516        assert!(!text.contains("HTTP 400"), "{text}");
3517    }
3518
3519    /// Any other refusal passes through untouched, and no `--host-did` is invented for a
3520    /// host the operator named none for.
3521    #[test]
3522    fn other_put_refusals_pass_through_and_no_host_did_is_invented() {
3523        let t = vtc_target();
3524        assert!(put_refusal_hint("rooms/records/put:recordTooLarge", "", &t).is_none());
3525        let err: Box<dyn std::error::Error> = Box::new(vtc_client::VtcError::Http {
3526            status: 500,
3527            body: "not a document".into(),
3528        });
3529        assert_eq!(
3530            put_refused(err, &t).to_string(),
3531            "VTC returned HTTP 500: not a document"
3532        );
3533
3534        let bare = RoomTarget {
3535            host_did: None,
3536            ..vtc_target()
3537        };
3538        assert_eq!(
3539            keys_commit_command(&bare),
3540            "pnm rooms keys commit --room did:webvh:example.com:rooms:northwind \
3541             --host https://community.example/v1"
3542        );
3543    }
3544
3545    fn open_rejected(reason: &str, extra: Value) -> VtaError {
3546        let mut details = json!({ "reason": reason });
3547        details
3548            .as_object_mut()
3549            .unwrap()
3550            .extend(extra.as_object().unwrap().clone());
3551        VtaError::Rejected {
3552            code: "rooms/keys/open:unknownEpoch".into(),
3553            message: "record is sealed under epoch 4".into(),
3554            details: Some(details),
3555        }
3556    }
3557
3558    /// A member removed from the group is told so, and is not sent to `keys commit`,
3559    /// which can never open what was sealed after the removal.
3560    #[test]
3561    fn a_removed_member_is_told_it_was_removed_not_to_catch_up() {
3562        let text = open_refusal_hint(
3563            &open_rejected("removed", json!({ "removedAtEpoch": 3 })),
3564            &vtc_target(),
3565        );
3566        assert!(
3567            text.contains("You were removed from this room's group at epoch 3"),
3568            "{text}"
3569        );
3570        assert!(!text.contains("keys commit"), "{text}");
3571    }
3572
3573    fn file_key_rejected(reason: &str, message: &str) -> VtaError {
3574        VtaError::Rejected {
3575            code: "rooms/keys/file-key:unknownEpoch".into(),
3576            message: message.into(),
3577            details: Some(json!({ "reason": reason })),
3578        }
3579    }
3580
3581    /// `pnm rooms file get` names the same remedies a record's refusal does. A removed
3582    /// member's file comes as `removed` (`rooms/keys/file-key/0.2`), and the operator is
3583    /// told so and not sent to `keys commit` — the caller MUST NOT retry after catching up.
3584    #[test]
3585    fn a_file_key_refusal_names_the_remedy() {
3586        let removed = VtaError::Rejected {
3587            code: "rooms/keys/file-key:unknownEpoch".into(),
3588            message: "the file is sealed under epoch 4, and this member was removed".into(),
3589            details: Some(json!({ "reason": "removed", "removedAtEpoch": 3 })),
3590        };
3591        let text = file_key_refusal_hint(&removed, &vtc_target());
3592        assert!(
3593            text.contains("You were removed from this room's group at epoch 3"),
3594            "{text}"
3595        );
3596        assert!(text.contains("Files sealed since then"), "{text}");
3597        assert!(!text.contains("keys commit"), "{text}");
3598
3599        // Only behind: catch up.
3600        let behind_commit = file_key_refusal_hint(
3601            &file_key_rejected("notDelivered", "the file is sealed under epoch 4"),
3602            &vtc_target(),
3603        );
3604        assert!(behind_commit.contains(CATCH_UP), "{behind_commit}");
3605
3606        let behind = file_key_refusal_hint(
3607            &file_key_rejected("beyondChain", "sealed under epoch 1"),
3608            &vtc_target(),
3609        );
3610        assert!(behind.contains("pnm rooms keys backfill"), "{behind}");
3611        assert!(behind.contains("The file is older"), "{behind}");
3612
3613        // An `open` code is not a file-key one: the reason is not read across tasks.
3614        let wrong_task = VtaError::Rejected {
3615            code: "rooms/keys/open:unknownEpoch".into(),
3616            message: "x".into(),
3617            details: Some(json!({ "reason": "beyondChain" })),
3618        };
3619        assert!(
3620            file_key_refusal_hint(&wrong_task, &vtc_target()).contains("did not open with any key")
3621        );
3622    }
3623
3624    /// A member who is only behind gets the catch-up command with the real values.
3625    #[test]
3626    fn a_member_behind_is_told_to_catch_up() {
3627        let text = open_refusal_hint(
3628            &open_rejected("notDelivered", json!({ "heldEpoch": 2 })),
3629            &vtc_target(),
3630        );
3631        assert!(text.contains(CATCH_UP), "{text}");
3632    }
3633
3634    /// `pnm rooms renew` refuses a sealed room and names the command that renews it
3635    /// (rooms/keys/act/0.3 producer item 4); an open room renews as before.
3636    #[test]
3637    fn renew_refuses_a_sealed_room_and_names_group_renew() {
3638        let target = RoomTarget {
3639            host_url: "https://community.example/v1",
3640            host_did: Some("did:webvh:community.example"),
3641            room_id: "did:webvh:example.com:rooms:northwind",
3642        };
3643        assert!(refuse_sealed_renew("open", &target).is_ok());
3644        for tier in ["attributed", "private"] {
3645            let e = refuse_sealed_renew(tier, &target).expect_err(tier);
3646            assert!(
3647                e.contains(
3648                    "pnm rooms owner group-renew --room did:webvh:example.com:rooms:northwind \
3649                     --host-did did:webvh:community.example"
3650                ),
3651                "{e}"
3652            );
3653            assert!(e.contains(tier), "{e}");
3654        }
3655        let no_did = RoomTarget {
3656            host_did: None,
3657            ..target
3658        };
3659        let e = refuse_sealed_renew("private", &no_did).unwrap_err();
3660        assert!(e.contains("--host-did <the host's DID>"), "{e}");
3661    }
3662
3663    #[test]
3664    fn a_minted_presentation_becomes_a_session() {
3665        let session = session_from_minted("did:webvh:room", &minted(json!(["leaf", "root"])))
3666            .expect("a well-formed reply rebuilds");
3667        assert_eq!(session.room_id(), "did:webvh:room");
3668        assert_eq!(session.chain_depth(), 2, "leaf first, root last");
3669    }
3670
3671    /// A private room's presentation carries the same-subject proof, and losing
3672    /// it on the way through would turn a valid call into a refusal the
3673    /// operator cannot explain.
3674    #[test]
3675    fn a_subject_binding_survives_the_rebuild() {
3676        let mut m = minted(json!(["leaf"]));
3677        m["presentation"]["subjectBinding"] = json!("zk-proof-blob");
3678        let session =
3679            session_from_minted("did:webvh:room", &m).expect("a private presentation rebuilds");
3680        assert_eq!(session.chain_depth(), 1);
3681    }
3682
3683    /// Each missing member is refused rather than defaulted. A reply this
3684    /// client does not understand must not become a request the host refuses
3685    /// for reasons that read as a credential problem.
3686    #[test]
3687    fn an_unreadable_reply_is_refused_rather_than_guessed() {
3688        assert!(
3689            session_from_minted("r", &json!({})).is_err(),
3690            "no presentation"
3691        );
3692        assert!(
3693            session_from_minted("r", &json!({ "presentation": { "authority": ["a"] } })).is_err(),
3694            "no membership"
3695        );
3696        assert!(
3697            session_from_minted("r", &json!({ "presentation": { "membership": "m" } })).is_err(),
3698            "no chain"
3699        );
3700        assert!(
3701            session_from_minted("r", &minted(json!([]))).is_err(),
3702            "an empty chain authorizes nothing and must not be sent"
3703        );
3704    }
3705
3706    /// A link that is not a string would otherwise be filtered out, silently
3707    /// shortening the chain — and a shorter chain confers less than the VTA
3708    /// minted, so the call would fail somewhere far from the cause.
3709    #[test]
3710    fn a_malformed_link_does_not_silently_shorten_the_chain() {
3711        let err = session_from_minted("r", &minted(json!(["leaf", 7])))
3712            .expect_err("a non-string link is refused");
3713        assert!(
3714            err.to_string().contains("non-string"),
3715            "the error must name the cause: {err}"
3716        );
3717    }
3718
3719    /// Another member's text reaches a terminal: an escape sequence in it must not.
3720    #[test]
3721    fn untrusted_text_loses_its_control_characters_but_keeps_its_lines() {
3722        assert_eq!(inert("a\x1b[2Jb\nc\td\x07"), "a[2Jb\nc\td");
3723    }
3724
3725    /// A file name chosen by another member must not choose where it is written.
3726    #[test]
3727    fn a_file_name_cannot_leave_the_directory_or_hide() {
3728        assert_eq!(local_name("../../etc/passwd"), "passwd");
3729        assert_eq!(local_name("C:\\Users\\x\\evil.exe"), "evil.exe");
3730        assert_eq!(local_name(".bashrc"), "bashrc");
3731        assert_eq!(local_name("term sheet.pdf"), "term sheet.pdf");
3732        assert_eq!(local_name("../"), "room-file");
3733        assert_eq!(local_name("a\nb.txt"), "a b.txt");
3734    }
3735
3736    /// Record keys on a sealed tier say nothing, and do not repeat.
3737    #[test]
3738    fn opaque_keys_are_fresh_and_uninformative() {
3739        let (a, b) = (opaque_key(), opaque_key());
3740        assert_ne!(a, b);
3741        assert_eq!(a.len(), 22, "16 random bytes, base64url");
3742    }
3743
3744    #[test]
3745    fn pin_flags_keep_unchanged_distinct_from_unpinned() {
3746        assert_eq!(pinned_from_flags(true, false), Some(true));
3747        assert_eq!(pinned_from_flags(false, true), Some(false));
3748        assert_eq!(
3749            pinned_from_flags(false, false),
3750            None,
3751            "neither flag must leave the pin alone, not clear it"
3752        );
3753    }
3754}