use std::io::Write as _;
use std::num::NonZeroU64;
use std::path::Path;
use chrono::{DateTime, Utc};
use vta_sdk::client::{RoomAction, RoomRegistration, VtaClient};
use super::rooms::RoomTarget;
type CmdResult = Result<(), Box<dyn std::error::Error>>;
const PERSONA_HINT: &str = "Room parties are persona DIDs — DIDs your VTA holds for you in \
one of your contexts (`pnm did-mgmt dids list`), never the key this CLI signs in with.";
fn read_text(src: &str, what: &str) -> Result<String, Box<dyn std::error::Error>> {
let text = if src == "-" {
let mut s = String::new();
std::io::Read::read_to_string(&mut std::io::stdin(), &mut s)?;
s
} else {
std::fs::read_to_string(src).map_err(|e| format!("read the {what} from {src}: {e}"))?
};
let text = text.trim().to_string();
if text.is_empty() {
return Err(format!("the {what} in {src} is empty").into());
}
Ok(text)
}
fn write_new(path: &Path, contents: &[u8]) -> CmdResult {
let mut f = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(path)
.map_err(|e| match e.kind() {
std::io::ErrorKind::AlreadyExists => {
format!(
"{} already exists; refusing to overwrite it",
path.display()
)
}
_ => format!("create {}: {e}", path.display()),
})?;
f.write_all(contents)?;
Ok(())
}
pub fn validity(
valid_until: Option<&str>,
valid_for: Option<&str>,
) -> Result<Option<DateTime<Utc>>, Box<dyn std::error::Error>> {
match (valid_until, valid_for) {
(Some(_), Some(_)) => Err("give --valid-until or --valid-for, not both".into()),
(Some(t), None) => {
let at = DateTime::parse_from_rfc3339(t)
.map_err(|e| {
format!(
"--valid-until `{t}` is not an RFC 3339 time (2027-01-01T00:00:00Z): {e}"
)
})?
.with_timezone(&Utc);
if at <= Utc::now() {
return Err(format!("--valid-until `{t}` is already in the past").into());
}
Ok(Some(at))
}
(None, Some(d)) => {
let secs = crate::duration::parse_duration_secs(d)?;
let secs = i64::try_from(secs).map_err(|_| format!("--valid-for `{d}` is too long"))?;
Ok(Some(
Utc::now()
+ chrono::Duration::try_seconds(secs)
.ok_or_else(|| format!("--valid-for `{d}` is too long"))?,
))
}
(None, None) => Ok(None),
}
}
pub fn parse_actions(actions: &[String]) -> Result<Vec<RoomAction>, Box<dyn std::error::Error>> {
if actions.is_empty() {
return Err("name at least one --action: read, write, curate or admin".into());
}
actions
.iter()
.map(|a| {
a.parse::<RoomAction>().map_err(|_| {
format!("`{a}` is not a room action; use read, write, curate or admin").into()
})
})
.collect()
}
pub async fn cmd_rooms_owner_register(
client: &VtaClient,
room_id: &str,
host_did: &str,
owner: &str,
visibility: &str,
retention_days: Option<u64>,
retention_policy: Option<&str>,
) -> CmdResult {
let visibility = visibility
.parse()
.map_err(|_| "--visibility must be one of: open, attributed, private")?;
let retention_days = match retention_days {
Some(d) => Some(NonZeroU64::new(d).ok_or("--retention-days must be at least 1")?),
None => None,
};
let retention_policy = match retention_policy {
Some(p) => Some(
p.parse()
.map_err(|_| "--retention-policy must be `chained` or `fromJoin`")?,
),
None => None,
};
let resp = client
.room_owner_register(RoomRegistration {
room_id,
host_did,
visibility,
owner_did: Some(owner),
retention_days,
retention_policy,
})
.await
.map_err(|e| {
format!(
"{e}\n\n`permissionDenied` means this VTA holds no signing key for `{owner}` in \
a context you act in. {PERSONA_HINT} On a community (VTC) host the owner must \
also be a member there: `not-a-member` means it is not, and \
`private-tier-not-enabled` that the community has not turned that tier on."
)
})?;
println!(
"Registered {} with {} as {owner}; the room is at epoch {}.",
resp.room_id, resp.host, resp.epoch
);
println!(
" Next: issue {owner} the room's credentials — `pnm rooms owner issue-membership` and \
`pnm rooms owner issue-authority --action read,write,curate,admin`, each with --store — \
and on an attributed or private room found its group with \
`pnm rooms owner group-create --member {owner}`."
);
Ok(())
}
fn issuance_hint(e: impl std::fmt::Display, signing_key_id: &str) -> String {
format!(
"{e}\n\nA refusal to sign means this VTA will not let you name `{signing_key_id}` — \
check it with `pnm keys list --context <id>` (it is the `Signing key:` printed when \
the room's DID was minted). A missing `credential-write` capability is granted with \
`pnm acl update <your-did> --capabilities credential-write`."
)
}
pub async fn cmd_rooms_owner_invite(
client: &VtaClient,
room_id: &str,
signing_key_id: &str,
subject: &str,
valid_until: Option<DateTime<Utc>>,
out: &Path,
) -> CmdResult {
if out.exists() {
return Err(format!("{} already exists; refusing to overwrite it", out.display()).into());
}
let resp = client
.idempotent(|| client.room_owner_invite(room_id, signing_key_id, subject, valid_until))
.await
.map_err(|e| issuance_hint(e, signing_key_id))?;
write_new(out, resp.credential.as_bytes())?;
println!(
"Invited {subject} to {room_id}: wrote {} ({}).",
out.display(),
resp.credential_id
);
println!(
" Next: deliver it to the joiner directly. Their side runs `pnm rooms keys key-package \
--room {room_id} --invitation <file> --out kp.b64` and sends you kp.b64 for \
`pnm rooms owner group-add`."
);
Ok(())
}
#[derive(Clone, Copy)]
pub enum IssuedKind {
Membership,
Authority,
}
pub struct IssuedOutput<'a> {
pub out: Option<&'a Path>,
pub store: bool,
}
async fn deliver_issued(
client: &VtaClient,
kind: IssuedKind,
subject: &str,
room_id: &str,
credential: &str,
credential_id: &str,
output: &IssuedOutput<'_>,
) -> CmdResult {
let name = match kind {
IssuedKind::Membership => "a membership credential",
IssuedKind::Authority => "an authority credential",
};
if let Some(out) = output.out {
write_new(out, credential.as_bytes())?;
println!(
"Issued {subject} {name} for {room_id}: wrote {} ({credential_id}).",
out.display()
);
} else {
println!("Issued {subject} {name} for {room_id} ({credential_id}).");
}
if output.store {
let body: serde_json::Value = serde_json::from_str(credential)
.map_err(|e| format!("the VTA's credential is not JSON: {e}"))?;
client.cred_vault_receive(body, None).await.map_err(|e| {
let saved = output
.out
.map(|p| format!(" It is saved in {}; retry with `pnm cred-vault receive --credential-file {}`.", p.display(), p.display()))
.unwrap_or_default();
format!("issued, but this VTA's credential vault refused it: {e}.{saved}")
})?;
println!(
" Stored in this VTA's credential vault, where its presentation oracle finds it."
);
} else if let Some(out) = output.out {
println!(
" Next: {subject}'s own VTA stores it — on their side, \
`pnm cred-vault receive --credential-file {}`.",
out.display()
);
}
Ok(())
}
fn require_destination(output: &IssuedOutput<'_>) -> CmdResult {
if output.out.is_none() && !output.store {
return Err(
"the credential would go nowhere: pass --out <file> to write it, --store to keep it \
in this VTA's credential vault (when the subject is your own persona), or both"
.into(),
);
}
if let Some(out) = output.out
&& out.exists()
{
return Err(format!("{} already exists; refusing to overwrite it", out.display()).into());
}
Ok(())
}
pub async fn cmd_rooms_owner_issue_membership(
client: &VtaClient,
room_id: &str,
signing_key_id: &str,
subject: &str,
valid_until: Option<DateTime<Utc>>,
output: IssuedOutput<'_>,
) -> CmdResult {
require_destination(&output)?;
let resp = client
.idempotent(|| {
client.room_owner_issue_membership(room_id, signing_key_id, subject, valid_until)
})
.await
.map_err(|e| issuance_hint(e, signing_key_id))?;
deliver_issued(
client,
IssuedKind::Membership,
subject,
room_id,
&resp.credential,
&resp.credential_id,
&output,
)
.await
}
#[allow(clippy::too_many_arguments)]
pub async fn cmd_rooms_owner_issue_authority(
client: &VtaClient,
room_id: &str,
signing_key_id: &str,
subject: &str,
actions: &[String],
valid_until: Option<DateTime<Utc>>,
output: IssuedOutput<'_>,
) -> CmdResult {
let actions = parse_actions(actions)?;
let valid_until = valid_until.ok_or(
"an authority credential needs an expiry: pass --valid-for <duration> (e.g. 90d) or \
--valid-until <RFC 3339 time>. A chain root is withdrawn by nothing else.",
)?;
require_destination(&output)?;
let resp = client
.idempotent(|| {
client.room_owner_issue_authority(
room_id,
signing_key_id,
subject,
actions.clone(),
valid_until,
)
})
.await
.map_err(|e| issuance_hint(e, signing_key_id))?;
deliver_issued(
client,
IssuedKind::Authority,
subject,
room_id,
&resp.credential,
&resp.credential_id,
&output,
)
.await
}
fn invitation_hint(e: impl std::fmt::Display) -> String {
format!(
"{e}\n\nThe invitation must be the one the room issued to your persona, unexpired and \
unused: ask the owner for a fresh one (`pnm rooms owner invite --subject <your persona \
DID>`). {PERSONA_HINT}"
)
}
pub async fn cmd_rooms_keys_key_package(
client: &VtaClient,
room_id: &str,
invitation_src: &str,
out: &Path,
) -> CmdResult {
if out.exists() {
return Err(format!("{} already exists; refusing to overwrite it", out.display()).into());
}
let invitation = read_text(invitation_src, "invitation")?;
let resp = client
.idempotent(|| client.room_key_package(room_id, &invitation))
.await
.map_err(invitation_hint)?;
write_new(out, format!("{}\n", resp.key_package).as_bytes())?;
let expiry = resp
.expires_at
.map(|t| {
format!(
", unused until {}",
crate::duration::format_local_datetime(t)
)
})
.unwrap_or_default();
println!(
"Minted a KeyPackage for {room_id}: wrote {}{expiry}.",
out.display()
);
println!(
" Next: send it to the room's owner for `pnm rooms owner group-add --key-package {}`, \
then join with the Welcome they send back: `pnm rooms keys welcome --room {room_id} \
--invitation {invitation_src} --welcome <file>`.",
out.display()
);
Ok(())
}
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct WelcomeFile {
room_id: Option<String>,
welcome: String,
ratchet_tree: Option<String>,
}
pub async fn cmd_rooms_keys_welcome(
client: &VtaClient,
room_id: &str,
invitation_src: &str,
welcome_src: &str,
) -> CmdResult {
let invitation = read_text(invitation_src, "invitation")?;
let file: WelcomeFile =
serde_json::from_str(&read_text(welcome_src, "Welcome")?).map_err(|e| {
format!("{welcome_src} is not a Welcome written by `pnm rooms owner group-add`: {e}")
})?;
if let Some(r) = &file.room_id
&& r != room_id
{
return Err(format!(
"{welcome_src} is a Welcome to room `{r}`, not `{room_id}`; pass --room {r} if that \
is the room you meant"
)
.into());
}
let resp = client
.idempotent(|| {
client.room_welcome(
room_id,
&invitation,
&file.welcome,
file.ratchet_tree.as_deref(),
)
})
.await
.map_err(invitation_hint)?;
println!(
"Joined {room_id} at room epoch {}. The invitation is now spent.",
resp.epoch + 1
);
println!(
" Next: store the membership and authority credentials the owner issues you \
(`pnm cred-vault receive --credential-file …`). Later membership changes reach this \
VTA as commits: `pnm rooms keys commit --room {room_id} --host <url> --host-did <did>`."
);
Ok(())
}
pub fn mls_epoch_of(room_epoch: u64) -> Result<NonZeroU64, Box<dyn std::error::Error>> {
room_epoch
.checked_sub(1)
.and_then(NonZeroU64::new)
.ok_or_else(|| {
format!(
"room epoch {room_epoch} is produced by no commit: a room's group starts at \
epoch 1, so the first commit produces epoch 2"
)
.into()
})
}
pub async fn cmd_rooms_keys_commit_file(
client: &VtaClient,
room_id: &str,
commit_src: &str,
room_epoch: u64,
) -> CmdResult {
let commit = read_text(commit_src, "commit")?;
let resp = client
.room_commit(room_id, &commit, mls_epoch_of(room_epoch)?)
.await
.map_err(commit_hint)?;
println!("{room_id}: this VTA is at room epoch {}.", resp.epoch + 1);
Ok(())
}
fn commit_hint(e: impl std::fmt::Display) -> String {
let text = e.to_string();
if text.contains("removed this holder") {
return format!(
"{text}\n\nYou are no longer a member of this room's group. Nothing written \
after your removal will open here; what you could read before still does. \
Rejoining takes a fresh invitation from the room's owner."
);
}
format!(
"{e}\n\nCommits apply in order. A refusal naming the epoch to resume from means one is \
missing: fetch them all from the host with `--host <url> --host-did <did>` instead of a \
file. `no group state` means this VTA never joined — `pnm rooms keys welcome` first."
)
}
pub async fn cmd_rooms_keys_commit_from_host(
client: &VtaClient,
target: RoomTarget<'_>,
) -> CmdResult {
let room_id = target.room_id;
let held = held_epoch(client, room_id).await?;
let mut at = held;
let mut applied = 0usize;
let room_epoch = loop {
let since = u32::try_from(at).map_err(|_| format!("room epoch {at} is out of range"))?;
let page: vti_rooms::wire::CommitsResponse = super::rooms::act_and_send_parsed(
client,
&target,
vti_rooms::wire::ROOMS_EPOCH_COMMITS_TYPE,
serde_json::json!({ "roomId": room_id, "sinceEpoch": since }),
)
.await?;
if page.commits.is_empty() {
break u64::from(page.room_epoch);
}
let before = at;
for c in &page.commits {
let resp = client
.room_commit(room_id, &c.commit, mls_epoch_of(u64::from(c.epoch))?)
.await
.map_err(commit_hint)?;
at = resp.epoch + 1;
applied += 1;
}
if at == before || at >= u64::from(page.room_epoch) {
break u64::from(page.room_epoch);
}
};
println!(
"{room_id}: applied {applied} commit(s); this VTA is at room epoch {at}, the host at {room_epoch}."
);
if at < room_epoch {
println!(
" The host is missing the commit that produces epoch {}: records sealed since will \
not open here until the room's owner re-delivers it.",
at + 1
);
}
Ok(())
}
async fn held_epoch(client: &VtaClient, room_id: &str) -> Result<u64, Box<dyn std::error::Error>> {
let list = client.room_keys_list().await?;
list.rooms
.iter()
.find(|r| r.room_id == room_id)
.map(|r| r.epoch.get())
.ok_or_else(|| {
format!(
"this VTA holds no group for room `{room_id}`: join it first with \
`pnm rooms keys welcome`, or found it with `pnm rooms owner group-create` if \
you own it"
)
.into()
})
}
pub async fn cmd_rooms_keys_backfill(
client: &VtaClient,
room_id: &str,
host_did: &str,
) -> CmdResult {
let mut from: Option<std::num::NonZeroU64> = None;
let mut fetched = 0u64;
let reach = loop {
let resp = client.room_backfill(room_id, host_did, from).await?;
let reach = resp.earliest_readable_epoch.get();
let served = resp.fetched.unwrap_or(0);
fetched += served;
if reach <= 1 || served == 0 || from.is_some_and(|f| f.get() == reach) {
break reach;
}
from = std::num::NonZeroU64::new(reach);
};
println!(
"{room_id}: fetched {fetched} link(s); this VTA now reads back to room epoch {reach}."
);
if reach > 1 {
println!(
" Records sealed before epoch {reach} will not open here: the host holds no links \
below it, or the room's history was deliberately severed there."
);
}
Ok(())
}
pub async fn cmd_rooms_keys_list(client: &VtaClient) -> CmdResult {
cmd_rooms_keys_list_named(client, None).await
}
pub async fn cmd_rooms_keys_list_named(client: &VtaClient, host_did: Option<&str>) -> CmdResult {
let list = match client.room_list(true).await {
Ok(list) => list,
Err(vta_sdk::error::VtaError::UnsupportedTaskType { .. }) => {
return cmd_rooms_keys_list_0_1(client).await;
}
Err(e) => return Err(e.into()),
};
if crate::render::is_json_output() {
crate::render::print_json(&list.rooms)?;
return Ok(());
}
if list.rooms.is_empty() {
println!("This VTA holds no room's group.");
return Ok(());
}
let held = held_ext(&list.ext);
let mut names: Vec<Option<String>> = Vec::with_capacity(list.rooms.len());
for r in &list.rooms {
names.push(match host_did {
Some(host) => client
.room_profile_read(r.room_id.as_str(), host, Some(r.member.as_str()))
.await
.ok()
.and_then(|p| p.profile.map(|p| inert_line(p.name.as_str()))),
None => None,
});
}
println!("{} room(s):", list.rooms.len());
for (i, r) in list.rooms.iter().enumerate() {
let personal = r.personal.as_ref();
match (personal.and_then(|p| p.label.as_ref()), &names[i]) {
(Some(name), _) => println!(" {} ({})", as_text(name), r.room_id.as_str()),
(None, Some(name)) => println!(" {name} ({})", r.room_id.as_str()),
(None, None) => println!(" {}", r.room_id.as_str()),
}
if personal.and_then(|p| p.label.as_ref()).is_some()
&& let Some(name) = &names[i]
{
println!(" the room calls itself: {name}");
}
let context = held
.get(i)
.and_then(|h| h["context"].as_str())
.map(|c| format!(" (context {c})"))
.unwrap_or_default();
println!(" as {}{context}", r.member.as_str());
println!(
" epoch {}, reads back to epoch {}",
r.epoch, r.earliest_readable_epoch
);
if let Some(p) = personal {
if let Some(d) = &p.description {
println!(" your description: {}", indent(&as_text(d)));
}
if let Some(tags) = &p.tags {
let tags: Vec<String> = tags.iter().map(|t| as_text(t)).collect();
println!(" your tags: {}", tags.join(", "));
}
if let Some(n) = &p.notes {
println!(" your notes: {}", indent(&as_text(n)));
}
}
}
if host_did.is_none() {
println!("\nAdd --host-did <host> to show the name each room gives itself.");
}
Ok(())
}
async fn cmd_rooms_keys_list_0_1(client: &VtaClient) -> CmdResult {
let list = client.room_keys_list().await?;
if crate::render::is_json_output() {
crate::render::print_json(&list.rooms)?;
return Ok(());
}
if list.rooms.is_empty() {
println!("This VTA holds no room's group.");
return Ok(());
}
let held = held_ext(&list.ext);
println!("{} room(s):", list.rooms.len());
for (i, r) in list.rooms.iter().enumerate() {
println!(
" {} epoch {} reads back to epoch {}",
r.room_id, r.epoch, r.earliest_readable_epoch
);
if let Some(member) = held.get(i).and_then(|h| h["member"].as_str()) {
let context = held[i]["context"]
.as_str()
.map(|c| format!(" (context {c})"))
.unwrap_or_default();
println!(" as {member}{context}");
}
}
Ok(())
}
fn held_ext<E: serde::Serialize>(ext: &E) -> Vec<serde_json::Value> {
serde_json::to_value(ext)
.ok()
.and_then(|e| e.get("org.openvtc.rooms")?.get("held").cloned())
.and_then(|h| h.as_array().cloned())
.unwrap_or_default()
}
fn as_text(s: &str) -> String {
s.chars()
.map(|c| {
if c.is_control() && c != '\n' && c != '\t' {
'\u{FFFD}'
} else {
c
}
})
.collect()
}
fn indent(s: &str) -> String {
s.replace('\n', "\n ")
}
pub async fn cmd_rooms_keys_label(
client: &VtaClient,
room_id: &str,
member: &str,
edit: &vta_sdk::client::RoomLabelEdit,
) -> CmdResult {
let resp = match client.room_label(room_id, member, edit).await {
Ok(r) => r,
Err(vta_sdk::error::VtaError::Rejected { code, .. })
if code == "rooms/keys/label:notHeld" =>
{
return Err(format!(
"this VTA holds no group for room `{room_id}` as `{member}`, or you do not act \
as that member. `pnm rooms keys list` shows each room with the member it is \
held as — pass that DID as --member."
)
.into());
}
Err(e) => return Err(e.into()),
};
if crate::render::is_json_output() {
crate::render::print_json(&resp)?;
return Ok(());
}
if !resp.changed {
println!("{room_id}: your label already says that; nothing changed.");
return Ok(());
}
match &resp.personal {
None => println!("{room_id}: your label is removed."),
Some(p) => {
println!("{room_id}: your label is updated (as {member}).");
if let Some(name) = &p.label {
println!(" name: {}", as_text(name));
}
if let Some(d) = &p.description {
println!(" description: {}", indent(&as_text(d)));
}
if let Some(tags) = &p.tags {
let tags: Vec<String> = tags.iter().map(|t| as_text(t)).collect();
println!(" tags: {}", tags.join(", "));
}
if let Some(n) = &p.notes {
println!(" notes: {}", indent(&as_text(n)));
}
}
}
Ok(())
}
fn inert(text: &str) -> String {
text.chars()
.filter(|c| !c.is_control() || *c == '\n' || *c == '\t')
.collect()
}
fn inert_line(text: &str) -> String {
inert(text).replace(['\n', '\t'], " ")
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Change<T> {
Keep,
Set(T),
Drop,
}
#[derive(Debug, Clone)]
pub struct ProfileEdit {
pub name: Option<String>,
pub description: Change<String>,
pub tags: Change<Vec<String>>,
pub emoji: Change<String>,
}
fn edited_profile(
current: Option<&serde_json::Value>,
edit: ProfileEdit,
) -> Result<serde_json::Value, Box<dyn std::error::Error>> {
let mut p = current.cloned().unwrap_or_else(|| serde_json::json!({}));
let Some(obj) = p.as_object_mut() else {
return Err("the room's current profile is not an object".into());
};
if let Some(name) = edit.name {
obj.insert("name".into(), serde_json::Value::String(name));
}
if !obj.contains_key("name") {
return Err(
"the room has no profile yet, so give it a name: --name \"<the room's name>\"".into(),
);
}
let mut apply = |key: &str, change: Change<serde_json::Value>| match change {
Change::Keep => {}
Change::Set(v) => {
obj.insert(key.into(), v);
}
Change::Drop => {
obj.remove(key);
}
};
let map = |c: Change<String>| match c {
Change::Keep => Change::Keep,
Change::Set(v) => Change::Set(serde_json::Value::String(v)),
Change::Drop => Change::Drop,
};
apply("description", map(edit.description));
apply("emoji", map(edit.emoji));
apply(
"tags",
match edit.tags {
Change::Keep => Change::Keep,
Change::Set(t) => Change::Set(serde_json::json!(t)),
Change::Drop => Change::Drop,
},
);
Ok(p)
}
fn print_profile(room_id: &str, resp: &serde_json::Value) {
match resp["status"].as_str().unwrap_or_default() {
"found" => {
let p = &resp["profile"];
let emoji = p["emoji"]
.as_str()
.map(|e| format!("{} ", inert_line(e)))
.unwrap_or_default();
println!(
"{emoji}{}",
inert_line(p["name"].as_str().unwrap_or_default())
);
if let Some(d) = p["description"].as_str() {
println!(" {}", inert(d).replace('\n', "\n "));
}
if let Some(tags) = p["tags"].as_array().filter(|t| !t.is_empty()) {
let tags: Vec<String> = tags
.iter()
.filter_map(|t| t.as_str().map(inert_line))
.collect();
println!(" tags: {}", tags.join(", "));
}
println!(
" named by {} at {}{}",
resp["author"].as_str().unwrap_or("?"),
resp["issuedAt"].as_str().unwrap_or("?"),
resp["epoch"]
.as_u64()
.map(|e| format!(", under epoch {e}"))
.unwrap_or_default()
);
if resp["stale"].as_bool() == Some(true) {
println!(
" This profile predates the room's latest epoch and was not written again \
since; it may be out of date."
);
}
}
"cleared" => println!("Room {room_id} has no profile: a curator cleared it."),
_ => println!(
"Room {room_id} has not given itself a name you can read{}.",
resp["searched"]["toEpoch"]
.as_u64()
.map(|e| format!(" (looked down to epoch {e})"))
.unwrap_or_default()
),
}
for r in resp["rejected"].as_array().into_iter().flatten() {
let reason = r["reason"].as_str().unwrap_or_default();
let who = r["author"].as_str().unwrap_or("somebody");
let at = r["epoch"]
.as_u64()
.map(|e| format!(" under epoch {e}"))
.unwrap_or_default();
match reason {
"noAuthority" | "timing" | "revoked" => println!(
" ! {who} wrote a newer profile{at} without the standing to name the room \
({reason}); it is ignored."
),
_ => println!(" ! a newer profile record{at} was ignored ({reason})."),
}
}
}
pub async fn cmd_rooms_profile_show(
client: &VtaClient,
room_id: &str,
host_did: &str,
member: Option<&str>,
) -> CmdResult {
let resp = client.room_profile_read(room_id, host_did, member).await?;
let resp = serde_json::to_value(&resp)?;
if crate::render::is_json_output() {
crate::render::print_json(&resp)?;
return Ok(());
}
print_profile(room_id, &resp);
Ok(())
}
pub async fn cmd_rooms_profile_set(
client: &VtaClient,
room_id: &str,
host_did: &str,
edit: ProfileEdit,
member: Option<&str>,
) -> CmdResult {
let current = client.room_profile_read(room_id, host_did, member).await?;
let current = current.profile.map(serde_json::to_value).transpose()?;
let profile = edited_profile(current.as_ref(), edit)?;
let profile =
serde_json::from_value(profile).map_err(|e| format!("that is not a room profile: {e}"))?;
let out = client
.room_profile_write(room_id, host_did, &profile, member)
.await
.map_err(|e| profile_refusal(e, room_id))?;
let out = serde_json::to_value(&out)?;
if crate::render::is_json_output() {
crate::render::print_json(&out)?;
return Ok(());
}
println!(
"Named room {room_id}{}. Every member reads it with `pnm rooms profile show`.",
out["epoch"]
.as_u64()
.map(|e| format!(" under epoch {e}"))
.unwrap_or_default()
);
Ok(())
}
pub async fn cmd_rooms_profile_clear(
client: &VtaClient,
room_id: &str,
host_did: &str,
member: Option<&str>,
) -> CmdResult {
let out = client
.room_profile_clear(room_id, host_did, member)
.await
.map_err(|e| profile_refusal(e, room_id))?;
let out = serde_json::to_value(&out)?;
if crate::render::is_json_output() {
crate::render::print_json(&out)?;
return Ok(());
}
println!("Cleared room {room_id}'s profile.");
Ok(())
}
fn profile_refusal(e: vta_sdk::error::VtaError, room_id: &str) -> Box<dyn std::error::Error> {
let text = e.to_string();
if text.contains("noAuthority") {
return format!(
"{text}\n\nNaming a room takes `write` and `curate` or `admin` in it; clearing takes \
`curate`. The room's owner grants them with `pnm rooms owner issue-authority --room \
{room_id} --subject <your persona DID> --action write,curate --valid-for 90d --store`."
)
.into();
}
if text.contains("contended") {
return format!(
"{text}\n\nOther writes kept landing in the room; run the same command again."
)
.into();
}
e.into()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_profile_edit_carries_over_what_it_does_not_touch() {
let current = serde_json::json!({
"name": "Board", "description": "Papers", "tags": ["board"], "emoji": "📋",
});
let renamed = edited_profile(
Some(¤t),
ProfileEdit {
name: Some("Board pack".into()),
description: Change::Keep,
tags: Change::Keep,
emoji: Change::Drop,
},
)
.unwrap();
assert_eq!(
renamed,
serde_json::json!({ "name": "Board pack", "description": "Papers", "tags": ["board"] })
);
let retagged = edited_profile(
Some(¤t),
ProfileEdit {
name: None,
description: Change::Drop,
tags: Change::Set(vec!["a".into(), "b".into()]),
emoji: Change::Keep,
},
)
.unwrap();
assert_eq!(
retagged,
serde_json::json!({ "name": "Board", "tags": ["a", "b"], "emoji": "📋" })
);
assert!(
edited_profile(
None,
ProfileEdit {
name: None,
description: Change::Set("x".into()),
tags: Change::Keep,
emoji: Change::Keep,
},
)
.is_err(),
"a room with no profile needs a name"
);
}
#[test]
fn profile_text_is_inert() {
assert_eq!(
inert_line("Board\u{1b}[31m pack\nnext"),
"Board[31m pack next"
);
}
#[test]
fn validity_takes_either_form_and_refuses_the_past() {
assert!(validity(None, None).unwrap().is_none());
let soon = validity(None, Some("1h")).unwrap().unwrap();
assert!(soon > Utc::now());
assert!(
validity(Some("2999-01-01T00:00:00Z"), None)
.unwrap()
.is_some()
);
assert!(
validity(Some("2000-01-01T00:00:00Z"), None).is_err(),
"past"
);
assert!(validity(Some("tomorrow"), None).is_err(), "not RFC 3339");
assert!(validity(Some("2999-01-01T00:00:00Z"), Some("1h")).is_err());
}
#[test]
fn a_label_is_shown_as_text_never_as_escapes() {
assert_eq!(as_text("Book\u{1b}[31m club"), "Book\u{FFFD}[31m club");
assert_eq!(as_text("a\u{9b}b\rc"), "a\u{FFFD}b\u{FFFD}c");
assert_eq!(as_text("line\n\tnext — ok"), "line\n\tnext — ok");
}
#[test]
fn actions_are_the_four_and_never_none() {
assert!(parse_actions(&[]).is_err());
assert_eq!(
parse_actions(&["read".into(), "admin".into()])
.unwrap()
.len(),
2
);
assert!(parse_actions(&["everything".into()]).is_err());
}
#[test]
fn room_epochs_map_onto_mls_epochs() {
assert_eq!(mls_epoch_of(2).unwrap().get(), 1);
assert_eq!(mls_epoch_of(7).unwrap().get(), 6);
assert!(mls_epoch_of(1).is_err());
assert!(mls_epoch_of(0).is_err());
}
#[test]
fn an_issuance_with_nowhere_to_go_is_refused() {
assert!(
require_destination(&IssuedOutput {
out: None,
store: false
})
.is_err()
);
assert!(
require_destination(&IssuedOutput {
out: None,
store: true
})
.is_ok()
);
}
#[test]
fn a_welcome_file_from_group_add_parses() {
let f: WelcomeFile = serde_json::from_value(serde_json::json!({
"roomId": "did:webvh:room", "member": "did:webvh:bob", "epoch": 2,
"welcome": "AAEC", "ratchetTree": null,
}))
.unwrap();
assert_eq!(f.welcome, "AAEC");
assert!(f.ratchet_tree.is_none());
}
}