use std::sync::Arc;
use std::time::Duration;
use axum::extract::{Path, State};
use axum::http::{HeaderValue, StatusCode, header};
use axum::response::IntoResponse;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use sha2::{Digest, Sha256};
use vti_common::error::AppError;
use vti_common::store::KeyspaceHandle;
use super::vta_storage::{PeerError, VtaChannel};
use crate::server::AppState;
pub mod codes {
use trust_tasks_rs::specs::vtc::rooms::community::{
create::v0_1 as create, get::v0_1 as get, members::add::v0_1 as add,
members::list::v0_1 as members_list, members::remove::v0_1 as remove,
update::v0_1 as update,
};
use trust_tasks_rs::specs::vtc::rooms::memberships::list::v0_1 as memberships_list;
use trust_tasks_rs::specs::vtc::rooms::offers::{accept::v0_1 as accept, list::v0_1 as list};
pub const UPDATE_NOT_FOUND: &str = update::error_codes::NOT_FOUND.code;
pub const CREATE_VISIBILITY_NOT_PERMITTED: &str =
create::error_codes::VISIBILITY_NOT_PERMITTED.code;
pub const CREATE_UNKNOWN_STORAGE_CONFIG: &str =
create::error_codes::UNKNOWN_STORAGE_CONFIG.code;
pub const CREATE_NO_KEY_HOLDER: &str = create::error_codes::NO_KEY_HOLDER.code;
pub const CREATE_KEY_HOLDER_UNAVAILABLE: &str =
create::error_codes::KEY_HOLDER_UNAVAILABLE.code;
pub const CREATE_KEY_HOLDER_REFUSED: &str = create::error_codes::KEY_HOLDER_REFUSED.code;
pub const GET_NOT_FOUND: &str = get::error_codes::NOT_FOUND.code;
pub const ADD_NOT_FOUND: &str = add::error_codes::NOT_FOUND.code;
pub const ADD_NOT_A_MEMBER: &str = add::error_codes::NOT_A_MEMBER.code;
pub const ADD_ALREADY_MEMBER: &str = add::error_codes::ALREADY_MEMBER.code;
pub const ADD_KEY_HOLDER_UNAVAILABLE: &str = add::error_codes::KEY_HOLDER_UNAVAILABLE.code;
pub const ADD_KEY_HOLDER_REFUSED: &str = add::error_codes::KEY_HOLDER_REFUSED.code;
pub const REMOVE_NOT_FOUND: &str = remove::error_codes::NOT_FOUND.code;
pub const REMOVE_NOT_IN_ROOM: &str = remove::error_codes::NOT_IN_ROOM.code;
pub const REMOVE_KEY_HOLDER_UNAVAILABLE: &str =
remove::error_codes::KEY_HOLDER_UNAVAILABLE.code;
pub const REMOVE_KEY_HOLDER_REFUSED: &str = remove::error_codes::KEY_HOLDER_REFUSED.code;
pub const MEMBERS_LIST_NOT_FOUND: &str = members_list::error_codes::NOT_FOUND.code;
pub const OFFERS_LIST_NOT_A_MEMBER: &str = list::error_codes::NOT_A_MEMBER.code;
pub const MEMBERSHIPS_LIST_NOT_A_MEMBER: &str =
memberships_list::error_codes::NOT_A_MEMBER.code;
pub const ACCEPT_NOT_FOUND: &str = accept::error_codes::NOT_FOUND.code;
pub const ACCEPT_NOT_A_MEMBER: &str = accept::error_codes::NOT_A_MEMBER.code;
pub const ACCEPT_OFFER_EXPIRED: &str = accept::error_codes::OFFER_EXPIRED.code;
pub const ACCEPT_KEY_PACKAGE_REQUIRED: &str = accept::error_codes::KEY_PACKAGE_REQUIRED.code;
pub const ACCEPT_KEY_PACKAGE_NOT_EXPECTED: &str =
accept::error_codes::KEY_PACKAGE_NOT_EXPECTED.code;
pub const ACCEPT_KEY_PACKAGE_INVALID: &str = accept::error_codes::KEY_PACKAGE_INVALID.code;
pub const ACCEPT_ALREADY_ACCEPTED: &str = accept::error_codes::ALREADY_ACCEPTED.code;
pub const ACCEPT_ADMISSION_PENDING: &str = accept::error_codes::ADMISSION_PENDING.code;
pub const ACCEPT_KEY_HOLDER_REFUSED: &str = accept::error_codes::KEY_HOLDER_REFUSED.code;
}
pub const SIGN_TIMEOUT: Duration = Duration::from_secs(30);
pub const ADVANCE_TIMEOUT: Duration = Duration::from_secs(60);
pub const WELCOME_KEPT: chrono::Duration = chrono::Duration::days(7);
pub const ROOM_DID_PATH: &str = "room-dids";
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct OwnedRoom {
pub room_id: String,
pub label: String,
pub visibility: String,
pub signing_key_id: String,
pub created_by: String,
pub created_at: String,
pub owner_authority_valid_until: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub did_path: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tags: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub details_updated_at: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub details_updated_by: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct OfferCredentials {
pub invitation: String,
pub membership: String,
pub authority: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct KeptWelcome {
pub key_package_digest: String,
pub welcome: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ratchet_tree: Option<String>,
pub epoch: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct MemberRecord {
pub room_id: String,
pub member: String,
pub actions: Vec<String>,
pub valid_until: String,
pub state: String,
pub offered_at: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub admitted_at: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub removed_at: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub credentials: Option<OfferCredentials>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub welcome: Option<KeptWelcome>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub admitted_epoch: Option<u64>,
}
impl MemberRecord {
pub const OFFERED: &'static str = "offered";
pub const ADMITTED: &'static str = "admitted";
pub const REMOVED: &'static str = "removed";
pub fn wire(&self) -> Value {
let mut v = serde_json::json!({
"member": self.member,
"actions": self.actions,
"validUntil": self.valid_until,
"state": self.state,
"offeredAt": self.offered_at,
});
if let Some(at) = &self.admitted_at {
v["admittedAt"] = Value::from(at.clone());
}
if let Some(at) = &self.removed_at {
v["removedAt"] = Value::from(at.clone());
}
v
}
pub fn welcome_kept(&self, now: chrono::DateTime<chrono::Utc>) -> bool {
self.state == Self::ADMITTED
&& self
.admitted_at
.as_deref()
.and_then(|a| chrono::DateTime::parse_from_rfc3339(a).ok())
.is_some_and(|a| now < a.with_timezone(&chrono::Utc) + WELCOME_KEPT)
}
}
pub fn key_package_digest(key_package: &str) -> String {
hex::encode(Sha256::digest(key_package.as_bytes()))
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub enum DepartureCause {
Left,
Removed,
AclRemoved,
Expired,
Suspended,
}
impl DepartureCause {
pub fn reason(self) -> &'static str {
match self {
Self::Left => "Left the community.",
Self::Removed => "Removed from the community.",
Self::AclRemoved => "Their access-control entry at the community was removed.",
Self::Expired => "Their access-control entry at the community expired.",
Self::Suspended => "Their access-control entry at the community was suspended.",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct PendingRemoval {
pub room_id: String,
pub member: String,
pub cause: DepartureCause,
pub departed_at: String,
#[serde(default)]
pub attempts: u32,
pub next_attempt_at: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_attempt_at: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_error: Option<String>,
}
impl PendingRemoval {
pub fn wire(&self) -> Value {
let mut v = serde_json::json!({
"roomId": self.room_id,
"member": self.member,
"cause": self.cause,
"departedAt": self.departed_at,
"status": if self.last_error.is_some() { "retrying" } else { "pending" },
"attempts": self.attempts,
"nextAttemptAt": self.next_attempt_at,
});
if let Some(at) = &self.last_attempt_at {
v["lastAttemptAt"] = Value::from(at.clone());
}
if let Some(e) = &self.last_error {
v["lastError"] = Value::from(e.clone());
}
v
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct DidLog {
did: String,
log: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum HolderError {
Unavailable(String),
Refused { code: String, message: String },
}
impl HolderError {
pub fn refused_details(code: &str, message: &str) -> Value {
serde_json::json!({
"code": truncate(code, 128),
"message": truncate(message, 500),
})
}
}
fn truncate(s: &str, max: usize) -> String {
match s.char_indices().nth(max) {
Some((i, _)) => s[..i].to_string(),
None => s.to_string(),
}
}
#[derive(Clone)]
pub struct KeyHolder(Arc<dyn VtaChannel>);
impl KeyHolder {
pub async fn ask(
&self,
type_uri: &str,
payload: Value,
timeout: Duration,
) -> Result<Value, HolderError> {
let reply = self
.0
.exchange(type_uri, &payload, timeout)
.await
.map_err(|e| match e {
PeerError::Transient(m) => HolderError::Unavailable(format!(
"the community's VTA could not be reached: {m}"
)),
PeerError::NoMatchingProtocol(m) => HolderError::Unavailable(format!(
"the community's VTA shares no transport with this community: {m}"
)),
})?;
if reply.type_uri.slug() == "trust-task-error" {
return Err(HolderError::Refused {
code: reply.payload["code"]
.as_str()
.unwrap_or("unknown")
.to_string(),
message: reply.payload["message"].as_str().unwrap_or("").to_string(),
});
}
if reply.type_uri.to_string() != format!("{type_uri}#response") {
return Err(HolderError::Unavailable(format!(
"the community's VTA answered `{}` to `{type_uri}`",
reply.type_uri
)));
}
Ok(reply.payload)
}
}
pub struct CommunityRooms {
ks: KeyspaceHandle,
holder: std::sync::RwLock<Option<KeyHolder>>,
write_lock: tokio::sync::Mutex<()>,
pub(crate) seen_inactive:
std::sync::Mutex<std::collections::HashMap<String, chrono::DateTime<chrono::Utc>>>,
record_lock: tokio::sync::Mutex<()>,
}
static DEPARTURES: tokio::sync::Notify = tokio::sync::Notify::const_new();
pub fn departure_hint() {
DEPARTURES.notify_one();
}
pub async fn departure_hinted() {
DEPARTURES.notified().await
}
fn room_key(room_id: &str) -> Vec<u8> {
format!("room:{room_id}").into_bytes()
}
fn member_prefix(room_id: &str) -> Vec<u8> {
format!("member:{room_id}\u{0}").into_bytes()
}
fn member_key(room_id: &str, member: &str) -> Vec<u8> {
let mut k = member_prefix(room_id);
k.extend_from_slice(member.as_bytes());
k
}
fn holder_prefix(member: &str) -> Vec<u8> {
format!("holder:{member}\u{0}").into_bytes()
}
fn holder_key(member: &str, room_id: &str) -> Vec<u8> {
let mut k = holder_prefix(member);
k.extend_from_slice(room_id.as_bytes());
k
}
const DEPARTURE_PREFIX: &str = "departure:";
fn departure_room_prefix(room_id: &str) -> Vec<u8> {
format!("{DEPARTURE_PREFIX}{room_id}\u{0}").into_bytes()
}
fn departure_key(room_id: &str, member: &str) -> Vec<u8> {
let mut k = departure_room_prefix(room_id);
k.extend_from_slice(member.as_bytes());
k
}
fn did_log_key(id: &str) -> Vec<u8> {
format!("didlog:{id}").into_bytes()
}
impl CommunityRooms {
pub fn new(ks: KeyspaceHandle, holder: Option<Arc<dyn VtaChannel>>) -> Arc<Self> {
Arc::new(Self {
ks,
holder: std::sync::RwLock::new(holder.map(KeyHolder)),
write_lock: tokio::sync::Mutex::new(()),
seen_inactive: Default::default(),
record_lock: tokio::sync::Mutex::new(()),
})
}
pub fn keyspace(&self) -> &KeyspaceHandle {
&self.ks
}
pub fn set_key_holder(&self, vta: Arc<dyn VtaChannel>) {
*self.holder.write().expect("holder lock") = Some(KeyHolder(vta));
}
pub fn key_holder(&self) -> Option<KeyHolder> {
self.holder.read().expect("holder lock").clone()
}
pub async fn lock(&self) -> tokio::sync::MutexGuard<'_, ()> {
self.write_lock.lock().await
}
pub async fn record_lock(&self) -> tokio::sync::MutexGuard<'_, ()> {
self.record_lock.lock().await
}
pub async fn room(&self, room_id: &str) -> Result<Option<OwnedRoom>, AppError> {
self.ks.get(room_key(room_id)).await
}
pub async fn put_room(&self, room: &OwnedRoom) -> Result<(), AppError> {
self.ks.insert(room_key(&room.room_id), room).await?;
crate::admin_events::notify(crate::admin_events::Topic::Rooms);
Ok(())
}
pub async fn put_room_details(&self, room: &OwnedRoom) -> Result<(), AppError> {
self.ks.insert(room_key(&room.room_id), room).await
}
pub async fn rooms(&self) -> Result<Vec<OwnedRoom>, AppError> {
let mut out = Vec::new();
for (_, raw) in self.ks.prefix_iter_raw(b"room:".to_vec()).await? {
out.push(serde_json::from_slice::<OwnedRoom>(&raw)?);
}
out.sort_by(|a, b| a.room_id.cmp(&b.room_id));
Ok(out)
}
pub async fn member(
&self,
room_id: &str,
member: &str,
) -> Result<Option<MemberRecord>, AppError> {
self.ks.get(member_key(room_id, member)).await
}
pub async fn put_member(&self, record: &MemberRecord) -> Result<(), AppError> {
self.ks
.insert(member_key(&record.room_id, &record.member), record)
.await?;
self.ks
.insert(holder_key(&record.member, &record.room_id), &true)
.await?;
crate::admin_events::notify(crate::admin_events::Topic::Rooms);
Ok(())
}
pub async fn members(&self, room_id: &str) -> Result<Vec<MemberRecord>, AppError> {
let mut out = Vec::new();
for (_, raw) in self.ks.prefix_iter_raw(member_prefix(room_id)).await? {
out.push(serde_json::from_slice::<MemberRecord>(&raw)?);
}
out.sort_by(|a, b| a.member.cmp(&b.member));
Ok(out)
}
pub async fn records_of(&self, member: &str) -> Result<Vec<MemberRecord>, AppError> {
let prefix = holder_prefix(member);
let mut out = Vec::new();
for key in self.ks.prefix_keys(prefix.clone()).await? {
let Ok(room_id) = std::str::from_utf8(&key[prefix.len()..]) else {
continue;
};
if let Some(r) = self.member(room_id, member).await? {
out.push(r);
}
}
out.sort_by(|a, b| a.room_id.cmp(&b.room_id));
Ok(out)
}
pub async fn counts(&self, room_id: &str) -> Result<(u64, u64), AppError> {
let members = self.members(room_id).await?;
let count = |s: &str| members.iter().filter(|m| m.state == s).count() as u64;
Ok((count(MemberRecord::ADMITTED), count(MemberRecord::OFFERED)))
}
pub async fn record_departure(
&self,
member: &str,
cause: DepartureCause,
now: chrono::DateTime<chrono::Utc>,
) -> Result<usize, AppError> {
let at = now.to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
let mut recorded = 0;
for r in self.records_of(member).await? {
if r.state == MemberRecord::REMOVED
|| self.pending_removal(&r.room_id, member).await?.is_some()
{
continue;
}
let pending = PendingRemoval {
room_id: r.room_id.clone(),
member: member.to_string(),
cause,
departed_at: at.clone(),
attempts: 0,
next_attempt_at: at.clone(),
last_attempt_at: None,
last_error: None,
};
self.ks
.insert(departure_key(&r.room_id, member), &pending)
.await?;
recorded += 1;
}
if recorded > 0 {
tracing::info!(
member,
?cause,
rooms = recorded,
"a departing member's community-room admissions are queued for removal"
);
crate::admin_events::notify(crate::admin_events::Topic::Rooms);
departure_hint();
}
Ok(recorded)
}
pub async fn pending_removal(
&self,
room_id: &str,
member: &str,
) -> Result<Option<PendingRemoval>, AppError> {
self.ks.get(departure_key(room_id, member)).await
}
pub async fn put_pending_removal(&self, p: &PendingRemoval) -> Result<(), AppError> {
self.ks
.insert(departure_key(&p.room_id, &p.member), p)
.await
}
pub async fn drop_pending_removal(&self, room_id: &str, member: &str) -> Result<(), AppError> {
self.ks.remove(departure_key(room_id, member)).await
}
pub async fn pending_removals(&self) -> Result<Vec<PendingRemoval>, AppError> {
self.pending_under(DEPARTURE_PREFIX.as_bytes().to_vec())
.await
}
pub async fn pending_removals_in(
&self,
room_id: &str,
) -> Result<Vec<PendingRemoval>, AppError> {
self.pending_under(departure_room_prefix(room_id)).await
}
async fn pending_under(&self, prefix: Vec<u8>) -> Result<Vec<PendingRemoval>, AppError> {
let mut out = Vec::new();
for (_, raw) in self.ks.prefix_iter_raw(prefix).await? {
match serde_json::from_slice::<PendingRemoval>(&raw) {
Ok(p) => out.push(p),
Err(e) => tracing::warn!(error = %e, "skipping an unreadable pending removal"),
}
}
out.sort_by(|a, b| (&a.room_id, &a.member).cmp(&(&b.room_id, &b.member)));
Ok(out)
}
pub async fn settle_removal(&self, record: &MemberRecord) -> Result<(), AppError> {
let mut batch = vti_common::store::Batch::new();
batch.insert(member_key(&record.room_id, &record.member), record)?;
batch.insert(holder_key(&record.member, &record.room_id), &true)?;
batch.remove(departure_key(&record.room_id, &record.member));
self.ks.apply_batch(batch).await?;
crate::admin_events::notify(crate::admin_events::Topic::Rooms);
Ok(())
}
pub async fn put_did_log(&self, id: &str, did: &str, log: &str) -> Result<(), AppError> {
self.ks
.insert(
did_log_key(id),
&DidLog {
did: did.to_string(),
log: log.to_string(),
},
)
.await
}
pub async fn remove_did_log(&self, id: &str) -> Result<(), AppError> {
self.ks.remove(did_log_key(id)).await
}
async fn did_log(&self, id: &str) -> Result<Option<String>, AppError> {
Ok(self.ks.get::<DidLog>(did_log_key(id)).await?.map(|l| l.log))
}
}
fn plain_id(id: &str) -> bool {
(1..=64).contains(&id.len()) && id.bytes().all(|b| b.is_ascii_hexdigit())
}
pub async fn serve_room_did_log(
State(state): State<AppState>,
Path(id): Path<String>,
) -> impl IntoResponse {
if !plain_id(&id) {
return (StatusCode::NOT_FOUND, "did log not found").into_response();
}
match state.community_rooms.did_log(&id).await {
Ok(Some(log)) => (
StatusCode::OK,
[(
header::CONTENT_TYPE,
HeaderValue::from_static("text/jsonl; charset=utf-8"),
)],
log,
)
.into_response(),
Ok(None) => (StatusCode::NOT_FOUND, "did log not found").into_response(),
Err(e) => {
tracing::warn!(error = %e, "could not read a room identity's log");
(StatusCode::INTERNAL_SERVER_ERROR, "did log unavailable").into_response()
}
}
}
pub fn is_room_did_log_path(path: &str) -> bool {
path.strip_prefix(&format!("/{ROOM_DID_PATH}/"))
.and_then(|rest| rest.strip_suffix("/did.jsonl"))
.is_some_and(plain_id)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn only_issued_ids_are_served() {
assert!(is_room_did_log_path("/room-dids/0a1b2c/did.jsonl"));
assert!(!is_room_did_log_path("/room-dids/../did.jsonl"));
assert!(!is_room_did_log_path("/room-dids/x/y/did.jsonl"));
assert!(!is_room_did_log_path("/room-dids//did.jsonl"));
assert!(!is_room_did_log_path("/room-dids/0a1b/other"));
}
#[test]
fn a_welcome_is_kept_for_a_week_after_admission() {
let now = chrono::Utc::now();
let mut r = MemberRecord {
room_id: "did:example:room".into(),
member: "did:example:m".into(),
actions: vec!["read".into()],
valid_until: now.to_rfc3339(),
state: MemberRecord::ADMITTED.into(),
offered_at: now.to_rfc3339(),
admitted_at: Some((now - chrono::Duration::days(6)).to_rfc3339()),
removed_at: None,
credentials: None,
welcome: None,
admitted_epoch: Some(2),
};
assert!(r.welcome_kept(now));
r.admitted_at = Some((now - chrono::Duration::days(8)).to_rfc3339());
assert!(!r.welcome_kept(now));
r.state = MemberRecord::OFFERED.into();
assert!(!r.welcome_kept(now));
}
}