#[cfg(feature = "mls")]
pub use vti_rooms::{mls, sealed};
use crate::{VtcClient, VtcError};
pub use vti_rooms::Visibility;
pub use vti_rooms::authz::MAX_CHAIN_DEPTH;
pub use vti_rooms::wire::{
AuthorityPresentation, ChainResponse, CleartextContent, CurateRecordResponse, EpochLink,
ListRecordsResponse, MintEpochResponse, OwnerResponse, PutRecordResponse, ROOMS_CREATE_TYPE,
ROOMS_EPOCH_CHAIN_TYPE, ROOMS_EPOCH_MINT_TYPE, ROOMS_OWNER_CLAIM_TYPE,
ROOMS_OWNER_TRANSFER_TYPE, ROOMS_RECORDS_CURATE_TYPE, ROOMS_RECORDS_GET_TYPE,
ROOMS_RECORDS_LIST_TYPE, ROOMS_RECORDS_PUT_TYPE, SealedContent,
};
#[derive(Debug, Clone)]
pub struct RoomSession {
room_id: String,
presentation: AuthorityPresentation,
}
impl RoomSession {
pub fn new(
room_id: impl Into<String>,
membership: impl Into<String>,
authority: Vec<String>,
) -> Result<Self, VtcError> {
if authority.is_empty() {
return Err(VtcError::Url(
"an authority chain is required: a room operation is authorized by the chain, \
never by a session"
.into(),
));
}
if authority.len() > MAX_CHAIN_DEPTH {
return Err(VtcError::Url(format!(
"authority chain is {} deep, exceeding the maximum of {MAX_CHAIN_DEPTH}",
authority.len()
)));
}
Ok(Self {
room_id: room_id.into(),
presentation: AuthorityPresentation {
membership: membership.into(),
authority,
subject_binding: None,
},
})
}
pub fn with_subject_binding(mut self, binding: impl Into<String>) -> Self {
self.presentation.subject_binding = Some(binding.into());
self
}
pub fn room_id(&self) -> &str {
&self.room_id
}
pub fn chain_depth(&self) -> usize {
self.presentation.authority.len()
}
}
impl VtcClient {
pub async fn create_room(
&self,
room_id: &str,
owner_did: &str,
visibility: Visibility,
retention_days: Option<u32>,
signer_did: &str,
private_key_multibase: &str,
) -> Result<serde_json::Value, VtcError> {
let payload = serde_json::json!({
"roomId": room_id,
"ownerDid": owner_did,
"visibility": visibility,
"retentionDays": retention_days,
});
self.room_task(
ROOMS_CREATE_TYPE,
payload,
signer_did,
private_key_multibase,
)
.await
}
#[allow(clippy::too_many_arguments)]
pub async fn put_record(
&self,
session: &RoomSession,
key: &str,
sealed: Option<SealedContent>,
cleartext: Option<CleartextContent>,
expected_version: Option<u64>,
signer_did: &str,
private_key_multibase: &str,
) -> Result<PutRecordResponse, VtcError> {
let mut payload = serde_json::json!({
"roomId": session.room_id,
"key": key,
"presentation": session.presentation,
});
if let Some(s) = sealed {
payload["sealed"] =
serde_json::to_value(s).map_err(|e| VtcError::Url(e.to_string()))?;
}
if let Some(c) = cleartext {
payload["cleartext"] =
serde_json::to_value(c).map_err(|e| VtcError::Url(e.to_string()))?;
}
if let Some(v) = expected_version {
payload["expectedVersion"] = serde_json::json!(v);
}
let value = self
.room_task(
ROOMS_RECORDS_PUT_TYPE,
payload,
signer_did,
private_key_multibase,
)
.await?;
serde_json::from_value(value).map_err(|e| VtcError::Http {
status: 200,
body: format!("put response is not a PutRecordResponse: {e}"),
})
}
pub async fn get_record(
&self,
session: &RoomSession,
key: &str,
signer_did: &str,
private_key_multibase: &str,
) -> Result<serde_json::Value, VtcError> {
let payload = serde_json::json!({
"roomId": session.room_id,
"key": key,
"presentation": session.presentation,
});
self.room_task(
ROOMS_RECORDS_GET_TYPE,
payload,
signer_did,
private_key_multibase,
)
.await
}
pub async fn list_records(
&self,
session: &RoomSession,
prefix: Option<&str>,
since_version: Option<u64>,
cursor: Option<&str>,
signer_did: &str,
private_key_multibase: &str,
) -> Result<ListRecordsResponse, VtcError> {
let mut payload = serde_json::json!({
"roomId": session.room_id,
"presentation": session.presentation,
});
if let Some(p) = prefix {
payload["prefix"] = serde_json::json!(p);
}
if let Some(v) = since_version {
payload["sinceVersion"] = serde_json::json!(v);
}
if let Some(c) = cursor {
payload["cursor"] = serde_json::json!(c);
}
let value = self
.room_task(
ROOMS_RECORDS_LIST_TYPE,
payload,
signer_did,
private_key_multibase,
)
.await?;
serde_json::from_value(value).map_err(|e| VtcError::Http {
status: 200,
body: format!("list response is not a ListRecordsResponse: {e}"),
})
}
pub async fn mint_epoch(
&self,
session: &RoomSession,
epoch: u32,
reason: Option<&str>,
signer_did: &str,
private_key_multibase: &str,
) -> Result<MintEpochResponse, VtcError> {
self.mint_epoch_with_link(
session,
epoch,
None,
reason,
signer_did,
private_key_multibase,
)
.await
}
pub async fn mint_epoch_with_link(
&self,
session: &RoomSession,
epoch: u32,
link: Option<&EpochLink>,
reason: Option<&str>,
signer_did: &str,
private_key_multibase: &str,
) -> Result<MintEpochResponse, VtcError> {
let mut payload = serde_json::json!({
"roomId": session.room_id,
"epoch": epoch,
"presentation": session.presentation,
});
if let Some(r) = reason {
payload["reason"] = serde_json::json!(r);
}
if let Some(l) = link {
payload["link"] = serde_json::to_value(l).map_err(|e| VtcError::Http {
status: 0,
body: format!("serialise the epoch link: {e}"),
})?;
}
let value = self
.room_task(
ROOMS_EPOCH_MINT_TYPE,
payload,
signer_did,
private_key_multibase,
)
.await?;
serde_json::from_value(value).map_err(|e| VtcError::Http {
status: 200,
body: format!("mint response is not a MintEpochResponse: {e}"),
})
}
pub async fn epoch_chain(
&self,
session: &RoomSession,
from_epoch: Option<u32>,
limit: Option<u32>,
signer_did: &str,
private_key_multibase: &str,
) -> Result<ChainResponse, VtcError> {
let mut payload = serde_json::json!({
"roomId": session.room_id,
"presentation": session.presentation,
});
if let Some(f) = from_epoch {
payload["fromEpoch"] = serde_json::json!(f);
}
if let Some(l) = limit {
payload["limit"] = serde_json::json!(l);
}
let value = self
.room_task(
ROOMS_EPOCH_CHAIN_TYPE,
payload,
signer_did,
private_key_multibase,
)
.await?;
serde_json::from_value(value).map_err(|e| VtcError::Http {
status: 200,
body: format!("chain response is not a ChainResponse: {e}"),
})
}
#[allow(clippy::too_many_arguments)]
pub async fn curate_record(
&self,
session: &RoomSession,
key: &str,
status: Option<String>,
pinned: Option<bool>,
reason: Option<String>,
signer_did: &str,
private_key_multibase: &str,
) -> Result<CurateRecordResponse, VtcError> {
let mut payload = serde_json::json!({
"roomId": session.room_id,
"key": key,
"presentation": session.presentation,
});
if let Some(s) = status {
payload["status"] = serde_json::json!(s);
}
if let Some(p) = pinned {
payload["pinned"] = serde_json::json!(p);
}
if let Some(r) = reason {
payload["reason"] = serde_json::json!(r);
}
let value = self
.room_task(
ROOMS_RECORDS_CURATE_TYPE,
payload,
signer_did,
private_key_multibase,
)
.await?;
serde_json::from_value(value).map_err(|e| VtcError::Http {
status: 200,
body: format!("curate response is not a CurateRecordResponse: {e}"),
})
}
pub async fn transfer_owner(
&self,
session: &RoomSession,
new_owner_did: &str,
reason: Option<&str>,
signer_did: &str,
private_key_multibase: &str,
) -> Result<OwnerResponse, VtcError> {
let mut payload = serde_json::json!({
"roomId": session.room_id,
"newOwnerDid": new_owner_did,
"presentation": session.presentation,
});
if let Some(r) = reason {
payload["reason"] = serde_json::json!(r);
}
self.owner_task(
ROOMS_OWNER_TRANSFER_TYPE,
payload,
signer_did,
private_key_multibase,
)
.await
}
pub async fn claim_owner(
&self,
session: &RoomSession,
nomination: &str,
reason: Option<&str>,
signer_did: &str,
private_key_multibase: &str,
) -> Result<OwnerResponse, VtcError> {
let mut payload = serde_json::json!({
"roomId": session.room_id,
"nomination": nomination,
"presentation": session.presentation,
});
if let Some(r) = reason {
payload["reason"] = serde_json::json!(r);
}
self.owner_task(
ROOMS_OWNER_CLAIM_TYPE,
payload,
signer_did,
private_key_multibase,
)
.await
}
async fn owner_task(
&self,
type_uri: &str,
payload: serde_json::Value,
signer_did: &str,
private_key_multibase: &str,
) -> Result<OwnerResponse, VtcError> {
let value = self
.room_task(type_uri, payload, signer_did, private_key_multibase)
.await?;
serde_json::from_value(value).map_err(|e| VtcError::Http {
status: 200,
body: format!("{type_uri} response is not an OwnerResponse: {e}"),
})
}
async fn room_task(
&self,
type_uri: &str,
payload: serde_json::Value,
signer_did: &str,
private_key_multibase: &str,
) -> Result<serde_json::Value, VtcError> {
let doc = vta_sdk::trust_task_sign::build_signed(
type_uri,
payload,
signer_did,
private_key_multibase,
&self.vtc_did,
)
.await
.map_err(|e| VtcError::Signing(e.to_string()))?;
let resp = self
.http
.post(format!("{}/trust-tasks", self.base_url))
.header("content-type", "application/json")
.body(doc)
.send()
.await?;
if !resp.status().is_success() {
let status = resp.status().as_u16();
let body = resp.text().await.unwrap_or_default();
return Err(VtcError::Http { status, body });
}
let text = resp.text().await?;
let response_doc: trust_tasks_rs::TrustTask<serde_json::Value> =
serde_json::from_str(&text).map_err(|e| VtcError::Http {
status: 200,
body: format!("unexpected room response (not a Trust Task document): {e}: {text}"),
})?;
Ok(response_doc.payload)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_session_requires_a_chain() {
let err = RoomSession::new("did:key:zRoom", "vmc", vec![]).unwrap_err();
assert!(
format!("{err}").contains("authorized by the chain"),
"a room session without a chain has nothing to present: {err}"
);
}
#[test]
fn a_session_refuses_a_chain_past_the_ceiling() {
let chain: Vec<String> = (0..=MAX_CHAIN_DEPTH).map(|i| format!("vac-{i}")).collect();
let err = RoomSession::new("did:key:zRoom", "vmc", chain).unwrap_err();
assert!(format!("{err}").contains("exceeding the maximum"), "{err}");
}
#[test]
fn a_members_session_and_their_agents_differ_only_in_the_chain() {
let member = RoomSession::new("did:key:zRoom", "vmc", vec!["vac-member".into()])
.expect("member session");
let agent = RoomSession::new(
"did:key:zRoom",
"vmc",
vec!["vac-agent".into(), "vac-member".into()],
)
.expect("agent session");
assert_eq!(member.room_id(), agent.room_id());
assert_eq!(member.chain_depth(), 1, "a grant straight from the room");
assert_eq!(agent.chain_depth(), 2, "one attenuation deeper");
}
#[test]
fn a_subject_binding_is_attached_only_when_asked_for() {
let s = RoomSession::new("did:key:zRoom", "vmc", vec!["vac".into()]).unwrap();
assert!(s.presentation.subject_binding.is_none());
let s = s.with_subject_binding("proof");
assert_eq!(s.presentation.subject_binding.as_deref(), Some("proof"));
}
#[test]
fn a_presentation_serialises_camel_case_and_omits_an_absent_binding() {
let s = RoomSession::new("did:key:zRoom", "vmc", vec!["a".into(), "b".into()]).unwrap();
let v = serde_json::to_value(&s.presentation).unwrap();
assert_eq!(v["membership"], "vmc");
assert_eq!(v["authority"][0], "a", "leaf first");
assert!(v.get("subjectBinding").is_none());
let s = s.with_subject_binding("bind");
let v = serde_json::to_value(&s.presentation).unwrap();
assert_eq!(v["subjectBinding"], "bind");
}
#[test]
fn visibility_serialises_lowercase_as_the_host_expects() {
assert_eq!(
serde_json::to_value(Visibility::Attributed).unwrap(),
serde_json::json!("attributed")
);
}
}