#[cfg(feature = "mls")]
pub use vti_rooms::{mls, sealed};
pub mod body;
pub mod files;
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, CommitsResponse, CurateRecordResponse,
EpochLink, ListRecordsResponse, MintEpochResponse, OwnerResponse, PutRecordResponse,
ROOMS_CREATE_TYPE, ROOMS_EPOCH_CHAIN_TYPE, ROOMS_EPOCH_COMMITS_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, RelayedCommit,
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}"),
})
}
pub async fn epoch_commits(
&self,
session: &RoomSession,
since_epoch: u32,
limit: Option<u32>,
signer_did: &str,
private_key_multibase: &str,
) -> Result<CommitsResponse, VtcError> {
let mut payload = serde_json::json!({
"roomId": session.room_id,
"sinceEpoch": since_epoch,
"presentation": session.presentation,
});
if let Some(l) = limit {
payload["limit"] = serde_json::json!(l);
}
let value = self
.room_task(
ROOMS_EPOCH_COMMITS_TYPE,
payload,
signer_did,
private_key_multibase,
)
.await?;
serde_json::from_value(value).map_err(|e| VtcError::Http {
status: 200,
body: format!("commits response is not a CommitsResponse: {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 retries = if is_room_read(type_uri) {
READ_RATE_LIMIT_ATTEMPTS - 1
} else {
0
};
let mut attempt = 0;
let resp = loop {
let doc = vta_sdk::trust_task_sign::build_signed(
type_uri,
payload.clone(),
signer_did,
private_key_multibase,
&self.vtc_did,
)
.await
.map_err(|e| VtcError::Signing(e.to_string()))?;
let resp = self.post_room_document(doc).await?;
if resp.status() == reqwest::StatusCode::TOO_MANY_REQUESTS
&& attempt < retries
&& let Some(wait) = rate_limit_wait(resp.headers())
{
attempt += 1;
tokio::time::sleep(wait).await;
continue;
}
break resp;
};
Self::room_response_payload(resp).await
}
pub async fn send_signed_room_document(
&self,
document: &serde_json::Value,
) -> Result<serde_json::Value, VtcError> {
let type_uri = document
.get("type")
.and_then(serde_json::Value::as_str)
.unwrap_or_default();
let retries = if is_room_read(type_uri) {
READ_RATE_LIMIT_ATTEMPTS - 1
} else {
0
};
let body = serde_json::to_string(document).map_err(|e| VtcError::Url(e.to_string()))?;
let mut attempt = 0;
let resp = loop {
let resp = self.post_room_document(body.clone()).await?;
if resp.status() == reqwest::StatusCode::TOO_MANY_REQUESTS
&& attempt < retries
&& let Some(wait) = rate_limit_wait(resp.headers())
{
attempt += 1;
tokio::time::sleep(wait).await;
continue;
}
break resp;
};
Self::room_response_payload(resp).await
}
async fn post_room_document(&self, body: String) -> Result<reqwest::Response, VtcError> {
Ok(self
.http
.post(format!("{}/trust-tasks", self.base_url))
.header("content-type", "application/json")
.body(body)
.send()
.await?)
}
async fn room_response_payload(resp: reqwest::Response) -> Result<serde_json::Value, VtcError> {
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)
}
}
const READ_RATE_LIMIT_ATTEMPTS: usize = 5;
const MAX_READ_RETRY_AFTER: std::time::Duration = std::time::Duration::from_secs(30);
fn is_room_read(type_uri: &str) -> bool {
use trust_tasks_rs::Payload as _;
use trust_tasks_rs::specs::rooms;
[
ROOMS_RECORDS_GET_TYPE,
ROOMS_RECORDS_LIST_TYPE,
ROOMS_EPOCH_CHAIN_TYPE,
ROOMS_EPOCH_COMMITS_TYPE,
rooms::records::get::v0_2::Payload::TYPE_URI,
rooms::records::list::v0_2::Payload::TYPE_URI,
rooms::blobs::get::v0_1::Payload::TYPE_URI,
rooms::blobs::get::v0_2::Payload::TYPE_URI,
rooms::blobs::chunk::v0_1::Payload::TYPE_URI,
rooms::info::v0_1::Payload::TYPE_URI,
]
.contains(&type_uri)
}
fn rate_limit_wait(headers: &reqwest::header::HeaderMap) -> Option<std::time::Duration> {
let now = chrono::Utc::now();
let wait = headers
.get(vta_sdk::rate_limit::RETRY_AFTER_HEADER)
.and_then(|v| v.to_str().ok())
.and_then(|v| vta_sdk::rate_limit::parse_retry_after(v, now))
.map_or(std::time::Duration::from_secs(1), |at| {
(at - now).to_std().unwrap_or_default()
});
(wait <= MAX_READ_RETRY_AFTER).then_some(wait)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn only_room_reads_are_retried() {
use trust_tasks_rs::Payload as _;
use trust_tasks_rs::specs::rooms;
assert!(is_room_read(ROOMS_RECORDS_GET_TYPE));
assert!(is_room_read(rooms::blobs::chunk::v0_1::Payload::TYPE_URI));
for write in [
ROOMS_CREATE_TYPE,
ROOMS_RECORDS_PUT_TYPE,
ROOMS_EPOCH_MINT_TYPE,
ROOMS_RECORDS_CURATE_TYPE,
rooms::blobs::upload::chunk::v0_1::Payload::TYPE_URI,
rooms::blobs::upload::commit::v0_1::Payload::TYPE_URI,
] {
assert!(!is_room_read(write), "{write} is a write");
}
}
#[test]
fn a_short_retry_after_is_waited_and_a_long_one_is_not() {
let mut h = reqwest::header::HeaderMap::new();
h.insert("retry-after", "3".parse().unwrap());
let wait = rate_limit_wait(&h).unwrap();
assert!((2..=3).contains(&wait.as_secs()), "{wait:?}");
h.insert("retry-after", "3600".parse().unwrap());
assert_eq!(rate_limit_wait(&h), None);
assert_eq!(
rate_limit_wait(&reqwest::header::HeaderMap::new()),
Some(std::time::Duration::from_secs(1))
);
}
#[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")
);
}
}