use std::collections::HashMap;
use serde_json::{Map, Value, json};
use super::VtaClient;
use crate::error::VtaError;
use crate::trust_tasks;
use vti_specs::rooms::keys::{act::v0_11 as act, browse::v0_4 as browse, read::v0_2 as read};
const ROOM_READ_TIMEOUT: u64 = 30;
const ROOM_BATCH_READ_TIMEOUT: u64 = 60;
const ROOM_SEAL_TIMEOUT: u64 = 30;
pub const ROOM_READ_MAX_KEYS: usize = 32;
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct RoomBrowse {
pub prefix: Option<String>,
pub since_version: Option<u64>,
pub limit: Option<u64>,
}
#[derive(Debug, Default)]
pub(crate) struct RecordMembersCache {
entries:
std::sync::Mutex<HashMap<(String, String, Option<String>), (u64, Option<Vec<String>>)>>,
}
impl RecordMembersCache {
fn get(
&self,
key: &(String, String, Option<String>),
epoch: u64,
) -> Option<Option<Vec<String>>> {
let entries = self.entries.lock().unwrap_or_else(|p| p.into_inner());
entries
.get(key)
.filter(|(e, _)| *e == epoch)
.map(|(_, m)| m.clone())
}
fn put(&self, key: (String, String, Option<String>), epoch: u64, members: Option<Vec<String>>) {
let mut entries = self.entries.lock().unwrap_or_else(|p| p.into_inner());
entries.insert(key, (epoch, members));
}
}
pub const RECORD_BODY_MEMBERS: [&str; 4] = ["kind", "inReplyTo", "writtenAt", "editedAt"];
pub const RECORD_FILE_MEMBERS: [&str; 3] = ["width", "height", "preview"];
pub const MEMBER_PROFILE_KIND: &str = "memberProfile";
pub const RECORD_KIND_VALUES: [&str; 1] = [MEMBER_PROFILE_KIND];
pub fn record_members_used(body: &Map<String, Value>) -> Vec<&'static str> {
let file = body.get("file").and_then(Value::as_object);
let kind = body.get("kind").and_then(Value::as_str);
RECORD_BODY_MEMBERS
.iter()
.filter(|m| body.contains_key(**m))
.chain(
RECORD_FILE_MEMBERS
.iter()
.filter(|m| file.is_some_and(|f| f.contains_key(**m))),
)
.chain(RECORD_KIND_VALUES.iter().filter(|v| kind == Some(**v)))
.copied()
.collect()
}
pub fn record_members_permitted(
body: &Map<String, Value>,
record_members: Option<&[String]>,
) -> Vec<&'static str> {
let listed = record_members.unwrap_or_default();
record_members_used(body)
.into_iter()
.filter(|m| !listed.iter().any(|l| l == m))
.collect()
}
pub fn check_record_members(
room_id: &str,
body: &Value,
record_members: Option<&[String]>,
unresolved: Option<String>,
) -> Result<(), VtaError> {
let Some(obj) = body.as_object() else {
return Ok(());
};
let refused: Vec<String> = record_members_permitted(obj, record_members)
.into_iter()
.map(str::to_string)
.collect();
if refused.is_empty() {
return Ok(());
}
let listed = record_members.map(<[String]>::to_vec).unwrap_or_default();
Err(VtaError::RecordMembersNotPermitted {
room_id: room_id.to_string(),
refused,
listed,
unresolved,
})
}
impl VtaClient {
pub async fn room_browse(
&self,
room_id: &str,
host_did: &str,
options: &RoomBrowse,
member: Option<&str>,
) -> Result<browse::Response, VtaError> {
let mut payload = json!({ "roomId": room_id, "host": host_did });
if let Some(p) = &options.prefix {
payload["prefix"] = json!(p);
}
if let Some(v) = options.since_version {
payload["sinceVersion"] = json!(v);
}
if let Some(l) = options.limit {
payload["limit"] = json!(l);
}
name_member(&mut payload, member);
let value = match self
.dispatch_trust_task(
trust_tasks::TASK_ROOMS_KEYS_BROWSE_0_4,
payload.clone(),
ROOM_READ_TIMEOUT,
)
.await
{
Err(VtaError::UnsupportedTaskType { .. }) => {
self.dispatch_trust_task(
trust_tasks::TASK_ROOMS_KEYS_BROWSE_0_3,
payload,
ROOM_READ_TIMEOUT,
)
.await?
}
other => other?,
};
parse(value, "rooms/keys/browse")
}
pub async fn room_read(
&self,
room_id: &str,
host_did: &str,
key: &str,
member: Option<&str>,
) -> Result<read::Response, VtaError> {
let mut payload = json!({ "roomId": room_id, "host": host_did, "key": key });
name_member(&mut payload, member);
let value = match self
.dispatch_trust_task(
trust_tasks::TASK_ROOMS_KEYS_READ_0_2,
payload.clone(),
ROOM_READ_TIMEOUT,
)
.await
{
Err(VtaError::UnsupportedTaskType { .. }) => {
self.dispatch_trust_task(
trust_tasks::TASK_ROOMS_KEYS_READ_0_1,
payload,
ROOM_READ_TIMEOUT,
)
.await?
}
other => other?,
};
parse(value, "rooms/keys/read")
}
pub async fn room_read_batch(
&self,
room_id: &str,
host_did: &str,
keys: &[&str],
member: Option<&str>,
) -> Result<read::Response, VtaError> {
if keys.is_empty() || keys.len() > ROOM_READ_MAX_KEYS {
return Err(VtaError::Validation(format!(
"a batch read names 1 to {ROOM_READ_MAX_KEYS} keys, not {}",
keys.len()
)));
}
let mut payload = json!({ "roomId": room_id, "host": host_did, "keys": keys });
name_member(&mut payload, member);
match self
.dispatch_trust_task(
trust_tasks::TASK_ROOMS_KEYS_READ_0_2,
payload,
ROOM_BATCH_READ_TIMEOUT,
)
.await
{
Err(VtaError::UnsupportedTaskType { .. }) => {}
other => return parse(other?, "rooms/keys/read"),
}
let mut results = Vec::with_capacity(keys.len());
for key in keys {
let mut payload = json!({ "roomId": room_id, "host": host_did, "key": key });
name_member(&mut payload, member);
let entry = match self
.dispatch_trust_task(
trust_tasks::TASK_ROOMS_KEYS_READ_0_1,
payload,
ROOM_READ_TIMEOUT,
)
.await
{
Ok(Value::Object(mut fields)) => {
fields.remove("roomId");
fields.remove("ext");
Value::Object(fields)
}
Ok(other) => {
return Err(VtaError::Protocol(format!(
"rooms/keys/read/0.1 answered something other than a record: {other}"
)));
}
Err(VtaError::Rejected {
code,
message,
details,
}) => {
let retryable = vti_specs::schema_index::error_codes_for(
trust_tasks::TASK_ROOMS_KEYS_READ_0_1,
)
.and_then(|c| c.iter().find(|c| c.code == code))
.is_some_and(|c| c.retryable);
let mut error = json!({
"code": code,
"message": message.chars().take(1024).collect::<String>(),
"retryable": retryable,
});
if let Some(d) = details.filter(Value::is_object) {
error["details"] = d;
}
json!({ "key": key, "error": error })
}
Err(e) => return Err(e),
};
results.push(entry);
}
parse(
json!({ "roomId": room_id, "results": results }),
"rooms/keys/read",
)
}
pub async fn room_record_members(
&self,
room_id: &str,
host_did: &str,
member: Option<&str>,
) -> Result<Option<Vec<String>>, VtaError> {
let epoch = self.room_epoch(room_id, member).await;
let key = (
room_id.to_string(),
host_did.to_string(),
member.map(str::to_string),
);
if let Some(e) = epoch
&& let Some(cached) = self.record_members.get(&key, e)
{
return Ok(cached);
}
let read = self.room_profile_read(room_id, host_did, member).await?;
let value = serde_json::to_value(&read)?;
let members = (value["status"] == "found")
.then(|| value["profile"]["recordMembers"].clone())
.and_then(|m| serde_json::from_value::<Vec<String>>(m).ok());
if let Some(e) = epoch {
self.record_members.put(key, e, members.clone());
}
Ok(members)
}
async fn room_epoch(&self, room_id: &str, member: Option<&str>) -> Option<u64> {
let list = self.room_list(false).await.ok()?;
let value = serde_json::to_value(&list).ok()?;
let held: Vec<&Value> = value["rooms"]
.as_array()?
.iter()
.filter(|r| r["roomId"] == room_id)
.filter(|r| member.is_none_or(|m| r["member"] == m))
.collect();
match held.as_slice() {
[one] => one["epoch"].as_u64(),
_ => None,
}
}
pub async fn room_check_record_members(
&self,
room_id: &str,
host_did: &str,
member: Option<&str>,
body: &Value,
) -> Result<(), VtaError> {
let uses_any = body
.as_object()
.is_some_and(|o| !record_members_used(o).is_empty());
if !uses_any {
return Ok(());
}
match self.room_record_members(room_id, host_did, member).await {
Ok(listed) => check_record_members(room_id, body, listed.as_deref(), None),
Err(e) => check_record_members(room_id, body, None, Some(e.to_string())),
}
}
pub async fn room_seal_as_principal_v0_3(
&self,
room_id: &str,
key: &str,
version: u64,
body: &[u8],
) -> Result<vti_specs::rooms::keys::seal::v0_3::Response, VtaError> {
let payload = json!({
"roomId": room_id,
"key": key,
"version": version,
"plaintext": base64::Engine::encode(
&base64::engine::general_purpose::URL_SAFE_NO_PAD,
body,
),
"signAsPrincipal": true,
});
match self
.dispatch_trust_task(
trust_tasks::TASK_ROOMS_KEYS_SEAL_0_3,
payload,
ROOM_SEAL_TIMEOUT,
)
.await
{
Err(e @ VtaError::UnsupportedTaskType { .. }) => {
let uses_any = serde_json::from_slice::<Value>(body)
.ok()
.and_then(|b| b.as_object().cloned())
.is_some_and(|o| !record_members_used(&o).is_empty());
if uses_any {
return Err(e);
}
let sealed = self
.room_seal_as_principal(room_id, key, version, body)
.await?;
parse(serde_json::to_value(sealed)?, "rooms/keys/seal")
}
other => parse(other?, "rooms/keys/seal"),
}
}
pub async fn room_act_v0_11(
&self,
document: &act::UnsignedRoomRequest,
) -> Result<act::Response, VtaError> {
let value = self
.dispatch_trust_task(
trust_tasks::TASK_ROOMS_KEYS_ACT_0_11,
json!({ "document": document }),
ROOM_SEAL_TIMEOUT,
)
.await?;
parse(value, "rooms/keys/act")
}
}
pub fn unsigned_room_request_v0_11(
type_uri: &str,
host: &str,
issuer: Option<&str>,
payload: Value,
) -> Result<act::UnsignedRoomRequest, VtaError> {
let v0_10 = super::unsigned_room_request(type_uri, host, issuer, payload)?;
serde_json::from_value(serde_json::to_value(v0_10)?)
.map_err(|e| VtaError::Validation(format!("not a room request this can complete: {e}")))
}
fn name_member(payload: &mut Value, member: Option<&str>) {
if let Some(m) = member {
payload["ext"] = json!({ "org.openvtc.rooms": { "member": m } });
}
}
fn parse<T: serde::de::DeserializeOwned>(value: Value, task: &str) -> Result<T, VtaError> {
serde_json::from_value(value)
.map_err(|e| VtaError::Protocol(format!("{task} response does not match its schema: {e}")))
}
#[cfg(test)]
mod tests {
use super::*;
fn listed(m: &[&str]) -> Vec<String> {
m.iter().map(|s| s.to_string()).collect()
}
#[test]
fn the_gate_refuses_a_member_the_profile_does_not_list() {
let body = json!({ "body": "hi", "kind": "message", "inReplyTo": { "key": "k" } });
let err = check_record_members("did:example:room", &body, Some(&listed(&["kind"])), None)
.unwrap_err();
match err {
VtaError::RecordMembersNotPermitted {
refused, listed, ..
} => {
assert_eq!(refused, vec!["inReplyTo".to_string()]);
assert_eq!(listed, vec!["kind".to_string()]);
}
other => panic!("{other:?}"),
}
}
#[test]
fn the_gate_allows_a_listed_member_and_a_body_using_none() {
let body = json!({ "body": "hi", "kind": "message", "writtenAt": "2026-01-01T00:00:00Z" });
check_record_members(
"did:example:room",
&body,
Some(&listed(&["kind", "writtenAt"])),
None,
)
.unwrap();
check_record_members("did:example:room", &json!({ "body": "plain" }), None, None).unwrap();
}
#[test]
fn no_profile_permits_none_and_a_file_member_is_governed() {
let body = json!({ "body": "x", "kind": "note" });
assert!(check_record_members("did:example:room", &body, None, None).is_err());
let image = json!({ "file": { "fileId": "f", "width": 640 } });
let err = check_record_members(
"did:example:room",
&image,
Some(&listed(&["kind"])),
Some("unreachable".into()),
)
.unwrap_err();
assert!(err.to_string().contains("width"), "{err}");
}
}