1use serde_json::Value;
32use vta_sdk::prelude::*;
33use vtc_client::VtcClient;
34use vtc_client::rooms::{CleartextContent, RoomSession, Visibility};
35
36pub struct RoomTarget<'a> {
44 pub host_url: &'a str,
45 pub host_did: Option<&'a str>,
46 pub room_id: &'a str,
47}
48
49impl RoomTarget<'_> {
50 fn client(&self) -> VtcClient {
51 VtcClient::anonymous(self.host_url, self.host_did.unwrap_or("did:key:zHost"))
56 }
57}
58
59async fn present(
61 client: &VtaClient,
62 target: &RoomTarget<'_>,
63 action: &str,
64) -> Result<RoomSession, Box<dyn std::error::Error>> {
65 if target.host_did.is_none() {
66 eprintln!(
67 "note: no --host-did given, so this presentation is not bound to a host. \
68 Anyone who observes it can use it against this room until it expires."
69 );
70 }
71
72 let minted = client
73 .room_present(target.room_id, action, target.host_did, None)
74 .await?;
75 session_from_minted(target.room_id, &minted)
76}
77
78pub fn session_from_minted(
89 room_id: &str,
90 minted: &Value,
91) -> Result<RoomSession, Box<dyn std::error::Error>> {
92 let presentation = minted
93 .get("presentation")
94 .ok_or_else(|| format!("the VTA's reply carried no presentation: {minted}"))?;
95 let membership = presentation
96 .get("membership")
97 .and_then(Value::as_str)
98 .ok_or("the minted presentation carried no membership credential")?;
99 let authority_values = presentation
100 .get("authority")
101 .and_then(Value::as_array)
102 .ok_or("the minted presentation carried no authority chain")?;
103 let authority: Vec<String> = authority_values
104 .iter()
105 .filter_map(|v| v.as_str().map(str::to_string))
106 .collect();
107 if authority.len() != authority_values.len() {
111 return Err("the minted authority chain contained a non-string link".into());
112 }
113
114 let mut session = RoomSession::new(room_id, membership, authority)?;
115 if let Some(binding) = presentation.get("subjectBinding").and_then(Value::as_str) {
116 session = session.with_subject_binding(binding);
117 }
118 Ok(session)
119}
120
121pub fn pinned_from_flags(pin: bool, unpin: bool) -> Option<bool> {
127 if pin {
128 Some(true)
129 } else if unpin {
130 Some(false)
131 } else {
132 None
133 }
134}
135
136pub struct RoomSigner<'a> {
143 pub did: &'a str,
144 pub key_multibase: &'a str,
145}
146
147pub async fn cmd_rooms_list(
149 client: &VtaClient,
150 target: RoomTarget<'_>,
151 signer: RoomSigner<'_>,
152 prefix: Option<&str>,
153 since_version: Option<u64>,
154 limit: Option<usize>,
155) -> Result<(), Box<dyn std::error::Error>> {
156 let session = present(client, &target, "read").await?;
157 let listing = target
158 .client()
159 .list_records(
160 &session,
161 prefix,
162 since_version,
163 signer.did,
164 signer.key_multibase,
165 )
166 .await?;
167
168 if crate::render::is_json_output() {
169 crate::render::print_json(&listing.records)?;
170 return Ok(());
171 }
172 if listing.records.is_empty() {
173 println!(
174 "No records{}.",
175 prefix.map(|p| format!(" under `{p}`")).unwrap_or_default()
176 );
177 return Ok(());
178 }
179
180 println!("{} record(s):", listing.records.len());
181 for r in listing.records.iter().take(limit.unwrap_or(usize::MAX)) {
182 let key = r.get("key").and_then(Value::as_str).unwrap_or("?");
183 let version = r.get("version").and_then(Value::as_u64).unwrap_or(0);
184 let status = r.get("status").and_then(Value::as_str).unwrap_or("active");
185 let title = r
188 .get("cleartext")
189 .and_then(|c| c.get("title"))
190 .and_then(Value::as_str)
191 .unwrap_or("(sealed)");
192 println!(" {key} v{version} {status:<10} {title}");
193 }
194 Ok(())
195}
196
197pub async fn cmd_rooms_get(
199 client: &VtaClient,
200 target: RoomTarget<'_>,
201 signer: RoomSigner<'_>,
202 key: &str,
203) -> Result<(), Box<dyn std::error::Error>> {
204 let session = present(client, &target, "read").await?;
205 let record = target
206 .client()
207 .get_record(&session, key, signer.did, signer.key_multibase)
208 .await?;
209
210 if let Some(cleartext) = record.get("cleartext") {
214 if crate::render::is_json_output() {
215 crate::render::print_json(cleartext)?;
216 } else {
217 if let Some(title) = cleartext.get("title").and_then(Value::as_str) {
218 println!("{title}\n");
219 }
220 println!(
221 "{}",
222 cleartext.get("body").and_then(Value::as_str).unwrap_or("")
223 );
224 }
225 return Ok(());
226 }
227
228 let sealed = record.get("sealed").and_then(Value::as_str).ok_or(
229 "the record carried neither cleartext nor sealed content — is this a room this \
230 host serves?",
231 )?;
232 let nonce = record
233 .get("nonce")
234 .and_then(Value::as_str)
235 .ok_or("a sealed record with no nonce cannot be opened")?;
236 let epoch = record.get("epoch").and_then(Value::as_u64).unwrap_or(0) as u32;
237 let version = record.get("version").and_then(Value::as_u64).unwrap_or(0);
238
239 let opened = client
240 .room_open(target.room_id, key, version, sealed, nonce, epoch)
241 .await
242 .map_err(|e| {
243 format!(
247 "{e}\n\nIf this mentions an epoch, your VTA has not been given the room's \
248 latest commit — ask the room's owner to deliver it, then retry."
249 )
250 })?;
251
252 let plaintext = opened
253 .get("plaintext")
254 .and_then(Value::as_str)
255 .ok_or("the VTA opened the record but returned no plaintext")?;
256 let bytes =
257 base64::Engine::decode(&base64::engine::general_purpose::URL_SAFE_NO_PAD, plaintext)?;
258 println!("{}", String::from_utf8_lossy(&bytes));
259 Ok(())
260}
261
262pub async fn cmd_rooms_put(
269 client: &VtaClient,
270 target: RoomTarget<'_>,
271 signer: RoomSigner<'_>,
272 key: &str,
273 title: Option<String>,
274 body: String,
275 expected_version: Option<u64>,
276) -> Result<(), Box<dyn std::error::Error>> {
277 let session = present(client, &target, "write").await?;
278 let put = target
279 .client()
280 .put_record(
281 &session,
282 key,
283 None,
284 Some(CleartextContent {
285 title,
286 body,
287 ..Default::default()
288 }),
289 expected_version,
290 signer.did,
291 signer.key_multibase,
292 )
293 .await
294 .map_err(|e| {
295 format!(
296 "{e}\n\nA sealed room (`attributed` / `private`) refuses cleartext: sealing \
297 needs the room's group key, which lives in your VTA, and no task seals on a \
298 caller's behalf yet."
299 )
300 })?;
301 println!("Wrote {} at version {}", put.key, put.version);
302 Ok(())
303}
304
305pub async fn cmd_rooms_curate(
307 client: &VtaClient,
308 target: RoomTarget<'_>,
309 signer: RoomSigner<'_>,
310 key: &str,
311 status: Option<String>,
312 pinned: Option<bool>,
313 reason: Option<String>,
314) -> Result<(), Box<dyn std::error::Error>> {
315 if status.is_none() && pinned.is_none() {
316 return Err("nothing to change — pass --status and/or --pin/--unpin".into());
317 }
318 let session = present(client, &target, "curate").await?;
323
324 let out = target
325 .client()
326 .curate_record(
327 &session,
328 key,
329 status,
330 pinned,
331 reason,
332 signer.did,
333 signer.key_multibase,
334 )
335 .await?;
336 println!(
337 "{} is now {} at version {}{}",
338 out.key,
339 serde_json::to_value(out.status)
340 .ok()
341 .and_then(|v| v.as_str().map(str::to_string))
342 .unwrap_or_else(|| "?".into()),
343 out.version,
344 if out.pinned { " (pinned)" } else { "" }
345 );
346 Ok(())
347}
348
349pub async fn cmd_rooms_renew(
355 client: &VtaClient,
356 target: RoomTarget<'_>,
357 signer: RoomSigner<'_>,
358 epoch: u32,
359 reason: Option<String>,
360) -> Result<(), Box<dyn std::error::Error>> {
361 let session = present(client, &target, "admin").await?;
362 let minted = target
363 .client()
364 .mint_epoch(
365 &session,
366 epoch,
367 reason.as_deref(),
368 signer.did,
369 signer.key_multibase,
370 )
371 .await?;
372 println!("Room {} is at epoch {}", minted.room_id, minted.epoch);
373 Ok(())
374}
375
376pub async fn cmd_rooms_create(
382 target: RoomTarget<'_>,
383 signer: RoomSigner<'_>,
384 visibility: &str,
385 retention_days: Option<u32>,
386) -> Result<(), Box<dyn std::error::Error>> {
387 let visibility: Visibility = serde_json::from_value(Value::String(visibility.to_string()))
388 .map_err(|_| "visibility must be one of: open, attributed, private")?;
389
390 target
391 .client()
392 .create_room(
393 target.room_id,
394 signer.did,
395 visibility,
396 retention_days,
397 signer.did,
398 signer.key_multibase,
399 )
400 .await
401 .map_err(|e| {
402 format!(
403 "{e}\n\nA community host decides whose rooms it stores. If this says \
404 `not-a-member`, you are not a member there; if it says \
405 `private-tier-not-enabled`, that community has not turned the tier on."
406 )
407 })?;
408 println!("Registered {} as {}", target.room_id, signer.did);
409 println!(
410 " Next: the room must issue you a membership and an authority credential before \
411 you can act in it."
412 );
413 Ok(())
414}
415
416#[cfg(test)]
417mod tests {
418 use super::*;
419 use serde_json::json;
420
421 fn minted(authority: Value) -> Value {
422 json!({
423 "presentation": {
424 "membership": "vmc-blob",
425 "authority": authority,
426 },
427 "expiresAt": "2026-09-07T16:00:00Z",
428 })
429 }
430
431 #[test]
432 fn a_minted_presentation_becomes_a_session() {
433 let session = session_from_minted("did:webvh:room", &minted(json!(["leaf", "root"])))
434 .expect("a well-formed reply rebuilds");
435 assert_eq!(session.room_id(), "did:webvh:room");
436 assert_eq!(session.chain_depth(), 2, "leaf first, root last");
437 }
438
439 #[test]
443 fn a_subject_binding_survives_the_rebuild() {
444 let mut m = minted(json!(["leaf"]));
445 m["presentation"]["subjectBinding"] = json!("zk-proof-blob");
446 let session =
447 session_from_minted("did:webvh:room", &m).expect("a private presentation rebuilds");
448 assert_eq!(session.chain_depth(), 1);
449 }
450
451 #[test]
455 fn an_unreadable_reply_is_refused_rather_than_guessed() {
456 assert!(
457 session_from_minted("r", &json!({})).is_err(),
458 "no presentation"
459 );
460 assert!(
461 session_from_minted("r", &json!({ "presentation": { "authority": ["a"] } })).is_err(),
462 "no membership"
463 );
464 assert!(
465 session_from_minted("r", &json!({ "presentation": { "membership": "m" } })).is_err(),
466 "no chain"
467 );
468 assert!(
469 session_from_minted("r", &minted(json!([]))).is_err(),
470 "an empty chain authorizes nothing and must not be sent"
471 );
472 }
473
474 #[test]
478 fn a_malformed_link_does_not_silently_shorten_the_chain() {
479 let err = session_from_minted("r", &minted(json!(["leaf", 7])))
480 .expect_err("a non-string link is refused");
481 assert!(
482 err.to_string().contains("non-string"),
483 "the error must name the cause: {err}"
484 );
485 }
486
487 #[test]
488 fn pin_flags_keep_unchanged_distinct_from_unpinned() {
489 assert_eq!(pinned_from_flags(true, false), Some(true));
490 assert_eq!(pinned_from_flags(false, true), Some(false));
491 assert_eq!(
492 pinned_from_flags(false, false),
493 None,
494 "neither flag must leave the pin alone, not clear it"
495 );
496 }
497}