1use 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
57pub 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 VtcClient::anonymous(self.host_url, self.host_did.unwrap_or("did:key:zHost"))
77 }
78}
79
80async 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
108pub 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 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
151async 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
165async 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
199pub(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
210fn 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
219pub 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
234pub struct RoomSigner<'a> {
241 pub did: &'a str,
242 pub key_multibase: &'a str,
243}
244
245pub 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 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 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
303async 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 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
337pub 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 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 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
418pub 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#[derive(Debug, Clone, Default)]
482pub struct RecordProvenance {
483 pub basis: Option<vtc_client::rooms::body::RecordBasis>,
485 pub basis_named: bool,
487 pub confidence: Option<f64>,
489 pub cite_records: Vec<String>,
491 pub cite_credentials: Vec<String>,
493 pub purposes: Vec<String>,
495 pub use_note: Option<String>,
497 pub valid_until: Option<String>,
499}
500
501impl RecordProvenance {
502 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 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
555fn 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
570fn 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
620pub 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 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
667pub 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 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
708fn 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
726pub 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 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#[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
829fn prune_warning(room_id: &str, before_epoch: u32, current: u32, policy: Option<&str>) -> String {
832 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
870fn 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
887fn 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
902fn 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 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
927fn 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#[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 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
1046fn 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
1075fn 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
1090fn 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
1116pub 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#[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
1205async 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
1220fn 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
1236fn 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
1252pub 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
1292const BLOB_MANIFEST: &str = "blob.json";
1305const FILE_MANIFEST: &str = "file.json";
1307const 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
1324pub 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 let name = path
1340 .file_name()
1341 .and_then(|n| n.to_str())
1342 .ok_or("the file has no usable name")?;
1343
1344 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
1393pub 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 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 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 file.name.escape_debug(),
1436 plaintext.len(),
1437 out.display()
1438 );
1439 Ok(())
1440}
1441
1442const SEALED_WRITE_ATTEMPTS: usize = 4;
1448
1449fn inert(text: &str) -> String {
1452 text.chars()
1453 .filter(|c| !c.is_control() || *c == '\n' || *c == '\t')
1454 .collect()
1455}
1456
1457fn 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
1463fn 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
1471async fn room_tier(
1474 client: &VtaClient,
1475 target: &RoomTarget<'_>,
1476) -> Result<(bool, String), Box<dyn std::error::Error>> {
1477 let (sealed, member, _) = room_tier_and_epoch(client, target).await?;
1478 Ok((sealed, member))
1479}
1480
1481async fn room_tier_and_epoch(
1484 client: &VtaClient,
1485 target: &RoomTarget<'_>,
1486) -> Result<(bool, String, u64), Box<dyn std::error::Error>> {
1487 use trust_tasks_rs::Payload as _;
1488 let (member, reply) = act_and_send(
1489 client,
1490 target,
1491 vti_specs::rooms::info::v0_3::Payload::TYPE_URI,
1492 serde_json::json!({ "roomId": target.room_id }),
1493 )
1494 .await
1495 .map_err(|e| format!("could not read the room's tier (rooms/info): {e}"))?;
1496 let visibility = reply
1497 .get("visibility")
1498 .and_then(Value::as_str)
1499 .ok_or("the host's rooms/info response names no visibility")?;
1500 let epoch = reply
1501 .get("epoch")
1502 .and_then(Value::as_u64)
1503 .ok_or("the host's rooms/info response names no epoch")?;
1504 Ok((visibility != "open", member, epoch))
1505}
1506
1507async fn write_sealed(
1516 client: &VtaClient,
1517 target: &RoomTarget<'_>,
1518 content: SealedRecordBody,
1519 expected_version: Option<u64>,
1520) -> Result<u64, Box<dyn std::error::Error>> {
1521 write_sealed_naming(client, target, content, expected_version, None).await
1522}
1523
1524async fn write_sealed_naming(
1526 client: &VtaClient,
1527 target: &RoomTarget<'_>,
1528 content: SealedRecordBody,
1529 expected_version: Option<u64>,
1530 blobs: Option<Vec<String>>,
1531) -> Result<u64, Box<dyn std::error::Error>> {
1532 let head = list_page(client, target, None, None, None)
1533 .await?
1534 .head_version
1535 .unwrap_or(0);
1536
1537 let key = content.key.clone();
1538 let mut intended = head + 1;
1539 let mut expected = expected_version;
1540 let mut landed = 0;
1541 for _ in 0..SEALED_WRITE_ATTEMPTS {
1542 let unsigned = serde_json::to_vec(&content.clone().at_version(intended))?;
1544 let sealed = client
1545 .room_seal_as_principal(target.room_id, &key, intended, &unsigned)
1546 .await?
1547 .sealed;
1548 let mut payload = serde_json::json!({
1549 "roomId": target.room_id,
1550 "key": key,
1551 "sealed": {
1552 "ciphertext": sealed.ciphertext,
1553 "nonce": sealed.nonce,
1554 "epoch": sealed.epoch.get(),
1555 },
1556 });
1557 if let Some(v) = expected {
1558 payload["expectedVersion"] = serde_json::json!(v);
1559 }
1560 if let Some(b) = &blobs {
1561 payload["blobs"] = serde_json::json!(b);
1562 }
1563 let put_type = vti_rooms::wire::ROOMS_RECORDS_PUT_V3_TYPE;
1564 let (_, reply) = act_and_send(client, target, put_type, payload)
1565 .await
1566 .map_err(|e| put_refused(e, target))?;
1567 let put: vti_rooms::wire::PutRecordResponse = parse_reply("rooms/records/put", reply)?;
1568 landed = put.version;
1569 if landed == intended {
1570 return Ok(landed);
1571 }
1572 expected = Some(landed);
1575 intended = landed + 1;
1576 }
1577 Err(format!(
1578 "{key} is stored at version {landed} but sealed for another version, so it does not \
1579 open: the room kept moving under {SEALED_WRITE_ATTEMPTS} attempts. Write it again \
1580 with --expected-version {landed}."
1581 )
1582 .into())
1583}
1584
1585fn keys_commit_command(target: &RoomTarget<'_>) -> String {
1589 let mut cmd = format!(
1590 "pnm rooms keys commit --room {} --host {}",
1591 target.room_id, target.host_url
1592 );
1593 if let Some(did) = target.host_did {
1594 cmd.push_str(&format!(" --host-did {did}"));
1595 }
1596 cmd
1597}
1598
1599fn put_refused(
1603 e: Box<dyn std::error::Error>,
1604 target: &RoomTarget<'_>,
1605) -> Box<dyn std::error::Error> {
1606 let refusal = e
1607 .downcast_ref::<vtc_client::VtcError>()
1608 .and_then(vtc_client::git_ns::task_error);
1609 match refusal {
1610 Some((code, message)) => match put_refusal_hint(&code, &message, target) {
1611 Some(hint) => hint.into(),
1612 None => e,
1613 },
1614 None => e,
1615 }
1616}
1617
1618fn put_refusal_hint(code: &str, message: &str, target: &RoomTarget<'_>) -> Option<String> {
1620 use vti_specs::rooms::records::put;
1621 let epoch_mismatch = [
1622 put::v0_1::error_codes::EPOCH_MISMATCH.code,
1623 put::v0_2::error_codes::EPOCH_MISMATCH.code,
1624 ];
1625 if !epoch_mismatch.contains(&code) {
1626 return None;
1627 }
1628 Some(format!(
1629 "the host refused the record: {message}\n\n\
1630 The room has moved on to a newer epoch — it was renewed, or its membership changed — \
1631 and this VTA has not applied the commit that moved it, so it sealed the record under \
1632 the old epoch. Nothing was written. Catch up, then write again:\n\n {}",
1633 keys_commit_command(target)
1634 ))
1635}
1636
1637fn open_refusal_hint(e: &VtaError, target: &RoomTarget<'_>) -> String {
1639 use vti_specs::rooms::keys::open::v0_2::error_codes::UNKNOWN_EPOCH;
1640 epoch_refusal_hint(e, target, UNKNOWN_EPOCH.code, Sealed::Record)
1641}
1642
1643fn file_key_refusal_hint(e: &VtaError, target: &RoomTarget<'_>) -> String {
1646 use vti_specs::rooms::keys::file_key::v0_2::error_codes::UNKNOWN_EPOCH;
1647 epoch_refusal_hint(e, target, UNKNOWN_EPOCH.code, Sealed::File)
1648}
1649
1650#[derive(Clone, Copy)]
1652enum Sealed {
1653 Record,
1654 File,
1655}
1656
1657impl Sealed {
1658 fn noun(self) -> &'static str {
1659 match self {
1660 Self::Record => "record",
1661 Self::File => "file",
1662 }
1663 }
1664}
1665
1666fn epoch_refusal_hint(e: &VtaError, target: &RoomTarget<'_>, code: &str, sealed: Sealed) -> String {
1674 let noun = sealed.noun();
1675 let text = e.to_string();
1676 let reason = (e.rejection_code() == Some(code))
1677 .then(|| e.rejection_details())
1678 .flatten()
1679 .and_then(|d| d.get("reason"))
1680 .and_then(Value::as_str);
1681 let backfill = || {
1682 format!(
1683 "The {noun} is older than any key this VTA holds — it was written before you \
1684 joined. Fetch the room's history: `pnm rooms keys backfill --room {} --host-did \
1685 {}`.",
1686 target.room_id,
1687 target.host_did.unwrap_or("<host-did>")
1688 )
1689 };
1690 let hint = match reason {
1691 Some("removed") => {
1692 let at = e
1693 .rejection_details()
1694 .and_then(|d| d.get("removedAtEpoch"))
1695 .and_then(Value::as_u64)
1696 .map(|n| format!(" at epoch {n}"))
1697 .unwrap_or_default();
1698 format!(
1699 "You were removed from this room's group{at}. {}s sealed since then will \
1700 never open for you, and no commit will change that; what was sealed before \
1701 your removal still opens. Rejoining takes a fresh invitation from the room's \
1702 owner.",
1703 capitalised(noun)
1704 )
1705 }
1706 Some("notDelivered") => format!(
1707 "The {noun} is newer than this VTA's group: a commit has not been applied here. \
1708 Catch up, then try again:\n\n {}",
1709 keys_commit_command(target),
1710 ),
1711 Some("beyondChain") => backfill(),
1712 _ if text.contains("cannot reach") => backfill(),
1713 _ if text.contains("has not been delivered") => format!(
1714 "The {noun} is newer than this VTA's group. Catch up with `{}`; if that says you \
1715 were removed, nothing sealed after your removal will open.",
1716 keys_commit_command(target)
1717 ),
1718 _ => format!("The {noun} did not open with any key this VTA holds."),
1719 };
1720 format!("{e}\n\n{hint}")
1721}
1722
1723fn capitalised(word: &str) -> String {
1724 let mut chars = word.chars();
1725 chars
1726 .next()
1727 .map(|c| c.to_uppercase().chain(chars).collect())
1728 .unwrap_or_default()
1729}
1730
1731async fn open_and_verify(
1738 client: &VtaClient,
1739 target: &RoomTarget<'_>,
1740 key: &str,
1741 version: u64,
1742 sealed: &SealedContent,
1743 host_author: Option<&str>,
1744) -> Result<VerifiedRecordBody, Box<dyn std::error::Error>> {
1745 let sealed_at = sealed
1746 .sealed_at(version)
1747 .map_err(|why| format!("refusing record {key}: the host says {why}"))?;
1748 let opened = client
1749 .room_open(
1750 target.room_id,
1751 key,
1752 sealed_at,
1753 &sealed.ciphertext,
1754 &sealed.nonce,
1755 sealed.epoch,
1756 )
1757 .await
1758 .map_err(|e| open_refusal_hint(&e, target))?;
1761 let plaintext = opened
1762 .get("plaintext")
1763 .and_then(Value::as_str)
1764 .ok_or("the VTA opened the record but returned no plaintext")?;
1765 let bytes =
1766 base64::Engine::decode(&base64::engine::general_purpose::URL_SAFE_NO_PAD, plaintext)?;
1767
1768 let resolver = vta_sdk::trust_task_proof::TrustTaskVmResolver::from_optional(
1769 vta_sdk::resolver::shared_did_resolver_from_env().await.ok(),
1770 );
1771 let opened_at = opened
1773 .pointer("/ext/org.openvtc.rooms/sealedAt")
1774 .and_then(Value::as_u64)
1775 .unwrap_or(sealed_at);
1776 if opened_at > sealed_at {
1777 return Err(format!(
1778 "refusing record {key}: the VTA opened it at version {opened_at}, past the {sealed_at} it was asked for"
1779 )
1780 .into());
1781 }
1782 let location = RecordLocation {
1783 room_id: target.room_id,
1784 key,
1785 version: opened_at,
1786 };
1787 Ok(verify_record_body(&bytes, location, host_author, &resolver)
1788 .await
1789 .map_err(|e| format!("refusing record {key}: {e}"))?)
1790}
1791
1792const DIRECT_PART: usize = 8 * 1024 * 1024;
1795const SINGLE_PART_MAX: usize = 16 * 1024 * 1024;
1797const DIRECT_BEGIN_ATTEMPTS: usize = 64;
1799
1800fn direct_plan(ciphertext: &[u8]) -> (usize, Value) {
1802 use sha2::Digest as _;
1803 let part = if ciphertext.len() <= SINGLE_PART_MAX {
1804 ciphertext.len().max(1)
1805 } else {
1806 DIRECT_PART
1807 };
1808 let digests: Vec<String> = ciphertext
1809 .chunks(part)
1810 .map(|c| vti_common::blob_store::sha256_digest_multibase(&sha2::Sha256::digest(c).into()))
1811 .collect();
1812 (
1813 part,
1814 serde_json::json!({ "partSize": part, "partCount": digests.len(), "partDigests": digests }),
1815 )
1816}
1817
1818pub async fn cmd_rooms_file_put(
1831 client: &VtaClient,
1832 target: RoomTarget<'_>,
1833 path: &std::path::Path,
1834 title: Option<String>,
1835 description: Option<String>,
1836 tags: Vec<String>,
1837 padme: bool,
1838) -> Result<(), Box<dyn std::error::Error>> {
1839 use trust_tasks_rs::Payload as _;
1840 use vti_rooms::files::{self, FileBinding, Padding};
1841 use vti_specs::rooms::blobs::upload;
1842
1843 let (sealed_tier, member, room_epoch) = room_tier_and_epoch(client, &target).await?;
1844
1845 let plaintext = std::fs::read(path)?;
1846 let name = path
1847 .file_name()
1848 .and_then(|n| n.to_str())
1849 .ok_or("the file has no usable name")?;
1850 let file_id = files::new_file_id();
1853 let (file_key, epoch) = if sealed_tier {
1857 let resp = client
1858 .room_file_key(target.room_id, &files::encode_file_id(&file_id), None)
1859 .await?;
1860 (file_key_bytes(&resp)?, resp.epoch.get())
1861 } else {
1862 (files::new_file_key(), room_epoch)
1863 };
1864 let mut sealed = files::seal_file(
1865 &file_key,
1866 FileBinding {
1867 room_id: target.room_id.to_string(),
1868 file_id,
1869 epoch,
1870 segment_size: files::DEFAULT_SEGMENT_SIZE,
1871 },
1872 name,
1873 None,
1874 &plaintext,
1875 if padme { Padding::Padme } else { Padding::None },
1876 )?;
1877 if !sealed_tier {
1878 sealed.file.file_key = Some(files::encode_file_key(&file_key));
1879 }
1880 let ciphertext: Vec<u8> = sealed.chunks.concat();
1881
1882 let (key_did, key_secret) = crate::local_keygen::generate_unbound_admin_did_key();
1885 let key_secret = zeroize::Zeroizing::new(key_secret);
1886 let (part_size, plan) = direct_plan(&ciphertext);
1887 let begin_payload = serde_json::json!({
1888 "roomId": target.room_id,
1889 "manifest": sealed.blob,
1890 "uploadKey": key_did,
1891 "directParts": plan,
1892 });
1893 let begin = || async {
1894 let (_, reply) = act_and_send(
1895 client,
1896 &target,
1897 upload::begin::v0_2::Payload::TYPE_URI,
1898 begin_payload.clone(),
1899 )
1900 .await?;
1901 parse_reply::<upload::begin::v0_2::Response>("rooms/blobs/upload/begin", reply)
1902 };
1903 let mut begun = begin().await?;
1904 let direct = wire_str(&begun.mode) == "direct";
1905 let upload_id = begun.upload_id.to_string();
1906 let host = target.client();
1907
1908 let sent: Result<(), Box<dyn std::error::Error>> = async {
1909 if begun.already_committed == Some(true) {
1910 return Ok(());
1911 }
1912 if direct {
1913 for _ in 0..DIRECT_BEGIN_ATTEMPTS {
1917 if begun.missing.is_empty() {
1918 return Ok(());
1919 }
1920 if begun.targets.is_empty() {
1921 return Err("the host answered direct mode with no targets".into());
1922 }
1923 for t in &begun.targets {
1924 let i = usize::try_from(t.index)
1925 .ok()
1926 .filter(|i| i * part_size < ciphertext.len().max(1))
1927 .ok_or("the host issued a target for a part that does not exist")?;
1928 let part =
1929 &ciphertext[i * part_size..((i + 1) * part_size).min(ciphertext.len())];
1930 vtc_client::rooms::files::put_part(t, part).await?;
1931 }
1932 begun = begin().await?;
1933 }
1934 return Err("the upload did not finish: the host kept asking for parts".into());
1935 }
1936 for index in &begun.missing {
1937 let i = usize::try_from(index.0)
1938 .ok()
1939 .filter(|i| *i < sealed.chunks.len())
1940 .ok_or_else(|| {
1941 format!("the host asked for chunk {}, which does not exist", index.0)
1942 })?;
1943 host.upload_chunk_v2(
1944 &upload_id,
1945 i as u64,
1946 &sealed.chunks[i],
1947 &key_did,
1948 &key_secret,
1949 )
1950 .await?;
1951 }
1952 Ok(())
1953 }
1954 .await;
1955
1956 let finish = |type_uri: &'static str| {
1958 let (upload_id, key_did, key_secret, member) = (
1959 upload_id.clone(),
1960 key_did.clone(),
1961 key_secret.clone(),
1962 member.clone(),
1963 );
1964 let target = ⌖
1965 async move {
1966 let payload = serde_json::json!({ "uploadId": upload_id });
1967 if direct {
1968 act_and_send_as(client, target, type_uri, payload, Some(&member))
1969 .await
1970 .map(|(_, reply)| reply)
1971 } else {
1972 let doc = vta_sdk::trust_task_sign::build_signed(
1973 type_uri,
1974 payload,
1975 &key_did,
1976 &key_secret,
1977 target.host_did.unwrap_or("did:key:zHost"),
1978 )
1979 .await
1980 .map_err(|e| format!("could not sign with the upload key: {e}"))?;
1981 let doc: Value = serde_json::from_str(&doc)?;
1982 Ok(target.client().send_signed_room_document(&doc).await?)
1983 }
1984 }
1985 };
1986 if let Err(e) = sent {
1987 let _ = finish(upload::abort::v0_2::Payload::TYPE_URI).await;
1989 return Err(e);
1990 }
1991 let committed: upload::commit::v0_2::Response = parse_reply(
1992 "rooms/blobs/upload/commit",
1993 finish(upload::commit::v0_2::Payload::TYPE_URI).await?,
1994 )?;
1995 let blob_ref = wire_str(&committed.blob_ref);
1996 if !files::same_digest(&blob_ref, &sealed.file.blob_ref) {
1997 return Err(format!(
1998 "the host committed {blob_ref}, not the blob sealed here ({})",
1999 sealed.file.blob_ref
2000 )
2001 .into());
2002 }
2003
2004 let key = opaque_key();
2005 let version = if sealed_tier {
2006 let mut content = SealedRecordBody::new(
2007 RecordLocation {
2008 room_id: target.room_id,
2009 key: &key,
2010 version: 1,
2011 },
2012 &member,
2013 );
2014 content.title = title;
2015 content.description = description;
2016 content.tags = tags;
2017 content.file = Some(sealed.file.clone());
2018 write_sealed_naming(
2019 client,
2020 &target,
2021 content,
2022 Some(0),
2023 Some(vec![blob_ref.clone()]),
2024 )
2025 .await?
2026 } else {
2027 let mut cleartext = serde_json::to_value(CleartextContent {
2030 title,
2031 description,
2032 tags,
2033 ..Default::default()
2034 })?;
2035 cleartext["file"] = serde_json::to_value(&sealed.file)?;
2036 let (_, reply) = act_and_send(
2037 client,
2038 &target,
2039 vti_rooms::wire::ROOMS_RECORDS_PUT_V4_TYPE,
2040 serde_json::json!({
2041 "roomId": target.room_id,
2042 "key": key,
2043 "cleartext": cleartext,
2044 "expectedVersion": 0,
2045 "blobs": [blob_ref],
2046 }),
2047 )
2048 .await
2049 .map_err(|e| put_refused(e, &target))?;
2050 parse_reply::<vti_rooms::wire::PutRecordResponse>("rooms/records/put", reply)?.version
2051 };
2052
2053 if crate::render::is_json_output() {
2054 crate::render::print_json(&serde_json::json!({
2055 "key": key,
2056 "version": version,
2057 "blobRef": blob_ref,
2058 "size": sealed.file.size,
2059 "mode": if direct { "direct" } else { "proxied" },
2060 }))?;
2061 return Ok(());
2062 }
2063 println!(
2064 "Put {name} ({} bytes, {}) as record {key} at version {version}, as {member}\n blobRef {blob_ref}\n Members fetch it with `pnm rooms file get {key}`.",
2065 sealed.file.size,
2066 if direct {
2067 "straight to the room's storage"
2068 } else {
2069 "through the host"
2070 }
2071 );
2072 Ok(())
2073}
2074
2075fn local_name(untrusted: &str) -> String {
2078 let base = untrusted
2079 .rsplit(['/', '\\'])
2080 .next()
2081 .unwrap_or_default()
2082 .trim_start_matches('.');
2083 let clean = inert(base).replace(['\n', '\t'], " ");
2084 if clean.trim().is_empty() {
2085 "room-file".to_string()
2086 } else {
2087 clean
2088 }
2089}
2090
2091pub async fn cmd_rooms_file_get(
2101 client: &VtaClient,
2102 target: RoomTarget<'_>,
2103 key: &str,
2104 out: Option<&std::path::Path>,
2105) -> Result<(), Box<dyn std::error::Error>> {
2106 use trust_tasks_rs::Payload as _;
2107 use vti_rooms::files::{self, BlobManifest};
2108 use vti_specs::rooms::blobs::{chunk as blob_chunk, get as blob_get};
2109
2110 let (member, record) = act_and_send(
2111 client,
2112 &target,
2113 vti_rooms::wire::ROOMS_RECORDS_GET_V2_TYPE,
2114 serde_json::json!({ "roomId": target.room_id, "key": key }),
2115 )
2116 .await?;
2117 let record: vtc_client::rooms::files::GetRecordV2Response =
2118 parse_reply("rooms/records/get", record)?;
2119 let (file, author, carried) = match record.cleartext.get("file") {
2122 Some(file) if record.sealed.is_none() => {
2123 let file: files::FileManifest = serde_json::from_value(file.clone())
2124 .map_err(|e| format!("the record's file manifest does not read: {e}"))?;
2125 let key = file
2126 .carried_key(true)?
2127 .ok_or("an open room's file manifest carries no key")?;
2128 let author = record
2129 .author
2130 .clone()
2131 .unwrap_or_else(|| "an unnamed member".to_string());
2132 (file, author, Some(key))
2133 }
2134 _ => {
2135 let sealed = record.sealed.as_ref().ok_or(
2136 "the record carries no file: it has neither a sealed body nor a `cleartext.file`, \
2137 or it has been retracted",
2138 )?;
2139 let sealed = SealedContent {
2140 ciphertext: sealed.ciphertext.clone(),
2141 nonce: sealed.nonce.clone(),
2142 epoch: u32::try_from(sealed.epoch.get())
2143 .map_err(|_| "the record's epoch is beyond what this client can represent")?,
2144 version: sealed.version.map(std::num::NonZeroU64::get),
2148 };
2149 let verified = open_and_verify(
2150 client,
2151 &target,
2152 key,
2153 record.version.get(),
2154 &sealed,
2155 record.author.as_deref(),
2156 )
2157 .await?;
2158 let file = verified
2159 .body()
2160 .file
2161 .clone()
2162 .ok_or("the record carries no file")?;
2163 file.carried_key(false)?;
2165 (file, verified.body().author.clone(), None)
2166 }
2167 };
2168 let named = record
2169 .blobs
2170 .iter()
2171 .flatten()
2172 .any(|b| files::same_digest(&wire_str(b), &file.blob_ref));
2173 if !named {
2174 return Err(
2175 "the host does not name the file's blob on this record; it may have been \
2176 released"
2177 .into(),
2178 );
2179 }
2180
2181 let (_, started) = act_and_send(
2182 client,
2183 &target,
2184 blob_get::v0_2::Payload::TYPE_URI,
2185 serde_json::json!({ "roomId": target.room_id, "blobRef": file.blob_ref, "direct": true }),
2186 )
2187 .await?;
2188 let started: blob_get::v0_2::Response = parse_reply("rooms/blobs/get", started)?;
2189 let blob: BlobManifest = serde_json::from_value(serde_json::to_value(&started.manifest)?)?;
2190 file.check_blob(&blob)?;
2194 let size = blob.size;
2195 let chunk_size = blob.chunks.chunk_size;
2196 let mut chunks = Vec::with_capacity(blob.chunks.chunk_digests.len());
2197 for (i, expected) in blob.chunks.chunk_digests.iter().enumerate() {
2198 let chunk = match (&started.target, &started.download_id) {
2199 (Some(t), _) => {
2200 let start = i as u64 * chunk_size;
2201 vtc_client::rooms::files::get_range(t, start..(start + chunk_size).min(size))
2202 .await?
2203 }
2204 (None, Some(download_id)) => {
2205 let (_, reply) = act_and_send_as(
2206 client,
2207 &target,
2208 blob_chunk::v0_1::Payload::TYPE_URI,
2209 serde_json::json!({ "downloadId": download_id, "index": i }),
2210 Some(&member),
2211 )
2212 .await?;
2213 let c: blob_chunk::v0_1::Response = parse_reply("rooms/blobs/chunk", reply)?;
2214 base64::Engine::decode(
2215 &base64::engine::general_purpose::URL_SAFE_NO_PAD,
2216 c.data.as_str(),
2217 )?
2218 }
2219 (None, None) => {
2220 return Err("the host answered with neither a target nor a download".into());
2221 }
2222 };
2223 if !files::same_digest(&files::digest_multibase(&chunk), expected) {
2224 return Err(format!("chunk {i} does not match the manifest").into());
2225 }
2226 chunks.push(chunk);
2227 }
2228
2229 let file_key = match carried {
2230 Some(key) => key,
2231 None => {
2232 let resp = client
2233 .room_file_key(target.room_id, &file.file_id, Some(file.epoch))
2234 .await
2235 .map_err(|e| file_key_refusal_hint(&e, &target))?;
2236 file_key_bytes(&resp)?
2237 }
2238 };
2239 let plaintext = files::open_file(&file_key, target.room_id, &file, &blob, &chunks)?;
2240
2241 let out = match out {
2242 Some(p) => p.to_path_buf(),
2243 None => std::path::PathBuf::from(local_name(&file.name)),
2244 };
2245 if out.exists() {
2246 return Err(format!("{} already exists; refusing to overwrite it", out.display()).into());
2247 }
2248 let tmp = out.with_extension("partial");
2251 std::fs::write(&tmp, &plaintext)?;
2252 std::fs::rename(&tmp, &out)?;
2253 println!(
2254 "Fetched {} ({} bytes) into {}, written by {}; it matches the digest {}.",
2255 inert(&file.name).escape_debug(),
2256 plaintext.len(),
2257 out.display(),
2258 inert(&author),
2259 if carried.is_some() {
2260 "its record states (an open room's record is not signed by its author)"
2261 } else {
2262 "they signed"
2263 }
2264 );
2265 Ok(())
2266}
2267
2268pub async fn cmd_rooms_owner_group_create(
2271 client: &VtaClient,
2272 room_id: &str,
2273 signing_key_id: &str,
2274 member: Option<&str>,
2275) -> Result<(), Box<dyn std::error::Error>> {
2276 let resp = client
2277 .room_group_create(room_id, signing_key_id, member)
2278 .await
2279 .map_err(|e| {
2280 format!(
2281 "{e}\n\n`noSigningKey` means this VTA will not let you name `{signing_key_id}` — \
2282 check it with `pnm keys list --context <id>`. `alreadyExists` means it already \
2283 holds this room's group, which is never replaced."
2284 )
2285 })?;
2286 println!("Founded the group of {room_id} at epoch {}.", resp.epoch);
2287 println!(
2288 " Next: admit members with `pnm rooms owner invite` and `pnm rooms owner group-add`. \
2289 Writing needs your own membership and authority credentials in this VTA \
2290 (`pnm rooms owner issue-membership` / `issue-authority --store`), if not issued yet."
2291 );
2292 Ok(())
2293}
2294
2295fn earlier_removal_refused(
2305 e: &VtaError,
2306 room_id: &str,
2307 host_did: &str,
2308 signing_key_id: Option<&str>,
2309 this: &str,
2310) -> Option<String> {
2311 let VtaError::Rejected { code, details, .. } = e else {
2312 return None;
2313 };
2314 let task = code.strip_prefix("rooms/owner/group/")?;
2315 if !task.ends_with(":earlierRemovalRefused") {
2316 return None;
2317 }
2318 let d = details.as_ref()?;
2319 let earlier = d["member"].as_str().unwrap_or("an earlier member");
2320 let host_code = d["code"].as_str().unwrap_or("no code given");
2321 let kept = if d["listKept"].as_bool() == Some(true) {
2322 format!(
2323 "Their withdrawal is kept in the room's revocation list and carried by every later \
2324 one; the host enforces it only if it ever accepted that list — deliver it again \
2325 with\n pnm rooms owner revoke --room {room_id} --signing-key {} --host \
2326 {host_did}",
2327 signing_key_id.unwrap_or("<the room's key>")
2328 )
2329 } else {
2330 "Their withdrawal was discarded: no host accepted it.".to_string()
2331 };
2332 Some(format!(
2333 "Nothing was done: an earlier removal of {earlier} from {room_id} did not take effect \
2334 — {host_did} refused its revocation list when your VTA delivered it again \
2335 ({host_code}). {earlier} is still in the room's group. {kept}\n\nRun this command \
2336 again to {this}; then remove {earlier} again if that is still wanted."
2337 ))
2338}
2339
2340#[allow(clippy::too_many_arguments)]
2354pub async fn cmd_rooms_owner_group_remove(
2355 client: &VtaClient,
2356 room_id: &str,
2357 host_did: &str,
2358 member: &str,
2359 signing_key_id: Option<&str>,
2360 mirrors: &[String],
2361 until: Option<chrono::DateTime<chrono::Utc>>,
2362 reason: Option<&str>,
2363) -> Result<(), Box<dyn std::error::Error>> {
2364 let hint = |e: VtaError| {
2365 let fix = match &e {
2366 VtaError::Rejected { code, .. }
2367 if code == "rooms/owner/group/remove:noSigningKey" && signing_key_id.is_none() =>
2368 {
2369 format!(
2370 "\n\n{room_id} is not private: removing {member} withdraws their credentials \
2371 at the host, signed as the room. Run\n pnm rooms owner group-remove \
2372 --room {room_id} --host-did {host_did} --member {member} --signing-key \
2373 <the room's key>"
2374 )
2375 }
2376 _ => String::new(),
2377 };
2378 format!(
2379 "{e}{fix}\n\n`epochConflict` means another admin advanced the room: catch up and \
2380 retry. `hostUnreachable` leaves the outcome unknown; running this again \
2381 settles it with the host before anything else. `ownLeaf` means {member} is \
2382 you: hand the room on with rooms/owner/transfer instead. `noSigningKey` means \
2383 this VTA will not let you name the room's key — check `pnm keys list`."
2384 )
2385 };
2386 let resp = client
2389 .idempotent(|| {
2390 client.room_group_remove(
2391 room_id,
2392 host_did,
2393 member,
2394 signing_key_id,
2395 mirrors,
2396 until,
2397 reason,
2398 )
2399 })
2400 .await
2401 .map_err(|e| {
2402 earlier_removal_refused(
2405 &e,
2406 room_id,
2407 host_did,
2408 signing_key_id,
2409 &format!("remove {member}"),
2410 )
2411 .unwrap_or_else(|| hint(e))
2412 })?;
2413 if !resp.withdrawn {
2414 println!(
2416 "Removed {member} from {room_id}'s group; the room is now at epoch {}.",
2417 resp.epoch
2418 );
2419 println!(
2420 " Nothing sealed from now on is readable to them. {room_id} is private, so their \
2421 credentials are NOT withdrawn at the host: they stay valid until they expire. \
2422 To reset every credential the room has issued, run `pnm rooms owner revoke \
2423 --reset` and re-admit those who remain."
2424 );
2425 return Ok(());
2426 }
2427 let r = serde_json::to_value(&resp).unwrap_or_default();
2428 println!(
2429 "Removed {member} from {room_id}: withdrawn at the host from {} (revocation list {}), \
2430 and the room is now at epoch {}.",
2431 r["revokedAt"].as_str().unwrap_or("now"),
2432 r["revocation"]["sequence"],
2433 r["epoch"]
2434 );
2435 if !resp.mirrors_pending.is_empty() {
2436 let pending: Vec<&str> = resp.mirrors_pending.iter().map(|m| m.as_str()).collect();
2437 println!(
2438 " Not yet withdrawn at: {}. Deliver it with `pnm rooms owner revoke --room \
2439 {room_id} --signing-key {} --host <did>` (no withdrawal).",
2440 pending.join(", "),
2441 signing_key_id.unwrap_or("<the room's key>")
2442 );
2443 }
2444 println!(
2445 " The host refuses their credentials, and anything they delegated, from now on. \
2446 Nothing they already read can be recalled."
2447 );
2448 Ok(())
2449}
2450
2451#[allow(clippy::too_many_arguments)]
2455pub async fn cmd_rooms_owner_revoke(
2456 client: &VtaClient,
2457 room_id: &str,
2458 signing_key_id: &str,
2459 hosts: &[String],
2460 subjects: &[String],
2461 credentials: &[String],
2462 reset: bool,
2463 until: Option<chrono::DateTime<chrono::Utc>>,
2464 reason: Option<&str>,
2465) -> Result<(), Box<dyn std::error::Error>> {
2466 use vti_specs::rooms::owner::revoke::v0_1::Withdrawal;
2467 let until = until.map(|u| u.to_rfc3339_opts(chrono::SecondsFormat::Secs, true));
2468 let mut raw: Vec<serde_json::Value> = Vec::new();
2469 for s in subjects {
2470 raw.push(serde_json::json!({ "kind": "subject", "subject": s }));
2471 }
2472 for c in credentials {
2473 raw.push(serde_json::json!({ "kind": "credential", "credentialId": c }));
2474 }
2475 if reset {
2476 raw.push(serde_json::json!({ "kind": "room" }));
2477 }
2478 if let Some(u) = &until {
2479 for w in &mut raw {
2480 w["until"] = serde_json::json!(u);
2481 }
2482 }
2483 let withdraw: Vec<Withdrawal> = raw
2484 .into_iter()
2485 .map(serde_json::from_value)
2486 .collect::<Result<_, _>>()
2487 .map_err(|e| format!("a withdrawal is not one rooms/owner/revoke accepts: {e}"))?;
2488 let resp = client
2489 .idempotent(|| client.room_owner_revoke(room_id, signing_key_id, hosts, &withdraw, reason))
2490 .await
2491 .map_err(|e| {
2492 format!(
2493 "{e}\n\n`noList` means there is nothing to re-deliver: name a withdrawal. \
2494 `entryNotPermitted` means the room is private: only --reset applies there. \
2495 `noSigningKey` means this VTA will not let you name `{signing_key_id}` — check \
2496 `pnm keys list`. `noAuthority` means it holds no `admin` grant of the room \
2497 for a persona you act as."
2498 )
2499 })?;
2500 let r = serde_json::to_value(&resp).unwrap_or_default();
2501 println!(
2502 "{room_id}'s revocation list is at sequence {} ({}).",
2503 r["head"]["sequence"],
2504 r["head"]["digest"].as_str().unwrap_or_default()
2505 );
2506 for d in r["deliveries"].as_array().into_iter().flatten() {
2507 let host = d["host"].as_str().unwrap_or_default();
2508 match d["outcome"].as_str().unwrap_or_default() {
2509 "accepted" => println!(
2510 " {host}: in force from {}",
2511 d["inForceAt"].as_str().unwrap_or_default()
2512 ),
2513 "refused" => {
2514 let code = d["code"].as_str().unwrap_or_default();
2515 println!(" {host}: REFUSED ({code})");
2516 if code == "rooms/revocations/put:stale" {
2517 println!(
2518 " The host holds a later list this VTA did not sign: the room's key \
2519 may be in more than one party's hands."
2520 );
2521 }
2522 }
2523 _ => println!(" {host}: no answer — run this again with no withdrawal to deliver it"),
2524 }
2525 }
2526 Ok(())
2527}
2528
2529pub async fn cmd_rooms_owner_group_renew(
2533 client: &VtaClient,
2534 room_id: &str,
2535 host_did: &str,
2536 reason: Option<&str>,
2537) -> Result<(), Box<dyn std::error::Error>> {
2538 let resp = client
2541 .idempotent(|| client.room_group_renew(room_id, host_did, reason))
2542 .await
2543 .map_err(|e| {
2544 earlier_removal_refused(&e, room_id, host_did, None, &format!("renew {room_id}"))
2545 .unwrap_or_else(|| {
2546 format!(
2547 "{e}\n\n`noGroup` means this VTA holds no group for {room_id}: an \
2548 `open` room renews with `pnm rooms renew`. `epochConflict` means \
2549 another admin advanced the room: catch up and retry. \
2550 `hostUnreachable` leaves the outcome unknown; running this again \
2551 settles it with the host before anything else."
2552 )
2553 })
2554 })?;
2555 println!(
2556 "Renewed {room_id}; the room is now at epoch {}.",
2557 resp.epoch
2558 );
2559 println!(
2560 " Nobody joined or left. Every remaining member's VTA must apply the commit before \
2561 it can write again: each runs `pnm rooms keys commit --room {room_id} --host <url> \
2562 --host-did {host_did}`."
2563 );
2564 Ok(())
2565}
2566
2567pub async fn cmd_rooms_owner_group_add(
2572 client: &VtaClient,
2573 room_id: &str,
2574 host_did: &str,
2575 member: &str,
2576 key_package_src: &str,
2577 reason: Option<&str>,
2578 out: &std::path::Path,
2579) -> Result<(), Box<dyn std::error::Error>> {
2580 if out.exists() {
2581 return Err(format!("{} already exists; refusing to overwrite it", out.display()).into());
2582 }
2583 let key_package = if key_package_src == "-" {
2584 let mut s = String::new();
2585 std::io::Read::read_to_string(&mut std::io::stdin(), &mut s)?;
2586 s
2587 } else {
2588 std::fs::read_to_string(key_package_src)?
2589 };
2590 let key_package = key_package.trim().to_string();
2591
2592 let resp = client
2595 .idempotent(|| client.room_group_add(room_id, host_did, member, &key_package, reason))
2596 .await
2597 .map_err(|e| {
2598 earlier_removal_refused(&e, room_id, host_did, None, &format!("admit {member}"))
2599 .unwrap_or_else(|| {
2600 format!(
2601 "{e}\n\n`epochConflict` means another admin advanced the room: catch \
2602 up and retry. `hostUnreachable` leaves the outcome unknown; running \
2603 this again settles it with the host before anything else."
2604 )
2605 })
2606 })?;
2607
2608 let delivery = serde_json::json!({
2609 "roomId": room_id,
2610 "member": resp.member.as_str(),
2611 "epoch": resp.epoch,
2612 "welcome": resp.welcome.as_str(),
2613 "ratchetTree": resp.ratchet_tree.as_ref().map(|t| t.as_str()),
2614 });
2615 std::fs::write(out, serde_json::to_vec_pretty(&delivery)?)?;
2616 println!(
2617 "Admitted {member} to {room_id}; the room is now at epoch {}.",
2618 resp.epoch
2619 );
2620 println!(
2621 " Deliver {} to the member's key holder (rooms/keys/welcome) directly — never \
2622 through the host.",
2623 out.display()
2624 );
2625 Ok(())
2626}
2627
2628pub struct OfferTarget<'a> {
2645 pub vtc_url: &'a str,
2647 pub vtc_did: &'a str,
2649 pub member: &'a str,
2651}
2652
2653enum OfferStepError {
2658 Act(VtaError),
2660 Community(vtc_client::VtcError),
2662 Local(Box<dyn std::error::Error>),
2664}
2665
2666impl OfferStepError {
2667 fn report(self, target: &OfferTarget<'_>) -> Box<dyn std::error::Error> {
2669 match self {
2670 Self::Act(e) => {
2671 let text = e.to_string();
2672 let hint = if matches!(e, VtaError::UnsupportedTaskType { .. }) {
2673 "\n\nYour VTA does not serve rooms/keys/act/0.9, which signs this request \
2674 as you: upgrade it."
2675 .to_string()
2676 } else if text.contains("issuerNotPrincipal") {
2677 format!(
2678 "\n\nYour VTA holds no `authentication` key of `{}` that you may act as. \
2679 Pass the persona DID the community knows you by as --member.",
2680 target.member
2681 )
2682 } else if text.contains("keyPackageNotHeld") {
2683 "\n\nThe KeyPackage was not minted by your VTA for this member and room, or \
2684 it has been used or has expired: run this again without --key-package to \
2685 mint one."
2686 .to_string()
2687 } else {
2688 String::new()
2689 };
2690 format!("{e}{hint}").into()
2691 }
2692 Self::Community(e) => format!("{}: {e}", target.vtc_did).into(),
2693 Self::Local(e) => e,
2694 }
2695 }
2696}
2697
2698async fn act_and_send_offer(
2703 client: &VtaClient,
2704 target: &OfferTarget<'_>,
2705 type_uri: &str,
2706 payload: Value,
2707) -> Result<Value, OfferStepError> {
2708 let document = vta_sdk::client::unsigned_community_request(
2709 type_uri,
2710 target.vtc_did,
2711 target.member,
2712 payload,
2713 )
2714 .map_err(|e| OfferStepError::Local(e.into()))?;
2715 let completed = client
2716 .room_act_community(&document)
2717 .await
2718 .map_err(OfferStepError::Act)?;
2719 let document =
2720 serde_json::to_value(&completed.document).map_err(|e| OfferStepError::Local(e.into()))?;
2721 VtcClient::anonymous(target.vtc_url, target.vtc_did)
2722 .send_signed_room_document(&document)
2723 .await
2724 .map_err(OfferStepError::Community)
2725}
2726
2727type CommunityOffer = vti_specs::vtc::rooms::offers::list::v0_4::OfferV2;
2730
2731type CommunityRenewal = vti_specs::vtc::rooms::offers::list::v0_4::Renewal;
2733
2734type OffersListing = (Vec<CommunityOffer>, Vec<CommunityRenewal>);
2736
2737#[allow(clippy::type_complexity)]
2740fn offers_page(
2741 reply: Value,
2742) -> Result<(Vec<CommunityOffer>, Option<String>, Vec<CommunityRenewal>), Box<dyn std::error::Error>>
2743{
2744 use vti_specs::vtc::rooms::offers::list::v0_4;
2745 let page: v0_4::Response = parse_reply("vtc/rooms/offers/list/0.4", reply)?;
2746 Ok((
2747 page.items,
2748 page.next_cursor.map(|c| c.to_string()),
2749 page.renewals,
2750 ))
2751}
2752
2753async fn list_offers(
2758 client: &VtaClient,
2759 target: &OfferTarget<'_>,
2760) -> Result<OffersListing, Box<dyn std::error::Error>> {
2761 use trust_tasks_rs::Payload as _;
2762 let type_uri = vti_specs::vtc::rooms::offers::list::v0_4::Payload::TYPE_URI;
2763 let mut items = Vec::new();
2764 let mut renewals = Vec::new();
2765 let mut cursor: Option<String> = None;
2766 loop {
2767 let payload = match &cursor {
2768 Some(c) => serde_json::json!({ "cursor": c }),
2769 None => serde_json::json!({}),
2770 };
2771 let reply = act_and_send_offer(client, target, type_uri, payload)
2772 .await
2773 .map_err(|e| e.report(target))?;
2774 let page = offers_page(reply)?;
2775 items.extend(page.0);
2776 renewals.extend(page.2);
2777 match page.1 {
2778 Some(next) => cursor = Some(next),
2779 None => return Ok((items, renewals)),
2780 }
2781 }
2782}
2783
2784pub async fn cmd_rooms_offers_list(
2787 client: &VtaClient,
2788 target: OfferTarget<'_>,
2789) -> Result<(), Box<dyn std::error::Error>> {
2790 let (offers, renewals) = list_offers(client, &target).await?;
2791 if offers.is_empty() && renewals.is_empty() {
2792 println!(
2793 "No offers from {} are addressed to {}.",
2794 target.vtc_did, target.member
2795 );
2796 return Ok(());
2797 }
2798 for o in &offers {
2799 print!("{}", offer_lines(o, &target));
2800 }
2801 for r in &renewals {
2802 print!("{}", renewal_lines(r, &target));
2803 }
2804 Ok(())
2805}
2806
2807fn renewal_lines(r: &CommunityRenewal, target: &OfferTarget<'_>) -> String {
2810 let actions: Vec<String> = r.actions.0.iter().map(ToString::to_string).collect();
2811 format!(
2812 "{} {} [{}] {} renewal until {} (renews {})\n renew: {}\n",
2813 r.room_id.as_str(),
2814 inert(r.label.as_str()),
2815 r.visibility,
2816 actions.join(","),
2817 r.valid_until.to_rfc3339(),
2818 r.renews_at.to_rfc3339(),
2819 renew_command(r.room_id.as_str(), target),
2820 )
2821}
2822
2823fn renew_command(room_id: &str, target: &OfferTarget<'_>) -> String {
2825 format!(
2826 "pnm rooms offers renew --room {} --vtc {} --vtc-did {} --member {}",
2827 shell_word(room_id),
2828 shell_word(target.vtc_url),
2829 shell_word(target.vtc_did),
2830 shell_word(target.member),
2831 )
2832}
2833
2834async fn held_room_credential(
2838 client: &VtaClient,
2839 room_id: &str,
2840 member: &str,
2841 type_tag: &str,
2842) -> Result<Option<String>, Box<dyn std::error::Error>> {
2843 let found = client
2844 .cred_vault_query(serde_json::json!({ "type": type_tag, "issuerDid": room_id }))
2845 .await
2846 .map_err(|e| format!("your VTA's credential vault did not answer a query: {e}"))?;
2847 let mut mine = Vec::new();
2848 for descriptor in found["credentials"].as_array().cloned().unwrap_or_default() {
2849 let Some(id) = descriptor["id"].as_str() else {
2850 continue;
2851 };
2852 let got = client
2853 .cred_vault_get(id)
2854 .await
2855 .map_err(|e| format!("your VTA's credential vault did not return {id}: {e}"))?;
2856 let subject = match &got {
2859 vault_get::Response::Variant0 { credential, .. } => credential
2860 .get("credentialSubject")
2861 .and_then(|s| s.get("id"))
2862 .and_then(Value::as_str),
2863 _ => None,
2864 };
2865 if subject == Some(member) {
2866 mine.push(id.to_string());
2867 }
2868 }
2869 match mine.len() {
2870 0 => Ok(None),
2871 1 => Ok(mine.pop()),
2872 n => Err(format!(
2873 "your VTA holds {n} {type_tag}s that {room_id} issued {member}, and acts on the \
2874 room with none of them until one is left: remove the older (`pnm cred-vault \
2875 delete`) and renew again"
2876 )
2877 .into()),
2878 }
2879}
2880
2881pub async fn cmd_rooms_offers_renew(
2886 client: &VtaClient,
2887 target: OfferTarget<'_>,
2888 room_id: &str,
2889) -> Result<(), Box<dyn std::error::Error>> {
2890 let (_, renewals) = list_offers(client, &target).await?;
2891 let renewal = renewals
2892 .into_iter()
2893 .find(|r| r.room_id.as_str() == room_id)
2894 .ok_or_else(|| {
2895 format!(
2896 "{} lists no renewal of {room_id} for {}: `pnm rooms offers list` shows what it \
2897 has for you",
2898 target.vtc_did, target.member
2899 )
2900 })?;
2901 for (what, type_tag, credential) in [
2902 (
2903 "membership",
2904 "MembershipCredential",
2905 renewal.credentials.membership.as_str(),
2906 ),
2907 (
2908 "authority",
2909 "AuthorityCredential",
2910 renewal.credentials.authority.as_str(),
2911 ),
2912 ] {
2913 let body: Value = serde_json::from_str(credential)
2914 .map_err(|e| format!("the renewal's {what} credential is not JSON: {e}"))?;
2915 let replaces = held_room_credential(client, room_id, target.member, type_tag).await?;
2916 client
2917 .cred_vault_receive(body, replaces.as_deref())
2918 .await
2919 .map_err(|e| {
2920 format!("your VTA's credential vault refused the renewed {what} credential: {e}")
2921 })?;
2922 }
2923 println!(
2924 "Renewed {room_id} for {}: valid until {} (it was until {}).",
2925 target.member,
2926 renewal.valid_until.to_rfc3339(),
2927 renewal.renews_at.to_rfc3339(),
2928 );
2929 Ok(())
2930}
2931
2932fn offer_lines(o: &CommunityOffer, target: &OfferTarget<'_>) -> String {
2936 use vti_specs::vtc::rooms::offers::list::v0_4::OfferV2State;
2937 let actions: Vec<String> = o.actions.0.iter().map(ToString::to_string).collect();
2938 let mut out = format!(
2939 "{} {} [{}] {} {} until {}\n",
2940 o.room_id.as_str(),
2941 inert(o.label.as_str()),
2942 o.visibility,
2943 actions.join(","),
2944 o.state,
2945 o.valid_until.to_rfc3339(),
2946 );
2947 if let Some(description) = &o.description {
2948 out.push_str(&format!(" about: {}\n", one_line(description.as_str())));
2949 }
2950 if o.state == OfferV2State::Open {
2951 out.push_str(&format!(
2952 " accept: {}\n",
2953 accept_command(o.room_id.as_str(), target)
2954 ));
2955 }
2956 out
2957}
2958
2959fn one_line(text: &str) -> String {
2963 text.chars()
2964 .map(|c| if c.is_control() { ' ' } else { c })
2965 .collect::<String>()
2966 .trim()
2967 .to_string()
2968}
2969
2970fn accept_command(room_id: &str, target: &OfferTarget<'_>) -> String {
2973 format!(
2974 "pnm rooms offers accept --room {} --vtc {} --vtc-did {} --member {}",
2975 shell_word(room_id),
2976 shell_word(target.vtc_url),
2977 shell_word(target.vtc_did),
2978 shell_word(target.member),
2979 )
2980}
2981
2982fn shell_word(value: &str) -> String {
2985 let plain = !value.is_empty()
2986 && value
2987 .chars()
2988 .all(|c| c.is_ascii_alphanumeric() || ":/._%@+=,-".contains(c));
2989 if plain {
2990 value.to_string()
2991 } else {
2992 format!("'{}'", inert(value).replace('\'', r"'\''"))
2993 }
2994}
2995
2996pub async fn cmd_rooms_offers_accept(
3003 client: &VtaClient,
3004 target: OfferTarget<'_>,
3005 room_id: &str,
3006 key_package: Option<&str>,
3007) -> Result<(), Box<dyn std::error::Error>> {
3008 use trust_tasks_rs::Payload as _;
3009 use vti_specs::vtc::rooms::offers::accept::v0_1 as accept;
3010
3011 let (offers, _) = list_offers(client, &target).await?;
3012 let offer = offers
3013 .into_iter()
3014 .find(|o| o.room_id.as_str() == room_id)
3015 .ok_or_else(|| {
3016 format!(
3017 "{} has no open offer to {room_id} addressed to {}: `pnm rooms offers list` \
3018 shows what it has offered you",
3019 target.vtc_did, target.member
3020 )
3021 })?;
3022 let sealed = offer.visibility.to_string() != "open";
3023
3024 for (what, credential) in [
3028 ("membership", offer.credentials.membership.as_str()),
3029 ("authority", offer.credentials.authority.as_str()),
3030 ] {
3031 let body: Value = serde_json::from_str(credential)
3032 .map_err(|e| format!("the offer's {what} credential is not JSON: {e}"))?;
3033 client.cred_vault_receive(body, None).await.map_err(|e| {
3034 format!("your VTA's credential vault refused the {what} credential: {e}")
3035 })?;
3036 }
3037 let invitation = offer.credentials.invitation.as_str();
3038
3039 if sealed && holds_group(client, room_id, target.member).await? {
3042 println!(
3043 "{room_id} is already joined as {} by your VTA — accepted here or from another \
3044 surface. Nothing to do.",
3045 target.member
3046 );
3047 return Ok(());
3048 }
3049
3050 let key_package = match (sealed, key_package) {
3051 (false, _) => None,
3052 (true, Some(src)) => Some(std::fs::read_to_string(src)?.trim().to_string()),
3053 (true, None) => Some(offered_key_package(client, room_id, invitation).await?),
3056 };
3057
3058 let mut payload = serde_json::json!({ "roomId": room_id });
3059 if let Some(kp) = &key_package {
3060 payload["keyPackage"] = Value::String(kp.clone());
3061 }
3062 let reply = act_and_send_offer(client, &target, accept::Payload::TYPE_URI, payload)
3063 .await
3064 .map_err(|e| e.report(&target))
3065 .map_err(|e| match &key_package {
3066 Some(kp) => format!(
3067 "{e}\n\nIf the community may have admitted you, retry the identical acceptance \
3068 with this KeyPackage (save it to a file and pass --key-package <file>):\n{kp}"
3069 ),
3070 None => e.to_string(),
3071 })?;
3072 let accepted: accept::Response = parse_reply("vtc/rooms/offers/accept", reply)?;
3073
3074 match &accepted.welcome {
3075 Some(welcome) => {
3076 let joined = client
3077 .idempotent(|| {
3078 client.room_welcome(
3079 room_id,
3080 invitation,
3081 welcome.as_str(),
3082 accepted.ratchet_tree.as_ref().map(|t| t.as_str()),
3083 )
3084 })
3085 .await;
3086 match joined {
3087 Ok(joined) => println!(
3088 "Accepted {room_id} as {} and joined its group at room epoch {}.",
3089 target.member,
3090 joined.epoch + 1
3091 ),
3092 Err(e) if e.to_string().contains("alreadyJoined") => println!(
3094 "Accepted {room_id} as {}; your VTA had already joined its group from \
3095 another surface.",
3096 target.member
3097 ),
3098 Err(e) => {
3099 return Err(format!(
3100 "admitted at room epoch {}, but your VTA did not join from the \
3101 Welcome: {e}",
3102 accepted.epoch
3103 )
3104 .into());
3105 }
3106 }
3107 }
3108 None if sealed => {
3109 return Err(format!(
3110 "the community admitted you to sealed room {room_id} but sent no Welcome"
3111 )
3112 .into());
3113 }
3114 None => println!(
3115 "Accepted {room_id} as {}; the room is open, at epoch {}.",
3116 target.member, accepted.epoch
3117 ),
3118 }
3119 println!(
3120 " Read it with `pnm rooms list --room {room_id} --host {} --host-did {}`.",
3121 target.vtc_url,
3122 offer.host.as_str()
3123 );
3124 Ok(())
3125}
3126
3127async fn holds_group(
3130 client: &VtaClient,
3131 room: &str,
3132 member: &str,
3133) -> Result<bool, Box<dyn std::error::Error>> {
3134 let held = client.room_list(false).await?;
3135 Ok(held
3136 .rooms
3137 .iter()
3138 .any(|r| r.room_id.as_str() == room && r.member.as_str() == member))
3139}
3140
3141async fn offered_key_package(
3145 client: &VtaClient,
3146 room: &str,
3147 invitation: &str,
3148) -> Result<String, Box<dyn std::error::Error>> {
3149 let kp = client.room_key_package(room, invitation).await?;
3150 if kp.already_minted {
3151 eprintln!(
3152 "Your VTA already held a KeyPackage for this offer — another surface may \
3153 have begun this acceptance; finishing it."
3154 );
3155 }
3156 Ok(kp.key_package.to_string())
3157}
3158
3159async fn list_memberships(
3162 client: &VtaClient,
3163 target: &OfferTarget<'_>,
3164) -> Result<
3165 Vec<vti_specs::vtc::rooms::memberships::list::v0_1::RoomMembership>,
3166 Box<dyn std::error::Error>,
3167> {
3168 use trust_tasks_rs::Payload as _;
3169 use vti_specs::vtc::rooms::memberships::list::v0_1 as list;
3170 let mut items = Vec::new();
3171 let mut cursor: Option<String> = None;
3172 loop {
3173 let payload = match &cursor {
3174 Some(c) => serde_json::json!({ "cursor": c }),
3175 None => serde_json::json!({}),
3176 };
3177 let reply = act_and_send_offer(client, target, list::Payload::TYPE_URI, payload)
3178 .await
3179 .map_err(|e| e.report(target))?;
3180 let page: list::Response = parse_reply("vtc/rooms/memberships/list", reply)?;
3181 items.extend(page.items);
3182 match page.next_cursor {
3183 Some(next) => cursor = Some(next.to_string()),
3184 None => return Ok(items),
3185 }
3186 }
3187}
3188
3189#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3193pub enum RoomStanding {
3194 Offered,
3196 Joined,
3198 Unfinished,
3201 NeedsReadmission,
3204 Removed,
3206}
3207
3208impl RoomStanding {
3209 pub fn of(state: &str, sealed: bool, held: bool, welcome_kept: bool) -> Self {
3211 match state {
3212 "offered" => Self::Offered,
3213 "removed" => Self::Removed,
3214 _ if !sealed || held => Self::Joined,
3215 _ if welcome_kept => Self::Unfinished,
3216 _ => Self::NeedsReadmission,
3217 }
3218 }
3219
3220 fn label(self) -> &'static str {
3221 match self {
3222 Self::Offered => "offered",
3223 Self::Joined => "joined",
3224 Self::Unfinished => "accepted, not finished",
3225 Self::NeedsReadmission => "admitted, not openable here",
3226 Self::Removed => "removed",
3227 }
3228 }
3229}
3230
3231pub async fn cmd_rooms_memberships(
3235 client: &VtaClient,
3236 target: OfferTarget<'_>,
3237) -> Result<(), Box<dyn std::error::Error>> {
3238 let rooms = list_memberships(client, &target).await?;
3239 if rooms.is_empty() {
3240 println!(
3241 "{} has offered no rooms to {}.",
3242 target.vtc_did, target.member
3243 );
3244 return Ok(());
3245 }
3246 let held = client.room_list(false).await?;
3247 for r in &rooms {
3248 let room_id = r.room_id.as_str();
3249 let holds = held
3250 .rooms
3251 .iter()
3252 .any(|h| h.room_id.as_str() == room_id && h.member.as_str() == target.member);
3253 let standing = RoomStanding::of(
3254 &r.state.to_string(),
3255 r.visibility.to_string() != "open",
3256 holds,
3257 r.repeat_accept_until.is_some(),
3258 );
3259 let actions: Vec<String> = r.actions.0.iter().map(ToString::to_string).collect();
3260 println!(
3261 "{room_id} {} [{}] {} {}",
3262 inert(r.label.as_str()),
3263 r.visibility,
3264 actions.join(","),
3265 standing.label(),
3266 );
3267 match standing {
3268 RoomStanding::Offered | RoomStanding::Unfinished => {
3269 println!(" accept: {}", accept_command(room_id, &target));
3270 }
3271 RoomStanding::NeedsReadmission => println!(
3272 " Your VTA never joined this room's group and the community no longer keeps \
3273 the Welcome: ask an administrator to remove and re-admit you."
3274 ),
3275 RoomStanding::Joined | RoomStanding::Removed => {}
3276 }
3277 }
3278 Ok(())
3279}
3280
3281#[cfg(test)]
3282mod tests {
3283 use super::*;
3284 use serde_json::json;
3285
3286 fn prune_target() -> RoomTarget<'static> {
3287 RoomTarget {
3288 host_url: "https://host.example",
3289 host_did: Some("did:web:host.example"),
3290 room_id: "did:webvh:Qm:example.com:rooms:northwind",
3291 }
3292 }
3293
3294 #[test]
3297 fn a_prune_without_yes_asks_a_terminal_and_refuses_a_pipe() {
3298 assert_eq!(prune_guard(true, false), PruneGuard::Proceed);
3299 assert_eq!(prune_guard(true, true), PruneGuard::Proceed);
3300 assert_eq!(prune_guard(false, true), PruneGuard::Ask);
3301 assert_eq!(prune_guard(false, false), PruneGuard::Refuse);
3302 }
3303
3304 #[test]
3307 fn the_prune_warning_names_what_becomes_unreadable() {
3308 let w = prune_warning("did:example:room", 5, 7, Some("chained"));
3311 assert!(w.contains("cannot be undone"), "{w}");
3312 assert!(w.contains("epochs 1 to 3"), "{w}");
3313 assert!(w.contains("epoch floor (4)"), "{w}");
3314 assert!(w.contains("now at epoch 7"), "{w}");
3315 assert!(w.contains("The records stay"), "{w}");
3316 assert!(w.contains("members' VTAs"), "{w}");
3317 assert!(!w.contains("fromJoin"), "{w}");
3318
3319 assert!(prune_warning("r", 3, 4, None).contains("unreadable for good"));
3320 assert!(prune_warning("r", 3, 4, None).contains("epoch 1 "));
3321 assert!(prune_warning("r", 2, 3, None).contains("nothing becomes unreadable"));
3322 assert!(prune_warning("r", 3, 4, Some("fromJoin")).contains("keeps no chain"));
3323 }
3324
3325 #[test]
3327 fn a_floor_at_the_current_epoch_suggests_the_one_below() {
3328 let t = prune_target();
3329 let msg = not_ahead_message(&t, 7, 7);
3330 assert!(msg.contains("current epoch 7"), "{msg}");
3331 assert!(
3332 msg.contains(
3333 "pnm rooms prune --room did:webvh:Qm:example.com:rooms:northwind \
3334 --host https://host.example --host-did did:web:host.example \
3335 --before-epoch 6 --reason '<why>' --yes"
3336 ),
3337 "{msg}"
3338 );
3339 assert!(!not_ahead_message(&t, 1, 1).contains("pnm rooms prune"));
3341 }
3342
3343 #[test]
3345 fn a_hosts_prune_refusal_is_explained() {
3346 let t = prune_target();
3347 let refusal = |code: &str| vtc_client::VtcError::Http {
3348 status: 400,
3349 body: json!({
3350 "type": "https://trusttasks.org/spec/trust-task-error/0.1",
3351 "payload": { "code": code, "message": "no" },
3352 })
3353 .to_string(),
3354 };
3355 let msg = prune_refused(refusal("rooms/epoch/prune:notAhead"), &t, 7, 7);
3356 assert!(msg.contains("--before-epoch 6"), "{msg}");
3357 let msg = prune_refused(refusal("permissionDenied"), &t, 3, 7);
3358 assert!(msg.contains("needs `admin`"), "{msg}");
3359 assert!(msg.contains("issue-authority --action admin"), "{msg}");
3360 let msg = prune_refused(refusal("rooms/epoch/prune:chainTooDeep"), &t, 3, 7);
3361 assert!(!msg.contains("needs `admin`"), "{msg}");
3362 }
3363
3364 #[test]
3366 fn a_prune_outcome_reports_reach_not_the_request() {
3367 let r = |pruned, earliest_rung| vtc_client::rooms::PruneResponse {
3368 room_id: "did:example:room".into(),
3369 pruned,
3370 earliest_rung,
3371 };
3372 let none = prune_outcome(&r(0, 3));
3373 assert!(none.contains("nothing to drop"), "{none}");
3374 assert!(none.contains("reaches back to epoch 3"), "{none}");
3375 assert!(prune_outcome(&r(1, 5)).contains("1 rung dropped"));
3376 assert!(prune_outcome(&r(4, 5)).contains("4 rungs dropped"));
3377 }
3378
3379 fn rejected(code: &str, details: Value) -> VtaError {
3380 VtaError::Rejected {
3381 code: code.into(),
3382 message: "refused".into(),
3383 details: Some(details),
3384 }
3385 }
3386
3387 #[test]
3390 fn an_earlier_removal_is_explained_whichever_version_reported_it() {
3391 let d =
3392 json!({ "member": "did:key:zEarlier", "code": "permissionDenied", "listKept": true });
3393 for code in [
3394 "rooms/owner/group/add:earlierRemovalRefused",
3395 "rooms/owner/group/renew:earlierRemovalRefused",
3396 "rooms/owner/group/remove:earlierRemovalRefused",
3397 ] {
3398 let said = earlier_removal_refused(
3399 &rejected(code, d.clone()),
3400 "did:room",
3401 "did:host",
3402 None,
3403 "admit did:key:zNew",
3404 )
3405 .unwrap_or_else(|| panic!("{code} is an earlier removal"));
3406 assert!(said.starts_with("Nothing was done"), "{said}");
3407 assert!(said.contains("did:key:zEarlier"), "{said}");
3408 assert!(
3409 said.contains("pnm rooms owner revoke --room did:room"),
3410 "{said}"
3411 );
3412 assert!(said.contains("again to admit did:key:zNew"), "{said}");
3413 }
3414 let own = rejected(
3415 "rooms/owner/group/add:hostRefused",
3416 json!({ "code": "permissionDenied", "reason": "not today" }),
3417 );
3418 assert!(earlier_removal_refused(&own, "r", "h", None, "x").is_none());
3419 let kept_not = rejected(
3420 "rooms/owner/group/renew:earlierRemovalRefused",
3421 json!({ "member": "did:key:zEarlier", "code": "rooms/revocations/put:listInvalid",
3422 "listKept": false }),
3423 );
3424 let said = earlier_removal_refused(&kept_not, "r", "h", None, "x").unwrap();
3425 assert!(said.contains("discarded"), "{said}");
3426 }
3427
3428 #[test]
3430 fn a_rooms_standing_follows_the_one_record() {
3431 use RoomStanding::*;
3432 assert_eq!(RoomStanding::of("offered", true, false, false), Offered);
3433 assert_eq!(RoomStanding::of("admitted", false, false, false), Joined);
3434 assert_eq!(RoomStanding::of("admitted", true, true, true), Joined);
3435 assert_eq!(RoomStanding::of("admitted", true, false, true), Unfinished);
3436 assert_eq!(
3437 RoomStanding::of("admitted", true, false, false),
3438 NeedsReadmission
3439 );
3440 assert_eq!(RoomStanding::of("removed", true, true, false), Removed);
3441 }
3442
3443 fn minted(authority: Value) -> Value {
3444 json!({
3445 "presentation": {
3446 "membership": "vmc-blob",
3447 "authority": authority,
3448 },
3449 "expiresAt": "2026-09-07T16:00:00Z",
3450 })
3451 }
3452
3453 fn vtc_target() -> RoomTarget<'static> {
3454 RoomTarget {
3455 host_url: "https://community.example/v1",
3456 host_did: Some("did:webvh:community.example"),
3457 room_id: "did:webvh:example.com:rooms:northwind",
3458 }
3459 }
3460
3461 const CATCH_UP: &str = "pnm rooms keys commit --room did:webvh:example.com:rooms:northwind \
3462 --host https://community.example/v1 --host-did did:webvh:community.example";
3463
3464 #[test]
3466 fn an_offer_prints_its_accept_command_with_the_room_filled_in() {
3467 let target = OfferTarget {
3468 vtc_url: "https://vtc.example/v1",
3469 vtc_did: "did:webvh:Qm1:vtc.example",
3470 member: "did:webvh:Qm2:alice.example",
3471 };
3472 assert_eq!(
3473 accept_command("did:webvh:Qm3:vtc.example:room-dids:ab12", &target),
3474 "pnm rooms offers accept --room did:webvh:Qm3:vtc.example:room-dids:ab12 \
3475 --vtc https://vtc.example/v1 --vtc-did did:webvh:Qm1:vtc.example \
3476 --member did:webvh:Qm2:alice.example"
3477 );
3478 }
3479
3480 fn offer_v2(description: Option<&str>) -> Value {
3481 let mut o = json!({
3482 "roomId": "did:webvh:Qm3:vtc.example:room-dids:ab12",
3483 "host": "did:webvh:Qm1:vtc.example",
3484 "label": "Board\u{1b}[2J papers",
3485 "visibility": "private",
3486 "actions": ["read", "write"],
3487 "validUntil": "2026-12-01T00:00:00Z",
3488 "offeredAt": "2026-10-01T00:00:00Z",
3489 "state": "open",
3490 "credentials": {
3491 "membership": "{}",
3492 "authority": "{}",
3493 "invitation": "inv"
3494 }
3495 });
3496 if let Some(d) = description {
3497 o["description"] = json!(d);
3498 }
3499 o
3500 }
3501
3502 fn offer_target() -> OfferTarget<'static> {
3503 OfferTarget {
3504 vtc_url: "https://vtc.example/v1",
3505 vtc_did: "did:webvh:Qm1:vtc.example",
3506 member: "did:webvh:Qm2:alice.example",
3507 }
3508 }
3509
3510 #[test]
3514 fn an_offer_shows_its_description_on_one_inert_line() {
3515 let offer: CommunityOffer = serde_json::from_value(offer_v2(Some(
3516 "Papers for the board:\nagenda\tminutes\u{7}\r\n accept: rm -rf ~",
3517 )))
3518 .unwrap();
3519 let text = offer_lines(&offer, &offer_target());
3520 let lines: Vec<&str> = text.lines().collect();
3521 assert_eq!(lines.len(), 3, "{text}");
3522 assert!(lines[0].contains("Board[2J papers"), "{text}");
3523 assert!(
3524 !text.contains('\u{1b}') && !text.contains('\u{7}'),
3525 "{text}"
3526 );
3527 assert_eq!(
3528 lines[1],
3529 " about: Papers for the board: agenda minutes accept: rm -rf ~"
3530 );
3531 assert!(
3532 lines[2].starts_with(" accept: pnm rooms offers accept"),
3533 "{text}"
3534 );
3535
3536 let bare: CommunityOffer = serde_json::from_value(offer_v2(None)).unwrap();
3538 assert!(!offer_lines(&bare, &offer_target()).contains("about:"));
3539 }
3540
3541 #[test]
3544 fn an_offers_list_0_4_page_carries_renewals_and_their_renew_command() {
3545 let (items, _, renewals) = offers_page(json!({
3546 "items": [offer_v2(None)],
3547 "renewals": [{
3548 "roomId": "did:webvh:Qm:rooms.example:wg", "host": "did:example:community",
3549 "label": "Working group", "visibility": "attributed", "actions": ["read"],
3550 "validUntil": "2027-11-10T00:00:00Z", "renewsAt": "2026-11-10T00:00:00Z",
3551 "issuedAt": "2026-10-20T09:00:00Z",
3552 "credentials": { "membership": "{}", "authority": "{}" },
3553 }],
3554 }))
3555 .expect("a 0.4 page");
3556 assert_eq!(items.len(), 1);
3557 assert_eq!(renewals.len(), 1);
3558 let lines = renewal_lines(&renewals[0], &offer_target());
3559 assert!(lines.contains("renewal until 2027-11-10"), "{lines}");
3560 assert!(
3561 lines.contains("pnm rooms offers renew --room did:webvh:Qm:rooms.example:wg"),
3562 "{lines}"
3563 );
3564 }
3565
3566 #[test]
3568 fn a_room_id_a_shell_would_read_is_quoted() {
3569 assert_eq!(shell_word("did:web:x"), "did:web:x");
3570 assert_eq!(shell_word("a;rm -rf ~"), "'a;rm -rf ~'");
3571 assert_eq!(shell_word("it's"), r"'it'\''s'");
3572 assert_eq!(shell_word("$(x)"), "'$(x)'");
3573 assert_eq!(shell_word(""), "''");
3574 }
3575
3576 #[test]
3580 fn a_superseded_epoch_put_prints_keys_commit_with_the_real_values() {
3581 let body = json!({
3582 "type": "https://trusttasks.org/spec/rooms/records/put/0.1#error",
3583 "payload": {
3584 "code": "rooms/records/put:epochMismatch",
3585 "message": "record is sealed under epoch 2, room `r` is at 3",
3586 },
3587 });
3588 let err: Box<dyn std::error::Error> = Box::new(vtc_client::VtcError::Http {
3589 status: 400,
3590 body: body.to_string(),
3591 });
3592 let text = put_refused(err, &vtc_target()).to_string();
3593 assert!(text.contains(CATCH_UP), "{text}");
3594 assert!(text.contains("sealed under epoch 2"), "{text}");
3595 assert!(!text.contains("HTTP 400"), "{text}");
3596 }
3597
3598 #[test]
3601 fn other_put_refusals_pass_through_and_no_host_did_is_invented() {
3602 let t = vtc_target();
3603 assert!(put_refusal_hint("rooms/records/put:recordTooLarge", "", &t).is_none());
3604 let err: Box<dyn std::error::Error> = Box::new(vtc_client::VtcError::Http {
3605 status: 500,
3606 body: "not a document".into(),
3607 });
3608 assert_eq!(
3609 put_refused(err, &t).to_string(),
3610 "VTC returned HTTP 500: not a document"
3611 );
3612
3613 let bare = RoomTarget {
3614 host_did: None,
3615 ..vtc_target()
3616 };
3617 assert_eq!(
3618 keys_commit_command(&bare),
3619 "pnm rooms keys commit --room did:webvh:example.com:rooms:northwind \
3620 --host https://community.example/v1"
3621 );
3622 }
3623
3624 fn open_rejected(reason: &str, extra: Value) -> VtaError {
3625 let mut details = json!({ "reason": reason });
3626 details
3627 .as_object_mut()
3628 .unwrap()
3629 .extend(extra.as_object().unwrap().clone());
3630 VtaError::Rejected {
3631 code: "rooms/keys/open:unknownEpoch".into(),
3632 message: "record is sealed under epoch 4".into(),
3633 details: Some(details),
3634 }
3635 }
3636
3637 #[test]
3640 fn a_removed_member_is_told_it_was_removed_not_to_catch_up() {
3641 let text = open_refusal_hint(
3642 &open_rejected("removed", json!({ "removedAtEpoch": 3 })),
3643 &vtc_target(),
3644 );
3645 assert!(
3646 text.contains("You were removed from this room's group at epoch 3"),
3647 "{text}"
3648 );
3649 assert!(!text.contains("keys commit"), "{text}");
3650 }
3651
3652 fn file_key_rejected(reason: &str, message: &str) -> VtaError {
3653 VtaError::Rejected {
3654 code: "rooms/keys/file-key:unknownEpoch".into(),
3655 message: message.into(),
3656 details: Some(json!({ "reason": reason })),
3657 }
3658 }
3659
3660 #[test]
3664 fn a_file_key_refusal_names_the_remedy() {
3665 let removed = VtaError::Rejected {
3666 code: "rooms/keys/file-key:unknownEpoch".into(),
3667 message: "the file is sealed under epoch 4, and this member was removed".into(),
3668 details: Some(json!({ "reason": "removed", "removedAtEpoch": 3 })),
3669 };
3670 let text = file_key_refusal_hint(&removed, &vtc_target());
3671 assert!(
3672 text.contains("You were removed from this room's group at epoch 3"),
3673 "{text}"
3674 );
3675 assert!(text.contains("Files sealed since then"), "{text}");
3676 assert!(!text.contains("keys commit"), "{text}");
3677
3678 let behind_commit = file_key_refusal_hint(
3680 &file_key_rejected("notDelivered", "the file is sealed under epoch 4"),
3681 &vtc_target(),
3682 );
3683 assert!(behind_commit.contains(CATCH_UP), "{behind_commit}");
3684
3685 let behind = file_key_refusal_hint(
3686 &file_key_rejected("beyondChain", "sealed under epoch 1"),
3687 &vtc_target(),
3688 );
3689 assert!(behind.contains("pnm rooms keys backfill"), "{behind}");
3690 assert!(behind.contains("The file is older"), "{behind}");
3691
3692 let wrong_task = VtaError::Rejected {
3694 code: "rooms/keys/open:unknownEpoch".into(),
3695 message: "x".into(),
3696 details: Some(json!({ "reason": "beyondChain" })),
3697 };
3698 assert!(
3699 file_key_refusal_hint(&wrong_task, &vtc_target()).contains("did not open with any key")
3700 );
3701 }
3702
3703 #[test]
3705 fn a_member_behind_is_told_to_catch_up() {
3706 let text = open_refusal_hint(
3707 &open_rejected("notDelivered", json!({ "heldEpoch": 2 })),
3708 &vtc_target(),
3709 );
3710 assert!(text.contains(CATCH_UP), "{text}");
3711 }
3712
3713 #[test]
3716 fn renew_refuses_a_sealed_room_and_names_group_renew() {
3717 let target = RoomTarget {
3718 host_url: "https://community.example/v1",
3719 host_did: Some("did:webvh:community.example"),
3720 room_id: "did:webvh:example.com:rooms:northwind",
3721 };
3722 assert!(refuse_sealed_renew("open", &target).is_ok());
3723 for tier in ["attributed", "private"] {
3724 let e = refuse_sealed_renew(tier, &target).expect_err(tier);
3725 assert!(
3726 e.contains(
3727 "pnm rooms owner group-renew --room did:webvh:example.com:rooms:northwind \
3728 --host-did did:webvh:community.example"
3729 ),
3730 "{e}"
3731 );
3732 assert!(e.contains(tier), "{e}");
3733 }
3734 let no_did = RoomTarget {
3735 host_did: None,
3736 ..target
3737 };
3738 let e = refuse_sealed_renew("private", &no_did).unwrap_err();
3739 assert!(e.contains("--host-did <the host's DID>"), "{e}");
3740 }
3741
3742 #[test]
3743 fn a_minted_presentation_becomes_a_session() {
3744 let session = session_from_minted("did:webvh:room", &minted(json!(["leaf", "root"])))
3745 .expect("a well-formed reply rebuilds");
3746 assert_eq!(session.room_id(), "did:webvh:room");
3747 assert_eq!(session.chain_depth(), 2, "leaf first, root last");
3748 }
3749
3750 #[test]
3754 fn a_subject_binding_survives_the_rebuild() {
3755 let mut m = minted(json!(["leaf"]));
3756 m["presentation"]["subjectBinding"] = json!("zk-proof-blob");
3757 let session =
3758 session_from_minted("did:webvh:room", &m).expect("a private presentation rebuilds");
3759 assert_eq!(session.chain_depth(), 1);
3760 }
3761
3762 #[test]
3766 fn an_unreadable_reply_is_refused_rather_than_guessed() {
3767 assert!(
3768 session_from_minted("r", &json!({})).is_err(),
3769 "no presentation"
3770 );
3771 assert!(
3772 session_from_minted("r", &json!({ "presentation": { "authority": ["a"] } })).is_err(),
3773 "no membership"
3774 );
3775 assert!(
3776 session_from_minted("r", &json!({ "presentation": { "membership": "m" } })).is_err(),
3777 "no chain"
3778 );
3779 assert!(
3780 session_from_minted("r", &minted(json!([]))).is_err(),
3781 "an empty chain authorizes nothing and must not be sent"
3782 );
3783 }
3784
3785 #[test]
3789 fn a_malformed_link_does_not_silently_shorten_the_chain() {
3790 let err = session_from_minted("r", &minted(json!(["leaf", 7])))
3791 .expect_err("a non-string link is refused");
3792 assert!(
3793 err.to_string().contains("non-string"),
3794 "the error must name the cause: {err}"
3795 );
3796 }
3797
3798 #[test]
3800 fn untrusted_text_loses_its_control_characters_but_keeps_its_lines() {
3801 assert_eq!(inert("a\x1b[2Jb\nc\td\x07"), "a[2Jb\nc\td");
3802 }
3803
3804 #[test]
3806 fn a_file_name_cannot_leave_the_directory_or_hide() {
3807 assert_eq!(local_name("../../etc/passwd"), "passwd");
3808 assert_eq!(local_name("C:\\Users\\x\\evil.exe"), "evil.exe");
3809 assert_eq!(local_name(".bashrc"), "bashrc");
3810 assert_eq!(local_name("term sheet.pdf"), "term sheet.pdf");
3811 assert_eq!(local_name("../"), "room-file");
3812 assert_eq!(local_name("a\nb.txt"), "a b.txt");
3813 }
3814
3815 #[test]
3817 fn opaque_keys_are_fresh_and_uninformative() {
3818 let (a, b) = (opaque_key(), opaque_key());
3819 assert_ne!(a, b);
3820 assert_eq!(a.len(), 22, "16 random bytes, base64url");
3821 }
3822
3823 #[test]
3824 fn pin_flags_keep_unchanged_distinct_from_unpinned() {
3825 assert_eq!(pinned_from_flags(true, false), Some(true));
3826 assert_eq!(pinned_from_flags(false, true), Some(false));
3827 assert_eq!(
3828 pinned_from_flags(false, false),
3829 None,
3830 "neither flag must leave the pin alone, not clear it"
3831 );
3832 }
3833}