use serde_json::Value;
use trust_tasks_rs::TrustTask;
use vti_common::acl::Capability;
use crate::auth::AuthClaims;
use crate::operations::room_host::RoomTaskError;
use crate::operations::room_issuance::{SigningContext, sign_as_room};
use crate::server::AppState;
use super::TrustTaskOutcome;
use super::helpers::{app_error_to_reject, parse_payload, success_response};
use super::room_group::record;
fn signing_context<'a>(state: &'a AppState, auth: &'a AuthClaims) -> SigningContext<'a> {
SigningContext {
keys_ks: &state.keys_ks,
imported_ks: &state.imported_ks,
internal_ks: &state.internal_ks,
contexts_ks: &state.contexts_ks,
acl_ks: &state.acl_ks,
seed_store: &state.seed_store,
audit: &state.audit_sink,
auth,
}
}
fn with_fresh_id(c: dtg_credentials::DTGCredential) -> dtg_credentials::DTGCredential {
c.with_id(format!("urn:uuid:{}", uuid::Uuid::new_v4()))
}
async fn issue(
state: &AppState,
auth: &AuthClaims,
doc: &TrustTask<Value>,
room_id: &str,
signing_key_id: &str,
audit_verb: &str,
credential: dtg_credentials::DTGCredential,
) -> TrustTaskOutcome {
let mut credential = with_fresh_id(credential);
match sign_as_room(
signing_context(state, auth),
signing_key_id,
room_id,
&mut credential,
)
.await
{
Ok((serialised, id)) => {
record(state, audit_verb, auth, room_id).await;
success_response(
doc,
serde_json::json!({ "credential": serialised, "credentialId": id }),
)
}
Err(e) => app_error_to_reject(doc, e),
}
}
pub(super) async fn handle_invite(
state: &AppState,
auth: &AuthClaims,
doc: TrustTask<Value>,
) -> TrustTaskOutcome {
if let Err(r) = super::helpers::require_capability(
state,
auth,
&doc,
Capability::CredentialWrite,
"inviting a party to a room",
)
.await
{
return r;
}
let req: trust_tasks_rs::specs::rooms::owner::invite::v0_1::Payload = match parse_payload(&doc)
{
Ok(r) => r,
Err(resp) => return resp,
};
let vic = dtg_credentials::DTGCredential::new_vic(
req.room_id.clone(),
dtg_credentials::IssuerScope::Public,
req.subject.clone(),
chrono::Utc::now(),
req.valid_until,
);
issue(
state,
auth,
&doc,
&req.room_id,
&req.signing_key_id,
"rooms.owner.invite",
vic,
)
.await
}
pub(super) async fn handle_issue_membership(
state: &AppState,
auth: &AuthClaims,
doc: TrustTask<Value>,
) -> TrustTaskOutcome {
if let Err(r) = super::helpers::require_capability(
state,
auth,
&doc,
Capability::CredentialWrite,
"admitting a member to a room",
)
.await
{
return r;
}
let req: trust_tasks_rs::specs::rooms::owner::issue_membership::v0_1::Payload =
match parse_payload(&doc) {
Ok(r) => r,
Err(resp) => return resp,
};
let vmc = dtg_credentials::DTGCredential::new_vmc(
req.room_id.clone(),
req.subject.clone(),
chrono::Utc::now(),
req.valid_until,
false,
);
issue(
state,
auth,
&doc,
&req.room_id,
&req.signing_key_id,
"rooms.owner.issue-membership",
vmc,
)
.await
}
pub(super) async fn handle_issue_authority(
state: &AppState,
auth: &AuthClaims,
doc: TrustTask<Value>,
) -> TrustTaskOutcome {
if let Err(r) = super::helpers::require_capability(
state,
auth,
&doc,
Capability::CredentialWrite,
"granting authority in a room",
)
.await
{
return r;
}
let req: trust_tasks_rs::specs::rooms::owner::issue_authority::v0_2::Payload =
match parse_payload(&doc) {
Ok(r) => r,
Err(resp) => return resp,
};
let actions: Vec<String> = req.actions.iter().map(|a| a.to_string()).collect();
let vac = match dtg_credentials::DTGCredential::new_vac(
req.room_id.clone(),
dtg_credentials::IssuerScope::Public,
req.subject.clone(),
req.room_id.clone(),
actions,
chrono::Utc::now(),
req.valid_until,
) {
Ok(v) => v,
Err(e) => {
return app_error_to_reject(
&doc,
vti_common::error::AppError::Validation(format!("build the grant: {e}")),
);
}
};
issue(
state,
auth,
&doc,
&req.room_id,
&req.signing_key_id,
"rooms.owner.issue-authority",
vac,
)
.await
}
#[cfg(feature = "webvh")]
pub(super) async fn handle_anchor(
state: &AppState,
auth: &AuthClaims,
doc: TrustTask<Value>,
) -> TrustTaskOutcome {
Box::pin(anchor_inner(state, auth, doc)).await
}
#[cfg(feature = "webvh")]
async fn anchor_inner(
state: &AppState,
auth: &AuthClaims,
doc: TrustTask<Value>,
) -> TrustTaskOutcome {
if let Err(r) = super::helpers::require_capability(
state,
auth,
&doc,
Capability::CredentialWrite,
"anchoring a room's state in its own log",
)
.await
{
return r;
}
let req: trust_tasks_rs::specs::rooms::owner::anchor::v0_1::Payload = match parse_payload(&doc)
{
Ok(r) => r,
Err(resp) => return resp,
};
let resolver = match super::room_group::outbound_resolver(state, &doc) {
Ok(v) => v,
Err(resp) => return resp,
};
let presenting =
match super::room_group::presenting_member(state, auth, &req.room_id, None).await {
Ok(v) => v,
Err(e) => return app_error_to_reject(&doc, e),
};
let (epoch, epoch_authenticator) = match crate::operations::room_groups::epoch_authenticator(
&state.room_groups_ks,
&presenting.member,
)
.await
{
Ok(a) => a,
Err(e) => return app_error_to_reject(&doc, e),
};
let reply = match super::room_group::send_as_member(
state,
auth,
&presenting,
&resolver,
&req.host,
vti_rooms::wire::ROOMS_RECORDS_LIST_TYPE,
serde_json::json!({ "roomId": req.room_id, "limit": 1 }),
)
.await
{
Ok(v) => v,
Err(e) => return app_error_to_reject(&doc, e),
};
let head: vti_rooms::wire::ListRecordsResponse = match serde_json::from_value(reply) {
Ok(h) => h,
Err(e) => {
return app_error_to_reject(
&doc,
vti_common::error::AppError::Validation(format!(
"room host `{}` served a head this agent cannot read: {e}",
req.host
)),
);
}
};
let Some(head_version) = head.head_version else {
return app_error_to_reject(
&doc,
vti_common::error::AppError::Validation(format!(
"room host `{}` served no `headVersion`, so there is no state to anchor. A \
host that maintains no tree has nothing for this to pin.",
req.host
)),
);
};
match crate::operations::room_anchor::publish(
state,
auth,
&req.room_id,
&req.signing_key_id,
crate::operations::room_anchor::Anchor {
epoch,
epoch_authenticator,
head_version,
data_commitment: head.data_commitment.clone(),
record_count: head.record_count,
},
)
.await
{
Ok(published) => {
record(state, "rooms.owner.anchor", auth, &req.room_id).await;
success_response(
&doc,
serde_json::json!({
"roomId": req.room_id,
"anchored": published.anchored,
"versionId": published.version_id,
"reconciled": head
.record_count
.is_none_or(|committed| committed == head.records.len() as u64
|| head.cursor.is_some()),
}),
)
}
Err(e) => app_error_to_reject(&doc, e),
}
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum RegisterVersion {
V0_1,
V0_2,
}
pub(super) async fn handle_register(
state: &AppState,
auth: &AuthClaims,
doc: TrustTask<Value>,
) -> TrustTaskOutcome {
register_at(state, auth, doc, RegisterVersion::V0_1).await
}
pub(super) async fn handle_register_v0_2(
state: &AppState,
auth: &AuthClaims,
doc: TrustTask<Value>,
) -> TrustTaskOutcome {
register_at(state, auth, doc, RegisterVersion::V0_2).await
}
async fn register_at(
state: &AppState,
auth: &AuthClaims,
doc: TrustTask<Value>,
at: RegisterVersion,
) -> TrustTaskOutcome {
use trust_tasks_rs::specs::rooms::owner::register::v0_2::error_codes as codes;
if let Err(r) = super::helpers::require_capability(
state,
auth,
&doc,
Capability::CredentialWrite,
"registering a room with a host",
)
.await
{
return r;
}
let req: trust_tasks_rs::specs::rooms::owner::register::v0_2::Payload =
match parse_payload(&doc) {
Ok(r) => r,
Err(resp) => return resp,
};
let vta_did = state.config.read().await.vta_did.clone();
let Some(resolver) = state.did_resolver.clone() else {
return app_error_to_reject(
&doc,
vti_common::error::AppError::Validation(
"this agent has no DID resolver configured, so it cannot find the host".into(),
),
);
};
let owner_did = req.owner_did.clone().unwrap_or_else(|| auth.did.clone());
let mut payload = serde_json::json!({
"roomId": req.room_id,
"visibility": req.visibility,
"ownerDid": owner_did,
});
if let Some(policy) = &req.retention_policy {
payload["retentionPolicy"] = serde_json::to_value(policy).unwrap_or(Value::Null);
}
if let Some(days) = req.retention_days {
payload["retentionDays"] = serde_json::json!(u64::from(days));
}
let owner_key = match crate::operations::room_host::owner_signing_key(
crate::operations::room_host::OwnerKeyStores {
keys_ks: &state.keys_ks,
#[cfg(feature = "webvh")]
webvh_ks: &state.webvh_ks,
},
auth,
&owner_did,
vta_did.as_deref(),
)
.await
{
Ok(k) => k,
Err(e) => return app_error_to_reject(&doc, e),
};
let member = match crate::operations::room_groups::Member::new(&req.room_id, &owner_did) {
Ok(m) => m,
Err(e) => return app_error_to_reject(&doc, e),
};
let reply = match crate::operations::room_host::send_room_task_typed(
signing_context(state, auth),
&state.room_groups_ks,
&member,
&crate::operations::outbound::Outbound::from_app_state(state, &resolver),
&req.host,
&owner_key.key_id,
&owner_did,
&owner_key.verification_method,
vti_rooms::wire::ROOMS_CREATE_TYPE,
&format!("{}#response", vti_rooms::wire::ROOMS_CREATE_TYPE),
payload,
)
.await
{
Ok(v) => v,
Err(RoomTaskError::Refused { code, reason, .. }) => {
return super::helpers::reject_with_code(
&doc,
super::room_group::declared(codes::HOST_REFUSED),
format!(
"host `{}` declined to register room `{}`: {code}: {reason}",
req.host, req.room_id
),
Some(serde_json::json!({ "code": code, "reason": reason })),
);
}
Err(RoomTaskError::Unknown(e)) => {
return super::helpers::reject_with_code(
&doc,
super::room_group::declared(codes::HOST_UNREACHABLE),
format!(
"host `{}` did not answer the registration of room `{}`: {e}",
req.host, req.room_id
),
None,
);
}
};
if let Some(tier) = serde_json::to_value(req.visibility)
.ok()
.and_then(|v| v.as_str().map(str::to_string))
&& let Err(e) = crate::operations::room_revocation::record_tier(
&state.room_groups_ks,
&req.room_id,
&tier,
)
.await
{
tracing::warn!(room = %req.room_id, error = %e, "could not record the room's tier");
}
let epoch = reply.get("epoch").and_then(Value::as_u64).unwrap_or(1);
let mut body = serde_json::json!({
"roomId": req.room_id,
"host": req.host,
"epoch": epoch,
});
match at {
RegisterVersion::V0_1 => {
if let Err(r) =
deliver_held_list(state, auth, &doc, &resolver, &req.room_id, &req.host).await
{
return r;
}
}
RegisterVersion::V0_2 => {
let delivered = deliver_after_registration(
state,
auth,
&resolver,
&req.room_id,
&req.host,
&owner_did,
)
.await;
match delivered {
Ok(Some(revocation)) => body["revocation"] = revocation,
Ok(None) => {}
Err(e) => {
tracing::error!(room = %req.room_id, host = %req.host, error = %e,
"could not read the room's revocation list after registering it");
return app_error_to_reject(&doc, e);
}
}
}
}
success_response(&doc, body)
}
async fn deliver_after_registration(
state: &AppState,
auth: &AuthClaims,
resolver: &affinidi_did_resolver_cache_sdk::DIDCacheClient,
room_id: &str,
host: &str,
owner_did: &str,
) -> Result<Option<Value>, vti_common::error::AppError> {
use super::room_owner_group::{Delivery, HostCall};
use crate::operations::room_revocation as revocation;
let Some(list) = revocation::held(&state.room_groups_ks, room_id).await? else {
return Ok(None);
};
let head = list.head().to_wire();
let pending = || serde_json::json!({ "head": head, "status": "pending" });
let host_call = HostCall::for_persona(state, auth, resolver, room_id, None).await;
let persona = match &host_call {
Ok(h) => h.persona().to_string(),
Err(_) => owner_did.to_string(),
};
if let Err(e) = revocation::retain_delivery(
&state.room_groups_ks,
&revocation::OutstandingDelivery {
room_id: room_id.to_string(),
host: host.to_string(),
sequence: list.sequence(),
persona,
},
)
.await
{
tracing::error!(room = %room_id, %host, error = %e,
"could not retain the room's revocation list as outstanding; if it is not \
delivered now, deliver it with rooms/owner/revoke");
}
let host_call = match host_call {
Ok(h) => h,
Err(e) => {
tracing::warn!(room = %room_id, %host, error = %e,
"the room is registered, but this agent holds no `admin` grant to deliver its \
revocation list with; it is pending");
return Ok(Some(pending()));
}
};
let presentation = match host_call.presentation_as_admin().await {
Ok(p) => p,
Err(e) => {
tracing::warn!(room = %room_id, %host, error = %e,
"the room is registered, but its revocation list could not be presented for; \
it is pending");
return Ok(Some(pending()));
}
};
Ok(Some(
match host_call.deliver_list(host, &list, &presentation).await {
Delivery::Accepted { .. } => serde_json::json!({ "head": head, "status": "inForce" }),
Delivery::Refused { code, reason } => {
tracing::warn!(room = %room_id, %host, %code, %reason,
"the room is registered, and its host refused the room's revocation list");
revocation::drop_delivery(&state.room_groups_ks, room_id, host).await?;
serde_json::json!({ "head": head, "status": "refused", "code": code })
}
Delivery::Unreachable(why) => {
tracing::warn!(room = %room_id, %host, reason = %why,
"the room is registered, and its revocation list is pending at the host");
pending()
}
},
))
}
async fn deliver_held_list(
state: &AppState,
auth: &AuthClaims,
doc: &TrustTask<Value>,
resolver: &affinidi_did_resolver_cache_sdk::DIDCacheClient,
room_id: &str,
host: &str,
) -> Result<(), TrustTaskOutcome> {
use super::room_owner_group::{Delivery, HostCall};
use trust_tasks_rs::specs::rooms::owner::register::v0_1::error_codes as codes;
let Some(list) = crate::operations::room_revocation::held(&state.room_groups_ks, room_id)
.await
.map_err(|e| app_error_to_reject(doc, e))?
else {
return Ok(());
};
let remedy = format!(
"Room `{room_id}` is registered at `{host}`, but the room's revocation list (sequence \
{}) is not in force there yet: deliver it with rooms/owner/revoke naming that host and \
no `withdraw`",
list.sequence()
);
let host_call =
HostCall::as_owner(state, auth, resolver, room_id, doc, codes::HOST_REFUSED).await?;
let presentation = host_call
.admin_presentation(doc, codes::HOST_REFUSED)
.await?;
match host_call.deliver_list(host, &list, &presentation).await {
Delivery::Accepted { .. } => Ok(()),
Delivery::Refused { code, reason } => Err(super::helpers::reject_with_code(
doc,
super::room_group::declared(codes::HOST_REFUSED),
format!(
"host `{host}` declined the room's revocation list: {code}: {reason}. {remedy}"
),
Some(serde_json::json!({ "code": code })),
)),
Delivery::Unreachable(why) => Err(super::helpers::reject_with_code(
doc,
super::room_group::declared(codes::HOST_UNREACHABLE),
format!("host `{host}` did not answer the room's revocation list: {why}. {remedy}"),
None,
)),
}
}