use serde_json::Value;
use vta_sdk::prelude::*;
use vtc_client::VtcClient;
use vtc_client::rooms::{CleartextContent, RoomSession, Visibility};
pub struct RoomTarget<'a> {
pub host_url: &'a str,
pub host_did: Option<&'a str>,
pub room_id: &'a str,
}
impl RoomTarget<'_> {
fn client(&self) -> VtcClient {
VtcClient::anonymous(self.host_url, self.host_did.unwrap_or("did:key:zHost"))
}
}
async fn present(
client: &VtaClient,
target: &RoomTarget<'_>,
action: &str,
) -> Result<RoomSession, Box<dyn std::error::Error>> {
if target.host_did.is_none() {
eprintln!(
"note: no --host-did given, so this presentation is not bound to a host. \
Anyone who observes it can use it against this room until it expires."
);
}
let minted = client
.room_present(target.room_id, action, target.host_did, None)
.await?;
session_from_minted(target.room_id, &minted)
}
pub fn session_from_minted(
room_id: &str,
minted: &Value,
) -> Result<RoomSession, Box<dyn std::error::Error>> {
let presentation = minted
.get("presentation")
.ok_or_else(|| format!("the VTA's reply carried no presentation: {minted}"))?;
let membership = presentation
.get("membership")
.and_then(Value::as_str)
.ok_or("the minted presentation carried no membership credential")?;
let authority_values = presentation
.get("authority")
.and_then(Value::as_array)
.ok_or("the minted presentation carried no authority chain")?;
let authority: Vec<String> = authority_values
.iter()
.filter_map(|v| v.as_str().map(str::to_string))
.collect();
if authority.len() != authority_values.len() {
return Err("the minted authority chain contained a non-string link".into());
}
let mut session = RoomSession::new(room_id, membership, authority)?;
if let Some(binding) = presentation.get("subjectBinding").and_then(Value::as_str) {
session = session.with_subject_binding(binding);
}
Ok(session)
}
pub fn pinned_from_flags(pin: bool, unpin: bool) -> Option<bool> {
if pin {
Some(true)
} else if unpin {
Some(false)
} else {
None
}
}
pub struct RoomSigner<'a> {
pub did: &'a str,
pub key_multibase: &'a str,
}
pub async fn cmd_rooms_list(
client: &VtaClient,
target: RoomTarget<'_>,
signer: RoomSigner<'_>,
prefix: Option<&str>,
since_version: Option<u64>,
limit: Option<usize>,
) -> Result<(), Box<dyn std::error::Error>> {
let session = present(client, &target, "read").await?;
let listing = target
.client()
.list_records(
&session,
prefix,
since_version,
signer.did,
signer.key_multibase,
)
.await?;
if crate::render::is_json_output() {
crate::render::print_json(&listing.records)?;
return Ok(());
}
if listing.records.is_empty() {
println!(
"No records{}.",
prefix.map(|p| format!(" under `{p}`")).unwrap_or_default()
);
return Ok(());
}
println!("{} record(s):", listing.records.len());
for r in listing.records.iter().take(limit.unwrap_or(usize::MAX)) {
let key = r.get("key").and_then(Value::as_str).unwrap_or("?");
let version = r.get("version").and_then(Value::as_u64).unwrap_or(0);
let status = r.get("status").and_then(Value::as_str).unwrap_or("active");
let title = r
.get("cleartext")
.and_then(|c| c.get("title"))
.and_then(Value::as_str)
.unwrap_or("(sealed)");
println!(" {key} v{version} {status:<10} {title}");
}
Ok(())
}
pub async fn cmd_rooms_get(
client: &VtaClient,
target: RoomTarget<'_>,
signer: RoomSigner<'_>,
key: &str,
) -> Result<(), Box<dyn std::error::Error>> {
let session = present(client, &target, "read").await?;
let record = target
.client()
.get_record(&session, key, signer.did, signer.key_multibase)
.await?;
if let Some(cleartext) = record.get("cleartext") {
if crate::render::is_json_output() {
crate::render::print_json(cleartext)?;
} else {
if let Some(title) = cleartext.get("title").and_then(Value::as_str) {
println!("{title}\n");
}
println!(
"{}",
cleartext.get("body").and_then(Value::as_str).unwrap_or("")
);
}
return Ok(());
}
let sealed = record.get("sealed").and_then(Value::as_str).ok_or(
"the record carried neither cleartext nor sealed content — is this a room this \
host serves?",
)?;
let nonce = record
.get("nonce")
.and_then(Value::as_str)
.ok_or("a sealed record with no nonce cannot be opened")?;
let epoch = record.get("epoch").and_then(Value::as_u64).unwrap_or(0) as u32;
let version = record.get("version").and_then(Value::as_u64).unwrap_or(0);
let opened = client
.room_open(target.room_id, key, version, sealed, nonce, epoch)
.await
.map_err(|e| {
format!(
"{e}\n\nIf this mentions an epoch, your VTA has not been given the room's \
latest commit — ask the room's owner to deliver it, then retry."
)
})?;
let plaintext = opened
.get("plaintext")
.and_then(Value::as_str)
.ok_or("the VTA opened the record but returned no plaintext")?;
let bytes =
base64::Engine::decode(&base64::engine::general_purpose::URL_SAFE_NO_PAD, plaintext)?;
println!("{}", String::from_utf8_lossy(&bytes));
Ok(())
}
pub async fn cmd_rooms_put(
client: &VtaClient,
target: RoomTarget<'_>,
signer: RoomSigner<'_>,
key: &str,
title: Option<String>,
body: String,
expected_version: Option<u64>,
) -> Result<(), Box<dyn std::error::Error>> {
let session = present(client, &target, "write").await?;
let put = target
.client()
.put_record(
&session,
key,
None,
Some(CleartextContent {
title,
body,
..Default::default()
}),
expected_version,
signer.did,
signer.key_multibase,
)
.await
.map_err(|e| {
format!(
"{e}\n\nA sealed room (`attributed` / `private`) refuses cleartext: sealing \
needs the room's group key, which lives in your VTA, and no task seals on a \
caller's behalf yet."
)
})?;
println!("Wrote {} at version {}", put.key, put.version);
Ok(())
}
pub async fn cmd_rooms_curate(
client: &VtaClient,
target: RoomTarget<'_>,
signer: RoomSigner<'_>,
key: &str,
status: Option<String>,
pinned: Option<bool>,
reason: Option<String>,
) -> Result<(), Box<dyn std::error::Error>> {
if status.is_none() && pinned.is_none() {
return Err("nothing to change — pass --status and/or --pin/--unpin".into());
}
let session = present(client, &target, "curate").await?;
let out = target
.client()
.curate_record(
&session,
key,
status,
pinned,
reason,
signer.did,
signer.key_multibase,
)
.await?;
println!(
"{} is now {} at version {}{}",
out.key,
serde_json::to_value(out.status)
.ok()
.and_then(|v| v.as_str().map(str::to_string))
.unwrap_or_else(|| "?".into()),
out.version,
if out.pinned { " (pinned)" } else { "" }
);
Ok(())
}
pub async fn cmd_rooms_renew(
client: &VtaClient,
target: RoomTarget<'_>,
signer: RoomSigner<'_>,
epoch: u32,
reason: Option<String>,
) -> Result<(), Box<dyn std::error::Error>> {
let session = present(client, &target, "admin").await?;
let minted = target
.client()
.mint_epoch(
&session,
epoch,
reason.as_deref(),
signer.did,
signer.key_multibase,
)
.await?;
println!("Room {} is at epoch {}", minted.room_id, minted.epoch);
Ok(())
}
pub async fn cmd_rooms_create(
target: RoomTarget<'_>,
signer: RoomSigner<'_>,
visibility: &str,
retention_days: Option<u32>,
) -> Result<(), Box<dyn std::error::Error>> {
let visibility: Visibility = serde_json::from_value(Value::String(visibility.to_string()))
.map_err(|_| "visibility must be one of: open, attributed, private")?;
target
.client()
.create_room(
target.room_id,
signer.did,
visibility,
retention_days,
signer.did,
signer.key_multibase,
)
.await
.map_err(|e| {
format!(
"{e}\n\nA community host decides whose rooms it stores. If this says \
`not-a-member`, you are not a member there; if it says \
`private-tier-not-enabled`, that community has not turned the tier on."
)
})?;
println!("Registered {} as {}", target.room_id, signer.did);
println!(
" Next: the room must issue you a membership and an authority credential before \
you can act in it."
);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn minted(authority: Value) -> Value {
json!({
"presentation": {
"membership": "vmc-blob",
"authority": authority,
},
"expiresAt": "2026-09-07T16:00:00Z",
})
}
#[test]
fn a_minted_presentation_becomes_a_session() {
let session = session_from_minted("did:webvh:room", &minted(json!(["leaf", "root"])))
.expect("a well-formed reply rebuilds");
assert_eq!(session.room_id(), "did:webvh:room");
assert_eq!(session.chain_depth(), 2, "leaf first, root last");
}
#[test]
fn a_subject_binding_survives_the_rebuild() {
let mut m = minted(json!(["leaf"]));
m["presentation"]["subjectBinding"] = json!("zk-proof-blob");
let session =
session_from_minted("did:webvh:room", &m).expect("a private presentation rebuilds");
assert_eq!(session.chain_depth(), 1);
}
#[test]
fn an_unreadable_reply_is_refused_rather_than_guessed() {
assert!(
session_from_minted("r", &json!({})).is_err(),
"no presentation"
);
assert!(
session_from_minted("r", &json!({ "presentation": { "authority": ["a"] } })).is_err(),
"no membership"
);
assert!(
session_from_minted("r", &json!({ "presentation": { "membership": "m" } })).is_err(),
"no chain"
);
assert!(
session_from_minted("r", &minted(json!([]))).is_err(),
"an empty chain authorizes nothing and must not be sent"
);
}
#[test]
fn a_malformed_link_does_not_silently_shorten_the_chain() {
let err = session_from_minted("r", &minted(json!(["leaf", 7])))
.expect_err("a non-string link is refused");
assert!(
err.to_string().contains("non-string"),
"the error must name the cause: {err}"
);
}
#[test]
fn pin_flags_keep_unchanged_distinct_from_unpinned() {
assert_eq!(pinned_from_flags(true, false), Some(true));
assert_eq!(pinned_from_flags(false, true), Some(false));
assert_eq!(
pinned_from_flags(false, false),
None,
"neither flag must leave the pin alone, not clear it"
);
}
}