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>> {
let minted = client.room_present(target.room_id, action).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 mut listing = target
.client()
.list_records(
&session,
prefix,
since_version,
None,
signer.did,
signer.key_multibase,
)
.await?;
let mut cursor = listing.cursor.clone();
while let Some(c) = cursor {
let next = target
.client()
.list_records(
&session,
prefix,
since_version,
Some(&c),
signer.did,
signer.key_multibase,
)
.await?;
cursor = next.cursor.clone();
listing.records.extend(next.records);
}
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(());
}
let shown = limit.unwrap_or(usize::MAX).min(listing.records.len());
if shown < listing.records.len() {
println!(
"{} record(s), showing {shown} — pass a larger --limit for the rest:",
listing.records.len()
);
} else {
println!("{} record(s):", listing.records.len());
}
for r in listing.records.iter().take(shown) {
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(())
}
const BLOB_MANIFEST: &str = "blob.json";
const FILE_MANIFEST: &str = "file.json";
const CHUNKS_DIR: &str = "chunks";
fn chunk_path(dir: &std::path::Path, index: usize) -> std::path::PathBuf {
dir.join(CHUNKS_DIR).join(format!("{index:06}.bin"))
}
fn file_key_bytes(
resp: &trust_tasks_rs::specs::rooms::keys::file_key::v0_1::Response,
) -> Result<vti_rooms::files::FileKey, Box<dyn std::error::Error>> {
use base64::Engine as _;
let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(resp.key.as_str())?;
bytes
.try_into()
.map_err(|_| "the VTA returned a file key that is not 32 bytes".into())
}
pub async fn cmd_rooms_file_seal(
client: &VtaClient,
room_id: &str,
path: &std::path::Path,
out: &std::path::Path,
padme: bool,
) -> Result<(), Box<dyn std::error::Error>> {
use vti_rooms::files::{self, FileBinding, Padding};
if out.exists() {
return Err(format!("{} already exists; choose a new directory", out.display()).into());
}
let plaintext = std::fs::read(path)?;
let name = path
.file_name()
.and_then(|n| n.to_str())
.ok_or("the file has no usable name")?;
let file_id = files::new_file_id();
let resp = client
.room_file_key(room_id, &files::encode_file_id(&file_id), None)
.await?;
let key = file_key_bytes(&resp)?;
let sealed = files::seal_file(
&key,
FileBinding {
room_id: room_id.to_string(),
file_id,
epoch: resp.epoch.get(),
segment_size: files::DEFAULT_SEGMENT_SIZE,
},
name,
None,
&plaintext,
if padme { Padding::Padme } else { Padding::None },
)?;
std::fs::create_dir_all(out.join(CHUNKS_DIR))?;
for (i, chunk) in sealed.chunks.iter().enumerate() {
std::fs::write(chunk_path(out, i), chunk)?;
}
std::fs::write(
out.join(BLOB_MANIFEST),
serde_json::to_vec_pretty(&sealed.blob)?,
)?;
std::fs::write(
out.join(FILE_MANIFEST),
serde_json::to_vec_pretty(&sealed.file)?,
)?;
println!(
"Sealed {} ({} bytes) under epoch {} into {}\n blobRef {}\n chunks {} of {} bytes",
name,
sealed.file.size,
sealed.file.epoch,
out.display(),
sealed.file.blob_ref,
sealed.blob.chunks.chunk_count,
sealed.blob.chunks.chunk_size,
);
println!(
" {FILE_MANIFEST} goes inside the sealed record body; it names the file and is never \
sent to the host in the clear."
);
Ok(())
}
pub async fn cmd_rooms_file_open(
client: &VtaClient,
room_id: &str,
dir: &std::path::Path,
out: &std::path::Path,
) -> Result<(), Box<dyn std::error::Error>> {
use vti_rooms::files::{self, BlobManifest, FileManifest};
if out.exists() {
return Err(format!("{} already exists; refusing to overwrite it", out.display()).into());
}
let file: FileManifest = serde_json::from_slice(&std::fs::read(dir.join(FILE_MANIFEST))?)?;
let blob: BlobManifest = serde_json::from_slice(&std::fs::read(dir.join(BLOB_MANIFEST))?)?;
file.check_blob(&blob)?;
let mut chunks = Vec::with_capacity(blob.chunks.chunk_count as usize);
for (i, expected) in blob.chunks.chunk_digests.iter().enumerate() {
let chunk = std::fs::read(chunk_path(dir, i))?;
if !files::same_digest(&files::digest_multibase(&chunk), expected) {
return Err(format!("chunk {i} does not match the manifest").into());
}
chunks.push(chunk);
}
let resp = client
.room_file_key(room_id, &file.file_id, Some(file.epoch))
.await?;
let key = file_key_bytes(&resp)?;
let plaintext = files::open_file(&key, room_id, &file, &blob, &chunks)?;
let tmp = out.with_extension("partial");
std::fs::write(&tmp, &plaintext)?;
std::fs::rename(&tmp, out)?;
println!(
"Opened {} ({} bytes) into {} — the digest its author signed matches.",
file.name.escape_debug(),
plaintext.len(),
out.display()
);
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"
);
}
}