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(
79 client: &VtaClient,
80 target: &RoomTarget<'_>,
81 action: &str,
82) -> Result<RoomSession, Box<dyn std::error::Error>> {
83 let minted = client.room_present(target.room_id, action).await?;
84 session_from_minted(target.room_id, &minted)
85}
86
87pub fn session_from_minted(
98 room_id: &str,
99 minted: &Value,
100) -> Result<RoomSession, Box<dyn std::error::Error>> {
101 let presentation = minted
102 .get("presentation")
103 .ok_or_else(|| format!("the VTA's reply carried no presentation: {minted}"))?;
104 let membership = presentation
105 .get("membership")
106 .and_then(Value::as_str)
107 .ok_or("the minted presentation carried no membership credential")?;
108 let authority_values = presentation
109 .get("authority")
110 .and_then(Value::as_array)
111 .ok_or("the minted presentation carried no authority chain")?;
112 let authority: Vec<String> = authority_values
113 .iter()
114 .filter_map(|v| v.as_str().map(str::to_string))
115 .collect();
116 if authority.len() != authority_values.len() {
120 return Err("the minted authority chain contained a non-string link".into());
121 }
122
123 let mut session = RoomSession::new(room_id, membership, authority)?;
124 if let Some(binding) = presentation.get("subjectBinding").and_then(Value::as_str) {
125 session = session.with_subject_binding(binding);
126 }
127 Ok(session)
128}
129
130pub fn pinned_from_flags(pin: bool, unpin: bool) -> Option<bool> {
136 if pin {
137 Some(true)
138 } else if unpin {
139 Some(false)
140 } else {
141 None
142 }
143}
144
145pub struct RoomSigner<'a> {
152 pub did: &'a str,
153 pub key_multibase: &'a str,
154}
155
156pub async fn cmd_rooms_list(
158 client: &VtaClient,
159 target: RoomTarget<'_>,
160 signer: RoomSigner<'_>,
161 prefix: Option<&str>,
162 since_version: Option<u64>,
163 limit: Option<usize>,
164) -> Result<(), Box<dyn std::error::Error>> {
165 let session = present(client, &target, "read").await?;
166
167 let mut listing = target
172 .client()
173 .list_records(
174 &session,
175 prefix,
176 since_version,
177 None,
178 signer.did,
179 signer.key_multibase,
180 )
181 .await?;
182 let mut cursor = listing.cursor.clone();
183 while let Some(c) = cursor {
184 let next = target
185 .client()
186 .list_records(
187 &session,
188 prefix,
189 since_version,
190 Some(&c),
191 signer.did,
192 signer.key_multibase,
193 )
194 .await?;
195 cursor = next.cursor.clone();
196 listing.records.extend(next.records);
197 }
198
199 if crate::render::is_json_output() {
200 crate::render::print_json(&listing.records)?;
201 return Ok(());
202 }
203 if listing.records.is_empty() {
204 println!(
205 "No records{}.",
206 prefix.map(|p| format!(" under `{p}`")).unwrap_or_default()
207 );
208 return Ok(());
209 }
210
211 let shown = limit.unwrap_or(usize::MAX).min(listing.records.len());
212 if shown < listing.records.len() {
213 println!(
214 "{} record(s), showing {shown} — pass a larger --limit for the rest:",
215 listing.records.len()
216 );
217 } else {
218 println!("{} record(s):", listing.records.len());
219 }
220 for r in listing.records.iter().take(shown) {
221 let key = r.get("key").and_then(Value::as_str).unwrap_or("?");
222 let version = r.get("version").and_then(Value::as_u64).unwrap_or(0);
223 let status = r.get("status").and_then(Value::as_str).unwrap_or("active");
224 let title = r
227 .get("cleartext")
228 .and_then(|c| c.get("title"))
229 .and_then(Value::as_str)
230 .unwrap_or("(sealed)");
231 println!(" {key} v{version} {status:<10} {title}");
232 }
233 Ok(())
234}
235
236pub async fn cmd_rooms_get(
238 client: &VtaClient,
239 target: RoomTarget<'_>,
240 signer: RoomSigner<'_>,
241 key: &str,
242) -> Result<(), Box<dyn std::error::Error>> {
243 let session = present(client, &target, "read").await?;
244 let record = target
245 .client()
246 .get_record(&session, key, signer.did, signer.key_multibase)
247 .await?;
248
249 if let Some(cleartext) = record.get("cleartext") {
253 if crate::render::is_json_output() {
254 crate::render::print_json(cleartext)?;
255 } else {
256 if let Some(title) = cleartext.get("title").and_then(Value::as_str) {
257 println!("{title}\n");
258 }
259 println!(
260 "{}",
261 cleartext.get("body").and_then(Value::as_str).unwrap_or("")
262 );
263 }
264 return Ok(());
265 }
266
267 let sealed = record.get("sealed").and_then(Value::as_str).ok_or(
268 "the record carried neither cleartext nor sealed content — is this a room this \
269 host serves?",
270 )?;
271 let nonce = record
272 .get("nonce")
273 .and_then(Value::as_str)
274 .ok_or("a sealed record with no nonce cannot be opened")?;
275 let epoch = record.get("epoch").and_then(Value::as_u64).unwrap_or(0) as u32;
276 let version = record.get("version").and_then(Value::as_u64).unwrap_or(0);
277
278 let opened = client
279 .room_open(target.room_id, key, version, sealed, nonce, epoch)
280 .await
281 .map_err(|e| {
282 format!(
286 "{e}\n\nIf this mentions an epoch, your VTA has not been given the room's \
287 latest commit — ask the room's owner to deliver it, then retry."
288 )
289 })?;
290
291 let plaintext = opened
292 .get("plaintext")
293 .and_then(Value::as_str)
294 .ok_or("the VTA opened the record but returned no plaintext")?;
295 let bytes =
296 base64::Engine::decode(&base64::engine::general_purpose::URL_SAFE_NO_PAD, plaintext)?;
297 println!("{}", String::from_utf8_lossy(&bytes));
298 Ok(())
299}
300
301pub async fn cmd_rooms_put(
308 client: &VtaClient,
309 target: RoomTarget<'_>,
310 signer: RoomSigner<'_>,
311 key: &str,
312 title: Option<String>,
313 body: String,
314 expected_version: Option<u64>,
315) -> Result<(), Box<dyn std::error::Error>> {
316 let session = present(client, &target, "write").await?;
317 let put = target
318 .client()
319 .put_record(
320 &session,
321 key,
322 None,
323 Some(CleartextContent {
324 title,
325 body,
326 ..Default::default()
327 }),
328 expected_version,
329 signer.did,
330 signer.key_multibase,
331 )
332 .await
333 .map_err(|e| {
334 format!(
335 "{e}\n\nA sealed room (`attributed` / `private`) refuses cleartext: sealing \
336 needs the room's group key, which lives in your VTA, and no task seals on a \
337 caller's behalf yet."
338 )
339 })?;
340 println!("Wrote {} at version {}", put.key, put.version);
341 Ok(())
342}
343
344pub async fn cmd_rooms_curate(
346 client: &VtaClient,
347 target: RoomTarget<'_>,
348 signer: RoomSigner<'_>,
349 key: &str,
350 status: Option<String>,
351 pinned: Option<bool>,
352 reason: Option<String>,
353) -> Result<(), Box<dyn std::error::Error>> {
354 if status.is_none() && pinned.is_none() {
355 return Err("nothing to change — pass --status and/or --pin/--unpin".into());
356 }
357 let session = present(client, &target, "curate").await?;
362
363 let out = target
364 .client()
365 .curate_record(
366 &session,
367 key,
368 status,
369 pinned,
370 reason,
371 signer.did,
372 signer.key_multibase,
373 )
374 .await?;
375 println!(
376 "{} is now {} at version {}{}",
377 out.key,
378 serde_json::to_value(out.status)
379 .ok()
380 .and_then(|v| v.as_str().map(str::to_string))
381 .unwrap_or_else(|| "?".into()),
382 out.version,
383 if out.pinned { " (pinned)" } else { "" }
384 );
385 Ok(())
386}
387
388pub async fn cmd_rooms_renew(
394 client: &VtaClient,
395 target: RoomTarget<'_>,
396 signer: RoomSigner<'_>,
397 epoch: u32,
398 reason: Option<String>,
399) -> Result<(), Box<dyn std::error::Error>> {
400 let session = present(client, &target, "admin").await?;
401 let minted = target
402 .client()
403 .mint_epoch(
404 &session,
405 epoch,
406 reason.as_deref(),
407 signer.did,
408 signer.key_multibase,
409 )
410 .await?;
411 println!("Room {} is at epoch {}", minted.room_id, minted.epoch);
412 Ok(())
413}
414
415pub async fn cmd_rooms_create(
421 target: RoomTarget<'_>,
422 signer: RoomSigner<'_>,
423 visibility: &str,
424 retention_days: Option<u32>,
425) -> Result<(), Box<dyn std::error::Error>> {
426 let visibility: Visibility = serde_json::from_value(Value::String(visibility.to_string()))
427 .map_err(|_| "visibility must be one of: open, attributed, private")?;
428
429 target
430 .client()
431 .create_room(
432 target.room_id,
433 signer.did,
434 visibility,
435 retention_days,
436 signer.did,
437 signer.key_multibase,
438 )
439 .await
440 .map_err(|e| {
441 format!(
442 "{e}\n\nA community host decides whose rooms it stores. If this says \
443 `not-a-member`, you are not a member there; if it says \
444 `private-tier-not-enabled`, that community has not turned the tier on."
445 )
446 })?;
447 println!("Registered {} as {}", target.room_id, signer.did);
448 println!(
449 " Next: the room must issue you a membership and an authority credential before \
450 you can act in it."
451 );
452 Ok(())
453}
454
455#[cfg(test)]
456mod tests {
457 use super::*;
458 use serde_json::json;
459
460 fn minted(authority: Value) -> Value {
461 json!({
462 "presentation": {
463 "membership": "vmc-blob",
464 "authority": authority,
465 },
466 "expiresAt": "2026-09-07T16:00:00Z",
467 })
468 }
469
470 #[test]
471 fn a_minted_presentation_becomes_a_session() {
472 let session = session_from_minted("did:webvh:room", &minted(json!(["leaf", "root"])))
473 .expect("a well-formed reply rebuilds");
474 assert_eq!(session.room_id(), "did:webvh:room");
475 assert_eq!(session.chain_depth(), 2, "leaf first, root last");
476 }
477
478 #[test]
482 fn a_subject_binding_survives_the_rebuild() {
483 let mut m = minted(json!(["leaf"]));
484 m["presentation"]["subjectBinding"] = json!("zk-proof-blob");
485 let session =
486 session_from_minted("did:webvh:room", &m).expect("a private presentation rebuilds");
487 assert_eq!(session.chain_depth(), 1);
488 }
489
490 #[test]
494 fn an_unreadable_reply_is_refused_rather_than_guessed() {
495 assert!(
496 session_from_minted("r", &json!({})).is_err(),
497 "no presentation"
498 );
499 assert!(
500 session_from_minted("r", &json!({ "presentation": { "authority": ["a"] } })).is_err(),
501 "no membership"
502 );
503 assert!(
504 session_from_minted("r", &json!({ "presentation": { "membership": "m" } })).is_err(),
505 "no chain"
506 );
507 assert!(
508 session_from_minted("r", &minted(json!([]))).is_err(),
509 "an empty chain authorizes nothing and must not be sent"
510 );
511 }
512
513 #[test]
517 fn a_malformed_link_does_not_silently_shorten_the_chain() {
518 let err = session_from_minted("r", &minted(json!(["leaf", 7])))
519 .expect_err("a non-string link is refused");
520 assert!(
521 err.to_string().contains("non-string"),
522 "the error must name the cause: {err}"
523 );
524 }
525
526 #[test]
527 fn pin_flags_keep_unchanged_distinct_from_unpinned() {
528 assert_eq!(pinned_from_flags(true, false), Some(true));
529 assert_eq!(pinned_from_flags(false, true), Some(false));
530 assert_eq!(
531 pinned_from_flags(false, false),
532 None,
533 "neither flag must leave the pin alone, not clear it"
534 );
535 }
536}